Compare commits

..
Author SHA1 Message Date
Peter Steinberger 50faccaba8 fix: register lazy subcommands before parse (#1683) (thanks @grrowl) 2026-01-25 03:16:41 +00:00
Tom McKenzieandClaude Sonnet 4.5 4d59d1758c CLI: fix subcommand registration to work without --help/--version flags
## Problem

The clawdbot-gateway systemd service was crash-looping on Linux (Fedora 42,
aarch64) with the error:

    error: unknown command '/usr/bin/node-22'

After ~20 seconds of runtime, the gateway would exit with status 1/FAILURE
and systemd would restart it, repeating the cycle indefinitely (80+ restarts
observed).

## Root Cause Analysis

### Investigation Steps

1. Examined systemd service logs via `journalctl --user -u clawdbot-gateway.service`
2. Found the error appeared consistently after the service had been running
   for 20-30 seconds
3. Added debug logging to trace argv at parseAsync() call
4. Discovered that argv was being passed to Commander.js with the node binary
   and script paths still present: `["/usr/bin/node-22", "/path/to/entry.js", "gateway", "--port", "18789"]`
5. Traced the issue to the lazy subcommand registration logic in runCli()

### The Bug

The lazy-loading logic for subcommands was gated behind `hasHelpOrVersion(parseArgv)`:

```typescript
if (hasHelpOrVersion(parseArgv)) {
  const primary = getPrimaryCommand(parseArgv);
  if (primary) {
    const { registerSubCliByName } = await import("./program/register.subclis.js");
    await registerSubCliByName(program, primary);
  }
}
```

This meant that when running `clawdbot gateway --port 18789` (without --help
or --version), the `gateway` subcommand was never registered before
`program.parseAsync(parseArgv)` was called. Commander.js would then try to
parse the arguments without knowing about the gateway command, leading to
parse errors.

The error message "unknown command '/usr/bin/node-22'" appeared because
Commander was treating the first positional argument as a command name due to
argv not being properly stripped on non-Windows platforms in some code paths.

## The Fix

Remove the `hasHelpOrVersion()` gate and always register the primary
subcommand when one is detected:

```typescript
// Register the primary subcommand if one exists (for lazy-loading)
const primary = getPrimaryCommand(parseArgv);
if (primary) {
  const { registerSubCliByName } = await import("./program/register.subclis.js");
  await registerSubCliByName(program, primary);
}
```

This ensures that subcommands like `gateway` are properly registered before
parsing begins, regardless of what flags are present.

## Environment

- OS: Fedora 42 (Linux 6.15.9-201.fc42.aarch64)
- Arch: aarch64
- Node: /usr/bin/node-22 (symlink to node-22)
- Deployment: systemd user service
- Runtime: Gateway started via `clawdbot gateway --port 18789`

## Why This Should Be Merged

1. **Critical Bug**: The gateway service cannot run reliably on Linux without
   this fix, making it a blocking issue for production deployments via systemd.

2. **Affects All Non-Help Invocations**: Any direct subcommand invocation
   (gateway, channels, etc.) without --help/--version is broken.

3. **Simple & Safe Fix**: The change removes an unnecessary condition that was
   preventing lazy-loading from working correctly. Subcommands should always be
   registered when detected, not just for help/version requests.

4. **No Regression Risk**: The fix maintains the lazy-loading behavior (only
   loads the requested subcommand), just ensures it works in all cases instead
   of only help/version scenarios.

5. **Tested**: Verified that the gateway service now runs stably for extended
   periods (45+ seconds continuous runtime with no crashes) after applying this
   fix.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-25 13:59:31 +11:00
107 changed files with 165 additions and 1741 deletions

No files matched your search

+1 -4
View File
@@ -10,7 +10,6 @@ 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
- 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: update Fly.io guide notes. - Docs: update Fly.io guide notes.
@@ -25,8 +24,6 @@ Docs: https://docs.clawd.bot
- 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.
- 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.
@@ -37,7 +34,7 @@ Docs: https://docs.clawd.bot
- 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.
- Messaging: keep newline chunking safe for fenced markdown blocks across channels. - CLI: register lazy subcommands before parse so non-help invocations work. (#1683) Thanks @grrowl.
## 2026.1.23-1 ## 2026.1.23-1
+27 -28
View File
@@ -477,32 +477,31 @@ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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=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/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=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>
-2
View File
@@ -205,7 +205,6 @@ 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).
@@ -307,7 +306,6 @@ 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).
-2
View File
@@ -219,7 +219,6 @@ 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
@@ -254,7 +253,6 @@ 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`).
-2
View File
@@ -32,8 +32,6 @@ 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).
-1
View File
@@ -215,7 +215,6 @@ 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).
-1
View File
@@ -415,7 +415,6 @@ 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)).
-1
View File
@@ -114,7 +114,6 @@ 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).
-23
View File
@@ -95,7 +95,6 @@ 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.
@@ -106,29 +105,8 @@ 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).
@@ -153,7 +131,6 @@ 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:
-1
View File
@@ -349,7 +349,6 @@ 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
-2
View File
@@ -128,7 +128,6 @@ 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).
@@ -517,7 +516,6 @@ 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.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).
+1 -2
View File
@@ -271,13 +271,12 @@ 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`, optional `channels.whatsapp.chunkMode`). - Text chunking: 4k max per message (configurable via `channels.whatsapp.textChunkLimit`).
- 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`.
+2 -10
View File
@@ -66,12 +66,11 @@ Name lookup:
- Discord only: `--poll-duration-hours`, `--message` - Discord only: `--poll-duration-hours`, `--message`
- `react` - `react`
- Channels: Discord/Google Chat/Slack/Telegram/WhatsApp/Signal - Channels: Discord/Google Chat/Slack/Telegram/WhatsApp
- Required: `--message-id`, `--target` - Required: `--message-id`, `--target`
- Optional: `--emoji`, `--remove`, `--participant`, `--from-me`, `--target-author`, `--target-author-uuid` - Optional: `--emoji`, `--remove`, `--participant`, `--from-me`
- 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
@@ -214,13 +213,6 @@ 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:" \
-1
View File
@@ -31,7 +31,6 @@ 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).
- 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)
-1
View File
@@ -38,7 +38,6 @@ 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:**
+2 -6
View File
@@ -504,7 +504,6 @@ 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)
} }
} }
@@ -1106,7 +1105,6 @@ 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,
@@ -1127,7 +1125,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). 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. 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.
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)
@@ -1220,7 +1218,6 @@ 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
} }
} }
@@ -1270,8 +1267,7 @@ 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"
} }
} }
} }
-2
View File
@@ -29,8 +29,6 @@ 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?
-86
View File
@@ -48,7 +48,6 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [What’s the difference between the hackable (git) install and npm install?](#whats-the-difference-between-the-hackable-git-install-and-npm-install) - [What’s the difference between the hackable (git) install and npm install?](#whats-the-difference-between-the-hackable-git-install-and-npm-install)
- [Can I switch between npm and git installs later?](#can-i-switch-between-npm-and-git-installs-later) - [Can I switch between npm and git installs later?](#can-i-switch-between-npm-and-git-installs-later)
- [Should I run the Gateway on my laptop or a VPS?](#should-i-run-the-gateway-on-my-laptop-or-a-vps) - [Should I run the Gateway on my laptop or a VPS?](#should-i-run-the-gateway-on-my-laptop-or-a-vps)
- [How important is it to run Clawdbot on a dedicated machine?](#how-important-is-it-to-run-clawdbot-on-a-dedicated-machine)
- [What is Clawdbot?](#what-is-clawdbot) - [What is Clawdbot?](#what-is-clawdbot)
- [What is Clawdbot, in one paragraph?](#what-is-clawdbot-in-one-paragraph) - [What is Clawdbot, in one paragraph?](#what-is-clawdbot-in-one-paragraph)
- [What’s the value proposition?](#whats-the-value-proposition) - [What’s the value proposition?](#whats-the-value-proposition)
@@ -86,7 +85,6 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [Remote gateways + nodes](#remote-gateways-nodes) - [Remote gateways + nodes](#remote-gateways-nodes)
- [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes) - [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes)
- [How can my agent access my computer if the Gateway is hosted remotely?](#how-can-my-agent-access-my-computer-if-the-gateway-is-hosted-remotely) - [How can my agent access my computer if the Gateway is hosted remotely?](#how-can-my-agent-access-my-computer-if-the-gateway-is-hosted-remotely)
- [Can two Clawdbots talk to each other (local + VPS)?](#can-two-clawdbots-talk-to-each-other-local--vps)
- [Is there a benefit to using a node on my personal laptop instead of SSH from a VPS?](#is-there-a-benefit-to-using-a-node-on-my-personal-laptop-instead-of-ssh-from-a-vps) - [Is there a benefit to using a node on my personal laptop instead of SSH from a VPS?](#is-there-a-benefit-to-using-a-node-on-my-personal-laptop-instead-of-ssh-from-a-vps)
- [Do nodes run a gateway service?](#do-nodes-run-a-gateway-service) - [Do nodes run a gateway service?](#do-nodes-run-a-gateway-service)
- [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config) - [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config)
@@ -144,7 +142,6 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [Logging and debugging](#logging-and-debugging) - [Logging and debugging](#logging-and-debugging)
- [Where are logs?](#where-are-logs) - [Where are logs?](#where-are-logs)
- [How do I start/stop/restart the Gateway service?](#how-do-i-startstoprestart-the-gateway-service) - [How do I start/stop/restart the Gateway service?](#how-do-i-startstoprestart-the-gateway-service)
- [I closed my terminal on Windows — how do I restart Clawdbot?](#i-closed-my-terminal-on-windows--how-do-i-restart-clawdbot)
- [How do I completely stop then start the Gateway?](#how-do-i-completely-stop-then-start-the-gateway) - [How do I completely stop then start the Gateway?](#how-do-i-completely-stop-then-start-the-gateway)
- [ELI5: `clawdbot gateway restart` vs `clawdbot gateway`](#eli5-clawdbot-gateway-restart-vs-clawdbot-gateway) - [ELI5: `clawdbot gateway restart` vs `clawdbot gateway`](#eli5-clawdbot-gateway-restart-vs-clawdbot-gateway)
- [What’s the fastest way to get more details when something fails?](#whats-the-fastest-way-to-get-more-details-when-something-fails) - [What’s the fastest way to get more details when something fails?](#whats-the-fastest-way-to-get-more-details-when-something-fails)
@@ -233,9 +230,6 @@ This installs Clawdbot **from a git checkout**, so the agent can read the code +
reason about the exact version you are running. You can always switch back to stable later reason about the exact version you are running. You can always switch back to stable later
by re-running the installer without `--install-method git`. by re-running the installer without `--install-method git`.
Tip: ask the agent to **plan and supervise** the fix (step-by-step), then execute only the
necessary commands. That keeps changes small and easier to audit.
If you discover a real bug or fix, please file a GitHub issue or send a PR: If you discover a real bug or fix, please file a GitHub issue or send a PR:
https://github.com/clawdbot/clawdbot/issues https://github.com/clawdbot/clawdbot/issues
https://github.com/clawdbot/clawdbot/pulls https://github.com/clawdbot/clawdbot/pulls
@@ -313,9 +307,6 @@ disk as enough for personal use, and note that a **Raspberry Pi 4 can run it**.
If you want extra headroom (logs, media, other services), **2GB is recommended**, but it’s If you want extra headroom (logs, media, other services), **2GB is recommended**, but it’s
not a hard minimum. not a hard minimum.
Tip: a small Pi/VPS can host the Gateway, and you can pair **nodes** on your laptop/phone for
local screen/camera/canvas or command execution. See [Nodes](/nodes).
### Can I migrate my setup to a new machine (Mac mini) without redoing onboarding? ### Can I migrate my setup to a new machine (Mac mini) without redoing onboarding?
Yes. Copy the **state directory** and **workspace**, then run Doctor once. This Yes. Copy the **state directory** and **workspace**, then run Doctor once. This
@@ -729,15 +720,6 @@ lowest friction and you’re okay with sleep/restarts, run it locally.
**Recommended default:** VPS if you had gateway disconnects before. Local is great when you’re actively using the Mac and want local file access or UI automation with a visible browser. **Recommended default:** VPS if you had gateway disconnects before. Local is great when you’re actively using the Mac and want local file access or UI automation with a visible browser.
### How important is it to run Clawdbot on a dedicated machine?
Not required, but **recommended for reliability and isolation**.
- **Dedicated host (VPS/Mac mini/Pi):** always‑on, fewer sleep/reboot interruptions, cleaner permissions, easier to keep running.
- **Shared laptop/desktop:** totally fine for testing and active use, but expect pauses when the machine sleeps or updates.
If you want the best of both worlds, keep the Gateway on a dedicated host and pair your laptop as a **node** for local screen/camera/exec tools. See [Nodes](/nodes).
## What is Clawdbot? ## What is Clawdbot?
### What is Clawdbot, in one paragraph? ### What is Clawdbot, in one paragraph?
@@ -991,9 +973,6 @@ If the bot “forgets” after a restart, confirm the Gateway is using the same
workspace on every launch (and remember: remote mode uses the **gateway host’s** workspace on every launch (and remember: remote mode uses the **gateway host’s**
workspace, not your local laptop). workspace, not your local laptop).
Tip: if you want a durable behavior or preference, ask the bot to **write it into
AGENTS.md or MEMORY.md** rather than relying on chat history.
See [Agent workspace](/concepts/agent-workspace) and [Memory](/concepts/memory). See [Agent workspace](/concepts/agent-workspace) and [Memory](/concepts/memory).
### What’s the recommended backup strategy? ### What’s the recommended backup strategy?
@@ -1188,29 +1167,6 @@ pair devices you trust, and review [Security](/gateway/security).
Docs: [Nodes](/nodes), [Gateway protocol](/gateway/protocol), [macOS remote mode](/platforms/mac/remote), [Security](/gateway/security). Docs: [Nodes](/nodes), [Gateway protocol](/gateway/protocol), [macOS remote mode](/platforms/mac/remote), [Security](/gateway/security).
### Can two Clawdbots talk to each other (local + VPS)?
Yes. There is no built-in "bot-to-bot" bridge, but you can wire it up in a few
reliable ways:
**Simplest:** use a normal chat channel both bots can access (Telegram/Slack/WhatsApp).
Have Bot A send a message to Bot B, then let Bot B reply as usual.
**CLI bridge (generic):** run a script that calls the other Gateway with
`clawdbot agent --message ... --deliver`, targeting a chat where the other bot
listens. If one bot is on Railway/VPS, point your CLI at that remote Gateway
via SSH/Tailscale (see [Remote access](/gateway/remote)).
Example pattern (run from a machine that can reach the target Gateway):
```bash
clawdbot agent --message "Hello from local bot" --deliver --channel telegram --reply-to <chat-id>
```
Tip: add a guardrail so the two bots do not loop endlessly (mention-only, channel
allowlists, or a "do not reply to bot messages" rule).
Docs: [Remote access](/gateway/remote), [Agent CLI](/cli/agent), [Agent send](/tools/agent-send).
### Is there a benefit to using a node on my personal laptop instead of SSH from a VPS? ### Is there a benefit to using a node on my personal laptop instead of SSH from a VPS?
Yes — nodes are the first‑class way to reach your laptop from a remote Gateway, and they Yes — nodes are the first‑class way to reach your laptop from a remote Gateway, and they
@@ -1559,11 +1515,6 @@ Models are referenced as `provider/model` (example: `anthropic/claude-opus-4-5`)
MiniMax M2.1 has its own docs: [MiniMax](/providers/minimax) and MiniMax M2.1 has its own docs: [MiniMax](/providers/minimax) and
[Local models](/gateway/local-models). [Local models](/gateway/local-models).
Rule of thumb: use the **best model you can afford** for high-stakes work, and a cheaper
model for routine chat or summaries. You can route models per agent and use sub-agents to
parallelize long tasks (each sub-agent consumes tokens). See [Models](/concepts/models) and
[Sub-agents](/tools/subagents).
Strong warning: weaker/over-quantized models are more vulnerable to prompt Strong warning: weaker/over-quantized models are more vulnerable to prompt
injection and unsafe behavior. See [Security](/gateway/security). injection and unsafe behavior. See [Security](/gateway/security).
@@ -2047,43 +1998,6 @@ clawdbot gateway restart
If you run the gateway manually, `clawdbot gateway --force` can reclaim the port. See [Gateway](/gateway). If you run the gateway manually, `clawdbot gateway --force` can reclaim the port. See [Gateway](/gateway).
### I closed my terminal on Windows — how do I restart Clawdbot?
There are **two Windows install modes**:
**1) WSL2 (recommended):** the Gateway runs inside Linux.
Open PowerShell, enter WSL, then restart:
```powershell
wsl
clawdbot gateway status
clawdbot gateway restart
```
If you never installed the service, start it in the foreground:
```bash
clawdbot gateway run
```
**2) Native Windows (not recommended):** the Gateway runs directly in Windows.
Open PowerShell and run:
```powershell
clawdbot gateway status
clawdbot gateway restart
```
If you run it manually (no service), use:
```powershell
clawdbot gateway run
```
Docs: [Windows (WSL2)](/platforms/windows), [Gateway service runbook](/gateway).
### How do I completely stop then start the Gateway? ### How do I completely stop then start the Gateway?
If you installed the service: If you installed the service:
-4
View File
@@ -26,10 +26,6 @@ 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`:
-7
View File
@@ -174,7 +174,6 @@ 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:**
@@ -194,12 +193,6 @@ 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
-1
View File
@@ -374,7 +374,6 @@ 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() ?? "";
+2 -13
View File
@@ -684,7 +684,6 @@ async function processMessageWithPipeline(params: {
spaceId, spaceId,
runtime, runtime,
core, core,
config,
statusSink, statusSink,
typingMessageName, typingMessageName,
}); });
@@ -726,11 +725,10 @@ 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, config, statusSink, typingMessageName } = params; const { payload, account, spaceId, runtime, core, statusSink, typingMessageName } = params;
const mediaList = payload.mediaUrls?.length const mediaList = payload.mediaUrls?.length
? payload.mediaUrls ? payload.mediaUrls
: payload.mediaUrl : payload.mediaUrl
@@ -801,16 +799,7 @@ async function deliverGoogleChatReply(params: {
if (payload.text) { if (payload.text) {
const chunkLimit = account.config.textChunkLimit ?? 4000; const chunkLimit = account.config.textChunkLimit ?? 4000;
const chunkMode = core.channel.text.resolveChunkMode( const chunks = core.channel.text.chunkMarkdownText(payload.text, chunkLimit);
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 {
-1
View File
@@ -186,7 +186,6 @@ 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;
-1
View File
@@ -50,7 +50,6 @@ 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,11 +16,10 @@ 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, cfg: core.config.loadConfig(),
channel: "matrix", channel: "matrix",
accountId: params.accountId, accountId: params.accountId,
}); });
@@ -30,7 +29,6 @@ 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;
@@ -56,11 +54,7 @@ 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.chunkMarkdownTextWithMode( for (const chunk of core.channel.text.chunkMarkdownText(text, chunkLimit)) {
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,9 +42,7 @@ 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,
}, },
+1 -6
View File
@@ -61,12 +61,7 @@ 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 chunkMode = getCore().channel.text.resolveChunkMode(cfg, "matrix", opts.accountId); const chunks = getCore().channel.text.chunkMarkdownText(convertedMessage, chunkLimit);
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)
-1
View File
@@ -6,7 +6,6 @@ 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;
-2
View File
@@ -69,8 +69,6 @@ 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. */
-1
View File
@@ -158,7 +158,6 @@ 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,7 +25,6 @@ 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,12 +738,7 @@ 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 chunkMode = core.channel.text.resolveChunkMode( const chunks = core.channel.text.chunkMarkdownText(text, textLimit);
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, {
-2
View File
@@ -36,8 +36,6 @@ 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 -12
View File
@@ -9,21 +9,18 @@ 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, chunkMarkdownText: (text: string, limit: number) => {
chunkMarkdownTextWithMode: chunkMarkdownText, 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;
},
resolveMarkdownTableMode: () => "code", resolveMarkdownTableMode: () => "code",
convertMarkdownTables: (text: string) => text, convertMarkdownTables: (text: string) => text,
}, },
+4 -12
View File
@@ -1,5 +1,4 @@
import { import {
type ChunkMode,
isSilentReplyText, isSilentReplyText,
loadWebMedia, loadWebMedia,
type MarkdownTableMode, type MarkdownTableMode,
@@ -64,7 +63,6 @@ export type MSTeamsReplyRenderOptions = {
chunkText?: boolean; chunkText?: boolean;
mediaMode?: "split" | "inline"; mediaMode?: "split" | "inline";
tableMode?: MarkdownTableMode; tableMode?: MarkdownTableMode;
chunkMode?: ChunkMode;
}; };
/** /**
@@ -131,16 +129,11 @@ 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.chunkMarkdownTextWithMode( for (const chunk of getMSTeamsRuntime().channel.text.chunkMarkdownText(text, opts.chunkLimit)) {
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 });
@@ -204,7 +197,6 @@ 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 ??
@@ -223,7 +215,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, chunkMode }); pushTextMessages(out, text, { chunkText, chunkLimit });
continue; continue;
} }
@@ -237,13 +229,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, chunkMode }); pushTextMessages(out, text, { chunkText, chunkLimit });
} }
continue; continue;
} }
// mediaMode === "split" // mediaMode === "split"
pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode }); pushTextMessages(out, text, { chunkText, chunkLimit });
for (const mediaUrl of mediaList) { for (const mediaUrl of mediaList) {
if (!mediaUrl) continue; if (!mediaUrl) continue;
out.push({ mediaUrl }); out.push({ mediaUrl });
-1
View File
@@ -7,7 +7,6 @@ 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,7 +59,6 @@ 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({
@@ -76,7 +75,6 @@ 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,
-1
View File
@@ -247,7 +247,6 @@ 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,7 +44,6 @@ 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(),
-2
View File
@@ -62,8 +62,6 @@ 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. */
+1 -12
View File
@@ -18,20 +18,12 @@ 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> = {
@@ -50,9 +42,7 @@ 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 },
}, },
@@ -125,7 +115,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
normalizeTarget: normalizeSignalMessagingTarget, normalizeTarget: normalizeSignalMessagingTarget,
targetResolver: { targetResolver: {
looksLikeId: looksLikeSignalTargetId, looksLikeId: looksLikeSignalTargetId,
hint: "<E.164|uuid:ID|group:ID|signal:group:ID|signal:+E.164>", hint: "<E.164|group:ID|signal:group:ID|signal:+E.164>",
}, },
}, },
setup: { setup: {
@@ -207,7 +197,6 @@ 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;
-1
View File
@@ -251,7 +251,6 @@ 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
View File
@@ -276,7 +276,6 @@ 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 }) => {
-1
View File
@@ -288,7 +288,6 @@ 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, {
+2 -11
View File
@@ -596,8 +596,6 @@ async function processMessageWithPipeline(params: {
chatId, chatId,
runtime, runtime,
core, core,
config,
accountId: account.accountId,
statusSink, statusSink,
fetcher, fetcher,
tableMode, tableMode,
@@ -616,13 +614,11 @@ 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, config, accountId, statusSink, fetcher } = params; const { payload, token, chatId, runtime, core, 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);
@@ -648,12 +644,7 @@ async function deliverZaloReply(params: {
} }
if (text) { if (text) {
const chunkMode = core.channel.text.resolveChunkMode(config, "zalo", accountId); const chunks = core.channel.text.chunkMarkdownText(text, ZALO_TEXT_LIMIT);
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);
-1
View File
@@ -506,7 +506,6 @@ 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 });
+2 -12
View File
@@ -332,8 +332,6 @@ 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,
@@ -358,13 +356,10 @@ 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, config, accountId, statusSink } = const { payload, profile, chatId, isGroup, runtime, core, statusSink } = params;
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);
@@ -395,12 +390,7 @@ async function deliverZalouserReply(params: {
} }
if (text) { if (text) {
const chunkMode = core.channel.text.resolveChunkMode(config, "zalouser", accountId); const chunks = core.channel.text.chunkMarkdownText(text, ZALOUSER_TEXT_LIMIT);
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 {
-25
View File
@@ -15,8 +15,6 @@ 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";
@@ -257,28 +255,6 @@ 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({
@@ -337,7 +313,6 @@ export async function compactEmbeddedPiSessionDirect(
ttsHint, ttsHint,
promptMode, promptMode,
runtimeInfo, runtimeInfo,
reactionGuidance,
messageToolHints, messageToolHints,
sandboxInfo, sandboxInfo,
tools, tools,
+7 -19
View File
@@ -15,7 +15,6 @@ 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";
@@ -256,25 +255,14 @@ export async function runEmbeddedAttempt(
} }
} }
const reactionGuidance = const reactionGuidance =
runtimeChannel && params.config runtimeChannel === "telegram" && params.config
? (() => { ? (() => {
if (runtimeChannel === "telegram") { const resolved = resolveTelegramReactionLevel({
const resolved = resolveTelegramReactionLevel({ cfg: params.config,
cfg: params.config, accountId: params.agentAccountId ?? undefined,
accountId: params.agentAccountId ?? undefined, });
}); const level = resolved.agentReactionGuidance;
const level = resolved.agentReactionGuidance; return level ? { level, channel: "Telegram" } : undefined;
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({
-3
View File
@@ -94,9 +94,6 @@ 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()),
}; };
} }
+1 -19
View File
@@ -2,8 +2,7 @@ import { describe, expect, it } from "vitest";
import { __testing } from "./web-search.js"; import { __testing } from "./web-search.js";
const { inferPerplexityBaseUrlFromApiKey, resolvePerplexityBaseUrl, normalizeFreshness } = const { inferPerplexityBaseUrlFromApiKey, resolvePerplexityBaseUrl } = __testing;
__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", () => {
@@ -52,20 +51,3 @@ 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();
});
});
+1 -65
View File
@@ -29,8 +29,6 @@ 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." }),
@@ -57,12 +55,6 @@ 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
@@ -227,37 +219,6 @@ 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 {
@@ -318,14 +279,11 @@ 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 === "brave" `${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}`,
? `${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 };
@@ -369,9 +327,6 @@ 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",
@@ -444,23 +399,6 @@ 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),
@@ -471,7 +409,6 @@ 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,
@@ -487,5 +424,4 @@ export function createWebSearchTool(options?: {
export const __testing = { export const __testing = {
inferPerplexityBaseUrlFromApiKey, inferPerplexityBaseUrlFromApiKey,
resolvePerplexityBaseUrl, resolvePerplexityBaseUrl,
normalizeFreshness,
} as const; } as const;
@@ -88,40 +88,6 @@ 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", () => {
@@ -154,27 +120,6 @@ 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");
+14 -54
View File
@@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest";
import { import {
chunkByNewline, chunkByNewline,
chunkMarkdownText, chunkMarkdownText,
chunkMarkdownTextWithMode,
chunkText, chunkText,
chunkTextWithMode, chunkTextWithMode,
resolveChunkMode, resolveChunkMode,
@@ -247,10 +246,10 @@ describe("chunkByNewline", () => {
expect(chunks).toEqual(["Line one", "Line two", "Line three"]); expect(chunks).toEqual(["Line one", "Line two", "Line three"]);
}); });
it("preserves blank lines by folding into the next chunk", () => { it("filters empty lines", () => {
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", "\n\nLine two", "\nLine three"]); expect(chunks).toEqual(["Line one", "Line two", "Line three"]);
}); });
it("trims whitespace from lines", () => { it("trims whitespace from lines", () => {
@@ -259,12 +258,6 @@ 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);
@@ -276,12 +269,6 @@ 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([]);
}); });
@@ -289,18 +276,6 @@ 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", () => {
@@ -317,26 +292,6 @@ 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");
@@ -349,16 +304,16 @@ describe("resolveChunkMode", () => {
expect(resolveChunkMode(cfg, "__internal__")).toBe("length"); expect(resolveChunkMode(cfg, "__internal__")).toBe("length");
}); });
it("supports provider-level overrides for slack", () => { it("supports provider-level overrides for bluebubbles", () => {
const cfg = { channels: { slack: { chunkMode: "newline" as const } } }; const cfg = { channels: { bluebubbles: { chunkMode: "newline" as const } } };
expect(resolveChunkMode(cfg, "slack")).toBe("newline"); expect(resolveChunkMode(cfg, "bluebubbles")).toBe("newline");
expect(resolveChunkMode(cfg, "discord")).toBe("length"); expect(resolveChunkMode(cfg, "discord")).toBe("length");
}); });
it("supports account-level overrides for slack", () => { it("supports account-level overrides for bluebubbles", () => {
const cfg = { const cfg = {
channels: { channels: {
slack: { bluebubbles: {
chunkMode: "length" as const, chunkMode: "length" as const,
accounts: { accounts: {
primary: { chunkMode: "newline" as const }, primary: { chunkMode: "newline" as const },
@@ -366,7 +321,12 @@ describe("resolveChunkMode", () => {
}, },
}, },
}; };
expect(resolveChunkMode(cfg, "slack", "primary")).toBe("newline"); expect(resolveChunkMode(cfg, "bluebubbles", "primary")).toBe("newline");
expect(resolveChunkMode(cfg, "slack", "other")).toBe("length"); expect(resolveChunkMode(cfg, "bluebubbles", "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");
}); });
}); });
+15 -92
View File
@@ -101,6 +101,8 @@ 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;
@@ -109,56 +111,25 @@ export function resolveChunkMode(
} }
/** /**
* Split text on newlines, trimming line whitespace. * Split text on newlines, filtering empty lines.
* Blank lines are folded into the next non-empty line as leading "\n" prefixes. * Lines exceeding maxLineLength are further split using length-based chunking.
* Long lines can be split by length (default) or kept intact via splitLongLines:false.
*/ */
export function chunkByNewline( export function chunkByNewline(text: string, maxLineLength: number): string[] {
text: string,
maxLineLength: number,
opts?: {
splitLongLines?: boolean;
trimLines?: boolean;
isSafeBreak?: (index: number) => boolean;
},
): string[] {
if (!text) return []; if (!text) return [];
if (maxLineLength <= 0) return text.trim() ? [text] : []; const lines = text.split("\n");
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) { if (!trimmed) continue; // skip empty lines
pendingBlankLines += 1;
continue; if (trimmed.length <= maxLineLength) {
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;
@@ -169,59 +140,11 @@ export function chunkByNewline(
*/ */
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") {
const chunks: string[] = []; return chunkByNewline(text, limit);
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];
+2 -2
View File
@@ -69,7 +69,7 @@ export function resolveBlockStreamingChunking(
}); });
const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk; const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk;
// When chunkMode is "newline", use newline-based streaming // BlueBubbles-only: if chunkMode is "newline", use newline-based streaming
const channelChunkMode = resolveChunkMode(cfg, providerKey, accountId); const channelChunkMode = resolveChunkMode(cfg, providerKey, accountId);
if (channelChunkMode === "newline") { if (channelChunkMode === "newline") {
// For newline mode: use very low minChars to flush quickly on newlines // For newline mode: use very low minChars to flush quickly on newlines
@@ -103,7 +103,7 @@ export function resolveBlockStreamingCoalescing(
): BlockStreamingCoalescing | undefined { ): BlockStreamingCoalescing | undefined {
const providerKey = normalizeChunkProvider(provider); const providerKey = normalizeChunkProvider(provider);
// When chunkMode is "newline", disable coalescing to send each line immediately // BlueBubbles-only: when chunkMode is "newline", disable coalescing to send each line immediately
const channelChunkMode = resolveChunkMode(cfg, providerKey, accountId); const channelChunkMode = resolveChunkMode(cfg, providerKey, accountId);
if (channelChunkMode === "newline") { if (channelChunkMode === "newline") {
return undefined; return undefined;
-151
View File
@@ -1,151 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../../config/config.js";
import { signalMessageActions } from "./signal.js";
const sendReactionSignal = vi.fn(async () => ({ ok: true }));
const removeReactionSignal = vi.fn(async () => ({ ok: true }));
vi.mock("../../../signal/send-reactions.js", () => ({
sendReactionSignal: (...args: unknown[]) => sendReactionSignal(...args),
removeReactionSignal: (...args: unknown[]) => removeReactionSignal(...args),
}));
describe("signalMessageActions", () => {
it("returns no actions when no configured accounts exist", () => {
const cfg = {} as ClawdbotConfig;
expect(signalMessageActions.listActions({ cfg })).toEqual([]);
});
it("hides react when reactions are disabled", () => {
const cfg = {
channels: { signal: { account: "+15550001111", actions: { reactions: false } } },
} as ClawdbotConfig;
expect(signalMessageActions.listActions({ cfg })).toEqual(["send"]);
});
it("enables react when at least one account allows reactions", () => {
const cfg = {
channels: {
signal: {
actions: { reactions: false },
accounts: {
work: { account: "+15550001111", actions: { reactions: true } },
},
},
},
} as ClawdbotConfig;
expect(signalMessageActions.listActions({ cfg })).toEqual(["send", "react"]);
});
it("skips send for plugin dispatch", () => {
expect(signalMessageActions.supportsAction?.({ action: "send" })).toBe(false);
expect(signalMessageActions.supportsAction?.({ action: "react" })).toBe(true);
});
it("blocks reactions when action gate is disabled", async () => {
const cfg = {
channels: { signal: { account: "+15550001111", actions: { reactions: false } } },
} as ClawdbotConfig;
await expect(
signalMessageActions.handleAction({
action: "react",
params: { to: "+15550001111", messageId: "123", emoji: "✅" },
cfg,
accountId: undefined,
}),
).rejects.toThrow(/actions\.reactions/);
});
it("uses account-level actions when enabled", async () => {
sendReactionSignal.mockClear();
const cfg = {
channels: {
signal: {
actions: { reactions: false },
accounts: {
work: { account: "+15550001111", actions: { reactions: true } },
},
},
},
} as ClawdbotConfig;
await signalMessageActions.handleAction({
action: "react",
params: { to: "+15550001111", messageId: "123", emoji: "👍" },
cfg,
accountId: "work",
});
expect(sendReactionSignal).toHaveBeenCalledWith("+15550001111", 123, "👍", {
accountId: "work",
});
});
it("normalizes uuid recipients", async () => {
sendReactionSignal.mockClear();
const cfg = {
channels: { signal: { account: "+15550001111" } },
} as ClawdbotConfig;
await signalMessageActions.handleAction({
action: "react",
params: {
recipient: "uuid:123e4567-e89b-12d3-a456-426614174000",
messageId: "123",
emoji: "🔥",
},
cfg,
accountId: undefined,
});
expect(sendReactionSignal).toHaveBeenCalledWith(
"123e4567-e89b-12d3-a456-426614174000",
123,
"🔥",
{ accountId: undefined },
);
});
it("requires targetAuthor for group reactions", async () => {
const cfg = {
channels: { signal: { account: "+15550001111" } },
} as ClawdbotConfig;
await expect(
signalMessageActions.handleAction({
action: "react",
params: { to: "signal:group:group-id", messageId: "123", emoji: "✅" },
cfg,
accountId: undefined,
}),
).rejects.toThrow(/targetAuthor/);
});
it("passes groupId and targetAuthor for group reactions", async () => {
sendReactionSignal.mockClear();
const cfg = {
channels: { signal: { account: "+15550001111" } },
} as ClawdbotConfig;
await signalMessageActions.handleAction({
action: "react",
params: {
to: "signal:group:group-id",
targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000",
messageId: "123",
emoji: "✅",
},
cfg,
accountId: undefined,
});
expect(sendReactionSignal).toHaveBeenCalledWith("", 123, "✅", {
accountId: undefined,
groupId: "group-id",
targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000",
targetAuthorUuid: undefined,
});
});
});
-130
View File
@@ -1,130 +0,0 @@
import { createActionGate, jsonResult, readStringParam } from "../../../agents/tools/common.js";
import { listEnabledSignalAccounts, resolveSignalAccount } from "../../../signal/accounts.js";
import { resolveSignalReactionLevel } from "../../../signal/reaction-level.js";
import { sendReactionSignal, removeReactionSignal } from "../../../signal/send-reactions.js";
import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../types.js";
const providerId = "signal";
const GROUP_PREFIX = "group:";
function normalizeSignalReactionRecipient(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return trimmed;
const withoutSignal = trimmed.replace(/^signal:/i, "").trim();
if (!withoutSignal) return withoutSignal;
if (withoutSignal.toLowerCase().startsWith("uuid:")) {
return withoutSignal.slice("uuid:".length).trim();
}
return withoutSignal;
}
function resolveSignalReactionTarget(raw: string): { recipient?: string; groupId?: string } {
const trimmed = raw.trim();
if (!trimmed) return {};
const withoutSignal = trimmed.replace(/^signal:/i, "").trim();
if (!withoutSignal) return {};
if (withoutSignal.toLowerCase().startsWith(GROUP_PREFIX)) {
const groupId = withoutSignal.slice(GROUP_PREFIX.length).trim();
return groupId ? { groupId } : {};
}
return { recipient: normalizeSignalReactionRecipient(withoutSignal) };
}
export const signalMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
const accounts = listEnabledSignalAccounts(cfg);
if (accounts.length === 0) return [];
const configuredAccounts = accounts.filter((account) => account.configured);
if (configuredAccounts.length === 0) return [];
const actions = new Set<ChannelMessageActionName>(["send"]);
const reactionsEnabled = configuredAccounts.some((account) =>
createActionGate(account.config.actions)("reactions"),
);
if (reactionsEnabled) {
actions.add("react");
}
return Array.from(actions);
},
supportsAction: ({ action }) => action !== "send",
handleAction: async ({ action, params, cfg, accountId }) => {
if (action === "send") {
throw new Error("Send should be handled by outbound, not actions handler.");
}
if (action === "react") {
// Check reaction level first
const reactionLevelInfo = resolveSignalReactionLevel({
cfg,
accountId: accountId ?? undefined,
});
if (!reactionLevelInfo.agentReactionsEnabled) {
throw new Error(
`Signal agent reactions disabled (reactionLevel="${reactionLevelInfo.level}"). ` +
`Set channels.signal.reactionLevel to "minimal" or "extensive" to enable.`,
);
}
// Also check the action gate for backward compatibility
const actionConfig = resolveSignalAccount({ cfg, accountId }).config.actions;
const isActionEnabled = createActionGate(actionConfig);
if (!isActionEnabled("reactions")) {
throw new Error("Signal reactions are disabled via actions.reactions.");
}
const recipientRaw =
readStringParam(params, "recipient") ??
readStringParam(params, "to", {
required: true,
label: "recipient (UUID, phone number, or group)",
});
const target = resolveSignalReactionTarget(recipientRaw);
if (!target.recipient && !target.groupId) {
throw new Error("recipient or group required");
}
const messageId = readStringParam(params, "messageId", {
required: true,
label: "messageId (timestamp)",
});
const targetAuthor = readStringParam(params, "targetAuthor");
const targetAuthorUuid = readStringParam(params, "targetAuthorUuid");
if (target.groupId && !targetAuthor && !targetAuthorUuid) {
throw new Error("targetAuthor or targetAuthorUuid required for group reactions.");
}
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
const remove = typeof params.remove === "boolean" ? params.remove : undefined;
const timestamp = parseInt(messageId, 10);
if (!Number.isFinite(timestamp)) {
throw new Error(`Invalid messageId: ${messageId}. Expected numeric timestamp.`);
}
if (remove) {
if (!emoji) throw new Error("Emoji required to remove reaction.");
await removeReactionSignal(target.recipient ?? "", timestamp, emoji, {
accountId: accountId ?? undefined,
groupId: target.groupId,
targetAuthor,
targetAuthorUuid,
});
return jsonResult({ ok: true, removed: emoji });
}
if (!emoji) throw new Error("Emoji required to add reaction.");
await sendReactionSignal(target.recipient ?? "", timestamp, emoji, {
accountId: accountId ?? undefined,
groupId: target.groupId,
targetAuthor,
targetAuthorUuid,
});
return jsonResult({ ok: true, added: emoji });
}
throw new Error(`Action ${action} not supported for ${providerId}.`);
},
};
@@ -1,32 +0,0 @@
import { describe, expect, it } from "vitest";
import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./signal.js";
describe("signal target normalization", () => {
it("normalizes uuid targets by stripping uuid:", () => {
expect(normalizeSignalMessagingTarget("uuid:123E4567-E89B-12D3-A456-426614174000")).toBe(
"123e4567-e89b-12d3-a456-426614174000",
);
});
it("normalizes signal:uuid targets", () => {
expect(normalizeSignalMessagingTarget("signal:uuid:123E4567-E89B-12D3-A456-426614174000")).toBe(
"123e4567-e89b-12d3-a456-426614174000",
);
});
it("accepts uuid prefixes for target detection", () => {
expect(looksLikeSignalTargetId("uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true);
expect(looksLikeSignalTargetId("signal:uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true);
});
it("accepts compact UUIDs for target detection", () => {
expect(looksLikeSignalTargetId("123e4567e89b12d3a456426614174000")).toBe(true);
expect(looksLikeSignalTargetId("uuid:123e4567e89b12d3a456426614174000")).toBe(true);
});
it("rejects invalid uuid prefixes", () => {
expect(looksLikeSignalTargetId("uuid:")).toBe(false);
expect(looksLikeSignalTargetId("uuid:not-a-uuid")).toBe(false);
});
});
-18
View File
@@ -19,30 +19,12 @@ export function normalizeSignalMessagingTarget(raw: string): string | undefined
const id = normalized.slice("u:".length).trim(); const id = normalized.slice("u:".length).trim();
return id ? `username:${id}`.toLowerCase() : undefined; return id ? `username:${id}`.toLowerCase() : undefined;
} }
if (lower.startsWith("uuid:")) {
const id = normalized.slice("uuid:".length).trim();
return id ? id.toLowerCase() : undefined;
}
return normalized.toLowerCase(); return normalized.toLowerCase();
} }
// UUID pattern for signal-cli recipient IDs
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const UUID_COMPACT_PATTERN = /^[0-9a-f]{32}$/i;
export function looksLikeSignalTargetId(raw: string): boolean { export function looksLikeSignalTargetId(raw: string): boolean {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) return false; if (!trimmed) return false;
if (/^(signal:)?(group:|username:|u:)/i.test(trimmed)) return true; if (/^(signal:)?(group:|username:|u:)/i.test(trimmed)) return true;
if (/^(signal:)?uuid:/i.test(trimmed)) {
const stripped = trimmed
.replace(/^signal:/i, "")
.replace(/^uuid:/i, "")
.trim();
if (!stripped) return false;
return UUID_PATTERN.test(stripped) || UUID_COMPACT_PATTERN.test(stripped);
}
// Accept UUIDs (used by signal-cli for reactions)
if (UUID_PATTERN.test(trimmed) || UUID_COMPACT_PATTERN.test(trimmed)) return true;
return /^\+?\d{3,}$/.test(trimmed); return /^\+?\d{3,}$/.test(trimmed);
} }
@@ -6,7 +6,6 @@ import type { ChannelOutboundAdapter } from "../types.js";
export const imessageOutbound: ChannelOutboundAdapter = { export const imessageOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct", deliveryMode: "direct",
chunker: chunkText, chunker: chunkText,
chunkerMode: "text",
textChunkLimit: 4000, textChunkLimit: 4000,
sendText: async ({ cfg, to, text, accountId, deps }) => { sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = deps?.sendIMessage ?? sendMessageIMessage; const send = deps?.sendIMessage ?? sendMessageIMessage;
-1
View File
@@ -6,7 +6,6 @@ import type { ChannelOutboundAdapter } from "../types.js";
export const signalOutbound: ChannelOutboundAdapter = { export const signalOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct", deliveryMode: "direct",
chunker: chunkText, chunker: chunkText,
chunkerMode: "text",
textChunkLimit: 4000, textChunkLimit: 4000,
sendText: async ({ cfg, to, text, accountId, deps }) => { sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = deps?.sendSignal ?? sendMessageSignal; const send = deps?.sendSignal ?? sendMessageSignal;
@@ -21,7 +21,6 @@ function parseThreadId(threadId?: string | number | null) {
export const telegramOutbound: ChannelOutboundAdapter = { export const telegramOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct", deliveryMode: "direct",
chunker: markdownToTelegramHtmlChunks, chunker: markdownToTelegramHtmlChunks,
chunkerMode: "markdown",
textChunkLimit: 4000, textChunkLimit: 4000,
sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => { sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => {
const send = deps?.sendTelegram ?? sendMessageTelegram; const send = deps?.sendTelegram ?? sendMessageTelegram;
@@ -8,7 +8,6 @@ import { missingTargetError } from "../../../infra/outbound/target-errors.js";
export const whatsappOutbound: ChannelOutboundAdapter = { export const whatsappOutbound: ChannelOutboundAdapter = {
deliveryMode: "gateway", deliveryMode: "gateway",
chunker: chunkText, chunker: chunkText,
chunkerMode: "text",
textChunkLimit: 4000, textChunkLimit: 4000,
pollMaxOptions: 12, pollMaxOptions: 12,
resolveTarget: ({ to, allowFrom, mode }) => { resolveTarget: ({ to, allowFrom, mode }) => {
-1
View File
@@ -84,7 +84,6 @@ export type ChannelOutboundContext = {
export type ChannelOutboundAdapter = { export type ChannelOutboundAdapter = {
deliveryMode: "direct" | "gateway" | "hybrid"; deliveryMode: "direct" | "gateway" | "hybrid";
chunker?: ((text: string, limit: number) => string[]) | null; chunker?: ((text: string, limit: number) => string[]) | null;
chunkerMode?: "text" | "markdown";
textChunkLimit?: number; textChunkLimit?: number;
pollMaxOptions?: number; pollMaxOptions?: number;
resolveTarget?: (params: { resolveTarget?: (params: {
-22
View File
@@ -67,28 +67,6 @@ describe("cli program (smoke)", () => {
expect(messageCommand).toHaveBeenCalled(); expect(messageCommand).toHaveBeenCalled();
}); });
it("runs message react with signal author fields", async () => {
const program = buildProgram();
await program.parseAsync(
[
"message",
"react",
"--channel",
"signal",
"--target",
"signal:group:abc123",
"--message-id",
"1737630212345",
"--emoji",
"✅",
"--target-author-uuid",
"123e4567-e89b-12d3-a456-426614174000",
],
{ from: "user" },
);
expect(messageCommand).toHaveBeenCalled();
});
it("runs status command", async () => { it("runs status command", async () => {
const program = buildProgram(); const program = buildProgram();
await program.parseAsync(["status"], { from: "user" }); await program.parseAsync(["status"], { from: "user" });
@@ -13,8 +13,6 @@ export function registerMessageReactionsCommands(message: Command, helpers: Mess
.option("--remove", "Remove reaction", false) .option("--remove", "Remove reaction", false)
.option("--participant <id>", "WhatsApp reaction participant") .option("--participant <id>", "WhatsApp reaction participant")
.option("--from-me", "WhatsApp reaction fromMe", false) .option("--from-me", "WhatsApp reaction fromMe", false)
.option("--target-author <id>", "Signal reaction target author (uuid or phone)")
.option("--target-author-uuid <uuid>", "Signal reaction target author uuid")
.action(async (opts) => { .action(async (opts) => {
await helpers.runMessageAction("react", opts); await helpers.runMessageAction("react", opts);
}); });
+28 -29
View File
@@ -1,37 +1,36 @@
import { describe, expect, it } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { rewriteUpdateFlagArgv } from "./run-main.js"; const registerSubCliByName = vi.fn(async () => true);
const parseAsync = vi.fn(async () => undefined);
const buildProgram = vi.fn(() => ({ parseAsync }));
describe("rewriteUpdateFlagArgv", () => { vi.mock("../infra/dotenv.js", () => ({ loadDotEnv: vi.fn() }));
it("leaves argv unchanged when --update is absent", () => { vi.mock("../infra/env.js", () => ({ normalizeEnv: vi.fn() }));
const argv = ["node", "entry.js", "status"]; vi.mock("../infra/path-env.js", () => ({ ensureClawdbotCliOnPath: vi.fn() }));
expect(rewriteUpdateFlagArgv(argv)).toBe(argv); vi.mock("../infra/runtime-guard.js", () => ({ assertSupportedRuntime: vi.fn() }));
vi.mock("../infra/errors.js", () => ({ formatUncaughtError: vi.fn(() => "error") }));
vi.mock("../infra/unhandled-rejections.js", () => ({ installUnhandledRejectionHandler: vi.fn() }));
vi.mock("../logging.js", () => ({ enableConsoleCapture: vi.fn() }));
vi.mock("./program.js", () => ({ buildProgram }));
vi.mock("./program/register.subclis.js", () => ({ registerSubCliByName }));
vi.mock("./route.js", () => ({ tryRouteCli: vi.fn(async () => false) }));
const { runCli } = await import("./run-main.js");
describe("runCli", () => {
afterEach(() => {
registerSubCliByName.mockClear();
parseAsync.mockClear();
buildProgram.mockClear();
}); });
it("rewrites --update into the update command", () => { it("registers the primary subcommand before parsing", async () => {
expect(rewriteUpdateFlagArgv(["node", "entry.js", "--update"])).toEqual([ const argv = ["/usr/bin/node-22", "/opt/clawdbot/entry.js", "gateway", "--port", "18789"];
"node",
"entry.js",
"update",
]);
});
it("preserves global flags that appear before --update", () => { await runCli(argv);
expect(rewriteUpdateFlagArgv(["node", "entry.js", "--profile", "p", "--update"])).toEqual([
"node",
"entry.js",
"--profile",
"p",
"update",
]);
});
it("keeps update options after the rewritten command", () => { expect(registerSubCliByName).toHaveBeenCalledTimes(1);
expect(rewriteUpdateFlagArgv(["node", "entry.js", "--update", "--json"])).toEqual([ expect(registerSubCliByName).toHaveBeenCalledWith(expect.any(Object), "gateway");
"node", expect(parseAsync).toHaveBeenCalledWith(argv);
"entry.js",
"update",
"--json",
]);
}); });
}); });
-2
View File
@@ -108,8 +108,6 @@ export type DiscordAccountConfig = {
groupPolicy?: GroupPolicy; groupPolicy?: GroupPolicy;
/** Outbound text chunk size (chars). Default: 2000. */ /** Outbound text chunk size (chars). Default: 2000. */
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. */
-2
View File
@@ -78,8 +78,6 @@ export type GoogleChatAccountConfig = {
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";
blockStreaming?: boolean; blockStreaming?: boolean;
/** Merge streamed block replies before sending. */ /** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig; blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
-2
View File
@@ -54,8 +54,6 @@ export type IMessageAccountConfig = {
mediaMaxMb?: number; mediaMaxMb?: number;
/** 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";
blockStreaming?: boolean; blockStreaming?: boolean;
/** Merge streamed block replies before sending. */ /** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig; blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
-2
View File
@@ -72,8 +72,6 @@ export type MSTeamsConfig = {
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";
/** Merge streamed block replies before sending. */ /** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig; blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
/** /**
-16
View File
@@ -8,7 +8,6 @@ import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
import type { DmConfig } from "./types.messages.js"; import type { DmConfig } from "./types.messages.js";
export type SignalReactionNotificationMode = "off" | "own" | "all" | "allowlist"; export type SignalReactionNotificationMode = "off" | "own" | "all" | "allowlist";
export type SignalReactionLevel = "off" | "ack" | "minimal" | "extensive";
export type SignalAccountConfig = { export type SignalAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */ /** Optional display name for this account (used in CLI/UI lists). */
@@ -57,8 +56,6 @@ export type SignalAccountConfig = {
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";
blockStreaming?: boolean; blockStreaming?: boolean;
/** Merge streamed block replies before sending. */ /** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig; blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
@@ -67,19 +64,6 @@ export type SignalAccountConfig = {
reactionNotifications?: SignalReactionNotificationMode; reactionNotifications?: SignalReactionNotificationMode;
/** Allowlist for reaction notifications when mode is allowlist. */ /** Allowlist for reaction notifications when mode is allowlist. */
reactionAllowlist?: Array<string | number>; reactionAllowlist?: Array<string | number>;
/** Action toggles for message tool capabilities. */
actions?: {
/** Enable/disable sending reactions via message tool (default: true). */
reactions?: boolean;
};
/**
* Controls agent reaction behavior:
* - "off": No reactions
* - "ack": Only automatic ack reactions (👀 when processing)
* - "minimal": Agent can react sparingly (default)
* - "extensive": Agent can react liberally
*/
reactionLevel?: SignalReactionLevel;
/** Heartbeat visibility settings for this channel. */ /** Heartbeat visibility settings for this channel. */
heartbeat?: ChannelHeartbeatVisibilityConfig; heartbeat?: ChannelHeartbeatVisibilityConfig;
}; };
-2
View File
@@ -116,8 +116,6 @@ export type SlackAccountConfig = {
/** Per-DM config overrides keyed by user ID. */ /** Per-DM config overrides keyed by user ID. */
dms?: Record<string, DmConfig>; dms?: Record<string, DmConfig>;
textChunkLimit?: number; textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
blockStreaming?: boolean; blockStreaming?: boolean;
/** Merge streamed block replies before sending. */ /** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig; blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
-2
View File
@@ -80,8 +80,6 @@ export type TelegramAccountConfig = {
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;
/** Chunking config for draft streaming in `streamMode: "block"`. */ /** Chunking config for draft streaming in `streamMode: "block"`. */
-4
View File
@@ -55,8 +55,6 @@ export type WhatsAppConfig = {
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";
/** Maximum media file size in MB. Default: 50. */ /** Maximum media file size in MB. Default: 50. */
mediaMaxMb?: number; mediaMaxMb?: number;
/** Disable block streaming for this account. */ /** Disable block streaming for this account. */
@@ -124,8 +122,6 @@ export type WhatsAppAccountConfig = {
/** Per-DM config overrides keyed by user ID. */ /** Per-DM config overrides keyed by user ID. */
dms?: Record<string, DmConfig>; dms?: Record<string, DmConfig>;
textChunkLimit?: number; textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
mediaMaxMb?: number; mediaMaxMb?: number;
blockStreaming?: boolean; blockStreaming?: boolean;
/** Merge streamed block replies before sending. */ /** Merge streamed block replies before sending. */
-14
View File
@@ -102,7 +102,6 @@ export const TelegramAccountSchemaBase = 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(),
draftChunk: BlockStreamingChunkSchema.optional(), draftChunk: BlockStreamingChunkSchema.optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
@@ -213,7 +212,6 @@ export const DiscordAccountSchema = 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(),
maxLinesPerMessage: z.number().int().positive().optional(), maxLinesPerMessage: z.number().int().positive().optional(),
@@ -312,7 +310,6 @@ export const GoogleChatAccountSchema = 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(),
@@ -404,7 +401,6 @@ export const SlackAccountSchema = 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(),
@@ -498,19 +494,11 @@ export const SignalAccountSchemaBase = 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().int().positive().optional(), mediaMaxMb: z.number().int().positive().optional(),
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(), reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(), reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
actions: z
.object({
reactions: z.boolean().optional(),
})
.strict()
.optional(),
reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(),
heartbeat: ChannelHeartbeatVisibilitySchema, heartbeat: ChannelHeartbeatVisibilitySchema,
}) })
.strict(); .strict();
@@ -559,7 +547,6 @@ export const IMessageAccountSchemaBase = z
includeAttachments: z.boolean().optional(), includeAttachments: z.boolean().optional(),
mediaMaxMb: z.number().int().positive().optional(), mediaMaxMb: z.number().int().positive().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(),
groups: z groups: z
@@ -718,7 +705,6 @@ export const MSTeamsConfigSchema = z
groupAllowFrom: z.array(z.string()).optional(), groupAllowFrom: z.array(z.string()).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(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
mediaAllowHosts: z.array(z.string()).optional(), mediaAllowHosts: z.array(z.string()).optional(),
requireMention: z.boolean().optional(), requireMention: z.boolean().optional(),
@@ -30,7 +30,6 @@ export const WhatsAppAccountSchema = 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(),
mediaMaxMb: z.number().int().positive().optional(), mediaMaxMb: z.number().int().positive().optional(),
blockStreaming: z.boolean().optional(), blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
@@ -86,7 +85,6 @@ export const WhatsAppConfigSchema = 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(),
mediaMaxMb: z.number().int().positive().optional().default(50), mediaMaxMb: z.number().int().positive().optional().default(50),
blockStreaming: z.boolean().optional(), blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
+1 -11
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { chunkDiscordText, chunkDiscordTextWithMode } from "./chunk.js"; import { chunkDiscordText } from "./chunk.js";
function countLines(text: string) { function countLines(text: string) {
return text.split("\n").length; return text.split("\n").length;
@@ -51,16 +51,6 @@ describe("chunkDiscordText", () => {
expect(chunks.at(-1)).toContain("Done."); expect(chunks.at(-1)).toContain("Done.");
}); });
it("keeps fenced blocks intact when chunkMode is newline", () => {
const text = "```js\nconst a = 1;\nconst b = 2;\n```\nAfter";
const chunks = chunkDiscordTextWithMode(text, {
maxChars: 2000,
maxLines: 50,
chunkMode: "newline",
});
expect(chunks).toEqual(["```js\nconst a = 1;\nconst b = 2;\n```", "After"]);
});
it("reserves space for closing fences when chunking", () => { it("reserves space for closing fences when chunking", () => {
const body = "a".repeat(120); const body = "a".repeat(120);
const text = `\`\`\`txt\n${body}\n\`\`\``; const text = `\`\`\`txt\n${body}\n\`\`\``;
-27
View File
@@ -1,5 +1,3 @@
import { chunkMarkdownTextWithMode, type ChunkMode } from "../auto-reply/chunk.js";
export type ChunkDiscordTextOpts = { export type ChunkDiscordTextOpts = {
/** Max characters per Discord message. Default: 2000. */ /** Max characters per Discord message. Default: 2000. */
maxChars?: number; maxChars?: number;
@@ -180,31 +178,6 @@ export function chunkDiscordText(text: string, opts: ChunkDiscordTextOpts = {}):
return rebalanceReasoningItalics(text, chunks); return rebalanceReasoningItalics(text, chunks);
} }
export function chunkDiscordTextWithMode(
text: string,
opts: ChunkDiscordTextOpts & { chunkMode?: ChunkMode },
): string[] {
const chunkMode = opts.chunkMode ?? "length";
if (chunkMode !== "newline") {
return chunkDiscordText(text, opts);
}
const lineChunks = chunkMarkdownTextWithMode(
text,
Math.max(1, Math.floor(opts.maxChars ?? DEFAULT_MAX_CHARS)),
"newline",
);
const chunks: string[] = [];
for (const line of lineChunks) {
const nested = chunkDiscordText(line, opts);
if (!nested.length && line) {
chunks.push(line);
continue;
}
chunks.push(...nested);
}
return chunks;
}
// Keep italics intact for reasoning payloads that are wrapped once with `_…_`. // Keep italics intact for reasoning payloads that are wrapped once with `_…_`.
// When Discord chunking splits the message, we close italics at the end of // When Discord chunking splits the message, we close italics at the end of
// each chunk and reopen at the start of the next so every chunk renders // each chunk and reopen at the start of the next so every chunk renders
@@ -1,4 +1,3 @@
import { ChannelType } from "@buape/carbon";
import { resolveAckReaction, resolveHumanDelayConfig } from "../../agents/identity.js"; import { resolveAckReaction, resolveHumanDelayConfig } from "../../agents/identity.js";
import { import {
removeAckReactionAfterReply, removeAckReactionAfterReply,
@@ -22,7 +21,6 @@ import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-di
import type { ReplyPayload } from "../../auto-reply/types.js"; import type { ReplyPayload } from "../../auto-reply/types.js";
import { recordInboundSession } from "../../channels/session.js"; import { recordInboundSession } from "../../channels/session.js";
import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js"; import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js";
import { resolveChunkMode } from "../../auto-reply/chunk.js";
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
import { danger, logVerbose, shouldLogVerbose } from "../../globals.js"; import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
import { buildAgentSessionKey } from "../../routing/resolve-route.js"; import { buildAgentSessionKey } from "../../routing/resolve-route.js";
@@ -131,15 +129,6 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
senderDisplay && senderTag && senderDisplay !== senderTag senderDisplay && senderTag && senderDisplay !== senderTag
? `${senderDisplay} (${senderTag})` ? `${senderDisplay} (${senderTag})`
: (senderDisplay ?? senderTag ?? author.id); : (senderDisplay ?? senderTag ?? author.id);
const isForumParent =
threadParentType === ChannelType.GuildForum || threadParentType === ChannelType.GuildMedia;
const forumParentSlug =
isForumParent && threadParentName ? normalizeDiscordSlug(threadParentName) : "";
const threadChannelId = threadChannel?.id;
const isForumStarter = Boolean(
threadChannelId && isForumParent && forumParentSlug && message.id === threadChannelId,
);
const forumContextLine = isForumStarter ? `[Forum parent: #${forumParentSlug}]` : null;
const groupChannel = isGuildMessage && displayChannelSlug ? `#${displayChannelSlug}` : undefined; const groupChannel = isGuildMessage && displayChannelSlug ? `#${displayChannelSlug}` : undefined;
const groupSubject = isDirectMessage ? undefined : groupChannel; const groupSubject = isDirectMessage ? undefined : groupChannel;
const channelDescription = channelInfo?.topic?.trim(); const channelDescription = channelInfo?.topic?.trim();
@@ -193,9 +182,6 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
if (replyContext) { if (replyContext) {
combinedBody = `[Replied message - for context]\n${replyContext}\n\n${combinedBody}`; combinedBody = `[Replied message - for context]\n${replyContext}\n\n${combinedBody}`;
} }
if (forumContextLine) {
combinedBody = `${combinedBody}\n${forumContextLine}`;
}
let threadStarterBody: string | undefined; let threadStarterBody: string | undefined;
let threadLabel: string | undefined; let threadLabel: string | undefined;
@@ -349,7 +335,6 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
textLimit, textLimit,
maxLinesPerMessage: discordConfig?.maxLinesPerMessage, maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
tableMode, tableMode,
chunkMode: resolveChunkMode(cfg, "discord", accountId),
}); });
replyReference.markSent(); replyReference.markSent();
}, },
+5 -11
View File
@@ -12,7 +12,7 @@ import {
import { ApplicationCommandOptionType, ButtonStyle } from "discord-api-types/v10"; import { ApplicationCommandOptionType, ButtonStyle } from "discord-api-types/v10";
import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../agents/identity.js"; import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../agents/identity.js";
import { resolveChunkMode, resolveTextChunkLimit } from "../../auto-reply/chunk.js"; import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
import { import {
buildCommandTextFromArgs, buildCommandTextFromArgs,
findCommandByNativeName, findCommandByNativeName,
@@ -40,7 +40,7 @@ import {
} from "../../pairing/pairing-store.js"; } from "../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js";
import { loadWebMedia } from "../../web/media.js"; import { loadWebMedia } from "../../web/media.js";
import { chunkDiscordTextWithMode } from "../chunk.js"; import { chunkDiscordText } from "../chunk.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
import { import {
allowListMatches, allowListMatches,
@@ -767,7 +767,6 @@ async function dispatchDiscordCommandInteraction(params: {
}), }),
maxLinesPerMessage: discordConfig?.maxLinesPerMessage, maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
preferFollowUp: preferFollowUp || didReply, preferFollowUp: preferFollowUp || didReply,
chunkMode: resolveChunkMode(cfg, "discord", accountId),
}); });
} catch (error) { } catch (error) {
if (isDiscordUnknownInteraction(error)) { if (isDiscordUnknownInteraction(error)) {
@@ -798,9 +797,8 @@ async function deliverDiscordInteractionReply(params: {
textLimit: number; textLimit: number;
maxLinesPerMessage?: number; maxLinesPerMessage?: number;
preferFollowUp: boolean; preferFollowUp: boolean;
chunkMode: "length" | "newline";
}) { }) {
const { interaction, payload, textLimit, maxLinesPerMessage, preferFollowUp, chunkMode } = params; const { interaction, payload, textLimit, maxLinesPerMessage, preferFollowUp } = params;
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = payload.text ?? ""; const text = payload.text ?? "";
@@ -840,12 +838,10 @@ async function deliverDiscordInteractionReply(params: {
}; };
}), }),
); );
const chunks = chunkDiscordTextWithMode(text, { const chunks = chunkDiscordText(text, {
maxChars: textLimit, maxChars: textLimit,
maxLines: maxLinesPerMessage, maxLines: maxLinesPerMessage,
chunkMode,
}); });
if (!chunks.length && text) chunks.push(text);
const caption = chunks[0] ?? ""; const caption = chunks[0] ?? "";
await sendMessage(caption, media); await sendMessage(caption, media);
for (const chunk of chunks.slice(1)) { for (const chunk of chunks.slice(1)) {
@@ -856,12 +852,10 @@ async function deliverDiscordInteractionReply(params: {
} }
if (!text.trim()) return; if (!text.trim()) return;
const chunks = chunkDiscordTextWithMode(text, { const chunks = chunkDiscordText(text, {
maxChars: textLimit, maxChars: textLimit,
maxLines: maxLinesPerMessage, maxLines: maxLinesPerMessage,
chunkMode,
}); });
if (!chunks.length && text) chunks.push(text);
for (const chunk of chunks) { for (const chunk of chunks) {
if (!chunk.trim()) continue; if (!chunk.trim()) continue;
await sendMessage(chunk); await sendMessage(chunk);
+3 -9
View File
@@ -1,11 +1,10 @@
import type { RequestClient } from "@buape/carbon"; import type { RequestClient } from "@buape/carbon";
import type { ChunkMode } from "../../auto-reply/chunk.js";
import type { ReplyPayload } from "../../auto-reply/types.js"; import type { ReplyPayload } from "../../auto-reply/types.js";
import type { MarkdownTableMode } from "../../config/types.base.js"; import type { MarkdownTableMode } from "../../config/types.base.js";
import { convertMarkdownTables } from "../../markdown/tables.js"; import { convertMarkdownTables } from "../../markdown/tables.js";
import type { RuntimeEnv } from "../../runtime.js"; import type { RuntimeEnv } from "../../runtime.js";
import { chunkDiscordTextWithMode } from "../chunk.js"; import { chunkDiscordText } from "../chunk.js";
import { sendMessageDiscord } from "../send.js"; import { sendMessageDiscord } from "../send.js";
export async function deliverDiscordReply(params: { export async function deliverDiscordReply(params: {
@@ -19,7 +18,6 @@ export async function deliverDiscordReply(params: {
maxLinesPerMessage?: number; maxLinesPerMessage?: number;
replyToId?: string; replyToId?: string;
tableMode?: MarkdownTableMode; tableMode?: MarkdownTableMode;
chunkMode?: ChunkMode;
}) { }) {
const chunkLimit = Math.min(params.textLimit, 2000); const chunkLimit = Math.min(params.textLimit, 2000);
for (const payload of params.replies) { for (const payload of params.replies) {
@@ -32,14 +30,10 @@ export async function deliverDiscordReply(params: {
if (mediaList.length === 0) { if (mediaList.length === 0) {
let isFirstChunk = true; let isFirstChunk = true;
const mode = params.chunkMode ?? "length"; for (const chunk of chunkDiscordText(text, {
const chunks = chunkDiscordTextWithMode(text, {
maxChars: chunkLimit, maxChars: chunkLimit,
maxLines: params.maxLinesPerMessage, maxLines: params.maxLinesPerMessage,
chunkMode: mode, })) {
});
if (!chunks.length && text) chunks.push(text);
for (const chunk of chunks) {
const trimmed = chunk.trim(); const trimmed = chunk.trim();
if (!trimmed) continue; if (!trimmed) continue;
await sendMessageDiscord(params.target, trimmed, { await sendMessageDiscord(params.target, trimmed, {
-4
View File
@@ -1,6 +1,5 @@
import type { RequestClient } from "@buape/carbon"; import type { RequestClient } from "@buape/carbon";
import { Routes } from "discord-api-types/v10"; import { Routes } from "discord-api-types/v10";
import { resolveChunkMode } from "../auto-reply/chunk.js";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
import { recordChannelActivity } from "../infra/channel-activity.js"; import { recordChannelActivity } from "../infra/channel-activity.js";
@@ -46,7 +45,6 @@ export async function sendMessageDiscord(
channel: "discord", channel: "discord",
accountId: accountInfo.accountId, accountId: accountInfo.accountId,
}); });
const chunkMode = resolveChunkMode(cfg, "discord", accountInfo.accountId);
const textWithTables = convertMarkdownTables(text ?? "", tableMode); const textWithTables = convertMarkdownTables(text ?? "", tableMode);
const { token, rest, request } = createDiscordClient(opts, cfg); const { token, rest, request } = createDiscordClient(opts, cfg);
const recipient = parseRecipient(to); const recipient = parseRecipient(to);
@@ -63,7 +61,6 @@ export async function sendMessageDiscord(
request, request,
accountInfo.config.maxLinesPerMessage, accountInfo.config.maxLinesPerMessage,
opts.embeds, opts.embeds,
chunkMode,
); );
} else { } else {
result = await sendDiscordText( result = await sendDiscordText(
@@ -74,7 +71,6 @@ export async function sendMessageDiscord(
request, request,
accountInfo.config.maxLinesPerMessage, accountInfo.config.maxLinesPerMessage,
opts.embeds, opts.embeds,
chunkMode,
); );
} }
} catch (err) { } catch (err) {
+4 -20
View File
@@ -9,8 +9,7 @@ import { createDiscordRetryRunner, type RetryRunner } from "../infra/retry-polic
import { normalizePollDurationHours, normalizePollInput, type PollInput } from "../polls.js"; import { normalizePollDurationHours, normalizePollInput, type PollInput } from "../polls.js";
import { loadWebMedia } from "../web/media.js"; import { loadWebMedia } from "../web/media.js";
import { resolveDiscordAccount } from "./accounts.js"; import { resolveDiscordAccount } from "./accounts.js";
import type { ChunkMode } from "../auto-reply/chunk.js"; import { chunkDiscordText } from "./chunk.js";
import { chunkDiscordTextWithMode } from "./chunk.js";
import { fetchChannelPermissionsDiscord, isThreadChannelType } from "./send.permissions.js"; import { fetchChannelPermissionsDiscord, isThreadChannelType } from "./send.permissions.js";
import { DiscordSendError } from "./send.types.js"; import { DiscordSendError } from "./send.types.js";
import { parseDiscordTarget } from "./targets.js"; import { parseDiscordTarget } from "./targets.js";
@@ -232,18 +231,15 @@ async function sendDiscordText(
request: DiscordRequest, request: DiscordRequest,
maxLinesPerMessage?: number, maxLinesPerMessage?: number,
embeds?: unknown[], embeds?: unknown[],
chunkMode?: ChunkMode,
) { ) {
if (!text.trim()) { if (!text.trim()) {
throw new Error("Message must be non-empty for Discord sends"); throw new Error("Message must be non-empty for Discord sends");
} }
const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined; const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined;
const chunks = chunkDiscordTextWithMode(text, { const chunks = chunkDiscordText(text, {
maxChars: DISCORD_TEXT_LIMIT, maxChars: DISCORD_TEXT_LIMIT,
maxLines: maxLinesPerMessage, maxLines: maxLinesPerMessage,
chunkMode,
}); });
if (!chunks.length && text) chunks.push(text);
if (chunks.length === 1) { if (chunks.length === 1) {
const res = (await request( const res = (await request(
() => () =>
@@ -289,17 +285,14 @@ async function sendDiscordMedia(
request: DiscordRequest, request: DiscordRequest,
maxLinesPerMessage?: number, maxLinesPerMessage?: number,
embeds?: unknown[], embeds?: unknown[],
chunkMode?: ChunkMode,
) { ) {
const media = await loadWebMedia(mediaUrl); const media = await loadWebMedia(mediaUrl);
const chunks = text const chunks = text
? chunkDiscordTextWithMode(text, { ? chunkDiscordText(text, {
maxChars: DISCORD_TEXT_LIMIT, maxChars: DISCORD_TEXT_LIMIT,
maxLines: maxLinesPerMessage, maxLines: maxLinesPerMessage,
chunkMode,
}) })
: []; : [];
if (!chunks.length && text) chunks.push(text);
const caption = chunks[0] ?? ""; const caption = chunks[0] ?? "";
const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined; const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined;
const res = (await request( const res = (await request(
@@ -321,16 +314,7 @@ async function sendDiscordMedia(
)) as { id: string; channel_id: string }; )) as { id: string; channel_id: string };
for (const chunk of chunks.slice(1)) { for (const chunk of chunks.slice(1)) {
if (!chunk.trim()) continue; if (!chunk.trim()) continue;
await sendDiscordText( await sendDiscordText(rest, channelId, chunk, undefined, request, maxLinesPerMessage);
rest,
channelId,
chunk,
undefined,
request,
maxLinesPerMessage,
undefined,
chunkMode,
);
} }
return res; return res;
} }
+2 -3
View File
@@ -1,4 +1,4 @@
import { chunkTextWithMode, resolveChunkMode } from "../../auto-reply/chunk.js"; import { chunkText } from "../../auto-reply/chunk.js";
import { loadConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js";
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
import { convertMarkdownTables } from "../../markdown/tables.js"; import { convertMarkdownTables } from "../../markdown/tables.js";
@@ -23,14 +23,13 @@ export async function deliverReplies(params: {
channel: "imessage", channel: "imessage",
accountId, accountId,
}); });
const chunkMode = resolveChunkMode(cfg, "imessage", accountId);
for (const payload of replies) { for (const payload of replies) {
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const rawText = payload.text ?? ""; const rawText = payload.text ?? "";
const text = convertMarkdownTables(rawText, tableMode); const text = convertMarkdownTables(rawText, tableMode);
if (!text && mediaList.length === 0) continue; if (!text && mediaList.length === 0) continue;
if (mediaList.length === 0) { if (mediaList.length === 0) {
for (const chunk of chunkTextWithMode(text, textLimit, chunkMode)) { for (const chunk of chunkText(text, textLimit)) {
await sendMessageIMessage(target, chunk, { await sendMessageIMessage(target, chunk, {
maxBytes, maxBytes,
client, client,
-14
View File
@@ -3,20 +3,6 @@ import { describe, expect, it, vi } from "vitest";
import { wrapFetchWithAbortSignal } from "./fetch.js"; import { wrapFetchWithAbortSignal } from "./fetch.js";
describe("wrapFetchWithAbortSignal", () => { describe("wrapFetchWithAbortSignal", () => {
it("adds duplex for requests with a body", async () => {
let seenInit: RequestInit | undefined;
const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
seenInit = init;
return {} as Response;
});
const wrapped = wrapFetchWithAbortSignal(fetchImpl);
await wrapped("https://example.com", { method: "POST", body: "hi" });
expect(seenInit?.duplex).toBe("half");
});
it("converts foreign abort signals to native controllers", async () => { it("converts foreign abort signals to native controllers", async () => {
let seenSignal: AbortSignal | undefined; let seenSignal: AbortSignal | undefined;
const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
+6 -26
View File
@@ -2,38 +2,18 @@ type FetchWithPreconnect = typeof fetch & {
preconnect: (url: string, init?: { credentials?: RequestCredentials }) => void; preconnect: (url: string, init?: { credentials?: RequestCredentials }) => void;
}; };
type RequestInitWithDuplex = RequestInit & { duplex?: "half" };
function withDuplex(
init: RequestInit | undefined,
input: RequestInfo | URL,
): RequestInit | undefined {
const hasInitBody = init?.body != null;
const hasRequestBody =
!hasInitBody &&
typeof Request !== "undefined" &&
input instanceof Request &&
input.body != null;
if (!hasInitBody && !hasRequestBody) return init;
if (init && "duplex" in (init as Record<string, unknown>)) return init;
return init
? ({ ...init, duplex: "half" as const } as RequestInitWithDuplex)
: ({ duplex: "half" as const } as RequestInitWithDuplex);
}
export function wrapFetchWithAbortSignal(fetchImpl: typeof fetch): typeof fetch { export function wrapFetchWithAbortSignal(fetchImpl: typeof fetch): typeof fetch {
const wrapped = ((input: RequestInfo | URL, init?: RequestInit) => { const wrapped = ((input: RequestInfo | URL, init?: RequestInit) => {
const patchedInit = withDuplex(init, input); const signal = init?.signal;
const signal = patchedInit?.signal; if (!signal) return fetchImpl(input, init);
if (!signal) return fetchImpl(input, patchedInit);
if (typeof AbortSignal !== "undefined" && signal instanceof AbortSignal) { if (typeof AbortSignal !== "undefined" && signal instanceof AbortSignal) {
return fetchImpl(input, patchedInit); return fetchImpl(input, init);
} }
if (typeof AbortController === "undefined") { if (typeof AbortController === "undefined") {
return fetchImpl(input, patchedInit); return fetchImpl(input, init);
} }
if (typeof signal.addEventListener !== "function") { if (typeof signal.addEventListener !== "function") {
return fetchImpl(input, patchedInit); return fetchImpl(input, init);
} }
const controller = new AbortController(); const controller = new AbortController();
const onAbort = () => controller.abort(); const onAbort = () => controller.abort();
@@ -42,7 +22,7 @@ export function wrapFetchWithAbortSignal(fetchImpl: typeof fetch): typeof fetch
} else { } else {
signal.addEventListener("abort", onAbort, { once: true }); signal.addEventListener("abort", onAbort, { once: true });
} }
const response = fetchImpl(input, { ...patchedInit, signal: controller.signal }); const response = fetchImpl(input, { ...init, signal: controller.signal });
if (typeof signal.removeEventListener === "function") { if (typeof signal.removeEventListener === "function") {
void response.finally(() => { void response.finally(() => {
signal.removeEventListener("abort", onAbort); signal.removeEventListener("abort", onAbort);
-78
View File
@@ -168,84 +168,6 @@ describe("deliverOutboundPayloads", () => {
expect(results.map((r) => r.messageId)).toEqual(["w1", "w2"]); expect(results.map((r) => r.messageId)).toEqual(["w1", "w2"]);
}); });
it("respects newline chunk mode for WhatsApp", async () => {
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" });
const cfg: ClawdbotConfig = {
channels: { whatsapp: { textChunkLimit: 4000, chunkMode: "newline" } },
};
await deliverOutboundPayloads({
cfg,
channel: "whatsapp",
to: "+1555",
payloads: [{ text: "Line one\n\nLine two" }],
deps: { sendWhatsApp },
});
expect(sendWhatsApp).toHaveBeenCalledTimes(2);
expect(sendWhatsApp).toHaveBeenNthCalledWith(
1,
"+1555",
"Line one",
expect.objectContaining({ verbose: false }),
);
expect(sendWhatsApp).toHaveBeenNthCalledWith(
2,
"+1555",
"\nLine two",
expect.objectContaining({ verbose: false }),
);
});
it("preserves fenced blocks for markdown chunkers in newline mode", async () => {
const chunker = vi.fn((text: string) => (text ? [text] : []));
const sendText = vi.fn().mockImplementation(async ({ text }: { text: string }) => ({
channel: "matrix" as const,
messageId: text,
roomId: "r1",
}));
const sendMedia = vi.fn().mockImplementation(async ({ text }: { text: string }) => ({
channel: "matrix" as const,
messageId: text,
roomId: "r1",
}));
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "matrix",
source: "test",
plugin: createOutboundTestPlugin({
id: "matrix",
outbound: {
deliveryMode: "direct",
chunker,
chunkerMode: "markdown",
textChunkLimit: 4000,
sendText,
sendMedia,
},
}),
},
]),
);
const cfg: ClawdbotConfig = {
channels: { matrix: { textChunkLimit: 4000, chunkMode: "newline" } },
};
const text = "```js\nconst a = 1;\nconst b = 2;\n```\nAfter";
await deliverOutboundPayloads({
cfg,
channel: "matrix",
to: "!room",
payloads: [{ text }],
});
expect(chunker).toHaveBeenCalledTimes(2);
expect(chunker).toHaveBeenNthCalledWith(1, "```js\nconst a = 1;\nconst b = 2;\n```", 4000);
expect(chunker).toHaveBeenNthCalledWith(2, "After", 4000);
});
it("uses iMessage media maxBytes from agent fallback", async () => { it("uses iMessage media maxBytes from agent fallback", async () => {
const sendIMessage = vi.fn().mockResolvedValue({ messageId: "i1" }); const sendIMessage = vi.fn().mockResolvedValue({ messageId: "i1" });
setActivePluginRegistry( setActivePluginRegistry(
+1 -27
View File
@@ -1,9 +1,4 @@
import { import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
chunkByNewline,
chunkMarkdownTextWithMode,
resolveChunkMode,
resolveTextChunkLimit,
} from "../../auto-reply/chunk.js";
import type { ReplyPayload } from "../../auto-reply/types.js"; import type { ReplyPayload } from "../../auto-reply/types.js";
import { resolveChannelMediaMaxBytes } from "../../channels/plugins/media-limits.js"; import { resolveChannelMediaMaxBytes } from "../../channels/plugins/media-limits.js";
import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js"; import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js";
@@ -67,7 +62,6 @@ type Chunker = (text: string, limit: number) => string[];
type ChannelHandler = { type ChannelHandler = {
chunker: Chunker | null; chunker: Chunker | null;
chunkerMode?: "text" | "markdown";
textChunkLimit?: number; textChunkLimit?: number;
sendText: (text: string) => Promise<OutboundDeliveryResult>; sendText: (text: string) => Promise<OutboundDeliveryResult>;
sendMedia: (caption: string, mediaUrl: string) => Promise<OutboundDeliveryResult>; sendMedia: (caption: string, mediaUrl: string) => Promise<OutboundDeliveryResult>;
@@ -127,10 +121,8 @@ function createPluginHandler(params: {
const sendText = outbound.sendText; const sendText = outbound.sendText;
const sendMedia = outbound.sendMedia; const sendMedia = outbound.sendMedia;
const chunker = outbound.chunker ?? null; const chunker = outbound.chunker ?? null;
const chunkerMode = outbound.chunkerMode;
return { return {
chunker, chunker,
chunkerMode,
textChunkLimit: outbound.textChunkLimit, textChunkLimit: outbound.textChunkLimit,
sendText: async (text) => sendText: async (text) =>
sendText({ sendText({
@@ -200,7 +192,6 @@ export async function deliverOutboundPayloads(params: {
fallbackLimit: handler.textChunkLimit, fallbackLimit: handler.textChunkLimit,
}) })
: undefined; : undefined;
const chunkMode = handler.chunker ? resolveChunkMode(cfg, channel, accountId) : "length";
const isSignalChannel = channel === "signal"; const isSignalChannel = channel === "signal";
const signalTableMode = isSignalChannel const signalTableMode = isSignalChannel
? resolveMarkdownTableMode({ cfg, channel: "signal", accountId }) ? resolveMarkdownTableMode({ cfg, channel: "signal", accountId })
@@ -221,23 +212,6 @@ export async function deliverOutboundPayloads(params: {
results.push(await handler.sendText(text)); results.push(await handler.sendText(text));
return; return;
} }
if (chunkMode === "newline") {
const mode = handler.chunkerMode ?? "text";
const lineChunks =
mode === "markdown"
? chunkMarkdownTextWithMode(text, textLimit, "newline")
: chunkByNewline(text, textLimit, { splitLongLines: false });
if (!lineChunks.length && text) lineChunks.push(text);
for (const lineChunk of lineChunks) {
const chunks = handler.chunker(lineChunk, textLimit);
if (!chunks.length && lineChunk) chunks.push(lineChunk);
for (const chunk of chunks) {
throwIfAborted(abortSignal);
results.push(await handler.sendText(chunk));
}
}
return;
}
const chunks = handler.chunker(text, textLimit); const chunks = handler.chunker(text, textLimit);
for (const chunk of chunks) { for (const chunk of chunks) {
throwIfAborted(abortSignal); throwIfAborted(abortSignal);
-4
View File
@@ -3,7 +3,6 @@ import { createRequire } from "node:module";
import { import {
chunkByNewline, chunkByNewline,
chunkMarkdownText, chunkMarkdownText,
chunkMarkdownTextWithMode,
chunkText, chunkText,
chunkTextWithMode, chunkTextWithMode,
resolveChunkMode, resolveChunkMode,
@@ -41,7 +40,6 @@ import { removeAckReactionAfterReply, shouldAckReaction } from "../../channels/a
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
import { recordInboundSession } from "../../channels/session.js"; import { recordInboundSession } from "../../channels/session.js";
import { discordMessageActions } from "../../channels/plugins/actions/discord.js"; import { discordMessageActions } from "../../channels/plugins/actions/discord.js";
import { signalMessageActions } from "../../channels/plugins/actions/signal.js";
import { telegramMessageActions } from "../../channels/plugins/actions/telegram.js"; import { telegramMessageActions } from "../../channels/plugins/actions/telegram.js";
import { createWhatsAppLoginTool } from "../../channels/plugins/agent-tools/whatsapp-login.js"; import { createWhatsAppLoginTool } from "../../channels/plugins/agent-tools/whatsapp-login.js";
import { monitorWebChannel } from "../../channels/web/index.js"; import { monitorWebChannel } from "../../channels/web/index.js";
@@ -171,7 +169,6 @@ export function createPluginRuntime(): PluginRuntime {
text: { text: {
chunkByNewline, chunkByNewline,
chunkMarkdownText, chunkMarkdownText,
chunkMarkdownTextWithMode,
chunkText, chunkText,
chunkTextWithMode, chunkTextWithMode,
resolveChunkMode, resolveChunkMode,
@@ -272,7 +269,6 @@ export function createPluginRuntime(): PluginRuntime {
probeSignal, probeSignal,
sendMessageSignal, sendMessageSignal,
monitorSignalProvider, monitorSignalProvider,
messageActions: signalMessageActions,
}, },
imessage: { imessage: {
monitorIMessageProvider, monitorIMessageProvider,
-6
View File
@@ -37,8 +37,6 @@ type ResolveCommandAuthorizedFromAuthorizers =
type ResolveTextChunkLimit = typeof import("../../auto-reply/chunk.js").resolveTextChunkLimit; type ResolveTextChunkLimit = typeof import("../../auto-reply/chunk.js").resolveTextChunkLimit;
type ResolveChunkMode = typeof import("../../auto-reply/chunk.js").resolveChunkMode; type ResolveChunkMode = typeof import("../../auto-reply/chunk.js").resolveChunkMode;
type ChunkMarkdownText = typeof import("../../auto-reply/chunk.js").chunkMarkdownText; type ChunkMarkdownText = typeof import("../../auto-reply/chunk.js").chunkMarkdownText;
type ChunkMarkdownTextWithMode =
typeof import("../../auto-reply/chunk.js").chunkMarkdownTextWithMode;
type ChunkText = typeof import("../../auto-reply/chunk.js").chunkText; type ChunkText = typeof import("../../auto-reply/chunk.js").chunkText;
type ChunkTextWithMode = typeof import("../../auto-reply/chunk.js").chunkTextWithMode; type ChunkTextWithMode = typeof import("../../auto-reply/chunk.js").chunkTextWithMode;
type ChunkByNewline = typeof import("../../auto-reply/chunk.js").chunkByNewline; type ChunkByNewline = typeof import("../../auto-reply/chunk.js").chunkByNewline;
@@ -125,8 +123,6 @@ type TelegramMessageActions =
type ProbeSignal = typeof import("../../signal/probe.js").probeSignal; type ProbeSignal = typeof import("../../signal/probe.js").probeSignal;
type SendMessageSignal = typeof import("../../signal/send.js").sendMessageSignal; type SendMessageSignal = typeof import("../../signal/send.js").sendMessageSignal;
type MonitorSignalProvider = typeof import("../../signal/index.js").monitorSignalProvider; type MonitorSignalProvider = typeof import("../../signal/index.js").monitorSignalProvider;
type SignalMessageActions =
typeof import("../../channels/plugins/actions/signal.js").signalMessageActions;
type MonitorIMessageProvider = typeof import("../../imessage/monitor.js").monitorIMessageProvider; type MonitorIMessageProvider = typeof import("../../imessage/monitor.js").monitorIMessageProvider;
type ProbeIMessage = typeof import("../../imessage/probe.js").probeIMessage; type ProbeIMessage = typeof import("../../imessage/probe.js").probeIMessage;
type SendMessageIMessage = typeof import("../../imessage/send.js").sendMessageIMessage; type SendMessageIMessage = typeof import("../../imessage/send.js").sendMessageIMessage;
@@ -182,7 +178,6 @@ export type PluginRuntime = {
text: { text: {
chunkByNewline: ChunkByNewline; chunkByNewline: ChunkByNewline;
chunkMarkdownText: ChunkMarkdownText; chunkMarkdownText: ChunkMarkdownText;
chunkMarkdownTextWithMode: ChunkMarkdownTextWithMode;
chunkText: ChunkText; chunkText: ChunkText;
chunkTextWithMode: ChunkTextWithMode; chunkTextWithMode: ChunkTextWithMode;
resolveChunkMode: ResolveChunkMode; resolveChunkMode: ResolveChunkMode;
@@ -283,7 +278,6 @@ export type PluginRuntime = {
probeSignal: ProbeSignal; probeSignal: ProbeSignal;
sendMessageSignal: SendMessageSignal; sendMessageSignal: SendMessageSignal;
monitorSignalProvider: MonitorSignalProvider; monitorSignalProvider: MonitorSignalProvider;
messageActions: SignalMessageActions;
}; };
imessage: { imessage: {
monitorIMessageProvider: MonitorIMessageProvider; monitorIMessageProvider: MonitorIMessageProvider;
-2
View File
@@ -1,5 +1,3 @@
export { monitorSignalProvider } from "./monitor.js"; export { monitorSignalProvider } from "./monitor.js";
export { probeSignal } from "./probe.js"; export { probeSignal } from "./probe.js";
export { sendMessageSignal } from "./send.js"; export { sendMessageSignal } from "./send.js";
export { sendReactionSignal, removeReactionSignal } from "./send-reactions.js";
export { resolveSignalReactionLevel } from "./reaction-level.js";
+4 -7
View File
@@ -1,4 +1,4 @@
import { chunkTextWithMode, resolveChunkMode, resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "../auto-reply/reply/history.js"; import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "../auto-reply/reply/history.js";
import type { ReplyPayload } from "../auto-reply/types.js"; import type { ReplyPayload } from "../auto-reply/types.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
@@ -214,16 +214,14 @@ async function deliverReplies(params: {
runtime: RuntimeEnv; runtime: RuntimeEnv;
maxBytes: number; maxBytes: number;
textLimit: number; textLimit: number;
chunkMode: "length" | "newline";
}) { }) {
const { replies, target, baseUrl, account, accountId, runtime, maxBytes, textLimit, chunkMode } = const { replies, target, baseUrl, account, accountId, runtime, maxBytes, textLimit } = params;
params;
for (const payload of replies) { for (const payload of replies) {
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = payload.text ?? ""; const text = payload.text ?? "";
if (!text && mediaList.length === 0) continue; if (!text && mediaList.length === 0) continue;
if (mediaList.length === 0) { if (mediaList.length === 0) {
for (const chunk of chunkTextWithMode(text, textLimit, chunkMode)) { for (const chunk of chunkText(text, textLimit)) {
await sendMessageSignal(target, chunk, { await sendMessageSignal(target, chunk, {
baseUrl, baseUrl,
account, account,
@@ -264,7 +262,6 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
); );
const groupHistories = new Map<string, HistoryEntry[]>(); const groupHistories = new Map<string, HistoryEntry[]>();
const textLimit = resolveTextChunkLimit(cfg, "signal", accountInfo.accountId); const textLimit = resolveTextChunkLimit(cfg, "signal", accountInfo.accountId);
const chunkMode = resolveChunkMode(cfg, "signal", accountInfo.accountId);
const baseUrl = opts.baseUrl?.trim() || accountInfo.baseUrl; const baseUrl = opts.baseUrl?.trim() || accountInfo.baseUrl;
const account = opts.account?.trim() || accountInfo.config.account?.trim(); const account = opts.account?.trim() || accountInfo.config.account?.trim();
const dmPolicy = accountInfo.config.dmPolicy ?? "pairing"; const dmPolicy = accountInfo.config.dmPolicy ?? "pairing";
@@ -343,7 +340,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
sendReadReceipts, sendReadReceipts,
readReceiptsViaDaemon, readReceiptsViaDaemon,
fetchAttachment, fetchAttachment,
deliverReplies: (params) => deliverReplies({ ...params, chunkMode }), deliverReplies,
resolveSignalReactionTargets, resolveSignalReactionTargets,
isSignalReactionMessage, isSignalReactionMessage,
shouldEmitSignalReactionNotification, shouldEmitSignalReactionNotification,
-71
View File
@@ -1,71 +0,0 @@
import type { ClawdbotConfig } from "../config/config.js";
import { resolveSignalAccount } from "./accounts.js";
export type SignalReactionLevel = "off" | "ack" | "minimal" | "extensive";
export type ResolvedSignalReactionLevel = {
level: SignalReactionLevel;
/** Whether ACK reactions (e.g., 👀 when processing) are enabled. */
ackEnabled: boolean;
/** Whether agent-controlled reactions are enabled. */
agentReactionsEnabled: boolean;
/** Guidance level for agent reactions (minimal = sparse, extensive = liberal). */
agentReactionGuidance?: "minimal" | "extensive";
};
/**
* Resolve the effective reaction level and its implications for Signal.
*
* Levels:
* - "off": No reactions at all
* - "ack": Only automatic ack reactions (👀 when processing), no agent reactions
* - "minimal": Agent can react, but sparingly (default)
* - "extensive": Agent can react liberally
*/
export function resolveSignalReactionLevel(params: {
cfg: ClawdbotConfig;
accountId?: string;
}): ResolvedSignalReactionLevel {
const account = resolveSignalAccount({
cfg: params.cfg,
accountId: params.accountId,
});
const level = (account.config.reactionLevel ?? "minimal") as SignalReactionLevel;
switch (level) {
case "off":
return {
level,
ackEnabled: false,
agentReactionsEnabled: false,
};
case "ack":
return {
level,
ackEnabled: true,
agentReactionsEnabled: false,
};
case "minimal":
return {
level,
ackEnabled: false,
agentReactionsEnabled: true,
agentReactionGuidance: "minimal",
};
case "extensive":
return {
level,
ackEnabled: false,
agentReactionsEnabled: true,
agentReactionGuidance: "extensive",
};
default:
// Fallback to minimal behavior
return {
level: "minimal",
ackEnabled: false,
agentReactionsEnabled: true,
agentReactionGuidance: "minimal",
};
}
}
-69
View File
@@ -1,69 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const rpcMock = vi.fn();
const loadSendReactions = async () => await import("./send-reactions.js");
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => ({}),
};
});
vi.mock("./accounts.js", () => ({
resolveSignalAccount: () => ({
accountId: "default",
enabled: true,
baseUrl: "http://signal.local",
configured: true,
config: { account: "+15550001111" },
}),
}));
vi.mock("./client.js", () => ({
signalRpcRequest: (...args: unknown[]) => rpcMock(...args),
}));
describe("sendReactionSignal", () => {
beforeEach(() => {
rpcMock.mockReset().mockResolvedValue({ timestamp: 123 });
vi.resetModules();
});
it("uses recipients array and targetAuthor for uuid dms", async () => {
const { sendReactionSignal } = await loadSendReactions();
await sendReactionSignal("uuid:123e4567-e89b-12d3-a456-426614174000", 123, "🔥");
const params = rpcMock.mock.calls[0]?.[1] as Record<string, unknown>;
expect(rpcMock).toHaveBeenCalledWith("sendReaction", expect.any(Object), expect.any(Object));
expect(params.recipients).toEqual(["123e4567-e89b-12d3-a456-426614174000"]);
expect(params.groupIds).toBeUndefined();
expect(params.targetAuthor).toBe("123e4567-e89b-12d3-a456-426614174000");
expect(params).not.toHaveProperty("recipient");
expect(params).not.toHaveProperty("groupId");
});
it("uses groupIds array and maps targetAuthorUuid", async () => {
const { sendReactionSignal } = await loadSendReactions();
await sendReactionSignal("", 123, "✅", {
groupId: "group-id",
targetAuthorUuid: "uuid:123e4567-e89b-12d3-a456-426614174000",
});
const params = rpcMock.mock.calls[0]?.[1] as Record<string, unknown>;
expect(params.recipients).toBeUndefined();
expect(params.groupIds).toEqual(["group-id"]);
expect(params.targetAuthor).toBe("123e4567-e89b-12d3-a456-426614174000");
});
it("defaults targetAuthor to recipient for removals", async () => {
const { removeReactionSignal } = await loadSendReactions();
await removeReactionSignal("+15551230000", 456, "❌");
const params = rpcMock.mock.calls[0]?.[1] as Record<string, unknown>;
expect(params.recipients).toEqual(["+15551230000"]);
expect(params.targetAuthor).toBe("+15551230000");
expect(params.remove).toBe(true);
});
});
-195
View File
@@ -1,195 +0,0 @@
/**
* Signal reactions via signal-cli JSON-RPC API
*/
import { loadConfig } from "../config/config.js";
import { resolveSignalAccount } from "./accounts.js";
import { signalRpcRequest } from "./client.js";
export type SignalReactionOpts = {
baseUrl?: string;
account?: string;
accountId?: string;
timeoutMs?: number;
targetAuthor?: string;
targetAuthorUuid?: string;
groupId?: string;
};
export type SignalReactionResult = {
ok: boolean;
timestamp?: number;
};
function normalizeSignalId(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return "";
return trimmed.replace(/^signal:/i, "").trim();
}
function normalizeSignalUuid(raw: string): string {
const trimmed = normalizeSignalId(raw);
if (!trimmed) return "";
if (trimmed.toLowerCase().startsWith("uuid:")) {
return trimmed.slice("uuid:".length).trim();
}
return trimmed;
}
function resolveTargetAuthorParams(params: {
targetAuthor?: string;
targetAuthorUuid?: string;
fallback?: string;
}): { targetAuthor?: string } {
const candidates = [params.targetAuthor, params.targetAuthorUuid, params.fallback];
for (const candidate of candidates) {
const raw = candidate?.trim();
if (!raw) continue;
const normalized = normalizeSignalUuid(raw);
if (normalized) return { targetAuthor: normalized };
}
return {};
}
function resolveReactionRpcContext(
opts: SignalReactionOpts,
accountInfo?: ReturnType<typeof resolveSignalAccount>,
) {
const hasBaseUrl = Boolean(opts.baseUrl?.trim());
const hasAccount = Boolean(opts.account?.trim());
const resolvedAccount =
accountInfo ||
(!hasBaseUrl || !hasAccount
? resolveSignalAccount({
cfg: loadConfig(),
accountId: opts.accountId,
})
: undefined);
const baseUrl = opts.baseUrl?.trim() || resolvedAccount?.baseUrl;
if (!baseUrl) {
throw new Error("Signal base URL is required");
}
const account = opts.account?.trim() || resolvedAccount?.config.account?.trim();
return { baseUrl, account };
}
/**
* Send a Signal reaction to a message
* @param recipient - UUID or E.164 phone number of the message author
* @param targetTimestamp - Message ID (timestamp) to react to
* @param emoji - Emoji to react with
* @param opts - Optional account/connection overrides
*/
export async function sendReactionSignal(
recipient: string,
targetTimestamp: number,
emoji: string,
opts: SignalReactionOpts = {},
): Promise<SignalReactionResult> {
const accountInfo = resolveSignalAccount({
cfg: loadConfig(),
accountId: opts.accountId,
});
const { baseUrl, account } = resolveReactionRpcContext(opts, accountInfo);
const normalizedRecipient = normalizeSignalUuid(recipient);
const groupId = opts.groupId?.trim();
if (!normalizedRecipient && !groupId) {
throw new Error("Recipient or groupId is required for Signal reaction");
}
if (!Number.isFinite(targetTimestamp) || targetTimestamp <= 0) {
throw new Error("Valid targetTimestamp is required for Signal reaction");
}
if (!emoji?.trim()) {
throw new Error("Emoji is required for Signal reaction");
}
const targetAuthorParams = resolveTargetAuthorParams({
targetAuthor: opts.targetAuthor,
targetAuthorUuid: opts.targetAuthorUuid,
fallback: normalizedRecipient,
});
if (groupId && !targetAuthorParams.targetAuthor) {
throw new Error("targetAuthor is required for group reactions");
}
const params: Record<string, unknown> = {
emoji: emoji.trim(),
targetTimestamp,
...targetAuthorParams,
};
if (normalizedRecipient) params.recipients = [normalizedRecipient];
if (groupId) params.groupIds = [groupId];
if (account) params.account = account;
const result = await signalRpcRequest<{ timestamp?: number }>("sendReaction", params, {
baseUrl,
timeoutMs: opts.timeoutMs,
});
return {
ok: true,
timestamp: result?.timestamp,
};
}
/**
* Remove a Signal reaction from a message
* @param recipient - UUID or E.164 phone number of the message author
* @param targetTimestamp - Message ID (timestamp) to remove reaction from
* @param emoji - Emoji to remove
* @param opts - Optional account/connection overrides
*/
export async function removeReactionSignal(
recipient: string,
targetTimestamp: number,
emoji: string,
opts: SignalReactionOpts = {},
): Promise<SignalReactionResult> {
const accountInfo = resolveSignalAccount({
cfg: loadConfig(),
accountId: opts.accountId,
});
const { baseUrl, account } = resolveReactionRpcContext(opts, accountInfo);
const normalizedRecipient = normalizeSignalUuid(recipient);
const groupId = opts.groupId?.trim();
if (!normalizedRecipient && !groupId) {
throw new Error("Recipient or groupId is required for Signal reaction removal");
}
if (!Number.isFinite(targetTimestamp) || targetTimestamp <= 0) {
throw new Error("Valid targetTimestamp is required for Signal reaction removal");
}
if (!emoji?.trim()) {
throw new Error("Emoji is required for Signal reaction removal");
}
const targetAuthorParams = resolveTargetAuthorParams({
targetAuthor: opts.targetAuthor,
targetAuthorUuid: opts.targetAuthorUuid,
fallback: normalizedRecipient,
});
if (groupId && !targetAuthorParams.targetAuthor) {
throw new Error("targetAuthor is required for group reaction removal");
}
const params: Record<string, unknown> = {
emoji: emoji.trim(),
targetTimestamp,
remove: true,
...targetAuthorParams,
};
if (normalizedRecipient) params.recipients = [normalizedRecipient];
if (groupId) params.groupIds = [groupId];
if (account) params.account = account;
const result = await signalRpcRequest<{ timestamp?: number }>("sendReaction", params, {
baseUrl,
timeoutMs: opts.timeoutMs,
});
return {
ok: true,
timestamp: result?.timestamp,
};
}
+3 -13
View File
@@ -1,7 +1,5 @@
import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.js"; import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.js";
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js"; import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js";
import type { ChunkMode } from "../../auto-reply/chunk.js";
import { chunkMarkdownTextWithMode } from "../../auto-reply/chunk.js";
import type { ReplyPayload } from "../../auto-reply/types.js"; import type { ReplyPayload } from "../../auto-reply/types.js";
import type { MarkdownTableMode } from "../../config/types.base.js"; import type { MarkdownTableMode } from "../../config/types.base.js";
import type { RuntimeEnv } from "../../runtime.js"; import type { RuntimeEnv } from "../../runtime.js";
@@ -120,7 +118,6 @@ export async function deliverSlackSlashReplies(params: {
ephemeral: boolean; ephemeral: boolean;
textLimit: number; textLimit: number;
tableMode?: MarkdownTableMode; tableMode?: MarkdownTableMode;
chunkMode?: ChunkMode;
}) { }) {
const messages: string[] = []; const messages: string[] = [];
const chunkLimit = Math.min(params.textLimit, 4000); const chunkLimit = Math.min(params.textLimit, 4000);
@@ -132,16 +129,9 @@ export async function deliverSlackSlashReplies(params: {
.filter(Boolean) .filter(Boolean)
.join("\n"); .join("\n");
if (!combined) continue; if (!combined) continue;
const chunkMode = params.chunkMode ?? "length"; for (const chunk of markdownToSlackMrkdwnChunks(combined, chunkLimit, {
const markdownChunks = tableMode: params.tableMode,
chunkMode === "newline" })) {
? chunkMarkdownTextWithMode(combined, chunkLimit, chunkMode)
: [combined];
const chunks = markdownChunks.flatMap((markdown) =>
markdownToSlackMrkdwnChunks(markdown, chunkLimit, { tableMode: params.tableMode }),
);
if (!chunks.length && combined) chunks.push(combined);
for (const chunk of chunks) {
messages.push(chunk); messages.push(chunk);
} }
} }
-3
View File
@@ -1,6 +1,5 @@
import type { SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs } from "@slack/bolt"; import type { SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs } from "@slack/bolt";
import type { ChatCommandDefinition, CommandArgs } from "../../auto-reply/commands-registry.js"; import type { ChatCommandDefinition, CommandArgs } from "../../auto-reply/commands-registry.js";
import { resolveChunkMode } from "../../auto-reply/chunk.js";
import { resolveEffectiveMessagesConfig } from "../../agents/identity.js"; import { resolveEffectiveMessagesConfig } from "../../agents/identity.js";
import { import {
buildCommandTextFromArgs, buildCommandTextFromArgs,
@@ -430,7 +429,6 @@ export function registerSlackMonitorSlashCommands(params: {
respond, respond,
ephemeral: slashCommand.ephemeral, ephemeral: slashCommand.ephemeral,
textLimit: ctx.textLimit, textLimit: ctx.textLimit,
chunkMode: resolveChunkMode(cfg, "slack", route.accountId),
tableMode: resolveMarkdownTableMode({ tableMode: resolveMarkdownTableMode({
cfg, cfg,
channel: "slack", channel: "slack",
@@ -450,7 +448,6 @@ export function registerSlackMonitorSlashCommands(params: {
respond, respond,
ephemeral: slashCommand.ephemeral, ephemeral: slashCommand.ephemeral,
textLimit: ctx.textLimit, textLimit: ctx.textLimit,
chunkMode: resolveChunkMode(cfg, "slack", route.accountId),
tableMode: resolveMarkdownTableMode({ tableMode: resolveMarkdownTableMode({
cfg, cfg,
channel: "slack", channel: "slack",
+2 -14
View File
@@ -1,10 +1,6 @@
import { type FilesUploadV2Arguments, type WebClient } from "@slack/web-api"; import { type FilesUploadV2Arguments, type WebClient } from "@slack/web-api";
import { import { resolveTextChunkLimit } from "../auto-reply/chunk.js";
chunkMarkdownTextWithMode,
resolveChunkMode,
resolveTextChunkLimit,
} from "../auto-reply/chunk.js";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { logVerbose } from "../globals.js"; import { logVerbose } from "../globals.js";
import { loadWebMedia } from "../web/media.js"; import { loadWebMedia } from "../web/media.js";
@@ -153,15 +149,7 @@ export async function sendMessageSlack(
channel: "slack", channel: "slack",
accountId: account.accountId, accountId: account.accountId,
}); });
const chunkMode = resolveChunkMode(cfg, "slack", account.accountId); const chunks = markdownToSlackMrkdwnChunks(trimmedMessage, chunkLimit, { tableMode });
const markdownChunks =
chunkMode === "newline"
? chunkMarkdownTextWithMode(trimmedMessage, chunkLimit, chunkMode)
: [trimmedMessage];
const chunks = markdownChunks.flatMap((markdown) =>
markdownToSlackMrkdwnChunks(markdown, chunkLimit, { tableMode }),
);
if (!chunks.length && trimmedMessage) chunks.push(trimmedMessage);
const mediaMaxBytes = const mediaMaxBytes =
typeof account.config.mediaMaxMb === "number" typeof account.config.mediaMaxMb === "number"
? account.config.mediaMaxMb * 1024 * 1024 ? account.config.mediaMaxMb * 1024 * 1024
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff. Show more