Compare commits

...

28 Commits

Author SHA1 Message Date
gabi-simons 221c4948cd feat(telegram): implement resolveChannelName via getChat API
Enables the channel-approval flow to show the Telegram group name
in the approval card instead of a generic "a telegram channel".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 12:56:12 +00:00
Gabi Simons 4a8887636c feat(slack): implement resolveChannelName via fetchThread
Adds resolveChannelName to the Slack adapter so the channel-approval
flow can show the actual channel name in the approval card. Uses the
existing fetchThread → conversations.info path.

Depends on: qwibitai/nanoclaw#2105 (adds resolveChannelName to ChannelAdapter interface)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 13:19:49 +00:00
gavrielc 7789fcc67a Update stale /new-setup references to /setup
The /new-setup skill was collapsed into /setup on main. Update doc
comments and one user-facing HINT to match.
2026-04-28 17:17:15 +03:00
gavrielc 6ec5f06d51 Merge main into channels
Bring channels up to date with main, including the channel-inbound
attachment path-traversal fix.

Resolved conflicts:
- package.json: kept channels-specific deps + added @clack/core from main
- pnpm-lock.yaml: regenerated against merged package.json
- setup/index.ts: union STEPS map (pair-telegram, whatsapp-auth, signal-auth)
- setup/whatsapp-auth.ts: took main's version (deliberate fork for setup-auto)
2026-04-28 13:50:36 +03:00
gavrielc 8f4c79dcaa Fix path traversal in WhatsApp attachment handling 2026-04-28 13:30:17 +03:00
gavrielc de448ef22f Merge pull request #1962 from jorgenclaw/feat/signal-improvements
feat(signal): replyTo shape fix + voice transcription, images, mentions, groupV2
2026-04-24 15:45:18 +03:00
Scott Jorgensen 53513db5bc feat(signal): replyTo shape fix + voice transcription, images, mentions, groupV2
The Signal adapter from #1953 had several gaps that left a meaningful set of
inbound message types unreachable to the agent. This change fills those gaps
and fixes one quiet contract bug between the adapter and the agent-runner
formatter, all in the existing factory shape so the wiring stays compatible.

Bug fix
- Quote-reply context was never reaching the agent. The adapter wrote
  `replyToSenderName` / `replyToMessageContent` / `replyToMessageId` at the
  top level of `content`, but the formatter at
  `container/agent-runner/src/formatter.ts:formatReplyContext` reads a
  nested `content.replyTo: { sender, text }`, requiring both sender and
  text or it omits the `<quoted_message>` block entirely. The two halves
  disagreed; this commit aligns them.

Capability adds (additive — defaults preserve existing behavior)
- Voice notes are now transcribed when `WHISPER_BIN` (local whisper.cpp)
  or `OPENAI_API_KEY` is set, surfacing as `[Voice: <transcript>]`. With
  neither set, behavior matches the prior `[Voice Message]` placeholder.
- Image attachments are forwarded as `[Image: <path>]` lines plus a
  structured `attachments` array on `content`, so vision-capable models
  actually see the picture instead of nothing.
- `@<mention>` placeholders are resolved to display names from the
  envelope's `mentions` array, so the agent reads "@Bob" instead of a UUID.
- Modern Signal groups (groupV2) are routed correctly. The previous code
  read only `groupInfo.groupId` and treated v2-only groups as DMs.

Tests
- Updated quote-context test to assert the nested `replyTo` shape.
- Replaced the "skips messages with attachments but no text" test with a
  positive assertion that image attachments are forwarded.
- Added tests for groupV2 routing and mention resolution.
- All 268 tests pass; build clean.

Compat
- Factory signature, env var names, daemon-management flag, EchoCache,
  text-style handling, and chunkText are unchanged. Operators who do not
  set `WHISPER_BIN` or `OPENAI_API_KEY` get exactly the prior voice-note
  UX. No changes to the channel-registry barrel are required.
2026-04-23 17:48:25 -07:00
gavrielc f0a0939860 feat(signal): add native Signal channel adapter
Ships the Signal channel adapter code corresponding to the
/add-signal skill. Native adapter speaking JSON-RPC to a
signal-cli TCP daemon — no Chat SDK bridge, no npm deps.

- src/channels/signal.ts — adapter implementation with DM and group
  support, echo suppression, Markdown → Signal text-style conversion,
  quoted-reply extraction, typing indicators (DMs only), Note to Self
  routing, voice-attachment detection, managed daemon lifecycle.
- src/channels/signal.test.ts — 31 vitest tests covering connection
  lifecycle, inbound/outbound paths, nested style offsets, italic
  mapping, cross-recipient echo isolation, socket-close handling,
  and file-drop warnings.
- src/channels/index.ts — signal entry added as a commented-out import
  for parity with other native channels; /add-signal uncomments it
  during install.

Env vars: SIGNAL_ACCOUNT (required), SIGNAL_TCP_HOST / SIGNAL_TCP_PORT,
SIGNAL_CLI_PATH, SIGNAL_MANAGE_DAEMON, SIGNAL_DATA_DIR.

Originally contributed in #1953; the adapter lives on this branch per
the channels/providers split (trunk doesn't ship channel adapters).

Co-Authored-By: Doug Daniels <ddaniels888@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:55:45 +03:00
gavrielc c91168bd74 style(telegram): apply prettier formatting to HR test 2026-04-23 01:50:44 +03:00
gavrielc 68352351e4 fix(telegram): flatten Markdown horizontal rules in the sanitizer
Bare --- / *** / ___ HR lines confuse Telegram's legacy Markdown parser and (for ***/___) unbalance the delimiter count the sanitizer relies on, which causes the fallback to strip all formatting. Replace them with a plain Unicode divider (⎯⎯⎯) before the delimiter pass — same approach the bullet conversion already uses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 01:50:21 +03:00
gavrielc 22ed951f05 fix(channels): use named pino import for NodeNext compat
pino 9.x's .d.ts exports `{ pino as default, pino }` where `pino` merges a
function declaration with a namespace. Under `moduleResolution: NodeNext`,
TypeScript resolves the default export to the namespace type (`typeof pino`)
rather than the callable function — `pino({ level: 'silent' })` fails with
"typeof import(...) has no call signatures" at `pnpm run build` time.

Switching to the named import resolves to the callable function directly,
sidestepping the quirk. Same zero-runtime change, but the build succeeds.

Fixed in both src/channels/whatsapp.ts and setup/whatsapp-auth.ts for
consistency; same pattern hit both files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 12:36:58 +03:00
gavrielc 4dfc2e3a24 style: apply prettier to whatsapp.ts 2026-04-22 12:02:22 +03:00
gavrielc 3a29674b46 fix(channels): adapt adapters to v2 interface changes
v2's adapter.ts + chat-sdk-bridge.ts rewrites left four branch-only
adapters with type errors. Minimal adaptations to the new contract:

- whatsapp.ts: drop the conversations-filter scheme. The v2 router
  owns messaging-group routing now, so the adapter no longer needs
  hostConfig.conversations, buildConversationMap, or the
  updateConversations method. ConversationConfig is gone from the
  contract — syncConversations still returns ConversationInfo.
- linear.ts: drop catchAll from the chat-sdk bridge config. The
  bridge's default onNewMessage(/./) handler always forwards.
- emacs.test.ts: replace conversations: [] in the ChannelSetup
  fixture with onInboundEvent: vi.fn() (the new admin-transport hook).
- wechat.ts: dep wechat-ilink-client@0.1.0 was referenced by the
  file but never added to package.json — pinned it at ^0.1.0. Types
  now flow from the package, clearing the four implicit-any errors.
2026-04-22 12:02:00 +03:00
gavrielc e8b01bdb07 style: apply prettier to merged files 2026-04-22 11:56:59 +03:00
gavrielc 6ed228f9a8 Merge v2 into channels
Picks up 105 commits from v2 (engage modes, sender/channel approval flows,
host-sweep heartbeat lifecycle, setup/onecli refactor, setup-flow docs,
DeliveryAddress/InboundEvent adapter contract changes).

Retires 9 deprecated skills that moved out of this branch's scope:
add-compact, add-gmail, add-image-vision, add-pdf-reader, add-reactions,
add-telegram-swarm, add-voice-transcription, channel-formatting,
use-local-whisper.

Preserves channels-branch code: all 20 channel adapters (discord, slack,
telegram, whatsapp, wechat, matrix, emacs, iMessage, github, linear, teams,
gchat, webex, resend, whatsapp-cloud + helpers) plus chat-sdk deps.

Conflicts resolved:
- package.json: combined channels' adapter deps with v2's telegram bump
  (^4.24.0 → 4.26.0) and new @clack/prompts + kleur.
- pnpm-lock.yaml: regenerated from v2 baseline via pnpm install.
- setup/pair-telegram.ts: took v2's version (new PAIR_TELEGRAM_CODE block
  protocol; channels' older PAIR_TELEGRAM_ISSUED design superseded).

Note: host TS build will fail on adapter type drift (adapter.ts renamed
InboundMessage → InboundEvent; chat-sdk-bridge.ts rewritten). Fix in
follow-up commits.
2026-04-22 11:56:30 +03:00
Gabi Simons fb2790a5d5 feat(channels/wechat): personal WeChat adapter via Tencent iLink Bot API
Native adapter (no Chat SDK bridge) for personal WeChat, using
Tencent's official iLink Bot API at ilinkai.weixin.qq.com — the same
protocol @tencent-weixin/openclaw-weixin uses. No webhook, no ban
risk, no paid tokens.

Lifecycle:
- Factory gated on WECHAT_ENABLED=true in .env.
- On setup, resume from data/wechat/auth.json if present; otherwise
  run QR login (URL written to data/wechat/qr.txt and logged) and
  persist botToken, accountId, baseUrl, operatorUserId on success.
- Long-poll via WeChatClient.start() with sync-buf persistence so
  no messages are dropped across restarts.
- Inbound routes to setupConfig.onInbound with platform_id =
  wechat:<from_user_id|group_id> and a log hint pointing at the
  /add-wechat wire-dm.ts helper for post-login wiring.
- Outbound via sendText (context_token auto-cached by the client).

Region-restricted to mainland 微信 accounts — the iLink QR flow
doesn't complete from international WeChat clients. This is a
platform-side restriction, not an adapter bug.

Pairs with the /add-wechat skill on v2 (installs this file, adds
the self-registration import on the user's install, pins
wechat-ilink-client@0.1.0).

Addresses https://github.com/qwibitai/nanoclaw/issues/1901.

Co-Authored-By: ythx-101 <226337373+ythx-101@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 17:24:56 +00:00
Gabi Simons 74c9c9e27a feat(channels): extend Matrix adapter with DM user-handle resolution
Self-contained — no changes to shared src. All Matrix quirks handled
in this file:

- Access-token or username/password auth via env (adapter reads
  process.env directly)
- Resolves DM user handles (matrix:@user:server) to room IDs on
  outbound via adapter.openDM; rewrites inbound room IDs back to
  user handles so the router matches the messaging group wired at
  init time
- Synchronous isDM() based on room member count (Chat SDK requires
  it; the upstream adapter only has async isDirectRoom)
- Prefixes senderId with "matrix:" so permissions module matches
  init-first-agent's channel-prefixed user IDs
- Awaits liveSyncReady before returning from setup() — prevents
  host delivery polls from starving the SDK's sync generator
  microtask queue
- Defaults MATRIX_INVITE_AUTOJOIN=true so DMs work without manual
  joins
2026-04-20 11:03:38 +00:00
Gabi Simons 91400f9f66 fix(channels/linear): OAuth app auth, userName, team-based channel ID, catchAll
Support client credentials auth (LINEAR_CLIENT_ID/SECRET) alongside
personal API key. Pass userName from env for self-message detection.
Override channelIdFromThreadId to use LINEAR_TEAM_KEY instead of
per-issue UUIDs. Enable catchAll for platforms where @-mention isn't
possible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 15:46:38 +00:00
Gabi Simons 46c8829f2f fix(channels/github): pass userName for @-mention detection
The Chat SDK adapter defaults userName to "github-bot" and only
auto-detects botUserId during initialize(), not userName. This
causes mention detection to fail — the SDK looks for @github-bot
instead of the actual bot account name. Read GITHUB_BOT_USERNAME
from env and pass it through.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 12:47:19 +00:00
gavrielc 12f50281c2 Merge remote-tracking branch 'origin/v2' into channels-sync
# Conflicts:
#	src/channels/index.ts
2026-04-18 22:06:44 +03:00
gavrielc 100e556ee9 fix(channels/telegram): update user/user-roles import paths after PR #5
PR #5 moved src/db/users.ts and src/db/user-roles.ts into the permissions
module. The channels branch's telegram adapter still imported from the
old paths — update to src/modules/permissions/db/*.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:15:28 +03:00
gavrielc 2444ab171f Merge branch 'v2' into channels 2026-04-18 19:14:00 +03:00
gavrielc 09a3b48dae Merge remote-tracking branch 'origin/v2' into channels 2026-04-18 15:58:47 +03:00
gavrielc cec6768f4b fix(channels): restore channel-specific setup scripts
Same failure mode as 303a5c7 — the Phase 1 v2 sync re-applied v2's
deletion of setup/groups.ts, setup/pair-telegram.ts, and
setup/whatsapp-auth.ts (v2 commit 437ba63 moved them off trunk because
they're channel-specific).

The /add-telegram and /add-whatsapp skills explicitly do
`git show origin/channels:setup/<file> > setup/<file>` on install, so
these files must exist on the channels branch.

Restored:
- setup/groups.ts (whatsapp group-sync)
- setup/pair-telegram.ts (telegram pairing)
- setup/whatsapp-auth.ts (whatsapp auth)
- STEPS entries in setup/index.ts: 'groups', 'pair-telegram',
  'whatsapp-auth'

Verified providers branch separately — no similar losses there
(channel adapters on providers were obsolete duplicates that correctly
got removed; opencode files are intact per dd53875).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:44:21 +03:00
gavrielc 303a5c7100 fix(channels): restore channel adapters deleted during v2 sync
Phase 1 boundary sync (5454bae) inadvertently re-applied v2's channel-
adapter deletions (v2 commit 437ba63 "move channel adapters off v2
trunk") to the channels branch. 17 adapter files and their package.json
deps were wiped:

- discord, gchat, github, imessage, linear, matrix, resend, slack,
  teams, telegram + telegram-markdown-sanitize + telegram-pairing,
  webex, whatsapp, whatsapp-cloud
- @chat-adapter/* packages, @whiskeysockets/baileys, @resend/...,
  qrcode, pino, chat-adapter-imessage, @beeper/...

Caught when testing PR #3 — the service had no channels to bind to.

Root cause: the sync merge commit message ("No channel adapter changes
required") was wrong. I checked the registry surface but not file
presence. Providers had the same failure mode during its sync, but
there it surfaced immediately via a test import; channels has no test
that imports adapter files directly, so it slipped through.

Fix: restore src/channels/*.ts and the matching package.json /
pnpm-lock.yaml entries from 0d75ca2 (last pre-sync commit). Tests pass
(198/198 vs 137/137 pre-restore — the restored telegram-pairing and
markdown-sanitize tests are back).

Going forward: channel/provider branches that carry files v2 has
deleted need `git checkout origin/<branch> -- <paths>` applied after
any v2 sync merge that touches those paths, or a merge strategy that
ignores deletions under the branch-owned directory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:38:59 +03:00
gavrielc 5454bae426 chore: sync channels branch with v2 (through PR #2)
Merge v2 → channels. Picks up v1 deletion and the module-registry
scaffolding (PR #1, PR #2). Resolves src/channels/index.ts by keeping
the full channel import list — channels branch is the fully-loaded
runnable branch.

No channel adapter changes required: the scaffolding only added new
registries with empty defaults. Existing `registerChannelAdapter()` /
`ChannelAdapter` interface is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:53:48 +03:00
gavrielc 0d75ca26f4 feat(channels): add emacs channel adapter
Native HTTP bridge on 127.0.0.1: POST /api/message fires onInbound,
GET /api/messages serves an outbound ring buffer. Single-user, single-chat
(platform_id = "default"); gated by EMACS_ENABLED. No threads, no cold DM.

Ships emacs/nanoclaw.el unchanged from v1 — the HTTP protocol is identical,
so the existing client works against the v2 adapter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:26:44 +03:00
gavrielc fbd8af618d fix(channels): drop @chat-adapter/shared dep in registry
channel-registry.ts imported NetworkError from a package that wasn't
declared as a direct dep, so tests blew up with ERR_MODULE_NOT_FOUND on
fresh installs. Mirrors the duck-type fix already on v2 trunk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:26:27 +03:00
47 changed files with 9396 additions and 29 deletions
+504
View File
@@ -0,0 +1,504 @@
;;; nanoclaw.el --- Emacs interface for NanoClaw AI assistant -*- lexical-binding: t -*-
;; Author: NanoClaw
;; Version: 0.1.0
;; Package-Requires: ((emacs "27.1"))
;; Keywords: ai, assistant, chat
;;
;; Vanilla Emacs (init.el):
;; (load-file "~/src/nanoclaw/emacs/nanoclaw.el")
;; (global-set-key (kbd "C-c n c") #'nanoclaw-chat)
;; (global-set-key (kbd "C-c n o") #'nanoclaw-org-send)
;;
;; Spacemacs (~/.spacemacs, in dotspacemacs/user-config):
;; (load-file "~/src/nanoclaw/emacs/nanoclaw.el")
;; (spacemacs/set-leader-keys "aNc" #'nanoclaw-chat)
;; (spacemacs/set-leader-keys "aNo" #'nanoclaw-org-send)
;;
;; Doom Emacs (config.el):
;; (load (expand-file-name "~/src/nanoclaw/emacs/nanoclaw.el"))
;; (map! :leader
;; :prefix ("N" . "NanoClaw")
;; :desc "Chat buffer" "c" #'nanoclaw-chat
;; :desc "Send org" "o" #'nanoclaw-org-send)
;; ;; Evil users: teach evil about the C-c C-c send binding
;; (after! evil
;; (evil-define-key '(normal insert) nanoclaw-chat-mode-map
;; (kbd "C-c C-c") #'nanoclaw-chat-send))
;;; Code:
(require 'cl-lib)
(require 'url)
(require 'json)
(require 'org)
;; ---------------------------------------------------------------------------
;; Customization
(defgroup nanoclaw nil
"NanoClaw AI assistant interface."
:group 'tools
:prefix "nanoclaw-")
(defcustom nanoclaw-host "localhost"
"Hostname where NanoClaw is running."
:type 'string
:group 'nanoclaw)
(defcustom nanoclaw-port 8766
"Port for the NanoClaw Emacs channel HTTP server."
:type 'integer
:group 'nanoclaw)
(defcustom nanoclaw-auth-token nil
"Bearer token for NanoClaw authentication (matches EMACS_AUTH_TOKEN in .env).
Leave nil if EMACS_AUTH_TOKEN is not set."
:type '(choice (const nil) string)
:group 'nanoclaw)
(defcustom nanoclaw-poll-interval 1.5
"Seconds between response polls when waiting for a reply."
:type 'number
:group 'nanoclaw)
(defcustom nanoclaw-agent-name "Andy"
"Display name for the NanoClaw agent (matches ASSISTANT_NAME in .env)."
:type 'string
:group 'nanoclaw)
(defcustom nanoclaw-convert-to-org t
"When non-nil, convert agent responses to org-mode format.
Uses pandoc when available; falls back to regex substitutions."
:type 'boolean
:group 'nanoclaw)
(defcustom nanoclaw-timestamp-format "%H:%M"
"Format string for timestamps shown next to agent replies in the chat buffer.
Passed to `format-time-string'. Set to nil to suppress timestamps."
:type '(choice (const nil) string)
:group 'nanoclaw)
;; ---------------------------------------------------------------------------
;; Formatting helpers
(defun nanoclaw--to-org (text)
"Convert TEXT (markdown or plain) to org-mode markup.
Tries pandoc -f gfm -t org when available; falls back to regex."
(if (not nanoclaw-convert-to-org)
text
(if (executable-find "pandoc")
(with-temp-buffer
(insert text)
(let* ((coding-system-for-read 'utf-8)
(coding-system-for-write 'utf-8)
(exit (call-process-region
(point-min) (point-max)
"pandoc" t t nil "-f" "gfm" "-t" "org" "--wrap=none")))
(if (zerop exit)
(string-trim (buffer-string))
text)))
(nanoclaw--md-to-org-regex text))))
;; NOTE: This function expects standard markdown as input (e.g. **bold**, *italic*).
;; Agents responding on this channel must output markdown, not org-mode syntax.
;; If the agent outputs org-mode directly, markers like *bold* will be incorrectly
;; re-converted to /bold/ by the italic rule.
(defun nanoclaw--md-to-org-regex (text)
"Lightweight markdown → org conversion using regexp substitutions."
(let ((s text))
;; Fenced code blocks ```lang\n…\n``` → #+begin_src lang\n…\n#+end_src
;; (must run before inline-code to avoid mangling backticks)
(setq s (replace-regexp-in-string
"```\\([a-zA-Z0-9_-]*\\)\n\\(\\(?:.\\|\n\\)*?\\)```"
(lambda (m)
(let ((lang (match-string 1 m))
(body (match-string 2 m)))
(concat "#+begin_src " (if (string-empty-p lang) "text" lang)
"\n" body "#+end_src")))
s t))
;; Bold **text** → *text*, italic *text* → /text/
;; Two-pass to prevent the italic regex from re-matching the bold result:
;; 1. Mark bold spans with a placeholder (control char \x01)
(setq s (replace-regexp-in-string "\\*\\*\\(.+?\\)\\*\\*" "\x01\\1\x01" s))
;; 2. Convert remaining single-star spans to italic
(setq s (replace-regexp-in-string "\\*\\(.+?\\)\\*" "/\\1/" s))
;; 3. Resolve bold placeholders to org bold markers
(setq s (replace-regexp-in-string "\x01\\(.+?\\)\x01" "*\\1*" s))
;; Strikethrough ~~text~~ → +text+
(setq s (replace-regexp-in-string "~~\\(.+?\\)~~" "+\\1+" s))
;; Underline __text__ → _text_
(setq s (replace-regexp-in-string "__\\(.+?\\)__" "_\\1_" s))
;; Inline code `code` → ~code~
(setq s (replace-regexp-in-string "`\\([^`]+\\)`" "~\\1~" s))
;; ATX headings ## … → ** …
(setq s (replace-regexp-in-string
"^\\(#+\\) "
(lambda (m) (concat (make-string (length (match-string 1 m)) ?*) " "))
s))
;; Links [text](url) → [[url][text]]
(setq s (replace-regexp-in-string
"\\[\\([^]]+\\)\\](\\([^)]+\\))" "[[\\2][\\1]]" s))
s))
(defun nanoclaw--format-timestamp ()
"Return a formatted timestamp string, or nil if disabled."
(when nanoclaw-timestamp-format
(format-time-string nanoclaw-timestamp-format)))
;; ---------------------------------------------------------------------------
;; Internal state
(defvar nanoclaw--poll-timer nil
"Timer used to poll for responses in the chat buffer.")
(defvar nanoclaw--last-timestamp 0
"Epoch ms of the most recently received message.")
(defvar nanoclaw--pending nil
"Non-nil while waiting for a response.")
(defvar-local nanoclaw--thinking-dot-count 0
"Dot cycle counter for the animated thinking indicator.")
(defvar-local nanoclaw--input-beg nil
"Marker for the start of the current user input area.")
;; ---------------------------------------------------------------------------
;; HTTP helpers
(defun nanoclaw--url (path)
"Return the full URL for PATH on the NanoClaw server."
(format "http://%s:%d%s" nanoclaw-host nanoclaw-port path))
(defun nanoclaw--headers ()
"Return alist of HTTP headers for NanoClaw requests."
(let ((hdrs '(("Content-Type" . "application/json"))))
(when nanoclaw-auth-token
(push (cons "Authorization" (concat "Bearer " nanoclaw-auth-token)) hdrs))
hdrs))
(defun nanoclaw--post (text callback)
"POST TEXT to NanoClaw and call CALLBACK with the response alist."
(let* ((url-request-method "POST")
(url-request-extra-headers (nanoclaw--headers))
(url-request-data (encode-coding-string
(json-encode `((text . ,text)))
'utf-8)))
(url-retrieve
(nanoclaw--url "/api/message")
(lambda (status)
(if (plist-get status :error)
(message "NanoClaw: POST error %s" (plist-get status :error))
(goto-char (point-min))
(re-search-forward "\n\n" nil t)
(let ((data (ignore-errors (json-read))))
(funcall callback data))))
nil t t)))
(defun nanoclaw--poll (since callback)
"GET messages newer than SINCE (epoch ms) and call CALLBACK with the list."
(let* ((url-request-method "GET")
(url-request-extra-headers (nanoclaw--headers)))
(url-retrieve
(nanoclaw--url (format "/api/messages?since=%d" since))
(lambda (status)
(unless (plist-get status :error)
(goto-char (point-min))
(re-search-forward "\n\n" nil t)
(let* ((raw (buffer-substring-no-properties (point) (point-max)))
(body (decode-coding-string raw 'utf-8))
(data (ignore-errors (json-read-from-string body)))
(msgs (cdr (assq 'messages data))))
(when msgs (funcall callback (append msgs nil))))))
nil t t)))
;; ---------------------------------------------------------------------------
;; Chat buffer
(defvar nanoclaw-chat-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "RET") #'newline)
(define-key map (kbd "<return>") #'newline)
(define-key map (kbd "C-c C-c") #'nanoclaw-chat-send)
map)
"Keymap for `nanoclaw-chat-mode'.")
(define-derived-mode nanoclaw-chat-mode org-mode "NanoClaw"
"Major mode for the NanoClaw chat buffer.
Derives from org-mode so that org markup (headings, bold, code blocks,
etc.) is fontified automatically. RET and <return> insert plain newlines
for multi-line input; send with C-c C-c."
(setq-local word-wrap t)
(visual-line-mode 1)
;; Disable org features that conflict with a linear chat buffer
(setq-local org-return-follows-link nil)
(setq-local org-cycle-emulate-tab nil)
;; Ensure send binding beats org-mode's C-c C-c via the buffer-local map
(local-set-key (kbd "C-c C-c") #'nanoclaw-chat-send))
(defun nanoclaw--advance-input-beg ()
"Move `nanoclaw--input-beg' to point-max in the chat buffer."
(with-current-buffer (nanoclaw--chat-buffer)
(when nanoclaw--input-beg (set-marker nanoclaw--input-beg nil))
(setq nanoclaw--input-beg (copy-marker (point-max)))))
(defun nanoclaw--chat-buffer ()
"Return the NanoClaw chat buffer, creating it if necessary."
(or (get-buffer "*NanoClaw*")
(with-current-buffer (get-buffer-create "*NanoClaw*")
(nanoclaw-chat-mode)
(set-buffer-file-coding-system 'utf-8)
(add-hook 'kill-buffer-hook #'nanoclaw--stop-poll nil t)
(nanoclaw--insert-header)
(setq nanoclaw--input-beg (copy-marker (point-max)))
(current-buffer))))
(defun nanoclaw--insert-header ()
"Insert the welcome header into the chat buffer."
(let ((inhibit-read-only t))
(insert (propertize
(format "── NanoClaw (%s) ──────────────────────────────\n\n"
nanoclaw-agent-name)
'face 'font-lock-comment-face))))
(defun nanoclaw--chat-insert (speaker text)
"Append SPEAKER: TEXT to the chat buffer."
(with-current-buffer (nanoclaw--chat-buffer)
(let* ((inhibit-read-only t)
(is-agent (not (string= speaker "You")))
(display-text (if is-agent (nanoclaw--to-org text) text))
(ts (nanoclaw--format-timestamp))
(label (if ts (format "%s [%s]" speaker ts) speaker))
(face (if is-agent 'font-lock-string-face 'font-lock-keyword-face)))
(goto-char (point-max))
(insert (propertize (concat label ": ") 'face face))
(insert display-text "\n\n")
(goto-char (point-max))
(when is-agent
(nanoclaw--advance-input-beg)))))
;;;###autoload
(defun nanoclaw-chat ()
"Open the NanoClaw chat buffer."
(interactive)
(pop-to-buffer (nanoclaw--chat-buffer))
(goto-char (point-max)))
(defun nanoclaw-chat-send ()
"Send the accumulated input area as a message to NanoClaw.
Use C-c C-c to send; RET inserts a plain newline for multi-line messages."
(interactive)
(when nanoclaw--pending
(message "NanoClaw: waiting for previous response...")
(cl-return-from nanoclaw-chat-send))
(let* ((beg (if (and nanoclaw--input-beg (marker-buffer nanoclaw--input-beg))
(marker-position nanoclaw--input-beg)
(line-beginning-position)))
(text (string-trim (buffer-substring-no-properties beg (point-max)))))
(when (string-empty-p text)
(user-error "Nothing to send"))
(let ((inhibit-read-only t))
(delete-region beg (point-max)))
(nanoclaw--chat-insert "You" text)
(nanoclaw--advance-input-beg)
(setq nanoclaw--pending t)
(nanoclaw--post text
(lambda (data)
(when data
(setq nanoclaw--last-timestamp
(or (cdr (assq 'timestamp data))
nanoclaw--last-timestamp))
(nanoclaw--start-thinking)
(nanoclaw--start-poll))))))
(defun nanoclaw--start-poll ()
"Start polling for new messages."
(nanoclaw--stop-poll)
(setq nanoclaw--poll-timer
(run-with-timer nanoclaw-poll-interval nanoclaw-poll-interval
#'nanoclaw--poll-tick)))
(defun nanoclaw--stop-poll ()
"Stop the polling timer."
(when nanoclaw--poll-timer
(cancel-timer nanoclaw--poll-timer)
(setq nanoclaw--poll-timer nil)))
(defun nanoclaw--start-thinking ()
"Insert an animated thinking indicator at the end of the chat buffer."
(with-current-buffer (nanoclaw--chat-buffer)
(let ((inhibit-read-only t))
(goto-char (point-max))
(setq nanoclaw--thinking-dot-count 1)
(insert (propertize (format "%s: .\n\n" nanoclaw-agent-name)
'nanoclaw-thinking t
'face 'font-lock-string-face)))))
(defun nanoclaw--tick-thinking ()
"Advance the dot animation in the thinking indicator."
(let ((buf (get-buffer "*NanoClaw*")))
(when buf
(with-current-buffer buf
(when nanoclaw--pending
(let* ((inhibit-read-only t)
(pos (text-property-any (point-min) (point-max)
'nanoclaw-thinking t)))
(when pos
(let* ((end (or (next-single-property-change
pos 'nanoclaw-thinking) (point-max)))
(n (1+ (mod nanoclaw--thinking-dot-count 3))))
(setq nanoclaw--thinking-dot-count n)
(delete-region pos end)
(save-excursion
(goto-char pos)
(insert (propertize
(format "%s: %s\n\n" nanoclaw-agent-name
(make-string n ?.))
'nanoclaw-thinking t
'face 'font-lock-string-face)))))))))))
(defun nanoclaw--clear-thinking ()
"Remove the thinking indicator from the chat buffer."
(let ((buf (get-buffer "*NanoClaw*")))
(when buf
(with-current-buffer buf
(let* ((inhibit-read-only t)
(pos (text-property-any (point-min) (point-max)
'nanoclaw-thinking t)))
(when pos
(delete-region pos (or (next-single-property-change
pos 'nanoclaw-thinking) (point-max)))))))))
(defun nanoclaw--poll-tick ()
"Poll for new messages and insert them into the chat buffer."
(nanoclaw--tick-thinking)
(nanoclaw--poll
nanoclaw--last-timestamp
(lambda (msgs)
(dolist (msg msgs)
(let ((text (cdr (assq 'text msg)))
(ts (cdr (assq 'timestamp msg))))
(when (and text (> ts nanoclaw--last-timestamp))
(setq nanoclaw--last-timestamp ts)
(nanoclaw--clear-thinking)
(nanoclaw--chat-insert nanoclaw-agent-name text))))
(when msgs
(setq nanoclaw--pending nil)
(nanoclaw--stop-poll)))))
;; ---------------------------------------------------------------------------
;; Org integration
;;;###autoload
(defun nanoclaw-org-send ()
"Send the current org subtree to NanoClaw and insert the response as a child.
If a region is active, send the region text instead."
(interactive)
(unless (derived-mode-p 'org-mode)
(user-error "Not in an org-mode buffer"))
(let ((text (if (use-region-p)
(buffer-substring-no-properties (region-beginning) (region-end))
(nanoclaw--org-subtree-text))))
(when (string-empty-p (string-trim text))
(user-error "Nothing to send"))
(message "NanoClaw: sending to %s..." nanoclaw-agent-name)
(let ((marker (point-marker))
(buf (current-buffer)))
(nanoclaw--post
text
(lambda (data)
(let* ((ts (or (cdr (assq 'timestamp data)) (nanoclaw--now-ms)))
(level (with-current-buffer buf
(save-excursion (goto-char marker) (org-outline-level))))
(ph (with-current-buffer buf
(save-excursion
(goto-char marker)
(nanoclaw--org-insert-placeholder level)))))
(nanoclaw--poll-until-response
ts
(lambda (response)
(with-current-buffer buf
(save-excursion
(when (marker-buffer ph)
(let* ((inhibit-read-only t)
(beg (marker-position ph))
(end (save-excursion
(goto-char (1+ beg))
(org-next-visible-heading 1)
(point))))
(delete-region beg end))
(set-marker ph nil))
(goto-char marker)
(nanoclaw--org-insert-response response))))
(lambda ()
(message "NanoClaw: timed out waiting for response")
(when (marker-buffer ph)
(with-current-buffer (marker-buffer ph)
(let* ((inhibit-read-only t)
(beg (marker-position ph))
(end (save-excursion
(goto-char (1+ beg))
(org-next-visible-heading 1)
(point))))
(delete-region beg end))
(set-marker ph nil)))))))))))
(defun nanoclaw--org-insert-placeholder (level)
"Insert a processing child heading at LEVEL+1 and return a marker at its start."
(org-back-to-heading t)
(org-end-of-subtree t t)
(let ((beg (point)))
(insert "\n" (make-string (1+ level) ?*) " "
nanoclaw-agent-name " [processing...]\n\n")
(copy-marker beg)))
(defun nanoclaw--org-subtree-text ()
"Return the text of the org subtree at point (heading + body)."
(org-with-wide-buffer
(org-back-to-heading t)
(let ((start (point))
(end (progn (org-end-of-subtree t t) (point))))
(buffer-substring-no-properties start end))))
(defun nanoclaw--org-insert-response (text)
"Insert TEXT as a child org heading under the current subtree."
(org-back-to-heading t)
(let* ((level (org-outline-level))
(child-stars (make-string (1+ level) ?*))
(timestamp (format-time-string "[%Y-%m-%d %a %H:%M]"))
(body (nanoclaw--to-org text)))
(org-end-of-subtree t t)
(insert "\n" child-stars " " nanoclaw-agent-name " " timestamp "\n"
body "\n")))
(defun nanoclaw--now-ms ()
"Return current time as milliseconds since epoch."
(let ((time (current-time)))
(+ (* (+ (* (car time) 65536) (cadr time)) 1000)
(/ (caddr time) 1000))))
(defun nanoclaw--poll-until-response (since callback timeout-fn &optional attempts)
"Poll until a message newer than SINCE arrives, then call CALLBACK.
Calls TIMEOUT-FN after 60 attempts (~90s)."
(let ((n (or attempts 0)))
(if (>= n 60)
(funcall timeout-fn)
(nanoclaw--poll
since
(lambda (msgs)
(let ((fresh (seq-filter (lambda (m) (> (cdr (assq 'timestamp m)) since))
msgs)))
(if fresh
(let ((text (mapconcat (lambda (m) (cdr (assq 'text m)))
fresh "\n")))
(funcall callback text))
(run-with-timer nanoclaw-poll-interval nil
#'nanoclaw--poll-until-response
since callback timeout-fn (1+ n)))))))))
;; ---------------------------------------------------------------------------
(provide 'nanoclaw)
;;; nanoclaw.el ends here
+19 -1
View File
@@ -24,13 +24,31 @@
"test:watch": "vitest"
},
"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",
"@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",
"better-sqlite3": "11.10.0",
"chat": "^4.24.0",
"chat-adapter-imessage": "^0.1.1",
"cron-parser": "5.5.0",
"kleur": "^4.1.5"
"kleur": "^4.1.5",
"pino": "^9.6.0",
"qrcode": "^1.5.4",
"wechat-ilink-client": "^0.1.0"
},
"devDependencies": {
"@eslint/js": "^9.35.0",
+3919 -2
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Initialize the scratch CLI agent used during `/new-setup`.
* Initialize the scratch CLI agent used during `/setup`.
*
* Creates the synthetic `cli:local` user, grants owner role if no owner
* exists yet, builds an agent group with a minimal CLAUDE.md, and wires it
+2 -2
View File
@@ -7,7 +7,7 @@
* already exists unless --force is passed.
*
* The actual user-facing prompt (subscription vs API key, paste the token)
* stays in the /new-setup SKILL.md. This step is just the machine side:
* stays in the /setup SKILL.md. This step is just the machine side:
* it calls `onecli secrets list` / `onecli secrets create` and emits a
* structured status block. The token value is never logged.
*/
@@ -124,7 +124,7 @@ export async function run(args: string[]): Promise<void> {
emitStatus('AUTH', {
STATUS: 'failed',
ERROR: 'onecli_list_failed',
HINT: 'Is OneCLI running? Run `/new-setup` from the onecli step.',
HINT: 'Is OneCLI running? Run `/setup` from the onecli step.',
LOG: 'logs/setup.log',
});
process.exit(1);
+2 -2
View File
@@ -1,8 +1,8 @@
/**
* Step: cli-agent — Create the scratch CLI agent for `/new-setup`.
* Step: cli-agent — Create the scratch CLI agent for `/setup`.
*
* Thin wrapper around `scripts/init-cli-agent.ts`. Emits a status block so
* /new-setup SKILL.md can parse the result without having to read the
* /setup SKILL.md can parse the result without having to read the
* script's plain stdout.
*
* Args:
+229
View File
@@ -0,0 +1,229 @@
/**
* Step: groups — Fetch group metadata from messaging platforms, write to DB.
* WhatsApp requires an upfront sync (Baileys groupFetchAllParticipating).
* Other channels discover group names at runtime — this step auto-skips for them.
* Replaces 05-sync-groups.sh + 05b-list-groups.sh
*/
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import Database from 'better-sqlite3';
import { STORE_DIR } from '../src/config.js';
import { log } from '../src/log.js';
import { emitStatus } from './status.js';
function parseArgs(args: string[]): { list: boolean; limit: number } {
let list = false;
let limit = 30;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--list') list = true;
if (args[i] === '--limit' && args[i + 1]) {
limit = parseInt(args[i + 1], 10);
i++;
}
}
return { list, limit };
}
export async function run(args: string[]): Promise<void> {
const projectRoot = process.cwd();
const { list, limit } = parseArgs(args);
if (list) {
await listGroups(limit);
return;
}
await syncGroups(projectRoot);
}
async function listGroups(limit: number): Promise<void> {
const dbPath = path.join(STORE_DIR, 'messages.db');
if (!fs.existsSync(dbPath)) {
console.error('ERROR: database not found');
process.exit(1);
}
const db = new Database(dbPath, { readonly: true });
const rows = db
.prepare(
`SELECT jid, name FROM chats
WHERE jid LIKE '%@g.us' AND jid <> '__group_sync__' AND name <> jid
ORDER BY last_message_time DESC
LIMIT ?`,
)
.all(limit) as Array<{ jid: string; name: string }>;
db.close();
for (const row of rows) {
console.log(`${row.jid}|${row.name}`);
}
}
async function syncGroups(projectRoot: string): Promise<void> {
// Only WhatsApp needs an upfront group sync; other channels resolve names at runtime.
// Detect WhatsApp by checking for auth credentials on disk.
const authDir = path.join(projectRoot, 'store', 'auth');
const hasWhatsAppAuth =
fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0;
if (!hasWhatsAppAuth) {
log.info('WhatsApp auth not found — skipping group sync');
emitStatus('SYNC_GROUPS', {
BUILD: 'skipped',
SYNC: 'skipped',
GROUPS_IN_DB: 0,
REASON: 'whatsapp_not_configured',
STATUS: 'success',
LOG: 'logs/setup.log',
});
return;
}
// Build TypeScript first
log.info('Building TypeScript');
let buildOk = false;
try {
execSync('pnpm run build', {
cwd: projectRoot,
stdio: ['ignore', 'pipe', 'pipe'],
});
buildOk = true;
log.info('Build succeeded');
} catch {
log.error('Build failed');
emitStatus('SYNC_GROUPS', {
BUILD: 'failed',
SYNC: 'skipped',
GROUPS_IN_DB: 0,
STATUS: 'failed',
ERROR: 'build_failed',
LOG: 'logs/setup.log',
});
process.exit(1);
}
// Run sync script via a temp file to avoid shell escaping issues with node -e
log.info('Fetching group metadata');
let syncOk = false;
try {
const syncScript = `
import makeWASocket, { useMultiFileAuthState, makeCacheableSignalKeyStore, Browsers } from '@whiskeysockets/baileys';
import pino from 'pino';
import path from 'path';
import fs from 'fs';
import Database from 'better-sqlite3';
const logger = pino({ level: 'silent' });
const authDir = path.join('store', 'auth');
const dbPath = path.join('store', 'messages.db');
if (!fs.existsSync(authDir)) {
console.error('NO_AUTH');
process.exit(1);
}
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.exec('CREATE TABLE IF NOT EXISTS chats (jid TEXT PRIMARY KEY, name TEXT, last_message_time TEXT)');
const upsert = db.prepare(
'INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?) ON CONFLICT(jid) DO UPDATE SET name = excluded.name'
);
const { state, saveCreds } = await useMultiFileAuthState(authDir);
const sock = makeWASocket({
auth: { creds: state.creds, keys: makeCacheableSignalKeyStore(state.keys, logger) },
printQRInTerminal: false,
logger,
browser: Browsers.macOS('Chrome'),
});
const timeout = setTimeout(() => {
console.error('TIMEOUT');
process.exit(1);
}, 30000);
sock.ev.on('creds.update', saveCreds);
sock.ev.on('connection.update', async (update) => {
if (update.connection === 'open') {
try {
const groups = await sock.groupFetchAllParticipating();
const now = new Date().toISOString();
let count = 0;
for (const [jid, metadata] of Object.entries(groups)) {
if (metadata.subject) {
upsert.run(jid, metadata.subject, now);
count++;
}
}
console.log('SYNCED:' + count);
} catch (err) {
console.error('FETCH_ERROR:' + err.message);
} finally {
clearTimeout(timeout);
sock.end(undefined);
db.close();
process.exit(0);
}
} else if (update.connection === 'close') {
clearTimeout(timeout);
console.error('CONNECTION_CLOSED');
process.exit(1);
}
});
`;
const tmpScript = path.join(projectRoot, '.tmp-group-sync.mjs');
fs.writeFileSync(tmpScript, syncScript, 'utf-8');
try {
const output = execSync(`node ${tmpScript}`, {
cwd: projectRoot,
encoding: 'utf-8',
timeout: 45000,
stdio: ['ignore', 'pipe', 'pipe'],
});
syncOk = output.includes('SYNCED:');
log.info('Sync output', { output: output.trim() });
} finally {
try { fs.unlinkSync(tmpScript); } catch { /* ignore cleanup errors */ }
}
} catch (err) {
log.error('Sync failed', { err });
}
// Count groups in DB using better-sqlite3 (no sqlite3 CLI)
let groupsInDb = 0;
const dbPath = path.join(STORE_DIR, 'messages.db');
if (fs.existsSync(dbPath)) {
try {
const db = new Database(dbPath, { readonly: true });
const row = db
.prepare(
"SELECT COUNT(*) as count FROM chats WHERE jid LIKE '%@g.us' AND jid <> '__group_sync__'",
)
.get() as { count: number };
groupsInDb = row.count;
db.close();
} catch {
// DB may not exist yet
}
}
const status = syncOk ? 'success' : 'failed';
emitStatus('SYNC_GROUPS', {
BUILD: buildOk ? 'success' : 'failed',
SYNC: syncOk ? 'success' : 'failed',
GROUPS_IN_DB: groupsInDb,
STATUS: status,
LOG: 'logs/setup.log',
});
if (status === 'failed') process.exit(1);
}
+2 -1
View File
@@ -13,8 +13,9 @@ const STEPS: Record<
'set-env': () => import('./set-env.js'),
environment: () => import('./environment.js'),
container: () => import('./container.js'),
register: () => import('./register.js'),
groups: () => import('./groups.js'),
register: () => import('./register.js'),
'pair-telegram': () => import('./pair-telegram.js'),
'whatsapp-auth': () => import('./whatsapp-auth.js'),
'signal-auth': () => import('./signal-auth.js'),
mounts: () => import('./mounts.js'),
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-discord — bundles the preflight + install commands
# from the /add-discord skill into one idempotent script so /new-setup can
# from the /add-discord skill into one idempotent script so /setup can
# run them programmatically before continuing to credentials.
#
# Copies the Discord adapter in from the `channels` branch; appends the
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-docker — bundles Docker install into one idempotent
# script so /new-setup can run it without needing `curl | sh` in the allowlist
# script so /setup can run it without needing `curl | sh` in the allowlist
# (pipelines split at matching time, and `sh` receiving stdin can't be
# pre-approved safely).
#
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-gchat — bundles the preflight + install commands
# from the /add-gchat skill into one idempotent script so /new-setup can
# from the /add-gchat skill into one idempotent script so /setup can
# run them programmatically before continuing to credentials.
#
# Copies the Google Chat adapter in from the `channels` branch; appends the
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-github — bundles the preflight + install commands
# from the /add-github skill into one idempotent script so /new-setup can
# from the /add-github skill into one idempotent script so /setup can
# run them programmatically before continuing to credentials.
#
# Copies the GitHub adapter in from the `channels` branch; appends the
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-imessage — bundles the preflight + install commands
# from the /add-imessage skill into one idempotent script so /new-setup can
# from the /add-imessage skill into one idempotent script so /setup can
# run them programmatically before continuing to credentials.
#
# Copies the iMessage adapter in from the `channels` branch; appends the
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-linear — bundles the preflight + install commands
# from the /add-linear skill into one idempotent script so /new-setup can
# from the /add-linear skill into one idempotent script so /setup can
# run them programmatically before continuing to credentials.
#
# Copies the Linear adapter in from the `channels` branch; appends the
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-matrix — bundles the preflight + install commands
# from the /add-matrix skill into one idempotent script so /new-setup can
# from the /add-matrix skill into one idempotent script so /setup can
# run them programmatically before continuing to credentials.
#
# Copies the Matrix adapter in from the `channels` branch; appends the
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-node — bundles Node 22 install into one idempotent
# script so /new-setup can run it without needing `curl | sudo -E bash -` in
# script so /setup can run it without needing `curl | sudo -E bash -` in
# the allowlist (that pattern is inherently unmatchable — bash reads from
# stdin, so pre-approval can't inspect what's being executed).
#
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-resend — bundles the preflight + install commands
# from the /add-resend skill into one idempotent script so /new-setup can
# from the /add-resend skill into one idempotent script so /setup can
# run them programmatically before continuing to credentials.
#
# Copies the Resend adapter in from the `channels` branch; appends the
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-slack — bundles the preflight + install commands
# from the /add-slack skill into one idempotent script so /new-setup can
# from the /add-slack skill into one idempotent script so /setup can
# run them programmatically before continuing to credentials.
#
# Copies the Slack adapter in from the `channels` branch; appends the
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-teams — bundles the preflight + install commands
# from the /add-teams skill into one idempotent script so /new-setup can
# from the /add-teams skill into one idempotent script so /setup can
# run them programmatically before continuing to credentials.
#
# Copies the Teams adapter in from the `channels` branch; appends the
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-telegram — bundles the preflight + install commands
# from the /add-telegram skill into one idempotent script so /new-setup can
# from the /add-telegram skill into one idempotent script so /setup can
# run them programmatically before continuing to credentials and pairing.
#
# Copies the Telegram adapter, helpers, tests, and the pair-telegram setup
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-webex — bundles the preflight + install commands
# from the /add-webex skill into one idempotent script so /new-setup can
# from the /add-webex skill into one idempotent script so /setup can
# run them programmatically before continuing to credentials.
#
# Copies the Webex adapter in from the `channels` branch; appends the
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Setup helper: install-whatsapp-cloud — bundles the preflight + install
# commands from the /add-whatsapp-cloud skill into one idempotent script so
# /new-setup can run them programmatically before continuing to credentials.
# /setup can run them programmatically before continuing to credentials.
#
# Copies the WhatsApp Cloud adapter in from the `channels` branch; appends the
# self-registration import; installs the pinned @chat-adapter/whatsapp package;
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-whatsapp — bundles the preflight + install commands
# from the /add-whatsapp skill into one idempotent script so /new-setup can
# from the /add-whatsapp skill into one idempotent script so /setup can
# run them programmatically before continuing to QR/pairing-code auth.
#
# Copies the native Baileys WhatsApp adapter, its whatsapp-auth and groups
+1 -1
View File
@@ -1,6 +1,6 @@
#!/bin/bash
# Setup step: probe — single upfront parallel-ish scan that snapshots every
# prerequisite and dependency for /new-setup's dynamic context injection.
# prerequisite and dependency for /setup's dynamic context injection.
# Rendered into the SKILL.md prompt via `!bash setup/probe.sh` so Claude sees
# the current system state before generating its first response.
#
+38
View File
@@ -0,0 +1,38 @@
/**
* Discord channel adapter (v2) — uses Chat SDK bridge.
* Self-registers on import.
*/
import { createDiscordAdapter } from '@chat-adapter/discord';
import { readEnvFile } from '../env.js';
import { createChatSdkBridge, type ReplyContext } from './chat-sdk-bridge.js';
import { registerChannelAdapter } from './channel-registry.js';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function extractReplyContext(raw: Record<string, any>): ReplyContext | null {
if (!raw.referenced_message) return null;
const reply = raw.referenced_message;
return {
text: reply.content || '',
sender: reply.author?.global_name || reply.author?.username || 'Unknown',
};
}
registerChannelAdapter('discord', {
factory: () => {
const env = readEnvFile(['DISCORD_BOT_TOKEN', 'DISCORD_PUBLIC_KEY', 'DISCORD_APPLICATION_ID']);
if (!env.DISCORD_BOT_TOKEN) return null;
const discordAdapter = createDiscordAdapter({
botToken: env.DISCORD_BOT_TOKEN,
publicKey: env.DISCORD_PUBLIC_KEY,
applicationId: env.DISCORD_APPLICATION_ID,
});
return createChatSdkBridge({
adapter: discordAdapter,
concurrency: 'concurrent',
botToken: env.DISCORD_BOT_TOKEN,
extractReplyContext,
supportsThreads: true,
});
},
});
+259
View File
@@ -0,0 +1,259 @@
/**
* Tests for the v2 emacs channel adapter.
*
* Exercises the HTTP surface (POST /api/message, GET /api/messages) and
* the ChannelAdapter lifecycle (setup / teardown / isConnected / deliver).
*/
import http from 'http';
import type { AddressInfo } from 'net';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createEmacsAdapter } from './emacs.js';
import type { ChannelAdapter, ChannelSetup } from './adapter.js';
vi.mock('../log.js', () => ({
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
function makeSetup(overrides: Partial<ChannelSetup> = {}): ChannelSetup {
return {
onInbound: vi.fn(),
onInboundEvent: vi.fn(),
onMetadata: vi.fn(),
onAction: vi.fn(),
...overrides,
};
}
/** Ask the OS for a free port, then immediately release it. Small race window
* before the adapter grabs it, but sufficient for local test use. */
async function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = http.createServer();
srv.once('error', reject);
srv.listen(0, '127.0.0.1', () => {
const port = (srv.address() as AddressInfo).port;
srv.close(() => resolve(port));
});
});
}
async function req(
port: number,
method: string,
path: string,
body?: string,
extraHeaders: Record<string, string> = {},
): Promise<{ status: number; data: unknown }> {
return new Promise((resolve, reject) => {
const headers: Record<string, string> = { 'Content-Type': 'application/json', ...extraHeaders };
const request = http.request({ host: '127.0.0.1', port, method, path, headers }, (res) => {
let raw = '';
res.on('data', (chunk: Buffer) => (raw += chunk.toString()));
res.on('end', () => {
try {
resolve({ status: res.statusCode!, data: JSON.parse(raw) });
} catch {
resolve({ status: res.statusCode!, data: raw });
}
});
});
request.on('error', reject);
if (body) request.write(body);
request.end();
});
}
describe('emacs adapter', () => {
let adapter: ChannelAdapter;
let port: number;
beforeEach(async () => {
port = await getFreePort();
adapter = createEmacsAdapter({ port, authToken: null, platformId: 'default' });
});
afterEach(async () => {
if (adapter.isConnected()) await adapter.teardown();
});
describe('lifecycle', () => {
it('isConnected is false before setup', () => {
expect(adapter.isConnected()).toBe(false);
});
it('isConnected is true after setup', async () => {
await adapter.setup(makeSetup());
expect(adapter.isConnected()).toBe(true);
});
it('isConnected is false after teardown', async () => {
await adapter.setup(makeSetup());
await adapter.teardown();
expect(adapter.isConnected()).toBe(false);
});
it('teardown is a no-op before setup', async () => {
await expect(adapter.teardown()).resolves.not.toThrow();
});
it('calls onMetadata after setup with channel name', async () => {
const onMetadata = vi.fn();
await adapter.setup(makeSetup({ onMetadata }));
expect(onMetadata).toHaveBeenCalledWith('default', 'Emacs', false);
});
});
describe('POST /api/message', () => {
let onInbound: ChannelSetup['onInbound'] & { mock: { calls: unknown[][] } };
beforeEach(async () => {
onInbound = vi.fn() as unknown as typeof onInbound;
await adapter.setup(makeSetup({ onInbound }));
});
it('fires onInbound with chat kind and sender metadata', async () => {
const { status, data } = await req(port, 'POST', '/api/message', JSON.stringify({ text: 'hello' }));
expect(status).toBe(200);
expect((data as { messageId: string }).messageId).toMatch(/^emacs-/);
expect(onInbound).toHaveBeenCalledOnce();
const [platformId, threadId, msg] = onInbound.mock.calls[0] as [string, string | null, { content: unknown }];
expect(platformId).toBe('default');
expect(threadId).toBeNull();
expect(msg).toMatchObject({
kind: 'chat',
content: { text: 'hello', sender: 'Emacs', senderId: 'emacs:default' },
});
});
it('returns 400 for empty text', async () => {
const { status } = await req(port, 'POST', '/api/message', JSON.stringify({ text: '' }));
expect(status).toBe(400);
expect(onInbound).not.toHaveBeenCalled();
});
it('returns 400 for whitespace-only text', async () => {
const { status } = await req(port, 'POST', '/api/message', JSON.stringify({ text: ' ' }));
expect(status).toBe(400);
});
it('returns 400 for invalid JSON', async () => {
const { status } = await req(port, 'POST', '/api/message', 'not-json');
expect(status).toBe(400);
});
it('returns 404 for unknown paths', async () => {
const { status } = await req(port, 'POST', '/api/unknown', JSON.stringify({ text: 'hi' }));
expect(status).toBe(404);
});
});
describe('GET /api/messages + deliver', () => {
beforeEach(async () => {
await adapter.setup(makeSetup());
});
it('returns empty buffer initially', async () => {
const { status, data } = await req(port, 'GET', '/api/messages?since=0');
expect(status).toBe(200);
expect(data).toEqual({ messages: [] });
});
it('deliver pushes text for the poll endpoint to return', async () => {
await adapter.deliver('default', null, { kind: 'chat', content: { text: 'reply' } });
const { data } = await req(port, 'GET', '/api/messages?since=0');
const messages = (data as { messages: { text: string; timestamp: number }[] }).messages;
expect(messages).toHaveLength(1);
expect(messages[0]?.text).toBe('reply');
expect(typeof messages[0]?.timestamp).toBe('number');
});
it('deliver accepts plain-string content', async () => {
await adapter.deliver('default', null, { kind: 'chat', content: 'raw text' });
const { data } = await req(port, 'GET', '/api/messages?since=0');
expect((data as { messages: { text: string }[] }).messages[0]?.text).toBe('raw text');
});
it('deliver skips empty text silently', async () => {
await adapter.deliver('default', null, { kind: 'chat', content: { text: '' } });
const { data } = await req(port, 'GET', '/api/messages?since=0');
expect((data as { messages: unknown[] }).messages).toHaveLength(0);
});
it('deliver rejects unknown platformId', async () => {
const result = await adapter.deliver('other', null, { kind: 'chat', content: { text: 'x' } });
expect(result).toBeUndefined();
const { data } = await req(port, 'GET', '/api/messages?since=0');
expect((data as { messages: unknown[] }).messages).toHaveLength(0);
});
it('filters out messages at or before the since cutoff', async () => {
await adapter.deliver('default', null, { kind: 'chat', content: { text: 'old' } });
const since = Date.now();
await new Promise((r) => setTimeout(r, 5));
await adapter.deliver('default', null, { kind: 'chat', content: { text: 'new' } });
const { data } = await req(port, 'GET', `/api/messages?since=${since}`);
const texts = (data as { messages: { text: string }[] }).messages.map((m) => m.text);
expect(texts).not.toContain('old');
expect(texts).toContain('new');
});
it('caps buffer at 200 messages, evicting the oldest', async () => {
for (let i = 0; i < 205; i++) {
await adapter.deliver('default', null, { kind: 'chat', content: { text: `m-${i}` } });
}
const { data } = await req(port, 'GET', '/api/messages?since=0');
const messages = (data as { messages: { text: string }[] }).messages;
expect(messages).toHaveLength(200);
expect(messages.map((m) => m.text)).not.toContain('m-0');
expect(messages.map((m) => m.text)).toContain('m-5');
expect(messages.map((m) => m.text)).toContain('m-204');
});
});
describe('auth', () => {
let authAdapter: ChannelAdapter;
let authPort: number;
beforeEach(async () => {
authPort = await getFreePort();
authAdapter = createEmacsAdapter({ port: authPort, authToken: 'secret', platformId: 'default' });
await authAdapter.setup(makeSetup());
});
afterEach(async () => {
if (authAdapter.isConnected()) await authAdapter.teardown();
});
it('rejects POST without Authorization header', async () => {
const { status } = await req(authPort, 'POST', '/api/message', JSON.stringify({ text: 'hi' }));
expect(status).toBe(401);
});
it('rejects POST with wrong token', async () => {
const { status } = await req(authPort, 'POST', '/api/message', JSON.stringify({ text: 'hi' }), {
Authorization: 'Bearer wrong',
});
expect(status).toBe(401);
});
it('accepts POST with correct Bearer token', async () => {
const { status } = await req(authPort, 'POST', '/api/message', JSON.stringify({ text: 'hi' }), {
Authorization: 'Bearer secret',
});
expect(status).toBe(200);
});
it('rejects GET without Authorization header', async () => {
const { status } = await req(authPort, 'GET', '/api/messages?since=0');
expect(status).toBe(401);
});
it('accepts GET with correct Bearer token', async () => {
const { status } = await req(authPort, 'GET', '/api/messages?since=0', undefined, {
Authorization: 'Bearer secret',
});
expect(status).toBe(200);
});
});
});
+186
View File
@@ -0,0 +1,186 @@
/**
* Emacs channel adapter (v2) — native HTTP bridge.
*
* Stands up a localhost HTTP server that the nanoclaw.el client talks to:
* - POST /api/message — user typed a message in Emacs; fire onInbound
* - GET /api/messages?since=<ms> — Emacs polls for agent replies
*
* Single-user, single-chat: one adapter instance = one messaging group with
* `platform_id = "default"` (override with EMACS_PLATFORM_ID). No threads,
* no cold DM. Self-registers on import.
*/
import http from 'http';
import { readEnvFile } from '../env.js';
import { log } from '../log.js';
import { registerChannelAdapter } from './channel-registry.js';
import type { ChannelAdapter, ChannelSetup, InboundMessage, OutboundMessage } from './adapter.js';
const OUTBOUND_BUFFER_MAX = 200;
interface BufferedMessage {
text: string;
timestamp: number;
}
interface EmacsAdapterOptions {
port: number;
authToken: string | null;
platformId: string;
}
function createEmacsAdapter(opts: EmacsAdapterOptions): ChannelAdapter {
let server: http.Server | null = null;
let setupConfig: ChannelSetup | null = null;
const outboundBuffer: BufferedMessage[] = [];
function checkAuth(req: http.IncomingMessage, res: http.ServerResponse): boolean {
if (!opts.authToken) return true;
if (req.headers['authorization'] === `Bearer ${opts.authToken}`) return true;
res
.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' })
.end(JSON.stringify({ error: 'Unauthorized' }));
return false;
}
function handlePost(req: http.IncomingMessage, res: http.ServerResponse): void {
let body = '';
req.on('data', (chunk) => (body += chunk));
req.on('end', () => {
let text: string;
try {
const parsed = JSON.parse(body) as { text?: string };
text = parsed.text ?? '';
} catch {
res
.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' })
.end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
if (!text.trim()) {
res
.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' })
.end(JSON.stringify({ error: 'text required' }));
return;
}
const timestamp = new Date().toISOString();
const id = `emacs-${Date.now()}`;
const inbound: InboundMessage = {
id,
kind: 'chat',
content: {
text,
sender: 'Emacs',
senderId: `emacs:${opts.platformId}`,
},
timestamp,
};
try {
setupConfig?.onInbound(opts.platformId, null, inbound);
} catch (err) {
log.error('Emacs onInbound failed', { err });
}
res
.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' })
.end(JSON.stringify({ messageId: id, timestamp: Date.now() }));
});
}
function handlePoll(url: URL, res: http.ServerResponse): void {
const since = parseInt(url.searchParams.get('since') ?? '0', 10);
const messages = outboundBuffer.filter((m) => m.timestamp > since);
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }).end(JSON.stringify({ messages }));
}
return {
name: 'emacs',
channelType: 'emacs',
supportsThreads: false,
async setup(config: ChannelSetup): Promise<void> {
setupConfig = config;
server = http.createServer((req, res) => {
if (!checkAuth(req, res)) return;
const url = new URL(req.url ?? '/', `http://localhost:${opts.port}`);
if (req.method === 'POST' && url.pathname === '/api/message') {
handlePost(req, res);
} else if (req.method === 'GET' && url.pathname === '/api/messages') {
handlePoll(url, res);
} else {
res
.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' })
.end(JSON.stringify({ error: 'Not found' }));
}
});
await new Promise<void>((resolve, reject) => {
server!.once('error', reject);
server!.listen(opts.port, '127.0.0.1', () => {
log.info('Emacs channel listening', { port: opts.port, platformId: opts.platformId });
resolve();
});
});
// Stamp a human-readable name on the messaging_groups row on first boot.
config.onMetadata(opts.platformId, 'Emacs', false);
},
async teardown(): Promise<void> {
if (!server) return;
await new Promise<void>((resolve) => server!.close(() => resolve()));
server = null;
log.info('Emacs channel stopped');
},
isConnected(): boolean {
return server?.listening ?? false;
},
async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise<string | undefined> {
if (platformId !== opts.platformId) {
log.warn('Emacs deliver called with unknown platformId', { platformId });
return undefined;
}
const text = extractText(message.content);
if (!text) return undefined;
const id = `emacs-out-${Date.now()}`;
outboundBuffer.push({ text, timestamp: Date.now() });
while (outboundBuffer.length > OUTBOUND_BUFFER_MAX) outboundBuffer.shift();
return id;
},
};
}
function extractText(content: unknown): string {
if (typeof content === 'string') return content;
if (content && typeof content === 'object') {
const c = content as { text?: unknown };
if (typeof c.text === 'string') return c.text;
}
return '';
}
registerChannelAdapter('emacs', {
factory: () => {
const env = readEnvFile(['EMACS_ENABLED', 'EMACS_CHANNEL_PORT', 'EMACS_AUTH_TOKEN', 'EMACS_PLATFORM_ID']);
const enabled = process.env.EMACS_ENABLED || env.EMACS_ENABLED;
if (!enabled || enabled === 'false') return null;
const portStr = process.env.EMACS_CHANNEL_PORT || env.EMACS_CHANNEL_PORT || '8766';
const port = parseInt(portStr, 10);
const authToken = process.env.EMACS_AUTH_TOKEN || env.EMACS_AUTH_TOKEN || null;
const platformId = process.env.EMACS_PLATFORM_ID || env.EMACS_PLATFORM_ID || 'default';
return createEmacsAdapter({ port, authToken, platformId });
},
});
export { createEmacsAdapter };
+20
View File
@@ -0,0 +1,20 @@
/**
* Google Chat channel adapter (v2) — uses Chat SDK bridge.
* Self-registers on import.
*/
import { createGoogleChatAdapter } from '@chat-adapter/gchat';
import { readEnvFile } from '../env.js';
import { createChatSdkBridge } from './chat-sdk-bridge.js';
import { registerChannelAdapter } from './channel-registry.js';
registerChannelAdapter('gchat', {
factory: () => {
const env = readEnvFile(['GCHAT_CREDENTIALS']);
if (!env.GCHAT_CREDENTIALS) return null;
const gchatAdapter = createGoogleChatAdapter({
credentials: JSON.parse(env.GCHAT_CREDENTIALS),
});
return createChatSdkBridge({ adapter: gchatAdapter, concurrency: 'concurrent', supportsThreads: true });
},
});
+23
View File
@@ -0,0 +1,23 @@
/**
* GitHub channel adapter (v2) — uses Chat SDK bridge.
* PR comment threads as conversations.
* Self-registers on import.
*/
import { createGitHubAdapter } from '@chat-adapter/github';
import { readEnvFile } from '../env.js';
import { createChatSdkBridge } from './chat-sdk-bridge.js';
import { registerChannelAdapter } from './channel-registry.js';
registerChannelAdapter('github', {
factory: () => {
const env = readEnvFile(['GITHUB_TOKEN', 'GITHUB_WEBHOOK_SECRET', 'GITHUB_BOT_USERNAME']);
if (!env.GITHUB_TOKEN) return null;
const githubAdapter = createGitHubAdapter({
token: env.GITHUB_TOKEN,
webhookSecret: env.GITHUB_WEBHOOK_SECRET,
userName: env.GITHUB_BOT_USERNAME,
});
return createChatSdkBridge({ adapter: githubAdapter, concurrency: 'queue', supportsThreads: true });
},
});
+29
View File
@@ -0,0 +1,29 @@
/**
* iMessage channel adapter (v2) — uses Chat SDK bridge.
* Supports local mode (macOS Full Disk Access) and remote mode (Photon API).
* Self-registers on import.
*/
import { createiMessageAdapter } from 'chat-adapter-imessage';
import { readEnvFile } from '../env.js';
import { createChatSdkBridge } from './chat-sdk-bridge.js';
import { registerChannelAdapter } from './channel-registry.js';
registerChannelAdapter('imessage', {
factory: () => {
const env = readEnvFile(['IMESSAGE_ENABLED', 'IMESSAGE_LOCAL', 'IMESSAGE_SERVER_URL', 'IMESSAGE_API_KEY']);
const isLocal = env.IMESSAGE_LOCAL !== 'false';
if (isLocal && !env.IMESSAGE_ENABLED) return null;
if (!isLocal && !env.IMESSAGE_SERVER_URL) return null;
const rawAdapter = createiMessageAdapter({
local: isLocal,
serverUrl: env.IMESSAGE_SERVER_URL,
apiKey: env.IMESSAGE_API_KEY,
});
// Polyfill channelIdFromThreadId (community adapter doesn't implement it)
const imessageAdapter = Object.assign(rawAdapter, {
channelIdFromThreadId: (threadId: string) => threadId,
});
return createChatSdkBridge({ adapter: imessageAdapter, concurrency: 'concurrent', supportsThreads: false });
},
});
+52 -4
View File
@@ -1,9 +1,57 @@
// Channel self-registration barrel.
// Each import triggers the channel module's registerChannelAdapter() call.
//
// Main ships with one default channel — `cli`, the always-on local-terminal
// channel. Other channel skills (/add-slack, /add-discord, /add-whatsapp,
// ...) copy their module from the `channels` branch and append a
// self-registration import below.
// The `channels` branch keeps this file fully populated — it's the
// fully-loaded, runnable branch. Individual `/add-<channel>` skills pull
// single files from this branch onto a user's install, appending their
// own import lines to a leaner barrel on main.
// cli — default channel that ships with main (always on, no credentials).
import './cli.js';
// discord
import './discord.js';
// slack
// import './slack.js';
// telegram
import './telegram.js';
// github
// import './github.js';
// linear
import './linear.js';
// google chat
// import './gchat.js';
// microsoft teams
// import './teams.js';
// whatsapp cloud api
// import './whatsapp-cloud.js';
// resend (email)
// import './resend.js';
// matrix
// import './matrix.js';
// webex
// import './webex.js';
// imessage
import './imessage.js';
// gmail (native, no Chat SDK)
// whatsapp (native, no Chat SDK)
import './whatsapp.js';
// signal (native, no Chat SDK — signal-cli TCP JSON-RPC daemon)
// import './signal.js';
// emacs (native HTTP bridge, no Chat SDK)
// import './emacs.js';
+45
View File
@@ -0,0 +1,45 @@
/**
* Linear channel adapter (v2) — uses Chat SDK bridge.
* Issue comment threads as conversations.
* Self-registers on import.
*
* Linear OAuth apps can't be @-mentioned, so this adapter relies on the
* bridge's default onNewMessage catch-all to forward every comment.
*/
import { createLinearAdapter } from '@chat-adapter/linear';
import { readEnvFile } from '../env.js';
import { createChatSdkBridge } from './chat-sdk-bridge.js';
import { registerChannelAdapter } from './channel-registry.js';
registerChannelAdapter('linear', {
factory: () => {
const env = readEnvFile([
'LINEAR_API_KEY',
'LINEAR_CLIENT_ID',
'LINEAR_CLIENT_SECRET',
'LINEAR_WEBHOOK_SECRET',
'LINEAR_BOT_USERNAME',
'LINEAR_TEAM_KEY',
]);
if (!env.LINEAR_API_KEY && !env.LINEAR_CLIENT_ID) return null;
const auth = env.LINEAR_CLIENT_ID
? { clientId: env.LINEAR_CLIENT_ID, clientSecret: env.LINEAR_CLIENT_SECRET }
: { apiKey: env.LINEAR_API_KEY };
const linearAdapter = createLinearAdapter({
...auth,
webhookSecret: env.LINEAR_WEBHOOK_SECRET,
userName: env.LINEAR_BOT_USERNAME,
});
// Override channelIdFromThreadId to return a team-based channel ID.
// The upstream adapter returns per-issue UUIDs which creates a new
// messaging group for every issue. We want one group per team.
const teamKey = env.LINEAR_TEAM_KEY || 'default';
linearAdapter.channelIdFromThreadId = () => `linear:${teamKey}`;
return createChatSdkBridge({ adapter: linearAdapter, concurrency: 'queue', supportsThreads: true });
},
});
+206
View File
@@ -0,0 +1,206 @@
/**
* Matrix channel adapter (v2) — uses Chat SDK bridge.
* Self-registers on import.
*
* Supports two auth methods (resolved by the adapter from env):
* - Access token: MATRIX_ACCESS_TOKEN + MATRIX_USER_ID
* - Password: MATRIX_USERNAME + MATRIX_PASSWORD (+ optional MATRIX_USER_ID)
*
* Optional env vars:
* MATRIX_BOT_USERNAME — display name for the bot (default: "bot")
* MATRIX_INVITE_AUTOJOIN — "true" to auto-accept room invites
* MATRIX_INVITE_AUTOJOIN_ALLOWLIST — comma-separated user IDs allowed to invite
* MATRIX_RECOVERY_KEY — enable E2EE cross-signing
* MATRIX_DEVICE_ID — stable device ID across restarts
*/
import { createMatrixAdapter } from '@beeper/chat-adapter-matrix';
import { log } from '../log.js';
import { readEnvFile } from '../env.js';
import { createChatSdkBridge } from './chat-sdk-bridge.js';
import { registerChannelAdapter } from './channel-registry.js';
const ENV_KEYS = [
'MATRIX_BASE_URL',
'MATRIX_ACCESS_TOKEN',
'MATRIX_USERNAME',
'MATRIX_PASSWORD',
'MATRIX_USER_ID',
'MATRIX_BOT_USERNAME',
'MATRIX_DEVICE_ID',
'MATRIX_RECOVERY_KEY',
'MATRIX_INVITE_AUTOJOIN',
'MATRIX_INVITE_AUTOJOIN_ALLOWLIST',
] as const;
/**
* Wrap the Matrix adapter so DM conversations are identified by user handle
* across the whole system, not by ephemeral room IDs.
*
* Matrix DMs live in rooms (e.g. "!abc:server"), but NanoClaw identifies
* channels by platform_id. Using a user handle as platform_id means both
* the user and the messaging group reference the same stable identifier.
*
* Two directions to bridge:
* - Outbound: delivery passes "matrix:@user:server" → resolve to room via openDM
* - Inbound: adapter emits "matrix:!room:server" → rewrite to user handle
* so the router finds the existing messaging group instead of creating
* a new one.
*
* Both resolutions are cached for the process lifetime.
*/
function wrapWithDmResolution(adapter: ReturnType<typeof createMatrixAdapter>): typeof adapter {
const origPostMessage = adapter.postMessage.bind(adapter);
const origStartTyping = adapter.startTyping.bind(adapter);
const origChannelIdFromThreadId = adapter.channelIdFromThreadId.bind(adapter);
// roomId → user handle, used to rewrite inbound channel IDs.
const roomToUserCache = new Map<string, string>();
function isUserHandle(threadId: string): boolean {
try {
const { roomID } = adapter.decodeThreadId(threadId);
return !roomID.startsWith('!');
} catch {
return true;
}
}
async function resolveThreadId(threadId: string): Promise<string> {
if (!isUserHandle(threadId)) return threadId;
const userHandle = threadId.startsWith('matrix:') ? threadId.slice('matrix:'.length) : threadId;
log.info('Matrix: resolving DM room for user handle', { userHandle });
const resolved = await adapter.openDM(userHandle);
try {
const { roomID } = adapter.decodeThreadId(resolved);
roomToUserCache.set(roomID, userHandle);
} catch {
// decode failure is non-fatal — outbound still works
}
return resolved;
}
// Rewrite inbound room-based channel IDs to user-handle form for DM rooms.
// Non-DM rooms pass through unchanged.
adapter.channelIdFromThreadId = (threadId: string): string => {
try {
const { roomID } = adapter.decodeThreadId(threadId);
if (!roomID.startsWith('!')) return origChannelIdFromThreadId(threadId);
const cached = roomToUserCache.get(roomID);
if (cached) return `matrix:${cached}`;
// Not cached — check if this is a DM by membership count
const client = (adapter as any).client;
const room = client?.getRoom(roomID);
if (!room) return origChannelIdFromThreadId(threadId);
if (room.getJoinedMemberCount() > 2) return origChannelIdFromThreadId(threadId);
const botId = (adapter as any).userID;
const otherMember = room.getJoinedMembers().find((m: { userId: string }) => m.userId !== botId);
if (!otherMember) return origChannelIdFromThreadId(threadId);
roomToUserCache.set(roomID, otherMember.userId);
return `matrix:${otherMember.userId}`;
} catch {
return origChannelIdFromThreadId(threadId);
}
};
// The Chat SDK calls adapter.isDM(threadId) synchronously to decide whether
// to dispatch to onDirectMessage handlers. The Matrix adapter doesn't expose
// this method — it only has an async isDirectRoom(). We add a synchronous
// isDM that checks room membership count: 2 members = DM.
(adapter as any).isDM = (threadId: string): boolean => {
try {
const { roomID } = adapter.decodeThreadId(threadId);
const client = (adapter as any).client;
if (!client) return false;
const room = client.getRoom(roomID);
if (!room) return false;
const members = room.getJoinedMemberCount();
return members <= 2;
} catch {
return false;
}
};
adapter.postMessage = async (
threadId: string,
...args: Parameters<typeof origPostMessage> extends [string, ...infer R] ? R : never
) => {
const resolvedTid = await resolveThreadId(threadId);
return origPostMessage(resolvedTid, ...args);
};
adapter.startTyping = async (threadId: string) => {
const resolvedTid = await resolveThreadId(threadId);
return origStartTyping(resolvedTid);
};
return adapter;
}
registerChannelAdapter('matrix', {
factory: () => {
const env = readEnvFile([...ENV_KEYS]);
if (!env.MATRIX_BASE_URL) return null;
if (!env.MATRIX_ACCESS_TOKEN && !(env.MATRIX_USERNAME && env.MATRIX_PASSWORD)) return null;
for (const key of ENV_KEYS) {
if (env[key]) process.env[key] = env[key];
}
// Default: auto-join room invites so DMs work without manual acceptance
if (!process.env.MATRIX_INVITE_AUTOJOIN) {
process.env.MATRIX_INVITE_AUTOJOIN = 'true';
}
const matrixAdapter = wrapWithDmResolution(createMatrixAdapter());
const bridge = createChatSdkBridge({ adapter: matrixAdapter, concurrency: 'concurrent', supportsThreads: false });
// Matrix user IDs contain ":" (e.g. "@user:matrix.org") which the shared
// permissions module interprets as already-prefixed. Wrap onInbound to
// ensure senderId always carries the "matrix:" channel prefix so user
// records match between init-first-agent and inbound routing.
const origSetup = bridge.setup.bind(bridge);
bridge.setup = async (hostConfig) => {
const origOnInbound = hostConfig.onInbound.bind(hostConfig);
await origSetup({
...hostConfig,
onInbound: (platformId, threadId, message) => {
if (message.content && typeof message.content === 'object') {
const content = message.content as Record<string, unknown>;
if (typeof content.senderId === 'string' && !content.senderId.startsWith('matrix:')) {
content.senderId = `matrix:${content.senderId}`;
}
}
return origOnInbound(platformId, threadId, message);
},
});
// Wait for Matrix sync to reach PREPARED state before returning from setup.
// Without this, the host's delivery poll and sweep timer start immediately
// and can starve the SDK's sync generator microtask queue, blocking
// incremental syncs so new inbound messages never get dispatched.
await new Promise<void>((resolve) => {
const check = setInterval(() => {
if ((matrixAdapter as unknown as { liveSyncReady?: boolean }).liveSyncReady) {
log.info('Matrix sync ready');
clearInterval(check);
resolve();
}
}, 500);
setTimeout(() => {
clearInterval(check);
resolve();
}, 30_000);
});
};
return bridge;
},
});
+23
View File
@@ -0,0 +1,23 @@
/**
* Resend (email) channel adapter (v2) — uses Chat SDK bridge.
* Self-registers on import.
*/
import { createResendAdapter } from '@resend/chat-sdk-adapter';
import { readEnvFile } from '../env.js';
import { createChatSdkBridge } from './chat-sdk-bridge.js';
import { registerChannelAdapter } from './channel-registry.js';
registerChannelAdapter('resend', {
factory: () => {
const env = readEnvFile(['RESEND_API_KEY', 'RESEND_FROM_ADDRESS', 'RESEND_FROM_NAME', 'RESEND_WEBHOOK_SECRET']);
if (!env.RESEND_API_KEY) return null;
const resendAdapter = createResendAdapter({
apiKey: env.RESEND_API_KEY,
fromAddress: env.RESEND_FROM_ADDRESS,
fromName: env.RESEND_FROM_NAME,
webhookSecret: env.RESEND_WEBHOOK_SECRET,
});
return createChatSdkBridge({ adapter: resendAdapter, concurrency: 'queue', supportsThreads: false });
},
});
+854
View File
@@ -0,0 +1,854 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
// --- Mocks ---
vi.mock('./channel-registry.js', () => ({ registerChannelAdapter: vi.fn() }));
vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})) }));
vi.mock('../log.js', () => ({
log: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('node:child_process', () => ({
spawn: vi.fn(),
execFileSync: vi.fn(),
}));
// --- TCP socket mock ---
import { EventEmitter } from 'events';
const tcpRef = vi.hoisted(() => ({
rpcResponses: new Map<string, unknown>(),
fakeSocket: null as any,
}));
function createFakeSocket(): EventEmitter & {
write: ReturnType<typeof vi.fn>;
destroy: ReturnType<typeof vi.fn>;
destroyed: boolean;
} {
const sock = new EventEmitter() as any;
sock.destroyed = false;
sock.destroy = vi.fn(() => {
sock.destroyed = true;
sock.emit('close');
});
sock.write = vi.fn((data: string) => {
try {
const req = JSON.parse(data.trim());
const result = tcpRef.rpcResponses.get(req.method) ?? { ok: true };
const response = JSON.stringify({ jsonrpc: '2.0', id: req.id, result }) + '\n';
setImmediate(() => sock.emit('data', Buffer.from(response)));
} catch {
/* ignore */
}
});
return sock;
}
vi.mock('node:net', () => ({
createConnection: vi.fn((_port: number, _host: string, cb?: () => void) => {
const sock = createFakeSocket();
tcpRef.fakeSocket = sock;
if (cb) setImmediate(cb);
return sock;
}),
}));
import type { ChannelSetup } from './adapter.js';
import { createSignalAdapter } from './signal.js';
// --- Test helpers ---
function createMockSetup() {
return {
onInbound: vi.fn() as unknown as ChannelSetup['onInbound'] & ReturnType<typeof vi.fn>,
onInboundEvent: vi.fn() as unknown as ChannelSetup['onInboundEvent'] & ReturnType<typeof vi.fn>,
onMetadata: vi.fn() as unknown as ChannelSetup['onMetadata'] & ReturnType<typeof vi.fn>,
onAction: vi.fn() as unknown as ChannelSetup['onAction'] & ReturnType<typeof vi.fn>,
};
}
function createAdapter() {
return createSignalAdapter({
cliPath: 'signal-cli',
account: '+15551234567',
tcpHost: '127.0.0.1',
tcpPort: 7583,
manageDaemon: false,
signalDataDir: '/tmp/signal-cli-test-data',
});
}
function getRpcCalls(): Array<{
method: string;
params: Record<string, unknown>;
id: string;
}> {
if (!tcpRef.fakeSocket) return [];
return tcpRef.fakeSocket.write.mock.calls
.map((c: any[]) => {
try {
return JSON.parse(c[0].trim());
} catch {
return null;
}
})
.filter(Boolean);
}
function getRpcCallsForMethod(method: string) {
return getRpcCalls().filter((c) => c.method === method);
}
function pushEvent(envelope: Record<string, unknown>) {
if (!tcpRef.fakeSocket) throw new Error('TCP socket not connected');
const notification =
JSON.stringify({
jsonrpc: '2.0',
method: 'receive',
params: { envelope },
}) + '\n';
tcpRef.fakeSocket.emit('data', Buffer.from(notification));
}
// --- Tests ---
describe('SignalAdapter', () => {
beforeEach(() => {
vi.clearAllMocks();
tcpRef.rpcResponses.clear();
tcpRef.fakeSocket = null;
tcpRef.rpcResponses.set('send', { timestamp: 1234567890 });
tcpRef.rpcResponses.set('sendTyping', {});
});
afterEach(() => {
try {
tcpRef.fakeSocket?.destroy();
} catch {
// already closed
}
});
// --- Connection lifecycle ---
describe('connection lifecycle', () => {
it('connects when daemon is reachable', async () => {
const adapter = createAdapter();
const cfg = createMockSetup();
await adapter.setup(cfg);
expect(adapter.isConnected()).toBe(true);
expect(tcpRef.fakeSocket).not.toBeNull();
await adapter.teardown();
});
it('isConnected() returns false before setup', () => {
const adapter = createAdapter();
expect(adapter.isConnected()).toBe(false);
});
it('disconnects cleanly', async () => {
const adapter = createAdapter();
await adapter.setup(createMockSetup());
expect(adapter.isConnected()).toBe(true);
await adapter.teardown();
expect(adapter.isConnected()).toBe(false);
});
it('throws NetworkError if daemon is unreachable', async () => {
const { createConnection } = await import('node:net');
vi.mocked(createConnection).mockImplementationOnce((...args: any[]) => {
const sock = createFakeSocket();
setImmediate(() => sock.emit('error', new Error('Connection refused')));
return sock as any;
});
const adapter = createAdapter();
await expect(adapter.setup(createMockSetup())).rejects.toThrow(/not reachable/);
});
});
// --- Inbound message handling ---
describe('inbound message handling', () => {
it('delivers DM via onInbound', async () => {
const adapter = createAdapter();
const cfg = createMockSetup();
await adapter.setup(cfg);
pushEvent({
sourceNumber: '+15555550123',
sourceName: 'Alice',
dataMessage: {
timestamp: 1700000000000,
message: 'Hello from Signal',
},
});
await new Promise((r) => setTimeout(r, 50));
expect(cfg.onMetadata).toHaveBeenCalledWith('+15555550123', 'Alice', false);
expect(cfg.onInbound).toHaveBeenCalledWith(
'+15555550123',
null,
expect.objectContaining({
id: '1700000000000',
kind: 'chat',
content: expect.objectContaining({
text: 'Hello from Signal',
sender: '+15555550123',
senderName: 'Alice',
}),
}),
);
await adapter.teardown();
});
it('delivers group message with group platformId', async () => {
const adapter = createAdapter();
const cfg = createMockSetup();
await adapter.setup(cfg);
pushEvent({
sourceNumber: '+15555550999',
sourceName: 'Bob',
dataMessage: {
timestamp: 1700000000000,
message: 'Group hello',
groupInfo: { groupId: 'abc123', groupName: 'Family' },
},
});
await new Promise((r) => setTimeout(r, 50));
expect(cfg.onMetadata).toHaveBeenCalledWith('group:abc123', 'Family', true);
expect(cfg.onInbound).toHaveBeenCalledWith(
'group:abc123',
null,
expect.objectContaining({
content: expect.objectContaining({
text: 'Group hello',
sender: '+15555550999',
}),
}),
);
await adapter.teardown();
});
it('skips sync messages (own outbound)', async () => {
const adapter = createAdapter();
const cfg = createMockSetup();
await adapter.setup(cfg);
pushEvent({
sourceNumber: '+15551234567',
syncMessage: {
sentMessage: {
timestamp: 1700000000000,
message: 'My own message',
destination: '+15555550123',
},
},
});
await new Promise((r) => setTimeout(r, 50));
expect(cfg.onInbound).not.toHaveBeenCalled();
await adapter.teardown();
});
it('processes Note to Self sync messages as inbound', async () => {
const adapter = createAdapter();
const cfg = createMockSetup();
await adapter.setup(cfg);
pushEvent({
sourceNumber: '+15551234567',
syncMessage: {
sentMessage: {
timestamp: 1700000000000,
message: 'Hello Bee',
destinationNumber: '+15551234567',
},
},
});
await new Promise((r) => setTimeout(r, 50));
expect(cfg.onInbound).toHaveBeenCalledWith(
'+15551234567',
null,
expect.objectContaining({
content: expect.objectContaining({
text: 'Hello Bee',
senderName: 'Me',
isFromMe: true,
}),
}),
);
await adapter.teardown();
});
it('skips empty messages', async () => {
const adapter = createAdapter();
const cfg = createMockSetup();
await adapter.setup(cfg);
pushEvent({
sourceNumber: '+15555550123',
dataMessage: { timestamp: 1700000000000, message: ' ' },
});
await new Promise((r) => setTimeout(r, 50));
expect(cfg.onInbound).not.toHaveBeenCalled();
await adapter.teardown();
});
it('skips echoed outbound messages', async () => {
const adapter = createAdapter();
const cfg = createMockSetup();
await adapter.setup(cfg);
await adapter.deliver('+15555550123', null, {
kind: 'text',
content: { text: 'Echo test' },
});
pushEvent({
sourceNumber: '+15555550123',
dataMessage: { timestamp: 1700000000000, message: 'Echo test' },
});
await new Promise((r) => setTimeout(r, 50));
expect(cfg.onInbound).not.toHaveBeenCalled();
await adapter.teardown();
});
it('forwards image attachments as [Image: <path>] plus structured attachments array', async () => {
const adapter = createAdapter();
const cfg = createMockSetup();
await adapter.setup(cfg);
pushEvent({
sourceNumber: '+15555550123',
sourceName: 'Alice',
dataMessage: {
timestamp: 1700000000000,
attachments: [{ id: 'att123abc', contentType: 'image/jpeg', size: 50000 }],
},
});
await new Promise((r) => setTimeout(r, 50));
expect(cfg.onInbound).toHaveBeenCalledWith(
'+15555550123',
null,
expect.objectContaining({
content: expect.objectContaining({
text: expect.stringMatching(/^\[Image: .+att123abc\]$/),
attachments: [expect.objectContaining({ contentType: 'image/jpeg' })],
}),
}),
);
await adapter.teardown();
});
});
// --- groupV2 ---
describe('group routing', () => {
it('routes to groupV2.id when present, falling back to legacy groupInfo.groupId', async () => {
const adapter = createAdapter();
const cfg = createMockSetup();
await adapter.setup(cfg);
pushEvent({
sourceNumber: '+15555550123',
sourceName: 'Alice',
dataMessage: {
timestamp: 1700000000000,
message: 'hello v2',
groupV2: { id: 'v2group=' },
},
});
await new Promise((r) => setTimeout(r, 50));
expect(cfg.onInbound).toHaveBeenCalledWith('group:v2group=', null, expect.anything());
await adapter.teardown();
});
});
// --- mention resolution ---
describe('mention resolution', () => {
it('replaces inline mention placeholders with display names', async () => {
const adapter = createAdapter();
const cfg = createMockSetup();
await adapter.setup(cfg);
pushEvent({
sourceNumber: '+15555550123',
sourceName: 'Alice',
dataMessage: {
timestamp: 1700000000000,
message: 'hey are you here?',
mentions: [{ start: 4, length: 1, name: 'Bob', uuid: 'bob-uuid' }],
},
});
await new Promise((r) => setTimeout(r, 50));
expect(cfg.onInbound).toHaveBeenCalledWith(
'+15555550123',
null,
expect.objectContaining({
content: expect.objectContaining({ text: 'hey @Bob are you here?' }),
}),
);
await adapter.teardown();
});
});
// --- Quote context ---
describe('quote context', () => {
it('emits a nested replyTo object matching the formatter contract', async () => {
const adapter = createAdapter();
const cfg = createMockSetup();
await adapter.setup(cfg);
pushEvent({
sourceNumber: '+15555550123',
sourceName: 'Alice',
dataMessage: {
timestamp: 1700000000000,
message: 'I disagree',
quote: {
id: 1699999999000,
authorNumber: '+15555550888',
authorName: 'Pineapple Pete',
text: 'Pineapple belongs on pizza',
},
},
});
await new Promise((r) => setTimeout(r, 50));
expect(cfg.onInbound).toHaveBeenCalledWith(
'+15555550123',
null,
expect.objectContaining({
content: expect.objectContaining({
text: 'I disagree',
replyTo: {
id: '1699999999000',
sender: 'Pineapple Pete',
text: 'Pineapple belongs on pizza',
},
}),
}),
);
await adapter.teardown();
});
});
// --- deliver ---
describe('deliver', () => {
it('sends DM via TCP RPC', async () => {
const adapter = createAdapter();
await adapter.setup(createMockSetup());
await adapter.deliver('+15555550123', null, {
kind: 'text',
content: { text: 'Hello' },
});
const sendCalls = getRpcCallsForMethod('send');
expect(sendCalls.length).toBeGreaterThan(0);
const last = sendCalls[sendCalls.length - 1];
expect(last.params).toEqual(
expect.objectContaining({
recipient: ['+15555550123'],
message: 'Hello',
account: '+15551234567',
}),
);
await adapter.teardown();
});
it('sends group message via groupId', async () => {
const adapter = createAdapter();
await adapter.setup(createMockSetup());
await adapter.deliver('group:abc123', null, {
kind: 'text',
content: { text: 'Group msg' },
});
const sendCalls = getRpcCallsForMethod('send');
const last = sendCalls[sendCalls.length - 1];
expect(last.params).toEqual(
expect.objectContaining({
groupId: 'abc123',
message: 'Group msg',
}),
);
await adapter.teardown();
});
it('chunks long messages', async () => {
const adapter = createAdapter();
await adapter.setup(createMockSetup());
const longText = 'x'.repeat(5000);
await adapter.deliver('+15555550123', null, {
kind: 'text',
content: { text: longText },
});
const sendCalls = getRpcCallsForMethod('send');
expect(sendCalls.length).toBeGreaterThan(1);
await adapter.teardown();
});
it('extracts text from string content', async () => {
const adapter = createAdapter();
await adapter.setup(createMockSetup());
await adapter.deliver('+15555550123', null, {
kind: 'text',
content: 'Plain string content',
});
const sendCalls = getRpcCallsForMethod('send');
expect(sendCalls.length).toBeGreaterThan(0);
const last = sendCalls[sendCalls.length - 1];
expect(last.params.message).toBe('Plain string content');
await adapter.teardown();
});
});
// --- Text styles ---
describe('text styles', () => {
it('sends bold text with textStyle parameter', async () => {
const adapter = createAdapter();
await adapter.setup(createMockSetup());
tcpRef.fakeSocket.write.mockClear();
await adapter.deliver('+15555550123', null, {
kind: 'text',
content: { text: 'Hello **world**' },
});
const sendCalls = getRpcCallsForMethod('send');
expect(sendCalls.length).toBeGreaterThan(0);
const last = sendCalls[sendCalls.length - 1];
expect(last.params.message).toBe('Hello world');
expect(last.params.textStyle).toEqual(['6:5:BOLD']);
await adapter.teardown();
});
it('sends inline code with MONOSPACE style', async () => {
const adapter = createAdapter();
await adapter.setup(createMockSetup());
tcpRef.fakeSocket.write.mockClear();
await adapter.deliver('+15555550123', null, {
kind: 'text',
content: { text: 'Run `npm test` now' },
});
const sendCalls = getRpcCallsForMethod('send');
const last = sendCalls[sendCalls.length - 1];
expect(last.params.message).toBe('Run npm test now');
expect(last.params.textStyle).toEqual(['4:8:MONOSPACE']);
await adapter.teardown();
});
it('sends plain text without textStyle', async () => {
const adapter = createAdapter();
await adapter.setup(createMockSetup());
tcpRef.fakeSocket.write.mockClear();
await adapter.deliver('+15555550123', null, {
kind: 'text',
content: { text: 'No formatting here' },
});
const sendCalls = getRpcCallsForMethod('send');
const last = sendCalls[sendCalls.length - 1];
expect(last.params.message).toBe('No formatting here');
expect(last.params.textStyle).toBeUndefined();
await adapter.teardown();
});
it('falls back to original markup when textStyle is rejected', async () => {
const adapter = createAdapter();
await adapter.setup(createMockSetup());
let sendCount = 0;
tcpRef.fakeSocket.write.mockImplementation((data: string) => {
try {
const req = JSON.parse(data.trim());
if (req.method === 'send') {
sendCount++;
if (sendCount === 1) {
const response =
JSON.stringify({
jsonrpc: '2.0',
id: req.id,
error: { message: 'Unknown parameter: textStyle' },
}) + '\n';
setImmediate(() => tcpRef.fakeSocket.emit('data', Buffer.from(response)));
return;
}
}
const response =
JSON.stringify({
jsonrpc: '2.0',
id: req.id,
result: { ok: true },
}) + '\n';
setImmediate(() => tcpRef.fakeSocket.emit('data', Buffer.from(response)));
} catch {
/* ignore */
}
});
await adapter.deliver('+15555550123', null, {
kind: 'text',
content: { text: 'Hello **world**' },
});
const sendCalls = getRpcCallsForMethod('send');
expect(sendCalls.length).toBe(2);
expect(sendCalls[1].params.message).toBe('Hello **world**');
expect(sendCalls[1].params.textStyle).toBeUndefined();
await adapter.teardown();
});
it('tracks nested styles with correct offsets', async () => {
const adapter = createAdapter();
await adapter.setup(createMockSetup());
tcpRef.fakeSocket.write.mockClear();
await adapter.deliver('+15555550123', null, {
kind: 'text',
content: { text: '**bold with `code` inside**' },
});
const sendCalls = getRpcCallsForMethod('send');
const last = sendCalls[sendCalls.length - 1];
expect(last.params.message).toBe('bold with code inside');
// BOLD covers the full inner span, MONOSPACE points at "code" in the
// final plain text (offset 10, length 4) — not the intermediate text.
const styles = (last.params.textStyle as string[]).slice().sort();
expect(styles).toEqual(['0:21:BOLD', '10:4:MONOSPACE']);
await adapter.teardown();
});
it('maps *single-asterisk* to ITALIC', async () => {
const adapter = createAdapter();
await adapter.setup(createMockSetup());
tcpRef.fakeSocket.write.mockClear();
await adapter.deliver('+15555550123', null, {
kind: 'text',
content: { text: 'Hello *world*' },
});
const sendCalls = getRpcCallsForMethod('send');
const last = sendCalls[sendCalls.length - 1];
expect(last.params.message).toBe('Hello world');
expect(last.params.textStyle).toEqual(['6:5:ITALIC']);
await adapter.teardown();
});
it('maps _underscore_ to ITALIC', async () => {
const adapter = createAdapter();
await adapter.setup(createMockSetup());
tcpRef.fakeSocket.write.mockClear();
await adapter.deliver('+15555550123', null, {
kind: 'text',
content: { text: 'hey _there_' },
});
const sendCalls = getRpcCallsForMethod('send');
const last = sendCalls[sendCalls.length - 1];
expect(last.params.message).toBe('hey there');
expect(last.params.textStyle).toEqual(['4:5:ITALIC']);
await adapter.teardown();
});
});
// --- Echo cache ---
describe('echo cache', () => {
it('does not drop same-text inbound from a different recipient', async () => {
// Bot sends "Hello" to Alice. Immediately after, Bob sends "Hello" from
// a different DM. Bob's message must still route — the earlier echo key
// was scoped to Alice.
const adapter = createAdapter();
const cfg = createMockSetup();
await adapter.setup(cfg);
await adapter.deliver('+15555550123', null, {
kind: 'text',
content: { text: 'Hello' },
});
pushEvent({
sourceNumber: '+15555550999',
sourceName: 'Bob',
dataMessage: { timestamp: 1700000000000, message: 'Hello' },
});
await new Promise((r) => setTimeout(r, 50));
expect(cfg.onInbound).toHaveBeenCalledWith(
'+15555550999',
null,
expect.objectContaining({
content: expect.objectContaining({ text: 'Hello', sender: '+15555550999' }),
}),
);
await adapter.teardown();
});
it('still skips echo on the same recipient', async () => {
const adapter = createAdapter();
const cfg = createMockSetup();
await adapter.setup(cfg);
await adapter.deliver('+15555550123', null, {
kind: 'text',
content: { text: 'Echo test' },
});
pushEvent({
sourceNumber: '+15555550123',
dataMessage: { timestamp: 1700000000000, message: 'Echo test' },
});
await new Promise((r) => setTimeout(r, 50));
expect(cfg.onInbound).not.toHaveBeenCalled();
await adapter.teardown();
});
});
// --- Connection drop ---
describe('connection drop', () => {
it('flips isConnected to false when the socket closes', async () => {
const adapter = createAdapter();
await adapter.setup(createMockSetup());
expect(adapter.isConnected()).toBe(true);
// Simulate the daemon dropping the TCP connection.
tcpRef.fakeSocket.destroy();
await new Promise((r) => setTimeout(r, 20));
expect(adapter.isConnected()).toBe(false);
await adapter.teardown();
});
});
// --- 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', () => {
it('sends typing indicator for DMs', async () => {
const adapter = createAdapter();
await adapter.setup(createMockSetup());
await adapter.setTyping!('+15555550123', null);
expect(getRpcCallsForMethod('sendTyping')).toHaveLength(1);
await adapter.teardown();
});
it('skips typing for groups', async () => {
const adapter = createAdapter();
await adapter.setup(createMockSetup());
await adapter.setTyping!('group:abc123', null);
expect(getRpcCallsForMethod('sendTyping')).toHaveLength(0);
await adapter.teardown();
});
});
// --- Adapter properties ---
describe('adapter properties', () => {
it('has channelType "signal"', () => {
const adapter = createAdapter();
expect(adapter.channelType).toBe('signal');
});
it('does not support threads', () => {
const adapter = createAdapter();
expect(adapter.supportsThreads).toBe(false);
});
});
});
+944
View File
@@ -0,0 +1,944 @@
/**
* Signal channel adapter for NanoClaw v2.
*
* Uses signal-cli's TCP JSON-RPC daemon for bidirectional messaging.
* Requires signal-cli (https://github.com/AsamK/signal-cli) installed
* and a linked account.
*
* 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 { createConnection, type Socket } from 'node:net';
import { homedir } from 'node:os';
import { join } from 'node:path';
import type { ChannelAdapter, ChannelSetup, InboundMessage, OutboundMessage } from './adapter.js';
import { registerChannelAdapter } from './channel-registry.js';
import { readEnvFile } from '../env.js';
import { log } from '../log.js';
// ---------------------------------------------------------------------------
// Signal CLI daemon management
// ---------------------------------------------------------------------------
interface DaemonHandle {
stop: () => void;
exited: Promise<void>;
isExited: () => boolean;
}
function spawnSignalDaemon(cliPath: string, account: string, host: string, port: number): DaemonHandle {
const args: string[] = [];
if (account) args.push('-a', account);
args.push('daemon', '--tcp', `${host}:${port}`, '--no-receive-stdout');
args.push('--receive-mode', 'on-start');
const child = spawn(cliPath, args, { stdio: ['ignore', 'pipe', 'pipe'] });
let exited = false;
const exitedPromise = new Promise<void>((resolve) => {
child.once('exit', (code, signal) => {
exited = true;
if (code !== 0 && code !== null) {
const reason = signal ? `signal ${signal}` : `code ${code}`;
log.error('signal-cli daemon exited', { reason });
}
resolve();
});
child.on('error', (err) => {
exited = true;
log.error('signal-cli spawn error', { err });
resolve();
});
});
child.stdout?.on('data', (data: Buffer) => {
for (const line of data.toString().split(/\r?\n/)) {
if (line.trim()) log.debug('signal-cli stdout', { line: line.trim() });
}
});
child.stderr?.on('data', (data: Buffer) => {
for (const line of data.toString().split(/\r?\n/)) {
if (!line.trim()) continue;
if (/\b(ERROR|WARN|FAILED|SEVERE)\b/i.test(line)) {
log.warn('signal-cli stderr', { line: line.trim() });
} else {
log.debug('signal-cli stderr', { line: line.trim() });
}
}
});
return {
stop: () => {
if (!child.killed && !exited) child.kill('SIGTERM');
},
exited: exitedPromise,
isExited: () => exited,
};
}
// ---------------------------------------------------------------------------
// TCP JSON-RPC client for signal-cli daemon (--tcp mode)
//
// signal-cli 0.14.x --tcp exposes a newline-delimited JSON-RPC socket.
// Requests are sent as JSON + newline; responses and push notifications
// (inbound messages) arrive the same way.
// ---------------------------------------------------------------------------
const RPC_TIMEOUT_MS = 15_000;
class SignalTcpClient {
private socket: Socket | null = null;
private buffer = '';
private pending = new Map<
string,
{
resolve: (value: unknown) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>;
}
>();
private onNotification: ((method: string, params: unknown) => void) | null = null;
private onClose: (() => void) | null = null;
constructor(
private host: string,
private port: number,
) {}
connect(handlers?: {
onNotification?: (method: string, params: unknown) => void;
onClose?: () => void;
}): Promise<void> {
this.onNotification = handlers?.onNotification ?? null;
this.onClose = handlers?.onClose ?? null;
return new Promise((resolve, reject) => {
const sock = createConnection(this.port, this.host, () => {
this.socket = sock;
resolve();
});
sock.on('error', (err) => {
if (!this.socket) {
reject(err);
return;
}
log.warn('Signal TCP socket error', { err });
});
sock.on('data', (chunk) => this.onData(chunk));
sock.on('close', () => {
const wasConnected = this.socket !== null;
this.socket = null;
for (const [, p] of this.pending) {
clearTimeout(p.timer);
p.reject(new Error('Signal TCP connection closed'));
}
this.pending.clear();
if (wasConnected) this.onClose?.();
});
});
}
async rpc<T = unknown>(method: string, params?: Record<string, unknown>): Promise<T> {
if (!this.socket) throw new Error('Signal TCP not connected');
const id = Math.random().toString(36).slice(2);
const msg = JSON.stringify({ jsonrpc: '2.0', method, params, id }) + '\n';
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`Signal RPC timeout: ${method}`));
}, RPC_TIMEOUT_MS);
this.pending.set(id, {
resolve: resolve as (v: unknown) => void,
reject,
timer,
});
this.socket!.write(msg);
});
}
close() {
this.socket?.destroy();
this.socket = null;
}
isConnected(): boolean {
return this.socket !== null && !this.socket.destroyed;
}
private onData(chunk: Buffer) {
this.buffer += chunk.toString();
let newlineIdx = this.buffer.indexOf('\n');
while (newlineIdx !== -1) {
const line = this.buffer.slice(0, newlineIdx).trim();
this.buffer = this.buffer.slice(newlineIdx + 1);
if (line) this.handleLine(line);
newlineIdx = this.buffer.indexOf('\n');
}
}
private handleLine(line: string) {
let parsed: any;
try {
parsed = JSON.parse(line);
} catch {
log.debug('Signal TCP: unparseable line', { line: line.slice(0, 200) });
return;
}
if (parsed.id && this.pending.has(parsed.id)) {
const p = this.pending.get(parsed.id)!;
this.pending.delete(parsed.id);
clearTimeout(p.timer);
if (parsed.error) {
p.reject(new Error(parsed.error.message ?? 'Signal RPC error'));
} else {
p.resolve(parsed.result);
}
return;
}
if (parsed.method && this.onNotification) {
this.onNotification(parsed.method, parsed.params);
}
}
}
async function signalTcpCheck(host: string, port: number): Promise<boolean> {
return new Promise((resolve) => {
let settled = false;
const finish = (result: boolean) => {
if (settled) return;
settled = true;
clearTimeout(timer);
sock.destroy();
resolve(result);
};
const sock = createConnection(port, host, () => finish(true));
sock.on('error', () => finish(false));
const timer = setTimeout(() => finish(false), 5000);
});
}
// ---------------------------------------------------------------------------
// Echo cache
// ---------------------------------------------------------------------------
const ECHO_TTL_MS = 10_000;
/**
* Per-recipient dedup for messages we sent ourselves.
*
* signal-cli echoes our own outbound back via syncMessage (and, for Note to
* Self, via sentMessage-with-self-destination). Without dedup, the agent sees
* its own replies as new inbound and loops. We remember `(platformId, text)`
* briefly after every send, and drop the first match within TTL.
*
* Keying on text alone is not enough: if we send "hi" to Alice and Bob then
* sends "hi" from a different chat, Bob's real message gets silently dropped.
*/
class EchoCache {
private entries = new Map<string, number>();
private keyFor(platformId: string, text: string): string {
return `${platformId}\x00${text.trim()}`;
}
remember(platformId: string, text: string): void {
const trimmed = text.trim();
if (!trimmed) return;
this.entries.set(this.keyFor(platformId, trimmed), Date.now());
this.cleanup();
}
isEcho(platformId: string, text: string): boolean {
const trimmed = text.trim();
if (!trimmed) return false;
const key = this.keyFor(platformId, trimmed);
const ts = this.entries.get(key);
if (!ts) return false;
if (Date.now() - ts > ECHO_TTL_MS) {
this.entries.delete(key);
return false;
}
this.entries.delete(key);
return true;
}
private cleanup(): void {
const now = Date.now();
for (const [key, ts] of this.entries) {
if (now - ts > ECHO_TTL_MS) this.entries.delete(key);
}
}
}
// ---------------------------------------------------------------------------
// Signal envelope types
// ---------------------------------------------------------------------------
interface SignalQuote {
id?: number;
author?: string;
authorNumber?: string;
authorUuid?: string;
authorName?: string;
text?: string;
}
interface SignalMention {
start?: number;
length?: number;
uuid?: string;
number?: string;
name?: string;
}
interface SignalDataMessage {
timestamp?: number;
message?: string;
mentions?: SignalMention[];
groupInfo?: { groupId?: string; groupName?: string; type?: string };
groupV2?: { id?: string };
quote?: SignalQuote;
attachments?: Array<{
id?: string;
contentType?: string;
filename?: string;
size?: number;
}>;
}
interface SignalEnvelope {
source?: string;
sourceName?: string;
sourceNumber?: string;
sourceUuid?: string;
dataMessage?: SignalDataMessage;
syncMessage?: {
sentMessage?: SignalDataMessage & {
destination?: string;
destinationNumber?: string;
};
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Replace inline `@<placeholder>` mention markers with display names so the
* agent sees `@Alice` instead of a raw UUID. Signal's protocol uses a single
* placeholder character (typically U+FFFC) at each mention's `start` offset.
*/
function resolveMentions(text: string, mentions?: SignalMention[]): string {
if (!mentions || mentions.length === 0) return text;
const sorted = [...mentions].sort((a, b) => (a.start ?? 0) - (b.start ?? 0));
let result = '';
let cursor = 0;
for (const m of sorted) {
const start = m.start ?? 0;
const length = m.length ?? 1;
const name = m.name || m.number || (m.uuid ? m.uuid.slice(0, 8) : 'someone');
if (start < cursor) continue;
result += text.slice(cursor, start) + `@${name}`;
cursor = start + length;
}
result += text.slice(cursor);
return result;
}
/**
* Optional voice-note transcription. Tries (in order):
* 1. local whisper.cpp CLI when `WHISPER_BIN` is set
* 2. OpenAI Whisper API when `OPENAI_API_KEY` is set
* Returns null if neither path is configured or transcription fails — caller
* falls back to a `[Voice Message]` placeholder.
*
* Signal voice notes are AAC/ADTS; whisper-cpp wants WAV. ffmpeg is invoked
* if available to convert; if ffmpeg is missing the local path is skipped.
*/
async function transcribeAudioOptional(filePath: string): Promise<string | null> {
const whisperBin = process.env.WHISPER_BIN;
if (whisperBin) {
try {
const wavPath = `${filePath}.wav`;
execSync(`ffmpeg -y -loglevel error -i "${filePath}" -ar 16000 -ac 1 "${wavPath}"`, { stdio: 'ignore' });
const model = process.env.WHISPER_MODEL || `${homedir()}/.local/share/whisper/models/ggml-base.en.bin`;
const out = execSync(`"${whisperBin}" -m "${model}" -f "${wavPath}" -nt -otxt -of "${wavPath}"`, {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
});
try {
unlinkSync(wavPath);
unlinkSync(`${wavPath}.txt`);
} catch {}
const text = out.replace(/\[[^\]]*\]/g, '').trim();
if (text) return text;
} catch (err) {
log.debug('Signal: local whisper transcription failed, trying OpenAI', { err });
}
}
const apiKey = process.env.OPENAI_API_KEY;
if (apiKey) {
try {
const buf = readFileSync(filePath);
const boundary = `----nanoclaw-${Date.now()}`;
const body = Buffer.concat([
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="model"\r\n\r\nwhisper-1\r\n--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="audio.aac"\r\nContent-Type: audio/aac\r\n\r\n`,
),
buf,
Buffer.from(`\r\n--${boundary}--\r\n`),
]);
const res = await fetch('https://api.openai.com/v1/audio/transcriptions', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': `multipart/form-data; boundary=${boundary}`,
},
body,
});
if (res.ok) {
const json = (await res.json()) as { text?: string };
if (json.text) return json.text.trim();
}
} catch (err) {
log.debug('Signal: OpenAI transcription failed', { err });
}
}
return null;
}
function chunkText(text: string, limit: number): string[] {
const chunks: string[] = [];
let remaining = text;
while (remaining.length > 0) {
if (remaining.length <= limit) {
chunks.push(remaining);
break;
}
let splitAt = remaining.lastIndexOf('\n', limit);
if (splitAt <= 0) splitAt = limit;
chunks.push(remaining.slice(0, splitAt));
remaining = remaining.slice(splitAt).replace(/^\n/, '');
}
return chunks;
}
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
// ---------------------------------------------------------------------------
// Signal text styles — convert Markdown to Signal's offset-based formatting
// ---------------------------------------------------------------------------
interface SignalTextStyle {
style: 'BOLD' | 'ITALIC' | 'STRIKETHROUGH' | 'MONOSPACE' | 'SPOILER';
start: number;
length: number;
}
interface StyledText {
text: string;
textStyles: SignalTextStyle[];
}
/**
* Convert Markdown-ish input to Signal's offset-based style ranges.
*
* Walks the input recursively: at each level we find the leftmost matching
* pattern, descend into its captured inner text (so `**bold with \`code\`
* inside**` stays bold-plus-monospace rather than leaking stripped markers),
* then continue past the match. Style offsets are recorded against the
* *output* text length as it's built, so nested styles always point at the
* right span of the final plain text.
*/
function parseSignalStyles(input: string): StyledText {
const styles: SignalTextStyle[] = [];
// Ordering matters: longer/greedier delimiters first so `` ``` `` beats
// `` ` ``, `**` beats `*`. The italic-`*` pattern refuses to start on
// whitespace so `*` isn't mistakenly opened on " * " in list-like text.
const patterns: Array<{ regex: RegExp; style: SignalTextStyle['style'] }> = [
{ regex: /```([\s\S]+?)```/, style: 'MONOSPACE' },
{ regex: /`([^`]+)`/, style: 'MONOSPACE' },
{ regex: /\*\*([^]+?)\*\*/, style: 'BOLD' },
{ regex: /~~([^]+?)~~/, style: 'STRIKETHROUGH' },
{ regex: /\|\|([^]+?)\|\|/, style: 'SPOILER' },
{ regex: /\*([^*\s][^*]*?)\*/, style: 'ITALIC' },
{ regex: /_([^_\s][^_]*?)_/, style: 'ITALIC' },
];
function walk(segment: string, outputBase: number): string {
let earliest: { start: number; match: RegExpExecArray; style: SignalTextStyle['style'] } | null = null;
for (const { regex, style } of patterns) {
const m = regex.exec(segment);
if (!m) continue;
if (earliest === null || m.index < earliest.start) {
earliest = { start: m.index, match: m, style };
}
}
if (!earliest) return segment;
const before = segment.slice(0, earliest.start);
const fullMatch = earliest.match[0];
const inner = earliest.match[1];
const afterStart = earliest.start + fullMatch.length;
const after = segment.slice(afterStart);
const innerOut = walk(inner, outputBase + before.length);
styles.push({
style: earliest.style,
start: outputBase + before.length,
length: innerOut.length,
});
const afterOut = walk(after, outputBase + before.length + innerOut.length);
return before + innerOut + afterOut;
}
const text = walk(input, 0);
return { text, textStyles: styles };
}
// ---------------------------------------------------------------------------
// SignalAdapter — v2 ChannelAdapter implementation
// ---------------------------------------------------------------------------
/**
* Platform ID format:
* DM: phone number or UUID (e.g. "+15555550123")
* Group: "group:<groupId>" (e.g. "group:abc123")
*
* channelType is always "signal". The router combines channelType + platformId
* to look up or create the messaging_group.
*/
export function createSignalAdapter(config: {
cliPath: string;
account: string;
tcpHost: string;
tcpPort: number;
manageDaemon: boolean;
signalDataDir: string;
}): ChannelAdapter {
let daemon: DaemonHandle | null = null;
let tcp: SignalTcpClient | null = null;
let connected = false;
const echoCache = new EchoCache();
let setup: ChannelSetup | null = null;
// -- inbound handling --
function handleNotification(method: string, params: unknown): void {
if (method === 'receive') {
const envelope = (params as any)?.envelope;
if (envelope) {
handleEnvelope(envelope).catch((err) => {
log.error('Signal: error handling envelope', { err });
});
}
}
}
async function handleEnvelope(envelope: SignalEnvelope): Promise<void> {
if (!setup) return;
// Sync messages (sent from another device)
const syncSent = envelope.syncMessage?.sentMessage;
if (syncSent) {
const dest = (syncSent.destinationNumber ?? syncSent.destination ?? '').trim();
// "Note to Self" — destination is our own account
if (dest === config.account) {
const text = (syncSent.message ?? '').trim();
if (!text) return;
const platformId = config.account;
if (echoCache.isEcho(platformId, text)) return;
const timestamp = syncSent.timestamp ? new Date(syncSent.timestamp).toISOString() : new Date().toISOString();
setup.onMetadata(platformId, 'Note to Self', false);
const msg: InboundMessage = {
id: String(syncSent.timestamp ?? Date.now()),
kind: 'chat',
content: {
text,
sender: config.account,
senderId: `signal:${config.account}`,
senderName: 'Me',
isFromMe: true,
...(syncSent.quote ? quoteToContent(syncSent.quote) : {}),
},
timestamp,
};
await setup.onInbound(platformId, null, msg);
return;
}
// Other sync messages are our outbound — skip
return;
}
const dataMessage = envelope.dataMessage;
if (!dataMessage) return;
const rawText = (dataMessage.message ?? '').trim();
const text = rawText ? resolveMentions(rawText, dataMessage.mentions) : '';
const audioAttachment = dataMessage.attachments?.find((a) => a.contentType?.startsWith('audio/') && a.id);
const imageAttachments = dataMessage.attachments?.filter((a) => a.contentType?.startsWith('image/') && a.id) ?? [];
const hasVoice = !text && !!audioAttachment;
if (!text && !hasVoice && imageAttachments.length === 0) return;
const sender = (envelope.sourceNumber ?? envelope.sourceUuid ?? envelope.source ?? '').trim();
if (!sender) return;
const senderName = (envelope.sourceName?.trim() || sender).trim();
// Modern Signal groups use groupV2; legacy groupInfo.groupId is the
// pre-V2 fallback. Without the V2 read, V2-only groups appear as DMs
// because `groupInfo` is undefined.
const groupInfo = dataMessage.groupInfo;
const groupId = dataMessage.groupV2?.id ?? groupInfo?.groupId;
const isGroup = Boolean(groupId);
const platformId = isGroup ? `group:${groupId}` : sender;
if (text && echoCache.isEcho(platformId, text)) {
log.debug('Signal: skipping echo', { platformId });
return;
}
const timestamp = dataMessage.timestamp ? new Date(dataMessage.timestamp).toISOString() : new Date().toISOString();
const chatName = groupInfo?.groupName ?? (isGroup ? `Group ${groupId?.slice(0, 8)}` : senderName);
setup.onMetadata(platformId, chatName, isGroup);
let content = text;
// Voice attachment — try transcription if WHISPER_BIN or OPENAI_API_KEY
// is configured; otherwise fall back to the original placeholder so
// operators who don't want transcription get the same UX as before.
if (hasVoice && audioAttachment?.id) {
const attachmentPath = join(config.signalDataDir, 'attachments', audioAttachment.id);
if (existsSync(attachmentPath)) {
log.info('Signal: voice attachment received', {
platformId,
attachmentId: audioAttachment.id,
path: attachmentPath,
});
const transcript = await transcribeAudioOptional(attachmentPath);
if (transcript) {
content = `[Voice: ${transcript}]`;
log.info('Signal: voice transcribed', { platformId, length: transcript.length });
} else {
content = '[Voice Message]';
}
} else {
log.warn('Signal: voice attachment file not found', {
id: audioAttachment.id,
path: attachmentPath,
});
content = '[Voice Message - file not found]';
}
}
// Image attachments — emit `[Image: <path>]` lines so the agent's Read
// tool can pick them up, and surface the structured `attachments` array
// for consumers that prefer that shape. Without this, vision-capable
// models never see images sent over Signal.
const attachmentRefs: Array<{ path: string; contentType: string }> = [];
for (const img of imageAttachments) {
const imagePath = join(config.signalDataDir, 'attachments', img.id!);
const imageLine = `[Image: ${imagePath}]`;
content = content ? `${content}\n${imageLine}` : imageLine;
attachmentRefs.push({ path: imagePath, contentType: img.contentType || 'image/jpeg' });
}
const msg: InboundMessage = {
id: String(dataMessage.timestamp ?? Date.now()),
kind: 'chat',
content: {
text: content,
sender,
senderId: `signal:${sender}`,
senderName,
...(attachmentRefs.length > 0 ? { attachments: attachmentRefs } : {}),
...(dataMessage.quote ? quoteToContent(dataMessage.quote) : {}),
},
timestamp,
};
await setup.onInbound(platformId, null, msg);
log.info('Signal message received', { platformId, sender: senderName });
}
/**
* Build the `replyTo` object the agent-runner formatter expects (see
* `container/agent-runner/src/formatter.ts:formatReplyContext`). The
* formatter requires both `sender` and `text` to render the
* `<quoted_message>` block; absent either, it omits the block entirely.
*
* The previous shape (`replyToSenderName` / `replyToMessageContent` /
* `replyToMessageId` flat keys) did not match the formatter contract, so
* quote-reply context was silently dropped end-to-end.
*/
function quoteToContent(quote: SignalQuote): Record<string, unknown> {
const sender = quote.authorName || quote.authorNumber || quote.author || quote.authorUuid || 'someone';
const text = quote.text || '';
return {
replyTo: {
id: quote.id ? String(quote.id) : undefined,
sender,
text,
},
};
}
// -- send helpers --
async function sendText(platformId: string, text: string): Promise<void> {
if (!connected || !tcp) return;
echoCache.remember(platformId, text);
const MAX_CHUNK = 4000;
const chunks = text.length <= MAX_CHUNK ? [text] : chunkText(text, MAX_CHUNK);
for (const chunk of chunks) {
try {
const { text: plainText, textStyles } = parseSignalStyles(chunk);
const params: Record<string, unknown> = { message: plainText };
if (config.account) params.account = config.account;
if (textStyles.length > 0) {
params.textStyle = textStyles.map((s) => `${s.start}:${s.length}:${s.style}`);
}
if (platformId.startsWith('group:')) {
params.groupId = platformId.slice('group:'.length);
} else {
params.recipient = [platformId];
}
try {
await tcp.rpc('send', params);
} catch (styledErr) {
if (textStyles.length > 0) {
log.debug('Signal: textStyle rejected, retrying with markup');
delete params.textStyle;
params.message = chunk;
await tcp.rpc('send', params);
} else {
throw styledErr;
}
}
} catch (err) {
log.error('Signal: send failed', { platformId, err });
}
}
log.info('Signal message sent', { platformId, length: text.length });
}
async function waitForDaemon(): Promise<boolean> {
const maxWait = 30_000;
const pollInterval = 1000;
const start = Date.now();
while (Date.now() - start < maxWait) {
if (daemon?.isExited()) return false;
const ok = await signalTcpCheck(config.tcpHost, config.tcpPort);
if (ok) return true;
await sleep(pollInterval);
}
return false;
}
// -- adapter --
const adapter: ChannelAdapter = {
name: 'signal',
channelType: 'signal',
supportsThreads: false,
async setup(cfg: ChannelSetup): Promise<void> {
setup = cfg;
if (config.manageDaemon) {
daemon = spawnSignalDaemon(config.cliPath, config.account, config.tcpHost, config.tcpPort);
const ready = await waitForDaemon();
if (!ready) {
daemon.stop();
throw new Error('Signal daemon failed to start. Is signal-cli installed and your account linked?');
}
} else {
const ok = await signalTcpCheck(config.tcpHost, config.tcpPort);
if (!ok) {
const err = new Error(
`Signal daemon not reachable at ${config.tcpHost}:${config.tcpPort}. Start it manually or set SIGNAL_MANAGE_DAEMON=true`,
);
(err as any).name = 'NetworkError';
throw err;
}
}
tcp = new SignalTcpClient(config.tcpHost, config.tcpPort);
await tcp.connect({
onNotification: handleNotification,
// Signal the adapter that the daemon dropped us. No auto-reconnect yet
// — subsequent deliver/setTyping calls short-circuit on `connected`
// and log rather than throw into the retry loop. Operators see this in
// logs/nanoclaw.log and can restart the service.
onClose: () => {
if (!connected) return;
connected = false;
log.warn('Signal channel lost TCP connection to signal-cli daemon', {
account: config.account,
host: config.tcpHost,
port: config.tcpPort,
});
},
});
try {
await tcp.rpc('updateProfile', {
name: 'NanoClaw',
account: config.account,
});
} catch {
log.debug('Signal: could not set profile name');
}
try {
await tcp.rpc('updateConfiguration', {
typingIndicators: true,
account: config.account,
});
} catch {
log.debug('Signal: could not enable typing indicators');
}
connected = true;
log.info('Signal channel connected', {
account: config.account,
host: config.tcpHost,
port: config.tcpPort,
});
},
async teardown(): Promise<void> {
connected = false;
tcp?.close();
tcp = null;
if (daemon && config.manageDaemon) {
daemon.stop();
await daemon.exited;
}
daemon = null;
log.info('Signal channel disconnected');
},
isConnected(): boolean {
return connected;
},
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') {
text = content;
} else if (content && typeof content === 'object' && typeof content.text === 'string') {
text = content.text;
}
if (!text) return undefined;
await sendText(platformId, text);
return undefined;
},
async setTyping(platformId: string, _threadId: string | null): Promise<void> {
if (!connected || !tcp) return;
if (platformId.startsWith('group:')) return;
try {
const params: Record<string, unknown> = { recipient: [platformId] };
if (config.account) params.account = config.account;
await tcp.rpc('sendTyping', params);
} catch (err) {
log.debug('Signal: typing indicator failed', { platformId, err });
}
},
};
return adapter;
}
// ---------------------------------------------------------------------------
// Self-registration
// ---------------------------------------------------------------------------
const DEFAULT_TCP_HOST = '127.0.0.1';
const DEFAULT_TCP_PORT = 7583;
registerChannelAdapter('signal', {
factory: () => {
const envVars = readEnvFile([
'SIGNAL_ACCOUNT',
'SIGNAL_TCP_HOST',
'SIGNAL_TCP_PORT',
'SIGNAL_CLI_PATH',
'SIGNAL_MANAGE_DAEMON',
'SIGNAL_DATA_DIR',
]);
const account = process.env.SIGNAL_ACCOUNT || envVars.SIGNAL_ACCOUNT || '';
if (!account) {
log.debug('Signal: SIGNAL_ACCOUNT not set, skipping channel');
return null;
}
const cliPath = process.env.SIGNAL_CLI_PATH || envVars.SIGNAL_CLI_PATH || 'signal-cli';
const tcpHost = process.env.SIGNAL_TCP_HOST || envVars.SIGNAL_TCP_HOST || DEFAULT_TCP_HOST;
const tcpPort = parseInt(process.env.SIGNAL_TCP_PORT || envVars.SIGNAL_TCP_PORT || String(DEFAULT_TCP_PORT), 10);
const manageDaemon = (process.env.SIGNAL_MANAGE_DAEMON || envVars.SIGNAL_MANAGE_DAEMON || 'true') === 'true';
const signalDataDir =
process.env.SIGNAL_DATA_DIR || envVars.SIGNAL_DATA_DIR || join(homedir(), '.local', 'share', 'signal-cli');
// Only check for `signal-cli` on PATH when the operator left cliPath at
// the default AND asked us to manage the daemon. A custom absolute path
// is treated as an explicit promise and spawn will surface its own ENOENT.
if (manageDaemon && cliPath === 'signal-cli') {
try {
execFileSync('which', ['signal-cli'], { stdio: 'ignore' });
} catch {
log.debug('Signal: signal-cli binary not found, skipping channel');
return null;
}
}
return createSignalAdapter({
cliPath,
account,
tcpHost,
tcpPort,
manageDaemon,
signalDataDir,
});
},
});
+30
View File
@@ -0,0 +1,30 @@
/**
* Slack channel adapter (v2) — uses Chat SDK bridge.
* Self-registers on import.
*/
import { createSlackAdapter } from '@chat-adapter/slack';
import { readEnvFile } from '../env.js';
import { createChatSdkBridge } from './chat-sdk-bridge.js';
import { registerChannelAdapter } from './channel-registry.js';
registerChannelAdapter('slack', {
factory: () => {
const env = readEnvFile(['SLACK_BOT_TOKEN', 'SLACK_SIGNING_SECRET']);
if (!env.SLACK_BOT_TOKEN) return null;
const slackAdapter = createSlackAdapter({
botToken: env.SLACK_BOT_TOKEN,
signingSecret: env.SLACK_SIGNING_SECRET,
});
const bridge = createChatSdkBridge({ adapter: slackAdapter, concurrency: 'concurrent', supportsThreads: true });
bridge.resolveChannelName = async (platformId: string) => {
try {
const info = await slackAdapter.fetchThread(platformId);
return (info as { channelName?: string }).channelName ?? null;
} catch {
return null;
}
};
return bridge;
},
});
+23
View File
@@ -0,0 +1,23 @@
/**
* Microsoft Teams channel adapter (v2) — uses Chat SDK bridge.
* Self-registers on import.
*/
import { createTeamsAdapter } from '@chat-adapter/teams';
import { readEnvFile } from '../env.js';
import { createChatSdkBridge } from './chat-sdk-bridge.js';
import { registerChannelAdapter } from './channel-registry.js';
registerChannelAdapter('teams', {
factory: () => {
const env = readEnvFile(['TEAMS_APP_ID', 'TEAMS_APP_PASSWORD', 'TEAMS_APP_TENANT_ID', 'TEAMS_APP_TYPE']);
if (!env.TEAMS_APP_ID) return null;
const teamsAdapter = createTeamsAdapter({
appId: env.TEAMS_APP_ID,
appPassword: env.TEAMS_APP_PASSWORD,
appType: (env.TEAMS_APP_TYPE as 'SingleTenant' | 'MultiTenant') || undefined,
appTenantId: env.TEAMS_APP_TENANT_ID || undefined,
});
return createChatSdkBridge({ adapter: teamsAdapter, concurrency: 'concurrent', supportsThreads: true });
},
});
@@ -0,0 +1,78 @@
import { describe, it, expect } from 'vitest';
import { sanitizeTelegramLegacyMarkdown } from './telegram-markdown-sanitize.js';
describe('sanitizeTelegramLegacyMarkdown', () => {
it('downgrades CommonMark **bold** to legacy *bold*', () => {
expect(sanitizeTelegramLegacyMarkdown('**Host path**')).toBe('*Host path*');
});
it('downgrades CommonMark __bold__ to legacy _italic_', () => {
expect(sanitizeTelegramLegacyMarkdown('__label__')).toBe('_label_');
});
it('leaves balanced legacy *bold* and _italic_ alone', () => {
expect(sanitizeTelegramLegacyMarkdown('a *b* c _d_ e')).toBe('a *b* c _d_ e');
});
it('preserves inline code spans untouched', () => {
const input = 'see `file_name.py` and `**not bold**` here';
expect(sanitizeTelegramLegacyMarkdown(input)).toBe(input);
});
it('preserves fenced code blocks untouched', () => {
const input = '```\nfoo_bar **baz**\n```';
expect(sanitizeTelegramLegacyMarkdown(input)).toBe(input);
});
it('strips formatting chars on odd delimiter count (unbalanced *)', () => {
expect(sanitizeTelegramLegacyMarkdown('a * b *c*')).toBe('a b c');
});
it('strips formatting chars on odd delimiter count (unbalanced _)', () => {
expect(sanitizeTelegramLegacyMarkdown('file_name has _one italic_')).toBe('filename has one italic');
});
it('strips brackets when unbalanced', () => {
expect(sanitizeTelegramLegacyMarkdown('see [docs here')).toBe('see docs here');
});
it('leaves matched brackets (e.g. links) alone when counts balance', () => {
const input = 'see [docs](https://example.com) for more';
expect(sanitizeTelegramLegacyMarkdown(input)).toBe(input);
});
it('fixes the real failing message', () => {
const input =
'Sure! What do you want to mount, and where should it appear inside the container?\n\n' +
'- **Host path** (on your machine): e.g. `~/projects/webapp`\n' +
'- **Container path**: e.g. `workspace/webapp`\n' +
'- **Read-only or read-write?**';
const out = sanitizeTelegramLegacyMarkdown(input);
expect(out).not.toContain('**');
expect(out).toContain('*Host path*');
expect(out).toContain('`~/projects/webapp`');
expect((out.match(/\*/g) ?? []).length % 2).toBe(0);
});
it('is a no-op on empty string', () => {
expect(sanitizeTelegramLegacyMarkdown('')).toBe('');
});
it('replaces dash list bullets with • so the adapter does not re-emit `*` markers', () => {
expect(sanitizeTelegramLegacyMarkdown('- one\n- two')).toBe('• one\n• two');
});
it('preserves indented list structure', () => {
expect(sanitizeTelegramLegacyMarkdown(' - nested')).toBe(' • nested');
});
it('flattens Markdown horizontal rules (---, ***, ___)', () => {
const input = 'before\n---\n***\n___\nafter';
expect(sanitizeTelegramLegacyMarkdown(input)).toBe('before\n⎯⎯⎯\n⎯⎯⎯\n⎯⎯⎯\nafter');
});
it('leaves horizontal rules inside code blocks alone', () => {
const input = '```\n---\n```';
expect(sanitizeTelegramLegacyMarkdown(input)).toBe(input);
});
});
@@ -0,0 +1,55 @@
/**
* Sanitize outbound text for Telegram's legacy `Markdown` parse mode.
*
* WORKAROUND: The @chat-adapter/telegram adapter hardcodes parse_mode=Markdown
* (legacy) but its converter emits CommonMark. Messages with `**bold**`, odd
* delimiter counts, or malformed links are rejected by Telegram and dropped
* after retries. Remove this once upstream ships real mode-aware conversion
* (vercel/chat PR #367 adds the knob; a follow-up is needed for the converter).
*/
const CODE_PATTERN = /```[\s\S]*?```|`[^`\n]*`/g;
const PLACEHOLDER_PREFIX = '\x00CODE';
const PLACEHOLDER_SUFFIX = '\x00';
export function sanitizeTelegramLegacyMarkdown(input: string): string {
if (!input) return input;
const codeSegments: string[] = [];
let text = input.replace(CODE_PATTERN, (m) => {
codeSegments.push(m);
return `${PLACEHOLDER_PREFIX}${codeSegments.length - 1}${PLACEHOLDER_SUFFIX}`;
});
// The adapter re-parses and re-stringifies markdown before sending, which
// rewrites `- item` list bullets into `* item` — injecting unbalanced
// asterisks that Telegram's legacy Markdown parser then rejects. Replace
// list bullets with a plain Unicode bullet so the adapter treats the line
// as prose.
text = text.replace(/^(\s*)[-+]\s+/gm, '$1• ');
// Flatten Markdown horizontal rules (bare --- / *** / ___ lines) to a
// plain Unicode divider. The parser doesn't understand HR syntax and the
// `*` / `_` characters would otherwise unbalance the delimiter counts below.
text = text.replace(/^[ \t]*[-_*]{3,}[ \t]*$/gm, '⎯⎯⎯');
text = text.replace(/\*\*([^*\n]+?)\*\*/g, '*$1*');
text = text.replace(/__([^_\n]+?)__/g, '_$1_');
const starCount = (text.match(/\*/g) ?? []).length;
const underCount = (text.match(/_/g) ?? []).length;
if (starCount % 2 !== 0 || underCount % 2 !== 0) {
text = text.replace(/[*_]/g, '');
}
const openBrackets = (text.match(/\[/g) ?? []).length;
const closeBrackets = (text.match(/\]/g) ?? []).length;
if (openBrackets !== closeBrackets) {
text = text.replace(/[[\]]/g, '');
}
return text.replace(
new RegExp(`${PLACEHOLDER_PREFIX}(\\d+)${PLACEHOLDER_SUFFIX}`, 'g'),
(_, i) => codeSegments[Number(i)],
);
}
+248
View File
@@ -0,0 +1,248 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'fs';
import path from 'path';
import os from 'os';
vi.mock('../log.js', () => ({ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } }));
import {
createPairing,
tryConsume,
getStatus,
getPairing,
waitForPairing,
extractCode,
extractAddressedText,
_setStorePathForTest,
_resetForTest,
} from './telegram-pairing.js';
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tg-pair-'));
_setStorePathForTest(path.join(tmpDir, 'pairings.json'));
});
afterEach(() => {
_resetForTest();
_setStorePathForTest(null);
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe('extractAddressedText', () => {
it('strips @botname prefix', () => {
expect(extractAddressedText('@nanobot 1234', 'nanobot')).toBe('1234');
});
it('is case-insensitive', () => {
expect(extractAddressedText('@NanoBot hello', 'nanobot')).toBe('hello');
});
it('returns null when not addressed', () => {
expect(extractAddressedText('hello 1234', 'nanobot')).toBeNull();
});
it('returns null when address is mid-text', () => {
expect(extractAddressedText('hi @nanobot 1234', 'nanobot')).toBeNull();
});
});
describe('extractCode', () => {
it('accepts a bare 4-digit code', () => {
expect(extractCode('0349', 'nanobot')).toBe('0349');
});
it('accepts 4-digit code after @botname', () => {
expect(extractCode('@nanobot 0042', 'nanobot')).toBe('0042');
});
it('rejects non-4-digit numbers', () => {
expect(extractCode('@nanobot 12345', 'nanobot')).toBeNull();
expect(extractCode('@nanobot 12', 'nanobot')).toBeNull();
expect(extractCode('12345', 'nanobot')).toBeNull();
});
it('rejects loose matches with surrounding text', () => {
expect(extractCode('my pin is 0349', 'nanobot')).toBeNull();
expect(extractCode('0349 thanks', 'nanobot')).toBeNull();
});
});
describe('createPairing', () => {
it('generates a 4-digit code', async () => {
const r = await createPairing('main');
expect(r.code).toMatch(/^\d{4}$/);
expect(r.status).toBe('pending');
});
it('does not collide with active codes', async () => {
const codes = new Set<string>();
for (let i = 0; i < 20; i++) {
const r = await createPairing('main');
expect(codes.has(r.code)).toBe(false);
codes.add(r.code);
}
});
});
describe('tryConsume', () => {
it('matches and marks consumed', async () => {
const r = await createPairing('main');
const consumed = await tryConsume({
text: `@nanobot ${r.code}`,
botUsername: 'nanobot',
platformId: 'telegram:123',
isGroup: false,
adminUserId: 'u1',
});
expect(consumed).not.toBeNull();
expect(consumed!.status).toBe('consumed');
expect(consumed!.consumed?.platformId).toBe('telegram:123');
expect(consumed!.consumed?.adminUserId).toBe('u1');
expect(getStatus(r.code)).toBe('consumed');
});
it('returns null on no match (silent drop)', async () => {
await createPairing('main');
const out = await tryConsume({
text: '@nanobot 9999',
botUsername: 'nanobot',
platformId: 'x',
isGroup: false,
});
expect(out).toBeNull();
});
it('matches a bare code without @botname addressing', async () => {
const r = await createPairing('main');
const out = await tryConsume({
text: r.code,
botUsername: 'nanobot',
platformId: 'x',
isGroup: false,
});
expect(out).not.toBeNull();
expect(out!.status).toBe('consumed');
});
it('cannot be consumed twice', async () => {
const r = await createPairing('main');
await tryConsume({ text: `@b ${r.code}`, botUsername: 'b', platformId: 'p', isGroup: false });
const second = await tryConsume({ text: `@b ${r.code}`, botUsername: 'b', platformId: 'p', isGroup: false });
expect(second).toBeNull();
});
it('cannot consume an invalidated pairing', async () => {
const r = await createPairing('main');
// Invalidate by sending a wrong code
await tryConsume({ text: '9999', botUsername: 'b', platformId: 'p', isGroup: false });
const out = await tryConsume({ text: `@b ${r.code}`, botUsername: 'b', platformId: 'p', isGroup: false });
expect(out).toBeNull();
expect(getStatus(r.code)).toBe('invalidated');
});
});
describe('getStatus', () => {
it('returns unknown for missing codes', () => {
expect(getStatus('0000')).toBe('unknown');
});
});
describe('waitForPairing', () => {
it('resolves when consumed', async () => {
const r = await createPairing('main');
const p = waitForPairing(r.code, { pollMs: 50 });
setTimeout(() => {
tryConsume({ text: `@b ${r.code}`, botUsername: 'b', platformId: 'tg:1', isGroup: true, name: 'Group' });
}, 100);
const consumed = await p;
expect(consumed.status).toBe('consumed');
expect(consumed.consumed?.name).toBe('Group');
});
it('rejects on invalidation', async () => {
const r = await createPairing('main');
const waiter = waitForPairing(r.code, { pollMs: 30 });
setTimeout(() => {
tryConsume({ text: '0000', botUsername: 'b', platformId: 'tg:1', isGroup: false });
}, 60);
await expect(waiter).rejects.toThrow(/invalidated/);
});
});
describe('replace-by-default', () => {
it('supersedes an existing pending pairing with the same intent', async () => {
const first = await createPairing('main');
const second = await createPairing('main');
expect(getStatus(first.code)).toBe('invalidated');
expect(getStatus(second.code)).toBe('pending');
});
it('does not supersede pairings with a different intent', async () => {
const a = await createPairing({ kind: 'wire-to', folder: 'work' });
const b = await createPairing({ kind: 'wire-to', folder: 'side' });
expect(getStatus(a.code)).toBe('pending');
expect(getStatus(b.code)).toBe('pending');
});
it('causes waitForPairing on the old code to reject as invalidated', async () => {
const first = await createPairing('main');
const waiter = waitForPairing(first.code, { pollMs: 30 });
await new Promise((r) => setTimeout(r, 50));
await createPairing('main');
await expect(waiter).rejects.toThrow(/invalidated/);
});
});
describe('attempt tracking', () => {
it('fires onAttempt for a wrong code, invalidates the pairing, and rejects the waiter', async () => {
const r = await createPairing('main');
const attempts: string[] = [];
const waiter = waitForPairing(r.code, {
pollMs: 30,
onAttempt: (a) => attempts.push(a.candidate),
});
setTimeout(() => {
tryConsume({ text: '9999', botUsername: 'b', platformId: 'tg:1', isGroup: false });
}, 60);
await expect(waiter).rejects.toThrow(/invalidated by wrong code \(9999\)/);
expect(attempts).toEqual(['9999']);
expect(getStatus(r.code)).toBe('invalidated');
});
it('a correct code consumes without firing onAttempt', async () => {
const r = await createPairing('main');
const attempts: string[] = [];
const waiter = waitForPairing(r.code, {
pollMs: 30,
onAttempt: (a) => attempts.push(a.candidate),
});
setTimeout(() => {
tryConsume({ text: r.code, botUsername: 'b', platformId: 'tg:1', isGroup: false });
}, 60);
const consumed = await waiter;
expect(consumed.status).toBe('consumed');
expect(attempts).toEqual([]);
});
it('ignores non-code messages and keeps the pairing pending', async () => {
const r = await createPairing('main');
await tryConsume({ text: 'hello there', botUsername: 'b', platformId: 'p', isGroup: false });
const after = getPairing(r.code);
expect(after?.status).toBe('pending');
expect(after?.attempts ?? []).toHaveLength(0);
});
it('a second code attempt after invalidation does not match', async () => {
const r = await createPairing('main');
await tryConsume({ text: '9999', botUsername: 'b', platformId: 'p', isGroup: false });
const retry = await tryConsume({ text: r.code, botUsername: 'b', platformId: 'p', isGroup: false });
expect(retry).toBeNull();
});
});
describe('intent passthrough', () => {
it('preserves wire-to and new-agent intents', async () => {
const a = await createPairing({ kind: 'wire-to', folder: 'work' });
const b = await createPairing({ kind: 'new-agent', folder: 'side' });
const ca = await tryConsume({ text: `@b ${a.code}`, botUsername: 'b', platformId: 'p1', isGroup: true });
const cb = await tryConsume({ text: `@b ${b.code}`, botUsername: 'b', platformId: 'p2', isGroup: true });
expect(ca!.intent).toEqual({ kind: 'wire-to', folder: 'work' });
expect(cb!.intent).toEqual({ kind: 'new-agent', folder: 'side' });
});
});
+339
View File
@@ -0,0 +1,339 @@
/**
* Telegram pairing — proves the operator owns the chat they're registering.
*
* BotFather hands out tokens with no user binding, so anyone who guesses the
* bot's username can DM it. Pairing closes that gap: setup creates a one-time
* 4-digit code and the operator echoes it back from the chat they want to
* register. The message must be exactly the 4 digits (optionally prefixed by
* `@botname ` for groups with privacy ON) — arbitrary messages that happen to
* contain a 4-digit number do NOT match. The inbound interceptor in
* telegram.ts matches the code, records the chat, upserts the paired user,
* and (if no owner exists yet) promotes them to owner — all before the
* message ever reaches the router.
*
* Storage is a JSON file at data/telegram-pairings.json — single-process,
* read-modify-write under an in-process mutex.
*/
import fs from 'fs';
import path from 'path';
import { DATA_DIR } from '../config.js';
import { log } from '../log.js';
export type PairingIntent = 'main' | { kind: 'wire-to'; folder: string } | { kind: 'new-agent'; folder: string };
export type PairingStatus = 'pending' | 'consumed' | 'invalidated' | 'unknown';
export interface ConsumedDetails {
platformId: string;
isGroup: boolean;
name: string | null;
adminUserId: string | null;
consumedAt: string;
}
export interface PairingAttempt {
candidate: string;
platformId: string;
at: string;
matched: boolean;
}
export interface PairingRecord {
code: string;
intent: PairingIntent;
createdAt: string;
status: Exclude<PairingStatus, 'unknown'>;
consumed?: ConsumedDetails;
/** Recent pairing attempts observed while this record was pending. Capped. */
attempts?: PairingAttempt[];
}
const MAX_ATTEMPTS_PER_RECORD = 10;
function intentEquals(a: PairingIntent, b: PairingIntent): boolean {
if (a === 'main' || b === 'main') return a === b;
return a.kind === b.kind && a.folder === b.folder;
}
interface Store {
pairings: PairingRecord[];
}
/** Pairing codes do not expire — they are consumed on match or invalidated by wrong guesses. */
const FILE_NAME = 'telegram-pairings.json';
let storePathOverride: string | null = null;
export function _setStorePathForTest(p: string | null): void {
storePathOverride = p;
}
function storePath(): string {
return storePathOverride ?? path.join(DATA_DIR, FILE_NAME);
}
let mutex: Promise<unknown> = Promise.resolve();
function withLock<T>(fn: () => Promise<T> | T): Promise<T> {
const next = mutex.then(() => fn());
mutex = next.catch(() => {});
return next;
}
function readStore(): Store {
try {
const raw = fs.readFileSync(storePath(), 'utf8');
const parsed = JSON.parse(raw) as Store;
if (!Array.isArray(parsed.pairings)) return { pairings: [] };
return parsed;
} catch {
return { pairings: [] };
}
}
function writeStore(store: Store): void {
const p = storePath();
fs.mkdirSync(path.dirname(p), { recursive: true });
const tmp = `${p}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(store, null, 2));
fs.renameSync(tmp, p);
}
/** Clean up old consumed/invalidated records (keep last 50). */
function sweep(store: Store): boolean {
if (store.pairings.length <= 50) return false;
store.pairings = store.pairings.slice(-50);
return true;
}
function generateCode(active: Set<string>): string {
// 4-digit numeric, zero-padded. 10k space, fine for one-at-a-time intents.
for (let i = 0; i < 50; i++) {
const code = Math.floor(Math.random() * 10000)
.toString()
.padStart(4, '0');
if (!active.has(code)) return code;
}
throw new Error('Could not allocate a free pairing code (too many active).');
}
export async function createPairing(intent: PairingIntent): Promise<PairingRecord> {
return withLock(() => {
const store = readStore();
sweep(store);
// Replace-by-default: a new pairing for an intent supersedes any existing
// pending pairing for the same intent. Old waitForPairing calls observe
// `invalidated` and exit on their own.
for (const r of store.pairings) {
if (r.status === 'pending' && intentEquals(r.intent, intent)) {
r.status = 'invalidated';
log.info('Pairing superseded by new request', { code: r.code, intent });
}
}
const active = new Set(store.pairings.filter((r) => r.status === 'pending').map((r) => r.code));
const record: PairingRecord = {
code: generateCode(active),
intent,
createdAt: new Date().toISOString(),
status: 'pending',
};
store.pairings.push(record);
writeStore(store);
log.info('Pairing created', { code: record.code, intent });
return record;
});
}
export interface ConsumeInput {
text: string;
botUsername: string;
platformId: string;
isGroup: boolean;
name?: string | null;
adminUserId?: string | null;
}
/** Strip leading @botname and return the trimmed remainder, or null if not addressed. */
export function extractAddressedText(text: string, botUsername: string): string | null {
const trimmed = text.trim();
const re = new RegExp(`^@${botUsername.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\b`, 'i');
const m = trimmed.match(re);
if (!m) return null;
return trimmed.slice(m[0].length).trim();
}
/**
* Extract a pairing code from an inbound message. The message must be exactly
* 4 digits (optionally prefixed by `@botname `) — loose matches like
* "my pin is 1234" are rejected to avoid false positives from chatter.
*/
export function extractCode(text: string, botUsername: string): string | null {
const addressed = extractAddressedText(text, botUsername);
const candidate = (addressed !== null ? addressed : text).trim();
const m = candidate.match(/^(\d{4})$/);
return m ? m[1] : null;
}
/**
* Try to match an inbound message against a pending pairing. On match,
* marks the pairing consumed atomically and returns the record. Returns
* null on no match or expiry (silent drop).
*/
export async function tryConsume(input: ConsumeInput): Promise<PairingRecord | null> {
const code = extractCode(input.text, input.botUsername);
if (!code) return null;
return withLock(() => {
const store = readStore();
const now = Date.now();
sweep(store);
const record = store.pairings.find((r) => r.code === code && r.status === 'pending');
if (!record) {
// Miss: record the attempt on every currently-pending record so each
// waitForPairing caller can surface it as user feedback.
const attempt: PairingAttempt = {
candidate: code,
platformId: input.platformId,
at: new Date(now).toISOString(),
matched: false,
};
let recorded = false;
for (const r of store.pairings) {
if (r.status !== 'pending') continue;
r.attempts = [...(r.attempts ?? []), attempt].slice(-MAX_ATTEMPTS_PER_RECORD);
// One attempt per code. A wrong guess invalidates the pairing
// immediately — pair-telegram observes the `invalidated` signal and
// auto-issues a fresh code (up to a retry cap).
r.status = 'invalidated';
recorded = true;
}
writeStore(store);
if (recorded) {
log.info('Pairing invalidated by wrong attempt', { candidate: code, platformId: input.platformId });
}
return null;
}
record.status = 'consumed';
record.consumed = {
platformId: input.platformId,
isGroup: input.isGroup,
name: input.name ?? null,
adminUserId: input.adminUserId ?? null,
consumedAt: new Date(now).toISOString(),
};
record.attempts = [
...(record.attempts ?? []),
{ candidate: code, platformId: input.platformId, at: new Date(now).toISOString(), matched: true },
].slice(-MAX_ATTEMPTS_PER_RECORD);
writeStore(store);
log.info('Pairing consumed', { code, platformId: input.platformId, intent: record.intent });
return record;
});
}
export function getStatus(code: string): PairingStatus {
const store = readStore();
sweep(store);
const r = store.pairings.find((p) => p.code === code);
if (!r) return 'unknown';
return r.status;
}
export function getPairing(code: string): PairingRecord | null {
const store = readStore();
sweep(store);
return store.pairings.find((p) => p.code === code) ?? null;
}
export interface WaitForPairingOptions {
/** Polling interval as a fallback when fs.watch misses an event. */
pollMs?: number;
/** Fires once per new attempt recorded against this pairing (misses only). */
onAttempt?: (attempt: PairingAttempt) => void;
}
/**
* Resolve when the pairing is consumed; reject when it is invalidated
* (wrong code guess). Waits indefinitely — codes do not expire.
* Uses fs.watch as the primary signal with a slow poll fallback.
*/
export async function waitForPairing(code: string, opts: WaitForPairingOptions = {}): Promise<PairingRecord> {
const pollMs = opts.pollMs ?? 1000;
const initial = getPairing(code);
if (!initial) throw new Error(`Unknown pairing code: ${code}`);
return new Promise<PairingRecord>((resolve, reject) => {
let watcher: fs.FSWatcher | null = null;
let interval: NodeJS.Timeout | null = null;
let settled = false;
const cleanup = () => {
settled = true;
if (watcher)
try {
watcher.close();
} catch {
/* ignore */
}
if (interval) clearInterval(interval);
};
let seenAttempts = 0;
const check = () => {
if (settled) return;
const r = getPairing(code);
if (!r) {
cleanup();
reject(new Error(`Pairing ${code} disappeared`));
return;
}
// Surface any new miss attempts since the last tick. Only fire for
// misses — matches are signaled by `status === 'consumed'` below.
if (opts.onAttempt && r.attempts) {
for (let i = seenAttempts; i < r.attempts.length; i++) {
const a = r.attempts[i];
if (!a.matched) {
try {
opts.onAttempt(a);
} catch {
/* ignore */
}
}
}
seenAttempts = r.attempts.length;
}
if (r.status === 'consumed') {
cleanup();
resolve(r);
return;
}
if (r.status === 'invalidated') {
cleanup();
const lastMiss = r.attempts
?.slice()
.reverse()
.find((a) => !a.matched);
reject(new Error(`Pairing ${code} invalidated by wrong code${lastMiss ? ` (${lastMiss.candidate})` : ''}`));
return;
}
};
try {
const dir = path.dirname(storePath());
fs.mkdirSync(dir, { recursive: true });
watcher = fs.watch(dir, (_event, fname) => {
if (!fname || fname.toString().startsWith(path.basename(storePath()))) check();
});
} catch {
// fs.watch unsupported — poll-only is fine
}
interval = setInterval(check, pollMs);
check();
});
}
/** Test helper — wipe the store. */
export function _resetForTest(): void {
try {
fs.unlinkSync(storePath());
} catch {
// ignore
}
}
+244
View File
@@ -0,0 +1,244 @@
/**
* Telegram channel adapter (v2) — uses Chat SDK bridge, with a pairing
* interceptor wrapped around onInbound to verify chat ownership before
* registration. See telegram-pairing.ts for the why.
*/
import { createTelegramAdapter } from '@chat-adapter/telegram';
import { readEnvFile } from '../env.js';
import { log } from '../log.js';
import { createMessagingGroup, getMessagingGroupByPlatform, updateMessagingGroup } from '../db/messaging-groups.js';
import { grantRole, hasAnyOwner } from '../modules/permissions/db/user-roles.js';
import { upsertUser } from '../modules/permissions/db/users.js';
import { createChatSdkBridge, type ReplyContext } from './chat-sdk-bridge.js';
import { sanitizeTelegramLegacyMarkdown } from './telegram-markdown-sanitize.js';
import { registerChannelAdapter } from './channel-registry.js';
import type { ChannelAdapter, ChannelSetup, InboundMessage } from './adapter.js';
import { tryConsume } from './telegram-pairing.js';
/**
* Retry a one-shot operation that can fail on transient network errors at
* cold-start (DNS hiccups, brief upstream outages). Exponential backoff capped
* at 5 attempts — if the network is truly down we surface it instead of
* hanging the service indefinitely.
*/
async function withRetry<T>(fn: () => Promise<T>, label: string, maxAttempts = 5): Promise<T> {
let lastErr: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
lastErr = err;
if (attempt === maxAttempts) break;
const delay = Math.min(16000, 1000 * 2 ** (attempt - 1));
log.warn('Telegram setup failed, retrying', { label, attempt, delayMs: delay, err });
await new Promise((r) => setTimeout(r, delay));
}
}
throw lastErr;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function extractReplyContext(raw: Record<string, any>): ReplyContext | null {
if (!raw.reply_to_message) return null;
const reply = raw.reply_to_message;
return {
text: reply.text || reply.caption || '',
sender: reply.from?.first_name || reply.from?.username || 'Unknown',
};
}
/** Look up the bot username via Telegram getMe. Cached after first call. */
async function fetchBotUsername(token: string): Promise<string | null> {
try {
const res = await fetch(`https://api.telegram.org/bot${token}/getMe`);
const json = (await res.json()) as { ok: boolean; result?: { username?: string } };
return json.ok ? (json.result?.username ?? null) : null;
} catch (err) {
log.warn('Telegram getMe failed', { err });
return null;
}
}
function isGroupPlatformId(platformId: string): boolean {
// platformId is "telegram:<chatId>". Negative chat IDs are groups/channels.
const id = platformId.split(':').pop() ?? '';
return id.startsWith('-');
}
interface InboundFields {
text: string;
authorUserId: string | null;
}
function readInboundFields(message: InboundMessage): InboundFields {
if (message.kind !== 'chat-sdk' || !message.content || typeof message.content !== 'object') {
return { text: '', authorUserId: null };
}
const c = message.content as { text?: string; author?: { userId?: string } };
return { text: c.text ?? '', authorUserId: c.author?.userId ?? null };
}
/**
* Build an onInbound interceptor that consumes pairing codes before they
* reach the router. On match: records the chat + its paired user, promotes
* the user to owner if the instance has no owner yet, and short-circuits.
* On miss: forwards to the host.
*/
/**
* Send a one-shot confirmation back to the paired chat. Best-effort — failures
* are logged but never propagated, so a Telegram outage can't undo a successful
* pairing or trigger the interceptor's fail-open path.
*/
async function sendPairingConfirmation(token: string, platformId: string): Promise<void> {
const chatId = platformId.split(':').slice(1).join(':');
if (!chatId) return;
try {
const res = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
method: 'POST',
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.",
}),
});
if (!res.ok) {
log.warn('Telegram pairing confirmation non-OK', { status: res.status });
}
} catch (err) {
log.warn('Telegram pairing confirmation failed', { err });
}
}
function createPairingInterceptor(
botUsernamePromise: Promise<string | null>,
hostOnInbound: ChannelSetup['onInbound'],
token: string,
): ChannelSetup['onInbound'] {
return async (platformId, threadId, message) => {
try {
const botUsername = await botUsernamePromise;
if (!botUsername) {
hostOnInbound(platformId, threadId, message);
return;
}
const { text, authorUserId } = readInboundFields(message);
if (!text) {
hostOnInbound(platformId, threadId, message);
return;
}
const consumed = await tryConsume({
text,
botUsername,
platformId,
isGroup: isGroupPlatformId(platformId),
adminUserId: authorUserId,
});
if (!consumed) {
hostOnInbound(platformId, threadId, message);
return;
}
// Pairing matched — record the chat and short-circuit so the
// code-bearing message never reaches an agent. Privilege is now a
// property of the paired user, not the chat: upsert the user, and if
// this instance has no owner yet, promote them to owner.
const existing = getMessagingGroupByPlatform('telegram', platformId);
if (existing) {
updateMessagingGroup(existing.id, {
is_group: consumed.consumed!.isGroup ? 1 : 0,
});
} else {
createMessagingGroup({
id: `mg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
channel_type: 'telegram',
platform_id: platformId,
name: consumed.consumed!.name,
is_group: consumed.consumed!.isGroup ? 1 : 0,
unknown_sender_policy: 'strict',
created_at: new Date().toISOString(),
});
}
const pairedUserId = `telegram:${consumed.consumed!.adminUserId}`;
upsertUser({
id: pairedUserId,
kind: 'telegram',
display_name: null,
created_at: new Date().toISOString(),
});
let promotedToOwner = false;
if (!hasAnyOwner()) {
grantRole({
user_id: pairedUserId,
role: 'owner',
agent_group_id: null,
granted_by: null,
granted_at: new Date().toISOString(),
});
promotedToOwner = true;
}
log.info('Telegram pairing accepted — chat registered', {
platformId,
pairedUser: pairedUserId,
promotedToOwner,
intent: consumed.intent,
});
await sendPairingConfirmation(token, platformId);
} catch (err) {
log.error('Telegram pairing interceptor error', { err });
// Fail open: pass through so a pairing bug doesn't break normal traffic.
hostOnInbound(platformId, threadId, message);
}
};
}
registerChannelAdapter('telegram', {
factory: () => {
const env = readEnvFile(['TELEGRAM_BOT_TOKEN']);
if (!env.TELEGRAM_BOT_TOKEN) return null;
const token = env.TELEGRAM_BOT_TOKEN;
const telegramAdapter = createTelegramAdapter({
botToken: token,
mode: 'polling',
});
const bridge = createChatSdkBridge({
adapter: telegramAdapter,
concurrency: 'concurrent',
extractReplyContext,
supportsThreads: false,
transformOutboundText: sanitizeTelegramLegacyMarkdown,
});
const botUsernamePromise = fetchBotUsername(token);
const wrapped: ChannelAdapter = {
...bridge,
resolveChannelName: async (platformId: string) => {
const chatId = platformId.split(':').slice(1).join(':');
if (!chatId) return null;
try {
const res = await fetch(`https://api.telegram.org/bot${token}/getChat`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ chat_id: chatId }),
});
const data = (await res.json()) as { ok?: boolean; result?: { title?: string } };
return data.ok ? (data.result?.title ?? null) : null;
} catch {
return null;
}
},
async setup(hostConfig: ChannelSetup) {
const intercepted: ChannelSetup = {
...hostConfig,
onInbound: createPairingInterceptor(botUsernamePromise, hostConfig.onInbound, token),
};
return withRetry(() => bridge.setup(intercepted), 'bridge.setup');
},
};
return wrapped;
},
});
+21
View File
@@ -0,0 +1,21 @@
/**
* Webex channel adapter (v2) — uses Chat SDK bridge.
* Self-registers on import.
*/
import { createWebexAdapter } from '@bitbasti/chat-adapter-webex';
import { readEnvFile } from '../env.js';
import { createChatSdkBridge } from './chat-sdk-bridge.js';
import { registerChannelAdapter } from './channel-registry.js';
registerChannelAdapter('webex', {
factory: () => {
const env = readEnvFile(['WEBEX_BOT_TOKEN', 'WEBEX_WEBHOOK_SECRET']);
if (!env.WEBEX_BOT_TOKEN) return null;
const webexAdapter = createWebexAdapter({
botToken: env.WEBEX_BOT_TOKEN,
webhookSecret: env.WEBEX_WEBHOOK_SECRET,
});
return createChatSdkBridge({ adapter: webexAdapter, concurrency: 'concurrent', supportsThreads: true });
},
});
+221
View File
@@ -0,0 +1,221 @@
/**
* WeChat channel adapter — uses Tencent's official iLink Bot API.
*
* Unlike puppet-based libraries (wechaty/PadLocal) this uses the first-party
* Tencent API. No ban risk. Free. Works with any personal WeChat account.
*
* Flow:
* 1. Factory gated on WECHAT_ENABLED=true in .env.
* 2. On setup, load saved auth if present; otherwise run QR login.
* The QR URL is written to data/wechat/qr.txt and logged.
* 3. Long-poll for messages via WeChatClient, cursor persisted between
* restarts so no messages are dropped.
* 4. Outbound via sendText — context_token auto-cached by the client.
*
* Self-registers on import.
*/
import fs from 'fs';
import path from 'path';
import { WeChatClient, MessageType, type WeixinMessage } from 'wechat-ilink-client';
import { readEnvFile } from '../env.js';
import { DATA_DIR } from '../config.js';
import { log } from '../log.js';
import { registerChannelAdapter } from './channel-registry.js';
import type { ChannelAdapter, ChannelSetup, InboundMessage, OutboundMessage } from './adapter.js';
const DATA_SUBDIR = path.join(DATA_DIR, 'wechat');
const AUTH_FILE = path.join(DATA_SUBDIR, 'auth.json');
const SYNC_BUF_FILE = path.join(DATA_SUBDIR, 'sync-buf.txt');
const QR_FILE = path.join(DATA_SUBDIR, 'qr.txt');
interface SavedAuth {
botToken: string;
accountId: string;
baseUrl?: string;
/** The WeChat user_id of whoever scanned the QR — i.e. the operator. */
operatorUserId?: string;
}
function loadAuth(): SavedAuth | null {
try {
return JSON.parse(fs.readFileSync(AUTH_FILE, 'utf8')) as SavedAuth;
} catch {
return null;
}
}
function saveAuth(auth: SavedAuth): void {
fs.mkdirSync(DATA_SUBDIR, { recursive: true });
fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2));
}
function loadSyncBuf(): string | undefined {
try {
return fs.readFileSync(SYNC_BUF_FILE, 'utf8');
} catch {
return undefined;
}
}
function saveSyncBuf(buf: string): void {
fs.mkdirSync(DATA_SUBDIR, { recursive: true });
fs.writeFileSync(SYNC_BUF_FILE, buf);
}
function writeQr(url: string): void {
fs.mkdirSync(DATA_SUBDIR, { recursive: true });
fs.writeFileSync(QR_FILE, url);
}
function messageText(msg: OutboundMessage): string {
if (typeof msg.content === 'string') return msg.content;
const c = msg.content as Record<string, unknown>;
return (c.text as string) || (c.markdown as string) || JSON.stringify(msg.content);
}
registerChannelAdapter('wechat', {
factory: () => {
const env = readEnvFile(['WECHAT_ENABLED']);
if (env.WECHAT_ENABLED !== 'true') return null;
let client: WeChatClient | null = null;
let setupConfig: ChannelSetup;
let connected = false;
let accountId: string | undefined;
async function ensureLoggedIn(): Promise<WeChatClient> {
const saved = loadAuth();
if (saved) {
const c = new WeChatClient({
token: saved.botToken,
baseUrl: saved.baseUrl,
accountId: saved.accountId,
});
accountId = saved.accountId;
log.info('WeChat: resumed from saved auth', { accountId });
return c;
}
const c = new WeChatClient();
const result = await c.login({
onQRCode: (url) => {
writeQr(url);
log.info('WeChat QR ready — open this URL in a browser and scan with the WeChat app', { url });
},
});
if (!result.connected || !result.botToken || !result.accountId) {
throw new Error(`WeChat login failed: ${result.message}`);
}
saveAuth({
botToken: result.botToken,
accountId: result.accountId,
baseUrl: result.baseUrl,
operatorUserId: result.userId,
});
accountId = result.accountId;
log.info('WeChat: login complete', { accountId, operatorUserId: result.userId });
return c;
}
function onMessage(msg: WeixinMessage): void {
if (msg.message_type !== MessageType.USER) return;
const isGroup = !!msg.group_id;
const platformIdRaw = isGroup ? msg.group_id! : msg.from_user_id!;
const platformId = `wechat:${platformIdRaw}`;
const senderId = `wechat:${msg.from_user_id ?? 'unknown'}`;
const text = WeChatClient.extractText(msg);
log.info('WeChat inbound', {
platformId,
senderId,
isGroup,
hint: 'if not wired yet, run: pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts',
});
setupConfig.onMetadata(platformId, undefined, isGroup);
const inbound: InboundMessage = {
id: String(msg.message_id ?? msg.seq ?? Date.now()),
kind: 'chat',
content: {
text,
senderId,
sender: msg.from_user_id,
senderName: msg.from_user_id,
isGroup,
},
timestamp: new Date(msg.create_time_ms ?? Date.now()).toISOString(),
};
setupConfig.onInbound(platformId, null, inbound);
}
const adapter: ChannelAdapter = {
name: 'wechat',
channelType: 'wechat',
supportsThreads: false,
async setup(config: ChannelSetup) {
setupConfig = config;
client = await ensureLoggedIn();
client.on('message', (msg) => {
try {
onMessage(msg);
} catch (err) {
log.warn('WeChat: onMessage error', { err });
}
});
client.on('error', (err) => log.warn('WeChat: poll error', { err }));
client.on('sessionExpired', () => {
log.error('WeChat: session expired — delete data/wechat/auth.json and restart to re-scan');
connected = false;
});
client
.start({
loadSyncBuf,
saveSyncBuf,
})
.catch((err) => log.error('WeChat: monitor loop crashed', { err }));
connected = true;
log.info('WeChat adapter ready', { accountId });
},
async teardown() {
connected = false;
client?.stop();
client = null;
},
isConnected() {
return connected;
},
async deliver(
platformId: string,
_threadId: string | null,
message: OutboundMessage,
): Promise<string | undefined> {
if (!client) return undefined;
const to = platformId.replace(/^wechat:/, '');
const text = messageText(message);
if (!text) return undefined;
try {
const msgId = await client.sendText(to, text);
return msgId;
} catch (err) {
log.error('WeChat deliver failed', { platformId, err });
return undefined;
}
},
};
return adapter;
},
});
+29
View File
@@ -0,0 +1,29 @@
/**
* WhatsApp Cloud API channel adapter (v2) — uses Chat SDK bridge.
* Uses the official Meta WhatsApp Business Cloud API (not Baileys).
* Self-registers on import.
*/
import { createWhatsAppAdapter } from '@chat-adapter/whatsapp';
import { readEnvFile } from '../env.js';
import { createChatSdkBridge } from './chat-sdk-bridge.js';
import { registerChannelAdapter } from './channel-registry.js';
registerChannelAdapter('whatsapp-cloud', {
factory: () => {
const env = readEnvFile([
'WHATSAPP_ACCESS_TOKEN',
'WHATSAPP_PHONE_NUMBER_ID',
'WHATSAPP_APP_SECRET',
'WHATSAPP_VERIFY_TOKEN',
]);
if (!env.WHATSAPP_ACCESS_TOKEN) return null;
const whatsappAdapter = createWhatsAppAdapter({
accessToken: env.WHATSAPP_ACCESS_TOKEN,
phoneNumberId: env.WHATSAPP_PHONE_NUMBER_ID,
appSecret: env.WHATSAPP_APP_SECRET,
verifyToken: env.WHATSAPP_VERIFY_TOKEN,
});
return createChatSdkBridge({ adapter: whatsappAdapter, concurrency: 'concurrent', supportsThreads: false });
},
});
+735
View File
@@ -0,0 +1,735 @@
/**
* WhatsApp channel adapter (v2) — native Baileys v6 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.
*
* Auth credentials persist in store/auth/. On first run:
* - If WHATSAPP_PHONE_NUMBER is set → pairing code (printed to log)
* - Otherwise → QR code (printed to log)
* Subsequent restarts reuse the saved session automatically.
*/
import fs from 'fs';
import path from 'path';
// Named import (not default) — pino's .d.ts under NodeNext resolution
// exports `{ pino as default, pino }`, but the namespace/function merge at
// `declare namespace pino` + `declare function pino` makes the default
// resolve to `typeof pino` (the namespace type), which isn't callable.
// The named export resolves to the callable function.
import { pino } from 'pino';
import {
makeWASocket,
Browsers,
DisconnectReason,
fetchLatestWaWebVersion,
downloadMediaMessage,
makeCacheableSignalKeyStore,
normalizeMessageContent,
useMultiFileAuthState,
} from '@whiskeysockets/baileys';
import type { GroupMetadata, WAMessageKey, WAMessage, WASocket } from '@whiskeysockets/baileys';
import { isSafeAttachmentName } from '../attachment-safety.js';
import { ASSISTANT_HAS_OWN_NUMBER, ASSISTANT_NAME, DATA_DIR } from '../config.js';
import { readEnvFile } from '../env.js';
import { log } from '../log.js';
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' });
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
const SENT_MESSAGE_CACHE_MAX = 256;
const RECONNECT_DELAY_MS = 5000;
const PENDING_QUESTIONS_MAX = 64;
/** Normalize an option label to a slash command: "Approve" → "/approve" */
function optionToCommand(option: string): string {
return '/' + option.toLowerCase().replace(/\s+/g, '-');
}
// --- Markdown → WhatsApp formatting ---
interface TextSegment {
content: string;
isProtected: boolean;
}
/** Split text into code-block-protected and unprotected regions. */
function splitProtectedRegions(text: string): TextSegment[] {
const segments: TextSegment[] = [];
const codeBlockRegex = /```[\s\S]*?```|`[^`\n]+`/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = codeBlockRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
segments.push({ content: text.slice(lastIndex, match.index), isProtected: false });
}
segments.push({ content: match[0], isProtected: true });
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
segments.push({ content: text.slice(lastIndex), isProtected: false });
}
return segments;
}
/** Apply WhatsApp-native formatting to an unprotected text segment. */
function transformForWhatsApp(text: string): string {
// Order matters: italic before bold to avoid **bold** → *bold* → _bold_
// 1. Italic: *text* (not **) → _text_
text = text.replace(/(?<!\*)\*(?=[^\s*])([^*\n]+?)(?<=[^\s*])\*(?!\*)/g, '_$1_');
// 2. Bold: **text** → *text*
text = text.replace(/\*\*(?=[^\s*])([^*]+?)(?<=[^\s*])\*\*/g, '*$1*');
// 3. Headings: ## Title → *Title*
text = text.replace(/^#{1,6}\s+(.+)$/gm, '*$1*');
// 4. Links: [text](url) → text (url)
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)');
// 5. Horizontal rules: --- / *** / ___ → stripped
text = text.replace(/^(-{3,}|\*{3,}|_{3,})$/gm, '');
return text;
}
/** Convert Claude's markdown to WhatsApp-native formatting. */
function formatWhatsApp(text: string): string {
const segments = splitProtectedRegions(text);
return segments.map(({ content, isProtected }) => (isProtected ? content : transformForWhatsApp(content))).join('');
}
/** Map file extension to Baileys media message type. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function buildMediaMessage(data: Buffer, filename: string, ext: string, caption?: string): any {
const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
const videoExts = ['.mp4', '.mov', '.avi', '.mkv'];
const audioExts = ['.mp3', '.ogg', '.m4a', '.wav', '.aac', '.opus'];
if (imageExts.includes(ext)) {
return { image: data, caption, mimetype: `image/${ext.slice(1) === 'jpg' ? 'jpeg' : ext.slice(1)}` };
}
if (videoExts.includes(ext)) {
return { video: data, caption, mimetype: `video/${ext.slice(1)}` };
}
if (audioExts.includes(ext)) {
return { audio: data, mimetype: `audio/${ext.slice(1) === 'mp3' ? 'mpeg' : ext.slice(1)}` };
}
// Default: send as document
return { document: data, fileName: filename, caption, mimetype: 'application/octet-stream' };
}
registerChannelAdapter('whatsapp', {
factory: () => {
const env = readEnvFile(['WHATSAPP_PHONE_NUMBER', 'WHATSAPP_ENABLED']);
const phoneNumber = env.WHATSAPP_PHONE_NUMBER;
const authDir = AUTH_DIR;
// Skip if no existing auth, no phone number for pairing, and not explicitly enabled (QR mode)
const hasAuth = fs.existsSync(path.join(authDir, 'creds.json'));
if (!hasAuth && !phoneNumber && !env.WHATSAPP_ENABLED) return null;
fs.mkdirSync(authDir, { recursive: true });
// State
let sock: WASocket;
let connected = false;
let setupConfig: ChannelSetup;
// LID → phone JID mapping (WhatsApp's new ID system)
const lidToPhoneMap: Record<string, string> = {};
let botLidUser: string | undefined;
// Outgoing queue for messages sent while disconnected
const outgoingQueue: Array<{ jid: string; text: string }> = [];
let flushing = false;
// Sent message cache for retry/re-encrypt requests
const sentMessageCache = new Map<string, any>();
// Group metadata cache with TTL
const groupMetadataCache = new Map<string, { metadata: GroupMetadata; expiresAt: number }>();
// Pending questions: chatJid → { questionId, options }
// User replies with /approve, /reject, etc. to answer
const pendingQuestions = new Map<
string,
{
questionId: string;
options: NormalizedOption[];
}
>();
// Group sync tracking
let lastGroupSync = 0;
let groupSyncTimerStarted = false;
// First-connect promise
let resolveFirstOpen: (() => void) | undefined;
let rejectFirstOpen: ((err: Error) => void) | undefined;
// Pairing code file for the setup skill to poll
const pairingCodeFile = path.join(process.cwd(), 'store', 'pairing-code.txt');
// --- Helpers ---
function setLidPhoneMapping(lidUser: string, phoneJid: string): void {
if (lidToPhoneMap[lidUser] === phoneJid) return;
lidToPhoneMap[lidUser] = phoneJid;
// Cached group metadata depends on participant IDs — invalidate
groupMetadataCache.clear();
}
async function translateJid(jid: string): Promise<string> {
if (!jid.endsWith('@lid')) return jid;
const lidUser = jid.split('@')[0].split(':')[0];
const cached = lidToPhoneMap[lidUser];
if (cached) return cached;
// Query Baileys' signal repository
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pn = await (sock.signalRepository as any)?.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 });
return phoneJid;
}
} catch (err) {
log.debug('Failed to resolve LID via signalRepository', { jid, err });
}
return jid;
}
async function getNormalizedGroupMetadata(jid: string): Promise<GroupMetadata | undefined> {
if (!jid.endsWith('@g.us')) return undefined;
const cached = groupMetadataCache.get(jid);
if (cached && cached.expiresAt > Date.now()) return cached.metadata;
const metadata = await sock.groupMetadata(jid);
const participants = await Promise.all(
metadata.participants.map(async (p) => ({
...p,
id: await translateJid(p.id),
})),
);
const normalized = { ...metadata, participants };
groupMetadataCache.set(jid, {
metadata: normalized,
expiresAt: Date.now() + GROUP_METADATA_CACHE_TTL_MS,
});
return normalized;
}
async function syncGroupMetadata(force = false): Promise<void> {
if (!force && lastGroupSync && Date.now() - lastGroupSync < GROUP_SYNC_INTERVAL_MS) {
return;
}
try {
log.info('Syncing group metadata from WhatsApp...');
const groups = await sock.groupFetchAllParticipating();
let count = 0;
for (const [jid, metadata] of Object.entries(groups)) {
if (metadata.subject) {
setupConfig.onMetadata(jid, metadata.subject, true);
count++;
}
}
lastGroupSync = Date.now();
log.info('Group metadata synced', { count });
} catch (err) {
log.error('Failed to sync group metadata', { err });
}
}
async function flushOutgoingQueue(): Promise<void> {
if (flushing || outgoingQueue.length === 0) return;
flushing = true;
try {
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 });
if (sent?.key?.id && sent.message) {
sentMessageCache.set(sent.key.id, sent.message);
}
}
} finally {
flushing = false;
}
}
/** Download media from an inbound message, save to /workspace/attachments/. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function downloadInboundMedia(
msg: WAMessage,
normalized: any,
): Promise<Array<{ type: string; name: string; localPath: string }>> {
const mediaTypes: Array<{ key: string; type: string; ext: string }> = [
{ key: 'imageMessage', type: 'image', ext: '.jpg' },
{ key: 'videoMessage', type: 'video', ext: '.mp4' },
{ key: 'audioMessage', type: 'audio', ext: '.ogg' },
{ key: 'documentMessage', type: 'document', ext: '' },
];
const results: Array<{ type: string; name: string; localPath: string }> = [];
for (const { key, type, ext } of mediaTypes) {
if (!normalized[key]) continue;
try {
const buffer = await downloadMediaMessage(msg, 'buffer', {});
// documentMessage.fileName is attacker-controlled and rides through
// WhatsApp's E2E channel — Meta can't sanitize it server-side. Without
// this guard, a `..`-laden fileName escapes attachDir on path.join.
const rawFilename = normalized[key].fileName;
const fallback = `${type}-${Date.now()}${ext}`;
const filename = isSafeAttachmentName(rawFilename) ? rawFilename : fallback;
if (rawFilename && filename !== rawFilename) {
log.warn('Refused unsafe attachment filename — would escape attachments dir', {
rawFilename,
replacement: filename,
});
}
const attachDir = path.join(DATA_DIR, 'attachments');
fs.mkdirSync(attachDir, { recursive: true });
const filePath = path.join(attachDir, filename);
fs.writeFileSync(filePath, buffer);
results.push({ type, name: filename, localPath: `attachments/${filename}` });
log.info('Media downloaded', { type, filename });
} catch (err) {
log.warn('Failed to download media', { type, err });
}
}
return results;
}
async function sendRawMessage(jid: string, text: string): Promise<string | undefined> {
if (!connected) {
outgoingQueue.push({ jid, text });
log.info('WA disconnected, message queued', { jid, queueSize: outgoingQueue.length });
return;
}
try {
const sent = await sock.sendMessage(jid, { text });
if (sent?.key?.id && sent.message) {
sentMessageCache.set(sent.key.id, sent.message);
if (sentMessageCache.size > SENT_MESSAGE_CACHE_MAX) {
const oldest = sentMessageCache.keys().next().value!;
sentMessageCache.delete(oldest);
}
}
return sent?.key?.id ?? undefined;
} catch (err) {
outgoingQueue.push({ jid, text });
log.warn('Failed to send, message queued', { jid, err, queueSize: outgoingQueue.length });
return undefined;
}
}
// --- Socket creation ---
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 };
});
sock = makeWASocket({
version,
auth: {
creds: state.creds,
keys: makeCacheableSignalKeyStore(state.keys, baileysLogger),
},
printQRInTerminal: false,
logger: baileysLogger,
browser: Browsers.macOS('Chrome'),
cachedGroupMetadata: async (jid: string) => getNormalizedGroupMetadata(jid),
getMessage: async (key: WAMessageKey) => {
// Check in-memory cache first (recently sent messages)
const cached = sentMessageCache.get(key.id || '');
if (cached) return cached;
// Return empty message to prevent indefinite "waiting for this message"
return proto.Message.fromObject({});
},
});
// Request pairing code if phone number is set and not yet registered
if (phoneNumber && !state.creds.registered) {
setTimeout(async () => {
try {
const code = await sock.requestPairingCode(phoneNumber);
log.info(`WhatsApp pairing code: ${code}`);
log.info('Enter in WhatsApp > Linked Devices > Link with phone number');
fs.writeFileSync(pairingCodeFile, code, 'utf-8');
} catch (err) {
log.error('Failed to request pairing code', { err });
}
}, 3000);
}
sock.ev.on('connection.update', (update) => {
const { connection, lastDisconnect, qr } = update;
if (qr && !phoneNumber) {
// QR code auth — print to terminal
(async () => {
try {
const QRCode = await import('qrcode');
const qrText = await QRCode.toString(qr, { type: 'terminal' });
log.info('WhatsApp QR code — scan with WhatsApp > Linked Devices:\n' + qrText);
} catch {
log.info('WhatsApp QR code (raw)', { qr });
}
})();
}
if (connection === 'close') {
connected = false;
const reason = (lastDisconnect?.error as { output?: { statusCode?: number } })?.output?.statusCode;
const shouldReconnect = reason !== DisconnectReason.loggedOut;
log.info('WhatsApp connection closed', { reason, shouldReconnect });
if (shouldReconnect) {
log.info('Reconnecting...');
connectSocket().catch((err) => {
log.error('Failed to reconnect, retrying in 5s', { err });
setTimeout(() => {
connectSocket().catch((err2) => {
log.error('Reconnection retry failed', { err: err2 });
});
}, RECONNECT_DELAY_MS);
});
} else {
log.info('WhatsApp logged out');
if (rejectFirstOpen) {
rejectFirstOpen(new Error('WhatsApp logged out'));
rejectFirstOpen = undefined;
resolveFirstOpen = undefined;
}
}
} else if (connection === 'open') {
connected = true;
log.info('Connected to WhatsApp');
// Clean up pairing code file after successful connection
try {
if (fs.existsSync(pairingCodeFile)) fs.unlinkSync(pairingCodeFile);
} catch {
/* ignore */
}
// Announce availability for presence updates
sock.sendPresenceUpdate('available').catch((err) => {
log.warn('Failed to send presence update', { err });
});
// Build LID → phone mapping from auth state
if (sock.user) {
const phoneUser = sock.user.id.split(':')[0];
const lidUser = sock.user.lid?.split(':')[0];
if (lidUser && phoneUser) {
setLidPhoneMapping(lidUser, `${phoneUser}@s.whatsapp.net`);
botLidUser = lidUser;
}
}
// Flush queued messages
flushOutgoingQueue().catch((err) => log.error('Failed to flush outgoing queue', { err }));
// Group sync
syncGroupMetadata().catch((err) => log.error('Initial group sync failed', { err }));
if (!groupSyncTimerStarted) {
groupSyncTimerStarted = true;
setInterval(() => {
syncGroupMetadata().catch((err) => log.error('Periodic group sync failed', { err }));
}, GROUP_SYNC_INTERVAL_MS);
}
// Signal first open
if (resolveFirstOpen) {
resolveFirstOpen();
resolveFirstOpen = undefined;
rejectFirstOpen = undefined;
}
}
});
sock.ev.on('creds.update', saveCreds);
// Phone number sharing events — update LID mapping
sock.ev.on('chats.phoneNumberShare', ({ lid, jid }) => {
const lidUser = lid?.split('@')[0].split(':')[0];
if (lidUser && jid) setLidPhoneMapping(lidUser, jid);
});
// Inbound messages
sock.ev.on('messages.upsert', async ({ messages }) => {
for (const msg of messages) {
try {
if (!msg.message) continue;
const normalized = normalizeMessageContent(msg.message);
if (!normalized) continue;
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;
}
const timestamp = new Date(Number(msg.messageTimestamp) * 1000).toISOString();
const isGroup = chatJid.endsWith('@g.us');
// Notify metadata for group discovery
setupConfig.onMetadata(chatJid, undefined, isGroup);
let content =
normalized.conversation ||
normalized.extendedTextMessage?.text ||
normalized.imageMessage?.caption ||
normalized.videoMessage?.caption ||
'';
// Normalize bot LID mention → assistant name for trigger matching
if (botLidUser && content.includes(`@${botLidUser}`)) {
content = content.replace(`@${botLidUser}`, `@${ASSISTANT_NAME}`);
}
// Download media attachments (images, video, audio, documents)
const attachments = await downloadInboundMedia(msg, normalized);
// Skip empty protocol messages (no text and no attachments)
if (!content && attachments.length === 0) continue;
const sender = msg.key.participant || msg.key.remoteJid || '';
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;
const isBotMessage = ASSISTANT_HAS_OWN_NUMBER ? false : content.startsWith(`${ASSISTANT_NAME}:`);
// Check if this reply answers a pending question via slash command
const pending = pendingQuestions.get(chatJid);
if (pending && content.startsWith('/')) {
const cmd = content.trim().toLowerCase();
const matched = pending.options.find((o) => optionToCommand(o.label) === cmd);
if (matched) {
const voterName = msg.pushName || sender.split('@')[0];
setupConfig.onAction(pending.questionId, matched.value, sender);
pendingQuestions.delete(chatJid);
await sendRawMessage(chatJid, `${matched.selectedLabel} by ${voterName}`);
log.info('Question answered', {
questionId: pending.questionId,
value: matched.value,
voterName,
});
continue; // Don't forward this reply to the agent
}
}
const inbound: InboundMessage = {
id: msg.key.id || `wa-${Date.now()}`,
kind: 'chat',
content: {
text: content,
sender,
senderName,
...(attachments.length > 0 && { attachments }),
fromMe,
isBotMessage,
isGroup,
chatJid,
},
timestamp,
};
// WhatsApp doesn't use threads — threadId is null
setupConfig.onInbound(chatJid, null, inbound);
} catch (err) {
log.error('Error processing incoming WhatsApp message', {
err,
remoteJid: msg.key?.remoteJid,
});
}
}
});
}
// --- ChannelAdapter implementation ---
const adapter: ChannelAdapter = {
name: 'whatsapp',
channelType: 'whatsapp',
supportsThreads: false,
async setup(hostConfig: ChannelSetup) {
setupConfig = hostConfig;
// Connect and wait for first open
await new Promise<void>((resolve, reject) => {
resolveFirstOpen = resolve;
rejectFirstOpen = reject;
connectSocket().catch(reject);
});
log.info('WhatsApp adapter initialized');
},
async deliver(
platformId: string,
_threadId: string | null,
message: OutboundMessage,
): Promise<string | undefined> {
const content = message.content as Record<string, unknown>;
// Ask question → text with slash command replies
if (content.type === 'ask_question' && content.questionId && content.options) {
const questionId = content.questionId as string;
const title = content.title as string;
const question = content.question as string;
if (!title) {
log.error('ask_question missing required title — skipping delivery', { questionId });
return;
}
const options: NormalizedOption[] = normalizeOptions(content.options as never);
const optionLines = options.map((o) => ` ${optionToCommand(o.label)}`).join('\n');
const text = `*${title}*\n\n${question}\n\nReply with:\n${optionLines}`;
const msgId = await sendRawMessage(platformId, text);
if (msgId) {
pendingQuestions.set(platformId, { questionId, options });
if (pendingQuestions.size > PENDING_QUESTIONS_MAX) {
const oldest = pendingQuestions.keys().next().value!;
pendingQuestions.delete(oldest);
}
}
return msgId;
}
// Reaction → emoji on a message
if (content.operation === 'reaction' && content.messageId && content.emoji) {
try {
await sock.sendMessage(platformId, {
react: {
text: content.emoji as string,
key: { remoteJid: platformId, id: content.messageId as string, fromMe: false },
},
});
} catch (err) {
log.debug('Failed to send reaction', { platformId, err });
}
return;
}
// Normal message (with optional file attachments)
const text = (content.markdown as string) || (content.text as string);
const hasFiles = message.files && message.files.length > 0;
if (!text && !hasFiles) return;
// Send file attachments (first file gets the caption, rest are captionless)
if (hasFiles) {
let captionUsed = false;
for (const file of message.files!) {
try {
const ext = path.extname(file.filename).toLowerCase();
const caption = !captionUsed ? text : undefined;
const mediaMsg = buildMediaMessage(file.data, file.filename, ext, caption);
const sent = await sock.sendMessage(platformId, mediaMsg);
if (sent?.key?.id && sent.message) {
sentMessageCache.set(sent.key.id, sent.message);
}
if (caption) captionUsed = true;
} catch (err) {
log.error('Failed to send file', { platformId, filename: file.filename, err });
}
}
if (captionUsed) return; // Text was sent as caption
}
if (text) {
const formatted = formatWhatsApp(text);
const prefixed = ASSISTANT_HAS_OWN_NUMBER ? formatted : `${ASSISTANT_NAME}: ${formatted}`;
return sendRawMessage(platformId, prefixed);
}
},
async setTyping(platformId: string) {
try {
await sock.sendPresenceUpdate('composing', platformId);
} catch (err) {
log.debug('Failed to update typing status', { jid: platformId, err });
}
},
async teardown() {
connected = false;
sock?.end(undefined);
log.info('WhatsApp adapter shut down');
},
isConnected() {
return connected;
},
async syncConversations(): Promise<ConversationInfo[]> {
try {
const groups = await sock.groupFetchAllParticipating();
return Object.entries(groups)
.filter(([, m]) => m.subject)
.map(([jid, m]) => ({
platformId: jid,
name: m.subject,
isGroup: true,
}));
} catch (err) {
log.error('Failed to sync WhatsApp conversations', { err });
return [];
}
},
};
return adapter;
},
});