mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-04 10:14:47 +08:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 43adb1998a | |||
| 5ba4735fe9 | |||
| 3986ce0e11 | |||
| 3777a9b614 | |||
| 36fb78092c | |||
| c52591f68f | |||
| e372f05d2e | |||
| 8e91d37bc9 | |||
| bba8213cbd | |||
| 5f069221b2 | |||
| 151091f384 | |||
| 5ada950982 | |||
| 6c455330e4 | |||
| 27af41d9b0 | |||
| ea68aa810b | |||
| 5987fdc189 | |||
| 0ef8757f50 | |||
| 878d3706b4 | |||
| b52ab850b2 | |||
| 7b4dfd28c3 | |||
| 106c21a567 | |||
| c6b21e7493 | |||
| b672e8271e |
@@ -0,0 +1,62 @@
|
||||
# Remove DeltaChat
|
||||
|
||||
## 1. Disable the adapter
|
||||
|
||||
Comment out the import in `src/channels/index.ts`:
|
||||
|
||||
```typescript
|
||||
// import './deltachat.js';
|
||||
```
|
||||
|
||||
## 2. Remove credentials
|
||||
|
||||
Remove the `DC_*` lines from `.env`:
|
||||
|
||||
```bash
|
||||
DC_EMAIL
|
||||
DC_PASSWORD
|
||||
DC_IMAP_HOST
|
||||
DC_IMAP_PORT
|
||||
DC_SMTP_HOST
|
||||
DC_SMTP_PORT
|
||||
```
|
||||
|
||||
## 3. Rebuild and restart
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
|
||||
# Linux
|
||||
systemctl --user restart nanoclaw
|
||||
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
```
|
||||
|
||||
## 4. Remove account data (optional)
|
||||
|
||||
To fully remove all account data including DeltaChat encryption keys:
|
||||
|
||||
```bash
|
||||
rm -rf dc-account/
|
||||
```
|
||||
|
||||
> **Warning:** This deletes the Autocrypt keys. Contacts who have verified your bot's key will need to re-verify if the same email address is re-used with a new account.
|
||||
|
||||
To keep the account for later reinstall, leave `dc-account/` intact.
|
||||
|
||||
## 5. Remove the package (optional)
|
||||
|
||||
```bash
|
||||
pnpm remove @deltachat/stdio-rpc-server
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
After removal, confirm the adapter is no longer starting:
|
||||
|
||||
```bash
|
||||
grep "deltachat" logs/nanoclaw.log | tail -5
|
||||
```
|
||||
|
||||
Expected: no `Channel adapter started` entry after the last restart.
|
||||
@@ -0,0 +1,254 @@
|
||||
---
|
||||
name: add-deltachat
|
||||
description: Add DeltaChat channel integration via @deltachat/stdio-rpc-server. Native adapter — no Chat SDK bridge. Email-based messaging with end-to-end encryption.
|
||||
---
|
||||
|
||||
# Add DeltaChat Channel
|
||||
|
||||
The adapter drives the `@deltachat/stdio-rpc-server` JSON-RPC subprocess directly — pure Node.js against the DeltaChat core library. Messages are delivered over email with Autocrypt/OpenPGP encryption.
|
||||
|
||||
## Install
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
- `src/channels/deltachat.ts` exists
|
||||
- `src/channels/index.ts` contains `import './deltachat.js';`
|
||||
- `@deltachat/stdio-rpc-server` is listed in `package.json` dependencies
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
### 1. Fetch the channels branch
|
||||
|
||||
```bash
|
||||
git fetch origin channels
|
||||
```
|
||||
|
||||
### 2. Copy the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/deltachat.ts > src/channels/deltachat.ts
|
||||
```
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if already present):
|
||||
|
||||
```typescript
|
||||
import './deltachat.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @deltachat/stdio-rpc-server@2.49.0
|
||||
```
|
||||
|
||||
### 5. Build
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
## Account Setup
|
||||
|
||||
A dedicated email account is strongly recommended — it will accumulate DeltaChat-formatted messages and store encryption keys. Not all providers work well with DeltaChat; check https://providers.delta.chat/ before picking one.
|
||||
|
||||
**Default security modes:** IMAP uses SSL/TLS (port 993), SMTP uses STARTTLS (port 587). Both are configurable via `.env` — see Credentials below.
|
||||
|
||||
To find the correct hostnames for a domain:
|
||||
|
||||
```bash
|
||||
node -e "require('dns').resolveMx('example.com', (e,r) => console.log(r))"
|
||||
```
|
||||
|
||||
Most providers publish their IMAP/SMTP hostnames in their help docs under "manual setup" or "IMAP access."
|
||||
|
||||
## Credentials
|
||||
|
||||
Add to `.env`:
|
||||
|
||||
```bash
|
||||
DC_EMAIL=bot@example.com
|
||||
DC_PASSWORD=your-app-password
|
||||
DC_IMAP_HOST=imap.example.com
|
||||
DC_IMAP_PORT=993
|
||||
DC_IMAP_SECURITY=1 # 1=SSL/TLS (default), 2=STARTTLS, 3=plain
|
||||
DC_SMTP_HOST=smtp.example.com
|
||||
DC_SMTP_PORT=587
|
||||
DC_SMTP_SECURITY=2 # 2=STARTTLS (default), 1=SSL/TLS, 3=plain
|
||||
```
|
||||
|
||||
Security settings are applied on every startup, so changing them in `.env` and restarting takes effect without wiping the account.
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
### Optional settings
|
||||
|
||||
The following are read from the process environment (not `.env`). To override them, add `Environment=` lines to the systemd service unit or your launchd plist:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DC_ACCOUNT_DIR` | `dc-account` | Directory for DeltaChat account data (IMAP state, keys, blobs) |
|
||||
| `DC_DISPLAY_NAME` | `NanoClaw` | Bot display name shown in DeltaChat |
|
||||
| `DC_AVATAR_PATH` | _(none)_ | Absolute path to avatar image; set at startup only |
|
||||
|
||||
The `/set-avatar` command (send an image with that caption) is the easiest way to set the avatar at runtime without modifying the service file. Only users with `owner` or global `admin` role can use it.
|
||||
|
||||
### Restart
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
systemctl --user restart nanoclaw
|
||||
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
```
|
||||
|
||||
On first start the adapter configures the email account (IMAP/SMTP credentials, calls `configure()`). Subsequent starts skip straight to `startIo()`. Account data is stored in `dc-account/` in the project root (or your `DC_ACCOUNT_DIR`).
|
||||
|
||||
## Wiring
|
||||
|
||||
### DMs
|
||||
|
||||
**DeltaChat contacts cannot be added by email alone** — to start a chat, the user must open the bot's invite link in their DeltaChat app or scan its QR code. This triggers the SecureJoin handshake.
|
||||
|
||||
#### Step 1 — Get the invite link
|
||||
|
||||
After the service starts, the adapter logs the invite URL and writes a QR SVG:
|
||||
|
||||
```bash
|
||||
grep "invite link" logs/nanoclaw.log | tail -1
|
||||
# url field contains the https://i.delta.chat/... invite link
|
||||
# also written to dc-account/invite-qr.svg (or $DC_ACCOUNT_DIR/invite-qr.svg)
|
||||
```
|
||||
|
||||
The invite URL is stable (tied to the bot's email and encryption keys) so it stays valid across restarts.
|
||||
|
||||
#### Step 2 — Add the bot in DeltaChat
|
||||
|
||||
Two options for the user to connect:
|
||||
|
||||
- **Link**: Copy the `https://i.delta.chat/...` URL and open it on the device running DeltaChat. The app recognises it and shows a "Start chat" prompt.
|
||||
- **QR code**: Open `dc-account/invite-qr.svg` in a browser or image viewer, display it on screen, and scan it from the DeltaChat app using the QR-scan button on the new-chat screen.
|
||||
|
||||
After accepting, DeltaChat exchanges keys and creates the chat automatically.
|
||||
|
||||
#### Step 3 — Wire the chat to an agent
|
||||
|
||||
Once the first message arrives the router auto-creates a `messaging_groups` row. Look up the chat ID:
|
||||
|
||||
```bash
|
||||
sqlite3 data/v2.db \
|
||||
"SELECT platform_id, name FROM messaging_groups WHERE channel_type='deltachat' AND is_group=0 ORDER BY created_at DESC LIMIT 5"
|
||||
```
|
||||
|
||||
Then run `/init-first-agent` — it creates the agent group, grants the user owner access, and wires the messaging group in one step:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/init-first-agent.ts \
|
||||
--channel deltachat \
|
||||
--user-id deltachat:user@example.com \
|
||||
--platform-id <platform_id from above> \
|
||||
--display-name "Your Name"
|
||||
```
|
||||
|
||||
### Groups
|
||||
|
||||
Add the bot email to a DeltaChat group. When any member sends a message, the router creates a `messaging_groups` row with `is_group = 1`. Run `/manage-channels` to wire it to an agent group.
|
||||
|
||||
## Next Steps
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now.
|
||||
|
||||
Otherwise, run `/init-first-agent` to create an agent and wire it to your DeltaChat DM (see Wiring above), or `/manage-channels` to wire this channel to an existing agent group.
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `deltachat`
|
||||
- **terminology**: DeltaChat calls them "chats" (1:1 DMs) and "groups"
|
||||
- **supports-threads**: no — DeltaChat has no thread model
|
||||
- **platform-id-format**: numeric chat ID as a string (e.g. `"12"`) — the DeltaChat core's internal chat identifier
|
||||
- **user-id-format**: `deltachat:{email}` — the contact's email address
|
||||
- **how-to-find-id**: Send a message from DeltaChat to the bot email, then query `messaging_groups` as shown above
|
||||
- **typical-use**: Personal assistant over DeltaChat DMs; small groups where participants use DeltaChat
|
||||
- **default-isolation**: One agent per bot identity. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode
|
||||
|
||||
### Features
|
||||
|
||||
- File attachments — inbound and outbound; inbound waits up to 30 seconds for large-message download to complete
|
||||
- Invite link logged on every startup — URL + QR SVG written to `dc-account/invite-qr.svg`; see Wiring for the bootstrap flow
|
||||
- `/set-avatar` — send an image with this caption to change the bot's DeltaChat avatar (admin/owner only)
|
||||
- Connectivity watchdog — restarts IO if IMAP goes quiet for 20 minutes or connectivity drops below threshold for two consecutive 5-minute checks
|
||||
- Network nudge — `maybeNetwork()` called every 10 minutes to recover from prolonged idle
|
||||
|
||||
Not supported: DeltaChat reactions, message editing/deletion, read receipts.
|
||||
|
||||
### Connectivity model
|
||||
|
||||
`isConnected()` returns `true` when the internal connectivity value is ≥ 3000:
|
||||
|
||||
| Range | Meaning |
|
||||
|-------|---------|
|
||||
| 1000–1999 | Not connected |
|
||||
| 2000–2999 | Connecting |
|
||||
| 3000–3999 | Working (IMAP fetching) |
|
||||
| ≥ 4000 | Fully connected (IMAP IDLE) |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Adapter not starting — credentials missing
|
||||
|
||||
```bash
|
||||
grep "Channel credentials missing" logs/nanoclaw.log | grep deltachat
|
||||
```
|
||||
|
||||
All six required vars (`DC_EMAIL`, `DC_PASSWORD`, `DC_IMAP_HOST`, `DC_IMAP_PORT`, `DC_SMTP_HOST`, `DC_SMTP_PORT`) must be present in `.env`.
|
||||
|
||||
### Account configure fails
|
||||
|
||||
```bash
|
||||
grep "DeltaChat" logs/nanoclaw.log | tail -20
|
||||
```
|
||||
|
||||
Common causes:
|
||||
- Wrong IMAP/SMTP hostnames — double-check provider docs
|
||||
- App password not generated — Gmail and some others require this when 2FA is enabled
|
||||
- Port/security mismatch — defaults are port 993 + SSL/TLS for IMAP and port 587 + STARTTLS for SMTP; override with `DC_IMAP_PORT`/`DC_IMAP_SECURITY` or `DC_SMTP_PORT`/`DC_SMTP_SECURITY` in `.env`
|
||||
|
||||
### Provider uses SMTP port 465 (SSL/TLS) instead of 587
|
||||
|
||||
Set `DC_SMTP_SECURITY=1` and `DC_SMTP_PORT=465` in `.env`, then restart.
|
||||
|
||||
### Messages not arriving
|
||||
|
||||
1. Check the service is running and the adapter started: `grep "Channel adapter started.*deltachat" logs/nanoclaw.log`
|
||||
2. Check connectivity: `grep "DeltaChat: IO started" logs/nanoclaw.log`
|
||||
3. Check the sender has been granted access — run `/init-first-agent` to create their user record and wire the chat
|
||||
4. Verify the messaging group is wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mga.agent_group_id FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='deltachat'"`
|
||||
|
||||
### Stale lock file after crash
|
||||
|
||||
```bash
|
||||
rm -f dc-account/accounts.lock
|
||||
systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
### Bot not responding after restart
|
||||
|
||||
The account is already configured — IO restarts automatically on service start. If the RPC subprocess is stuck, restart the service. Check for errors:
|
||||
|
||||
```bash
|
||||
grep "DeltaChat" logs/nanoclaw.error.log | tail -20
|
||||
```
|
||||
|
||||
### Messages received but agent not responding
|
||||
|
||||
The messaging group exists but may not be wired to an agent group. Run:
|
||||
|
||||
```bash
|
||||
sqlite3 data/v2.db "SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat'"
|
||||
```
|
||||
|
||||
If the group has no entry in `messaging_group_agents`, wire it with `/manage-channels`.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Verify DeltaChat
|
||||
|
||||
## 1. Check the adapter started
|
||||
|
||||
```bash
|
||||
grep "Channel adapter started.*deltachat" logs/nanoclaw.log | tail -1
|
||||
```
|
||||
|
||||
Expected: `Channel adapter started { channel: 'deltachat', type: 'deltachat' }`
|
||||
|
||||
## 2. Check IMAP/SMTP connectivity
|
||||
|
||||
Replace with your provider's hostnames from `.env`:
|
||||
|
||||
```bash
|
||||
DC_IMAP=$(grep '^DC_IMAP_HOST=' .env | cut -d= -f2)
|
||||
DC_SMTP=$(grep '^DC_SMTP_HOST=' .env | cut -d= -f2)
|
||||
|
||||
bash -c "echo >/dev/tcp/$DC_IMAP/993" && echo "IMAP open" || echo "IMAP blocked"
|
||||
bash -c "echo >/dev/tcp/$DC_SMTP/587" && echo "SMTP open" || echo "SMTP blocked"
|
||||
```
|
||||
|
||||
## 3. End-to-end message test
|
||||
|
||||
1. Open DeltaChat on your device
|
||||
2. Add the bot email address as a contact
|
||||
3. Send a message
|
||||
4. The bot should respond within a few seconds
|
||||
|
||||
If nothing arrives, check:
|
||||
|
||||
```bash
|
||||
grep "DeltaChat" logs/nanoclaw.log | tail -20
|
||||
grep "DeltaChat" logs/nanoclaw.error.log | tail -10
|
||||
```
|
||||
|
||||
## 4. Check messaging group was created
|
||||
|
||||
```bash
|
||||
sqlite3 data/v2.db \
|
||||
"SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat' ORDER BY created_at DESC LIMIT 5"
|
||||
```
|
||||
|
||||
If a row appears, the inbound routing is working. If not, the adapter isn't receiving the message — check logs for `DeltaChat: error handling incoming message`.
|
||||
|
||||
## 5. Verify user access
|
||||
|
||||
If the message arrived but the agent didn't respond, the sender may not have access:
|
||||
|
||||
```bash
|
||||
sqlite3 data/v2.db "SELECT id, display_name FROM users WHERE id LIKE 'deltachat:%'"
|
||||
```
|
||||
|
||||
Grant access as shown in the SKILL.md "Grant user access" section.
|
||||
@@ -57,7 +57,7 @@ groups: () => import('./groups.js'),
|
||||
### 5. Install the adapter packages (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
||||
pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
||||
```
|
||||
|
||||
### 6. Build
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@
|
||||
"@onecli-sh/sdk": "^0.3.1",
|
||||
"@resend/chat-sdk-adapter": "^0.1.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@whiskeysockets/baileys": "^6.17.16",
|
||||
"@whiskeysockets/baileys": "7.0.0-rc.9",
|
||||
"better-sqlite3": "11.10.0",
|
||||
"chat": "^4.24.0",
|
||||
"chat-adapter-imessage": "^0.1.1",
|
||||
|
||||
Generated
+125
-174
@@ -57,8 +57,8 @@ importers:
|
||||
specifier: ^1.5.6
|
||||
version: 1.5.6
|
||||
'@whiskeysockets/baileys':
|
||||
specifier: ^6.17.16
|
||||
version: 6.17.16(eslint@9.39.4)(qrcode-terminal@0.12.0)(typescript@5.9.3)
|
||||
specifier: 7.0.0-rc.9
|
||||
version: 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5)
|
||||
better-sqlite3:
|
||||
specifier: 11.10.0
|
||||
version: 11.10.0
|
||||
@@ -123,9 +123,6 @@ 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'}
|
||||
@@ -147,6 +144,9 @@ 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,6 +1162,10 @@ 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==}
|
||||
|
||||
@@ -1329,42 +1333,29 @@ packages:
|
||||
'@wasm-audio-decoders/opus-ml@0.0.2':
|
||||
resolution: {integrity: sha512-58rWEqDGg+CKCyEeKm2KoxxSwTWtHh/NLTW9ObR4K8CGF6VwuuGudEI1CtniS/oSRmL1nJq/eh8MKARiluw4DQ==}
|
||||
|
||||
'@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.
|
||||
'@whiskeysockets/baileys@7.0.0-rc.9':
|
||||
resolution: {integrity: sha512-YFm5gKXfDP9byCXCW3OPHKXLzrAKzolzgVUlRosHHgwbnf2YOO3XknkMm6J7+F0ns8OA0uuSBhgkRHTDtqkacw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
jimp: ^0.16.1
|
||||
audio-decode: ^2.1.3
|
||||
jimp: ^1.6.0
|
||||
link-preview-js: ^3.0.0
|
||||
qrcode-terminal: ^0.12.0
|
||||
sharp: ^0.32.6
|
||||
sharp: '*'
|
||||
peerDependenciesMeta:
|
||||
audio-decode:
|
||||
optional: true
|
||||
jimp:
|
||||
optional: true
|
||||
link-preview-js:
|
||||
optional: true
|
||||
qrcode-terminal:
|
||||
optional: true
|
||||
sharp:
|
||||
optional: true
|
||||
|
||||
'@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}
|
||||
'@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'}
|
||||
@@ -1404,8 +1395,8 @@ packages:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
async-lock@1.4.1:
|
||||
resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==}
|
||||
async-mutex@0.5.0:
|
||||
resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==}
|
||||
|
||||
asynckit@0.4.0:
|
||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||
@@ -1493,17 +1484,10 @@ 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==}
|
||||
|
||||
@@ -1781,11 +1765,6 @@ 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}
|
||||
@@ -1839,10 +1818,6 @@ 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==}
|
||||
|
||||
@@ -1909,9 +1884,9 @@ packages:
|
||||
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
file-type@16.5.4:
|
||||
resolution: {integrity: sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==}
|
||||
engines: {node: '>=10'}
|
||||
file-type@21.3.4:
|
||||
resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
file-uri-to-path@1.0.0:
|
||||
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
|
||||
@@ -2211,9 +2186,6 @@ 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'}
|
||||
@@ -2345,8 +2317,9 @@ packages:
|
||||
longest-streak@3.1.0:
|
||||
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
||||
|
||||
lru-cache@10.4.3:
|
||||
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
||||
lru-cache@11.3.6:
|
||||
resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
lru-cache@6.0.0:
|
||||
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
|
||||
@@ -2554,9 +2527,9 @@ packages:
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
music-metadata@7.14.0:
|
||||
resolution: {integrity: sha512-xrm3w7SV0Wk+OythZcSbaI8mcr/KHd0knJieu8bVpaPfMv/Agz5EooCAPz3OR5hbYMiUG6dgAPKZKnMzV+3amA==}
|
||||
engines: {node: '>=10'}
|
||||
music-metadata@11.12.3:
|
||||
resolution: {integrity: sha512-n6hSTZkuD59qWgHh6IP5dtDlDZQXoxk/bcA85Jywg8Z1iFrlNgl2+GTFgjZyn52W5UgQpV42V4XqrQZZAMbZTQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
nanoid@3.3.11:
|
||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||
@@ -2659,6 +2632,10 @@ 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'}
|
||||
@@ -2671,6 +2648,10 @@ 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'}
|
||||
@@ -2703,10 +2684,6 @@ 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==}
|
||||
|
||||
@@ -2757,14 +2734,6 @@ 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==}
|
||||
|
||||
@@ -2839,14 +2808,6 @@ 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'}
|
||||
@@ -3045,9 +3006,9 @@ packages:
|
||||
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
strtok3@6.3.0:
|
||||
resolution: {integrity: sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==}
|
||||
engines: {node: '>=10'}
|
||||
strtok3@10.3.5:
|
||||
resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
supports-color@7.2.0:
|
||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||
@@ -3092,9 +3053,9 @@ packages:
|
||||
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
token-types@4.2.1:
|
||||
resolution: {integrity: sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==}
|
||||
engines: {node: '>=10'}
|
||||
token-types@6.1.2:
|
||||
resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
trim-lines@3.0.1:
|
||||
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
|
||||
@@ -3142,6 +3103,10 @@ 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==}
|
||||
|
||||
@@ -3316,6 +3281,9 @@ 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'}
|
||||
@@ -3381,8 +3349,6 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@adiwajshing/keyed-db@0.2.4': {}
|
||||
|
||||
'@azure/msal-common@15.17.0': {}
|
||||
|
||||
'@azure/msal-node@3.8.10':
|
||||
@@ -3413,6 +3379,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@borewit/text-codec@0.2.2': {}
|
||||
|
||||
'@cacheable/memory@2.0.8':
|
||||
dependencies:
|
||||
'@cacheable/utils': 2.4.1
|
||||
@@ -3681,7 +3649,8 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.27.7':
|
||||
optional: true
|
||||
|
||||
'@eshaz/web-worker@1.2.2': {}
|
||||
'@eshaz/web-worker@1.2.2':
|
||||
optional: true
|
||||
|
||||
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)':
|
||||
dependencies:
|
||||
@@ -4338,8 +4307,17 @@ snapshots:
|
||||
'@thi.ng/bitstream@2.4.46':
|
||||
dependencies:
|
||||
'@thi.ng/errors': 2.6.8
|
||||
optional: true
|
||||
|
||||
'@thi.ng/errors@2.6.8': {}
|
||||
'@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
|
||||
|
||||
'@tokenizer/token@0.3.0': {}
|
||||
|
||||
@@ -4544,70 +4522,52 @@ 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@6.17.16(eslint@9.39.4)(qrcode-terminal@0.12.0)(typescript@5.9.3)':
|
||||
'@whiskeysockets/baileys@7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5)':
|
||||
dependencies:
|
||||
'@adiwajshing/keyed-db': 0.2.4
|
||||
'@cacheable/node-cache': 1.7.6
|
||||
'@hapi/boom': 9.1.4
|
||||
'@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
|
||||
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
|
||||
pino: 9.14.0
|
||||
protobufjs: 7.5.5
|
||||
uuid: 10.0.0
|
||||
sharp: 0.34.5
|
||||
ws: 8.20.0
|
||||
optionalDependencies:
|
||||
qrcode-terminal: 0.12.0
|
||||
audio-decode: 2.2.3
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- debug
|
||||
- eslint
|
||||
- supports-color
|
||||
- typescript
|
||||
- utf-8-validate
|
||||
|
||||
'@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':
|
||||
'@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
|
||||
@@ -4640,13 +4600,16 @@ snapshots:
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
async-lock@1.4.1: {}
|
||||
async-mutex@0.5.0:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
atomic-sleep@1.0.0: {}
|
||||
|
||||
audio-buffer@5.0.0: {}
|
||||
audio-buffer@5.0.0:
|
||||
optional: true
|
||||
|
||||
audio-decode@2.2.3:
|
||||
dependencies:
|
||||
@@ -4658,8 +4621,10 @@ snapshots:
|
||||
node-wav: 0.0.2
|
||||
ogg-opus-decoder: 1.7.3
|
||||
qoa-format: 1.0.1
|
||||
optional: true
|
||||
|
||||
audio-type@2.4.1: {}
|
||||
audio-type@2.4.1:
|
||||
optional: true
|
||||
|
||||
axios@1.15.2:
|
||||
dependencies:
|
||||
@@ -4746,20 +4711,8 @@ 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
|
||||
@@ -4832,7 +4785,8 @@ snapshots:
|
||||
|
||||
cluster-key-slot@1.1.2: {}
|
||||
|
||||
codec-parser@2.5.0: {}
|
||||
codec-parser@2.5.0:
|
||||
optional: true
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
@@ -5058,10 +5012,6 @@ 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
|
||||
@@ -5136,8 +5086,6 @@ snapshots:
|
||||
|
||||
etag@1.8.1: {}
|
||||
|
||||
event-target-shim@5.0.1: {}
|
||||
|
||||
eventemitter3@4.0.7: {}
|
||||
|
||||
eventemitter3@5.0.4: {}
|
||||
@@ -5216,11 +5164,14 @@ snapshots:
|
||||
dependencies:
|
||||
flat-cache: 4.0.1
|
||||
|
||||
file-type@16.5.4:
|
||||
file-type@21.3.4:
|
||||
dependencies:
|
||||
readable-web-to-node-stream: 3.0.4
|
||||
strtok3: 6.3.0
|
||||
token-types: 4.2.1
|
||||
'@tokenizer/inflate': 0.4.1
|
||||
strtok3: 10.3.5
|
||||
token-types: 6.1.2
|
||||
uint8array-extras: 1.5.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
file-uri-to-path@1.0.0: {}
|
||||
|
||||
@@ -5542,8 +5493,6 @@ snapshots:
|
||||
prelude-ls: 1.2.1
|
||||
type-check: 0.4.0
|
||||
|
||||
libphonenumber-js@1.12.41: {}
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
optional: true
|
||||
|
||||
@@ -5633,7 +5582,7 @@ snapshots:
|
||||
|
||||
longest-streak@3.1.0: {}
|
||||
|
||||
lru-cache@10.4.3: {}
|
||||
lru-cache@11.3.6: {}
|
||||
|
||||
lru-cache@6.0.0:
|
||||
dependencies:
|
||||
@@ -6020,18 +5969,22 @@ snapshots:
|
||||
mpg123-decoder@1.0.3:
|
||||
dependencies:
|
||||
'@wasm-audio-decoders/common': 9.0.7
|
||||
optional: true
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
music-metadata@7.14.0:
|
||||
music-metadata@11.12.3:
|
||||
dependencies:
|
||||
'@borewit/text-codec': 0.2.2
|
||||
'@tokenizer/token': 0.3.0
|
||||
content-type: 1.0.5
|
||||
debug: 4.4.3
|
||||
file-type: 16.5.4
|
||||
file-type: 21.3.4
|
||||
media-typer: 1.1.0
|
||||
strtok3: 6.3.0
|
||||
token-types: 4.2.1
|
||||
strtok3: 10.3.5
|
||||
token-types: 6.1.2
|
||||
uint8array-extras: 1.5.0
|
||||
win-guid: 0.2.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -6064,7 +6017,8 @@ snapshots:
|
||||
dependencies:
|
||||
bplist-parser: 0.3.2
|
||||
|
||||
node-wav@0.0.2: {}
|
||||
node-wav@0.0.2:
|
||||
optional: true
|
||||
|
||||
nth-check@2.1.1:
|
||||
dependencies:
|
||||
@@ -6082,6 +6036,7 @@ 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:
|
||||
@@ -6109,6 +6064,7 @@ snapshots:
|
||||
opus-decoder@0.7.11:
|
||||
dependencies:
|
||||
'@wasm-audio-decoders/common': 9.0.7
|
||||
optional: true
|
||||
|
||||
p-finally@1.0.0: {}
|
||||
|
||||
@@ -6133,6 +6089,11 @@ 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
|
||||
@@ -6146,6 +6107,8 @@ snapshots:
|
||||
dependencies:
|
||||
p-finally: 1.0.0
|
||||
|
||||
p-timeout@7.0.1: {}
|
||||
|
||||
p-try@2.2.0: {}
|
||||
|
||||
parent-module@1.0.1:
|
||||
@@ -6169,8 +6132,6 @@ snapshots:
|
||||
|
||||
peberminta@0.9.0: {}
|
||||
|
||||
peek-readable@4.1.0: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@4.0.4: {}
|
||||
@@ -6228,10 +6189,6 @@ snapshots:
|
||||
|
||||
process-warning@5.0.0: {}
|
||||
|
||||
process@0.11.10: {}
|
||||
|
||||
promise-coalesce@1.5.0: {}
|
||||
|
||||
property-information@7.1.0: {}
|
||||
|
||||
protobufjs@6.8.8:
|
||||
@@ -6286,6 +6243,7 @@ snapshots:
|
||||
qoa-format@1.0.1:
|
||||
dependencies:
|
||||
'@thi.ng/bitstream': 2.4.46
|
||||
optional: true
|
||||
|
||||
qrcode-terminal@0.12.0:
|
||||
optional: true
|
||||
@@ -6331,18 +6289,6 @@ 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:
|
||||
@@ -6554,7 +6500,8 @@ snapshots:
|
||||
once: 1.4.0
|
||||
simple-concat: 1.0.1
|
||||
|
||||
simple-yenc@1.0.4: {}
|
||||
simple-yenc@1.0.4:
|
||||
optional: true
|
||||
|
||||
sisteransi@1.0.5: {}
|
||||
|
||||
@@ -6620,10 +6567,9 @@ snapshots:
|
||||
|
||||
strip-json-comments@3.1.1: {}
|
||||
|
||||
strtok3@6.3.0:
|
||||
strtok3@10.3.5:
|
||||
dependencies:
|
||||
'@tokenizer/token': 0.3.0
|
||||
peek-readable: 4.1.0
|
||||
|
||||
supports-color@7.2.0:
|
||||
dependencies:
|
||||
@@ -6670,8 +6616,9 @@ snapshots:
|
||||
|
||||
toidentifier@1.0.1: {}
|
||||
|
||||
token-types@4.2.1:
|
||||
token-types@6.1.2:
|
||||
dependencies:
|
||||
'@borewit/text-codec': 0.2.2
|
||||
'@tokenizer/token': 0.3.0
|
||||
ieee754: 1.2.1
|
||||
|
||||
@@ -6721,6 +6668,8 @@ snapshots:
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
uint8array-extras@1.5.0: {}
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
undici@6.24.1: {}
|
||||
@@ -6849,6 +6798,8 @@ snapshots:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
|
||||
win-guid@0.2.1: {}
|
||||
|
||||
word-wrap@1.2.5: {}
|
||||
|
||||
wrap-ansi@6.2.0:
|
||||
|
||||
@@ -16,7 +16,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-whatsapp/SKILL.md.
|
||||
BAILEYS_VERSION="@whiskeysockets/baileys@6.17.16"
|
||||
BAILEYS_VERSION="@whiskeysockets/baileys@7.0.0-rc.9"
|
||||
QRCODE_VERSION="qrcode@1.5.4"
|
||||
QRCODE_TYPES_VERSION="@types/qrcode@1.5.6"
|
||||
PINO_VERSION="pino@9.6.0"
|
||||
|
||||
@@ -66,7 +66,7 @@ if ! grep -q "'whatsapp-auth':" setup/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
||||
pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
+19
-25
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Step: whatsapp-auth — standalone WhatsApp (Baileys) authentication.
|
||||
* Step: whatsapp-auth — standalone WhatsApp (Baileys v7) authentication.
|
||||
*
|
||||
* Forked from the channels-branch version so setup:auto's driver can render
|
||||
* the terminal UX itself (inside clack) instead of the step dumping a raw QR
|
||||
@@ -27,7 +27,6 @@
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { createRequire } from 'module';
|
||||
// Named import (not default) — pino's d.ts under NodeNext resolves the
|
||||
// default export to `typeof pino` (namespace), which isn't callable. The
|
||||
// named `pino` export resolves to the callable function.
|
||||
@@ -47,26 +46,23 @@ const AUTH_DIR = path.join(process.cwd(), 'store', 'auth');
|
||||
const PAIRING_CODE_FILE = path.join(process.cwd(), 'store', 'pairing-code.txt');
|
||||
const baileysLogger = pino({ level: 'silent' });
|
||||
|
||||
// Baileys v6 bug: getPlatformId sends charCode (49) instead of enum value (1).
|
||||
// Fixed in Baileys 7.x but not backported. Without this patch pairing codes
|
||||
// fail with "couldn't link device" because WhatsApp receives an invalid
|
||||
// platform id. createRequire because proto is not a named ESM export.
|
||||
const _require = createRequire(import.meta.url);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { proto } = _require('@whiskeysockets/baileys') as { proto: any };
|
||||
try {
|
||||
const _generics = _require(
|
||||
'@whiskeysockets/baileys/lib/Utils/generics',
|
||||
) as Record<string, unknown>;
|
||||
_generics.getPlatformId = (browser: string): string => {
|
||||
const platformType =
|
||||
proto.DeviceProps.PlatformType[
|
||||
browser.toUpperCase() as keyof typeof proto.DeviceProps.PlatformType
|
||||
];
|
||||
return platformType ? platformType.toString() : '1';
|
||||
};
|
||||
} catch {
|
||||
// If CJS require fails, QR auth still works; only pairing code may be affected.
|
||||
/** Fetch current WA Web version — wppconnect tracker, then Baileys sw.js scrape. */
|
||||
async function resolveWaWebVersion(): Promise<[number, number, number]> {
|
||||
try {
|
||||
const res = await fetch('https://wppconnect.io/whatsapp-versions/', {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (res.ok) {
|
||||
const html = await res.text();
|
||||
const match = html.match(/2\.3000\.(\d+)/);
|
||||
if (match) return [2, 3000, Number(match[1])];
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
try {
|
||||
const { version } = await fetchLatestWaWebVersion({});
|
||||
if (version) return version as [number, number, number];
|
||||
} catch { /* fall through */ }
|
||||
throw new Error('Could not fetch current WhatsApp Web version — cannot connect with stale version');
|
||||
}
|
||||
|
||||
type AuthMethod = 'qr' | 'pairing-code';
|
||||
@@ -139,9 +135,7 @@ export async function run(args: string[]): Promise<void> {
|
||||
|
||||
async function connectSocket(isReconnect = false): Promise<void> {
|
||||
const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
|
||||
const { version } = await fetchLatestWaWebVersion({}).catch(() => ({
|
||||
version: undefined,
|
||||
}));
|
||||
const version = await resolveWaWebVersion();
|
||||
|
||||
const sock = makeWASocket({
|
||||
version,
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* DeltaChat channel adapter.
|
||||
*
|
||||
* Bridges NanoClaw with DeltaChat via the @deltachat/stdio-rpc-server JSON-RPC
|
||||
* process. Each DeltaChat chat becomes a separate NanoClaw messaging group
|
||||
* (platformId = chatId string, e.g. "12"). No thread model — supportsThreads: false.
|
||||
*
|
||||
* Required env vars (.env): DC_EMAIL, DC_PASSWORD,
|
||||
* DC_IMAP_HOST, DC_IMAP_PORT,
|
||||
* DC_SMTP_HOST, DC_SMTP_PORT
|
||||
* Optional env vars (.env): DC_IMAP_SECURITY (default: "1" = SSL/TLS),
|
||||
* DC_SMTP_SECURITY (default: "2" = STARTTLS)
|
||||
* Security values: 1=SSL/TLS, 2=STARTTLS, 3=plain
|
||||
* Optional env vars (service unit): DC_ACCOUNT_DIR (default: "dc-account"),
|
||||
* DC_DISPLAY_NAME, DC_AVATAR_PATH
|
||||
*/
|
||||
import { existsSync, mkdtempSync, writeFileSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { basename, join, resolve } from 'path';
|
||||
|
||||
import { getDb, hasTable } from '../db/connection.js';
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { log } from '../log.js';
|
||||
import type { ChannelAdapter, ChannelSetup, OutboundMessage } from './adapter.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
import { DeltaChatOverJsonRpc } from '@deltachat/stdio-rpc-server';
|
||||
|
||||
const REQUIRED_ENV = [
|
||||
'DC_EMAIL',
|
||||
'DC_PASSWORD',
|
||||
'DC_IMAP_HOST',
|
||||
'DC_IMAP_PORT',
|
||||
'DC_SMTP_HOST',
|
||||
'DC_SMTP_PORT',
|
||||
] as const;
|
||||
|
||||
const OPTIONAL_ENV = ['DC_IMAP_SECURITY', 'DC_SMTP_SECURITY'] as const;
|
||||
|
||||
type DcEnv = { [K in (typeof REQUIRED_ENV)[number]]: string } & { [K in (typeof OPTIONAL_ENV)[number]]?: string };
|
||||
|
||||
function isDcAdmin(userId: string): boolean {
|
||||
try {
|
||||
const db = getDb();
|
||||
if (!hasTable(db, 'user_roles')) return true;
|
||||
return (
|
||||
db
|
||||
.prepare(
|
||||
`SELECT 1 FROM user_roles
|
||||
WHERE user_id = ?
|
||||
AND (role = 'owner' OR role = 'admin')
|
||||
AND agent_group_id IS NULL
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(userId) != null
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createAdapter(env: DcEnv): ChannelAdapter {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let dc: any = null;
|
||||
let accountId = 0;
|
||||
let connectivity = 0;
|
||||
let lastImapIdleTs = Date.now();
|
||||
let consecutiveBadChecks = 0;
|
||||
let watchdogTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let networkTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async function restartIo(reason: string): Promise<void> {
|
||||
log.warn('DeltaChat: restarting IO', { reason });
|
||||
try {
|
||||
await dc.rpc.stopIo(accountId);
|
||||
await dc.rpc.startIo(accountId);
|
||||
lastImapIdleTs = Date.now();
|
||||
consecutiveBadChecks = 0;
|
||||
} catch (err) {
|
||||
log.error('DeltaChat: IO restart failed', { err });
|
||||
}
|
||||
}
|
||||
|
||||
const adapter: ChannelAdapter = {
|
||||
name: 'deltachat',
|
||||
channelType: 'deltachat',
|
||||
supportsThreads: false,
|
||||
|
||||
async setup(config: ChannelSetup): Promise<void> {
|
||||
const accountDir = process.env.DC_ACCOUNT_DIR ?? 'dc-account';
|
||||
dc = new DeltaChatOverJsonRpc(accountDir, {});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
dc.on('Error', (_: any, event: any) => log.error('DeltaChat RPC error', { msg: event.msg ?? event }));
|
||||
|
||||
const accounts = await dc.rpc.getAllAccounts();
|
||||
accountId = accounts[0]?.id;
|
||||
if (!accountId) accountId = await dc.rpc.addAccount();
|
||||
|
||||
const imapSecurity = env.DC_IMAP_SECURITY ?? '1';
|
||||
const smtpSecurity = env.DC_SMTP_SECURITY ?? '2';
|
||||
|
||||
if (!(await dc.rpc.isConfigured(accountId))) {
|
||||
await dc.rpc.setConfig(accountId, 'addr', env.DC_EMAIL);
|
||||
await dc.rpc.setConfig(accountId, 'mail_pw', env.DC_PASSWORD);
|
||||
await dc.rpc.setConfig(accountId, 'mail_server', env.DC_IMAP_HOST);
|
||||
await dc.rpc.setConfig(accountId, 'mail_port', env.DC_IMAP_PORT);
|
||||
await dc.rpc.setConfig(accountId, 'send_server', env.DC_SMTP_HOST);
|
||||
await dc.rpc.setConfig(accountId, 'send_port', env.DC_SMTP_PORT);
|
||||
await dc.rpc.configure(accountId);
|
||||
log.info('DeltaChat: account configured', { email: env.DC_EMAIL });
|
||||
} else {
|
||||
log.info('DeltaChat: account ready', { email: env.DC_EMAIL });
|
||||
}
|
||||
|
||||
await dc.rpc.setConfig(accountId, 'mail_security', imapSecurity);
|
||||
await dc.rpc.setConfig(accountId, 'send_security', smtpSecurity);
|
||||
await dc.rpc.setConfig(accountId, 'displayname', process.env.DC_DISPLAY_NAME ?? 'NanoClaw');
|
||||
const avatarPath = process.env.DC_AVATAR_PATH;
|
||||
if (avatarPath && existsSync(avatarPath)) {
|
||||
await dc.rpc.setConfig(accountId, 'selfavatar', avatarPath);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
dc.on('IncomingMsg', async (contextId: number, event: any) => {
|
||||
if (contextId !== accountId) return;
|
||||
try {
|
||||
let msg = await dc.rpc.getMessage(accountId, event.msgId);
|
||||
if (msg.isInfo) return;
|
||||
|
||||
// Wait for large-message download to complete
|
||||
if (msg.downloadState !== 'Done') {
|
||||
await dc.rpc.downloadFullMessage(accountId, event.msgId);
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
msg = await dc.rpc.getMessage(accountId, event.msgId);
|
||||
if (msg.downloadState === 'Done') break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!msg.text && !msg.file) return;
|
||||
|
||||
const contact = await dc.rpc.getContact(accountId, msg.fromId);
|
||||
const chat = await dc.rpc.getBasicChatInfo(accountId, event.chatId);
|
||||
|
||||
if (/^\/set-avatar$/i.test((msg.text || '').trim()) && msg.file) {
|
||||
const userId = `deltachat:${contact.address}`;
|
||||
try {
|
||||
if (isDcAdmin(userId)) {
|
||||
const absPath = resolve(msg.file as string);
|
||||
await dc.rpc.setConfig(accountId, 'selfavatar', absPath);
|
||||
await dc.rpc.sendMsg(accountId, event.chatId, { text: 'Avatar updated.' });
|
||||
} else {
|
||||
await dc.rpc.sendMsg(accountId, event.chatId, { text: 'Permission denied.' });
|
||||
}
|
||||
} catch (avatarErr: unknown) {
|
||||
log.error('DeltaChat: failed to set avatar', {
|
||||
err: avatarErr instanceof Error ? avatarErr.message : JSON.stringify(avatarErr),
|
||||
});
|
||||
await dc.rpc.sendMsg(accountId, event.chatId, { text: 'Failed to set avatar.' }).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const content: Record<string, unknown> = {
|
||||
text: msg.text || '',
|
||||
sender: contact.displayName || contact.address,
|
||||
senderId: contact.address,
|
||||
};
|
||||
if (msg.file) {
|
||||
content.attachments = [
|
||||
{
|
||||
name: basename(msg.file as string),
|
||||
type: 'file',
|
||||
localPath: msg.file,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const isGroup = chat?.isGroup ?? false;
|
||||
await config.onInbound(String(event.chatId), null, {
|
||||
id: String(event.msgId),
|
||||
kind: 'chat',
|
||||
content,
|
||||
timestamp: new Date().toISOString(),
|
||||
isGroup,
|
||||
isMention: !isGroup,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
log.error('DeltaChat: error handling incoming message', {
|
||||
err: err instanceof Error ? err.message : JSON.stringify(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
dc.on('ImapInboxIdle', (contextId: number) => {
|
||||
if (contextId === accountId) lastImapIdleTs = Date.now();
|
||||
});
|
||||
|
||||
dc.on('ConnectivityChanged', async (contextId: number) => {
|
||||
if (contextId !== accountId) return;
|
||||
try {
|
||||
connectivity = await dc.rpc.getConnectivity(accountId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
await dc.rpc.startIo(accountId);
|
||||
try {
|
||||
connectivity = await dc.rpc.getConnectivity(accountId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
log.info('DeltaChat: IO started', { email: env.DC_EMAIL });
|
||||
|
||||
// Log invite link on every startup so the operator can bootstrap the first contact.
|
||||
// In DeltaChat, contacts can't simply be added by email — the user must open this
|
||||
// https://i.delta.chat/ invite URL in their DeltaChat app (or scan invite-qr.svg) to initiate contact.
|
||||
try {
|
||||
// null chatId → Setup-Contact invite (not group-specific)
|
||||
const [inviteUrl, svg] = await dc.rpc.getChatSecurejoinQrCodeSvg(accountId, null);
|
||||
const accountDir = resolve(process.env.DC_ACCOUNT_DIR ?? 'dc-account');
|
||||
const svgPath = join(accountDir, 'invite-qr.svg');
|
||||
writeFileSync(svgPath, svg);
|
||||
log.info('DeltaChat: invite link — open URL in DeltaChat app or scan ' + svgPath, { url: inviteUrl });
|
||||
} catch (err: unknown) {
|
||||
log.warn('DeltaChat: could not generate invite link', {
|
||||
err: err instanceof Error ? err.message : JSON.stringify(err),
|
||||
});
|
||||
}
|
||||
|
||||
// Connectivity watchdog: restart IO if IMAP goes quiet or connectivity drops
|
||||
watchdogTimer = setInterval(
|
||||
async () => {
|
||||
try {
|
||||
const conn = await dc.rpc.getConnectivity(accountId);
|
||||
connectivity = conn;
|
||||
if (conn < 3000) {
|
||||
consecutiveBadChecks++;
|
||||
if (consecutiveBadChecks >= 2) {
|
||||
await restartIo(`connectivity=${conn} for 2 consecutive checks`);
|
||||
}
|
||||
} else {
|
||||
consecutiveBadChecks = 0;
|
||||
}
|
||||
const idleAgeMin = (Date.now() - lastImapIdleTs) / 60000;
|
||||
if (idleAgeMin > 20) {
|
||||
await restartIo(`no IMAP IDLE in ${idleAgeMin.toFixed(0)}min`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
log.warn('DeltaChat: watchdog error', {
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
|
||||
// Nudge the network stack every 10 minutes (recovers from prolonged idle)
|
||||
networkTimer = setInterval(
|
||||
async () => {
|
||||
try {
|
||||
await dc.rpc.maybeNetwork();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
10 * 60 * 1000,
|
||||
);
|
||||
},
|
||||
|
||||
async teardown(): Promise<void> {
|
||||
if (watchdogTimer) clearInterval(watchdogTimer);
|
||||
if (networkTimer) clearInterval(networkTimer);
|
||||
try {
|
||||
await dc?.rpc.stopIo(accountId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
dc?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
|
||||
isConnected(): boolean {
|
||||
// 4000 = fully connected (IMAP), 3000 = connecting; treat ≥3000 as live
|
||||
return connectivity >= 3000;
|
||||
},
|
||||
|
||||
async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise<string | undefined> {
|
||||
const chatId = parseInt(platformId, 10);
|
||||
if (isNaN(chatId)) {
|
||||
log.warn('DeltaChat: invalid platformId for delivery', { platformId });
|
||||
return undefined;
|
||||
}
|
||||
const content = message.content as Record<string, unknown>;
|
||||
const text = typeof content.text === 'string' ? content.text : '';
|
||||
|
||||
if (message.files && message.files.length > 0) {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'nanoclaw-dc-'));
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let firstId: any;
|
||||
for (let i = 0; i < message.files.length; i++) {
|
||||
const f = message.files[i];
|
||||
const tempPath = join(tempDir, f.filename);
|
||||
writeFileSync(tempPath, f.data);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const params: any = { file: tempPath };
|
||||
if (i === 0 && text) params.text = text;
|
||||
const sentId = await dc.rpc.sendMsg(accountId, chatId, params);
|
||||
if (i === 0) firstId = sentId;
|
||||
}
|
||||
return firstId != null ? String(firstId) : undefined;
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (!text) return undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sentId: any = await dc.rpc.sendMsg(accountId, chatId, { text });
|
||||
return sentId != null ? String(sentId) : undefined;
|
||||
},
|
||||
};
|
||||
|
||||
return adapter;
|
||||
}
|
||||
|
||||
registerChannelAdapter('deltachat', {
|
||||
factory: () => {
|
||||
const env = readEnvFile([...REQUIRED_ENV, ...OPTIONAL_ENV]);
|
||||
if (!env.DC_EMAIL || !env.DC_PASSWORD) return null;
|
||||
return createAdapter(env as DcEnv);
|
||||
},
|
||||
});
|
||||
@@ -55,3 +55,6 @@ import './whatsapp.js';
|
||||
|
||||
// emacs (native HTTP bridge, no Chat SDK)
|
||||
// import './emacs.js';
|
||||
|
||||
// deltachat (native, no Chat SDK)
|
||||
// import './deltachat.js'
|
||||
|
||||
+135
-28
@@ -548,6 +548,141 @@ describe('SignalAdapter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- Outbound attachments ---
|
||||
|
||||
describe('deliver — attachments', () => {
|
||||
// Real fs writes happen in tmpdir(); confirm the bytes round-trip and
|
||||
// are cleaned up after deliver returns.
|
||||
it('sends a single attachment via attachments[] param', async () => {
|
||||
const fs = await import('node:fs');
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
tcpRef.fakeSocket.write.mockClear();
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'file',
|
||||
content: {},
|
||||
files: [{ filename: 'report.md', data: Buffer.from('# Report\n\nbody') }],
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
expect(sendCalls.length).toBe(1);
|
||||
const params = sendCalls[0].params as Record<string, unknown>;
|
||||
expect(params.recipient).toEqual(['+15555550123']);
|
||||
expect(params.account).toBe('+15551234567');
|
||||
expect(params.message).toBeUndefined();
|
||||
const paths = params.attachments as string[];
|
||||
expect(paths).toHaveLength(1);
|
||||
expect(paths[0]).toMatch(/signal-out-\d+-[a-z0-9]+-report\.md$/);
|
||||
// Temp file should no longer exist — finally{} cleanup ran
|
||||
expect(fs.existsSync(paths[0])).toBe(false);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('sends text first, then attachment, when both are present', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
tcpRef.fakeSocket.write.mockClear();
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'file',
|
||||
content: { text: 'Here is the digest' },
|
||||
files: [{ filename: 'digest.md', data: Buffer.from('content') }],
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
expect(sendCalls).toHaveLength(2);
|
||||
// First call: text message
|
||||
expect(sendCalls[0].params).toEqual(
|
||||
expect.objectContaining({ message: 'Here is the digest', recipient: ['+15555550123'] }),
|
||||
);
|
||||
expect((sendCalls[0].params as Record<string, unknown>).attachments).toBeUndefined();
|
||||
// Second call: attachment, no message
|
||||
expect(sendCalls[1].params).toEqual(
|
||||
expect.objectContaining({ recipient: ['+15555550123'] }),
|
||||
);
|
||||
const attachments = (sendCalls[1].params as Record<string, unknown>).attachments as string[];
|
||||
expect(attachments).toHaveLength(1);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('sends multiple attachments in a single send call', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
tcpRef.fakeSocket.write.mockClear();
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'file',
|
||||
content: {},
|
||||
files: [
|
||||
{ filename: 'a.txt', data: Buffer.from('a') },
|
||||
{ filename: 'b.png', data: Buffer.from([0x89, 0x50, 0x4e, 0x47]) },
|
||||
],
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
expect(sendCalls).toHaveLength(1);
|
||||
const attachments = (sendCalls[0].params as Record<string, unknown>).attachments as string[];
|
||||
expect(attachments).toHaveLength(2);
|
||||
expect(attachments[0]).toMatch(/-a\.txt$/);
|
||||
expect(attachments[1]).toMatch(/-b\.png$/);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
it('uses groupId for group destinations', async () => {
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
tcpRef.fakeSocket.write.mockClear();
|
||||
|
||||
await adapter.deliver('group:abc123', null, {
|
||||
kind: 'file',
|
||||
content: {},
|
||||
files: [{ filename: 'pic.jpg', data: Buffer.from('jpg') }],
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
expect(sendCalls).toHaveLength(1);
|
||||
const params = sendCalls[0].params as Record<string, unknown>;
|
||||
expect(params.groupId).toBe('abc123');
|
||||
expect(params.recipient).toBeUndefined();
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
|
||||
/**
|
||||
* Defensive test: `OutboundFile.filename` is operator-supplied data, so
|
||||
* the implementation must not let a filename containing path separators
|
||||
* escape the temp directory. We feed an attempt-to-traverse filename and
|
||||
* assert the resolved path stays strictly inside `tmpdir()`.
|
||||
*/
|
||||
it('keeps temp paths inside tmpdir even when filename contains path separators', async () => {
|
||||
const path = await import('node:path');
|
||||
const os = await import('node:os');
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
tcpRef.fakeSocket.write.mockClear();
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'file',
|
||||
content: {},
|
||||
files: [{ filename: '../sneaky.txt', data: Buffer.from('x') }],
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
const paths = (sendCalls[0].params as Record<string, unknown>).attachments as string[];
|
||||
const resolvedTmp = path.resolve(os.tmpdir());
|
||||
const resolvedResult = path.resolve(paths[0]);
|
||||
// path.resolve normalizes away any "../"; if sanitization failed, the
|
||||
// result would resolve to tmpdir's parent.
|
||||
expect(resolvedResult.startsWith(resolvedTmp + path.sep)).toBe(true);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Text styles ---
|
||||
|
||||
describe('text styles', () => {
|
||||
@@ -784,34 +919,6 @@ describe('SignalAdapter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- Outbound files ---
|
||||
|
||||
describe('outbound files', () => {
|
||||
it('logs a warning and drops unsupported file attachments', async () => {
|
||||
const { log } = await import('../log.js');
|
||||
const warnMock = log.warn as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
const adapter = createAdapter();
|
||||
await adapter.setup(createMockSetup());
|
||||
warnMock.mockClear();
|
||||
|
||||
await adapter.deliver('+15555550123', null, {
|
||||
kind: 'text',
|
||||
content: { text: 'with an attachment' },
|
||||
files: [{ filename: 'hi.txt', data: Buffer.from('hi') }],
|
||||
});
|
||||
|
||||
const sendCalls = getRpcCallsForMethod('send');
|
||||
expect(sendCalls.length).toBeGreaterThan(0);
|
||||
expect(warnMock).toHaveBeenCalledWith(
|
||||
'Signal: outbound files not supported, dropping',
|
||||
expect.objectContaining({ platformId: '+15555550123', count: 1 }),
|
||||
);
|
||||
|
||||
await adapter.teardown();
|
||||
});
|
||||
});
|
||||
|
||||
// --- setTyping ---
|
||||
|
||||
describe('setTyping', () => {
|
||||
|
||||
+54
-15
@@ -8,9 +8,9 @@
|
||||
* Ported from v1 — see v1 source for commit history.
|
||||
*/
|
||||
import { execFileSync, execSync, spawn } from 'node:child_process';
|
||||
import { existsSync, readFileSync, unlinkSync } from 'node:fs';
|
||||
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { createConnection, type Socket } from 'node:net';
|
||||
import { homedir } from 'node:os';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type { ChannelAdapter, ChannelSetup, InboundMessage, OutboundMessage } from './adapter.js';
|
||||
@@ -744,6 +744,51 @@ export function createSignalAdapter(config: {
|
||||
log.info('Signal message sent', { platformId, length: text.length });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one or more file attachments via signal-cli's `send` JSON-RPC, which
|
||||
* accepts an `attachments` array of host filesystem paths. The OutboundFile
|
||||
* Buffer is materialized to an OS temp file so signal-cli can read it, then
|
||||
* removed in the finally block.
|
||||
*
|
||||
* Caption text, if any, is sent first via `sendText` (which handles chunking
|
||||
* + textStyles) — keeps this function single-purpose and avoids a long
|
||||
* caption colliding with signal-cli's per-message size limits.
|
||||
*/
|
||||
async function sendAttachments(platformId: string, files: { filename: string; data: Buffer }[]): Promise<void> {
|
||||
if (!connected || !tcp) return;
|
||||
if (files.length === 0) return;
|
||||
|
||||
const tempPaths: string[] = [];
|
||||
for (const file of files) {
|
||||
const safeName = file.filename.replace(/[/\\\0]/g, '_');
|
||||
const tempPath = join(tmpdir(), `signal-out-${Date.now()}-${Math.random().toString(36).slice(2, 8)}-${safeName}`);
|
||||
writeFileSync(tempPath, file.data);
|
||||
tempPaths.push(tempPath);
|
||||
}
|
||||
|
||||
try {
|
||||
const params: Record<string, unknown> = { attachments: tempPaths };
|
||||
if (config.account) params.account = config.account;
|
||||
if (platformId.startsWith('group:')) {
|
||||
params.groupId = platformId.slice('group:'.length);
|
||||
} else {
|
||||
params.recipient = [platformId];
|
||||
}
|
||||
await tcp.rpc('send', params);
|
||||
log.info('Signal attachments sent', { platformId, count: files.length, filenames: files.map((f) => f.filename) });
|
||||
} catch (err) {
|
||||
log.error('Signal: attachment send failed', { platformId, count: files.length, err });
|
||||
} finally {
|
||||
for (const p of tempPaths) {
|
||||
try {
|
||||
unlinkSync(p);
|
||||
} catch {
|
||||
/* best-effort cleanup */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForDaemon(): Promise<boolean> {
|
||||
const maxWait = 30_000;
|
||||
const pollInterval = 1000;
|
||||
@@ -847,17 +892,6 @@ export function createSignalAdapter(config: {
|
||||
},
|
||||
|
||||
async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise<string | undefined> {
|
||||
if (message.files && message.files.length > 0) {
|
||||
// Native adapter doesn't yet forward file uploads to signal-cli's
|
||||
// `send --attachment`. Don't silently swallow — operators need to see
|
||||
// that an attachment was requested but not sent.
|
||||
log.warn('Signal: outbound files not supported, dropping', {
|
||||
platformId,
|
||||
count: message.files.length,
|
||||
filenames: message.files.map((f) => f.filename),
|
||||
});
|
||||
}
|
||||
|
||||
const content = message.content as Record<string, unknown> | string | undefined;
|
||||
let text: string | null = null;
|
||||
if (typeof content === 'string') {
|
||||
@@ -865,9 +899,14 @@ export function createSignalAdapter(config: {
|
||||
} else if (content && typeof content === 'object' && typeof content.text === 'string') {
|
||||
text = content.text;
|
||||
}
|
||||
if (!text) return undefined;
|
||||
|
||||
await sendText(platformId, text);
|
||||
const files = message.files ?? [];
|
||||
|
||||
// Send accompanying text first so it lands above the attachment(s) in
|
||||
// the recipient's chat. Both branches no-op cleanly if their input is
|
||||
// empty, so any combination of (text, files) works.
|
||||
if (text) await sendText(platformId, text);
|
||||
if (files.length > 0) await sendAttachments(platformId, files);
|
||||
return undefined;
|
||||
},
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ async function sendPairingConfirmation(token: string, platformId: string): Promi
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text: "Pairing success! I'm spinning up the agent now, you'll get a message from them shortly.",
|
||||
text: 'Pairing success! Head back to the NanoClaw installer to finish setup.',
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -210,6 +210,7 @@ registerChannelAdapter('telegram', {
|
||||
extractReplyContext,
|
||||
supportsThreads: false,
|
||||
transformOutboundText: sanitizeTelegramLegacyMarkdown,
|
||||
maxTextLength: 4000,
|
||||
});
|
||||
|
||||
const botUsernamePromise = fetchBotUsername(token);
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Regression coverage for #2560 — group @-mentions of the bot must set
|
||||
* `InboundMessage.isMention`. Before the fix, the inbound construction
|
||||
* site hard-coded `isMention: !isGroup ? true : undefined`, which dropped
|
||||
* every group mention on the floor and prevented the router from waking
|
||||
* the agent on a mention-only trigger.
|
||||
*
|
||||
* The detection logic lives in the exported pure helper `isBotMentionedInGroup`;
|
||||
* the inbound site calls it with `normalized`, `botPhoneJid`, `botLidUser`.
|
||||
* `isMention` is then computed as:
|
||||
*
|
||||
* isMention: !isGroup ? true : botMentionedInGroup ? true : undefined
|
||||
*
|
||||
* Both the helper and the call-site ternary are covered below so a future
|
||||
* refactor that breaks either part fails this suite.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { computeIsMention, isBotMentionedInGroup, parseWhatsAppMentions } from './whatsapp.js';
|
||||
|
||||
const BOT_PHONE_JID = '15550009999@s.whatsapp.net';
|
||||
const BOT_LID_USER = '987654321';
|
||||
|
||||
describe('isBotMentionedInGroup (#2560)', () => {
|
||||
it('detects the bot phone JID in extendedTextMessage.contextInfo.mentionedJid', () => {
|
||||
const normalized = {
|
||||
extendedTextMessage: {
|
||||
text: 'hey @15550009999 take a look',
|
||||
contextInfo: { mentionedJid: [BOT_PHONE_JID] },
|
||||
},
|
||||
};
|
||||
expect(isBotMentionedInGroup(normalized, BOT_PHONE_JID, BOT_LID_USER)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when the bot is not in mentionedJid', () => {
|
||||
const normalized = {
|
||||
extendedTextMessage: {
|
||||
text: 'hey @15551112222 take a look',
|
||||
contextInfo: { mentionedJid: ['15551112222@s.whatsapp.net'] },
|
||||
},
|
||||
};
|
||||
expect(isBotMentionedInGroup(normalized, BOT_PHONE_JID, BOT_LID_USER)).toBe(false);
|
||||
});
|
||||
|
||||
it('detects an LID-only mention when no phone JID is in the list', () => {
|
||||
// Modern WhatsApp clients increasingly emit the LID even when the
|
||||
// human typed a phone-number mention; the phone JID may not appear.
|
||||
const normalized = {
|
||||
extendedTextMessage: {
|
||||
contextInfo: { mentionedJid: [`${BOT_LID_USER}@lid`] },
|
||||
},
|
||||
};
|
||||
expect(isBotMentionedInGroup(normalized, BOT_PHONE_JID, BOT_LID_USER)).toBe(true);
|
||||
});
|
||||
|
||||
it('detects a mention in an image caption', () => {
|
||||
const normalized = {
|
||||
imageMessage: {
|
||||
caption: 'check this @15550009999',
|
||||
contextInfo: { mentionedJid: [BOT_PHONE_JID] },
|
||||
},
|
||||
};
|
||||
expect(isBotMentionedInGroup(normalized, BOT_PHONE_JID, BOT_LID_USER)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false on an empty / missing mentionedJid array', () => {
|
||||
expect(isBotMentionedInGroup({}, BOT_PHONE_JID, BOT_LID_USER)).toBe(false);
|
||||
expect(
|
||||
isBotMentionedInGroup(
|
||||
{ extendedTextMessage: { contextInfo: { mentionedJid: [] } } },
|
||||
BOT_PHONE_JID,
|
||||
BOT_LID_USER,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when neither bot identifier is known', () => {
|
||||
const normalized = {
|
||||
extendedTextMessage: {
|
||||
contextInfo: { mentionedJid: [BOT_PHONE_JID, `${BOT_LID_USER}@lid`] },
|
||||
},
|
||||
};
|
||||
expect(isBotMentionedInGroup(normalized, undefined, undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('InboundMessage.isMention semantics (#2560)', () => {
|
||||
it('is undefined for a group message with no bot mention', () => {
|
||||
expect(computeIsMention(true, false)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is true for a group message where the bot is mentioned', () => {
|
||||
expect(computeIsMention(true, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('is true for a DM regardless of mention state', () => {
|
||||
// DMs are unconditionally mentions — the helper isn't consulted there.
|
||||
expect(computeIsMention(false, false)).toBe(true);
|
||||
expect(computeIsMention(false, true)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseWhatsAppMentions', () => {
|
||||
it('returns empty mentions for plain text', () => {
|
||||
const { text, mentions } = parseWhatsAppMentions('hello there');
|
||||
expect(text).toBe('hello there');
|
||||
expect(mentions).toEqual([]);
|
||||
});
|
||||
|
||||
it('extracts a single @<digits> mention into a JID', () => {
|
||||
const { text, mentions } = parseWhatsAppMentions('hey @15551234567 you around?');
|
||||
expect(text).toBe('hey @15551234567 you around?');
|
||||
expect(mentions).toEqual(['15551234567@s.whatsapp.net']);
|
||||
});
|
||||
|
||||
it('strips a leading + so the literal text matches the JID digits', () => {
|
||||
const { text, mentions } = parseWhatsAppMentions('ping @+15551234567 please');
|
||||
expect(text).toBe('ping @15551234567 please');
|
||||
expect(mentions).toEqual(['15551234567@s.whatsapp.net']);
|
||||
});
|
||||
|
||||
it('matches a mention at the start of the string', () => {
|
||||
const { text, mentions } = parseWhatsAppMentions('@15551234567 hi');
|
||||
expect(text).toBe('@15551234567 hi');
|
||||
expect(mentions).toEqual(['15551234567@s.whatsapp.net']);
|
||||
});
|
||||
|
||||
it('extracts multiple distinct mentions', () => {
|
||||
const { text, mentions } = parseWhatsAppMentions('cc @15551234567 and @17775556666');
|
||||
expect(text).toBe('cc @15551234567 and @17775556666');
|
||||
expect(mentions).toEqual(['15551234567@s.whatsapp.net', '17775556666@s.whatsapp.net']);
|
||||
});
|
||||
|
||||
it('deduplicates repeated mentions of the same number', () => {
|
||||
const { mentions } = parseWhatsAppMentions('@15551234567 ping @15551234567 again');
|
||||
expect(mentions).toEqual(['15551234567@s.whatsapp.net']);
|
||||
});
|
||||
|
||||
it('does not tag email-like patterns', () => {
|
||||
const { text, mentions } = parseWhatsAppMentions('write to test@1234567890.com');
|
||||
expect(text).toBe('write to test@1234567890.com');
|
||||
expect(mentions).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not tag sequences shorter than 5 digits', () => {
|
||||
const { text, mentions } = parseWhatsAppMentions('see issue @123 for details');
|
||||
expect(text).toBe('see issue @123 for details');
|
||||
expect(mentions).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles punctuation directly after the digits', () => {
|
||||
const { text, mentions } = parseWhatsAppMentions('thanks @15551234567!');
|
||||
expect(text).toBe('thanks @15551234567!');
|
||||
expect(mentions).toEqual(['15551234567@s.whatsapp.net']);
|
||||
});
|
||||
|
||||
it('handles parenthesized mentions', () => {
|
||||
const { text, mentions } = parseWhatsAppMentions('(@15551234567) wrote this');
|
||||
expect(text).toBe('(@15551234567) wrote this');
|
||||
expect(mentions).toEqual(['15551234567@s.whatsapp.net']);
|
||||
});
|
||||
});
|
||||
+242
-67
@@ -1,10 +1,15 @@
|
||||
/**
|
||||
* WhatsApp channel adapter (v2) — native Baileys v6 implementation.
|
||||
* WhatsApp channel adapter (v2) — native Baileys v7 implementation.
|
||||
*
|
||||
* Implements ChannelAdapter directly (no Chat SDK bridge) using
|
||||
* @whiskeysockets/baileys v6 (stable). Ports proven v1 infrastructure:
|
||||
* getMessage fallback, outgoing queue, group metadata cache, LID mapping,
|
||||
* reconnection with backoff.
|
||||
* @whiskeysockets/baileys 7.0.0-rc.9 (pinned — last release, unmaintained).
|
||||
* Ports proven v1 infrastructure: getMessage fallback, outgoing queue,
|
||||
* group metadata cache, LID mapping, reconnection with backoff.
|
||||
*
|
||||
* LID handling: Baileys v7 provides participantAlt / remoteJidAlt on every
|
||||
* inbound message via extractAddressingContext, plus a real
|
||||
* signalRepository.lidMapping.getPNForLID API. The adapter always resolves
|
||||
* to phone JID (@s.whatsapp.net) before emitting to the router.
|
||||
*
|
||||
* Auth credentials persist in store/auth/. On first run:
|
||||
* - If WHATSAPP_PHONE_NUMBER is set → pairing code (printed to log)
|
||||
@@ -22,6 +27,7 @@ import { pino } from 'pino';
|
||||
|
||||
import {
|
||||
makeWASocket,
|
||||
proto,
|
||||
Browsers,
|
||||
DisconnectReason,
|
||||
fetchLatestWaWebVersion,
|
||||
@@ -40,29 +46,54 @@ import { registerChannelAdapter } from './channel-registry.js';
|
||||
import { normalizeOptions, type NormalizedOption } from './ask-question.js';
|
||||
import type { ChannelAdapter, ChannelSetup, ConversationInfo, InboundMessage, OutboundMessage } from './adapter.js';
|
||||
|
||||
// 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' });
|
||||
|
||||
/**
|
||||
* Fetch the latest WhatsApp Web version. Baileys' built-in
|
||||
* fetchLatestWaWebVersion scrapes sw.js which is aggressively
|
||||
* rate-limited (429). When it fails, Baileys falls back to a
|
||||
* hardcoded version that goes stale within weeks — WhatsApp
|
||||
* rejects connections with an expired buildHash (405 at Noise
|
||||
* layer). This fetches from wppconnect's version tracker as a
|
||||
* more reliable source, with Baileys' own fetch as fallback.
|
||||
*/
|
||||
async function resolveWaWebVersion(): Promise<[number, number, number]> {
|
||||
// 1. Try wppconnect version tracker (HTML scrape — no JSON API)
|
||||
try {
|
||||
const res = await fetch('https://wppconnect.io/whatsapp-versions/', {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (res.ok) {
|
||||
const html = await res.text();
|
||||
const match = html.match(/2\.3000\.(\d+)/);
|
||||
if (match) {
|
||||
const version: [number, number, number] = [2, 3000, Number(match[1])];
|
||||
log.info('Fetched WA Web version from wppconnect', { version });
|
||||
return version;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall through to Baileys' own fetch
|
||||
}
|
||||
|
||||
// 2. Try Baileys' built-in fetch (scrapes sw.js — often 429'd)
|
||||
try {
|
||||
const { version } = await fetchLatestWaWebVersion({});
|
||||
if (version) {
|
||||
log.info('Fetched WA Web version from Baileys', { version });
|
||||
return version as [number, number, number];
|
||||
}
|
||||
} catch {
|
||||
// Fall through
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Could not fetch current WhatsApp Web version from any source. ' +
|
||||
'Baileys hardcodes a stale version that WhatsApp rejects (405). ' +
|
||||
'Check network connectivity to wppconnect.io and web.whatsapp.com.',
|
||||
);
|
||||
}
|
||||
|
||||
const AUTH_DIR = path.join(process.cwd(), 'store', 'auth');
|
||||
const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h
|
||||
const GROUP_METADATA_CACHE_TTL_MS = 60_000; // 1 min for outbound sends
|
||||
@@ -120,10 +151,99 @@ function transformForWhatsApp(text: string): string {
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Convert Claude's markdown to WhatsApp-native formatting. */
|
||||
function formatWhatsApp(text: string): string {
|
||||
// WhatsApp tags `@<phone-digits>` (5–15 digit local part — covers short test
|
||||
// numbers up to ITU E.164 max). A leading `+` is accepted but stripped so
|
||||
// the literal in text matches the digits in the JID — WhatsApp clients
|
||||
// scan the rendered text for `@<digits>` and cross-reference it with the
|
||||
// contextInfo.mentionedJid list to draw the bold/clickable tag.
|
||||
const MENTION_RE = /(^|[^\w@+])@\+?(\d{5,15})(?!\d)/g;
|
||||
|
||||
/** Extract `@<digits>` mentions from text and normalize them. */
|
||||
export function parseWhatsAppMentions(text: string): { text: string; mentions: string[] } {
|
||||
const mentions = new Set<string>();
|
||||
const out = text.replace(MENTION_RE, (_full, lead: string, digits: string) => {
|
||||
mentions.add(`${digits}@s.whatsapp.net`);
|
||||
return `${lead}@${digits}`;
|
||||
});
|
||||
return { text: out, mentions: [...mentions] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Claude's markdown to WhatsApp-native formatting and extract any
|
||||
* `@<phone>` mentions. Code-block regions are passed through untouched so
|
||||
* phone-like sequences inside code aren't tagged.
|
||||
*/
|
||||
function formatWhatsApp(text: string): { text: string; mentions: string[] } {
|
||||
const segments = splitProtectedRegions(text);
|
||||
return segments.map(({ content, isProtected }) => (isProtected ? content : transformForWhatsApp(content))).join('');
|
||||
const mentions = new Set<string>();
|
||||
const out = segments
|
||||
.map(({ content, isProtected }) => {
|
||||
if (isProtected) return content;
|
||||
const transformed = transformForWhatsApp(content);
|
||||
const { text: withMentions, mentions: found } = parseWhatsAppMentions(transformed);
|
||||
for (const m of found) mentions.add(m);
|
||||
return withMentions;
|
||||
})
|
||||
.join('');
|
||||
return { text: out, mentions: [...mentions] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Subset of a normalized Baileys message content carrying the message
|
||||
* types that can host a `contextInfo.mentionedJid` array. Kept as a
|
||||
* structural type so the helper (and its tests) don't pull in the full
|
||||
* `proto.IMessage` shape just to construct fixtures.
|
||||
*/
|
||||
type MentionContextSource = {
|
||||
extendedTextMessage?: { contextInfo?: { mentionedJid?: string[] | null } | null } | null;
|
||||
imageMessage?: { contextInfo?: { mentionedJid?: string[] | null } | null } | null;
|
||||
videoMessage?: { contextInfo?: { mentionedJid?: string[] | null } | null } | null;
|
||||
documentMessage?: { contextInfo?: { mentionedJid?: string[] | null } | null } | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect an explicit @-mention of the bot in a WhatsApp group message.
|
||||
* WhatsApp carries mentions in `contextInfo.mentionedJid` on the text +
|
||||
* caption-bearing message types. Matches against both the bot's phone
|
||||
* JID and LID — most modern clients emit the LID even when the human
|
||||
* typed a phone-number mention.
|
||||
*
|
||||
* Exported for unit testing. The inbound construction site calls this
|
||||
* to set `InboundMessage.isMention` for group messages (#2560). DMs are
|
||||
* unconditionally mentions and don't go through this helper.
|
||||
*/
|
||||
export function isBotMentionedInGroup(
|
||||
normalized: MentionContextSource,
|
||||
botPhoneJid: string | undefined,
|
||||
botLidUser: string | undefined,
|
||||
): boolean {
|
||||
if (!botPhoneJid && !botLidUser) return false;
|
||||
const mentionedJids: string[] = [
|
||||
...(normalized.extendedTextMessage?.contextInfo?.mentionedJid ?? []),
|
||||
...(normalized.imageMessage?.contextInfo?.mentionedJid ?? []),
|
||||
...(normalized.videoMessage?.contextInfo?.mentionedJid ?? []),
|
||||
...(normalized.documentMessage?.contextInfo?.mentionedJid ?? []),
|
||||
];
|
||||
const botLidJid = botLidUser ? `${botLidUser}@lid` : undefined;
|
||||
return mentionedJids.some((jid) => {
|
||||
if (!jid) return false;
|
||||
const bare = jid.split(':')[0];
|
||||
return bare === botPhoneJid || bare === botLidJid;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute `InboundMessage.isMention` for a WhatsApp message:
|
||||
* - DMs are always mentions (router auto-engages on the bot's behalf).
|
||||
* - Group messages are mentions only when the bot is explicitly tagged.
|
||||
*
|
||||
* Returns `true | undefined` rather than `true | false` because the
|
||||
* `InboundMessage` field is `isMention?: boolean` and downstream code
|
||||
* treats `undefined` differently than an explicit `false` (#2560).
|
||||
*/
|
||||
export function computeIsMention(isGroup: boolean, botMentionedInGroup: boolean): true | undefined {
|
||||
if (!isGroup) return true;
|
||||
return botMentionedInGroup ? true : undefined;
|
||||
}
|
||||
|
||||
/** Map file extension to Baileys media message type. */
|
||||
@@ -161,14 +281,16 @@ 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 }> = [];
|
||||
const outgoingQueue: Array<{ jid: string; text: string; mentions?: string[] }> = [];
|
||||
let flushing = false;
|
||||
|
||||
// Sent message cache for retry/re-encrypt requests
|
||||
@@ -207,21 +329,30 @@ registerChannelAdapter('whatsapp', {
|
||||
groupMetadataCache.clear();
|
||||
}
|
||||
|
||||
async function translateJid(jid: string): Promise<string> {
|
||||
async function translateJid(jid: string, altJid?: string): Promise<string> {
|
||||
if (!jid.endsWith('@lid')) return jid;
|
||||
const lidUser = jid.split('@')[0].split(':')[0];
|
||||
|
||||
// 1. Check local cache
|
||||
const cached = lidToPhoneMap[lidUser];
|
||||
if (cached) return cached;
|
||||
|
||||
// Query Baileys' signal repository
|
||||
// 2. Use the alt JID from extractAddressingContext (v7 provides this
|
||||
// on every inbound message as remoteJidAlt / participantAlt)
|
||||
if (altJid && !altJid.endsWith('@lid')) {
|
||||
const phoneJid = altJid.includes('@') ? altJid : `${altJid}@s.whatsapp.net`;
|
||||
setLidPhoneMapping(lidUser, phoneJid);
|
||||
log.info('Translated LID via alt JID', { lidJid: jid, phoneJid });
|
||||
return phoneJid;
|
||||
}
|
||||
|
||||
// 3. Query Baileys v7 LID mapping store
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const pn = await (sock.signalRepository as any)?.lidMapping?.getPNForLID(jid);
|
||||
const pn = await sock.signalRepository.lidMapping.getPNForLID(jid);
|
||||
if (pn) {
|
||||
const phoneJid = `${pn.split('@')[0].split(':')[0]}@s.whatsapp.net`;
|
||||
setLidPhoneMapping(lidUser, phoneJid);
|
||||
log.info('Translated LID to phone JID', { lidJid: jid, phoneJid });
|
||||
log.info('Translated LID via signal repository', { lidJid: jid, phoneJid });
|
||||
return phoneJid;
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -280,7 +411,9 @@ registerChannelAdapter('whatsapp', {
|
||||
log.info('Flushing outgoing message queue', { count: outgoingQueue.length });
|
||||
while (outgoingQueue.length > 0) {
|
||||
const item = outgoingQueue.shift()!;
|
||||
const sent = await sock.sendMessage(item.jid, { text: item.text });
|
||||
const payload: { text: string; mentions?: string[] } = { text: item.text };
|
||||
if (item.mentions && item.mentions.length > 0) payload.mentions = item.mentions;
|
||||
const sent = await sock.sendMessage(item.jid, payload);
|
||||
if (sent?.key?.id && sent.message) {
|
||||
sentMessageCache.set(sent.key.id, sent.message);
|
||||
}
|
||||
@@ -332,14 +465,16 @@ registerChannelAdapter('whatsapp', {
|
||||
return results;
|
||||
}
|
||||
|
||||
async function sendRawMessage(jid: string, text: string): Promise<string | undefined> {
|
||||
async function sendRawMessage(jid: string, text: string, mentions?: string[]): Promise<string | undefined> {
|
||||
if (!connected) {
|
||||
outgoingQueue.push({ jid, text });
|
||||
outgoingQueue.push({ jid, text, mentions });
|
||||
log.info('WA disconnected, message queued', { jid, queueSize: outgoingQueue.length });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const sent = await sock.sendMessage(jid, { text });
|
||||
const payload: { text: string; mentions?: string[] } = { text };
|
||||
if (mentions && mentions.length > 0) payload.mentions = mentions;
|
||||
const sent = await sock.sendMessage(jid, payload);
|
||||
if (sent?.key?.id && sent.message) {
|
||||
sentMessageCache.set(sent.key.id, sent.message);
|
||||
if (sentMessageCache.size > SENT_MESSAGE_CACHE_MAX) {
|
||||
@@ -349,7 +484,7 @@ registerChannelAdapter('whatsapp', {
|
||||
}
|
||||
return sent?.key?.id ?? undefined;
|
||||
} catch (err) {
|
||||
outgoingQueue.push({ jid, text });
|
||||
outgoingQueue.push({ jid, text, mentions });
|
||||
log.warn('Failed to send, message queued', { jid, err, queueSize: outgoingQueue.length });
|
||||
return undefined;
|
||||
}
|
||||
@@ -360,10 +495,7 @@ registerChannelAdapter('whatsapp', {
|
||||
async function connectSocket(): Promise<void> {
|
||||
const { state, saveCreds } = await useMultiFileAuthState(authDir);
|
||||
|
||||
const { version } = await fetchLatestWaWebVersion({}).catch((err) => {
|
||||
log.warn('Failed to fetch latest WA Web version, using default', { err });
|
||||
return { version: undefined };
|
||||
});
|
||||
const version = await resolveWaWebVersion();
|
||||
|
||||
sock = makeWASocket({
|
||||
version,
|
||||
@@ -380,7 +512,7 @@ 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.fromObject({});
|
||||
return proto.Message.create({});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -417,9 +549,13 @@ registerChannelAdapter('whatsapp', {
|
||||
if (connection === 'close') {
|
||||
connected = false;
|
||||
const reason = (lastDisconnect?.error as { output?: { statusCode?: number } })?.output?.statusCode;
|
||||
const shouldReconnect = reason !== DisconnectReason.loggedOut;
|
||||
// Don't auto-reconnect during shutdown — a parallel connectSocket()
|
||||
// initializes useMultiFileAuthState which can truncate creds.json
|
||||
// mid-write when the process exits, leaving a 0-byte creds file
|
||||
// and forcing a fresh QR pairing on next start.
|
||||
const shouldReconnect = !shuttingDown && reason !== DisconnectReason.loggedOut;
|
||||
|
||||
log.info('WhatsApp connection closed', { reason, shouldReconnect });
|
||||
log.info('WhatsApp connection closed', { reason, shouldReconnect, shuttingDown });
|
||||
|
||||
if (shouldReconnect) {
|
||||
log.info('Reconnecting...');
|
||||
@@ -433,6 +569,17 @@ registerChannelAdapter('whatsapp', {
|
||||
});
|
||||
} else {
|
||||
log.info('WhatsApp logged out');
|
||||
// Delete auth credentials immediately. Keeping stale credentials
|
||||
// causes the next service restart to attempt authentication with an
|
||||
// invalidated session, producing a second 401 that can trigger
|
||||
// WhatsApp's re-link cooldown ("can't link new devices now").
|
||||
try {
|
||||
fs.rmSync(authDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
log.info('WhatsApp auth cleared — set WHATSAPP_ENABLED=true and restart to re-link');
|
||||
} catch (err) {
|
||||
log.error('Failed to clear WhatsApp auth after logout', { err });
|
||||
}
|
||||
if (rejectFirstOpen) {
|
||||
rejectFirstOpen(new Error('WhatsApp logged out'));
|
||||
rejectFirstOpen = undefined;
|
||||
@@ -459,8 +606,9 @@ 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, `${phoneUser}@s.whatsapp.net`);
|
||||
setLidPhoneMapping(lidUser, botPhoneJid);
|
||||
botLidUser = lidUser;
|
||||
}
|
||||
}
|
||||
@@ -488,10 +636,13 @@ registerChannelAdapter('whatsapp', {
|
||||
|
||||
sock.ev.on('creds.update', saveCreds);
|
||||
|
||||
// Phone number sharing events — update LID mapping
|
||||
sock.ev.on('chats.phoneNumberShare', ({ lid, jid }) => {
|
||||
// LID ↔ phone mapping updates (v7 replaces chats.phoneNumberShare)
|
||||
sock.ev.on('lid-mapping.update', ({ lid, pn }) => {
|
||||
const lidUser = lid?.split('@')[0].split(':')[0];
|
||||
if (lidUser && jid) setLidPhoneMapping(lidUser, jid);
|
||||
if (lidUser && pn) {
|
||||
const phoneJid = pn.includes('@') ? pn : `${pn}@s.whatsapp.net`;
|
||||
setLidPhoneMapping(lidUser, phoneJid);
|
||||
}
|
||||
});
|
||||
|
||||
// Inbound messages
|
||||
@@ -504,16 +655,8 @@ registerChannelAdapter('whatsapp', {
|
||||
const rawJid = msg.key.remoteJid;
|
||||
if (!rawJid || rawJid === 'status@broadcast') continue;
|
||||
|
||||
// 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;
|
||||
}
|
||||
// Translate LID → phone JID using v7's alt JID from extractAddressingContext
|
||||
const chatJid = await translateJid(rawJid, msg.key.remoteJidAlt);
|
||||
|
||||
const timestamp = new Date(Number(msg.messageTimestamp) * 1000).toISOString();
|
||||
const isGroup = chatJid.endsWith('@g.us');
|
||||
@@ -539,13 +682,24 @@ registerChannelAdapter('whatsapp', {
|
||||
// Skip empty protocol messages (no text and no attachments)
|
||||
if (!content && attachments.length === 0) continue;
|
||||
|
||||
const sender = msg.key.participant || msg.key.remoteJid || '';
|
||||
// Resolve sender: in groups, participant may be LID — use participantAlt
|
||||
const rawSender = msg.key.participant || msg.key.remoteJid || '';
|
||||
const sender = rawSender.endsWith('@lid')
|
||||
? await translateJid(rawSender, msg.key.participantAlt)
|
||||
: rawSender;
|
||||
const senderName = msg.pushName || sender.split('@')[0];
|
||||
const fromMe = msg.key.fromMe || false;
|
||||
// Filter bot's own messages to prevent echo loops.
|
||||
// fromMe is always true for messages sent from this linked device,
|
||||
// regardless of ASSISTANT_HAS_OWN_NUMBER mode.
|
||||
if (fromMe) continue;
|
||||
// In self-chat (user messaging their own number), all messages have
|
||||
// fromMe=true — use sentMessageCache to distinguish bot echoes from
|
||||
// user-typed messages. For all other chats, the blanket fromMe
|
||||
// filter is correct since the user's phone messages shouldn't wake
|
||||
// the agent in third-party conversations.
|
||||
if (fromMe) {
|
||||
const isSelfChat = botPhoneJid && chatJid === botPhoneJid;
|
||||
if (!isSelfChat) continue;
|
||||
if (sentMessageCache.has(msg.key.id || '')) continue;
|
||||
}
|
||||
|
||||
const isBotMessage = ASSISTANT_HAS_OWN_NUMBER ? false : content.startsWith(`${ASSISTANT_NAME}:`);
|
||||
|
||||
@@ -568,9 +722,22 @@ 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
|
||||
// approval-required messaging_group when the chat is unknown,
|
||||
// instead of silently dropping. In groups, only an explicit
|
||||
// @-mention counts.
|
||||
isMention: computeIsMention(isGroup, botMentionedInGroup),
|
||||
isGroup,
|
||||
content: {
|
||||
text: content,
|
||||
sender,
|
||||
@@ -674,8 +841,15 @@ registerChannelAdapter('whatsapp', {
|
||||
for (const file of message.files!) {
|
||||
try {
|
||||
const ext = path.extname(file.filename).toLowerCase();
|
||||
const caption = !captionUsed ? text : undefined;
|
||||
let caption: string | undefined;
|
||||
let captionMentions: string[] | undefined;
|
||||
if (!captionUsed && text) {
|
||||
const formatted = formatWhatsApp(text);
|
||||
caption = formatted.text;
|
||||
captionMentions = formatted.mentions.length > 0 ? formatted.mentions : undefined;
|
||||
}
|
||||
const mediaMsg = buildMediaMessage(file.data, file.filename, ext, caption);
|
||||
if (captionMentions) mediaMsg.mentions = captionMentions;
|
||||
const sent = await sock.sendMessage(platformId, mediaMsg);
|
||||
if (sent?.key?.id && sent.message) {
|
||||
sentMessageCache.set(sent.key.id, sent.message);
|
||||
@@ -689,9 +863,9 @@ registerChannelAdapter('whatsapp', {
|
||||
}
|
||||
|
||||
if (text) {
|
||||
const formatted = formatWhatsApp(text);
|
||||
const { text: formatted, mentions } = formatWhatsApp(text);
|
||||
const prefixed = ASSISTANT_HAS_OWN_NUMBER ? formatted : `${ASSISTANT_NAME}: ${formatted}`;
|
||||
return sendRawMessage(platformId, prefixed);
|
||||
return sendRawMessage(platformId, prefixed, mentions);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -704,6 +878,7 @@ registerChannelAdapter('whatsapp', {
|
||||
},
|
||||
|
||||
async teardown() {
|
||||
shuttingDown = true;
|
||||
connected = false;
|
||||
sock?.end(undefined);
|
||||
log.info('WhatsApp adapter shut down');
|
||||
|
||||
+7
-5
@@ -9,15 +9,17 @@
|
||||
* will later emit as event.platformId, or router lookups miss and messages
|
||||
* get silently dropped.
|
||||
*
|
||||
* Native adapters (Signal, WhatsApp, iMessage) use their own ID formats and
|
||||
* send them as-is — no channel prefix. WhatsApp/iMessage emit JIDs/emails
|
||||
* containing '@'. Signal emits raw phone numbers ('+15551234567') for DMs
|
||||
* and 'group:<id>' for group chats. Prefixing any of these would cause a
|
||||
* mismatch with what the adapter later emits.
|
||||
* Native adapters (Signal, WhatsApp, iMessage, DeltaChat) use their own ID
|
||||
* formats and send them as-is — no channel prefix. WhatsApp/iMessage emit
|
||||
* JIDs/emails containing '@'. Signal emits raw phone numbers ('+15551234567')
|
||||
* for DMs and 'group:<id>' for group chats. DeltaChat emits numeric chat IDs
|
||||
* ('12'). Prefixing any of these would cause a mismatch with what the adapter
|
||||
* later emits.
|
||||
*/
|
||||
export function namespacedPlatformId(channel: string, raw: string): string {
|
||||
if (raw.startsWith(`${channel}:`)) return raw;
|
||||
if (raw.includes('@')) return raw;
|
||||
if (raw.startsWith('+') || raw.startsWith('group:')) return raw;
|
||||
if (channel === 'deltachat') return raw;
|
||||
return `${channel}:${raw}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user