Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
862e89e683 | ||
|
|
03384e4b80 | ||
|
|
bfbeea0f20 | ||
|
|
2c85b1b409 | ||
|
|
8b7b7e154f | ||
|
|
bb9bddebb4 | ||
|
|
6e570561b6 | ||
|
|
fb6363ae58 | ||
|
|
1b77e086d4 | ||
|
|
d371a4c8c3 | ||
|
|
03e8b7c4ba | ||
|
|
8aadcaa1bd | ||
|
|
96800c27ec | ||
|
|
13d1712850 | ||
|
|
c5546f0d5b | ||
|
|
3de5ea818d | ||
|
|
dc07f1e021 | ||
|
|
310a248a44 | ||
|
|
88e7684258 | ||
|
|
716f901504 | ||
|
|
e817c0cee5 | ||
|
|
e634791585 | ||
|
|
78071f8ec4 | ||
|
|
c48751a99c | ||
|
|
86e0916fa3 | ||
|
|
dc89bc4004 | ||
|
|
0c7e649676 | ||
|
|
45ce07a098 | ||
|
|
aed8dc1ade | ||
|
|
86a341be62 | ||
|
|
ff78e9a564 | ||
|
|
60a60779d7 | ||
|
|
32da00cb2f | ||
|
|
0420f2804c | ||
|
|
4de660bec6 | ||
|
|
58f638463f | ||
|
|
f1afc722da | ||
|
|
bc75d58e9e | ||
|
|
2efd265697 | ||
|
|
9c1f1476bc | ||
|
|
e8352c8d21 |
No files matched your search
@@ -29,5 +29,6 @@ jobs:
|
|||||||
CLAWDBOT_INSTALL_CLI_URL: https://clawd.bot/install-cli.sh
|
CLAWDBOT_INSTALL_CLI_URL: https://clawd.bot/install-cli.sh
|
||||||
CLAWDBOT_NO_ONBOARD: "1"
|
CLAWDBOT_NO_ONBOARD: "1"
|
||||||
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
|
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
|
||||||
|
CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT: ${{ github.event_name == 'pull_request' && '1' || '0' }}
|
||||||
CLAWDBOT_INSTALL_SMOKE_PREVIOUS: "2026.1.11-4"
|
CLAWDBOT_INSTALL_SMOKE_PREVIOUS: "2026.1.11-4"
|
||||||
run: pnpm test:install:smoke
|
run: pnpm test:install:smoke
|
||||||
@@ -22,6 +22,15 @@
|
|||||||
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
|
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
|
||||||
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
|
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
|
||||||
|
|
||||||
|
## exe.dev VM ops (general)
|
||||||
|
- Access: SSH to the VM directly: `ssh vm-name.exe.xyz` (or use exe.dev web terminal).
|
||||||
|
- Updates: `sudo npm i -g clawdbot@latest` (global install needs root on `/usr/lib/node_modules`).
|
||||||
|
- Config: use `clawdbot config set ...`; set `gateway.mode=local` if unset.
|
||||||
|
- Restart: exe.dev often lacks systemd user bus; stop old gateway and run:
|
||||||
|
`pkill -9 -f clawdbot-gateway || true; nohup clawdbot gateway run --bind loopback --port 18789 --force > /tmp/clawdbot-gateway.log 2>&1 &`
|
||||||
|
- Verify: `clawdbot --version`, `clawdbot health`, `ss -ltnp | rg 18789`.
|
||||||
|
- SSH flaky: use exe.dev web terminal or Shelley (web agent) instead of CLI SSH.
|
||||||
|
|
||||||
## Build, Test, and Development Commands
|
## Build, Test, and Development Commands
|
||||||
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
|
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
|
||||||
- Install deps: `pnpm install`
|
- Install deps: `pnpm install`
|
||||||
@@ -51,6 +60,7 @@
|
|||||||
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
||||||
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
|
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
|
||||||
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
|
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
|
||||||
|
- Do not set test workers above 16; tried already.
|
||||||
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (Clawdbot-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
|
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (Clawdbot-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
|
||||||
- Full kit + what’s covered: `docs/testing.md`.
|
- Full kit + what’s covered: `docs/testing.md`.
|
||||||
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
|
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
|
||||||
|
|||||||
+26
-16
@@ -2,28 +2,24 @@
|
|||||||
|
|
||||||
Docs: https://docs.clawd.bot
|
Docs: https://docs.clawd.bot
|
||||||
|
|
||||||
|
## 2026.1.23
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
|
||||||
|
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.
|
||||||
|
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
|
||||||
|
|
||||||
## 2026.1.22
|
## 2026.1.22
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
|
|
||||||
- Highlight: Compaction safeguard now uses adaptive chunking, progressive fallback, and UI status + retries. (#1466) Thanks @dlauer.
|
- Highlight: Compaction safeguard now uses adaptive chunking, progressive fallback, and UI status + retries. (#1466) Thanks @dlauer.
|
||||||
- Lobster: allow workflow file args via `argsJson` in the plugin tool. https://docs.clawd.bot/tools/lobster
|
- Providers: add Antigravity usage tracking to status output. (#1490) Thanks @patelhiren.
|
||||||
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
|
|
||||||
- Slack: add chat-type reply threading overrides via `replyToModeByChatType`. (#1442) Thanks @stefangalescu.
|
- Slack: add chat-type reply threading overrides via `replyToModeByChatType`. (#1442) Thanks @stefangalescu.
|
||||||
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
|
|
||||||
- BlueBubbles: add `asVoice` support for MP3/CAF voice memos in sendAttachment. (#1477, #1482) Thanks @Nicell.
|
- BlueBubbles: add `asVoice` support for MP3/CAF voice memos in sendAttachment. (#1477, #1482) Thanks @Nicell.
|
||||||
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
|
||||||
- Docs: add /model allowlist troubleshooting note. (#1405)
|
|
||||||
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
|
|
||||||
- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
|
|
||||||
- Onboarding: add hatch choice (TUI/Web/Later), token explainer, background dashboard seed on macOS, and showcase link.
|
- Onboarding: add hatch choice (TUI/Web/Later), token explainer, background dashboard seed on macOS, and showcase link.
|
||||||
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
|
||||||
- Signal: add typing indicators and DM read receipts via signal-cli.
|
|
||||||
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
|
|
||||||
- CLI: add `clawdbot update wizard` for interactive channel selection and restart prompts. https://docs.clawd.bot/cli/update
|
|
||||||
|
|
||||||
### Breaking
|
|
||||||
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
|
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
|
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
|
||||||
@@ -32,6 +28,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.
|
- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.
|
||||||
- Agents: sanitize assistant history text to strip tool-call markers. (#1456) Thanks @zerone0x.
|
- Agents: sanitize assistant history text to strip tool-call markers. (#1456) Thanks @zerone0x.
|
||||||
- Discord: clarify Message Content Intent onboarding hint. (#1487) Thanks @kyleok.
|
- Discord: clarify Message Content Intent onboarding hint. (#1487) Thanks @kyleok.
|
||||||
|
- Gateway: stop the service before uninstalling and fail if it remains loaded.
|
||||||
- Agents: surface concrete API error details instead of generic AI service errors.
|
- Agents: surface concrete API error details instead of generic AI service errors.
|
||||||
- Exec: fall back to non-PTY when PTY spawn fails (EBADF). (#1484)
|
- Exec: fall back to non-PTY when PTY spawn fails (EBADF). (#1484)
|
||||||
- Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj.
|
- Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj.
|
||||||
@@ -48,9 +45,9 @@ Docs: https://docs.clawd.bot
|
|||||||
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
|
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
|
||||||
- Slack: reduce WebClient retries to avoid duplicate sends. (#1481)
|
- Slack: reduce WebClient retries to avoid duplicate sends. (#1481)
|
||||||
- Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.
|
- Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.
|
||||||
|
- Discord: honor accountId across message actions and cron deliveries. (#1492) Thanks @svkozak.
|
||||||
- macOS: prefer linked channels in gateway summary to avoid false “not linked” status.
|
- macOS: prefer linked channels in gateway summary to avoid false “not linked” status.
|
||||||
- macOS/tests: fix gateway summary lookup after guard unwrap; prevent browser opens during tests. (ECID-1483)
|
- macOS/tests: fix gateway summary lookup after guard unwrap; prevent browser opens during tests. (ECID-1483)
|
||||||
- Providers: improve GitHub Copilot integration (enterprise support, base URL, and auth flow alignment).
|
|
||||||
|
|
||||||
## 2026.1.21-2
|
## 2026.1.21-2
|
||||||
|
|
||||||
@@ -61,6 +58,8 @@ Docs: https://docs.clawd.bot
|
|||||||
## 2026.1.21
|
## 2026.1.21
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
|
||||||
|
- Lobster: allow workflow file args via `argsJson` in the plugin tool. https://docs.clawd.bot/tools/lobster
|
||||||
- Heartbeat: allow running heartbeats in an explicit session key. (#1256) Thanks @zknicker.
|
- Heartbeat: allow running heartbeats in an explicit session key. (#1256) Thanks @zknicker.
|
||||||
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
|
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
|
||||||
- CLI: exec approvals mutations render tables instead of raw JSON.
|
- CLI: exec approvals mutations render tables instead of raw JSON.
|
||||||
@@ -70,13 +69,24 @@ Docs: https://docs.clawd.bot
|
|||||||
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs.
|
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs.
|
||||||
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability.
|
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability.
|
||||||
- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot.
|
- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot.
|
||||||
|
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
|
||||||
|
- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
|
||||||
|
- CLI: add `clawdbot update wizard` for interactive channel selection and restart prompts. https://docs.clawd.bot/cli/update
|
||||||
|
- Signal: add typing indicators and DM read receipts via signal-cli.
|
||||||
|
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
|
||||||
|
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
||||||
|
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
||||||
|
- Docs: add /model allowlist troubleshooting note. (#1405)
|
||||||
|
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
|
||||||
|
|
||||||
### Breaking
|
### Breaking
|
||||||
- **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.clawd.bot/web/control-ui#insecure-http
|
- **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.clawd.bot/web/control-ui#insecure-http
|
||||||
|
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.
|
- Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.
|
||||||
- Gateway: keep auto bind loopback-first and add explicit tailnet binding to avoid Tailscale taking over local UI. (#1380)
|
- Gateway: keep auto bind loopback-first and add explicit tailnet binding to avoid Tailscale taking over local UI. (#1380)
|
||||||
|
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
|
||||||
- Agents: enforce 9-char alphanumeric tool call ids for Mistral providers. (#1372) Thanks @zerone0x.
|
- Agents: enforce 9-char alphanumeric tool call ids for Mistral providers. (#1372) Thanks @zerone0x.
|
||||||
- Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell.
|
- Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell.
|
||||||
- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging.
|
- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging.
|
||||||
|
|||||||
+48
-16
@@ -2,6 +2,54 @@
|
|||||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||||
<channel>
|
<channel>
|
||||||
<title>Clawdbot</title>
|
<title>Clawdbot</title>
|
||||||
|
<item>
|
||||||
|
<title>2026.1.22</title>
|
||||||
|
<pubDate>Fri, 23 Jan 2026 08:58:14 +0000</pubDate>
|
||||||
|
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||||
|
<sparkle:version>7530</sparkle:version>
|
||||||
|
<sparkle:shortVersionString>2026.1.22</sparkle:shortVersionString>
|
||||||
|
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||||
|
<description><![CDATA[<h2>Clawdbot 2026.1.22</h2>
|
||||||
|
<h3>Changes</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Highlight: Compaction safeguard now uses adaptive chunking, progressive fallback, and UI status + retries. (#1466) Thanks @dlauer.</li>
|
||||||
|
<li>Providers: add Antigravity usage tracking to status output. (#1490) Thanks @patelhiren.</li>
|
||||||
|
<li>Slack: add chat-type reply threading overrides via <code>replyToModeByChatType</code>. (#1442) Thanks @stefangalescu.</li>
|
||||||
|
<li>BlueBubbles: add <code>asVoice</code> support for MP3/CAF voice memos in sendAttachment. (#1477, #1482) Thanks @Nicell.</li>
|
||||||
|
<li>Onboarding: add hatch choice (TUI/Web/Later), token explainer, background dashboard seed on macOS, and showcase link.</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Fixes</h3>
|
||||||
|
<ul>
|
||||||
|
<li>BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.</li>
|
||||||
|
<li>Message tool: keep path/filePath as-is for send; hydrate buffers only for sendAttachment. (#1444) Thanks @hopyky.</li>
|
||||||
|
<li>Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.</li>
|
||||||
|
<li>Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.</li>
|
||||||
|
<li>Agents: sanitize assistant history text to strip tool-call markers. (#1456) Thanks @zerone0x.</li>
|
||||||
|
<li>Discord: clarify Message Content Intent onboarding hint. (#1487) Thanks @kyleok.</li>
|
||||||
|
<li>Gateway: stop the service before uninstalling and fail if it remains loaded.</li>
|
||||||
|
<li>Agents: surface concrete API error details instead of generic AI service errors.</li>
|
||||||
|
<li>Exec: fall back to non-PTY when PTY spawn fails (EBADF). (#1484)</li>
|
||||||
|
<li>Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj.</li>
|
||||||
|
<li>Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider.</li>
|
||||||
|
<li>Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c.</li>
|
||||||
|
<li>Agents: make tool summaries more readable and only show optional params when set.</li>
|
||||||
|
<li>Agents: honor SOUL.md guidance even when the file is nested or path-qualified. (#1434) Thanks @neooriginal.</li>
|
||||||
|
<li>Matrix (plugin): persist m.direct for resolved DMs and harden room fallback. (#1436, #1486) Thanks @sibbl.</li>
|
||||||
|
<li>CLI: prefer <code>~</code> for home paths in output.</li>
|
||||||
|
<li>Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic.</li>
|
||||||
|
<li>Agents: centralize transcript sanitization in the runner; keep <final> tags and error turns intact.</li>
|
||||||
|
<li>Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik.</li>
|
||||||
|
<li>Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff.</li>
|
||||||
|
<li>Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.</li>
|
||||||
|
<li>Slack: reduce WebClient retries to avoid duplicate sends. (#1481)</li>
|
||||||
|
<li>Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.</li>
|
||||||
|
<li>macOS: prefer linked channels in gateway summary to avoid false “not linked” status.</li>
|
||||||
|
<li>macOS/tests: fix gateway summary lookup after guard unwrap; prevent browser opens during tests. (ECID-1483)</li>
|
||||||
|
</ul>
|
||||||
|
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||||
|
]]></description>
|
||||||
|
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.22/Clawdbot-2026.1.22.zip" length="22302446" type="application/octet-stream" sparkle:edSignature="w/EzfwGBCRRuCg5vz8enIfYujxOZJWRw9PaunQ7gIafKwnBJSTtxcnkvMVwQsnBwB6VN5Tu2MPij7PjDFFX+CA=="/>
|
||||||
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<title>2026.1.21</title>
|
<title>2026.1.21</title>
|
||||||
<pubDate>Thu, 22 Jan 2026 12:22:35 +0000</pubDate>
|
<pubDate>Thu, 22 Jan 2026 12:22:35 +0000</pubDate>
|
||||||
@@ -266,21 +314,5 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
|||||||
]]></description>
|
]]></description>
|
||||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.21/Clawdbot-2026.1.21.zip" length="12208102" type="application/octet-stream" sparkle:edSignature="hU495Eii8O3qmmUnxYFhXyEGv+qan6KL+GpeuBhPIXf+7B5F/gBh5Oz9cHaqaAPoZ4/3Bo6xgvic0HTkbz6gDw=="/>
|
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.21/Clawdbot-2026.1.21.zip" length="12208102" type="application/octet-stream" sparkle:edSignature="hU495Eii8O3qmmUnxYFhXyEGv+qan6KL+GpeuBhPIXf+7B5F/gBh5Oz9cHaqaAPoZ4/3Bo6xgvic0HTkbz6gDw=="/>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
|
||||||
<title>2026.1.16-2</title>
|
|
||||||
<pubDate>Sat, 17 Jan 2026 12:46:22 +0000</pubDate>
|
|
||||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
|
||||||
<sparkle:version>6273</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>2026.1.16-2</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
|
||||||
<description><![CDATA[<h2>Clawdbot 2026.1.16-2</h2>
|
|
||||||
<h3>Changes</h3>
|
|
||||||
<ul>
|
|
||||||
<li>CLI: stamp build commit into dist metadata so banners show the commit in npm installs.</li>
|
|
||||||
</ul>
|
|
||||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
|
||||||
]]></description>
|
|
||||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.16-2/Clawdbot-2026.1.16-2.zip" length="21399591" type="application/octet-stream" sparkle:edSignature="zelT+KzN32cXsihbFniPF5Heq0hkwFfL3Agrh/AaoKUkr7kJAFarkGSOZRTWZ9y+DvOluzn2wHHjVigRjMzrBA=="/>
|
|
||||||
</item>
|
|
||||||
</channel>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
+5
-4
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
summary: "CLI reference for `clawdbot update` (safe-ish source update + optional gateway restart)"
|
summary: "CLI reference for `clawdbot update` (safe-ish source update + gateway auto-restart)"
|
||||||
read_when:
|
read_when:
|
||||||
- You want to update a source checkout safely
|
- You want to update a source checkout safely
|
||||||
- You need to understand `--update` shorthand behavior
|
- You need to understand `--update` shorthand behavior
|
||||||
@@ -20,14 +20,14 @@ clawdbot update wizard
|
|||||||
clawdbot update --channel beta
|
clawdbot update --channel beta
|
||||||
clawdbot update --channel dev
|
clawdbot update --channel dev
|
||||||
clawdbot update --tag beta
|
clawdbot update --tag beta
|
||||||
clawdbot update --restart
|
clawdbot update --no-restart
|
||||||
clawdbot update --json
|
clawdbot update --json
|
||||||
clawdbot --update
|
clawdbot --update
|
||||||
```
|
```
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
- `--restart`: restart the Gateway service after a successful update.
|
- `--no-restart`: skip restarting the Gateway service after a successful update.
|
||||||
- `--channel <stable|beta|dev>`: set the update channel (git + npm; persisted in config).
|
- `--channel <stable|beta|dev>`: set the update channel (git + npm; persisted in config).
|
||||||
- `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only.
|
- `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only.
|
||||||
- `--json`: print machine-readable `UpdateRunResult` JSON.
|
- `--json`: print machine-readable `UpdateRunResult` JSON.
|
||||||
@@ -52,7 +52,8 @@ Options:
|
|||||||
## `update wizard`
|
## `update wizard`
|
||||||
|
|
||||||
Interactive flow to pick an update channel and confirm whether to restart the Gateway
|
Interactive flow to pick an update channel and confirm whether to restart the Gateway
|
||||||
after updating. If you select `dev` without a git checkout, it offers to create one.
|
after updating (default is to restart). If you select `dev` without a git checkout, it
|
||||||
|
offers to create one.
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ stay consistent across channels.
|
|||||||
1. **Parse Markdown -> IR**
|
1. **Parse Markdown -> IR**
|
||||||
- IR is plain text plus style spans (bold/italic/strike/code/spoiler) and link spans.
|
- IR is plain text plus style spans (bold/italic/strike/code/spoiler) and link spans.
|
||||||
- Offsets are UTF-16 code units so Signal style ranges align with its API.
|
- Offsets are UTF-16 code units so Signal style ranges align with its API.
|
||||||
|
- Tables are parsed only when a channel opts into table conversion.
|
||||||
2. **Chunk IR (format-first)**
|
2. **Chunk IR (format-first)**
|
||||||
- Chunking happens on the IR text before rendering.
|
- Chunking happens on the IR text before rendering.
|
||||||
- Inline formatting does not split across chunks; spans are sliced per chunk.
|
- Inline formatting does not split across chunks; spans are sliced per chunk.
|
||||||
@@ -59,7 +60,30 @@ IR (schematic):
|
|||||||
|
|
||||||
- Slack, Telegram, and Signal outbound adapters render from the IR.
|
- Slack, Telegram, and Signal outbound adapters render from the IR.
|
||||||
- Other channels (WhatsApp, iMessage, MS Teams, Discord) still use plain text or
|
- Other channels (WhatsApp, iMessage, MS Teams, Discord) still use plain text or
|
||||||
their own formatting rules.
|
their own formatting rules, with Markdown table conversion applied before
|
||||||
|
chunking when enabled.
|
||||||
|
|
||||||
|
## Table handling
|
||||||
|
|
||||||
|
Markdown tables are not consistently supported across chat clients. Use
|
||||||
|
`markdown.tables` to control conversion per channel (and per account).
|
||||||
|
|
||||||
|
- `code`: render tables as code blocks (default for most channels).
|
||||||
|
- `bullets`: convert each row into bullet points (default for Signal + WhatsApp).
|
||||||
|
- `off`: disable table parsing and conversion; raw table text passes through.
|
||||||
|
|
||||||
|
Config keys:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
channels:
|
||||||
|
discord:
|
||||||
|
markdown:
|
||||||
|
tables: code
|
||||||
|
accounts:
|
||||||
|
work:
|
||||||
|
markdown:
|
||||||
|
tables: off
|
||||||
|
```
|
||||||
|
|
||||||
## Chunking rules
|
## Chunking rules
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ read_when:
|
|||||||
|
|
||||||
# Updating
|
# Updating
|
||||||
|
|
||||||
Clawdbot is moving fast (pre “1.0”). Treat updates like shipping infra: update → run checks → restart → verify.
|
Clawdbot is moving fast (pre “1.0”). Treat updates like shipping infra: update → run checks → restart (or use `clawdbot update`, which restarts) → verify.
|
||||||
|
|
||||||
## Recommended: re-run the website installer (upgrade in place)
|
## Recommended: re-run the website installer (upgrade in place)
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ Notes:
|
|||||||
For **source installs** (git checkout), prefer:
|
For **source installs** (git checkout), prefer:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot update --restart
|
clawdbot update
|
||||||
```
|
```
|
||||||
|
|
||||||
It runs a safe-ish update flow:
|
It runs a safe-ish update flow:
|
||||||
@@ -89,6 +89,7 @@ It runs a safe-ish update flow:
|
|||||||
- Switches to the selected channel (tag or branch).
|
- Switches to the selected channel (tag or branch).
|
||||||
- Fetches + rebases against the configured upstream (dev channel).
|
- Fetches + rebases against the configured upstream (dev channel).
|
||||||
- Installs deps, builds, builds the Control UI, and runs `clawdbot doctor`.
|
- Installs deps, builds, builds the Control UI, and runs `clawdbot doctor`.
|
||||||
|
- Restarts the gateway by default (use `--no-restart` to skip).
|
||||||
|
|
||||||
If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will try to update via your package manager. If it can’t detect the install, use “Update (global install)” instead.
|
If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will try to update via your package manager. If it can’t detect the install, use “Update (global install)” instead.
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ This app now ships Sparkle auto-updates. Release builds must be Developer ID–s
|
|||||||
|
|
||||||
## Prereqs
|
## Prereqs
|
||||||
- Developer ID Application cert installed (example: `Developer ID Application: <Developer Name> (<TEAMID>)`).
|
- Developer ID Application cert installed (example: `Developer ID Application: <Developer Name> (<TEAMID>)`).
|
||||||
- Sparkle private key path set in the environment as `SPARKLE_PRIVATE_KEY_FILE` (path to your Sparkle ed25519 private key; public key baked into Info.plist).
|
- Sparkle private key path set in the environment as `SPARKLE_PRIVATE_KEY_FILE` (path to your Sparkle ed25519 private key; public key baked into Info.plist). If it is missing, check `~/.profile`.
|
||||||
- Notary credentials (keychain profile or API key) for `xcrun notarytool` if you want Gatekeeper-safe DMG/zip distribution.
|
- Notary credentials (keychain profile or API key) for `xcrun notarytool` if you want Gatekeeper-safe DMG/zip distribution.
|
||||||
- We use a Keychain profile named `clawdbot-notary`, created from App Store Connect API key env vars in your shell profile:
|
- We use a Keychain profile named `clawdbot-notary`, created from App Store Connect API key env vars in your shell profile:
|
||||||
- `APP_STORE_CONNECT_API_KEY_P8`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_ISSUER_ID`
|
- `APP_STORE_CONNECT_API_KEY_P8`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_ISSUER_ID`
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ Enable optional tools in `agents.list[].tools.allow` (or global `tools.allow`):
|
|||||||
```
|
```
|
||||||
|
|
||||||
Other config knobs that affect tool availability:
|
Other config knobs that affect tool availability:
|
||||||
|
- Allowlists that only name plugin tools are treated as plugin opt-ins; core tools remain
|
||||||
|
enabled unless you also include core tools or groups in the allowlist.
|
||||||
- `tools.profile` / `agents.list[].tools.profile` (base allowlist)
|
- `tools.profile` / `agents.list[].tools.profile` (base allowlist)
|
||||||
- `tools.byProvider` / `agents.list[].tools.byProvider` (provider‑specific allow/deny)
|
- `tools.byProvider` / `agents.list[].tools.byProvider` (provider‑specific allow/deny)
|
||||||
- `tools.sandbox.tools.*` (sandbox tool policy when sandboxed)
|
- `tools.sandbox.tools.*` (sandbox tool policy when sandboxed)
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ provider in two different ways.
|
|||||||
|
|
||||||
### 1) Built-in GitHub Copilot provider (`github-copilot`)
|
### 1) Built-in GitHub Copilot provider (`github-copilot`)
|
||||||
|
|
||||||
Use the native device-login flow to obtain a GitHub token and use it directly
|
Use the native device-login flow to obtain a GitHub token, then exchange it for
|
||||||
against the Copilot API. This is the **default** and simplest path because it
|
Copilot API tokens when Clawdbot runs. This is the **default** and simplest path
|
||||||
does not require VS Code. Enterprise domains are supported.
|
because it does not require VS Code.
|
||||||
|
|
||||||
### 2) Copilot Proxy plugin (`copilot-proxy`)
|
### 2) Copilot Proxy plugin (`copilot-proxy`)
|
||||||
|
|
||||||
@@ -39,8 +39,6 @@ clawdbot models auth login-github-copilot
|
|||||||
|
|
||||||
You'll be prompted to visit a URL and enter a one-time code. Keep the terminal
|
You'll be prompted to visit a URL and enter a one-time code. Keep the terminal
|
||||||
open until it completes.
|
open until it completes.
|
||||||
If you're on GitHub Enterprise, the login will ask for your enterprise URL or
|
|
||||||
domain (for example `company.ghe.com`).
|
|
||||||
|
|
||||||
### Optional flags
|
### Optional flags
|
||||||
|
|
||||||
@@ -68,7 +66,5 @@ clawdbot models set github-copilot/gpt-4o
|
|||||||
- Requires an interactive TTY; run it directly in a terminal.
|
- Requires an interactive TTY; run it directly in a terminal.
|
||||||
- Copilot model availability depends on your plan; if a model is rejected, try
|
- Copilot model availability depends on your plan; if a model is rejected, try
|
||||||
another ID (for example `github-copilot/gpt-4.1`).
|
another ID (for example `github-copilot/gpt-4.1`).
|
||||||
- The login stores a GitHub token in the auth profile store and uses it directly
|
- The login stores a GitHub token in the auth profile store and exchanges it for a
|
||||||
for Copilot API calls.
|
Copilot API token when Clawdbot runs.
|
||||||
- Base URL: `https://api.githubcopilot.com` (public) or `https://copilot-api.<domain>`
|
|
||||||
for GitHub Enterprise.
|
|
||||||
@@ -13,7 +13,7 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
|
|||||||
## Operator trigger
|
## Operator trigger
|
||||||
When the operator says “release”, immediately do this preflight (no extra questions unless blocked):
|
When the operator says “release”, immediately do this preflight (no extra questions unless blocked):
|
||||||
- Read this doc and `docs/platforms/mac/release.md`.
|
- Read this doc and `docs/platforms/mac/release.md`.
|
||||||
- Load env from `~/.profile` and confirm `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect vars are set.
|
- Load env from `~/.profile` and confirm `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect vars are set (SPARKLE_PRIVATE_KEY_FILE should live in `~/.profile`).
|
||||||
- Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed.
|
- Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed.
|
||||||
|
|
||||||
1) **Version & metadata**
|
1) **Version & metadata**
|
||||||
@@ -39,8 +39,9 @@ When the operator says “release”, immediately do this preflight (no extra qu
|
|||||||
- [ ] `pnpm test` (or `pnpm test:coverage` if you need coverage output)
|
- [ ] `pnpm test` (or `pnpm test:coverage` if you need coverage output)
|
||||||
- [ ] `pnpm run build` (last sanity check after tests)
|
- [ ] `pnpm run build` (last sanity check after tests)
|
||||||
- [ ] `pnpm release:check` (verifies npm pack contents)
|
- [ ] `pnpm release:check` (verifies npm pack contents)
|
||||||
- [ ] `pnpm test:install:smoke` (Docker install smoke test; required before release)
|
- [ ] `CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke` (Docker install smoke test, fast path; required before release)
|
||||||
- If the immediate previous npm release is known broken, set `CLAWDBOT_INSTALL_SMOKE_PREVIOUS=<last-good-version>` or `CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS=1` for the preinstall step.
|
- If the immediate previous npm release is known broken, set `CLAWDBOT_INSTALL_SMOKE_PREVIOUS=<last-good-version>` or `CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS=1` for the preinstall step.
|
||||||
|
- [ ] (Optional) Full installer smoke (adds non-root + CLI coverage): `pnpm test:install:smoke`
|
||||||
- [ ] (Optional) Installer E2E (Docker, runs `curl -fsSL https://clawd.bot/install.sh | bash`, onboards, then runs real tool calls):
|
- [ ] (Optional) Installer E2E (Docker, runs `curl -fsSL https://clawd.bot/install.sh | bash`, onboards, then runs real tool calls):
|
||||||
- `pnpm test:install:e2e:openai` (requires `OPENAI_API_KEY`)
|
- `pnpm test:install:e2e:openai` (requires `OPENAI_API_KEY`)
|
||||||
- `pnpm test:install:e2e:anthropic` (requires `ANTHROPIC_API_KEY`)
|
- `pnpm test:install:e2e:anthropic` (requires `ANTHROPIC_API_KEY`)
|
||||||
|
|||||||
@@ -121,6 +121,10 @@ Lobster is an **optional** plugin tool (not enabled by default). Allow it per ag
|
|||||||
|
|
||||||
You can also allow it globally with `tools.allow` if every agent should see it.
|
You can also allow it globally with `tools.allow` if every agent should see it.
|
||||||
|
|
||||||
|
Note: allowlists are opt-in for optional plugins. If your allowlist only names
|
||||||
|
plugin tools (like `lobster`), Clawdbot keeps core tools enabled. To restrict core
|
||||||
|
tools, include the core tools or groups you want in the allowlist too.
|
||||||
|
|
||||||
## Example: Email triage
|
## Example: Email triage
|
||||||
|
|
||||||
Without Lobster:
|
Without Lobster:
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||||
@@ -25,6 +26,7 @@ const bluebubblesGroupConfigSchema = z.object({
|
|||||||
const bluebubblesAccountSchema = z.object({
|
const bluebubblesAccountSchema = z.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
|
markdown: MarkdownConfigSchema,
|
||||||
serverUrl: z.string().optional(),
|
serverUrl: z.string().optional(),
|
||||||
password: z.string().optional(),
|
password: z.string().optional(),
|
||||||
webhookPath: z.string().optional(),
|
webhookPath: z.string().optional(),
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ function createMockRuntime(): PluginRuntime {
|
|||||||
chunkText: vi.fn() as unknown as PluginRuntime["channel"]["text"]["chunkText"],
|
chunkText: vi.fn() as unknown as PluginRuntime["channel"]["text"]["chunkText"],
|
||||||
resolveTextChunkLimit: vi.fn(() => 4000) as unknown as PluginRuntime["channel"]["text"]["resolveTextChunkLimit"],
|
resolveTextChunkLimit: vi.fn(() => 4000) as unknown as PluginRuntime["channel"]["text"]["resolveTextChunkLimit"],
|
||||||
hasControlCommand: mockHasControlCommand as unknown as PluginRuntime["channel"]["text"]["hasControlCommand"],
|
hasControlCommand: mockHasControlCommand as unknown as PluginRuntime["channel"]["text"]["hasControlCommand"],
|
||||||
|
resolveMarkdownTableMode: vi.fn(() => "code") as unknown as PluginRuntime["channel"]["text"]["resolveMarkdownTableMode"],
|
||||||
|
convertMarkdownTables: vi.fn((text: string) => text) as unknown as PluginRuntime["channel"]["text"]["convertMarkdownTables"],
|
||||||
},
|
},
|
||||||
reply: {
|
reply: {
|
||||||
dispatchReplyWithBufferedBlockDispatcher: mockDispatchReplyWithBufferedBlockDispatcher as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
|
dispatchReplyWithBufferedBlockDispatcher: mockDispatchReplyWithBufferedBlockDispatcher as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
|
||||||
|
|||||||
@@ -1662,9 +1662,15 @@ async function processMessage(
|
|||||||
? [payload.mediaUrl]
|
? [payload.mediaUrl]
|
||||||
: [];
|
: [];
|
||||||
if (mediaList.length > 0) {
|
if (mediaList.length > 0) {
|
||||||
|
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||||
|
cfg: config,
|
||||||
|
channel: "bluebubbles",
|
||||||
|
accountId: account.accountId,
|
||||||
|
});
|
||||||
|
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
||||||
let first = true;
|
let first = true;
|
||||||
for (const mediaUrl of mediaList) {
|
for (const mediaUrl of mediaList) {
|
||||||
const caption = first ? payload.text : undefined;
|
const caption = first ? text : undefined;
|
||||||
first = false;
|
first = false;
|
||||||
const result = await sendBlueBubblesMedia({
|
const result = await sendBlueBubblesMedia({
|
||||||
cfg: config,
|
cfg: config,
|
||||||
@@ -1686,8 +1692,14 @@ async function processMessage(
|
|||||||
account.config.textChunkLimit && account.config.textChunkLimit > 0
|
account.config.textChunkLimit && account.config.textChunkLimit > 0
|
||||||
? account.config.textChunkLimit
|
? account.config.textChunkLimit
|
||||||
: DEFAULT_TEXT_LIMIT;
|
: DEFAULT_TEXT_LIMIT;
|
||||||
const chunks = core.channel.text.chunkMarkdownText(payload.text ?? "", textLimit);
|
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||||
if (!chunks.length && payload.text) chunks.push(payload.text);
|
cfg: config,
|
||||||
|
channel: "bluebubbles",
|
||||||
|
accountId: account.accountId,
|
||||||
|
});
|
||||||
|
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
||||||
|
const chunks = core.channel.text.chunkMarkdownText(text, textLimit);
|
||||||
|
if (!chunks.length && text) chunks.push(text);
|
||||||
if (!chunks.length) return;
|
if (!chunks.length) return;
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
|
const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||||
@@ -35,6 +36,7 @@ const matrixRoomSchema = z
|
|||||||
export const MatrixConfigSchema = z.object({
|
export const MatrixConfigSchema = z.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
|
markdown: MarkdownConfigSchema,
|
||||||
homeserver: z.string().optional(),
|
homeserver: z.string().optional(),
|
||||||
userId: z.string().optional(),
|
userId: z.string().optional(),
|
||||||
accessToken: z.string().optional(),
|
accessToken: z.string().optional(),
|
||||||
|
|||||||
@@ -548,6 +548,11 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
}
|
}
|
||||||
|
|
||||||
let didSendReply = false;
|
let didSendReply = false;
|
||||||
|
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||||
|
cfg,
|
||||||
|
channel: "matrix",
|
||||||
|
accountId: route.accountId,
|
||||||
|
});
|
||||||
const { dispatcher, replyOptions, markDispatchIdle } =
|
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||||
core.channel.reply.createReplyDispatcherWithTyping({
|
core.channel.reply.createReplyDispatcherWithTyping({
|
||||||
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId)
|
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId)
|
||||||
@@ -562,6 +567,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
textLimit,
|
textLimit,
|
||||||
replyToMode,
|
replyToMode,
|
||||||
threadId: threadTarget,
|
threadId: threadTarget,
|
||||||
|
accountId: route.accountId,
|
||||||
|
tableMode,
|
||||||
});
|
});
|
||||||
didSendReply = true;
|
didSendReply = true;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { MatrixClient } from "matrix-bot-sdk";
|
import type { MatrixClient } from "matrix-bot-sdk";
|
||||||
|
|
||||||
import type { ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk";
|
import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||||
import { sendMessageMatrix } from "../send.js";
|
import { sendMessageMatrix } from "../send.js";
|
||||||
import { getMatrixRuntime } from "../../runtime.js";
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
|
|
||||||
@@ -12,8 +12,17 @@ export async function deliverMatrixReplies(params: {
|
|||||||
textLimit: number;
|
textLimit: number;
|
||||||
replyToMode: "off" | "first" | "all";
|
replyToMode: "off" | "first" | "all";
|
||||||
threadId?: string;
|
threadId?: string;
|
||||||
|
accountId?: string;
|
||||||
|
tableMode?: MarkdownTableMode;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const core = getMatrixRuntime();
|
const core = getMatrixRuntime();
|
||||||
|
const tableMode =
|
||||||
|
params.tableMode ??
|
||||||
|
core.channel.text.resolveMarkdownTableMode({
|
||||||
|
cfg: core.config.loadConfig(),
|
||||||
|
channel: "matrix",
|
||||||
|
accountId: params.accountId,
|
||||||
|
});
|
||||||
const logVerbose = (message: string) => {
|
const logVerbose = (message: string) => {
|
||||||
if (core.logging.shouldLogVerbose()) {
|
if (core.logging.shouldLogVerbose()) {
|
||||||
params.runtime.log?.(message);
|
params.runtime.log?.(message);
|
||||||
@@ -33,6 +42,8 @@ export async function deliverMatrixReplies(params: {
|
|||||||
}
|
}
|
||||||
const replyToIdRaw = reply.replyToId?.trim();
|
const replyToIdRaw = reply.replyToId?.trim();
|
||||||
const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw;
|
const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw;
|
||||||
|
const rawText = reply.text ?? "";
|
||||||
|
const text = core.channel.text.convertMarkdownTables(rawText, tableMode);
|
||||||
const mediaList = reply.mediaUrls?.length
|
const mediaList = reply.mediaUrls?.length
|
||||||
? reply.mediaUrls
|
? reply.mediaUrls
|
||||||
: reply.mediaUrl
|
: reply.mediaUrl
|
||||||
@@ -43,13 +54,14 @@ export async function deliverMatrixReplies(params: {
|
|||||||
Boolean(id) && (params.replyToMode === "all" || !hasReplied);
|
Boolean(id) && (params.replyToMode === "all" || !hasReplied);
|
||||||
|
|
||||||
if (mediaList.length === 0) {
|
if (mediaList.length === 0) {
|
||||||
for (const chunk of core.channel.text.chunkMarkdownText(reply.text ?? "", chunkLimit)) {
|
for (const chunk of core.channel.text.chunkMarkdownText(text, chunkLimit)) {
|
||||||
const trimmed = chunk.trim();
|
const trimmed = chunk.trim();
|
||||||
if (!trimmed) continue;
|
if (!trimmed) continue;
|
||||||
await sendMessageMatrix(params.roomId, trimmed, {
|
await sendMessageMatrix(params.roomId, trimmed, {
|
||||||
client: params.client,
|
client: params.client,
|
||||||
replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
|
replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
|
||||||
threadId: params.threadId,
|
threadId: params.threadId,
|
||||||
|
accountId: params.accountId,
|
||||||
});
|
});
|
||||||
if (shouldIncludeReply(replyToId)) {
|
if (shouldIncludeReply(replyToId)) {
|
||||||
hasReplied = true;
|
hasReplied = true;
|
||||||
@@ -60,13 +72,14 @@ export async function deliverMatrixReplies(params: {
|
|||||||
|
|
||||||
let first = true;
|
let first = true;
|
||||||
for (const mediaUrl of mediaList) {
|
for (const mediaUrl of mediaList) {
|
||||||
const caption = first ? (reply.text ?? "") : "";
|
const caption = first ? text : "";
|
||||||
await sendMessageMatrix(params.roomId, caption, {
|
await sendMessageMatrix(params.roomId, caption, {
|
||||||
client: params.client,
|
client: params.client,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
|
replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
|
||||||
threadId: params.threadId,
|
threadId: params.threadId,
|
||||||
audioAsVoice: reply.audioAsVoice,
|
audioAsVoice: reply.audioAsVoice,
|
||||||
|
accountId: params.accountId,
|
||||||
});
|
});
|
||||||
if (shouldIncludeReply(replyToId)) {
|
if (shouldIncludeReply(replyToId)) {
|
||||||
hasReplied = true;
|
hasReplied = true;
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ const runtimeStub = {
|
|||||||
text: {
|
text: {
|
||||||
resolveTextChunkLimit: () => 4000,
|
resolveTextChunkLimit: () => 4000,
|
||||||
chunkMarkdownText: (text: string) => (text ? [text] : []),
|
chunkMarkdownText: (text: string) => (text ? [text] : []),
|
||||||
|
resolveMarkdownTableMode: () => "code",
|
||||||
|
convertMarkdownTables: (text: string) => text,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as unknown as PluginRuntime;
|
} as unknown as PluginRuntime;
|
||||||
|
|||||||
@@ -50,9 +50,18 @@ export async function sendMessageMatrix(
|
|||||||
try {
|
try {
|
||||||
const roomId = await resolveMatrixRoomId(client, to);
|
const roomId = await resolveMatrixRoomId(client, to);
|
||||||
const cfg = getCore().config.loadConfig();
|
const cfg = getCore().config.loadConfig();
|
||||||
|
const tableMode = getCore().channel.text.resolveMarkdownTableMode({
|
||||||
|
cfg,
|
||||||
|
channel: "matrix",
|
||||||
|
accountId: opts.accountId,
|
||||||
|
});
|
||||||
|
const convertedMessage = getCore().channel.text.convertMarkdownTables(
|
||||||
|
trimmedMessage,
|
||||||
|
tableMode,
|
||||||
|
);
|
||||||
const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix");
|
const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix");
|
||||||
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
|
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
|
||||||
const chunks = getCore().channel.text.chunkMarkdownText(trimmedMessage, chunkLimit);
|
const chunks = getCore().channel.text.chunkMarkdownText(convertedMessage, chunkLimit);
|
||||||
const threadId = normalizeThreadId(opts.threadId);
|
const threadId = normalizeThreadId(opts.threadId);
|
||||||
const relation = threadId
|
const relation = threadId
|
||||||
? buildThreadRelation(threadId, opts.replyToId)
|
? buildThreadRelation(threadId, opts.replyToId)
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ export type MatrixSendResult = {
|
|||||||
export type MatrixSendOpts = {
|
export type MatrixSendOpts = {
|
||||||
client?: import("matrix-bot-sdk").MatrixClient;
|
client?: import("matrix-bot-sdk").MatrixClient;
|
||||||
mediaUrl?: string;
|
mediaUrl?: string;
|
||||||
|
accountId?: string;
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
threadId?: string | number | null;
|
threadId?: string | number | null;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/mattermost",
|
"name": "@clawdbot/mattermost",
|
||||||
"version": "2026.1.20-2",
|
"version": "2026.1.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Mattermost channel plugin",
|
"description": "Clawdbot Mattermost channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
BlockStreamingCoalesceSchema,
|
BlockStreamingCoalesceSchema,
|
||||||
DmPolicySchema,
|
DmPolicySchema,
|
||||||
GroupPolicySchema,
|
GroupPolicySchema,
|
||||||
|
MarkdownConfigSchema,
|
||||||
requireOpenAllowFrom,
|
requireOpenAllowFrom,
|
||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ const MattermostAccountSchemaBase = z
|
|||||||
.object({
|
.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
capabilities: z.array(z.string()).optional(),
|
capabilities: z.array(z.string()).optional(),
|
||||||
|
markdown: MarkdownConfigSchema,
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
configWrites: z.boolean().optional(),
|
configWrites: z.boolean().optional(),
|
||||||
botToken: z.string().optional(),
|
botToken: z.string().optional(),
|
||||||
|
|||||||
@@ -707,6 +707,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "mattermost", account.accountId, {
|
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "mattermost", account.accountId, {
|
||||||
fallbackLimit: account.textChunkLimit ?? 4000,
|
fallbackLimit: account.textChunkLimit ?? 4000,
|
||||||
});
|
});
|
||||||
|
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||||
|
cfg,
|
||||||
|
channel: "mattermost",
|
||||||
|
accountId: account.accountId,
|
||||||
|
});
|
||||||
|
|
||||||
let prefixContext: ResponsePrefixContext = {
|
let prefixContext: ResponsePrefixContext = {
|
||||||
identityName: resolveIdentityName(cfg, route.agentId),
|
identityName: resolveIdentityName(cfg, route.agentId),
|
||||||
@@ -720,7 +725,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
||||||
deliver: async (payload: ReplyPayload) => {
|
deliver: async (payload: ReplyPayload) => {
|
||||||
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||||
const text = payload.text ?? "";
|
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
||||||
if (mediaUrls.length === 0) {
|
if (mediaUrls.length === 0) {
|
||||||
const chunks = core.channel.text.chunkMarkdownText(text, textLimit);
|
const chunks = core.channel.text.chunkMarkdownText(text, textLimit);
|
||||||
for (const chunk of chunks.length > 0 ? chunks : [text]) {
|
for (const chunk of chunks.length > 0 ? chunks : [text]) {
|
||||||
|
|||||||
@@ -181,6 +181,15 @@ export async function sendMessageMattermost(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message) {
|
||||||
|
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||||
|
cfg,
|
||||||
|
channel: "mattermost",
|
||||||
|
accountId: account.accountId,
|
||||||
|
});
|
||||||
|
message = core.channel.text.convertMarkdownTables(message, tableMode);
|
||||||
|
}
|
||||||
|
|
||||||
if (!message && (!fileIds || fileIds.length === 0)) {
|
if (!message && (!fileIds || fileIds.length === 0)) {
|
||||||
if (uploadError) {
|
if (uploadError) {
|
||||||
throw new Error(`Mattermost media upload failed: ${uploadError.message}`);
|
throw new Error(`Mattermost media upload failed: ${uploadError.message}`);
|
||||||
@@ -205,4 +214,4 @@ export async function sendMessageMattermost(
|
|||||||
messageId: post.id ?? "unknown",
|
messageId: post.id ?? "unknown",
|
||||||
channelId,
|
channelId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -21,6 +21,8 @@ const runtimeStub = {
|
|||||||
}
|
}
|
||||||
return chunks;
|
return chunks;
|
||||||
},
|
},
|
||||||
|
resolveMarkdownTableMode: () => "code",
|
||||||
|
convertMarkdownTables: (text: string) => text,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as unknown as PluginRuntime;
|
} as unknown as PluginRuntime;
|
||||||
@@ -34,6 +36,7 @@ describe("msteams messenger", () => {
|
|||||||
it("filters silent replies", () => {
|
it("filters silent replies", () => {
|
||||||
const messages = renderReplyPayloadsToMessages([{ text: SILENT_REPLY_TOKEN }], {
|
const messages = renderReplyPayloadsToMessages([{ text: SILENT_REPLY_TOKEN }], {
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
|
tableMode: "code",
|
||||||
});
|
});
|
||||||
expect(messages).toEqual([]);
|
expect(messages).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -41,7 +44,7 @@ describe("msteams messenger", () => {
|
|||||||
it("filters silent reply prefixes", () => {
|
it("filters silent reply prefixes", () => {
|
||||||
const messages = renderReplyPayloadsToMessages(
|
const messages = renderReplyPayloadsToMessages(
|
||||||
[{ text: `${SILENT_REPLY_TOKEN} -- ignored` }],
|
[{ text: `${SILENT_REPLY_TOKEN} -- ignored` }],
|
||||||
{ textChunkLimit: 4000 },
|
{ textChunkLimit: 4000, tableMode: "code" },
|
||||||
);
|
);
|
||||||
expect(messages).toEqual([]);
|
expect(messages).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -49,7 +52,7 @@ describe("msteams messenger", () => {
|
|||||||
it("splits media into separate messages by default", () => {
|
it("splits media into separate messages by default", () => {
|
||||||
const messages = renderReplyPayloadsToMessages(
|
const messages = renderReplyPayloadsToMessages(
|
||||||
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
|
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
|
||||||
{ textChunkLimit: 4000 },
|
{ textChunkLimit: 4000, tableMode: "code" },
|
||||||
);
|
);
|
||||||
expect(messages).toEqual([{ text: "hi" }, { mediaUrl: "https://example.com/a.png" }]);
|
expect(messages).toEqual([{ text: "hi" }, { mediaUrl: "https://example.com/a.png" }]);
|
||||||
});
|
});
|
||||||
@@ -57,7 +60,7 @@ describe("msteams messenger", () => {
|
|||||||
it("supports inline media mode", () => {
|
it("supports inline media mode", () => {
|
||||||
const messages = renderReplyPayloadsToMessages(
|
const messages = renderReplyPayloadsToMessages(
|
||||||
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
|
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
|
||||||
{ textChunkLimit: 4000, mediaMode: "inline" },
|
{ textChunkLimit: 4000, mediaMode: "inline", tableMode: "code" },
|
||||||
);
|
);
|
||||||
expect(messages).toEqual([{ text: "hi", mediaUrl: "https://example.com/a.png" }]);
|
expect(messages).toEqual([{ text: "hi", mediaUrl: "https://example.com/a.png" }]);
|
||||||
});
|
});
|
||||||
@@ -66,6 +69,7 @@ describe("msteams messenger", () => {
|
|||||||
const long = "hello ".repeat(200);
|
const long = "hello ".repeat(200);
|
||||||
const messages = renderReplyPayloadsToMessages([{ text: long }], {
|
const messages = renderReplyPayloadsToMessages([{ text: long }], {
|
||||||
textChunkLimit: 50,
|
textChunkLimit: 50,
|
||||||
|
tableMode: "code",
|
||||||
});
|
});
|
||||||
expect(messages.length).toBeGreaterThan(1);
|
expect(messages.length).toBeGreaterThan(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
isSilentReplyText,
|
isSilentReplyText,
|
||||||
loadWebMedia,
|
loadWebMedia,
|
||||||
|
type MarkdownTableMode,
|
||||||
type MSTeamsReplyStyle,
|
type MSTeamsReplyStyle,
|
||||||
type ReplyPayload,
|
type ReplyPayload,
|
||||||
SILENT_REPLY_TOKEN,
|
SILENT_REPLY_TOKEN,
|
||||||
@@ -61,6 +62,7 @@ export type MSTeamsReplyRenderOptions = {
|
|||||||
textChunkLimit: number;
|
textChunkLimit: number;
|
||||||
chunkText?: boolean;
|
chunkText?: boolean;
|
||||||
mediaMode?: "split" | "inline";
|
mediaMode?: "split" | "inline";
|
||||||
|
tableMode?: MarkdownTableMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -196,10 +198,19 @@ export function renderReplyPayloadsToMessages(
|
|||||||
const chunkLimit = Math.min(options.textChunkLimit, 4000);
|
const chunkLimit = Math.min(options.textChunkLimit, 4000);
|
||||||
const chunkText = options.chunkText !== false;
|
const chunkText = options.chunkText !== false;
|
||||||
const mediaMode = options.mediaMode ?? "split";
|
const mediaMode = options.mediaMode ?? "split";
|
||||||
|
const tableMode =
|
||||||
|
options.tableMode ??
|
||||||
|
getMSTeamsRuntime().channel.text.resolveMarkdownTableMode({
|
||||||
|
cfg: getMSTeamsRuntime().config.loadConfig(),
|
||||||
|
channel: "msteams",
|
||||||
|
});
|
||||||
|
|
||||||
for (const payload of replies) {
|
for (const payload of replies) {
|
||||||
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||||
const text = payload.text ?? "";
|
const text = getMSTeamsRuntime().channel.text.convertMarkdownTables(
|
||||||
|
payload.text ?? "",
|
||||||
|
tableMode,
|
||||||
|
);
|
||||||
|
|
||||||
if (!text && mediaList.length === 0) continue;
|
if (!text && mediaList.length === 0) continue;
|
||||||
|
|
||||||
|
|||||||
@@ -53,10 +53,15 @@ export function createMSTeamsReplyDispatcher(params: {
|
|||||||
).responsePrefix,
|
).responsePrefix,
|
||||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
|
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
|
||||||
deliver: async (payload) => {
|
deliver: async (payload) => {
|
||||||
|
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||||
|
cfg: params.cfg,
|
||||||
|
channel: "msteams",
|
||||||
|
});
|
||||||
const messages = renderReplyPayloadsToMessages([payload], {
|
const messages = renderReplyPayloadsToMessages([payload], {
|
||||||
textChunkLimit: params.textLimit,
|
textChunkLimit: params.textLimit,
|
||||||
chunkText: true,
|
chunkText: true,
|
||||||
mediaMode: "split",
|
mediaMode: "split",
|
||||||
|
tableMode,
|
||||||
});
|
});
|
||||||
const mediaMaxBytes = resolveChannelMediaMaxBytes({
|
const mediaMaxBytes = resolveChannelMediaMaxBytes({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import { extractFilename, extractMessageId } from "./media-helpers.js";
|
import { extractFilename, extractMessageId } from "./media-helpers.js";
|
||||||
import { buildConversationReference, sendMSTeamsMessages } from "./messenger.js";
|
import { buildConversationReference, sendMSTeamsMessages } from "./messenger.js";
|
||||||
import { buildMSTeamsPollCard } from "./polls.js";
|
import { buildMSTeamsPollCard } from "./polls.js";
|
||||||
|
import { getMSTeamsRuntime } from "./runtime.js";
|
||||||
import { resolveMSTeamsSendContext, type MSTeamsProactiveContext } from "./send-context.js";
|
import { resolveMSTeamsSendContext, type MSTeamsProactiveContext } from "./send-context.js";
|
||||||
|
|
||||||
export type SendMSTeamsMessageParams = {
|
export type SendMSTeamsMessageParams = {
|
||||||
@@ -93,13 +94,21 @@ export async function sendMessageMSTeams(
|
|||||||
params: SendMSTeamsMessageParams,
|
params: SendMSTeamsMessageParams,
|
||||||
): Promise<SendMSTeamsMessageResult> {
|
): Promise<SendMSTeamsMessageResult> {
|
||||||
const { cfg, to, text, mediaUrl } = params;
|
const { cfg, to, text, mediaUrl } = params;
|
||||||
|
const tableMode = getMSTeamsRuntime().channel.text.resolveMarkdownTableMode({
|
||||||
|
cfg,
|
||||||
|
channel: "msteams",
|
||||||
|
});
|
||||||
|
const messageText = getMSTeamsRuntime().channel.text.convertMarkdownTables(
|
||||||
|
text ?? "",
|
||||||
|
tableMode,
|
||||||
|
);
|
||||||
const ctx = await resolveMSTeamsSendContext({ cfg, to });
|
const ctx = await resolveMSTeamsSendContext({ cfg, to });
|
||||||
const { adapter, appId, conversationId, ref, log, conversationType, tokenProvider, sharePointSiteId } = ctx;
|
const { adapter, appId, conversationId, ref, log, conversationType, tokenProvider, sharePointSiteId } = ctx;
|
||||||
|
|
||||||
log.debug("sending proactive message", {
|
log.debug("sending proactive message", {
|
||||||
conversationId,
|
conversationId,
|
||||||
conversationType,
|
conversationType,
|
||||||
textLength: text.length,
|
textLength: messageText.length,
|
||||||
hasMedia: Boolean(mediaUrl),
|
hasMedia: Boolean(mediaUrl),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -134,7 +143,7 @@ export async function sendMessageMSTeams(
|
|||||||
const { activity, uploadId } = prepareFileConsentActivity({
|
const { activity, uploadId } = prepareFileConsentActivity({
|
||||||
media: { buffer: media.buffer, filename: fileName, contentType: media.contentType },
|
media: { buffer: media.buffer, filename: fileName, contentType: media.contentType },
|
||||||
conversationId,
|
conversationId,
|
||||||
description: text || undefined,
|
description: messageText || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
log.debug("sending file consent card", { uploadId, fileName, size: media.buffer.length });
|
log.debug("sending file consent card", { uploadId, fileName, size: media.buffer.length });
|
||||||
@@ -172,14 +181,14 @@ export async function sendMessageMSTeams(
|
|||||||
const base64 = media.buffer.toString("base64");
|
const base64 = media.buffer.toString("base64");
|
||||||
const finalMediaUrl = `data:${media.contentType};base64,${base64}`;
|
const finalMediaUrl = `data:${media.contentType};base64,${base64}`;
|
||||||
|
|
||||||
return sendTextWithMedia(ctx, text, finalMediaUrl);
|
return sendTextWithMedia(ctx, messageText, finalMediaUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isImage && !sharePointSiteId) {
|
if (isImage && !sharePointSiteId) {
|
||||||
// Group chat/channel without SharePoint: send image inline (avoids OneDrive failures)
|
// Group chat/channel without SharePoint: send image inline (avoids OneDrive failures)
|
||||||
const base64 = media.buffer.toString("base64");
|
const base64 = media.buffer.toString("base64");
|
||||||
const finalMediaUrl = `data:${media.contentType};base64,${base64}`;
|
const finalMediaUrl = `data:${media.contentType};base64,${base64}`;
|
||||||
return sendTextWithMedia(ctx, text, finalMediaUrl);
|
return sendTextWithMedia(ctx, messageText, finalMediaUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group chat or channel: upload to SharePoint (if siteId configured) or OneDrive
|
// Group chat or channel: upload to SharePoint (if siteId configured) or OneDrive
|
||||||
@@ -223,7 +232,7 @@ export async function sendMessageMSTeams(
|
|||||||
const fileCardAttachment = buildTeamsFileInfoCard(driveItem);
|
const fileCardAttachment = buildTeamsFileInfoCard(driveItem);
|
||||||
const activity = {
|
const activity = {
|
||||||
type: "message",
|
type: "message",
|
||||||
text: text || undefined,
|
text: messageText || undefined,
|
||||||
attachments: [fileCardAttachment],
|
attachments: [fileCardAttachment],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -264,7 +273,7 @@ export async function sendMessageMSTeams(
|
|||||||
const fileLink = `📎 [${uploaded.name}](${uploaded.shareUrl})`;
|
const fileLink = `📎 [${uploaded.name}](${uploaded.shareUrl})`;
|
||||||
const activity = {
|
const activity = {
|
||||||
type: "message",
|
type: "message",
|
||||||
text: text ? `${text}\n\n${fileLink}` : fileLink,
|
text: messageText ? `${messageText}\n\n${fileLink}` : fileLink,
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseRef = buildConversationReference(ref);
|
const baseRef = buildConversationReference(ref);
|
||||||
@@ -290,7 +299,7 @@ export async function sendMessageMSTeams(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// No media: send text only
|
// No media: send text only
|
||||||
return sendTextWithMedia(ctx, text, undefined);
|
return sendTextWithMedia(ctx, messageText, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
DmConfigSchema,
|
DmConfigSchema,
|
||||||
DmPolicySchema,
|
DmPolicySchema,
|
||||||
GroupPolicySchema,
|
GroupPolicySchema,
|
||||||
|
MarkdownConfigSchema,
|
||||||
requireOpenAllowFrom,
|
requireOpenAllowFrom,
|
||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -21,6 +22,7 @@ export const NextcloudTalkAccountSchemaBase = z
|
|||||||
.object({
|
.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
|
markdown: MarkdownConfigSchema,
|
||||||
baseUrl: z.string().optional(),
|
baseUrl: z.string().optional(),
|
||||||
botSecret: z.string().optional(),
|
botSecret: z.string().optional(),
|
||||||
botSecretFile: z.string().optional(),
|
botSecretFile: z.string().optional(),
|
||||||
|
|||||||
@@ -71,8 +71,18 @@ export async function sendMessageNextcloudTalk(
|
|||||||
throw new Error("Message must be non-empty for Nextcloud Talk sends");
|
throw new Error("Message must be non-empty for Nextcloud Talk sends");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tableMode = getNextcloudTalkRuntime().channel.text.resolveMarkdownTableMode({
|
||||||
|
cfg,
|
||||||
|
channel: "nextcloud-talk",
|
||||||
|
accountId: account.accountId,
|
||||||
|
});
|
||||||
|
const message = getNextcloudTalkRuntime().channel.text.convertMarkdownTables(
|
||||||
|
text.trim(),
|
||||||
|
tableMode,
|
||||||
|
);
|
||||||
|
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
message: text.trim(),
|
message,
|
||||||
};
|
};
|
||||||
if (opts.replyTo) {
|
if (opts.replyTo) {
|
||||||
body.replyTo = opts.replyTo;
|
body.replyTo = opts.replyTo;
|
||||||
|
|||||||
@@ -133,13 +133,20 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
|
|||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
sendText: async ({ to, text, accountId }) => {
|
sendText: async ({ to, text, accountId }) => {
|
||||||
|
const core = getNostrRuntime();
|
||||||
const aid = accountId ?? DEFAULT_ACCOUNT_ID;
|
const aid = accountId ?? DEFAULT_ACCOUNT_ID;
|
||||||
const bus = activeBuses.get(aid);
|
const bus = activeBuses.get(aid);
|
||||||
if (!bus) {
|
if (!bus) {
|
||||||
throw new Error(`Nostr bus not running for account ${aid}`);
|
throw new Error(`Nostr bus not running for account ${aid}`);
|
||||||
}
|
}
|
||||||
|
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||||
|
cfg: core.config.loadConfig(),
|
||||||
|
channel: "nostr",
|
||||||
|
accountId: aid,
|
||||||
|
});
|
||||||
|
const message = core.channel.text.convertMarkdownTables(text ?? "", tableMode);
|
||||||
const normalizedTo = normalizePubkey(to);
|
const normalizedTo = normalizePubkey(to);
|
||||||
await bus.sendDm(normalizedTo, text);
|
await bus.sendDm(normalizedTo, message);
|
||||||
return { channel: "nostr", to: normalizedTo };
|
return { channel: "nostr", to: normalizedTo };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import { MarkdownConfigSchema, buildChannelConfigSchema } from "clawdbot/plugin-sdk";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { buildChannelConfigSchema } from "clawdbot/plugin-sdk";
|
|
||||||
|
|
||||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||||
|
|
||||||
@@ -63,6 +63,9 @@ export const NostrConfigSchema = z.object({
|
|||||||
/** Whether this channel is enabled */
|
/** Whether this channel is enabled */
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
|
|
||||||
|
/** Markdown formatting overrides (tables). */
|
||||||
|
markdown: MarkdownConfigSchema,
|
||||||
|
|
||||||
/** Private key in hex or nsec bech32 format */
|
/** Private key in hex or nsec bech32 format */
|
||||||
privateKey: z.string().optional(),
|
privateKey: z.string().optional(),
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/open-prose",
|
"name": "@clawdbot/open-prose",
|
||||||
"version": "2026.1.23",
|
"version": "2026.1.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
|
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||||
@@ -5,6 +6,7 @@ const allowFromEntry = z.union([z.string(), z.number()]);
|
|||||||
const zaloAccountSchema = z.object({
|
const zaloAccountSchema = z.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
|
markdown: MarkdownConfigSchema,
|
||||||
botToken: z.string().optional(),
|
botToken: z.string().optional(),
|
||||||
tokenFile: z.string().optional(),
|
tokenFile: z.string().optional(),
|
||||||
webhookUrl: z.string().optional(),
|
webhookUrl: z.string().optional(),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
import type { ClawdbotConfig, MarkdownTableMode } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
import type { ResolvedZaloAccount } from "./accounts.js";
|
import type { ResolvedZaloAccount } from "./accounts.js";
|
||||||
import {
|
import {
|
||||||
@@ -578,6 +578,12 @@ async function processMessageWithPipeline(params: {
|
|||||||
runtime.error?.(`zalo: failed updating session meta: ${String(err)}`);
|
runtime.error?.(`zalo: failed updating session meta: ${String(err)}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||||
|
cfg: config,
|
||||||
|
channel: "zalo",
|
||||||
|
accountId: account.accountId,
|
||||||
|
});
|
||||||
|
|
||||||
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
cfg: config,
|
cfg: config,
|
||||||
@@ -591,6 +597,7 @@ async function processMessageWithPipeline(params: {
|
|||||||
core,
|
core,
|
||||||
statusSink,
|
statusSink,
|
||||||
fetcher,
|
fetcher,
|
||||||
|
tableMode,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (err, info) => {
|
onError: (err, info) => {
|
||||||
@@ -608,8 +615,11 @@ async function deliverZaloReply(params: {
|
|||||||
core: ZaloCoreRuntime;
|
core: ZaloCoreRuntime;
|
||||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||||
fetcher?: ZaloFetch;
|
fetcher?: ZaloFetch;
|
||||||
|
tableMode?: MarkdownTableMode;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { payload, token, chatId, runtime, core, statusSink, fetcher } = params;
|
const { payload, token, chatId, runtime, core, statusSink, fetcher } = params;
|
||||||
|
const tableMode = params.tableMode ?? "code";
|
||||||
|
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
||||||
|
|
||||||
const mediaList = payload.mediaUrls?.length
|
const mediaList = payload.mediaUrls?.length
|
||||||
? payload.mediaUrls
|
? payload.mediaUrls
|
||||||
@@ -620,7 +630,7 @@ async function deliverZaloReply(params: {
|
|||||||
if (mediaList.length > 0) {
|
if (mediaList.length > 0) {
|
||||||
let first = true;
|
let first = true;
|
||||||
for (const mediaUrl of mediaList) {
|
for (const mediaUrl of mediaList) {
|
||||||
const caption = first ? payload.text : undefined;
|
const caption = first ? text : undefined;
|
||||||
first = false;
|
first = false;
|
||||||
try {
|
try {
|
||||||
await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher);
|
await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher);
|
||||||
@@ -632,8 +642,8 @@ async function deliverZaloReply(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.text) {
|
if (text) {
|
||||||
const chunks = core.channel.text.chunkMarkdownText(payload.text, ZALO_TEXT_LIMIT);
|
const chunks = core.channel.text.chunkMarkdownText(text, ZALO_TEXT_LIMIT);
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
try {
|
try {
|
||||||
await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher);
|
await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||||
@@ -10,6 +11,7 @@ const groupConfigSchema = z.object({
|
|||||||
const zalouserAccountSchema = z.object({
|
const zalouserAccountSchema = z.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
|
markdown: MarkdownConfigSchema,
|
||||||
profile: z.string().optional(),
|
profile: z.string().optional(),
|
||||||
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
||||||
allowFrom: z.array(allowFromEntry).optional(),
|
allowFrom: z.array(allowFromEntry).optional(),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ChildProcess } from "node:child_process";
|
import type { ChildProcess } from "node:child_process";
|
||||||
|
|
||||||
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
|
import type { ClawdbotConfig, MarkdownTableMode, RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||||
import { mergeAllowlist, summarizeMapping } from "clawdbot/plugin-sdk";
|
import { mergeAllowlist, summarizeMapping } from "clawdbot/plugin-sdk";
|
||||||
import { sendMessageZalouser } from "./send.js";
|
import { sendMessageZalouser } from "./send.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -332,6 +332,11 @@ async function processMessage(
|
|||||||
runtime,
|
runtime,
|
||||||
core,
|
core,
|
||||||
statusSink,
|
statusSink,
|
||||||
|
tableMode: core.channel.text.resolveMarkdownTableMode({
|
||||||
|
cfg: config,
|
||||||
|
channel: "zalouser",
|
||||||
|
accountId: account.accountId,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (err, info) => {
|
onError: (err, info) => {
|
||||||
@@ -351,8 +356,11 @@ async function deliverZalouserReply(params: {
|
|||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
core: ZalouserCoreRuntime;
|
core: ZalouserCoreRuntime;
|
||||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||||
|
tableMode?: MarkdownTableMode;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { payload, profile, chatId, isGroup, runtime, core, statusSink } = params;
|
const { payload, profile, chatId, isGroup, runtime, core, statusSink } = params;
|
||||||
|
const tableMode = params.tableMode ?? "code";
|
||||||
|
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
||||||
|
|
||||||
const mediaList = payload.mediaUrls?.length
|
const mediaList = payload.mediaUrls?.length
|
||||||
? payload.mediaUrls
|
? payload.mediaUrls
|
||||||
@@ -363,7 +371,7 @@ async function deliverZalouserReply(params: {
|
|||||||
if (mediaList.length > 0) {
|
if (mediaList.length > 0) {
|
||||||
let first = true;
|
let first = true;
|
||||||
for (const mediaUrl of mediaList) {
|
for (const mediaUrl of mediaList) {
|
||||||
const caption = first ? payload.text : undefined;
|
const caption = first ? text : undefined;
|
||||||
first = false;
|
first = false;
|
||||||
try {
|
try {
|
||||||
logVerbose(core, runtime, `Sending media to ${chatId}`);
|
logVerbose(core, runtime, `Sending media to ${chatId}`);
|
||||||
@@ -380,8 +388,8 @@ async function deliverZalouserReply(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.text) {
|
if (text) {
|
||||||
const chunks = core.channel.text.chunkMarkdownText(payload.text, ZALOUSER_TEXT_LIMIT);
|
const chunks = core.channel.text.chunkMarkdownText(text, ZALOUSER_TEXT_LIMIT);
|
||||||
logVerbose(core, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`);
|
logVerbose(core, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`);
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+1
-1
@@ -111,7 +111,7 @@
|
|||||||
"format:swift": "swiftformat --lint --config .swiftformat apps/macos/Sources apps/ios/Sources apps/shared/ClawdbotKit/Sources",
|
"format:swift": "swiftformat --lint --config .swiftformat apps/macos/Sources apps/ios/Sources apps/shared/ClawdbotKit/Sources",
|
||||||
"format:all": "pnpm format && pnpm format:swift",
|
"format:all": "pnpm format && pnpm format:swift",
|
||||||
"format:fix": "oxfmt --write src test",
|
"format:fix": "oxfmt --write src test",
|
||||||
"test": "vitest run",
|
"test": "node scripts/test-parallel.mjs",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:ui": "pnpm --dir ui test",
|
"test:ui": "pnpm --dir ui test",
|
||||||
"test:force": "node --import tsx scripts/test-force.ts",
|
"test:force": "node --import tsx scripts/test-force.ts",
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ echo "==> Verify git installed"
|
|||||||
command -v git >/dev/null
|
command -v git >/dev/null
|
||||||
|
|
||||||
echo "==> Verify clawdbot installed"
|
echo "==> Verify clawdbot installed"
|
||||||
LATEST_VERSION="$(npm view clawdbot version)"
|
EXPECTED_VERSION="${CLAWDBOT_INSTALL_EXPECT_VERSION:-}"
|
||||||
|
if [[ -n "$EXPECTED_VERSION" ]]; then
|
||||||
|
LATEST_VERSION="$EXPECTED_VERSION"
|
||||||
|
else
|
||||||
|
LATEST_VERSION="$(npm view clawdbot version)"
|
||||||
|
fi
|
||||||
CMD_PATH="$(command -v clawdbot || true)"
|
CMD_PATH="$(command -v clawdbot || true)"
|
||||||
if [[ -z "$CMD_PATH" && -x "$HOME/.npm-global/bin/clawdbot" ]]; then
|
if [[ -z "$CMD_PATH" && -x "$HOME/.npm-global/bin/clawdbot" ]]; then
|
||||||
CMD_PATH="$HOME/.npm-global/bin/clawdbot"
|
CMD_PATH="$HOME/.npm-global/bin/clawdbot"
|
||||||
|
|||||||
@@ -6,21 +6,36 @@ SMOKE_PREVIOUS_VERSION="${CLAWDBOT_INSTALL_SMOKE_PREVIOUS:-}"
|
|||||||
SKIP_PREVIOUS="${CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS:-0}"
|
SKIP_PREVIOUS="${CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS:-0}"
|
||||||
|
|
||||||
echo "==> Resolve npm versions"
|
echo "==> Resolve npm versions"
|
||||||
LATEST_VERSION="$(npm view clawdbot version)"
|
|
||||||
if [[ -n "$SMOKE_PREVIOUS_VERSION" ]]; then
|
if [[ -n "$SMOKE_PREVIOUS_VERSION" ]]; then
|
||||||
|
LATEST_VERSION="$(npm view clawdbot version)"
|
||||||
PREVIOUS_VERSION="$SMOKE_PREVIOUS_VERSION"
|
PREVIOUS_VERSION="$SMOKE_PREVIOUS_VERSION"
|
||||||
else
|
else
|
||||||
PREVIOUS_VERSION="$(node - <<'NODE'
|
VERSIONS_JSON="$(npm view clawdbot versions --json)"
|
||||||
const { execSync } = require("node:child_process");
|
versions_line="$(node - <<'NODE'
|
||||||
|
const raw = process.env.VERSIONS_JSON || "[]";
|
||||||
const versions = JSON.parse(execSync("npm view clawdbot versions --json", { encoding: "utf8" }));
|
let versions;
|
||||||
if (!Array.isArray(versions) || versions.length === 0) {
|
try {
|
||||||
|
versions = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
versions = raw ? [raw] : [];
|
||||||
|
}
|
||||||
|
if (!Array.isArray(versions)) {
|
||||||
|
versions = [versions];
|
||||||
|
}
|
||||||
|
if (versions.length === 0) {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const previous = versions.length >= 2 ? versions[versions.length - 2] : versions[0];
|
const latest = versions[versions.length - 1];
|
||||||
process.stdout.write(previous);
|
const previous = versions.length >= 2 ? versions[versions.length - 2] : latest;
|
||||||
|
process.stdout.write(`${latest} ${previous}`);
|
||||||
NODE
|
NODE
|
||||||
)"
|
)"
|
||||||
|
LATEST_VERSION="${versions_line%% *}"
|
||||||
|
PREVIOUS_VERSION="${versions_line#* }"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "${CLAWDBOT_INSTALL_LATEST_OUT:-}" ]]; then
|
||||||
|
printf "%s" "$LATEST_VERSION" > "$CLAWDBOT_INSTALL_LATEST_OUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "latest=$LATEST_VERSION previous=$PREVIOUS_VERSION"
|
echo "latest=$LATEST_VERSION previous=$PREVIOUS_VERSION"
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ SMOKE_IMAGE="${CLAWDBOT_INSTALL_SMOKE_IMAGE:-clawdbot-install-smoke:local}"
|
|||||||
NONROOT_IMAGE="${CLAWDBOT_INSTALL_NONROOT_IMAGE:-clawdbot-install-nonroot:local}"
|
NONROOT_IMAGE="${CLAWDBOT_INSTALL_NONROOT_IMAGE:-clawdbot-install-nonroot:local}"
|
||||||
INSTALL_URL="${CLAWDBOT_INSTALL_URL:-https://clawd.bot/install.sh}"
|
INSTALL_URL="${CLAWDBOT_INSTALL_URL:-https://clawd.bot/install.sh}"
|
||||||
CLI_INSTALL_URL="${CLAWDBOT_INSTALL_CLI_URL:-https://clawd.bot/install-cli.sh}"
|
CLI_INSTALL_URL="${CLAWDBOT_INSTALL_CLI_URL:-https://clawd.bot/install-cli.sh}"
|
||||||
|
SKIP_NONROOT="${CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT:-0}"
|
||||||
|
LATEST_DIR="$(mktemp -d)"
|
||||||
|
LATEST_FILE="${LATEST_DIR}/latest"
|
||||||
|
|
||||||
echo "==> Build smoke image (upgrade, root): $SMOKE_IMAGE"
|
echo "==> Build smoke image (upgrade, root): $SMOKE_IMAGE"
|
||||||
docker build \
|
docker build \
|
||||||
@@ -15,31 +18,48 @@ docker build \
|
|||||||
|
|
||||||
echo "==> Run installer smoke test (root): $INSTALL_URL"
|
echo "==> Run installer smoke test (root): $INSTALL_URL"
|
||||||
docker run --rm -t \
|
docker run --rm -t \
|
||||||
|
-v "${LATEST_DIR}:/out" \
|
||||||
-e CLAWDBOT_INSTALL_URL="$INSTALL_URL" \
|
-e CLAWDBOT_INSTALL_URL="$INSTALL_URL" \
|
||||||
|
-e CLAWDBOT_INSTALL_LATEST_OUT="/out/latest" \
|
||||||
-e CLAWDBOT_INSTALL_SMOKE_PREVIOUS="${CLAWDBOT_INSTALL_SMOKE_PREVIOUS:-}" \
|
-e CLAWDBOT_INSTALL_SMOKE_PREVIOUS="${CLAWDBOT_INSTALL_SMOKE_PREVIOUS:-}" \
|
||||||
-e CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS="${CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS:-0}" \
|
-e CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS="${CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS:-0}" \
|
||||||
-e CLAWDBOT_NO_ONBOARD=1 \
|
-e CLAWDBOT_NO_ONBOARD=1 \
|
||||||
-e DEBIAN_FRONTEND=noninteractive \
|
-e DEBIAN_FRONTEND=noninteractive \
|
||||||
"$SMOKE_IMAGE"
|
"$SMOKE_IMAGE"
|
||||||
|
|
||||||
echo "==> Build non-root image: $NONROOT_IMAGE"
|
LATEST_VERSION=""
|
||||||
docker build \
|
if [[ -f "$LATEST_FILE" ]]; then
|
||||||
-t "$NONROOT_IMAGE" \
|
LATEST_VERSION="$(cat "$LATEST_FILE")"
|
||||||
-f "$ROOT_DIR/scripts/docker/install-sh-nonroot/Dockerfile" \
|
fi
|
||||||
"$ROOT_DIR/scripts/docker/install-sh-nonroot"
|
|
||||||
|
|
||||||
echo "==> Run installer non-root test: $INSTALL_URL"
|
if [[ "$SKIP_NONROOT" == "1" ]]; then
|
||||||
docker run --rm -t \
|
echo "==> Skip non-root installer smoke (CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT=1)"
|
||||||
-e CLAWDBOT_INSTALL_URL="$INSTALL_URL" \
|
else
|
||||||
-e CLAWDBOT_NO_ONBOARD=1 \
|
echo "==> Build non-root image: $NONROOT_IMAGE"
|
||||||
-e DEBIAN_FRONTEND=noninteractive \
|
docker build \
|
||||||
"$NONROOT_IMAGE"
|
-t "$NONROOT_IMAGE" \
|
||||||
|
-f "$ROOT_DIR/scripts/docker/install-sh-nonroot/Dockerfile" \
|
||||||
|
"$ROOT_DIR/scripts/docker/install-sh-nonroot"
|
||||||
|
|
||||||
|
echo "==> Run installer non-root test: $INSTALL_URL"
|
||||||
|
docker run --rm -t \
|
||||||
|
-e CLAWDBOT_INSTALL_URL="$INSTALL_URL" \
|
||||||
|
-e CLAWDBOT_INSTALL_EXPECT_VERSION="$LATEST_VERSION" \
|
||||||
|
-e CLAWDBOT_NO_ONBOARD=1 \
|
||||||
|
-e DEBIAN_FRONTEND=noninteractive \
|
||||||
|
"$NONROOT_IMAGE"
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ "${CLAWDBOT_INSTALL_SMOKE_SKIP_CLI:-0}" == "1" ]]; then
|
if [[ "${CLAWDBOT_INSTALL_SMOKE_SKIP_CLI:-0}" == "1" ]]; then
|
||||||
echo "==> Skip CLI installer smoke (CLAWDBOT_INSTALL_SMOKE_SKIP_CLI=1)"
|
echo "==> Skip CLI installer smoke (CLAWDBOT_INSTALL_SMOKE_SKIP_CLI=1)"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ "$SKIP_NONROOT" == "1" ]]; then
|
||||||
|
echo "==> Skip CLI installer smoke (non-root image skipped)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
echo "==> Run CLI installer non-root test (same image)"
|
echo "==> Run CLI installer non-root test (same image)"
|
||||||
docker run --rm -t \
|
docker run --rm -t \
|
||||||
--entrypoint /bin/bash \
|
--entrypoint /bin/bash \
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import os from "node:os";
|
||||||
|
|
||||||
|
const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
||||||
|
|
||||||
|
const runs = [
|
||||||
|
{
|
||||||
|
name: "unit",
|
||||||
|
args: ["vitest", "run", "--config", "vitest.unit.config.ts"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "extensions",
|
||||||
|
args: ["vitest", "run", "--config", "vitest.extensions.config.ts"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gateway",
|
||||||
|
args: ["vitest", "run", "--config", "vitest.gateway.config.ts"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const parallelRuns = runs.filter((entry) => entry.name !== "gateway");
|
||||||
|
const serialRuns = runs.filter((entry) => entry.name === "gateway");
|
||||||
|
|
||||||
|
const children = new Set();
|
||||||
|
const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
|
||||||
|
const overrideWorkers = Number.parseInt(process.env.CLAWDBOT_TEST_WORKERS ?? "", 10);
|
||||||
|
const resolvedOverride = Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null;
|
||||||
|
const localWorkers = Math.max(4, Math.min(16, os.cpus().length));
|
||||||
|
const perRunWorkers = Math.max(1, Math.floor(localWorkers / parallelRuns.length));
|
||||||
|
const maxWorkers = isCI ? null : resolvedOverride ?? perRunWorkers;
|
||||||
|
|
||||||
|
const run = (entry) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const args = maxWorkers ? [...entry.args, "--maxWorkers", String(maxWorkers)] : entry.args;
|
||||||
|
const child = spawn(pnpm, args, {
|
||||||
|
stdio: "inherit",
|
||||||
|
env: { ...process.env, VITEST_GROUP: entry.name },
|
||||||
|
shell: process.platform === "win32",
|
||||||
|
});
|
||||||
|
children.add(child);
|
||||||
|
child.on("exit", (code, signal) => {
|
||||||
|
children.delete(child);
|
||||||
|
resolve(code ?? (signal ? 1 : 0));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const shutdown = (signal) => {
|
||||||
|
for (const child of children) {
|
||||||
|
child.kill(signal);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||||
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||||
|
|
||||||
|
const parallelCodes = await Promise.all(parallelRuns.map(run));
|
||||||
|
const failedParallel = parallelCodes.find((code) => code !== 0);
|
||||||
|
if (failedParallel !== undefined) {
|
||||||
|
process.exit(failedParallel);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of serialRuns) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const code = await run(entry);
|
||||||
|
if (code !== 0) {
|
||||||
|
process.exit(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import fs from "node:fs/promises";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import {
|
|
||||||
type AuthProfileStore,
|
|
||||||
ensureAuthProfileStore,
|
|
||||||
resolveApiKeyForProfile,
|
|
||||||
} from "./auth-profiles.js";
|
|
||||||
|
|
||||||
vi.mock("@mariozechner/pi-ai", () => ({
|
|
||||||
getOAuthApiKey: vi.fn(() => {
|
|
||||||
throw new Error("refresh should not be called");
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("auth-profiles (github-copilot)", () => {
|
|
||||||
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
|
||||||
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
|
|
||||||
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
||||||
let tempDir: string | null = null;
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
if (tempDir) {
|
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
|
||||||
tempDir = null;
|
|
||||||
}
|
|
||||||
if (previousStateDir === undefined) delete process.env.CLAWDBOT_STATE_DIR;
|
|
||||||
else process.env.CLAWDBOT_STATE_DIR = previousStateDir;
|
|
||||||
if (previousAgentDir === undefined) delete process.env.CLAWDBOT_AGENT_DIR;
|
|
||||||
else process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
|
|
||||||
if (previousPiAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
|
||||||
else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats copilot oauth tokens with expires=0 as non-expiring", async () => {
|
|
||||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-copilot-"));
|
|
||||||
process.env.CLAWDBOT_STATE_DIR = tempDir;
|
|
||||||
process.env.CLAWDBOT_AGENT_DIR = path.join(tempDir, "agents", "main", "agent");
|
|
||||||
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
|
|
||||||
|
|
||||||
const authProfilePath = path.join(tempDir, "agents", "main", "agent", "auth-profiles.json");
|
|
||||||
await fs.mkdir(path.dirname(authProfilePath), { recursive: true });
|
|
||||||
|
|
||||||
const store: AuthProfileStore = {
|
|
||||||
version: 1,
|
|
||||||
profiles: {
|
|
||||||
"github-copilot:github": {
|
|
||||||
type: "oauth",
|
|
||||||
provider: "github-copilot",
|
|
||||||
refresh: "gh-token",
|
|
||||||
access: "gh-token",
|
|
||||||
expires: 0,
|
|
||||||
enterpriseUrl: "company.ghe.com",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
await fs.writeFile(authProfilePath, `${JSON.stringify(store)}\n`);
|
|
||||||
|
|
||||||
const loaded = ensureAuthProfileStore();
|
|
||||||
const resolved = await resolveApiKeyForProfile({
|
|
||||||
store: loaded,
|
|
||||||
profileId: "github-copilot:github",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(resolved?.apiKey).toBe("gh-token");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -39,15 +39,6 @@ async function refreshOAuthTokenWithLock(params: {
|
|||||||
const cred = store.profiles[params.profileId];
|
const cred = store.profiles[params.profileId];
|
||||||
if (!cred || cred.type !== "oauth") return null;
|
if (!cred || cred.type !== "oauth") return null;
|
||||||
|
|
||||||
if (
|
|
||||||
cred.provider === "github-copilot" &&
|
|
||||||
(!Number.isFinite(cred.expires) || cred.expires <= 0)
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
apiKey: buildOAuthApiKey(cred.provider, cred),
|
|
||||||
newCredentials: cred,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (Date.now() < cred.expires) {
|
if (Date.now() < cred.expires) {
|
||||||
return {
|
return {
|
||||||
apiKey: buildOAuthApiKey(cred.provider, cred),
|
apiKey: buildOAuthApiKey(cred.provider, cred),
|
||||||
@@ -112,20 +103,6 @@ async function tryResolveOAuthProfile(params: {
|
|||||||
if (profileConfig && profileConfig.provider !== cred.provider) return null;
|
if (profileConfig && profileConfig.provider !== cred.provider) return null;
|
||||||
if (profileConfig && profileConfig.mode !== cred.type) return null;
|
if (profileConfig && profileConfig.mode !== cred.type) return null;
|
||||||
|
|
||||||
if (cred.provider === "github-copilot" && (!Number.isFinite(cred.expires) || cred.expires <= 0)) {
|
|
||||||
return {
|
|
||||||
apiKey: buildOAuthApiKey(cred.provider, cred),
|
|
||||||
provider: cred.provider,
|
|
||||||
email: cred.email,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (cred.provider === "github-copilot" && (!Number.isFinite(cred.expires) || cred.expires <= 0)) {
|
|
||||||
return {
|
|
||||||
apiKey: buildOAuthApiKey(cred.provider, cred),
|
|
||||||
provider: cred.provider,
|
|
||||||
email: cred.email,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (Date.now() < cred.expires) {
|
if (Date.now() < cred.expires) {
|
||||||
return {
|
return {
|
||||||
apiKey: buildOAuthApiKey(cred.provider, cred),
|
apiKey: buildOAuthApiKey(cred.provider, cred),
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ export type TokenCredential = {
|
|||||||
token: string;
|
token: string;
|
||||||
/** Optional expiry timestamp (ms since epoch). */
|
/** Optional expiry timestamp (ms since epoch). */
|
||||||
expires?: number;
|
expires?: number;
|
||||||
enterpriseUrl?: string;
|
|
||||||
email?: string;
|
email?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ export function isModernModelRef(ref: ModelRef): boolean {
|
|||||||
return matchesPrefix(id, XAI_PREFIXES);
|
return matchesPrefix(id, XAI_PREFIXES);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (provider === "opencode" && id.endsWith("-free")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (provider === "openrouter" || provider === "opencode") {
|
if (provider === "openrouter" || provider === "opencode") {
|
||||||
return matchesAny(id, [
|
return matchesAny(id, [
|
||||||
...ANTHROPIC_PREFIXES,
|
...ANTHROPIC_PREFIXES,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import path from "node:path";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { DEFAULT_GITHUB_COPILOT_BASE_URL } from "../providers/github-copilot-utils.js";
|
|
||||||
|
|
||||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||||
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
|
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
|
||||||
@@ -52,6 +51,16 @@ describe("models-config", () => {
|
|||||||
try {
|
try {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
|
||||||
|
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||||
|
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
||||||
|
resolveCopilotApiToken: vi.fn().mockResolvedValue({
|
||||||
|
token: "copilot",
|
||||||
|
expiresAt: Date.now() + 60 * 60 * 1000,
|
||||||
|
source: "mock",
|
||||||
|
baseUrl: "https://api.copilot.example",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||||
|
|
||||||
const agentDir = path.join(home, "agent-default-base-url");
|
const agentDir = path.join(home, "agent-default-base-url");
|
||||||
@@ -62,55 +71,48 @@ describe("models-config", () => {
|
|||||||
providers: Record<string, { baseUrl?: string; models?: unknown[] }>;
|
providers: Record<string, { baseUrl?: string; models?: unknown[] }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_GITHUB_COPILOT_BASE_URL);
|
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example");
|
||||||
expect(parsed.providers["github-copilot"]?.models?.length ?? 0).toBe(0);
|
expect(parsed.providers["github-copilot"]?.models?.length ?? 0).toBe(0);
|
||||||
} finally {
|
} finally {
|
||||||
process.env.COPILOT_GITHUB_TOKEN = previous;
|
process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it("uses enterprise URL from auth profiles to derive base URL", async () => {
|
it("prefers COPILOT_GITHUB_TOKEN over GH_TOKEN and GITHUB_TOKEN", async () => {
|
||||||
await withTempHome(async () => {
|
await withTempHome(async () => {
|
||||||
|
const previous = process.env.COPILOT_GITHUB_TOKEN;
|
||||||
|
const previousGh = process.env.GH_TOKEN;
|
||||||
|
const previousGithub = process.env.GITHUB_TOKEN;
|
||||||
|
process.env.COPILOT_GITHUB_TOKEN = "copilot-token";
|
||||||
|
process.env.GH_TOKEN = "gh-token";
|
||||||
|
process.env.GITHUB_TOKEN = "github-token";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
|
||||||
const agentDir = path.join(process.env.HOME ?? home, "agent-enterprise");
|
const resolveCopilotApiToken = vi.fn().mockResolvedValue({
|
||||||
await fs.mkdir(agentDir, { recursive: true });
|
token: "copilot",
|
||||||
await fs.writeFile(
|
expiresAt: Date.now() + 60 * 60 * 1000,
|
||||||
path.join(agentDir, "auth-profiles.json"),
|
source: "mock",
|
||||||
JSON.stringify(
|
baseUrl: "https://api.copilot.example",
|
||||||
{
|
});
|
||||||
version: 1,
|
|
||||||
profiles: {
|
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||||
"github-copilot:github": {
|
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
||||||
type: "oauth",
|
resolveCopilotApiToken,
|
||||||
provider: "github-copilot",
|
}));
|
||||||
refresh: "gh-token",
|
|
||||||
access: "gh-token",
|
|
||||||
expires: 0,
|
|
||||||
enterpriseUrl: "company.ghe.com",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||||
|
|
||||||
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
|
await ensureClawdbotModelsJson({ models: { providers: {} } });
|
||||||
|
|
||||||
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8");
|
expect(resolveCopilotApiToken).toHaveBeenCalledWith(
|
||||||
const parsed = JSON.parse(raw) as {
|
expect.objectContaining({ githubToken: "copilot-token" }),
|
||||||
providers: Record<string, { baseUrl?: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(
|
|
||||||
"https://copilot-api.company.ghe.com",
|
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
// no-op
|
process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||||
|
process.env.GH_TOKEN = previousGh;
|
||||||
|
process.env.GITHUB_TOKEN = previousGithub;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import path from "node:path";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { DEFAULT_GITHUB_COPILOT_BASE_URL } from "../providers/github-copilot-utils.js";
|
|
||||||
|
|
||||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||||
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
|
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
|
||||||
@@ -44,7 +43,7 @@ describe("models-config", () => {
|
|||||||
process.env.HOME = previousHome;
|
process.env.HOME = previousHome;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses default baseUrl when env token is present", async () => {
|
it("falls back to default baseUrl when token exchange fails", async () => {
|
||||||
await withTempHome(async () => {
|
await withTempHome(async () => {
|
||||||
const previous = process.env.COPILOT_GITHUB_TOKEN;
|
const previous = process.env.COPILOT_GITHUB_TOKEN;
|
||||||
process.env.COPILOT_GITHUB_TOKEN = "gh-token";
|
process.env.COPILOT_GITHUB_TOKEN = "gh-token";
|
||||||
@@ -52,6 +51,11 @@ describe("models-config", () => {
|
|||||||
try {
|
try {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
|
||||||
|
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||||
|
DEFAULT_COPILOT_API_BASE_URL: "https://api.default.test",
|
||||||
|
resolveCopilotApiToken: vi.fn().mockRejectedValue(new Error("boom")),
|
||||||
|
}));
|
||||||
|
|
||||||
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||||
const { resolveClawdbotAgentDir } = await import("./agent-paths.js");
|
const { resolveClawdbotAgentDir } = await import("./agent-paths.js");
|
||||||
|
|
||||||
@@ -63,13 +67,13 @@ describe("models-config", () => {
|
|||||||
providers: Record<string, { baseUrl?: string }>;
|
providers: Record<string, { baseUrl?: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_GITHUB_COPILOT_BASE_URL);
|
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.default.test");
|
||||||
} finally {
|
} finally {
|
||||||
process.env.COPILOT_GITHUB_TOKEN = previous;
|
process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it("normalizes enterprise URL when deriving base URL", async () => {
|
it("uses agentDir override auth profiles for copilot injection", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
const previous = process.env.COPILOT_GITHUB_TOKEN;
|
const previous = process.env.COPILOT_GITHUB_TOKEN;
|
||||||
const previousGh = process.env.GH_TOKEN;
|
const previousGh = process.env.GH_TOKEN;
|
||||||
@@ -90,12 +94,9 @@ describe("models-config", () => {
|
|||||||
version: 1,
|
version: 1,
|
||||||
profiles: {
|
profiles: {
|
||||||
"github-copilot:github": {
|
"github-copilot:github": {
|
||||||
type: "oauth",
|
type: "token",
|
||||||
provider: "github-copilot",
|
provider: "github-copilot",
|
||||||
refresh: "gh-profile-token",
|
token: "gh-profile-token",
|
||||||
access: "gh-profile-token",
|
|
||||||
expires: 0,
|
|
||||||
enterpriseUrl: "https://company.ghe.com/",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -104,6 +105,16 @@ describe("models-config", () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||||
|
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
||||||
|
resolveCopilotApiToken: vi.fn().mockResolvedValue({
|
||||||
|
token: "copilot",
|
||||||
|
expiresAt: Date.now() + 60 * 60 * 1000,
|
||||||
|
source: "mock",
|
||||||
|
baseUrl: "https://api.copilot.example",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||||
|
|
||||||
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
|
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
|
||||||
@@ -113,9 +124,7 @@ describe("models-config", () => {
|
|||||||
providers: Record<string, { baseUrl?: string }>;
|
providers: Record<string, { baseUrl?: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(
|
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example");
|
||||||
"https://copilot-api.company.ghe.com",
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
|
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
|
||||||
else process.env.COPILOT_GITHUB_TOKEN = previous;
|
else process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
normalizeGithubCopilotDomain,
|
DEFAULT_COPILOT_API_BASE_URL,
|
||||||
resolveGithubCopilotBaseUrl,
|
resolveCopilotApiToken,
|
||||||
} from "../providers/github-copilot-utils.js";
|
} from "../providers/github-copilot-token.js";
|
||||||
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
|
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
|
||||||
import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js";
|
import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js";
|
||||||
import {
|
import {
|
||||||
@@ -331,18 +331,29 @@ export async function resolveImplicitCopilotProvider(params: {
|
|||||||
|
|
||||||
if (!hasProfile && !githubToken) return null;
|
if (!hasProfile && !githubToken) return null;
|
||||||
|
|
||||||
let enterpriseDomain: string | null = null;
|
let selectedGithubToken = githubToken;
|
||||||
if (hasProfile) {
|
if (!selectedGithubToken && hasProfile) {
|
||||||
// Use the first available profile as a default for discovery (it will be
|
// Use the first available profile as a default for discovery (it will be
|
||||||
// re-resolved per-run by the embedded runner).
|
// re-resolved per-run by the embedded runner).
|
||||||
const profileId = listProfilesForProvider(authStore, "github-copilot")[0];
|
const profileId = listProfilesForProvider(authStore, "github-copilot")[0];
|
||||||
const profile = profileId ? authStore.profiles[profileId] : undefined;
|
const profile = profileId ? authStore.profiles[profileId] : undefined;
|
||||||
if (profile && "enterpriseUrl" in profile && typeof profile.enterpriseUrl === "string") {
|
if (profile && profile.type === "token") {
|
||||||
enterpriseDomain = normalizeGithubCopilotDomain(profile.enterpriseUrl);
|
selectedGithubToken = profile.token;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = resolveGithubCopilotBaseUrl(enterpriseDomain);
|
let baseUrl = DEFAULT_COPILOT_API_BASE_URL;
|
||||||
|
if (selectedGithubToken) {
|
||||||
|
try {
|
||||||
|
const token = await resolveCopilotApiToken({
|
||||||
|
githubToken: selectedGithubToken,
|
||||||
|
env,
|
||||||
|
});
|
||||||
|
baseUrl = token.baseUrl;
|
||||||
|
} catch {
|
||||||
|
baseUrl = DEFAULT_COPILOT_API_BASE_URL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// pi-coding-agent's ModelRegistry marks a model "available" only if its
|
// pi-coding-agent's ModelRegistry marks a model "available" only if its
|
||||||
// `AuthStorage` has auth configured for that provider (via auth.json/env/etc).
|
// `AuthStorage` has auth configured for that provider (via auth.json/env/etc).
|
||||||
@@ -353,7 +364,7 @@ export async function resolveImplicitCopilotProvider(params: {
|
|||||||
// GitHub token (not the exchanged Copilot token), and (3) matches existing
|
// GitHub token (not the exchanged Copilot token), and (3) matches existing
|
||||||
// patterns for OAuth-like providers in pi-coding-agent.
|
// patterns for OAuth-like providers in pi-coding-agent.
|
||||||
// Note: we deliberately do not write pi-coding-agent's `auth.json` here.
|
// Note: we deliberately do not write pi-coding-agent's `auth.json` here.
|
||||||
// Clawdbot uses its own auth store and passes the GitHub token at runtime.
|
// Clawdbot uses its own auth store and exchanges tokens at runtime.
|
||||||
// `models list` uses Clawdbot's auth heuristics for availability.
|
// `models list` uses Clawdbot's auth heuristics for availability.
|
||||||
|
|
||||||
// We intentionally do NOT define custom models for Copilot in models.json.
|
// We intentionally do NOT define custom models for Copilot in models.json.
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import path from "node:path";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { DEFAULT_GITHUB_COPILOT_BASE_URL } from "../providers/github-copilot-utils.js";
|
|
||||||
|
|
||||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||||
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
|
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
|
||||||
@@ -81,16 +80,25 @@ describe("models-config", () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const resolveCopilotApiToken = vi.fn().mockResolvedValue({
|
||||||
|
token: "copilot",
|
||||||
|
expiresAt: Date.now() + 60 * 60 * 1000,
|
||||||
|
source: "mock",
|
||||||
|
baseUrl: "https://api.copilot.example",
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||||
|
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
||||||
|
resolveCopilotApiToken,
|
||||||
|
}));
|
||||||
|
|
||||||
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||||
|
|
||||||
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
|
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
|
||||||
|
|
||||||
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8");
|
expect(resolveCopilotApiToken).toHaveBeenCalledWith(
|
||||||
const parsed = JSON.parse(raw) as {
|
expect.objectContaining({ githubToken: "alpha-token" }),
|
||||||
providers: Record<string, { baseUrl?: string }>;
|
);
|
||||||
};
|
|
||||||
|
|
||||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_GITHUB_COPILOT_BASE_URL);
|
|
||||||
} finally {
|
} finally {
|
||||||
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
|
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
|
||||||
else process.env.COPILOT_GITHUB_TOKEN = previous;
|
else process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||||
@@ -109,6 +117,16 @@ describe("models-config", () => {
|
|||||||
try {
|
try {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
|
||||||
|
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||||
|
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
||||||
|
resolveCopilotApiToken: vi.fn().mockResolvedValue({
|
||||||
|
token: "copilot",
|
||||||
|
expiresAt: Date.now() + 60 * 60 * 1000,
|
||||||
|
source: "mock",
|
||||||
|
baseUrl: "https://api.copilot.example",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||||
const { resolveClawdbotAgentDir } = await import("./agent-paths.js");
|
const { resolveClawdbotAgentDir } = await import("./agent-paths.js");
|
||||||
|
|
||||||
|
|||||||
@@ -41,12 +41,11 @@ describe("resolveOpencodeZenAlias", () => {
|
|||||||
describe("resolveOpencodeZenModelApi", () => {
|
describe("resolveOpencodeZenModelApi", () => {
|
||||||
it("maps APIs by model family", () => {
|
it("maps APIs by model family", () => {
|
||||||
expect(resolveOpencodeZenModelApi("claude-opus-4-5")).toBe("anthropic-messages");
|
expect(resolveOpencodeZenModelApi("claude-opus-4-5")).toBe("anthropic-messages");
|
||||||
expect(resolveOpencodeZenModelApi("minimax-m2.1-free")).toBe("anthropic-messages");
|
|
||||||
expect(resolveOpencodeZenModelApi("gemini-3-pro")).toBe("google-generative-ai");
|
expect(resolveOpencodeZenModelApi("gemini-3-pro")).toBe("google-generative-ai");
|
||||||
expect(resolveOpencodeZenModelApi("gpt-5.2")).toBe("openai-responses");
|
expect(resolveOpencodeZenModelApi("gpt-5.2")).toBe("openai-responses");
|
||||||
expect(resolveOpencodeZenModelApi("alpha-gd4")).toBe("openai-completions");
|
expect(resolveOpencodeZenModelApi("alpha-gd4")).toBe("openai-completions");
|
||||||
expect(resolveOpencodeZenModelApi("big-pickle")).toBe("openai-completions");
|
expect(resolveOpencodeZenModelApi("big-pickle")).toBe("openai-completions");
|
||||||
expect(resolveOpencodeZenModelApi("glm-4.7-free")).toBe("openai-completions");
|
expect(resolveOpencodeZenModelApi("glm-4.7")).toBe("openai-completions");
|
||||||
expect(resolveOpencodeZenModelApi("some-unknown-model")).toBe("openai-completions");
|
expect(resolveOpencodeZenModelApi("some-unknown-model")).toBe("openai-completions");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -55,10 +54,10 @@ describe("getOpencodeZenStaticFallbackModels", () => {
|
|||||||
it("returns an array of models", () => {
|
it("returns an array of models", () => {
|
||||||
const models = getOpencodeZenStaticFallbackModels();
|
const models = getOpencodeZenStaticFallbackModels();
|
||||||
expect(Array.isArray(models)).toBe(true);
|
expect(Array.isArray(models)).toBe(true);
|
||||||
expect(models.length).toBe(11);
|
expect(models.length).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes Claude, GPT, Gemini, GLM, and MiniMax models", () => {
|
it("includes Claude, GPT, Gemini, and GLM models", () => {
|
||||||
const models = getOpencodeZenStaticFallbackModels();
|
const models = getOpencodeZenStaticFallbackModels();
|
||||||
const ids = models.map((m) => m.id);
|
const ids = models.map((m) => m.id);
|
||||||
|
|
||||||
@@ -66,8 +65,7 @@ describe("getOpencodeZenStaticFallbackModels", () => {
|
|||||||
expect(ids).toContain("gpt-5.2");
|
expect(ids).toContain("gpt-5.2");
|
||||||
expect(ids).toContain("gpt-5.1-codex");
|
expect(ids).toContain("gpt-5.1-codex");
|
||||||
expect(ids).toContain("gemini-3-pro");
|
expect(ids).toContain("gemini-3-pro");
|
||||||
expect(ids).toContain("glm-4.7-free");
|
expect(ids).toContain("glm-4.7");
|
||||||
expect(ids).toContain("minimax-m2.1-free");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns valid ModelDefinitionConfig objects", () => {
|
it("returns valid ModelDefinitionConfig objects", () => {
|
||||||
@@ -90,8 +88,7 @@ describe("OPENCODE_ZEN_MODEL_ALIASES", () => {
|
|||||||
expect(OPENCODE_ZEN_MODEL_ALIASES.codex).toBe("gpt-5.1-codex");
|
expect(OPENCODE_ZEN_MODEL_ALIASES.codex).toBe("gpt-5.1-codex");
|
||||||
expect(OPENCODE_ZEN_MODEL_ALIASES.gpt5).toBe("gpt-5.2");
|
expect(OPENCODE_ZEN_MODEL_ALIASES.gpt5).toBe("gpt-5.2");
|
||||||
expect(OPENCODE_ZEN_MODEL_ALIASES.gemini).toBe("gemini-3-pro");
|
expect(OPENCODE_ZEN_MODEL_ALIASES.gemini).toBe("gemini-3-pro");
|
||||||
expect(OPENCODE_ZEN_MODEL_ALIASES.glm).toBe("glm-4.7-free");
|
expect(OPENCODE_ZEN_MODEL_ALIASES.glm).toBe("glm-4.7");
|
||||||
expect(OPENCODE_ZEN_MODEL_ALIASES.minimax).toBe("minimax-m2.1-free");
|
|
||||||
|
|
||||||
// Legacy aliases (kept for backward compatibility).
|
// Legacy aliases (kept for backward compatibility).
|
||||||
expect(OPENCODE_ZEN_MODEL_ALIASES.sonnet).toBe("claude-opus-4-5");
|
expect(OPENCODE_ZEN_MODEL_ALIASES.sonnet).toBe("claude-opus-4-5");
|
||||||
|
|||||||
@@ -68,13 +68,9 @@ export const OPENCODE_ZEN_MODEL_ALIASES: Record<string, string> = {
|
|||||||
"gemini-2.5-flash": "gemini-3-flash",
|
"gemini-2.5-flash": "gemini-3-flash",
|
||||||
|
|
||||||
// GLM (free + alpha)
|
// GLM (free + alpha)
|
||||||
glm: "glm-4.7-free",
|
glm: "glm-4.7",
|
||||||
"glm-free": "glm-4.7-free",
|
"glm-free": "glm-4.7",
|
||||||
"alpha-glm": "alpha-glm-4.7",
|
"alpha-glm": "alpha-glm-4.7",
|
||||||
|
|
||||||
// MiniMax
|
|
||||||
minimax: "minimax-m2.1-free",
|
|
||||||
"minimax-free": "minimax-m2.1-free",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,7 +130,7 @@ const MODEL_COSTS: Record<
|
|||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
},
|
},
|
||||||
"gpt-5.1": { input: 1.07, output: 8.5, cacheRead: 0.107, cacheWrite: 0 },
|
"gpt-5.1": { input: 1.07, output: 8.5, cacheRead: 0.107, cacheWrite: 0 },
|
||||||
"glm-4.7-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
"glm-4.7": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
"gemini-3-flash": { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0 },
|
"gemini-3-flash": { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0 },
|
||||||
"gpt-5.1-codex-max": {
|
"gpt-5.1-codex-max": {
|
||||||
input: 1.25,
|
input: 1.25,
|
||||||
@@ -142,7 +138,6 @@ const MODEL_COSTS: Record<
|
|||||||
cacheRead: 0.125,
|
cacheRead: 0.125,
|
||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
},
|
},
|
||||||
"minimax-m2.1-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
||||||
"gpt-5.2": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
|
"gpt-5.2": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -155,10 +150,9 @@ const MODEL_CONTEXT_WINDOWS: Record<string, number> = {
|
|||||||
"alpha-glm-4.7": 204800,
|
"alpha-glm-4.7": 204800,
|
||||||
"gpt-5.1-codex-mini": 400000,
|
"gpt-5.1-codex-mini": 400000,
|
||||||
"gpt-5.1": 400000,
|
"gpt-5.1": 400000,
|
||||||
"glm-4.7-free": 204800,
|
"glm-4.7": 204800,
|
||||||
"gemini-3-flash": 1048576,
|
"gemini-3-flash": 1048576,
|
||||||
"gpt-5.1-codex-max": 400000,
|
"gpt-5.1-codex-max": 400000,
|
||||||
"minimax-m2.1-free": 204800,
|
|
||||||
"gpt-5.2": 400000,
|
"gpt-5.2": 400000,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -173,10 +167,9 @@ const MODEL_MAX_TOKENS: Record<string, number> = {
|
|||||||
"alpha-glm-4.7": 131072,
|
"alpha-glm-4.7": 131072,
|
||||||
"gpt-5.1-codex-mini": 128000,
|
"gpt-5.1-codex-mini": 128000,
|
||||||
"gpt-5.1": 128000,
|
"gpt-5.1": 128000,
|
||||||
"glm-4.7-free": 131072,
|
"glm-4.7": 131072,
|
||||||
"gemini-3-flash": 65536,
|
"gemini-3-flash": 65536,
|
||||||
"gpt-5.1-codex-max": 128000,
|
"gpt-5.1-codex-max": 128000,
|
||||||
"minimax-m2.1-free": 131072,
|
|
||||||
"gpt-5.2": 128000,
|
"gpt-5.2": 128000,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -211,10 +204,9 @@ const MODEL_NAMES: Record<string, string> = {
|
|||||||
"alpha-glm-4.7": "Alpha GLM-4.7",
|
"alpha-glm-4.7": "Alpha GLM-4.7",
|
||||||
"gpt-5.1-codex-mini": "GPT-5.1 Codex Mini",
|
"gpt-5.1-codex-mini": "GPT-5.1 Codex Mini",
|
||||||
"gpt-5.1": "GPT-5.1",
|
"gpt-5.1": "GPT-5.1",
|
||||||
"glm-4.7-free": "GLM-4.7",
|
"glm-4.7": "GLM-4.7",
|
||||||
"gemini-3-flash": "Gemini 3 Flash",
|
"gemini-3-flash": "Gemini 3 Flash",
|
||||||
"gpt-5.1-codex-max": "GPT-5.1 Codex Max",
|
"gpt-5.1-codex-max": "GPT-5.1 Codex Max",
|
||||||
"minimax-m2.1-free": "MiniMax M2.1",
|
|
||||||
"gpt-5.2": "GPT-5.2",
|
"gpt-5.2": "GPT-5.2",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -240,10 +232,9 @@ export function getOpencodeZenStaticFallbackModels(): ModelDefinitionConfig[] {
|
|||||||
"alpha-glm-4.7",
|
"alpha-glm-4.7",
|
||||||
"gpt-5.1-codex-mini",
|
"gpt-5.1-codex-mini",
|
||||||
"gpt-5.1",
|
"gpt-5.1",
|
||||||
"glm-4.7-free",
|
"glm-4.7",
|
||||||
"gemini-3-flash",
|
"gemini-3-flash",
|
||||||
"gpt-5.1-codex-max",
|
"gpt-5.1-codex-max",
|
||||||
"minimax-m2.1-free",
|
|
||||||
"gpt-5.2",
|
"gpt-5.2",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import type { EmbeddedRunAttemptResult } from "./pi-embedded-runner/run/types.js";
|
import type { EmbeddedRunAttemptResult } from "./pi-embedded-runner/run/types.js";
|
||||||
@@ -16,13 +16,15 @@ vi.mock("./pi-embedded-runner/run/attempt.js", () => ({
|
|||||||
|
|
||||||
let runEmbeddedPiAgent: typeof import("./pi-embedded-runner.js").runEmbeddedPiAgent;
|
let runEmbeddedPiAgent: typeof import("./pi-embedded-runner.js").runEmbeddedPiAgent;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeAll(async () => {
|
||||||
vi.useRealTimers();
|
|
||||||
vi.resetModules();
|
|
||||||
runEmbeddedAttemptMock.mockReset();
|
|
||||||
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js"));
|
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
runEmbeddedAttemptMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
const baseUsage = {
|
const baseUsage = {
|
||||||
input: 0,
|
input: 0,
|
||||||
output: 0,
|
output: 0,
|
||||||
|
|||||||
@@ -128,6 +128,13 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
`No API key resolved for provider "${model.provider}" (auth mode: ${apiKeyInfo.mode}).`,
|
`No API key resolved for provider "${model.provider}" (auth mode: ${apiKeyInfo.mode}).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else if (model.provider === "github-copilot") {
|
||||||
|
const { resolveCopilotApiToken } =
|
||||||
|
await import("../../providers/github-copilot-token.js");
|
||||||
|
const copilotToken = await resolveCopilotApiToken({
|
||||||
|
githubToken: apiKeyInfo.apiKey,
|
||||||
|
});
|
||||||
|
authStorage.setRuntimeApiKey(model.provider, copilotToken.token);
|
||||||
} else {
|
} else {
|
||||||
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,20 +7,9 @@ import { resolveClawdbotAgentDir } from "../agent-paths.js";
|
|||||||
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
|
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
|
||||||
import { normalizeModelCompat } from "../model-compat.js";
|
import { normalizeModelCompat } from "../model-compat.js";
|
||||||
import { normalizeProviderId } from "../model-selection.js";
|
import { normalizeProviderId } from "../model-selection.js";
|
||||||
import { resolveGithubCopilotUserAgent } from "../../providers/github-copilot-utils.js";
|
|
||||||
|
|
||||||
type InlineModelEntry = ModelDefinitionConfig & { provider: string };
|
type InlineModelEntry = ModelDefinitionConfig & { provider: string };
|
||||||
|
|
||||||
function applyProviderModelOverrides(model: Model<Api>): Model<Api> {
|
|
||||||
if (model.provider === "github-copilot") {
|
|
||||||
const headers = model.headers
|
|
||||||
? { ...model.headers, "User-Agent": resolveGithubCopilotUserAgent() }
|
|
||||||
: { "User-Agent": resolveGithubCopilotUserAgent() };
|
|
||||||
return { ...model, headers };
|
|
||||||
}
|
|
||||||
return model;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildInlineProviderModels(
|
export function buildInlineProviderModels(
|
||||||
providers: Record<string, { models?: ModelDefinitionConfig[] }>,
|
providers: Record<string, { models?: ModelDefinitionConfig[] }>,
|
||||||
): InlineModelEntry[] {
|
): InlineModelEntry[] {
|
||||||
@@ -71,7 +60,7 @@ export function resolveModel(
|
|||||||
if (inlineMatch) {
|
if (inlineMatch) {
|
||||||
const normalized = normalizeModelCompat(inlineMatch as Model<Api>);
|
const normalized = normalizeModelCompat(inlineMatch as Model<Api>);
|
||||||
return {
|
return {
|
||||||
model: applyProviderModelOverrides(normalized),
|
model: normalized,
|
||||||
authStorage,
|
authStorage,
|
||||||
modelRegistry,
|
modelRegistry,
|
||||||
};
|
};
|
||||||
@@ -89,7 +78,7 @@ export function resolveModel(
|
|||||||
contextWindow: providerCfg?.models?.[0]?.contextWindow ?? DEFAULT_CONTEXT_TOKENS,
|
contextWindow: providerCfg?.models?.[0]?.contextWindow ?? DEFAULT_CONTEXT_TOKENS,
|
||||||
maxTokens: providerCfg?.models?.[0]?.maxTokens ?? DEFAULT_CONTEXT_TOKENS,
|
maxTokens: providerCfg?.models?.[0]?.maxTokens ?? DEFAULT_CONTEXT_TOKENS,
|
||||||
} as Model<Api>);
|
} as Model<Api>);
|
||||||
return { model: applyProviderModelOverrides(fallbackModel), authStorage, modelRegistry };
|
return { model: fallbackModel, authStorage, modelRegistry };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
error: `Unknown model: ${provider}/${modelId}`,
|
error: `Unknown model: ${provider}/${modelId}`,
|
||||||
@@ -97,9 +86,5 @@ export function resolveModel(
|
|||||||
modelRegistry,
|
modelRegistry,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return { model: normalizeModelCompat(model), authStorage, modelRegistry };
|
||||||
model: applyProviderModelOverrides(normalizeModelCompat(model)),
|
|
||||||
authStorage,
|
|
||||||
modelRegistry,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
@@ -184,8 +184,17 @@ export async function runEmbeddedPiAgent(
|
|||||||
lastProfileId = resolvedProfileId;
|
lastProfileId = resolvedProfileId;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
if (model.provider === "github-copilot") {
|
||||||
lastProfileId = resolvedProfileId;
|
const { resolveCopilotApiToken } =
|
||||||
|
await import("../../providers/github-copilot-token.js");
|
||||||
|
const copilotToken = await resolveCopilotApiToken({
|
||||||
|
githubToken: apiKeyInfo.apiKey,
|
||||||
|
});
|
||||||
|
authStorage.setRuntimeApiKey(model.provider, copilotToken.token);
|
||||||
|
} else {
|
||||||
|
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
||||||
|
}
|
||||||
|
lastProfileId = apiKeyInfo.profileId;
|
||||||
};
|
};
|
||||||
|
|
||||||
const advanceAuthProfile = async (): Promise<boolean> => {
|
const advanceAuthProfile = async (): Promise<boolean> => {
|
||||||
|
|||||||
-212
@@ -1,212 +0,0 @@
|
|||||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
|
|
||||||
import { createBrowserTool } from "./tools/browser-tool.js";
|
|
||||||
|
|
||||||
describe("createClawdbotCodingTools", () => {
|
|
||||||
describe("Claude/Gemini alias support", () => {
|
|
||||||
it("adds Claude-style aliases to schemas without dropping metadata", () => {
|
|
||||||
const base: AgentTool = {
|
|
||||||
name: "write",
|
|
||||||
description: "test",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
required: ["path", "content"],
|
|
||||||
properties: {
|
|
||||||
path: { type: "string", description: "Path" },
|
|
||||||
content: { type: "string", description: "Body" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
execute: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const patched = __testing.patchToolSchemaForClaudeCompatibility(base);
|
|
||||||
const params = patched.parameters as {
|
|
||||||
properties?: Record<string, unknown>;
|
|
||||||
required?: string[];
|
|
||||||
};
|
|
||||||
const props = params.properties ?? {};
|
|
||||||
|
|
||||||
expect(props.file_path).toEqual(props.path);
|
|
||||||
expect(params.required ?? []).not.toContain("path");
|
|
||||||
expect(params.required ?? []).not.toContain("file_path");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes file_path to path and enforces required groups at runtime", async () => {
|
|
||||||
const execute = vi.fn(async (_id, args) => args);
|
|
||||||
const tool: AgentTool = {
|
|
||||||
name: "write",
|
|
||||||
description: "test",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
required: ["path", "content"],
|
|
||||||
properties: {
|
|
||||||
path: { type: "string" },
|
|
||||||
content: { type: "string" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
execute,
|
|
||||||
};
|
|
||||||
|
|
||||||
const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]);
|
|
||||||
|
|
||||||
await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" });
|
|
||||||
expect(execute).toHaveBeenCalledWith(
|
|
||||||
"tool-1",
|
|
||||||
{ path: "foo.txt", content: "x" },
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
|
|
||||||
/Missing required parameter/,
|
|
||||||
);
|
|
||||||
await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow(
|
|
||||||
/Missing required parameter/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps browser tool schema OpenAI-compatible without normalization", () => {
|
|
||||||
const browser = createBrowserTool();
|
|
||||||
const schema = browser.parameters as { type?: unknown; anyOf?: unknown };
|
|
||||||
expect(schema.type).toBe("object");
|
|
||||||
expect(schema.anyOf).toBeUndefined();
|
|
||||||
});
|
|
||||||
it("mentions Chrome extension relay in browser tool description", () => {
|
|
||||||
const browser = createBrowserTool();
|
|
||||||
expect(browser.description).toMatch(/Chrome extension/i);
|
|
||||||
expect(browser.description).toMatch(/profile="chrome"/i);
|
|
||||||
});
|
|
||||||
it("keeps browser tool schema properties after normalization", () => {
|
|
||||||
const tools = createClawdbotCodingTools();
|
|
||||||
const browser = tools.find((tool) => tool.name === "browser");
|
|
||||||
expect(browser).toBeDefined();
|
|
||||||
const parameters = browser?.parameters as {
|
|
||||||
anyOf?: unknown[];
|
|
||||||
properties?: Record<string, unknown>;
|
|
||||||
required?: string[];
|
|
||||||
};
|
|
||||||
expect(parameters.properties?.action).toBeDefined();
|
|
||||||
expect(parameters.properties?.target).toBeDefined();
|
|
||||||
expect(parameters.properties?.controlUrl).toBeDefined();
|
|
||||||
expect(parameters.properties?.targetUrl).toBeDefined();
|
|
||||||
expect(parameters.properties?.request).toBeDefined();
|
|
||||||
expect(parameters.required ?? []).toContain("action");
|
|
||||||
});
|
|
||||||
it("exposes raw for gateway config.apply tool calls", () => {
|
|
||||||
const tools = createClawdbotCodingTools();
|
|
||||||
const gateway = tools.find((tool) => tool.name === "gateway");
|
|
||||||
expect(gateway).toBeDefined();
|
|
||||||
|
|
||||||
const parameters = gateway?.parameters as {
|
|
||||||
type?: unknown;
|
|
||||||
required?: string[];
|
|
||||||
properties?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
expect(parameters.type).toBe("object");
|
|
||||||
expect(parameters.properties?.raw).toBeDefined();
|
|
||||||
expect(parameters.required ?? []).not.toContain("raw");
|
|
||||||
});
|
|
||||||
it("flattens anyOf-of-literals to enum for provider compatibility", () => {
|
|
||||||
const tools = createClawdbotCodingTools();
|
|
||||||
const browser = tools.find((tool) => tool.name === "browser");
|
|
||||||
expect(browser).toBeDefined();
|
|
||||||
|
|
||||||
const parameters = browser?.parameters as {
|
|
||||||
properties?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
const action = parameters.properties?.action as
|
|
||||||
| {
|
|
||||||
type?: unknown;
|
|
||||||
enum?: unknown[];
|
|
||||||
anyOf?: unknown[];
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
expect(action?.type).toBe("string");
|
|
||||||
expect(action?.anyOf).toBeUndefined();
|
|
||||||
expect(Array.isArray(action?.enum)).toBe(true);
|
|
||||||
expect(action?.enum).toContain("act");
|
|
||||||
|
|
||||||
const snapshotFormat = parameters.properties?.snapshotFormat as
|
|
||||||
| {
|
|
||||||
type?: unknown;
|
|
||||||
enum?: unknown[];
|
|
||||||
anyOf?: unknown[];
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
expect(snapshotFormat?.type).toBe("string");
|
|
||||||
expect(snapshotFormat?.anyOf).toBeUndefined();
|
|
||||||
expect(snapshotFormat?.enum).toEqual(["aria", "ai"]);
|
|
||||||
});
|
|
||||||
it("inlines local $ref before removing unsupported keywords", () => {
|
|
||||||
const cleaned = __testing.cleanToolSchemaForGemini({
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
foo: { $ref: "#/$defs/Foo" },
|
|
||||||
},
|
|
||||||
$defs: {
|
|
||||||
Foo: { type: "string", enum: ["a", "b"] },
|
|
||||||
},
|
|
||||||
}) as {
|
|
||||||
$defs?: unknown;
|
|
||||||
properties?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(cleaned.$defs).toBeUndefined();
|
|
||||||
expect(cleaned.properties).toBeDefined();
|
|
||||||
expect(cleaned.properties?.foo).toMatchObject({
|
|
||||||
type: "string",
|
|
||||||
enum: ["a", "b"],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
it("cleans tuple items schemas", () => {
|
|
||||||
const cleaned = __testing.cleanToolSchemaForGemini({
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
tuples: {
|
|
||||||
type: "array",
|
|
||||||
items: [
|
|
||||||
{ type: "string", format: "uuid" },
|
|
||||||
{ type: "number", minimum: 1 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}) as {
|
|
||||||
properties?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const tuples = cleaned.properties?.tuples as { items?: unknown } | undefined;
|
|
||||||
const items = Array.isArray(tuples?.items) ? tuples?.items : [];
|
|
||||||
const first = items[0] as { format?: unknown } | undefined;
|
|
||||||
const second = items[1] as { minimum?: unknown } | undefined;
|
|
||||||
|
|
||||||
expect(first?.format).toBeUndefined();
|
|
||||||
expect(second?.minimum).toBeUndefined();
|
|
||||||
});
|
|
||||||
it("drops null-only union variants without flattening other unions", () => {
|
|
||||||
const cleaned = __testing.cleanToolSchemaForGemini({
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
parentId: { anyOf: [{ type: "string" }, { type: "null" }] },
|
|
||||||
count: { oneOf: [{ type: "string" }, { type: "number" }] },
|
|
||||||
},
|
|
||||||
}) as {
|
|
||||||
properties?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const parentId = cleaned.properties?.parentId as
|
|
||||||
| { type?: unknown; anyOf?: unknown; oneOf?: unknown }
|
|
||||||
| undefined;
|
|
||||||
expect(parentId?.anyOf).toBeUndefined();
|
|
||||||
expect(parentId?.oneOf).toBeUndefined();
|
|
||||||
expect(parentId?.type).toBe("string");
|
|
||||||
|
|
||||||
const count = cleaned.properties?.count as
|
|
||||||
| { type?: unknown; anyOf?: unknown; oneOf?: unknown }
|
|
||||||
| undefined;
|
|
||||||
expect(count?.anyOf).toBeUndefined();
|
|
||||||
expect(Array.isArray(count?.oneOf)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+8
-72
@@ -1,74 +1,11 @@
|
|||||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
import { describe, expect, it } from "vitest";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
|
import { createClawdbotCodingTools } from "./pi-tools.js";
|
||||||
|
|
||||||
|
const defaultTools = createClawdbotCodingTools();
|
||||||
|
|
||||||
describe("createClawdbotCodingTools", () => {
|
describe("createClawdbotCodingTools", () => {
|
||||||
describe("Claude/Gemini alias support", () => {
|
|
||||||
it("adds Claude-style aliases to schemas without dropping metadata", () => {
|
|
||||||
const base: AgentTool = {
|
|
||||||
name: "write",
|
|
||||||
description: "test",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
required: ["path", "content"],
|
|
||||||
properties: {
|
|
||||||
path: { type: "string", description: "Path" },
|
|
||||||
content: { type: "string", description: "Body" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
execute: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const patched = __testing.patchToolSchemaForClaudeCompatibility(base);
|
|
||||||
const params = patched.parameters as {
|
|
||||||
properties?: Record<string, unknown>;
|
|
||||||
required?: string[];
|
|
||||||
};
|
|
||||||
const props = params.properties ?? {};
|
|
||||||
|
|
||||||
expect(props.file_path).toEqual(props.path);
|
|
||||||
expect(params.required ?? []).not.toContain("path");
|
|
||||||
expect(params.required ?? []).not.toContain("file_path");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes file_path to path and enforces required groups at runtime", async () => {
|
|
||||||
const execute = vi.fn(async (_id, args) => args);
|
|
||||||
const tool: AgentTool = {
|
|
||||||
name: "write",
|
|
||||||
description: "test",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
required: ["path", "content"],
|
|
||||||
properties: {
|
|
||||||
path: { type: "string" },
|
|
||||||
content: { type: "string" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
execute,
|
|
||||||
};
|
|
||||||
|
|
||||||
const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]);
|
|
||||||
|
|
||||||
await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" });
|
|
||||||
expect(execute).toHaveBeenCalledWith(
|
|
||||||
"tool-1",
|
|
||||||
{ path: "foo.txt", content: "x" },
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
|
|
||||||
/Missing required parameter/,
|
|
||||||
);
|
|
||||||
await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow(
|
|
||||||
/Missing required parameter/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves action enums in normalized schemas", () => {
|
it("preserves action enums in normalized schemas", () => {
|
||||||
const tools = createClawdbotCodingTools();
|
|
||||||
const toolNames = ["browser", "canvas", "nodes", "cron", "gateway", "message"];
|
const toolNames = ["browser", "canvas", "nodes", "cron", "gateway", "message"];
|
||||||
|
|
||||||
const collectActionValues = (schema: unknown, values: Set<string>): void => {
|
const collectActionValues = (schema: unknown, values: Set<string>): void => {
|
||||||
@@ -88,7 +25,7 @@ describe("createClawdbotCodingTools", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (const name of toolNames) {
|
for (const name of toolNames) {
|
||||||
const tool = tools.find((candidate) => candidate.name === name);
|
const tool = defaultTools.find((candidate) => candidate.name === name);
|
||||||
expect(tool).toBeDefined();
|
expect(tool).toBeDefined();
|
||||||
const parameters = tool?.parameters as {
|
const parameters = tool?.parameters as {
|
||||||
properties?: Record<string, unknown>;
|
properties?: Record<string, unknown>;
|
||||||
@@ -108,10 +45,9 @@ describe("createClawdbotCodingTools", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
it("includes exec and process tools by default", () => {
|
it("includes exec and process tools by default", () => {
|
||||||
const tools = createClawdbotCodingTools();
|
expect(defaultTools.some((tool) => tool.name === "exec")).toBe(true);
|
||||||
expect(tools.some((tool) => tool.name === "exec")).toBe(true);
|
expect(defaultTools.some((tool) => tool.name === "process")).toBe(true);
|
||||||
expect(tools.some((tool) => tool.name === "process")).toBe(true);
|
expect(defaultTools.some((tool) => tool.name === "apply_patch")).toBe(false);
|
||||||
expect(tools.some((tool) => tool.name === "apply_patch")).toBe(false);
|
|
||||||
});
|
});
|
||||||
it("gates apply_patch behind tools.exec.applyPatch for OpenAI models", () => {
|
it("gates apply_patch behind tools.exec.applyPatch for OpenAI models", () => {
|
||||||
const config: ClawdbotConfig = {
|
const config: ClawdbotConfig = {
|
||||||
|
|||||||
+5
-68
@@ -1,78 +1,15 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
|
import { createClawdbotCodingTools } from "./pi-tools.js";
|
||||||
|
|
||||||
|
const defaultTools = createClawdbotCodingTools();
|
||||||
|
|
||||||
describe("createClawdbotCodingTools", () => {
|
describe("createClawdbotCodingTools", () => {
|
||||||
describe("Claude/Gemini alias support", () => {
|
|
||||||
it("adds Claude-style aliases to schemas without dropping metadata", () => {
|
|
||||||
const base: AgentTool = {
|
|
||||||
name: "write",
|
|
||||||
description: "test",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
required: ["path", "content"],
|
|
||||||
properties: {
|
|
||||||
path: { type: "string", description: "Path" },
|
|
||||||
content: { type: "string", description: "Body" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
execute: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const patched = __testing.patchToolSchemaForClaudeCompatibility(base);
|
|
||||||
const params = patched.parameters as {
|
|
||||||
properties?: Record<string, unknown>;
|
|
||||||
required?: string[];
|
|
||||||
};
|
|
||||||
const props = params.properties ?? {};
|
|
||||||
|
|
||||||
expect(props.file_path).toEqual(props.path);
|
|
||||||
expect(params.required ?? []).not.toContain("path");
|
|
||||||
expect(params.required ?? []).not.toContain("file_path");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes file_path to path and enforces required groups at runtime", async () => {
|
|
||||||
const execute = vi.fn(async (_id, args) => args);
|
|
||||||
const tool: AgentTool = {
|
|
||||||
name: "write",
|
|
||||||
description: "test",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
required: ["path", "content"],
|
|
||||||
properties: {
|
|
||||||
path: { type: "string" },
|
|
||||||
content: { type: "string" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
execute,
|
|
||||||
};
|
|
||||||
|
|
||||||
const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]);
|
|
||||||
|
|
||||||
await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" });
|
|
||||||
expect(execute).toHaveBeenCalledWith(
|
|
||||||
"tool-1",
|
|
||||||
{ path: "foo.txt", content: "x" },
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
|
|
||||||
/Missing required parameter/,
|
|
||||||
);
|
|
||||||
await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow(
|
|
||||||
/Missing required parameter/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps read tool image metadata intact", async () => {
|
it("keeps read tool image metadata intact", async () => {
|
||||||
const tools = createClawdbotCodingTools();
|
const readTool = defaultTools.find((tool) => tool.name === "read");
|
||||||
const readTool = tools.find((tool) => tool.name === "read");
|
|
||||||
expect(readTool).toBeDefined();
|
expect(readTool).toBeDefined();
|
||||||
|
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-read-"));
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-read-"));
|
||||||
|
|||||||
-183
@@ -1,183 +0,0 @@
|
|||||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
|
|
||||||
|
|
||||||
describe("createClawdbotCodingTools", () => {
|
|
||||||
describe("Claude/Gemini alias support", () => {
|
|
||||||
it("adds Claude-style aliases to schemas without dropping metadata", () => {
|
|
||||||
const base: AgentTool = {
|
|
||||||
name: "write",
|
|
||||||
description: "test",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
required: ["path", "content"],
|
|
||||||
properties: {
|
|
||||||
path: { type: "string", description: "Path" },
|
|
||||||
content: { type: "string", description: "Body" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
execute: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const patched = __testing.patchToolSchemaForClaudeCompatibility(base);
|
|
||||||
const params = patched.parameters as {
|
|
||||||
properties?: Record<string, unknown>;
|
|
||||||
required?: string[];
|
|
||||||
};
|
|
||||||
const props = params.properties ?? {};
|
|
||||||
|
|
||||||
expect(props.file_path).toEqual(props.path);
|
|
||||||
expect(params.required ?? []).not.toContain("path");
|
|
||||||
expect(params.required ?? []).not.toContain("file_path");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes file_path to path and enforces required groups at runtime", async () => {
|
|
||||||
const execute = vi.fn(async (_id, args) => args);
|
|
||||||
const tool: AgentTool = {
|
|
||||||
name: "write",
|
|
||||||
description: "test",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
required: ["path", "content"],
|
|
||||||
properties: {
|
|
||||||
path: { type: "string" },
|
|
||||||
content: { type: "string" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
execute,
|
|
||||||
};
|
|
||||||
|
|
||||||
const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]);
|
|
||||||
|
|
||||||
await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" });
|
|
||||||
expect(execute).toHaveBeenCalledWith(
|
|
||||||
"tool-1",
|
|
||||||
{ path: "foo.txt", content: "x" },
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
|
|
||||||
/Missing required parameter/,
|
|
||||||
);
|
|
||||||
await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow(
|
|
||||||
/Missing required parameter/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("applies tool profiles before allow/deny policies", () => {
|
|
||||||
const tools = createClawdbotCodingTools({
|
|
||||||
config: { tools: { profile: "messaging" } },
|
|
||||||
});
|
|
||||||
const names = new Set(tools.map((tool) => tool.name));
|
|
||||||
expect(names.has("message")).toBe(true);
|
|
||||||
expect(names.has("sessions_send")).toBe(true);
|
|
||||||
expect(names.has("sessions_spawn")).toBe(false);
|
|
||||||
expect(names.has("exec")).toBe(false);
|
|
||||||
expect(names.has("browser")).toBe(false);
|
|
||||||
});
|
|
||||||
it("expands group shorthands in global tool policy", () => {
|
|
||||||
const tools = createClawdbotCodingTools({
|
|
||||||
config: { tools: { allow: ["group:fs"] } },
|
|
||||||
});
|
|
||||||
const names = new Set(tools.map((tool) => tool.name));
|
|
||||||
expect(names.has("read")).toBe(true);
|
|
||||||
expect(names.has("write")).toBe(true);
|
|
||||||
expect(names.has("edit")).toBe(true);
|
|
||||||
expect(names.has("exec")).toBe(false);
|
|
||||||
expect(names.has("browser")).toBe(false);
|
|
||||||
});
|
|
||||||
it("expands group shorthands in global tool deny policy", () => {
|
|
||||||
const tools = createClawdbotCodingTools({
|
|
||||||
config: { tools: { deny: ["group:fs"] } },
|
|
||||||
});
|
|
||||||
const names = new Set(tools.map((tool) => tool.name));
|
|
||||||
expect(names.has("read")).toBe(false);
|
|
||||||
expect(names.has("write")).toBe(false);
|
|
||||||
expect(names.has("edit")).toBe(false);
|
|
||||||
expect(names.has("exec")).toBe(true);
|
|
||||||
});
|
|
||||||
it("lets agent profiles override global profiles", () => {
|
|
||||||
const tools = createClawdbotCodingTools({
|
|
||||||
sessionKey: "agent:work:main",
|
|
||||||
config: {
|
|
||||||
tools: { profile: "coding" },
|
|
||||||
agents: {
|
|
||||||
list: [{ id: "work", tools: { profile: "messaging" } }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const names = new Set(tools.map((tool) => tool.name));
|
|
||||||
expect(names.has("message")).toBe(true);
|
|
||||||
expect(names.has("exec")).toBe(false);
|
|
||||||
expect(names.has("read")).toBe(false);
|
|
||||||
});
|
|
||||||
it("removes unsupported JSON Schema keywords for Cloud Code Assist API compatibility", () => {
|
|
||||||
const tools = createClawdbotCodingTools();
|
|
||||||
|
|
||||||
// Helper to recursively check schema for unsupported keywords
|
|
||||||
const unsupportedKeywords = new Set([
|
|
||||||
"patternProperties",
|
|
||||||
"additionalProperties",
|
|
||||||
"$schema",
|
|
||||||
"$id",
|
|
||||||
"$ref",
|
|
||||||
"$defs",
|
|
||||||
"definitions",
|
|
||||||
"examples",
|
|
||||||
"minLength",
|
|
||||||
"maxLength",
|
|
||||||
"minimum",
|
|
||||||
"maximum",
|
|
||||||
"multipleOf",
|
|
||||||
"pattern",
|
|
||||||
"format",
|
|
||||||
"minItems",
|
|
||||||
"maxItems",
|
|
||||||
"uniqueItems",
|
|
||||||
"minProperties",
|
|
||||||
"maxProperties",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const findUnsupportedKeywords = (schema: unknown, path: string): string[] => {
|
|
||||||
const found: string[] = [];
|
|
||||||
if (!schema || typeof schema !== "object") return found;
|
|
||||||
if (Array.isArray(schema)) {
|
|
||||||
schema.forEach((item, i) => {
|
|
||||||
found.push(...findUnsupportedKeywords(item, `${path}[${i}]`));
|
|
||||||
});
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
|
|
||||||
const record = schema as Record<string, unknown>;
|
|
||||||
const properties =
|
|
||||||
record.properties &&
|
|
||||||
typeof record.properties === "object" &&
|
|
||||||
!Array.isArray(record.properties)
|
|
||||||
? (record.properties as Record<string, unknown>)
|
|
||||||
: undefined;
|
|
||||||
if (properties) {
|
|
||||||
for (const [key, value] of Object.entries(properties)) {
|
|
||||||
found.push(...findUnsupportedKeywords(value, `${path}.properties.${key}`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(record)) {
|
|
||||||
if (key === "properties") continue;
|
|
||||||
if (unsupportedKeywords.has(key)) {
|
|
||||||
found.push(`${path}.${key}`);
|
|
||||||
}
|
|
||||||
if (value && typeof value === "object") {
|
|
||||||
found.push(...findUnsupportedKeywords(value, `${path}.${key}`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return found;
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const tool of tools) {
|
|
||||||
const violations = findUnsupportedKeywords(tool.parameters, `${tool.name}.parameters`);
|
|
||||||
expect(violations).toEqual([]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+2
-66
@@ -1,74 +1,10 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
import { describe, expect, it } from "vitest";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { createClawdbotCodingTools } from "./pi-tools.js";
|
||||||
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
|
|
||||||
|
|
||||||
describe("createClawdbotCodingTools", () => {
|
describe("createClawdbotCodingTools", () => {
|
||||||
describe("Claude/Gemini alias support", () => {
|
|
||||||
it("adds Claude-style aliases to schemas without dropping metadata", () => {
|
|
||||||
const base: AgentTool = {
|
|
||||||
name: "write",
|
|
||||||
description: "test",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
required: ["path", "content"],
|
|
||||||
properties: {
|
|
||||||
path: { type: "string", description: "Path" },
|
|
||||||
content: { type: "string", description: "Body" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
execute: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const patched = __testing.patchToolSchemaForClaudeCompatibility(base);
|
|
||||||
const params = patched.parameters as {
|
|
||||||
properties?: Record<string, unknown>;
|
|
||||||
required?: string[];
|
|
||||||
};
|
|
||||||
const props = params.properties ?? {};
|
|
||||||
|
|
||||||
expect(props.file_path).toEqual(props.path);
|
|
||||||
expect(params.required ?? []).not.toContain("path");
|
|
||||||
expect(params.required ?? []).not.toContain("file_path");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes file_path to path and enforces required groups at runtime", async () => {
|
|
||||||
const execute = vi.fn(async (_id, args) => args);
|
|
||||||
const tool: AgentTool = {
|
|
||||||
name: "write",
|
|
||||||
description: "test",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
required: ["path", "content"],
|
|
||||||
properties: {
|
|
||||||
path: { type: "string" },
|
|
||||||
content: { type: "string" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
execute,
|
|
||||||
};
|
|
||||||
|
|
||||||
const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]);
|
|
||||||
|
|
||||||
await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" });
|
|
||||||
expect(execute).toHaveBeenCalledWith(
|
|
||||||
"tool-1",
|
|
||||||
{ path: "foo.txt", content: "x" },
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
|
|
||||||
/Missing required parameter/,
|
|
||||||
);
|
|
||||||
await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow(
|
|
||||||
/Missing required parameter/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses workspaceDir for Read tool path resolution", async () => {
|
it("uses workspaceDir for Read tool path resolution", async () => {
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ws-"));
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ws-"));
|
||||||
try {
|
try {
|
||||||
|
|||||||
-95
@@ -1,95 +0,0 @@
|
|||||||
import fs from "node:fs/promises";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
|
|
||||||
import { createSandboxedReadTool } from "./pi-tools.read.js";
|
|
||||||
|
|
||||||
describe("createClawdbotCodingTools", () => {
|
|
||||||
describe("Claude/Gemini alias support", () => {
|
|
||||||
it("adds Claude-style aliases to schemas without dropping metadata", () => {
|
|
||||||
const base: AgentTool = {
|
|
||||||
name: "write",
|
|
||||||
description: "test",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
required: ["path", "content"],
|
|
||||||
properties: {
|
|
||||||
path: { type: "string", description: "Path" },
|
|
||||||
content: { type: "string", description: "Body" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
execute: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const patched = __testing.patchToolSchemaForClaudeCompatibility(base);
|
|
||||||
const params = patched.parameters as {
|
|
||||||
properties?: Record<string, unknown>;
|
|
||||||
required?: string[];
|
|
||||||
};
|
|
||||||
const props = params.properties ?? {};
|
|
||||||
|
|
||||||
expect(props.file_path).toEqual(props.path);
|
|
||||||
expect(params.required ?? []).not.toContain("path");
|
|
||||||
expect(params.required ?? []).not.toContain("file_path");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes file_path to path and enforces required groups at runtime", async () => {
|
|
||||||
const execute = vi.fn(async (_id, args) => args);
|
|
||||||
const tool: AgentTool = {
|
|
||||||
name: "write",
|
|
||||||
description: "test",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
required: ["path", "content"],
|
|
||||||
properties: {
|
|
||||||
path: { type: "string" },
|
|
||||||
content: { type: "string" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
execute,
|
|
||||||
};
|
|
||||||
|
|
||||||
const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]);
|
|
||||||
|
|
||||||
await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" });
|
|
||||||
expect(execute).toHaveBeenCalledWith(
|
|
||||||
"tool-1",
|
|
||||||
{ path: "foo.txt", content: "x" },
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
|
|
||||||
/Missing required parameter/,
|
|
||||||
);
|
|
||||||
await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow(
|
|
||||||
/Missing required parameter/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("applies sandbox path guards to file_path alias", async () => {
|
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sbx-"));
|
|
||||||
const outsidePath = path.join(os.tmpdir(), "clawdbot-outside.txt");
|
|
||||||
await fs.writeFile(outsidePath, "outside", "utf8");
|
|
||||||
try {
|
|
||||||
const readTool = createSandboxedReadTool(tmpDir);
|
|
||||||
await expect(readTool.execute("tool-sbx-1", { file_path: outsidePath })).rejects.toThrow();
|
|
||||||
} finally {
|
|
||||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
||||||
await fs.rm(outsidePath, { force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
it("falls back to process.cwd() when workspaceDir not provided", () => {
|
|
||||||
const prevCwd = process.cwd();
|
|
||||||
const tools = createClawdbotCodingTools();
|
|
||||||
// Tools should be created without error
|
|
||||||
expect(tools.some((tool) => tool.name === "read")).toBe(true);
|
|
||||||
expect(tools.some((tool) => tool.name === "write")).toBe(true);
|
|
||||||
expect(tools.some((tool) => tool.name === "edit")).toBe(true);
|
|
||||||
// cwd should be unchanged
|
|
||||||
expect(process.cwd()).toBe(prevCwd);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+272
-2
@@ -1,7 +1,14 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||||
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
|
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
|
||||||
|
import { createSandboxedReadTool } from "./pi-tools.read.js";
|
||||||
|
import { createBrowserTool } from "./tools/browser-tool.js";
|
||||||
|
|
||||||
|
const defaultTools = createClawdbotCodingTools();
|
||||||
|
|
||||||
describe("createClawdbotCodingTools", () => {
|
describe("createClawdbotCodingTools", () => {
|
||||||
describe("Claude/Gemini alias support", () => {
|
describe("Claude/Gemini alias support", () => {
|
||||||
@@ -67,8 +74,144 @@ describe("createClawdbotCodingTools", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps browser tool schema OpenAI-compatible without normalization", () => {
|
||||||
|
const browser = createBrowserTool();
|
||||||
|
const schema = browser.parameters as { type?: unknown; anyOf?: unknown };
|
||||||
|
expect(schema.type).toBe("object");
|
||||||
|
expect(schema.anyOf).toBeUndefined();
|
||||||
|
});
|
||||||
|
it("mentions Chrome extension relay in browser tool description", () => {
|
||||||
|
const browser = createBrowserTool();
|
||||||
|
expect(browser.description).toMatch(/Chrome extension/i);
|
||||||
|
expect(browser.description).toMatch(/profile="chrome"/i);
|
||||||
|
});
|
||||||
|
it("keeps browser tool schema properties after normalization", () => {
|
||||||
|
const browser = defaultTools.find((tool) => tool.name === "browser");
|
||||||
|
expect(browser).toBeDefined();
|
||||||
|
const parameters = browser?.parameters as {
|
||||||
|
anyOf?: unknown[];
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
required?: string[];
|
||||||
|
};
|
||||||
|
expect(parameters.properties?.action).toBeDefined();
|
||||||
|
expect(parameters.properties?.target).toBeDefined();
|
||||||
|
expect(parameters.properties?.controlUrl).toBeDefined();
|
||||||
|
expect(parameters.properties?.targetUrl).toBeDefined();
|
||||||
|
expect(parameters.properties?.request).toBeDefined();
|
||||||
|
expect(parameters.required ?? []).toContain("action");
|
||||||
|
});
|
||||||
|
it("exposes raw for gateway config.apply tool calls", () => {
|
||||||
|
const gateway = defaultTools.find((tool) => tool.name === "gateway");
|
||||||
|
expect(gateway).toBeDefined();
|
||||||
|
|
||||||
|
const parameters = gateway?.parameters as {
|
||||||
|
type?: unknown;
|
||||||
|
required?: string[];
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
expect(parameters.type).toBe("object");
|
||||||
|
expect(parameters.properties?.raw).toBeDefined();
|
||||||
|
expect(parameters.required ?? []).not.toContain("raw");
|
||||||
|
});
|
||||||
|
it("flattens anyOf-of-literals to enum for provider compatibility", () => {
|
||||||
|
const browser = defaultTools.find((tool) => tool.name === "browser");
|
||||||
|
expect(browser).toBeDefined();
|
||||||
|
|
||||||
|
const parameters = browser?.parameters as {
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
const action = parameters.properties?.action as
|
||||||
|
| {
|
||||||
|
type?: unknown;
|
||||||
|
enum?: unknown[];
|
||||||
|
anyOf?: unknown[];
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
expect(action?.type).toBe("string");
|
||||||
|
expect(action?.anyOf).toBeUndefined();
|
||||||
|
expect(Array.isArray(action?.enum)).toBe(true);
|
||||||
|
expect(action?.enum).toContain("act");
|
||||||
|
|
||||||
|
const snapshotFormat = parameters.properties?.snapshotFormat as
|
||||||
|
| {
|
||||||
|
type?: unknown;
|
||||||
|
enum?: unknown[];
|
||||||
|
anyOf?: unknown[];
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
expect(snapshotFormat?.type).toBe("string");
|
||||||
|
expect(snapshotFormat?.anyOf).toBeUndefined();
|
||||||
|
expect(snapshotFormat?.enum).toEqual(["aria", "ai"]);
|
||||||
|
});
|
||||||
|
it("inlines local $ref before removing unsupported keywords", () => {
|
||||||
|
const cleaned = __testing.cleanToolSchemaForGemini({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
foo: { $ref: "#/$defs/Foo" },
|
||||||
|
},
|
||||||
|
$defs: {
|
||||||
|
Foo: { type: "string", enum: ["a", "b"] },
|
||||||
|
},
|
||||||
|
}) as {
|
||||||
|
$defs?: unknown;
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(cleaned.$defs).toBeUndefined();
|
||||||
|
expect(cleaned.properties).toBeDefined();
|
||||||
|
expect(cleaned.properties?.foo).toMatchObject({
|
||||||
|
type: "string",
|
||||||
|
enum: ["a", "b"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("cleans tuple items schemas", () => {
|
||||||
|
const cleaned = __testing.cleanToolSchemaForGemini({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
tuples: {
|
||||||
|
type: "array",
|
||||||
|
items: [
|
||||||
|
{ type: "string", format: "uuid" },
|
||||||
|
{ type: "number", minimum: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}) as {
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tuples = cleaned.properties?.tuples as { items?: unknown } | undefined;
|
||||||
|
const items = Array.isArray(tuples?.items) ? tuples?.items : [];
|
||||||
|
const first = items[0] as { format?: unknown } | undefined;
|
||||||
|
const second = items[1] as { minimum?: unknown } | undefined;
|
||||||
|
|
||||||
|
expect(first?.format).toBeUndefined();
|
||||||
|
expect(second?.minimum).toBeUndefined();
|
||||||
|
});
|
||||||
|
it("drops null-only union variants without flattening other unions", () => {
|
||||||
|
const cleaned = __testing.cleanToolSchemaForGemini({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
parentId: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||||
|
count: { oneOf: [{ type: "string" }, { type: "number" }] },
|
||||||
|
},
|
||||||
|
}) as {
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parentId = cleaned.properties?.parentId as
|
||||||
|
| { type?: unknown; anyOf?: unknown; oneOf?: unknown }
|
||||||
|
| undefined;
|
||||||
|
const count = cleaned.properties?.count as
|
||||||
|
| { type?: unknown; anyOf?: unknown; oneOf?: unknown }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
expect(parentId?.type).toBe("string");
|
||||||
|
expect(parentId?.anyOf).toBeUndefined();
|
||||||
|
expect(count?.oneOf).toBeDefined();
|
||||||
|
});
|
||||||
it("avoids anyOf/oneOf/allOf in tool schemas", () => {
|
it("avoids anyOf/oneOf/allOf in tool schemas", () => {
|
||||||
const tools = createClawdbotCodingTools();
|
|
||||||
const offenders: Array<{
|
const offenders: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
keyword: string;
|
keyword: string;
|
||||||
@@ -96,7 +239,7 @@ describe("createClawdbotCodingTools", () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const tool of tools) {
|
for (const tool of defaultTools) {
|
||||||
walk(tool.parameters, "", tool.name);
|
walk(tool.parameters, "", tool.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,4 +335,131 @@ describe("createClawdbotCodingTools", () => {
|
|||||||
});
|
});
|
||||||
expect(tools.map((tool) => tool.name)).toEqual(["read"]);
|
expect(tools.map((tool) => tool.name)).toEqual(["read"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies tool profiles before allow/deny policies", () => {
|
||||||
|
const tools = createClawdbotCodingTools({
|
||||||
|
config: { tools: { profile: "messaging" } },
|
||||||
|
});
|
||||||
|
const names = new Set(tools.map((tool) => tool.name));
|
||||||
|
expect(names.has("message")).toBe(true);
|
||||||
|
expect(names.has("sessions_send")).toBe(true);
|
||||||
|
expect(names.has("sessions_spawn")).toBe(false);
|
||||||
|
expect(names.has("exec")).toBe(false);
|
||||||
|
expect(names.has("browser")).toBe(false);
|
||||||
|
});
|
||||||
|
it("expands group shorthands in global tool policy", () => {
|
||||||
|
const tools = createClawdbotCodingTools({
|
||||||
|
config: { tools: { allow: ["group:fs"] } },
|
||||||
|
});
|
||||||
|
const names = new Set(tools.map((tool) => tool.name));
|
||||||
|
expect(names.has("read")).toBe(true);
|
||||||
|
expect(names.has("write")).toBe(true);
|
||||||
|
expect(names.has("edit")).toBe(true);
|
||||||
|
expect(names.has("exec")).toBe(false);
|
||||||
|
expect(names.has("browser")).toBe(false);
|
||||||
|
});
|
||||||
|
it("expands group shorthands in global tool deny policy", () => {
|
||||||
|
const tools = createClawdbotCodingTools({
|
||||||
|
config: { tools: { deny: ["group:fs"] } },
|
||||||
|
});
|
||||||
|
const names = new Set(tools.map((tool) => tool.name));
|
||||||
|
expect(names.has("read")).toBe(false);
|
||||||
|
expect(names.has("write")).toBe(false);
|
||||||
|
expect(names.has("edit")).toBe(false);
|
||||||
|
expect(names.has("exec")).toBe(true);
|
||||||
|
});
|
||||||
|
it("lets agent profiles override global profiles", () => {
|
||||||
|
const tools = createClawdbotCodingTools({
|
||||||
|
sessionKey: "agent:work:main",
|
||||||
|
config: {
|
||||||
|
tools: { profile: "coding" },
|
||||||
|
agents: {
|
||||||
|
list: [{ id: "work", tools: { profile: "messaging" } }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const names = new Set(tools.map((tool) => tool.name));
|
||||||
|
expect(names.has("message")).toBe(true);
|
||||||
|
expect(names.has("exec")).toBe(false);
|
||||||
|
expect(names.has("read")).toBe(false);
|
||||||
|
});
|
||||||
|
it("removes unsupported JSON Schema keywords for Cloud Code Assist API compatibility", () => {
|
||||||
|
// Helper to recursively check schema for unsupported keywords
|
||||||
|
const unsupportedKeywords = new Set([
|
||||||
|
"patternProperties",
|
||||||
|
"additionalProperties",
|
||||||
|
"$schema",
|
||||||
|
"$id",
|
||||||
|
"$ref",
|
||||||
|
"$defs",
|
||||||
|
"definitions",
|
||||||
|
"examples",
|
||||||
|
"minLength",
|
||||||
|
"maxLength",
|
||||||
|
"minimum",
|
||||||
|
"maximum",
|
||||||
|
"multipleOf",
|
||||||
|
"pattern",
|
||||||
|
"format",
|
||||||
|
"minItems",
|
||||||
|
"maxItems",
|
||||||
|
"uniqueItems",
|
||||||
|
"minProperties",
|
||||||
|
"maxProperties",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const findUnsupportedKeywords = (schema: unknown, path: string): string[] => {
|
||||||
|
const found: string[] = [];
|
||||||
|
if (!schema || typeof schema !== "object") return found;
|
||||||
|
if (Array.isArray(schema)) {
|
||||||
|
schema.forEach((item, i) => {
|
||||||
|
found.push(...findUnsupportedKeywords(item, `${path}[${i}]`));
|
||||||
|
});
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = schema as Record<string, unknown>;
|
||||||
|
const properties =
|
||||||
|
record.properties &&
|
||||||
|
typeof record.properties === "object" &&
|
||||||
|
!Array.isArray(record.properties)
|
||||||
|
? (record.properties as Record<string, unknown>)
|
||||||
|
: undefined;
|
||||||
|
if (properties) {
|
||||||
|
for (const [key, value] of Object.entries(properties)) {
|
||||||
|
found.push(...findUnsupportedKeywords(value, `${path}.properties.${key}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(record)) {
|
||||||
|
if (key === "properties") continue;
|
||||||
|
if (unsupportedKeywords.has(key)) {
|
||||||
|
found.push(`${path}.${key}`);
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
found.push(...findUnsupportedKeywords(value, `${path}.${key}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const tool of defaultTools) {
|
||||||
|
const violations = findUnsupportedKeywords(tool.parameters, `${tool.name}.parameters`);
|
||||||
|
expect(violations).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it("applies sandbox path guards to file_path alias", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sbx-"));
|
||||||
|
const outsidePath = path.join(os.tmpdir(), "clawdbot-outside.txt");
|
||||||
|
await fs.writeFile(outsidePath, "outside", "utf8");
|
||||||
|
try {
|
||||||
|
const readTool = createSandboxedReadTool(tmpDir);
|
||||||
|
await expect(readTool.execute("sandbox-1", { file_path: outsidePath })).rejects.toThrow(
|
||||||
|
/sandbox root/i,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await fs.rm(outsidePath, { force: true });
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+25
-6
@@ -44,6 +44,7 @@ import {
|
|||||||
collectExplicitAllowlist,
|
collectExplicitAllowlist,
|
||||||
expandPolicyWithPluginGroups,
|
expandPolicyWithPluginGroups,
|
||||||
resolveToolProfilePolicy,
|
resolveToolProfilePolicy,
|
||||||
|
stripPluginOnlyAllowlist,
|
||||||
} from "./tool-policy.js";
|
} from "./tool-policy.js";
|
||||||
import { getPluginToolMeta } from "../plugins/tools.js";
|
import { getPluginToolMeta } from "../plugins/tools.js";
|
||||||
|
|
||||||
@@ -298,12 +299,30 @@ export function createClawdbotCodingTools(options?: {
|
|||||||
tools,
|
tools,
|
||||||
toolMeta: (tool) => getPluginToolMeta(tool as AnyAgentTool),
|
toolMeta: (tool) => getPluginToolMeta(tool as AnyAgentTool),
|
||||||
});
|
});
|
||||||
const profilePolicyExpanded = expandPolicyWithPluginGroups(profilePolicy, pluginGroups);
|
const profilePolicyExpanded = expandPolicyWithPluginGroups(
|
||||||
const providerProfileExpanded = expandPolicyWithPluginGroups(providerProfilePolicy, pluginGroups);
|
stripPluginOnlyAllowlist(profilePolicy, pluginGroups),
|
||||||
const globalPolicyExpanded = expandPolicyWithPluginGroups(globalPolicy, pluginGroups);
|
pluginGroups,
|
||||||
const globalProviderExpanded = expandPolicyWithPluginGroups(globalProviderPolicy, pluginGroups);
|
);
|
||||||
const agentPolicyExpanded = expandPolicyWithPluginGroups(agentPolicy, pluginGroups);
|
const providerProfileExpanded = expandPolicyWithPluginGroups(
|
||||||
const agentProviderExpanded = expandPolicyWithPluginGroups(agentProviderPolicy, pluginGroups);
|
stripPluginOnlyAllowlist(providerProfilePolicy, pluginGroups),
|
||||||
|
pluginGroups,
|
||||||
|
);
|
||||||
|
const globalPolicyExpanded = expandPolicyWithPluginGroups(
|
||||||
|
stripPluginOnlyAllowlist(globalPolicy, pluginGroups),
|
||||||
|
pluginGroups,
|
||||||
|
);
|
||||||
|
const globalProviderExpanded = expandPolicyWithPluginGroups(
|
||||||
|
stripPluginOnlyAllowlist(globalProviderPolicy, pluginGroups),
|
||||||
|
pluginGroups,
|
||||||
|
);
|
||||||
|
const agentPolicyExpanded = expandPolicyWithPluginGroups(
|
||||||
|
stripPluginOnlyAllowlist(agentPolicy, pluginGroups),
|
||||||
|
pluginGroups,
|
||||||
|
);
|
||||||
|
const agentProviderExpanded = expandPolicyWithPluginGroups(
|
||||||
|
stripPluginOnlyAllowlist(agentProviderPolicy, pluginGroups),
|
||||||
|
pluginGroups,
|
||||||
|
);
|
||||||
const sandboxPolicyExpanded = expandPolicyWithPluginGroups(sandbox?.tools, pluginGroups);
|
const sandboxPolicyExpanded = expandPolicyWithPluginGroups(sandbox?.tools, pluginGroups);
|
||||||
const subagentPolicyExpanded = expandPolicyWithPluginGroups(subagentPolicy, pluginGroups);
|
const subagentPolicyExpanded = expandPolicyWithPluginGroups(subagentPolicy, pluginGroups);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { stripPluginOnlyAllowlist, type PluginToolGroups } from "./tool-policy.js";
|
||||||
|
|
||||||
|
const pluginGroups: PluginToolGroups = {
|
||||||
|
all: ["lobster", "workflow_tool"],
|
||||||
|
byPlugin: new Map([["lobster", ["lobster", "workflow_tool"]]]),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("stripPluginOnlyAllowlist", () => {
|
||||||
|
it("strips allowlist when it only targets plugin tools", () => {
|
||||||
|
const policy = stripPluginOnlyAllowlist({ allow: ["lobster"] }, pluginGroups);
|
||||||
|
expect(policy?.allow).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips allowlist when it only targets plugin groups", () => {
|
||||||
|
const policy = stripPluginOnlyAllowlist({ allow: ["group:plugins"] }, pluginGroups);
|
||||||
|
expect(policy?.allow).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps allowlist when it mixes plugin and core entries", () => {
|
||||||
|
const policy = stripPluginOnlyAllowlist({ allow: ["lobster", "read"] }, pluginGroups);
|
||||||
|
expect(policy?.allow).toEqual(["lobster", "read"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -178,6 +178,22 @@ export function expandPolicyWithPluginGroups(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function stripPluginOnlyAllowlist(
|
||||||
|
policy: ToolPolicyLike | undefined,
|
||||||
|
groups: PluginToolGroups,
|
||||||
|
): ToolPolicyLike | undefined {
|
||||||
|
if (!policy?.allow || policy.allow.length === 0) return policy;
|
||||||
|
const normalized = normalizeToolList(policy.allow);
|
||||||
|
if (normalized.length === 0) return policy;
|
||||||
|
const pluginIds = new Set(groups.byPlugin.keys());
|
||||||
|
const pluginTools = new Set(groups.all);
|
||||||
|
const isPluginEntry = (entry: string) =>
|
||||||
|
entry === "group:plugins" || pluginIds.has(entry) || pluginTools.has(entry);
|
||||||
|
const isPluginOnly = normalized.every((entry) => isPluginEntry(entry));
|
||||||
|
if (!isPluginOnly) return policy;
|
||||||
|
return { ...policy, allow: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveToolProfilePolicy(profile?: string): ToolProfilePolicy | undefined {
|
export function resolveToolProfilePolicy(profile?: string): ToolProfilePolicy | undefined {
|
||||||
if (!profile) return undefined;
|
if (!profile) return undefined;
|
||||||
const resolved = TOOL_PROFILES[profile as ToolProfileId];
|
const resolved = TOOL_PROFILES[profile as ToolProfileId];
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ vi.mock("../../gateway/call.js", () => ({
|
|||||||
callGateway: (opts: unknown) => callGatewayMock(opts),
|
callGateway: (opts: unknown) => callGatewayMock(opts),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../agent-scope.js", () => ({
|
||||||
|
resolveSessionAgentId: () => "agent-123",
|
||||||
|
}));
|
||||||
|
|
||||||
import { createCronTool } from "./cron-tool.js";
|
import { createCronTool } from "./cron-tool.js";
|
||||||
|
|
||||||
describe("cron tool", () => {
|
describe("cron tool", () => {
|
||||||
@@ -85,6 +89,23 @@ describe("cron tool", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not default agentId when job.agentId is null", async () => {
|
||||||
|
const tool = createCronTool({ agentSessionKey: "main" });
|
||||||
|
await tool.execute("call-null", {
|
||||||
|
action: "add",
|
||||||
|
job: {
|
||||||
|
name: "wake-up",
|
||||||
|
schedule: { atMs: 123 },
|
||||||
|
agentId: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const call = callGatewayMock.mock.calls[0]?.[0] as {
|
||||||
|
params?: { agentId?: unknown };
|
||||||
|
};
|
||||||
|
expect(call?.params?.agentId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("adds recent context for systemEvent reminders when contextMessages > 0", async () => {
|
it("adds recent context for systemEvent reminders when contextMessages > 0", async () => {
|
||||||
callGatewayMock
|
callGatewayMock
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
@@ -188,4 +209,26 @@ describe("cron tool", () => {
|
|||||||
const text = cronCall.params?.payload?.text ?? "";
|
const text = cronCall.params?.payload?.text ?? "";
|
||||||
expect(text).not.toContain("Recent context:");
|
expect(text).not.toContain("Recent context:");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves explicit agentId null on add", async () => {
|
||||||
|
callGatewayMock.mockResolvedValueOnce({ ok: true });
|
||||||
|
|
||||||
|
const tool = createCronTool({ agentSessionKey: "main" });
|
||||||
|
await tool.execute("call6", {
|
||||||
|
action: "add",
|
||||||
|
job: {
|
||||||
|
name: "reminder",
|
||||||
|
schedule: { atMs: 123 },
|
||||||
|
agentId: null,
|
||||||
|
payload: { kind: "systemEvent", text: "Reminder: the thing." },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const call = callGatewayMock.mock.calls[0]?.[0] as {
|
||||||
|
method?: string;
|
||||||
|
params?: { agentId?: string | null };
|
||||||
|
};
|
||||||
|
expect(call.method).toBe("cron.add");
|
||||||
|
expect(call.params?.agentId).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -3,6 +3,7 @@ import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normal
|
|||||||
import { loadConfig } from "../../config/config.js";
|
import { loadConfig } from "../../config/config.js";
|
||||||
import { truncateUtf16Safe } from "../../utils.js";
|
import { truncateUtf16Safe } from "../../utils.js";
|
||||||
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
|
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
|
||||||
|
import { resolveSessionAgentId } from "../agent-scope.js";
|
||||||
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
|
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
|
||||||
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
|
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
|
||||||
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js";
|
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js";
|
||||||
@@ -158,6 +159,15 @@ export function createCronTool(opts?: CronToolOptions): AnyAgentTool {
|
|||||||
throw new Error("job required");
|
throw new Error("job required");
|
||||||
}
|
}
|
||||||
const job = normalizeCronJobCreate(params.job) ?? params.job;
|
const job = normalizeCronJobCreate(params.job) ?? params.job;
|
||||||
|
if (job && typeof job === "object" && !("agentId" in job)) {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const agentId = opts?.agentSessionKey
|
||||||
|
? resolveSessionAgentId({ sessionKey: opts.agentSessionKey, config: cfg })
|
||||||
|
: undefined;
|
||||||
|
if (agentId) {
|
||||||
|
(job as { agentId?: string }).agentId = agentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
const contextMessages =
|
const contextMessages =
|
||||||
typeof params.contextMessages === "number" && Number.isFinite(params.contextMessages)
|
typeof params.contextMessages === "number" && Number.isFinite(params.contextMessages)
|
||||||
? params.contextMessages
|
? params.contextMessages
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export async function handleDiscordGuildAction(
|
|||||||
params: Record<string, unknown>,
|
params: Record<string, unknown>,
|
||||||
isActionEnabled: ActionGate<DiscordActionConfig>,
|
isActionEnabled: ActionGate<DiscordActionConfig>,
|
||||||
): Promise<AgentToolResult<unknown>> {
|
): Promise<AgentToolResult<unknown>> {
|
||||||
|
const accountId = readStringParam(params, "accountId");
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "memberInfo": {
|
case "memberInfo": {
|
||||||
if (!isActionEnabled("memberInfo")) {
|
if (!isActionEnabled("memberInfo")) {
|
||||||
@@ -50,7 +51,9 @@ export async function handleDiscordGuildAction(
|
|||||||
const userId = readStringParam(params, "userId", {
|
const userId = readStringParam(params, "userId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const member = await fetchMemberInfoDiscord(guildId, userId);
|
const member = accountId
|
||||||
|
? await fetchMemberInfoDiscord(guildId, userId, { accountId })
|
||||||
|
: await fetchMemberInfoDiscord(guildId, userId);
|
||||||
return jsonResult({ ok: true, member });
|
return jsonResult({ ok: true, member });
|
||||||
}
|
}
|
||||||
case "roleInfo": {
|
case "roleInfo": {
|
||||||
@@ -60,7 +63,9 @@ export async function handleDiscordGuildAction(
|
|||||||
const guildId = readStringParam(params, "guildId", {
|
const guildId = readStringParam(params, "guildId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const roles = await fetchRoleInfoDiscord(guildId);
|
const roles = accountId
|
||||||
|
? await fetchRoleInfoDiscord(guildId, { accountId })
|
||||||
|
: await fetchRoleInfoDiscord(guildId);
|
||||||
return jsonResult({ ok: true, roles });
|
return jsonResult({ ok: true, roles });
|
||||||
}
|
}
|
||||||
case "emojiList": {
|
case "emojiList": {
|
||||||
@@ -70,7 +75,9 @@ export async function handleDiscordGuildAction(
|
|||||||
const guildId = readStringParam(params, "guildId", {
|
const guildId = readStringParam(params, "guildId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const emojis = await listGuildEmojisDiscord(guildId);
|
const emojis = accountId
|
||||||
|
? await listGuildEmojisDiscord(guildId, { accountId })
|
||||||
|
: await listGuildEmojisDiscord(guildId);
|
||||||
return jsonResult({ ok: true, emojis });
|
return jsonResult({ ok: true, emojis });
|
||||||
}
|
}
|
||||||
case "emojiUpload": {
|
case "emojiUpload": {
|
||||||
@@ -85,12 +92,22 @@ export async function handleDiscordGuildAction(
|
|||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const roleIds = readStringArrayParam(params, "roleIds");
|
const roleIds = readStringArrayParam(params, "roleIds");
|
||||||
const emoji = await uploadEmojiDiscord({
|
const emoji = accountId
|
||||||
guildId,
|
? await uploadEmojiDiscord(
|
||||||
name,
|
{
|
||||||
mediaUrl,
|
guildId,
|
||||||
roleIds: roleIds?.length ? roleIds : undefined,
|
name,
|
||||||
});
|
mediaUrl,
|
||||||
|
roleIds: roleIds?.length ? roleIds : undefined,
|
||||||
|
},
|
||||||
|
{ accountId },
|
||||||
|
)
|
||||||
|
: await uploadEmojiDiscord({
|
||||||
|
guildId,
|
||||||
|
name,
|
||||||
|
mediaUrl,
|
||||||
|
roleIds: roleIds?.length ? roleIds : undefined,
|
||||||
|
});
|
||||||
return jsonResult({ ok: true, emoji });
|
return jsonResult({ ok: true, emoji });
|
||||||
}
|
}
|
||||||
case "stickerUpload": {
|
case "stickerUpload": {
|
||||||
@@ -108,13 +125,24 @@ export async function handleDiscordGuildAction(
|
|||||||
const mediaUrl = readStringParam(params, "mediaUrl", {
|
const mediaUrl = readStringParam(params, "mediaUrl", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const sticker = await uploadStickerDiscord({
|
const sticker = accountId
|
||||||
guildId,
|
? await uploadStickerDiscord(
|
||||||
name,
|
{
|
||||||
description,
|
guildId,
|
||||||
tags,
|
name,
|
||||||
mediaUrl,
|
description,
|
||||||
});
|
tags,
|
||||||
|
mediaUrl,
|
||||||
|
},
|
||||||
|
{ accountId },
|
||||||
|
)
|
||||||
|
: await uploadStickerDiscord({
|
||||||
|
guildId,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
tags,
|
||||||
|
mediaUrl,
|
||||||
|
});
|
||||||
return jsonResult({ ok: true, sticker });
|
return jsonResult({ ok: true, sticker });
|
||||||
}
|
}
|
||||||
case "roleAdd": {
|
case "roleAdd": {
|
||||||
@@ -128,7 +156,11 @@ export async function handleDiscordGuildAction(
|
|||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const roleId = readStringParam(params, "roleId", { required: true });
|
const roleId = readStringParam(params, "roleId", { required: true });
|
||||||
await addRoleDiscord({ guildId, userId, roleId });
|
if (accountId) {
|
||||||
|
await addRoleDiscord({ guildId, userId, roleId }, { accountId });
|
||||||
|
} else {
|
||||||
|
await addRoleDiscord({ guildId, userId, roleId });
|
||||||
|
}
|
||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
case "roleRemove": {
|
case "roleRemove": {
|
||||||
@@ -142,7 +174,11 @@ export async function handleDiscordGuildAction(
|
|||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const roleId = readStringParam(params, "roleId", { required: true });
|
const roleId = readStringParam(params, "roleId", { required: true });
|
||||||
await removeRoleDiscord({ guildId, userId, roleId });
|
if (accountId) {
|
||||||
|
await removeRoleDiscord({ guildId, userId, roleId }, { accountId });
|
||||||
|
} else {
|
||||||
|
await removeRoleDiscord({ guildId, userId, roleId });
|
||||||
|
}
|
||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
case "channelInfo": {
|
case "channelInfo": {
|
||||||
@@ -152,7 +188,9 @@ export async function handleDiscordGuildAction(
|
|||||||
const channelId = readStringParam(params, "channelId", {
|
const channelId = readStringParam(params, "channelId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const channel = await fetchChannelInfoDiscord(channelId);
|
const channel = accountId
|
||||||
|
? await fetchChannelInfoDiscord(channelId, { accountId })
|
||||||
|
: await fetchChannelInfoDiscord(channelId);
|
||||||
return jsonResult({ ok: true, channel });
|
return jsonResult({ ok: true, channel });
|
||||||
}
|
}
|
||||||
case "channelList": {
|
case "channelList": {
|
||||||
@@ -162,7 +200,9 @@ export async function handleDiscordGuildAction(
|
|||||||
const guildId = readStringParam(params, "guildId", {
|
const guildId = readStringParam(params, "guildId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const channels = await listGuildChannelsDiscord(guildId);
|
const channels = accountId
|
||||||
|
? await listGuildChannelsDiscord(guildId, { accountId })
|
||||||
|
: await listGuildChannelsDiscord(guildId);
|
||||||
return jsonResult({ ok: true, channels });
|
return jsonResult({ ok: true, channels });
|
||||||
}
|
}
|
||||||
case "voiceStatus": {
|
case "voiceStatus": {
|
||||||
@@ -175,7 +215,9 @@ export async function handleDiscordGuildAction(
|
|||||||
const userId = readStringParam(params, "userId", {
|
const userId = readStringParam(params, "userId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const voice = await fetchVoiceStatusDiscord(guildId, userId);
|
const voice = accountId
|
||||||
|
? await fetchVoiceStatusDiscord(guildId, userId, { accountId })
|
||||||
|
: await fetchVoiceStatusDiscord(guildId, userId);
|
||||||
return jsonResult({ ok: true, voice });
|
return jsonResult({ ok: true, voice });
|
||||||
}
|
}
|
||||||
case "eventList": {
|
case "eventList": {
|
||||||
@@ -185,7 +227,9 @@ export async function handleDiscordGuildAction(
|
|||||||
const guildId = readStringParam(params, "guildId", {
|
const guildId = readStringParam(params, "guildId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const events = await listScheduledEventsDiscord(guildId);
|
const events = accountId
|
||||||
|
? await listScheduledEventsDiscord(guildId, { accountId })
|
||||||
|
: await listScheduledEventsDiscord(guildId);
|
||||||
return jsonResult({ ok: true, events });
|
return jsonResult({ ok: true, events });
|
||||||
}
|
}
|
||||||
case "eventCreate": {
|
case "eventCreate": {
|
||||||
@@ -215,7 +259,9 @@ export async function handleDiscordGuildAction(
|
|||||||
entity_metadata: entityType === 3 && location ? { location } : undefined,
|
entity_metadata: entityType === 3 && location ? { location } : undefined,
|
||||||
privacy_level: 2,
|
privacy_level: 2,
|
||||||
};
|
};
|
||||||
const event = await createScheduledEventDiscord(guildId, payload);
|
const event = accountId
|
||||||
|
? await createScheduledEventDiscord(guildId, payload, { accountId })
|
||||||
|
: await createScheduledEventDiscord(guildId, payload);
|
||||||
return jsonResult({ ok: true, event });
|
return jsonResult({ ok: true, event });
|
||||||
}
|
}
|
||||||
case "channelCreate": {
|
case "channelCreate": {
|
||||||
@@ -229,15 +275,28 @@ export async function handleDiscordGuildAction(
|
|||||||
const topic = readStringParam(params, "topic");
|
const topic = readStringParam(params, "topic");
|
||||||
const position = readNumberParam(params, "position", { integer: true });
|
const position = readNumberParam(params, "position", { integer: true });
|
||||||
const nsfw = params.nsfw as boolean | undefined;
|
const nsfw = params.nsfw as boolean | undefined;
|
||||||
const channel = await createChannelDiscord({
|
const channel = accountId
|
||||||
guildId,
|
? await createChannelDiscord(
|
||||||
name,
|
{
|
||||||
type: type ?? undefined,
|
guildId,
|
||||||
parentId: parentId ?? undefined,
|
name,
|
||||||
topic: topic ?? undefined,
|
type: type ?? undefined,
|
||||||
position: position ?? undefined,
|
parentId: parentId ?? undefined,
|
||||||
nsfw,
|
topic: topic ?? undefined,
|
||||||
});
|
position: position ?? undefined,
|
||||||
|
nsfw,
|
||||||
|
},
|
||||||
|
{ accountId },
|
||||||
|
)
|
||||||
|
: await createChannelDiscord({
|
||||||
|
guildId,
|
||||||
|
name,
|
||||||
|
type: type ?? undefined,
|
||||||
|
parentId: parentId ?? undefined,
|
||||||
|
topic: topic ?? undefined,
|
||||||
|
position: position ?? undefined,
|
||||||
|
nsfw,
|
||||||
|
});
|
||||||
return jsonResult({ ok: true, channel });
|
return jsonResult({ ok: true, channel });
|
||||||
}
|
}
|
||||||
case "channelEdit": {
|
case "channelEdit": {
|
||||||
@@ -255,15 +314,28 @@ export async function handleDiscordGuildAction(
|
|||||||
const rateLimitPerUser = readNumberParam(params, "rateLimitPerUser", {
|
const rateLimitPerUser = readNumberParam(params, "rateLimitPerUser", {
|
||||||
integer: true,
|
integer: true,
|
||||||
});
|
});
|
||||||
const channel = await editChannelDiscord({
|
const channel = accountId
|
||||||
channelId,
|
? await editChannelDiscord(
|
||||||
name: name ?? undefined,
|
{
|
||||||
topic: topic ?? undefined,
|
channelId,
|
||||||
position: position ?? undefined,
|
name: name ?? undefined,
|
||||||
parentId,
|
topic: topic ?? undefined,
|
||||||
nsfw,
|
position: position ?? undefined,
|
||||||
rateLimitPerUser: rateLimitPerUser ?? undefined,
|
parentId,
|
||||||
});
|
nsfw,
|
||||||
|
rateLimitPerUser: rateLimitPerUser ?? undefined,
|
||||||
|
},
|
||||||
|
{ accountId },
|
||||||
|
)
|
||||||
|
: await editChannelDiscord({
|
||||||
|
channelId,
|
||||||
|
name: name ?? undefined,
|
||||||
|
topic: topic ?? undefined,
|
||||||
|
position: position ?? undefined,
|
||||||
|
parentId,
|
||||||
|
nsfw,
|
||||||
|
rateLimitPerUser: rateLimitPerUser ?? undefined,
|
||||||
|
});
|
||||||
return jsonResult({ ok: true, channel });
|
return jsonResult({ ok: true, channel });
|
||||||
}
|
}
|
||||||
case "channelDelete": {
|
case "channelDelete": {
|
||||||
@@ -273,7 +345,9 @@ export async function handleDiscordGuildAction(
|
|||||||
const channelId = readStringParam(params, "channelId", {
|
const channelId = readStringParam(params, "channelId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const result = await deleteChannelDiscord(channelId);
|
const result = accountId
|
||||||
|
? await deleteChannelDiscord(channelId, { accountId })
|
||||||
|
: await deleteChannelDiscord(channelId);
|
||||||
return jsonResult(result);
|
return jsonResult(result);
|
||||||
}
|
}
|
||||||
case "channelMove": {
|
case "channelMove": {
|
||||||
@@ -286,12 +360,24 @@ export async function handleDiscordGuildAction(
|
|||||||
});
|
});
|
||||||
const parentId = readParentIdParam(params);
|
const parentId = readParentIdParam(params);
|
||||||
const position = readNumberParam(params, "position", { integer: true });
|
const position = readNumberParam(params, "position", { integer: true });
|
||||||
await moveChannelDiscord({
|
if (accountId) {
|
||||||
guildId,
|
await moveChannelDiscord(
|
||||||
channelId,
|
{
|
||||||
parentId,
|
guildId,
|
||||||
position: position ?? undefined,
|
channelId,
|
||||||
});
|
parentId,
|
||||||
|
position: position ?? undefined,
|
||||||
|
},
|
||||||
|
{ accountId },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await moveChannelDiscord({
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
parentId,
|
||||||
|
position: position ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
case "categoryCreate": {
|
case "categoryCreate": {
|
||||||
@@ -301,12 +387,22 @@ export async function handleDiscordGuildAction(
|
|||||||
const guildId = readStringParam(params, "guildId", { required: true });
|
const guildId = readStringParam(params, "guildId", { required: true });
|
||||||
const name = readStringParam(params, "name", { required: true });
|
const name = readStringParam(params, "name", { required: true });
|
||||||
const position = readNumberParam(params, "position", { integer: true });
|
const position = readNumberParam(params, "position", { integer: true });
|
||||||
const channel = await createChannelDiscord({
|
const channel = accountId
|
||||||
guildId,
|
? await createChannelDiscord(
|
||||||
name,
|
{
|
||||||
type: 4,
|
guildId,
|
||||||
position: position ?? undefined,
|
name,
|
||||||
});
|
type: 4,
|
||||||
|
position: position ?? undefined,
|
||||||
|
},
|
||||||
|
{ accountId },
|
||||||
|
)
|
||||||
|
: await createChannelDiscord({
|
||||||
|
guildId,
|
||||||
|
name,
|
||||||
|
type: 4,
|
||||||
|
position: position ?? undefined,
|
||||||
|
});
|
||||||
return jsonResult({ ok: true, category: channel });
|
return jsonResult({ ok: true, category: channel });
|
||||||
}
|
}
|
||||||
case "categoryEdit": {
|
case "categoryEdit": {
|
||||||
@@ -318,11 +414,20 @@ export async function handleDiscordGuildAction(
|
|||||||
});
|
});
|
||||||
const name = readStringParam(params, "name");
|
const name = readStringParam(params, "name");
|
||||||
const position = readNumberParam(params, "position", { integer: true });
|
const position = readNumberParam(params, "position", { integer: true });
|
||||||
const channel = await editChannelDiscord({
|
const channel = accountId
|
||||||
channelId: categoryId,
|
? await editChannelDiscord(
|
||||||
name: name ?? undefined,
|
{
|
||||||
position: position ?? undefined,
|
channelId: categoryId,
|
||||||
});
|
name: name ?? undefined,
|
||||||
|
position: position ?? undefined,
|
||||||
|
},
|
||||||
|
{ accountId },
|
||||||
|
)
|
||||||
|
: await editChannelDiscord({
|
||||||
|
channelId: categoryId,
|
||||||
|
name: name ?? undefined,
|
||||||
|
position: position ?? undefined,
|
||||||
|
});
|
||||||
return jsonResult({ ok: true, category: channel });
|
return jsonResult({ ok: true, category: channel });
|
||||||
}
|
}
|
||||||
case "categoryDelete": {
|
case "categoryDelete": {
|
||||||
@@ -332,7 +437,9 @@ export async function handleDiscordGuildAction(
|
|||||||
const categoryId = readStringParam(params, "categoryId", {
|
const categoryId = readStringParam(params, "categoryId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const result = await deleteChannelDiscord(categoryId);
|
const result = accountId
|
||||||
|
? await deleteChannelDiscord(categoryId, { accountId })
|
||||||
|
: await deleteChannelDiscord(categoryId);
|
||||||
return jsonResult(result);
|
return jsonResult(result);
|
||||||
}
|
}
|
||||||
case "channelPermissionSet": {
|
case "channelPermissionSet": {
|
||||||
@@ -349,13 +456,26 @@ export async function handleDiscordGuildAction(
|
|||||||
const targetType = targetTypeRaw === "member" ? 1 : 0;
|
const targetType = targetTypeRaw === "member" ? 1 : 0;
|
||||||
const allow = readStringParam(params, "allow");
|
const allow = readStringParam(params, "allow");
|
||||||
const deny = readStringParam(params, "deny");
|
const deny = readStringParam(params, "deny");
|
||||||
await setChannelPermissionDiscord({
|
if (accountId) {
|
||||||
channelId,
|
await setChannelPermissionDiscord(
|
||||||
targetId,
|
{
|
||||||
targetType,
|
channelId,
|
||||||
allow: allow ?? undefined,
|
targetId,
|
||||||
deny: deny ?? undefined,
|
targetType,
|
||||||
});
|
allow: allow ?? undefined,
|
||||||
|
deny: deny ?? undefined,
|
||||||
|
},
|
||||||
|
{ accountId },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await setChannelPermissionDiscord({
|
||||||
|
channelId,
|
||||||
|
targetId,
|
||||||
|
targetType,
|
||||||
|
allow: allow ?? undefined,
|
||||||
|
deny: deny ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
case "channelPermissionRemove": {
|
case "channelPermissionRemove": {
|
||||||
@@ -366,7 +486,11 @@ export async function handleDiscordGuildAction(
|
|||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const targetId = readStringParam(params, "targetId", { required: true });
|
const targetId = readStringParam(params, "targetId", { required: true });
|
||||||
await removeChannelPermissionDiscord(channelId, targetId);
|
if (accountId) {
|
||||||
|
await removeChannelPermissionDiscord(channelId, targetId, { accountId });
|
||||||
|
} else {
|
||||||
|
await removeChannelPermissionDiscord(channelId, targetId);
|
||||||
|
}
|
||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export async function handleDiscordMessagingAction(
|
|||||||
required: true,
|
required: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
const accountId = readStringParam(params, "accountId");
|
||||||
const normalizeMessage = (message: unknown) => {
|
const normalizeMessage = (message: unknown) => {
|
||||||
if (!message || typeof message !== "object") return message;
|
if (!message || typeof message !== "object") return message;
|
||||||
return withNormalizedTimestamp(
|
return withNormalizedTimestamp(
|
||||||
@@ -78,14 +79,24 @@ export async function handleDiscordMessagingAction(
|
|||||||
removeErrorMessage: "Emoji is required to remove a Discord reaction.",
|
removeErrorMessage: "Emoji is required to remove a Discord reaction.",
|
||||||
});
|
});
|
||||||
if (remove) {
|
if (remove) {
|
||||||
await removeReactionDiscord(channelId, messageId, emoji);
|
if (accountId) {
|
||||||
|
await removeReactionDiscord(channelId, messageId, emoji, { accountId });
|
||||||
|
} else {
|
||||||
|
await removeReactionDiscord(channelId, messageId, emoji);
|
||||||
|
}
|
||||||
return jsonResult({ ok: true, removed: emoji });
|
return jsonResult({ ok: true, removed: emoji });
|
||||||
}
|
}
|
||||||
if (isEmpty) {
|
if (isEmpty) {
|
||||||
const removed = await removeOwnReactionsDiscord(channelId, messageId);
|
const removed = accountId
|
||||||
|
? await removeOwnReactionsDiscord(channelId, messageId, { accountId })
|
||||||
|
: await removeOwnReactionsDiscord(channelId, messageId);
|
||||||
return jsonResult({ ok: true, removed: removed.removed });
|
return jsonResult({ ok: true, removed: removed.removed });
|
||||||
}
|
}
|
||||||
await reactMessageDiscord(channelId, messageId, emoji);
|
if (accountId) {
|
||||||
|
await reactMessageDiscord(channelId, messageId, emoji, { accountId });
|
||||||
|
} else {
|
||||||
|
await reactMessageDiscord(channelId, messageId, emoji);
|
||||||
|
}
|
||||||
return jsonResult({ ok: true, added: emoji });
|
return jsonResult({ ok: true, added: emoji });
|
||||||
}
|
}
|
||||||
case "reactions": {
|
case "reactions": {
|
||||||
@@ -100,6 +111,7 @@ export async function handleDiscordMessagingAction(
|
|||||||
const limit =
|
const limit =
|
||||||
typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined;
|
typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined;
|
||||||
const reactions = await fetchReactionsDiscord(channelId, messageId, {
|
const reactions = await fetchReactionsDiscord(channelId, messageId, {
|
||||||
|
...(accountId ? { accountId } : {}),
|
||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
return jsonResult({ ok: true, reactions });
|
return jsonResult({ ok: true, reactions });
|
||||||
@@ -114,7 +126,10 @@ export async function handleDiscordMessagingAction(
|
|||||||
required: true,
|
required: true,
|
||||||
label: "stickerIds",
|
label: "stickerIds",
|
||||||
});
|
});
|
||||||
await sendStickerDiscord(to, stickerIds, { content });
|
await sendStickerDiscord(to, stickerIds, {
|
||||||
|
...(accountId ? { accountId } : {}),
|
||||||
|
content,
|
||||||
|
});
|
||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
case "poll": {
|
case "poll": {
|
||||||
@@ -140,7 +155,7 @@ export async function handleDiscordMessagingAction(
|
|||||||
await sendPollDiscord(
|
await sendPollDiscord(
|
||||||
to,
|
to,
|
||||||
{ question, options: answers, maxSelections, durationHours },
|
{ question, options: answers, maxSelections, durationHours },
|
||||||
{ content },
|
{ ...(accountId ? { accountId } : {}), content },
|
||||||
);
|
);
|
||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
@@ -149,7 +164,9 @@ export async function handleDiscordMessagingAction(
|
|||||||
throw new Error("Discord permissions are disabled.");
|
throw new Error("Discord permissions are disabled.");
|
||||||
}
|
}
|
||||||
const channelId = resolveChannelId();
|
const channelId = resolveChannelId();
|
||||||
const permissions = await fetchChannelPermissionsDiscord(channelId);
|
const permissions = accountId
|
||||||
|
? await fetchChannelPermissionsDiscord(channelId, { accountId })
|
||||||
|
: await fetchChannelPermissionsDiscord(channelId);
|
||||||
return jsonResult({ ok: true, permissions });
|
return jsonResult({ ok: true, permissions });
|
||||||
}
|
}
|
||||||
case "fetchMessage": {
|
case "fetchMessage": {
|
||||||
@@ -171,7 +188,9 @@ export async function handleDiscordMessagingAction(
|
|||||||
"Discord message fetch requires guildId, channelId, and messageId (or a valid messageLink).",
|
"Discord message fetch requires guildId, channelId, and messageId (or a valid messageLink).",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const message = await fetchMessageDiscord(channelId, messageId);
|
const message = accountId
|
||||||
|
? await fetchMessageDiscord(channelId, messageId, { accountId })
|
||||||
|
: await fetchMessageDiscord(channelId, messageId);
|
||||||
return jsonResult({
|
return jsonResult({
|
||||||
ok: true,
|
ok: true,
|
||||||
message: normalizeMessage(message),
|
message: normalizeMessage(message),
|
||||||
@@ -185,7 +204,7 @@ export async function handleDiscordMessagingAction(
|
|||||||
throw new Error("Discord message reads are disabled.");
|
throw new Error("Discord message reads are disabled.");
|
||||||
}
|
}
|
||||||
const channelId = resolveChannelId();
|
const channelId = resolveChannelId();
|
||||||
const messages = await readMessagesDiscord(channelId, {
|
const query = {
|
||||||
limit:
|
limit:
|
||||||
typeof params.limit === "number" && Number.isFinite(params.limit)
|
typeof params.limit === "number" && Number.isFinite(params.limit)
|
||||||
? params.limit
|
? params.limit
|
||||||
@@ -193,7 +212,10 @@ export async function handleDiscordMessagingAction(
|
|||||||
before: readStringParam(params, "before"),
|
before: readStringParam(params, "before"),
|
||||||
after: readStringParam(params, "after"),
|
after: readStringParam(params, "after"),
|
||||||
around: readStringParam(params, "around"),
|
around: readStringParam(params, "around"),
|
||||||
});
|
};
|
||||||
|
const messages = accountId
|
||||||
|
? await readMessagesDiscord(channelId, query, { accountId })
|
||||||
|
: await readMessagesDiscord(channelId, query);
|
||||||
return jsonResult({
|
return jsonResult({
|
||||||
ok: true,
|
ok: true,
|
||||||
messages: messages.map((message) => normalizeMessage(message)),
|
messages: messages.map((message) => normalizeMessage(message)),
|
||||||
@@ -212,6 +234,7 @@ export async function handleDiscordMessagingAction(
|
|||||||
const embeds =
|
const embeds =
|
||||||
Array.isArray(params.embeds) && params.embeds.length > 0 ? params.embeds : undefined;
|
Array.isArray(params.embeds) && params.embeds.length > 0 ? params.embeds : undefined;
|
||||||
const result = await sendMessageDiscord(to, content, {
|
const result = await sendMessageDiscord(to, content, {
|
||||||
|
...(accountId ? { accountId } : {}),
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
replyTo,
|
replyTo,
|
||||||
embeds,
|
embeds,
|
||||||
@@ -229,9 +252,9 @@ export async function handleDiscordMessagingAction(
|
|||||||
const content = readStringParam(params, "content", {
|
const content = readStringParam(params, "content", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const message = await editMessageDiscord(channelId, messageId, {
|
const message = accountId
|
||||||
content,
|
? await editMessageDiscord(channelId, messageId, { content }, { accountId })
|
||||||
});
|
: await editMessageDiscord(channelId, messageId, { content });
|
||||||
return jsonResult({ ok: true, message });
|
return jsonResult({ ok: true, message });
|
||||||
}
|
}
|
||||||
case "deleteMessage": {
|
case "deleteMessage": {
|
||||||
@@ -242,7 +265,11 @@ export async function handleDiscordMessagingAction(
|
|||||||
const messageId = readStringParam(params, "messageId", {
|
const messageId = readStringParam(params, "messageId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
await deleteMessageDiscord(channelId, messageId);
|
if (accountId) {
|
||||||
|
await deleteMessageDiscord(channelId, messageId, { accountId });
|
||||||
|
} else {
|
||||||
|
await deleteMessageDiscord(channelId, messageId);
|
||||||
|
}
|
||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
case "threadCreate": {
|
case "threadCreate": {
|
||||||
@@ -257,11 +284,13 @@ export async function handleDiscordMessagingAction(
|
|||||||
typeof autoArchiveMinutesRaw === "number" && Number.isFinite(autoArchiveMinutesRaw)
|
typeof autoArchiveMinutesRaw === "number" && Number.isFinite(autoArchiveMinutesRaw)
|
||||||
? autoArchiveMinutesRaw
|
? autoArchiveMinutesRaw
|
||||||
: undefined;
|
: undefined;
|
||||||
const thread = await createThreadDiscord(channelId, {
|
const thread = accountId
|
||||||
name,
|
? await createThreadDiscord(
|
||||||
messageId,
|
channelId,
|
||||||
autoArchiveMinutes,
|
{ name, messageId, autoArchiveMinutes },
|
||||||
});
|
{ accountId },
|
||||||
|
)
|
||||||
|
: await createThreadDiscord(channelId, { name, messageId, autoArchiveMinutes });
|
||||||
return jsonResult({ ok: true, thread });
|
return jsonResult({ ok: true, thread });
|
||||||
}
|
}
|
||||||
case "threadList": {
|
case "threadList": {
|
||||||
@@ -279,13 +308,24 @@ export async function handleDiscordMessagingAction(
|
|||||||
typeof params.limit === "number" && Number.isFinite(params.limit)
|
typeof params.limit === "number" && Number.isFinite(params.limit)
|
||||||
? params.limit
|
? params.limit
|
||||||
: undefined;
|
: undefined;
|
||||||
const threads = await listThreadsDiscord({
|
const threads = accountId
|
||||||
guildId,
|
? await listThreadsDiscord(
|
||||||
channelId,
|
{
|
||||||
includeArchived,
|
guildId,
|
||||||
before,
|
channelId,
|
||||||
limit,
|
includeArchived,
|
||||||
});
|
before,
|
||||||
|
limit,
|
||||||
|
},
|
||||||
|
{ accountId },
|
||||||
|
)
|
||||||
|
: await listThreadsDiscord({
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
includeArchived,
|
||||||
|
before,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
return jsonResult({ ok: true, threads });
|
return jsonResult({ ok: true, threads });
|
||||||
}
|
}
|
||||||
case "threadReply": {
|
case "threadReply": {
|
||||||
@@ -299,6 +339,7 @@ export async function handleDiscordMessagingAction(
|
|||||||
const mediaUrl = readStringParam(params, "mediaUrl");
|
const mediaUrl = readStringParam(params, "mediaUrl");
|
||||||
const replyTo = readStringParam(params, "replyTo");
|
const replyTo = readStringParam(params, "replyTo");
|
||||||
const result = await sendMessageDiscord(`channel:${channelId}`, content, {
|
const result = await sendMessageDiscord(`channel:${channelId}`, content, {
|
||||||
|
...(accountId ? { accountId } : {}),
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
replyTo,
|
replyTo,
|
||||||
});
|
});
|
||||||
@@ -312,7 +353,11 @@ export async function handleDiscordMessagingAction(
|
|||||||
const messageId = readStringParam(params, "messageId", {
|
const messageId = readStringParam(params, "messageId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
await pinMessageDiscord(channelId, messageId);
|
if (accountId) {
|
||||||
|
await pinMessageDiscord(channelId, messageId, { accountId });
|
||||||
|
} else {
|
||||||
|
await pinMessageDiscord(channelId, messageId);
|
||||||
|
}
|
||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
case "unpinMessage": {
|
case "unpinMessage": {
|
||||||
@@ -323,7 +368,11 @@ export async function handleDiscordMessagingAction(
|
|||||||
const messageId = readStringParam(params, "messageId", {
|
const messageId = readStringParam(params, "messageId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
await unpinMessageDiscord(channelId, messageId);
|
if (accountId) {
|
||||||
|
await unpinMessageDiscord(channelId, messageId, { accountId });
|
||||||
|
} else {
|
||||||
|
await unpinMessageDiscord(channelId, messageId);
|
||||||
|
}
|
||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
case "listPins": {
|
case "listPins": {
|
||||||
@@ -331,7 +380,9 @@ export async function handleDiscordMessagingAction(
|
|||||||
throw new Error("Discord pins are disabled.");
|
throw new Error("Discord pins are disabled.");
|
||||||
}
|
}
|
||||||
const channelId = resolveChannelId();
|
const channelId = resolveChannelId();
|
||||||
const pins = await listPinsDiscord(channelId);
|
const pins = accountId
|
||||||
|
? await listPinsDiscord(channelId, { accountId })
|
||||||
|
: await listPinsDiscord(channelId);
|
||||||
return jsonResult({ ok: true, pins: pins.map((pin) => normalizeMessage(pin)) });
|
return jsonResult({ ok: true, pins: pins.map((pin) => normalizeMessage(pin)) });
|
||||||
}
|
}
|
||||||
case "searchMessages": {
|
case "searchMessages": {
|
||||||
@@ -354,13 +405,24 @@ export async function handleDiscordMessagingAction(
|
|||||||
: undefined;
|
: undefined;
|
||||||
const channelIdList = [...(channelIds ?? []), ...(channelId ? [channelId] : [])];
|
const channelIdList = [...(channelIds ?? []), ...(channelId ? [channelId] : [])];
|
||||||
const authorIdList = [...(authorIds ?? []), ...(authorId ? [authorId] : [])];
|
const authorIdList = [...(authorIds ?? []), ...(authorId ? [authorId] : [])];
|
||||||
const results = await searchMessagesDiscord({
|
const results = accountId
|
||||||
guildId,
|
? await searchMessagesDiscord(
|
||||||
content,
|
{
|
||||||
channelIds: channelIdList.length ? channelIdList : undefined,
|
guildId,
|
||||||
authorIds: authorIdList.length ? authorIdList : undefined,
|
content,
|
||||||
limit,
|
channelIds: channelIdList.length ? channelIdList : undefined,
|
||||||
});
|
authorIds: authorIdList.length ? authorIdList : undefined,
|
||||||
|
limit,
|
||||||
|
},
|
||||||
|
{ accountId },
|
||||||
|
)
|
||||||
|
: await searchMessagesDiscord({
|
||||||
|
guildId,
|
||||||
|
content,
|
||||||
|
channelIds: channelIdList.length ? channelIdList : undefined,
|
||||||
|
authorIds: authorIdList.length ? authorIdList : undefined,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
if (!results || typeof results !== "object") {
|
if (!results || typeof results !== "object") {
|
||||||
return jsonResult({ ok: true, results });
|
return jsonResult({ ok: true, results });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export async function handleDiscordModerationAction(
|
|||||||
params: Record<string, unknown>,
|
params: Record<string, unknown>,
|
||||||
isActionEnabled: ActionGate<DiscordActionConfig>,
|
isActionEnabled: ActionGate<DiscordActionConfig>,
|
||||||
): Promise<AgentToolResult<unknown>> {
|
): Promise<AgentToolResult<unknown>> {
|
||||||
|
const accountId = readStringParam(params, "accountId");
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "timeout": {
|
case "timeout": {
|
||||||
if (!isActionEnabled("moderation", false)) {
|
if (!isActionEnabled("moderation", false)) {
|
||||||
@@ -25,13 +26,24 @@ export async function handleDiscordModerationAction(
|
|||||||
: undefined;
|
: undefined;
|
||||||
const until = readStringParam(params, "until");
|
const until = readStringParam(params, "until");
|
||||||
const reason = readStringParam(params, "reason");
|
const reason = readStringParam(params, "reason");
|
||||||
const member = await timeoutMemberDiscord({
|
const member = accountId
|
||||||
guildId,
|
? await timeoutMemberDiscord(
|
||||||
userId,
|
{
|
||||||
durationMinutes,
|
guildId,
|
||||||
until,
|
userId,
|
||||||
reason,
|
durationMinutes,
|
||||||
});
|
until,
|
||||||
|
reason,
|
||||||
|
},
|
||||||
|
{ accountId },
|
||||||
|
)
|
||||||
|
: await timeoutMemberDiscord({
|
||||||
|
guildId,
|
||||||
|
userId,
|
||||||
|
durationMinutes,
|
||||||
|
until,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
return jsonResult({ ok: true, member });
|
return jsonResult({ ok: true, member });
|
||||||
}
|
}
|
||||||
case "kick": {
|
case "kick": {
|
||||||
@@ -45,7 +57,11 @@ export async function handleDiscordModerationAction(
|
|||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const reason = readStringParam(params, "reason");
|
const reason = readStringParam(params, "reason");
|
||||||
await kickMemberDiscord({ guildId, userId, reason });
|
if (accountId) {
|
||||||
|
await kickMemberDiscord({ guildId, userId, reason }, { accountId });
|
||||||
|
} else {
|
||||||
|
await kickMemberDiscord({ guildId, userId, reason });
|
||||||
|
}
|
||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
case "ban": {
|
case "ban": {
|
||||||
@@ -63,12 +79,24 @@ export async function handleDiscordModerationAction(
|
|||||||
typeof params.deleteMessageDays === "number" && Number.isFinite(params.deleteMessageDays)
|
typeof params.deleteMessageDays === "number" && Number.isFinite(params.deleteMessageDays)
|
||||||
? params.deleteMessageDays
|
? params.deleteMessageDays
|
||||||
: undefined;
|
: undefined;
|
||||||
await banMemberDiscord({
|
if (accountId) {
|
||||||
guildId,
|
await banMemberDiscord(
|
||||||
userId,
|
{
|
||||||
reason,
|
guildId,
|
||||||
deleteMessageDays,
|
userId,
|
||||||
});
|
reason,
|
||||||
|
deleteMessageDays,
|
||||||
|
},
|
||||||
|
{ accountId },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await banMemberDiscord({
|
||||||
|
guildId,
|
||||||
|
userId,
|
||||||
|
reason,
|
||||||
|
deleteMessageDays,
|
||||||
|
});
|
||||||
|
}
|
||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import type { DiscordActionConfig } from "../../config/config.js";
|
import type { DiscordActionConfig } from "../../config/config.js";
|
||||||
import { handleDiscordGuildAction } from "./discord-actions-guild.js";
|
import { handleDiscordGuildAction } from "./discord-actions-guild.js";
|
||||||
import { handleDiscordMessagingAction } from "./discord-actions-messaging.js";
|
import { handleDiscordMessagingAction } from "./discord-actions-messaging.js";
|
||||||
|
import { handleDiscordModerationAction } from "./discord-actions-moderation.js";
|
||||||
|
|
||||||
const createChannelDiscord = vi.fn(async () => ({
|
const createChannelDiscord = vi.fn(async () => ({
|
||||||
id: "new-channel",
|
id: "new-channel",
|
||||||
@@ -20,6 +21,7 @@ const editMessageDiscord = vi.fn(async () => ({}));
|
|||||||
const fetchMessageDiscord = vi.fn(async () => ({}));
|
const fetchMessageDiscord = vi.fn(async () => ({}));
|
||||||
const fetchChannelPermissionsDiscord = vi.fn(async () => ({}));
|
const fetchChannelPermissionsDiscord = vi.fn(async () => ({}));
|
||||||
const fetchReactionsDiscord = vi.fn(async () => ({}));
|
const fetchReactionsDiscord = vi.fn(async () => ({}));
|
||||||
|
const listGuildChannelsDiscord = vi.fn(async () => []);
|
||||||
const listPinsDiscord = vi.fn(async () => ({}));
|
const listPinsDiscord = vi.fn(async () => ({}));
|
||||||
const listThreadsDiscord = vi.fn(async () => ({}));
|
const listThreadsDiscord = vi.fn(async () => ({}));
|
||||||
const moveChannelDiscord = vi.fn(async () => ({ ok: true }));
|
const moveChannelDiscord = vi.fn(async () => ({ ok: true }));
|
||||||
@@ -35,8 +37,12 @@ const sendPollDiscord = vi.fn(async () => ({}));
|
|||||||
const sendStickerDiscord = vi.fn(async () => ({}));
|
const sendStickerDiscord = vi.fn(async () => ({}));
|
||||||
const setChannelPermissionDiscord = vi.fn(async () => ({ ok: true }));
|
const setChannelPermissionDiscord = vi.fn(async () => ({ ok: true }));
|
||||||
const unpinMessageDiscord = vi.fn(async () => ({}));
|
const unpinMessageDiscord = vi.fn(async () => ({}));
|
||||||
|
const timeoutMemberDiscord = vi.fn(async () => ({}));
|
||||||
|
const kickMemberDiscord = vi.fn(async () => ({}));
|
||||||
|
const banMemberDiscord = vi.fn(async () => ({}));
|
||||||
|
|
||||||
vi.mock("../../discord/send.js", () => ({
|
vi.mock("../../discord/send.js", () => ({
|
||||||
|
banMemberDiscord: (...args: unknown[]) => banMemberDiscord(...args),
|
||||||
createChannelDiscord: (...args: unknown[]) => createChannelDiscord(...args),
|
createChannelDiscord: (...args: unknown[]) => createChannelDiscord(...args),
|
||||||
createThreadDiscord: (...args: unknown[]) => createThreadDiscord(...args),
|
createThreadDiscord: (...args: unknown[]) => createThreadDiscord(...args),
|
||||||
deleteChannelDiscord: (...args: unknown[]) => deleteChannelDiscord(...args),
|
deleteChannelDiscord: (...args: unknown[]) => deleteChannelDiscord(...args),
|
||||||
@@ -46,6 +52,8 @@ vi.mock("../../discord/send.js", () => ({
|
|||||||
fetchMessageDiscord: (...args: unknown[]) => fetchMessageDiscord(...args),
|
fetchMessageDiscord: (...args: unknown[]) => fetchMessageDiscord(...args),
|
||||||
fetchChannelPermissionsDiscord: (...args: unknown[]) => fetchChannelPermissionsDiscord(...args),
|
fetchChannelPermissionsDiscord: (...args: unknown[]) => fetchChannelPermissionsDiscord(...args),
|
||||||
fetchReactionsDiscord: (...args: unknown[]) => fetchReactionsDiscord(...args),
|
fetchReactionsDiscord: (...args: unknown[]) => fetchReactionsDiscord(...args),
|
||||||
|
kickMemberDiscord: (...args: unknown[]) => kickMemberDiscord(...args),
|
||||||
|
listGuildChannelsDiscord: (...args: unknown[]) => listGuildChannelsDiscord(...args),
|
||||||
listPinsDiscord: (...args: unknown[]) => listPinsDiscord(...args),
|
listPinsDiscord: (...args: unknown[]) => listPinsDiscord(...args),
|
||||||
listThreadsDiscord: (...args: unknown[]) => listThreadsDiscord(...args),
|
listThreadsDiscord: (...args: unknown[]) => listThreadsDiscord(...args),
|
||||||
moveChannelDiscord: (...args: unknown[]) => moveChannelDiscord(...args),
|
moveChannelDiscord: (...args: unknown[]) => moveChannelDiscord(...args),
|
||||||
@@ -60,12 +68,15 @@ vi.mock("../../discord/send.js", () => ({
|
|||||||
sendPollDiscord: (...args: unknown[]) => sendPollDiscord(...args),
|
sendPollDiscord: (...args: unknown[]) => sendPollDiscord(...args),
|
||||||
sendStickerDiscord: (...args: unknown[]) => sendStickerDiscord(...args),
|
sendStickerDiscord: (...args: unknown[]) => sendStickerDiscord(...args),
|
||||||
setChannelPermissionDiscord: (...args: unknown[]) => setChannelPermissionDiscord(...args),
|
setChannelPermissionDiscord: (...args: unknown[]) => setChannelPermissionDiscord(...args),
|
||||||
|
timeoutMemberDiscord: (...args: unknown[]) => timeoutMemberDiscord(...args),
|
||||||
unpinMessageDiscord: (...args: unknown[]) => unpinMessageDiscord(...args),
|
unpinMessageDiscord: (...args: unknown[]) => unpinMessageDiscord(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const enableAllActions = () => true;
|
const enableAllActions = () => true;
|
||||||
|
|
||||||
const disabledActions = (key: keyof DiscordActionConfig) => key !== "reactions";
|
const disabledActions = (key: keyof DiscordActionConfig) => key !== "reactions";
|
||||||
|
const channelInfoEnabled = (key: keyof DiscordActionConfig) => key === "channelInfo";
|
||||||
|
const moderationEnabled = (key: keyof DiscordActionConfig) => key === "moderation";
|
||||||
|
|
||||||
describe("handleDiscordMessagingAction", () => {
|
describe("handleDiscordMessagingAction", () => {
|
||||||
it("adds reactions", async () => {
|
it("adds reactions", async () => {
|
||||||
@@ -81,6 +92,20 @@ describe("handleDiscordMessagingAction", () => {
|
|||||||
expect(reactMessageDiscord).toHaveBeenCalledWith("C1", "M1", "✅");
|
expect(reactMessageDiscord).toHaveBeenCalledWith("C1", "M1", "✅");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forwards accountId for reactions", async () => {
|
||||||
|
await handleDiscordMessagingAction(
|
||||||
|
"react",
|
||||||
|
{
|
||||||
|
channelId: "C1",
|
||||||
|
messageId: "M1",
|
||||||
|
emoji: "✅",
|
||||||
|
accountId: "ops",
|
||||||
|
},
|
||||||
|
enableAllActions,
|
||||||
|
);
|
||||||
|
expect(reactMessageDiscord).toHaveBeenCalledWith("C1", "M1", "✅", { accountId: "ops" });
|
||||||
|
});
|
||||||
|
|
||||||
it("removes reactions on empty emoji", async () => {
|
it("removes reactions on empty emoji", async () => {
|
||||||
await handleDiscordMessagingAction(
|
await handleDiscordMessagingAction(
|
||||||
"react",
|
"react",
|
||||||
@@ -245,6 +270,15 @@ describe("handleDiscordGuildAction - channel management", () => {
|
|||||||
).rejects.toThrow(/Discord channel management is disabled/);
|
).rejects.toThrow(/Discord channel management is disabled/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forwards accountId for channelList", async () => {
|
||||||
|
await handleDiscordGuildAction(
|
||||||
|
"channelList",
|
||||||
|
{ guildId: "G1", accountId: "ops" },
|
||||||
|
channelInfoEnabled,
|
||||||
|
);
|
||||||
|
expect(listGuildChannelsDiscord).toHaveBeenCalledWith("G1", { accountId: "ops" });
|
||||||
|
});
|
||||||
|
|
||||||
it("edits a channel", async () => {
|
it("edits a channel", async () => {
|
||||||
await handleDiscordGuildAction(
|
await handleDiscordGuildAction(
|
||||||
"channelEdit",
|
"channelEdit",
|
||||||
@@ -448,3 +482,26 @@ describe("handleDiscordGuildAction - channel management", () => {
|
|||||||
expect(removeChannelPermissionDiscord).toHaveBeenCalledWith("C1", "R1");
|
expect(removeChannelPermissionDiscord).toHaveBeenCalledWith("C1", "R1");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("handleDiscordModerationAction", () => {
|
||||||
|
it("forwards accountId for timeout", async () => {
|
||||||
|
await handleDiscordModerationAction(
|
||||||
|
"timeout",
|
||||||
|
{
|
||||||
|
guildId: "G1",
|
||||||
|
userId: "U1",
|
||||||
|
durationMinutes: 5,
|
||||||
|
accountId: "ops",
|
||||||
|
},
|
||||||
|
moderationEnabled,
|
||||||
|
);
|
||||||
|
expect(timeoutMemberDiscord).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
guildId: "G1",
|
||||||
|
userId: "U1",
|
||||||
|
durationMinutes: 5,
|
||||||
|
}),
|
||||||
|
{ accountId: "ops" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -342,6 +342,9 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
|||||||
}) as ChannelMessageActionName;
|
}) as ChannelMessageActionName;
|
||||||
|
|
||||||
const accountId = readStringParam(params, "accountId") ?? agentAccountId;
|
const accountId = readStringParam(params, "accountId") ?? agentAccountId;
|
||||||
|
if (accountId) {
|
||||||
|
params.accountId = accountId;
|
||||||
|
}
|
||||||
|
|
||||||
const gateway = {
|
const gateway = {
|
||||||
url: readStringParam(params, "gatewayUrl", { trim: false }),
|
url: readStringParam(params, "gatewayUrl", { trim: false }),
|
||||||
|
|||||||
@@ -1,11 +1,41 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../../../config/config.js";
|
import type { ClawdbotConfig } from "../../../config/config.js";
|
||||||
import { discordMessageActions } from "./discord.js";
|
type SendMessageDiscord = typeof import("../../../discord/send.js").sendMessageDiscord;
|
||||||
|
type SendPollDiscord = typeof import("../../../discord/send.js").sendPollDiscord;
|
||||||
|
|
||||||
|
const sendMessageDiscord = vi.fn<Parameters<SendMessageDiscord>, ReturnType<SendMessageDiscord>>(
|
||||||
|
async () => ({ ok: true }) as Awaited<ReturnType<SendMessageDiscord>>,
|
||||||
|
);
|
||||||
|
const sendPollDiscord = vi.fn<Parameters<SendPollDiscord>, ReturnType<SendPollDiscord>>(
|
||||||
|
async () => ({ ok: true }) as Awaited<ReturnType<SendPollDiscord>>,
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mock("../../../discord/send.js", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("../../../discord/send.js")>(
|
||||||
|
"../../../discord/send.js",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
sendMessageDiscord: (...args: Parameters<SendMessageDiscord>) => sendMessageDiscord(...args),
|
||||||
|
sendPollDiscord: (...args: Parameters<SendPollDiscord>) => sendPollDiscord(...args),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadHandleDiscordMessageAction = async () => {
|
||||||
|
const mod = await import("./discord/handle-action.js");
|
||||||
|
return mod.handleDiscordMessageAction;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadDiscordMessageActions = async () => {
|
||||||
|
const mod = await import("./discord.js");
|
||||||
|
return mod.discordMessageActions;
|
||||||
|
};
|
||||||
|
|
||||||
describe("discord message actions", () => {
|
describe("discord message actions", () => {
|
||||||
it("lists channel and upload actions by default", () => {
|
it("lists channel and upload actions by default", async () => {
|
||||||
const cfg = { channels: { discord: { token: "d0" } } } as ClawdbotConfig;
|
const cfg = { channels: { discord: { token: "d0" } } } as ClawdbotConfig;
|
||||||
|
const discordMessageActions = await loadDiscordMessageActions();
|
||||||
const actions = discordMessageActions.listActions?.({ cfg }) ?? [];
|
const actions = discordMessageActions.listActions?.({ cfg }) ?? [];
|
||||||
|
|
||||||
expect(actions).toContain("emoji-upload");
|
expect(actions).toContain("emoji-upload");
|
||||||
@@ -13,12 +43,88 @@ describe("discord message actions", () => {
|
|||||||
expect(actions).toContain("channel-create");
|
expect(actions).toContain("channel-create");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("respects disabled channel actions", () => {
|
it("respects disabled channel actions", async () => {
|
||||||
const cfg = {
|
const cfg = {
|
||||||
channels: { discord: { token: "d0", actions: { channels: false } } },
|
channels: { discord: { token: "d0", actions: { channels: false } } },
|
||||||
} as ClawdbotConfig;
|
} as ClawdbotConfig;
|
||||||
|
const discordMessageActions = await loadDiscordMessageActions();
|
||||||
const actions = discordMessageActions.listActions?.({ cfg }) ?? [];
|
const actions = discordMessageActions.listActions?.({ cfg }) ?? [];
|
||||||
|
|
||||||
expect(actions).not.toContain("channel-create");
|
expect(actions).not.toContain("channel-create");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("handleDiscordMessageAction", () => {
|
||||||
|
it("forwards context accountId for send", async () => {
|
||||||
|
sendMessageDiscord.mockClear();
|
||||||
|
const handleDiscordMessageAction = await loadHandleDiscordMessageAction();
|
||||||
|
|
||||||
|
await handleDiscordMessageAction({
|
||||||
|
action: "send",
|
||||||
|
params: {
|
||||||
|
to: "channel:123",
|
||||||
|
message: "hi",
|
||||||
|
},
|
||||||
|
cfg: {} as ClawdbotConfig,
|
||||||
|
accountId: "ops",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendMessageDiscord).toHaveBeenCalledWith(
|
||||||
|
"channel:123",
|
||||||
|
"hi",
|
||||||
|
expect.objectContaining({
|
||||||
|
accountId: "ops",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to params accountId when context missing", async () => {
|
||||||
|
sendPollDiscord.mockClear();
|
||||||
|
const handleDiscordMessageAction = await loadHandleDiscordMessageAction();
|
||||||
|
|
||||||
|
await handleDiscordMessageAction({
|
||||||
|
action: "poll",
|
||||||
|
params: {
|
||||||
|
to: "channel:123",
|
||||||
|
pollQuestion: "Ready?",
|
||||||
|
pollOption: ["Yes", "No"],
|
||||||
|
accountId: "marve",
|
||||||
|
},
|
||||||
|
cfg: {} as ClawdbotConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendPollDiscord).toHaveBeenCalledWith(
|
||||||
|
"channel:123",
|
||||||
|
expect.objectContaining({
|
||||||
|
question: "Ready?",
|
||||||
|
options: ["Yes", "No"],
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
accountId: "marve",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards accountId for thread replies", async () => {
|
||||||
|
sendMessageDiscord.mockClear();
|
||||||
|
const handleDiscordMessageAction = await loadHandleDiscordMessageAction();
|
||||||
|
|
||||||
|
await handleDiscordMessageAction({
|
||||||
|
action: "thread-reply",
|
||||||
|
params: {
|
||||||
|
channelId: "123",
|
||||||
|
message: "hi",
|
||||||
|
},
|
||||||
|
cfg: {} as ClawdbotConfig,
|
||||||
|
accountId: "ops",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendMessageDiscord).toHaveBeenCalledWith(
|
||||||
|
"channel:123",
|
||||||
|
"hi",
|
||||||
|
expect.objectContaining({
|
||||||
|
accountId: "ops",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -80,7 +80,7 @@ export const discordMessageActions: ChannelMessageActionAdapter = {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
handleAction: async ({ action, params, cfg }) => {
|
handleAction: async ({ action, params, cfg, accountId }) => {
|
||||||
return await handleDiscordMessageAction({ action, params, cfg });
|
return await handleDiscordMessageAction({ action, params, cfg, accountId });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
import { handleDiscordAction } from "../../../../agents/tools/discord-actions.js";
|
import { handleDiscordAction } from "../../../../agents/tools/discord-actions.js";
|
||||||
import type { ChannelMessageActionContext } from "../../types.js";
|
import type { ChannelMessageActionContext } from "../../types.js";
|
||||||
|
|
||||||
type Ctx = Pick<ChannelMessageActionContext, "action" | "params" | "cfg">;
|
type Ctx = Pick<ChannelMessageActionContext, "action" | "params" | "cfg" | "accountId">;
|
||||||
|
|
||||||
export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
||||||
ctx: Ctx;
|
ctx: Ctx;
|
||||||
@@ -16,27 +16,37 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
|||||||
}): Promise<AgentToolResult<unknown> | undefined> {
|
}): Promise<AgentToolResult<unknown> | undefined> {
|
||||||
const { ctx, resolveChannelId, readParentIdParam } = params;
|
const { ctx, resolveChannelId, readParentIdParam } = params;
|
||||||
const { action, params: actionParams, cfg } = ctx;
|
const { action, params: actionParams, cfg } = ctx;
|
||||||
|
const accountId = ctx.accountId ?? readStringParam(actionParams, "accountId");
|
||||||
|
|
||||||
if (action === "member-info") {
|
if (action === "member-info") {
|
||||||
const userId = readStringParam(actionParams, "userId", { required: true });
|
const userId = readStringParam(actionParams, "userId", { required: true });
|
||||||
const guildId = readStringParam(actionParams, "guildId", {
|
const guildId = readStringParam(actionParams, "guildId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
return await handleDiscordAction({ action: "memberInfo", guildId, userId }, cfg);
|
return await handleDiscordAction(
|
||||||
|
{ action: "memberInfo", accountId: accountId ?? undefined, guildId, userId },
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "role-info") {
|
if (action === "role-info") {
|
||||||
const guildId = readStringParam(actionParams, "guildId", {
|
const guildId = readStringParam(actionParams, "guildId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
return await handleDiscordAction({ action: "roleInfo", guildId }, cfg);
|
return await handleDiscordAction(
|
||||||
|
{ action: "roleInfo", accountId: accountId ?? undefined, guildId },
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "emoji-list") {
|
if (action === "emoji-list") {
|
||||||
const guildId = readStringParam(actionParams, "guildId", {
|
const guildId = readStringParam(actionParams, "guildId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
return await handleDiscordAction({ action: "emojiList", guildId }, cfg);
|
return await handleDiscordAction(
|
||||||
|
{ action: "emojiList", accountId: accountId ?? undefined, guildId },
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "emoji-upload") {
|
if (action === "emoji-upload") {
|
||||||
@@ -50,7 +60,14 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
|||||||
});
|
});
|
||||||
const roleIds = readStringArrayParam(actionParams, "roleIds");
|
const roleIds = readStringArrayParam(actionParams, "roleIds");
|
||||||
return await handleDiscordAction(
|
return await handleDiscordAction(
|
||||||
{ action: "emojiUpload", guildId, name, mediaUrl, roleIds },
|
{
|
||||||
|
action: "emojiUpload",
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
guildId,
|
||||||
|
name,
|
||||||
|
mediaUrl,
|
||||||
|
roleIds,
|
||||||
|
},
|
||||||
cfg,
|
cfg,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -73,7 +90,15 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
|||||||
trim: false,
|
trim: false,
|
||||||
});
|
});
|
||||||
return await handleDiscordAction(
|
return await handleDiscordAction(
|
||||||
{ action: "stickerUpload", guildId, name, description, tags, mediaUrl },
|
{
|
||||||
|
action: "stickerUpload",
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
guildId,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
tags,
|
||||||
|
mediaUrl,
|
||||||
|
},
|
||||||
cfg,
|
cfg,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -87,6 +112,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
|||||||
return await handleDiscordAction(
|
return await handleDiscordAction(
|
||||||
{
|
{
|
||||||
action: action === "role-add" ? "roleAdd" : "roleRemove",
|
action: action === "role-add" ? "roleAdd" : "roleRemove",
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
guildId,
|
guildId,
|
||||||
userId,
|
userId,
|
||||||
roleId,
|
roleId,
|
||||||
@@ -99,14 +125,20 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
|||||||
const channelId = readStringParam(actionParams, "channelId", {
|
const channelId = readStringParam(actionParams, "channelId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
return await handleDiscordAction({ action: "channelInfo", channelId }, cfg);
|
return await handleDiscordAction(
|
||||||
|
{ action: "channelInfo", accountId: accountId ?? undefined, channelId },
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "channel-list") {
|
if (action === "channel-list") {
|
||||||
const guildId = readStringParam(actionParams, "guildId", {
|
const guildId = readStringParam(actionParams, "guildId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
return await handleDiscordAction({ action: "channelList", guildId }, cfg);
|
return await handleDiscordAction(
|
||||||
|
{ action: "channelList", accountId: accountId ?? undefined, guildId },
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "channel-create") {
|
if (action === "channel-create") {
|
||||||
@@ -124,6 +156,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
|||||||
return await handleDiscordAction(
|
return await handleDiscordAction(
|
||||||
{
|
{
|
||||||
action: "channelCreate",
|
action: "channelCreate",
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
guildId,
|
guildId,
|
||||||
name,
|
name,
|
||||||
type: type ?? undefined,
|
type: type ?? undefined,
|
||||||
@@ -153,6 +186,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
|||||||
return await handleDiscordAction(
|
return await handleDiscordAction(
|
||||||
{
|
{
|
||||||
action: "channelEdit",
|
action: "channelEdit",
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
channelId,
|
channelId,
|
||||||
name: name ?? undefined,
|
name: name ?? undefined,
|
||||||
topic: topic ?? undefined,
|
topic: topic ?? undefined,
|
||||||
@@ -169,7 +203,10 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
|||||||
const channelId = readStringParam(actionParams, "channelId", {
|
const channelId = readStringParam(actionParams, "channelId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
return await handleDiscordAction({ action: "channelDelete", channelId }, cfg);
|
return await handleDiscordAction(
|
||||||
|
{ action: "channelDelete", accountId: accountId ?? undefined, channelId },
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "channel-move") {
|
if (action === "channel-move") {
|
||||||
@@ -186,6 +223,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
|||||||
return await handleDiscordAction(
|
return await handleDiscordAction(
|
||||||
{
|
{
|
||||||
action: "channelMove",
|
action: "channelMove",
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
guildId,
|
guildId,
|
||||||
channelId,
|
channelId,
|
||||||
parentId: parentId === undefined ? undefined : parentId,
|
parentId: parentId === undefined ? undefined : parentId,
|
||||||
@@ -206,6 +244,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
|||||||
return await handleDiscordAction(
|
return await handleDiscordAction(
|
||||||
{
|
{
|
||||||
action: "categoryCreate",
|
action: "categoryCreate",
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
guildId,
|
guildId,
|
||||||
name,
|
name,
|
||||||
position: position ?? undefined,
|
position: position ?? undefined,
|
||||||
@@ -225,6 +264,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
|||||||
return await handleDiscordAction(
|
return await handleDiscordAction(
|
||||||
{
|
{
|
||||||
action: "categoryEdit",
|
action: "categoryEdit",
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
categoryId,
|
categoryId,
|
||||||
name: name ?? undefined,
|
name: name ?? undefined,
|
||||||
position: position ?? undefined,
|
position: position ?? undefined,
|
||||||
@@ -237,7 +277,10 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
|||||||
const categoryId = readStringParam(actionParams, "categoryId", {
|
const categoryId = readStringParam(actionParams, "categoryId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
return await handleDiscordAction({ action: "categoryDelete", categoryId }, cfg);
|
return await handleDiscordAction(
|
||||||
|
{ action: "categoryDelete", accountId: accountId ?? undefined, categoryId },
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "voice-status") {
|
if (action === "voice-status") {
|
||||||
@@ -245,14 +288,20 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
|||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const userId = readStringParam(actionParams, "userId", { required: true });
|
const userId = readStringParam(actionParams, "userId", { required: true });
|
||||||
return await handleDiscordAction({ action: "voiceStatus", guildId, userId }, cfg);
|
return await handleDiscordAction(
|
||||||
|
{ action: "voiceStatus", accountId: accountId ?? undefined, guildId, userId },
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "event-list") {
|
if (action === "event-list") {
|
||||||
const guildId = readStringParam(actionParams, "guildId", {
|
const guildId = readStringParam(actionParams, "guildId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
return await handleDiscordAction({ action: "eventList", guildId }, cfg);
|
return await handleDiscordAction(
|
||||||
|
{ action: "eventList", accountId: accountId ?? undefined, guildId },
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "event-create") {
|
if (action === "event-create") {
|
||||||
@@ -271,6 +320,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
|||||||
return await handleDiscordAction(
|
return await handleDiscordAction(
|
||||||
{
|
{
|
||||||
action: "eventCreate",
|
action: "eventCreate",
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
guildId,
|
guildId,
|
||||||
name,
|
name,
|
||||||
startTime,
|
startTime,
|
||||||
@@ -301,6 +351,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
|||||||
return await handleDiscordAction(
|
return await handleDiscordAction(
|
||||||
{
|
{
|
||||||
action: discordAction,
|
action: discordAction,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
guildId,
|
guildId,
|
||||||
userId,
|
userId,
|
||||||
durationMinutes,
|
durationMinutes,
|
||||||
@@ -325,6 +376,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
|||||||
return await handleDiscordAction(
|
return await handleDiscordAction(
|
||||||
{
|
{
|
||||||
action: "threadList",
|
action: "threadList",
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
guildId,
|
guildId,
|
||||||
channelId,
|
channelId,
|
||||||
includeArchived,
|
includeArchived,
|
||||||
@@ -344,6 +396,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
|||||||
return await handleDiscordAction(
|
return await handleDiscordAction(
|
||||||
{
|
{
|
||||||
action: "threadReply",
|
action: "threadReply",
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
channelId: resolveChannelId(),
|
channelId: resolveChannelId(),
|
||||||
content,
|
content,
|
||||||
mediaUrl: mediaUrl ?? undefined,
|
mediaUrl: mediaUrl ?? undefined,
|
||||||
@@ -361,6 +414,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
|||||||
return await handleDiscordAction(
|
return await handleDiscordAction(
|
||||||
{
|
{
|
||||||
action: "searchMessages",
|
action: "searchMessages",
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
guildId,
|
guildId,
|
||||||
content: query,
|
content: query,
|
||||||
channelId: readStringParam(actionParams, "channelId"),
|
channelId: readStringParam(actionParams, "channelId"),
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ function readParentIdParam(params: Record<string, unknown>): string | null | und
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function handleDiscordMessageAction(
|
export async function handleDiscordMessageAction(
|
||||||
ctx: Pick<ChannelMessageActionContext, "action" | "params" | "cfg">,
|
ctx: Pick<ChannelMessageActionContext, "action" | "params" | "cfg" | "accountId">,
|
||||||
): Promise<AgentToolResult<unknown>> {
|
): Promise<AgentToolResult<unknown>> {
|
||||||
const { action, params, cfg } = ctx;
|
const { action, params, cfg } = ctx;
|
||||||
|
const accountId = ctx.accountId ?? readStringParam(params, "accountId");
|
||||||
|
|
||||||
const resolveChannelId = () =>
|
const resolveChannelId = () =>
|
||||||
resolveDiscordChannelId(
|
resolveDiscordChannelId(
|
||||||
@@ -39,6 +40,7 @@ export async function handleDiscordMessageAction(
|
|||||||
return await handleDiscordAction(
|
return await handleDiscordAction(
|
||||||
{
|
{
|
||||||
action: "sendMessage",
|
action: "sendMessage",
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
to,
|
to,
|
||||||
content,
|
content,
|
||||||
mediaUrl: mediaUrl ?? undefined,
|
mediaUrl: mediaUrl ?? undefined,
|
||||||
@@ -62,6 +64,7 @@ export async function handleDiscordMessageAction(
|
|||||||
return await handleDiscordAction(
|
return await handleDiscordAction(
|
||||||
{
|
{
|
||||||
action: "poll",
|
action: "poll",
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
to,
|
to,
|
||||||
question,
|
question,
|
||||||
answers,
|
answers,
|
||||||
@@ -80,6 +83,7 @@ export async function handleDiscordMessageAction(
|
|||||||
return await handleDiscordAction(
|
return await handleDiscordAction(
|
||||||
{
|
{
|
||||||
action: "react",
|
action: "react",
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
channelId: resolveChannelId(),
|
channelId: resolveChannelId(),
|
||||||
messageId,
|
messageId,
|
||||||
emoji,
|
emoji,
|
||||||
@@ -93,7 +97,13 @@ export async function handleDiscordMessageAction(
|
|||||||
const messageId = readStringParam(params, "messageId", { required: true });
|
const messageId = readStringParam(params, "messageId", { required: true });
|
||||||
const limit = readNumberParam(params, "limit", { integer: true });
|
const limit = readNumberParam(params, "limit", { integer: true });
|
||||||
return await handleDiscordAction(
|
return await handleDiscordAction(
|
||||||
{ action: "reactions", channelId: resolveChannelId(), messageId, limit },
|
{
|
||||||
|
action: "reactions",
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
channelId: resolveChannelId(),
|
||||||
|
messageId,
|
||||||
|
limit,
|
||||||
|
},
|
||||||
cfg,
|
cfg,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -103,6 +113,7 @@ export async function handleDiscordMessageAction(
|
|||||||
return await handleDiscordAction(
|
return await handleDiscordAction(
|
||||||
{
|
{
|
||||||
action: "readMessages",
|
action: "readMessages",
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
channelId: resolveChannelId(),
|
channelId: resolveChannelId(),
|
||||||
limit,
|
limit,
|
||||||
before: readStringParam(params, "before"),
|
before: readStringParam(params, "before"),
|
||||||
@@ -119,6 +130,7 @@ export async function handleDiscordMessageAction(
|
|||||||
return await handleDiscordAction(
|
return await handleDiscordAction(
|
||||||
{
|
{
|
||||||
action: "editMessage",
|
action: "editMessage",
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
channelId: resolveChannelId(),
|
channelId: resolveChannelId(),
|
||||||
messageId,
|
messageId,
|
||||||
content,
|
content,
|
||||||
@@ -130,7 +142,12 @@ export async function handleDiscordMessageAction(
|
|||||||
if (action === "delete") {
|
if (action === "delete") {
|
||||||
const messageId = readStringParam(params, "messageId", { required: true });
|
const messageId = readStringParam(params, "messageId", { required: true });
|
||||||
return await handleDiscordAction(
|
return await handleDiscordAction(
|
||||||
{ action: "deleteMessage", channelId: resolveChannelId(), messageId },
|
{
|
||||||
|
action: "deleteMessage",
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
channelId: resolveChannelId(),
|
||||||
|
messageId,
|
||||||
|
},
|
||||||
cfg,
|
cfg,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -141,6 +158,7 @@ export async function handleDiscordMessageAction(
|
|||||||
return await handleDiscordAction(
|
return await handleDiscordAction(
|
||||||
{
|
{
|
||||||
action: action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins",
|
action: action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins",
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
channelId: resolveChannelId(),
|
channelId: resolveChannelId(),
|
||||||
messageId,
|
messageId,
|
||||||
},
|
},
|
||||||
@@ -149,7 +167,14 @@ export async function handleDiscordMessageAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action === "permissions") {
|
if (action === "permissions") {
|
||||||
return await handleDiscordAction({ action: "permissions", channelId: resolveChannelId() }, cfg);
|
return await handleDiscordAction(
|
||||||
|
{
|
||||||
|
action: "permissions",
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
channelId: resolveChannelId(),
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "thread-create") {
|
if (action === "thread-create") {
|
||||||
@@ -161,6 +186,7 @@ export async function handleDiscordMessageAction(
|
|||||||
return await handleDiscordAction(
|
return await handleDiscordAction(
|
||||||
{
|
{
|
||||||
action: "threadCreate",
|
action: "threadCreate",
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
channelId: resolveChannelId(),
|
channelId: resolveChannelId(),
|
||||||
name,
|
name,
|
||||||
messageId,
|
messageId,
|
||||||
@@ -179,6 +205,7 @@ export async function handleDiscordMessageAction(
|
|||||||
return await handleDiscordAction(
|
return await handleDiscordAction(
|
||||||
{
|
{
|
||||||
action: "sticker",
|
action: "sticker",
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
to: readStringParam(params, "to", { required: true }),
|
to: readStringParam(params, "to", { required: true }),
|
||||||
stickerIds,
|
stickerIds,
|
||||||
content: readStringParam(params, "message"),
|
content: readStringParam(params, "message"),
|
||||||
|
|||||||
@@ -38,6 +38,19 @@ export async function runDaemonUninstall(opts: DaemonLifecycleOptions = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const service = resolveGatewayService();
|
const service = resolveGatewayService();
|
||||||
|
let loaded = false;
|
||||||
|
try {
|
||||||
|
loaded = await service.isLoaded({ env: process.env });
|
||||||
|
} catch {
|
||||||
|
loaded = false;
|
||||||
|
}
|
||||||
|
if (loaded) {
|
||||||
|
try {
|
||||||
|
await service.stop({ env: process.env, stdout });
|
||||||
|
} catch {
|
||||||
|
// Best-effort stop; final loaded check gates success.
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await service.uninstall({ env: process.env, stdout });
|
await service.uninstall({ env: process.env, stdout });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -45,12 +58,16 @@ export async function runDaemonUninstall(opts: DaemonLifecycleOptions = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let loaded = false;
|
loaded = false;
|
||||||
try {
|
try {
|
||||||
loaded = await service.isLoaded({ env: process.env });
|
loaded = await service.isLoaded({ env: process.env });
|
||||||
} catch {
|
} catch {
|
||||||
loaded = false;
|
loaded = false;
|
||||||
}
|
}
|
||||||
|
if (loaded) {
|
||||||
|
fail("Gateway service still loaded after uninstall.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
emit({
|
emit({
|
||||||
ok: true,
|
ok: true,
|
||||||
result: "uninstalled",
|
result: "uninstalled",
|
||||||
|
|||||||
@@ -415,7 +415,7 @@ describe("update-cli", () => {
|
|||||||
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
|
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updateCommand restarts daemon when --restart is set", async () => {
|
it("updateCommand restarts daemon by default", async () => {
|
||||||
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
||||||
const { runDaemonRestart } = await import("./daemon-cli.js");
|
const { runDaemonRestart } = await import("./daemon-cli.js");
|
||||||
const { updateCommand } = await import("./update-cli.js");
|
const { updateCommand } = await import("./update-cli.js");
|
||||||
@@ -430,11 +430,30 @@ describe("update-cli", () => {
|
|||||||
vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult);
|
vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult);
|
||||||
vi.mocked(runDaemonRestart).mockResolvedValue(true);
|
vi.mocked(runDaemonRestart).mockResolvedValue(true);
|
||||||
|
|
||||||
await updateCommand({ restart: true });
|
await updateCommand({});
|
||||||
|
|
||||||
expect(runDaemonRestart).toHaveBeenCalled();
|
expect(runDaemonRestart).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("updateCommand skips restart when --no-restart is set", async () => {
|
||||||
|
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
||||||
|
const { runDaemonRestart } = await import("./daemon-cli.js");
|
||||||
|
const { updateCommand } = await import("./update-cli.js");
|
||||||
|
|
||||||
|
const mockResult: UpdateRunResult = {
|
||||||
|
status: "ok",
|
||||||
|
mode: "git",
|
||||||
|
steps: [],
|
||||||
|
durationMs: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult);
|
||||||
|
|
||||||
|
await updateCommand({ restart: false });
|
||||||
|
|
||||||
|
expect(runDaemonRestart).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("updateCommand skips success message when restart does not run", async () => {
|
it("updateCommand skips success message when restart does not run", async () => {
|
||||||
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
||||||
const { runDaemonRestart } = await import("./daemon-cli.js");
|
const { runDaemonRestart } = await import("./daemon-cli.js");
|
||||||
|
|||||||
@@ -553,6 +553,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
|||||||
process.noDeprecation = true;
|
process.noDeprecation = true;
|
||||||
process.env.NODE_NO_WARNINGS = "1";
|
process.env.NODE_NO_WARNINGS = "1";
|
||||||
const timeoutMs = opts.timeout ? Number.parseInt(opts.timeout, 10) * 1000 : undefined;
|
const timeoutMs = opts.timeout ? Number.parseInt(opts.timeout, 10) * 1000 : undefined;
|
||||||
|
const shouldRestart = opts.restart !== false;
|
||||||
|
|
||||||
if (timeoutMs !== undefined && (Number.isNaN(timeoutMs) || timeoutMs <= 0)) {
|
if (timeoutMs !== undefined && (Number.isNaN(timeoutMs) || timeoutMs <= 0)) {
|
||||||
defaultRuntime.error("--timeout must be a positive integer (seconds)");
|
defaultRuntime.error("--timeout must be a positive integer (seconds)");
|
||||||
@@ -898,7 +899,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Restart service if requested
|
// Restart service if requested
|
||||||
if (opts.restart) {
|
if (shouldRestart) {
|
||||||
if (!opts.json) {
|
if (!opts.json) {
|
||||||
defaultRuntime.log("");
|
defaultRuntime.log("");
|
||||||
defaultRuntime.log(theme.heading("Restarting service..."));
|
defaultRuntime.log(theme.heading("Restarting service..."));
|
||||||
@@ -1068,7 +1069,7 @@ export async function updateWizardCommand(opts: UpdateWizardOptions = {}): Promi
|
|||||||
|
|
||||||
const restart = await confirm({
|
const restart = await confirm({
|
||||||
message: stylePromptMessage("Restart the gateway service after update?"),
|
message: stylePromptMessage("Restart the gateway service after update?"),
|
||||||
initialValue: false,
|
initialValue: true,
|
||||||
});
|
});
|
||||||
if (isCancel(restart)) {
|
if (isCancel(restart)) {
|
||||||
defaultRuntime.log(theme.muted("Update cancelled."));
|
defaultRuntime.log(theme.muted("Update cancelled."));
|
||||||
@@ -1093,7 +1094,7 @@ export function registerUpdateCli(program: Command) {
|
|||||||
.command("update")
|
.command("update")
|
||||||
.description("Update Clawdbot to the latest version")
|
.description("Update Clawdbot to the latest version")
|
||||||
.option("--json", "Output result as JSON", false)
|
.option("--json", "Output result as JSON", false)
|
||||||
.option("--restart", "Restart the gateway service after a successful update", false)
|
.option("--no-restart", "Skip restarting the gateway service after a successful update")
|
||||||
.option("--channel <stable|beta|dev>", "Persist update channel (git + npm)")
|
.option("--channel <stable|beta|dev>", "Persist update channel (git + npm)")
|
||||||
.option("--tag <dist-tag|version>", "Override npm dist-tag or version for this update")
|
.option("--tag <dist-tag|version>", "Override npm dist-tag or version for this update")
|
||||||
.option("--timeout <seconds>", "Timeout for each update step in seconds (default: 1200)")
|
.option("--timeout <seconds>", "Timeout for each update step in seconds (default: 1200)")
|
||||||
@@ -1104,7 +1105,7 @@ export function registerUpdateCli(program: Command) {
|
|||||||
["clawdbot update --channel beta", "Switch to beta channel (git + npm)"],
|
["clawdbot update --channel beta", "Switch to beta channel (git + npm)"],
|
||||||
["clawdbot update --channel dev", "Switch to dev channel (git + npm)"],
|
["clawdbot update --channel dev", "Switch to dev channel (git + npm)"],
|
||||||
["clawdbot update --tag beta", "One-off update to a dist-tag or version"],
|
["clawdbot update --tag beta", "One-off update to a dist-tag or version"],
|
||||||
["clawdbot update --restart", "Update and restart the service"],
|
["clawdbot update --no-restart", "Update without restarting the service"],
|
||||||
["clawdbot update --json", "Output result as JSON"],
|
["clawdbot update --json", "Output result as JSON"],
|
||||||
["clawdbot update --yes", "Non-interactive (accept downgrade prompts)"],
|
["clawdbot update --yes", "Non-interactive (accept downgrade prompts)"],
|
||||||
["clawdbot update wizard", "Interactive update wizard"],
|
["clawdbot update wizard", "Interactive update wizard"],
|
||||||
|
|||||||
@@ -198,10 +198,20 @@ export async function applyAuthChoiceAnthropic(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (params.authChoice === "apiKey") {
|
if (params.authChoice === "apiKey") {
|
||||||
|
if (params.opts?.tokenProvider && params.opts.tokenProvider !== "anthropic") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
let nextConfig = params.config;
|
let nextConfig = params.config;
|
||||||
let hasCredential = false;
|
let hasCredential = false;
|
||||||
const envKey = process.env.ANTHROPIC_API_KEY?.trim();
|
const envKey = process.env.ANTHROPIC_API_KEY?.trim();
|
||||||
if (envKey) {
|
|
||||||
|
if (params.opts?.token) {
|
||||||
|
await setAnthropicApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir);
|
||||||
|
hasCredential = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasCredential && envKey) {
|
||||||
const useExisting = await params.prompter.confirm({
|
const useExisting = await params.prompter.confirm({
|
||||||
message: `Use existing ANTHROPIC_API_KEY (env, ${formatApiKeyPreview(envKey)})?`,
|
message: `Use existing ANTHROPIC_API_KEY (env, ${formatApiKeyPreview(envKey)})?`,
|
||||||
initialValue: true,
|
initialValue: true,
|
||||||
|
|||||||
@@ -56,7 +56,33 @@ export async function applyAuthChoiceApiProviders(
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (params.authChoice === "openrouter-api-key") {
|
let authChoice = params.authChoice;
|
||||||
|
if (
|
||||||
|
authChoice === "apiKey" &&
|
||||||
|
params.opts?.tokenProvider &&
|
||||||
|
params.opts.tokenProvider !== "anthropic" &&
|
||||||
|
params.opts.tokenProvider !== "openai"
|
||||||
|
) {
|
||||||
|
if (params.opts.tokenProvider === "openrouter") {
|
||||||
|
authChoice = "openrouter-api-key";
|
||||||
|
} else if (params.opts.tokenProvider === "vercel-ai-gateway") {
|
||||||
|
authChoice = "ai-gateway-api-key";
|
||||||
|
} else if (params.opts.tokenProvider === "moonshot") {
|
||||||
|
authChoice = "moonshot-api-key";
|
||||||
|
} else if (params.opts.tokenProvider === "kimi-code") {
|
||||||
|
authChoice = "kimi-code-api-key";
|
||||||
|
} else if (params.opts.tokenProvider === "google") {
|
||||||
|
authChoice = "gemini-api-key";
|
||||||
|
} else if (params.opts.tokenProvider === "zai") {
|
||||||
|
authChoice = "zai-api-key";
|
||||||
|
} else if (params.opts.tokenProvider === "synthetic") {
|
||||||
|
authChoice = "synthetic-api-key";
|
||||||
|
} else if (params.opts.tokenProvider === "opencode") {
|
||||||
|
authChoice = "opencode-zen";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authChoice === "openrouter-api-key") {
|
||||||
const store = ensureAuthProfileStore(params.agentDir, {
|
const store = ensureAuthProfileStore(params.agentDir, {
|
||||||
allowKeychainPrompt: false,
|
allowKeychainPrompt: false,
|
||||||
});
|
});
|
||||||
@@ -82,6 +108,11 @@ export async function applyAuthChoiceApiProviders(
|
|||||||
hasCredential = true;
|
hasCredential = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "openrouter") {
|
||||||
|
await setOpenrouterApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir);
|
||||||
|
hasCredential = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (!hasCredential) {
|
if (!hasCredential) {
|
||||||
const envKey = resolveEnvApiKey("openrouter");
|
const envKey = resolveEnvApiKey("openrouter");
|
||||||
if (envKey) {
|
if (envKey) {
|
||||||
@@ -129,8 +160,18 @@ export async function applyAuthChoiceApiProviders(
|
|||||||
return { config: nextConfig, agentModelOverride };
|
return { config: nextConfig, agentModelOverride };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.authChoice === "ai-gateway-api-key") {
|
if (authChoice === "ai-gateway-api-key") {
|
||||||
let hasCredential = false;
|
let hasCredential = false;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!hasCredential &&
|
||||||
|
params.opts?.token &&
|
||||||
|
params.opts?.tokenProvider === "vercel-ai-gateway"
|
||||||
|
) {
|
||||||
|
await setVercelAiGatewayApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir);
|
||||||
|
hasCredential = true;
|
||||||
|
}
|
||||||
|
|
||||||
const envKey = resolveEnvApiKey("vercel-ai-gateway");
|
const envKey = resolveEnvApiKey("vercel-ai-gateway");
|
||||||
if (envKey) {
|
if (envKey) {
|
||||||
const useExisting = await params.prompter.confirm({
|
const useExisting = await params.prompter.confirm({
|
||||||
@@ -171,8 +212,14 @@ export async function applyAuthChoiceApiProviders(
|
|||||||
return { config: nextConfig, agentModelOverride };
|
return { config: nextConfig, agentModelOverride };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.authChoice === "moonshot-api-key") {
|
if (authChoice === "moonshot-api-key") {
|
||||||
let hasCredential = false;
|
let hasCredential = false;
|
||||||
|
|
||||||
|
if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "moonshot") {
|
||||||
|
await setMoonshotApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir);
|
||||||
|
hasCredential = true;
|
||||||
|
}
|
||||||
|
|
||||||
const envKey = resolveEnvApiKey("moonshot");
|
const envKey = resolveEnvApiKey("moonshot");
|
||||||
if (envKey) {
|
if (envKey) {
|
||||||
const useExisting = await params.prompter.confirm({
|
const useExisting = await params.prompter.confirm({
|
||||||
@@ -212,15 +259,22 @@ export async function applyAuthChoiceApiProviders(
|
|||||||
return { config: nextConfig, agentModelOverride };
|
return { config: nextConfig, agentModelOverride };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.authChoice === "kimi-code-api-key") {
|
if (authChoice === "kimi-code-api-key") {
|
||||||
await params.prompter.note(
|
|
||||||
[
|
|
||||||
"Kimi Code uses a dedicated endpoint and API key.",
|
|
||||||
"Get your API key at: https://www.kimi.com/code/en",
|
|
||||||
].join("\n"),
|
|
||||||
"Kimi Code",
|
|
||||||
);
|
|
||||||
let hasCredential = false;
|
let hasCredential = false;
|
||||||
|
if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "kimi-code") {
|
||||||
|
await setKimiCodeApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir);
|
||||||
|
hasCredential = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasCredential) {
|
||||||
|
await params.prompter.note(
|
||||||
|
[
|
||||||
|
"Kimi Code uses a dedicated endpoint and API key.",
|
||||||
|
"Get your API key at: https://www.kimi.com/code/en",
|
||||||
|
].join("\n"),
|
||||||
|
"Kimi Code",
|
||||||
|
);
|
||||||
|
}
|
||||||
const envKey = resolveEnvApiKey("kimi-code");
|
const envKey = resolveEnvApiKey("kimi-code");
|
||||||
if (envKey) {
|
if (envKey) {
|
||||||
const useExisting = await params.prompter.confirm({
|
const useExisting = await params.prompter.confirm({
|
||||||
@@ -261,8 +315,14 @@ export async function applyAuthChoiceApiProviders(
|
|||||||
return { config: nextConfig, agentModelOverride };
|
return { config: nextConfig, agentModelOverride };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.authChoice === "gemini-api-key") {
|
if (authChoice === "gemini-api-key") {
|
||||||
let hasCredential = false;
|
let hasCredential = false;
|
||||||
|
|
||||||
|
if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "google") {
|
||||||
|
await setGeminiApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir);
|
||||||
|
hasCredential = true;
|
||||||
|
}
|
||||||
|
|
||||||
const envKey = resolveEnvApiKey("google");
|
const envKey = resolveEnvApiKey("google");
|
||||||
if (envKey) {
|
if (envKey) {
|
||||||
const useExisting = await params.prompter.confirm({
|
const useExisting = await params.prompter.confirm({
|
||||||
@@ -302,8 +362,14 @@ export async function applyAuthChoiceApiProviders(
|
|||||||
return { config: nextConfig, agentModelOverride };
|
return { config: nextConfig, agentModelOverride };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.authChoice === "zai-api-key") {
|
if (authChoice === "zai-api-key") {
|
||||||
let hasCredential = false;
|
let hasCredential = false;
|
||||||
|
|
||||||
|
if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "zai") {
|
||||||
|
await setZaiApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir);
|
||||||
|
hasCredential = true;
|
||||||
|
}
|
||||||
|
|
||||||
const envKey = resolveEnvApiKey("zai");
|
const envKey = resolveEnvApiKey("zai");
|
||||||
if (envKey) {
|
if (envKey) {
|
||||||
const useExisting = await params.prompter.confirm({
|
const useExisting = await params.prompter.confirm({
|
||||||
@@ -359,12 +425,16 @@ export async function applyAuthChoiceApiProviders(
|
|||||||
return { config: nextConfig, agentModelOverride };
|
return { config: nextConfig, agentModelOverride };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.authChoice === "synthetic-api-key") {
|
if (authChoice === "synthetic-api-key") {
|
||||||
const key = await params.prompter.text({
|
if (params.opts?.token && params.opts?.tokenProvider === "synthetic") {
|
||||||
message: "Enter Synthetic API key",
|
await setSyntheticApiKey(String(params.opts.token).trim(), params.agentDir);
|
||||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
} else {
|
||||||
});
|
const key = await params.prompter.text({
|
||||||
await setSyntheticApiKey(String(key).trim(), params.agentDir);
|
message: "Enter Synthetic API key",
|
||||||
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||||
|
});
|
||||||
|
await setSyntheticApiKey(String(key).trim(), params.agentDir);
|
||||||
|
}
|
||||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||||
profileId: "synthetic:default",
|
profileId: "synthetic:default",
|
||||||
provider: "synthetic",
|
provider: "synthetic",
|
||||||
@@ -387,16 +457,23 @@ export async function applyAuthChoiceApiProviders(
|
|||||||
return { config: nextConfig, agentModelOverride };
|
return { config: nextConfig, agentModelOverride };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.authChoice === "opencode-zen") {
|
if (authChoice === "opencode-zen") {
|
||||||
await params.prompter.note(
|
|
||||||
[
|
|
||||||
"OpenCode Zen provides access to Claude, GPT, Gemini, and more models.",
|
|
||||||
"Get your API key at: https://opencode.ai/auth",
|
|
||||||
"Requires an active OpenCode Zen subscription.",
|
|
||||||
].join("\n"),
|
|
||||||
"OpenCode Zen",
|
|
||||||
);
|
|
||||||
let hasCredential = false;
|
let hasCredential = false;
|
||||||
|
if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "opencode") {
|
||||||
|
await setOpencodeZenApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir);
|
||||||
|
hasCredential = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasCredential) {
|
||||||
|
await params.prompter.note(
|
||||||
|
[
|
||||||
|
"OpenCode Zen provides access to Claude, GPT, Gemini, and more models.",
|
||||||
|
"Get your API key at: https://opencode.ai/auth",
|
||||||
|
"Requires an active OpenCode Zen subscription.",
|
||||||
|
].join("\n"),
|
||||||
|
"OpenCode Zen",
|
||||||
|
);
|
||||||
|
}
|
||||||
const envKey = resolveEnvApiKey("opencode");
|
const envKey = resolveEnvApiKey("opencode");
|
||||||
if (envKey) {
|
if (envKey) {
|
||||||
const useExisting = await params.prompter.confirm({
|
const useExisting = await params.prompter.confirm({
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export async function applyAuthChoiceGitHubCopilot(
|
|||||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||||
profileId: "github-copilot:github",
|
profileId: "github-copilot:github",
|
||||||
provider: "github-copilot",
|
provider: "github-copilot",
|
||||||
mode: "oauth",
|
mode: "token",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (params.setDefaultModel) {
|
if (params.setDefaultModel) {
|
||||||
|
|||||||
@@ -20,7 +20,12 @@ import {
|
|||||||
export async function applyAuthChoiceOpenAI(
|
export async function applyAuthChoiceOpenAI(
|
||||||
params: ApplyAuthChoiceParams,
|
params: ApplyAuthChoiceParams,
|
||||||
): Promise<ApplyAuthChoiceResult | null> {
|
): Promise<ApplyAuthChoiceResult | null> {
|
||||||
if (params.authChoice === "openai-api-key") {
|
let authChoice = params.authChoice;
|
||||||
|
if (authChoice === "apiKey" && params.opts?.tokenProvider === "openai") {
|
||||||
|
authChoice = "openai-api-key";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authChoice === "openai-api-key") {
|
||||||
const envKey = resolveEnvApiKey("openai");
|
const envKey = resolveEnvApiKey("openai");
|
||||||
if (envKey) {
|
if (envKey) {
|
||||||
const useExisting = await params.prompter.confirm({
|
const useExisting = await params.prompter.confirm({
|
||||||
@@ -43,10 +48,16 @@ export async function applyAuthChoiceOpenAI(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = await params.prompter.text({
|
let key: string | undefined;
|
||||||
message: "Enter OpenAI API key",
|
if (params.opts?.token && params.opts?.tokenProvider === "openai") {
|
||||||
validate: validateApiKeyInput,
|
key = params.opts.token;
|
||||||
});
|
} else {
|
||||||
|
key = await params.prompter.text({
|
||||||
|
message: "Enter OpenAI API key",
|
||||||
|
validate: validateApiKeyInput,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const trimmed = normalizeApiKeyInput(String(key));
|
const trimmed = normalizeApiKeyInput(String(key));
|
||||||
const result = upsertSharedEnvVar({
|
const result = upsertSharedEnvVar({
|
||||||
key: "OPENAI_API_KEY",
|
key: "OPENAI_API_KEY",
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ export type ApplyAuthChoiceParams = {
|
|||||||
agentDir?: string;
|
agentDir?: string;
|
||||||
setDefaultModel: boolean;
|
setDefaultModel: boolean;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
|
opts?: {
|
||||||
|
tokenProvider?: string;
|
||||||
|
token?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ApplyAuthChoiceResult = {
|
export type ApplyAuthChoiceResult = {
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { normalizeChannelId } from "../channels/plugins/index.js";
|
||||||
|
import { normalizeAccountId } from "../routing/session-key.js";
|
||||||
|
import type { ClawdbotConfig } from "./config.js";
|
||||||
|
import type { MarkdownTableMode } from "./types.base.js";
|
||||||
|
|
||||||
|
type MarkdownConfigEntry = {
|
||||||
|
markdown?: {
|
||||||
|
tables?: MarkdownTableMode;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type MarkdownConfigSection = MarkdownConfigEntry & {
|
||||||
|
accounts?: Record<string, MarkdownConfigEntry>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_TABLE_MODES = new Map<string, MarkdownTableMode>([
|
||||||
|
["signal", "bullets"],
|
||||||
|
["whatsapp", "bullets"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isMarkdownTableMode = (value: unknown): value is MarkdownTableMode =>
|
||||||
|
value === "off" || value === "bullets" || value === "code";
|
||||||
|
|
||||||
|
function resolveMarkdownModeFromSection(
|
||||||
|
section: MarkdownConfigSection | undefined,
|
||||||
|
accountId?: string | null,
|
||||||
|
): MarkdownTableMode | undefined {
|
||||||
|
if (!section) return undefined;
|
||||||
|
const normalizedAccountId = normalizeAccountId(accountId);
|
||||||
|
const accounts = section.accounts;
|
||||||
|
if (accounts && typeof accounts === "object") {
|
||||||
|
const direct = accounts[normalizedAccountId];
|
||||||
|
const directMode = direct?.markdown?.tables;
|
||||||
|
if (isMarkdownTableMode(directMode)) return directMode;
|
||||||
|
const matchKey = Object.keys(accounts).find(
|
||||||
|
(key) => key.toLowerCase() === normalizedAccountId.toLowerCase(),
|
||||||
|
);
|
||||||
|
const match = matchKey ? accounts[matchKey] : undefined;
|
||||||
|
const matchMode = match?.markdown?.tables;
|
||||||
|
if (isMarkdownTableMode(matchMode)) return matchMode;
|
||||||
|
}
|
||||||
|
const sectionMode = section.markdown?.tables;
|
||||||
|
return isMarkdownTableMode(sectionMode) ? sectionMode : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMarkdownTableMode(params: {
|
||||||
|
cfg?: Partial<ClawdbotConfig>;
|
||||||
|
channel?: string | null;
|
||||||
|
accountId?: string | null;
|
||||||
|
}): MarkdownTableMode {
|
||||||
|
const channel = normalizeChannelId(params.channel);
|
||||||
|
const defaultMode = channel ? (DEFAULT_TABLE_MODES.get(channel) ?? "code") : "code";
|
||||||
|
if (!channel || !params.cfg) return defaultMode;
|
||||||
|
const channelsConfig = params.cfg.channels as Record<string, unknown> | undefined;
|
||||||
|
const section = (channelsConfig?.[channel] ??
|
||||||
|
(params.cfg as Record<string, unknown> | undefined)?.[channel]) as
|
||||||
|
| MarkdownConfigSection
|
||||||
|
| undefined;
|
||||||
|
return resolveMarkdownModeFromSection(section, params.accountId) ?? defaultMode;
|
||||||
|
}
|
||||||
@@ -31,6 +31,13 @@ export type BlockStreamingChunkConfig = {
|
|||||||
breakPreference?: "paragraph" | "newline" | "sentence";
|
breakPreference?: "paragraph" | "newline" | "sentence";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MarkdownTableMode = "off" | "bullets" | "code";
|
||||||
|
|
||||||
|
export type MarkdownConfig = {
|
||||||
|
/** Table rendering mode (off|bullets|code). */
|
||||||
|
tables?: MarkdownTableMode;
|
||||||
|
};
|
||||||
|
|
||||||
export type HumanDelayConfig = {
|
export type HumanDelayConfig = {
|
||||||
/** Delay style for block replies (off|natural|custom). */
|
/** Delay style for block replies (off|natural|custom). */
|
||||||
mode?: "off" | "natural" | "custom";
|
mode?: "off" | "natural" | "custom";
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
BlockStreamingCoalesceConfig,
|
BlockStreamingCoalesceConfig,
|
||||||
DmPolicy,
|
DmPolicy,
|
||||||
GroupPolicy,
|
GroupPolicy,
|
||||||
|
MarkdownConfig,
|
||||||
OutboundRetryConfig,
|
OutboundRetryConfig,
|
||||||
ReplyToMode,
|
ReplyToMode,
|
||||||
} from "./types.base.js";
|
} from "./types.base.js";
|
||||||
@@ -70,6 +71,8 @@ export type DiscordAccountConfig = {
|
|||||||
name?: string;
|
name?: string;
|
||||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||||
capabilities?: string[];
|
capabilities?: string[];
|
||||||
|
/** Markdown formatting overrides (tables). */
|
||||||
|
markdown?: MarkdownConfig;
|
||||||
/** Override native command registration for Discord (bool or "auto"). */
|
/** Override native command registration for Discord (bool or "auto"). */
|
||||||
commands?: ProviderCommandsConfig;
|
commands?: ProviderCommandsConfig;
|
||||||
/** Allow channel-initiated config writes (default: true). */
|
/** Allow channel-initiated config writes (default: true). */
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "./types.base.js";
|
import type {
|
||||||
|
BlockStreamingCoalesceConfig,
|
||||||
|
DmPolicy,
|
||||||
|
GroupPolicy,
|
||||||
|
MarkdownConfig,
|
||||||
|
} from "./types.base.js";
|
||||||
import type { DmConfig } from "./types.messages.js";
|
import type { DmConfig } from "./types.messages.js";
|
||||||
|
|
||||||
export type IMessageAccountConfig = {
|
export type IMessageAccountConfig = {
|
||||||
@@ -6,6 +11,8 @@ export type IMessageAccountConfig = {
|
|||||||
name?: string;
|
name?: string;
|
||||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||||
capabilities?: string[];
|
capabilities?: string[];
|
||||||
|
/** Markdown formatting overrides (tables). */
|
||||||
|
markdown?: MarkdownConfig;
|
||||||
/** Allow channel-initiated config writes (default: true). */
|
/** Allow channel-initiated config writes (default: true). */
|
||||||
configWrites?: boolean;
|
configWrites?: boolean;
|
||||||
/** If false, do not start this iMessage account. Default: true. */
|
/** If false, do not start this iMessage account. Default: true. */
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "./types.base.js";
|
import type {
|
||||||
|
BlockStreamingCoalesceConfig,
|
||||||
|
DmPolicy,
|
||||||
|
GroupPolicy,
|
||||||
|
MarkdownConfig,
|
||||||
|
} from "./types.base.js";
|
||||||
import type { DmConfig } from "./types.messages.js";
|
import type { DmConfig } from "./types.messages.js";
|
||||||
|
|
||||||
export type MSTeamsWebhookConfig = {
|
export type MSTeamsWebhookConfig = {
|
||||||
@@ -34,6 +39,8 @@ export type MSTeamsConfig = {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||||
capabilities?: string[];
|
capabilities?: string[];
|
||||||
|
/** Markdown formatting overrides (tables). */
|
||||||
|
markdown?: MarkdownConfig;
|
||||||
/** Allow channel-initiated config writes (default: true). */
|
/** Allow channel-initiated config writes (default: true). */
|
||||||
configWrites?: boolean;
|
configWrites?: boolean;
|
||||||
/** Azure Bot App ID (from Azure Bot registration). */
|
/** Azure Bot App ID (from Azure Bot registration). */
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "./types.base.js";
|
import type {
|
||||||
|
BlockStreamingCoalesceConfig,
|
||||||
|
DmPolicy,
|
||||||
|
GroupPolicy,
|
||||||
|
MarkdownConfig,
|
||||||
|
} from "./types.base.js";
|
||||||
import type { DmConfig } from "./types.messages.js";
|
import type { DmConfig } from "./types.messages.js";
|
||||||
|
|
||||||
export type SignalReactionNotificationMode = "off" | "own" | "all" | "allowlist";
|
export type SignalReactionNotificationMode = "off" | "own" | "all" | "allowlist";
|
||||||
@@ -8,6 +13,8 @@ export type SignalAccountConfig = {
|
|||||||
name?: string;
|
name?: string;
|
||||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||||
capabilities?: string[];
|
capabilities?: string[];
|
||||||
|
/** Markdown formatting overrides (tables). */
|
||||||
|
markdown?: MarkdownConfig;
|
||||||
/** Allow channel-initiated config writes (default: true). */
|
/** Allow channel-initiated config writes (default: true). */
|
||||||
configWrites?: boolean;
|
configWrites?: boolean;
|
||||||
/** If false, do not start this Signal account. Default: true. */
|
/** If false, do not start this Signal account. Default: true. */
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
BlockStreamingCoalesceConfig,
|
BlockStreamingCoalesceConfig,
|
||||||
DmPolicy,
|
DmPolicy,
|
||||||
GroupPolicy,
|
GroupPolicy,
|
||||||
|
MarkdownConfig,
|
||||||
ReplyToMode,
|
ReplyToMode,
|
||||||
} from "./types.base.js";
|
} from "./types.base.js";
|
||||||
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
|
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
|
||||||
@@ -80,6 +81,8 @@ export type SlackAccountConfig = {
|
|||||||
webhookPath?: string;
|
webhookPath?: string;
|
||||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||||
capabilities?: string[];
|
capabilities?: string[];
|
||||||
|
/** Markdown formatting overrides (tables). */
|
||||||
|
markdown?: MarkdownConfig;
|
||||||
/** Override native command registration for Slack (bool or "auto"). */
|
/** Override native command registration for Slack (bool or "auto"). */
|
||||||
commands?: ProviderCommandsConfig;
|
commands?: ProviderCommandsConfig;
|
||||||
/** Allow channel-initiated config writes (default: true). */
|
/** Allow channel-initiated config writes (default: true). */
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
BlockStreamingCoalesceConfig,
|
BlockStreamingCoalesceConfig,
|
||||||
DmPolicy,
|
DmPolicy,
|
||||||
GroupPolicy,
|
GroupPolicy,
|
||||||
|
MarkdownConfig,
|
||||||
OutboundRetryConfig,
|
OutboundRetryConfig,
|
||||||
ReplyToMode,
|
ReplyToMode,
|
||||||
} from "./types.base.js";
|
} from "./types.base.js";
|
||||||
@@ -35,6 +36,8 @@ export type TelegramAccountConfig = {
|
|||||||
name?: string;
|
name?: string;
|
||||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||||
capabilities?: TelegramCapabilitiesConfig;
|
capabilities?: TelegramCapabilitiesConfig;
|
||||||
|
/** Markdown formatting overrides (tables). */
|
||||||
|
markdown?: MarkdownConfig;
|
||||||
/** Override native command registration for Telegram (bool or "auto"). */
|
/** Override native command registration for Telegram (bool or "auto"). */
|
||||||
commands?: ProviderCommandsConfig;
|
commands?: ProviderCommandsConfig;
|
||||||
/** Custom commands to register in Telegram's command menu (merged with native). */
|
/** Custom commands to register in Telegram's command menu (merged with native). */
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "./types.base.js";
|
import type {
|
||||||
|
BlockStreamingCoalesceConfig,
|
||||||
|
DmPolicy,
|
||||||
|
GroupPolicy,
|
||||||
|
MarkdownConfig,
|
||||||
|
} from "./types.base.js";
|
||||||
import type { DmConfig } from "./types.messages.js";
|
import type { DmConfig } from "./types.messages.js";
|
||||||
|
|
||||||
export type WhatsAppActionConfig = {
|
export type WhatsAppActionConfig = {
|
||||||
@@ -12,6 +17,8 @@ export type WhatsAppConfig = {
|
|||||||
accounts?: Record<string, WhatsAppAccountConfig>;
|
accounts?: Record<string, WhatsAppAccountConfig>;
|
||||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||||
capabilities?: string[];
|
capabilities?: string[];
|
||||||
|
/** Markdown formatting overrides (tables). */
|
||||||
|
markdown?: MarkdownConfig;
|
||||||
/** Allow channel-initiated config writes (default: true). */
|
/** Allow channel-initiated config writes (default: true). */
|
||||||
configWrites?: boolean;
|
configWrites?: boolean;
|
||||||
/** Send read receipts for incoming messages (default true). */
|
/** Send read receipts for incoming messages (default true). */
|
||||||
@@ -84,6 +91,8 @@ export type WhatsAppAccountConfig = {
|
|||||||
name?: string;
|
name?: string;
|
||||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||||
capabilities?: string[];
|
capabilities?: string[];
|
||||||
|
/** Markdown formatting overrides (tables). */
|
||||||
|
markdown?: MarkdownConfig;
|
||||||
/** Allow channel-initiated config writes (default: true). */
|
/** Allow channel-initiated config writes (default: true). */
|
||||||
configWrites?: boolean;
|
configWrites?: boolean;
|
||||||
/** If false, do not start this WhatsApp account provider. Default: true. */
|
/** If false, do not start this WhatsApp account provider. Default: true. */
|
||||||
|
|||||||
@@ -133,6 +133,15 @@ export const BlockStreamingChunkSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
export const MarkdownTableModeSchema = z.enum(["off", "bullets", "code"]);
|
||||||
|
|
||||||
|
export const MarkdownConfigSchema = z
|
||||||
|
.object({
|
||||||
|
tables: MarkdownTableModeSchema.optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional();
|
||||||
|
|
||||||
export const HumanDelaySchema = z
|
export const HumanDelaySchema = z
|
||||||
.object({
|
.object({
|
||||||
mode: z.union([z.literal("off"), z.literal("natural"), z.literal("custom")]).optional(),
|
mode: z.union([z.literal("off"), z.literal("natural"), z.literal("custom")]).optional(),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
DmPolicySchema,
|
DmPolicySchema,
|
||||||
ExecutableTokenSchema,
|
ExecutableTokenSchema,
|
||||||
GroupPolicySchema,
|
GroupPolicySchema,
|
||||||
|
MarkdownConfigSchema,
|
||||||
MSTeamsReplyStyleSchema,
|
MSTeamsReplyStyleSchema,
|
||||||
ProviderCommandsSchema,
|
ProviderCommandsSchema,
|
||||||
ReplyToModeSchema,
|
ReplyToModeSchema,
|
||||||
@@ -81,6 +82,7 @@ export const TelegramAccountSchemaBase = z
|
|||||||
.object({
|
.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
capabilities: TelegramCapabilitiesSchema.optional(),
|
capabilities: TelegramCapabilitiesSchema.optional(),
|
||||||
|
markdown: MarkdownConfigSchema,
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
commands: ProviderCommandsSchema,
|
commands: ProviderCommandsSchema,
|
||||||
customCommands: z.array(TelegramCustomCommandSchema).optional(),
|
customCommands: z.array(TelegramCustomCommandSchema).optional(),
|
||||||
@@ -193,6 +195,7 @@ export const DiscordAccountSchema = z
|
|||||||
.object({
|
.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
capabilities: z.array(z.string()).optional(),
|
capabilities: z.array(z.string()).optional(),
|
||||||
|
markdown: MarkdownConfigSchema,
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
commands: ProviderCommandsSchema,
|
commands: ProviderCommandsSchema,
|
||||||
configWrites: z.boolean().optional(),
|
configWrites: z.boolean().optional(),
|
||||||
@@ -296,6 +299,7 @@ export const SlackAccountSchema = z
|
|||||||
signingSecret: z.string().optional(),
|
signingSecret: z.string().optional(),
|
||||||
webhookPath: z.string().optional(),
|
webhookPath: z.string().optional(),
|
||||||
capabilities: z.array(z.string()).optional(),
|
capabilities: z.array(z.string()).optional(),
|
||||||
|
markdown: MarkdownConfigSchema,
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
commands: ProviderCommandsSchema,
|
commands: ProviderCommandsSchema,
|
||||||
configWrites: z.boolean().optional(),
|
configWrites: z.boolean().optional(),
|
||||||
@@ -381,6 +385,7 @@ export const SignalAccountSchemaBase = z
|
|||||||
.object({
|
.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
capabilities: z.array(z.string()).optional(),
|
capabilities: z.array(z.string()).optional(),
|
||||||
|
markdown: MarkdownConfigSchema,
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
configWrites: z.boolean().optional(),
|
configWrites: z.boolean().optional(),
|
||||||
account: z.string().optional(),
|
account: z.string().optional(),
|
||||||
@@ -435,6 +440,7 @@ export const IMessageAccountSchemaBase = z
|
|||||||
.object({
|
.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
capabilities: z.array(z.string()).optional(),
|
capabilities: z.array(z.string()).optional(),
|
||||||
|
markdown: MarkdownConfigSchema,
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
configWrites: z.boolean().optional(),
|
configWrites: z.boolean().optional(),
|
||||||
cliPath: ExecutableTokenSchema.optional(),
|
cliPath: ExecutableTokenSchema.optional(),
|
||||||
@@ -521,6 +527,7 @@ export const BlueBubblesAccountSchemaBase = z
|
|||||||
.object({
|
.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
capabilities: z.array(z.string()).optional(),
|
capabilities: z.array(z.string()).optional(),
|
||||||
|
markdown: MarkdownConfigSchema,
|
||||||
configWrites: z.boolean().optional(),
|
configWrites: z.boolean().optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
serverUrl: z.string().optional(),
|
serverUrl: z.string().optional(),
|
||||||
@@ -585,6 +592,7 @@ export const MSTeamsConfigSchema = z
|
|||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
capabilities: z.array(z.string()).optional(),
|
capabilities: z.array(z.string()).optional(),
|
||||||
|
markdown: MarkdownConfigSchema,
|
||||||
configWrites: z.boolean().optional(),
|
configWrites: z.boolean().optional(),
|
||||||
appId: z.string().optional(),
|
appId: z.string().optional(),
|
||||||
appPassword: z.string().optional(),
|
appPassword: z.string().optional(),
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import {
|
|||||||
DmConfigSchema,
|
DmConfigSchema,
|
||||||
DmPolicySchema,
|
DmPolicySchema,
|
||||||
GroupPolicySchema,
|
GroupPolicySchema,
|
||||||
|
MarkdownConfigSchema,
|
||||||
} from "./zod-schema.core.js";
|
} from "./zod-schema.core.js";
|
||||||
|
|
||||||
export const WhatsAppAccountSchema = z
|
export const WhatsAppAccountSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
capabilities: z.array(z.string()).optional(),
|
capabilities: z.array(z.string()).optional(),
|
||||||
|
markdown: MarkdownConfigSchema,
|
||||||
configWrites: z.boolean().optional(),
|
configWrites: z.boolean().optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
sendReadReceipts: z.boolean().optional(),
|
sendReadReceipts: z.boolean().optional(),
|
||||||
@@ -66,6 +68,7 @@ export const WhatsAppConfigSchema = z
|
|||||||
.object({
|
.object({
|
||||||
accounts: z.record(z.string(), WhatsAppAccountSchema.optional()).optional(),
|
accounts: z.record(z.string(), WhatsAppAccountSchema.optional()).optional(),
|
||||||
capabilities: z.array(z.string()).optional(),
|
capabilities: z.array(z.string()).optional(),
|
||||||
|
markdown: MarkdownConfigSchema,
|
||||||
configWrites: z.boolean().optional(),
|
configWrites: z.boolean().optional(),
|
||||||
sendReadReceipts: z.boolean().optional(),
|
sendReadReceipts: z.boolean().optional(),
|
||||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||||
|
|||||||
@@ -299,6 +299,7 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
sessionId: cronSession.sessionEntry.sessionId,
|
sessionId: cronSession.sessionEntry.sessionId,
|
||||||
sessionKey: agentSessionKey,
|
sessionKey: agentSessionKey,
|
||||||
messageChannel,
|
messageChannel,
|
||||||
|
agentAccountId: resolvedDelivery.accountId,
|
||||||
sessionFile,
|
sessionFile,
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
config: cfgWithAgentDefaults,
|
config: cfgWithAgentDefaults,
|
||||||
|
|||||||
@@ -45,6 +45,26 @@ describe("resolveGatewayProgramArguments", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prefers symlinked path over realpath for stable service config", async () => {
|
||||||
|
// Simulates pnpm global install where node_modules/clawdbot is a symlink
|
||||||
|
// to .pnpm/clawdbot@X.Y.Z/node_modules/clawdbot
|
||||||
|
const symlinkPath = path.resolve(
|
||||||
|
"/Users/test/Library/pnpm/global/5/node_modules/clawdbot/dist/entry.js",
|
||||||
|
);
|
||||||
|
const realpathResolved = path.resolve(
|
||||||
|
"/Users/test/Library/pnpm/global/5/node_modules/.pnpm/clawdbot@2026.1.21-2/node_modules/clawdbot/dist/entry.js",
|
||||||
|
);
|
||||||
|
process.argv = ["node", symlinkPath];
|
||||||
|
fsMocks.realpath.mockResolvedValue(realpathResolved);
|
||||||
|
fsMocks.access.mockResolvedValue(undefined); // Both paths exist
|
||||||
|
|
||||||
|
const result = await resolveGatewayProgramArguments({ port: 18789 });
|
||||||
|
|
||||||
|
// Should use the symlinked path, not the realpath-resolved versioned path
|
||||||
|
expect(result.programArguments[1]).toBe(symlinkPath);
|
||||||
|
expect(result.programArguments[1]).not.toContain("@2026.1.21-2");
|
||||||
|
});
|
||||||
|
|
||||||
it("falls back to node_modules package dist when .bin path is not resolved", async () => {
|
it("falls back to node_modules package dist when .bin path is not resolved", async () => {
|
||||||
const argv1 = path.resolve("/tmp/.npm/_npx/63c3/node_modules/.bin/clawdbot");
|
const argv1 = path.resolve("/tmp/.npm/_npx/63c3/node_modules/.bin/clawdbot");
|
||||||
const indexPath = path.resolve("/tmp/.npm/_npx/63c3/node_modules/clawdbot/dist/index.js");
|
const indexPath = path.resolve("/tmp/.npm/_npx/63c3/node_modules/clawdbot/dist/index.js");
|
||||||
|
|||||||
Loaded 100 of 154 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user