Compare commits

...

23 Commits

Author SHA1 Message Date
gavrielc 43adb1998a Merge pull request #2552 from IamAdamJowett/fix/whatsapp-mentions-and-shutdown-race
Thanks @IamAdamJowett!
2026-05-22 23:17:35 +03:00
gavrielc 5ba4735fe9 merge: resolve conflict with channels branch in whatsapp.test.ts
Combine both test suites — inbound bot mention detection (#2560 from
channels) and outbound parseWhatsAppMentions (this PR).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-22 23:16:48 +03:00
gavrielc 3986ce0e11 Merge pull request #2579 from cfis/fix/whatsapp-clear-auth-on-logout
fix(whatsapp): clear auth credentials immediately on 401 logout
2026-05-22 22:31:10 +03:00
Charlie Savage 3777a9b614 fix(whatsapp): clear auth credentials immediately on 401 logout
When WhatsApp issues a forced logout (status 401), the adapter now
deletes store/auth/ immediately rather than leaving stale credentials
on disk. Previously, stale credentials persisted across service
restarts — the next startup would re-attempt authentication with the
dead session, receive a second 401, and contribute to WhatsApp's
temporary re-link cooldown ("can't link new devices now. try again
later").

After clearing, a log message instructs the operator to set
WHATSAPP_ENABLED=true and restart in order to re-link. Without that
env var, the adapter stays dormant on subsequent restarts (existing
guard at adapter startup: no creds + no WHATSAPP_ENABLED → return
null), avoiding any further auth attempts until the operator is ready.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 11:18:52 -07:00
glifocat 36fb78092c Merge pull request #2565 from glifocat/fix/whatsapp-group-mention-ismention
fix(whatsapp): detect group @-mentions via contextInfo.mentionedJid
2026-05-20 03:50:03 +02:00
nanoclaw-coder c52591f68f fix(whatsapp): detect group @-mentions via contextInfo.mentionedJid
Before this change, the inbound construction site hard-coded
`isMention: !isGroup ? true : undefined`, which meant group messages
that explicitly @-mentioned the bot never set the field. The router
then never woke the agent on a mention-only trigger.

Detection lives in a new pure helper `isBotMentionedInGroup` which
scans `contextInfo.mentionedJid` across the four message types that
can host mentions (extendedTextMessage + image/video/document
captions), matching against both the bot's phone JID and LID since
modern WhatsApp clients increasingly emit the LID for phone-number
mentions. A second helper `computeIsMention` wraps the DM/group
ternary so both branches of the fix are unit-testable.

Tests in src/channels/whatsapp.test.ts cover phone-JID detection,
LID-only detection, image-caption mentions, the negative cases, and
the call-site isMention semantics for DMs vs groups vs no-mention.

Fixes #2560
2026-05-20 03:06:05 +02:00
Adam e372f05d2e fix(whatsapp): render @<phone> as real mentions; prevent shutdown-race creds wipe
Two bugs in src/channels/whatsapp.ts that compound each other:

1) Outbound `@<digits>` never rendered as a WhatsApp tag because every
   send path called `sock.sendMessage(jid, { text })` with no `mentions`
   array. Baileys copies `mentions` into `contextInfo.mentionedJid`
   (lib/Utils/messages.js:477), and WhatsApp clients use that list to
   draw the bold/clickable tag. Without it the `@<digits>` is plain
   text with no notification.

   Fix: new `parseWhatsAppMentions` scans outbound text for
   `@<5-15 digits>` (with optional leading `+`, stripped so the literal
   matches the JID), and `formatWhatsApp` now returns `{ text, mentions }`.
   All four outbound paths — `sendRawMessage`, `flushOutgoingQueue`,
   media caption, and the normal-text branch in `deliver` — pass the
   `mentions` array through. Code-block protected regions are exempt
   so phone-like sequences inside fenced code aren't tagged.

2) On SIGTERM the `connection.update` close handler unconditionally
   called `connectSocket()` (because `shouldReconnect` was only false
   for `loggedOut`). The fresh `useMultiFileAuthState` it initialized
   could truncate `creds.json` mid-write as the process exited, leaving
   a 0-byte creds file and forcing a fresh QR pairing on next start.

   Fix: new `shuttingDown` flag set by `teardown()`; the close handler
   skips the reconnect when shutting down.

Adds src/channels/whatsapp.test.ts covering the new mention parser
(10 cases: basic extraction, `+` stripping, multiple/dedup mentions,
edge cases like emails, short sequences, parens, punctuation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:20:52 +10:00
gavrielc 8e91d37bc9 Merge pull request #2302 from Koshkoshinsk/fix/whatsapp-self-chat
fix(whatsapp): allow self-chat messages through fromMe filter
2026-05-07 00:09:59 +03:00
koshkoshinsk bba8213cbd fix(whatsapp): allow self-chat messages through fromMe filter
The blanket `if (fromMe) continue` filter dropped all messages from the
linked device, including user-typed messages in self-chat. Use the
existing sentMessageCache to distinguish bot echoes from user messages
when chatJid matches the bot's own phone JID.

Introduced in c02ac06 (Apr 14 2026).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 11:15:57 +00:00
gavrielc 5f069221b2 Merge pull request #2259 from qwibitai/fix/whatsapp-baileys-v7-lid
fix(whatsapp): upgrade Baileys v6→v7 for proper LID handling
2026-05-05 16:15:51 +03:00
exe.dev user 151091f384 chore: update lockfile for Baileys 7.0.0-rc.9
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-04 22:35:38 +00:00
exe.dev user 5ada950982 fix(whatsapp): fail fast when WA Web version can't be fetched
Never fall back to Baileys' hardcoded stale version — it will just
get rejected with 405 at the Noise layer. Throw a clear error
instead so the problem is visible immediately.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-04 22:26:18 +00:00
exe.dev user 6c455330e4 fix(whatsapp): fetch current WA Web version from wppconnect tracker
Baileys' fetchLatestWaWebVersion scrapes sw.js which WhatsApp
aggressively rate-limits (429). When it fails, Baileys falls back
to a hardcoded version (2.3000.1027934701) that goes stale within
weeks — WhatsApp rejects connections with a mismatched buildHash
(405 at Noise protocol layer, before QR/pairing code can start).

Add resolveWaWebVersion() that fetches the current version from
wppconnect.io/whatsapp-versions first, then falls back to Baileys'
own fetch, then to the hardcoded default. Applied to both the
adapter and whatsapp-auth.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-04 22:25:21 +00:00
exe.dev user 27af41d9b0 fix(whatsapp): upgrade Baileys v6→v7 for proper LID handling
v1 ran on Baileys 7.0.0-rc.9 which has proper LID support via
extractAddressingContext (participantAlt/remoteJidAlt on every inbound
message) and signalRepository.lidMapping.getPNForLID. The v2 adapter
was mistakenly downgraded to v6 where getPNForLID doesn't exist,
making the call dead code behind an `as any` cast. This caused:

- Unresolvable LIDs leaking to the router as @lid platform IDs
- Split sessions when dual messaging_groups rows were created
- Silent message drops on cold start before mappings were learned

Changes:
- Pin @whiskeysockets/baileys to exact 7.0.0-rc.9 (last release)
- Import proto directly (ESM named export in v7, no createRequire hack)
- Remove getPlatformId monkey-patch (bug fixed in v7)
- translateJid: use msg.key.remoteJidAlt/participantAlt first, then
  real signalRepository.lidMapping.getPNForLID (no as any cast)
- Replace chats.phoneNumberShare with lid-mapping.update event
- proto.Message.fromObject → proto.Message.create (v7 migration)
- Resolve sender JID in groups via participantAlt
- Mark DM inbound messages with isMention=true (subsumes #2213 fix)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-04 21:55:49 +00:00
gavrielc ea68aa810b Merge pull request #2192 from axxml/channels
Add DeltaChat channel adapter
2026-05-02 21:14:33 +03:00
Axel McLaren 5987fdc189 Add namespacedPlatformId exclusion for DeltaChat 2026-05-02 09:57:25 -07:00
Axel McLaren 0ef8757f50 Add DeltaChat channel adapter 2026-05-02 09:57:21 -07:00
exe.dev user 878d3706b4 telegram: redirect post-pairing chat message back to the installer
The previous wording promised a welcome DM "shortly", but in practice the
welcome can be delayed (cold container start, OneCLI selective-secret
mode, etc.) until well after the user has more terminal interactions to
complete. Telling them to wait in Telegram pulls attention away from the
installer at the worst moment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:04:45 +00:00
gavrielc b52ab850b2 Merge pull request #2040 from ddaniels/feat/signal-outbound-attachments
feat(signal): support outbound attachments
2026-05-01 01:02:42 +03:00
gavrielc 7b4dfd28c3 Merge pull request #2112 from robbyczgw-cla/fix/telegram-maxtextlength-wiring
fix(channels/telegram): wire maxTextLength to engage splitter from #1900
2026-05-01 00:32:26 +03:00
gavrielc 106c21a567 Merge pull request #2107 from qwibitai/feat/slack-resolve-channel-name
feat: implement resolveChannelName for Slack and Telegram
2026-04-30 22:54:30 +03:00
robbyczgw-cla c6b21e7493 fix(channels/telegram): wire maxTextLength to engage splitter from #1900
PR #1900 added the splitForLimit helper and maxTextLength config option,
but explicitly flagged that channel adapters need a follow-up to wire it.
This commit completes the wiring for Telegram.

Without this, the telegram adapter silently truncates outbound messages
>4096 chars via legacy truncateMessage() behavior. With this fix, the
splitter engages and posts chunks sequentially. The returned id is the
first chunk's id so edits/reactions still target the reply head.
2026-04-29 14:20:25 +00:00
Doug Daniels b672e8271e feat(signal): support outbound attachments via signal-cli attachments
The native Signal adapter previously logged-and-dropped any
OutboundFile entries with a TODO. This wires through to signal-cli's
already-supported `send` JSON-RPC `attachments` parameter:

  - New `sendAttachments(platformId, files)` helper writes each
    OutboundFile.data Buffer to a temp file in os.tmpdir(), passes
    the paths to `tcp.rpc('send', { attachments: [...] })`, then
    cleans up the temp files in finally{}.
  - `deliver()` no longer drops files — sends accompanying text
    first via the existing sendText (preserving chunking + textStyle),
    then attachments as a single send call.
  - Filename sanitization replaces `/`, `\`, and `\0` with `_` so
    operator-supplied filenames can't escape tmpdir (CWE-22).
  - Tests cover: single attachment, text+attachment ordering,
    multiple attachments in one send, group destinations, and a
    structural invariant proving sanitized paths stay inside tmpdir().

Total signal tests: 33 → 37 (one stale "drops files" test replaced
by the positive-behavior tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 22:19:28 -04:00
17 changed files with 1461 additions and 319 deletions
+62
View File
@@ -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.
+254
View File
@@ -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 |
|-------|---------|
| 10001999 | Not connected |
| 20002999 | Connecting |
| 30003999 | Working (IMAP fetching) |
| ≥ 4000 | Fully connected (IMAP IDLE) |
## Troubleshooting
### Adapter not starting — credentials missing
```bash
grep "Channel credentials missing" logs/nanoclaw.log | grep deltachat
```
All six required vars (`DC_EMAIL`, `DC_PASSWORD`, `DC_IMAP_HOST`, `DC_IMAP_PORT`, `DC_SMTP_HOST`, `DC_SMTP_PORT`) must be present in `.env`.
### Account configure fails
```bash
grep "DeltaChat" logs/nanoclaw.log | tail -20
```
Common causes:
- Wrong IMAP/SMTP hostnames — double-check provider docs
- App password not generated — Gmail and some others require this when 2FA is enabled
- Port/security mismatch — defaults are port 993 + SSL/TLS for IMAP and port 587 + STARTTLS for SMTP; override with `DC_IMAP_PORT`/`DC_IMAP_SECURITY` or `DC_SMTP_PORT`/`DC_SMTP_SECURITY` in `.env`
### Provider uses SMTP port 465 (SSL/TLS) instead of 587
Set `DC_SMTP_SECURITY=1` and `DC_SMTP_PORT=465` in `.env`, then restart.
### Messages not arriving
1. Check the service is running and the adapter started: `grep "Channel adapter started.*deltachat" logs/nanoclaw.log`
2. Check connectivity: `grep "DeltaChat: IO started" logs/nanoclaw.log`
3. Check the sender has been granted access — run `/init-first-agent` to create their user record and wire the chat
4. Verify the messaging group is wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mga.agent_group_id FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='deltachat'"`
### Stale lock file after crash
```bash
rm -f dc-account/accounts.lock
systemctl --user restart nanoclaw
```
### Bot not responding after restart
The account is already configured — IO restarts automatically on service start. If the RPC subprocess is stuck, restart the service. Check for errors:
```bash
grep "DeltaChat" logs/nanoclaw.error.log | tail -20
```
### Messages received but agent not responding
The messaging group exists but may not be wired to an agent group. Run:
```bash
sqlite3 data/v2.db "SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat'"
```
If the group has no entry in `messaging_group_agents`, wire it with `/manage-channels`.
+54
View File
@@ -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.
+1 -1
View File
@@ -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
View File
@@ -40,7 +40,7 @@
"@onecli-sh/sdk": "^0.3.1",
"@resend/chat-sdk-adapter": "^0.1.1",
"@types/qrcode": "^1.5.6",
"@whiskeysockets/baileys": "^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",
+125 -174
View File
@@ -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:
+1 -1
View File
@@ -16,7 +16,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Keep in sync with .claude/skills/add-whatsapp/SKILL.md.
BAILEYS_VERSION="@whiskeysockets/baileys@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"
+1 -1
View File
@@ -66,7 +66,7 @@ if ! grep -q "'whatsapp-auth':" setup/index.ts; then
fi
echo "STEP: pnpm-install"
pnpm install @whiskeysockets/baileys@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
View File
@@ -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,
+338
View File
@@ -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);
},
});
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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;
},
+2 -1
View File
@@ -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);
+162
View File
@@ -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
View File
@@ -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>` (515 digit local part — covers short test
// numbers up to ITU E.164 max). A leading `+` is accepted but stripped so
// the literal in text matches the digits in the JID — WhatsApp clients
// scan the rendered text for `@<digits>` and cross-reference it with the
// contextInfo.mentionedJid list to draw the bold/clickable tag.
const MENTION_RE = /(^|[^\w@+])@\+?(\d{5,15})(?!\d)/g;
/** Extract `@<digits>` mentions from text and normalize them. */
export function parseWhatsAppMentions(text: string): { text: string; mentions: string[] } {
const mentions = new Set<string>();
const out = text.replace(MENTION_RE, (_full, lead: string, digits: string) => {
mentions.add(`${digits}@s.whatsapp.net`);
return `${lead}@${digits}`;
});
return { text: out, mentions: [...mentions] };
}
/**
* Convert Claude's markdown to WhatsApp-native formatting and extract any
* `@<phone>` mentions. Code-block regions are passed through untouched so
* phone-like sequences inside code aren't tagged.
*/
function formatWhatsApp(text: string): { text: string; mentions: string[] } {
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
View File
@@ -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}`;
}