Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca26e17273 | ||
|
|
34ab1d245c | ||
|
|
a11b98f801 |
No files matched your search
+24
-62
@@ -32,29 +32,20 @@ jobs:
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
|
||||
- name: Setup pnpm (corepack retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
corepack enable
|
||||
for attempt in 1 2 3; do
|
||||
if corepack prepare pnpm@10.23.0 --activate; then
|
||||
pnpm -v
|
||||
exit 0
|
||||
fi
|
||||
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Runtime versions
|
||||
run: |
|
||||
node -v
|
||||
npm -v
|
||||
pnpm -v
|
||||
|
||||
- name: Capture node path
|
||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Enable corepack and pin pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.23.0 --activate
|
||||
pnpm -v
|
||||
|
||||
- name: Install dependencies (frozen)
|
||||
env:
|
||||
CI: true
|
||||
@@ -117,20 +108,6 @@ jobs:
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
|
||||
- name: Setup pnpm (corepack retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
corepack enable
|
||||
for attempt in 1 2 3; do
|
||||
if corepack prepare pnpm@10.23.0 --activate; then
|
||||
pnpm -v
|
||||
exit 0
|
||||
fi
|
||||
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
@@ -141,11 +118,16 @@ jobs:
|
||||
node -v
|
||||
npm -v
|
||||
bun -v
|
||||
pnpm -v
|
||||
|
||||
- name: Capture node path
|
||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Enable corepack and pin pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.23.0 --activate
|
||||
pnpm -v
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
CI: true
|
||||
@@ -186,8 +168,6 @@ jobs:
|
||||
|
||||
checks-windows:
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -232,20 +212,6 @@ jobs:
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
|
||||
- name: Setup pnpm (corepack retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
corepack enable
|
||||
for attempt in 1 2 3; do
|
||||
if corepack prepare pnpm@10.23.0 --activate; then
|
||||
pnpm -v
|
||||
exit 0
|
||||
fi
|
||||
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
@@ -256,11 +222,16 @@ jobs:
|
||||
node -v
|
||||
npm -v
|
||||
bun -v
|
||||
pnpm -v
|
||||
|
||||
- name: Capture node path
|
||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Enable corepack and pin pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.23.0 --activate
|
||||
pnpm -v
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
CI: true
|
||||
@@ -308,29 +279,20 @@ jobs:
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
|
||||
- name: Setup pnpm (corepack retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
corepack enable
|
||||
for attempt in 1 2 3; do
|
||||
if corepack prepare pnpm@10.23.0 --activate; then
|
||||
pnpm -v
|
||||
exit 0
|
||||
fi
|
||||
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Runtime versions
|
||||
run: |
|
||||
node -v
|
||||
npm -v
|
||||
pnpm -v
|
||||
|
||||
- name: Capture node path
|
||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Enable corepack and pin pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.23.0 --activate
|
||||
pnpm -v
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
CI: true
|
||||
|
||||
@@ -13,19 +13,12 @@ jobs:
|
||||
- name: Checkout CLI
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm (corepack retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
corepack enable
|
||||
for attempt in 1 2 3; do
|
||||
if corepack prepare pnpm@10.23.0 --activate; then
|
||||
pnpm -v
|
||||
exit 0
|
||||
fi
|
||||
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Install pnpm deps (minimal)
|
||||
run: pnpm install --ignore-scripts --frozen-lockfile
|
||||
|
||||
+27
-44
@@ -2,75 +2,58 @@
|
||||
|
||||
Docs: https://docs.clawd.bot
|
||||
|
||||
## 2026.1.24-1
|
||||
|
||||
### Fixes
|
||||
- Packaging: include dist/shared output in npm tarball (fixes missing reasoning-tags import on install).
|
||||
|
||||
## 2026.1.24
|
||||
|
||||
### Highlights
|
||||
- Providers: Ollama discovery + docs; Venice guide upgrades + cross-links. (#1606) Thanks @abhaymundhara. https://docs.clawd.bot/providers/ollama https://docs.clawd.bot/providers/venice
|
||||
- Channels: LINE plugin (Messaging API) with rich replies + quick replies. (#1630) Thanks @plum-dawg.
|
||||
- TTS: Edge fallback (keyless) + `/tts` auto modes. (#1668, #1667) Thanks @steipete, @sebslight. https://docs.clawd.bot/tts
|
||||
- Exec approvals: approve in-chat via `/approve` across all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
|
||||
- Telegram: DM topics as separate sessions + outbound link preview toggle. (#1597, #1700) Thanks @rohannagpal, @zerone0x. https://docs.clawd.bot/channels/telegram
|
||||
- Ollama: provider discovery + docs. (#1606) Thanks @abhaymundhara. https://docs.clawd.bot/providers/ollama
|
||||
- Venius (Venice AI): highlight provider guide + cross-links + expanded guidance. https://docs.clawd.bot/providers/venice
|
||||
|
||||
### Changes
|
||||
- Channels: add LINE plugin (Messaging API) with rich replies, quick replies, and plugin HTTP registry. (#1630) Thanks @plum-dawg.
|
||||
- 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 auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts
|
||||
- Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal.
|
||||
- Telegram: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram
|
||||
- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web
|
||||
- UI: refresh Control UI dashboard design system (typography, colors, spacing). (#1786) Thanks @mousberg.
|
||||
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
|
||||
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.
|
||||
- Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags
|
||||
- TTS: add auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts
|
||||
- Dev: add prek pre-commit hooks + dependabot config for weekly updates. (#1720) Thanks @dguido.
|
||||
- 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 macOS VM guide with local/hosted options + VPS/nodes guidance. (#1693) Thanks @f-trycua.
|
||||
- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock
|
||||
- Docs: update Fly.io guide notes.
|
||||
- Dev: add prek pre-commit hooks + dependabot config for weekly updates. (#1720) Thanks @dguido.
|
||||
- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock
|
||||
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
|
||||
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.
|
||||
- Telegram: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram
|
||||
- Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal.
|
||||
- Telegram: add verbose raw-update logging for inbound Telegram updates. (#1597) Thanks @rohannagpal.
|
||||
- Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags
|
||||
|
||||
### Fixes
|
||||
- Web UI: fix config/debug layout overflow, scrolling, and code block sizing. (#1715) Thanks @saipreetham589.
|
||||
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
|
||||
- Web UI: clear stale disconnect banners on reconnect; allow form saves with unsupported schema paths but block missing schema. (#1707) Thanks @Glucksberg.
|
||||
- Web UI: hide internal `message_id` hints in chat bubbles.
|
||||
- Gateway: allow Control UI token-only auth to skip device pairing even when device identity is present (`gateway.controlUi.allowInsecureAuth`). (#1679) Thanks @steipete.
|
||||
- Matrix: decrypt E2EE media attachments with preflight size guard. (#1744) Thanks @araa47.
|
||||
- macOS: rearm gateway receive loop before push handling to avoid node invoke stalls. (#1752) Thanks @ngutman.
|
||||
- Gateway: include inline config env vars in service install environments. (#1735) Thanks @Seredeep.
|
||||
- BlueBubbles: route phone-number targets to DMs, avoid leaking routing IDs, and auto-create missing DMs (Private API required). (#1751) Thanks @tyler6204. https://docs.clawd.bot/channels/bluebubbles
|
||||
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
|
||||
- iMessage: normalize chat_id/chat_guid/chat_identifier prefixes case-insensitively and keep service-prefixed handles stable. (#1708) Thanks @aaronn.
|
||||
- Web UI: hide internal `message_id` hints in chat bubbles.
|
||||
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
|
||||
- Web UI: clear stale disconnect banners on reconnect; allow form saves with unsupported schema paths but block missing schema. (#1707) Thanks @Glucksberg.
|
||||
- Heartbeat: normalize target identifiers for consistent routing.
|
||||
- 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: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.
|
||||
- Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev.
|
||||
- Signal: add configurable signal-cli startup timeout + external daemon mode docs. (#1677) https://docs.clawd.bot/channels/signal
|
||||
- Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.
|
||||
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
|
||||
- Telegram: honor per-account proxy for outbound API calls. (#1774) Thanks @radek-paclt.
|
||||
- Telegram: fall back to text when voice notes are blocked by privacy settings. (#1725) Thanks @foeken.
|
||||
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
|
||||
- Voice Call: serialize Twilio TTS playback and cancel on barge-in to prevent overlap. (#1713) Thanks @dguido.
|
||||
- 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.
|
||||
- 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: use the active auth profile for auto-compaction recovery.
|
||||
- Media understanding: skip image understanding when the primary model already supports vision. (#1747) Thanks @tyler6204.
|
||||
- Models: default missing custom provider fields so minimal configs are accepted.
|
||||
- Messaging: keep newline chunking safe for fenced markdown blocks across channels.
|
||||
- Messaging: treat newline chunking as paragraph-aware (blank-line splits) to keep lists and headings together. (#1726) Thanks @tyler6204.
|
||||
- TUI: reload history after gateway reconnect to restore session state. (#1663)
|
||||
- Heartbeat: normalize target identifiers for consistent routing.
|
||||
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
|
||||
- Exec: treat Windows platform labels as Windows for node shell selection. (#1760) Thanks @ymat19.
|
||||
- Gateway: include inline config env vars in service install environments. (#1735) Thanks @Seredeep.
|
||||
- Media understanding: skip image understanding when the primary model already supports vision. (#1747) Thanks @tyler6204.
|
||||
- Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671)
|
||||
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
|
||||
- Gateway: clarify Control UI/WebChat auth error hints for missing tokens. (#1690)
|
||||
- Gateway: listen on IPv6 loopback when bound to 127.0.0.1 so localhost webhooks work.
|
||||
- Gateway: store lock files in the temp directory to avoid stale locks on persistent volumes. (#1676)
|
||||
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
|
||||
- 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: normalize space targets without double `spaces/` prefix.
|
||||
- Messaging: keep newline chunking safe for fenced markdown blocks across channels.
|
||||
- Tests: cap Vitest workers on CI macOS to reduce timeouts. (#1597) Thanks @rohannagpal.
|
||||
- Tests: avoid fake-timer dependency in embedded runner stream mock to reduce CI flakes. (#1597) Thanks @rohannagpal.
|
||||
- Tests: increase embedded runner ordering test timeout to reduce CI flakes. (#1597) Thanks @rohannagpal.
|
||||
@@ -1083,4 +1066,4 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Agent tools: honor `agent.tools` allow/deny policy even when sandbox is off.
|
||||
- Discord: avoid duplicate replies when OpenAI emits repeated `message_end` events.
|
||||
- Commands: unify /status (inline) and command auth across providers; group bypass for authorized control commands; remove Discord /clawd slash handler.
|
||||
- CLI: run `clawdbot agent` via the Gateway by default; use `--local` to force embedded mode.
|
||||
- CLI: run `clawdbot agent` via the Gateway by default; use `--local` to force embedded mode.
|
||||
@@ -40,13 +40,3 @@ Please include in your PR:
|
||||
- [ ] Confirm you understand what the code does
|
||||
|
||||
AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for.
|
||||
|
||||
## Current Focus & Roadmap 🗺
|
||||
|
||||
We are currently prioritizing:
|
||||
- **Stability**: Fixing edge cases in channel connections (WhatsApp/Telegram).
|
||||
- **UX**: Improving the onboarding wizard and error messages.
|
||||
- **Skills**: Expanding the library of bundled skills and improving the Skill Creation developer experience.
|
||||
- **Performance**: Optimizing token usage and compaction logic.
|
||||
|
||||
Check the [GitHub Issues](https://github.com/clawdbot/clawdbot/issues) for "good first issue" labels!
|
||||
@@ -477,33 +477,32 @@ Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and
|
||||
Thanks to all clawtributors:
|
||||
|
||||
<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/plum-dawg"><img src="https://avatars.githubusercontent.com/u/5909950?v=4&s=48" width="48" height="48" alt="plum-dawg" title="plum-dawg"/></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/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/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/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/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/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/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/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/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/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/mousberg"><img src="https://avatars.githubusercontent.com/u/57605064?v=4&s=48" width="48" height="48" alt="mousberg" title="mousberg"/></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/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/rohannagpal"><img src="https://avatars.githubusercontent.com/u/4009239?v=4&s=48" width="48" height="48" alt="rohannagpal" title="rohannagpal"/></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/benostein"><img src="https://avatars.githubusercontent.com/u/31802821?v=4&s=48" width="48" height="48" alt="benostein" title="benostein"/></a> <a href="https://github.com/f-trycua"><img src="https://avatars.githubusercontent.com/u/195596869?v=4&s=48" width="48" height="48" alt="f-trycua" title="f-trycua"/></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/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/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/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/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/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/uos-status"><img src="https://avatars.githubusercontent.com/u/255712580?v=4&s=48" width="48" height="48" alt="uos-status" title="uos-status"/></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/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/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/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/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/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/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/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/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/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/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/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/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/dguido"><img src="https://avatars.githubusercontent.com/u/294844?v=4&s=48" width="48" height="48" alt="dguido" title="dguido"/></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/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/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/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/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/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=Developer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Developer" title="Developer"/></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/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/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/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/louzhixian"><img src="https://avatars.githubusercontent.com/u/7994361?v=4&s=48" width="48" height="48" alt="louzhixian" title="louzhixian"/></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/senoldogann"><img src="https://avatars.githubusercontent.com/u/45736551?v=4&s=48" width="48" height="48" alt="senoldogann" title="senoldogann"/></a> <a href="https://github.com/Seredeep"><img src="https://avatars.githubusercontent.com/u/22802816?v=4&s=48" width="48" height="48" alt="Seredeep" title="Seredeep"/></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/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=ymat19"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ymat19" title="ymat19"/></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/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a>
|
||||
<a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
<a href="https://github.com/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a>
|
||||
<a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
</p>
|
||||
+123
-96
@@ -2,101 +2,6 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>Clawdbot</title>
|
||||
<item>
|
||||
<title>2026.1.24-1</title>
|
||||
<pubDate>Sun, 25 Jan 2026 14:05:25 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>7952</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.24-1</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.24-1</h2>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Packaging: include dist/shared output in npm tarball (fixes missing reasoning-tags import on install).</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.24-1/Clawdbot-2026.1.24-1.zip" length="12396699" type="application/octet-stream" sparkle:edSignature="VaEdWIgEJBrZLIp2UmigoQ6vaq4P/jNFXpHYXvXHD5MsATS0CqBl6ugyyxRq+/GbpUqmdgdlht4dTUVbLRw6BA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.24</title>
|
||||
<pubDate>Sun, 25 Jan 2026 13:31:05 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>7944</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.24</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.24</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>Providers: Ollama discovery + docs; Venice guide upgrades + cross-links. (#1606) Thanks @abhaymundhara. https://docs.clawd.bot/providers/ollama https://docs.clawd.bot/providers/venice</li>
|
||||
<li>Channels: LINE plugin (Messaging API) with rich replies + quick replies. (#1630) Thanks @plum-dawg.</li>
|
||||
<li>TTS: Edge fallback (keyless) + <code>/tts</code> auto modes. (#1668, #1667) Thanks @steipete, @sebslight. https://docs.clawd.bot/tts</li>
|
||||
<li>Exec approvals: approve in-chat via <code>/approve</code> across all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands</li>
|
||||
<li>Telegram: DM topics as separate sessions + outbound link preview toggle. (#1597, #1700) Thanks @rohannagpal, @zerone0x. https://docs.clawd.bot/channels/telegram</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Channels: add LINE plugin (Messaging API) with rich replies, quick replies, and plugin HTTP registry. (#1630) Thanks @plum-dawg.</li>
|
||||
<li>TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.clawd.bot/tts</li>
|
||||
<li>TTS: add auto mode enum (off/always/inbound/tagged) with per-session <code>/tts</code> override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts</li>
|
||||
<li>Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal.</li>
|
||||
<li>Telegram: add <code>channels.telegram.linkPreview</code> to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram</li>
|
||||
<li>Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web</li>
|
||||
<li>UI: refresh Control UI dashboard design system (typography, colors, spacing). (#1786) Thanks @mousberg.</li>
|
||||
<li>Exec approvals: forward approval prompts to chat with <code>/approve</code> for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands</li>
|
||||
<li>Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.</li>
|
||||
<li>Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags</li>
|
||||
<li>Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).</li>
|
||||
<li>Docs: add verbose installer troubleshooting guidance.</li>
|
||||
<li>Docs: add macOS VM guide with local/hosted options + VPS/nodes guidance. (#1693) Thanks @f-trycua.</li>
|
||||
<li>Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock</li>
|
||||
<li>Docs: update Fly.io guide notes.</li>
|
||||
<li>Dev: add prek pre-commit hooks + dependabot config for weekly updates. (#1720) Thanks @dguido.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Web UI: fix config/debug layout overflow, scrolling, and code block sizing. (#1715) Thanks @saipreetham589.</li>
|
||||
<li>Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.</li>
|
||||
<li>Web UI: clear stale disconnect banners on reconnect; allow form saves with unsupported schema paths but block missing schema. (#1707) Thanks @Glucksberg.</li>
|
||||
<li>Web UI: hide internal <code>message_id</code> hints in chat bubbles.</li>
|
||||
<li>Gateway: allow Control UI token-only auth to skip device pairing even when device identity is present (<code>gateway.controlUi.allowInsecureAuth</code>). (#1679) Thanks @steipete.</li>
|
||||
<li>Matrix: decrypt E2EE media attachments with preflight size guard. (#1744) Thanks @araa47.</li>
|
||||
<li>BlueBubbles: route phone-number targets to DMs, avoid leaking routing IDs, and auto-create missing DMs (Private API required). (#1751) Thanks @tyler6204. https://docs.clawd.bot/channels/bluebubbles</li>
|
||||
<li>BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.</li>
|
||||
<li>Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev.</li>
|
||||
<li>Signal: add configurable signal-cli startup timeout + external daemon mode docs. (#1677) https://docs.clawd.bot/channels/signal</li>
|
||||
<li>Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.</li>
|
||||
<li>Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)</li>
|
||||
<li>Telegram: honor per-account proxy for outbound API calls. (#1774) Thanks @radek-paclt.</li>
|
||||
<li>Telegram: fall back to text when voice notes are blocked by privacy settings. (#1725) Thanks @foeken.</li>
|
||||
<li>Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)</li>
|
||||
<li>Voice Call: serialize Twilio TTS playback and cancel on barge-in to prevent overlap. (#1713) Thanks @dguido.</li>
|
||||
<li>Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.</li>
|
||||
<li>Google Chat: normalize space targets without double <code>spaces/</code> prefix.</li>
|
||||
<li>Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.</li>
|
||||
<li>Agents: use the active auth profile for auto-compaction recovery.</li>
|
||||
<li>Media understanding: skip image understanding when the primary model already supports vision. (#1747) Thanks @tyler6204.</li>
|
||||
<li>Models: default missing custom provider fields so minimal configs are accepted.</li>
|
||||
<li>Messaging: keep newline chunking safe for fenced markdown blocks across channels.</li>
|
||||
<li>TUI: reload history after gateway reconnect to restore session state. (#1663)</li>
|
||||
<li>Heartbeat: normalize target identifiers for consistent routing.</li>
|
||||
<li>Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.</li>
|
||||
<li>Exec: treat Windows platform labels as Windows for node shell selection. (#1760) Thanks @ymat19.</li>
|
||||
<li>Gateway: include inline config env vars in service install environments. (#1735) Thanks @Seredeep.</li>
|
||||
<li>Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671)</li>
|
||||
<li>Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.</li>
|
||||
<li>Gateway: clarify Control UI/WebChat auth error hints for missing tokens. (#1690)</li>
|
||||
<li>Gateway: listen on IPv6 loopback when bound to 127.0.0.1 so localhost webhooks work.</li>
|
||||
<li>Gateway: store lock files in the temp directory to avoid stale locks on persistent volumes. (#1676)</li>
|
||||
<li>macOS: default direct-transport <code>ws://</code> URLs to port 18789; document <code>gateway.remote.transport</code>. (#1603) Thanks @ngutman.</li>
|
||||
<li>Tests: cap Vitest workers on CI macOS to reduce timeouts. (#1597) Thanks @rohannagpal.</li>
|
||||
<li>Tests: avoid fake-timer dependency in embedded runner stream mock to reduce CI flakes. (#1597) Thanks @rohannagpal.</li>
|
||||
<li>Tests: increase embedded runner ordering test timeout to reduce CI flakes. (#1597) Thanks @rohannagpal.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.24/Clawdbot-2026.1.24.zip" length="12396700" type="application/octet-stream" sparkle:edSignature="u+XzKD3YwV8s79gIr7LK4OtDCcmp/b+cjNC6SHav3/1CVJegh02SsBKatrampox32XGx8P2+8c/+fHV+qpkHCA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.23</title>
|
||||
<pubDate>Sat, 24 Jan 2026 13:02:18 +0000</pubDate>
|
||||
@@ -184,5 +89,127 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.23/Clawdbot-2026.1.23.zip" length="22326233" type="application/octet-stream" sparkle:edSignature="p40dFczUfmMpsif4BrEUYVqUPG2WiBXleWgefwu4WiqjuyXbw7CAaH5CpQKig/k2qRLlE59kX7AR/qJqmy+yCA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.22</title>
|
||||
<pubDate>Fri, 23 Jan 2026 08:58:14 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>7530</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.22</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.22</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Highlight: Compaction safeguard now uses adaptive chunking, progressive fallback, and UI status + retries. (#1466) Thanks @dlauer.</li>
|
||||
<li>Providers: add Antigravity usage tracking to status output. (#1490) Thanks @patelhiren.</li>
|
||||
<li>Slack: add chat-type reply threading overrides via <code>replyToModeByChatType</code>. (#1442) Thanks @stefangalescu.</li>
|
||||
<li>BlueBubbles: add <code>asVoice</code> support for MP3/CAF voice memos in sendAttachment. (#1477, #1482) Thanks @Nicell.</li>
|
||||
<li>Onboarding: add hatch choice (TUI/Web/Later), token explainer, background dashboard seed on macOS, and showcase link.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.</li>
|
||||
<li>Message tool: keep path/filePath as-is for send; hydrate buffers only for sendAttachment. (#1444) Thanks @hopyky.</li>
|
||||
<li>Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.</li>
|
||||
<li>Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.</li>
|
||||
<li>Agents: sanitize assistant history text to strip tool-call markers. (#1456) Thanks @zerone0x.</li>
|
||||
<li>Discord: clarify Message Content Intent onboarding hint. (#1487) Thanks @kyleok.</li>
|
||||
<li>Gateway: stop the service before uninstalling and fail if it remains loaded.</li>
|
||||
<li>Agents: surface concrete API error details instead of generic AI service errors.</li>
|
||||
<li>Exec: fall back to non-PTY when PTY spawn fails (EBADF). (#1484)</li>
|
||||
<li>Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj.</li>
|
||||
<li>Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider.</li>
|
||||
<li>Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c.</li>
|
||||
<li>Agents: make tool summaries more readable and only show optional params when set.</li>
|
||||
<li>Agents: honor SOUL.md guidance even when the file is nested or path-qualified. (#1434) Thanks @neooriginal.</li>
|
||||
<li>Matrix (plugin): persist m.direct for resolved DMs and harden room fallback. (#1436, #1486) Thanks @sibbl.</li>
|
||||
<li>CLI: prefer <code>~</code> for home paths in output.</li>
|
||||
<li>Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic.</li>
|
||||
<li>Agents: centralize transcript sanitization in the runner; keep <final> tags and error turns intact.</li>
|
||||
<li>Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik.</li>
|
||||
<li>Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff.</li>
|
||||
<li>Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.</li>
|
||||
<li>Slack: reduce WebClient retries to avoid duplicate sends. (#1481)</li>
|
||||
<li>Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.</li>
|
||||
<li>macOS: prefer linked channels in gateway summary to avoid false “not linked” status.</li>
|
||||
<li>macOS/tests: fix gateway summary lookup after guard unwrap; prevent browser opens during tests. (ECID-1483)</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.22/Clawdbot-2026.1.22.zip" length="22302446" type="application/octet-stream" sparkle:edSignature="w/EzfwGBCRRuCg5vz8enIfYujxOZJWRw9PaunQ7gIafKwnBJSTtxcnkvMVwQsnBwB6VN5Tu2MPij7PjDFFX+CA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.21</title>
|
||||
<pubDate>Thu, 22 Jan 2026 12:22:35 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>7374</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.21</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.21</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster</li>
|
||||
<li>Custom assistant identity + avatars in the Control UI. https://docs.clawd.bot/cli/agents https://docs.clawd.bot/web/control-ui</li>
|
||||
<li>Cache optimizations: cache-ttl pruning + defaults reduce token spend on cold requests. https://docs.clawd.bot/concepts/session-pruning</li>
|
||||
<li>Exec approvals + elevated ask/full modes. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/elevated</li>
|
||||
<li>Signal typing/read receipts + MSTeams attachments. https://docs.clawd.bot/channels/signal https://docs.clawd.bot/channels/msteams</li>
|
||||
<li><code>/models</code> UX refresh + <code>clawdbot update wizard</code>. https://docs.clawd.bot/cli/models https://docs.clawd.bot/cli/update</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster (#1152) Thanks @vignesh07.</li>
|
||||
<li>Agents/UI: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer. https://docs.clawd.bot/gateway/configuration https://docs.clawd.bot/cli/agents</li>
|
||||
<li>Control UI: add custom assistant identity support and per-session identity display. (#1420) Thanks @robbyczgw-cla. https://docs.clawd.bot/web/control-ui</li>
|
||||
<li>CLI: add <code>clawdbot update wizard</code> with interactive channel selection + restart prompts, plus preflight checks before rebasing. https://docs.clawd.bot/cli/update</li>
|
||||
<li>Models/Commands: add <code>/models</code>, improve <code>/model</code> listing UX, and expand <code>clawdbot models</code> paging. (#1398) Thanks @vignesh07. https://docs.clawd.bot/cli/models</li>
|
||||
<li>CLI: move gateway service commands under <code>clawdbot gateway</code>, flatten node service commands under <code>clawdbot node</code>, and add <code>gateway probe</code> for reachability. https://docs.clawd.bot/cli/gateway https://docs.clawd.bot/cli/node</li>
|
||||
<li>Exec: add elevated ask/full modes, tighten allowlist gating, and render approvals tables on write. https://docs.clawd.bot/tools/elevated https://docs.clawd.bot/tools/exec-approvals</li>
|
||||
<li>Exec approvals: default to local host, add gateway/node targeting + target details, support wildcard agent allowlists, and tighten allowlist parsing/safe bins. https://docs.clawd.bot/cli/approvals https://docs.clawd.bot/tools/exec-approvals</li>
|
||||
<li>Heartbeat: allow explicit session keys and active hours. (#1256) Thanks @zknicker. https://docs.clawd.bot/gateway/heartbeat</li>
|
||||
<li>Sessions: add per-channel idle durations via <code>sessions.channelIdleMinutes</code>. (#1353) Thanks @cash-echo-bot.</li>
|
||||
<li>Nodes: run exec-style, expose PATH in status/describe, and bootstrap PATH for node-host execution. https://docs.clawd.bot/cli/node</li>
|
||||
<li>Cache: add <code>cache.ttlPrune</code> mode and auth-aware defaults for cache TTL behavior.</li>
|
||||
<li>Queue: add per-channel debounce overrides for auto-reply. https://docs.clawd.bot/concepts/queue</li>
|
||||
<li>Discord: add wildcard channel config support. (#1334) Thanks @pvoo. https://docs.clawd.bot/channels/discord</li>
|
||||
<li>Signal: add typing indicators and DM read receipts via signal-cli. https://docs.clawd.bot/channels/signal</li>
|
||||
<li>MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero. https://docs.clawd.bot/channels/msteams</li>
|
||||
<li>Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).</li>
|
||||
<li>macOS: refresh Settings (location access in Permissions, connection mode in menu, remove CLI install UI).</li>
|
||||
<li>Diagnostics: add cache trace config for debugging. (#1370) Thanks @parubets.</li>
|
||||
<li>Docs: Lobster guides + org URL updates, /model allowlist troubleshooting, Gmail message search examples, gateway.mode troubleshooting, prompt injection guidance, npm prefix/node CLI notes, control UI dev gatewayUrl note, tool_use FAQ, showcase video, and sharp/node-gyp workaround. (#1427, #1220, #1405) Thanks @vignesh07, @mbelinky.</li>
|
||||
</ul>
|
||||
<h3>Breaking</h3>
|
||||
<ul>
|
||||
<li><strong>BREAKING:</strong> Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set <code>gateway.controlUi.allowInsecureAuth: true</code> to allow token-only auth. https://docs.clawd.bot/web/control-ui#insecure-http</li>
|
||||
<li><strong>BREAKING:</strong> Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Streaming/Typing/Media: keep reply tags across streamed chunks, start typing indicators at run start, and accept MEDIA paths with spaces/tilde while preferring the message tool hint for image replies.</li>
|
||||
<li>Agents/Providers: drop unsigned thinking blocks for Claude models (Google Antigravity) and enforce alphanumeric tool call ids for strict providers (Mistral/OpenRouter). (#1372) Thanks @zerone0x.</li>
|
||||
<li>Exec approvals: treat main as the default agent, align node/gateway allowlist prechecks, validate resolved paths, avoid allowlist resolve races, and avoid null optional params. (#1417, #1414, #1425) Thanks @czekaj.</li>
|
||||
<li>Exec/Windows: resolve Windows exec paths with extensions and handle safe-bin exe names.</li>
|
||||
<li>Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.</li>
|
||||
<li>Gateway: prevent multiple gateways from sharing the same config/state (singleton lock), keep auto bind loopback-first with explicit tailnet binding, and improve SSH auth handling. (#1380)</li>
|
||||
<li>Control UI: remove the chat stop button, keep the composer aligned to the bottom edge, stabilize session previews, and refresh the debug panel on route-driven tab changes. (#1373) Thanks @yazinsai.</li>
|
||||
<li>UI/config: export <code>SECTION_META</code> for config form modules. (#1418) Thanks @MaudeBot.</li>
|
||||
<li>macOS: keep chat pinned during streaming replies, include Textual resources, respect wildcard exec approvals, allow SSH agent auth, and default distribution builds to universal binaries. (#1279, #1362, #1384, #1396) Thanks @ameno-, @JustYannicc.</li>
|
||||
<li>BlueBubbles: resolve short message IDs safely, expose full IDs in templates, and harden short-id fetch wrappers. (#1369, #1387) Thanks @tyler6204.</li>
|
||||
<li>Models/Configure: inherit session model overrides in threads/topics, map OpenCode Zen models to the correct APIs, narrow Anthropic OAuth allowlist handling, seed allowlist fallbacks, list the full catalog when no allowlist is set, and limit <code>/model</code> list output. (#1376, #1416)</li>
|
||||
<li>Memory: prevent CLI hangs by deferring vector probes, add sqlite-vec/embedding timeouts, and make session memory indexing async.</li>
|
||||
<li>Cron: cap reminder context history to 10 messages and honor <code>contextMessages</code>. (#1103) Thanks @mkbehr.</li>
|
||||
<li>Cache: restore the 1h cache TTL option and reset the pruning window.</li>
|
||||
<li>Zalo Personal: tolerate ANSI/log-prefixed JSON output from <code>zca</code>. (#1379) Thanks @ptn1411.</li>
|
||||
<li>Browser: suppress Chrome restore prompts for managed profiles. (#1419) Thanks @jamesgroat.</li>
|
||||
<li>Infra: preserve fetch helper methods/preconnect when wrapping abort signals and normalize Telegram fetch aborts.</li>
|
||||
<li>Config/Doctor: avoid stack traces for invalid configs, log the config path, avoid WhatsApp config resurrection, and warn when <code>gateway.mode</code> is unset. (#900)</li>
|
||||
<li>CLI: read Codex CLI account_id for workspace billing. (#1422) Thanks @aj47.</li>
|
||||
<li>Logs/Status: align rolling log filenames with local time and report sandboxed runtime in <code>clawdbot status</code>. (#1343)</li>
|
||||
<li>Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell.</li>
|
||||
<li>Nodes/Subagents: include agent/node/gateway context in tool failure logs and ensure subagent list uses the command session.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.21/Clawdbot-2026.1.21.zip" length="22284796" type="application/octet-stream" sparkle:edSignature="pXji4NMA/cu35iMxln385d6LnsT4yIZtFtFiR7sIimKeSC2CsyeWzzSD0EhJsN98PdSoy69iEFZt4I2ZtNCECg=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
</rss>
|
||||
@@ -427,8 +427,8 @@ public actor GatewayChannelActor {
|
||||
Task { await self.handleReceiveFailure(err) }
|
||||
case let .success(msg):
|
||||
Task {
|
||||
await self.handle(msg)
|
||||
await self.listen()
|
||||
await self.handle(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -619,29 +619,6 @@ public actor GatewayChannelActor {
|
||||
return Data() // Should not happen, but tolerate empty payloads.
|
||||
}
|
||||
|
||||
public func send(method: String, params: [String: AnyCodable]?) async throws {
|
||||
try await self.connectOrThrow(context: "gateway connect")
|
||||
let payload = try self.encodeRequest(method: method, params: params, kind: "send")
|
||||
guard let task = self.task else {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 5,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway socket unavailable"])
|
||||
}
|
||||
do {
|
||||
try await task.send(.data(payload.data))
|
||||
} catch {
|
||||
let wrapped = self.wrap(error, context: "gateway send \(method)")
|
||||
self.connected = false
|
||||
self.task?.cancel(with: .goingAway, reason: nil)
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.scheduleReconnect()
|
||||
}
|
||||
throw wrapped
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap low-level URLSession/WebSocket errors with context so UI can surface them.
|
||||
private func wrap(_ error: Error, context: String) -> Error {
|
||||
if let urlError = error as? URLError {
|
||||
|
||||
@@ -143,7 +143,7 @@ public actor GatewayNodeSession {
|
||||
"payloadJSON": AnyCodable(payloadJSON ?? NSNull()),
|
||||
]
|
||||
do {
|
||||
try await channel.send(method: "node.event", params: params)
|
||||
_ = try await channel.request(method: "node.event", params: params, timeoutMs: 8000)
|
||||
} catch {
|
||||
self.logger.error("node event failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
@@ -224,7 +224,7 @@ public actor GatewayNodeSession {
|
||||
])
|
||||
}
|
||||
do {
|
||||
try await channel.send(method: "node.invoke.result", params: params)
|
||||
_ = try await channel.request(method: "node.invoke.result", params: params, timeoutMs: 15000)
|
||||
} catch {
|
||||
self.logger.error("node invoke result failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
|
||||
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var queue: [URLSessionWebSocketTask.Message] = []
|
||||
private var pendingHandler: (@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
|
||||
private var pendingContinuation: CheckedContinuation<URLSessionWebSocketTask.Message, Error>?
|
||||
private let encoder = JSONEncoder()
|
||||
private let decoder = JSONDecoder()
|
||||
|
||||
var state: URLSessionTask.State = .running
|
||||
|
||||
func resume() {}
|
||||
|
||||
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
||||
state = .canceling
|
||||
}
|
||||
|
||||
func send(_ message: URLSessionWebSocketTask.Message) async throws {
|
||||
guard case let .data(data) = message else { return }
|
||||
guard let frame = try? decoder.decode(RequestFrame.self, from: data) else { return }
|
||||
switch frame.method {
|
||||
case "connect":
|
||||
enqueueResponse(id: frame.id, payload: helloOkPayload())
|
||||
default:
|
||||
enqueueResponse(id: frame.id, payload: ["ok": true])
|
||||
}
|
||||
}
|
||||
|
||||
func receive() async throws -> URLSessionWebSocketTask.Message {
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
lock.lock()
|
||||
if !queue.isEmpty {
|
||||
let msg = queue.removeFirst()
|
||||
lock.unlock()
|
||||
cont.resume(returning: msg)
|
||||
return
|
||||
}
|
||||
pendingContinuation = cont
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func receive(
|
||||
completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
|
||||
{
|
||||
lock.lock()
|
||||
if !queue.isEmpty {
|
||||
let msg = queue.removeFirst()
|
||||
lock.unlock()
|
||||
completionHandler(.success(msg))
|
||||
return
|
||||
}
|
||||
pendingHandler = completionHandler
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func enqueue(_ message: URLSessionWebSocketTask.Message) {
|
||||
lock.lock()
|
||||
if let handler = pendingHandler {
|
||||
pendingHandler = nil
|
||||
lock.unlock()
|
||||
handler(.success(message))
|
||||
return
|
||||
}
|
||||
if let continuation = pendingContinuation {
|
||||
pendingContinuation = nil
|
||||
lock.unlock()
|
||||
continuation.resume(returning: message)
|
||||
return
|
||||
}
|
||||
queue.append(message)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
private func enqueueResponse(id: String, payload: [String: Any]) {
|
||||
let response = ResponseFrame(
|
||||
type: "res",
|
||||
id: id,
|
||||
ok: true,
|
||||
payload: ClawdbotProtocol.AnyCodable(payload),
|
||||
error: nil)
|
||||
guard let data = try? encoder.encode(response) else { return }
|
||||
enqueue(.data(data))
|
||||
}
|
||||
|
||||
private func helloOkPayload() -> [String: Any] {
|
||||
[
|
||||
"type": "hello.ok",
|
||||
"protocol": 1,
|
||||
"server": [:],
|
||||
"features": [:],
|
||||
"snapshot": [
|
||||
"presence": [],
|
||||
"health": [:],
|
||||
"stateVersion": [
|
||||
"presence": 0,
|
||||
"health": 0,
|
||||
],
|
||||
"uptimeMs": 0,
|
||||
],
|
||||
"policy": [
|
||||
"tickIntervalMs": 1000,
|
||||
],
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private final class FakeWebSocketSession: WebSocketSessioning {
|
||||
let task: FakeWebSocketTask
|
||||
|
||||
init(task: FakeWebSocketTask) {
|
||||
self.task = task
|
||||
}
|
||||
|
||||
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
|
||||
WebSocketTaskBox(task: task)
|
||||
}
|
||||
}
|
||||
|
||||
private actor AsyncSignal {
|
||||
private var continuation: CheckedContinuation<Result<Void, Error>, Never>?
|
||||
private var stored: Result<Void, Error>?
|
||||
|
||||
func finish(_ result: Result<Void, Error>) {
|
||||
if let continuation {
|
||||
self.continuation = nil
|
||||
continuation.resume(returning: result)
|
||||
return
|
||||
}
|
||||
stored = result
|
||||
}
|
||||
|
||||
func wait() async throws {
|
||||
let result = await withCheckedContinuation { cont in
|
||||
if let stored {
|
||||
self.stored = nil
|
||||
cont.resume(returning: stored)
|
||||
return
|
||||
}
|
||||
continuation = cont
|
||||
}
|
||||
switch result {
|
||||
case .success:
|
||||
return
|
||||
case let .failure(error):
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum TestError: Error {
|
||||
case timeout
|
||||
}
|
||||
|
||||
struct GatewayChannelTests {
|
||||
@Test
|
||||
func listenRearmsBeforePushHandler() async throws {
|
||||
let task = FakeWebSocketTask()
|
||||
let session = FakeWebSocketSession(task: task)
|
||||
let signal = AsyncSignal()
|
||||
let url = URL(string: "ws://example.invalid")!
|
||||
final class ChannelBox { var channel: GatewayChannelActor? }
|
||||
let box = ChannelBox()
|
||||
|
||||
let channel = GatewayChannelActor(
|
||||
url: url,
|
||||
token: nil,
|
||||
session: WebSocketSessionBox(session: session),
|
||||
pushHandler: { push in
|
||||
guard case let .event(evt) = push, evt.event == "test.event" else { return }
|
||||
guard let channel = box.channel else { return }
|
||||
let params: [String: ClawdbotKit.AnyCodable] = [
|
||||
"event": ClawdbotKit.AnyCodable("test"),
|
||||
"payloadJSON": ClawdbotKit.AnyCodable(NSNull()),
|
||||
]
|
||||
do {
|
||||
_ = try await channel.request(method: "node.event", params: params, timeoutMs: 50)
|
||||
await signal.finish(.success(()))
|
||||
} catch {
|
||||
await signal.finish(.failure(error))
|
||||
}
|
||||
})
|
||||
box.channel = channel
|
||||
|
||||
let challenge = EventFrame(
|
||||
type: "event",
|
||||
event: "connect.challenge",
|
||||
payload: ClawdbotProtocol.AnyCodable(["nonce": "test-nonce"]),
|
||||
seq: nil,
|
||||
stateversion: nil)
|
||||
let encoder = JSONEncoder()
|
||||
task.enqueue(.data(try encoder.encode(challenge)))
|
||||
|
||||
try await channel.connect()
|
||||
|
||||
let event = EventFrame(
|
||||
type: "event",
|
||||
event: "test.event",
|
||||
payload: ClawdbotProtocol.AnyCodable([:]),
|
||||
seq: nil,
|
||||
stateversion: nil)
|
||||
task.enqueue(.data(try encoder.encode(event)))
|
||||
|
||||
try await AsyncTimeout.withTimeout(seconds: 1, onTimeout: { TestError.timeout }) {
|
||||
try await signal.wait()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,7 +196,7 @@ Provider options:
|
||||
- `channels.bluebubbles.sendReadReceipts`: Send read receipts (default: `true`).
|
||||
- `channels.bluebubbles.blockStreaming`: Enable block streaming (default: `true`).
|
||||
- `channels.bluebubbles.textChunkLimit`: Outbound chunk size in chars (default: 4000).
|
||||
- `channels.bluebubbles.chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on blank lines (paragraph boundaries) before length chunking.
|
||||
- `channels.bluebubbles.chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on every newline and sends each line immediately during streaming.
|
||||
- `channels.bluebubbles.mediaMaxMb`: Inbound media cap in MB (default: 8).
|
||||
- `channels.bluebubbles.historyLimit`: Max group messages for context (0 disables).
|
||||
- `channels.bluebubbles.dmHistoryLimit`: DM history limit.
|
||||
|
||||
@@ -205,7 +205,7 @@ Notes:
|
||||
## Capabilities & limits
|
||||
- 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).
|
||||
- Optional newline chunking: set `channels.discord.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking.
|
||||
- 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).
|
||||
- Mention-gated guild replies by default to avoid noisy bots.
|
||||
- Reply context is injected when a message references another message (quoted content + ids).
|
||||
@@ -307,7 +307,7 @@ ack reaction after the bot replies.
|
||||
- `guilds.<id>.requireMention`: per-guild mention requirement (overridable per channel).
|
||||
- `guilds.<id>.reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`).
|
||||
- `textChunkLimit`: outbound text chunk size (chars). Default: 2000.
|
||||
- `chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on blank lines (paragraph boundaries) before length chunking.
|
||||
- `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.
|
||||
- `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).
|
||||
|
||||
@@ -219,7 +219,7 @@ This is useful when you want an isolated personality/model for a specific thread
|
||||
|
||||
## Limits
|
||||
- Outbound text is chunked to `channels.imessage.textChunkLimit` (default 4000).
|
||||
- Optional newline chunking: set `channels.imessage.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking.
|
||||
- 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).
|
||||
|
||||
## Addressing / delivery targets
|
||||
@@ -254,7 +254,7 @@ Provider options:
|
||||
- `channels.imessage.includeAttachments`: ingest attachments into context.
|
||||
- `channels.imessage.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||
- `channels.imessage.textChunkLimit`: outbound chunk size (chars).
|
||||
- `channels.imessage.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
||||
- `channels.imessage.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
|
||||
|
||||
Related global options:
|
||||
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`).
|
||||
|
||||
@@ -215,7 +215,7 @@ Provider options:
|
||||
- `channels.matrix.initialSyncLimit`: initial sync limit.
|
||||
- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound).
|
||||
- `channels.matrix.textChunkLimit`: outbound text chunk size (chars).
|
||||
- `channels.matrix.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
||||
- `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.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).
|
||||
|
||||
@@ -415,7 +415,7 @@ Key settings (see `/gateway/configuration` for shared channel patterns):
|
||||
- `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.textChunkLimit`: outbound text chunk size.
|
||||
- `channels.msteams.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
||||
- `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.requireMention`: require @mention in channels/groups (default true).
|
||||
- `channels.msteams.replyStyle`: `thread | top-level` (see [Reply Style](#reply-style-threads-vs-posts)).
|
||||
|
||||
@@ -114,7 +114,7 @@ Provider options:
|
||||
- `channels.nextcloud-talk.dmHistoryLimit`: DM history limit (0 disables).
|
||||
- `channels.nextcloud-talk.dms`: per-DM overrides (historyLimit).
|
||||
- `channels.nextcloud-talk.textChunkLimit`: outbound text chunk size (chars).
|
||||
- `channels.nextcloud-talk.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
||||
- `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.blockStreamingCoalesce`: block streaming coalesce tuning.
|
||||
- `channels.nextcloud-talk.mediaMaxMb`: inbound media cap (MB).
|
||||
@@ -111,7 +111,7 @@ Groups:
|
||||
|
||||
## Media + limits
|
||||
- Outbound text is chunked to `channels.signal.textChunkLimit` (default 4000).
|
||||
- Optional newline chunking: set `channels.signal.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking.
|
||||
- Optional newline chunking: set `channels.signal.chunkMode="newline"` to split on each line before length chunking.
|
||||
- Attachments supported (base64 fetched from `signal-cli`).
|
||||
- Default media cap: `channels.signal.mediaMaxMb` (default 8).
|
||||
- Use `channels.signal.ignoreAttachments` to skip downloading media.
|
||||
@@ -170,7 +170,7 @@ Provider options:
|
||||
- `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.textChunkLimit`: outbound chunk size (chars).
|
||||
- `channels.signal.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
||||
- `channels.signal.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
|
||||
- `channels.signal.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||
|
||||
Related global options:
|
||||
|
||||
+16
-16
@@ -26,7 +26,7 @@ Minimal config:
|
||||
```
|
||||
|
||||
### Setup
|
||||
1) Create a Slack app (From scratch) in https://api.slack.com/apps.
|
||||
1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps.
|
||||
2) **Socket Mode** → toggle on. Then go to **Basic Information** → **App-Level Tokens** → **Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`).
|
||||
3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`).
|
||||
4) Optional: **OAuth & Permissions** → add **User Token Scopes** (see the read-only list below). Reinstall the app and copy the **User OAuth Token** (`xoxp-...`).
|
||||
@@ -245,29 +245,29 @@ If you enable native commands, add one `slash_commands` entry per command you wa
|
||||
## Scopes (current vs optional)
|
||||
Slack's Conversations API is type-scoped: you only need the scopes for the
|
||||
conversation types you actually touch (channels, groups, im, mpim). See
|
||||
https://docs.slack.dev/apis/web-api/using-the-conversations-api/ for the overview.
|
||||
https://api.channels.slack.com/docs/conversations-api for the overview.
|
||||
|
||||
### Bot token scopes (required)
|
||||
- `chat:write` (send/update/delete messages via `chat.postMessage`)
|
||||
https://docs.slack.dev/reference/methods/chat.postMessage
|
||||
https://api.channels.slack.com/methods/chat.postMessage
|
||||
- `im:write` (open DMs via `conversations.open` for user DMs)
|
||||
https://docs.slack.dev/reference/methods/conversations.open
|
||||
https://api.channels.slack.com/methods/conversations.open
|
||||
- `channels:history`, `groups:history`, `im:history`, `mpim:history`
|
||||
https://docs.slack.dev/reference/methods/conversations.history
|
||||
https://api.channels.slack.com/methods/conversations.history
|
||||
- `channels:read`, `groups:read`, `im:read`, `mpim:read`
|
||||
https://docs.slack.dev/reference/methods/conversations.info
|
||||
https://api.channels.slack.com/methods/conversations.info
|
||||
- `users:read` (user lookup)
|
||||
https://docs.slack.dev/reference/methods/users.info
|
||||
https://api.channels.slack.com/methods/users.info
|
||||
- `reactions:read`, `reactions:write` (`reactions.get` / `reactions.add`)
|
||||
https://docs.slack.dev/reference/methods/reactions.get
|
||||
https://docs.slack.dev/reference/methods/reactions.add
|
||||
https://api.channels.slack.com/methods/reactions.get
|
||||
https://api.channels.slack.com/methods/reactions.add
|
||||
- `pins:read`, `pins:write` (`pins.list` / `pins.add` / `pins.remove`)
|
||||
https://docs.slack.dev/reference/scopes/pins.read
|
||||
https://docs.slack.dev/reference/scopes/pins.write
|
||||
https://api.channels.slack.com/scopes/pins:read
|
||||
https://api.channels.slack.com/scopes/pins:write
|
||||
- `emoji:read` (`emoji.list`)
|
||||
https://docs.slack.dev/reference/scopes/emoji.read
|
||||
https://api.channels.slack.com/scopes/emoji:read
|
||||
- `files:write` (uploads via `files.uploadV2`)
|
||||
https://docs.slack.dev/messaging/working-with-files/#upload
|
||||
https://api.channels.slack.com/messaging/files/uploading
|
||||
|
||||
### User token scopes (optional, read-only by default)
|
||||
Add these under **User Token Scopes** if you configure `channels.slack.userToken`.
|
||||
@@ -284,9 +284,9 @@ Add these under **User Token Scopes** if you configure `channels.slack.userToken
|
||||
- `mpim:write` (only if we add group-DM open/DM start via `conversations.open`)
|
||||
- `groups:write` (only if we add private-channel management: create/rename/invite/archive)
|
||||
- `chat:write.public` (only if we want to post to channels the bot isn't in)
|
||||
https://docs.slack.dev/reference/scopes/chat.write.public
|
||||
https://api.channels.slack.com/scopes/chat:write.public
|
||||
- `users:read.email` (only if we need email fields from `users.info`)
|
||||
https://docs.slack.dev/changelog/2017-04-narrowing-email-access
|
||||
https://api.channels.slack.com/changelog/2017-04-narrowing-email-access
|
||||
- `files:read` (only if we start listing/reading file metadata)
|
||||
|
||||
## Config
|
||||
@@ -349,7 +349,7 @@ ack reaction after the bot replies.
|
||||
|
||||
## Limits
|
||||
- Outbound text is chunked to `channels.slack.textChunkLimit` (default 4000).
|
||||
- Optional newline chunking: set `channels.slack.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking.
|
||||
- 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).
|
||||
|
||||
## Reply threading
|
||||
|
||||
@@ -135,7 +135,7 @@ Notes:
|
||||
|
||||
## Limits
|
||||
- Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000).
|
||||
- Optional newline chunking: set `channels.telegram.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking.
|
||||
- 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).
|
||||
- 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).
|
||||
@@ -524,7 +524,7 @@ Provider options:
|
||||
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
|
||||
- `channels.telegram.replyToMode`: `off | first | all` (default: `first`).
|
||||
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
|
||||
- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
||||
- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
|
||||
- `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true).
|
||||
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
|
||||
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||
|
||||
@@ -271,7 +271,7 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
|
||||
|
||||
## Limits
|
||||
- Outbound text is chunked to `channels.whatsapp.textChunkLimit` (default 4000).
|
||||
- Optional newline chunking: set `channels.whatsapp.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking.
|
||||
- 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).
|
||||
- Outbound media items are capped by `agents.defaults.mediaMaxMb` (default 5 MB).
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ Legend:
|
||||
- `agents.defaults.blockStreamingChunk`: `{ minChars, maxChars, breakPreference? }`.
|
||||
- `agents.defaults.blockStreamingCoalesce`: `{ minChars?, maxChars?, idleMs? }` (merge streamed blocks before send).
|
||||
- Channel hard cap: `*.textChunkLimit` (e.g., `channels.whatsapp.textChunkLimit`).
|
||||
- Channel chunk mode: `*.chunkMode` (`length` default, `newline` splits on blank lines (paragraph boundaries) before length chunking).
|
||||
- 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.
|
||||
|
||||
**Boundary semantics:**
|
||||
|
||||
@@ -1131,7 +1131,7 @@ Reaction notification modes:
|
||||
- `own`: reactions on the bot's own messages (default).
|
||||
- `all`: all reactions on all messages.
|
||||
- `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 blank lines (paragraph 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). Set `channels.discord.chunkMode="newline"` to split on line boundaries before length chunking. Discord clients can clip very tall messages, so `channels.discord.maxLinesPerMessage` (default 17) splits long multi-line replies even when under 2000 chars.
|
||||
Retry policy defaults and behavior are documented in [Retry policy](/concepts/retry).
|
||||
|
||||
### `channels.googlechat` (Chat API webhook)
|
||||
@@ -2847,9 +2847,8 @@ Control UI base path:
|
||||
- `gateway.controlUi.basePath` sets the URL prefix where the Control UI is served.
|
||||
- Examples: `"/ui"`, `"/clawdbot"`, `"/apps/clawdbot"`.
|
||||
- Default: root (`/`) (unchanged).
|
||||
- `gateway.controlUi.allowInsecureAuth` allows token-only auth for the Control UI and skips
|
||||
device identity + pairing (even on HTTPS). Default: `false`. Prefer HTTPS
|
||||
(Tailscale Serve) or `127.0.0.1`.
|
||||
- `gateway.controlUi.allowInsecureAuth` allows token-only auth over **HTTP** (no device identity).
|
||||
Default: `false`. Prefer HTTPS (Tailscale Serve) or `127.0.0.1`.
|
||||
|
||||
Related docs:
|
||||
- [Control UI](/web/control-ui)
|
||||
|
||||
@@ -58,7 +58,7 @@ When the audit prints findings, treat this as a priority order:
|
||||
|
||||
The Control UI needs a **secure context** (HTTPS or localhost) to generate device
|
||||
identity. If you enable `gateway.controlUi.allowInsecureAuth`, the UI falls back
|
||||
to **token-only auth** and skips device pairing (even on HTTPS). This is a security
|
||||
to **token-only auth** on plain HTTP and skips device pairing. This is a security
|
||||
downgrade—prefer HTTPS (Tailscale Serve) or open the UI on `127.0.0.1`.
|
||||
|
||||
`clawdbot security audit` warns when this setting is enabled.
|
||||
|
||||
@@ -30,17 +30,17 @@ Notes:
|
||||
# From repo root; set release IDs so Sparkle feed is enabled.
|
||||
# APP_BUILD must be numeric + monotonic for Sparkle compare.
|
||||
BUNDLE_ID=com.clawdbot.mac \
|
||||
APP_VERSION=2026.1.24-1 \
|
||||
APP_VERSION=2026.1.24 \
|
||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
scripts/package-mac-app.sh
|
||||
|
||||
# Zip for distribution (includes resource forks for Sparkle delta support)
|
||||
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.24-1.zip
|
||||
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.24.zip
|
||||
|
||||
# Optional: also build a styled DMG for humans (drag to /Applications)
|
||||
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.24-1.dmg
|
||||
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.24.dmg
|
||||
|
||||
# Recommended: build + notarize/staple zip + DMG
|
||||
# First, create a keychain profile once:
|
||||
@@ -48,26 +48,26 @@ scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.24-1.dmg
|
||||
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
|
||||
NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \
|
||||
BUNDLE_ID=com.clawdbot.mac \
|
||||
APP_VERSION=2026.1.24-1 \
|
||||
APP_VERSION=2026.1.24 \
|
||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
scripts/package-mac-dist.sh
|
||||
|
||||
# Optional: ship dSYM alongside the release
|
||||
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.24-1.dSYM.zip
|
||||
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.24.dSYM.zip
|
||||
```
|
||||
|
||||
## Appcast entry
|
||||
Use the release note generator so Sparkle renders formatted HTML notes:
|
||||
```bash
|
||||
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.24-1.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
|
||||
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.24.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
|
||||
```
|
||||
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
|
||||
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
|
||||
|
||||
## Publish & verify
|
||||
- Upload `Clawdbot-2026.1.24-1.zip` (and `Clawdbot-2026.1.24-1.dSYM.zip`) to the GitHub release for tag `v2026.1.24-1`.
|
||||
- Upload `Clawdbot-2026.1.24.zip` (and `Clawdbot-2026.1.24.dSYM.zip`) to the GitHub release for tag `v2026.1.24`.
|
||||
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`.
|
||||
- Sanity checks:
|
||||
- `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200.
|
||||
|
||||
@@ -17,7 +17,7 @@ When the operator says “release”, immediately do this preflight (no extra qu
|
||||
- Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed.
|
||||
|
||||
1) **Version & metadata**
|
||||
- [ ] Bump `package.json` version (e.g., `2026.1.24`).
|
||||
- [ ] Bump `package.json` version (e.g., `1.1.0`).
|
||||
- [ ] Run `pnpm plugins:sync` to align extension package versions + changelogs.
|
||||
- [ ] Update CLI/version strings: [`src/cli/program.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/cli/program.ts) and the Baileys user agent in [`src/provider-web.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/provider-web.ts).
|
||||
- [ ] Confirm package metadata (name, description, repository, keywords, license) and `bin` map points to [`dist/entry.js`](https://github.com/clawdbot/clawdbot/blob/main/dist/entry.js) for `clawdbot`.
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
# Creating Custom Skills 🛠
|
||||
|
||||
Clawdbot is designed to be easily extensible. "Skills" are the primary way to add new capabilities to your assistant.
|
||||
|
||||
## What is a Skill?
|
||||
A skill is a directory containing a `SKILL.md` file (which provides instructions and tool definitions to the LLM) and optionally some scripts or resources.
|
||||
|
||||
## Step-by-Step: Your First Skill
|
||||
|
||||
### 1. Create the Directory
|
||||
Skills live in your workspace, usually `~/clawd/skills/`. Create a new folder for your skill:
|
||||
```bash
|
||||
mkdir -p ~/clawd/skills/hello-world
|
||||
```
|
||||
|
||||
### 2. Define the `SKILL.md`
|
||||
Create a `SKILL.md` file in that directory. This file uses YAML frontmatter for metadata and Markdown for instructions.
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: hello_world
|
||||
description: A simple skill that says hello.
|
||||
---
|
||||
|
||||
# Hello World Skill
|
||||
When the user asks for a greeting, use the `echo` tool to say "Hello from your custom skill!".
|
||||
```
|
||||
|
||||
### 3. Add Tools (Optional)
|
||||
You can define custom tools in the frontmatter or instruct the agent to use existing system tools (like `bash` or `browser`).
|
||||
|
||||
### 4. Refresh Clawdbot
|
||||
Ask your agent to "refresh skills" or restart the gateway. Clawdbot will discover the new directory and index the `SKILL.md`.
|
||||
|
||||
## Best Practices
|
||||
- **Be Concise**: Instruct the model on *what* to do, not how to be an AI.
|
||||
- **Safety First**: If your skill uses `bash`, ensure the prompts don't allow arbitrary command injection from untrusted user input.
|
||||
- **Test Locally**: Use `clawdbot agent --message "use my new skill"` to test.
|
||||
|
||||
## Shared Skills
|
||||
You can also browse and contribute skills to [ClawdHub](https://clawdhub.com).
|
||||
@@ -108,8 +108,8 @@ Clawdbot **blocks** Control UI connections without device identity.
|
||||
}
|
||||
```
|
||||
|
||||
This disables device identity + pairing for the Control UI (even on HTTPS). Use
|
||||
only if you trust the network.
|
||||
This disables device identity + pairing for the Control UI. Use only if you
|
||||
trust the network.
|
||||
|
||||
See [Tailscale](/gateway/tailscale) for HTTPS setup guidance.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/bluebubbles",
|
||||
"version": "2026.1.24",
|
||||
"version": "2026.1.23",
|
||||
"type": "module",
|
||||
"description": "Clawdbot BlueBubbles channel plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/copilot-proxy",
|
||||
"version": "2026.1.24",
|
||||
"version": "2026.1.23",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Copilot Proxy provider plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/diagnostics-otel",
|
||||
"version": "2026.1.24",
|
||||
"version": "2026.1.23",
|
||||
"type": "module",
|
||||
"description": "Clawdbot diagnostics OpenTelemetry exporter",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/discord",
|
||||
"version": "2026.1.24",
|
||||
"version": "2026.1.23",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Discord channel plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/google-antigravity-auth",
|
||||
"version": "2026.1.24",
|
||||
"version": "2026.1.23",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Google Antigravity OAuth provider plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -18,18 +18,7 @@ Restart the Gateway after enabling.
|
||||
clawdbot models auth login --provider google-gemini-cli --set-default
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
Requires the Gemini CLI to be installed (credentials are extracted automatically):
|
||||
|
||||
```bash
|
||||
brew install gemini-cli
|
||||
# or: npm install -g @google/gemini-cli
|
||||
```
|
||||
|
||||
## Env vars (optional)
|
||||
|
||||
Override auto-detected credentials with:
|
||||
## Env vars
|
||||
|
||||
- `CLAWDBOT_GEMINI_OAUTH_CLIENT_ID` / `GEMINI_CLI_OAUTH_CLIENT_ID`
|
||||
- `CLAWDBOT_GEMINI_OAUTH_CLIENT_SECRET` / `GEMINI_CLI_OAUTH_CLIENT_SECRET`
|
||||
@@ -1,151 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Mock fs module before importing the module under test
|
||||
const mockExistsSync = vi.fn();
|
||||
const mockReadFileSync = vi.fn();
|
||||
const mockRealpathSync = vi.fn();
|
||||
const mockReaddirSync = vi.fn();
|
||||
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs")>();
|
||||
return {
|
||||
...actual,
|
||||
existsSync: (...args: Parameters<typeof actual.existsSync>) => mockExistsSync(...args),
|
||||
readFileSync: (...args: Parameters<typeof actual.readFileSync>) => mockReadFileSync(...args),
|
||||
realpathSync: (...args: Parameters<typeof actual.realpathSync>) => mockRealpathSync(...args),
|
||||
readdirSync: (...args: Parameters<typeof actual.readdirSync>) => mockReaddirSync(...args),
|
||||
};
|
||||
});
|
||||
|
||||
describe("extractGeminiCliCredentials", () => {
|
||||
const normalizePath = (value: string) => value.replace(/\\/g, "/");
|
||||
const FAKE_CLIENT_ID = "123456789-abcdef.apps.googleusercontent.com";
|
||||
const FAKE_CLIENT_SECRET = "GOCSPX-FakeSecretValue123";
|
||||
const FAKE_OAUTH2_CONTENT = `
|
||||
const clientId = "${FAKE_CLIENT_ID}";
|
||||
const clientSecret = "${FAKE_CLIENT_SECRET}";
|
||||
`;
|
||||
|
||||
let originalPath: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
originalPath = process.env.PATH;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.PATH = originalPath;
|
||||
});
|
||||
|
||||
it("returns null when gemini binary is not in PATH", async () => {
|
||||
process.env.PATH = "/nonexistent";
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
|
||||
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
|
||||
clearCredentialsCache();
|
||||
expect(extractGeminiCliCredentials()).toBeNull();
|
||||
});
|
||||
|
||||
it("extracts credentials from oauth2.js in known path", async () => {
|
||||
const fakeBinDir = "/fake/bin";
|
||||
const fakeGeminiPath = join(fakeBinDir, "gemini");
|
||||
const fakeResolvedPath = "/fake/lib/node_modules/@google/gemini-cli/dist/index.js";
|
||||
const fakeOauth2Path =
|
||||
"/fake/lib/node_modules/@google/gemini-cli/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js";
|
||||
|
||||
process.env.PATH = fakeBinDir;
|
||||
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
const normalized = normalizePath(p);
|
||||
if (normalized === normalizePath(fakeGeminiPath)) return true;
|
||||
if (normalized === normalizePath(fakeOauth2Path)) return true;
|
||||
return false;
|
||||
});
|
||||
mockRealpathSync.mockReturnValue(fakeResolvedPath);
|
||||
mockReadFileSync.mockReturnValue(FAKE_OAUTH2_CONTENT);
|
||||
|
||||
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
|
||||
clearCredentialsCache();
|
||||
const result = extractGeminiCliCredentials();
|
||||
|
||||
expect(result).toEqual({
|
||||
clientId: FAKE_CLIENT_ID,
|
||||
clientSecret: FAKE_CLIENT_SECRET,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when oauth2.js cannot be found", async () => {
|
||||
const fakeBinDir = "/fake/bin";
|
||||
const fakeGeminiPath = join(fakeBinDir, "gemini");
|
||||
const fakeResolvedPath = "/fake/lib/node_modules/@google/gemini-cli/dist/index.js";
|
||||
|
||||
process.env.PATH = fakeBinDir;
|
||||
|
||||
mockExistsSync.mockImplementation(
|
||||
(p: string) => normalizePath(p) === normalizePath(fakeGeminiPath),
|
||||
);
|
||||
mockRealpathSync.mockReturnValue(fakeResolvedPath);
|
||||
mockReaddirSync.mockReturnValue([]); // Empty directory for recursive search
|
||||
|
||||
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
|
||||
clearCredentialsCache();
|
||||
expect(extractGeminiCliCredentials()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when oauth2.js lacks credentials", async () => {
|
||||
const fakeBinDir = "/fake/bin";
|
||||
const fakeGeminiPath = join(fakeBinDir, "gemini");
|
||||
const fakeResolvedPath = "/fake/lib/node_modules/@google/gemini-cli/dist/index.js";
|
||||
const fakeOauth2Path =
|
||||
"/fake/lib/node_modules/@google/gemini-cli/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js";
|
||||
|
||||
process.env.PATH = fakeBinDir;
|
||||
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
const normalized = normalizePath(p);
|
||||
if (normalized === normalizePath(fakeGeminiPath)) return true;
|
||||
if (normalized === normalizePath(fakeOauth2Path)) return true;
|
||||
return false;
|
||||
});
|
||||
mockRealpathSync.mockReturnValue(fakeResolvedPath);
|
||||
mockReadFileSync.mockReturnValue("// no credentials here");
|
||||
|
||||
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
|
||||
clearCredentialsCache();
|
||||
expect(extractGeminiCliCredentials()).toBeNull();
|
||||
});
|
||||
|
||||
it("caches credentials after first extraction", async () => {
|
||||
const fakeBinDir = "/fake/bin";
|
||||
const fakeGeminiPath = join(fakeBinDir, "gemini");
|
||||
const fakeResolvedPath = "/fake/lib/node_modules/@google/gemini-cli/dist/index.js";
|
||||
const fakeOauth2Path =
|
||||
"/fake/lib/node_modules/@google/gemini-cli/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js";
|
||||
|
||||
process.env.PATH = fakeBinDir;
|
||||
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
const normalized = normalizePath(p);
|
||||
if (normalized === normalizePath(fakeGeminiPath)) return true;
|
||||
if (normalized === normalizePath(fakeOauth2Path)) return true;
|
||||
return false;
|
||||
});
|
||||
mockRealpathSync.mockReturnValue(fakeResolvedPath);
|
||||
mockReadFileSync.mockReturnValue(FAKE_OAUTH2_CONTENT);
|
||||
|
||||
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
|
||||
clearCredentialsCache();
|
||||
|
||||
// First call
|
||||
const result1 = extractGeminiCliCredentials();
|
||||
expect(result1).not.toBeNull();
|
||||
|
||||
// Second call should use cache (readFileSync not called again)
|
||||
const readCount = mockReadFileSync.mock.calls.length;
|
||||
const result2 = extractGeminiCliCredentials();
|
||||
expect(result2).toEqual(result1);
|
||||
expect(mockReadFileSync.mock.calls.length).toBe(readCount);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import { delimiter, dirname, join } from "node:path";
|
||||
|
||||
const CLIENT_ID_KEYS = ["CLAWDBOT_GEMINI_OAUTH_CLIENT_ID", "GEMINI_CLI_OAUTH_CLIENT_ID"];
|
||||
const CLIENT_SECRET_KEYS = [
|
||||
@@ -48,98 +47,15 @@ function resolveEnv(keys: string[]): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cachedGeminiCliCredentials: { clientId: string; clientSecret: string } | null = null;
|
||||
|
||||
/** @internal */
|
||||
export function clearCredentialsCache(): void {
|
||||
cachedGeminiCliCredentials = null;
|
||||
}
|
||||
|
||||
/** Extracts OAuth credentials from the installed Gemini CLI's bundled oauth2.js. */
|
||||
export function extractGeminiCliCredentials(): { clientId: string; clientSecret: string } | null {
|
||||
if (cachedGeminiCliCredentials) return cachedGeminiCliCredentials;
|
||||
|
||||
try {
|
||||
const geminiPath = findInPath("gemini");
|
||||
if (!geminiPath) return null;
|
||||
|
||||
const resolvedPath = realpathSync(geminiPath);
|
||||
const geminiCliDir = dirname(dirname(resolvedPath));
|
||||
|
||||
const searchPaths = [
|
||||
join(geminiCliDir, "node_modules", "@google", "gemini-cli-core", "dist", "src", "code_assist", "oauth2.js"),
|
||||
join(geminiCliDir, "node_modules", "@google", "gemini-cli-core", "dist", "code_assist", "oauth2.js"),
|
||||
];
|
||||
|
||||
let content: string | null = null;
|
||||
for (const p of searchPaths) {
|
||||
if (existsSync(p)) {
|
||||
content = readFileSync(p, "utf8");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!content) {
|
||||
const found = findFile(geminiCliDir, "oauth2.js", 10);
|
||||
if (found) content = readFileSync(found, "utf8");
|
||||
}
|
||||
if (!content) return null;
|
||||
|
||||
const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/);
|
||||
const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/);
|
||||
if (idMatch && secretMatch) {
|
||||
cachedGeminiCliCredentials = { clientId: idMatch[1], clientSecret: secretMatch[1] };
|
||||
return cachedGeminiCliCredentials;
|
||||
}
|
||||
} catch {
|
||||
// Gemini CLI not installed or extraction failed
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findInPath(name: string): string | null {
|
||||
const exts = process.platform === "win32" ? [".cmd", ".bat", ".exe", ""] : [""];
|
||||
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
||||
for (const ext of exts) {
|
||||
const p = join(dir, name + ext);
|
||||
if (existsSync(p)) return p;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findFile(dir: string, name: string, depth: number): string | null {
|
||||
if (depth <= 0) return null;
|
||||
try {
|
||||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = join(dir, e.name);
|
||||
if (e.isFile() && e.name === name) return p;
|
||||
if (e.isDirectory() && !e.name.startsWith(".")) {
|
||||
const found = findFile(p, name, depth - 1);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string } {
|
||||
// 1. Check env vars first (user override)
|
||||
const envClientId = resolveEnv(CLIENT_ID_KEYS);
|
||||
const envClientSecret = resolveEnv(CLIENT_SECRET_KEYS);
|
||||
if (envClientId) {
|
||||
return { clientId: envClientId, clientSecret: envClientSecret };
|
||||
const clientId = resolveEnv(CLIENT_ID_KEYS);
|
||||
if (!clientId) {
|
||||
throw new Error(
|
||||
"Missing Gemini OAuth client ID. Set CLAWDBOT_GEMINI_OAUTH_CLIENT_ID (or GEMINI_CLI_OAUTH_CLIENT_ID).",
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Try to extract from installed Gemini CLI
|
||||
const extracted = extractGeminiCliCredentials();
|
||||
if (extracted) {
|
||||
return extracted;
|
||||
}
|
||||
|
||||
// 3. No credentials available
|
||||
throw new Error(
|
||||
"Gemini CLI not found. Install it first: brew install gemini-cli (or npm install -g @google/gemini-cli), or set GEMINI_CLI_OAUTH_CLIENT_ID.",
|
||||
);
|
||||
const clientSecret = resolveEnv(CLIENT_SECRET_KEYS);
|
||||
return { clientId, clientSecret };
|
||||
}
|
||||
|
||||
function isWSL(): boolean {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/google-gemini-cli-auth",
|
||||
"version": "2026.1.24",
|
||||
"version": "2026.1.23",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Gemini CLI OAuth provider plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/googlechat",
|
||||
"version": "2026.1.24",
|
||||
"version": "2026.1.22",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Google Chat channel plugin",
|
||||
"clawdbot": {
|
||||
@@ -34,6 +34,6 @@
|
||||
"clawdbot": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"clawdbot": ">=2026.1.24"
|
||||
"clawdbot": ">=2026.1.24-0"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/imessage",
|
||||
"version": "2026.1.24",
|
||||
"version": "2026.1.23",
|
||||
"type": "module",
|
||||
"description": "Clawdbot iMessage channel plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -8,10 +8,8 @@ import {
|
||||
imessageOnboardingAdapter,
|
||||
IMessageConfigSchema,
|
||||
listIMessageAccountIds,
|
||||
looksLikeIMessageTargetId,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
normalizeAccountId,
|
||||
normalizeIMessageMessagingTarget,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
resolveChannelMediaMaxBytes,
|
||||
resolveDefaultIMessageAccountId,
|
||||
@@ -112,9 +110,14 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
||||
resolveToolPolicy: resolveIMessageGroupToolPolicy,
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeIMessageMessagingTarget,
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeIMessageTargetId,
|
||||
looksLikeId: (raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/^(imessage:|chat_id:)/i.test(trimmed)) return true;
|
||||
if (trimmed.includes("@")) return true;
|
||||
return /^\+?\d{3,}$/.test(trimmed);
|
||||
},
|
||||
hint: "<handle|chat_id:ID>",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"id": "line",
|
||||
"channels": [
|
||||
"line"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { linePlugin } from "./src/channel.js";
|
||||
import { registerLineCardCommand } from "./src/card-command.js";
|
||||
import { setLineRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
id: "line",
|
||||
name: "LINE",
|
||||
description: "LINE Messaging API channel plugin",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setLineRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: linePlugin });
|
||||
registerLineCardCommand(api);
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"name": "@clawdbot/line",
|
||||
"version": "2026.1.24",
|
||||
"type": "module",
|
||||
"description": "Clawdbot LINE channel plugin",
|
||||
"clawdbot": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"channel": {
|
||||
"id": "line",
|
||||
"label": "LINE",
|
||||
"selectionLabel": "LINE (Messaging API)",
|
||||
"docsPath": "/channels/line",
|
||||
"docsLabel": "line",
|
||||
"blurb": "LINE Messaging API bot for Japan/Taiwan/Thailand markets.",
|
||||
"order": 75,
|
||||
"quickstartAllowFrom": true
|
||||
},
|
||||
"install": {
|
||||
"npmSpec": "@clawdbot/line",
|
||||
"localPath": "extensions/line",
|
||||
"defaultChoice": "npm"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"clawdbot": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
import type { ClawdbotPluginApi, LineChannelData, ReplyPayload } from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
createActionCard,
|
||||
createImageCard,
|
||||
createInfoCard,
|
||||
createListCard,
|
||||
createReceiptCard,
|
||||
type CardAction,
|
||||
type ListItem,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
const CARD_USAGE = `Usage: /card <type> "title" "body" [options]
|
||||
|
||||
Types:
|
||||
info "Title" "Body" ["Footer"]
|
||||
image "Title" "Caption" --url <image-url>
|
||||
action "Title" "Body" --actions "Btn1|url1,Btn2|text2"
|
||||
list "Title" "Item1|Desc1,Item2|Desc2"
|
||||
receipt "Title" "Item1:$10,Item2:$20" --total "$30"
|
||||
confirm "Question?" --yes "Yes|data" --no "No|data"
|
||||
buttons "Title" "Text" --actions "Btn1|url1,Btn2|data2"
|
||||
|
||||
Examples:
|
||||
/card info "Welcome" "Thanks for joining!"
|
||||
/card image "Product" "Check it out" --url https://example.com/img.jpg
|
||||
/card action "Menu" "Choose an option" --actions "Order|/order,Help|/help"`;
|
||||
|
||||
function buildLineReply(lineData: LineChannelData): ReplyPayload {
|
||||
return {
|
||||
channelData: {
|
||||
line: lineData,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse action string format: "Label|data,Label2|data2"
|
||||
* Data can be a URL (uri action) or plain text (message action) or key=value (postback)
|
||||
*/
|
||||
function parseActions(actionsStr: string | undefined): CardAction[] {
|
||||
if (!actionsStr) return [];
|
||||
|
||||
const results: CardAction[] = [];
|
||||
|
||||
for (const part of actionsStr.split(",")) {
|
||||
const [label, data] = part
|
||||
.trim()
|
||||
.split("|")
|
||||
.map((s) => s.trim());
|
||||
if (!label) continue;
|
||||
|
||||
const actionData = data || label;
|
||||
|
||||
if (actionData.startsWith("http://") || actionData.startsWith("https://")) {
|
||||
results.push({
|
||||
label,
|
||||
action: { type: "uri", label: label.slice(0, 20), uri: actionData },
|
||||
});
|
||||
} else if (actionData.includes("=")) {
|
||||
results.push({
|
||||
label,
|
||||
action: {
|
||||
type: "postback",
|
||||
label: label.slice(0, 20),
|
||||
data: actionData.slice(0, 300),
|
||||
displayText: label,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
results.push({
|
||||
label,
|
||||
action: { type: "message", label: label.slice(0, 20), text: actionData },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse list items format: "Item1|Subtitle1,Item2|Subtitle2"
|
||||
*/
|
||||
function parseListItems(itemsStr: string): ListItem[] {
|
||||
return itemsStr
|
||||
.split(",")
|
||||
.map((part) => {
|
||||
const [title, subtitle] = part
|
||||
.trim()
|
||||
.split("|")
|
||||
.map((s) => s.trim());
|
||||
return { title: title || "", subtitle };
|
||||
})
|
||||
.filter((item) => item.title);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse receipt items format: "Item1:$10,Item2:$20"
|
||||
*/
|
||||
function parseReceiptItems(itemsStr: string): Array<{ name: string; value: string }> {
|
||||
return itemsStr
|
||||
.split(",")
|
||||
.map((part) => {
|
||||
const colonIndex = part.lastIndexOf(":");
|
||||
if (colonIndex === -1) {
|
||||
return { name: part.trim(), value: "" };
|
||||
}
|
||||
return {
|
||||
name: part.slice(0, colonIndex).trim(),
|
||||
value: part.slice(colonIndex + 1).trim(),
|
||||
};
|
||||
})
|
||||
.filter((item) => item.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse quoted arguments from command string
|
||||
* Supports: /card type "arg1" "arg2" "arg3" --flag value
|
||||
*/
|
||||
function parseCardArgs(argsStr: string): {
|
||||
type: string;
|
||||
args: string[];
|
||||
flags: Record<string, string>;
|
||||
} {
|
||||
const result: { type: string; args: string[]; flags: Record<string, string> } = {
|
||||
type: "",
|
||||
args: [],
|
||||
flags: {},
|
||||
};
|
||||
|
||||
// Extract type (first word)
|
||||
const typeMatch = argsStr.match(/^(\w+)/);
|
||||
if (typeMatch) {
|
||||
result.type = typeMatch[1].toLowerCase();
|
||||
argsStr = argsStr.slice(typeMatch[0].length).trim();
|
||||
}
|
||||
|
||||
// Extract quoted arguments
|
||||
const quotedRegex = /"([^"]*?)"/g;
|
||||
let match;
|
||||
while ((match = quotedRegex.exec(argsStr)) !== null) {
|
||||
result.args.push(match[1]);
|
||||
}
|
||||
|
||||
// Extract flags (--key value or --key "value")
|
||||
const flagRegex = /--(\w+)\s+(?:"([^"]*?)"|(\S+))/g;
|
||||
while ((match = flagRegex.exec(argsStr)) !== null) {
|
||||
result.flags[match[1]] = match[2] ?? match[3];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function registerLineCardCommand(api: ClawdbotPluginApi): void {
|
||||
api.registerCommand({
|
||||
name: "card",
|
||||
description: "Send a rich card message (LINE).",
|
||||
acceptsArgs: true,
|
||||
requireAuth: false,
|
||||
handler: async (ctx) => {
|
||||
const argsStr = ctx.args?.trim() ?? "";
|
||||
if (!argsStr) return { text: CARD_USAGE };
|
||||
|
||||
const parsed = parseCardArgs(argsStr);
|
||||
const { type, args, flags } = parsed;
|
||||
|
||||
if (!type) return { text: CARD_USAGE };
|
||||
|
||||
// Only LINE supports rich cards; fallback to text elsewhere.
|
||||
if (ctx.channel !== "line") {
|
||||
const fallbackText = args.join(" - ");
|
||||
return { text: `[${type} card] ${fallbackText}`.trim() };
|
||||
}
|
||||
|
||||
try {
|
||||
switch (type) {
|
||||
case "info": {
|
||||
const [title = "Info", body = "", footer] = args;
|
||||
const bubble = createInfoCard(title, body, footer);
|
||||
return buildLineReply({
|
||||
flexMessage: {
|
||||
altText: `${title}: ${body}`.slice(0, 400),
|
||||
contents: bubble,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
case "image": {
|
||||
const [title = "Image", caption = ""] = args;
|
||||
const imageUrl = flags.url || flags.image;
|
||||
if (!imageUrl) {
|
||||
return { text: "Error: Image card requires --url <image-url>" };
|
||||
}
|
||||
const bubble = createImageCard(imageUrl, title, caption);
|
||||
return buildLineReply({
|
||||
flexMessage: {
|
||||
altText: `${title}: ${caption}`.slice(0, 400),
|
||||
contents: bubble,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
case "action": {
|
||||
const [title = "Actions", body = ""] = args;
|
||||
const actions = parseActions(flags.actions);
|
||||
if (actions.length === 0) {
|
||||
return { text: 'Error: Action card requires --actions "Label1|data1,Label2|data2"' };
|
||||
}
|
||||
const bubble = createActionCard(title, body, actions, {
|
||||
imageUrl: flags.url || flags.image,
|
||||
});
|
||||
return buildLineReply({
|
||||
flexMessage: {
|
||||
altText: `${title}: ${body}`.slice(0, 400),
|
||||
contents: bubble,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
case "list": {
|
||||
const [title = "List", itemsStr = ""] = args;
|
||||
const items = parseListItems(itemsStr || flags.items || "");
|
||||
if (items.length === 0) {
|
||||
return {
|
||||
text:
|
||||
'Error: List card requires items. Usage: /card list "Title" "Item1|Desc1,Item2|Desc2"',
|
||||
};
|
||||
}
|
||||
const bubble = createListCard(title, items);
|
||||
return buildLineReply({
|
||||
flexMessage: {
|
||||
altText: `${title}: ${items.map((i) => i.title).join(", ")}`.slice(0, 400),
|
||||
contents: bubble,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
case "receipt": {
|
||||
const [title = "Receipt", itemsStr = ""] = args;
|
||||
const items = parseReceiptItems(itemsStr || flags.items || "");
|
||||
const total = flags.total ? { label: "Total", value: flags.total } : undefined;
|
||||
const footer = flags.footer;
|
||||
|
||||
if (items.length === 0) {
|
||||
return {
|
||||
text:
|
||||
'Error: Receipt card requires items. Usage: /card receipt "Title" "Item1:$10,Item2:$20" --total "$30"',
|
||||
};
|
||||
}
|
||||
|
||||
const bubble = createReceiptCard({ title, items, total, footer });
|
||||
return buildLineReply({
|
||||
flexMessage: {
|
||||
altText: `${title}: ${items.map((i) => `${i.name} ${i.value}`).join(", ")}`.slice(
|
||||
0,
|
||||
400,
|
||||
),
|
||||
contents: bubble,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
case "confirm": {
|
||||
const [question = "Confirm?"] = args;
|
||||
const yesStr = flags.yes || "Yes|yes";
|
||||
const noStr = flags.no || "No|no";
|
||||
|
||||
const [yesLabel, yesData] = yesStr.split("|").map((s) => s.trim());
|
||||
const [noLabel, noData] = noStr.split("|").map((s) => s.trim());
|
||||
|
||||
return buildLineReply({
|
||||
templateMessage: {
|
||||
type: "confirm",
|
||||
text: question,
|
||||
confirmLabel: yesLabel || "Yes",
|
||||
confirmData: yesData || "yes",
|
||||
cancelLabel: noLabel || "No",
|
||||
cancelData: noData || "no",
|
||||
altText: question,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
case "buttons": {
|
||||
const [title = "Menu", text = "Choose an option"] = args;
|
||||
const actionsStr = flags.actions || "";
|
||||
const actionParts = parseActions(actionsStr);
|
||||
|
||||
if (actionParts.length === 0) {
|
||||
return { text: 'Error: Buttons card requires --actions "Label1|data1,Label2|data2"' };
|
||||
}
|
||||
|
||||
const templateActions: Array<{
|
||||
type: "message" | "uri" | "postback";
|
||||
label: string;
|
||||
data?: string;
|
||||
uri?: string;
|
||||
}> = actionParts.map((a) => {
|
||||
const action = a.action;
|
||||
const label = action.label ?? a.label;
|
||||
if (action.type === "uri") {
|
||||
return { type: "uri" as const, label, uri: (action as { uri: string }).uri };
|
||||
}
|
||||
if (action.type === "postback") {
|
||||
return {
|
||||
type: "postback" as const,
|
||||
label,
|
||||
data: (action as { data: string }).data,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "message" as const,
|
||||
label,
|
||||
data: (action as { text: string }).text,
|
||||
};
|
||||
});
|
||||
|
||||
return buildLineReply({
|
||||
templateMessage: {
|
||||
type: "buttons",
|
||||
title,
|
||||
text,
|
||||
thumbnailImageUrl: flags.url || flags.image,
|
||||
actions: templateActions,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
text: `Unknown card type: "${type}". Available types: info, image, action, list, receipt, confirm, buttons`,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
return { text: `Error creating card: ${String(err)}` };
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
import { linePlugin } from "./channel.js";
|
||||
import { setLineRuntime } from "./runtime.js";
|
||||
|
||||
const DEFAULT_ACCOUNT_ID = "default";
|
||||
|
||||
type LineRuntimeMocks = {
|
||||
writeConfigFile: ReturnType<typeof vi.fn>;
|
||||
resolveLineAccount: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } {
|
||||
const writeConfigFile = vi.fn(async () => {});
|
||||
const resolveLineAccount = vi.fn(({ cfg, accountId }: { cfg: ClawdbotConfig; accountId?: string }) => {
|
||||
const lineConfig = (cfg.channels?.line ?? {}) as {
|
||||
tokenFile?: string;
|
||||
secretFile?: string;
|
||||
channelAccessToken?: string;
|
||||
channelSecret?: string;
|
||||
accounts?: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
const entry =
|
||||
accountId && accountId !== DEFAULT_ACCOUNT_ID
|
||||
? lineConfig.accounts?.[accountId] ?? {}
|
||||
: lineConfig;
|
||||
const hasToken =
|
||||
Boolean((entry as any).channelAccessToken) || Boolean((entry as any).tokenFile);
|
||||
const hasSecret =
|
||||
Boolean((entry as any).channelSecret) || Boolean((entry as any).secretFile);
|
||||
return { tokenSource: hasToken && hasSecret ? "config" : "none" };
|
||||
});
|
||||
|
||||
const runtime = {
|
||||
config: { writeConfigFile },
|
||||
channel: { line: { resolveLineAccount } },
|
||||
} as unknown as PluginRuntime;
|
||||
|
||||
return { runtime, mocks: { writeConfigFile, resolveLineAccount } };
|
||||
}
|
||||
|
||||
describe("linePlugin gateway.logoutAccount", () => {
|
||||
beforeEach(() => {
|
||||
setLineRuntime(createRuntime().runtime);
|
||||
});
|
||||
|
||||
it("clears tokenFile/secretFile on default account logout", async () => {
|
||||
const { runtime, mocks } = createRuntime();
|
||||
setLineRuntime(runtime);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
line: {
|
||||
tokenFile: "/tmp/token",
|
||||
secretFile: "/tmp/secret",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await linePlugin.gateway.logoutAccount({
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(result.cleared).toBe(true);
|
||||
expect(result.loggedOut).toBe(true);
|
||||
expect(mocks.writeConfigFile).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
it("clears tokenFile/secretFile on account logout", async () => {
|
||||
const { runtime, mocks } = createRuntime();
|
||||
setLineRuntime(runtime);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
line: {
|
||||
accounts: {
|
||||
primary: {
|
||||
tokenFile: "/tmp/token",
|
||||
secretFile: "/tmp/secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await linePlugin.gateway.logoutAccount({
|
||||
accountId: "primary",
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(result.cleared).toBe(true);
|
||||
expect(result.loggedOut).toBe(true);
|
||||
expect(mocks.writeConfigFile).toHaveBeenCalledWith({});
|
||||
});
|
||||
});
|
||||
@@ -1,308 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
import { linePlugin } from "./channel.js";
|
||||
import { setLineRuntime } from "./runtime.js";
|
||||
|
||||
type LineRuntimeMocks = {
|
||||
pushMessageLine: ReturnType<typeof vi.fn>;
|
||||
pushMessagesLine: ReturnType<typeof vi.fn>;
|
||||
pushFlexMessage: ReturnType<typeof vi.fn>;
|
||||
pushTemplateMessage: ReturnType<typeof vi.fn>;
|
||||
pushLocationMessage: ReturnType<typeof vi.fn>;
|
||||
pushTextMessageWithQuickReplies: ReturnType<typeof vi.fn>;
|
||||
createQuickReplyItems: ReturnType<typeof vi.fn>;
|
||||
buildTemplateMessageFromPayload: ReturnType<typeof vi.fn>;
|
||||
sendMessageLine: ReturnType<typeof vi.fn>;
|
||||
chunkMarkdownText: ReturnType<typeof vi.fn>;
|
||||
resolveLineAccount: ReturnType<typeof vi.fn>;
|
||||
resolveTextChunkLimit: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } {
|
||||
const pushMessageLine = vi.fn(async () => ({ messageId: "m-text", chatId: "c1" }));
|
||||
const pushMessagesLine = vi.fn(async () => ({ messageId: "m-batch", chatId: "c1" }));
|
||||
const pushFlexMessage = vi.fn(async () => ({ messageId: "m-flex", chatId: "c1" }));
|
||||
const pushTemplateMessage = vi.fn(async () => ({ messageId: "m-template", chatId: "c1" }));
|
||||
const pushLocationMessage = vi.fn(async () => ({ messageId: "m-loc", chatId: "c1" }));
|
||||
const pushTextMessageWithQuickReplies = vi.fn(async () => ({
|
||||
messageId: "m-quick",
|
||||
chatId: "c1",
|
||||
}));
|
||||
const createQuickReplyItems = vi.fn((labels: string[]) => ({ items: labels }));
|
||||
const buildTemplateMessageFromPayload = vi.fn(() => ({ type: "buttons" }));
|
||||
const sendMessageLine = vi.fn(async () => ({ messageId: "m-media", chatId: "c1" }));
|
||||
const chunkMarkdownText = vi.fn((text: string) => [text]);
|
||||
const resolveTextChunkLimit = vi.fn(() => 123);
|
||||
const resolveLineAccount = vi.fn(({ cfg, accountId }: { cfg: ClawdbotConfig; accountId?: string }) => {
|
||||
const resolved = accountId ?? "default";
|
||||
const lineConfig = (cfg.channels?.line ?? {}) as {
|
||||
accounts?: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
const accountConfig =
|
||||
resolved !== "default" ? lineConfig.accounts?.[resolved] ?? {} : {};
|
||||
return {
|
||||
accountId: resolved,
|
||||
config: { ...lineConfig, ...accountConfig },
|
||||
};
|
||||
});
|
||||
|
||||
const runtime = {
|
||||
channel: {
|
||||
line: {
|
||||
pushMessageLine,
|
||||
pushMessagesLine,
|
||||
pushFlexMessage,
|
||||
pushTemplateMessage,
|
||||
pushLocationMessage,
|
||||
pushTextMessageWithQuickReplies,
|
||||
createQuickReplyItems,
|
||||
buildTemplateMessageFromPayload,
|
||||
sendMessageLine,
|
||||
resolveLineAccount,
|
||||
},
|
||||
text: {
|
||||
chunkMarkdownText,
|
||||
resolveTextChunkLimit,
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
|
||||
return {
|
||||
runtime,
|
||||
mocks: {
|
||||
pushMessageLine,
|
||||
pushMessagesLine,
|
||||
pushFlexMessage,
|
||||
pushTemplateMessage,
|
||||
pushLocationMessage,
|
||||
pushTextMessageWithQuickReplies,
|
||||
createQuickReplyItems,
|
||||
buildTemplateMessageFromPayload,
|
||||
sendMessageLine,
|
||||
chunkMarkdownText,
|
||||
resolveLineAccount,
|
||||
resolveTextChunkLimit,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("linePlugin outbound.sendPayload", () => {
|
||||
it("sends flex message without dropping text", async () => {
|
||||
const { runtime, mocks } = createRuntime();
|
||||
setLineRuntime(runtime);
|
||||
const cfg = { channels: { line: {} } } as ClawdbotConfig;
|
||||
|
||||
const payload = {
|
||||
text: "Now playing:",
|
||||
channelData: {
|
||||
line: {
|
||||
flexMessage: {
|
||||
altText: "Now playing",
|
||||
contents: { type: "bubble" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await linePlugin.outbound.sendPayload({
|
||||
to: "line:group:1",
|
||||
payload,
|
||||
accountId: "default",
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(mocks.pushFlexMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.pushMessageLine).toHaveBeenCalledWith("line:group:1", "Now playing:", {
|
||||
verbose: false,
|
||||
accountId: "default",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends template message without dropping text", async () => {
|
||||
const { runtime, mocks } = createRuntime();
|
||||
setLineRuntime(runtime);
|
||||
const cfg = { channels: { line: {} } } as ClawdbotConfig;
|
||||
|
||||
const payload = {
|
||||
text: "Choose one:",
|
||||
channelData: {
|
||||
line: {
|
||||
templateMessage: {
|
||||
type: "confirm",
|
||||
text: "Continue?",
|
||||
confirmLabel: "Yes",
|
||||
confirmData: "yes",
|
||||
cancelLabel: "No",
|
||||
cancelData: "no",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await linePlugin.outbound.sendPayload({
|
||||
to: "line:user:1",
|
||||
payload,
|
||||
accountId: "default",
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(mocks.buildTemplateMessageFromPayload).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.pushTemplateMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.pushMessageLine).toHaveBeenCalledWith("line:user:1", "Choose one:", {
|
||||
verbose: false,
|
||||
accountId: "default",
|
||||
});
|
||||
});
|
||||
|
||||
it("attaches quick replies when no text chunks are present", async () => {
|
||||
const { runtime, mocks } = createRuntime();
|
||||
setLineRuntime(runtime);
|
||||
const cfg = { channels: { line: {} } } as ClawdbotConfig;
|
||||
|
||||
const payload = {
|
||||
channelData: {
|
||||
line: {
|
||||
quickReplies: ["One", "Two"],
|
||||
flexMessage: {
|
||||
altText: "Card",
|
||||
contents: { type: "bubble" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await linePlugin.outbound.sendPayload({
|
||||
to: "line:user:2",
|
||||
payload,
|
||||
accountId: "default",
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(mocks.pushFlexMessage).not.toHaveBeenCalled();
|
||||
expect(mocks.pushMessagesLine).toHaveBeenCalledWith(
|
||||
"line:user:2",
|
||||
[
|
||||
{
|
||||
type: "flex",
|
||||
altText: "Card",
|
||||
contents: { type: "bubble" },
|
||||
quickReply: { items: ["One", "Two"] },
|
||||
},
|
||||
],
|
||||
{ verbose: false, accountId: "default" },
|
||||
);
|
||||
expect(mocks.createQuickReplyItems).toHaveBeenCalledWith(["One", "Two"]);
|
||||
});
|
||||
|
||||
it("sends media before quick-reply text so buttons stay visible", async () => {
|
||||
const { runtime, mocks } = createRuntime();
|
||||
setLineRuntime(runtime);
|
||||
const cfg = { channels: { line: {} } } as ClawdbotConfig;
|
||||
|
||||
const payload = {
|
||||
text: "Hello",
|
||||
mediaUrl: "https://example.com/img.jpg",
|
||||
channelData: {
|
||||
line: {
|
||||
quickReplies: ["One", "Two"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await linePlugin.outbound.sendPayload({
|
||||
to: "line:user:3",
|
||||
payload,
|
||||
accountId: "default",
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(mocks.sendMessageLine).toHaveBeenCalledWith("line:user:3", "", {
|
||||
verbose: false,
|
||||
mediaUrl: "https://example.com/img.jpg",
|
||||
accountId: "default",
|
||||
});
|
||||
expect(mocks.pushTextMessageWithQuickReplies).toHaveBeenCalledWith(
|
||||
"line:user:3",
|
||||
"Hello",
|
||||
["One", "Two"],
|
||||
{ verbose: false, accountId: "default" },
|
||||
);
|
||||
const mediaOrder = mocks.sendMessageLine.mock.invocationCallOrder[0];
|
||||
const quickReplyOrder = mocks.pushTextMessageWithQuickReplies.mock.invocationCallOrder[0];
|
||||
expect(mediaOrder).toBeLessThan(quickReplyOrder);
|
||||
});
|
||||
|
||||
it("uses configured text chunk limit for payloads", async () => {
|
||||
const { runtime, mocks } = createRuntime();
|
||||
setLineRuntime(runtime);
|
||||
const cfg = { channels: { line: { textChunkLimit: 123 } } } as ClawdbotConfig;
|
||||
|
||||
const payload = {
|
||||
text: "Hello world",
|
||||
channelData: {
|
||||
line: {
|
||||
flexMessage: {
|
||||
altText: "Card",
|
||||
contents: { type: "bubble" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await linePlugin.outbound.sendPayload({
|
||||
to: "line:user:3",
|
||||
payload,
|
||||
accountId: "primary",
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(mocks.resolveTextChunkLimit).toHaveBeenCalledWith(
|
||||
cfg,
|
||||
"line",
|
||||
"primary",
|
||||
{ fallbackLimit: 5000 },
|
||||
);
|
||||
expect(mocks.chunkMarkdownText).toHaveBeenCalledWith("Hello world", 123);
|
||||
});
|
||||
});
|
||||
|
||||
describe("linePlugin config.formatAllowFrom", () => {
|
||||
it("strips line:user: prefixes without lowercasing", () => {
|
||||
const formatted = linePlugin.config.formatAllowFrom({
|
||||
allowFrom: ["line:user:UABC", "line:UDEF"],
|
||||
});
|
||||
expect(formatted).toEqual(["UABC", "UDEF"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("linePlugin groups.resolveRequireMention", () => {
|
||||
it("uses account-level group settings when provided", () => {
|
||||
const { runtime } = createRuntime();
|
||||
setLineRuntime(runtime);
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
line: {
|
||||
groups: {
|
||||
"*": { requireMention: false },
|
||||
},
|
||||
accounts: {
|
||||
primary: {
|
||||
groups: {
|
||||
"group-1": { requireMention: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const requireMention = linePlugin.groups.resolveRequireMention({
|
||||
cfg,
|
||||
accountId: "primary",
|
||||
groupId: "group-1",
|
||||
});
|
||||
|
||||
expect(requireMention).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,773 +0,0 @@
|
||||
import {
|
||||
buildChannelConfigSchema,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
LineConfigSchema,
|
||||
processLineMessage,
|
||||
type ChannelPlugin,
|
||||
type ClawdbotConfig,
|
||||
type LineConfig,
|
||||
type LineChannelData,
|
||||
type ResolvedLineAccount,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import { getLineRuntime } from "./runtime.js";
|
||||
|
||||
// LINE channel metadata
|
||||
const meta = {
|
||||
id: "line",
|
||||
label: "LINE",
|
||||
selectionLabel: "LINE (Messaging API)",
|
||||
detailLabel: "LINE Bot",
|
||||
docsPath: "/channels/line",
|
||||
docsLabel: "line",
|
||||
blurb: "LINE Messaging API bot for Japan/Taiwan/Thailand markets.",
|
||||
systemImage: "message.fill",
|
||||
};
|
||||
|
||||
function parseThreadId(threadId?: string | number | null): number | undefined {
|
||||
if (threadId == null) return undefined;
|
||||
if (typeof threadId === "number") {
|
||||
return Number.isFinite(threadId) ? Math.trunc(threadId) : undefined;
|
||||
}
|
||||
const trimmed = threadId.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const parsed = Number.parseInt(trimmed, 10);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
||||
id: "line",
|
||||
meta: {
|
||||
...meta,
|
||||
quickstartAllowFrom: true,
|
||||
},
|
||||
pairing: {
|
||||
idLabel: "lineUserId",
|
||||
normalizeAllowEntry: (entry) => {
|
||||
// LINE IDs are case-sensitive; only strip prefix variants (line: / line:user:).
|
||||
return entry.replace(/^line:(?:user:)?/i, "");
|
||||
},
|
||||
notifyApproval: async ({ cfg, id }) => {
|
||||
const line = getLineRuntime().channel.line;
|
||||
const account = line.resolveLineAccount({ cfg });
|
||||
if (!account.channelAccessToken) {
|
||||
throw new Error("LINE channel access token not configured");
|
||||
}
|
||||
await line.pushMessageLine(id, "Clawdbot: your access has been approved.", {
|
||||
channelAccessToken: account.channelAccessToken,
|
||||
});
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
reactions: false,
|
||||
threads: false,
|
||||
media: true,
|
||||
nativeCommands: false,
|
||||
blockStreaming: true,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.line"] },
|
||||
configSchema: buildChannelConfigSchema(LineConfigSchema),
|
||||
config: {
|
||||
listAccountIds: (cfg) => getLineRuntime().channel.line.listLineAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) =>
|
||||
getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId }),
|
||||
defaultAccountId: (cfg) => getLineRuntime().channel.line.resolveDefaultLineAccountId(cfg),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) => {
|
||||
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
line: {
|
||||
...lineConfig,
|
||||
enabled,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
line: {
|
||||
...lineConfig,
|
||||
accounts: {
|
||||
...lineConfig.accounts,
|
||||
[accountId]: {
|
||||
...lineConfig.accounts?.[accountId],
|
||||
enabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
deleteAccount: ({ cfg, accountId }) => {
|
||||
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
const { channelAccessToken, channelSecret, tokenFile, secretFile, ...rest } = lineConfig;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
line: rest,
|
||||
},
|
||||
};
|
||||
}
|
||||
const accounts = { ...lineConfig.accounts };
|
||||
delete accounts[accountId];
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
line: {
|
||||
...lineConfig,
|
||||
accounts: Object.keys(accounts).length > 0 ? accounts : undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
isConfigured: (account) => Boolean(account.channelAccessToken?.trim()),
|
||||
describeAccount: (account) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: Boolean(account.channelAccessToken?.trim()),
|
||||
tokenSource: account.tokenSource,
|
||||
}),
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
(getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId }).config.allowFrom ?? []).map(
|
||||
(entry) => String(entry),
|
||||
),
|
||||
formatAllowFrom: ({ allowFrom }) =>
|
||||
allowFrom
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => {
|
||||
// LINE sender IDs are case-sensitive; keep original casing.
|
||||
return entry.replace(/^line:(?:user:)?/i, "");
|
||||
}),
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
const useAccountPath = Boolean(
|
||||
(cfg.channels?.line as LineConfig | undefined)?.accounts?.[resolvedAccountId],
|
||||
);
|
||||
const basePath = useAccountPath
|
||||
? `channels.line.accounts.${resolvedAccountId}.`
|
||||
: "channels.line.";
|
||||
return {
|
||||
policy: account.config.dmPolicy ?? "pairing",
|
||||
allowFrom: account.config.allowFrom ?? [],
|
||||
policyPath: `${basePath}dmPolicy`,
|
||||
allowFromPath: basePath,
|
||||
approveHint: "clawdbot pairing approve line <code>",
|
||||
normalizeEntry: (raw) => raw.replace(/^line:(?:user:)?/i, ""),
|
||||
};
|
||||
},
|
||||
collectWarnings: ({ account, cfg }) => {
|
||||
const defaultGroupPolicy =
|
||||
(cfg.channels?.defaults as { groupPolicy?: string } | undefined)?.groupPolicy;
|
||||
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||
if (groupPolicy !== "open") return [];
|
||||
return [
|
||||
`- LINE groups: groupPolicy="open" allows any member in groups to trigger. Set channels.line.groupPolicy="allowlist" + channels.line.groupAllowFrom to restrict senders.`,
|
||||
];
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: ({ cfg, accountId, groupId }) => {
|
||||
const account = getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId });
|
||||
const groups = account.config.groups;
|
||||
if (!groups) return false;
|
||||
const groupConfig = groups[groupId] ?? groups["*"];
|
||||
return groupConfig?.requireMention ?? false;
|
||||
},
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: (target) => {
|
||||
const trimmed = target.trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed.replace(/^line:(group|room|user):/i, "").replace(/^line:/i, "");
|
||||
},
|
||||
targetResolver: {
|
||||
looksLikeId: (id) => {
|
||||
const trimmed = id?.trim();
|
||||
if (!trimmed) return false;
|
||||
// LINE user IDs are typically U followed by 32 hex characters
|
||||
// Group IDs are C followed by 32 hex characters
|
||||
// Room IDs are R followed by 32 hex characters
|
||||
return /^[UCR][a-f0-9]{32}$/i.test(trimmed) || /^line:/i.test(trimmed);
|
||||
},
|
||||
hint: "<userId|groupId|roomId>",
|
||||
},
|
||||
},
|
||||
directory: {
|
||||
self: async () => null,
|
||||
listPeers: async () => [],
|
||||
listGroups: async () => [],
|
||||
},
|
||||
setup: {
|
||||
resolveAccountId: ({ accountId }) =>
|
||||
getLineRuntime().channel.line.normalizeAccountId(accountId),
|
||||
applyAccountName: ({ cfg, accountId, name }) => {
|
||||
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
line: {
|
||||
...lineConfig,
|
||||
name,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
line: {
|
||||
...lineConfig,
|
||||
accounts: {
|
||||
...lineConfig.accounts,
|
||||
[accountId]: {
|
||||
...lineConfig.accounts?.[accountId],
|
||||
name,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
validateInput: ({ accountId, input }) => {
|
||||
const typedInput = input as {
|
||||
useEnv?: boolean;
|
||||
channelAccessToken?: string;
|
||||
channelSecret?: string;
|
||||
tokenFile?: string;
|
||||
secretFile?: string;
|
||||
};
|
||||
if (typedInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
|
||||
return "LINE_CHANNEL_ACCESS_TOKEN can only be used for the default account.";
|
||||
}
|
||||
if (!typedInput.useEnv && !typedInput.channelAccessToken && !typedInput.tokenFile) {
|
||||
return "LINE requires channelAccessToken or --token-file (or --use-env).";
|
||||
}
|
||||
if (!typedInput.useEnv && !typedInput.channelSecret && !typedInput.secretFile) {
|
||||
return "LINE requires channelSecret or --secret-file (or --use-env).";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
applyAccountConfig: ({ cfg, accountId, input }) => {
|
||||
const typedInput = input as {
|
||||
name?: string;
|
||||
useEnv?: boolean;
|
||||
channelAccessToken?: string;
|
||||
channelSecret?: string;
|
||||
tokenFile?: string;
|
||||
secretFile?: string;
|
||||
};
|
||||
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
||||
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
line: {
|
||||
...lineConfig,
|
||||
enabled: true,
|
||||
...(typedInput.name ? { name: typedInput.name } : {}),
|
||||
...(typedInput.useEnv
|
||||
? {}
|
||||
: typedInput.tokenFile
|
||||
? { tokenFile: typedInput.tokenFile }
|
||||
: typedInput.channelAccessToken
|
||||
? { channelAccessToken: typedInput.channelAccessToken }
|
||||
: {}),
|
||||
...(typedInput.useEnv
|
||||
? {}
|
||||
: typedInput.secretFile
|
||||
? { secretFile: typedInput.secretFile }
|
||||
: typedInput.channelSecret
|
||||
? { channelSecret: typedInput.channelSecret }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
line: {
|
||||
...lineConfig,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...lineConfig.accounts,
|
||||
[accountId]: {
|
||||
...lineConfig.accounts?.[accountId],
|
||||
enabled: true,
|
||||
...(typedInput.name ? { name: typedInput.name } : {}),
|
||||
...(typedInput.tokenFile
|
||||
? { tokenFile: typedInput.tokenFile }
|
||||
: typedInput.channelAccessToken
|
||||
? { channelAccessToken: typedInput.channelAccessToken }
|
||||
: {}),
|
||||
...(typedInput.secretFile
|
||||
? { secretFile: typedInput.secretFile }
|
||||
: typedInput.channelSecret
|
||||
? { channelSecret: typedInput.channelSecret }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => getLineRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
textChunkLimit: 5000, // LINE allows up to 5000 characters per text message
|
||||
sendPayload: async ({ to, payload, accountId, cfg }) => {
|
||||
const runtime = getLineRuntime();
|
||||
const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {};
|
||||
const sendText = runtime.channel.line.pushMessageLine;
|
||||
const sendBatch = runtime.channel.line.pushMessagesLine;
|
||||
const sendFlex = runtime.channel.line.pushFlexMessage;
|
||||
const sendTemplate = runtime.channel.line.pushTemplateMessage;
|
||||
const sendLocation = runtime.channel.line.pushLocationMessage;
|
||||
const sendQuickReplies = runtime.channel.line.pushTextMessageWithQuickReplies;
|
||||
const buildTemplate = runtime.channel.line.buildTemplateMessageFromPayload;
|
||||
const createQuickReplyItems = runtime.channel.line.createQuickReplyItems;
|
||||
|
||||
let lastResult: { messageId: string; chatId: string } | null = null;
|
||||
const hasQuickReplies = Boolean(lineData.quickReplies?.length);
|
||||
const quickReply = hasQuickReplies
|
||||
? createQuickReplyItems(lineData.quickReplies!)
|
||||
: undefined;
|
||||
|
||||
const sendMessageBatch = async (messages: Array<Record<string, unknown>>) => {
|
||||
if (messages.length === 0) return;
|
||||
for (let i = 0; i < messages.length; i += 5) {
|
||||
const result = await sendBatch(to, messages.slice(i, i + 5), {
|
||||
verbose: false,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
lastResult = { messageId: result.messageId, chatId: result.chatId };
|
||||
}
|
||||
};
|
||||
|
||||
const processed = payload.text
|
||||
? processLineMessage(payload.text)
|
||||
: { text: "", flexMessages: [] };
|
||||
|
||||
const chunkLimit =
|
||||
runtime.channel.text.resolveTextChunkLimit?.(
|
||||
cfg,
|
||||
"line",
|
||||
accountId ?? undefined,
|
||||
{
|
||||
fallbackLimit: 5000,
|
||||
},
|
||||
) ?? 5000;
|
||||
|
||||
const chunks = processed.text
|
||||
? runtime.channel.text.chunkMarkdownText(processed.text, chunkLimit)
|
||||
: [];
|
||||
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const shouldSendQuickRepliesInline = chunks.length === 0 && hasQuickReplies;
|
||||
|
||||
if (!shouldSendQuickRepliesInline) {
|
||||
if (lineData.flexMessage) {
|
||||
lastResult = await sendFlex(
|
||||
to,
|
||||
lineData.flexMessage.altText,
|
||||
lineData.flexMessage.contents,
|
||||
{
|
||||
verbose: false,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (lineData.templateMessage) {
|
||||
const template = buildTemplate(lineData.templateMessage);
|
||||
if (template) {
|
||||
lastResult = await sendTemplate(to, template, {
|
||||
verbose: false,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (lineData.location) {
|
||||
lastResult = await sendLocation(to, lineData.location, {
|
||||
verbose: false,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
for (const flexMsg of processed.flexMessages) {
|
||||
lastResult = await sendFlex(to, flexMsg.altText, flexMsg.contents, {
|
||||
verbose: false,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sendMediaAfterText = !(hasQuickReplies && chunks.length > 0);
|
||||
if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && !sendMediaAfterText) {
|
||||
for (const url of mediaUrls) {
|
||||
lastResult = await runtime.channel.line.sendMessageLine(to, "", {
|
||||
verbose: false,
|
||||
mediaUrl: url,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (chunks.length > 0) {
|
||||
for (let i = 0; i < chunks.length; i += 1) {
|
||||
const isLast = i === chunks.length - 1;
|
||||
if (isLast && hasQuickReplies) {
|
||||
lastResult = await sendQuickReplies(to, chunks[i]!, lineData.quickReplies!, {
|
||||
verbose: false,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
} else {
|
||||
lastResult = await sendText(to, chunks[i]!, {
|
||||
verbose: false,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (shouldSendQuickRepliesInline) {
|
||||
const quickReplyMessages: Array<Record<string, unknown>> = [];
|
||||
if (lineData.flexMessage) {
|
||||
quickReplyMessages.push({
|
||||
type: "flex",
|
||||
altText: lineData.flexMessage.altText.slice(0, 400),
|
||||
contents: lineData.flexMessage.contents,
|
||||
});
|
||||
}
|
||||
if (lineData.templateMessage) {
|
||||
const template = buildTemplate(lineData.templateMessage);
|
||||
if (template) {
|
||||
quickReplyMessages.push(template);
|
||||
}
|
||||
}
|
||||
if (lineData.location) {
|
||||
quickReplyMessages.push({
|
||||
type: "location",
|
||||
title: lineData.location.title.slice(0, 100),
|
||||
address: lineData.location.address.slice(0, 100),
|
||||
latitude: lineData.location.latitude,
|
||||
longitude: lineData.location.longitude,
|
||||
});
|
||||
}
|
||||
for (const flexMsg of processed.flexMessages) {
|
||||
quickReplyMessages.push({
|
||||
type: "flex",
|
||||
altText: flexMsg.altText.slice(0, 400),
|
||||
contents: flexMsg.contents,
|
||||
});
|
||||
}
|
||||
for (const url of mediaUrls) {
|
||||
const trimmed = url?.trim();
|
||||
if (!trimmed) continue;
|
||||
quickReplyMessages.push({
|
||||
type: "image",
|
||||
originalContentUrl: trimmed,
|
||||
previewImageUrl: trimmed,
|
||||
});
|
||||
}
|
||||
if (quickReplyMessages.length > 0 && quickReply) {
|
||||
const lastIndex = quickReplyMessages.length - 1;
|
||||
quickReplyMessages[lastIndex] = {
|
||||
...quickReplyMessages[lastIndex],
|
||||
quickReply,
|
||||
};
|
||||
await sendMessageBatch(quickReplyMessages);
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && sendMediaAfterText) {
|
||||
for (const url of mediaUrls) {
|
||||
lastResult = await runtime.channel.line.sendMessageLine(to, "", {
|
||||
verbose: false,
|
||||
mediaUrl: url,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (lastResult) return { channel: "line", ...lastResult };
|
||||
return { channel: "line", messageId: "empty", chatId: to };
|
||||
},
|
||||
sendText: async ({ to, text, accountId }) => {
|
||||
const runtime = getLineRuntime();
|
||||
const sendText = runtime.channel.line.pushMessageLine;
|
||||
const sendFlex = runtime.channel.line.pushFlexMessage;
|
||||
|
||||
// Process markdown: extract tables/code blocks, strip formatting
|
||||
const processed = processLineMessage(text);
|
||||
|
||||
// Send cleaned text first (if non-empty)
|
||||
let result: { messageId: string; chatId: string };
|
||||
if (processed.text.trim()) {
|
||||
result = await sendText(to, processed.text, {
|
||||
verbose: false,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
} else {
|
||||
// If text is empty after processing, still need a result
|
||||
result = { messageId: "processed", chatId: to };
|
||||
}
|
||||
|
||||
// Send flex messages for tables/code blocks
|
||||
for (const flexMsg of processed.flexMessages) {
|
||||
await sendFlex(to, flexMsg.altText, flexMsg.contents, {
|
||||
verbose: false,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return { channel: "line", ...result };
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId }) => {
|
||||
const send = getLineRuntime().channel.line.sendMessageLine;
|
||||
const result = await send(to, text, {
|
||||
verbose: false,
|
||||
mediaUrl,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
return { channel: "line", ...result };
|
||||
},
|
||||
},
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
collectStatusIssues: ({ account }) => {
|
||||
const issues: Array<{ level: "error" | "warning"; message: string }> = [];
|
||||
if (!account.channelAccessToken?.trim()) {
|
||||
issues.push({
|
||||
level: "error",
|
||||
message: "LINE channel access token not configured",
|
||||
});
|
||||
}
|
||||
if (!account.channelSecret?.trim()) {
|
||||
issues.push({
|
||||
level: "error",
|
||||
message: "LINE channel secret not configured",
|
||||
});
|
||||
}
|
||||
return issues;
|
||||
},
|
||||
buildChannelSummary: ({ snapshot }) => ({
|
||||
configured: snapshot.configured ?? false,
|
||||
tokenSource: snapshot.tokenSource ?? "none",
|
||||
running: snapshot.running ?? false,
|
||||
mode: snapshot.mode ?? null,
|
||||
lastStartAt: snapshot.lastStartAt ?? null,
|
||||
lastStopAt: snapshot.lastStopAt ?? null,
|
||||
lastError: snapshot.lastError ?? null,
|
||||
probe: snapshot.probe,
|
||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||
}),
|
||||
probeAccount: async ({ account, timeoutMs }) =>
|
||||
getLineRuntime().channel.line.probeLineBot(account.channelAccessToken, timeoutMs),
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => {
|
||||
const configured = Boolean(account.channelAccessToken?.trim());
|
||||
return {
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured,
|
||||
tokenSource: account.tokenSource,
|
||||
running: runtime?.running ?? false,
|
||||
lastStartAt: runtime?.lastStartAt ?? null,
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
lastError: runtime?.lastError ?? null,
|
||||
mode: "webhook",
|
||||
probe,
|
||||
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||
};
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
const token = account.channelAccessToken.trim();
|
||||
const secret = account.channelSecret.trim();
|
||||
|
||||
let lineBotLabel = "";
|
||||
try {
|
||||
const probe = await getLineRuntime().channel.line.probeLineBot(token, 2500);
|
||||
const displayName = probe.ok ? probe.bot?.displayName?.trim() : null;
|
||||
if (displayName) lineBotLabel = ` (${displayName})`;
|
||||
} catch (err) {
|
||||
if (getLineRuntime().logging.shouldLogVerbose()) {
|
||||
ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.log?.info(`[${account.accountId}] starting LINE provider${lineBotLabel}`);
|
||||
|
||||
return getLineRuntime().channel.line.monitorLineProvider({
|
||||
channelAccessToken: token,
|
||||
channelSecret: secret,
|
||||
accountId: account.accountId,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
webhookPath: account.config.webhookPath,
|
||||
});
|
||||
},
|
||||
logoutAccount: async ({ accountId, cfg }) => {
|
||||
const envToken = process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim() ?? "";
|
||||
const nextCfg = { ...cfg } as ClawdbotConfig;
|
||||
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
||||
const nextLine = { ...lineConfig };
|
||||
let cleared = false;
|
||||
let changed = false;
|
||||
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
if (
|
||||
nextLine.channelAccessToken ||
|
||||
nextLine.channelSecret ||
|
||||
nextLine.tokenFile ||
|
||||
nextLine.secretFile
|
||||
) {
|
||||
delete nextLine.channelAccessToken;
|
||||
delete nextLine.channelSecret;
|
||||
delete nextLine.tokenFile;
|
||||
delete nextLine.secretFile;
|
||||
cleared = true;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const accounts = nextLine.accounts ? { ...nextLine.accounts } : undefined;
|
||||
if (accounts && accountId in accounts) {
|
||||
const entry = accounts[accountId];
|
||||
if (entry && typeof entry === "object") {
|
||||
const nextEntry = { ...entry } as Record<string, unknown>;
|
||||
if (
|
||||
"channelAccessToken" in nextEntry ||
|
||||
"channelSecret" in nextEntry ||
|
||||
"tokenFile" in nextEntry ||
|
||||
"secretFile" in nextEntry
|
||||
) {
|
||||
cleared = true;
|
||||
delete nextEntry.channelAccessToken;
|
||||
delete nextEntry.channelSecret;
|
||||
delete nextEntry.tokenFile;
|
||||
delete nextEntry.secretFile;
|
||||
changed = true;
|
||||
}
|
||||
if (Object.keys(nextEntry).length === 0) {
|
||||
delete accounts[accountId];
|
||||
changed = true;
|
||||
} else {
|
||||
accounts[accountId] = nextEntry as typeof entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (accounts) {
|
||||
if (Object.keys(accounts).length === 0) {
|
||||
delete nextLine.accounts;
|
||||
changed = true;
|
||||
} else {
|
||||
nextLine.accounts = accounts;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
if (Object.keys(nextLine).length > 0) {
|
||||
nextCfg.channels = { ...nextCfg.channels, line: nextLine };
|
||||
} else {
|
||||
const nextChannels = { ...nextCfg.channels };
|
||||
delete (nextChannels as Record<string, unknown>).line;
|
||||
if (Object.keys(nextChannels).length > 0) {
|
||||
nextCfg.channels = nextChannels;
|
||||
} else {
|
||||
delete nextCfg.channels;
|
||||
}
|
||||
}
|
||||
await getLineRuntime().config.writeConfigFile(nextCfg);
|
||||
}
|
||||
|
||||
const resolved = getLineRuntime().channel.line.resolveLineAccount({
|
||||
cfg: changed ? nextCfg : cfg,
|
||||
accountId,
|
||||
});
|
||||
const loggedOut = resolved.tokenSource === "none";
|
||||
|
||||
return { cleared, envToken: Boolean(envToken), loggedOut };
|
||||
},
|
||||
},
|
||||
agentPrompt: {
|
||||
messageToolHints: () => [
|
||||
"",
|
||||
"### LINE Rich Messages",
|
||||
"LINE supports rich visual messages. Use these directives in your reply when appropriate:",
|
||||
"",
|
||||
"**Quick Replies** (bottom button suggestions):",
|
||||
" [[quick_replies: Option 1, Option 2, Option 3]]",
|
||||
"",
|
||||
"**Location** (map pin):",
|
||||
" [[location: Place Name | Address | latitude | longitude]]",
|
||||
"",
|
||||
"**Confirm Dialog** (yes/no prompt):",
|
||||
" [[confirm: Question text? | Yes Label | No Label]]",
|
||||
"",
|
||||
"**Button Menu** (title + text + buttons):",
|
||||
" [[buttons: Title | Description | Btn1:action1, Btn2:https://url.com]]",
|
||||
"",
|
||||
"**Media Player Card** (music status):",
|
||||
" [[media_player: Song Title | Artist Name | Source | https://albumart.url | playing]]",
|
||||
" - Status: 'playing' or 'paused' (optional)",
|
||||
"",
|
||||
"**Event Card** (calendar events, meetings):",
|
||||
" [[event: Event Title | Date | Time | Location | Description]]",
|
||||
" - Time, Location, Description are optional",
|
||||
"",
|
||||
"**Agenda Card** (multiple events/schedule):",
|
||||
" [[agenda: Schedule Title | Event1:9:00 AM, Event2:12:00 PM, Event3:3:00 PM]]",
|
||||
"",
|
||||
"**Device Control Card** (smart devices, TVs, etc.):",
|
||||
" [[device: Device Name | Device Type | Status | Control1:data1, Control2:data2]]",
|
||||
"",
|
||||
"**Apple TV Remote** (full D-pad + transport):",
|
||||
" [[appletv_remote: Apple TV | Playing]]",
|
||||
"",
|
||||
"**Auto-converted**: Markdown tables become Flex cards, code blocks become styled cards.",
|
||||
"",
|
||||
"When to use rich messages:",
|
||||
"- Use [[quick_replies:...]] when offering 2-4 clear options",
|
||||
"- Use [[confirm:...]] for yes/no decisions",
|
||||
"- Use [[buttons:...]] for menus with actions/links",
|
||||
"- Use [[location:...]] when sharing a place",
|
||||
"- Use [[media_player:...]] when showing what's playing",
|
||||
"- Use [[event:...]] for calendar event details",
|
||||
"- Use [[agenda:...]] for a day's schedule or event list",
|
||||
"- Use [[device:...]] for smart device status/controls",
|
||||
"- Tables/code in your response auto-convert to visual cards",
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setLineRuntime(r: PluginRuntime): void {
|
||||
runtime = r;
|
||||
}
|
||||
|
||||
export function getLineRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("LINE runtime not initialized - plugin not registered");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/llm-task",
|
||||
"version": "2026.1.24",
|
||||
"version": "2026.1.23",
|
||||
"type": "module",
|
||||
"description": "Clawdbot JSON-only LLM task plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/lobster",
|
||||
"version": "2026.1.24",
|
||||
"version": "2026.1.23",
|
||||
"type": "module",
|
||||
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/matrix",
|
||||
"version": "2026.1.24",
|
||||
"version": "2026.1.23",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Matrix channel plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -329,20 +329,16 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
return;
|
||||
}
|
||||
|
||||
const contentInfo =
|
||||
"info" in content && content.info && typeof content.info === "object"
|
||||
? (content.info as { mimetype?: string; size?: number })
|
||||
const contentType =
|
||||
"info" in content && content.info && "mimetype" in content.info
|
||||
? (content.info as { mimetype?: string }).mimetype
|
||||
: undefined;
|
||||
const contentType = contentInfo?.mimetype;
|
||||
const contentSize =
|
||||
typeof contentInfo?.size === "number" ? contentInfo.size : undefined;
|
||||
if (mediaUrl?.startsWith("mxc://")) {
|
||||
try {
|
||||
media = await downloadMatrixMedia({
|
||||
client,
|
||||
mxcUrl: mediaUrl,
|
||||
contentType,
|
||||
sizeBytes: contentSize,
|
||||
maxBytes: mediaMaxBytes,
|
||||
file: contentFile,
|
||||
});
|
||||
|
||||
@@ -25,8 +25,10 @@ describe("downloadMatrixMedia", () => {
|
||||
|
||||
it("decrypts encrypted media when file payloads are present", async () => {
|
||||
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
|
||||
const downloadContent = vi.fn().mockResolvedValue(Buffer.from("encrypted"));
|
||||
|
||||
const client = {
|
||||
downloadContent,
|
||||
crypto: { decryptMedia },
|
||||
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
|
||||
} as unknown as import("matrix-bot-sdk").MatrixClient;
|
||||
@@ -53,8 +55,7 @@ describe("downloadMatrixMedia", () => {
|
||||
file,
|
||||
});
|
||||
|
||||
// decryptMedia should be called with just the file object (it handles download internally)
|
||||
expect(decryptMedia).toHaveBeenCalledWith(file);
|
||||
expect(decryptMedia).toHaveBeenCalled();
|
||||
expect(saveMediaBuffer).toHaveBeenCalledWith(
|
||||
Buffer.from("decrypted"),
|
||||
"image/png",
|
||||
@@ -63,41 +64,4 @@ describe("downloadMatrixMedia", () => {
|
||||
);
|
||||
expect(result?.path).toBe("/tmp/media");
|
||||
});
|
||||
|
||||
it("rejects encrypted media that exceeds maxBytes before decrypting", async () => {
|
||||
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
|
||||
|
||||
const client = {
|
||||
crypto: { decryptMedia },
|
||||
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
|
||||
} as unknown as import("matrix-bot-sdk").MatrixClient;
|
||||
|
||||
const file = {
|
||||
url: "mxc://example/file",
|
||||
key: {
|
||||
kty: "oct",
|
||||
key_ops: ["encrypt", "decrypt"],
|
||||
alg: "A256CTR",
|
||||
k: "secret",
|
||||
ext: true,
|
||||
},
|
||||
iv: "iv",
|
||||
hashes: { sha256: "hash" },
|
||||
v: "v2",
|
||||
};
|
||||
|
||||
await expect(
|
||||
downloadMatrixMedia({
|
||||
client,
|
||||
mxcUrl: "mxc://example/file",
|
||||
contentType: "image/png",
|
||||
sizeBytes: 2048,
|
||||
maxBytes: 1024,
|
||||
file,
|
||||
}),
|
||||
).rejects.toThrow("Matrix media exceeds configured size limit");
|
||||
|
||||
expect(decryptMedia).not.toHaveBeenCalled();
|
||||
expect(saveMediaBuffer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -40,7 +40,6 @@ async function fetchMatrixMediaBuffer(params: {
|
||||
|
||||
/**
|
||||
* Download and decrypt encrypted media from a Matrix room.
|
||||
* Uses matrix-bot-sdk's decryptMedia which handles both download and decryption.
|
||||
*/
|
||||
async function fetchEncryptedMediaBuffer(params: {
|
||||
client: MatrixClient;
|
||||
@@ -51,13 +50,18 @@ async function fetchEncryptedMediaBuffer(params: {
|
||||
throw new Error("Cannot decrypt media: crypto not enabled");
|
||||
}
|
||||
|
||||
// decryptMedia handles downloading and decrypting the encrypted content internally
|
||||
const decrypted = await params.client.crypto.decryptMedia(params.file);
|
||||
|
||||
if (decrypted.byteLength > params.maxBytes) {
|
||||
// Download the encrypted content
|
||||
const encryptedBuffer = await params.client.downloadContent(params.file.url);
|
||||
if (encryptedBuffer.byteLength > params.maxBytes) {
|
||||
throw new Error("Matrix media exceeds configured size limit");
|
||||
}
|
||||
|
||||
// Decrypt using matrix-bot-sdk crypto
|
||||
const decrypted = await params.client.crypto.decryptMedia(
|
||||
Buffer.from(encryptedBuffer),
|
||||
params.file,
|
||||
);
|
||||
|
||||
return { buffer: decrypted };
|
||||
}
|
||||
|
||||
@@ -65,7 +69,6 @@ export async function downloadMatrixMedia(params: {
|
||||
client: MatrixClient;
|
||||
mxcUrl: string;
|
||||
contentType?: string;
|
||||
sizeBytes?: number;
|
||||
maxBytes: number;
|
||||
file?: EncryptedFile;
|
||||
}): Promise<{
|
||||
@@ -74,12 +77,6 @@ export async function downloadMatrixMedia(params: {
|
||||
placeholder: string;
|
||||
} | null> {
|
||||
let fetched: { buffer: Buffer; headerType?: string } | null;
|
||||
if (
|
||||
typeof params.sizeBytes === "number" &&
|
||||
params.sizeBytes > params.maxBytes
|
||||
) {
|
||||
throw new Error("Matrix media exceeds configured size limit");
|
||||
}
|
||||
|
||||
if (params.file) {
|
||||
// Encrypted media
|
||||
|
||||
@@ -29,7 +29,6 @@ export type RoomMessageEventContent = MessageEventContent & {
|
||||
file?: EncryptedFile;
|
||||
info?: {
|
||||
mimetype?: string;
|
||||
size?: number;
|
||||
};
|
||||
"m.relates_to"?: {
|
||||
rel_type?: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/mattermost",
|
||||
"version": "2026.1.24",
|
||||
"version": "2026.1.23",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Mattermost channel plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/memory-core",
|
||||
"version": "2026.1.24",
|
||||
"version": "2026.1.23",
|
||||
"type": "module",
|
||||
"description": "Clawdbot core memory search plugin",
|
||||
"clawdbot": {
|
||||
@@ -9,6 +9,6 @@
|
||||
]
|
||||
},
|
||||
"peerDependencies": {
|
||||
"clawdbot": ">=2026.1.24"
|
||||
"clawdbot": ">=2026.1.23-1"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/memory-lancedb",
|
||||
"version": "2026.1.24",
|
||||
"version": "2026.1.23",
|
||||
"type": "module",
|
||||
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/msteams",
|
||||
"version": "2026.1.24",
|
||||
"version": "2026.1.23",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Microsoft Teams channel plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/nextcloud-talk",
|
||||
"version": "2026.1.24",
|
||||
"version": "2026.1.23",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Nextcloud Talk channel plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/nostr",
|
||||
"version": "2026.1.24",
|
||||
"version": "2026.1.23",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/open-prose",
|
||||
"version": "2026.1.24",
|
||||
"version": "2026.1.23",
|
||||
"type": "module",
|
||||
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/signal",
|
||||
"version": "2026.1.24",
|
||||
"version": "2026.1.23",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Signal channel plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/slack",
|
||||
"version": "2026.1.24",
|
||||
"version": "2026.1.23",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Slack channel plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/telegram",
|
||||
"version": "2026.1.24",
|
||||
"version": "2026.1.23",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Telegram channel plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/tlon",
|
||||
"version": "2026.1.24",
|
||||
"version": "2026.1.22",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Tlon/Urbit channel plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/voice-call",
|
||||
"version": "2026.1.24",
|
||||
"version": "2026.1.23",
|
||||
"type": "module",
|
||||
"description": "Clawdbot voice-call plugin",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type {
|
||||
OpenAIRealtimeSTTProvider,
|
||||
RealtimeSTTSession,
|
||||
} from "./providers/stt-openai-realtime.js";
|
||||
import { MediaStreamHandler } from "./media-stream.js";
|
||||
|
||||
const createStubSession = (): RealtimeSTTSession => ({
|
||||
connect: async () => {},
|
||||
sendAudio: () => {},
|
||||
waitForTranscript: async () => "",
|
||||
onPartial: () => {},
|
||||
onTranscript: () => {},
|
||||
onSpeechStart: () => {},
|
||||
close: () => {},
|
||||
isConnected: () => true,
|
||||
});
|
||||
|
||||
const createStubSttProvider = (): OpenAIRealtimeSTTProvider =>
|
||||
({
|
||||
createSession: () => createStubSession(),
|
||||
}) as unknown as OpenAIRealtimeSTTProvider;
|
||||
|
||||
const flush = async (): Promise<void> => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
};
|
||||
|
||||
const waitForAbort = (signal: AbortSignal): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
if (signal.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
signal.addEventListener("abort", () => resolve(), { once: true });
|
||||
});
|
||||
|
||||
describe("MediaStreamHandler TTS queue", () => {
|
||||
it("serializes TTS playback and resolves in order", async () => {
|
||||
const handler = new MediaStreamHandler({
|
||||
sttProvider: createStubSttProvider(),
|
||||
});
|
||||
const started: number[] = [];
|
||||
const finished: number[] = [];
|
||||
|
||||
let resolveFirst!: () => void;
|
||||
const firstGate = new Promise<void>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
|
||||
const first = handler.queueTts("stream-1", async () => {
|
||||
started.push(1);
|
||||
await firstGate;
|
||||
finished.push(1);
|
||||
});
|
||||
const second = handler.queueTts("stream-1", async () => {
|
||||
started.push(2);
|
||||
finished.push(2);
|
||||
});
|
||||
|
||||
await flush();
|
||||
expect(started).toEqual([1]);
|
||||
|
||||
resolveFirst();
|
||||
await first;
|
||||
await second;
|
||||
|
||||
expect(started).toEqual([1, 2]);
|
||||
expect(finished).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("cancels active playback and clears queued items", async () => {
|
||||
const handler = new MediaStreamHandler({
|
||||
sttProvider: createStubSttProvider(),
|
||||
});
|
||||
|
||||
let queuedRan = false;
|
||||
const started: string[] = [];
|
||||
|
||||
const active = handler.queueTts("stream-1", async (signal) => {
|
||||
started.push("active");
|
||||
await waitForAbort(signal);
|
||||
});
|
||||
void handler.queueTts("stream-1", async () => {
|
||||
queuedRan = true;
|
||||
});
|
||||
|
||||
await flush();
|
||||
expect(started).toEqual(["active"]);
|
||||
|
||||
handler.clearTtsQueue("stream-1");
|
||||
await active;
|
||||
await flush();
|
||||
|
||||
expect(queuedRan).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -29,8 +29,6 @@ export interface MediaStreamConfig {
|
||||
onPartialTranscript?: (callId: string, partial: string) => void;
|
||||
/** Callback when stream connects */
|
||||
onConnect?: (callId: string, streamSid: string) => void;
|
||||
/** Callback when speech starts (barge-in) */
|
||||
onSpeechStart?: (callId: string) => void;
|
||||
/** Callback when stream disconnects */
|
||||
onDisconnect?: (callId: string) => void;
|
||||
}
|
||||
@@ -45,13 +43,6 @@ interface StreamSession {
|
||||
sttSession: RealtimeSTTSession;
|
||||
}
|
||||
|
||||
type TtsQueueEntry = {
|
||||
playFn: (signal: AbortSignal) => Promise<void>;
|
||||
controller: AbortController;
|
||||
resolve: () => void;
|
||||
reject: (error: unknown) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Manages WebSocket connections for Twilio media streams.
|
||||
*/
|
||||
@@ -59,12 +50,6 @@ export class MediaStreamHandler {
|
||||
private wss: WebSocketServer | null = null;
|
||||
private sessions = new Map<string, StreamSession>();
|
||||
private config: MediaStreamConfig;
|
||||
/** TTS playback queues per stream (serialize audio to prevent overlap) */
|
||||
private ttsQueues = new Map<string, TtsQueueEntry[]>();
|
||||
/** Whether TTS is currently playing per stream */
|
||||
private ttsPlaying = new Map<string, boolean>();
|
||||
/** Active TTS playback controllers per stream */
|
||||
private ttsActiveControllers = new Map<string, AbortController>();
|
||||
|
||||
constructor(config: MediaStreamConfig) {
|
||||
this.config = config;
|
||||
@@ -163,10 +148,6 @@ export class MediaStreamHandler {
|
||||
this.config.onTranscript?.(callSid, transcript);
|
||||
});
|
||||
|
||||
sttSession.onSpeechStart(() => {
|
||||
this.config.onSpeechStart?.(callSid);
|
||||
});
|
||||
|
||||
const session: StreamSession = {
|
||||
callId: callSid,
|
||||
streamSid,
|
||||
@@ -196,7 +177,6 @@ export class MediaStreamHandler {
|
||||
private handleStop(session: StreamSession): void {
|
||||
console.log(`[MediaStream] Stream stopped: ${session.streamSid}`);
|
||||
|
||||
this.clearTtsState(session.streamSid);
|
||||
session.sttSession.close();
|
||||
this.sessions.delete(session.streamSid);
|
||||
this.config.onDisconnect?.(session.callId);
|
||||
@@ -248,46 +228,6 @@ export class MediaStreamHandler {
|
||||
this.sendToStream(streamSid, { event: "clear", streamSid });
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a TTS operation for sequential playback.
|
||||
* Only one TTS operation plays at a time per stream to prevent overlap.
|
||||
*/
|
||||
async queueTts(
|
||||
streamSid: string,
|
||||
playFn: (signal: AbortSignal) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const queue = this.getTtsQueue(streamSid);
|
||||
let resolveEntry: () => void;
|
||||
let rejectEntry: (error: unknown) => void;
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
resolveEntry = resolve;
|
||||
rejectEntry = reject;
|
||||
});
|
||||
|
||||
queue.push({
|
||||
playFn,
|
||||
controller: new AbortController(),
|
||||
resolve: resolveEntry!,
|
||||
reject: rejectEntry!,
|
||||
});
|
||||
|
||||
if (!this.ttsPlaying.get(streamSid)) {
|
||||
void this.processQueue(streamSid);
|
||||
}
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear TTS queue and interrupt current playback (barge-in).
|
||||
*/
|
||||
clearTtsQueue(streamSid: string): void {
|
||||
const queue = this.getTtsQueue(streamSid);
|
||||
queue.length = 0;
|
||||
this.ttsActiveControllers.get(streamSid)?.abort();
|
||||
this.clearAudio(streamSid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active session by call ID.
|
||||
*/
|
||||
@@ -302,65 +242,11 @@ export class MediaStreamHandler {
|
||||
*/
|
||||
closeAll(): void {
|
||||
for (const session of this.sessions.values()) {
|
||||
this.clearTtsState(session.streamSid);
|
||||
session.sttSession.close();
|
||||
session.ws.close();
|
||||
}
|
||||
this.sessions.clear();
|
||||
}
|
||||
|
||||
private getTtsQueue(streamSid: string): TtsQueueEntry[] {
|
||||
const existing = this.ttsQueues.get(streamSid);
|
||||
if (existing) return existing;
|
||||
const queue: TtsQueueEntry[] = [];
|
||||
this.ttsQueues.set(streamSid, queue);
|
||||
return queue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the TTS queue for a stream.
|
||||
* Uses iterative approach to avoid stack accumulation from recursion.
|
||||
*/
|
||||
private async processQueue(streamSid: string): Promise<void> {
|
||||
this.ttsPlaying.set(streamSid, true);
|
||||
|
||||
while (true) {
|
||||
const queue = this.ttsQueues.get(streamSid);
|
||||
if (!queue || queue.length === 0) {
|
||||
this.ttsPlaying.set(streamSid, false);
|
||||
this.ttsActiveControllers.delete(streamSid);
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = queue.shift()!;
|
||||
this.ttsActiveControllers.set(streamSid, entry.controller);
|
||||
|
||||
try {
|
||||
await entry.playFn(entry.controller.signal);
|
||||
entry.resolve();
|
||||
} catch (error) {
|
||||
if (entry.controller.signal.aborted) {
|
||||
entry.resolve();
|
||||
} else {
|
||||
console.error("[MediaStream] TTS playback error:", error);
|
||||
entry.reject(error);
|
||||
}
|
||||
} finally {
|
||||
if (this.ttsActiveControllers.get(streamSid) === entry.controller) {
|
||||
this.ttsActiveControllers.delete(streamSid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private clearTtsState(streamSid: string): void {
|
||||
const queue = this.ttsQueues.get(streamSid);
|
||||
if (queue) queue.length = 0;
|
||||
this.ttsActiveControllers.get(streamSid)?.abort();
|
||||
this.ttsActiveControllers.delete(streamSid);
|
||||
this.ttsPlaying.delete(streamSid);
|
||||
this.ttsQueues.delete(streamSid);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -38,8 +38,6 @@ export interface RealtimeSTTSession {
|
||||
onPartial(callback: (partial: string) => void): void;
|
||||
/** Set callback for final transcripts */
|
||||
onTranscript(callback: (transcript: string) => void): void;
|
||||
/** Set callback when speech starts (VAD) */
|
||||
onSpeechStart(callback: () => void): void;
|
||||
/** Close the session */
|
||||
close(): void;
|
||||
/** Check if session is connected */
|
||||
@@ -93,7 +91,6 @@ class OpenAIRealtimeSTTSession implements RealtimeSTTSession {
|
||||
private pendingTranscript = "";
|
||||
private onTranscriptCallback: ((transcript: string) => void) | null = null;
|
||||
private onPartialCallback: ((partial: string) => void) | null = null;
|
||||
private onSpeechStartCallback: (() => void) | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly apiKey: string,
|
||||
@@ -246,7 +243,6 @@ class OpenAIRealtimeSTTSession implements RealtimeSTTSession {
|
||||
case "input_audio_buffer.speech_started":
|
||||
console.log("[RealtimeSTT] Speech started");
|
||||
this.pendingTranscript = "";
|
||||
this.onSpeechStartCallback?.();
|
||||
break;
|
||||
|
||||
case "error":
|
||||
@@ -277,10 +273,6 @@ class OpenAIRealtimeSTTSession implements RealtimeSTTSession {
|
||||
this.onTranscriptCallback = callback;
|
||||
}
|
||||
|
||||
onSpeechStart(callback: () => void): void {
|
||||
this.onSpeechStartCallback = callback;
|
||||
}
|
||||
|
||||
async waitForTranscript(timeoutMs = 30000): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
|
||||
@@ -135,17 +135,6 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
this.callStreamMap.delete(callSid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear TTS queue for a call (barge-in).
|
||||
* Used when user starts speaking to interrupt current TTS playback.
|
||||
*/
|
||||
clearTtsQueue(callSid: string): void {
|
||||
const streamSid = this.callStreamMap.get(callSid);
|
||||
if (streamSid && this.mediaStreamHandler) {
|
||||
this.mediaStreamHandler.clearTtsQueue(streamSid);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated request to the Twilio API.
|
||||
*/
|
||||
@@ -515,7 +504,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
/**
|
||||
* Play TTS via core TTS and Twilio Media Streams.
|
||||
* Generates audio with core TTS, converts to mu-law, and streams via WebSocket.
|
||||
* Uses a queue to serialize playback and prevent overlapping audio.
|
||||
* Uses a jitter buffer to smooth out timing variations.
|
||||
*/
|
||||
private async playTtsViaStream(
|
||||
text: string,
|
||||
@@ -525,29 +514,22 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
throw new Error("TTS provider and media stream handler required");
|
||||
}
|
||||
|
||||
// Generate audio with core TTS (returns mu-law at 8kHz)
|
||||
const muLawAudio = await this.ttsProvider.synthesizeForTelephony(text);
|
||||
|
||||
// Stream audio in 20ms chunks (160 bytes at 8kHz mu-law)
|
||||
const CHUNK_SIZE = 160;
|
||||
const CHUNK_DELAY_MS = 20;
|
||||
|
||||
const handler = this.mediaStreamHandler;
|
||||
const ttsProvider = this.ttsProvider;
|
||||
await handler.queueTts(streamSid, async (signal) => {
|
||||
// Generate audio with core TTS (returns mu-law at 8kHz)
|
||||
const muLawAudio = await ttsProvider.synthesizeForTelephony(text);
|
||||
for (const chunk of chunkAudio(muLawAudio, CHUNK_SIZE)) {
|
||||
if (signal.aborted) break;
|
||||
handler.sendAudio(streamSid, chunk);
|
||||
for (const chunk of chunkAudio(muLawAudio, CHUNK_SIZE)) {
|
||||
this.mediaStreamHandler.sendAudio(streamSid, chunk);
|
||||
|
||||
// Pace the audio to match real-time playback
|
||||
await new Promise((resolve) => setTimeout(resolve, CHUNK_DELAY_MS));
|
||||
if (signal.aborted) break;
|
||||
}
|
||||
// Pace the audio to match real-time playback
|
||||
await new Promise((resolve) => setTimeout(resolve, CHUNK_DELAY_MS));
|
||||
}
|
||||
|
||||
if (!signal.aborted) {
|
||||
// Send a mark to track when audio finishes
|
||||
handler.sendMark(streamSid, `tts-${Date.now()}`);
|
||||
}
|
||||
});
|
||||
// Send a mark to track when audio finishes
|
||||
this.mediaStreamHandler.sendMark(streamSid, `tts-${Date.now()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -78,11 +78,6 @@ export class VoiceCallWebhookServer {
|
||||
`[voice-call] Transcript for ${providerCallId}: ${transcript}`,
|
||||
);
|
||||
|
||||
// Clear TTS queue on barge-in (user started speaking, interrupt current playback)
|
||||
if (this.provider.name === "twilio") {
|
||||
(this.provider as TwilioProvider).clearTtsQueue(providerCallId);
|
||||
}
|
||||
|
||||
// Look up our internal call ID from the provider call ID
|
||||
const call = this.manager.getCallByProviderCallId(providerCallId);
|
||||
if (!call) {
|
||||
@@ -114,11 +109,6 @@ export class VoiceCallWebhookServer {
|
||||
});
|
||||
}
|
||||
},
|
||||
onSpeechStart: (providerCallId) => {
|
||||
if (this.provider.name === "twilio") {
|
||||
(this.provider as TwilioProvider).clearTtsQueue(providerCallId);
|
||||
}
|
||||
},
|
||||
onPartialTranscript: (callId, partial) => {
|
||||
console.log(`[voice-call] Partial for ${callId}: ${partial}`);
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/whatsapp",
|
||||
"version": "2026.1.24",
|
||||
"version": "2026.1.23",
|
||||
"type": "module",
|
||||
"description": "Clawdbot WhatsApp channel plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/zalo",
|
||||
"version": "2026.1.24",
|
||||
"version": "2026.1.23",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Zalo channel plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/zalouser",
|
||||
"version": "2026.1.24",
|
||||
"version": "2026.1.23",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
|
||||
"dependencies": {
|
||||
|
||||
+2
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawdbot",
|
||||
"version": "2026.1.24-1",
|
||||
"version": "2026.1.24-0",
|
||||
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -42,7 +42,6 @@
|
||||
"dist/signal/**",
|
||||
"dist/slack/**",
|
||||
"dist/telegram/**",
|
||||
"dist/line/**",
|
||||
"dist/tui/**",
|
||||
"dist/tts/**",
|
||||
"dist/web/**",
|
||||
@@ -64,7 +63,6 @@
|
||||
"git-hooks/**",
|
||||
"dist/terminal/**",
|
||||
"dist/routing/**",
|
||||
"dist/shared/**",
|
||||
"dist/utils/**",
|
||||
"dist/logging/**",
|
||||
"dist/memory/**",
|
||||
@@ -156,7 +154,6 @@
|
||||
"@grammyjs/runner": "^2.0.3",
|
||||
"@grammyjs/transformer-throttler": "^1.2.1",
|
||||
"@homebridge/ciao": "^1.3.4",
|
||||
"@line/bot-sdk": "^10.6.0",
|
||||
"@lydell/node-pty": "1.2.0-beta.3",
|
||||
"@mariozechner/pi-agent-core": "0.49.3",
|
||||
"@mariozechner/pi-ai": "0.49.3",
|
||||
@@ -276,4 +273,4 @@
|
||||
"dist/Clawdbot.app/**"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+83
-30
@@ -34,9 +34,6 @@ importers:
|
||||
'@homebridge/ciao':
|
||||
specifier: ^1.3.4
|
||||
version: 1.3.4
|
||||
'@line/bot-sdk':
|
||||
specifier: ^10.6.0
|
||||
version: 10.6.0
|
||||
'@lydell/node-pty':
|
||||
specifier: 1.2.0-beta.3
|
||||
version: 1.2.0-beta.3
|
||||
@@ -320,12 +317,6 @@ importers:
|
||||
|
||||
extensions/imessage: {}
|
||||
|
||||
extensions/line:
|
||||
devDependencies:
|
||||
clawdbot:
|
||||
specifier: workspace:*
|
||||
version: link:../..
|
||||
|
||||
extensions/llm-task: {}
|
||||
|
||||
extensions/lobster: {}
|
||||
@@ -357,8 +348,8 @@ importers:
|
||||
extensions/memory-core:
|
||||
dependencies:
|
||||
clawdbot:
|
||||
specifier: '>=2026.1.24'
|
||||
version: link:../..
|
||||
specifier: '>=2026.1.23-1'
|
||||
version: 2026.1.23-1(@types/express@5.0.6)(audio-decode@2.2.3)(devtools-protocol@0.0.1561482)(typescript@5.9.3)
|
||||
|
||||
extensions/memory-lancedb:
|
||||
dependencies:
|
||||
@@ -1269,10 +1260,6 @@ packages:
|
||||
peerDependencies:
|
||||
apache-arrow: '>=15.0.0 <=18.1.0'
|
||||
|
||||
'@line/bot-sdk@10.6.0':
|
||||
resolution: {integrity: sha512-4hSpglL/G/cW2JCcohaYz/BS0uOSJNV9IEYdMm0EiPEvDLayoI2hGq2D86uYPQFD2gvgkyhmzdShpWLG3P5r3w==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@lit-labs/signals@0.2.0':
|
||||
resolution: {integrity: sha512-68plyIbciumbwKaiilhLNyhz4Vg6/+nJwDufG2xxWA9r/fUw58jxLHCAlKs+q1CE5Lmh3cZ3ShyYKnOCebEpVA==}
|
||||
|
||||
@@ -2660,9 +2647,6 @@ packages:
|
||||
'@types/node@20.19.30':
|
||||
resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==}
|
||||
|
||||
'@types/node@24.10.9':
|
||||
resolution: {integrity: sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==}
|
||||
|
||||
'@types/node@25.0.10':
|
||||
resolution: {integrity: sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==}
|
||||
|
||||
@@ -3124,6 +3108,11 @@ packages:
|
||||
class-variance-authority@0.7.1:
|
||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||
|
||||
clawdbot@2026.1.23-1:
|
||||
resolution: {integrity: sha512-t51ks5bnTRQNCzoTunUJaoeMjamvP3zP5EyyadmI34kXYGIbWcCx242w5XMr5h4sLSw59nBw3lJ74vErWDsz9w==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
hasBin: true
|
||||
|
||||
cli-cursor@5.0.0:
|
||||
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -6732,14 +6721,6 @@ snapshots:
|
||||
'@lancedb/lancedb-win32-arm64-msvc': 0.23.0
|
||||
'@lancedb/lancedb-win32-x64-msvc': 0.23.0
|
||||
|
||||
'@line/bot-sdk@10.6.0':
|
||||
dependencies:
|
||||
'@types/node': 24.10.9
|
||||
optionalDependencies:
|
||||
axios: 1.13.2(debug@4.4.3)
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
'@lit-labs/signals@0.2.0':
|
||||
dependencies:
|
||||
lit: 3.3.2
|
||||
@@ -8317,10 +8298,6 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@24.10.9':
|
||||
dependencies:
|
||||
undici-types: 7.16.0
|
||||
|
||||
'@types/node@25.0.10':
|
||||
dependencies:
|
||||
undici-types: 7.16.0
|
||||
@@ -8862,6 +8839,82 @@ snapshots:
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
|
||||
clawdbot@2026.1.23-1(@types/express@5.0.6)(audio-decode@2.2.3)(devtools-protocol@0.0.1561482)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@agentclientprotocol/sdk': 0.13.1(zod@4.3.6)
|
||||
'@aws-sdk/client-bedrock': 3.975.0
|
||||
'@buape/carbon': 0.14.0(hono@4.11.4)
|
||||
'@clack/prompts': 0.11.0
|
||||
'@grammyjs/runner': 2.0.3(grammy@1.39.3)
|
||||
'@grammyjs/transformer-throttler': 1.2.1(grammy@1.39.3)
|
||||
'@homebridge/ciao': 1.3.4
|
||||
'@lydell/node-pty': 1.2.0-beta.3
|
||||
'@mariozechner/pi-agent-core': 0.49.3(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-ai': 0.49.3(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-coding-agent': 0.49.3(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-tui': 0.49.3
|
||||
'@mozilla/readability': 0.6.0
|
||||
'@sinclair/typebox': 0.34.47
|
||||
'@slack/bolt': 4.6.0(@types/express@5.0.6)
|
||||
'@slack/web-api': 7.13.0
|
||||
'@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5)
|
||||
ajv: 8.17.1
|
||||
body-parser: 2.2.2
|
||||
chalk: 5.6.2
|
||||
chokidar: 5.0.0
|
||||
chromium-bidi: 13.0.1(devtools-protocol@0.0.1561482)
|
||||
cli-highlight: 2.1.11
|
||||
commander: 14.0.2
|
||||
croner: 9.1.0
|
||||
detect-libc: 2.1.2
|
||||
discord-api-types: 0.38.37
|
||||
dotenv: 17.2.3
|
||||
express: 5.2.1
|
||||
file-type: 21.3.0
|
||||
grammy: 1.39.3
|
||||
hono: 4.11.4
|
||||
jiti: 2.6.1
|
||||
json5: 2.2.3
|
||||
jszip: 3.10.1
|
||||
linkedom: 0.18.12
|
||||
long: 5.3.2
|
||||
markdown-it: 14.1.0
|
||||
osc-progress: 0.3.0
|
||||
pdfjs-dist: 5.4.530
|
||||
playwright-core: 1.58.0
|
||||
proper-lockfile: 4.1.2
|
||||
qrcode-terminal: 0.12.0
|
||||
sharp: 0.34.5
|
||||
sqlite-vec: 0.1.7-alpha.2
|
||||
tar: 7.5.4
|
||||
tslog: 4.10.2
|
||||
undici: 7.19.0
|
||||
ws: 8.19.0
|
||||
yaml: 2.8.2
|
||||
zod: 4.3.6
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas': 0.1.88
|
||||
node-llama-cpp: 3.15.0(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- '@discordjs/opus'
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- '@types/express'
|
||||
- audio-decode
|
||||
- aws-crt
|
||||
- bufferutil
|
||||
- canvas
|
||||
- debug
|
||||
- devtools-protocol
|
||||
- encoding
|
||||
- ffmpeg-static
|
||||
- jimp
|
||||
- link-preview-js
|
||||
- node-opus
|
||||
- opusscript
|
||||
- supports-color
|
||||
- typescript
|
||||
- utf-8-validate
|
||||
|
||||
cli-cursor@5.0.0:
|
||||
dependencies:
|
||||
restore-cursor: 5.1.0
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"ensureLogins": [
|
||||
"odrobnik",
|
||||
"alphonse-arianee",
|
||||
"aaronn",
|
||||
"ronak-guliani",
|
||||
"cpojer",
|
||||
"carlulsoe",
|
||||
|
||||
@@ -28,10 +28,9 @@ const overrideWorkers = Number.parseInt(process.env.CLAWDBOT_TEST_WORKERS ?? "",
|
||||
const resolvedOverride = Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null;
|
||||
const localWorkers = Math.max(4, Math.min(16, os.cpus().length));
|
||||
const perRunWorkers = Math.max(1, Math.floor(localWorkers / parallelRuns.length));
|
||||
const macCiWorkers = isCI && isMacOS ? 1 : perRunWorkers;
|
||||
// Keep worker counts predictable for local runs; trim macOS CI workers to avoid worker crashes/OOM.
|
||||
// Keep worker counts predictable for local runs and for CI on macOS.
|
||||
// In CI on linux/windows, prefer Vitest defaults to avoid cross-test interference from lower worker counts.
|
||||
const maxWorkers = resolvedOverride ?? (isCI && !isMacOS ? null : macCiWorkers);
|
||||
const maxWorkers = resolvedOverride ?? (isCI && !isMacOS ? null : perRunWorkers);
|
||||
|
||||
const WARNING_SUPPRESSION_FLAGS = [
|
||||
"--disable-warning=ExperimentalWarning",
|
||||
|
||||
@@ -54,8 +54,6 @@ export function createClawdbotTools(options?: {
|
||||
hasRepliedRef?: { value: boolean };
|
||||
/** If true, the model has native vision capability */
|
||||
modelHasVision?: boolean;
|
||||
/** Explicit agent ID override for cron/hook sessions. */
|
||||
requesterAgentIdOverride?: string;
|
||||
}): AnyAgentTool[] {
|
||||
const imageTool = options?.agentDir?.trim()
|
||||
? createImageTool({
|
||||
@@ -107,10 +105,7 @@ export function createClawdbotTools(options?: {
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
config: options?.config,
|
||||
}),
|
||||
createAgentsListTool({
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
requesterAgentIdOverride: options?.requesterAgentIdOverride,
|
||||
}),
|
||||
createAgentsListTool({ agentSessionKey: options?.agentSessionKey }),
|
||||
createSessionsListTool({
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
sandboxed: options?.sandboxed,
|
||||
@@ -134,7 +129,6 @@ export function createClawdbotTools(options?: {
|
||||
agentGroupChannel: options?.agentGroupChannel,
|
||||
agentGroupSpace: options?.agentGroupSpace,
|
||||
sandboxed: options?.sandboxed,
|
||||
requesterAgentIdOverride: options?.requesterAgentIdOverride,
|
||||
}),
|
||||
createSessionStatusTool({
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
|
||||
+229
-116
@@ -1,139 +1,252 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
parseModelRef,
|
||||
resolveModelRefFromString,
|
||||
resolveConfiguredModelRef,
|
||||
buildModelAliasIndex,
|
||||
normalizeProviderId,
|
||||
modelKey,
|
||||
} from "./model-selection.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { DEFAULT_PROVIDER } from "./defaults.js";
|
||||
import {
|
||||
buildAllowedModelSet,
|
||||
modelKey,
|
||||
parseModelRef,
|
||||
resolveAllowedModelRef,
|
||||
resolveHooksGmailModel,
|
||||
} from "./model-selection.js";
|
||||
|
||||
describe("model-selection", () => {
|
||||
describe("normalizeProviderId", () => {
|
||||
it("should normalize provider names", () => {
|
||||
expect(normalizeProviderId("Anthropic")).toBe("anthropic");
|
||||
expect(normalizeProviderId("Z.ai")).toBe("zai");
|
||||
expect(normalizeProviderId("z-ai")).toBe("zai");
|
||||
expect(normalizeProviderId("OpenCode-Zen")).toBe("opencode");
|
||||
expect(normalizeProviderId("qwen")).toBe("qwen-portal");
|
||||
});
|
||||
});
|
||||
const catalog = [
|
||||
{
|
||||
provider: "openai",
|
||||
id: "gpt-4",
|
||||
name: "GPT-4",
|
||||
},
|
||||
];
|
||||
|
||||
describe("parseModelRef", () => {
|
||||
it("should parse full model refs", () => {
|
||||
expect(parseModelRef("anthropic/claude-3-5-sonnet", "openai")).toEqual({
|
||||
provider: "anthropic",
|
||||
model: "claude-3-5-sonnet",
|
||||
});
|
||||
});
|
||||
|
||||
it("should use default provider if none specified", () => {
|
||||
expect(parseModelRef("claude-3-5-sonnet", "anthropic")).toEqual({
|
||||
provider: "anthropic",
|
||||
model: "claude-3-5-sonnet",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return null for empty strings", () => {
|
||||
expect(parseModelRef("", "anthropic")).toBeNull();
|
||||
expect(parseModelRef(" ", "anthropic")).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle invalid slash usage", () => {
|
||||
expect(parseModelRef("/", "anthropic")).toBeNull();
|
||||
expect(parseModelRef("anthropic/", "anthropic")).toBeNull();
|
||||
expect(parseModelRef("/model", "anthropic")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildModelAliasIndex", () => {
|
||||
it("should build alias index from config", () => {
|
||||
const cfg: Partial<ClawdbotConfig> = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"anthropic/claude-3-5-sonnet": { alias: "fast" },
|
||||
"openai/gpt-4o": { alias: "smart" },
|
||||
},
|
||||
describe("buildAllowedModelSet", () => {
|
||||
it("always allows the configured default model", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"openai/gpt-4": { alias: "gpt4" },
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const index = buildModelAliasIndex({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
defaultProvider: "anthropic",
|
||||
});
|
||||
|
||||
expect(index.byAlias.get("fast")?.ref).toEqual({
|
||||
provider: "anthropic",
|
||||
model: "claude-3-5-sonnet",
|
||||
});
|
||||
expect(index.byAlias.get("smart")?.ref).toEqual({ provider: "openai", model: "gpt-4o" });
|
||||
expect(index.byKey.get(modelKey("anthropic", "claude-3-5-sonnet"))).toEqual(["fast"]);
|
||||
const allowed = buildAllowedModelSet({
|
||||
cfg,
|
||||
catalog,
|
||||
defaultProvider: "claude-cli",
|
||||
defaultModel: "opus-4.5",
|
||||
});
|
||||
|
||||
expect(allowed.allowAny).toBe(false);
|
||||
expect(allowed.allowedKeys.has(modelKey("openai", "gpt-4"))).toBe(true);
|
||||
expect(allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5"))).toBe(true);
|
||||
});
|
||||
|
||||
describe("resolveModelRefFromString", () => {
|
||||
it("should resolve from string with alias", () => {
|
||||
const index = {
|
||||
byAlias: new Map([
|
||||
["fast", { alias: "fast", ref: { provider: "anthropic", model: "sonnet" } }],
|
||||
]),
|
||||
byKey: new Map(),
|
||||
};
|
||||
it("includes the default model when no allowlist is set", () => {
|
||||
const cfg = {
|
||||
agents: { defaults: {} },
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: "fast",
|
||||
defaultProvider: "openai",
|
||||
aliasIndex: index,
|
||||
});
|
||||
|
||||
expect(resolved?.ref).toEqual({ provider: "anthropic", model: "sonnet" });
|
||||
expect(resolved?.alias).toBe("fast");
|
||||
const allowed = buildAllowedModelSet({
|
||||
cfg,
|
||||
catalog,
|
||||
defaultProvider: "claude-cli",
|
||||
defaultModel: "opus-4.5",
|
||||
});
|
||||
|
||||
it("should resolve direct ref if no alias match", () => {
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: "openai/gpt-4",
|
||||
defaultProvider: "anthropic",
|
||||
});
|
||||
expect(resolved?.ref).toEqual({ provider: "openai", model: "gpt-4" });
|
||||
});
|
||||
expect(allowed.allowAny).toBe(true);
|
||||
expect(allowed.allowedKeys.has(modelKey("openai", "gpt-4"))).toBe(true);
|
||||
expect(allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5"))).toBe(true);
|
||||
});
|
||||
|
||||
describe("resolveConfiguredModelRef", () => {
|
||||
it("should fall back to anthropic and warn if provider is missing for non-alias", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const cfg: Partial<ClawdbotConfig> = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "claude-3-5-sonnet",
|
||||
it("allows explicit custom providers from models.providers", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"moonshot/kimi-k2-0905-preview": { alias: "kimi" },
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
moonshot: {
|
||||
baseUrl: "https://api.moonshot.ai/v1",
|
||||
apiKey: "x",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "kimi-k2-0905-preview", name: "Kimi" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const result = resolveConfiguredModelRef({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
defaultProvider: "google",
|
||||
defaultModel: "gemini-pro",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ provider: "anthropic", model: "claude-3-5-sonnet" });
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Falling back to "anthropic/claude-3-5-sonnet"'),
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
const allowed = buildAllowedModelSet({
|
||||
cfg,
|
||||
catalog: [],
|
||||
defaultProvider: "anthropic",
|
||||
defaultModel: "claude-opus-4-5",
|
||||
});
|
||||
|
||||
it("should use default provider/model if config is empty", () => {
|
||||
const cfg: Partial<ClawdbotConfig> = {};
|
||||
const result = resolveConfiguredModelRef({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
defaultProvider: "openai",
|
||||
defaultModel: "gpt-4",
|
||||
expect(allowed.allowAny).toBe(false);
|
||||
expect(allowed.allowedKeys.has(modelKey("moonshot", "kimi-k2-0905-preview"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseModelRef", () => {
|
||||
it("normalizes anthropic/opus-4.5 to claude-opus-4-5", () => {
|
||||
const ref = parseModelRef("anthropic/opus-4.5", "anthropic");
|
||||
expect(ref).toEqual({
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes google gemini 3 models to preview ids", () => {
|
||||
expect(parseModelRef("google/gemini-3-pro", "anthropic")).toEqual({
|
||||
provider: "google",
|
||||
model: "gemini-3-pro-preview",
|
||||
});
|
||||
expect(parseModelRef("google/gemini-3-flash", "anthropic")).toEqual({
|
||||
provider: "google",
|
||||
model: "gemini-3-flash-preview",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes default-provider google models", () => {
|
||||
expect(parseModelRef("gemini-3-pro", "google")).toEqual({
|
||||
provider: "google",
|
||||
model: "gemini-3-pro-preview",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveHooksGmailModel", () => {
|
||||
it("returns null when hooks.gmail.model is not set", () => {
|
||||
const cfg = {} satisfies ClawdbotConfig;
|
||||
const result = resolveHooksGmailModel({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when hooks.gmail.model is empty", () => {
|
||||
const cfg = {
|
||||
hooks: { gmail: { model: "" } },
|
||||
} satisfies ClawdbotConfig;
|
||||
const result = resolveHooksGmailModel({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("parses provider/model from hooks.gmail.model", () => {
|
||||
const cfg = {
|
||||
hooks: { gmail: { model: "openrouter/meta-llama/llama-3.3-70b:free" } },
|
||||
} satisfies ClawdbotConfig;
|
||||
const result = resolveHooksGmailModel({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
provider: "openrouter",
|
||||
model: "meta-llama/llama-3.3-70b:free",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves alias from agent.models", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-1": { alias: "Sonnet" },
|
||||
},
|
||||
},
|
||||
},
|
||||
hooks: { gmail: { model: "Sonnet" } },
|
||||
} satisfies ClawdbotConfig;
|
||||
const result = resolveHooksGmailModel({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses default provider when model omits provider", () => {
|
||||
const cfg = {
|
||||
hooks: { gmail: { model: "claude-haiku-3-5" } },
|
||||
} satisfies ClawdbotConfig;
|
||||
const result = resolveHooksGmailModel({
|
||||
cfg,
|
||||
defaultProvider: "anthropic",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
provider: "anthropic",
|
||||
model: "claude-haiku-3-5",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAllowedModelRef", () => {
|
||||
it("resolves aliases when allowed", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-1": { alias: "Sonnet" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies ClawdbotConfig;
|
||||
const resolved = resolveAllowedModelRef({
|
||||
cfg,
|
||||
catalog: [
|
||||
{
|
||||
provider: "anthropic",
|
||||
id: "claude-sonnet-4-1",
|
||||
name: "Sonnet",
|
||||
},
|
||||
],
|
||||
raw: "Sonnet",
|
||||
defaultProvider: "anthropic",
|
||||
defaultModel: "claude-opus-4-5",
|
||||
});
|
||||
expect("error" in resolved).toBe(false);
|
||||
if ("ref" in resolved) {
|
||||
expect(resolved.ref).toEqual({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-1",
|
||||
});
|
||||
expect(result).toEqual({ provider: "openai", model: "gpt-4" });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects disallowed models", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"openai/gpt-4": { alias: "GPT4" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies ClawdbotConfig;
|
||||
const resolved = resolveAllowedModelRef({
|
||||
cfg,
|
||||
catalog: [
|
||||
{ provider: "openai", id: "gpt-4", name: "GPT-4" },
|
||||
{ provider: "anthropic", id: "claude-sonnet-4-1", name: "Sonnet" },
|
||||
],
|
||||
raw: "anthropic/claude-sonnet-4-1",
|
||||
defaultProvider: "openai",
|
||||
defaultModel: "gpt-4",
|
||||
});
|
||||
expect(resolved).toEqual({
|
||||
error: "model not allowed: anthropic/claude-sonnet-4-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -131,24 +131,14 @@ export function resolveConfiguredModelRef(params: {
|
||||
cfg: params.cfg,
|
||||
defaultProvider: params.defaultProvider,
|
||||
});
|
||||
if (!trimmed.includes("/")) {
|
||||
const aliasKey = normalizeAliasKey(trimmed);
|
||||
const aliasMatch = aliasIndex.byAlias.get(aliasKey);
|
||||
if (aliasMatch) return aliasMatch.ref;
|
||||
|
||||
// Default to anthropic if no provider is specified, but warn as this is deprecated.
|
||||
console.warn(
|
||||
`[clawdbot] Model "${trimmed}" specified without provider. Falling back to "anthropic/${trimmed}". Please use "anthropic/${trimmed}" in your config.`,
|
||||
);
|
||||
return { provider: "anthropic", model: trimmed };
|
||||
}
|
||||
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: trimmed,
|
||||
defaultProvider: params.defaultProvider,
|
||||
aliasIndex,
|
||||
});
|
||||
if (resolved) return resolved.ref;
|
||||
// TODO(steipete): drop this fallback once provider-less agents.defaults.model is fully deprecated.
|
||||
return { provider: "anthropic", model: trimmed };
|
||||
}
|
||||
return { provider: params.defaultProvider, model: params.defaultModel };
|
||||
}
|
||||
|
||||
@@ -599,7 +599,7 @@ export async function runEmbeddedPiAgent(
|
||||
verboseLevel: params.verboseLevel,
|
||||
reasoningLevel: params.reasoningLevel,
|
||||
toolResultFormat: resolvedToolResultFormat,
|
||||
inlineToolResultsAllowed: false,
|
||||
inlineToolResultsAllowed: !params.onPartialReply && !params.onToolResult,
|
||||
});
|
||||
|
||||
log.debug(
|
||||
|
||||
@@ -313,7 +313,6 @@ export function createClawdbotCodingTools(options?: {
|
||||
replyToMode: options?.replyToMode,
|
||||
hasRepliedRef: options?.hasRepliedRef,
|
||||
modelHasVision: options?.modelHasVision,
|
||||
requesterAgentIdOverride: agentId,
|
||||
}),
|
||||
];
|
||||
const coreToolNames = new Set(
|
||||
|
||||
@@ -19,11 +19,7 @@ type AgentListEntry = {
|
||||
configured: boolean;
|
||||
};
|
||||
|
||||
export function createAgentsListTool(opts?: {
|
||||
agentSessionKey?: string;
|
||||
/** Explicit agent ID override for cron/hook sessions. */
|
||||
requesterAgentIdOverride?: string;
|
||||
}): AnyAgentTool {
|
||||
export function createAgentsListTool(opts?: { agentSessionKey?: string }): AnyAgentTool {
|
||||
return {
|
||||
label: "Agents",
|
||||
name: "agents_list",
|
||||
@@ -41,9 +37,7 @@ export function createAgentsListTool(opts?: {
|
||||
})
|
||||
: alias;
|
||||
const requesterAgentId = normalizeAgentId(
|
||||
opts?.requesterAgentIdOverride ??
|
||||
parseAgentSessionKey(requesterInternalKey)?.agentId ??
|
||||
DEFAULT_AGENT_ID,
|
||||
parseAgentSessionKey(requesterInternalKey)?.agentId ?? DEFAULT_AGENT_ID,
|
||||
);
|
||||
|
||||
const allowAgents = resolveAgentConfig(cfg, requesterAgentId)?.subagents?.allowAgents ?? [];
|
||||
|
||||
@@ -67,8 +67,6 @@ export function createSessionsSpawnTool(opts?: {
|
||||
agentGroupChannel?: string | null;
|
||||
agentGroupSpace?: string | null;
|
||||
sandboxed?: boolean;
|
||||
/** Explicit agent ID override for cron/hook sessions where session key parsing may not work. */
|
||||
requesterAgentIdOverride?: string;
|
||||
}): AnyAgentTool {
|
||||
return {
|
||||
label: "Sessions",
|
||||
@@ -131,7 +129,7 @@ export function createSessionsSpawnTool(opts?: {
|
||||
});
|
||||
|
||||
const requesterAgentId = normalizeAgentId(
|
||||
opts?.requesterAgentIdOverride ?? parseAgentSessionKey(requesterInternalKey)?.agentId,
|
||||
parseAgentSessionKey(requesterInternalKey)?.agentId,
|
||||
);
|
||||
const targetAgentId = requestedAgentId
|
||||
? normalizeAgentId(requestedAgentId)
|
||||
|
||||
@@ -310,16 +310,10 @@ describe("chunkTextWithMode", () => {
|
||||
expect(chunks).toEqual(["Line one\nLine two"]);
|
||||
});
|
||||
|
||||
it("uses paragraph-based chunking for newline mode", () => {
|
||||
it("uses newline-based chunking for newline mode", () => {
|
||||
const text = "Line one\nLine two";
|
||||
const chunks = chunkTextWithMode(text, 1000, "newline");
|
||||
expect(chunks).toEqual(["Line one\nLine two"]);
|
||||
});
|
||||
|
||||
it("splits on blank lines for newline mode", () => {
|
||||
const text = "Para one\n\nPara two";
|
||||
const chunks = chunkTextWithMode(text, 1000, "newline");
|
||||
expect(chunks).toEqual(["Para one", "Para two"]);
|
||||
expect(chunks).toEqual(["Line one", "Line two"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -329,35 +323,17 @@ describe("chunkMarkdownTextWithMode", () => {
|
||||
expect(chunkMarkdownTextWithMode(text, 1000, "length")).toEqual(chunkMarkdownText(text, 1000));
|
||||
});
|
||||
|
||||
it("uses paragraph-based chunking for newline mode", () => {
|
||||
it("uses newline-based chunking for newline mode", () => {
|
||||
const text = "Line one\nLine two";
|
||||
expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual(["Line one\nLine two"]);
|
||||
expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual(["Line one", "Line two"]);
|
||||
});
|
||||
|
||||
it("splits on blank lines for newline mode", () => {
|
||||
const text = "Para one\n\nPara two";
|
||||
expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual(["Para one", "Para two"]);
|
||||
});
|
||||
|
||||
it("does not split single-newline code fences in newline mode", () => {
|
||||
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([text]);
|
||||
});
|
||||
|
||||
it("defers long markdown paragraphs to markdown chunking in newline mode", () => {
|
||||
const text = `\`\`\`js\n${"const a = 1;\n".repeat(20)}\`\`\``;
|
||||
expect(chunkMarkdownTextWithMode(text, 40, "newline")).toEqual(chunkMarkdownText(text, 40));
|
||||
});
|
||||
|
||||
it("does not split on blank lines inside a fenced code block", () => {
|
||||
const text = "```python\ndef my_function():\n x = 1\n\n y = 2\n return x + y\n```";
|
||||
expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual([text]);
|
||||
});
|
||||
|
||||
it("splits on blank lines between a code fence and following paragraph", () => {
|
||||
const fence = "```python\ndef my_function():\n x = 1\n\n y = 2\n return x + y\n```";
|
||||
const text = `${fence}\n\nAfter`;
|
||||
expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual([fence, "After"]);
|
||||
expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual([
|
||||
"```js\nconst a = 1;\nconst b = 2;\n```",
|
||||
"After",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+27
-78
@@ -13,9 +13,7 @@ export type TextChunkProvider = ChannelId | typeof INTERNAL_MESSAGE_CHANNEL;
|
||||
/**
|
||||
* Chunking mode for outbound messages:
|
||||
* - "length": Split only when exceeding textChunkLimit (default)
|
||||
* - "newline": Prefer breaking on "soft" boundaries. Historically this split on every
|
||||
* newline; now it only breaks on paragraph boundaries (blank lines) unless the text
|
||||
* exceeds the length limit.
|
||||
* - "newline": Split on every newline, with fallback to length-based for long lines
|
||||
*/
|
||||
export type ChunkMode = "length" | "newline";
|
||||
|
||||
@@ -166,93 +164,44 @@ export function chunkByNewline(
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split text into chunks on paragraph boundaries (blank lines), preserving lists and
|
||||
* single-newline line wraps inside paragraphs.
|
||||
*
|
||||
* - Only breaks at paragraph separators ("\n\n" or more, allowing whitespace on blank lines)
|
||||
* - Packs multiple paragraphs into a single chunk up to `limit`
|
||||
* - Falls back to length-based splitting when a single paragraph exceeds `limit`
|
||||
* (unless `splitLongParagraphs` is disabled)
|
||||
*/
|
||||
export function chunkByParagraph(
|
||||
text: string,
|
||||
limit: number,
|
||||
opts?: { splitLongParagraphs?: boolean },
|
||||
): string[] {
|
||||
if (!text) return [];
|
||||
if (limit <= 0) return [text];
|
||||
const splitLongParagraphs = opts?.splitLongParagraphs !== false;
|
||||
|
||||
// Normalize to \n so blank line detection is consistent.
|
||||
const normalized = text.replace(/\r\n?/g, "\n");
|
||||
|
||||
// Fast-path: if there are no blank-line paragraph separators, do not split.
|
||||
// (We *do not* early-return based on `limit` — newline mode is about paragraph
|
||||
// boundaries, not only exceeding a length limit.)
|
||||
const paragraphRe = /\n[\t ]*\n+/;
|
||||
if (!paragraphRe.test(normalized)) {
|
||||
if (normalized.length <= limit) return [normalized];
|
||||
if (!splitLongParagraphs) return [normalized];
|
||||
return chunkText(normalized, limit);
|
||||
}
|
||||
|
||||
const spans = parseFenceSpans(normalized);
|
||||
|
||||
const parts: string[] = [];
|
||||
const re = /\n[\t ]*\n+/g; // paragraph break: blank line(s), allowing whitespace
|
||||
let lastIndex = 0;
|
||||
for (const match of normalized.matchAll(re)) {
|
||||
const idx = match.index ?? 0;
|
||||
|
||||
// Do not split on blank lines that occur inside fenced code blocks.
|
||||
if (!isSafeFenceBreak(spans, idx)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
parts.push(normalized.slice(lastIndex, idx));
|
||||
lastIndex = idx + match[0].length;
|
||||
}
|
||||
parts.push(normalized.slice(lastIndex));
|
||||
|
||||
const chunks: string[] = [];
|
||||
for (const part of parts) {
|
||||
const paragraph = part.replace(/\s+$/g, "");
|
||||
if (!paragraph.trim()) continue;
|
||||
if (paragraph.length <= limit) {
|
||||
chunks.push(paragraph);
|
||||
} else if (!splitLongParagraphs) {
|
||||
chunks.push(paragraph);
|
||||
} else {
|
||||
chunks.push(...chunkText(paragraph, limit));
|
||||
}
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified chunking function that dispatches based on mode.
|
||||
*/
|
||||
export function chunkTextWithMode(text: string, limit: number, mode: ChunkMode): string[] {
|
||||
if (mode === "newline") {
|
||||
return chunkByParagraph(text, limit);
|
||||
const chunks: string[] = [];
|
||||
const lineChunks = chunkByNewline(text, limit, { splitLongLines: false });
|
||||
for (const line of lineChunks) {
|
||||
const nested = chunkText(line, limit);
|
||||
if (!nested.length && line) {
|
||||
chunks.push(line);
|
||||
continue;
|
||||
}
|
||||
chunks.push(...nested);
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
return chunkText(text, limit);
|
||||
}
|
||||
|
||||
export function chunkMarkdownTextWithMode(text: string, limit: number, mode: ChunkMode): string[] {
|
||||
if (mode === "newline") {
|
||||
// Paragraph chunking is fence-safe because we never split at arbitrary indices.
|
||||
// If a paragraph must be split by length, defer to the markdown-aware chunker.
|
||||
const paragraphChunks = chunkByParagraph(text, limit, { splitLongParagraphs: false });
|
||||
const out: string[] = [];
|
||||
for (const chunk of paragraphChunks) {
|
||||
const nested = chunkMarkdownText(chunk, limit);
|
||||
if (!nested.length && chunk) out.push(chunk);
|
||||
else out.push(...nested);
|
||||
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 out;
|
||||
return chunks;
|
||||
}
|
||||
return chunkMarkdownText(text, limit);
|
||||
}
|
||||
|
||||
@@ -178,13 +178,6 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
||||
textAlias: "/context",
|
||||
acceptsArgs: true,
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "tts",
|
||||
nativeName: "tts",
|
||||
description: "Configure text-to-speech.",
|
||||
textAlias: "/tts",
|
||||
acceptsArgs: true,
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "whoami",
|
||||
nativeName: "whoami",
|
||||
@@ -286,6 +279,27 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
||||
],
|
||||
argsMenu: "auto",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "tts",
|
||||
nativeName: "tts",
|
||||
description: "Control text-to-speech (TTS).",
|
||||
textAlias: "/tts",
|
||||
args: [
|
||||
{
|
||||
name: "action",
|
||||
description: "on | off | status | provider | limit | summary | audio | help",
|
||||
type: "string",
|
||||
choices: ["on", "off", "status", "provider", "limit", "summary", "audio", "help"],
|
||||
},
|
||||
{
|
||||
name: "value",
|
||||
description: "Provider, limit, or text",
|
||||
type: "string",
|
||||
captureRemaining: true,
|
||||
},
|
||||
],
|
||||
argsMenu: "auto",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "stop",
|
||||
nativeName: "stop",
|
||||
|
||||
@@ -32,8 +32,6 @@ export function isHeartbeatContentEffectivelyEmpty(content: string | undefined |
|
||||
// This intentionally does NOT skip lines like "#TODO" or "#hashtag" which might be content
|
||||
// (Those aren't valid markdown headers - ATX headers require space after #)
|
||||
if (/^#+(\s|$)/.test(trimmed)) continue;
|
||||
// Skip empty markdown list items like "- [ ]" or "* [ ]" or just "- "
|
||||
if (/^[-*+]\s*(\[[\sXx]?\]\s*)?$/.test(trimmed)) continue;
|
||||
// Found a non-empty, non-comment line - there's actionable content
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
INTERNAL_MESSAGE_CHANNEL,
|
||||
listDeliverableMessageChannels,
|
||||
} from "../../utils/message-channel.js";
|
||||
import { resolveTextChunkLimit, type TextChunkProvider } from "../chunk.js";
|
||||
import { resolveChunkMode, resolveTextChunkLimit, type TextChunkProvider } from "../chunk.js";
|
||||
|
||||
const DEFAULT_BLOCK_STREAM_MIN = 800;
|
||||
const DEFAULT_BLOCK_STREAM_MAX = 1200;
|
||||
@@ -69,11 +69,15 @@ export function resolveBlockStreamingChunking(
|
||||
});
|
||||
const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk;
|
||||
|
||||
// Note: chunkMode="newline" used to imply splitting on each newline, but outbound
|
||||
// delivery now treats it as paragraph-aware chunking (only split on blank lines).
|
||||
// Block streaming should follow the same rule, so we do NOT special-case newline
|
||||
// mode here.
|
||||
// (chunkMode no longer alters block streaming behavior)
|
||||
// When chunkMode is "newline", use newline-based streaming
|
||||
const channelChunkMode = resolveChunkMode(cfg, providerKey, accountId);
|
||||
if (channelChunkMode === "newline") {
|
||||
// For newline mode: use very low minChars to flush quickly on newlines
|
||||
const minChars = Math.max(1, Math.floor(chunkCfg?.minChars ?? 1));
|
||||
const maxRequested = Math.max(1, Math.floor(chunkCfg?.maxChars ?? textLimit));
|
||||
const maxChars = Math.max(1, Math.min(maxRequested, textLimit));
|
||||
return { minChars, maxChars, breakPreference: "newline" };
|
||||
}
|
||||
|
||||
const maxRequested = Math.max(1, Math.floor(chunkCfg?.maxChars ?? DEFAULT_BLOCK_STREAM_MAX));
|
||||
const maxChars = Math.max(1, Math.min(maxRequested, textLimit));
|
||||
@@ -99,8 +103,11 @@ export function resolveBlockStreamingCoalescing(
|
||||
): BlockStreamingCoalescing | undefined {
|
||||
const providerKey = normalizeChunkProvider(provider);
|
||||
|
||||
// Note: chunkMode="newline" is paragraph-aware in outbound delivery (blank-line splits),
|
||||
// so block streaming should not disable coalescing or flush per single newline.
|
||||
// When chunkMode is "newline", disable coalescing to send each line immediately
|
||||
const channelChunkMode = resolveChunkMode(cfg, providerKey, accountId);
|
||||
if (channelChunkMode === "newline") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const providerId = providerKey ? normalizeChannelId(providerKey) : null;
|
||||
const providerChunkLimit = providerId
|
||||
|
||||
@@ -15,12 +15,10 @@ import type { CommandHandler, CommandHandlerResult } from "./commands-types.js";
|
||||
*/
|
||||
export const handlePluginCommand: CommandHandler = async (
|
||||
params,
|
||||
allowTextCommands,
|
||||
_allowTextCommands,
|
||||
): Promise<CommandHandlerResult | null> => {
|
||||
const { command, cfg } = params;
|
||||
|
||||
if (!allowTextCommands) return null;
|
||||
|
||||
// Try to match a plugin command
|
||||
const match = matchPluginCommand(command.commandBodyNormalized);
|
||||
if (!match) return null;
|
||||
@@ -38,6 +36,6 @@ export const handlePluginCommand: CommandHandler = async (
|
||||
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: result,
|
||||
reply: { text: result.text },
|
||||
};
|
||||
};
|
||||
@@ -10,26 +10,11 @@ import {
|
||||
} from "../../agents/subagent-registry.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import * as internalHooks from "../../hooks/internal-hooks.js";
|
||||
import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import { resetBashChatCommandForTests } from "./bash-command.js";
|
||||
import { buildCommandContext, handleCommands } from "./commands.js";
|
||||
import { parseInlineDirectives } from "./directive-handling.js";
|
||||
|
||||
// Avoid expensive workspace scans during /context tests.
|
||||
vi.mock("./commands-context-report.js", () => ({
|
||||
buildContextReply: async (params: { command: { commandBodyNormalized: string } }) => {
|
||||
const normalized = params.command.commandBodyNormalized;
|
||||
if (normalized === "/context list") {
|
||||
return { text: "Injected workspace files:\n- AGENTS.md" };
|
||||
}
|
||||
if (normalized === "/context detail") {
|
||||
return { text: "Context breakdown (detailed)\nTop tools (schema size):" };
|
||||
}
|
||||
return { text: "/context\n- /context list\nInline shortcut" };
|
||||
},
|
||||
}));
|
||||
|
||||
let testWorkspaceDir = os.tmpdir();
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -158,29 +143,6 @@ describe("handleCommands bash alias", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleCommands plugin commands", () => {
|
||||
it("dispatches registered plugin commands", async () => {
|
||||
clearPluginCommands();
|
||||
const result = registerPluginCommand("test-plugin", {
|
||||
name: "card",
|
||||
description: "Test card",
|
||||
handler: async () => ({ text: "from plugin" }),
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as ClawdbotConfig;
|
||||
const params = buildParams("/card", cfg);
|
||||
const commandResult = await handleCommands(params);
|
||||
|
||||
expect(commandResult.shouldContinue).toBe(false);
|
||||
expect(commandResult.reply?.text).toBe("from plugin");
|
||||
clearPluginCommands();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleCommands identity", () => {
|
||||
it("returns sender details for /whoami", async () => {
|
||||
const cfg = {
|
||||
|
||||
@@ -138,38 +138,6 @@ describe("dispatchReplyFromConfig", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not provide onToolResult when routing cross-provider", async () => {
|
||||
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
||||
handled: false,
|
||||
aborted: false,
|
||||
});
|
||||
mocks.routeReply.mockClear();
|
||||
const cfg = {} as ClawdbotConfig;
|
||||
const dispatcher = createDispatcher();
|
||||
const ctx = buildTestCtx({
|
||||
Provider: "slack",
|
||||
OriginatingChannel: "telegram",
|
||||
OriginatingTo: "telegram:999",
|
||||
});
|
||||
|
||||
const replyResolver = async (
|
||||
_ctx: MsgContext,
|
||||
opts: GetReplyOptions | undefined,
|
||||
_cfg: ClawdbotConfig,
|
||||
) => {
|
||||
expect(opts?.onToolResult).toBeUndefined();
|
||||
return { text: "hi" } satisfies ReplyPayload;
|
||||
};
|
||||
|
||||
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
||||
|
||||
expect(mocks.routeReply).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({ text: "hi" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fast-aborts without calling the reply resolver", async () => {
|
||||
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
||||
handled: true,
|
||||
|
||||
@@ -206,7 +206,6 @@ export async function dispatchReplyFromConfig(params: {
|
||||
const sendPayloadAsync = async (
|
||||
payload: ReplyPayload,
|
||||
abortSignal?: AbortSignal,
|
||||
mirror?: boolean,
|
||||
): Promise<void> => {
|
||||
// TypeScript doesn't narrow these from the shouldRouteToOriginating check,
|
||||
// but they're guaranteed non-null when this function is called.
|
||||
@@ -221,7 +220,6 @@ export async function dispatchReplyFromConfig(params: {
|
||||
threadId: ctx.MessageThreadId,
|
||||
cfg,
|
||||
abortSignal,
|
||||
mirror,
|
||||
});
|
||||
if (!result.ok) {
|
||||
logVerbose(`dispatch-from-config: route-reply failed: ${result.error ?? "unknown error"}`);
|
||||
@@ -270,6 +268,24 @@ export async function dispatchReplyFromConfig(params: {
|
||||
ctx,
|
||||
{
|
||||
...params.replyOptions,
|
||||
onToolResult: (payload: ReplyPayload) => {
|
||||
const run = async () => {
|
||||
const ttsPayload = await maybeApplyTtsToPayload({
|
||||
payload,
|
||||
cfg,
|
||||
channel: ttsChannel,
|
||||
kind: "tool",
|
||||
inboundAudio,
|
||||
ttsAuto: sessionTtsAuto,
|
||||
});
|
||||
if (shouldRouteToOriginating) {
|
||||
await sendPayloadAsync(ttsPayload);
|
||||
} else {
|
||||
dispatcher.sendToolResult(ttsPayload);
|
||||
}
|
||||
};
|
||||
return run();
|
||||
},
|
||||
onBlockReply: (payload: ReplyPayload, context) => {
|
||||
const run = async () => {
|
||||
const ttsPayload = await maybeApplyTtsToPayload({
|
||||
@@ -281,7 +297,7 @@ export async function dispatchReplyFromConfig(params: {
|
||||
ttsAuto: sessionTtsAuto,
|
||||
});
|
||||
if (shouldRouteToOriginating) {
|
||||
await sendPayloadAsync(ttsPayload, context?.abortSignal, false);
|
||||
await sendPayloadAsync(ttsPayload, context?.abortSignal);
|
||||
} else {
|
||||
dispatcher.sendBlockReply(ttsPayload);
|
||||
}
|
||||
|
||||
@@ -1,377 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseLineDirectives, hasLineDirectives } from "./line-directives.js";
|
||||
|
||||
const getLineData = (result: ReturnType<typeof parseLineDirectives>) =>
|
||||
(result.channelData?.line as Record<string, unknown> | undefined) ?? {};
|
||||
|
||||
describe("hasLineDirectives", () => {
|
||||
it("detects quick_replies directive", () => {
|
||||
expect(hasLineDirectives("Here are options [[quick_replies: A, B, C]]")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects location directive", () => {
|
||||
expect(hasLineDirectives("[[location: Place | Address | 35.6 | 139.7]]")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects confirm directive", () => {
|
||||
expect(hasLineDirectives("[[confirm: Continue? | Yes | No]]")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects buttons directive", () => {
|
||||
expect(hasLineDirectives("[[buttons: Menu | Choose | Opt1:data1, Opt2:data2]]")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for regular text", () => {
|
||||
expect(hasLineDirectives("Just regular text")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for similar but invalid patterns", () => {
|
||||
expect(hasLineDirectives("[[not_a_directive: something]]")).toBe(false);
|
||||
});
|
||||
|
||||
it("detects media_player directive", () => {
|
||||
expect(hasLineDirectives("[[media_player: Song | Artist | Speaker]]")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects event directive", () => {
|
||||
expect(hasLineDirectives("[[event: Meeting | Jan 24 | 2pm]]")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects agenda directive", () => {
|
||||
expect(hasLineDirectives("[[agenda: Today | Meeting:9am, Lunch:12pm]]")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects device directive", () => {
|
||||
expect(hasLineDirectives("[[device: TV | Room]]")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects appletv_remote directive", () => {
|
||||
expect(hasLineDirectives("[[appletv_remote: Apple TV | Playing]]")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseLineDirectives", () => {
|
||||
describe("quick_replies", () => {
|
||||
it("parses quick_replies and removes from text", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "Choose one:\n[[quick_replies: Option A, Option B, Option C]]",
|
||||
});
|
||||
|
||||
expect(getLineData(result).quickReplies).toEqual(["Option A", "Option B", "Option C"]);
|
||||
expect(result.text).toBe("Choose one:");
|
||||
});
|
||||
|
||||
it("handles quick_replies in middle of text", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "Before [[quick_replies: A, B]] After",
|
||||
});
|
||||
|
||||
expect(getLineData(result).quickReplies).toEqual(["A", "B"]);
|
||||
expect(result.text).toBe("Before After");
|
||||
});
|
||||
|
||||
it("merges with existing quickReplies", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "Text [[quick_replies: C, D]]",
|
||||
channelData: { line: { quickReplies: ["A", "B"] } },
|
||||
});
|
||||
|
||||
expect(getLineData(result).quickReplies).toEqual(["A", "B", "C", "D"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("location", () => {
|
||||
it("parses location with all fields", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "Here's the location:\n[[location: Tokyo Station | Tokyo, Japan | 35.6812 | 139.7671]]",
|
||||
});
|
||||
|
||||
expect(getLineData(result).location).toEqual({
|
||||
title: "Tokyo Station",
|
||||
address: "Tokyo, Japan",
|
||||
latitude: 35.6812,
|
||||
longitude: 139.7671,
|
||||
});
|
||||
expect(result.text).toBe("Here's the location:");
|
||||
});
|
||||
|
||||
it("ignores invalid coordinates", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[location: Place | Address | invalid | 139.7]]",
|
||||
});
|
||||
|
||||
expect(getLineData(result).location).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not override existing location", () => {
|
||||
const existing = { title: "Existing", address: "Addr", latitude: 1, longitude: 2 };
|
||||
const result = parseLineDirectives({
|
||||
text: "[[location: New | New Addr | 35.6 | 139.7]]",
|
||||
channelData: { line: { location: existing } },
|
||||
});
|
||||
|
||||
expect(getLineData(result).location).toEqual(existing);
|
||||
});
|
||||
});
|
||||
|
||||
describe("confirm", () => {
|
||||
it("parses simple confirm", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[confirm: Delete this item? | Yes | No]]",
|
||||
});
|
||||
|
||||
expect(getLineData(result).templateMessage).toEqual({
|
||||
type: "confirm",
|
||||
text: "Delete this item?",
|
||||
confirmLabel: "Yes",
|
||||
confirmData: "yes",
|
||||
cancelLabel: "No",
|
||||
cancelData: "no",
|
||||
altText: "Delete this item?",
|
||||
});
|
||||
// Text is undefined when directive consumes entire text
|
||||
expect(result.text).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses confirm with custom data", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[confirm: Proceed? | OK:action=confirm | Cancel:action=cancel]]",
|
||||
});
|
||||
|
||||
expect(getLineData(result).templateMessage).toEqual({
|
||||
type: "confirm",
|
||||
text: "Proceed?",
|
||||
confirmLabel: "OK",
|
||||
confirmData: "action=confirm",
|
||||
cancelLabel: "Cancel",
|
||||
cancelData: "action=cancel",
|
||||
altText: "Proceed?",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buttons", () => {
|
||||
it("parses buttons with message actions", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[buttons: Menu | Select an option | Help:/help, Status:/status]]",
|
||||
});
|
||||
|
||||
expect(getLineData(result).templateMessage).toEqual({
|
||||
type: "buttons",
|
||||
title: "Menu",
|
||||
text: "Select an option",
|
||||
actions: [
|
||||
{ type: "message", label: "Help", data: "/help" },
|
||||
{ type: "message", label: "Status", data: "/status" },
|
||||
],
|
||||
altText: "Menu: Select an option",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses buttons with uri actions", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[buttons: Links | Visit us | Site:https://example.com]]",
|
||||
});
|
||||
|
||||
const templateMessage = getLineData(result).templateMessage as {
|
||||
type?: string;
|
||||
actions?: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(templateMessage?.type).toBe("buttons");
|
||||
if (templateMessage?.type === "buttons") {
|
||||
expect(templateMessage.actions?.[0]).toEqual({
|
||||
type: "uri",
|
||||
label: "Site",
|
||||
uri: "https://example.com",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("parses buttons with postback actions", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[buttons: Actions | Choose | Select:action=select&id=1]]",
|
||||
});
|
||||
|
||||
const templateMessage = getLineData(result).templateMessage as {
|
||||
type?: string;
|
||||
actions?: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(templateMessage?.type).toBe("buttons");
|
||||
if (templateMessage?.type === "buttons") {
|
||||
expect(templateMessage.actions?.[0]).toEqual({
|
||||
type: "postback",
|
||||
label: "Select",
|
||||
data: "action=select&id=1",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("limits to 4 actions", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[buttons: Menu | Text | A:a, B:b, C:c, D:d, E:e, F:f]]",
|
||||
});
|
||||
|
||||
const templateMessage = getLineData(result).templateMessage as {
|
||||
type?: string;
|
||||
actions?: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(templateMessage?.type).toBe("buttons");
|
||||
if (templateMessage?.type === "buttons") {
|
||||
expect(templateMessage.actions?.length).toBe(4);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("media_player", () => {
|
||||
it("parses media_player with all fields", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "Now playing:\n[[media_player: Bohemian Rhapsody | Queen | Speaker | https://example.com/album.jpg | playing]]",
|
||||
});
|
||||
|
||||
const flexMessage = getLineData(result).flexMessage as {
|
||||
altText?: string;
|
||||
contents?: { footer?: { contents?: unknown[] } };
|
||||
};
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toBe("🎵 Bohemian Rhapsody - Queen");
|
||||
const contents = flexMessage?.contents as { footer?: { contents?: unknown[] } };
|
||||
expect(contents.footer?.contents?.length).toBeGreaterThan(0);
|
||||
expect(result.text).toBe("Now playing:");
|
||||
});
|
||||
|
||||
it("parses media_player with minimal fields", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[media_player: Unknown Track]]",
|
||||
});
|
||||
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toBe("🎵 Unknown Track");
|
||||
});
|
||||
|
||||
it("handles paused status", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[media_player: Song | Artist | Player | | paused]]",
|
||||
});
|
||||
|
||||
const flexMessage = getLineData(result).flexMessage as {
|
||||
contents?: { body: { contents: unknown[] } };
|
||||
};
|
||||
expect(flexMessage).toBeDefined();
|
||||
const contents = flexMessage?.contents as { body: { contents: unknown[] } };
|
||||
expect(contents).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("event", () => {
|
||||
it("parses event with all fields", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[event: Team Meeting | January 24, 2026 | 2:00 PM - 3:00 PM | Conference Room A | Discuss Q1 roadmap]]",
|
||||
});
|
||||
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toBe("📅 Team Meeting - January 24, 2026 2:00 PM - 3:00 PM");
|
||||
});
|
||||
|
||||
it("parses event with minimal fields", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[event: Birthday Party | March 15]]",
|
||||
});
|
||||
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toBe("📅 Birthday Party - March 15");
|
||||
});
|
||||
});
|
||||
|
||||
describe("agenda", () => {
|
||||
it("parses agenda with multiple events", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[agenda: Today's Schedule | Team Meeting:9:00 AM, Lunch:12:00 PM, Review:3:00 PM]]",
|
||||
});
|
||||
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toBe("📋 Today's Schedule (3 events)");
|
||||
});
|
||||
|
||||
it("parses agenda with events without times", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[agenda: Tasks | Buy groceries, Call mom, Workout]]",
|
||||
});
|
||||
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toBe("📋 Tasks (3 events)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("device", () => {
|
||||
it("parses device with controls", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[device: TV | Streaming Box | Playing | Play/Pause:toggle, Menu:menu]]",
|
||||
});
|
||||
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toBe("📱 TV: Playing");
|
||||
});
|
||||
|
||||
it("parses device with minimal fields", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[device: Speaker]]",
|
||||
});
|
||||
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toBe("📱 Speaker");
|
||||
});
|
||||
});
|
||||
|
||||
describe("appletv_remote", () => {
|
||||
it("parses appletv_remote with status", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[appletv_remote: Apple TV | Playing]]",
|
||||
});
|
||||
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toContain("Apple TV");
|
||||
});
|
||||
|
||||
it("parses appletv_remote with minimal fields", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[appletv_remote: Apple TV]]",
|
||||
});
|
||||
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("combined directives", () => {
|
||||
it("handles text with no directives", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "Just plain text here",
|
||||
});
|
||||
|
||||
expect(result.text).toBe("Just plain text here");
|
||||
expect(getLineData(result).quickReplies).toBeUndefined();
|
||||
expect(getLineData(result).location).toBeUndefined();
|
||||
expect(getLineData(result).templateMessage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves other payload fields", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "Hello [[quick_replies: A, B]]",
|
||||
mediaUrl: "https://example.com/image.jpg",
|
||||
replyToId: "msg123",
|
||||
});
|
||||
|
||||
expect(result.mediaUrl).toBe("https://example.com/image.jpg");
|
||||
expect(result.replyToId).toBe("msg123");
|
||||
expect(getLineData(result).quickReplies).toEqual(["A", "B"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,336 +0,0 @@
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import type { LineChannelData } from "../../line/types.js";
|
||||
import {
|
||||
createMediaPlayerCard,
|
||||
createEventCard,
|
||||
createAgendaCard,
|
||||
createDeviceControlCard,
|
||||
createAppleTvRemoteCard,
|
||||
} from "../../line/flex-templates.js";
|
||||
|
||||
/**
|
||||
* Parse LINE-specific directives from text and extract them into ReplyPayload fields.
|
||||
*
|
||||
* Supported directives:
|
||||
* - [[quick_replies: option1, option2, option3]]
|
||||
* - [[location: title | address | latitude | longitude]]
|
||||
* - [[confirm: question | yes_label | no_label]]
|
||||
* - [[buttons: title | text | btn1:data1, btn2:data2]]
|
||||
* - [[media_player: title | artist | source | imageUrl | playing/paused]]
|
||||
* - [[event: title | date | time | location | description]]
|
||||
* - [[agenda: title | event1_title:event1_time, event2_title:event2_time, ...]]
|
||||
* - [[device: name | type | status | ctrl1:data1, ctrl2:data2]]
|
||||
* - [[appletv_remote: name | status]]
|
||||
*
|
||||
* Returns the modified payload with directives removed from text and fields populated.
|
||||
*/
|
||||
export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
|
||||
let text = payload.text;
|
||||
if (!text) return payload;
|
||||
|
||||
const result: ReplyPayload = { ...payload };
|
||||
const lineData: LineChannelData = {
|
||||
...(result.channelData?.line as LineChannelData | undefined),
|
||||
};
|
||||
const toSlug = (value: string): string =>
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "") || "device";
|
||||
const lineActionData = (action: string, extras?: Record<string, string>): string => {
|
||||
const base = [`line.action=${encodeURIComponent(action)}`];
|
||||
if (extras) {
|
||||
for (const [key, value] of Object.entries(extras)) {
|
||||
base.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
|
||||
}
|
||||
}
|
||||
return base.join("&");
|
||||
};
|
||||
|
||||
// Parse [[quick_replies: option1, option2, option3]]
|
||||
const quickRepliesMatch = text.match(/\[\[quick_replies:\s*([^\]]+)\]\]/i);
|
||||
if (quickRepliesMatch) {
|
||||
const options = quickRepliesMatch[1]
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (options.length > 0) {
|
||||
lineData.quickReplies = [...(lineData.quickReplies || []), ...options];
|
||||
}
|
||||
text = text.replace(quickRepliesMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Parse [[location: title | address | latitude | longitude]]
|
||||
const locationMatch = text.match(/\[\[location:\s*([^\]]+)\]\]/i);
|
||||
if (locationMatch && !lineData.location) {
|
||||
const parts = locationMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 4) {
|
||||
const [title, address, latStr, lonStr] = parts;
|
||||
const latitude = parseFloat(latStr);
|
||||
const longitude = parseFloat(lonStr);
|
||||
if (!isNaN(latitude) && !isNaN(longitude)) {
|
||||
lineData.location = {
|
||||
title: title || "Location",
|
||||
address: address || "",
|
||||
latitude,
|
||||
longitude,
|
||||
};
|
||||
}
|
||||
}
|
||||
text = text.replace(locationMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Parse [[confirm: question | yes_label | no_label]] or [[confirm: question | yes_label:yes_data | no_label:no_data]]
|
||||
const confirmMatch = text.match(/\[\[confirm:\s*([^\]]+)\]\]/i);
|
||||
if (confirmMatch && !lineData.templateMessage) {
|
||||
const parts = confirmMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 3) {
|
||||
const [question, yesPart, noPart] = parts;
|
||||
|
||||
// Parse yes_label:yes_data format
|
||||
const [yesLabel, yesData] = yesPart.includes(":")
|
||||
? yesPart.split(":").map((s) => s.trim())
|
||||
: [yesPart, yesPart.toLowerCase()];
|
||||
|
||||
const [noLabel, noData] = noPart.includes(":")
|
||||
? noPart.split(":").map((s) => s.trim())
|
||||
: [noPart, noPart.toLowerCase()];
|
||||
|
||||
lineData.templateMessage = {
|
||||
type: "confirm",
|
||||
text: question,
|
||||
confirmLabel: yesLabel,
|
||||
confirmData: yesData,
|
||||
cancelLabel: noLabel,
|
||||
cancelData: noData,
|
||||
altText: question,
|
||||
};
|
||||
}
|
||||
text = text.replace(confirmMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Parse [[buttons: title | text | btn1:data1, btn2:data2]]
|
||||
const buttonsMatch = text.match(/\[\[buttons:\s*([^\]]+)\]\]/i);
|
||||
if (buttonsMatch && !lineData.templateMessage) {
|
||||
const parts = buttonsMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 3) {
|
||||
const [title, bodyText, actionsStr] = parts;
|
||||
|
||||
const actions = actionsStr.split(",").map((actionStr) => {
|
||||
const trimmed = actionStr.trim();
|
||||
// Find first colon delimiter, ignoring URLs without a label.
|
||||
const colonIndex = (() => {
|
||||
const index = trimmed.indexOf(":");
|
||||
if (index === -1) return -1;
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower.startsWith("http://") || lower.startsWith("https://")) return -1;
|
||||
return index;
|
||||
})();
|
||||
|
||||
let label: string;
|
||||
let data: string;
|
||||
|
||||
if (colonIndex === -1) {
|
||||
label = trimmed;
|
||||
data = trimmed;
|
||||
} else {
|
||||
label = trimmed.slice(0, colonIndex).trim();
|
||||
data = trimmed.slice(colonIndex + 1).trim();
|
||||
}
|
||||
|
||||
// Detect action type
|
||||
if (data.startsWith("http://") || data.startsWith("https://")) {
|
||||
return { type: "uri" as const, label, uri: data };
|
||||
}
|
||||
if (data.includes("=")) {
|
||||
return { type: "postback" as const, label, data };
|
||||
}
|
||||
return { type: "message" as const, label, data: data || label };
|
||||
});
|
||||
|
||||
if (actions.length > 0) {
|
||||
lineData.templateMessage = {
|
||||
type: "buttons",
|
||||
title,
|
||||
text: bodyText,
|
||||
actions: actions.slice(0, 4), // LINE limit
|
||||
altText: `${title}: ${bodyText}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
text = text.replace(buttonsMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Parse [[media_player: title | artist | source | imageUrl | playing/paused]]
|
||||
const mediaPlayerMatch = text.match(/\[\[media_player:\s*([^\]]+)\]\]/i);
|
||||
if (mediaPlayerMatch && !lineData.flexMessage) {
|
||||
const parts = mediaPlayerMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 1) {
|
||||
const [title, artist, source, imageUrl, statusStr] = parts;
|
||||
const isPlaying = statusStr?.toLowerCase() === "playing";
|
||||
|
||||
// LINE requires HTTPS URLs for images - skip local/HTTP URLs
|
||||
const validImageUrl = imageUrl?.startsWith("https://") ? imageUrl : undefined;
|
||||
|
||||
const deviceKey = toSlug(source || title || "media");
|
||||
const card = createMediaPlayerCard({
|
||||
title: title || "Unknown Track",
|
||||
subtitle: artist || undefined,
|
||||
source: source || undefined,
|
||||
imageUrl: validImageUrl,
|
||||
isPlaying: statusStr ? isPlaying : undefined,
|
||||
controls: {
|
||||
previous: { data: lineActionData("previous", { "line.device": deviceKey }) },
|
||||
play: { data: lineActionData("play", { "line.device": deviceKey }) },
|
||||
pause: { data: lineActionData("pause", { "line.device": deviceKey }) },
|
||||
next: { data: lineActionData("next", { "line.device": deviceKey }) },
|
||||
},
|
||||
});
|
||||
|
||||
lineData.flexMessage = {
|
||||
altText: `🎵 ${title}${artist ? ` - ${artist}` : ""}`,
|
||||
contents: card,
|
||||
};
|
||||
}
|
||||
text = text.replace(mediaPlayerMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Parse [[event: title | date | time | location | description]]
|
||||
const eventMatch = text.match(/\[\[event:\s*([^\]]+)\]\]/i);
|
||||
if (eventMatch && !lineData.flexMessage) {
|
||||
const parts = eventMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 2) {
|
||||
const [title, date, time, location, description] = parts;
|
||||
|
||||
const card = createEventCard({
|
||||
title: title || "Event",
|
||||
date: date || "TBD",
|
||||
time: time || undefined,
|
||||
location: location || undefined,
|
||||
description: description || undefined,
|
||||
});
|
||||
|
||||
lineData.flexMessage = {
|
||||
altText: `📅 ${title} - ${date}${time ? ` ${time}` : ""}`,
|
||||
contents: card,
|
||||
};
|
||||
}
|
||||
text = text.replace(eventMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Parse [[appletv_remote: name | status]]
|
||||
const appleTvMatch = text.match(/\[\[appletv_remote:\s*([^\]]+)\]\]/i);
|
||||
if (appleTvMatch && !lineData.flexMessage) {
|
||||
const parts = appleTvMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 1) {
|
||||
const [deviceName, status] = parts;
|
||||
const deviceKey = toSlug(deviceName || "apple_tv");
|
||||
|
||||
const card = createAppleTvRemoteCard({
|
||||
deviceName: deviceName || "Apple TV",
|
||||
status: status || undefined,
|
||||
actionData: {
|
||||
up: lineActionData("up", { "line.device": deviceKey }),
|
||||
down: lineActionData("down", { "line.device": deviceKey }),
|
||||
left: lineActionData("left", { "line.device": deviceKey }),
|
||||
right: lineActionData("right", { "line.device": deviceKey }),
|
||||
select: lineActionData("select", { "line.device": deviceKey }),
|
||||
menu: lineActionData("menu", { "line.device": deviceKey }),
|
||||
home: lineActionData("home", { "line.device": deviceKey }),
|
||||
play: lineActionData("play", { "line.device": deviceKey }),
|
||||
pause: lineActionData("pause", { "line.device": deviceKey }),
|
||||
volumeUp: lineActionData("volume_up", { "line.device": deviceKey }),
|
||||
volumeDown: lineActionData("volume_down", { "line.device": deviceKey }),
|
||||
mute: lineActionData("mute", { "line.device": deviceKey }),
|
||||
},
|
||||
});
|
||||
|
||||
lineData.flexMessage = {
|
||||
altText: `📺 ${deviceName || "Apple TV"} Remote`,
|
||||
contents: card,
|
||||
};
|
||||
}
|
||||
text = text.replace(appleTvMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Parse [[agenda: title | event1_title:event1_time, event2_title:event2_time, ...]]
|
||||
const agendaMatch = text.match(/\[\[agenda:\s*([^\]]+)\]\]/i);
|
||||
if (agendaMatch && !lineData.flexMessage) {
|
||||
const parts = agendaMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 2) {
|
||||
const [title, eventsStr] = parts;
|
||||
|
||||
const events = eventsStr.split(",").map((eventStr) => {
|
||||
const trimmed = eventStr.trim();
|
||||
const colonIdx = trimmed.lastIndexOf(":");
|
||||
if (colonIdx > 0) {
|
||||
return {
|
||||
title: trimmed.slice(0, colonIdx).trim(),
|
||||
time: trimmed.slice(colonIdx + 1).trim(),
|
||||
};
|
||||
}
|
||||
return { title: trimmed };
|
||||
});
|
||||
|
||||
const card = createAgendaCard({
|
||||
title: title || "Agenda",
|
||||
events,
|
||||
});
|
||||
|
||||
lineData.flexMessage = {
|
||||
altText: `📋 ${title} (${events.length} events)`,
|
||||
contents: card,
|
||||
};
|
||||
}
|
||||
text = text.replace(agendaMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Parse [[device: name | type | status | ctrl1:data1, ctrl2:data2]]
|
||||
const deviceMatch = text.match(/\[\[device:\s*([^\]]+)\]\]/i);
|
||||
if (deviceMatch && !lineData.flexMessage) {
|
||||
const parts = deviceMatch[1].split("|").map((s) => s.trim());
|
||||
if (parts.length >= 1) {
|
||||
const [deviceName, deviceType, status, controlsStr] = parts;
|
||||
|
||||
const deviceKey = toSlug(deviceName || "device");
|
||||
const controls = controlsStr
|
||||
? controlsStr.split(",").map((ctrlStr) => {
|
||||
const [label, data] = ctrlStr.split(":").map((s) => s.trim());
|
||||
const action = data || label.toLowerCase().replace(/\s+/g, "_");
|
||||
return { label, data: lineActionData(action, { "line.device": deviceKey }) };
|
||||
})
|
||||
: [];
|
||||
|
||||
const card = createDeviceControlCard({
|
||||
deviceName: deviceName || "Device",
|
||||
deviceType: deviceType || undefined,
|
||||
status: status || undefined,
|
||||
controls,
|
||||
});
|
||||
|
||||
lineData.flexMessage = {
|
||||
altText: `📱 ${deviceName}${status ? `: ${status}` : ""}`,
|
||||
contents: card,
|
||||
};
|
||||
}
|
||||
text = text.replace(deviceMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Clean up multiple whitespace/newlines
|
||||
text = text.replace(/\n{3,}/g, "\n\n").trim();
|
||||
|
||||
result.text = text || undefined;
|
||||
if (Object.keys(lineData).length > 0) {
|
||||
result.channelData = { ...result.channelData, line: lineData };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text contains any LINE directives
|
||||
*/
|
||||
export function hasLineDirectives(text: string): boolean {
|
||||
return /\[\[(quick_replies|location|confirm|buttons|media_player|event|agenda|device|appletv_remote):/i.test(
|
||||
text,
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { normalizeReplyPayload } from "./normalize-reply.js";
|
||||
|
||||
// Keep channelData-only payloads so channel-specific replies survive normalization.
|
||||
describe("normalizeReplyPayload", () => {
|
||||
it("keeps channelData-only replies", () => {
|
||||
const payload = {
|
||||
channelData: {
|
||||
line: {
|
||||
flexMessage: { type: "bubble" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const normalized = normalizeReplyPayload(payload);
|
||||
|
||||
expect(normalized).not.toBeNull();
|
||||
expect(normalized?.text).toBeUndefined();
|
||||
expect(normalized?.channelData).toEqual(payload.channelData);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
resolveResponsePrefixTemplate,
|
||||
type ResponsePrefixContext,
|
||||
} from "./response-prefix-template.js";
|
||||
import { hasLineDirectives, parseLineDirectives } from "./line-directives.js";
|
||||
|
||||
export type NormalizeReplyOptions = {
|
||||
responsePrefix?: string;
|
||||
@@ -22,16 +21,13 @@ export function normalizeReplyPayload(
|
||||
opts: NormalizeReplyOptions = {},
|
||||
): ReplyPayload | null {
|
||||
const hasMedia = Boolean(payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0);
|
||||
const hasChannelData = Boolean(
|
||||
payload.channelData && Object.keys(payload.channelData).length > 0,
|
||||
);
|
||||
const trimmed = payload.text?.trim() ?? "";
|
||||
if (!trimmed && !hasMedia && !hasChannelData) return null;
|
||||
if (!trimmed && !hasMedia) return null;
|
||||
|
||||
const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN;
|
||||
let text = payload.text ?? undefined;
|
||||
if (text && isSilentReplyText(text, silentToken)) {
|
||||
if (!hasMedia && !hasChannelData) return null;
|
||||
if (!hasMedia) return null;
|
||||
text = "";
|
||||
}
|
||||
if (text && !trimmed) {
|
||||
@@ -43,21 +39,14 @@ export function normalizeReplyPayload(
|
||||
if (shouldStripHeartbeat && text?.includes(HEARTBEAT_TOKEN)) {
|
||||
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
||||
if (stripped.didStrip) opts.onHeartbeatStrip?.();
|
||||
if (stripped.shouldSkip && !hasMedia && !hasChannelData) return null;
|
||||
if (stripped.shouldSkip && !hasMedia) return null;
|
||||
text = stripped.text;
|
||||
}
|
||||
|
||||
if (text) {
|
||||
text = sanitizeUserFacingText(text);
|
||||
}
|
||||
if (!text?.trim() && !hasMedia && !hasChannelData) return null;
|
||||
|
||||
// Parse LINE-specific directives from text (quick_replies, location, confirm, buttons)
|
||||
let enrichedPayload: ReplyPayload = { ...payload, text };
|
||||
if (text && hasLineDirectives(text)) {
|
||||
enrichedPayload = parseLineDirectives(enrichedPayload);
|
||||
text = enrichedPayload.text;
|
||||
}
|
||||
if (!text?.trim() && !hasMedia) return null;
|
||||
|
||||
// Resolve template variables in responsePrefix if context is provided
|
||||
const effectivePrefix = opts.responsePrefixContext
|
||||
@@ -73,5 +62,5 @@ export function normalizeReplyPayload(
|
||||
text = `${effectivePrefix} ${text}`;
|
||||
}
|
||||
|
||||
return { ...enrichedPayload, text };
|
||||
return { ...payload, text };
|
||||
}
|
||||
@@ -45,8 +45,7 @@ export function isRenderablePayload(payload: ReplyPayload): boolean {
|
||||
payload.text ||
|
||||
payload.mediaUrl ||
|
||||
(payload.mediaUrls && payload.mediaUrls.length > 0) ||
|
||||
payload.audioAsVoice ||
|
||||
payload.channelData,
|
||||
payload.audioAsVoice,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,6 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
|
||||
providers: [],
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
diagnostics: [],
|
||||
@@ -380,23 +379,6 @@ describe("routeReply", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips mirror data when mirror is false", async () => {
|
||||
mocks.deliverOutboundPayloads.mockResolvedValue([]);
|
||||
await routeReply({
|
||||
payload: { text: "hi" },
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
sessionKey: "agent:main:main",
|
||||
mirror: false,
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mirror: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const emptyRegistry = createRegistry([]);
|
||||
|
||||
@@ -33,8 +33,6 @@ export type RouteReplyParams = {
|
||||
cfg: ClawdbotConfig;
|
||||
/** Optional abort signal for cooperative cancellation. */
|
||||
abortSignal?: AbortSignal;
|
||||
/** Mirror reply into session transcript (default: true when sessionKey is set). */
|
||||
mirror?: boolean;
|
||||
};
|
||||
|
||||
export type RouteReplyResult = {
|
||||
@@ -120,15 +118,14 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
|
||||
replyToId: resolvedReplyToId ?? null,
|
||||
threadId: resolvedThreadId,
|
||||
abortSignal,
|
||||
mirror:
|
||||
params.mirror !== false && params.sessionKey
|
||||
? {
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: resolveSessionAgentId({ sessionKey: params.sessionKey, config: cfg }),
|
||||
text,
|
||||
mediaUrls,
|
||||
}
|
||||
: undefined,
|
||||
mirror: params.sessionKey
|
||||
? {
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: resolveSessionAgentId({ sessionKey: params.sessionKey, config: cfg }),
|
||||
text,
|
||||
mediaUrls,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const last = results.at(-1);
|
||||
|
||||
Loaded 100 of 189 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user