Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43c2c9b697 | ||
|
|
bb82851124 | ||
|
|
ca627ddcd4 | ||
|
|
9a6964b81f | ||
|
|
0f662c2935 | ||
|
|
32bcd291d5 | ||
|
|
5f9863098b | ||
|
|
fdecf5c59a | ||
|
|
83f92e34af | ||
|
|
9366cbc7db | ||
|
|
d4f895d8f2 | ||
|
|
f08c34a73f | ||
|
|
6a9301c27d | ||
|
|
653401774d | ||
|
|
c6cdbb630c | ||
|
|
da2439f2cc | ||
|
|
92ab3f22dc | ||
|
|
43a6c5b77f | ||
|
|
495616d13e | ||
|
|
bac80f0886 | ||
|
|
ef078fec70 | ||
|
|
8e3ac01db6 | ||
|
|
c3f90dd4e2 | ||
|
|
00c4556d7b | ||
|
|
a4bc69dbec | ||
|
|
3f1457de2a | ||
|
|
8507ea08bd | ||
|
|
7ae2548fc6 | ||
|
|
f06f83ddd0 | ||
|
|
c78297d80f | ||
|
|
69f6e1a20b | ||
|
|
5f6409a73d | ||
|
|
06a7e1e8ce | ||
|
|
9eaaadf8ee | ||
|
|
d4f60bf16a | ||
|
|
ede5145191 | ||
|
|
26d3fbb09f | ||
|
|
f7c89ba796 | ||
|
|
9afde64e26 | ||
|
|
8682524da3 | ||
|
|
5956dde459 | ||
|
|
50bb418fe7 | ||
|
|
458e731f8b | ||
|
|
ca78ccf74c | ||
|
|
580fd7abbd | ||
|
|
4a82c258c7 | ||
|
|
58c7c61e62 | ||
|
|
629ce4454d | ||
|
|
617d8a12d7 | ||
|
|
cdceff2284 | ||
|
|
cb52ffb842 | ||
|
|
3a35d313d9 | ||
|
|
116fbb747f |
No files matched your search
@@ -18,6 +18,7 @@
|
|||||||
- Docs are hosted on Mintlify (docs.clawd.bot).
|
- Docs are hosted on Mintlify (docs.clawd.bot).
|
||||||
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`).
|
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`).
|
||||||
- Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`).
|
- Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`).
|
||||||
|
- Doc headings and anchors: avoid em dashes and apostrophes in headings because they break Mintlify anchor links.
|
||||||
- When Peter asks for links, reply with full `https://docs.clawd.bot/...` URLs (not root-relative).
|
- When Peter asks for links, reply with full `https://docs.clawd.bot/...` URLs (not root-relative).
|
||||||
- When you touch docs, end the reply with the `https://docs.clawd.bot/...` URLs you referenced.
|
- When you touch docs, end the reply with the `https://docs.clawd.bot/...` URLs you referenced.
|
||||||
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
|
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
|
||||||
|
|||||||
+19
-1
@@ -10,31 +10,49 @@ Docs: https://docs.clawd.bot
|
|||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.clawd.bot/tts
|
- TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.clawd.bot/tts
|
||||||
|
- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web
|
||||||
|
- TTS: add auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts
|
||||||
|
- Channels: add LINE plugin (Messaging API) with rich replies, quick replies, and plugin HTTP registry. (#1630) Thanks @plum-dawg.
|
||||||
- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).
|
- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).
|
||||||
- Docs: add verbose installer troubleshooting guidance.
|
- Docs: add verbose installer troubleshooting guidance.
|
||||||
|
- Docs: add macOS VM guide with local/hosted options + VPS/nodes guidance. (#1693) Thanks @f-trycua.
|
||||||
- Docs: update Fly.io guide notes.
|
- Docs: update Fly.io guide notes.
|
||||||
- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock
|
- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock
|
||||||
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
|
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
|
||||||
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.
|
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.
|
||||||
|
- Telegram: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram
|
||||||
|
- Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal.
|
||||||
|
- Telegram: add verbose raw-update logging for inbound Telegram updates. (#1597) Thanks @rohannagpal.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
|
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
|
||||||
- Web UI: hide internal `message_id` hints in chat bubbles.
|
- Web UI: hide internal `message_id` hints in chat bubbles.
|
||||||
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
|
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
|
||||||
|
- Web UI: clear stale disconnect banners on reconnect; allow form saves with unsupported schema paths but block missing schema. (#1707) Thanks @Glucksberg.
|
||||||
- Heartbeat: normalize target identifiers for consistent routing.
|
- Heartbeat: normalize target identifiers for consistent routing.
|
||||||
- TUI: reload history after gateway reconnect to restore session state. (#1663)
|
- TUI: reload history after gateway reconnect to restore session state. (#1663)
|
||||||
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
|
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
|
||||||
|
- Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.
|
||||||
|
- Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev.
|
||||||
|
- Signal: add configurable signal-cli startup timeout + external daemon mode docs. (#1677) https://docs.clawd.bot/channels/signal
|
||||||
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
|
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
|
||||||
- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
|
- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
|
||||||
- Agents: use the active auth profile for auto-compaction recovery.
|
- Agents: use the active auth profile for auto-compaction recovery.
|
||||||
- Models: default missing custom provider fields so minimal configs are accepted.
|
- Models: default missing custom provider fields so minimal configs are accepted.
|
||||||
|
- Media understanding: skip image understanding when the primary model already supports vision. (#1747) Thanks @tyler6204.
|
||||||
- Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671)
|
- Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671)
|
||||||
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
|
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
|
||||||
|
- Gateway: clarify Control UI/WebChat auth error hints for missing tokens. (#1690)
|
||||||
|
- Gateway: listen on IPv6 loopback when bound to 127.0.0.1 so localhost webhooks work.
|
||||||
|
- Gateway: store lock files in the temp directory to avoid stale locks on persistent volumes. (#1676)
|
||||||
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
|
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
|
||||||
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
|
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
|
||||||
- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.
|
- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.
|
||||||
- Google Chat: normalize space targets without double `spaces/` prefix.
|
- Google Chat: normalize space targets without double `spaces/` prefix.
|
||||||
- CLI: register lazy subcommands before parse so non-help invocations work. (#1683) Thanks @grrowl.
|
- Messaging: keep newline chunking safe for fenced markdown blocks across channels.
|
||||||
|
- Tests: cap Vitest workers on CI macOS to reduce timeouts. (#1597) Thanks @rohannagpal.
|
||||||
|
- Tests: avoid fake-timer dependency in embedded runner stream mock to reduce CI flakes. (#1597) Thanks @rohannagpal.
|
||||||
|
- Tests: increase embedded runner ordering test timeout to reduce CI flakes. (#1597) Thanks @rohannagpal.
|
||||||
|
|
||||||
## 2026.1.23-1
|
## 2026.1.23-1
|
||||||
|
|
||||||
|
|||||||
@@ -477,31 +477,32 @@ Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and
|
|||||||
Thanks to all clawtributors:
|
Thanks to all clawtributors:
|
||||||
|
|
||||||
<p align="left">
|
<p align="left">
|
||||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/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/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a>
|
||||||
<a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/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/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></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/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/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/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/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/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/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/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/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/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/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a>
|
<a href="https://github.com/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/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/jonisjongithub"><img src="https://avatars.githubusercontent.com/u/86072337?v=4&s=48" width="48" height="48" alt="jonisjongithub" title="jonisjongithub"/></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/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/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/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/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></a> <a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/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/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a>
|
<a href="https://github.com/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></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/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></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/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/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/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></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/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></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/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/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/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/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></a>
|
||||||
<a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/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/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/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></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/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/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/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a>
|
||||||
<a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/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/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/denysvitali"><img src="https://avatars.githubusercontent.com/u/4939519?v=4&s=48" width="48" height="48" alt="denysvitali" title="denysvitali"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a>
|
||||||
<a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></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/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></a>
|
<a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a>
|
||||||
<a href="https://github.com/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/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></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/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a>
|
<a href="https://github.com/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/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></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/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></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/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></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/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/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a>
|
<a href="https://github.com/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/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="kyleok" title="kyleok"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/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/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/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/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/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/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a>
|
<a href="https://github.com/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/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a>
|
||||||
<a href="https://github.com/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/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/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/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/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/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/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a>
|
||||||
<a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a>
|
<a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/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/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a>
|
||||||
<a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/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/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/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/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/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a>
|
||||||
<a href="https://github.com/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/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/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a>
|
<a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/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/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/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a>
|
||||||
<a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/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/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/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a>
|
||||||
<a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/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/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/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/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a>
|
<a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a>
|
||||||
<a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a>
|
<a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></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/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a> <a href="https://github.com/ivancasco"><img src="https://avatars.githubusercontent.com/u/2452858?v=4&s=48" width="48" height="48" alt="ivancasco" title="ivancasco"/></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/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a>
|
||||||
<a href="https://github.com/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></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/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></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/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a>
|
<a href="https://github.com/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/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></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/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></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/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a> <a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></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/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></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/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></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/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/arthyn"><img src="https://avatars.githubusercontent.com/u/5466421?v=4&s=48" width="48" height="48" alt="arthyn" title="arthyn"/></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/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a>
|
||||||
<a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a>
|
<a href="https://github.com/dasilva333"><img src="https://avatars.githubusercontent.com/u/947827?v=4&s=48" width="48" height="48" alt="dasilva333" title="dasilva333"/></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/EnzeD"><img src="https://avatars.githubusercontent.com/u/9866900?v=4&s=48" width="48" height="48" alt="EnzeD" title="EnzeD"/></a> <a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/grrowl"><img src="https://avatars.githubusercontent.com/u/907140?v=4&s=48" width="48" height="48" alt="grrowl" title="grrowl"/></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/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></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/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a>
|
<a href="https://github.com/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/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a>
|
||||||
<a href="https://github.com/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/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></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/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></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/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=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></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/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a>
|
||||||
<a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a>
|
<a href="https://github.com/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/sergical"><img src="https://avatars.githubusercontent.com/u/3760543?v=4&s=48" width="48" height="48" alt="sergical" title="sergical"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></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/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></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/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a>
|
<a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a>
|
||||||
<a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
<a href="https://github.com/search?q=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/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a>
|
||||||
|
<a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||||
</p>
|
</p>
|
||||||
@@ -205,6 +205,7 @@ Notes:
|
|||||||
## Capabilities & limits
|
## Capabilities & limits
|
||||||
- DMs and guild text channels (threads are treated as separate channels; voice not supported).
|
- DMs and guild text channels (threads are treated as separate channels; voice not supported).
|
||||||
- Typing indicators sent best-effort; message chunking uses `channels.discord.textChunkLimit` (default 2000) and splits tall replies by line count (`channels.discord.maxLinesPerMessage`, default 17).
|
- Typing indicators sent best-effort; message chunking uses `channels.discord.textChunkLimit` (default 2000) and splits tall replies by line count (`channels.discord.maxLinesPerMessage`, default 17).
|
||||||
|
- Optional newline chunking: set `channels.discord.chunkMode="newline"` to split on each line before length chunking.
|
||||||
- File uploads supported up to the configured `channels.discord.mediaMaxMb` (default 8 MB).
|
- File uploads supported up to the configured `channels.discord.mediaMaxMb` (default 8 MB).
|
||||||
- Mention-gated guild replies by default to avoid noisy bots.
|
- Mention-gated guild replies by default to avoid noisy bots.
|
||||||
- Reply context is injected when a message references another message (quoted content + ids).
|
- Reply context is injected when a message references another message (quoted content + ids).
|
||||||
@@ -306,6 +307,7 @@ ack reaction after the bot replies.
|
|||||||
- `guilds.<id>.requireMention`: per-guild mention requirement (overridable per channel).
|
- `guilds.<id>.requireMention`: per-guild mention requirement (overridable per channel).
|
||||||
- `guilds.<id>.reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`).
|
- `guilds.<id>.reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`).
|
||||||
- `textChunkLimit`: outbound text chunk size (chars). Default: 2000.
|
- `textChunkLimit`: outbound text chunk size (chars). Default: 2000.
|
||||||
|
- `chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on every newline before length chunking.
|
||||||
- `maxLinesPerMessage`: soft max line count per message. Default: 17.
|
- `maxLinesPerMessage`: soft max line count per message. Default: 17.
|
||||||
- `mediaMaxMb`: clamp inbound media saved to disk.
|
- `mediaMaxMb`: clamp inbound media saved to disk.
|
||||||
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20; falls back to `messages.groupChat.historyLimit`; `0` disables).
|
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20; falls back to `messages.groupChat.historyLimit`; `0` disables).
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ read_when:
|
|||||||
- **Proxy:** optional `channels.telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`.
|
- **Proxy:** optional `channels.telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`.
|
||||||
- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` is set (otherwise it long-polls).
|
- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` is set (otherwise it long-polls).
|
||||||
- **Sessions:** direct chats collapse into the agent main session (`agent:<agentId>:<mainKey>`); groups use `agent:<agentId>:telegram:group:<chatId>`; replies route back to the same channel.
|
- **Sessions:** direct chats collapse into the agent main session (`agent:<agentId>:<mainKey>`); groups use `agent:<agentId>:telegram:group:<chatId>`; replies route back to the same channel.
|
||||||
- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`.
|
- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.linkPreview`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`.
|
||||||
- **Draft streaming:** optional `channels.telegram.streamMode` uses `sendMessageDraft` in private topic chats (Bot API 9.3+). This is separate from channel block streaming.
|
- **Draft streaming:** optional `channels.telegram.streamMode` uses `sendMessageDraft` in private topic chats (Bot API 9.3+). This is separate from channel block streaming.
|
||||||
- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.
|
- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.
|
||||||
|
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ This is useful when you want an isolated personality/model for a specific thread
|
|||||||
|
|
||||||
## Limits
|
## Limits
|
||||||
- Outbound text is chunked to `channels.imessage.textChunkLimit` (default 4000).
|
- Outbound text is chunked to `channels.imessage.textChunkLimit` (default 4000).
|
||||||
|
- Optional newline chunking: set `channels.imessage.chunkMode="newline"` to split on each line before length chunking.
|
||||||
- Media uploads are capped by `channels.imessage.mediaMaxMb` (default 16).
|
- Media uploads are capped by `channels.imessage.mediaMaxMb` (default 16).
|
||||||
|
|
||||||
## Addressing / delivery targets
|
## Addressing / delivery targets
|
||||||
@@ -253,6 +254,7 @@ Provider options:
|
|||||||
- `channels.imessage.includeAttachments`: ingest attachments into context.
|
- `channels.imessage.includeAttachments`: ingest attachments into context.
|
||||||
- `channels.imessage.mediaMaxMb`: inbound/outbound media cap (MB).
|
- `channels.imessage.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||||
- `channels.imessage.textChunkLimit`: outbound chunk size (chars).
|
- `channels.imessage.textChunkLimit`: outbound chunk size (chars).
|
||||||
|
- `channels.imessage.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
|
||||||
|
|
||||||
Related global options:
|
Related global options:
|
||||||
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`).
|
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`).
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ Text is supported everywhere; media and reactions vary by channel.
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Channels can run simultaneously; configure multiple and Clawdbot will route per chat.
|
- Channels can run simultaneously; configure multiple and Clawdbot will route per chat.
|
||||||
|
- Fastest setup is usually **Telegram** (simple bot token). WhatsApp requires QR pairing and
|
||||||
|
stores more state on disk.
|
||||||
- Group behavior varies by channel; see [Groups](/concepts/groups).
|
- Group behavior varies by channel; see [Groups](/concepts/groups).
|
||||||
- DM pairing and allowlists are enforced for safety; see [Security](/gateway/security).
|
- DM pairing and allowlists are enforced for safety; see [Security](/gateway/security).
|
||||||
- Telegram internals: [grammY notes](/channels/grammy).
|
- Telegram internals: [grammY notes](/channels/grammy).
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ Provider options:
|
|||||||
- `channels.matrix.initialSyncLimit`: initial sync limit.
|
- `channels.matrix.initialSyncLimit`: initial sync limit.
|
||||||
- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound).
|
- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound).
|
||||||
- `channels.matrix.textChunkLimit`: outbound text chunk size (chars).
|
- `channels.matrix.textChunkLimit`: outbound text chunk size (chars).
|
||||||
|
- `channels.matrix.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
|
||||||
- `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing).
|
- `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||||
- `channels.matrix.dm.allowFrom`: DM allowlist (user IDs or display names). `open` requires `"*"`. The wizard resolves names to IDs when possible.
|
- `channels.matrix.dm.allowFrom`: DM allowlist (user IDs or display names). `open` requires `"*"`. The wizard resolves names to IDs when possible.
|
||||||
- `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist).
|
- `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist).
|
||||||
|
|||||||
@@ -415,6 +415,7 @@ Key settings (see `/gateway/configuration` for shared channel patterns):
|
|||||||
- `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing)
|
- `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing)
|
||||||
- `channels.msteams.allowFrom`: allowlist for DMs (AAD object IDs, UPNs, or display names). The wizard resolves names to IDs during setup when Graph access is available.
|
- `channels.msteams.allowFrom`: allowlist for DMs (AAD object IDs, UPNs, or display names). The wizard resolves names to IDs during setup when Graph access is available.
|
||||||
- `channels.msteams.textChunkLimit`: outbound text chunk size.
|
- `channels.msteams.textChunkLimit`: outbound text chunk size.
|
||||||
|
- `channels.msteams.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
|
||||||
- `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains).
|
- `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains).
|
||||||
- `channels.msteams.requireMention`: require @mention in channels/groups (default true).
|
- `channels.msteams.requireMention`: require @mention in channels/groups (default true).
|
||||||
- `channels.msteams.replyStyle`: `thread | top-level` (see [Reply Style](#reply-style-threads-vs-posts)).
|
- `channels.msteams.replyStyle`: `thread | top-level` (see [Reply Style](#reply-style-threads-vs-posts)).
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ Provider options:
|
|||||||
- `channels.nextcloud-talk.dmHistoryLimit`: DM history limit (0 disables).
|
- `channels.nextcloud-talk.dmHistoryLimit`: DM history limit (0 disables).
|
||||||
- `channels.nextcloud-talk.dms`: per-DM overrides (historyLimit).
|
- `channels.nextcloud-talk.dms`: per-DM overrides (historyLimit).
|
||||||
- `channels.nextcloud-talk.textChunkLimit`: outbound text chunk size (chars).
|
- `channels.nextcloud-talk.textChunkLimit`: outbound text chunk size (chars).
|
||||||
|
- `channels.nextcloud-talk.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
|
||||||
- `channels.nextcloud-talk.blockStreaming`: disable block streaming for this channel.
|
- `channels.nextcloud-talk.blockStreaming`: disable block streaming for this channel.
|
||||||
- `channels.nextcloud-talk.blockStreamingCoalesce`: block streaming coalesce tuning.
|
- `channels.nextcloud-talk.blockStreamingCoalesce`: block streaming coalesce tuning.
|
||||||
- `channels.nextcloud-talk.mediaMaxMb`: inbound media cap (MB).
|
- `channels.nextcloud-talk.mediaMaxMb`: inbound media cap (MB).
|
||||||
@@ -74,6 +74,22 @@ Example:
|
|||||||
|
|
||||||
Multi-account support: use `channels.signal.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
|
Multi-account support: use `channels.signal.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
|
||||||
|
|
||||||
|
## External daemon mode (httpUrl)
|
||||||
|
If you want to manage `signal-cli` yourself (slow JVM cold starts, container init, or shared CPUs), run the daemon separately and point Clawdbot at it:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
signal: {
|
||||||
|
httpUrl: "http://127.0.0.1:8080",
|
||||||
|
autoStart: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This skips auto-spawn and the startup wait inside Clawdbot. For slow starts when auto-spawning, set `channels.signal.startupTimeoutMs`.
|
||||||
|
|
||||||
## Access control (DMs + groups)
|
## Access control (DMs + groups)
|
||||||
DMs:
|
DMs:
|
||||||
- Default: `channels.signal.dmPolicy = "pairing"`.
|
- Default: `channels.signal.dmPolicy = "pairing"`.
|
||||||
@@ -95,6 +111,7 @@ Groups:
|
|||||||
|
|
||||||
## Media + limits
|
## Media + limits
|
||||||
- Outbound text is chunked to `channels.signal.textChunkLimit` (default 4000).
|
- Outbound text is chunked to `channels.signal.textChunkLimit` (default 4000).
|
||||||
|
- Optional newline chunking: set `channels.signal.chunkMode="newline"` to split on each line before length chunking.
|
||||||
- Attachments supported (base64 fetched from `signal-cli`).
|
- Attachments supported (base64 fetched from `signal-cli`).
|
||||||
- Default media cap: `channels.signal.mediaMaxMb` (default 8).
|
- Default media cap: `channels.signal.mediaMaxMb` (default 8).
|
||||||
- Use `channels.signal.ignoreAttachments` to skip downloading media.
|
- Use `channels.signal.ignoreAttachments` to skip downloading media.
|
||||||
@@ -105,8 +122,29 @@ Groups:
|
|||||||
- **Read receipts**: when `channels.signal.sendReadReceipts` is true, Clawdbot forwards read receipts for allowed DMs.
|
- **Read receipts**: when `channels.signal.sendReadReceipts` is true, Clawdbot forwards read receipts for allowed DMs.
|
||||||
- Signal-cli does not expose read receipts for groups.
|
- Signal-cli does not expose read receipts for groups.
|
||||||
|
|
||||||
|
## Reactions (message tool)
|
||||||
|
- Use `message action=react` with `channel=signal`.
|
||||||
|
- Targets: sender E.164 or UUID (use `uuid:<id>` from pairing output; bare UUID works too).
|
||||||
|
- `messageId` is the Signal timestamp for the message you’re reacting to.
|
||||||
|
- Group reactions require `targetAuthor` or `targetAuthorUuid`.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
```
|
||||||
|
message action=react channel=signal target=uuid:123e4567-e89b-12d3-a456-426614174000 messageId=1737630212345 emoji=🔥
|
||||||
|
message action=react channel=signal target=+15551234567 messageId=1737630212345 emoji=🔥 remove=true
|
||||||
|
message action=react channel=signal target=signal:group:<groupId> targetAuthor=uuid:<sender-uuid> messageId=1737630212345 emoji=✅
|
||||||
|
```
|
||||||
|
|
||||||
|
Config:
|
||||||
|
- `channels.signal.actions.reactions`: enable/disable reaction actions (default true).
|
||||||
|
- `channels.signal.reactionLevel`: `off | ack | minimal | extensive`.
|
||||||
|
- `off`/`ack` disables agent reactions (message tool `react` will error).
|
||||||
|
- `minimal`/`extensive` enables agent reactions and sets the guidance level.
|
||||||
|
- Per-account overrides: `channels.signal.accounts.<id>.actions.reactions`, `channels.signal.accounts.<id>.reactionLevel`.
|
||||||
|
|
||||||
## Delivery targets (CLI/cron)
|
## Delivery targets (CLI/cron)
|
||||||
- DMs: `signal:+15551234567` (or plain E.164).
|
- DMs: `signal:+15551234567` (or plain E.164).
|
||||||
|
- UUID DMs: `uuid:<id>` (or bare UUID).
|
||||||
- Groups: `signal:group:<groupId>`.
|
- Groups: `signal:group:<groupId>`.
|
||||||
- Usernames: `username:<name>` (if supported by your Signal account).
|
- Usernames: `username:<name>` (if supported by your Signal account).
|
||||||
|
|
||||||
@@ -120,6 +158,7 @@ Provider options:
|
|||||||
- `channels.signal.httpUrl`: full daemon URL (overrides host/port).
|
- `channels.signal.httpUrl`: full daemon URL (overrides host/port).
|
||||||
- `channels.signal.httpHost`, `channels.signal.httpPort`: daemon bind (default 127.0.0.1:8080).
|
- `channels.signal.httpHost`, `channels.signal.httpPort`: daemon bind (default 127.0.0.1:8080).
|
||||||
- `channels.signal.autoStart`: auto-spawn daemon (default true if `httpUrl` unset).
|
- `channels.signal.autoStart`: auto-spawn daemon (default true if `httpUrl` unset).
|
||||||
|
- `channels.signal.startupTimeoutMs`: startup wait timeout in ms (cap 120000).
|
||||||
- `channels.signal.receiveMode`: `on-start | manual`.
|
- `channels.signal.receiveMode`: `on-start | manual`.
|
||||||
- `channels.signal.ignoreAttachments`: skip attachment downloads.
|
- `channels.signal.ignoreAttachments`: skip attachment downloads.
|
||||||
- `channels.signal.ignoreStories`: ignore stories from the daemon.
|
- `channels.signal.ignoreStories`: ignore stories from the daemon.
|
||||||
@@ -131,6 +170,7 @@ Provider options:
|
|||||||
- `channels.signal.historyLimit`: max group messages to include as context (0 disables).
|
- `channels.signal.historyLimit`: max group messages to include as context (0 disables).
|
||||||
- `channels.signal.dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `channels.signal.dms["<phone_or_uuid>"].historyLimit`.
|
- `channels.signal.dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `channels.signal.dms["<phone_or_uuid>"].historyLimit`.
|
||||||
- `channels.signal.textChunkLimit`: outbound chunk size (chars).
|
- `channels.signal.textChunkLimit`: outbound chunk size (chars).
|
||||||
|
- `channels.signal.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
|
||||||
- `channels.signal.mediaMaxMb`: inbound/outbound media cap (MB).
|
- `channels.signal.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||||
|
|
||||||
Related global options:
|
Related global options:
|
||||||
|
|||||||
@@ -349,6 +349,7 @@ ack reaction after the bot replies.
|
|||||||
|
|
||||||
## Limits
|
## Limits
|
||||||
- Outbound text is chunked to `channels.slack.textChunkLimit` (default 4000).
|
- Outbound text is chunked to `channels.slack.textChunkLimit` (default 4000).
|
||||||
|
- Optional newline chunking: set `channels.slack.chunkMode="newline"` to split on each line before length chunking.
|
||||||
- Media uploads are capped by `channels.slack.mediaMaxMb` (default 20).
|
- Media uploads are capped by `channels.slack.mediaMaxMb` (default 20).
|
||||||
|
|
||||||
## Reply threading
|
## Reply threading
|
||||||
|
|||||||
@@ -120,6 +120,13 @@ You can add custom commands to the menu via config:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- `setMyCommands failed` in logs usually means outbound HTTPS/DNS is blocked to `api.telegram.org`.
|
||||||
|
- If you see `sendMessage` or `sendChatAction` failures, check IPv6 routing and DNS.
|
||||||
|
|
||||||
|
More help: [Channel troubleshooting](/channels/troubleshooting).
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Custom commands are **menu entries only**; Clawdbot does not implement them unless you handle them elsewhere.
|
- Custom commands are **menu entries only**; Clawdbot does not implement them unless you handle them elsewhere.
|
||||||
- Command names are normalized (leading `/` stripped, lowercased) and must match `a-z`, `0-9`, `_` (1–32 chars).
|
- Command names are normalized (leading `/` stripped, lowercased) and must match `a-z`, `0-9`, `_` (1–32 chars).
|
||||||
@@ -128,6 +135,7 @@ Notes:
|
|||||||
|
|
||||||
## Limits
|
## Limits
|
||||||
- Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000).
|
- Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000).
|
||||||
|
- Optional newline chunking: set `channels.telegram.chunkMode="newline"` to split on each line before length chunking.
|
||||||
- Media downloads/uploads are capped by `channels.telegram.mediaMaxMb` (default 5).
|
- Media downloads/uploads are capped by `channels.telegram.mediaMaxMb` (default 5).
|
||||||
- Telegram Bot API requests time out after `channels.telegram.timeoutSeconds` (default 500 via grammY). Set lower to avoid long hangs.
|
- Telegram Bot API requests time out after `channels.telegram.timeoutSeconds` (default 500 via grammY). Set lower to avoid long hangs.
|
||||||
- Group history context uses `channels.telegram.historyLimit` (or `channels.telegram.accounts.*.historyLimit`), falling back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
|
- Group history context uses `channels.telegram.historyLimit` (or `channels.telegram.accounts.*.historyLimit`), falling back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
|
||||||
@@ -516,6 +524,8 @@ Provider options:
|
|||||||
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
|
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
|
||||||
- `channels.telegram.replyToMode`: `off | first | all` (default: `first`).
|
- `channels.telegram.replyToMode`: `off | first | all` (default: `first`).
|
||||||
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
|
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
|
||||||
|
- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
|
||||||
|
- `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true).
|
||||||
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
|
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
|
||||||
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
|
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||||
- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
||||||
|
|||||||
@@ -22,3 +22,4 @@ clawdbot channels status --probe
|
|||||||
|
|
||||||
## Telegram quick fixes
|
## Telegram quick fixes
|
||||||
- Logs show `HttpError: Network request for 'sendMessage' failed` or `sendChatAction` → check IPv6 DNS. If `api.telegram.org` resolves to IPv6 first and the host lacks IPv6 egress, force IPv4 or enable IPv6. See [/channels/telegram#troubleshooting](/channels/telegram#troubleshooting).
|
- Logs show `HttpError: Network request for 'sendMessage' failed` or `sendChatAction` → check IPv6 DNS. If `api.telegram.org` resolves to IPv6 first and the host lacks IPv6 egress, force IPv4 or enable IPv6. See [/channels/telegram#troubleshooting](/channels/telegram#troubleshooting).
|
||||||
|
- Logs show `setMyCommands failed` → check outbound HTTPS and DNS reachability to `api.telegram.org` (common on locked-down VPS or proxies).
|
||||||
@@ -271,12 +271,13 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
|
|||||||
|
|
||||||
## Limits
|
## Limits
|
||||||
- Outbound text is chunked to `channels.whatsapp.textChunkLimit` (default 4000).
|
- Outbound text is chunked to `channels.whatsapp.textChunkLimit` (default 4000).
|
||||||
|
- Optional newline chunking: set `channels.whatsapp.chunkMode="newline"` to split on each line before length chunking.
|
||||||
- Inbound media saves are capped by `channels.whatsapp.mediaMaxMb` (default 50 MB).
|
- Inbound media saves are capped by `channels.whatsapp.mediaMaxMb` (default 50 MB).
|
||||||
- Outbound media items are capped by `agents.defaults.mediaMaxMb` (default 5 MB).
|
- Outbound media items are capped by `agents.defaults.mediaMaxMb` (default 5 MB).
|
||||||
|
|
||||||
## Outbound send (text + media)
|
## Outbound send (text + media)
|
||||||
- Uses active web listener; error if gateway not running.
|
- Uses active web listener; error if gateway not running.
|
||||||
- Text chunking: 4k max per message (configurable via `channels.whatsapp.textChunkLimit`).
|
- Text chunking: 4k max per message (configurable via `channels.whatsapp.textChunkLimit`, optional `channels.whatsapp.chunkMode`).
|
||||||
- Media:
|
- Media:
|
||||||
- Image/video/audio/document supported.
|
- Image/video/audio/document supported.
|
||||||
- Audio sent as PTT; `audio/ogg` => `audio/ogg; codecs=opus`.
|
- Audio sent as PTT; `audio/ogg` => `audio/ogg; codecs=opus`.
|
||||||
|
|||||||
+10
-2
@@ -66,11 +66,12 @@ Name lookup:
|
|||||||
- Discord only: `--poll-duration-hours`, `--message`
|
- Discord only: `--poll-duration-hours`, `--message`
|
||||||
|
|
||||||
- `react`
|
- `react`
|
||||||
- Channels: Discord/Google Chat/Slack/Telegram/WhatsApp
|
- Channels: Discord/Google Chat/Slack/Telegram/WhatsApp/Signal
|
||||||
- Required: `--message-id`, `--target`
|
- Required: `--message-id`, `--target`
|
||||||
- Optional: `--emoji`, `--remove`, `--participant`, `--from-me`
|
- Optional: `--emoji`, `--remove`, `--participant`, `--from-me`, `--target-author`, `--target-author-uuid`
|
||||||
- Note: `--remove` requires `--emoji` (omit `--emoji` to clear own reactions where supported; see /tools/reactions)
|
- Note: `--remove` requires `--emoji` (omit `--emoji` to clear own reactions where supported; see /tools/reactions)
|
||||||
- WhatsApp only: `--participant`, `--from-me`
|
- WhatsApp only: `--participant`, `--from-me`
|
||||||
|
- Signal group reactions: `--target-author` or `--target-author-uuid` required
|
||||||
|
|
||||||
- `reactions`
|
- `reactions`
|
||||||
- Channels: Discord/Google Chat/Slack
|
- Channels: Discord/Google Chat/Slack
|
||||||
@@ -213,6 +214,13 @@ clawdbot message react --channel slack \
|
|||||||
--target C123 --message-id 456 --emoji "✅"
|
--target C123 --message-id 456 --emoji "✅"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
React in a Signal group:
|
||||||
|
```
|
||||||
|
clawdbot message react --channel signal \
|
||||||
|
--target signal:group:abc123 --message-id 1737630212345 \
|
||||||
|
--emoji "✅" --target-author-uuid 123e4567-e89b-12d3-a456-426614174000
|
||||||
|
```
|
||||||
|
|
||||||
Send Telegram inline buttons:
|
Send Telegram inline buttons:
|
||||||
```
|
```
|
||||||
clawdbot message send --channel telegram --target @mychat --message "Choose:" \
|
clawdbot message send --channel telegram --target @mychat --message "Choose:" \
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ These files live under the workspace (`agents.defaults.workspace`, default
|
|||||||
- Decisions, preferences, and durable facts go to `MEMORY.md`.
|
- Decisions, preferences, and durable facts go to `MEMORY.md`.
|
||||||
- Day-to-day notes and running context go to `memory/YYYY-MM-DD.md`.
|
- Day-to-day notes and running context go to `memory/YYYY-MM-DD.md`.
|
||||||
- If someone says "remember this," write it down (do not keep it in RAM).
|
- If someone says "remember this," write it down (do not keep it in RAM).
|
||||||
|
- This area is still evolving. It helps to remind the model to store memories; it will know what to do.
|
||||||
|
- If you want something to stick, **ask the bot to write it** into memory.
|
||||||
|
|
||||||
## Automatic memory flush (pre-compaction ping)
|
## Automatic memory flush (pre-compaction ping)
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ Clawdbot ships with the pi‑ai catalog. These providers require **no**
|
|||||||
- Gemini CLI OAuth is shipped as a bundled plugin (`google-gemini-cli-auth`, disabled by default).
|
- Gemini CLI OAuth is shipped as a bundled plugin (`google-gemini-cli-auth`, disabled by default).
|
||||||
- Enable: `clawdbot plugins enable google-gemini-cli-auth`
|
- Enable: `clawdbot plugins enable google-gemini-cli-auth`
|
||||||
- Login: `clawdbot models auth login --provider google-gemini-cli --set-default`
|
- Login: `clawdbot models auth login --provider google-gemini-cli --set-default`
|
||||||
|
- Note: you do **not** paste a client id or secret into `clawdbot.json`. The CLI login flow stores
|
||||||
|
tokens in auth profiles on the gateway host.
|
||||||
|
|
||||||
### Z.AI (GLM)
|
### Z.AI (GLM)
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ Legend:
|
|||||||
- `agents.defaults.blockStreamingChunk`: `{ minChars, maxChars, breakPreference? }`.
|
- `agents.defaults.blockStreamingChunk`: `{ minChars, maxChars, breakPreference? }`.
|
||||||
- `agents.defaults.blockStreamingCoalesce`: `{ minChars?, maxChars?, idleMs? }` (merge streamed blocks before send).
|
- `agents.defaults.blockStreamingCoalesce`: `{ minChars?, maxChars?, idleMs? }` (merge streamed blocks before send).
|
||||||
- Channel hard cap: `*.textChunkLimit` (e.g., `channels.whatsapp.textChunkLimit`).
|
- Channel hard cap: `*.textChunkLimit` (e.g., `channels.whatsapp.textChunkLimit`).
|
||||||
|
- Channel chunk mode: `*.chunkMode` (`length` default, `newline` splits on each line before length chunking).
|
||||||
- Discord soft cap: `channels.discord.maxLinesPerMessage` (default 17) splits tall replies to avoid UI clipping.
|
- Discord soft cap: `channels.discord.maxLinesPerMessage` (default 17) splits tall replies to avoid UI clipping.
|
||||||
|
|
||||||
**Boundary semantics:**
|
**Boundary semantics:**
|
||||||
|
|||||||
@@ -1048,6 +1048,7 @@
|
|||||||
"pages": [
|
"pages": [
|
||||||
"platforms",
|
"platforms",
|
||||||
"platforms/macos",
|
"platforms/macos",
|
||||||
|
"platforms/macos-vm",
|
||||||
"platforms/ios",
|
"platforms/ios",
|
||||||
"platforms/android",
|
"platforms/android",
|
||||||
"platforms/windows",
|
"platforms/windows",
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ better forms without hard-coding config knowledge.
|
|||||||
Use `config.apply` to validate + write the full config and restart the Gateway in one step.
|
Use `config.apply` to validate + write the full config and restart the Gateway in one step.
|
||||||
It writes a restart sentinel and pings the last active session after the Gateway comes back.
|
It writes a restart sentinel and pings the last active session after the Gateway comes back.
|
||||||
|
|
||||||
|
Warning: `config.apply` replaces the **entire config**. If you want to change only a few keys,
|
||||||
|
use `config.patch` or `clawdbot config set`. Keep a backup of `~/.clawdbot/clawdbot.json`.
|
||||||
|
|
||||||
Params:
|
Params:
|
||||||
- `raw` (string) — JSON5 payload for the entire config
|
- `raw` (string) — JSON5 payload for the entire config
|
||||||
- `baseHash` (optional) — config hash from `config.get` (required when a config already exists)
|
- `baseHash` (optional) — config hash from `config.get` (required when a config already exists)
|
||||||
@@ -504,6 +507,7 @@ For groups, use `channels.whatsapp.groupPolicy` + `channels.whatsapp.groupAllowF
|
|||||||
dmPolicy: "pairing", // pairing | allowlist | open | disabled
|
dmPolicy: "pairing", // pairing | allowlist | open | disabled
|
||||||
allowFrom: ["+15555550123", "+447700900123"],
|
allowFrom: ["+15555550123", "+447700900123"],
|
||||||
textChunkLimit: 4000, // optional outbound chunk size (chars)
|
textChunkLimit: 4000, // optional outbound chunk size (chars)
|
||||||
|
chunkMode: "length", // optional chunking mode (length | newline)
|
||||||
mediaMaxMb: 50 // optional inbound media cap (MB)
|
mediaMaxMb: 50 // optional inbound media cap (MB)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1017,6 +1021,7 @@ Set `channels.telegram.configWrites: false` to block Telegram-initiated config w
|
|||||||
],
|
],
|
||||||
historyLimit: 50, // include last N group messages as context (0 disables)
|
historyLimit: 50, // include last N group messages as context (0 disables)
|
||||||
replyToMode: "first", // off | first | all
|
replyToMode: "first", // off | first | all
|
||||||
|
linkPreview: true, // toggle outbound link previews
|
||||||
streamMode: "partial", // off | partial | block (draft streaming; separate from block streaming)
|
streamMode: "partial", // off | partial | block (draft streaming; separate from block streaming)
|
||||||
draftChunk: { // optional; only for streamMode=block
|
draftChunk: { // optional; only for streamMode=block
|
||||||
minChars: 200,
|
minChars: 200,
|
||||||
@@ -1105,6 +1110,7 @@ Multi-account support lives under `channels.discord.accounts` (see the multi-acc
|
|||||||
},
|
},
|
||||||
historyLimit: 20, // include last N guild messages as context
|
historyLimit: 20, // include last N guild messages as context
|
||||||
textChunkLimit: 2000, // optional outbound text chunk size (chars)
|
textChunkLimit: 2000, // optional outbound text chunk size (chars)
|
||||||
|
chunkMode: "length", // optional chunking mode (length | newline)
|
||||||
maxLinesPerMessage: 17, // soft max lines per message (Discord UI clipping)
|
maxLinesPerMessage: 17, // soft max lines per message (Discord UI clipping)
|
||||||
retry: { // outbound retry policy
|
retry: { // outbound retry policy
|
||||||
attempts: 3,
|
attempts: 3,
|
||||||
@@ -1125,7 +1131,7 @@ Reaction notification modes:
|
|||||||
- `own`: reactions on the bot's own messages (default).
|
- `own`: reactions on the bot's own messages (default).
|
||||||
- `all`: all reactions on all messages.
|
- `all`: all reactions on all messages.
|
||||||
- `allowlist`: reactions from `guilds.<id>.users` on all messages (empty list disables).
|
- `allowlist`: reactions from `guilds.<id>.users` on all messages (empty list disables).
|
||||||
Outbound text is chunked by `channels.discord.textChunkLimit` (default 2000). Discord clients can clip very tall messages, so `channels.discord.maxLinesPerMessage` (default 17) splits long multi-line replies even when under 2000 chars.
|
Outbound text is chunked by `channels.discord.textChunkLimit` (default 2000). Set `channels.discord.chunkMode="newline"` to split on line boundaries before length chunking. Discord clients can clip very tall messages, so `channels.discord.maxLinesPerMessage` (default 17) splits long multi-line replies even when under 2000 chars.
|
||||||
Retry policy defaults and behavior are documented in [Retry policy](/concepts/retry).
|
Retry policy defaults and behavior are documented in [Retry policy](/concepts/retry).
|
||||||
|
|
||||||
### `channels.googlechat` (Chat API webhook)
|
### `channels.googlechat` (Chat API webhook)
|
||||||
@@ -1218,6 +1224,7 @@ Slack runs in Socket Mode and requires both a bot token and app token:
|
|||||||
ephemeral: true
|
ephemeral: true
|
||||||
},
|
},
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
|
chunkMode: "length",
|
||||||
mediaMaxMb: 20
|
mediaMaxMb: 20
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1267,7 +1274,8 @@ Mattermost requires a bot token plus the base URL for your server:
|
|||||||
dmPolicy: "pairing",
|
dmPolicy: "pairing",
|
||||||
chatmode: "oncall", // oncall | onmessage | onchar
|
chatmode: "oncall", // oncall | onmessage | onchar
|
||||||
oncharPrefixes: [">", "!"],
|
oncharPrefixes: [">", "!"],
|
||||||
textChunkLimit: 4000
|
textChunkLimit: 4000,
|
||||||
|
chunkMode: "length"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1502,7 +1510,7 @@ voice notes; other channels send MP3 audio.
|
|||||||
{
|
{
|
||||||
messages: {
|
messages: {
|
||||||
tts: {
|
tts: {
|
||||||
enabled: true,
|
auto: "always", // off | always | inbound | tagged
|
||||||
mode: "final", // final | all (include tool/block replies)
|
mode: "final", // final | all (include tool/block replies)
|
||||||
provider: "elevenlabs",
|
provider: "elevenlabs",
|
||||||
summaryModel: "openai/gpt-4.1-mini",
|
summaryModel: "openai/gpt-4.1-mini",
|
||||||
@@ -1539,8 +1547,10 @@ voice notes; other channels send MP3 audio.
|
|||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- `messages.tts.enabled` can be overridden by local user prefs (see `/tts on`, `/tts off`).
|
- `messages.tts.auto` controls auto‑TTS (`off`, `always`, `inbound`, `tagged`).
|
||||||
- `prefsPath` stores local overrides (enabled/provider/limit/summarize).
|
- `/tts off|always|inbound|tagged` sets the per‑session auto mode (overrides config).
|
||||||
|
- `messages.tts.enabled` is legacy; doctor migrates it to `messages.tts.auto`.
|
||||||
|
- `prefsPath` stores local overrides (provider/limit/summarize).
|
||||||
- `maxTextLength` is a hard cap for TTS input; summaries are truncated to fit.
|
- `maxTextLength` is a hard cap for TTS input; summaries are truncated to fit.
|
||||||
- `summaryModel` overrides `agents.defaults.model.primary` for auto-summary.
|
- `summaryModel` overrides `agents.defaults.model.primary` for auto-summary.
|
||||||
- Accepts `provider/model` or an alias from `agents.defaults.models`.
|
- Accepts `provider/model` or an alias from `agents.defaults.models`.
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ Clawdbot is both a product and an experiment: you’re wiring frontier-model beh
|
|||||||
- where the bot is allowed to act
|
- where the bot is allowed to act
|
||||||
- what the bot can touch
|
- what the bot can touch
|
||||||
|
|
||||||
|
Start with the smallest access that still works, then widen it as you gain confidence.
|
||||||
|
|
||||||
### What the audit checks (high level)
|
### What the audit checks (high level)
|
||||||
|
|
||||||
- **Inbound access** (DM policies, group policies, allowlists): can strangers trigger the bot?
|
- **Inbound access** (DM policies, group policies, allowlists): can strangers trigger the bot?
|
||||||
|
|||||||
+701
-180
File diff suppressed because it is too large.
Load diff
@@ -114,3 +114,9 @@ Git requirement:
|
|||||||
|
|
||||||
If you choose `-InstallMethod git` and Git is missing, the installer will print the
|
If you choose `-InstallMethod git` and Git is missing, the installer will print the
|
||||||
Git for Windows link (`https://git-scm.com/download/win`) and exit.
|
Git for Windows link (`https://git-scm.com/download/win`) and exit.
|
||||||
|
|
||||||
|
Common Windows issues:
|
||||||
|
|
||||||
|
- **npm error spawn git / ENOENT**: install Git for Windows and reopen PowerShell, then rerun the installer.
|
||||||
|
- **"clawdbot" is not recognized**: your npm global bin folder is not on PATH. Most systems use
|
||||||
|
`%AppData%\\npm`. You can also run `npm config get prefix` and add `\\bin` to PATH, then reopen PowerShell.
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
---
|
||||||
|
summary: "Run Clawdbot in a sandboxed macOS VM (local or hosted) when you need isolation or iMessage"
|
||||||
|
read_when:
|
||||||
|
- You want Clawdbot isolated from your main macOS environment
|
||||||
|
- You want iMessage integration (BlueBubbles) in a sandbox
|
||||||
|
- You want a resettable macOS environment you can clone
|
||||||
|
- You want to compare local vs hosted macOS VM options
|
||||||
|
---
|
||||||
|
|
||||||
|
# Clawdbot on macOS VMs (Sandboxing)
|
||||||
|
|
||||||
|
## Recommended default (most users)
|
||||||
|
|
||||||
|
- **Small Linux VPS** for an always-on Gateway and low cost. See [VPS hosting](/vps).
|
||||||
|
- **Dedicated hardware** (Mac mini or Linux box) if you want full control and a **residential IP** for browser automation. Many sites block data center IPs, so local browsing often works better.
|
||||||
|
- **Hybrid:** keep the Gateway on a cheap VPS, and connect your Mac as a **node** when you need browser/UI automation. See [Nodes](/nodes) and [Gateway remote](/gateway/remote).
|
||||||
|
|
||||||
|
Use a macOS VM when you specifically need macOS-only capabilities (iMessage/BlueBubbles) or want strict isolation from your daily Mac.
|
||||||
|
|
||||||
|
## macOS VM options
|
||||||
|
|
||||||
|
### Local VM on your Apple Silicon Mac (Lume)
|
||||||
|
|
||||||
|
Run Clawdbot in a sandboxed macOS VM on your existing Apple Silicon Mac using [Lume](https://cua.ai/docs/lume).
|
||||||
|
|
||||||
|
This gives you:
|
||||||
|
- Full macOS environment in isolation (your host stays clean)
|
||||||
|
- iMessage support via BlueBubbles (impossible on Linux/Windows)
|
||||||
|
- Instant reset by cloning VMs
|
||||||
|
- No extra hardware or cloud costs
|
||||||
|
|
||||||
|
### Hosted Mac providers (cloud)
|
||||||
|
|
||||||
|
If you want macOS in the cloud, hosted Mac providers work too:
|
||||||
|
- [MacStadium](https://www.macstadium.com/) (hosted Macs)
|
||||||
|
- Other hosted Mac vendors also work; follow their VM + SSH docs
|
||||||
|
|
||||||
|
Once you have SSH access to a macOS VM, continue at step 6 below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick path (Lume, experienced users)
|
||||||
|
|
||||||
|
1. Install Lume
|
||||||
|
2. `lume create clawdbot --os macos --ipsw latest`
|
||||||
|
3. Complete Setup Assistant, enable Remote Login (SSH)
|
||||||
|
4. `lume run clawdbot --no-display`
|
||||||
|
5. SSH in, install Clawdbot, configure channels
|
||||||
|
6. Done
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What you need (Lume)
|
||||||
|
|
||||||
|
- Apple Silicon Mac (M1/M2/M3/M4)
|
||||||
|
- macOS Sequoia or later on the host
|
||||||
|
- ~60 GB free disk space per VM
|
||||||
|
- ~20 minutes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) Install Lume
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/lume/scripts/install.sh)"
|
||||||
|
```
|
||||||
|
|
||||||
|
If `~/.local/bin` isn't in your PATH:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo 'export PATH="$PATH:$HOME/.local/bin"' >> ~/.zshrc && source ~/.zshrc
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lume --version
|
||||||
|
```
|
||||||
|
|
||||||
|
Docs: [Lume Installation](https://cua.ai/docs/lume/guide/getting-started/installation)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Create the macOS VM
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lume create clawdbot --os macos --ipsw latest
|
||||||
|
```
|
||||||
|
|
||||||
|
This downloads macOS and creates the VM. A VNC window opens automatically.
|
||||||
|
|
||||||
|
Note: The download can take a while depending on your connection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Complete Setup Assistant
|
||||||
|
|
||||||
|
In the VNC window:
|
||||||
|
1. Select language and region
|
||||||
|
2. Skip Apple ID (or sign in if you want iMessage later)
|
||||||
|
3. Create a user account (remember the username and password)
|
||||||
|
4. Skip all optional features
|
||||||
|
|
||||||
|
After setup completes, enable SSH:
|
||||||
|
1. Open System Settings → General → Sharing
|
||||||
|
2. Enable "Remote Login"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Get the VM's IP address
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lume get clawdbot
|
||||||
|
```
|
||||||
|
|
||||||
|
Look for the IP address (usually `192.168.64.x`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) SSH into the VM
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh youruser@192.168.64.X
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `youruser` with the account you created, and the IP with your VM's IP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) Install Clawdbot
|
||||||
|
|
||||||
|
Inside the VM:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g clawdbot@latest
|
||||||
|
clawdbot onboard --install-daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
Follow the onboarding prompts to set up your model provider (Anthropic, OpenAI, etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7) Configure channels
|
||||||
|
|
||||||
|
Edit the config file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nano ~/.clawdbot/clawdbot.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Add your channels:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"whatsapp": {
|
||||||
|
"dmPolicy": "allowlist",
|
||||||
|
"allowFrom": ["+15551234567"]
|
||||||
|
},
|
||||||
|
"telegram": {
|
||||||
|
"botToken": "YOUR_BOT_TOKEN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then login to WhatsApp (scan QR):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot channels login
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8) Run the VM headlessly
|
||||||
|
|
||||||
|
Stop the VM and restart without display:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lume stop clawdbot
|
||||||
|
lume run clawdbot --no-display
|
||||||
|
```
|
||||||
|
|
||||||
|
The VM runs in the background. Clawdbot's daemon keeps the gateway running.
|
||||||
|
|
||||||
|
To check status:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh youruser@192.168.64.X "clawdbot status"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bonus: iMessage integration
|
||||||
|
|
||||||
|
This is the killer feature of running on macOS. Use [BlueBubbles](https://bluebubbles.app) to add iMessage to Clawdbot.
|
||||||
|
|
||||||
|
Inside the VM:
|
||||||
|
|
||||||
|
1. Download BlueBubbles from bluebubbles.app
|
||||||
|
2. Sign in with your Apple ID
|
||||||
|
3. Enable the Web API and set a password
|
||||||
|
4. Point BlueBubbles webhooks at your gateway (example: `https://your-gateway-host:3000/bluebubbles-webhook?password=<password>`)
|
||||||
|
|
||||||
|
Add to your Clawdbot config:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"bluebubbles": {
|
||||||
|
"serverUrl": "http://localhost:1234",
|
||||||
|
"password": "your-api-password",
|
||||||
|
"webhookPath": "/bluebubbles-webhook"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the gateway. Now your agent can send and receive iMessages.
|
||||||
|
|
||||||
|
Full setup details: [BlueBubbles channel](/channels/bluebubbles)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Save a golden image
|
||||||
|
|
||||||
|
Before customizing further, snapshot your clean state:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lume stop clawdbot
|
||||||
|
lume clone clawdbot clawdbot-golden
|
||||||
|
```
|
||||||
|
|
||||||
|
Reset anytime:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lume stop clawdbot && lume delete clawdbot
|
||||||
|
lume clone clawdbot-golden clawdbot
|
||||||
|
lume run clawdbot --no-display
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running 24/7
|
||||||
|
|
||||||
|
Keep the VM running by:
|
||||||
|
- Keeping your Mac plugged in
|
||||||
|
- Disabling sleep in System Settings → Energy Saver
|
||||||
|
- Using `caffeinate` if needed
|
||||||
|
|
||||||
|
For true always-on, consider a dedicated Mac mini or a small VPS. See [VPS hosting](/vps).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Problem | Solution |
|
||||||
|
|---------|----------|
|
||||||
|
| Can't SSH into VM | Check "Remote Login" is enabled in VM's System Settings |
|
||||||
|
| VM IP not showing | Wait for VM to fully boot, run `lume get clawdbot` again |
|
||||||
|
| Lume command not found | Add `~/.local/bin` to your PATH |
|
||||||
|
| WhatsApp QR not scanning | Ensure you're logged into the VM (not host) when running `clawdbot channels login` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related docs
|
||||||
|
|
||||||
|
- [VPS hosting](/vps)
|
||||||
|
- [Nodes](/nodes)
|
||||||
|
- [Gateway remote](/gateway/remote)
|
||||||
|
- [BlueBubbles channel](/channels/bluebubbles)
|
||||||
|
- [Lume Quickstart](https://cua.ai/docs/lume/guide/getting-started/quickstart)
|
||||||
|
- [Lume CLI Reference](https://cua.ai/docs/lume/reference/cli-reference)
|
||||||
|
- [Unattended VM Setup](https://cua.ai/docs/lume/guide/fundamentals/unattended-setup) (advanced)
|
||||||
|
- [Docker Sandboxing](/install/docker) (alternative isolation approach)
|
||||||
@@ -67,6 +67,22 @@ Plugins can register:
|
|||||||
Plugins run **in‑process** with the Gateway, so treat them as trusted code.
|
Plugins run **in‑process** with the Gateway, so treat them as trusted code.
|
||||||
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
|
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
|
||||||
|
|
||||||
|
## Runtime helpers
|
||||||
|
|
||||||
|
Plugins can access selected core helpers via `api.runtime`. For telephony TTS:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const result = await api.runtime.tts.textToSpeechTelephony({
|
||||||
|
text: "Hello from Clawdbot",
|
||||||
|
cfg: api.config,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Uses core `messages.tts` configuration (OpenAI or ElevenLabs).
|
||||||
|
- Returns PCM audio buffer + sample rate. Plugins must resample/encode for providers.
|
||||||
|
- Edge TTS is not supported for telephony.
|
||||||
|
|
||||||
## Discovery & precedence
|
## Discovery & precedence
|
||||||
|
|
||||||
Clawdbot scans, in order:
|
Clawdbot scans, in order:
|
||||||
|
|||||||
@@ -104,6 +104,87 @@ Notes:
|
|||||||
- `mock` is a local dev provider (no network calls).
|
- `mock` is a local dev provider (no network calls).
|
||||||
- `skipSignatureVerification` is for local testing only.
|
- `skipSignatureVerification` is for local testing only.
|
||||||
|
|
||||||
|
## TTS for calls
|
||||||
|
|
||||||
|
Voice Call uses the core `messages.tts` configuration (OpenAI or ElevenLabs) for
|
||||||
|
streaming speech on calls. You can override it under the plugin config with the
|
||||||
|
**same shape** — it deep‑merges with `messages.tts`.
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
tts: {
|
||||||
|
provider: "elevenlabs",
|
||||||
|
elevenlabs: {
|
||||||
|
voiceId: "pMsXgVXv3BLzUgSXRplE",
|
||||||
|
modelId: "eleven_multilingual_v2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- **Edge TTS is ignored for voice calls** (telephony audio needs PCM; Edge output is unreliable).
|
||||||
|
- Core TTS is used when Twilio media streaming is enabled; otherwise calls fall back to provider native voices.
|
||||||
|
|
||||||
|
### More examples
|
||||||
|
|
||||||
|
Use core TTS only (no override):
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
messages: {
|
||||||
|
tts: {
|
||||||
|
provider: "openai",
|
||||||
|
openai: { voice: "alloy" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Override to ElevenLabs just for calls (keep core default elsewhere):
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
plugins: {
|
||||||
|
entries: {
|
||||||
|
"voice-call": {
|
||||||
|
config: {
|
||||||
|
tts: {
|
||||||
|
provider: "elevenlabs",
|
||||||
|
elevenlabs: {
|
||||||
|
apiKey: "elevenlabs_key",
|
||||||
|
voiceId: "pMsXgVXv3BLzUgSXRplE",
|
||||||
|
modelId: "eleven_multilingual_v2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Override only the OpenAI model for calls (deep‑merge example):
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
plugins: {
|
||||||
|
entries: {
|
||||||
|
"voice-call": {
|
||||||
|
config: {
|
||||||
|
tts: {
|
||||||
|
openai: {
|
||||||
|
model: "gpt-4o-mini-tts",
|
||||||
|
voice: "marin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Inbound calls
|
## Inbound calls
|
||||||
|
|
||||||
Inbound policy defaults to `disabled`. To enable inbound calls, set:
|
Inbound policy defaults to `disabled`. To enable inbound calls, set:
|
||||||
|
|||||||
+5
-3
@@ -16,7 +16,7 @@ and you configure everything via the `/setup` web wizard.
|
|||||||
|
|
||||||
## One-click deploy
|
## One-click deploy
|
||||||
|
|
||||||
<a href="https://railway.com/deploy/clawdbot-railway-template" target="_blank" rel="noreferrer">Deploy on Railway</a>
|
<a href="https://railway.app/new/template?template=https://github.com/vignesh07/clawdbot-railway-template" target="_blank" rel="noreferrer">Deploy on Railway</a>
|
||||||
|
|
||||||
After deploy, find your public URL in **Railway → your service → Settings → Domains**.
|
After deploy, find your public URL in **Railway → your service → Settings → Domains**.
|
||||||
|
|
||||||
@@ -55,6 +55,7 @@ Attach a volume mounted at:
|
|||||||
Set these variables on the service:
|
Set these variables on the service:
|
||||||
|
|
||||||
- `SETUP_PASSWORD` (required)
|
- `SETUP_PASSWORD` (required)
|
||||||
|
- `PORT=8080` (required — must match the port in Public Networking)
|
||||||
- `CLAWDBOT_STATE_DIR=/data/.clawdbot` (recommended)
|
- `CLAWDBOT_STATE_DIR=/data/.clawdbot` (recommended)
|
||||||
- `CLAWDBOT_WORKSPACE_DIR=/data/workspace` (recommended)
|
- `CLAWDBOT_WORKSPACE_DIR=/data/workspace` (recommended)
|
||||||
- `CLAWDBOT_GATEWAY_TOKEN` (recommended; treat as an admin secret)
|
- `CLAWDBOT_GATEWAY_TOKEN` (recommended; treat as an admin secret)
|
||||||
@@ -82,8 +83,9 @@ If Telegram DMs are set to pairing, the setup wizard can approve the pairing cod
|
|||||||
1) Go to https://discord.com/developers/applications
|
1) Go to https://discord.com/developers/applications
|
||||||
2) **New Application** → choose a name
|
2) **New Application** → choose a name
|
||||||
3) **Bot** → **Add Bot**
|
3) **Bot** → **Add Bot**
|
||||||
4) Copy the **Bot Token** and paste into `/setup`
|
4) **Enable MESSAGE CONTENT INTENT** under Bot → Privileged Gateway Intents (required or the bot will crash on startup)
|
||||||
5) Invite the bot to your server (OAuth2 URL Generator; scopes: `bot`, `applications.commands`)
|
5) Copy the **Bot Token** and paste into `/setup`
|
||||||
|
6) Invite the bot to your server (OAuth2 URL Generator; scopes: `bot`, `applications.commands`)
|
||||||
|
|
||||||
## Backups & migration
|
## Backups & migration
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ Text + native (when enabled):
|
|||||||
- `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`)
|
- `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`)
|
||||||
- `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`)
|
- `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`)
|
||||||
- `/usage off|tokens|full|cost` (per-response usage footer or local cost summary)
|
- `/usage off|tokens|full|cost` (per-response usage footer or local cost summary)
|
||||||
- `/tts on|off|status|provider|limit|summary|audio` (control TTS; see [/tts](/tts))
|
- `/tts off|always|inbound|tagged|status|provider|limit|summary|audio` (control TTS; see [/tts](/tts))
|
||||||
- Discord: native command is `/voice` (Discord reserves `/tts`); text `/tts` still works.
|
- Discord: native command is `/voice` (Discord reserves `/tts`); text `/tts` still works.
|
||||||
- `/stop`
|
- `/stop`
|
||||||
- `/restart`
|
- `/restart`
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ Primary goals:
|
|||||||
- Keep the tool surface hard to misuse: sub-agents do **not** get session tools by default.
|
- Keep the tool surface hard to misuse: sub-agents do **not** get session tools by default.
|
||||||
- Avoid nested fan-out: sub-agents cannot spawn sub-agents.
|
- Avoid nested fan-out: sub-agents cannot spawn sub-agents.
|
||||||
|
|
||||||
|
Cost note: each sub-agent has its **own** context and token usage. For heavy or repetitive
|
||||||
|
tasks, set a cheaper model for sub-agents and keep your main agent on a higher-quality model.
|
||||||
|
You can configure this via `agents.defaults.subagents.model` or per-agent overrides.
|
||||||
|
|
||||||
## Tool
|
## Tool
|
||||||
|
|
||||||
Use `sessions_spawn`:
|
Use `sessions_spawn`:
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ Search the web using your configured provider.
|
|||||||
- `country` (optional): 2-letter country code for region-specific results (e.g., "DE", "US", "ALL"). If omitted, Brave chooses its default region.
|
- `country` (optional): 2-letter country code for region-specific results (e.g., "DE", "US", "ALL"). If omitted, Brave chooses its default region.
|
||||||
- `search_lang` (optional): ISO language code for search results (e.g., "de", "en", "fr")
|
- `search_lang` (optional): ISO language code for search results (e.g., "de", "en", "fr")
|
||||||
- `ui_lang` (optional): ISO language code for UI elements
|
- `ui_lang` (optional): ISO language code for UI elements
|
||||||
|
- `freshness` (optional, Brave only): filter by discovery time (`pd`, `pw`, `pm`, `py`, or `YYYY-MM-DDtoYYYY-MM-DD`)
|
||||||
|
|
||||||
**Examples:**
|
**Examples:**
|
||||||
|
|
||||||
@@ -193,6 +194,12 @@ await web_search({
|
|||||||
search_lang: "fr",
|
search_lang: "fr",
|
||||||
ui_lang: "fr"
|
ui_lang: "fr"
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Recent results (past week)
|
||||||
|
await web_search({
|
||||||
|
query: "TMBG interview",
|
||||||
|
freshness: "pw"
|
||||||
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
## web_fetch
|
## web_fetch
|
||||||
|
|||||||
+29
-10
@@ -53,8 +53,8 @@ so that provider must also be authenticated if you enable summaries.
|
|||||||
|
|
||||||
## Is it enabled by default?
|
## Is it enabled by default?
|
||||||
|
|
||||||
No. TTS is **disabled** by default. Enable it in config or with `/tts on`,
|
No. Auto‑TTS is **off** by default. Enable it in config with
|
||||||
which writes a local preference override.
|
`messages.tts.auto` or per session with `/tts always` (alias: `/tts on`).
|
||||||
|
|
||||||
Edge TTS **is** enabled by default once TTS is on, and is used automatically
|
Edge TTS **is** enabled by default once TTS is on, and is used automatically
|
||||||
when no OpenAI or ElevenLabs API keys are available.
|
when no OpenAI or ElevenLabs API keys are available.
|
||||||
@@ -70,7 +70,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
|||||||
{
|
{
|
||||||
messages: {
|
messages: {
|
||||||
tts: {
|
tts: {
|
||||||
enabled: true,
|
auto: "always",
|
||||||
provider: "elevenlabs"
|
provider: "elevenlabs"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
|||||||
{
|
{
|
||||||
messages: {
|
messages: {
|
||||||
tts: {
|
tts: {
|
||||||
enabled: true,
|
auto: "always",
|
||||||
provider: "openai",
|
provider: "openai",
|
||||||
summaryModel: "openai/gpt-4.1-mini",
|
summaryModel: "openai/gpt-4.1-mini",
|
||||||
modelOverrides: {
|
modelOverrides: {
|
||||||
@@ -121,7 +121,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
|||||||
{
|
{
|
||||||
messages: {
|
messages: {
|
||||||
tts: {
|
tts: {
|
||||||
enabled: true,
|
auto: "always",
|
||||||
provider: "edge",
|
provider: "edge",
|
||||||
edge: {
|
edge: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -156,7 +156,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
|||||||
{
|
{
|
||||||
messages: {
|
messages: {
|
||||||
tts: {
|
tts: {
|
||||||
enabled: true,
|
auto: "always",
|
||||||
maxTextLength: 4000,
|
maxTextLength: 4000,
|
||||||
timeoutMs: 30000,
|
timeoutMs: 30000,
|
||||||
prefsPath: "~/.clawdbot/settings/tts.json"
|
prefsPath: "~/.clawdbot/settings/tts.json"
|
||||||
@@ -165,13 +165,25 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Only reply with audio after an inbound voice note
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
messages: {
|
||||||
|
tts: {
|
||||||
|
auto: "inbound"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Disable auto-summary for long replies
|
### Disable auto-summary for long replies
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
messages: {
|
messages: {
|
||||||
tts: {
|
tts: {
|
||||||
enabled: true
|
auto: "always"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,7 +197,10 @@ Then run:
|
|||||||
|
|
||||||
### Notes on fields
|
### Notes on fields
|
||||||
|
|
||||||
- `enabled`: master toggle (default `false`; local prefs can override).
|
- `auto`: auto‑TTS mode (`off`, `always`, `inbound`, `tagged`).
|
||||||
|
- `inbound` only sends audio after an inbound voice note.
|
||||||
|
- `tagged` only sends audio when the reply includes `[[tts]]` tags.
|
||||||
|
- `enabled`: legacy toggle (doctor migrates this to `auto`).
|
||||||
- `mode`: `"final"` (default) or `"all"` (includes tool/block replies).
|
- `mode`: `"final"` (default) or `"all"` (includes tool/block replies).
|
||||||
- `provider`: `"elevenlabs"`, `"openai"`, or `"edge"` (fallback is automatic).
|
- `provider`: `"elevenlabs"`, `"openai"`, or `"edge"` (fallback is automatic).
|
||||||
- If `provider` is **unset**, Clawdbot prefers `openai` (if key), then `elevenlabs` (if key),
|
- If `provider` is **unset**, Clawdbot prefers `openai` (if key), then `elevenlabs` (if key),
|
||||||
@@ -195,7 +210,7 @@ Then run:
|
|||||||
- `modelOverrides`: allow the model to emit TTS directives (on by default).
|
- `modelOverrides`: allow the model to emit TTS directives (on by default).
|
||||||
- `maxTextLength`: hard cap for TTS input (chars). `/tts audio` fails if exceeded.
|
- `maxTextLength`: hard cap for TTS input (chars). `/tts audio` fails if exceeded.
|
||||||
- `timeoutMs`: request timeout (ms).
|
- `timeoutMs`: request timeout (ms).
|
||||||
- `prefsPath`: override the local prefs JSON path.
|
- `prefsPath`: override the local prefs JSON path (provider/limit/summary).
|
||||||
- `apiKey` values fall back to env vars (`ELEVENLABS_API_KEY`/`XI_API_KEY`, `OPENAI_API_KEY`).
|
- `apiKey` values fall back to env vars (`ELEVENLABS_API_KEY`/`XI_API_KEY`, `OPENAI_API_KEY`).
|
||||||
- `elevenlabs.baseUrl`: override ElevenLabs API base URL.
|
- `elevenlabs.baseUrl`: override ElevenLabs API base URL.
|
||||||
- `elevenlabs.voiceSettings`:
|
- `elevenlabs.voiceSettings`:
|
||||||
@@ -218,6 +233,7 @@ Then run:
|
|||||||
## Model-driven overrides (default on)
|
## Model-driven overrides (default on)
|
||||||
|
|
||||||
By default, the model **can** emit TTS directives for a single reply.
|
By default, the model **can** emit TTS directives for a single reply.
|
||||||
|
When `messages.tts.auto` is `tagged`, these directives are required to trigger audio.
|
||||||
|
|
||||||
When enabled, the model can emit `[[tts:...]]` directives to override the voice
|
When enabled, the model can emit `[[tts:...]]` directives to override the voice
|
||||||
for a single reply, plus an optional `[[tts:text]]...[[/tts:text]]` block to
|
for a single reply, plus an optional `[[tts:text]]...[[/tts:text]]` block to
|
||||||
@@ -338,8 +354,10 @@ Discord note: `/tts` is a built-in Discord command, so Clawdbot registers
|
|||||||
`/voice` as the native command there. Text `/tts ...` still works.
|
`/voice` as the native command there. Text `/tts ...` still works.
|
||||||
|
|
||||||
```
|
```
|
||||||
/tts on
|
|
||||||
/tts off
|
/tts off
|
||||||
|
/tts always
|
||||||
|
/tts inbound
|
||||||
|
/tts tagged
|
||||||
/tts status
|
/tts status
|
||||||
/tts provider openai
|
/tts provider openai
|
||||||
/tts limit 2000
|
/tts limit 2000
|
||||||
@@ -350,6 +368,7 @@ Discord note: `/tts` is a built-in Discord command, so Clawdbot registers
|
|||||||
Notes:
|
Notes:
|
||||||
- Commands require an authorized sender (allowlist/owner rules still apply).
|
- Commands require an authorized sender (allowlist/owner rules still apply).
|
||||||
- `commands.text` or native command registration must be enabled.
|
- `commands.text` or native command registration must be enabled.
|
||||||
|
- `off|always|inbound|tagged` are per‑session toggles (`/tts on` is an alias for `/tts always`).
|
||||||
- `limit` and `summary` are stored in local prefs, not the main config.
|
- `limit` and `summary` are stored in local prefs, not the main config.
|
||||||
- `/tts audio` generates a one-off audio reply (does not toggle TTS on).
|
- `/tts audio` generates a one-off audio reply (does not toggle TTS on).
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,14 @@ Other Gateway slash commands (for example, `/context`) are forwarded to the Gate
|
|||||||
- `--deliver`: Deliver assistant replies to the provider (default off)
|
- `--deliver`: Deliver assistant replies to the provider (default off)
|
||||||
- `--thinking <level>`: Override thinking level for sends
|
- `--thinking <level>`: Override thinking level for sends
|
||||||
- `--timeout-ms <ms>`: Agent timeout in ms (defaults to `agents.defaults.timeoutSeconds`)
|
- `--timeout-ms <ms>`: Agent timeout in ms (defaults to `agents.defaults.timeoutSeconds`)
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
No output after sending a message:
|
||||||
|
- Run `/status` in the TUI to confirm the Gateway is connected and idle/busy.
|
||||||
|
- Check the Gateway logs: `clawdbot logs --follow`.
|
||||||
|
- Confirm the agent can run: `clawdbot status` and `clawdbot models status`.
|
||||||
|
- If you expect messages in a chat channel, enable delivery (`/deliver on` or `--deliver`).
|
||||||
- `--history-limit <n>`: History entries to load (default 200)
|
- `--history-limit <n>`: History entries to load (default 200)
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|||||||
@@ -25,9 +25,11 @@ import { resolveBlueBubblesMessageId } from "./monitor.js";
|
|||||||
import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
|
import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
|
||||||
import { sendMessageBlueBubbles } from "./send.js";
|
import { sendMessageBlueBubbles } from "./send.js";
|
||||||
import {
|
import {
|
||||||
|
extractHandleFromChatGuid,
|
||||||
looksLikeBlueBubblesTargetId,
|
looksLikeBlueBubblesTargetId,
|
||||||
normalizeBlueBubblesHandle,
|
normalizeBlueBubblesHandle,
|
||||||
normalizeBlueBubblesMessagingTarget,
|
normalizeBlueBubblesMessagingTarget,
|
||||||
|
parseBlueBubblesTarget,
|
||||||
} from "./targets.js";
|
} from "./targets.js";
|
||||||
import { bluebubblesMessageActions } from "./actions.js";
|
import { bluebubblesMessageActions } from "./actions.js";
|
||||||
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
|
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
|
||||||
@@ -148,6 +150,58 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
looksLikeId: looksLikeBlueBubblesTargetId,
|
looksLikeId: looksLikeBlueBubblesTargetId,
|
||||||
hint: "<handle|chat_guid:GUID|chat_id:ID|chat_identifier:ID>",
|
hint: "<handle|chat_guid:GUID|chat_id:ID|chat_identifier:ID>",
|
||||||
},
|
},
|
||||||
|
formatTargetDisplay: ({ target, display }) => {
|
||||||
|
const shouldParseDisplay = (value: string): boolean => {
|
||||||
|
if (looksLikeBlueBubblesTargetId(value)) return true;
|
||||||
|
return /^(bluebubbles:|chat_guid:|chat_id:|chat_identifier:)/i.test(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to extract a clean handle from any BlueBubbles target format
|
||||||
|
const extractCleanDisplay = (value: string | undefined): string | null => {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
try {
|
||||||
|
const parsed = parseBlueBubblesTarget(trimmed);
|
||||||
|
if (parsed.kind === "chat_guid") {
|
||||||
|
const handle = extractHandleFromChatGuid(parsed.chatGuid);
|
||||||
|
if (handle) return handle;
|
||||||
|
}
|
||||||
|
if (parsed.kind === "handle") {
|
||||||
|
return normalizeBlueBubblesHandle(parsed.to);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through
|
||||||
|
}
|
||||||
|
// Strip common prefixes and try raw extraction
|
||||||
|
const stripped = trimmed
|
||||||
|
.replace(/^bluebubbles:/i, "")
|
||||||
|
.replace(/^chat_guid:/i, "")
|
||||||
|
.replace(/^chat_id:/i, "")
|
||||||
|
.replace(/^chat_identifier:/i, "");
|
||||||
|
const handle = extractHandleFromChatGuid(stripped);
|
||||||
|
if (handle) return handle;
|
||||||
|
// Don't return raw chat_guid formats - they contain internal routing info
|
||||||
|
if (stripped.includes(";-;") || stripped.includes(";+;")) return null;
|
||||||
|
return stripped;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to get a clean display from the display parameter first
|
||||||
|
const trimmedDisplay = display?.trim();
|
||||||
|
if (trimmedDisplay) {
|
||||||
|
if (!shouldParseDisplay(trimmedDisplay)) {
|
||||||
|
return trimmedDisplay;
|
||||||
|
}
|
||||||
|
const cleanDisplay = extractCleanDisplay(trimmedDisplay);
|
||||||
|
if (cleanDisplay) return cleanDisplay;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to extracting from target
|
||||||
|
const cleanTarget = extractCleanDisplay(target);
|
||||||
|
if (cleanTarget) return cleanTarget;
|
||||||
|
|
||||||
|
// Last resort: return display or target as-is
|
||||||
|
return display?.trim() || target?.trim() || "";
|
||||||
|
},
|
||||||
},
|
},
|
||||||
setup: {
|
setup: {
|
||||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||||
|
|||||||
@@ -187,6 +187,47 @@ describe("send", () => {
|
|||||||
expect(result).toBe("iMessage;-;+15551234567");
|
expect(result).toBe("iMessage;-;+15551234567");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns null when handle only exists in group chat (not DM)", async () => {
|
||||||
|
// This is the critical fix: if a phone number only exists as a participant in a group chat
|
||||||
|
// (no direct DM chat), we should NOT send to that group. Return null instead.
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
guid: "iMessage;+;group-the-council",
|
||||||
|
participants: [
|
||||||
|
{ address: "+12622102921" },
|
||||||
|
{ address: "+15550001111" },
|
||||||
|
{ address: "+15550002222" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
// Empty second page to stop pagination
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ data: [] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const target: BlueBubblesSendTarget = {
|
||||||
|
kind: "handle",
|
||||||
|
address: "+12622102921",
|
||||||
|
service: "imessage",
|
||||||
|
};
|
||||||
|
const result = await resolveChatGuidForTarget({
|
||||||
|
baseUrl: "http://localhost:1234",
|
||||||
|
password: "test",
|
||||||
|
target,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should return null, NOT the group chat GUID
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("returns null when chat not found", async () => {
|
it("returns null when chat not found", async () => {
|
||||||
mockFetch.mockResolvedValueOnce({
|
mockFetch.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -351,7 +392,7 @@ describe("send", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sendMessageBlueBubbles("+15559999999", "Hello", {
|
sendMessageBlueBubbles("chat_id:123", "Hello", {
|
||||||
serverUrl: "http://localhost:1234",
|
serverUrl: "http://localhost:1234",
|
||||||
password: "test",
|
password: "test",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -257,11 +257,17 @@ export async function resolveChatGuidForTarget(params: {
|
|||||||
return guid;
|
return guid;
|
||||||
}
|
}
|
||||||
if (!participantMatch && guid) {
|
if (!participantMatch && guid) {
|
||||||
const participants = extractParticipantAddresses(chat).map((entry) =>
|
// Only consider DM chats (`;-;` separator) as participant matches.
|
||||||
normalizeBlueBubblesHandle(entry),
|
// Group chats (`;+;` separator) should never match when searching by handle/phone.
|
||||||
);
|
// This prevents routing "send to +1234567890" to a group chat that contains that number.
|
||||||
if (participants.includes(normalizedHandle)) {
|
const isDmChat = guid.includes(";-;");
|
||||||
participantMatch = guid;
|
if (isDmChat) {
|
||||||
|
const participants = extractParticipantAddresses(chat).map((entry) =>
|
||||||
|
normalizeBlueBubblesHandle(entry),
|
||||||
|
);
|
||||||
|
if (participants.includes(normalizedHandle)) {
|
||||||
|
participantMatch = guid;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,6 +276,55 @@ export async function resolveChatGuidForTarget(params: {
|
|||||||
return participantMatch;
|
return participantMatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new chat (DM) and optionally sends an initial message.
|
||||||
|
* Requires Private API to be enabled in BlueBubbles.
|
||||||
|
*/
|
||||||
|
async function createNewChatWithMessage(params: {
|
||||||
|
baseUrl: string;
|
||||||
|
password: string;
|
||||||
|
address: string;
|
||||||
|
message: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}): Promise<BlueBubblesSendResult> {
|
||||||
|
const url = buildBlueBubblesApiUrl({
|
||||||
|
baseUrl: params.baseUrl,
|
||||||
|
path: "/api/v1/chat/new",
|
||||||
|
password: params.password,
|
||||||
|
});
|
||||||
|
const payload = {
|
||||||
|
addresses: [params.address],
|
||||||
|
message: params.message,
|
||||||
|
};
|
||||||
|
const res = await blueBubblesFetchWithTimeout(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
params.timeoutMs,
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorText = await res.text();
|
||||||
|
// Check for Private API not enabled error
|
||||||
|
if (res.status === 400 || res.status === 403 || errorText.toLowerCase().includes("private api")) {
|
||||||
|
throw new Error(
|
||||||
|
`BlueBubbles send failed: Cannot create new chat - Private API must be enabled. Original error: ${errorText || res.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error(`BlueBubbles create chat failed (${res.status}): ${errorText || "unknown"}`);
|
||||||
|
}
|
||||||
|
const body = await res.text();
|
||||||
|
if (!body) return { messageId: "ok" };
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(body) as unknown;
|
||||||
|
return { messageId: extractMessageId(parsed) };
|
||||||
|
} catch {
|
||||||
|
return { messageId: "ok" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendMessageBlueBubbles(
|
export async function sendMessageBlueBubbles(
|
||||||
to: string,
|
to: string,
|
||||||
text: string,
|
text: string,
|
||||||
@@ -297,6 +352,17 @@ export async function sendMessageBlueBubbles(
|
|||||||
target,
|
target,
|
||||||
});
|
});
|
||||||
if (!chatGuid) {
|
if (!chatGuid) {
|
||||||
|
// If target is a phone number/handle and no existing chat found,
|
||||||
|
// auto-create a new DM chat using the /api/v1/chat/new endpoint
|
||||||
|
if (target.kind === "handle") {
|
||||||
|
return createNewChatWithMessage({
|
||||||
|
baseUrl,
|
||||||
|
password,
|
||||||
|
address: target.address,
|
||||||
|
message: trimmedText,
|
||||||
|
timeoutMs: opts.timeoutMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"BlueBubbles send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.",
|
"BlueBubbles send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -374,6 +374,7 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
|
|||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
chunker: (text, limit) =>
|
chunker: (text, limit) =>
|
||||||
getGoogleChatRuntime().channel.text.chunkMarkdownText(text, limit),
|
getGoogleChatRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||||
|
chunkerMode: "markdown",
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
resolveTarget: ({ to, allowFrom, mode }) => {
|
resolveTarget: ({ to, allowFrom, mode }) => {
|
||||||
const trimmed = to?.trim() ?? "";
|
const trimmed = to?.trim() ?? "";
|
||||||
|
|||||||
@@ -684,6 +684,7 @@ async function processMessageWithPipeline(params: {
|
|||||||
spaceId,
|
spaceId,
|
||||||
runtime,
|
runtime,
|
||||||
core,
|
core,
|
||||||
|
config,
|
||||||
statusSink,
|
statusSink,
|
||||||
typingMessageName,
|
typingMessageName,
|
||||||
});
|
});
|
||||||
@@ -725,10 +726,11 @@ async function deliverGoogleChatReply(params: {
|
|||||||
spaceId: string;
|
spaceId: string;
|
||||||
runtime: GoogleChatRuntimeEnv;
|
runtime: GoogleChatRuntimeEnv;
|
||||||
core: GoogleChatCoreRuntime;
|
core: GoogleChatCoreRuntime;
|
||||||
|
config: ClawdbotConfig;
|
||||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||||
typingMessageName?: string;
|
typingMessageName?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { payload, account, spaceId, runtime, core, statusSink, typingMessageName } = params;
|
const { payload, account, spaceId, runtime, core, config, statusSink, typingMessageName } = params;
|
||||||
const mediaList = payload.mediaUrls?.length
|
const mediaList = payload.mediaUrls?.length
|
||||||
? payload.mediaUrls
|
? payload.mediaUrls
|
||||||
: payload.mediaUrl
|
: payload.mediaUrl
|
||||||
@@ -799,7 +801,16 @@ async function deliverGoogleChatReply(params: {
|
|||||||
|
|
||||||
if (payload.text) {
|
if (payload.text) {
|
||||||
const chunkLimit = account.config.textChunkLimit ?? 4000;
|
const chunkLimit = account.config.textChunkLimit ?? 4000;
|
||||||
const chunks = core.channel.text.chunkMarkdownText(payload.text, chunkLimit);
|
const chunkMode = core.channel.text.resolveChunkMode(
|
||||||
|
config,
|
||||||
|
"googlechat",
|
||||||
|
account.accountId,
|
||||||
|
);
|
||||||
|
const chunks = core.channel.text.chunkMarkdownTextWithMode(
|
||||||
|
payload.text,
|
||||||
|
chunkLimit,
|
||||||
|
chunkMode,
|
||||||
|
);
|
||||||
for (let i = 0; i < chunks.length; i++) {
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
const chunk = chunks[i];
|
const chunk = chunks[i];
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
|||||||
outbound: {
|
outbound: {
|
||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
chunker: (text, limit) => getIMessageRuntime().channel.text.chunkText(text, limit),
|
chunker: (text, limit) => getIMessageRuntime().channel.text.chunkText(text, limit),
|
||||||
|
chunkerMode: "text",
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
sendText: async ({ cfg, to, text, accountId, deps }) => {
|
sendText: async ({ cfg, to, text, accountId, deps }) => {
|
||||||
const send = deps?.sendIMessage ?? getIMessageRuntime().channel.imessage.sendMessageIMessage;
|
const send = deps?.sendIMessage ?? getIMessageRuntime().channel.imessage.sendMessageIMessage;
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"id": "line",
|
||||||
|
"channels": [
|
||||||
|
"line"
|
||||||
|
],
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||||
|
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import { linePlugin } from "./src/channel.js";
|
||||||
|
import { registerLineCardCommand } from "./src/card-command.js";
|
||||||
|
import { setLineRuntime } from "./src/runtime.js";
|
||||||
|
|
||||||
|
const plugin = {
|
||||||
|
id: "line",
|
||||||
|
name: "LINE",
|
||||||
|
description: "LINE Messaging API channel plugin",
|
||||||
|
configSchema: emptyPluginConfigSchema(),
|
||||||
|
register(api: ClawdbotPluginApi) {
|
||||||
|
setLineRuntime(api.runtime);
|
||||||
|
api.registerChannel({ plugin: linePlugin });
|
||||||
|
registerLineCardCommand(api);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default plugin;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "@clawdbot/line",
|
||||||
|
"version": "2026.1.22",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Clawdbot LINE channel plugin",
|
||||||
|
"clawdbot": {
|
||||||
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
],
|
||||||
|
"channel": {
|
||||||
|
"id": "line",
|
||||||
|
"label": "LINE",
|
||||||
|
"selectionLabel": "LINE (Messaging API)",
|
||||||
|
"docsPath": "/channels/line",
|
||||||
|
"docsLabel": "line",
|
||||||
|
"blurb": "LINE Messaging API bot for Japan/Taiwan/Thailand markets.",
|
||||||
|
"order": 75,
|
||||||
|
"quickstartAllowFrom": true
|
||||||
|
},
|
||||||
|
"install": {
|
||||||
|
"npmSpec": "@clawdbot/line",
|
||||||
|
"localPath": "extensions/line",
|
||||||
|
"defaultChoice": "npm"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"clawdbot": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import type { ClawdbotPluginApi, LineChannelData, ReplyPayload } from "clawdbot/plugin-sdk";
|
||||||
|
import {
|
||||||
|
createActionCard,
|
||||||
|
createImageCard,
|
||||||
|
createInfoCard,
|
||||||
|
createListCard,
|
||||||
|
createReceiptCard,
|
||||||
|
type CardAction,
|
||||||
|
type ListItem,
|
||||||
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
const CARD_USAGE = `Usage: /card <type> "title" "body" [options]
|
||||||
|
|
||||||
|
Types:
|
||||||
|
info "Title" "Body" ["Footer"]
|
||||||
|
image "Title" "Caption" --url <image-url>
|
||||||
|
action "Title" "Body" --actions "Btn1|url1,Btn2|text2"
|
||||||
|
list "Title" "Item1|Desc1,Item2|Desc2"
|
||||||
|
receipt "Title" "Item1:$10,Item2:$20" --total "$30"
|
||||||
|
confirm "Question?" --yes "Yes|data" --no "No|data"
|
||||||
|
buttons "Title" "Text" --actions "Btn1|url1,Btn2|data2"
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
/card info "Welcome" "Thanks for joining!"
|
||||||
|
/card image "Product" "Check it out" --url https://example.com/img.jpg
|
||||||
|
/card action "Menu" "Choose an option" --actions "Order|/order,Help|/help"`;
|
||||||
|
|
||||||
|
function buildLineReply(lineData: LineChannelData): ReplyPayload {
|
||||||
|
return {
|
||||||
|
channelData: {
|
||||||
|
line: lineData,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse action string format: "Label|data,Label2|data2"
|
||||||
|
* Data can be a URL (uri action) or plain text (message action) or key=value (postback)
|
||||||
|
*/
|
||||||
|
function parseActions(actionsStr: string | undefined): CardAction[] {
|
||||||
|
if (!actionsStr) return [];
|
||||||
|
|
||||||
|
const results: CardAction[] = [];
|
||||||
|
|
||||||
|
for (const part of actionsStr.split(",")) {
|
||||||
|
const [label, data] = part
|
||||||
|
.trim()
|
||||||
|
.split("|")
|
||||||
|
.map((s) => s.trim());
|
||||||
|
if (!label) continue;
|
||||||
|
|
||||||
|
const actionData = data || label;
|
||||||
|
|
||||||
|
if (actionData.startsWith("http://") || actionData.startsWith("https://")) {
|
||||||
|
results.push({
|
||||||
|
label,
|
||||||
|
action: { type: "uri", label: label.slice(0, 20), uri: actionData },
|
||||||
|
});
|
||||||
|
} else if (actionData.includes("=")) {
|
||||||
|
results.push({
|
||||||
|
label,
|
||||||
|
action: {
|
||||||
|
type: "postback",
|
||||||
|
label: label.slice(0, 20),
|
||||||
|
data: actionData.slice(0, 300),
|
||||||
|
displayText: label,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
results.push({
|
||||||
|
label,
|
||||||
|
action: { type: "message", label: label.slice(0, 20), text: actionData },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse list items format: "Item1|Subtitle1,Item2|Subtitle2"
|
||||||
|
*/
|
||||||
|
function parseListItems(itemsStr: string): ListItem[] {
|
||||||
|
return itemsStr
|
||||||
|
.split(",")
|
||||||
|
.map((part) => {
|
||||||
|
const [title, subtitle] = part
|
||||||
|
.trim()
|
||||||
|
.split("|")
|
||||||
|
.map((s) => s.trim());
|
||||||
|
return { title: title || "", subtitle };
|
||||||
|
})
|
||||||
|
.filter((item) => item.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse receipt items format: "Item1:$10,Item2:$20"
|
||||||
|
*/
|
||||||
|
function parseReceiptItems(itemsStr: string): Array<{ name: string; value: string }> {
|
||||||
|
return itemsStr
|
||||||
|
.split(",")
|
||||||
|
.map((part) => {
|
||||||
|
const colonIndex = part.lastIndexOf(":");
|
||||||
|
if (colonIndex === -1) {
|
||||||
|
return { name: part.trim(), value: "" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: part.slice(0, colonIndex).trim(),
|
||||||
|
value: part.slice(colonIndex + 1).trim(),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item) => item.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse quoted arguments from command string
|
||||||
|
* Supports: /card type "arg1" "arg2" "arg3" --flag value
|
||||||
|
*/
|
||||||
|
function parseCardArgs(argsStr: string): {
|
||||||
|
type: string;
|
||||||
|
args: string[];
|
||||||
|
flags: Record<string, string>;
|
||||||
|
} {
|
||||||
|
const result: { type: string; args: string[]; flags: Record<string, string> } = {
|
||||||
|
type: "",
|
||||||
|
args: [],
|
||||||
|
flags: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract type (first word)
|
||||||
|
const typeMatch = argsStr.match(/^(\w+)/);
|
||||||
|
if (typeMatch) {
|
||||||
|
result.type = typeMatch[1].toLowerCase();
|
||||||
|
argsStr = argsStr.slice(typeMatch[0].length).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract quoted arguments
|
||||||
|
const quotedRegex = /"([^"]*?)"/g;
|
||||||
|
let match;
|
||||||
|
while ((match = quotedRegex.exec(argsStr)) !== null) {
|
||||||
|
result.args.push(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract flags (--key value or --key "value")
|
||||||
|
const flagRegex = /--(\w+)\s+(?:"([^"]*?)"|(\S+))/g;
|
||||||
|
while ((match = flagRegex.exec(argsStr)) !== null) {
|
||||||
|
result.flags[match[1]] = match[2] ?? match[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerLineCardCommand(api: ClawdbotPluginApi): void {
|
||||||
|
api.registerCommand({
|
||||||
|
name: "card",
|
||||||
|
description: "Send a rich card message (LINE).",
|
||||||
|
acceptsArgs: true,
|
||||||
|
requireAuth: false,
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const argsStr = ctx.args?.trim() ?? "";
|
||||||
|
if (!argsStr) return { text: CARD_USAGE };
|
||||||
|
|
||||||
|
const parsed = parseCardArgs(argsStr);
|
||||||
|
const { type, args, flags } = parsed;
|
||||||
|
|
||||||
|
if (!type) return { text: CARD_USAGE };
|
||||||
|
|
||||||
|
// Only LINE supports rich cards; fallback to text elsewhere.
|
||||||
|
if (ctx.channel !== "line") {
|
||||||
|
const fallbackText = args.join(" - ");
|
||||||
|
return { text: `[${type} card] ${fallbackText}`.trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (type) {
|
||||||
|
case "info": {
|
||||||
|
const [title = "Info", body = "", footer] = args;
|
||||||
|
const bubble = createInfoCard(title, body, footer);
|
||||||
|
return buildLineReply({
|
||||||
|
flexMessage: {
|
||||||
|
altText: `${title}: ${body}`.slice(0, 400),
|
||||||
|
contents: bubble,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case "image": {
|
||||||
|
const [title = "Image", caption = ""] = args;
|
||||||
|
const imageUrl = flags.url || flags.image;
|
||||||
|
if (!imageUrl) {
|
||||||
|
return { text: "Error: Image card requires --url <image-url>" };
|
||||||
|
}
|
||||||
|
const bubble = createImageCard(imageUrl, title, caption);
|
||||||
|
return buildLineReply({
|
||||||
|
flexMessage: {
|
||||||
|
altText: `${title}: ${caption}`.slice(0, 400),
|
||||||
|
contents: bubble,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case "action": {
|
||||||
|
const [title = "Actions", body = ""] = args;
|
||||||
|
const actions = parseActions(flags.actions);
|
||||||
|
if (actions.length === 0) {
|
||||||
|
return { text: 'Error: Action card requires --actions "Label1|data1,Label2|data2"' };
|
||||||
|
}
|
||||||
|
const bubble = createActionCard(title, body, actions, {
|
||||||
|
imageUrl: flags.url || flags.image,
|
||||||
|
});
|
||||||
|
return buildLineReply({
|
||||||
|
flexMessage: {
|
||||||
|
altText: `${title}: ${body}`.slice(0, 400),
|
||||||
|
contents: bubble,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case "list": {
|
||||||
|
const [title = "List", itemsStr = ""] = args;
|
||||||
|
const items = parseListItems(itemsStr || flags.items || "");
|
||||||
|
if (items.length === 0) {
|
||||||
|
return {
|
||||||
|
text:
|
||||||
|
'Error: List card requires items. Usage: /card list "Title" "Item1|Desc1,Item2|Desc2"',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const bubble = createListCard(title, items);
|
||||||
|
return buildLineReply({
|
||||||
|
flexMessage: {
|
||||||
|
altText: `${title}: ${items.map((i) => i.title).join(", ")}`.slice(0, 400),
|
||||||
|
contents: bubble,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case "receipt": {
|
||||||
|
const [title = "Receipt", itemsStr = ""] = args;
|
||||||
|
const items = parseReceiptItems(itemsStr || flags.items || "");
|
||||||
|
const total = flags.total ? { label: "Total", value: flags.total } : undefined;
|
||||||
|
const footer = flags.footer;
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return {
|
||||||
|
text:
|
||||||
|
'Error: Receipt card requires items. Usage: /card receipt "Title" "Item1:$10,Item2:$20" --total "$30"',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const bubble = createReceiptCard({ title, items, total, footer });
|
||||||
|
return buildLineReply({
|
||||||
|
flexMessage: {
|
||||||
|
altText: `${title}: ${items.map((i) => `${i.name} ${i.value}`).join(", ")}`.slice(
|
||||||
|
0,
|
||||||
|
400,
|
||||||
|
),
|
||||||
|
contents: bubble,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case "confirm": {
|
||||||
|
const [question = "Confirm?"] = args;
|
||||||
|
const yesStr = flags.yes || "Yes|yes";
|
||||||
|
const noStr = flags.no || "No|no";
|
||||||
|
|
||||||
|
const [yesLabel, yesData] = yesStr.split("|").map((s) => s.trim());
|
||||||
|
const [noLabel, noData] = noStr.split("|").map((s) => s.trim());
|
||||||
|
|
||||||
|
return buildLineReply({
|
||||||
|
templateMessage: {
|
||||||
|
type: "confirm",
|
||||||
|
text: question,
|
||||||
|
confirmLabel: yesLabel || "Yes",
|
||||||
|
confirmData: yesData || "yes",
|
||||||
|
cancelLabel: noLabel || "No",
|
||||||
|
cancelData: noData || "no",
|
||||||
|
altText: question,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case "buttons": {
|
||||||
|
const [title = "Menu", text = "Choose an option"] = args;
|
||||||
|
const actionsStr = flags.actions || "";
|
||||||
|
const actionParts = parseActions(actionsStr);
|
||||||
|
|
||||||
|
if (actionParts.length === 0) {
|
||||||
|
return { text: 'Error: Buttons card requires --actions "Label1|data1,Label2|data2"' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateActions: Array<{
|
||||||
|
type: "message" | "uri" | "postback";
|
||||||
|
label: string;
|
||||||
|
data?: string;
|
||||||
|
uri?: string;
|
||||||
|
}> = actionParts.map((a) => {
|
||||||
|
const action = a.action;
|
||||||
|
const label = action.label ?? a.label;
|
||||||
|
if (action.type === "uri") {
|
||||||
|
return { type: "uri" as const, label, uri: (action as { uri: string }).uri };
|
||||||
|
}
|
||||||
|
if (action.type === "postback") {
|
||||||
|
return {
|
||||||
|
type: "postback" as const,
|
||||||
|
label,
|
||||||
|
data: (action as { data: string }).data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "message" as const,
|
||||||
|
label,
|
||||||
|
data: (action as { text: string }).text,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return buildLineReply({
|
||||||
|
templateMessage: {
|
||||||
|
type: "buttons",
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
thumbnailImageUrl: flags.url || flags.image,
|
||||||
|
actions: templateActions,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
text: `Unknown card type: "${type}". Available types: info, image, action, list, receipt, confirm, buttons`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return { text: `Error creating card: ${String(err)}` };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
|
import { linePlugin } from "./channel.js";
|
||||||
|
import { setLineRuntime } from "./runtime.js";
|
||||||
|
|
||||||
|
const DEFAULT_ACCOUNT_ID = "default";
|
||||||
|
|
||||||
|
type LineRuntimeMocks = {
|
||||||
|
writeConfigFile: ReturnType<typeof vi.fn>;
|
||||||
|
resolveLineAccount: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } {
|
||||||
|
const writeConfigFile = vi.fn(async () => {});
|
||||||
|
const resolveLineAccount = vi.fn(({ cfg, accountId }: { cfg: ClawdbotConfig; accountId?: string }) => {
|
||||||
|
const lineConfig = (cfg.channels?.line ?? {}) as {
|
||||||
|
tokenFile?: string;
|
||||||
|
secretFile?: string;
|
||||||
|
channelAccessToken?: string;
|
||||||
|
channelSecret?: string;
|
||||||
|
accounts?: Record<string, Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
const entry =
|
||||||
|
accountId && accountId !== DEFAULT_ACCOUNT_ID
|
||||||
|
? lineConfig.accounts?.[accountId] ?? {}
|
||||||
|
: lineConfig;
|
||||||
|
const hasToken =
|
||||||
|
Boolean((entry as any).channelAccessToken) || Boolean((entry as any).tokenFile);
|
||||||
|
const hasSecret =
|
||||||
|
Boolean((entry as any).channelSecret) || Boolean((entry as any).secretFile);
|
||||||
|
return { tokenSource: hasToken && hasSecret ? "config" : "none" };
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtime = {
|
||||||
|
config: { writeConfigFile },
|
||||||
|
channel: { line: { resolveLineAccount } },
|
||||||
|
} as unknown as PluginRuntime;
|
||||||
|
|
||||||
|
return { runtime, mocks: { writeConfigFile, resolveLineAccount } };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("linePlugin gateway.logoutAccount", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setLineRuntime(createRuntime().runtime);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears tokenFile/secretFile on default account logout", async () => {
|
||||||
|
const { runtime, mocks } = createRuntime();
|
||||||
|
setLineRuntime(runtime);
|
||||||
|
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
channels: {
|
||||||
|
line: {
|
||||||
|
tokenFile: "/tmp/token",
|
||||||
|
secretFile: "/tmp/secret",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await linePlugin.gateway.logoutAccount({
|
||||||
|
accountId: DEFAULT_ACCOUNT_ID,
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.cleared).toBe(true);
|
||||||
|
expect(result.loggedOut).toBe(true);
|
||||||
|
expect(mocks.writeConfigFile).toHaveBeenCalledWith({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears tokenFile/secretFile on account logout", async () => {
|
||||||
|
const { runtime, mocks } = createRuntime();
|
||||||
|
setLineRuntime(runtime);
|
||||||
|
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
channels: {
|
||||||
|
line: {
|
||||||
|
accounts: {
|
||||||
|
primary: {
|
||||||
|
tokenFile: "/tmp/token",
|
||||||
|
secretFile: "/tmp/secret",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await linePlugin.gateway.logoutAccount({
|
||||||
|
accountId: "primary",
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.cleared).toBe(true);
|
||||||
|
expect(result.loggedOut).toBe(true);
|
||||||
|
expect(mocks.writeConfigFile).toHaveBeenCalledWith({});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
|
import { linePlugin } from "./channel.js";
|
||||||
|
import { setLineRuntime } from "./runtime.js";
|
||||||
|
|
||||||
|
type LineRuntimeMocks = {
|
||||||
|
pushMessageLine: ReturnType<typeof vi.fn>;
|
||||||
|
pushMessagesLine: ReturnType<typeof vi.fn>;
|
||||||
|
pushFlexMessage: ReturnType<typeof vi.fn>;
|
||||||
|
pushTemplateMessage: ReturnType<typeof vi.fn>;
|
||||||
|
pushLocationMessage: ReturnType<typeof vi.fn>;
|
||||||
|
pushTextMessageWithQuickReplies: ReturnType<typeof vi.fn>;
|
||||||
|
createQuickReplyItems: ReturnType<typeof vi.fn>;
|
||||||
|
buildTemplateMessageFromPayload: ReturnType<typeof vi.fn>;
|
||||||
|
sendMessageLine: ReturnType<typeof vi.fn>;
|
||||||
|
chunkMarkdownText: ReturnType<typeof vi.fn>;
|
||||||
|
resolveLineAccount: ReturnType<typeof vi.fn>;
|
||||||
|
resolveTextChunkLimit: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } {
|
||||||
|
const pushMessageLine = vi.fn(async () => ({ messageId: "m-text", chatId: "c1" }));
|
||||||
|
const pushMessagesLine = vi.fn(async () => ({ messageId: "m-batch", chatId: "c1" }));
|
||||||
|
const pushFlexMessage = vi.fn(async () => ({ messageId: "m-flex", chatId: "c1" }));
|
||||||
|
const pushTemplateMessage = vi.fn(async () => ({ messageId: "m-template", chatId: "c1" }));
|
||||||
|
const pushLocationMessage = vi.fn(async () => ({ messageId: "m-loc", chatId: "c1" }));
|
||||||
|
const pushTextMessageWithQuickReplies = vi.fn(async () => ({
|
||||||
|
messageId: "m-quick",
|
||||||
|
chatId: "c1",
|
||||||
|
}));
|
||||||
|
const createQuickReplyItems = vi.fn((labels: string[]) => ({ items: labels }));
|
||||||
|
const buildTemplateMessageFromPayload = vi.fn(() => ({ type: "buttons" }));
|
||||||
|
const sendMessageLine = vi.fn(async () => ({ messageId: "m-media", chatId: "c1" }));
|
||||||
|
const chunkMarkdownText = vi.fn((text: string) => [text]);
|
||||||
|
const resolveTextChunkLimit = vi.fn(() => 123);
|
||||||
|
const resolveLineAccount = vi.fn(({ cfg, accountId }: { cfg: ClawdbotConfig; accountId?: string }) => {
|
||||||
|
const resolved = accountId ?? "default";
|
||||||
|
const lineConfig = (cfg.channels?.line ?? {}) as {
|
||||||
|
accounts?: Record<string, Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
const accountConfig =
|
||||||
|
resolved !== "default" ? lineConfig.accounts?.[resolved] ?? {} : {};
|
||||||
|
return {
|
||||||
|
accountId: resolved,
|
||||||
|
config: { ...lineConfig, ...accountConfig },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtime = {
|
||||||
|
channel: {
|
||||||
|
line: {
|
||||||
|
pushMessageLine,
|
||||||
|
pushMessagesLine,
|
||||||
|
pushFlexMessage,
|
||||||
|
pushTemplateMessage,
|
||||||
|
pushLocationMessage,
|
||||||
|
pushTextMessageWithQuickReplies,
|
||||||
|
createQuickReplyItems,
|
||||||
|
buildTemplateMessageFromPayload,
|
||||||
|
sendMessageLine,
|
||||||
|
resolveLineAccount,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
chunkMarkdownText,
|
||||||
|
resolveTextChunkLimit,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as PluginRuntime;
|
||||||
|
|
||||||
|
return {
|
||||||
|
runtime,
|
||||||
|
mocks: {
|
||||||
|
pushMessageLine,
|
||||||
|
pushMessagesLine,
|
||||||
|
pushFlexMessage,
|
||||||
|
pushTemplateMessage,
|
||||||
|
pushLocationMessage,
|
||||||
|
pushTextMessageWithQuickReplies,
|
||||||
|
createQuickReplyItems,
|
||||||
|
buildTemplateMessageFromPayload,
|
||||||
|
sendMessageLine,
|
||||||
|
chunkMarkdownText,
|
||||||
|
resolveLineAccount,
|
||||||
|
resolveTextChunkLimit,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("linePlugin outbound.sendPayload", () => {
|
||||||
|
it("sends flex message without dropping text", async () => {
|
||||||
|
const { runtime, mocks } = createRuntime();
|
||||||
|
setLineRuntime(runtime);
|
||||||
|
const cfg = { channels: { line: {} } } as ClawdbotConfig;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
text: "Now playing:",
|
||||||
|
channelData: {
|
||||||
|
line: {
|
||||||
|
flexMessage: {
|
||||||
|
altText: "Now playing",
|
||||||
|
contents: { type: "bubble" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await linePlugin.outbound.sendPayload({
|
||||||
|
to: "line:group:1",
|
||||||
|
payload,
|
||||||
|
accountId: "default",
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.pushFlexMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.pushMessageLine).toHaveBeenCalledWith("line:group:1", "Now playing:", {
|
||||||
|
verbose: false,
|
||||||
|
accountId: "default",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends template message without dropping text", async () => {
|
||||||
|
const { runtime, mocks } = createRuntime();
|
||||||
|
setLineRuntime(runtime);
|
||||||
|
const cfg = { channels: { line: {} } } as ClawdbotConfig;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
text: "Choose one:",
|
||||||
|
channelData: {
|
||||||
|
line: {
|
||||||
|
templateMessage: {
|
||||||
|
type: "confirm",
|
||||||
|
text: "Continue?",
|
||||||
|
confirmLabel: "Yes",
|
||||||
|
confirmData: "yes",
|
||||||
|
cancelLabel: "No",
|
||||||
|
cancelData: "no",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await linePlugin.outbound.sendPayload({
|
||||||
|
to: "line:user:1",
|
||||||
|
payload,
|
||||||
|
accountId: "default",
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.buildTemplateMessageFromPayload).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.pushTemplateMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.pushMessageLine).toHaveBeenCalledWith("line:user:1", "Choose one:", {
|
||||||
|
verbose: false,
|
||||||
|
accountId: "default",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches quick replies when no text chunks are present", async () => {
|
||||||
|
const { runtime, mocks } = createRuntime();
|
||||||
|
setLineRuntime(runtime);
|
||||||
|
const cfg = { channels: { line: {} } } as ClawdbotConfig;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
channelData: {
|
||||||
|
line: {
|
||||||
|
quickReplies: ["One", "Two"],
|
||||||
|
flexMessage: {
|
||||||
|
altText: "Card",
|
||||||
|
contents: { type: "bubble" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await linePlugin.outbound.sendPayload({
|
||||||
|
to: "line:user:2",
|
||||||
|
payload,
|
||||||
|
accountId: "default",
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.pushFlexMessage).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.pushMessagesLine).toHaveBeenCalledWith(
|
||||||
|
"line:user:2",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "flex",
|
||||||
|
altText: "Card",
|
||||||
|
contents: { type: "bubble" },
|
||||||
|
quickReply: { items: ["One", "Two"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ verbose: false, accountId: "default" },
|
||||||
|
);
|
||||||
|
expect(mocks.createQuickReplyItems).toHaveBeenCalledWith(["One", "Two"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends media before quick-reply text so buttons stay visible", async () => {
|
||||||
|
const { runtime, mocks } = createRuntime();
|
||||||
|
setLineRuntime(runtime);
|
||||||
|
const cfg = { channels: { line: {} } } as ClawdbotConfig;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
text: "Hello",
|
||||||
|
mediaUrl: "https://example.com/img.jpg",
|
||||||
|
channelData: {
|
||||||
|
line: {
|
||||||
|
quickReplies: ["One", "Two"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await linePlugin.outbound.sendPayload({
|
||||||
|
to: "line:user:3",
|
||||||
|
payload,
|
||||||
|
accountId: "default",
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.sendMessageLine).toHaveBeenCalledWith("line:user:3", "", {
|
||||||
|
verbose: false,
|
||||||
|
mediaUrl: "https://example.com/img.jpg",
|
||||||
|
accountId: "default",
|
||||||
|
});
|
||||||
|
expect(mocks.pushTextMessageWithQuickReplies).toHaveBeenCalledWith(
|
||||||
|
"line:user:3",
|
||||||
|
"Hello",
|
||||||
|
["One", "Two"],
|
||||||
|
{ verbose: false, accountId: "default" },
|
||||||
|
);
|
||||||
|
const mediaOrder = mocks.sendMessageLine.mock.invocationCallOrder[0];
|
||||||
|
const quickReplyOrder = mocks.pushTextMessageWithQuickReplies.mock.invocationCallOrder[0];
|
||||||
|
expect(mediaOrder).toBeLessThan(quickReplyOrder);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses configured text chunk limit for payloads", async () => {
|
||||||
|
const { runtime, mocks } = createRuntime();
|
||||||
|
setLineRuntime(runtime);
|
||||||
|
const cfg = { channels: { line: { textChunkLimit: 123 } } } as ClawdbotConfig;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
text: "Hello world",
|
||||||
|
channelData: {
|
||||||
|
line: {
|
||||||
|
flexMessage: {
|
||||||
|
altText: "Card",
|
||||||
|
contents: { type: "bubble" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await linePlugin.outbound.sendPayload({
|
||||||
|
to: "line:user:3",
|
||||||
|
payload,
|
||||||
|
accountId: "primary",
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.resolveTextChunkLimit).toHaveBeenCalledWith(
|
||||||
|
cfg,
|
||||||
|
"line",
|
||||||
|
"primary",
|
||||||
|
{ fallbackLimit: 5000 },
|
||||||
|
);
|
||||||
|
expect(mocks.chunkMarkdownText).toHaveBeenCalledWith("Hello world", 123);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("linePlugin config.formatAllowFrom", () => {
|
||||||
|
it("strips line:user: prefixes without lowercasing", () => {
|
||||||
|
const formatted = linePlugin.config.formatAllowFrom({
|
||||||
|
allowFrom: ["line:user:UABC", "line:UDEF"],
|
||||||
|
});
|
||||||
|
expect(formatted).toEqual(["UABC", "UDEF"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("linePlugin groups.resolveRequireMention", () => {
|
||||||
|
it("uses account-level group settings when provided", () => {
|
||||||
|
const { runtime } = createRuntime();
|
||||||
|
setLineRuntime(runtime);
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
channels: {
|
||||||
|
line: {
|
||||||
|
groups: {
|
||||||
|
"*": { requireMention: false },
|
||||||
|
},
|
||||||
|
accounts: {
|
||||||
|
primary: {
|
||||||
|
groups: {
|
||||||
|
"group-1": { requireMention: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
|
||||||
|
const requireMention = linePlugin.groups.resolveRequireMention({
|
||||||
|
cfg,
|
||||||
|
accountId: "primary",
|
||||||
|
groupId: "group-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(requireMention).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,773 @@
|
|||||||
|
import {
|
||||||
|
buildChannelConfigSchema,
|
||||||
|
DEFAULT_ACCOUNT_ID,
|
||||||
|
LineConfigSchema,
|
||||||
|
processLineMessage,
|
||||||
|
type ChannelPlugin,
|
||||||
|
type ClawdbotConfig,
|
||||||
|
type LineConfig,
|
||||||
|
type LineChannelData,
|
||||||
|
type ResolvedLineAccount,
|
||||||
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import { getLineRuntime } from "./runtime.js";
|
||||||
|
|
||||||
|
// LINE channel metadata
|
||||||
|
const meta = {
|
||||||
|
id: "line",
|
||||||
|
label: "LINE",
|
||||||
|
selectionLabel: "LINE (Messaging API)",
|
||||||
|
detailLabel: "LINE Bot",
|
||||||
|
docsPath: "/channels/line",
|
||||||
|
docsLabel: "line",
|
||||||
|
blurb: "LINE Messaging API bot for Japan/Taiwan/Thailand markets.",
|
||||||
|
systemImage: "message.fill",
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseThreadId(threadId?: string | number | null): number | undefined {
|
||||||
|
if (threadId == null) return undefined;
|
||||||
|
if (typeof threadId === "number") {
|
||||||
|
return Number.isFinite(threadId) ? Math.trunc(threadId) : undefined;
|
||||||
|
}
|
||||||
|
const trimmed = threadId.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
const parsed = Number.parseInt(trimmed, 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
||||||
|
id: "line",
|
||||||
|
meta: {
|
||||||
|
...meta,
|
||||||
|
quickstartAllowFrom: true,
|
||||||
|
},
|
||||||
|
pairing: {
|
||||||
|
idLabel: "lineUserId",
|
||||||
|
normalizeAllowEntry: (entry) => {
|
||||||
|
// LINE IDs are case-sensitive; only strip prefix variants (line: / line:user:).
|
||||||
|
return entry.replace(/^line:(?:user:)?/i, "");
|
||||||
|
},
|
||||||
|
notifyApproval: async ({ cfg, id }) => {
|
||||||
|
const line = getLineRuntime().channel.line;
|
||||||
|
const account = line.resolveLineAccount({ cfg });
|
||||||
|
if (!account.channelAccessToken) {
|
||||||
|
throw new Error("LINE channel access token not configured");
|
||||||
|
}
|
||||||
|
await line.pushMessageLine(id, "Clawdbot: your access has been approved.", {
|
||||||
|
channelAccessToken: account.channelAccessToken,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
chatTypes: ["direct", "group"],
|
||||||
|
reactions: false,
|
||||||
|
threads: false,
|
||||||
|
media: true,
|
||||||
|
nativeCommands: false,
|
||||||
|
blockStreaming: true,
|
||||||
|
},
|
||||||
|
reload: { configPrefixes: ["channels.line"] },
|
||||||
|
configSchema: buildChannelConfigSchema(LineConfigSchema),
|
||||||
|
config: {
|
||||||
|
listAccountIds: (cfg) => getLineRuntime().channel.line.listLineAccountIds(cfg),
|
||||||
|
resolveAccount: (cfg, accountId) =>
|
||||||
|
getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId }),
|
||||||
|
defaultAccountId: (cfg) => getLineRuntime().channel.line.resolveDefaultLineAccountId(cfg),
|
||||||
|
setAccountEnabled: ({ cfg, accountId, enabled }) => {
|
||||||
|
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
||||||
|
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
line: {
|
||||||
|
...lineConfig,
|
||||||
|
enabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
line: {
|
||||||
|
...lineConfig,
|
||||||
|
accounts: {
|
||||||
|
...lineConfig.accounts,
|
||||||
|
[accountId]: {
|
||||||
|
...lineConfig.accounts?.[accountId],
|
||||||
|
enabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
deleteAccount: ({ cfg, accountId }) => {
|
||||||
|
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
||||||
|
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||||
|
const { channelAccessToken, channelSecret, tokenFile, secretFile, ...rest } = lineConfig;
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
line: rest,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const accounts = { ...lineConfig.accounts };
|
||||||
|
delete accounts[accountId];
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
line: {
|
||||||
|
...lineConfig,
|
||||||
|
accounts: Object.keys(accounts).length > 0 ? accounts : undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
isConfigured: (account) => Boolean(account.channelAccessToken?.trim()),
|
||||||
|
describeAccount: (account) => ({
|
||||||
|
accountId: account.accountId,
|
||||||
|
name: account.name,
|
||||||
|
enabled: account.enabled,
|
||||||
|
configured: Boolean(account.channelAccessToken?.trim()),
|
||||||
|
tokenSource: account.tokenSource,
|
||||||
|
}),
|
||||||
|
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||||
|
(getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId }).config.allowFrom ?? []).map(
|
||||||
|
(entry) => String(entry),
|
||||||
|
),
|
||||||
|
formatAllowFrom: ({ allowFrom }) =>
|
||||||
|
allowFrom
|
||||||
|
.map((entry) => String(entry).trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((entry) => {
|
||||||
|
// LINE sender IDs are case-sensitive; keep original casing.
|
||||||
|
return entry.replace(/^line:(?:user:)?/i, "");
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
security: {
|
||||||
|
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||||
|
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||||
|
const useAccountPath = Boolean(
|
||||||
|
(cfg.channels?.line as LineConfig | undefined)?.accounts?.[resolvedAccountId],
|
||||||
|
);
|
||||||
|
const basePath = useAccountPath
|
||||||
|
? `channels.line.accounts.${resolvedAccountId}.`
|
||||||
|
: "channels.line.";
|
||||||
|
return {
|
||||||
|
policy: account.config.dmPolicy ?? "pairing",
|
||||||
|
allowFrom: account.config.allowFrom ?? [],
|
||||||
|
policyPath: `${basePath}dmPolicy`,
|
||||||
|
allowFromPath: basePath,
|
||||||
|
approveHint: "clawdbot pairing approve line <code>",
|
||||||
|
normalizeEntry: (raw) => raw.replace(/^line:(?:user:)?/i, ""),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
collectWarnings: ({ account, cfg }) => {
|
||||||
|
const defaultGroupPolicy =
|
||||||
|
(cfg.channels?.defaults as { groupPolicy?: string } | undefined)?.groupPolicy;
|
||||||
|
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||||
|
if (groupPolicy !== "open") return [];
|
||||||
|
return [
|
||||||
|
`- LINE groups: groupPolicy="open" allows any member in groups to trigger. Set channels.line.groupPolicy="allowlist" + channels.line.groupAllowFrom to restrict senders.`,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
groups: {
|
||||||
|
resolveRequireMention: ({ cfg, accountId, groupId }) => {
|
||||||
|
const account = getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId });
|
||||||
|
const groups = account.config.groups;
|
||||||
|
if (!groups) return false;
|
||||||
|
const groupConfig = groups[groupId] ?? groups["*"];
|
||||||
|
return groupConfig?.requireMention ?? false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
messaging: {
|
||||||
|
normalizeTarget: (target) => {
|
||||||
|
const trimmed = target.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
return trimmed.replace(/^line:(group|room|user):/i, "").replace(/^line:/i, "");
|
||||||
|
},
|
||||||
|
targetResolver: {
|
||||||
|
looksLikeId: (id) => {
|
||||||
|
const trimmed = id?.trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
// LINE user IDs are typically U followed by 32 hex characters
|
||||||
|
// Group IDs are C followed by 32 hex characters
|
||||||
|
// Room IDs are R followed by 32 hex characters
|
||||||
|
return /^[UCR][a-f0-9]{32}$/i.test(trimmed) || /^line:/i.test(trimmed);
|
||||||
|
},
|
||||||
|
hint: "<userId|groupId|roomId>",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
directory: {
|
||||||
|
self: async () => null,
|
||||||
|
listPeers: async () => [],
|
||||||
|
listGroups: async () => [],
|
||||||
|
},
|
||||||
|
setup: {
|
||||||
|
resolveAccountId: ({ accountId }) =>
|
||||||
|
getLineRuntime().channel.line.normalizeAccountId(accountId),
|
||||||
|
applyAccountName: ({ cfg, accountId, name }) => {
|
||||||
|
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
||||||
|
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
line: {
|
||||||
|
...lineConfig,
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
line: {
|
||||||
|
...lineConfig,
|
||||||
|
accounts: {
|
||||||
|
...lineConfig.accounts,
|
||||||
|
[accountId]: {
|
||||||
|
...lineConfig.accounts?.[accountId],
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
validateInput: ({ accountId, input }) => {
|
||||||
|
const typedInput = input as {
|
||||||
|
useEnv?: boolean;
|
||||||
|
channelAccessToken?: string;
|
||||||
|
channelSecret?: string;
|
||||||
|
tokenFile?: string;
|
||||||
|
secretFile?: string;
|
||||||
|
};
|
||||||
|
if (typedInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
|
||||||
|
return "LINE_CHANNEL_ACCESS_TOKEN can only be used for the default account.";
|
||||||
|
}
|
||||||
|
if (!typedInput.useEnv && !typedInput.channelAccessToken && !typedInput.tokenFile) {
|
||||||
|
return "LINE requires channelAccessToken or --token-file (or --use-env).";
|
||||||
|
}
|
||||||
|
if (!typedInput.useEnv && !typedInput.channelSecret && !typedInput.secretFile) {
|
||||||
|
return "LINE requires channelSecret or --secret-file (or --use-env).";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
applyAccountConfig: ({ cfg, accountId, input }) => {
|
||||||
|
const typedInput = input as {
|
||||||
|
name?: string;
|
||||||
|
useEnv?: boolean;
|
||||||
|
channelAccessToken?: string;
|
||||||
|
channelSecret?: string;
|
||||||
|
tokenFile?: string;
|
||||||
|
secretFile?: string;
|
||||||
|
};
|
||||||
|
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
||||||
|
|
||||||
|
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
line: {
|
||||||
|
...lineConfig,
|
||||||
|
enabled: true,
|
||||||
|
...(typedInput.name ? { name: typedInput.name } : {}),
|
||||||
|
...(typedInput.useEnv
|
||||||
|
? {}
|
||||||
|
: typedInput.tokenFile
|
||||||
|
? { tokenFile: typedInput.tokenFile }
|
||||||
|
: typedInput.channelAccessToken
|
||||||
|
? { channelAccessToken: typedInput.channelAccessToken }
|
||||||
|
: {}),
|
||||||
|
...(typedInput.useEnv
|
||||||
|
? {}
|
||||||
|
: typedInput.secretFile
|
||||||
|
? { secretFile: typedInput.secretFile }
|
||||||
|
: typedInput.channelSecret
|
||||||
|
? { channelSecret: typedInput.channelSecret }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
line: {
|
||||||
|
...lineConfig,
|
||||||
|
enabled: true,
|
||||||
|
accounts: {
|
||||||
|
...lineConfig.accounts,
|
||||||
|
[accountId]: {
|
||||||
|
...lineConfig.accounts?.[accountId],
|
||||||
|
enabled: true,
|
||||||
|
...(typedInput.name ? { name: typedInput.name } : {}),
|
||||||
|
...(typedInput.tokenFile
|
||||||
|
? { tokenFile: typedInput.tokenFile }
|
||||||
|
: typedInput.channelAccessToken
|
||||||
|
? { channelAccessToken: typedInput.channelAccessToken }
|
||||||
|
: {}),
|
||||||
|
...(typedInput.secretFile
|
||||||
|
? { secretFile: typedInput.secretFile }
|
||||||
|
: typedInput.channelSecret
|
||||||
|
? { channelSecret: typedInput.channelSecret }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
outbound: {
|
||||||
|
deliveryMode: "direct",
|
||||||
|
chunker: (text, limit) => getLineRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||||
|
textChunkLimit: 5000, // LINE allows up to 5000 characters per text message
|
||||||
|
sendPayload: async ({ to, payload, accountId, cfg }) => {
|
||||||
|
const runtime = getLineRuntime();
|
||||||
|
const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {};
|
||||||
|
const sendText = runtime.channel.line.pushMessageLine;
|
||||||
|
const sendBatch = runtime.channel.line.pushMessagesLine;
|
||||||
|
const sendFlex = runtime.channel.line.pushFlexMessage;
|
||||||
|
const sendTemplate = runtime.channel.line.pushTemplateMessage;
|
||||||
|
const sendLocation = runtime.channel.line.pushLocationMessage;
|
||||||
|
const sendQuickReplies = runtime.channel.line.pushTextMessageWithQuickReplies;
|
||||||
|
const buildTemplate = runtime.channel.line.buildTemplateMessageFromPayload;
|
||||||
|
const createQuickReplyItems = runtime.channel.line.createQuickReplyItems;
|
||||||
|
|
||||||
|
let lastResult: { messageId: string; chatId: string } | null = null;
|
||||||
|
const hasQuickReplies = Boolean(lineData.quickReplies?.length);
|
||||||
|
const quickReply = hasQuickReplies
|
||||||
|
? createQuickReplyItems(lineData.quickReplies!)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const sendMessageBatch = async (messages: Array<Record<string, unknown>>) => {
|
||||||
|
if (messages.length === 0) return;
|
||||||
|
for (let i = 0; i < messages.length; i += 5) {
|
||||||
|
const result = await sendBatch(to, messages.slice(i, i + 5), {
|
||||||
|
verbose: false,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
|
lastResult = { messageId: result.messageId, chatId: result.chatId };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const processed = payload.text
|
||||||
|
? processLineMessage(payload.text)
|
||||||
|
: { text: "", flexMessages: [] };
|
||||||
|
|
||||||
|
const chunkLimit =
|
||||||
|
runtime.channel.text.resolveTextChunkLimit?.(
|
||||||
|
cfg,
|
||||||
|
"line",
|
||||||
|
accountId ?? undefined,
|
||||||
|
{
|
||||||
|
fallbackLimit: 5000,
|
||||||
|
},
|
||||||
|
) ?? 5000;
|
||||||
|
|
||||||
|
const chunks = processed.text
|
||||||
|
? runtime.channel.text.chunkMarkdownText(processed.text, chunkLimit)
|
||||||
|
: [];
|
||||||
|
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||||
|
const shouldSendQuickRepliesInline = chunks.length === 0 && hasQuickReplies;
|
||||||
|
|
||||||
|
if (!shouldSendQuickRepliesInline) {
|
||||||
|
if (lineData.flexMessage) {
|
||||||
|
lastResult = await sendFlex(
|
||||||
|
to,
|
||||||
|
lineData.flexMessage.altText,
|
||||||
|
lineData.flexMessage.contents,
|
||||||
|
{
|
||||||
|
verbose: false,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lineData.templateMessage) {
|
||||||
|
const template = buildTemplate(lineData.templateMessage);
|
||||||
|
if (template) {
|
||||||
|
lastResult = await sendTemplate(to, template, {
|
||||||
|
verbose: false,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lineData.location) {
|
||||||
|
lastResult = await sendLocation(to, lineData.location, {
|
||||||
|
verbose: false,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const flexMsg of processed.flexMessages) {
|
||||||
|
lastResult = await sendFlex(to, flexMsg.altText, flexMsg.contents, {
|
||||||
|
verbose: false,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendMediaAfterText = !(hasQuickReplies && chunks.length > 0);
|
||||||
|
if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && !sendMediaAfterText) {
|
||||||
|
for (const url of mediaUrls) {
|
||||||
|
lastResult = await runtime.channel.line.sendMessageLine(to, "", {
|
||||||
|
verbose: false,
|
||||||
|
mediaUrl: url,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunks.length > 0) {
|
||||||
|
for (let i = 0; i < chunks.length; i += 1) {
|
||||||
|
const isLast = i === chunks.length - 1;
|
||||||
|
if (isLast && hasQuickReplies) {
|
||||||
|
lastResult = await sendQuickReplies(to, chunks[i]!, lineData.quickReplies!, {
|
||||||
|
verbose: false,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
lastResult = await sendText(to, chunks[i]!, {
|
||||||
|
verbose: false,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (shouldSendQuickRepliesInline) {
|
||||||
|
const quickReplyMessages: Array<Record<string, unknown>> = [];
|
||||||
|
if (lineData.flexMessage) {
|
||||||
|
quickReplyMessages.push({
|
||||||
|
type: "flex",
|
||||||
|
altText: lineData.flexMessage.altText.slice(0, 400),
|
||||||
|
contents: lineData.flexMessage.contents,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (lineData.templateMessage) {
|
||||||
|
const template = buildTemplate(lineData.templateMessage);
|
||||||
|
if (template) {
|
||||||
|
quickReplyMessages.push(template);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lineData.location) {
|
||||||
|
quickReplyMessages.push({
|
||||||
|
type: "location",
|
||||||
|
title: lineData.location.title.slice(0, 100),
|
||||||
|
address: lineData.location.address.slice(0, 100),
|
||||||
|
latitude: lineData.location.latitude,
|
||||||
|
longitude: lineData.location.longitude,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const flexMsg of processed.flexMessages) {
|
||||||
|
quickReplyMessages.push({
|
||||||
|
type: "flex",
|
||||||
|
altText: flexMsg.altText.slice(0, 400),
|
||||||
|
contents: flexMsg.contents,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const url of mediaUrls) {
|
||||||
|
const trimmed = url?.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
quickReplyMessages.push({
|
||||||
|
type: "image",
|
||||||
|
originalContentUrl: trimmed,
|
||||||
|
previewImageUrl: trimmed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (quickReplyMessages.length > 0 && quickReply) {
|
||||||
|
const lastIndex = quickReplyMessages.length - 1;
|
||||||
|
quickReplyMessages[lastIndex] = {
|
||||||
|
...quickReplyMessages[lastIndex],
|
||||||
|
quickReply,
|
||||||
|
};
|
||||||
|
await sendMessageBatch(quickReplyMessages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && sendMediaAfterText) {
|
||||||
|
for (const url of mediaUrls) {
|
||||||
|
lastResult = await runtime.channel.line.sendMessageLine(to, "", {
|
||||||
|
verbose: false,
|
||||||
|
mediaUrl: url,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastResult) return { channel: "line", ...lastResult };
|
||||||
|
return { channel: "line", messageId: "empty", chatId: to };
|
||||||
|
},
|
||||||
|
sendText: async ({ to, text, accountId }) => {
|
||||||
|
const runtime = getLineRuntime();
|
||||||
|
const sendText = runtime.channel.line.pushMessageLine;
|
||||||
|
const sendFlex = runtime.channel.line.pushFlexMessage;
|
||||||
|
|
||||||
|
// Process markdown: extract tables/code blocks, strip formatting
|
||||||
|
const processed = processLineMessage(text);
|
||||||
|
|
||||||
|
// Send cleaned text first (if non-empty)
|
||||||
|
let result: { messageId: string; chatId: string };
|
||||||
|
if (processed.text.trim()) {
|
||||||
|
result = await sendText(to, processed.text, {
|
||||||
|
verbose: false,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// If text is empty after processing, still need a result
|
||||||
|
result = { messageId: "processed", chatId: to };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send flex messages for tables/code blocks
|
||||||
|
for (const flexMsg of processed.flexMessages) {
|
||||||
|
await sendFlex(to, flexMsg.altText, flexMsg.contents, {
|
||||||
|
verbose: false,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { channel: "line", ...result };
|
||||||
|
},
|
||||||
|
sendMedia: async ({ to, text, mediaUrl, accountId }) => {
|
||||||
|
const send = getLineRuntime().channel.line.sendMessageLine;
|
||||||
|
const result = await send(to, text, {
|
||||||
|
verbose: false,
|
||||||
|
mediaUrl,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
});
|
||||||
|
return { channel: "line", ...result };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
defaultRuntime: {
|
||||||
|
accountId: DEFAULT_ACCOUNT_ID,
|
||||||
|
running: false,
|
||||||
|
lastStartAt: null,
|
||||||
|
lastStopAt: null,
|
||||||
|
lastError: null,
|
||||||
|
},
|
||||||
|
collectStatusIssues: ({ account }) => {
|
||||||
|
const issues: Array<{ level: "error" | "warning"; message: string }> = [];
|
||||||
|
if (!account.channelAccessToken?.trim()) {
|
||||||
|
issues.push({
|
||||||
|
level: "error",
|
||||||
|
message: "LINE channel access token not configured",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!account.channelSecret?.trim()) {
|
||||||
|
issues.push({
|
||||||
|
level: "error",
|
||||||
|
message: "LINE channel secret not configured",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
},
|
||||||
|
buildChannelSummary: ({ snapshot }) => ({
|
||||||
|
configured: snapshot.configured ?? false,
|
||||||
|
tokenSource: snapshot.tokenSource ?? "none",
|
||||||
|
running: snapshot.running ?? false,
|
||||||
|
mode: snapshot.mode ?? null,
|
||||||
|
lastStartAt: snapshot.lastStartAt ?? null,
|
||||||
|
lastStopAt: snapshot.lastStopAt ?? null,
|
||||||
|
lastError: snapshot.lastError ?? null,
|
||||||
|
probe: snapshot.probe,
|
||||||
|
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||||
|
}),
|
||||||
|
probeAccount: async ({ account, timeoutMs }) =>
|
||||||
|
getLineRuntime().channel.line.probeLineBot(account.channelAccessToken, timeoutMs),
|
||||||
|
buildAccountSnapshot: ({ account, runtime, probe }) => {
|
||||||
|
const configured = Boolean(account.channelAccessToken?.trim());
|
||||||
|
return {
|
||||||
|
accountId: account.accountId,
|
||||||
|
name: account.name,
|
||||||
|
enabled: account.enabled,
|
||||||
|
configured,
|
||||||
|
tokenSource: account.tokenSource,
|
||||||
|
running: runtime?.running ?? false,
|
||||||
|
lastStartAt: runtime?.lastStartAt ?? null,
|
||||||
|
lastStopAt: runtime?.lastStopAt ?? null,
|
||||||
|
lastError: runtime?.lastError ?? null,
|
||||||
|
mode: "webhook",
|
||||||
|
probe,
|
||||||
|
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||||
|
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
gateway: {
|
||||||
|
startAccount: async (ctx) => {
|
||||||
|
const account = ctx.account;
|
||||||
|
const token = account.channelAccessToken.trim();
|
||||||
|
const secret = account.channelSecret.trim();
|
||||||
|
|
||||||
|
let lineBotLabel = "";
|
||||||
|
try {
|
||||||
|
const probe = await getLineRuntime().channel.line.probeLineBot(token, 2500);
|
||||||
|
const displayName = probe.ok ? probe.bot?.displayName?.trim() : null;
|
||||||
|
if (displayName) lineBotLabel = ` (${displayName})`;
|
||||||
|
} catch (err) {
|
||||||
|
if (getLineRuntime().logging.shouldLogVerbose()) {
|
||||||
|
ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.log?.info(`[${account.accountId}] starting LINE provider${lineBotLabel}`);
|
||||||
|
|
||||||
|
return getLineRuntime().channel.line.monitorLineProvider({
|
||||||
|
channelAccessToken: token,
|
||||||
|
channelSecret: secret,
|
||||||
|
accountId: account.accountId,
|
||||||
|
config: ctx.cfg,
|
||||||
|
runtime: ctx.runtime,
|
||||||
|
abortSignal: ctx.abortSignal,
|
||||||
|
webhookPath: account.config.webhookPath,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
logoutAccount: async ({ accountId, cfg }) => {
|
||||||
|
const envToken = process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim() ?? "";
|
||||||
|
const nextCfg = { ...cfg } as ClawdbotConfig;
|
||||||
|
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
||||||
|
const nextLine = { ...lineConfig };
|
||||||
|
let cleared = false;
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||||
|
if (
|
||||||
|
nextLine.channelAccessToken ||
|
||||||
|
nextLine.channelSecret ||
|
||||||
|
nextLine.tokenFile ||
|
||||||
|
nextLine.secretFile
|
||||||
|
) {
|
||||||
|
delete nextLine.channelAccessToken;
|
||||||
|
delete nextLine.channelSecret;
|
||||||
|
delete nextLine.tokenFile;
|
||||||
|
delete nextLine.secretFile;
|
||||||
|
cleared = true;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const accounts = nextLine.accounts ? { ...nextLine.accounts } : undefined;
|
||||||
|
if (accounts && accountId in accounts) {
|
||||||
|
const entry = accounts[accountId];
|
||||||
|
if (entry && typeof entry === "object") {
|
||||||
|
const nextEntry = { ...entry } as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
"channelAccessToken" in nextEntry ||
|
||||||
|
"channelSecret" in nextEntry ||
|
||||||
|
"tokenFile" in nextEntry ||
|
||||||
|
"secretFile" in nextEntry
|
||||||
|
) {
|
||||||
|
cleared = true;
|
||||||
|
delete nextEntry.channelAccessToken;
|
||||||
|
delete nextEntry.channelSecret;
|
||||||
|
delete nextEntry.tokenFile;
|
||||||
|
delete nextEntry.secretFile;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (Object.keys(nextEntry).length === 0) {
|
||||||
|
delete accounts[accountId];
|
||||||
|
changed = true;
|
||||||
|
} else {
|
||||||
|
accounts[accountId] = nextEntry as typeof entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accounts) {
|
||||||
|
if (Object.keys(accounts).length === 0) {
|
||||||
|
delete nextLine.accounts;
|
||||||
|
changed = true;
|
||||||
|
} else {
|
||||||
|
nextLine.accounts = accounts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
if (Object.keys(nextLine).length > 0) {
|
||||||
|
nextCfg.channels = { ...nextCfg.channels, line: nextLine };
|
||||||
|
} else {
|
||||||
|
const nextChannels = { ...nextCfg.channels };
|
||||||
|
delete (nextChannels as Record<string, unknown>).line;
|
||||||
|
if (Object.keys(nextChannels).length > 0) {
|
||||||
|
nextCfg.channels = nextChannels;
|
||||||
|
} else {
|
||||||
|
delete nextCfg.channels;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await getLineRuntime().config.writeConfigFile(nextCfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = getLineRuntime().channel.line.resolveLineAccount({
|
||||||
|
cfg: changed ? nextCfg : cfg,
|
||||||
|
accountId,
|
||||||
|
});
|
||||||
|
const loggedOut = resolved.tokenSource === "none";
|
||||||
|
|
||||||
|
return { cleared, envToken: Boolean(envToken), loggedOut };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
agentPrompt: {
|
||||||
|
messageToolHints: () => [
|
||||||
|
"",
|
||||||
|
"### LINE Rich Messages",
|
||||||
|
"LINE supports rich visual messages. Use these directives in your reply when appropriate:",
|
||||||
|
"",
|
||||||
|
"**Quick Replies** (bottom button suggestions):",
|
||||||
|
" [[quick_replies: Option 1, Option 2, Option 3]]",
|
||||||
|
"",
|
||||||
|
"**Location** (map pin):",
|
||||||
|
" [[location: Place Name | Address | latitude | longitude]]",
|
||||||
|
"",
|
||||||
|
"**Confirm Dialog** (yes/no prompt):",
|
||||||
|
" [[confirm: Question text? | Yes Label | No Label]]",
|
||||||
|
"",
|
||||||
|
"**Button Menu** (title + text + buttons):",
|
||||||
|
" [[buttons: Title | Description | Btn1:action1, Btn2:https://url.com]]",
|
||||||
|
"",
|
||||||
|
"**Media Player Card** (music status):",
|
||||||
|
" [[media_player: Song Title | Artist Name | Source | https://albumart.url | playing]]",
|
||||||
|
" - Status: 'playing' or 'paused' (optional)",
|
||||||
|
"",
|
||||||
|
"**Event Card** (calendar events, meetings):",
|
||||||
|
" [[event: Event Title | Date | Time | Location | Description]]",
|
||||||
|
" - Time, Location, Description are optional",
|
||||||
|
"",
|
||||||
|
"**Agenda Card** (multiple events/schedule):",
|
||||||
|
" [[agenda: Schedule Title | Event1:9:00 AM, Event2:12:00 PM, Event3:3:00 PM]]",
|
||||||
|
"",
|
||||||
|
"**Device Control Card** (smart devices, TVs, etc.):",
|
||||||
|
" [[device: Device Name | Device Type | Status | Control1:data1, Control2:data2]]",
|
||||||
|
"",
|
||||||
|
"**Apple TV Remote** (full D-pad + transport):",
|
||||||
|
" [[appletv_remote: Apple TV | Playing]]",
|
||||||
|
"",
|
||||||
|
"**Auto-converted**: Markdown tables become Flex cards, code blocks become styled cards.",
|
||||||
|
"",
|
||||||
|
"When to use rich messages:",
|
||||||
|
"- Use [[quick_replies:...]] when offering 2-4 clear options",
|
||||||
|
"- Use [[confirm:...]] for yes/no decisions",
|
||||||
|
"- Use [[buttons:...]] for menus with actions/links",
|
||||||
|
"- Use [[location:...]] when sharing a place",
|
||||||
|
"- Use [[media_player:...]] when showing what's playing",
|
||||||
|
"- Use [[event:...]] for calendar event details",
|
||||||
|
"- Use [[agenda:...]] for a day's schedule or event list",
|
||||||
|
"- Use [[device:...]] for smart device status/controls",
|
||||||
|
"- Tables/code in your response auto-convert to visual cards",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
let runtime: PluginRuntime | null = null;
|
||||||
|
|
||||||
|
export function setLineRuntime(r: PluginRuntime): void {
|
||||||
|
runtime = r;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLineRuntime(): PluginRuntime {
|
||||||
|
if (!runtime) {
|
||||||
|
throw new Error("LINE runtime not initialized - plugin not registered");
|
||||||
|
}
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ export const MatrixConfigSchema = z.object({
|
|||||||
replyToMode: z.enum(["off", "first", "all"]).optional(),
|
replyToMode: z.enum(["off", "first", "all"]).optional(),
|
||||||
threadReplies: z.enum(["off", "inbound", "always"]).optional(),
|
threadReplies: z.enum(["off", "inbound", "always"]).optional(),
|
||||||
textChunkLimit: z.number().optional(),
|
textChunkLimit: z.number().optional(),
|
||||||
|
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||||
mediaMaxMb: z.number().optional(),
|
mediaMaxMb: z.number().optional(),
|
||||||
autoJoin: z.enum(["always", "allowlist", "off"]).optional(),
|
autoJoin: z.enum(["always", "allowlist", "off"]).optional(),
|
||||||
autoJoinAllowlist: z.array(allowFromEntry).optional(),
|
autoJoinAllowlist: z.array(allowFromEntry).optional(),
|
||||||
|
|||||||
@@ -16,10 +16,11 @@ export async function deliverMatrixReplies(params: {
|
|||||||
tableMode?: MarkdownTableMode;
|
tableMode?: MarkdownTableMode;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const core = getMatrixRuntime();
|
const core = getMatrixRuntime();
|
||||||
|
const cfg = core.config.loadConfig();
|
||||||
const tableMode =
|
const tableMode =
|
||||||
params.tableMode ??
|
params.tableMode ??
|
||||||
core.channel.text.resolveMarkdownTableMode({
|
core.channel.text.resolveMarkdownTableMode({
|
||||||
cfg: core.config.loadConfig(),
|
cfg,
|
||||||
channel: "matrix",
|
channel: "matrix",
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
});
|
});
|
||||||
@@ -29,6 +30,7 @@ export async function deliverMatrixReplies(params: {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const chunkLimit = Math.min(params.textLimit, 4000);
|
const chunkLimit = Math.min(params.textLimit, 4000);
|
||||||
|
const chunkMode = core.channel.text.resolveChunkMode(cfg, "matrix", params.accountId);
|
||||||
let hasReplied = false;
|
let hasReplied = false;
|
||||||
for (const reply of params.replies) {
|
for (const reply of params.replies) {
|
||||||
const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0;
|
const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0;
|
||||||
@@ -54,7 +56,11 @@ export async function deliverMatrixReplies(params: {
|
|||||||
Boolean(id) && (params.replyToMode === "all" || !hasReplied);
|
Boolean(id) && (params.replyToMode === "all" || !hasReplied);
|
||||||
|
|
||||||
if (mediaList.length === 0) {
|
if (mediaList.length === 0) {
|
||||||
for (const chunk of core.channel.text.chunkMarkdownText(text, chunkLimit)) {
|
for (const chunk of core.channel.text.chunkMarkdownTextWithMode(
|
||||||
|
text,
|
||||||
|
chunkLimit,
|
||||||
|
chunkMode,
|
||||||
|
)) {
|
||||||
const trimmed = chunk.trim();
|
const trimmed = chunk.trim();
|
||||||
if (!trimmed) continue;
|
if (!trimmed) continue;
|
||||||
await sendMessageMatrix(params.roomId, trimmed, {
|
await sendMessageMatrix(params.roomId, trimmed, {
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ const runtimeStub = {
|
|||||||
channel: {
|
channel: {
|
||||||
text: {
|
text: {
|
||||||
resolveTextChunkLimit: () => 4000,
|
resolveTextChunkLimit: () => 4000,
|
||||||
|
resolveChunkMode: () => "length",
|
||||||
chunkMarkdownText: (text: string) => (text ? [text] : []),
|
chunkMarkdownText: (text: string) => (text ? [text] : []),
|
||||||
|
chunkMarkdownTextWithMode: (text: string) => (text ? [text] : []),
|
||||||
resolveMarkdownTableMode: () => "code",
|
resolveMarkdownTableMode: () => "code",
|
||||||
convertMarkdownTables: (text: string) => text,
|
convertMarkdownTables: (text: string) => text,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -61,7 +61,12 @@ export async function sendMessageMatrix(
|
|||||||
);
|
);
|
||||||
const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix");
|
const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix");
|
||||||
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
|
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
|
||||||
const chunks = getCore().channel.text.chunkMarkdownText(convertedMessage, chunkLimit);
|
const chunkMode = getCore().channel.text.resolveChunkMode(cfg, "matrix", opts.accountId);
|
||||||
|
const chunks = getCore().channel.text.chunkMarkdownTextWithMode(
|
||||||
|
convertedMessage,
|
||||||
|
chunkLimit,
|
||||||
|
chunkMode,
|
||||||
|
);
|
||||||
const threadId = normalizeThreadId(opts.threadId);
|
const threadId = normalizeThreadId(opts.threadId);
|
||||||
const relation = threadId
|
const relation = threadId
|
||||||
? buildThreadRelation(threadId, opts.replyToId)
|
? buildThreadRelation(threadId, opts.replyToId)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { sendMessageMatrix, sendPollMatrix } from "./matrix/send.js";
|
|||||||
export const matrixOutbound: ChannelOutboundAdapter = {
|
export const matrixOutbound: ChannelOutboundAdapter = {
|
||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText(text, limit),
|
chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||||
|
chunkerMode: "markdown",
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
sendText: async ({ to, text, deps, replyToId, threadId }) => {
|
sendText: async ({ to, text, deps, replyToId, threadId }) => {
|
||||||
const send = deps?.sendMatrix ?? sendMessageMatrix;
|
const send = deps?.sendMatrix ?? sendMessageMatrix;
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ export type MatrixConfig = {
|
|||||||
threadReplies?: "off" | "inbound" | "always";
|
threadReplies?: "off" | "inbound" | "always";
|
||||||
/** Outbound text chunk size (chars). Default: 4000. */
|
/** Outbound text chunk size (chars). Default: 4000. */
|
||||||
textChunkLimit?: number;
|
textChunkLimit?: number;
|
||||||
|
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||||
|
chunkMode?: "length" | "newline";
|
||||||
/** Max outbound media size in MB. */
|
/** Max outbound media size in MB. */
|
||||||
mediaMaxMb?: number;
|
mediaMaxMb?: number;
|
||||||
/** Auto-join invites (always|allowlist|off). Default: always. */
|
/** Auto-join invites (always|allowlist|off). Default: always. */
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
|
|||||||
outbound: {
|
outbound: {
|
||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
chunker: (text, limit) => getMattermostRuntime().channel.text.chunkMarkdownText(text, limit),
|
chunker: (text, limit) => getMattermostRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||||
|
chunkerMode: "markdown",
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
resolveTarget: ({ to }) => {
|
resolveTarget: ({ to }) => {
|
||||||
const trimmed = to?.trim();
|
const trimmed = to?.trim();
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const MattermostAccountSchemaBase = z
|
|||||||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
|
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||||
blockStreaming: z.boolean().optional(),
|
blockStreaming: z.boolean().optional(),
|
||||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -738,7 +738,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||||
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
||||||
if (mediaUrls.length === 0) {
|
if (mediaUrls.length === 0) {
|
||||||
const chunks = core.channel.text.chunkMarkdownText(text, textLimit);
|
const chunkMode = core.channel.text.resolveChunkMode(
|
||||||
|
cfg,
|
||||||
|
"mattermost",
|
||||||
|
account.accountId,
|
||||||
|
);
|
||||||
|
const chunks = core.channel.text.chunkMarkdownTextWithMode(text, textLimit, chunkMode);
|
||||||
for (const chunk of chunks.length > 0 ? chunks : [text]) {
|
for (const chunk of chunks.length > 0 ? chunks : [text]) {
|
||||||
if (!chunk) continue;
|
if (!chunk) continue;
|
||||||
await sendMessageMattermost(to, chunk, {
|
await sendMessageMattermost(to, chunk, {
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export type MattermostAccountConfig = {
|
|||||||
groupPolicy?: GroupPolicy;
|
groupPolicy?: GroupPolicy;
|
||||||
/** Outbound text chunk size (chars). Default: 4000. */
|
/** Outbound text chunk size (chars). Default: 4000. */
|
||||||
textChunkLimit?: number;
|
textChunkLimit?: number;
|
||||||
|
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||||
|
chunkMode?: "length" | "newline";
|
||||||
/** Disable block streaming for this account. */
|
/** Disable block streaming for this account. */
|
||||||
blockStreaming?: boolean;
|
blockStreaming?: boolean;
|
||||||
/** Merge streamed block replies before sending. */
|
/** Merge streamed block replies before sending. */
|
||||||
|
|||||||
@@ -9,18 +9,21 @@ import {
|
|||||||
} from "./messenger.js";
|
} from "./messenger.js";
|
||||||
import { setMSTeamsRuntime } from "./runtime.js";
|
import { setMSTeamsRuntime } from "./runtime.js";
|
||||||
|
|
||||||
|
const chunkMarkdownText = (text: string, limit: number) => {
|
||||||
|
if (!text) return [];
|
||||||
|
if (limit <= 0 || text.length <= limit) return [text];
|
||||||
|
const chunks: string[] = [];
|
||||||
|
for (let index = 0; index < text.length; index += limit) {
|
||||||
|
chunks.push(text.slice(index, index + limit));
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
};
|
||||||
|
|
||||||
const runtimeStub = {
|
const runtimeStub = {
|
||||||
channel: {
|
channel: {
|
||||||
text: {
|
text: {
|
||||||
chunkMarkdownText: (text: string, limit: number) => {
|
chunkMarkdownText,
|
||||||
if (!text) return [];
|
chunkMarkdownTextWithMode: chunkMarkdownText,
|
||||||
if (limit <= 0 || text.length <= limit) return [text];
|
|
||||||
const chunks: string[] = [];
|
|
||||||
for (let index = 0; index < text.length; index += limit) {
|
|
||||||
chunks.push(text.slice(index, index + limit));
|
|
||||||
}
|
|
||||||
return chunks;
|
|
||||||
},
|
|
||||||
resolveMarkdownTableMode: () => "code",
|
resolveMarkdownTableMode: () => "code",
|
||||||
convertMarkdownTables: (text: string) => text,
|
convertMarkdownTables: (text: string) => text,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
type ChunkMode,
|
||||||
isSilentReplyText,
|
isSilentReplyText,
|
||||||
loadWebMedia,
|
loadWebMedia,
|
||||||
type MarkdownTableMode,
|
type MarkdownTableMode,
|
||||||
@@ -63,6 +64,7 @@ export type MSTeamsReplyRenderOptions = {
|
|||||||
chunkText?: boolean;
|
chunkText?: boolean;
|
||||||
mediaMode?: "split" | "inline";
|
mediaMode?: "split" | "inline";
|
||||||
tableMode?: MarkdownTableMode;
|
tableMode?: MarkdownTableMode;
|
||||||
|
chunkMode?: ChunkMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -129,11 +131,16 @@ function pushTextMessages(
|
|||||||
opts: {
|
opts: {
|
||||||
chunkText: boolean;
|
chunkText: boolean;
|
||||||
chunkLimit: number;
|
chunkLimit: number;
|
||||||
|
chunkMode: ChunkMode;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
if (opts.chunkText) {
|
if (opts.chunkText) {
|
||||||
for (const chunk of getMSTeamsRuntime().channel.text.chunkMarkdownText(text, opts.chunkLimit)) {
|
for (const chunk of getMSTeamsRuntime().channel.text.chunkMarkdownTextWithMode(
|
||||||
|
text,
|
||||||
|
opts.chunkLimit,
|
||||||
|
opts.chunkMode,
|
||||||
|
)) {
|
||||||
const trimmed = chunk.trim();
|
const trimmed = chunk.trim();
|
||||||
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue;
|
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue;
|
||||||
out.push({ text: trimmed });
|
out.push({ text: trimmed });
|
||||||
@@ -197,6 +204,7 @@ export function renderReplyPayloadsToMessages(
|
|||||||
const out: MSTeamsRenderedMessage[] = [];
|
const out: MSTeamsRenderedMessage[] = [];
|
||||||
const chunkLimit = Math.min(options.textChunkLimit, 4000);
|
const chunkLimit = Math.min(options.textChunkLimit, 4000);
|
||||||
const chunkText = options.chunkText !== false;
|
const chunkText = options.chunkText !== false;
|
||||||
|
const chunkMode = options.chunkMode ?? "length";
|
||||||
const mediaMode = options.mediaMode ?? "split";
|
const mediaMode = options.mediaMode ?? "split";
|
||||||
const tableMode =
|
const tableMode =
|
||||||
options.tableMode ??
|
options.tableMode ??
|
||||||
@@ -215,7 +223,7 @@ export function renderReplyPayloadsToMessages(
|
|||||||
if (!text && mediaList.length === 0) continue;
|
if (!text && mediaList.length === 0) continue;
|
||||||
|
|
||||||
if (mediaList.length === 0) {
|
if (mediaList.length === 0) {
|
||||||
pushTextMessages(out, text, { chunkText, chunkLimit });
|
pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,13 +237,13 @@ export function renderReplyPayloadsToMessages(
|
|||||||
if (mediaList[i]) out.push({ mediaUrl: mediaList[i] });
|
if (mediaList[i]) out.push({ mediaUrl: mediaList[i] });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
pushTextMessages(out, text, { chunkText, chunkLimit });
|
pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode });
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// mediaMode === "split"
|
// mediaMode === "split"
|
||||||
pushTextMessages(out, text, { chunkText, chunkLimit });
|
pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode });
|
||||||
for (const mediaUrl of mediaList) {
|
for (const mediaUrl of mediaList) {
|
||||||
if (!mediaUrl) continue;
|
if (!mediaUrl) continue;
|
||||||
out.push({ mediaUrl });
|
out.push({ mediaUrl });
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { sendMessageMSTeams, sendPollMSTeams } from "./send.js";
|
|||||||
export const msteamsOutbound: ChannelOutboundAdapter = {
|
export const msteamsOutbound: ChannelOutboundAdapter = {
|
||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
chunker: (text, limit) => getMSTeamsRuntime().channel.text.chunkMarkdownText(text, limit),
|
chunker: (text, limit) => getMSTeamsRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||||
|
chunkerMode: "markdown",
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
pollMaxOptions: 12,
|
pollMaxOptions: 12,
|
||||||
sendText: async ({ cfg, to, text, deps }) => {
|
sendText: async ({ cfg, to, text, deps }) => {
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export function createMSTeamsReplyDispatcher(params: {
|
|||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
agentId: params.agentId,
|
agentId: params.agentId,
|
||||||
});
|
});
|
||||||
|
const chunkMode = core.channel.text.resolveChunkMode(params.cfg, "msteams");
|
||||||
|
|
||||||
const { dispatcher, replyOptions, markDispatchIdle } =
|
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||||
core.channel.reply.createReplyDispatcherWithTyping({
|
core.channel.reply.createReplyDispatcherWithTyping({
|
||||||
@@ -75,6 +76,7 @@ export function createMSTeamsReplyDispatcher(params: {
|
|||||||
chunkText: true,
|
chunkText: true,
|
||||||
mediaMode: "split",
|
mediaMode: "split",
|
||||||
tableMode,
|
tableMode,
|
||||||
|
chunkMode,
|
||||||
});
|
});
|
||||||
const mediaMaxBytes = resolveChannelMediaMaxBytes({
|
const mediaMaxBytes = resolveChannelMediaMaxBytes({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
|
|||||||
outbound: {
|
outbound: {
|
||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
chunker: (text, limit) => getNextcloudTalkRuntime().channel.text.chunkMarkdownText(text, limit),
|
chunker: (text, limit) => getNextcloudTalkRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||||
|
chunkerMode: "markdown",
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
sendText: async ({ to, text, accountId, replyToId }) => {
|
sendText: async ({ to, text, accountId, replyToId }) => {
|
||||||
const result = await sendMessageNextcloudTalk(to, text, {
|
const result = await sendMessageNextcloudTalk(to, text, {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export const NextcloudTalkAccountSchemaBase = z
|
|||||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
|
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||||
blockStreaming: z.boolean().optional(),
|
blockStreaming: z.boolean().optional(),
|
||||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||||
mediaMaxMb: z.number().positive().optional(),
|
mediaMaxMb: z.number().positive().optional(),
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export type NextcloudTalkAccountConfig = {
|
|||||||
dms?: Record<string, DmConfig>;
|
dms?: Record<string, DmConfig>;
|
||||||
/** Outbound text chunk size (chars). Default: 4000. */
|
/** Outbound text chunk size (chars). Default: 4000. */
|
||||||
textChunkLimit?: number;
|
textChunkLimit?: number;
|
||||||
|
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||||
|
chunkMode?: "length" | "newline";
|
||||||
/** Disable block streaming for this account. */
|
/** Disable block streaming for this account. */
|
||||||
blockStreaming?: boolean;
|
blockStreaming?: boolean;
|
||||||
/** Merge streamed block replies before sending. */
|
/** Merge streamed block replies before sending. */
|
||||||
|
|||||||
@@ -18,12 +18,20 @@ import {
|
|||||||
setAccountEnabledInConfigSection,
|
setAccountEnabledInConfigSection,
|
||||||
signalOnboardingAdapter,
|
signalOnboardingAdapter,
|
||||||
SignalConfigSchema,
|
SignalConfigSchema,
|
||||||
|
type ChannelMessageActionAdapter,
|
||||||
type ChannelPlugin,
|
type ChannelPlugin,
|
||||||
type ResolvedSignalAccount,
|
type ResolvedSignalAccount,
|
||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
import { getSignalRuntime } from "./runtime.js";
|
import { getSignalRuntime } from "./runtime.js";
|
||||||
|
|
||||||
|
const signalMessageActions: ChannelMessageActionAdapter = {
|
||||||
|
listActions: (ctx) => getSignalRuntime().channel.signal.messageActions.listActions(ctx),
|
||||||
|
supportsAction: (ctx) => getSignalRuntime().channel.signal.messageActions.supportsAction?.(ctx),
|
||||||
|
handleAction: async (ctx) =>
|
||||||
|
await getSignalRuntime().channel.signal.messageActions.handleAction(ctx),
|
||||||
|
};
|
||||||
|
|
||||||
const meta = getChatChannelMeta("signal");
|
const meta = getChatChannelMeta("signal");
|
||||||
|
|
||||||
export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
||||||
@@ -42,7 +50,9 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
|||||||
capabilities: {
|
capabilities: {
|
||||||
chatTypes: ["direct", "group"],
|
chatTypes: ["direct", "group"],
|
||||||
media: true,
|
media: true,
|
||||||
|
reactions: true,
|
||||||
},
|
},
|
||||||
|
actions: signalMessageActions,
|
||||||
streaming: {
|
streaming: {
|
||||||
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
||||||
},
|
},
|
||||||
@@ -115,7 +125,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
|||||||
normalizeTarget: normalizeSignalMessagingTarget,
|
normalizeTarget: normalizeSignalMessagingTarget,
|
||||||
targetResolver: {
|
targetResolver: {
|
||||||
looksLikeId: looksLikeSignalTargetId,
|
looksLikeId: looksLikeSignalTargetId,
|
||||||
hint: "<E.164|group:ID|signal:group:ID|signal:+E.164>",
|
hint: "<E.164|uuid:ID|group:ID|signal:group:ID|signal:+E.164>",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup: {
|
setup: {
|
||||||
@@ -197,6 +207,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
|||||||
outbound: {
|
outbound: {
|
||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
chunker: (text, limit) => getSignalRuntime().channel.text.chunkText(text, limit),
|
chunker: (text, limit) => getSignalRuntime().channel.text.chunkText(text, limit),
|
||||||
|
chunkerMode: "text",
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
sendText: async ({ cfg, to, text, accountId, deps }) => {
|
sendText: async ({ cfg, to, text, accountId, deps }) => {
|
||||||
const send = deps?.sendSignal ?? getSignalRuntime().channel.signal.sendMessageSignal;
|
const send = deps?.sendSignal ?? getSignalRuntime().channel.signal.sendMessageSignal;
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
|
|||||||
outbound: {
|
outbound: {
|
||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
chunker: (text, limit) => getTelegramRuntime().channel.text.chunkMarkdownText(text, limit),
|
chunker: (text, limit) => getTelegramRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||||
|
chunkerMode: "markdown",
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => {
|
sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => {
|
||||||
const send =
|
const send =
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026.1.24
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Breaking: voice-call TTS now uses core `messages.tts` (plugin TTS config deep‑merges with core).
|
||||||
|
- Telephony TTS supports OpenAI + ElevenLabs; Edge TTS is ignored for calls.
|
||||||
|
- Removed legacy `tts.model`/`tts.voice`/`tts.instructions` plugin fields.
|
||||||
|
|
||||||
## 2026.1.23
|
## 2026.1.23
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -75,6 +75,27 @@ Notes:
|
|||||||
- Twilio/Telnyx/Plivo require a **publicly reachable** webhook URL.
|
- Twilio/Telnyx/Plivo require a **publicly reachable** webhook URL.
|
||||||
- `mock` is a local dev provider (no network calls).
|
- `mock` is a local dev provider (no network calls).
|
||||||
|
|
||||||
|
## TTS for calls
|
||||||
|
|
||||||
|
Voice Call uses the core `messages.tts` configuration (OpenAI or ElevenLabs) for
|
||||||
|
streaming speech on calls. You can override it under the plugin config with the
|
||||||
|
same shape — overrides deep-merge with `messages.tts`.
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
tts: {
|
||||||
|
provider: "openai",
|
||||||
|
openai: {
|
||||||
|
voice: "alloy"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Edge TTS is ignored for voice calls (telephony audio needs PCM; Edge output is unreliable).
|
||||||
|
- Core TTS is used when Twilio media streaming is enabled; otherwise calls fall back to provider native voices.
|
||||||
|
|
||||||
## CLI
|
## CLI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -99,16 +99,39 @@
|
|||||||
"label": "Media Stream Path",
|
"label": "Media Stream Path",
|
||||||
"advanced": true
|
"advanced": true
|
||||||
},
|
},
|
||||||
"tts.model": {
|
"tts.provider": {
|
||||||
"label": "TTS Model",
|
"label": "TTS Provider Override",
|
||||||
|
"help": "Deep-merges with messages.tts (Edge is ignored for calls).",
|
||||||
"advanced": true
|
"advanced": true
|
||||||
},
|
},
|
||||||
"tts.voice": {
|
"tts.openai.model": {
|
||||||
"label": "TTS Voice",
|
"label": "OpenAI TTS Model",
|
||||||
"advanced": true
|
"advanced": true
|
||||||
},
|
},
|
||||||
"tts.instructions": {
|
"tts.openai.voice": {
|
||||||
"label": "TTS Instructions",
|
"label": "OpenAI TTS Voice",
|
||||||
|
"advanced": true
|
||||||
|
},
|
||||||
|
"tts.openai.apiKey": {
|
||||||
|
"label": "OpenAI API Key",
|
||||||
|
"sensitive": true,
|
||||||
|
"advanced": true
|
||||||
|
},
|
||||||
|
"tts.elevenlabs.modelId": {
|
||||||
|
"label": "ElevenLabs Model ID",
|
||||||
|
"advanced": true
|
||||||
|
},
|
||||||
|
"tts.elevenlabs.voiceId": {
|
||||||
|
"label": "ElevenLabs Voice ID",
|
||||||
|
"advanced": true
|
||||||
|
},
|
||||||
|
"tts.elevenlabs.apiKey": {
|
||||||
|
"label": "ElevenLabs API Key",
|
||||||
|
"sensitive": true,
|
||||||
|
"advanced": true
|
||||||
|
},
|
||||||
|
"tts.elevenlabs.baseUrl": {
|
||||||
|
"label": "ElevenLabs Base URL",
|
||||||
"advanced": true
|
"advanced": true
|
||||||
},
|
},
|
||||||
"publicUrl": {
|
"publicUrl": {
|
||||||
@@ -370,20 +393,193 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"auto": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"off",
|
||||||
|
"always",
|
||||||
|
"inbound",
|
||||||
|
"tagged"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"mode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"final",
|
||||||
|
"all"
|
||||||
|
]
|
||||||
|
},
|
||||||
"provider": {
|
"provider": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"openai"
|
"openai",
|
||||||
|
"elevenlabs",
|
||||||
|
"edge"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"model": {
|
"summaryModel": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"voice": {
|
"modelOverrides": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"allowText": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"allowProvider": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"allowVoice": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"allowModelId": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"allowVoiceSettings": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"allowNormalization": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"allowSeed": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"elevenlabs": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"apiKey": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"baseUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"voiceId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"modelId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"seed": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 4294967295
|
||||||
|
},
|
||||||
|
"applyTextNormalization": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"auto",
|
||||||
|
"on",
|
||||||
|
"off"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"languageCode": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"voiceSettings": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"stability": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 1
|
||||||
|
},
|
||||||
|
"similarityBoost": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 1
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 1
|
||||||
|
},
|
||||||
|
"useSpeakerBoost": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"speed": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0.5,
|
||||||
|
"maximum": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"openai": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"apiKey": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"voice": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"edge": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"voice": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"lang": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"outputFormat": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"pitch": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"rate": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"volume": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"saveSubtitles": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"proxy": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"timeoutMs": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1000,
|
||||||
|
"maximum": 120000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"prefsPath": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"instructions": {
|
"maxTextLength": {
|
||||||
"type": "string"
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"timeoutMs": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1000,
|
||||||
|
"maximum": 120000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -74,9 +74,26 @@ const voiceCallConfigSchema = {
|
|||||||
},
|
},
|
||||||
"streaming.sttModel": { label: "Realtime STT Model", advanced: true },
|
"streaming.sttModel": { label: "Realtime STT Model", advanced: true },
|
||||||
"streaming.streamPath": { label: "Media Stream Path", advanced: true },
|
"streaming.streamPath": { label: "Media Stream Path", advanced: true },
|
||||||
"tts.model": { label: "TTS Model", advanced: true },
|
"tts.provider": {
|
||||||
"tts.voice": { label: "TTS Voice", advanced: true },
|
label: "TTS Provider Override",
|
||||||
"tts.instructions": { label: "TTS Instructions", advanced: true },
|
help: "Deep-merges with messages.tts (Edge is ignored for calls).",
|
||||||
|
advanced: true,
|
||||||
|
},
|
||||||
|
"tts.openai.model": { label: "OpenAI TTS Model", advanced: true },
|
||||||
|
"tts.openai.voice": { label: "OpenAI TTS Voice", advanced: true },
|
||||||
|
"tts.openai.apiKey": {
|
||||||
|
label: "OpenAI API Key",
|
||||||
|
sensitive: true,
|
||||||
|
advanced: true,
|
||||||
|
},
|
||||||
|
"tts.elevenlabs.modelId": { label: "ElevenLabs Model ID", advanced: true },
|
||||||
|
"tts.elevenlabs.voiceId": { label: "ElevenLabs Voice ID", advanced: true },
|
||||||
|
"tts.elevenlabs.apiKey": {
|
||||||
|
label: "ElevenLabs API Key",
|
||||||
|
sensitive: true,
|
||||||
|
advanced: true,
|
||||||
|
},
|
||||||
|
"tts.elevenlabs.baseUrl": { label: "ElevenLabs Base URL", advanced: true },
|
||||||
publicUrl: { label: "Public Webhook URL", advanced: true },
|
publicUrl: { label: "Public Webhook URL", advanced: true },
|
||||||
skipSignatureVerification: {
|
skipSignatureVerification: {
|
||||||
label: "Skip Signature Verification",
|
label: "Skip Signature Verification",
|
||||||
@@ -161,6 +178,7 @@ const voiceCallPlugin = {
|
|||||||
runtimePromise = createVoiceCallRuntime({
|
runtimePromise = createVoiceCallRuntime({
|
||||||
config: cfg,
|
config: cfg,
|
||||||
coreConfig: api.config as CoreConfig,
|
coreConfig: api.config as CoreConfig,
|
||||||
|
ttsRuntime: api.runtime.tts,
|
||||||
logger: api.logger,
|
logger: api.logger,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,31 +82,82 @@ export const SttConfigSchema = z
|
|||||||
.default({ provider: "openai", model: "whisper-1" });
|
.default({ provider: "openai", model: "whisper-1" });
|
||||||
export type SttConfig = z.infer<typeof SttConfigSchema>;
|
export type SttConfig = z.infer<typeof SttConfigSchema>;
|
||||||
|
|
||||||
|
export const TtsProviderSchema = z.enum(["openai", "elevenlabs", "edge"]);
|
||||||
|
export const TtsModeSchema = z.enum(["final", "all"]);
|
||||||
|
export const TtsAutoSchema = z.enum(["off", "always", "inbound", "tagged"]);
|
||||||
|
|
||||||
export const TtsConfigSchema = z
|
export const TtsConfigSchema = z
|
||||||
.object({
|
.object({
|
||||||
/** TTS provider (currently only OpenAI supported) */
|
auto: TtsAutoSchema.optional(),
|
||||||
provider: z.literal("openai").default("openai"),
|
enabled: z.boolean().optional(),
|
||||||
/**
|
mode: TtsModeSchema.optional(),
|
||||||
* TTS model to use:
|
provider: TtsProviderSchema.optional(),
|
||||||
* - gpt-4o-mini-tts: newest, supports instructions for tone/style control (recommended)
|
summaryModel: z.string().optional(),
|
||||||
* - tts-1: lower latency
|
modelOverrides: z
|
||||||
* - tts-1-hd: higher quality
|
.object({
|
||||||
*/
|
enabled: z.boolean().optional(),
|
||||||
model: z.string().min(1).default("gpt-4o-mini-tts"),
|
allowText: z.boolean().optional(),
|
||||||
/**
|
allowProvider: z.boolean().optional(),
|
||||||
* Voice ID. For best quality, use marin or cedar.
|
allowVoice: z.boolean().optional(),
|
||||||
* All voices: alloy, ash, ballad, coral, echo, fable, nova, onyx, sage, shimmer, verse, marin, cedar
|
allowModelId: z.boolean().optional(),
|
||||||
*/
|
allowVoiceSettings: z.boolean().optional(),
|
||||||
voice: z.string().min(1).default("coral"),
|
allowNormalization: z.boolean().optional(),
|
||||||
/**
|
allowSeed: z.boolean().optional(),
|
||||||
* Instructions for speech style (only works with gpt-4o-mini-tts).
|
})
|
||||||
* Examples: "Speak in a cheerful tone", "Talk like a sympathetic customer service agent"
|
.strict()
|
||||||
*/
|
.optional(),
|
||||||
instructions: z.string().optional(),
|
elevenlabs: z
|
||||||
|
.object({
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
baseUrl: z.string().optional(),
|
||||||
|
voiceId: z.string().optional(),
|
||||||
|
modelId: z.string().optional(),
|
||||||
|
seed: z.number().int().min(0).max(4294967295).optional(),
|
||||||
|
applyTextNormalization: z.enum(["auto", "on", "off"]).optional(),
|
||||||
|
languageCode: z.string().optional(),
|
||||||
|
voiceSettings: z
|
||||||
|
.object({
|
||||||
|
stability: z.number().min(0).max(1).optional(),
|
||||||
|
similarityBoost: z.number().min(0).max(1).optional(),
|
||||||
|
style: z.number().min(0).max(1).optional(),
|
||||||
|
useSpeakerBoost: z.boolean().optional(),
|
||||||
|
speed: z.number().min(0.5).max(2).optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
|
openai: z
|
||||||
|
.object({
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
model: z.string().optional(),
|
||||||
|
voice: z.string().optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
|
edge: z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
voice: z.string().optional(),
|
||||||
|
lang: z.string().optional(),
|
||||||
|
outputFormat: z.string().optional(),
|
||||||
|
pitch: z.string().optional(),
|
||||||
|
rate: z.string().optional(),
|
||||||
|
volume: z.string().optional(),
|
||||||
|
saveSubtitles: z.boolean().optional(),
|
||||||
|
proxy: z.string().optional(),
|
||||||
|
timeoutMs: z.number().int().min(1000).max(120000).optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
|
prefsPath: z.string().optional(),
|
||||||
|
maxTextLength: z.number().int().min(1).optional(),
|
||||||
|
timeoutMs: z.number().int().min(1000).max(120000).optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.default({ provider: "openai", model: "gpt-4o-mini-tts", voice: "coral" });
|
.optional();
|
||||||
export type TtsConfig = z.infer<typeof TtsConfigSchema>;
|
export type VoiceCallTtsConfig = z.infer<typeof TtsConfigSchema>;
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Webhook Server Configuration
|
// Webhook Server Configuration
|
||||||
@@ -307,7 +358,7 @@ export const VoiceCallConfigSchema = z
|
|||||||
/** STT configuration */
|
/** STT configuration */
|
||||||
stt: SttConfigSchema,
|
stt: SttConfigSchema,
|
||||||
|
|
||||||
/** TTS configuration */
|
/** TTS override (deep-merges with core messages.tts) */
|
||||||
tts: TtsConfigSchema,
|
tts: TtsConfigSchema,
|
||||||
|
|
||||||
/** Store path for call logs */
|
/** Store path for call logs */
|
||||||
|
|||||||
@@ -2,10 +2,16 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
import type { VoiceCallTtsConfig } from "./config.js";
|
||||||
|
|
||||||
export type CoreConfig = {
|
export type CoreConfig = {
|
||||||
session?: {
|
session?: {
|
||||||
store?: string;
|
store?: string;
|
||||||
};
|
};
|
||||||
|
messages?: {
|
||||||
|
tts?: VoiceCallTtsConfig;
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CoreAgentDeps = {
|
type CoreAgentDeps = {
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export class CallManager {
|
|||||||
// For notify mode with a message, use inline TwiML with <Say>
|
// For notify mode with a message, use inline TwiML with <Say>
|
||||||
let inlineTwiml: string | undefined;
|
let inlineTwiml: string | undefined;
|
||||||
if (mode === "notify" && initialMessage) {
|
if (mode === "notify" && initialMessage) {
|
||||||
const pollyVoice = mapVoiceToPolly(this.config.tts.voice);
|
const pollyVoice = mapVoiceToPolly(this.config.tts?.openai?.voice);
|
||||||
inlineTwiml = this.generateNotifyTwiml(initialMessage, pollyVoice);
|
inlineTwiml = this.generateNotifyTwiml(initialMessage, pollyVoice);
|
||||||
console.log(
|
console.log(
|
||||||
`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`,
|
`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`,
|
||||||
@@ -210,11 +210,13 @@ export class CallManager {
|
|||||||
this.addTranscriptEntry(call, "bot", text);
|
this.addTranscriptEntry(call, "bot", text);
|
||||||
|
|
||||||
// Play TTS
|
// Play TTS
|
||||||
|
const voice =
|
||||||
|
this.provider?.name === "twilio" ? this.config.tts?.openai?.voice : undefined;
|
||||||
await this.provider.playTts({
|
await this.provider.playTts({
|
||||||
callId,
|
callId,
|
||||||
providerCallId: call.providerCallId,
|
providerCallId: call.providerCallId,
|
||||||
text,
|
text,
|
||||||
voice: this.config.tts.voice,
|
voice,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export async function initiateCall(
|
|||||||
// For notify mode with a message, use inline TwiML with <Say>.
|
// For notify mode with a message, use inline TwiML with <Say>.
|
||||||
let inlineTwiml: string | undefined;
|
let inlineTwiml: string | undefined;
|
||||||
if (mode === "notify" && initialMessage) {
|
if (mode === "notify" && initialMessage) {
|
||||||
const pollyVoice = mapVoiceToPolly(ctx.config.tts.voice);
|
const pollyVoice = mapVoiceToPolly(ctx.config.tts?.openai?.voice);
|
||||||
inlineTwiml = generateNotifyTwiml(initialMessage, pollyVoice);
|
inlineTwiml = generateNotifyTwiml(initialMessage, pollyVoice);
|
||||||
console.log(`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`);
|
console.log(`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`);
|
||||||
}
|
}
|
||||||
@@ -120,11 +120,13 @@ export async function speak(
|
|||||||
|
|
||||||
addTranscriptEntry(call, "bot", text);
|
addTranscriptEntry(call, "bot", text);
|
||||||
|
|
||||||
|
const voice =
|
||||||
|
ctx.provider?.name === "twilio" ? ctx.config.tts?.openai?.voice : undefined;
|
||||||
await ctx.provider.playTts({
|
await ctx.provider.playTts({
|
||||||
callId,
|
callId,
|
||||||
providerCallId: call.providerCallId,
|
providerCallId: call.providerCallId,
|
||||||
text,
|
text,
|
||||||
voice: ctx.config.tts.voice,
|
voice,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -244,4 +246,3 @@ export async function endCall(
|
|||||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,9 +15,9 @@ import type {
|
|||||||
WebhookVerificationResult,
|
WebhookVerificationResult,
|
||||||
} from "../types.js";
|
} from "../types.js";
|
||||||
import { escapeXml, mapVoiceToPolly } from "../voice-mapping.js";
|
import { escapeXml, mapVoiceToPolly } from "../voice-mapping.js";
|
||||||
|
import { chunkAudio } from "../telephony-audio.js";
|
||||||
|
import type { TelephonyTtsProvider } from "../telephony-tts.js";
|
||||||
import type { VoiceCallProvider } from "./base.js";
|
import type { VoiceCallProvider } from "./base.js";
|
||||||
import type { OpenAITTSProvider } from "./tts-openai.js";
|
|
||||||
import { chunkAudio } from "./tts-openai.js";
|
|
||||||
import { twilioApiRequest } from "./twilio/api.js";
|
import { twilioApiRequest } from "./twilio/api.js";
|
||||||
import { verifyTwilioProviderWebhook } from "./twilio/webhook.js";
|
import { verifyTwilioProviderWebhook } from "./twilio/webhook.js";
|
||||||
|
|
||||||
@@ -53,8 +53,8 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
/** Current public webhook URL (set when tunnel starts or from config) */
|
/** Current public webhook URL (set when tunnel starts or from config) */
|
||||||
private currentPublicUrl: string | null = null;
|
private currentPublicUrl: string | null = null;
|
||||||
|
|
||||||
/** Optional OpenAI TTS provider for streaming TTS */
|
/** Optional telephony TTS provider for streaming TTS */
|
||||||
private ttsProvider: OpenAITTSProvider | null = null;
|
private ttsProvider: TelephonyTtsProvider | null = null;
|
||||||
|
|
||||||
/** Optional media stream handler for sending audio */
|
/** Optional media stream handler for sending audio */
|
||||||
private mediaStreamHandler: MediaStreamHandler | null = null;
|
private mediaStreamHandler: MediaStreamHandler | null = null;
|
||||||
@@ -119,7 +119,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
return this.currentPublicUrl;
|
return this.currentPublicUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
setTTSProvider(provider: OpenAITTSProvider): void {
|
setTTSProvider(provider: TelephonyTtsProvider): void {
|
||||||
this.ttsProvider = provider;
|
this.ttsProvider = provider;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,13 +454,13 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
* Play TTS audio via Twilio.
|
* Play TTS audio via Twilio.
|
||||||
*
|
*
|
||||||
* Two modes:
|
* Two modes:
|
||||||
* 1. OpenAI TTS + Media Streams: If TTS provider and media stream are available,
|
* 1. Core TTS + Media Streams: If TTS provider and media stream are available,
|
||||||
* generates audio via OpenAI and streams it through WebSocket (preferred).
|
* generates audio via core TTS and streams it through WebSocket (preferred).
|
||||||
* 2. TwiML <Say>: Falls back to Twilio's native TTS with Polly voices.
|
* 2. TwiML <Say>: Falls back to Twilio's native TTS with Polly voices.
|
||||||
* Note: This may not work on all Twilio accounts.
|
* Note: This may not work on all Twilio accounts.
|
||||||
*/
|
*/
|
||||||
async playTts(input: PlayTtsInput): Promise<void> {
|
async playTts(input: PlayTtsInput): Promise<void> {
|
||||||
// Try OpenAI TTS via media stream first (if configured)
|
// Try telephony TTS via media stream first (if configured)
|
||||||
const streamSid = this.callStreamMap.get(input.providerCallId);
|
const streamSid = this.callStreamMap.get(input.providerCallId);
|
||||||
if (this.ttsProvider && this.mediaStreamHandler && streamSid) {
|
if (this.ttsProvider && this.mediaStreamHandler && streamSid) {
|
||||||
try {
|
try {
|
||||||
@@ -468,7 +468,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[voice-call] OpenAI TTS failed, falling back to Twilio <Say>:`,
|
`[voice-call] Telephony TTS failed, falling back to Twilio <Say>:`,
|
||||||
err instanceof Error ? err.message : err,
|
err instanceof Error ? err.message : err,
|
||||||
);
|
);
|
||||||
// Fall through to TwiML <Say> fallback
|
// Fall through to TwiML <Say> fallback
|
||||||
@@ -484,7 +484,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.warn(
|
console.warn(
|
||||||
"[voice-call] Using TwiML <Say> fallback - OpenAI TTS not configured or media stream not active",
|
"[voice-call] Using TwiML <Say> fallback - telephony TTS not configured or media stream not active",
|
||||||
);
|
);
|
||||||
|
|
||||||
const pollyVoice = mapVoiceToPolly(input.voice);
|
const pollyVoice = mapVoiceToPolly(input.voice);
|
||||||
@@ -502,8 +502,8 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Play TTS via OpenAI and Twilio Media Streams.
|
* Play TTS via core TTS and Twilio Media Streams.
|
||||||
* Generates audio with OpenAI TTS, converts to mu-law, and streams via WebSocket.
|
* Generates audio with core TTS, converts to mu-law, and streams via WebSocket.
|
||||||
* Uses a jitter buffer to smooth out timing variations.
|
* Uses a jitter buffer to smooth out timing variations.
|
||||||
*/
|
*/
|
||||||
private async playTtsViaStream(
|
private async playTtsViaStream(
|
||||||
@@ -514,8 +514,8 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
throw new Error("TTS provider and media stream handler required");
|
throw new Error("TTS provider and media stream handler required");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate audio with OpenAI TTS (returns mu-law at 8kHz)
|
// Generate audio with core TTS (returns mu-law at 8kHz)
|
||||||
const muLawAudio = await this.ttsProvider.synthesizeForTwilio(text);
|
const muLawAudio = await this.ttsProvider.synthesizeForTelephony(text);
|
||||||
|
|
||||||
// Stream audio in 20ms chunks (160 bytes at 8kHz mu-law)
|
// Stream audio in 20ms chunks (160 bytes at 8kHz mu-law)
|
||||||
const CHUNK_SIZE = 160;
|
const CHUNK_SIZE = 160;
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import type { VoiceCallProvider } from "./providers/base.js";
|
|||||||
import { MockProvider } from "./providers/mock.js";
|
import { MockProvider } from "./providers/mock.js";
|
||||||
import { PlivoProvider } from "./providers/plivo.js";
|
import { PlivoProvider } from "./providers/plivo.js";
|
||||||
import { TelnyxProvider } from "./providers/telnyx.js";
|
import { TelnyxProvider } from "./providers/telnyx.js";
|
||||||
import { OpenAITTSProvider } from "./providers/tts-openai.js";
|
|
||||||
import { TwilioProvider } from "./providers/twilio.js";
|
import { TwilioProvider } from "./providers/twilio.js";
|
||||||
|
import type { TelephonyTtsRuntime } from "./telephony-tts.js";
|
||||||
|
import { createTelephonyTtsProvider } from "./telephony-tts.js";
|
||||||
import { startTunnel, type TunnelResult } from "./tunnel.js";
|
import { startTunnel, type TunnelResult } from "./tunnel.js";
|
||||||
import {
|
import {
|
||||||
cleanupTailscaleExposure,
|
cleanupTailscaleExposure,
|
||||||
@@ -81,9 +82,10 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
|
|||||||
export async function createVoiceCallRuntime(params: {
|
export async function createVoiceCallRuntime(params: {
|
||||||
config: VoiceCallConfig;
|
config: VoiceCallConfig;
|
||||||
coreConfig: CoreConfig;
|
coreConfig: CoreConfig;
|
||||||
|
ttsRuntime?: TelephonyTtsRuntime;
|
||||||
logger?: Logger;
|
logger?: Logger;
|
||||||
}): Promise<VoiceCallRuntime> {
|
}): Promise<VoiceCallRuntime> {
|
||||||
const { config, coreConfig, logger } = params;
|
const { config, coreConfig, ttsRuntime, logger } = params;
|
||||||
const log = logger ?? {
|
const log = logger ?? {
|
||||||
info: console.log,
|
info: console.log,
|
||||||
warn: console.warn,
|
warn: console.warn,
|
||||||
@@ -149,27 +151,24 @@ export async function createVoiceCallRuntime(params: {
|
|||||||
|
|
||||||
if (provider.name === "twilio" && config.streaming?.enabled) {
|
if (provider.name === "twilio" && config.streaming?.enabled) {
|
||||||
const twilioProvider = provider as TwilioProvider;
|
const twilioProvider = provider as TwilioProvider;
|
||||||
const openaiApiKey =
|
if (ttsRuntime?.textToSpeechTelephony) {
|
||||||
config.streaming.openaiApiKey || process.env.OPENAI_API_KEY;
|
|
||||||
if (openaiApiKey) {
|
|
||||||
try {
|
try {
|
||||||
const ttsProvider = new OpenAITTSProvider({
|
const ttsProvider = createTelephonyTtsProvider({
|
||||||
apiKey: openaiApiKey,
|
coreConfig,
|
||||||
voice: config.tts.voice,
|
ttsOverride: config.tts,
|
||||||
model: config.tts.model,
|
runtime: ttsRuntime,
|
||||||
instructions: config.tts.instructions,
|
|
||||||
});
|
});
|
||||||
twilioProvider.setTTSProvider(ttsProvider);
|
twilioProvider.setTTSProvider(ttsProvider);
|
||||||
log.info("[voice-call] OpenAI TTS provider configured");
|
log.info("[voice-call] Telephony TTS provider configured");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn(
|
log.warn(
|
||||||
`[voice-call] Failed to initialize OpenAI TTS: ${
|
`[voice-call] Failed to initialize telephony TTS: ${
|
||||||
err instanceof Error ? err.message : String(err)
|
err instanceof Error ? err.message : String(err)
|
||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.warn("[voice-call] OpenAI TTS key missing; streaming TTS disabled");
|
log.warn("[voice-call] Telephony TTS unavailable; streaming TTS disabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaHandler = webhookServer.getMediaStreamHandler();
|
const mediaHandler = webhookServer.getMediaStreamHandler();
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
const TELEPHONY_SAMPLE_RATE = 8000;
|
||||||
|
|
||||||
|
function clamp16(value: number): number {
|
||||||
|
return Math.max(-32768, Math.min(32767, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resample 16-bit PCM (little-endian mono) to 8kHz using linear interpolation.
|
||||||
|
*/
|
||||||
|
export function resamplePcmTo8k(input: Buffer, inputSampleRate: number): Buffer {
|
||||||
|
if (inputSampleRate === TELEPHONY_SAMPLE_RATE) return input;
|
||||||
|
const inputSamples = Math.floor(input.length / 2);
|
||||||
|
if (inputSamples === 0) return Buffer.alloc(0);
|
||||||
|
|
||||||
|
const ratio = inputSampleRate / TELEPHONY_SAMPLE_RATE;
|
||||||
|
const outputSamples = Math.floor(inputSamples / ratio);
|
||||||
|
const output = Buffer.alloc(outputSamples * 2);
|
||||||
|
|
||||||
|
for (let i = 0; i < outputSamples; i++) {
|
||||||
|
const srcPos = i * ratio;
|
||||||
|
const srcIndex = Math.floor(srcPos);
|
||||||
|
const frac = srcPos - srcIndex;
|
||||||
|
|
||||||
|
const s0 = input.readInt16LE(srcIndex * 2);
|
||||||
|
const s1Index = Math.min(srcIndex + 1, inputSamples - 1);
|
||||||
|
const s1 = input.readInt16LE(s1Index * 2);
|
||||||
|
|
||||||
|
const sample = Math.round(s0 + frac * (s1 - s0));
|
||||||
|
output.writeInt16LE(clamp16(sample), i * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert 16-bit PCM to 8-bit mu-law (G.711).
|
||||||
|
*/
|
||||||
|
export function pcmToMulaw(pcm: Buffer): Buffer {
|
||||||
|
const samples = Math.floor(pcm.length / 2);
|
||||||
|
const mulaw = Buffer.alloc(samples);
|
||||||
|
|
||||||
|
for (let i = 0; i < samples; i++) {
|
||||||
|
const sample = pcm.readInt16LE(i * 2);
|
||||||
|
mulaw[i] = linearToMulaw(sample);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mulaw;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertPcmToMulaw8k(
|
||||||
|
pcm: Buffer,
|
||||||
|
inputSampleRate: number,
|
||||||
|
): Buffer {
|
||||||
|
const pcm8k = resamplePcmTo8k(pcm, inputSampleRate);
|
||||||
|
return pcmToMulaw(pcm8k);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chunk audio buffer into 20ms frames for streaming (8kHz mono mu-law).
|
||||||
|
*/
|
||||||
|
export function chunkAudio(
|
||||||
|
audio: Buffer,
|
||||||
|
chunkSize = 160,
|
||||||
|
): Generator<Buffer, void, unknown> {
|
||||||
|
return (function* () {
|
||||||
|
for (let i = 0; i < audio.length; i += chunkSize) {
|
||||||
|
yield audio.subarray(i, Math.min(i + chunkSize, audio.length));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
function linearToMulaw(sample: number): number {
|
||||||
|
const BIAS = 132;
|
||||||
|
const CLIP = 32635;
|
||||||
|
|
||||||
|
const sign = sample < 0 ? 0x80 : 0;
|
||||||
|
if (sample < 0) sample = -sample;
|
||||||
|
if (sample > CLIP) sample = CLIP;
|
||||||
|
|
||||||
|
sample += BIAS;
|
||||||
|
let exponent = 7;
|
||||||
|
for (let expMask = 0x4000; (sample & expMask) === 0 && exponent > 0; exponent--) {
|
||||||
|
expMask >>= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mantissa = (sample >> (exponent + 3)) & 0x0f;
|
||||||
|
return ~(sign | (exponent << 4) | mantissa) & 0xff;
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import type { CoreConfig } from "./core-bridge.js";
|
||||||
|
import type { VoiceCallTtsConfig } from "./config.js";
|
||||||
|
import { convertPcmToMulaw8k } from "./telephony-audio.js";
|
||||||
|
|
||||||
|
export type TelephonyTtsRuntime = {
|
||||||
|
textToSpeechTelephony: (params: {
|
||||||
|
text: string;
|
||||||
|
cfg: CoreConfig;
|
||||||
|
prefsPath?: string;
|
||||||
|
}) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
audioBuffer?: Buffer;
|
||||||
|
sampleRate?: number;
|
||||||
|
provider?: string;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TelephonyTtsProvider = {
|
||||||
|
synthesizeForTelephony: (text: string) => Promise<Buffer>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createTelephonyTtsProvider(params: {
|
||||||
|
coreConfig: CoreConfig;
|
||||||
|
ttsOverride?: VoiceCallTtsConfig;
|
||||||
|
runtime: TelephonyTtsRuntime;
|
||||||
|
}): TelephonyTtsProvider {
|
||||||
|
const { coreConfig, ttsOverride, runtime } = params;
|
||||||
|
const mergedConfig = applyTtsOverride(coreConfig, ttsOverride);
|
||||||
|
|
||||||
|
return {
|
||||||
|
synthesizeForTelephony: async (text: string) => {
|
||||||
|
const result = await runtime.textToSpeechTelephony({
|
||||||
|
text,
|
||||||
|
cfg: mergedConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success || !result.audioBuffer || !result.sampleRate) {
|
||||||
|
throw new Error(result.error ?? "TTS conversion failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return convertPcmToMulaw8k(result.audioBuffer, result.sampleRate);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTtsOverride(
|
||||||
|
coreConfig: CoreConfig,
|
||||||
|
override?: VoiceCallTtsConfig,
|
||||||
|
): CoreConfig {
|
||||||
|
if (!override) return coreConfig;
|
||||||
|
|
||||||
|
const base = coreConfig.messages?.tts;
|
||||||
|
const merged = mergeTtsConfig(base, override);
|
||||||
|
if (!merged) return coreConfig;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...coreConfig,
|
||||||
|
messages: {
|
||||||
|
...(coreConfig.messages ?? {}),
|
||||||
|
tts: merged,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeTtsConfig(
|
||||||
|
base?: VoiceCallTtsConfig,
|
||||||
|
override?: VoiceCallTtsConfig,
|
||||||
|
): VoiceCallTtsConfig | undefined {
|
||||||
|
if (!base && !override) return undefined;
|
||||||
|
if (!override) return base;
|
||||||
|
if (!base) return override;
|
||||||
|
return deepMerge(base, override);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deepMerge<T>(base: T, override: T): T {
|
||||||
|
if (!isPlainObject(base) || !isPlainObject(override)) {
|
||||||
|
return override;
|
||||||
|
}
|
||||||
|
const result: Record<string, unknown> = { ...base };
|
||||||
|
for (const [key, value] of Object.entries(override)) {
|
||||||
|
if (value === undefined) continue;
|
||||||
|
const existing = (base as Record<string, unknown>)[key];
|
||||||
|
if (isPlainObject(existing) && isPlainObject(value)) {
|
||||||
|
result[key] = deepMerge(existing, value);
|
||||||
|
} else {
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -276,6 +276,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
|||||||
outbound: {
|
outbound: {
|
||||||
deliveryMode: "gateway",
|
deliveryMode: "gateway",
|
||||||
chunker: (text, limit) => getWhatsAppRuntime().channel.text.chunkText(text, limit),
|
chunker: (text, limit) => getWhatsAppRuntime().channel.text.chunkText(text, limit),
|
||||||
|
chunkerMode: "text",
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
pollMaxOptions: 12,
|
pollMaxOptions: 12,
|
||||||
resolveTarget: ({ to, allowFrom, mode }) => {
|
resolveTarget: ({ to, allowFrom, mode }) => {
|
||||||
|
|||||||
@@ -288,6 +288,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|||||||
if (remaining.length) chunks.push(remaining);
|
if (remaining.length) chunks.push(remaining);
|
||||||
return chunks;
|
return chunks;
|
||||||
},
|
},
|
||||||
|
chunkerMode: "text",
|
||||||
textChunkLimit: 2000,
|
textChunkLimit: 2000,
|
||||||
sendText: async ({ to, text, accountId, cfg }) => {
|
sendText: async ({ to, text, accountId, cfg }) => {
|
||||||
const result = await sendMessageZalo(to, text, {
|
const result = await sendMessageZalo(to, text, {
|
||||||
|
|||||||
@@ -596,6 +596,8 @@ async function processMessageWithPipeline(params: {
|
|||||||
chatId,
|
chatId,
|
||||||
runtime,
|
runtime,
|
||||||
core,
|
core,
|
||||||
|
config,
|
||||||
|
accountId: account.accountId,
|
||||||
statusSink,
|
statusSink,
|
||||||
fetcher,
|
fetcher,
|
||||||
tableMode,
|
tableMode,
|
||||||
@@ -614,11 +616,13 @@ async function deliverZaloReply(params: {
|
|||||||
chatId: string;
|
chatId: string;
|
||||||
runtime: ZaloRuntimeEnv;
|
runtime: ZaloRuntimeEnv;
|
||||||
core: ZaloCoreRuntime;
|
core: ZaloCoreRuntime;
|
||||||
|
config: ClawdbotConfig;
|
||||||
|
accountId?: string;
|
||||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||||
fetcher?: ZaloFetch;
|
fetcher?: ZaloFetch;
|
||||||
tableMode?: MarkdownTableMode;
|
tableMode?: MarkdownTableMode;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { payload, token, chatId, runtime, core, statusSink, fetcher } = params;
|
const { payload, token, chatId, runtime, core, config, accountId, statusSink, fetcher } = params;
|
||||||
const tableMode = params.tableMode ?? "code";
|
const tableMode = params.tableMode ?? "code";
|
||||||
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
||||||
|
|
||||||
@@ -644,7 +648,12 @@ async function deliverZaloReply(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (text) {
|
if (text) {
|
||||||
const chunks = core.channel.text.chunkMarkdownText(text, ZALO_TEXT_LIMIT);
|
const chunkMode = core.channel.text.resolveChunkMode(config, "zalo", accountId);
|
||||||
|
const chunks = core.channel.text.chunkMarkdownTextWithMode(
|
||||||
|
text,
|
||||||
|
ZALO_TEXT_LIMIT,
|
||||||
|
chunkMode,
|
||||||
|
);
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
try {
|
try {
|
||||||
await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher);
|
await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher);
|
||||||
|
|||||||
@@ -506,6 +506,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|||||||
if (remaining.length) chunks.push(remaining);
|
if (remaining.length) chunks.push(remaining);
|
||||||
return chunks;
|
return chunks;
|
||||||
},
|
},
|
||||||
|
chunkerMode: "text",
|
||||||
textChunkLimit: 2000,
|
textChunkLimit: 2000,
|
||||||
sendText: async ({ to, text, accountId, cfg }) => {
|
sendText: async ({ to, text, accountId, cfg }) => {
|
||||||
const account = resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId });
|
const account = resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId });
|
||||||
|
|||||||
@@ -332,6 +332,8 @@ async function processMessage(
|
|||||||
isGroup,
|
isGroup,
|
||||||
runtime,
|
runtime,
|
||||||
core,
|
core,
|
||||||
|
config,
|
||||||
|
accountId: account.accountId,
|
||||||
statusSink,
|
statusSink,
|
||||||
tableMode: core.channel.text.resolveMarkdownTableMode({
|
tableMode: core.channel.text.resolveMarkdownTableMode({
|
||||||
cfg: config,
|
cfg: config,
|
||||||
@@ -356,10 +358,13 @@ async function deliverZalouserReply(params: {
|
|||||||
isGroup: boolean;
|
isGroup: boolean;
|
||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
core: ZalouserCoreRuntime;
|
core: ZalouserCoreRuntime;
|
||||||
|
config: ClawdbotConfig;
|
||||||
|
accountId?: string;
|
||||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||||
tableMode?: MarkdownTableMode;
|
tableMode?: MarkdownTableMode;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { payload, profile, chatId, isGroup, runtime, core, statusSink } = params;
|
const { payload, profile, chatId, isGroup, runtime, core, config, accountId, statusSink } =
|
||||||
|
params;
|
||||||
const tableMode = params.tableMode ?? "code";
|
const tableMode = params.tableMode ?? "code";
|
||||||
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
||||||
|
|
||||||
@@ -390,7 +395,12 @@ async function deliverZalouserReply(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (text) {
|
if (text) {
|
||||||
const chunks = core.channel.text.chunkMarkdownText(text, ZALOUSER_TEXT_LIMIT);
|
const chunkMode = core.channel.text.resolveChunkMode(config, "zalouser", accountId);
|
||||||
|
const chunks = core.channel.text.chunkMarkdownTextWithMode(
|
||||||
|
text,
|
||||||
|
ZALOUSER_TEXT_LIMIT,
|
||||||
|
chunkMode,
|
||||||
|
);
|
||||||
logVerbose(core, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`);
|
logVerbose(core, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`);
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
"dist/signal/**",
|
"dist/signal/**",
|
||||||
"dist/slack/**",
|
"dist/slack/**",
|
||||||
"dist/telegram/**",
|
"dist/telegram/**",
|
||||||
|
"dist/line/**",
|
||||||
"dist/tui/**",
|
"dist/tui/**",
|
||||||
"dist/tts/**",
|
"dist/tts/**",
|
||||||
"dist/web/**",
|
"dist/web/**",
|
||||||
@@ -154,6 +155,7 @@
|
|||||||
"@grammyjs/runner": "^2.0.3",
|
"@grammyjs/runner": "^2.0.3",
|
||||||
"@grammyjs/transformer-throttler": "^1.2.1",
|
"@grammyjs/transformer-throttler": "^1.2.1",
|
||||||
"@homebridge/ciao": "^1.3.4",
|
"@homebridge/ciao": "^1.3.4",
|
||||||
|
"@line/bot-sdk": "^10.6.0",
|
||||||
"@lydell/node-pty": "1.2.0-beta.3",
|
"@lydell/node-pty": "1.2.0-beta.3",
|
||||||
"@mariozechner/pi-agent-core": "0.49.3",
|
"@mariozechner/pi-agent-core": "0.49.3",
|
||||||
"@mariozechner/pi-ai": "0.49.3",
|
"@mariozechner/pi-ai": "0.49.3",
|
||||||
|
|||||||
Generated
+28
@@ -34,6 +34,9 @@ importers:
|
|||||||
'@homebridge/ciao':
|
'@homebridge/ciao':
|
||||||
specifier: ^1.3.4
|
specifier: ^1.3.4
|
||||||
version: 1.3.4
|
version: 1.3.4
|
||||||
|
'@line/bot-sdk':
|
||||||
|
specifier: ^10.6.0
|
||||||
|
version: 10.6.0
|
||||||
'@lydell/node-pty':
|
'@lydell/node-pty':
|
||||||
specifier: 1.2.0-beta.3
|
specifier: 1.2.0-beta.3
|
||||||
version: 1.2.0-beta.3
|
version: 1.2.0-beta.3
|
||||||
@@ -317,6 +320,12 @@ importers:
|
|||||||
|
|
||||||
extensions/imessage: {}
|
extensions/imessage: {}
|
||||||
|
|
||||||
|
extensions/line:
|
||||||
|
devDependencies:
|
||||||
|
clawdbot:
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../..
|
||||||
|
|
||||||
extensions/llm-task: {}
|
extensions/llm-task: {}
|
||||||
|
|
||||||
extensions/lobster: {}
|
extensions/lobster: {}
|
||||||
@@ -1260,6 +1269,10 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
apache-arrow: '>=15.0.0 <=18.1.0'
|
apache-arrow: '>=15.0.0 <=18.1.0'
|
||||||
|
|
||||||
|
'@line/bot-sdk@10.6.0':
|
||||||
|
resolution: {integrity: sha512-4hSpglL/G/cW2JCcohaYz/BS0uOSJNV9IEYdMm0EiPEvDLayoI2hGq2D86uYPQFD2gvgkyhmzdShpWLG3P5r3w==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
'@lit-labs/signals@0.2.0':
|
'@lit-labs/signals@0.2.0':
|
||||||
resolution: {integrity: sha512-68plyIbciumbwKaiilhLNyhz4Vg6/+nJwDufG2xxWA9r/fUw58jxLHCAlKs+q1CE5Lmh3cZ3ShyYKnOCebEpVA==}
|
resolution: {integrity: sha512-68plyIbciumbwKaiilhLNyhz4Vg6/+nJwDufG2xxWA9r/fUw58jxLHCAlKs+q1CE5Lmh3cZ3ShyYKnOCebEpVA==}
|
||||||
|
|
||||||
@@ -2647,6 +2660,9 @@ packages:
|
|||||||
'@types/node@20.19.30':
|
'@types/node@20.19.30':
|
||||||
resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==}
|
resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==}
|
||||||
|
|
||||||
|
'@types/node@24.10.9':
|
||||||
|
resolution: {integrity: sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==}
|
||||||
|
|
||||||
'@types/node@25.0.10':
|
'@types/node@25.0.10':
|
||||||
resolution: {integrity: sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==}
|
resolution: {integrity: sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==}
|
||||||
|
|
||||||
@@ -6721,6 +6737,14 @@ snapshots:
|
|||||||
'@lancedb/lancedb-win32-arm64-msvc': 0.23.0
|
'@lancedb/lancedb-win32-arm64-msvc': 0.23.0
|
||||||
'@lancedb/lancedb-win32-x64-msvc': 0.23.0
|
'@lancedb/lancedb-win32-x64-msvc': 0.23.0
|
||||||
|
|
||||||
|
'@line/bot-sdk@10.6.0':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 24.10.9
|
||||||
|
optionalDependencies:
|
||||||
|
axios: 1.13.2(debug@4.4.3)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- debug
|
||||||
|
|
||||||
'@lit-labs/signals@0.2.0':
|
'@lit-labs/signals@0.2.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
lit: 3.3.2
|
lit: 3.3.2
|
||||||
@@ -8298,6 +8322,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
|
|
||||||
|
'@types/node@24.10.9':
|
||||||
|
dependencies:
|
||||||
|
undici-types: 7.16.0
|
||||||
|
|
||||||
'@types/node@25.0.10':
|
'@types/node@25.0.10':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.16.0
|
undici-types: 7.16.0
|
||||||
|
|||||||
@@ -23,11 +23,14 @@ const serialRuns = runs.filter((entry) => entry.name === "gateway");
|
|||||||
|
|
||||||
const children = new Set();
|
const children = new Set();
|
||||||
const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
|
const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
|
||||||
|
const isMacOS = process.platform === "darwin" || process.env.RUNNER_OS === "macOS";
|
||||||
const overrideWorkers = Number.parseInt(process.env.CLAWDBOT_TEST_WORKERS ?? "", 10);
|
const overrideWorkers = Number.parseInt(process.env.CLAWDBOT_TEST_WORKERS ?? "", 10);
|
||||||
const resolvedOverride = Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null;
|
const resolvedOverride = Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null;
|
||||||
const localWorkers = Math.max(4, Math.min(16, os.cpus().length));
|
const localWorkers = Math.max(4, Math.min(16, os.cpus().length));
|
||||||
const perRunWorkers = Math.max(1, Math.floor(localWorkers / parallelRuns.length));
|
const perRunWorkers = Math.max(1, Math.floor(localWorkers / parallelRuns.length));
|
||||||
const maxWorkers = isCI ? null : resolvedOverride ?? perRunWorkers;
|
// Keep worker counts predictable for local runs and for CI on macOS.
|
||||||
|
// In CI on linux/windows, prefer Vitest defaults to avoid cross-test interference from lower worker counts.
|
||||||
|
const maxWorkers = resolvedOverride ?? (isCI && !isMacOS ? null : perRunWorkers);
|
||||||
|
|
||||||
const WARNING_SUPPRESSION_FLAGS = [
|
const WARNING_SUPPRESSION_FLAGS = [
|
||||||
"--disable-warning=ExperimentalWarning",
|
"--disable-warning=ExperimentalWarning",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
} from "../infra/shell-env.js";
|
} from "../infra/shell-env.js";
|
||||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||||
import { logInfo, logWarn } from "../logger.js";
|
import { logInfo, logWarn } from "../logger.js";
|
||||||
|
import { formatSpawnError, spawnWithFallback } from "../process/spawn-utils.js";
|
||||||
import {
|
import {
|
||||||
type ProcessSession,
|
type ProcessSession,
|
||||||
type SessionStdin,
|
type SessionStdin,
|
||||||
@@ -362,23 +363,38 @@ async function runExecProcess(opts: {
|
|||||||
let stdin: SessionStdin | undefined;
|
let stdin: SessionStdin | undefined;
|
||||||
|
|
||||||
if (opts.sandbox) {
|
if (opts.sandbox) {
|
||||||
child = spawn(
|
const { child: spawned } = await spawnWithFallback({
|
||||||
"docker",
|
argv: [
|
||||||
buildDockerExecArgs({
|
"docker",
|
||||||
containerName: opts.sandbox.containerName,
|
...buildDockerExecArgs({
|
||||||
command: opts.command,
|
containerName: opts.sandbox.containerName,
|
||||||
workdir: opts.containerWorkdir ?? opts.sandbox.containerWorkdir,
|
command: opts.command,
|
||||||
env: opts.env,
|
workdir: opts.containerWorkdir ?? opts.sandbox.containerWorkdir,
|
||||||
tty: opts.usePty,
|
env: opts.env,
|
||||||
}),
|
tty: opts.usePty,
|
||||||
{
|
}),
|
||||||
|
],
|
||||||
|
options: {
|
||||||
cwd: opts.workdir,
|
cwd: opts.workdir,
|
||||||
env: process.env,
|
env: process.env,
|
||||||
detached: process.platform !== "win32",
|
detached: process.platform !== "win32",
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
},
|
},
|
||||||
) as ChildProcessWithoutNullStreams;
|
fallbacks: [
|
||||||
|
{
|
||||||
|
label: "no-detach",
|
||||||
|
options: { detached: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
onFallback: (err, fallback) => {
|
||||||
|
const errText = formatSpawnError(err);
|
||||||
|
const warning = `Warning: spawn failed (${errText}); retrying with ${fallback.label}.`;
|
||||||
|
logWarn(`exec: spawn failed (${errText}); retrying with ${fallback.label}.`);
|
||||||
|
opts.warnings.push(warning);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
child = spawned as ChildProcessWithoutNullStreams;
|
||||||
stdin = child.stdin;
|
stdin = child.stdin;
|
||||||
} else if (opts.usePty) {
|
} else if (opts.usePty) {
|
||||||
const { shell, args: shellArgs } = getShellConfig();
|
const { shell, args: shellArgs } = getShellConfig();
|
||||||
@@ -422,24 +438,56 @@ async function runExecProcess(opts: {
|
|||||||
const warning = `Warning: PTY spawn failed (${errText}); retrying without PTY for \`${opts.command}\`.`;
|
const warning = `Warning: PTY spawn failed (${errText}); retrying without PTY for \`${opts.command}\`.`;
|
||||||
logWarn(`exec: PTY spawn failed (${errText}); retrying without PTY for "${opts.command}".`);
|
logWarn(`exec: PTY spawn failed (${errText}); retrying without PTY for "${opts.command}".`);
|
||||||
opts.warnings.push(warning);
|
opts.warnings.push(warning);
|
||||||
child = spawn(shell, [...shellArgs, opts.command], {
|
const { child: spawned } = await spawnWithFallback({
|
||||||
|
argv: [shell, ...shellArgs, opts.command],
|
||||||
|
options: {
|
||||||
|
cwd: opts.workdir,
|
||||||
|
env: opts.env,
|
||||||
|
detached: process.platform !== "win32",
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
windowsHide: true,
|
||||||
|
},
|
||||||
|
fallbacks: [
|
||||||
|
{
|
||||||
|
label: "no-detach",
|
||||||
|
options: { detached: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
onFallback: (fallbackErr, fallback) => {
|
||||||
|
const fallbackText = formatSpawnError(fallbackErr);
|
||||||
|
const fallbackWarning = `Warning: spawn failed (${fallbackText}); retrying with ${fallback.label}.`;
|
||||||
|
logWarn(`exec: spawn failed (${fallbackText}); retrying with ${fallback.label}.`);
|
||||||
|
opts.warnings.push(fallbackWarning);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
child = spawned as ChildProcessWithoutNullStreams;
|
||||||
|
stdin = child.stdin;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const { shell, args: shellArgs } = getShellConfig();
|
||||||
|
const { child: spawned } = await spawnWithFallback({
|
||||||
|
argv: [shell, ...shellArgs, opts.command],
|
||||||
|
options: {
|
||||||
cwd: opts.workdir,
|
cwd: opts.workdir,
|
||||||
env: opts.env,
|
env: opts.env,
|
||||||
detached: process.platform !== "win32",
|
detached: process.platform !== "win32",
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
}) as ChildProcessWithoutNullStreams;
|
},
|
||||||
stdin = child.stdin;
|
fallbacks: [
|
||||||
}
|
{
|
||||||
} else {
|
label: "no-detach",
|
||||||
const { shell, args: shellArgs } = getShellConfig();
|
options: { detached: false },
|
||||||
child = spawn(shell, [...shellArgs, opts.command], {
|
},
|
||||||
cwd: opts.workdir,
|
],
|
||||||
env: opts.env,
|
onFallback: (err, fallback) => {
|
||||||
detached: process.platform !== "win32",
|
const errText = formatSpawnError(err);
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
const warning = `Warning: spawn failed (${errText}); retrying with ${fallback.label}.`;
|
||||||
windowsHide: true,
|
logWarn(`exec: spawn failed (${errText}); retrying with ${fallback.label}.`);
|
||||||
}) as ChildProcessWithoutNullStreams;
|
opts.warnings.push(warning);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
child = spawned as ChildProcessWithoutNullStreams;
|
||||||
stdin = child.stdin;
|
stdin = child.stdin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type ModelCatalogEntry = {
|
|||||||
provider: string;
|
provider: string;
|
||||||
contextWindow?: number;
|
contextWindow?: number;
|
||||||
reasoning?: boolean;
|
reasoning?: boolean;
|
||||||
|
input?: Array<"text" | "image">;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DiscoveredModel = {
|
type DiscoveredModel = {
|
||||||
@@ -16,6 +17,7 @@ type DiscoveredModel = {
|
|||||||
provider: string;
|
provider: string;
|
||||||
contextWindow?: number;
|
contextWindow?: number;
|
||||||
reasoning?: boolean;
|
reasoning?: boolean;
|
||||||
|
input?: Array<"text" | "image">;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PiSdkModule = typeof import("@mariozechner/pi-coding-agent");
|
type PiSdkModule = typeof import("@mariozechner/pi-coding-agent");
|
||||||
@@ -80,7 +82,10 @@ export async function loadModelCatalog(params?: {
|
|||||||
? entry.contextWindow
|
? entry.contextWindow
|
||||||
: undefined;
|
: undefined;
|
||||||
const reasoning = typeof entry?.reasoning === "boolean" ? entry.reasoning : undefined;
|
const reasoning = typeof entry?.reasoning === "boolean" ? entry.reasoning : undefined;
|
||||||
models.push({ id, name, provider, contextWindow, reasoning });
|
const input = Array.isArray(entry?.input)
|
||||||
|
? (entry.input as Array<"text" | "image">)
|
||||||
|
: undefined;
|
||||||
|
models.push({ id, name, provider, contextWindow, reasoning, input });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (models.length === 0) {
|
if (models.length === 0) {
|
||||||
@@ -105,3 +110,27 @@ export async function loadModelCatalog(params?: {
|
|||||||
|
|
||||||
return modelCatalogPromise;
|
return modelCatalogPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a model supports image input based on its catalog entry.
|
||||||
|
*/
|
||||||
|
export function modelSupportsVision(entry: ModelCatalogEntry | undefined): boolean {
|
||||||
|
return entry?.input?.includes("image") ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a model in the catalog by provider and model ID.
|
||||||
|
*/
|
||||||
|
export function findModelInCatalog(
|
||||||
|
catalog: ModelCatalogEntry[],
|
||||||
|
provider: string,
|
||||||
|
modelId: string,
|
||||||
|
): ModelCatalogEntry | undefined {
|
||||||
|
const normalizedProvider = provider.toLowerCase().trim();
|
||||||
|
const normalizedModelId = modelId.toLowerCase().trim();
|
||||||
|
return catalog.find(
|
||||||
|
(entry) =>
|
||||||
|
entry.provider.toLowerCase() === normalizedProvider &&
|
||||||
|
entry.id.toLowerCase() === normalizedModelId,
|
||||||
|
);
|
||||||
|
}
|
||||||
+20
@@ -121,6 +121,26 @@ describe("getDmHistoryLimitFromSessionKey", () => {
|
|||||||
} as ClawdbotConfig;
|
} as ClawdbotConfig;
|
||||||
expect(getDmHistoryLimitFromSessionKey("agent:main:telegram:dm:123", config)).toBe(10);
|
expect(getDmHistoryLimitFromSessionKey("agent:main:telegram:dm:123", config)).toBe(10);
|
||||||
});
|
});
|
||||||
|
it("strips thread suffix from dm session keys", () => {
|
||||||
|
const config = {
|
||||||
|
channels: { telegram: { dmHistoryLimit: 10, dms: { "123": { historyLimit: 7 } } } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
expect(getDmHistoryLimitFromSessionKey("agent:main:telegram:dm:123:thread:999", config)).toBe(
|
||||||
|
7,
|
||||||
|
);
|
||||||
|
expect(getDmHistoryLimitFromSessionKey("agent:main:telegram:dm:123:topic:555", config)).toBe(7);
|
||||||
|
expect(getDmHistoryLimitFromSessionKey("telegram:dm:123:thread:999", config)).toBe(7);
|
||||||
|
});
|
||||||
|
it("keeps non-numeric thread markers in dm ids", () => {
|
||||||
|
const config = {
|
||||||
|
channels: {
|
||||||
|
telegram: { dms: { "user:thread:abc": { historyLimit: 9 } } },
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
expect(getDmHistoryLimitFromSessionKey("agent:main:telegram:dm:user:thread:abc", config)).toBe(
|
||||||
|
9,
|
||||||
|
);
|
||||||
|
});
|
||||||
it("returns undefined for non-dm session kinds", () => {
|
it("returns undefined for non-dm session kinds", () => {
|
||||||
const config = {
|
const config = {
|
||||||
channels: {
|
channels: {
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ vi.mock("@mariozechner/pi-ai", async () => {
|
|||||||
},
|
},
|
||||||
streamSimple: (model: { api: string; provider: string; id: string }) => {
|
streamSimple: (model: { api: string; provider: string; id: string }) => {
|
||||||
const stream = new actual.AssistantMessageEventStream();
|
const stream = new actual.AssistantMessageEventStream();
|
||||||
setTimeout(() => {
|
queueMicrotask(() => {
|
||||||
stream.push({
|
stream.push({
|
||||||
type: "done",
|
type: "done",
|
||||||
reason: "stop",
|
reason: "stop",
|
||||||
@@ -80,7 +80,7 @@ vi.mock("@mariozechner/pi-ai", async () => {
|
|||||||
: buildAssistantMessage(model),
|
: buildAssistantMessage(model),
|
||||||
});
|
});
|
||||||
stream.end();
|
stream.end();
|
||||||
}, 0);
|
});
|
||||||
return stream;
|
return stream;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -213,7 +213,7 @@ describe("runEmbeddedPiAgent", () => {
|
|||||||
|
|
||||||
itIfNotWin32(
|
itIfNotWin32(
|
||||||
"persists the first user message before assistant output",
|
"persists the first user message before assistant output",
|
||||||
{ timeout: 60_000 },
|
{ timeout: 120_000 },
|
||||||
async () => {
|
async () => {
|
||||||
const sessionFile = nextSessionFile();
|
const sessionFile = nextSessionFile();
|
||||||
const cfg = makeOpenAiConfig(["mock-1"]);
|
const cfg = makeOpenAiConfig(["mock-1"]);
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { resolveChannelCapabilities } from "../../config/channel-capabilities.js
|
|||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { getMachineDisplayName } from "../../infra/machine-name.js";
|
import { getMachineDisplayName } from "../../infra/machine-name.js";
|
||||||
import { resolveTelegramInlineButtonsScope } from "../../telegram/inline-buttons.js";
|
import { resolveTelegramInlineButtonsScope } from "../../telegram/inline-buttons.js";
|
||||||
|
import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js";
|
||||||
|
import { resolveSignalReactionLevel } from "../../signal/reaction-level.js";
|
||||||
import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js";
|
import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js";
|
||||||
import { normalizeMessageChannel } from "../../utils/message-channel.js";
|
import { normalizeMessageChannel } from "../../utils/message-channel.js";
|
||||||
import { isSubagentSessionKey } from "../../routing/session-key.js";
|
import { isSubagentSessionKey } from "../../routing/session-key.js";
|
||||||
@@ -255,6 +257,28 @@ export async function compactEmbeddedPiSessionDirect(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const reactionGuidance =
|
||||||
|
runtimeChannel && params.config
|
||||||
|
? (() => {
|
||||||
|
if (runtimeChannel === "telegram") {
|
||||||
|
const resolved = resolveTelegramReactionLevel({
|
||||||
|
cfg: params.config,
|
||||||
|
accountId: params.agentAccountId ?? undefined,
|
||||||
|
});
|
||||||
|
const level = resolved.agentReactionGuidance;
|
||||||
|
return level ? { level, channel: "Telegram" } : undefined;
|
||||||
|
}
|
||||||
|
if (runtimeChannel === "signal") {
|
||||||
|
const resolved = resolveSignalReactionLevel({
|
||||||
|
cfg: params.config,
|
||||||
|
accountId: params.agentAccountId ?? undefined,
|
||||||
|
});
|
||||||
|
const level = resolved.agentReactionGuidance;
|
||||||
|
return level ? { level, channel: "Signal" } : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
})()
|
||||||
|
: undefined;
|
||||||
// Resolve channel-specific message actions for system prompt
|
// Resolve channel-specific message actions for system prompt
|
||||||
const channelActions = runtimeChannel
|
const channelActions = runtimeChannel
|
||||||
? listChannelSupportedActions({
|
? listChannelSupportedActions({
|
||||||
@@ -313,6 +337,7 @@ export async function compactEmbeddedPiSessionDirect(
|
|||||||
ttsHint,
|
ttsHint,
|
||||||
promptMode,
|
promptMode,
|
||||||
runtimeInfo,
|
runtimeInfo,
|
||||||
|
reactionGuidance,
|
||||||
messageToolHints,
|
messageToolHints,
|
||||||
sandboxInfo,
|
sandboxInfo,
|
||||||
tools,
|
tools,
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
|||||||
|
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
|
||||||
|
const THREAD_SUFFIX_REGEX = /^(.*)(?::(?:thread|topic):\d+)$/i;
|
||||||
|
|
||||||
|
function stripThreadSuffix(value: string): string {
|
||||||
|
const match = value.match(THREAD_SUFFIX_REGEX);
|
||||||
|
return match?.[1] ?? value;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Limits conversation history to the last N user turns (and their associated
|
* Limits conversation history to the last N user turns (and their associated
|
||||||
* assistant responses). This reduces token usage for long-running DM sessions.
|
* assistant responses). This reduces token usage for long-running DM sessions.
|
||||||
@@ -44,7 +51,8 @@ export function getDmHistoryLimitFromSessionKey(
|
|||||||
if (!provider) return undefined;
|
if (!provider) return undefined;
|
||||||
|
|
||||||
const kind = providerParts[1]?.toLowerCase();
|
const kind = providerParts[1]?.toLowerCase();
|
||||||
const userId = providerParts.slice(2).join(":");
|
const userIdRaw = providerParts.slice(2).join(":");
|
||||||
|
const userId = stripThreadSuffix(userIdRaw);
|
||||||
if (kind !== "dm") return undefined;
|
if (kind !== "dm") return undefined;
|
||||||
|
|
||||||
const getLimit = (
|
const getLimit = (
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { resolveChannelCapabilities } from "../../../config/channel-capabilities
|
|||||||
import { getMachineDisplayName } from "../../../infra/machine-name.js";
|
import { getMachineDisplayName } from "../../../infra/machine-name.js";
|
||||||
import { resolveTelegramInlineButtonsScope } from "../../../telegram/inline-buttons.js";
|
import { resolveTelegramInlineButtonsScope } from "../../../telegram/inline-buttons.js";
|
||||||
import { resolveTelegramReactionLevel } from "../../../telegram/reaction-level.js";
|
import { resolveTelegramReactionLevel } from "../../../telegram/reaction-level.js";
|
||||||
|
import { resolveSignalReactionLevel } from "../../../signal/reaction-level.js";
|
||||||
import { normalizeMessageChannel } from "../../../utils/message-channel.js";
|
import { normalizeMessageChannel } from "../../../utils/message-channel.js";
|
||||||
import { isReasoningTagProvider } from "../../../utils/provider-utils.js";
|
import { isReasoningTagProvider } from "../../../utils/provider-utils.js";
|
||||||
import { isSubagentSessionKey } from "../../../routing/session-key.js";
|
import { isSubagentSessionKey } from "../../../routing/session-key.js";
|
||||||
@@ -255,14 +256,25 @@ export async function runEmbeddedAttempt(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const reactionGuidance =
|
const reactionGuidance =
|
||||||
runtimeChannel === "telegram" && params.config
|
runtimeChannel && params.config
|
||||||
? (() => {
|
? (() => {
|
||||||
const resolved = resolveTelegramReactionLevel({
|
if (runtimeChannel === "telegram") {
|
||||||
cfg: params.config,
|
const resolved = resolveTelegramReactionLevel({
|
||||||
accountId: params.agentAccountId ?? undefined,
|
cfg: params.config,
|
||||||
});
|
accountId: params.agentAccountId ?? undefined,
|
||||||
const level = resolved.agentReactionGuidance;
|
});
|
||||||
return level ? { level, channel: "Telegram" } : undefined;
|
const level = resolved.agentReactionGuidance;
|
||||||
|
return level ? { level, channel: "Telegram" } : undefined;
|
||||||
|
}
|
||||||
|
if (runtimeChannel === "signal") {
|
||||||
|
const resolved = resolveSignalReactionLevel({
|
||||||
|
cfg: params.config,
|
||||||
|
accountId: params.agentAccountId ?? undefined,
|
||||||
|
});
|
||||||
|
const level = resolved.agentReactionGuidance;
|
||||||
|
return level ? { level, channel: "Signal" } : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
})()
|
})()
|
||||||
: undefined;
|
: undefined;
|
||||||
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
|
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
|
||||||
|
|||||||
@@ -94,6 +94,9 @@ function buildReactionSchema() {
|
|||||||
messageId: Type.Optional(Type.String()),
|
messageId: Type.Optional(Type.String()),
|
||||||
emoji: Type.Optional(Type.String()),
|
emoji: Type.Optional(Type.String()),
|
||||||
remove: Type.Optional(Type.Boolean()),
|
remove: Type.Optional(Type.Boolean()),
|
||||||
|
targetAuthor: Type.Optional(Type.String()),
|
||||||
|
targetAuthorUuid: Type.Optional(Type.String()),
|
||||||
|
groupId: Type.Optional(Type.String()),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,7 +333,13 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
|||||||
name: "message",
|
name: "message",
|
||||||
description,
|
description,
|
||||||
parameters: schema,
|
parameters: schema,
|
||||||
execute: async (_toolCallId, args) => {
|
execute: async (_toolCallId, args, signal) => {
|
||||||
|
// Check if already aborted before doing any work
|
||||||
|
if (signal?.aborted) {
|
||||||
|
const err = new Error("Message send aborted");
|
||||||
|
err.name = "AbortError";
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
const params = args as Record<string, unknown>;
|
const params = args as Record<string, unknown>;
|
||||||
const cfg = options?.config ?? loadConfig();
|
const cfg = options?.config ?? loadConfig();
|
||||||
const action = readStringParam(params, "action", {
|
const action = readStringParam(params, "action", {
|
||||||
@@ -363,6 +372,9 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
|||||||
currentThreadTs: options?.currentThreadTs,
|
currentThreadTs: options?.currentThreadTs,
|
||||||
replyToMode: options?.replyToMode,
|
replyToMode: options?.replyToMode,
|
||||||
hasRepliedRef: options?.hasRepliedRef,
|
hasRepliedRef: options?.hasRepliedRef,
|
||||||
|
// Direct tool invocations should not add cross-context decoration.
|
||||||
|
// The agent is composing a message, not forwarding from another chat.
|
||||||
|
skipCrossContextDecoration: true,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
@@ -376,6 +388,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
|||||||
agentId: options?.agentSessionKey
|
agentId: options?.agentSessionKey
|
||||||
? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: cfg })
|
? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: cfg })
|
||||||
: undefined,
|
: undefined,
|
||||||
|
abortSignal: signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
const toolResult = getToolResult(result);
|
const toolResult = getToolResult(result);
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import { __testing } from "./web-search.js";
|
import { __testing } from "./web-search.js";
|
||||||
|
|
||||||
const { inferPerplexityBaseUrlFromApiKey, resolvePerplexityBaseUrl } = __testing;
|
const { inferPerplexityBaseUrlFromApiKey, resolvePerplexityBaseUrl, normalizeFreshness } =
|
||||||
|
__testing;
|
||||||
|
|
||||||
describe("web_search perplexity baseUrl defaults", () => {
|
describe("web_search perplexity baseUrl defaults", () => {
|
||||||
it("detects a Perplexity key prefix", () => {
|
it("detects a Perplexity key prefix", () => {
|
||||||
@@ -51,3 +52,20 @@ describe("web_search perplexity baseUrl defaults", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("web_search freshness normalization", () => {
|
||||||
|
it("accepts Brave shortcut values", () => {
|
||||||
|
expect(normalizeFreshness("pd")).toBe("pd");
|
||||||
|
expect(normalizeFreshness("PW")).toBe("pw");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts valid date ranges", () => {
|
||||||
|
expect(normalizeFreshness("2024-01-01to2024-01-31")).toBe("2024-01-01to2024-01-31");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid date ranges", () => {
|
||||||
|
expect(normalizeFreshness("2024-13-01to2024-01-31")).toBeUndefined();
|
||||||
|
expect(normalizeFreshness("2024-02-30to2024-03-01")).toBeUndefined();
|
||||||
|
expect(normalizeFreshness("2024-03-10to2024-03-01")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,6 +29,8 @@ const PERPLEXITY_KEY_PREFIXES = ["pplx-"];
|
|||||||
const OPENROUTER_KEY_PREFIXES = ["sk-or-"];
|
const OPENROUTER_KEY_PREFIXES = ["sk-or-"];
|
||||||
|
|
||||||
const SEARCH_CACHE = new Map<string, CacheEntry<Record<string, unknown>>>();
|
const SEARCH_CACHE = new Map<string, CacheEntry<Record<string, unknown>>>();
|
||||||
|
const BRAVE_FRESHNESS_SHORTCUTS = new Set(["pd", "pw", "pm", "py"]);
|
||||||
|
const BRAVE_FRESHNESS_RANGE = /^(\d{4}-\d{2}-\d{2})to(\d{4}-\d{2}-\d{2})$/;
|
||||||
|
|
||||||
const WebSearchSchema = Type.Object({
|
const WebSearchSchema = Type.Object({
|
||||||
query: Type.String({ description: "Search query string." }),
|
query: Type.String({ description: "Search query string." }),
|
||||||
@@ -55,6 +57,12 @@ const WebSearchSchema = Type.Object({
|
|||||||
description: "ISO language code for UI elements.",
|
description: "ISO language code for UI elements.",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
freshness: Type.Optional(
|
||||||
|
Type.String({
|
||||||
|
description:
|
||||||
|
"Filter results by discovery time (Brave only). Values: 'pd' (past 24h), 'pw' (past week), 'pm' (past month), 'py' (past year), or date range 'YYYY-MM-DDtoYYYY-MM-DD'.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
type WebSearchConfig = NonNullable<ClawdbotConfig["tools"]>["web"] extends infer Web
|
type WebSearchConfig = NonNullable<ClawdbotConfig["tools"]>["web"] extends infer Web
|
||||||
@@ -219,6 +227,35 @@ function resolveSearchCount(value: unknown, fallback: number): number {
|
|||||||
return clamped;
|
return clamped;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeFreshness(value: string | undefined): string | undefined {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
|
||||||
|
const lower = trimmed.toLowerCase();
|
||||||
|
if (BRAVE_FRESHNESS_SHORTCUTS.has(lower)) return lower;
|
||||||
|
|
||||||
|
const match = trimmed.match(BRAVE_FRESHNESS_RANGE);
|
||||||
|
if (!match) return undefined;
|
||||||
|
|
||||||
|
const [, start, end] = match;
|
||||||
|
if (!isValidIsoDate(start) || !isValidIsoDate(end)) return undefined;
|
||||||
|
if (start > end) return undefined;
|
||||||
|
|
||||||
|
return `${start}to${end}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidIsoDate(value: string): boolean {
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
||||||
|
const [year, month, day] = value.split("-").map((part) => Number.parseInt(part, 10));
|
||||||
|
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return false;
|
||||||
|
|
||||||
|
const date = new Date(Date.UTC(year, month - 1, day));
|
||||||
|
return (
|
||||||
|
date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveSiteName(url: string | undefined): string | undefined {
|
function resolveSiteName(url: string | undefined): string | undefined {
|
||||||
if (!url) return undefined;
|
if (!url) return undefined;
|
||||||
try {
|
try {
|
||||||
@@ -279,11 +316,14 @@ async function runWebSearch(params: {
|
|||||||
country?: string;
|
country?: string;
|
||||||
search_lang?: string;
|
search_lang?: string;
|
||||||
ui_lang?: string;
|
ui_lang?: string;
|
||||||
|
freshness?: string;
|
||||||
perplexityBaseUrl?: string;
|
perplexityBaseUrl?: string;
|
||||||
perplexityModel?: string;
|
perplexityModel?: string;
|
||||||
}): Promise<Record<string, unknown>> {
|
}): Promise<Record<string, unknown>> {
|
||||||
const cacheKey = normalizeCacheKey(
|
const cacheKey = normalizeCacheKey(
|
||||||
`${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}`,
|
params.provider === "brave"
|
||||||
|
? `${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}:${params.freshness || "default"}`
|
||||||
|
: `${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}`,
|
||||||
);
|
);
|
||||||
const cached = readCache(SEARCH_CACHE, cacheKey);
|
const cached = readCache(SEARCH_CACHE, cacheKey);
|
||||||
if (cached) return { ...cached.value, cached: true };
|
if (cached) return { ...cached.value, cached: true };
|
||||||
@@ -327,6 +367,9 @@ async function runWebSearch(params: {
|
|||||||
if (params.ui_lang) {
|
if (params.ui_lang) {
|
||||||
url.searchParams.set("ui_lang", params.ui_lang);
|
url.searchParams.set("ui_lang", params.ui_lang);
|
||||||
}
|
}
|
||||||
|
if (params.freshness) {
|
||||||
|
url.searchParams.set("freshness", params.freshness);
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(url.toString(), {
|
const res = await fetch(url.toString(), {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -399,6 +442,23 @@ export function createWebSearchTool(options?: {
|
|||||||
const country = readStringParam(params, "country");
|
const country = readStringParam(params, "country");
|
||||||
const search_lang = readStringParam(params, "search_lang");
|
const search_lang = readStringParam(params, "search_lang");
|
||||||
const ui_lang = readStringParam(params, "ui_lang");
|
const ui_lang = readStringParam(params, "ui_lang");
|
||||||
|
const rawFreshness = readStringParam(params, "freshness");
|
||||||
|
if (rawFreshness && provider !== "brave") {
|
||||||
|
return jsonResult({
|
||||||
|
error: "unsupported_freshness",
|
||||||
|
message: "freshness is only supported by the Brave web_search provider.",
|
||||||
|
docs: "https://docs.clawd.bot/tools/web",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const freshness = rawFreshness ? normalizeFreshness(rawFreshness) : undefined;
|
||||||
|
if (rawFreshness && !freshness) {
|
||||||
|
return jsonResult({
|
||||||
|
error: "invalid_freshness",
|
||||||
|
message:
|
||||||
|
"freshness must be one of pd, pw, pm, py, or a range like YYYY-MM-DDtoYYYY-MM-DD.",
|
||||||
|
docs: "https://docs.clawd.bot/tools/web",
|
||||||
|
});
|
||||||
|
}
|
||||||
const result = await runWebSearch({
|
const result = await runWebSearch({
|
||||||
query,
|
query,
|
||||||
count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
|
count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
|
||||||
@@ -409,6 +469,7 @@ export function createWebSearchTool(options?: {
|
|||||||
country,
|
country,
|
||||||
search_lang,
|
search_lang,
|
||||||
ui_lang,
|
ui_lang,
|
||||||
|
freshness,
|
||||||
perplexityBaseUrl: resolvePerplexityBaseUrl(
|
perplexityBaseUrl: resolvePerplexityBaseUrl(
|
||||||
perplexityConfig,
|
perplexityConfig,
|
||||||
perplexityAuth?.source,
|
perplexityAuth?.source,
|
||||||
@@ -424,4 +485,5 @@ export function createWebSearchTool(options?: {
|
|||||||
export const __testing = {
|
export const __testing = {
|
||||||
inferPerplexityBaseUrlFromApiKey,
|
inferPerplexityBaseUrlFromApiKey,
|
||||||
resolvePerplexityBaseUrl,
|
resolvePerplexityBaseUrl,
|
||||||
|
normalizeFreshness,
|
||||||
} as const;
|
} as const;
|
||||||
@@ -88,6 +88,40 @@ describe("web_search country and language parameters", () => {
|
|||||||
const url = new URL(mockFetch.mock.calls[0][0] as string);
|
const url = new URL(mockFetch.mock.calls[0][0] as string);
|
||||||
expect(url.searchParams.get("ui_lang")).toBe("de");
|
expect(url.searchParams.get("ui_lang")).toBe("de");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should pass freshness parameter to Brave API", async () => {
|
||||||
|
const mockFetch = vi.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ web: { results: [] } }),
|
||||||
|
} as Response),
|
||||||
|
);
|
||||||
|
// @ts-expect-error mock fetch
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
|
||||||
|
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
|
||||||
|
await tool?.execute?.(1, { query: "test", freshness: "pw" });
|
||||||
|
|
||||||
|
const url = new URL(mockFetch.mock.calls[0][0] as string);
|
||||||
|
expect(url.searchParams.get("freshness")).toBe("pw");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid freshness values", async () => {
|
||||||
|
const mockFetch = vi.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ web: { results: [] } }),
|
||||||
|
} as Response),
|
||||||
|
);
|
||||||
|
// @ts-expect-error mock fetch
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
|
||||||
|
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
|
||||||
|
const result = await tool?.execute?.(1, { query: "test", freshness: "yesterday" });
|
||||||
|
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
|
expect(result?.details).toMatchObject({ error: "invalid_freshness" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("web_search perplexity baseUrl defaults", () => {
|
describe("web_search perplexity baseUrl defaults", () => {
|
||||||
@@ -120,6 +154,27 @@ describe("web_search perplexity baseUrl defaults", () => {
|
|||||||
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://api.perplexity.ai/chat/completions");
|
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://api.perplexity.ai/chat/completions");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects freshness for Perplexity provider", async () => {
|
||||||
|
vi.stubEnv("PERPLEXITY_API_KEY", "pplx-test");
|
||||||
|
const mockFetch = vi.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }),
|
||||||
|
} as Response),
|
||||||
|
);
|
||||||
|
// @ts-expect-error mock fetch
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
|
||||||
|
const tool = createWebSearchTool({
|
||||||
|
config: { tools: { web: { search: { provider: "perplexity" } } } },
|
||||||
|
sandboxed: true,
|
||||||
|
});
|
||||||
|
const result = await tool?.execute?.(1, { query: "test", freshness: "pw" });
|
||||||
|
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
|
expect(result?.details).toMatchObject({ error: "unsupported_freshness" });
|
||||||
|
});
|
||||||
|
|
||||||
it("defaults to OpenRouter when OPENROUTER_API_KEY is set", async () => {
|
it("defaults to OpenRouter when OPENROUTER_API_KEY is set", async () => {
|
||||||
vi.stubEnv("PERPLEXITY_API_KEY", "");
|
vi.stubEnv("PERPLEXITY_API_KEY", "");
|
||||||
vi.stubEnv("OPENROUTER_API_KEY", "sk-or-test");
|
vi.stubEnv("OPENROUTER_API_KEY", "sk-or-test");
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
chunkByNewline,
|
chunkByNewline,
|
||||||
chunkMarkdownText,
|
chunkMarkdownText,
|
||||||
|
chunkMarkdownTextWithMode,
|
||||||
chunkText,
|
chunkText,
|
||||||
chunkTextWithMode,
|
chunkTextWithMode,
|
||||||
resolveChunkMode,
|
resolveChunkMode,
|
||||||
@@ -246,10 +247,10 @@ describe("chunkByNewline", () => {
|
|||||||
expect(chunks).toEqual(["Line one", "Line two", "Line three"]);
|
expect(chunks).toEqual(["Line one", "Line two", "Line three"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("filters empty lines", () => {
|
it("preserves blank lines by folding into the next chunk", () => {
|
||||||
const text = "Line one\n\n\nLine two\n\nLine three";
|
const text = "Line one\n\n\nLine two\n\nLine three";
|
||||||
const chunks = chunkByNewline(text, 1000);
|
const chunks = chunkByNewline(text, 1000);
|
||||||
expect(chunks).toEqual(["Line one", "Line two", "Line three"]);
|
expect(chunks).toEqual(["Line one", "\n\nLine two", "\nLine three"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("trims whitespace from lines", () => {
|
it("trims whitespace from lines", () => {
|
||||||
@@ -258,6 +259,12 @@ describe("chunkByNewline", () => {
|
|||||||
expect(chunks).toEqual(["Line one", "Line two"]);
|
expect(chunks).toEqual(["Line one", "Line two"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves leading blank lines on the first chunk", () => {
|
||||||
|
const text = "\n\nLine one\nLine two";
|
||||||
|
const chunks = chunkByNewline(text, 1000);
|
||||||
|
expect(chunks).toEqual(["\n\nLine one", "Line two"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("falls back to length-based for long lines", () => {
|
it("falls back to length-based for long lines", () => {
|
||||||
const text = "Short line\n" + "a".repeat(50) + "\nAnother short";
|
const text = "Short line\n" + "a".repeat(50) + "\nAnother short";
|
||||||
const chunks = chunkByNewline(text, 20);
|
const chunks = chunkByNewline(text, 20);
|
||||||
@@ -269,6 +276,12 @@ describe("chunkByNewline", () => {
|
|||||||
expect(chunks[4]).toBe("Another short");
|
expect(chunks[4]).toBe("Another short");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not split long lines when splitLongLines is false", () => {
|
||||||
|
const text = "a".repeat(50);
|
||||||
|
const chunks = chunkByNewline(text, 20, { splitLongLines: false });
|
||||||
|
expect(chunks).toEqual([text]);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns empty array for empty input", () => {
|
it("returns empty array for empty input", () => {
|
||||||
expect(chunkByNewline("", 100)).toEqual([]);
|
expect(chunkByNewline("", 100)).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -276,6 +289,18 @@ describe("chunkByNewline", () => {
|
|||||||
it("returns empty array for whitespace-only input", () => {
|
it("returns empty array for whitespace-only input", () => {
|
||||||
expect(chunkByNewline(" \n\n ", 100)).toEqual([]);
|
expect(chunkByNewline(" \n\n ", 100)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves trailing blank lines on the last chunk", () => {
|
||||||
|
const text = "Line one\n\n";
|
||||||
|
const chunks = chunkByNewline(text, 1000);
|
||||||
|
expect(chunks).toEqual(["Line one\n\n"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps whitespace when trimLines is false", () => {
|
||||||
|
const text = " indented line \nNext";
|
||||||
|
const chunks = chunkByNewline(text, 1000, { trimLines: false });
|
||||||
|
expect(chunks).toEqual([" indented line ", "Next"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("chunkTextWithMode", () => {
|
describe("chunkTextWithMode", () => {
|
||||||
@@ -292,6 +317,26 @@ describe("chunkTextWithMode", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("chunkMarkdownTextWithMode", () => {
|
||||||
|
it("uses markdown-aware chunking for length mode", () => {
|
||||||
|
const text = "Line one\nLine two";
|
||||||
|
expect(chunkMarkdownTextWithMode(text, 1000, "length")).toEqual(chunkMarkdownText(text, 1000));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses newline-based chunking for newline mode", () => {
|
||||||
|
const text = "Line one\nLine two";
|
||||||
|
expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual(["Line one", "Line two"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not split inside code fences for newline mode", () => {
|
||||||
|
const text = "```js\nconst a = 1;\nconst b = 2;\n```\nAfter";
|
||||||
|
expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual([
|
||||||
|
"```js\nconst a = 1;\nconst b = 2;\n```",
|
||||||
|
"After",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("resolveChunkMode", () => {
|
describe("resolveChunkMode", () => {
|
||||||
it("returns length as default", () => {
|
it("returns length as default", () => {
|
||||||
expect(resolveChunkMode(undefined, "telegram")).toBe("length");
|
expect(resolveChunkMode(undefined, "telegram")).toBe("length");
|
||||||
@@ -304,16 +349,16 @@ describe("resolveChunkMode", () => {
|
|||||||
expect(resolveChunkMode(cfg, "__internal__")).toBe("length");
|
expect(resolveChunkMode(cfg, "__internal__")).toBe("length");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports provider-level overrides for bluebubbles", () => {
|
it("supports provider-level overrides for slack", () => {
|
||||||
const cfg = { channels: { bluebubbles: { chunkMode: "newline" as const } } };
|
const cfg = { channels: { slack: { chunkMode: "newline" as const } } };
|
||||||
expect(resolveChunkMode(cfg, "bluebubbles")).toBe("newline");
|
expect(resolveChunkMode(cfg, "slack")).toBe("newline");
|
||||||
expect(resolveChunkMode(cfg, "discord")).toBe("length");
|
expect(resolveChunkMode(cfg, "discord")).toBe("length");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports account-level overrides for bluebubbles", () => {
|
it("supports account-level overrides for slack", () => {
|
||||||
const cfg = {
|
const cfg = {
|
||||||
channels: {
|
channels: {
|
||||||
bluebubbles: {
|
slack: {
|
||||||
chunkMode: "length" as const,
|
chunkMode: "length" as const,
|
||||||
accounts: {
|
accounts: {
|
||||||
primary: { chunkMode: "newline" as const },
|
primary: { chunkMode: "newline" as const },
|
||||||
@@ -321,12 +366,7 @@ describe("resolveChunkMode", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
expect(resolveChunkMode(cfg, "bluebubbles", "primary")).toBe("newline");
|
expect(resolveChunkMode(cfg, "slack", "primary")).toBe("newline");
|
||||||
expect(resolveChunkMode(cfg, "bluebubbles", "other")).toBe("length");
|
expect(resolveChunkMode(cfg, "slack", "other")).toBe("length");
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores chunkMode for non-bluebubbles providers", () => {
|
|
||||||
const cfg = { channels: { ["telegram" as string]: { chunkMode: "newline" as const } } };
|
|
||||||
expect(resolveChunkMode(cfg, "telegram")).toBe("length");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+92
-15
@@ -101,8 +101,6 @@ export function resolveChunkMode(
|
|||||||
accountId?: string | null,
|
accountId?: string | null,
|
||||||
): ChunkMode {
|
): ChunkMode {
|
||||||
if (!provider || provider === INTERNAL_MESSAGE_CHANNEL) return DEFAULT_CHUNK_MODE;
|
if (!provider || provider === INTERNAL_MESSAGE_CHANNEL) return DEFAULT_CHUNK_MODE;
|
||||||
// Chunk mode is only supported for BlueBubbles.
|
|
||||||
if (provider !== "bluebubbles") return DEFAULT_CHUNK_MODE;
|
|
||||||
const channelsConfig = cfg?.channels as Record<string, unknown> | undefined;
|
const channelsConfig = cfg?.channels as Record<string, unknown> | undefined;
|
||||||
const providerConfig = (channelsConfig?.[provider] ??
|
const providerConfig = (channelsConfig?.[provider] ??
|
||||||
(cfg as Record<string, unknown> | undefined)?.[provider]) as ProviderChunkConfig | undefined;
|
(cfg as Record<string, unknown> | undefined)?.[provider]) as ProviderChunkConfig | undefined;
|
||||||
@@ -111,25 +109,56 @@ export function resolveChunkMode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Split text on newlines, filtering empty lines.
|
* Split text on newlines, trimming line whitespace.
|
||||||
* Lines exceeding maxLineLength are further split using length-based chunking.
|
* Blank lines are folded into the next non-empty line as leading "\n" prefixes.
|
||||||
|
* Long lines can be split by length (default) or kept intact via splitLongLines:false.
|
||||||
*/
|
*/
|
||||||
export function chunkByNewline(text: string, maxLineLength: number): string[] {
|
export function chunkByNewline(
|
||||||
|
text: string,
|
||||||
|
maxLineLength: number,
|
||||||
|
opts?: {
|
||||||
|
splitLongLines?: boolean;
|
||||||
|
trimLines?: boolean;
|
||||||
|
isSafeBreak?: (index: number) => boolean;
|
||||||
|
},
|
||||||
|
): string[] {
|
||||||
if (!text) return [];
|
if (!text) return [];
|
||||||
const lines = text.split("\n");
|
if (maxLineLength <= 0) return text.trim() ? [text] : [];
|
||||||
|
const splitLongLines = opts?.splitLongLines !== false;
|
||||||
|
const trimLines = opts?.trimLines !== false;
|
||||||
|
const lines = splitByNewline(text, opts?.isSafeBreak);
|
||||||
const chunks: string[] = [];
|
const chunks: string[] = [];
|
||||||
|
let pendingBlankLines = 0;
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed) continue; // skip empty lines
|
if (!trimmed) {
|
||||||
|
pendingBlankLines += 1;
|
||||||
if (trimmed.length <= maxLineLength) {
|
continue;
|
||||||
chunks.push(trimmed);
|
|
||||||
} else {
|
|
||||||
// Long line: fall back to length-based chunking
|
|
||||||
const subChunks = chunkText(trimmed, maxLineLength);
|
|
||||||
chunks.push(...subChunks);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maxPrefix = Math.max(0, maxLineLength - 1);
|
||||||
|
const cappedBlankLines = pendingBlankLines > 0 ? Math.min(pendingBlankLines, maxPrefix) : 0;
|
||||||
|
const prefix = cappedBlankLines > 0 ? "\n".repeat(cappedBlankLines) : "";
|
||||||
|
pendingBlankLines = 0;
|
||||||
|
|
||||||
|
const lineValue = trimLines ? trimmed : line;
|
||||||
|
if (!splitLongLines || lineValue.length + prefix.length <= maxLineLength) {
|
||||||
|
chunks.push(prefix + lineValue);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstLimit = Math.max(1, maxLineLength - prefix.length);
|
||||||
|
const first = lineValue.slice(0, firstLimit);
|
||||||
|
chunks.push(prefix + first);
|
||||||
|
const remaining = lineValue.slice(firstLimit);
|
||||||
|
if (remaining) {
|
||||||
|
chunks.push(...chunkText(remaining, maxLineLength));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingBlankLines > 0 && chunks.length > 0) {
|
||||||
|
chunks[chunks.length - 1] += "\n".repeat(pendingBlankLines);
|
||||||
}
|
}
|
||||||
|
|
||||||
return chunks;
|
return chunks;
|
||||||
@@ -140,11 +169,59 @@ export function chunkByNewline(text: string, maxLineLength: number): string[] {
|
|||||||
*/
|
*/
|
||||||
export function chunkTextWithMode(text: string, limit: number, mode: ChunkMode): string[] {
|
export function chunkTextWithMode(text: string, limit: number, mode: ChunkMode): string[] {
|
||||||
if (mode === "newline") {
|
if (mode === "newline") {
|
||||||
return chunkByNewline(text, limit);
|
const chunks: string[] = [];
|
||||||
|
const lineChunks = chunkByNewline(text, limit, { splitLongLines: false });
|
||||||
|
for (const line of lineChunks) {
|
||||||
|
const nested = chunkText(line, limit);
|
||||||
|
if (!nested.length && line) {
|
||||||
|
chunks.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
chunks.push(...nested);
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
}
|
}
|
||||||
return chunkText(text, limit);
|
return chunkText(text, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function chunkMarkdownTextWithMode(text: string, limit: number, mode: ChunkMode): string[] {
|
||||||
|
if (mode === "newline") {
|
||||||
|
const spans = parseFenceSpans(text);
|
||||||
|
const chunks: string[] = [];
|
||||||
|
const lineChunks = chunkByNewline(text, limit, {
|
||||||
|
splitLongLines: false,
|
||||||
|
trimLines: false,
|
||||||
|
isSafeBreak: (index) => isSafeFenceBreak(spans, index),
|
||||||
|
});
|
||||||
|
for (const line of lineChunks) {
|
||||||
|
const nested = chunkMarkdownText(line, limit);
|
||||||
|
if (!nested.length && line) {
|
||||||
|
chunks.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
chunks.push(...nested);
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
return chunkMarkdownText(text, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitByNewline(
|
||||||
|
text: string,
|
||||||
|
isSafeBreak: (index: number) => boolean = () => true,
|
||||||
|
): string[] {
|
||||||
|
const lines: string[] = [];
|
||||||
|
let start = 0;
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
if (text[i] === "\n" && isSafeBreak(i)) {
|
||||||
|
lines.push(text.slice(start, i));
|
||||||
|
start = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push(text.slice(start));
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
export function chunkText(text: string, limit: number): string[] {
|
export function chunkText(text: string, limit: number): string[] {
|
||||||
if (!text) return [];
|
if (!text) return [];
|
||||||
if (limit <= 0) return [text];
|
if (limit <= 0) return [text];
|
||||||
|
|||||||
@@ -178,6 +178,13 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
textAlias: "/context",
|
textAlias: "/context",
|
||||||
acceptsArgs: true,
|
acceptsArgs: true,
|
||||||
}),
|
}),
|
||||||
|
defineChatCommand({
|
||||||
|
key: "tts",
|
||||||
|
nativeName: "tts",
|
||||||
|
description: "Configure text-to-speech.",
|
||||||
|
textAlias: "/tts",
|
||||||
|
acceptsArgs: true,
|
||||||
|
}),
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "whoami",
|
key: "whoami",
|
||||||
nativeName: "whoami",
|
nativeName: "whoami",
|
||||||
@@ -279,27 +286,6 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
],
|
],
|
||||||
argsMenu: "auto",
|
argsMenu: "auto",
|
||||||
}),
|
}),
|
||||||
defineChatCommand({
|
|
||||||
key: "tts",
|
|
||||||
nativeName: "tts",
|
|
||||||
description: "Control text-to-speech (TTS).",
|
|
||||||
textAlias: "/tts",
|
|
||||||
args: [
|
|
||||||
{
|
|
||||||
name: "action",
|
|
||||||
description: "on | off | status | provider | limit | summary | audio | help",
|
|
||||||
type: "string",
|
|
||||||
choices: ["on", "off", "status", "provider", "limit", "summary", "audio", "help"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "value",
|
|
||||||
description: "Provider, limit, or text",
|
|
||||||
type: "string",
|
|
||||||
captureRemaining: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
argsMenu: "auto",
|
|
||||||
}),
|
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "stop",
|
key: "stop",
|
||||||
nativeName: "stop",
|
nativeName: "stop",
|
||||||
|
|||||||
Loaded 100 of 271 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user