Compare commits

..
Author SHA1 Message Date
Peter Steinberger fc45148155 fix: harden qwen oauth flow (#1120) (thanks @mukhtharcm) 2026-01-18 01:03:08 +00:00
Muhammed Mukhthar CM 215c395fc2 UI: simplify Qwen labels 2026-01-18 01:03:08 +00:00
Muhammed Mukhthar CM b56b67cdbd UI: label Qwen provider 2026-01-18 01:03:08 +00:00
Muhammed Mukhthar CM a760db9921 Docs: add Qwen Portal provider 2026-01-18 01:03:08 +00:00
Muhammed Mukhthar CM 8eb80ee40a Models: add Qwen Portal OAuth support 2026-01-18 01:03:08 +00:00
Peter Steinberger f9e3b129ed test: reindex on embedding model change 2026-01-18 01:00:57 +00:00
Peter Steinberger e5050abe2a docs: note model change reindex 2026-01-18 01:00:57 +00:00
Peter Steinberger 4f0771f67b fix(channels): clean up discord resolve typing 2026-01-18 01:00:25 +00:00
Peter Steinberger 075ff675ac refactor(channels): share allowlist + resolver helpers 2026-01-18 01:00:25 +00:00
Peter Steinberger c7ea47e886 feat(channels): add resolve command + defaults 2026-01-18 01:00:24 +00:00
Rodrigo Uroz b543339373 Update tagline.ts with a nice reference from an old movie 2026-01-18 00:59:43 +00:00
Peter Steinbergerandthewilloftheshadow 22c7f659f6 fix: surface match metadata in audits and slack logs
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
2026-01-18 00:50:36 +00:00
Peter Steinberger 79a44d0da4 refactor(channels): unify target parsing 2026-01-18 00:31:42 +00:00
Peter Steinberger d593a809f0 fix: apply openai batch defaults 2026-01-18 00:29:18 +00:00
Peter Steinberger 22add31e91 docs: update changelog for sessions_spawn thinking 2026-01-18 00:17:28 +00:00
Peter SteinbergerandNicholas Spisak b44d740720 refactor: centralize cli manager cleanup
Co-authored-by: Nicholas Spisak <jsnsdirect@gmail.com>
2026-01-18 00:16:01 +00:00
Peter Steinberger 4d590f9254 refactor(slack): centralize target parsing 2026-01-18 00:15:05 +00:00
Peter Steinbergerandthewilloftheshadow a5aa48beea feat: add dm allowlist match metadata logs
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
2026-01-18 00:14:44 +00:00
Peter Steinberger 1bf3861ca4 feat: add thinking override to sessions_spawn 2026-01-18 00:14:18 +00:00
joshrad-dev f8052be369 docs: add docs for Copilot device flow 2026-01-18 00:06:04 +00:00
Peter SteinbergerandJonathan Rhyne a08438ae97 refactor(discord): centralize target parsing
Co-authored-by: Jonathan Rhyne <jonathan@pspdfkit.com>
2026-01-18 00:04:38 +00:00
Peter Steinbergerandthewilloftheshadow fe00d6aacf feat: add matrix room match metadata logs
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
2026-01-18 00:00:00 +00:00
Peter Steinbergerandthewilloftheshadow 984692cda2 refactor: reuse channel config resolver in matrix extension
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
2026-01-17 23:53:05 +00:00
Peter Steinbergerandthewilloftheshadow 4c12c4fc04 feat: add channel match metadata logs
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
2026-01-17 23:48:45 +00:00
Peter SteinbergerandNicholas Spisak 794bab45ff fix: harden memory cli manager cleanup
Co-authored-by: Nicholas Spisak <jsnsdirect@gmail.com>
2026-01-17 23:45:42 +00:00
Peter Steinberger 16e5fa1db9 test: cover daemon install helpers 2026-01-17 23:41:45 +00:00
Peter Steinberger 125be3e111 fix: restore wizard/doctor imports 2026-01-17 23:41:45 +00:00
Peter Steinberger b60a53e10d feat: enable batch indexing by default 2026-01-17 23:29:40 +00:00
Peter Steinberger 9de762faa2 refactor: unify gateway daemon install plan 2026-01-17 23:29:34 +00:00
Peter SteinbergerandCodex 5aed38eebc fix(discord): honor thread allowlists in reactions
Co-authored-by: Codex <codex@openai.com>
2026-01-17 23:03:51 +00:00
Peter SteinbergerandCodex e63e483c38 refactor(channels): share channel config matching
Co-authored-by: Codex <codex@openai.com>
2026-01-17 23:03:51 +00:00
Shadow 277e43e32c Discord: inherit thread allowlists 2026-01-17 23:03:51 +00:00
Peter Steinberger 852aa16ca0 fix: stabilize memory sync progress 2026-01-17 23:02:03 +00:00
Peter Steinberger 82b7153ac1 fix: handle daemon install failure in wizard 2026-01-17 23:00:34 +00:00
Peter Steinberger 7d2e510087 fix: retry embedding 5xx errors 2026-01-17 22:48:50 +00:00
Peter Steinberger 9ca4c10e59 test: cover channels capabilities probes 2026-01-17 22:33:18 +00:00
Peter Steinberger a31a79396b feat: add OpenAI batch memory indexing 2026-01-17 22:32:04 +00:00
Peter Steinberger acc3eb11d0 Update bird skill with Twitter posting wisdom from Ruby
- CLI for reading only (Twitter flags CLI posts as automated)
- Browser tool with paste hack for writing
- React input workaround with ClipboardEvent
- Selectors and rate limiting tips
- Credit: Shadow's Ruby documented the forbidden arts
2026-01-17 22:28:23 +00:00
Peter Steinbergerandabdaraxus 9d9fff2991 fix: sessions list label fallback
Co-authored-by: abdaraxus <abdaraxus@users.noreply.github.com>
2026-01-17 22:22:01 +00:00
Peter Steinberger 030ed5d592 fix: skip empty memory chunks 2026-01-17 21:58:59 +00:00
Peter Steinberger f6d359932a fix: parallelize memory embedding indexing 2026-01-17 21:57:12 +00:00
Peter Steinberger 3200b51160 fix: format exec elevated flag first in tool summaries 2026-01-17 21:54:24 +00:00
Peter Steinberger 4b11ebb30e fix: split long memory lines 2026-01-17 21:11:56 +00:00
Peter Steinberger 40345642fa fix: show memory index counts in progress 2026-01-17 21:09:22 +00:00
Peter Steinberger e932772230 fix: report memory index progress 2026-01-17 20:42:04 +00:00
Peter SteinbergerandClaude Opus 4.5 63d466fe5e fix(telegram): expand text_link entities in inbound text
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 20:41:34 +00:00
Peter Steinberger c2fada7062 fix: suppress duplicate discord slow-listener logs 2026-01-17 20:37:36 +00:00
Peter Steinberger d9c29f5ce5 fix: add agent context to ws logs 2026-01-17 20:37:36 +00:00
Peter Steinberger f5d5ef6857 feat: confirm memory index completion 2026-01-17 20:35:15 +00:00
Peter Steinberger 361a17415f chore: release 2026.1.17-1 2026-01-17 20:26:24 +00:00
Peter Steinberger fb393c3c51 feat: add progress to memory status deep 2026-01-17 20:25:19 +00:00
Peter Steinberger e0158c5d5d feat: add deep memory status checks 2026-01-17 20:18:36 +00:00
Peter Steinberger be12b0771c fix: soften windows daemon install 2026-01-17 20:12:26 +00:00
Peter Steinberger 1309fc1f48 test: expand frontmatter coverage 2026-01-17 20:12:04 +00:00
Peter Steinberger 4fdecfb845 fix: split memory embedding batches 2026-01-17 20:10:11 +00:00
Peter Steinberger 31c6f178f3 fix: preserve inline frontmatter values 2026-01-17 19:56:10 +00:00
Peter Steinberger 1e2ab8bf1e fix: improve frontmatter parsing 2026-01-17 19:56:10 +00:00
Sebastian Slight 35a1d81518 fix: handle multi-line metadata blocks in HOOK.md frontmatter
The frontmatter parser was using a simple line-by-line regex that only
captured single-line key-value pairs. This meant multi-line metadata
blocks (as used by bundled hooks) were not parsed correctly.

Changes:
- Add extractMultiLineValue() to handle indented continuation lines
- Use JSON5 instead of JSON.parse() to support trailing commas
- Add comprehensive test coverage for frontmatter parsing

Fixes #1113
2026-01-17 19:56:10 +00:00
Peter Steinberger 1c4297d8b5 test: update memory cli mocks for vector probe 2026-01-17 19:49:41 +00:00
Peter Steinberger e3638a9a9e fix: probe memory vector availability 2026-01-17 19:46:34 +00:00
Peter Steinberger 1f8558771a Docs: note MiniMax usage endpoint 2026-01-17 19:45:54 +00:00
Peter Steinberger 2e231d09ec Infra: update MiniMax usage endpoint 2026-01-17 19:45:48 +00:00
Peter Steinberger 727c07bd88 feat: add slack user scopes and teams graph hints 2026-01-17 19:33:03 +00:00
Peter Steinberger c32ad19377 docs: restore changelog entries 2026-01-17 19:32:30 +00:00
Peter Steinberger ef40ab2933 test: expand memory cli coverage 2026-01-17 19:30:46 +00:00
Peter Steinberger e71fa4a145 docs: note session log disk access 2026-01-17 19:30:46 +00:00
Peter Steinberger a7c0887f94 feat: add per-provider scope probes to channels capabilities 2026-01-17 19:28:52 +00:00
Peter Steinberger 53218b91c6 fix: close memory cli managers 2026-01-17 19:20:55 +00:00
Peter Steinberger 2d4de656d2 test: avoid global SIGTERM emit in child-process-bridge 2026-01-17 19:20:48 +00:00
Peter Steinberger b0f44acf9e chore: bump versions to 2026.1.17 2026-01-17 19:16:35 +00:00
Peter Steinberger a828e60067 feat: add channels capabilities command 2026-01-17 19:06:07 +00:00
Peter Steinberger 96df70fccf fix: add nested agent log context 2026-01-17 18:59:59 +00:00
Peter Steinberger 0e49dca53c feat: add experimental session memory source 2026-01-17 18:53:52 +00:00
Peter SteinbergerandRhys Johnston 8ec4af4641 fix(status): show 2 usage windows in /status (#1101)
Thanks @rhjoh.

Co-authored-by: Rhys Johnston <rhys.johnston00@gmail.com>
2026-01-17 18:46:41 +00:00
Peter Steinberger 2f6d9417bd test(memory): await watch sync completion 2026-01-17 18:45:42 +00:00
Peter Steinberger 534a012a4e style: apply oxfmt 2026-01-17 18:32:23 +00:00
Peter Steinberger 7a3fa9ce03 feat: show update availability in status 2026-01-17 18:23:27 +00:00
Peter Steinberger 8a67d29748 fix: improve WSL2 systemd daemon hints 2026-01-17 18:19:55 +00:00
Peter Steinberger 408f4f2dac fix: reuse shared ansi stripper 2026-01-17 18:18:14 +00:00
Peter Steinberger 3df2dc0b15 fix: normalize exec tool alias naming 2026-01-17 18:15:45 +00:00
Peter Steinberger 5304a8c2d1 fix: add timestamped tool context to logs 2026-01-17 18:14:21 +00:00
Peter Steinberger 1569d29b2d fix: normalize telegram forwarded context (#1090) (thanks @sleontenko) 2026-01-17 18:08:23 +00:00
Peter SteinbergerandYuri Chukhlib 50c8e74230 fix(doctor): avoid ack reaction migration without config (#1087)
Thanks @YuriNachos.

Co-authored-by: Yuri Chukhlib <YuriNachos@users.noreply.github.com>
2026-01-17 18:07:06 +00:00
Peter Steinberger 1045b032a2 refactor(logging): use subsystem loggers for discord/ws 2026-01-17 18:03:40 +00:00
Peter SteinbergerandYuri Chukhlib a813343aa7 docs: clarify model refs and runtime notes
Co-authored-by: Yuri Chukhlib <YuriNachos@users.noreply.github.com>
2026-01-17 18:03:40 +00:00
Peter Steinberger 5a08471dcd feat: add sqlite-vec memory search acceleration 2026-01-17 18:02:34 +00:00
Peter Steinberger 252dfbcd40 fix: include context in elevated exec denial 2026-01-17 17:55:11 +00:00
Peter Steinberger 75588fe732 test: expand semver parsing coverage 2026-01-17 17:54:41 +00:00
Peter Steinberger 9bbdeb3d52 Merge pull request #1111 from artuskg/fix/cli-install-version-suffix
macos: keep CLI install build suffix
2026-01-17 17:46:13 +00:00
Peter Steinberger ec9ba5b784 fix: show full gateway version string in status (#1111) (thanks @artuskg) 2026-01-17 17:45:14 +00:00
Artus KG cee4149884 macos: handle empty install version safely 2026-01-17 17:45:14 +00:00
Artus KG 5599e4cf35 test: make session update timestamp UTC 2026-01-17 17:45:14 +00:00
Artus KG 84cdd2df73 changelog: note CLI install build suffix fix 2026-01-17 17:45:14 +00:00
Artus KG 7929f57460 macos: keep CLI install build suffix 2026-01-17 17:45:04 +00:00
Peter Steinberger 7876679c5d style: apply oxfmt 2026-01-17 17:44:54 +00:00
Peter Steinberger bc6928525d docs: note discord interaction logging fix 2026-01-17 17:42:56 +00:00
Peter Steinberger 755c847d9a fix: soften discord interaction logging 2026-01-17 17:42:46 +00:00
Peter Steinberger 80a8639940 refactor: centralize telegram send param parsing 2026-01-17 17:36:37 +00:00
Peter Steinberger 6cb5704291 Merge pull request #1085 from dan-dr/chore/kimi-code-provider
Add Kimi Code provider onboarding
2026-01-17 17:36:30 +00:00
Peter Steinberger 4a987c836d fix: add Kimi Code docs + defaults (#1085) (thanks @dan-dr) 2026-01-17 17:35:40 +00:00
Peter Steinberger a2fb55326c Merge pull request #1099 from mukhtharcm/feat/message-tool-voice-support
feat(telegram): support sending audio as native voice notes via asVoice param in message tool
2026-01-17 17:33:20 +00:00
Peter Steinberger af29c6a980 fix: allow media-only telegram voice sends (#1099) (thanks @mukhtharcm) 2026-01-17 17:33:08 +00:00
Muhammed Mukhthar CM f2a0e8e5bb feat(telegram): support sending audio as native voice notes via asVoice param in message tool 2026-01-17 17:32:50 +00:00
Peter Steinberger f6456c2883 Merge pull request #1106 from gumadeiras/patch-2
Remove extra 'logging' page in the docs
2026-01-17 17:32:15 +00:00
Peter Steinberger 39f0d000d1 Merge pull request #1088 from sibbl/fix-matrix
feat(matrix): fix sending bug, add specific support for voice messages and images
2026-01-17 17:27:44 +00:00
Peter Steinberger a8d9d630bc fix: handle legacy matrix polls (#1088) (thanks @sibbl) 2026-01-17 17:27:12 +00:00
ddyo e93a1d8138 feat: add kimi code provider onboarding 2026-01-17 17:25:07 +00:00
Peter Steinberger f6681be6f4 style: tidy macOS config UI formatting 2026-01-17 17:22:42 +00:00
Peter Steinberger c79ac3fe81 test: cover semver suffix variants 2026-01-17 17:15:08 +00:00
Sebastian Schubotz b78b06353a feat(matrix): add specific voice message + image sending extending generic attachment sending 2026-01-17 17:12:38 +00:00
Sebastian Schubotz c49b6cc241 fix(matrix): fix sending being broken by normalizing thread ID normalization in message sending functions; improve matrix types 2026-01-17 17:12:38 +00:00
Peter Steinberger 30c945fe92 fix: cover semver patch suffix parsing (#1110) (thanks @zerone0x) 2026-01-17 16:50:05 +00:00
Peter Steinberger dfd511c310 Merge pull request #1110 from zerone0x/fix/issue-1107-semver-prerelease-suffix
fix(macos): parse semver patch correctly when version has prerelease suffix
2026-01-17 16:45:49 +00:00
Peter Steinberger 1657525201 chore: prep 2026.1.17 and onboard flow 2026-01-17 16:41:25 +00:00
zerone0xandClaude 3e4b0d0505 fix(macos): parse semver patch correctly when version has prerelease suffix
Strip prerelease (`-beta.1`) and build (`-4`) suffixes from the patch
component before parsing as integer. Previously `2026.1.11-4` parsed to
`patch: 0` because `Int("11-4")` returns nil; now correctly yields
`patch: 11`.

Fixes #1107

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-18 00:31:20 +08:00
Michael Behr 003c6c9ae1 add patches/.gitkeep (#1104) 2026-01-17 08:50:50 -06:00
Gustavo Madeira Santana 41fbb4d8b0 Remove extra 'logging' page in the docs
Removed 'logging' from the list of gateways.
2026-01-17 09:49:39 -05:00
Peter Steinberger eecb340f64 style: format update-cli 2026-01-17 12:51:08 +00:00
Peter Steinberger d029eaa0bb docs: tighten release preflight 2026-01-17 12:47:54 +00:00
Peter Steinberger 9c7dcc1ed7 chore: update appcast for 2026.1.16-2 2026-01-17 12:46:42 +00:00
286 changed files with 13279 additions and 1436 deletions

No files matched your search

+1
View File
@@ -73,6 +73,7 @@
- Pi sessions live under `~/.clawdbot/sessions/` by default; the base directory is not configurable.
- Environment variables: see `~/.profile`.
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
- Release flow: always read `docs/reference/RELEASING.md` and `docs/platforms/mac/release.md` before any release work; do not ask routine questions once those docs answer them.
## Troubleshooting
- Rebrand/migration issues or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).
+57
View File
@@ -2,10 +2,61 @@
Docs: https://docs.clawd.bot
## 2026.1.18-1
### Changes
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
### Fixes
- Memory: apply OpenAI batch defaults even without explicit remote config.
## 2026.1.17-3
### Changes
- Memory: add OpenAI Batch API indexing for embeddings when configured.
- Memory: enable OpenAI batch indexing by default for OpenAI embeddings.
### Fixes
- Memory: retry transient 5xx errors (Cloudflare) during embedding indexing.
## 2026.1.17-2
### Changes
### Fixes
- Tools: show exec elevated flag before the command and keep it outside markdown in tool summaries.
- Memory: parallelize embedding indexing with rate-limit retries.
- Memory: split overly long lines to keep embeddings under token limits.
- Memory: skip empty chunks to avoid invalid embedding inputs.
- Sessions: fall back to session labels when listing display names. (#1124) — thanks @abdaraxus.
- Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123) — thanks @thewilloftheshadow.
## 2026.1.17-1
### Changes
- Telegram: enrich forwarded message context with normalized origin details + legacy fallback. (#1090) — thanks @sleontenko.
- macOS: strip prerelease/build suffixes when parsing gateway semver patches. (#1110) — thanks @zerone0x.
- macOS: keep CLI install pinned to the full build suffix. (#1111) — thanks @artuskg.
- CLI: surface update availability in `clawdbot status`.
- CLI: add `clawdbot memory status --deep/--index` probes.
- CLI: add playful update completion quips.
### Fixes
- Doctor: avoid re-adding WhatsApp ack reaction config when only legacy auth files exist. (#1087) — thanks @YuriNachos.
- Hooks: parse multi-line/YAML frontmatter metadata blocks (JSON5-friendly). (#1114) — thanks @sebslight.
- CLI: add WSL2/systemd unavailable hints in daemon status/doctor output.
- Windows: install gateway scheduled task as the current user; show friendly guidance instead of failing on access denied.
- Status: show both usage windows with reset hints when usage data is available. (#1101) — thanks @rhjoh.
- Memory: probe sqlite-vec availability in `clawdbot memory status`.
- Memory: split embedding batches to avoid OpenAI token limits during indexing.
- Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118) — thanks @sleontenko.
## 2026.1.16-2
### Changes
- CLI: stamp build commit into dist metadata so banners show the commit in npm installs.
- CLI: close memory manager after memory commands to avoid hanging processes. (#1127) — thanks @NicholasSpisak.
## 2026.1.16-1
@@ -43,6 +94,8 @@ Docs: https://docs.clawd.bot
- Status: trim `/status` to current-provider usage only and drop the OAuth/token block.
- Directory: unify `clawdbot directory` across channels and plugin channels.
- UI: allow deleting sessions from the Control UI.
- Memory: add sqlite-vec vector acceleration with CLI status details.
- Memory: add experimental session transcript indexing for memory_search (opt-in via memorySearch.experimental.sessionMemory + sources).
- Skills: add user-invocable skill commands and expanded skill command registration.
- Telegram: default reaction level to minimal and enable reaction notifications by default.
- Telegram: allow reply-chain messages to bypass mention gating in groups. (#1038) — thanks @adityashaw2.
@@ -61,6 +114,10 @@ Docs: https://docs.clawd.bot
### Fixes
- macOS: drain subprocess pipes before waiting to avoid deadlocks. (#1081) — thanks @thesash.
- Verbose: wrap tool summaries/output in markdown only for markdown-capable channels.
- Tools: include provider/session context in elevated exec denial errors.
- Tools: normalize exec tool alias naming in tool error logs.
- Logging: reuse shared ANSI stripping to keep console capture lint-clean.
- Logging: prefix nested agent output with session/run/channel context.
- Telegram: accept tg/group/telegram prefixes + topic targets for inline button validation. (#1072) — thanks @danielz1z.
- Telegram: split long captions into follow-up messages.
- Config: block startup on invalid config, preserve best-effort doctor config, and keep rolling config backups. (#1083) — thanks @mukhtharcm.
+17 -16
View File
@@ -478,20 +478,21 @@ Thanks to all clawtributors:
<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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></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/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/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/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/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=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/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=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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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=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/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=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/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/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/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/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>
+16 -16
View File
@@ -2,6 +2,22 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>Clawdbot</title>
<item>
<title>2026.1.16-2</title>
<pubDate>Sat, 17 Jan 2026 12:46:22 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>6273</sparkle:version>
<sparkle:shortVersionString>2026.1.16-2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.16-2</h2>
<h3>Changes</h3>
<ul>
<li>CLI: stamp build commit into dist metadata so banners show the commit in npm installs.</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.16-2/Clawdbot-2026.1.16-2.zip" length="21399591" type="application/octet-stream" sparkle:edSignature="zelT+KzN32cXsihbFniPF5Heq0hkwFfL3Agrh/AaoKUkr7kJAFarkGSOZRTWZ9y+DvOluzn2wHHjVigRjMzrBA=="/>
</item>
<item>
<title>2026.1.15</title>
<pubDate>Fri, 16 Jan 2026 10:31:53 +0000</pubDate>
@@ -255,21 +271,5 @@
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.14-1/Clawdbot-2026.1.14-1.zip" length="19887144" type="application/octet-stream" sparkle:edSignature="1irKxBLt2eRtns34m/8JsjL/ZzhZQNjahwrxtArTvzaCnidS/MEnpD4nV2SHnhuo8g+fJZQpV9NoCAoEOAinCw=="/>
</item>
<item>
<title>2026.1.12-2</title>
<pubDate>Tue, 13 Jan 2026 10:05:25 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5534</sparkle:version>
<sparkle:shortVersionString>2026.1.12-2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.12-2</h2>
<h3>Fixes</h3>
<ul>
<li>Packaging: include <code>dist/memory/**</code> in the npm tarball (fixes <code>ERR_MODULE_NOT_FOUND</code> for <code>dist/memory/index.js</code>).</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.12-2/Clawdbot-2026.1.12-2.zip" length="19854203" type="application/octet-stream" sparkle:edSignature="CVpUofNS+pl6Smk/K0Q8q35saRuuFx90s4sePABORFvGcAF1biajC8zpiImKuXpqD0ENb+VTwDJ1ul1Oxh3wDA=="/>
</item>
</channel>
</rss>
@@ -35,7 +35,7 @@ enum CLIInstaller {
}
static func install(statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async {
let expected = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest"
let expected = GatewayEnvironment.expectedGatewayVersionString() ?? "latest"
let prefix = Self.installPrefix()
await statusHandler("Installing clawdbot CLI…")
let cmd = self.installScriptCommand(version: expected, prefix: prefix)
@@ -6,11 +6,11 @@ struct ConfigSchemaForm: View {
let path: ConfigPath
var body: some View {
self.renderNode(schema, path: path)
self.renderNode(self.schema, path: self.path)
}
private func renderNode(_ schema: ConfigSchemaNode, path: ConfigPath) -> AnyView {
let storedValue = store.configValue(at: path)
let storedValue = self.store.configValue(at: path)
let value = storedValue ?? schema.explicitDefault
let label = hintForPath(path, hints: store.configUiHints)?.label ?? schema.title
let help = hintForPath(path, hints: store.configUiHints)?.help ?? schema.description
@@ -21,7 +21,7 @@ struct ConfigSchemaForm: View {
if nonNull.count == 1, let only = nonNull.first {
return self.renderNode(only, path: path)
}
let literals = nonNull.compactMap { $0.literalValue }
let literals = nonNull.compactMap(\.literalValue)
if !literals.isEmpty, literals.count == nonNull.count {
return AnyView(
VStack(alignment: .leading, spacing: 6) {
@@ -31,15 +31,20 @@ struct ConfigSchemaForm: View {
.font(.caption)
.foregroundStyle(.secondary)
}
Picker("", selection: self.enumBinding(path, options: literals, defaultValue: schema.explicitDefault)) {
Picker(
"",
selection: self.enumBinding(
path,
options: literals,
defaultValue: schema.explicitDefault))
{
Text("Select…").tag(-1)
ForEach(literals.indices, id: \ .self) { index in
Text(String(describing: literals[index])).tag(index)
}
}
.pickerStyle(.menu)
}
)
})
}
}
@@ -71,8 +76,7 @@ struct ConfigSchemaForm: View {
if schema.allowsAdditionalProperties {
self.renderAdditionalProperties(schema, path: path, value: value)
}
}
)
})
case "array":
return AnyView(self.renderArray(schema, path: path, value: value, label: label, help: help))
case "boolean":
@@ -80,8 +84,7 @@ struct ConfigSchemaForm: View {
Toggle(isOn: self.boolBinding(path, defaultValue: schema.explicitDefault as? Bool)) {
if let label { Text(label) } else { Text("Enabled") }
}
.help(help ?? "")
)
.help(help ?? ""))
case "number", "integer":
return AnyView(self.renderNumberField(schema, path: path, label: label, help: help))
case "string":
@@ -93,8 +96,7 @@ struct ConfigSchemaForm: View {
Text("Unsupported field type.")
.font(.caption)
.foregroundStyle(.secondary)
}
)
})
}
}
@@ -155,9 +157,7 @@ struct ConfigSchemaForm: View {
text: self.numberBinding(
path,
isInteger: schema.schemaType == "integer",
defaultValue: defaultValue
)
)
defaultValue: defaultValue))
.textFieldStyle(.roundedBorder)
}
}
@@ -189,7 +189,7 @@ struct ConfigSchemaForm: View {
Button("Remove") {
var next = items
next.remove(at: index)
store.updateConfigValue(path: path, value: next)
self.store.updateConfigValue(path: path, value: next)
}
.buttonStyle(.bordered)
.controlSize(.small)
@@ -202,7 +202,7 @@ struct ConfigSchemaForm: View {
} else {
next.append("")
}
store.updateConfigValue(path: path, value: next)
self.store.updateConfigValue(path: path, value: next)
}
.buttonStyle(.bordered)
.controlSize(.small)
@@ -238,7 +238,7 @@ struct ConfigSchemaForm: View {
Button("Remove") {
var next = dict
next.removeValue(forKey: key)
store.updateConfigValue(path: path, value: next)
self.store.updateConfigValue(path: path, value: next)
}
.buttonStyle(.bordered)
.controlSize(.small)
@@ -254,7 +254,7 @@ struct ConfigSchemaForm: View {
key = "new-\(index)"
}
next[key] = additionalSchema.defaultValue
store.updateConfigValue(path: path, value: next)
self.store.updateConfigValue(path: path, value: next)
}
.buttonStyle(.bordered)
.controlSize(.small)
@@ -270,9 +270,8 @@ struct ConfigSchemaForm: View {
},
set: { newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
store.updateConfigValue(path: path, value: trimmed.isEmpty ? nil : trimmed)
}
)
self.store.updateConfigValue(path: path, value: trimmed.isEmpty ? nil : trimmed)
})
}
private func boolBinding(_ path: ConfigPath, defaultValue: Bool?) -> Binding<Bool> {
@@ -282,16 +281,15 @@ struct ConfigSchemaForm: View {
return defaultValue ?? false
},
set: { newValue in
store.updateConfigValue(path: path, value: newValue)
}
)
self.store.updateConfigValue(path: path, value: newValue)
})
}
private func numberBinding(
_ path: ConfigPath,
isInteger: Bool,
defaultValue: Double?
) -> Binding<String> {
defaultValue: Double?) -> Binding<String>
{
Binding(
get: {
if let value = store.configValue(at: path) { return String(describing: value) }
@@ -301,22 +299,21 @@ struct ConfigSchemaForm: View {
set: { newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
store.updateConfigValue(path: path, value: nil)
self.store.updateConfigValue(path: path, value: nil)
} else if let value = Double(trimmed) {
store.updateConfigValue(path: path, value: isInteger ? Int(value) : value)
self.store.updateConfigValue(path: path, value: isInteger ? Int(value) : value)
}
}
)
})
}
private func enumBinding(
_ path: ConfigPath,
options: [Any],
defaultValue: Any?
) -> Binding<Int> {
defaultValue: Any?) -> Binding<Int>
{
Binding(
get: {
let value = store.configValue(at: path) ?? defaultValue
let value = self.store.configValue(at: path) ?? defaultValue
guard let value else { return -1 }
return options.firstIndex { option in
String(describing: option) == String(describing: value)
@@ -324,12 +321,11 @@ struct ConfigSchemaForm: View {
},
set: { index in
guard index >= 0, index < options.count else {
store.updateConfigValue(path: path, value: nil)
self.store.updateConfigValue(path: path, value: nil)
return
}
store.updateConfigValue(path: path, value: options[index])
}
)
self.store.updateConfigValue(path: path, value: options[index])
})
}
private func mapKeyBinding(path: ConfigPath, key: String) -> Binding<String> {
@@ -339,14 +335,13 @@ struct ConfigSchemaForm: View {
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
guard trimmed != key else { return }
let current = store.configValue(at: path) as? [String: Any] ?? [:]
let current = self.store.configValue(at: path) as? [String: Any] ?? [:]
guard current[trimmed] == nil else { return }
var next = current
next[trimmed] = current[key]
next.removeValue(forKey: key)
store.updateConfigValue(path: path, value: next)
}
)
self.store.updateConfigValue(path: path, value: next)
})
}
}
@@ -355,10 +350,10 @@ struct ChannelConfigForm: View {
let channelId: String
var body: some View {
if store.configSchemaLoading {
if self.store.configSchemaLoading {
ProgressView().controlSize(.small)
} else if let schema = store.channelConfigSchema(for: channelId) {
ConfigSchemaForm(store: store, schema: schema, path: [.key("channels"), .key(channelId)])
ConfigSchemaForm(store: self.store, schema: schema, path: [.key("channels"), .key(self.channelId)])
} else {
Text("Schema unavailable for this channel.")
.font(.caption)
@@ -434,25 +434,25 @@ extension ChannelsSettings {
private func resolveChannelDetailTitle(_ id: String) -> String {
switch id {
case "whatsapp": return "WhatsApp Web"
case "telegram": return "Telegram Bot"
case "discord": return "Discord Bot"
case "slack": return "Slack Bot"
case "signal": return "Signal REST"
case "imessage": return "iMessage"
default: return self.resolveChannelTitle(id)
case "whatsapp": "WhatsApp Web"
case "telegram": "Telegram Bot"
case "discord": "Discord Bot"
case "slack": "Slack Bot"
case "signal": "Signal REST"
case "imessage": "iMessage"
default: self.resolveChannelTitle(id)
}
}
private func resolveChannelSystemImage(_ id: String) -> String {
switch id {
case "whatsapp": return "message"
case "telegram": return "paperplane"
case "discord": return "bubble.left.and.bubble.right"
case "slack": return "number"
case "signal": return "antenna.radiowaves.left.and.right"
case "imessage": return "message.fill"
default: return "message"
case "whatsapp": "message"
case "telegram": "paperplane"
case "discord": "bubble.left.and.bubble.right"
case "slack": "number"
case "signal": "antenna.radiowaves.left.and.right"
case "imessage": "message.fill"
default: "message"
}
}
@@ -57,7 +57,7 @@ extension ChannelsStore {
return value
}
guard path.count >= 2 else { return nil }
if case .key("channels") = path[0], case .key(_) = path[1] {
if case .key("channels") = path[0], case .key = path[1] {
let fallbackPath = Array(path.dropFirst())
return valueAtPath(self.configDraft, path: fallbackPath)
}
@@ -93,10 +93,10 @@ private func valueAtPath(_ root: Any, path: ConfigPath) -> Any? {
var current: Any? = root
for segment in path {
switch segment {
case .key(let key):
case let .key(key):
guard let dict = current as? [String: Any] else { return nil }
current = dict[key]
case .index(let index):
case let .index(index):
guard let array = current as? [Any], array.indices.contains(index) else { return nil }
current = array[index]
}
@@ -107,7 +107,7 @@ private func valueAtPath(_ root: Any, path: ConfigPath) -> Any? {
private func setValue(_ root: inout Any, path: ConfigPath, value: Any?) {
guard let segment = path.first else { return }
switch segment {
case .key(let key):
case let .key(key):
var dict = root as? [String: Any] ?? [:]
if path.count == 1 {
if let value {
@@ -122,7 +122,7 @@ private func setValue(_ root: inout Any, path: ConfigPath, value: Any?) {
setValue(&child, path: Array(path.dropFirst()), value: value)
dict[key] = child
root = dict
case .index(let index):
case let .index(index):
var array = root as? [Any] ?? []
if index >= array.count {
array.append(contentsOf: repeatElement(NSNull() as Any, count: index - array.count + 1))
@@ -133,7 +133,7 @@ struct ConfigSchemaNode {
for segment in path {
guard let node = current else { return nil }
switch segment {
case .key(let key):
case let .key(key):
if node.schemaType == "object" {
if let next = node.properties[key] {
current = next
@@ -174,7 +174,7 @@ func hintForPath(_ path: ConfigPath, hints: [String: ConfigUiHint]) -> ConfigUiH
var match = true
for (index, seg) in segments.enumerated() {
let hintSegment = hintSegments[index]
if hintSegment != "*" && hintSegment != seg {
if hintSegment != "*", hintSegment != seg {
match = false
break
}
@@ -196,7 +196,7 @@ func isSensitivePath(_ path: ConfigPath) -> Bool {
func pathKey(_ path: ConfigPath) -> String {
path.compactMap { segment -> String? in
switch segment {
case .key(let key): return key
case let .key(key): return key
case .index: return nil
}
}
@@ -47,7 +47,7 @@ extension ConfigSettings {
.foregroundStyle(.secondary)
}
}
if self.store.configDirty && !self.isNixMode {
if self.store.configDirty, !self.isNixMode {
Text("Unsaved changes")
.font(.caption)
.foregroundStyle(.secondary)
@@ -25,8 +25,10 @@ struct Semver: Comparable, CustomStringConvertible, Sendable {
let major = Int(parts[0]),
let minor = Int(parts[1])
else { return nil }
let patch = Int(parts[2]) ?? 0
return Semver(major: major, minor: minor, patch: patch)
// Strip prerelease suffix (e.g., "11-4" → "11", "5-beta.1" → "5")
let patchRaw = String(parts[2])
let patchNumeric = patchRaw.split { $0 == "-" || $0 == "+" }.first.flatMap { Int($0) } ?? 0
return Semver(major: major, minor: minor, patch: patchNumeric)
}
func compatible(with required: Semver) -> Bool {
@@ -78,8 +80,13 @@ enum GatewayEnvironment {
}
static func expectedGatewayVersion() -> Semver? {
Semver.parse(self.expectedGatewayVersionString())
}
static func expectedGatewayVersionString() -> String? {
let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
return Semver.parse(bundleVersion)
let trimmed = bundleVersion?.trimmingCharacters(in: .whitespacesAndNewlines)
return (trimmed?.isEmpty == false) ? trimmed : nil
}
// Exposed for tests so we can inject fake version checks without rewriting bundle metadata.
@@ -98,6 +105,7 @@ enum GatewayEnvironment {
}
}
let expected = self.expectedGatewayVersion()
let expectedString = self.expectedGatewayVersionString()
let projectRoot = CommandResolver.projectRoot()
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
@@ -108,8 +116,8 @@ enum GatewayEnvironment {
kind: .missingNode,
nodeVersion: nil,
gatewayVersion: nil,
requiredGateway: expected?.description,
message: RuntimeLocator.describeFailure(err))
requiredGateway: expectedString,
message: RuntimeLocator.describeFailure(err))
case let .success(runtime):
let gatewayBin = CommandResolver.clawdbotExecutable()
@@ -118,7 +126,7 @@ enum GatewayEnvironment {
kind: .missingGateway,
nodeVersion: runtime.version.description,
gatewayVersion: nil,
requiredGateway: expected?.description,
requiredGateway: expectedString,
message: "clawdbot CLI not found in PATH; install the CLI.")
}
@@ -126,13 +134,14 @@ enum GatewayEnvironment {
?? self.readLocalGatewayVersion(projectRoot: projectRoot)
if let expected, let installed, !installed.compatible(with: expected) {
let expectedText = expectedString ?? expected.description
return GatewayEnvironmentStatus(
kind: .incompatible(found: installed.description, required: expected.description),
kind: .incompatible(found: installed.description, required: expectedText),
nodeVersion: runtime.version.description,
gatewayVersion: installed.description,
requiredGateway: expected.description,
requiredGateway: expectedText,
message: """
Gateway version \(installed.description) is incompatible with app \(expected.description);
Gateway version \(installed.description) is incompatible with app \(expectedText);
install or update the global package.
""")
}
@@ -150,7 +159,7 @@ enum GatewayEnvironment {
kind: .ok,
nodeVersion: runtime.version.description,
gatewayVersion: gatewayVersionText,
requiredGateway: expected?.description,
requiredGateway: expectedString,
message: "Node \(runtime.version.description); gateway \(gatewayVersionText) \(gatewayLabelText)")
}
}
@@ -218,8 +227,18 @@ enum GatewayEnvironment {
}
static func installGlobal(version: Semver?, statusHandler: @escaping @Sendable (String) -> Void) async {
await self.installGlobal(versionString: version?.description, statusHandler: statusHandler)
}
static func installGlobal(versionString: String?, statusHandler: @escaping @Sendable (String) -> Void) async {
let preferred = CommandResolver.preferredPaths().joined(separator: ":")
let target = version?.description ?? "latest"
let trimmed = versionString?.trimmingCharacters(in: .whitespacesAndNewlines)
let target: String
if let trimmed, !trimmed.isEmpty {
target = trimmed
} else {
target = "latest"
}
let npm = CommandResolver.findExecutable(named: "npm")
let pnpm = CommandResolver.findExecutable(named: "pnpm")
let bun = CommandResolver.findExecutable(named: "bun")
+1 -1
View File
@@ -155,7 +155,7 @@ struct OnboardingView: View {
var canAdvance: Bool { !self.isWizardBlocking }
var devLinkCommand: String {
let version = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest"
let version = GatewayEnvironment.expectedGatewayVersionString() ?? "latest"
return "npm install -g clawdbot@\(version)"
}
@@ -5,16 +5,28 @@ import Testing
@Suite struct GatewayEnvironmentTests {
@Test func semverParsesCommonForms() {
#expect(Semver.parse("1.2.3") == Semver(major: 1, minor: 2, patch: 3))
#expect(Semver.parse(" v1.2.3 \n") == Semver(major: 1, minor: 2, patch: 3))
#expect(Semver.parse("v2.0.0") == Semver(major: 2, minor: 0, patch: 0))
#expect(Semver.parse("3.4.5-beta.1") == Semver(major: 3, minor: 4, patch: 0)) // patch drops trailing text
#expect(Semver.parse("3.4.5-beta.1") == Semver(major: 3, minor: 4, patch: 5)) // prerelease suffix stripped
#expect(Semver.parse("2026.1.11-4") == Semver(major: 2026, minor: 1, patch: 11)) // build suffix stripped
#expect(Semver.parse("1.0.5+build.123") == Semver(major: 1, minor: 0, patch: 5)) // metadata suffix stripped
#expect(Semver.parse("v1.2.3+build.9") == Semver(major: 1, minor: 2, patch: 3))
#expect(Semver.parse("1.2.3+build.123") == Semver(major: 1, minor: 2, patch: 3))
#expect(Semver.parse("1.2.3-rc.1+build.7") == Semver(major: 1, minor: 2, patch: 3))
#expect(Semver.parse("v1.2.3-rc.1") == Semver(major: 1, minor: 2, patch: 3))
#expect(Semver.parse("1.2.0") == Semver(major: 1, minor: 2, patch: 0))
#expect(Semver.parse(nil) == nil)
#expect(Semver.parse("invalid") == nil)
#expect(Semver.parse("1.2") == nil)
#expect(Semver.parse("1.2.x") == nil)
}
@Test func semverCompatibilityRequiresSameMajorAndNotOlder() {
let required = Semver(major: 2, minor: 1, patch: 0)
#expect(Semver(major: 2, minor: 1, patch: 0).compatible(with: required))
#expect(Semver(major: 2, minor: 2, patch: 0).compatible(with: required))
#expect(Semver(major: 2, minor: 1, patch: 1).compatible(with: required))
#expect(Semver(major: 2, minor: 0, patch: 9).compatible(with: required) == false)
#expect(Semver(major: 3, minor: 0, patch: 0).compatible(with: required) == false)
#expect(Semver(major: 1, minor: 9, patch: 9).compatible(with: required) == false)
}
@@ -36,6 +48,7 @@ import Testing
@Test func expectedGatewayVersionFromStringUsesParser() {
#expect(GatewayEnvironment.expectedGatewayVersion(from: "v9.1.2") == Semver(major: 9, minor: 1, patch: 2))
#expect(GatewayEnvironment.expectedGatewayVersion(from: "2026.1.11-4") == Semver(major: 2026, minor: 1, patch: 11))
#expect(GatewayEnvironment.expectedGatewayVersion(from: nil) == nil)
}
}
+11 -3
View File
@@ -58,7 +58,7 @@ Minimal config:
- The `discord` tool is only exposed when the current channel is Discord.
13. Native commands use isolated session keys (`agent:<agentId>:discord:slash:<userId>`) rather than the shared `main` session.
Note: Discord does not provide a simple username → id lookup without extra guild context, so prefer ids or `<@id>` mentions for DM delivery targets.
Note: Name → id resolution uses guild member search and requires Server Members Intent; if the bot can’t search members, use ids or `<@id>` mentions.
Note: Slugs are lowercase with spaces replaced by `-`. Channel names are slugged without the leading `#`.
Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-ready replies easy.
@@ -175,6 +175,7 @@ Notes:
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions for guild messages.
- Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`.
- If `channels` is present, any channel not listed is denied by default.
- Threads inherit parent channel config (allowlist, `requireMention`, skills, prompts, etc.) unless you add the thread id explicitly.
- Bot-authored messages are ignored by default; set `channels.discord.allowBots=true` to allow them (own messages remain filtered).
- Warning: If you allow replies to other bots (`channels.discord.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.discord.guilds.*.channels.<id>.users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`.
@@ -192,8 +193,11 @@ Notes:
- Your config requires mentions and you didn’t mention it, or
- Your guild/channel allowlist denies the channel/user.
- **`requireMention: false` but still no replies**:
- `channels.discord.groupPolicy` defaults to **allowlist**; set it to `"open"` or add a guild entry under `channels.discord.guilds` (optionally list channels under `channels.discord.guilds.<id>.channels` to restrict).
- `requireMention` must live under `channels.discord.guilds` (or a specific channel). `channels.discord.requireMention` at the top level is ignored.
- `channels.discord.groupPolicy` defaults to **allowlist**; set it to `"open"` or add a guild entry under `channels.discord.guilds` (optionally list channels under `channels.discord.guilds.<id>.channels` to restrict).
- If you only set `DISCORD_BOT_TOKEN` and never create a `channels.discord` section, the runtime
defaults `groupPolicy` to `open`. Add `channels.discord.groupPolicy`,
`channels.defaults.groupPolicy`, or a guild/channel allowlist to lock it down.
- `requireMention` must live under `channels.discord.guilds` (or a specific channel). `channels.discord.requireMention` at the top level is ignored.
- **Permission audits** (`channels status --probe`) only check numeric channel IDs. If you use slugs/names as `channels.discord.guilds.*.channels` keys, the audit can’t verify permissions.
- **DMs don’t work**: `channels.discord.dm.enabled=false`, `channels.discord.dm.policy="disabled"`, or you haven’t been approved yet (`channels.discord.dm.policy="pairing"`).
@@ -361,6 +365,10 @@ Allowlist matching notes:
- Use `*` to allow any sender/channel.
- When `guilds.<id>.channels` is present, channels not listed are denied by default.
- When `guilds.<id>.channels` is omitted, all channels in the allowlisted guild are allowed.
- To allow **no channels**, set `channels.discord.groupPolicy: "disabled"` (or keep an empty allowlist).
- The configure wizard accepts `Guild/Channel` names (public + private) and resolves them to IDs when possible.
- On startup, Clawdbot resolves channel/user names in allowlists to IDs (when the bot can search members)
and logs the mapping; unresolved entries are kept as typed.
Native command notes:
- The registered commands mirror Clawdbot’s chat commands.
+5 -1
View File
@@ -70,9 +70,10 @@ Matrix is an open messaging protocol. Clawdbot connects as a Matrix user and lis
- `clawdbot pairing list matrix`
- `clawdbot pairing approve matrix <CODE>`
- Public DMs: `channels.matrix.dm.policy="open"` plus `channels.matrix.dm.allowFrom=["*"]`.
- `channels.matrix.dm.allowFrom` accepts user IDs or display names (resolved at startup when directory search is available).
## Rooms (groups)
- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated).
- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). Use `channels.defaults.groupPolicy` to override the default when unset.
- Allowlist rooms with `channels.matrix.rooms`:
```json5
{
@@ -86,6 +87,9 @@ Matrix is an open messaging protocol. Clawdbot connects as a Matrix user and lis
}
```
- `requireMention: false` enables auto-reply in that room.
- The configure wizard prompts for room allowlists (room IDs, aliases, or names) and resolves names when possible.
- On startup, Clawdbot resolves room/user names in allowlists to IDs and logs the mapping; unresolved entries are kept as typed.
- To allow **no rooms**, set `channels.matrix.groupPolicy: "disabled"` (or keep an empty allowlist).
## Threads
- Reply threading is supported.
+29 -2
View File
@@ -76,12 +76,13 @@ Disable with:
**DM access**
- Default: `channels.msteams.dmPolicy = "pairing"`. Unknown senders are ignored until approved.
- `channels.msteams.allowFrom` accepts AAD object IDs or UPNs.
- `channels.msteams.allowFrom` accepts AAD object IDs, UPNs, or display names (resolved at startup when Graph allows).
**Group access**
- Default: `channels.msteams.groupPolicy = "allowlist"` (blocked unless you add `groupAllowFrom`).
- Default: `channels.msteams.groupPolicy = "allowlist"` (blocked unless you add `groupAllowFrom`). Use `channels.defaults.groupPolicy` to override the default when unset.
- `channels.msteams.groupAllowFrom` controls which senders can trigger in group chats/channels (falls back to `channels.msteams.allowFrom`).
- Set `groupPolicy: "open"` to allow any member (still mention‑gated by default).
- To allow **no channels**, set `channels.msteams.groupPolicy: "disabled"`.
Example:
```json5
@@ -95,6 +96,32 @@ Example:
}
```
**Teams + channel allowlist**
- Scope group/channel replies by listing teams and channels under `channels.msteams.teams`.
- Keys can be team IDs or names; channel keys can be conversation IDs or names.
- When `groupPolicy="allowlist"` and a teams allowlist is present, only listed teams/channels are accepted (mention‑gated).
- The configure wizard accepts `Team/Channel` entries and stores them for you.
- On startup, Clawdbot resolves team/channel and user allowlist names to IDs (when Graph permissions allow)
and logs the mapping; unresolved entries are kept as typed.
Example:
```json5
{
channels: {
msteams: {
groupPolicy: "allowlist",
teams: {
"My Team": {
channels: {
"General": { requireMention: true }
}
}
}
}
}
}
```
## How it works
1. Install the Microsoft Teams plugin.
2. Create an **Azure Bot** (App ID + secret + tenant ID).
+10
View File
@@ -335,6 +335,7 @@ For fine-grained control, use these tags in agent responses:
- DMs share the `main` session (like WhatsApp/Telegram).
- Channels map to `agent:<agentId>:slack:channel:<channelId>` sessions.
- Slash commands use `agent:<agentId>:slack:slash:<userId>` sessions (prefix configurable via `channels.slack.slashCommand.sessionPrefix`).
- If Slack doesn’t provide `channel_type`, Clawdbot infers it from the channel ID prefix (`D`, `C`, `G`) and defaults to `channel` to keep session keys stable.
- Native command registration uses `commands.native` (global default `"auto"` → Slack off) and can be overridden per-workspace with `channels.slack.commands.native`. Text commands require standalone `/...` messages and can be disabled with `commands.text: false`. Slack slash commands are managed in the Slack app and are not removed automatically. Use `commands.useAccessGroups: false` to bypass access-group checks for commands.
- Full command list + config: [Slash commands](/tools/slash-commands)
@@ -342,10 +343,19 @@ For fine-grained control, use these tags in agent responses:
- Default: `channels.slack.dm.policy="pairing"` — unknown DM senders get a pairing code (expires after 1 hour).
- Approve via: `clawdbot pairing approve slack <code>`.
- To allow anyone: set `channels.slack.dm.policy="open"` and `channels.slack.dm.allowFrom=["*"]`.
- `channels.slack.dm.allowFrom` accepts user IDs, @handles, or emails (resolved at startup when tokens allow).
## Group policy
- `channels.slack.groupPolicy` controls channel handling (`open|disabled|allowlist`).
- `allowlist` requires channels to be listed in `channels.slack.channels`.
- If you only set `SLACK_BOT_TOKEN`/`SLACK_APP_TOKEN` and never create a `channels.slack` section,
the runtime defaults `groupPolicy` to `open`. Add `channels.slack.groupPolicy`,
`channels.defaults.groupPolicy`, or a channel allowlist to lock it down.
- The configure wizard accepts `#channel` names and resolves them to IDs when possible
(public + private); if multiple matches exist, it prefers the active channel.
- On startup, Clawdbot resolves channel/user names in allowlists to IDs (when tokens allow)
and logs the mapping; unresolved entries are kept as typed.
- To allow **no channels**, set `channels.slack.groupPolicy: "disabled"` (or keep an empty allowlist).
Channel options (`channels.slack.channels.<id>` or `channels.slack.channels.<name>`):
- `allow`: allow/deny the channel when `groupPolicy="allowlist"`.
+14
View File
@@ -216,6 +216,7 @@ Telegram forum topics include a `message_thread_id` per message. Clawdbot:
- General topic (thread id `1`) is special: message sends omit `message_thread_id` (Telegram rejects it), but typing indicators still include it.
- Exposes `MessageThreadId` + `IsForum` in template context for routing/templating.
- Topic-specific configuration is available under `channels.telegram.groups.<chatId>.topics.<threadId>` (skills, allowlists, auto-reply, system prompts, disable).
- Topic configs inherit group settings (requireMention, allowlists, skills, prompts, enabled) unless overridden per topic.
Private chats can include `message_thread_id` in some edge cases. Clawdbot keeps the DM session key unchanged, but still uses the thread id for replies/draft streaming when it is present.
@@ -360,6 +361,19 @@ To force a voice note bubble in agent replies, include this tag anywhere in the
The tag is stripped from the delivered text. Other channels ignore this tag.
For message tool sends, set `asVoice: true` with a voice-compatible audio `media` URL
(`message` is optional when media is present):
```json5
{
"action": "send",
"channel": "telegram",
"to": "123456789",
"media": "https://example.com/voice.ogg",
"asVoice": true
}
```
## Streaming (drafts)
Telegram can stream **draft bubbles** while the agent is generating a response.
Clawdbot uses Bot API `sendMessageDraft` (not real messages) and then sends the
+25
View File
@@ -66,11 +66,36 @@ clawdbot directory groups list --channel zalouser --query "work"
## Access control (DMs)
`channels.zalouser.dmPolicy` supports: `pairing | allowlist | open | disabled` (default: `pairing`).
`channels.zalouser.allowFrom` accepts user IDs or names (resolved at startup when available).
Approve via:
- `clawdbot pairing list zalouser`
- `clawdbot pairing approve zalouser <code>`
## Group access (optional)
- Default: `channels.zalouser.groupPolicy = "open"` (groups allowed). Use `channels.defaults.groupPolicy` to override the default when unset.
- Restrict to an allowlist with:
- `channels.zalouser.groupPolicy = "allowlist"`
- `channels.zalouser.groups` (keys are group IDs or names)
- Block all groups: `channels.zalouser.groupPolicy = "disabled"`.
- The configure wizard can prompt for group allowlists.
- On startup, Clawdbot resolves group/user names in allowlists to IDs and logs the mapping; unresolved entries are kept as typed.
Example:
```json5
{
channels: {
zalouser: {
groupPolicy: "allowlist",
groups: {
"123456789": { allow: true },
"Work Chat": { allow: true }
}
}
}
}
```
## Multi-account
Accounts map to zca profiles. Example:
+30
View File
@@ -18,6 +18,9 @@ Related docs:
```bash
clawdbot channels list
clawdbot channels status
clawdbot channels capabilities
clawdbot channels capabilities --channel discord --target channel:123
clawdbot channels resolve --channel slack "#general" "@jane"
clawdbot channels logs --channel all
```
@@ -42,3 +45,30 @@ clawdbot channels logout --channel whatsapp
- Run `clawdbot status --deep` for a broad probe.
- Use `clawdbot doctor` for guided fixes.
## Capabilities probe
Fetch provider capability hints (intents/scopes where available) plus static feature support:
```bash
clawdbot channels capabilities
clawdbot channels capabilities --channel discord --target channel:123
```
Notes:
- `--channel` is optional; omit it to list every channel (including extensions).
- `--target` accepts `channel:<id>` or a raw numeric channel id and only applies to Discord.
- Probes are provider-specific: Discord intents + optional channel permissions; Slack bot + user scopes; Telegram bot flags + webhook; Signal daemon version; MS Teams app token + Graph roles/scopes (annotated where known). Channels without probes report `Probe: unavailable`.
## Resolve names to IDs
Resolve channel/user names to IDs using the provider directory:
```bash
clawdbot channels resolve --channel slack "#general" "@jane"
clawdbot channels resolve --channel discord "My Server/#support" "@someone"
clawdbot channels resolve --channel matrix "Project Room"
```
Notes:
- Use `--kind user|group|auto` to force the target type.
- Resolution prefers active matches when multiple entries share the same name.
+1
View File
@@ -17,6 +17,7 @@ Related:
Notes:
- Choosing where the Gateway runs always updates `gateway.mode`. You can select "Continue" without other sections if that is all you need.
- Channel-oriented services (Slack/Discord/Matrix/Microsoft Teams) prompt for channel/room allowlists during setup. You can enter names or IDs; the wizard resolves names to IDs when possible.
## Examples
+3
View File
@@ -21,6 +21,9 @@ clawdbot doctor --repair
clawdbot doctor --deep
```
Notes:
- Interactive prompts (like keychain/OAuth fixes) only run when stdin is a TTY and `--non-interactive` is **not** set. Headless runs (cron, Telegram, no terminal) will skip prompts.
## macOS: `launchctl` env overrides
If you previously ran `launchctl setenv CLAWDBOT_GATEWAY_TOKEN ...` (or `...PASSWORD`), that value overrides your config file and can cause persistent “unauthorized” errors.
+1
View File
@@ -29,6 +29,7 @@ Notes:
- By default, the Gateway refuses to start unless `gateway.mode=local` is set in `~/.clawdbot/clawdbot.json`. Use `--allow-unconfigured` for ad-hoc/dev runs.
- Binding beyond loopback without auth is blocked (safety guardrail).
- `SIGUSR1` triggers an in-process restart (useful without a supervisor).
- `SIGINT`/`SIGTERM` handlers stop the gateway process, but they don’t restore any custom terminal state. If you wrap the CLI with a TUI or raw-mode input, restore the terminal before exit.
### Options
+2 -1
View File
@@ -292,7 +292,7 @@ Options:
- `--non-interactive`
- `--mode <local|remote>`
- `--flow <quickstart|advanced>`
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|opencode-zen|skip>`
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|opencode-zen|skip>`
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
- `--token <token>` (non-interactive; used with `--auth-choice token`)
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
@@ -302,6 +302,7 @@ Options:
- `--openrouter-api-key <key>`
- `--ai-gateway-api-key <key>`
- `--moonshot-api-key <key>`
- `--kimi-code-api-key <key>`
- `--gemini-api-key <key>`
- `--zai-api-key <key>`
- `--minimax-api-key <key>`
+2 -1
View File
@@ -16,7 +16,8 @@ Related:
```bash
clawdbot memory status
clawdbot memory status --deep
clawdbot memory status --deep --index
clawdbot memory index
clawdbot memory search "release checklist"
```
+5
View File
@@ -26,6 +26,11 @@ clawdbot models scan
When provider usage snapshots are available, the OAuth/token status section includes
provider usage headers.
Notes:
- `models set <model-or-alias>` accepts `provider/model` or an alias.
- Model refs are parsed by splitting on the **first** `/`. If the model ID includes `/` (OpenRouter-style), include the provider prefix (example: `openrouter/moonshotai/kimi-k2`).
- If you omit the provider, Clawdbot treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID).
## Aliases + fallbacks
```bash
+1
View File
@@ -19,3 +19,4 @@ clawdbot status --usage
Notes:
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
- Output includes per-agent session stores when multiple agents are configured.
- Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)).
+8
View File
@@ -98,6 +98,14 @@ Verbose tool summaries are emitted at tool start (no debounce); Control UI
streams tool output via agent events when available.
More details: [Streaming + chunking](/concepts/streaming).
## Model refs
Model refs in config (for example `agents.defaults.model` and `agents.defaults.models`) are parsed by splitting on the **first** `/`.
- Use `provider/model` when configuring models.
- If the model ID itself contains `/` (OpenRouter-style), include the provider prefix (example: `openrouter/moonshotai/kimi-k2`).
- If you omit the provider, Clawdbot treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID).
## Configuration (minimal)
At minimum, set:
+64
View File
@@ -78,6 +78,7 @@ Defaults:
- Watches memory files for changes (debounced).
- Uses remote embeddings (OpenAI) unless configured for local.
- Local mode uses node-llama-cpp and may require `pnpm approve-builds`.
- Uses sqlite-vec (when available) to accelerate vector search inside SQLite.
Remote embeddings **require** an API key for the embedding provider. By default
this is OpenAI (`OPENAI_API_KEY` or `models.providers.openai.apiKey`). Codex
@@ -107,6 +108,11 @@ agents: {
If you don't want to set an API key, use `memorySearch.provider = "local"` or set
`memorySearch.fallback = "none"`.
Batch indexing (OpenAI only):
- Enabled by default for OpenAI embeddings. Set `agents.defaults.memorySearch.remote.batch.enabled = false` to disable.
- Default behavior waits for batch completion; tune `remote.batch.wait`, `remote.batch.pollIntervalMs`, and `remote.batch.timeoutMinutes` if needed.
- Batch mode currently applies only when `memorySearch.provider = "openai"` and uses your OpenAI API key.
Config example:
```json5
@@ -116,6 +122,9 @@ agents: {
provider: "openai",
model: "text-embedding-3-small",
fallback: "openai",
remote: {
batch: { enabled: false }
},
sync: { watch: true }
}
}
@@ -142,6 +151,61 @@ Local mode:
- File type: Markdown only (`MEMORY.md`, `memory/**/*.md`).
- Index storage: per-agent SQLite at `~/.clawdbot/state/memory/<agentId>.sqlite` (configurable via `agents.defaults.memorySearch.store.path`, supports `{agentId}` token).
- Freshness: watcher on `MEMORY.md` + `memory/` marks the index dirty (debounce 1.5s). Sync runs on session start, on first search when dirty, and optionally on an interval. Reindex triggers when embedding model/provider or chunk sizes change.
- Model changes: the index stores the embedding **model + provider + chunking params**. If any of those change, Clawdbot automatically resets and reindexes the entire store.
### Session memory search (experimental)
You can optionally index **session transcripts** and surface them via `memory_search`.
This is gated behind an experimental flag.
```json5
agents: {
defaults: {
memorySearch: {
experimental: { sessionMemory: true },
sources: ["memory", "sessions"]
}
}
}
```
Notes:
- Session indexing is **opt-in** (off by default).
- Session updates are debounced and indexed lazily on the next `memory_search` (or manual `clawdbot memory index`).
- Results still include snippets only; `memory_get` remains limited to memory files.
- Session indexing is isolated per agent (only that agent’s session logs are indexed).
- Session logs live on disk (`~/.clawdbot/agents/<agentId>/sessions/*.jsonl`). Any process/user with filesystem access can read them, so treat disk access as the trust boundary. For stricter isolation, run agents under separate OS users or hosts.
### SQLite vector acceleration (sqlite-vec)
When the sqlite-vec extension is available, Clawdbot stores embeddings in a
SQLite virtual table (`vec0`) and performs vector distance queries in the
database. This keeps search fast without loading every embedding into JS.
Configuration (optional):
```json5
agents: {
defaults: {
memorySearch: {
store: {
vector: {
enabled: true,
extensionPath: "/path/to/sqlite-vec"
}
}
}
}
}
```
Notes:
- `enabled` defaults to true; when disabled, search falls back to in-process
cosine similarity over stored embeddings.
- If the sqlite-vec extension is missing or fails to load, Clawdbot logs the
error and continues with the JS fallback (no vector table).
- `extensionPath` overrides the bundled sqlite-vec path (useful for custom builds
or non-standard install locations).
### Local embedding auto-download
+44
View File
@@ -155,6 +155,50 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
}
```
### Kimi Code
Kimi Code uses a dedicated endpoint and key (separate from Moonshot):
- Provider: `kimi-code`
- Auth: `KIMICODE_API_KEY`
- Example model: `kimi-code/kimi-for-coding`
```json5
{
env: { KIMICODE_API_KEY: "sk-..." },
agents: {
defaults: { model: { primary: "kimi-code/kimi-for-coding" } }
},
models: {
mode: "merge",
providers: {
"kimi-code": {
baseUrl: "https://api.kimi.com/coding/v1",
apiKey: "${KIMICODE_API_KEY}",
api: "openai-completions",
models: [{ id: "kimi-for-coding", name: "Kimi For Coding" }]
}
}
}
}
```
### Qwen OAuth (free tier)
Qwen provides OAuth access to Qwen Coder + Vision via a device-code flow.
Enable the bundled plugin, then log in:
```bash
clawdbot plugins enable qwen-portal-auth
clawdbot models auth login --provider qwen-portal --set-default
```
Model refs:
- `qwen-portal/coder-model`
- `qwen-portal/vision-model`
See [/providers/qwen](/providers/qwen) for setup details and notes.
### Synthetic
Synthetic provides Anthropic-compatible models behind the `synthetic` provider:
+3
View File
@@ -102,6 +102,9 @@ Notes:
- `/model` (and `/model list`) is a compact, numbered picker (model family + available providers).
- `/model <#>` selects from that picker.
- `/model status` is the detailed view (auth candidates and, when configured, provider endpoint `baseUrl` + `api` mode).
- Model refs are parsed by splitting on the **first** `/`. Use `provider/model` when typing `/model <ref>`.
- If the model ID itself contains `/` (OpenRouter-style), you must include the provider prefix (example: `/model openrouter/moonshotai/kimi-k2`).
- If you omit the provider, Clawdbot treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID).
Full command behavior/config: [Slash commands](/tools/slash-commands).
-1
View File
@@ -894,7 +894,6 @@
"gateway/heartbeat",
"gateway/doctor",
"gateway/logging",
"logging",
"gateway/security",
"gateway/sandbox-vs-tool-policy-vs-elevated",
"gateway/sandboxing",
+45 -1
View File
@@ -678,10 +678,11 @@ Notes:
- `"open"`: groups bypass allowlists; mention-gating still applies.
- `"disabled"`: block all group/room messages.
- `"allowlist"`: only allow groups/rooms that match the configured allowlist.
- `channels.defaults.groupPolicy` sets the default when a provider’s `groupPolicy` is unset.
- WhatsApp/Telegram/Signal/iMessage/Microsoft Teams use `groupAllowFrom` (fallback: explicit `allowFrom`).
- Discord/Slack use channel allowlists (`channels.discord.guilds.*.channels`, `channels.slack.channels`).
- Group DMs (Discord/Slack) are still controlled by `dm.groupEnabled` + `dm.groupChannels`.
- Default is `groupPolicy: "allowlist"`; if no allowlist is configured, group messages are blocked.
- Default is `groupPolicy: "allowlist"` (unless overridden by `channels.defaults.groupPolicy`); if no allowlist is configured, group messages are blocked.
### Multi-agent routing (`agents.list` + `bindings`)
@@ -2234,6 +2235,49 @@ Notes:
- Model ref: `moonshot/kimi-k2-0905-preview`.
- Use `https://api.moonshot.cn/v1` if you need the China endpoint.
### Kimi Code
Use Kimi Code's dedicated OpenAI-compatible endpoint (separate from Moonshot):
```json5
{
env: { KIMICODE_API_KEY: "sk-..." },
agents: {
defaults: {
model: { primary: "kimi-code/kimi-for-coding" },
models: { "kimi-code/kimi-for-coding": { alias: "Kimi Code" } }
}
},
models: {
mode: "merge",
providers: {
"kimi-code": {
baseUrl: "https://api.kimi.com/coding/v1",
apiKey: "${KIMICODE_API_KEY}",
api: "openai-completions",
models: [
{
id: "kimi-for-coding",
name: "Kimi For Coding",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 32768,
headers: { "User-Agent": "KimiCLI/0.77" },
compat: { supportsDeveloperRole: false }
}
]
}
}
}
}
```
Notes:
- Set `KIMICODE_API_KEY` in the environment or use `clawdbot onboard --auth-choice kimi-code-api-key`.
- Model ref: `kimi-code/kimi-for-coding`.
### Synthetic (Anthropic-compatible)
Use Synthetic's Anthropic-compatible endpoint:
+8
View File
@@ -52,6 +52,14 @@ When the audit prints findings, treat this as a priority order:
5. **Plugins/extensions**: only load what you explicitly trust.
6. **Model choice**: prefer modern, instruction-hardened models for any bot with tools.
## Local session logs live on disk
Clawdbot stores session transcripts on disk under `~/.clawdbot/agents/<agentId>/sessions/*.jsonl`.
This is required for session continuity and (optionally) session memory indexing, but it also means
**any process/user with filesystem access can read those logs**. Treat disk access as the trust
boundary and lock down permissions on `~/.clawdbot` (see the audit section below). If you need
stronger isolation between agents, run them under separate OS users or separate hosts.
## Node execution (system.run)
If a macOS node is paired, the Gateway can invoke `system.run` on that node. This is **remote code execution** on the Mac:
+1
View File
@@ -43,6 +43,7 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
- [Microsoft Teams](/channels/msteams) — `@clawdbot/msteams`
- Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default)
- Gemini CLI OAuth (provider auth) — bundled as `google-gemini-cli-auth` (disabled by default)
- Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default)
- Copilot Proxy (provider auth) — bundled as `copilot-proxy` (disabled by default)
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
+49
View File
@@ -0,0 +1,49 @@
---
summary: "Sign in to GitHub Copilot from Clawdbot using the device flow"
read_when:
- You want to use GitHub Copilot as a model provider
- You need the `clawdbot models auth login-github-copilot` flow
---
# Github Copilot
Use GitHub Copilot as a model provider (`github-copilot`). The login command runs
the GitHub device flow, saves an auth profile, and updates your config to use that
profile.
## CLI setup
```bash
clawdbot models auth login-github-copilot
```
You'll be prompted to visit a URL and enter a one-time code. Keep the terminal
open until it completes.
### Optional flags
```bash
clawdbot models auth login-github-copilot --profile-id github-copilot:work
clawdbot models auth login-github-copilot --yes
```
## Set a default model
```bash
clawdbot models set github-copilot/gpt-4o
```
### Config snippet
```json5
{
agents: { defaults: { model: { primary: "github-copilot/gpt-4o" } } }
}
```
## Notes
- Requires an interactive TTY; run it directly in a terminal.
- Copilot model availability depends on your plan; if a model is rejected, try
another ID (for example `github-copilot/gpt-4.1`).
- The login stores a GitHub token in the auth profile store and exchanges it for a
Copilot API token when Clawdbot runs.
+2 -1
View File
@@ -26,9 +26,10 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/etc.)? See [Chann
- [OpenAI (API + Codex)](/providers/openai)
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
- [Qwen (OAuth)](/providers/qwen)
- [OpenRouter](/providers/openrouter)
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
- [Moonshot AI (Kimi)](/providers/moonshot)
- [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot)
- [OpenCode Zen](/providers/opencode)
- [Z.AI](/providers/zai)
- [GLM models](/providers/glm)
+1
View File
@@ -155,6 +155,7 @@ Use the interactive config wizard to set MiniMax without editing JSON:
## Notes
- Model refs are `minimax/<model>`.
- Coding Plan usage API: `https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains` (requires a coding plan key).
- Update pricing values in `models.json` if you need exact cost tracking.
- Referral link for MiniMax Coding Plan (10% off): https://platform.minimax.io/subscribe/coding-plan?code=DbXJTRClnb&source=link
- See [/concepts/model-providers](/concepts/model-providers) for provider rules.
+1 -1
View File
@@ -26,7 +26,7 @@ model as `provider/model`.
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
- [OpenRouter](/providers/openrouter)
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
- [Moonshot AI (Kimi)](/providers/moonshot)
- [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot)
- [Synthetic](/providers/synthetic)
- [OpenCode Zen](/providers/opencode)
- [Z.AI](/providers/zai)
+56 -6
View File
@@ -1,13 +1,16 @@
---
summary: "Use Moonshot AI (Kimi K2) with Clawdbot"
summary: "Configure Moonshot K2 vs Kimi Code (separate providers + keys)"
read_when:
- You want to use Moonshot/Kimi models in Clawdbot
- You need the Moonshot auth + config example
- You want Moonshot K2 (Moonshot Open Platform) vs Kimi Code setup
- You need to understand separate endpoints, keys, and model refs
- You want copy/paste config for either provider
---
# Moonshot AI (Kimi)
Moonshot provides the Kimi API with OpenAI-compatible endpoints. Configure the
provider and set the default model to `moonshot/kimi-k2-0905-preview`.
provider and set the default model to `moonshot/kimi-k2-0905-preview`, or use
Kimi Code with `kimi-code/kimi-for-coding`.
Current Kimi K2 model IDs:
{/* moonshot-kimi-k2-ids:start */}
@@ -21,7 +24,15 @@ Current Kimi K2 model IDs:
clawdbot onboard --auth-choice moonshot-api-key
```
## Config snippet
Kimi Code:
```bash
clawdbot onboard --auth-choice kimi-code-api-key
```
Note: Moonshot and Kimi Code are separate providers. Keys are not interchangeable, endpoints differ, and model refs differ (Moonshot uses `moonshot/...`, Kimi Code uses `kimi-code/...`).
## Config snippet (Moonshot API)
```json5
{
@@ -92,9 +103,48 @@ clawdbot onboard --auth-choice moonshot-api-key
}
```
## Kimi Code
```json5
{
env: { KIMICODE_API_KEY: "sk-..." },
agents: {
defaults: {
model: { primary: "kimi-code/kimi-for-coding" },
models: {
"kimi-code/kimi-for-coding": { alias: "Kimi Code" }
}
}
},
models: {
mode: "merge",
providers: {
"kimi-code": {
baseUrl: "https://api.kimi.com/coding/v1",
apiKey: "${KIMICODE_API_KEY}",
api: "openai-completions",
models: [
{
id: "kimi-for-coding",
name: "Kimi For Coding",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 32768,
headers: { "User-Agent": "KimiCLI/0.77" },
compat: { supportsDeveloperRole: false }
}
]
}
}
}
}
```
## Notes
- Model refs use `moonshot/<modelId>`.
- Moonshot model refs use `moonshot/<modelId>`. Kimi Code model refs use `kimi-code/<modelId>`.
- Override pricing and context metadata in `models.providers` if needed.
- If Moonshot publishes different context limits for a model, adjust
`contextWindow` accordingly.
+51
View File
@@ -0,0 +1,51 @@
---
summary: "Use Qwen OAuth (free tier) in Clawdbot"
read_when:
- You want to use Qwen with Clawdbot
- You want free-tier OAuth access to Qwen Coder
---
# Qwen
Qwen provides a free-tier OAuth flow for Qwen Coder and Qwen Vision models
(2,000 requests/day, subject to Qwen rate limits).
## Enable the plugin
```bash
clawdbot plugins enable qwen-portal-auth
```
Restart the Gateway after enabling.
## Authenticate
```bash
clawdbot models auth login --provider qwen-portal --set-default
```
This runs the Qwen device-code OAuth flow and writes a provider entry to your
`models.json` (plus a `qwen` alias for quick switching).
## Model IDs
- `qwen-portal/coder-model`
- `qwen-portal/vision-model`
Switch models with:
```bash
clawdbot models set qwen-portal/coder-model
```
## Reuse Qwen Code CLI login
If you already logged in with the Qwen Code CLI, Clawdbot will sync credentials
from `~/.qwen/oauth_creds.json` when it loads the auth store. You still need a
`models.providers.qwen-portal` entry (use the login command above to create one).
## Notes
- Tokens auto-refresh; re-run the login command if refresh fails or access is revoked.
- Default base URL: `https://portal.qwen.ai/v1` (override with
`models.providers.qwen-portal.baseUrl` if Qwen provides a different endpoint).
- See [Model providers](/concepts/model-providers) for provider-wide rules.
+6
View File
@@ -10,6 +10,12 @@ read_when:
Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tagging/publishing.
## Operator trigger
When the operator says “release”, immediately do this preflight (no extra questions unless blocked):
- Read this doc and `docs/platforms/mac/release.md`.
- Load env from `~/.profile` and confirm `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect vars are set.
- Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed.
1) **Version & metadata**
- [ ] Bump `package.json` version (e.g., `1.1.0`).
- [ ] Run `pnpm plugins:sync` to align extension package versions + changelogs.
+3 -1
View File
@@ -95,7 +95,8 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
- **Synthetic (Anthropic-compatible)**: prompts for `SYNTHETIC_API_KEY`.
- More detail: [Synthetic](/providers/synthetic)
- **Moonshot (Kimi K2)**: config is auto-written.
- More detail: [Moonshot AI](/providers/moonshot)
- **Kimi Code**: config is auto-written.
- More detail: [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot)
- **Skip**: no auth configured yet.
- Pick a default model from detected options (or enter provider/model manually).
- Wizard runs a model check and warns if the configured model is unknown or missing auth.
@@ -292,6 +293,7 @@ Typical fields in `~/.clawdbot/clawdbot.json`:
- `agents.defaults.model` / `models.providers` (if Minimax chosen)
- `gateway.*` (mode, bind, auth, tailscale)
- `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*`
- Channel allowlists (Slack/Discord/Matrix/Microsoft Teams) when you opt in during the prompts (names resolve to IDs when possible).
- `skills.install.nodeManager`
- `wizard.lastRunAt`
- `wizard.lastRunVersion`
+1
View File
@@ -27,6 +27,7 @@ Tool params:
- `label?` (optional)
- `agentId?` (optional; spawn under another agent id if allowed)
- `model?` (optional; overrides the sub-agent model; invalid values are skipped and the sub-agent runs on the default model with a warning in the tool result)
- `thinking?` (optional; overrides thinking level for the sub-agent run)
- `runTimeoutSeconds?` (default `0`; when set, the sub-agent run is aborted after N seconds)
- `cleanup?` (`delete|keep`, default `keep`)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/copilot-proxy",
"version": "2026.1.16-1",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot Copilot Proxy provider plugin",
"clawdbot": {
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/google-antigravity-auth",
"version": "2026.1.16-1",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot Google Antigravity OAuth provider plugin",
"clawdbot": {
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/google-gemini-cli-auth",
"version": "2026.1.16-1",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot Gemini CLI OAuth provider plugin",
"clawdbot": {
+10
View File
@@ -1,5 +1,15 @@
# Changelog
## 2026.1.17-1
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.17
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.16
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/matrix",
"version": "2026.1.16",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot Matrix channel plugin",
"clawdbot": {
+16 -2
View File
@@ -25,6 +25,11 @@ import { probeMatrix } from "./matrix/probe.js";
import { sendMessageMatrix } from "./matrix/send.js";
import { matrixOnboardingAdapter } from "./onboarding.js";
import { matrixOutbound } from "./outbound.js";
import { resolveMatrixTargets } from "./resolve-targets.js";
import {
listMatrixDirectoryGroupsLive,
listMatrixDirectoryPeersLive,
} from "./directory-live.js";
const meta = {
id: "matrix",
@@ -147,8 +152,9 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
approveHint: formatPairingApproveHint("matrix"),
normalizeEntry: (raw) => raw.replace(/^matrix:/i, "").trim().toLowerCase(),
}),
collectWarnings: ({ account }) => {
const groupPolicy = account.config.groupPolicy ?? "allowlist";
collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy = (cfg as CoreConfig).channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
return [
"- Matrix rooms: groupPolicy=\"open\" allows any room to trigger (mention-gated). Set channels.matrix.groupPolicy=\"allowlist\" + channels.matrix.rooms to restrict rooms.",
@@ -234,6 +240,14 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
.map((id) => ({ kind: "group", id }) as const);
return ids;
},
listPeersLive: async ({ cfg, query, limit }) =>
listMatrixDirectoryPeersLive({ cfg, query, limit }),
listGroupsLive: async ({ cfg, query, limit }) =>
listMatrixDirectoryGroupsLive({ cfg, query, limit }),
},
resolver: {
resolveTargets: async ({ cfg, inputs, kind, runtime }) =>
resolveMatrixTargets({ cfg, inputs, kind, runtime }),
},
actions: matrixMessageActions,
setup: {
+175
View File
@@ -0,0 +1,175 @@
import type { ChannelDirectoryEntry } from "../../../src/channels/plugins/types.js";
import { resolveMatrixAuth } from "./matrix/client.js";
type MatrixUserResult = {
user_id?: string;
display_name?: string;
};
type MatrixUserDirectoryResponse = {
results?: MatrixUserResult[];
};
type MatrixJoinedRoomsResponse = {
joined_rooms?: string[];
};
type MatrixRoomNameState = {
name?: string;
};
type MatrixAliasLookup = {
room_id?: string;
};
async function fetchMatrixJson<T>(params: {
homeserver: string;
path: string;
accessToken: string;
method?: "GET" | "POST";
body?: unknown;
}): Promise<T> {
const res = await fetch(`${params.homeserver}${params.path}`, {
method: params.method ?? "GET",
headers: {
Authorization: `Bearer ${params.accessToken}`,
"Content-Type": "application/json",
},
body: params.body ? JSON.stringify(params.body) : undefined,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Matrix API ${params.path} failed (${res.status}): ${text || "unknown error"}`);
}
return (await res.json()) as T;
}
function normalizeQuery(value?: string | null): string {
return value?.trim().toLowerCase() ?? "";
}
export async function listMatrixDirectoryPeersLive(params: {
cfg: unknown;
query?: string | null;
limit?: number | null;
}): Promise<ChannelDirectoryEntry[]> {
const query = normalizeQuery(params.query);
if (!query) return [];
const auth = await resolveMatrixAuth({ cfg: params.cfg as never });
const res = await fetchMatrixJson<MatrixUserDirectoryResponse>({
homeserver: auth.homeserver,
accessToken: auth.accessToken,
path: "/_matrix/client/v3/user_directory/search",
method: "POST",
body: {
search_term: query,
limit: typeof params.limit === "number" && params.limit > 0 ? params.limit : 20,
},
});
const results = res.results ?? [];
return results
.map((entry) => {
const userId = entry.user_id?.trim();
if (!userId) return null;
return {
kind: "user",
id: userId,
name: entry.display_name?.trim() || undefined,
handle: entry.display_name ? `@${entry.display_name.trim()}` : undefined,
raw: entry,
} satisfies ChannelDirectoryEntry;
})
.filter(Boolean) as ChannelDirectoryEntry[];
}
async function resolveMatrixRoomAlias(
homeserver: string,
accessToken: string,
alias: string,
): Promise<string | null> {
try {
const res = await fetchMatrixJson<MatrixAliasLookup>({
homeserver,
accessToken,
path: `/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`,
});
return res.room_id?.trim() || null;
} catch {
return null;
}
}
async function fetchMatrixRoomName(
homeserver: string,
accessToken: string,
roomId: string,
): Promise<string | null> {
try {
const res = await fetchMatrixJson<MatrixRoomNameState>({
homeserver,
accessToken,
path: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.name`,
});
return res.name?.trim() || null;
} catch {
return null;
}
}
export async function listMatrixDirectoryGroupsLive(params: {
cfg: unknown;
query?: string | null;
limit?: number | null;
}): Promise<ChannelDirectoryEntry[]> {
const query = normalizeQuery(params.query);
if (!query) return [];
const auth = await resolveMatrixAuth({ cfg: params.cfg as never });
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20;
if (query.startsWith("#")) {
const roomId = await resolveMatrixRoomAlias(auth.homeserver, auth.accessToken, query);
if (!roomId) return [];
return [
{
kind: "group",
id: roomId,
name: query,
handle: query,
} satisfies ChannelDirectoryEntry,
];
}
if (query.startsWith("!")) {
return [
{
kind: "group",
id: query,
name: query,
} satisfies ChannelDirectoryEntry,
];
}
const joined = await fetchMatrixJson<MatrixJoinedRoomsResponse>({
homeserver: auth.homeserver,
accessToken: auth.accessToken,
path: "/_matrix/client/v3/joined_rooms",
});
const rooms = joined.joined_rooms ?? [];
const results: ChannelDirectoryEntry[] = [];
for (const roomId of rooms) {
const name = await fetchMatrixRoomName(auth.homeserver, auth.accessToken, roomId);
if (!name) continue;
if (!name.toLowerCase().includes(query)) continue;
results.push({
kind: "group",
id: roomId,
name,
handle: `#${name}`,
});
if (results.length >= limit) break;
}
return results;
}
@@ -10,23 +10,49 @@ function normalizeMatrixUser(raw?: string | null): string {
return (raw ?? "").trim().toLowerCase();
}
export type MatrixAllowListMatch = {
allowed: boolean;
matchKey?: string;
matchSource?: "wildcard" | "id" | "prefixed-id" | "prefixed-user" | "name" | "localpart";
};
export function resolveMatrixAllowListMatch(params: {
allowList: string[];
userId?: string;
userName?: string;
}): MatrixAllowListMatch {
const allowList = params.allowList;
if (allowList.length === 0) return { allowed: false };
if (allowList.includes("*")) {
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
}
const userId = normalizeMatrixUser(params.userId);
const userName = normalizeMatrixUser(params.userName);
const localPart = userId.startsWith("@") ? (userId.slice(1).split(":")[0] ?? "") : "";
const candidates: Array<{ value?: string; source: MatrixAllowListMatch["matchSource"] }> = [
{ value: userId, source: "id" },
{ value: userId ? `matrix:${userId}` : "", source: "prefixed-id" },
{ value: userId ? `user:${userId}` : "", source: "prefixed-user" },
{ value: userName, source: "name" },
{ value: localPart, source: "localpart" },
];
for (const candidate of candidates) {
if (!candidate.value) continue;
if (allowList.includes(candidate.value)) {
return {
allowed: true,
matchKey: candidate.value,
matchSource: candidate.source,
};
}
}
return { allowed: false };
}
export function resolveMatrixAllowListMatches(params: {
allowList: string[];
userId?: string;
userName?: string;
}) {
const allowList = params.allowList;
if (allowList.length === 0) return false;
if (allowList.includes("*")) return true;
const userId = normalizeMatrixUser(params.userId);
const userName = normalizeMatrixUser(params.userName);
const localPart = userId.startsWith("@") ? (userId.slice(1).split(":")[0] ?? "") : "";
const candidates = [
userId,
userId ? `matrix:${userId}` : "",
userId ? `user:${userId}` : "",
userName,
localPart,
].filter(Boolean);
return candidates.some((value) => allowList.includes(value));
return resolveMatrixAllowListMatch(params).allowed;
}
+143 -15
View File
@@ -41,7 +41,12 @@ import {
parsePollStartContent,
} from "../poll-types.js";
import { reactMatrixMessage, sendMessageMatrix, sendTypingMatrix } from "../send.js";
import { resolveMatrixAllowListMatches, normalizeAllowListLower } from "./allowlist.js";
import {
resolveMatrixAllowListMatch,
resolveMatrixAllowListMatches,
normalizeAllowListLower,
} from "./allowlist.js";
import { mergeAllowlist, summarizeMapping } from "../../../../../src/channels/allowlists/resolve-utils.js";
import { registerMatrixAutoJoin } from "./auto-join.js";
import { createDirectRoomTracker } from "./direct.js";
import { downloadMatrixMedia } from "./media.js";
@@ -49,6 +54,7 @@ import { resolveMentions } from "./mentions.js";
import { deliverMatrixReplies } from "./replies.js";
import { resolveMatrixRoomConfig } from "./rooms.js";
import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js";
import { resolveMatrixTargets } from "../../resolve-targets.js";
export type MonitorMatrixOpts = {
runtime?: RuntimeEnv;
@@ -64,7 +70,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
if (isBunRuntime()) {
throw new Error("Matrix provider requires Node (bun runtime not supported)");
}
const cfg = loadConfig() as CoreConfig;
let cfg = loadConfig() as CoreConfig;
if (cfg.channels?.matrix?.enabled === false) return;
const runtime: RuntimeEnv = opts.runtime ?? {
@@ -75,6 +81,110 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
},
};
const normalizeUserEntry = (raw: string) =>
raw.replace(/^matrix:/i, "").replace(/^user:/i, "").trim();
const normalizeRoomEntry = (raw: string) =>
raw.replace(/^matrix:/i, "").replace(/^(room|channel):/i, "").trim();
const isMatrixUserId = (value: string) => value.startsWith("@") && value.includes(":");
let allowFrom = cfg.channels?.matrix?.dm?.allowFrom ?? [];
let roomsConfig = cfg.channels?.matrix?.rooms;
if (allowFrom.length > 0) {
const entries = allowFrom
.map((entry) => normalizeUserEntry(String(entry)))
.filter((entry) => entry && entry !== "*");
if (entries.length > 0) {
const mapping: string[] = [];
const unresolved: string[] = [];
const additions: string[] = [];
const pending: string[] = [];
for (const entry of entries) {
if (isMatrixUserId(entry)) {
additions.push(entry);
continue;
}
pending.push(entry);
}
if (pending.length > 0) {
const resolved = await resolveMatrixTargets({
cfg,
inputs: pending,
kind: "user",
runtime,
});
for (const entry of resolved) {
if (entry.resolved && entry.id) {
additions.push(entry.id);
mapping.push(`${entry.input}→${entry.id}`);
} else {
unresolved.push(entry.input);
}
}
}
allowFrom = mergeAllowlist({ existing: allowFrom, additions });
summarizeMapping("matrix users", mapping, unresolved, runtime);
}
}
if (roomsConfig && Object.keys(roomsConfig).length > 0) {
const entries = Object.keys(roomsConfig).filter((key) => key !== "*");
const mapping: string[] = [];
const unresolved: string[] = [];
const nextRooms = { ...roomsConfig };
const pending: Array<{ input: string; query: string }> = [];
for (const entry of entries) {
const trimmed = entry.trim();
if (!trimmed) continue;
const cleaned = normalizeRoomEntry(trimmed);
if (cleaned.startsWith("!") && cleaned.includes(":")) {
if (!nextRooms[cleaned]) {
nextRooms[cleaned] = roomsConfig[entry];
}
mapping.push(`${entry}→${cleaned}`);
continue;
}
pending.push({ input: entry, query: trimmed });
}
if (pending.length > 0) {
const resolved = await resolveMatrixTargets({
cfg,
inputs: pending.map((entry) => entry.query),
kind: "group",
runtime,
});
resolved.forEach((entry, index) => {
const source = pending[index];
if (!source) return;
if (entry.resolved && entry.id) {
if (!nextRooms[entry.id]) {
nextRooms[entry.id] = roomsConfig[source.input];
}
mapping.push(`${source.input}→${entry.id}`);
} else {
unresolved.push(source.input);
}
});
}
roomsConfig = nextRooms;
summarizeMapping("matrix rooms", mapping, unresolved, runtime);
}
cfg = {
...cfg,
channels: {
...cfg.channels,
matrix: {
...cfg.channels?.matrix,
dm: {
...cfg.channels?.matrix?.dm,
allowFrom,
},
rooms: roomsConfig,
},
},
};
const auth = await resolveMatrixAuth({ cfg });
const resolvedInitialSyncLimit =
typeof opts.initialSyncLimit === "number"
@@ -94,7 +204,8 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
const mentionRegexes = buildMentionRegexes(cfg);
const logger = getChildLogger({ module: "matrix-auto-reply" });
const allowlistOnly = cfg.channels?.matrix?.allowlistOnly === true;
const groupPolicyRaw = cfg.channels?.matrix?.groupPolicy ?? "allowlist";
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicyRaw = cfg.channels?.matrix?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
const groupPolicy = allowlistOnly && groupPolicyRaw === "open" ? "allowlist" : groupPolicyRaw;
const replyToMode = opts.replyToMode ?? cfg.channels?.matrix?.replyToMode ?? "off";
const threadReplies = cfg.channels?.matrix?.threadReplies ?? "inbound";
@@ -184,18 +295,21 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
aliases: roomAliases,
name: roomName,
});
const roomMatchMeta = `matchKey=${roomConfigInfo.matchKey ?? "none"} matchSource=${
roomConfigInfo.matchSource ?? "none"
}`;
if (roomConfigInfo.config && !roomConfigInfo.allowed) {
logVerbose(`matrix: room disabled room=${roomId}`);
logVerbose(`matrix: room disabled room=${roomId} (${roomMatchMeta})`);
return;
}
if (groupPolicy === "allowlist") {
if (!roomConfigInfo.allowlistConfigured) {
logVerbose("matrix: drop room message (no allowlist)");
logVerbose(`matrix: drop room message (no allowlist, ${roomMatchMeta})`);
return;
}
if (!roomConfigInfo.config) {
logVerbose("matrix: drop room message (not in allowlist)");
logVerbose(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`);
return;
}
}
@@ -207,14 +321,15 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
if (isDirectMessage) {
if (!dmEnabled || dmPolicy === "disabled") return;
if (dmPolicy !== "open") {
const permitted =
effectiveAllowFrom.length > 0 &&
resolveMatrixAllowListMatches({
allowList: effectiveAllowFrom,
userId: senderId,
userName: senderName,
});
if (!permitted) {
const allowMatch = resolveMatrixAllowListMatch({
allowList: effectiveAllowFrom,
userId: senderId,
userName: senderName,
});
const allowMatchMeta = `matchKey=${allowMatch.matchKey ?? "none"} matchSource=${
allowMatch.matchSource ?? "none"
}`;
if (!allowMatch.allowed) {
if (dmPolicy === "pairing") {
const { code, created } = await upsertChannelPairingRequest({
channel: "matrix",
@@ -222,6 +337,9 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
meta: { name: senderName },
});
if (created) {
logVerbose(
`matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`,
);
try {
await sendMessageMatrix(
`room:${roomId}`,
@@ -240,6 +358,11 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
}
}
}
if (dmPolicy !== "pairing") {
logVerbose(
`matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
);
}
return;
}
}
@@ -252,10 +375,15 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
userName: senderName,
});
if (!userAllowed) {
logVerbose(`matrix: blocked sender ${senderId} (room users allowlist)`);
logVerbose(
`matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta})`,
);
return;
}
}
if (isRoom) {
logVerbose(`matrix: allow room ${roomId} (${roomMatchMeta})`);
}
const rawBody = content.body.trim();
let media: {
@@ -2,7 +2,7 @@ import type { MatrixClient } from "matrix-js-sdk";
import { chunkMarkdownText } from "../../../../../src/auto-reply/chunk.js";
import type { ReplyPayload } from "../../../../../src/auto-reply/types.js";
import { danger } from "../../../../../src/globals.js";
import { danger, logVerbose } from "../../../../../src/globals.js";
import type { RuntimeEnv } from "../../../../../src/runtime.js";
import { sendMessageMatrix } from "../send.js";
@@ -18,7 +18,12 @@ export async function deliverMatrixReplies(params: {
const chunkLimit = Math.min(params.textLimit, 4000);
let hasReplied = false;
for (const reply of params.replies) {
if (!reply?.text && !reply?.mediaUrl && !(reply?.mediaUrls?.length ?? 0)) {
const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0;
if (!reply?.text && !hasMedia) {
if (reply?.audioAsVoice) {
logVerbose("matrix reply has audioAsVoice without media/text; skipping");
continue;
}
params.runtime.error?.(danger("matrix reply missing text/media"));
continue;
}
@@ -57,6 +62,7 @@ export async function deliverMatrixReplies(params: {
mediaUrl,
replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
threadId: params.threadId,
audioAsVoice: reply.audioAsVoice,
});
if (shouldIncludeReply(replyToId)) {
hasReplied = true;
+24 -14
View File
@@ -1,9 +1,15 @@
import type { MatrixConfig, MatrixRoomConfig } from "../../types.js";
import {
buildChannelKeyCandidates,
resolveChannelEntryMatch,
} from "../../../../../src/channels/plugins/channel-config.js";
export type MatrixRoomConfigResolved = {
allowed: boolean;
allowlistConfigured: boolean;
config?: MatrixRoomConfig;
matchKey?: string;
matchSource?: "direct" | "wildcard";
};
export function resolveMatrixRoomConfig(params: {
@@ -15,22 +21,26 @@ export function resolveMatrixRoomConfig(params: {
const rooms = params.rooms ?? {};
const keys = Object.keys(rooms);
const allowlistConfigured = keys.length > 0;
const candidates = [
const candidates = buildChannelKeyCandidates(
params.roomId,
`room:${params.roomId}`,
...params.aliases,
params.name ?? "",
].filter(Boolean);
let matched: MatrixRoomConfigResolved["config"] | undefined;
for (const candidate of candidates) {
if (rooms[candidate]) {
matched = rooms[candidate];
break;
}
}
if (!matched && rooms["*"]) {
matched = rooms["*"];
}
const allowed = matched ? matched.enabled !== false && matched.allow !== false : false;
return { allowed, allowlistConfigured, config: matched };
);
const { entry: matched, key: matchedKey, wildcardEntry, wildcardKey } = resolveChannelEntryMatch({
entries: rooms,
keys: candidates,
wildcardKey: "*",
});
const resolved = matched ?? wildcardEntry;
const allowed = resolved ? resolved.enabled !== false && resolved.allow !== false : false;
const matchKey = matchedKey ?? wildcardKey;
const matchSource = matched ? "direct" : wildcardEntry ? "wildcard" : undefined;
return {
allowed,
allowlistConfigured,
config: resolved,
matchKey,
matchSource,
};
}
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { parsePollStartContent } from "./poll-types.js";
describe("parsePollStartContent", () => {
it("parses legacy m.poll payloads", () => {
const summary = parsePollStartContent({
"m.poll": {
question: { "m.text": "Lunch?" },
kind: "m.poll.disclosed",
max_selections: 1,
answers: [
{ id: "answer1", "m.text": "Yes" },
{ id: "answer2", "m.text": "No" },
],
},
});
expect(summary?.question).toBe("Lunch?");
expect(summary?.answers).toEqual(["Yes", "No"]);
});
});
+35 -28
View File
@@ -7,15 +7,17 @@
* - m.poll.end - Closes a poll
*/
import type { TimelineEvents } from "matrix-js-sdk/lib/@types/event.js";
import type { ExtensibleAnyMessageEventContent } from "matrix-js-sdk/lib/@types/extensible_events.js";
import type { PollInput } from "../../../../src/polls.js";
export const M_POLL_START = "m.poll.start";
export const M_POLL_RESPONSE = "m.poll.response";
export const M_POLL_END = "m.poll.end";
export const M_POLL_START = "m.poll.start" as const;
export const M_POLL_RESPONSE = "m.poll.response" as const;
export const M_POLL_END = "m.poll.end" as const;
export const ORG_POLL_START = "org.matrix.msc3381.poll.start";
export const ORG_POLL_RESPONSE = "org.matrix.msc3381.poll.response";
export const ORG_POLL_END = "org.matrix.msc3381.poll.end";
export const ORG_POLL_START = "org.matrix.msc3381.poll.start" as const;
export const ORG_POLL_RESPONSE = "org.matrix.msc3381.poll.response" as const;
export const ORG_POLL_END = "org.matrix.msc3381.poll.end" as const;
export const POLL_EVENT_TYPES = [
M_POLL_START,
@@ -32,9 +34,7 @@ export const POLL_END_TYPES = [M_POLL_END, ORG_POLL_END];
export type PollKind = "m.poll.disclosed" | "m.poll.undisclosed";
export type TextContent = {
"m.text"?: string;
"org.matrix.msc1767.text"?: string;
export type TextContent = ExtensibleAnyMessageEventContent & {
body?: string;
};
@@ -42,25 +42,19 @@ export type PollAnswer = {
id: string;
} & TextContent;
export type PollStartContent = {
"m.poll"?: {
question: TextContent;
kind?: PollKind;
max_selections?: number;
answers: PollAnswer[];
};
"org.matrix.msc3381.poll.start"?: {
question: TextContent;
kind?: PollKind;
max_selections?: number;
answers: PollAnswer[];
};
"m.relates_to"?: {
rel_type: "m.reference";
event_id: string;
};
export type PollStartSubtype = {
question: TextContent;
kind?: PollKind;
max_selections?: number;
answers: PollAnswer[];
};
export type LegacyPollStartContent = {
"m.poll"?: PollStartSubtype;
};
export type PollStartContent = TimelineEvents[typeof M_POLL_START] | LegacyPollStartContent;
export type PollSummary = {
eventId: string;
roomId: string;
@@ -82,7 +76,9 @@ export function getTextContent(text?: TextContent): string {
}
export function parsePollStartContent(content: PollStartContent): PollSummary | null {
const poll = content["m.poll"] ?? content["org.matrix.msc3381.poll.start"];
const poll = (content as Record<string, PollStartSubtype | undefined>)[M_POLL_START]
?? (content as Record<string, PollStartSubtype | undefined>)[ORG_POLL_START]
?? (content as Record<string, PollStartSubtype | undefined>)["m.poll"];
if (!poll) return null;
const question = getTextContent(poll.question);
@@ -121,6 +117,11 @@ function buildTextContent(body: string): TextContent {
};
}
function buildPollFallbackText(question: string, answers: string[]): string {
if (answers.length === 0) return question;
return `${question}\n${answers.map((answer, idx) => `${idx + 1}. ${answer}`).join("\n")}`;
}
export function buildPollStartContent(poll: PollInput): PollStartContent {
const question = poll.question.trim();
const answers = poll.options
@@ -132,13 +133,19 @@ export function buildPollStartContent(poll: PollInput): PollStartContent {
}));
const maxSelections = poll.multiple ? Math.max(1, answers.length) : 1;
const fallbackText = buildPollFallbackText(
question,
answers.map((answer) => getTextContent(answer)),
);
return {
"m.poll": {
[M_POLL_START]: {
question: buildTextContent(question),
kind: poll.multiple ? "m.poll.undisclosed" : "m.poll.disclosed",
max_selections: maxSelections,
answers,
},
"m.text": fallbackText,
"org.matrix.msc1767.text": fallbackText,
};
}
+7 -2
View File
@@ -31,6 +31,11 @@ vi.mock("../../../../src/web/media.js", () => ({
}),
}));
vi.mock("../../../../src/media/image-ops.js", () => ({
getImageMetadata: vi.fn().mockResolvedValue(null),
resizeToJpeg: vi.fn(),
}));
let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix;
const makeClient = () => {
@@ -65,13 +70,13 @@ describe("sendMessageMatrix media", () => {
const uploadArg = uploadContent.mock.calls[0]?.[0];
expect(Buffer.isBuffer(uploadArg)).toBe(true);
const content = sendMessage.mock.calls[0]?.[2] as {
const content = sendMessage.mock.calls[0]?.[1] as {
url?: string;
msgtype?: string;
format?: string;
formatted_body?: string;
};
expect(content.msgtype).toBe("m.file");
expect(content.msgtype).toBe("m.image");
expect(content.format).toBe("org.matrix.custom.html");
expect(content.formatted_body).toContain("caption");
expect(content.url).toBe("mxc://example/file");
+145 -22
View File
@@ -1,12 +1,15 @@
import type { AccountDataEvents, MatrixClient } from "matrix-js-sdk";
import { EventType, MsgType, RelationType } from "matrix-js-sdk";
import type {
ReactionEventContent,
RoomMessageEventContent,
ReactionEventContent,
} from "matrix-js-sdk/lib/@types/events.js";
import { chunkMarkdownText, resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js";
import { loadConfig } from "../../../../src/config/config.js";
import { isVoiceCompatibleAudio } from "../../../../src/media/audio.js";
import { mediaKindFromMime } from "../../../../src/media/constants.js";
import { getImageMetadata, resizeToJpeg } from "../../../../src/media/image-ops.js";
import type { PollInput } from "../../../../src/polls.js";
import { loadWebMedia } from "../../../../src/web/media.js";
import { getActiveMatrixClient } from "./active-client.js";
@@ -47,6 +50,8 @@ export type MatrixSendOpts = {
replyToId?: string;
threadId?: string | number | null;
timeoutMs?: number;
/** Send audio as voice message (voice bubble) instead of audio file. Defaults to false. */
audioAsVoice?: boolean;
};
function ensureNodeRuntime() {
@@ -71,6 +76,12 @@ function normalizeTarget(raw: string): string {
return trimmed;
}
function normalizeThreadId(raw?: string | number | null): string | null {
if (raw === undefined || raw === null) return null;
const trimmed = String(raw).trim();
return trimmed ? trimmed : null;
}
async function resolveDirectRoomId(client: MatrixClient, userId: string): Promise<string> {
const trimmed = userId.trim();
if (!trimmed.startsWith("@")) {
@@ -119,6 +130,18 @@ export async function resolveMatrixRoomId(
return target;
}
type MatrixImageInfo = {
w?: number;
h?: number;
thumbnail_url?: string;
thumbnail_info?: {
w: number;
h: number;
mimetype: string;
size: number;
};
};
function buildMediaContent(params: {
msgtype: MsgType.Image | MsgType.Audio | MsgType.Video | MsgType.File;
body: string;
@@ -127,8 +150,24 @@ function buildMediaContent(params: {
mimetype?: string;
size: number;
relation?: MatrixReplyRelation;
isVoice?: boolean;
durationMs?: number;
imageInfo?: MatrixImageInfo;
}): RoomMessageEventContent {
const info = { mimetype: params.mimetype, size: params.size };
const info: Record<string, unknown> = { mimetype: params.mimetype, size: params.size };
if (params.durationMs !== undefined) {
info.duration = params.durationMs;
}
if (params.imageInfo) {
if (params.imageInfo.w) info.w = params.imageInfo.w;
if (params.imageInfo.h) info.h = params.imageInfo.h;
if (params.imageInfo.thumbnail_url) {
info.thumbnail_url = params.imageInfo.thumbnail_url;
if (params.imageInfo.thumbnail_info) {
info.thumbnail_info = params.imageInfo.thumbnail_info;
}
}
}
const base: MatrixMessageContent = {
msgtype: params.msgtype,
body: params.body,
@@ -136,6 +175,12 @@ function buildMediaContent(params: {
info,
url: params.url,
};
if (params.isVoice) {
base["org.matrix.msc3245.voice"] = {};
base["org.matrix.msc1767.audio"] = {
duration: params.durationMs,
};
}
if (params.relation) {
base["m.relates_to"] = params.relation;
}
@@ -171,6 +216,75 @@ function buildReplyRelation(replyToId?: string): MatrixReplyRelation | undefined
return { "m.in_reply_to": { event_id: trimmed } };
}
function resolveMatrixMsgType(
contentType?: string,
fileName?: string,
): MsgType.Image | MsgType.Audio | MsgType.Video | MsgType.File {
const kind = mediaKindFromMime(contentType ?? "");
switch (kind) {
case "image":
return MsgType.Image;
case "audio":
return MsgType.Audio;
case "video":
return MsgType.Video;
default:
return MsgType.File;
}
}
function resolveMatrixVoiceDecision(opts: {
wantsVoice: boolean;
contentType?: string;
fileName?: string;
}): { useVoice: boolean } {
if (!opts.wantsVoice) return { useVoice: false };
if (isVoiceCompatibleAudio({ contentType: opts.contentType, fileName: opts.fileName })) {
return { useVoice: true };
}
return { useVoice: false };
}
const THUMBNAIL_MAX_SIDE = 800;
const THUMBNAIL_QUALITY = 80;
async function prepareImageInfo(params: {
buffer: Buffer;
client: MatrixClient;
}): Promise<MatrixImageInfo | undefined> {
const meta = await getImageMetadata(params.buffer).catch(() => null);
if (!meta) return undefined;
const imageInfo: MatrixImageInfo = { w: meta.width, h: meta.height };
const maxDim = Math.max(meta.width, meta.height);
if (maxDim > THUMBNAIL_MAX_SIDE) {
try {
const thumbBuffer = await resizeToJpeg({
buffer: params.buffer,
maxSide: THUMBNAIL_MAX_SIDE,
quality: THUMBNAIL_QUALITY,
withoutEnlargement: true,
});
const thumbMeta = await getImageMetadata(thumbBuffer).catch(() => null);
const thumbUri = await params.client.uploadContent(thumbBuffer as MatrixUploadContent, {
type: "image/jpeg",
name: "thumbnail.jpg",
});
imageInfo.thumbnail_url = thumbUri.content_uri;
if (thumbMeta) {
imageInfo.thumbnail_info = {
w: thumbMeta.width,
h: thumbMeta.height,
mimetype: "image/jpeg",
size: thumbBuffer.byteLength,
};
}
} catch {
// Thumbnail generation failed, continue without it
}
}
return imageInfo;
}
async function uploadFile(
client: MatrixClient,
file: MatrixUploadContent | Buffer,
@@ -238,14 +352,10 @@ export async function sendMessageMatrix(
const textLimit = resolveTextChunkLimit(cfg, "matrix");
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
const chunks = chunkMarkdownText(trimmedMessage, chunkLimit);
const rawThreadId = opts.threadId;
const threadId =
rawThreadId !== undefined && rawThreadId !== null
? String(rawThreadId).trim()
: null;
const threadId = normalizeThreadId(opts.threadId);
const relation = threadId ? undefined : buildReplyRelation(opts.replyToId);
const sendContent = (content: RoomMessageEventContent) =>
client.sendMessage(roomId, threadId ?? undefined, content);
threadId ? client.sendMessage(roomId, threadId, content) : client.sendMessage(roomId, content);
let lastMessageId = "";
if (opts.mediaUrl) {
@@ -255,9 +365,17 @@ export async function sendMessageMatrix(
contentType: media.contentType,
filename: media.fileName,
});
const msgtype = MsgType.File;
const baseMsgType = resolveMatrixMsgType(media.contentType, media.fileName);
const { useVoice } = resolveMatrixVoiceDecision({
wantsVoice: opts.audioAsVoice === true,
contentType: media.contentType,
fileName: media.fileName,
});
const msgtype = useVoice ? MsgType.Audio : baseMsgType;
const isImage = msgtype === MsgType.Image;
const imageInfo = isImage ? await prepareImageInfo({ buffer: media.buffer, client }) : undefined;
const [firstChunk, ...rest] = chunks;
const body = firstChunk ?? media.fileName ?? "(file)";
const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)");
const content = buildMediaContent({
msgtype,
body,
@@ -266,10 +384,13 @@ export async function sendMessageMatrix(
mimetype: media.contentType,
size: media.buffer.byteLength,
relation,
isVoice: useVoice,
imageInfo,
});
const response = await sendContent(content);
lastMessageId = response.event_id ?? lastMessageId;
for (const chunk of rest) {
const textChunks = useVoice ? chunks : rest;
for (const chunk of textChunks) {
const text = chunk.trim();
if (!text) continue;
const followup = buildTextContent(text);
@@ -316,17 +437,19 @@ export async function sendPollMatrix(
try {
const roomId = await resolveMatrixRoomId(client, to);
const pollContent = buildPollStartContent(poll);
const rawThreadId = opts.threadId;
const threadId =
rawThreadId !== undefined && rawThreadId !== null
? String(rawThreadId).trim()
: null;
const response = await client.sendEvent(
roomId,
threadId ?? undefined,
M_POLL_START as EventType.RoomMessage,
pollContent as unknown as RoomMessageEventContent,
);
const threadId = normalizeThreadId(opts.threadId);
const response = threadId
? await client.sendEvent(
roomId,
threadId,
M_POLL_START,
pollContent,
)
: await client.sendEvent(
roomId,
M_POLL_START,
pollContent,
);
return {
eventId: response.event_id ?? "unknown",
+100
View File
@@ -3,8 +3,10 @@ import type {
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
} from "../../../src/channels/plugins/onboarding-types.js";
import { promptChannelAccessConfig } from "../../../src/channels/plugins/onboarding/channel-access.js";
import { formatDocsLink } from "../../../src/terminal/links.js";
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
import { listMatrixDirectoryGroupsLive } from "./directory-live.js";
import { resolveMatrixAccount } from "./matrix/accounts.js";
import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js";
import type { CoreConfig, DmPolicy } from "./types.js";
@@ -83,6 +85,35 @@ async function promptMatrixAllowFrom(params: {
};
}
function setMatrixGroupPolicy(cfg: CoreConfig, groupPolicy: "open" | "allowlist" | "disabled") {
return {
...cfg,
channels: {
...cfg.channels,
matrix: {
...cfg.channels?.matrix,
enabled: true,
groupPolicy,
},
},
};
}
function setMatrixRoomAllowlist(cfg: CoreConfig, roomKeys: string[]) {
const rooms = Object.fromEntries(roomKeys.map((key) => [key, { allow: true }]));
return {
...cfg,
channels: {
...cfg.channels,
matrix: {
...cfg.channels?.matrix,
enabled: true,
rooms,
},
},
};
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Matrix",
channel,
@@ -254,6 +285,75 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
next = await promptMatrixAllowFrom({ cfg: next, prompter });
}
const accessConfig = await promptChannelAccessConfig({
prompter,
label: "Matrix rooms",
currentPolicy: next.channels?.matrix?.groupPolicy ?? "allowlist",
currentEntries: Object.keys(next.channels?.matrix?.rooms ?? {}),
placeholder: "!roomId:server, #alias:server, Project Room",
updatePrompt: Boolean(next.channels?.matrix?.rooms),
});
if (accessConfig) {
if (accessConfig.policy !== "allowlist") {
next = setMatrixGroupPolicy(next, accessConfig.policy);
} else {
let roomKeys = accessConfig.entries;
if (accessConfig.entries.length > 0) {
try {
const resolvedIds: string[] = [];
const unresolved: string[] = [];
for (const entry of accessConfig.entries) {
const trimmed = entry.trim();
if (!trimmed) continue;
const cleaned = trimmed.replace(/^(room|channel):/i, "").trim();
if (cleaned.startsWith("!") && cleaned.includes(":")) {
resolvedIds.push(cleaned);
continue;
}
const matches = await listMatrixDirectoryGroupsLive({
cfg: next,
query: trimmed,
limit: 10,
});
const exact = matches.find(
(match) => (match.name ?? "").toLowerCase() === trimmed.toLowerCase(),
);
const best = exact ?? matches[0];
if (best?.id) {
resolvedIds.push(best.id);
} else {
unresolved.push(entry);
}
}
roomKeys = [
...resolvedIds,
...unresolved.map((entry) => entry.trim()).filter(Boolean),
];
if (resolvedIds.length > 0 || unresolved.length > 0) {
await prompter.note(
[
resolvedIds.length > 0 ? `Resolved: ${resolvedIds.join(", ")}` : undefined,
unresolved.length > 0
? `Unresolved (kept as typed): ${unresolved.join(", ")}`
: undefined,
]
.filter(Boolean)
.join("\n"),
"Matrix rooms",
);
}
} catch (err) {
await prompter.note(
`Room lookup failed; keeping entries as typed. ${String(err)}`,
"Matrix rooms",
);
}
}
next = setMatrixGroupPolicy(next, "allowlist");
next = setMatrixRoomAllowlist(next, roomKeys);
}
}
return { cfg: next };
},
dmPolicy,
+89
View File
@@ -0,0 +1,89 @@
import type {
ChannelDirectoryEntry,
ChannelResolveKind,
ChannelResolveResult,
} from "../../../src/channels/plugins/types.js";
import type { RuntimeEnv } from "../../../src/runtime.js";
import {
listMatrixDirectoryGroupsLive,
listMatrixDirectoryPeersLive,
} from "./directory-live.js";
function pickBestGroupMatch(
matches: ChannelDirectoryEntry[],
query: string,
): ChannelDirectoryEntry | undefined {
if (matches.length === 0) return undefined;
const normalized = query.trim().toLowerCase();
if (normalized) {
const exact = matches.find((match) => {
const name = match.name?.trim().toLowerCase();
const handle = match.handle?.trim().toLowerCase();
const id = match.id.trim().toLowerCase();
return name === normalized || handle === normalized || id === normalized;
});
if (exact) return exact;
}
return matches[0];
}
export async function resolveMatrixTargets(params: {
cfg: unknown;
inputs: string[];
kind: ChannelResolveKind;
runtime?: RuntimeEnv;
}): Promise<ChannelResolveResult[]> {
const results: ChannelResolveResult[] = [];
for (const input of params.inputs) {
const trimmed = input.trim();
if (!trimmed) {
results.push({ input, resolved: false, note: "empty input" });
continue;
}
if (params.kind === "user") {
if (trimmed.startsWith("@") && trimmed.includes(":")) {
results.push({ input, resolved: true, id: trimmed });
continue;
}
try {
const matches = await listMatrixDirectoryPeersLive({
cfg: params.cfg,
query: trimmed,
limit: 5,
});
const best = matches[0];
results.push({
input,
resolved: Boolean(best?.id),
id: best?.id,
name: best?.name,
note: matches.length > 1 ? "multiple matches; chose first" : undefined,
});
} catch (err) {
params.runtime?.error?.(`matrix resolve failed: ${String(err)}`);
results.push({ input, resolved: false, note: "lookup failed" });
}
continue;
}
try {
const matches = await listMatrixDirectoryGroupsLive({
cfg: params.cfg,
query: trimmed,
limit: 5,
});
const best = pickBestGroupMatch(matches, trimmed);
results.push({
input,
resolved: Boolean(best?.id),
id: best?.id,
name: best?.name,
note: matches.length > 1 ? "multiple matches; chose first" : undefined,
});
} catch (err) {
params.runtime?.error?.(`matrix resolve failed: ${String(err)}`);
results.push({ input, resolved: false, note: "lookup failed" });
}
}
return results;
}
+10
View File
@@ -1,5 +1,15 @@
# Changelog
## 2026.1.17-1
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.17
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.16
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/msteams",
"version": "2026.1.16",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot Microsoft Teams channel plugin",
"clawdbot": {
+141 -17
View File
@@ -7,8 +7,21 @@ import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
import { msteamsOnboardingAdapter } from "./onboarding.js";
import { msteamsOutbound } from "./outbound.js";
import { probeMSTeams } from "./probe.js";
import {
normalizeMSTeamsMessagingTarget,
normalizeMSTeamsUserInput,
parseMSTeamsConversationId,
parseMSTeamsTeamChannelInput,
resolveMSTeamsChannelAllowlist,
resolveMSTeamsUserAllowlist,
} from "./resolve-allowlist.js";
import { sendMessageMSTeams } from "./send.js";
import { resolveMSTeamsCredentials } from "./token.js";
import {
listMSTeamsDirectoryGroupsLive,
listMSTeamsDirectoryPeersLive,
} from "./directory-live.js";
type ResolvedMSTeamsAccount = {
accountId: string;
@@ -27,21 +40,6 @@ const meta = {
order: 60,
} as const;
function normalizeMSTeamsMessagingTarget(raw: string): string | undefined {
let trimmed = raw.trim();
if (!trimmed) return undefined;
if (/^(msteams|teams):/i.test(trimmed)) {
trimmed = trimmed.replace(/^(msteams|teams):/i, "");
}
if (/^conversation:/i.test(trimmed)) {
return `conversation:${trimmed.slice("conversation:".length).trim()}`;
}
if (/^user:/i.test(trimmed)) {
return `user:${trimmed.slice("user:".length).trim()}`;
}
return trimmed;
}
export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
id: "msteams",
meta: {
@@ -111,7 +109,8 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
},
security: {
collectWarnings: ({ cfg }) => {
const groupPolicy = cfg.channels?.msteams?.groupPolicy ?? "allowlist";
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = cfg.channels?.msteams?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
return [
`- MS Teams groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.msteams.groupPolicy="allowlist" + channels.msteams.groupAllowFrom to restrict senders.`,
@@ -188,6 +187,129 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "group", id }) as const);
},
listPeersLive: async ({ cfg, query, limit }) =>
listMSTeamsDirectoryPeersLive({ cfg, query, limit }),
listGroupsLive: async ({ cfg, query, limit }) =>
listMSTeamsDirectoryGroupsLive({ cfg, query, limit }),
},
resolver: {
resolveTargets: async ({ cfg, inputs, kind, runtime }) => {
const results = inputs.map((input) => ({
input,
resolved: false,
id: undefined as string | undefined,
name: undefined as string | undefined,
note: undefined as string | undefined,
}));
const stripPrefix = (value: string) =>
normalizeMSTeamsUserInput(value);
if (kind === "user") {
const pending: Array<{ input: string; query: string; index: number }> = [];
results.forEach((entry, index) => {
const trimmed = entry.input.trim();
if (!trimmed) {
entry.note = "empty input";
return;
}
const cleaned = stripPrefix(trimmed);
if (/^[0-9a-fA-F-]{16,}$/.test(cleaned) || cleaned.includes("@")) {
entry.resolved = true;
entry.id = cleaned;
return;
}
pending.push({ input: entry.input, query: cleaned, index });
});
if (pending.length > 0) {
try {
const resolved = await resolveMSTeamsUserAllowlist({
cfg,
entries: pending.map((entry) => entry.query),
});
resolved.forEach((entry, idx) => {
const target = results[pending[idx]?.index ?? -1];
if (!target) return;
target.resolved = entry.resolved;
target.id = entry.id;
target.name = entry.name;
target.note = entry.note;
});
} catch (err) {
runtime.error?.(`msteams resolve failed: ${String(err)}`);
pending.forEach(({ index }) => {
const entry = results[index];
if (entry) entry.note = "lookup failed";
});
}
}
return results;
}
const pending: Array<{ input: string; query: string; index: number }> = [];
results.forEach((entry, index) => {
const trimmed = entry.input.trim();
if (!trimmed) {
entry.note = "empty input";
return;
}
const conversationId = parseMSTeamsConversationId(trimmed);
if (conversationId !== null) {
entry.resolved = Boolean(conversationId);
entry.id = conversationId || undefined;
entry.note = conversationId ? "conversation id" : "empty conversation id";
return;
}
const parsed = parseMSTeamsTeamChannelInput(trimmed);
if (!parsed.team) {
entry.note = "missing team";
return;
}
const query = parsed.channel ? `${parsed.team}/${parsed.channel}` : parsed.team;
pending.push({ input: entry.input, query, index });
});
if (pending.length > 0) {
try {
const resolved = await resolveMSTeamsChannelAllowlist({
cfg,
entries: pending.map((entry) => entry.query),
});
resolved.forEach((entry, idx) => {
const target = results[pending[idx]?.index ?? -1];
if (!target) return;
if (!entry.resolved || !entry.teamId) {
target.resolved = false;
target.note = entry.note;
return;
}
target.resolved = true;
if (entry.channelId) {
target.id = `${entry.teamId}/${entry.channelId}`;
target.name =
entry.channelName && entry.teamName
? `${entry.teamName}/${entry.channelName}`
: entry.channelName ?? entry.teamName;
} else {
target.id = entry.teamId;
target.name = entry.teamName;
target.note = "team id";
}
if (entry.note) target.note = entry.note;
});
} catch (err) {
runtime.error?.(`msteams resolve failed: ${String(err)}`);
pending.forEach(({ index }) => {
const entry = results[index];
if (entry) entry.note = "lookup failed";
});
}
}
return results;
},
},
actions: {
listActions: ({ cfg }) => {
@@ -218,7 +340,8 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
buildAccountSnapshot: ({ account, runtime }) => ({
probeAccount: async ({ cfg }) => await probeMSTeams(cfg.channels?.msteams),
buildAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId,
enabled: account.enabled,
configured: account.configured,
@@ -227,6 +350,7 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
port: runtime?.port ?? null,
probe,
}),
},
gateway: {
+179
View File
@@ -0,0 +1,179 @@
import type { ChannelDirectoryEntry } from "../../../src/channels/plugins/types.js";
import { GRAPH_ROOT } from "./attachments/shared.js";
import { loadMSTeamsSdkWithAuth } from "./sdk.js";
import { resolveMSTeamsCredentials } from "./token.js";
type GraphUser = {
id?: string;
displayName?: string;
userPrincipalName?: string;
mail?: string;
};
type GraphGroup = {
id?: string;
displayName?: string;
};
type GraphChannel = {
id?: string;
displayName?: string;
};
type GraphResponse<T> = { value?: T[] };
function readAccessToken(value: unknown): string | null {
if (typeof value === "string") return value;
if (value && typeof value === "object") {
const token =
(value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token;
return typeof token === "string" ? token : null;
}
return null;
}
function normalizeQuery(value?: string | null): string {
return value?.trim() ?? "";
}
function escapeOData(value: string): string {
return value.replace(/'/g, "''");
}
async function fetchGraphJson<T>(params: {
token: string;
path: string;
headers?: Record<string, string>;
}): Promise<T> {
const res = await fetch(`${GRAPH_ROOT}${params.path}`, {
headers: {
Authorization: `Bearer ${params.token}`,
...(params.headers ?? {}),
},
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Graph ${params.path} failed (${res.status}): ${text || "unknown error"}`);
}
return (await res.json()) as T;
}
async function resolveGraphToken(cfg: unknown): Promise<string> {
const creds = resolveMSTeamsCredentials((cfg as { channels?: { msteams?: unknown } })?.channels?.msteams);
if (!creds) throw new Error("MS Teams credentials missing");
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
const tokenProvider = new sdk.MsalTokenProvider(authConfig);
const token = await tokenProvider.getAccessToken("https://graph.microsoft.com/.default");
const accessToken = readAccessToken(token);
if (!accessToken) throw new Error("MS Teams graph token unavailable");
return accessToken;
}
async function listTeamsByName(token: string, query: string): Promise<GraphGroup[]> {
const escaped = escapeOData(query);
const filter = `resourceProvisioningOptions/Any(x:x eq 'Team') and startsWith(displayName,'${escaped}')`;
const path = `/groups?$filter=${encodeURIComponent(filter)}&$select=id,displayName`;
const res = await fetchGraphJson<GraphResponse<GraphGroup>>({ token, path });
return res.value ?? [];
}
async function listChannelsForTeam(token: string, teamId: string): Promise<GraphChannel[]> {
const path = `/teams/${encodeURIComponent(teamId)}/channels?$select=id,displayName`;
const res = await fetchGraphJson<GraphResponse<GraphChannel>>({ token, path });
return res.value ?? [];
}
export async function listMSTeamsDirectoryPeersLive(params: {
cfg: unknown;
query?: string | null;
limit?: number | null;
}): Promise<ChannelDirectoryEntry[]> {
const query = normalizeQuery(params.query);
if (!query) return [];
const token = await resolveGraphToken(params.cfg);
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20;
let users: GraphUser[] = [];
if (query.includes("@")) {
const escaped = escapeOData(query);
const filter = `(mail eq '${escaped}' or userPrincipalName eq '${escaped}')`;
const path = `/users?$filter=${encodeURIComponent(filter)}&$select=id,displayName,mail,userPrincipalName`;
const res = await fetchGraphJson<GraphResponse<GraphUser>>({ token, path });
users = res.value ?? [];
} else {
const path = `/users?$search=${encodeURIComponent(`"displayName:${query}"`)}&$select=id,displayName,mail,userPrincipalName&$top=${limit}`;
const res = await fetchGraphJson<GraphResponse<GraphUser>>({
token,
path,
headers: { ConsistencyLevel: "eventual" },
});
users = res.value ?? [];
}
return users
.map((user) => {
const id = user.id?.trim();
if (!id) return null;
const name = user.displayName?.trim();
const handle = user.userPrincipalName?.trim() || user.mail?.trim();
return {
kind: "user",
id: `user:${id}`,
name: name || undefined,
handle: handle ? `@${handle}` : undefined,
raw: user,
} satisfies ChannelDirectoryEntry;
})
.filter(Boolean) as ChannelDirectoryEntry[];
}
export async function listMSTeamsDirectoryGroupsLive(params: {
cfg: unknown;
query?: string | null;
limit?: number | null;
}): Promise<ChannelDirectoryEntry[]> {
const rawQuery = normalizeQuery(params.query);
if (!rawQuery) return [];
const token = await resolveGraphToken(params.cfg);
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20;
const [teamQuery, channelQuery] = rawQuery.includes("/")
? rawQuery.split("/", 2).map((part) => part.trim()).filter(Boolean)
: [rawQuery, null];
const teams = await listTeamsByName(token, teamQuery);
const results: ChannelDirectoryEntry[] = [];
for (const team of teams) {
const teamId = team.id?.trim();
if (!teamId) continue;
const teamName = team.displayName?.trim() || teamQuery;
if (!channelQuery) {
results.push({
kind: "group",
id: `team:${teamId}`,
name: teamName,
handle: teamName ? `#${teamName}` : undefined,
raw: team,
});
if (results.length >= limit) return results;
continue;
}
const channels = await listChannelsForTeam(token, teamId);
for (const channel of channels) {
const name = channel.displayName?.trim();
if (!name) continue;
if (!name.toLowerCase().includes(channelQuery.toLowerCase())) continue;
results.push({
kind: "group",
id: `conversation:${channel.id}`,
name: `${teamName}/${name}`,
handle: `#${name}`,
raw: channel,
});
if (results.length >= limit) return results;
}
}
return results;
}
@@ -176,7 +176,11 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
}
}
const groupPolicy = !isDirectMessage && msteamsCfg ? (msteamsCfg.groupPolicy ?? "allowlist") : "disabled";
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy =
!isDirectMessage && msteamsCfg
? (msteamsCfg.groupPolicy ?? defaultGroupPolicy ?? "allowlist")
: "disabled";
const groupAllowFrom =
!isDirectMessage && msteamsCfg
? (msteamsCfg.groupAllowFrom ??
@@ -186,6 +190,16 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
!isDirectMessage && msteamsCfg
? [...groupAllowFrom.map((v) => String(v)), ...storedAllowFrom]
: [];
const teamId = activity.channelData?.team?.id;
const teamName = activity.channelData?.team?.name;
const channelName = activity.channelData?.channel?.name;
const channelGate = resolveMSTeamsRouteConfig({
cfg: msteamsCfg,
teamId,
teamName,
conversationId,
channelName,
});
if (!isDirectMessage && msteamsCfg) {
if (groupPolicy === "disabled") {
@@ -196,25 +210,33 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
}
if (groupPolicy === "allowlist") {
if (effectiveGroupAllowFrom.length === 0) {
log.debug("dropping group message (groupPolicy: allowlist, no groupAllowFrom)", {
if (channelGate.allowlistConfigured && !channelGate.allowed) {
log.debug("dropping group message (not in team/channel allowlist)", {
conversationId,
});
return;
}
const allowed = isMSTeamsGroupAllowed({
groupPolicy,
allowFrom: effectiveGroupAllowFrom,
senderId,
senderName,
});
if (!allowed) {
log.debug("dropping group message (not in groupAllowFrom)", {
sender: senderId,
label: senderName,
if (effectiveGroupAllowFrom.length === 0 && !channelGate.allowlistConfigured) {
log.debug("dropping group message (groupPolicy: allowlist, no allowlist)", {
conversationId,
});
return;
}
if (effectiveGroupAllowFrom.length > 0) {
const allowed = isMSTeamsGroupAllowed({
groupPolicy,
allowFrom: effectiveGroupAllowFrom,
senderId,
senderName,
});
if (!allowed) {
log.debug("dropping group message (not in groupAllowFrom)", {
sender: senderId,
label: senderName,
});
return;
}
}
}
}
@@ -244,7 +266,6 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
// Build conversation reference for proactive replies.
const agent = activity.recipient;
const teamId = activity.channelData?.team?.id;
const conversationRef: StoredConversationReference = {
activityId: activity.id,
user: { id: from.id, name: from.name, aadObjectId: from.aadObjectId },
@@ -326,11 +347,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
});
const channelId = conversationId;
const { teamConfig, channelConfig } = resolveMSTeamsRouteConfig({
cfg: msteamsCfg,
teamId,
conversationId: channelId,
});
const { teamConfig, channelConfig } = channelGate;
const { requireMention, replyStyle } = resolveMSTeamsReplyPolicy({
isDirectMessage,
globalConfig: msteamsCfg,
+143 -2
View File
@@ -1,5 +1,6 @@
import type { Request, Response } from "express";
import { resolveTextChunkLimit } from "../../../src/auto-reply/chunk.js";
import { mergeAllowlist, summarizeMapping } from "../../../src/channels/allowlists/resolve-utils.js";
import type { ClawdbotConfig } from "../../../src/config/types.js";
import { getChildLogger } from "../../../src/logging.js";
import type { RuntimeEnv } from "../../../src/runtime.js";
@@ -9,6 +10,10 @@ import { formatUnknownError } from "./errors.js";
import type { MSTeamsAdapter } from "./messenger.js";
import { registerMSTeamsHandlers } from "./monitor-handler.js";
import { createMSTeamsPollStoreFs, type MSTeamsPollStore } from "./polls.js";
import {
resolveMSTeamsChannelAllowlist,
resolveMSTeamsUserAllowlist,
} from "./resolve-allowlist.js";
import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js";
import { resolveMSTeamsCredentials } from "./token.js";
@@ -30,8 +35,8 @@ export type MonitorMSTeamsResult = {
export async function monitorMSTeamsProvider(
opts: MonitorMSTeamsOpts,
): Promise<MonitorMSTeamsResult> {
const cfg = opts.cfg;
const msteamsCfg = cfg.channels?.msteams;
let cfg = opts.cfg;
let msteamsCfg = cfg.channels?.msteams;
if (!msteamsCfg?.enabled) {
log.debug("msteams provider disabled");
return { app: null, shutdown: async () => {} };
@@ -52,6 +57,142 @@ export async function monitorMSTeamsProvider(
},
};
let allowFrom = msteamsCfg.allowFrom;
let groupAllowFrom = msteamsCfg.groupAllowFrom;
let teamsConfig = msteamsCfg.teams;
const cleanAllowEntry = (entry: string) =>
entry
.replace(/^(msteams|teams):/i, "")
.replace(/^user:/i, "")
.trim();
const resolveAllowlistUsers = async (label: string, entries: string[]) => {
if (entries.length === 0) return { additions: [], unresolved: [] };
const resolved = await resolveMSTeamsUserAllowlist({ cfg, entries });
const additions: string[] = [];
const unresolved: string[] = [];
for (const entry of resolved) {
if (entry.resolved && entry.id) {
additions.push(entry.id);
} else {
unresolved.push(entry.input);
}
}
const mapping = resolved
.filter((entry) => entry.resolved && entry.id)
.map((entry) => `${entry.input}→${entry.id}`);
summarizeMapping(label, mapping, unresolved, runtime);
return { additions, unresolved };
};
try {
const allowEntries =
allowFrom?.map((entry) => cleanAllowEntry(String(entry))).filter(
(entry) => entry && entry !== "*",
) ?? [];
if (allowEntries.length > 0) {
const { additions } = await resolveAllowlistUsers("msteams users", allowEntries);
allowFrom = mergeAllowlist({ existing: allowFrom, additions });
}
if (Array.isArray(groupAllowFrom) && groupAllowFrom.length > 0) {
const groupEntries = groupAllowFrom
.map((entry) => cleanAllowEntry(String(entry)))
.filter((entry) => entry && entry !== "*");
if (groupEntries.length > 0) {
const { additions } = await resolveAllowlistUsers("msteams group users", groupEntries);
groupAllowFrom = mergeAllowlist({ existing: groupAllowFrom, additions });
}
}
if (teamsConfig && Object.keys(teamsConfig).length > 0) {
const entries: Array<{ input: string; teamKey: string; channelKey?: string }> = [];
for (const [teamKey, teamCfg] of Object.entries(teamsConfig)) {
if (teamKey === "*") continue;
const channels = teamCfg?.channels ?? {};
const channelKeys = Object.keys(channels).filter((key) => key !== "*");
if (channelKeys.length === 0) {
entries.push({ input: teamKey, teamKey });
continue;
}
for (const channelKey of channelKeys) {
entries.push({
input: `${teamKey}/${channelKey}`,
teamKey,
channelKey,
});
}
}
if (entries.length > 0) {
const resolved = await resolveMSTeamsChannelAllowlist({
cfg,
entries: entries.map((entry) => entry.input),
});
const mapping: string[] = [];
const unresolved: string[] = [];
const nextTeams = { ...(teamsConfig ?? {}) };
resolved.forEach((entry, idx) => {
const source = entries[idx];
if (!source) return;
const sourceTeam = teamsConfig?.[source.teamKey] ?? {};
if (!entry.resolved || !entry.teamId) {
unresolved.push(entry.input);
return;
}
mapping.push(
entry.channelId
? `${entry.input}→${entry.teamId}/${entry.channelId}`
: `${entry.input}→${entry.teamId}`,
);
const existing = nextTeams[entry.teamId] ?? {};
const mergedChannels = {
...(sourceTeam.channels ?? {}),
...(existing.channels ?? {}),
};
const mergedTeam = { ...sourceTeam, ...existing, channels: mergedChannels };
nextTeams[entry.teamId] = mergedTeam;
if (source.channelKey && entry.channelId) {
const sourceChannel = sourceTeam.channels?.[source.channelKey];
if (sourceChannel) {
nextTeams[entry.teamId] = {
...mergedTeam,
channels: {
...mergedChannels,
[entry.channelId]: {
...sourceChannel,
...(mergedChannels?.[entry.channelId] ?? {}),
},
},
};
}
}
});
teamsConfig = nextTeams;
summarizeMapping("msteams channels", mapping, unresolved, runtime);
}
}
} catch (err) {
runtime.log?.(`msteams resolve failed; using config entries. ${String(err)}`);
}
msteamsCfg = {
...msteamsCfg,
allowFrom,
groupAllowFrom,
teams: teamsConfig,
};
cfg = {
...cfg,
channels: {
...cfg.channels,
msteams: msteamsCfg,
},
};
const port = msteamsCfg.webhook?.port ?? 3978;
const textLimit = resolveTextChunkLimit(cfg, "msteams");
const MB = 1024 * 1024;
+140
View File
@@ -7,9 +7,14 @@ import type {
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
} from "../../../src/channels/plugins/onboarding-types.js";
import { promptChannelAccessConfig } from "../../../src/channels/plugins/onboarding/channel-access.js";
import { addWildcardAllowFrom } from "../../../src/channels/plugins/onboarding/helpers.js";
import { resolveMSTeamsCredentials } from "./token.js";
import {
parseMSTeamsTeamEntry,
resolveMSTeamsChannelAllowlist,
} from "./resolve-allowlist.js";
const channel = "msteams" as const;
@@ -44,6 +49,54 @@ async function noteMSTeamsCredentialHelp(prompter: WizardPrompter): Promise<void
);
}
function setMSTeamsGroupPolicy(
cfg: ClawdbotConfig,
groupPolicy: "open" | "allowlist" | "disabled",
): ClawdbotConfig {
return {
...cfg,
channels: {
...cfg.channels,
msteams: {
...cfg.channels?.msteams,
enabled: true,
groupPolicy,
},
},
};
}
function setMSTeamsTeamsAllowlist(
cfg: ClawdbotConfig,
entries: Array<{ teamKey: string; channelKey?: string }>,
): ClawdbotConfig {
const baseTeams = cfg.channels?.msteams?.teams ?? {};
const teams: Record<string, { channels?: Record<string, unknown> }> = { ...baseTeams };
for (const entry of entries) {
const teamKey = entry.teamKey;
if (!teamKey) continue;
const existing = teams[teamKey] ?? {};
if (entry.channelKey) {
const channels = { ...(existing.channels ?? {}) };
channels[entry.channelKey] = channels[entry.channelKey] ?? {};
teams[teamKey] = { ...existing, channels };
} else {
teams[teamKey] = existing;
}
}
return {
...cfg,
channels: {
...cfg.channels,
msteams: {
...cfg.channels?.msteams,
enabled: true,
teams,
},
},
};
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "MS Teams",
channel,
@@ -184,6 +237,93 @@ export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = {
};
}
const currentEntries = Object.entries(next.channels?.msteams?.teams ?? {}).flatMap(
([teamKey, value]) => {
const channels = value?.channels ?? {};
const channelKeys = Object.keys(channels);
if (channelKeys.length === 0) return [teamKey];
return channelKeys.map((channelKey) => `${teamKey}/${channelKey}`);
},
);
const accessConfig = await promptChannelAccessConfig({
prompter,
label: "MS Teams channels",
currentPolicy: next.channels?.msteams?.groupPolicy ?? "allowlist",
currentEntries,
placeholder: "Team Name/Channel Name, teamId/conversationId",
updatePrompt: Boolean(next.channels?.msteams?.teams),
});
if (accessConfig) {
if (accessConfig.policy !== "allowlist") {
next = setMSTeamsGroupPolicy(next, accessConfig.policy);
} else {
let entries = accessConfig.entries
.map((entry) => parseMSTeamsTeamEntry(entry))
.filter(Boolean) as Array<{ teamKey: string; channelKey?: string }>;
if (accessConfig.entries.length > 0 && resolveMSTeamsCredentials(next.channels?.msteams)) {
try {
const resolved = await resolveMSTeamsChannelAllowlist({
cfg: next,
entries: accessConfig.entries,
});
const resolvedChannels = resolved.filter(
(entry) => entry.resolved && entry.teamId && entry.channelId,
);
const resolvedTeams = resolved.filter(
(entry) => entry.resolved && entry.teamId && !entry.channelId,
);
const unresolved = resolved
.filter((entry) => !entry.resolved)
.map((entry) => entry.input);
entries = [
...resolvedChannels.map((entry) => ({
teamKey: entry.teamId as string,
channelKey: entry.channelId as string,
})),
...resolvedTeams.map((entry) => ({
teamKey: entry.teamId as string,
})),
...unresolved
.map((entry) => parseMSTeamsTeamEntry(entry))
.filter(Boolean),
] as Array<{ teamKey: string; channelKey?: string }>;
if (resolvedChannels.length > 0 || resolvedTeams.length > 0 || unresolved.length > 0) {
const summary: string[] = [];
if (resolvedChannels.length > 0) {
summary.push(
`Resolved channels: ${resolvedChannels
.map((entry) => entry.channelId)
.filter(Boolean)
.join(", ")}`,
);
}
if (resolvedTeams.length > 0) {
summary.push(
`Resolved teams: ${resolvedTeams
.map((entry) => entry.teamId)
.filter(Boolean)
.join(", ")}`,
);
}
if (unresolved.length > 0) {
summary.push(`Unresolved (kept as typed): ${unresolved.join(", ")}`);
}
await prompter.note(summary.join("\n"), "MS Teams channels");
}
} catch (err) {
await prompter.note(
`Channel lookup failed; keeping entries as typed. ${String(err)}`,
"MS Teams channels",
);
}
}
next = setMSTeamsGroupPolicy(next, "allowlist");
next = setMSTeamsTeamsAllowlist(next, entries);
}
}
return { cfg: next, accountId: DEFAULT_ACCOUNT_ID };
},
dmPolicy,
+40
View File
@@ -29,6 +29,8 @@ describe("msteams policy", () => {
expect(res.teamConfig?.requireMention).toBe(false);
expect(res.channelConfig?.requireMention).toBe(true);
expect(res.allowlistConfigured).toBe(true);
expect(res.allowed).toBe(true);
});
it("returns undefined configs when teamId is missing", () => {
@@ -43,6 +45,32 @@ describe("msteams policy", () => {
});
expect(res.teamConfig).toBeUndefined();
expect(res.channelConfig).toBeUndefined();
expect(res.allowlistConfigured).toBe(true);
expect(res.allowed).toBe(false);
});
it("matches team and channel by name", () => {
const cfg: MSTeamsConfig = {
teams: {
"My Team": {
requireMention: true,
channels: {
"General Chat": { requireMention: false },
},
},
},
};
const res = resolveMSTeamsRouteConfig({
cfg,
teamName: "My Team",
channelName: "General Chat",
conversationId: "ignored",
});
expect(res.teamConfig?.requireMention).toBe(true);
expect(res.channelConfig?.requireMention).toBe(false);
expect(res.allowed).toBe(true);
});
});
@@ -89,6 +117,18 @@ describe("msteams policy", () => {
});
});
it("inherits team mention settings when channel config is missing", () => {
const policy = resolveMSTeamsReplyPolicy({
isDirectMessage: false,
globalConfig: { requireMention: true },
teamConfig: { requireMention: false },
});
expect(policy).toEqual({
requireMention: false,
replyStyle: "top-level",
});
});
it("uses explicit replyStyle even when requireMention defaults would differ", () => {
const policy = resolveMSTeamsReplyPolicy({
isDirectMessage: false,
+58 -4
View File
@@ -9,19 +9,73 @@ import type {
export type MSTeamsResolvedRouteConfig = {
teamConfig?: MSTeamsTeamConfig;
channelConfig?: MSTeamsChannelConfig;
allowlistConfigured: boolean;
allowed: boolean;
teamKey?: string;
channelKey?: string;
};
export function resolveMSTeamsRouteConfig(params: {
cfg?: MSTeamsConfig;
teamId?: string | null | undefined;
teamName?: string | null | undefined;
conversationId?: string | null | undefined;
channelName?: string | null | undefined;
}): MSTeamsResolvedRouteConfig {
const teamId = params.teamId?.trim();
const teamName = params.teamName?.trim();
const conversationId = params.conversationId?.trim();
const teamConfig = teamId ? params.cfg?.teams?.[teamId] : undefined;
const channelConfig =
teamConfig && conversationId ? teamConfig.channels?.[conversationId] : undefined;
return { teamConfig, channelConfig };
const channelName = params.channelName?.trim();
const teams = params.cfg?.teams ?? {};
const teamKeys = Object.keys(teams);
const allowlistConfigured = teamKeys.length > 0;
const normalize = (value: string) =>
value
.trim()
.toLowerCase()
.replace(/^#/, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
let teamKey: string | undefined;
if (teamId && teams[teamId]) teamKey = teamId;
if (!teamKey && teamName) {
const slug = normalize(teamName);
if (slug) {
teamKey = teamKeys.find((key) => normalize(key) === slug);
}
}
if (!teamKey && teams["*"]) teamKey = "*";
const teamConfig = teamKey ? teams[teamKey] : undefined;
const channels = teamConfig?.channels ?? {};
const channelKeys = Object.keys(channels);
let channelKey: string | undefined;
if (conversationId && channels[conversationId]) channelKey = conversationId;
if (!channelKey && channelName) {
const slug = normalize(channelName);
if (slug) {
channelKey = channelKeys.find((key) => normalize(key) === slug);
}
}
if (!channelKey && channels["*"]) channelKey = "*";
const channelConfig = channelKey ? channels[channelKey] : undefined;
const channelAllowlistConfigured = channelKeys.length > 0;
const allowed = !allowlistConfigured
? true
: Boolean(teamConfig) && (!channelAllowlistConfigured || Boolean(channelConfig));
return {
teamConfig,
channelConfig,
allowlistConfigured,
allowed,
teamKey,
channelKey,
};
}
export type MSTeamsReplyPolicy = {
+67 -1
View File
@@ -7,8 +7,52 @@ export type ProbeMSTeamsResult = {
ok: boolean;
error?: string;
appId?: string;
graph?: {
ok: boolean;
error?: string;
roles?: string[];
scopes?: string[];
};
};
function readAccessToken(value: unknown): string | null {
if (typeof value === "string") return value;
if (value && typeof value === "object") {
const token =
(value as { accessToken?: unknown }).accessToken ??
(value as { token?: unknown }).token;
return typeof token === "string" ? token : null;
}
return null;
}
function decodeJwtPayload(token: string): Record<string, unknown> | null {
const parts = token.split(".");
if (parts.length < 2) return null;
const payload = parts[1] ?? "";
const padded = payload.padEnd(payload.length + ((4 - (payload.length % 4)) % 4), "=");
const normalized = padded.replace(/-/g, "+").replace(/_/g, "/");
try {
const decoded = Buffer.from(normalized, "base64").toString("utf8");
const parsed = JSON.parse(decoded) as Record<string, unknown>;
return parsed && typeof parsed === "object" ? parsed : null;
} catch {
return null;
}
}
function readStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined;
const out = value.map((entry) => String(entry).trim()).filter(Boolean);
return out.length > 0 ? out : undefined;
}
function readScopes(value: unknown): string[] | undefined {
if (typeof value !== "string") return undefined;
const out = value.split(/\s+/).map((entry) => entry.trim()).filter(Boolean);
return out.length > 0 ? out : undefined;
}
export async function probeMSTeams(cfg?: MSTeamsConfig): Promise<ProbeMSTeamsResult> {
const creds = resolveMSTeamsCredentials(cfg);
if (!creds) {
@@ -22,7 +66,29 @@ export async function probeMSTeams(cfg?: MSTeamsConfig): Promise<ProbeMSTeamsRes
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
const tokenProvider = new sdk.MsalTokenProvider(authConfig);
await tokenProvider.getAccessToken("https://api.botframework.com/.default");
return { ok: true, appId: creds.appId };
let graph:
| {
ok: boolean;
error?: string;
roles?: string[];
scopes?: string[];
}
| undefined;
try {
const graphToken = await tokenProvider.getAccessToken(
"https://graph.microsoft.com/.default",
);
const accessToken = readAccessToken(graphToken);
const payload = accessToken ? decodeJwtPayload(accessToken) : null;
graph = {
ok: true,
roles: readStringArray(payload?.roles),
scopes: readScopes(payload?.scp),
};
} catch (err) {
graph = { ok: false, error: formatUnknownError(err) };
}
return { ok: true, appId: creds.appId, ...(graph ? { graph } : {}) };
} catch (err) {
return {
ok: false,
+277
View File
@@ -0,0 +1,277 @@
import { GRAPH_ROOT } from "./attachments/shared.js";
import { loadMSTeamsSdkWithAuth } from "./sdk.js";
import { resolveMSTeamsCredentials } from "./token.js";
type GraphUser = {
id?: string;
displayName?: string;
userPrincipalName?: string;
mail?: string;
};
type GraphGroup = {
id?: string;
displayName?: string;
};
type GraphChannel = {
id?: string;
displayName?: string;
};
type GraphResponse<T> = { value?: T[] };
export type MSTeamsChannelResolution = {
input: string;
resolved: boolean;
teamId?: string;
teamName?: string;
channelId?: string;
channelName?: string;
note?: string;
};
export type MSTeamsUserResolution = {
input: string;
resolved: boolean;
id?: string;
name?: string;
note?: string;
};
function readAccessToken(value: unknown): string | null {
if (typeof value === "string") return value;
if (value && typeof value === "object") {
const token =
(value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token;
return typeof token === "string" ? token : null;
}
return null;
}
function stripProviderPrefix(raw: string): string {
return raw.replace(/^(msteams|teams):/i, "");
}
export function normalizeMSTeamsMessagingTarget(raw: string): string | undefined {
let trimmed = raw.trim();
if (!trimmed) return undefined;
trimmed = stripProviderPrefix(trimmed).trim();
if (/^conversation:/i.test(trimmed)) {
const id = trimmed.slice("conversation:".length).trim();
return id ? `conversation:${id}` : undefined;
}
if (/^user:/i.test(trimmed)) {
const id = trimmed.slice("user:".length).trim();
return id ? `user:${id}` : undefined;
}
return trimmed || undefined;
}
export function normalizeMSTeamsUserInput(raw: string): string {
return stripProviderPrefix(raw).replace(/^(user|conversation):/i, "").trim();
}
export function parseMSTeamsConversationId(raw: string): string | null {
const trimmed = stripProviderPrefix(raw).trim();
if (!/^conversation:/i.test(trimmed)) return null;
const id = trimmed.slice("conversation:".length).trim();
return id;
}
function normalizeMSTeamsTeamKey(raw: string): string | undefined {
const trimmed = stripProviderPrefix(raw).replace(/^team:/i, "").trim();
return trimmed || undefined;
}
function normalizeMSTeamsChannelKey(raw?: string | null): string | undefined {
const trimmed = raw?.trim().replace(/^#/, "").trim() ?? "";
return trimmed || undefined;
}
export function parseMSTeamsTeamChannelInput(raw: string): { team?: string; channel?: string } {
const trimmed = stripProviderPrefix(raw).trim();
if (!trimmed) return {};
const parts = trimmed.split("/");
const team = normalizeMSTeamsTeamKey(parts[0] ?? "");
const channel = parts.length > 1 ? normalizeMSTeamsChannelKey(parts.slice(1).join("/")) : undefined;
return {
...(team ? { team } : {}),
...(channel ? { channel } : {}),
};
}
export function parseMSTeamsTeamEntry(
raw: string,
): { teamKey: string; channelKey?: string } | null {
const { team, channel } = parseMSTeamsTeamChannelInput(raw);
if (!team) return null;
return {
teamKey: team,
...(channel ? { channelKey: channel } : {}),
};
}
function normalizeQuery(value?: string | null): string {
return value?.trim() ?? "";
}
function escapeOData(value: string): string {
return value.replace(/'/g, "''");
}
async function fetchGraphJson<T>(params: {
token: string;
path: string;
headers?: Record<string, string>;
}): Promise<T> {
const res = await fetch(`${GRAPH_ROOT}${params.path}`, {
headers: {
Authorization: `Bearer ${params.token}`,
...(params.headers ?? {}),
},
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Graph ${params.path} failed (${res.status}): ${text || "unknown error"}`);
}
return (await res.json()) as T;
}
async function resolveGraphToken(cfg: unknown): Promise<string> {
const creds = resolveMSTeamsCredentials((cfg as { channels?: { msteams?: unknown } })?.channels?.msteams);
if (!creds) throw new Error("MS Teams credentials missing");
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
const tokenProvider = new sdk.MsalTokenProvider(authConfig);
const token = await tokenProvider.getAccessToken("https://graph.microsoft.com/.default");
const accessToken = readAccessToken(token);
if (!accessToken) throw new Error("MS Teams graph token unavailable");
return accessToken;
}
async function listTeamsByName(token: string, query: string): Promise<GraphGroup[]> {
const escaped = escapeOData(query);
const filter = `resourceProvisioningOptions/Any(x:x eq 'Team') and startsWith(displayName,'${escaped}')`;
const path = `/groups?$filter=${encodeURIComponent(filter)}&$select=id,displayName`;
const res = await fetchGraphJson<GraphResponse<GraphGroup>>({ token, path });
return res.value ?? [];
}
async function listChannelsForTeam(token: string, teamId: string): Promise<GraphChannel[]> {
const path = `/teams/${encodeURIComponent(teamId)}/channels?$select=id,displayName`;
const res = await fetchGraphJson<GraphResponse<GraphChannel>>({ token, path });
return res.value ?? [];
}
export async function resolveMSTeamsChannelAllowlist(params: {
cfg: unknown;
entries: string[];
}): Promise<MSTeamsChannelResolution[]> {
const token = await resolveGraphToken(params.cfg);
const results: MSTeamsChannelResolution[] = [];
for (const input of params.entries) {
const { team, channel } = parseMSTeamsTeamChannelInput(input);
if (!team) {
results.push({ input, resolved: false });
continue;
}
const teams =
/^[0-9a-fA-F-]{16,}$/.test(team) ? [{ id: team, displayName: team }] : await listTeamsByName(token, team);
if (teams.length === 0) {
results.push({ input, resolved: false, note: "team not found" });
continue;
}
const teamMatch = teams[0];
const teamId = teamMatch.id?.trim();
const teamName = teamMatch.displayName?.trim() || team;
if (!teamId) {
results.push({ input, resolved: false, note: "team id missing" });
continue;
}
if (!channel) {
results.push({
input,
resolved: true,
teamId,
teamName,
note: teams.length > 1 ? "multiple teams; chose first" : undefined,
});
continue;
}
const channels = await listChannelsForTeam(token, teamId);
const channelMatch =
channels.find((item) => item.id === channel) ??
channels.find(
(item) => item.displayName?.toLowerCase() === channel.toLowerCase(),
) ??
channels.find(
(item) => item.displayName?.toLowerCase().includes(channel.toLowerCase() ?? ""),
);
if (!channelMatch?.id) {
results.push({ input, resolved: false, note: "channel not found" });
continue;
}
results.push({
input,
resolved: true,
teamId,
teamName,
channelId: channelMatch.id,
channelName: channelMatch.displayName ?? channel,
note: channels.length > 1 ? "multiple channels; chose first" : undefined,
});
}
return results;
}
export async function resolveMSTeamsUserAllowlist(params: {
cfg: unknown;
entries: string[];
}): Promise<MSTeamsUserResolution[]> {
const token = await resolveGraphToken(params.cfg);
const results: MSTeamsUserResolution[] = [];
for (const input of params.entries) {
const query = normalizeQuery(normalizeMSTeamsUserInput(input));
if (!query) {
results.push({ input, resolved: false });
continue;
}
if (/^[0-9a-fA-F-]{16,}$/.test(query)) {
results.push({ input, resolved: true, id: query });
continue;
}
let users: GraphUser[] = [];
if (query.includes("@")) {
const escaped = escapeOData(query);
const filter = `(mail eq '${escaped}' or userPrincipalName eq '${escaped}')`;
const path = `/users?$filter=${encodeURIComponent(filter)}&$select=id,displayName,mail,userPrincipalName`;
const res = await fetchGraphJson<GraphResponse<GraphUser>>({ token, path });
users = res.value ?? [];
} else {
const path = `/users?$search=${encodeURIComponent(`"displayName:${query}"`)}&$select=id,displayName,mail,userPrincipalName&$top=10`;
const res = await fetchGraphJson<GraphResponse<GraphUser>>({
token,
path,
headers: { ConsistencyLevel: "eventual" },
});
users = res.value ?? [];
}
const match = users[0];
if (!match?.id) {
results.push({ input, resolved: false });
continue;
}
results.push({
input,
resolved: true,
id: match.id,
name: match.displayName ?? undefined,
note: users.length > 1 ? "multiple matches; chose first" : undefined,
});
}
return results;
}
+24
View File
@@ -0,0 +1,24 @@
# Qwen OAuth (Clawdbot plugin)
OAuth provider plugin for **Qwen** (free-tier OAuth).
## Enable
Bundled plugins are disabled by default. Enable this one:
```bash
clawdbot plugins enable qwen-portal-auth
```
Restart the Gateway after enabling.
## Authenticate
```bash
clawdbot models auth login --provider qwen-portal --set-default
```
## Notes
- Qwen OAuth uses a device-code login flow.
- Tokens auto-refresh; re-run login if refresh fails or access is revoked.
+124
View File
@@ -0,0 +1,124 @@
import { loginQwenPortalOAuth } from "./oauth.js";
const PROVIDER_ID = "qwen-portal";
const PROVIDER_LABEL = "Qwen";
const DEFAULT_MODEL = "qwen-portal/coder-model";
const DEFAULT_BASE_URL = "https://portal.qwen.ai/v1";
const DEFAULT_CONTEXT_WINDOW = 128000;
const DEFAULT_MAX_TOKENS = 8192;
const OAUTH_PLACEHOLDER = "qwen-oauth";
function normalizeBaseUrl(value: string | undefined): string {
const raw = value?.trim() || DEFAULT_BASE_URL;
const withProtocol = raw.startsWith("http") ? raw : `https://${raw}`;
return withProtocol.endsWith("/v1") ? withProtocol : `${withProtocol.replace(/\/+$/, "")}/v1`;
}
function buildModelDefinition(params: { id: string; name: string; input: Array<"text" | "image"> }) {
return {
id: params.id,
name: params.name,
reasoning: false,
input: params.input,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: DEFAULT_CONTEXT_WINDOW,
maxTokens: DEFAULT_MAX_TOKENS,
};
}
const qwenPortalPlugin = {
id: "qwen-portal-auth",
name: "Qwen OAuth",
description: "OAuth flow for Qwen (free-tier) models",
register(api) {
api.registerProvider({
id: PROVIDER_ID,
label: PROVIDER_LABEL,
docsPath: "/providers/qwen",
aliases: ["qwen"],
auth: [
{
id: "device",
label: "Qwen OAuth",
hint: "Device code login",
kind: "device_code",
run: async (ctx) => {
const progress = ctx.prompter.progress("Starting Qwen OAuth…");
try {
const result = await loginQwenPortalOAuth({
openUrl: ctx.openUrl,
note: ctx.prompter.note,
progress,
});
progress.stop("Qwen OAuth complete");
const profileId = `${PROVIDER_ID}:default`;
const baseUrl = normalizeBaseUrl(result.resourceUrl);
return {
profiles: [
{
profileId,
credential: {
type: "oauth",
provider: PROVIDER_ID,
access: result.access,
refresh: result.refresh,
expires: result.expires,
},
},
],
configPatch: {
models: {
providers: {
[PROVIDER_ID]: {
baseUrl,
apiKey: OAUTH_PLACEHOLDER,
api: "openai-completions",
models: [
buildModelDefinition({
id: "coder-model",
name: "Qwen Coder",
input: ["text"],
}),
buildModelDefinition({
id: "vision-model",
name: "Qwen Vision",
input: ["text", "image"],
}),
],
},
},
},
agents: {
defaults: {
models: {
"qwen-portal/coder-model": { alias: "qwen" },
"qwen-portal/vision-model": {},
},
},
},
},
defaultModel: DEFAULT_MODEL,
notes: [
"Qwen OAuth tokens auto-refresh. Re-run login if refresh fails or access is revoked.",
`Base URL defaults to ${DEFAULT_BASE_URL}. Override models.providers.${PROVIDER_ID}.baseUrl if needed.`,
],
};
} catch (err) {
progress.stop("Qwen OAuth failed");
await ctx.prompter.note(
"If OAuth fails, verify your Qwen account has portal access and try again.",
"Qwen OAuth",
);
throw err;
}
},
},
],
});
},
};
export default qwenPortalPlugin;
+190
View File
@@ -0,0 +1,190 @@
import { createHash, randomBytes, randomUUID } from "node:crypto";
const QWEN_OAUTH_BASE_URL = "https://chat.qwen.ai";
const QWEN_OAUTH_DEVICE_CODE_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/device/code`;
const QWEN_OAUTH_TOKEN_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token`;
const QWEN_OAUTH_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56";
const QWEN_OAUTH_SCOPE = "openid profile email model.completion";
const QWEN_OAUTH_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
export type QwenDeviceAuthorization = {
device_code: string;
user_code: string;
verification_uri: string;
verification_uri_complete?: string;
expires_in: number;
interval?: number;
};
export type QwenOAuthToken = {
access: string;
refresh: string;
expires: number;
resourceUrl?: string;
};
type TokenPending = { status: "pending"; slowDown?: boolean };
type DeviceTokenResult =
| { status: "success"; token: QwenOAuthToken }
| TokenPending
| { status: "error"; message: string };
function toFormUrlEncoded(data: Record<string, string>): string {
return Object.entries(data)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join("&");
}
function generatePkce(): { verifier: string; challenge: string } {
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
async function requestDeviceCode(params: { challenge: string }): Promise<QwenDeviceAuthorization> {
const response = await fetch(QWEN_OAUTH_DEVICE_CODE_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
"x-request-id": randomUUID(),
},
body: toFormUrlEncoded({
client_id: QWEN_OAUTH_CLIENT_ID,
scope: QWEN_OAUTH_SCOPE,
code_challenge: params.challenge,
code_challenge_method: "S256",
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Qwen device authorization failed: ${text || response.statusText}`);
}
const payload = (await response.json()) as QwenDeviceAuthorization & { error?: string };
if (!payload.device_code || !payload.user_code || !payload.verification_uri) {
throw new Error(
payload.error ??
"Qwen device authorization returned an incomplete payload (missing user_code or verification_uri).",
);
}
return payload;
}
async function pollDeviceToken(params: {
deviceCode: string;
verifier: string;
}): Promise<DeviceTokenResult> {
const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: toFormUrlEncoded({
grant_type: QWEN_OAUTH_GRANT_TYPE,
client_id: QWEN_OAUTH_CLIENT_ID,
device_code: params.deviceCode,
code_verifier: params.verifier,
}),
});
if (!response.ok) {
let payload: { error?: string; error_description?: string } | undefined;
try {
payload = (await response.json()) as { error?: string; error_description?: string };
} catch {
const text = await response.text();
return { status: "error", message: text || response.statusText };
}
if (payload?.error === "authorization_pending") {
return { status: "pending" };
}
if (payload?.error === "slow_down") {
return { status: "pending", slowDown: true };
}
return {
status: "error",
message: payload?.error_description || payload?.error || response.statusText,
};
}
const tokenPayload = (await response.json()) as {
access_token?: string | null;
refresh_token?: string | null;
expires_in?: number | null;
token_type?: string;
resource_url?: string;
};
if (!tokenPayload.access_token || !tokenPayload.refresh_token || !tokenPayload.expires_in) {
return { status: "error", message: "Qwen OAuth returned incomplete token payload." };
}
return {
status: "success",
token: {
access: tokenPayload.access_token,
refresh: tokenPayload.refresh_token,
expires: Date.now() + tokenPayload.expires_in * 1000,
resourceUrl: tokenPayload.resource_url,
},
};
}
export async function loginQwenPortalOAuth(params: {
openUrl: (url: string) => Promise<void>;
note: (message: string, title?: string) => Promise<void>;
progress: { update: (message: string) => void; stop: (message?: string) => void };
}): Promise<QwenOAuthToken> {
const { verifier, challenge } = generatePkce();
const device = await requestDeviceCode({ challenge });
const verificationUrl = device.verification_uri_complete || device.verification_uri;
await params.note(
[
`Open ${verificationUrl} to approve access.`,
`If prompted, enter the code ${device.user_code}.`,
].join("\n"),
"Qwen OAuth",
);
try {
await params.openUrl(verificationUrl);
} catch {
// Fall back to manual copy/paste if browser open fails.
}
const start = Date.now();
let pollIntervalMs = device.interval ? device.interval * 1000 : 2000;
const timeoutMs = device.expires_in * 1000;
while (Date.now() - start < timeoutMs) {
params.progress.update("Waiting for Qwen OAuth approval…");
const result = await pollDeviceToken({
deviceCode: device.device_code,
verifier,
});
if (result.status === "success") {
return result.token;
}
if (result.status === "error") {
throw new Error(`Qwen OAuth failed: ${result.message}`);
}
if (result.status === "pending" && result.slowDown) {
pollIntervalMs = Math.min(pollIntervalMs * 1.5, 10000);
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
throw new Error("Qwen OAuth timed out waiting for authorization.");
}
+10
View File
@@ -1,5 +1,15 @@
# Changelog
## 2026.1.17-1
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.17
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.16
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/voice-call",
"version": "2026.1.16",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot voice-call plugin",
"dependencies": {
+10
View File
@@ -1,5 +1,15 @@
# Changelog
## 2026.1.17-1
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.17
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.16
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/zalo",
"version": "2026.1.16",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot Zalo channel plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/zalouser",
"version": "2026.1.16",
"version": "2026.1.17-1",
"type": "module",
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
"dependencies": {
+67
View File
@@ -324,6 +324,73 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
return sliced as ChannelDirectoryEntry[];
},
},
resolver: {
resolveTargets: async ({ cfg, accountId, inputs, kind, runtime }) => {
const results = [];
for (const input of inputs) {
const trimmed = input.trim();
if (!trimmed) {
results.push({ input, resolved: false, note: "empty input" });
continue;
}
if (/^\d+$/.test(trimmed)) {
results.push({ input, resolved: true, id: trimmed });
continue;
}
try {
const account = resolveZalouserAccountSync({
cfg: cfg as CoreConfig,
accountId: accountId ?? DEFAULT_ACCOUNT_ID,
});
const args =
kind === "user"
? trimmed
? ["friend", "find", trimmed]
: ["friend", "list", "-j"]
: ["group", "list", "-j"];
const result = await runZca(args, { profile: account.profile, timeout: 15000 });
if (!result.ok) throw new Error(result.stderr || "zca lookup failed");
if (kind === "user") {
const parsed = parseJsonOutput<ZcaFriend[]>(result.stdout) ?? [];
const matches = Array.isArray(parsed)
? parsed.map((f) => ({
id: String(f.userId),
name: f.displayName ?? undefined,
}))
: [];
const best = matches[0];
results.push({
input,
resolved: Boolean(best?.id),
id: best?.id,
name: best?.name,
note: matches.length > 1 ? "multiple matches; chose first" : undefined,
});
} else {
const parsed = parseJsonOutput<ZcaGroup[]>(result.stdout) ?? [];
const matches = Array.isArray(parsed)
? parsed.map((g) => ({
id: String(g.groupId),
name: g.name ?? undefined,
}))
: [];
const best = matches.find((g) => g.name?.toLowerCase() === trimmed.toLowerCase()) ?? matches[0];
results.push({
input,
resolved: Boolean(best?.id),
id: best?.id,
name: best?.name,
note: matches.length > 1 ? "multiple matches; chose first" : undefined,
});
}
} catch (err) {
runtime.error?.(`zalouser resolve failed: ${String(err)}`);
results.push({ input, resolved: false, note: "lookup failed" });
}
}
return results;
},
},
pairing: {
idLabel: "zalouserUserId",
normalizeAllowEntry: (entry) => entry.replace(/^(zalouser|zlu):/i, ""),
+171 -8
View File
@@ -5,12 +5,19 @@ import {
isControlCommandMessage,
shouldComputeCommandAuthorized,
} from "../../../src/auto-reply/command-detection.js";
import { mergeAllowlist, summarizeMapping } from "../../../src/channels/allowlists/resolve-utils.js";
import { finalizeInboundContext } from "../../../src/auto-reply/reply/inbound-context.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../../src/channels/command-gating.js";
import { loadCoreChannelDeps, type CoreChannelDeps } from "./core-bridge.js";
import { sendMessageZalouser } from "./send.js";
import type { CoreConfig, ResolvedZalouserAccount, ZcaMessage } from "./types.js";
import { runZcaStreaming } from "./zca.js";
import type {
CoreConfig,
ResolvedZalouserAccount,
ZcaFriend,
ZcaGroup,
ZcaMessage,
} from "./types.js";
import { parseJsonOutput, runZca, runZcaStreaming } from "./zca.js";
export type ZalouserMonitorOptions = {
account: ResolvedZalouserAccount;
@@ -26,6 +33,25 @@ export type ZalouserMonitorResult = {
const ZALOUSER_TEXT_LIMIT = 2000;
function normalizeZalouserEntry(entry: string): string {
return entry.replace(/^(zalouser|zlu):/i, "").trim();
}
function buildNameIndex<T>(
items: T[],
nameFn: (item: T) => string | undefined,
): Map<string, T[]> {
const index = new Map<string, T[]>();
for (const item of items) {
const name = nameFn(item)?.trim().toLowerCase();
if (!name) continue;
const list = index.get(name) ?? [];
list.push(item);
index.set(name, list);
}
return index;
}
function logVerbose(deps: CoreChannelDeps, runtime: RuntimeEnv, message: string): void {
if (deps.shouldLogVerbose()) {
runtime.log(`[zalouser] ${message}`);
@@ -41,6 +67,39 @@ function isSenderAllowed(senderId: string, allowFrom: string[]): boolean {
});
}
function normalizeGroupSlug(raw?: string | null): string {
const trimmed = raw?.trim().toLowerCase() ?? "";
if (!trimmed) return "";
return trimmed
.replace(/^#/, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function isGroupAllowed(params: {
groupId: string;
groupName?: string | null;
groups: Record<string, { allow?: boolean; enabled?: boolean }>;
}): boolean {
const groups = params.groups ?? {};
const keys = Object.keys(groups);
if (keys.length === 0) return false;
const candidates = [
params.groupId,
`group:${params.groupId}`,
params.groupName ?? "",
normalizeGroupSlug(params.groupName ?? ""),
].filter(Boolean);
for (const candidate of candidates) {
const entry = groups[candidate];
if (!entry) continue;
return entry.allow !== false && entry.enabled !== false;
}
const wildcard = groups["*"];
if (wildcard) return wildcard.allow !== false && wildcard.enabled !== false;
return false;
}
function startZcaListener(
runtime: RuntimeEnv,
profile: string,
@@ -106,8 +165,26 @@ async function processMessage(
const isGroup = metadata?.isGroup ?? false;
const senderId = metadata?.fromId ?? threadId;
const senderName = metadata?.senderName ?? "";
const groupName = metadata?.threadName ?? "";
const chatId = threadId;
const defaultGroupPolicy = config.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open";
const groups = account.config.groups ?? {};
if (isGroup) {
if (groupPolicy === "disabled") {
logVerbose(deps, runtime, `zalouser: drop group ${chatId} (groupPolicy=disabled)`);
return;
}
if (groupPolicy === "allowlist") {
const allowed = isGroupAllowed({ groupId: chatId, groupName, groups });
if (!allowed) {
logVerbose(deps, runtime, `zalouser: drop group ${chatId} (not allowlisted)`);
return;
}
}
}
const dmPolicy = account.config.dmPolicy ?? "pairing";
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
const rawBody = content.trim();
@@ -194,11 +271,10 @@ async function processMessage(
},
});
const rawBody = content.trim();
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
const body = deps.formatAgentEnvelope({
channel: "Zalo Personal",
from: fromLabel,
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
const body = deps.formatAgentEnvelope({
channel: "Zalo Personal",
from: fromLabel,
timestamp: timestamp ? timestamp * 1000 : undefined,
body: rawBody,
});
@@ -301,7 +377,8 @@ async function deliverZalouserReply(params: {
export async function monitorZalouserProvider(
options: ZalouserMonitorOptions,
): Promise<ZalouserMonitorResult> {
const { account, config, abortSignal, statusSink, runtime } = options;
let { account, config } = options;
const { abortSignal, statusSink, runtime } = options;
const deps = await loadCoreChannelDeps();
let stopped = false;
@@ -309,6 +386,92 @@ export async function monitorZalouserProvider(
let restartTimer: ReturnType<typeof setTimeout> | null = null;
let resolveRunning: (() => void) | null = null;
try {
const profile = account.profile;
const allowFromEntries = (account.config.allowFrom ?? [])
.map((entry) => normalizeZalouserEntry(String(entry)))
.filter((entry) => entry && entry !== "*");
if (allowFromEntries.length > 0) {
const result = await runZca(["friend", "list", "-j"], { profile, timeout: 15000 });
if (result.ok) {
const friends = parseJsonOutput<ZcaFriend[]>(result.stdout) ?? [];
const byName = buildNameIndex(friends, (friend) => friend.displayName);
const additions: string[] = [];
const mapping: string[] = [];
const unresolved: string[] = [];
for (const entry of allowFromEntries) {
if (/^\d+$/.test(entry)) {
additions.push(entry);
continue;
}
const matches = byName.get(entry.toLowerCase()) ?? [];
const match = matches[0];
const id = match?.userId ? String(match.userId) : undefined;
if (id) {
additions.push(id);
mapping.push(`${entry}→${id}`);
} else {
unresolved.push(entry);
}
}
const allowFrom = mergeAllowlist({ existing: account.config.allowFrom, additions });
account = {
...account,
config: {
...account.config,
allowFrom,
},
};
summarizeMapping("zalouser users", mapping, unresolved, runtime);
} else {
runtime.log?.(`zalouser user resolve failed; using config entries. ${result.stderr}`);
}
}
const groupsConfig = account.config.groups ?? {};
const groupKeys = Object.keys(groupsConfig).filter((key) => key !== "*");
if (groupKeys.length > 0) {
const result = await runZca(["group", "list", "-j"], { profile, timeout: 15000 });
if (result.ok) {
const groups = parseJsonOutput<ZcaGroup[]>(result.stdout) ?? [];
const byName = buildNameIndex(groups, (group) => group.name);
const mapping: string[] = [];
const unresolved: string[] = [];
const nextGroups = { ...groupsConfig };
for (const entry of groupKeys) {
const cleaned = normalizeZalouserEntry(entry);
if (/^\d+$/.test(cleaned)) {
if (!nextGroups[cleaned]) nextGroups[cleaned] = groupsConfig[entry];
mapping.push(`${entry}→${cleaned}`);
continue;
}
const matches = byName.get(cleaned.toLowerCase()) ?? [];
const match = matches[0];
const id = match?.groupId ? String(match.groupId) : undefined;
if (id) {
if (!nextGroups[id]) nextGroups[id] = groupsConfig[entry];
mapping.push(`${entry}→${id}`);
} else {
unresolved.push(entry);
}
}
account = {
...account,
config: {
...account.config,
groups: nextGroups,
},
};
summarizeMapping("zalouser groups", mapping, unresolved, runtime);
} else {
runtime.log?.(`zalouser group resolve failed; using config entries. ${result.stderr}`);
}
}
} catch (err) {
runtime.log?.(`zalouser resolve failed; using config entries. ${String(err)}`);
}
const stop = () => {
stopped = true;
if (restartTimer) {
+167 -2
View File
@@ -1,5 +1,6 @@
import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js";
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
import { promptChannelAccessConfig } from "../../../src/channels/plugins/onboarding/channel-access.js";
import {
listZalouserAccountIds,
@@ -8,8 +9,8 @@ import {
normalizeAccountId,
checkZcaAuthenticated,
} from "./accounts.js";
import { runZcaInteractive, checkZcaInstalled } from "./zca.js";
import { DEFAULT_ACCOUNT_ID, type CoreConfig } from "./types.js";
import { runZca, runZcaInteractive, checkZcaInstalled, parseJsonOutput } from "./zca.js";
import { DEFAULT_ACCOUNT_ID, type CoreConfig, type ZcaGroup } from "./types.js";
const channel = "zalouser" as const;
@@ -113,6 +114,115 @@ async function promptZalouserAllowFrom(params: {
} as CoreConfig;
}
function setZalouserGroupPolicy(
cfg: CoreConfig,
accountId: string,
groupPolicy: "open" | "allowlist" | "disabled",
): CoreConfig {
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
enabled: true,
groupPolicy,
},
},
} as CoreConfig;
}
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
enabled: true,
accounts: {
...(cfg.channels?.zalouser?.accounts ?? {}),
[accountId]: {
...(cfg.channels?.zalouser?.accounts?.[accountId] ?? {}),
enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true,
groupPolicy,
},
},
},
},
} as CoreConfig;
}
function setZalouserGroupAllowlist(
cfg: CoreConfig,
accountId: string,
groupKeys: string[],
): CoreConfig {
const groups = Object.fromEntries(groupKeys.map((key) => [key, { allow: true }]));
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
enabled: true,
groups,
},
},
} as CoreConfig;
}
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
enabled: true,
accounts: {
...(cfg.channels?.zalouser?.accounts ?? {}),
[accountId]: {
...(cfg.channels?.zalouser?.accounts?.[accountId] ?? {}),
enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true,
groups,
},
},
},
},
} as CoreConfig;
}
async function resolveZalouserGroups(params: {
cfg: CoreConfig;
accountId: string;
entries: string[];
}): Promise<Array<{ input: string; resolved: boolean; id?: string }>> {
const account = resolveZalouserAccountSync({ cfg: params.cfg, accountId: params.accountId });
const result = await runZca(["group", "list", "-j"], { profile: account.profile, timeout: 15000 });
if (!result.ok) throw new Error(result.stderr || "Failed to list groups");
const groups = (parseJsonOutput<ZcaGroup[]>(result.stdout) ?? []).filter(
(group) => Boolean(group.groupId),
);
const byName = new Map<string, ZcaGroup[]>();
for (const group of groups) {
const name = group.name?.trim().toLowerCase();
if (!name) continue;
const list = byName.get(name) ?? [];
list.push(group);
byName.set(name, list);
}
return params.entries.map((input) => {
const trimmed = input.trim();
if (!trimmed) return { input, resolved: false };
if (/^\d+$/.test(trimmed)) return { input, resolved: true, id: trimmed };
const matches = byName.get(trimmed.toLowerCase()) ?? [];
const match = matches[0];
return match?.groupId
? { input, resolved: true, id: String(match.groupId) }
: { input, resolved: false };
});
}
async function promptAccountId(params: {
cfg: CoreConfig;
prompter: WizardPrompter;
@@ -307,6 +417,61 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
});
}
const accessConfig = await promptChannelAccessConfig({
prompter,
label: "Zalo groups",
currentPolicy: account.config.groupPolicy ?? "open",
currentEntries: Object.keys(account.config.groups ?? {}),
placeholder: "Family, Work, 123456789",
updatePrompt: Boolean(account.config.groups),
});
if (accessConfig) {
if (accessConfig.policy !== "allowlist") {
next = setZalouserGroupPolicy(next, accountId, accessConfig.policy);
} else {
let keys = accessConfig.entries;
if (accessConfig.entries.length > 0) {
try {
const resolved = await resolveZalouserGroups({
cfg: next,
accountId,
entries: accessConfig.entries,
});
const resolvedIds = resolved
.filter((entry) => entry.resolved && entry.id)
.map((entry) => entry.id as string);
const unresolved = resolved
.filter((entry) => !entry.resolved)
.map((entry) => entry.input);
keys = [
...resolvedIds,
...unresolved.map((entry) => entry.trim()).filter(Boolean),
];
if (resolvedIds.length > 0 || unresolved.length > 0) {
await prompter.note(
[
resolvedIds.length > 0 ? `Resolved: ${resolvedIds.join(", ")}` : undefined,
unresolved.length > 0
? `Unresolved (kept as typed): ${unresolved.join(", ")}`
: undefined,
]
.filter(Boolean)
.join("\n"),
"Zalo groups",
);
}
} catch (err) {
await prompter.note(
`Group lookup failed; keeping entries as typed. ${String(err)}`,
"Zalo groups",
);
}
}
next = setZalouserGroupPolicy(next, accountId, "allowlist");
next = setZalouserGroupAllowlist(next, accountId, keys);
}
}
return { cfg: next, accountId };
},
};
+4
View File
@@ -77,6 +77,8 @@ export type ZalouserAccountConfig = {
profile?: string;
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
allowFrom?: Array<string | number>;
groupPolicy?: "open" | "allowlist" | "disabled";
groups?: Record<string, { allow?: boolean; enabled?: boolean }>;
messagePrefix?: string;
};
@@ -87,6 +89,8 @@ export type ZalouserConfig = {
defaultAccount?: string;
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
allowFrom?: Array<string | number>;
groupPolicy?: "open" | "allowlist" | "disabled";
groups?: Record<string, { allow?: boolean; enabled?: boolean }>;
messagePrefix?: string;
accounts?: Record<string, ZalouserAccountConfig>;
};
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "clawdbot",
"version": "2026.1.16-2",
"version": "2026.1.17-1",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"type": "module",
"main": "dist/index.js",
@@ -174,10 +174,12 @@
"proper-lockfile": "^4.1.2",
"qrcode-terminal": "^0.12.0",
"sharp": "^0.34.5",
"sqlite-vec": "0.1.7-alpha.2",
"tar": "^7.5.3",
"tslog": "^4.10.2",
"undici": "^7.18.2",
"ws": "^8.19.0",
"yaml": "^2.8.2",
"zod": "^4.3.5"
},
"optionalDependencies": {
View File
Whitespace-only changes.
+63
View File
@@ -133,6 +133,9 @@ importers:
sharp:
specifier: ^0.34.5
version: 0.34.5
sqlite-vec:
specifier: 0.1.7-alpha.2
version: 0.1.7-alpha.2
tar:
specifier: 7.5.3
version: 7.5.3
@@ -145,6 +148,9 @@ importers:
ws:
specifier: ^8.19.0
version: 8.19.0
yaml:
specifier: ^2.8.2
version: 2.8.2
zod:
specifier: ^4.3.5
version: 4.3.5
@@ -232,6 +238,12 @@ importers:
specifier: 3.14.5
version: 3.14.5(typescript@5.9.3)
extensions/copilot-proxy: {}
extensions/google-antigravity-auth: {}
extensions/google-gemini-cli-auth: {}
extensions/matrix:
dependencies:
markdown-it:
@@ -3904,6 +3916,34 @@ packages:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
sqlite-vec-darwin-arm64@0.1.7-alpha.2:
resolution: {integrity: sha512-raIATOqFYkeCHhb/t3r7W7Cf2lVYdf4J3ogJ6GFc8PQEgHCPEsi+bYnm2JT84MzLfTlSTIdxr4/NKv+zF7oLPw==}
cpu: [arm64]
os: [darwin]
sqlite-vec-darwin-x64@0.1.7-alpha.2:
resolution: {integrity: sha512-jeZEELsQjjRsVojsvU5iKxOvkaVuE+JYC8Y4Ma8U45aAERrDYmqZoHvgSG7cg1PXL3bMlumFTAmHynf1y4pOzA==}
cpu: [x64]
os: [darwin]
sqlite-vec-linux-arm64@0.1.7-alpha.2:
resolution: {integrity: sha512-6Spj4Nfi7tG13jsUG+W7jnT0bCTWbyPImu2M8nWp20fNrd1SZ4g3CSlDAK8GBdavX7wRlbBHCZ+BDa++rbDewA==}
cpu: [arm64]
os: [linux]
sqlite-vec-linux-x64@0.1.7-alpha.2:
resolution: {integrity: sha512-IcgrbHaDccTVhXDf8Orwdc2+hgDLAFORl6OBUhcvlmwswwBP1hqBTSEhovClG4NItwTOBNgpwOoQ7Qp3VDPWLg==}
cpu: [x64]
os: [linux]
sqlite-vec-windows-x64@0.1.7-alpha.2:
resolution: {integrity: sha512-TRP6hTjAcwvQ6xpCZvjP00pdlda8J38ArFy1lMYhtQWXiIBmWnhMaMbq4kaeCYwvTTddfidatRS+TJrwIKB/oQ==}
cpu: [x64]
os: [win32]
sqlite-vec@0.1.7-alpha.2:
resolution: {integrity: sha512-rNgRCv+4V4Ed3yc33Qr+nNmjhtrMnnHzXfLVPeGb28Dx5mmDL3Ngw/Wk8vhCGjj76+oC6gnkmMG8y73BZWGBwQ==}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
@@ -8557,6 +8597,29 @@ snapshots:
split2@4.2.0: {}
sqlite-vec-darwin-arm64@0.1.7-alpha.2:
optional: true
sqlite-vec-darwin-x64@0.1.7-alpha.2:
optional: true
sqlite-vec-linux-arm64@0.1.7-alpha.2:
optional: true
sqlite-vec-linux-x64@0.1.7-alpha.2:
optional: true
sqlite-vec-windows-x64@0.1.7-alpha.2:
optional: true
sqlite-vec@0.1.7-alpha.2:
optionalDependencies:
sqlite-vec-darwin-arm64: 0.1.7-alpha.2
sqlite-vec-darwin-x64: 0.1.7-alpha.2
sqlite-vec-linux-arm64: 0.1.7-alpha.2
sqlite-vec-linux-x64: 0.1.7-alpha.2
sqlite-vec-windows-x64: 0.1.7-alpha.2
stackback@0.0.2: {}
statuses@2.0.2: {}
+2 -1
View File
@@ -8,7 +8,8 @@
"latitudeki5223",
"longmaba",
"manmal",
"thesash"
"thesash",
"rhjoh"
],
"seedCommit": "d6863f87",
"placeholderAvatar": "assets/avatar-placeholder.svg",
+26 -15
View File
@@ -168,24 +168,33 @@ TRASH
fi
}
select_skip_hooks() {
# Hooks multiselect: pick "Skip for now".
wait_for_log "Enable hooks?" 60 || true
send $'"'"' \r'"'"' 0.6
}
send_local_basic() {
# Risk acknowledgement (default is "No").
send $'"'"'y\r'"'"' 0.6
# Choose local gateway, accept defaults, skip channels/skills/daemon, skip UI.
send $'"'"'\r'"'"' 0.5
select_skip_hooks
}
send_reset_config_only() {
# Risk acknowledgement (default is "No").
send $'"'"'y\r'"'"' 0.8
# Reset config + reuse the local defaults flow.
send $'"'"'\e[B'"'"' 0.3
send $'"'"'\e[B'"'"' 0.3
send $'"'"'\r'"'"' 0.4
send $'"'"'\r'"'"' 0.4
send "" 1.2
send_local_basic
}
send_reset_config_only() {
# Risk acknowledgement (default is "No").
send $'"'"'y\r'"'"' 0.8
# Select reset flow for existing config.
wait_for_log "Config handling" 40 || true
send $'"'"'\e[B'"'"' 0.3
send $'"'"'\e[B'"'"' 0.3
send $'"'"'\r'"'"' 0.4
# Reset scope -> Config only (default).
wait_for_log "Reset scope" 40 || true
send $'"'"'\r'"'"' 0.4
select_skip_hooks
}
send_channels_flow() {
# Configure channels via configure wizard.
@@ -202,11 +211,13 @@ TRASH
send_skills_flow() {
# Select skills section and skip optional installs.
send $'"'"'\r'"'"' 1.2
send "" 1.0
wait_for_log "Where will the Gateway run?" 40 || true
send $'"'"'\r'"'"' 0.8
# Configure skills now? -> No
send $'"'"'n\r'"'"' 1.2
send "" 2.0
wait_for_log "Configure skills now?" 40 || true
send $'"'"'n\r'"'"' 0.8
wait_for_log "Configure complete." 40 || true
send "" 0.8
}
run_case_local_basic() {
+40
View File
@@ -0,0 +1,40 @@
import { DatabaseSync } from "node:sqlite";
import { load, getLoadablePath } from "sqlite-vec";
function vec(values) {
return Buffer.from(new Float32Array(values).buffer);
}
const db = new DatabaseSync(":memory:", { allowExtension: true });
try {
load(db);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error("sqlite-vec load failed:");
console.error(message);
console.error("expected extension path:", getLoadablePath());
process.exit(1);
}
db.exec(`
CREATE VIRTUAL TABLE v USING vec0(
id TEXT PRIMARY KEY,
embedding FLOAT[4]
);
`);
const insert = db.prepare("INSERT INTO v (id, embedding) VALUES (?, ?)");
insert.run("a", vec([1, 0, 0, 0]));
insert.run("b", vec([0, 1, 0, 0]));
insert.run("c", vec([0.2, 0.2, 0, 0]));
const query = vec([1, 0, 0, 0]);
const rows = db
.prepare(
"SELECT id, vec_distance_cosine(embedding, ?) AS dist FROM v ORDER BY dist ASC"
)
.all(query);
console.log("sqlite-vec ok");
console.log(rows);
+65 -13
View File
@@ -7,19 +7,71 @@ metadata: {"clawdbot":{"emoji":"🐦","requires":{"bins":["bird"]},"install":[{"
# bird
Use `bird` to read/search X and post tweets/replies.
## Reading vs Writing - Different Tools
Quick start
- `bird whoami`
- `bird read <url-or-id>`
- `bird thread <url-or-id>`
- `bird search "query" -n 5`
**For READING tweets** (works great):
- Use the `bird` CLI - it's fast and reliable
- `bird read <url-or-id>` - grab a specific tweet
- `bird search "query" -n 5` - search tweets
- `bird mentions` - check notifications
Posting (confirm with user first)
- `bird tweet "text"`
- `bird reply <id-or-url> "text"`
**For WRITING tweets** (here's where it gets spicy):
- **Don't use the CLI for posting** - Twitter flags it as automated and you'll get rate limited or soft-banned fast
- **Use the browser tool instead** - mimics real human behavior
Auth sources
- Browser cookies (default: Firefox/Chrome)
- Sweetistics API: set `SWEETISTICS_API_KEY` or use `--engine sweetistics`
- Check sources: `bird check`
## Quick Reference (Reading Only)
```bash
bird whoami # Check auth status
bird read <url-or-id> # Read a specific tweet
bird thread <url-or-id> # Read full thread
bird search "query" -n 5 # Search tweets
bird mentions # Check notifications
```
## The React Input Problem
Twitter's compose box is a React controlled input. You can't just set `.value` like a normal input - React ignores it. The workaround:
**Simulate a paste event:**
```javascript
// 1. Focus the editor
var editor = document.querySelector('[data-testid="tweetTextarea_0"]');
editor.focus();
// 2. Create a fake paste event (this triggers React's state update)
var dataTransfer = new DataTransfer();
dataTransfer.setData('text/plain', 'your tweet text here');
var pasteEvent = new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
});
editor.dispatchEvent(pasteEvent);
// 3. Click the post button
document.querySelector('[data-testid="tweetButtonInline"]').click();
```
## Browser Setup Notes
- Use a dedicated browser profile (e.g. "clawd") so you're logged in persistently
- The cookie config lives at `~/.config/bird/config.json5` for the CLI
- If CDP fails, kill Chrome completely (`pkill -9 "Google Chrome"`) and restart with `browser action=start`
## Rate Limiting & Detection
- Space out your posts - don't rapid-fire
- Vary your timing slightly (don't post at exactly :00 every hour)
- If you get flagged, the account might need a CAPTCHA solve manually
- Reading is basically unlimited, posting is where they watch you
## Selectors That Work (as of late 2025)
- Tweet compose box: `[data-testid="tweetTextarea_0"]`
- Post button: `[data-testid="tweetButtonInline"]`
- These change occasionally so if stuff breaks, inspect the page
---
**TL;DR: read with CLI, write with browser + paste hack.** 🐦
+1
View File
@@ -6,6 +6,7 @@ export const LEGACY_AUTH_FILENAME = "auth.json";
export const CLAUDE_CLI_PROFILE_ID = "anthropic:claude-cli";
export const CODEX_CLI_PROFILE_ID = "openai-codex:codex-cli";
export const QWEN_CLI_PROFILE_ID = "qwen-portal:qwen-cli";
export const AUTH_STORE_LOCK_OPTIONS = {
retries: {
+35 -1
View File
@@ -1,12 +1,14 @@
import {
readClaudeCliCredentialsCached,
readCodexCliCredentialsCached,
readQwenCliCredentialsCached,
} from "../cli-credentials.js";
import {
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
EXTERNAL_CLI_NEAR_EXPIRY_MS,
EXTERNAL_CLI_SYNC_TTL_MS,
QWEN_CLI_PROFILE_ID,
log,
} from "./constants.js";
import type {
@@ -45,7 +47,11 @@ function shallowEqualTokenCredentials(a: TokenCredential | undefined, b: TokenCr
function isExternalProfileFresh(cred: AuthProfileCredential | undefined, now: number): boolean {
if (!cred) return false;
if (cred.type !== "oauth" && cred.type !== "token") return false;
if (cred.provider !== "anthropic" && cred.provider !== "openai-codex") {
if (
cred.provider !== "anthropic" &&
cred.provider !== "openai-codex" &&
cred.provider !== "qwen-portal"
) {
return false;
}
if (typeof cred.expires !== "number") return true;
@@ -165,5 +171,33 @@ export function syncExternalCliCredentials(
}
}
// Sync from Qwen Code CLI
const existingQwen = store.profiles[QWEN_CLI_PROFILE_ID];
const shouldSyncQwen =
!existingQwen ||
existingQwen.provider !== "qwen-portal" ||
!isExternalProfileFresh(existingQwen, now);
const qwenCreds = shouldSyncQwen
? readQwenCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS })
: null;
if (qwenCreds) {
const existing = store.profiles[QWEN_CLI_PROFILE_ID];
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
const shouldUpdate =
!existingOAuth ||
existingOAuth.provider !== "qwen-portal" ||
existingOAuth.expires <= now ||
qwenCreds.expires > existingOAuth.expires;
if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, qwenCreds)) {
store.profiles[QWEN_CLI_PROFILE_ID] = qwenCreds;
mutated = true;
log.info("synced qwen credentials from qwen cli", {
profileId: QWEN_CLI_PROFILE_ID,
expires: new Date(qwenCreds.expires).toISOString(),
});
}
}
return mutated;
}
+7 -1
View File
@@ -3,6 +3,7 @@ import lockfile from "proper-lockfile";
import type { ClawdbotConfig } from "../../config/config.js";
import { refreshChutesTokens } from "../chutes-oauth.js";
import { refreshQwenPortalCredentials } from "../../providers/qwen-portal-oauth.js";
import { writeClaudeCliCredentials } from "../cli-credentials.js";
import { AUTH_STORE_LOCK_OPTIONS, CLAUDE_CLI_PROFILE_ID } from "./constants.js";
import { formatAuthDoctorHint } from "./doctor.js";
@@ -57,7 +58,12 @@ async function refreshOAuthTokenWithLock(params: {
});
return { apiKey: newCredentials.access, newCredentials };
})()
: await getOAuthApiKey(cred.provider as OAuthProvider, oauthCreds);
: String(cred.provider) === "qwen-portal"
? await (async () => {
const newCredentials = await refreshQwenPortalCredentials(cred);
return { apiKey: newCredentials.access, newCredentials };
})()
: await getOAuthApiKey(cred.provider as OAuthProvider, oauthCreds);
if (!result) return null;
store.profiles[params.profileId] = {
...cred,
+10 -1
View File
@@ -75,6 +75,7 @@ export type ExecToolDefaults = {
allowBackground?: boolean;
scopeKey?: string;
sessionKey?: string;
messageProvider?: string;
notifyOnExit?: boolean;
cwd?: string;
};
@@ -220,6 +221,11 @@ export function createExecTool(
if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) {
const runtime = defaults?.sandbox ? "sandboxed" : "direct";
const gates: string[] = [];
const contextParts: string[] = [];
const provider = defaults?.messageProvider?.trim();
const sessionKey = defaults?.sessionKey?.trim();
if (provider) contextParts.push(`provider=${provider}`);
if (sessionKey) contextParts.push(`session=${sessionKey}`);
if (!elevatedDefaults?.enabled) {
gates.push("enabled (tools.elevated.enabled / agents.list[].tools.elevated.enabled)");
} else {
@@ -231,12 +237,15 @@ export function createExecTool(
[
`elevated is not available right now (runtime=${runtime}).`,
`Failing gates: ${gates.join(", ")}`,
contextParts.length > 0 ? `Context: ${contextParts.join(" ")}` : undefined,
"Fix-it keys:",
"- tools.elevated.enabled",
"- tools.elevated.allowFrom.<provider>",
"- agents.list[].tools.elevated.enabled",
"- agents.list[].tools.elevated.allowFrom.<provider>",
].join("\n"),
]
.filter(Boolean)
.join("\n"),
);
}
logInfo(
+3 -1
View File
@@ -150,6 +150,8 @@ describe("exec tool backgrounding", () => {
it("rejects elevated requests when not allowed", async () => {
const customBash = createExecTool({
elevated: { enabled: true, allowed: false, defaultLevel: "off" },
messageProvider: "telegram",
sessionKey: "agent:main:main",
});
await expect(
@@ -157,7 +159,7 @@ describe("exec tool backgrounding", () => {
command: "echo hi",
elevated: true,
}),
).rejects.toThrow("tools.elevated.allowFrom.<provider>");
).rejects.toThrow("Context: provider=telegram session=agent:main:main");
});
it("does not default to elevated when not allowed", async () => {
@@ -54,6 +54,7 @@ describe("sessions tools", () => {
expect(schemaProp("sessions_list", "activeMinutes").type).toBe("number");
expect(schemaProp("sessions_list", "messageLimit").type).toBe("number");
expect(schemaProp("sessions_send", "timeoutSeconds").type).toBe("number");
expect(schemaProp("sessions_spawn", "thinking").type).toBe("string");
expect(schemaProp("sessions_spawn", "runTimeoutSeconds").type).toBe("number");
expect(schemaProp("sessions_spawn", "timeoutSeconds").type).toBe("number");
});
@@ -92,6 +92,68 @@ describe("clawdbot-tools: subagents", () => {
model: "claude-haiku-4-5",
});
});
it("sessions_spawn forwards thinking overrides to the agent run", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = [];
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "agent") {
return { runId: "run-thinking", status: "accepted" };
}
return {};
});
const tool = createClawdbotTools({
agentSessionKey: "discord:group:req",
agentChannel: "discord",
}).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) throw new Error("missing sessions_spawn tool");
const result = await tool.execute("call-thinking", {
task: "do thing",
thinking: "high",
});
expect(result.details).toMatchObject({
status: "accepted",
});
const agentCall = calls.find((call) => call.method === "agent");
expect(agentCall?.params).toMatchObject({
thinking: "high",
});
});
it("sessions_spawn rejects invalid thinking levels", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
const calls: Array<{ method?: string }> = [];
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string };
calls.push(request);
return {};
});
const tool = createClawdbotTools({
agentSessionKey: "discord:group:req",
agentChannel: "discord",
}).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) throw new Error("missing sessions_spawn tool");
const result = await tool.execute("call-thinking-invalid", {
task: "do thing",
thinking: "banana",
});
expect(result.details).toMatchObject({
status: "error",
});
expect(String(result.details?.error)).toMatch(/Invalid thinking level/i);
expect(calls).toHaveLength(0);
});
it("sessions_spawn applies default subagent model from defaults config", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
+59
View File
@@ -13,6 +13,7 @@ const log = createSubsystemLogger("agents/auth-profiles");
const CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH = ".claude/.credentials.json";
const CODEX_CLI_AUTH_FILENAME = "auth.json";
const QWEN_CLI_CREDENTIALS_RELATIVE_PATH = ".qwen/oauth_creds.json";
const CLAUDE_CLI_KEYCHAIN_SERVICE = "Claude Code-credentials";
const CLAUDE_CLI_KEYCHAIN_ACCOUNT = "Claude Code";
@@ -25,6 +26,7 @@ type CachedValue<T> = {
let claudeCliCache: CachedValue<ClaudeCliCredential> | null = null;
let codexCliCache: CachedValue<CodexCliCredential> | null = null;
let qwenCliCache: CachedValue<QwenCliCredential> | null = null;
export type ClaudeCliCredential =
| {
@@ -49,6 +51,14 @@ export type CodexCliCredential = {
expires: number;
};
export type QwenCliCredential = {
type: "oauth";
provider: "qwen-portal";
access: string;
refresh: string;
expires: number;
};
type ClaudeCliFileOptions = {
homeDir?: string;
};
@@ -78,6 +88,11 @@ function resolveCodexHomePath() {
}
}
function resolveQwenCliCredentialsPath(homeDir?: string) {
const baseDir = homeDir ?? resolveUserPath("~");
return path.join(baseDir, QWEN_CLI_CREDENTIALS_RELATIVE_PATH);
}
function computeCodexKeychainAccount(codexHome: string) {
const hash = createHash("sha256").update(codexHome).digest("hex");
return `cli|${hash.slice(0, 16)}`;
@@ -133,6 +148,28 @@ function readCodexKeychainCredentials(options?: {
}
}
function readQwenCliCredentials(options?: { homeDir?: string }): QwenCliCredential | null {
const credPath = resolveQwenCliCredentialsPath(options?.homeDir);
const raw = loadJsonFile(credPath);
if (!raw || typeof raw !== "object") return null;
const data = raw as Record<string, unknown>;
const accessToken = data.access_token;
const refreshToken = data.refresh_token;
const expiresAt = data.expiry_date;
if (typeof accessToken !== "string" || !accessToken) return null;
if (typeof refreshToken !== "string" || !refreshToken) return null;
if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt)) return null;
return {
type: "oauth",
provider: "qwen-portal",
access: accessToken,
refresh: refreshToken,
expires: expiresAt,
};
}
function readClaudeCliKeychainCredentials(): ClaudeCliCredential | null {
try {
const result = execSync(
@@ -406,3 +443,25 @@ export function readCodexCliCredentialsCached(options?: {
}
return value;
}
export function readQwenCliCredentialsCached(options?: {
ttlMs?: number;
homeDir?: string;
}): QwenCliCredential | null {
const ttlMs = options?.ttlMs ?? 0;
const now = Date.now();
const cacheKey = resolveQwenCliCredentialsPath(options?.homeDir);
if (
ttlMs > 0 &&
qwenCliCache &&
qwenCliCache.cacheKey === cacheKey &&
now - qwenCliCache.readAt < ttlMs
) {
return qwenCliCache.value;
}
const value = readQwenCliCredentials({ homeDir: options?.homeDir });
if (ttlMs > 0) {
qwenCliCache = { value, readAt: now, cacheKey };
}
return value;
}
+90
View File
@@ -29,6 +29,12 @@ describe("memory search config", () => {
memorySearch: {
provider: "openai",
model: "text-embedding-3-small",
store: {
vector: {
enabled: false,
extensionPath: "/opt/sqlite-vec.dylib",
},
},
chunking: { tokens: 500, overlap: 100 },
query: { maxResults: 4, minScore: 0.2 },
},
@@ -40,6 +46,11 @@ describe("memory search config", () => {
memorySearch: {
chunking: { tokens: 320 },
query: { maxResults: 8 },
store: {
vector: {
enabled: true,
},
},
},
},
],
@@ -52,6 +63,41 @@ describe("memory search config", () => {
expect(resolved?.chunking.overlap).toBe(100);
expect(resolved?.query.maxResults).toBe(8);
expect(resolved?.query.minScore).toBe(0.2);
expect(resolved?.store.vector.enabled).toBe(true);
expect(resolved?.store.vector.extensionPath).toBe("/opt/sqlite-vec.dylib");
});
it("includes batch defaults for openai without remote overrides", () => {
const cfg = {
agents: {
defaults: {
memorySearch: {
provider: "openai",
},
},
},
};
const resolved = resolveMemorySearchConfig(cfg, "main");
expect(resolved?.remote?.batch).toEqual({
enabled: true,
wait: true,
pollIntervalMs: 5000,
timeoutMinutes: 60,
});
});
it("keeps remote unset for local provider without overrides", () => {
const cfg = {
agents: {
defaults: {
memorySearch: {
provider: "local",
},
},
},
};
const resolved = resolveMemorySearchConfig(cfg, "main");
expect(resolved?.remote).toBeUndefined();
});
it("merges remote defaults with agent overrides", () => {
@@ -84,6 +130,50 @@ describe("memory search config", () => {
baseUrl: "https://agent.example/v1",
apiKey: "default-key",
headers: { "X-Default": "on" },
batch: {
enabled: true,
wait: true,
pollIntervalMs: 5000,
timeoutMinutes: 60,
},
});
});
it("gates session sources behind experimental flag", () => {
const cfg = {
agents: {
defaults: {
memorySearch: {
sources: ["memory", "sessions"],
},
},
list: [
{
id: "main",
default: true,
memorySearch: {
experimental: { sessionMemory: false },
},
},
],
},
};
const resolved = resolveMemorySearchConfig(cfg, "main");
expect(resolved?.sources).toEqual(["memory"]);
});
it("allows session sources when experimental flag is enabled", () => {
const cfg = {
agents: {
defaults: {
memorySearch: {
sources: ["memory", "sessions"],
experimental: { sessionMemory: true },
},
},
},
};
const resolved = resolveMemorySearchConfig(cfg, "main");
expect(resolved?.sources).toContain("sessions");
});
});
Loaded 100 of 286 files, more files were not shown because too many files have changed in this diff. Show more