Compare commits

...

33 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
exe.dev user 878d3706b4 telegram: redirect post-pairing chat message back to the installer
The previous wording promised a welcome DM "shortly", but in practice the
welcome can be delayed (cold container start, OneCLI selective-secret
mode, etc.) until well after the user has more terminal interactions to
complete. Telling them to wait in Telegram pulls attention away from the
installer at the worst moment.

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 22:19:28 -04:00
56 changed files with 2397 additions and 528 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');
});
});
+135 -28
View File
@@ -548,6 +548,141 @@ describe('SignalAdapter', () => {
});
});
// --- Outbound attachments ---
describe('deliver — attachments', () => {
// Real fs writes happen in tmpdir(); confirm the bytes round-trip and
// are cleaned up after deliver returns.
it('sends a single attachment via attachments[] param', async () => {
const fs = await import('node:fs');
const adapter = createAdapter();
await adapter.setup(createMockSetup());
tcpRef.fakeSocket.write.mockClear();
await adapter.deliver('+15555550123', null, {
kind: 'file',
content: {},
files: [{ filename: 'report.md', data: Buffer.from('# Report\n\nbody') }],
});
const sendCalls = getRpcCallsForMethod('send');
expect(sendCalls.length).toBe(1);
const params = sendCalls[0].params as Record<string, unknown>;
expect(params.recipient).toEqual(['+15555550123']);
expect(params.account).toBe('+15551234567');
expect(params.message).toBeUndefined();
const paths = params.attachments as string[];
expect(paths).toHaveLength(1);
expect(paths[0]).toMatch(/signal-out-\d+-[a-z0-9]+-report\.md$/);
// Temp file should no longer exist — finally{} cleanup ran
expect(fs.existsSync(paths[0])).toBe(false);
await adapter.teardown();
});
it('sends text first, then attachment, when both are present', async () => {
const adapter = createAdapter();
await adapter.setup(createMockSetup());
tcpRef.fakeSocket.write.mockClear();
await adapter.deliver('+15555550123', null, {
kind: 'file',
content: { text: 'Here is the digest' },
files: [{ filename: 'digest.md', data: Buffer.from('content') }],
});
const sendCalls = getRpcCallsForMethod('send');
expect(sendCalls).toHaveLength(2);
// First call: text message
expect(sendCalls[0].params).toEqual(
expect.objectContaining({ message: 'Here is the digest', recipient: ['+15555550123'] }),
);
expect((sendCalls[0].params as Record<string, unknown>).attachments).toBeUndefined();
// Second call: attachment, no message
expect(sendCalls[1].params).toEqual(
expect.objectContaining({ recipient: ['+15555550123'] }),
);
const attachments = (sendCalls[1].params as Record<string, unknown>).attachments as string[];
expect(attachments).toHaveLength(1);
await adapter.teardown();
});
it('sends multiple attachments in a single send call', async () => {
const adapter = createAdapter();
await adapter.setup(createMockSetup());
tcpRef.fakeSocket.write.mockClear();
await adapter.deliver('+15555550123', null, {
kind: 'file',
content: {},
files: [
{ filename: 'a.txt', data: Buffer.from('a') },
{ filename: 'b.png', data: Buffer.from([0x89, 0x50, 0x4e, 0x47]) },
],
});
const sendCalls = getRpcCallsForMethod('send');
expect(sendCalls).toHaveLength(1);
const attachments = (sendCalls[0].params as Record<string, unknown>).attachments as string[];
expect(attachments).toHaveLength(2);
expect(attachments[0]).toMatch(/-a\.txt$/);
expect(attachments[1]).toMatch(/-b\.png$/);
await adapter.teardown();
});
it('uses groupId for group destinations', async () => {
const adapter = createAdapter();
await adapter.setup(createMockSetup());
tcpRef.fakeSocket.write.mockClear();
await adapter.deliver('group:abc123', null, {
kind: 'file',
content: {},
files: [{ filename: 'pic.jpg', data: Buffer.from('jpg') }],
});
const sendCalls = getRpcCallsForMethod('send');
expect(sendCalls).toHaveLength(1);
const params = sendCalls[0].params as Record<string, unknown>;
expect(params.groupId).toBe('abc123');
expect(params.recipient).toBeUndefined();
await adapter.teardown();
});
/**
* Defensive test: `OutboundFile.filename` is operator-supplied data, so
* the implementation must not let a filename containing path separators
* escape the temp directory. We feed an attempt-to-traverse filename and
* assert the resolved path stays strictly inside `tmpdir()`.
*/
it('keeps temp paths inside tmpdir even when filename contains path separators', async () => {
const path = await import('node:path');
const os = await import('node:os');
const adapter = createAdapter();
await adapter.setup(createMockSetup());
tcpRef.fakeSocket.write.mockClear();
await adapter.deliver('+15555550123', null, {
kind: 'file',
content: {},
files: [{ filename: '../sneaky.txt', data: Buffer.from('x') }],
});
const sendCalls = getRpcCallsForMethod('send');
const paths = (sendCalls[0].params as Record<string, unknown>).attachments as string[];
const resolvedTmp = path.resolve(os.tmpdir());
const resolvedResult = path.resolve(paths[0]);
// path.resolve normalizes away any "../"; if sanitization failed, the
// result would resolve to tmpdir's parent.
expect(resolvedResult.startsWith(resolvedTmp + path.sep)).toBe(true);
await adapter.teardown();
});
});
// --- Text styles ---
describe('text styles', () => {
@@ -784,34 +919,6 @@ describe('SignalAdapter', () => {
});
});
// --- Outbound files ---
describe('outbound files', () => {
it('logs a warning and drops unsupported file attachments', async () => {
const { log } = await import('../log.js');
const warnMock = log.warn as unknown as ReturnType<typeof vi.fn>;
const adapter = createAdapter();
await adapter.setup(createMockSetup());
warnMock.mockClear();
await adapter.deliver('+15555550123', null, {
kind: 'text',
content: { text: 'with an attachment' },
files: [{ filename: 'hi.txt', data: Buffer.from('hi') }],
});
const sendCalls = getRpcCallsForMethod('send');
expect(sendCalls.length).toBeGreaterThan(0);
expect(warnMock).toHaveBeenCalledWith(
'Signal: outbound files not supported, dropping',
expect.objectContaining({ platformId: '+15555550123', count: 1 }),
);
await adapter.teardown();
});
});
// --- setTyping ---
describe('setTyping', () => {
+54 -15
View File
@@ -8,9 +8,9 @@
* Ported from v1 see v1 source for commit history.
*/
import { execFileSync, execSync, spawn } from 'node:child_process';
import { existsSync, readFileSync, unlinkSync } from 'node:fs';
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
import { createConnection, type Socket } from 'node:net';
import { homedir } from 'node:os';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
import type { ChannelAdapter, ChannelSetup, InboundMessage, OutboundMessage } from './adapter.js';
@@ -744,6 +744,51 @@ export function createSignalAdapter(config: {
log.info('Signal message sent', { platformId, length: text.length });
}
/**
* Send one or more file attachments via signal-cli's `send` JSON-RPC, which
* accepts an `attachments` array of host filesystem paths. The OutboundFile
* Buffer is materialized to an OS temp file so signal-cli can read it, then
* removed in the finally block.
*
* Caption text, if any, is sent first via `sendText` (which handles chunking
* + textStyles) keeps this function single-purpose and avoids a long
* caption colliding with signal-cli's per-message size limits.
*/
async function sendAttachments(platformId: string, files: { filename: string; data: Buffer }[]): Promise<void> {
if (!connected || !tcp) return;
if (files.length === 0) return;
const tempPaths: string[] = [];
for (const file of files) {
const safeName = file.filename.replace(/[/\\\0]/g, '_');
const tempPath = join(tmpdir(), `signal-out-${Date.now()}-${Math.random().toString(36).slice(2, 8)}-${safeName}`);
writeFileSync(tempPath, file.data);
tempPaths.push(tempPath);
}
try {
const params: Record<string, unknown> = { attachments: tempPaths };
if (config.account) params.account = config.account;
if (platformId.startsWith('group:')) {
params.groupId = platformId.slice('group:'.length);
} else {
params.recipient = [platformId];
}
await tcp.rpc('send', params);
log.info('Signal attachments sent', { platformId, count: files.length, filenames: files.map((f) => f.filename) });
} catch (err) {
log.error('Signal: attachment send failed', { platformId, count: files.length, err });
} finally {
for (const p of tempPaths) {
try {
unlinkSync(p);
} catch {
/* best-effort cleanup */
}
}
}
}
async function waitForDaemon(): Promise<boolean> {
const maxWait = 30_000;
const pollInterval = 1000;
@@ -847,17 +892,6 @@ export function createSignalAdapter(config: {
},
async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise<string | undefined> {
if (message.files && message.files.length > 0) {
// Native adapter doesn't yet forward file uploads to signal-cli's
// `send --attachment`. Don't silently swallow — operators need to see
// that an attachment was requested but not sent.
log.warn('Signal: outbound files not supported, dropping', {
platformId,
count: message.files.length,
filenames: message.files.map((f) => f.filename),
});
}
const content = message.content as Record<string, unknown> | string | undefined;
let text: string | null = null;
if (typeof content === 'string') {
@@ -865,9 +899,14 @@ export function createSignalAdapter(config: {
} else if (content && typeof content === 'object' && typeof content.text === 'string') {
text = content.text;
}
if (!text) return undefined;
await sendText(platformId, text);
const files = message.files ?? [];
// Send accompanying text first so it lands above the attachment(s) in
// the recipient's chat. Both branches no-op cleanly if their input is
// empty, so any combination of (text, files) works.
if (text) await sendText(platformId, text);
if (files.length > 0) await sendAttachments(platformId, files);
return undefined;
},
+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');
});
});
+2 -1
View File
@@ -99,7 +99,7 @@ async function sendPairingConfirmation(token: string, platformId: string): Promi
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text: "Pairing success! I'm spinning up the agent now, you'll get a message from them shortly.",
text: 'Pairing success! Head back to the NanoClaw installer to finish setup.',
}),
});
if (!res.ok) {
@@ -210,6 +210,7 @@ registerChannelAdapter('telegram', {
extractReplyContext,
supportsThreads: false,
transformOutboundText: sanitizeTelegramLegacyMarkdown,
maxTextLength: 4000,
});
const botUsernamePromise = fetchBotUsername(token);
+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}`;
}