mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-04 10:14:47 +08:00
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 43adb1998a | |||
| 5ba4735fe9 | |||
| 3986ce0e11 | |||
| 3777a9b614 | |||
| 36fb78092c | |||
| c52591f68f | |||
| e372f05d2e | |||
| 8e91d37bc9 | |||
| bba8213cbd | |||
| 5f069221b2 | |||
| 151091f384 | |||
| 5ada950982 | |||
| 6c455330e4 | |||
| 27af41d9b0 | |||
| ea68aa810b | |||
| 5987fdc189 | |||
| 0ef8757f50 | |||
| 878d3706b4 | |||
| b52ab850b2 | |||
| 7b4dfd28c3 | |||
| 106c21a567 | |||
| 221c4948cd | |||
| c6b21e7493 | |||
| 4a8887636c | |||
| 7789fcc67a | |||
| 6ec5f06d51 | |||
| 8f4c79dcaa | |||
| b672e8271e | |||
| de448ef22f | |||
| 53513db5bc | |||
| f0a0939860 | |||
| c91168bd74 | |||
| 68352351e4 | |||
| 22ed951f05 | |||
| 4dfc2e3a24 | |||
| 3a29674b46 | |||
| e8b01bdb07 | |||
| 6ed228f9a8 | |||
| fb2790a5d5 | |||
| 74c9c9e27a | |||
| 91400f9f66 | |||
| 46c8829f2f | |||
| 12f50281c2 | |||
| 100e556ee9 | |||
| 2444ab171f | |||
| 09a3b48dae | |||
| cec6768f4b | |||
| 303a5c7100 | |||
| 5454bae426 | |||
| 0d75ca26f4 | |||
| fbd8af618d |
@@ -0,0 +1,62 @@
|
||||
# Remove DeltaChat
|
||||
|
||||
## 1. Disable the adapter
|
||||
|
||||
Comment out the import in `src/channels/index.ts`:
|
||||
|
||||
```typescript
|
||||
// import './deltachat.js';
|
||||
```
|
||||
|
||||
## 2. Remove credentials
|
||||
|
||||
Remove the `DC_*` lines from `.env`:
|
||||
|
||||
```bash
|
||||
DC_EMAIL
|
||||
DC_PASSWORD
|
||||
DC_IMAP_HOST
|
||||
DC_IMAP_PORT
|
||||
DC_SMTP_HOST
|
||||
DC_SMTP_PORT
|
||||
```
|
||||
|
||||
## 3. Rebuild and restart
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
|
||||
# Linux
|
||||
systemctl --user restart nanoclaw
|
||||
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
```
|
||||
|
||||
## 4. Remove account data (optional)
|
||||
|
||||
To fully remove all account data including DeltaChat encryption keys:
|
||||
|
||||
```bash
|
||||
rm -rf dc-account/
|
||||
```
|
||||
|
||||
> **Warning:** This deletes the Autocrypt keys. Contacts who have verified your bot's key will need to re-verify if the same email address is re-used with a new account.
|
||||
|
||||
To keep the account for later reinstall, leave `dc-account/` intact.
|
||||
|
||||
## 5. Remove the package (optional)
|
||||
|
||||
```bash
|
||||
pnpm remove @deltachat/stdio-rpc-server
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
After removal, confirm the adapter is no longer starting:
|
||||
|
||||
```bash
|
||||
grep "deltachat" logs/nanoclaw.log | tail -5
|
||||
```
|
||||
|
||||
Expected: no `Channel adapter started` entry after the last restart.
|
||||
@@ -0,0 +1,254 @@
|
||||
---
|
||||
name: add-deltachat
|
||||
description: Add DeltaChat channel integration via @deltachat/stdio-rpc-server. Native adapter — no Chat SDK bridge. Email-based messaging with end-to-end encryption.
|
||||
---
|
||||
|
||||
# Add DeltaChat Channel
|
||||
|
||||
The adapter drives the `@deltachat/stdio-rpc-server` JSON-RPC subprocess directly — pure Node.js against the DeltaChat core library. Messages are delivered over email with Autocrypt/OpenPGP encryption.
|
||||
|
||||
## Install
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
- `src/channels/deltachat.ts` exists
|
||||
- `src/channels/index.ts` contains `import './deltachat.js';`
|
||||
- `@deltachat/stdio-rpc-server` is listed in `package.json` dependencies
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
### 1. Fetch the channels branch
|
||||
|
||||
```bash
|
||||
git fetch origin channels
|
||||
```
|
||||
|
||||
### 2. Copy the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/deltachat.ts > src/channels/deltachat.ts
|
||||
```
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if already present):
|
||||
|
||||
```typescript
|
||||
import './deltachat.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @deltachat/stdio-rpc-server@2.49.0
|
||||
```
|
||||
|
||||
### 5. Build
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
## Account Setup
|
||||
|
||||
A dedicated email account is strongly recommended — it will accumulate DeltaChat-formatted messages and store encryption keys. Not all providers work well with DeltaChat; check https://providers.delta.chat/ before picking one.
|
||||
|
||||
**Default security modes:** IMAP uses SSL/TLS (port 993), SMTP uses STARTTLS (port 587). Both are configurable via `.env` — see Credentials below.
|
||||
|
||||
To find the correct hostnames for a domain:
|
||||
|
||||
```bash
|
||||
node -e "require('dns').resolveMx('example.com', (e,r) => console.log(r))"
|
||||
```
|
||||
|
||||
Most providers publish their IMAP/SMTP hostnames in their help docs under "manual setup" or "IMAP access."
|
||||
|
||||
## Credentials
|
||||
|
||||
Add to `.env`:
|
||||
|
||||
```bash
|
||||
DC_EMAIL=bot@example.com
|
||||
DC_PASSWORD=your-app-password
|
||||
DC_IMAP_HOST=imap.example.com
|
||||
DC_IMAP_PORT=993
|
||||
DC_IMAP_SECURITY=1 # 1=SSL/TLS (default), 2=STARTTLS, 3=plain
|
||||
DC_SMTP_HOST=smtp.example.com
|
||||
DC_SMTP_PORT=587
|
||||
DC_SMTP_SECURITY=2 # 2=STARTTLS (default), 1=SSL/TLS, 3=plain
|
||||
```
|
||||
|
||||
Security settings are applied on every startup, so changing them in `.env` and restarting takes effect without wiping the account.
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
### Optional settings
|
||||
|
||||
The following are read from the process environment (not `.env`). To override them, add `Environment=` lines to the systemd service unit or your launchd plist:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DC_ACCOUNT_DIR` | `dc-account` | Directory for DeltaChat account data (IMAP state, keys, blobs) |
|
||||
| `DC_DISPLAY_NAME` | `NanoClaw` | Bot display name shown in DeltaChat |
|
||||
| `DC_AVATAR_PATH` | _(none)_ | Absolute path to avatar image; set at startup only |
|
||||
|
||||
The `/set-avatar` command (send an image with that caption) is the easiest way to set the avatar at runtime without modifying the service file. Only users with `owner` or global `admin` role can use it.
|
||||
|
||||
### Restart
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
systemctl --user restart nanoclaw
|
||||
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
```
|
||||
|
||||
On first start the adapter configures the email account (IMAP/SMTP credentials, calls `configure()`). Subsequent starts skip straight to `startIo()`. Account data is stored in `dc-account/` in the project root (or your `DC_ACCOUNT_DIR`).
|
||||
|
||||
## Wiring
|
||||
|
||||
### DMs
|
||||
|
||||
**DeltaChat contacts cannot be added by email alone** — to start a chat, the user must open the bot's invite link in their DeltaChat app or scan its QR code. This triggers the SecureJoin handshake.
|
||||
|
||||
#### Step 1 — Get the invite link
|
||||
|
||||
After the service starts, the adapter logs the invite URL and writes a QR SVG:
|
||||
|
||||
```bash
|
||||
grep "invite link" logs/nanoclaw.log | tail -1
|
||||
# url field contains the https://i.delta.chat/... invite link
|
||||
# also written to dc-account/invite-qr.svg (or $DC_ACCOUNT_DIR/invite-qr.svg)
|
||||
```
|
||||
|
||||
The invite URL is stable (tied to the bot's email and encryption keys) so it stays valid across restarts.
|
||||
|
||||
#### Step 2 — Add the bot in DeltaChat
|
||||
|
||||
Two options for the user to connect:
|
||||
|
||||
- **Link**: Copy the `https://i.delta.chat/...` URL and open it on the device running DeltaChat. The app recognises it and shows a "Start chat" prompt.
|
||||
- **QR code**: Open `dc-account/invite-qr.svg` in a browser or image viewer, display it on screen, and scan it from the DeltaChat app using the QR-scan button on the new-chat screen.
|
||||
|
||||
After accepting, DeltaChat exchanges keys and creates the chat automatically.
|
||||
|
||||
#### Step 3 — Wire the chat to an agent
|
||||
|
||||
Once the first message arrives the router auto-creates a `messaging_groups` row. Look up the chat ID:
|
||||
|
||||
```bash
|
||||
sqlite3 data/v2.db \
|
||||
"SELECT platform_id, name FROM messaging_groups WHERE channel_type='deltachat' AND is_group=0 ORDER BY created_at DESC LIMIT 5"
|
||||
```
|
||||
|
||||
Then run `/init-first-agent` — it creates the agent group, grants the user owner access, and wires the messaging group in one step:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/init-first-agent.ts \
|
||||
--channel deltachat \
|
||||
--user-id deltachat:user@example.com \
|
||||
--platform-id <platform_id from above> \
|
||||
--display-name "Your Name"
|
||||
```
|
||||
|
||||
### Groups
|
||||
|
||||
Add the bot email to a DeltaChat group. When any member sends a message, the router creates a `messaging_groups` row with `is_group = 1`. Run `/manage-channels` to wire it to an agent group.
|
||||
|
||||
## Next Steps
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now.
|
||||
|
||||
Otherwise, run `/init-first-agent` to create an agent and wire it to your DeltaChat DM (see Wiring above), or `/manage-channels` to wire this channel to an existing agent group.
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `deltachat`
|
||||
- **terminology**: DeltaChat calls them "chats" (1:1 DMs) and "groups"
|
||||
- **supports-threads**: no — DeltaChat has no thread model
|
||||
- **platform-id-format**: numeric chat ID as a string (e.g. `"12"`) — the DeltaChat core's internal chat identifier
|
||||
- **user-id-format**: `deltachat:{email}` — the contact's email address
|
||||
- **how-to-find-id**: Send a message from DeltaChat to the bot email, then query `messaging_groups` as shown above
|
||||
- **typical-use**: Personal assistant over DeltaChat DMs; small groups where participants use DeltaChat
|
||||
- **default-isolation**: One agent per bot identity. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode
|
||||
|
||||
### Features
|
||||
|
||||
- File attachments — inbound and outbound; inbound waits up to 30 seconds for large-message download to complete
|
||||
- Invite link logged on every startup — URL + QR SVG written to `dc-account/invite-qr.svg`; see Wiring for the bootstrap flow
|
||||
- `/set-avatar` — send an image with this caption to change the bot's DeltaChat avatar (admin/owner only)
|
||||
- Connectivity watchdog — restarts IO if IMAP goes quiet for 20 minutes or connectivity drops below threshold for two consecutive 5-minute checks
|
||||
- Network nudge — `maybeNetwork()` called every 10 minutes to recover from prolonged idle
|
||||
|
||||
Not supported: DeltaChat reactions, message editing/deletion, read receipts.
|
||||
|
||||
### Connectivity model
|
||||
|
||||
`isConnected()` returns `true` when the internal connectivity value is ≥ 3000:
|
||||
|
||||
| Range | Meaning |
|
||||
|-------|---------|
|
||||
| 1000–1999 | Not connected |
|
||||
| 2000–2999 | Connecting |
|
||||
| 3000–3999 | Working (IMAP fetching) |
|
||||
| ≥ 4000 | Fully connected (IMAP IDLE) |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Adapter not starting — credentials missing
|
||||
|
||||
```bash
|
||||
grep "Channel credentials missing" logs/nanoclaw.log | grep deltachat
|
||||
```
|
||||
|
||||
All six required vars (`DC_EMAIL`, `DC_PASSWORD`, `DC_IMAP_HOST`, `DC_IMAP_PORT`, `DC_SMTP_HOST`, `DC_SMTP_PORT`) must be present in `.env`.
|
||||
|
||||
### Account configure fails
|
||||
|
||||
```bash
|
||||
grep "DeltaChat" logs/nanoclaw.log | tail -20
|
||||
```
|
||||
|
||||
Common causes:
|
||||
- Wrong IMAP/SMTP hostnames — double-check provider docs
|
||||
- App password not generated — Gmail and some others require this when 2FA is enabled
|
||||
- Port/security mismatch — defaults are port 993 + SSL/TLS for IMAP and port 587 + STARTTLS for SMTP; override with `DC_IMAP_PORT`/`DC_IMAP_SECURITY` or `DC_SMTP_PORT`/`DC_SMTP_SECURITY` in `.env`
|
||||
|
||||
### Provider uses SMTP port 465 (SSL/TLS) instead of 587
|
||||
|
||||
Set `DC_SMTP_SECURITY=1` and `DC_SMTP_PORT=465` in `.env`, then restart.
|
||||
|
||||
### Messages not arriving
|
||||
|
||||
1. Check the service is running and the adapter started: `grep "Channel adapter started.*deltachat" logs/nanoclaw.log`
|
||||
2. Check connectivity: `grep "DeltaChat: IO started" logs/nanoclaw.log`
|
||||
3. Check the sender has been granted access — run `/init-first-agent` to create their user record and wire the chat
|
||||
4. Verify the messaging group is wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mga.agent_group_id FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='deltachat'"`
|
||||
|
||||
### Stale lock file after crash
|
||||
|
||||
```bash
|
||||
rm -f dc-account/accounts.lock
|
||||
systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
### Bot not responding after restart
|
||||
|
||||
The account is already configured — IO restarts automatically on service start. If the RPC subprocess is stuck, restart the service. Check for errors:
|
||||
|
||||
```bash
|
||||
grep "DeltaChat" logs/nanoclaw.error.log | tail -20
|
||||
```
|
||||
|
||||
### Messages received but agent not responding
|
||||
|
||||
The messaging group exists but may not be wired to an agent group. Run:
|
||||
|
||||
```bash
|
||||
sqlite3 data/v2.db "SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat'"
|
||||
```
|
||||
|
||||
If the group has no entry in `messaging_group_agents`, wire it with `/manage-channels`.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Verify DeltaChat
|
||||
|
||||
## 1. Check the adapter started
|
||||
|
||||
```bash
|
||||
grep "Channel adapter started.*deltachat" logs/nanoclaw.log | tail -1
|
||||
```
|
||||
|
||||
Expected: `Channel adapter started { channel: 'deltachat', type: 'deltachat' }`
|
||||
|
||||
## 2. Check IMAP/SMTP connectivity
|
||||
|
||||
Replace with your provider's hostnames from `.env`:
|
||||
|
||||
```bash
|
||||
DC_IMAP=$(grep '^DC_IMAP_HOST=' .env | cut -d= -f2)
|
||||
DC_SMTP=$(grep '^DC_SMTP_HOST=' .env | cut -d= -f2)
|
||||
|
||||
bash -c "echo >/dev/tcp/$DC_IMAP/993" && echo "IMAP open" || echo "IMAP blocked"
|
||||
bash -c "echo >/dev/tcp/$DC_SMTP/587" && echo "SMTP open" || echo "SMTP blocked"
|
||||
```
|
||||
|
||||
## 3. End-to-end message test
|
||||
|
||||
1. Open DeltaChat on your device
|
||||
2. Add the bot email address as a contact
|
||||
3. Send a message
|
||||
4. The bot should respond within a few seconds
|
||||
|
||||
If nothing arrives, check:
|
||||
|
||||
```bash
|
||||
grep "DeltaChat" logs/nanoclaw.log | tail -20
|
||||
grep "DeltaChat" logs/nanoclaw.error.log | tail -10
|
||||
```
|
||||
|
||||
## 4. Check messaging group was created
|
||||
|
||||
```bash
|
||||
sqlite3 data/v2.db \
|
||||
"SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat' ORDER BY created_at DESC LIMIT 5"
|
||||
```
|
||||
|
||||
If a row appears, the inbound routing is working. If not, the adapter isn't receiving the message — check logs for `DeltaChat: error handling incoming message`.
|
||||
|
||||
## 5. Verify user access
|
||||
|
||||
If the message arrived but the agent didn't respond, the sender may not have access:
|
||||
|
||||
```bash
|
||||
sqlite3 data/v2.db "SELECT id, display_name FROM users WHERE id LIKE 'deltachat:%'"
|
||||
```
|
||||
|
||||
Grant access as shown in the SKILL.md "Grant user access" section.
|
||||
@@ -57,7 +57,7 @@ groups: () => import('./groups.js'),
|
||||
### 5. Install the adapter packages (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
||||
pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
||||
```
|
||||
|
||||
### 6. Build
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
;;; nanoclaw.el --- Emacs interface for NanoClaw AI assistant -*- lexical-binding: t -*-
|
||||
|
||||
;; Author: NanoClaw
|
||||
;; Version: 0.1.0
|
||||
;; Package-Requires: ((emacs "27.1"))
|
||||
;; Keywords: ai, assistant, chat
|
||||
;;
|
||||
;; Vanilla Emacs (init.el):
|
||||
;; (load-file "~/src/nanoclaw/emacs/nanoclaw.el")
|
||||
;; (global-set-key (kbd "C-c n c") #'nanoclaw-chat)
|
||||
;; (global-set-key (kbd "C-c n o") #'nanoclaw-org-send)
|
||||
;;
|
||||
;; Spacemacs (~/.spacemacs, in dotspacemacs/user-config):
|
||||
;; (load-file "~/src/nanoclaw/emacs/nanoclaw.el")
|
||||
;; (spacemacs/set-leader-keys "aNc" #'nanoclaw-chat)
|
||||
;; (spacemacs/set-leader-keys "aNo" #'nanoclaw-org-send)
|
||||
;;
|
||||
;; Doom Emacs (config.el):
|
||||
;; (load (expand-file-name "~/src/nanoclaw/emacs/nanoclaw.el"))
|
||||
;; (map! :leader
|
||||
;; :prefix ("N" . "NanoClaw")
|
||||
;; :desc "Chat buffer" "c" #'nanoclaw-chat
|
||||
;; :desc "Send org" "o" #'nanoclaw-org-send)
|
||||
;; ;; Evil users: teach evil about the C-c C-c send binding
|
||||
;; (after! evil
|
||||
;; (evil-define-key '(normal insert) nanoclaw-chat-mode-map
|
||||
;; (kbd "C-c C-c") #'nanoclaw-chat-send))
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'cl-lib)
|
||||
(require 'url)
|
||||
(require 'json)
|
||||
(require 'org)
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Customization
|
||||
|
||||
(defgroup nanoclaw nil
|
||||
"NanoClaw AI assistant interface."
|
||||
:group 'tools
|
||||
:prefix "nanoclaw-")
|
||||
|
||||
(defcustom nanoclaw-host "localhost"
|
||||
"Hostname where NanoClaw is running."
|
||||
:type 'string
|
||||
:group 'nanoclaw)
|
||||
|
||||
(defcustom nanoclaw-port 8766
|
||||
"Port for the NanoClaw Emacs channel HTTP server."
|
||||
:type 'integer
|
||||
:group 'nanoclaw)
|
||||
|
||||
(defcustom nanoclaw-auth-token nil
|
||||
"Bearer token for NanoClaw authentication (matches EMACS_AUTH_TOKEN in .env).
|
||||
Leave nil if EMACS_AUTH_TOKEN is not set."
|
||||
:type '(choice (const nil) string)
|
||||
:group 'nanoclaw)
|
||||
|
||||
(defcustom nanoclaw-poll-interval 1.5
|
||||
"Seconds between response polls when waiting for a reply."
|
||||
:type 'number
|
||||
:group 'nanoclaw)
|
||||
|
||||
(defcustom nanoclaw-agent-name "Andy"
|
||||
"Display name for the NanoClaw agent (matches ASSISTANT_NAME in .env)."
|
||||
:type 'string
|
||||
:group 'nanoclaw)
|
||||
|
||||
(defcustom nanoclaw-convert-to-org t
|
||||
"When non-nil, convert agent responses to org-mode format.
|
||||
Uses pandoc when available; falls back to regex substitutions."
|
||||
:type 'boolean
|
||||
:group 'nanoclaw)
|
||||
|
||||
(defcustom nanoclaw-timestamp-format "%H:%M"
|
||||
"Format string for timestamps shown next to agent replies in the chat buffer.
|
||||
Passed to `format-time-string'. Set to nil to suppress timestamps."
|
||||
:type '(choice (const nil) string)
|
||||
:group 'nanoclaw)
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Formatting helpers
|
||||
|
||||
(defun nanoclaw--to-org (text)
|
||||
"Convert TEXT (markdown or plain) to org-mode markup.
|
||||
Tries pandoc -f gfm -t org when available; falls back to regex."
|
||||
(if (not nanoclaw-convert-to-org)
|
||||
text
|
||||
(if (executable-find "pandoc")
|
||||
(with-temp-buffer
|
||||
(insert text)
|
||||
(let* ((coding-system-for-read 'utf-8)
|
||||
(coding-system-for-write 'utf-8)
|
||||
(exit (call-process-region
|
||||
(point-min) (point-max)
|
||||
"pandoc" t t nil "-f" "gfm" "-t" "org" "--wrap=none")))
|
||||
(if (zerop exit)
|
||||
(string-trim (buffer-string))
|
||||
text)))
|
||||
(nanoclaw--md-to-org-regex text))))
|
||||
|
||||
;; NOTE: This function expects standard markdown as input (e.g. **bold**, *italic*).
|
||||
;; Agents responding on this channel must output markdown, not org-mode syntax.
|
||||
;; If the agent outputs org-mode directly, markers like *bold* will be incorrectly
|
||||
;; re-converted to /bold/ by the italic rule.
|
||||
(defun nanoclaw--md-to-org-regex (text)
|
||||
"Lightweight markdown → org conversion using regexp substitutions."
|
||||
(let ((s text))
|
||||
;; Fenced code blocks ```lang\n…\n``` → #+begin_src lang\n…\n#+end_src
|
||||
;; (must run before inline-code to avoid mangling backticks)
|
||||
(setq s (replace-regexp-in-string
|
||||
"```\\([a-zA-Z0-9_-]*\\)\n\\(\\(?:.\\|\n\\)*?\\)```"
|
||||
(lambda (m)
|
||||
(let ((lang (match-string 1 m))
|
||||
(body (match-string 2 m)))
|
||||
(concat "#+begin_src " (if (string-empty-p lang) "text" lang)
|
||||
"\n" body "#+end_src")))
|
||||
s t))
|
||||
;; Bold **text** → *text*, italic *text* → /text/
|
||||
;; Two-pass to prevent the italic regex from re-matching the bold result:
|
||||
;; 1. Mark bold spans with a placeholder (control char \x01)
|
||||
(setq s (replace-regexp-in-string "\\*\\*\\(.+?\\)\\*\\*" "\x01\\1\x01" s))
|
||||
;; 2. Convert remaining single-star spans to italic
|
||||
(setq s (replace-regexp-in-string "\\*\\(.+?\\)\\*" "/\\1/" s))
|
||||
;; 3. Resolve bold placeholders to org bold markers
|
||||
(setq s (replace-regexp-in-string "\x01\\(.+?\\)\x01" "*\\1*" s))
|
||||
;; Strikethrough ~~text~~ → +text+
|
||||
(setq s (replace-regexp-in-string "~~\\(.+?\\)~~" "+\\1+" s))
|
||||
;; Underline __text__ → _text_
|
||||
(setq s (replace-regexp-in-string "__\\(.+?\\)__" "_\\1_" s))
|
||||
;; Inline code `code` → ~code~
|
||||
(setq s (replace-regexp-in-string "`\\([^`]+\\)`" "~\\1~" s))
|
||||
;; ATX headings ## … → ** …
|
||||
(setq s (replace-regexp-in-string
|
||||
"^\\(#+\\) "
|
||||
(lambda (m) (concat (make-string (length (match-string 1 m)) ?*) " "))
|
||||
s))
|
||||
;; Links [text](url) → [[url][text]]
|
||||
(setq s (replace-regexp-in-string
|
||||
"\\[\\([^]]+\\)\\](\\([^)]+\\))" "[[\\2][\\1]]" s))
|
||||
s))
|
||||
|
||||
(defun nanoclaw--format-timestamp ()
|
||||
"Return a formatted timestamp string, or nil if disabled."
|
||||
(when nanoclaw-timestamp-format
|
||||
(format-time-string nanoclaw-timestamp-format)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Internal state
|
||||
|
||||
(defvar nanoclaw--poll-timer nil
|
||||
"Timer used to poll for responses in the chat buffer.")
|
||||
|
||||
(defvar nanoclaw--last-timestamp 0
|
||||
"Epoch ms of the most recently received message.")
|
||||
|
||||
(defvar nanoclaw--pending nil
|
||||
"Non-nil while waiting for a response.")
|
||||
|
||||
(defvar-local nanoclaw--thinking-dot-count 0
|
||||
"Dot cycle counter for the animated thinking indicator.")
|
||||
|
||||
(defvar-local nanoclaw--input-beg nil
|
||||
"Marker for the start of the current user input area.")
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; HTTP helpers
|
||||
|
||||
(defun nanoclaw--url (path)
|
||||
"Return the full URL for PATH on the NanoClaw server."
|
||||
(format "http://%s:%d%s" nanoclaw-host nanoclaw-port path))
|
||||
|
||||
(defun nanoclaw--headers ()
|
||||
"Return alist of HTTP headers for NanoClaw requests."
|
||||
(let ((hdrs '(("Content-Type" . "application/json"))))
|
||||
(when nanoclaw-auth-token
|
||||
(push (cons "Authorization" (concat "Bearer " nanoclaw-auth-token)) hdrs))
|
||||
hdrs))
|
||||
|
||||
(defun nanoclaw--post (text callback)
|
||||
"POST TEXT to NanoClaw and call CALLBACK with the response alist."
|
||||
(let* ((url-request-method "POST")
|
||||
(url-request-extra-headers (nanoclaw--headers))
|
||||
(url-request-data (encode-coding-string
|
||||
(json-encode `((text . ,text)))
|
||||
'utf-8)))
|
||||
(url-retrieve
|
||||
(nanoclaw--url "/api/message")
|
||||
(lambda (status)
|
||||
(if (plist-get status :error)
|
||||
(message "NanoClaw: POST error %s" (plist-get status :error))
|
||||
(goto-char (point-min))
|
||||
(re-search-forward "\n\n" nil t)
|
||||
(let ((data (ignore-errors (json-read))))
|
||||
(funcall callback data))))
|
||||
nil t t)))
|
||||
|
||||
(defun nanoclaw--poll (since callback)
|
||||
"GET messages newer than SINCE (epoch ms) and call CALLBACK with the list."
|
||||
(let* ((url-request-method "GET")
|
||||
(url-request-extra-headers (nanoclaw--headers)))
|
||||
(url-retrieve
|
||||
(nanoclaw--url (format "/api/messages?since=%d" since))
|
||||
(lambda (status)
|
||||
(unless (plist-get status :error)
|
||||
(goto-char (point-min))
|
||||
(re-search-forward "\n\n" nil t)
|
||||
(let* ((raw (buffer-substring-no-properties (point) (point-max)))
|
||||
(body (decode-coding-string raw 'utf-8))
|
||||
(data (ignore-errors (json-read-from-string body)))
|
||||
(msgs (cdr (assq 'messages data))))
|
||||
(when msgs (funcall callback (append msgs nil))))))
|
||||
nil t t)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Chat buffer
|
||||
|
||||
(defvar nanoclaw-chat-mode-map
|
||||
(let ((map (make-sparse-keymap)))
|
||||
(define-key map (kbd "RET") #'newline)
|
||||
(define-key map (kbd "<return>") #'newline)
|
||||
(define-key map (kbd "C-c C-c") #'nanoclaw-chat-send)
|
||||
map)
|
||||
"Keymap for `nanoclaw-chat-mode'.")
|
||||
|
||||
(define-derived-mode nanoclaw-chat-mode org-mode "NanoClaw"
|
||||
"Major mode for the NanoClaw chat buffer.
|
||||
Derives from org-mode so that org markup (headings, bold, code blocks,
|
||||
etc.) is fontified automatically. RET and <return> insert plain newlines
|
||||
for multi-line input; send with C-c C-c."
|
||||
(setq-local word-wrap t)
|
||||
(visual-line-mode 1)
|
||||
;; Disable org features that conflict with a linear chat buffer
|
||||
(setq-local org-return-follows-link nil)
|
||||
(setq-local org-cycle-emulate-tab nil)
|
||||
;; Ensure send binding beats org-mode's C-c C-c via the buffer-local map
|
||||
(local-set-key (kbd "C-c C-c") #'nanoclaw-chat-send))
|
||||
|
||||
(defun nanoclaw--advance-input-beg ()
|
||||
"Move `nanoclaw--input-beg' to point-max in the chat buffer."
|
||||
(with-current-buffer (nanoclaw--chat-buffer)
|
||||
(when nanoclaw--input-beg (set-marker nanoclaw--input-beg nil))
|
||||
(setq nanoclaw--input-beg (copy-marker (point-max)))))
|
||||
|
||||
(defun nanoclaw--chat-buffer ()
|
||||
"Return the NanoClaw chat buffer, creating it if necessary."
|
||||
(or (get-buffer "*NanoClaw*")
|
||||
(with-current-buffer (get-buffer-create "*NanoClaw*")
|
||||
(nanoclaw-chat-mode)
|
||||
(set-buffer-file-coding-system 'utf-8)
|
||||
(add-hook 'kill-buffer-hook #'nanoclaw--stop-poll nil t)
|
||||
(nanoclaw--insert-header)
|
||||
(setq nanoclaw--input-beg (copy-marker (point-max)))
|
||||
(current-buffer))))
|
||||
|
||||
(defun nanoclaw--insert-header ()
|
||||
"Insert the welcome header into the chat buffer."
|
||||
(let ((inhibit-read-only t))
|
||||
(insert (propertize
|
||||
(format "── NanoClaw (%s) ──────────────────────────────\n\n"
|
||||
nanoclaw-agent-name)
|
||||
'face 'font-lock-comment-face))))
|
||||
|
||||
(defun nanoclaw--chat-insert (speaker text)
|
||||
"Append SPEAKER: TEXT to the chat buffer."
|
||||
(with-current-buffer (nanoclaw--chat-buffer)
|
||||
(let* ((inhibit-read-only t)
|
||||
(is-agent (not (string= speaker "You")))
|
||||
(display-text (if is-agent (nanoclaw--to-org text) text))
|
||||
(ts (nanoclaw--format-timestamp))
|
||||
(label (if ts (format "%s [%s]" speaker ts) speaker))
|
||||
(face (if is-agent 'font-lock-string-face 'font-lock-keyword-face)))
|
||||
(goto-char (point-max))
|
||||
(insert (propertize (concat label ": ") 'face face))
|
||||
(insert display-text "\n\n")
|
||||
(goto-char (point-max))
|
||||
(when is-agent
|
||||
(nanoclaw--advance-input-beg)))))
|
||||
|
||||
;;;###autoload
|
||||
(defun nanoclaw-chat ()
|
||||
"Open the NanoClaw chat buffer."
|
||||
(interactive)
|
||||
(pop-to-buffer (nanoclaw--chat-buffer))
|
||||
(goto-char (point-max)))
|
||||
|
||||
(defun nanoclaw-chat-send ()
|
||||
"Send the accumulated input area as a message to NanoClaw.
|
||||
Use C-c C-c to send; RET inserts a plain newline for multi-line messages."
|
||||
(interactive)
|
||||
(when nanoclaw--pending
|
||||
(message "NanoClaw: waiting for previous response...")
|
||||
(cl-return-from nanoclaw-chat-send))
|
||||
(let* ((beg (if (and nanoclaw--input-beg (marker-buffer nanoclaw--input-beg))
|
||||
(marker-position nanoclaw--input-beg)
|
||||
(line-beginning-position)))
|
||||
(text (string-trim (buffer-substring-no-properties beg (point-max)))))
|
||||
(when (string-empty-p text)
|
||||
(user-error "Nothing to send"))
|
||||
(let ((inhibit-read-only t))
|
||||
(delete-region beg (point-max)))
|
||||
(nanoclaw--chat-insert "You" text)
|
||||
(nanoclaw--advance-input-beg)
|
||||
(setq nanoclaw--pending t)
|
||||
(nanoclaw--post text
|
||||
(lambda (data)
|
||||
(when data
|
||||
(setq nanoclaw--last-timestamp
|
||||
(or (cdr (assq 'timestamp data))
|
||||
nanoclaw--last-timestamp))
|
||||
(nanoclaw--start-thinking)
|
||||
(nanoclaw--start-poll))))))
|
||||
|
||||
(defun nanoclaw--start-poll ()
|
||||
"Start polling for new messages."
|
||||
(nanoclaw--stop-poll)
|
||||
(setq nanoclaw--poll-timer
|
||||
(run-with-timer nanoclaw-poll-interval nanoclaw-poll-interval
|
||||
#'nanoclaw--poll-tick)))
|
||||
|
||||
(defun nanoclaw--stop-poll ()
|
||||
"Stop the polling timer."
|
||||
(when nanoclaw--poll-timer
|
||||
(cancel-timer nanoclaw--poll-timer)
|
||||
(setq nanoclaw--poll-timer nil)))
|
||||
|
||||
(defun nanoclaw--start-thinking ()
|
||||
"Insert an animated thinking indicator at the end of the chat buffer."
|
||||
(with-current-buffer (nanoclaw--chat-buffer)
|
||||
(let ((inhibit-read-only t))
|
||||
(goto-char (point-max))
|
||||
(setq nanoclaw--thinking-dot-count 1)
|
||||
(insert (propertize (format "%s: .\n\n" nanoclaw-agent-name)
|
||||
'nanoclaw-thinking t
|
||||
'face 'font-lock-string-face)))))
|
||||
|
||||
(defun nanoclaw--tick-thinking ()
|
||||
"Advance the dot animation in the thinking indicator."
|
||||
(let ((buf (get-buffer "*NanoClaw*")))
|
||||
(when buf
|
||||
(with-current-buffer buf
|
||||
(when nanoclaw--pending
|
||||
(let* ((inhibit-read-only t)
|
||||
(pos (text-property-any (point-min) (point-max)
|
||||
'nanoclaw-thinking t)))
|
||||
(when pos
|
||||
(let* ((end (or (next-single-property-change
|
||||
pos 'nanoclaw-thinking) (point-max)))
|
||||
(n (1+ (mod nanoclaw--thinking-dot-count 3))))
|
||||
(setq nanoclaw--thinking-dot-count n)
|
||||
(delete-region pos end)
|
||||
(save-excursion
|
||||
(goto-char pos)
|
||||
(insert (propertize
|
||||
(format "%s: %s\n\n" nanoclaw-agent-name
|
||||
(make-string n ?.))
|
||||
'nanoclaw-thinking t
|
||||
'face 'font-lock-string-face)))))))))))
|
||||
|
||||
(defun nanoclaw--clear-thinking ()
|
||||
"Remove the thinking indicator from the chat buffer."
|
||||
(let ((buf (get-buffer "*NanoClaw*")))
|
||||
(when buf
|
||||
(with-current-buffer buf
|
||||
(let* ((inhibit-read-only t)
|
||||
(pos (text-property-any (point-min) (point-max)
|
||||
'nanoclaw-thinking t)))
|
||||
(when pos
|
||||
(delete-region pos (or (next-single-property-change
|
||||
pos 'nanoclaw-thinking) (point-max)))))))))
|
||||
|
||||
(defun nanoclaw--poll-tick ()
|
||||
"Poll for new messages and insert them into the chat buffer."
|
||||
(nanoclaw--tick-thinking)
|
||||
(nanoclaw--poll
|
||||
nanoclaw--last-timestamp
|
||||
(lambda (msgs)
|
||||
(dolist (msg msgs)
|
||||
(let ((text (cdr (assq 'text msg)))
|
||||
(ts (cdr (assq 'timestamp msg))))
|
||||
(when (and text (> ts nanoclaw--last-timestamp))
|
||||
(setq nanoclaw--last-timestamp ts)
|
||||
(nanoclaw--clear-thinking)
|
||||
(nanoclaw--chat-insert nanoclaw-agent-name text))))
|
||||
(when msgs
|
||||
(setq nanoclaw--pending nil)
|
||||
(nanoclaw--stop-poll)))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Org integration
|
||||
|
||||
;;;###autoload
|
||||
(defun nanoclaw-org-send ()
|
||||
"Send the current org subtree to NanoClaw and insert the response as a child.
|
||||
|
||||
If a region is active, send the region text instead."
|
||||
(interactive)
|
||||
(unless (derived-mode-p 'org-mode)
|
||||
(user-error "Not in an org-mode buffer"))
|
||||
(let ((text (if (use-region-p)
|
||||
(buffer-substring-no-properties (region-beginning) (region-end))
|
||||
(nanoclaw--org-subtree-text))))
|
||||
(when (string-empty-p (string-trim text))
|
||||
(user-error "Nothing to send"))
|
||||
(message "NanoClaw: sending to %s..." nanoclaw-agent-name)
|
||||
(let ((marker (point-marker))
|
||||
(buf (current-buffer)))
|
||||
(nanoclaw--post
|
||||
text
|
||||
(lambda (data)
|
||||
(let* ((ts (or (cdr (assq 'timestamp data)) (nanoclaw--now-ms)))
|
||||
(level (with-current-buffer buf
|
||||
(save-excursion (goto-char marker) (org-outline-level))))
|
||||
(ph (with-current-buffer buf
|
||||
(save-excursion
|
||||
(goto-char marker)
|
||||
(nanoclaw--org-insert-placeholder level)))))
|
||||
(nanoclaw--poll-until-response
|
||||
ts
|
||||
(lambda (response)
|
||||
(with-current-buffer buf
|
||||
(save-excursion
|
||||
(when (marker-buffer ph)
|
||||
(let* ((inhibit-read-only t)
|
||||
(beg (marker-position ph))
|
||||
(end (save-excursion
|
||||
(goto-char (1+ beg))
|
||||
(org-next-visible-heading 1)
|
||||
(point))))
|
||||
(delete-region beg end))
|
||||
(set-marker ph nil))
|
||||
(goto-char marker)
|
||||
(nanoclaw--org-insert-response response))))
|
||||
(lambda ()
|
||||
(message "NanoClaw: timed out waiting for response")
|
||||
(when (marker-buffer ph)
|
||||
(with-current-buffer (marker-buffer ph)
|
||||
(let* ((inhibit-read-only t)
|
||||
(beg (marker-position ph))
|
||||
(end (save-excursion
|
||||
(goto-char (1+ beg))
|
||||
(org-next-visible-heading 1)
|
||||
(point))))
|
||||
(delete-region beg end))
|
||||
(set-marker ph nil)))))))))))
|
||||
|
||||
(defun nanoclaw--org-insert-placeholder (level)
|
||||
"Insert a processing child heading at LEVEL+1 and return a marker at its start."
|
||||
(org-back-to-heading t)
|
||||
(org-end-of-subtree t t)
|
||||
(let ((beg (point)))
|
||||
(insert "\n" (make-string (1+ level) ?*) " "
|
||||
nanoclaw-agent-name " [processing...]\n\n")
|
||||
(copy-marker beg)))
|
||||
|
||||
(defun nanoclaw--org-subtree-text ()
|
||||
"Return the text of the org subtree at point (heading + body)."
|
||||
(org-with-wide-buffer
|
||||
(org-back-to-heading t)
|
||||
(let ((start (point))
|
||||
(end (progn (org-end-of-subtree t t) (point))))
|
||||
(buffer-substring-no-properties start end))))
|
||||
|
||||
(defun nanoclaw--org-insert-response (text)
|
||||
"Insert TEXT as a child org heading under the current subtree."
|
||||
(org-back-to-heading t)
|
||||
(let* ((level (org-outline-level))
|
||||
(child-stars (make-string (1+ level) ?*))
|
||||
(timestamp (format-time-string "[%Y-%m-%d %a %H:%M]"))
|
||||
(body (nanoclaw--to-org text)))
|
||||
(org-end-of-subtree t t)
|
||||
(insert "\n" child-stars " " nanoclaw-agent-name " " timestamp "\n"
|
||||
body "\n")))
|
||||
|
||||
(defun nanoclaw--now-ms ()
|
||||
"Return current time as milliseconds since epoch."
|
||||
(let ((time (current-time)))
|
||||
(+ (* (+ (* (car time) 65536) (cadr time)) 1000)
|
||||
(/ (caddr time) 1000))))
|
||||
|
||||
(defun nanoclaw--poll-until-response (since callback timeout-fn &optional attempts)
|
||||
"Poll until a message newer than SINCE arrives, then call CALLBACK.
|
||||
Calls TIMEOUT-FN after 60 attempts (~90s)."
|
||||
(let ((n (or attempts 0)))
|
||||
(if (>= n 60)
|
||||
(funcall timeout-fn)
|
||||
(nanoclaw--poll
|
||||
since
|
||||
(lambda (msgs)
|
||||
(let ((fresh (seq-filter (lambda (m) (> (cdr (assq 'timestamp m)) since))
|
||||
msgs)))
|
||||
(if fresh
|
||||
(let ((text (mapconcat (lambda (m) (cdr (assq 'text m)))
|
||||
fresh "\n")))
|
||||
(funcall callback text))
|
||||
(run-with-timer nanoclaw-poll-interval nil
|
||||
#'nanoclaw--poll-until-response
|
||||
since callback timeout-fn (1+ n)))))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(provide 'nanoclaw)
|
||||
;;; nanoclaw.el ends here
|
||||
+19
-1
@@ -24,13 +24,31 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@beeper/chat-adapter-matrix": "^0.2.0",
|
||||
"@bitbasti/chat-adapter-webex": "^0.1.0",
|
||||
"@chat-adapter/discord": "^4.24.0",
|
||||
"@chat-adapter/gchat": "^4.24.0",
|
||||
"@chat-adapter/github": "^4.24.0",
|
||||
"@chat-adapter/linear": "^4.26.0",
|
||||
"@chat-adapter/slack": "^4.24.0",
|
||||
"@chat-adapter/state-memory": "^4.24.0",
|
||||
"@chat-adapter/teams": "^4.24.0",
|
||||
"@chat-adapter/telegram": "4.26.0",
|
||||
"@chat-adapter/whatsapp": "^4.24.0",
|
||||
"@clack/core": "^1.2.0",
|
||||
"@clack/prompts": "^1.2.0",
|
||||
"@onecli-sh/sdk": "^0.3.1",
|
||||
"@resend/chat-sdk-adapter": "^0.1.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@whiskeysockets/baileys": "7.0.0-rc.9",
|
||||
"better-sqlite3": "11.10.0",
|
||||
"chat": "^4.24.0",
|
||||
"chat-adapter-imessage": "^0.1.1",
|
||||
"cron-parser": "5.5.0",
|
||||
"kleur": "^4.1.5"
|
||||
"kleur": "^4.1.5",
|
||||
"pino": "^9.6.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"wechat-ilink-client": "^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.35.0",
|
||||
|
||||
Generated
+3870
-2
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Initialize the scratch CLI agent used during `/new-setup`.
|
||||
* Initialize the scratch CLI agent used during `/setup`.
|
||||
*
|
||||
* Creates the synthetic `cli:local` user, grants owner role if no owner
|
||||
* exists yet, builds an agent group with a minimal CLAUDE.md, and wires it
|
||||
|
||||
@@ -16,7 +16,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-whatsapp/SKILL.md.
|
||||
BAILEYS_VERSION="@whiskeysockets/baileys@6.17.16"
|
||||
BAILEYS_VERSION="@whiskeysockets/baileys@7.0.0-rc.9"
|
||||
QRCODE_VERSION="qrcode@1.5.4"
|
||||
QRCODE_TYPES_VERSION="@types/qrcode@1.5.6"
|
||||
PINO_VERSION="pino@9.6.0"
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@
|
||||
* already exists unless --force is passed.
|
||||
*
|
||||
* The actual user-facing prompt (subscription vs API key, paste the token)
|
||||
* stays in the /new-setup SKILL.md. This step is just the machine side:
|
||||
* stays in the /setup SKILL.md. This step is just the machine side:
|
||||
* it calls `onecli secrets list` / `onecli secrets create` and emits a
|
||||
* structured status block. The token value is never logged.
|
||||
*/
|
||||
@@ -124,7 +124,7 @@ export async function run(args: string[]): Promise<void> {
|
||||
emitStatus('AUTH', {
|
||||
STATUS: 'failed',
|
||||
ERROR: 'onecli_list_failed',
|
||||
HINT: 'Is OneCLI running? Run `/new-setup` from the onecli step.',
|
||||
HINT: 'Is OneCLI running? Run `/setup` from the onecli step.',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(1);
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Step: cli-agent — Create the scratch CLI agent for `/new-setup`.
|
||||
* Step: cli-agent — Create the scratch CLI agent for `/setup`.
|
||||
*
|
||||
* Thin wrapper around `scripts/init-cli-agent.ts`. Emits a status block so
|
||||
* /new-setup SKILL.md can parse the result without having to read the
|
||||
* /setup SKILL.md can parse the result without having to read the
|
||||
* script's plain stdout.
|
||||
*
|
||||
* Args:
|
||||
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Step: groups — Fetch group metadata from messaging platforms, write to DB.
|
||||
* WhatsApp requires an upfront sync (Baileys groupFetchAllParticipating).
|
||||
* Other channels discover group names at runtime — this step auto-skips for them.
|
||||
* Replaces 05-sync-groups.sh + 05b-list-groups.sh
|
||||
*/
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import { STORE_DIR } from '../src/config.js';
|
||||
import { log } from '../src/log.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
function parseArgs(args: string[]): { list: boolean; limit: number } {
|
||||
let list = false;
|
||||
let limit = 30;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--list') list = true;
|
||||
if (args[i] === '--limit' && args[i + 1]) {
|
||||
limit = parseInt(args[i + 1], 10);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return { list, limit };
|
||||
}
|
||||
|
||||
export async function run(args: string[]): Promise<void> {
|
||||
const projectRoot = process.cwd();
|
||||
const { list, limit } = parseArgs(args);
|
||||
|
||||
if (list) {
|
||||
await listGroups(limit);
|
||||
return;
|
||||
}
|
||||
|
||||
await syncGroups(projectRoot);
|
||||
}
|
||||
|
||||
async function listGroups(limit: number): Promise<void> {
|
||||
const dbPath = path.join(STORE_DIR, 'messages.db');
|
||||
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
console.error('ERROR: database not found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT jid, name FROM chats
|
||||
WHERE jid LIKE '%@g.us' AND jid <> '__group_sync__' AND name <> jid
|
||||
ORDER BY last_message_time DESC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(limit) as Array<{ jid: string; name: string }>;
|
||||
db.close();
|
||||
|
||||
for (const row of rows) {
|
||||
console.log(`${row.jid}|${row.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncGroups(projectRoot: string): Promise<void> {
|
||||
// Only WhatsApp needs an upfront group sync; other channels resolve names at runtime.
|
||||
// Detect WhatsApp by checking for auth credentials on disk.
|
||||
const authDir = path.join(projectRoot, 'store', 'auth');
|
||||
const hasWhatsAppAuth =
|
||||
fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0;
|
||||
|
||||
if (!hasWhatsAppAuth) {
|
||||
log.info('WhatsApp auth not found — skipping group sync');
|
||||
emitStatus('SYNC_GROUPS', {
|
||||
BUILD: 'skipped',
|
||||
SYNC: 'skipped',
|
||||
GROUPS_IN_DB: 0,
|
||||
REASON: 'whatsapp_not_configured',
|
||||
STATUS: 'success',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Build TypeScript first
|
||||
log.info('Building TypeScript');
|
||||
let buildOk = false;
|
||||
try {
|
||||
execSync('pnpm run build', {
|
||||
cwd: projectRoot,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
buildOk = true;
|
||||
log.info('Build succeeded');
|
||||
} catch {
|
||||
log.error('Build failed');
|
||||
emitStatus('SYNC_GROUPS', {
|
||||
BUILD: 'failed',
|
||||
SYNC: 'skipped',
|
||||
GROUPS_IN_DB: 0,
|
||||
STATUS: 'failed',
|
||||
ERROR: 'build_failed',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Run sync script via a temp file to avoid shell escaping issues with node -e
|
||||
log.info('Fetching group metadata');
|
||||
let syncOk = false;
|
||||
try {
|
||||
const syncScript = `
|
||||
import makeWASocket, { useMultiFileAuthState, makeCacheableSignalKeyStore, Browsers } from '@whiskeysockets/baileys';
|
||||
import pino from 'pino';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
const logger = pino({ level: 'silent' });
|
||||
const authDir = path.join('store', 'auth');
|
||||
const dbPath = path.join('store', 'messages.db');
|
||||
|
||||
if (!fs.existsSync(authDir)) {
|
||||
console.error('NO_AUTH');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.exec('CREATE TABLE IF NOT EXISTS chats (jid TEXT PRIMARY KEY, name TEXT, last_message_time TEXT)');
|
||||
|
||||
const upsert = db.prepare(
|
||||
'INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?) ON CONFLICT(jid) DO UPDATE SET name = excluded.name'
|
||||
);
|
||||
|
||||
const { state, saveCreds } = await useMultiFileAuthState(authDir);
|
||||
|
||||
const sock = makeWASocket({
|
||||
auth: { creds: state.creds, keys: makeCacheableSignalKeyStore(state.keys, logger) },
|
||||
printQRInTerminal: false,
|
||||
logger,
|
||||
browser: Browsers.macOS('Chrome'),
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
console.error('TIMEOUT');
|
||||
process.exit(1);
|
||||
}, 30000);
|
||||
|
||||
sock.ev.on('creds.update', saveCreds);
|
||||
|
||||
sock.ev.on('connection.update', async (update) => {
|
||||
if (update.connection === 'open') {
|
||||
try {
|
||||
const groups = await sock.groupFetchAllParticipating();
|
||||
const now = new Date().toISOString();
|
||||
let count = 0;
|
||||
for (const [jid, metadata] of Object.entries(groups)) {
|
||||
if (metadata.subject) {
|
||||
upsert.run(jid, metadata.subject, now);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
console.log('SYNCED:' + count);
|
||||
} catch (err) {
|
||||
console.error('FETCH_ERROR:' + err.message);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
sock.end(undefined);
|
||||
db.close();
|
||||
process.exit(0);
|
||||
}
|
||||
} else if (update.connection === 'close') {
|
||||
clearTimeout(timeout);
|
||||
console.error('CONNECTION_CLOSED');
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
`;
|
||||
|
||||
const tmpScript = path.join(projectRoot, '.tmp-group-sync.mjs');
|
||||
fs.writeFileSync(tmpScript, syncScript, 'utf-8');
|
||||
try {
|
||||
const output = execSync(`node ${tmpScript}`, {
|
||||
cwd: projectRoot,
|
||||
encoding: 'utf-8',
|
||||
timeout: 45000,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
syncOk = output.includes('SYNCED:');
|
||||
log.info('Sync output', { output: output.trim() });
|
||||
} finally {
|
||||
try { fs.unlinkSync(tmpScript); } catch { /* ignore cleanup errors */ }
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('Sync failed', { err });
|
||||
}
|
||||
|
||||
// Count groups in DB using better-sqlite3 (no sqlite3 CLI)
|
||||
let groupsInDb = 0;
|
||||
const dbPath = path.join(STORE_DIR, 'messages.db');
|
||||
if (fs.existsSync(dbPath)) {
|
||||
try {
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
const row = db
|
||||
.prepare(
|
||||
"SELECT COUNT(*) as count FROM chats WHERE jid LIKE '%@g.us' AND jid <> '__group_sync__'",
|
||||
)
|
||||
.get() as { count: number };
|
||||
groupsInDb = row.count;
|
||||
db.close();
|
||||
} catch {
|
||||
// DB may not exist yet
|
||||
}
|
||||
}
|
||||
|
||||
const status = syncOk ? 'success' : 'failed';
|
||||
|
||||
emitStatus('SYNC_GROUPS', {
|
||||
BUILD: buildOk ? 'success' : 'failed',
|
||||
SYNC: syncOk ? 'success' : 'failed',
|
||||
GROUPS_IN_DB: groupsInDb,
|
||||
STATUS: status,
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
|
||||
if (status === 'failed') process.exit(1);
|
||||
}
|
||||
+2
-1
@@ -13,8 +13,9 @@ const STEPS: Record<
|
||||
'set-env': () => import('./set-env.js'),
|
||||
environment: () => import('./environment.js'),
|
||||
container: () => import('./container.js'),
|
||||
register: () => import('./register.js'),
|
||||
groups: () => import('./groups.js'),
|
||||
register: () => import('./register.js'),
|
||||
'pair-telegram': () => import('./pair-telegram.js'),
|
||||
'whatsapp-auth': () => import('./whatsapp-auth.js'),
|
||||
'signal-auth': () => import('./signal-auth.js'),
|
||||
mounts: () => import('./mounts.js'),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-discord — bundles the preflight + install commands
|
||||
# from the /add-discord skill into one idempotent script so /new-setup can
|
||||
# from the /add-discord skill into one idempotent script so /setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Discord adapter in from the `channels` branch; appends the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-docker — bundles Docker install into one idempotent
|
||||
# script so /new-setup can run it without needing `curl | sh` in the allowlist
|
||||
# script so /setup can run it without needing `curl | sh` in the allowlist
|
||||
# (pipelines split at matching time, and `sh` receiving stdin can't be
|
||||
# pre-approved safely).
|
||||
#
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-gchat — bundles the preflight + install commands
|
||||
# from the /add-gchat skill into one idempotent script so /new-setup can
|
||||
# from the /add-gchat skill into one idempotent script so /setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Google Chat adapter in from the `channels` branch; appends the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-github — bundles the preflight + install commands
|
||||
# from the /add-github skill into one idempotent script so /new-setup can
|
||||
# from the /add-github skill into one idempotent script so /setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the GitHub adapter in from the `channels` branch; appends the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-imessage — bundles the preflight + install commands
|
||||
# from the /add-imessage skill into one idempotent script so /new-setup can
|
||||
# from the /add-imessage skill into one idempotent script so /setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the iMessage adapter in from the `channels` branch; appends the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-linear — bundles the preflight + install commands
|
||||
# from the /add-linear skill into one idempotent script so /new-setup can
|
||||
# from the /add-linear skill into one idempotent script so /setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Linear adapter in from the `channels` branch; appends the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-matrix — bundles the preflight + install commands
|
||||
# from the /add-matrix skill into one idempotent script so /new-setup can
|
||||
# from the /add-matrix skill into one idempotent script so /setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Matrix adapter in from the `channels` branch; appends the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-node — bundles Node 22 install into one idempotent
|
||||
# script so /new-setup can run it without needing `curl | sudo -E bash -` in
|
||||
# script so /setup can run it without needing `curl | sudo -E bash -` in
|
||||
# the allowlist (that pattern is inherently unmatchable — bash reads from
|
||||
# stdin, so pre-approval can't inspect what's being executed).
|
||||
#
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-resend — bundles the preflight + install commands
|
||||
# from the /add-resend skill into one idempotent script so /new-setup can
|
||||
# from the /add-resend skill into one idempotent script so /setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Resend adapter in from the `channels` branch; appends the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-slack — bundles the preflight + install commands
|
||||
# from the /add-slack skill into one idempotent script so /new-setup can
|
||||
# from the /add-slack skill into one idempotent script so /setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Slack adapter in from the `channels` branch; appends the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-teams — bundles the preflight + install commands
|
||||
# from the /add-teams skill into one idempotent script so /new-setup can
|
||||
# from the /add-teams skill into one idempotent script so /setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Teams adapter in from the `channels` branch; appends the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-telegram — bundles the preflight + install commands
|
||||
# from the /add-telegram skill into one idempotent script so /new-setup can
|
||||
# from the /add-telegram skill into one idempotent script so /setup can
|
||||
# run them programmatically before continuing to credentials and pairing.
|
||||
#
|
||||
# Copies the Telegram adapter, helpers, tests, and the pair-telegram setup
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-webex — bundles the preflight + install commands
|
||||
# from the /add-webex skill into one idempotent script so /new-setup can
|
||||
# from the /add-webex skill into one idempotent script so /setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Webex adapter in from the `channels` branch; appends the
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-whatsapp-cloud — bundles the preflight + install
|
||||
# commands from the /add-whatsapp-cloud skill into one idempotent script so
|
||||
# /new-setup can run them programmatically before continuing to credentials.
|
||||
# /setup can run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the WhatsApp Cloud adapter in from the `channels` branch; appends the
|
||||
# self-registration import; installs the pinned @chat-adapter/whatsapp package;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-whatsapp — bundles the preflight + install commands
|
||||
# from the /add-whatsapp skill into one idempotent script so /new-setup can
|
||||
# from the /add-whatsapp skill into one idempotent script so /setup can
|
||||
# run them programmatically before continuing to QR/pairing-code auth.
|
||||
#
|
||||
# Copies the native Baileys WhatsApp adapter, its whatsapp-auth and groups
|
||||
@@ -66,7 +66,7 @@ if ! grep -q "'whatsapp-auth':" setup/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
||||
pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# Setup step: probe — single upfront parallel-ish scan that snapshots every
|
||||
# prerequisite and dependency for /new-setup's dynamic context injection.
|
||||
# prerequisite and dependency for /setup's dynamic context injection.
|
||||
# Rendered into the SKILL.md prompt via `!bash setup/probe.sh` so Claude sees
|
||||
# the current system state before generating its first response.
|
||||
#
|
||||
|
||||
+19
-25
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Step: whatsapp-auth — standalone WhatsApp (Baileys) authentication.
|
||||
* Step: whatsapp-auth — standalone WhatsApp (Baileys v7) authentication.
|
||||
*
|
||||
* Forked from the channels-branch version so setup:auto's driver can render
|
||||
* the terminal UX itself (inside clack) instead of the step dumping a raw QR
|
||||
@@ -27,7 +27,6 @@
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { createRequire } from 'module';
|
||||
// Named import (not default) — pino's d.ts under NodeNext resolves the
|
||||
// default export to `typeof pino` (namespace), which isn't callable. The
|
||||
// named `pino` export resolves to the callable function.
|
||||
@@ -47,26 +46,23 @@ const AUTH_DIR = path.join(process.cwd(), 'store', 'auth');
|
||||
const PAIRING_CODE_FILE = path.join(process.cwd(), 'store', 'pairing-code.txt');
|
||||
const baileysLogger = pino({ level: 'silent' });
|
||||
|
||||
// Baileys v6 bug: getPlatformId sends charCode (49) instead of enum value (1).
|
||||
// Fixed in Baileys 7.x but not backported. Without this patch pairing codes
|
||||
// fail with "couldn't link device" because WhatsApp receives an invalid
|
||||
// platform id. createRequire because proto is not a named ESM export.
|
||||
const _require = createRequire(import.meta.url);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { proto } = _require('@whiskeysockets/baileys') as { proto: any };
|
||||
try {
|
||||
const _generics = _require(
|
||||
'@whiskeysockets/baileys/lib/Utils/generics',
|
||||
) as Record<string, unknown>;
|
||||
_generics.getPlatformId = (browser: string): string => {
|
||||
const platformType =
|
||||
proto.DeviceProps.PlatformType[
|
||||
browser.toUpperCase() as keyof typeof proto.DeviceProps.PlatformType
|
||||
];
|
||||
return platformType ? platformType.toString() : '1';
|
||||
};
|
||||
} catch {
|
||||
// If CJS require fails, QR auth still works; only pairing code may be affected.
|
||||
/** Fetch current WA Web version — wppconnect tracker, then Baileys sw.js scrape. */
|
||||
async function resolveWaWebVersion(): Promise<[number, number, number]> {
|
||||
try {
|
||||
const res = await fetch('https://wppconnect.io/whatsapp-versions/', {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (res.ok) {
|
||||
const html = await res.text();
|
||||
const match = html.match(/2\.3000\.(\d+)/);
|
||||
if (match) return [2, 3000, Number(match[1])];
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
try {
|
||||
const { version } = await fetchLatestWaWebVersion({});
|
||||
if (version) return version as [number, number, number];
|
||||
} catch { /* fall through */ }
|
||||
throw new Error('Could not fetch current WhatsApp Web version — cannot connect with stale version');
|
||||
}
|
||||
|
||||
type AuthMethod = 'qr' | 'pairing-code';
|
||||
@@ -139,9 +135,7 @@ export async function run(args: string[]): Promise<void> {
|
||||
|
||||
async function connectSocket(isReconnect = false): Promise<void> {
|
||||
const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
|
||||
const { version } = await fetchLatestWaWebVersion({}).catch(() => ({
|
||||
version: undefined,
|
||||
}));
|
||||
const version = await resolveWaWebVersion();
|
||||
|
||||
const sock = makeWASocket({
|
||||
version,
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* DeltaChat channel adapter.
|
||||
*
|
||||
* Bridges NanoClaw with DeltaChat via the @deltachat/stdio-rpc-server JSON-RPC
|
||||
* process. Each DeltaChat chat becomes a separate NanoClaw messaging group
|
||||
* (platformId = chatId string, e.g. "12"). No thread model — supportsThreads: false.
|
||||
*
|
||||
* Required env vars (.env): DC_EMAIL, DC_PASSWORD,
|
||||
* DC_IMAP_HOST, DC_IMAP_PORT,
|
||||
* DC_SMTP_HOST, DC_SMTP_PORT
|
||||
* Optional env vars (.env): DC_IMAP_SECURITY (default: "1" = SSL/TLS),
|
||||
* DC_SMTP_SECURITY (default: "2" = STARTTLS)
|
||||
* Security values: 1=SSL/TLS, 2=STARTTLS, 3=plain
|
||||
* Optional env vars (service unit): DC_ACCOUNT_DIR (default: "dc-account"),
|
||||
* DC_DISPLAY_NAME, DC_AVATAR_PATH
|
||||
*/
|
||||
import { existsSync, mkdtempSync, writeFileSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { basename, join, resolve } from 'path';
|
||||
|
||||
import { getDb, hasTable } from '../db/connection.js';
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { log } from '../log.js';
|
||||
import type { ChannelAdapter, ChannelSetup, OutboundMessage } from './adapter.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
import { DeltaChatOverJsonRpc } from '@deltachat/stdio-rpc-server';
|
||||
|
||||
const REQUIRED_ENV = [
|
||||
'DC_EMAIL',
|
||||
'DC_PASSWORD',
|
||||
'DC_IMAP_HOST',
|
||||
'DC_IMAP_PORT',
|
||||
'DC_SMTP_HOST',
|
||||
'DC_SMTP_PORT',
|
||||
] as const;
|
||||
|
||||
const OPTIONAL_ENV = ['DC_IMAP_SECURITY', 'DC_SMTP_SECURITY'] as const;
|
||||
|
||||
type DcEnv = { [K in (typeof REQUIRED_ENV)[number]]: string } & { [K in (typeof OPTIONAL_ENV)[number]]?: string };
|
||||
|
||||
function isDcAdmin(userId: string): boolean {
|
||||
try {
|
||||
const db = getDb();
|
||||
if (!hasTable(db, 'user_roles')) return true;
|
||||
return (
|
||||
db
|
||||
.prepare(
|
||||
`SELECT 1 FROM user_roles
|
||||
WHERE user_id = ?
|
||||
AND (role = 'owner' OR role = 'admin')
|
||||
AND agent_group_id IS NULL
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(userId) != null
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createAdapter(env: DcEnv): ChannelAdapter {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let dc: any = null;
|
||||
let accountId = 0;
|
||||
let connectivity = 0;
|
||||
let lastImapIdleTs = Date.now();
|
||||
let consecutiveBadChecks = 0;
|
||||
let watchdogTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let networkTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async function restartIo(reason: string): Promise<void> {
|
||||
log.warn('DeltaChat: restarting IO', { reason });
|
||||
try {
|
||||
await dc.rpc.stopIo(accountId);
|
||||
await dc.rpc.startIo(accountId);
|
||||
lastImapIdleTs = Date.now();
|
||||
consecutiveBadChecks = 0;
|
||||
} catch (err) {
|
||||
log.error('DeltaChat: IO restart failed', { err });
|
||||
}
|
||||
}
|
||||
|
||||
const adapter: ChannelAdapter = {
|
||||
name: 'deltachat',
|
||||
channelType: 'deltachat',
|
||||
supportsThreads: false,
|
||||
|
||||
async setup(config: ChannelSetup): Promise<void> {
|
||||
const accountDir = process.env.DC_ACCOUNT_DIR ?? 'dc-account';
|
||||
dc = new DeltaChatOverJsonRpc(accountDir, {});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
dc.on('Error', (_: any, event: any) => log.error('DeltaChat RPC error', { msg: event.msg ?? event }));
|
||||
|
||||
const accounts = await dc.rpc.getAllAccounts();
|
||||
accountId = accounts[0]?.id;
|
||||
if (!accountId) accountId = await dc.rpc.addAccount();
|
||||
|
||||
const imapSecurity = env.DC_IMAP_SECURITY ?? '1';
|
||||
const smtpSecurity = env.DC_SMTP_SECURITY ?? '2';
|
||||
|
||||
if (!(await dc.rpc.isConfigured(accountId))) {
|
||||
await dc.rpc.setConfig(accountId, 'addr', env.DC_EMAIL);
|
||||
await dc.rpc.setConfig(accountId, 'mail_pw', env.DC_PASSWORD);
|
||||
await dc.rpc.setConfig(accountId, 'mail_server', env.DC_IMAP_HOST);
|
||||
await dc.rpc.setConfig(accountId, 'mail_port', env.DC_IMAP_PORT);
|
||||
await dc.rpc.setConfig(accountId, 'send_server', env.DC_SMTP_HOST);
|
||||
await dc.rpc.setConfig(accountId, 'send_port', env.DC_SMTP_PORT);
|
||||
await dc.rpc.configure(accountId);
|
||||
log.info('DeltaChat: account configured', { email: env.DC_EMAIL });
|
||||
} else {
|
||||
log.info('DeltaChat: account ready', { email: env.DC_EMAIL });
|
||||
}
|
||||
|
||||
await dc.rpc.setConfig(accountId, 'mail_security', imapSecurity);
|
||||
await dc.rpc.setConfig(accountId, 'send_security', smtpSecurity);
|
||||
await dc.rpc.setConfig(accountId, 'displayname', process.env.DC_DISPLAY_NAME ?? 'NanoClaw');
|
||||
const avatarPath = process.env.DC_AVATAR_PATH;
|
||||
if (avatarPath && existsSync(avatarPath)) {
|
||||
await dc.rpc.setConfig(accountId, 'selfavatar', avatarPath);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
dc.on('IncomingMsg', async (contextId: number, event: any) => {
|
||||
if (contextId !== accountId) return;
|
||||
try {
|
||||
let msg = await dc.rpc.getMessage(accountId, event.msgId);
|
||||
if (msg.isInfo) return;
|
||||
|
||||
// Wait for large-message download to complete
|
||||
if (msg.downloadState !== 'Done') {
|
||||
await dc.rpc.downloadFullMessage(accountId, event.msgId);
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
msg = await dc.rpc.getMessage(accountId, event.msgId);
|
||||
if (msg.downloadState === 'Done') break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!msg.text && !msg.file) return;
|
||||
|
||||
const contact = await dc.rpc.getContact(accountId, msg.fromId);
|
||||
const chat = await dc.rpc.getBasicChatInfo(accountId, event.chatId);
|
||||
|
||||
if (/^\/set-avatar$/i.test((msg.text || '').trim()) && msg.file) {
|
||||
const userId = `deltachat:${contact.address}`;
|
||||
try {
|
||||
if (isDcAdmin(userId)) {
|
||||
const absPath = resolve(msg.file as string);
|
||||
await dc.rpc.setConfig(accountId, 'selfavatar', absPath);
|
||||
await dc.rpc.sendMsg(accountId, event.chatId, { text: 'Avatar updated.' });
|
||||
} else {
|
||||
await dc.rpc.sendMsg(accountId, event.chatId, { text: 'Permission denied.' });
|
||||
}
|
||||
} catch (avatarErr: unknown) {
|
||||
log.error('DeltaChat: failed to set avatar', {
|
||||
err: avatarErr instanceof Error ? avatarErr.message : JSON.stringify(avatarErr),
|
||||
});
|
||||
await dc.rpc.sendMsg(accountId, event.chatId, { text: 'Failed to set avatar.' }).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const content: Record<string, unknown> = {
|
||||
text: msg.text || '',
|
||||
sender: contact.displayName || contact.address,
|
||||
senderId: contact.address,
|
||||
};
|
||||
if (msg.file) {
|
||||
content.attachments = [
|
||||
{
|
||||
name: basename(msg.file as string),
|
||||
type: 'file',
|
||||
localPath: msg.file,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const isGroup = chat?.isGroup ?? false;
|
||||
await config.onInbound(String(event.chatId), null, {
|
||||
id: String(event.msgId),
|
||||
kind: 'chat',
|
||||
content,
|
||||
timestamp: new Date().toISOString(),
|
||||
isGroup,
|
||||
isMention: !isGroup,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
log.error('DeltaChat: error handling incoming message', {
|
||||
err: err instanceof Error ? err.message : JSON.stringify(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
dc.on('ImapInboxIdle', (contextId: number) => {
|
||||
if (contextId === accountId) lastImapIdleTs = Date.now();
|
||||
});
|
||||
|
||||
dc.on('ConnectivityChanged', async (contextId: number) => {
|
||||
if (contextId !== accountId) return;
|
||||
try {
|
||||
connectivity = await dc.rpc.getConnectivity(accountId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
await dc.rpc.startIo(accountId);
|
||||
try {
|
||||
connectivity = await dc.rpc.getConnectivity(accountId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
log.info('DeltaChat: IO started', { email: env.DC_EMAIL });
|
||||
|
||||
// Log invite link on every startup so the operator can bootstrap the first contact.
|
||||
// In DeltaChat, contacts can't simply be added by email — the user must open this
|
||||
// https://i.delta.chat/ invite URL in their DeltaChat app (or scan invite-qr.svg) to initiate contact.
|
||||
try {
|
||||
// null chatId → Setup-Contact invite (not group-specific)
|
||||
const [inviteUrl, svg] = await dc.rpc.getChatSecurejoinQrCodeSvg(accountId, null);
|
||||
const accountDir = resolve(process.env.DC_ACCOUNT_DIR ?? 'dc-account');
|
||||
const svgPath = join(accountDir, 'invite-qr.svg');
|
||||
writeFileSync(svgPath, svg);
|
||||
log.info('DeltaChat: invite link — open URL in DeltaChat app or scan ' + svgPath, { url: inviteUrl });
|
||||
} catch (err: unknown) {
|
||||
log.warn('DeltaChat: could not generate invite link', {
|
||||
err: err instanceof Error ? err.message : JSON.stringify(err),
|
||||
});
|
||||
}
|
||||
|
||||
// Connectivity watchdog: restart IO if IMAP goes quiet or connectivity drops
|
||||
watchdogTimer = setInterval(
|
||||
async () => {
|
||||
try {
|
||||
const conn = await dc.rpc.getConnectivity(accountId);
|
||||
connectivity = conn;
|
||||
if (conn < 3000) {
|
||||
consecutiveBadChecks++;
|
||||
if (consecutiveBadChecks >= 2) {
|
||||
await restartIo(`connectivity=${conn} for 2 consecutive checks`);
|
||||
}
|
||||
} else {
|
||||
consecutiveBadChecks = 0;
|
||||
}
|
||||
const idleAgeMin = (Date.now() - lastImapIdleTs) / 60000;
|
||||
if (idleAgeMin > 20) {
|
||||
await restartIo(`no IMAP IDLE in ${idleAgeMin.toFixed(0)}min`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
log.warn('DeltaChat: watchdog error', {
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
|
||||
// Nudge the network stack every 10 minutes (recovers from prolonged idle)
|
||||
networkTimer = setInterval(
|
||||
async () => {
|
||||
try {
|
||||
await dc.rpc.maybeNetwork();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
10 * 60 * 1000,
|
||||
);
|
||||
},
|
||||
|
||||
async teardown(): Promise<void> {
|
||||
if (watchdogTimer) clearInterval(watchdogTimer);
|
||||
if (networkTimer) clearInterval(networkTimer);
|
||||
try {
|
||||
await dc?.rpc.stopIo(accountId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
dc?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
|
||||
isConnected(): boolean {
|
||||
// 4000 = fully connected (IMAP), 3000 = connecting; treat ≥3000 as live
|
||||
return connectivity >= 3000;
|
||||
},
|
||||
|
||||
async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise<string | undefined> {
|
||||
const chatId = parseInt(platformId, 10);
|
||||
if (isNaN(chatId)) {
|
||||
log.warn('DeltaChat: invalid platformId for delivery', { platformId });
|
||||
return undefined;
|
||||
}
|
||||
const content = message.content as Record<string, unknown>;
|
||||
const text = typeof content.text === 'string' ? content.text : '';
|
||||
|
||||
if (message.files && message.files.length > 0) {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'nanoclaw-dc-'));
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let firstId: any;
|
||||
for (let i = 0; i < message.files.length; i++) {
|
||||
const f = message.files[i];
|
||||
const tempPath = join(tempDir, f.filename);
|
||||
writeFileSync(tempPath, f.data);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const params: any = { file: tempPath };
|
||||
if (i === 0 && text) params.text = text;
|
||||
const sentId = await dc.rpc.sendMsg(accountId, chatId, params);
|
||||
if (i === 0) firstId = sentId;
|
||||
}
|
||||
return firstId != null ? String(firstId) : undefined;
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (!text) return undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sentId: any = await dc.rpc.sendMsg(accountId, chatId, { text });
|
||||
return sentId != null ? String(sentId) : undefined;
|
||||
},
|
||||
};
|
||||
|
||||
return adapter;
|
||||
}
|
||||
|
||||
registerChannelAdapter('deltachat', {
|
||||
factory: () => {
|
||||
const env = readEnvFile([...REQUIRED_ENV, ...OPTIONAL_ENV]);
|
||||
if (!env.DC_EMAIL || !env.DC_PASSWORD) return null;
|
||||
return createAdapter(env as DcEnv);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Discord channel adapter (v2) — uses Chat SDK bridge.
|
||||
* Self-registers on import.
|
||||
*/
|
||||
import { createDiscordAdapter } from '@chat-adapter/discord';
|
||||
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { createChatSdkBridge, type ReplyContext } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function extractReplyContext(raw: Record<string, any>): ReplyContext | null {
|
||||
if (!raw.referenced_message) return null;
|
||||
const reply = raw.referenced_message;
|
||||
return {
|
||||
text: reply.content || '',
|
||||
sender: reply.author?.global_name || reply.author?.username || 'Unknown',
|
||||
};
|
||||
}
|
||||
|
||||
registerChannelAdapter('discord', {
|
||||
factory: () => {
|
||||
const env = readEnvFile(['DISCORD_BOT_TOKEN', 'DISCORD_PUBLIC_KEY', 'DISCORD_APPLICATION_ID']);
|
||||
if (!env.DISCORD_BOT_TOKEN) return null;
|
||||
const discordAdapter = createDiscordAdapter({
|
||||
botToken: env.DISCORD_BOT_TOKEN,
|
||||
publicKey: env.DISCORD_PUBLIC_KEY,
|
||||
applicationId: env.DISCORD_APPLICATION_ID,
|
||||
});
|
||||
return createChatSdkBridge({
|
||||
adapter: discordAdapter,
|
||||
concurrency: 'concurrent',
|
||||
botToken: env.DISCORD_BOT_TOKEN,
|
||||
extractReplyContext,
|
||||
supportsThreads: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Tests for the v2 emacs channel adapter.
|
||||
*
|
||||
* Exercises the HTTP surface (POST /api/message, GET /api/messages) and
|
||||
* the ChannelAdapter lifecycle (setup / teardown / isConnected / deliver).
|
||||
*/
|
||||
import http from 'http';
|
||||
import type { AddressInfo } from 'net';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createEmacsAdapter } from './emacs.js';
|
||||
import type { ChannelAdapter, ChannelSetup } from './adapter.js';
|
||||
|
||||
vi.mock('../log.js', () => ({
|
||||
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
function makeSetup(overrides: Partial<ChannelSetup> = {}): ChannelSetup {
|
||||
return {
|
||||
onInbound: vi.fn(),
|
||||
onInboundEvent: vi.fn(),
|
||||
onMetadata: vi.fn(),
|
||||
onAction: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ask the OS for a free port, then immediately release it. Small race window
|
||||
* before the adapter grabs it, but sufficient for local test use. */
|
||||
async function getFreePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const srv = http.createServer();
|
||||
srv.once('error', reject);
|
||||
srv.listen(0, '127.0.0.1', () => {
|
||||
const port = (srv.address() as AddressInfo).port;
|
||||
srv.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function req(
|
||||
port: number,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: string,
|
||||
extraHeaders: Record<string, string> = {},
|
||||
): Promise<{ status: number; data: unknown }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json', ...extraHeaders };
|
||||
const request = http.request({ host: '127.0.0.1', port, method, path, headers }, (res) => {
|
||||
let raw = '';
|
||||
res.on('data', (chunk: Buffer) => (raw += chunk.toString()));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve({ status: res.statusCode!, data: JSON.parse(raw) });
|
||||
} catch {
|
||||
resolve({ status: res.statusCode!, data: raw });
|
||||
}
|
||||
});
|
||||
});
|
||||
request.on('error', reject);
|
||||
if (body) request.write(body);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
describe('emacs adapter', () => {
|
||||
let adapter: ChannelAdapter;
|
||||
let port: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
port = await getFreePort();
|
||||
adapter = createEmacsAdapter({ port, authToken: null, platformId: 'default' });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter.isConnected()) await adapter.teardown();
|
||||
});
|
||||
|
||||
describe('lifecycle', () => {
|
||||
it('isConnected is false before setup', () => {
|
||||
expect(adapter.isConnected()).toBe(false);
|
||||
});
|
||||
|
||||
it('isConnected is true after setup', async () => {
|
||||
await adapter.setup(makeSetup());
|
||||
expect(adapter.isConnected()).toBe(true);
|
||||
});
|
||||
|
||||
it('isConnected is false after teardown', async () => {
|
||||
await adapter.setup(makeSetup());
|
||||
await adapter.teardown();
|
||||
expect(adapter.isConnected()).toBe(false);
|
||||
});
|
||||
|
||||
it('teardown is a no-op before setup', async () => {
|
||||
await expect(adapter.teardown()).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('calls onMetadata after setup with channel name', async () => {
|
||||
const onMetadata = vi.fn();
|
||||
await adapter.setup(makeSetup({ onMetadata }));
|
||||
expect(onMetadata).toHaveBeenCalledWith('default', 'Emacs', false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/message', () => {
|
||||
let onInbound: ChannelSetup['onInbound'] & { mock: { calls: unknown[][] } };
|
||||
|
||||
beforeEach(async () => {
|
||||
onInbound = vi.fn() as unknown as typeof onInbound;
|
||||
await adapter.setup(makeSetup({ onInbound }));
|
||||
});
|
||||
|
||||
it('fires onInbound with chat kind and sender metadata', async () => {
|
||||
const { status, data } = await req(port, 'POST', '/api/message', JSON.stringify({ text: 'hello' }));
|
||||
expect(status).toBe(200);
|
||||
expect((data as { messageId: string }).messageId).toMatch(/^emacs-/);
|
||||
expect(onInbound).toHaveBeenCalledOnce();
|
||||
const [platformId, threadId, msg] = onInbound.mock.calls[0] as [string, string | null, { content: unknown }];
|
||||
expect(platformId).toBe('default');
|
||||
expect(threadId).toBeNull();
|
||||
expect(msg).toMatchObject({
|
||||
kind: 'chat',
|
||||
content: { text: 'hello', sender: 'Emacs', senderId: 'emacs:default' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 400 for empty text', async () => {
|
||||
const { status } = await req(port, 'POST', '/api/message', JSON.stringify({ text: '' }));
|
||||
expect(status).toBe(400);
|
||||
expect(onInbound).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 400 for whitespace-only text', async () => {
|
||||
const { status } = await req(port, 'POST', '/api/message', JSON.stringify({ text: ' ' }));
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 for invalid JSON', async () => {
|
||||
const { status } = await req(port, 'POST', '/api/message', 'not-json');
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 404 for unknown paths', async () => {
|
||||
const { status } = await req(port, 'POST', '/api/unknown', JSON.stringify({ text: 'hi' }));
|
||||
expect(status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/messages + deliver', () => {
|
||||
beforeEach(async () => {
|
||||
await adapter.setup(makeSetup());
|
||||
});
|
||||
|
||||
it('returns empty buffer initially', async () => {
|
||||
const { status, data } = await req(port, 'GET', '/api/messages?since=0');
|
||||
expect(status).toBe(200);
|
||||
expect(data).toEqual({ messages: [] });
|
||||
});
|
||||
|
||||
it('deliver pushes text for the poll endpoint to return', async () => {
|
||||
await adapter.deliver('default', null, { kind: 'chat', content: { text: 'reply' } });
|
||||
const { data } = await req(port, 'GET', '/api/messages?since=0');
|
||||
const messages = (data as { messages: { text: string; timestamp: number }[] }).messages;
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]?.text).toBe('reply');
|
||||
expect(typeof messages[0]?.timestamp).toBe('number');
|
||||
});
|
||||
|
||||
it('deliver accepts plain-string content', async () => {
|
||||
await adapter.deliver('default', null, { kind: 'chat', content: 'raw text' });
|
||||
const { data } = await req(port, 'GET', '/api/messages?since=0');
|
||||
expect((data as { messages: { text: string }[] }).messages[0]?.text).toBe('raw text');
|
||||
});
|
||||
|
||||
it('deliver skips empty text silently', async () => {
|
||||
await adapter.deliver('default', null, { kind: 'chat', content: { text: '' } });
|
||||
const { data } = await req(port, 'GET', '/api/messages?since=0');
|
||||
expect((data as { messages: unknown[] }).messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('deliver rejects unknown platformId', async () => {
|
||||
const result = await adapter.deliver('other', null, { kind: 'chat', content: { text: 'x' } });
|
||||
expect(result).toBeUndefined();
|
||||
const { data } = await req(port, 'GET', '/api/messages?since=0');
|
||||
expect((data as { messages: unknown[] }).messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('filters out messages at or before the since cutoff', async () => {
|
||||
await adapter.deliver('default', null, { kind: 'chat', content: { text: 'old' } });
|
||||
const since = Date.now();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
await adapter.deliver('default', null, { kind: 'chat', content: { text: 'new' } });
|
||||
const { data } = await req(port, 'GET', `/api/messages?since=${since}`);
|
||||
const texts = (data as { messages: { text: string }[] }).messages.map((m) => m.text);
|
||||
expect(texts).not.toContain('old');
|
||||
expect(texts).toContain('new');
|
||||
});
|
||||
|
||||
it('caps buffer at 200 messages, evicting the oldest', async () => {
|
||||
for (let i = 0; i < 205; i++) {
|
||||
await adapter.deliver('default', null, { kind: 'chat', content: { text: `m-${i}` } });
|
||||
}
|
||||
const { data } = await req(port, 'GET', '/api/messages?since=0');
|
||||
const messages = (data as { messages: { text: string }[] }).messages;
|
||||
expect(messages).toHaveLength(200);
|
||||
expect(messages.map((m) => m.text)).not.toContain('m-0');
|
||||
expect(messages.map((m) => m.text)).toContain('m-5');
|
||||
expect(messages.map((m) => m.text)).toContain('m-204');
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth', () => {
|
||||
let authAdapter: ChannelAdapter;
|
||||
let authPort: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
authPort = await getFreePort();
|
||||
authAdapter = createEmacsAdapter({ port: authPort, authToken: 'secret', platformId: 'default' });
|
||||
await authAdapter.setup(makeSetup());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (authAdapter.isConnected()) await authAdapter.teardown();
|
||||
});
|
||||
|
||||
it('rejects POST without Authorization header', async () => {
|
||||
const { status } = await req(authPort, 'POST', '/api/message', JSON.stringify({ text: 'hi' }));
|
||||
expect(status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects POST with wrong token', async () => {
|
||||
const { status } = await req(authPort, 'POST', '/api/message', JSON.stringify({ text: 'hi' }), {
|
||||
Authorization: 'Bearer wrong',
|
||||
});
|
||||
expect(status).toBe(401);
|
||||
});
|
||||
|
||||
it('accepts POST with correct Bearer token', async () => {
|
||||
const { status } = await req(authPort, 'POST', '/api/message', JSON.stringify({ text: 'hi' }), {
|
||||
Authorization: 'Bearer secret',
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
|
||||
it('rejects GET without Authorization header', async () => {
|
||||
const { status } = await req(authPort, 'GET', '/api/messages?since=0');
|
||||
expect(status).toBe(401);
|
||||
});
|
||||
|
||||
it('accepts GET with correct Bearer token', async () => {
|
||||
const { status } = await req(authPort, 'GET', '/api/messages?since=0', undefined, {
|
||||
Authorization: 'Bearer secret',
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Emacs channel adapter (v2) — native HTTP bridge.
|
||||
*
|
||||
* Stands up a localhost HTTP server that the nanoclaw.el client talks to:
|
||||
* - POST /api/message — user typed a message in Emacs; fire onInbound
|
||||
* - GET /api/messages?since=<ms> — Emacs polls for agent replies
|
||||
*
|
||||
* Single-user, single-chat: one adapter instance = one messaging group with
|
||||
* `platform_id = "default"` (override with EMACS_PLATFORM_ID). No threads,
|
||||
* no cold DM. Self-registers on import.
|
||||
*/
|
||||
import http from 'http';
|
||||
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { log } from '../log.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
import type { ChannelAdapter, ChannelSetup, InboundMessage, OutboundMessage } from './adapter.js';
|
||||
|
||||
const OUTBOUND_BUFFER_MAX = 200;
|
||||
|
||||
interface BufferedMessage {
|
||||
text: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface EmacsAdapterOptions {
|
||||
port: number;
|
||||
authToken: string | null;
|
||||
platformId: string;
|
||||
}
|
||||
|
||||
function createEmacsAdapter(opts: EmacsAdapterOptions): ChannelAdapter {
|
||||
let server: http.Server | null = null;
|
||||
let setupConfig: ChannelSetup | null = null;
|
||||
const outboundBuffer: BufferedMessage[] = [];
|
||||
|
||||
function checkAuth(req: http.IncomingMessage, res: http.ServerResponse): boolean {
|
||||
if (!opts.authToken) return true;
|
||||
if (req.headers['authorization'] === `Bearer ${opts.authToken}`) return true;
|
||||
res
|
||||
.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' })
|
||||
.end(JSON.stringify({ error: 'Unauthorized' }));
|
||||
return false;
|
||||
}
|
||||
|
||||
function handlePost(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => (body += chunk));
|
||||
req.on('end', () => {
|
||||
let text: string;
|
||||
try {
|
||||
const parsed = JSON.parse(body) as { text?: string };
|
||||
text = parsed.text ?? '';
|
||||
} catch {
|
||||
res
|
||||
.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' })
|
||||
.end(JSON.stringify({ error: 'Invalid JSON' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!text.trim()) {
|
||||
res
|
||||
.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' })
|
||||
.end(JSON.stringify({ error: 'text required' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const id = `emacs-${Date.now()}`;
|
||||
|
||||
const inbound: InboundMessage = {
|
||||
id,
|
||||
kind: 'chat',
|
||||
content: {
|
||||
text,
|
||||
sender: 'Emacs',
|
||||
senderId: `emacs:${opts.platformId}`,
|
||||
},
|
||||
timestamp,
|
||||
};
|
||||
|
||||
try {
|
||||
setupConfig?.onInbound(opts.platformId, null, inbound);
|
||||
} catch (err) {
|
||||
log.error('Emacs onInbound failed', { err });
|
||||
}
|
||||
|
||||
res
|
||||
.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' })
|
||||
.end(JSON.stringify({ messageId: id, timestamp: Date.now() }));
|
||||
});
|
||||
}
|
||||
|
||||
function handlePoll(url: URL, res: http.ServerResponse): void {
|
||||
const since = parseInt(url.searchParams.get('since') ?? '0', 10);
|
||||
const messages = outboundBuffer.filter((m) => m.timestamp > since);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }).end(JSON.stringify({ messages }));
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'emacs',
|
||||
channelType: 'emacs',
|
||||
supportsThreads: false,
|
||||
|
||||
async setup(config: ChannelSetup): Promise<void> {
|
||||
setupConfig = config;
|
||||
|
||||
server = http.createServer((req, res) => {
|
||||
if (!checkAuth(req, res)) return;
|
||||
|
||||
const url = new URL(req.url ?? '/', `http://localhost:${opts.port}`);
|
||||
if (req.method === 'POST' && url.pathname === '/api/message') {
|
||||
handlePost(req, res);
|
||||
} else if (req.method === 'GET' && url.pathname === '/api/messages') {
|
||||
handlePoll(url, res);
|
||||
} else {
|
||||
res
|
||||
.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' })
|
||||
.end(JSON.stringify({ error: 'Not found' }));
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server!.once('error', reject);
|
||||
server!.listen(opts.port, '127.0.0.1', () => {
|
||||
log.info('Emacs channel listening', { port: opts.port, platformId: opts.platformId });
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Stamp a human-readable name on the messaging_groups row on first boot.
|
||||
config.onMetadata(opts.platformId, 'Emacs', false);
|
||||
},
|
||||
|
||||
async teardown(): Promise<void> {
|
||||
if (!server) return;
|
||||
await new Promise<void>((resolve) => server!.close(() => resolve()));
|
||||
server = null;
|
||||
log.info('Emacs channel stopped');
|
||||
},
|
||||
|
||||
isConnected(): boolean {
|
||||
return server?.listening ?? false;
|
||||
},
|
||||
|
||||
async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise<string | undefined> {
|
||||
if (platformId !== opts.platformId) {
|
||||
log.warn('Emacs deliver called with unknown platformId', { platformId });
|
||||
return undefined;
|
||||
}
|
||||
const text = extractText(message.content);
|
||||
if (!text) return undefined;
|
||||
|
||||
const id = `emacs-out-${Date.now()}`;
|
||||
outboundBuffer.push({ text, timestamp: Date.now() });
|
||||
while (outboundBuffer.length > OUTBOUND_BUFFER_MAX) outboundBuffer.shift();
|
||||
return id;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function extractText(content: unknown): string {
|
||||
if (typeof content === 'string') return content;
|
||||
if (content && typeof content === 'object') {
|
||||
const c = content as { text?: unknown };
|
||||
if (typeof c.text === 'string') return c.text;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
registerChannelAdapter('emacs', {
|
||||
factory: () => {
|
||||
const env = readEnvFile(['EMACS_ENABLED', 'EMACS_CHANNEL_PORT', 'EMACS_AUTH_TOKEN', 'EMACS_PLATFORM_ID']);
|
||||
const enabled = process.env.EMACS_ENABLED || env.EMACS_ENABLED;
|
||||
if (!enabled || enabled === 'false') return null;
|
||||
|
||||
const portStr = process.env.EMACS_CHANNEL_PORT || env.EMACS_CHANNEL_PORT || '8766';
|
||||
const port = parseInt(portStr, 10);
|
||||
const authToken = process.env.EMACS_AUTH_TOKEN || env.EMACS_AUTH_TOKEN || null;
|
||||
const platformId = process.env.EMACS_PLATFORM_ID || env.EMACS_PLATFORM_ID || 'default';
|
||||
|
||||
return createEmacsAdapter({ port, authToken, platformId });
|
||||
},
|
||||
});
|
||||
|
||||
export { createEmacsAdapter };
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Google Chat channel adapter (v2) — uses Chat SDK bridge.
|
||||
* Self-registers on import.
|
||||
*/
|
||||
import { createGoogleChatAdapter } from '@chat-adapter/gchat';
|
||||
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { createChatSdkBridge } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
registerChannelAdapter('gchat', {
|
||||
factory: () => {
|
||||
const env = readEnvFile(['GCHAT_CREDENTIALS']);
|
||||
if (!env.GCHAT_CREDENTIALS) return null;
|
||||
const gchatAdapter = createGoogleChatAdapter({
|
||||
credentials: JSON.parse(env.GCHAT_CREDENTIALS),
|
||||
});
|
||||
return createChatSdkBridge({ adapter: gchatAdapter, concurrency: 'concurrent', supportsThreads: true });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* GitHub channel adapter (v2) — uses Chat SDK bridge.
|
||||
* PR comment threads as conversations.
|
||||
* Self-registers on import.
|
||||
*/
|
||||
import { createGitHubAdapter } from '@chat-adapter/github';
|
||||
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { createChatSdkBridge } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
registerChannelAdapter('github', {
|
||||
factory: () => {
|
||||
const env = readEnvFile(['GITHUB_TOKEN', 'GITHUB_WEBHOOK_SECRET', 'GITHUB_BOT_USERNAME']);
|
||||
if (!env.GITHUB_TOKEN) return null;
|
||||
const githubAdapter = createGitHubAdapter({
|
||||
token: env.GITHUB_TOKEN,
|
||||
webhookSecret: env.GITHUB_WEBHOOK_SECRET,
|
||||
userName: env.GITHUB_BOT_USERNAME,
|
||||
});
|
||||
return createChatSdkBridge({ adapter: githubAdapter, concurrency: 'queue', supportsThreads: true });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* iMessage channel adapter (v2) — uses Chat SDK bridge.
|
||||
* Supports local mode (macOS Full Disk Access) and remote mode (Photon API).
|
||||
* Self-registers on import.
|
||||
*/
|
||||
import { createiMessageAdapter } from 'chat-adapter-imessage';
|
||||
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { createChatSdkBridge } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
registerChannelAdapter('imessage', {
|
||||
factory: () => {
|
||||
const env = readEnvFile(['IMESSAGE_ENABLED', 'IMESSAGE_LOCAL', 'IMESSAGE_SERVER_URL', 'IMESSAGE_API_KEY']);
|
||||
const isLocal = env.IMESSAGE_LOCAL !== 'false';
|
||||
if (isLocal && !env.IMESSAGE_ENABLED) return null;
|
||||
if (!isLocal && !env.IMESSAGE_SERVER_URL) return null;
|
||||
const rawAdapter = createiMessageAdapter({
|
||||
local: isLocal,
|
||||
serverUrl: env.IMESSAGE_SERVER_URL,
|
||||
apiKey: env.IMESSAGE_API_KEY,
|
||||
});
|
||||
// Polyfill channelIdFromThreadId (community adapter doesn't implement it)
|
||||
const imessageAdapter = Object.assign(rawAdapter, {
|
||||
channelIdFromThreadId: (threadId: string) => threadId,
|
||||
});
|
||||
return createChatSdkBridge({ adapter: imessageAdapter, concurrency: 'concurrent', supportsThreads: false });
|
||||
},
|
||||
});
|
||||
+55
-4
@@ -1,9 +1,60 @@
|
||||
// Channel self-registration barrel.
|
||||
// Each import triggers the channel module's registerChannelAdapter() call.
|
||||
//
|
||||
// Main ships with one default channel — `cli`, the always-on local-terminal
|
||||
// channel. Other channel skills (/add-slack, /add-discord, /add-whatsapp,
|
||||
// ...) copy their module from the `channels` branch and append a
|
||||
// self-registration import below.
|
||||
// The `channels` branch keeps this file fully populated — it's the
|
||||
// fully-loaded, runnable branch. Individual `/add-<channel>` skills pull
|
||||
// single files from this branch onto a user's install, appending their
|
||||
// own import lines to a leaner barrel on main.
|
||||
|
||||
// cli — default channel that ships with main (always on, no credentials).
|
||||
import './cli.js';
|
||||
|
||||
// discord
|
||||
import './discord.js';
|
||||
|
||||
// slack
|
||||
// import './slack.js';
|
||||
|
||||
// telegram
|
||||
import './telegram.js';
|
||||
|
||||
// github
|
||||
// import './github.js';
|
||||
|
||||
// linear
|
||||
import './linear.js';
|
||||
|
||||
// google chat
|
||||
// import './gchat.js';
|
||||
|
||||
// microsoft teams
|
||||
// import './teams.js';
|
||||
|
||||
// whatsapp cloud api
|
||||
// import './whatsapp-cloud.js';
|
||||
|
||||
// resend (email)
|
||||
// import './resend.js';
|
||||
|
||||
// matrix
|
||||
// import './matrix.js';
|
||||
|
||||
// webex
|
||||
// import './webex.js';
|
||||
|
||||
// imessage
|
||||
import './imessage.js';
|
||||
|
||||
// gmail (native, no Chat SDK)
|
||||
|
||||
// whatsapp (native, no Chat SDK)
|
||||
import './whatsapp.js';
|
||||
|
||||
// signal (native, no Chat SDK — signal-cli TCP JSON-RPC daemon)
|
||||
// import './signal.js';
|
||||
|
||||
// emacs (native HTTP bridge, no Chat SDK)
|
||||
// import './emacs.js';
|
||||
|
||||
// deltachat (native, no Chat SDK)
|
||||
// import './deltachat.js'
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Linear channel adapter (v2) — uses Chat SDK bridge.
|
||||
* Issue comment threads as conversations.
|
||||
* Self-registers on import.
|
||||
*
|
||||
* Linear OAuth apps can't be @-mentioned, so this adapter relies on the
|
||||
* bridge's default onNewMessage catch-all to forward every comment.
|
||||
*/
|
||||
import { createLinearAdapter } from '@chat-adapter/linear';
|
||||
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { createChatSdkBridge } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
registerChannelAdapter('linear', {
|
||||
factory: () => {
|
||||
const env = readEnvFile([
|
||||
'LINEAR_API_KEY',
|
||||
'LINEAR_CLIENT_ID',
|
||||
'LINEAR_CLIENT_SECRET',
|
||||
'LINEAR_WEBHOOK_SECRET',
|
||||
'LINEAR_BOT_USERNAME',
|
||||
'LINEAR_TEAM_KEY',
|
||||
]);
|
||||
if (!env.LINEAR_API_KEY && !env.LINEAR_CLIENT_ID) return null;
|
||||
|
||||
const auth = env.LINEAR_CLIENT_ID
|
||||
? { clientId: env.LINEAR_CLIENT_ID, clientSecret: env.LINEAR_CLIENT_SECRET }
|
||||
: { apiKey: env.LINEAR_API_KEY };
|
||||
|
||||
const linearAdapter = createLinearAdapter({
|
||||
...auth,
|
||||
webhookSecret: env.LINEAR_WEBHOOK_SECRET,
|
||||
userName: env.LINEAR_BOT_USERNAME,
|
||||
});
|
||||
|
||||
// Override channelIdFromThreadId to return a team-based channel ID.
|
||||
// The upstream adapter returns per-issue UUIDs which creates a new
|
||||
// messaging group for every issue. We want one group per team.
|
||||
const teamKey = env.LINEAR_TEAM_KEY || 'default';
|
||||
linearAdapter.channelIdFromThreadId = () => `linear:${teamKey}`;
|
||||
|
||||
return createChatSdkBridge({ adapter: linearAdapter, concurrency: 'queue', supportsThreads: true });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Matrix channel adapter (v2) — uses Chat SDK bridge.
|
||||
* Self-registers on import.
|
||||
*
|
||||
* Supports two auth methods (resolved by the adapter from env):
|
||||
* - Access token: MATRIX_ACCESS_TOKEN + MATRIX_USER_ID
|
||||
* - Password: MATRIX_USERNAME + MATRIX_PASSWORD (+ optional MATRIX_USER_ID)
|
||||
*
|
||||
* Optional env vars:
|
||||
* MATRIX_BOT_USERNAME — display name for the bot (default: "bot")
|
||||
* MATRIX_INVITE_AUTOJOIN — "true" to auto-accept room invites
|
||||
* MATRIX_INVITE_AUTOJOIN_ALLOWLIST — comma-separated user IDs allowed to invite
|
||||
* MATRIX_RECOVERY_KEY — enable E2EE cross-signing
|
||||
* MATRIX_DEVICE_ID — stable device ID across restarts
|
||||
*/
|
||||
import { createMatrixAdapter } from '@beeper/chat-adapter-matrix';
|
||||
|
||||
import { log } from '../log.js';
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { createChatSdkBridge } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
const ENV_KEYS = [
|
||||
'MATRIX_BASE_URL',
|
||||
'MATRIX_ACCESS_TOKEN',
|
||||
'MATRIX_USERNAME',
|
||||
'MATRIX_PASSWORD',
|
||||
'MATRIX_USER_ID',
|
||||
'MATRIX_BOT_USERNAME',
|
||||
'MATRIX_DEVICE_ID',
|
||||
'MATRIX_RECOVERY_KEY',
|
||||
'MATRIX_INVITE_AUTOJOIN',
|
||||
'MATRIX_INVITE_AUTOJOIN_ALLOWLIST',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Wrap the Matrix adapter so DM conversations are identified by user handle
|
||||
* across the whole system, not by ephemeral room IDs.
|
||||
*
|
||||
* Matrix DMs live in rooms (e.g. "!abc:server"), but NanoClaw identifies
|
||||
* channels by platform_id. Using a user handle as platform_id means both
|
||||
* the user and the messaging group reference the same stable identifier.
|
||||
*
|
||||
* Two directions to bridge:
|
||||
* - Outbound: delivery passes "matrix:@user:server" → resolve to room via openDM
|
||||
* - Inbound: adapter emits "matrix:!room:server" → rewrite to user handle
|
||||
* so the router finds the existing messaging group instead of creating
|
||||
* a new one.
|
||||
*
|
||||
* Both resolutions are cached for the process lifetime.
|
||||
*/
|
||||
function wrapWithDmResolution(adapter: ReturnType<typeof createMatrixAdapter>): typeof adapter {
|
||||
const origPostMessage = adapter.postMessage.bind(adapter);
|
||||
const origStartTyping = adapter.startTyping.bind(adapter);
|
||||
const origChannelIdFromThreadId = adapter.channelIdFromThreadId.bind(adapter);
|
||||
|
||||
// roomId → user handle, used to rewrite inbound channel IDs.
|
||||
const roomToUserCache = new Map<string, string>();
|
||||
|
||||
function isUserHandle(threadId: string): boolean {
|
||||
try {
|
||||
const { roomID } = adapter.decodeThreadId(threadId);
|
||||
return !roomID.startsWith('!');
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveThreadId(threadId: string): Promise<string> {
|
||||
if (!isUserHandle(threadId)) return threadId;
|
||||
|
||||
const userHandle = threadId.startsWith('matrix:') ? threadId.slice('matrix:'.length) : threadId;
|
||||
log.info('Matrix: resolving DM room for user handle', { userHandle });
|
||||
const resolved = await adapter.openDM(userHandle);
|
||||
|
||||
try {
|
||||
const { roomID } = adapter.decodeThreadId(resolved);
|
||||
roomToUserCache.set(roomID, userHandle);
|
||||
} catch {
|
||||
// decode failure is non-fatal — outbound still works
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Rewrite inbound room-based channel IDs to user-handle form for DM rooms.
|
||||
// Non-DM rooms pass through unchanged.
|
||||
adapter.channelIdFromThreadId = (threadId: string): string => {
|
||||
try {
|
||||
const { roomID } = adapter.decodeThreadId(threadId);
|
||||
if (!roomID.startsWith('!')) return origChannelIdFromThreadId(threadId);
|
||||
|
||||
const cached = roomToUserCache.get(roomID);
|
||||
if (cached) return `matrix:${cached}`;
|
||||
|
||||
// Not cached — check if this is a DM by membership count
|
||||
const client = (adapter as any).client;
|
||||
const room = client?.getRoom(roomID);
|
||||
if (!room) return origChannelIdFromThreadId(threadId);
|
||||
if (room.getJoinedMemberCount() > 2) return origChannelIdFromThreadId(threadId);
|
||||
|
||||
const botId = (adapter as any).userID;
|
||||
const otherMember = room.getJoinedMembers().find((m: { userId: string }) => m.userId !== botId);
|
||||
if (!otherMember) return origChannelIdFromThreadId(threadId);
|
||||
|
||||
roomToUserCache.set(roomID, otherMember.userId);
|
||||
return `matrix:${otherMember.userId}`;
|
||||
} catch {
|
||||
return origChannelIdFromThreadId(threadId);
|
||||
}
|
||||
};
|
||||
|
||||
// The Chat SDK calls adapter.isDM(threadId) synchronously to decide whether
|
||||
// to dispatch to onDirectMessage handlers. The Matrix adapter doesn't expose
|
||||
// this method — it only has an async isDirectRoom(). We add a synchronous
|
||||
// isDM that checks room membership count: 2 members = DM.
|
||||
(adapter as any).isDM = (threadId: string): boolean => {
|
||||
try {
|
||||
const { roomID } = adapter.decodeThreadId(threadId);
|
||||
const client = (adapter as any).client;
|
||||
if (!client) return false;
|
||||
const room = client.getRoom(roomID);
|
||||
if (!room) return false;
|
||||
const members = room.getJoinedMemberCount();
|
||||
return members <= 2;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
adapter.postMessage = async (
|
||||
threadId: string,
|
||||
...args: Parameters<typeof origPostMessage> extends [string, ...infer R] ? R : never
|
||||
) => {
|
||||
const resolvedTid = await resolveThreadId(threadId);
|
||||
return origPostMessage(resolvedTid, ...args);
|
||||
};
|
||||
|
||||
adapter.startTyping = async (threadId: string) => {
|
||||
const resolvedTid = await resolveThreadId(threadId);
|
||||
return origStartTyping(resolvedTid);
|
||||
};
|
||||
|
||||
return adapter;
|
||||
}
|
||||
|
||||
registerChannelAdapter('matrix', {
|
||||
factory: () => {
|
||||
const env = readEnvFile([...ENV_KEYS]);
|
||||
if (!env.MATRIX_BASE_URL) return null;
|
||||
if (!env.MATRIX_ACCESS_TOKEN && !(env.MATRIX_USERNAME && env.MATRIX_PASSWORD)) return null;
|
||||
|
||||
for (const key of ENV_KEYS) {
|
||||
if (env[key]) process.env[key] = env[key];
|
||||
}
|
||||
|
||||
// Default: auto-join room invites so DMs work without manual acceptance
|
||||
if (!process.env.MATRIX_INVITE_AUTOJOIN) {
|
||||
process.env.MATRIX_INVITE_AUTOJOIN = 'true';
|
||||
}
|
||||
|
||||
const matrixAdapter = wrapWithDmResolution(createMatrixAdapter());
|
||||
const bridge = createChatSdkBridge({ adapter: matrixAdapter, concurrency: 'concurrent', supportsThreads: false });
|
||||
|
||||
// Matrix user IDs contain ":" (e.g. "@user:matrix.org") which the shared
|
||||
// permissions module interprets as already-prefixed. Wrap onInbound to
|
||||
// ensure senderId always carries the "matrix:" channel prefix so user
|
||||
// records match between init-first-agent and inbound routing.
|
||||
const origSetup = bridge.setup.bind(bridge);
|
||||
bridge.setup = async (hostConfig) => {
|
||||
const origOnInbound = hostConfig.onInbound.bind(hostConfig);
|
||||
await origSetup({
|
||||
...hostConfig,
|
||||
onInbound: (platformId, threadId, message) => {
|
||||
if (message.content && typeof message.content === 'object') {
|
||||
const content = message.content as Record<string, unknown>;
|
||||
if (typeof content.senderId === 'string' && !content.senderId.startsWith('matrix:')) {
|
||||
content.senderId = `matrix:${content.senderId}`;
|
||||
}
|
||||
}
|
||||
return origOnInbound(platformId, threadId, message);
|
||||
},
|
||||
});
|
||||
|
||||
// Wait for Matrix sync to reach PREPARED state before returning from setup.
|
||||
// Without this, the host's delivery poll and sweep timer start immediately
|
||||
// and can starve the SDK's sync generator microtask queue, blocking
|
||||
// incremental syncs so new inbound messages never get dispatched.
|
||||
await new Promise<void>((resolve) => {
|
||||
const check = setInterval(() => {
|
||||
if ((matrixAdapter as unknown as { liveSyncReady?: boolean }).liveSyncReady) {
|
||||
log.info('Matrix sync ready');
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}
|
||||
}, 500);
|
||||
setTimeout(() => {
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}, 30_000);
|
||||
});
|
||||
};
|
||||
|
||||
return bridge;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Resend (email) channel adapter (v2) — uses Chat SDK bridge.
|
||||
* Self-registers on import.
|
||||
*/
|
||||
import { createResendAdapter } from '@resend/chat-sdk-adapter';
|
||||
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { createChatSdkBridge } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
registerChannelAdapter('resend', {
|
||||
factory: () => {
|
||||
const env = readEnvFile(['RESEND_API_KEY', 'RESEND_FROM_ADDRESS', 'RESEND_FROM_NAME', 'RESEND_WEBHOOK_SECRET']);
|
||||
if (!env.RESEND_API_KEY) return null;
|
||||
const resendAdapter = createResendAdapter({
|
||||
apiKey: env.RESEND_API_KEY,
|
||||
fromAddress: env.RESEND_FROM_ADDRESS,
|
||||
fromName: env.RESEND_FROM_NAME,
|
||||
webhookSecret: env.RESEND_WEBHOOK_SECRET,
|
||||
});
|
||||
return createChatSdkBridge({ adapter: resendAdapter, concurrency: 'queue', supportsThreads: false });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,961 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
vi.mock('./channel-registry.js', () => ({ registerChannelAdapter: vi.fn() }));
|
||||
vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})) }));
|
||||
vi.mock('../log.js', () => ({
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
spawn: vi.fn(),
|
||||
execFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
// --- TCP socket mock ---
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
const tcpRef = vi.hoisted(() => ({
|
||||
rpcResponses: new Map<string, unknown>(),
|
||||
fakeSocket: null as any,
|
||||
}));
|
||||
|
||||
function createFakeSocket(): EventEmitter & {
|
||||
write: ReturnType<typeof vi.fn>;
|
||||
destroy: ReturnType<typeof vi.fn>;
|
||||
destroyed: boolean;
|
||||
} {
|
||||
const sock = new EventEmitter() as any;
|
||||
sock.destroyed = false;
|
||||
sock.destroy = vi.fn(() => {
|
||||
sock.destroyed = true;
|
||||
sock.emit('close');
|
||||
});
|
||||
sock.write = vi.fn((data: string) => {
|
||||
try {
|
||||
const req = JSON.parse(data.trim());
|
||||
const result = tcpRef.rpcResponses.get(req.method) ?? { ok: true };
|
||||
const response = JSON.stringify({ jsonrpc: '2.0', id: req.id, result }) + '\n';
|
||||
setImmediate(() => sock.emit('data', Buffer.from(response)));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
return sock;
|
||||
}
|
||||
|
||||
vi.mock('node:net', () => ({
|
||||
createConnection: vi.fn((_port: number, _host: string, cb?: () => void) => {
|
||||
const sock = createFakeSocket();
|
||||
tcpRef.fakeSocket = sock;
|
||||
if (cb) setImmediate(cb);
|
||||
return sock;
|
||||
}),
|
||||
}));
|
||||
|
||||
import type { ChannelSetup } from './adapter.js';
|
||||
import { createSignalAdapter } from './signal.js';
|
||||
|
||||
// --- Test helpers ---
|
||||
|
||||
function createMockSetup() {
|
||||
return {
|
||||
onInbound: vi.fn() as unknown as ChannelSetup['onInbound'] & ReturnType<typeof vi.fn>,
|
||||
onInboundEvent: vi.fn() as unknown as ChannelSetup['onInboundEvent'] & ReturnType<typeof vi.fn>,
|
||||
onMetadata: vi.fn() as unknown as ChannelSetup['onMetadata'] & ReturnType<typeof vi.fn>,
|
||||
onAction: vi.fn() as unknown as ChannelSetup['onAction'] & ReturnType<typeof vi.fn>,
|
||||
};
|
||||
}
|
||||
|
||||
function createAdapter() {
|
||||
return createSignalAdapter({
|
||||
cliPath: 'signal-cli',
|
||||
account: '+15551234567',
|
||||
tcpHost: '127.0.0.1',
|
||||
tcpPort: 7583,
|
||||
manageDaemon: false,
|
||||
signalDataDir: '/tmp/signal-cli-test-data',
|
||||
});
|
||||
}
|
||||
|
||||
function getRpcCalls(): Array<{
|
||||
method: string;
|
||||
params: Record<string, unknown>;
|
||||
id: string;
|
||||
}> {
|
||||
if (!tcpRef.fakeSocket) return [];
|
||||
return tcpRef.fakeSocket.write.mock.calls
|
||||
.map((c: any[]) => {
|
||||
try {
|
||||
return JSON.parse(c[0].trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function getRpcCallsForMethod(method: string) {
|
||||
return getRpcCalls().filter((c) => c.method === method);
|
||||
}
|
||||
|
||||
function pushEvent(envelope: Record<string, unknown>) {
|
||||
if (!tcpRef.fakeSocket) throw new Error('TCP socket not connected');
|
||||
const notification =
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'receive',
|
||||
params: { envelope },
|
||||
}) + '\n';
|
||||
tcpRef.fakeSocket.emit('data', Buffer.from(notification));
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe('SignalAdapter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
tcpRef.rpcResponses.clear();
|
||||
tcpRef.fakeSocket = null;
|
||||
tcpRef.rpcResponses.set('send', { timestamp: 1234567890 });
|
||||
tcpRef.rpcResponses.set('sendTyping', {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
tcpRef.fakeSocket?.destroy();
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
});
|
||||
|
||||
// --- Connection lifecycle ---
|
||||
|
||||
describe('connection lifecycle', () => {
|
||||
it('connects when daemon is reachable', async () => {
|
||||
const adapter = createAdapter();
|
||||
const cfg = createMockSetup();
|
||||
await adapter.setup(cfg);
|
||||
|
||||
expect(adapter.isConnected()).toBe(true);
|
||||
expect(tcpRef.fakeSocket).not.toBeNull();
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('isConnected() returns false before setup', () => {
|
||||
const adapter = createAdapter();
|
||||
expect(adapter.isConnected()).toBe(false);
|
||||
});
|
||||
|
||||
it('disconnects cleanly', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
expect(adapter.isConnected()).toBe(true);
|
||||
|
||||
await adapter.teardown();
|
||||
expect(adapter.isConnected()).toBe(false);
|
||||
});
|
||||
|
||||
it('throws NetworkError if daemon is unreachable', async () => {
|
||||
const { createConnection } = await import('node:net');
|
||||
vi.mocked(createConnection).mockImplementationOnce((...args: any[]) => {
|
||||
const sock = createFakeSocket();
|
||||
setImmediate(() => sock.emit('error', new Error('Connection refused')));
|
||||
return sock as any;
|
||||
});
|
||||
|
||||
const adapter = createAdapter();
|
||||
await expect(adapter.setup(createMockSetup())).rejects.toThrow(/not reachable/);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Inbound message handling ---
|
||||
|
||||
describe('inbound message handling', () => {
|
||||
it('delivers DM via onInbound', async () => {
|
||||
const adapter = createAdapter();
|
||||
const cfg = createMockSetup();
|
||||
await adapter.setup(cfg);
|
||||
|
||||
pushEvent({
|
||||
sourceNumber: '+15555550123',
|
||||
sourceName: 'Alice',
|
||||
dataMessage: {
|
||||
timestamp: 1700000000000,
|
||||
message: 'Hello from Signal',
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
expect(cfg.onMetadata).toHaveBeenCalledWith('+15555550123', 'Alice', false);
|
||||
expect(cfg.onInbound).toHaveBeenCalledWith(
|
||||
'+15555550123',
|
||||
null,
|
||||
expect.objectContaining({
|
||||
id: '1700000000000',
|
||||
kind: 'chat',
|
||||
content: expect.objectContaining({
|
||||
text: 'Hello from Signal',
|
||||
sender: '+15555550123',
|
||||
senderName: 'Alice',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('delivers group message with group platformId', async () => {
|
||||
const adapter = createAdapter();
|
||||
const cfg = createMockSetup();
|
||||
await adapter.setup(cfg);
|
||||
|
||||
pushEvent({
|
||||
sourceNumber: '+15555550999',
|
||||
sourceName: 'Bob',
|
||||
dataMessage: {
|
||||
timestamp: 1700000000000,
|
||||
message: 'Group hello',
|
||||
groupInfo: { groupId: 'abc123', groupName: 'Family' },
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
expect(cfg.onMetadata).toHaveBeenCalledWith('group:abc123', 'Family', true);
|
||||
expect(cfg.onInbound).toHaveBeenCalledWith(
|
||||
'group:abc123',
|
||||
null,
|
||||
expect.objectContaining({
|
||||
content: expect.objectContaining({
|
||||
text: 'Group hello',
|
||||
sender: '+15555550999',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('skips sync messages (own outbound)', async () => {
|
||||
const adapter = createAdapter();
|
||||
const cfg = createMockSetup();
|
||||
await adapter.setup(cfg);
|
||||
|
||||
pushEvent({
|
||||
sourceNumber: '+15551234567',
|
||||
syncMessage: {
|
||||
sentMessage: {
|
||||
timestamp: 1700000000000,
|
||||
message: 'My own message',
|
||||
destination: '+15555550123',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(cfg.onInbound).not.toHaveBeenCalled();
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('processes Note to Self sync messages as inbound', async () => {
|
||||
const adapter = createAdapter();
|
||||
const cfg = createMockSetup();
|
||||
await adapter.setup(cfg);
|
||||
|
||||
pushEvent({
|
||||
sourceNumber: '+15551234567',
|
||||
syncMessage: {
|
||||
sentMessage: {
|
||||
timestamp: 1700000000000,
|
||||
message: 'Hello Bee',
|
||||
destinationNumber: '+15551234567',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(cfg.onInbound).toHaveBeenCalledWith(
|
||||
'+15551234567',
|
||||
null,
|
||||
expect.objectContaining({
|
||||
content: expect.objectContaining({
|
||||
text: 'Hello Bee',
|
||||
senderName: 'Me',
|
||||
isFromMe: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('skips empty messages', async () => {
|
||||
const adapter = createAdapter();
|
||||
const cfg = createMockSetup();
|
||||
await adapter.setup(cfg);
|
||||
|
||||
pushEvent({
|
||||
sourceNumber: '+15555550123',
|
||||
dataMessage: { timestamp: 1700000000000, message: ' ' },
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(cfg.onInbound).not.toHaveBeenCalled();
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('skips echoed outbound messages', async () => {
|
||||
const adapter = createAdapter();
|
||||
const cfg = createMockSetup();
|
||||
await adapter.setup(cfg);
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'text',
|
||||
content: { text: 'Echo test' },
|
||||
});
|
||||
|
||||
pushEvent({
|
||||
sourceNumber: '+15555550123',
|
||||
dataMessage: { timestamp: 1700000000000, message: 'Echo test' },
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(cfg.onInbound).not.toHaveBeenCalled();
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('forwards image attachments as [Image: <path>] plus structured attachments array', async () => {
|
||||
const adapter = createAdapter();
|
||||
const cfg = createMockSetup();
|
||||
await adapter.setup(cfg);
|
||||
|
||||
pushEvent({
|
||||
sourceNumber: '+15555550123',
|
||||
sourceName: 'Alice',
|
||||
dataMessage: {
|
||||
timestamp: 1700000000000,
|
||||
attachments: [{ id: 'att123abc', contentType: 'image/jpeg', size: 50000 }],
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(cfg.onInbound).toHaveBeenCalledWith(
|
||||
'+15555550123',
|
||||
null,
|
||||
expect.objectContaining({
|
||||
content: expect.objectContaining({
|
||||
text: expect.stringMatching(/^\[Image: .+att123abc\]$/),
|
||||
attachments: [expect.objectContaining({ contentType: 'image/jpeg' })],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
});
|
||||
|
||||
// --- groupV2 ---
|
||||
|
||||
describe('group routing', () => {
|
||||
it('routes to groupV2.id when present, falling back to legacy groupInfo.groupId', async () => {
|
||||
const adapter = createAdapter();
|
||||
const cfg = createMockSetup();
|
||||
await adapter.setup(cfg);
|
||||
|
||||
pushEvent({
|
||||
sourceNumber: '+15555550123',
|
||||
sourceName: 'Alice',
|
||||
dataMessage: {
|
||||
timestamp: 1700000000000,
|
||||
message: 'hello v2',
|
||||
groupV2: { id: 'v2group=' },
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(cfg.onInbound).toHaveBeenCalledWith('group:v2group=', null, expect.anything());
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
});
|
||||
|
||||
// --- mention resolution ---
|
||||
|
||||
describe('mention resolution', () => {
|
||||
it('replaces inline mention placeholders with display names', async () => {
|
||||
const adapter = createAdapter();
|
||||
const cfg = createMockSetup();
|
||||
await adapter.setup(cfg);
|
||||
|
||||
pushEvent({
|
||||
sourceNumber: '+15555550123',
|
||||
sourceName: 'Alice',
|
||||
dataMessage: {
|
||||
timestamp: 1700000000000,
|
||||
message: 'hey  are you here?',
|
||||
mentions: [{ start: 4, length: 1, name: 'Bob', uuid: 'bob-uuid' }],
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(cfg.onInbound).toHaveBeenCalledWith(
|
||||
'+15555550123',
|
||||
null,
|
||||
expect.objectContaining({
|
||||
content: expect.objectContaining({ text: 'hey @Bob are you here?' }),
|
||||
}),
|
||||
);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Quote context ---
|
||||
|
||||
describe('quote context', () => {
|
||||
it('emits a nested replyTo object matching the formatter contract', async () => {
|
||||
const adapter = createAdapter();
|
||||
const cfg = createMockSetup();
|
||||
await adapter.setup(cfg);
|
||||
|
||||
pushEvent({
|
||||
sourceNumber: '+15555550123',
|
||||
sourceName: 'Alice',
|
||||
dataMessage: {
|
||||
timestamp: 1700000000000,
|
||||
message: 'I disagree',
|
||||
quote: {
|
||||
id: 1699999999000,
|
||||
authorNumber: '+15555550888',
|
||||
authorName: 'Pineapple Pete',
|
||||
text: 'Pineapple belongs on pizza',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(cfg.onInbound).toHaveBeenCalledWith(
|
||||
'+15555550123',
|
||||
null,
|
||||
expect.objectContaining({
|
||||
content: expect.objectContaining({
|
||||
text: 'I disagree',
|
||||
replyTo: {
|
||||
id: '1699999999000',
|
||||
sender: 'Pineapple Pete',
|
||||
text: 'Pineapple belongs on pizza',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
});
|
||||
|
||||
// --- deliver ---
|
||||
|
||||
describe('deliver', () => {
|
||||
it('sends DM via TCP RPC', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'text',
|
||||
content: { text: 'Hello' },
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
expect(sendCalls.length).toBeGreaterThan(0);
|
||||
|
||||
const last = sendCalls[sendCalls.length - 1];
|
||||
expect(last.params).toEqual(
|
||||
expect.objectContaining({
|
||||
recipient: ['+15555550123'],
|
||||
message: 'Hello',
|
||||
account: '+15551234567',
|
||||
}),
|
||||
);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('sends group message via groupId', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
|
||||
await adapter.deliver('group:abc123', null, {
|
||||
kind: 'text',
|
||||
content: { text: 'Group msg' },
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
const last = sendCalls[sendCalls.length - 1];
|
||||
expect(last.params).toEqual(
|
||||
expect.objectContaining({
|
||||
groupId: 'abc123',
|
||||
message: 'Group msg',
|
||||
}),
|
||||
);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('chunks long messages', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
|
||||
const longText = 'x'.repeat(5000);
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'text',
|
||||
content: { text: longText },
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
expect(sendCalls.length).toBeGreaterThan(1);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('extracts text from string content', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'text',
|
||||
content: 'Plain string content',
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
expect(sendCalls.length).toBeGreaterThan(0);
|
||||
const last = sendCalls[sendCalls.length - 1];
|
||||
expect(last.params.message).toBe('Plain string content');
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Outbound attachments ---
|
||||
|
||||
describe('deliver — attachments', () => {
|
||||
// Real fs writes happen in tmpdir(); confirm the bytes round-trip and
|
||||
// are cleaned up after deliver returns.
|
||||
it('sends a single attachment via attachments[] param', async () => {
|
||||
const fs = await import('node:fs');
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
tcpRef.fakeSocket.write.mockClear();
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'file',
|
||||
content: {},
|
||||
files: [{ filename: 'report.md', data: Buffer.from('# Report\n\nbody') }],
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
expect(sendCalls.length).toBe(1);
|
||||
const params = sendCalls[0].params as Record<string, unknown>;
|
||||
expect(params.recipient).toEqual(['+15555550123']);
|
||||
expect(params.account).toBe('+15551234567');
|
||||
expect(params.message).toBeUndefined();
|
||||
const paths = params.attachments as string[];
|
||||
expect(paths).toHaveLength(1);
|
||||
expect(paths[0]).toMatch(/signal-out-\d+-[a-z0-9]+-report\.md$/);
|
||||
// Temp file should no longer exist — finally{} cleanup ran
|
||||
expect(fs.existsSync(paths[0])).toBe(false);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('sends text first, then attachment, when both are present', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
tcpRef.fakeSocket.write.mockClear();
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'file',
|
||||
content: { text: 'Here is the digest' },
|
||||
files: [{ filename: 'digest.md', data: Buffer.from('content') }],
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
expect(sendCalls).toHaveLength(2);
|
||||
// First call: text message
|
||||
expect(sendCalls[0].params).toEqual(
|
||||
expect.objectContaining({ message: 'Here is the digest', recipient: ['+15555550123'] }),
|
||||
);
|
||||
expect((sendCalls[0].params as Record<string, unknown>).attachments).toBeUndefined();
|
||||
// Second call: attachment, no message
|
||||
expect(sendCalls[1].params).toEqual(
|
||||
expect.objectContaining({ recipient: ['+15555550123'] }),
|
||||
);
|
||||
const attachments = (sendCalls[1].params as Record<string, unknown>).attachments as string[];
|
||||
expect(attachments).toHaveLength(1);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('sends multiple attachments in a single send call', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
tcpRef.fakeSocket.write.mockClear();
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'file',
|
||||
content: {},
|
||||
files: [
|
||||
{ filename: 'a.txt', data: Buffer.from('a') },
|
||||
{ filename: 'b.png', data: Buffer.from([0x89, 0x50, 0x4e, 0x47]) },
|
||||
],
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
expect(sendCalls).toHaveLength(1);
|
||||
const attachments = (sendCalls[0].params as Record<string, unknown>).attachments as string[];
|
||||
expect(attachments).toHaveLength(2);
|
||||
expect(attachments[0]).toMatch(/-a\.txt$/);
|
||||
expect(attachments[1]).toMatch(/-b\.png$/);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('uses groupId for group destinations', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
tcpRef.fakeSocket.write.mockClear();
|
||||
|
||||
await adapter.deliver('group:abc123', null, {
|
||||
kind: 'file',
|
||||
content: {},
|
||||
files: [{ filename: 'pic.jpg', data: Buffer.from('jpg') }],
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
expect(sendCalls).toHaveLength(1);
|
||||
const params = sendCalls[0].params as Record<string, unknown>;
|
||||
expect(params.groupId).toBe('abc123');
|
||||
expect(params.recipient).toBeUndefined();
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
/**
|
||||
* Defensive test: `OutboundFile.filename` is operator-supplied data, so
|
||||
* the implementation must not let a filename containing path separators
|
||||
* escape the temp directory. We feed an attempt-to-traverse filename and
|
||||
* assert the resolved path stays strictly inside `tmpdir()`.
|
||||
*/
|
||||
it('keeps temp paths inside tmpdir even when filename contains path separators', async () => {
|
||||
const path = await import('node:path');
|
||||
const os = await import('node:os');
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
tcpRef.fakeSocket.write.mockClear();
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'file',
|
||||
content: {},
|
||||
files: [{ filename: '../sneaky.txt', data: Buffer.from('x') }],
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
const paths = (sendCalls[0].params as Record<string, unknown>).attachments as string[];
|
||||
const resolvedTmp = path.resolve(os.tmpdir());
|
||||
const resolvedResult = path.resolve(paths[0]);
|
||||
// path.resolve normalizes away any "../"; if sanitization failed, the
|
||||
// result would resolve to tmpdir's parent.
|
||||
expect(resolvedResult.startsWith(resolvedTmp + path.sep)).toBe(true);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Text styles ---
|
||||
|
||||
describe('text styles', () => {
|
||||
it('sends bold text with textStyle parameter', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
tcpRef.fakeSocket.write.mockClear();
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'text',
|
||||
content: { text: 'Hello **world**' },
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
expect(sendCalls.length).toBeGreaterThan(0);
|
||||
const last = sendCalls[sendCalls.length - 1];
|
||||
expect(last.params.message).toBe('Hello world');
|
||||
expect(last.params.textStyle).toEqual(['6:5:BOLD']);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('sends inline code with MONOSPACE style', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
tcpRef.fakeSocket.write.mockClear();
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'text',
|
||||
content: { text: 'Run `npm test` now' },
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
const last = sendCalls[sendCalls.length - 1];
|
||||
expect(last.params.message).toBe('Run npm test now');
|
||||
expect(last.params.textStyle).toEqual(['4:8:MONOSPACE']);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('sends plain text without textStyle', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
tcpRef.fakeSocket.write.mockClear();
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'text',
|
||||
content: { text: 'No formatting here' },
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
const last = sendCalls[sendCalls.length - 1];
|
||||
expect(last.params.message).toBe('No formatting here');
|
||||
expect(last.params.textStyle).toBeUndefined();
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('falls back to original markup when textStyle is rejected', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
|
||||
let sendCount = 0;
|
||||
tcpRef.fakeSocket.write.mockImplementation((data: string) => {
|
||||
try {
|
||||
const req = JSON.parse(data.trim());
|
||||
if (req.method === 'send') {
|
||||
sendCount++;
|
||||
if (sendCount === 1) {
|
||||
const response =
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: req.id,
|
||||
error: { message: 'Unknown parameter: textStyle' },
|
||||
}) + '\n';
|
||||
setImmediate(() => tcpRef.fakeSocket.emit('data', Buffer.from(response)));
|
||||
return;
|
||||
}
|
||||
}
|
||||
const response =
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: req.id,
|
||||
result: { ok: true },
|
||||
}) + '\n';
|
||||
setImmediate(() => tcpRef.fakeSocket.emit('data', Buffer.from(response)));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'text',
|
||||
content: { text: 'Hello **world**' },
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
expect(sendCalls.length).toBe(2);
|
||||
expect(sendCalls[1].params.message).toBe('Hello **world**');
|
||||
expect(sendCalls[1].params.textStyle).toBeUndefined();
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('tracks nested styles with correct offsets', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
tcpRef.fakeSocket.write.mockClear();
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'text',
|
||||
content: { text: '**bold with `code` inside**' },
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
const last = sendCalls[sendCalls.length - 1];
|
||||
expect(last.params.message).toBe('bold with code inside');
|
||||
// BOLD covers the full inner span, MONOSPACE points at "code" in the
|
||||
// final plain text (offset 10, length 4) — not the intermediate text.
|
||||
const styles = (last.params.textStyle as string[]).slice().sort();
|
||||
expect(styles).toEqual(['0:21:BOLD', '10:4:MONOSPACE']);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('maps *single-asterisk* to ITALIC', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
tcpRef.fakeSocket.write.mockClear();
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'text',
|
||||
content: { text: 'Hello *world*' },
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
const last = sendCalls[sendCalls.length - 1];
|
||||
expect(last.params.message).toBe('Hello world');
|
||||
expect(last.params.textStyle).toEqual(['6:5:ITALIC']);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('maps _underscore_ to ITALIC', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
tcpRef.fakeSocket.write.mockClear();
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'text',
|
||||
content: { text: 'hey _there_' },
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
const last = sendCalls[sendCalls.length - 1];
|
||||
expect(last.params.message).toBe('hey there');
|
||||
expect(last.params.textStyle).toEqual(['4:5:ITALIC']);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Echo cache ---
|
||||
|
||||
describe('echo cache', () => {
|
||||
it('does not drop same-text inbound from a different recipient', async () => {
|
||||
// Bot sends "Hello" to Alice. Immediately after, Bob sends "Hello" from
|
||||
// a different DM. Bob's message must still route — the earlier echo key
|
||||
// was scoped to Alice.
|
||||
const adapter = createAdapter();
|
||||
const cfg = createMockSetup();
|
||||
await adapter.setup(cfg);
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'text',
|
||||
content: { text: 'Hello' },
|
||||
});
|
||||
|
||||
pushEvent({
|
||||
sourceNumber: '+15555550999',
|
||||
sourceName: 'Bob',
|
||||
dataMessage: { timestamp: 1700000000000, message: 'Hello' },
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(cfg.onInbound).toHaveBeenCalledWith(
|
||||
'+15555550999',
|
||||
null,
|
||||
expect.objectContaining({
|
||||
content: expect.objectContaining({ text: 'Hello', sender: '+15555550999' }),
|
||||
}),
|
||||
);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('still skips echo on the same recipient', async () => {
|
||||
const adapter = createAdapter();
|
||||
const cfg = createMockSetup();
|
||||
await adapter.setup(cfg);
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'text',
|
||||
content: { text: 'Echo test' },
|
||||
});
|
||||
|
||||
pushEvent({
|
||||
sourceNumber: '+15555550123',
|
||||
dataMessage: { timestamp: 1700000000000, message: 'Echo test' },
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(cfg.onInbound).not.toHaveBeenCalled();
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Connection drop ---
|
||||
|
||||
describe('connection drop', () => {
|
||||
it('flips isConnected to false when the socket closes', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
expect(adapter.isConnected()).toBe(true);
|
||||
|
||||
// Simulate the daemon dropping the TCP connection.
|
||||
tcpRef.fakeSocket.destroy();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
expect(adapter.isConnected()).toBe(false);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
});
|
||||
|
||||
// --- setTyping ---
|
||||
|
||||
describe('setTyping', () => {
|
||||
it('sends typing indicator for DMs', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
|
||||
await adapter.setTyping!('+15555550123', null);
|
||||
|
||||
expect(getRpcCallsForMethod('sendTyping')).toHaveLength(1);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('skips typing for groups', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
|
||||
await adapter.setTyping!('group:abc123', null);
|
||||
|
||||
expect(getRpcCallsForMethod('sendTyping')).toHaveLength(0);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Adapter properties ---
|
||||
|
||||
describe('adapter properties', () => {
|
||||
it('has channelType "signal"', () => {
|
||||
const adapter = createAdapter();
|
||||
expect(adapter.channelType).toBe('signal');
|
||||
});
|
||||
|
||||
it('does not support threads', () => {
|
||||
const adapter = createAdapter();
|
||||
expect(adapter.supportsThreads).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,983 @@
|
||||
/**
|
||||
* Signal channel adapter for NanoClaw v2.
|
||||
*
|
||||
* Uses signal-cli's TCP JSON-RPC daemon for bidirectional messaging.
|
||||
* Requires signal-cli (https://github.com/AsamK/signal-cli) installed
|
||||
* and a linked account.
|
||||
*
|
||||
* Ported from v1 — see v1 source for commit history.
|
||||
*/
|
||||
import { execFileSync, execSync, spawn } from 'node:child_process';
|
||||
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { createConnection, type Socket } from 'node:net';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type { ChannelAdapter, ChannelSetup, InboundMessage, OutboundMessage } from './adapter.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { log } from '../log.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Signal CLI daemon management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DaemonHandle {
|
||||
stop: () => void;
|
||||
exited: Promise<void>;
|
||||
isExited: () => boolean;
|
||||
}
|
||||
|
||||
function spawnSignalDaemon(cliPath: string, account: string, host: string, port: number): DaemonHandle {
|
||||
const args: string[] = [];
|
||||
if (account) args.push('-a', account);
|
||||
args.push('daemon', '--tcp', `${host}:${port}`, '--no-receive-stdout');
|
||||
args.push('--receive-mode', 'on-start');
|
||||
|
||||
const child = spawn(cliPath, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let exited = false;
|
||||
|
||||
const exitedPromise = new Promise<void>((resolve) => {
|
||||
child.once('exit', (code, signal) => {
|
||||
exited = true;
|
||||
if (code !== 0 && code !== null) {
|
||||
const reason = signal ? `signal ${signal}` : `code ${code}`;
|
||||
log.error('signal-cli daemon exited', { reason });
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
child.on('error', (err) => {
|
||||
exited = true;
|
||||
log.error('signal-cli spawn error', { err });
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
child.stdout?.on('data', (data: Buffer) => {
|
||||
for (const line of data.toString().split(/\r?\n/)) {
|
||||
if (line.trim()) log.debug('signal-cli stdout', { line: line.trim() });
|
||||
}
|
||||
});
|
||||
child.stderr?.on('data', (data: Buffer) => {
|
||||
for (const line of data.toString().split(/\r?\n/)) {
|
||||
if (!line.trim()) continue;
|
||||
if (/\b(ERROR|WARN|FAILED|SEVERE)\b/i.test(line)) {
|
||||
log.warn('signal-cli stderr', { line: line.trim() });
|
||||
} else {
|
||||
log.debug('signal-cli stderr', { line: line.trim() });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
stop: () => {
|
||||
if (!child.killed && !exited) child.kill('SIGTERM');
|
||||
},
|
||||
exited: exitedPromise,
|
||||
isExited: () => exited,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TCP JSON-RPC client for signal-cli daemon (--tcp mode)
|
||||
//
|
||||
// signal-cli 0.14.x --tcp exposes a newline-delimited JSON-RPC socket.
|
||||
// Requests are sent as JSON + newline; responses and push notifications
|
||||
// (inbound messages) arrive the same way.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RPC_TIMEOUT_MS = 15_000;
|
||||
|
||||
class SignalTcpClient {
|
||||
private socket: Socket | null = null;
|
||||
private buffer = '';
|
||||
private pending = new Map<
|
||||
string,
|
||||
{
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (err: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
>();
|
||||
private onNotification: ((method: string, params: unknown) => void) | null = null;
|
||||
private onClose: (() => void) | null = null;
|
||||
|
||||
constructor(
|
||||
private host: string,
|
||||
private port: number,
|
||||
) {}
|
||||
|
||||
connect(handlers?: {
|
||||
onNotification?: (method: string, params: unknown) => void;
|
||||
onClose?: () => void;
|
||||
}): Promise<void> {
|
||||
this.onNotification = handlers?.onNotification ?? null;
|
||||
this.onClose = handlers?.onClose ?? null;
|
||||
return new Promise((resolve, reject) => {
|
||||
const sock = createConnection(this.port, this.host, () => {
|
||||
this.socket = sock;
|
||||
resolve();
|
||||
});
|
||||
sock.on('error', (err) => {
|
||||
if (!this.socket) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
log.warn('Signal TCP socket error', { err });
|
||||
});
|
||||
sock.on('data', (chunk) => this.onData(chunk));
|
||||
sock.on('close', () => {
|
||||
const wasConnected = this.socket !== null;
|
||||
this.socket = null;
|
||||
for (const [, p] of this.pending) {
|
||||
clearTimeout(p.timer);
|
||||
p.reject(new Error('Signal TCP connection closed'));
|
||||
}
|
||||
this.pending.clear();
|
||||
if (wasConnected) this.onClose?.();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async rpc<T = unknown>(method: string, params?: Record<string, unknown>): Promise<T> {
|
||||
if (!this.socket) throw new Error('Signal TCP not connected');
|
||||
const id = Math.random().toString(36).slice(2);
|
||||
const msg = JSON.stringify({ jsonrpc: '2.0', method, params, id }) + '\n';
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`Signal RPC timeout: ${method}`));
|
||||
}, RPC_TIMEOUT_MS);
|
||||
|
||||
this.pending.set(id, {
|
||||
resolve: resolve as (v: unknown) => void,
|
||||
reject,
|
||||
timer,
|
||||
});
|
||||
this.socket!.write(msg);
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.socket?.destroy();
|
||||
this.socket = null;
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.socket !== null && !this.socket.destroyed;
|
||||
}
|
||||
|
||||
private onData(chunk: Buffer) {
|
||||
this.buffer += chunk.toString();
|
||||
let newlineIdx = this.buffer.indexOf('\n');
|
||||
while (newlineIdx !== -1) {
|
||||
const line = this.buffer.slice(0, newlineIdx).trim();
|
||||
this.buffer = this.buffer.slice(newlineIdx + 1);
|
||||
if (line) this.handleLine(line);
|
||||
newlineIdx = this.buffer.indexOf('\n');
|
||||
}
|
||||
}
|
||||
|
||||
private handleLine(line: string) {
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
log.debug('Signal TCP: unparseable line', { line: line.slice(0, 200) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.id && this.pending.has(parsed.id)) {
|
||||
const p = this.pending.get(parsed.id)!;
|
||||
this.pending.delete(parsed.id);
|
||||
clearTimeout(p.timer);
|
||||
if (parsed.error) {
|
||||
p.reject(new Error(parsed.error.message ?? 'Signal RPC error'));
|
||||
} else {
|
||||
p.resolve(parsed.result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.method && this.onNotification) {
|
||||
this.onNotification(parsed.method, parsed.params);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function signalTcpCheck(host: string, port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (result: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
sock.destroy();
|
||||
resolve(result);
|
||||
};
|
||||
const sock = createConnection(port, host, () => finish(true));
|
||||
sock.on('error', () => finish(false));
|
||||
const timer = setTimeout(() => finish(false), 5000);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Echo cache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ECHO_TTL_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Per-recipient dedup for messages we sent ourselves.
|
||||
*
|
||||
* signal-cli echoes our own outbound back via syncMessage (and, for Note to
|
||||
* Self, via sentMessage-with-self-destination). Without dedup, the agent sees
|
||||
* its own replies as new inbound and loops. We remember `(platformId, text)`
|
||||
* briefly after every send, and drop the first match within TTL.
|
||||
*
|
||||
* Keying on text alone is not enough: if we send "hi" to Alice and Bob then
|
||||
* sends "hi" from a different chat, Bob's real message gets silently dropped.
|
||||
*/
|
||||
class EchoCache {
|
||||
private entries = new Map<string, number>();
|
||||
|
||||
private keyFor(platformId: string, text: string): string {
|
||||
return `${platformId}\x00${text.trim()}`;
|
||||
}
|
||||
|
||||
remember(platformId: string, text: string): void {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
this.entries.set(this.keyFor(platformId, trimmed), Date.now());
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
isEcho(platformId: string, text: string): boolean {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return false;
|
||||
const key = this.keyFor(platformId, trimmed);
|
||||
const ts = this.entries.get(key);
|
||||
if (!ts) return false;
|
||||
if (Date.now() - ts > ECHO_TTL_MS) {
|
||||
this.entries.delete(key);
|
||||
return false;
|
||||
}
|
||||
this.entries.delete(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, ts] of this.entries) {
|
||||
if (now - ts > ECHO_TTL_MS) this.entries.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Signal envelope types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SignalQuote {
|
||||
id?: number;
|
||||
author?: string;
|
||||
authorNumber?: string;
|
||||
authorUuid?: string;
|
||||
authorName?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
interface SignalMention {
|
||||
start?: number;
|
||||
length?: number;
|
||||
uuid?: string;
|
||||
number?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface SignalDataMessage {
|
||||
timestamp?: number;
|
||||
message?: string;
|
||||
mentions?: SignalMention[];
|
||||
groupInfo?: { groupId?: string; groupName?: string; type?: string };
|
||||
groupV2?: { id?: string };
|
||||
quote?: SignalQuote;
|
||||
attachments?: Array<{
|
||||
id?: string;
|
||||
contentType?: string;
|
||||
filename?: string;
|
||||
size?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface SignalEnvelope {
|
||||
source?: string;
|
||||
sourceName?: string;
|
||||
sourceNumber?: string;
|
||||
sourceUuid?: string;
|
||||
dataMessage?: SignalDataMessage;
|
||||
syncMessage?: {
|
||||
sentMessage?: SignalDataMessage & {
|
||||
destination?: string;
|
||||
destinationNumber?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Replace inline `@<placeholder>` mention markers with display names so the
|
||||
* agent sees `@Alice` instead of a raw UUID. Signal's protocol uses a single
|
||||
* placeholder character (typically U+FFFC) at each mention's `start` offset.
|
||||
*/
|
||||
function resolveMentions(text: string, mentions?: SignalMention[]): string {
|
||||
if (!mentions || mentions.length === 0) return text;
|
||||
const sorted = [...mentions].sort((a, b) => (a.start ?? 0) - (b.start ?? 0));
|
||||
let result = '';
|
||||
let cursor = 0;
|
||||
for (const m of sorted) {
|
||||
const start = m.start ?? 0;
|
||||
const length = m.length ?? 1;
|
||||
const name = m.name || m.number || (m.uuid ? m.uuid.slice(0, 8) : 'someone');
|
||||
if (start < cursor) continue;
|
||||
result += text.slice(cursor, start) + `@${name}`;
|
||||
cursor = start + length;
|
||||
}
|
||||
result += text.slice(cursor);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional voice-note transcription. Tries (in order):
|
||||
* 1. local whisper.cpp CLI when `WHISPER_BIN` is set
|
||||
* 2. OpenAI Whisper API when `OPENAI_API_KEY` is set
|
||||
* Returns null if neither path is configured or transcription fails — caller
|
||||
* falls back to a `[Voice Message]` placeholder.
|
||||
*
|
||||
* Signal voice notes are AAC/ADTS; whisper-cpp wants WAV. ffmpeg is invoked
|
||||
* if available to convert; if ffmpeg is missing the local path is skipped.
|
||||
*/
|
||||
async function transcribeAudioOptional(filePath: string): Promise<string | null> {
|
||||
const whisperBin = process.env.WHISPER_BIN;
|
||||
if (whisperBin) {
|
||||
try {
|
||||
const wavPath = `${filePath}.wav`;
|
||||
execSync(`ffmpeg -y -loglevel error -i "${filePath}" -ar 16000 -ac 1 "${wavPath}"`, { stdio: 'ignore' });
|
||||
const model = process.env.WHISPER_MODEL || `${homedir()}/.local/share/whisper/models/ggml-base.en.bin`;
|
||||
const out = execSync(`"${whisperBin}" -m "${model}" -f "${wavPath}" -nt -otxt -of "${wavPath}"`, {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
try {
|
||||
unlinkSync(wavPath);
|
||||
unlinkSync(`${wavPath}.txt`);
|
||||
} catch {}
|
||||
const text = out.replace(/\[[^\]]*\]/g, '').trim();
|
||||
if (text) return text;
|
||||
} catch (err) {
|
||||
log.debug('Signal: local whisper transcription failed, trying OpenAI', { err });
|
||||
}
|
||||
}
|
||||
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
if (apiKey) {
|
||||
try {
|
||||
const buf = readFileSync(filePath);
|
||||
const boundary = `----nanoclaw-${Date.now()}`;
|
||||
const body = Buffer.concat([
|
||||
Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="model"\r\n\r\nwhisper-1\r\n--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="audio.aac"\r\nContent-Type: audio/aac\r\n\r\n`,
|
||||
),
|
||||
buf,
|
||||
Buffer.from(`\r\n--${boundary}--\r\n`),
|
||||
]);
|
||||
const res = await fetch('https://api.openai.com/v1/audio/transcriptions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
},
|
||||
body,
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = (await res.json()) as { text?: string };
|
||||
if (json.text) return json.text.trim();
|
||||
}
|
||||
} catch (err) {
|
||||
log.debug('Signal: OpenAI transcription failed', { err });
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function chunkText(text: string, limit: number): string[] {
|
||||
const chunks: string[] = [];
|
||||
let remaining = text;
|
||||
while (remaining.length > 0) {
|
||||
if (remaining.length <= limit) {
|
||||
chunks.push(remaining);
|
||||
break;
|
||||
}
|
||||
let splitAt = remaining.lastIndexOf('\n', limit);
|
||||
if (splitAt <= 0) splitAt = limit;
|
||||
chunks.push(remaining.slice(0, splitAt));
|
||||
remaining = remaining.slice(splitAt).replace(/^\n/, '');
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Signal text styles — convert Markdown to Signal's offset-based formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SignalTextStyle {
|
||||
style: 'BOLD' | 'ITALIC' | 'STRIKETHROUGH' | 'MONOSPACE' | 'SPOILER';
|
||||
start: number;
|
||||
length: number;
|
||||
}
|
||||
|
||||
interface StyledText {
|
||||
text: string;
|
||||
textStyles: SignalTextStyle[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Markdown-ish input to Signal's offset-based style ranges.
|
||||
*
|
||||
* Walks the input recursively: at each level we find the leftmost matching
|
||||
* pattern, descend into its captured inner text (so `**bold with \`code\`
|
||||
* inside**` stays bold-plus-monospace rather than leaking stripped markers),
|
||||
* then continue past the match. Style offsets are recorded against the
|
||||
* *output* text length as it's built, so nested styles always point at the
|
||||
* right span of the final plain text.
|
||||
*/
|
||||
function parseSignalStyles(input: string): StyledText {
|
||||
const styles: SignalTextStyle[] = [];
|
||||
|
||||
// Ordering matters: longer/greedier delimiters first so `` ``` `` beats
|
||||
// `` ` ``, `**` beats `*`. The italic-`*` pattern refuses to start on
|
||||
// whitespace so `*` isn't mistakenly opened on " * " in list-like text.
|
||||
const patterns: Array<{ regex: RegExp; style: SignalTextStyle['style'] }> = [
|
||||
{ regex: /```([\s\S]+?)```/, style: 'MONOSPACE' },
|
||||
{ regex: /`([^`]+)`/, style: 'MONOSPACE' },
|
||||
{ regex: /\*\*([^]+?)\*\*/, style: 'BOLD' },
|
||||
{ regex: /~~([^]+?)~~/, style: 'STRIKETHROUGH' },
|
||||
{ regex: /\|\|([^]+?)\|\|/, style: 'SPOILER' },
|
||||
{ regex: /\*([^*\s][^*]*?)\*/, style: 'ITALIC' },
|
||||
{ regex: /_([^_\s][^_]*?)_/, style: 'ITALIC' },
|
||||
];
|
||||
|
||||
function walk(segment: string, outputBase: number): string {
|
||||
let earliest: { start: number; match: RegExpExecArray; style: SignalTextStyle['style'] } | null = null;
|
||||
for (const { regex, style } of patterns) {
|
||||
const m = regex.exec(segment);
|
||||
if (!m) continue;
|
||||
if (earliest === null || m.index < earliest.start) {
|
||||
earliest = { start: m.index, match: m, style };
|
||||
}
|
||||
}
|
||||
if (!earliest) return segment;
|
||||
|
||||
const before = segment.slice(0, earliest.start);
|
||||
const fullMatch = earliest.match[0];
|
||||
const inner = earliest.match[1];
|
||||
const afterStart = earliest.start + fullMatch.length;
|
||||
const after = segment.slice(afterStart);
|
||||
|
||||
const innerOut = walk(inner, outputBase + before.length);
|
||||
styles.push({
|
||||
style: earliest.style,
|
||||
start: outputBase + before.length,
|
||||
length: innerOut.length,
|
||||
});
|
||||
const afterOut = walk(after, outputBase + before.length + innerOut.length);
|
||||
|
||||
return before + innerOut + afterOut;
|
||||
}
|
||||
|
||||
const text = walk(input, 0);
|
||||
return { text, textStyles: styles };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SignalAdapter — v2 ChannelAdapter implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Platform ID format:
|
||||
* DM: phone number or UUID (e.g. "+15555550123")
|
||||
* Group: "group:<groupId>" (e.g. "group:abc123")
|
||||
*
|
||||
* channelType is always "signal". The router combines channelType + platformId
|
||||
* to look up or create the messaging_group.
|
||||
*/
|
||||
export function createSignalAdapter(config: {
|
||||
cliPath: string;
|
||||
account: string;
|
||||
tcpHost: string;
|
||||
tcpPort: number;
|
||||
manageDaemon: boolean;
|
||||
signalDataDir: string;
|
||||
}): ChannelAdapter {
|
||||
let daemon: DaemonHandle | null = null;
|
||||
let tcp: SignalTcpClient | null = null;
|
||||
let connected = false;
|
||||
const echoCache = new EchoCache();
|
||||
let setup: ChannelSetup | null = null;
|
||||
|
||||
// -- inbound handling --
|
||||
|
||||
function handleNotification(method: string, params: unknown): void {
|
||||
if (method === 'receive') {
|
||||
const envelope = (params as any)?.envelope;
|
||||
if (envelope) {
|
||||
handleEnvelope(envelope).catch((err) => {
|
||||
log.error('Signal: error handling envelope', { err });
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEnvelope(envelope: SignalEnvelope): Promise<void> {
|
||||
if (!setup) return;
|
||||
|
||||
// Sync messages (sent from another device)
|
||||
const syncSent = envelope.syncMessage?.sentMessage;
|
||||
if (syncSent) {
|
||||
const dest = (syncSent.destinationNumber ?? syncSent.destination ?? '').trim();
|
||||
// "Note to Self" — destination is our own account
|
||||
if (dest === config.account) {
|
||||
const text = (syncSent.message ?? '').trim();
|
||||
if (!text) return;
|
||||
const platformId = config.account;
|
||||
if (echoCache.isEcho(platformId, text)) return;
|
||||
const timestamp = syncSent.timestamp ? new Date(syncSent.timestamp).toISOString() : new Date().toISOString();
|
||||
|
||||
setup.onMetadata(platformId, 'Note to Self', false);
|
||||
|
||||
const msg: InboundMessage = {
|
||||
id: String(syncSent.timestamp ?? Date.now()),
|
||||
kind: 'chat',
|
||||
content: {
|
||||
text,
|
||||
sender: config.account,
|
||||
senderId: `signal:${config.account}`,
|
||||
senderName: 'Me',
|
||||
isFromMe: true,
|
||||
...(syncSent.quote ? quoteToContent(syncSent.quote) : {}),
|
||||
},
|
||||
timestamp,
|
||||
};
|
||||
await setup.onInbound(platformId, null, msg);
|
||||
return;
|
||||
}
|
||||
// Other sync messages are our outbound — skip
|
||||
return;
|
||||
}
|
||||
|
||||
const dataMessage = envelope.dataMessage;
|
||||
if (!dataMessage) return;
|
||||
|
||||
const rawText = (dataMessage.message ?? '').trim();
|
||||
const text = rawText ? resolveMentions(rawText, dataMessage.mentions) : '';
|
||||
|
||||
const audioAttachment = dataMessage.attachments?.find((a) => a.contentType?.startsWith('audio/') && a.id);
|
||||
const imageAttachments = dataMessage.attachments?.filter((a) => a.contentType?.startsWith('image/') && a.id) ?? [];
|
||||
const hasVoice = !text && !!audioAttachment;
|
||||
|
||||
if (!text && !hasVoice && imageAttachments.length === 0) return;
|
||||
|
||||
const sender = (envelope.sourceNumber ?? envelope.sourceUuid ?? envelope.source ?? '').trim();
|
||||
if (!sender) return;
|
||||
|
||||
const senderName = (envelope.sourceName?.trim() || sender).trim();
|
||||
|
||||
// Modern Signal groups use groupV2; legacy groupInfo.groupId is the
|
||||
// pre-V2 fallback. Without the V2 read, V2-only groups appear as DMs
|
||||
// because `groupInfo` is undefined.
|
||||
const groupInfo = dataMessage.groupInfo;
|
||||
const groupId = dataMessage.groupV2?.id ?? groupInfo?.groupId;
|
||||
const isGroup = Boolean(groupId);
|
||||
|
||||
const platformId = isGroup ? `group:${groupId}` : sender;
|
||||
|
||||
if (text && echoCache.isEcho(platformId, text)) {
|
||||
log.debug('Signal: skipping echo', { platformId });
|
||||
return;
|
||||
}
|
||||
const timestamp = dataMessage.timestamp ? new Date(dataMessage.timestamp).toISOString() : new Date().toISOString();
|
||||
|
||||
const chatName = groupInfo?.groupName ?? (isGroup ? `Group ${groupId?.slice(0, 8)}` : senderName);
|
||||
|
||||
setup.onMetadata(platformId, chatName, isGroup);
|
||||
|
||||
let content = text;
|
||||
|
||||
// Voice attachment — try transcription if WHISPER_BIN or OPENAI_API_KEY
|
||||
// is configured; otherwise fall back to the original placeholder so
|
||||
// operators who don't want transcription get the same UX as before.
|
||||
if (hasVoice && audioAttachment?.id) {
|
||||
const attachmentPath = join(config.signalDataDir, 'attachments', audioAttachment.id);
|
||||
if (existsSync(attachmentPath)) {
|
||||
log.info('Signal: voice attachment received', {
|
||||
platformId,
|
||||
attachmentId: audioAttachment.id,
|
||||
path: attachmentPath,
|
||||
});
|
||||
const transcript = await transcribeAudioOptional(attachmentPath);
|
||||
if (transcript) {
|
||||
content = `[Voice: ${transcript}]`;
|
||||
log.info('Signal: voice transcribed', { platformId, length: transcript.length });
|
||||
} else {
|
||||
content = '[Voice Message]';
|
||||
}
|
||||
} else {
|
||||
log.warn('Signal: voice attachment file not found', {
|
||||
id: audioAttachment.id,
|
||||
path: attachmentPath,
|
||||
});
|
||||
content = '[Voice Message - file not found]';
|
||||
}
|
||||
}
|
||||
|
||||
// Image attachments — emit `[Image: <path>]` lines so the agent's Read
|
||||
// tool can pick them up, and surface the structured `attachments` array
|
||||
// for consumers that prefer that shape. Without this, vision-capable
|
||||
// models never see images sent over Signal.
|
||||
const attachmentRefs: Array<{ path: string; contentType: string }> = [];
|
||||
for (const img of imageAttachments) {
|
||||
const imagePath = join(config.signalDataDir, 'attachments', img.id!);
|
||||
const imageLine = `[Image: ${imagePath}]`;
|
||||
content = content ? `${content}\n${imageLine}` : imageLine;
|
||||
attachmentRefs.push({ path: imagePath, contentType: img.contentType || 'image/jpeg' });
|
||||
}
|
||||
|
||||
const msg: InboundMessage = {
|
||||
id: String(dataMessage.timestamp ?? Date.now()),
|
||||
kind: 'chat',
|
||||
content: {
|
||||
text: content,
|
||||
sender,
|
||||
senderId: `signal:${sender}`,
|
||||
senderName,
|
||||
...(attachmentRefs.length > 0 ? { attachments: attachmentRefs } : {}),
|
||||
...(dataMessage.quote ? quoteToContent(dataMessage.quote) : {}),
|
||||
},
|
||||
timestamp,
|
||||
};
|
||||
await setup.onInbound(platformId, null, msg);
|
||||
|
||||
log.info('Signal message received', { platformId, sender: senderName });
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `replyTo` object the agent-runner formatter expects (see
|
||||
* `container/agent-runner/src/formatter.ts:formatReplyContext`). The
|
||||
* formatter requires both `sender` and `text` to render the
|
||||
* `<quoted_message>` block; absent either, it omits the block entirely.
|
||||
*
|
||||
* The previous shape (`replyToSenderName` / `replyToMessageContent` /
|
||||
* `replyToMessageId` flat keys) did not match the formatter contract, so
|
||||
* quote-reply context was silently dropped end-to-end.
|
||||
*/
|
||||
function quoteToContent(quote: SignalQuote): Record<string, unknown> {
|
||||
const sender = quote.authorName || quote.authorNumber || quote.author || quote.authorUuid || 'someone';
|
||||
const text = quote.text || '';
|
||||
return {
|
||||
replyTo: {
|
||||
id: quote.id ? String(quote.id) : undefined,
|
||||
sender,
|
||||
text,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// -- send helpers --
|
||||
|
||||
async function sendText(platformId: string, text: string): Promise<void> {
|
||||
if (!connected || !tcp) return;
|
||||
|
||||
echoCache.remember(platformId, text);
|
||||
|
||||
const MAX_CHUNK = 4000;
|
||||
const chunks = text.length <= MAX_CHUNK ? [text] : chunkText(text, MAX_CHUNK);
|
||||
|
||||
for (const chunk of chunks) {
|
||||
try {
|
||||
const { text: plainText, textStyles } = parseSignalStyles(chunk);
|
||||
const params: Record<string, unknown> = { message: plainText };
|
||||
if (config.account) params.account = config.account;
|
||||
if (textStyles.length > 0) {
|
||||
params.textStyle = textStyles.map((s) => `${s.start}:${s.length}:${s.style}`);
|
||||
}
|
||||
|
||||
if (platformId.startsWith('group:')) {
|
||||
params.groupId = platformId.slice('group:'.length);
|
||||
} else {
|
||||
params.recipient = [platformId];
|
||||
}
|
||||
|
||||
try {
|
||||
await tcp.rpc('send', params);
|
||||
} catch (styledErr) {
|
||||
if (textStyles.length > 0) {
|
||||
log.debug('Signal: textStyle rejected, retrying with markup');
|
||||
delete params.textStyle;
|
||||
params.message = chunk;
|
||||
await tcp.rpc('send', params);
|
||||
} else {
|
||||
throw styledErr;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('Signal: send failed', { platformId, err });
|
||||
}
|
||||
}
|
||||
|
||||
log.info('Signal message sent', { platformId, length: text.length });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one or more file attachments via signal-cli's `send` JSON-RPC, which
|
||||
* accepts an `attachments` array of host filesystem paths. The OutboundFile
|
||||
* Buffer is materialized to an OS temp file so signal-cli can read it, then
|
||||
* removed in the finally block.
|
||||
*
|
||||
* Caption text, if any, is sent first via `sendText` (which handles chunking
|
||||
* + textStyles) — keeps this function single-purpose and avoids a long
|
||||
* caption colliding with signal-cli's per-message size limits.
|
||||
*/
|
||||
async function sendAttachments(platformId: string, files: { filename: string; data: Buffer }[]): Promise<void> {
|
||||
if (!connected || !tcp) return;
|
||||
if (files.length === 0) return;
|
||||
|
||||
const tempPaths: string[] = [];
|
||||
for (const file of files) {
|
||||
const safeName = file.filename.replace(/[/\\\0]/g, '_');
|
||||
const tempPath = join(tmpdir(), `signal-out-${Date.now()}-${Math.random().toString(36).slice(2, 8)}-${safeName}`);
|
||||
writeFileSync(tempPath, file.data);
|
||||
tempPaths.push(tempPath);
|
||||
}
|
||||
|
||||
try {
|
||||
const params: Record<string, unknown> = { attachments: tempPaths };
|
||||
if (config.account) params.account = config.account;
|
||||
if (platformId.startsWith('group:')) {
|
||||
params.groupId = platformId.slice('group:'.length);
|
||||
} else {
|
||||
params.recipient = [platformId];
|
||||
}
|
||||
await tcp.rpc('send', params);
|
||||
log.info('Signal attachments sent', { platformId, count: files.length, filenames: files.map((f) => f.filename) });
|
||||
} catch (err) {
|
||||
log.error('Signal: attachment send failed', { platformId, count: files.length, err });
|
||||
} finally {
|
||||
for (const p of tempPaths) {
|
||||
try {
|
||||
unlinkSync(p);
|
||||
} catch {
|
||||
/* best-effort cleanup */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForDaemon(): Promise<boolean> {
|
||||
const maxWait = 30_000;
|
||||
const pollInterval = 1000;
|
||||
const start = Date.now();
|
||||
|
||||
while (Date.now() - start < maxWait) {
|
||||
if (daemon?.isExited()) return false;
|
||||
const ok = await signalTcpCheck(config.tcpHost, config.tcpPort);
|
||||
if (ok) return true;
|
||||
await sleep(pollInterval);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// -- adapter --
|
||||
|
||||
const adapter: ChannelAdapter = {
|
||||
name: 'signal',
|
||||
channelType: 'signal',
|
||||
supportsThreads: false,
|
||||
|
||||
async setup(cfg: ChannelSetup): Promise<void> {
|
||||
setup = cfg;
|
||||
|
||||
if (config.manageDaemon) {
|
||||
daemon = spawnSignalDaemon(config.cliPath, config.account, config.tcpHost, config.tcpPort);
|
||||
const ready = await waitForDaemon();
|
||||
if (!ready) {
|
||||
daemon.stop();
|
||||
throw new Error('Signal daemon failed to start. Is signal-cli installed and your account linked?');
|
||||
}
|
||||
} else {
|
||||
const ok = await signalTcpCheck(config.tcpHost, config.tcpPort);
|
||||
if (!ok) {
|
||||
const err = new Error(
|
||||
`Signal daemon not reachable at ${config.tcpHost}:${config.tcpPort}. Start it manually or set SIGNAL_MANAGE_DAEMON=true`,
|
||||
);
|
||||
(err as any).name = 'NetworkError';
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
tcp = new SignalTcpClient(config.tcpHost, config.tcpPort);
|
||||
await tcp.connect({
|
||||
onNotification: handleNotification,
|
||||
// Signal the adapter that the daemon dropped us. No auto-reconnect yet
|
||||
// — subsequent deliver/setTyping calls short-circuit on `connected`
|
||||
// and log rather than throw into the retry loop. Operators see this in
|
||||
// logs/nanoclaw.log and can restart the service.
|
||||
onClose: () => {
|
||||
if (!connected) return;
|
||||
connected = false;
|
||||
log.warn('Signal channel lost TCP connection to signal-cli daemon', {
|
||||
account: config.account,
|
||||
host: config.tcpHost,
|
||||
port: config.tcpPort,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await tcp.rpc('updateProfile', {
|
||||
name: 'NanoClaw',
|
||||
account: config.account,
|
||||
});
|
||||
} catch {
|
||||
log.debug('Signal: could not set profile name');
|
||||
}
|
||||
|
||||
try {
|
||||
await tcp.rpc('updateConfiguration', {
|
||||
typingIndicators: true,
|
||||
account: config.account,
|
||||
});
|
||||
} catch {
|
||||
log.debug('Signal: could not enable typing indicators');
|
||||
}
|
||||
|
||||
connected = true;
|
||||
log.info('Signal channel connected', {
|
||||
account: config.account,
|
||||
host: config.tcpHost,
|
||||
port: config.tcpPort,
|
||||
});
|
||||
},
|
||||
|
||||
async teardown(): Promise<void> {
|
||||
connected = false;
|
||||
tcp?.close();
|
||||
tcp = null;
|
||||
if (daemon && config.manageDaemon) {
|
||||
daemon.stop();
|
||||
await daemon.exited;
|
||||
}
|
||||
daemon = null;
|
||||
log.info('Signal channel disconnected');
|
||||
},
|
||||
|
||||
isConnected(): boolean {
|
||||
return connected;
|
||||
},
|
||||
|
||||
async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise<string | undefined> {
|
||||
const content = message.content as Record<string, unknown> | string | undefined;
|
||||
let text: string | null = null;
|
||||
if (typeof content === 'string') {
|
||||
text = content;
|
||||
} else if (content && typeof content === 'object' && typeof content.text === 'string') {
|
||||
text = content.text;
|
||||
}
|
||||
|
||||
const files = message.files ?? [];
|
||||
|
||||
// Send accompanying text first so it lands above the attachment(s) in
|
||||
// the recipient's chat. Both branches no-op cleanly if their input is
|
||||
// empty, so any combination of (text, files) works.
|
||||
if (text) await sendText(platformId, text);
|
||||
if (files.length > 0) await sendAttachments(platformId, files);
|
||||
return undefined;
|
||||
},
|
||||
|
||||
async setTyping(platformId: string, _threadId: string | null): Promise<void> {
|
||||
if (!connected || !tcp) return;
|
||||
if (platformId.startsWith('group:')) return;
|
||||
|
||||
try {
|
||||
const params: Record<string, unknown> = { recipient: [platformId] };
|
||||
if (config.account) params.account = config.account;
|
||||
await tcp.rpc('sendTyping', params);
|
||||
} catch (err) {
|
||||
log.debug('Signal: typing indicator failed', { platformId, err });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return adapter;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Self-registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DEFAULT_TCP_HOST = '127.0.0.1';
|
||||
const DEFAULT_TCP_PORT = 7583;
|
||||
|
||||
registerChannelAdapter('signal', {
|
||||
factory: () => {
|
||||
const envVars = readEnvFile([
|
||||
'SIGNAL_ACCOUNT',
|
||||
'SIGNAL_TCP_HOST',
|
||||
'SIGNAL_TCP_PORT',
|
||||
'SIGNAL_CLI_PATH',
|
||||
'SIGNAL_MANAGE_DAEMON',
|
||||
'SIGNAL_DATA_DIR',
|
||||
]);
|
||||
|
||||
const account = process.env.SIGNAL_ACCOUNT || envVars.SIGNAL_ACCOUNT || '';
|
||||
if (!account) {
|
||||
log.debug('Signal: SIGNAL_ACCOUNT not set, skipping channel');
|
||||
return null;
|
||||
}
|
||||
|
||||
const cliPath = process.env.SIGNAL_CLI_PATH || envVars.SIGNAL_CLI_PATH || 'signal-cli';
|
||||
const tcpHost = process.env.SIGNAL_TCP_HOST || envVars.SIGNAL_TCP_HOST || DEFAULT_TCP_HOST;
|
||||
const tcpPort = parseInt(process.env.SIGNAL_TCP_PORT || envVars.SIGNAL_TCP_PORT || String(DEFAULT_TCP_PORT), 10);
|
||||
const manageDaemon = (process.env.SIGNAL_MANAGE_DAEMON || envVars.SIGNAL_MANAGE_DAEMON || 'true') === 'true';
|
||||
|
||||
const signalDataDir =
|
||||
process.env.SIGNAL_DATA_DIR || envVars.SIGNAL_DATA_DIR || join(homedir(), '.local', 'share', 'signal-cli');
|
||||
|
||||
// Only check for `signal-cli` on PATH when the operator left cliPath at
|
||||
// the default AND asked us to manage the daemon. A custom absolute path
|
||||
// is treated as an explicit promise and spawn will surface its own ENOENT.
|
||||
if (manageDaemon && cliPath === 'signal-cli') {
|
||||
try {
|
||||
execFileSync('which', ['signal-cli'], { stdio: 'ignore' });
|
||||
} catch {
|
||||
log.debug('Signal: signal-cli binary not found, skipping channel');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return createSignalAdapter({
|
||||
cliPath,
|
||||
account,
|
||||
tcpHost,
|
||||
tcpPort,
|
||||
manageDaemon,
|
||||
signalDataDir,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Slack channel adapter (v2) — uses Chat SDK bridge.
|
||||
* Self-registers on import.
|
||||
*/
|
||||
import { createSlackAdapter } from '@chat-adapter/slack';
|
||||
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { createChatSdkBridge } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
registerChannelAdapter('slack', {
|
||||
factory: () => {
|
||||
const env = readEnvFile(['SLACK_BOT_TOKEN', 'SLACK_SIGNING_SECRET']);
|
||||
if (!env.SLACK_BOT_TOKEN) return null;
|
||||
const slackAdapter = createSlackAdapter({
|
||||
botToken: env.SLACK_BOT_TOKEN,
|
||||
signingSecret: env.SLACK_SIGNING_SECRET,
|
||||
});
|
||||
const bridge = createChatSdkBridge({ adapter: slackAdapter, concurrency: 'concurrent', supportsThreads: true });
|
||||
bridge.resolveChannelName = async (platformId: string) => {
|
||||
try {
|
||||
const info = await slackAdapter.fetchThread(platformId);
|
||||
return (info as { channelName?: string }).channelName ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
return bridge;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Microsoft Teams channel adapter (v2) — uses Chat SDK bridge.
|
||||
* Self-registers on import.
|
||||
*/
|
||||
import { createTeamsAdapter } from '@chat-adapter/teams';
|
||||
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { createChatSdkBridge } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
registerChannelAdapter('teams', {
|
||||
factory: () => {
|
||||
const env = readEnvFile(['TEAMS_APP_ID', 'TEAMS_APP_PASSWORD', 'TEAMS_APP_TENANT_ID', 'TEAMS_APP_TYPE']);
|
||||
if (!env.TEAMS_APP_ID) return null;
|
||||
const teamsAdapter = createTeamsAdapter({
|
||||
appId: env.TEAMS_APP_ID,
|
||||
appPassword: env.TEAMS_APP_PASSWORD,
|
||||
appType: (env.TEAMS_APP_TYPE as 'SingleTenant' | 'MultiTenant') || undefined,
|
||||
appTenantId: env.TEAMS_APP_TENANT_ID || undefined,
|
||||
});
|
||||
return createChatSdkBridge({ adapter: teamsAdapter, concurrency: 'concurrent', supportsThreads: true });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { sanitizeTelegramLegacyMarkdown } from './telegram-markdown-sanitize.js';
|
||||
|
||||
describe('sanitizeTelegramLegacyMarkdown', () => {
|
||||
it('downgrades CommonMark **bold** to legacy *bold*', () => {
|
||||
expect(sanitizeTelegramLegacyMarkdown('**Host path**')).toBe('*Host path*');
|
||||
});
|
||||
|
||||
it('downgrades CommonMark __bold__ to legacy _italic_', () => {
|
||||
expect(sanitizeTelegramLegacyMarkdown('__label__')).toBe('_label_');
|
||||
});
|
||||
|
||||
it('leaves balanced legacy *bold* and _italic_ alone', () => {
|
||||
expect(sanitizeTelegramLegacyMarkdown('a *b* c _d_ e')).toBe('a *b* c _d_ e');
|
||||
});
|
||||
|
||||
it('preserves inline code spans untouched', () => {
|
||||
const input = 'see `file_name.py` and `**not bold**` here';
|
||||
expect(sanitizeTelegramLegacyMarkdown(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('preserves fenced code blocks untouched', () => {
|
||||
const input = '```\nfoo_bar **baz**\n```';
|
||||
expect(sanitizeTelegramLegacyMarkdown(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('strips formatting chars on odd delimiter count (unbalanced *)', () => {
|
||||
expect(sanitizeTelegramLegacyMarkdown('a * b *c*')).toBe('a b c');
|
||||
});
|
||||
|
||||
it('strips formatting chars on odd delimiter count (unbalanced _)', () => {
|
||||
expect(sanitizeTelegramLegacyMarkdown('file_name has _one italic_')).toBe('filename has one italic');
|
||||
});
|
||||
|
||||
it('strips brackets when unbalanced', () => {
|
||||
expect(sanitizeTelegramLegacyMarkdown('see [docs here')).toBe('see docs here');
|
||||
});
|
||||
|
||||
it('leaves matched brackets (e.g. links) alone when counts balance', () => {
|
||||
const input = 'see [docs](https://example.com) for more';
|
||||
expect(sanitizeTelegramLegacyMarkdown(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('fixes the real failing message', () => {
|
||||
const input =
|
||||
'Sure! What do you want to mount, and where should it appear inside the container?\n\n' +
|
||||
'- **Host path** (on your machine): e.g. `~/projects/webapp`\n' +
|
||||
'- **Container path**: e.g. `workspace/webapp`\n' +
|
||||
'- **Read-only or read-write?**';
|
||||
const out = sanitizeTelegramLegacyMarkdown(input);
|
||||
expect(out).not.toContain('**');
|
||||
expect(out).toContain('*Host path*');
|
||||
expect(out).toContain('`~/projects/webapp`');
|
||||
expect((out.match(/\*/g) ?? []).length % 2).toBe(0);
|
||||
});
|
||||
|
||||
it('is a no-op on empty string', () => {
|
||||
expect(sanitizeTelegramLegacyMarkdown('')).toBe('');
|
||||
});
|
||||
|
||||
it('replaces dash list bullets with • so the adapter does not re-emit `*` markers', () => {
|
||||
expect(sanitizeTelegramLegacyMarkdown('- one\n- two')).toBe('• one\n• two');
|
||||
});
|
||||
|
||||
it('preserves indented list structure', () => {
|
||||
expect(sanitizeTelegramLegacyMarkdown(' - nested')).toBe(' • nested');
|
||||
});
|
||||
|
||||
it('flattens Markdown horizontal rules (---, ***, ___)', () => {
|
||||
const input = 'before\n---\n***\n___\nafter';
|
||||
expect(sanitizeTelegramLegacyMarkdown(input)).toBe('before\n⎯⎯⎯\n⎯⎯⎯\n⎯⎯⎯\nafter');
|
||||
});
|
||||
|
||||
it('leaves horizontal rules inside code blocks alone', () => {
|
||||
const input = '```\n---\n```';
|
||||
expect(sanitizeTelegramLegacyMarkdown(input)).toBe(input);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Sanitize outbound text for Telegram's legacy `Markdown` parse mode.
|
||||
*
|
||||
* WORKAROUND: The @chat-adapter/telegram adapter hardcodes parse_mode=Markdown
|
||||
* (legacy) but its converter emits CommonMark. Messages with `**bold**`, odd
|
||||
* delimiter counts, or malformed links are rejected by Telegram and dropped
|
||||
* after retries. Remove this once upstream ships real mode-aware conversion
|
||||
* (vercel/chat PR #367 adds the knob; a follow-up is needed for the converter).
|
||||
*/
|
||||
|
||||
const CODE_PATTERN = /```[\s\S]*?```|`[^`\n]*`/g;
|
||||
const PLACEHOLDER_PREFIX = '\x00CODE';
|
||||
const PLACEHOLDER_SUFFIX = '\x00';
|
||||
|
||||
export function sanitizeTelegramLegacyMarkdown(input: string): string {
|
||||
if (!input) return input;
|
||||
|
||||
const codeSegments: string[] = [];
|
||||
let text = input.replace(CODE_PATTERN, (m) => {
|
||||
codeSegments.push(m);
|
||||
return `${PLACEHOLDER_PREFIX}${codeSegments.length - 1}${PLACEHOLDER_SUFFIX}`;
|
||||
});
|
||||
|
||||
// The adapter re-parses and re-stringifies markdown before sending, which
|
||||
// rewrites `- item` list bullets into `* item` — injecting unbalanced
|
||||
// asterisks that Telegram's legacy Markdown parser then rejects. Replace
|
||||
// list bullets with a plain Unicode bullet so the adapter treats the line
|
||||
// as prose.
|
||||
text = text.replace(/^(\s*)[-+]\s+/gm, '$1• ');
|
||||
|
||||
// Flatten Markdown horizontal rules (bare --- / *** / ___ lines) to a
|
||||
// plain Unicode divider. The parser doesn't understand HR syntax and the
|
||||
// `*` / `_` characters would otherwise unbalance the delimiter counts below.
|
||||
text = text.replace(/^[ \t]*[-_*]{3,}[ \t]*$/gm, '⎯⎯⎯');
|
||||
|
||||
text = text.replace(/\*\*([^*\n]+?)\*\*/g, '*$1*');
|
||||
text = text.replace(/__([^_\n]+?)__/g, '_$1_');
|
||||
|
||||
const starCount = (text.match(/\*/g) ?? []).length;
|
||||
const underCount = (text.match(/_/g) ?? []).length;
|
||||
if (starCount % 2 !== 0 || underCount % 2 !== 0) {
|
||||
text = text.replace(/[*_]/g, '');
|
||||
}
|
||||
|
||||
const openBrackets = (text.match(/\[/g) ?? []).length;
|
||||
const closeBrackets = (text.match(/\]/g) ?? []).length;
|
||||
if (openBrackets !== closeBrackets) {
|
||||
text = text.replace(/[[\]]/g, '');
|
||||
}
|
||||
|
||||
return text.replace(
|
||||
new RegExp(`${PLACEHOLDER_PREFIX}(\\d+)${PLACEHOLDER_SUFFIX}`, 'g'),
|
||||
(_, i) => codeSegments[Number(i)],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
vi.mock('../log.js', () => ({ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } }));
|
||||
|
||||
import {
|
||||
createPairing,
|
||||
tryConsume,
|
||||
getStatus,
|
||||
getPairing,
|
||||
waitForPairing,
|
||||
extractCode,
|
||||
extractAddressedText,
|
||||
_setStorePathForTest,
|
||||
_resetForTest,
|
||||
} from './telegram-pairing.js';
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tg-pair-'));
|
||||
_setStorePathForTest(path.join(tmpDir, 'pairings.json'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
_resetForTest();
|
||||
_setStorePathForTest(null);
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('extractAddressedText', () => {
|
||||
it('strips @botname prefix', () => {
|
||||
expect(extractAddressedText('@nanobot 1234', 'nanobot')).toBe('1234');
|
||||
});
|
||||
it('is case-insensitive', () => {
|
||||
expect(extractAddressedText('@NanoBot hello', 'nanobot')).toBe('hello');
|
||||
});
|
||||
it('returns null when not addressed', () => {
|
||||
expect(extractAddressedText('hello 1234', 'nanobot')).toBeNull();
|
||||
});
|
||||
it('returns null when address is mid-text', () => {
|
||||
expect(extractAddressedText('hi @nanobot 1234', 'nanobot')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractCode', () => {
|
||||
it('accepts a bare 4-digit code', () => {
|
||||
expect(extractCode('0349', 'nanobot')).toBe('0349');
|
||||
});
|
||||
it('accepts 4-digit code after @botname', () => {
|
||||
expect(extractCode('@nanobot 0042', 'nanobot')).toBe('0042');
|
||||
});
|
||||
it('rejects non-4-digit numbers', () => {
|
||||
expect(extractCode('@nanobot 12345', 'nanobot')).toBeNull();
|
||||
expect(extractCode('@nanobot 12', 'nanobot')).toBeNull();
|
||||
expect(extractCode('12345', 'nanobot')).toBeNull();
|
||||
});
|
||||
it('rejects loose matches with surrounding text', () => {
|
||||
expect(extractCode('my pin is 0349', 'nanobot')).toBeNull();
|
||||
expect(extractCode('0349 thanks', 'nanobot')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPairing', () => {
|
||||
it('generates a 4-digit code', async () => {
|
||||
const r = await createPairing('main');
|
||||
expect(r.code).toMatch(/^\d{4}$/);
|
||||
expect(r.status).toBe('pending');
|
||||
});
|
||||
|
||||
it('does not collide with active codes', async () => {
|
||||
const codes = new Set<string>();
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const r = await createPairing('main');
|
||||
expect(codes.has(r.code)).toBe(false);
|
||||
codes.add(r.code);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('tryConsume', () => {
|
||||
it('matches and marks consumed', async () => {
|
||||
const r = await createPairing('main');
|
||||
const consumed = await tryConsume({
|
||||
text: `@nanobot ${r.code}`,
|
||||
botUsername: 'nanobot',
|
||||
platformId: 'telegram:123',
|
||||
isGroup: false,
|
||||
adminUserId: 'u1',
|
||||
});
|
||||
expect(consumed).not.toBeNull();
|
||||
expect(consumed!.status).toBe('consumed');
|
||||
expect(consumed!.consumed?.platformId).toBe('telegram:123');
|
||||
expect(consumed!.consumed?.adminUserId).toBe('u1');
|
||||
expect(getStatus(r.code)).toBe('consumed');
|
||||
});
|
||||
|
||||
it('returns null on no match (silent drop)', async () => {
|
||||
await createPairing('main');
|
||||
const out = await tryConsume({
|
||||
text: '@nanobot 9999',
|
||||
botUsername: 'nanobot',
|
||||
platformId: 'x',
|
||||
isGroup: false,
|
||||
});
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
|
||||
it('matches a bare code without @botname addressing', async () => {
|
||||
const r = await createPairing('main');
|
||||
const out = await tryConsume({
|
||||
text: r.code,
|
||||
botUsername: 'nanobot',
|
||||
platformId: 'x',
|
||||
isGroup: false,
|
||||
});
|
||||
expect(out).not.toBeNull();
|
||||
expect(out!.status).toBe('consumed');
|
||||
});
|
||||
|
||||
it('cannot be consumed twice', async () => {
|
||||
const r = await createPairing('main');
|
||||
await tryConsume({ text: `@b ${r.code}`, botUsername: 'b', platformId: 'p', isGroup: false });
|
||||
const second = await tryConsume({ text: `@b ${r.code}`, botUsername: 'b', platformId: 'p', isGroup: false });
|
||||
expect(second).toBeNull();
|
||||
});
|
||||
|
||||
it('cannot consume an invalidated pairing', async () => {
|
||||
const r = await createPairing('main');
|
||||
// Invalidate by sending a wrong code
|
||||
await tryConsume({ text: '9999', botUsername: 'b', platformId: 'p', isGroup: false });
|
||||
const out = await tryConsume({ text: `@b ${r.code}`, botUsername: 'b', platformId: 'p', isGroup: false });
|
||||
expect(out).toBeNull();
|
||||
expect(getStatus(r.code)).toBe('invalidated');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('returns unknown for missing codes', () => {
|
||||
expect(getStatus('0000')).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('waitForPairing', () => {
|
||||
it('resolves when consumed', async () => {
|
||||
const r = await createPairing('main');
|
||||
const p = waitForPairing(r.code, { pollMs: 50 });
|
||||
setTimeout(() => {
|
||||
tryConsume({ text: `@b ${r.code}`, botUsername: 'b', platformId: 'tg:1', isGroup: true, name: 'Group' });
|
||||
}, 100);
|
||||
const consumed = await p;
|
||||
expect(consumed.status).toBe('consumed');
|
||||
expect(consumed.consumed?.name).toBe('Group');
|
||||
});
|
||||
|
||||
it('rejects on invalidation', async () => {
|
||||
const r = await createPairing('main');
|
||||
const waiter = waitForPairing(r.code, { pollMs: 30 });
|
||||
setTimeout(() => {
|
||||
tryConsume({ text: '0000', botUsername: 'b', platformId: 'tg:1', isGroup: false });
|
||||
}, 60);
|
||||
await expect(waiter).rejects.toThrow(/invalidated/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replace-by-default', () => {
|
||||
it('supersedes an existing pending pairing with the same intent', async () => {
|
||||
const first = await createPairing('main');
|
||||
const second = await createPairing('main');
|
||||
expect(getStatus(first.code)).toBe('invalidated');
|
||||
expect(getStatus(second.code)).toBe('pending');
|
||||
});
|
||||
|
||||
it('does not supersede pairings with a different intent', async () => {
|
||||
const a = await createPairing({ kind: 'wire-to', folder: 'work' });
|
||||
const b = await createPairing({ kind: 'wire-to', folder: 'side' });
|
||||
expect(getStatus(a.code)).toBe('pending');
|
||||
expect(getStatus(b.code)).toBe('pending');
|
||||
});
|
||||
|
||||
it('causes waitForPairing on the old code to reject as invalidated', async () => {
|
||||
const first = await createPairing('main');
|
||||
const waiter = waitForPairing(first.code, { pollMs: 30 });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await createPairing('main');
|
||||
await expect(waiter).rejects.toThrow(/invalidated/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attempt tracking', () => {
|
||||
it('fires onAttempt for a wrong code, invalidates the pairing, and rejects the waiter', async () => {
|
||||
const r = await createPairing('main');
|
||||
const attempts: string[] = [];
|
||||
const waiter = waitForPairing(r.code, {
|
||||
pollMs: 30,
|
||||
onAttempt: (a) => attempts.push(a.candidate),
|
||||
});
|
||||
setTimeout(() => {
|
||||
tryConsume({ text: '9999', botUsername: 'b', platformId: 'tg:1', isGroup: false });
|
||||
}, 60);
|
||||
await expect(waiter).rejects.toThrow(/invalidated by wrong code \(9999\)/);
|
||||
expect(attempts).toEqual(['9999']);
|
||||
expect(getStatus(r.code)).toBe('invalidated');
|
||||
});
|
||||
|
||||
it('a correct code consumes without firing onAttempt', async () => {
|
||||
const r = await createPairing('main');
|
||||
const attempts: string[] = [];
|
||||
const waiter = waitForPairing(r.code, {
|
||||
pollMs: 30,
|
||||
onAttempt: (a) => attempts.push(a.candidate),
|
||||
});
|
||||
setTimeout(() => {
|
||||
tryConsume({ text: r.code, botUsername: 'b', platformId: 'tg:1', isGroup: false });
|
||||
}, 60);
|
||||
const consumed = await waiter;
|
||||
expect(consumed.status).toBe('consumed');
|
||||
expect(attempts).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores non-code messages and keeps the pairing pending', async () => {
|
||||
const r = await createPairing('main');
|
||||
await tryConsume({ text: 'hello there', botUsername: 'b', platformId: 'p', isGroup: false });
|
||||
const after = getPairing(r.code);
|
||||
expect(after?.status).toBe('pending');
|
||||
expect(after?.attempts ?? []).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('a second code attempt after invalidation does not match', async () => {
|
||||
const r = await createPairing('main');
|
||||
await tryConsume({ text: '9999', botUsername: 'b', platformId: 'p', isGroup: false });
|
||||
const retry = await tryConsume({ text: r.code, botUsername: 'b', platformId: 'p', isGroup: false });
|
||||
expect(retry).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('intent passthrough', () => {
|
||||
it('preserves wire-to and new-agent intents', async () => {
|
||||
const a = await createPairing({ kind: 'wire-to', folder: 'work' });
|
||||
const b = await createPairing({ kind: 'new-agent', folder: 'side' });
|
||||
const ca = await tryConsume({ text: `@b ${a.code}`, botUsername: 'b', platformId: 'p1', isGroup: true });
|
||||
const cb = await tryConsume({ text: `@b ${b.code}`, botUsername: 'b', platformId: 'p2', isGroup: true });
|
||||
expect(ca!.intent).toEqual({ kind: 'wire-to', folder: 'work' });
|
||||
expect(cb!.intent).toEqual({ kind: 'new-agent', folder: 'side' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* Telegram pairing — proves the operator owns the chat they're registering.
|
||||
*
|
||||
* BotFather hands out tokens with no user binding, so anyone who guesses the
|
||||
* bot's username can DM it. Pairing closes that gap: setup creates a one-time
|
||||
* 4-digit code and the operator echoes it back from the chat they want to
|
||||
* register. The message must be exactly the 4 digits (optionally prefixed by
|
||||
* `@botname ` for groups with privacy ON) — arbitrary messages that happen to
|
||||
* contain a 4-digit number do NOT match. The inbound interceptor in
|
||||
* telegram.ts matches the code, records the chat, upserts the paired user,
|
||||
* and (if no owner exists yet) promotes them to owner — all before the
|
||||
* message ever reaches the router.
|
||||
*
|
||||
* Storage is a JSON file at data/telegram-pairings.json — single-process,
|
||||
* read-modify-write under an in-process mutex.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR } from '../config.js';
|
||||
import { log } from '../log.js';
|
||||
|
||||
export type PairingIntent = 'main' | { kind: 'wire-to'; folder: string } | { kind: 'new-agent'; folder: string };
|
||||
export type PairingStatus = 'pending' | 'consumed' | 'invalidated' | 'unknown';
|
||||
|
||||
export interface ConsumedDetails {
|
||||
platformId: string;
|
||||
isGroup: boolean;
|
||||
name: string | null;
|
||||
adminUserId: string | null;
|
||||
consumedAt: string;
|
||||
}
|
||||
|
||||
export interface PairingAttempt {
|
||||
candidate: string;
|
||||
platformId: string;
|
||||
at: string;
|
||||
matched: boolean;
|
||||
}
|
||||
|
||||
export interface PairingRecord {
|
||||
code: string;
|
||||
intent: PairingIntent;
|
||||
createdAt: string;
|
||||
status: Exclude<PairingStatus, 'unknown'>;
|
||||
consumed?: ConsumedDetails;
|
||||
/** Recent pairing attempts observed while this record was pending. Capped. */
|
||||
attempts?: PairingAttempt[];
|
||||
}
|
||||
|
||||
const MAX_ATTEMPTS_PER_RECORD = 10;
|
||||
|
||||
function intentEquals(a: PairingIntent, b: PairingIntent): boolean {
|
||||
if (a === 'main' || b === 'main') return a === b;
|
||||
return a.kind === b.kind && a.folder === b.folder;
|
||||
}
|
||||
|
||||
interface Store {
|
||||
pairings: PairingRecord[];
|
||||
}
|
||||
|
||||
/** Pairing codes do not expire — they are consumed on match or invalidated by wrong guesses. */
|
||||
const FILE_NAME = 'telegram-pairings.json';
|
||||
|
||||
let storePathOverride: string | null = null;
|
||||
export function _setStorePathForTest(p: string | null): void {
|
||||
storePathOverride = p;
|
||||
}
|
||||
|
||||
function storePath(): string {
|
||||
return storePathOverride ?? path.join(DATA_DIR, FILE_NAME);
|
||||
}
|
||||
|
||||
let mutex: Promise<unknown> = Promise.resolve();
|
||||
function withLock<T>(fn: () => Promise<T> | T): Promise<T> {
|
||||
const next = mutex.then(() => fn());
|
||||
mutex = next.catch(() => {});
|
||||
return next;
|
||||
}
|
||||
|
||||
function readStore(): Store {
|
||||
try {
|
||||
const raw = fs.readFileSync(storePath(), 'utf8');
|
||||
const parsed = JSON.parse(raw) as Store;
|
||||
if (!Array.isArray(parsed.pairings)) return { pairings: [] };
|
||||
return parsed;
|
||||
} catch {
|
||||
return { pairings: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function writeStore(store: Store): void {
|
||||
const p = storePath();
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
const tmp = `${p}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(store, null, 2));
|
||||
fs.renameSync(tmp, p);
|
||||
}
|
||||
|
||||
/** Clean up old consumed/invalidated records (keep last 50). */
|
||||
function sweep(store: Store): boolean {
|
||||
if (store.pairings.length <= 50) return false;
|
||||
store.pairings = store.pairings.slice(-50);
|
||||
return true;
|
||||
}
|
||||
|
||||
function generateCode(active: Set<string>): string {
|
||||
// 4-digit numeric, zero-padded. 10k space, fine for one-at-a-time intents.
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const code = Math.floor(Math.random() * 10000)
|
||||
.toString()
|
||||
.padStart(4, '0');
|
||||
if (!active.has(code)) return code;
|
||||
}
|
||||
throw new Error('Could not allocate a free pairing code (too many active).');
|
||||
}
|
||||
|
||||
export async function createPairing(intent: PairingIntent): Promise<PairingRecord> {
|
||||
return withLock(() => {
|
||||
const store = readStore();
|
||||
sweep(store);
|
||||
// Replace-by-default: a new pairing for an intent supersedes any existing
|
||||
// pending pairing for the same intent. Old waitForPairing calls observe
|
||||
// `invalidated` and exit on their own.
|
||||
for (const r of store.pairings) {
|
||||
if (r.status === 'pending' && intentEquals(r.intent, intent)) {
|
||||
r.status = 'invalidated';
|
||||
log.info('Pairing superseded by new request', { code: r.code, intent });
|
||||
}
|
||||
}
|
||||
const active = new Set(store.pairings.filter((r) => r.status === 'pending').map((r) => r.code));
|
||||
const record: PairingRecord = {
|
||||
code: generateCode(active),
|
||||
intent,
|
||||
createdAt: new Date().toISOString(),
|
||||
status: 'pending',
|
||||
};
|
||||
store.pairings.push(record);
|
||||
writeStore(store);
|
||||
log.info('Pairing created', { code: record.code, intent });
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
export interface ConsumeInput {
|
||||
text: string;
|
||||
botUsername: string;
|
||||
platformId: string;
|
||||
isGroup: boolean;
|
||||
name?: string | null;
|
||||
adminUserId?: string | null;
|
||||
}
|
||||
|
||||
/** Strip leading @botname and return the trimmed remainder, or null if not addressed. */
|
||||
export function extractAddressedText(text: string, botUsername: string): string | null {
|
||||
const trimmed = text.trim();
|
||||
const re = new RegExp(`^@${botUsername.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\b`, 'i');
|
||||
const m = trimmed.match(re);
|
||||
if (!m) return null;
|
||||
return trimmed.slice(m[0].length).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a pairing code from an inbound message. The message must be exactly
|
||||
* 4 digits (optionally prefixed by `@botname `) — loose matches like
|
||||
* "my pin is 1234" are rejected to avoid false positives from chatter.
|
||||
*/
|
||||
export function extractCode(text: string, botUsername: string): string | null {
|
||||
const addressed = extractAddressedText(text, botUsername);
|
||||
const candidate = (addressed !== null ? addressed : text).trim();
|
||||
const m = candidate.match(/^(\d{4})$/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to match an inbound message against a pending pairing. On match,
|
||||
* marks the pairing consumed atomically and returns the record. Returns
|
||||
* null on no match or expiry (silent drop).
|
||||
*/
|
||||
export async function tryConsume(input: ConsumeInput): Promise<PairingRecord | null> {
|
||||
const code = extractCode(input.text, input.botUsername);
|
||||
if (!code) return null;
|
||||
return withLock(() => {
|
||||
const store = readStore();
|
||||
const now = Date.now();
|
||||
sweep(store);
|
||||
const record = store.pairings.find((r) => r.code === code && r.status === 'pending');
|
||||
if (!record) {
|
||||
// Miss: record the attempt on every currently-pending record so each
|
||||
// waitForPairing caller can surface it as user feedback.
|
||||
const attempt: PairingAttempt = {
|
||||
candidate: code,
|
||||
platformId: input.platformId,
|
||||
at: new Date(now).toISOString(),
|
||||
matched: false,
|
||||
};
|
||||
let recorded = false;
|
||||
for (const r of store.pairings) {
|
||||
if (r.status !== 'pending') continue;
|
||||
r.attempts = [...(r.attempts ?? []), attempt].slice(-MAX_ATTEMPTS_PER_RECORD);
|
||||
// One attempt per code. A wrong guess invalidates the pairing
|
||||
// immediately — pair-telegram observes the `invalidated` signal and
|
||||
// auto-issues a fresh code (up to a retry cap).
|
||||
r.status = 'invalidated';
|
||||
recorded = true;
|
||||
}
|
||||
writeStore(store);
|
||||
if (recorded) {
|
||||
log.info('Pairing invalidated by wrong attempt', { candidate: code, platformId: input.platformId });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
record.status = 'consumed';
|
||||
record.consumed = {
|
||||
platformId: input.platformId,
|
||||
isGroup: input.isGroup,
|
||||
name: input.name ?? null,
|
||||
adminUserId: input.adminUserId ?? null,
|
||||
consumedAt: new Date(now).toISOString(),
|
||||
};
|
||||
record.attempts = [
|
||||
...(record.attempts ?? []),
|
||||
{ candidate: code, platformId: input.platformId, at: new Date(now).toISOString(), matched: true },
|
||||
].slice(-MAX_ATTEMPTS_PER_RECORD);
|
||||
writeStore(store);
|
||||
log.info('Pairing consumed', { code, platformId: input.platformId, intent: record.intent });
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
export function getStatus(code: string): PairingStatus {
|
||||
const store = readStore();
|
||||
sweep(store);
|
||||
const r = store.pairings.find((p) => p.code === code);
|
||||
if (!r) return 'unknown';
|
||||
return r.status;
|
||||
}
|
||||
|
||||
export function getPairing(code: string): PairingRecord | null {
|
||||
const store = readStore();
|
||||
sweep(store);
|
||||
return store.pairings.find((p) => p.code === code) ?? null;
|
||||
}
|
||||
|
||||
export interface WaitForPairingOptions {
|
||||
/** Polling interval as a fallback when fs.watch misses an event. */
|
||||
pollMs?: number;
|
||||
/** Fires once per new attempt recorded against this pairing (misses only). */
|
||||
onAttempt?: (attempt: PairingAttempt) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve when the pairing is consumed; reject when it is invalidated
|
||||
* (wrong code guess). Waits indefinitely — codes do not expire.
|
||||
* Uses fs.watch as the primary signal with a slow poll fallback.
|
||||
*/
|
||||
export async function waitForPairing(code: string, opts: WaitForPairingOptions = {}): Promise<PairingRecord> {
|
||||
const pollMs = opts.pollMs ?? 1000;
|
||||
const initial = getPairing(code);
|
||||
if (!initial) throw new Error(`Unknown pairing code: ${code}`);
|
||||
|
||||
return new Promise<PairingRecord>((resolve, reject) => {
|
||||
let watcher: fs.FSWatcher | null = null;
|
||||
let interval: NodeJS.Timeout | null = null;
|
||||
let settled = false;
|
||||
|
||||
const cleanup = () => {
|
||||
settled = true;
|
||||
if (watcher)
|
||||
try {
|
||||
watcher.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
|
||||
let seenAttempts = 0;
|
||||
const check = () => {
|
||||
if (settled) return;
|
||||
const r = getPairing(code);
|
||||
if (!r) {
|
||||
cleanup();
|
||||
reject(new Error(`Pairing ${code} disappeared`));
|
||||
return;
|
||||
}
|
||||
// Surface any new miss attempts since the last tick. Only fire for
|
||||
// misses — matches are signaled by `status === 'consumed'` below.
|
||||
if (opts.onAttempt && r.attempts) {
|
||||
for (let i = seenAttempts; i < r.attempts.length; i++) {
|
||||
const a = r.attempts[i];
|
||||
if (!a.matched) {
|
||||
try {
|
||||
opts.onAttempt(a);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
seenAttempts = r.attempts.length;
|
||||
}
|
||||
if (r.status === 'consumed') {
|
||||
cleanup();
|
||||
resolve(r);
|
||||
return;
|
||||
}
|
||||
if (r.status === 'invalidated') {
|
||||
cleanup();
|
||||
const lastMiss = r.attempts
|
||||
?.slice()
|
||||
.reverse()
|
||||
.find((a) => !a.matched);
|
||||
reject(new Error(`Pairing ${code} invalidated by wrong code${lastMiss ? ` (${lastMiss.candidate})` : ''}`));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const dir = path.dirname(storePath());
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
watcher = fs.watch(dir, (_event, fname) => {
|
||||
if (!fname || fname.toString().startsWith(path.basename(storePath()))) check();
|
||||
});
|
||||
} catch {
|
||||
// fs.watch unsupported — poll-only is fine
|
||||
}
|
||||
interval = setInterval(check, pollMs);
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
/** Test helper — wipe the store. */
|
||||
export function _resetForTest(): void {
|
||||
try {
|
||||
fs.unlinkSync(storePath());
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Telegram channel adapter (v2) — uses Chat SDK bridge, with a pairing
|
||||
* interceptor wrapped around onInbound to verify chat ownership before
|
||||
* registration. See telegram-pairing.ts for the why.
|
||||
*/
|
||||
import { createTelegramAdapter } from '@chat-adapter/telegram';
|
||||
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { log } from '../log.js';
|
||||
import { createMessagingGroup, getMessagingGroupByPlatform, updateMessagingGroup } from '../db/messaging-groups.js';
|
||||
import { grantRole, hasAnyOwner } from '../modules/permissions/db/user-roles.js';
|
||||
import { upsertUser } from '../modules/permissions/db/users.js';
|
||||
import { createChatSdkBridge, type ReplyContext } from './chat-sdk-bridge.js';
|
||||
import { sanitizeTelegramLegacyMarkdown } from './telegram-markdown-sanitize.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
import type { ChannelAdapter, ChannelSetup, InboundMessage } from './adapter.js';
|
||||
import { tryConsume } from './telegram-pairing.js';
|
||||
|
||||
/**
|
||||
* Retry a one-shot operation that can fail on transient network errors at
|
||||
* cold-start (DNS hiccups, brief upstream outages). Exponential backoff capped
|
||||
* at 5 attempts — if the network is truly down we surface it instead of
|
||||
* hanging the service indefinitely.
|
||||
*/
|
||||
async function withRetry<T>(fn: () => Promise<T>, label: string, maxAttempts = 5): Promise<T> {
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (attempt === maxAttempts) break;
|
||||
const delay = Math.min(16000, 1000 * 2 ** (attempt - 1));
|
||||
log.warn('Telegram setup failed, retrying', { label, attempt, delayMs: delay, err });
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function extractReplyContext(raw: Record<string, any>): ReplyContext | null {
|
||||
if (!raw.reply_to_message) return null;
|
||||
const reply = raw.reply_to_message;
|
||||
return {
|
||||
text: reply.text || reply.caption || '',
|
||||
sender: reply.from?.first_name || reply.from?.username || 'Unknown',
|
||||
};
|
||||
}
|
||||
|
||||
/** Look up the bot username via Telegram getMe. Cached after first call. */
|
||||
async function fetchBotUsername(token: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(`https://api.telegram.org/bot${token}/getMe`);
|
||||
const json = (await res.json()) as { ok: boolean; result?: { username?: string } };
|
||||
return json.ok ? (json.result?.username ?? null) : null;
|
||||
} catch (err) {
|
||||
log.warn('Telegram getMe failed', { err });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isGroupPlatformId(platformId: string): boolean {
|
||||
// platformId is "telegram:<chatId>". Negative chat IDs are groups/channels.
|
||||
const id = platformId.split(':').pop() ?? '';
|
||||
return id.startsWith('-');
|
||||
}
|
||||
|
||||
interface InboundFields {
|
||||
text: string;
|
||||
authorUserId: string | null;
|
||||
}
|
||||
|
||||
function readInboundFields(message: InboundMessage): InboundFields {
|
||||
if (message.kind !== 'chat-sdk' || !message.content || typeof message.content !== 'object') {
|
||||
return { text: '', authorUserId: null };
|
||||
}
|
||||
const c = message.content as { text?: string; author?: { userId?: string } };
|
||||
return { text: c.text ?? '', authorUserId: c.author?.userId ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an onInbound interceptor that consumes pairing codes before they
|
||||
* reach the router. On match: records the chat + its paired user, promotes
|
||||
* the user to owner if the instance has no owner yet, and short-circuits.
|
||||
* On miss: forwards to the host.
|
||||
*/
|
||||
/**
|
||||
* Send a one-shot confirmation back to the paired chat. Best-effort — failures
|
||||
* are logged but never propagated, so a Telegram outage can't undo a successful
|
||||
* pairing or trigger the interceptor's fail-open path.
|
||||
*/
|
||||
async function sendPairingConfirmation(token: string, platformId: string): Promise<void> {
|
||||
const chatId = platformId.split(':').slice(1).join(':');
|
||||
if (!chatId) return;
|
||||
try {
|
||||
const res = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text: 'Pairing success! Head back to the NanoClaw installer to finish setup.',
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
log.warn('Telegram pairing confirmation non-OK', { status: res.status });
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('Telegram pairing confirmation failed', { err });
|
||||
}
|
||||
}
|
||||
|
||||
function createPairingInterceptor(
|
||||
botUsernamePromise: Promise<string | null>,
|
||||
hostOnInbound: ChannelSetup['onInbound'],
|
||||
token: string,
|
||||
): ChannelSetup['onInbound'] {
|
||||
return async (platformId, threadId, message) => {
|
||||
try {
|
||||
const botUsername = await botUsernamePromise;
|
||||
if (!botUsername) {
|
||||
hostOnInbound(platformId, threadId, message);
|
||||
return;
|
||||
}
|
||||
const { text, authorUserId } = readInboundFields(message);
|
||||
if (!text) {
|
||||
hostOnInbound(platformId, threadId, message);
|
||||
return;
|
||||
}
|
||||
const consumed = await tryConsume({
|
||||
text,
|
||||
botUsername,
|
||||
platformId,
|
||||
isGroup: isGroupPlatformId(platformId),
|
||||
adminUserId: authorUserId,
|
||||
});
|
||||
if (!consumed) {
|
||||
hostOnInbound(platformId, threadId, message);
|
||||
return;
|
||||
}
|
||||
// Pairing matched — record the chat and short-circuit so the
|
||||
// code-bearing message never reaches an agent. Privilege is now a
|
||||
// property of the paired user, not the chat: upsert the user, and if
|
||||
// this instance has no owner yet, promote them to owner.
|
||||
const existing = getMessagingGroupByPlatform('telegram', platformId);
|
||||
if (existing) {
|
||||
updateMessagingGroup(existing.id, {
|
||||
is_group: consumed.consumed!.isGroup ? 1 : 0,
|
||||
});
|
||||
} else {
|
||||
createMessagingGroup({
|
||||
id: `mg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
channel_type: 'telegram',
|
||||
platform_id: platformId,
|
||||
name: consumed.consumed!.name,
|
||||
is_group: consumed.consumed!.isGroup ? 1 : 0,
|
||||
unknown_sender_policy: 'strict',
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const pairedUserId = `telegram:${consumed.consumed!.adminUserId}`;
|
||||
upsertUser({
|
||||
id: pairedUserId,
|
||||
kind: 'telegram',
|
||||
display_name: null,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
let promotedToOwner = false;
|
||||
if (!hasAnyOwner()) {
|
||||
grantRole({
|
||||
user_id: pairedUserId,
|
||||
role: 'owner',
|
||||
agent_group_id: null,
|
||||
granted_by: null,
|
||||
granted_at: new Date().toISOString(),
|
||||
});
|
||||
promotedToOwner = true;
|
||||
}
|
||||
|
||||
log.info('Telegram pairing accepted — chat registered', {
|
||||
platformId,
|
||||
pairedUser: pairedUserId,
|
||||
promotedToOwner,
|
||||
intent: consumed.intent,
|
||||
});
|
||||
|
||||
await sendPairingConfirmation(token, platformId);
|
||||
} catch (err) {
|
||||
log.error('Telegram pairing interceptor error', { err });
|
||||
// Fail open: pass through so a pairing bug doesn't break normal traffic.
|
||||
hostOnInbound(platformId, threadId, message);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
registerChannelAdapter('telegram', {
|
||||
factory: () => {
|
||||
const env = readEnvFile(['TELEGRAM_BOT_TOKEN']);
|
||||
if (!env.TELEGRAM_BOT_TOKEN) return null;
|
||||
const token = env.TELEGRAM_BOT_TOKEN;
|
||||
const telegramAdapter = createTelegramAdapter({
|
||||
botToken: token,
|
||||
mode: 'polling',
|
||||
});
|
||||
const bridge = createChatSdkBridge({
|
||||
adapter: telegramAdapter,
|
||||
concurrency: 'concurrent',
|
||||
extractReplyContext,
|
||||
supportsThreads: false,
|
||||
transformOutboundText: sanitizeTelegramLegacyMarkdown,
|
||||
maxTextLength: 4000,
|
||||
});
|
||||
|
||||
const botUsernamePromise = fetchBotUsername(token);
|
||||
|
||||
const wrapped: ChannelAdapter = {
|
||||
...bridge,
|
||||
resolveChannelName: async (platformId: string) => {
|
||||
const chatId = platformId.split(':').slice(1).join(':');
|
||||
if (!chatId) return null;
|
||||
try {
|
||||
const res = await fetch(`https://api.telegram.org/bot${token}/getChat`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ chat_id: chatId }),
|
||||
});
|
||||
const data = (await res.json()) as { ok?: boolean; result?: { title?: string } };
|
||||
return data.ok ? (data.result?.title ?? null) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async setup(hostConfig: ChannelSetup) {
|
||||
const intercepted: ChannelSetup = {
|
||||
...hostConfig,
|
||||
onInbound: createPairingInterceptor(botUsernamePromise, hostConfig.onInbound, token),
|
||||
};
|
||||
return withRetry(() => bridge.setup(intercepted), 'bridge.setup');
|
||||
},
|
||||
};
|
||||
return wrapped;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Webex channel adapter (v2) — uses Chat SDK bridge.
|
||||
* Self-registers on import.
|
||||
*/
|
||||
import { createWebexAdapter } from '@bitbasti/chat-adapter-webex';
|
||||
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { createChatSdkBridge } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
registerChannelAdapter('webex', {
|
||||
factory: () => {
|
||||
const env = readEnvFile(['WEBEX_BOT_TOKEN', 'WEBEX_WEBHOOK_SECRET']);
|
||||
if (!env.WEBEX_BOT_TOKEN) return null;
|
||||
const webexAdapter = createWebexAdapter({
|
||||
botToken: env.WEBEX_BOT_TOKEN,
|
||||
webhookSecret: env.WEBEX_WEBHOOK_SECRET,
|
||||
});
|
||||
return createChatSdkBridge({ adapter: webexAdapter, concurrency: 'concurrent', supportsThreads: true });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* WeChat channel adapter — uses Tencent's official iLink Bot API.
|
||||
*
|
||||
* Unlike puppet-based libraries (wechaty/PadLocal) this uses the first-party
|
||||
* Tencent API. No ban risk. Free. Works with any personal WeChat account.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Factory gated on WECHAT_ENABLED=true in .env.
|
||||
* 2. On setup, load saved auth if present; otherwise run QR login.
|
||||
* The QR URL is written to data/wechat/qr.txt and logged.
|
||||
* 3. Long-poll for messages via WeChatClient, cursor persisted between
|
||||
* restarts so no messages are dropped.
|
||||
* 4. Outbound via sendText — context_token auto-cached by the client.
|
||||
*
|
||||
* Self-registers on import.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { WeChatClient, MessageType, type WeixinMessage } from 'wechat-ilink-client';
|
||||
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { DATA_DIR } from '../config.js';
|
||||
import { log } from '../log.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
import type { ChannelAdapter, ChannelSetup, InboundMessage, OutboundMessage } from './adapter.js';
|
||||
|
||||
const DATA_SUBDIR = path.join(DATA_DIR, 'wechat');
|
||||
const AUTH_FILE = path.join(DATA_SUBDIR, 'auth.json');
|
||||
const SYNC_BUF_FILE = path.join(DATA_SUBDIR, 'sync-buf.txt');
|
||||
const QR_FILE = path.join(DATA_SUBDIR, 'qr.txt');
|
||||
|
||||
interface SavedAuth {
|
||||
botToken: string;
|
||||
accountId: string;
|
||||
baseUrl?: string;
|
||||
/** The WeChat user_id of whoever scanned the QR — i.e. the operator. */
|
||||
operatorUserId?: string;
|
||||
}
|
||||
|
||||
function loadAuth(): SavedAuth | null {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(AUTH_FILE, 'utf8')) as SavedAuth;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveAuth(auth: SavedAuth): void {
|
||||
fs.mkdirSync(DATA_SUBDIR, { recursive: true });
|
||||
fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2));
|
||||
}
|
||||
|
||||
function loadSyncBuf(): string | undefined {
|
||||
try {
|
||||
return fs.readFileSync(SYNC_BUF_FILE, 'utf8');
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function saveSyncBuf(buf: string): void {
|
||||
fs.mkdirSync(DATA_SUBDIR, { recursive: true });
|
||||
fs.writeFileSync(SYNC_BUF_FILE, buf);
|
||||
}
|
||||
|
||||
function writeQr(url: string): void {
|
||||
fs.mkdirSync(DATA_SUBDIR, { recursive: true });
|
||||
fs.writeFileSync(QR_FILE, url);
|
||||
}
|
||||
|
||||
function messageText(msg: OutboundMessage): string {
|
||||
if (typeof msg.content === 'string') return msg.content;
|
||||
const c = msg.content as Record<string, unknown>;
|
||||
return (c.text as string) || (c.markdown as string) || JSON.stringify(msg.content);
|
||||
}
|
||||
|
||||
registerChannelAdapter('wechat', {
|
||||
factory: () => {
|
||||
const env = readEnvFile(['WECHAT_ENABLED']);
|
||||
if (env.WECHAT_ENABLED !== 'true') return null;
|
||||
|
||||
let client: WeChatClient | null = null;
|
||||
let setupConfig: ChannelSetup;
|
||||
let connected = false;
|
||||
let accountId: string | undefined;
|
||||
|
||||
async function ensureLoggedIn(): Promise<WeChatClient> {
|
||||
const saved = loadAuth();
|
||||
if (saved) {
|
||||
const c = new WeChatClient({
|
||||
token: saved.botToken,
|
||||
baseUrl: saved.baseUrl,
|
||||
accountId: saved.accountId,
|
||||
});
|
||||
accountId = saved.accountId;
|
||||
log.info('WeChat: resumed from saved auth', { accountId });
|
||||
return c;
|
||||
}
|
||||
|
||||
const c = new WeChatClient();
|
||||
const result = await c.login({
|
||||
onQRCode: (url) => {
|
||||
writeQr(url);
|
||||
log.info('WeChat QR ready — open this URL in a browser and scan with the WeChat app', { url });
|
||||
},
|
||||
});
|
||||
if (!result.connected || !result.botToken || !result.accountId) {
|
||||
throw new Error(`WeChat login failed: ${result.message}`);
|
||||
}
|
||||
saveAuth({
|
||||
botToken: result.botToken,
|
||||
accountId: result.accountId,
|
||||
baseUrl: result.baseUrl,
|
||||
operatorUserId: result.userId,
|
||||
});
|
||||
accountId = result.accountId;
|
||||
log.info('WeChat: login complete', { accountId, operatorUserId: result.userId });
|
||||
return c;
|
||||
}
|
||||
|
||||
function onMessage(msg: WeixinMessage): void {
|
||||
if (msg.message_type !== MessageType.USER) return;
|
||||
|
||||
const isGroup = !!msg.group_id;
|
||||
const platformIdRaw = isGroup ? msg.group_id! : msg.from_user_id!;
|
||||
const platformId = `wechat:${platformIdRaw}`;
|
||||
const senderId = `wechat:${msg.from_user_id ?? 'unknown'}`;
|
||||
const text = WeChatClient.extractText(msg);
|
||||
|
||||
log.info('WeChat inbound', {
|
||||
platformId,
|
||||
senderId,
|
||||
isGroup,
|
||||
hint: 'if not wired yet, run: pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts',
|
||||
});
|
||||
|
||||
setupConfig.onMetadata(platformId, undefined, isGroup);
|
||||
|
||||
const inbound: InboundMessage = {
|
||||
id: String(msg.message_id ?? msg.seq ?? Date.now()),
|
||||
kind: 'chat',
|
||||
content: {
|
||||
text,
|
||||
senderId,
|
||||
sender: msg.from_user_id,
|
||||
senderName: msg.from_user_id,
|
||||
isGroup,
|
||||
},
|
||||
timestamp: new Date(msg.create_time_ms ?? Date.now()).toISOString(),
|
||||
};
|
||||
|
||||
setupConfig.onInbound(platformId, null, inbound);
|
||||
}
|
||||
|
||||
const adapter: ChannelAdapter = {
|
||||
name: 'wechat',
|
||||
channelType: 'wechat',
|
||||
supportsThreads: false,
|
||||
|
||||
async setup(config: ChannelSetup) {
|
||||
setupConfig = config;
|
||||
|
||||
client = await ensureLoggedIn();
|
||||
|
||||
client.on('message', (msg) => {
|
||||
try {
|
||||
onMessage(msg);
|
||||
} catch (err) {
|
||||
log.warn('WeChat: onMessage error', { err });
|
||||
}
|
||||
});
|
||||
client.on('error', (err) => log.warn('WeChat: poll error', { err }));
|
||||
client.on('sessionExpired', () => {
|
||||
log.error('WeChat: session expired — delete data/wechat/auth.json and restart to re-scan');
|
||||
connected = false;
|
||||
});
|
||||
|
||||
client
|
||||
.start({
|
||||
loadSyncBuf,
|
||||
saveSyncBuf,
|
||||
})
|
||||
.catch((err) => log.error('WeChat: monitor loop crashed', { err }));
|
||||
|
||||
connected = true;
|
||||
log.info('WeChat adapter ready', { accountId });
|
||||
},
|
||||
|
||||
async teardown() {
|
||||
connected = false;
|
||||
client?.stop();
|
||||
client = null;
|
||||
},
|
||||
|
||||
isConnected() {
|
||||
return connected;
|
||||
},
|
||||
|
||||
async deliver(
|
||||
platformId: string,
|
||||
_threadId: string | null,
|
||||
message: OutboundMessage,
|
||||
): Promise<string | undefined> {
|
||||
if (!client) return undefined;
|
||||
const to = platformId.replace(/^wechat:/, '');
|
||||
const text = messageText(message);
|
||||
if (!text) return undefined;
|
||||
try {
|
||||
const msgId = await client.sendText(to, text);
|
||||
return msgId;
|
||||
} catch (err) {
|
||||
log.error('WeChat deliver failed', { platformId, err });
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return adapter;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* WhatsApp Cloud API channel adapter (v2) — uses Chat SDK bridge.
|
||||
* Uses the official Meta WhatsApp Business Cloud API (not Baileys).
|
||||
* Self-registers on import.
|
||||
*/
|
||||
import { createWhatsAppAdapter } from '@chat-adapter/whatsapp';
|
||||
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { createChatSdkBridge } from './chat-sdk-bridge.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
registerChannelAdapter('whatsapp-cloud', {
|
||||
factory: () => {
|
||||
const env = readEnvFile([
|
||||
'WHATSAPP_ACCESS_TOKEN',
|
||||
'WHATSAPP_PHONE_NUMBER_ID',
|
||||
'WHATSAPP_APP_SECRET',
|
||||
'WHATSAPP_VERIFY_TOKEN',
|
||||
]);
|
||||
if (!env.WHATSAPP_ACCESS_TOKEN) return null;
|
||||
const whatsappAdapter = createWhatsAppAdapter({
|
||||
accessToken: env.WHATSAPP_ACCESS_TOKEN,
|
||||
phoneNumberId: env.WHATSAPP_PHONE_NUMBER_ID,
|
||||
appSecret: env.WHATSAPP_APP_SECRET,
|
||||
verifyToken: env.WHATSAPP_VERIFY_TOKEN,
|
||||
});
|
||||
return createChatSdkBridge({ adapter: whatsappAdapter, concurrency: 'concurrent', supportsThreads: false });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Regression coverage for #2560 — group @-mentions of the bot must set
|
||||
* `InboundMessage.isMention`. Before the fix, the inbound construction
|
||||
* site hard-coded `isMention: !isGroup ? true : undefined`, which dropped
|
||||
* every group mention on the floor and prevented the router from waking
|
||||
* the agent on a mention-only trigger.
|
||||
*
|
||||
* The detection logic lives in the exported pure helper `isBotMentionedInGroup`;
|
||||
* the inbound site calls it with `normalized`, `botPhoneJid`, `botLidUser`.
|
||||
* `isMention` is then computed as:
|
||||
*
|
||||
* isMention: !isGroup ? true : botMentionedInGroup ? true : undefined
|
||||
*
|
||||
* Both the helper and the call-site ternary are covered below so a future
|
||||
* refactor that breaks either part fails this suite.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { computeIsMention, isBotMentionedInGroup, parseWhatsAppMentions } from './whatsapp.js';
|
||||
|
||||
const BOT_PHONE_JID = '15550009999@s.whatsapp.net';
|
||||
const BOT_LID_USER = '987654321';
|
||||
|
||||
describe('isBotMentionedInGroup (#2560)', () => {
|
||||
it('detects the bot phone JID in extendedTextMessage.contextInfo.mentionedJid', () => {
|
||||
const normalized = {
|
||||
extendedTextMessage: {
|
||||
text: 'hey @15550009999 take a look',
|
||||
contextInfo: { mentionedJid: [BOT_PHONE_JID] },
|
||||
},
|
||||
};
|
||||
expect(isBotMentionedInGroup(normalized, BOT_PHONE_JID, BOT_LID_USER)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when the bot is not in mentionedJid', () => {
|
||||
const normalized = {
|
||||
extendedTextMessage: {
|
||||
text: 'hey @15551112222 take a look',
|
||||
contextInfo: { mentionedJid: ['15551112222@s.whatsapp.net'] },
|
||||
},
|
||||
};
|
||||
expect(isBotMentionedInGroup(normalized, BOT_PHONE_JID, BOT_LID_USER)).toBe(false);
|
||||
});
|
||||
|
||||
it('detects an LID-only mention when no phone JID is in the list', () => {
|
||||
// Modern WhatsApp clients increasingly emit the LID even when the
|
||||
// human typed a phone-number mention; the phone JID may not appear.
|
||||
const normalized = {
|
||||
extendedTextMessage: {
|
||||
contextInfo: { mentionedJid: [`${BOT_LID_USER}@lid`] },
|
||||
},
|
||||
};
|
||||
expect(isBotMentionedInGroup(normalized, BOT_PHONE_JID, BOT_LID_USER)).toBe(true);
|
||||
});
|
||||
|
||||
it('detects a mention in an image caption', () => {
|
||||
const normalized = {
|
||||
imageMessage: {
|
||||
caption: 'check this @15550009999',
|
||||
contextInfo: { mentionedJid: [BOT_PHONE_JID] },
|
||||
},
|
||||
};
|
||||
expect(isBotMentionedInGroup(normalized, BOT_PHONE_JID, BOT_LID_USER)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false on an empty / missing mentionedJid array', () => {
|
||||
expect(isBotMentionedInGroup({}, BOT_PHONE_JID, BOT_LID_USER)).toBe(false);
|
||||
expect(
|
||||
isBotMentionedInGroup(
|
||||
{ extendedTextMessage: { contextInfo: { mentionedJid: [] } } },
|
||||
BOT_PHONE_JID,
|
||||
BOT_LID_USER,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when neither bot identifier is known', () => {
|
||||
const normalized = {
|
||||
extendedTextMessage: {
|
||||
contextInfo: { mentionedJid: [BOT_PHONE_JID, `${BOT_LID_USER}@lid`] },
|
||||
},
|
||||
};
|
||||
expect(isBotMentionedInGroup(normalized, undefined, undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('InboundMessage.isMention semantics (#2560)', () => {
|
||||
it('is undefined for a group message with no bot mention', () => {
|
||||
expect(computeIsMention(true, false)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is true for a group message where the bot is mentioned', () => {
|
||||
expect(computeIsMention(true, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('is true for a DM regardless of mention state', () => {
|
||||
// DMs are unconditionally mentions — the helper isn't consulted there.
|
||||
expect(computeIsMention(false, false)).toBe(true);
|
||||
expect(computeIsMention(false, true)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseWhatsAppMentions', () => {
|
||||
it('returns empty mentions for plain text', () => {
|
||||
const { text, mentions } = parseWhatsAppMentions('hello there');
|
||||
expect(text).toBe('hello there');
|
||||
expect(mentions).toEqual([]);
|
||||
});
|
||||
|
||||
it('extracts a single @<digits> mention into a JID', () => {
|
||||
const { text, mentions } = parseWhatsAppMentions('hey @15551234567 you around?');
|
||||
expect(text).toBe('hey @15551234567 you around?');
|
||||
expect(mentions).toEqual(['15551234567@s.whatsapp.net']);
|
||||
});
|
||||
|
||||
it('strips a leading + so the literal text matches the JID digits', () => {
|
||||
const { text, mentions } = parseWhatsAppMentions('ping @+15551234567 please');
|
||||
expect(text).toBe('ping @15551234567 please');
|
||||
expect(mentions).toEqual(['15551234567@s.whatsapp.net']);
|
||||
});
|
||||
|
||||
it('matches a mention at the start of the string', () => {
|
||||
const { text, mentions } = parseWhatsAppMentions('@15551234567 hi');
|
||||
expect(text).toBe('@15551234567 hi');
|
||||
expect(mentions).toEqual(['15551234567@s.whatsapp.net']);
|
||||
});
|
||||
|
||||
it('extracts multiple distinct mentions', () => {
|
||||
const { text, mentions } = parseWhatsAppMentions('cc @15551234567 and @17775556666');
|
||||
expect(text).toBe('cc @15551234567 and @17775556666');
|
||||
expect(mentions).toEqual(['15551234567@s.whatsapp.net', '17775556666@s.whatsapp.net']);
|
||||
});
|
||||
|
||||
it('deduplicates repeated mentions of the same number', () => {
|
||||
const { mentions } = parseWhatsAppMentions('@15551234567 ping @15551234567 again');
|
||||
expect(mentions).toEqual(['15551234567@s.whatsapp.net']);
|
||||
});
|
||||
|
||||
it('does not tag email-like patterns', () => {
|
||||
const { text, mentions } = parseWhatsAppMentions('write to test@1234567890.com');
|
||||
expect(text).toBe('write to test@1234567890.com');
|
||||
expect(mentions).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not tag sequences shorter than 5 digits', () => {
|
||||
const { text, mentions } = parseWhatsAppMentions('see issue @123 for details');
|
||||
expect(text).toBe('see issue @123 for details');
|
||||
expect(mentions).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles punctuation directly after the digits', () => {
|
||||
const { text, mentions } = parseWhatsAppMentions('thanks @15551234567!');
|
||||
expect(text).toBe('thanks @15551234567!');
|
||||
expect(mentions).toEqual(['15551234567@s.whatsapp.net']);
|
||||
});
|
||||
|
||||
it('handles parenthesized mentions', () => {
|
||||
const { text, mentions } = parseWhatsAppMentions('(@15551234567) wrote this');
|
||||
expect(text).toBe('(@15551234567) wrote this');
|
||||
expect(mentions).toEqual(['15551234567@s.whatsapp.net']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,910 @@
|
||||
/**
|
||||
* WhatsApp channel adapter (v2) — native Baileys v7 implementation.
|
||||
*
|
||||
* Implements ChannelAdapter directly (no Chat SDK bridge) using
|
||||
* @whiskeysockets/baileys 7.0.0-rc.9 (pinned — last release, unmaintained).
|
||||
* Ports proven v1 infrastructure: getMessage fallback, outgoing queue,
|
||||
* group metadata cache, LID mapping, reconnection with backoff.
|
||||
*
|
||||
* LID handling: Baileys v7 provides participantAlt / remoteJidAlt on every
|
||||
* inbound message via extractAddressingContext, plus a real
|
||||
* signalRepository.lidMapping.getPNForLID API. The adapter always resolves
|
||||
* to phone JID (@s.whatsapp.net) before emitting to the router.
|
||||
*
|
||||
* Auth credentials persist in store/auth/. On first run:
|
||||
* - If WHATSAPP_PHONE_NUMBER is set → pairing code (printed to log)
|
||||
* - Otherwise → QR code (printed to log)
|
||||
* Subsequent restarts reuse the saved session automatically.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
// Named import (not default) — pino's .d.ts under NodeNext resolution
|
||||
// exports `{ pino as default, pino }`, but the namespace/function merge at
|
||||
// `declare namespace pino` + `declare function pino` makes the default
|
||||
// resolve to `typeof pino` (the namespace type), which isn't callable.
|
||||
// The named export resolves to the callable function.
|
||||
import { pino } from 'pino';
|
||||
|
||||
import {
|
||||
makeWASocket,
|
||||
proto,
|
||||
Browsers,
|
||||
DisconnectReason,
|
||||
fetchLatestWaWebVersion,
|
||||
downloadMediaMessage,
|
||||
makeCacheableSignalKeyStore,
|
||||
normalizeMessageContent,
|
||||
useMultiFileAuthState,
|
||||
} from '@whiskeysockets/baileys';
|
||||
import type { GroupMetadata, WAMessageKey, WAMessage, WASocket } from '@whiskeysockets/baileys';
|
||||
|
||||
import { isSafeAttachmentName } from '../attachment-safety.js';
|
||||
import { ASSISTANT_HAS_OWN_NUMBER, ASSISTANT_NAME, DATA_DIR } from '../config.js';
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { log } from '../log.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
import { normalizeOptions, type NormalizedOption } from './ask-question.js';
|
||||
import type { ChannelAdapter, ChannelSetup, ConversationInfo, InboundMessage, OutboundMessage } from './adapter.js';
|
||||
|
||||
const baileysLogger = pino({ level: 'silent' });
|
||||
|
||||
/**
|
||||
* Fetch the latest WhatsApp Web version. Baileys' built-in
|
||||
* fetchLatestWaWebVersion scrapes sw.js which is aggressively
|
||||
* rate-limited (429). When it fails, Baileys falls back to a
|
||||
* hardcoded version that goes stale within weeks — WhatsApp
|
||||
* rejects connections with an expired buildHash (405 at Noise
|
||||
* layer). This fetches from wppconnect's version tracker as a
|
||||
* more reliable source, with Baileys' own fetch as fallback.
|
||||
*/
|
||||
async function resolveWaWebVersion(): Promise<[number, number, number]> {
|
||||
// 1. Try wppconnect version tracker (HTML scrape — no JSON API)
|
||||
try {
|
||||
const res = await fetch('https://wppconnect.io/whatsapp-versions/', {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (res.ok) {
|
||||
const html = await res.text();
|
||||
const match = html.match(/2\.3000\.(\d+)/);
|
||||
if (match) {
|
||||
const version: [number, number, number] = [2, 3000, Number(match[1])];
|
||||
log.info('Fetched WA Web version from wppconnect', { version });
|
||||
return version;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall through to Baileys' own fetch
|
||||
}
|
||||
|
||||
// 2. Try Baileys' built-in fetch (scrapes sw.js — often 429'd)
|
||||
try {
|
||||
const { version } = await fetchLatestWaWebVersion({});
|
||||
if (version) {
|
||||
log.info('Fetched WA Web version from Baileys', { version });
|
||||
return version as [number, number, number];
|
||||
}
|
||||
} catch {
|
||||
// Fall through
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Could not fetch current WhatsApp Web version from any source. ' +
|
||||
'Baileys hardcodes a stale version that WhatsApp rejects (405). ' +
|
||||
'Check network connectivity to wppconnect.io and web.whatsapp.com.',
|
||||
);
|
||||
}
|
||||
|
||||
const AUTH_DIR = path.join(process.cwd(), 'store', 'auth');
|
||||
const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h
|
||||
const GROUP_METADATA_CACHE_TTL_MS = 60_000; // 1 min for outbound sends
|
||||
const SENT_MESSAGE_CACHE_MAX = 256;
|
||||
const RECONNECT_DELAY_MS = 5000;
|
||||
const PENDING_QUESTIONS_MAX = 64;
|
||||
|
||||
/** Normalize an option label to a slash command: "Approve" → "/approve" */
|
||||
function optionToCommand(option: string): string {
|
||||
return '/' + option.toLowerCase().replace(/\s+/g, '-');
|
||||
}
|
||||
|
||||
// --- Markdown → WhatsApp formatting ---
|
||||
|
||||
interface TextSegment {
|
||||
content: string;
|
||||
isProtected: boolean;
|
||||
}
|
||||
|
||||
/** Split text into code-block-protected and unprotected regions. */
|
||||
function splitProtectedRegions(text: string): TextSegment[] {
|
||||
const segments: TextSegment[] = [];
|
||||
const codeBlockRegex = /```[\s\S]*?```|`[^`\n]+`/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = codeBlockRegex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({ content: text.slice(lastIndex, match.index), isProtected: false });
|
||||
}
|
||||
segments.push({ content: match[0], isProtected: true });
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
segments.push({ content: text.slice(lastIndex), isProtected: false });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
/** Apply WhatsApp-native formatting to an unprotected text segment. */
|
||||
function transformForWhatsApp(text: string): string {
|
||||
// Order matters: italic before bold to avoid **bold** → *bold* → _bold_
|
||||
// 1. Italic: *text* (not **) → _text_
|
||||
text = text.replace(/(?<!\*)\*(?=[^\s*])([^*\n]+?)(?<=[^\s*])\*(?!\*)/g, '_$1_');
|
||||
// 2. Bold: **text** → *text*
|
||||
text = text.replace(/\*\*(?=[^\s*])([^*]+?)(?<=[^\s*])\*\*/g, '*$1*');
|
||||
// 3. Headings: ## Title → *Title*
|
||||
text = text.replace(/^#{1,6}\s+(.+)$/gm, '*$1*');
|
||||
// 4. Links: [text](url) → text (url)
|
||||
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)');
|
||||
// 5. Horizontal rules: --- / *** / ___ → stripped
|
||||
text = text.replace(/^(-{3,}|\*{3,}|_{3,})$/gm, '');
|
||||
return text;
|
||||
}
|
||||
|
||||
// WhatsApp tags `@<phone-digits>` (5–15 digit local part — covers short test
|
||||
// numbers up to ITU E.164 max). A leading `+` is accepted but stripped so
|
||||
// the literal in text matches the digits in the JID — WhatsApp clients
|
||||
// scan the rendered text for `@<digits>` and cross-reference it with the
|
||||
// contextInfo.mentionedJid list to draw the bold/clickable tag.
|
||||
const MENTION_RE = /(^|[^\w@+])@\+?(\d{5,15})(?!\d)/g;
|
||||
|
||||
/** Extract `@<digits>` mentions from text and normalize them. */
|
||||
export function parseWhatsAppMentions(text: string): { text: string; mentions: string[] } {
|
||||
const mentions = new Set<string>();
|
||||
const out = text.replace(MENTION_RE, (_full, lead: string, digits: string) => {
|
||||
mentions.add(`${digits}@s.whatsapp.net`);
|
||||
return `${lead}@${digits}`;
|
||||
});
|
||||
return { text: out, mentions: [...mentions] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Claude's markdown to WhatsApp-native formatting and extract any
|
||||
* `@<phone>` mentions. Code-block regions are passed through untouched so
|
||||
* phone-like sequences inside code aren't tagged.
|
||||
*/
|
||||
function formatWhatsApp(text: string): { text: string; mentions: string[] } {
|
||||
const segments = splitProtectedRegions(text);
|
||||
const mentions = new Set<string>();
|
||||
const out = segments
|
||||
.map(({ content, isProtected }) => {
|
||||
if (isProtected) return content;
|
||||
const transformed = transformForWhatsApp(content);
|
||||
const { text: withMentions, mentions: found } = parseWhatsAppMentions(transformed);
|
||||
for (const m of found) mentions.add(m);
|
||||
return withMentions;
|
||||
})
|
||||
.join('');
|
||||
return { text: out, mentions: [...mentions] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Subset of a normalized Baileys message content carrying the message
|
||||
* types that can host a `contextInfo.mentionedJid` array. Kept as a
|
||||
* structural type so the helper (and its tests) don't pull in the full
|
||||
* `proto.IMessage` shape just to construct fixtures.
|
||||
*/
|
||||
type MentionContextSource = {
|
||||
extendedTextMessage?: { contextInfo?: { mentionedJid?: string[] | null } | null } | null;
|
||||
imageMessage?: { contextInfo?: { mentionedJid?: string[] | null } | null } | null;
|
||||
videoMessage?: { contextInfo?: { mentionedJid?: string[] | null } | null } | null;
|
||||
documentMessage?: { contextInfo?: { mentionedJid?: string[] | null } | null } | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect an explicit @-mention of the bot in a WhatsApp group message.
|
||||
* WhatsApp carries mentions in `contextInfo.mentionedJid` on the text +
|
||||
* caption-bearing message types. Matches against both the bot's phone
|
||||
* JID and LID — most modern clients emit the LID even when the human
|
||||
* typed a phone-number mention.
|
||||
*
|
||||
* Exported for unit testing. The inbound construction site calls this
|
||||
* to set `InboundMessage.isMention` for group messages (#2560). DMs are
|
||||
* unconditionally mentions and don't go through this helper.
|
||||
*/
|
||||
export function isBotMentionedInGroup(
|
||||
normalized: MentionContextSource,
|
||||
botPhoneJid: string | undefined,
|
||||
botLidUser: string | undefined,
|
||||
): boolean {
|
||||
if (!botPhoneJid && !botLidUser) return false;
|
||||
const mentionedJids: string[] = [
|
||||
...(normalized.extendedTextMessage?.contextInfo?.mentionedJid ?? []),
|
||||
...(normalized.imageMessage?.contextInfo?.mentionedJid ?? []),
|
||||
...(normalized.videoMessage?.contextInfo?.mentionedJid ?? []),
|
||||
...(normalized.documentMessage?.contextInfo?.mentionedJid ?? []),
|
||||
];
|
||||
const botLidJid = botLidUser ? `${botLidUser}@lid` : undefined;
|
||||
return mentionedJids.some((jid) => {
|
||||
if (!jid) return false;
|
||||
const bare = jid.split(':')[0];
|
||||
return bare === botPhoneJid || bare === botLidJid;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute `InboundMessage.isMention` for a WhatsApp message:
|
||||
* - DMs are always mentions (router auto-engages on the bot's behalf).
|
||||
* - Group messages are mentions only when the bot is explicitly tagged.
|
||||
*
|
||||
* Returns `true | undefined` rather than `true | false` because the
|
||||
* `InboundMessage` field is `isMention?: boolean` and downstream code
|
||||
* treats `undefined` differently than an explicit `false` (#2560).
|
||||
*/
|
||||
export function computeIsMention(isGroup: boolean, botMentionedInGroup: boolean): true | undefined {
|
||||
if (!isGroup) return true;
|
||||
return botMentionedInGroup ? true : undefined;
|
||||
}
|
||||
|
||||
/** Map file extension to Baileys media message type. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function buildMediaMessage(data: Buffer, filename: string, ext: string, caption?: string): any {
|
||||
const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
||||
const videoExts = ['.mp4', '.mov', '.avi', '.mkv'];
|
||||
const audioExts = ['.mp3', '.ogg', '.m4a', '.wav', '.aac', '.opus'];
|
||||
|
||||
if (imageExts.includes(ext)) {
|
||||
return { image: data, caption, mimetype: `image/${ext.slice(1) === 'jpg' ? 'jpeg' : ext.slice(1)}` };
|
||||
}
|
||||
if (videoExts.includes(ext)) {
|
||||
return { video: data, caption, mimetype: `video/${ext.slice(1)}` };
|
||||
}
|
||||
if (audioExts.includes(ext)) {
|
||||
return { audio: data, mimetype: `audio/${ext.slice(1) === 'mp3' ? 'mpeg' : ext.slice(1)}` };
|
||||
}
|
||||
// Default: send as document
|
||||
return { document: data, fileName: filename, caption, mimetype: 'application/octet-stream' };
|
||||
}
|
||||
|
||||
registerChannelAdapter('whatsapp', {
|
||||
factory: () => {
|
||||
const env = readEnvFile(['WHATSAPP_PHONE_NUMBER', 'WHATSAPP_ENABLED']);
|
||||
const phoneNumber = env.WHATSAPP_PHONE_NUMBER;
|
||||
const authDir = AUTH_DIR;
|
||||
|
||||
// Skip if no existing auth, no phone number for pairing, and not explicitly enabled (QR mode)
|
||||
const hasAuth = fs.existsSync(path.join(authDir, 'creds.json'));
|
||||
if (!hasAuth && !phoneNumber && !env.WHATSAPP_ENABLED) return null;
|
||||
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
|
||||
// State
|
||||
let sock: WASocket;
|
||||
let connected = false;
|
||||
let shuttingDown = false;
|
||||
let setupConfig: ChannelSetup;
|
||||
|
||||
// LID → phone JID mapping (WhatsApp's new ID system)
|
||||
const lidToPhoneMap: Record<string, string> = {};
|
||||
let botLidUser: string | undefined;
|
||||
let botPhoneJid: string | undefined;
|
||||
|
||||
// Outgoing queue for messages sent while disconnected
|
||||
const outgoingQueue: Array<{ jid: string; text: string; mentions?: string[] }> = [];
|
||||
let flushing = false;
|
||||
|
||||
// Sent message cache for retry/re-encrypt requests
|
||||
const sentMessageCache = new Map<string, any>();
|
||||
|
||||
// Group metadata cache with TTL
|
||||
const groupMetadataCache = new Map<string, { metadata: GroupMetadata; expiresAt: number }>();
|
||||
|
||||
// Pending questions: chatJid → { questionId, options }
|
||||
// User replies with /approve, /reject, etc. to answer
|
||||
const pendingQuestions = new Map<
|
||||
string,
|
||||
{
|
||||
questionId: string;
|
||||
options: NormalizedOption[];
|
||||
}
|
||||
>();
|
||||
|
||||
// Group sync tracking
|
||||
let lastGroupSync = 0;
|
||||
let groupSyncTimerStarted = false;
|
||||
|
||||
// First-connect promise
|
||||
let resolveFirstOpen: (() => void) | undefined;
|
||||
let rejectFirstOpen: ((err: Error) => void) | undefined;
|
||||
|
||||
// Pairing code file for the setup skill to poll
|
||||
const pairingCodeFile = path.join(process.cwd(), 'store', 'pairing-code.txt');
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function setLidPhoneMapping(lidUser: string, phoneJid: string): void {
|
||||
if (lidToPhoneMap[lidUser] === phoneJid) return;
|
||||
lidToPhoneMap[lidUser] = phoneJid;
|
||||
// Cached group metadata depends on participant IDs — invalidate
|
||||
groupMetadataCache.clear();
|
||||
}
|
||||
|
||||
async function translateJid(jid: string, altJid?: string): Promise<string> {
|
||||
if (!jid.endsWith('@lid')) return jid;
|
||||
const lidUser = jid.split('@')[0].split(':')[0];
|
||||
|
||||
// 1. Check local cache
|
||||
const cached = lidToPhoneMap[lidUser];
|
||||
if (cached) return cached;
|
||||
|
||||
// 2. Use the alt JID from extractAddressingContext (v7 provides this
|
||||
// on every inbound message as remoteJidAlt / participantAlt)
|
||||
if (altJid && !altJid.endsWith('@lid')) {
|
||||
const phoneJid = altJid.includes('@') ? altJid : `${altJid}@s.whatsapp.net`;
|
||||
setLidPhoneMapping(lidUser, phoneJid);
|
||||
log.info('Translated LID via alt JID', { lidJid: jid, phoneJid });
|
||||
return phoneJid;
|
||||
}
|
||||
|
||||
// 3. Query Baileys v7 LID mapping store
|
||||
try {
|
||||
const pn = await sock.signalRepository.lidMapping.getPNForLID(jid);
|
||||
if (pn) {
|
||||
const phoneJid = `${pn.split('@')[0].split(':')[0]}@s.whatsapp.net`;
|
||||
setLidPhoneMapping(lidUser, phoneJid);
|
||||
log.info('Translated LID via signal repository', { lidJid: jid, phoneJid });
|
||||
return phoneJid;
|
||||
}
|
||||
} catch (err) {
|
||||
log.debug('Failed to resolve LID via signalRepository', { jid, err });
|
||||
}
|
||||
|
||||
return jid;
|
||||
}
|
||||
|
||||
async function getNormalizedGroupMetadata(jid: string): Promise<GroupMetadata | undefined> {
|
||||
if (!jid.endsWith('@g.us')) return undefined;
|
||||
|
||||
const cached = groupMetadataCache.get(jid);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.metadata;
|
||||
|
||||
const metadata = await sock.groupMetadata(jid);
|
||||
const participants = await Promise.all(
|
||||
metadata.participants.map(async (p) => ({
|
||||
...p,
|
||||
id: await translateJid(p.id),
|
||||
})),
|
||||
);
|
||||
const normalized = { ...metadata, participants };
|
||||
groupMetadataCache.set(jid, {
|
||||
metadata: normalized,
|
||||
expiresAt: Date.now() + GROUP_METADATA_CACHE_TTL_MS,
|
||||
});
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function syncGroupMetadata(force = false): Promise<void> {
|
||||
if (!force && lastGroupSync && Date.now() - lastGroupSync < GROUP_SYNC_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
log.info('Syncing group metadata from WhatsApp...');
|
||||
const groups = await sock.groupFetchAllParticipating();
|
||||
let count = 0;
|
||||
for (const [jid, metadata] of Object.entries(groups)) {
|
||||
if (metadata.subject) {
|
||||
setupConfig.onMetadata(jid, metadata.subject, true);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
lastGroupSync = Date.now();
|
||||
log.info('Group metadata synced', { count });
|
||||
} catch (err) {
|
||||
log.error('Failed to sync group metadata', { err });
|
||||
}
|
||||
}
|
||||
|
||||
async function flushOutgoingQueue(): Promise<void> {
|
||||
if (flushing || outgoingQueue.length === 0) return;
|
||||
flushing = true;
|
||||
try {
|
||||
log.info('Flushing outgoing message queue', { count: outgoingQueue.length });
|
||||
while (outgoingQueue.length > 0) {
|
||||
const item = outgoingQueue.shift()!;
|
||||
const payload: { text: string; mentions?: string[] } = { text: item.text };
|
||||
if (item.mentions && item.mentions.length > 0) payload.mentions = item.mentions;
|
||||
const sent = await sock.sendMessage(item.jid, payload);
|
||||
if (sent?.key?.id && sent.message) {
|
||||
sentMessageCache.set(sent.key.id, sent.message);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
flushing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Download media from an inbound message, save to /workspace/attachments/. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async function downloadInboundMedia(
|
||||
msg: WAMessage,
|
||||
normalized: any,
|
||||
): Promise<Array<{ type: string; name: string; localPath: string }>> {
|
||||
const mediaTypes: Array<{ key: string; type: string; ext: string }> = [
|
||||
{ key: 'imageMessage', type: 'image', ext: '.jpg' },
|
||||
{ key: 'videoMessage', type: 'video', ext: '.mp4' },
|
||||
{ key: 'audioMessage', type: 'audio', ext: '.ogg' },
|
||||
{ key: 'documentMessage', type: 'document', ext: '' },
|
||||
];
|
||||
const results: Array<{ type: string; name: string; localPath: string }> = [];
|
||||
for (const { key, type, ext } of mediaTypes) {
|
||||
if (!normalized[key]) continue;
|
||||
try {
|
||||
const buffer = await downloadMediaMessage(msg, 'buffer', {});
|
||||
// documentMessage.fileName is attacker-controlled and rides through
|
||||
// WhatsApp's E2E channel — Meta can't sanitize it server-side. Without
|
||||
// this guard, a `..`-laden fileName escapes attachDir on path.join.
|
||||
const rawFilename = normalized[key].fileName;
|
||||
const fallback = `${type}-${Date.now()}${ext}`;
|
||||
const filename = isSafeAttachmentName(rawFilename) ? rawFilename : fallback;
|
||||
if (rawFilename && filename !== rawFilename) {
|
||||
log.warn('Refused unsafe attachment filename — would escape attachments dir', {
|
||||
rawFilename,
|
||||
replacement: filename,
|
||||
});
|
||||
}
|
||||
const attachDir = path.join(DATA_DIR, 'attachments');
|
||||
fs.mkdirSync(attachDir, { recursive: true });
|
||||
const filePath = path.join(attachDir, filename);
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
results.push({ type, name: filename, localPath: `attachments/${filename}` });
|
||||
log.info('Media downloaded', { type, filename });
|
||||
} catch (err) {
|
||||
log.warn('Failed to download media', { type, err });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function sendRawMessage(jid: string, text: string, mentions?: string[]): Promise<string | undefined> {
|
||||
if (!connected) {
|
||||
outgoingQueue.push({ jid, text, mentions });
|
||||
log.info('WA disconnected, message queued', { jid, queueSize: outgoingQueue.length });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload: { text: string; mentions?: string[] } = { text };
|
||||
if (mentions && mentions.length > 0) payload.mentions = mentions;
|
||||
const sent = await sock.sendMessage(jid, payload);
|
||||
if (sent?.key?.id && sent.message) {
|
||||
sentMessageCache.set(sent.key.id, sent.message);
|
||||
if (sentMessageCache.size > SENT_MESSAGE_CACHE_MAX) {
|
||||
const oldest = sentMessageCache.keys().next().value!;
|
||||
sentMessageCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
return sent?.key?.id ?? undefined;
|
||||
} catch (err) {
|
||||
outgoingQueue.push({ jid, text, mentions });
|
||||
log.warn('Failed to send, message queued', { jid, err, queueSize: outgoingQueue.length });
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Socket creation ---
|
||||
|
||||
async function connectSocket(): Promise<void> {
|
||||
const { state, saveCreds } = await useMultiFileAuthState(authDir);
|
||||
|
||||
const version = await resolveWaWebVersion();
|
||||
|
||||
sock = makeWASocket({
|
||||
version,
|
||||
auth: {
|
||||
creds: state.creds,
|
||||
keys: makeCacheableSignalKeyStore(state.keys, baileysLogger),
|
||||
},
|
||||
printQRInTerminal: false,
|
||||
logger: baileysLogger,
|
||||
browser: Browsers.macOS('Chrome'),
|
||||
cachedGroupMetadata: async (jid: string) => getNormalizedGroupMetadata(jid),
|
||||
getMessage: async (key: WAMessageKey) => {
|
||||
// Check in-memory cache first (recently sent messages)
|
||||
const cached = sentMessageCache.get(key.id || '');
|
||||
if (cached) return cached;
|
||||
// Return empty message to prevent indefinite "waiting for this message"
|
||||
return proto.Message.create({});
|
||||
},
|
||||
});
|
||||
|
||||
// Request pairing code if phone number is set and not yet registered
|
||||
if (phoneNumber && !state.creds.registered) {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const code = await sock.requestPairingCode(phoneNumber);
|
||||
log.info(`WhatsApp pairing code: ${code}`);
|
||||
log.info('Enter in WhatsApp > Linked Devices > Link with phone number');
|
||||
fs.writeFileSync(pairingCodeFile, code, 'utf-8');
|
||||
} catch (err) {
|
||||
log.error('Failed to request pairing code', { err });
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
sock.ev.on('connection.update', (update) => {
|
||||
const { connection, lastDisconnect, qr } = update;
|
||||
|
||||
if (qr && !phoneNumber) {
|
||||
// QR code auth — print to terminal
|
||||
(async () => {
|
||||
try {
|
||||
const QRCode = await import('qrcode');
|
||||
const qrText = await QRCode.toString(qr, { type: 'terminal' });
|
||||
log.info('WhatsApp QR code — scan with WhatsApp > Linked Devices:\n' + qrText);
|
||||
} catch {
|
||||
log.info('WhatsApp QR code (raw)', { qr });
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
if (connection === 'close') {
|
||||
connected = false;
|
||||
const reason = (lastDisconnect?.error as { output?: { statusCode?: number } })?.output?.statusCode;
|
||||
// Don't auto-reconnect during shutdown — a parallel connectSocket()
|
||||
// initializes useMultiFileAuthState which can truncate creds.json
|
||||
// mid-write when the process exits, leaving a 0-byte creds file
|
||||
// and forcing a fresh QR pairing on next start.
|
||||
const shouldReconnect = !shuttingDown && reason !== DisconnectReason.loggedOut;
|
||||
|
||||
log.info('WhatsApp connection closed', { reason, shouldReconnect, shuttingDown });
|
||||
|
||||
if (shouldReconnect) {
|
||||
log.info('Reconnecting...');
|
||||
connectSocket().catch((err) => {
|
||||
log.error('Failed to reconnect, retrying in 5s', { err });
|
||||
setTimeout(() => {
|
||||
connectSocket().catch((err2) => {
|
||||
log.error('Reconnection retry failed', { err: err2 });
|
||||
});
|
||||
}, RECONNECT_DELAY_MS);
|
||||
});
|
||||
} else {
|
||||
log.info('WhatsApp logged out');
|
||||
// Delete auth credentials immediately. Keeping stale credentials
|
||||
// causes the next service restart to attempt authentication with an
|
||||
// invalidated session, producing a second 401 that can trigger
|
||||
// WhatsApp's re-link cooldown ("can't link new devices now").
|
||||
try {
|
||||
fs.rmSync(authDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
log.info('WhatsApp auth cleared — set WHATSAPP_ENABLED=true and restart to re-link');
|
||||
} catch (err) {
|
||||
log.error('Failed to clear WhatsApp auth after logout', { err });
|
||||
}
|
||||
if (rejectFirstOpen) {
|
||||
rejectFirstOpen(new Error('WhatsApp logged out'));
|
||||
rejectFirstOpen = undefined;
|
||||
resolveFirstOpen = undefined;
|
||||
}
|
||||
}
|
||||
} else if (connection === 'open') {
|
||||
connected = true;
|
||||
log.info('Connected to WhatsApp');
|
||||
|
||||
// Clean up pairing code file after successful connection
|
||||
try {
|
||||
if (fs.existsSync(pairingCodeFile)) fs.unlinkSync(pairingCodeFile);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
// Announce availability for presence updates
|
||||
sock.sendPresenceUpdate('available').catch((err) => {
|
||||
log.warn('Failed to send presence update', { err });
|
||||
});
|
||||
|
||||
// Build LID → phone mapping from auth state
|
||||
if (sock.user) {
|
||||
const phoneUser = sock.user.id.split(':')[0];
|
||||
const lidUser = sock.user.lid?.split(':')[0];
|
||||
botPhoneJid = `${phoneUser}@s.whatsapp.net`;
|
||||
if (lidUser && phoneUser) {
|
||||
setLidPhoneMapping(lidUser, botPhoneJid);
|
||||
botLidUser = lidUser;
|
||||
}
|
||||
}
|
||||
|
||||
// Flush queued messages
|
||||
flushOutgoingQueue().catch((err) => log.error('Failed to flush outgoing queue', { err }));
|
||||
|
||||
// Group sync
|
||||
syncGroupMetadata().catch((err) => log.error('Initial group sync failed', { err }));
|
||||
if (!groupSyncTimerStarted) {
|
||||
groupSyncTimerStarted = true;
|
||||
setInterval(() => {
|
||||
syncGroupMetadata().catch((err) => log.error('Periodic group sync failed', { err }));
|
||||
}, GROUP_SYNC_INTERVAL_MS);
|
||||
}
|
||||
|
||||
// Signal first open
|
||||
if (resolveFirstOpen) {
|
||||
resolveFirstOpen();
|
||||
resolveFirstOpen = undefined;
|
||||
rejectFirstOpen = undefined;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
sock.ev.on('creds.update', saveCreds);
|
||||
|
||||
// LID ↔ phone mapping updates (v7 replaces chats.phoneNumberShare)
|
||||
sock.ev.on('lid-mapping.update', ({ lid, pn }) => {
|
||||
const lidUser = lid?.split('@')[0].split(':')[0];
|
||||
if (lidUser && pn) {
|
||||
const phoneJid = pn.includes('@') ? pn : `${pn}@s.whatsapp.net`;
|
||||
setLidPhoneMapping(lidUser, phoneJid);
|
||||
}
|
||||
});
|
||||
|
||||
// Inbound messages
|
||||
sock.ev.on('messages.upsert', async ({ messages }) => {
|
||||
for (const msg of messages) {
|
||||
try {
|
||||
if (!msg.message) continue;
|
||||
const normalized = normalizeMessageContent(msg.message);
|
||||
if (!normalized) continue;
|
||||
const rawJid = msg.key.remoteJid;
|
||||
if (!rawJid || rawJid === 'status@broadcast') continue;
|
||||
|
||||
// Translate LID → phone JID using v7's alt JID from extractAddressingContext
|
||||
const chatJid = await translateJid(rawJid, msg.key.remoteJidAlt);
|
||||
|
||||
const timestamp = new Date(Number(msg.messageTimestamp) * 1000).toISOString();
|
||||
const isGroup = chatJid.endsWith('@g.us');
|
||||
|
||||
// Notify metadata for group discovery
|
||||
setupConfig.onMetadata(chatJid, undefined, isGroup);
|
||||
|
||||
let content =
|
||||
normalized.conversation ||
|
||||
normalized.extendedTextMessage?.text ||
|
||||
normalized.imageMessage?.caption ||
|
||||
normalized.videoMessage?.caption ||
|
||||
'';
|
||||
|
||||
// Normalize bot LID mention → assistant name for trigger matching
|
||||
if (botLidUser && content.includes(`@${botLidUser}`)) {
|
||||
content = content.replace(`@${botLidUser}`, `@${ASSISTANT_NAME}`);
|
||||
}
|
||||
|
||||
// Download media attachments (images, video, audio, documents)
|
||||
const attachments = await downloadInboundMedia(msg, normalized);
|
||||
|
||||
// Skip empty protocol messages (no text and no attachments)
|
||||
if (!content && attachments.length === 0) continue;
|
||||
|
||||
// Resolve sender: in groups, participant may be LID — use participantAlt
|
||||
const rawSender = msg.key.participant || msg.key.remoteJid || '';
|
||||
const sender = rawSender.endsWith('@lid')
|
||||
? await translateJid(rawSender, msg.key.participantAlt)
|
||||
: rawSender;
|
||||
const senderName = msg.pushName || sender.split('@')[0];
|
||||
const fromMe = msg.key.fromMe || false;
|
||||
// Filter bot's own messages to prevent echo loops.
|
||||
// In self-chat (user messaging their own number), all messages have
|
||||
// fromMe=true — use sentMessageCache to distinguish bot echoes from
|
||||
// user-typed messages. For all other chats, the blanket fromMe
|
||||
// filter is correct since the user's phone messages shouldn't wake
|
||||
// the agent in third-party conversations.
|
||||
if (fromMe) {
|
||||
const isSelfChat = botPhoneJid && chatJid === botPhoneJid;
|
||||
if (!isSelfChat) continue;
|
||||
if (sentMessageCache.has(msg.key.id || '')) continue;
|
||||
}
|
||||
|
||||
const isBotMessage = ASSISTANT_HAS_OWN_NUMBER ? false : content.startsWith(`${ASSISTANT_NAME}:`);
|
||||
|
||||
// Check if this reply answers a pending question via slash command
|
||||
const pending = pendingQuestions.get(chatJid);
|
||||
if (pending && content.startsWith('/')) {
|
||||
const cmd = content.trim().toLowerCase();
|
||||
const matched = pending.options.find((o) => optionToCommand(o.label) === cmd);
|
||||
if (matched) {
|
||||
const voterName = msg.pushName || sender.split('@')[0];
|
||||
setupConfig.onAction(pending.questionId, matched.value, sender);
|
||||
pendingQuestions.delete(chatJid);
|
||||
await sendRawMessage(chatJid, `${matched.selectedLabel} by ${voterName}`);
|
||||
log.info('Question answered', {
|
||||
questionId: pending.questionId,
|
||||
value: matched.value,
|
||||
voterName,
|
||||
});
|
||||
continue; // Don't forward this reply to the agent
|
||||
}
|
||||
}
|
||||
|
||||
// Detect explicit @-mentions of the bot in groups. Detail in
|
||||
// isBotMentionedInGroup(); short version is contextInfo.mentionedJid
|
||||
// on text + caption-bearing messages, matched against the bot's
|
||||
// phone JID and LID (#2560).
|
||||
const botMentionedInGroup = isGroup && isBotMentionedInGroup(normalized, botPhoneJid, botLidUser);
|
||||
|
||||
const inbound: InboundMessage = {
|
||||
id: msg.key.id || `wa-${Date.now()}`,
|
||||
kind: 'chat',
|
||||
// DMs are addressed to the bot by definition. Mark them as
|
||||
// platform-confirmed mentions so the router auto-creates an
|
||||
// approval-required messaging_group when the chat is unknown,
|
||||
// instead of silently dropping. In groups, only an explicit
|
||||
// @-mention counts.
|
||||
isMention: computeIsMention(isGroup, botMentionedInGroup),
|
||||
isGroup,
|
||||
content: {
|
||||
text: content,
|
||||
sender,
|
||||
senderName,
|
||||
...(attachments.length > 0 && { attachments }),
|
||||
fromMe,
|
||||
isBotMessage,
|
||||
isGroup,
|
||||
chatJid,
|
||||
},
|
||||
timestamp,
|
||||
};
|
||||
|
||||
// WhatsApp doesn't use threads — threadId is null
|
||||
setupConfig.onInbound(chatJid, null, inbound);
|
||||
} catch (err) {
|
||||
log.error('Error processing incoming WhatsApp message', {
|
||||
err,
|
||||
remoteJid: msg.key?.remoteJid,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- ChannelAdapter implementation ---
|
||||
|
||||
const adapter: ChannelAdapter = {
|
||||
name: 'whatsapp',
|
||||
channelType: 'whatsapp',
|
||||
supportsThreads: false,
|
||||
|
||||
async setup(hostConfig: ChannelSetup) {
|
||||
setupConfig = hostConfig;
|
||||
|
||||
// Connect and wait for first open
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
resolveFirstOpen = resolve;
|
||||
rejectFirstOpen = reject;
|
||||
connectSocket().catch(reject);
|
||||
});
|
||||
|
||||
log.info('WhatsApp adapter initialized');
|
||||
},
|
||||
|
||||
async deliver(
|
||||
platformId: string,
|
||||
_threadId: string | null,
|
||||
message: OutboundMessage,
|
||||
): Promise<string | undefined> {
|
||||
const content = message.content as Record<string, unknown>;
|
||||
|
||||
// Ask question → text with slash command replies
|
||||
if (content.type === 'ask_question' && content.questionId && content.options) {
|
||||
const questionId = content.questionId as string;
|
||||
const title = content.title as string;
|
||||
const question = content.question as string;
|
||||
if (!title) {
|
||||
log.error('ask_question missing required title — skipping delivery', { questionId });
|
||||
return;
|
||||
}
|
||||
const options: NormalizedOption[] = normalizeOptions(content.options as never);
|
||||
|
||||
const optionLines = options.map((o) => ` ${optionToCommand(o.label)}`).join('\n');
|
||||
const text = `*${title}*\n\n${question}\n\nReply with:\n${optionLines}`;
|
||||
const msgId = await sendRawMessage(platformId, text);
|
||||
if (msgId) {
|
||||
pendingQuestions.set(platformId, { questionId, options });
|
||||
if (pendingQuestions.size > PENDING_QUESTIONS_MAX) {
|
||||
const oldest = pendingQuestions.keys().next().value!;
|
||||
pendingQuestions.delete(oldest);
|
||||
}
|
||||
}
|
||||
return msgId;
|
||||
}
|
||||
|
||||
// Reaction → emoji on a message
|
||||
if (content.operation === 'reaction' && content.messageId && content.emoji) {
|
||||
try {
|
||||
await sock.sendMessage(platformId, {
|
||||
react: {
|
||||
text: content.emoji as string,
|
||||
key: { remoteJid: platformId, id: content.messageId as string, fromMe: false },
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
log.debug('Failed to send reaction', { platformId, err });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal message (with optional file attachments)
|
||||
const text = (content.markdown as string) || (content.text as string);
|
||||
const hasFiles = message.files && message.files.length > 0;
|
||||
|
||||
if (!text && !hasFiles) return;
|
||||
|
||||
// Send file attachments (first file gets the caption, rest are captionless)
|
||||
if (hasFiles) {
|
||||
let captionUsed = false;
|
||||
for (const file of message.files!) {
|
||||
try {
|
||||
const ext = path.extname(file.filename).toLowerCase();
|
||||
let caption: string | undefined;
|
||||
let captionMentions: string[] | undefined;
|
||||
if (!captionUsed && text) {
|
||||
const formatted = formatWhatsApp(text);
|
||||
caption = formatted.text;
|
||||
captionMentions = formatted.mentions.length > 0 ? formatted.mentions : undefined;
|
||||
}
|
||||
const mediaMsg = buildMediaMessage(file.data, file.filename, ext, caption);
|
||||
if (captionMentions) mediaMsg.mentions = captionMentions;
|
||||
const sent = await sock.sendMessage(platformId, mediaMsg);
|
||||
if (sent?.key?.id && sent.message) {
|
||||
sentMessageCache.set(sent.key.id, sent.message);
|
||||
}
|
||||
if (caption) captionUsed = true;
|
||||
} catch (err) {
|
||||
log.error('Failed to send file', { platformId, filename: file.filename, err });
|
||||
}
|
||||
}
|
||||
if (captionUsed) return; // Text was sent as caption
|
||||
}
|
||||
|
||||
if (text) {
|
||||
const { text: formatted, mentions } = formatWhatsApp(text);
|
||||
const prefixed = ASSISTANT_HAS_OWN_NUMBER ? formatted : `${ASSISTANT_NAME}: ${formatted}`;
|
||||
return sendRawMessage(platformId, prefixed, mentions);
|
||||
}
|
||||
},
|
||||
|
||||
async setTyping(platformId: string) {
|
||||
try {
|
||||
await sock.sendPresenceUpdate('composing', platformId);
|
||||
} catch (err) {
|
||||
log.debug('Failed to update typing status', { jid: platformId, err });
|
||||
}
|
||||
},
|
||||
|
||||
async teardown() {
|
||||
shuttingDown = true;
|
||||
connected = false;
|
||||
sock?.end(undefined);
|
||||
log.info('WhatsApp adapter shut down');
|
||||
},
|
||||
|
||||
isConnected() {
|
||||
return connected;
|
||||
},
|
||||
|
||||
async syncConversations(): Promise<ConversationInfo[]> {
|
||||
try {
|
||||
const groups = await sock.groupFetchAllParticipating();
|
||||
return Object.entries(groups)
|
||||
.filter(([, m]) => m.subject)
|
||||
.map(([jid, m]) => ({
|
||||
platformId: jid,
|
||||
name: m.subject,
|
||||
isGroup: true,
|
||||
}));
|
||||
} catch (err) {
|
||||
log.error('Failed to sync WhatsApp conversations', { err });
|
||||
return [];
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return adapter;
|
||||
},
|
||||
});
|
||||
+7
-5
@@ -9,15 +9,17 @@
|
||||
* will later emit as event.platformId, or router lookups miss and messages
|
||||
* get silently dropped.
|
||||
*
|
||||
* Native adapters (Signal, WhatsApp, iMessage) use their own ID formats and
|
||||
* send them as-is — no channel prefix. WhatsApp/iMessage emit JIDs/emails
|
||||
* containing '@'. Signal emits raw phone numbers ('+15551234567') for DMs
|
||||
* and 'group:<id>' for group chats. Prefixing any of these would cause a
|
||||
* mismatch with what the adapter later emits.
|
||||
* Native adapters (Signal, WhatsApp, iMessage, DeltaChat) use their own ID
|
||||
* formats and send them as-is — no channel prefix. WhatsApp/iMessage emit
|
||||
* JIDs/emails containing '@'. Signal emits raw phone numbers ('+15551234567')
|
||||
* for DMs and 'group:<id>' for group chats. DeltaChat emits numeric chat IDs
|
||||
* ('12'). Prefixing any of these would cause a mismatch with what the adapter
|
||||
* later emits.
|
||||
*/
|
||||
export function namespacedPlatformId(channel: string, raw: string): string {
|
||||
if (raw.startsWith(`${channel}:`)) return raw;
|
||||
if (raw.includes('@')) return raw;
|
||||
if (raw.startsWith('+') || raw.startsWith('group:')) return raw;
|
||||
if (channel === 'deltachat') return raw;
|
||||
return `${channel}:${raw}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user