Compare commits

..

1 Commits

Author SHA1 Message Date
Gavriel Cohen 3f1319273b fix(whatsapp): mark DMs as isMention so router auto-engages
DMs are addressed to the bot by definition. Mirror the chat-sdk-bridge
semantics (onDirectMessage forwards isMention=true) so the host router
can short-circuit DM auto-create + approval flow at router.ts:184
instead of silently dropping inbound from chats it doesn't recognize.

Without this, a v1 → v2 migration where Baileys' LID→phone translation
fails on the first message (no senderPn, signal repository cold) leaves
v2 unable to route DMs at all — the chatJid arrives as `<lid>@lid`,
the migrated messaging_group is keyed by `<phone>@s.whatsapp.net`, and
the router drops because !isMention.

Group messages keep isMention undefined so the router falls through to
agent-name regex matching, unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 15:24:26 +03:00
31 changed files with 282 additions and 1844 deletions
-62
View File
@@ -1,62 +0,0 @@
# 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.
-254
View File
@@ -1,254 +0,0 @@
---
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 |
|-------|---------|
| 10001999 | Not connected |
| 20002999 | Connecting |
| 30003999 | 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`.
-54
View File
@@ -1,54 +0,0 @@
# 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.
+1 -1
View File
@@ -57,7 +57,7 @@ groups: () => import('./groups.js'),
### 5. Install the adapter packages (pinned)
```bash
pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
```
### 6. Build
+1 -1
View File
@@ -40,7 +40,7 @@
"@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",
"@whiskeysockets/baileys": "^6.17.16",
"better-sqlite3": "11.10.0",
"chat": "^4.24.0",
"chat-adapter-imessage": "^0.1.1",
+174 -125
View File
@@ -57,8 +57,8 @@ importers:
specifier: ^1.5.6
version: 1.5.6
'@whiskeysockets/baileys':
specifier: 7.0.0-rc.9
version: 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5)
specifier: ^6.17.16
version: 6.17.16(eslint@9.39.4)(qrcode-terminal@0.12.0)(typescript@5.9.3)
better-sqlite3:
specifier: 11.10.0
version: 11.10.0
@@ -123,6 +123,9 @@ importers:
packages:
'@adiwajshing/keyed-db@0.2.4':
resolution: {integrity: sha512-yprSnAtj80/VKuDqRcFFLDYltoNV8tChNwFfIgcf6PGD4sjzWIBgs08pRuTqGH5mk5wgL6PBRSsMCZqtZwzFEw==}
'@azure/msal-common@15.17.0':
resolution: {integrity: sha512-VQ5/gTLFADkwue+FohVuCqlzFPUq4xSrX8jeZe+iwZuY6moliNC8xt86qPVNYdtbQfELDf2Nu6LI+demFPHGgw==}
engines: {node: '>=0.8.0'}
@@ -144,9 +147,6 @@ packages:
peerDependencies:
chat: ^4.15.0
'@borewit/text-codec@0.2.2':
resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==}
'@cacheable/memory@2.0.8':
resolution: {integrity: sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==}
@@ -1162,10 +1162,6 @@ packages:
resolution: {integrity: sha512-npKV69U8JYpMLZiqhUWf9dmd9Esjy36o7CxxGUgoLRS4ZmTLuIKqKfFnZuLrx6D5Mmb+D9ARCDR7qXO1QJV8DQ==}
engines: {node: '>=18'}
'@tokenizer/inflate@0.4.1':
resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==}
engines: {node: '>=18'}
'@tokenizer/token@0.3.0':
resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==}
@@ -1333,29 +1329,42 @@ packages:
'@wasm-audio-decoders/opus-ml@0.0.2':
resolution: {integrity: sha512-58rWEqDGg+CKCyEeKm2KoxxSwTWtHh/NLTW9ObR4K8CGF6VwuuGudEI1CtniS/oSRmL1nJq/eh8MKARiluw4DQ==}
'@whiskeysockets/baileys@7.0.0-rc.9':
resolution: {integrity: sha512-YFm5gKXfDP9byCXCW3OPHKXLzrAKzolzgVUlRosHHgwbnf2YOO3XknkMm6J7+F0ns8OA0uuSBhgkRHTDtqkacw==}
engines: {node: '>=20.0.0'}
'@whiskeysockets/baileys@6.17.16':
resolution: {integrity: sha512-cZoUaKpO4fsDUNiCtyZfbjkW0Bjl/IudzHLCvpqfqtq5TACQzNynYsYdKPJz1I8Cu/SSEvmewk0RorIs0zDWyw==}
deprecated: The new official package name for the Baileys package is "baileys". Please use that package name going forward. This version may stop receiving updates in the future.
peerDependencies:
audio-decode: ^2.1.3
jimp: ^1.6.0
jimp: ^0.16.1
link-preview-js: ^3.0.0
sharp: '*'
qrcode-terminal: ^0.12.0
sharp: ^0.32.6
peerDependenciesMeta:
audio-decode:
optional: true
jimp:
optional: true
link-preview-js:
optional: true
qrcode-terminal:
optional: true
sharp:
optional: true
'@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67':
resolution: {tarball: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67}
'@whiskeysockets/eslint-config@https://codeload.github.com/whiskeysockets/eslint-config/tar.gz/299e8389baf62f9aa3034de18ff0d62cc0a5e838':
resolution: {tarball: https://codeload.github.com/whiskeysockets/eslint-config/tar.gz/299e8389baf62f9aa3034de18ff0d62cc0a5e838}
version: 1.0.0
peerDependencies:
eslint: ^9.31.0
typescript: '>=4'
'@whiskeysockets/libsignal-node@https://codeload.github.com/WhiskeySockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67':
resolution: {tarball: https://codeload.github.com/WhiskeySockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67}
version: 2.0.1
'@workflow/serde@4.1.0-beta.2':
resolution: {integrity: sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww==}
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
@@ -1395,8 +1404,8 @@ packages:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
async-mutex@0.5.0:
resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==}
async-lock@1.4.1:
resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
@@ -1484,10 +1493,17 @@ packages:
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
cache-manager@5.7.6:
resolution: {integrity: sha512-wBxnBHjDxF1RXpHCBD6HGvKER003Ts7IIm0CHpggliHzN1RZditb7rXoduE1rplc2DEFYKxhLKgFuchXMJje9w==}
engines: {node: '>= 18'}
cacheable@2.3.4:
resolution: {integrity: sha512-djgxybDbw9fL/ZWMI3+CE8ZilNxcwFkVtDc1gJ+IlOSSWkSMPQabhV/XCHTQ6pwwN6aivXPZ43omTooZiX06Ew==}
@@ -1765,6 +1781,11 @@ packages:
peerDependencies:
eslint: '>=2.0.0'
eslint-plugin-simple-import-sort@12.1.1:
resolution: {integrity: sha512-6nuzu4xwQtE3332Uz0to+TxDQYRLTKRESSc2hefVT48Zc8JthmN23Gx9lnYhu0FtkRSL1oxny3kJ2aveVhmOVA==}
peerDependencies:
eslint: '>=5.0.0'
eslint-scope@8.4.0:
resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1818,6 +1839,10 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
event-target-shim@5.0.1:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
@@ -1884,9 +1909,9 @@ packages:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
file-type@21.3.4:
resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==}
engines: {node: '>=20'}
file-type@16.5.4:
resolution: {integrity: sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==}
engines: {node: '>=10'}
file-uri-to-path@1.0.0:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
@@ -2186,6 +2211,9 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
libphonenumber-js@1.12.41:
resolution: {integrity: sha512-lsmMmGXBxXIK/VMLEj0kL6MtUs1kBGj1nTCzi6zgQoG1DEwqwt2DQyHxcLykceIxAnfE3hya7NuIh6PpC6S3fA==}
lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'}
@@ -2317,9 +2345,8 @@ packages:
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
lru-cache@11.3.6:
resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==}
engines: {node: 20 || >=22}
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
lru-cache@6.0.0:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
@@ -2527,9 +2554,9 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
music-metadata@11.12.3:
resolution: {integrity: sha512-n6hSTZkuD59qWgHh6IP5dtDlDZQXoxk/bcA85Jywg8Z1iFrlNgl2+GTFgjZyn52W5UgQpV42V4XqrQZZAMbZTQ==}
engines: {node: '>=18'}
music-metadata@7.14.0:
resolution: {integrity: sha512-xrm3w7SV0Wk+OythZcSbaI8mcr/KHd0knJieu8bVpaPfMv/Agz5EooCAPz3OR5hbYMiUG6dgAPKZKnMzV+3amA==}
engines: {node: '>=10'}
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
@@ -2632,10 +2659,6 @@ packages:
resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==}
engines: {node: '>=8'}
p-queue@9.2.0:
resolution: {integrity: sha512-dWgLE8AH0HjQ9fe74pUkKkvzzYT18Inp4zra3lKHnnwqGvcfcUBrvF2EAVX+envufDNBOzpPq/IBUONDbI7+3g==}
engines: {node: '>=20'}
p-retry@4.6.2:
resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==}
engines: {node: '>=8'}
@@ -2648,10 +2671,6 @@ packages:
resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==}
engines: {node: '>=8'}
p-timeout@7.0.1:
resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==}
engines: {node: '>=20'}
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
@@ -2684,6 +2703,10 @@ packages:
peberminta@0.9.0:
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
peek-readable@4.1.0:
resolution: {integrity: sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==}
engines: {node: '>=8'}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -2734,6 +2757,14 @@ packages:
process-warning@5.0.0:
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
process@0.11.10:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
promise-coalesce@1.5.0:
resolution: {integrity: sha512-cTJ30U+ur1LD7pMPyQxiKIwxjtAjLsyU7ivRhVWZrX9BNIXtf78pc37vSMc8Vikx7DVzEKNk2SEJ5KWUpSG2ig==}
engines: {node: '>=16'}
property-information@7.1.0:
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
@@ -2808,6 +2839,14 @@ packages:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
readable-stream@4.7.0:
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
readable-web-to-node-stream@3.0.4:
resolution: {integrity: sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==}
engines: {node: '>=8'}
real-require@0.2.0:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'}
@@ -3006,9 +3045,9 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
strtok3@10.3.5:
resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==}
engines: {node: '>=18'}
strtok3@6.3.0:
resolution: {integrity: sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==}
engines: {node: '>=10'}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
@@ -3053,9 +3092,9 @@ packages:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
token-types@6.1.2:
resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==}
engines: {node: '>=14.16'}
token-types@4.2.1:
resolution: {integrity: sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==}
engines: {node: '>=10'}
trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
@@ -3103,10 +3142,6 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
uint8array-extras@1.5.0:
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
engines: {node: '>=18'}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@@ -3281,9 +3316,6 @@ packages:
engines: {node: '>=8'}
hasBin: true
win-guid@0.2.1:
resolution: {integrity: sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==}
word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
@@ -3349,6 +3381,8 @@ packages:
snapshots:
'@adiwajshing/keyed-db@0.2.4': {}
'@azure/msal-common@15.17.0': {}
'@azure/msal-node@3.8.10':
@@ -3379,8 +3413,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@borewit/text-codec@0.2.2': {}
'@cacheable/memory@2.0.8':
dependencies:
'@cacheable/utils': 2.4.1
@@ -3649,8 +3681,7 @@ snapshots:
'@esbuild/win32-x64@0.27.7':
optional: true
'@eshaz/web-worker@1.2.2':
optional: true
'@eshaz/web-worker@1.2.2': {}
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)':
dependencies:
@@ -4307,17 +4338,8 @@ snapshots:
'@thi.ng/bitstream@2.4.46':
dependencies:
'@thi.ng/errors': 2.6.8
optional: true
'@thi.ng/errors@2.6.8':
optional: true
'@tokenizer/inflate@0.4.1':
dependencies:
debug: 4.4.3
token-types: 6.1.2
transitivePeerDependencies:
- supports-color
'@thi.ng/errors@2.6.8': {}
'@tokenizer/token@0.3.0': {}
@@ -4522,52 +4544,70 @@ snapshots:
dependencies:
'@eshaz/web-worker': 1.2.2
simple-yenc: 1.0.4
optional: true
'@wasm-audio-decoders/flac@0.2.10':
dependencies:
'@wasm-audio-decoders/common': 9.0.7
codec-parser: 2.5.0
optional: true
'@wasm-audio-decoders/ogg-vorbis@0.1.20':
dependencies:
'@wasm-audio-decoders/common': 9.0.7
codec-parser: 2.5.0
optional: true
'@wasm-audio-decoders/opus-ml@0.0.2':
dependencies:
'@wasm-audio-decoders/common': 9.0.7
optional: true
'@whiskeysockets/baileys@7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5)':
'@whiskeysockets/baileys@6.17.16(eslint@9.39.4)(qrcode-terminal@0.12.0)(typescript@5.9.3)':
dependencies:
'@adiwajshing/keyed-db': 0.2.4
'@cacheable/node-cache': 1.7.6
'@hapi/boom': 9.1.4
async-mutex: 0.5.0
libsignal: '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67'
lru-cache: 11.3.6
music-metadata: 11.12.3
p-queue: 9.2.0
'@whiskeysockets/eslint-config': https://codeload.github.com/whiskeysockets/eslint-config/tar.gz/299e8389baf62f9aa3034de18ff0d62cc0a5e838(eslint@9.39.4)(typescript@5.9.3)
async-lock: 1.4.1
audio-decode: 2.2.3
axios: 1.15.2
cache-manager: 5.7.6
libphonenumber-js: 1.12.41
libsignal: '@whiskeysockets/libsignal-node@https://codeload.github.com/WhiskeySockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67'
lodash: 4.18.1
music-metadata: 7.14.0
pino: 9.14.0
protobufjs: 7.5.5
sharp: 0.34.5
uuid: 10.0.0
ws: 8.20.0
optionalDependencies:
audio-decode: 2.2.3
qrcode-terminal: 0.12.0
transitivePeerDependencies:
- bufferutil
- debug
- eslint
- supports-color
- typescript
- utf-8-validate
'@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67':
'@whiskeysockets/eslint-config@https://codeload.github.com/whiskeysockets/eslint-config/tar.gz/299e8389baf62f9aa3034de18ff0d62cc0a5e838(eslint@9.39.4)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/eslint-plugin': 8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)
'@typescript-eslint/parser': 8.58.2(eslint@9.39.4)(typescript@5.9.3)
eslint: 9.39.4
eslint-plugin-simple-import-sort: 12.1.1(eslint@9.39.4)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@whiskeysockets/libsignal-node@https://codeload.github.com/WhiskeySockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67':
dependencies:
curve25519-js: 0.0.4
protobufjs: 6.8.8
'@workflow/serde@4.1.0-beta.2': {}
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@@ -4600,16 +4640,13 @@ snapshots:
assertion-error@2.0.1: {}
async-mutex@0.5.0:
dependencies:
tslib: 2.8.1
async-lock@1.4.1: {}
asynckit@0.4.0: {}
atomic-sleep@1.0.0: {}
audio-buffer@5.0.0:
optional: true
audio-buffer@5.0.0: {}
audio-decode@2.2.3:
dependencies:
@@ -4621,10 +4658,8 @@ snapshots:
node-wav: 0.0.2
ogg-opus-decoder: 1.7.3
qoa-format: 1.0.1
optional: true
audio-type@2.4.1:
optional: true
audio-type@2.4.1: {}
axios@1.15.2:
dependencies:
@@ -4711,8 +4746,20 @@ snapshots:
base64-js: 1.5.1
ieee754: 1.2.1
buffer@6.0.3:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
bytes@3.1.2: {}
cache-manager@5.7.6:
dependencies:
eventemitter3: 5.0.4
lodash.clonedeep: 4.5.0
lru-cache: 10.4.3
promise-coalesce: 1.5.0
cacheable@2.3.4:
dependencies:
'@cacheable/memory': 2.0.8
@@ -4785,8 +4832,7 @@ snapshots:
cluster-key-slot@1.1.2: {}
codec-parser@2.5.0:
optional: true
codec-parser@2.5.0: {}
color-convert@2.0.1:
dependencies:
@@ -5012,6 +5058,10 @@ snapshots:
dependencies:
eslint: 9.39.4
eslint-plugin-simple-import-sort@12.1.1(eslint@9.39.4):
dependencies:
eslint: 9.39.4
eslint-scope@8.4.0:
dependencies:
esrecurse: 4.3.0
@@ -5086,6 +5136,8 @@ snapshots:
etag@1.8.1: {}
event-target-shim@5.0.1: {}
eventemitter3@4.0.7: {}
eventemitter3@5.0.4: {}
@@ -5164,14 +5216,11 @@ snapshots:
dependencies:
flat-cache: 4.0.1
file-type@21.3.4:
file-type@16.5.4:
dependencies:
'@tokenizer/inflate': 0.4.1
strtok3: 10.3.5
token-types: 6.1.2
uint8array-extras: 1.5.0
transitivePeerDependencies:
- supports-color
readable-web-to-node-stream: 3.0.4
strtok3: 6.3.0
token-types: 4.2.1
file-uri-to-path@1.0.0: {}
@@ -5493,6 +5542,8 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
libphonenumber-js@1.12.41: {}
lightningcss-android-arm64@1.32.0:
optional: true
@@ -5582,7 +5633,7 @@ snapshots:
longest-streak@3.1.0: {}
lru-cache@11.3.6: {}
lru-cache@10.4.3: {}
lru-cache@6.0.0:
dependencies:
@@ -5969,22 +6020,18 @@ snapshots:
mpg123-decoder@1.0.3:
dependencies:
'@wasm-audio-decoders/common': 9.0.7
optional: true
ms@2.1.3: {}
music-metadata@11.12.3:
music-metadata@7.14.0:
dependencies:
'@borewit/text-codec': 0.2.2
'@tokenizer/token': 0.3.0
content-type: 1.0.5
debug: 4.4.3
file-type: 21.3.4
file-type: 16.5.4
media-typer: 1.1.0
strtok3: 10.3.5
token-types: 6.1.2
uint8array-extras: 1.5.0
win-guid: 0.2.1
strtok3: 6.3.0
token-types: 4.2.1
transitivePeerDependencies:
- supports-color
@@ -6017,8 +6064,7 @@ snapshots:
dependencies:
bplist-parser: 0.3.2
node-wav@0.0.2:
optional: true
node-wav@0.0.2: {}
nth-check@2.1.1:
dependencies:
@@ -6036,7 +6082,6 @@ snapshots:
'@wasm-audio-decoders/opus-ml': 0.0.2
codec-parser: 2.5.0
opus-decoder: 0.7.11
optional: true
oidc-client-ts@3.5.0:
dependencies:
@@ -6064,7 +6109,6 @@ snapshots:
opus-decoder@0.7.11:
dependencies:
'@wasm-audio-decoders/common': 9.0.7
optional: true
p-finally@1.0.0: {}
@@ -6089,11 +6133,6 @@ snapshots:
eventemitter3: 4.0.7
p-timeout: 3.2.0
p-queue@9.2.0:
dependencies:
eventemitter3: 5.0.4
p-timeout: 7.0.1
p-retry@4.6.2:
dependencies:
'@types/retry': 0.12.0
@@ -6107,8 +6146,6 @@ snapshots:
dependencies:
p-finally: 1.0.0
p-timeout@7.0.1: {}
p-try@2.2.0: {}
parent-module@1.0.1:
@@ -6132,6 +6169,8 @@ snapshots:
peberminta@0.9.0: {}
peek-readable@4.1.0: {}
picocolors@1.1.1: {}
picomatch@4.0.4: {}
@@ -6189,6 +6228,10 @@ snapshots:
process-warning@5.0.0: {}
process@0.11.10: {}
promise-coalesce@1.5.0: {}
property-information@7.1.0: {}
protobufjs@6.8.8:
@@ -6243,7 +6286,6 @@ snapshots:
qoa-format@1.0.1:
dependencies:
'@thi.ng/bitstream': 2.4.46
optional: true
qrcode-terminal@0.12.0:
optional: true
@@ -6289,6 +6331,18 @@ snapshots:
string_decoder: 1.3.0
util-deprecate: 1.0.2
readable-stream@4.7.0:
dependencies:
abort-controller: 3.0.0
buffer: 6.0.3
events: 3.3.0
process: 0.11.10
string_decoder: 1.3.0
readable-web-to-node-stream@3.0.4:
dependencies:
readable-stream: 4.7.0
real-require@0.2.0: {}
redis@5.12.1:
@@ -6500,8 +6554,7 @@ snapshots:
once: 1.4.0
simple-concat: 1.0.1
simple-yenc@1.0.4:
optional: true
simple-yenc@1.0.4: {}
sisteransi@1.0.5: {}
@@ -6567,9 +6620,10 @@ snapshots:
strip-json-comments@3.1.1: {}
strtok3@10.3.5:
strtok3@6.3.0:
dependencies:
'@tokenizer/token': 0.3.0
peek-readable: 4.1.0
supports-color@7.2.0:
dependencies:
@@ -6616,9 +6670,8 @@ snapshots:
toidentifier@1.0.1: {}
token-types@6.1.2:
token-types@4.2.1:
dependencies:
'@borewit/text-codec': 0.2.2
'@tokenizer/token': 0.3.0
ieee754: 1.2.1
@@ -6668,8 +6721,6 @@ snapshots:
typescript@5.9.3: {}
uint8array-extras@1.5.0: {}
undici-types@6.21.0: {}
undici@6.24.1: {}
@@ -6798,8 +6849,6 @@ snapshots:
siginfo: 2.0.0
stackback: 0.0.2
win-guid@0.2.1: {}
word-wrap@1.2.5: {}
wrap-ansi@6.2.0:
+1 -1
View File
@@ -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@7.0.0-rc.9"
BAILEYS_VERSION="@whiskeysockets/baileys@6.17.16"
QRCODE_VERSION="qrcode@1.5.4"
QRCODE_TYPES_VERSION="@types/qrcode@1.5.6"
PINO_VERSION="pino@9.6.0"
+1 -1
View File
@@ -66,7 +66,7 @@ if ! grep -q "'whatsapp-auth':" setup/index.ts; then
fi
echo "STEP: pnpm-install"
pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
echo "STEP: pnpm-build"
pnpm run build
+25 -19
View File
@@ -1,5 +1,5 @@
/**
* Step: whatsapp-auth standalone WhatsApp (Baileys v7) authentication.
* Step: whatsapp-auth standalone WhatsApp (Baileys) 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,6 +27,7 @@
*/
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.
@@ -46,23 +47,26 @@ 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' });
/** 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');
// 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.
}
type AuthMethod = 'qr' | 'pairing-code';
@@ -135,7 +139,9 @@ 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 resolveWaWebVersion();
const { version } = await fetchLatestWaWebVersion({}).catch(() => ({
version: undefined,
}));
const sock = makeWASocket({
version,
@@ -1,28 +0,0 @@
/**
* Integration test for the deltachat channel's single reach-in: the
* self-registration import in the `src/channels/index.ts` barrel. Importing the
* barrel runs deltachat.ts's top-level `registerChannelAdapter('deltachat', )`;
* without the import the channel is silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './deltachat.js';` line is deleted, or the barrel fails to evaluate for
* any reason (so the channel genuinely would not register), this goes red. A
* structural check of the import line would falsely pass in that second case.
*
* Importing the barrel is safe: registration is a pure top-level call, and
* deltachat.ts only instantiates DeltaChatOverJsonRpc inside setup() (run at host
* startup), never at import so nothing spawns here. It does require the adapter
* package to be installed, which holds in a composed install: the skill's
* `pnpm install` step runs before this test in the apply flow.
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('deltachat channel registration', () => {
it('registers deltachat via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('deltachat');
});
});
-338
View File
@@ -1,338 +0,0 @@
/**
* 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);
},
});
-34
View File
@@ -1,34 +0,0 @@
/**
* Integration test for the discord channel's single reach-in: the self-registration
* import in the `src/channels/index.ts` barrel. Importing the barrel runs discord.ts's
* top-level `registerChannelAdapter('discord', …)`; without the import the channel is
* silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './discord.js';` line is deleted, or the barrel fails to evaluate for any
* reason (so the channel genuinely would not register), this goes red. A structural
* check of the import line would falsely pass in that second case.
*
* Importing the barrel is safe: registration is a pure top-level call, and discord.ts
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
* never at import. It does require the adapter package (`@chat-adapter/discord`) to be installed,
* which holds in a composed install: the skill's `pnpm install` step runs before this
* test so this test also implicitly guards that dependency (an unmocked import throws
* if the package is missing).
*
* discord is a Chat SDK channel: discord.ts also consumes a load-bearing *core* API
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
* drift, not this test. Every Chat SDK channel follows this same shape.
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('discord channel registration', () => {
it('registers discord via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('discord');
});
});
-29
View File
@@ -1,29 +0,0 @@
/**
* Integration test for the emacs channel's single reach-in: the self-registration
* import in the `src/channels/index.ts` barrel. Importing the barrel runs emacs.ts's
* top-level `registerChannelAdapter('emacs', …)`; without the import the channel is
* silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './emacs.js';` line is deleted, or the barrel fails to evaluate for any
* reason (so the channel genuinely would not register), this goes red. A structural
* check of the import line would falsely pass in that second case.
*
* emacs is a native adapter with no npm dependency (it uses the Node http builtin); it talks to an Emacs HTTP client.
* Importing the barrel is safe: registration is a pure top-level call and emacs.ts
* opens connections / spawns subprocesses only inside setup() (run at host startup),
* never at import. There is no adapter package to guard here this test guards the
* one barrel reach-in (red if `import './emacs.js';` is deleted or the barrel fails
* to evaluate).
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('emacs channel registration', () => {
it('registers emacs via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('emacs');
});
});
-34
View File
@@ -1,34 +0,0 @@
/**
* Integration test for the gchat channel's single reach-in: the self-registration
* import in the `src/channels/index.ts` barrel. Importing the barrel runs gchat.ts's
* top-level `registerChannelAdapter('gchat', …)`; without the import the channel is
* silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './gchat.js';` line is deleted, or the barrel fails to evaluate for any
* reason (so the channel genuinely would not register), this goes red. A structural
* check of the import line would falsely pass in that second case.
*
* Importing the barrel is safe: registration is a pure top-level call, and gchat.ts
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
* never at import. It does require the adapter package (`@chat-adapter/gchat`) to be installed,
* which holds in a composed install: the skill's `pnpm install` step runs before this
* test so this test also implicitly guards that dependency (an unmocked import throws
* if the package is missing).
*
* gchat is a Chat SDK channel: gchat.ts also consumes a load-bearing *core* API
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
* drift, not this test. Every Chat SDK channel follows this same shape.
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('gchat channel registration', () => {
it('registers gchat via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('gchat');
});
});
-34
View File
@@ -1,34 +0,0 @@
/**
* Integration test for the github channel's single reach-in: the self-registration
* import in the `src/channels/index.ts` barrel. Importing the barrel runs github.ts's
* top-level `registerChannelAdapter('github', …)`; without the import the channel is
* silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './github.js';` line is deleted, or the barrel fails to evaluate for any
* reason (so the channel genuinely would not register), this goes red. A structural
* check of the import line would falsely pass in that second case.
*
* Importing the barrel is safe: registration is a pure top-level call, and github.ts
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
* never at import. It does require the adapter package (`@chat-adapter/github`) to be installed,
* which holds in a composed install: the skill's `pnpm install` step runs before this
* test so this test also implicitly guards that dependency (an unmocked import throws
* if the package is missing).
*
* github is a Chat SDK channel: github.ts also consumes a load-bearing *core* API
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
* drift, not this test. Every Chat SDK channel follows this same shape.
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('github channel registration', () => {
it('registers github via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('github');
});
});
@@ -1,34 +0,0 @@
/**
* Integration test for the imessage channel's single reach-in: the self-registration
* import in the `src/channels/index.ts` barrel. Importing the barrel runs imessage.ts's
* top-level `registerChannelAdapter('imessage', …)`; without the import the channel is
* silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './imessage.js';` line is deleted, or the barrel fails to evaluate for any
* reason (so the channel genuinely would not register), this goes red. A structural
* check of the import line would falsely pass in that second case.
*
* Importing the barrel is safe: registration is a pure top-level call, and imessage.ts
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
* never at import. It does require the adapter package (`chat-adapter-imessage`) to be installed,
* which holds in a composed install: the skill's `pnpm install` step runs before this
* test so this test also implicitly guards that dependency (an unmocked import throws
* if the package is missing).
*
* imessage is a Chat SDK channel: imessage.ts also consumes a load-bearing *core* API
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
* drift, not this test. Every Chat SDK channel follows this same shape.
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('imessage channel registration', () => {
it('registers imessage via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('imessage');
});
});
-3
View File
@@ -55,6 +55,3 @@ import './whatsapp.js';
// emacs (native HTTP bridge, no Chat SDK)
// import './emacs.js';
// deltachat (native, no Chat SDK)
// import './deltachat.js'
-34
View File
@@ -1,34 +0,0 @@
/**
* Integration test for the linear channel's single reach-in: the self-registration
* import in the `src/channels/index.ts` barrel. Importing the barrel runs linear.ts's
* top-level `registerChannelAdapter('linear', …)`; without the import the channel is
* silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './linear.js';` line is deleted, or the barrel fails to evaluate for any
* reason (so the channel genuinely would not register), this goes red. A structural
* check of the import line would falsely pass in that second case.
*
* Importing the barrel is safe: registration is a pure top-level call, and linear.ts
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
* never at import. It does require the adapter package (`@chat-adapter/linear`) to be installed,
* which holds in a composed install: the skill's `pnpm install` step runs before this
* test so this test also implicitly guards that dependency (an unmocked import throws
* if the package is missing).
*
* linear is a Chat SDK channel: linear.ts also consumes a load-bearing *core* API
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
* drift, not this test. Every Chat SDK channel follows this same shape.
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('linear channel registration', () => {
it('registers linear via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('linear');
});
});
-34
View File
@@ -1,34 +0,0 @@
/**
* Integration test for the matrix channel's single reach-in: the self-registration
* import in the `src/channels/index.ts` barrel. Importing the barrel runs matrix.ts's
* top-level `registerChannelAdapter('matrix', …)`; without the import the channel is
* silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './matrix.js';` line is deleted, or the barrel fails to evaluate for any
* reason (so the channel genuinely would not register), this goes red. A structural
* check of the import line would falsely pass in that second case.
*
* Importing the barrel is safe: registration is a pure top-level call, and matrix.ts
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
* never at import. It does require the adapter package (`@beeper/chat-adapter-matrix`) to be installed,
* which holds in a composed install: the skill's `pnpm install` step runs before this
* test so this test also implicitly guards that dependency (an unmocked import throws
* if the package is missing).
*
* matrix is a Chat SDK channel: matrix.ts also consumes a load-bearing *core* API
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
* drift, not this test. Every Chat SDK channel follows this same shape.
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('matrix channel registration', () => {
it('registers matrix via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('matrix');
});
});
-34
View File
@@ -1,34 +0,0 @@
/**
* Integration test for the resend channel's single reach-in: the self-registration
* import in the `src/channels/index.ts` barrel. Importing the barrel runs resend.ts's
* top-level `registerChannelAdapter('resend', …)`; without the import the channel is
* silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './resend.js';` line is deleted, or the barrel fails to evaluate for any
* reason (so the channel genuinely would not register), this goes red. A structural
* check of the import line would falsely pass in that second case.
*
* Importing the barrel is safe: registration is a pure top-level call, and resend.ts
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
* never at import. It does require the adapter package (`@resend/chat-sdk-adapter`) to be installed,
* which holds in a composed install: the skill's `pnpm install` step runs before this
* test so this test also implicitly guards that dependency (an unmocked import throws
* if the package is missing).
*
* resend is a Chat SDK channel: resend.ts also consumes a load-bearing *core* API
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
* drift, not this test. Every Chat SDK channel follows this same shape.
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('resend channel registration', () => {
it('registers resend via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('resend');
});
});
-29
View File
@@ -1,29 +0,0 @@
/**
* Integration test for the signal channel's single reach-in: the self-registration
* import in the `src/channels/index.ts` barrel. Importing the barrel runs signal.ts's
* top-level `registerChannelAdapter('signal', …)`; without the import the channel is
* silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './signal.js';` line is deleted, or the barrel fails to evaluate for any
* reason (so the channel genuinely would not register), this goes red. A structural
* check of the import line would falsely pass in that second case.
*
* signal is a native adapter with no npm dependency (it drives the external signal-cli binary over a local TCP socket); it talks to signal-cli.
* Importing the barrel is safe: registration is a pure top-level call and signal.ts
* opens connections / spawns subprocesses only inside setup() (run at host startup),
* never at import. There is no adapter package to guard here this test guards the
* one barrel reach-in (red if `import './signal.js';` is deleted or the barrel fails
* to evaluate).
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('signal channel registration', () => {
it('registers signal via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('signal');
});
});
-34
View File
@@ -1,34 +0,0 @@
/**
* Integration test for the slack channel's single reach-in: the self-registration
* import in the `src/channels/index.ts` barrel. Importing the barrel runs slack.ts's
* top-level `registerChannelAdapter('slack', …)`; without the import the channel is
* silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './slack.js';` line is deleted, or the barrel fails to evaluate for any
* reason (so the channel genuinely would not register), this goes red. A structural
* check of the import line would falsely pass in that second case.
*
* Importing the barrel is safe: registration is a pure top-level call, and slack.ts
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
* never at import. It does require the adapter package to be installed, which holds
* in a composed install: the skill's `pnpm install` step runs before this test.
*
* Note on the Chat SDK family: slack.ts also consumes a load-bearing *core* API
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js with a specific options
* shape. That core-consumption is a typed call, so the build/typecheck leg
* (`pnpm run build`) guards it against upstream drift, not this test. Every Chat SDK
* channel (discord, telegram, teams, gchat, webex, ) follows this same shape:
* swap the channel name below and the adapter package in the build.
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('slack channel registration', () => {
it('registers slack via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('slack');
});
});
-34
View File
@@ -1,34 +0,0 @@
/**
* Integration test for the teams channel's single reach-in: the self-registration
* import in the `src/channels/index.ts` barrel. Importing the barrel runs teams.ts's
* top-level `registerChannelAdapter('teams', …)`; without the import the channel is
* silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './teams.js';` line is deleted, or the barrel fails to evaluate for any
* reason (so the channel genuinely would not register), this goes red. A structural
* check of the import line would falsely pass in that second case.
*
* Importing the barrel is safe: registration is a pure top-level call, and teams.ts
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
* never at import. It does require the adapter package (`@chat-adapter/teams`) to be installed,
* which holds in a composed install: the skill's `pnpm install` step runs before this
* test so this test also implicitly guards that dependency (an unmocked import throws
* if the package is missing).
*
* teams is a Chat SDK channel: teams.ts also consumes a load-bearing *core* API
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
* drift, not this test. Every Chat SDK channel follows this same shape.
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('teams channel registration', () => {
it('registers teams via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('teams');
});
});
@@ -1,34 +0,0 @@
/**
* Integration test for the telegram channel's single reach-in: the self-registration
* import in the `src/channels/index.ts` barrel. Importing the barrel runs telegram.ts's
* top-level `registerChannelAdapter('telegram', …)`; without the import the channel is
* silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './telegram.js';` line is deleted, or the barrel fails to evaluate for any
* reason (so the channel genuinely would not register), this goes red. A structural
* check of the import line would falsely pass in that second case.
*
* Importing the barrel is safe: registration is a pure top-level call, and telegram.ts
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
* never at import. It does require the adapter package (`@chat-adapter/telegram`) to be installed,
* which holds in a composed install: the skill's `pnpm install` step runs before this
* test so this test also implicitly guards that dependency (an unmocked import throws
* if the package is missing).
*
* telegram is a Chat SDK channel: telegram.ts also consumes a load-bearing *core* API
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
* drift, not this test. Every Chat SDK channel follows this same shape.
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('telegram channel registration', () => {
it('registers telegram via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('telegram');
});
});
-34
View File
@@ -1,34 +0,0 @@
/**
* Integration test for the webex channel's single reach-in: the self-registration
* import in the `src/channels/index.ts` barrel. Importing the barrel runs webex.ts's
* top-level `registerChannelAdapter('webex', …)`; without the import the channel is
* silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './webex.js';` line is deleted, or the barrel fails to evaluate for any
* reason (so the channel genuinely would not register), this goes red. A structural
* check of the import line would falsely pass in that second case.
*
* Importing the barrel is safe: registration is a pure top-level call, and webex.ts
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
* never at import. It does require the adapter package (`@bitbasti/chat-adapter-webex`) to be installed,
* which holds in a composed install: the skill's `pnpm install` step runs before this
* test so this test also implicitly guards that dependency (an unmocked import throws
* if the package is missing).
*
* webex is a Chat SDK channel: webex.ts also consumes a load-bearing *core* API
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
* drift, not this test. Every Chat SDK channel follows this same shape.
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('webex channel registration', () => {
it('registers webex via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('webex');
});
});
-29
View File
@@ -1,29 +0,0 @@
/**
* Integration test for the wechat channel's single reach-in: the self-registration
* import in the `src/channels/index.ts` barrel. Importing the barrel runs wechat.ts's
* top-level `registerChannelAdapter('wechat', …)`; without the import the channel is
* silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './wechat.js';` line is deleted, or the barrel fails to evaluate for any
* reason (so the channel genuinely would not register), this goes red. A structural
* check of the import line would falsely pass in that second case.
*
* wechat is a native adapter (no Chat SDK bridge). Importing the barrel is safe:
* registration is a pure top-level call and wechat.ts opens connections / spawns
* subprocesses only inside setup() (run at host startup), never at import. It does
* require the adapter package (`wechat-ilink-client`) to be installed, which holds in a composed
* install: the skill's `pnpm install` step runs before this test so this test also
* implicitly guards that dependency (an unmocked import throws if the package is missing).
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('wechat channel registration', () => {
it('registers wechat via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('wechat');
});
});
@@ -1,34 +0,0 @@
/**
* Integration test for the whatsapp-cloud channel's single reach-in: the self-registration
* import in the `src/channels/index.ts` barrel. Importing the barrel runs whatsapp-cloud.ts's
* top-level `registerChannelAdapter('whatsapp-cloud', …)`; without the import the channel is
* silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './whatsapp-cloud.js';` line is deleted, or the barrel fails to evaluate for any
* reason (so the channel genuinely would not register), this goes red. A structural
* check of the import line would falsely pass in that second case.
*
* Importing the barrel is safe: registration is a pure top-level call, and whatsapp-cloud.ts
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
* never at import. It does require the adapter package (`@chat-adapter/whatsapp`) to be installed,
* which holds in a composed install: the skill's `pnpm install` step runs before this
* test so this test also implicitly guards that dependency (an unmocked import throws
* if the package is missing).
*
* whatsapp-cloud is a Chat SDK channel: whatsapp-cloud.ts also consumes a load-bearing *core* API
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
* drift, not this test. Every Chat SDK channel follows this same shape.
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('whatsapp-cloud channel registration', () => {
it('registers whatsapp-cloud via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('whatsapp-cloud');
});
});
@@ -1,29 +0,0 @@
/**
* Integration test for the whatsapp channel's single reach-in: the self-registration
* import in the `src/channels/index.ts` barrel. Importing the barrel runs whatsapp.ts's
* top-level `registerChannelAdapter('whatsapp', …)`; without the import the channel is
* silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './whatsapp.js';` line is deleted, or the barrel fails to evaluate for any
* reason (so the channel genuinely would not register), this goes red. A structural
* check of the import line would falsely pass in that second case.
*
* whatsapp is a native adapter (no Chat SDK bridge). Importing the barrel is safe:
* registration is a pure top-level call and whatsapp.ts opens connections / spawns
* subprocesses only inside setup() (run at host startup), never at import. It does
* require the adapter package (`@whiskeysockets/baileys`) to be installed, which holds in a composed
* install: the skill's `pnpm install` step runs before this test so this test also
* implicitly guards that dependency (an unmocked import throws if the package is missing).
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('whatsapp channel registration', () => {
it('registers whatsapp via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('whatsapp');
});
});
-162
View File
@@ -1,162 +0,0 @@
/**
* 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']);
});
});
+74 -264
View File
@@ -1,15 +1,10 @@
/**
* WhatsApp channel adapter (v2) native Baileys v7 implementation.
* WhatsApp channel adapter (v2) native Baileys v6 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.
* @whiskeysockets/baileys v6 (stable). Ports proven v1 infrastructure:
* getMessage fallback, outgoing queue, group metadata cache, LID mapping,
* reconnection with backoff.
*
* Auth credentials persist in store/auth/. On first run:
* - If WHATSAPP_PHONE_NUMBER is set pairing code (printed to log)
@@ -27,7 +22,6 @@ import { pino } from 'pino';
import {
makeWASocket,
proto,
Browsers,
DisconnectReason,
fetchLatestWaWebVersion,
@@ -46,54 +40,29 @@ 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.',
);
// Baileys v6 bug: getPlatformId sends charCode (49) instead of enum value (1).
// Fixed in Baileys 7.x but not backported. Without this, pairing codes fail with
// "couldn't link device" because WhatsApp receives an invalid platform ID.
// Must use createRequire — ESM `import *` creates a read-only namespace.
// proto is not available as a named ESM export — use createRequire (same as v1)
import { createRequire } from 'module';
const _require = createRequire(import.meta.url);
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 (Node version mismatch), pairing codes may not work
// but QR auth will still function fine.
log.warn('Could not patch getPlatformId — pairing code auth may fail');
}
const baileysLogger = pino({ level: 'silent' });
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
@@ -151,99 +120,10 @@ function transformForWhatsApp(text: string): string {
return text;
}
// WhatsApp tags `@<phone-digits>` (515 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[] } {
/** Convert Claude's markdown to WhatsApp-native formatting. */
function formatWhatsApp(text: string): 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;
return segments.map(({ content, isProtected }) => (isProtected ? content : transformForWhatsApp(content))).join('');
}
/** Map file extension to Baileys media message type. */
@@ -281,16 +161,14 @@ registerChannelAdapter('whatsapp', {
// 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[] }> = [];
const outgoingQueue: Array<{ jid: string; text: string }> = [];
let flushing = false;
// Sent message cache for retry/re-encrypt requests
@@ -329,30 +207,21 @@ registerChannelAdapter('whatsapp', {
groupMetadataCache.clear();
}
async function translateJid(jid: string, altJid?: string): Promise<string> {
async function translateJid(jid: 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
// Query Baileys' signal repository
try {
const pn = await sock.signalRepository.lidMapping.getPNForLID(jid);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pn = await (sock.signalRepository as any)?.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 });
log.info('Translated LID to phone JID', { lidJid: jid, phoneJid });
return phoneJid;
}
} catch (err) {
@@ -411,9 +280,7 @@ registerChannelAdapter('whatsapp', {
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);
const sent = await sock.sendMessage(item.jid, { text: item.text });
if (sent?.key?.id && sent.message) {
sentMessageCache.set(sent.key.id, sent.message);
}
@@ -465,16 +332,14 @@ registerChannelAdapter('whatsapp', {
return results;
}
async function sendRawMessage(jid: string, text: string, mentions?: string[]): Promise<string | undefined> {
async function sendRawMessage(jid: string, text: string): Promise<string | undefined> {
if (!connected) {
outgoingQueue.push({ jid, text, mentions });
outgoingQueue.push({ jid, text });
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);
const sent = await sock.sendMessage(jid, { text });
if (sent?.key?.id && sent.message) {
sentMessageCache.set(sent.key.id, sent.message);
if (sentMessageCache.size > SENT_MESSAGE_CACHE_MAX) {
@@ -484,7 +349,7 @@ registerChannelAdapter('whatsapp', {
}
return sent?.key?.id ?? undefined;
} catch (err) {
outgoingQueue.push({ jid, text, mentions });
outgoingQueue.push({ jid, text });
log.warn('Failed to send, message queued', { jid, err, queueSize: outgoingQueue.length });
return undefined;
}
@@ -495,7 +360,10 @@ registerChannelAdapter('whatsapp', {
async function connectSocket(): Promise<void> {
const { state, saveCreds } = await useMultiFileAuthState(authDir);
const version = await resolveWaWebVersion();
const { version } = await fetchLatestWaWebVersion({}).catch((err) => {
log.warn('Failed to fetch latest WA Web version, using default', { err });
return { version: undefined };
});
sock = makeWASocket({
version,
@@ -512,22 +380,12 @@ registerChannelAdapter('whatsapp', {
const cached = sentMessageCache.get(key.id || '');
if (cached) return cached;
// Return empty message to prevent indefinite "waiting for this message"
return proto.Message.create({});
return proto.Message.fromObject({});
},
});
// Request pairing code only when there's no paired account yet.
//
// We can't use `state.creds.registered` here: Baileys 7.x doesn't
// reliably flip that flag back to `true` after the post-pair stream
// restart (statusCode 515). An already-paired socket would then see
// `registered=false` and request a *new* pairing code 3s after the
// restart, which the WhatsApp server rejects with 401 and the adapter
// wipes the auth directory — re-pair from scratch every restart.
//
// `state.creds.me` is set as part of the QR / pairing-code handshake
// and is the authoritative "this socket has an account" signal.
if (phoneNumber && !state.creds.me) {
// 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);
@@ -559,13 +417,9 @@ registerChannelAdapter('whatsapp', {
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;
const shouldReconnect = reason !== DisconnectReason.loggedOut;
log.info('WhatsApp connection closed', { reason, shouldReconnect, shuttingDown });
log.info('WhatsApp connection closed', { reason, shouldReconnect });
if (shouldReconnect) {
log.info('Reconnecting...');
@@ -577,36 +431,13 @@ registerChannelAdapter('whatsapp', {
});
}, RECONNECT_DELAY_MS);
});
} else if (reason === DisconnectReason.loggedOut) {
// Server-side logout (account unlinked, 401, etc.). Clear auth so
// the next start prompts for a fresh pair — stale creds would
// 401 again and risk WhatsApp's "can't link new devices now"
// cooldown.
} else {
log.info('WhatsApp logged out');
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 {
// Clean shutdown (shuttingDown=true) or a non-loggedOut disconnect
// that won't auto-reconnect. KEEP AUTH — the next process boot
// must be able to restore the session. Wiping here turned every
// `systemctl restart` into a forced re-pair, which is catastrophic
// when the bot phone is not in reach.
log.info('WhatsApp adapter stopped (auth preserved)');
if (rejectFirstOpen) {
rejectFirstOpen(new Error('WhatsApp adapter shutdown'));
rejectFirstOpen = undefined;
resolveFirstOpen = undefined;
}
}
} else if (connection === 'open') {
connected = true;
@@ -628,9 +459,8 @@ registerChannelAdapter('whatsapp', {
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);
setLidPhoneMapping(lidUser, `${phoneUser}@s.whatsapp.net`);
botLidUser = lidUser;
}
}
@@ -658,13 +488,10 @@ registerChannelAdapter('whatsapp', {
sock.ev.on('creds.update', saveCreds);
// LID ↔ phone mapping updates (v7 replaces chats.phoneNumberShare)
sock.ev.on('lid-mapping.update', ({ lid, pn }) => {
// Phone number sharing events — update LID mapping
sock.ev.on('chats.phoneNumberShare', ({ lid, jid }) => {
const lidUser = lid?.split('@')[0].split(':')[0];
if (lidUser && pn) {
const phoneJid = pn.includes('@') ? pn : `${pn}@s.whatsapp.net`;
setLidPhoneMapping(lidUser, phoneJid);
}
if (lidUser && jid) setLidPhoneMapping(lidUser, jid);
});
// Inbound messages
@@ -677,8 +504,16 @@ registerChannelAdapter('whatsapp', {
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);
// Translate LID → phone JID
let chatJid = await translateJid(rawJid);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (chatJid.endsWith('@lid') && (msg.key as any).senderPn) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pn = (msg.key as any).senderPn as string;
const phoneJid = pn.includes('@') ? pn : `${pn}@s.whatsapp.net`;
setLidPhoneMapping(rawJid.split('@')[0].split(':')[0], phoneJid);
chatJid = phoneJid;
}
const timestamp = new Date(Number(msg.messageTimestamp) * 1000).toISOString();
const isGroup = chatJid.endsWith('@g.us');
@@ -704,24 +539,13 @@ registerChannelAdapter('whatsapp', {
// 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 sender = msg.key.participant || msg.key.remoteJid || '';
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;
}
// fromMe is always true for messages sent from this linked device,
// regardless of ASSISTANT_HAS_OWN_NUMBER mode.
if (fromMe) continue;
const isBotMessage = ASSISTANT_HAS_OWN_NUMBER ? false : content.startsWith(`${ASSISTANT_NAME}:`);
@@ -744,21 +568,15 @@ registerChannelAdapter('whatsapp', {
}
}
// 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
// platform-confirmed mentions so the router treats them like
// chat-sdk-bridge's onDirectMessage path — auto-creating 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),
// instead of silently dropping at router.ts:184.
isMention: !isGroup ? true : undefined,
isGroup,
content: {
text: content,
@@ -863,15 +681,8 @@ registerChannelAdapter('whatsapp', {
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 caption = !captionUsed ? text : 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);
@@ -885,9 +696,9 @@ registerChannelAdapter('whatsapp', {
}
if (text) {
const { text: formatted, mentions } = formatWhatsApp(text);
const formatted = formatWhatsApp(text);
const prefixed = ASSISTANT_HAS_OWN_NUMBER ? formatted : `${ASSISTANT_NAME}: ${formatted}`;
return sendRawMessage(platformId, prefixed, mentions);
return sendRawMessage(platformId, prefixed);
}
},
@@ -900,7 +711,6 @@ registerChannelAdapter('whatsapp', {
},
async teardown() {
shuttingDown = true;
connected = false;
sock?.end(undefined);
log.info('WhatsApp adapter shut down');
+5 -7
View File
@@ -9,17 +9,15 @@
* will later emit as event.platformId, or router lookups miss and messages
* get silently dropped.
*
* 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.
* 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.
*/
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}`;
}