Compare commits

..
451 changed files with 7407 additions and 14118 deletions

No files matched your search

-1
View File
@@ -29,6 +29,5 @@ 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
-16
View File
@@ -7,7 +7,6 @@
- Tests: colocated `*.test.ts`. - Tests: colocated `*.test.ts`.
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`. - Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them. - Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
- Plugins: install runs `npm install --omit=dev` in plugin dir; runtime deps must live in `dependencies`. Avoid `workspace:*` in `dependencies` (npm install breaks); put `clawdbot` in `devDependencies` or `peerDependencies` instead (runtime resolves `clawdbot/plugin-sdk` via jiti alias).
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`). - Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs). - Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs).
- Core channel docs: `docs/channels/` - Core channel docs: `docs/channels/`
@@ -23,16 +22,6 @@
- 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: stable path is `ssh exe.dev` then `ssh vm-name` (assume SSH key already set).
- SSH flaky: use exe.dev web terminal or Shelley (web agent); keep a tmux session for long ops.
- Update: `sudo npm i -g clawdbot@latest` (global install needs root on `/usr/lib/node_modules`).
- Config: use `clawdbot config set ...`; ensure `gateway.mode=local` is set.
- Discord: store raw token only (no `DISCORD_BOT_TOKEN=` prefix).
- Restart: 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 channels status --probe`, `ss -ltnp | rg 18789`, `tail -n 120 /tmp/clawdbot-gateway.log`.
## 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`
@@ -62,7 +51,6 @@
- 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.
@@ -130,10 +118,6 @@
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested. - **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested.
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session. - **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
- **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those. - **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those.
- Lint/format churn:
- If staged+unstaged diffs are formatting-only, auto-resolve without asking.
- If commit/push already requested, auto-stage and include formatting-only follow-ups in the same commit (or a tiny follow-up commit if needed), no extra confirmation.
- Only ask when changes are semantic (logic/data/behavior).
- Lobster seam: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed. - Lobster seam: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed.
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant. - **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause. - Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
+17 -50
View File
@@ -2,68 +2,48 @@
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.
- CLI: add live auth probes to `clawdbot models status` for per-profile verification.
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
- Docs: remove the misplaced Google Docs Editor entry from the showcase. (#1547) Thanks @aj47.
### Fixes
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway.
- TUI: include Gateway slash commands in autocomplete and `/help`.
- CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable.
- CLI: suppress diagnostic session/run noise during auth probes.
- CLI: hide auth probe timeout warnings from embedded runs.
- CLI: render auth probe results as a table in `clawdbot models status`.
- Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.
- TUI: render Gateway slash-command replies as system output (for example, `/context`).
- 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)
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
- MS Teams (plugin): remove `.default` suffix from Graph scopes to avoid double-appending. (#1507) Thanks @Evizero.
## 2026.1.22 ## 2026.1.22
### Changes ### Changes
- Highlight: Compaction safeguard now uses adaptive chunking, progressive fallback, and UI status + retries. (#1466) Thanks @dlauer. - Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
- Providers: add Antigravity usage tracking to status output. (#1490) Thanks @patelhiren. - Lobster: allow workflow file args via `argsJson` in the plugin tool. https://docs.clawd.bot/tools/lobster
- Slack: add chat-type reply threading overrides via `replyToModeByChatType`. (#1442) Thanks @stefangalescu. - Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
- 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.
- Message tool: keep path/filePath as-is for send; hydrate buffers only for sendAttachment. (#1444) Thanks @hopyky. - Message tool: keep path/filePath as-is for send; hydrate buffers only for sendAttachment. (#1444) Thanks @hopyky.
- Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla. - Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.
- 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.
- 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 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.
- Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider. - Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider.
- Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c. - Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c.
- Agents: make tool summaries more readable and only show optional params when set. - Agents: make tool summaries more readable and only show optional params when set.
- Agents: honor SOUL.md guidance even when the file is nested or path-qualified. (#1434) Thanks @neooriginal. - Agents: honor SOUL.md guidance even when the file is nested or path-qualified. (#1434) Thanks @neooriginal.
- Matrix (plugin): persist m.direct for resolved DMs and harden room fallback. (#1436, #1486) Thanks @sibbl. - Matrix (plugin): persist m.direct for resolved DMs and harden room fallback. (#1436) Thanks @sibbl.
- CLI: prefer `~` for home paths in output. - CLI: prefer `~` for home paths in output.
- Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic. - Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic.
- Agents: centralize transcript sanitization in the runner; keep <final> tags and error turns intact. - Agents: centralize transcript sanitization in the runner; keep <final> tags and error turns intact.
- Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik. - Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik.
- Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff. - Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff.
- 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: 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) - Providers: improve GitHub Copilot integration (enterprise support, base URL, and auth flow alignment).
## 2026.1.21-2 ## 2026.1.21-2
@@ -74,8 +54,6 @@ 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.
@@ -85,24 +63,13 @@ 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.
+16 -48
View File
@@ -2,54 +2,6 @@
<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>
@@ -314,5 +266,21 @@ 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>
+2 -2
View File
@@ -21,8 +21,8 @@ android {
applicationId = "com.clawdbot.android" applicationId = "com.clawdbot.android"
minSdk = 31 minSdk = 31
targetSdk = 36 targetSdk = 36
versionCode = 202601230 versionCode = 202601210
versionName = "2026.1.23" versionName = "2026.1.21"
} }
buildTypes { buildTypes {
@@ -8,14 +8,10 @@ object WakeWords {
return input.split(",").map { it.trim() }.filter { it.isNotEmpty() } return input.split(",").map { it.trim() }.filter { it.isNotEmpty() }
} }
fun parseIfChanged(input: String, current: List<String>): List<String>? {
val parsed = parseCommaSeparated(input)
return if (parsed == current) null else parsed
}
fun sanitize(words: List<String>, defaults: List<String>): List<String> { fun sanitize(words: List<String>, defaults: List<String>): List<String> {
val cleaned = val cleaned =
words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) } words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) }
return cleaned.ifEmpty { defaults } return cleaned.ifEmpty { defaults }
} }
} }
@@ -28,8 +28,6 @@ import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
@@ -51,10 +49,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -63,7 +58,6 @@ import com.clawdbot.android.LocationMode
import com.clawdbot.android.MainViewModel import com.clawdbot.android.MainViewModel
import com.clawdbot.android.NodeForegroundService import com.clawdbot.android.NodeForegroundService
import com.clawdbot.android.VoiceWakeMode import com.clawdbot.android.VoiceWakeMode
import com.clawdbot.android.WakeWords
@Composable @Composable
fun SettingsSheet(viewModel: MainViewModel) { fun SettingsSheet(viewModel: MainViewModel) {
@@ -92,8 +86,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") } val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) } val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
var wakeWordsHadFocus by remember { mutableStateOf(false) }
val deviceModel = val deviceModel =
remember { remember {
listOfNotNull(Build.MANUFACTURER, Build.MODEL) listOfNotNull(Build.MANUFACTURER, Build.MODEL)
@@ -112,12 +104,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
} }
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) } LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
val commitWakeWords = {
val parsed = WakeWords.parseIfChanged(wakeWordsText, wakeWords)
if (parsed != null) {
viewModel.setWakeWords(parsed)
}
}
val permissionLauncher = val permissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms -> rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
@@ -495,27 +481,25 @@ fun SettingsSheet(viewModel: MainViewModel) {
value = wakeWordsText, value = wakeWordsText,
onValueChange = setWakeWordsText, onValueChange = setWakeWordsText,
label = { Text("Wake Words (comma-separated)") }, label = { Text("Wake Words (comma-separated)") },
modifier = modifier = Modifier.fillMaxWidth(),
Modifier.fillMaxWidth().onFocusChanged { focusState ->
if (focusState.isFocused) {
wakeWordsHadFocus = true
} else if (wakeWordsHadFocus) {
wakeWordsHadFocus = false
commitWakeWords()
}
},
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(
onDone = {
commitWakeWords()
focusManager.clearFocus()
},
),
) )
} }
item { Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") } } item {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = {
val parsed = com.clawdbot.android.WakeWords.parseCommaSeparated(wakeWordsText)
viewModel.setWakeWords(parsed)
},
enabled = isConnected,
) {
Text("Save + Sync")
}
Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") }
}
}
item { item {
Text( Text(
if (isConnected) { if (isConnected) {
@@ -1,7 +1,6 @@
package com.clawdbot.android package com.clawdbot.android
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test import org.junit.Test
class WakeWordsTest { class WakeWordsTest {
@@ -33,18 +32,5 @@ class WakeWordsTest {
assertEquals("w1", sanitized.first()) assertEquals("w1", sanitized.first())
assertEquals("w${WakeWords.maxWords}", sanitized.last()) assertEquals("w${WakeWords.maxWords}", sanitized.last())
} }
@Test
fun parseIfChangedSkipsWhenUnchanged() {
val current = listOf("clawd", "claude")
val parsed = WakeWords.parseIfChanged(" clawd , claude ", current)
assertNull(parsed)
}
@Test
fun parseIfChangedReturnsUpdatedList() {
val current = listOf("clawd")
val parsed = WakeWords.parseIfChanged(" clawd , jarvis ", current)
assertEquals(listOf("clawd", "jarvis"), parsed)
}
} }
+2 -2
View File
@@ -19,9 +19,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2026.1.23</string> <string>2026.1.21</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>20260123</string> <string>20260121</string>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
<dict> <dict>
<key>NSAllowsArbitraryLoadsInWebContent</key> <key>NSAllowsArbitraryLoadsInWebContent</key>
@@ -1,10 +1,8 @@
import SwiftUI import SwiftUI
import Combine
struct VoiceWakeWordsSettingsView: View { struct VoiceWakeWordsSettingsView: View {
@Environment(NodeAppModel.self) private var appModel @Environment(NodeAppModel.self) private var appModel
@State private var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords() @State private var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
@FocusState private var focusedTriggerIndex: Int?
@State private var syncTask: Task<Void, Never>? @State private var syncTask: Task<Void, Never>?
var body: some View { var body: some View {
@@ -14,10 +12,6 @@ struct VoiceWakeWordsSettingsView: View {
TextField("Wake word", text: self.binding(for: index)) TextField("Wake word", text: self.binding(for: index))
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
.focused(self.$focusedTriggerIndex, equals: index)
.onSubmit {
self.commitTriggerWords()
}
} }
.onDelete(perform: self.removeWords) .onDelete(perform: self.removeWords)
@@ -45,18 +39,17 @@ struct VoiceWakeWordsSettingsView: View {
.onAppear { .onAppear {
if self.triggerWords.isEmpty { if self.triggerWords.isEmpty {
self.triggerWords = VoiceWakePreferences.defaultTriggerWords self.triggerWords = VoiceWakePreferences.defaultTriggerWords
self.commitTriggerWords()
} }
} }
.onChange(of: self.focusedTriggerIndex) { oldValue, newValue in .onChange(of: self.triggerWords) { _, newValue in
guard oldValue != nil, oldValue != newValue else { return } // Keep local voice wake responsive even if the gateway isn't connected yet.
self.commitTriggerWords() VoiceWakePreferences.saveTriggerWords(newValue)
}
.onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in let snapshot = VoiceWakePreferences.sanitizeTriggerWords(newValue)
guard self.focusedTriggerIndex == nil else { return } self.syncTask?.cancel()
let updated = VoiceWakePreferences.loadTriggerWords() self.syncTask = Task { [snapshot, weak appModel = self.appModel] in
if updated != self.triggerWords { try? await Task.sleep(nanoseconds: 650_000_000)
self.triggerWords = updated await appModel?.setGlobalWakeWords(snapshot)
} }
} }
} }
@@ -70,7 +63,6 @@ struct VoiceWakeWordsSettingsView: View {
if self.triggerWords.isEmpty { if self.triggerWords.isEmpty {
self.triggerWords = VoiceWakePreferences.defaultTriggerWords self.triggerWords = VoiceWakePreferences.defaultTriggerWords
} }
self.commitTriggerWords()
} }
private func binding(for index: Int) -> Binding<String> { private func binding(for index: Int) -> Binding<String> {
@@ -84,15 +76,4 @@ struct VoiceWakeWordsSettingsView: View {
self.triggerWords[index] = newValue self.triggerWords[index] = newValue
}) })
} }
private func commitTriggerWords() {
VoiceWakePreferences.saveTriggerWords(self.triggerWords)
let snapshot = VoiceWakePreferences.sanitizeTriggerWords(self.triggerWords)
self.syncTask?.cancel()
self.syncTask = Task { [snapshot, weak appModel = self.appModel] in
try? await Task.sleep(nanoseconds: 650_000_000)
await appModel?.setGlobalWakeWords(snapshot)
}
}
} }
@@ -6,8 +6,6 @@ enum VoiceWakePreferences {
// Keep defaults aligned with the mac app. // Keep defaults aligned with the mac app.
static let defaultTriggerWords: [String] = ["clawd", "claude"] static let defaultTriggerWords: [String] = ["clawd", "claude"]
static let maxWords = 32
static let maxWordLength = 64
static func decodeGatewayTriggers(from payloadJSON: String) -> [String]? { static func decodeGatewayTriggers(from payloadJSON: String) -> [String]? {
guard let data = payloadJSON.data(using: .utf8) else { return nil } guard let data = payloadJSON.data(using: .utf8) else { return nil }
@@ -32,8 +30,6 @@ enum VoiceWakePreferences {
let cleaned = words let cleaned = words
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty } .filter { !$0.isEmpty }
.prefix(Self.maxWords)
.map { String($0.prefix(Self.maxWordLength)) }
return cleaned.isEmpty ? Self.defaultTriggerWords : cleaned return cleaned.isEmpty ? Self.defaultTriggerWords : cleaned
} }
+2 -2
View File
@@ -17,8 +17,8 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>BNDL</string> <string>BNDL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2026.1.23</string> <string>2026.1.21</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>20260123</string> <string>20260121</string>
</dict> </dict>
</plist> </plist>
@@ -11,18 +11,6 @@ import Testing
#expect(VoiceWakePreferences.sanitizeTriggerWords(["", " "]) == VoiceWakePreferences.defaultTriggerWords) #expect(VoiceWakePreferences.sanitizeTriggerWords(["", " "]) == VoiceWakePreferences.defaultTriggerWords)
} }
@Test func sanitizeTriggerWordsLimitsWordLength() {
let long = String(repeating: "x", count: VoiceWakePreferences.maxWordLength + 5)
let cleaned = VoiceWakePreferences.sanitizeTriggerWords(["ok", long])
#expect(cleaned[1].count == VoiceWakePreferences.maxWordLength)
}
@Test func sanitizeTriggerWordsLimitsWordCount() {
let words = (1...VoiceWakePreferences.maxWords + 3).map { "w\($0)" }
let cleaned = VoiceWakePreferences.sanitizeTriggerWords(words)
#expect(cleaned.count == VoiceWakePreferences.maxWords)
}
@Test func displayStringUsesSanitizedWords() { @Test func displayStringUsesSanitizedWords() {
#expect(VoiceWakePreferences.displayString(for: ["", " "]) == "clawd, claude") #expect(VoiceWakePreferences.displayString(for: ["", " "]) == "clawd, claude")
} }
+4 -4
View File
@@ -81,8 +81,8 @@ targets:
properties: properties:
CFBundleDisplayName: Clawdbot CFBundleDisplayName: Clawdbot
CFBundleIconName: AppIcon CFBundleIconName: AppIcon
CFBundleShortVersionString: "2026.1.23" CFBundleShortVersionString: "2026.1.21"
CFBundleVersion: "20260123" CFBundleVersion: "20260121"
UILaunchScreen: {} UILaunchScreen: {}
UIApplicationSceneManifest: UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false UIApplicationSupportsMultipleScenes: false
@@ -130,5 +130,5 @@ targets:
path: Tests/Info.plist path: Tests/Info.plist
properties: properties:
CFBundleDisplayName: ClawdbotTests CFBundleDisplayName: ClawdbotTests
CFBundleShortVersionString: "2026.1.23" CFBundleShortVersionString: "2026.1.21"
CFBundleVersion: "20260123" CFBundleVersion: "20260121"
@@ -12,8 +12,6 @@ let voiceWakeTriggerChimeKey = "clawdbot.voiceWakeTriggerChime"
let voiceWakeSendChimeKey = "clawdbot.voiceWakeSendChime" let voiceWakeSendChimeKey = "clawdbot.voiceWakeSendChime"
let showDockIconKey = "clawdbot.showDockIcon" let showDockIconKey = "clawdbot.showDockIcon"
let defaultVoiceWakeTriggers = ["clawd", "claude"] let defaultVoiceWakeTriggers = ["clawd", "claude"]
let voiceWakeMaxWords = 32
let voiceWakeMaxWordLength = 64
let voiceWakeMicKey = "clawdbot.voiceWakeMicID" let voiceWakeMicKey = "clawdbot.voiceWakeMicID"
let voiceWakeMicNameKey = "clawdbot.voiceWakeMicName" let voiceWakeMicNameKey = "clawdbot.voiceWakeMicName"
let voiceWakeLocaleKey = "clawdbot.voiceWakeLocaleID" let voiceWakeLocaleKey = "clawdbot.voiceWakeLocaleID"
@@ -102,9 +102,7 @@ struct DebugSettings: View {
} }
} }
Text( Text("When enabled, Clawdbot won't install or manage \(gatewayLaunchdLabel). It will only attach to an existing Gateway.")
"When enabled, Clawdbot won't install or manage \(gatewayLaunchdLabel). " +
"It will only attach to an existing Gateway.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -84,52 +84,11 @@ enum ExecApprovalDecision: String, Codable, Sendable {
case deny case deny
} }
struct ExecAllowlistEntry: Codable, Hashable, Identifiable { struct ExecAllowlistEntry: Codable, Hashable {
var id: UUID
var pattern: String var pattern: String
var lastUsedAt: Double? var lastUsedAt: Double?
var lastUsedCommand: String? var lastUsedCommand: String?
var lastResolvedPath: String? var lastResolvedPath: String?
init(
id: UUID = UUID(),
pattern: String,
lastUsedAt: Double? = nil,
lastUsedCommand: String? = nil,
lastResolvedPath: String? = nil)
{
self.id = id
self.pattern = pattern
self.lastUsedAt = lastUsedAt
self.lastUsedCommand = lastUsedCommand
self.lastResolvedPath = lastResolvedPath
}
private enum CodingKeys: String, CodingKey {
case id
case pattern
case lastUsedAt
case lastUsedCommand
case lastResolvedPath
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
self.pattern = try container.decode(String.self, forKey: .pattern)
self.lastUsedAt = try container.decodeIfPresent(Double.self, forKey: .lastUsedAt)
self.lastUsedCommand = try container.decodeIfPresent(String.self, forKey: .lastUsedCommand)
self.lastResolvedPath = try container.decodeIfPresent(String.self, forKey: .lastResolvedPath)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.id, forKey: .id)
try container.encode(self.pattern, forKey: .pattern)
try container.encodeIfPresent(self.lastUsedAt, forKey: .lastUsedAt)
try container.encodeIfPresent(self.lastUsedCommand, forKey: .lastUsedCommand)
try container.encodeIfPresent(self.lastResolvedPath, forKey: .lastResolvedPath)
}
} }
struct ExecApprovalsDefaults: Codable { struct ExecApprovalsDefaults: Codable {
@@ -336,7 +295,6 @@ enum ExecApprovalsStore {
let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? [])) let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []))
.map { entry in .map { entry in
ExecAllowlistEntry( ExecAllowlistEntry(
id: entry.id,
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines), pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
lastUsedAt: entry.lastUsedAt, lastUsedAt: entry.lastUsedAt,
lastUsedCommand: entry.lastUsedCommand, lastUsedCommand: entry.lastUsedCommand,
@@ -421,7 +379,6 @@ enum ExecApprovalsStore {
let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in
guard item.pattern == pattern else { return item } guard item.pattern == pattern else { return item }
return ExecAllowlistEntry( return ExecAllowlistEntry(
id: item.id,
pattern: item.pattern, pattern: item.pattern,
lastUsedAt: Date().timeIntervalSince1970 * 1000, lastUsedAt: Date().timeIntervalSince1970 * 1000,
lastUsedCommand: command, lastUsedCommand: command,
@@ -441,7 +398,6 @@ enum ExecApprovalsStore {
let cleaned = allowlist let cleaned = allowlist
.map { item in .map { item in
ExecAllowlistEntry( ExecAllowlistEntry(
id: item.id,
pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines), pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
lastUsedAt: item.lastUsedAt, lastUsedAt: item.lastUsedAt,
lastUsedCommand: item.lastUsedCommand, lastUsedCommand: item.lastUsedCommand,
@@ -248,11 +248,12 @@ final class GatewayProcessManager {
guard let linkId else { guard let linkId else {
return "port \(port), health probe succeeded, \(instanceText)" return "port \(port), health probe succeeded, \(instanceText)"
} }
let linked = snap.channels[linkId]?.linked ?? false let linked = linkId.flatMap { snap.channels[$0]?.linked } ?? false
let authAge = snap.channels[linkId]?.authAgeMs.flatMap(msToAge) ?? "unknown age" let authAge = linkId.flatMap { snap.channels[$0]?.authAgeMs }.flatMap(msToAge) ?? "unknown age"
let label = let label =
snap.channelLabels?[linkId] ?? linkId.flatMap { snap.channelLabels?[$0] } ??
linkId.capitalized linkId?.capitalized ??
"channel"
let linkText = linked ? "linked" : "not linked" let linkText = linked ? "linked" : "not linked"
return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)" return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)"
} }
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2026.1.23</string> <string>2026.1.21</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>202601230</string> <string>202601210</string>
<key>CFBundleIconFile</key> <key>CFBundleIconFile</key>
<string>Clawdbot</string> <string>Clawdbot</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
@@ -123,12 +123,12 @@ struct SystemRunSettingsView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else { } else {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
ForEach(self.model.entries, id: \.id) { entry in ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in
ExecAllowlistRow( ExecAllowlistRow(
entry: Binding( entry: Binding(
get: { self.model.entry(for: entry.id) ?? entry }, get: { self.model.entries[index] },
set: { self.model.updateEntry($0, id: entry.id) }), set: { self.model.updateEntry($0, at: index) }),
onRemove: { self.model.removeEntry(id: entry.id) }) onRemove: { self.model.removeEntry(at: index) })
} }
} }
} }
@@ -373,24 +373,20 @@ final class ExecApprovalsSettingsModel {
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
} }
func updateEntry(_ entry: ExecAllowlistEntry, id: UUID) { func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) {
guard !self.isDefaultsScope else { return } guard !self.isDefaultsScope else { return }
guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return } guard self.entries.indices.contains(index) else { return }
self.entries[index] = entry self.entries[index] = entry
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
} }
func removeEntry(id: UUID) { func removeEntry(at index: Int) {
guard !self.isDefaultsScope else { return } guard !self.isDefaultsScope else { return }
guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return } guard self.entries.indices.contains(index) else { return }
self.entries.remove(at: index) self.entries.remove(at: index)
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
} }
func entry(for id: UUID) -> ExecAllowlistEntry? {
self.entries.first(where: { $0.id == id })
}
func refreshSkillBins(force: Bool = false) async { func refreshSkillBins(force: Bool = false) async {
guard self.autoAllowSkills else { guard self.autoAllowSkills else {
self.skillBins = [] self.skillBins = []
@@ -4,8 +4,6 @@ func sanitizeVoiceWakeTriggers(_ words: [String]) -> [String] {
let cleaned = words let cleaned = words
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty } .filter { !$0.isEmpty }
.prefix(voiceWakeMaxWords)
.map { String($0.prefix(voiceWakeMaxWordLength)) }
return cleaned.isEmpty ? defaultVoiceWakeTriggers : cleaned return cleaned.isEmpty ? defaultVoiceWakeTriggers : cleaned
} }
@@ -21,7 +21,6 @@ struct VoiceWakeSettings: View {
@State private var micObserver = AudioInputDeviceObserver() @State private var micObserver = AudioInputDeviceObserver()
@State private var micRefreshTask: Task<Void, Never>? @State private var micRefreshTask: Task<Void, Never>?
@State private var availableLocales: [Locale] = [] @State private var availableLocales: [Locale] = []
@State private var triggerEntries: [TriggerEntry] = []
private let fieldLabelWidth: CGFloat = 140 private let fieldLabelWidth: CGFloat = 140
private let controlWidth: CGFloat = 240 private let controlWidth: CGFloat = 240
private let isPreview = ProcessInfo.processInfo.isPreview private let isPreview = ProcessInfo.processInfo.isPreview
@@ -32,9 +31,9 @@ struct VoiceWakeSettings: View {
var id: String { self.uid } var id: String { self.uid }
} }
private struct TriggerEntry: Identifiable { private struct IndexedWord: Identifiable {
let id: UUID let id: Int
var value: String let value: String
} }
private var voiceWakeBinding: Binding<Bool> { private var voiceWakeBinding: Binding<Bool> {
@@ -106,7 +105,6 @@ struct VoiceWakeSettings: View {
.onAppear { .onAppear {
guard !self.isPreview else { return } guard !self.isPreview else { return }
self.startMicObserver() self.startMicObserver()
self.loadTriggerEntries()
} }
.onChange(of: self.state.voiceWakeMicID) { _, _ in .onChange(of: self.state.voiceWakeMicID) { _, _ in
guard !self.isPreview else { return } guard !self.isPreview else { return }
@@ -124,10 +122,8 @@ struct VoiceWakeSettings: View {
self.micRefreshTask = nil self.micRefreshTask = nil
Task { await self.meter.stop() } Task { await self.meter.stop() }
self.micObserver.stop() self.micObserver.stop()
self.syncTriggerEntriesToState()
} else { } else {
self.startMicObserver() self.startMicObserver()
self.loadTriggerEntries()
} }
} }
.onDisappear { .onDisappear {
@@ -140,16 +136,11 @@ struct VoiceWakeSettings: View {
self.micRefreshTask = nil self.micRefreshTask = nil
self.micObserver.stop() self.micObserver.stop()
Task { await self.meter.stop() } Task { await self.meter.stop() }
self.syncTriggerEntriesToState()
} }
} }
private func loadTriggerEntries() { private var indexedWords: [IndexedWord] {
self.triggerEntries = self.state.swabbleTriggerWords.map { TriggerEntry(id: UUID(), value: $0) } self.state.swabbleTriggerWords.enumerated().map { IndexedWord(id: $0.offset, value: $0.element) }
}
private func syncTriggerEntriesToState() {
self.state.swabbleTriggerWords = self.triggerEntries.map(\.value)
} }
private var triggerTable: some View { private var triggerTable: some View {
@@ -163,42 +154,29 @@ struct VoiceWakeSettings: View {
} label: { } label: {
Label("Add word", systemImage: "plus") Label("Add word", systemImage: "plus")
} }
.disabled(self.triggerEntries .disabled(self.state.swabbleTriggerWords
.contains(where: { $0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })) .contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
Button("Reset defaults") { Button("Reset defaults") { self.state.swabbleTriggerWords = defaultVoiceWakeTriggers }
self.triggerEntries = defaultVoiceWakeTriggers.map { TriggerEntry(id: UUID(), value: $0) }
self.syncTriggerEntriesToState()
}
} }
VStack(spacing: 0) { Table(self.indexedWords) {
ForEach(self.$triggerEntries) { $entry in TableColumn("Word") { row in
HStack(spacing: 8) { TextField("Wake word", text: self.binding(for: row.id))
TextField("Wake word", text: $entry.value) .textFieldStyle(.roundedBorder)
.textFieldStyle(.roundedBorder)
.onSubmit {
self.syncTriggerEntriesToState()
}
Button {
self.removeWord(id: entry.id)
} label: {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
.help("Remove trigger word")
.frame(width: 24)
}
.padding(8)
if entry.id != self.triggerEntries.last?.id {
Divider()
}
} }
TableColumn("") { row in
Button {
self.removeWord(at: row.id)
} label: {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
.help("Remove trigger word")
}
.width(36)
} }
.frame(maxWidth: .infinity, minHeight: 180, alignment: .topLeading) .frame(minHeight: 180)
.background(Color(nsColor: .textBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 6)) .clipShape(RoundedRectangle(cornerRadius: 6))
.overlay( .overlay(
RoundedRectangle(cornerRadius: 6) RoundedRectangle(cornerRadius: 6)
@@ -233,12 +211,24 @@ struct VoiceWakeSettings: View {
} }
private func addWord() { private func addWord() {
self.triggerEntries.append(TriggerEntry(id: UUID(), value: "")) self.state.swabbleTriggerWords.append("")
} }
private func removeWord(id: UUID) { private func removeWord(at index: Int) {
self.triggerEntries.removeAll { $0.id == id } guard self.state.swabbleTriggerWords.indices.contains(index) else { return }
self.syncTriggerEntriesToState() self.state.swabbleTriggerWords.remove(at: index)
}
private func binding(for index: Int) -> Binding<String> {
Binding(
get: {
guard self.state.swabbleTriggerWords.indices.contains(index) else { return "" }
return self.state.swabbleTriggerWords[index]
},
set: { newValue in
guard self.state.swabbleTriggerWords.indices.contains(index) else { return }
self.state.swabbleTriggerWords[index] = newValue
})
} }
private func toggleTest() { private func toggleTest() {
@@ -648,14 +638,13 @@ extension VoiceWakeSettings {
state.voicePushToTalkEnabled = true state.voicePushToTalkEnabled = true
state.swabbleTriggerWords = ["Claude", "Hey"] state.swabbleTriggerWords = ["Claude", "Hey"]
var view = VoiceWakeSettings(state: state, isActive: true) let view = VoiceWakeSettings(state: state, isActive: true)
view.availableMics = [AudioInputDevice(uid: "mic-1", name: "Built-in")] view.availableMics = [AudioInputDevice(uid: "mic-1", name: "Built-in")]
view.availableLocales = [Locale(identifier: "en_US")] view.availableLocales = [Locale(identifier: "en_US")]
view.meterLevel = 0.42 view.meterLevel = 0.42
view.meterError = "No input" view.meterError = "No input"
view.testState = .detected("ok") view.testState = .detected("ok")
view.isTesting = true view.isTesting = true
view.triggerEntries = [TriggerEntry(id: UUID(), value: "Claude")]
_ = view.body _ = view.body
_ = view.localePicker _ = view.localePicker
@@ -665,9 +654,8 @@ extension VoiceWakeSettings {
_ = view.chimeSection _ = view.chimeSection
view.addWord() view.addWord()
if let entryId = view.triggerEntries.first?.id { _ = view.binding(for: 0).wrappedValue
view.removeWord(id: entryId) view.removeWord(at: 0)
}
} }
} }
#endif #endif
@@ -12,18 +12,6 @@ struct VoiceWakeHelpersTests {
#expect(cleaned == defaultVoiceWakeTriggers) #expect(cleaned == defaultVoiceWakeTriggers)
} }
@Test func sanitizeTriggersLimitsWordLength() {
let long = String(repeating: "x", count: voiceWakeMaxWordLength + 5)
let cleaned = sanitizeVoiceWakeTriggers(["ok", long])
#expect(cleaned[1].count == voiceWakeMaxWordLength)
}
@Test func sanitizeTriggersLimitsWordCount() {
let words = (1...voiceWakeMaxWords + 3).map { "w\($0)" }
let cleaned = sanitizeVoiceWakeTriggers(words)
#expect(cleaned.count == voiceWakeMaxWords)
}
@Test func normalizeLocaleStripsCollation() { @Test func normalizeLocaleStripsCollation() {
#expect(normalizeLocaleIdentifier("en_US@collation=phonebook") == "en_US") #expect(normalizeLocaleIdentifier("en_US@collation=phonebook") == "en_US")
} }
+6 -55
View File
@@ -362,72 +362,23 @@ By default, Clawdbot replies in the main channel. Use `channels.slack.replyToMod
The mode applies to both auto-replies and agent tool calls (`slack sendMessage`). The mode applies to both auto-replies and agent tool calls (`slack sendMessage`).
### Per-chat-type threading ### DM-specific threading
You can configure different threading behavior per chat type by setting `channels.slack.replyToModeByChatType`: You can configure different threading behavior for DMs vs channels by setting `channels.slack.dm.replyToMode`:
```json5 ```json5
{ {
channels: { channels: {
slack: { slack: {
replyToMode: "off", // default for channels replyToMode: "off", // default for channels
replyToModeByChatType: { dm: {
direct: "all", // DMs always thread replyToMode: "all" // DMs always thread
group: "first" // group DMs/MPIM thread first reply }
},
} }
} }
} }
``` ```
Supported chat types: When `dm.replyToMode` is set, DMs use that mode; channels use the top-level `replyToMode`. If `dm.replyToMode` is not set, both DMs and channels use the top-level setting.
- `direct`: 1:1 DMs (Slack `im`)
- `group`: group DMs / MPIMs (Slack `mpim`)
- `channel`: standard channels (public/private)
Precedence:
1) `replyToModeByChatType.<chatType>`
2) `replyToMode`
3) Provider default (`off`)
Legacy `channels.slack.dm.replyToMode` is still accepted as a fallback for `direct` when no chat-type override is set.
Examples:
Thread DMs only:
```json5
{
channels: {
slack: {
replyToMode: "off",
replyToModeByChatType: { direct: "all" }
}
}
}
```
Thread group DMs but keep channels in the root:
```json5
{
channels: {
slack: {
replyToMode: "off",
replyToModeByChatType: { group: "first" }
}
}
}
```
Make channels thread, keep DMs in the root:
```json5
{
channels: {
slack: {
replyToMode: "first",
replyToModeByChatType: { direct: "off", group: "off" }
}
}
}
```
### Manual threading tags ### Manual threading tags
For fine-grained control, use these tags in agent responses: For fine-grained control, use these tags in agent responses:
-7
View File
@@ -700,15 +700,8 @@ Options:
- `--json` - `--json`
- `--plain` - `--plain`
- `--check` (exit 1=expired/missing, 2=expiring) - `--check` (exit 1=expired/missing, 2=expiring)
- `--probe` (live probe of configured auth profiles)
- `--probe-provider <name>`
- `--probe-profile <id>` (repeat or comma-separated)
- `--probe-timeout <ms>`
- `--probe-concurrency <n>`
- `--probe-max-tokens <n>`
Always includes the auth overview and OAuth expiry status for profiles in the auth store. Always includes the auth overview and OAuth expiry status for profiles in the auth store.
`--probe` runs live requests (may consume tokens and trigger rate limits).
### `models set <model>` ### `models set <model>`
Set `agents.defaults.model.primary`. Set `agents.defaults.model.primary`.
-14
View File
@@ -25,26 +25,12 @@ clawdbot models scan
`clawdbot models status` shows the resolved default/fallbacks plus an auth overview. `clawdbot models status` shows the resolved default/fallbacks plus an auth overview.
When provider usage snapshots are available, the OAuth/token status section includes When provider usage snapshots are available, the OAuth/token status section includes
provider usage headers. provider usage headers.
Add `--probe` to run live auth probes against each configured provider profile.
Probes are real requests (may consume tokens and trigger rate limits).
Notes: Notes:
- `models set <model-or-alias>` accepts `provider/model` or an alias. - `models set <model-or-alias>` accepts `provider/model` or an alias.
- Model refs are parsed by splitting on the **first** `/`. If the model ID includes `/` (OpenRouter-style), include the provider prefix (example: `openrouter/moonshotai/kimi-k2`). - Model refs are parsed by splitting on the **first** `/`. If the model ID includes `/` (OpenRouter-style), include the provider prefix (example: `openrouter/moonshotai/kimi-k2`).
- If you omit the provider, Clawdbot treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID). - If you omit the provider, Clawdbot treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID).
### `models status`
Options:
- `--json`
- `--plain`
- `--check` (exit 1=expired/missing, 2=expiring)
- `--probe` (live probe of configured auth profiles)
- `--probe-provider <name>` (probe one provider)
- `--probe-profile <id>` (repeat or comma-separated profile ids)
- `--probe-timeout <ms>`
- `--probe-concurrency <n>`
- `--probe-max-tokens <n>`
## Aliases + fallbacks ## Aliases + fallbacks
```bash ```bash
+4 -5
View File
@@ -1,5 +1,5 @@
--- ---
summary: "CLI reference for `clawdbot update` (safe-ish source update + gateway auto-restart)" summary: "CLI reference for `clawdbot update` (safe-ish source update + optional gateway 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 --no-restart clawdbot update --restart
clawdbot update --json clawdbot update --json
clawdbot --update clawdbot --update
``` ```
## Options ## Options
- `--no-restart`: skip restarting the Gateway service after a successful update. - `--restart`: restart 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,8 +52,7 @@ 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 (default is to restart). If you select `dev` without a git checkout, it after updating. If you select `dev` without a git checkout, it offers to create one.
offers to create one.
## What it does ## What it does
+1 -25
View File
@@ -25,7 +25,6 @@ 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.
@@ -60,30 +59,7 @@ 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, with Markdown table conversion applied before their own formatting rules.
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
+1 -1
View File
@@ -129,7 +129,7 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
enabled: true, enabled: true,
maxBytes: 20971520, maxBytes: 20971520,
models: [ models: [
{ provider: "openai", model: "gpt-4o-mini-transcribe" }, { provider: "openai", model: "whisper-1" },
// Optional CLI fallback (Whisper binary): // Optional CLI fallback (Whisper binary):
// { type: "cli", command: "whisper", args: ["--model", "base", "{{MediaPath}}"] } // { type: "cli", command: "whisper", args: ["--model", "base", "{{MediaPath}}"] }
], ],
+2 -2
View File
@@ -1865,7 +1865,7 @@ Note: `applyPatch` is only under `tools.exec`.
- Each `models[]` entry: - Each `models[]` entry:
- Provider entry (`type: "provider"` or omitted): - Provider entry (`type: "provider"` or omitted):
- `provider`: API provider id (`openai`, `anthropic`, `google`/`gemini`, `groq`, etc). - `provider`: API provider id (`openai`, `anthropic`, `google`/`gemini`, `groq`, etc).
- `model`: model id override (required for image; defaults to `gpt-4o-mini-transcribe`/`whisper-large-v3-turbo` for audio providers, and `gemini-3-flash-preview` for video). - `model`: model id override (required for image; defaults to `whisper-1`/`whisper-large-v3-turbo` for audio providers, and `gemini-3-flash-preview` for video).
- `profile` / `preferredProfile`: auth profile selection. - `profile` / `preferredProfile`: auth profile selection.
- CLI entry (`type: "cli"`): - CLI entry (`type: "cli"`):
- `command`: executable to run. - `command`: executable to run.
@@ -1890,7 +1890,7 @@ Example:
rules: [{ action: "allow", match: { chatType: "direct" } }] rules: [{ action: "allow", match: { chatType: "direct" } }]
}, },
models: [ models: [
{ provider: "openai", model: "gpt-4o-mini-transcribe" }, { provider: "openai", model: "whisper-1" },
{ type: "cli", command: "whisper", args: ["--model", "base", "{{MediaPath}}"] } { type: "cli", command: "whisper", args: ["--model", "base", "{{MediaPath}}"] }
] ]
}, },
-8
View File
@@ -43,14 +43,6 @@ Almost always a Node/npm PATH issue. Start here:
- [Gateway troubleshooting](/gateway/troubleshooting) - [Gateway troubleshooting](/gateway/troubleshooting)
- [Control UI](/web/control-ui#insecure-http) - [Control UI](/web/control-ui#insecure-http)
### `docs.clawd.bot` shows an SSL error (Comcast/Xfinity)
Some Comcast/Xfinity connections block `docs.clawd.bot` via Xfinity Advanced Security.
Disable Advanced Security or add `docs.clawd.bot` to the allowlist, then retry.
- Xfinity Advanced Security help: https://www.xfinity.com/support/articles/using-xfinity-xfi-advanced-security
- Quick sanity checks: try a mobile hotspot or VPN to confirm it’s ISP-level filtering
### Service says running, but RPC probe fails ### Service says running, but RPC probe fails
- [Gateway troubleshooting](/gateway/troubleshooting) - [Gateway troubleshooting](/gateway/troubleshooting)
+2 -3
View File
@@ -7,7 +7,7 @@ read_when:
# Updating # Updating
Clawdbot is moving fast (pre “1.0”). Treat updates like shipping infra: update → run checks → restart (or use `clawdbot update`, which restarts) → verify. Clawdbot is moving fast (pre “1.0”). Treat updates like shipping infra: update → run checks → restart → 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 clawdbot update --restart
``` ```
It runs a safe-ish update flow: It runs a safe-ish update flow:
@@ -89,7 +89,6 @@ 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.
+3 -19
View File
@@ -6,7 +6,7 @@ read_when:
# Audio / Voice Notes — 2026-01-17 # Audio / Voice Notes — 2026-01-17
## What works ## What works
- **Media understanding (audio)**: If audio understanding is enabled (or auto‑detected), Clawdbot: - **Media understanding (audio)**: If `tools.media.audio` is enabled (or a shared `tools.media.models` entry supports audio), Clawdbot:
1) Locates the first audio attachment (local path or URL) and downloads it if needed. 1) Locates the first audio attachment (local path or URL) and downloads it if needed.
2) Enforces `maxBytes` before sending to each model entry. 2) Enforces `maxBytes` before sending to each model entry.
3) Runs the first eligible model entry in order (provider or CLI). 3) Runs the first eligible model entry in order (provider or CLI).
@@ -15,21 +15,6 @@ read_when:
- **Command parsing**: When transcription succeeds, `CommandBody`/`RawBody` are set to the transcript so slash commands still work. - **Command parsing**: When transcription succeeds, `CommandBody`/`RawBody` are set to the transcript so slash commands still work.
- **Verbose logging**: In `--verbose`, we log when transcription runs and when it replaces the body. - **Verbose logging**: In `--verbose`, we log when transcription runs and when it replaces the body.
## Auto-detection (default)
If you **don’t configure models** and `tools.media.audio.enabled` is **not** set to `false`,
Clawdbot auto-detects in this order and stops at the first working option:
1) **Local CLIs** (if installed)
- `sherpa-onnx-offline` (requires `SHERPA_ONNX_MODEL_DIR` with encoder/decoder/joiner/tokens)
- `whisper-cli` (from `whisper-cpp`; uses `WHISPER_CPP_MODEL` or the bundled tiny model)
- `whisper` (Python CLI; downloads models automatically)
2) **Gemini CLI** (`gemini`) using `read_many_files`
3) **Provider keys** (OpenAI → Groq → Deepgram → Google)
To disable auto-detection, set `tools.media.audio.enabled: false`.
To customize, set `tools.media.audio.models`.
Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI is on `PATH` (we expand `~`), or set an explicit CLI model with a full command path.
## Config examples ## Config examples
### Provider + CLI fallback (OpenAI + Whisper CLI) ### Provider + CLI fallback (OpenAI + Whisper CLI)
@@ -41,7 +26,7 @@ Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI
enabled: true, enabled: true,
maxBytes: 20971520, maxBytes: 20971520,
models: [ models: [
{ provider: "openai", model: "gpt-4o-mini-transcribe" }, { provider: "openai", model: "whisper-1" },
{ {
type: "cli", type: "cli",
command: "whisper", command: "whisper",
@@ -69,7 +54,7 @@ Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI
] ]
}, },
models: [ models: [
{ provider: "openai", model: "gpt-4o-mini-transcribe" } { provider: "openai", model: "whisper-1" }
] ]
} }
} }
@@ -98,7 +83,6 @@ Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI
- Audio providers can override `baseUrl`, `headers`, and `providerOptions` via `tools.media.audio`. - Audio providers can override `baseUrl`, `headers`, and `providerOptions` via `tools.media.audio`.
- Default size cap is 20MB (`tools.media.audio.maxBytes`). Oversize audio is skipped for that model and the next entry is tried. - Default size cap is 20MB (`tools.media.audio.maxBytes`). Oversize audio is skipped for that model and the next entry is tried.
- Default `maxChars` for audio is **unset** (full transcript). Set `tools.media.audio.maxChars` or per-entry `maxChars` to trim output. - Default `maxChars` for audio is **unset** (full transcript). Set `tools.media.audio.maxChars` or per-entry `maxChars` to trim output.
- OpenAI auto default is `gpt-4o-mini-transcribe`; set `model: "gpt-4o-transcribe"` for higher accuracy.
- Use `tools.media.audio.attachments` to process multiple voice notes (`mode: "all"` + `maxAttachments`). - Use `tools.media.audio.attachments` to process multiple voice notes (`mode: "all"` + `maxAttachments`).
- Transcript is available to templates as `{{Transcript}}`. - Transcript is available to templates as `{{Transcript}}`.
- CLI stdout is capped (5MB); keep CLI output concise. - CLI stdout is capped (5MB); keep CLI output concise.
+14 -25
View File
@@ -6,7 +6,7 @@ read_when:
--- ---
# Media Understanding (Inbound) — 2026-01-17 # Media Understanding (Inbound) — 2026-01-17
Clawdbot can **summarize inbound media** (image/audio/video) before the reply pipeline runs. It auto‑detects when local tools or provider keys are available, and can be disabled or customized. If understanding is off, models still receive the original files/URLs as usual. Clawdbot can optionally **summarize inbound media** (image/audio/video) before the reply pipeline runs. This is **opt-in** and separate from the base attachment flow—if understanding is off, models still receive the original files/URLs as usual.
## Goals ## Goals
- Optional: pre‑digest inbound media into short text for faster routing + better command parsing. - Optional: pre‑digest inbound media into short text for faster routing + better command parsing.
@@ -88,11 +88,6 @@ Each `models[]` entry can be **provider** or **CLI**:
} }
``` ```
CLI templates can also use:
- `{{MediaDir}}` (directory containing the media file)
- `{{OutputDir}}` (scratch dir created for this run)
- `{{OutputBase}}` (scratch file base path, no extension)
## Defaults and limits ## Defaults and limits
Recommended defaults: Recommended defaults:
- `maxChars`: **500** for image/video (short, command‑friendly) - `maxChars`: **500** for image/video (short, command‑friendly)
@@ -109,22 +104,17 @@ Rules:
- If `<capability>.enabled: true` but no models are configured, Clawdbot tries the - If `<capability>.enabled: true` but no models are configured, Clawdbot tries the
**active reply model** when its provider supports the capability. **active reply model** when its provider supports the capability.
### Auto-detect media understanding (default) ### Auto-enable audio (when keys exist)
If `tools.media.<capability>.enabled` is **not** set to `false` and you haven’t If `tools.media.audio.enabled` is **not** set to `false` and you have any supported
configured models, Clawdbot auto-detects in this order and **stops at the first audio provider keys configured, Clawdbot will **auto-enable audio transcription**
working option**: even when you haven’t listed models explicitly.
1) **Local CLIs** (audio only; if installed) Providers checked (in order):
- `sherpa-onnx-offline` (requires `SHERPA_ONNX_MODEL_DIR` with encoder/decoder/joiner/tokens) 1) OpenAI
- `whisper-cli` (`whisper-cpp`; uses `WHISPER_CPP_MODEL` or the bundled tiny model) 2) Groq
- `whisper` (Python CLI; downloads models automatically) 3) Deepgram
2) **Gemini CLI** (`gemini`) using `read_many_files`
3) **Provider keys**
- Audio: OpenAI → Groq → Deepgram → Google
- Image: OpenAI → Anthropic → Google → MiniMax
- Video: Google
To disable auto-detection, set: To disable this behavior, set:
```json5 ```json5
{ {
tools: { tools: {
@@ -136,7 +126,6 @@ To disable auto-detection, set:
} }
} }
``` ```
Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI is on `PATH` (we expand `~`), or set an explicit CLI model with a full command path.
## Capabilities (optional) ## Capabilities (optional)
If you set `capabilities`, the entry only runs for those media types. For shared If you set `capabilities`, the entry only runs for those media types. For shared
@@ -153,7 +142,7 @@ If you omit `capabilities`, the entry is eligible for the list it appears in.
| Capability | Provider integration | Notes | | Capability | Provider integration | Notes |
|------------|----------------------|-------| |------------|----------------------|-------|
| Image | OpenAI / Anthropic / Google / others via `pi-ai` | Any image-capable model in the registry works. | | Image | OpenAI / Anthropic / Google / others via `pi-ai` | Any image-capable model in the registry works. |
| Audio | OpenAI, Groq, Deepgram, Google | Provider transcription (Whisper/Deepgram/Gemini). | | Audio | OpenAI, Groq, Deepgram | Provider transcription (Whisper/Deepgram). |
| Video | Google (Gemini API) | Provider video understanding. | | Video | Google (Gemini API) | Provider video understanding. |
## Recommended providers ## Recommended providers
@@ -162,8 +151,8 @@ If you omit `capabilities`, the entry is eligible for the list it appears in.
- Good defaults: `openai/gpt-5.2`, `anthropic/claude-opus-4-5`, `google/gemini-3-pro-preview`. - Good defaults: `openai/gpt-5.2`, `anthropic/claude-opus-4-5`, `google/gemini-3-pro-preview`.
**Audio** **Audio**
- `openai/gpt-4o-mini-transcribe`, `groq/whisper-large-v3-turbo`, or `deepgram/nova-3`. - `openai/whisper-1`, `groq/whisper-large-v3-turbo`, or `deepgram/nova-3`.
- CLI fallback: `whisper-cli` (whisper-cpp) or `whisper`. - CLI fallback: `whisper` binary.
- Deepgram setup: [Deepgram (audio transcription)](/providers/deepgram). - Deepgram setup: [Deepgram (audio transcription)](/providers/deepgram).
**Video** **Video**
@@ -220,7 +209,7 @@ When `mode: "all"`, outputs are labeled `[Image 1/2]`, `[Audio 2/2]`, etc.
audio: { audio: {
enabled: true, enabled: true,
models: [ models: [
{ provider: "openai", model: "gpt-4o-mini-transcribe" }, { provider: "openai", model: "whisper-1" },
{ {
type: "cli", type: "cli",
command: "whisper", command: "whisper",
+8 -8
View File
@@ -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). If it is missing, check `~/.profile`. - 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).
- 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`
@@ -30,17 +30,17 @@ Notes:
# From repo root; set release IDs so Sparkle feed is enabled. # From repo root; set release IDs so Sparkle feed is enabled.
# APP_BUILD must be numeric + monotonic for Sparkle compare. # APP_BUILD must be numeric + monotonic for Sparkle compare.
BUNDLE_ID=com.clawdbot.mac \ BUNDLE_ID=com.clawdbot.mac \
APP_VERSION=2026.1.23 \ APP_VERSION=2026.1.21 \
APP_BUILD="$(git rev-list --count HEAD)" \ APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \ BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \ SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-app.sh scripts/package-mac-app.sh
# Zip for distribution (includes resource forks for Sparkle delta support) # Zip for distribution (includes resource forks for Sparkle delta support)
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.23.zip ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.21.zip
# Optional: also build a styled DMG for humans (drag to /Applications) # Optional: also build a styled DMG for humans (drag to /Applications)
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.23.dmg scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.21.dmg
# Recommended: build + notarize/staple zip + DMG # Recommended: build + notarize/staple zip + DMG
# First, create a keychain profile once: # First, create a keychain profile once:
@@ -48,26 +48,26 @@ scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.23.dmg
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>" # --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \ NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \
BUNDLE_ID=com.clawdbot.mac \ BUNDLE_ID=com.clawdbot.mac \
APP_VERSION=2026.1.23 \ APP_VERSION=2026.1.21 \
APP_BUILD="$(git rev-list --count HEAD)" \ APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \ BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \ SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-dist.sh scripts/package-mac-dist.sh
# Optional: ship dSYM alongside the release # Optional: ship dSYM alongside the release
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.23.dSYM.zip ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.21.dSYM.zip
``` ```
## Appcast entry ## Appcast entry
Use the release note generator so Sparkle renders formatted HTML notes: Use the release note generator so Sparkle renders formatted HTML notes:
```bash ```bash
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.23.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.21.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
``` ```
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry. Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing. Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
## Publish & verify ## Publish & verify
- Upload `Clawdbot-2026.1.23.zip` (and `Clawdbot-2026.1.23.dSYM.zip`) to the GitHub release for tag `v2026.1.23`. - Upload `Clawdbot-2026.1.21.zip` (and `Clawdbot-2026.1.21.dSYM.zip`) to the GitHub release for tag `v2026.1.21`.
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`. - Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`.
- Sanity checks: - Sanity checks:
- `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200. - `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200.
-2
View File
@@ -82,8 +82,6 @@ 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)
+9 -5
View File
@@ -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, then exchange it for Use the native device-login flow to obtain a GitHub token and use it directly
Copilot API tokens when Clawdbot runs. This is the **default** and simplest path against the Copilot API. This is the **default** and simplest path because it
because it does not require VS Code. does not require VS Code. Enterprise domains are supported.
### 2) Copilot Proxy plugin (`copilot-proxy`) ### 2) Copilot Proxy plugin (`copilot-proxy`)
@@ -39,6 +39,8 @@ 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
@@ -66,5 +68,7 @@ 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 exchanges it for a - The login stores a GitHub token in the auth profile store and uses it directly
Copilot API token when Clawdbot runs. for Copilot API calls.
- Base URL: `https://api.githubcopilot.com` (public) or `https://copilot-api.<domain>`
for GitHub Enterprise.
+2 -3
View File
@@ -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 (SPARKLE_PRIVATE_KEY_FILE should live in `~/.profile`). - Load env from `~/.profile` and confirm `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect vars are set.
- 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,9 +39,8 @@ 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)
- [ ] `CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke` (Docker install smoke test, fast path; required before release) - [ ] `pnpm test:install:smoke` (Docker install smoke test; 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`)
-1
View File
@@ -324,7 +324,6 @@ brew install <formula>
``` ```
If you run Clawdbot via systemd, ensure the service PATH includes `/home/linuxbrew/.linuxbrew/bin` (or your brew prefix) so `brew`-installed tools resolve in non‑login shells. If you run Clawdbot via systemd, ensure the service PATH includes `/home/linuxbrew/.linuxbrew/bin` (or your brew prefix) so `brew`-installed tools resolve in non‑login shells.
Recent builds also prepend common user bin dirs on Linux systemd services (for example `~/.local/bin`, `~/.npm-global/bin`, `~/.local/share/pnpm`, `~/.bun/bin`) and honor `PNPM_HOME`, `NPM_CONFIG_PREFIX`, `BUN_INSTALL`, `VOLTA_HOME`, `ASDF_DATA_DIR`, `NVM_DIR`, and `FNM_DIR` when set.
### Can I switch between npm and git installs later? ### Can I switch between npm and git installs later?
+7 -1
View File
@@ -327,10 +327,16 @@ Full setup walkthrough (28m) by VelvetShark.
<Card title="OpenRouter Transcription" icon="microphone" href="https://clawdhub.com/obviyus/openrouter-transcribe"> <Card title="OpenRouter Transcription" icon="microphone" href="https://clawdhub.com/obviyus/openrouter-transcribe">
**@obviyus** • `transcription` `multilingual` `skill` **@obviyus** • `transcription` `multilingual` `skill`
Multi-lingual audio transcription via OpenRouter (Gemini, etc). Available on ClawdHub. Multi-lingual audio transcription via OpenRouter (Gemini, etc). Available on ClawdHub.
</Card> </Card>
<Card title="Google Docs Editor" icon="file-word">
**Community** • `docs` `editing` `skill`
Rich-text Google Docs editing skill. Built rapidly with Claude Code.
</Card>
</CardGroup> </CardGroup>
## 🏗️ Infrastructure & Deployment ## 🏗️ Infrastructure & Deployment
-2
View File
@@ -54,7 +54,6 @@ Example schema:
"autoAllowSkills": true, "autoAllowSkills": true,
"allowlist": [ "allowlist": [
{ {
"id": "B0C8C0B3-2C2D-4F8A-9A3C-5A4B3C2D1E0F",
"pattern": "~/Projects/**/bin/rg", "pattern": "~/Projects/**/bin/rg",
"lastUsedAt": 1737150000000, "lastUsedAt": 1737150000000,
"lastUsedCommand": "rg -n TODO", "lastUsedCommand": "rg -n TODO",
@@ -97,7 +96,6 @@ Examples:
- `/opt/homebrew/bin/rg` - `/opt/homebrew/bin/rg`
Each allowlist entry tracks: Each allowlist entry tracks:
- **id** stable UUID used for UI identity (optional)
- **last used** timestamp - **last used** timestamp
- **last used command** - **last used command**
- **last resolved path** - **last resolved path**
-4
View File
@@ -121,10 +121,6 @@ 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:
-2
View File
@@ -88,8 +88,6 @@ Session lifecycle:
- `/settings` - `/settings`
- `/exit` - `/exit`
Other Gateway slash commands (for example, `/context`) are forwarded to the Gateway and shown as system output. See [Slash commands](/tools/slash-commands).
## Local shell commands ## Local shell commands
- Prefix a line with `!` to run a local shell command on the TUI host. - Prefix a line with `!` to run a local shell command on the TUI host.
- The TUI prompts once per session to allow local execution; declining keeps `!` disabled for the session. - The TUI prompts once per session to allow local execution; declining keeps `!` disabled for the session.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/bluebubbles", "name": "@clawdbot/bluebubbles",
"version": "2026.1.23", "version": "2026.1.22",
"type": "module", "type": "module",
"description": "Clawdbot BlueBubbles channel plugin", "description": "Clawdbot BlueBubbles channel plugin",
"clawdbot": { "clawdbot": {
@@ -1,4 +1,3 @@
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()]);
@@ -26,7 +25,6 @@ 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(),
+36 -50
View File
@@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { removeAckReactionAfterReply, shouldAckReaction } from "clawdbot/plugin-sdk";
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk"; import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
import { import {
handleBlueBubblesWebhookRequest, handleBlueBubblesWebhookRequest,
@@ -100,8 +99,6 @@ 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"],
@@ -129,7 +126,6 @@ function createMockRuntime(): PluginRuntime {
session: { session: {
resolveStorePath: mockResolveStorePath as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"], resolveStorePath: mockResolveStorePath as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"],
readSessionUpdatedAt: mockReadSessionUpdatedAt as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"], readSessionUpdatedAt: mockReadSessionUpdatedAt as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"],
recordInboundSession: vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"],
recordSessionMetaFromInbound: vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordSessionMetaFromInbound"], recordSessionMetaFromInbound: vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordSessionMetaFromInbound"],
updateLastRoute: vi.fn() as unknown as PluginRuntime["channel"]["session"]["updateLastRoute"], updateLastRoute: vi.fn() as unknown as PluginRuntime["channel"]["session"]["updateLastRoute"],
}, },
@@ -137,10 +133,6 @@ function createMockRuntime(): PluginRuntime {
buildMentionRegexes: mockBuildMentionRegexes as unknown as PluginRuntime["channel"]["mentions"]["buildMentionRegexes"], buildMentionRegexes: mockBuildMentionRegexes as unknown as PluginRuntime["channel"]["mentions"]["buildMentionRegexes"],
matchesMentionPatterns: mockMatchesMentionPatterns as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"], matchesMentionPatterns: mockMatchesMentionPatterns as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"],
}, },
reactions: {
shouldAckReaction,
removeAckReactionAfterReply,
},
groups: { groups: {
resolveGroupPolicy: mockResolveGroupPolicy as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"], resolveGroupPolicy: mockResolveGroupPolicy as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"],
resolveRequireMention: mockResolveRequireMention as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"], resolveRequireMention: mockResolveRequireMention as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"],
@@ -228,12 +220,6 @@ function createMockResponse(): ServerResponse & { body: string; statusCode: numb
return res; return res;
} }
const flushAsync = async () => {
for (let i = 0; i < 2; i += 1) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
};
describe("BlueBubbles webhook monitor", () => { describe("BlueBubbles webhook monitor", () => {
let unregister: () => void; let unregister: () => void;
@@ -520,7 +506,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
expect(resolveChatGuidForTarget).toHaveBeenCalledWith( expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@@ -568,7 +554,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
expect(resolveChatGuidForTarget).not.toHaveBeenCalled(); expect(resolveChatGuidForTarget).not.toHaveBeenCalled();
expect(sendMessageBlueBubbles).toHaveBeenCalledWith( expect(sendMessageBlueBubbles).toHaveBeenCalledWith(
@@ -615,7 +601,7 @@ describe("BlueBubbles webhook monitor", () => {
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
// Wait for async processing // Wait for async processing
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
@@ -654,7 +640,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
@@ -695,7 +681,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockUpsertPairingRequest).toHaveBeenCalled(); expect(mockUpsertPairingRequest).toHaveBeenCalled();
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
@@ -738,7 +724,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockUpsertPairingRequest).toHaveBeenCalled(); expect(mockUpsertPairingRequest).toHaveBeenCalled();
// Should not send pairing reply since created=false // Should not send pairing reply since created=false
@@ -779,7 +765,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
}); });
@@ -816,7 +802,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
}); });
@@ -856,7 +842,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
}); });
@@ -894,7 +880,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
}); });
@@ -933,7 +919,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
}); });
@@ -972,7 +958,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
}); });
@@ -1013,7 +999,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1054,7 +1040,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
}); });
@@ -1092,7 +1078,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
}); });
@@ -1135,7 +1121,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1181,7 +1167,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1227,7 +1213,7 @@ describe("BlueBubbles webhook monitor", () => {
const originalRes = createMockResponse(); const originalRes = createMockResponse();
await handleBlueBubblesWebhookRequest(originalReq, originalRes); await handleBlueBubblesWebhookRequest(originalReq, originalRes);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
// Only assert the reply message behavior below. // Only assert the reply message behavior below.
mockDispatchReplyWithBufferedBlockDispatcher.mockClear(); mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
@@ -1251,7 +1237,7 @@ describe("BlueBubbles webhook monitor", () => {
const replyRes = createMockResponse(); const replyRes = createMockResponse();
await handleBlueBubblesWebhookRequest(replyReq, replyRes); await handleBlueBubblesWebhookRequest(replyReq, replyRes);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1297,7 +1283,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1345,7 +1331,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
expect(sendBlueBubblesReaction).toHaveBeenCalledWith( expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@@ -1398,7 +1384,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
// Should process even without mention because it's an authorized control command // Should process even without mention because it's an authorized control command
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
@@ -1441,7 +1427,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
}); });
@@ -1484,7 +1470,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
expect(markBlueBubblesChatRead).toHaveBeenCalled(); expect(markBlueBubblesChatRead).toHaveBeenCalled();
}); });
@@ -1525,7 +1511,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
expect(markBlueBubblesChatRead).not.toHaveBeenCalled(); expect(markBlueBubblesChatRead).not.toHaveBeenCalled();
}); });
@@ -1568,7 +1554,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
// Should call typing start when reply flow triggers it. // Should call typing start when reply flow triggers it.
expect(sendBlueBubblesTyping).toHaveBeenCalledWith( expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
@@ -1618,7 +1604,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
expect(sendBlueBubblesTyping).toHaveBeenCalledWith( expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
expect.any(String), expect.any(String),
@@ -1663,7 +1649,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
expect(sendBlueBubblesTyping).toHaveBeenCalledWith( expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
expect.any(String), expect.any(String),
@@ -1711,7 +1697,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
// Outbound message ID uses short ID "2" (inbound msg-1 is "1", outbound msg-123 is "2") // Outbound message ID uses short ID "2" (inbound msg-1 is "1", outbound msg-123 is "2")
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
@@ -1756,7 +1742,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
expect.stringContaining("reaction added"), expect.stringContaining("reaction added"),
@@ -1796,7 +1782,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
expect.stringContaining("reaction removed"), expect.stringContaining("reaction removed"),
@@ -1836,7 +1822,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockEnqueueSystemEvent).not.toHaveBeenCalled(); expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
}); });
@@ -1874,7 +1860,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
expect.stringContaining("👍"), expect.stringContaining("👍"),
@@ -1915,7 +1901,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1955,7 +1941,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
// The short ID "1" should resolve back to the full UUID // The short ID "1" should resolve back to the full UUID
expect(resolveBlueBubblesMessageId("1")).toBe("msg-uuid-12345"); expect(resolveBlueBubblesMessageId("1")).toBe("msg-uuid-12345");
@@ -2007,7 +1993,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await flushAsync(); await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
}); });
+56 -81
View File
@@ -1,13 +1,7 @@
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { import { resolveAckReaction } from "clawdbot/plugin-sdk";
logAckFailure,
logInboundDrop,
logTypingFailure,
resolveAckReaction,
resolveControlCommandGate,
} from "clawdbot/plugin-sdk";
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js"; import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
import { downloadBlueBubblesAttachment } from "./attachments.js"; import { downloadBlueBubblesAttachment } from "./attachments.js";
@@ -1352,25 +1346,23 @@ async function processMessage(
}) })
: false; : false;
const dmAuthorized = dmPolicy === "open" || ownerAllowedForCommands; const dmAuthorized = dmPolicy === "open" || ownerAllowedForCommands;
const commandGate = resolveControlCommandGate({ const commandAuthorized = isGroup
useAccessGroups, ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
authorizers: [ useAccessGroups,
{ configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands }, authorizers: [
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, { configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands },
], { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
allowTextCommands: true, ],
hasControlCommand: hasControlCmd, })
}); : dmAuthorized;
const commandAuthorized = isGroup ? commandGate.commandAuthorized : dmAuthorized;
// Block control commands from unauthorized senders in groups // Block control commands from unauthorized senders in groups
if (isGroup && commandGate.shouldBlock) { if (isGroup && hasControlCmd && !commandAuthorized) {
logInboundDrop({ logVerbose(
log: (msg) => logVerbose(core, runtime, msg), core,
channel: "bluebubbles", runtime,
reason: "control command (unauthorized)", `bluebubbles: drop control command from unauthorized sender ${message.senderId}`,
target: message.senderId, );
});
return; return;
} }
@@ -1529,20 +1521,19 @@ async function processMessage(
core, core,
runtime, runtime,
}); });
const shouldAckReaction = () => const shouldAckReaction = () => {
Boolean( if (!ackReactionValue) return false;
ackReactionValue && if (ackReactionScope === "all") return true;
core.channel.reactions.shouldAckReaction({ if (ackReactionScope === "direct") return !isGroup;
scope: ackReactionScope, if (ackReactionScope === "group-all") return isGroup;
isDirect: !isGroup, if (ackReactionScope === "group-mentions") {
isGroup, if (!isGroup) return false;
isMentionableGroup: isGroup, if (!requireMention) return false;
requireMention: Boolean(requireMention), if (!canDetectMention) return false;
canDetectMention, return effectiveWasMentioned;
effectiveWasMentioned, }
shouldBypassMention, return false;
}), };
);
const ackMessageId = message.messageId?.trim() || ""; const ackMessageId = message.messageId?.trim() || "";
const ackReactionPromise = const ackReactionPromise =
shouldAckReaction() && ackMessageId && chatGuidForActions && ackReactionValue shouldAckReaction() && ackMessageId && chatGuidForActions && ackReactionValue
@@ -1671,15 +1662,9 @@ 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 ? text : undefined; const caption = first ? payload.text : undefined;
first = false; first = false;
const result = await sendBlueBubblesMedia({ const result = await sendBlueBubblesMedia({
cfg: config, cfg: config,
@@ -1701,14 +1686,8 @@ 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 tableMode = core.channel.text.resolveMarkdownTableMode({ const chunks = core.channel.text.chunkMarkdownText(payload.text ?? "", textLimit);
cfg: config, if (!chunks.length && payload.text) chunks.push(payload.text);
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, {
@@ -1758,27 +1737,29 @@ async function processMessage(
}, },
}); });
} finally { } finally {
if (sentMessage && chatGuidForActions && ackMessageId) { if (
core.channel.reactions.removeAckReactionAfterReply({ removeAckAfterReply &&
removeAfterReply: removeAckAfterReply, sentMessage &&
ackReactionPromise, ackReactionPromise &&
ackReactionValue: ackReactionValue ?? null, ackReactionValue &&
remove: () => chatGuidForActions &&
sendBlueBubblesReaction({ ackMessageId
chatGuid: chatGuidForActions, ) {
messageGuid: ackMessageId, void ackReactionPromise.then((didAck) => {
emoji: ackReactionValue ?? "", if (!didAck) return;
remove: true, sendBlueBubblesReaction({
opts: { cfg: config, accountId: account.accountId }, chatGuid: chatGuidForActions,
}), messageGuid: ackMessageId,
onError: (err) => { emoji: ackReactionValue,
logAckFailure({ remove: true,
log: (msg) => logVerbose(core, runtime, msg), opts: { cfg: config, accountId: account.accountId },
channel: "bluebubbles", }).catch((err) => {
target: `${chatGuidForActions}/${ackMessageId}`, logVerbose(
error: err, core,
}); runtime,
}, `ack reaction removal failed chatGuid=${chatGuidForActions} msg=${ackMessageId}: ${String(err)}`,
);
});
}); });
} }
if (chatGuidForActions && baseUrl && password && !sentMessage) { if (chatGuidForActions && baseUrl && password && !sentMessage) {
@@ -1787,13 +1768,7 @@ async function processMessage(
cfg: config, cfg: config,
accountId: account.accountId, accountId: account.accountId,
}).catch((err) => { }).catch((err) => {
logTypingFailure({ logVerbose(core, runtime, `typing stop (no reply) failed: ${String(err)}`);
log: (msg) => logVerbose(core, runtime, msg),
channel: "bluebubbles",
action: "stop",
target: chatGuidForActions,
error: err,
});
}); });
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/copilot-proxy", "name": "@clawdbot/copilot-proxy",
"version": "2026.1.23", "version": "2026.1.22",
"type": "module", "type": "module",
"description": "Clawdbot Copilot Proxy provider plugin", "description": "Clawdbot Copilot Proxy provider plugin",
"clawdbot": { "clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/diagnostics-otel", "name": "@clawdbot/diagnostics-otel",
"version": "2026.1.23", "version": "2026.1.22",
"type": "module", "type": "module",
"description": "Clawdbot diagnostics OpenTelemetry exporter", "description": "Clawdbot diagnostics OpenTelemetry exporter",
"clawdbot": { "clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/discord", "name": "@clawdbot/discord",
"version": "2026.1.23", "version": "2026.1.22",
"type": "module", "type": "module",
"description": "Clawdbot Discord channel plugin", "description": "Clawdbot Discord channel plugin",
"clawdbot": { "clawdbot": {
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/google-antigravity-auth", "name": "@clawdbot/google-antigravity-auth",
"version": "2026.1.23", "version": "2026.1.22",
"type": "module", "type": "module",
"description": "Clawdbot Google Antigravity OAuth provider plugin", "description": "Clawdbot Google Antigravity OAuth provider plugin",
"clawdbot": { "clawdbot": {
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/google-gemini-cli-auth", "name": "@clawdbot/google-gemini-cli-auth",
"version": "2026.1.23", "version": "2026.1.22",
"type": "module", "type": "module",
"description": "Clawdbot Gemini CLI OAuth provider plugin", "description": "Clawdbot Gemini CLI OAuth provider plugin",
"clawdbot": { "clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/imessage", "name": "@clawdbot/imessage",
"version": "2026.1.23", "version": "2026.1.22",
"type": "module", "type": "module",
"description": "Clawdbot iMessage channel plugin", "description": "Clawdbot iMessage channel plugin",
"clawdbot": { "clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/lobster", "name": "@clawdbot/lobster",
"version": "2026.1.23", "version": "2026.1.22",
"type": "module", "type": "module",
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)", "description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
"clawdbot": { "clawdbot": {
-5
View File
@@ -1,10 +1,5 @@
# Changelog # Changelog
## 2026.1.23
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.22 ## 2026.1.22
### Changes ### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/matrix", "name": "@clawdbot/matrix",
"version": "2026.1.23", "version": "2026.1.22",
"type": "module", "type": "module",
"description": "Clawdbot Matrix channel plugin", "description": "Clawdbot Matrix channel plugin",
"clawdbot": { "clawdbot": {
-2
View File
@@ -1,4 +1,3 @@
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()]);
@@ -36,7 +35,6 @@ 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(),
+46 -83
View File
@@ -1,12 +1,7 @@
import type { LocationMessageEventContent, MatrixClient } from "matrix-bot-sdk"; import type { LocationMessageEventContent, MatrixClient } from "matrix-bot-sdk";
import { import {
createReplyPrefixContext,
createTypingCallbacks,
formatAllowlistMatchMeta, formatAllowlistMatchMeta,
logInboundDrop,
logTypingFailure,
resolveControlCommandGate,
type RuntimeEnv, type RuntimeEnv,
} from "clawdbot/plugin-sdk"; } from "clawdbot/plugin-sdk";
import type { CoreConfig, ReplyToMode } from "../../types.js"; import type { CoreConfig, ReplyToMode } from "../../types.js";
@@ -381,25 +376,21 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
userName: senderName, userName: senderName,
}) })
: false; : false;
const hasControlCommandInMessage = core.channel.text.hasControlCommand(bodyText, cfg); const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
const commandGate = resolveControlCommandGate({
useAccessGroups, useAccessGroups,
authorizers: [ authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }, { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
{ configured: roomUsers.length > 0, allowed: senderAllowedForRoomUsers }, { configured: roomUsers.length > 0, allowed: senderAllowedForRoomUsers },
{ configured: groupAllowConfigured, allowed: senderAllowedForGroup }, { configured: groupAllowConfigured, allowed: senderAllowedForGroup },
], ],
allowTextCommands,
hasControlCommand: hasControlCommandInMessage,
}); });
const commandAuthorized = commandGate.commandAuthorized; if (
if (isRoom && commandGate.shouldBlock) { isRoom &&
logInboundDrop({ allowTextCommands &&
log: logVerboseMessage, core.channel.text.hasControlCommand(bodyText, cfg) &&
channel: "matrix", !commandAuthorized
reason: "control command (unauthorized)", ) {
target: senderId, logVerboseMessage(`matrix: drop control command from unauthorized sender ${senderId}`);
});
return; return;
} }
const shouldRequireMention = isRoom const shouldRequireMention = isRoom
@@ -418,8 +409,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
!wasMentioned && !wasMentioned &&
!hasExplicitMention && !hasExplicitMention &&
commandAuthorized && commandAuthorized &&
hasControlCommandInMessage; core.channel.text.hasControlCommand(bodyText);
const canDetectMention = mentionRegexes.length > 0 || hasExplicitMention;
if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) { if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) {
logger.info({ roomId, reason: "no-mention" }, "skipping room message"); logger.info({ roomId, reason: "no-mention" }, "skipping room message");
return; return;
@@ -496,45 +486,47 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
OriginatingTo: `room:${roomId}`, OriginatingTo: `room:${roomId}`,
}); });
await core.channel.session.recordInboundSession({ void core.channel.session
storePath, .recordSessionMetaFromInbound({
sessionKey: ctxPayload.SessionKey ?? route.sessionKey, storePath,
ctx: ctxPayload, sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
updateLastRoute: isDirectMessage ctx: ctxPayload,
? { })
sessionKey: route.mainSessionKey, .catch((err) => {
channel: "matrix",
to: `room:${roomId}`,
accountId: route.accountId,
}
: undefined,
onRecordError: (err) => {
logger.warn( logger.warn(
{ error: String(err), storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey }, { error: String(err), storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey },
"failed updating session meta", "failed updating session meta",
); );
}, });
});
if (isDirectMessage) {
await core.channel.session.updateLastRoute({
storePath,
sessionKey: route.mainSessionKey,
channel: "matrix",
to: `room:${roomId}`,
accountId: route.accountId,
ctx: ctxPayload,
});
}
const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n"); const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n");
logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`); logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions"; const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions";
const shouldAckReaction = () => const shouldAckReaction = () => {
Boolean( if (!ackReaction) return false;
ackReaction && if (ackScope === "all") return true;
core.channel.reactions.shouldAckReaction({ if (ackScope === "direct") return isDirectMessage;
scope: ackScope, if (ackScope === "group-all") return isRoom;
isDirect: isDirectMessage, if (ackScope === "group-mentions") {
isGroup: isRoom, if (!isRoom) return false;
isMentionableGroup: isRoom, if (!shouldRequireMention) return false;
requireMention: Boolean(shouldRequireMention), return wasMentioned || shouldBypassMention;
canDetectMention, }
effectiveWasMentioned: wasMentioned || shouldBypassMention, return false;
shouldBypassMention, };
}),
);
if (shouldAckReaction() && messageId) { if (shouldAckReaction() && messageId) {
reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => { reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => {
logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`); logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`);
@@ -556,38 +548,10 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
} }
let didSendReply = false; let didSendReply = false;
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg,
channel: "matrix",
accountId: route.accountId,
});
const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
const typingCallbacks = createTypingCallbacks({
start: () => sendTypingMatrix(roomId, true, undefined, client),
stop: () => sendTypingMatrix(roomId, false, undefined, client),
onStartError: (err) => {
logTypingFailure({
log: logVerboseMessage,
channel: "matrix",
action: "start",
target: roomId,
error: err,
});
},
onStopError: (err) => {
logTypingFailure({
log: logVerboseMessage,
channel: "matrix",
action: "stop",
target: roomId,
error: err,
});
},
});
const { dispatcher, replyOptions, markDispatchIdle } = const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({ core.channel.reply.createReplyDispatcherWithTyping({
responsePrefix: prefixContext.responsePrefix, responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId)
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, .responsePrefix,
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload) => { deliver: async (payload) => {
await deliverMatrixReplies({ await deliverMatrixReplies({
@@ -598,16 +562,16 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
textLimit, textLimit,
replyToMode, replyToMode,
threadId: threadTarget, threadId: threadTarget,
accountId: route.accountId,
tableMode,
}); });
didSendReply = true; didSendReply = true;
}, },
onError: (err, info) => { onError: (err, info) => {
runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`); runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`);
}, },
onReplyStart: typingCallbacks.onReplyStart, onReplyStart: () =>
onIdle: typingCallbacks.onIdle, sendTypingMatrix(roomId, true, undefined, client).catch(() => {}),
onIdle: () =>
sendTypingMatrix(roomId, false, undefined, client).catch(() => {}),
}); });
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({ const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
@@ -617,7 +581,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
replyOptions: { replyOptions: {
...replyOptions, ...replyOptions,
skillFilter: roomConfig?.skills, skillFilter: roomConfig?.skills,
onModelSelected: prefixContext.onModelSelected,
}, },
}); });
markDispatchIdle(); markDispatchIdle();
@@ -1,6 +1,6 @@
import type { MatrixClient } from "matrix-bot-sdk"; import type { MatrixClient } from "matrix-bot-sdk";
import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk"; import type { 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,17 +12,8 @@ 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);
@@ -42,8 +33,6 @@ 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
@@ -54,14 +43,13 @@ 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(text, chunkLimit)) { for (const chunk of core.channel.text.chunkMarkdownText(reply.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;
@@ -72,14 +60,13 @@ export async function deliverMatrixReplies(params: {
let first = true; let first = true;
for (const mediaUrl of mediaList) { for (const mediaUrl of mediaList) {
const caption = first ? text : ""; const caption = first ? (reply.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,8 +43,6 @@ 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;
+1 -10
View File
@@ -50,18 +50,9 @@ 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(convertedMessage, chunkLimit); const chunks = getCore().channel.text.chunkMarkdownText(trimmedMessage, 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,7 +87,6 @@ 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 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/mattermost", "name": "@clawdbot/mattermost",
"version": "2026.1.23", "version": "2026.1.20-2",
"type": "module", "type": "module",
"description": "Clawdbot Mattermost channel plugin", "description": "Clawdbot Mattermost channel plugin",
"clawdbot": { "clawdbot": {
@@ -4,7 +4,6 @@ import {
BlockStreamingCoalesceSchema, BlockStreamingCoalesceSchema,
DmPolicySchema, DmPolicySchema,
GroupPolicySchema, GroupPolicySchema,
MarkdownConfigSchema,
requireOpenAllowFrom, requireOpenAllowFrom,
} from "clawdbot/plugin-sdk"; } from "clawdbot/plugin-sdk";
@@ -12,7 +11,6 @@ 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(),
+54 -65
View File
@@ -7,15 +7,10 @@ import type {
RuntimeEnv, RuntimeEnv,
} from "clawdbot/plugin-sdk"; } from "clawdbot/plugin-sdk";
import { import {
createReplyPrefixContext,
createTypingCallbacks,
logInboundDrop,
logTypingFailure,
buildPendingHistoryContextFromMap, buildPendingHistoryContextFromMap,
clearHistoryEntriesIfEnabled, clearHistoryEntries,
DEFAULT_GROUP_HISTORY_LIMIT, DEFAULT_GROUP_HISTORY_LIMIT,
recordPendingHistoryEntryIfEnabled, recordPendingHistoryEntry,
resolveControlCommandGate,
resolveChannelMediaMaxBytes, resolveChannelMediaMaxBytes,
type HistoryEntry, type HistoryEntry,
} from "clawdbot/plugin-sdk"; } from "clawdbot/plugin-sdk";
@@ -35,9 +30,12 @@ import {
} from "./client.js"; } from "./client.js";
import { import {
createDedupeCache, createDedupeCache,
extractShortModelName,
formatInboundFromLabel, formatInboundFromLabel,
rawDataToString, rawDataToString,
resolveIdentityName,
resolveThreadSessionKeys, resolveThreadSessionKeys,
type ResponsePrefixContext,
} from "./monitor-helpers.js"; } from "./monitor-helpers.js";
import { sendMessageMattermost } from "./send.js"; import { sendMessageMattermost } from "./send.js";
@@ -309,7 +307,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
}; };
const sendTypingIndicator = async (channelId: string, parentId?: string) => { const sendTypingIndicator = async (channelId: string, parentId?: string) => {
await sendMattermostTyping(client, { channelId, parentId }); try {
await sendMattermostTyping(client, { channelId, parentId });
} catch (err) {
logger.debug?.(`mattermost typing cue failed for channel ${channelId}: ${String(err)}`);
}
}; };
const resolveChannelInfo = async (channelId: string): Promise<MattermostChannel | null> => { const resolveChannelInfo = async (channelId: string): Promise<MattermostChannel | null> => {
@@ -401,8 +403,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
cfg, cfg,
surface: "mattermost", surface: "mattermost",
}); });
const hasControlCommand = core.channel.text.hasControlCommand(rawText, cfg); const isControlCommand = allowTextCommands && core.channel.text.hasControlCommand(rawText, cfg);
const isControlCommand = allowTextCommands && hasControlCommand;
const useAccessGroups = cfg.commands?.useAccessGroups !== false; const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const senderAllowedForCommands = isSenderAllowed({ const senderAllowedForCommands = isSenderAllowed({
senderId, senderId,
@@ -414,20 +415,19 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
senderName, senderName,
allowFrom: effectiveGroupAllowFrom, allowFrom: effectiveGroupAllowFrom,
}); });
const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
{
configured: effectiveGroupAllowFrom.length > 0,
allowed: groupAllowedForCommands,
},
],
allowTextCommands,
hasControlCommand,
});
const commandAuthorized = const commandAuthorized =
kind === "dm" ? dmPolicy === "open" || senderAllowedForCommands : commandGate.commandAuthorized; kind === "dm"
? dmPolicy === "open" || senderAllowedForCommands
: core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
{
configured: effectiveGroupAllowFrom.length > 0,
allowed: groupAllowedForCommands,
},
],
});
if (kind === "dm") { if (kind === "dm") {
if (dmPolicy === "disabled") { if (dmPolicy === "disabled") {
@@ -488,13 +488,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
} }
} }
if (kind !== "dm" && commandGate.shouldBlock) { if (kind !== "dm" && isControlCommand && !commandAuthorized) {
logInboundDrop({ logVerboseMessage(
log: logVerboseMessage, `mattermost: drop control command from unauthorized sender ${senderId}`,
channel: "mattermost", );
reason: "control command (unauthorized)",
target: senderId,
});
return; return;
} }
@@ -537,19 +534,19 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
: ""); : "");
const pendingSender = senderName; const pendingSender = senderName;
const recordPendingHistory = () => { const recordPendingHistory = () => {
if (!historyKey || historyLimit <= 0) return;
const trimmed = pendingBody.trim(); const trimmed = pendingBody.trim();
recordPendingHistoryEntryIfEnabled({ if (!trimmed) return;
recordPendingHistoryEntry({
historyMap: channelHistories, historyMap: channelHistories,
historyKey,
limit: historyLimit, limit: historyLimit,
historyKey: historyKey ?? "", entry: {
entry: historyKey && trimmed sender: pendingSender,
? { body: trimmed,
sender: pendingSender, timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
body: trimmed, messageId: post.id ?? undefined,
timestamp: typeof post.create_at === "number" ? post.create_at : undefined, },
messageId: post.id ?? undefined,
}
: null,
}); });
}; };
@@ -626,7 +623,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
sender: { name: senderName, id: senderId }, sender: { name: senderName, id: senderId },
}); });
let combinedBody = body; let combinedBody = body;
if (historyKey) { if (historyKey && historyLimit > 0) {
combinedBody = buildPendingHistoryContextFromMap({ combinedBody = buildPendingHistoryContextFromMap({
historyMap: channelHistories, historyMap: channelHistories,
historyKey, historyKey,
@@ -710,33 +707,20 @@ 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,
});
const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId }); let prefixContext: ResponsePrefixContext = {
identityName: resolveIdentityName(cfg, route.agentId),
};
const typingCallbacks = createTypingCallbacks({
start: () => sendTypingIndicator(channelId, threadRootId),
onStartError: (err) => {
logTypingFailure({
log: (message) => logger.debug?.(message),
channel: "mattermost",
target: channelId,
error: err,
});
},
});
const { dispatcher, replyOptions, markDispatchIdle } = const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({ core.channel.reply.createReplyDispatcherWithTyping({
responsePrefix: prefixContext.responsePrefix, responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId)
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, .responsePrefix,
responsePrefixContextProvider: () => prefixContext,
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 = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); const text = payload.text ?? "";
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]) {
@@ -763,7 +747,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
onError: (err, info) => { onError: (err, info) => {
runtime.error?.(`mattermost ${info.kind} reply failed: ${String(err)}`); runtime.error?.(`mattermost ${info.kind} reply failed: ${String(err)}`);
}, },
onReplyStart: typingCallbacks.onReplyStart, onReplyStart: () => sendTypingIndicator(channelId, threadRootId),
}); });
await core.channel.reply.dispatchReplyFromConfig({ await core.channel.reply.dispatchReplyFromConfig({
@@ -774,12 +758,17 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
...replyOptions, ...replyOptions,
disableBlockStreaming: disableBlockStreaming:
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined, typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
onModelSelected: prefixContext.onModelSelected, onModelSelected: (ctx) => {
prefixContext.provider = ctx.provider;
prefixContext.model = extractShortModelName(ctx.model);
prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
},
}, },
}); });
markDispatchIdle(); markDispatchIdle();
if (historyKey) { if (historyKey && historyLimit > 0) {
clearHistoryEntriesIfEnabled({ historyMap: channelHistories, historyKey, limit: historyLimit }); clearHistoryEntries({ historyMap: channelHistories, historyKey });
} }
}; };
+1 -10
View File
@@ -181,15 +181,6 @@ 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}`);
@@ -214,4 +205,4 @@ export async function sendMessageMattermost(
messageId: post.id ?? "unknown", messageId: post.id ?? "unknown",
channelId, channelId,
}; };
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/memory-core", "name": "@clawdbot/memory-core",
"version": "2026.1.23", "version": "2026.1.22",
"type": "module", "type": "module",
"description": "Clawdbot core memory search plugin", "description": "Clawdbot core memory search plugin",
"clawdbot": { "clawdbot": {
+6 -8
View File
@@ -14,12 +14,11 @@ import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import os from "node:os"; import os from "node:os";
const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? "test-key"; // Skip if no OpenAI API key
const HAS_OPENAI_KEY = Boolean(process.env.OPENAI_API_KEY); const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
const liveEnabled = HAS_OPENAI_KEY && process.env.CLAWDBOT_LIVE_TEST === "1"; const describeWithKey = OPENAI_API_KEY ? describe : describe.skip;
const describeLive = liveEnabled ? describe : describe.skip;
describe("memory plugin e2e", () => { describeWithKey("memory plugin e2e", () => {
let tmpDir: string; let tmpDir: string;
let dbPath: string; let dbPath: string;
@@ -160,7 +159,7 @@ describe("memory plugin e2e", () => {
}); });
// Live tests that require OpenAI API key and actually use LanceDB // Live tests that require OpenAI API key and actually use LanceDB
describeLive("memory plugin live tests", () => { describeWithKey("memory plugin live tests", () => {
let tmpDir: string; let tmpDir: string;
let dbPath: string; let dbPath: string;
@@ -177,7 +176,6 @@ describeLive("memory plugin live tests", () => {
test("memory tools work end-to-end", async () => { test("memory tools work end-to-end", async () => {
const { default: memoryPlugin } = await import("./index.js"); const { default: memoryPlugin } = await import("./index.js");
const liveApiKey = process.env.OPENAI_API_KEY ?? "";
// Mock plugin API // Mock plugin API
const registeredTools: any[] = []; const registeredTools: any[] = [];
@@ -193,7 +191,7 @@ describeLive("memory plugin live tests", () => {
config: {}, config: {},
pluginConfig: { pluginConfig: {
embedding: { embedding: {
apiKey: liveApiKey, apiKey: OPENAI_API_KEY,
model: "text-embedding-3-small", model: "text-embedding-3-small",
}, },
dbPath, dbPath,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/memory-lancedb", "name": "@clawdbot/memory-lancedb",
"version": "2026.1.23", "version": "2026.1.22",
"type": "module", "type": "module",
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture", "description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
"dependencies": { "dependencies": {
-5
View File
@@ -1,10 +1,5 @@
# Changelog # Changelog
## 2026.1.23
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.22 ## 2026.1.22
### Changes ### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/msteams", "name": "@clawdbot/msteams",
"version": "2026.1.23", "version": "2026.1.22",
"type": "module", "type": "module",
"description": "Clawdbot Microsoft Teams channel plugin", "description": "Clawdbot Microsoft Teams channel plugin",
"clawdbot": { "clawdbot": {
@@ -68,10 +68,10 @@ function scopeCandidatesForUrl(url: string): string[] {
host.endsWith("1drv.ms") || host.endsWith("1drv.ms") ||
host.includes("sharepoint"); host.includes("sharepoint");
return looksLikeGraph return looksLikeGraph
? ["https://graph.microsoft.com", "https://api.botframework.com"] ? ["https://graph.microsoft.com/.default", "https://api.botframework.com/.default"]
: ["https://api.botframework.com", "https://graph.microsoft.com"]; : ["https://api.botframework.com/.default", "https://graph.microsoft.com/.default"];
} catch { } catch {
return ["https://api.botframework.com", "https://graph.microsoft.com"]; return ["https://api.botframework.com/.default", "https://graph.microsoft.com/.default"];
} }
} }
+1 -1
View File
@@ -198,7 +198,7 @@ export async function downloadMSTeamsGraphMedia(params: {
const messageUrl = params.messageUrl; const messageUrl = params.messageUrl;
let accessToken: string; let accessToken: string;
try { try {
accessToken = await params.tokenProvider.getAccessToken("https://graph.microsoft.com"); accessToken = await params.tokenProvider.getAccessToken("https://graph.microsoft.com/.default");
} catch { } catch {
return { media: [], messageUrl, tokenError: true }; return { media: [], messageUrl, tokenError: true };
} }
+1 -1
View File
@@ -64,7 +64,7 @@ async function resolveGraphToken(cfg: unknown): Promise<string> {
if (!creds) throw new Error("MS Teams credentials missing"); if (!creds) throw new Error("MS Teams credentials missing");
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds); const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
const tokenProvider = new sdk.MsalTokenProvider(authConfig); const tokenProvider = new sdk.MsalTokenProvider(authConfig);
const token = await tokenProvider.getAccessToken("https://graph.microsoft.com"); const token = await tokenProvider.getAccessToken("https://graph.microsoft.com/.default");
const accessToken = readAccessToken(token); const accessToken = readAccessToken(token);
if (!accessToken) throw new Error("MS Teams graph token unavailable"); if (!accessToken) throw new Error("MS Teams graph token unavailable");
return accessToken; return accessToken;
+1 -1
View File
@@ -13,7 +13,7 @@ import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
const GRAPH_ROOT = "https://graph.microsoft.com/v1.0"; const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
const GRAPH_BETA = "https://graph.microsoft.com/beta"; const GRAPH_BETA = "https://graph.microsoft.com/beta";
const GRAPH_SCOPE = "https://graph.microsoft.com"; const GRAPH_SCOPE = "https://graph.microsoft.com/.default";
export interface OneDriveUploadResult { export interface OneDriveUploadResult {
id: string; id: string;
+3 -7
View File
@@ -21,8 +21,6 @@ const runtimeStub = {
} }
return chunks; return chunks;
}, },
resolveMarkdownTableMode: () => "code",
convertMarkdownTables: (text: string) => text,
}, },
}, },
} as unknown as PluginRuntime; } as unknown as PluginRuntime;
@@ -36,7 +34,6 @@ 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([]);
}); });
@@ -44,7 +41,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, tableMode: "code" }, { textChunkLimit: 4000 },
); );
expect(messages).toEqual([]); expect(messages).toEqual([]);
}); });
@@ -52,7 +49,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, tableMode: "code" }, { textChunkLimit: 4000 },
); );
expect(messages).toEqual([{ text: "hi" }, { mediaUrl: "https://example.com/a.png" }]); expect(messages).toEqual([{ text: "hi" }, { mediaUrl: "https://example.com/a.png" }]);
}); });
@@ -60,7 +57,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", tableMode: "code" }, { textChunkLimit: 4000, mediaMode: "inline" },
); );
expect(messages).toEqual([{ text: "hi", mediaUrl: "https://example.com/a.png" }]); expect(messages).toEqual([{ text: "hi", mediaUrl: "https://example.com/a.png" }]);
}); });
@@ -69,7 +66,6 @@ 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 -12
View File
@@ -1,7 +1,6 @@
import { import {
isSilentReplyText, isSilentReplyText,
loadWebMedia, loadWebMedia,
type MarkdownTableMode,
type MSTeamsReplyStyle, type MSTeamsReplyStyle,
type ReplyPayload, type ReplyPayload,
SILENT_REPLY_TOKEN, SILENT_REPLY_TOKEN,
@@ -62,7 +61,6 @@ export type MSTeamsReplyRenderOptions = {
textChunkLimit: number; textChunkLimit: number;
chunkText?: boolean; chunkText?: boolean;
mediaMode?: "split" | "inline"; mediaMode?: "split" | "inline";
tableMode?: MarkdownTableMode;
}; };
/** /**
@@ -198,19 +196,10 @@ 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 = getMSTeamsRuntime().channel.text.convertMarkdownTables( const text = payload.text ?? "";
payload.text ?? "",
tableMode,
);
if (!text && mediaList.length === 0) continue; if (!text && mediaList.length === 0) continue;
@@ -1,10 +1,8 @@
import { import {
buildPendingHistoryContextFromMap, buildPendingHistoryContextFromMap,
clearHistoryEntriesIfEnabled, clearHistoryEntries,
DEFAULT_GROUP_HISTORY_LIMIT, DEFAULT_GROUP_HISTORY_LIMIT,
logInboundDrop, recordPendingHistoryEntry,
recordPendingHistoryEntryIfEnabled,
resolveControlCommandGate,
resolveMentionGating, resolveMentionGating,
formatAllowlistMatchMeta, formatAllowlistMatchMeta,
type HistoryEntry, type HistoryEntry,
@@ -253,24 +251,15 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
senderId, senderId,
senderName, senderName,
}); });
const hasControlCommandInMessage = core.channel.text.hasControlCommand(text, cfg); const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
const commandGate = resolveControlCommandGate({
useAccessGroups, useAccessGroups,
authorizers: [ authorizers: [
{ configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands }, { configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
], ],
allowTextCommands: true,
hasControlCommand: hasControlCommandInMessage,
}); });
const commandAuthorized = commandGate.commandAuthorized; if (core.channel.text.hasControlCommand(text, cfg) && !commandAuthorized) {
if (commandGate.shouldBlock) { logVerboseMessage(`msteams: drop control command from unauthorized sender ${senderId}`);
logInboundDrop({
log: logVerboseMessage,
channel: "msteams",
reason: "control command (unauthorized)",
target: senderId,
});
return; return;
} }
@@ -382,17 +371,19 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
requireMention, requireMention,
mentioned, mentioned,
}); });
recordPendingHistoryEntryIfEnabled({ if (historyLimit > 0) {
historyMap: conversationHistories, recordPendingHistoryEntry({
historyKey: conversationId, historyMap: conversationHistories,
limit: historyLimit, historyKey: conversationId,
entry: { limit: historyLimit,
sender: senderName, entry: {
body: rawBody, sender: senderName,
timestamp: timestamp?.getTime(), body: rawBody,
messageId: activity.id ?? undefined, timestamp: timestamp?.getTime(),
}, messageId: activity.id ?? undefined,
}); },
});
}
return; return;
} }
} }
@@ -435,7 +426,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
let combinedBody = body; let combinedBody = body;
const isRoomish = !isDirectMessage; const isRoomish = !isDirectMessage;
const historyKey = isRoomish ? conversationId : undefined; const historyKey = isRoomish ? conversationId : undefined;
if (isRoomish && historyKey) { if (isRoomish && historyKey && historyLimit > 0) {
combinedBody = buildPendingHistoryContextFromMap({ combinedBody = buildPendingHistoryContextFromMap({
historyMap: conversationHistories, historyMap: conversationHistories,
historyKey, historyKey,
@@ -476,13 +467,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
...mediaPayload, ...mediaPayload,
}); });
await core.channel.session.recordInboundSession({ void core.channel.session.recordSessionMetaFromInbound({
storePath, storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey, sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload, ctx: ctxPayload,
onRecordError: (err) => { }).catch((err) => {
logVerboseMessage(`msteams: failed updating session meta: ${String(err)}`); logVerboseMessage(`msteams: failed updating session meta: ${String(err)}`);
},
}); });
logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`); logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
@@ -522,11 +512,10 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const didSendReply = counts.final + counts.tool + counts.block > 0; const didSendReply = counts.final + counts.tool + counts.block > 0;
if (!queuedFinal) { if (!queuedFinal) {
if (isRoomish && historyKey) { if (isRoomish && historyKey && historyLimit > 0) {
clearHistoryEntriesIfEnabled({ clearHistoryEntries({
historyMap: conversationHistories, historyMap: conversationHistories,
historyKey, historyKey,
limit: historyLimit,
}); });
} }
return; return;
@@ -535,12 +524,8 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
logVerboseMessage( logVerboseMessage(
`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`, `msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
); );
if (isRoomish && historyKey) { if (isRoomish && historyKey && historyLimit > 0) {
clearHistoryEntriesIfEnabled({ clearHistoryEntries({ historyMap: conversationHistories, historyKey });
historyMap: conversationHistories,
historyKey,
limit: historyLimit,
});
} }
} catch (err) { } catch (err) {
log.error("dispatch failed", { error: String(err) }); log.error("dispatch failed", { error: String(err) });
+29 -53
View File
@@ -1,7 +1,4 @@
import { import {
createReplyPrefixContext,
createTypingCallbacks,
logTypingFailure,
resolveChannelMediaMaxBytes, resolveChannelMediaMaxBytes,
type ClawdbotConfig, type ClawdbotConfig,
type MSTeamsReplyStyle, type MSTeamsReplyStyle,
@@ -42,39 +39,24 @@ export function createMSTeamsReplyDispatcher(params: {
}) { }) {
const core = getMSTeamsRuntime(); const core = getMSTeamsRuntime();
const sendTypingIndicator = async () => { const sendTypingIndicator = async () => {
await params.context.sendActivities([{ type: "typing" }]); try {
await params.context.sendActivities([{ type: "typing" }]);
} catch {
// Typing indicator is best-effort.
}
}; };
const typingCallbacks = createTypingCallbacks({
start: sendTypingIndicator,
onStartError: (err) => {
logTypingFailure({
log: (message) => params.log.debug(message),
channel: "msteams",
action: "start",
error: err,
});
},
});
const prefixContext = createReplyPrefixContext({
cfg: params.cfg,
agentId: params.agentId,
});
const { dispatcher, replyOptions, markDispatchIdle } = return core.channel.reply.createReplyDispatcherWithTyping({
core.channel.reply.createReplyDispatcherWithTyping({ responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(
responsePrefix: prefixContext.responsePrefix, params.cfg,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, params.agentId,
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId), ).responsePrefix,
deliver: async (payload) => { humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
const tableMode = core.channel.text.resolveMarkdownTableMode({ deliver: async (payload) => {
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,
@@ -100,27 +82,21 @@ export function createMSTeamsReplyDispatcher(params: {
mediaMaxBytes, mediaMaxBytes,
}); });
if (ids.length > 0) params.onSentMessageIds?.(ids); if (ids.length > 0) params.onSentMessageIds?.(ids);
}, },
onError: (err, info) => { onError: (err, info) => {
const errMsg = formatUnknownError(err); const errMsg = formatUnknownError(err);
const classification = classifyMSTeamsSendError(err); const classification = classifyMSTeamsSendError(err);
const hint = formatMSTeamsSendErrorHint(classification); const hint = formatMSTeamsSendErrorHint(classification);
params.runtime.error?.( params.runtime.error?.(
`msteams ${info.kind} reply failed: ${errMsg}${hint ? ` (${hint})` : ""}`, `msteams ${info.kind} reply failed: ${errMsg}${hint ? ` (${hint})` : ""}`,
); );
params.log.error("reply failed", { params.log.error("reply failed", {
kind: info.kind, kind: info.kind,
error: errMsg, error: errMsg,
classification, classification,
hint, hint,
}); });
}, },
onReplyStart: typingCallbacks.onReplyStart, onReplyStart: sendTypingIndicator,
}); });
return {
dispatcher,
replyOptions: { ...replyOptions, onModelSelected: prefixContext.onModelSelected },
markDispatchIdle,
};
} }
+1 -1
View File
@@ -143,7 +143,7 @@ async function resolveGraphToken(cfg: unknown): Promise<string> {
if (!creds) throw new Error("MS Teams credentials missing"); if (!creds) throw new Error("MS Teams credentials missing");
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds); const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
const tokenProvider = new sdk.MsalTokenProvider(authConfig); const tokenProvider = new sdk.MsalTokenProvider(authConfig);
const token = await tokenProvider.getAccessToken("https://graph.microsoft.com"); const token = await tokenProvider.getAccessToken("https://graph.microsoft.com/.default");
const accessToken = readAccessToken(token); const accessToken = readAccessToken(token);
if (!accessToken) throw new Error("MS Teams graph token unavailable"); if (!accessToken) throw new Error("MS Teams graph token unavailable");
return accessToken; return accessToken;
+7 -16
View File
@@ -16,7 +16,6 @@ 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 = {
@@ -94,21 +93,13 @@ 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: messageText.length, textLength: text.length,
hasMedia: Boolean(mediaUrl), hasMedia: Boolean(mediaUrl),
}); });
@@ -143,7 +134,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: messageText || undefined, description: text || 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 });
@@ -181,14 +172,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, messageText, finalMediaUrl); return sendTextWithMedia(ctx, text, 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, messageText, finalMediaUrl); return sendTextWithMedia(ctx, text, 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
@@ -232,7 +223,7 @@ export async function sendMessageMSTeams(
const fileCardAttachment = buildTeamsFileInfoCard(driveItem); const fileCardAttachment = buildTeamsFileInfoCard(driveItem);
const activity = { const activity = {
type: "message", type: "message",
text: messageText || undefined, text: text || undefined,
attachments: [fileCardAttachment], attachments: [fileCardAttachment],
}; };
@@ -273,7 +264,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: messageText ? `${messageText}\n\n${fileLink}` : fileLink, text: text ? `${text}\n\n${fileLink}` : fileLink,
}; };
const baseRef = buildConversationReference(ref); const baseRef = buildConversationReference(ref);
@@ -299,7 +290,7 @@ export async function sendMessageMSTeams(
} }
// No media: send text only // No media: send text only
return sendTextWithMedia(ctx, messageText, undefined); return sendTextWithMedia(ctx, text, undefined);
} }
/** /**
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/nextcloud-talk", "name": "@clawdbot/nextcloud-talk",
"version": "2026.1.23", "version": "2026.1.22",
"type": "module", "type": "module",
"description": "Clawdbot Nextcloud Talk channel plugin", "description": "Clawdbot Nextcloud Talk channel plugin",
"clawdbot": { "clawdbot": {
@@ -3,7 +3,6 @@ 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";
@@ -22,7 +21,6 @@ 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(),
+23 -28
View File
@@ -1,9 +1,4 @@
import { import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
logInboundDrop,
resolveControlCommandGate,
type ClawdbotConfig,
type RuntimeEnv,
} from "clawdbot/plugin-sdk";
import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
import { import {
@@ -123,11 +118,7 @@ export async function handleNextcloudTalkInbound(params: {
senderId, senderId,
senderName, senderName,
}).allowed; }).allowed;
const hasControlCommand = core.channel.text.hasControlCommand( const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
rawBody,
config as ClawdbotConfig,
);
const commandGate = resolveControlCommandGate({
useAccessGroups, useAccessGroups,
authorizers: [ authorizers: [
{ {
@@ -136,10 +127,7 @@ export async function handleNextcloudTalkInbound(params: {
allowed: senderAllowedForCommands, allowed: senderAllowedForCommands,
}, },
], ],
allowTextCommands,
hasControlCommand,
}); });
const commandAuthorized = commandGate.commandAuthorized;
if (isGroup) { if (isGroup) {
const groupAllow = resolveNextcloudTalkGroupAllow({ const groupAllow = resolveNextcloudTalkGroupAllow({
@@ -200,13 +188,15 @@ export async function handleNextcloudTalkInbound(params: {
} }
} }
if (isGroup && commandGate.shouldBlock) { if (
logInboundDrop({ isGroup &&
log: (message) => runtime.log?.(message), allowTextCommands &&
channel: CHANNEL_ID, core.channel.text.hasControlCommand(rawBody, config as ClawdbotConfig) &&
reason: "control command (unauthorized)", commandAuthorized !== true
target: senderId, ) {
}); runtime.log?.(
`nextcloud-talk: drop control command from unauthorized sender ${senderId}`,
);
return; return;
} }
@@ -222,6 +212,10 @@ export async function handleNextcloudTalkInbound(params: {
wildcardConfig: roomMatch.wildcardConfig, wildcardConfig: roomMatch.wildcardConfig,
}) })
: false; : false;
const hasControlCommand = core.channel.text.hasControlCommand(
rawBody,
config as ClawdbotConfig,
);
const mentionGate = resolveNextcloudTalkMentionGate({ const mentionGate = resolveNextcloudTalkMentionGate({
isGroup, isGroup,
requireMention: shouldRequireMention, requireMention: shouldRequireMention,
@@ -293,14 +287,15 @@ export async function handleNextcloudTalkInbound(params: {
CommandAuthorized: commandAuthorized, CommandAuthorized: commandAuthorized,
}); });
await core.channel.session.recordInboundSession({ void core.channel.session
storePath, .recordSessionMetaFromInbound({
sessionKey: ctxPayload.SessionKey ?? route.sessionKey, storePath,
ctx: ctxPayload, sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
onRecordError: (err) => { ctx: ctxPayload,
})
.catch((err) => {
runtime.error?.(`nextcloud-talk: failed updating session meta: ${String(err)}`); runtime.error?.(`nextcloud-talk: failed updating session meta: ${String(err)}`);
}, });
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload, ctx: ctxPayload,
+1 -11
View File
@@ -71,18 +71,8 @@ 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, message: text.trim(),
}; };
if (opts.replyTo) { if (opts.replyTo) {
body.replyTo = opts.replyTo; body.replyTo = opts.replyTo;
-5
View File
@@ -1,10 +1,5 @@
# Changelog # Changelog
## 2026.1.23
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.22 ## 2026.1.22
### Changes ### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/nostr", "name": "@clawdbot/nostr",
"version": "2026.1.23", "version": "2026.1.22",
"type": "module", "type": "module",
"description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs", "description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs",
"clawdbot": { "clawdbot": {
+1 -8
View File
@@ -133,20 +133,13 @@ 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, message); await bus.sendDm(normalizedTo, text);
return { channel: "nostr", to: normalizedTo }; return { channel: "nostr", to: normalizedTo };
}, },
}, },
+1 -4
View File
@@ -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,9 +63,6 @@ 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 -3
View File
@@ -4,8 +4,6 @@
"type": "module", "type": "module",
"description": "OpenProse VM skill pack plugin (slash command + telemetry).", "description": "OpenProse VM skill pack plugin (slash command + telemetry).",
"clawdbot": { "clawdbot": {
"extensions": [ "extensions": ["./index.ts"]
"./index.ts"
]
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/signal", "name": "@clawdbot/signal",
"version": "2026.1.23", "version": "2026.1.22",
"type": "module", "type": "module",
"description": "Clawdbot Signal channel plugin", "description": "Clawdbot Signal channel plugin",
"clawdbot": { "clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/slack", "name": "@clawdbot/slack",
"version": "2026.1.23", "version": "2026.1.22",
"type": "module", "type": "module",
"description": "Clawdbot Slack channel plugin", "description": "Clawdbot Slack channel plugin",
"clawdbot": { "clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/telegram", "name": "@clawdbot/telegram",
"version": "2026.1.23", "version": "2026.1.22",
"type": "module", "type": "module",
"description": "Clawdbot Telegram channel plugin", "description": "Clawdbot Telegram channel plugin",
"clawdbot": { "clawdbot": {
-5
View File
@@ -1,10 +1,5 @@
# Changelog # Changelog
## 2026.1.23
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.22 ## 2026.1.22
### Changes ### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/voice-call", "name": "@clawdbot/voice-call",
"version": "2026.1.23", "version": "2026.1.22",
"type": "module", "type": "module",
"description": "Clawdbot voice-call plugin", "description": "Clawdbot voice-call plugin",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/whatsapp", "name": "@clawdbot/whatsapp",
"version": "2026.1.23", "version": "2026.1.22",
"type": "module", "type": "module",
"description": "Clawdbot WhatsApp channel plugin", "description": "Clawdbot WhatsApp channel plugin",
"clawdbot": { "clawdbot": {
-5
View File
@@ -1,10 +1,5 @@
# Changelog # Changelog
## 2026.1.23
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.22 ## 2026.1.22
### Changes ### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/zalo", "name": "@clawdbot/zalo",
"version": "2026.1.23", "version": "2026.1.22",
"type": "module", "type": "module",
"description": "Clawdbot Zalo channel plugin", "description": "Clawdbot Zalo channel plugin",
"clawdbot": { "clawdbot": {
-2
View File
@@ -1,4 +1,3 @@
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()]);
@@ -6,7 +5,6 @@ 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(),
+7 -18
View File
@@ -1,6 +1,6 @@
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import type { ClawdbotConfig, MarkdownTableMode } from "clawdbot/plugin-sdk"; import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import type { ResolvedZaloAccount } from "./accounts.js"; import type { ResolvedZaloAccount } from "./accounts.js";
import { import {
@@ -570,19 +570,12 @@ async function processMessageWithPipeline(params: {
OriginatingTo: `zalo:${chatId}`, OriginatingTo: `zalo:${chatId}`,
}); });
await core.channel.session.recordInboundSession({ void core.channel.session.recordSessionMetaFromInbound({
storePath, storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey, sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload, ctx: ctxPayload,
onRecordError: (err) => { }).catch((err) => {
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({
@@ -598,7 +591,6 @@ async function processMessageWithPipeline(params: {
core, core,
statusSink, statusSink,
fetcher, fetcher,
tableMode,
}); });
}, },
onError: (err, info) => { onError: (err, info) => {
@@ -616,11 +608,8 @@ 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
@@ -631,7 +620,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 ? text : undefined; const caption = first ? payload.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);
@@ -643,8 +632,8 @@ async function deliverZaloReply(params: {
return; return;
} }
if (text) { if (payload.text) {
const chunks = core.channel.text.chunkMarkdownText(text, ZALO_TEXT_LIMIT); const chunks = core.channel.text.chunkMarkdownText(payload.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);
Loaded 100 of 451 files, more files were not shown because too many files have changed in this diff. Show more