Compare commits

...

27 Commits

Author SHA1 Message Date
Gabi Simons a26abd6b6b feat(slack): guided setup picks webhook vs Socket Mode
setup:auto's Slack flow now asks the delivery mode up front (brightSelect),
then collects only the credential that mode needs:
- Socket Mode → SLACK_APP_TOKEN (xapp-…); skips the public-URL checklist
- Webhook → SLACK_SIGNING_SECRET + the public Request URL checklist (unchanged)

add-slack.sh now requires either SLACK_APP_TOKEN or SLACK_SIGNING_SECRET (was:
signing secret mandatory) and upserts whichever is present. The adapter already
selects the mode from SLACK_APP_TOKEN's presence (this PR) — this is the
guided-setup surface for it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:34:18 +03:00
Gabi Simons e9712d033a feat(slack): add Socket Mode (SLACK_APP_TOKEN)
Set SLACK_APP_TOKEN (xapp-…) and the adapter connects over an outbound
WebSocket (mode: 'socket') instead of an inbound HTTPS webhook — no public
endpoint required. Without the app token, behavior is unchanged (webhook mode);
the signing secret becomes optional under Socket Mode.

Requires @chat-adapter/slack@4.29.0, where Socket Mode is implemented — that pin
landed in the chat-SDK 4.29.0 bump this stacks on. SKILL.md documents the
app-level token (connections:write), enabling Socket Mode, and the
no-public-URL path.

Verified: this exact adapter change builds + passes the slack registration test
on current main at chat@4.29.0 / @chat-adapter/slack@4.29.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:16:10 +03:00
Gabi Simons cf5ac09320 chore(deps): bump @chat-adapter/* + chat to 4.29.0
Companion to the core `chat` 4.29.0 bump on main. Bumps every channel adapter
install pin (8 /add-* SKILL.md + the setup/*.sh install scripts) and the
package.json deps to the matched 4.29.0 release, so `/add-<channel>` installs an
adapter whose ChatInstance matches main's Chat SDK bridge.

`chat` and @chat-adapter/* are version-locked, so all are pinned exactly to
4.29.0. The Slack adapter is proven on current main at chat@4.29.0 (build +
registration test green). Pre-existing standalone build/test failures on this
branch (optional native-adapter packages not installed, branch behind main) are
unchanged by this bump — identical failure set at the 4.26.0 baseline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 12:36:04 +03:00
gavrielc 8137440698 test(channels): add behavior registration tests for the remaining channel fleet
discord, gchat, github, imessage, linear, matrix, resend, teams, telegram,
webex, whatsapp-cloud, signal, wechat, whatsapp, emacs.

Same behavior shape as the slack/deltachat exemplars: import the real
src/channels/index.ts barrel and assert getRegisteredChannelNames() contains
the channel. Red if the barrel import line is deleted/drifts, if the barrel
fails to evaluate, or (for channels with an npm adapter) if the adapter package
is not installed — so each test also implicitly guards the skill's dependency.
signal and emacs have no npm adapter (signal-cli binary / http builtin), so
their tests guard the single barrel reach-in only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:44:14 +03:00
gavrielc 7ceb06cc8a test(channels): switch deltachat + slack registration tests to behavior
Replace the structural barrel-parse with a behavior test that imports the real
barrel and asserts the registry actually contains the channel. This reflects
host boot: it goes red if the `import './<ch>.js';` line is deleted, if the
barrel fails to evaluate (channel genuinely won't register), or if the adapter
package isn't installed (the unmocked import throws) — so it also implicitly
verifies the dependency-install integration point. A structural check would
falsely pass in the latter two cases.

Importing is safe: registration is a pure top-level call; deltachat instantiates
DeltaChatOverJsonRpc only in setup(), and slack builds its SDK adapter/bridge
only in its factory — neither at import. Requires the adapter package installed,
which holds in the composed install (the skill's pnpm install runs first).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:05:11 +03:00
gavrielc d011752c67 test(slack): add channel registration integration test
Guards the single reach-in a slack install makes into core — the
`import './slack.js';` line in the src/channels/index.ts barrel that triggers
the adapter's top-level registerChannelAdapter call. Structural (parses the
barrel) rather than behavior, so it stays hermetic and does not pull
@chat-adapter/slack into the test process; the adapter's createChatSdkBridge
core-API consumption is a typed call guarded by the build leg. Red-on-delete
of the barrel line. Template for the Chat SDK channel family.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:55:59 +03:00
gavrielc 2e6f10cdd7 test(deltachat): add channel registration integration test
Guards the single reach-in a deltachat install makes into core — the
`import './deltachat.js';` line in the src/channels/index.ts barrel that
triggers the adapter's top-level registerChannelAdapter call. Structural
(parses the barrel) rather than behavior, so it stays hermetic and does not
pull the native @deltachat/stdio-rpc-server into the test process; the build
leg covers that the import resolves. Red-on-delete of the barrel line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:02:43 +03:00
glifocat 8906105825 Merge pull request #2633 from maschenborn/fix/whatsapp-self-destruct-and-shutdown-auth-wipe
Fix/whatsapp self destruct and shutdown auth wipe
2026-06-04 15:49:28 +03:00
Michael Aschenborn ef9e7d5f99 fix(whatsapp): preserve auth on clean shutdown — only clear on server logout
The auth-cleanup branch ran on every `shouldReconnect=false` path, which
is true for *both* `DisconnectReason.loggedOut` *and* `shuttingDown=true`.
That meant every clean `systemctl restart nanoclaw` would log "WhatsApp
logged out" and `fs.rmSync(authDir)` — wiping a perfectly good session
and forcing a fresh re-pair on next start.

Split the branch: only clear `authDir` when the disconnect reason is
actually `DisconnectReason.loggedOut`. On clean shutdown, log
"WhatsApp adapter stopped (auth preserved)" and exit cleanly. The next
process boot will pick up the preserved auth via
`useMultiFileAuthState` and reconnect transparently.

Symptom in logs that this fixes:

  WhatsApp connection closed reason=undefined shuttingDown=true
  WhatsApp logged out
  WhatsApp auth cleared           (wrong — this was a clean shutdown)
  ... next restart ...
  WhatsApp pairing code: XXXXXXXX (because auth is gone)

This pair of bugs together (the previous commit + this one) is why
WhatsApp installs with `WHATSAPP_PHONE_NUMBER` set on Baileys 7.x feel
unstable: any restart wipes auth, then the next restart's adapter
self-destructs while trying to re-pair, leaving the bot offline until
a human re-runs the pair flow. With both fixes a `systemctl restart`
is now a transparent ~3s connection blip.
2026-05-28 15:09:10 +02:00
Michael Aschenborn 051b895b3c fix(whatsapp): don't self-destruct paired session after stream restart
Baileys 7.x does not reliably flip `state.creds.registered` back to
true after the post-pair stream-restart (statusCode 515). The adapter
then sees `registered=false` on an already-paired socket and queues a
fresh `requestPairingCode()` 3 seconds later. The WhatsApp server sees
two conflicting auth flows for the same account, rejects with 401, and
the adapter wipes `authDir` — every restart forces a re-pair from
scratch.

Use `state.creds.me` instead. It is set during QR / pair-code handshake
and is the authoritative "this socket has an account" signal — it does
not toggle on stream restarts.

Symptom in logs that this fixes:

  Connected to WhatsApp
  WhatsApp pairing code: XXXXXXXX        (3s later — the bug)
  WhatsApp connection closed reason=401
  WhatsApp logged out
  WhatsApp auth cleared

Reproduced on Baileys 7.0.0-rc.9. Affects any install with
WHATSAPP_PHONE_NUMBER set, which is the recommended config for
dedicated bot numbers.
2026-05-28 15:08:44 +02:00
gavrielc 43adb1998a Merge pull request #2552 from IamAdamJowett/fix/whatsapp-mentions-and-shutdown-race
Thanks @IamAdamJowett!
2026-05-22 23:17:35 +03:00
gavrielc 5ba4735fe9 merge: resolve conflict with channels branch in whatsapp.test.ts
Combine both test suites — inbound bot mention detection (#2560 from
channels) and outbound parseWhatsAppMentions (this PR).

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

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

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

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

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

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

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

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

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

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

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

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

Introduced in c02ac06 (Apr 14 2026).

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-04 21:55:49 +00:00
gavrielc ea68aa810b Merge pull request #2192 from axxml/channels
Add DeltaChat channel adapter
2026-05-02 21:14:33 +03:00
Axel McLaren 5987fdc189 Add namespacedPlatformId exclusion for DeltaChat 2026-05-02 09:57:25 -07:00
Axel McLaren 0ef8757f50 Add DeltaChat channel adapter 2026-05-02 09:57:21 -07:00
53 changed files with 2206 additions and 484 deletions
+62
View File
@@ -0,0 +1,62 @@
# Remove DeltaChat
## 1. Disable the adapter
Comment out the import in `src/channels/index.ts`:
```typescript
// import './deltachat.js';
```
## 2. Remove credentials
Remove the `DC_*` lines from `.env`:
```bash
DC_EMAIL
DC_PASSWORD
DC_IMAP_HOST
DC_IMAP_PORT
DC_SMTP_HOST
DC_SMTP_PORT
```
## 3. Rebuild and restart
```bash
pnpm run build
# Linux
systemctl --user restart nanoclaw
# macOS
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
```
## 4. Remove account data (optional)
To fully remove all account data including DeltaChat encryption keys:
```bash
rm -rf dc-account/
```
> **Warning:** This deletes the Autocrypt keys. Contacts who have verified your bot's key will need to re-verify if the same email address is re-used with a new account.
To keep the account for later reinstall, leave `dc-account/` intact.
## 5. Remove the package (optional)
```bash
pnpm remove @deltachat/stdio-rpc-server
```
## Verification
After removal, confirm the adapter is no longer starting:
```bash
grep "deltachat" logs/nanoclaw.log | tail -5
```
Expected: no `Channel adapter started` entry after the last restart.
+254
View File
@@ -0,0 +1,254 @@
---
name: add-deltachat
description: Add DeltaChat channel integration via @deltachat/stdio-rpc-server. Native adapter — no Chat SDK bridge. Email-based messaging with end-to-end encryption.
---
# Add DeltaChat Channel
The adapter drives the `@deltachat/stdio-rpc-server` JSON-RPC subprocess directly — pure Node.js against the DeltaChat core library. Messages are delivered over email with Autocrypt/OpenPGP encryption.
## Install
### Pre-flight (idempotent)
Skip to **Credentials** if all of these are already in place:
- `src/channels/deltachat.ts` exists
- `src/channels/index.ts` contains `import './deltachat.js';`
- `@deltachat/stdio-rpc-server` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```
### 2. Copy the adapter
```bash
git show origin/channels:src/channels/deltachat.ts > src/channels/deltachat.ts
```
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if already present):
```typescript
import './deltachat.js';
```
### 4. Install the adapter package (pinned)
```bash
pnpm install @deltachat/stdio-rpc-server@2.49.0
```
### 5. Build
```bash
pnpm run build
```
## Account Setup
A dedicated email account is strongly recommended — it will accumulate DeltaChat-formatted messages and store encryption keys. Not all providers work well with DeltaChat; check https://providers.delta.chat/ before picking one.
**Default security modes:** IMAP uses SSL/TLS (port 993), SMTP uses STARTTLS (port 587). Both are configurable via `.env` — see Credentials below.
To find the correct hostnames for a domain:
```bash
node -e "require('dns').resolveMx('example.com', (e,r) => console.log(r))"
```
Most providers publish their IMAP/SMTP hostnames in their help docs under "manual setup" or "IMAP access."
## Credentials
Add to `.env`:
```bash
DC_EMAIL=bot@example.com
DC_PASSWORD=your-app-password
DC_IMAP_HOST=imap.example.com
DC_IMAP_PORT=993
DC_IMAP_SECURITY=1 # 1=SSL/TLS (default), 2=STARTTLS, 3=plain
DC_SMTP_HOST=smtp.example.com
DC_SMTP_PORT=587
DC_SMTP_SECURITY=2 # 2=STARTTLS (default), 1=SSL/TLS, 3=plain
```
Security settings are applied on every startup, so changing them in `.env` and restarting takes effect without wiping the account.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Optional settings
The following are read from the process environment (not `.env`). To override them, add `Environment=` lines to the systemd service unit or your launchd plist:
| Variable | Default | Description |
|----------|---------|-------------|
| `DC_ACCOUNT_DIR` | `dc-account` | Directory for DeltaChat account data (IMAP state, keys, blobs) |
| `DC_DISPLAY_NAME` | `NanoClaw` | Bot display name shown in DeltaChat |
| `DC_AVATAR_PATH` | _(none)_ | Absolute path to avatar image; set at startup only |
The `/set-avatar` command (send an image with that caption) is the easiest way to set the avatar at runtime without modifying the service file. Only users with `owner` or global `admin` role can use it.
### Restart
```bash
# Linux
systemctl --user restart nanoclaw
# macOS
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
```
On first start the adapter configures the email account (IMAP/SMTP credentials, calls `configure()`). Subsequent starts skip straight to `startIo()`. Account data is stored in `dc-account/` in the project root (or your `DC_ACCOUNT_DIR`).
## Wiring
### DMs
**DeltaChat contacts cannot be added by email alone** — to start a chat, the user must open the bot's invite link in their DeltaChat app or scan its QR code. This triggers the SecureJoin handshake.
#### Step 1 — Get the invite link
After the service starts, the adapter logs the invite URL and writes a QR SVG:
```bash
grep "invite link" logs/nanoclaw.log | tail -1
# url field contains the https://i.delta.chat/... invite link
# also written to dc-account/invite-qr.svg (or $DC_ACCOUNT_DIR/invite-qr.svg)
```
The invite URL is stable (tied to the bot's email and encryption keys) so it stays valid across restarts.
#### Step 2 — Add the bot in DeltaChat
Two options for the user to connect:
- **Link**: Copy the `https://i.delta.chat/...` URL and open it on the device running DeltaChat. The app recognises it and shows a "Start chat" prompt.
- **QR code**: Open `dc-account/invite-qr.svg` in a browser or image viewer, display it on screen, and scan it from the DeltaChat app using the QR-scan button on the new-chat screen.
After accepting, DeltaChat exchanges keys and creates the chat automatically.
#### Step 3 — Wire the chat to an agent
Once the first message arrives the router auto-creates a `messaging_groups` row. Look up the chat ID:
```bash
sqlite3 data/v2.db \
"SELECT platform_id, name FROM messaging_groups WHERE channel_type='deltachat' AND is_group=0 ORDER BY created_at DESC LIMIT 5"
```
Then run `/init-first-agent` — it creates the agent group, grants the user owner access, and wires the messaging group in one step:
```bash
pnpm exec tsx scripts/init-first-agent.ts \
--channel deltachat \
--user-id deltachat:user@example.com \
--platform-id <platform_id from above> \
--display-name "Your Name"
```
### Groups
Add the bot email to a DeltaChat group. When any member sends a message, the router creates a `messaging_groups` row with `is_group = 1`. Run `/manage-channels` to wire it to an agent group.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/init-first-agent` to create an agent and wire it to your DeltaChat DM (see Wiring above), or `/manage-channels` to wire this channel to an existing agent group.
## Channel Info
- **type**: `deltachat`
- **terminology**: DeltaChat calls them "chats" (1:1 DMs) and "groups"
- **supports-threads**: no — DeltaChat has no thread model
- **platform-id-format**: numeric chat ID as a string (e.g. `"12"`) — the DeltaChat core's internal chat identifier
- **user-id-format**: `deltachat:{email}` — the contact's email address
- **how-to-find-id**: Send a message from DeltaChat to the bot email, then query `messaging_groups` as shown above
- **typical-use**: Personal assistant over DeltaChat DMs; small groups where participants use DeltaChat
- **default-isolation**: One agent per bot identity. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode
### Features
- File attachments — inbound and outbound; inbound waits up to 30 seconds for large-message download to complete
- Invite link logged on every startup — URL + QR SVG written to `dc-account/invite-qr.svg`; see Wiring for the bootstrap flow
- `/set-avatar` — send an image with this caption to change the bot's DeltaChat avatar (admin/owner only)
- Connectivity watchdog — restarts IO if IMAP goes quiet for 20 minutes or connectivity drops below threshold for two consecutive 5-minute checks
- Network nudge — `maybeNetwork()` called every 10 minutes to recover from prolonged idle
Not supported: DeltaChat reactions, message editing/deletion, read receipts.
### Connectivity model
`isConnected()` returns `true` when the internal connectivity value is ≥ 3000:
| Range | Meaning |
|-------|---------|
| 10001999 | Not connected |
| 20002999 | Connecting |
| 30003999 | Working (IMAP fetching) |
| ≥ 4000 | Fully connected (IMAP IDLE) |
## Troubleshooting
### Adapter not starting — credentials missing
```bash
grep "Channel credentials missing" logs/nanoclaw.log | grep deltachat
```
All six required vars (`DC_EMAIL`, `DC_PASSWORD`, `DC_IMAP_HOST`, `DC_IMAP_PORT`, `DC_SMTP_HOST`, `DC_SMTP_PORT`) must be present in `.env`.
### Account configure fails
```bash
grep "DeltaChat" logs/nanoclaw.log | tail -20
```
Common causes:
- Wrong IMAP/SMTP hostnames — double-check provider docs
- App password not generated — Gmail and some others require this when 2FA is enabled
- Port/security mismatch — defaults are port 993 + SSL/TLS for IMAP and port 587 + STARTTLS for SMTP; override with `DC_IMAP_PORT`/`DC_IMAP_SECURITY` or `DC_SMTP_PORT`/`DC_SMTP_SECURITY` in `.env`
### Provider uses SMTP port 465 (SSL/TLS) instead of 587
Set `DC_SMTP_SECURITY=1` and `DC_SMTP_PORT=465` in `.env`, then restart.
### Messages not arriving
1. Check the service is running and the adapter started: `grep "Channel adapter started.*deltachat" logs/nanoclaw.log`
2. Check connectivity: `grep "DeltaChat: IO started" logs/nanoclaw.log`
3. Check the sender has been granted access — run `/init-first-agent` to create their user record and wire the chat
4. Verify the messaging group is wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mga.agent_group_id FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='deltachat'"`
### Stale lock file after crash
```bash
rm -f dc-account/accounts.lock
systemctl --user restart nanoclaw
```
### Bot not responding after restart
The account is already configured — IO restarts automatically on service start. If the RPC subprocess is stuck, restart the service. Check for errors:
```bash
grep "DeltaChat" logs/nanoclaw.error.log | tail -20
```
### Messages received but agent not responding
The messaging group exists but may not be wired to an agent group. Run:
```bash
sqlite3 data/v2.db "SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat'"
```
If the group has no entry in `messaging_group_agents`, wire it with `/manage-channels`.
+54
View File
@@ -0,0 +1,54 @@
# Verify DeltaChat
## 1. Check the adapter started
```bash
grep "Channel adapter started.*deltachat" logs/nanoclaw.log | tail -1
```
Expected: `Channel adapter started { channel: 'deltachat', type: 'deltachat' }`
## 2. Check IMAP/SMTP connectivity
Replace with your provider's hostnames from `.env`:
```bash
DC_IMAP=$(grep '^DC_IMAP_HOST=' .env | cut -d= -f2)
DC_SMTP=$(grep '^DC_SMTP_HOST=' .env | cut -d= -f2)
bash -c "echo >/dev/tcp/$DC_IMAP/993" && echo "IMAP open" || echo "IMAP blocked"
bash -c "echo >/dev/tcp/$DC_SMTP/587" && echo "SMTP open" || echo "SMTP blocked"
```
## 3. End-to-end message test
1. Open DeltaChat on your device
2. Add the bot email address as a contact
3. Send a message
4. The bot should respond within a few seconds
If nothing arrives, check:
```bash
grep "DeltaChat" logs/nanoclaw.log | tail -20
grep "DeltaChat" logs/nanoclaw.error.log | tail -10
```
## 4. Check messaging group was created
```bash
sqlite3 data/v2.db \
"SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat' ORDER BY created_at DESC LIMIT 5"
```
If a row appears, the inbound routing is working. If not, the adapter isn't receiving the message — check logs for `DeltaChat: error handling incoming message`.
## 5. Verify user access
If the message arrived but the agent didn't respond, the sender may not have access:
```bash
sqlite3 data/v2.db "SELECT id, display_name FROM users WHERE id LIKE 'deltachat:%'"
```
Grant access as shown in the SKILL.md "Grant user access" section.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+20 -5
View File
@@ -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`.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -57,7 +57,7 @@ groups: () => import('./groups.js'),
### 5. Install the adapter packages (pinned)
```bash
pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
```
### 6. Build
+11 -11
View File
@@ -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",
+296 -292
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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`.
+1 -1
View File
@@ -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`.
+1 -1
View File
@@ -16,7 +16,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Keep in sync with .claude/skills/add-whatsapp/SKILL.md.
BAILEYS_VERSION="@whiskeysockets/baileys@6.17.16"
BAILEYS_VERSION="@whiskeysockets/baileys@7.0.0-rc.9"
QRCODE_VERSION="qrcode@1.5.4"
QRCODE_TYPES_VERSION="@types/qrcode@1.5.6"
PINO_VERSION="pino@9.6.0"
+116 -48
View File
@@ -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(
[
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -66,7 +66,7 @@ if ! grep -q "'whatsapp-auth':" setup/index.ts; then
fi
echo "STEP: pnpm-install"
pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
echo "STEP: pnpm-build"
pnpm run build
+19 -25
View File
@@ -1,5 +1,5 @@
/**
* Step: whatsapp-auth standalone WhatsApp (Baileys) authentication.
* Step: whatsapp-auth standalone WhatsApp (Baileys v7) authentication.
*
* Forked from the channels-branch version so setup:auto's driver can render
* the terminal UX itself (inside clack) instead of the step dumping a raw QR
@@ -27,7 +27,6 @@
*/
import fs from 'fs';
import path from 'path';
import { createRequire } from 'module';
// Named import (not default) — pino's d.ts under NodeNext resolves the
// default export to `typeof pino` (namespace), which isn't callable. The
// named `pino` export resolves to the callable function.
@@ -47,26 +46,23 @@ const AUTH_DIR = path.join(process.cwd(), 'store', 'auth');
const PAIRING_CODE_FILE = path.join(process.cwd(), 'store', 'pairing-code.txt');
const baileysLogger = pino({ level: 'silent' });
// Baileys v6 bug: getPlatformId sends charCode (49) instead of enum value (1).
// Fixed in Baileys 7.x but not backported. Without this patch pairing codes
// fail with "couldn't link device" because WhatsApp receives an invalid
// platform id. createRequire because proto is not a named ESM export.
const _require = createRequire(import.meta.url);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { proto } = _require('@whiskeysockets/baileys') as { proto: any };
try {
const _generics = _require(
'@whiskeysockets/baileys/lib/Utils/generics',
) as Record<string, unknown>;
_generics.getPlatformId = (browser: string): string => {
const platformType =
proto.DeviceProps.PlatformType[
browser.toUpperCase() as keyof typeof proto.DeviceProps.PlatformType
];
return platformType ? platformType.toString() : '1';
};
} catch {
// If CJS require fails, QR auth still works; only pairing code may be affected.
/** Fetch current WA Web version — wppconnect tracker, then Baileys sw.js scrape. */
async function resolveWaWebVersion(): Promise<[number, number, number]> {
try {
const res = await fetch('https://wppconnect.io/whatsapp-versions/', {
signal: AbortSignal.timeout(5000),
});
if (res.ok) {
const html = await res.text();
const match = html.match(/2\.3000\.(\d+)/);
if (match) return [2, 3000, Number(match[1])];
}
} catch { /* fall through */ }
try {
const { version } = await fetchLatestWaWebVersion({});
if (version) return version as [number, number, number];
} catch { /* fall through */ }
throw new Error('Could not fetch current WhatsApp Web version — cannot connect with stale version');
}
type AuthMethod = 'qr' | 'pairing-code';
@@ -139,9 +135,7 @@ export async function run(args: string[]): Promise<void> {
async function connectSocket(isReconnect = false): Promise<void> {
const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
const { version } = await fetchLatestWaWebVersion({}).catch(() => ({
version: undefined,
}));
const version = await resolveWaWebVersion();
const sock = makeWASocket({
version,
@@ -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');
});
});
+338
View File
@@ -0,0 +1,338 @@
/**
* DeltaChat channel adapter.
*
* Bridges NanoClaw with DeltaChat via the @deltachat/stdio-rpc-server JSON-RPC
* process. Each DeltaChat chat becomes a separate NanoClaw messaging group
* (platformId = chatId string, e.g. "12"). No thread model supportsThreads: false.
*
* Required env vars (.env): DC_EMAIL, DC_PASSWORD,
* DC_IMAP_HOST, DC_IMAP_PORT,
* DC_SMTP_HOST, DC_SMTP_PORT
* Optional env vars (.env): DC_IMAP_SECURITY (default: "1" = SSL/TLS),
* DC_SMTP_SECURITY (default: "2" = STARTTLS)
* Security values: 1=SSL/TLS, 2=STARTTLS, 3=plain
* Optional env vars (service unit): DC_ACCOUNT_DIR (default: "dc-account"),
* DC_DISPLAY_NAME, DC_AVATAR_PATH
*/
import { existsSync, mkdtempSync, writeFileSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { basename, join, resolve } from 'path';
import { getDb, hasTable } from '../db/connection.js';
import { readEnvFile } from '../env.js';
import { log } from '../log.js';
import type { ChannelAdapter, ChannelSetup, OutboundMessage } from './adapter.js';
import { registerChannelAdapter } from './channel-registry.js';
import { DeltaChatOverJsonRpc } from '@deltachat/stdio-rpc-server';
const REQUIRED_ENV = [
'DC_EMAIL',
'DC_PASSWORD',
'DC_IMAP_HOST',
'DC_IMAP_PORT',
'DC_SMTP_HOST',
'DC_SMTP_PORT',
] as const;
const OPTIONAL_ENV = ['DC_IMAP_SECURITY', 'DC_SMTP_SECURITY'] as const;
type DcEnv = { [K in (typeof REQUIRED_ENV)[number]]: string } & { [K in (typeof OPTIONAL_ENV)[number]]?: string };
function isDcAdmin(userId: string): boolean {
try {
const db = getDb();
if (!hasTable(db, 'user_roles')) return true;
return (
db
.prepare(
`SELECT 1 FROM user_roles
WHERE user_id = ?
AND (role = 'owner' OR role = 'admin')
AND agent_group_id IS NULL
LIMIT 1`,
)
.get(userId) != null
);
} catch {
return false;
}
}
function createAdapter(env: DcEnv): ChannelAdapter {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let dc: any = null;
let accountId = 0;
let connectivity = 0;
let lastImapIdleTs = Date.now();
let consecutiveBadChecks = 0;
let watchdogTimer: ReturnType<typeof setInterval> | null = null;
let networkTimer: ReturnType<typeof setInterval> | null = null;
async function restartIo(reason: string): Promise<void> {
log.warn('DeltaChat: restarting IO', { reason });
try {
await dc.rpc.stopIo(accountId);
await dc.rpc.startIo(accountId);
lastImapIdleTs = Date.now();
consecutiveBadChecks = 0;
} catch (err) {
log.error('DeltaChat: IO restart failed', { err });
}
}
const adapter: ChannelAdapter = {
name: 'deltachat',
channelType: 'deltachat',
supportsThreads: false,
async setup(config: ChannelSetup): Promise<void> {
const accountDir = process.env.DC_ACCOUNT_DIR ?? 'dc-account';
dc = new DeltaChatOverJsonRpc(accountDir, {});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
dc.on('Error', (_: any, event: any) => log.error('DeltaChat RPC error', { msg: event.msg ?? event }));
const accounts = await dc.rpc.getAllAccounts();
accountId = accounts[0]?.id;
if (!accountId) accountId = await dc.rpc.addAccount();
const imapSecurity = env.DC_IMAP_SECURITY ?? '1';
const smtpSecurity = env.DC_SMTP_SECURITY ?? '2';
if (!(await dc.rpc.isConfigured(accountId))) {
await dc.rpc.setConfig(accountId, 'addr', env.DC_EMAIL);
await dc.rpc.setConfig(accountId, 'mail_pw', env.DC_PASSWORD);
await dc.rpc.setConfig(accountId, 'mail_server', env.DC_IMAP_HOST);
await dc.rpc.setConfig(accountId, 'mail_port', env.DC_IMAP_PORT);
await dc.rpc.setConfig(accountId, 'send_server', env.DC_SMTP_HOST);
await dc.rpc.setConfig(accountId, 'send_port', env.DC_SMTP_PORT);
await dc.rpc.configure(accountId);
log.info('DeltaChat: account configured', { email: env.DC_EMAIL });
} else {
log.info('DeltaChat: account ready', { email: env.DC_EMAIL });
}
await dc.rpc.setConfig(accountId, 'mail_security', imapSecurity);
await dc.rpc.setConfig(accountId, 'send_security', smtpSecurity);
await dc.rpc.setConfig(accountId, 'displayname', process.env.DC_DISPLAY_NAME ?? 'NanoClaw');
const avatarPath = process.env.DC_AVATAR_PATH;
if (avatarPath && existsSync(avatarPath)) {
await dc.rpc.setConfig(accountId, 'selfavatar', avatarPath);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
dc.on('IncomingMsg', async (contextId: number, event: any) => {
if (contextId !== accountId) return;
try {
let msg = await dc.rpc.getMessage(accountId, event.msgId);
if (msg.isInfo) return;
// Wait for large-message download to complete
if (msg.downloadState !== 'Done') {
await dc.rpc.downloadFullMessage(accountId, event.msgId);
for (let i = 0; i < 30; i++) {
await new Promise((r) => setTimeout(r, 1000));
msg = await dc.rpc.getMessage(accountId, event.msgId);
if (msg.downloadState === 'Done') break;
}
}
if (!msg.text && !msg.file) return;
const contact = await dc.rpc.getContact(accountId, msg.fromId);
const chat = await dc.rpc.getBasicChatInfo(accountId, event.chatId);
if (/^\/set-avatar$/i.test((msg.text || '').trim()) && msg.file) {
const userId = `deltachat:${contact.address}`;
try {
if (isDcAdmin(userId)) {
const absPath = resolve(msg.file as string);
await dc.rpc.setConfig(accountId, 'selfavatar', absPath);
await dc.rpc.sendMsg(accountId, event.chatId, { text: 'Avatar updated.' });
} else {
await dc.rpc.sendMsg(accountId, event.chatId, { text: 'Permission denied.' });
}
} catch (avatarErr: unknown) {
log.error('DeltaChat: failed to set avatar', {
err: avatarErr instanceof Error ? avatarErr.message : JSON.stringify(avatarErr),
});
await dc.rpc.sendMsg(accountId, event.chatId, { text: 'Failed to set avatar.' }).catch(() => {});
}
return;
}
const content: Record<string, unknown> = {
text: msg.text || '',
sender: contact.displayName || contact.address,
senderId: contact.address,
};
if (msg.file) {
content.attachments = [
{
name: basename(msg.file as string),
type: 'file',
localPath: msg.file,
},
];
}
const isGroup = chat?.isGroup ?? false;
await config.onInbound(String(event.chatId), null, {
id: String(event.msgId),
kind: 'chat',
content,
timestamp: new Date().toISOString(),
isGroup,
isMention: !isGroup,
});
} catch (err: unknown) {
log.error('DeltaChat: error handling incoming message', {
err: err instanceof Error ? err.message : JSON.stringify(err),
});
}
});
dc.on('ImapInboxIdle', (contextId: number) => {
if (contextId === accountId) lastImapIdleTs = Date.now();
});
dc.on('ConnectivityChanged', async (contextId: number) => {
if (contextId !== accountId) return;
try {
connectivity = await dc.rpc.getConnectivity(accountId);
} catch {
/* ignore */
}
});
await dc.rpc.startIo(accountId);
try {
connectivity = await dc.rpc.getConnectivity(accountId);
} catch {
/* ignore */
}
log.info('DeltaChat: IO started', { email: env.DC_EMAIL });
// Log invite link on every startup so the operator can bootstrap the first contact.
// In DeltaChat, contacts can't simply be added by email — the user must open this
// https://i.delta.chat/ invite URL in their DeltaChat app (or scan invite-qr.svg) to initiate contact.
try {
// null chatId → Setup-Contact invite (not group-specific)
const [inviteUrl, svg] = await dc.rpc.getChatSecurejoinQrCodeSvg(accountId, null);
const accountDir = resolve(process.env.DC_ACCOUNT_DIR ?? 'dc-account');
const svgPath = join(accountDir, 'invite-qr.svg');
writeFileSync(svgPath, svg);
log.info('DeltaChat: invite link — open URL in DeltaChat app or scan ' + svgPath, { url: inviteUrl });
} catch (err: unknown) {
log.warn('DeltaChat: could not generate invite link', {
err: err instanceof Error ? err.message : JSON.stringify(err),
});
}
// Connectivity watchdog: restart IO if IMAP goes quiet or connectivity drops
watchdogTimer = setInterval(
async () => {
try {
const conn = await dc.rpc.getConnectivity(accountId);
connectivity = conn;
if (conn < 3000) {
consecutiveBadChecks++;
if (consecutiveBadChecks >= 2) {
await restartIo(`connectivity=${conn} for 2 consecutive checks`);
}
} else {
consecutiveBadChecks = 0;
}
const idleAgeMin = (Date.now() - lastImapIdleTs) / 60000;
if (idleAgeMin > 20) {
await restartIo(`no IMAP IDLE in ${idleAgeMin.toFixed(0)}min`);
}
} catch (err: unknown) {
log.warn('DeltaChat: watchdog error', {
err: err instanceof Error ? err.message : String(err),
});
}
},
5 * 60 * 1000,
);
// Nudge the network stack every 10 minutes (recovers from prolonged idle)
networkTimer = setInterval(
async () => {
try {
await dc.rpc.maybeNetwork();
} catch {
/* ignore */
}
},
10 * 60 * 1000,
);
},
async teardown(): Promise<void> {
if (watchdogTimer) clearInterval(watchdogTimer);
if (networkTimer) clearInterval(networkTimer);
try {
await dc?.rpc.stopIo(accountId);
} catch {
/* ignore */
}
try {
dc?.close();
} catch {
/* ignore */
}
},
isConnected(): boolean {
// 4000 = fully connected (IMAP), 3000 = connecting; treat ≥3000 as live
return connectivity >= 3000;
},
async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise<string | undefined> {
const chatId = parseInt(platformId, 10);
if (isNaN(chatId)) {
log.warn('DeltaChat: invalid platformId for delivery', { platformId });
return undefined;
}
const content = message.content as Record<string, unknown>;
const text = typeof content.text === 'string' ? content.text : '';
if (message.files && message.files.length > 0) {
const tempDir = mkdtempSync(join(tmpdir(), 'nanoclaw-dc-'));
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let firstId: any;
for (let i = 0; i < message.files.length; i++) {
const f = message.files[i];
const tempPath = join(tempDir, f.filename);
writeFileSync(tempPath, f.data);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const params: any = { file: tempPath };
if (i === 0 && text) params.text = text;
const sentId = await dc.rpc.sendMsg(accountId, chatId, params);
if (i === 0) firstId = sentId;
}
return firstId != null ? String(firstId) : undefined;
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
}
if (!text) return undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sentId: any = await dc.rpc.sendMsg(accountId, chatId, { text });
return sentId != null ? String(sentId) : undefined;
},
};
return adapter;
}
registerChannelAdapter('deltachat', {
factory: () => {
const env = readEnvFile([...REQUIRED_ENV, ...OPTIONAL_ENV]);
if (!env.DC_EMAIL || !env.DC_PASSWORD) return null;
return createAdapter(env as DcEnv);
},
});
+34
View File
@@ -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');
});
});
+29
View File
@@ -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');
});
});
+34
View File
@@ -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');
});
});
+34
View File
@@ -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');
});
});
+3
View File
@@ -55,3 +55,6 @@ import './whatsapp.js';
// emacs (native HTTP bridge, no Chat SDK)
// import './emacs.js';
// deltachat (native, no Chat SDK)
// import './deltachat.js'
+34
View File
@@ -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');
});
});
+34
View File
@@ -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');
});
});
+34
View File
@@ -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');
});
});
+29
View File
@@ -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');
});
});
+34
View File
@@ -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
View File
@@ -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) => {
+34
View File
@@ -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');
});
});
+34
View File
@@ -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');
});
});
+29
View File
@@ -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');
});
});
+162
View File
@@ -0,0 +1,162 @@
/**
* Regression coverage for #2560 group @-mentions of the bot must set
* `InboundMessage.isMention`. Before the fix, the inbound construction
* site hard-coded `isMention: !isGroup ? true : undefined`, which dropped
* every group mention on the floor and prevented the router from waking
* the agent on a mention-only trigger.
*
* The detection logic lives in the exported pure helper `isBotMentionedInGroup`;
* the inbound site calls it with `normalized`, `botPhoneJid`, `botLidUser`.
* `isMention` is then computed as:
*
* isMention: !isGroup ? true : botMentionedInGroup ? true : undefined
*
* Both the helper and the call-site ternary are covered below so a future
* refactor that breaks either part fails this suite.
*/
import { describe, it, expect } from 'vitest';
import { computeIsMention, isBotMentionedInGroup, parseWhatsAppMentions } from './whatsapp.js';
const BOT_PHONE_JID = '15550009999@s.whatsapp.net';
const BOT_LID_USER = '987654321';
describe('isBotMentionedInGroup (#2560)', () => {
it('detects the bot phone JID in extendedTextMessage.contextInfo.mentionedJid', () => {
const normalized = {
extendedTextMessage: {
text: 'hey @15550009999 take a look',
contextInfo: { mentionedJid: [BOT_PHONE_JID] },
},
};
expect(isBotMentionedInGroup(normalized, BOT_PHONE_JID, BOT_LID_USER)).toBe(true);
});
it('returns false when the bot is not in mentionedJid', () => {
const normalized = {
extendedTextMessage: {
text: 'hey @15551112222 take a look',
contextInfo: { mentionedJid: ['15551112222@s.whatsapp.net'] },
},
};
expect(isBotMentionedInGroup(normalized, BOT_PHONE_JID, BOT_LID_USER)).toBe(false);
});
it('detects an LID-only mention when no phone JID is in the list', () => {
// Modern WhatsApp clients increasingly emit the LID even when the
// human typed a phone-number mention; the phone JID may not appear.
const normalized = {
extendedTextMessage: {
contextInfo: { mentionedJid: [`${BOT_LID_USER}@lid`] },
},
};
expect(isBotMentionedInGroup(normalized, BOT_PHONE_JID, BOT_LID_USER)).toBe(true);
});
it('detects a mention in an image caption', () => {
const normalized = {
imageMessage: {
caption: 'check this @15550009999',
contextInfo: { mentionedJid: [BOT_PHONE_JID] },
},
};
expect(isBotMentionedInGroup(normalized, BOT_PHONE_JID, BOT_LID_USER)).toBe(true);
});
it('returns false on an empty / missing mentionedJid array', () => {
expect(isBotMentionedInGroup({}, BOT_PHONE_JID, BOT_LID_USER)).toBe(false);
expect(
isBotMentionedInGroup(
{ extendedTextMessage: { contextInfo: { mentionedJid: [] } } },
BOT_PHONE_JID,
BOT_LID_USER,
),
).toBe(false);
});
it('returns false when neither bot identifier is known', () => {
const normalized = {
extendedTextMessage: {
contextInfo: { mentionedJid: [BOT_PHONE_JID, `${BOT_LID_USER}@lid`] },
},
};
expect(isBotMentionedInGroup(normalized, undefined, undefined)).toBe(false);
});
});
describe('InboundMessage.isMention semantics (#2560)', () => {
it('is undefined for a group message with no bot mention', () => {
expect(computeIsMention(true, false)).toBeUndefined();
});
it('is true for a group message where the bot is mentioned', () => {
expect(computeIsMention(true, true)).toBe(true);
});
it('is true for a DM regardless of mention state', () => {
// DMs are unconditionally mentions — the helper isn't consulted there.
expect(computeIsMention(false, false)).toBe(true);
expect(computeIsMention(false, true)).toBe(true);
});
});
describe('parseWhatsAppMentions', () => {
it('returns empty mentions for plain text', () => {
const { text, mentions } = parseWhatsAppMentions('hello there');
expect(text).toBe('hello there');
expect(mentions).toEqual([]);
});
it('extracts a single @<digits> mention into a JID', () => {
const { text, mentions } = parseWhatsAppMentions('hey @15551234567 you around?');
expect(text).toBe('hey @15551234567 you around?');
expect(mentions).toEqual(['15551234567@s.whatsapp.net']);
});
it('strips a leading + so the literal text matches the JID digits', () => {
const { text, mentions } = parseWhatsAppMentions('ping @+15551234567 please');
expect(text).toBe('ping @15551234567 please');
expect(mentions).toEqual(['15551234567@s.whatsapp.net']);
});
it('matches a mention at the start of the string', () => {
const { text, mentions } = parseWhatsAppMentions('@15551234567 hi');
expect(text).toBe('@15551234567 hi');
expect(mentions).toEqual(['15551234567@s.whatsapp.net']);
});
it('extracts multiple distinct mentions', () => {
const { text, mentions } = parseWhatsAppMentions('cc @15551234567 and @17775556666');
expect(text).toBe('cc @15551234567 and @17775556666');
expect(mentions).toEqual(['15551234567@s.whatsapp.net', '17775556666@s.whatsapp.net']);
});
it('deduplicates repeated mentions of the same number', () => {
const { mentions } = parseWhatsAppMentions('@15551234567 ping @15551234567 again');
expect(mentions).toEqual(['15551234567@s.whatsapp.net']);
});
it('does not tag email-like patterns', () => {
const { text, mentions } = parseWhatsAppMentions('write to test@1234567890.com');
expect(text).toBe('write to test@1234567890.com');
expect(mentions).toEqual([]);
});
it('does not tag sequences shorter than 5 digits', () => {
const { text, mentions } = parseWhatsAppMentions('see issue @123 for details');
expect(text).toBe('see issue @123 for details');
expect(mentions).toEqual([]);
});
it('handles punctuation directly after the digits', () => {
const { text, mentions } = parseWhatsAppMentions('thanks @15551234567!');
expect(text).toBe('thanks @15551234567!');
expect(mentions).toEqual(['15551234567@s.whatsapp.net']);
});
it('handles parenthesized mentions', () => {
const { text, mentions } = parseWhatsAppMentions('(@15551234567) wrote this');
expect(text).toBe('(@15551234567) wrote this');
expect(mentions).toEqual(['15551234567@s.whatsapp.net']);
});
});
+267 -70
View File
@@ -1,10 +1,15 @@
/**
* WhatsApp channel adapter (v2) native Baileys v6 implementation.
* WhatsApp channel adapter (v2) native Baileys v7 implementation.
*
* Implements ChannelAdapter directly (no Chat SDK bridge) using
* @whiskeysockets/baileys v6 (stable). Ports proven v1 infrastructure:
* getMessage fallback, outgoing queue, group metadata cache, LID mapping,
* reconnection with backoff.
* @whiskeysockets/baileys 7.0.0-rc.9 (pinned last release, unmaintained).
* Ports proven v1 infrastructure: getMessage fallback, outgoing queue,
* group metadata cache, LID mapping, reconnection with backoff.
*
* LID handling: Baileys v7 provides participantAlt / remoteJidAlt on every
* inbound message via extractAddressingContext, plus a real
* signalRepository.lidMapping.getPNForLID API. The adapter always resolves
* to phone JID (@s.whatsapp.net) before emitting to the router.
*
* Auth credentials persist in store/auth/. On first run:
* - If WHATSAPP_PHONE_NUMBER is set pairing code (printed to log)
@@ -22,6 +27,7 @@ import { pino } from 'pino';
import {
makeWASocket,
proto,
Browsers,
DisconnectReason,
fetchLatestWaWebVersion,
@@ -40,29 +46,54 @@ import { registerChannelAdapter } from './channel-registry.js';
import { normalizeOptions, type NormalizedOption } from './ask-question.js';
import type { ChannelAdapter, ChannelSetup, ConversationInfo, InboundMessage, OutboundMessage } from './adapter.js';
// Baileys v6 bug: getPlatformId sends charCode (49) instead of enum value (1).
// Fixed in Baileys 7.x but not backported. Without this, pairing codes fail with
// "couldn't link device" because WhatsApp receives an invalid platform ID.
// Must use createRequire — ESM `import *` creates a read-only namespace.
// proto is not available as a named ESM export — use createRequire (same as v1)
import { createRequire } from 'module';
const _require = createRequire(import.meta.url);
const { proto } = _require('@whiskeysockets/baileys') as { proto: any };
try {
const _generics = _require('@whiskeysockets/baileys/lib/Utils/generics') as Record<string, unknown>;
_generics.getPlatformId = (browser: string): string => {
const platformType =
proto.DeviceProps.PlatformType[browser.toUpperCase() as keyof typeof proto.DeviceProps.PlatformType];
return platformType ? platformType.toString() : '1';
};
} catch {
// If CJS require fails (Node version mismatch), pairing codes may not work
// but QR auth will still function fine.
log.warn('Could not patch getPlatformId — pairing code auth may fail');
}
const baileysLogger = pino({ level: 'silent' });
/**
* Fetch the latest WhatsApp Web version. Baileys' built-in
* fetchLatestWaWebVersion scrapes sw.js which is aggressively
* rate-limited (429). When it fails, Baileys falls back to a
* hardcoded version that goes stale within weeks WhatsApp
* rejects connections with an expired buildHash (405 at Noise
* layer). This fetches from wppconnect's version tracker as a
* more reliable source, with Baileys' own fetch as fallback.
*/
async function resolveWaWebVersion(): Promise<[number, number, number]> {
// 1. Try wppconnect version tracker (HTML scrape — no JSON API)
try {
const res = await fetch('https://wppconnect.io/whatsapp-versions/', {
signal: AbortSignal.timeout(5000),
});
if (res.ok) {
const html = await res.text();
const match = html.match(/2\.3000\.(\d+)/);
if (match) {
const version: [number, number, number] = [2, 3000, Number(match[1])];
log.info('Fetched WA Web version from wppconnect', { version });
return version;
}
}
} catch {
// Fall through to Baileys' own fetch
}
// 2. Try Baileys' built-in fetch (scrapes sw.js — often 429'd)
try {
const { version } = await fetchLatestWaWebVersion({});
if (version) {
log.info('Fetched WA Web version from Baileys', { version });
return version as [number, number, number];
}
} catch {
// Fall through
}
throw new Error(
'Could not fetch current WhatsApp Web version from any source. ' +
'Baileys hardcodes a stale version that WhatsApp rejects (405). ' +
'Check network connectivity to wppconnect.io and web.whatsapp.com.',
);
}
const AUTH_DIR = path.join(process.cwd(), 'store', 'auth');
const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h
const GROUP_METADATA_CACHE_TTL_MS = 60_000; // 1 min for outbound sends
@@ -120,10 +151,99 @@ function transformForWhatsApp(text: string): string {
return text;
}
/** Convert Claude's markdown to WhatsApp-native formatting. */
function formatWhatsApp(text: string): string {
// WhatsApp tags `@<phone-digits>` (515 digit local part — covers short test
// numbers up to ITU E.164 max). A leading `+` is accepted but stripped so
// the literal in text matches the digits in the JID — WhatsApp clients
// scan the rendered text for `@<digits>` and cross-reference it with the
// contextInfo.mentionedJid list to draw the bold/clickable tag.
const MENTION_RE = /(^|[^\w@+])@\+?(\d{5,15})(?!\d)/g;
/** Extract `@<digits>` mentions from text and normalize them. */
export function parseWhatsAppMentions(text: string): { text: string; mentions: string[] } {
const mentions = new Set<string>();
const out = text.replace(MENTION_RE, (_full, lead: string, digits: string) => {
mentions.add(`${digits}@s.whatsapp.net`);
return `${lead}@${digits}`;
});
return { text: out, mentions: [...mentions] };
}
/**
* Convert Claude's markdown to WhatsApp-native formatting and extract any
* `@<phone>` mentions. Code-block regions are passed through untouched so
* phone-like sequences inside code aren't tagged.
*/
function formatWhatsApp(text: string): { text: string; mentions: string[] } {
const segments = splitProtectedRegions(text);
return segments.map(({ content, isProtected }) => (isProtected ? content : transformForWhatsApp(content))).join('');
const mentions = new Set<string>();
const out = segments
.map(({ content, isProtected }) => {
if (isProtected) return content;
const transformed = transformForWhatsApp(content);
const { text: withMentions, mentions: found } = parseWhatsAppMentions(transformed);
for (const m of found) mentions.add(m);
return withMentions;
})
.join('');
return { text: out, mentions: [...mentions] };
}
/**
* Subset of a normalized Baileys message content carrying the message
* types that can host a `contextInfo.mentionedJid` array. Kept as a
* structural type so the helper (and its tests) don't pull in the full
* `proto.IMessage` shape just to construct fixtures.
*/
type MentionContextSource = {
extendedTextMessage?: { contextInfo?: { mentionedJid?: string[] | null } | null } | null;
imageMessage?: { contextInfo?: { mentionedJid?: string[] | null } | null } | null;
videoMessage?: { contextInfo?: { mentionedJid?: string[] | null } | null } | null;
documentMessage?: { contextInfo?: { mentionedJid?: string[] | null } | null } | null;
};
/**
* Detect an explicit @-mention of the bot in a WhatsApp group message.
* WhatsApp carries mentions in `contextInfo.mentionedJid` on the text +
* caption-bearing message types. Matches against both the bot's phone
* JID and LID most modern clients emit the LID even when the human
* typed a phone-number mention.
*
* Exported for unit testing. The inbound construction site calls this
* to set `InboundMessage.isMention` for group messages (#2560). DMs are
* unconditionally mentions and don't go through this helper.
*/
export function isBotMentionedInGroup(
normalized: MentionContextSource,
botPhoneJid: string | undefined,
botLidUser: string | undefined,
): boolean {
if (!botPhoneJid && !botLidUser) return false;
const mentionedJids: string[] = [
...(normalized.extendedTextMessage?.contextInfo?.mentionedJid ?? []),
...(normalized.imageMessage?.contextInfo?.mentionedJid ?? []),
...(normalized.videoMessage?.contextInfo?.mentionedJid ?? []),
...(normalized.documentMessage?.contextInfo?.mentionedJid ?? []),
];
const botLidJid = botLidUser ? `${botLidUser}@lid` : undefined;
return mentionedJids.some((jid) => {
if (!jid) return false;
const bare = jid.split(':')[0];
return bare === botPhoneJid || bare === botLidJid;
});
}
/**
* Compute `InboundMessage.isMention` for a WhatsApp message:
* - DMs are always mentions (router auto-engages on the bot's behalf).
* - Group messages are mentions only when the bot is explicitly tagged.
*
* Returns `true | undefined` rather than `true | false` because the
* `InboundMessage` field is `isMention?: boolean` and downstream code
* treats `undefined` differently than an explicit `false` (#2560).
*/
export function computeIsMention(isGroup: boolean, botMentionedInGroup: boolean): true | undefined {
if (!isGroup) return true;
return botMentionedInGroup ? true : undefined;
}
/** Map file extension to Baileys media message type. */
@@ -161,14 +281,16 @@ registerChannelAdapter('whatsapp', {
// State
let sock: WASocket;
let connected = false;
let shuttingDown = false;
let setupConfig: ChannelSetup;
// LID → phone JID mapping (WhatsApp's new ID system)
const lidToPhoneMap: Record<string, string> = {};
let botLidUser: string | undefined;
let botPhoneJid: string | undefined;
// Outgoing queue for messages sent while disconnected
const outgoingQueue: Array<{ jid: string; text: string }> = [];
const outgoingQueue: Array<{ jid: string; text: string; mentions?: string[] }> = [];
let flushing = false;
// Sent message cache for retry/re-encrypt requests
@@ -207,21 +329,30 @@ registerChannelAdapter('whatsapp', {
groupMetadataCache.clear();
}
async function translateJid(jid: string): Promise<string> {
async function translateJid(jid: string, altJid?: string): Promise<string> {
if (!jid.endsWith('@lid')) return jid;
const lidUser = jid.split('@')[0].split(':')[0];
// 1. Check local cache
const cached = lidToPhoneMap[lidUser];
if (cached) return cached;
// Query Baileys' signal repository
// 2. Use the alt JID from extractAddressingContext (v7 provides this
// on every inbound message as remoteJidAlt / participantAlt)
if (altJid && !altJid.endsWith('@lid')) {
const phoneJid = altJid.includes('@') ? altJid : `${altJid}@s.whatsapp.net`;
setLidPhoneMapping(lidUser, phoneJid);
log.info('Translated LID via alt JID', { lidJid: jid, phoneJid });
return phoneJid;
}
// 3. Query Baileys v7 LID mapping store
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pn = await (sock.signalRepository as any)?.lidMapping?.getPNForLID(jid);
const pn = await sock.signalRepository.lidMapping.getPNForLID(jid);
if (pn) {
const phoneJid = `${pn.split('@')[0].split(':')[0]}@s.whatsapp.net`;
setLidPhoneMapping(lidUser, phoneJid);
log.info('Translated LID to phone JID', { lidJid: jid, phoneJid });
log.info('Translated LID via signal repository', { lidJid: jid, phoneJid });
return phoneJid;
}
} catch (err) {
@@ -280,7 +411,9 @@ registerChannelAdapter('whatsapp', {
log.info('Flushing outgoing message queue', { count: outgoingQueue.length });
while (outgoingQueue.length > 0) {
const item = outgoingQueue.shift()!;
const sent = await sock.sendMessage(item.jid, { text: item.text });
const payload: { text: string; mentions?: string[] } = { text: item.text };
if (item.mentions && item.mentions.length > 0) payload.mentions = item.mentions;
const sent = await sock.sendMessage(item.jid, payload);
if (sent?.key?.id && sent.message) {
sentMessageCache.set(sent.key.id, sent.message);
}
@@ -332,14 +465,16 @@ registerChannelAdapter('whatsapp', {
return results;
}
async function sendRawMessage(jid: string, text: string): Promise<string | undefined> {
async function sendRawMessage(jid: string, text: string, mentions?: string[]): Promise<string | undefined> {
if (!connected) {
outgoingQueue.push({ jid, text });
outgoingQueue.push({ jid, text, mentions });
log.info('WA disconnected, message queued', { jid, queueSize: outgoingQueue.length });
return;
}
try {
const sent = await sock.sendMessage(jid, { text });
const payload: { text: string; mentions?: string[] } = { text };
if (mentions && mentions.length > 0) payload.mentions = mentions;
const sent = await sock.sendMessage(jid, payload);
if (sent?.key?.id && sent.message) {
sentMessageCache.set(sent.key.id, sent.message);
if (sentMessageCache.size > SENT_MESSAGE_CACHE_MAX) {
@@ -349,7 +484,7 @@ registerChannelAdapter('whatsapp', {
}
return sent?.key?.id ?? undefined;
} catch (err) {
outgoingQueue.push({ jid, text });
outgoingQueue.push({ jid, text, mentions });
log.warn('Failed to send, message queued', { jid, err, queueSize: outgoingQueue.length });
return undefined;
}
@@ -360,10 +495,7 @@ registerChannelAdapter('whatsapp', {
async function connectSocket(): Promise<void> {
const { state, saveCreds } = await useMultiFileAuthState(authDir);
const { version } = await fetchLatestWaWebVersion({}).catch((err) => {
log.warn('Failed to fetch latest WA Web version, using default', { err });
return { version: undefined };
});
const version = await resolveWaWebVersion();
sock = makeWASocket({
version,
@@ -380,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
View File
@@ -9,15 +9,17 @@
* will later emit as event.platformId, or router lookups miss and messages
* get silently dropped.
*
* Native adapters (Signal, WhatsApp, iMessage) use their own ID formats and
* send them as-is no channel prefix. WhatsApp/iMessage emit JIDs/emails
* containing '@'. Signal emits raw phone numbers ('+15551234567') for DMs
* and 'group:<id>' for group chats. Prefixing any of these would cause a
* mismatch with what the adapter later emits.
* Native adapters (Signal, WhatsApp, iMessage, DeltaChat) use their own ID
* formats and send them as-is no channel prefix. WhatsApp/iMessage emit
* JIDs/emails containing '@'. Signal emits raw phone numbers ('+15551234567')
* for DMs and 'group:<id>' for group chats. DeltaChat emits numeric chat IDs
* ('12'). Prefixing any of these would cause a mismatch with what the adapter
* later emits.
*/
export function namespacedPlatformId(channel: string, raw: string): string {
if (raw.startsWith(`${channel}:`)) return raw;
if (raw.includes('@')) return raw;
if (raw.startsWith('+') || raw.startsWith('group:')) return raw;
if (channel === 'deltachat') return raw;
return `${channel}:${raw}`;
}