mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-24 18:31:31 +08:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a26abd6b6b | |||
| e9712d033a | |||
| cf5ac09320 | |||
| 8137440698 | |||
| 7ceb06cc8a | |||
| d011752c67 | |||
| 2e6f10cdd7 | |||
| 8906105825 | |||
| ef9e7d5f99 | |||
| 051b895b3c | |||
| 43adb1998a | |||
| 5ba4735fe9 | |||
| 3986ce0e11 | |||
| 3777a9b614 | |||
| 36fb78092c | |||
| c52591f68f | |||
| e372f05d2e | |||
| 8e91d37bc9 | |||
| bba8213cbd | |||
| 5f069221b2 | |||
| 151091f384 | |||
| 5ada950982 | |||
| 6c455330e4 | |||
| 27af41d9b0 | |||
| ea68aa810b | |||
| 5987fdc189 | |||
| 0ef8757f50 |
@@ -0,0 +1,62 @@
|
||||
# Remove DeltaChat
|
||||
|
||||
## 1. Disable the adapter
|
||||
|
||||
Comment out the import in `src/channels/index.ts`:
|
||||
|
||||
```typescript
|
||||
// import './deltachat.js';
|
||||
```
|
||||
|
||||
## 2. Remove credentials
|
||||
|
||||
Remove the `DC_*` lines from `.env`:
|
||||
|
||||
```bash
|
||||
DC_EMAIL
|
||||
DC_PASSWORD
|
||||
DC_IMAP_HOST
|
||||
DC_IMAP_PORT
|
||||
DC_SMTP_HOST
|
||||
DC_SMTP_PORT
|
||||
```
|
||||
|
||||
## 3. Rebuild and restart
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
|
||||
# Linux
|
||||
systemctl --user restart nanoclaw
|
||||
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
```
|
||||
|
||||
## 4. Remove account data (optional)
|
||||
|
||||
To fully remove all account data including DeltaChat encryption keys:
|
||||
|
||||
```bash
|
||||
rm -rf dc-account/
|
||||
```
|
||||
|
||||
> **Warning:** This deletes the Autocrypt keys. Contacts who have verified your bot's key will need to re-verify if the same email address is re-used with a new account.
|
||||
|
||||
To keep the account for later reinstall, leave `dc-account/` intact.
|
||||
|
||||
## 5. Remove the package (optional)
|
||||
|
||||
```bash
|
||||
pnpm remove @deltachat/stdio-rpc-server
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
After removal, confirm the adapter is no longer starting:
|
||||
|
||||
```bash
|
||||
grep "deltachat" logs/nanoclaw.log | tail -5
|
||||
```
|
||||
|
||||
Expected: no `Channel adapter started` entry after the last restart.
|
||||
@@ -0,0 +1,254 @@
|
||||
---
|
||||
name: add-deltachat
|
||||
description: Add DeltaChat channel integration via @deltachat/stdio-rpc-server. Native adapter — no Chat SDK bridge. Email-based messaging with end-to-end encryption.
|
||||
---
|
||||
|
||||
# Add DeltaChat Channel
|
||||
|
||||
The adapter drives the `@deltachat/stdio-rpc-server` JSON-RPC subprocess directly — pure Node.js against the DeltaChat core library. Messages are delivered over email with Autocrypt/OpenPGP encryption.
|
||||
|
||||
## Install
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
- `src/channels/deltachat.ts` exists
|
||||
- `src/channels/index.ts` contains `import './deltachat.js';`
|
||||
- `@deltachat/stdio-rpc-server` is listed in `package.json` dependencies
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
### 1. Fetch the channels branch
|
||||
|
||||
```bash
|
||||
git fetch origin channels
|
||||
```
|
||||
|
||||
### 2. Copy the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/deltachat.ts > src/channels/deltachat.ts
|
||||
```
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if already present):
|
||||
|
||||
```typescript
|
||||
import './deltachat.js';
|
||||
```
|
||||
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @deltachat/stdio-rpc-server@2.49.0
|
||||
```
|
||||
|
||||
### 5. Build
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
## Account Setup
|
||||
|
||||
A dedicated email account is strongly recommended — it will accumulate DeltaChat-formatted messages and store encryption keys. Not all providers work well with DeltaChat; check https://providers.delta.chat/ before picking one.
|
||||
|
||||
**Default security modes:** IMAP uses SSL/TLS (port 993), SMTP uses STARTTLS (port 587). Both are configurable via `.env` — see Credentials below.
|
||||
|
||||
To find the correct hostnames for a domain:
|
||||
|
||||
```bash
|
||||
node -e "require('dns').resolveMx('example.com', (e,r) => console.log(r))"
|
||||
```
|
||||
|
||||
Most providers publish their IMAP/SMTP hostnames in their help docs under "manual setup" or "IMAP access."
|
||||
|
||||
## Credentials
|
||||
|
||||
Add to `.env`:
|
||||
|
||||
```bash
|
||||
DC_EMAIL=bot@example.com
|
||||
DC_PASSWORD=your-app-password
|
||||
DC_IMAP_HOST=imap.example.com
|
||||
DC_IMAP_PORT=993
|
||||
DC_IMAP_SECURITY=1 # 1=SSL/TLS (default), 2=STARTTLS, 3=plain
|
||||
DC_SMTP_HOST=smtp.example.com
|
||||
DC_SMTP_PORT=587
|
||||
DC_SMTP_SECURITY=2 # 2=STARTTLS (default), 1=SSL/TLS, 3=plain
|
||||
```
|
||||
|
||||
Security settings are applied on every startup, so changing them in `.env` and restarting takes effect without wiping the account.
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
### Optional settings
|
||||
|
||||
The following are read from the process environment (not `.env`). To override them, add `Environment=` lines to the systemd service unit or your launchd plist:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DC_ACCOUNT_DIR` | `dc-account` | Directory for DeltaChat account data (IMAP state, keys, blobs) |
|
||||
| `DC_DISPLAY_NAME` | `NanoClaw` | Bot display name shown in DeltaChat |
|
||||
| `DC_AVATAR_PATH` | _(none)_ | Absolute path to avatar image; set at startup only |
|
||||
|
||||
The `/set-avatar` command (send an image with that caption) is the easiest way to set the avatar at runtime without modifying the service file. Only users with `owner` or global `admin` role can use it.
|
||||
|
||||
### Restart
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
systemctl --user restart nanoclaw
|
||||
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
```
|
||||
|
||||
On first start the adapter configures the email account (IMAP/SMTP credentials, calls `configure()`). Subsequent starts skip straight to `startIo()`. Account data is stored in `dc-account/` in the project root (or your `DC_ACCOUNT_DIR`).
|
||||
|
||||
## Wiring
|
||||
|
||||
### DMs
|
||||
|
||||
**DeltaChat contacts cannot be added by email alone** — to start a chat, the user must open the bot's invite link in their DeltaChat app or scan its QR code. This triggers the SecureJoin handshake.
|
||||
|
||||
#### Step 1 — Get the invite link
|
||||
|
||||
After the service starts, the adapter logs the invite URL and writes a QR SVG:
|
||||
|
||||
```bash
|
||||
grep "invite link" logs/nanoclaw.log | tail -1
|
||||
# url field contains the https://i.delta.chat/... invite link
|
||||
# also written to dc-account/invite-qr.svg (or $DC_ACCOUNT_DIR/invite-qr.svg)
|
||||
```
|
||||
|
||||
The invite URL is stable (tied to the bot's email and encryption keys) so it stays valid across restarts.
|
||||
|
||||
#### Step 2 — Add the bot in DeltaChat
|
||||
|
||||
Two options for the user to connect:
|
||||
|
||||
- **Link**: Copy the `https://i.delta.chat/...` URL and open it on the device running DeltaChat. The app recognises it and shows a "Start chat" prompt.
|
||||
- **QR code**: Open `dc-account/invite-qr.svg` in a browser or image viewer, display it on screen, and scan it from the DeltaChat app using the QR-scan button on the new-chat screen.
|
||||
|
||||
After accepting, DeltaChat exchanges keys and creates the chat automatically.
|
||||
|
||||
#### Step 3 — Wire the chat to an agent
|
||||
|
||||
Once the first message arrives the router auto-creates a `messaging_groups` row. Look up the chat ID:
|
||||
|
||||
```bash
|
||||
sqlite3 data/v2.db \
|
||||
"SELECT platform_id, name FROM messaging_groups WHERE channel_type='deltachat' AND is_group=0 ORDER BY created_at DESC LIMIT 5"
|
||||
```
|
||||
|
||||
Then run `/init-first-agent` — it creates the agent group, grants the user owner access, and wires the messaging group in one step:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/init-first-agent.ts \
|
||||
--channel deltachat \
|
||||
--user-id deltachat:user@example.com \
|
||||
--platform-id <platform_id from above> \
|
||||
--display-name "Your Name"
|
||||
```
|
||||
|
||||
### Groups
|
||||
|
||||
Add the bot email to a DeltaChat group. When any member sends a message, the router creates a `messaging_groups` row with `is_group = 1`. Run `/manage-channels` to wire it to an agent group.
|
||||
|
||||
## Next Steps
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now.
|
||||
|
||||
Otherwise, run `/init-first-agent` to create an agent and wire it to your DeltaChat DM (see Wiring above), or `/manage-channels` to wire this channel to an existing agent group.
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `deltachat`
|
||||
- **terminology**: DeltaChat calls them "chats" (1:1 DMs) and "groups"
|
||||
- **supports-threads**: no — DeltaChat has no thread model
|
||||
- **platform-id-format**: numeric chat ID as a string (e.g. `"12"`) — the DeltaChat core's internal chat identifier
|
||||
- **user-id-format**: `deltachat:{email}` — the contact's email address
|
||||
- **how-to-find-id**: Send a message from DeltaChat to the bot email, then query `messaging_groups` as shown above
|
||||
- **typical-use**: Personal assistant over DeltaChat DMs; small groups where participants use DeltaChat
|
||||
- **default-isolation**: One agent per bot identity. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode
|
||||
|
||||
### Features
|
||||
|
||||
- File attachments — inbound and outbound; inbound waits up to 30 seconds for large-message download to complete
|
||||
- Invite link logged on every startup — URL + QR SVG written to `dc-account/invite-qr.svg`; see Wiring for the bootstrap flow
|
||||
- `/set-avatar` — send an image with this caption to change the bot's DeltaChat avatar (admin/owner only)
|
||||
- Connectivity watchdog — restarts IO if IMAP goes quiet for 20 minutes or connectivity drops below threshold for two consecutive 5-minute checks
|
||||
- Network nudge — `maybeNetwork()` called every 10 minutes to recover from prolonged idle
|
||||
|
||||
Not supported: DeltaChat reactions, message editing/deletion, read receipts.
|
||||
|
||||
### Connectivity model
|
||||
|
||||
`isConnected()` returns `true` when the internal connectivity value is ≥ 3000:
|
||||
|
||||
| Range | Meaning |
|
||||
|-------|---------|
|
||||
| 1000–1999 | Not connected |
|
||||
| 2000–2999 | Connecting |
|
||||
| 3000–3999 | Working (IMAP fetching) |
|
||||
| ≥ 4000 | Fully connected (IMAP IDLE) |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Adapter not starting — credentials missing
|
||||
|
||||
```bash
|
||||
grep "Channel credentials missing" logs/nanoclaw.log | grep deltachat
|
||||
```
|
||||
|
||||
All six required vars (`DC_EMAIL`, `DC_PASSWORD`, `DC_IMAP_HOST`, `DC_IMAP_PORT`, `DC_SMTP_HOST`, `DC_SMTP_PORT`) must be present in `.env`.
|
||||
|
||||
### Account configure fails
|
||||
|
||||
```bash
|
||||
grep "DeltaChat" logs/nanoclaw.log | tail -20
|
||||
```
|
||||
|
||||
Common causes:
|
||||
- Wrong IMAP/SMTP hostnames — double-check provider docs
|
||||
- App password not generated — Gmail and some others require this when 2FA is enabled
|
||||
- Port/security mismatch — defaults are port 993 + SSL/TLS for IMAP and port 587 + STARTTLS for SMTP; override with `DC_IMAP_PORT`/`DC_IMAP_SECURITY` or `DC_SMTP_PORT`/`DC_SMTP_SECURITY` in `.env`
|
||||
|
||||
### Provider uses SMTP port 465 (SSL/TLS) instead of 587
|
||||
|
||||
Set `DC_SMTP_SECURITY=1` and `DC_SMTP_PORT=465` in `.env`, then restart.
|
||||
|
||||
### Messages not arriving
|
||||
|
||||
1. Check the service is running and the adapter started: `grep "Channel adapter started.*deltachat" logs/nanoclaw.log`
|
||||
2. Check connectivity: `grep "DeltaChat: IO started" logs/nanoclaw.log`
|
||||
3. Check the sender has been granted access — run `/init-first-agent` to create their user record and wire the chat
|
||||
4. Verify the messaging group is wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mga.agent_group_id FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='deltachat'"`
|
||||
|
||||
### Stale lock file after crash
|
||||
|
||||
```bash
|
||||
rm -f dc-account/accounts.lock
|
||||
systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
### Bot not responding after restart
|
||||
|
||||
The account is already configured — IO restarts automatically on service start. If the RPC subprocess is stuck, restart the service. Check for errors:
|
||||
|
||||
```bash
|
||||
grep "DeltaChat" logs/nanoclaw.error.log | tail -20
|
||||
```
|
||||
|
||||
### Messages received but agent not responding
|
||||
|
||||
The messaging group exists but may not be wired to an agent group. Run:
|
||||
|
||||
```bash
|
||||
sqlite3 data/v2.db "SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat'"
|
||||
```
|
||||
|
||||
If the group has no entry in `messaging_group_agents`, wire it with `/manage-channels`.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Verify DeltaChat
|
||||
|
||||
## 1. Check the adapter started
|
||||
|
||||
```bash
|
||||
grep "Channel adapter started.*deltachat" logs/nanoclaw.log | tail -1
|
||||
```
|
||||
|
||||
Expected: `Channel adapter started { channel: 'deltachat', type: 'deltachat' }`
|
||||
|
||||
## 2. Check IMAP/SMTP connectivity
|
||||
|
||||
Replace with your provider's hostnames from `.env`:
|
||||
|
||||
```bash
|
||||
DC_IMAP=$(grep '^DC_IMAP_HOST=' .env | cut -d= -f2)
|
||||
DC_SMTP=$(grep '^DC_SMTP_HOST=' .env | cut -d= -f2)
|
||||
|
||||
bash -c "echo >/dev/tcp/$DC_IMAP/993" && echo "IMAP open" || echo "IMAP blocked"
|
||||
bash -c "echo >/dev/tcp/$DC_SMTP/587" && echo "SMTP open" || echo "SMTP blocked"
|
||||
```
|
||||
|
||||
## 3. End-to-end message test
|
||||
|
||||
1. Open DeltaChat on your device
|
||||
2. Add the bot email address as a contact
|
||||
3. Send a message
|
||||
4. The bot should respond within a few seconds
|
||||
|
||||
If nothing arrives, check:
|
||||
|
||||
```bash
|
||||
grep "DeltaChat" logs/nanoclaw.log | tail -20
|
||||
grep "DeltaChat" logs/nanoclaw.error.log | tail -10
|
||||
```
|
||||
|
||||
## 4. Check messaging group was created
|
||||
|
||||
```bash
|
||||
sqlite3 data/v2.db \
|
||||
"SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat' ORDER BY created_at DESC LIMIT 5"
|
||||
```
|
||||
|
||||
If a row appears, the inbound routing is working. If not, the adapter isn't receiving the message — check logs for `DeltaChat: error handling incoming message`.
|
||||
|
||||
## 5. Verify user access
|
||||
|
||||
If the message arrived but the agent didn't respond, the sender may not have access:
|
||||
|
||||
```bash
|
||||
sqlite3 data/v2.db "SELECT id, display_name FROM users WHERE id LIKE 'deltachat:%'"
|
||||
```
|
||||
|
||||
Grant access as shown in the SKILL.md "Grant user access" section.
|
||||
@@ -44,7 +44,7 @@ import './discord.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/discord@4.26.0
|
||||
pnpm install @chat-adapter/discord@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build
|
||||
|
||||
@@ -44,7 +44,7 @@ import './gchat.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/gchat@4.26.0
|
||||
pnpm install @chat-adapter/gchat@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build
|
||||
|
||||
@@ -48,7 +48,7 @@ import './github.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/github@4.26.0
|
||||
pnpm install @chat-adapter/github@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build
|
||||
|
||||
@@ -87,7 +87,7 @@ Linear OAuth apps can't be @-mentioned, so the bridge's `onNewMention` handler n
|
||||
### 5. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/linear@4.26.0
|
||||
pnpm install @chat-adapter/linear@4.29.0
|
||||
```
|
||||
|
||||
### 6. Build
|
||||
|
||||
@@ -44,7 +44,7 @@ import './slack.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/slack@4.26.0
|
||||
pnpm install @chat-adapter/slack@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build
|
||||
@@ -72,26 +72,41 @@ pnpm run build
|
||||
### Event Subscriptions
|
||||
|
||||
8. Go to **Event Subscriptions** and toggle **Enable Events**
|
||||
9. Set the **Request URL** to `https://your-domain/webhook/slack` — Slack will send a verification challenge; it must pass before you can save
|
||||
9. **Webhook mode:** set the **Request URL** to `https://your-domain/webhook/slack` — Slack will send a verification challenge; it must pass before you can save. For **Socket Mode** (below), skip the Request URL.
|
||||
10. Under **Subscribe to bot events**, add:
|
||||
- `message.channels`, `message.groups`, `message.im`, `app_mention`
|
||||
11. Click **Save Changes**
|
||||
12. Slack will show a banner asking you to **reinstall the app** — click it to apply the new event subscriptions
|
||||
|
||||
### Socket Mode (optional — no public URL)
|
||||
|
||||
Socket Mode delivers events over an outbound WebSocket the bot opens to Slack, so the host needs **no public HTTPS endpoint** — ideal for local dev or a host behind NAT/a firewall. Setting `SLACK_APP_TOKEN` is what flips the adapter into Socket Mode; without it the adapter stays in webhook mode.
|
||||
|
||||
13. Go to **Basic Information** > **App-Level Tokens** > **Generate Token and Scopes**, add the `connections:write` scope, and copy the token (`xapp-...`)
|
||||
14. Go to **Socket Mode** and toggle **Enable Socket Mode** on
|
||||
15. Keep **Event Subscriptions** enabled with the bot events above — under Socket Mode no Request URL is required
|
||||
|
||||
### Configure environment
|
||||
|
||||
Add to `.env`:
|
||||
Add to `.env` — **webhook mode**:
|
||||
|
||||
```bash
|
||||
SLACK_BOT_TOKEN=xoxb-your-bot-token
|
||||
SLACK_SIGNING_SECRET=your-signing-secret
|
||||
```
|
||||
|
||||
…or **Socket Mode** (no public URL; signing secret optional):
|
||||
|
||||
```bash
|
||||
SLACK_BOT_TOKEN=xoxb-your-bot-token
|
||||
SLACK_APP_TOKEN=xapp-your-app-level-token
|
||||
```
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
### Webhook server
|
||||
### Webhook server (webhook mode only)
|
||||
|
||||
The Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/webhook/slack` for Slack and other webhook-based adapters. This port must be publicly reachable from the internet for Slack to deliver events.
|
||||
In **webhook mode** the Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/webhook/slack` for Slack and other webhook-based adapters. This port must be publicly reachable from the internet for Slack to deliver events. **In Socket Mode this is not needed** — skip this section if you set `SLACK_APP_TOKEN`.
|
||||
|
||||
If running locally, discuss options for exposing the server — e.g. ngrok (`ngrok http 3000`), Cloudflare Tunnel, or a reverse proxy on a VPS. The resulting public URL becomes the base for `https://your-domain/webhook/slack`.
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ import './teams.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/teams@4.26.0
|
||||
pnpm install @chat-adapter/teams@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build
|
||||
|
||||
@@ -58,7 +58,7 @@ In `setup/index.ts`, add this entry to the `STEPS` map (right after the `registe
|
||||
### 5. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/telegram@4.26.0
|
||||
pnpm install @chat-adapter/telegram@4.29.0
|
||||
```
|
||||
|
||||
### 6. Build
|
||||
|
||||
@@ -44,7 +44,7 @@ import './whatsapp-cloud.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/whatsapp@4.26.0
|
||||
pnpm install @chat-adapter/whatsapp@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build
|
||||
|
||||
@@ -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
|
||||
|
||||
+11
-11
@@ -26,23 +26,23 @@
|
||||
"dependencies": {
|
||||
"@beeper/chat-adapter-matrix": "^0.2.0",
|
||||
"@bitbasti/chat-adapter-webex": "^0.1.0",
|
||||
"@chat-adapter/discord": "^4.24.0",
|
||||
"@chat-adapter/gchat": "^4.24.0",
|
||||
"@chat-adapter/github": "^4.24.0",
|
||||
"@chat-adapter/linear": "^4.26.0",
|
||||
"@chat-adapter/slack": "^4.24.0",
|
||||
"@chat-adapter/state-memory": "^4.24.0",
|
||||
"@chat-adapter/teams": "^4.24.0",
|
||||
"@chat-adapter/telegram": "4.26.0",
|
||||
"@chat-adapter/whatsapp": "^4.24.0",
|
||||
"@chat-adapter/discord": "4.29.0",
|
||||
"@chat-adapter/gchat": "4.29.0",
|
||||
"@chat-adapter/github": "4.29.0",
|
||||
"@chat-adapter/linear": "4.29.0",
|
||||
"@chat-adapter/slack": "4.29.0",
|
||||
"@chat-adapter/state-memory": "4.29.0",
|
||||
"@chat-adapter/teams": "4.29.0",
|
||||
"@chat-adapter/telegram": "4.29.0",
|
||||
"@chat-adapter/whatsapp": "4.29.0",
|
||||
"@clack/core": "^1.2.0",
|
||||
"@clack/prompts": "^1.2.0",
|
||||
"@onecli-sh/sdk": "^0.3.1",
|
||||
"@resend/chat-sdk-adapter": "^0.1.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@whiskeysockets/baileys": "^6.17.16",
|
||||
"@whiskeysockets/baileys": "7.0.0-rc.9",
|
||||
"better-sqlite3": "11.10.0",
|
||||
"chat": "^4.24.0",
|
||||
"chat": "4.29.0",
|
||||
"chat-adapter-imessage": "^0.1.1",
|
||||
"cron-parser": "5.5.0",
|
||||
"kleur": "^4.1.5",
|
||||
|
||||
Generated
+296
-292
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-discord/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/discord@4.26.0"
|
||||
ADAPTER_VERSION="@chat-adapter/discord@4.29.0"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
|
||||
+14
-6
@@ -1,10 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the Slack adapter, persist SLACK_BOT_TOKEN + SLACK_SIGNING_SECRET to
|
||||
# Install the Slack adapter, persist SLACK_BOT_TOKEN plus the mode-specific
|
||||
# secret (SLACK_APP_TOKEN for Socket Mode, SLACK_SIGNING_SECRET for webhook) to
|
||||
# .env + data/env/env, and restart the service. Non-interactive — the
|
||||
# operator-facing app creation walkthrough + credential paste live in
|
||||
# setup/channels/slack.ts. Credentials come in via env vars:
|
||||
# SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET.
|
||||
# SLACK_BOT_TOKEN, and SLACK_APP_TOKEN and/or SLACK_SIGNING_SECRET.
|
||||
#
|
||||
# Emits exactly one status block on stdout (ADD_SLACK) at the end. All chatty
|
||||
# progress messages go to stderr so setup:auto's raw-log capture sees the full
|
||||
@@ -15,7 +16,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-slack/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/slack@4.26.0"
|
||||
ADAPTER_VERSION="@chat-adapter/slack@4.29.0"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
@@ -41,8 +42,10 @@ if [ -z "${SLACK_BOT_TOKEN:-}" ]; then
|
||||
emit_status failed "SLACK_BOT_TOKEN env var not set"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${SLACK_SIGNING_SECRET:-}" ]; then
|
||||
emit_status failed "SLACK_SIGNING_SECRET env var not set"
|
||||
# Socket Mode authenticates with SLACK_APP_TOKEN; webhook mode with
|
||||
# SLACK_SIGNING_SECRET. Require at least one.
|
||||
if [ -z "${SLACK_APP_TOKEN:-}" ] && [ -z "${SLACK_SIGNING_SECRET:-}" ]; then
|
||||
emit_status failed "Set SLACK_APP_TOKEN (Socket Mode) or SLACK_SIGNING_SECRET (webhook)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -98,7 +101,12 @@ upsert_env() {
|
||||
fi
|
||||
}
|
||||
upsert_env SLACK_BOT_TOKEN "$SLACK_BOT_TOKEN"
|
||||
upsert_env SLACK_SIGNING_SECRET "$SLACK_SIGNING_SECRET"
|
||||
if [ -n "${SLACK_APP_TOKEN:-}" ]; then
|
||||
upsert_env SLACK_APP_TOKEN "$SLACK_APP_TOKEN"
|
||||
fi
|
||||
if [ -n "${SLACK_SIGNING_SECRET:-}" ]; then
|
||||
upsert_env SLACK_SIGNING_SECRET "$SLACK_SIGNING_SECRET"
|
||||
fi
|
||||
|
||||
# Container reads from data/env/env (the host mounts it).
|
||||
mkdir -p data/env
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-teams/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/teams@4.26.0"
|
||||
ADAPTER_VERSION="@chat-adapter/teams@4.29.0"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
|
||||
@@ -15,7 +15,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-telegram/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/telegram@4.26.0"
|
||||
ADAPTER_VERSION="@chat-adapter/telegram@4.29.0"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
|
||||
@@ -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"
|
||||
|
||||
+116
-48
@@ -4,21 +4,23 @@
|
||||
* `runSlackChannel(displayName)` walks the operator from a bare Slack
|
||||
* workspace through a running bot, then stops before wiring an agent:
|
||||
*
|
||||
* 1. Walk through creating a Slack app (api.slack.com/apps) — scopes,
|
||||
* event subscriptions, and signing secret
|
||||
* 2. Paste the bot token + signing secret (clack password prompts)
|
||||
* 3. Validate via auth.test → resolves workspace + bot identity
|
||||
* 4. Install the adapter (setup/add-slack.sh, non-interactive)
|
||||
* 5. Print the post-install checklist: set the public webhook URL in
|
||||
* Slack's Event Subscriptions, DM the bot to bootstrap the channel,
|
||||
* then `/manage-channels` to wire an agent.
|
||||
* 1. Ask the delivery mode: Socket Mode (outbound WebSocket, no public
|
||||
* URL) or a public webhook
|
||||
* 2. Walk through creating a Slack app (api.slack.com/apps) — scopes,
|
||||
* events, and the mode-specific credential (app-level token for
|
||||
* Socket Mode, signing secret for webhook)
|
||||
* 3. Paste the bot token + that credential (clack password prompts)
|
||||
* 4. Validate via auth.test → resolves workspace + bot identity
|
||||
* 5. Install the adapter (setup/add-slack.sh, non-interactive)
|
||||
* 6. Print the post-install checklist (Socket Mode: just DM the bot;
|
||||
* webhook: set the public Request URL in Event Subscriptions), then
|
||||
* `/manage-channels` to wire an agent.
|
||||
*
|
||||
* Why no welcome DM here: unlike Discord/Telegram (gateway / long-poll),
|
||||
* Slack needs a public Event Subscriptions URL for inbound events, and
|
||||
* opening an unsolicited DM would need `im:write` scope we don't force
|
||||
* the SKILL.md to require. Shipping a honest "here's what's left" note
|
||||
* is better than a welcome DM the user won't receive until they
|
||||
* configure the webhook anyway.
|
||||
* Why no welcome DM here: opening an unsolicited DM would need `im:write`
|
||||
* scope we don't force the SKILL.md to require — and in webhook mode inbound
|
||||
* events don't flow until the public Event Subscriptions URL is configured.
|
||||
* Shipping an honest "here's what's left" note is better than a welcome DM
|
||||
* the user won't receive until they finish wiring Slack up.
|
||||
*
|
||||
* All output obeys the three-level contract. See docs/setup-flow.md.
|
||||
*/
|
||||
@@ -26,6 +28,7 @@ import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { brightSelect } from '../lib/bright-select.js';
|
||||
import { confirmThenOpen } from '../lib/browser.js';
|
||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||
import { wrapForGutter } from '../lib/theme.js';
|
||||
@@ -40,16 +43,28 @@ interface WorkspaceInfo {
|
||||
botUserId: string;
|
||||
}
|
||||
|
||||
// Socket Mode (SLACK_APP_TOKEN, xapp-…) needs no public URL; webhook mode
|
||||
// (SLACK_SIGNING_SECRET) needs a public Request URL. The adapter picks the mode
|
||||
// purely from SLACK_APP_TOKEN's presence — this choice just decides which
|
||||
// credential to collect and which post-install guidance to show.
|
||||
type SlackMode = 'socket' | 'webhook';
|
||||
|
||||
// displayName is reserved for when we start wiring the first agent here.
|
||||
// Kept to match the `run<X>Channel(displayName)` signature every other
|
||||
// channel driver uses, so auto.ts can dispatch without a branch.
|
||||
export async function runSlackChannel(_displayName: string): Promise<void> {
|
||||
await walkThroughAppCreation();
|
||||
const mode = await askSlackMode();
|
||||
await walkThroughAppCreation(mode);
|
||||
|
||||
const token = await collectBotToken();
|
||||
const signingSecret = await collectSigningSecret();
|
||||
const appToken = mode === 'socket' ? await collectAppToken() : undefined;
|
||||
const signingSecret = mode === 'webhook' ? await collectSigningSecret() : undefined;
|
||||
const info = await validateSlackToken(token);
|
||||
|
||||
const env: Record<string, string> = { SLACK_BOT_TOKEN: token };
|
||||
if (appToken) env.SLACK_APP_TOKEN = appToken;
|
||||
if (signingSecret) env.SLACK_SIGNING_SECRET = signingSecret;
|
||||
|
||||
const install = await runQuietChild(
|
||||
'slack-install',
|
||||
'bash',
|
||||
@@ -59,11 +74,9 @@ export async function runSlackChannel(_displayName: string): Promise<void> {
|
||||
done: 'Slack adapter installed.',
|
||||
},
|
||||
{
|
||||
env: {
|
||||
SLACK_BOT_TOKEN: token,
|
||||
SLACK_SIGNING_SECRET: signingSecret,
|
||||
},
|
||||
env,
|
||||
extraFields: {
|
||||
MODE: mode,
|
||||
BOT_NAME: info.botName,
|
||||
TEAM_NAME: info.teamName,
|
||||
TEAM_ID: info.teamId,
|
||||
@@ -71,21 +84,52 @@ export async function runSlackChannel(_displayName: string): Promise<void> {
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
await fail(
|
||||
'slack-install',
|
||||
"Couldn't connect Slack.",
|
||||
'See logs/setup-steps/ for details, then retry setup.',
|
||||
);
|
||||
await fail('slack-install', "Couldn't connect Slack.", 'See logs/setup-steps/ for details, then retry setup.');
|
||||
}
|
||||
|
||||
showPostInstallChecklist(info);
|
||||
showPostInstallChecklist(info, mode);
|
||||
}
|
||||
|
||||
async function walkThroughAppCreation(): Promise<void> {
|
||||
async function askSlackMode(): Promise<SlackMode> {
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect<SlackMode>({
|
||||
message: 'How should Slack deliver events to NanoClaw?',
|
||||
initialValue: 'socket',
|
||||
options: [
|
||||
{
|
||||
value: 'socket',
|
||||
label: 'Socket Mode',
|
||||
hint: 'no public URL — recommended for local or behind NAT',
|
||||
},
|
||||
{
|
||||
value: 'webhook',
|
||||
label: 'Public webhook',
|
||||
hint: 'needs a public HTTPS Request URL',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
setupLog.userInput('slack_mode', String(choice));
|
||||
return choice;
|
||||
}
|
||||
|
||||
async function walkThroughAppCreation(mode: SlackMode): Promise<void> {
|
||||
const credSteps =
|
||||
mode === 'socket'
|
||||
? [
|
||||
' 4. Basic Information → App-Level Tokens → "Generate Token and',
|
||||
' Scopes" → add the connections:write scope → copy it (xapp-…)',
|
||||
' 5. Socket Mode → toggle "Enable Socket Mode" on',
|
||||
' 6. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
|
||||
]
|
||||
: [
|
||||
' 4. Basic Information → copy the "Signing Secret"',
|
||||
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
|
||||
];
|
||||
p.note(
|
||||
[
|
||||
"You'll create a Slack app that the assistant talks through.",
|
||||
"Free and stays inside the workspaces you pick.",
|
||||
'Free and stays inside the workspaces you pick.',
|
||||
'',
|
||||
' 1. Create a new app "From scratch", name it, pick a workspace',
|
||||
' 2. OAuth & Permissions → add Bot Token Scopes:',
|
||||
@@ -93,8 +137,7 @@ async function walkThroughAppCreation(): Promise<void> {
|
||||
' channels:read, groups:read, users:read, reactions:write',
|
||||
' 3. App Home → enable "Messages Tab" and "Allow users to send',
|
||||
' slash commands and messages from the messages tab"',
|
||||
' 4. Basic Information → copy the "Signing Secret"',
|
||||
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
|
||||
...credSteps,
|
||||
'',
|
||||
k.dim(SLACK_APPS_URL),
|
||||
].join('\n'),
|
||||
@@ -104,7 +147,7 @@ async function walkThroughAppCreation(): Promise<void> {
|
||||
|
||||
ensureAnswer(
|
||||
await p.confirm({
|
||||
message: 'Got your bot token and signing secret?',
|
||||
message: mode === 'socket' ? 'Got your bot token and app-level token?' : 'Got your bot token and signing secret?',
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
@@ -124,10 +167,7 @@ async function collectBotToken(): Promise<string> {
|
||||
}),
|
||||
);
|
||||
const token = (answer as string).trim();
|
||||
setupLog.userInput(
|
||||
'slack_bot_token',
|
||||
`${token.slice(0, 10)}…${token.slice(-4)}`,
|
||||
);
|
||||
setupLog.userInput('slack_bot_token', `${token.slice(0, 10)}…${token.slice(-4)}`);
|
||||
return token;
|
||||
}
|
||||
|
||||
@@ -148,13 +188,28 @@ async function collectSigningSecret(): Promise<string> {
|
||||
}),
|
||||
);
|
||||
const secret = (answer as string).trim();
|
||||
setupLog.userInput(
|
||||
'slack_signing_secret',
|
||||
`${secret.slice(0, 4)}…${secret.slice(-4)}`,
|
||||
);
|
||||
setupLog.userInput('slack_signing_secret', `${secret.slice(0, 4)}…${secret.slice(-4)}`);
|
||||
return secret;
|
||||
}
|
||||
|
||||
async function collectAppToken(): Promise<string> {
|
||||
const answer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Paste your Slack app-level token (Socket Mode)',
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'App-level token is required for Socket Mode';
|
||||
if (!t.startsWith('xapp-')) return 'App-level tokens start with xapp-';
|
||||
if (t.length < 24) return "That's shorter than a real Slack app-level token";
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const token = (answer as string).trim();
|
||||
setupLog.userInput('slack_app_token', `${token.slice(0, 10)}…${token.slice(-4)}`);
|
||||
return token;
|
||||
}
|
||||
|
||||
async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
@@ -177,9 +232,7 @@ async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
|
||||
};
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
if (data.ok && data.team && data.user) {
|
||||
s.stop(
|
||||
`Connected to ${data.team} as @${data.user}. ${k.dim(`(${elapsedS}s)`)}`,
|
||||
);
|
||||
s.stop(`Connected to ${data.team} as @${data.user}. ${k.dim(`(${elapsedS}s)`)}`);
|
||||
const info: WorkspaceInfo = {
|
||||
teamName: data.team,
|
||||
teamId: data.team_id ?? '',
|
||||
@@ -213,15 +266,30 @@ async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
|
||||
setupLog.step('slack-validate', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
});
|
||||
await fail(
|
||||
'slack-validate',
|
||||
"Couldn't reach Slack.",
|
||||
'Check your internet connection and retry setup.',
|
||||
);
|
||||
await fail('slack-validate', "Couldn't reach Slack.", 'Check your internet connection and retry setup.');
|
||||
}
|
||||
}
|
||||
|
||||
function showPostInstallChecklist(info: WorkspaceInfo): void {
|
||||
function showPostInstallChecklist(info: WorkspaceInfo, mode: SlackMode): void {
|
||||
if (mode === 'socket') {
|
||||
p.note(
|
||||
wrapForGutter(
|
||||
[
|
||||
`The Slack adapter is installed in Socket Mode and your creds are saved. No public URL needed — ${info.teamName} reaches NanoClaw over an outbound WebSocket.`,
|
||||
'',
|
||||
` 1. DM @${info.botName} from Slack once — that bootstraps the`,
|
||||
' messaging group. Then run `/manage-channels` in `claude` to',
|
||||
' wire an agent to it.',
|
||||
'',
|
||||
' Note: keep the NanoClaw host running to hold the socket open —',
|
||||
' Slack does not retry delivery while it is down.',
|
||||
].join('\n'),
|
||||
6,
|
||||
),
|
||||
'Finish setting up Slack',
|
||||
);
|
||||
return;
|
||||
}
|
||||
p.note(
|
||||
wrapForGutter(
|
||||
[
|
||||
|
||||
@@ -37,7 +37,7 @@ if ! grep -q "import './discord.js';" src/channels/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/discord@4.26.0
|
||||
pnpm install @chat-adapter/discord@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -37,7 +37,7 @@ if ! grep -q "import './gchat.js';" src/channels/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/gchat@4.26.0
|
||||
pnpm install @chat-adapter/gchat@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -37,7 +37,7 @@ if ! grep -q "import './github.js';" src/channels/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/github@4.26.0
|
||||
pnpm install @chat-adapter/github@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -86,7 +86,7 @@ if ! grep -q 'if (config.catchAll) {' src/channels/chat-sdk-bridge.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/linear@4.26.0
|
||||
pnpm install @chat-adapter/linear@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -37,7 +37,7 @@ if ! grep -q "import './slack.js';" src/channels/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/slack@4.26.0
|
||||
pnpm install @chat-adapter/slack@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -37,7 +37,7 @@ if ! grep -q "import './teams.js';" src/channels/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/teams@4.26.0
|
||||
pnpm install @chat-adapter/teams@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -63,7 +63,7 @@ if ! grep -q "'pair-telegram':" setup/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/telegram@4.26.0
|
||||
pnpm install @chat-adapter/telegram@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -37,7 +37,7 @@ if ! grep -q "import './whatsapp-cloud.js';" src/channels/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/whatsapp@4.26.0
|
||||
pnpm install @chat-adapter/whatsapp@4.29.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
@@ -66,7 +66,7 @@ if ! grep -q "'whatsapp-auth':" setup/index.ts; then
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
||||
pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
+19
-25
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Step: whatsapp-auth — standalone WhatsApp (Baileys) authentication.
|
||||
* Step: whatsapp-auth — standalone WhatsApp (Baileys v7) authentication.
|
||||
*
|
||||
* Forked from the channels-branch version so setup:auto's driver can render
|
||||
* the terminal UX itself (inside clack) instead of the step dumping a raw QR
|
||||
@@ -27,7 +27,6 @@
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { createRequire } from 'module';
|
||||
// Named import (not default) — pino's d.ts under NodeNext resolves the
|
||||
// default export to `typeof pino` (namespace), which isn't callable. The
|
||||
// named `pino` export resolves to the callable function.
|
||||
@@ -47,26 +46,23 @@ const AUTH_DIR = path.join(process.cwd(), 'store', 'auth');
|
||||
const PAIRING_CODE_FILE = path.join(process.cwd(), 'store', 'pairing-code.txt');
|
||||
const baileysLogger = pino({ level: 'silent' });
|
||||
|
||||
// Baileys v6 bug: getPlatformId sends charCode (49) instead of enum value (1).
|
||||
// Fixed in Baileys 7.x but not backported. Without this patch pairing codes
|
||||
// fail with "couldn't link device" because WhatsApp receives an invalid
|
||||
// platform id. createRequire because proto is not a named ESM export.
|
||||
const _require = createRequire(import.meta.url);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { proto } = _require('@whiskeysockets/baileys') as { proto: any };
|
||||
try {
|
||||
const _generics = _require(
|
||||
'@whiskeysockets/baileys/lib/Utils/generics',
|
||||
) as Record<string, unknown>;
|
||||
_generics.getPlatformId = (browser: string): string => {
|
||||
const platformType =
|
||||
proto.DeviceProps.PlatformType[
|
||||
browser.toUpperCase() as keyof typeof proto.DeviceProps.PlatformType
|
||||
];
|
||||
return platformType ? platformType.toString() : '1';
|
||||
};
|
||||
} catch {
|
||||
// If CJS require fails, QR auth still works; only pairing code may be affected.
|
||||
/** Fetch current WA Web version — wppconnect tracker, then Baileys sw.js scrape. */
|
||||
async function resolveWaWebVersion(): Promise<[number, number, number]> {
|
||||
try {
|
||||
const res = await fetch('https://wppconnect.io/whatsapp-versions/', {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (res.ok) {
|
||||
const html = await res.text();
|
||||
const match = html.match(/2\.3000\.(\d+)/);
|
||||
if (match) return [2, 3000, Number(match[1])];
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
try {
|
||||
const { version } = await fetchLatestWaWebVersion({});
|
||||
if (version) return version as [number, number, number];
|
||||
} catch { /* fall through */ }
|
||||
throw new Error('Could not fetch current WhatsApp Web version — cannot connect with stale version');
|
||||
}
|
||||
|
||||
type AuthMethod = 'qr' | 'pairing-code';
|
||||
@@ -139,9 +135,7 @@ export async function run(args: string[]): Promise<void> {
|
||||
|
||||
async function connectSocket(isReconnect = false): Promise<void> {
|
||||
const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
|
||||
const { version } = await fetchLatestWaWebVersion({}).catch(() => ({
|
||||
version: undefined,
|
||||
}));
|
||||
const version = await resolveWaWebVersion();
|
||||
|
||||
const sock = makeWASocket({
|
||||
version,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Integration test for the deltachat channel's single reach-in: the
|
||||
* self-registration import in the `src/channels/index.ts` barrel. Importing the
|
||||
* barrel runs deltachat.ts's top-level `registerChannelAdapter('deltachat', …)`;
|
||||
* without the import the channel is silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './deltachat.js';` line is deleted, or the barrel fails to evaluate for
|
||||
* any reason (so the channel genuinely would not register), this goes red. A
|
||||
* structural check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* Importing the barrel is safe: registration is a pure top-level call, and
|
||||
* deltachat.ts only instantiates DeltaChatOverJsonRpc inside setup() (run at host
|
||||
* startup), never at import — so nothing spawns here. It does require the adapter
|
||||
* package to be installed, which holds in a composed install: the skill's
|
||||
* `pnpm install` step runs before this test in the apply flow.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('deltachat channel registration', () => {
|
||||
it('registers deltachat via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('deltachat');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* DeltaChat channel adapter.
|
||||
*
|
||||
* Bridges NanoClaw with DeltaChat via the @deltachat/stdio-rpc-server JSON-RPC
|
||||
* process. Each DeltaChat chat becomes a separate NanoClaw messaging group
|
||||
* (platformId = chatId string, e.g. "12"). No thread model — supportsThreads: false.
|
||||
*
|
||||
* Required env vars (.env): DC_EMAIL, DC_PASSWORD,
|
||||
* DC_IMAP_HOST, DC_IMAP_PORT,
|
||||
* DC_SMTP_HOST, DC_SMTP_PORT
|
||||
* Optional env vars (.env): DC_IMAP_SECURITY (default: "1" = SSL/TLS),
|
||||
* DC_SMTP_SECURITY (default: "2" = STARTTLS)
|
||||
* Security values: 1=SSL/TLS, 2=STARTTLS, 3=plain
|
||||
* Optional env vars (service unit): DC_ACCOUNT_DIR (default: "dc-account"),
|
||||
* DC_DISPLAY_NAME, DC_AVATAR_PATH
|
||||
*/
|
||||
import { existsSync, mkdtempSync, writeFileSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { basename, join, resolve } from 'path';
|
||||
|
||||
import { getDb, hasTable } from '../db/connection.js';
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { log } from '../log.js';
|
||||
import type { ChannelAdapter, ChannelSetup, OutboundMessage } from './adapter.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
import { DeltaChatOverJsonRpc } from '@deltachat/stdio-rpc-server';
|
||||
|
||||
const REQUIRED_ENV = [
|
||||
'DC_EMAIL',
|
||||
'DC_PASSWORD',
|
||||
'DC_IMAP_HOST',
|
||||
'DC_IMAP_PORT',
|
||||
'DC_SMTP_HOST',
|
||||
'DC_SMTP_PORT',
|
||||
] as const;
|
||||
|
||||
const OPTIONAL_ENV = ['DC_IMAP_SECURITY', 'DC_SMTP_SECURITY'] as const;
|
||||
|
||||
type DcEnv = { [K in (typeof REQUIRED_ENV)[number]]: string } & { [K in (typeof OPTIONAL_ENV)[number]]?: string };
|
||||
|
||||
function isDcAdmin(userId: string): boolean {
|
||||
try {
|
||||
const db = getDb();
|
||||
if (!hasTable(db, 'user_roles')) return true;
|
||||
return (
|
||||
db
|
||||
.prepare(
|
||||
`SELECT 1 FROM user_roles
|
||||
WHERE user_id = ?
|
||||
AND (role = 'owner' OR role = 'admin')
|
||||
AND agent_group_id IS NULL
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(userId) != null
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createAdapter(env: DcEnv): ChannelAdapter {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let dc: any = null;
|
||||
let accountId = 0;
|
||||
let connectivity = 0;
|
||||
let lastImapIdleTs = Date.now();
|
||||
let consecutiveBadChecks = 0;
|
||||
let watchdogTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let networkTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async function restartIo(reason: string): Promise<void> {
|
||||
log.warn('DeltaChat: restarting IO', { reason });
|
||||
try {
|
||||
await dc.rpc.stopIo(accountId);
|
||||
await dc.rpc.startIo(accountId);
|
||||
lastImapIdleTs = Date.now();
|
||||
consecutiveBadChecks = 0;
|
||||
} catch (err) {
|
||||
log.error('DeltaChat: IO restart failed', { err });
|
||||
}
|
||||
}
|
||||
|
||||
const adapter: ChannelAdapter = {
|
||||
name: 'deltachat',
|
||||
channelType: 'deltachat',
|
||||
supportsThreads: false,
|
||||
|
||||
async setup(config: ChannelSetup): Promise<void> {
|
||||
const accountDir = process.env.DC_ACCOUNT_DIR ?? 'dc-account';
|
||||
dc = new DeltaChatOverJsonRpc(accountDir, {});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
dc.on('Error', (_: any, event: any) => log.error('DeltaChat RPC error', { msg: event.msg ?? event }));
|
||||
|
||||
const accounts = await dc.rpc.getAllAccounts();
|
||||
accountId = accounts[0]?.id;
|
||||
if (!accountId) accountId = await dc.rpc.addAccount();
|
||||
|
||||
const imapSecurity = env.DC_IMAP_SECURITY ?? '1';
|
||||
const smtpSecurity = env.DC_SMTP_SECURITY ?? '2';
|
||||
|
||||
if (!(await dc.rpc.isConfigured(accountId))) {
|
||||
await dc.rpc.setConfig(accountId, 'addr', env.DC_EMAIL);
|
||||
await dc.rpc.setConfig(accountId, 'mail_pw', env.DC_PASSWORD);
|
||||
await dc.rpc.setConfig(accountId, 'mail_server', env.DC_IMAP_HOST);
|
||||
await dc.rpc.setConfig(accountId, 'mail_port', env.DC_IMAP_PORT);
|
||||
await dc.rpc.setConfig(accountId, 'send_server', env.DC_SMTP_HOST);
|
||||
await dc.rpc.setConfig(accountId, 'send_port', env.DC_SMTP_PORT);
|
||||
await dc.rpc.configure(accountId);
|
||||
log.info('DeltaChat: account configured', { email: env.DC_EMAIL });
|
||||
} else {
|
||||
log.info('DeltaChat: account ready', { email: env.DC_EMAIL });
|
||||
}
|
||||
|
||||
await dc.rpc.setConfig(accountId, 'mail_security', imapSecurity);
|
||||
await dc.rpc.setConfig(accountId, 'send_security', smtpSecurity);
|
||||
await dc.rpc.setConfig(accountId, 'displayname', process.env.DC_DISPLAY_NAME ?? 'NanoClaw');
|
||||
const avatarPath = process.env.DC_AVATAR_PATH;
|
||||
if (avatarPath && existsSync(avatarPath)) {
|
||||
await dc.rpc.setConfig(accountId, 'selfavatar', avatarPath);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
dc.on('IncomingMsg', async (contextId: number, event: any) => {
|
||||
if (contextId !== accountId) return;
|
||||
try {
|
||||
let msg = await dc.rpc.getMessage(accountId, event.msgId);
|
||||
if (msg.isInfo) return;
|
||||
|
||||
// Wait for large-message download to complete
|
||||
if (msg.downloadState !== 'Done') {
|
||||
await dc.rpc.downloadFullMessage(accountId, event.msgId);
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
msg = await dc.rpc.getMessage(accountId, event.msgId);
|
||||
if (msg.downloadState === 'Done') break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!msg.text && !msg.file) return;
|
||||
|
||||
const contact = await dc.rpc.getContact(accountId, msg.fromId);
|
||||
const chat = await dc.rpc.getBasicChatInfo(accountId, event.chatId);
|
||||
|
||||
if (/^\/set-avatar$/i.test((msg.text || '').trim()) && msg.file) {
|
||||
const userId = `deltachat:${contact.address}`;
|
||||
try {
|
||||
if (isDcAdmin(userId)) {
|
||||
const absPath = resolve(msg.file as string);
|
||||
await dc.rpc.setConfig(accountId, 'selfavatar', absPath);
|
||||
await dc.rpc.sendMsg(accountId, event.chatId, { text: 'Avatar updated.' });
|
||||
} else {
|
||||
await dc.rpc.sendMsg(accountId, event.chatId, { text: 'Permission denied.' });
|
||||
}
|
||||
} catch (avatarErr: unknown) {
|
||||
log.error('DeltaChat: failed to set avatar', {
|
||||
err: avatarErr instanceof Error ? avatarErr.message : JSON.stringify(avatarErr),
|
||||
});
|
||||
await dc.rpc.sendMsg(accountId, event.chatId, { text: 'Failed to set avatar.' }).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const content: Record<string, unknown> = {
|
||||
text: msg.text || '',
|
||||
sender: contact.displayName || contact.address,
|
||||
senderId: contact.address,
|
||||
};
|
||||
if (msg.file) {
|
||||
content.attachments = [
|
||||
{
|
||||
name: basename(msg.file as string),
|
||||
type: 'file',
|
||||
localPath: msg.file,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const isGroup = chat?.isGroup ?? false;
|
||||
await config.onInbound(String(event.chatId), null, {
|
||||
id: String(event.msgId),
|
||||
kind: 'chat',
|
||||
content,
|
||||
timestamp: new Date().toISOString(),
|
||||
isGroup,
|
||||
isMention: !isGroup,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
log.error('DeltaChat: error handling incoming message', {
|
||||
err: err instanceof Error ? err.message : JSON.stringify(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
dc.on('ImapInboxIdle', (contextId: number) => {
|
||||
if (contextId === accountId) lastImapIdleTs = Date.now();
|
||||
});
|
||||
|
||||
dc.on('ConnectivityChanged', async (contextId: number) => {
|
||||
if (contextId !== accountId) return;
|
||||
try {
|
||||
connectivity = await dc.rpc.getConnectivity(accountId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
await dc.rpc.startIo(accountId);
|
||||
try {
|
||||
connectivity = await dc.rpc.getConnectivity(accountId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
log.info('DeltaChat: IO started', { email: env.DC_EMAIL });
|
||||
|
||||
// Log invite link on every startup so the operator can bootstrap the first contact.
|
||||
// In DeltaChat, contacts can't simply be added by email — the user must open this
|
||||
// https://i.delta.chat/ invite URL in their DeltaChat app (or scan invite-qr.svg) to initiate contact.
|
||||
try {
|
||||
// null chatId → Setup-Contact invite (not group-specific)
|
||||
const [inviteUrl, svg] = await dc.rpc.getChatSecurejoinQrCodeSvg(accountId, null);
|
||||
const accountDir = resolve(process.env.DC_ACCOUNT_DIR ?? 'dc-account');
|
||||
const svgPath = join(accountDir, 'invite-qr.svg');
|
||||
writeFileSync(svgPath, svg);
|
||||
log.info('DeltaChat: invite link — open URL in DeltaChat app or scan ' + svgPath, { url: inviteUrl });
|
||||
} catch (err: unknown) {
|
||||
log.warn('DeltaChat: could not generate invite link', {
|
||||
err: err instanceof Error ? err.message : JSON.stringify(err),
|
||||
});
|
||||
}
|
||||
|
||||
// Connectivity watchdog: restart IO if IMAP goes quiet or connectivity drops
|
||||
watchdogTimer = setInterval(
|
||||
async () => {
|
||||
try {
|
||||
const conn = await dc.rpc.getConnectivity(accountId);
|
||||
connectivity = conn;
|
||||
if (conn < 3000) {
|
||||
consecutiveBadChecks++;
|
||||
if (consecutiveBadChecks >= 2) {
|
||||
await restartIo(`connectivity=${conn} for 2 consecutive checks`);
|
||||
}
|
||||
} else {
|
||||
consecutiveBadChecks = 0;
|
||||
}
|
||||
const idleAgeMin = (Date.now() - lastImapIdleTs) / 60000;
|
||||
if (idleAgeMin > 20) {
|
||||
await restartIo(`no IMAP IDLE in ${idleAgeMin.toFixed(0)}min`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
log.warn('DeltaChat: watchdog error', {
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
|
||||
// Nudge the network stack every 10 minutes (recovers from prolonged idle)
|
||||
networkTimer = setInterval(
|
||||
async () => {
|
||||
try {
|
||||
await dc.rpc.maybeNetwork();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
10 * 60 * 1000,
|
||||
);
|
||||
},
|
||||
|
||||
async teardown(): Promise<void> {
|
||||
if (watchdogTimer) clearInterval(watchdogTimer);
|
||||
if (networkTimer) clearInterval(networkTimer);
|
||||
try {
|
||||
await dc?.rpc.stopIo(accountId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
dc?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
|
||||
isConnected(): boolean {
|
||||
// 4000 = fully connected (IMAP), 3000 = connecting; treat ≥3000 as live
|
||||
return connectivity >= 3000;
|
||||
},
|
||||
|
||||
async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise<string | undefined> {
|
||||
const chatId = parseInt(platformId, 10);
|
||||
if (isNaN(chatId)) {
|
||||
log.warn('DeltaChat: invalid platformId for delivery', { platformId });
|
||||
return undefined;
|
||||
}
|
||||
const content = message.content as Record<string, unknown>;
|
||||
const text = typeof content.text === 'string' ? content.text : '';
|
||||
|
||||
if (message.files && message.files.length > 0) {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'nanoclaw-dc-'));
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let firstId: any;
|
||||
for (let i = 0; i < message.files.length; i++) {
|
||||
const f = message.files[i];
|
||||
const tempPath = join(tempDir, f.filename);
|
||||
writeFileSync(tempPath, f.data);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const params: any = { file: tempPath };
|
||||
if (i === 0 && text) params.text = text;
|
||||
const sentId = await dc.rpc.sendMsg(accountId, chatId, params);
|
||||
if (i === 0) firstId = sentId;
|
||||
}
|
||||
return firstId != null ? String(firstId) : undefined;
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (!text) return undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sentId: any = await dc.rpc.sendMsg(accountId, chatId, { text });
|
||||
return sentId != null ? String(sentId) : undefined;
|
||||
},
|
||||
};
|
||||
|
||||
return adapter;
|
||||
}
|
||||
|
||||
registerChannelAdapter('deltachat', {
|
||||
factory: () => {
|
||||
const env = readEnvFile([...REQUIRED_ENV, ...OPTIONAL_ENV]);
|
||||
if (!env.DC_EMAIL || !env.DC_PASSWORD) return null;
|
||||
return createAdapter(env as DcEnv);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Integration test for the discord channel's single reach-in: the self-registration
|
||||
* import in the `src/channels/index.ts` barrel. Importing the barrel runs discord.ts's
|
||||
* top-level `registerChannelAdapter('discord', …)`; without the import the channel is
|
||||
* silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './discord.js';` line is deleted, or the barrel fails to evaluate for any
|
||||
* reason (so the channel genuinely would not register), this goes red. A structural
|
||||
* check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* Importing the barrel is safe: registration is a pure top-level call, and discord.ts
|
||||
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
|
||||
* never at import. It does require the adapter package (`@chat-adapter/discord`) to be installed,
|
||||
* which holds in a composed install: the skill's `pnpm install` step runs before this
|
||||
* test — so this test also implicitly guards that dependency (an unmocked import throws
|
||||
* if the package is missing).
|
||||
*
|
||||
* discord is a Chat SDK channel: discord.ts also consumes a load-bearing *core* API —
|
||||
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
|
||||
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
|
||||
* drift, not this test. Every Chat SDK channel follows this same shape.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('discord channel registration', () => {
|
||||
it('registers discord via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('discord');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Integration test for the emacs channel's single reach-in: the self-registration
|
||||
* import in the `src/channels/index.ts` barrel. Importing the barrel runs emacs.ts's
|
||||
* top-level `registerChannelAdapter('emacs', …)`; without the import the channel is
|
||||
* silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './emacs.js';` line is deleted, or the barrel fails to evaluate for any
|
||||
* reason (so the channel genuinely would not register), this goes red. A structural
|
||||
* check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* emacs is a native adapter with no npm dependency (it uses the Node http builtin); it talks to an Emacs HTTP client.
|
||||
* Importing the barrel is safe: registration is a pure top-level call and emacs.ts
|
||||
* opens connections / spawns subprocesses only inside setup() (run at host startup),
|
||||
* never at import. There is no adapter package to guard here — this test guards the
|
||||
* one barrel reach-in (red if `import './emacs.js';` is deleted or the barrel fails
|
||||
* to evaluate).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('emacs channel registration', () => {
|
||||
it('registers emacs via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('emacs');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Integration test for the gchat channel's single reach-in: the self-registration
|
||||
* import in the `src/channels/index.ts` barrel. Importing the barrel runs gchat.ts's
|
||||
* top-level `registerChannelAdapter('gchat', …)`; without the import the channel is
|
||||
* silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './gchat.js';` line is deleted, or the barrel fails to evaluate for any
|
||||
* reason (so the channel genuinely would not register), this goes red. A structural
|
||||
* check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* Importing the barrel is safe: registration is a pure top-level call, and gchat.ts
|
||||
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
|
||||
* never at import. It does require the adapter package (`@chat-adapter/gchat`) to be installed,
|
||||
* which holds in a composed install: the skill's `pnpm install` step runs before this
|
||||
* test — so this test also implicitly guards that dependency (an unmocked import throws
|
||||
* if the package is missing).
|
||||
*
|
||||
* gchat is a Chat SDK channel: gchat.ts also consumes a load-bearing *core* API —
|
||||
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
|
||||
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
|
||||
* drift, not this test. Every Chat SDK channel follows this same shape.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('gchat channel registration', () => {
|
||||
it('registers gchat via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('gchat');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Integration test for the github channel's single reach-in: the self-registration
|
||||
* import in the `src/channels/index.ts` barrel. Importing the barrel runs github.ts's
|
||||
* top-level `registerChannelAdapter('github', …)`; without the import the channel is
|
||||
* silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './github.js';` line is deleted, or the barrel fails to evaluate for any
|
||||
* reason (so the channel genuinely would not register), this goes red. A structural
|
||||
* check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* Importing the barrel is safe: registration is a pure top-level call, and github.ts
|
||||
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
|
||||
* never at import. It does require the adapter package (`@chat-adapter/github`) to be installed,
|
||||
* which holds in a composed install: the skill's `pnpm install` step runs before this
|
||||
* test — so this test also implicitly guards that dependency (an unmocked import throws
|
||||
* if the package is missing).
|
||||
*
|
||||
* github is a Chat SDK channel: github.ts also consumes a load-bearing *core* API —
|
||||
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
|
||||
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
|
||||
* drift, not this test. Every Chat SDK channel follows this same shape.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('github channel registration', () => {
|
||||
it('registers github via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('github');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Integration test for the imessage channel's single reach-in: the self-registration
|
||||
* import in the `src/channels/index.ts` barrel. Importing the barrel runs imessage.ts's
|
||||
* top-level `registerChannelAdapter('imessage', …)`; without the import the channel is
|
||||
* silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './imessage.js';` line is deleted, or the barrel fails to evaluate for any
|
||||
* reason (so the channel genuinely would not register), this goes red. A structural
|
||||
* check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* Importing the barrel is safe: registration is a pure top-level call, and imessage.ts
|
||||
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
|
||||
* never at import. It does require the adapter package (`chat-adapter-imessage`) to be installed,
|
||||
* which holds in a composed install: the skill's `pnpm install` step runs before this
|
||||
* test — so this test also implicitly guards that dependency (an unmocked import throws
|
||||
* if the package is missing).
|
||||
*
|
||||
* imessage is a Chat SDK channel: imessage.ts also consumes a load-bearing *core* API —
|
||||
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
|
||||
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
|
||||
* drift, not this test. Every Chat SDK channel follows this same shape.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('imessage channel registration', () => {
|
||||
it('registers imessage via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('imessage');
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Integration test for the linear channel's single reach-in: the self-registration
|
||||
* import in the `src/channels/index.ts` barrel. Importing the barrel runs linear.ts's
|
||||
* top-level `registerChannelAdapter('linear', …)`; without the import the channel is
|
||||
* silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './linear.js';` line is deleted, or the barrel fails to evaluate for any
|
||||
* reason (so the channel genuinely would not register), this goes red. A structural
|
||||
* check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* Importing the barrel is safe: registration is a pure top-level call, and linear.ts
|
||||
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
|
||||
* never at import. It does require the adapter package (`@chat-adapter/linear`) to be installed,
|
||||
* which holds in a composed install: the skill's `pnpm install` step runs before this
|
||||
* test — so this test also implicitly guards that dependency (an unmocked import throws
|
||||
* if the package is missing).
|
||||
*
|
||||
* linear is a Chat SDK channel: linear.ts also consumes a load-bearing *core* API —
|
||||
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
|
||||
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
|
||||
* drift, not this test. Every Chat SDK channel follows this same shape.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('linear channel registration', () => {
|
||||
it('registers linear via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('linear');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Integration test for the matrix channel's single reach-in: the self-registration
|
||||
* import in the `src/channels/index.ts` barrel. Importing the barrel runs matrix.ts's
|
||||
* top-level `registerChannelAdapter('matrix', …)`; without the import the channel is
|
||||
* silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './matrix.js';` line is deleted, or the barrel fails to evaluate for any
|
||||
* reason (so the channel genuinely would not register), this goes red. A structural
|
||||
* check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* Importing the barrel is safe: registration is a pure top-level call, and matrix.ts
|
||||
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
|
||||
* never at import. It does require the adapter package (`@beeper/chat-adapter-matrix`) to be installed,
|
||||
* which holds in a composed install: the skill's `pnpm install` step runs before this
|
||||
* test — so this test also implicitly guards that dependency (an unmocked import throws
|
||||
* if the package is missing).
|
||||
*
|
||||
* matrix is a Chat SDK channel: matrix.ts also consumes a load-bearing *core* API —
|
||||
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
|
||||
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
|
||||
* drift, not this test. Every Chat SDK channel follows this same shape.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('matrix channel registration', () => {
|
||||
it('registers matrix via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('matrix');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Integration test for the resend channel's single reach-in: the self-registration
|
||||
* import in the `src/channels/index.ts` barrel. Importing the barrel runs resend.ts's
|
||||
* top-level `registerChannelAdapter('resend', …)`; without the import the channel is
|
||||
* silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './resend.js';` line is deleted, or the barrel fails to evaluate for any
|
||||
* reason (so the channel genuinely would not register), this goes red. A structural
|
||||
* check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* Importing the barrel is safe: registration is a pure top-level call, and resend.ts
|
||||
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
|
||||
* never at import. It does require the adapter package (`@resend/chat-sdk-adapter`) to be installed,
|
||||
* which holds in a composed install: the skill's `pnpm install` step runs before this
|
||||
* test — so this test also implicitly guards that dependency (an unmocked import throws
|
||||
* if the package is missing).
|
||||
*
|
||||
* resend is a Chat SDK channel: resend.ts also consumes a load-bearing *core* API —
|
||||
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
|
||||
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
|
||||
* drift, not this test. Every Chat SDK channel follows this same shape.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('resend channel registration', () => {
|
||||
it('registers resend via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('resend');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Integration test for the signal channel's single reach-in: the self-registration
|
||||
* import in the `src/channels/index.ts` barrel. Importing the barrel runs signal.ts's
|
||||
* top-level `registerChannelAdapter('signal', …)`; without the import the channel is
|
||||
* silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './signal.js';` line is deleted, or the barrel fails to evaluate for any
|
||||
* reason (so the channel genuinely would not register), this goes red. A structural
|
||||
* check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* signal is a native adapter with no npm dependency (it drives the external signal-cli binary over a local TCP socket); it talks to signal-cli.
|
||||
* Importing the barrel is safe: registration is a pure top-level call and signal.ts
|
||||
* opens connections / spawns subprocesses only inside setup() (run at host startup),
|
||||
* never at import. There is no adapter package to guard here — this test guards the
|
||||
* one barrel reach-in (red if `import './signal.js';` is deleted or the barrel fails
|
||||
* to evaluate).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('signal channel registration', () => {
|
||||
it('registers signal via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('signal');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Integration test for the slack channel's single reach-in: the self-registration
|
||||
* import in the `src/channels/index.ts` barrel. Importing the barrel runs slack.ts's
|
||||
* top-level `registerChannelAdapter('slack', …)`; without the import the channel is
|
||||
* silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './slack.js';` line is deleted, or the barrel fails to evaluate for any
|
||||
* reason (so the channel genuinely would not register), this goes red. A structural
|
||||
* check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* Importing the barrel is safe: registration is a pure top-level call, and slack.ts
|
||||
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
|
||||
* never at import. It does require the adapter package to be installed, which holds
|
||||
* in a composed install: the skill's `pnpm install` step runs before this test.
|
||||
*
|
||||
* Note on the Chat SDK family: slack.ts also consumes a load-bearing *core* API —
|
||||
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js — with a specific options
|
||||
* shape. That core-consumption is a typed call, so the build/typecheck leg
|
||||
* (`pnpm run build`) guards it against upstream drift, not this test. Every Chat SDK
|
||||
* channel (discord, telegram, teams, gchat, webex, …) follows this same shape:
|
||||
* swap the channel name below and the adapter package in the build.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('slack channel registration', () => {
|
||||
it('registers slack via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('slack');
|
||||
});
|
||||
});
|
||||
+10
-1
@@ -1,6 +1,9 @@
|
||||
/**
|
||||
* Slack channel adapter (v2) — uses Chat SDK bridge.
|
||||
* Self-registers on import.
|
||||
*
|
||||
* Socket Mode opt-in: set SLACK_APP_TOKEN (xapp-…) to receive events over an
|
||||
* outbound WebSocket instead of an inbound HTTPS webhook.
|
||||
*/
|
||||
import { createSlackAdapter } from '@chat-adapter/slack';
|
||||
|
||||
@@ -10,11 +13,17 @@ import { registerChannelAdapter } from './channel-registry.js';
|
||||
|
||||
registerChannelAdapter('slack', {
|
||||
factory: () => {
|
||||
const env = readEnvFile(['SLACK_BOT_TOKEN', 'SLACK_SIGNING_SECRET']);
|
||||
const env = readEnvFile(['SLACK_BOT_TOKEN', 'SLACK_SIGNING_SECRET', 'SLACK_APP_TOKEN']);
|
||||
if (!env.SLACK_BOT_TOKEN) return null;
|
||||
// SLACK_APP_TOKEN (xapp-…) enables Socket Mode: events arrive over an
|
||||
// outbound WebSocket, so no public HTTPS endpoint is required. When set,
|
||||
// the signing secret is optional (Slack signs socket frames separately).
|
||||
const useSocketMode = Boolean(env.SLACK_APP_TOKEN);
|
||||
const slackAdapter = createSlackAdapter({
|
||||
botToken: env.SLACK_BOT_TOKEN,
|
||||
signingSecret: env.SLACK_SIGNING_SECRET,
|
||||
appToken: env.SLACK_APP_TOKEN,
|
||||
mode: useSocketMode ? 'socket' : 'webhook',
|
||||
});
|
||||
const bridge = createChatSdkBridge({ adapter: slackAdapter, concurrency: 'concurrent', supportsThreads: true });
|
||||
bridge.resolveChannelName = async (platformId: string) => {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Integration test for the teams channel's single reach-in: the self-registration
|
||||
* import in the `src/channels/index.ts` barrel. Importing the barrel runs teams.ts's
|
||||
* top-level `registerChannelAdapter('teams', …)`; without the import the channel is
|
||||
* silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './teams.js';` line is deleted, or the barrel fails to evaluate for any
|
||||
* reason (so the channel genuinely would not register), this goes red. A structural
|
||||
* check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* Importing the barrel is safe: registration is a pure top-level call, and teams.ts
|
||||
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
|
||||
* never at import. It does require the adapter package (`@chat-adapter/teams`) to be installed,
|
||||
* which holds in a composed install: the skill's `pnpm install` step runs before this
|
||||
* test — so this test also implicitly guards that dependency (an unmocked import throws
|
||||
* if the package is missing).
|
||||
*
|
||||
* teams is a Chat SDK channel: teams.ts also consumes a load-bearing *core* API —
|
||||
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
|
||||
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
|
||||
* drift, not this test. Every Chat SDK channel follows this same shape.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('teams channel registration', () => {
|
||||
it('registers teams via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('teams');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Integration test for the telegram channel's single reach-in: the self-registration
|
||||
* import in the `src/channels/index.ts` barrel. Importing the barrel runs telegram.ts's
|
||||
* top-level `registerChannelAdapter('telegram', …)`; without the import the channel is
|
||||
* silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './telegram.js';` line is deleted, or the barrel fails to evaluate for any
|
||||
* reason (so the channel genuinely would not register), this goes red. A structural
|
||||
* check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* Importing the barrel is safe: registration is a pure top-level call, and telegram.ts
|
||||
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
|
||||
* never at import. It does require the adapter package (`@chat-adapter/telegram`) to be installed,
|
||||
* which holds in a composed install: the skill's `pnpm install` step runs before this
|
||||
* test — so this test also implicitly guards that dependency (an unmocked import throws
|
||||
* if the package is missing).
|
||||
*
|
||||
* telegram is a Chat SDK channel: telegram.ts also consumes a load-bearing *core* API —
|
||||
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
|
||||
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
|
||||
* drift, not this test. Every Chat SDK channel follows this same shape.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('telegram channel registration', () => {
|
||||
it('registers telegram via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('telegram');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Integration test for the webex channel's single reach-in: the self-registration
|
||||
* import in the `src/channels/index.ts` barrel. Importing the barrel runs webex.ts's
|
||||
* top-level `registerChannelAdapter('webex', …)`; without the import the channel is
|
||||
* silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './webex.js';` line is deleted, or the barrel fails to evaluate for any
|
||||
* reason (so the channel genuinely would not register), this goes red. A structural
|
||||
* check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* Importing the barrel is safe: registration is a pure top-level call, and webex.ts
|
||||
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
|
||||
* never at import. It does require the adapter package (`@bitbasti/chat-adapter-webex`) to be installed,
|
||||
* which holds in a composed install: the skill's `pnpm install` step runs before this
|
||||
* test — so this test also implicitly guards that dependency (an unmocked import throws
|
||||
* if the package is missing).
|
||||
*
|
||||
* webex is a Chat SDK channel: webex.ts also consumes a load-bearing *core* API —
|
||||
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
|
||||
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
|
||||
* drift, not this test. Every Chat SDK channel follows this same shape.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('webex channel registration', () => {
|
||||
it('registers webex via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('webex');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Integration test for the wechat channel's single reach-in: the self-registration
|
||||
* import in the `src/channels/index.ts` barrel. Importing the barrel runs wechat.ts's
|
||||
* top-level `registerChannelAdapter('wechat', …)`; without the import the channel is
|
||||
* silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './wechat.js';` line is deleted, or the barrel fails to evaluate for any
|
||||
* reason (so the channel genuinely would not register), this goes red. A structural
|
||||
* check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* wechat is a native adapter (no Chat SDK bridge). Importing the barrel is safe:
|
||||
* registration is a pure top-level call and wechat.ts opens connections / spawns
|
||||
* subprocesses only inside setup() (run at host startup), never at import. It does
|
||||
* require the adapter package (`wechat-ilink-client`) to be installed, which holds in a composed
|
||||
* install: the skill's `pnpm install` step runs before this test — so this test also
|
||||
* implicitly guards that dependency (an unmocked import throws if the package is missing).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('wechat channel registration', () => {
|
||||
it('registers wechat via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('wechat');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Integration test for the whatsapp-cloud channel's single reach-in: the self-registration
|
||||
* import in the `src/channels/index.ts` barrel. Importing the barrel runs whatsapp-cloud.ts's
|
||||
* top-level `registerChannelAdapter('whatsapp-cloud', …)`; without the import the channel is
|
||||
* silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './whatsapp-cloud.js';` line is deleted, or the barrel fails to evaluate for any
|
||||
* reason (so the channel genuinely would not register), this goes red. A structural
|
||||
* check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* Importing the barrel is safe: registration is a pure top-level call, and whatsapp-cloud.ts
|
||||
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
|
||||
* never at import. It does require the adapter package (`@chat-adapter/whatsapp`) to be installed,
|
||||
* which holds in a composed install: the skill's `pnpm install` step runs before this
|
||||
* test — so this test also implicitly guards that dependency (an unmocked import throws
|
||||
* if the package is missing).
|
||||
*
|
||||
* whatsapp-cloud is a Chat SDK channel: whatsapp-cloud.ts also consumes a load-bearing *core* API —
|
||||
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
|
||||
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
|
||||
* drift, not this test. Every Chat SDK channel follows this same shape.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('whatsapp-cloud channel registration', () => {
|
||||
it('registers whatsapp-cloud via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('whatsapp-cloud');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Integration test for the whatsapp channel's single reach-in: the self-registration
|
||||
* import in the `src/channels/index.ts` barrel. Importing the barrel runs whatsapp.ts's
|
||||
* top-level `registerChannelAdapter('whatsapp', …)`; without the import the channel is
|
||||
* silently absent.
|
||||
*
|
||||
* Behavior, not structural: it imports the real barrel and asserts the registry
|
||||
* actually contains the channel. This reflects what happens at host boot — if the
|
||||
* `import './whatsapp.js';` line is deleted, or the barrel fails to evaluate for any
|
||||
* reason (so the channel genuinely would not register), this goes red. A structural
|
||||
* check of the import line would falsely pass in that second case.
|
||||
*
|
||||
* whatsapp is a native adapter (no Chat SDK bridge). Importing the barrel is safe:
|
||||
* registration is a pure top-level call and whatsapp.ts opens connections / spawns
|
||||
* subprocesses only inside setup() (run at host startup), never at import. It does
|
||||
* require the adapter package (`@whiskeysockets/baileys`) to be installed, which holds in a composed
|
||||
* install: the skill's `pnpm install` step runs before this test — so this test also
|
||||
* implicitly guards that dependency (an unmocked import throws if the package is missing).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getRegisteredChannelNames } from './channel-registry.js';
|
||||
import './index.js'; // the real barrel — triggers every channel's self-registration
|
||||
|
||||
describe('whatsapp channel registration', () => {
|
||||
it('registers whatsapp via the channel barrel', () => {
|
||||
expect(getRegisteredChannelNames()).toContain('whatsapp');
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
+267
-70
@@ -1,10 +1,15 @@
|
||||
/**
|
||||
* WhatsApp channel adapter (v2) — native Baileys v6 implementation.
|
||||
* WhatsApp channel adapter (v2) — native Baileys v7 implementation.
|
||||
*
|
||||
* Implements ChannelAdapter directly (no Chat SDK bridge) using
|
||||
* @whiskeysockets/baileys v6 (stable). Ports proven v1 infrastructure:
|
||||
* getMessage fallback, outgoing queue, group metadata cache, LID mapping,
|
||||
* reconnection with backoff.
|
||||
* @whiskeysockets/baileys 7.0.0-rc.9 (pinned — last release, unmaintained).
|
||||
* Ports proven v1 infrastructure: getMessage fallback, outgoing queue,
|
||||
* group metadata cache, LID mapping, reconnection with backoff.
|
||||
*
|
||||
* LID handling: Baileys v7 provides participantAlt / remoteJidAlt on every
|
||||
* inbound message via extractAddressingContext, plus a real
|
||||
* signalRepository.lidMapping.getPNForLID API. The adapter always resolves
|
||||
* to phone JID (@s.whatsapp.net) before emitting to the router.
|
||||
*
|
||||
* Auth credentials persist in store/auth/. On first run:
|
||||
* - If WHATSAPP_PHONE_NUMBER is set → pairing code (printed to log)
|
||||
@@ -22,6 +27,7 @@ import { pino } from 'pino';
|
||||
|
||||
import {
|
||||
makeWASocket,
|
||||
proto,
|
||||
Browsers,
|
||||
DisconnectReason,
|
||||
fetchLatestWaWebVersion,
|
||||
@@ -40,29 +46,54 @@ import { registerChannelAdapter } from './channel-registry.js';
|
||||
import { normalizeOptions, type NormalizedOption } from './ask-question.js';
|
||||
import type { ChannelAdapter, ChannelSetup, ConversationInfo, InboundMessage, OutboundMessage } from './adapter.js';
|
||||
|
||||
// Baileys v6 bug: getPlatformId sends charCode (49) instead of enum value (1).
|
||||
// Fixed in Baileys 7.x but not backported. Without this, pairing codes fail with
|
||||
// "couldn't link device" because WhatsApp receives an invalid platform ID.
|
||||
// Must use createRequire — ESM `import *` creates a read-only namespace.
|
||||
// proto is not available as a named ESM export — use createRequire (same as v1)
|
||||
import { createRequire } from 'module';
|
||||
const _require = createRequire(import.meta.url);
|
||||
const { proto } = _require('@whiskeysockets/baileys') as { proto: any };
|
||||
try {
|
||||
const _generics = _require('@whiskeysockets/baileys/lib/Utils/generics') as Record<string, unknown>;
|
||||
_generics.getPlatformId = (browser: string): string => {
|
||||
const platformType =
|
||||
proto.DeviceProps.PlatformType[browser.toUpperCase() as keyof typeof proto.DeviceProps.PlatformType];
|
||||
return platformType ? platformType.toString() : '1';
|
||||
};
|
||||
} catch {
|
||||
// If CJS require fails (Node version mismatch), pairing codes may not work
|
||||
// but QR auth will still function fine.
|
||||
log.warn('Could not patch getPlatformId — pairing code auth may fail');
|
||||
}
|
||||
|
||||
const baileysLogger = pino({ level: 'silent' });
|
||||
|
||||
/**
|
||||
* Fetch the latest WhatsApp Web version. Baileys' built-in
|
||||
* fetchLatestWaWebVersion scrapes sw.js which is aggressively
|
||||
* rate-limited (429). When it fails, Baileys falls back to a
|
||||
* hardcoded version that goes stale within weeks — WhatsApp
|
||||
* rejects connections with an expired buildHash (405 at Noise
|
||||
* layer). This fetches from wppconnect's version tracker as a
|
||||
* more reliable source, with Baileys' own fetch as fallback.
|
||||
*/
|
||||
async function resolveWaWebVersion(): Promise<[number, number, number]> {
|
||||
// 1. Try wppconnect version tracker (HTML scrape — no JSON API)
|
||||
try {
|
||||
const res = await fetch('https://wppconnect.io/whatsapp-versions/', {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (res.ok) {
|
||||
const html = await res.text();
|
||||
const match = html.match(/2\.3000\.(\d+)/);
|
||||
if (match) {
|
||||
const version: [number, number, number] = [2, 3000, Number(match[1])];
|
||||
log.info('Fetched WA Web version from wppconnect', { version });
|
||||
return version;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall through to Baileys' own fetch
|
||||
}
|
||||
|
||||
// 2. Try Baileys' built-in fetch (scrapes sw.js — often 429'd)
|
||||
try {
|
||||
const { version } = await fetchLatestWaWebVersion({});
|
||||
if (version) {
|
||||
log.info('Fetched WA Web version from Baileys', { version });
|
||||
return version as [number, number, number];
|
||||
}
|
||||
} catch {
|
||||
// Fall through
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Could not fetch current WhatsApp Web version from any source. ' +
|
||||
'Baileys hardcodes a stale version that WhatsApp rejects (405). ' +
|
||||
'Check network connectivity to wppconnect.io and web.whatsapp.com.',
|
||||
);
|
||||
}
|
||||
|
||||
const AUTH_DIR = path.join(process.cwd(), 'store', 'auth');
|
||||
const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h
|
||||
const GROUP_METADATA_CACHE_TTL_MS = 60_000; // 1 min for outbound sends
|
||||
@@ -120,10 +151,99 @@ function transformForWhatsApp(text: string): string {
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Convert Claude's markdown to WhatsApp-native formatting. */
|
||||
function formatWhatsApp(text: string): string {
|
||||
// WhatsApp tags `@<phone-digits>` (5–15 digit local part — covers short test
|
||||
// numbers up to ITU E.164 max). A leading `+` is accepted but stripped so
|
||||
// the literal in text matches the digits in the JID — WhatsApp clients
|
||||
// scan the rendered text for `@<digits>` and cross-reference it with the
|
||||
// contextInfo.mentionedJid list to draw the bold/clickable tag.
|
||||
const MENTION_RE = /(^|[^\w@+])@\+?(\d{5,15})(?!\d)/g;
|
||||
|
||||
/** Extract `@<digits>` mentions from text and normalize them. */
|
||||
export function parseWhatsAppMentions(text: string): { text: string; mentions: string[] } {
|
||||
const mentions = new Set<string>();
|
||||
const out = text.replace(MENTION_RE, (_full, lead: string, digits: string) => {
|
||||
mentions.add(`${digits}@s.whatsapp.net`);
|
||||
return `${lead}@${digits}`;
|
||||
});
|
||||
return { text: out, mentions: [...mentions] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Claude's markdown to WhatsApp-native formatting and extract any
|
||||
* `@<phone>` mentions. Code-block regions are passed through untouched so
|
||||
* phone-like sequences inside code aren't tagged.
|
||||
*/
|
||||
function formatWhatsApp(text: string): { text: string; mentions: string[] } {
|
||||
const segments = splitProtectedRegions(text);
|
||||
return segments.map(({ content, isProtected }) => (isProtected ? content : transformForWhatsApp(content))).join('');
|
||||
const mentions = new Set<string>();
|
||||
const out = segments
|
||||
.map(({ content, isProtected }) => {
|
||||
if (isProtected) return content;
|
||||
const transformed = transformForWhatsApp(content);
|
||||
const { text: withMentions, mentions: found } = parseWhatsAppMentions(transformed);
|
||||
for (const m of found) mentions.add(m);
|
||||
return withMentions;
|
||||
})
|
||||
.join('');
|
||||
return { text: out, mentions: [...mentions] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Subset of a normalized Baileys message content carrying the message
|
||||
* types that can host a `contextInfo.mentionedJid` array. Kept as a
|
||||
* structural type so the helper (and its tests) don't pull in the full
|
||||
* `proto.IMessage` shape just to construct fixtures.
|
||||
*/
|
||||
type MentionContextSource = {
|
||||
extendedTextMessage?: { contextInfo?: { mentionedJid?: string[] | null } | null } | null;
|
||||
imageMessage?: { contextInfo?: { mentionedJid?: string[] | null } | null } | null;
|
||||
videoMessage?: { contextInfo?: { mentionedJid?: string[] | null } | null } | null;
|
||||
documentMessage?: { contextInfo?: { mentionedJid?: string[] | null } | null } | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect an explicit @-mention of the bot in a WhatsApp group message.
|
||||
* WhatsApp carries mentions in `contextInfo.mentionedJid` on the text +
|
||||
* caption-bearing message types. Matches against both the bot's phone
|
||||
* JID and LID — most modern clients emit the LID even when the human
|
||||
* typed a phone-number mention.
|
||||
*
|
||||
* Exported for unit testing. The inbound construction site calls this
|
||||
* to set `InboundMessage.isMention` for group messages (#2560). DMs are
|
||||
* unconditionally mentions and don't go through this helper.
|
||||
*/
|
||||
export function isBotMentionedInGroup(
|
||||
normalized: MentionContextSource,
|
||||
botPhoneJid: string | undefined,
|
||||
botLidUser: string | undefined,
|
||||
): boolean {
|
||||
if (!botPhoneJid && !botLidUser) return false;
|
||||
const mentionedJids: string[] = [
|
||||
...(normalized.extendedTextMessage?.contextInfo?.mentionedJid ?? []),
|
||||
...(normalized.imageMessage?.contextInfo?.mentionedJid ?? []),
|
||||
...(normalized.videoMessage?.contextInfo?.mentionedJid ?? []),
|
||||
...(normalized.documentMessage?.contextInfo?.mentionedJid ?? []),
|
||||
];
|
||||
const botLidJid = botLidUser ? `${botLidUser}@lid` : undefined;
|
||||
return mentionedJids.some((jid) => {
|
||||
if (!jid) return false;
|
||||
const bare = jid.split(':')[0];
|
||||
return bare === botPhoneJid || bare === botLidJid;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute `InboundMessage.isMention` for a WhatsApp message:
|
||||
* - DMs are always mentions (router auto-engages on the bot's behalf).
|
||||
* - Group messages are mentions only when the bot is explicitly tagged.
|
||||
*
|
||||
* Returns `true | undefined` rather than `true | false` because the
|
||||
* `InboundMessage` field is `isMention?: boolean` and downstream code
|
||||
* treats `undefined` differently than an explicit `false` (#2560).
|
||||
*/
|
||||
export function computeIsMention(isGroup: boolean, botMentionedInGroup: boolean): true | undefined {
|
||||
if (!isGroup) return true;
|
||||
return botMentionedInGroup ? true : undefined;
|
||||
}
|
||||
|
||||
/** Map file extension to Baileys media message type. */
|
||||
@@ -161,14 +281,16 @@ registerChannelAdapter('whatsapp', {
|
||||
// State
|
||||
let sock: WASocket;
|
||||
let connected = false;
|
||||
let shuttingDown = false;
|
||||
let setupConfig: ChannelSetup;
|
||||
|
||||
// LID → phone JID mapping (WhatsApp's new ID system)
|
||||
const lidToPhoneMap: Record<string, string> = {};
|
||||
let botLidUser: string | undefined;
|
||||
let botPhoneJid: string | undefined;
|
||||
|
||||
// Outgoing queue for messages sent while disconnected
|
||||
const outgoingQueue: Array<{ jid: string; text: string }> = [];
|
||||
const outgoingQueue: Array<{ jid: string; text: string; mentions?: string[] }> = [];
|
||||
let flushing = false;
|
||||
|
||||
// Sent message cache for retry/re-encrypt requests
|
||||
@@ -207,21 +329,30 @@ registerChannelAdapter('whatsapp', {
|
||||
groupMetadataCache.clear();
|
||||
}
|
||||
|
||||
async function translateJid(jid: string): Promise<string> {
|
||||
async function translateJid(jid: string, altJid?: string): Promise<string> {
|
||||
if (!jid.endsWith('@lid')) return jid;
|
||||
const lidUser = jid.split('@')[0].split(':')[0];
|
||||
|
||||
// 1. Check local cache
|
||||
const cached = lidToPhoneMap[lidUser];
|
||||
if (cached) return cached;
|
||||
|
||||
// Query Baileys' signal repository
|
||||
// 2. Use the alt JID from extractAddressingContext (v7 provides this
|
||||
// on every inbound message as remoteJidAlt / participantAlt)
|
||||
if (altJid && !altJid.endsWith('@lid')) {
|
||||
const phoneJid = altJid.includes('@') ? altJid : `${altJid}@s.whatsapp.net`;
|
||||
setLidPhoneMapping(lidUser, phoneJid);
|
||||
log.info('Translated LID via alt JID', { lidJid: jid, phoneJid });
|
||||
return phoneJid;
|
||||
}
|
||||
|
||||
// 3. Query Baileys v7 LID mapping store
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const pn = await (sock.signalRepository as any)?.lidMapping?.getPNForLID(jid);
|
||||
const pn = await sock.signalRepository.lidMapping.getPNForLID(jid);
|
||||
if (pn) {
|
||||
const phoneJid = `${pn.split('@')[0].split(':')[0]}@s.whatsapp.net`;
|
||||
setLidPhoneMapping(lidUser, phoneJid);
|
||||
log.info('Translated LID to phone JID', { lidJid: jid, phoneJid });
|
||||
log.info('Translated LID via signal repository', { lidJid: jid, phoneJid });
|
||||
return phoneJid;
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -280,7 +411,9 @@ registerChannelAdapter('whatsapp', {
|
||||
log.info('Flushing outgoing message queue', { count: outgoingQueue.length });
|
||||
while (outgoingQueue.length > 0) {
|
||||
const item = outgoingQueue.shift()!;
|
||||
const sent = await sock.sendMessage(item.jid, { text: item.text });
|
||||
const payload: { text: string; mentions?: string[] } = { text: item.text };
|
||||
if (item.mentions && item.mentions.length > 0) payload.mentions = item.mentions;
|
||||
const sent = await sock.sendMessage(item.jid, payload);
|
||||
if (sent?.key?.id && sent.message) {
|
||||
sentMessageCache.set(sent.key.id, sent.message);
|
||||
}
|
||||
@@ -332,14 +465,16 @@ registerChannelAdapter('whatsapp', {
|
||||
return results;
|
||||
}
|
||||
|
||||
async function sendRawMessage(jid: string, text: string): Promise<string | undefined> {
|
||||
async function sendRawMessage(jid: string, text: string, mentions?: string[]): Promise<string | undefined> {
|
||||
if (!connected) {
|
||||
outgoingQueue.push({ jid, text });
|
||||
outgoingQueue.push({ jid, text, mentions });
|
||||
log.info('WA disconnected, message queued', { jid, queueSize: outgoingQueue.length });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const sent = await sock.sendMessage(jid, { text });
|
||||
const payload: { text: string; mentions?: string[] } = { text };
|
||||
if (mentions && mentions.length > 0) payload.mentions = mentions;
|
||||
const sent = await sock.sendMessage(jid, payload);
|
||||
if (sent?.key?.id && sent.message) {
|
||||
sentMessageCache.set(sent.key.id, sent.message);
|
||||
if (sentMessageCache.size > SENT_MESSAGE_CACHE_MAX) {
|
||||
@@ -349,7 +484,7 @@ registerChannelAdapter('whatsapp', {
|
||||
}
|
||||
return sent?.key?.id ?? undefined;
|
||||
} catch (err) {
|
||||
outgoingQueue.push({ jid, text });
|
||||
outgoingQueue.push({ jid, text, mentions });
|
||||
log.warn('Failed to send, message queued', { jid, err, queueSize: outgoingQueue.length });
|
||||
return undefined;
|
||||
}
|
||||
@@ -360,10 +495,7 @@ registerChannelAdapter('whatsapp', {
|
||||
async function connectSocket(): Promise<void> {
|
||||
const { state, saveCreds } = await useMultiFileAuthState(authDir);
|
||||
|
||||
const { version } = await fetchLatestWaWebVersion({}).catch((err) => {
|
||||
log.warn('Failed to fetch latest WA Web version, using default', { err });
|
||||
return { version: undefined };
|
||||
});
|
||||
const version = await resolveWaWebVersion();
|
||||
|
||||
sock = makeWASocket({
|
||||
version,
|
||||
@@ -380,12 +512,22 @@ 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({});
|
||||
},
|
||||
});
|
||||
|
||||
// Request pairing code if phone number is set and not yet registered
|
||||
if (phoneNumber && !state.creds.registered) {
|
||||
// Request pairing code only when there's no paired account yet.
|
||||
//
|
||||
// We can't use `state.creds.registered` here: Baileys 7.x doesn't
|
||||
// reliably flip that flag back to `true` after the post-pair stream
|
||||
// restart (statusCode 515). An already-paired socket would then see
|
||||
// `registered=false` and request a *new* pairing code 3s after the
|
||||
// restart, which the WhatsApp server rejects with 401 and the adapter
|
||||
// wipes the auth directory — re-pair from scratch every restart.
|
||||
//
|
||||
// `state.creds.me` is set as part of the QR / pairing-code handshake
|
||||
// and is the authoritative "this socket has an account" signal.
|
||||
if (phoneNumber && !state.creds.me) {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const code = await sock.requestPairingCode(phoneNumber);
|
||||
@@ -417,9 +559,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...');
|
||||
@@ -431,13 +577,36 @@ registerChannelAdapter('whatsapp', {
|
||||
});
|
||||
}, RECONNECT_DELAY_MS);
|
||||
});
|
||||
} else {
|
||||
} else if (reason === DisconnectReason.loggedOut) {
|
||||
// Server-side logout (account unlinked, 401, etc.). Clear auth so
|
||||
// the next start prompts for a fresh pair — stale creds would
|
||||
// 401 again and risk WhatsApp's "can't link new devices now"
|
||||
// cooldown.
|
||||
log.info('WhatsApp logged out');
|
||||
try {
|
||||
fs.rmSync(authDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
log.info('WhatsApp auth cleared — set WHATSAPP_ENABLED=true and restart to re-link');
|
||||
} catch (err) {
|
||||
log.error('Failed to clear WhatsApp auth after logout', { err });
|
||||
}
|
||||
if (rejectFirstOpen) {
|
||||
rejectFirstOpen(new Error('WhatsApp logged out'));
|
||||
rejectFirstOpen = undefined;
|
||||
resolveFirstOpen = undefined;
|
||||
}
|
||||
} else {
|
||||
// Clean shutdown (shuttingDown=true) or a non-loggedOut disconnect
|
||||
// that won't auto-reconnect. KEEP AUTH — the next process boot
|
||||
// must be able to restore the session. Wiping here turned every
|
||||
// `systemctl restart` into a forced re-pair, which is catastrophic
|
||||
// when the bot phone is not in reach.
|
||||
log.info('WhatsApp adapter stopped (auth preserved)');
|
||||
if (rejectFirstOpen) {
|
||||
rejectFirstOpen(new Error('WhatsApp adapter shutdown'));
|
||||
rejectFirstOpen = undefined;
|
||||
resolveFirstOpen = undefined;
|
||||
}
|
||||
}
|
||||
} else if (connection === 'open') {
|
||||
connected = true;
|
||||
@@ -459,8 +628,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 +658,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 +677,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 +704,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 +744,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 +863,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 +885,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 +900,7 @@ registerChannelAdapter('whatsapp', {
|
||||
},
|
||||
|
||||
async teardown() {
|
||||
shuttingDown = true;
|
||||
connected = false;
|
||||
sock?.end(undefined);
|
||||
log.info('WhatsApp adapter shut down');
|
||||
|
||||
+7
-5
@@ -9,15 +9,17 @@
|
||||
* will later emit as event.platformId, or router lookups miss and messages
|
||||
* get silently dropped.
|
||||
*
|
||||
* Native adapters (Signal, WhatsApp, iMessage) use their own ID formats and
|
||||
* send them as-is — no channel prefix. WhatsApp/iMessage emit JIDs/emails
|
||||
* containing '@'. Signal emits raw phone numbers ('+15551234567') for DMs
|
||||
* and 'group:<id>' for group chats. Prefixing any of these would cause a
|
||||
* mismatch with what the adapter later emits.
|
||||
* Native adapters (Signal, WhatsApp, iMessage, DeltaChat) use their own ID
|
||||
* formats and send them as-is — no channel prefix. WhatsApp/iMessage emit
|
||||
* JIDs/emails containing '@'. Signal emits raw phone numbers ('+15551234567')
|
||||
* for DMs and 'group:<id>' for group chats. DeltaChat emits numeric chat IDs
|
||||
* ('12'). Prefixing any of these would cause a mismatch with what the adapter
|
||||
* later emits.
|
||||
*/
|
||||
export function namespacedPlatformId(channel: string, raw: string): string {
|
||||
if (raw.startsWith(`${channel}:`)) return raw;
|
||||
if (raw.includes('@')) return raw;
|
||||
if (raw.startsWith('+') || raw.startsWith('group:')) return raw;
|
||||
if (channel === 'deltachat') return raw;
|
||||
return `${channel}:${raw}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user