Compare commits

...

35 Commits

Author SHA1 Message Date
Gavriel Cohen 3f1319273b fix(whatsapp): mark DMs as isMention so router auto-engages
DMs are addressed to the bot by definition. Mirror the chat-sdk-bridge
semantics (onDirectMessage forwards isMention=true) so the host router
can short-circuit DM auto-create + approval flow at router.ts:184
instead of silently dropping inbound from chats it doesn't recognize.

Without this, a v1 → v2 migration where Baileys' LID→phone translation
fails on the first message (no senderPn, signal repository cold) leaves
v2 unable to route DMs at all — the chatJid arrives as `<lid>@lid`,
the migrated messaging_group is keyed by `<phone>@s.whatsapp.net`, and
the router drops because !isMention.

Group messages keep isMention undefined so the router falls through to
agent-name regex matching, unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 15:24:26 +03:00
exe.dev user 878d3706b4 telegram: redirect post-pairing chat message back to the installer
The previous wording promised a welcome DM "shortly", but in practice the
welcome can be delayed (cold container start, OneCLI selective-secret
mode, etc.) until well after the user has more terminal interactions to
complete. Telling them to wait in Telegram pulls attention away from the
installer at the worst moment.

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 22:19:28 -04:00
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 9550 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 });
},
});
+961
View File
@@ -0,0 +1,961 @@
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();
});
});
// --- Outbound attachments ---
describe('deliver — attachments', () => {
// Real fs writes happen in tmpdir(); confirm the bytes round-trip and
// are cleaned up after deliver returns.
it('sends a single attachment via attachments[] param', async () => {
const fs = await import('node:fs');
const adapter = createAdapter();
await adapter.setup(createMockSetup());
tcpRef.fakeSocket.write.mockClear();
await adapter.deliver('+15555550123', null, {
kind: 'file',
content: {},
files: [{ filename: 'report.md', data: Buffer.from('# Report\n\nbody') }],
});
const sendCalls = getRpcCallsForMethod('send');
expect(sendCalls.length).toBe(1);
const params = sendCalls[0].params as Record<string, unknown>;
expect(params.recipient).toEqual(['+15555550123']);
expect(params.account).toBe('+15551234567');
expect(params.message).toBeUndefined();
const paths = params.attachments as string[];
expect(paths).toHaveLength(1);
expect(paths[0]).toMatch(/signal-out-\d+-[a-z0-9]+-report\.md$/);
// Temp file should no longer exist — finally{} cleanup ran
expect(fs.existsSync(paths[0])).toBe(false);
await adapter.teardown();
});
it('sends text first, then attachment, when both are present', async () => {
const adapter = createAdapter();
await adapter.setup(createMockSetup());
tcpRef.fakeSocket.write.mockClear();
await adapter.deliver('+15555550123', null, {
kind: 'file',
content: { text: 'Here is the digest' },
files: [{ filename: 'digest.md', data: Buffer.from('content') }],
});
const sendCalls = getRpcCallsForMethod('send');
expect(sendCalls).toHaveLength(2);
// First call: text message
expect(sendCalls[0].params).toEqual(
expect.objectContaining({ message: 'Here is the digest', recipient: ['+15555550123'] }),
);
expect((sendCalls[0].params as Record<string, unknown>).attachments).toBeUndefined();
// Second call: attachment, no message
expect(sendCalls[1].params).toEqual(
expect.objectContaining({ recipient: ['+15555550123'] }),
);
const attachments = (sendCalls[1].params as Record<string, unknown>).attachments as string[];
expect(attachments).toHaveLength(1);
await adapter.teardown();
});
it('sends multiple attachments in a single send call', async () => {
const adapter = createAdapter();
await adapter.setup(createMockSetup());
tcpRef.fakeSocket.write.mockClear();
await adapter.deliver('+15555550123', null, {
kind: 'file',
content: {},
files: [
{ filename: 'a.txt', data: Buffer.from('a') },
{ filename: 'b.png', data: Buffer.from([0x89, 0x50, 0x4e, 0x47]) },
],
});
const sendCalls = getRpcCallsForMethod('send');
expect(sendCalls).toHaveLength(1);
const attachments = (sendCalls[0].params as Record<string, unknown>).attachments as string[];
expect(attachments).toHaveLength(2);
expect(attachments[0]).toMatch(/-a\.txt$/);
expect(attachments[1]).toMatch(/-b\.png$/);
await adapter.teardown();
});
it('uses groupId for group destinations', async () => {
const adapter = createAdapter();
await adapter.setup(createMockSetup());
tcpRef.fakeSocket.write.mockClear();
await adapter.deliver('group:abc123', null, {
kind: 'file',
content: {},
files: [{ filename: 'pic.jpg', data: Buffer.from('jpg') }],
});
const sendCalls = getRpcCallsForMethod('send');
expect(sendCalls).toHaveLength(1);
const params = sendCalls[0].params as Record<string, unknown>;
expect(params.groupId).toBe('abc123');
expect(params.recipient).toBeUndefined();
await adapter.teardown();
});
/**
* Defensive test: `OutboundFile.filename` is operator-supplied data, so
* the implementation must not let a filename containing path separators
* escape the temp directory. We feed an attempt-to-traverse filename and
* assert the resolved path stays strictly inside `tmpdir()`.
*/
it('keeps temp paths inside tmpdir even when filename contains path separators', async () => {
const path = await import('node:path');
const os = await import('node:os');
const adapter = createAdapter();
await adapter.setup(createMockSetup());
tcpRef.fakeSocket.write.mockClear();
await adapter.deliver('+15555550123', null, {
kind: 'file',
content: {},
files: [{ filename: '../sneaky.txt', data: Buffer.from('x') }],
});
const sendCalls = getRpcCallsForMethod('send');
const paths = (sendCalls[0].params as Record<string, unknown>).attachments as string[];
const resolvedTmp = path.resolve(os.tmpdir());
const resolvedResult = path.resolve(paths[0]);
// path.resolve normalizes away any "../"; if sanitization failed, the
// result would resolve to tmpdir's parent.
expect(resolvedResult.startsWith(resolvedTmp + path.sep)).toBe(true);
await adapter.teardown();
});
});
// --- Text styles ---
describe('text styles', () => {
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();
});
});
// --- 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);
});
});
});
+983
View File
@@ -0,0 +1,983 @@
/**
* 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, writeFileSync } from 'node:fs';
import { createConnection, type Socket } from 'node:net';
import { homedir, tmpdir } 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 });
}
/**
* Send one or more file attachments via signal-cli's `send` JSON-RPC, which
* accepts an `attachments` array of host filesystem paths. The OutboundFile
* Buffer is materialized to an OS temp file so signal-cli can read it, then
* removed in the finally block.
*
* Caption text, if any, is sent first via `sendText` (which handles chunking
* + textStyles) keeps this function single-purpose and avoids a long
* caption colliding with signal-cli's per-message size limits.
*/
async function sendAttachments(platformId: string, files: { filename: string; data: Buffer }[]): Promise<void> {
if (!connected || !tcp) return;
if (files.length === 0) return;
const tempPaths: string[] = [];
for (const file of files) {
const safeName = file.filename.replace(/[/\\\0]/g, '_');
const tempPath = join(tmpdir(), `signal-out-${Date.now()}-${Math.random().toString(36).slice(2, 8)}-${safeName}`);
writeFileSync(tempPath, file.data);
tempPaths.push(tempPath);
}
try {
const params: Record<string, unknown> = { attachments: tempPaths };
if (config.account) params.account = config.account;
if (platformId.startsWith('group:')) {
params.groupId = platformId.slice('group:'.length);
} else {
params.recipient = [platformId];
}
await tcp.rpc('send', params);
log.info('Signal attachments sent', { platformId, count: files.length, filenames: files.map((f) => f.filename) });
} catch (err) {
log.error('Signal: attachment send failed', { platformId, count: files.length, err });
} finally {
for (const p of tempPaths) {
try {
unlinkSync(p);
} catch {
/* best-effort cleanup */
}
}
}
}
async function waitForDaemon(): Promise<boolean> {
const maxWait = 30_000;
const pollInterval = 1000;
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> {
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;
}
const files = message.files ?? [];
// Send accompanying text first so it lands above the attachment(s) in
// the recipient's chat. Both branches no-op cleanly if their input is
// empty, so any combination of (text, files) works.
if (text) await sendText(platformId, text);
if (files.length > 0) await sendAttachments(platformId, files);
return undefined;
},
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
}
}
+245
View File
@@ -0,0 +1,245 @@
/**
* 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! Head back to the NanoClaw installer to finish setup.',
}),
});
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,
maxTextLength: 4000,
});
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 });
},
});
+742
View File
@@ -0,0 +1,742 @@
/**
* 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',
// DMs are addressed to the bot by definition. Mark them as
// platform-confirmed mentions so the router treats them like
// chat-sdk-bridge's onDirectMessage path — auto-creating an
// approval-required messaging_group when the chat is unknown,
// instead of silently dropping at router.ts:184.
isMention: !isGroup ? true : undefined,
isGroup,
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;
},
});