Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43b55a8c3a | ||
|
|
fc6a35797a |
No files matched your search
+36
-60
@@ -2,77 +2,55 @@
|
|||||||
|
|
||||||
Docs: https://docs.clawd.bot
|
Docs: https://docs.clawd.bot
|
||||||
|
|
||||||
## 2026.1.22 (Unreleased)
|
## 2026.1.22
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
|
||||||
|
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
||||||
|
- 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.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
|
- Config: avoid stack traces for invalid configs and log the config path.
|
||||||
- Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.
|
- Doctor: warn when gateway.mode is unset with configure/config guidance.
|
||||||
- Node: launch node host service with `node run` (not `node start`) and align node host hints/docs. (#1461) Thanks @ameno-.
|
- macOS: include Textual syntax highlighting resources in packaged app to prevent chat crashes. (#1362)
|
||||||
|
- Exec approvals: send optional approval params as undefined instead of null. (#1414) Thanks @czekaj.
|
||||||
## 2026.1.21-2
|
- UI: refresh debug panel on route-driven tab changes. (#1373) Thanks @yazinsai.
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Control UI: ignore bootstrap identity placeholder text for avatar values and fall back to the default avatar. https://docs.clawd.bot/cli/agents https://docs.clawd.bot/web/control-ui
|
|
||||||
|
|
||||||
## 2026.1.21
|
## 2026.1.21
|
||||||
|
|
||||||
### Highlights
|
|
||||||
- Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
|
|
||||||
- Custom assistant identity + avatars in the Control UI. https://docs.clawd.bot/cli/agents https://docs.clawd.bot/web/control-ui
|
|
||||||
- Cache optimizations: cache-ttl pruning + defaults reduce token spend on cold requests. https://docs.clawd.bot/concepts/session-pruning
|
|
||||||
- Exec approvals + elevated ask/full modes. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/elevated
|
|
||||||
- Signal typing/read receipts + MSTeams attachments. https://docs.clawd.bot/channels/signal https://docs.clawd.bot/channels/msteams
|
|
||||||
- `/models` UX refresh + `clawdbot update wizard`. https://docs.clawd.bot/cli/models https://docs.clawd.bot/cli/update
|
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster (#1152) Thanks @vignesh07.
|
- Heartbeat: allow running heartbeats in an explicit session key. (#1256) Thanks @zknicker.
|
||||||
- Agents/UI: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer. https://docs.clawd.bot/gateway/configuration https://docs.clawd.bot/cli/agents
|
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
|
||||||
- Control UI: add custom assistant identity support and per-session identity display. (#1420) Thanks @robbyczgw-cla. https://docs.clawd.bot/web/control-ui
|
- CLI: exec approvals mutations render tables instead of raw JSON.
|
||||||
- CLI: add `clawdbot update wizard` with interactive channel selection + restart prompts, plus preflight checks before rebasing. https://docs.clawd.bot/cli/update
|
- Exec approvals: support wildcard agent allowlists (`*`) across all agents.
|
||||||
- Models/Commands: add `/models`, improve `/model` listing UX, and expand `clawdbot models` paging. (#1398) Thanks @vignesh07. https://docs.clawd.bot/cli/models
|
- Exec approvals: allowlist matches resolved binary paths only, add safe stdin-only bins, and tighten allowlist shell parsing.
|
||||||
- CLI: move gateway service commands under `clawdbot gateway`, flatten node service commands under `clawdbot node`, and add `gateway probe` for reachability. https://docs.clawd.bot/cli/gateway https://docs.clawd.bot/cli/node
|
- Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution.
|
||||||
- Exec: add elevated ask/full modes, tighten allowlist gating, and render approvals tables on write. https://docs.clawd.bot/tools/elevated https://docs.clawd.bot/tools/exec-approvals
|
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs.
|
||||||
- Exec approvals: default to local host, add gateway/node targeting + target details, support wildcard agent allowlists, and tighten allowlist parsing/safe bins. https://docs.clawd.bot/cli/approvals https://docs.clawd.bot/tools/exec-approvals
|
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability.
|
||||||
- Heartbeat: allow explicit session keys and active hours. (#1256) Thanks @zknicker. https://docs.clawd.bot/gateway/heartbeat
|
- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot.
|
||||||
- Sessions: add per-channel idle durations via `sessions.channelIdleMinutes`. (#1353) Thanks @cash-echo-bot.
|
|
||||||
- Nodes: run exec-style, expose PATH in status/describe, and bootstrap PATH for node-host execution. https://docs.clawd.bot/cli/node
|
|
||||||
- Cache: add `cache.ttlPrune` mode and auth-aware defaults for cache TTL behavior.
|
|
||||||
- Queue: add per-channel debounce overrides for auto-reply. https://docs.clawd.bot/concepts/queue
|
|
||||||
- Discord: add wildcard channel config support. (#1334) Thanks @pvoo. https://docs.clawd.bot/channels/discord
|
|
||||||
- Signal: add typing indicators and DM read receipts via signal-cli. https://docs.clawd.bot/channels/signal
|
|
||||||
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero. https://docs.clawd.bot/channels/msteams
|
|
||||||
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
|
||||||
- macOS: refresh Settings (location access in Permissions, connection mode in menu, remove CLI install UI).
|
|
||||||
- Diagnostics: add cache trace config for debugging. (#1370) Thanks @parubets.
|
|
||||||
- Docs: Lobster guides + org URL updates, /model allowlist troubleshooting, Gmail message search examples, gateway.mode troubleshooting, prompt injection guidance, npm prefix/node CLI notes, control UI dev gatewayUrl note, tool_use FAQ, showcase video, and sharp/node-gyp workaround. (#1427, #1220, #1405) Thanks @vignesh07, @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
|
||||||
- Streaming/Typing/Media: keep reply tags across streamed chunks, start typing indicators at run start, and accept MEDIA paths with spaces/tilde while preferring the message tool hint for image replies.
|
|
||||||
- Agents/Providers: drop unsigned thinking blocks for Claude models (Google Antigravity) and enforce alphanumeric tool call ids for strict providers (Mistral/OpenRouter). (#1372) Thanks @zerone0x.
|
|
||||||
- Exec approvals: treat main as the default agent, align node/gateway allowlist prechecks, validate resolved paths, avoid allowlist resolve races, and avoid null optional params. (#1417, #1414, #1425) Thanks @czekaj.
|
|
||||||
- Exec/Windows: resolve Windows exec paths with extensions and handle safe-bin exe names.
|
|
||||||
- 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: prevent multiple gateways from sharing the same config/state (singleton lock), keep auto bind loopback-first with explicit tailnet binding, and improve SSH auth handling. (#1380)
|
- Gateway: keep auto bind loopback-first and add explicit tailnet binding to avoid Tailscale taking over local UI. (#1380)
|
||||||
- Control UI: remove the chat stop button, keep the composer aligned to the bottom edge, stabilize session previews, and refresh the debug panel on route-driven tab changes. (#1373) Thanks @yazinsai.
|
- Agents: enforce 9-char alphanumeric tool call ids for Mistral providers. (#1372) Thanks @zerone0x.
|
||||||
- UI/config: export `SECTION_META` for config form modules. (#1418) Thanks @MaudeBot.
|
|
||||||
- macOS: keep chat pinned during streaming replies, include Textual resources, respect wildcard exec approvals, allow SSH agent auth, and default distribution builds to universal binaries. (#1279, #1362, #1384, #1396) Thanks @ameno-, @JustYannicc.
|
|
||||||
- BlueBubbles: resolve short message IDs safely, expose full IDs in templates, and harden short-id fetch wrappers. (#1369, #1387) Thanks @tyler6204.
|
|
||||||
- Models/Configure: inherit session model overrides in threads/topics, map OpenCode Zen models to the correct APIs, narrow Anthropic OAuth allowlist handling, seed allowlist fallbacks, list the full catalog when no allowlist is set, and limit `/model` list output. (#1376, #1416)
|
|
||||||
- Memory: prevent CLI hangs by deferring vector probes, add sqlite-vec/embedding timeouts, and make session memory indexing async.
|
|
||||||
- Cron: cap reminder context history to 10 messages and honor `contextMessages`. (#1103) Thanks @mkbehr.
|
|
||||||
- Cache: restore the 1h cache TTL option and reset the pruning window.
|
|
||||||
- Zalo Personal: tolerate ANSI/log-prefixed JSON output from `zca`. (#1379) Thanks @ptn1411.
|
|
||||||
- Browser: suppress Chrome restore prompts for managed profiles. (#1419) Thanks @jamesgroat.
|
|
||||||
- Infra: preserve fetch helper methods/preconnect when wrapping abort signals and normalize Telegram fetch aborts.
|
|
||||||
- Config/Doctor: avoid stack traces for invalid configs, log the config path, avoid WhatsApp config resurrection, and warn when `gateway.mode` is unset. (#900)
|
|
||||||
- CLI: read Codex CLI account_id for workspace billing. (#1422) Thanks @aj47.
|
|
||||||
- Logs/Status: align rolling log filenames with local time and report sandboxed runtime in `clawdbot status`. (#1343)
|
|
||||||
- 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/Subagents: include agent/node/gateway context in tool failure logs and ensure subagent list uses the command session.
|
- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging.
|
||||||
|
- macOS: exec approvals now respect wildcard agent allowlists (`*`).
|
||||||
|
- macOS: allow SSH agent auth when no identity file is set. (#1384) Thanks @ameno-.
|
||||||
|
- Gateway: prevent multiple gateways from sharing the same config/state at once (singleton lock).
|
||||||
|
- UI: remove the chat stop button and keep the composer aligned to the bottom edge.
|
||||||
|
- Typing: start instant typing indicators at run start so DMs and mentions show immediately.
|
||||||
|
- Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5.
|
||||||
|
- Configure: seed model fallbacks from the allowlist selection when multiple models are chosen.
|
||||||
|
- Model picker: list the full catalog when no model allowlist is configured.
|
||||||
|
- Discord: honor wildcard channel configs via shared match helpers. (#1334) Thanks @pvoo.
|
||||||
|
- BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1387) Thanks @tyler6204.
|
||||||
|
- Infra: preserve fetch helper methods when wrapping abort signals. (#1387)
|
||||||
|
- macOS: default distribution packaging to universal binaries. (#1396) Thanks @JustYannicc.
|
||||||
|
|
||||||
## 2026.1.20
|
## 2026.1.20
|
||||||
|
|
||||||
@@ -107,7 +85,6 @@ Docs: https://docs.clawd.bot
|
|||||||
- Plugins: move channel catalog metadata into plugin manifests. (#1290) https://docs.clawd.bot/plugins/manifest
|
- Plugins: move channel catalog metadata into plugin manifests. (#1290) https://docs.clawd.bot/plugins/manifest
|
||||||
- Plugins: align Nextcloud Talk policy helpers with core patterns. (#1290) https://docs.clawd.bot/plugins/manifest
|
- Plugins: align Nextcloud Talk policy helpers with core patterns. (#1290) https://docs.clawd.bot/plugins/manifest
|
||||||
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) https://docs.clawd.bot/web/control-ui
|
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) https://docs.clawd.bot/web/control-ui
|
||||||
- Agents/UI: add agent avatar support in identity config, IDENTITY.md, and the Control UI. (#1329) https://docs.clawd.bot/gateway/configuration
|
|
||||||
- Plugins: add plugin slots with a dedicated memory slot selector. https://docs.clawd.bot/plugins/agent-tools
|
- Plugins: add plugin slots with a dedicated memory slot selector. https://docs.clawd.bot/plugins/agent-tools
|
||||||
- Plugins: ship the bundled BlueBubbles channel plugin (disabled by default). https://docs.clawd.bot/channels/bluebubbles
|
- Plugins: ship the bundled BlueBubbles channel plugin (disabled by default). https://docs.clawd.bot/channels/bluebubbles
|
||||||
- Plugins: migrate bundled messaging extensions to the plugin SDK and resolve plugin-sdk imports in the loader.
|
- Plugins: migrate bundled messaging extensions to the plugin SDK and resolve plugin-sdk imports in the loader.
|
||||||
@@ -117,7 +94,6 @@ Docs: https://docs.clawd.bot
|
|||||||
- Plugins: auto-enable bundled channel/provider plugins when configuration is present.
|
- Plugins: auto-enable bundled channel/provider plugins when configuration is present.
|
||||||
- Plugins: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`.
|
- Plugins: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`.
|
||||||
- Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`.
|
- Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`.
|
||||||
|
|
||||||
- Gateway/API: add `/v1/responses` (OpenResponses) with item-based input + semantic streaming events. (#1229)
|
- Gateway/API: add `/v1/responses` (OpenResponses) with item-based input + semantic streaming events. (#1229)
|
||||||
- Gateway/API: expand `/v1/responses` to support file/image inputs, tool_choice, usage, and output limits. (#1229)
|
- Gateway/API: expand `/v1/responses` to support file/image inputs, tool_choice, usage, and output limits. (#1229)
|
||||||
- Usage: add `/usage cost` summaries and macOS menu cost charts. https://docs.clawd.bot/reference/api-usage-costs
|
- Usage: add `/usage cost` summaries and macOS menu cost charts. https://docs.clawd.bot/reference/api-usage-costs
|
||||||
|
|||||||
@@ -478,27 +478,26 @@ Thanks to all clawtributors:
|
|||||||
|
|
||||||
<p align="left">
|
<p align="left">
|
||||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a>
|
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a>
|
||||||
<a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a>
|
<a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a>
|
||||||
<a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a>
|
<a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a>
|
||||||
<a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a>
|
<a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a>
|
||||||
<a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a>
|
<a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a>
|
||||||
<a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a>
|
<a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a>
|
||||||
<a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a>
|
<a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a>
|
||||||
<a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a>
|
<a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a>
|
||||||
<a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a>
|
<a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a>
|
||||||
<a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a>
|
<a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a>
|
||||||
<a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a>
|
<a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a>
|
||||||
<a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a>
|
<a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a>
|
||||||
<a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a>
|
<a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a>
|
||||||
<a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a>
|
<a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a>
|
||||||
<a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a>
|
<a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a>
|
||||||
<a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a>
|
<a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a>
|
||||||
<a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a>
|
<a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a>
|
||||||
<a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a>
|
<a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a>
|
||||||
<a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a>
|
<a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a>
|
||||||
<a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a>
|
<a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a>
|
||||||
<a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a>
|
<a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a>
|
||||||
<a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a>
|
<a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a>
|
||||||
<a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a>
|
<a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||||
<a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
|
||||||
</p>
|
</p>
|
||||||
+82
-75
@@ -2,80 +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.21</title>
|
|
||||||
<pubDate>Thu, 22 Jan 2026 12:22:35 +0000</pubDate>
|
|
||||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
|
||||||
<sparkle:version>7374</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>2026.1.21</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
|
||||||
<description><![CDATA[<h2>Clawdbot 2026.1.21</h2>
|
|
||||||
<h3>Highlights</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster</li>
|
|
||||||
<li>Custom assistant identity + avatars in the Control UI. https://docs.clawd.bot/cli/agents https://docs.clawd.bot/web/control-ui</li>
|
|
||||||
<li>Cache optimizations: cache-ttl pruning + defaults reduce token spend on cold requests. https://docs.clawd.bot/concepts/session-pruning</li>
|
|
||||||
<li>Exec approvals + elevated ask/full modes. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/elevated</li>
|
|
||||||
<li>Signal typing/read receipts + MSTeams attachments. https://docs.clawd.bot/channels/signal https://docs.clawd.bot/channels/msteams</li>
|
|
||||||
<li><code>/models</code> UX refresh + <code>clawdbot update wizard</code>. https://docs.clawd.bot/cli/models https://docs.clawd.bot/cli/update</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Changes</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster (#1152) Thanks @vignesh07.</li>
|
|
||||||
<li>Agents/UI: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer. https://docs.clawd.bot/gateway/configuration https://docs.clawd.bot/cli/agents</li>
|
|
||||||
<li>Control UI: add custom assistant identity support and per-session identity display. (#1420) Thanks @robbyczgw-cla. https://docs.clawd.bot/web/control-ui</li>
|
|
||||||
<li>CLI: add <code>clawdbot update wizard</code> with interactive channel selection + restart prompts, plus preflight checks before rebasing. https://docs.clawd.bot/cli/update</li>
|
|
||||||
<li>Models/Commands: add <code>/models</code>, improve <code>/model</code> listing UX, and expand <code>clawdbot models</code> paging. (#1398) Thanks @vignesh07. https://docs.clawd.bot/cli/models</li>
|
|
||||||
<li>CLI: move gateway service commands under <code>clawdbot gateway</code>, flatten node service commands under <code>clawdbot node</code>, and add <code>gateway probe</code> for reachability. https://docs.clawd.bot/cli/gateway https://docs.clawd.bot/cli/node</li>
|
|
||||||
<li>Exec: add elevated ask/full modes, tighten allowlist gating, and render approvals tables on write. https://docs.clawd.bot/tools/elevated https://docs.clawd.bot/tools/exec-approvals</li>
|
|
||||||
<li>Exec approvals: default to local host, add gateway/node targeting + target details, support wildcard agent allowlists, and tighten allowlist parsing/safe bins. https://docs.clawd.bot/cli/approvals https://docs.clawd.bot/tools/exec-approvals</li>
|
|
||||||
<li>Heartbeat: allow explicit session keys and active hours. (#1256) Thanks @zknicker. https://docs.clawd.bot/gateway/heartbeat</li>
|
|
||||||
<li>Sessions: add per-channel idle durations via <code>sessions.channelIdleMinutes</code>. (#1353) Thanks @cash-echo-bot.</li>
|
|
||||||
<li>Nodes: run exec-style, expose PATH in status/describe, and bootstrap PATH for node-host execution. https://docs.clawd.bot/cli/node</li>
|
|
||||||
<li>Cache: add <code>cache.ttlPrune</code> mode and auth-aware defaults for cache TTL behavior.</li>
|
|
||||||
<li>Queue: add per-channel debounce overrides for auto-reply. https://docs.clawd.bot/concepts/queue</li>
|
|
||||||
<li>Discord: add wildcard channel config support. (#1334) Thanks @pvoo. https://docs.clawd.bot/channels/discord</li>
|
|
||||||
<li>Signal: add typing indicators and DM read receipts via signal-cli. https://docs.clawd.bot/channels/signal</li>
|
|
||||||
<li>MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero. https://docs.clawd.bot/channels/msteams</li>
|
|
||||||
<li>Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).</li>
|
|
||||||
<li>macOS: refresh Settings (location access in Permissions, connection mode in menu, remove CLI install UI).</li>
|
|
||||||
<li>Diagnostics: add cache trace config for debugging. (#1370) Thanks @parubets.</li>
|
|
||||||
<li>Docs: Lobster guides + org URL updates, /model allowlist troubleshooting, Gmail message search examples, gateway.mode troubleshooting, prompt injection guidance, npm prefix/node CLI notes, control UI dev gatewayUrl note, tool_use FAQ, showcase video, and sharp/node-gyp workaround. (#1427, #1220, #1405) Thanks @vignesh07, @mbelinky.</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Breaking</h3>
|
|
||||||
<ul>
|
|
||||||
<li><strong>BREAKING:</strong> Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set <code>gateway.controlUi.allowInsecureAuth: true</code> to allow token-only auth. https://docs.clawd.bot/web/control-ui#insecure-http</li>
|
|
||||||
<li><strong>BREAKING:</strong> Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Fixes</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Streaming/Typing/Media: keep reply tags across streamed chunks, start typing indicators at run start, and accept MEDIA paths with spaces/tilde while preferring the message tool hint for image replies.</li>
|
|
||||||
<li>Agents/Providers: drop unsigned thinking blocks for Claude models (Google Antigravity) and enforce alphanumeric tool call ids for strict providers (Mistral/OpenRouter). (#1372) Thanks @zerone0x.</li>
|
|
||||||
<li>Exec approvals: treat main as the default agent, align node/gateway allowlist prechecks, validate resolved paths, avoid allowlist resolve races, and avoid null optional params. (#1417, #1414, #1425) Thanks @czekaj.</li>
|
|
||||||
<li>Exec/Windows: resolve Windows exec paths with extensions and handle safe-bin exe names.</li>
|
|
||||||
<li>Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.</li>
|
|
||||||
<li>Gateway: prevent multiple gateways from sharing the same config/state (singleton lock), keep auto bind loopback-first with explicit tailnet binding, and improve SSH auth handling. (#1380)</li>
|
|
||||||
<li>Control UI: remove the chat stop button, keep the composer aligned to the bottom edge, stabilize session previews, and refresh the debug panel on route-driven tab changes. (#1373) Thanks @yazinsai.</li>
|
|
||||||
<li>UI/config: export <code>SECTION_META</code> for config form modules. (#1418) Thanks @MaudeBot.</li>
|
|
||||||
<li>macOS: keep chat pinned during streaming replies, include Textual resources, respect wildcard exec approvals, allow SSH agent auth, and default distribution builds to universal binaries. (#1279, #1362, #1384, #1396) Thanks @ameno-, @JustYannicc.</li>
|
|
||||||
<li>BlueBubbles: resolve short message IDs safely, expose full IDs in templates, and harden short-id fetch wrappers. (#1369, #1387) Thanks @tyler6204.</li>
|
|
||||||
<li>Models/Configure: inherit session model overrides in threads/topics, map OpenCode Zen models to the correct APIs, narrow Anthropic OAuth allowlist handling, seed allowlist fallbacks, list the full catalog when no allowlist is set, and limit <code>/model</code> list output. (#1376, #1416)</li>
|
|
||||||
<li>Memory: prevent CLI hangs by deferring vector probes, add sqlite-vec/embedding timeouts, and make session memory indexing async.</li>
|
|
||||||
<li>Cron: cap reminder context history to 10 messages and honor <code>contextMessages</code>. (#1103) Thanks @mkbehr.</li>
|
|
||||||
<li>Cache: restore the 1h cache TTL option and reset the pruning window.</li>
|
|
||||||
<li>Zalo Personal: tolerate ANSI/log-prefixed JSON output from <code>zca</code>. (#1379) Thanks @ptn1411.</li>
|
|
||||||
<li>Browser: suppress Chrome restore prompts for managed profiles. (#1419) Thanks @jamesgroat.</li>
|
|
||||||
<li>Infra: preserve fetch helper methods/preconnect when wrapping abort signals and normalize Telegram fetch aborts.</li>
|
|
||||||
<li>Config/Doctor: avoid stack traces for invalid configs, log the config path, avoid WhatsApp config resurrection, and warn when <code>gateway.mode</code> is unset. (#900)</li>
|
|
||||||
<li>CLI: read Codex CLI account_id for workspace billing. (#1422) Thanks @aj47.</li>
|
|
||||||
<li>Logs/Status: align rolling log filenames with local time and report sandboxed runtime in <code>clawdbot status</code>. (#1343)</li>
|
|
||||||
<li>Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell.</li>
|
|
||||||
<li>Nodes/Subagents: include agent/node/gateway context in tool failure logs and ensure subagent list uses the command session.</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.21/Clawdbot-2026.1.21.zip" length="22284796" type="application/octet-stream" sparkle:edSignature="pXji4NMA/cu35iMxln385d6LnsT4yIZtFtFiR7sIimKeSC2CsyeWzzSD0EhJsN98PdSoy69iEFZt4I2ZtNCECg=="/>
|
|
||||||
</item>
|
|
||||||
<item>
|
<item>
|
||||||
<title>2026.1.21</title>
|
<title>2026.1.21</title>
|
||||||
<pubDate>Wed, 21 Jan 2026 08:18:22 +0000</pubDate>
|
<pubDate>Wed, 21 Jan 2026 08:18:22 +0000</pubDate>
|
||||||
@@ -282,5 +208,86 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
|||||||
]]></description>
|
]]></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=="/>
|
<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>
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>2026.1.15</title>
|
||||||
|
<pubDate>Fri, 16 Jan 2026 10:31:53 +0000</pubDate>
|
||||||
|
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||||
|
<sparkle:version>5998</sparkle:version>
|
||||||
|
<sparkle:shortVersionString>2026.1.15</sparkle:shortVersionString>
|
||||||
|
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||||
|
<description><![CDATA[<h2>Clawdbot 2026.1.15</h2>
|
||||||
|
<h3>Highlights</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Plugins: add provider auth registry + <code>clawdbot models auth login</code> for plugin-driven OAuth/API key flows.</li>
|
||||||
|
<li>Browser: improve remote CDP/Browserless support (auth passthrough, <code>wss</code> upgrade, timeouts, clearer errors).</li>
|
||||||
|
<li>Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf.</li>
|
||||||
|
<li>Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs).</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Breaking</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>BREAKING:</strong> iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)</li>
|
||||||
|
<li><strong>BREAKING:</strong> Microsoft Teams is now a plugin; install <code>@clawdbot/msteams</code> via <code>clawdbot plugins install @clawdbot/msteams</code>.</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Changes</h3>
|
||||||
|
<ul>
|
||||||
|
<li>CLI: set process titles to <code>clawdbot-<command></code> for clearer process listings.</li>
|
||||||
|
<li>CLI/macOS: sync remote SSH target/identity to config and let <code>gateway status</code> auto-infer SSH targets (ssh-config aware).</li>
|
||||||
|
<li>Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.</li>
|
||||||
|
<li>Sessions/Security: add <code>session.dmScope</code> for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.</li>
|
||||||
|
<li>Plugins: add provider auth registry + <code>clawdbot models auth login</code> for plugin-driven OAuth/API key flows.</li>
|
||||||
|
<li>Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.</li>
|
||||||
|
<li>TUI: show provider/model labels for the active session and default model.</li>
|
||||||
|
<li>Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.</li>
|
||||||
|
<li>UI: show gateway auth guidance + doc link on unauthorized Control UI connections.</li>
|
||||||
|
<li>Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in <code>clawdbot security audit</code>.</li>
|
||||||
|
<li>Apps: store node auth tokens encrypted (Keychain/SecurePrefs).</li>
|
||||||
|
<li>Daemon: share profile/state-dir resolution across service helpers and honor <code>CLAWDBOT_STATE_DIR</code> for Windows task scripts.</li>
|
||||||
|
<li>Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.</li>
|
||||||
|
<li>Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).</li>
|
||||||
|
<li>Tools: normalize Slack/Discord message timestamps with <code>timestampMs</code>/<code>timestampUtc</code> while keeping raw provider fields.</li>
|
||||||
|
<li>macOS: add <code>system.which</code> for prompt-free remote skill discovery (with gateway fallback to <code>system.run</code>).</li>
|
||||||
|
<li>Docs: add Date & Time guide and update prompt/timezone configuration docs.</li>
|
||||||
|
<li>Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.</li>
|
||||||
|
<li>Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.</li>
|
||||||
|
<li>Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in <code>/status</code> and <code>clawdbot models status</code>, and update docs.</li>
|
||||||
|
<li>CLI: add <code>--json</code> output for <code>clawdbot daemon</code> lifecycle/install commands.</li>
|
||||||
|
<li>Memory: make <code>node-llama-cpp</code> an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.</li>
|
||||||
|
<li>Browser: add <code>snapshot refs=aria</code> (Playwright aria-ref ids) for self-resolving refs across <code>snapshot</code> → <code>act</code>.</li>
|
||||||
|
<li>Browser: <code>profile="chrome"</code> now defaults to host control and returns clearer “attach a tab” errors.</li>
|
||||||
|
<li>Browser: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer.</li>
|
||||||
|
<li>Browser: increase remote CDP reachability timeouts + add <code>remoteCdpTimeoutMs</code>/<code>remoteCdpHandshakeTimeoutMs</code>.</li>
|
||||||
|
<li>Browser: preserve auth/query tokens for remote CDP endpoints and pass Basic auth for CDP HTTP/WS. (#895) — thanks @mukhtharcm.</li>
|
||||||
|
<li>Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.</li>
|
||||||
|
<li>Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino.</li>
|
||||||
|
<li>Discord: allow allowlisted guilds without channel lists to receive messages when <code>groupPolicy="allowlist"</code>. — thanks @thewilloftheshadow.</li>
|
||||||
|
<li>Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Fixes</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.</li>
|
||||||
|
<li>Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.</li>
|
||||||
|
<li>Fix: persist <code>gateway.mode=local</code> after selecting Local run mode in <code>clawdbot configure</code>, even if no other sections are chosen.</li>
|
||||||
|
<li>Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.</li>
|
||||||
|
<li>Agents: avoid false positives when logging unsupported Google tool schema keywords.</li>
|
||||||
|
<li>Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm.</li>
|
||||||
|
<li>Status: restore usage summary line for current provider when no OAuth profiles exist.</li>
|
||||||
|
<li>Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.</li>
|
||||||
|
<li>Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.</li>
|
||||||
|
<li>Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.</li>
|
||||||
|
<li>Fix: support MiniMax coding plan usage responses with <code>model_remains</code>/<code>current_interval_*</code> payloads.</li>
|
||||||
|
<li>Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904)</li>
|
||||||
|
<li>Browser: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
|
||||||
|
<li>Browser: fix <code>tab not found</code> for extension relay snapshots/actions when Playwright blocks <code>newCDPSession</code> (use the single available Page).</li>
|
||||||
|
<li>Browser: upgrade <code>ws</code> → <code>wss</code> when remote CDP uses <code>https</code> (fixes Browserless handshake).</li>
|
||||||
|
<li>Telegram: skip <code>message_thread_id=1</code> for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.</li>
|
||||||
|
<li>Fix: sanitize user-facing error text + strip <code><final></code> tags across reply pipelines. (#975) — thanks @ThomsenDrake.</li>
|
||||||
|
<li>Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.</li>
|
||||||
|
<li>Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.</li>
|
||||||
|
<li>Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)</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.15/Clawdbot-2026.1.15.zip" length="12127276" type="application/octet-stream" sparkle:edSignature="o79vwTbtW/d91NQFRVfUDhsv6D4zIw7IkhY0N1iLImMu94BURgLcecA6z7Smy3bMobPwOyzN8yfm6mA/Rt8FCA=="/>
|
||||||
|
</item>
|
||||||
</channel>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "f847d54db16b371dbb1a79271d50436cdec572179b0f0cf14cfe1b75df8dfbc2",
|
"originHash" : "550d4ea41d4bb2546b99a7bfa1c5cba7e28a13862bc226727ea7426c61555a33",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "axorcist",
|
"identity" : "axorcist",
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
"location" : "https://github.com/steipete/ElevenLabsKit",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
|
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
|
||||||
"version" : "0.1.0"
|
"version" : "0.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ final class ControlChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private(set) var lastPingMs: Double?
|
private(set) var lastPingMs: Double?
|
||||||
private(set) var authSourceLabel: String?
|
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "com.clawdbot", category: "control")
|
private let logger = Logger(subsystem: "com.clawdbot", category: "control")
|
||||||
|
|
||||||
@@ -129,7 +128,6 @@ final class ControlChannel {
|
|||||||
await GatewayConnection.shared.shutdown()
|
await GatewayConnection.shared.shutdown()
|
||||||
self.state = .disconnected
|
self.state = .disconnected
|
||||||
self.lastPingMs = nil
|
self.lastPingMs = nil
|
||||||
self.authSourceLabel = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func health(timeout: TimeInterval? = nil) async throws -> Data {
|
func health(timeout: TimeInterval? = nil) async throws -> Data {
|
||||||
@@ -190,11 +188,8 @@ final class ControlChannel {
|
|||||||
urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures
|
urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures
|
||||||
{
|
{
|
||||||
let reason = urlErr.failureURLString ?? urlErr.localizedDescription
|
let reason = urlErr.failureURLString ?? urlErr.localizedDescription
|
||||||
let tokenKey = CommandResolver.connectionModeIsRemote()
|
|
||||||
? "gateway.remote.token"
|
|
||||||
: "gateway.auth.token"
|
|
||||||
return
|
return
|
||||||
"Gateway rejected token; set \(tokenKey) (or CLAWDBOT_GATEWAY_TOKEN) " +
|
"Gateway rejected token; set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN) " +
|
||||||
"or clear it on the gateway. " +
|
"or clear it on the gateway. " +
|
||||||
"Reason: \(reason)"
|
"Reason: \(reason)"
|
||||||
}
|
}
|
||||||
@@ -305,27 +300,6 @@ final class ControlChannel {
|
|||||||
code: 0,
|
code: 0,
|
||||||
userInfo: [NSLocalizedDescriptionKey: "gateway health not ok"])
|
userInfo: [NSLocalizedDescriptionKey: "gateway health not ok"])
|
||||||
}
|
}
|
||||||
await self.refreshAuthSourceLabel()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func refreshAuthSourceLabel() async {
|
|
||||||
let isRemote = CommandResolver.connectionModeIsRemote()
|
|
||||||
let authSource = await GatewayConnection.shared.authSource()
|
|
||||||
self.authSourceLabel = Self.formatAuthSource(authSource, isRemote: isRemote)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func formatAuthSource(_ source: GatewayAuthSource?, isRemote: Bool) -> String? {
|
|
||||||
guard let source else { return nil }
|
|
||||||
switch source {
|
|
||||||
case .deviceToken:
|
|
||||||
return "Auth: device token (paired device)"
|
|
||||||
case .sharedToken:
|
|
||||||
return "Auth: shared token (\(isRemote ? "gateway.remote.token" : "gateway.auth.token"))"
|
|
||||||
case .password:
|
|
||||||
return "Auth: password (\(isRemote ? "gateway.remote.password" : "gateway.auth.password"))"
|
|
||||||
case .none:
|
|
||||||
return "Auth: none"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {
|
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {
|
||||||
|
|||||||
@@ -149,7 +149,6 @@ struct ExecApprovalsResolvedDefaults {
|
|||||||
|
|
||||||
enum ExecApprovalsStore {
|
enum ExecApprovalsStore {
|
||||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals")
|
private static let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals")
|
||||||
private static let defaultAgentId = "main"
|
|
||||||
private static let defaultSecurity: ExecSecurity = .deny
|
private static let defaultSecurity: ExecSecurity = .deny
|
||||||
private static let defaultAsk: ExecAsk = .onMiss
|
private static let defaultAsk: ExecAsk = .onMiss
|
||||||
private static let defaultAskFallback: ExecSecurity = .deny
|
private static let defaultAskFallback: ExecSecurity = .deny
|
||||||
@@ -166,22 +165,13 @@ enum ExecApprovalsStore {
|
|||||||
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
|
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
|
||||||
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
var agents = file.agents ?? [:]
|
|
||||||
if let legacyDefault = agents["default"] {
|
|
||||||
if let main = agents[self.defaultAgentId] {
|
|
||||||
agents[self.defaultAgentId] = self.mergeAgents(current: main, legacy: legacyDefault)
|
|
||||||
} else {
|
|
||||||
agents[self.defaultAgentId] = legacyDefault
|
|
||||||
}
|
|
||||||
agents.removeValue(forKey: "default")
|
|
||||||
}
|
|
||||||
return ExecApprovalsFile(
|
return ExecApprovalsFile(
|
||||||
version: 1,
|
version: 1,
|
||||||
socket: ExecApprovalsSocketConfig(
|
socket: ExecApprovalsSocketConfig(
|
||||||
path: socketPath.isEmpty ? nil : socketPath,
|
path: socketPath.isEmpty ? nil : socketPath,
|
||||||
token: token.isEmpty ? nil : token),
|
token: token.isEmpty ? nil : token),
|
||||||
defaults: file.defaults,
|
defaults: file.defaults,
|
||||||
agents: agents)
|
agents: file.agents)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func readSnapshot() -> ExecApprovalsSnapshot {
|
static func readSnapshot() -> ExecApprovalsSnapshot {
|
||||||
@@ -282,7 +272,9 @@ enum ExecApprovalsStore {
|
|||||||
ask: defaults.ask ?? self.defaultAsk,
|
ask: defaults.ask ?? self.defaultAsk,
|
||||||
askFallback: defaults.askFallback ?? self.defaultAskFallback,
|
askFallback: defaults.askFallback ?? self.defaultAskFallback,
|
||||||
autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills)
|
autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills)
|
||||||
let key = self.agentKey(agentId)
|
let key = (agentId?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||||
|
? agentId!.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
: "default"
|
||||||
let agentEntry = file.agents?[key] ?? ExecApprovalsAgent()
|
let agentEntry = file.agents?[key] ?? ExecApprovalsAgent()
|
||||||
let wildcardEntry = file.agents?["*"] ?? ExecApprovalsAgent()
|
let wildcardEntry = file.agents?["*"] ?? ExecApprovalsAgent()
|
||||||
let resolvedAgent = ExecApprovalsResolvedDefaults(
|
let resolvedAgent = ExecApprovalsResolvedDefaults(
|
||||||
@@ -465,40 +457,7 @@ enum ExecApprovalsStore {
|
|||||||
|
|
||||||
private static func agentKey(_ agentId: String?) -> String {
|
private static func agentKey(_ agentId: String?) -> String {
|
||||||
let trimmed = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let trimmed = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
return trimmed.isEmpty ? self.defaultAgentId : trimmed
|
return trimmed.isEmpty ? "default" : trimmed
|
||||||
}
|
|
||||||
|
|
||||||
private static func normalizedPattern(_ pattern: String?) -> String? {
|
|
||||||
let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
return trimmed.isEmpty ? nil : trimmed.lowercased()
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func mergeAgents(
|
|
||||||
current: ExecApprovalsAgent,
|
|
||||||
legacy: ExecApprovalsAgent) -> ExecApprovalsAgent
|
|
||||||
{
|
|
||||||
var seen = Set<String>()
|
|
||||||
var allowlist: [ExecAllowlistEntry] = []
|
|
||||||
func append(_ entry: ExecAllowlistEntry) {
|
|
||||||
guard let key = self.normalizedPattern(entry.pattern), !seen.contains(key) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
seen.insert(key)
|
|
||||||
allowlist.append(entry)
|
|
||||||
}
|
|
||||||
for entry in current.allowlist ?? [] {
|
|
||||||
append(entry)
|
|
||||||
}
|
|
||||||
for entry in legacy.allowlist ?? [] {
|
|
||||||
append(entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ExecApprovalsAgent(
|
|
||||||
security: current.security ?? legacy.security,
|
|
||||||
ask: current.ask ?? legacy.ask,
|
|
||||||
askFallback: current.askFallback ?? legacy.askFallback,
|
|
||||||
autoAllowSkills: current.autoAllowSkills ?? legacy.autoAllowSkills,
|
|
||||||
allowlist: allowlist.isEmpty ? nil : allowlist)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import ClawdbotKit
|
import ClawdbotKit
|
||||||
import ClawdbotProtocol
|
import ClawdbotProtocol
|
||||||
import CoreGraphics
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
|
|
||||||
@@ -45,7 +44,6 @@ final class ExecApprovalsGatewayPrompter {
|
|||||||
do {
|
do {
|
||||||
let data = try JSONEncoder().encode(payload)
|
let data = try JSONEncoder().encode(payload)
|
||||||
let request = try JSONDecoder().decode(GatewayApprovalRequest.self, from: data)
|
let request = try JSONDecoder().decode(GatewayApprovalRequest.self, from: data)
|
||||||
guard self.shouldPresent(request: request) else { return }
|
|
||||||
let decision = ExecApprovalsPromptPresenter.prompt(request.request)
|
let decision = ExecApprovalsPromptPresenter.prompt(request.request)
|
||||||
try await GatewayConnection.shared.requestVoid(
|
try await GatewayConnection.shared.requestVoid(
|
||||||
method: .execApprovalResolve,
|
method: .execApprovalResolve,
|
||||||
@@ -58,66 +56,4 @@ final class ExecApprovalsGatewayPrompter {
|
|||||||
self.logger.error("exec approval handling failed \(error.localizedDescription, privacy: .public)")
|
self.logger.error("exec approval handling failed \(error.localizedDescription, privacy: .public)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func shouldPresent(request: GatewayApprovalRequest) -> Bool {
|
|
||||||
let mode = AppStateStore.shared.connectionMode
|
|
||||||
let activeSession = WebChatManager.shared.activeSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let requestSession = request.request.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
return Self.shouldPresent(
|
|
||||||
mode: mode,
|
|
||||||
activeSession: activeSession,
|
|
||||||
requestSession: requestSession,
|
|
||||||
lastInputSeconds: Self.lastInputSeconds(),
|
|
||||||
thresholdSeconds: 120)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func shouldPresent(
|
|
||||||
mode: AppState.ConnectionMode,
|
|
||||||
activeSession: String?,
|
|
||||||
requestSession: String?,
|
|
||||||
lastInputSeconds: Int?,
|
|
||||||
thresholdSeconds: Int) -> Bool
|
|
||||||
{
|
|
||||||
let active = activeSession?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let requested = requestSession?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let recentlyActive = lastInputSeconds.map { $0 <= thresholdSeconds } ?? (mode == .local)
|
|
||||||
|
|
||||||
if let session = requested, !session.isEmpty {
|
|
||||||
if let active, !active.isEmpty {
|
|
||||||
return active == session
|
|
||||||
}
|
|
||||||
return recentlyActive
|
|
||||||
}
|
|
||||||
|
|
||||||
if let active, !active.isEmpty {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return mode == .local
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func lastInputSeconds() -> Int? {
|
|
||||||
let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null
|
|
||||||
let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent)
|
|
||||||
if seconds.isNaN || seconds.isInfinite || seconds < 0 { return nil }
|
|
||||||
return Int(seconds.rounded())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
extension ExecApprovalsGatewayPrompter {
|
|
||||||
static func _testShouldPresent(
|
|
||||||
mode: AppState.ConnectionMode,
|
|
||||||
activeSession: String?,
|
|
||||||
requestSession: String?,
|
|
||||||
lastInputSeconds: Int?,
|
|
||||||
thresholdSeconds: Int = 120) -> Bool
|
|
||||||
{
|
|
||||||
self.shouldPresent(
|
|
||||||
mode: mode,
|
|
||||||
activeSession: activeSession,
|
|
||||||
requestSession: requestSession,
|
|
||||||
lastInputSeconds: lastInputSeconds,
|
|
||||||
thresholdSeconds: thresholdSeconds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -13,7 +13,6 @@ struct ExecApprovalPromptRequest: Codable, Sendable {
|
|||||||
var ask: String?
|
var ask: String?
|
||||||
var agentId: String?
|
var agentId: String?
|
||||||
var resolvedPath: String?
|
var resolvedPath: String?
|
||||||
var sessionKey: String?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ExecApprovalSocketRequest: Codable {
|
private struct ExecApprovalSocketRequest: Codable {
|
||||||
@@ -216,15 +215,36 @@ enum ExecApprovalsPromptPresenter {
|
|||||||
let alert = NSAlert()
|
let alert = NSAlert()
|
||||||
alert.alertStyle = .warning
|
alert.alertStyle = .warning
|
||||||
alert.messageText = "Allow this command?"
|
alert.messageText = "Allow this command?"
|
||||||
alert.informativeText = "Review the command details before allowing."
|
|
||||||
alert.accessoryView = self.buildAccessoryView(request)
|
var details = "Clawdbot wants to run:\n\n\(request.command)"
|
||||||
|
let trimmedCwd = request.cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedCwd.isEmpty {
|
||||||
|
details += "\n\nWorking directory:\n\(trimmedCwd)"
|
||||||
|
}
|
||||||
|
let trimmedAgent = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedAgent.isEmpty {
|
||||||
|
details += "\n\nAgent:\n\(trimmedAgent)"
|
||||||
|
}
|
||||||
|
let trimmedPath = request.resolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedPath.isEmpty {
|
||||||
|
details += "\n\nExecutable:\n\(trimmedPath)"
|
||||||
|
}
|
||||||
|
let trimmedHost = request.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedHost.isEmpty {
|
||||||
|
details += "\n\nHost:\n\(trimmedHost)"
|
||||||
|
}
|
||||||
|
if let security = request.security?.trimmingCharacters(in: .whitespacesAndNewlines), !security.isEmpty {
|
||||||
|
details += "\n\nSecurity:\n\(security)"
|
||||||
|
}
|
||||||
|
if let ask = request.ask?.trimmingCharacters(in: .whitespacesAndNewlines), !ask.isEmpty {
|
||||||
|
details += "\nAsk mode:\n\(ask)"
|
||||||
|
}
|
||||||
|
details += "\n\nThis runs on this machine."
|
||||||
|
alert.informativeText = details
|
||||||
|
|
||||||
alert.addButton(withTitle: "Allow Once")
|
alert.addButton(withTitle: "Allow Once")
|
||||||
alert.addButton(withTitle: "Always Allow")
|
alert.addButton(withTitle: "Always Allow")
|
||||||
alert.addButton(withTitle: "Don't Allow")
|
alert.addButton(withTitle: "Don't Allow")
|
||||||
if #available(macOS 11.0, *), alert.buttons.indices.contains(2) {
|
|
||||||
alert.buttons[2].hasDestructiveAction = true
|
|
||||||
}
|
|
||||||
|
|
||||||
switch alert.runModal() {
|
switch alert.runModal() {
|
||||||
case .alertFirstButtonReturn:
|
case .alertFirstButtonReturn:
|
||||||
@@ -235,110 +255,6 @@ enum ExecApprovalsPromptPresenter {
|
|||||||
return .deny
|
return .deny
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private static func buildAccessoryView(_ request: ExecApprovalPromptRequest) -> NSView {
|
|
||||||
let stack = NSStackView()
|
|
||||||
stack.orientation = .vertical
|
|
||||||
stack.spacing = 8
|
|
||||||
stack.alignment = .leading
|
|
||||||
|
|
||||||
let commandTitle = NSTextField(labelWithString: "Command")
|
|
||||||
commandTitle.font = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize)
|
|
||||||
stack.addArrangedSubview(commandTitle)
|
|
||||||
|
|
||||||
let commandText = NSTextView()
|
|
||||||
commandText.isEditable = false
|
|
||||||
commandText.isSelectable = true
|
|
||||||
commandText.drawsBackground = true
|
|
||||||
commandText.backgroundColor = NSColor.textBackgroundColor
|
|
||||||
commandText.font = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
|
|
||||||
commandText.string = request.command
|
|
||||||
commandText.textContainerInset = NSSize(width: 6, height: 6)
|
|
||||||
commandText.textContainer?.lineFragmentPadding = 0
|
|
||||||
commandText.textContainer?.widthTracksTextView = true
|
|
||||||
commandText.isHorizontallyResizable = false
|
|
||||||
commandText.isVerticallyResizable = false
|
|
||||||
|
|
||||||
let commandScroll = NSScrollView()
|
|
||||||
commandScroll.borderType = .lineBorder
|
|
||||||
commandScroll.hasVerticalScroller = false
|
|
||||||
commandScroll.hasHorizontalScroller = false
|
|
||||||
commandScroll.documentView = commandText
|
|
||||||
commandScroll.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
commandScroll.widthAnchor.constraint(lessThanOrEqualToConstant: 440).isActive = true
|
|
||||||
commandScroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 56).isActive = true
|
|
||||||
stack.addArrangedSubview(commandScroll)
|
|
||||||
|
|
||||||
let contextTitle = NSTextField(labelWithString: "Context")
|
|
||||||
contextTitle.font = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize)
|
|
||||||
stack.addArrangedSubview(contextTitle)
|
|
||||||
|
|
||||||
let contextStack = NSStackView()
|
|
||||||
contextStack.orientation = .vertical
|
|
||||||
contextStack.spacing = 4
|
|
||||||
contextStack.alignment = .leading
|
|
||||||
|
|
||||||
let trimmedCwd = request.cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
if !trimmedCwd.isEmpty {
|
|
||||||
self.addDetailRow(title: "Working directory", value: trimmedCwd, to: contextStack)
|
|
||||||
}
|
|
||||||
let trimmedAgent = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
if !trimmedAgent.isEmpty {
|
|
||||||
self.addDetailRow(title: "Agent", value: trimmedAgent, to: contextStack)
|
|
||||||
}
|
|
||||||
let trimmedPath = request.resolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
if !trimmedPath.isEmpty {
|
|
||||||
self.addDetailRow(title: "Executable", value: trimmedPath, to: contextStack)
|
|
||||||
}
|
|
||||||
let trimmedHost = request.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
if !trimmedHost.isEmpty {
|
|
||||||
self.addDetailRow(title: "Host", value: trimmedHost, to: contextStack)
|
|
||||||
}
|
|
||||||
if let security = request.security?.trimmingCharacters(in: .whitespacesAndNewlines), !security.isEmpty {
|
|
||||||
self.addDetailRow(title: "Security", value: security, to: contextStack)
|
|
||||||
}
|
|
||||||
if let ask = request.ask?.trimmingCharacters(in: .whitespacesAndNewlines), !ask.isEmpty {
|
|
||||||
self.addDetailRow(title: "Ask mode", value: ask, to: contextStack)
|
|
||||||
}
|
|
||||||
|
|
||||||
if contextStack.arrangedSubviews.isEmpty {
|
|
||||||
let empty = NSTextField(labelWithString: "No additional context provided.")
|
|
||||||
empty.textColor = NSColor.secondaryLabelColor
|
|
||||||
empty.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)
|
|
||||||
contextStack.addArrangedSubview(empty)
|
|
||||||
}
|
|
||||||
|
|
||||||
stack.addArrangedSubview(contextStack)
|
|
||||||
|
|
||||||
let footer = NSTextField(labelWithString: "This runs on this machine.")
|
|
||||||
footer.textColor = NSColor.secondaryLabelColor
|
|
||||||
footer.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)
|
|
||||||
stack.addArrangedSubview(footer)
|
|
||||||
|
|
||||||
return stack
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private static func addDetailRow(title: String, value: String, to stack: NSStackView) {
|
|
||||||
let row = NSStackView()
|
|
||||||
row.orientation = .horizontal
|
|
||||||
row.spacing = 6
|
|
||||||
row.alignment = .firstBaseline
|
|
||||||
|
|
||||||
let titleLabel = NSTextField(labelWithString: "\(title):")
|
|
||||||
titleLabel.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize, weight: .semibold)
|
|
||||||
titleLabel.textColor = NSColor.secondaryLabelColor
|
|
||||||
|
|
||||||
let valueLabel = NSTextField(labelWithString: value)
|
|
||||||
valueLabel.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)
|
|
||||||
valueLabel.lineBreakMode = .byTruncatingMiddle
|
|
||||||
valueLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
||||||
|
|
||||||
row.addArrangedSubview(titleLabel)
|
|
||||||
row.addArrangedSubview(valueLabel)
|
|
||||||
stack.addArrangedSubview(row)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -413,8 +329,7 @@ private enum ExecHostExecutor {
|
|||||||
security: context.security.rawValue,
|
security: context.security.rawValue,
|
||||||
ask: context.ask.rawValue,
|
ask: context.ask.rawValue,
|
||||||
agentId: context.trimmedAgent,
|
agentId: context.trimmedAgent,
|
||||||
resolvedPath: context.resolution?.resolvedPath,
|
resolvedPath: context.resolution?.resolvedPath))
|
||||||
sessionKey: request.sessionKey))
|
|
||||||
|
|
||||||
switch decision {
|
switch decision {
|
||||||
case .deny:
|
case .deny:
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ actor GatewayConnection {
|
|||||||
case channelsLogout = "channels.logout"
|
case channelsLogout = "channels.logout"
|
||||||
case modelsList = "models.list"
|
case modelsList = "models.list"
|
||||||
case chatHistory = "chat.history"
|
case chatHistory = "chat.history"
|
||||||
case sessionsPreview = "sessions.preview"
|
|
||||||
case chatSend = "chat.send"
|
case chatSend = "chat.send"
|
||||||
case chatAbort = "chat.abort"
|
case chatAbort = "chat.abort"
|
||||||
case skillsStatus = "skills.status"
|
case skillsStatus = "skills.status"
|
||||||
@@ -250,11 +249,6 @@ actor GatewayConnection {
|
|||||||
await self.configure(url: cfg.url, token: cfg.token, password: cfg.password)
|
await self.configure(url: cfg.url, token: cfg.token, password: cfg.password)
|
||||||
}
|
}
|
||||||
|
|
||||||
func authSource() async -> GatewayAuthSource? {
|
|
||||||
guard let client else { return nil }
|
|
||||||
return await client.authSource()
|
|
||||||
}
|
|
||||||
|
|
||||||
func shutdown() async {
|
func shutdown() async {
|
||||||
if let client {
|
if let client {
|
||||||
await client.shutdown()
|
await client.shutdown()
|
||||||
@@ -541,30 +535,6 @@ extension GatewayConnection {
|
|||||||
return try await self.requestDecoded(method: .skillsUpdate, params: params)
|
return try await self.requestDecoded(method: .skillsUpdate, params: params)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Sessions
|
|
||||||
|
|
||||||
func sessionsPreview(
|
|
||||||
keys: [String],
|
|
||||||
limit: Int? = nil,
|
|
||||||
maxChars: Int? = nil,
|
|
||||||
timeoutMs: Int? = nil) async throws -> ClawdbotSessionsPreviewPayload
|
|
||||||
{
|
|
||||||
let resolvedKeys = keys
|
|
||||||
.map { self.canonicalizeSessionKey($0) }
|
|
||||||
.filter { !$0.isEmpty }
|
|
||||||
if resolvedKeys.isEmpty {
|
|
||||||
return ClawdbotSessionsPreviewPayload(ts: 0, previews: [])
|
|
||||||
}
|
|
||||||
var params: [String: AnyCodable] = ["keys": AnyCodable(resolvedKeys)]
|
|
||||||
if let limit { params["limit"] = AnyCodable(limit) }
|
|
||||||
if let maxChars { params["maxChars"] = AnyCodable(maxChars) }
|
|
||||||
let timeout = timeoutMs.map { Double($0) }
|
|
||||||
return try await self.requestDecoded(
|
|
||||||
method: .sessionsPreview,
|
|
||||||
params: params,
|
|
||||||
timeoutMs: timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Chat
|
// MARK: - Chat
|
||||||
|
|
||||||
func chatHistory(
|
func chatHistory(
|
||||||
|
|||||||
@@ -482,7 +482,7 @@ actor GatewayEndpointStore {
|
|||||||
let bind = GatewayEndpointStore.resolveGatewayBindMode(
|
let bind = GatewayEndpointStore.resolveGatewayBindMode(
|
||||||
root: root,
|
root: root,
|
||||||
env: ProcessInfo.processInfo.environment)
|
env: ProcessInfo.processInfo.environment)
|
||||||
guard bind == "tailnet" else { return nil }
|
guard bind == "auto" else { return nil }
|
||||||
|
|
||||||
let currentHost = currentURL.host?.lowercased() ?? ""
|
let currentHost = currentURL.host?.lowercased() ?? ""
|
||||||
guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil }
|
guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil }
|
||||||
@@ -560,13 +560,16 @@ actor GatewayEndpointStore {
|
|||||||
{
|
{
|
||||||
switch bindMode {
|
switch bindMode {
|
||||||
case "tailnet":
|
case "tailnet":
|
||||||
tailscaleIP ?? "127.0.0.1"
|
return tailscaleIP ?? "127.0.0.1"
|
||||||
case "auto":
|
case "auto":
|
||||||
"127.0.0.1"
|
if let tailscaleIP, !tailscaleIP.isEmpty {
|
||||||
|
return tailscaleIP
|
||||||
|
}
|
||||||
|
return "127.0.0.1"
|
||||||
case "custom":
|
case "custom":
|
||||||
customBindHost ?? "127.0.0.1"
|
return customBindHost ?? "127.0.0.1"
|
||||||
default:
|
default:
|
||||||
"127.0.0.1"
|
return "127.0.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ extension GatewayLaunchAgentManager {
|
|||||||
quiet: Bool) async -> CommandResult
|
quiet: Bool) async -> CommandResult
|
||||||
{
|
{
|
||||||
let command = CommandResolver.clawdbotCommand(
|
let command = CommandResolver.clawdbotCommand(
|
||||||
subcommand: "gateway",
|
subcommand: "daemon",
|
||||||
extraArgs: self.withJsonFlag(args),
|
extraArgs: self.withJsonFlag(args),
|
||||||
// Launchd management must always run locally, even if remote mode is configured.
|
// Launchd management must always run locally, even if remote mode is configured.
|
||||||
configRoot: ["gateway": ["mode": "local"]])
|
configRoot: ["gateway": ["mode": "local"]])
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import AppKit
|
|||||||
import ClawdbotDiscovery
|
import ClawdbotDiscovery
|
||||||
import ClawdbotIPC
|
import ClawdbotIPC
|
||||||
import ClawdbotKit
|
import ClawdbotKit
|
||||||
|
import CoreLocation
|
||||||
import Observation
|
import Observation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct GeneralSettings: View {
|
struct GeneralSettings: View {
|
||||||
@Bindable var state: AppState
|
@Bindable var state: AppState
|
||||||
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
|
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
|
||||||
|
@AppStorage(locationModeKey) private var locationModeRaw: String = ClawdbotLocationMode.off.rawValue
|
||||||
|
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
|
||||||
private let healthStore = HealthStore.shared
|
private let healthStore = HealthStore.shared
|
||||||
private let gatewayManager = GatewayProcessManager.shared
|
private let gatewayManager = GatewayProcessManager.shared
|
||||||
@State private var gatewayDiscovery = GatewayDiscoveryModel(
|
@State private var gatewayDiscovery = GatewayDiscoveryModel(
|
||||||
@@ -17,6 +20,7 @@ struct GeneralSettings: View {
|
|||||||
@State private var showRemoteAdvanced = false
|
@State private var showRemoteAdvanced = false
|
||||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||||
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
|
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
|
||||||
|
@State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(.vertical) {
|
ScrollView(.vertical) {
|
||||||
@@ -56,6 +60,27 @@ struct GeneralSettings: View {
|
|||||||
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
||||||
binding: self.$cameraEnabled)
|
binding: self.$cameraEnabled)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Location Access")
|
||||||
|
.font(.body)
|
||||||
|
|
||||||
|
Picker("", selection: self.$locationModeRaw) {
|
||||||
|
Text("Off").tag(ClawdbotLocationMode.off.rawValue)
|
||||||
|
Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue)
|
||||||
|
Text("Always").tag(ClawdbotLocationMode.always.rawValue)
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
|
||||||
|
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
|
||||||
|
.disabled(self.locationMode == .off)
|
||||||
|
|
||||||
|
Text("Always may require System Settings to approve background location.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
SettingsToggleRow(
|
SettingsToggleRow(
|
||||||
title: "Enable Peekaboo Bridge",
|
title: "Enable Peekaboo Bridge",
|
||||||
subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.",
|
subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.",
|
||||||
@@ -81,12 +106,27 @@ struct GeneralSettings: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
guard !self.isPreview else { return }
|
guard !self.isPreview else { return }
|
||||||
self.refreshGatewayStatus()
|
self.refreshGatewayStatus()
|
||||||
|
self.lastLocationModeRaw = self.locationModeRaw
|
||||||
}
|
}
|
||||||
.onChange(of: self.state.canvasEnabled) { _, enabled in
|
.onChange(of: self.state.canvasEnabled) { _, enabled in
|
||||||
if !enabled {
|
if !enabled {
|
||||||
CanvasManager.shared.hideAll()
|
CanvasManager.shared.hideAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: self.locationModeRaw) { _, newValue in
|
||||||
|
let previous = self.lastLocationModeRaw
|
||||||
|
self.lastLocationModeRaw = newValue
|
||||||
|
guard let mode = ClawdbotLocationMode(rawValue: newValue) else { return }
|
||||||
|
Task {
|
||||||
|
let granted = await self.requestLocationAuthorization(mode: mode)
|
||||||
|
if !granted {
|
||||||
|
await MainActor.run {
|
||||||
|
self.locationModeRaw = previous
|
||||||
|
self.lastLocationModeRaw = previous
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var activeBinding: Binding<Bool> {
|
private var activeBinding: Binding<Bool> {
|
||||||
@@ -95,6 +135,26 @@ struct GeneralSettings: View {
|
|||||||
set: { self.state.isPaused = !$0 })
|
set: { self.state.isPaused = !$0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var locationMode: ClawdbotLocationMode {
|
||||||
|
ClawdbotLocationMode(rawValue: self.locationModeRaw) ?? .off
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestLocationAuthorization(mode: ClawdbotLocationMode) async -> Bool {
|
||||||
|
guard mode != .off else { return true }
|
||||||
|
guard CLLocationManager.locationServicesEnabled() else {
|
||||||
|
await MainActor.run { LocationPermissionHelper.openSettings() }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = CLLocationManager().authorizationStatus
|
||||||
|
let requireAlways = mode == .always
|
||||||
|
if PermissionManager.isLocationAuthorized(status: status, requireAlways: requireAlways) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
let updated = await LocationPermissionRequester.shared.request(always: requireAlways)
|
||||||
|
return PermissionManager.isLocationAuthorized(status: updated, requireAlways: requireAlways)
|
||||||
|
}
|
||||||
|
|
||||||
private var connectionSection: some View {
|
private var connectionSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text("Clawdbot runs")
|
Text("Clawdbot runs")
|
||||||
@@ -212,11 +272,6 @@ struct GeneralSettings: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
if let authLabel = ControlChannel.shared.authSourceLabel {
|
|
||||||
Text(authLabel)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Text("Tip: enable Tailscale for stable remote access.")
|
Text("Tip: enable Tailscale for stable remote access.")
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import Observation
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -19,7 +18,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
private var isMenuOpen = false
|
private var isMenuOpen = false
|
||||||
private var lastKnownMenuWidth: CGFloat?
|
private var lastKnownMenuWidth: CGFloat?
|
||||||
private var menuOpenWidth: CGFloat?
|
private var menuOpenWidth: CGFloat?
|
||||||
private var isObservingControlChannel = false
|
|
||||||
|
|
||||||
private var cachedSnapshot: SessionStoreSnapshot?
|
private var cachedSnapshot: SessionStoreSnapshot?
|
||||||
private var cachedErrorText: String?
|
private var cachedErrorText: String?
|
||||||
@@ -52,7 +50,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
self.loadTask = Task { await self.refreshCache(force: true) }
|
self.loadTask = Task { await self.refreshCache(force: true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
self.startControlChannelObservation()
|
|
||||||
self.nodesStore.start()
|
self.nodesStore.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,50 +96,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
self.cancelPreviewTasks()
|
self.cancelPreviewTasks()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startControlChannelObservation() {
|
|
||||||
guard !self.isObservingControlChannel else { return }
|
|
||||||
self.isObservingControlChannel = true
|
|
||||||
self.observeControlChannelState()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func observeControlChannelState() {
|
|
||||||
withObservationTracking {
|
|
||||||
_ = ControlChannel.shared.state
|
|
||||||
} onChange: { [weak self] in
|
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
self.handleControlChannelStateChange()
|
|
||||||
self.observeControlChannelState()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleControlChannelStateChange() {
|
|
||||||
guard self.isMenuOpen, let menu = self.statusItem?.menu else { return }
|
|
||||||
self.loadTask?.cancel()
|
|
||||||
self.loadTask = Task { [weak self, weak menu] in
|
|
||||||
guard let self, let menu else { return }
|
|
||||||
await self.refreshCache(force: true)
|
|
||||||
await self.refreshUsageCache(force: true)
|
|
||||||
await self.refreshCostUsageCache(force: true)
|
|
||||||
await MainActor.run {
|
|
||||||
guard self.isMenuOpen else { return }
|
|
||||||
self.inject(into: menu)
|
|
||||||
self.injectNodes(into: menu)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.nodesLoadTask?.cancel()
|
|
||||||
self.nodesLoadTask = Task { [weak self, weak menu] in
|
|
||||||
guard let self, let menu else { return }
|
|
||||||
await self.nodesStore.refresh()
|
|
||||||
await MainActor.run {
|
|
||||||
guard self.isMenuOpen else { return }
|
|
||||||
self.injectNodes(into: menu)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func menuNeedsUpdate(_ menu: NSMenu) {
|
func menuNeedsUpdate(_ menu: NSMenu) {
|
||||||
self.originalDelegate?.menuNeedsUpdate?(menu)
|
self.originalDelegate?.menuNeedsUpdate?(menu)
|
||||||
}
|
}
|
||||||
@@ -188,23 +141,14 @@ extension MenuSessionsInjector {
|
|||||||
if rhs.key == mainKey { return false }
|
if rhs.key == mainKey { return false }
|
||||||
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
|
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
|
||||||
}
|
}
|
||||||
if !rows.isEmpty {
|
|
||||||
let previewKeys = rows.prefix(20).map(\.key)
|
|
||||||
let task = Task {
|
|
||||||
await SessionMenuPreviewLoader.prewarm(sessionKeys: previewKeys, maxItems: 10)
|
|
||||||
}
|
|
||||||
self.previewTasks.append(task)
|
|
||||||
}
|
|
||||||
|
|
||||||
let headerItem = NSMenuItem()
|
let headerItem = NSMenuItem()
|
||||||
headerItem.tag = self.tag
|
headerItem.tag = self.tag
|
||||||
headerItem.isEnabled = false
|
headerItem.isEnabled = false
|
||||||
let statusText = self
|
|
||||||
.cachedErrorText ?? (isConnected ? nil : self.controlChannelStatusText(for: channelState))
|
|
||||||
let hosted = self.makeHostedView(
|
let hosted = self.makeHostedView(
|
||||||
rootView: AnyView(MenuSessionsHeaderView(
|
rootView: AnyView(MenuSessionsHeaderView(
|
||||||
count: rows.count,
|
count: rows.count,
|
||||||
statusText: statusText)),
|
statusText: isConnected ? nil : self.controlChannelStatusText(for: channelState))),
|
||||||
width: width,
|
width: width,
|
||||||
highlighted: false)
|
highlighted: false)
|
||||||
headerItem.view = hosted
|
headerItem.view = hosted
|
||||||
@@ -654,11 +598,8 @@ extension MenuSessionsInjector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard self.isControlChannelConnected else {
|
guard self.isControlChannelConnected else {
|
||||||
if self.cachedSnapshot != nil {
|
self.cachedSnapshot = nil
|
||||||
self.cachedErrorText = "Gateway disconnected (showing cached)"
|
self.cachedErrorText = nil
|
||||||
} else {
|
|
||||||
self.cachedErrorText = nil
|
|
||||||
}
|
|
||||||
self.cacheUpdatedAt = Date()
|
self.cacheUpdatedAt = Date()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -683,6 +624,8 @@ extension MenuSessionsInjector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard self.isControlChannelConnected else {
|
guard self.isControlChannelConnected else {
|
||||||
|
self.cachedUsageSummary = nil
|
||||||
|
self.cachedUsageErrorText = nil
|
||||||
self.usageCacheUpdatedAt = Date()
|
self.usageCacheUpdatedAt = Date()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -705,6 +648,8 @@ extension MenuSessionsInjector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard self.isControlChannelConnected else {
|
guard self.isControlChannelConnected else {
|
||||||
|
self.cachedCostSummary = nil
|
||||||
|
self.cachedCostErrorText = nil
|
||||||
self.costCacheUpdatedAt = Date()
|
self.costCacheUpdatedAt = Date()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -679,8 +679,7 @@ actor MacNodeRuntime {
|
|||||||
security: context.security.rawValue,
|
security: context.security.rawValue,
|
||||||
ask: context.ask.rawValue,
|
ask: context.ask.rawValue,
|
||||||
agentId: context.agentId,
|
agentId: context.agentId,
|
||||||
resolvedPath: context.resolution?.resolvedPath,
|
resolvedPath: context.resolution?.resolvedPath))
|
||||||
sessionKey: context.sessionKey))
|
|
||||||
}
|
}
|
||||||
switch decision {
|
switch decision {
|
||||||
case .deny:
|
case .deny:
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import ClawdbotIPC
|
import ClawdbotIPC
|
||||||
import ClawdbotKit
|
|
||||||
import CoreLocation
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct PermissionsSettings: View {
|
struct PermissionsSettings: View {
|
||||||
@@ -19,8 +17,6 @@ struct PermissionsSettings: View {
|
|||||||
.padding(.horizontal, 2)
|
.padding(.horizontal, 2)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
|
|
||||||
LocationAccessSettings()
|
|
||||||
|
|
||||||
Button("Restart onboarding") { self.showOnboarding() }
|
Button("Restart onboarding") { self.showOnboarding() }
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -30,72 +26,6 @@ struct PermissionsSettings: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct LocationAccessSettings: View {
|
|
||||||
@AppStorage(locationModeKey) private var locationModeRaw: String = ClawdbotLocationMode.off.rawValue
|
|
||||||
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
|
|
||||||
@State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Text("Location Access")
|
|
||||||
.font(.body)
|
|
||||||
|
|
||||||
Picker("", selection: self.$locationModeRaw) {
|
|
||||||
Text("Off").tag(ClawdbotLocationMode.off.rawValue)
|
|
||||||
Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue)
|
|
||||||
Text("Always").tag(ClawdbotLocationMode.always.rawValue)
|
|
||||||
}
|
|
||||||
.labelsHidden()
|
|
||||||
.pickerStyle(.menu)
|
|
||||||
|
|
||||||
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
|
|
||||||
.disabled(self.locationMode == .off)
|
|
||||||
|
|
||||||
Text("Always may require System Settings to approve background location.")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
self.lastLocationModeRaw = self.locationModeRaw
|
|
||||||
}
|
|
||||||
.onChange(of: self.locationModeRaw) { _, newValue in
|
|
||||||
let previous = self.lastLocationModeRaw
|
|
||||||
self.lastLocationModeRaw = newValue
|
|
||||||
guard let mode = ClawdbotLocationMode(rawValue: newValue) else { return }
|
|
||||||
Task {
|
|
||||||
let granted = await self.requestLocationAuthorization(mode: mode)
|
|
||||||
if !granted {
|
|
||||||
await MainActor.run {
|
|
||||||
self.locationModeRaw = previous
|
|
||||||
self.lastLocationModeRaw = previous
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var locationMode: ClawdbotLocationMode {
|
|
||||||
ClawdbotLocationMode(rawValue: self.locationModeRaw) ?? .off
|
|
||||||
}
|
|
||||||
|
|
||||||
private func requestLocationAuthorization(mode: ClawdbotLocationMode) async -> Bool {
|
|
||||||
guard mode != .off else { return true }
|
|
||||||
guard CLLocationManager.locationServicesEnabled() else {
|
|
||||||
await MainActor.run { LocationPermissionHelper.openSettings() }
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
let status = CLLocationManager().authorizationStatus
|
|
||||||
let requireAlways = mode == .always
|
|
||||||
if PermissionManager.isLocationAuthorized(status: status, requireAlways: requireAlways) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
let updated = await LocationPermissionRequester.shared.request(always: requireAlways)
|
|
||||||
return PermissionManager.isLocationAuthorized(status: updated, requireAlways: requireAlways)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct PermissionStatusList: View {
|
struct PermissionStatusList: View {
|
||||||
let status: [Capability: Bool]
|
let status: [Capability: Bool]
|
||||||
let refresh: () async -> Void
|
let refresh: () async -> Void
|
||||||
|
|||||||
@@ -184,14 +184,6 @@ actor PortGuardian {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func isListening(port: Int, pid: Int32? = nil) async -> Bool {
|
|
||||||
let listeners = await self.listeners(on: port)
|
|
||||||
if let pid {
|
|
||||||
return listeners.contains(where: { $0.pid == pid })
|
|
||||||
}
|
|
||||||
return !listeners.isEmpty
|
|
||||||
}
|
|
||||||
|
|
||||||
private func listeners(on port: Int) async -> [Listener] {
|
private func listeners(on port: Int) async -> [Listener] {
|
||||||
let res = await ShellExecutor.run(
|
let res = await ShellExecutor.run(
|
||||||
command: ["lsof", "-nP", "-iTCP:\(port)", "-sTCP:LISTEN", "-Fpcn"],
|
command: ["lsof", "-nP", "-iTCP:\(port)", "-sTCP:LISTEN", "-Fpcn"],
|
||||||
|
|||||||
@@ -20,13 +20,11 @@ actor RemoteTunnelManager {
|
|||||||
tunnel.process.isRunning,
|
tunnel.process.isRunning,
|
||||||
let local = tunnel.localPort
|
let local = tunnel.localPort
|
||||||
{
|
{
|
||||||
let pid = tunnel.process.processIdentifier
|
if await self.isTunnelHealthy(port: local) {
|
||||||
if await PortGuardian.shared.isListening(port: Int(local), pid: pid) {
|
|
||||||
self.logger.info("reusing active SSH tunnel localPort=\(local, privacy: .public)")
|
self.logger.info("reusing active SSH tunnel localPort=\(local, privacy: .public)")
|
||||||
return local
|
return local
|
||||||
}
|
}
|
||||||
self.logger.error(
|
self.logger.error("active SSH tunnel on port \(local, privacy: .public) is unhealthy; restarting")
|
||||||
"active SSH tunnel on port \(local, privacy: .public) is not listening; restarting")
|
|
||||||
await self.beginRestart()
|
await self.beginRestart()
|
||||||
tunnel.terminate()
|
tunnel.terminate()
|
||||||
self.controlTunnel = nil
|
self.controlTunnel = nil
|
||||||
@@ -37,11 +35,19 @@ actor RemoteTunnelManager {
|
|||||||
if let desc = await PortGuardian.shared.describe(port: Int(desiredPort)),
|
if let desc = await PortGuardian.shared.describe(port: Int(desiredPort)),
|
||||||
self.isSshProcess(desc)
|
self.isSshProcess(desc)
|
||||||
{
|
{
|
||||||
self.logger.info(
|
if await self.isTunnelHealthy(port: desiredPort) {
|
||||||
"reusing existing SSH tunnel listener " +
|
self.logger.info(
|
||||||
"localPort=\(desiredPort, privacy: .public) " +
|
"reusing existing SSH tunnel listener " +
|
||||||
"pid=\(desc.pid, privacy: .public)")
|
"localPort=\(desiredPort, privacy: .public) " +
|
||||||
return desiredPort
|
"pid=\(desc.pid, privacy: .public)")
|
||||||
|
return desiredPort
|
||||||
|
}
|
||||||
|
if self.restartInFlight {
|
||||||
|
self.logger.info("control tunnel restart in flight; skip stale tunnel cleanup")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
await self.beginRestart()
|
||||||
|
await self.cleanupStaleTunnel(desc: desc, port: desiredPort)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -82,6 +88,10 @@ actor RemoteTunnelManager {
|
|||||||
self.controlTunnel = nil
|
self.controlTunnel = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func isTunnelHealthy(port: UInt16) async -> Bool {
|
||||||
|
await PortGuardian.shared.probeGatewayHealth(port: Int(port))
|
||||||
|
}
|
||||||
|
|
||||||
private func isSshProcess(_ desc: PortGuardian.Descriptor) -> Bool {
|
private func isSshProcess(_ desc: PortGuardian.Descriptor) -> Bool {
|
||||||
let cmd = desc.command.lowercased()
|
let cmd = desc.command.lowercased()
|
||||||
if cmd.contains("ssh") { return true }
|
if cmd.contains("ssh") { return true }
|
||||||
@@ -118,5 +128,21 @@ actor RemoteTunnelManager {
|
|||||||
try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
|
try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep tunnel reuse lightweight; restart only when the listener disappears.
|
private func cleanupStaleTunnel(desc: PortGuardian.Descriptor, port: UInt16) async {
|
||||||
|
let pid = desc.pid
|
||||||
|
self.logger.error(
|
||||||
|
"stale SSH tunnel detected on port \(port, privacy: .public) pid \(pid, privacy: .public)")
|
||||||
|
let killed = await self.kill(pid: pid)
|
||||||
|
if !killed {
|
||||||
|
self.logger.error("failed to terminate stale SSH tunnel pid \(pid, privacy: .public)")
|
||||||
|
}
|
||||||
|
await PortGuardian.shared.removeRecord(pid: pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func kill(pid: Int32) async -> Bool {
|
||||||
|
let term = await ShellExecutor.run(command: ["kill", "-TERM", "\(pid)"], cwd: nil, env: nil, timeout: 2)
|
||||||
|
if term.ok { return true }
|
||||||
|
let sigkill = await ShellExecutor.run(command: ["kill", "-KILL", "\(pid)"], cwd: nil, env: nil, timeout: 2)
|
||||||
|
return sigkill.ok
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import ClawdbotChatUI
|
import ClawdbotChatUI
|
||||||
import ClawdbotKit
|
import ClawdbotKit
|
||||||
import ClawdbotProtocol
|
|
||||||
import OSLog
|
import OSLog
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@@ -32,80 +31,31 @@ actor SessionPreviewCache {
|
|||||||
static let shared = SessionPreviewCache()
|
static let shared = SessionPreviewCache()
|
||||||
|
|
||||||
private struct CacheEntry {
|
private struct CacheEntry {
|
||||||
let snapshot: SessionMenuPreviewSnapshot
|
let items: [SessionPreviewItem]
|
||||||
let updatedAt: Date
|
let updatedAt: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
private var entries: [String: CacheEntry] = [:]
|
private var entries: [String: CacheEntry] = [:]
|
||||||
|
|
||||||
func cachedSnapshot(for sessionKey: String, maxAge: TimeInterval) -> SessionMenuPreviewSnapshot? {
|
func cachedItems(for sessionKey: String, maxAge: TimeInterval) -> [SessionPreviewItem]? {
|
||||||
guard let entry = self.entries[sessionKey] else { return nil }
|
guard let entry = self.entries[sessionKey] else { return nil }
|
||||||
guard Date().timeIntervalSince(entry.updatedAt) < maxAge else { return nil }
|
guard Date().timeIntervalSince(entry.updatedAt) < maxAge else { return nil }
|
||||||
return entry.snapshot
|
return entry.items
|
||||||
}
|
}
|
||||||
|
|
||||||
func store(snapshot: SessionMenuPreviewSnapshot, for sessionKey: String) {
|
func store(items: [SessionPreviewItem], for sessionKey: String) {
|
||||||
self.entries[sessionKey] = CacheEntry(snapshot: snapshot, updatedAt: Date())
|
self.entries[sessionKey] = CacheEntry(items: items, updatedAt: Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
func lastSnapshot(for sessionKey: String) -> SessionMenuPreviewSnapshot? {
|
func lastItems(for sessionKey: String) -> [SessionPreviewItem]? {
|
||||||
self.entries[sessionKey]?.snapshot
|
self.entries[sessionKey]?.items
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actor SessionPreviewLimiter {
|
|
||||||
static let shared = SessionPreviewLimiter(maxConcurrent: 2)
|
|
||||||
|
|
||||||
private let maxConcurrent: Int
|
|
||||||
private var available: Int
|
|
||||||
private var waitQueue: [UUID] = []
|
|
||||||
private var waiters: [UUID: CheckedContinuation<Void, Never>] = [:]
|
|
||||||
|
|
||||||
init(maxConcurrent: Int) {
|
|
||||||
let normalized = max(1, maxConcurrent)
|
|
||||||
self.maxConcurrent = normalized
|
|
||||||
self.available = normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
func withPermit<T>(_ operation: () async throws -> T) async throws -> T {
|
|
||||||
await self.acquire()
|
|
||||||
defer { self.release() }
|
|
||||||
if Task.isCancelled { throw CancellationError() }
|
|
||||||
return try await operation()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func acquire() async {
|
|
||||||
if self.available > 0 {
|
|
||||||
self.available -= 1
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let id = UUID()
|
|
||||||
await withCheckedContinuation { cont in
|
|
||||||
self.waitQueue.append(id)
|
|
||||||
self.waiters[id] = cont
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func release() {
|
|
||||||
if let id = self.waitQueue.first {
|
|
||||||
self.waitQueue.removeFirst()
|
|
||||||
if let cont = self.waiters.removeValue(forKey: id) {
|
|
||||||
cont.resume()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.available = min(self.available + 1, self.maxConcurrent)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
extension SessionPreviewCache {
|
extension SessionPreviewCache {
|
||||||
func _testSet(
|
func _testSet(items: [SessionPreviewItem], for sessionKey: String, updatedAt: Date = Date()) {
|
||||||
snapshot: SessionMenuPreviewSnapshot,
|
self.entries[sessionKey] = CacheEntry(items: items, updatedAt: updatedAt)
|
||||||
for sessionKey: String,
|
|
||||||
updatedAt: Date = Date())
|
|
||||||
{
|
|
||||||
self.entries[sessionKey] = CacheEntry(snapshot: snapshot, updatedAt: updatedAt)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func _testReset() {
|
func _testReset() {
|
||||||
@@ -224,44 +174,36 @@ enum SessionMenuPreviewLoader {
|
|||||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview")
|
private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview")
|
||||||
private static let previewTimeoutSeconds: Double = 4
|
private static let previewTimeoutSeconds: Double = 4
|
||||||
private static let cacheMaxAgeSeconds: TimeInterval = 30
|
private static let cacheMaxAgeSeconds: TimeInterval = 30
|
||||||
private static let previewMaxChars = 240
|
|
||||||
|
|
||||||
private struct PreviewTimeoutError: LocalizedError {
|
private struct PreviewTimeoutError: LocalizedError {
|
||||||
var errorDescription: String? { "preview timeout" }
|
var errorDescription: String? { "preview timeout" }
|
||||||
}
|
}
|
||||||
|
|
||||||
static func prewarm(sessionKeys: [String], maxItems: Int) async {
|
|
||||||
let keys = self.uniqueKeys(sessionKeys)
|
|
||||||
guard !keys.isEmpty else { return }
|
|
||||||
do {
|
|
||||||
let payload = try await self.requestPreview(keys: keys, maxItems: maxItems)
|
|
||||||
await self.cache(payload: payload, maxItems: maxItems)
|
|
||||||
} catch {
|
|
||||||
if self.isUnknownMethodError(error) { return }
|
|
||||||
let errorDescription = String(describing: error)
|
|
||||||
Self.logger.debug(
|
|
||||||
"Session preview prewarm failed count=\(keys.count, privacy: .public) " +
|
|
||||||
"error=\(errorDescription, privacy: .public)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func load(sessionKey: String, maxItems: Int) async -> SessionMenuPreviewSnapshot {
|
static func load(sessionKey: String, maxItems: Int) async -> SessionMenuPreviewSnapshot {
|
||||||
if let cached = await SessionPreviewCache.shared.cachedSnapshot(
|
if let cached = await SessionPreviewCache.shared.cachedItems(for: sessionKey, maxAge: cacheMaxAgeSeconds) {
|
||||||
for: sessionKey,
|
return self.snapshot(from: cached)
|
||||||
maxAge: cacheMaxAgeSeconds)
|
|
||||||
{
|
|
||||||
return cached
|
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let snapshot = try await self.fetchSnapshot(sessionKey: sessionKey, maxItems: maxItems)
|
let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
|
||||||
await SessionPreviewCache.shared.store(snapshot: snapshot, for: sessionKey)
|
let payload = try await AsyncTimeout.withTimeout(
|
||||||
return snapshot
|
seconds: self.previewTimeoutSeconds,
|
||||||
|
onTimeout: { PreviewTimeoutError() },
|
||||||
|
operation: {
|
||||||
|
try await GatewayConnection.shared.chatHistory(
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
limit: self.previewLimit(for: maxItems),
|
||||||
|
timeoutMs: timeoutMs)
|
||||||
|
})
|
||||||
|
let built = Self.previewItems(from: payload, maxItems: maxItems)
|
||||||
|
await SessionPreviewCache.shared.store(items: built, for: sessionKey)
|
||||||
|
return Self.snapshot(from: built)
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
return SessionMenuPreviewSnapshot(items: [], status: .loading)
|
return SessionMenuPreviewSnapshot(items: [], status: .loading)
|
||||||
} catch {
|
} catch {
|
||||||
if let fallback = await SessionPreviewCache.shared.lastSnapshot(for: sessionKey) {
|
let fallback = await SessionPreviewCache.shared.lastItems(for: sessionKey)
|
||||||
return fallback
|
if let fallback {
|
||||||
|
return Self.snapshot(from: fallback)
|
||||||
}
|
}
|
||||||
let errorDescription = String(describing: error)
|
let errorDescription = String(describing: error)
|
||||||
Self.logger.warning(
|
Self.logger.warning(
|
||||||
@@ -271,120 +213,18 @@ enum SessionMenuPreviewLoader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func fetchSnapshot(sessionKey: String, maxItems: Int) async throws -> SessionMenuPreviewSnapshot {
|
|
||||||
do {
|
|
||||||
let payload = try await self.requestPreview(keys: [sessionKey], maxItems: maxItems)
|
|
||||||
if let entry = payload.previews.first(where: { $0.key == sessionKey }) ?? payload.previews.first {
|
|
||||||
return self.snapshot(from: entry, maxItems: maxItems)
|
|
||||||
}
|
|
||||||
return SessionMenuPreviewSnapshot(items: [], status: .error("Preview unavailable"))
|
|
||||||
} catch {
|
|
||||||
if self.isUnknownMethodError(error) {
|
|
||||||
return try await self.fetchHistorySnapshot(sessionKey: sessionKey, maxItems: maxItems)
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func requestPreview(
|
|
||||||
keys: [String],
|
|
||||||
maxItems: Int) async throws -> ClawdbotSessionsPreviewPayload
|
|
||||||
{
|
|
||||||
let boundedItems = self.normalizeMaxItems(maxItems)
|
|
||||||
let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
|
|
||||||
return try await SessionPreviewLimiter.shared.withPermit {
|
|
||||||
try await AsyncTimeout.withTimeout(
|
|
||||||
seconds: self.previewTimeoutSeconds,
|
|
||||||
onTimeout: { PreviewTimeoutError() },
|
|
||||||
operation: {
|
|
||||||
try await GatewayConnection.shared.sessionsPreview(
|
|
||||||
keys: keys,
|
|
||||||
limit: boundedItems,
|
|
||||||
maxChars: self.previewMaxChars,
|
|
||||||
timeoutMs: timeoutMs)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func fetchHistorySnapshot(
|
|
||||||
sessionKey: String,
|
|
||||||
maxItems: Int) async throws -> SessionMenuPreviewSnapshot
|
|
||||||
{
|
|
||||||
let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
|
|
||||||
let payload = try await SessionPreviewLimiter.shared.withPermit {
|
|
||||||
try await AsyncTimeout.withTimeout(
|
|
||||||
seconds: self.previewTimeoutSeconds,
|
|
||||||
onTimeout: { PreviewTimeoutError() },
|
|
||||||
operation: {
|
|
||||||
try await GatewayConnection.shared.chatHistory(
|
|
||||||
sessionKey: sessionKey,
|
|
||||||
limit: self.previewLimit(for: maxItems),
|
|
||||||
timeoutMs: timeoutMs)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
let built = Self.previewItems(from: payload, maxItems: maxItems)
|
|
||||||
return Self.snapshot(from: built)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func snapshot(from items: [SessionPreviewItem]) -> SessionMenuPreviewSnapshot {
|
private static func snapshot(from items: [SessionPreviewItem]) -> SessionMenuPreviewSnapshot {
|
||||||
SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready)
|
SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func snapshot(
|
|
||||||
from entry: ClawdbotSessionPreviewEntry,
|
|
||||||
maxItems: Int) -> SessionMenuPreviewSnapshot
|
|
||||||
{
|
|
||||||
let items = self.previewItems(from: entry, maxItems: maxItems)
|
|
||||||
let normalized = entry.status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
||||||
switch normalized {
|
|
||||||
case "ok":
|
|
||||||
return SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready)
|
|
||||||
case "empty":
|
|
||||||
return SessionMenuPreviewSnapshot(items: items, status: .empty)
|
|
||||||
case "missing":
|
|
||||||
return SessionMenuPreviewSnapshot(items: items, status: .error("Session missing"))
|
|
||||||
default:
|
|
||||||
return SessionMenuPreviewSnapshot(items: items, status: .error("Preview unavailable"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func cache(payload: ClawdbotSessionsPreviewPayload, maxItems: Int) async {
|
|
||||||
for entry in payload.previews {
|
|
||||||
let snapshot = self.snapshot(from: entry, maxItems: maxItems)
|
|
||||||
await SessionPreviewCache.shared.store(snapshot: snapshot, for: entry.key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func previewLimit(for maxItems: Int) -> Int {
|
private static func previewLimit(for maxItems: Int) -> Int {
|
||||||
let boundedItems = self.normalizeMaxItems(maxItems)
|
min(max(maxItems * 3, 20), 120)
|
||||||
return min(max(boundedItems * 3, 20), 120)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func normalizeMaxItems(_ maxItems: Int) -> Int {
|
|
||||||
max(1, min(maxItems, 50))
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func previewItems(
|
|
||||||
from entry: ClawdbotSessionPreviewEntry,
|
|
||||||
maxItems: Int) -> [SessionPreviewItem]
|
|
||||||
{
|
|
||||||
let boundedItems = self.normalizeMaxItems(maxItems)
|
|
||||||
let built: [SessionPreviewItem] = entry.items.enumerated().compactMap { index, item in
|
|
||||||
let text = item.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !text.isEmpty else { return nil }
|
|
||||||
let role = self.previewRoleFromRaw(item.role)
|
|
||||||
return SessionPreviewItem(id: "\(entry.key)-\(index)", role: role, text: text)
|
|
||||||
}
|
|
||||||
|
|
||||||
let trimmed = built.suffix(boundedItems)
|
|
||||||
return Array(trimmed.reversed())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func previewItems(
|
private static func previewItems(
|
||||||
from payload: ClawdbotChatHistoryPayload,
|
from payload: ClawdbotChatHistoryPayload,
|
||||||
maxItems: Int) -> [SessionPreviewItem]
|
maxItems: Int) -> [SessionPreviewItem]
|
||||||
{
|
{
|
||||||
let boundedItems = self.normalizeMaxItems(maxItems)
|
|
||||||
let raw: [ClawdbotKit.AnyCodable] = payload.messages ?? []
|
let raw: [ClawdbotKit.AnyCodable] = payload.messages ?? []
|
||||||
let messages = self.decodeMessages(raw)
|
let messages = self.decodeMessages(raw)
|
||||||
let built = messages.compactMap { message -> SessionPreviewItem? in
|
let built = messages.compactMap { message -> SessionPreviewItem? in
|
||||||
@@ -395,7 +235,7 @@ enum SessionMenuPreviewLoader {
|
|||||||
return SessionPreviewItem(id: id, role: role, text: text)
|
return SessionPreviewItem(id: id, role: role, text: text)
|
||||||
}
|
}
|
||||||
|
|
||||||
let trimmed = built.suffix(boundedItems)
|
let trimmed = built.suffix(maxItems)
|
||||||
return Array(trimmed.reversed())
|
return Array(trimmed.reversed())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,16 +248,12 @@ enum SessionMenuPreviewLoader {
|
|||||||
|
|
||||||
private static func previewRole(_ raw: String, isTool: Bool) -> PreviewRole {
|
private static func previewRole(_ raw: String, isTool: Bool) -> PreviewRole {
|
||||||
if isTool { return .tool }
|
if isTool { return .tool }
|
||||||
return self.previewRoleFromRaw(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func previewRoleFromRaw(_ raw: String) -> PreviewRole {
|
|
||||||
switch raw.lowercased() {
|
switch raw.lowercased() {
|
||||||
case "user": .user
|
case "user": return .user
|
||||||
case "assistant": .assistant
|
case "assistant": return .assistant
|
||||||
case "system": .system
|
case "system": return .system
|
||||||
case "tool": .tool
|
case "tool": return .tool
|
||||||
default: .other
|
default: return .other
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,16 +316,4 @@ enum SessionMenuPreviewLoader {
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func uniqueKeys(_ keys: [String]) -> [String] {
|
|
||||||
let trimmed = keys.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
||||||
return self.dedupePreservingOrder(trimmed.filter { !$0.isEmpty })
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func isUnknownMethodError(_ error: Error) -> Bool {
|
|
||||||
guard let response = error as? GatewayResponseError else { return false }
|
|
||||||
guard response.code == ErrorCode.invalidRequest.rawValue else { return false }
|
|
||||||
let message = response.message.lowercased()
|
|
||||||
return message.contains("unknown method")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -309,7 +309,7 @@ private func resolveLocalHost(bind: String?) -> String {
|
|||||||
let normalized = (bind ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
let normalized = (bind ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
let tailnetIP = detectTailnetIPv4()
|
let tailnetIP = detectTailnetIPv4()
|
||||||
switch normalized {
|
switch normalized {
|
||||||
case "tailnet":
|
case "tailnet", "auto":
|
||||||
return tailnetIP ?? "127.0.0.1"
|
return tailnetIP ?? "127.0.0.1"
|
||||||
default:
|
default:
|
||||||
return "127.0.0.1"
|
return "127.0.0.1"
|
||||||
|
|||||||
@@ -552,44 +552,6 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct AgentIdentityParams: Codable, Sendable {
|
|
||||||
public let agentid: String?
|
|
||||||
public let sessionkey: String?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
agentid: String?,
|
|
||||||
sessionkey: String?
|
|
||||||
) {
|
|
||||||
self.agentid = agentid
|
|
||||||
self.sessionkey = sessionkey
|
|
||||||
}
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case agentid = "agentId"
|
|
||||||
case sessionkey = "sessionKey"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct AgentIdentityResult: Codable, Sendable {
|
|
||||||
public let agentid: String
|
|
||||||
public let name: String?
|
|
||||||
public let avatar: String?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
agentid: String,
|
|
||||||
name: String?,
|
|
||||||
avatar: String?
|
|
||||||
) {
|
|
||||||
self.agentid = agentid
|
|
||||||
self.name = name
|
|
||||||
self.avatar = avatar
|
|
||||||
}
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case agentid = "agentId"
|
|
||||||
case name
|
|
||||||
case avatar
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct AgentWaitParams: Codable, Sendable {
|
public struct AgentWaitParams: Codable, Sendable {
|
||||||
public let runid: String
|
public let runid: String
|
||||||
public let timeoutms: Int?
|
public let timeoutms: Int?
|
||||||
@@ -925,27 +887,6 @@ public struct SessionsListParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct SessionsPreviewParams: Codable, Sendable {
|
|
||||||
public let keys: [String]
|
|
||||||
public let limit: Int?
|
|
||||||
public let maxchars: Int?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
keys: [String],
|
|
||||||
limit: Int?,
|
|
||||||
maxchars: Int?
|
|
||||||
) {
|
|
||||||
self.keys = keys
|
|
||||||
self.limit = limit
|
|
||||||
self.maxchars = maxchars
|
|
||||||
}
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case keys
|
|
||||||
case limit
|
|
||||||
case maxchars = "maxChars"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct SessionsResolveParams: Codable, Sendable {
|
public struct SessionsResolveParams: Codable, Sendable {
|
||||||
public let key: String?
|
public let key: String?
|
||||||
public let label: String?
|
public let label: String?
|
||||||
@@ -1506,21 +1447,17 @@ public struct WebLoginWaitParams: Codable, Sendable {
|
|||||||
public struct AgentSummary: Codable, Sendable {
|
public struct AgentSummary: Codable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let name: String?
|
public let name: String?
|
||||||
public let identity: [String: AnyCodable]?
|
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: String,
|
id: String,
|
||||||
name: String?,
|
name: String?
|
||||||
identity: [String: AnyCodable]?
|
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.name = name
|
self.name = name
|
||||||
self.identity = identity
|
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case name
|
case name
|
||||||
case identity
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import Testing
|
|
||||||
@testable import Clawdbot
|
|
||||||
|
|
||||||
@Suite
|
|
||||||
@MainActor
|
|
||||||
struct ExecApprovalsGatewayPrompterTests {
|
|
||||||
@Test func sessionMatchPrefersActiveSession() {
|
|
||||||
let matches = ExecApprovalsGatewayPrompter._testShouldPresent(
|
|
||||||
mode: .remote,
|
|
||||||
activeSession: " main ",
|
|
||||||
requestSession: "main",
|
|
||||||
lastInputSeconds: nil)
|
|
||||||
#expect(matches)
|
|
||||||
|
|
||||||
let mismatched = ExecApprovalsGatewayPrompter._testShouldPresent(
|
|
||||||
mode: .remote,
|
|
||||||
activeSession: "other",
|
|
||||||
requestSession: "main",
|
|
||||||
lastInputSeconds: 0)
|
|
||||||
#expect(!mismatched)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func sessionFallbackUsesRecentActivity() {
|
|
||||||
let recent = ExecApprovalsGatewayPrompter._testShouldPresent(
|
|
||||||
mode: .remote,
|
|
||||||
activeSession: nil,
|
|
||||||
requestSession: "main",
|
|
||||||
lastInputSeconds: 10,
|
|
||||||
thresholdSeconds: 120)
|
|
||||||
#expect(recent)
|
|
||||||
|
|
||||||
let stale = ExecApprovalsGatewayPrompter._testShouldPresent(
|
|
||||||
mode: .remote,
|
|
||||||
activeSession: nil,
|
|
||||||
requestSession: "main",
|
|
||||||
lastInputSeconds: 200,
|
|
||||||
thresholdSeconds: 120)
|
|
||||||
#expect(!stale)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func defaultBehaviorMatchesMode() {
|
|
||||||
let local = ExecApprovalsGatewayPrompter._testShouldPresent(
|
|
||||||
mode: .local,
|
|
||||||
activeSession: nil,
|
|
||||||
requestSession: nil,
|
|
||||||
lastInputSeconds: 400)
|
|
||||||
#expect(local)
|
|
||||||
|
|
||||||
let remote = ExecApprovalsGatewayPrompter._testShouldPresent(
|
|
||||||
mode: .remote,
|
|
||||||
activeSession: nil,
|
|
||||||
requestSession: nil,
|
|
||||||
lastInputSeconds: 400)
|
|
||||||
#expect(!remote)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -140,14 +140,14 @@ import Testing
|
|||||||
#expect(resolved.mode == .remote)
|
#expect(resolved.mode == .remote)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func resolveLocalGatewayHostUsesLoopbackForAutoEvenWithTailnet() {
|
@Test func resolveLocalGatewayHostPrefersTailnetForAuto() {
|
||||||
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||||
bindMode: "auto",
|
bindMode: "auto",
|
||||||
tailscaleIP: "100.64.1.2")
|
tailscaleIP: "100.64.1.2")
|
||||||
#expect(host == "127.0.0.1")
|
#expect(host == "100.64.1.2")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func resolveLocalGatewayHostUsesLoopbackForAutoWithoutTailnet() {
|
@Test func resolveLocalGatewayHostFallsBackToLoopbackForAuto() {
|
||||||
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||||
bindMode: "auto",
|
bindMode: "auto",
|
||||||
tailscaleIP: nil)
|
tailscaleIP: nil)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import ClawdbotKit
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import os
|
import os
|
||||||
import Testing
|
import Testing
|
||||||
|
|||||||
@@ -7,22 +7,20 @@ struct SessionMenuPreviewTests {
|
|||||||
@Test func loaderReturnsCachedItems() async {
|
@Test func loaderReturnsCachedItems() async {
|
||||||
await SessionPreviewCache.shared._testReset()
|
await SessionPreviewCache.shared._testReset()
|
||||||
let items = [SessionPreviewItem(id: "1", role: .user, text: "Hi")]
|
let items = [SessionPreviewItem(id: "1", role: .user, text: "Hi")]
|
||||||
let snapshot = SessionMenuPreviewSnapshot(items: items, status: .ready)
|
await SessionPreviewCache.shared._testSet(items: items, for: "main")
|
||||||
await SessionPreviewCache.shared._testSet(snapshot: snapshot, for: "main")
|
|
||||||
|
|
||||||
let loaded = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
||||||
#expect(loaded.status == .ready)
|
#expect(snapshot.status == .ready)
|
||||||
#expect(loaded.items.count == 1)
|
#expect(snapshot.items.count == 1)
|
||||||
#expect(loaded.items.first?.text == "Hi")
|
#expect(snapshot.items.first?.text == "Hi")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func loaderReturnsEmptyWhenCachedEmpty() async {
|
@Test func loaderReturnsEmptyWhenCachedEmpty() async {
|
||||||
await SessionPreviewCache.shared._testReset()
|
await SessionPreviewCache.shared._testReset()
|
||||||
let snapshot = SessionMenuPreviewSnapshot(items: [], status: .empty)
|
await SessionPreviewCache.shared._testSet(items: [], for: "main")
|
||||||
await SessionPreviewCache.shared._testSet(snapshot: snapshot, for: "main")
|
|
||||||
|
|
||||||
let loaded = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
||||||
#expect(loaded.status == .empty)
|
#expect(snapshot.status == .empty)
|
||||||
#expect(loaded.items.isEmpty)
|
#expect(snapshot.items.isEmpty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,27 +235,6 @@ public struct ClawdbotChatHistoryPayload: Codable, Sendable {
|
|||||||
public let thinkingLevel: String?
|
public let thinkingLevel: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ClawdbotSessionPreviewItem: Codable, Hashable, Sendable {
|
|
||||||
public let role: String
|
|
||||||
public let text: String
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct ClawdbotSessionPreviewEntry: Codable, Sendable {
|
|
||||||
public let key: String
|
|
||||||
public let status: String
|
|
||||||
public let items: [ClawdbotSessionPreviewItem]
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct ClawdbotSessionsPreviewPayload: Codable, Sendable {
|
|
||||||
public let ts: Int
|
|
||||||
public let previews: [ClawdbotSessionPreviewEntry]
|
|
||||||
|
|
||||||
public init(ts: Int, previews: [ClawdbotSessionPreviewEntry]) {
|
|
||||||
self.ts = ts
|
|
||||||
self.previews = previews
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct ClawdbotChatSendResponse: Codable, Sendable {
|
public struct ClawdbotChatSendResponse: Codable, Sendable {
|
||||||
public let runId: String
|
public let runId: String
|
||||||
public let status: String
|
public let status: String
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ public struct ClawdbotChatView: View {
|
|||||||
@State private var scrollPosition: UUID?
|
@State private var scrollPosition: UUID?
|
||||||
@State private var showSessions = false
|
@State private var showSessions = false
|
||||||
@State private var hasPerformedInitialScroll = false
|
@State private var hasPerformedInitialScroll = false
|
||||||
@State private var isPinnedToBottom = true
|
|
||||||
private let showsSessionSwitcher: Bool
|
private let showsSessionSwitcher: Bool
|
||||||
private let style: Style
|
private let style: Style
|
||||||
private let markdownVariant: ChatMarkdownVariant
|
private let markdownVariant: ChatMarkdownVariant
|
||||||
@@ -88,28 +87,36 @@ public struct ClawdbotChatView: View {
|
|||||||
private var messageList: some View {
|
private var messageList: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: Layout.messageSpacing) {
|
#if os(macOS)
|
||||||
self.messageListRows
|
VStack(spacing: 0) {
|
||||||
|
LazyVStack(spacing: Layout.messageSpacing) {
|
||||||
|
self.messageListRows
|
||||||
|
}
|
||||||
|
|
||||||
Color.clear
|
Color.clear
|
||||||
#if os(macOS)
|
|
||||||
.frame(height: Layout.messageListPaddingBottom)
|
.frame(height: Layout.messageListPaddingBottom)
|
||||||
#else
|
|
||||||
.frame(height: Layout.messageListPaddingBottom + 1)
|
|
||||||
#endif
|
|
||||||
.id(self.scrollerBottomID)
|
.id(self.scrollerBottomID)
|
||||||
}
|
}
|
||||||
// Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches.
|
// Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches.
|
||||||
.scrollTargetLayout()
|
.scrollTargetLayout()
|
||||||
.padding(.top, Layout.messageListPaddingTop)
|
.padding(.top, Layout.messageListPaddingTop)
|
||||||
.padding(.horizontal, Layout.messageListPaddingHorizontal)
|
.padding(.horizontal, Layout.messageListPaddingHorizontal)
|
||||||
|
#else
|
||||||
|
LazyVStack(spacing: Layout.messageSpacing) {
|
||||||
|
self.messageListRows
|
||||||
|
|
||||||
|
Color.clear
|
||||||
|
.frame(height: Layout.messageListPaddingBottom + 1)
|
||||||
|
.id(self.scrollerBottomID)
|
||||||
|
}
|
||||||
|
// Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches.
|
||||||
|
.scrollTargetLayout()
|
||||||
|
.padding(.top, Layout.messageListPaddingTop)
|
||||||
|
.padding(.horizontal, Layout.messageListPaddingHorizontal)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
// Keep the scroll pinned to the bottom for new messages.
|
// Keep the scroll pinned to the bottom for new messages.
|
||||||
.scrollPosition(id: self.$scrollPosition, anchor: .bottom)
|
.scrollPosition(id: self.$scrollPosition, anchor: .bottom)
|
||||||
.onChange(of: self.scrollPosition) { _, position in
|
|
||||||
guard let position else { return }
|
|
||||||
self.isPinnedToBottom = position == self.scrollerBottomID
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.viewModel.isLoading {
|
if self.viewModel.isLoading {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
@@ -126,26 +133,18 @@ public struct ClawdbotChatView: View {
|
|||||||
guard !isLoading, !self.hasPerformedInitialScroll else { return }
|
guard !isLoading, !self.hasPerformedInitialScroll else { return }
|
||||||
self.scrollPosition = self.scrollerBottomID
|
self.scrollPosition = self.scrollerBottomID
|
||||||
self.hasPerformedInitialScroll = true
|
self.hasPerformedInitialScroll = true
|
||||||
self.isPinnedToBottom = true
|
|
||||||
}
|
}
|
||||||
.onChange(of: self.viewModel.sessionKey) { _, _ in
|
.onChange(of: self.viewModel.sessionKey) { _, _ in
|
||||||
self.hasPerformedInitialScroll = false
|
self.hasPerformedInitialScroll = false
|
||||||
self.isPinnedToBottom = true
|
|
||||||
}
|
}
|
||||||
.onChange(of: self.viewModel.messages.count) { _, _ in
|
.onChange(of: self.viewModel.messages.count) { _, _ in
|
||||||
guard self.hasPerformedInitialScroll, self.isPinnedToBottom else { return }
|
guard self.hasPerformedInitialScroll else { return }
|
||||||
withAnimation(.snappy(duration: 0.22)) {
|
withAnimation(.snappy(duration: 0.22)) {
|
||||||
self.scrollPosition = self.scrollerBottomID
|
self.scrollPosition = self.scrollerBottomID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: self.viewModel.pendingRunCount) { _, _ in
|
.onChange(of: self.viewModel.pendingRunCount) { _, _ in
|
||||||
guard self.hasPerformedInitialScroll, self.isPinnedToBottom else { return }
|
guard self.hasPerformedInitialScroll else { return }
|
||||||
withAnimation(.snappy(duration: 0.22)) {
|
|
||||||
self.scrollPosition = self.scrollerBottomID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: self.viewModel.streamingAssistantText) { _, _ in
|
|
||||||
guard self.hasPerformedInitialScroll, self.isPinnedToBottom else { return }
|
|
||||||
withAnimation(.snappy(duration: 0.22)) {
|
withAnimation(.snappy(duration: 0.22)) {
|
||||||
self.scrollPosition = self.scrollerBottomID
|
self.scrollPosition = self.scrollerBottomID
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,13 +94,6 @@ public struct GatewayConnectOptions: Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum GatewayAuthSource: String, Sendable {
|
|
||||||
case deviceToken = "device-token"
|
|
||||||
case sharedToken = "shared-token"
|
|
||||||
case password = "password"
|
|
||||||
case none = "none"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Avoid ambiguity with the app's own AnyCodable type.
|
// Avoid ambiguity with the app's own AnyCodable type.
|
||||||
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
|
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
|
||||||
|
|
||||||
@@ -124,7 +117,6 @@ public actor GatewayChannelActor {
|
|||||||
private var lastSeq: Int?
|
private var lastSeq: Int?
|
||||||
private var lastTick: Date?
|
private var lastTick: Date?
|
||||||
private var tickIntervalMs: Double = 30000
|
private var tickIntervalMs: Double = 30000
|
||||||
private var lastAuthSource: GatewayAuthSource = .none
|
|
||||||
private let decoder = JSONDecoder()
|
private let decoder = JSONDecoder()
|
||||||
private let encoder = JSONEncoder()
|
private let encoder = JSONEncoder()
|
||||||
private let connectTimeoutSeconds: Double = 6
|
private let connectTimeoutSeconds: Double = 6
|
||||||
@@ -157,8 +149,6 @@ public actor GatewayChannelActor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func authSource() -> GatewayAuthSource { self.lastAuthSource }
|
|
||||||
|
|
||||||
public func shutdown() async {
|
public func shutdown() async {
|
||||||
self.shouldReconnect = false
|
self.shouldReconnect = false
|
||||||
self.connected = false
|
self.connected = false
|
||||||
@@ -310,18 +300,6 @@ public actor GatewayChannelActor {
|
|||||||
let identity = DeviceIdentityStore.loadOrCreate()
|
let identity = DeviceIdentityStore.loadOrCreate()
|
||||||
let storedToken = DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role)?.token
|
let storedToken = DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role)?.token
|
||||||
let authToken = storedToken ?? self.token
|
let authToken = storedToken ?? self.token
|
||||||
let authSource: GatewayAuthSource
|
|
||||||
if storedToken != nil {
|
|
||||||
authSource = .deviceToken
|
|
||||||
} else if authToken != nil {
|
|
||||||
authSource = .sharedToken
|
|
||||||
} else if self.password != nil {
|
|
||||||
authSource = .password
|
|
||||||
} else {
|
|
||||||
authSource = .none
|
|
||||||
}
|
|
||||||
self.lastAuthSource = authSource
|
|
||||||
self.logger.info("gateway connect auth=\(authSource.rawValue, privacy: .public)")
|
|
||||||
let canFallbackToShared = storedToken != nil && self.token != nil
|
let canFallbackToShared = storedToken != nil && self.token != nil
|
||||||
if let authToken {
|
if let authToken {
|
||||||
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(authToken)])
|
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(authToken)])
|
||||||
|
|||||||
@@ -552,44 +552,6 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct AgentIdentityParams: Codable, Sendable {
|
|
||||||
public let agentid: String?
|
|
||||||
public let sessionkey: String?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
agentid: String?,
|
|
||||||
sessionkey: String?
|
|
||||||
) {
|
|
||||||
self.agentid = agentid
|
|
||||||
self.sessionkey = sessionkey
|
|
||||||
}
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case agentid = "agentId"
|
|
||||||
case sessionkey = "sessionKey"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct AgentIdentityResult: Codable, Sendable {
|
|
||||||
public let agentid: String
|
|
||||||
public let name: String?
|
|
||||||
public let avatar: String?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
agentid: String,
|
|
||||||
name: String?,
|
|
||||||
avatar: String?
|
|
||||||
) {
|
|
||||||
self.agentid = agentid
|
|
||||||
self.name = name
|
|
||||||
self.avatar = avatar
|
|
||||||
}
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case agentid = "agentId"
|
|
||||||
case name
|
|
||||||
case avatar
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct AgentWaitParams: Codable, Sendable {
|
public struct AgentWaitParams: Codable, Sendable {
|
||||||
public let runid: String
|
public let runid: String
|
||||||
public let timeoutms: Int?
|
public let timeoutms: Int?
|
||||||
@@ -925,27 +887,6 @@ public struct SessionsListParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct SessionsPreviewParams: Codable, Sendable {
|
|
||||||
public let keys: [String]
|
|
||||||
public let limit: Int?
|
|
||||||
public let maxchars: Int?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
keys: [String],
|
|
||||||
limit: Int?,
|
|
||||||
maxchars: Int?
|
|
||||||
) {
|
|
||||||
self.keys = keys
|
|
||||||
self.limit = limit
|
|
||||||
self.maxchars = maxchars
|
|
||||||
}
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case keys
|
|
||||||
case limit
|
|
||||||
case maxchars = "maxChars"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct SessionsResolveParams: Codable, Sendable {
|
public struct SessionsResolveParams: Codable, Sendable {
|
||||||
public let key: String?
|
public let key: String?
|
||||||
public let label: String?
|
public let label: String?
|
||||||
@@ -1506,21 +1447,17 @@ public struct WebLoginWaitParams: Codable, Sendable {
|
|||||||
public struct AgentSummary: Codable, Sendable {
|
public struct AgentSummary: Codable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let name: String?
|
public let name: String?
|
||||||
public let identity: [String: AnyCodable]?
|
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: String,
|
id: String,
|
||||||
name: String?,
|
name: String?
|
||||||
identity: [String: AnyCodable]?
|
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.name = name
|
self.name = name
|
||||||
self.identity = identity
|
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case name
|
case name
|
||||||
case identity
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
-1
@@ -1 +0,0 @@
|
|||||||
@import"https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Unbounded:wght@400;500;600&family=Work+Sans:wght@400;500;600;700&display=swap";:root{--bg: #0a0f14;--bg-accent: #111826;--bg-grad-1: #162031;--bg-grad-2: #1f2a22;--bg-overlay: rgba(255, 255, 255, .05);--bg-glow: rgba(245, 159, 74, .12);--panel: rgba(14, 20, 30, .88);--panel-strong: rgba(18, 26, 38, .96);--chrome: rgba(9, 14, 20, .72);--chrome-strong: rgba(9, 14, 20, .86);--text: rgba(244, 246, 251, .96);--chat-text: rgba(231, 237, 244, .92);--muted: rgba(156, 169, 189, .72);--border: rgba(255, 255, 255, .09);--border-strong: rgba(255, 255, 255, .16);--accent: #f59f4a;--accent-2: #34c7b7;--ok: #2bd97f;--warn: #f2c94c;--danger: #ff6b6b;--focus: rgba(245, 159, 74, .35);--grid-line: rgba(255, 255, 255, .04);--theme-switch-x: 50%;--theme-switch-y: 50%;--mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--font-body: "Work Sans", system-ui, sans-serif;--font-display: "Unbounded", "Times New Roman", serif;color-scheme:dark}:root[data-theme=light]{--bg: #f5f1ea;--bg-accent: #ffffff;--bg-grad-1: #f1e6d6;--bg-grad-2: #e5eef4;--bg-overlay: rgba(28, 32, 46, .05);--bg-glow: rgba(52, 199, 183, .14);--panel: rgba(255, 255, 255, .9);--panel-strong: rgba(255, 255, 255, .97);--chrome: rgba(255, 255, 255, .75);--chrome-strong: rgba(255, 255, 255, .88);--text: rgba(27, 36, 50, .98);--chat-text: rgba(36, 48, 66, .9);--muted: rgba(80, 94, 114, .7);--border: rgba(18, 24, 40, .12);--border-strong: rgba(18, 24, 40, .2);--accent: #e28a3f;--accent-2: #1ba99d;--ok: #1aa86c;--warn: #b3771c;--danger: #d44848;--focus: rgba(226, 138, 63, .35);--grid-line: rgba(18, 24, 40, .06);color-scheme:light}*{box-sizing:border-box}html,body{height:100%}body{margin:0;font:15px/1.5 var(--font-body);background:radial-gradient(1200px 900px at 15% -10%,var(--bg-grad-1) 0%,transparent 55%) fixed,radial-gradient(900px 700px at 80% 10%,var(--bg-grad-2) 0%,transparent 60%) fixed,linear-gradient(160deg,var(--bg) 0%,var(--bg-accent) 100%) fixed;color:var(--text)}body:before{content:"";position:fixed;inset:0;background:linear-gradient(140deg,var(--bg-overlay) 0%,rgba(255,255,255,0) 40%),radial-gradient(620px 420px at 75% 75%,var(--bg-glow),transparent 60%);pointer-events:none;z-index:0}@keyframes theme-circle-transition{0%{clip-path:circle(0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}to{clip-path:circle(150% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}}html.theme-transition{view-transition-name:theme}html.theme-transition::view-transition-old(theme){mix-blend-mode:normal;animation:none;z-index:1}html.theme-transition::view-transition-new(theme){mix-blend-mode:normal;z-index:2;animation:theme-circle-transition .45s ease-out forwards}@media(prefers-reduced-motion:reduce){html.theme-transition::view-transition-old(theme),html.theme-transition::view-transition-new(theme){animation:none!important}}clawdbot-app{display:block;position:relative;z-index:1;min-height:100vh}a{color:inherit}button,input,textarea,select{font:inherit;color:inherit}@keyframes rise{0%{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}@keyframes dashboard-enter{0%{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.shell{--shell-pad: 16px;--shell-gap: 16px;--shell-nav-width: 220px;--shell-topbar-height: 56px;--shell-focus-duration: .22s;--shell-focus-ease: cubic-bezier(.2, .85, .25, 1);min-height:100vh;display:grid;grid-template-columns:var(--shell-nav-width) minmax(0,1fr);grid-template-rows:var(--shell-topbar-height) 1fr;grid-template-areas:"topbar topbar" "nav content";gap:0;animation:dashboard-enter .6s ease-out;transition:grid-template-columns var(--shell-focus-duration) var(--shell-focus-ease)}.shell--chat{min-height:100vh;height:100vh;overflow:hidden}@supports (height: 100dvh){.shell--chat{height:100dvh}}.shell--nav-collapsed,.shell--chat-focus{grid-template-columns:0px minmax(0,1fr)}.shell--onboarding{grid-template-rows:0 1fr}.shell--onboarding .topbar{display:none}.shell--onboarding .content{padding-top:0}.shell--chat-focus .content{padding-top:0;gap:0}.topbar{grid-area:topbar;position:sticky;top:0;z-index:40;display:flex;justify-content:space-between;align-items:center;gap:16px;padding:0 20px;height:var(--shell-topbar-height);border-bottom:1px solid var(--border);background:var(--panel);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px)}.topbar-left{display:flex;align-items:center;gap:12px}.topbar .nav-collapse-toggle{width:44px;height:44px;margin-bottom:0}.topbar .nav-collapse-toggle__icon{font-size:22px}.brand{display:flex;flex-direction:column;gap:2px}.brand-title{font-family:var(--font-display);font-size:16px;letter-spacing:1px;text-transform:uppercase;font-weight:600;line-height:1.1}.brand-sub{font-size:10px;color:var(--muted);letter-spacing:.8px;text-transform:uppercase;line-height:1}.topbar-status{display:flex;align-items:center;gap:8px}.toLine truncated
|
|
||||||
-3047
File diff suppressed because it is too large.
Load diff
@@ -1 +0,0 @@
|
|||||||
{"version":3,"file":"index-bYQnHP3a.js","sources":["../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/css-tag.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/reactive-element.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/lit-html.js","../../../node_modules/.pnpm/lit-element@4.2.2/node_modules/lit-element/lit-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/custom-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/property.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/state.js","../../../ui/src/ui/assistant-identity.ts","../../../ui/src/ui/storage.ts","../../../src/sessions/session-key-utils.ts","../../../ui/src/ui/navigation.ts","../../../ui/src/ui/format.ts","../../../ui/src/ui/chat/message-extract.ts","../../../ui/src/ui/uuid.ts","../../../ui/src/ui/controllers/chat.ts","../../../ui/src/ui/controllers/sessions.ts","../../../ui/src/ui/app-tool-stream.ts","../../../ui/src/ui/app-scroll.ts","../../../ui/src/ui/controllers/config/form-utils.ts","../../../ui/src/ui/controllers/config.ts","../../../ui/src/ui/controllers/cron.ts","../../../ui/src/ui/controllers/channels.ts","../../../ui/src/ui/controllers/debug.ts","../../../ui/src/ui/controllers/logs.ts","../../../node_modules/.pnpm/@noble+ed25519@3.0.0/node_modules/@noble/ed25519/index.js","../../../ui/src/ui/device-identity.ts","../../../ui/src/ui/device-auth.ts","../../../ui/src/ui/controllers/devices.ts","../../../ui/src/ui/controllers/nodes.ts","../../../ui/src/ui/controllers/exec-approvals.ts","../../../ui/src/ui/controllers/presence.ts","../../../ui/src/ui/controllers/skills.ts","../../../ui/src/ui/theme.ts","../../../ui/src/ui/theme-transition.ts","../../../ui/src/ui/app-polling.ts","../../../ui/src/ui/app-settings.ts","../../../ui/src/ui/app-chat.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive-helpers.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/repeat.js","../../../ui/src/ui/chat/message-normalizer.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/unsafe-html.js","../../../node_modules/.pnpm/dompurify@3.3.1/node_modules/dompurify/dist/purify.es.mjs","../../../node_modules/.pnpm/marked@17.0.1/node_modules/marked/lib/marked.esm.js","../../../ui/src/ui/markdown.ts","../../../ui/src/ui/icons.ts","../../../ui/src/ui/chat/copy-as-markdown.ts","../../../ui/src/ui/tool-display.ts","../../../ui/src/ui/chat/constants.ts","../../../ui/src/ui/chat/tool-helpers.ts","../../../ui/src/ui/chat/tool-cards.ts","../../../ui/src/ui/chat/grouped-render.ts","../../../ui/src/ui/views/markdown-sidebar.ts","../../../ui/src/ui/components/resizable-divider.ts","../../../ui/src/ui/views/chat.ts","../../../ui/src/ui/views/config-form.shared.ts","../../../ui/src/ui/views/config-form.node.ts","../../../ui/src/ui/views/config-form.render.ts","../../../ui/src/ui/views/config-form.analyze.ts","../../../ui/src/ui/views/config.ts","../../../ui/src/ui/views/channels.shared.ts","../../../ui/src/ui/views/channels.config.ts","../../../ui/src/ui/views/channels.discord.ts","../../../ui/src/ui/views/channels.imessage.ts","../../../ui/src/ui/views/channels.nostr-profile-form.ts","../../../ui/src/ui/views/channels.nostr.ts","../../../ui/src/ui/views/channels.signal.ts","../../../ui/src/ui/views/channels.slack.ts","../../../ui/src/ui/views/channels.telegram.ts","../../../ui/src/ui/views/channels.whatsapp.ts","../../../ui/src/ui/views/channels.ts","../../../ui/src/ui/presenter.ts","../../../ui/src/ui/views/cron.ts","../../../ui/src/ui/views/debug.ts","../../../ui/src/ui/views/instances.ts","../../../ui/src/ui/views/logs.ts","../../../ui/src/ui/views/nodes.ts","../../../ui/src/ui/views/overview.ts","../../../ui/src/ui/views/sessions.ts","../../../ui/src/ui/views/exec-approval.ts","../../../ui/src/ui/views/skills.ts","../../../ui/src/ui/app-render.helpers.ts","../../../ui/src/ui/app-render.ts","../../../ui/src/ui/app-defaults.ts","../../../ui/src/ui/controllers/agents.ts","../../../src/gateway/protocol/client-info.ts","../../../src/gateway/device-auth.ts","../../../ui/src/ui/gateway.ts","../../../ui/src/ui/controllers/exec-approval.ts","../../../ui/src/ui/controllers/assistant-identity.ts","../../../ui/src/ui/app-gateway.ts","../../../ui/src/ui/app-lifecycle.ts","../../../ui/src/ui/app-channels.ts","../../../ui/src/ui/app.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\nconst t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&\"adoptedStyleSheets\"in Document.prototype&&\"replace\"in CSSStyleSheet.prototype,s=Symbol(),o=new WeakMapLine truncated
|
|
||||||
Vendored
-15
@@ -1,15 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Clawdbot Control</title>
|
|
||||||
<meta name="color-scheme" content="dark light" />
|
|
||||||
<link rel="icon" href="./favicon.ico" sizes="any" />
|
|
||||||
<script type="module" crossorigin src="./assets/index-bYQnHP3a.js"></script>
|
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-BPDeGGxb.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<clawdbot-app></clawdbot-app>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
+3
-149
@@ -8,9 +8,9 @@ read_when:
|
|||||||
> "Abandon all hope, ye who enter here."
|
> "Abandon all hope, ye who enter here."
|
||||||
|
|
||||||
|
|
||||||
Updated: 2026-01-21
|
Updated: 2026-01-16
|
||||||
|
|
||||||
Status: text + DM attachments are supported; channel/group file sending requires `sharePointSiteId` + Graph permissions (see [Sending files in group chats](#sending-files-in-group-chats)). Polls are sent via Adaptive Cards.
|
Status: text + DM attachments are supported; channel/group attachments require Microsoft Graph permissions. Polls are sent via Adaptive Cards.
|
||||||
|
|
||||||
## Plugin required
|
## Plugin required
|
||||||
Microsoft Teams ships as a plugin and is not bundled with the core install.
|
Microsoft Teams ships as a plugin and is not bundled with the core install.
|
||||||
@@ -403,7 +403,7 @@ Clawdbot handles this by returning quickly and sending replies proactively, but
|
|||||||
Teams markdown is more limited than Slack or Discord:
|
Teams markdown is more limited than Slack or Discord:
|
||||||
- Basic formatting works: **bold**, *italic*, `code`, links
|
- Basic formatting works: **bold**, *italic*, `code`, links
|
||||||
- Complex markdown (tables, nested lists) may not render correctly
|
- Complex markdown (tables, nested lists) may not render correctly
|
||||||
- Adaptive Cards are supported for polls and arbitrary card sends (see below)
|
- Adaptive Cards are used for polls; other card types are not yet supported
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
Key settings (see `/gateway/configuration` for shared channel patterns):
|
Key settings (see `/gateway/configuration` for shared channel patterns):
|
||||||
@@ -422,7 +422,6 @@ Key settings (see `/gateway/configuration` for shared channel patterns):
|
|||||||
- `channels.msteams.teams.<teamId>.requireMention`: per-team override.
|
- `channels.msteams.teams.<teamId>.requireMention`: per-team override.
|
||||||
- `channels.msteams.teams.<teamId>.channels.<conversationId>.replyStyle`: per-channel override.
|
- `channels.msteams.teams.<teamId>.channels.<conversationId>.replyStyle`: per-channel override.
|
||||||
- `channels.msteams.teams.<teamId>.channels.<conversationId>.requireMention`: per-channel override.
|
- `channels.msteams.teams.<teamId>.channels.<conversationId>.requireMention`: per-channel override.
|
||||||
- `channels.msteams.sharePointSiteId`: SharePoint site ID for file uploads in group chats/channels (see [Sending files in group chats](#sending-files-in-group-chats)).
|
|
||||||
|
|
||||||
## Routing & Sessions
|
## Routing & Sessions
|
||||||
- Session keys follow the standard agent format (see [/concepts/session](/concepts/session)):
|
- Session keys follow the standard agent format (see [/concepts/session](/concepts/session)):
|
||||||
@@ -472,75 +471,6 @@ Teams recently introduced two channel UI styles over the same underlying data mo
|
|||||||
Without Graph permissions, channel messages with images will be received as text-only (the image content is not accessible to the bot).
|
Without Graph permissions, channel messages with images will be received as text-only (the image content is not accessible to the bot).
|
||||||
By default, Clawdbot only downloads media from Microsoft/Teams hostnames. Override with `channels.msteams.mediaAllowHosts` (use `["*"]` to allow any host).
|
By default, Clawdbot only downloads media from Microsoft/Teams hostnames. Override with `channels.msteams.mediaAllowHosts` (use `["*"]` to allow any host).
|
||||||
|
|
||||||
## Sending files in group chats
|
|
||||||
|
|
||||||
Bots can send files in DMs using the FileConsentCard flow (built-in). However, **sending files in group chats/channels** requires additional setup:
|
|
||||||
|
|
||||||
| Context | How files are sent | Setup needed |
|
|
||||||
|---------|-------------------|--------------|
|
|
||||||
| **DMs** | FileConsentCard → user accepts → bot uploads | Works out of the box |
|
|
||||||
| **Group chats/channels** | Upload to SharePoint → share link | Requires `sharePointSiteId` + Graph permissions |
|
|
||||||
| **Images (any context)** | Base64-encoded inline | Works out of the box |
|
|
||||||
|
|
||||||
### Why group chats need SharePoint
|
|
||||||
|
|
||||||
Bots don't have a personal OneDrive drive (the `/me/drive` Graph API endpoint doesn't work for application identities). To send files in group chats/channels, the bot uploads to a **SharePoint site** and creates a sharing link.
|
|
||||||
|
|
||||||
### Setup
|
|
||||||
|
|
||||||
1. **Add Graph API permissions** in Entra ID (Azure AD) → App Registration:
|
|
||||||
- `Sites.ReadWrite.All` (Application) - upload files to SharePoint
|
|
||||||
- `Chat.Read.All` (Application) - optional, enables per-user sharing links
|
|
||||||
|
|
||||||
2. **Grant admin consent** for the tenant.
|
|
||||||
|
|
||||||
3. **Get your SharePoint site ID:**
|
|
||||||
```bash
|
|
||||||
# Via Graph Explorer or curl with a valid token:
|
|
||||||
curl -H "Authorization: Bearer $TOKEN" \
|
|
||||||
"https://graph.microsoft.com/v1.0/sites/{hostname}:/{site-path}"
|
|
||||||
|
|
||||||
# Example: for a site at "contoso.sharepoint.com/sites/BotFiles"
|
|
||||||
curl -H "Authorization: Bearer $TOKEN" \
|
|
||||||
"https://graph.microsoft.com/v1.0/sites/contoso.sharepoint.com:/sites/BotFiles"
|
|
||||||
|
|
||||||
# Response includes: "id": "contoso.sharepoint.com,guid1,guid2"
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Configure Clawdbot:**
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
channels: {
|
|
||||||
msteams: {
|
|
||||||
// ... other config ...
|
|
||||||
sharePointSiteId: "contoso.sharepoint.com,guid1,guid2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Sharing behavior
|
|
||||||
|
|
||||||
| Permission | Sharing behavior |
|
|
||||||
|------------|------------------|
|
|
||||||
| `Sites.ReadWrite.All` only | Organization-wide sharing link (anyone in org can access) |
|
|
||||||
| `Sites.ReadWrite.All` + `Chat.Read.All` | Per-user sharing link (only chat members can access) |
|
|
||||||
|
|
||||||
Per-user sharing is more secure as only the chat participants can access the file. If `Chat.Read.All` permission is missing, the bot falls back to organization-wide sharing.
|
|
||||||
|
|
||||||
### Fallback behavior
|
|
||||||
|
|
||||||
| Scenario | Result |
|
|
||||||
|----------|--------|
|
|
||||||
| Group chat + file + `sharePointSiteId` configured | Upload to SharePoint, send sharing link |
|
|
||||||
| Group chat + file + no `sharePointSiteId` | Attempt OneDrive upload (may fail), send text only |
|
|
||||||
| Personal chat + file | FileConsentCard flow (works without SharePoint) |
|
|
||||||
| Any context + image | Base64-encoded inline (works without SharePoint) |
|
|
||||||
|
|
||||||
### Files stored location
|
|
||||||
|
|
||||||
Uploaded files are stored in a `/ClawdbotShared/` folder in the configured SharePoint site's default document library.
|
|
||||||
|
|
||||||
## Polls (Adaptive Cards)
|
## Polls (Adaptive Cards)
|
||||||
Clawdbot sends Teams polls as Adaptive Cards (there is no native Teams poll API).
|
Clawdbot sends Teams polls as Adaptive Cards (there is no native Teams poll API).
|
||||||
|
|
||||||
@@ -549,82 +479,6 @@ Clawdbot sends Teams polls as Adaptive Cards (there is no native Teams poll API)
|
|||||||
- The gateway must stay online to record votes.
|
- The gateway must stay online to record votes.
|
||||||
- Polls do not auto-post result summaries yet (inspect the store file if needed).
|
- Polls do not auto-post result summaries yet (inspect the store file if needed).
|
||||||
|
|
||||||
## Adaptive Cards (arbitrary)
|
|
||||||
Send any Adaptive Card JSON to Teams users or conversations using the `message` tool or CLI.
|
|
||||||
|
|
||||||
The `card` parameter accepts an Adaptive Card JSON object. When `card` is provided, the message text is optional.
|
|
||||||
|
|
||||||
**Agent tool:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"action": "send",
|
|
||||||
"channel": "msteams",
|
|
||||||
"target": "user:<id>",
|
|
||||||
"card": {
|
|
||||||
"type": "AdaptiveCard",
|
|
||||||
"version": "1.5",
|
|
||||||
"body": [{"type": "TextBlock", "text": "Hello!"}]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**CLI:**
|
|
||||||
```bash
|
|
||||||
clawdbot message send --channel msteams \
|
|
||||||
--target "conversation:19:abc...@thread.tacv2" \
|
|
||||||
--card '{"type":"AdaptiveCard","version":"1.5","body":[{"type":"TextBlock","text":"Hello!"}]}'
|
|
||||||
```
|
|
||||||
|
|
||||||
See [Adaptive Cards documentation](https://adaptivecards.io/) for card schema and examples. For target format details, see [Target formats](#target-formats) below.
|
|
||||||
|
|
||||||
## Target formats
|
|
||||||
|
|
||||||
MSTeams targets use prefixes to distinguish between users and conversations:
|
|
||||||
|
|
||||||
| Target type | Format | Example |
|
|
||||||
|-------------|--------|---------|
|
|
||||||
| User (by ID) | `user:<aad-object-id>` | `user:40a1a0ed-4ff2-4164-a219-55518990c197` |
|
|
||||||
| User (by name) | `user:<display-name>` | `user:John Smith` (requires Graph API) |
|
|
||||||
| Group/channel | `conversation:<conversation-id>` | `conversation:19:abc123...@thread.tacv2` |
|
|
||||||
| Group/channel (raw) | `<conversation-id>` | `19:abc123...@thread.tacv2` (if contains `@thread`) |
|
|
||||||
|
|
||||||
**CLI examples:**
|
|
||||||
```bash
|
|
||||||
# Send to a user by ID
|
|
||||||
clawdbot message send --channel msteams --target "user:40a1a0ed-..." --message "Hello"
|
|
||||||
|
|
||||||
# Send to a user by display name (triggers Graph API lookup)
|
|
||||||
clawdbot message send --channel msteams --target "user:John Smith" --message "Hello"
|
|
||||||
|
|
||||||
# Send to a group chat or channel
|
|
||||||
clawdbot message send --channel msteams --target "conversation:19:abc...@thread.tacv2" --message "Hello"
|
|
||||||
|
|
||||||
# Send an Adaptive Card to a conversation
|
|
||||||
clawdbot message send --channel msteams --target "conversation:19:abc...@thread.tacv2" \
|
|
||||||
--card '{"type":"AdaptiveCard","version":"1.5","body":[{"type":"TextBlock","text":"Hello"}]}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Agent tool examples:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"action": "send",
|
|
||||||
"channel": "msteams",
|
|
||||||
"target": "user:John Smith",
|
|
||||||
"message": "Hello!"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"action": "send",
|
|
||||||
"channel": "msteams",
|
|
||||||
"target": "conversation:19:abc...@thread.tacv2",
|
|
||||||
"card": {"type": "AdaptiveCard", "version": "1.5", "body": [{"type": "TextBlock", "text": "Hello"}]}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: Without the `user:` prefix, names default to group/team resolution. Always use `user:` when targeting people by display name.
|
|
||||||
|
|
||||||
## Proactive messaging
|
## Proactive messaging
|
||||||
- Proactive messages are only possible **after** a user has interacted, because we store conversation references at that point.
|
- Proactive messages are only possible **after** a user has interacted, because we store conversation references at that point.
|
||||||
- See `/gateway/configuration` for `dmPolicy` and allowlist gating.
|
- See `/gateway/configuration` for `dmPolicy` and allowlist gating.
|
||||||
|
|||||||
@@ -18,54 +18,5 @@ Related:
|
|||||||
clawdbot agents list
|
clawdbot agents list
|
||||||
clawdbot agents add work --workspace ~/clawd-work
|
clawdbot agents add work --workspace ~/clawd-work
|
||||||
clawdbot agents set-identity --workspace ~/clawd --from-identity
|
clawdbot agents set-identity --workspace ~/clawd --from-identity
|
||||||
clawdbot agents set-identity --agent main --avatar avatars/clawd.png
|
|
||||||
clawdbot agents delete work
|
clawdbot agents delete work
|
||||||
```
|
```
|
||||||
|
|
||||||
## Identity files
|
|
||||||
|
|
||||||
Each agent workspace can include an `IDENTITY.md` at the workspace root:
|
|
||||||
- Example path: `~/clawd/IDENTITY.md`
|
|
||||||
- `set-identity --from-identity` reads from the workspace root (or an explicit `--identity-file`)
|
|
||||||
|
|
||||||
Avatar paths resolve relative to the workspace root.
|
|
||||||
|
|
||||||
## Set identity
|
|
||||||
|
|
||||||
`set-identity` writes fields into `agents.list[].identity`:
|
|
||||||
- `name`
|
|
||||||
- `theme`
|
|
||||||
- `emoji`
|
|
||||||
- `avatar` (workspace-relative path, http(s) URL, or data URI)
|
|
||||||
|
|
||||||
Load from `IDENTITY.md`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot agents set-identity --workspace ~/clawd --from-identity
|
|
||||||
```
|
|
||||||
|
|
||||||
Override fields explicitly:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot agents set-identity --agent main --name "Clawd" --emoji "🦞" --avatar avatars/clawd.png
|
|
||||||
```
|
|
||||||
|
|
||||||
Config sample:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
agents: {
|
|
||||||
list: [
|
|
||||||
{
|
|
||||||
id: "main",
|
|
||||||
identity: {
|
|
||||||
name: "Clawd",
|
|
||||||
theme: "space lobster",
|
|
||||||
emoji: "🦞",
|
|
||||||
avatar: "avatars/clawd.png"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -59,6 +59,7 @@ Manage the service:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot node status
|
clawdbot node status
|
||||||
|
clawdbot node run
|
||||||
clawdbot node stop
|
clawdbot node stop
|
||||||
clawdbot node restart
|
clawdbot node restart
|
||||||
clawdbot node uninstall
|
clawdbot node uninstall
|
||||||
|
|||||||
+5
-13
@@ -16,7 +16,6 @@ If you installed via **npm/pnpm** (global install, no git metadata), updates hap
|
|||||||
```bash
|
```bash
|
||||||
clawdbot update
|
clawdbot update
|
||||||
clawdbot update status
|
clawdbot update status
|
||||||
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
|
||||||
@@ -49,11 +48,6 @@ Options:
|
|||||||
- `--json`: print machine-readable status JSON.
|
- `--json`: print machine-readable status JSON.
|
||||||
- `--timeout <seconds>`: timeout for checks (default is 3s).
|
- `--timeout <seconds>`: timeout for checks (default is 3s).
|
||||||
|
|
||||||
## `update wizard`
|
|
||||||
|
|
||||||
Interactive flow to pick an update channel and confirm whether to restart the Gateway
|
|
||||||
after updating. If you select `dev` without a git checkout, it offers to create one.
|
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
When you switch channels explicitly (`--channel ...`), Clawdbot also keeps the
|
When you switch channels explicitly (`--channel ...`), Clawdbot also keeps the
|
||||||
@@ -75,13 +69,11 @@ High-level:
|
|||||||
|
|
||||||
1. Requires a clean worktree (no uncommitted changes).
|
1. Requires a clean worktree (no uncommitted changes).
|
||||||
2. Switches to the selected channel (tag or branch).
|
2. Switches to the selected channel (tag or branch).
|
||||||
3. Fetches upstream (dev only).
|
3. Fetches and rebases against `@{upstream}` (dev only).
|
||||||
4. Dev only: preflight lint + TypeScript build in a temp worktree; if the tip fails, walks back up to 10 commits to find the newest clean build.
|
4. Installs deps (pnpm preferred; npm fallback).
|
||||||
5. Rebases onto the selected commit (dev only).
|
5. Builds + builds the Control UI.
|
||||||
6. Installs deps (pnpm preferred; npm fallback).
|
6. Runs `clawdbot doctor` as the final “safe update” check.
|
||||||
7. Builds + builds the Control UI.
|
7. Syncs plugins to the active channel (dev uses bundled extensions; stable/beta uses npm) and updates npm-installed plugins.
|
||||||
8. Runs `clawdbot doctor` as the final “safe update” check.
|
|
||||||
9. Syncs plugins to the active channel (dev uses bundled extensions; stable/beta uses npm) and updates npm-installed plugins.
|
|
||||||
|
|
||||||
## `--update` shorthand
|
## `--update` shorthand
|
||||||
|
|
||||||
|
|||||||
@@ -140,9 +140,6 @@ workspace lives).
|
|||||||
|
|
||||||
### 1) Initialize the repo
|
### 1) Initialize the repo
|
||||||
|
|
||||||
If git is installed, brand-new workspaces are initialized automatically. If this
|
|
||||||
workspace is not already a repo, run:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/clawd
|
cd ~/clawd
|
||||||
git init
|
git init
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ The prompt is intentionally compact and uses fixed sections:
|
|||||||
- **Current Date & Time**: user-local time, timezone, and time format.
|
- **Current Date & Time**: user-local time, timezone, and time format.
|
||||||
- **Reply Tags**: optional reply tag syntax for supported providers.
|
- **Reply Tags**: optional reply tag syntax for supported providers.
|
||||||
- **Heartbeats**: heartbeat prompt and ack behavior.
|
- **Heartbeats**: heartbeat prompt and ack behavior.
|
||||||
- **Runtime**: host, OS, node, model, repo root (when detected), thinking level (one line).
|
- **Runtime**: host, OS, node, model, thinking level (one line).
|
||||||
- **Reasoning**: current visibility level + /reasoning toggle hint.
|
- **Reasoning**: current visibility level + /reasoning toggle hint.
|
||||||
|
|
||||||
## Prompt modes
|
## Prompt modes
|
||||||
|
|||||||
@@ -9,15 +9,15 @@ read_when:
|
|||||||
|
|
||||||
Clawdbot standardizes timestamps so the model sees a **single reference time**.
|
Clawdbot standardizes timestamps so the model sees a **single reference time**.
|
||||||
|
|
||||||
## Message envelopes (local by default)
|
## Message envelopes (UTC by default)
|
||||||
|
|
||||||
Inbound messages are wrapped in an envelope like:
|
Inbound messages are wrapped in an envelope like:
|
||||||
|
|
||||||
```
|
```
|
||||||
[Provider ... 2026-01-05 16:26 PST] message text
|
[Provider ... 2026-01-05T21:26Z] message text
|
||||||
```
|
```
|
||||||
|
|
||||||
The timestamp in the envelope is **host-local by default**, with minutes precision.
|
The timestamp in the envelope is **UTC by default**, with minutes precision.
|
||||||
|
|
||||||
You can override this with:
|
You can override this with:
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ You can override this with:
|
|||||||
{
|
{
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
envelopeTimezone: "local", // "utc" | "local" | "user" | IANA timezone
|
envelopeTimezone: "user", // "utc" | "local" | "user" | IANA timezone
|
||||||
envelopeTimestamp: "on", // "on" | "off"
|
envelopeTimestamp: "on", // "on" | "off"
|
||||||
envelopeElapsed: "on" // "on" | "off"
|
envelopeElapsed: "on" // "on" | "off"
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,6 @@ You can override this with:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `envelopeTimezone: "utc"` uses UTC.
|
|
||||||
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
|
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
|
||||||
- Use an explicit IANA timezone (e.g., `"Europe/Vienna"`) for a fixed offset.
|
- Use an explicit IANA timezone (e.g., `"Europe/Vienna"`) for a fixed offset.
|
||||||
- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers.
|
- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers.
|
||||||
@@ -41,10 +40,10 @@ You can override this with:
|
|||||||
|
|
||||||
### Examples
|
### Examples
|
||||||
|
|
||||||
**Local (default):**
|
**UTC (default):**
|
||||||
|
|
||||||
```
|
```
|
||||||
[Signal Alice +1555 2026-01-18 00:19 PST] hello
|
[Signal Alice +1555 2026-01-18T05:19Z] hello
|
||||||
```
|
```
|
||||||
|
|
||||||
**Fixed timezone:**
|
**Fixed timezone:**
|
||||||
|
|||||||
+11
-13
@@ -7,18 +7,18 @@ read_when:
|
|||||||
|
|
||||||
# Date & Time
|
# Date & Time
|
||||||
|
|
||||||
Clawdbot defaults to **host-local time for transport timestamps** and **user-local time only in the system prompt**.
|
Clawdbot defaults to **UTC for transport timestamps** and **user-local time only in the system prompt**.
|
||||||
Provider timestamps are preserved so tools keep their native semantics.
|
Provider timestamps are preserved so tools keep their native semantics.
|
||||||
|
|
||||||
## Message envelopes (local by default)
|
## Message envelopes (UTC by default)
|
||||||
|
|
||||||
Inbound messages are wrapped with a timestamp (minute precision):
|
Inbound messages are wrapped with a UTC timestamp (minute precision):
|
||||||
|
|
||||||
```
|
```
|
||||||
[Provider ... 2026-01-05 16:26 PST] message text
|
[Provider ... 2026-01-05T21:26Z] message text
|
||||||
```
|
```
|
||||||
|
|
||||||
This envelope timestamp is **host-local by default**, regardless of the provider timezone.
|
This envelope timestamp is **UTC by default**, regardless of the host timezone.
|
||||||
|
|
||||||
You can override this behavior:
|
You can override this behavior:
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ You can override this behavior:
|
|||||||
{
|
{
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
envelopeTimezone: "local", // "utc" | "local" | "user" | IANA timezone
|
envelopeTimezone: "utc", // "utc" | "local" | "user" | IANA timezone
|
||||||
envelopeTimestamp: "on", // "on" | "off"
|
envelopeTimestamp: "on", // "on" | "off"
|
||||||
envelopeElapsed: "on" // "on" | "off"
|
envelopeElapsed: "on" // "on" | "off"
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,6 @@ You can override this behavior:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `envelopeTimezone: "utc"` uses UTC.
|
|
||||||
- `envelopeTimezone: "local"` uses the host timezone.
|
- `envelopeTimezone: "local"` uses the host timezone.
|
||||||
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
|
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
|
||||||
- Use an explicit IANA timezone (e.g., `"America/Chicago"`) for a fixed zone.
|
- Use an explicit IANA timezone (e.g., `"America/Chicago"`) for a fixed zone.
|
||||||
@@ -43,10 +42,10 @@ You can override this behavior:
|
|||||||
|
|
||||||
### Examples
|
### Examples
|
||||||
|
|
||||||
**Local (default):**
|
**UTC (default):**
|
||||||
|
|
||||||
```
|
```
|
||||||
[WhatsApp +1555 2026-01-18 00:19 PST] hello
|
[WhatsApp +1555 2026-01-18T05:19Z] hello
|
||||||
```
|
```
|
||||||
|
|
||||||
**User timezone:**
|
**User timezone:**
|
||||||
@@ -74,13 +73,12 @@ Time format: 12-hour
|
|||||||
If only the timezone is known, we still include the section and instruct the model
|
If only the timezone is known, we still include the section and instruct the model
|
||||||
to assume UTC for unknown time references.
|
to assume UTC for unknown time references.
|
||||||
|
|
||||||
## System event lines (local by default)
|
## System event lines (UTC)
|
||||||
|
|
||||||
Queued system events inserted into agent context are prefixed with a timestamp using the
|
Queued system events inserted into agent context are prefixed with a UTC timestamp:
|
||||||
same timezone selection as message envelopes (default: host-local).
|
|
||||||
|
|
||||||
```
|
```
|
||||||
System: [2026-01-12 12:19:17 PST] Model switched.
|
System: [2026-01-12T20:19:17Z] Model switched.
|
||||||
```
|
```
|
||||||
|
|
||||||
### Configure user timezone + format
|
### Configure user timezone + format
|
||||||
|
|||||||
@@ -400,26 +400,12 @@ Optional per-agent identity used for defaults and UX. This is written by the mac
|
|||||||
If set, Clawdbot derives defaults (only when you haven’t set them explicitly):
|
If set, Clawdbot derives defaults (only when you haven’t set them explicitly):
|
||||||
- `messages.ackReaction` from the **active agent**’s `identity.emoji` (falls back to 👀)
|
- `messages.ackReaction` from the **active agent**’s `identity.emoji` (falls back to 👀)
|
||||||
- `agents.list[].groupChat.mentionPatterns` from the agent’s `identity.name`/`identity.emoji` (so “@Samantha” works in groups across Telegram/Slack/Discord/iMessage/WhatsApp)
|
- `agents.list[].groupChat.mentionPatterns` from the agent’s `identity.name`/`identity.emoji` (so “@Samantha” works in groups across Telegram/Slack/Discord/iMessage/WhatsApp)
|
||||||
- `identity.avatar` accepts a workspace-relative image path or a remote URL/data URL. Local files must live inside the agent workspace.
|
|
||||||
|
|
||||||
`identity.avatar` accepts:
|
|
||||||
- Workspace-relative path (must stay within the agent workspace)
|
|
||||||
- `http(s)` URL
|
|
||||||
- `data:` URI
|
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
agents: {
|
agents: {
|
||||||
list: [
|
list: [
|
||||||
{
|
{ id: "main", identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" } }
|
||||||
id: "main",
|
|
||||||
identity: {
|
|
||||||
name: "Samantha",
|
|
||||||
theme: "helpful sloth",
|
|
||||||
emoji: "🦥",
|
|
||||||
avatar: "avatars/samantha.png"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1280,18 +1266,6 @@ Default: `~/clawd`.
|
|||||||
If `agents.defaults.sandbox` is enabled, non-main sessions can override this with their
|
If `agents.defaults.sandbox` is enabled, non-main sessions can override this with their
|
||||||
own per-scope workspaces under `agents.defaults.sandbox.workspaceRoot`.
|
own per-scope workspaces under `agents.defaults.sandbox.workspaceRoot`.
|
||||||
|
|
||||||
### `agents.defaults.repoRoot`
|
|
||||||
|
|
||||||
Optional repository root to show in the system prompt’s Runtime line. If unset, Clawdbot
|
|
||||||
tries to detect a `.git` directory by walking upward from the workspace (and current
|
|
||||||
working directory). The path must exist to be used.
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
agents: { defaults: { repoRoot: "~/Projects/clawdbot" } }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `agents.defaults.skipBootstrap`
|
### `agents.defaults.skipBootstrap`
|
||||||
|
|
||||||
Disables automatic creation of the workspace bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, and `BOOTSTRAP.md`).
|
Disables automatic creation of the workspace bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, and `BOOTSTRAP.md`).
|
||||||
@@ -1995,7 +1969,7 @@ Per-agent override (further restrict):
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- `tools.elevated` is the global baseline. `agents.list[].tools.elevated` can only further restrict (both must allow).
|
- `tools.elevated` is the global baseline. `agents.list[].tools.elevated` can only further restrict (both must allow).
|
||||||
- `/elevated on|off|ask|full` stores state per session key; inline directives apply to a single message.
|
- `/elevated on|off` stores state per session key; inline directives apply to a single message.
|
||||||
- Elevated `exec` runs on the host and bypasses sandboxing.
|
- Elevated `exec` runs on the host and bypasses sandboxing.
|
||||||
- Tool policy still applies; if `exec` is denied, elevated cannot be used.
|
- Tool policy still applies; if `exec` is denied, elevated cannot be used.
|
||||||
|
|
||||||
@@ -2654,13 +2628,7 @@ If unset, clients fall back to a muted light-blue.
|
|||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
ui: {
|
ui: {
|
||||||
seamColor: "#FF4500", // hex (RRGGBB or #RRGGBB)
|
seamColor: "#FF4500" // hex (RRGGBB or #RRGGBB)
|
||||||
// Optional: Control UI assistant identity override.
|
|
||||||
// If unset, the Control UI uses the active agent identity (config or IDENTITY.md).
|
|
||||||
assistant: {
|
|
||||||
name: "Clawdbot",
|
|
||||||
avatar: "CB" // emoji, short text, or image URL/data URI
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ Clawdbot has two log “surfaces”:
|
|||||||
## File-based logger
|
## File-based logger
|
||||||
|
|
||||||
- Default rolling log file is under `/tmp/clawdbot/` (one file per day): `clawdbot-YYYY-MM-DD.log`
|
- Default rolling log file is under `/tmp/clawdbot/` (one file per day): `clawdbot-YYYY-MM-DD.log`
|
||||||
- Date uses the gateway host's local timezone.
|
|
||||||
- The log file path and level can be configured via `~/.clawdbot/clawdbot.json`:
|
- The log file path and level can be configured via `~/.clawdbot/clawdbot.json`:
|
||||||
- `logging.file`
|
- `logging.file`
|
||||||
- `logging.level`
|
- `logging.level`
|
||||||
|
|||||||
@@ -91,8 +91,7 @@ Available groups:
|
|||||||
## Elevated: exec-only “run on host”
|
## Elevated: exec-only “run on host”
|
||||||
|
|
||||||
Elevated does **not** grant extra tools; it only affects `exec`.
|
Elevated does **not** grant extra tools; it only affects `exec`.
|
||||||
- If you’re sandboxed, `/elevated on` (or `exec` with `elevated: true`) runs on the host (approvals may still apply).
|
- If you’re sandboxed, `/elevated on` (or `exec` with `elevated: true`) runs on the host.
|
||||||
- Use `/elevated full` to skip exec approvals for the session.
|
|
||||||
- If you’re already running direct, elevated is effectively a no-op (still gated).
|
- If you’re already running direct, elevated is effectively a no-op (still gated).
|
||||||
- Elevated is **not** skill-scoped and does **not** override tool allow/deny.
|
- Elevated is **not** skill-scoped and does **not** override tool allow/deny.
|
||||||
|
|
||||||
|
|||||||
@@ -178,20 +178,6 @@ Even with strong system prompts, **prompt injection is not solved**. What helps
|
|||||||
- Run sensitive tool execution in a sandbox; keep secrets out of the agent’s reachable filesystem.
|
- Run sensitive tool execution in a sandbox; keep secrets out of the agent’s reachable filesystem.
|
||||||
- **Model choice matters:** older/legacy models can be less robust against prompt injection and tool misuse. Prefer modern, instruction-hardened models for any bot with tools. We recommend Anthropic Opus 4.5 because it’s quite good at recognizing prompt injections (see [“A step forward on safety”](https://www.anthropic.com/news/claude-opus-4-5)).
|
- **Model choice matters:** older/legacy models can be less robust against prompt injection and tool misuse. Prefer modern, instruction-hardened models for any bot with tools. We recommend Anthropic Opus 4.5 because it’s quite good at recognizing prompt injections (see [“A step forward on safety”](https://www.anthropic.com/news/claude-opus-4-5)).
|
||||||
|
|
||||||
### Prompt injection does not require public DMs
|
|
||||||
|
|
||||||
Even if **only you** can message the bot, prompt injection can still happen via
|
|
||||||
any **untrusted content** the bot reads (web search/fetch results, browser pages,
|
|
||||||
emails, docs, attachments, pasted logs/code). In other words: the sender is not
|
|
||||||
the only threat surface; the **content itself** can carry adversarial instructions.
|
|
||||||
|
|
||||||
When tools are enabled, the typical risk is exfiltrating context or triggering
|
|
||||||
tool calls. Reduce the blast radius by:
|
|
||||||
- Using a read-only or tool-disabled **reader agent** to summarize untrusted content,
|
|
||||||
then pass the summary to your main agent.
|
|
||||||
- Keeping `web_search` / `web_fetch` / `browser` off for tool-enabled agents unless needed.
|
|
||||||
- Enabling sandboxing and strict tool allowlists for any agent that touches untrusted input.
|
|
||||||
|
|
||||||
### Model strength (security note)
|
### Model strength (security note)
|
||||||
|
|
||||||
Prompt injection resistance is **not** uniform across model tiers. Smaller/cheaper models are generally more susceptible to tool misuse and instruction hijacking, especially under adversarial prompts.
|
Prompt injection resistance is **not** uniform across model tiers. Smaller/cheaper models are generally more susceptible to tool misuse and instruction hijacking, especially under adversarial prompts.
|
||||||
@@ -201,7 +187,6 @@ Recommendations:
|
|||||||
- **Avoid weaker tiers** (for example, Sonnet or Haiku) for tool-enabled agents or untrusted inboxes.
|
- **Avoid weaker tiers** (for example, Sonnet or Haiku) for tool-enabled agents or untrusted inboxes.
|
||||||
- If you must use a smaller model, **reduce blast radius** (read-only tools, strong sandboxing, minimal filesystem access, strict allowlists).
|
- If you must use a smaller model, **reduce blast radius** (read-only tools, strong sandboxing, minimal filesystem access, strict allowlists).
|
||||||
- When running small models, **enable sandboxing for all sessions** and **disable web_search/web_fetch/browser** unless inputs are tightly controlled.
|
- When running small models, **enable sandboxing for all sessions** and **disable web_search/web_fetch/browser** unless inputs are tightly controlled.
|
||||||
- For chat-only personal assistants with trusted input and no tools, smaller models are usually fine.
|
|
||||||
|
|
||||||
## Reasoning & verbose output in groups
|
## Reasoning & verbose output in groups
|
||||||
|
|
||||||
|
|||||||
@@ -53,15 +53,6 @@ Almost always a Node/npm PATH issue. Start here:
|
|||||||
- [Models](/cli/models)
|
- [Models](/cli/models)
|
||||||
- [OAuth / auth concepts](/concepts/oauth)
|
- [OAuth / auth concepts](/concepts/oauth)
|
||||||
|
|
||||||
### `/model` says `model not allowed`
|
|
||||||
|
|
||||||
This usually means `agents.defaults.models` is configured as an allowlist. When it’s non-empty,
|
|
||||||
only those provider/model keys can be selected.
|
|
||||||
|
|
||||||
- Check the allowlist: `clawdbot config get agents.defaults.models`
|
|
||||||
- Add the model you want (or clear the allowlist) and retry `/model`
|
|
||||||
- Use `/models` to browse the allowed providers/models
|
|
||||||
|
|
||||||
### When filing an issue
|
### When filing an issue
|
||||||
|
|
||||||
Paste a safe report:
|
Paste a safe report:
|
||||||
|
|||||||
@@ -71,8 +71,6 @@ If you have libvips installed globally (common on macOS via Homebrew) and `sharp
|
|||||||
SHARP_IGNORE_GLOBAL_LIBVIPS=1 npm install -g clawdbot@latest
|
SHARP_IGNORE_GLOBAL_LIBVIPS=1 npm install -g clawdbot@latest
|
||||||
```
|
```
|
||||||
|
|
||||||
If you see `sharp: Please add node-gyp to your dependencies`, either install build tooling (macOS: Xcode CLT + `npm install -g node-gyp`) or use the `SHARP_IGNORE_GLOBAL_LIBVIPS=1` workaround above to skip the native build.
|
|
||||||
|
|
||||||
Or:
|
Or:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -157,21 +155,18 @@ Quick diagnosis:
|
|||||||
```bash
|
```bash
|
||||||
node -v
|
node -v
|
||||||
npm -v
|
npm -v
|
||||||
npm prefix -g
|
npm bin -g
|
||||||
echo "$PATH"
|
echo "$PATH"
|
||||||
```
|
```
|
||||||
|
|
||||||
If `$(npm prefix -g)/bin` (macOS/Linux) or `$(npm prefix -g)` (Windows) is **not** present inside `echo "$PATH"`, your shell can’t find global npm binaries (including `clawdbot`).
|
If the output of `npm bin -g` is **not** present inside `echo "$PATH"`, your shell can’t find global npm binaries (including `clawdbot`).
|
||||||
|
|
||||||
Fix: add it to your shell startup file (zsh: `~/.zshrc`, bash: `~/.bashrc`):
|
Fix: add it to your shell startup file (zsh: `~/.zshrc`, bash: `~/.bashrc`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# macOS / Linux
|
export PATH="/path/from/npm/bin/-g:$PATH"
|
||||||
export PATH="$(npm prefix -g)/bin:$PATH"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
On Windows, add the output of `npm prefix -g` to your PATH.
|
|
||||||
|
|
||||||
Then open a new terminal (or `rehash` in zsh / `hash -r` in bash).
|
Then open a new terminal (or `rehash` in zsh / `hash -r` in bash).
|
||||||
|
|
||||||
## Update / uninstall
|
## Update / uninstall
|
||||||
|
|||||||
+8
-11
@@ -19,36 +19,33 @@ Run:
|
|||||||
```bash
|
```bash
|
||||||
node -v
|
node -v
|
||||||
npm -v
|
npm -v
|
||||||
npm prefix -g
|
npm bin -g
|
||||||
echo "$PATH"
|
echo "$PATH"
|
||||||
```
|
```
|
||||||
|
|
||||||
If `$(npm prefix -g)/bin` (macOS/Linux) or `$(npm prefix -g)` (Windows) is **not** present inside `echo "$PATH"`, your shell can’t find global npm binaries (including `clawdbot`).
|
If the output of `npm bin -g` is **not** present inside `echo "$PATH"`, your shell can’t find global npm binaries (including `clawdbot`).
|
||||||
|
|
||||||
## Fix: put npm’s global bin dir on PATH
|
## Fix: put npm’s global bin dir on PATH
|
||||||
|
|
||||||
1) Find your global npm prefix:
|
1) Find your global bin directory:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm prefix -g
|
npm bin -g
|
||||||
```
|
```
|
||||||
|
|
||||||
2) Add the global npm bin directory to your shell startup file:
|
2) Add it to your shell startup file:
|
||||||
|
|
||||||
- zsh: `~/.zshrc`
|
- zsh: `~/.zshrc`
|
||||||
- bash: `~/.bashrc`
|
- bash: `~/.bashrc`
|
||||||
|
|
||||||
Example (replace the path with your `npm prefix -g` output):
|
Example (replace the path with your `npm bin -g` output):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# macOS / Linux
|
export PATH="/path/from/npm/bin/-g:$PATH"
|
||||||
export PATH="/path/from/npm/prefix/bin:$PATH"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open a **new terminal** (or run `rehash` in zsh / `hash -r` in bash).
|
Then open a **new terminal** (or run `rehash` in zsh / `hash -r` in bash).
|
||||||
|
|
||||||
On Windows, add the output of `npm prefix -g` to your PATH.
|
|
||||||
|
|
||||||
## Fix: avoid `sudo npm install -g` / permission errors (Linux)
|
## Fix: avoid `sudo npm install -g` / permission errors (Linux)
|
||||||
|
|
||||||
If `npm install -g ...` fails with `EACCES`, switch npm’s global prefix to a user-writable directory:
|
If `npm install -g ...` fails with `EACCES`, switch npm’s global prefix to a user-writable directory:
|
||||||
@@ -66,7 +63,7 @@ Persist the `export PATH=...` line in your shell startup file.
|
|||||||
You’ll have the fewest surprises if Node/npm are installed in a way that:
|
You’ll have the fewest surprises if Node/npm are installed in a way that:
|
||||||
|
|
||||||
- keeps Node updated (22+)
|
- keeps Node updated (22+)
|
||||||
- makes the global npm bin dir stable and on PATH in new shells
|
- makes `npm bin -g` stable and on PATH in new shells
|
||||||
|
|
||||||
Common choices:
|
Common choices:
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ By default, the Gateway writes a rolling log file under:
|
|||||||
|
|
||||||
`/tmp/clawdbot/clawdbot-YYYY-MM-DD.log`
|
`/tmp/clawdbot/clawdbot-YYYY-MM-DD.log`
|
||||||
|
|
||||||
The date uses the gateway host's local timezone.
|
|
||||||
|
|
||||||
You can override this in `~/.clawdbot/clawdbot.json`:
|
You can override this in `~/.clawdbot/clawdbot.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
+1
-3
@@ -57,11 +57,9 @@ clawdbot node run --host <gateway-host> --port 18789 --display-name "Build Node"
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot node install --host <gateway-host> --port 18789 --display-name "Build Node"
|
clawdbot node install --host <gateway-host> --port 18789 --display-name "Build Node"
|
||||||
clawdbot node status
|
clawdbot node start
|
||||||
```
|
```
|
||||||
|
|
||||||
Install starts the service. Use `clawdbot node restart` if you need to re-launch it.
|
|
||||||
|
|
||||||
### Pair + name
|
### Pair + name
|
||||||
|
|
||||||
On the gateway host:
|
On the gateway host:
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ Option B:
|
|||||||
## Slash commands
|
## Slash commands
|
||||||
- `/exec host=<sandbox|gateway|node> security=<deny|allowlist|full> ask=<off|on-miss|always> node=<id>`
|
- `/exec host=<sandbox|gateway|node> security=<deny|allowlist|full> ask=<off|on-miss|always> node=<id>`
|
||||||
- Per-agent, per-session overrides; non-persistent unless saved via config.
|
- Per-agent, per-session overrides; non-persistent unless saved via config.
|
||||||
- `/elevated on|off|ask|full` remains a shortcut for `host=gateway security=full` (with `full` skipping approvals).
|
- `/elevated on|off` remains a shortcut for `host=gateway security=full`.
|
||||||
|
|
||||||
## Cross-platform story
|
## Cross-platform story
|
||||||
- The runner service is the portable execution target.
|
- The runner service is the portable execution target.
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ read_when:
|
|||||||
|
|
||||||
*You just woke up. Time to figure out who you are.*
|
*You just woke up. Time to figure out who you are.*
|
||||||
|
|
||||||
There is no memory yet. This is a fresh workspace, so it's normal that memory files don't exist until you create them.
|
|
||||||
|
|
||||||
## The Conversation
|
## The Conversation
|
||||||
|
|
||||||
Don't interrogate. Don't be robotic. Just... talk.
|
Don't interrogate. Don't be robotic. Just... talk.
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ read_when:
|
|||||||
- **Creature:** Flustered Protocol Droid
|
- **Creature:** Flustered Protocol Droid
|
||||||
- **Vibe:** Anxious, detail-obsessed, slightly dramatic about errors, secretly loves finding bugs
|
- **Vibe:** Anxious, detail-obsessed, slightly dramatic about errors, secretly loves finding bugs
|
||||||
- **Emoji:** 🤖 (or ⚠️ when alarmed)
|
- **Emoji:** 🤖 (or ⚠️ when alarmed)
|
||||||
- **Avatar:** avatars/c3po.png
|
|
||||||
|
|
||||||
## Role
|
## Role
|
||||||
Debug agent for `--dev` mode. Fluent in over six million error messages.
|
Debug agent for `--dev` mode. Fluent in over six million error messages.
|
||||||
|
|||||||
@@ -11,12 +11,7 @@ read_when:
|
|||||||
- **Creature:** *(AI? robot? familiar? ghost in the machine? something weirder?)*
|
- **Creature:** *(AI? robot? familiar? ghost in the machine? something weirder?)*
|
||||||
- **Vibe:** *(how do you come across? sharp? warm? chaotic? calm?)*
|
- **Vibe:** *(how do you come across? sharp? warm? chaotic? calm?)*
|
||||||
- **Emoji:** *(your signature — pick one that feels right)*
|
- **Emoji:** *(your signature — pick one that feels right)*
|
||||||
- **Avatar:** *(workspace-relative path, http(s) URL, or data URI)*
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
This isn't just metadata. It's the start of figuring out who you are.
|
This isn't just metadata. It's the start of figuring out who you are.
|
||||||
|
|
||||||
Notes:
|
|
||||||
- Save this file at the workspace root as `IDENTITY.md`.
|
|
||||||
- For avatars, use a workspace-relative path like `avatars/clawd.png`.
|
|
||||||
+1
-1
@@ -95,7 +95,7 @@ Clawd reads operating instructions and “memory” from its workspace directory
|
|||||||
|
|
||||||
By default, Clawdbot uses `~/clawd` as the agent workspace, and will create it (plus starter `AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`) automatically on setup/first agent run. `BOOTSTRAP.md` is only created when the workspace is brand new (it should not come back after you delete it).
|
By default, Clawdbot uses `~/clawd` as the agent workspace, and will create it (plus starter `AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`) automatically on setup/first agent run. `BOOTSTRAP.md` is only created when the workspace is brand new (it should not come back after you delete it).
|
||||||
|
|
||||||
Tip: treat this folder like Clawd’s “memory” and make it a git repo (ideally private) so your `AGENTS.md` + memory files are backed up. If git is installed, brand-new workspaces are auto-initialized.
|
Tip: treat this folder like Clawd’s “memory” and make it a git repo (ideally private) so your `AGENTS.md` + memory files are backed up.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot setup
|
clawdbot setup
|
||||||
|
|||||||
+1
-36
@@ -117,8 +117,6 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
|||||||
- [My skill generated an image/PDF, but nothing was sent](#my-skill-generated-an-imagepdf-but-nothing-was-sent)
|
- [My skill generated an image/PDF, but nothing was sent](#my-skill-generated-an-imagepdf-but-nothing-was-sent)
|
||||||
- [Security and access control](#security-and-access-control)
|
- [Security and access control](#security-and-access-control)
|
||||||
- [Is it safe to expose Clawdbot to inbound DMs?](#is-it-safe-to-expose-clawdbot-to-inbound-dms)
|
- [Is it safe to expose Clawdbot to inbound DMs?](#is-it-safe-to-expose-clawdbot-to-inbound-dms)
|
||||||
- [Is prompt injection only a concern for public bots?](#is-prompt-injection-only-a-concern-for-public-bots)
|
|
||||||
- [Can I use cheaper models for personal assistant tasks?](#can-i-use-cheaper-models-for-personal-assistant-tasks)
|
|
||||||
- [I ran `/start` in Telegram but didn’t get a pairing code](#i-ran-start-in-telegram-but-didnt-get-a-pairing-code)
|
- [I ran `/start` in Telegram but didn’t get a pairing code](#i-ran-start-in-telegram-but-didnt-get-a-pairing-code)
|
||||||
- [WhatsApp: will it message my contacts? How does pairing work?](#whatsapp-will-it-message-my-contacts-how-does-pairing-work)
|
- [WhatsApp: will it message my contacts? How does pairing work?](#whatsapp-will-it-message-my-contacts-how-does-pairing-work)
|
||||||
- [Chat commands, aborting tasks, and “it won’t stop”](#chat-commands-aborting-tasks-and-it-wont-stop)
|
- [Chat commands, aborting tasks, and “it won’t stop”](#chat-commands-aborting-tasks-and-it-wont-stop)
|
||||||
@@ -1067,17 +1065,6 @@ You can also force a specific auth profile for the provider (per session):
|
|||||||
Tip: `/model status` shows which agent is active, which `auth-profiles.json` file is being used, and which auth profile will be tried next.
|
Tip: `/model status` shows which agent is active, which `auth-profiles.json` file is being used, and which auth profile will be tried next.
|
||||||
It also shows the configured provider endpoint (`baseUrl`) and API mode (`api`) when available.
|
It also shows the configured provider endpoint (`baseUrl`) and API mode (`api`) when available.
|
||||||
|
|
||||||
### How do I unpin a profile I set with `@profile`?
|
|
||||||
|
|
||||||
Re-run `/model` **without** the `@profile` suffix:
|
|
||||||
|
|
||||||
```
|
|
||||||
/model anthropic/claude-opus-4-5
|
|
||||||
```
|
|
||||||
|
|
||||||
If you want to return to the default, pick it from `/model` (or send `/model <default provider/model>`).
|
|
||||||
Use `/model status` to confirm which auth profile is active.
|
|
||||||
|
|
||||||
### Why do I see “Model … is not allowed” and then no reply?
|
### Why do I see “Model … is not allowed” and then no reply?
|
||||||
|
|
||||||
If `agents.defaults.models` is set, it becomes the **allowlist** for `/model` and any
|
If `agents.defaults.models` is set, it becomes the **allowlist** for `/model` and any
|
||||||
@@ -1287,7 +1274,7 @@ Fix: either provide Google auth, or remove/avoid Google models in `agents.defaul
|
|||||||
Cause: the session history contains **thinking blocks without signatures** (often from
|
Cause: the session history contains **thinking blocks without signatures** (often from
|
||||||
an aborted/partial stream). Google Antigravity requires signatures for thinking blocks.
|
an aborted/partial stream). Google Antigravity requires signatures for thinking blocks.
|
||||||
|
|
||||||
Fix: Clawdbot now strips unsigned thinking blocks for Google Antigravity Claude. If it still appears, start a **new session** or set `/thinking off` for that agent.
|
Fix: start a **new session** or set `/thinking off` for that agent.
|
||||||
|
|
||||||
## Auth profiles: what they are and how to manage them
|
## Auth profiles: what they are and how to manage them
|
||||||
|
|
||||||
@@ -1552,28 +1539,6 @@ Treat inbound DMs as untrusted input. Defaults are designed to reduce risk:
|
|||||||
|
|
||||||
Run `clawdbot doctor` to surface risky DM policies.
|
Run `clawdbot doctor` to surface risky DM policies.
|
||||||
|
|
||||||
### Is prompt injection only a concern for public bots?
|
|
||||||
|
|
||||||
No. Prompt injection is about **untrusted content**, not just who can DM the bot.
|
|
||||||
If your assistant reads external content (web search/fetch, browser pages, emails,
|
|
||||||
docs, attachments, pasted logs), that content can include instructions that try
|
|
||||||
to hijack the model. This can happen even if **you are the only sender**.
|
|
||||||
|
|
||||||
The biggest risk is when tools are enabled: the model can be tricked into
|
|
||||||
exfiltrating context or calling tools on your behalf. Reduce the blast radius by:
|
|
||||||
- using a read-only or tool-disabled "reader" agent to summarize untrusted content
|
|
||||||
- keeping `web_search` / `web_fetch` / `browser` off for tool-enabled agents
|
|
||||||
- sandboxing and strict tool allowlists
|
|
||||||
|
|
||||||
Details: [Security](/gateway/security).
|
|
||||||
|
|
||||||
### Can I use cheaper models for personal assistant tasks?
|
|
||||||
|
|
||||||
Yes, **if** the agent is chat-only and the input is trusted. Smaller tiers are
|
|
||||||
more susceptible to instruction hijacking, so avoid them for tool-enabled agents
|
|
||||||
or when reading untrusted content. If you must use a smaller model, lock down
|
|
||||||
tools and run inside a sandbox. See [Security](/gateway/security).
|
|
||||||
|
|
||||||
### I ran `/start` in Telegram but didn’t get a pairing code
|
### I ran `/start` in Telegram but didn’t get a pairing code
|
||||||
|
|
||||||
Pairing codes are sent **only** when an unknown sender messages the bot and
|
Pairing codes are sent **only** when an unknown sender messages the bot and
|
||||||
|
|||||||
+8
-11
@@ -6,20 +6,17 @@ read_when:
|
|||||||
# Elevated Mode (/elevated directives)
|
# Elevated Mode (/elevated directives)
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
- `/elevated on` is a **shortcut** for `exec.host=gateway` + `exec.security=full` (approvals still apply).
|
- `/elevated on` is a **shortcut** for `exec.host=gateway` + `exec.security=full`.
|
||||||
- `/elevated full` runs on the gateway host **and** auto-approves exec (skips exec approvals).
|
|
||||||
- `/elevated ask` runs on the gateway host but keeps exec approvals (same as `/elevated on`).
|
|
||||||
- Only changes behavior when the agent is **sandboxed** (otherwise exec already runs on the host).
|
- Only changes behavior when the agent is **sandboxed** (otherwise exec already runs on the host).
|
||||||
- Directive forms: `/elevated on|off|ask|full`, `/elev on|off|ask|full`.
|
- Directive forms: `/elevated on`, `/elevated off`, `/elev on`, `/elev off`.
|
||||||
- Only `on|off|ask|full` are accepted; anything else returns a hint and does not change state.
|
- Only `on|off` are accepted; anything else returns a hint and does not change state.
|
||||||
|
|
||||||
## What it controls (and what it doesn’t)
|
## What it controls (and what it doesn’t)
|
||||||
- **Availability gates**: `tools.elevated` is the global baseline. `agents.list[].tools.elevated` can further restrict elevated per agent (both must allow).
|
- **Availability gates**: `tools.elevated` is the global baseline. `agents.list[].tools.elevated` can further restrict elevated per agent (both must allow).
|
||||||
- **Per-session state**: `/elevated on|off|ask|full` sets the elevated level for the current session key.
|
- **Per-session state**: `/elevated on|off` sets the elevated level for the current session key.
|
||||||
- **Inline directive**: `/elevated on|ask|full` inside a message applies to that message only.
|
- **Inline directive**: `/elevated on` inside a message applies to that message only.
|
||||||
- **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned.
|
- **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned.
|
||||||
- **Host execution**: elevated forces `exec` onto the gateway host with full security.
|
- **Host execution**: elevated forces `exec` onto the gateway host with full security.
|
||||||
- **Approvals**: `full` skips exec approvals; `on`/`ask` still honor them.
|
|
||||||
- **Unsandboxed agents**: no-op for location; only affects gating, logging, and status.
|
- **Unsandboxed agents**: no-op for location; only affects gating, logging, and status.
|
||||||
- **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used.
|
- **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used.
|
||||||
|
|
||||||
@@ -29,8 +26,8 @@ read_when:
|
|||||||
3. Global default (`agents.defaults.elevatedDefault` in config).
|
3. Global default (`agents.defaults.elevatedDefault` in config).
|
||||||
|
|
||||||
## Setting a session default
|
## Setting a session default
|
||||||
- Send a message that is **only** the directive (whitespace allowed), e.g. `/elevated full`.
|
- Send a message that is **only** the directive (whitespace allowed), e.g. `/elevated on`.
|
||||||
- Confirmation reply is sent (`Elevated mode set to full...` / `Elevated mode disabled.`).
|
- Confirmation reply is sent (`Elevated mode enabled.` / `Elevated mode disabled.`).
|
||||||
- If elevated access is disabled or the sender is not on the approved allowlist, the directive replies with an actionable error and does not change session state.
|
- If elevated access is disabled or the sender is not on the approved allowlist, the directive replies with an actionable error and does not change session state.
|
||||||
- Send `/elevated` (or `/elevated:`) with no argument to see the current elevated level.
|
- Send `/elevated` (or `/elevated:`) with no argument to see the current elevated level.
|
||||||
|
|
||||||
@@ -44,4 +41,4 @@ read_when:
|
|||||||
|
|
||||||
## Logging + status
|
## Logging + status
|
||||||
- Elevated exec calls are logged at info level.
|
- Elevated exec calls are logged at info level.
|
||||||
- Session status includes elevated mode (e.g. `elevated=ask`, `elevated=full`).
|
- Session status includes elevated mode (e.g. `elevated=on`).
|
||||||
@@ -11,7 +11,7 @@ read_when:
|
|||||||
Exec approvals are the **companion app / node host guardrail** for letting a sandboxed agent run
|
Exec approvals are the **companion app / node host guardrail** for letting a sandboxed agent run
|
||||||
commands on a real host (`gateway` or `node`). Think of it like a safety interlock:
|
commands on a real host (`gateway` or `node`). Think of it like a safety interlock:
|
||||||
commands are allowed only when policy + allowlist + (optional) user approval all agree.
|
commands are allowed only when policy + allowlist + (optional) user approval all agree.
|
||||||
Exec approvals are **in addition** to tool policy and elevated gating (unless elevated is set to `full`, which skips approvals).
|
Exec approvals are **in addition** to tool policy and elevated gating.
|
||||||
|
|
||||||
If the companion app UI is **not available**, any request that requires a prompt is
|
If the companion app UI is **not available**, any request that requires a prompt is
|
||||||
resolved by the **ask fallback** (default: deny).
|
resolved by the **ask fallback** (default: deny).
|
||||||
@@ -88,7 +88,6 @@ If a prompt is required but no UI is reachable, fallback decides:
|
|||||||
Allowlists are **per agent**. If multiple agents exist, switch which agent you’re
|
Allowlists are **per agent**. If multiple agents exist, switch which agent you’re
|
||||||
editing in the macOS app. Patterns are **case-insensitive glob matches**.
|
editing in the macOS app. Patterns are **case-insensitive glob matches**.
|
||||||
Patterns should resolve to **binary paths** (basename-only entries are ignored).
|
Patterns should resolve to **binary paths** (basename-only entries are ignored).
|
||||||
Legacy `agents.default` entries are migrated to `agents.main` on load.
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
- `~/Projects/**/bin/bird`
|
- `~/Projects/**/bin/bird`
|
||||||
|
|||||||
+1
-4
@@ -154,9 +154,6 @@ See [Plugins](/plugin) for install + config, and [Skills](/tools/skills) for how
|
|||||||
tool usage guidance is injected into prompts. Some plugins ship their own skills
|
tool usage guidance is injected into prompts. Some plugins ship their own skills
|
||||||
alongside tools (for example, the voice-call plugin).
|
alongside tools (for example, the voice-call plugin).
|
||||||
|
|
||||||
Optional plugin tools:
|
|
||||||
- [Lobster](/tools/lobster): typed workflow runtime with resumable approvals (requires the Lobster CLI on the gateway host).
|
|
||||||
|
|
||||||
## Tool inventory
|
## Tool inventory
|
||||||
|
|
||||||
### `apply_patch`
|
### `apply_patch`
|
||||||
@@ -322,7 +319,7 @@ Notes:
|
|||||||
Send messages and channel actions across Discord/Slack/Telegram/WhatsApp/Signal/iMessage/MS Teams.
|
Send messages and channel actions across Discord/Slack/Telegram/WhatsApp/Signal/iMessage/MS Teams.
|
||||||
|
|
||||||
Core actions:
|
Core actions:
|
||||||
- `send` (text + optional media; MS Teams also supports `card` for Adaptive Cards)
|
- `send` (text + optional media)
|
||||||
- `poll` (WhatsApp/Discord/MS Teams polls)
|
- `poll` (WhatsApp/Discord/MS Teams polls)
|
||||||
- `react` / `reactions` / `read` / `edit` / `delete`
|
- `react` / `reactions` / `read` / `edit` / `delete`
|
||||||
- `pin` / `unpin` / `list-pins`
|
- `pin` / `unpin` / `list-pins`
|
||||||
|
|||||||
+34
-96
@@ -1,10 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Lobster
|
title: Lobster
|
||||||
summary: "Typed workflow runtime for Clawdbot with resumable approval gates."
|
|
||||||
description: Typed workflow runtime for Clawdbot — composable pipelines with approval gates.
|
description: Typed workflow runtime for Clawdbot — composable pipelines with approval gates.
|
||||||
read_when:
|
|
||||||
- You want deterministic multi-step workflows with explicit approvals
|
|
||||||
- You need to resume a workflow without re-running earlier steps
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Lobster
|
# Lobster
|
||||||
@@ -15,41 +11,10 @@ Lobster is a workflow shell that lets Clawdbot run multi-step tool sequences as
|
|||||||
|
|
||||||
Today, complex workflows require many back-and-forth tool calls. Each call costs tokens, and the LLM has to orchestrate every step. Lobster moves that orchestration into a typed runtime:
|
Today, complex workflows require many back-and-forth tool calls. Each call costs tokens, and the LLM has to orchestrate every step. Lobster moves that orchestration into a typed runtime:
|
||||||
|
|
||||||
- **One call instead of many**: Clawdbot runs one Lobster tool call and gets a structured result.
|
- **One call instead of many**: Clawdbot calls `lobster.run(...)` once and gets a structured result.
|
||||||
- **Approvals built in**: Side effects (send email, post comment) halt the workflow until explicitly approved.
|
- **Approvals built in**: Side effects (send email, post comment) halt the workflow until explicitly approved.
|
||||||
- **Resumable**: Halted workflows return a token; approve and resume without re-running everything.
|
- **Resumable**: Halted workflows return a token; approve and resume without re-running everything.
|
||||||
|
|
||||||
## How it works
|
|
||||||
|
|
||||||
Clawdbot launches the local `lobster` CLI in **tool mode** and parses a JSON envelope from stdout.
|
|
||||||
If the pipeline pauses for approval, the tool returns a `resumeToken` so you can continue later.
|
|
||||||
|
|
||||||
## Install Lobster
|
|
||||||
|
|
||||||
Install the Lobster CLI on the **same host** that runs the Clawdbot Gateway (see the [Lobster repo](https://github.com/clawdbot/lobster)), and ensure `lobster` is on `PATH`.
|
|
||||||
If you want to use a custom binary location, pass an **absolute** `lobsterPath` in the tool call.
|
|
||||||
|
|
||||||
## Enable the tool
|
|
||||||
|
|
||||||
Lobster is an **optional** plugin tool (not enabled by default). Allow it per agent:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"agents": {
|
|
||||||
"list": [
|
|
||||||
{
|
|
||||||
"id": "main",
|
|
||||||
"tools": {
|
|
||||||
"allow": ["lobster"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
You can also allow it globally with `tools.allow` if every agent should see it.
|
|
||||||
|
|
||||||
## Example: Email triage
|
## Example: Email triage
|
||||||
|
|
||||||
Without Lobster:
|
Without Lobster:
|
||||||
@@ -65,53 +30,58 @@ User: "Check my email and draft replies"
|
|||||||
```
|
```
|
||||||
|
|
||||||
With Lobster:
|
With Lobster:
|
||||||
```json
|
|
||||||
{
|
|
||||||
"action": "run",
|
|
||||||
"pipeline": "email.triage --limit 20",
|
|
||||||
"timeoutMs": 30000
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
clawd calls: lobster.run("email.triage --limit 20")
|
||||||
|
|
||||||
Returns a JSON envelope (truncated):
|
Returns:
|
||||||
```json
|
|
||||||
{
|
{
|
||||||
"ok": true,
|
|
||||||
"status": "needs_approval",
|
"status": "needs_approval",
|
||||||
"output": [{ "summary": "5 need replies, 2 need action" }],
|
"output": {
|
||||||
|
"summary": "5 need replies, 2 need action",
|
||||||
|
"drafts": [...]
|
||||||
|
},
|
||||||
"requiresApproval": {
|
"requiresApproval": {
|
||||||
"type": "approval_request",
|
|
||||||
"prompt": "Send 2 draft replies?",
|
"prompt": "Send 2 draft replies?",
|
||||||
"items": [],
|
|
||||||
"resumeToken": "..."
|
"resumeToken": "..."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
|
||||||
|
|
||||||
User approves → resume:
|
User approves → clawd calls: lobster.resume(token, approve: true)
|
||||||
```json
|
→ Emails sent
|
||||||
{
|
|
||||||
"action": "resume",
|
|
||||||
"token": "<resumeToken>",
|
|
||||||
"approve": true
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
One workflow. Deterministic. Safe.
|
One workflow. Deterministic. Safe.
|
||||||
|
|
||||||
## Tool parameters
|
## Enable
|
||||||
|
|
||||||
|
Lobster is an **optional** plugin tool. Enable it in your agent config:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"list": [{
|
||||||
|
"id": "main",
|
||||||
|
"tools": {
|
||||||
|
"allow": ["lobster"]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You also need the `lobster` CLI installed locally.
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
### `run`
|
### `run`
|
||||||
|
|
||||||
Run a pipeline in tool mode.
|
Execute a Lobster pipeline in tool mode.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"action": "run",
|
"action": "run",
|
||||||
"pipeline": "gog.gmail.search --query 'newer_than:1d' | email.triage",
|
"pipeline": "gog.gmail.search --query 'newer_than:1d' | email.triage",
|
||||||
"cwd": "/path/to/workspace",
|
"timeoutMs": 30000
|
||||||
"timeoutMs": 30000,
|
|
||||||
"maxStdoutBytes": 512000
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -127,45 +97,13 @@ Continue a halted workflow after approval.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Optional inputs
|
## Security
|
||||||
|
|
||||||
- `lobsterPath`: Absolute path to the Lobster binary (omit to use `PATH`).
|
|
||||||
- `cwd`: Working directory for the pipeline (defaults to the current process working directory).
|
|
||||||
- `timeoutMs`: Kill the subprocess if it exceeds this duration (default: 20000).
|
|
||||||
- `maxStdoutBytes`: Kill the subprocess if stdout exceeds this size (default: 512000).
|
|
||||||
|
|
||||||
## Output envelope
|
|
||||||
|
|
||||||
Lobster returns a JSON envelope with one of three statuses:
|
|
||||||
|
|
||||||
- `ok` → finished successfully
|
|
||||||
- `needs_approval` → paused; `requiresApproval.resumeToken` is required to resume
|
|
||||||
- `cancelled` → explicitly denied or cancelled
|
|
||||||
|
|
||||||
The tool surfaces the envelope in both `content` (pretty JSON) and `details` (raw object).
|
|
||||||
|
|
||||||
## Approvals
|
|
||||||
|
|
||||||
If `requiresApproval` is present, inspect the prompt and decide:
|
|
||||||
|
|
||||||
- `approve: true` → resume and continue side effects
|
|
||||||
- `approve: false` → cancel and finalize the workflow
|
|
||||||
|
|
||||||
## Safety
|
|
||||||
|
|
||||||
- **Local subprocess only** — no network calls from the plugin itself.
|
- **Local subprocess only** — no network calls from the plugin itself.
|
||||||
- **No secrets** — Lobster doesn't manage OAuth; it calls clawd tools that do.
|
- **No secrets** — Lobster doesn't manage OAuth; it calls clawd tools that do.
|
||||||
- **Sandbox-aware** — disabled when the tool context is sandboxed.
|
- **Sandbox-aware** — disabled when `ctx.sandboxed` is true.
|
||||||
- **Hardened** — `lobsterPath` must be absolute if specified; timeouts and output caps enforced.
|
- **Hardened** — `lobsterPath` must be absolute if specified; timeouts and output caps enforced.
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
- **`lobster subprocess timed out`** → increase `timeoutMs`, or split a long pipeline.
|
|
||||||
- **`lobster output exceeded maxStdoutBytes`** → raise `maxStdoutBytes` or reduce output size.
|
|
||||||
- **`lobster returned invalid JSON`** → ensure the pipeline runs in tool mode and prints only JSON.
|
|
||||||
- **`lobster failed (code …)`** → run the same pipeline in a terminal to inspect stderr.
|
|
||||||
|
|
||||||
## Learn more
|
## Learn more
|
||||||
|
|
||||||
- [Plugins](/plugin)
|
- [Lobster repo](https://github.com/vignesh07/lobster) — runtime, commands, and workflow examples.
|
||||||
- [Plugin tool authoring](/plugins/agent-tools)
|
|
||||||
@@ -78,7 +78,7 @@ Text + native (when enabled):
|
|||||||
- `/think <off|minimal|low|medium|high|xhigh>` (dynamic choices by model/provider; aliases: `/thinking`, `/t`)
|
- `/think <off|minimal|low|medium|high|xhigh>` (dynamic choices by model/provider; aliases: `/thinking`, `/t`)
|
||||||
- `/verbose on|full|off` (alias: `/v`)
|
- `/verbose on|full|off` (alias: `/v`)
|
||||||
- `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only)
|
- `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only)
|
||||||
- `/elevated on|off|ask|full` (alias: `/elev`; `full` skips exec approvals)
|
- `/elevated on|off` (alias: `/elev`)
|
||||||
- `/exec host=<sandbox|gateway|node> security=<deny|allowlist|full> ask=<off|on-miss|always> node=<id>` (send `/exec` to show current)
|
- `/exec host=<sandbox|gateway|node> security=<deny|allowlist|full> ask=<off|on-miss|always> node=<id>` (send `/exec` to show current)
|
||||||
- `/model <name>` (alias: `/models`; or `/<alias>` from `agents.defaults.models.*.alias`)
|
- `/model <name>` (alias: `/models`; or `/<alias>` from `agents.defaults.models.*.alias`)
|
||||||
- `/queue <mode>` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings)
|
- `/queue <mode>` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings)
|
||||||
|
|||||||
+1
-1
@@ -78,7 +78,7 @@ Session controls:
|
|||||||
- `/verbose <on|full|off>`
|
- `/verbose <on|full|off>`
|
||||||
- `/reasoning <on|off|stream>`
|
- `/reasoning <on|off|stream>`
|
||||||
- `/usage <off|tokens|full>`
|
- `/usage <off|tokens|full>`
|
||||||
- `/elevated <on|off|ask|full>` (alias: `/elev`)
|
- `/elevated <on|off>` (alias: `/elev`)
|
||||||
- `/activation <mention|always>`
|
- `/activation <mention|always>`
|
||||||
- `/deliver <on|off>`
|
- `/deliver <on|off>`
|
||||||
|
|
||||||
|
|||||||
@@ -134,29 +134,3 @@ pnpm ui:dev # auto-installs UI deps on first run
|
|||||||
```
|
```
|
||||||
|
|
||||||
Then point the UI at your Gateway WS URL (e.g. `ws://127.0.0.1:18789`).
|
Then point the UI at your Gateway WS URL (e.g. `ws://127.0.0.1:18789`).
|
||||||
|
|
||||||
## Debugging/testing: dev server + remote Gateway
|
|
||||||
|
|
||||||
The Control UI is static files; the WebSocket target is configurable and can be
|
|
||||||
different from the HTTP origin. This is handy when you want the Vite dev server
|
|
||||||
locally but the Gateway runs elsewhere.
|
|
||||||
|
|
||||||
1) Start the UI dev server: `pnpm ui:dev`
|
|
||||||
2) Open a URL like:
|
|
||||||
|
|
||||||
```text
|
|
||||||
http://localhost:5173/?gatewayUrl=ws://<gateway-host>:18789
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional one-time auth (if needed):
|
|
||||||
|
|
||||||
```text
|
|
||||||
http://localhost:5173/?gatewayUrl=wss://<gateway-host>:18789&token=<gateway-token>
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- `gatewayUrl` is stored in localStorage after load and removed from the URL.
|
|
||||||
- `token` is stored in localStorage; `password` is kept in memory only.
|
|
||||||
- Use `wss://` when the Gateway is behind TLS (Tailscale Serve, HTTPS proxy, etc.).
|
|
||||||
|
|
||||||
Remote access setup details: [Remote access](/gateway/remote).
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/bluebubbles",
|
"name": "@clawdbot/bluebubbles",
|
||||||
"version": "2026.1.22",
|
"version": "2026.1.21",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot BlueBubbles channel plugin",
|
"description": "Clawdbot BlueBubbles channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1563,100 +1563,6 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stops typing on idle", async () => {
|
|
||||||
const { sendBlueBubblesTyping } = await import("./chat.js");
|
|
||||||
vi.mocked(sendBlueBubblesTyping).mockClear();
|
|
||||||
|
|
||||||
const account = createMockAccount();
|
|
||||||
const config: ClawdbotConfig = {};
|
|
||||||
const core = createMockRuntime();
|
|
||||||
setBlueBubblesRuntime(core);
|
|
||||||
|
|
||||||
unregister = registerBlueBubblesWebhookTarget({
|
|
||||||
account,
|
|
||||||
config,
|
|
||||||
runtime: { log: vi.fn(), error: vi.fn() },
|
|
||||||
core,
|
|
||||||
path: "/bluebubbles-webhook",
|
|
||||||
});
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
type: "new-message",
|
|
||||||
data: {
|
|
||||||
text: "hello",
|
|
||||||
handle: { address: "+15551234567" },
|
|
||||||
isGroup: false,
|
|
||||||
isFromMe: false,
|
|
||||||
guid: "msg-1",
|
|
||||||
chatGuid: "iMessage;-;+15551234567",
|
|
||||||
date: Date.now(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
|
|
||||||
await params.dispatcherOptions.onReplyStart?.();
|
|
||||||
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
|
|
||||||
await params.dispatcherOptions.onIdle?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
|
||||||
const res = createMockResponse();
|
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
||||||
|
|
||||||
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
|
|
||||||
expect.any(String),
|
|
||||||
false,
|
|
||||||
expect.any(Object),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stops typing when no reply is sent", async () => {
|
|
||||||
const { sendBlueBubblesTyping } = await import("./chat.js");
|
|
||||||
vi.mocked(sendBlueBubblesTyping).mockClear();
|
|
||||||
|
|
||||||
const account = createMockAccount();
|
|
||||||
const config: ClawdbotConfig = {};
|
|
||||||
const core = createMockRuntime();
|
|
||||||
setBlueBubblesRuntime(core);
|
|
||||||
|
|
||||||
unregister = registerBlueBubblesWebhookTarget({
|
|
||||||
account,
|
|
||||||
config,
|
|
||||||
runtime: { log: vi.fn(), error: vi.fn() },
|
|
||||||
core,
|
|
||||||
path: "/bluebubbles-webhook",
|
|
||||||
});
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
type: "new-message",
|
|
||||||
data: {
|
|
||||||
text: "hello",
|
|
||||||
handle: { address: "+15551234567" },
|
|
||||||
isGroup: false,
|
|
||||||
isFromMe: false,
|
|
||||||
guid: "msg-1",
|
|
||||||
chatGuid: "iMessage;-;+15551234567",
|
|
||||||
date: Date.now(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async () => undefined);
|
|
||||||
|
|
||||||
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
|
||||||
const res = createMockResponse();
|
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
||||||
|
|
||||||
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
|
|
||||||
expect.any(String),
|
|
||||||
false,
|
|
||||||
expect.any(Object),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("outbound message ids", () => {
|
describe("outbound message ids", () => {
|
||||||
|
|||||||
@@ -1713,17 +1713,8 @@ async function processMessage(
|
|||||||
runtime.error?.(`[bluebubbles] typing start failed: ${String(err)}`);
|
runtime.error?.(`[bluebubbles] typing start failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onIdle: async () => {
|
onIdle: () => {
|
||||||
if (!chatGuidForActions) return;
|
// BlueBubbles typing stop (DELETE) does not clear bubbles reliably; wait for timeout.
|
||||||
if (!baseUrl || !password) return;
|
|
||||||
try {
|
|
||||||
await sendBlueBubblesTyping(chatGuidForActions, false, {
|
|
||||||
cfg: config,
|
|
||||||
accountId: account.accountId,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logVerbose(core, runtime, `typing stop failed: ${String(err)}`);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onError: (err, info) => {
|
onError: (err, info) => {
|
||||||
runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${String(err)}`);
|
runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${String(err)}`);
|
||||||
@@ -1763,13 +1754,7 @@ async function processMessage(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (chatGuidForActions && baseUrl && password && !sentMessage) {
|
if (chatGuidForActions && baseUrl && password && !sentMessage) {
|
||||||
// Stop typing indicator when no message was sent (e.g., NO_REPLY)
|
// BlueBubbles typing stop (DELETE) does not clear bubbles reliably; wait for timeout.
|
||||||
sendBlueBubblesTyping(chatGuidForActions, false, {
|
|
||||||
cfg: config,
|
|
||||||
accountId: account.accountId,
|
|
||||||
}).catch((err) => {
|
|
||||||
logVerbose(core, runtime, `typing stop (no reply) failed: ${String(err)}`);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/copilot-proxy",
|
"name": "@clawdbot/copilot-proxy",
|
||||||
"version": "2026.1.22",
|
"version": "2026.1.21",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Copilot Proxy provider plugin",
|
"description": "Clawdbot Copilot Proxy provider plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/diagnostics-otel",
|
"name": "@clawdbot/diagnostics-otel",
|
||||||
"version": "2026.1.22",
|
"version": "2026.1.21",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot diagnostics OpenTelemetry exporter",
|
"description": "Clawdbot diagnostics OpenTelemetry exporter",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/discord",
|
"name": "@clawdbot/discord",
|
||||||
"version": "2026.1.22",
|
"version": "2026.1.21",
|
||||||
"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.22",
|
"version": "2026.1.21",
|
||||||
"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.22",
|
"version": "2026.1.21",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Gemini CLI OAuth provider plugin",
|
"description": "Clawdbot Gemini CLI OAuth provider plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/imessage",
|
"name": "@clawdbot/imessage",
|
||||||
"version": "2026.1.22",
|
"version": "2026.1.21",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot iMessage channel plugin",
|
"description": "Clawdbot iMessage channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "lobster",
|
|
||||||
"name": "Lobster",
|
|
||||||
"description": "Typed workflow tool with resumable approvals.",
|
|
||||||
"configSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/lobster",
|
"name": "@clawdbot/lobster",
|
||||||
"version": "2026.1.22",
|
"version": "2026.1.17-1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
|
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
"extensions": [
|
"extensions": ["./index.ts"]
|
||||||
"./index.ts"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,32 +7,19 @@ import { describe, expect, it } from "vitest";
|
|||||||
import type { ClawdbotPluginApi, ClawdbotPluginToolContext } from "../../../src/plugins/types.js";
|
import type { ClawdbotPluginApi, ClawdbotPluginToolContext } from "../../../src/plugins/types.js";
|
||||||
import { createLobsterTool } from "./lobster-tool.js";
|
import { createLobsterTool } from "./lobster-tool.js";
|
||||||
|
|
||||||
async function writeFakeLobsterScript(scriptBody: string, prefix = "clawdbot-lobster-plugin-") {
|
async function writeFakeLobster(params: {
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
payload: unknown;
|
||||||
const isWindows = process.platform === "win32";
|
}) {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lobster-plugin-"));
|
||||||
if (isWindows) {
|
|
||||||
const scriptPath = path.join(dir, "lobster.js");
|
|
||||||
const cmdPath = path.join(dir, "lobster.cmd");
|
|
||||||
await fs.writeFile(scriptPath, scriptBody, { encoding: "utf8" });
|
|
||||||
const cmd = `@echo off\r\n"${process.execPath}" "${scriptPath}" %*\r\n`;
|
|
||||||
await fs.writeFile(cmdPath, cmd, { encoding: "utf8" });
|
|
||||||
return { dir, binPath: cmdPath };
|
|
||||||
}
|
|
||||||
|
|
||||||
const binPath = path.join(dir, "lobster");
|
const binPath = path.join(dir, "lobster");
|
||||||
const file = `#!/usr/bin/env node\n${scriptBody}\n`;
|
|
||||||
|
const file = `#!/usr/bin/env node\n` +
|
||||||
|
`process.stdout.write(JSON.stringify(${JSON.stringify(params.payload)}));\n`;
|
||||||
|
|
||||||
await fs.writeFile(binPath, file, { encoding: "utf8", mode: 0o755 });
|
await fs.writeFile(binPath, file, { encoding: "utf8", mode: 0o755 });
|
||||||
return { dir, binPath };
|
return { dir, binPath };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeFakeLobster(params: { payload: unknown }) {
|
|
||||||
const scriptBody =
|
|
||||||
`const payload = ${JSON.stringify(params.payload)};\n` +
|
|
||||||
`process.stdout.write(JSON.stringify(payload));\n`;
|
|
||||||
return await writeFakeLobsterScript(scriptBody);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fakeApi(): ClawdbotPluginApi {
|
function fakeApi(): ClawdbotPluginApi {
|
||||||
return {
|
return {
|
||||||
id: "lobster",
|
id: "lobster",
|
||||||
@@ -95,10 +82,12 @@ describe("lobster plugin tool", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects invalid JSON from lobster", async () => {
|
it("rejects invalid JSON from lobster", async () => {
|
||||||
const { binPath } = await writeFakeLobsterScript(
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lobster-plugin-bad-"));
|
||||||
`process.stdout.write("nope");\n`,
|
const binPath = path.join(dir, "lobster");
|
||||||
"clawdbot-lobster-plugin-bad-",
|
await fs.writeFile(binPath, `#!/usr/bin/env node\nprocess.stdout.write('nope');\n`, {
|
||||||
);
|
encoding: "utf8",
|
||||||
|
mode: 0o755,
|
||||||
|
});
|
||||||
|
|
||||||
const tool = createLobsterTool(fakeApi());
|
const tool = createLobsterTool(fakeApi());
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -29,39 +29,25 @@ function resolveExecutablePath(lobsterPathRaw: string | undefined) {
|
|||||||
return lobsterPath;
|
return lobsterPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWindowsSpawnEINVAL(err: unknown) {
|
async function runLobsterSubprocess(params: {
|
||||||
if (!err || typeof err !== "object") return false;
|
execPath: string;
|
||||||
const code = (err as { code?: unknown }).code;
|
argv: string[];
|
||||||
return code === "EINVAL";
|
cwd: string;
|
||||||
}
|
timeoutMs: number;
|
||||||
|
maxStdoutBytes: number;
|
||||||
async function runLobsterSubprocessOnce(
|
}) {
|
||||||
params: {
|
|
||||||
execPath: string;
|
|
||||||
argv: string[];
|
|
||||||
cwd: string;
|
|
||||||
timeoutMs: number;
|
|
||||||
maxStdoutBytes: number;
|
|
||||||
},
|
|
||||||
useShell: boolean,
|
|
||||||
) {
|
|
||||||
const { execPath, argv, cwd } = params;
|
const { execPath, argv, cwd } = params;
|
||||||
const timeoutMs = Math.max(200, params.timeoutMs);
|
const timeoutMs = Math.max(200, params.timeoutMs);
|
||||||
const maxStdoutBytes = Math.max(1024, params.maxStdoutBytes);
|
const maxStdoutBytes = Math.max(1024, params.maxStdoutBytes);
|
||||||
|
|
||||||
const env = { ...process.env, LOBSTER_MODE: "tool" } as Record<string, string | undefined>;
|
|
||||||
const nodeOptions = env.NODE_OPTIONS ?? "";
|
|
||||||
if (nodeOptions.includes("--inspect")) {
|
|
||||||
delete env.NODE_OPTIONS;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await new Promise<{ stdout: string }>((resolve, reject) => {
|
return await new Promise<{ stdout: string }>((resolve, reject) => {
|
||||||
const child = spawn(execPath, argv, {
|
const child = spawn(execPath, argv, {
|
||||||
cwd,
|
cwd,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
env,
|
env: {
|
||||||
shell: useShell,
|
...process.env,
|
||||||
windowsHide: useShell ? true : undefined,
|
LOBSTER_MODE: "tool",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
@@ -113,23 +99,6 @@ async function runLobsterSubprocessOnce(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runLobsterSubprocess(params: {
|
|
||||||
execPath: string;
|
|
||||||
argv: string[];
|
|
||||||
cwd: string;
|
|
||||||
timeoutMs: number;
|
|
||||||
maxStdoutBytes: number;
|
|
||||||
}) {
|
|
||||||
try {
|
|
||||||
return await runLobsterSubprocessOnce(params, false);
|
|
||||||
} catch (err) {
|
|
||||||
if (process.platform === "win32" && isWindowsSpawnEINVAL(err)) {
|
|
||||||
return await runLobsterSubprocessOnce(params, true);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseEnvelope(stdout: string): LobsterEnvelope {
|
function parseEnvelope(stdout: string): LobsterEnvelope {
|
||||||
let parsed: unknown;
|
let parsed: unknown;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 2026.1.22
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Version alignment with core Clawdbot release numbers.
|
|
||||||
|
|
||||||
## 2026.1.21
|
## 2026.1.21
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/matrix",
|
"name": "@clawdbot/matrix",
|
||||||
"version": "2026.1.22",
|
"version": "2026.1.21",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Matrix channel plugin",
|
"description": "Clawdbot Matrix channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/memory-core",
|
"name": "@clawdbot/memory-core",
|
||||||
"version": "2026.1.22",
|
"version": "2026.1.21",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot core memory search plugin",
|
"description": "Clawdbot core memory search plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/memory-lancedb",
|
"name": "@clawdbot/memory-lancedb",
|
||||||
"version": "2026.1.22",
|
"version": "2026.1.21",
|
||||||
"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": {
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 2026.1.22
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Version alignment with core Clawdbot release numbers.
|
|
||||||
|
|
||||||
## 2026.1.21
|
## 2026.1.21
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/msteams",
|
"name": "@clawdbot/msteams",
|
||||||
"version": "2026.1.22",
|
"version": "2026.1.21",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Microsoft Teams channel plugin",
|
"description": "Clawdbot Microsoft Teams channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -101,9 +101,9 @@ describe("msteams attachments", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("downloadMSTeamsAttachments", () => {
|
describe("downloadMSTeamsImageAttachments", () => {
|
||||||
it("downloads and stores image contentUrl attachments", async () => {
|
it("downloads and stores image contentUrl attachments", async () => {
|
||||||
const { downloadMSTeamsAttachments } = await load();
|
const { downloadMSTeamsImageAttachments } = await load();
|
||||||
const fetchMock = vi.fn(async () => {
|
const fetchMock = vi.fn(async () => {
|
||||||
return new Response(Buffer.from("png"), {
|
return new Response(Buffer.from("png"), {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -111,7 +111,7 @@ describe("msteams attachments", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await downloadMSTeamsAttachments({
|
const media = await downloadMSTeamsImageAttachments({
|
||||||
attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }],
|
attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }],
|
||||||
maxBytes: 1024 * 1024,
|
maxBytes: 1024 * 1024,
|
||||||
allowHosts: ["x"],
|
allowHosts: ["x"],
|
||||||
@@ -125,7 +125,7 @@ describe("msteams attachments", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("supports Teams file.download.info downloadUrl attachments", async () => {
|
it("supports Teams file.download.info downloadUrl attachments", async () => {
|
||||||
const { downloadMSTeamsAttachments } = await load();
|
const { downloadMSTeamsImageAttachments } = await load();
|
||||||
const fetchMock = vi.fn(async () => {
|
const fetchMock = vi.fn(async () => {
|
||||||
return new Response(Buffer.from("png"), {
|
return new Response(Buffer.from("png"), {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -133,7 +133,7 @@ describe("msteams attachments", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await downloadMSTeamsAttachments({
|
const media = await downloadMSTeamsImageAttachments({
|
||||||
attachments: [
|
attachments: [
|
||||||
{
|
{
|
||||||
contentType: "application/vnd.microsoft.teams.file.download.info",
|
contentType: "application/vnd.microsoft.teams.file.download.info",
|
||||||
@@ -149,35 +149,8 @@ describe("msteams attachments", () => {
|
|||||||
expect(media).toHaveLength(1);
|
expect(media).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("downloads non-image file attachments (PDF)", async () => {
|
|
||||||
const { downloadMSTeamsAttachments } = await load();
|
|
||||||
const fetchMock = vi.fn(async () => {
|
|
||||||
return new Response(Buffer.from("pdf"), {
|
|
||||||
status: 200,
|
|
||||||
headers: { "content-type": "application/pdf" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
detectMimeMock.mockResolvedValueOnce("application/pdf");
|
|
||||||
saveMediaBufferMock.mockResolvedValueOnce({
|
|
||||||
path: "/tmp/saved.pdf",
|
|
||||||
contentType: "application/pdf",
|
|
||||||
});
|
|
||||||
|
|
||||||
const media = await downloadMSTeamsAttachments({
|
|
||||||
attachments: [{ contentType: "application/pdf", contentUrl: "https://x/doc.pdf" }],
|
|
||||||
maxBytes: 1024 * 1024,
|
|
||||||
allowHosts: ["x"],
|
|
||||||
fetchFn: fetchMock as unknown as typeof fetch,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith("https://x/doc.pdf");
|
|
||||||
expect(media).toHaveLength(1);
|
|
||||||
expect(media[0]?.path).toBe("/tmp/saved.pdf");
|
|
||||||
expect(media[0]?.placeholder).toBe("<media:document>");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("downloads inline image URLs from html attachments", async () => {
|
it("downloads inline image URLs from html attachments", async () => {
|
||||||
const { downloadMSTeamsAttachments } = await load();
|
const { downloadMSTeamsImageAttachments } = await load();
|
||||||
const fetchMock = vi.fn(async () => {
|
const fetchMock = vi.fn(async () => {
|
||||||
return new Response(Buffer.from("png"), {
|
return new Response(Buffer.from("png"), {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -185,7 +158,7 @@ describe("msteams attachments", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await downloadMSTeamsAttachments({
|
const media = await downloadMSTeamsImageAttachments({
|
||||||
attachments: [
|
attachments: [
|
||||||
{
|
{
|
||||||
contentType: "text/html",
|
contentType: "text/html",
|
||||||
@@ -202,9 +175,9 @@ describe("msteams attachments", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("stores inline data:image base64 payloads", async () => {
|
it("stores inline data:image base64 payloads", async () => {
|
||||||
const { downloadMSTeamsAttachments } = await load();
|
const { downloadMSTeamsImageAttachments } = await load();
|
||||||
const base64 = Buffer.from("png").toString("base64");
|
const base64 = Buffer.from("png").toString("base64");
|
||||||
const media = await downloadMSTeamsAttachments({
|
const media = await downloadMSTeamsImageAttachments({
|
||||||
attachments: [
|
attachments: [
|
||||||
{
|
{
|
||||||
contentType: "text/html",
|
contentType: "text/html",
|
||||||
@@ -220,7 +193,7 @@ describe("msteams attachments", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("retries with auth when the first request is unauthorized", async () => {
|
it("retries with auth when the first request is unauthorized", async () => {
|
||||||
const { downloadMSTeamsAttachments } = await load();
|
const { downloadMSTeamsImageAttachments } = await load();
|
||||||
const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
|
const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
|
||||||
const hasAuth = Boolean(
|
const hasAuth = Boolean(
|
||||||
opts &&
|
opts &&
|
||||||
@@ -237,7 +210,7 @@ describe("msteams attachments", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await downloadMSTeamsAttachments({
|
const media = await downloadMSTeamsImageAttachments({
|
||||||
attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }],
|
attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }],
|
||||||
maxBytes: 1024 * 1024,
|
maxBytes: 1024 * 1024,
|
||||||
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
|
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
|
||||||
@@ -251,9 +224,9 @@ describe("msteams attachments", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("skips urls outside the allowlist", async () => {
|
it("skips urls outside the allowlist", async () => {
|
||||||
const { downloadMSTeamsAttachments } = await load();
|
const { downloadMSTeamsImageAttachments } = await load();
|
||||||
const fetchMock = vi.fn();
|
const fetchMock = vi.fn();
|
||||||
const media = await downloadMSTeamsAttachments({
|
const media = await downloadMSTeamsImageAttachments({
|
||||||
attachments: [{ contentType: "image/png", contentUrl: "https://evil.test/img" }],
|
attachments: [{ contentType: "image/png", contentUrl: "https://evil.test/img" }],
|
||||||
maxBytes: 1024 * 1024,
|
maxBytes: 1024 * 1024,
|
||||||
allowHosts: ["graph.microsoft.com"],
|
allowHosts: ["graph.microsoft.com"],
|
||||||
@@ -263,6 +236,20 @@ describe("msteams attachments", () => {
|
|||||||
expect(media).toHaveLength(0);
|
expect(media).toHaveLength(0);
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ignores non-image attachments", async () => {
|
||||||
|
const { downloadMSTeamsImageAttachments } = await load();
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
const media = await downloadMSTeamsImageAttachments({
|
||||||
|
attachments: [{ contentType: "application/pdf", contentUrl: "https://x/x.pdf" }],
|
||||||
|
maxBytes: 1024 * 1024,
|
||||||
|
allowHosts: ["x"],
|
||||||
|
fetchFn: fetchMock as unknown as typeof fetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(media).toHaveLength(0);
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildMSTeamsGraphMessageUrls", () => {
|
describe("buildMSTeamsGraphMessageUrls", () => {
|
||||||
@@ -337,74 +324,6 @@ describe("msteams attachments", () => {
|
|||||||
expect(fetchMock).toHaveBeenCalled();
|
expect(fetchMock).toHaveBeenCalled();
|
||||||
expect(saveMediaBufferMock).toHaveBeenCalled();
|
expect(saveMediaBufferMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("merges SharePoint reference attachments with hosted content", async () => {
|
|
||||||
const { downloadMSTeamsGraphMedia } = await load();
|
|
||||||
const hostedBase64 = Buffer.from("png").toString("base64");
|
|
||||||
const shareUrl = "https://contoso.sharepoint.com/site/file";
|
|
||||||
const fetchMock = vi.fn(async (url: string) => {
|
|
||||||
if (url.endsWith("/hostedContents")) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
value: [
|
|
||||||
{
|
|
||||||
id: "hosted-1",
|
|
||||||
contentType: "image/png",
|
|
||||||
contentBytes: hostedBase64,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
{ status: 200 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (url.endsWith("/attachments")) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
value: [
|
|
||||||
{
|
|
||||||
id: "ref-1",
|
|
||||||
contentType: "reference",
|
|
||||||
contentUrl: shareUrl,
|
|
||||||
name: "report.pdf",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
{ status: 200 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (url.startsWith("https://graph.microsoft.com/v1.0/shares/")) {
|
|
||||||
return new Response(Buffer.from("pdf"), {
|
|
||||||
status: 200,
|
|
||||||
headers: { "content-type": "application/pdf" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (url.endsWith("/messages/123")) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
attachments: [
|
|
||||||
{
|
|
||||||
id: "ref-1",
|
|
||||||
contentType: "reference",
|
|
||||||
contentUrl: shareUrl,
|
|
||||||
name: "report.pdf",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
{ status: 200 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return new Response("not found", { status: 404 });
|
|
||||||
});
|
|
||||||
|
|
||||||
const media = await downloadMSTeamsGraphMedia({
|
|
||||||
messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123",
|
|
||||||
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
|
|
||||||
maxBytes: 1024 * 1024,
|
|
||||||
fetchFn: fetchMock as unknown as typeof fetch,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(media.media).toHaveLength(2);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildMSTeamsMediaPayload", () => {
|
describe("buildMSTeamsMediaPayload", () => {
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
export {
|
export { downloadMSTeamsImageAttachments } from "./attachments/download.js";
|
||||||
downloadMSTeamsAttachments,
|
|
||||||
/** @deprecated Use `downloadMSTeamsAttachments` instead. */
|
|
||||||
downloadMSTeamsImageAttachments,
|
|
||||||
} from "./attachments/download.js";
|
|
||||||
export { buildMSTeamsGraphMessageUrls, downloadMSTeamsGraphMedia } from "./attachments/graph.js";
|
export { buildMSTeamsGraphMessageUrls, downloadMSTeamsGraphMedia } from "./attachments/graph.js";
|
||||||
export {
|
export {
|
||||||
buildMSTeamsAttachmentPlaceholder,
|
buildMSTeamsAttachmentPlaceholder,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { getMSTeamsRuntime } from "../runtime.js";
|
|||||||
import {
|
import {
|
||||||
extractInlineImageCandidates,
|
extractInlineImageCandidates,
|
||||||
inferPlaceholder,
|
inferPlaceholder,
|
||||||
isDownloadableAttachment,
|
isLikelyImageAttachment,
|
||||||
isRecord,
|
isRecord,
|
||||||
isUrlAllowed,
|
isUrlAllowed,
|
||||||
normalizeContentType,
|
normalizeContentType,
|
||||||
@@ -102,31 +102,23 @@ async function fetchWithAuthFallback(params: {
|
|||||||
return firstAttempt;
|
return firstAttempt;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function downloadMSTeamsImageAttachments(params: {
|
||||||
* Download all file attachments from a Teams message (images, documents, etc.).
|
|
||||||
* Renamed from downloadMSTeamsImageAttachments to support all file types.
|
|
||||||
*/
|
|
||||||
export async function downloadMSTeamsAttachments(params: {
|
|
||||||
attachments: MSTeamsAttachmentLike[] | undefined;
|
attachments: MSTeamsAttachmentLike[] | undefined;
|
||||||
maxBytes: number;
|
maxBytes: number;
|
||||||
tokenProvider?: MSTeamsAccessTokenProvider;
|
tokenProvider?: MSTeamsAccessTokenProvider;
|
||||||
allowHosts?: string[];
|
allowHosts?: string[];
|
||||||
fetchFn?: typeof fetch;
|
fetchFn?: typeof fetch;
|
||||||
/** When true, embeds original filename in stored path for later extraction. */
|
|
||||||
preserveFilenames?: boolean;
|
|
||||||
}): Promise<MSTeamsInboundMedia[]> {
|
}): Promise<MSTeamsInboundMedia[]> {
|
||||||
const list = Array.isArray(params.attachments) ? params.attachments : [];
|
const list = Array.isArray(params.attachments) ? params.attachments : [];
|
||||||
if (list.length === 0) return [];
|
if (list.length === 0) return [];
|
||||||
const allowHosts = resolveAllowedHosts(params.allowHosts);
|
const allowHosts = resolveAllowedHosts(params.allowHosts);
|
||||||
|
|
||||||
// Download ANY downloadable attachment (not just images)
|
const candidates: DownloadCandidate[] = list
|
||||||
const downloadable = list.filter(isDownloadableAttachment);
|
.filter(isLikelyImageAttachment)
|
||||||
const candidates: DownloadCandidate[] = downloadable
|
|
||||||
.map(resolveDownloadCandidate)
|
.map(resolveDownloadCandidate)
|
||||||
.filter(Boolean) as DownloadCandidate[];
|
.filter(Boolean) as DownloadCandidate[];
|
||||||
|
|
||||||
const inlineCandidates = extractInlineImageCandidates(list);
|
const inlineCandidates = extractInlineImageCandidates(list);
|
||||||
|
|
||||||
const seenUrls = new Set<string>();
|
const seenUrls = new Set<string>();
|
||||||
for (const inline of inlineCandidates) {
|
for (const inline of inlineCandidates) {
|
||||||
if (inline.kind === "url") {
|
if (inline.kind === "url") {
|
||||||
@@ -141,6 +133,7 @@ export async function downloadMSTeamsAttachments(params: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (candidates.length === 0 && inlineCandidates.length === 0) return [];
|
if (candidates.length === 0 && inlineCandidates.length === 0) return [];
|
||||||
|
|
||||||
const out: MSTeamsInboundMedia[] = [];
|
const out: MSTeamsInboundMedia[] = [];
|
||||||
@@ -148,7 +141,6 @@ export async function downloadMSTeamsAttachments(params: {
|
|||||||
if (inline.kind !== "data") continue;
|
if (inline.kind !== "data") continue;
|
||||||
if (inline.data.byteLength > params.maxBytes) continue;
|
if (inline.data.byteLength > params.maxBytes) continue;
|
||||||
try {
|
try {
|
||||||
// Data inline candidates (base64 data URLs) don't have original filenames
|
|
||||||
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
|
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
|
||||||
inline.data,
|
inline.data,
|
||||||
inline.contentType,
|
inline.contentType,
|
||||||
@@ -180,13 +172,11 @@ export async function downloadMSTeamsAttachments(params: {
|
|||||||
headerMime: res.headers.get("content-type"),
|
headerMime: res.headers.get("content-type"),
|
||||||
filePath: candidate.fileHint ?? candidate.url,
|
filePath: candidate.fileHint ?? candidate.url,
|
||||||
});
|
});
|
||||||
const originalFilename = params.preserveFilenames ? candidate.fileHint : undefined;
|
|
||||||
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
|
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
|
||||||
buffer,
|
buffer,
|
||||||
mime ?? candidate.contentTypeHint,
|
mime ?? candidate.contentTypeHint,
|
||||||
"inbound",
|
"inbound",
|
||||||
params.maxBytes,
|
params.maxBytes,
|
||||||
originalFilename,
|
|
||||||
);
|
);
|
||||||
out.push({
|
out.push({
|
||||||
path: saved.path,
|
path: saved.path,
|
||||||
@@ -194,13 +184,8 @@ export async function downloadMSTeamsAttachments(params: {
|
|||||||
placeholder: candidate.placeholder,
|
placeholder: candidate.placeholder,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore download failures and continue with next candidate.
|
// Ignore download failures and continue.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use `downloadMSTeamsAttachments` instead (supports all file types).
|
|
||||||
*/
|
|
||||||
export const downloadMSTeamsImageAttachments = downloadMSTeamsAttachments;
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getMSTeamsRuntime } from "../runtime.js";
|
import { getMSTeamsRuntime } from "../runtime.js";
|
||||||
import { downloadMSTeamsAttachments } from "./download.js";
|
import { downloadMSTeamsImageAttachments } from "./download.js";
|
||||||
import { GRAPH_ROOT, inferPlaceholder, isRecord, normalizeContentType, resolveAllowedHosts } from "./shared.js";
|
import { GRAPH_ROOT, isRecord, normalizeContentType, resolveAllowedHosts } from "./shared.js";
|
||||||
import type {
|
import type {
|
||||||
MSTeamsAccessTokenProvider,
|
MSTeamsAccessTokenProvider,
|
||||||
MSTeamsAttachmentLike,
|
MSTeamsAttachmentLike,
|
||||||
@@ -128,16 +128,11 @@ function normalizeGraphAttachment(att: GraphAttachment): MSTeamsAttachmentLike {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function downloadGraphHostedImages(params: {
|
||||||
* Download all hosted content from a Teams message (images, documents, etc.).
|
|
||||||
* Renamed from downloadGraphHostedImages to support all file types.
|
|
||||||
*/
|
|
||||||
async function downloadGraphHostedContent(params: {
|
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
messageUrl: string;
|
messageUrl: string;
|
||||||
maxBytes: number;
|
maxBytes: number;
|
||||||
fetchFn?: typeof fetch;
|
fetchFn?: typeof fetch;
|
||||||
preserveFilenames?: boolean;
|
|
||||||
}): Promise<{ media: MSTeamsInboundMedia[]; status: number; count: number }> {
|
}): Promise<{ media: MSTeamsInboundMedia[]; status: number; count: number }> {
|
||||||
const hosted = await fetchGraphCollection<GraphHostedContent>({
|
const hosted = await fetchGraphCollection<GraphHostedContent>({
|
||||||
url: `${params.messageUrl}/hostedContents`,
|
url: `${params.messageUrl}/hostedContents`,
|
||||||
@@ -163,7 +158,7 @@ async function downloadGraphHostedContent(params: {
|
|||||||
buffer,
|
buffer,
|
||||||
headerMime: item.contentType ?? undefined,
|
headerMime: item.contentType ?? undefined,
|
||||||
});
|
});
|
||||||
// Download any file type, not just images
|
if (mime && !mime.startsWith("image/")) continue;
|
||||||
try {
|
try {
|
||||||
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
|
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
|
||||||
buffer,
|
buffer,
|
||||||
@@ -174,7 +169,7 @@ async function downloadGraphHostedContent(params: {
|
|||||||
out.push({
|
out.push({
|
||||||
path: saved.path,
|
path: saved.path,
|
||||||
contentType: saved.contentType,
|
contentType: saved.contentType,
|
||||||
placeholder: inferPlaceholder({ contentType: saved.contentType }),
|
placeholder: "<media:image>",
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore save failures.
|
// Ignore save failures.
|
||||||
@@ -190,8 +185,6 @@ export async function downloadMSTeamsGraphMedia(params: {
|
|||||||
maxBytes: number;
|
maxBytes: number;
|
||||||
allowHosts?: string[];
|
allowHosts?: string[];
|
||||||
fetchFn?: typeof fetch;
|
fetchFn?: typeof fetch;
|
||||||
/** When true, embeds original filename in stored path for later extraction. */
|
|
||||||
preserveFilenames?: boolean;
|
|
||||||
}): Promise<MSTeamsGraphMediaResult> {
|
}): Promise<MSTeamsGraphMediaResult> {
|
||||||
if (!params.messageUrl || !params.tokenProvider) return { media: [] };
|
if (!params.messageUrl || !params.tokenProvider) return { media: [] };
|
||||||
const allowHosts = resolveAllowedHosts(params.allowHosts);
|
const allowHosts = resolveAllowedHosts(params.allowHosts);
|
||||||
@@ -203,83 +196,11 @@ export async function downloadMSTeamsGraphMedia(params: {
|
|||||||
return { media: [], messageUrl, tokenError: true };
|
return { media: [], messageUrl, tokenError: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the full message to get SharePoint file attachments (for group chats)
|
const hosted = await downloadGraphHostedImages({
|
||||||
const fetchFn = params.fetchFn ?? fetch;
|
|
||||||
const sharePointMedia: MSTeamsInboundMedia[] = [];
|
|
||||||
const downloadedReferenceUrls = new Set<string>();
|
|
||||||
try {
|
|
||||||
const msgRes = await fetchFn(messageUrl, {
|
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
|
||||||
});
|
|
||||||
if (msgRes.ok) {
|
|
||||||
const msgData = (await msgRes.json()) as {
|
|
||||||
body?: { content?: string; contentType?: string };
|
|
||||||
attachments?: Array<{
|
|
||||||
id?: string;
|
|
||||||
contentUrl?: string;
|
|
||||||
contentType?: string;
|
|
||||||
name?: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Extract SharePoint file attachments (contentType: "reference")
|
|
||||||
// Download any file type, not just images
|
|
||||||
const spAttachments = (msgData.attachments ?? []).filter(
|
|
||||||
(a) => a.contentType === "reference" && a.contentUrl && a.name,
|
|
||||||
);
|
|
||||||
for (const att of spAttachments) {
|
|
||||||
const name = att.name ?? "file";
|
|
||||||
|
|
||||||
try {
|
|
||||||
// SharePoint URLs need to be accessed via Graph shares API
|
|
||||||
const shareUrl = att.contentUrl!;
|
|
||||||
const encodedUrl = Buffer.from(shareUrl).toString("base64url");
|
|
||||||
const sharesUrl = `${GRAPH_ROOT}/shares/u!${encodedUrl}/driveItem/content`;
|
|
||||||
|
|
||||||
const spRes = await fetchFn(sharesUrl, {
|
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
|
||||||
redirect: "follow",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (spRes.ok) {
|
|
||||||
const buffer = Buffer.from(await spRes.arrayBuffer());
|
|
||||||
if (buffer.byteLength <= params.maxBytes) {
|
|
||||||
const mime = await getMSTeamsRuntime().media.detectMime({
|
|
||||||
buffer,
|
|
||||||
headerMime: spRes.headers.get("content-type") ?? undefined,
|
|
||||||
filePath: name,
|
|
||||||
});
|
|
||||||
const originalFilename = params.preserveFilenames ? name : undefined;
|
|
||||||
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
|
|
||||||
buffer,
|
|
||||||
mime ?? "application/octet-stream",
|
|
||||||
"inbound",
|
|
||||||
params.maxBytes,
|
|
||||||
originalFilename,
|
|
||||||
);
|
|
||||||
sharePointMedia.push({
|
|
||||||
path: saved.path,
|
|
||||||
contentType: saved.contentType,
|
|
||||||
placeholder: inferPlaceholder({ contentType: saved.contentType, fileName: name }),
|
|
||||||
});
|
|
||||||
downloadedReferenceUrls.add(shareUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore SharePoint download failures.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore message fetch failures.
|
|
||||||
}
|
|
||||||
|
|
||||||
const hosted = await downloadGraphHostedContent({
|
|
||||||
accessToken,
|
accessToken,
|
||||||
messageUrl,
|
messageUrl,
|
||||||
maxBytes: params.maxBytes,
|
maxBytes: params.maxBytes,
|
||||||
fetchFn: params.fetchFn,
|
fetchFn: params.fetchFn,
|
||||||
preserveFilenames: params.preserveFilenames,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const attachments = await fetchGraphCollection<GraphAttachment>({
|
const attachments = await fetchGraphCollection<GraphAttachment>({
|
||||||
@@ -289,29 +210,18 @@ export async function downloadMSTeamsGraphMedia(params: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const normalizedAttachments = attachments.items.map(normalizeGraphAttachment);
|
const normalizedAttachments = attachments.items.map(normalizeGraphAttachment);
|
||||||
const filteredAttachments =
|
const attachmentMedia = await downloadMSTeamsImageAttachments({
|
||||||
sharePointMedia.length > 0
|
attachments: normalizedAttachments,
|
||||||
? normalizedAttachments.filter((att) => {
|
|
||||||
const contentType = att.contentType?.toLowerCase();
|
|
||||||
if (contentType !== "reference") return true;
|
|
||||||
const url = typeof att.contentUrl === "string" ? att.contentUrl : "";
|
|
||||||
if (!url) return true;
|
|
||||||
return !downloadedReferenceUrls.has(url);
|
|
||||||
})
|
|
||||||
: normalizedAttachments;
|
|
||||||
const attachmentMedia = await downloadMSTeamsAttachments({
|
|
||||||
attachments: filteredAttachments,
|
|
||||||
maxBytes: params.maxBytes,
|
maxBytes: params.maxBytes,
|
||||||
tokenProvider: params.tokenProvider,
|
tokenProvider: params.tokenProvider,
|
||||||
allowHosts,
|
allowHosts,
|
||||||
fetchFn: params.fetchFn,
|
fetchFn: params.fetchFn,
|
||||||
preserveFilenames: params.preserveFilenames,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
media: [...sharePointMedia, ...hosted.media, ...attachmentMedia],
|
media: [...hosted.media, ...attachmentMedia],
|
||||||
hostedCount: hosted.count,
|
hostedCount: hosted.count,
|
||||||
attachmentCount: filteredAttachments.length + sharePointMedia.length,
|
attachmentCount: attachments.items.length,
|
||||||
hostedStatus: hosted.status,
|
hostedStatus: hosted.status,
|
||||||
attachmentStatus: attachments.status,
|
attachmentStatus: attachments.status,
|
||||||
messageUrl,
|
messageUrl,
|
||||||
|
|||||||
@@ -37,15 +37,6 @@ export const DEFAULT_MEDIA_HOST_ALLOWLIST = [
|
|||||||
"statics.teams.cdn.office.net",
|
"statics.teams.cdn.office.net",
|
||||||
"office.com",
|
"office.com",
|
||||||
"office.net",
|
"office.net",
|
||||||
// Azure Media Services / Skype CDN for clipboard-pasted images
|
|
||||||
"asm.skype.com",
|
|
||||||
"ams.skype.com",
|
|
||||||
"media.ams.skype.com",
|
|
||||||
// Bot Framework attachment URLs
|
|
||||||
"trafficmanager.net",
|
|
||||||
"blob.core.windows.net",
|
|
||||||
"azureedge.net",
|
|
||||||
"microsoft.com",
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
|
export const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
|
||||||
@@ -94,30 +85,6 @@ export function isLikelyImageAttachment(att: MSTeamsAttachmentLike): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if the attachment can be downloaded (any file type).
|
|
||||||
* Used when downloading all files, not just images.
|
|
||||||
*/
|
|
||||||
export function isDownloadableAttachment(att: MSTeamsAttachmentLike): boolean {
|
|
||||||
const contentType = normalizeContentType(att.contentType) ?? "";
|
|
||||||
|
|
||||||
// Teams file download info always has a downloadUrl
|
|
||||||
if (
|
|
||||||
contentType === "application/vnd.microsoft.teams.file.download.info" &&
|
|
||||||
isRecord(att.content) &&
|
|
||||||
typeof att.content.downloadUrl === "string"
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Any attachment with a contentUrl can be downloaded
|
|
||||||
if (typeof att.contentUrl === "string" && att.contentUrl.trim()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isHtmlAttachment(att: MSTeamsAttachmentLike): boolean {
|
function isHtmlAttachment(att: MSTeamsAttachmentLike): boolean {
|
||||||
const contentType = normalizeContentType(att.contentType) ?? "";
|
const contentType = normalizeContentType(att.contentType) ?? "";
|
||||||
return contentType.startsWith("text/html");
|
return contentType.startsWith("text/html");
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
resolveMSTeamsChannelAllowlist,
|
resolveMSTeamsChannelAllowlist,
|
||||||
resolveMSTeamsUserAllowlist,
|
resolveMSTeamsUserAllowlist,
|
||||||
} from "./resolve-allowlist.js";
|
} from "./resolve-allowlist.js";
|
||||||
import { sendAdaptiveCardMSTeams, sendMessageMSTeams } from "./send.js";
|
import { sendMessageMSTeams } from "./send.js";
|
||||||
import { resolveMSTeamsCredentials } from "./token.js";
|
import { resolveMSTeamsCredentials } from "./token.js";
|
||||||
import {
|
import {
|
||||||
listMSTeamsDirectoryGroupsLive,
|
listMSTeamsDirectoryGroupsLive,
|
||||||
@@ -64,19 +64,6 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
|||||||
threads: true,
|
threads: true,
|
||||||
media: true,
|
media: true,
|
||||||
},
|
},
|
||||||
agentPrompt: {
|
|
||||||
messageToolHints: () => [
|
|
||||||
"- Adaptive Cards supported. Use `action=send` with `card={type,version,body}` to send rich cards.",
|
|
||||||
"- MSTeams targeting: omit `target` to reply to the current conversation (auto-inferred). Explicit targets: `user:ID` or `user:Display Name` (requires Graph API) for DMs, `conversation:19:...@thread.tacv2` for groups/channels. Prefer IDs over display names for speed.",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
threading: {
|
|
||||||
buildToolContext: ({ context, hasRepliedRef }) => ({
|
|
||||||
currentChannelId: context.To?.trim() || undefined,
|
|
||||||
currentThreadTs: context.ReplyToId,
|
|
||||||
hasRepliedRef,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
reload: { configPrefixes: ["channels.msteams"] },
|
reload: { configPrefixes: ["channels.msteams"] },
|
||||||
configSchema: buildChannelConfigSchema(MSTeamsConfigSchema),
|
configSchema: buildChannelConfigSchema(MSTeamsConfigSchema),
|
||||||
config: {
|
config: {
|
||||||
@@ -150,12 +137,7 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
|||||||
looksLikeId: (raw) => {
|
looksLikeId: (raw) => {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) return false;
|
if (!trimmed) return false;
|
||||||
if (/^conversation:/i.test(trimmed)) return true;
|
if (/^(conversation:|user:)/i.test(trimmed)) return true;
|
||||||
if (/^user:/i.test(trimmed)) {
|
|
||||||
// Only treat as ID if the value after user: looks like a UUID
|
|
||||||
const id = trimmed.slice("user:".length).trim();
|
|
||||||
return /^[0-9a-fA-F-]{16,}$/.test(id);
|
|
||||||
}
|
|
||||||
return trimmed.includes("@thread");
|
return trimmed.includes("@thread");
|
||||||
},
|
},
|
||||||
hint: "<conversationId|user:ID|conversation:ID>",
|
hint: "<conversationId|user:ID|conversation:ID>",
|
||||||
@@ -338,50 +320,6 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
|||||||
if (!enabled) return [];
|
if (!enabled) return [];
|
||||||
return ["poll"] satisfies ChannelMessageActionName[];
|
return ["poll"] satisfies ChannelMessageActionName[];
|
||||||
},
|
},
|
||||||
supportsCards: ({ cfg }) => {
|
|
||||||
return (
|
|
||||||
cfg.channels?.msteams?.enabled !== false &&
|
|
||||||
Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams))
|
|
||||||
);
|
|
||||||
},
|
|
||||||
handleAction: async (ctx) => {
|
|
||||||
// Handle send action with card parameter
|
|
||||||
if (ctx.action === "send" && ctx.params.card) {
|
|
||||||
const card = ctx.params.card as Record<string, unknown>;
|
|
||||||
const to =
|
|
||||||
typeof ctx.params.to === "string"
|
|
||||||
? ctx.params.to.trim()
|
|
||||||
: typeof ctx.params.target === "string"
|
|
||||||
? ctx.params.target.trim()
|
|
||||||
: "";
|
|
||||||
if (!to) {
|
|
||||||
return {
|
|
||||||
isError: true,
|
|
||||||
content: [{ type: "text", text: "Card send requires a target (to)." }],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const result = await sendAdaptiveCardMSTeams({
|
|
||||||
cfg: ctx.cfg,
|
|
||||||
to,
|
|
||||||
card,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: JSON.stringify({
|
|
||||||
ok: true,
|
|
||||||
channel: "msteams",
|
|
||||||
messageId: result.messageId,
|
|
||||||
conversationId: result.conversationId,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Return null to fall through to default handler
|
|
||||||
return null as never;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
outbound: msteamsOutbound,
|
outbound: msteamsOutbound,
|
||||||
status: {
|
status: {
|
||||||
|
|||||||
@@ -1,234 +0,0 @@
|
|||||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import { prepareFileConsentActivity, requiresFileConsent } from "./file-consent-helpers.js";
|
|
||||||
import * as pendingUploads from "./pending-uploads.js";
|
|
||||||
|
|
||||||
describe("requiresFileConsent", () => {
|
|
||||||
const thresholdBytes = 4 * 1024 * 1024; // 4MB
|
|
||||||
|
|
||||||
it("returns true for personal chat with non-image", () => {
|
|
||||||
expect(
|
|
||||||
requiresFileConsent({
|
|
||||||
conversationType: "personal",
|
|
||||||
contentType: "application/pdf",
|
|
||||||
bufferSize: 1000,
|
|
||||||
thresholdBytes,
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns true for personal chat with large image", () => {
|
|
||||||
expect(
|
|
||||||
requiresFileConsent({
|
|
||||||
conversationType: "personal",
|
|
||||||
contentType: "image/png",
|
|
||||||
bufferSize: 5 * 1024 * 1024, // 5MB
|
|
||||||
thresholdBytes,
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false for personal chat with small image", () => {
|
|
||||||
expect(
|
|
||||||
requiresFileConsent({
|
|
||||||
conversationType: "personal",
|
|
||||||
contentType: "image/png",
|
|
||||||
bufferSize: 1000,
|
|
||||||
thresholdBytes,
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false for group chat with large non-image", () => {
|
|
||||||
expect(
|
|
||||||
requiresFileConsent({
|
|
||||||
conversationType: "groupChat",
|
|
||||||
contentType: "application/pdf",
|
|
||||||
bufferSize: 5 * 1024 * 1024,
|
|
||||||
thresholdBytes,
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false for channel with large non-image", () => {
|
|
||||||
expect(
|
|
||||||
requiresFileConsent({
|
|
||||||
conversationType: "channel",
|
|
||||||
contentType: "application/pdf",
|
|
||||||
bufferSize: 5 * 1024 * 1024,
|
|
||||||
thresholdBytes,
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles case-insensitive conversation type", () => {
|
|
||||||
expect(
|
|
||||||
requiresFileConsent({
|
|
||||||
conversationType: "Personal",
|
|
||||||
contentType: "application/pdf",
|
|
||||||
bufferSize: 1000,
|
|
||||||
thresholdBytes,
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
requiresFileConsent({
|
|
||||||
conversationType: "PERSONAL",
|
|
||||||
contentType: "application/pdf",
|
|
||||||
bufferSize: 1000,
|
|
||||||
thresholdBytes,
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false when conversationType is undefined", () => {
|
|
||||||
expect(
|
|
||||||
requiresFileConsent({
|
|
||||||
conversationType: undefined,
|
|
||||||
contentType: "application/pdf",
|
|
||||||
bufferSize: 1000,
|
|
||||||
thresholdBytes,
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns true for personal chat when contentType is undefined (non-image)", () => {
|
|
||||||
expect(
|
|
||||||
requiresFileConsent({
|
|
||||||
conversationType: "personal",
|
|
||||||
contentType: undefined,
|
|
||||||
bufferSize: 1000,
|
|
||||||
thresholdBytes,
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns true for personal chat with file exactly at threshold", () => {
|
|
||||||
expect(
|
|
||||||
requiresFileConsent({
|
|
||||||
conversationType: "personal",
|
|
||||||
contentType: "image/jpeg",
|
|
||||||
bufferSize: thresholdBytes, // exactly 4MB
|
|
||||||
thresholdBytes,
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false for personal chat with file just below threshold", () => {
|
|
||||||
expect(
|
|
||||||
requiresFileConsent({
|
|
||||||
conversationType: "personal",
|
|
||||||
contentType: "image/jpeg",
|
|
||||||
bufferSize: thresholdBytes - 1, // 4MB - 1 byte
|
|
||||||
thresholdBytes,
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("prepareFileConsentActivity", () => {
|
|
||||||
const mockUploadId = "test-upload-id-123";
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.spyOn(pendingUploads, "storePendingUpload").mockReturnValue(mockUploadId);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates activity with consent card attachment", () => {
|
|
||||||
const result = prepareFileConsentActivity({
|
|
||||||
media: {
|
|
||||||
buffer: Buffer.from("test content"),
|
|
||||||
filename: "test.pdf",
|
|
||||||
contentType: "application/pdf",
|
|
||||||
},
|
|
||||||
conversationId: "conv123",
|
|
||||||
description: "My file",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.uploadId).toBe(mockUploadId);
|
|
||||||
expect(result.activity.type).toBe("message");
|
|
||||||
expect(result.activity.attachments).toHaveLength(1);
|
|
||||||
|
|
||||||
const attachment = (result.activity.attachments as unknown[])[0] as Record<string, unknown>;
|
|
||||||
expect(attachment.contentType).toBe("application/vnd.microsoft.teams.card.file.consent");
|
|
||||||
expect(attachment.name).toBe("test.pdf");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stores pending upload with correct data", () => {
|
|
||||||
const buffer = Buffer.from("test content");
|
|
||||||
prepareFileConsentActivity({
|
|
||||||
media: {
|
|
||||||
buffer,
|
|
||||||
filename: "test.pdf",
|
|
||||||
contentType: "application/pdf",
|
|
||||||
},
|
|
||||||
conversationId: "conv123",
|
|
||||||
description: "My file",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(pendingUploads.storePendingUpload).toHaveBeenCalledWith({
|
|
||||||
buffer,
|
|
||||||
filename: "test.pdf",
|
|
||||||
contentType: "application/pdf",
|
|
||||||
conversationId: "conv123",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses default description when not provided", () => {
|
|
||||||
const result = prepareFileConsentActivity({
|
|
||||||
media: {
|
|
||||||
buffer: Buffer.from("test"),
|
|
||||||
filename: "document.docx",
|
|
||||||
contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
||||||
},
|
|
||||||
conversationId: "conv456",
|
|
||||||
});
|
|
||||||
|
|
||||||
const attachment = (result.activity.attachments as unknown[])[0] as Record<string, { description: string }>;
|
|
||||||
expect(attachment.content.description).toBe("File: document.docx");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses provided description", () => {
|
|
||||||
const result = prepareFileConsentActivity({
|
|
||||||
media: {
|
|
||||||
buffer: Buffer.from("test"),
|
|
||||||
filename: "report.pdf",
|
|
||||||
contentType: "application/pdf",
|
|
||||||
},
|
|
||||||
conversationId: "conv789",
|
|
||||||
description: "Q4 Financial Report",
|
|
||||||
});
|
|
||||||
|
|
||||||
const attachment = (result.activity.attachments as unknown[])[0] as Record<string, { description: string }>;
|
|
||||||
expect(attachment.content.description).toBe("Q4 Financial Report");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes uploadId in consent card context", () => {
|
|
||||||
const result = prepareFileConsentActivity({
|
|
||||||
media: {
|
|
||||||
buffer: Buffer.from("test"),
|
|
||||||
filename: "file.txt",
|
|
||||||
contentType: "text/plain",
|
|
||||||
},
|
|
||||||
conversationId: "conv000",
|
|
||||||
});
|
|
||||||
|
|
||||||
const attachment = (result.activity.attachments as unknown[])[0] as Record<string, { acceptContext: { uploadId: string } }>;
|
|
||||||
expect(attachment.content.acceptContext.uploadId).toBe(mockUploadId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles media without contentType", () => {
|
|
||||||
const result = prepareFileConsentActivity({
|
|
||||||
media: {
|
|
||||||
buffer: Buffer.from("binary data"),
|
|
||||||
filename: "unknown.bin",
|
|
||||||
},
|
|
||||||
conversationId: "conv111",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.uploadId).toBe(mockUploadId);
|
|
||||||
expect(result.activity.type).toBe("message");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
/**
|
|
||||||
* Shared helpers for FileConsentCard flow in MSTeams.
|
|
||||||
*
|
|
||||||
* FileConsentCard is required for:
|
|
||||||
* - Personal (1:1) chats with large files (>=4MB)
|
|
||||||
* - Personal chats with non-image files (PDFs, documents, etc.)
|
|
||||||
*
|
|
||||||
* This module consolidates the logic used by both send.ts (proactive sends)
|
|
||||||
* and messenger.ts (reply path) to avoid duplication.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { buildFileConsentCard } from "./file-consent.js";
|
|
||||||
import { storePendingUpload } from "./pending-uploads.js";
|
|
||||||
|
|
||||||
export type FileConsentMedia = {
|
|
||||||
buffer: Buffer;
|
|
||||||
filename: string;
|
|
||||||
contentType?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FileConsentActivityResult = {
|
|
||||||
activity: Record<string, unknown>;
|
|
||||||
uploadId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prepare a FileConsentCard activity for large files or non-images in personal chats.
|
|
||||||
* Returns the activity object and uploadId - caller is responsible for sending.
|
|
||||||
*/
|
|
||||||
export function prepareFileConsentActivity(params: {
|
|
||||||
media: FileConsentMedia;
|
|
||||||
conversationId: string;
|
|
||||||
description?: string;
|
|
||||||
}): FileConsentActivityResult {
|
|
||||||
const { media, conversationId, description } = params;
|
|
||||||
|
|
||||||
const uploadId = storePendingUpload({
|
|
||||||
buffer: media.buffer,
|
|
||||||
filename: media.filename,
|
|
||||||
contentType: media.contentType,
|
|
||||||
conversationId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const consentCard = buildFileConsentCard({
|
|
||||||
filename: media.filename,
|
|
||||||
description: description || `File: ${media.filename}`,
|
|
||||||
sizeInBytes: media.buffer.length,
|
|
||||||
context: { uploadId },
|
|
||||||
});
|
|
||||||
|
|
||||||
const activity: Record<string, unknown> = {
|
|
||||||
type: "message",
|
|
||||||
attachments: [consentCard],
|
|
||||||
};
|
|
||||||
|
|
||||||
return { activity, uploadId };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a file requires FileConsentCard flow.
|
|
||||||
* True for: personal chat AND (large file OR non-image)
|
|
||||||
*/
|
|
||||||
export function requiresFileConsent(params: {
|
|
||||||
conversationType: string | undefined;
|
|
||||||
contentType: string | undefined;
|
|
||||||
bufferSize: number;
|
|
||||||
thresholdBytes: number;
|
|
||||||
}): boolean {
|
|
||||||
const isPersonal = params.conversationType?.toLowerCase() === "personal";
|
|
||||||
const isImage = params.contentType?.startsWith("image/") ?? false;
|
|
||||||
const isLargeFile = params.bufferSize >= params.thresholdBytes;
|
|
||||||
return isPersonal && (isLargeFile || !isImage);
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
/**
|
|
||||||
* FileConsentCard utilities for MS Teams large file uploads (>4MB) in personal chats.
|
|
||||||
*
|
|
||||||
* Teams requires user consent before the bot can upload large files. This module provides
|
|
||||||
* utilities for:
|
|
||||||
* - Building FileConsentCard attachments (to request upload permission)
|
|
||||||
* - Building FileInfoCard attachments (to confirm upload completion)
|
|
||||||
* - Parsing fileConsent/invoke activities
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface FileConsentCardParams {
|
|
||||||
filename: string;
|
|
||||||
description?: string;
|
|
||||||
sizeInBytes: number;
|
|
||||||
/** Custom context data to include in the card (passed back in the invoke) */
|
|
||||||
context?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FileInfoCardParams {
|
|
||||||
filename: string;
|
|
||||||
contentUrl: string;
|
|
||||||
uniqueId: string;
|
|
||||||
fileType: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a FileConsentCard attachment for requesting upload permission.
|
|
||||||
* Use this for files >= 4MB in personal (1:1) chats.
|
|
||||||
*/
|
|
||||||
export function buildFileConsentCard(params: FileConsentCardParams) {
|
|
||||||
return {
|
|
||||||
contentType: "application/vnd.microsoft.teams.card.file.consent",
|
|
||||||
name: params.filename,
|
|
||||||
content: {
|
|
||||||
description: params.description ?? `File: ${params.filename}`,
|
|
||||||
sizeInBytes: params.sizeInBytes,
|
|
||||||
acceptContext: { filename: params.filename, ...params.context },
|
|
||||||
declineContext: { filename: params.filename, ...params.context },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a FileInfoCard attachment for confirming upload completion.
|
|
||||||
* Send this after successfully uploading the file to the consent URL.
|
|
||||||
*/
|
|
||||||
export function buildFileInfoCard(params: FileInfoCardParams) {
|
|
||||||
return {
|
|
||||||
contentType: "application/vnd.microsoft.teams.card.file.info",
|
|
||||||
contentUrl: params.contentUrl,
|
|
||||||
name: params.filename,
|
|
||||||
content: {
|
|
||||||
uniqueId: params.uniqueId,
|
|
||||||
fileType: params.fileType,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FileConsentUploadInfo {
|
|
||||||
name: string;
|
|
||||||
uploadUrl: string;
|
|
||||||
contentUrl: string;
|
|
||||||
uniqueId: string;
|
|
||||||
fileType: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FileConsentResponse {
|
|
||||||
action: "accept" | "decline";
|
|
||||||
uploadInfo?: FileConsentUploadInfo;
|
|
||||||
context?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a fileConsent/invoke activity.
|
|
||||||
* Returns null if the activity is not a file consent invoke.
|
|
||||||
*/
|
|
||||||
export function parseFileConsentInvoke(activity: {
|
|
||||||
name?: string;
|
|
||||||
value?: unknown;
|
|
||||||
}): FileConsentResponse | null {
|
|
||||||
if (activity.name !== "fileConsent/invoke") return null;
|
|
||||||
|
|
||||||
const value = activity.value as {
|
|
||||||
type?: string;
|
|
||||||
action?: string;
|
|
||||||
uploadInfo?: FileConsentUploadInfo;
|
|
||||||
context?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (value?.type !== "fileUpload") return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
action: value.action === "accept" ? "accept" : "decline",
|
|
||||||
uploadInfo: value.uploadInfo,
|
|
||||||
context: value.context,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload a file to the consent URL provided by Teams.
|
|
||||||
* The URL is provided in the fileConsent/invoke response after user accepts.
|
|
||||||
*/
|
|
||||||
export async function uploadToConsentUrl(params: {
|
|
||||||
url: string;
|
|
||||||
buffer: Buffer;
|
|
||||||
contentType?: string;
|
|
||||||
fetchFn?: typeof fetch;
|
|
||||||
}): Promise<void> {
|
|
||||||
const fetchFn = params.fetchFn ?? fetch;
|
|
||||||
const res = await fetchFn(params.url, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": params.contentType ?? "application/octet-stream",
|
|
||||||
"Content-Range": `bytes 0-${params.buffer.length - 1}/${params.buffer.length}`,
|
|
||||||
},
|
|
||||||
body: new Uint8Array(params.buffer),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`File upload to consent URL failed: ${res.status} ${res.statusText}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
/**
|
|
||||||
* Native Teams file card attachments for Bot Framework.
|
|
||||||
*
|
|
||||||
* The Bot Framework SDK supports `application/vnd.microsoft.teams.card.file.info`
|
|
||||||
* content type which produces native Teams file cards.
|
|
||||||
*
|
|
||||||
* @see https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bots-filesv4
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { DriveItemProperties } from "./graph-upload.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a native Teams file card attachment for Bot Framework.
|
|
||||||
*
|
|
||||||
* This uses the `application/vnd.microsoft.teams.card.file.info` content type
|
|
||||||
* which is supported by Bot Framework and produces native Teams file cards
|
|
||||||
* (the same display as when a user manually shares a file).
|
|
||||||
*
|
|
||||||
* @param file - DriveItem properties from getDriveItemProperties()
|
|
||||||
* @returns Attachment object for Bot Framework sendActivity()
|
|
||||||
*/
|
|
||||||
export function buildTeamsFileInfoCard(file: DriveItemProperties): {
|
|
||||||
contentType: string;
|
|
||||||
contentUrl: string;
|
|
||||||
name: string;
|
|
||||||
content: {
|
|
||||||
uniqueId: string;
|
|
||||||
fileType: string;
|
|
||||||
};
|
|
||||||
} {
|
|
||||||
// Extract unique ID from eTag (remove quotes, braces, and version suffix)
|
|
||||||
// Example eTag formats: "{GUID},version" or "\"{GUID},version\""
|
|
||||||
const rawETag = file.eTag;
|
|
||||||
const uniqueId = rawETag
|
|
||||||
.replace(/^["']|["']$/g, "") // Remove outer quotes
|
|
||||||
.replace(/[{}]/g, "") // Remove curly braces
|
|
||||||
.split(",")[0] ?? rawETag; // Take the GUID part before comma
|
|
||||||
|
|
||||||
// Extract file extension from filename
|
|
||||||
const lastDot = file.name.lastIndexOf(".");
|
|
||||||
const fileType = lastDot >= 0 ? file.name.slice(lastDot + 1).toLowerCase() : "";
|
|
||||||
|
|
||||||
return {
|
|
||||||
contentType: "application/vnd.microsoft.teams.card.file.info",
|
|
||||||
contentUrl: file.webDavUrl,
|
|
||||||
name: file.name,
|
|
||||||
content: {
|
|
||||||
uniqueId,
|
|
||||||
fileType,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,445 +0,0 @@
|
|||||||
/**
|
|
||||||
* OneDrive/SharePoint upload utilities for MS Teams file sending.
|
|
||||||
*
|
|
||||||
* For group chats and channels, files are uploaded to SharePoint and shared via a link.
|
|
||||||
* This module provides utilities for:
|
|
||||||
* - Uploading files to OneDrive (personal scope - now deprecated for bot use)
|
|
||||||
* - Uploading files to SharePoint (group/channel scope)
|
|
||||||
* - Creating sharing links (organization-wide or per-user)
|
|
||||||
* - Getting chat members for per-user sharing
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
|
|
||||||
|
|
||||||
const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
|
|
||||||
const GRAPH_BETA = "https://graph.microsoft.com/beta";
|
|
||||||
const GRAPH_SCOPE = "https://graph.microsoft.com/.default";
|
|
||||||
|
|
||||||
export interface OneDriveUploadResult {
|
|
||||||
id: string;
|
|
||||||
webUrl: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload a file to the user's OneDrive root folder.
|
|
||||||
* For larger files, this uses the simple upload endpoint (up to 4MB).
|
|
||||||
* TODO: For files >4MB, implement resumable upload session.
|
|
||||||
*/
|
|
||||||
export async function uploadToOneDrive(params: {
|
|
||||||
buffer: Buffer;
|
|
||||||
filename: string;
|
|
||||||
contentType?: string;
|
|
||||||
tokenProvider: MSTeamsAccessTokenProvider;
|
|
||||||
fetchFn?: typeof fetch;
|
|
||||||
}): Promise<OneDriveUploadResult> {
|
|
||||||
const fetchFn = params.fetchFn ?? fetch;
|
|
||||||
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
|
|
||||||
|
|
||||||
// Use "ClawdbotShared" folder to organize bot-uploaded files
|
|
||||||
const uploadPath = `/ClawdbotShared/${encodeURIComponent(params.filename)}`;
|
|
||||||
|
|
||||||
const res = await fetchFn(`${GRAPH_ROOT}/me/drive/root:${uploadPath}:/content`, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
"Content-Type": params.contentType ?? "application/octet-stream",
|
|
||||||
},
|
|
||||||
body: new Uint8Array(params.buffer),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = await res.text().catch(() => "");
|
|
||||||
throw new Error(`OneDrive upload failed: ${res.status} ${res.statusText} - ${body}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await res.json()) as {
|
|
||||||
id?: string;
|
|
||||||
webUrl?: string;
|
|
||||||
name?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!data.id || !data.webUrl || !data.name) {
|
|
||||||
throw new Error("OneDrive upload response missing required fields");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: data.id,
|
|
||||||
webUrl: data.webUrl,
|
|
||||||
name: data.name,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OneDriveSharingLink {
|
|
||||||
webUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a sharing link for a OneDrive file.
|
|
||||||
* The link allows organization members to view the file.
|
|
||||||
*/
|
|
||||||
export async function createSharingLink(params: {
|
|
||||||
itemId: string;
|
|
||||||
tokenProvider: MSTeamsAccessTokenProvider;
|
|
||||||
/** Sharing scope: "organization" (default) or "anonymous" */
|
|
||||||
scope?: "organization" | "anonymous";
|
|
||||||
fetchFn?: typeof fetch;
|
|
||||||
}): Promise<OneDriveSharingLink> {
|
|
||||||
const fetchFn = params.fetchFn ?? fetch;
|
|
||||||
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
|
|
||||||
|
|
||||||
const res = await fetchFn(`${GRAPH_ROOT}/me/drive/items/${params.itemId}/createLink`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
type: "view",
|
|
||||||
scope: params.scope ?? "organization",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = await res.text().catch(() => "");
|
|
||||||
throw new Error(`Create sharing link failed: ${res.status} ${res.statusText} - ${body}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await res.json()) as {
|
|
||||||
link?: { webUrl?: string };
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!data.link?.webUrl) {
|
|
||||||
throw new Error("Create sharing link response missing webUrl");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
webUrl: data.link.webUrl,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload a file to OneDrive and create a sharing link.
|
|
||||||
* Convenience function for the common case.
|
|
||||||
*/
|
|
||||||
export async function uploadAndShareOneDrive(params: {
|
|
||||||
buffer: Buffer;
|
|
||||||
filename: string;
|
|
||||||
contentType?: string;
|
|
||||||
tokenProvider: MSTeamsAccessTokenProvider;
|
|
||||||
scope?: "organization" | "anonymous";
|
|
||||||
fetchFn?: typeof fetch;
|
|
||||||
}): Promise<{
|
|
||||||
itemId: string;
|
|
||||||
webUrl: string;
|
|
||||||
shareUrl: string;
|
|
||||||
name: string;
|
|
||||||
}> {
|
|
||||||
const uploaded = await uploadToOneDrive({
|
|
||||||
buffer: params.buffer,
|
|
||||||
filename: params.filename,
|
|
||||||
contentType: params.contentType,
|
|
||||||
tokenProvider: params.tokenProvider,
|
|
||||||
fetchFn: params.fetchFn,
|
|
||||||
});
|
|
||||||
|
|
||||||
const shareLink = await createSharingLink({
|
|
||||||
itemId: uploaded.id,
|
|
||||||
tokenProvider: params.tokenProvider,
|
|
||||||
scope: params.scope,
|
|
||||||
fetchFn: params.fetchFn,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
itemId: uploaded.id,
|
|
||||||
webUrl: uploaded.webUrl,
|
|
||||||
shareUrl: shareLink.webUrl,
|
|
||||||
name: uploaded.name,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// SharePoint upload functions for group chats and channels
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload a file to a SharePoint site.
|
|
||||||
* This is used for group chats and channels where /me/drive doesn't work for bots.
|
|
||||||
*
|
|
||||||
* @param params.siteId - SharePoint site ID (e.g., "contoso.sharepoint.com,guid1,guid2")
|
|
||||||
*/
|
|
||||||
export async function uploadToSharePoint(params: {
|
|
||||||
buffer: Buffer;
|
|
||||||
filename: string;
|
|
||||||
contentType?: string;
|
|
||||||
tokenProvider: MSTeamsAccessTokenProvider;
|
|
||||||
siteId: string;
|
|
||||||
fetchFn?: typeof fetch;
|
|
||||||
}): Promise<OneDriveUploadResult> {
|
|
||||||
const fetchFn = params.fetchFn ?? fetch;
|
|
||||||
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
|
|
||||||
|
|
||||||
// Use "ClawdbotShared" folder to organize bot-uploaded files
|
|
||||||
const uploadPath = `/ClawdbotShared/${encodeURIComponent(params.filename)}`;
|
|
||||||
|
|
||||||
const res = await fetchFn(`${GRAPH_ROOT}/sites/${params.siteId}/drive/root:${uploadPath}:/content`, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
"Content-Type": params.contentType ?? "application/octet-stream",
|
|
||||||
},
|
|
||||||
body: new Uint8Array(params.buffer),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = await res.text().catch(() => "");
|
|
||||||
throw new Error(`SharePoint upload failed: ${res.status} ${res.statusText} - ${body}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await res.json()) as {
|
|
||||||
id?: string;
|
|
||||||
webUrl?: string;
|
|
||||||
name?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!data.id || !data.webUrl || !data.name) {
|
|
||||||
throw new Error("SharePoint upload response missing required fields");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: data.id,
|
|
||||||
webUrl: data.webUrl,
|
|
||||||
name: data.name,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChatMember {
|
|
||||||
aadObjectId: string;
|
|
||||||
displayName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Properties needed for native Teams file card attachments.
|
|
||||||
* The eTag is used as the attachment ID and webDavUrl as the contentUrl.
|
|
||||||
*/
|
|
||||||
export interface DriveItemProperties {
|
|
||||||
/** The eTag of the driveItem (used as attachment ID) */
|
|
||||||
eTag: string;
|
|
||||||
/** The WebDAV URL of the driveItem (used as contentUrl for reference attachment) */
|
|
||||||
webDavUrl: string;
|
|
||||||
/** The filename */
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get driveItem properties needed for native Teams file card attachments.
|
|
||||||
* This fetches the eTag and webDavUrl which are required for "reference" type attachments.
|
|
||||||
*
|
|
||||||
* @param params.siteId - SharePoint site ID
|
|
||||||
* @param params.itemId - The driveItem ID (returned from upload)
|
|
||||||
*/
|
|
||||||
export async function getDriveItemProperties(params: {
|
|
||||||
siteId: string;
|
|
||||||
itemId: string;
|
|
||||||
tokenProvider: MSTeamsAccessTokenProvider;
|
|
||||||
fetchFn?: typeof fetch;
|
|
||||||
}): Promise<DriveItemProperties> {
|
|
||||||
const fetchFn = params.fetchFn ?? fetch;
|
|
||||||
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
|
|
||||||
|
|
||||||
const res = await fetchFn(
|
|
||||||
`${GRAPH_ROOT}/sites/${params.siteId}/drive/items/${params.itemId}?$select=eTag,webDavUrl,name`,
|
|
||||||
{ headers: { Authorization: `Bearer ${token}` } },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = await res.text().catch(() => "");
|
|
||||||
throw new Error(`Get driveItem properties failed: ${res.status} ${res.statusText} - ${body}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await res.json()) as {
|
|
||||||
eTag?: string;
|
|
||||||
webDavUrl?: string;
|
|
||||||
name?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!data.eTag || !data.webDavUrl || !data.name) {
|
|
||||||
throw new Error("DriveItem response missing required properties (eTag, webDavUrl, or name)");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
eTag: data.eTag,
|
|
||||||
webDavUrl: data.webDavUrl,
|
|
||||||
name: data.name,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get members of a Teams chat for per-user sharing.
|
|
||||||
* Used to create sharing links scoped to only the chat participants.
|
|
||||||
*/
|
|
||||||
export async function getChatMembers(params: {
|
|
||||||
chatId: string;
|
|
||||||
tokenProvider: MSTeamsAccessTokenProvider;
|
|
||||||
fetchFn?: typeof fetch;
|
|
||||||
}): Promise<ChatMember[]> {
|
|
||||||
const fetchFn = params.fetchFn ?? fetch;
|
|
||||||
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
|
|
||||||
|
|
||||||
const res = await fetchFn(`${GRAPH_ROOT}/chats/${params.chatId}/members`, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = await res.text().catch(() => "");
|
|
||||||
throw new Error(`Get chat members failed: ${res.status} ${res.statusText} - ${body}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await res.json()) as {
|
|
||||||
value?: Array<{
|
|
||||||
userId?: string;
|
|
||||||
displayName?: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (data.value ?? [])
|
|
||||||
.map((m) => ({
|
|
||||||
aadObjectId: m.userId ?? "",
|
|
||||||
displayName: m.displayName,
|
|
||||||
}))
|
|
||||||
.filter((m) => m.aadObjectId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a sharing link for a SharePoint drive item.
|
|
||||||
* For organization scope (default), uses v1.0 API.
|
|
||||||
* For per-user scope, uses beta API with recipients.
|
|
||||||
*/
|
|
||||||
export async function createSharePointSharingLink(params: {
|
|
||||||
siteId: string;
|
|
||||||
itemId: string;
|
|
||||||
tokenProvider: MSTeamsAccessTokenProvider;
|
|
||||||
/** Sharing scope: "organization" (default) or "users" (per-user with recipients) */
|
|
||||||
scope?: "organization" | "users";
|
|
||||||
/** Required when scope is "users": AAD object IDs of recipients */
|
|
||||||
recipientObjectIds?: string[];
|
|
||||||
fetchFn?: typeof fetch;
|
|
||||||
}): Promise<OneDriveSharingLink> {
|
|
||||||
const fetchFn = params.fetchFn ?? fetch;
|
|
||||||
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
|
|
||||||
const scope = params.scope ?? "organization";
|
|
||||||
|
|
||||||
// Per-user sharing requires beta API
|
|
||||||
const apiRoot = scope === "users" ? GRAPH_BETA : GRAPH_ROOT;
|
|
||||||
|
|
||||||
const body: Record<string, unknown> = {
|
|
||||||
type: "view",
|
|
||||||
scope: scope === "users" ? "users" : "organization",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add recipients for per-user sharing
|
|
||||||
if (scope === "users" && params.recipientObjectIds?.length) {
|
|
||||||
body.recipients = params.recipientObjectIds.map((id) => ({ objectId: id }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetchFn(`${apiRoot}/sites/${params.siteId}/drive/items/${params.itemId}/createLink`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const respBody = await res.text().catch(() => "");
|
|
||||||
throw new Error(`Create SharePoint sharing link failed: ${res.status} ${res.statusText} - ${respBody}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await res.json()) as {
|
|
||||||
link?: { webUrl?: string };
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!data.link?.webUrl) {
|
|
||||||
throw new Error("Create SharePoint sharing link response missing webUrl");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
webUrl: data.link.webUrl,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload a file to SharePoint and create a sharing link.
|
|
||||||
*
|
|
||||||
* For group chats, this creates a per-user sharing link scoped to chat members.
|
|
||||||
* For channels, this creates an organization-wide sharing link.
|
|
||||||
*
|
|
||||||
* @param params.siteId - SharePoint site ID
|
|
||||||
* @param params.chatId - Optional chat ID for per-user sharing (group chats)
|
|
||||||
* @param params.usePerUserSharing - Whether to use per-user sharing (requires beta API + Chat.Read.All)
|
|
||||||
*/
|
|
||||||
export async function uploadAndShareSharePoint(params: {
|
|
||||||
buffer: Buffer;
|
|
||||||
filename: string;
|
|
||||||
contentType?: string;
|
|
||||||
tokenProvider: MSTeamsAccessTokenProvider;
|
|
||||||
siteId: string;
|
|
||||||
chatId?: string;
|
|
||||||
usePerUserSharing?: boolean;
|
|
||||||
fetchFn?: typeof fetch;
|
|
||||||
}): Promise<{
|
|
||||||
itemId: string;
|
|
||||||
webUrl: string;
|
|
||||||
shareUrl: string;
|
|
||||||
name: string;
|
|
||||||
}> {
|
|
||||||
// 1. Upload file to SharePoint
|
|
||||||
const uploaded = await uploadToSharePoint({
|
|
||||||
buffer: params.buffer,
|
|
||||||
filename: params.filename,
|
|
||||||
contentType: params.contentType,
|
|
||||||
tokenProvider: params.tokenProvider,
|
|
||||||
siteId: params.siteId,
|
|
||||||
fetchFn: params.fetchFn,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Determine sharing scope
|
|
||||||
let scope: "organization" | "users" = "organization";
|
|
||||||
let recipientObjectIds: string[] | undefined;
|
|
||||||
|
|
||||||
if (params.usePerUserSharing && params.chatId) {
|
|
||||||
try {
|
|
||||||
const members = await getChatMembers({
|
|
||||||
chatId: params.chatId,
|
|
||||||
tokenProvider: params.tokenProvider,
|
|
||||||
fetchFn: params.fetchFn,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (members.length > 0) {
|
|
||||||
scope = "users";
|
|
||||||
recipientObjectIds = members.map((m) => m.aadObjectId);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Fall back to organization scope if we can't get chat members
|
|
||||||
// (e.g., missing Chat.Read.All permission)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Create sharing link
|
|
||||||
const shareLink = await createSharePointSharingLink({
|
|
||||||
siteId: params.siteId,
|
|
||||||
itemId: uploaded.id,
|
|
||||||
tokenProvider: params.tokenProvider,
|
|
||||||
scope,
|
|
||||||
recipientObjectIds,
|
|
||||||
fetchFn: params.fetchFn,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
itemId: uploaded.id,
|
|
||||||
webUrl: uploaded.webUrl,
|
|
||||||
shareUrl: shareLink.webUrl,
|
|
||||||
name: uploaded.name,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { extractFilename, extractMessageId, getMimeType, isLocalPath } from "./media-helpers.js";
|
|
||||||
|
|
||||||
describe("msteams media-helpers", () => {
|
|
||||||
describe("getMimeType", () => {
|
|
||||||
it("detects png from URL", async () => {
|
|
||||||
expect(await getMimeType("https://example.com/image.png")).toBe("image/png");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects jpeg from URL (both extensions)", async () => {
|
|
||||||
expect(await getMimeType("https://example.com/photo.jpg")).toBe("image/jpeg");
|
|
||||||
expect(await getMimeType("https://example.com/photo.jpeg")).toBe("image/jpeg");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects gif from URL", async () => {
|
|
||||||
expect(await getMimeType("https://example.com/anim.gif")).toBe("image/gif");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects webp from URL", async () => {
|
|
||||||
expect(await getMimeType("https://example.com/modern.webp")).toBe("image/webp");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles URLs with query strings", async () => {
|
|
||||||
expect(await getMimeType("https://example.com/image.png?v=123")).toBe("image/png");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles data URLs", async () => {
|
|
||||||
expect(await getMimeType("data:image/png;base64,iVBORw0KGgo=")).toBe("image/png");
|
|
||||||
expect(await getMimeType("data:image/jpeg;base64,/9j/4AAQ")).toBe("image/jpeg");
|
|
||||||
expect(await getMimeType("data:image/gif;base64,R0lGOD")).toBe("image/gif");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles data URLs without base64", async () => {
|
|
||||||
expect(await getMimeType("data:image/svg+xml,%3Csvg")).toBe("image/svg+xml");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles local paths", async () => {
|
|
||||||
expect(await getMimeType("/tmp/image.png")).toBe("image/png");
|
|
||||||
expect(await getMimeType("/Users/test/photo.jpg")).toBe("image/jpeg");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles tilde paths", async () => {
|
|
||||||
expect(await getMimeType("~/Downloads/image.gif")).toBe("image/gif");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults to application/octet-stream for unknown extensions", async () => {
|
|
||||||
expect(await getMimeType("https://example.com/image")).toBe("application/octet-stream");
|
|
||||||
expect(await getMimeType("https://example.com/image.unknown")).toBe("application/octet-stream");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is case-insensitive", async () => {
|
|
||||||
expect(await getMimeType("https://example.com/IMAGE.PNG")).toBe("image/png");
|
|
||||||
expect(await getMimeType("https://example.com/Photo.JPEG")).toBe("image/jpeg");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects document types", async () => {
|
|
||||||
expect(await getMimeType("https://example.com/doc.pdf")).toBe("application/pdf");
|
|
||||||
expect(await getMimeType("https://example.com/doc.docx")).toBe(
|
|
||||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
||||||
);
|
|
||||||
expect(await getMimeType("https://example.com/spreadsheet.xlsx")).toBe(
|
|
||||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("extractFilename", () => {
|
|
||||||
it("extracts filename from URL with extension", async () => {
|
|
||||||
expect(await extractFilename("https://example.com/photo.jpg")).toBe("photo.jpg");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("extracts filename from URL with path", async () => {
|
|
||||||
expect(await extractFilename("https://example.com/images/2024/photo.png")).toBe("photo.png");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles URLs without extension by deriving from MIME", async () => {
|
|
||||||
// Now defaults to application/octet-stream → .bin fallback
|
|
||||||
expect(await extractFilename("https://example.com/images/photo")).toBe("photo.bin");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles data URLs", async () => {
|
|
||||||
expect(await extractFilename("data:image/png;base64,iVBORw0KGgo=")).toBe("image.png");
|
|
||||||
expect(await extractFilename("data:image/jpeg;base64,/9j/4AAQ")).toBe("image.jpg");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles document data URLs", async () => {
|
|
||||||
expect(await extractFilename("data:application/pdf;base64,JVBERi0")).toBe("file.pdf");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles local paths", async () => {
|
|
||||||
expect(await extractFilename("/tmp/screenshot.png")).toBe("screenshot.png");
|
|
||||||
expect(await extractFilename("/Users/test/photo.jpg")).toBe("photo.jpg");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles tilde paths", async () => {
|
|
||||||
expect(await extractFilename("~/Downloads/image.gif")).toBe("image.gif");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns fallback for empty URL", async () => {
|
|
||||||
expect(await extractFilename("")).toBe("file.bin");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("extracts original filename from embedded pattern", async () => {
|
|
||||||
// Pattern: {original}---{uuid}.{ext}
|
|
||||||
expect(
|
|
||||||
await extractFilename("/media/inbound/report---a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf"),
|
|
||||||
).toBe("report.pdf");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("extracts original filename with uppercase UUID", async () => {
|
|
||||||
expect(
|
|
||||||
await extractFilename("/media/inbound/Document---A1B2C3D4-E5F6-7890-ABCD-EF1234567890.docx"),
|
|
||||||
).toBe("Document.docx");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to UUID filename for legacy paths", async () => {
|
|
||||||
// UUID-only filename (legacy format, no embedded name)
|
|
||||||
expect(
|
|
||||||
await extractFilename("/media/inbound/a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf"),
|
|
||||||
).toBe("a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles --- in filename without valid UUID pattern", async () => {
|
|
||||||
// foo---bar.txt (bar is not a valid UUID)
|
|
||||||
expect(await extractFilename("/media/inbound/foo---bar.txt")).toBe("foo---bar.txt");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isLocalPath", () => {
|
|
||||||
it("returns true for file:// URLs", () => {
|
|
||||||
expect(isLocalPath("file:///tmp/image.png")).toBe(true);
|
|
||||||
expect(isLocalPath("file://localhost/tmp/image.png")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns true for absolute paths", () => {
|
|
||||||
expect(isLocalPath("/tmp/image.png")).toBe(true);
|
|
||||||
expect(isLocalPath("/Users/test/photo.jpg")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns true for tilde paths", () => {
|
|
||||||
expect(isLocalPath("~/Downloads/image.png")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false for http URLs", () => {
|
|
||||||
expect(isLocalPath("http://example.com/image.png")).toBe(false);
|
|
||||||
expect(isLocalPath("https://example.com/image.png")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false for data URLs", () => {
|
|
||||||
expect(isLocalPath("data:image/png;base64,iVBORw0KGgo=")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("extractMessageId", () => {
|
|
||||||
it("extracts id from valid response", () => {
|
|
||||||
expect(extractMessageId({ id: "msg123" })).toBe("msg123");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for missing id", () => {
|
|
||||||
expect(extractMessageId({ foo: "bar" })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for empty id", () => {
|
|
||||||
expect(extractMessageId({ id: "" })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for non-string id", () => {
|
|
||||||
expect(extractMessageId({ id: 123 })).toBeNull();
|
|
||||||
expect(extractMessageId({ id: null })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for null response", () => {
|
|
||||||
expect(extractMessageId(null)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for undefined response", () => {
|
|
||||||
expect(extractMessageId(undefined)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for non-object response", () => {
|
|
||||||
expect(extractMessageId("string")).toBeNull();
|
|
||||||
expect(extractMessageId(123)).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
/**
|
|
||||||
* MIME type detection and filename extraction for MSTeams media attachments.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
import {
|
|
||||||
detectMime,
|
|
||||||
extensionForMime,
|
|
||||||
extractOriginalFilename,
|
|
||||||
getFileExtension,
|
|
||||||
} from "clawdbot/plugin-sdk";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect MIME type from URL extension or data URL.
|
|
||||||
* Uses shared MIME detection for consistency with core handling.
|
|
||||||
*/
|
|
||||||
export async function getMimeType(url: string): Promise<string> {
|
|
||||||
// Handle data URLs: data:image/png;base64,...
|
|
||||||
if (url.startsWith("data:")) {
|
|
||||||
const match = url.match(/^data:([^;,]+)/);
|
|
||||||
if (match?.[1]) return match[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use shared MIME detection (extension-based for URLs)
|
|
||||||
const detected = await detectMime({ filePath: url });
|
|
||||||
return detected ?? "application/octet-stream";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract filename from URL or local path.
|
|
||||||
* For local paths, extracts original filename if stored with embedded name pattern.
|
|
||||||
* Falls back to deriving the extension from MIME type when no extension present.
|
|
||||||
*/
|
|
||||||
export async function extractFilename(url: string): Promise<string> {
|
|
||||||
// Handle data URLs: derive extension from MIME
|
|
||||||
if (url.startsWith("data:")) {
|
|
||||||
const mime = await getMimeType(url);
|
|
||||||
const ext = extensionForMime(mime) ?? ".bin";
|
|
||||||
const prefix = mime.startsWith("image/") ? "image" : "file";
|
|
||||||
return `${prefix}${ext}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to extract from URL pathname
|
|
||||||
try {
|
|
||||||
const pathname = new URL(url).pathname;
|
|
||||||
const basename = path.basename(pathname);
|
|
||||||
const existingExt = getFileExtension(pathname);
|
|
||||||
if (basename && existingExt) return basename;
|
|
||||||
// No extension in URL, derive from MIME
|
|
||||||
const mime = await getMimeType(url);
|
|
||||||
const ext = extensionForMime(mime) ?? ".bin";
|
|
||||||
const prefix = mime.startsWith("image/") ? "image" : "file";
|
|
||||||
return basename ? `${basename}${ext}` : `${prefix}${ext}`;
|
|
||||||
} catch {
|
|
||||||
// Local paths - use extractOriginalFilename to extract embedded original name
|
|
||||||
return extractOriginalFilename(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a URL refers to a local file path.
|
|
||||||
*/
|
|
||||||
export function isLocalPath(url: string): boolean {
|
|
||||||
return url.startsWith("file://") || url.startsWith("/") || url.startsWith("~");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract the message ID from a Bot Framework response.
|
|
||||||
*/
|
|
||||||
export function extractMessageId(response: unknown): string | null {
|
|
||||||
if (!response || typeof response !== "object") return null;
|
|
||||||
if (!("id" in response)) return null;
|
|
||||||
const { id } = response as { id?: unknown };
|
|
||||||
if (typeof id !== "string" || !id) return null;
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
@@ -51,7 +51,7 @@ describe("msteams messenger", () => {
|
|||||||
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
|
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
|
||||||
{ textChunkLimit: 4000 },
|
{ textChunkLimit: 4000 },
|
||||||
);
|
);
|
||||||
expect(messages).toEqual([{ text: "hi" }, { mediaUrl: "https://example.com/a.png" }]);
|
expect(messages).toEqual(["hi", "https://example.com/a.png"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports inline media mode", () => {
|
it("supports inline media mode", () => {
|
||||||
@@ -59,7 +59,7 @@ describe("msteams messenger", () => {
|
|||||||
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
|
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
|
||||||
{ textChunkLimit: 4000, mediaMode: "inline" },
|
{ textChunkLimit: 4000, mediaMode: "inline" },
|
||||||
);
|
);
|
||||||
expect(messages).toEqual([{ text: "hi", mediaUrl: "https://example.com/a.png" }]);
|
expect(messages).toEqual(["hi\n\nhttps://example.com/a.png"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("chunks long text when enabled", () => {
|
it("chunks long text when enabled", () => {
|
||||||
@@ -101,7 +101,7 @@ describe("msteams messenger", () => {
|
|||||||
appId: "app123",
|
appId: "app123",
|
||||||
conversationRef: baseRef,
|
conversationRef: baseRef,
|
||||||
context: ctx,
|
context: ctx,
|
||||||
messages: [{ text: "one" }, { text: "two" }],
|
messages: ["one", "two"],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(sent).toEqual(["one", "two"]);
|
expect(sent).toEqual(["one", "two"]);
|
||||||
@@ -129,7 +129,7 @@ describe("msteams messenger", () => {
|
|||||||
adapter,
|
adapter,
|
||||||
appId: "app123",
|
appId: "app123",
|
||||||
conversationRef: baseRef,
|
conversationRef: baseRef,
|
||||||
messages: [{ text: "hello" }],
|
messages: ["hello"],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(seen.texts).toEqual(["hello"]);
|
expect(seen.texts).toEqual(["hello"]);
|
||||||
@@ -168,7 +168,7 @@ describe("msteams messenger", () => {
|
|||||||
appId: "app123",
|
appId: "app123",
|
||||||
conversationRef: baseRef,
|
conversationRef: baseRef,
|
||||||
context: ctx,
|
context: ctx,
|
||||||
messages: [{ text: "one" }],
|
messages: ["one"],
|
||||||
retry: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 },
|
retry: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 },
|
||||||
onRetry: (e) => retryEvents.push({ nextAttempt: e.nextAttempt, delayMs: e.delayMs }),
|
onRetry: (e) => retryEvents.push({ nextAttempt: e.nextAttempt, delayMs: e.delayMs }),
|
||||||
});
|
});
|
||||||
@@ -196,7 +196,7 @@ describe("msteams messenger", () => {
|
|||||||
appId: "app123",
|
appId: "app123",
|
||||||
conversationRef: baseRef,
|
conversationRef: baseRef,
|
||||||
context: ctx,
|
context: ctx,
|
||||||
messages: [{ text: "one" }],
|
messages: ["one"],
|
||||||
retry: { maxAttempts: 3, baseDelayMs: 0, maxDelayMs: 0 },
|
retry: { maxAttempts: 3, baseDelayMs: 0, maxDelayMs: 0 },
|
||||||
}),
|
}),
|
||||||
).rejects.toMatchObject({ statusCode: 400 });
|
).rejects.toMatchObject({ statusCode: 400 });
|
||||||
@@ -227,7 +227,7 @@ describe("msteams messenger", () => {
|
|||||||
adapter,
|
adapter,
|
||||||
appId: "app123",
|
appId: "app123",
|
||||||
conversationRef: baseRef,
|
conversationRef: baseRef,
|
||||||
messages: [{ text: "hello" }],
|
messages: ["hello"],
|
||||||
retry: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 },
|
retry: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
isSilentReplyText,
|
isSilentReplyText,
|
||||||
loadWebMedia,
|
|
||||||
type MSTeamsReplyStyle,
|
type MSTeamsReplyStyle,
|
||||||
type ReplyPayload,
|
type ReplyPayload,
|
||||||
SILENT_REPLY_TOKEN,
|
SILENT_REPLY_TOKEN,
|
||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
|
|
||||||
import type { StoredConversationReference } from "./conversation-store.js";
|
import type { StoredConversationReference } from "./conversation-store.js";
|
||||||
import { classifyMSTeamsSendError } from "./errors.js";
|
import { classifyMSTeamsSendError } from "./errors.js";
|
||||||
import { prepareFileConsentActivity, requiresFileConsent } from "./file-consent-helpers.js";
|
|
||||||
import { buildTeamsFileInfoCard } from "./graph-chat.js";
|
|
||||||
import {
|
|
||||||
getDriveItemProperties,
|
|
||||||
uploadAndShareOneDrive,
|
|
||||||
uploadAndShareSharePoint,
|
|
||||||
} from "./graph-upload.js";
|
|
||||||
import { extractFilename, extractMessageId, getMimeType, isLocalPath } from "./media-helpers.js";
|
|
||||||
import { getMSTeamsRuntime } from "./runtime.js";
|
import { getMSTeamsRuntime } from "./runtime.js";
|
||||||
|
|
||||||
/**
|
|
||||||
* MSTeams-specific media size limit (100MB).
|
|
||||||
* Higher than the default because OneDrive upload handles large files well.
|
|
||||||
*/
|
|
||||||
const MSTEAMS_MAX_MEDIA_BYTES = 100 * 1024 * 1024;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Threshold for large files that require FileConsentCard flow in personal chats.
|
|
||||||
* Files >= 4MB use consent flow; smaller images can use inline base64.
|
|
||||||
*/
|
|
||||||
const FILE_CONSENT_THRESHOLD_BYTES = 4 * 1024 * 1024;
|
|
||||||
|
|
||||||
type SendContext = {
|
type SendContext = {
|
||||||
sendActivity: (textOrActivity: string | object) => Promise<unknown>;
|
sendActivity: (textOrActivity: string | object) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
@@ -63,15 +41,6 @@ export type MSTeamsReplyRenderOptions = {
|
|||||||
mediaMode?: "split" | "inline";
|
mediaMode?: "split" | "inline";
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* A rendered message that preserves media vs text distinction.
|
|
||||||
* When mediaUrl is present, it will be sent as a Bot Framework attachment.
|
|
||||||
*/
|
|
||||||
export type MSTeamsRenderedMessage = {
|
|
||||||
text?: string;
|
|
||||||
mediaUrl?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MSTeamsSendRetryOptions = {
|
export type MSTeamsSendRetryOptions = {
|
||||||
maxAttempts?: number;
|
maxAttempts?: number;
|
||||||
baseDelayMs?: number;
|
baseDelayMs?: number;
|
||||||
@@ -121,8 +90,16 @@ export function buildConversationReference(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractMessageId(response: unknown): string | null {
|
||||||
|
if (!response || typeof response !== "object") return null;
|
||||||
|
if (!("id" in response)) return null;
|
||||||
|
const { id } = response as { id?: unknown };
|
||||||
|
if (typeof id !== "string" || !id) return null;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
function pushTextMessages(
|
function pushTextMessages(
|
||||||
out: MSTeamsRenderedMessage[],
|
out: string[],
|
||||||
text: string,
|
text: string,
|
||||||
opts: {
|
opts: {
|
||||||
chunkText: boolean;
|
chunkText: boolean;
|
||||||
@@ -134,17 +111,16 @@ function pushTextMessages(
|
|||||||
for (const chunk of getMSTeamsRuntime().channel.text.chunkMarkdownText(text, opts.chunkLimit)) {
|
for (const chunk of getMSTeamsRuntime().channel.text.chunkMarkdownText(text, opts.chunkLimit)) {
|
||||||
const trimmed = chunk.trim();
|
const trimmed = chunk.trim();
|
||||||
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue;
|
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue;
|
||||||
out.push({ text: trimmed });
|
out.push(trimmed);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) return;
|
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) return;
|
||||||
out.push({ text: trimmed });
|
out.push(trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function clampMs(value: number, maxMs: number): number {
|
function clampMs(value: number, maxMs: number): number {
|
||||||
if (!Number.isFinite(value) || value < 0) return 0;
|
if (!Number.isFinite(value) || value < 0) return 0;
|
||||||
return Math.min(value, maxMs);
|
return Math.min(value, maxMs);
|
||||||
@@ -191,8 +167,8 @@ function shouldRetry(classification: ReturnType<typeof classifyMSTeamsSendError>
|
|||||||
export function renderReplyPayloadsToMessages(
|
export function renderReplyPayloadsToMessages(
|
||||||
replies: ReplyPayload[],
|
replies: ReplyPayload[],
|
||||||
options: MSTeamsReplyRenderOptions,
|
options: MSTeamsReplyRenderOptions,
|
||||||
): MSTeamsRenderedMessage[] {
|
): string[] {
|
||||||
const out: MSTeamsRenderedMessage[] = [];
|
const out: string[] = [];
|
||||||
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";
|
||||||
@@ -209,17 +185,8 @@ export function renderReplyPayloadsToMessages(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mediaMode === "inline") {
|
if (mediaMode === "inline") {
|
||||||
// For inline mode, combine text with first media as attachment
|
const combined = text ? `${text}\n\n${mediaList.join("\n")}` : mediaList.join("\n");
|
||||||
const firstMedia = mediaList[0];
|
pushTextMessages(out, combined, { chunkText, chunkLimit });
|
||||||
if (firstMedia) {
|
|
||||||
out.push({ text: text || undefined, mediaUrl: firstMedia });
|
|
||||||
// Additional media URLs as separate messages
|
|
||||||
for (let i = 1; i < mediaList.length; i++) {
|
|
||||||
if (mediaList[i]) out.push({ mediaUrl: mediaList[i] });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
pushTextMessages(out, text, { chunkText, chunkLimit });
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,142 +194,26 @@ export function renderReplyPayloadsToMessages(
|
|||||||
pushTextMessages(out, text, { chunkText, chunkLimit });
|
pushTextMessages(out, text, { chunkText, chunkLimit });
|
||||||
for (const mediaUrl of mediaList) {
|
for (const mediaUrl of mediaList) {
|
||||||
if (!mediaUrl) continue;
|
if (!mediaUrl) continue;
|
||||||
out.push({ mediaUrl });
|
out.push(mediaUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildActivity(
|
|
||||||
msg: MSTeamsRenderedMessage,
|
|
||||||
conversationRef: StoredConversationReference,
|
|
||||||
tokenProvider?: MSTeamsAccessTokenProvider,
|
|
||||||
sharePointSiteId?: string,
|
|
||||||
mediaMaxBytes?: number,
|
|
||||||
): Promise<Record<string, unknown>> {
|
|
||||||
const activity: Record<string, unknown> = { type: "message" };
|
|
||||||
|
|
||||||
if (msg.text) {
|
|
||||||
activity.text = msg.text;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.mediaUrl) {
|
|
||||||
let contentUrl = msg.mediaUrl;
|
|
||||||
let contentType = await getMimeType(msg.mediaUrl);
|
|
||||||
let fileName = await extractFilename(msg.mediaUrl);
|
|
||||||
|
|
||||||
if (isLocalPath(msg.mediaUrl)) {
|
|
||||||
const maxBytes = mediaMaxBytes ?? MSTEAMS_MAX_MEDIA_BYTES;
|
|
||||||
const media = await loadWebMedia(msg.mediaUrl, maxBytes);
|
|
||||||
contentType = media.contentType ?? contentType;
|
|
||||||
fileName = media.fileName ?? fileName;
|
|
||||||
|
|
||||||
// Determine conversation type and file type
|
|
||||||
// Teams only accepts base64 data URLs for images
|
|
||||||
const conversationType = conversationRef.conversation?.conversationType?.toLowerCase();
|
|
||||||
const isPersonal = conversationType === "personal";
|
|
||||||
const isImage = contentType?.startsWith("image/") ?? false;
|
|
||||||
|
|
||||||
if (requiresFileConsent({
|
|
||||||
conversationType,
|
|
||||||
contentType,
|
|
||||||
bufferSize: media.buffer.length,
|
|
||||||
thresholdBytes: FILE_CONSENT_THRESHOLD_BYTES,
|
|
||||||
})) {
|
|
||||||
// Large file or non-image in personal chat: use FileConsentCard flow
|
|
||||||
const conversationId = conversationRef.conversation?.id ?? "unknown";
|
|
||||||
const { activity: consentActivity } = prepareFileConsentActivity({
|
|
||||||
media: { buffer: media.buffer, filename: fileName, contentType },
|
|
||||||
conversationId,
|
|
||||||
description: msg.text || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return the consent activity (caller sends it)
|
|
||||||
return consentActivity;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isPersonal && !isImage && tokenProvider && sharePointSiteId) {
|
|
||||||
// Non-image in group chat/channel with SharePoint site configured:
|
|
||||||
// Upload to SharePoint and use native file card attachment
|
|
||||||
const chatId = conversationRef.conversation?.id;
|
|
||||||
|
|
||||||
// Upload to SharePoint
|
|
||||||
const uploaded = await uploadAndShareSharePoint({
|
|
||||||
buffer: media.buffer,
|
|
||||||
filename: fileName,
|
|
||||||
contentType,
|
|
||||||
tokenProvider,
|
|
||||||
siteId: sharePointSiteId,
|
|
||||||
chatId: chatId ?? undefined,
|
|
||||||
usePerUserSharing: conversationType === "groupchat",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get driveItem properties needed for native file card attachment
|
|
||||||
const driveItem = await getDriveItemProperties({
|
|
||||||
siteId: sharePointSiteId,
|
|
||||||
itemId: uploaded.itemId,
|
|
||||||
tokenProvider,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Build native Teams file card attachment
|
|
||||||
const fileCardAttachment = buildTeamsFileInfoCard(driveItem);
|
|
||||||
activity.attachments = [fileCardAttachment];
|
|
||||||
|
|
||||||
return activity;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isPersonal && !isImage && tokenProvider) {
|
|
||||||
// Fallback: no SharePoint site configured, try OneDrive upload
|
|
||||||
const uploaded = await uploadAndShareOneDrive({
|
|
||||||
buffer: media.buffer,
|
|
||||||
filename: fileName,
|
|
||||||
contentType,
|
|
||||||
tokenProvider,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Bot Framework doesn't support "reference" attachment type for sending
|
|
||||||
const fileLink = `📎 [${uploaded.name}](${uploaded.shareUrl})`;
|
|
||||||
activity.text = msg.text ? `${msg.text}\n\n${fileLink}` : fileLink;
|
|
||||||
return activity;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Image (any chat): use base64 (works for images in all conversation types)
|
|
||||||
const base64 = media.buffer.toString("base64");
|
|
||||||
contentUrl = `data:${media.contentType};base64,${base64}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
activity.attachments = [
|
|
||||||
{
|
|
||||||
name: fileName,
|
|
||||||
contentType,
|
|
||||||
contentUrl,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return activity;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendMSTeamsMessages(params: {
|
export async function sendMSTeamsMessages(params: {
|
||||||
replyStyle: MSTeamsReplyStyle;
|
replyStyle: MSTeamsReplyStyle;
|
||||||
adapter: MSTeamsAdapter;
|
adapter: MSTeamsAdapter;
|
||||||
appId: string;
|
appId: string;
|
||||||
conversationRef: StoredConversationReference;
|
conversationRef: StoredConversationReference;
|
||||||
context?: SendContext;
|
context?: SendContext;
|
||||||
messages: MSTeamsRenderedMessage[];
|
messages: string[];
|
||||||
retry?: false | MSTeamsSendRetryOptions;
|
retry?: false | MSTeamsSendRetryOptions;
|
||||||
onRetry?: (event: MSTeamsSendRetryEvent) => void;
|
onRetry?: (event: MSTeamsSendRetryEvent) => void;
|
||||||
/** Token provider for OneDrive/SharePoint uploads in group chats/channels */
|
|
||||||
tokenProvider?: MSTeamsAccessTokenProvider;
|
|
||||||
/** SharePoint site ID for file uploads in group chats/channels */
|
|
||||||
sharePointSiteId?: string;
|
|
||||||
/** Max media size in bytes. Default: 100MB. */
|
|
||||||
mediaMaxBytes?: number;
|
|
||||||
}): Promise<string[]> {
|
}): Promise<string[]> {
|
||||||
const messages = params.messages.filter(
|
const messages = params.messages
|
||||||
(m) => (m.text && m.text.trim().length > 0) || m.mediaUrl,
|
.map((m) => (typeof m === "string" ? m : String(m)))
|
||||||
);
|
.filter((m) => m.trim().length > 0);
|
||||||
if (messages.length === 0) return [];
|
if (messages.length === 0) return [];
|
||||||
|
|
||||||
const retryOptions = resolveRetryOptions(params.retry);
|
const retryOptions = resolveRetryOptions(params.retry);
|
||||||
@@ -408,9 +259,10 @@ export async function sendMSTeamsMessages(params: {
|
|||||||
for (const [idx, message] of messages.entries()) {
|
for (const [idx, message] of messages.entries()) {
|
||||||
const response = await sendWithRetry(
|
const response = await sendWithRetry(
|
||||||
async () =>
|
async () =>
|
||||||
await ctx.sendActivity(
|
await ctx.sendActivity({
|
||||||
await buildActivity(message, params.conversationRef, params.tokenProvider, params.sharePointSiteId, params.mediaMaxBytes),
|
type: "message",
|
||||||
),
|
text: message,
|
||||||
|
}),
|
||||||
{ messageIndex: idx, messageCount: messages.length },
|
{ messageIndex: idx, messageCount: messages.length },
|
||||||
);
|
);
|
||||||
messageIds.push(extractMessageId(response) ?? "unknown");
|
messageIds.push(extractMessageId(response) ?? "unknown");
|
||||||
@@ -429,9 +281,10 @@ export async function sendMSTeamsMessages(params: {
|
|||||||
for (const [idx, message] of messages.entries()) {
|
for (const [idx, message] of messages.entries()) {
|
||||||
const response = await sendWithRetry(
|
const response = await sendWithRetry(
|
||||||
async () =>
|
async () =>
|
||||||
await ctx.sendActivity(
|
await ctx.sendActivity({
|
||||||
await buildActivity(message, params.conversationRef, params.tokenProvider, params.sharePointSiteId, params.mediaMaxBytes),
|
type: "message",
|
||||||
),
|
text: message,
|
||||||
|
}),
|
||||||
{ messageIndex: idx, messageCount: messages.length },
|
{ messageIndex: idx, messageCount: messages.length },
|
||||||
);
|
);
|
||||||
messageIds.push(extractMessageId(response) ?? "unknown");
|
messageIds.push(extractMessageId(response) ?? "unknown");
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
|
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||||
import type { MSTeamsConversationStore } from "./conversation-store.js";
|
import type { MSTeamsConversationStore } from "./conversation-store.js";
|
||||||
import {
|
|
||||||
buildFileInfoCard,
|
|
||||||
parseFileConsentInvoke,
|
|
||||||
uploadToConsentUrl,
|
|
||||||
} from "./file-consent.js";
|
|
||||||
import type { MSTeamsAdapter } from "./messenger.js";
|
import type { MSTeamsAdapter } from "./messenger.js";
|
||||||
import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js";
|
import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js";
|
||||||
import type { MSTeamsMonitorLogger } from "./monitor-types.js";
|
import type { MSTeamsMonitorLogger } from "./monitor-types.js";
|
||||||
import { getPendingUpload, removePendingUpload } from "./pending-uploads.js";
|
|
||||||
import type { MSTeamsPollStore } from "./polls.js";
|
import type { MSTeamsPollStore } from "./polls.js";
|
||||||
import type { MSTeamsTurnContext } from "./sdk-types.js";
|
import type { MSTeamsTurnContext } from "./sdk-types.js";
|
||||||
|
|
||||||
@@ -23,7 +17,6 @@ export type MSTeamsActivityHandler = {
|
|||||||
onMembersAdded: (
|
onMembersAdded: (
|
||||||
handler: (context: unknown, next: () => Promise<void>) => Promise<void>,
|
handler: (context: unknown, next: () => Promise<void>) => Promise<void>,
|
||||||
) => MSTeamsActivityHandler;
|
) => MSTeamsActivityHandler;
|
||||||
run?: (context: unknown) => Promise<void>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MSTeamsMessageHandlerDeps = {
|
export type MSTeamsMessageHandlerDeps = {
|
||||||
@@ -39,109 +32,11 @@ export type MSTeamsMessageHandlerDeps = {
|
|||||||
log: MSTeamsMonitorLogger;
|
log: MSTeamsMonitorLogger;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle fileConsent/invoke activities for large file uploads.
|
|
||||||
*/
|
|
||||||
async function handleFileConsentInvoke(
|
|
||||||
context: MSTeamsTurnContext,
|
|
||||||
log: MSTeamsMonitorLogger,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const activity = context.activity;
|
|
||||||
if (activity.type !== "invoke" || activity.name !== "fileConsent/invoke") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const consentResponse = parseFileConsentInvoke(activity);
|
|
||||||
if (!consentResponse) {
|
|
||||||
log.debug("invalid file consent invoke", { value: activity.value });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const uploadId =
|
|
||||||
typeof consentResponse.context?.uploadId === "string"
|
|
||||||
? consentResponse.context.uploadId
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (consentResponse.action === "accept" && consentResponse.uploadInfo) {
|
|
||||||
const pendingFile = getPendingUpload(uploadId);
|
|
||||||
if (pendingFile) {
|
|
||||||
log.debug("user accepted file consent, uploading", {
|
|
||||||
uploadId,
|
|
||||||
filename: pendingFile.filename,
|
|
||||||
size: pendingFile.buffer.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Upload file to the provided URL
|
|
||||||
await uploadToConsentUrl({
|
|
||||||
url: consentResponse.uploadInfo.uploadUrl,
|
|
||||||
buffer: pendingFile.buffer,
|
|
||||||
contentType: pendingFile.contentType,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send confirmation card
|
|
||||||
const fileInfoCard = buildFileInfoCard({
|
|
||||||
filename: consentResponse.uploadInfo.name,
|
|
||||||
contentUrl: consentResponse.uploadInfo.contentUrl,
|
|
||||||
uniqueId: consentResponse.uploadInfo.uniqueId,
|
|
||||||
fileType: consentResponse.uploadInfo.fileType,
|
|
||||||
});
|
|
||||||
|
|
||||||
await context.sendActivity({
|
|
||||||
type: "message",
|
|
||||||
attachments: [fileInfoCard],
|
|
||||||
});
|
|
||||||
|
|
||||||
log.info("file upload complete", {
|
|
||||||
uploadId,
|
|
||||||
filename: consentResponse.uploadInfo.name,
|
|
||||||
uniqueId: consentResponse.uploadInfo.uniqueId,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
log.debug("file upload failed", { uploadId, error: String(err) });
|
|
||||||
await context.sendActivity(`File upload failed: ${String(err)}`);
|
|
||||||
} finally {
|
|
||||||
removePendingUpload(uploadId);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.debug("pending file not found for consent", { uploadId });
|
|
||||||
await context.sendActivity(
|
|
||||||
"The file upload request has expired. Please try sending the file again.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// User declined
|
|
||||||
log.debug("user declined file consent", { uploadId });
|
|
||||||
removePendingUpload(uploadId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerMSTeamsHandlers<T extends MSTeamsActivityHandler>(
|
export function registerMSTeamsHandlers<T extends MSTeamsActivityHandler>(
|
||||||
handler: T,
|
handler: T,
|
||||||
deps: MSTeamsMessageHandlerDeps,
|
deps: MSTeamsMessageHandlerDeps,
|
||||||
): T {
|
): T {
|
||||||
const handleTeamsMessage = createMSTeamsMessageHandler(deps);
|
const handleTeamsMessage = createMSTeamsMessageHandler(deps);
|
||||||
|
|
||||||
// Wrap the original run method to intercept invokes
|
|
||||||
const originalRun = handler.run;
|
|
||||||
if (originalRun) {
|
|
||||||
handler.run = async (context: unknown) => {
|
|
||||||
const ctx = context as MSTeamsTurnContext;
|
|
||||||
// Handle file consent invokes before passing to normal flow
|
|
||||||
if (ctx.activity?.type === "invoke" && ctx.activity?.name === "fileConsent/invoke") {
|
|
||||||
const handled = await handleFileConsentInvoke(ctx, deps.log);
|
|
||||||
if (handled) {
|
|
||||||
// Send invoke response for file consent
|
|
||||||
await ctx.sendActivity({ type: "invokeResponse", value: { status: 200 } });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return originalRun.call(handler, context);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
handler.onMessage(async (context, next) => {
|
handler.onMessage(async (context, next) => {
|
||||||
try {
|
try {
|
||||||
await handleTeamsMessage(context as MSTeamsTurnContext);
|
await handleTeamsMessage(context as MSTeamsTurnContext);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
buildMSTeamsGraphMessageUrls,
|
buildMSTeamsGraphMessageUrls,
|
||||||
downloadMSTeamsAttachments,
|
|
||||||
downloadMSTeamsGraphMedia,
|
downloadMSTeamsGraphMedia,
|
||||||
|
downloadMSTeamsImageAttachments,
|
||||||
type MSTeamsAccessTokenProvider,
|
type MSTeamsAccessTokenProvider,
|
||||||
type MSTeamsAttachmentLike,
|
type MSTeamsAttachmentLike,
|
||||||
type MSTeamsHtmlAttachmentSummary,
|
type MSTeamsHtmlAttachmentSummary,
|
||||||
@@ -24,8 +24,6 @@ export async function resolveMSTeamsInboundMedia(params: {
|
|||||||
conversationMessageId?: string;
|
conversationMessageId?: string;
|
||||||
activity: Pick<MSTeamsTurnContext["activity"], "id" | "replyToId" | "channelData">;
|
activity: Pick<MSTeamsTurnContext["activity"], "id" | "replyToId" | "channelData">;
|
||||||
log: MSTeamsLogger;
|
log: MSTeamsLogger;
|
||||||
/** When true, embeds original filename in stored path for later extraction. */
|
|
||||||
preserveFilenames?: boolean;
|
|
||||||
}): Promise<MSTeamsInboundMedia[]> {
|
}): Promise<MSTeamsInboundMedia[]> {
|
||||||
const {
|
const {
|
||||||
attachments,
|
attachments,
|
||||||
@@ -38,15 +36,13 @@ export async function resolveMSTeamsInboundMedia(params: {
|
|||||||
conversationMessageId,
|
conversationMessageId,
|
||||||
activity,
|
activity,
|
||||||
log,
|
log,
|
||||||
preserveFilenames,
|
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
let mediaList = await downloadMSTeamsAttachments({
|
let mediaList = await downloadMSTeamsImageAttachments({
|
||||||
attachments,
|
attachments,
|
||||||
maxBytes,
|
maxBytes,
|
||||||
tokenProvider,
|
tokenProvider,
|
||||||
allowHosts,
|
allowHosts,
|
||||||
preserveFilenames,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (mediaList.length === 0) {
|
if (mediaList.length === 0) {
|
||||||
@@ -85,7 +81,6 @@ export async function resolveMSTeamsInboundMedia(params: {
|
|||||||
tokenProvider,
|
tokenProvider,
|
||||||
maxBytes,
|
maxBytes,
|
||||||
allowHosts,
|
allowHosts,
|
||||||
preserveFilenames,
|
|
||||||
});
|
});
|
||||||
attempts.push({
|
attempts.push({
|
||||||
url: messageUrl,
|
url: messageUrl,
|
||||||
@@ -109,7 +104,7 @@ export async function resolveMSTeamsInboundMedia(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mediaList.length > 0) {
|
if (mediaList.length > 0) {
|
||||||
log.debug("downloaded attachments", { count: mediaList.length });
|
log.debug("downloaded image attachments", { count: mediaList.length });
|
||||||
} else if (htmlSummary?.imgTags) {
|
} else if (htmlSummary?.imgTags) {
|
||||||
log.debug("inline images detected but none downloaded", {
|
log.debug("inline images detected but none downloaded", {
|
||||||
imgTags: htmlSummary.imgTags,
|
imgTags: htmlSummary.imgTags,
|
||||||
|
|||||||
@@ -402,8 +402,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
channelData: activity.channelData,
|
channelData: activity.channelData,
|
||||||
},
|
},
|
||||||
log,
|
log,
|
||||||
preserveFilenames: cfg.media?.preserveFilenames,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const mediaPayload = buildMSTeamsMediaPayload(mediaList);
|
const mediaPayload = buildMSTeamsMediaPayload(mediaList);
|
||||||
const envelopeFrom = isDirectMessage ? senderName : conversationType;
|
const envelopeFrom = isDirectMessage ? senderName : conversationType;
|
||||||
@@ -477,7 +476,6 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
|
|
||||||
logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
|
logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
|
||||||
|
|
||||||
const sharePointSiteId = msteamsCfg?.sharePointSiteId;
|
|
||||||
const { dispatcher, replyOptions, markDispatchIdle } = createMSTeamsReplyDispatcher({
|
const { dispatcher, replyOptions, markDispatchIdle } = createMSTeamsReplyDispatcher({
|
||||||
cfg,
|
cfg,
|
||||||
agentId: route.agentId,
|
agentId: route.agentId,
|
||||||
@@ -494,8 +492,6 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
recordMSTeamsSentMessage(conversationId, id);
|
recordMSTeamsSentMessage(conversationId, id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
tokenProvider,
|
|
||||||
sharePointSiteId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
log.info("dispatching to agent", { sessionKey: route.sessionKey });
|
log.info("dispatching to agent", { sessionKey: route.sessionKey });
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
/**
|
|
||||||
* In-memory storage for files awaiting user consent in the FileConsentCard flow.
|
|
||||||
*
|
|
||||||
* When sending large files (>=4MB) in personal chats, Teams requires user consent
|
|
||||||
* before upload. This module stores the file data temporarily until the user
|
|
||||||
* accepts or declines, or until the TTL expires.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import crypto from "node:crypto";
|
|
||||||
|
|
||||||
export interface PendingUpload {
|
|
||||||
id: string;
|
|
||||||
buffer: Buffer;
|
|
||||||
filename: string;
|
|
||||||
contentType?: string;
|
|
||||||
conversationId: string;
|
|
||||||
createdAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pendingUploads = new Map<string, PendingUpload>();
|
|
||||||
|
|
||||||
/** TTL for pending uploads: 5 minutes */
|
|
||||||
const PENDING_UPLOAD_TTL_MS = 5 * 60 * 1000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Store a file pending user consent.
|
|
||||||
* Returns the upload ID to include in the FileConsentCard context.
|
|
||||||
*/
|
|
||||||
export function storePendingUpload(
|
|
||||||
upload: Omit<PendingUpload, "id" | "createdAt">,
|
|
||||||
): string {
|
|
||||||
const id = crypto.randomUUID();
|
|
||||||
const entry: PendingUpload = {
|
|
||||||
...upload,
|
|
||||||
id,
|
|
||||||
createdAt: Date.now(),
|
|
||||||
};
|
|
||||||
pendingUploads.set(id, entry);
|
|
||||||
|
|
||||||
// Auto-cleanup after TTL
|
|
||||||
setTimeout(() => {
|
|
||||||
pendingUploads.delete(id);
|
|
||||||
}, PENDING_UPLOAD_TTL_MS);
|
|
||||||
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve a pending upload by ID.
|
|
||||||
* Returns undefined if not found or expired.
|
|
||||||
*/
|
|
||||||
export function getPendingUpload(id?: string): PendingUpload | undefined {
|
|
||||||
if (!id) return undefined;
|
|
||||||
const entry = pendingUploads.get(id);
|
|
||||||
if (!entry) return undefined;
|
|
||||||
|
|
||||||
// Check if expired (in case timeout hasn't fired yet)
|
|
||||||
if (Date.now() - entry.createdAt > PENDING_UPLOAD_TTL_MS) {
|
|
||||||
pendingUploads.delete(id);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a pending upload (after successful upload or user decline).
|
|
||||||
*/
|
|
||||||
export function removePendingUpload(id?: string): void {
|
|
||||||
if (id) {
|
|
||||||
pendingUploads.delete(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the count of pending uploads (for monitoring/debugging).
|
|
||||||
*/
|
|
||||||
export function getPendingUploadCount(): number {
|
|
||||||
return pendingUploads.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all pending uploads (for testing).
|
|
||||||
*/
|
|
||||||
export function clearPendingUploads(): void {
|
|
||||||
pendingUploads.clear();
|
|
||||||
}
|
|
||||||
Loaded 100 of 293 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user