Compare commits

..

346 Commits

Author SHA1 Message Date
gavrielc db42032ca4 merge: catch up with upstream main
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:28:05 +03:00
gavrielc 3ab833b4eb docs: note that workflow removal recurs on every forward merge
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:14:07 +03:00
gavrielc 8c4ab36ef2 docs: update fork maintenance guide with merge learnings
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:05:57 +03:00
gavrielc 8bb8e036e4 docs: add branch and fork maintenance guidelines
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 14:48:34 +03:00
github-actions[bot] 37aee02b46 chore: bump version to 1.2.42 2026-03-28 11:23:18 +00:00
gavrielc 90af26a6b1 chore: remove claw skill test
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 14:23:01 +03:00
gavrielc fff37d590c fix: setup skill routes credential system by container runtime
OneCLI is incompatible with Apple Container. Setup now picks the
credential system after the container runtime: Docker → OneCLI,
Apple Container → native credential proxy. Also marks Apple Container
as experimental, pauses after claude setup-token, limits AskUserQuestion
to multiple-choice, and removes telegram swarm upsell.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 14:05:58 +03:00
github-actions[bot] c3e9a892c2 chore: bump version to 1.2.41 2026-03-27 20:19:23 +00:00
gavrielc acb0abaf8b fix: broken tests and stale .env.example
- Fix container-runner bug: stopContainer() returns void but was
  passed to exec() as a command string. Replace with direct call
  and try/catch.
- Mock container-runtime in tests so they don't need Docker running.
- Increase claw-skill test timeout to handle slower python startup.
- Clear .env.example (telegram token was added by mistake).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 23:19:07 +03:00
gavrielc f773b73ee0 chore: remove direct pino/pino-pretty dependency
Pino was replaced with a built-in logger on main. For branches
with baileys (WhatsApp), pino resolves as a transitive dependency
of @whiskeysockets/baileys.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 22:39:12 +03:00
gavrielc 4f1b09fcb6 fix: migrate x-integration host.ts from pino to built-in logger
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 22:36:54 +03:00
github-actions[bot] fa4ace423c docs: update token count to 42.4k tokens · 21% of context window 2026-03-27 18:42:42 +00:00
github-actions[bot] e6e0c6fa9e chore: bump version to 1.2.40 2026-03-27 18:42:36 +00:00
gavrielc c5e0001637 Merge pull request #1497 from qwibitai/fix/message-history-overflow
fix: prevent full message history from being sent to container agents
2026-03-27 21:42:24 +03:00
gavrielc e73bf2f324 Merge branch 'main' into fix/message-history-overflow 2026-03-27 21:39:41 +03:00
exe.dev user c98205ca0d fix: prevent full message history from being sent to container agents
When lastAgentTimestamp was missing (new group, corrupted state, or
startup recovery), the empty-string fallback caused getMessagesSince to
return up to 200 messages — the entire group history. This sent a
massive prompt to the container agent instead of just recent messages.

Fix: recover the cursor from the last bot reply timestamp in the DB
(proof of what we already processed), and cap all prompt queries to a
configurable MAX_MESSAGES_PER_PROMPT (default 10). Covers all three
call sites: processGroupMessages, the piping path, and
recoverPendingMessages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:38:40 +00:00
github-actions[bot] 2faf1c6e19 docs: update token count to 42.1k tokens · 21% of context window 2026-03-27 14:15:56 +00:00
github-actions[bot] 842ec5fd30 chore: bump version to 1.2.39 2026-03-27 14:15:52 +00:00
gavrielc 017a72d57d Merge pull request #1475 from foxsky/fix/security-stopcontainer-mount
fix(security): prevent command injection in stopContainer and mount path injection
2026-03-27 17:15:38 +03:00
gavrielc bd94c8144a Merge branch 'main' into fix/security-stopcontainer-mount 2026-03-27 17:15:06 +03:00
github-actions[bot] 6e602a1f5b chore: bump version to 1.2.38 2026-03-27 14:10:36 +00:00
gavrielc 415a1cfd44 Merge pull request #1477 from snw35/ipc-fix
fix: Preserve isMain on IPC updates
2026-03-27 17:10:22 +03:00
gavrielc fee05f7ee8 Merge branch 'main' into ipc-fix 2026-03-27 17:10:13 +03:00
github-actions[bot] 877650541a chore: bump version to 1.2.37 2026-03-27 14:10:01 +00:00
gavrielc c923f07829 Merge pull request #1476 from foxsky/fix/env-parser-single-char
fix(env): prevent crash on single-character .env values
2026-03-27 17:09:49 +03:00
gavrielc f138f25c79 Merge branch 'main' into fix/env-parser-single-char 2026-03-27 17:09:38 +03:00
gavrielc e9e9e05290 Merge branch 'main' into ipc-fix 2026-03-27 17:02:42 +03:00
gavrielc 5b7b0867da Merge pull request #1484 from Jimbo1167/docs/k8s-image-gc-known-issue
docs: add k8s image GC known issue to debug checklist
2026-03-27 16:56:34 +03:00
gavrielc e606eac91d Merge branch 'main' into docs/k8s-image-gc-known-issue 2026-03-27 16:56:25 +03:00
James Schindler 8935e4f636 docs: add k8s image GC known issue to debug checklist
Kubernetes image garbage collection silently deletes the nanoclaw-agent
image when disk usage is high because ephemeral containers don't
protect the image from GC. Documents symptoms, cause, fix, and diagnosis.
2026-03-27 08:29:53 -04:00
github-actions[bot] f900670aaf docs: update token count to 42.0k tokens · 21% of context window 2026-03-27 12:13:56 +00:00
github-actions[bot] 62fc8c7708 chore: bump version to 1.2.36 2026-03-27 12:13:53 +00:00
gavrielc 7e7492ebba style: apply prettier formatting to logger
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 15:13:39 +03:00
gavrielc 7b22e23761 chore: replace pino/pino-pretty with built-in logger
Drop 23 transitive dependencies by replacing pino + pino-pretty with a
~70-line logger that matches the same output format and API. All 80+
call sites work unchanged. Production deps now: @onecli-sh/sdk,
better-sqlite3, cron-parser.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 15:13:34 +03:00
gavrielc 2f472a8600 feat: add opt-in model management tools to ollama skill setup
Update SKILL.md to ask users during setup whether they want model
management tools (pull, delete, show, list-running) and set
OLLAMA_ADMIN_TOOLS=true in .env accordingly. Core inference tools
remain always available.

Incorporates #1456 by @bitcryptic-gw. Closes #1331.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:32:58 +03:00
gavrielc 8f01a9a05e chore: remove unused dependencies (yaml, zod, @vitest/coverage-v8)
None of these are imported or referenced by the main codebase.
yaml had zero imports; zod is only used in container/agent-runner
(which has its own package.json); coverage-v8 was never configured.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:24:41 +03:00
gavrielc a4591ab5e0 Merge pull request #1449 from kenbolton/fix/text-styles
fix(skill/channel-formatting): three correctness fixes to text-styles
2026-03-27 13:42:19 +03:00
gavrielc 3332da03af Merge branch 'main' into fix/text-styles 2026-03-27 13:42:05 +03:00
snw35 f5375972c4 Preserve isMain on IPC updates 2026-03-26 23:20:30 +00:00
root 0f01fe2c07 fix(env): prevent crash on single-character .env values
A value like `X=a` would pass the startsWith/endsWith quote check
(both `"` and `'` are single chars), then slice(1, -1) would produce
an empty string, silently dropping the value. Add length >= 2 guard
before checking for surrounding quotes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 19:01:17 -03:00
root a4fd4f2a2f fix(security): prevent command injection in stopContainer and mount path injection
**stopContainer (container-runtime.ts):**
- Validate container name against `^[a-zA-Z0-9][a-zA-Z0-9_.-]*$` before
  passing to shell command. Rejects names with shell metacharacters
  (`;`, `$()`, backticks, etc.) that could execute arbitrary commands.
- Changed return type from string to void — callers no longer build
  shell commands from the return value.

**mount-security.ts:**
- Reject container paths containing `:` to prevent Docker `-v` option
  injection (e.g., `repo:rw` could override readonly flags).
- Don't permanently cache "file not found" for mount allowlist — the
  file may be created later without requiring a service restart. Only
  parse/structural errors are permanently cached.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 19:00:28 -03:00
github-actions[bot] 4383e3e61a chore: bump version to 1.2.35 2026-03-26 15:39:34 +00:00
gavrielc 1f5cc760a7 Merge pull request #1453 from qwibitai/fix/task-scripts-instructions-clean
fix: improve task scripts agent instructions
2026-03-26 17:39:22 +02:00
Daniel M 722c8ee595 Merge branch 'main' into fix/task-scripts-instructions-clean 2026-03-26 17:06:30 +02:00
NanoClaw User 730ea0d713 fix: refine task scripts intro wording
Use third-person voice and clearer terminology for the task scripts
intro paragraph.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:05:53 +00:00
gavrielc 637545dfca Merge pull request #1468 from Koshkoshinsk/docs/auth-credentials-guidance
docs: add auth credentials guidance to main group CLAUDE.md
2026-03-26 16:08:11 +02:00
Daniel M 4588579622 Merge branch 'main' into docs/auth-credentials-guidance 2026-03-26 15:22:41 +02:00
NanoClaw User eda14f472b fix: include script field in task snapshot for current_tasks.json
The task snapshot mappings in index.ts were omitting the script field,
making it appear that scheduled tasks had no script even when one was
stored in the database.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 13:20:24 +00:00
NanoClaw User a29ca0835c fix: rewrite task scripts intro for broader use cases and clarity
Broadens the trigger from "check or monitor" to "any recurring task",
adds context about API credit usage and account risk for frequent tasks,
and prompts the agent to clarify ambiguous requests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 13:20:24 +00:00
NanoClaw User 813e1c6fa4 fix: improve task scripts agent instructions
Reword Task Scripts opening in main template to guide agents toward
schedule_task instead of inline bash loops. Add missing Task Scripts
section to global template — non-main groups have unrestricted access
to schedule_task with script parameter, so omitting instructions just
leads to worse patterns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 13:20:24 +00:00
NanoClaw d25b79a5a9 docs: add auth credentials guidance to main group CLAUDE.md
Clarify that only long-lived OAuth tokens (claude setup-token) or API keys
should be used — short-lived tokens from the keychain expire within hours
and cause recurring 401s. Also update native credential proxy skill to
swap the OneCLI reference when applied.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 13:17:07 +00:00
gavrielc a41746530f fix(init-onecli): only offer to migrate container-facing credentials
Channel tokens (Telegram, Slack, Discord) are used by the host
process, not by containers via the gateway. Only offer to migrate
credentials that containers use for outbound API calls (OpenAI,
Parallel, etc.).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 13:52:25 +02:00
gavrielc d398ba5ac6 feat(init-onecli): offer to migrate non-Anthropic .env credentials to vault
After migrating Anthropic credentials, the skill now scans .env for
other service tokens (Telegram, Slack, Discord, OpenAI, etc.) and
offers to move them into OneCLI Agent Vault as well.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 13:51:24 +02:00
gavrielc 8b53a95a5f feat: add /init-onecli skill for OneCLI Agent Vault setup and credential migration
Operational skill that installs OneCLI, configures the Agent Vault
gateway, and migrates existing .env credentials into the vault.
Designed to run after /update-nanoclaw introduces OneCLI as a
breaking change. Added [BREAKING] changelog entry so update-nanoclaw
automatically offers to run /init-onecli.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 13:31:31 +02:00
gavrielc 4c6d9241d4 docs: update README and security docs to reflect OneCLI Agent Vault adoption
Replace references to the old built-in credential proxy with OneCLI's
Agent Vault across README (feature list, FAQ) and docs/SECURITY.md
(credential isolation section, architecture diagram).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 13:25:18 +02:00
gavrielc 87c3640cfc Merge pull request #1346 from tomermesser/status-bar
feat(skill): add macOS menu bar status indicator
2026-03-25 23:55:47 +02:00
gavrielc e4f15b659e rename skill to add-macos-statusbar
Co-Authored-By: tomermesser <tomeaces@gmail.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 23:55:21 +02:00
gavrielc 349b54ae9e fix(add-statusbar): derive log path from binary location, fix SKILL.md
- statusbar.swift: derive project root from binary location instead of
  hardcoding ~/Documents/Projects/nanoclaw
- SKILL.md: remove references to non-existent apply-skill.ts, compile
  directly from skill directory using ${CLAUDE_SKILL_DIR}
- SKILL.md: add xattr -cr step for Gatekeeper on macOS Sequoia+
- Remove unused manifest.yaml

Co-Authored-By: tomermesser <tomeaces@gmail.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 23:54:05 +02:00
gavrielc 9413ace113 chore: add edwinwzhe and scottgl9 to contributors
Co-Authored-By: Edwin He <edwinwzhe@users.noreply.github.com>
Co-Authored-By: Scott Glover <scottgl9@users.noreply.github.com>
2026-03-25 23:43:54 +02:00
gavrielc 2c447085b5 chore: add edwinwzhe to contributors
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 23:41:37 +02:00
github-actions[bot] 2cddefbef4 docs: update token count to 41.3k tokens · 21% of context window 2026-03-25 21:29:09 +00:00
github-actions[bot] 125757bc7d chore: bump version to 1.2.34 2026-03-25 21:29:02 +00:00
gavrielc 2483cb3e2a Merge pull request #1367 from RichardCao/fix/1272-telegram-dm-backfill
fix: default Telegram migration backfill chats to DMs
2026-03-25 23:28:51 +02:00
gavrielc c16d70cdf7 Merge branch 'main' into fix/1272-telegram-dm-backfill 2026-03-25 23:28:35 +02:00
gavrielc f7979bfa11 Merge pull request #1370 from shawnyeager/fix/ci-fork-guards
fix: skip bump-version and update-tokens on forks
2026-03-25 23:10:22 +02:00
gavrielc 271acf9101 Merge pull request #1375 from kenbolton/feature/emacs-channel
feat(skill): add Emacs channel skill
2026-03-25 23:10:15 +02:00
gavrielc ab9613a2b0 Merge branch 'main' into fix/ci-fork-guards 2026-03-25 23:10:07 +02:00
gavrielc 68c59a1abf feat(skill): add Emacs channel skill
Adds SKILL.md for the Emacs channel — an HTTP bridge that lets Emacs
send messages to NanoClaw and poll for responses. Source code lives on
the skill/emacs branch.

Co-Authored-By: Ken Bolton <ken@bscientific.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 23:09:33 +02:00
gavrielc b16fe4d9fc Merge pull request #1378 from akasha-scheuermann/fix/setup-preserve-mount-allowlist
fix: skip mount-allowlist write during setup if file already exists
2026-03-25 22:56:04 +02:00
gavrielc 5f385974e7 Merge branch 'main' into fix/setup-preserve-mount-allowlist 2026-03-25 22:55:29 +02:00
Ken Bolton deb5389077 fix(skill/channel-formatting): correct Telegram link behaviour in SKILL.md
Telegram Markdown v1 renders [text](url) links natively — they are now
preserved rather than flattened to "text (url)". Update the skill table
to reflect the actual post-fix behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 16:52:29 -04:00
github-actions[bot] 7bfd060536 chore: bump version to 1.2.33 2026-03-25 20:47:41 +00:00
gavrielc 255e139433 Merge pull request #1384 from kenbolton/fix/claw-mounts
fix(claw): mount group folder and sessions into container
2026-03-25 22:47:29 +02:00
gavrielc 3699363eb7 Merge branch 'main' into fix/claw-mounts 2026-03-25 22:47:14 +02:00
github-actions[bot] 3a26f69c7f chore: bump version to 1.2.32 2026-03-25 20:39:06 +00:00
gavrielc aae173d86f Merge pull request #1402 from mrbob-git/codex/fix-issue-1141-per-group-trigger
Fix per-group trigger_pattern matching
2026-03-25 22:38:51 +02:00
gavrielc 23e9e1c150 Merge branch 'main' into codex/fix-issue-1141-per-group-trigger 2026-03-25 22:38:04 +02:00
gavrielc 8b6e9d6cf6 Merge pull request #1418 from IYENTeam/fix/enable-linger-clean
fix: enable loginctl linger so user service survives SSH logout
2026-03-25 22:27:17 +02:00
gavrielc 77b7c658d6 Merge branch 'main' into fix/enable-linger-clean 2026-03-25 22:26:22 +02:00
gavrielc 5954dfb3e7 Merge pull request #1423 from flobo3/fix/telegram-topics
docs: add flobo3 to contributors
2026-03-25 22:25:35 +02:00
flobo3 1f36232ef0 docs: add flobo3 to contributors 2026-03-25 22:25:00 +02:00
gavrielc e9e6d987ac Merge pull request #1426 from ingyukoh/fix/whatsapp-phone-prompt-example
fix: clarify WhatsApp phone number prompt to prevent auth failures
2026-03-25 22:18:05 +02:00
gavrielc 608f935ad7 Merge branch 'main' into fix/whatsapp-phone-prompt-example 2026-03-25 22:17:53 +02:00
gavrielc b2fa85b04a feat(skill): add channel-formatting skill (#1448)
feat(skill): channel-aware text formatting for WhatsApp, Telegram, Slack, Signal
2026-03-25 22:02:27 +02:00
gavrielc 7bba21af1e feat(skill): add channel-formatting skill
Adds SKILL.md for channel-aware text formatting. When applied, converts
Claude's Markdown output to each channel's native syntax (WhatsApp,
Telegram, Slack) before delivery. Source code lives on the
skill/channel-formatting branch.

Co-Authored-By: Ken Bolton <ken@bscientific.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 22:01:54 +02:00
gavrielc 22c1186f16 Merge pull request #1388 from glifocat/docs/update-upstream-docs
docs: update outdated documentation, add docs portal links
2026-03-25 21:30:59 +02:00
gavrielc b71414957d Merge branch 'main' into docs/update-upstream-docs 2026-03-25 21:30:39 +02:00
github-actions[bot] 6d4f972ad0 chore: bump version to 1.2.31 2026-03-25 15:37:53 +00:00
gavrielc 36a8ec643f Merge pull request #1444 from qwibitai/fix/ismain-template-selection
fix: use main template for isMain groups in runtime registration
2026-03-25 17:37:39 +02:00
gavrielc 28937938b2 Merge branch 'main' into fix/ismain-template-selection 2026-03-25 17:36:52 +02:00
github-actions[bot] b8f6a9b794 docs: update token count to 41.2k tokens · 21% of context window 2026-03-25 15:36:27 +00:00
gavrielc 89681a6d0d Merge branch 'main' into fix/ismain-template-selection 2026-03-25 17:36:27 +02:00
github-actions[bot] fd444681ef chore: bump version to 1.2.30 2026-03-25 15:36:23 +00:00
gavrielc 72404968e1 Merge pull request #1429 from ingyukoh/fix/ipc-register-group-claude-md
fix: create CLAUDE.md from template when registering groups via IPC
2026-03-25 17:36:10 +02:00
gavrielc 115b0a3167 Merge branch 'main' into fix/ipc-register-group-claude-md 2026-03-25 17:36:01 +02:00
gavrielc 17c63b94a2 Merge pull request #756 from glifocat/upstream/fix-register-claude-md
fix(register): create CLAUDE.md in group folder from template
2026-03-25 17:34:08 +02:00
gavrielc ff4075d9cb Merge branch 'main' into upstream/fix-register-claude-md 2026-03-25 17:33:56 +02:00
gavrielc 8824a84afe Merge pull request #1443 from Koshkoshinsk/fix/diagnostics-read-directive
fix: explicit Read tool directive for diagnostics pickup
2026-03-25 17:29:56 +02:00
gavrielc 627f13a83c Merge branch 'main' into fix/diagnostics-read-directive 2026-03-25 17:29:44 +02:00
github-actions[bot] df76dc6797 docs: update token count to 41.0k tokens · 20% of context window 2026-03-25 15:28:28 +00:00
github-actions[bot] bb736f37f2 chore: bump version to 1.2.29 2026-03-25 15:28:25 +00:00
gavrielc f3644f123e Merge pull request #1232 from gabi-simons/feat/scheduled-task-scripts-clean
feat: add script support to scheduled tasks
2026-03-25 17:28:10 +02:00
gavrielc deece6bf35 Merge branch 'main' into feat/scheduled-task-scripts-clean 2026-03-25 17:27:59 +02:00
github-actions[bot] 9391304e70 docs: update token count to 40.2k tokens · 20% of context window 2026-03-25 15:27:48 +00:00
github-actions[bot] 31c03cf924 chore: bump version to 1.2.28 2026-03-25 15:27:45 +00:00
gavrielc 33ff3b8c03 Merge pull request #1401 from qwibitai/fix/agent-runner-cache-refresh
fix: refresh stale agent-runner source cache on code changes
2026-03-25 17:27:32 +02:00
gavrielc b112fafff4 Merge branch 'main' into fix/agent-runner-cache-refresh 2026-03-25 17:27:23 +02:00
Ken Bolton 300dcda9c9 Merge branch 'main' into fix/claw-mounts 2026-03-25 11:13:16 -04:00
NanoClaw User 0240f48751 fix: use main template for isMain groups in runtime registration
Main groups (e.g. telegram_main) should get the full main template
with Admin Context section, not the minimal global template.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:43:08 +00:00
Koshkoshinsk b7434b8a76 fix: use explicit Read tool directive for diagnostics instructions
The previous wording ("Send diagnostics data by following ...") was too
passive — Claude treated the backtick-quoted path as informational rather
than an action, so the diagnostics file was never actually read and the
PostHog prompt was silently skipped.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:07:59 +00:00
Gabi Simons 15b9aa99ff Merge branch 'main' into feat/scheduled-task-scripts-clean 2026-03-25 06:58:09 -07:00
NanoClaw User 80f6fb2b9a style: fix prettier formatting in registerGroup template copy
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:37:01 +00:00
Daniel M 5395b732a5 Merge branch 'main' into fix/ipc-register-group-claude-md 2026-03-25 15:21:53 +02:00
Daniel M cf5fa1daf0 Merge branch 'main' into upstream/fix-register-claude-md 2026-03-25 15:21:50 +02:00
gavrielc d4073a01c5 chore: remove auto-sync GitHub Actions
These workflows auto-resolved package.json conflicts with --theirs,
silently stripping fork-specific dependencies during upstream syncs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:08:59 +02:00
Koshkoshinsk d622a79fe2 fix: suppress spurious chat message on script skip
When a script returns wakeAgent=false, set result to null so the host
doesn't forward an internal status string to the user's chat.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:41:25 +00:00
github-actions[bot] 6e5834ee3c docs: update token count to 40.1k tokens · 20% of context window 2026-03-25 11:26:23 +00:00
github-actions[bot] 093530a418 chore: bump version to 1.2.27 2026-03-25 11:26:17 +00:00
gavrielc 675a6d87a3 chore: remove accidentally merged Telegram channel code
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:25:58 +02:00
gavrielc e60eb6dea2 Merge remote-tracking branch 'telegram/main' 2026-03-25 13:22:40 +02:00
gavrielc 63f680d0be chore: remove grammy and pin better-sqlite3/cron-parser versions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:22:36 +02:00
Gabi Simons 1b18d50ae4 Merge branch 'main' into feat/scheduled-task-scripts-clean 2026-03-25 02:25:23 -07:00
ingyukoh 4e3189da8f fix: create CLAUDE.md from template when registering groups via IPC
The registerGroup() function in index.ts creates the group folder and
logs subdirectory but never copies the global CLAUDE.md template.
Agents in newly registered groups start without identity or
instructions until the container is manually fixed.

Copy groups/global/CLAUDE.md into the new group folder on registration,
substituting the assistant name if it differs from the default.

Closes #1391
2026-03-25 16:17:26 +09:00
ingyukoh 2c46d74066 fix: clarify WhatsApp phone number prompt to prevent auth failures
The example "1234567890" was ambiguous — users couldn't tell where the
country code ended and the number began, and some included a leading "+"
which caused pairing to fail. Use a realistic US example (14155551234)
and explicit formatting rules in both the prompt and troubleshooting.

Closes #447
2026-03-25 15:33:44 +09:00
nanoclaw3 aeabfcc65a fix: enable loginctl linger so user service survives SSH logout
Without linger enabled, systemd terminates all user-level processes
(including the NanoClaw service) when the last SSH session closes.
This adds `loginctl enable-linger` during setup for non-root users.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 03:48:08 +00:00
github-actions[bot] 5d5b90448c chore: bump version to 1.2.26 2026-03-24 23:05:58 +00:00
github-actions[bot] 341b8df0a2 Merge remote-tracking branch 'upstream/main' 2026-03-24 23:05:38 +00:00
github-actions[bot] f375dd5011 docs: update token count to 42.4k tokens · 21% of context window 2026-03-24 23:05:20 +00:00
github-actions[bot] 6d4e251534 chore: bump version to 1.2.25 2026-03-24 23:05:15 +00:00
gavrielc 11847a1af0 fix: validate timezone to prevent crash on POSIX-style TZ values
POSIX-style TZ strings like IST-2 cause a hard RangeError crash in
formatMessages because Intl.DateTimeFormat only accepts IANA identifiers.

- Add isValidTimezone/resolveTimezone helpers to src/timezone.ts
- Make formatLocalTime fall back to UTC on invalid timezone
- Validate TZ candidates in config.ts before accepting
- Add timezone setup step to detect and prompt when autodetection fails
- Use node:22-slim in Dockerfile (node:24-slim Trixie package renames)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 01:04:59 +02:00
gavrielc 616c1ae10a fix: expand auto-resolve patterns and add missing forks to dispatch
- Auto-resolve .env.example (keep fork's channel-specific vars) and
  .github/workflows/* (always take upstream) during fork sync
- Add docker-sandbox and docker-sandbox-windows to dispatch list

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:44:30 +02:00
github-actions[bot] 2142f03eaf chore: bump version to 1.2.25 2026-03-24 22:40:34 +00:00
github-actions[bot] 4d853c5d38 docs: update token count to 42.2k tokens · 21% of context window 2026-03-24 22:39:43 +00:00
github-actions[bot] e26e1b3e68 chore: bump version to 1.2.24 2026-03-24 22:39:38 +00:00
gavrielc bf9b7d0311 fix: auto-resolve package-lock/badge/version conflicts in fork sync
The fork-sync and merge-forward workflows were failing on every run
because package-lock.json, package.json (version), and badge.svg
always conflict between upstream and forks. These are always safe to
take from upstream/main. Now auto-resolves these trivial conflicts
and only fails on real code conflicts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:39:20 +02:00
gavrielc 57e520c7e1 Merge origin/main: catch up with upstream (OneCLI, diagnostics, credential proxy)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:34:29 +02:00
gavrielc 2f1d7fe98b Merge pull request #1372 from Koshkoshinsk/fix/diagnostics-prompt
fix: diagnostics prompt never shown to user
2026-03-24 18:04:58 +02:00
gavrielc b7f59da70a Merge branch 'main' into fix/diagnostics-prompt 2026-03-24 18:04:47 +02:00
NanoClaw 8d0baac892 fix: remove prompt manipulation text from diagnostics steps
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:04:20 +00:00
github-actions[bot] e6df18ca8b docs: update token count to 39.9k tokens · 20% of context window 2026-03-24 15:55:51 +00:00
gavrielc 1fff99ffb8 Merge pull request #1400 from Koshkoshinsk/docs/onecli-claude-md-v2
docs: add OneCLI secrets section to CLAUDE.md
2026-03-24 17:55:35 +02:00
github-actions[bot] 58faf624a3 docs: update token count to 39.8k tokens · 20% of context window 2026-03-24 15:45:55 +00:00
github-actions[bot] 7d640cb9f6 chore: bump version to 1.2.23 2026-03-24 15:45:50 +00:00
gavrielc 8fc42e4f82 Merge pull request #1399 from gabi-simons/skill/use-native-credential-proxy
skill: add /use-native-credential-proxy
2026-03-24 17:45:35 +02:00
NanoClaw 7366b0d7db docs: trim OneCLI section wording
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:44:30 +00:00
MrBob 0015931e37 fix: honor per-group trigger patterns 2026-03-24 12:26:17 -03:00
Daniel M d05a8dec49 fix: refresh stale agent-runner source cache on code changes
Closes #1361

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:21:13 +00:00
Gabi Simons 35722801e3 style: fix prettier formatting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:49:42 +02:00
NanoClaw 07cf1fb8a5 docs: add OneCLI secrets management section to CLAUDE.md
Gives Claude context on how credentials/API keys/OAuth tokens are managed via the OneCLI gateway, so it doesn't suggest storing secrets in .env or passing them to containers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:47:52 +00:00
Gabi Simons 14247d0b57 skill: add /use-native-credential-proxy, remove dead proxy code
Add SKILL.md for the native credential proxy feature skill.
Delete src/credential-proxy.ts and src/credential-proxy.test.ts
which became dead code after PR #1237 (OneCLI integration).
These files live on the skill/native-credential-proxy branch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:37:27 +02:00
github-actions[bot] b0671ef9e6 docs: update token count to 40.7k tokens · 20% of context window 2026-03-24 12:55:58 +00:00
github-actions[bot] 81f6703102 chore: bump version to 1.2.22 2026-03-24 12:55:54 +00:00
gavrielc d8cc230227 Merge pull request #1237 from guyb1/feat/onecli-integration
feat: replace credential proxy with OneCLI gateway for secret injection
2026-03-24 14:55:41 +02:00
glifocat 57085cc02e fix: revert promotion logic — never overwrite existing CLAUDE.md
The promotion logic (overwriting CLAUDE.md when a group becomes main)
is unsafe. Real-world setups use is_main for groups that intentionally
lack admin context — e.g. a family chat (whatsapp_casa) with 144 lines
of custom persona, PARA workspace, task management, and family context.
Overwriting based on missing "## Admin Context" would destroy user work.

register.ts now follows a simple rule: create template for new folders,
never touch existing files. Tests updated to verify preservation across
re-registration and main promotion scenarios.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:09:26 +01:00
glifocat 3207c35e50 fix: promote CLAUDE.md to main template when group becomes main
When a non-main group is re-registered with --is-main, the existing
CLAUDE.md (copied from global template) lacked admin context. Now
register.ts detects this promotion case and replaces it with the main
template. Files that already contain "## Admin Context" are preserved.

Adds tests for:
- promoting non-main to main upgrades the template
- cross-channel promotion (e.g. Telegram non-main → main)
- promotion with custom assistant name
- re-registration preserves user-modified main CLAUDE.md
- re-registration preserves user-modified non-main CLAUDE.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:44:24 +01:00
glifocat 07dc8c977c test: cover multi-channel main and cross-channel name propagation
Replaces single-channel tests with multi-channel scenarios:
- each channel can have its own main with admin context
- non-main groups across channels get global template
- custom name propagates to all channels and groups
- user-modified CLAUDE.md preserved on re-registration
- missing templates handled gracefully

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:39:21 +01:00
glifocat b6e18688c2 test: add coverage for CLAUDE.md template copy in register step
Adds 5 tests verifying the template copy and glob-based name update
logic introduced in the parent commit:
- copies global template for non-main groups
- copies main template for main groups
- does not overwrite existing CLAUDE.md
- updates name across all groups/*/CLAUDE.md files
- handles missing template gracefully (no crash)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:35:13 +01:00
glifocat 5a12ddd4cb fix(register): create CLAUDE.md in group folder from template
When registering a new group, create CLAUDE.md in the group folder from
the appropriate template (groups/main/ for main groups, groups/global/
for others). Without this, the container agent runs with no CLAUDE.md
since its CWD is /workspace/group (the group folder).

Also update the name-replacement glob to cover all groups/*/CLAUDE.md
files rather than only two hardcoded paths, so newly created files and
any future group folders are updated correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 12:19:44 +01:00
glifocat 8dcc70cf5c docs: add Windows (WSL2) to supported platforms
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 10:48:18 +01:00
glifocat 01b6258f59 docs: update outdated documentation, add docs portal links
- README.md: add docs.nanoclaw.dev link, point architecture and security
  references to documentation site
- CHANGELOG.md: add all releases from v1.1.0 through v1.2.21 (was only v1.2.0),
  link to full changelog on docs site
- docs/REQUIREMENTS.md: update multi-channel references (NanoClaw now supports
  WhatsApp, Telegram, Discord, Slack, Gmail), update RFS to reflect existing
  skills, fix deployment info (macOS + Linux)
- docs/SECURITY.md: generalize WhatsApp-specific language to channel-neutral
- docs/DEBUG_CHECKLIST.md: use Docker commands (default runtime) instead of
  Apple Container syntax, generalize WhatsApp references
- docs/README.md: new file pointing to docs.nanoclaw.dev as the authoritative
  source, with mapping table from local files to docs site pages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 10:40:04 +01:00
Ken Bolton 724fe7250d fix(claw): mount group folder and sessions into container
claw was running containers with no volume mounts, so the agent
always saw an empty /workspace/group. Add build_mounts() to
replicate the same bind-mounts that container-runner.ts sets up
(group folder, .claude sessions, IPC dir, agent-runner source,
and project root for main).

Also includes upstream fix from qwibitai/nanoclaw#1368:
graceful terminate() before kill() on output sentinel, and early
return after a successful structured response so exit code stays 0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:36:05 -04:00
Akasha 5f42646598 fix: implement --force flag for mount-allowlist overwrite
The skip message mentioned --force but parseArgs didn't handle it,
making it a false promise. Now --force is parsed and passed through,
allowing users to regenerate the mount allowlist when needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 16:57:09 -04:00
Akasha ff16e93713 fix: skip mount-allowlist write if file already exists
/setup overwrote ~/.config/nanoclaw/mount-allowlist.json unconditionally,
clobbering any user customizations made after initial setup. Now checks for
the file first and skips with a 'skipped' status if it exists.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 16:55:36 -04:00
Koshkoshinsk 4f7efd3c67 fix: make diagnostics step explicit so Claude actually follows it
The diagnostics section used a markdown link that Claude never resolved,
so the prompt was silently skipped. Replace with a numbered step (setup)
and mandatory final step (update-nanoclaw) that instructs Claude to use
the Read tool on the full file path. Update opt-out instructions to
match the renamed section headings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:37:47 +00:00
NanoClaw Setup def3748d02 fix: restore subscription vs API key choice in setup step 4
The OneCLI integration removed the upstream subscription/API key question
and only offered dashboard vs CLI. This restores the choice so users with
a Claude Pro/Max subscription can use `claude setup-token` to get their
OAuth token, while API key users get the existing flow.

Both paths converge to the same `onecli secrets create --type anthropic`
command — OneCLI handles both token types transparently.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:43:30 +02:00
Shawn Yeager d40affbdef fix: skip bump-version and update-tokens on forks
These workflows use APP_ID/APP_PRIVATE_KEY secrets that only exist on
the upstream repo. Without a fork guard they fail on every push for
every fork. merge-forward-skills already has the correct guard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 13:41:20 +00:00
Guy Ben Aharon 2583af7ead fix: ensure OneCLI agents exist for all groups on startup 2026-03-23 14:45:58 +02:00
NanoClaw Setup 7f6298a1bb fix: add onecli CLI to PATH if not found after install
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:45:58 +02:00
NanoClaw Setup b7f8c20a25 fix: setup skill uses 127.0.0.1 for OneCLI and offers dashboard vs CLI choice
- Configure CLI api-host to local instance (defaults to cloud otherwise)
- Use 127.0.0.1 instead of localhost to avoid IPv6 resolution issues
- Present dashboard and CLI as two options with platform guidance
- Accept ONECLI_URL as valid credentials in verify step

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:45:58 +02:00
Guy Ben Aharon e9369617fb feat: replace credential proxy with OneCLI gateway for secret injection 2026-03-23 14:45:58 +02:00
RichardCao 00ff0e00eb fix(db): default Telegram backfill chats to DMs 2026-03-23 16:53:26 +08:00
gavrielc deee4b2a96 Merge pull request #1280 from Koshkoshinsk/feature/diagnostics
feat: add opt-in diagnostics via PostHog
2026-03-22 16:55:07 +02:00
gavrielc 4f60be7803 Merge branch 'main' into feature/diagnostics 2026-03-22 16:54:10 +02:00
gavrielc 02d51afe09 trim diagnostics verbosity
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 18:53:53 +02:00
gavrielc a4fbc9d615 show full payload to user, not just properties
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 18:51:15 +02:00
gavrielc f97394656c cross-skill opt-out and gather system info via shell
- "Never ask again" now removes diagnostics from both skills
- Added shell commands to gather version, platform, arch, node version
- Show only properties object to user, not api_key/distinct_id
- Write full PostHog payload to temp file, send with curl -d @file

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 18:47:54 +02:00
gavrielc 09d833c310 replace diagnostics script with curl, simplify flow
Remove send-diagnostics.ts entirely. Claude writes the JSON, shows
it to the user, and sends via curl. Opt-out is permanent: Claude
replaces diagnostics.md contents and removes the section from SKILL.md.
No dependencies, no state files, no .nanoclaw/ directory.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 18:45:04 +02:00
gavrielc f33c66b046 simplify setup diagnostics to single event
One setup_complete event at the end, not per-skill events.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 18:37:13 +02:00
gavrielc e2423171e1 simplify diagnostics instructions
Show example commands with placeholder values. Claude fills in the
actual values from the session in one shot — no multi-step build process.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 18:36:08 +02:00
gavrielc e10b136df6 refactor: move diagnostics into each skill's own directory
Replace shared _shared/diagnostics.md with dedicated diagnostics.md
files in setup/ and update-nanoclaw/. Each contains only the event
types relevant to that skill. References updated to local links.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 16:31:59 +02:00
gavrielc 31ac74f5f2 fix: remove claw skill accidentally added to this branch
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 16:28:36 +02:00
gavrielc d96be5ddfd scope diagnostics to setup and update-nanoclaw only
Remove diagnostics appendage from all other skills. Only /setup and
/update-nanoclaw need telemetry — these are the two points where we
can detect regressions and track improvements across the user base.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 16:27:10 +02:00
gavrielc d768a04843 docs: move Docker Sandboxes out of README hero section
Demote Docker Sandboxes from a prominent hero banner to inline
mentions in the features list and FAQ. New users now land on
Quick Start first.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 13:10:54 +02:00
github-actions[bot] 8c3979556a docs: update token count to 40.9k tokens · 20% of context window 2026-03-21 11:09:04 +00:00
gavrielc ec1b14504b docs: update contributing guidelines and skill type taxonomy
- Rewrite CONTRIBUTING.md with four skill types (feature, utility,
  operational, container), PR requirements, pre-submission checklist
- Update PR template with skill type checkboxes and docs option
- Add label-pr workflow to auto-label PRs from template checkboxes
- Add hidden template version marker (v1) for follows-guidelines label
- Update CLAUDE.md with skill types overview and contributing instruction
- Update skills-as-branches.md to reference full taxonomy
- Remove /clear from README RFS (already exists as /add-compact)
- Delete obsolete docs (nanorepo-architecture.md, nanoclaw-architecture-final.md)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 13:08:42 +02:00
gavrielc 3cc30501dd Merge pull request #1296 from kenbolton/skill/claw-cli
skill: claw — run NanoClaw agents from the command line
2026-03-21 12:21:11 +02:00
gavrielc bf1e2a3819 refactor: extract claw script from SKILL.md into separate file
Move the Python CLI script from inline markdown into scripts/claw,
aligning with the Claude Code skills standard (code in files, not md).
Remove non-standard `author` frontmatter field. SKILL.md now uses
${CLAUDE_SKILL_DIR} substitution to copy the script during install.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 12:16:57 +02:00
Ken Bolton b2377bb390 Fix Python 3.8 compat, document --image flag and --rm behavior 2026-03-21 12:03:00 +02:00
Ken Bolton 18469294ce Add claw CLI skill 2026-03-21 12:03:00 +02:00
github-actions[bot] c3b19876eb chore: bump version to 1.2.21 2026-03-21 09:57:39 +00:00
gavrielc f1150ac624 Merge pull request #1297 from trycua/claude/add-eslint-preserve-error-YnKYj
Add ESLint configuration and fix linting issues
2026-03-21 11:57:30 +02:00
Claude b30b5a6a8f style: apply prettier formatting to modified files
https://claude.ai/code/session_01JPjzhBp9PR5LtfLWVDrYrH
2026-03-21 11:57:22 +02:00
Claude 30ebcaa61e feat: add ESLint with error-handling rules
Add ESLint v9.35+ with typescript-eslint recommended config and
error-handling rules: preserve-caught-error (enforces { cause } when
re-throwing), no-unused-vars with caughtErrors:all, and
eslint-plugin-no-catch-all (warns on catch blocks that don't rethrow).

Fix existing violations: add error cause to container-runtime rethrow,
prefix unused vars with underscore, remove unused imports.

https://claude.ai/code/session_01JPjzhBp9PR5LtfLWVDrYrH
2026-03-21 11:57:22 +02:00
github-actions[bot] b7420c6562 chore: bump version to 1.2.20 2026-03-21 09:54:51 +00:00
gavrielc 656d7f7bff Merge pull request #1300 from trycua/claude/slack-add-slack-formatting-skill-kKnnL
Add Slack formatting skill and update message formatting guides
2026-03-21 11:54:39 +02:00
Claude 0ce11f6f4d feat: add Slack formatting skill for NanoClaw agents
Add a new skill that teaches agents how to format messages using Slack's
mrkdwn syntax. Updates agent CLAUDE.md files to detect channel type from
folder name prefix and use appropriate formatting.

- container/skills/slack-formatting/SKILL.md: comprehensive mrkdwn reference
- groups/global/CLAUDE.md: channel-aware formatting instructions
- groups/main/CLAUDE.md: same, plus emoji shortcode examples

https://claude.ai/code/session_01W44WtL2gRETr9YBB6h62YM
2026-03-21 06:55:51 +00:00
NanoClaw User 1734be7259 fix: collect diagnostics for sub-skills invoked during setup
Previously, sub-skills (e.g. /add-telegram) skipped diagnostics when
called from a parent skill like /setup. This lost channel-level events.
Now all events are collected and shown to the user in a single prompt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 01:24:32 +02:00
Koshkoshinsk 8c1d5598ba fix: strip PostHog internal fields from dry-run output
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 01:24:32 +02:00
Koshkoshinsk 3747dfeacc fix: also strip distinct_id from dry-run output
Ephemeral UUID is harmless but showing it to users creates unnecessary
doubt about whether they're being tracked.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 01:24:32 +02:00
Koshkoshinsk 33874de175 fix: strip api_key from dry-run output shown to user
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 01:24:32 +02:00
Koshkoshinsk f04a8955aa feat: add opt-in diagnostics via PostHog
Per-event consent diagnostics that sends anonymous install/update/skill data
to PostHog. Conflict filenames are gated against upstream. Supports --dry-run
to show exact payload before sending, and "never ask again" opt-out via state.yaml.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 01:24:32 +02:00
github-actions[bot] 91f17a11b2 chore: bump version to 1.2.19 2026-03-19 19:05:42 +00:00
gavrielc b5d0f1e5aa Merge pull request #651 from takeru/fix/docker-stop-timeout
fix: reduce docker stop timeout for faster restarts
2026-03-19 21:05:27 +02:00
sasaki takeru cf3d9dcbd5 fix: reduce docker stop timeout for faster restarts
Pass -t 1 to docker stop, reducing SIGTERM-to-SIGKILL grace period from
10s to 1s. NanoClaw containers are stateless (--rm, mounted filesystems)
so they don't need a long grace period. Makes restarts ~10x faster.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 21:05:19 +02:00
github-actions[bot] 7a8c24b092 docs: update token count to 40.7k tokens · 20% of context window 2026-03-19 19:03:36 +00:00
github-actions[bot] c78042e90e chore: bump version to 1.2.18 2026-03-19 19:03:27 +00:00
gavrielc 6dc23fda80 Merge pull request #1191 from moktamd/fix/redact-prompt-from-error-logs
security: stop logging user prompt content on container errors
2026-03-19 21:03:16 +02:00
moktamd cf899049f7 security: stop logging user prompt content on container errors
Container error logs wrote the full ContainerInput (including user
prompt) to disk on every non-zero exit. The structured log stream
also included the first 200 chars of agent output.

- container-runner: only include full input at verbose level; error
  path now logs prompt length and session ID instead
- index: log output length instead of content snippet

Fixes #1150
2026-03-19 21:03:07 +02:00
gavrielc fc2cc5368f Merge pull request #1230 from eltociear/add-ja-doc
docs: add Japanese README
2026-03-19 13:53:00 +02:00
Gabi Simons b7f1d48423 style: fix prettier formatting in db.ts 2026-03-18 14:04:31 +02:00
Gabi Simons a4dc3a7446 docs: add task script instructions to agent CLAUDE.md 2026-03-18 14:04:11 +02:00
Gabi Simons 9f5aff99b6 feat: add script execution phase to agent-runner
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 14:04:11 +02:00
Gabi Simons 42d098c3c1 feat: pass script from task scheduler to container
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 14:04:11 +02:00
Gabi Simons eb65121938 feat: add script to ContainerInput and task snapshot
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 14:04:11 +02:00
Gabi Simons 0f283cbdd3 feat: pass script through IPC task processing
Thread the optional `script` field through the IPC layer so it is
persisted when an agent calls schedule_task, and updated when an agent
calls update_task (empty string clears the script).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 14:04:11 +02:00
Gabi Simons a516cc5cfe feat: add script parameter to MCP task tools
Add optional `script` field to schedule_task and update_task MCP tools,
allowing agents to attach a pre-flight bash script that controls whether
the task agent is woken up.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 14:04:11 +02:00
Gabi Simons 675acffeb1 feat: add script field to ScheduledTask type and database layer
Adds optional `script` field to the ScheduledTask interface, with a
migration for existing DBs and full support in createTask/updateTask.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 14:04:11 +02:00
Ikko Ashimine c75de24029 docs: add Japanese README 2026-03-18 19:43:46 +09:00
github-actions[bot] c71c7b7e83 chore: bump version to 1.2.17 2026-03-18 10:10:45 +00:00
gavrielc fe0a309325 Merge pull request #1086 from akshan-main/admin_mode_1
add /capabilities and /status skills
2026-03-18 12:10:34 +02:00
gavrielc f2ed7fe490 Merge branch 'main' into admin_mode_1 2026-03-18 12:10:19 +02:00
gavrielc 96852f686e Apply suggestion from @gavrielc 2026-03-18 12:08:22 +02:00
github-actions[bot] e7d0ffb208 docs: update token count to 40.6k tokens · 20% of context window 2026-03-18 09:52:29 +00:00
github-actions[bot] 9200612dd1 chore: bump version to 1.2.16 2026-03-18 09:52:20 +00:00
gavrielc aa4f7a27ae Merge pull request #1159 from mbravorus/upstream-pr/refresh-tasks-snapshot
fix: refresh tasks snapshot immediately after IPC task mutations
2026-03-18 11:48:36 +02:00
Gabi Simons 0c495b0efe Merge branch 'main' into upstream-pr/refresh-tasks-snapshot 2026-03-18 01:05:29 -07:00
gavrielc f629f9361a Merge branch 'main' of https://github.com/qwibitai/nanoclaw
# Conflicts:
#	package-lock.json
#	repo-tokens/badge.svg
2026-03-17 09:43:05 +02:00
github-actions[bot] c8f03eddeb docs: update token count to 40.5k tokens · 20% of context window 2026-03-16 17:37:20 +00:00
github-actions[bot] 8b647410c6 chore: bump version to 1.2.15 2026-03-16 17:37:14 +00:00
gavrielc 4b53ce008b Merge pull request #1133 from gabi-simons/fix/remote-control-stdin-clean
fix: auto-accept remote-control prompt to prevent immediate exit
2026-03-16 19:37:00 +02:00
Gabi Simons 260812702c fix: add KillMode=process so remote-control survives restarts
systemd's default KillMode=control-group kills all processes in the
cgroup on service restart, including the detached claude remote-control
process. KillMode=process only kills the main Node.js process, letting
detached children survive. restoreRemoteControl() already handles
reattaching on startup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 13:12:07 +02:00
Gabi Simons 12ff2589fa style: format remote-control tests with prettier
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 11:51:47 +02:00
Gabi Simons 924482870e test: update remote-control tests for stdin pipe change
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 09:41:09 +00:00
Gabi Simons d49af91cc2 fix: auto-accept remote-control prompt to prevent immediate exit
`claude remote-control` prompts "Enable Remote Control? (y/n)" on every
launch. With stdin set to 'ignore', the process exits immediately because
it cannot read the response. Pipe 'y\n' to stdin instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 09:34:23 +00:00
Akshan Krithick de62ef6b3f format remote-control files with Prettier 2026-03-14 21:41:56 -07:00
Akshan Krithick 8cbd715ee2 add read-only /capabilities and /status skills 2026-03-14 21:33:48 -07:00
gavrielc 9e5dde6ebb Merge branch 'feat/remote-control' 2026-03-14 17:26:30 +02:00
github-actions[bot] fb66428eeb docs: update token count to 40.4k tokens · 20% of context window 2026-03-14 15:24:01 +00:00
github-actions[bot] 9b82611dc1 chore: bump version to 1.2.14 2026-03-14 15:23:57 +00:00
gavrielc 4e7eb3e278 Merge pull request #1072 from qwibitai/feat/remote-control
feat: add /remote-control command for host-level Claude Code access
2026-03-14 17:23:46 +02:00
gavrielc 82206570d1 Merge remote-tracking branch 'telegram/main' 2026-03-14 17:15:08 +02:00
github-actions[bot] c984e6f13d docs: update token count to 41.1k tokens · 21% of context window 2026-03-14 15:08:12 +00:00
github-actions[bot] 3d649c386e chore: bump version to 1.2.17 2026-03-14 15:08:11 +00:00
gavrielc 6f40ed148c Merge pull request #45 from qwibitai/fix/telegram-slash-command-filter
fix: only skip /chatid and /ping, let other / messages through
2026-03-14 17:07:57 +02:00
gavrielc cb20038956 fix: only skip /chatid and /ping, let other / messages through
Previously all messages starting with / were silently dropped. This
prevented NanoClaw-level commands like /remote-control from reaching
the onMessage callback. Now only Telegram bot commands (/chatid, /ping)
are skipped; everything else flows through as a regular message.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 17:01:23 +02:00
gavrielc e2b0d2d0aa feat: add /remote-control command for host-level Claude Code access
Users can send /remote-control from the main group in any channel to
spawn a detached `claude remote-control` process on the host. The
session URL is sent back through the channel. /remote-control-end
kills the session.

Key design decisions:
- One global session at a time, restricted to main group only
- Process is fully detached (stdout/stderr to files, not pipes) so it
  survives NanoClaw restarts
- PID + URL persisted to data/remote-control.json; restored on startup
- Commands intercepted in onMessage before DB storage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 16:59:52 +02:00
github-actions[bot] 2640973b41 chore: bump version to 1.2.13 2026-03-14 13:26:21 +00:00
gavrielc e7318be0a2 chore: bump claude-agent-sdk to ^0.2.76
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 15:24:15 +02:00
github-actions[bot] 662e81fc9e chore: bump version to 1.2.16 2026-03-14 13:17:37 +00:00
github-actions[bot] 54a55affa4 chore: bump version to 1.2.15 2026-03-14 13:16:49 +00:00
gavrielc d1975462c4 chore: bump claude-agent-sdk to ^0.2.76
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 15:16:33 +02:00
gavrielc 696ae5e872 Merge remote-tracking branch 'telegram/main'
# Conflicts:
#	repo-tokens/badge.svg
2026-03-14 15:01:08 +02:00
gavrielc c0902877fa Merge pull request #1031 from qwibitai/gavrielc-patch-1
Update README.md
2026-03-13 13:59:26 +02:00
gavrielc 38ebb31e6d Update README.md 2026-03-13 13:59:15 +02:00
gavrielc fedfaf3f50 Merge pull request #1030 from qwibitai/docker-sandboxes-announcement
Docker sandboxes announcement
2026-03-13 13:58:53 +02:00
gavrielc df9ba0e5f9 fix: correct Docker Sandboxes documentation URL
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:03:27 +02:00
gavrielc e6ff5c640c feat: add manual Docker Sandboxes setup guide
Step-by-step guide for running NanoClaw in Docker Sandboxes from
scratch without the install script. Covers proxy patches, DinD
mount fixes, channel setup, networking details, and troubleshooting.

Validated on macOS (Apple Silicon) with WhatsApp — other channels
and environments may need additional proxy patches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:02:15 +02:00
gavrielc 6f64b31d03 fix: add divider after announcement section
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:57:07 +02:00
gavrielc c7391757ac fix: add divider between badges and announcement section
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:56:14 +02:00
gavrielc 3414625a6d fix: left-align install commands in announcement section
Keep heading and description centered, but left-align the install
blocks and labels so they don't clash with the code block layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:55:27 +02:00
gavrielc 2a90f98138 fix: add supported platforms note to Docker Sandboxes section
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:49:01 +02:00
gavrielc 49595b9c70 fix: separate install commands into individual code blocks
Allows each curl command to be copied independently without the
comment line.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:48:10 +02:00
gavrielc 48d352a142 feat: add Docker Sandboxes announcement to README
Replace the Agent Swarms / Claude Code lines at the top with a
prominent Docker Sandboxes announcement section including install
commands and a link to the blog post.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:46:03 +02:00
github-actions[bot] d81f8e1221 docs: update token count to 41.0k tokens · 20% of context window 2026-03-11 20:52:44 +00:00
github-actions[bot] f210fd5049 chore: bump version to 1.2.14 2026-03-11 20:52:39 +00:00
gavrielc 7d5f322ae6 Merge pull request #28 from gabi-simons/fix/sandbox-proxy-agent
fix: use https.globalAgent in grammY Bot for sandbox proxy
2026-03-11 22:52:26 +02:00
Gabi Simons d000acc687 fix: use https.globalAgent in grammY Bot to support sandbox proxy
grammY creates its own https.Agent internally, bypassing any global
proxy. In Docker Sandbox, NanoClaw sets https.globalAgent to a proxy
agent at startup. This tells grammY to use it instead. On non-sandbox
setups it's a no-op.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:46:57 +02:00
gavrielc 7e9a698aa1 feat: add nanoclaw-docker-sandboxes to fork dispatch list
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 12:30:14 +02:00
gavrielc 1f2e930d16 fix: auto-resolve package-lock conflicts when merging forks
Instead of failing on package-lock.json merge conflicts, take the
fork's version and continue. Applied to all channel skill merge
instructions and CLAUDE.md troubleshooting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 12:25:14 +02:00
github-actions[bot] 2dedd15ec7 docs: update token count to 40.9k tokens · 20% of context window 2026-03-11 10:09:51 +00:00
github-actions[bot] cb9fba8472 chore: bump version to 1.2.13 2026-03-11 10:09:48 +00:00
gavrielc f530806c96 Merge pull request #21 from qwibitai/fix/sendmessage-test-parse-mode
fix: sendMessage test expectations for parse_mode
2026-03-11 12:09:36 +02:00
gavrielc 845da49fa3 fix: prettier formatting for telegram.ts
Pre-existing formatting issue that causes CI format check to fail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 12:08:52 +02:00
gavrielc 272cbcf18f fix: update sendMessage test expectations for Markdown parse_mode
The sendTelegramMessage helper now passes { parse_mode: 'Markdown' }
to bot.api.sendMessage, but three tests still expected only two args.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 12:06:28 +02:00
gavrielc 0cfdde46c6 fix: remove claude plugin marketplace commands (skills are local now)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:59:23 +02:00
gavrielc 04fb44e417 fix: setup registration — use initDatabase/setRegisteredGroup, .ts imports, correct CLI commands
- setup/register.ts: replace inline DB logic with initDatabase() + setRegisteredGroup()
  (fixes missing is_main column on existing DBs, .js MODULE_NOT_FOUND with tsx)
- SKILL.md (telegram, slack, discord): replace broken registerGroup() pseudo-code
  with actual `npx tsx setup/index.ts --step register` commands
- docs/SPEC.md: fix registerGroup → setRegisteredGroup in example

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:51:40 +02:00
gavrielc 7061480ac0 fix: add concurrency group to prevent parallel fork-sync races 2026-03-10 22:43:00 +02:00
gavrielc d8a1ee8c3c fix: use npm ci in bootstrap to prevent dirty lockfile blocking merges
setup.sh ran npm install which modified package-lock.json, causing
git merge to refuse during channel skill installation. Switch to
npm ci (deterministic, doesn't modify lockfile) and clean up stale
peer flags in the lockfile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:39:26 +02:00
gavrielc 51ad949979 fix: re-fetch before skill branch merges to avoid stale refs 2026-03-10 22:28:12 +02:00
gavrielc 018deca3ef fix: use GitHub App token for fork-sync (workflows permission needed) 2026-03-10 22:16:02 +02:00
gavrielc 15ed3cf2a6 fix: repair escaped newlines in fork-sync workflow 2026-03-10 22:10:37 +02:00
gavrielc 107f9742a9 fix: update sync condition to check repo name, not owner 2026-03-10 22:00:36 +02:00
gavrielc 5a0bda8d37 Merge pull request #8 from Jimbo1167/feat/markdown-formatting
feat: add Markdown formatting for outbound messages
2026-03-10 18:41:21 +02:00
James Schindler 9a4fb61f6e feat: add Markdown formatting for outbound messages
Wrap outbound sendMessage calls with parse_mode: 'Markdown' so that
Claude's natural formatting (*bold*, _italic_, `code`, etc.) renders
correctly in Telegram instead of showing raw asterisks and underscores.

Falls back to plain text if Telegram rejects the Markdown formatting.
2026-03-10 11:58:00 -04:00
Michael Bravo 5ca0633c27 fix: refresh tasks snapshot immediately after IPC task mutations
Previously, current_tasks.json was only written at container-start time,
so tasks created (or paused/cancelled/updated) during a session were
invisible to list_tasks until the next invocation.

Add an onTasksChanged callback to IpcDeps, called after every successful
mutation in processTaskIpc (schedule_task, pause_task, resume_task,
cancel_task, update_task). index.ts wires it up to write fresh snapshots
for all registered groups immediately, keeping no new coupling between
ipc.ts and the container layer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 15:59:52 +02:00
gavrielc d572bab5c6 feat: add marketplace skills as local project skills
Move skill definitions from the nanoclaw-skills marketplace plugin
into .claude/skills/ so they're available as unprefixed slash commands
(e.g. /add-whatsapp instead of /nanoclaw-skills:add-whatsapp).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 02:25:17 +02:00
gavrielc 2eb5871454 Merge remote-tracking branch 'telegram/main' into rebuild-fork 2026-03-10 01:11:48 +02:00
gavrielc 621fde8c75 fix: update marketplace cache before installing skills plugin in setup 2026-03-10 01:05:41 +02:00
gavrielc f41b399aa1 fix: register marketplace and install channel skills individually in setup 2026-03-10 01:03:26 +02:00
gavrielc 4dee68c230 fix: run npm install after channel merges in setup to catch new dependencies 2026-03-10 00:57:18 +02:00
gavrielc b913a37c21 ci: remove old merge-forward-skills.yml (replaced by fork-sync-skills.yml) 2026-03-10 00:53:51 +02:00
gavrielc d487faf55a ci: rename sync workflow to fork-sync-skills.yml to avoid merge conflicts with core 2026-03-10 00:53:40 +02:00
gavrielc e6ea914ef1 ci: add repo guard to merge-forward workflow to prevent conflicts on forks 2026-03-10 00:53:33 +02:00
github-actions[bot] 8564937d93 docs: update token count to 38.8k tokens · 19% of context window 2026-03-09 22:19:01 +00:00
gavrielc 5118239cea feat: skills as branches, channels as forks
Replace the custom skills engine with standard git operations.
Feature skills are now git branches (on upstream or channel forks)
applied via `git merge`. Channels are separate fork repos.

- Remove skills-engine/ (6,300+ lines), apply/uninstall/rebase scripts
- Remove old skill format (add/, modify/, manifest.yaml) from all skills
- Remove old CI (skill-drift.yml, skill-pr.yml)
- Add merge-forward CI for upstream skill branches
- Add fork notification (repository_dispatch to channel forks)
- Add marketplace config (.claude/settings.json)
- Add /update-skills operational skill
- Update /setup and /customize for marketplace plugin install
- Add docs/skills-as-branches.md architecture doc

Channel forks created: nanoclaw-whatsapp (with 5 skill branches),
nanoclaw-telegram, nanoclaw-discord, nanoclaw-slack, nanoclaw-gmail.

Upstream retains: skill/ollama-tool, skill/apple-container, skill/compact.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 00:18:25 +02:00
gavrielc 5acab2c09d ci: add upstream sync and merge-forward workflow
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:43:19 +02:00
gavrielc 27e241c13e Merge remote-tracking branch 'origin/main' into skill/telegram 2026-03-09 23:21:10 +02:00
github-actions[bot] e7852a45a5 chore: bump version to 1.2.12 2026-03-08 22:27:26 +00:00
Gabi Simons 13ce4aaf67 feat: enhance container environment isolation via credential proxy (#798)
* feat: implement credential proxy for enhanced container environment isolation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review — bind proxy to loopback, scope OAuth injection, add tests

- Bind credential proxy to 127.0.0.1 instead of 0.0.0.0 (security)
- OAuth mode: only inject Authorization on token exchange endpoint
- Add 5 integration tests for credential-proxy.ts
- Remove dangling comment
- Extract host gateway into container-runtime.ts abstraction
- Update Apple Container skill for credential proxy compatibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: scope OAuth token injection by header presence instead of path

Path-based matching missed auth probe requests the CLI sends before
the token exchange. Now the proxy replaces Authorization only when
the container actually sends one, leaving x-api-key-only requests
(post-exchange) untouched.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: bind credential proxy to docker0 bridge IP on Linux

On bare-metal Linux Docker, containers reach the host via the bridge IP
(e.g. 172.17.0.1), not loopback. Detect the docker0 interface address
via os.networkInterfaces() and bind there instead of 0.0.0.0, so the
proxy is reachable by containers but not exposed to the LAN.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: bind credential proxy to loopback on WSL

WSL uses Docker Desktop with the same VM routing as macOS, so
127.0.0.1 is correct and secure. Without this, the fallback to
0.0.0.0 was triggered because WSL has no docker0 interface.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: detect WSL via /proc instead of env var

WSL_DISTRO_NAME isn't set under systemd. Use
/proc/sys/fs/binfmt_misc/WSLInterop which is always present on WSL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:27:13 +02:00
gavrielc 16559e9dbf Merge remote-tracking branch 'origin/main' into skill/telegram 2026-03-09 00:07:58 +02:00
Akshan Krithick 8521e42f7b Add /compact skill for manual context compaction (#817)
* feat: add /compact skill for manual context compaction

added /compact session command to fight context rot in long-running sessions. Uses Claude Agent SDK's built-in /compact command with auth gating (main-group or is_from_me only).

* simplify: remove group-queue modification, streamline denied path confirmed against fresh-clone merge.

* refactor: extract handleSessionCommand from index.ts into session-commands.ts

Verified: 345/345 tests pass on fresh-clone merge.
2026-03-08 23:59:17 +02:00
gavrielc a6dc297722 Merge remote-tracking branch 'origin/main' into skill/telegram 2026-03-08 23:24:39 +02:00
gavrielc 83b91b3bf1 skill/telegram: Telegram channel integration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:43:37 +02:00
Gabi Simons c7a24f4716 Merge branch 'main' into feat/add-github-skill 2026-03-08 22:37:30 +02:00
Gabi Simons b582498e27 feat(skill): add GitHub skill for container agents
Installs gh CLI in the container, passes GITHUB_TOKEN and GH_REPO
via stdin, and adds agent-facing docs so agents can manage issues,
PRs, and code review.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:37:01 +02:00
github-actions[bot] 4ccc5c57f2 chore: bump version to 1.2.11 2026-03-08 19:43:31 +00:00
glifocat a689a18dfa fix: close task container promptly when agent uses IPC-only messaging (#840)
Scheduled tasks that send messages via send_message (IPC) instead of
returning text as result left the container idle for ~30 minutes until
the hard timeout killed it (exit 137). This blocked new messages for
the group during that window.

Root cause: scheduleClose() was only called inside the
`if (streamedOutput.result)` branch. Tasks that communicate solely
through IPC (e.g. heartbeat check-ins) complete with result=null,
so the 10s close timer was never set.

Fix: also call scheduleClose() on status==='success', covering both
result-based and IPC-only task completions.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 21:43:21 +02:00
Yonatan Azrielant ab9abbb21a feat(skill): add WhatsApp reactions skill (emoji reactions + status tracker) (#509)
* feat(skill): add reactions skill (emoji reactions + status tracker)

* refactor(reactions): minimize overlays per upstream review

Address gavrielc's review on qwibitai/nanoclaw#509:
- SKILL.md: remove all inline code, follow add-telegram/add-whatsapp pattern (465→79 lines)
- Rebuild overlays as minimal deltas against upstream/main base
- ipc-mcp-stdio.ts: upstream base + only react_to_message tool (8% delta)
- ipc.ts: upstream base + only reactions delta (14% delta)
- group-queue.test.ts: upstream base + isActive tests only (5% delta)
- Remove group-queue.ts overlay (isActive provided by container-hardening)
- Remove group-queue.ts from manifest modifies list

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 20:02:20 +02:00
glifocat 5b2bafd7bb fix(whatsapp): use sender's JID for DM-with-bot registration, skip trigger (#751)
Two bugs in the DM with dedicated bot number setup:

1. The skill asked for the bot's own phone number to use as the JID.
   But from the bot's perspective, incoming DMs appear with the SENDER's
   JID (the user's personal number), not the bot's own number. The
   registration must use the user's personal number as the JID.

2. DM with bot (1:1 conversation) should use --no-trigger-required, same
   as self-chat. A trigger prefix is unnecessary in a private DM.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:58:48 +02:00
tomermesser 32dda34af4 status-icon-01 2026-03-08 16:38:03 +02:00
Thomas Lok cfabdd816b fix broken step references in setup/SKILL.md (#794) 2026-03-07 20:58:52 +02:00
glifocat af937d6453 feat(skills): add image vision skill for WhatsApp (#770)
* chore: prepare image-vision skill for template regeneration

- Delete stale modify/*.ts templates (built against 1.1.2)
- Update core_version to 1.2.6
- Strip fork-specific details from intent docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(skills): regenerate image-vision modify/ templates against upstream

Templates regenerated against upstream 1.2.6:
- src/container-runner.ts: imageAttachments field in ContainerInput
- src/index.ts: parseImageReferences + threading to runAgent
- src/channels/whatsapp.ts: downloadMediaMessage + image handling block
- src/channels/whatsapp.test.ts: image mocks + 4 test cases
- container/agent-runner/src/index.ts: ContentBlock types, pushMultimodal, image loading

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: update image-vision tests for upstream templates

- Relax downloadMediaMessage import pattern check (multi-line import)
- Remove check for [Image - processing failed] (not in upstream template)
- Add vitest.skills.config.ts for skill package test runs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: update image-vision core_version to 1.2.8

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 18:52:59 +02:00
glifocat be1991108b fix(whatsapp): write pairing code to file for immediate access (#745)
The pairing code was only emitted to stdout, which is buffered by the
calling process and not visible until the auth command exits (~120s).
By also writing to store/pairing-code.txt the moment the code is ready,
callers can poll that file and display the code to the user within seconds
instead of after the 60s expiry window.

Update the add-whatsapp skill instructions to use the background +
file-poll pattern instead of waiting on buffered stdout.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 18:51:00 +02:00
glifocat 0b260ece57 feat(skills): add pdf-reader skill (#772)
Thanks @glifocat! Clean skill package — good docs, solid tests, nice intent files. Pushed a small fix for path traversal on the PDF filename before merging.
2026-03-06 18:47:12 +02:00
github-actions[bot] 1e89d61928 docs: update token count to 37.5k tokens · 19% of context window 2026-03-06 16:35:15 +00:00
github-actions[bot] cf99b833b0 chore: bump version to 1.2.10 2026-03-06 16:35:08 +00:00
Gabi Simons 74b02c8715 fix(db): add LIMIT to unbounded message history queries (#692) (#735)
getNewMessages() and getMessagesSince() loaded all rows after a
checkpoint with no cap, causing growing memory and token costs.
Both queries now use a DESC LIMIT subquery to return only the
most recent N messages, re-sorted chronologically.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 18:34:55 +02:00
github-actions[bot] 8c38a8c7ff docs: update token count to 37.4k tokens · 19% of context window 2026-03-06 16:28:46 +00:00
github-actions[bot] 24c6fa25b1 chore: bump version to 1.2.9 2026-03-06 16:28:41 +00:00
Gabi Simons 632713b208 feat: timezone-aware context injection for agent prompts (#691)
* feat: per-group timezone architecture with context injection (#483)

Implement a comprehensive timezone consistency layer so the AI agent always
receives timestamps in the user's local timezone. The framework handles all
UTC↔local conversion transparently — the agent never performs manual timezone
math.

Key changes:
- Per-group timezone stored in containerConfig (no DB migration needed)
- Context injection: <context timezone="..." current_time="..." /> header
  prepended to every agent prompt with local time and IANA timezone
- Message timestamps converted from UTC to local display in formatMessages()
- schedule_task translation layer: agent writes local times, framework
  converts to UTC using per-group timezone for cron, once, and interval types
- Container TZ env var now uses per-group timezone instead of global constant
- New set_timezone MCP tool for users to update their timezone dynamically
- NANOCLAW_TIMEZONE passed to MCP server environment for tool confirmations

Architecture: Store UTC everywhere, convert at boundaries (display to agent,
parse from agent). Groups without timezone configured fall back to the server
TIMEZONE constant for full backward compatibility.

Closes #483
Closes #526

Co-authored-by: shawnYJ <shawny011717@users.noreply.github.com>
Co-authored-by: Adrian <Lafunamor@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* style: apply prettier formatting

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: strip to minimalist context injection — global TIMEZONE only

Remove per-group timezone support, set_timezone MCP tool, and all
related IPC handlers. The implementation now uses the global system
TIMEZONE for all groups, keeping the diff focused on the message
formatting layer: mandatory timezone param in formatMessages(),
<context> header injection, and formatLocalTime/formatCurrentTime
helpers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: drop formatCurrentTime and simplify context header

Address PR review: remove redundant formatCurrentTime() since message
timestamps already carry localized times. Simplify <context> header to
only include timezone name.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: shawnYJ <shawny011717@users.noreply.github.com>
Co-authored-by: Adrian <Lafunamor@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 18:28:29 +02:00
vsabavat 47ad2e654c fix: add-voice-transcription skill drops WhatsApp registerChannel call (#766)
The modify/src/channels/whatsapp.ts patch in the add-voice-transcription
skill was missing the registerChannel() call and its registry import.
When the three-way merge ran, this caused the WhatsApp channel to silently
skip registration on every service restart — messages were never received.

Added the missing import and registerChannel factory with a creds.json
guard, matching the pattern used by the add-whatsapp skill template.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 12:27:25 +02:00
github-actions[bot] 5a4e32623f chore: bump version to 1.2.8 2026-03-06 10:23:23 +00:00
Minwoo Kim ec0e42b034 fix: correct misleading send_message tool description for scheduled tasks (#729)
The send_message tool description incorrectly stated that a scheduled
task's final output is not delivered to the user, instructing agents to
use the MCP tool for any communication. In reality, task-scheduler.ts
unconditionally forwards the agent's result to the user via a streaming
output callback (deps.sendMessage), which is a direct call to the
channel layer — entirely separate from the MCP tool path.

This caused agents following the description to call send_message
explicitly, resulting in duplicate messages: once via MCP and once via
the native streaming callback.

- Remove the incorrect note from the send_message tool description
- Fix the misleading comment at task-scheduler.ts which attributed
  result delivery to the MCP tool rather than the streaming callback
2026-03-06 12:23:09 +02:00
github-actions[bot] ced0068738 docs: update token count to 37.3k tokens · 19% of context window 2026-03-06 10:17:12 +00:00
github-actions[bot] c48314d813 chore: bump version to 1.2.7 2026-03-06 10:17:06 +00:00
Gavriel Cohen 68123fdd81 feat: add update_task tool and return task ID from schedule_task
schedule_task was creating duplicate tasks when users asked to modify
a schedule, because the agent had no way to update an existing task
and didn't know the ID of the task it created. Now schedule_task
generates and returns the task ID, and a new update_task tool allows
modifying prompt, schedule_type, and schedule_value in place.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:16:42 +02:00
gavrielc e14c8580af Update README.md 2026-03-06 10:49:40 +02:00
daniviber 298c3eade4 feat: add /add-ollama skill for local model inference (#712)
* feat: add /add-ollama skill for local model inference

Adds a skill that integrates Ollama as an MCP server, allowing the
container agent to offload tasks to local models (summarization,
translation, general queries) while keeping Claude as orchestrator.

Skill contents:
- ollama-mcp-stdio.ts: stdio MCP server with ollama_list_models and
  ollama_generate tools
- ollama-watch.sh: macOS notification watcher for Ollama activity
- Modifications to index.ts (MCP config) and container-runner.ts
  (log surfacing)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: rename skill from /add-ollama to /add-ollama-tool

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: gavrielc <gabicohen22@yahoo.com>
2026-03-04 23:48:23 +02:00
github-actions[bot] 6ad6328885 chore: bump version to 1.2.6 2026-03-04 20:02:30 +00:00
gavrielc 5955cd6ee5 chore: update claude-agent-sdk to 0.2.68
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:02:11 +02:00
github-actions[bot] 590dc2193c chore: bump version to 1.2.5 2026-03-04 18:51:57 +00:00
glifocat df2bac61f0 fix: format src/index.ts to pass CI prettier check (#711)
Closes #710

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:51:40 +02:00
github-actions[bot] 5aed615ac5 chore: bump version to 1.2.4 2026-03-04 16:07:35 +00:00
gavrielc 1436186c75 fix: rename _chatJid to chatJid in onMessage callback
The underscore prefix convention signals an unused parameter, but it's
now actively used by the sender allowlist logic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 18:07:13 +02:00
github-actions[bot] 3d17e95c1b docs: update token count to 36.4k tokens · 18% of context window 2026-03-04 16:06:05 +00:00
github-actions[bot] cd3c2f06d4 chore: bump version to 1.2.3 2026-03-04 16:05:59 +00:00
Akshan Krithick 4de981b9b9 add sender allowlist for per-chat access control (#705)
* feat: add sender allowlist for per-chat access control

* style: fix prettier formatting
2026-03-04 18:05:45 +02:00
github-actions[bot] a5845a5cf4 docs: update token count to 35.3k tokens · 18% of context window 2026-03-04 14:23:46 +00:00
github-actions[bot] 32d5827fe4 chore: bump version to 1.2.2 2026-03-04 14:23:42 +00:00
Gabi Simons f794185c21 fix: atomic claim prevents scheduled tasks from executing twice (#657)
* fix: atomic claim prevents scheduled tasks from executing twice (#138)

Replace the two-phase getDueTasks() + deferred updateTaskAfterRun() with
an atomic SQLite transaction (claimDueTasks) that advances next_run
BEFORE dispatching tasks to the queue. This eliminates the race window
where subsequent scheduler polls re-discover in-progress tasks.

Key changes:
- claimDueTasks(): SELECT + UPDATE in a single db.transaction(), so no
  poll can read stale next_run values. Once-tasks get next_run=NULL;
  recurring tasks get next_run advanced to the future.
- computeNextRun(): anchors interval tasks to the scheduled time (not
  Date.now()) to prevent cumulative drift. Includes a while-loop to
  skip missed intervals and a guard against invalid interval values.
- updateTaskAfterRun(): simplified to only record last_run/last_result
  since next_run is already handled by the claim.

Closes #138, #211, #300, #578

Co-authored-by: @taslim (PR #601)
Co-authored-by: @baijunjie (Issue #138)
Co-authored-by: @Michaelliv (Issue #300)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* style: apply prettier formatting

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: track running task ID in GroupQueue to prevent duplicate execution (#138)

Previous commits implemented an "atomic claim" approach (claimDueTasks)
that advanced next_run before execution. Per Gavriel's review, this
solved the symptom at the wrong layer and introduced crash-recovery
risks for once-tasks.

This commit reverts claimDueTasks and instead fixes the actual bug:
GroupQueue.enqueueTask() only checked pendingTasks for duplicates, but
running tasks had already been shifted out. Adding runningTaskId to
GroupState closes that gap with a 3-line fix at the correct layer.

The computeNextRun() drift fix is retained, applied post-execution
where it belongs.

Closes #138, #211, #300, #578

Co-authored-by: @taslim (PR #601)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add changelog entry for scheduler duplicate fix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add contributors for scheduler race condition fix

Co-Authored-By: Taslim <9999802+taslim@users.noreply.github.com>
Co-Authored-By: BaiJunjie <7956480+baijunjie@users.noreply.github.com>
Co-Authored-By: Michael <13676242+Michaelliv@users.noreply.github.com>
Co-Authored-By: Kyle Zhike Chen <3477852+kk17@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: gavrielc <gabicohen22@yahoo.com>
Co-authored-by: Taslim <9999802+taslim@users.noreply.github.com>
Co-authored-by: BaiJunjie <7956480+baijunjie@users.noreply.github.com>
Co-authored-by: Michael <13676242+Michaelliv@users.noreply.github.com>
Co-authored-by: Kyle Zhike Chen <3477852+kk17@users.noreply.github.com>
2026-03-04 16:23:29 +02:00
glifocat 03f792bfce feat(skills): add use-local-whisper skill package (#702)
Thanks for the great contribution @glifocat! This is a really well-structured skill — clean package, thorough docs, and solid test coverage. Hope to see more skills like this from you!
2026-03-04 15:56:31 +02:00
glifocat 5e3d8b6c2c fix(whatsapp): add error handling to messages.upsert handler (#695)
Wrap the inner message processing loop in a try-catch to prevent a
single malformed or edge-case message from crashing the entire handler.
Logs the error with remoteJid for debugging while continuing to process
remaining messages in the batch.

Co-authored-by: Ethan M <ethan@nanoclaw.local>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:02:21 +02:00
Sense_wang 58dec06e4c docs: use canonical lowercase clone URL (#681)
Co-authored-by: Hao Wang <haosen.wang@example.com>
2026-03-04 14:59:45 +02:00
gavrielc 8e664e6ba3 Update README.md 2026-03-04 13:26:33 +02:00
github-actions[bot] 21a4eaf6a0 docs: update token count to 35.1k tokens · 18% of context window 2026-03-02 22:36:04 +00:00
github-actions[bot] e7bad73515 chore: bump version to 1.2.1 2026-03-02 22:35:57 +00:00
211 changed files with 9774 additions and 18671 deletions
+1
View File
@@ -0,0 +1 @@
{}
+135
View File
@@ -0,0 +1,135 @@
---
name: add-compact
description: Add /compact command for manual context compaction. Solves context rot in long sessions by forwarding the SDK's built-in /compact slash command. Main-group or trusted sender only.
---
# Add /compact Command
Adds a `/compact` session command that compacts conversation history to fight context rot in long-running sessions. Uses the Claude Agent SDK's built-in `/compact` slash command — no synthetic system prompts.
**Session contract:** `/compact` keeps the same logical session alive. The SDK returns a new session ID after compaction (via the `init` system message), which the agent-runner forwards to the orchestrator as `newSessionId`. No destructive reset occurs — the agent retains summarized context.
## Phase 1: Pre-flight
Check if `src/session-commands.ts` exists:
```bash
test -f src/session-commands.ts && echo "Already applied" || echo "Not applied"
```
If already applied, skip to Phase 3 (Verify).
## Phase 2: Apply Code Changes
Merge the skill branch:
```bash
git fetch upstream skill/compact
git merge upstream/skill/compact
```
> **Note:** `upstream` is the remote pointing to `qwibitai/nanoclaw`. If using a different remote name, substitute accordingly.
This adds:
- `src/session-commands.ts` (extract and authorize session commands)
- `src/session-commands.test.ts` (unit tests for command parsing and auth)
- Session command interception in `src/index.ts` (both `processGroupMessages` and `startMessageLoop`)
- Slash command handling in `container/agent-runner/src/index.ts`
### Validate
```bash
npm test
npm run build
```
### Rebuild container
```bash
./container/build.sh
```
### Restart service
```bash
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
## Phase 3: Verify
### Integration Test
1. Start NanoClaw in dev mode: `npm run dev`
2. From the **main group** (self-chat), send exactly: `/compact`
3. Verify:
- The agent acknowledges compaction (e.g., "Conversation compacted.")
- The session continues — send a follow-up message and verify the agent responds coherently
- A conversation archive is written to `groups/{folder}/conversations/` (by the PreCompact hook)
- Container logs show `Compact boundary observed` (confirms SDK actually compacted)
- If `compact_boundary` was NOT observed, the response says "compact_boundary was not observed"
4. From a **non-main group** as a non-admin user, send: `@<assistant> /compact`
5. Verify:
- The bot responds with "Session commands require admin access."
- No compaction occurs, no container is spawned for the command
6. From a **non-main group** as the admin (device owner / `is_from_me`), send: `@<assistant> /compact`
7. Verify:
- Compaction proceeds normally (same behavior as main group)
8. While an **active container** is running for the main group, send `/compact`
9. Verify:
- The active container is signaled to close (authorized senders only — untrusted senders cannot kill in-flight work)
- Compaction proceeds via a new container once the active one exits
- The command is not dropped (no cursor race)
10. Send a normal message, then `/compact`, then another normal message in quick succession (same polling batch):
11. Verify:
- Pre-compact messages are sent to the agent first (check container logs for two `runAgent` calls)
- Compaction proceeds after pre-compact messages are processed
- Messages **after** `/compact` in the batch are preserved (cursor advances to `/compact`'s timestamp only) and processed on the next poll cycle
12. From a **non-main group** as a non-admin user, send `@<assistant> /compact`:
13. Verify:
- Denial message is sent ("Session commands require admin access.")
- The `/compact` is consumed (cursor advanced) — it does NOT replay on future polls
- Other messages in the same batch are also consumed (cursor is a high-water mark — this is an accepted tradeoff for the narrow edge case of denied `/compact` + other messages in the same polling interval)
- No container is killed or interrupted
14. From a **non-main group** (with `requiresTrigger` enabled) as a non-admin user, send bare `/compact` (no trigger prefix):
15. Verify:
- No denial message is sent (trigger policy prevents untrusted bot responses)
- The `/compact` is consumed silently
- Note: in groups where `requiresTrigger` is `false`, a denial message IS sent because the sender is considered reachable
16. After compaction, verify **no auto-compaction** behavior — only manual `/compact` triggers it
### Validation on Fresh Clone
```bash
git clone <your-fork> /tmp/nanoclaw-test
cd /tmp/nanoclaw-test
claude # then run /add-compact
npm run build
npm test
./container/build.sh
# Manual: send /compact from main group, verify compaction + continuation
# Manual: send @<assistant> /compact from non-main as non-admin, verify denial
# Manual: send @<assistant> /compact from non-main as admin, verify allowed
# Manual: verify no auto-compaction behavior
```
## Security Constraints
- **Main-group or trusted/admin sender only.** The main group is the user's private self-chat and is trusted (see `docs/SECURITY.md`). Non-main groups are untrusted — a careless or malicious user could wipe the agent's short-term memory. However, the device owner (`is_from_me`) is always trusted and can compact from any group.
- **No auto-compaction.** This skill implements manual compaction only. Automatic threshold-based compaction is a separate concern and should be a separate skill.
- **No config file.** NanoClaw's philosophy is customization through code changes, not configuration sprawl.
- **Transcript archived before compaction.** The existing `PreCompact` hook in the agent-runner archives the full transcript to `conversations/` before the SDK compacts it.
- **Session continues after compaction.** This is not a destructive reset. The conversation continues with summarized context.
## What This Does NOT Do
- No automatic compaction threshold (add separately if desired)
- No `/clear` command (separate skill, separate semantics — `/clear` is a destructive reset)
- No cross-group compaction (each group's session is isolated)
- No changes to the container image, Dockerfile, or build script
## Troubleshooting
- **"Session commands require admin access"**: Only the device owner (`is_from_me`) or main-group senders can use `/compact`. Other users are denied.
- **No compact_boundary in logs**: The SDK may not emit this event in all versions. Check the agent-runner logs for the warning message. Compaction may still have succeeded.
- **Pre-compact failure**: If messages before `/compact` fail to process, the error message says "Failed to process messages before /compact." The cursor advances past sent output to prevent duplicates; `/compact` remains pending for the next attempt.
+36 -39
View File
@@ -1,12 +1,17 @@
---
name: add-discord
description: Add Discord bot channel integration to NanoClaw.
---
# Add Discord Channel
This skill adds Discord support to NanoClaw using the skills engine for deterministic code changes, then walks through interactive setup.
This skill adds Discord support to NanoClaw, then walks through interactive setup.
## Phase 1: Pre-flight
### Check if already applied
Read `.nanoclaw/state.yaml`. If `discord` is in `applied_skills`, skip to Phase 3 (Setup). The code changes are already in place.
Check if `src/channels/discord.ts` exists. If it does, skip to Phase 3 (Setup). The code changes are already in place.
### Ask the user
@@ -18,39 +23,44 @@ If they have one, collect it now. If not, we'll create one in Phase 3.
## Phase 2: Apply Code Changes
Run the skills engine to apply this skill's code package. The package files are in this directory alongside this SKILL.md.
### Initialize skills system (if needed)
If `.nanoclaw/` directory doesn't exist yet:
### Ensure channel remote
```bash
npx tsx scripts/apply-skill.ts --init
git remote -v
```
Or call `initSkillsSystem()` from `skills-engine/migrate.ts`.
### Apply the skill
If `discord` is missing, add it:
```bash
npx tsx scripts/apply-skill.ts .claude/skills/add-discord
git remote add discord https://github.com/qwibitai/nanoclaw-discord.git
```
This deterministically:
- Adds `src/channels/discord.ts` (DiscordChannel class with self-registration via `registerChannel`)
- Adds `src/channels/discord.test.ts` (unit tests with discord.js mock)
- Appends `import './discord.js'` to the channel barrel file `src/channels/index.ts`
- Installs the `discord.js` npm dependency
- Records the application in `.nanoclaw/state.yaml`
### Merge the skill branch
If the apply reports merge conflicts, read the intent file:
- `modify/src/channels/index.ts.intent.md` — what changed and invariants
```bash
git fetch discord main
git merge discord/main || {
git checkout --theirs package-lock.json
git add package-lock.json
git merge --continue
}
```
This merges in:
- `src/channels/discord.ts` (DiscordChannel class with self-registration via `registerChannel`)
- `src/channels/discord.test.ts` (unit tests with discord.js mock)
- `import './discord.js'` appended to the channel barrel file `src/channels/index.ts`
- `discord.js` npm dependency in `package.json`
- `DISCORD_BOT_TOKEN` in `.env.example`
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
### Validate code changes
```bash
npm test
npm install
npm run build
npx vitest run src/channels/discord.test.ts
```
All tests must pass (including the new Discord tests) and build must be clean before proceeding.
@@ -120,31 +130,18 @@ Wait for the user to provide the channel ID (format: `dc:1234567890123456`).
### Register the channel
Use the IPC register flow or register directly. The channel ID, name, and folder name are needed.
The channel ID, name, and folder name are needed. Use `npx tsx setup/index.ts --step register` with the appropriate flags.
For a main channel (responds to all messages):
```typescript
registerGroup("dc:<channel-id>", {
name: "<server-name> #<channel-name>",
folder: "discord_main",
trigger: `@${ASSISTANT_NAME}`,
added_at: new Date().toISOString(),
requiresTrigger: false,
isMain: true,
});
```bash
npx tsx setup/index.ts --step register -- --jid "dc:<channel-id>" --name "<server-name> #<channel-name>" --folder "discord_main" --trigger "@${ASSISTANT_NAME}" --channel discord --no-trigger-required --is-main
```
For additional channels (trigger-only):
```typescript
registerGroup("dc:<channel-id>", {
name: "<server-name> #<channel-name>",
folder: "discord_<channel-name>",
trigger: `@${ASSISTANT_NAME}`,
added_at: new Date().toISOString(),
requiresTrigger: true,
});
```bash
npx tsx setup/index.ts --step register -- --jid "dc:<channel-id>" --name "<server-name> #<channel-name>" --folder "discord_<channel-name>" --trigger "@${ASSISTANT_NAME}" --channel discord
```
## Phase 5: Verify
@@ -1,776 +0,0 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
// --- Mocks ---
// Mock registry (registerChannel runs at import time)
vi.mock('./registry.js', () => ({ registerChannel: vi.fn() }));
// Mock env reader (used by the factory, not needed in unit tests)
vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})) }));
// Mock config
vi.mock('../config.js', () => ({
ASSISTANT_NAME: 'Andy',
TRIGGER_PATTERN: /^@Andy\b/i,
}));
// Mock logger
vi.mock('../logger.js', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
// --- discord.js mock ---
type Handler = (...args: any[]) => any;
const clientRef = vi.hoisted(() => ({ current: null as any }));
vi.mock('discord.js', () => {
const Events = {
MessageCreate: 'messageCreate',
ClientReady: 'ready',
Error: 'error',
};
const GatewayIntentBits = {
Guilds: 1,
GuildMessages: 2,
MessageContent: 4,
DirectMessages: 8,
};
class MockClient {
eventHandlers = new Map<string, Handler[]>();
user: any = { id: '999888777', tag: 'Andy#1234' };
private _ready = false;
constructor(_opts: any) {
clientRef.current = this;
}
on(event: string, handler: Handler) {
const existing = this.eventHandlers.get(event) || [];
existing.push(handler);
this.eventHandlers.set(event, existing);
return this;
}
once(event: string, handler: Handler) {
return this.on(event, handler);
}
async login(_token: string) {
this._ready = true;
// Fire the ready event
const readyHandlers = this.eventHandlers.get('ready') || [];
for (const h of readyHandlers) {
h({ user: this.user });
}
}
isReady() {
return this._ready;
}
channels = {
fetch: vi.fn().mockResolvedValue({
send: vi.fn().mockResolvedValue(undefined),
sendTyping: vi.fn().mockResolvedValue(undefined),
}),
};
destroy() {
this._ready = false;
}
}
// Mock TextChannel type
class TextChannel {}
return {
Client: MockClient,
Events,
GatewayIntentBits,
TextChannel,
};
});
import { DiscordChannel, DiscordChannelOpts } from './discord.js';
// --- Test helpers ---
function createTestOpts(
overrides?: Partial<DiscordChannelOpts>,
): DiscordChannelOpts {
return {
onMessage: vi.fn(),
onChatMetadata: vi.fn(),
registeredGroups: vi.fn(() => ({
'dc:1234567890123456': {
name: 'Test Server #general',
folder: 'test-server',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
},
})),
...overrides,
};
}
function createMessage(overrides: {
channelId?: string;
content?: string;
authorId?: string;
authorUsername?: string;
authorDisplayName?: string;
memberDisplayName?: string;
isBot?: boolean;
guildName?: string;
channelName?: string;
messageId?: string;
createdAt?: Date;
attachments?: Map<string, any>;
reference?: { messageId?: string };
mentionsBotId?: boolean;
}) {
const channelId = overrides.channelId ?? '1234567890123456';
const authorId = overrides.authorId ?? '55512345';
const botId = '999888777'; // matches mock client user id
const mentionsMap = new Map();
if (overrides.mentionsBotId) {
mentionsMap.set(botId, { id: botId });
}
return {
channelId,
id: overrides.messageId ?? 'msg_001',
content: overrides.content ?? 'Hello everyone',
createdAt: overrides.createdAt ?? new Date('2024-01-01T00:00:00.000Z'),
author: {
id: authorId,
username: overrides.authorUsername ?? 'alice',
displayName: overrides.authorDisplayName ?? 'Alice',
bot: overrides.isBot ?? false,
},
member: overrides.memberDisplayName
? { displayName: overrides.memberDisplayName }
: null,
guild: overrides.guildName
? { name: overrides.guildName }
: null,
channel: {
name: overrides.channelName ?? 'general',
messages: {
fetch: vi.fn().mockResolvedValue({
author: { username: 'Bob', displayName: 'Bob' },
member: { displayName: 'Bob' },
}),
},
},
mentions: {
users: mentionsMap,
},
attachments: overrides.attachments ?? new Map(),
reference: overrides.reference ?? null,
};
}
function currentClient() {
return clientRef.current;
}
async function triggerMessage(message: any) {
const handlers = currentClient().eventHandlers.get('messageCreate') || [];
for (const h of handlers) await h(message);
}
// --- Tests ---
describe('DiscordChannel', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
// --- Connection lifecycle ---
describe('connection lifecycle', () => {
it('resolves connect() when client is ready', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
expect(channel.isConnected()).toBe(true);
});
it('registers message handlers on connect', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
expect(currentClient().eventHandlers.has('messageCreate')).toBe(true);
expect(currentClient().eventHandlers.has('error')).toBe(true);
expect(currentClient().eventHandlers.has('ready')).toBe(true);
});
it('disconnects cleanly', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
expect(channel.isConnected()).toBe(true);
await channel.disconnect();
expect(channel.isConnected()).toBe(false);
});
it('isConnected() returns false before connect', () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
expect(channel.isConnected()).toBe(false);
});
});
// --- Text message handling ---
describe('text message handling', () => {
it('delivers message for registered channel', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
content: 'Hello everyone',
guildName: 'Test Server',
channelName: 'general',
});
await triggerMessage(msg);
expect(opts.onChatMetadata).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.any(String),
'Test Server #general',
'discord',
true,
);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
id: 'msg_001',
chat_jid: 'dc:1234567890123456',
sender: '55512345',
sender_name: 'Alice',
content: 'Hello everyone',
is_from_me: false,
}),
);
});
it('only emits metadata for unregistered channels', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
channelId: '9999999999999999',
content: 'Unknown channel',
guildName: 'Other Server',
});
await triggerMessage(msg);
expect(opts.onChatMetadata).toHaveBeenCalledWith(
'dc:9999999999999999',
expect.any(String),
expect.any(String),
'discord',
true,
);
expect(opts.onMessage).not.toHaveBeenCalled();
});
it('ignores bot messages', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({ isBot: true, content: 'I am a bot' });
await triggerMessage(msg);
expect(opts.onMessage).not.toHaveBeenCalled();
expect(opts.onChatMetadata).not.toHaveBeenCalled();
});
it('uses member displayName when available (server nickname)', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
content: 'Hi',
memberDisplayName: 'Alice Nickname',
authorDisplayName: 'Alice Global',
guildName: 'Server',
});
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({ sender_name: 'Alice Nickname' }),
);
});
it('falls back to author displayName when no member', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
content: 'Hi',
memberDisplayName: undefined,
authorDisplayName: 'Alice Global',
guildName: 'Server',
});
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({ sender_name: 'Alice Global' }),
);
});
it('uses sender name for DM chats (no guild)', async () => {
const opts = createTestOpts({
registeredGroups: vi.fn(() => ({
'dc:1234567890123456': {
name: 'DM',
folder: 'dm',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
},
})),
});
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
content: 'Hello',
guildName: undefined,
authorDisplayName: 'Alice',
});
await triggerMessage(msg);
expect(opts.onChatMetadata).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.any(String),
'Alice',
'discord',
false,
);
});
it('uses guild name + channel name for server messages', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
content: 'Hello',
guildName: 'My Server',
channelName: 'bot-chat',
});
await triggerMessage(msg);
expect(opts.onChatMetadata).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.any(String),
'My Server #bot-chat',
'discord',
true,
);
});
});
// --- @mention translation ---
describe('@mention translation', () => {
it('translates <@botId> mention to trigger format', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
content: '<@999888777> what time is it?',
mentionsBotId: true,
guildName: 'Server',
});
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: '@Andy what time is it?',
}),
);
});
it('does not translate if message already matches trigger', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
content: '@Andy hello <@999888777>',
mentionsBotId: true,
guildName: 'Server',
});
await triggerMessage(msg);
// Should NOT prepend @Andy — already starts with trigger
// But the <@botId> should still be stripped
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: '@Andy hello',
}),
);
});
it('does not translate when bot is not mentioned', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
content: 'hello everyone',
guildName: 'Server',
});
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: 'hello everyone',
}),
);
});
it('handles <@!botId> (nickname mention format)', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
content: '<@!999888777> check this',
mentionsBotId: true,
guildName: 'Server',
});
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: '@Andy check this',
}),
);
});
});
// --- Attachments ---
describe('attachments', () => {
it('stores image attachment with placeholder', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const attachments = new Map([
['att1', { name: 'photo.png', contentType: 'image/png' }],
]);
const msg = createMessage({
content: '',
attachments,
guildName: 'Server',
});
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: '[Image: photo.png]',
}),
);
});
it('stores video attachment with placeholder', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const attachments = new Map([
['att1', { name: 'clip.mp4', contentType: 'video/mp4' }],
]);
const msg = createMessage({
content: '',
attachments,
guildName: 'Server',
});
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: '[Video: clip.mp4]',
}),
);
});
it('stores file attachment with placeholder', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const attachments = new Map([
['att1', { name: 'report.pdf', contentType: 'application/pdf' }],
]);
const msg = createMessage({
content: '',
attachments,
guildName: 'Server',
});
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: '[File: report.pdf]',
}),
);
});
it('includes text content with attachments', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const attachments = new Map([
['att1', { name: 'photo.jpg', contentType: 'image/jpeg' }],
]);
const msg = createMessage({
content: 'Check this out',
attachments,
guildName: 'Server',
});
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: 'Check this out\n[Image: photo.jpg]',
}),
);
});
it('handles multiple attachments', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const attachments = new Map([
['att1', { name: 'a.png', contentType: 'image/png' }],
['att2', { name: 'b.txt', contentType: 'text/plain' }],
]);
const msg = createMessage({
content: '',
attachments,
guildName: 'Server',
});
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: '[Image: a.png]\n[File: b.txt]',
}),
);
});
});
// --- Reply context ---
describe('reply context', () => {
it('includes reply author in content', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
content: 'I agree with that',
reference: { messageId: 'original_msg_id' },
guildName: 'Server',
});
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: '[Reply to Bob] I agree with that',
}),
);
});
});
// --- sendMessage ---
describe('sendMessage', () => {
it('sends message via channel', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
await channel.sendMessage('dc:1234567890123456', 'Hello');
const fetchedChannel = await currentClient().channels.fetch('1234567890123456');
expect(currentClient().channels.fetch).toHaveBeenCalledWith('1234567890123456');
});
it('strips dc: prefix from JID', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
await channel.sendMessage('dc:9876543210', 'Test');
expect(currentClient().channels.fetch).toHaveBeenCalledWith('9876543210');
});
it('handles send failure gracefully', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
currentClient().channels.fetch.mockRejectedValueOnce(
new Error('Channel not found'),
);
// Should not throw
await expect(
channel.sendMessage('dc:1234567890123456', 'Will fail'),
).resolves.toBeUndefined();
});
it('does nothing when client is not initialized', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
// Don't connect — client is null
await channel.sendMessage('dc:1234567890123456', 'No client');
// No error, no API call
});
it('splits messages exceeding 2000 characters', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const mockChannel = {
send: vi.fn().mockResolvedValue(undefined),
sendTyping: vi.fn(),
};
currentClient().channels.fetch.mockResolvedValue(mockChannel);
const longText = 'x'.repeat(3000);
await channel.sendMessage('dc:1234567890123456', longText);
expect(mockChannel.send).toHaveBeenCalledTimes(2);
expect(mockChannel.send).toHaveBeenNthCalledWith(1, 'x'.repeat(2000));
expect(mockChannel.send).toHaveBeenNthCalledWith(2, 'x'.repeat(1000));
});
});
// --- ownsJid ---
describe('ownsJid', () => {
it('owns dc: JIDs', () => {
const channel = new DiscordChannel('test-token', createTestOpts());
expect(channel.ownsJid('dc:1234567890123456')).toBe(true);
});
it('does not own WhatsApp group JIDs', () => {
const channel = new DiscordChannel('test-token', createTestOpts());
expect(channel.ownsJid('12345@g.us')).toBe(false);
});
it('does not own Telegram JIDs', () => {
const channel = new DiscordChannel('test-token', createTestOpts());
expect(channel.ownsJid('tg:123456789')).toBe(false);
});
it('does not own unknown JID formats', () => {
const channel = new DiscordChannel('test-token', createTestOpts());
expect(channel.ownsJid('random-string')).toBe(false);
});
});
// --- setTyping ---
describe('setTyping', () => {
it('sends typing indicator when isTyping is true', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const mockChannel = {
send: vi.fn(),
sendTyping: vi.fn().mockResolvedValue(undefined),
};
currentClient().channels.fetch.mockResolvedValue(mockChannel);
await channel.setTyping('dc:1234567890123456', true);
expect(mockChannel.sendTyping).toHaveBeenCalled();
});
it('does nothing when isTyping is false', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
await channel.setTyping('dc:1234567890123456', false);
// channels.fetch should NOT be called
expect(currentClient().channels.fetch).not.toHaveBeenCalled();
});
it('does nothing when client is not initialized', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
// Don't connect
await channel.setTyping('dc:1234567890123456', true);
// No error
});
});
// --- Channel properties ---
describe('channel properties', () => {
it('has name "discord"', () => {
const channel = new DiscordChannel('test-token', createTestOpts());
expect(channel.name).toBe('discord');
});
});
});
@@ -1,250 +0,0 @@
import { Client, Events, GatewayIntentBits, Message, TextChannel } from 'discord.js';
import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js';
import { readEnvFile } from '../env.js';
import { logger } from '../logger.js';
import { registerChannel, ChannelOpts } from './registry.js';
import {
Channel,
OnChatMetadata,
OnInboundMessage,
RegisteredGroup,
} from '../types.js';
export interface DiscordChannelOpts {
onMessage: OnInboundMessage;
onChatMetadata: OnChatMetadata;
registeredGroups: () => Record<string, RegisteredGroup>;
}
export class DiscordChannel implements Channel {
name = 'discord';
private client: Client | null = null;
private opts: DiscordChannelOpts;
private botToken: string;
constructor(botToken: string, opts: DiscordChannelOpts) {
this.botToken = botToken;
this.opts = opts;
}
async connect(): Promise<void> {
this.client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.DirectMessages,
],
});
this.client.on(Events.MessageCreate, async (message: Message) => {
// Ignore bot messages (including own)
if (message.author.bot) return;
const channelId = message.channelId;
const chatJid = `dc:${channelId}`;
let content = message.content;
const timestamp = message.createdAt.toISOString();
const senderName =
message.member?.displayName ||
message.author.displayName ||
message.author.username;
const sender = message.author.id;
const msgId = message.id;
// Determine chat name
let chatName: string;
if (message.guild) {
const textChannel = message.channel as TextChannel;
chatName = `${message.guild.name} #${textChannel.name}`;
} else {
chatName = senderName;
}
// Translate Discord @bot mentions into TRIGGER_PATTERN format.
// Discord mentions look like <@botUserId> — these won't match
// TRIGGER_PATTERN (e.g., ^@Andy\b), so we prepend the trigger
// when the bot is @mentioned.
if (this.client?.user) {
const botId = this.client.user.id;
const isBotMentioned =
message.mentions.users.has(botId) ||
content.includes(`<@${botId}>`) ||
content.includes(`<@!${botId}>`);
if (isBotMentioned) {
// Strip the <@botId> mention to avoid visual clutter
content = content
.replace(new RegExp(`<@!?${botId}>`, 'g'), '')
.trim();
// Prepend trigger if not already present
if (!TRIGGER_PATTERN.test(content)) {
content = `@${ASSISTANT_NAME} ${content}`;
}
}
}
// Handle attachments — store placeholders so the agent knows something was sent
if (message.attachments.size > 0) {
const attachmentDescriptions = [...message.attachments.values()].map((att) => {
const contentType = att.contentType || '';
if (contentType.startsWith('image/')) {
return `[Image: ${att.name || 'image'}]`;
} else if (contentType.startsWith('video/')) {
return `[Video: ${att.name || 'video'}]`;
} else if (contentType.startsWith('audio/')) {
return `[Audio: ${att.name || 'audio'}]`;
} else {
return `[File: ${att.name || 'file'}]`;
}
});
if (content) {
content = `${content}\n${attachmentDescriptions.join('\n')}`;
} else {
content = attachmentDescriptions.join('\n');
}
}
// Handle reply context — include who the user is replying to
if (message.reference?.messageId) {
try {
const repliedTo = await message.channel.messages.fetch(
message.reference.messageId,
);
const replyAuthor =
repliedTo.member?.displayName ||
repliedTo.author.displayName ||
repliedTo.author.username;
content = `[Reply to ${replyAuthor}] ${content}`;
} catch {
// Referenced message may have been deleted
}
}
// Store chat metadata for discovery
const isGroup = message.guild !== null;
this.opts.onChatMetadata(chatJid, timestamp, chatName, 'discord', isGroup);
// Only deliver full message for registered groups
const group = this.opts.registeredGroups()[chatJid];
if (!group) {
logger.debug(
{ chatJid, chatName },
'Message from unregistered Discord channel',
);
return;
}
// Deliver message — startMessageLoop() will pick it up
this.opts.onMessage(chatJid, {
id: msgId,
chat_jid: chatJid,
sender,
sender_name: senderName,
content,
timestamp,
is_from_me: false,
});
logger.info(
{ chatJid, chatName, sender: senderName },
'Discord message stored',
);
});
// Handle errors gracefully
this.client.on(Events.Error, (err) => {
logger.error({ err: err.message }, 'Discord client error');
});
return new Promise<void>((resolve) => {
this.client!.once(Events.ClientReady, (readyClient) => {
logger.info(
{ username: readyClient.user.tag, id: readyClient.user.id },
'Discord bot connected',
);
console.log(`\n Discord bot: ${readyClient.user.tag}`);
console.log(
` Use /chatid command or check channel IDs in Discord settings\n`,
);
resolve();
});
this.client!.login(this.botToken);
});
}
async sendMessage(jid: string, text: string): Promise<void> {
if (!this.client) {
logger.warn('Discord client not initialized');
return;
}
try {
const channelId = jid.replace(/^dc:/, '');
const channel = await this.client.channels.fetch(channelId);
if (!channel || !('send' in channel)) {
logger.warn({ jid }, 'Discord channel not found or not text-based');
return;
}
const textChannel = channel as TextChannel;
// Discord has a 2000 character limit per message — split if needed
const MAX_LENGTH = 2000;
if (text.length <= MAX_LENGTH) {
await textChannel.send(text);
} else {
for (let i = 0; i < text.length; i += MAX_LENGTH) {
await textChannel.send(text.slice(i, i + MAX_LENGTH));
}
}
logger.info({ jid, length: text.length }, 'Discord message sent');
} catch (err) {
logger.error({ jid, err }, 'Failed to send Discord message');
}
}
isConnected(): boolean {
return this.client !== null && this.client.isReady();
}
ownsJid(jid: string): boolean {
return jid.startsWith('dc:');
}
async disconnect(): Promise<void> {
if (this.client) {
this.client.destroy();
this.client = null;
logger.info('Discord bot stopped');
}
}
async setTyping(jid: string, isTyping: boolean): Promise<void> {
if (!this.client || !isTyping) return;
try {
const channelId = jid.replace(/^dc:/, '');
const channel = await this.client.channels.fetch(channelId);
if (channel && 'sendTyping' in channel) {
await (channel as TextChannel).sendTyping();
}
} catch (err) {
logger.debug({ jid, err }, 'Failed to send Discord typing indicator');
}
}
}
registerChannel('discord', (opts: ChannelOpts) => {
const envVars = readEnvFile(['DISCORD_BOT_TOKEN']);
const token =
process.env.DISCORD_BOT_TOKEN || envVars.DISCORD_BOT_TOKEN || '';
if (!token) {
logger.warn('Discord: DISCORD_BOT_TOKEN not set');
return null;
}
return new DiscordChannel(token, opts);
});
-17
View File
@@ -1,17 +0,0 @@
skill: discord
version: 1.0.0
description: "Discord Bot integration via discord.js"
core_version: 0.1.0
adds:
- src/channels/discord.ts
- src/channels/discord.test.ts
modifies:
- src/channels/index.ts
structured:
npm_dependencies:
discord.js: "^14.18.0"
env_additions:
- DISCORD_BOT_TOKEN
conflicts: []
depends: []
test: "npx vitest run src/channels/discord.test.ts"
@@ -1,13 +0,0 @@
// Channel self-registration barrel file.
// Each import triggers the channel module's registerChannel() call.
// discord
import './discord.js';
// gmail
// slack
// telegram
// whatsapp
@@ -1,7 +0,0 @@
# Intent: Add Discord channel import
Add `import './discord.js';` to the channel barrel file so the Discord
module self-registers with the channel registry on startup.
This is an append-only change — existing import lines for other channels
must be preserved.
@@ -1,69 +0,0 @@
import { describe, expect, it } from 'vitest';
import fs from 'fs';
import path from 'path';
describe('discord skill package', () => {
const skillDir = path.resolve(__dirname, '..');
it('has a valid manifest', () => {
const manifestPath = path.join(skillDir, 'manifest.yaml');
expect(fs.existsSync(manifestPath)).toBe(true);
const content = fs.readFileSync(manifestPath, 'utf-8');
expect(content).toContain('skill: discord');
expect(content).toContain('version: 1.0.0');
expect(content).toContain('discord.js');
});
it('has all files declared in adds', () => {
const channelFile = path.join(
skillDir,
'add',
'src',
'channels',
'discord.ts',
);
expect(fs.existsSync(channelFile)).toBe(true);
const content = fs.readFileSync(channelFile, 'utf-8');
expect(content).toContain('class DiscordChannel');
expect(content).toContain('implements Channel');
expect(content).toContain("registerChannel('discord'");
// Test file for the channel
const testFile = path.join(
skillDir,
'add',
'src',
'channels',
'discord.test.ts',
);
expect(fs.existsSync(testFile)).toBe(true);
const testContent = fs.readFileSync(testFile, 'utf-8');
expect(testContent).toContain("describe('DiscordChannel'");
});
it('has all files declared in modifies', () => {
// Channel barrel file
const indexFile = path.join(
skillDir,
'modify',
'src',
'channels',
'index.ts',
);
expect(fs.existsSync(indexFile)).toBe(true);
const indexContent = fs.readFileSync(indexFile, 'utf-8');
expect(indexContent).toContain("import './discord.js'");
});
it('has intent files for modified files', () => {
expect(
fs.existsSync(
path.join(skillDir, 'modify', 'src', 'channels', 'index.ts.intent.md'),
),
).toBe(true);
});
});
+289
View File
@@ -0,0 +1,289 @@
---
name: add-emacs
description: Add Emacs as a channel. Opens an interactive chat buffer and org-mode integration so you can talk to NanoClaw from within Emacs (Doom, Spacemacs, or vanilla). Uses a local HTTP bridge — no bot token or external service needed.
---
# Add Emacs Channel
This skill adds Emacs support to NanoClaw, then walks through interactive setup.
Works with Doom Emacs, Spacemacs, and vanilla Emacs 27.1+.
## What you can do with this
- **Ask while coding** — open the chat buffer (`C-c n c` / `SPC N c`), ask about a function or error without leaving Emacs
- **Code review** — select a region and send it with `nanoclaw-org-send`; the response appears as a child heading inline in your org file
- **Meeting notes** — send an org agenda entry; get a summary or action item list back as a child node
- **Draft writing** — send org prose; receive revisions or continuations in place
- **Research capture** — ask a question directly in your org notes; the answer lands exactly where you need it
- **Schedule tasks** — ask Andy to set a reminder or create a scheduled NanoClaw task (e.g. "remind me tomorrow to review the PR")
## Phase 1: Pre-flight
### Check if already applied
Check if `src/channels/emacs.ts` exists:
```bash
test -f src/channels/emacs.ts && echo "already applied" || echo "not applied"
```
If it exists, skip to Phase 3 (Setup). The code changes are already in place.
## Phase 2: Apply Code Changes
### Ensure the upstream remote
```bash
git remote -v
```
If an `upstream` remote pointing to `https://github.com/qwibitai/nanoclaw.git` is missing,
add it:
```bash
git remote add upstream https://github.com/qwibitai/nanoclaw.git
```
### Merge the skill branch
```bash
git fetch upstream skill/emacs
git merge upstream/skill/emacs
```
If there are merge conflicts on `package-lock.json`, resolve them by accepting the incoming
version and continuing:
```bash
git checkout --theirs package-lock.json
git add package-lock.json
git merge --continue
```
For any other conflict, read the conflicted file and reconcile both sides manually.
This adds:
- `src/channels/emacs.ts``EmacsBridgeChannel` HTTP server (port 8766)
- `src/channels/emacs.test.ts` — unit tests
- `emacs/nanoclaw.el` — Emacs Lisp package (`nanoclaw-chat`, `nanoclaw-org-send`)
- `import './emacs.js'` appended to `src/channels/index.ts`
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
### Validate code changes
```bash
npm run build
npx vitest run src/channels/emacs.test.ts
```
Build must be clean and tests must pass before proceeding.
## Phase 3: Setup
### Configure environment (optional)
The channel works out of the box with defaults. Add to `.env` only if you need non-defaults:
```bash
EMACS_CHANNEL_PORT=8766 # default — change if 8766 is already in use
EMACS_AUTH_TOKEN=<random> # optional — locks the endpoint to Emacs only
```
If you change or add values, sync to the container environment:
```bash
mkdir -p data/env && cp .env data/env/env
```
### Configure Emacs
The `nanoclaw.el` package requires only Emacs 27.1+ built-in libraries (`url`, `json`, `org`) — no package manager setup needed.
AskUserQuestion: Which Emacs distribution are you using?
- **Doom Emacs** - config.el with map! keybindings
- **Spacemacs** - dotspacemacs/user-config in ~/.spacemacs
- **Vanilla Emacs / other** - init.el with global-set-key
**Doom Emacs** — add to `~/.config/doom/config.el` (or `~/.doom.d/config.el`):
```elisp
;; NanoClaw — personal AI assistant channel
(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)
```
Then reload: `M-x doom/reload`
**Spacemacs** — add to `dotspacemacs/user-config` in `~/.spacemacs`:
```elisp
;; NanoClaw — personal AI assistant channel
(load-file "~/src/nanoclaw/emacs/nanoclaw.el")
(spacemacs/set-leader-keys "aNc" #'nanoclaw-chat)
(spacemacs/set-leader-keys "aNo" #'nanoclaw-org-send)
```
Then reload: `M-x dotspacemacs/sync-configuration-layers` or restart Emacs.
**Vanilla Emacs** — add to `~/.emacs.d/init.el` (or `~/.emacs`):
```elisp
;; NanoClaw — personal AI assistant channel
(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)
```
Then reload: `M-x eval-buffer` or restart Emacs.
If `EMACS_AUTH_TOKEN` was set, also add (any distribution):
```elisp
(setq nanoclaw-auth-token "<your-token>")
```
If `EMACS_CHANNEL_PORT` was changed from the default, also add:
```elisp
(setq nanoclaw-port <your-port>)
```
### Restart NanoClaw
```bash
npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
## Phase 4: Verify
### Test the HTTP endpoint
```bash
curl -s "http://localhost:8766/api/messages?since=0"
```
Expected: `{"messages":[]}`
If you set `EMACS_AUTH_TOKEN`:
```bash
curl -s -H "Authorization: Bearer <token>" "http://localhost:8766/api/messages?since=0"
```
### Test from Emacs
Tell the user:
> 1. Open the chat buffer with your keybinding (`SPC N c`, `SPC a N c`, or `C-c n c`)
> 2. Type a message and press `RET`
> 3. A response from Andy should appear within a few seconds
>
> For org-mode: open any `.org` file, position the cursor on a heading, and use `SPC N o` / `SPC a N o` / `C-c n o`
### Check logs if needed
```bash
tail -f logs/nanoclaw.log
```
Look for `Emacs channel listening` at startup and `Emacs message received` when a message is sent.
## Troubleshooting
### Port already in use
```
Error: listen EADDRINUSE: address already in use :::8766
```
Either a stale NanoClaw process is running, or 8766 is taken by another app.
Find and kill the stale process:
```bash
lsof -ti :8766 | xargs kill -9
```
Or change the port in `.env` (`EMACS_CHANNEL_PORT=8767`) and update `nanoclaw-port` in Emacs config.
### No response from agent
Check:
1. NanoClaw is running: `launchctl list | grep nanoclaw` (macOS) or `systemctl --user status nanoclaw` (Linux)
2. Emacs group is registered: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid = 'emacs:default'"`
3. Logs show activity: `tail -50 logs/nanoclaw.log`
If the group is not registered, it will be created automatically on the next NanoClaw restart.
### Auth token mismatch (401 Unauthorized)
Verify the token in Emacs matches `.env`:
```elisp
;; M-x describe-variable RET nanoclaw-auth-token RET
```
Must exactly match `EMACS_AUTH_TOKEN` in `.env`.
### nanoclaw.el not loading
Check the path is correct:
```bash
ls ~/src/nanoclaw/emacs/nanoclaw.el
```
If NanoClaw is cloned elsewhere, update the `load`/`load-file` path in your Emacs config.
## After Setup
If running `npm run dev` while the service is active:
```bash
# macOS:
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
npm run dev
# When done testing:
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
# Linux:
# systemctl --user stop nanoclaw
# npm run dev
# systemctl --user start nanoclaw
```
## Agent Formatting
The Emacs bridge converts markdown → org-mode automatically. Agents should
output standard markdown — **not** org-mode syntax. The conversion handles:
| Markdown | Org-mode |
|----------|----------|
| `**bold**` | `*bold*` |
| `*italic*` | `/italic/` |
| `~~text~~` | `+text+` |
| `` `code` `` | `~code~` |
| ` ```lang ` | `#+begin_src lang` |
If an agent outputs org-mode directly, bold/italic/etc. will be double-converted
and render incorrectly.
## Removal
To remove the Emacs channel:
1. Delete `src/channels/emacs.ts`, `src/channels/emacs.test.ts`, and `emacs/nanoclaw.el`
2. Remove `import './emacs.js'` from `src/channels/index.ts`
3. Remove the NanoClaw block from your Emacs config file
4. Remove Emacs registration from SQLite: `sqlite3 store/messages.db "DELETE FROM registered_groups WHERE jid = 'emacs:default'"`
5. Remove `EMACS_CHANNEL_PORT` and `EMACS_AUTH_TOKEN` from `.env` if set
6. Rebuild: `npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `npm run build && systemctl --user restart nanoclaw` (Linux)
+153
View File
@@ -0,0 +1,153 @@
---
name: add-github
description: Add GitHub skill to container agents. Installs gh CLI in the container, passes GITHUB_TOKEN via stdin, and adds the agent-github skill doc so agents can manage issues, PRs, and code review.
---
# Add GitHub Skill
This skill gives container agents access to the GitHub CLI (`gh`) for managing issues, pull requests, and code review.
## Phase 1: Pre-flight
### Check if already applied
Read `.nanoclaw/state.yaml`. If `github` is in `applied_skills`, skip to Phase 3 (Configure). The code changes are already in place.
### Ask the user
Use `AskUserQuestion` to collect the GitHub token:
AskUserQuestion: Do you have a GitHub personal access token, or do you need to create one?
If they have one, collect it now. If not, guide them in Phase 3.
## Phase 2: Apply Code Changes
### Initialize skills system (if needed)
If `.nanoclaw/` directory doesn't exist yet:
```bash
npx tsx scripts/apply-skill.ts --init
```
### Apply the skill
```bash
npx tsx scripts/apply-skill.ts .claude/skills/add-github
```
This deterministically:
- Adds `container/skills/agent-github/SKILL.md` (agent-facing GitHub CLI docs)
- Three-way merges `gh` CLI installation into `container/Dockerfile`
- Three-way merges `GITHUB_TOKEN` and `GH_REPO` into `readSecrets()` in `src/container-runner.ts`
- Records the application in `.nanoclaw/state.yaml`
If the apply reports merge conflicts, read the intent files:
- `modify/container/Dockerfile.intent.md` — what changed for the Dockerfile
- `modify/src/container-runner.ts.intent.md` — what changed for container-runner.ts
### Validate
```bash
npm run build
```
Build must be clean before proceeding.
## Phase 3: Configure Token and Repository
### Step 1: Ask for token or guide creation
Use `AskUserQuestion`:
AskUserQuestion: Do you have a GitHub personal access token already, or do you need to create one?
If they need to create one, tell them:
> Create a GitHub personal access token (classic):
>
> 1. Go to https://github.com/settings/tokens/new
> 2. **Note**: `NanoClaw agent` (or whatever you like)
> 3. **Expiration**: 90 days (recommended) or "No expiration"
> 4. **Scopes**: select `repo` (full repo access — covers issues, PRs, code, status)
> 5. Click **Generate token** and copy it immediately (you can only see it once)
>
> The token will start with `ghp_...`. Paste it here when you have it.
Wait for the user to provide the token.
### Step 2: Ask for target repository
Use `AskUserQuestion`:
AskUserQuestion: Which GitHub repository should the agent manage? (format: `owner/repo`)
### Step 3: Add to `.env`
Add both values to `.env`:
```bash
GITHUB_TOKEN=ghp_...
GH_REPO=owner/repo
```
`GH_REPO` sets the default repository so the agent doesn't need `--repo` on every command.
### Step 4: Sync env and restart
```bash
npm run build
./container/build.sh
rm -r data/sessions/*/agent-runner-src 2>/dev/null || true
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
## Phase 4: Verify
### Test the connection
Tell the user:
> GitHub is connected! Send this in your main channel:
>
> "List the open issues on our repo"
The agent should invoke `gh issue list` and return results.
### Check logs if needed
```bash
tail -f logs/nanoclaw.log
```
## Troubleshooting
### gh: not found
The container needs to be rebuilt with the `gh` CLI:
```bash
./container/build.sh
rm -r data/sessions/*/agent-runner-src 2>/dev/null || true
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
```
### gh: authentication required
- Check `GITHUB_TOKEN` is set in `.env`
- Restart NanoClaw
### Rate limit exceeded
GitHub allows 5,000 requests/hour with token auth. If hitting limits, the agent is likely making too many API calls in a loop.
## Removal
1. Delete `container/skills/agent-github/SKILL.md`
2. Remove `gh` installation block from `container/Dockerfile`
3. Remove `GITHUB_TOKEN` and `GH_REPO` from `readSecrets()` in `src/container-runner.ts`
4. Remove `GITHUB_TOKEN` and `GH_REPO` from `.env`
5. Remove `github` from `.nanoclaw/state.yaml`
6. Rebuild: `./container/build.sh && npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
@@ -0,0 +1,86 @@
---
name: agent-github
description: Manage GitHub issues, pull requests, and code review for the repository. Use for any GitHub task — listing issues, reviewing PRs, checking CI status, adding labels, posting comments.
allowed-tools: Bash(gh:*)
---
# GitHub CLI (`gh`)
## Authentication
`gh` is pre-authenticated via `GITHUB_TOKEN` env var. The target repo is set via `GH_REPO` (e.g., `owner/repo`), so you don't need to specify `--repo` on every command.
## Read operations
### Issues
```bash
gh issue list # Open issues
gh issue list --state closed --limit 10 # Recent closed issues
gh issue list --label "bug" # Filter by label
gh issue list --assignee "@me" # Assigned to me
gh issue list --search "keyword" # Search issues
gh issue view 42 # View issue details
gh issue view 42 --comments # Include comments
```
### Pull requests
```bash
gh pr list # Open PRs
gh pr list --state merged --limit 10 # Recent merged PRs
gh pr list --author "username" # Filter by author
gh pr view 99 # View PR details
gh pr view 99 --comments # Include comments
gh pr diff 99 # View PR diff
gh pr checks 99 # CI/check status
```
### Repository info
```bash
gh api repos/{owner}/{repo}/branches # List branches
gh api repos/{owner}/{repo}/commits?per_page=10 # Recent commits
gh api repos/{owner}/{repo}/releases/latest # Latest release
gh api repos/{owner}/{repo}/actions/runs?per_page=5 # Recent workflow runs
```
## Safe write operations
### Issue comments and labels
```bash
gh issue comment 42 --body "Looks good, marking as reviewed."
gh issue edit 42 --add-label "reviewed"
gh issue edit 42 --remove-label "needs-triage"
gh issue edit 42 --add-assignee "username"
```
### PR comments and reviews
```bash
gh pr comment 99 --body "LGTM, one minor suggestion below."
gh pr review 99 --approve --body "Approved."
gh pr review 99 --request-changes --body "Please fix the failing test."
gh pr review 99 --comment --body "Looks good overall."
```
## NOT allowed
Do NOT perform these operations — they are destructive or require human decision:
- `gh pr merge` / `gh pr close` / `gh pr reopen`
- `gh issue create` / `gh issue close` / `gh issue reopen`
- `gh release create` / `gh release delete`
- `gh repo delete` / `gh repo rename`
- `git push --force` / `git push --force-with-lease`
- Any `gh api -X DELETE` call
- Any `gh api -X PUT/PATCH` on branch protection rules
## Tips
- Use `--json` for structured output: `gh issue list --json number,title,labels`
- Combine with `jq` for filtering: `gh pr list --json number,title,checks --jq '.[] | select(.checks | length > 0)'`
- Rate limits: GitHub allows 5,000 requests/hour with token auth — plenty for normal use
- For large lists, use `--limit`: `gh issue list --limit 50`
- To get the repo owner/name: `gh repo view --json nameWithOwner --jq .nameWithOwner`
+17
View File
@@ -0,0 +1,17 @@
skill: github
version: 1.0.0
description: "GitHub CLI skill for container agents"
core_version: 0.1.0
adds:
- container/skills/agent-github/SKILL.md
modifies:
- container/Dockerfile
- src/container-runner.ts
structured:
npm_dependencies: {}
env_additions:
- GITHUB_TOKEN
- GH_REPO
conflicts: []
depends: []
test: "npm run build"
@@ -7,6 +7,7 @@ FROM node:22-slim
RUN apt-get update && apt-get install -y \
chromium \
fonts-liberation \
fonts-noto-cjk \
fonts-noto-color-emoji \
libgbm1 \
libnss3 \
@@ -25,6 +26,14 @@ RUN apt-get update && apt-get install -y \
git \
&& rm -rf /var/lib/apt/lists/*
# Install GitHub CLI
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
-o /usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list \
&& apt-get update && apt-get install -y gh \
&& rm -rf /var/lib/apt/lists/*
# Set Chromium path for agent-browser
ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium
@@ -53,14 +62,14 @@ RUN mkdir -p /workspace/group /workspace/global /workspace/extra /workspace/ipc/
# Create entrypoint script
# Secrets are passed via stdin JSON — temp file is deleted immediately after Node reads it
# Follow-up messages arrive via IPC files in /workspace/ipc/input/
# Apple Container only supports directory mounts (VirtioFS), so .env cannot be
# shadowed with a host-side /dev/null file mount. Instead the entrypoint starts
# as root, uses mount --bind to shadow .env, then drops to the host user via setpriv.
RUN printf '#!/bin/bash\nset -e\n\n# Shadow .env so the agent cannot read host secrets (requires root)\nif [ "$(id -u)" = "0" ] && [ -f /workspace/project/.env ]; then\n mount --bind /dev/null /workspace/project/.env\nfi\n\n# Compile agent-runner\ncd /app && npx tsc --outDir /tmp/dist 2>&1 >&2\nln -s /app/node_modules /tmp/dist/node_modules\nchmod -R a-w /tmp/dist\n\n# Capture stdin (secrets JSON) to temp file\ncat > /tmp/input.json\n\n# Drop privileges if running as root (main-group containers)\nif [ "$(id -u)" = "0" ] && [ -n "$RUN_UID" ]; then\n chown "$RUN_UID:$RUN_GID" /tmp/input.json /tmp/dist\n exec setpriv --reuid="$RUN_UID" --regid="$RUN_GID" --clear-groups -- node /tmp/dist/index.js < /tmp/input.json\nfi\n\nexec node /tmp/dist/index.js < /tmp/input.json\n' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh
RUN printf '#!/bin/bash\nset -e\ncd /app && npx tsc --outDir /tmp/dist 2>&1 >&2\nln -s /app/node_modules /tmp/dist/node_modules\nchmod -R a-w /tmp/dist\ncat > /tmp/input.json\nnode /tmp/dist/index.js < /tmp/input.json\n' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh
# Set ownership to node user (non-root) for writable directories
RUN chown -R node:node /workspace && chmod 777 /home/node
# Switch to non-root user (required for --dangerously-skip-permissions)
USER node
# Set working directory to group workspace
WORKDIR /workspace/group
@@ -0,0 +1,30 @@
# Intent: container/Dockerfile modifications
## What changed
Added GitHub CLI (`gh`) installation from GitHub's official apt repository, placed after the existing system dependencies block.
## Key sections
### gh CLI installation
- Added after the `rm -rf /var/lib/apt/lists/*` line that closes the system dependencies block:
```dockerfile
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
-o /usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list \
&& apt-get update && apt-get install -y gh \
&& rm -rf /var/lib/apt/lists/*
```
- Uses the official GitHub apt repo with signed keyring
- Cleans apt lists to minimize image size
## Invariants
- All existing system dependencies (chromium, fonts, curl, git) are unchanged
- The Chromium env vars below are unchanged
- All subsequent steps (npm install, COPY, build, entrypoint) are unchanged
## Must-keep
- The existing `apt-get install` block for system dependencies
- The `AGENT_BROWSER_EXECUTABLE_PATH` and `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` env vars
- The npm global install of `agent-browser` and `@anthropic-ai/claude-code`
- The entire build, COPY, and entrypoint sequence
@@ -74,6 +74,17 @@ function buildVolumeMounts(
readonly: true,
});
// Shadow .env so the agent cannot read secrets from the mounted project root.
// Secrets are passed via stdin instead (see readSecrets()).
const envFile = path.join(projectRoot, '.env');
if (fs.existsSync(envFile)) {
mounts.push({
hostPath: '/dev/null',
containerPath: '/workspace/project/.env',
readonly: true,
});
}
// Main also gets its group folder as the working directory
mounts.push({
hostPath: groupDir,
@@ -204,13 +215,19 @@ function buildVolumeMounts(
* Secrets are never written to disk or mounted as files.
*/
function readSecrets(): Record<string, string> {
return readEnvFile(['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']);
return readEnvFile([
'CLAUDE_CODE_OAUTH_TOKEN',
'ANTHROPIC_API_KEY',
'ANTHROPIC_BASE_URL',
'ANTHROPIC_AUTH_TOKEN',
'GITHUB_TOKEN',
'GH_REPO',
]);
}
function buildContainerArgs(
mounts: VolumeMount[],
containerName: string,
isMain: boolean,
): string[] {
const args: string[] = ['run', '-i', '--rm', '--name', containerName];
@@ -223,14 +240,7 @@ function buildContainerArgs(
const hostUid = process.getuid?.();
const hostGid = process.getgid?.();
if (hostUid != null && hostUid !== 0 && hostUid !== 1000) {
if (isMain) {
// Main containers start as root so the entrypoint can mount --bind
// to shadow .env. Privileges are dropped via setpriv in entrypoint.sh.
args.push('-e', `RUN_UID=${hostUid}`);
args.push('-e', `RUN_GID=${hostGid}`);
} else {
args.push('--user', `${hostUid}:${hostGid}`);
}
args.push('--user', `${hostUid}:${hostGid}`);
args.push('-e', 'HOME=/home/node');
}
@@ -261,7 +271,7 @@ export async function runContainerAgent(
const mounts = buildVolumeMounts(group, input.isMain);
const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
const containerName = `nanoclaw-${safeName}-${Date.now()}`;
const containerArgs = buildContainerArgs(mounts, containerName, input.isMain);
const containerArgs = buildContainerArgs(mounts, containerName);
logger.debug(
{
@@ -0,0 +1,34 @@
# Intent: src/container-runner.ts modifications
## What changed
Added `GITHUB_TOKEN` and `GH_REPO` to the `readSecrets()` allowlist so they are passed to the container via stdin JSON.
## Key sections
### readSecrets()
- Added two keys to the array passed to `readEnvFile()`:
```typescript
function readSecrets(): Record<string, string> {
return readEnvFile([
'CLAUDE_CODE_OAUTH_TOKEN',
'ANTHROPIC_API_KEY',
'ANTHROPIC_BASE_URL',
'ANTHROPIC_AUTH_TOKEN',
'GITHUB_TOKEN',
'GH_REPO',
]);
}
```
- These values flow via stdin -> `containerInput.secrets` -> `sdkEnv` -> available to Bash
- `GITHUB_TOKEN` and `GH_REPO` are NOT added to `SECRET_ENV_VARS` in agent-runner because `gh` CLI needs them visible in Bash commands
## Invariants
- All existing secrets (`CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`) are unchanged
- The stdin-based secret passing flow is unchanged
- No changes to volume mounts, container args, or any other functions
## Must-keep
- All existing volume mounts
- The mount security model
- Container lifecycle (spawn, timeout, output parsing)
- The `buildContainerArgs`, `runContainerAgent`, and all other functions
+28 -50
View File
@@ -11,7 +11,7 @@ This skill adds Gmail support to NanoClaw — either as a tool (read, send, sear
### Check if already applied
Read `.nanoclaw/state.yaml`. If `gmail` is in `applied_skills`, skip to Phase 3 (Setup). The code changes are already in place.
Check if `src/channels/gmail.ts` exists. If it does, skip to Phase 3 (Setup). The code changes are already in place.
### Ask the user
@@ -24,65 +24,42 @@ AskUserQuestion: Should incoming emails be able to trigger the agent?
## Phase 2: Apply Code Changes
### Initialize skills system (if needed)
If `.nanoclaw/` directory doesn't exist yet:
### Ensure channel remote
```bash
npx tsx scripts/apply-skill.ts --init
git remote -v
```
### Path A: Tool-only (user chose "No")
Do NOT run the full apply script. Only two source files need changes. This avoids adding dead code (`gmail.ts`, `gmail.test.ts`, index.ts channel logic, routing tests, `googleapis` dependency).
#### 1. Mount Gmail credentials in container
Apply the changes described in `modify/src/container-runner.ts.intent.md` to `src/container-runner.ts`: import `os`, add a conditional read-write mount of `~/.gmail-mcp` to `/home/node/.gmail-mcp` in `buildVolumeMounts()` after the session mounts.
#### 2. Add Gmail MCP server to agent runner
Apply the changes described in `modify/container/agent-runner/src/index.ts.intent.md` to `container/agent-runner/src/index.ts`: add `gmail` MCP server (`npx -y @gongrzhe/server-gmail-autoauth-mcp`) and `'mcp__gmail__*'` to `allowedTools`.
#### 3. Record in state
Add `gmail` to `.nanoclaw/state.yaml` under `applied_skills` with `mode: tool-only`.
#### 4. Validate
If `gmail` is missing, add it:
```bash
npm run build
git remote add gmail https://github.com/qwibitai/nanoclaw-gmail.git
```
Build must be clean before proceeding. Skip to Phase 3.
### Path B: Channel mode (user chose "Yes")
Run the full skills engine to apply all code changes:
### Merge the skill branch
```bash
npx tsx scripts/apply-skill.ts .claude/skills/add-gmail
git fetch gmail main
git merge gmail/main || {
git checkout --theirs package-lock.json
git add package-lock.json
git merge --continue
}
```
This deterministically:
This merges in:
- `src/channels/gmail.ts` (GmailChannel class with self-registration via `registerChannel`)
- `src/channels/gmail.test.ts` (unit tests)
- `import './gmail.js'` appended to the channel barrel file `src/channels/index.ts`
- Gmail credentials mount (`~/.gmail-mcp`) in `src/container-runner.ts`
- Gmail MCP server (`@gongrzhe/server-gmail-autoauth-mcp`) and `mcp__gmail__*` allowed tool in `container/agent-runner/src/index.ts`
- `googleapis` npm dependency in `package.json`
- Adds `src/channels/gmail.ts` (GmailChannel class with self-registration via `registerChannel`)
- Adds `src/channels/gmail.test.ts` (unit tests)
- Appends `import './gmail.js'` to the channel barrel file `src/channels/index.ts`
- Three-way merges Gmail credentials mount into `src/container-runner.ts` (~/.gmail-mcp -> /home/node/.gmail-mcp)
- Three-way merges Gmail MCP server into `container/agent-runner/src/index.ts` (@gongrzhe/server-gmail-autoauth-mcp)
- Installs the `googleapis` npm dependency
- Records the application in `.nanoclaw/state.yaml`
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
If the apply reports merge conflicts, read the intent files:
### Add email handling instructions (Channel mode only)
- `modify/src/channels/index.ts.intent.md` — what changed for the barrel file
- `modify/src/container-runner.ts.intent.md` — what changed for container-runner.ts
- `modify/container/agent-runner/src/index.ts.intent.md` — what changed for agent-runner
#### Add email handling instructions
Append the following to `groups/main/CLAUDE.md` (before the formatting section):
If the user chose channel mode, append the following to `groups/main/CLAUDE.md` (before the formatting section):
```markdown
## Email Notifications
@@ -90,14 +67,15 @@ Append the following to `groups/main/CLAUDE.md` (before the formatting section):
When you receive an email notification (messages starting with `[Email from ...`), inform the user about it but do NOT reply to the email unless specifically asked. You have Gmail tools available — use them only when the user explicitly asks you to reply, forward, or take action on an email.
```
#### Validate
### Validate code changes
```bash
npm test
npm install
npm run build
npx vitest run src/channels/gmail.test.ts
```
All tests must pass (including the new gmail tests) and build must be clean before proceeding.
All tests must pass (including the new Gmail tests) and build must be clean before proceeding.
## Phase 3: Setup
@@ -226,7 +204,7 @@ npx -y @gongrzhe/server-gmail-autoauth-mcp
1. Remove `~/.gmail-mcp` mount from `src/container-runner.ts`
2. Remove `gmail` MCP server and `mcp__gmail__*` from `container/agent-runner/src/index.ts`
3. Remove `gmail` from `.nanoclaw/state.yaml`
3. Rebuild and restart
4. Clear stale agent-runner copies: `rm -r data/sessions/*/agent-runner-src 2>/dev/null || true`
5. Rebuild: `cd container && ./build.sh && cd .. && npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux)
@@ -237,6 +215,6 @@ npx -y @gongrzhe/server-gmail-autoauth-mcp
3. Remove `~/.gmail-mcp` mount from `src/container-runner.ts`
4. Remove `gmail` MCP server and `mcp__gmail__*` from `container/agent-runner/src/index.ts`
5. Uninstall: `npm uninstall googleapis`
6. Remove `gmail` from `.nanoclaw/state.yaml`
6. Rebuild and restart
7. Clear stale agent-runner copies: `rm -r data/sessions/*/agent-runner-src 2>/dev/null || true`
8. Rebuild: `cd container && ./build.sh && cd .. && npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux)
@@ -1,74 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock registry (registerChannel runs at import time)
vi.mock('./registry.js', () => ({ registerChannel: vi.fn() }));
import { GmailChannel, GmailChannelOpts } from './gmail.js';
function makeOpts(overrides?: Partial<GmailChannelOpts>): GmailChannelOpts {
return {
onMessage: vi.fn(),
onChatMetadata: vi.fn(),
registeredGroups: () => ({}),
...overrides,
};
}
describe('GmailChannel', () => {
let channel: GmailChannel;
beforeEach(() => {
channel = new GmailChannel(makeOpts());
});
describe('ownsJid', () => {
it('returns true for gmail: prefixed JIDs', () => {
expect(channel.ownsJid('gmail:abc123')).toBe(true);
expect(channel.ownsJid('gmail:thread-id-456')).toBe(true);
});
it('returns false for non-gmail JIDs', () => {
expect(channel.ownsJid('12345@g.us')).toBe(false);
expect(channel.ownsJid('tg:123')).toBe(false);
expect(channel.ownsJid('dc:456')).toBe(false);
expect(channel.ownsJid('user@s.whatsapp.net')).toBe(false);
});
});
describe('name', () => {
it('is gmail', () => {
expect(channel.name).toBe('gmail');
});
});
describe('isConnected', () => {
it('returns false before connect', () => {
expect(channel.isConnected()).toBe(false);
});
});
describe('disconnect', () => {
it('sets connected to false', async () => {
await channel.disconnect();
expect(channel.isConnected()).toBe(false);
});
});
describe('constructor options', () => {
it('accepts custom poll interval', () => {
const ch = new GmailChannel(makeOpts(), 30000);
expect(ch.name).toBe('gmail');
});
it('defaults to unread query when no filter configured', () => {
const ch = new GmailChannel(makeOpts());
const query = (ch as unknown as { buildQuery: () => string }).buildQuery();
expect(query).toBe('is:unread category:primary');
});
it('defaults with no options provided', () => {
const ch = new GmailChannel(makeOpts());
expect(ch.name).toBe('gmail');
});
});
});
@@ -1,352 +0,0 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { google, gmail_v1 } from 'googleapis';
import { OAuth2Client } from 'google-auth-library';
// isMain flag is used instead of MAIN_GROUP_FOLDER constant
import { logger } from '../logger.js';
import { registerChannel, ChannelOpts } from './registry.js';
import {
Channel,
OnChatMetadata,
OnInboundMessage,
RegisteredGroup,
} from '../types.js';
export interface GmailChannelOpts {
onMessage: OnInboundMessage;
onChatMetadata: OnChatMetadata;
registeredGroups: () => Record<string, RegisteredGroup>;
}
interface ThreadMeta {
sender: string;
senderName: string;
subject: string;
messageId: string; // RFC 2822 Message-ID for In-Reply-To
}
export class GmailChannel implements Channel {
name = 'gmail';
private oauth2Client: OAuth2Client | null = null;
private gmail: gmail_v1.Gmail | null = null;
private opts: GmailChannelOpts;
private pollIntervalMs: number;
private pollTimer: ReturnType<typeof setTimeout> | null = null;
private processedIds = new Set<string>();
private threadMeta = new Map<string, ThreadMeta>();
private consecutiveErrors = 0;
private userEmail = '';
constructor(opts: GmailChannelOpts, pollIntervalMs = 60000) {
this.opts = opts;
this.pollIntervalMs = pollIntervalMs;
}
async connect(): Promise<void> {
const credDir = path.join(os.homedir(), '.gmail-mcp');
const keysPath = path.join(credDir, 'gcp-oauth.keys.json');
const tokensPath = path.join(credDir, 'credentials.json');
if (!fs.existsSync(keysPath) || !fs.existsSync(tokensPath)) {
logger.warn(
'Gmail credentials not found in ~/.gmail-mcp/. Skipping Gmail channel. Run /add-gmail to set up.',
);
return;
}
const keys = JSON.parse(fs.readFileSync(keysPath, 'utf-8'));
const tokens = JSON.parse(fs.readFileSync(tokensPath, 'utf-8'));
const clientConfig = keys.installed || keys.web || keys;
const { client_id, client_secret, redirect_uris } = clientConfig;
this.oauth2Client = new google.auth.OAuth2(
client_id,
client_secret,
redirect_uris?.[0],
);
this.oauth2Client.setCredentials(tokens);
// Persist refreshed tokens
this.oauth2Client.on('tokens', (newTokens) => {
try {
const current = JSON.parse(fs.readFileSync(tokensPath, 'utf-8'));
Object.assign(current, newTokens);
fs.writeFileSync(tokensPath, JSON.stringify(current, null, 2));
logger.debug('Gmail OAuth tokens refreshed');
} catch (err) {
logger.warn({ err }, 'Failed to persist refreshed Gmail tokens');
}
});
this.gmail = google.gmail({ version: 'v1', auth: this.oauth2Client });
// Verify connection
const profile = await this.gmail.users.getProfile({ userId: 'me' });
this.userEmail = profile.data.emailAddress || '';
logger.info({ email: this.userEmail }, 'Gmail channel connected');
// Start polling with error backoff
const schedulePoll = () => {
const backoffMs = this.consecutiveErrors > 0
? Math.min(this.pollIntervalMs * Math.pow(2, this.consecutiveErrors), 30 * 60 * 1000)
: this.pollIntervalMs;
this.pollTimer = setTimeout(() => {
this.pollForMessages()
.catch((err) => logger.error({ err }, 'Gmail poll error'))
.finally(() => {
if (this.gmail) schedulePoll();
});
}, backoffMs);
};
// Initial poll
await this.pollForMessages();
schedulePoll();
}
async sendMessage(jid: string, text: string): Promise<void> {
if (!this.gmail) {
logger.warn('Gmail not initialized');
return;
}
const threadId = jid.replace(/^gmail:/, '');
const meta = this.threadMeta.get(threadId);
if (!meta) {
logger.warn({ jid }, 'No thread metadata for reply, cannot send');
return;
}
const subject = meta.subject.startsWith('Re:')
? meta.subject
: `Re: ${meta.subject}`;
const headers = [
`To: ${meta.sender}`,
`From: ${this.userEmail}`,
`Subject: ${subject}`,
`In-Reply-To: ${meta.messageId}`,
`References: ${meta.messageId}`,
'Content-Type: text/plain; charset=utf-8',
'',
text,
].join('\r\n');
const encodedMessage = Buffer.from(headers)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
try {
await this.gmail.users.messages.send({
userId: 'me',
requestBody: {
raw: encodedMessage,
threadId,
},
});
logger.info({ to: meta.sender, threadId }, 'Gmail reply sent');
} catch (err) {
logger.error({ jid, err }, 'Failed to send Gmail reply');
}
}
isConnected(): boolean {
return this.gmail !== null;
}
ownsJid(jid: string): boolean {
return jid.startsWith('gmail:');
}
async disconnect(): Promise<void> {
if (this.pollTimer) {
clearTimeout(this.pollTimer);
this.pollTimer = null;
}
this.gmail = null;
this.oauth2Client = null;
logger.info('Gmail channel stopped');
}
// --- Private ---
private buildQuery(): string {
return 'is:unread category:primary';
}
private async pollForMessages(): Promise<void> {
if (!this.gmail) return;
try {
const query = this.buildQuery();
const res = await this.gmail.users.messages.list({
userId: 'me',
q: query,
maxResults: 10,
});
const messages = res.data.messages || [];
for (const stub of messages) {
if (!stub.id || this.processedIds.has(stub.id)) continue;
this.processedIds.add(stub.id);
await this.processMessage(stub.id);
}
// Cap processed ID set to prevent unbounded growth
if (this.processedIds.size > 5000) {
const ids = [...this.processedIds];
this.processedIds = new Set(ids.slice(ids.length - 2500));
}
this.consecutiveErrors = 0;
} catch (err) {
this.consecutiveErrors++;
const backoffMs = Math.min(this.pollIntervalMs * Math.pow(2, this.consecutiveErrors), 30 * 60 * 1000);
logger.error({ err, consecutiveErrors: this.consecutiveErrors, nextPollMs: backoffMs }, 'Gmail poll failed');
}
}
private async processMessage(messageId: string): Promise<void> {
if (!this.gmail) return;
const msg = await this.gmail.users.messages.get({
userId: 'me',
id: messageId,
format: 'full',
});
const headers = msg.data.payload?.headers || [];
const getHeader = (name: string) =>
headers.find((h) => h.name?.toLowerCase() === name.toLowerCase())
?.value || '';
const from = getHeader('From');
const subject = getHeader('Subject');
const rfc2822MessageId = getHeader('Message-ID');
const threadId = msg.data.threadId || messageId;
const timestamp = new Date(
parseInt(msg.data.internalDate || '0', 10),
).toISOString();
// Extract sender name and email
const senderMatch = from.match(/^(.+?)\s*<(.+?)>$/);
const senderName = senderMatch ? senderMatch[1].replace(/"/g, '') : from;
const senderEmail = senderMatch ? senderMatch[2] : from;
// Skip emails from self (our own replies)
if (senderEmail === this.userEmail) return;
// Extract body text
const body = this.extractTextBody(msg.data.payload);
if (!body) {
logger.debug({ messageId, subject }, 'Skipping email with no text body');
return;
}
const chatJid = `gmail:${threadId}`;
// Cache thread metadata for replies
this.threadMeta.set(threadId, {
sender: senderEmail,
senderName,
subject,
messageId: rfc2822MessageId,
});
// Store chat metadata for group discovery
this.opts.onChatMetadata(chatJid, timestamp, subject, 'gmail', false);
// Find the main group to deliver the email notification
const groups = this.opts.registeredGroups();
const mainEntry = Object.entries(groups).find(
([, g]) => g.isMain === true,
);
if (!mainEntry) {
logger.debug(
{ chatJid, subject },
'No main group registered, skipping email',
);
return;
}
const mainJid = mainEntry[0];
const content = `[Email from ${senderName} <${senderEmail}>]\nSubject: ${subject}\n\n${body}`;
this.opts.onMessage(mainJid, {
id: messageId,
chat_jid: mainJid,
sender: senderEmail,
sender_name: senderName,
content,
timestamp,
is_from_me: false,
});
// Mark as read
try {
await this.gmail.users.messages.modify({
userId: 'me',
id: messageId,
requestBody: { removeLabelIds: ['UNREAD'] },
});
} catch (err) {
logger.warn({ messageId, err }, 'Failed to mark email as read');
}
logger.info(
{ mainJid, from: senderName, subject },
'Gmail email delivered to main group',
);
}
private extractTextBody(
payload: gmail_v1.Schema$MessagePart | undefined,
): string {
if (!payload) return '';
// Direct text/plain body
if (payload.mimeType === 'text/plain' && payload.body?.data) {
return Buffer.from(payload.body.data, 'base64').toString('utf-8');
}
// Multipart: search parts recursively
if (payload.parts) {
// Prefer text/plain
for (const part of payload.parts) {
if (part.mimeType === 'text/plain' && part.body?.data) {
return Buffer.from(part.body.data, 'base64').toString('utf-8');
}
}
// Recurse into nested multipart
for (const part of payload.parts) {
const text = this.extractTextBody(part);
if (text) return text;
}
}
return '';
}
}
registerChannel('gmail', (opts: ChannelOpts) => {
const credDir = path.join(os.homedir(), '.gmail-mcp');
if (
!fs.existsSync(path.join(credDir, 'gcp-oauth.keys.json')) ||
!fs.existsSync(path.join(credDir, 'credentials.json'))
) {
logger.warn('Gmail: credentials not found in ~/.gmail-mcp/');
return null;
}
return new GmailChannel(opts);
});
-17
View File
@@ -1,17 +0,0 @@
skill: gmail
version: 1.0.0
description: "Gmail integration via Google APIs"
core_version: 0.1.0
adds:
- src/channels/gmail.ts
- src/channels/gmail.test.ts
modifies:
- src/channels/index.ts
- src/container-runner.ts
- container/agent-runner/src/index.ts
structured:
npm_dependencies:
googleapis: "^144.0.0"
conflicts: []
depends: []
test: "npx vitest run src/channels/gmail.test.ts"
@@ -1,593 +0,0 @@
/**
* NanoClaw Agent Runner
* Runs inside a container, receives config via stdin, outputs result to stdout
*
* Input protocol:
* Stdin: Full ContainerInput JSON (read until EOF, like before)
* IPC: Follow-up messages written as JSON files to /workspace/ipc/input/
* Files: {type:"message", text:"..."}.json — polled and consumed
* Sentinel: /workspace/ipc/input/_close — signals session end
*
* Stdout protocol:
* Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs.
* Multiple results may be emitted (one per agent teams result).
* Final marker after loop ends signals completion.
*/
import fs from 'fs';
import path from 'path';
import { query, HookCallback, PreCompactHookInput, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk';
import { fileURLToPath } from 'url';
interface ContainerInput {
prompt: string;
sessionId?: string;
groupFolder: string;
chatJid: string;
isMain: boolean;
isScheduledTask?: boolean;
assistantName?: string;
secrets?: Record<string, string>;
}
interface ContainerOutput {
status: 'success' | 'error';
result: string | null;
newSessionId?: string;
error?: string;
}
interface SessionEntry {
sessionId: string;
fullPath: string;
summary: string;
firstPrompt: string;
}
interface SessionsIndex {
entries: SessionEntry[];
}
interface SDKUserMessage {
type: 'user';
message: { role: 'user'; content: string };
parent_tool_use_id: null;
session_id: string;
}
const IPC_INPUT_DIR = '/workspace/ipc/input';
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
const IPC_POLL_MS = 500;
/**
* Push-based async iterable for streaming user messages to the SDK.
* Keeps the iterable alive until end() is called, preventing isSingleUserTurn.
*/
class MessageStream {
private queue: SDKUserMessage[] = [];
private waiting: (() => void) | null = null;
private done = false;
push(text: string): void {
this.queue.push({
type: 'user',
message: { role: 'user', content: text },
parent_tool_use_id: null,
session_id: '',
});
this.waiting?.();
}
end(): void {
this.done = true;
this.waiting?.();
}
async *[Symbol.asyncIterator](): AsyncGenerator<SDKUserMessage> {
while (true) {
while (this.queue.length > 0) {
yield this.queue.shift()!;
}
if (this.done) return;
await new Promise<void>(r => { this.waiting = r; });
this.waiting = null;
}
}
}
async function readStdin(): Promise<string> {
return new Promise((resolve, reject) => {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => { data += chunk; });
process.stdin.on('end', () => resolve(data));
process.stdin.on('error', reject);
});
}
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
function writeOutput(output: ContainerOutput): void {
console.log(OUTPUT_START_MARKER);
console.log(JSON.stringify(output));
console.log(OUTPUT_END_MARKER);
}
function log(message: string): void {
console.error(`[agent-runner] ${message}`);
}
function getSessionSummary(sessionId: string, transcriptPath: string): string | null {
const projectDir = path.dirname(transcriptPath);
const indexPath = path.join(projectDir, 'sessions-index.json');
if (!fs.existsSync(indexPath)) {
log(`Sessions index not found at ${indexPath}`);
return null;
}
try {
const index: SessionsIndex = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
const entry = index.entries.find(e => e.sessionId === sessionId);
if (entry?.summary) {
return entry.summary;
}
} catch (err) {
log(`Failed to read sessions index: ${err instanceof Error ? err.message : String(err)}`);
}
return null;
}
/**
* Archive the full transcript to conversations/ before compaction.
*/
function createPreCompactHook(assistantName?: string): HookCallback {
return async (input, _toolUseId, _context) => {
const preCompact = input as PreCompactHookInput;
const transcriptPath = preCompact.transcript_path;
const sessionId = preCompact.session_id;
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
log('No transcript found for archiving');
return {};
}
try {
const content = fs.readFileSync(transcriptPath, 'utf-8');
const messages = parseTranscript(content);
if (messages.length === 0) {
log('No messages to archive');
return {};
}
const summary = getSessionSummary(sessionId, transcriptPath);
const name = summary ? sanitizeFilename(summary) : generateFallbackName();
const conversationsDir = '/workspace/group/conversations';
fs.mkdirSync(conversationsDir, { recursive: true });
const date = new Date().toISOString().split('T')[0];
const filename = `${date}-${name}.md`;
const filePath = path.join(conversationsDir, filename);
const markdown = formatTranscriptMarkdown(messages, summary, assistantName);
fs.writeFileSync(filePath, markdown);
log(`Archived conversation to ${filePath}`);
} catch (err) {
log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`);
}
return {};
};
}
// Secrets to strip from Bash tool subprocess environments.
// These are needed by claude-code for API auth but should never
// be visible to commands Kit runs.
const SECRET_ENV_VARS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'];
function createSanitizeBashHook(): HookCallback {
return async (input, _toolUseId, _context) => {
const preInput = input as PreToolUseHookInput;
const command = (preInput.tool_input as { command?: string })?.command;
if (!command) return {};
const unsetPrefix = `unset ${SECRET_ENV_VARS.join(' ')} 2>/dev/null; `;
return {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
updatedInput: {
...(preInput.tool_input as Record<string, unknown>),
command: unsetPrefix + command,
},
},
};
};
}
function sanitizeFilename(summary: string): string {
return summary
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 50);
}
function generateFallbackName(): string {
const time = new Date();
return `conversation-${time.getHours().toString().padStart(2, '0')}${time.getMinutes().toString().padStart(2, '0')}`;
}
interface ParsedMessage {
role: 'user' | 'assistant';
content: string;
}
function parseTranscript(content: string): ParsedMessage[] {
const messages: ParsedMessage[] = [];
for (const line of content.split('\n')) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line);
if (entry.type === 'user' && entry.message?.content) {
const text = typeof entry.message.content === 'string'
? entry.message.content
: entry.message.content.map((c: { text?: string }) => c.text || '').join('');
if (text) messages.push({ role: 'user', content: text });
} else if (entry.type === 'assistant' && entry.message?.content) {
const textParts = entry.message.content
.filter((c: { type: string }) => c.type === 'text')
.map((c: { text: string }) => c.text);
const text = textParts.join('');
if (text) messages.push({ role: 'assistant', content: text });
}
} catch {
}
}
return messages;
}
function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | null, assistantName?: string): string {
const now = new Date();
const formatDateTime = (d: Date) => d.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true
});
const lines: string[] = [];
lines.push(`# ${title || 'Conversation'}`);
lines.push('');
lines.push(`Archived: ${formatDateTime(now)}`);
lines.push('');
lines.push('---');
lines.push('');
for (const msg of messages) {
const sender = msg.role === 'user' ? 'User' : (assistantName || 'Assistant');
const content = msg.content.length > 2000
? msg.content.slice(0, 2000) + '...'
: msg.content;
lines.push(`**${sender}**: ${content}`);
lines.push('');
}
return lines.join('\n');
}
/**
* Check for _close sentinel.
*/
function shouldClose(): boolean {
if (fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) {
try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ }
return true;
}
return false;
}
/**
* Drain all pending IPC input messages.
* Returns messages found, or empty array.
*/
function drainIpcInput(): string[] {
try {
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
const files = fs.readdirSync(IPC_INPUT_DIR)
.filter(f => f.endsWith('.json'))
.sort();
const messages: string[] = [];
for (const file of files) {
const filePath = path.join(IPC_INPUT_DIR, file);
try {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
fs.unlinkSync(filePath);
if (data.type === 'message' && data.text) {
messages.push(data.text);
}
} catch (err) {
log(`Failed to process input file ${file}: ${err instanceof Error ? err.message : String(err)}`);
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
}
}
return messages;
} catch (err) {
log(`IPC drain error: ${err instanceof Error ? err.message : String(err)}`);
return [];
}
}
/**
* Wait for a new IPC message or _close sentinel.
* Returns the messages as a single string, or null if _close.
*/
function waitForIpcMessage(): Promise<string | null> {
return new Promise((resolve) => {
const poll = () => {
if (shouldClose()) {
resolve(null);
return;
}
const messages = drainIpcInput();
if (messages.length > 0) {
resolve(messages.join('\n'));
return;
}
setTimeout(poll, IPC_POLL_MS);
};
poll();
});
}
/**
* Run a single query and stream results via writeOutput.
* Uses MessageStream (AsyncIterable) to keep isSingleUserTurn=false,
* allowing agent teams subagents to run to completion.
* Also pipes IPC messages into the stream during the query.
*/
async function runQuery(
prompt: string,
sessionId: string | undefined,
mcpServerPath: string,
containerInput: ContainerInput,
sdkEnv: Record<string, string | undefined>,
resumeAt?: string,
): Promise<{ newSessionId?: string; lastAssistantUuid?: string; closedDuringQuery: boolean }> {
const stream = new MessageStream();
stream.push(prompt);
// Poll IPC for follow-up messages and _close sentinel during the query
let ipcPolling = true;
let closedDuringQuery = false;
const pollIpcDuringQuery = () => {
if (!ipcPolling) return;
if (shouldClose()) {
log('Close sentinel detected during query, ending stream');
closedDuringQuery = true;
stream.end();
ipcPolling = false;
return;
}
const messages = drainIpcInput();
for (const text of messages) {
log(`Piping IPC message into active query (${text.length} chars)`);
stream.push(text);
}
setTimeout(pollIpcDuringQuery, IPC_POLL_MS);
};
setTimeout(pollIpcDuringQuery, IPC_POLL_MS);
let newSessionId: string | undefined;
let lastAssistantUuid: string | undefined;
let messageCount = 0;
let resultCount = 0;
// Load global CLAUDE.md as additional system context (shared across all groups)
const globalClaudeMdPath = '/workspace/global/CLAUDE.md';
let globalClaudeMd: string | undefined;
if (!containerInput.isMain && fs.existsSync(globalClaudeMdPath)) {
globalClaudeMd = fs.readFileSync(globalClaudeMdPath, 'utf-8');
}
// Discover additional directories mounted at /workspace/extra/*
// These are passed to the SDK so their CLAUDE.md files are loaded automatically
const extraDirs: string[] = [];
const extraBase = '/workspace/extra';
if (fs.existsSync(extraBase)) {
for (const entry of fs.readdirSync(extraBase)) {
const fullPath = path.join(extraBase, entry);
if (fs.statSync(fullPath).isDirectory()) {
extraDirs.push(fullPath);
}
}
}
if (extraDirs.length > 0) {
log(`Additional directories: ${extraDirs.join(', ')}`);
}
for await (const message of query({
prompt: stream,
options: {
cwd: '/workspace/group',
additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined,
resume: sessionId,
resumeSessionAt: resumeAt,
systemPrompt: globalClaudeMd
? { type: 'preset' as const, preset: 'claude_code' as const, append: globalClaudeMd }
: undefined,
allowedTools: [
'Bash',
'Read', 'Write', 'Edit', 'Glob', 'Grep',
'WebSearch', 'WebFetch',
'Task', 'TaskOutput', 'TaskStop',
'TeamCreate', 'TeamDelete', 'SendMessage',
'TodoWrite', 'ToolSearch', 'Skill',
'NotebookEdit',
'mcp__nanoclaw__*',
'mcp__gmail__*',
],
env: sdkEnv,
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
settingSources: ['project', 'user'],
mcpServers: {
nanoclaw: {
command: 'node',
args: [mcpServerPath],
env: {
NANOCLAW_CHAT_JID: containerInput.chatJid,
NANOCLAW_GROUP_FOLDER: containerInput.groupFolder,
NANOCLAW_IS_MAIN: containerInput.isMain ? '1' : '0',
},
},
gmail: {
command: 'npx',
args: ['-y', '@gongrzhe/server-gmail-autoauth-mcp'],
},
},
hooks: {
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
PreToolUse: [{ matcher: 'Bash', hooks: [createSanitizeBashHook()] }],
},
}
})) {
messageCount++;
const msgType = message.type === 'system' ? `system/${(message as { subtype?: string }).subtype}` : message.type;
log(`[msg #${messageCount}] type=${msgType}`);
if (message.type === 'assistant' && 'uuid' in message) {
lastAssistantUuid = (message as { uuid: string }).uuid;
}
if (message.type === 'system' && message.subtype === 'init') {
newSessionId = message.session_id;
log(`Session initialized: ${newSessionId}`);
}
if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') {
const tn = message as { task_id: string; status: string; summary: string };
log(`Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`);
}
if (message.type === 'result') {
resultCount++;
const textResult = 'result' in message ? (message as { result?: string }).result : null;
log(`Result #${resultCount}: subtype=${message.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`);
writeOutput({
status: 'success',
result: textResult || null,
newSessionId
});
}
}
ipcPolling = false;
log(`Query done. Messages: ${messageCount}, results: ${resultCount}, lastAssistantUuid: ${lastAssistantUuid || 'none'}, closedDuringQuery: ${closedDuringQuery}`);
return { newSessionId, lastAssistantUuid, closedDuringQuery };
}
async function main(): Promise<void> {
let containerInput: ContainerInput;
try {
const stdinData = await readStdin();
containerInput = JSON.parse(stdinData);
// Delete the temp file the entrypoint wrote — it contains secrets
try { fs.unlinkSync('/tmp/input.json'); } catch { /* may not exist */ }
log(`Received input for group: ${containerInput.groupFolder}`);
} catch (err) {
writeOutput({
status: 'error',
result: null,
error: `Failed to parse input: ${err instanceof Error ? err.message : String(err)}`
});
process.exit(1);
}
// Build SDK env: merge secrets into process.env for the SDK only.
// Secrets never touch process.env itself, so Bash subprocesses can't see them.
const sdkEnv: Record<string, string | undefined> = { ...process.env };
for (const [key, value] of Object.entries(containerInput.secrets || {})) {
sdkEnv[key] = value;
}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js');
let sessionId = containerInput.sessionId;
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
// Clean up stale _close sentinel from previous container runs
try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ }
// Build initial prompt (drain any pending IPC messages too)
let prompt = containerInput.prompt;
if (containerInput.isScheduledTask) {
prompt = `[SCHEDULED TASK - The following message was sent automatically and is not coming directly from the user or group.]\n\n${prompt}`;
}
const pending = drainIpcInput();
if (pending.length > 0) {
log(`Draining ${pending.length} pending IPC messages into initial prompt`);
prompt += '\n' + pending.join('\n');
}
// Query loop: run query → wait for IPC message → run new query → repeat
let resumeAt: string | undefined;
try {
while (true) {
log(`Starting query (session: ${sessionId || 'new'}, resumeAt: ${resumeAt || 'latest'})...`);
const queryResult = await runQuery(prompt, sessionId, mcpServerPath, containerInput, sdkEnv, resumeAt);
if (queryResult.newSessionId) {
sessionId = queryResult.newSessionId;
}
if (queryResult.lastAssistantUuid) {
resumeAt = queryResult.lastAssistantUuid;
}
// If _close was consumed during the query, exit immediately.
// Don't emit a session-update marker (it would reset the host's
// idle timer and cause a 30-min delay before the next _close).
if (queryResult.closedDuringQuery) {
log('Close sentinel consumed during query, exiting');
break;
}
// Emit session update so host can track it
writeOutput({ status: 'success', result: null, newSessionId: sessionId });
log('Query ended, waiting for next IPC message...');
// Wait for the next message or _close sentinel
const nextMessage = await waitForIpcMessage();
if (nextMessage === null) {
log('Close sentinel received, exiting');
break;
}
log(`Got new message (${nextMessage.length} chars), starting new query`);
prompt = nextMessage;
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
log(`Agent error: ${errorMessage}`);
writeOutput({
status: 'error',
result: null,
newSessionId: sessionId,
error: errorMessage
});
process.exit(1);
}
}
main();
@@ -1,32 +0,0 @@
# Intent: container/agent-runner/src/index.ts modifications
## What changed
Added Gmail MCP server to the agent's available tools so it can read and send emails.
## Key sections
### mcpServers (inside runQuery → query() call)
- Added: `gmail` MCP server alongside the existing `nanoclaw` server:
```
gmail: {
command: 'npx',
args: ['-y', '@gongrzhe/server-gmail-autoauth-mcp'],
},
```
### allowedTools (inside runQuery → query() call)
- Added: `'mcp__gmail__*'` to allow all Gmail MCP tools
## Invariants
- The `nanoclaw` MCP server configuration is unchanged
- All existing allowed tools are preserved
- The query loop, IPC handling, MessageStream, and all other logic is untouched
- Hooks (PreCompact, sanitize Bash) are unchanged
- Output protocol (markers) is unchanged
## Must-keep
- The `nanoclaw` MCP server with its environment variables
- All existing allowedTools entries
- The hook system (PreCompact, PreToolUse sanitize)
- The IPC input/close sentinel handling
- The MessageStream class and query loop
@@ -1,13 +0,0 @@
// Channel self-registration barrel file.
// Each import triggers the channel module's registerChannel() call.
// discord
// gmail
import './gmail.js';
// slack
// telegram
// whatsapp
@@ -1,7 +0,0 @@
# Intent: Add Gmail channel import
Add `import './gmail.js';` to the channel barrel file so the Gmail
module self-registers with the channel registry on startup.
This is an append-only change — existing import lines for other channels
must be preserved.
@@ -1,661 +0,0 @@
/**
* Container Runner for NanoClaw
* Spawns agent execution in containers and handles IPC
*/
import { ChildProcess, exec, spawn } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
CONTAINER_IMAGE,
CONTAINER_MAX_OUTPUT_SIZE,
CONTAINER_TIMEOUT,
DATA_DIR,
GROUPS_DIR,
IDLE_TIMEOUT,
TIMEZONE,
} from './config.js';
import { readEnvFile } from './env.js';
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
import { logger } from './logger.js';
import { CONTAINER_RUNTIME_BIN, readonlyMountArgs, stopContainer } from './container-runtime.js';
import { validateAdditionalMounts } from './mount-security.js';
import { RegisteredGroup } from './types.js';
// Sentinel markers for robust output parsing (must match agent-runner)
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
export interface ContainerInput {
prompt: string;
sessionId?: string;
groupFolder: string;
chatJid: string;
isMain: boolean;
isScheduledTask?: boolean;
assistantName?: string;
secrets?: Record<string, string>;
}
export interface ContainerOutput {
status: 'success' | 'error';
result: string | null;
newSessionId?: string;
error?: string;
}
interface VolumeMount {
hostPath: string;
containerPath: string;
readonly: boolean;
}
function buildVolumeMounts(
group: RegisteredGroup,
isMain: boolean,
): VolumeMount[] {
const mounts: VolumeMount[] = [];
const projectRoot = process.cwd();
const homeDir = os.homedir();
const groupDir = resolveGroupFolderPath(group.folder);
if (isMain) {
// Main gets the project root read-only. Writable paths the agent needs
// (group folder, IPC, .claude/) are mounted separately below.
// Read-only prevents the agent from modifying host application code
// (src/, dist/, package.json, etc.) which would bypass the sandbox
// entirely on next restart.
mounts.push({
hostPath: projectRoot,
containerPath: '/workspace/project',
readonly: true,
});
// Main also gets its group folder as the working directory
mounts.push({
hostPath: groupDir,
containerPath: '/workspace/group',
readonly: false,
});
} else {
// Other groups only get their own folder
mounts.push({
hostPath: groupDir,
containerPath: '/workspace/group',
readonly: false,
});
// Global memory directory (read-only for non-main)
// Only directory mounts are supported, not file mounts
const globalDir = path.join(GROUPS_DIR, 'global');
if (fs.existsSync(globalDir)) {
mounts.push({
hostPath: globalDir,
containerPath: '/workspace/global',
readonly: true,
});
}
}
// Per-group Claude sessions directory (isolated from other groups)
// Each group gets their own .claude/ to prevent cross-group session access
const groupSessionsDir = path.join(
DATA_DIR,
'sessions',
group.folder,
'.claude',
);
fs.mkdirSync(groupSessionsDir, { recursive: true });
const settingsFile = path.join(groupSessionsDir, 'settings.json');
if (!fs.existsSync(settingsFile)) {
fs.writeFileSync(settingsFile, JSON.stringify({
env: {
// Enable agent swarms (subagent orchestration)
// https://code.claude.com/docs/en/agent-teams#orchestrate-teams-of-claude-code-sessions
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
// Load CLAUDE.md from additional mounted directories
// https://code.claude.com/docs/en/memory#load-memory-from-additional-directories
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1',
// Enable Claude's memory feature (persists user preferences between sessions)
// https://code.claude.com/docs/en/memory#manage-auto-memory
CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
},
}, null, 2) + '\n');
}
// Sync skills from container/skills/ into each group's .claude/skills/
const skillsSrc = path.join(process.cwd(), 'container', 'skills');
const skillsDst = path.join(groupSessionsDir, 'skills');
if (fs.existsSync(skillsSrc)) {
for (const skillDir of fs.readdirSync(skillsSrc)) {
const srcDir = path.join(skillsSrc, skillDir);
if (!fs.statSync(srcDir).isDirectory()) continue;
const dstDir = path.join(skillsDst, skillDir);
fs.cpSync(srcDir, dstDir, { recursive: true });
}
}
mounts.push({
hostPath: groupSessionsDir,
containerPath: '/home/node/.claude',
readonly: false,
});
// Gmail credentials directory (for Gmail MCP inside the container)
const gmailDir = path.join(homeDir, '.gmail-mcp');
if (fs.existsSync(gmailDir)) {
mounts.push({
hostPath: gmailDir,
containerPath: '/home/node/.gmail-mcp',
readonly: false, // MCP may need to refresh OAuth tokens
});
}
// Per-group IPC namespace: each group gets its own IPC directory
// This prevents cross-group privilege escalation via IPC
const groupIpcDir = resolveGroupIpcPath(group.folder);
fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true });
fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true });
fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true });
mounts.push({
hostPath: groupIpcDir,
containerPath: '/workspace/ipc',
readonly: false,
});
// Copy agent-runner source into a per-group writable location so agents
// can customize it (add tools, change behavior) without affecting other
// groups. Recompiled on container startup via entrypoint.sh.
const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src');
const groupAgentRunnerDir = path.join(DATA_DIR, 'sessions', group.folder, 'agent-runner-src');
if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) {
fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true });
}
mounts.push({
hostPath: groupAgentRunnerDir,
containerPath: '/app/src',
readonly: false,
});
// Additional mounts validated against external allowlist (tamper-proof from containers)
if (group.containerConfig?.additionalMounts) {
const validatedMounts = validateAdditionalMounts(
group.containerConfig.additionalMounts,
group.name,
isMain,
);
mounts.push(...validatedMounts);
}
return mounts;
}
/**
* Read allowed secrets from .env for passing to the container via stdin.
* Secrets are never written to disk or mounted as files.
*/
function readSecrets(): Record<string, string> {
return readEnvFile(['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']);
}
function buildContainerArgs(mounts: VolumeMount[], containerName: string): string[] {
const args: string[] = ['run', '-i', '--rm', '--name', containerName];
// Pass host timezone so container's local time matches the user's
args.push('-e', `TZ=${TIMEZONE}`);
// Run as host user so bind-mounted files are accessible.
// Skip when running as root (uid 0), as the container's node user (uid 1000),
// or when getuid is unavailable (native Windows without WSL).
const hostUid = process.getuid?.();
const hostGid = process.getgid?.();
if (hostUid != null && hostUid !== 0 && hostUid !== 1000) {
args.push('--user', `${hostUid}:${hostGid}`);
args.push('-e', 'HOME=/home/node');
}
for (const mount of mounts) {
if (mount.readonly) {
args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath));
} else {
args.push('-v', `${mount.hostPath}:${mount.containerPath}`);
}
}
args.push(CONTAINER_IMAGE);
return args;
}
export async function runContainerAgent(
group: RegisteredGroup,
input: ContainerInput,
onProcess: (proc: ChildProcess, containerName: string) => void,
onOutput?: (output: ContainerOutput) => Promise<void>,
): Promise<ContainerOutput> {
const startTime = Date.now();
const groupDir = resolveGroupFolderPath(group.folder);
fs.mkdirSync(groupDir, { recursive: true });
const mounts = buildVolumeMounts(group, input.isMain);
const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
const containerName = `nanoclaw-${safeName}-${Date.now()}`;
const containerArgs = buildContainerArgs(mounts, containerName);
logger.debug(
{
group: group.name,
containerName,
mounts: mounts.map(
(m) =>
`${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`,
),
containerArgs: containerArgs.join(' '),
},
'Container mount configuration',
);
logger.info(
{
group: group.name,
containerName,
mountCount: mounts.length,
isMain: input.isMain,
},
'Spawning container agent',
);
const logsDir = path.join(groupDir, 'logs');
fs.mkdirSync(logsDir, { recursive: true });
return new Promise((resolve) => {
const container = spawn(CONTAINER_RUNTIME_BIN, containerArgs, {
stdio: ['pipe', 'pipe', 'pipe'],
});
onProcess(container, containerName);
let stdout = '';
let stderr = '';
let stdoutTruncated = false;
let stderrTruncated = false;
// Pass secrets via stdin (never written to disk or mounted as files)
input.secrets = readSecrets();
container.stdin.write(JSON.stringify(input));
container.stdin.end();
// Remove secrets from input so they don't appear in logs
delete input.secrets;
// Streaming output: parse OUTPUT_START/END marker pairs as they arrive
let parseBuffer = '';
let newSessionId: string | undefined;
let outputChain = Promise.resolve();
container.stdout.on('data', (data) => {
const chunk = data.toString();
// Always accumulate for logging
if (!stdoutTruncated) {
const remaining = CONTAINER_MAX_OUTPUT_SIZE - stdout.length;
if (chunk.length > remaining) {
stdout += chunk.slice(0, remaining);
stdoutTruncated = true;
logger.warn(
{ group: group.name, size: stdout.length },
'Container stdout truncated due to size limit',
);
} else {
stdout += chunk;
}
}
// Stream-parse for output markers
if (onOutput) {
parseBuffer += chunk;
let startIdx: number;
while ((startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1) {
const endIdx = parseBuffer.indexOf(OUTPUT_END_MARKER, startIdx);
if (endIdx === -1) break; // Incomplete pair, wait for more data
const jsonStr = parseBuffer
.slice(startIdx + OUTPUT_START_MARKER.length, endIdx)
.trim();
parseBuffer = parseBuffer.slice(endIdx + OUTPUT_END_MARKER.length);
try {
const parsed: ContainerOutput = JSON.parse(jsonStr);
if (parsed.newSessionId) {
newSessionId = parsed.newSessionId;
}
hadStreamingOutput = true;
// Activity detected — reset the hard timeout
resetTimeout();
// Call onOutput for all markers (including null results)
// so idle timers start even for "silent" query completions.
outputChain = outputChain.then(() => onOutput(parsed));
} catch (err) {
logger.warn(
{ group: group.name, error: err },
'Failed to parse streamed output chunk',
);
}
}
}
});
container.stderr.on('data', (data) => {
const chunk = data.toString();
const lines = chunk.trim().split('\n');
for (const line of lines) {
if (line) logger.debug({ container: group.folder }, line);
}
// Don't reset timeout on stderr — SDK writes debug logs continuously.
// Timeout only resets on actual output (OUTPUT_MARKER in stdout).
if (stderrTruncated) return;
const remaining = CONTAINER_MAX_OUTPUT_SIZE - stderr.length;
if (chunk.length > remaining) {
stderr += chunk.slice(0, remaining);
stderrTruncated = true;
logger.warn(
{ group: group.name, size: stderr.length },
'Container stderr truncated due to size limit',
);
} else {
stderr += chunk;
}
});
let timedOut = false;
let hadStreamingOutput = false;
const configTimeout = group.containerConfig?.timeout || CONTAINER_TIMEOUT;
// Grace period: hard timeout must be at least IDLE_TIMEOUT + 30s so the
// graceful _close sentinel has time to trigger before the hard kill fires.
const timeoutMs = Math.max(configTimeout, IDLE_TIMEOUT + 30_000);
const killOnTimeout = () => {
timedOut = true;
logger.error({ group: group.name, containerName }, 'Container timeout, stopping gracefully');
exec(stopContainer(containerName), { timeout: 15000 }, (err) => {
if (err) {
logger.warn({ group: group.name, containerName, err }, 'Graceful stop failed, force killing');
container.kill('SIGKILL');
}
});
};
let timeout = setTimeout(killOnTimeout, timeoutMs);
// Reset the timeout whenever there's activity (streaming output)
const resetTimeout = () => {
clearTimeout(timeout);
timeout = setTimeout(killOnTimeout, timeoutMs);
};
container.on('close', (code) => {
clearTimeout(timeout);
const duration = Date.now() - startTime;
if (timedOut) {
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const timeoutLog = path.join(logsDir, `container-${ts}.log`);
fs.writeFileSync(timeoutLog, [
`=== Container Run Log (TIMEOUT) ===`,
`Timestamp: ${new Date().toISOString()}`,
`Group: ${group.name}`,
`Container: ${containerName}`,
`Duration: ${duration}ms`,
`Exit Code: ${code}`,
`Had Streaming Output: ${hadStreamingOutput}`,
].join('\n'));
// Timeout after output = idle cleanup, not failure.
// The agent already sent its response; this is just the
// container being reaped after the idle period expired.
if (hadStreamingOutput) {
logger.info(
{ group: group.name, containerName, duration, code },
'Container timed out after output (idle cleanup)',
);
outputChain.then(() => {
resolve({
status: 'success',
result: null,
newSessionId,
});
});
return;
}
logger.error(
{ group: group.name, containerName, duration, code },
'Container timed out with no output',
);
resolve({
status: 'error',
result: null,
error: `Container timed out after ${configTimeout}ms`,
});
return;
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const logFile = path.join(logsDir, `container-${timestamp}.log`);
const isVerbose = process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace';
const logLines = [
`=== Container Run Log ===`,
`Timestamp: ${new Date().toISOString()}`,
`Group: ${group.name}`,
`IsMain: ${input.isMain}`,
`Duration: ${duration}ms`,
`Exit Code: ${code}`,
`Stdout Truncated: ${stdoutTruncated}`,
`Stderr Truncated: ${stderrTruncated}`,
``,
];
const isError = code !== 0;
if (isVerbose || isError) {
logLines.push(
`=== Input ===`,
JSON.stringify(input, null, 2),
``,
`=== Container Args ===`,
containerArgs.join(' '),
``,
`=== Mounts ===`,
mounts
.map(
(m) =>
`${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`,
)
.join('\n'),
``,
`=== Stderr${stderrTruncated ? ' (TRUNCATED)' : ''} ===`,
stderr,
``,
`=== Stdout${stdoutTruncated ? ' (TRUNCATED)' : ''} ===`,
stdout,
);
} else {
logLines.push(
`=== Input Summary ===`,
`Prompt length: ${input.prompt.length} chars`,
`Session ID: ${input.sessionId || 'new'}`,
``,
`=== Mounts ===`,
mounts
.map((m) => `${m.containerPath}${m.readonly ? ' (ro)' : ''}`)
.join('\n'),
``,
);
}
fs.writeFileSync(logFile, logLines.join('\n'));
logger.debug({ logFile, verbose: isVerbose }, 'Container log written');
if (code !== 0) {
logger.error(
{
group: group.name,
code,
duration,
stderr,
stdout,
logFile,
},
'Container exited with error',
);
resolve({
status: 'error',
result: null,
error: `Container exited with code ${code}: ${stderr.slice(-200)}`,
});
return;
}
// Streaming mode: wait for output chain to settle, return completion marker
if (onOutput) {
outputChain.then(() => {
logger.info(
{ group: group.name, duration, newSessionId },
'Container completed (streaming mode)',
);
resolve({
status: 'success',
result: null,
newSessionId,
});
});
return;
}
// Legacy mode: parse the last output marker pair from accumulated stdout
try {
// Extract JSON between sentinel markers for robust parsing
const startIdx = stdout.indexOf(OUTPUT_START_MARKER);
const endIdx = stdout.indexOf(OUTPUT_END_MARKER);
let jsonLine: string;
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
jsonLine = stdout
.slice(startIdx + OUTPUT_START_MARKER.length, endIdx)
.trim();
} else {
// Fallback: last non-empty line (backwards compatibility)
const lines = stdout.trim().split('\n');
jsonLine = lines[lines.length - 1];
}
const output: ContainerOutput = JSON.parse(jsonLine);
logger.info(
{
group: group.name,
duration,
status: output.status,
hasResult: !!output.result,
},
'Container completed',
);
resolve(output);
} catch (err) {
logger.error(
{
group: group.name,
stdout,
stderr,
error: err,
},
'Failed to parse container output',
);
resolve({
status: 'error',
result: null,
error: `Failed to parse container output: ${err instanceof Error ? err.message : String(err)}`,
});
}
});
container.on('error', (err) => {
clearTimeout(timeout);
logger.error({ group: group.name, containerName, error: err }, 'Container spawn error');
resolve({
status: 'error',
result: null,
error: `Container spawn error: ${err.message}`,
});
});
});
}
export function writeTasksSnapshot(
groupFolder: string,
isMain: boolean,
tasks: Array<{
id: string;
groupFolder: string;
prompt: string;
schedule_type: string;
schedule_value: string;
status: string;
next_run: string | null;
}>,
): void {
// Write filtered tasks to the group's IPC directory
const groupIpcDir = resolveGroupIpcPath(groupFolder);
fs.mkdirSync(groupIpcDir, { recursive: true });
// Main sees all tasks, others only see their own
const filteredTasks = isMain
? tasks
: tasks.filter((t) => t.groupFolder === groupFolder);
const tasksFile = path.join(groupIpcDir, 'current_tasks.json');
fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2));
}
export interface AvailableGroup {
jid: string;
name: string;
lastActivity: string;
isRegistered: boolean;
}
/**
* Write available groups snapshot for the container to read.
* Only main group can see all available groups (for activation).
* Non-main groups only see their own registration status.
*/
export function writeGroupsSnapshot(
groupFolder: string,
isMain: boolean,
groups: AvailableGroup[],
registeredJids: Set<string>,
): void {
const groupIpcDir = resolveGroupIpcPath(groupFolder);
fs.mkdirSync(groupIpcDir, { recursive: true });
// Main sees all groups; others see nothing (they can't activate groups)
const visibleGroups = isMain ? groups : [];
const groupsFile = path.join(groupIpcDir, 'available_groups.json');
fs.writeFileSync(
groupsFile,
JSON.stringify(
{
groups: visibleGroups,
lastSync: new Date().toISOString(),
},
null,
2,
),
);
}
@@ -1,37 +0,0 @@
# Intent: src/container-runner.ts modifications
## What changed
Added a volume mount for Gmail OAuth credentials (`~/.gmail-mcp/`) so the Gmail MCP server inside the container can authenticate with Google.
## Key sections
### buildVolumeMounts()
- Added: Gmail credentials mount after the `.claude` sessions mount:
```
const gmailDir = path.join(homeDir, '.gmail-mcp');
if (fs.existsSync(gmailDir)) {
mounts.push({
hostPath: gmailDir,
containerPath: '/home/node/.gmail-mcp',
readonly: false, // MCP may need to refresh OAuth tokens
});
}
```
- Uses `os.homedir()` to resolve the home directory
- Mount is read-write because the Gmail MCP server needs to refresh OAuth tokens
- Mount is conditional — only added if `~/.gmail-mcp/` exists on the host
### Imports
- Added: `os` import for `os.homedir()`
## Invariants
- All existing mounts are unchanged
- Mount ordering is preserved (Gmail added after session mounts, before additional mounts)
- The `buildContainerArgs`, `runContainerAgent`, and all other functions are untouched
- Additional mount validation via `validateAdditionalMounts` is unchanged
## Must-keep
- All existing volume mounts (project root, group dir, global, sessions, IPC, agent-runner, additional)
- The mount security model (allowlist validation for additional mounts)
- The `readSecrets` function and stdin-based secret passing
- Container lifecycle (spawn, timeout, output parsing)
@@ -1,98 +0,0 @@
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';
describe('add-gmail skill package', () => {
const skillDir = path.resolve(__dirname, '..');
it('has a valid manifest', () => {
const manifestPath = path.join(skillDir, 'manifest.yaml');
expect(fs.existsSync(manifestPath)).toBe(true);
const content = fs.readFileSync(manifestPath, 'utf-8');
expect(content).toContain('skill: gmail');
expect(content).toContain('version: 1.0.0');
expect(content).toContain('googleapis');
});
it('has channel file with self-registration', () => {
const channelFile = path.join(
skillDir,
'add',
'src',
'channels',
'gmail.ts',
);
expect(fs.existsSync(channelFile)).toBe(true);
const content = fs.readFileSync(channelFile, 'utf-8');
expect(content).toContain('class GmailChannel');
expect(content).toContain('implements Channel');
expect(content).toContain("registerChannel('gmail'");
});
it('has channel barrel file modification', () => {
const indexFile = path.join(
skillDir,
'modify',
'src',
'channels',
'index.ts',
);
expect(fs.existsSync(indexFile)).toBe(true);
const indexContent = fs.readFileSync(indexFile, 'utf-8');
expect(indexContent).toContain("import './gmail.js'");
});
it('has intent files for modified files', () => {
expect(
fs.existsSync(
path.join(skillDir, 'modify', 'src', 'channels', 'index.ts.intent.md'),
),
).toBe(true);
});
it('has container-runner mount modification', () => {
const crFile = path.join(
skillDir,
'modify',
'src',
'container-runner.ts',
);
expect(fs.existsSync(crFile)).toBe(true);
const content = fs.readFileSync(crFile, 'utf-8');
expect(content).toContain('.gmail-mcp');
});
it('has agent-runner Gmail MCP server modification', () => {
const arFile = path.join(
skillDir,
'modify',
'container',
'agent-runner',
'src',
'index.ts',
);
expect(fs.existsSync(arFile)).toBe(true);
const content = fs.readFileSync(arFile, 'utf-8');
expect(content).toContain('mcp__gmail__*');
expect(content).toContain('@gongrzhe/server-gmail-autoauth-mcp');
});
it('has test file for the channel', () => {
const testFile = path.join(
skillDir,
'add',
'src',
'channels',
'gmail.test.ts',
);
expect(fs.existsSync(testFile)).toBe(true);
const testContent = fs.readFileSync(testFile, 'utf-8');
expect(testContent).toContain("describe('GmailChannel'");
});
});
+94
View File
@@ -0,0 +1,94 @@
---
name: add-image-vision
description: Add image vision to NanoClaw agents. Resizes and processes WhatsApp image attachments, then sends them to Claude as multimodal content blocks.
---
# Image Vision Skill
Adds the ability for NanoClaw agents to see and understand images sent via WhatsApp. Images are downloaded, resized with sharp, saved to the group workspace, and passed to the agent as base64-encoded multimodal content blocks.
## Phase 1: Pre-flight
1. Check if `src/image.ts` exists — skip to Phase 3 if already applied
2. Confirm `sharp` is installable (native bindings require build tools)
**Prerequisite:** WhatsApp must be installed first (`skill/whatsapp` merged). This skill modifies WhatsApp channel files.
## Phase 2: Apply Code Changes
### Ensure WhatsApp fork remote
```bash
git remote -v
```
If `whatsapp` is missing, add it:
```bash
git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
```
### Merge the skill branch
```bash
git fetch whatsapp skill/image-vision
git merge whatsapp/skill/image-vision || {
git checkout --theirs package-lock.json
git add package-lock.json
git merge --continue
}
```
This merges in:
- `src/image.ts` (image download, resize via sharp, base64 encoding)
- `src/image.test.ts` (8 unit tests)
- Image attachment handling in `src/channels/whatsapp.ts`
- Image passing to agent in `src/index.ts` and `src/container-runner.ts`
- Image content block support in `container/agent-runner/src/index.ts`
- `sharp` npm dependency in `package.json`
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
### Validate code changes
```bash
npm install
npm run build
npx vitest run src/image.test.ts
```
All tests must pass and build must be clean before proceeding.
## Phase 3: Configure
1. Rebuild the container (agent-runner changes need a rebuild):
```bash
./container/build.sh
```
2. Sync agent-runner source to group caches:
```bash
for dir in data/sessions/*/agent-runner-src/; do
cp container/agent-runner/src/*.ts "$dir"
done
```
3. Restart the service:
```bash
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
```
## Phase 4: Verify
1. Send an image in a registered WhatsApp group
2. Check the agent responds with understanding of the image content
3. Check logs for "Processed image attachment":
```bash
tail -50 groups/*/logs/container-*.log
```
## Troubleshooting
- **"Image - download failed"**: Check WhatsApp connection stability. The download may timeout on slow connections.
- **"Image - processing failed"**: Sharp may not be installed correctly. Run `npm ls sharp` to verify.
- **Agent doesn't mention image content**: Check container logs for "Loaded image" messages. If missing, ensure agent-runner source was synced to group caches.
+133
View File
@@ -0,0 +1,133 @@
---
name: add-macos-statusbar
description: Add a macOS menu bar status indicator for NanoClaw. Shows a bolt icon with a green/red dot indicating whether NanoClaw is running, with Start, Stop, and Restart controls. macOS only.
---
# Add macOS Menu Bar Status Indicator
Adds a persistent menu bar icon that shows NanoClaw's running status and lets the user
start, stop, or restart the service — similar to how Docker Desktop appears in the menu bar.
**macOS only.** Requires Xcode Command Line Tools (`swiftc`).
## Phase 1: Pre-flight
### Check platform
If not on macOS, stop and tell the user:
> This skill is macOS only. The menu bar status indicator uses AppKit and requires `swiftc` (Xcode Command Line Tools).
### Check for swiftc
```bash
which swiftc
```
If not found, tell the user:
> Xcode Command Line Tools are required. Install them by running:
>
> ```bash
> xcode-select --install
> ```
>
> Then re-run `/add-macos-statusbar`.
### Check if already installed
```bash
launchctl list | grep com.nanoclaw.statusbar
```
If it returns a PID (not `-`), tell the user it's already installed and skip to Phase 3 (Verify).
## Phase 2: Compile and Install
### Compile the Swift binary
The source lives in the skill directory. Compile it into `dist/`:
```bash
mkdir -p dist
swiftc -O -o dist/statusbar "${CLAUDE_SKILL_DIR}/add/src/statusbar.swift"
```
This produces a small native binary at `dist/statusbar`.
On macOS Sequoia or later, clear the quarantine attribute so the binary can run:
```bash
xattr -cr dist/statusbar
```
### Create the launchd plist
Determine the absolute project root and home directory:
```bash
pwd
echo $HOME
```
Create `~/Library/LaunchAgents/com.nanoclaw.statusbar.plist`, substituting the actual values
for `{PROJECT_ROOT}` and `{HOME}`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.nanoclaw.statusbar</string>
<key>ProgramArguments</key>
<array>
<string>{PROJECT_ROOT}/dist/statusbar</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key>
<string>{HOME}</string>
</dict>
<key>StandardOutPath</key>
<string>{PROJECT_ROOT}/logs/statusbar.log</string>
<key>StandardErrorPath</key>
<string>{PROJECT_ROOT}/logs/statusbar.error.log</string>
</dict>
</plist>
```
### Load the service
```bash
launchctl load ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist
```
## Phase 3: Verify
```bash
launchctl list | grep com.nanoclaw.statusbar
```
The first column should show a PID (not `-`).
Tell the user:
> The bolt icon should now appear in your macOS menu bar. Click it to see NanoClaw's status and control the service.
>
> - **Green dot** — NanoClaw is running
> - **Red dot** — NanoClaw is stopped
>
> Use **Restart** after making code changes, and **View Logs** to open the log file directly.
## Removal
```bash
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist
rm ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist
rm dist/statusbar
```
@@ -0,0 +1,147 @@
import AppKit
class StatusBarController: NSObject {
private var statusItem: NSStatusItem!
private var isRunning = false
private var timer: Timer?
private let plistPath = "\(NSHomeDirectory())/Library/LaunchAgents/com.nanoclaw.plist"
/// Derive the NanoClaw project root from the binary location.
/// The binary is compiled to {project}/dist/statusbar, so the parent of
/// the parent directory is the project root.
private static let projectRoot: String = {
let binary = URL(fileURLWithPath: CommandLine.arguments[0]).resolvingSymlinksInPath()
return binary.deletingLastPathComponent().deletingLastPathComponent().path
}()
override init() {
super.init()
setupStatusItem()
isRunning = checkRunning()
updateMenu()
// Poll every 5 seconds to reflect external state changes
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
guard let self else { return }
let current = self.checkRunning()
if current != self.isRunning {
self.isRunning = current
self.updateMenu()
}
}
}
private func setupStatusItem() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = statusItem.button {
if let image = NSImage(systemSymbolName: "bolt.fill", accessibilityDescription: "NanoClaw") {
image.isTemplate = true
button.image = image
} else {
button.title = ""
}
button.toolTip = "NanoClaw"
}
}
private func checkRunning() -> Bool {
let task = Process()
task.launchPath = "/bin/launchctl"
task.arguments = ["list", "com.nanoclaw"]
let pipe = Pipe()
task.standardOutput = pipe
task.standardError = Pipe()
guard (try? task.run()) != nil else { return false }
task.waitUntilExit()
if task.terminationStatus != 0 { return false }
let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
// launchctl list output: "PID\tExitCode\tLabel" "-" means not running
let pid = output.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: "\t").first ?? "-"
return pid != "-"
}
private func updateMenu() {
let menu = NSMenu()
// Status row with colored dot
let statusItem = NSMenuItem()
let dot = ""
let dotColor: NSColor = isRunning ? .systemGreen : .systemRed
let attr = NSMutableAttributedString(string: dot, attributes: [.foregroundColor: dotColor])
let label = isRunning ? "NanoClaw is running" : "NanoClaw is stopped"
attr.append(NSAttributedString(string: label, attributes: [.foregroundColor: NSColor.labelColor]))
statusItem.attributedTitle = attr
statusItem.isEnabled = false
menu.addItem(statusItem)
menu.addItem(NSMenuItem.separator())
if isRunning {
let stop = NSMenuItem(title: "Stop", action: #selector(stopService), keyEquivalent: "")
stop.target = self
menu.addItem(stop)
let restart = NSMenuItem(title: "Restart", action: #selector(restartService), keyEquivalent: "r")
restart.target = self
menu.addItem(restart)
} else {
let start = NSMenuItem(title: "Start", action: #selector(startService), keyEquivalent: "")
start.target = self
menu.addItem(start)
}
menu.addItem(NSMenuItem.separator())
let logs = NSMenuItem(title: "View Logs", action: #selector(viewLogs), keyEquivalent: "")
logs.target = self
menu.addItem(logs)
self.statusItem.menu = menu
}
@objc private func startService() {
run("/bin/launchctl", ["load", plistPath])
refresh(after: 2)
}
@objc private func stopService() {
run("/bin/launchctl", ["unload", plistPath])
refresh(after: 2)
}
@objc private func restartService() {
let uid = getuid()
run("/bin/launchctl", ["kickstart", "-k", "gui/\(uid)/com.nanoclaw"])
refresh(after: 3)
}
@objc private func viewLogs() {
let logPath = "\(StatusBarController.projectRoot)/logs/nanoclaw.log"
NSWorkspace.shared.open(URL(fileURLWithPath: logPath))
}
private func refresh(after seconds: Double) {
DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { [weak self] in
guard let self else { return }
self.isRunning = self.checkRunning()
self.updateMenu()
}
}
@discardableResult
private func run(_ path: String, _ args: [String]) -> Int32 {
let task = Process()
task.launchPath = path
task.arguments = args
task.standardOutput = Pipe()
task.standardError = Pipe()
try? task.run()
task.waitUntilExit()
return task.terminationStatus
}
}
let app = NSApplication.shared
app.setActivationPolicy(.accessory)
let controller = StatusBarController()
app.run()
+193
View File
@@ -0,0 +1,193 @@
---
name: add-ollama-tool
description: Add Ollama MCP server so the container agent can call local models and optionally manage the Ollama model library.
---
# Add Ollama Integration
This skill adds a stdio-based MCP server that exposes local Ollama models as tools for the container agent. Claude remains the orchestrator but can offload work to local models, and can optionally manage the model library directly.
Core tools (always available):
- `ollama_list_models` — list installed Ollama models with name, size, and family
- `ollama_generate` — send a prompt to a specified model and return the response
Management tools (opt-in via `OLLAMA_ADMIN_TOOLS=true`):
- `ollama_pull_model` — pull (download) a model from the Ollama registry
- `ollama_delete_model` — delete a locally installed model to free disk space
- `ollama_show_model` — show model details: modelfile, parameters, and architecture info
- `ollama_list_running` — list models currently loaded in memory with memory usage and processor type
## Phase 1: Pre-flight
### Check if already applied
Check if `container/agent-runner/src/ollama-mcp-stdio.ts` exists. If it does, skip to Phase 3 (Configure).
### Check prerequisites
Verify Ollama is installed and running on the host:
```bash
ollama list
```
If Ollama is not installed, direct the user to https://ollama.com/download.
If no models are installed, suggest pulling one:
> You need at least one model. I recommend:
>
> ```bash
> ollama pull gemma3:1b # Small, fast (1GB)
> ollama pull llama3.2 # Good general purpose (2GB)
> ollama pull qwen3-coder:30b # Best for code tasks (18GB)
> ```
## Phase 2: Apply Code Changes
### Ensure upstream remote
```bash
git remote -v
```
If `upstream` is missing, add it:
```bash
git remote add upstream https://github.com/qwibitai/nanoclaw.git
```
### Merge the skill branch
```bash
git fetch upstream skill/ollama-tool
git merge upstream/skill/ollama-tool
```
This merges in:
- `container/agent-runner/src/ollama-mcp-stdio.ts` (Ollama MCP server)
- `scripts/ollama-watch.sh` (macOS notification watcher)
- Ollama MCP config in `container/agent-runner/src/index.ts` (allowedTools + mcpServers)
- `[OLLAMA]` log surfacing in `src/container-runner.ts`
- `OLLAMA_HOST` in `.env.example`
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
### Copy to per-group agent-runner
Existing groups have a cached copy of the agent-runner source. Copy the new files:
```bash
for dir in data/sessions/*/agent-runner-src; do
cp container/agent-runner/src/ollama-mcp-stdio.ts "$dir/"
cp container/agent-runner/src/index.ts "$dir/"
done
```
### Validate code changes
```bash
npm run build
./container/build.sh
```
Build must be clean before proceeding.
## Phase 3: Configure
### Enable model management tools (optional)
Ask the user:
> Would you like the agent to be able to **manage Ollama models** (pull, delete, inspect, list running)?
>
> - **Yes** — adds tools to pull new models, delete old ones, show model info, and check what's loaded in memory
> - **No** — the agent can only list installed models and generate responses (you manage models yourself on the host)
If the user wants management tools, add to `.env`:
```bash
OLLAMA_ADMIN_TOOLS=true
```
If they decline (or don't answer), do not add the variable — management tools will be disabled by default.
### Set Ollama host (optional)
By default, the MCP server connects to `http://host.docker.internal:11434` (Docker Desktop) with a fallback to `localhost`. To use a custom Ollama host, add to `.env`:
```bash
OLLAMA_HOST=http://your-ollama-host:11434
```
### Restart the service
```bash
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
## Phase 4: Verify
### Test inference
Tell the user:
> Send a message like: "use ollama to tell me the capital of France"
>
> The agent should use `ollama_list_models` to find available models, then `ollama_generate` to get a response.
### Test model management (if enabled)
If `OLLAMA_ADMIN_TOOLS=true` was set, tell the user:
> Send a message like: "pull the gemma3:1b model" or "which ollama models are currently loaded in memory?"
>
> The agent should call `ollama_pull_model` or `ollama_list_running` respectively.
### Monitor activity (optional)
Run the watcher script for macOS notifications when Ollama is used:
```bash
./scripts/ollama-watch.sh
```
### Check logs if needed
```bash
tail -f logs/nanoclaw.log | grep -i ollama
```
Look for:
- `[OLLAMA] >>> Generating` — generation started
- `[OLLAMA] <<< Done` — generation completed
- `[OLLAMA] Pulling model:` — pull in progress (management tools)
- `[OLLAMA] Deleted:` — model removed (management tools)
## Troubleshooting
### Agent says "Ollama is not installed"
The agent is trying to run `ollama` CLI inside the container instead of using the MCP tools. This means:
1. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `ollama` entry in `mcpServers`
2. The per-group source wasn't updated — re-copy files (see Phase 2)
3. The container wasn't rebuilt — run `./container/build.sh`
### "Failed to connect to Ollama"
1. Verify Ollama is running: `ollama list`
2. Check Docker can reach the host: `docker run --rm curlimages/curl curl -s http://host.docker.internal:11434/api/tags`
3. If using a custom host, check `OLLAMA_HOST` in `.env`
### Agent doesn't use Ollama tools
The agent may not know about the tools. Try being explicit: "use the ollama_generate tool with gemma3:1b to answer: ..."
### `ollama_pull_model` times out on large models
Large models (7B+) can take several minutes. The tool uses `stream: false` so it blocks until complete — this is intentional. For very large pulls, use the host CLI directly: `ollama pull <model>`
### Management tools not showing up
Ensure `OLLAMA_ADMIN_TOOLS=true` is set in `.env` and the service was restarted after adding it.
+104
View File
@@ -0,0 +1,104 @@
---
name: add-pdf-reader
description: Add PDF reading to NanoClaw agents. Extracts text from PDFs via pdftotext CLI. Handles WhatsApp attachments, URLs, and local files.
---
# Add PDF Reader
Adds PDF reading capability to all container agents using poppler-utils (pdftotext/pdfinfo). PDFs sent as WhatsApp attachments are auto-downloaded to the group workspace.
## Phase 1: Pre-flight
1. Check if `container/skills/pdf-reader/pdf-reader` exists — skip to Phase 3 if already applied
2. Confirm WhatsApp is installed first (`skill/whatsapp` merged). This skill modifies WhatsApp channel files.
## Phase 2: Apply Code Changes
### Ensure WhatsApp fork remote
```bash
git remote -v
```
If `whatsapp` is missing, add it:
```bash
git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
```
### Merge the skill branch
```bash
git fetch whatsapp skill/pdf-reader
git merge whatsapp/skill/pdf-reader || {
git checkout --theirs package-lock.json
git add package-lock.json
git merge --continue
}
```
This merges in:
- `container/skills/pdf-reader/SKILL.md` (agent-facing documentation)
- `container/skills/pdf-reader/pdf-reader` (CLI script)
- `poppler-utils` in `container/Dockerfile`
- PDF attachment download in `src/channels/whatsapp.ts`
- PDF tests in `src/channels/whatsapp.test.ts`
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
### Validate
```bash
npm run build
npx vitest run src/channels/whatsapp.test.ts
```
### Rebuild container
```bash
./container/build.sh
```
### Restart service
```bash
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
## Phase 3: Verify
### Test PDF extraction
Send a PDF file in any registered WhatsApp chat. The agent should:
1. Download the PDF to `attachments/`
2. Respond acknowledging the PDF
3. Be able to extract text when asked
### Test URL fetching
Ask the agent to read a PDF from a URL. It should use `pdf-reader fetch <url>`.
### Check logs if needed
```bash
tail -f logs/nanoclaw.log | grep -i pdf
```
Look for:
- `Downloaded PDF attachment` — successful download
- `Failed to download PDF attachment` — media download issue
## Troubleshooting
### Agent says pdf-reader command not found
Container needs rebuilding. Run `./container/build.sh` and restart the service.
### PDF text extraction is empty
The PDF may be scanned (image-based). pdftotext only handles text-based PDFs. Consider using the agent-browser to open the PDF visually instead.
### WhatsApp PDF not detected
Verify the message has `documentMessage` with `mimetype: application/pdf`. Some file-sharing apps send PDFs as generic files without the correct mimetype.
+117
View File
@@ -0,0 +1,117 @@
---
name: add-reactions
description: Add WhatsApp emoji reaction support — receive, send, store, and search reactions.
---
# Add Reactions
This skill adds emoji reaction support to NanoClaw's WhatsApp channel: receive and store reactions, send reactions from the container agent via MCP tool, and query reaction history from SQLite.
## Phase 1: Pre-flight
### Check if already applied
Check if `src/status-tracker.ts` exists:
```bash
test -f src/status-tracker.ts && echo "Already applied" || echo "Not applied"
```
If already applied, skip to Phase 3 (Verify).
## Phase 2: Apply Code Changes
### Ensure WhatsApp fork remote
```bash
git remote -v
```
If `whatsapp` is missing, add it:
```bash
git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
```
### Merge the skill branch
```bash
git fetch whatsapp skill/reactions
git merge whatsapp/skill/reactions || {
git checkout --theirs package-lock.json
git add package-lock.json
git merge --continue
}
```
This adds:
- `scripts/migrate-reactions.ts` (database migration for `reactions` table with composite PK and indexes)
- `src/status-tracker.ts` (forward-only emoji state machine for message lifecycle signaling, with persistence and retry)
- `src/status-tracker.test.ts` (unit tests for StatusTracker)
- `container/skills/reactions/SKILL.md` (agent-facing documentation for the `react_to_message` MCP tool)
- Reaction support in `src/db.ts`, `src/channels/whatsapp.ts`, `src/types.ts`, `src/ipc.ts`, `src/index.ts`, `src/group-queue.ts`, and `container/agent-runner/src/ipc-mcp-stdio.ts`
### Run database migration
```bash
npx tsx scripts/migrate-reactions.ts
```
### Validate code changes
```bash
npm test
npm run build
```
All tests must pass and build must be clean before proceeding.
## Phase 3: Verify
### Build and restart
```bash
npm run build
```
Linux:
```bash
systemctl --user restart nanoclaw
```
macOS:
```bash
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
```
### Test receiving reactions
1. Send a message from your phone
2. React to it with an emoji on WhatsApp
3. Check the database:
```bash
sqlite3 store/messages.db "SELECT * FROM reactions ORDER BY timestamp DESC LIMIT 5;"
```
### Test sending reactions
Ask the agent to react to a message via the `react_to_message` MCP tool. Check your phone — the reaction should appear on the message.
## Troubleshooting
### Reactions not appearing in database
- Check NanoClaw logs for `Failed to process reaction` errors
- Verify the chat is registered
- Confirm the service is running
### Migration fails
- Ensure `store/messages.db` exists and is accessible
- If "table reactions already exists", the migration already ran — skip it
### Agent can't send reactions
- Check IPC logs for `Unauthorized IPC reaction attempt blocked` — the agent can only react in its own group's chat
- Verify WhatsApp is connected: check logs for connection status
+32 -40
View File
@@ -5,13 +5,13 @@ description: Add Slack as a channel. Can replace WhatsApp entirely or run alongs
# Add Slack Channel
This skill adds Slack support to NanoClaw using the skills engine for deterministic code changes, then walks through interactive setup.
This skill adds Slack support to NanoClaw, then walks through interactive setup.
## Phase 1: Pre-flight
### Check if already applied
Read `.nanoclaw/state.yaml`. If `slack` is in `applied_skills`, skip to Phase 3 (Setup). The code changes are already in place.
Check if `src/channels/slack.ts` exists. If it does, skip to Phase 3 (Setup). The code changes are already in place.
### Ask the user
@@ -19,42 +19,47 @@ Read `.nanoclaw/state.yaml`. If `slack` is in `applied_skills`, skip to Phase 3
## Phase 2: Apply Code Changes
Run the skills engine to apply this skill's code package. The package files are in this directory alongside this SKILL.md.
### Initialize skills system (if needed)
If `.nanoclaw/` directory doesn't exist yet:
### Ensure channel remote
```bash
npx tsx scripts/apply-skill.ts --init
git remote -v
```
Or call `initSkillsSystem()` from `skills-engine/migrate.ts`.
### Apply the skill
If `slack` is missing, add it:
```bash
npx tsx scripts/apply-skill.ts .claude/skills/add-slack
git remote add slack https://github.com/qwibitai/nanoclaw-slack.git
```
This deterministically:
- Adds `src/channels/slack.ts` (SlackChannel class with self-registration via `registerChannel`)
- Adds `src/channels/slack.test.ts` (46 unit tests)
- Appends `import './slack.js'` to the channel barrel file `src/channels/index.ts`
- Installs the `@slack/bolt` npm dependency
- Records the application in `.nanoclaw/state.yaml`
### Merge the skill branch
If the apply reports merge conflicts, read the intent file:
- `modify/src/channels/index.ts.intent.md` — what changed and invariants
```bash
git fetch slack main
git merge slack/main || {
git checkout --theirs package-lock.json
git add package-lock.json
git merge --continue
}
```
This merges in:
- `src/channels/slack.ts` (SlackChannel class with self-registration via `registerChannel`)
- `src/channels/slack.test.ts` (46 unit tests)
- `import './slack.js'` appended to the channel barrel file `src/channels/index.ts`
- `@slack/bolt` npm dependency in `package.json`
- `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` in `.env.example`
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
### Validate code changes
```bash
npm test
npm install
npm run build
npx vitest run src/channels/slack.test.ts
```
All tests must pass (including the new slack tests) and build must be clean before proceeding.
All tests must pass (including the new Slack tests) and build must be clean before proceeding.
## Phase 3: Setup
@@ -113,31 +118,18 @@ Wait for the user to provide the channel ID.
### Register the channel
Use the IPC register flow or register directly. The channel ID, name, and folder name are needed.
The channel ID, name, and folder name are needed. Use `npx tsx setup/index.ts --step register` with the appropriate flags.
For a main channel (responds to all messages):
```typescript
registerGroup("slack:<channel-id>", {
name: "<channel-name>",
folder: "slack_main",
trigger: `@${ASSISTANT_NAME}`,
added_at: new Date().toISOString(),
requiresTrigger: false,
isMain: true,
});
```bash
npx tsx setup/index.ts --step register -- --jid "slack:<channel-id>" --name "<channel-name>" --folder "slack_main" --trigger "@${ASSISTANT_NAME}" --channel slack --no-trigger-required --is-main
```
For additional channels (trigger-only):
```typescript
registerGroup("slack:<channel-id>", {
name: "<channel-name>",
folder: "slack_<channel-name>",
trigger: `@${ASSISTANT_NAME}`,
added_at: new Date().toISOString(),
requiresTrigger: true,
});
```bash
npx tsx setup/index.ts --step register -- --jid "slack:<channel-id>" --name "<channel-name>" --folder "slack_<channel-name>" --trigger "@${ASSISTANT_NAME}" --channel slack
```
## Phase 5: Verify
-149
View File
@@ -1,149 +0,0 @@
# Slack App Setup for NanoClaw
Step-by-step guide to creating and configuring a Slack app for use with NanoClaw.
## Prerequisites
- A Slack workspace where you have admin permissions (or permission to install apps)
- Your NanoClaw instance with the `/add-slack` skill applied
## Step 1: Create the Slack App
1. Go to [api.slack.com/apps](https://api.slack.com/apps)
2. Click **Create New App**
3. Choose **From scratch**
4. Enter an app name (e.g., your `ASSISTANT_NAME` value, or any name you like)
5. Select the workspace you want to install it in
6. Click **Create App**
## Step 2: Enable Socket Mode
Socket Mode lets the bot connect to Slack without needing a public URL. This is what makes it work from your local machine.
1. In the sidebar, click **Socket Mode**
2. Toggle **Enable Socket Mode** to **On**
3. When prompted for a token name, enter something like `nanoclaw`
4. Click **Generate**
5. **Copy the App-Level Token** — it starts with `xapp-`. Save this somewhere safe; you'll need it later.
## Step 3: Subscribe to Events
This tells Slack which messages to forward to your bot.
1. In the sidebar, click **Event Subscriptions**
2. Toggle **Enable Events** to **On**
3. Under **Subscribe to bot events**, click **Add Bot User Event** and add these three events:
| Event | What it does |
|-------|-------------|
| `message.channels` | Receive messages in public channels the bot is in |
| `message.groups` | Receive messages in private channels the bot is in |
| `message.im` | Receive direct messages to the bot |
4. Click **Save Changes** at the bottom of the page
## Step 4: Set Bot Permissions (OAuth Scopes)
These scopes control what the bot is allowed to do.
1. In the sidebar, click **OAuth & Permissions**
2. Scroll down to **Scopes** > **Bot Token Scopes**
3. Click **Add an OAuth Scope** and add each of these:
| Scope | Why it's needed |
|-------|----------------|
| `chat:write` | Send messages to channels and DMs |
| `channels:history` | Read messages in public channels |
| `groups:history` | Read messages in private channels |
| `im:history` | Read direct messages |
| `channels:read` | List channels (for metadata sync) |
| `groups:read` | List private channels (for metadata sync) |
| `users:read` | Look up user display names |
## Step 5: Install to Workspace
1. In the sidebar, click **Install App**
2. Click **Install to Workspace**
3. Review the permissions and click **Allow**
4. **Copy the Bot User OAuth Token** — it starts with `xoxb-`. Save this somewhere safe.
## Step 6: Configure NanoClaw
Add both tokens to your `.env` file:
```
SLACK_BOT_TOKEN=xoxb-your-bot-token-here
SLACK_APP_TOKEN=xapp-your-app-token-here
```
If you want Slack to replace WhatsApp entirely (no WhatsApp channel), also add:
```
SLACK_ONLY=true
```
Then sync the environment to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
## Step 7: Add the Bot to Channels
The bot only receives messages from channels it has been explicitly added to.
1. Open the Slack channel you want the bot to monitor
2. Click the channel name at the top to open channel details
3. Go to **Integrations** > **Add apps**
4. Search for your bot name and add it
Repeat for each channel you want the bot in.
## Step 8: Get Channel IDs for Registration
You need the Slack channel ID to register it with NanoClaw.
**Option A — From the URL:**
Open the channel in Slack on the web. The URL looks like:
```
https://app.slack.com/client/TXXXXXXX/C0123456789
```
The `C0123456789` part is the channel ID.
**Option B — Right-click:**
Right-click the channel name in Slack > **Copy link** > the channel ID is the last path segment.
**Option C — Via API:**
```bash
curl -s -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
"https://slack.com/api/conversations.list" | jq '.channels[] | {id, name}'
```
The NanoClaw JID format is `slack:` followed by the channel ID, e.g., `slack:C0123456789`.
## Token Reference
| Token | Prefix | Where to find it |
|-------|--------|-----------------|
| Bot User OAuth Token | `xoxb-` | **OAuth & Permissions** > **Bot User OAuth Token** |
| App-Level Token | `xapp-` | **Basic Information** > **App-Level Tokens** (or during Socket Mode setup) |
## Troubleshooting
**Bot not receiving messages:**
- Verify Socket Mode is enabled (Step 2)
- Verify all three events are subscribed (Step 3)
- Verify the bot has been added to the channel (Step 7)
**"missing_scope" errors:**
- Go back to **OAuth & Permissions** and add the missing scope
- After adding scopes, you must **reinstall the app** to your workspace (Slack will show a banner prompting you to do this)
**Bot can't send messages:**
- Verify the `chat:write` scope is added
- Verify the bot has been added to the target channel
**Token not working:**
- Bot tokens start with `xoxb-` — if yours doesn't, you may have copied the wrong token
- App tokens start with `xapp-` — these are generated in the Socket Mode or Basic Information pages
- If you regenerated a token, update `.env` and re-sync: `cp .env data/env/env`
@@ -1,851 +0,0 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
// --- Mocks ---
// Mock registry (registerChannel runs at import time)
vi.mock('./registry.js', () => ({ registerChannel: vi.fn() }));
// Mock config
vi.mock('../config.js', () => ({
ASSISTANT_NAME: 'Jonesy',
TRIGGER_PATTERN: /^@Jonesy\b/i,
}));
// Mock logger
vi.mock('../logger.js', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
// Mock db
vi.mock('../db.js', () => ({
updateChatName: vi.fn(),
}));
// --- @slack/bolt mock ---
type Handler = (...args: any[]) => any;
const appRef = vi.hoisted(() => ({ current: null as any }));
vi.mock('@slack/bolt', () => ({
App: class MockApp {
eventHandlers = new Map<string, Handler>();
token: string;
appToken: string;
client = {
auth: {
test: vi.fn().mockResolvedValue({ user_id: 'U_BOT_123' }),
},
chat: {
postMessage: vi.fn().mockResolvedValue(undefined),
},
conversations: {
list: vi.fn().mockResolvedValue({
channels: [],
response_metadata: {},
}),
},
users: {
info: vi.fn().mockResolvedValue({
user: { real_name: 'Alice Smith', name: 'alice' },
}),
},
};
constructor(opts: any) {
this.token = opts.token;
this.appToken = opts.appToken;
appRef.current = this;
}
event(name: string, handler: Handler) {
this.eventHandlers.set(name, handler);
}
async start() {}
async stop() {}
},
LogLevel: { ERROR: 'error' },
}));
// Mock env
vi.mock('../env.js', () => ({
readEnvFile: vi.fn().mockReturnValue({
SLACK_BOT_TOKEN: 'xoxb-test-token',
SLACK_APP_TOKEN: 'xapp-test-token',
}),
}));
import { SlackChannel, SlackChannelOpts } from './slack.js';
import { updateChatName } from '../db.js';
import { readEnvFile } from '../env.js';
// --- Test helpers ---
function createTestOpts(
overrides?: Partial<SlackChannelOpts>,
): SlackChannelOpts {
return {
onMessage: vi.fn(),
onChatMetadata: vi.fn(),
registeredGroups: vi.fn(() => ({
'slack:C0123456789': {
name: 'Test Channel',
folder: 'test-channel',
trigger: '@Jonesy',
added_at: '2024-01-01T00:00:00.000Z',
},
})),
...overrides,
};
}
function createMessageEvent(overrides: {
channel?: string;
channelType?: string;
user?: string;
text?: string;
ts?: string;
threadTs?: string;
subtype?: string;
botId?: string;
}) {
return {
channel: overrides.channel ?? 'C0123456789',
channel_type: overrides.channelType ?? 'channel',
user: overrides.user ?? 'U_USER_456',
text: 'text' in overrides ? overrides.text : 'Hello everyone',
ts: overrides.ts ?? '1704067200.000000',
thread_ts: overrides.threadTs,
subtype: overrides.subtype,
bot_id: overrides.botId,
};
}
function currentApp() {
return appRef.current;
}
async function triggerMessageEvent(event: ReturnType<typeof createMessageEvent>) {
const handler = currentApp().eventHandlers.get('message');
if (handler) await handler({ event });
}
// --- Tests ---
describe('SlackChannel', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
// --- Connection lifecycle ---
describe('connection lifecycle', () => {
it('resolves connect() when app starts', async () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
await channel.connect();
expect(channel.isConnected()).toBe(true);
});
it('registers message event handler on construction', () => {
const opts = createTestOpts();
new SlackChannel(opts);
expect(currentApp().eventHandlers.has('message')).toBe(true);
});
it('gets bot user ID on connect', async () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
await channel.connect();
expect(currentApp().client.auth.test).toHaveBeenCalled();
});
it('disconnects cleanly', async () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
await channel.connect();
expect(channel.isConnected()).toBe(true);
await channel.disconnect();
expect(channel.isConnected()).toBe(false);
});
it('isConnected() returns false before connect', () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
expect(channel.isConnected()).toBe(false);
});
});
// --- Message handling ---
describe('message handling', () => {
it('delivers message for registered channel', async () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
await channel.connect();
const event = createMessageEvent({ text: 'Hello everyone' });
await triggerMessageEvent(event);
expect(opts.onChatMetadata).toHaveBeenCalledWith(
'slack:C0123456789',
expect.any(String),
undefined,
'slack',
true,
);
expect(opts.onMessage).toHaveBeenCalledWith(
'slack:C0123456789',
expect.objectContaining({
id: '1704067200.000000',
chat_jid: 'slack:C0123456789',
sender: 'U_USER_456',
content: 'Hello everyone',
is_from_me: false,
}),
);
});
it('only emits metadata for unregistered channels', async () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
await channel.connect();
const event = createMessageEvent({ channel: 'C9999999999' });
await triggerMessageEvent(event);
expect(opts.onChatMetadata).toHaveBeenCalledWith(
'slack:C9999999999',
expect.any(String),
undefined,
'slack',
true,
);
expect(opts.onMessage).not.toHaveBeenCalled();
});
it('skips non-text subtypes (channel_join, etc.)', async () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
await channel.connect();
const event = createMessageEvent({ subtype: 'channel_join' });
await triggerMessageEvent(event);
expect(opts.onMessage).not.toHaveBeenCalled();
expect(opts.onChatMetadata).not.toHaveBeenCalled();
});
it('allows bot_message subtype through', async () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
await channel.connect();
const event = createMessageEvent({
subtype: 'bot_message',
botId: 'B_OTHER_BOT',
text: 'Bot message',
});
await triggerMessageEvent(event);
expect(opts.onChatMetadata).toHaveBeenCalled();
});
it('skips messages with no text', async () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
await channel.connect();
const event = createMessageEvent({ text: undefined as any });
await triggerMessageEvent(event);
expect(opts.onMessage).not.toHaveBeenCalled();
});
it('detects bot messages by bot_id', async () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
await channel.connect();
const event = createMessageEvent({
subtype: 'bot_message',
botId: 'B_MY_BOT',
text: 'Bot response',
});
await triggerMessageEvent(event);
// Has bot_id so should be marked as bot message
expect(opts.onMessage).toHaveBeenCalledWith(
'slack:C0123456789',
expect.objectContaining({
is_from_me: true,
is_bot_message: true,
sender_name: 'Jonesy',
}),
);
});
it('detects bot messages by matching bot user ID', async () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
await channel.connect();
const event = createMessageEvent({ user: 'U_BOT_123', text: 'Self message' });
await triggerMessageEvent(event);
expect(opts.onMessage).toHaveBeenCalledWith(
'slack:C0123456789',
expect.objectContaining({
is_from_me: true,
is_bot_message: true,
}),
);
});
it('identifies IM channel type as non-group', async () => {
const opts = createTestOpts({
registeredGroups: vi.fn(() => ({
'slack:D0123456789': {
name: 'DM',
folder: 'dm',
trigger: '@Jonesy',
added_at: '2024-01-01T00:00:00.000Z',
},
})),
});
const channel = new SlackChannel(opts);
await channel.connect();
const event = createMessageEvent({
channel: 'D0123456789',
channelType: 'im',
});
await triggerMessageEvent(event);
expect(opts.onChatMetadata).toHaveBeenCalledWith(
'slack:D0123456789',
expect.any(String),
undefined,
'slack',
false, // IM is not a group
);
});
it('converts ts to ISO timestamp', async () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
await channel.connect();
const event = createMessageEvent({ ts: '1704067200.000000' });
await triggerMessageEvent(event);
expect(opts.onMessage).toHaveBeenCalledWith(
'slack:C0123456789',
expect.objectContaining({
timestamp: '2024-01-01T00:00:00.000Z',
}),
);
});
it('resolves user name from Slack API', async () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
await channel.connect();
const event = createMessageEvent({ user: 'U_USER_456', text: 'Hello' });
await triggerMessageEvent(event);
expect(currentApp().client.users.info).toHaveBeenCalledWith({
user: 'U_USER_456',
});
expect(opts.onMessage).toHaveBeenCalledWith(
'slack:C0123456789',
expect.objectContaining({
sender_name: 'Alice Smith',
}),
);
});
it('caches user names to avoid repeated API calls', async () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
await channel.connect();
// First message — API call
await triggerMessageEvent(createMessageEvent({ user: 'U_USER_456', text: 'First' }));
// Second message — should use cache
await triggerMessageEvent(createMessageEvent({
user: 'U_USER_456',
text: 'Second',
ts: '1704067201.000000',
}));
expect(currentApp().client.users.info).toHaveBeenCalledTimes(1);
});
it('falls back to user ID when API fails', async () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
await channel.connect();
currentApp().client.users.info.mockRejectedValueOnce(new Error('API error'));
const event = createMessageEvent({ user: 'U_UNKNOWN', text: 'Hi' });
await triggerMessageEvent(event);
expect(opts.onMessage).toHaveBeenCalledWith(
'slack:C0123456789',
expect.objectContaining({
sender_name: 'U_UNKNOWN',
}),
);
});
it('flattens threaded replies into channel messages', async () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
await channel.connect();
const event = createMessageEvent({
ts: '1704067201.000000',
threadTs: '1704067200.000000', // parent message ts — this is a reply
text: 'Thread reply',
});
await triggerMessageEvent(event);
// Threaded replies are delivered as regular channel messages
expect(opts.onMessage).toHaveBeenCalledWith(
'slack:C0123456789',
expect.objectContaining({
content: 'Thread reply',
}),
);
});
it('delivers thread parent messages normally', async () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
await channel.connect();
const event = createMessageEvent({
ts: '1704067200.000000',
threadTs: '1704067200.000000', // same as ts — this IS the parent
text: 'Thread parent',
});
await triggerMessageEvent(event);
expect(opts.onMessage).toHaveBeenCalledWith(
'slack:C0123456789',
expect.objectContaining({
content: 'Thread parent',
}),
);
});
it('delivers messages without thread_ts normally', async () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
await channel.connect();
const event = createMessageEvent({ text: 'Normal message' });
await triggerMessageEvent(event);
expect(opts.onMessage).toHaveBeenCalled();
});
});
// --- @mention translation ---
describe('@mention translation', () => {
it('prepends trigger when bot is @mentioned via Slack format', async () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
await channel.connect(); // sets botUserId to 'U_BOT_123'
const event = createMessageEvent({
text: 'Hey <@U_BOT_123> what do you think?',
user: 'U_USER_456',
});
await triggerMessageEvent(event);
expect(opts.onMessage).toHaveBeenCalledWith(
'slack:C0123456789',
expect.objectContaining({
content: '@Jonesy Hey <@U_BOT_123> what do you think?',
}),
);
});
it('does not prepend trigger when trigger pattern already matches', async () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
await channel.connect();
const event = createMessageEvent({
text: '@Jonesy <@U_BOT_123> hello',
user: 'U_USER_456',
});
await triggerMessageEvent(event);
// Content should be unchanged since it already matches TRIGGER_PATTERN
expect(opts.onMessage).toHaveBeenCalledWith(
'slack:C0123456789',
expect.objectContaining({
content: '@Jonesy <@U_BOT_123> hello',
}),
);
});
it('does not translate mentions in bot messages', async () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
await channel.connect();
const event = createMessageEvent({
text: 'Echo: <@U_BOT_123>',
subtype: 'bot_message',
botId: 'B_MY_BOT',
});
await triggerMessageEvent(event);
// Bot messages skip mention translation
expect(opts.onMessage).toHaveBeenCalledWith(
'slack:C0123456789',
expect.objectContaining({
content: 'Echo: <@U_BOT_123>',
}),
);
});
it('does not translate mentions for other users', async () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
await channel.connect();
const event = createMessageEvent({
text: 'Hey <@U_OTHER_USER> look at this',
user: 'U_USER_456',
});
await triggerMessageEvent(event);
// Mention is for a different user, not the bot
expect(opts.onMessage).toHaveBeenCalledWith(
'slack:C0123456789',
expect.objectContaining({
content: 'Hey <@U_OTHER_USER> look at this',
}),
);
});
});
// --- sendMessage ---
describe('sendMessage', () => {
it('sends message via Slack client', async () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
await channel.connect();
await channel.sendMessage('slack:C0123456789', 'Hello');
expect(currentApp().client.chat.postMessage).toHaveBeenCalledWith({
channel: 'C0123456789',
text: 'Hello',
});
});
it('strips slack: prefix from JID', async () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
await channel.connect();
await channel.sendMessage('slack:D9876543210', 'DM message');
expect(currentApp().client.chat.postMessage).toHaveBeenCalledWith({
channel: 'D9876543210',
text: 'DM message',
});
});
it('queues message when disconnected', async () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
// Don't connect — should queue
await channel.sendMessage('slack:C0123456789', 'Queued message');
expect(currentApp().client.chat.postMessage).not.toHaveBeenCalled();
});
it('queues message on send failure', async () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
await channel.connect();
currentApp().client.chat.postMessage.mockRejectedValueOnce(
new Error('Network error'),
);
// Should not throw
await expect(
channel.sendMessage('slack:C0123456789', 'Will fail'),
).resolves.toBeUndefined();
});
it('splits long messages at 4000 character boundary', async () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
await channel.connect();
// Create a message longer than 4000 chars
const longText = 'A'.repeat(4500);
await channel.sendMessage('slack:C0123456789', longText);
// Should be split into 2 messages: 4000 + 500
expect(currentApp().client.chat.postMessage).toHaveBeenCalledTimes(2);
expect(currentApp().client.chat.postMessage).toHaveBeenNthCalledWith(1, {
channel: 'C0123456789',
text: 'A'.repeat(4000),
});
expect(currentApp().client.chat.postMessage).toHaveBeenNthCalledWith(2, {
channel: 'C0123456789',
text: 'A'.repeat(500),
});
});
it('sends exactly-4000-char messages as a single message', async () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
await channel.connect();
const text = 'B'.repeat(4000);
await channel.sendMessage('slack:C0123456789', text);
expect(currentApp().client.chat.postMessage).toHaveBeenCalledTimes(1);
expect(currentApp().client.chat.postMessage).toHaveBeenCalledWith({
channel: 'C0123456789',
text,
});
});
it('splits messages into 3 parts when over 8000 chars', async () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
await channel.connect();
const longText = 'C'.repeat(8500);
await channel.sendMessage('slack:C0123456789', longText);
// 4000 + 4000 + 500 = 3 messages
expect(currentApp().client.chat.postMessage).toHaveBeenCalledTimes(3);
});
it('flushes queued messages on connect', async () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
// Queue messages while disconnected
await channel.sendMessage('slack:C0123456789', 'First queued');
await channel.sendMessage('slack:C0123456789', 'Second queued');
expect(currentApp().client.chat.postMessage).not.toHaveBeenCalled();
// Connect triggers flush
await channel.connect();
expect(currentApp().client.chat.postMessage).toHaveBeenCalledWith({
channel: 'C0123456789',
text: 'First queued',
});
expect(currentApp().client.chat.postMessage).toHaveBeenCalledWith({
channel: 'C0123456789',
text: 'Second queued',
});
});
});
// --- ownsJid ---
describe('ownsJid', () => {
it('owns slack: JIDs', () => {
const channel = new SlackChannel(createTestOpts());
expect(channel.ownsJid('slack:C0123456789')).toBe(true);
});
it('owns slack: DM JIDs', () => {
const channel = new SlackChannel(createTestOpts());
expect(channel.ownsJid('slack:D0123456789')).toBe(true);
});
it('does not own WhatsApp group JIDs', () => {
const channel = new SlackChannel(createTestOpts());
expect(channel.ownsJid('12345@g.us')).toBe(false);
});
it('does not own WhatsApp DM JIDs', () => {
const channel = new SlackChannel(createTestOpts());
expect(channel.ownsJid('12345@s.whatsapp.net')).toBe(false);
});
it('does not own Telegram JIDs', () => {
const channel = new SlackChannel(createTestOpts());
expect(channel.ownsJid('tg:123456')).toBe(false);
});
it('does not own unknown JID formats', () => {
const channel = new SlackChannel(createTestOpts());
expect(channel.ownsJid('random-string')).toBe(false);
});
});
// --- syncChannelMetadata ---
describe('syncChannelMetadata', () => {
it('calls conversations.list and updates chat names', async () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
currentApp().client.conversations.list.mockResolvedValue({
channels: [
{ id: 'C001', name: 'general', is_member: true },
{ id: 'C002', name: 'random', is_member: true },
{ id: 'C003', name: 'external', is_member: false },
],
response_metadata: {},
});
await channel.connect();
// connect() calls syncChannelMetadata internally
expect(updateChatName).toHaveBeenCalledWith('slack:C001', 'general');
expect(updateChatName).toHaveBeenCalledWith('slack:C002', 'random');
// Non-member channels are skipped
expect(updateChatName).not.toHaveBeenCalledWith('slack:C003', 'external');
});
it('handles API errors gracefully', async () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
currentApp().client.conversations.list.mockRejectedValue(
new Error('API error'),
);
// Should not throw
await expect(channel.connect()).resolves.toBeUndefined();
});
});
// --- setTyping ---
describe('setTyping', () => {
it('resolves without error (no-op)', async () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
// Should not throw — Slack has no bot typing indicator API
await expect(
channel.setTyping('slack:C0123456789', true),
).resolves.toBeUndefined();
});
it('accepts false without error', async () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
await expect(
channel.setTyping('slack:C0123456789', false),
).resolves.toBeUndefined();
});
});
// --- Constructor error handling ---
describe('constructor', () => {
it('throws when SLACK_BOT_TOKEN is missing', () => {
vi.mocked(readEnvFile).mockReturnValueOnce({
SLACK_BOT_TOKEN: '',
SLACK_APP_TOKEN: 'xapp-test-token',
});
expect(() => new SlackChannel(createTestOpts())).toThrow(
'SLACK_BOT_TOKEN and SLACK_APP_TOKEN must be set in .env',
);
});
it('throws when SLACK_APP_TOKEN is missing', () => {
vi.mocked(readEnvFile).mockReturnValueOnce({
SLACK_BOT_TOKEN: 'xoxb-test-token',
SLACK_APP_TOKEN: '',
});
expect(() => new SlackChannel(createTestOpts())).toThrow(
'SLACK_BOT_TOKEN and SLACK_APP_TOKEN must be set in .env',
);
});
});
// --- syncChannelMetadata pagination ---
describe('syncChannelMetadata pagination', () => {
it('paginates through multiple pages of channels', async () => {
const opts = createTestOpts();
const channel = new SlackChannel(opts);
// First page returns a cursor; second page returns no cursor
currentApp().client.conversations.list
.mockResolvedValueOnce({
channels: [
{ id: 'C001', name: 'general', is_member: true },
],
response_metadata: { next_cursor: 'cursor_page2' },
})
.mockResolvedValueOnce({
channels: [
{ id: 'C002', name: 'random', is_member: true },
],
response_metadata: {},
});
await channel.connect();
// Should have called conversations.list twice (once per page)
expect(currentApp().client.conversations.list).toHaveBeenCalledTimes(2);
expect(currentApp().client.conversations.list).toHaveBeenNthCalledWith(2,
expect.objectContaining({ cursor: 'cursor_page2' }),
);
// Both channels from both pages stored
expect(updateChatName).toHaveBeenCalledWith('slack:C001', 'general');
expect(updateChatName).toHaveBeenCalledWith('slack:C002', 'random');
});
});
// --- Channel properties ---
describe('channel properties', () => {
it('has name "slack"', () => {
const channel = new SlackChannel(createTestOpts());
expect(channel.name).toBe('slack');
});
});
});
@@ -1,300 +0,0 @@
import { App, LogLevel } from '@slack/bolt';
import type { GenericMessageEvent, BotMessageEvent } from '@slack/types';
import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js';
import { updateChatName } from '../db.js';
import { readEnvFile } from '../env.js';
import { logger } from '../logger.js';
import { registerChannel, ChannelOpts } from './registry.js';
import {
Channel,
OnInboundMessage,
OnChatMetadata,
RegisteredGroup,
} from '../types.js';
// Slack's chat.postMessage API limits text to ~4000 characters per call.
// Messages exceeding this are split into sequential chunks.
const MAX_MESSAGE_LENGTH = 4000;
// The message subtypes we process. Bolt delivers all subtypes via app.event('message');
// we filter to regular messages (GenericMessageEvent, subtype undefined) and bot messages
// (BotMessageEvent, subtype 'bot_message') so we can track our own output.
type HandledMessageEvent = GenericMessageEvent | BotMessageEvent;
export interface SlackChannelOpts {
onMessage: OnInboundMessage;
onChatMetadata: OnChatMetadata;
registeredGroups: () => Record<string, RegisteredGroup>;
}
export class SlackChannel implements Channel {
name = 'slack';
private app: App;
private botUserId: string | undefined;
private connected = false;
private outgoingQueue: Array<{ jid: string; text: string }> = [];
private flushing = false;
private userNameCache = new Map<string, string>();
private opts: SlackChannelOpts;
constructor(opts: SlackChannelOpts) {
this.opts = opts;
// Read tokens from .env (not process.env — keeps secrets off the environment
// so they don't leak to child processes, matching NanoClaw's security pattern)
const env = readEnvFile(['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN']);
const botToken = env.SLACK_BOT_TOKEN;
const appToken = env.SLACK_APP_TOKEN;
if (!botToken || !appToken) {
throw new Error(
'SLACK_BOT_TOKEN and SLACK_APP_TOKEN must be set in .env',
);
}
this.app = new App({
token: botToken,
appToken,
socketMode: true,
logLevel: LogLevel.ERROR,
});
this.setupEventHandlers();
}
private setupEventHandlers(): void {
// Use app.event('message') instead of app.message() to capture all
// message subtypes including bot_message (needed to track our own output)
this.app.event('message', async ({ event }) => {
// Bolt's event type is the full MessageEvent union (17+ subtypes).
// We filter on subtype first, then narrow to the two types we handle.
const subtype = (event as { subtype?: string }).subtype;
if (subtype && subtype !== 'bot_message') return;
// After filtering, event is either GenericMessageEvent or BotMessageEvent
const msg = event as HandledMessageEvent;
if (!msg.text) return;
// Threaded replies are flattened into the channel conversation.
// The agent sees them alongside channel-level messages; responses
// always go to the channel, not back into the thread.
const jid = `slack:${msg.channel}`;
const timestamp = new Date(parseFloat(msg.ts) * 1000).toISOString();
const isGroup = msg.channel_type !== 'im';
// Always report metadata for group discovery
this.opts.onChatMetadata(jid, timestamp, undefined, 'slack', isGroup);
// Only deliver full messages for registered groups
const groups = this.opts.registeredGroups();
if (!groups[jid]) return;
const isBotMessage =
!!msg.bot_id || msg.user === this.botUserId;
let senderName: string;
if (isBotMessage) {
senderName = ASSISTANT_NAME;
} else {
senderName =
(await this.resolveUserName(msg.user)) ||
msg.user ||
'unknown';
}
// Translate Slack <@UBOTID> mentions into TRIGGER_PATTERN format.
// Slack encodes @mentions as <@U12345>, which won't match TRIGGER_PATTERN
// (e.g., ^@<ASSISTANT_NAME>\b), so we prepend the trigger when the bot is @mentioned.
let content = msg.text;
if (this.botUserId && !isBotMessage) {
const mentionPattern = `<@${this.botUserId}>`;
if (content.includes(mentionPattern) && !TRIGGER_PATTERN.test(content)) {
content = `@${ASSISTANT_NAME} ${content}`;
}
}
this.opts.onMessage(jid, {
id: msg.ts,
chat_jid: jid,
sender: msg.user || msg.bot_id || '',
sender_name: senderName,
content,
timestamp,
is_from_me: isBotMessage,
is_bot_message: isBotMessage,
});
});
}
async connect(): Promise<void> {
await this.app.start();
// Get bot's own user ID for self-message detection.
// Resolve this BEFORE setting connected=true so that messages arriving
// during startup can correctly detect bot-sent messages.
try {
const auth = await this.app.client.auth.test();
this.botUserId = auth.user_id as string;
logger.info({ botUserId: this.botUserId }, 'Connected to Slack');
} catch (err) {
logger.warn(
{ err },
'Connected to Slack but failed to get bot user ID',
);
}
this.connected = true;
// Flush any messages queued before connection
await this.flushOutgoingQueue();
// Sync channel names on startup
await this.syncChannelMetadata();
}
async sendMessage(jid: string, text: string): Promise<void> {
const channelId = jid.replace(/^slack:/, '');
if (!this.connected) {
this.outgoingQueue.push({ jid, text });
logger.info(
{ jid, queueSize: this.outgoingQueue.length },
'Slack disconnected, message queued',
);
return;
}
try {
// Slack limits messages to ~4000 characters; split if needed
if (text.length <= MAX_MESSAGE_LENGTH) {
await this.app.client.chat.postMessage({ channel: channelId, text });
} else {
for (let i = 0; i < text.length; i += MAX_MESSAGE_LENGTH) {
await this.app.client.chat.postMessage({
channel: channelId,
text: text.slice(i, i + MAX_MESSAGE_LENGTH),
});
}
}
logger.info({ jid, length: text.length }, 'Slack message sent');
} catch (err) {
this.outgoingQueue.push({ jid, text });
logger.warn(
{ jid, err, queueSize: this.outgoingQueue.length },
'Failed to send Slack message, queued',
);
}
}
isConnected(): boolean {
return this.connected;
}
ownsJid(jid: string): boolean {
return jid.startsWith('slack:');
}
async disconnect(): Promise<void> {
this.connected = false;
await this.app.stop();
}
// Slack does not expose a typing indicator API for bots.
// This no-op satisfies the Channel interface so the orchestrator
// doesn't need channel-specific branching.
async setTyping(_jid: string, _isTyping: boolean): Promise<void> {
// no-op: Slack Bot API has no typing indicator endpoint
}
/**
* Sync channel metadata from Slack.
* Fetches channels the bot is a member of and stores their names in the DB.
*/
async syncChannelMetadata(): Promise<void> {
try {
logger.info('Syncing channel metadata from Slack...');
let cursor: string | undefined;
let count = 0;
do {
const result = await this.app.client.conversations.list({
types: 'public_channel,private_channel',
exclude_archived: true,
limit: 200,
cursor,
});
for (const ch of result.channels || []) {
if (ch.id && ch.name && ch.is_member) {
updateChatName(`slack:${ch.id}`, ch.name);
count++;
}
}
cursor = result.response_metadata?.next_cursor || undefined;
} while (cursor);
logger.info({ count }, 'Slack channel metadata synced');
} catch (err) {
logger.error({ err }, 'Failed to sync Slack channel metadata');
}
}
private async resolveUserName(
userId: string,
): Promise<string | undefined> {
if (!userId) return undefined;
const cached = this.userNameCache.get(userId);
if (cached) return cached;
try {
const result = await this.app.client.users.info({ user: userId });
const name = result.user?.real_name || result.user?.name;
if (name) this.userNameCache.set(userId, name);
return name;
} catch (err) {
logger.debug({ userId, err }, 'Failed to resolve Slack user name');
return undefined;
}
}
private async flushOutgoingQueue(): Promise<void> {
if (this.flushing || this.outgoingQueue.length === 0) return;
this.flushing = true;
try {
logger.info(
{ count: this.outgoingQueue.length },
'Flushing Slack outgoing queue',
);
while (this.outgoingQueue.length > 0) {
const item = this.outgoingQueue.shift()!;
const channelId = item.jid.replace(/^slack:/, '');
await this.app.client.chat.postMessage({
channel: channelId,
text: item.text,
});
logger.info(
{ jid: item.jid, length: item.text.length },
'Queued Slack message sent',
);
}
} finally {
this.flushing = false;
}
}
}
registerChannel('slack', (opts: ChannelOpts) => {
const envVars = readEnvFile(['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN']);
if (!envVars.SLACK_BOT_TOKEN || !envVars.SLACK_APP_TOKEN) {
logger.warn('Slack: SLACK_BOT_TOKEN or SLACK_APP_TOKEN not set');
return null;
}
return new SlackChannel(opts);
});
-18
View File
@@ -1,18 +0,0 @@
skill: slack
version: 1.0.0
description: "Slack Bot integration via @slack/bolt with Socket Mode"
core_version: 0.1.0
adds:
- src/channels/slack.ts
- src/channels/slack.test.ts
modifies:
- src/channels/index.ts
structured:
npm_dependencies:
"@slack/bolt": "^4.6.0"
env_additions:
- SLACK_BOT_TOKEN
- SLACK_APP_TOKEN
conflicts: []
depends: []
test: "npx vitest run src/channels/slack.test.ts"
@@ -1,13 +0,0 @@
// Channel self-registration barrel file.
// Each import triggers the channel module's registerChannel() call.
// discord
// gmail
// slack
import './slack.js';
// telegram
// whatsapp
@@ -1,7 +0,0 @@
# Intent: Add Slack channel import
Add `import './slack.js';` to the channel barrel file so the Slack
module self-registers with the channel registry on startup.
This is an append-only change — existing import lines for other channels
must be preserved.
@@ -1,100 +0,0 @@
import { describe, expect, it } from 'vitest';
import fs from 'fs';
import path from 'path';
describe('slack skill package', () => {
const skillDir = path.resolve(__dirname, '..');
it('has a valid manifest', () => {
const manifestPath = path.join(skillDir, 'manifest.yaml');
expect(fs.existsSync(manifestPath)).toBe(true);
const content = fs.readFileSync(manifestPath, 'utf-8');
expect(content).toContain('skill: slack');
expect(content).toContain('version: 1.0.0');
expect(content).toContain('@slack/bolt');
});
it('has all files declared in adds', () => {
const channelFile = path.join(
skillDir,
'add',
'src',
'channels',
'slack.ts',
);
expect(fs.existsSync(channelFile)).toBe(true);
const content = fs.readFileSync(channelFile, 'utf-8');
expect(content).toContain('class SlackChannel');
expect(content).toContain('implements Channel');
expect(content).toContain("registerChannel('slack'");
// Test file for the channel
const testFile = path.join(
skillDir,
'add',
'src',
'channels',
'slack.test.ts',
);
expect(fs.existsSync(testFile)).toBe(true);
const testContent = fs.readFileSync(testFile, 'utf-8');
expect(testContent).toContain("describe('SlackChannel'");
});
it('has all files declared in modifies', () => {
// Channel barrel file
const indexFile = path.join(
skillDir,
'modify',
'src',
'channels',
'index.ts',
);
expect(fs.existsSync(indexFile)).toBe(true);
const indexContent = fs.readFileSync(indexFile, 'utf-8');
expect(indexContent).toContain("import './slack.js'");
});
it('has intent files for modified files', () => {
expect(
fs.existsSync(
path.join(skillDir, 'modify', 'src', 'channels', 'index.ts.intent.md'),
),
).toBe(true);
});
it('has setup documentation', () => {
expect(fs.existsSync(path.join(skillDir, 'SKILL.md'))).toBe(true);
expect(fs.existsSync(path.join(skillDir, 'SLACK_SETUP.md'))).toBe(true);
});
it('slack.ts implements required Channel interface methods', () => {
const content = fs.readFileSync(
path.join(skillDir, 'add', 'src', 'channels', 'slack.ts'),
'utf-8',
);
// Channel interface methods
expect(content).toContain('async connect()');
expect(content).toContain('async sendMessage(');
expect(content).toContain('isConnected()');
expect(content).toContain('ownsJid(');
expect(content).toContain('async disconnect()');
expect(content).toContain('async setTyping(');
// Security pattern: reads tokens from .env, not process.env
expect(content).toContain('readEnvFile');
expect(content).not.toContain('process.env.SLACK_BOT_TOKEN');
expect(content).not.toContain('process.env.SLACK_APP_TOKEN');
// Key behaviors
expect(content).toContain('socketMode: true');
expect(content).toContain('MAX_MESSAGE_LENGTH');
expect(content).toContain('TRIGGER_PATTERN');
expect(content).toContain('userNameCache');
});
});
+32 -49
View File
@@ -5,13 +5,13 @@ description: Add Telegram as a channel. Can replace WhatsApp entirely or run alo
# Add Telegram Channel
This skill adds Telegram support to NanoClaw using the skills engine for deterministic code changes, then walks through interactive setup.
This skill adds Telegram support to NanoClaw, then walks through interactive setup.
## Phase 1: Pre-flight
### Check if already applied
Read `.nanoclaw/state.yaml`. If `telegram` is in `applied_skills`, skip to Phase 3 (Setup). The code changes are already in place.
Check if `src/channels/telegram.ts` exists. If it does, skip to Phase 3 (Setup). The code changes are already in place.
### Ask the user
@@ -23,43 +23,47 @@ If they have one, collect it now. If not, we'll create one in Phase 3.
## Phase 2: Apply Code Changes
Run the skills engine to apply this skill's code package. The package files are in this directory alongside this SKILL.md.
### Initialize skills system (if needed)
If `.nanoclaw/` directory doesn't exist yet:
### Ensure channel remote
```bash
npx tsx scripts/apply-skill.ts --init
git remote -v
```
Or call `initSkillsSystem()` from `skills-engine/migrate.ts`.
### Apply the skill
If `telegram` is missing, add it:
```bash
npx tsx scripts/apply-skill.ts .claude/skills/add-telegram
git remote add telegram https://github.com/qwibitai/nanoclaw-telegram.git
```
This deterministically:
- Adds `src/channels/telegram.ts` (TelegramChannel class with self-registration via `registerChannel`)
- Adds `src/channels/telegram.test.ts` (46 unit tests)
- Appends `import './telegram.js'` to the channel barrel file `src/channels/index.ts`
- Installs the `grammy` npm dependency
- Updates `.env.example` with `TELEGRAM_BOT_TOKEN`
- Records the application in `.nanoclaw/state.yaml`
### Merge the skill branch
If the apply reports merge conflicts, read the intent file:
- `modify/src/channels/index.ts.intent.md` — what changed and invariants
```bash
git fetch telegram main
git merge telegram/main || {
git checkout --theirs package-lock.json
git add package-lock.json
git merge --continue
}
```
This merges in:
- `src/channels/telegram.ts` (TelegramChannel class with self-registration via `registerChannel`)
- `src/channels/telegram.test.ts` (unit tests with grammy mock)
- `import './telegram.js'` appended to the channel barrel file `src/channels/index.ts`
- `grammy` npm dependency in `package.json`
- `TELEGRAM_BOT_TOKEN` in `.env.example`
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
### Validate code changes
```bash
npm test
npm install
npm run build
npx vitest run src/channels/telegram.test.ts
```
All tests must pass (including the new telegram tests) and build must be clean before proceeding.
All tests must pass (including the new Telegram tests) and build must be clean before proceeding.
## Phase 3: Setup
@@ -129,31 +133,18 @@ Wait for the user to provide the chat ID (format: `tg:123456789` or `tg:-1001234
### Register the chat
Use the IPC register flow or register directly. The chat ID, name, and folder name are needed.
The chat ID, name, and folder name are needed. Use `npx tsx setup/index.ts --step register` with the appropriate flags.
For a main chat (responds to all messages):
```typescript
registerGroup("tg:<chat-id>", {
name: "<chat-name>",
folder: "telegram_main",
trigger: `@${ASSISTANT_NAME}`,
added_at: new Date().toISOString(),
requiresTrigger: false,
isMain: true,
});
```bash
npx tsx setup/index.ts --step register -- --jid "tg:<chat-id>" --name "<chat-name>" --folder "telegram_main" --trigger "@${ASSISTANT_NAME}" --channel telegram --no-trigger-required --is-main
```
For additional chats (trigger-only):
```typescript
registerGroup("tg:<chat-id>", {
name: "<chat-name>",
folder: "telegram_<group-name>",
trigger: `@${ASSISTANT_NAME}`,
added_at: new Date().toISOString(),
requiresTrigger: true,
});
```bash
npx tsx setup/index.ts --step register -- --jid "tg:<chat-id>" --name "<chat-name>" --folder "telegram_<group-name>" --trigger "@${ASSISTANT_NAME}" --channel telegram
```
## Phase 5: Verify
@@ -211,14 +202,6 @@ launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
# systemctl --user start nanoclaw
```
## Agent Swarms (Teams)
After completing the Telegram setup, use `AskUserQuestion`:
AskUserQuestion: Would you like to add Agent Swarm support? Without it, Agent Teams still work — they just operate behind the scenes. With Swarm support, each subagent appears as a different bot in the Telegram group so you can see who's saying what and have interactive team sessions.
If they say yes, invoke the `/add-telegram-swarm` skill.
## Removal
To remove Telegram integration:
@@ -1,932 +0,0 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
// --- Mocks ---
// Mock registry (registerChannel runs at import time)
vi.mock('./registry.js', () => ({ registerChannel: vi.fn() }));
// Mock env reader (used by the factory, not needed in unit tests)
vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})) }));
// Mock config
vi.mock('../config.js', () => ({
ASSISTANT_NAME: 'Andy',
TRIGGER_PATTERN: /^@Andy\b/i,
}));
// Mock logger
vi.mock('../logger.js', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
// --- Grammy mock ---
type Handler = (...args: any[]) => any;
const botRef = vi.hoisted(() => ({ current: null as any }));
vi.mock('grammy', () => ({
Bot: class MockBot {
token: string;
commandHandlers = new Map<string, Handler>();
filterHandlers = new Map<string, Handler[]>();
errorHandler: Handler | null = null;
api = {
sendMessage: vi.fn().mockResolvedValue(undefined),
sendChatAction: vi.fn().mockResolvedValue(undefined),
};
constructor(token: string) {
this.token = token;
botRef.current = this;
}
command(name: string, handler: Handler) {
this.commandHandlers.set(name, handler);
}
on(filter: string, handler: Handler) {
const existing = this.filterHandlers.get(filter) || [];
existing.push(handler);
this.filterHandlers.set(filter, existing);
}
catch(handler: Handler) {
this.errorHandler = handler;
}
start(opts: { onStart: (botInfo: any) => void }) {
opts.onStart({ username: 'andy_ai_bot', id: 12345 });
}
stop() {}
},
}));
import { TelegramChannel, TelegramChannelOpts } from './telegram.js';
// --- Test helpers ---
function createTestOpts(
overrides?: Partial<TelegramChannelOpts>,
): TelegramChannelOpts {
return {
onMessage: vi.fn(),
onChatMetadata: vi.fn(),
registeredGroups: vi.fn(() => ({
'tg:100200300': {
name: 'Test Group',
folder: 'test-group',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
},
})),
...overrides,
};
}
function createTextCtx(overrides: {
chatId?: number;
chatType?: string;
chatTitle?: string;
text: string;
fromId?: number;
firstName?: string;
username?: string;
messageId?: number;
date?: number;
entities?: any[];
}) {
const chatId = overrides.chatId ?? 100200300;
const chatType = overrides.chatType ?? 'group';
return {
chat: {
id: chatId,
type: chatType,
title: overrides.chatTitle ?? 'Test Group',
},
from: {
id: overrides.fromId ?? 99001,
first_name: overrides.firstName ?? 'Alice',
username: overrides.username ?? 'alice_user',
},
message: {
text: overrides.text,
date: overrides.date ?? Math.floor(Date.now() / 1000),
message_id: overrides.messageId ?? 1,
entities: overrides.entities ?? [],
},
me: { username: 'andy_ai_bot' },
reply: vi.fn(),
};
}
function createMediaCtx(overrides: {
chatId?: number;
chatType?: string;
fromId?: number;
firstName?: string;
date?: number;
messageId?: number;
caption?: string;
extra?: Record<string, any>;
}) {
const chatId = overrides.chatId ?? 100200300;
return {
chat: {
id: chatId,
type: overrides.chatType ?? 'group',
title: 'Test Group',
},
from: {
id: overrides.fromId ?? 99001,
first_name: overrides.firstName ?? 'Alice',
username: 'alice_user',
},
message: {
date: overrides.date ?? Math.floor(Date.now() / 1000),
message_id: overrides.messageId ?? 1,
caption: overrides.caption,
...(overrides.extra || {}),
},
me: { username: 'andy_ai_bot' },
};
}
function currentBot() {
return botRef.current;
}
async function triggerTextMessage(ctx: ReturnType<typeof createTextCtx>) {
const handlers = currentBot().filterHandlers.get('message:text') || [];
for (const h of handlers) await h(ctx);
}
async function triggerMediaMessage(
filter: string,
ctx: ReturnType<typeof createMediaCtx>,
) {
const handlers = currentBot().filterHandlers.get(filter) || [];
for (const h of handlers) await h(ctx);
}
// --- Tests ---
describe('TelegramChannel', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
// --- Connection lifecycle ---
describe('connection lifecycle', () => {
it('resolves connect() when bot starts', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
expect(channel.isConnected()).toBe(true);
});
it('registers command and message handlers on connect', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
expect(currentBot().commandHandlers.has('chatid')).toBe(true);
expect(currentBot().commandHandlers.has('ping')).toBe(true);
expect(currentBot().filterHandlers.has('message:text')).toBe(true);
expect(currentBot().filterHandlers.has('message:photo')).toBe(true);
expect(currentBot().filterHandlers.has('message:video')).toBe(true);
expect(currentBot().filterHandlers.has('message:voice')).toBe(true);
expect(currentBot().filterHandlers.has('message:audio')).toBe(true);
expect(currentBot().filterHandlers.has('message:document')).toBe(true);
expect(currentBot().filterHandlers.has('message:sticker')).toBe(true);
expect(currentBot().filterHandlers.has('message:location')).toBe(true);
expect(currentBot().filterHandlers.has('message:contact')).toBe(true);
});
it('registers error handler on connect', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
expect(currentBot().errorHandler).not.toBeNull();
});
it('disconnects cleanly', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
expect(channel.isConnected()).toBe(true);
await channel.disconnect();
expect(channel.isConnected()).toBe(false);
});
it('isConnected() returns false before connect', () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
expect(channel.isConnected()).toBe(false);
});
});
// --- Text message handling ---
describe('text message handling', () => {
it('delivers message for registered group', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createTextCtx({ text: 'Hello everyone' });
await triggerTextMessage(ctx);
expect(opts.onChatMetadata).toHaveBeenCalledWith(
'tg:100200300',
expect.any(String),
'Test Group',
'telegram',
true,
);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({
id: '1',
chat_jid: 'tg:100200300',
sender: '99001',
sender_name: 'Alice',
content: 'Hello everyone',
is_from_me: false,
}),
);
});
it('only emits metadata for unregistered chats', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createTextCtx({ chatId: 999999, text: 'Unknown chat' });
await triggerTextMessage(ctx);
expect(opts.onChatMetadata).toHaveBeenCalledWith(
'tg:999999',
expect.any(String),
'Test Group',
'telegram',
true,
);
expect(opts.onMessage).not.toHaveBeenCalled();
});
it('skips command messages (starting with /)', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createTextCtx({ text: '/start' });
await triggerTextMessage(ctx);
expect(opts.onMessage).not.toHaveBeenCalled();
expect(opts.onChatMetadata).not.toHaveBeenCalled();
});
it('extracts sender name from first_name', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createTextCtx({ text: 'Hi', firstName: 'Bob' });
await triggerTextMessage(ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({ sender_name: 'Bob' }),
);
});
it('falls back to username when first_name missing', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createTextCtx({ text: 'Hi' });
ctx.from.first_name = undefined as any;
await triggerTextMessage(ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({ sender_name: 'alice_user' }),
);
});
it('falls back to user ID when name and username missing', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createTextCtx({ text: 'Hi', fromId: 42 });
ctx.from.first_name = undefined as any;
ctx.from.username = undefined as any;
await triggerTextMessage(ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({ sender_name: '42' }),
);
});
it('uses sender name as chat name for private chats', async () => {
const opts = createTestOpts({
registeredGroups: vi.fn(() => ({
'tg:100200300': {
name: 'Private',
folder: 'private',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
},
})),
});
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createTextCtx({
text: 'Hello',
chatType: 'private',
firstName: 'Alice',
});
await triggerTextMessage(ctx);
expect(opts.onChatMetadata).toHaveBeenCalledWith(
'tg:100200300',
expect.any(String),
'Alice', // Private chats use sender name
'telegram',
false,
);
});
it('uses chat title as name for group chats', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createTextCtx({
text: 'Hello',
chatType: 'supergroup',
chatTitle: 'Project Team',
});
await triggerTextMessage(ctx);
expect(opts.onChatMetadata).toHaveBeenCalledWith(
'tg:100200300',
expect.any(String),
'Project Team',
'telegram',
true,
);
});
it('converts message.date to ISO timestamp', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const unixTime = 1704067200; // 2024-01-01T00:00:00.000Z
const ctx = createTextCtx({ text: 'Hello', date: unixTime });
await triggerTextMessage(ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({
timestamp: '2024-01-01T00:00:00.000Z',
}),
);
});
});
// --- @mention translation ---
describe('@mention translation', () => {
it('translates @bot_username mention to trigger format', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createTextCtx({
text: '@andy_ai_bot what time is it?',
entities: [{ type: 'mention', offset: 0, length: 12 }],
});
await triggerTextMessage(ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({
content: '@Andy @andy_ai_bot what time is it?',
}),
);
});
it('does not translate if message already matches trigger', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createTextCtx({
text: '@Andy @andy_ai_bot hello',
entities: [{ type: 'mention', offset: 6, length: 12 }],
});
await triggerTextMessage(ctx);
// Should NOT double-prepend — already starts with @Andy
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({
content: '@Andy @andy_ai_bot hello',
}),
);
});
it('does not translate mentions of other bots', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createTextCtx({
text: '@some_other_bot hi',
entities: [{ type: 'mention', offset: 0, length: 15 }],
});
await triggerTextMessage(ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({
content: '@some_other_bot hi', // No translation
}),
);
});
it('handles mention in middle of message', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createTextCtx({
text: 'hey @andy_ai_bot check this',
entities: [{ type: 'mention', offset: 4, length: 12 }],
});
await triggerTextMessage(ctx);
// Bot is mentioned, message doesn't match trigger → prepend trigger
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({
content: '@Andy hey @andy_ai_bot check this',
}),
);
});
it('handles message with no entities', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createTextCtx({ text: 'plain message' });
await triggerTextMessage(ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({
content: 'plain message',
}),
);
});
it('ignores non-mention entities', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createTextCtx({
text: 'check https://example.com',
entities: [{ type: 'url', offset: 6, length: 19 }],
});
await triggerTextMessage(ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({
content: 'check https://example.com',
}),
);
});
});
// --- Non-text messages ---
describe('non-text messages', () => {
it('stores photo with placeholder', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createMediaCtx({});
await triggerMediaMessage('message:photo', ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({ content: '[Photo]' }),
);
});
it('stores photo with caption', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createMediaCtx({ caption: 'Look at this' });
await triggerMediaMessage('message:photo', ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({ content: '[Photo] Look at this' }),
);
});
it('stores video with placeholder', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createMediaCtx({});
await triggerMediaMessage('message:video', ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({ content: '[Video]' }),
);
});
it('stores voice message with placeholder', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createMediaCtx({});
await triggerMediaMessage('message:voice', ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({ content: '[Voice message]' }),
);
});
it('stores audio with placeholder', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createMediaCtx({});
await triggerMediaMessage('message:audio', ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({ content: '[Audio]' }),
);
});
it('stores document with filename', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createMediaCtx({
extra: { document: { file_name: 'report.pdf' } },
});
await triggerMediaMessage('message:document', ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({ content: '[Document: report.pdf]' }),
);
});
it('stores document with fallback name when filename missing', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createMediaCtx({ extra: { document: {} } });
await triggerMediaMessage('message:document', ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({ content: '[Document: file]' }),
);
});
it('stores sticker with emoji', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createMediaCtx({
extra: { sticker: { emoji: '😂' } },
});
await triggerMediaMessage('message:sticker', ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({ content: '[Sticker 😂]' }),
);
});
it('stores location with placeholder', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createMediaCtx({});
await triggerMediaMessage('message:location', ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({ content: '[Location]' }),
);
});
it('stores contact with placeholder', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createMediaCtx({});
await triggerMediaMessage('message:contact', ctx);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({ content: '[Contact]' }),
);
});
it('ignores non-text messages from unregistered chats', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const ctx = createMediaCtx({ chatId: 999999 });
await triggerMediaMessage('message:photo', ctx);
expect(opts.onMessage).not.toHaveBeenCalled();
});
});
// --- sendMessage ---
describe('sendMessage', () => {
it('sends message via bot API', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
await channel.sendMessage('tg:100200300', 'Hello');
expect(currentBot().api.sendMessage).toHaveBeenCalledWith(
'100200300',
'Hello',
);
});
it('strips tg: prefix from JID', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
await channel.sendMessage('tg:-1001234567890', 'Group message');
expect(currentBot().api.sendMessage).toHaveBeenCalledWith(
'-1001234567890',
'Group message',
);
});
it('splits messages exceeding 4096 characters', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const longText = 'x'.repeat(5000);
await channel.sendMessage('tg:100200300', longText);
expect(currentBot().api.sendMessage).toHaveBeenCalledTimes(2);
expect(currentBot().api.sendMessage).toHaveBeenNthCalledWith(
1,
'100200300',
'x'.repeat(4096),
);
expect(currentBot().api.sendMessage).toHaveBeenNthCalledWith(
2,
'100200300',
'x'.repeat(904),
);
});
it('sends exactly one message at 4096 characters', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const exactText = 'y'.repeat(4096);
await channel.sendMessage('tg:100200300', exactText);
expect(currentBot().api.sendMessage).toHaveBeenCalledTimes(1);
});
it('handles send failure gracefully', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
currentBot().api.sendMessage.mockRejectedValueOnce(
new Error('Network error'),
);
// Should not throw
await expect(
channel.sendMessage('tg:100200300', 'Will fail'),
).resolves.toBeUndefined();
});
it('does nothing when bot is not initialized', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
// Don't connect — bot is null
await channel.sendMessage('tg:100200300', 'No bot');
// No error, no API call
});
});
// --- ownsJid ---
describe('ownsJid', () => {
it('owns tg: JIDs', () => {
const channel = new TelegramChannel('test-token', createTestOpts());
expect(channel.ownsJid('tg:123456')).toBe(true);
});
it('owns tg: JIDs with negative IDs (groups)', () => {
const channel = new TelegramChannel('test-token', createTestOpts());
expect(channel.ownsJid('tg:-1001234567890')).toBe(true);
});
it('does not own WhatsApp group JIDs', () => {
const channel = new TelegramChannel('test-token', createTestOpts());
expect(channel.ownsJid('12345@g.us')).toBe(false);
});
it('does not own WhatsApp DM JIDs', () => {
const channel = new TelegramChannel('test-token', createTestOpts());
expect(channel.ownsJid('12345@s.whatsapp.net')).toBe(false);
});
it('does not own unknown JID formats', () => {
const channel = new TelegramChannel('test-token', createTestOpts());
expect(channel.ownsJid('random-string')).toBe(false);
});
});
// --- setTyping ---
describe('setTyping', () => {
it('sends typing action when isTyping is true', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
await channel.setTyping('tg:100200300', true);
expect(currentBot().api.sendChatAction).toHaveBeenCalledWith(
'100200300',
'typing',
);
});
it('does nothing when isTyping is false', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
await channel.setTyping('tg:100200300', false);
expect(currentBot().api.sendChatAction).not.toHaveBeenCalled();
});
it('does nothing when bot is not initialized', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
// Don't connect
await channel.setTyping('tg:100200300', true);
// No error, no API call
});
it('handles typing indicator failure gracefully', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
currentBot().api.sendChatAction.mockRejectedValueOnce(
new Error('Rate limited'),
);
await expect(
channel.setTyping('tg:100200300', true),
).resolves.toBeUndefined();
});
});
// --- Bot commands ---
describe('bot commands', () => {
it('/chatid replies with chat ID and metadata', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const handler = currentBot().commandHandlers.get('chatid')!;
const ctx = {
chat: { id: 100200300, type: 'group' as const },
from: { first_name: 'Alice' },
reply: vi.fn(),
};
await handler(ctx);
expect(ctx.reply).toHaveBeenCalledWith(
expect.stringContaining('tg:100200300'),
expect.objectContaining({ parse_mode: 'Markdown' }),
);
});
it('/chatid shows chat type', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const handler = currentBot().commandHandlers.get('chatid')!;
const ctx = {
chat: { id: 555, type: 'private' as const },
from: { first_name: 'Bob' },
reply: vi.fn(),
};
await handler(ctx);
expect(ctx.reply).toHaveBeenCalledWith(
expect.stringContaining('private'),
expect.any(Object),
);
});
it('/ping replies with bot status', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
const handler = currentBot().commandHandlers.get('ping')!;
const ctx = { reply: vi.fn() };
await handler(ctx);
expect(ctx.reply).toHaveBeenCalledWith('Andy is online.');
});
});
// --- Channel properties ---
describe('channel properties', () => {
it('has name "telegram"', () => {
const channel = new TelegramChannel('test-token', createTestOpts());
expect(channel.name).toBe('telegram');
});
});
});
@@ -1,257 +0,0 @@
import { Bot } from 'grammy';
import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js';
import { readEnvFile } from '../env.js';
import { logger } from '../logger.js';
import { registerChannel, ChannelOpts } from './registry.js';
import {
Channel,
OnChatMetadata,
OnInboundMessage,
RegisteredGroup,
} from '../types.js';
export interface TelegramChannelOpts {
onMessage: OnInboundMessage;
onChatMetadata: OnChatMetadata;
registeredGroups: () => Record<string, RegisteredGroup>;
}
export class TelegramChannel implements Channel {
name = 'telegram';
private bot: Bot | null = null;
private opts: TelegramChannelOpts;
private botToken: string;
constructor(botToken: string, opts: TelegramChannelOpts) {
this.botToken = botToken;
this.opts = opts;
}
async connect(): Promise<void> {
this.bot = new Bot(this.botToken);
// Command to get chat ID (useful for registration)
this.bot.command('chatid', (ctx) => {
const chatId = ctx.chat.id;
const chatType = ctx.chat.type;
const chatName =
chatType === 'private'
? ctx.from?.first_name || 'Private'
: (ctx.chat as any).title || 'Unknown';
ctx.reply(
`Chat ID: \`tg:${chatId}\`\nName: ${chatName}\nType: ${chatType}`,
{ parse_mode: 'Markdown' },
);
});
// Command to check bot status
this.bot.command('ping', (ctx) => {
ctx.reply(`${ASSISTANT_NAME} is online.`);
});
this.bot.on('message:text', async (ctx) => {
// Skip commands
if (ctx.message.text.startsWith('/')) return;
const chatJid = `tg:${ctx.chat.id}`;
let content = ctx.message.text;
const timestamp = new Date(ctx.message.date * 1000).toISOString();
const senderName =
ctx.from?.first_name ||
ctx.from?.username ||
ctx.from?.id.toString() ||
'Unknown';
const sender = ctx.from?.id.toString() || '';
const msgId = ctx.message.message_id.toString();
// Determine chat name
const chatName =
ctx.chat.type === 'private'
? senderName
: (ctx.chat as any).title || chatJid;
// Translate Telegram @bot_username mentions into TRIGGER_PATTERN format.
// Telegram @mentions (e.g., @andy_ai_bot) won't match TRIGGER_PATTERN
// (e.g., ^@Andy\b), so we prepend the trigger when the bot is @mentioned.
const botUsername = ctx.me?.username?.toLowerCase();
if (botUsername) {
const entities = ctx.message.entities || [];
const isBotMentioned = entities.some((entity) => {
if (entity.type === 'mention') {
const mentionText = content
.substring(entity.offset, entity.offset + entity.length)
.toLowerCase();
return mentionText === `@${botUsername}`;
}
return false;
});
if (isBotMentioned && !TRIGGER_PATTERN.test(content)) {
content = `@${ASSISTANT_NAME} ${content}`;
}
}
// Store chat metadata for discovery
const isGroup = ctx.chat.type === 'group' || ctx.chat.type === 'supergroup';
this.opts.onChatMetadata(chatJid, timestamp, chatName, 'telegram', isGroup);
// Only deliver full message for registered groups
const group = this.opts.registeredGroups()[chatJid];
if (!group) {
logger.debug(
{ chatJid, chatName },
'Message from unregistered Telegram chat',
);
return;
}
// Deliver message — startMessageLoop() will pick it up
this.opts.onMessage(chatJid, {
id: msgId,
chat_jid: chatJid,
sender,
sender_name: senderName,
content,
timestamp,
is_from_me: false,
});
logger.info(
{ chatJid, chatName, sender: senderName },
'Telegram message stored',
);
});
// Handle non-text messages with placeholders so the agent knows something was sent
const storeNonText = (ctx: any, placeholder: string) => {
const chatJid = `tg:${ctx.chat.id}`;
const group = this.opts.registeredGroups()[chatJid];
if (!group) return;
const timestamp = new Date(ctx.message.date * 1000).toISOString();
const senderName =
ctx.from?.first_name ||
ctx.from?.username ||
ctx.from?.id?.toString() ||
'Unknown';
const caption = ctx.message.caption ? ` ${ctx.message.caption}` : '';
const isGroup = ctx.chat.type === 'group' || ctx.chat.type === 'supergroup';
this.opts.onChatMetadata(chatJid, timestamp, undefined, 'telegram', isGroup);
this.opts.onMessage(chatJid, {
id: ctx.message.message_id.toString(),
chat_jid: chatJid,
sender: ctx.from?.id?.toString() || '',
sender_name: senderName,
content: `${placeholder}${caption}`,
timestamp,
is_from_me: false,
});
};
this.bot.on('message:photo', (ctx) => storeNonText(ctx, '[Photo]'));
this.bot.on('message:video', (ctx) => storeNonText(ctx, '[Video]'));
this.bot.on('message:voice', (ctx) =>
storeNonText(ctx, '[Voice message]'),
);
this.bot.on('message:audio', (ctx) => storeNonText(ctx, '[Audio]'));
this.bot.on('message:document', (ctx) => {
const name = ctx.message.document?.file_name || 'file';
storeNonText(ctx, `[Document: ${name}]`);
});
this.bot.on('message:sticker', (ctx) => {
const emoji = ctx.message.sticker?.emoji || '';
storeNonText(ctx, `[Sticker ${emoji}]`);
});
this.bot.on('message:location', (ctx) => storeNonText(ctx, '[Location]'));
this.bot.on('message:contact', (ctx) => storeNonText(ctx, '[Contact]'));
// Handle errors gracefully
this.bot.catch((err) => {
logger.error({ err: err.message }, 'Telegram bot error');
});
// Start polling — returns a Promise that resolves when started
return new Promise<void>((resolve) => {
this.bot!.start({
onStart: (botInfo) => {
logger.info(
{ username: botInfo.username, id: botInfo.id },
'Telegram bot connected',
);
console.log(`\n Telegram bot: @${botInfo.username}`);
console.log(
` Send /chatid to the bot to get a chat's registration ID\n`,
);
resolve();
},
});
});
}
async sendMessage(jid: string, text: string): Promise<void> {
if (!this.bot) {
logger.warn('Telegram bot not initialized');
return;
}
try {
const numericId = jid.replace(/^tg:/, '');
// Telegram has a 4096 character limit per message — split if needed
const MAX_LENGTH = 4096;
if (text.length <= MAX_LENGTH) {
await this.bot.api.sendMessage(numericId, text);
} else {
for (let i = 0; i < text.length; i += MAX_LENGTH) {
await this.bot.api.sendMessage(
numericId,
text.slice(i, i + MAX_LENGTH),
);
}
}
logger.info({ jid, length: text.length }, 'Telegram message sent');
} catch (err) {
logger.error({ jid, err }, 'Failed to send Telegram message');
}
}
isConnected(): boolean {
return this.bot !== null;
}
ownsJid(jid: string): boolean {
return jid.startsWith('tg:');
}
async disconnect(): Promise<void> {
if (this.bot) {
this.bot.stop();
this.bot = null;
logger.info('Telegram bot stopped');
}
}
async setTyping(jid: string, isTyping: boolean): Promise<void> {
if (!this.bot || !isTyping) return;
try {
const numericId = jid.replace(/^tg:/, '');
await this.bot.api.sendChatAction(numericId, 'typing');
} catch (err) {
logger.debug({ jid, err }, 'Failed to send Telegram typing indicator');
}
}
}
registerChannel('telegram', (opts: ChannelOpts) => {
const envVars = readEnvFile(['TELEGRAM_BOT_TOKEN']);
const token =
process.env.TELEGRAM_BOT_TOKEN || envVars.TELEGRAM_BOT_TOKEN || '';
if (!token) {
logger.warn('Telegram: TELEGRAM_BOT_TOKEN not set');
return null;
}
return new TelegramChannel(token, opts);
});
-17
View File
@@ -1,17 +0,0 @@
skill: telegram
version: 1.0.0
description: "Telegram Bot API integration via Grammy"
core_version: 0.1.0
adds:
- src/channels/telegram.ts
- src/channels/telegram.test.ts
modifies:
- src/channels/index.ts
structured:
npm_dependencies:
grammy: "^1.39.3"
env_additions:
- TELEGRAM_BOT_TOKEN
conflicts: []
depends: []
test: "npx vitest run src/channels/telegram.test.ts"
@@ -1,13 +0,0 @@
// Channel self-registration barrel file.
// Each import triggers the channel module's registerChannel() call.
// discord
// gmail
// slack
// telegram
import './telegram.js';
// whatsapp
@@ -1,7 +0,0 @@
# Intent: Add Telegram channel import
Add `import './telegram.js';` to the channel barrel file so the Telegram
module self-registers with the channel registry on startup.
This is an append-only change — existing import lines for other channels
must be preserved.
@@ -1,69 +0,0 @@
import { describe, expect, it } from 'vitest';
import fs from 'fs';
import path from 'path';
describe('telegram skill package', () => {
const skillDir = path.resolve(__dirname, '..');
it('has a valid manifest', () => {
const manifestPath = path.join(skillDir, 'manifest.yaml');
expect(fs.existsSync(manifestPath)).toBe(true);
const content = fs.readFileSync(manifestPath, 'utf-8');
expect(content).toContain('skill: telegram');
expect(content).toContain('version: 1.0.0');
expect(content).toContain('grammy');
});
it('has all files declared in adds', () => {
const channelFile = path.join(
skillDir,
'add',
'src',
'channels',
'telegram.ts',
);
expect(fs.existsSync(channelFile)).toBe(true);
const content = fs.readFileSync(channelFile, 'utf-8');
expect(content).toContain('class TelegramChannel');
expect(content).toContain('implements Channel');
expect(content).toContain("registerChannel('telegram'");
// Test file for the channel
const testFile = path.join(
skillDir,
'add',
'src',
'channels',
'telegram.test.ts',
);
expect(fs.existsSync(testFile)).toBe(true);
const testContent = fs.readFileSync(testFile, 'utf-8');
expect(testContent).toContain("describe('TelegramChannel'");
});
it('has all files declared in modifies', () => {
// Channel barrel file
const indexFile = path.join(
skillDir,
'modify',
'src',
'channels',
'index.ts',
);
expect(fs.existsSync(indexFile)).toBe(true);
const indexContent = fs.readFileSync(indexFile, 'utf-8');
expect(indexContent).toContain("import './telegram.js'");
});
it('has intent files for modified files', () => {
expect(
fs.existsSync(
path.join(skillDir, 'modify', 'src', 'channels', 'index.ts.intent.md'),
),
).toBe(true);
});
});
+27 -20
View File
@@ -11,7 +11,7 @@ This skill adds automatic voice message transcription to NanoClaw's WhatsApp cha
### Check if already applied
Read `.nanoclaw/state.yaml`. If `voice-transcription` is in `applied_skills`, skip to Phase 3 (Configure). The code changes are already in place.
Check if `src/transcription.ts` exists. If it does, skip to Phase 3 (Configure). The code changes are already in place.
### Ask the user
@@ -23,42 +23,49 @@ If yes, collect it now. If no, direct them to create one at https://platform.ope
## Phase 2: Apply Code Changes
Run the skills engine to apply this skill's code package.
**Prerequisite:** WhatsApp must be installed first (`skill/whatsapp` merged). This skill modifies WhatsApp channel files.
### Initialize skills system (if needed)
If `.nanoclaw/` directory doesn't exist yet:
### Ensure WhatsApp fork remote
```bash
npx tsx scripts/apply-skill.ts --init
git remote -v
```
### Apply the skill
If `whatsapp` is missing, add it:
```bash
npx tsx scripts/apply-skill.ts .claude/skills/add-voice-transcription
git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
```
This deterministically:
- Adds `src/transcription.ts` (voice transcription module using OpenAI Whisper)
- Three-way merges voice handling into `src/channels/whatsapp.ts` (isVoiceMessage check, transcribeAudioMessage call)
- Three-way merges transcription tests into `src/channels/whatsapp.test.ts` (mock + 3 test cases)
- Installs the `openai` npm dependency
- Updates `.env.example` with `OPENAI_API_KEY`
- Records the application in `.nanoclaw/state.yaml`
### Merge the skill branch
If the apply reports merge conflicts, read the intent files:
- `modify/src/channels/whatsapp.ts.intent.md` — what changed and invariants for whatsapp.ts
- `modify/src/channels/whatsapp.test.ts.intent.md` — what changed for whatsapp.test.ts
```bash
git fetch whatsapp skill/voice-transcription
git merge whatsapp/skill/voice-transcription || {
git checkout --theirs package-lock.json
git add package-lock.json
git merge --continue
}
```
This merges in:
- `src/transcription.ts` (voice transcription module using OpenAI Whisper)
- Voice handling in `src/channels/whatsapp.ts` (isVoiceMessage check, transcribeAudioMessage call)
- Transcription tests in `src/channels/whatsapp.test.ts`
- `openai` npm dependency in `package.json`
- `OPENAI_API_KEY` in `.env.example`
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
### Validate code changes
```bash
npm test
npm install --legacy-peer-deps
npm run build
npx vitest run src/channels/whatsapp.test.ts
```
All tests must pass (including the 3 new voice transcription tests) and build must be clean before proceeding.
All tests must pass and build must be clean before proceeding.
## Phase 3: Configure
@@ -1,98 +0,0 @@
import { downloadMediaMessage } from '@whiskeysockets/baileys';
import { WAMessage, WASocket } from '@whiskeysockets/baileys';
import { readEnvFile } from './env.js';
interface TranscriptionConfig {
model: string;
enabled: boolean;
fallbackMessage: string;
}
const DEFAULT_CONFIG: TranscriptionConfig = {
model: 'whisper-1',
enabled: true,
fallbackMessage: '[Voice Message - transcription unavailable]',
};
async function transcribeWithOpenAI(
audioBuffer: Buffer,
config: TranscriptionConfig,
): Promise<string | null> {
const env = readEnvFile(['OPENAI_API_KEY']);
const apiKey = env.OPENAI_API_KEY;
if (!apiKey) {
console.warn('OPENAI_API_KEY not set in .env');
return null;
}
try {
const openaiModule = await import('openai');
const OpenAI = openaiModule.default;
const toFile = openaiModule.toFile;
const openai = new OpenAI({ apiKey });
const file = await toFile(audioBuffer, 'voice.ogg', {
type: 'audio/ogg',
});
const transcription = await openai.audio.transcriptions.create({
file: file,
model: config.model,
response_format: 'text',
});
// When response_format is 'text', the API returns a plain string
return transcription as unknown as string;
} catch (err) {
console.error('OpenAI transcription failed:', err);
return null;
}
}
export async function transcribeAudioMessage(
msg: WAMessage,
sock: WASocket,
): Promise<string | null> {
const config = DEFAULT_CONFIG;
if (!config.enabled) {
return config.fallbackMessage;
}
try {
const buffer = (await downloadMediaMessage(
msg,
'buffer',
{},
{
logger: console as any,
reuploadRequest: sock.updateMediaMessage,
},
)) as Buffer;
if (!buffer || buffer.length === 0) {
console.error('Failed to download audio message');
return config.fallbackMessage;
}
console.log(`Downloaded audio message: ${buffer.length} bytes`);
const transcript = await transcribeWithOpenAI(buffer, config);
if (!transcript) {
return config.fallbackMessage;
}
return transcript.trim();
} catch (err) {
console.error('Transcription error:', err);
return config.fallbackMessage;
}
}
export function isVoiceMessage(msg: WAMessage): boolean {
return msg.message?.audioMessage?.ptt === true;
}
@@ -1,17 +0,0 @@
skill: voice-transcription
version: 1.0.0
description: "Voice message transcription via OpenAI Whisper"
core_version: 0.1.0
adds:
- src/transcription.ts
modifies:
- src/channels/whatsapp.ts
- src/channels/whatsapp.test.ts
structured:
npm_dependencies:
openai: "^4.77.0"
env_additions:
- OPENAI_API_KEY
conflicts: []
depends: []
test: "npx vitest run src/channels/whatsapp.test.ts"
@@ -1,963 +0,0 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { EventEmitter } from 'events';
// --- Mocks ---
// Mock config
vi.mock('../config.js', () => ({
STORE_DIR: '/tmp/nanoclaw-test-store',
ASSISTANT_NAME: 'Andy',
ASSISTANT_HAS_OWN_NUMBER: false,
}));
// Mock logger
vi.mock('../logger.js', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
// Mock db
vi.mock('../db.js', () => ({
getLastGroupSync: vi.fn(() => null),
setLastGroupSync: vi.fn(),
updateChatName: vi.fn(),
}));
// Mock transcription
vi.mock('../transcription.js', () => ({
isVoiceMessage: vi.fn((msg: any) => msg.message?.audioMessage?.ptt === true),
transcribeAudioMessage: vi.fn().mockResolvedValue('Hello this is a voice message'),
}));
// Mock fs
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs');
return {
...actual,
default: {
...actual,
existsSync: vi.fn(() => true),
mkdirSync: vi.fn(),
},
};
});
// Mock child_process (used for osascript notification)
vi.mock('child_process', () => ({
exec: vi.fn(),
}));
// Build a fake WASocket that's an EventEmitter with the methods we need
function createFakeSocket() {
const ev = new EventEmitter();
const sock = {
ev: {
on: (event: string, handler: (...args: unknown[]) => void) => {
ev.on(event, handler);
},
},
user: {
id: '1234567890:1@s.whatsapp.net',
lid: '9876543210:1@lid',
},
sendMessage: vi.fn().mockResolvedValue(undefined),
sendPresenceUpdate: vi.fn().mockResolvedValue(undefined),
groupFetchAllParticipating: vi.fn().mockResolvedValue({}),
end: vi.fn(),
// Expose the event emitter for triggering events in tests
_ev: ev,
};
return sock;
}
let fakeSocket: ReturnType<typeof createFakeSocket>;
// Mock Baileys
vi.mock('@whiskeysockets/baileys', () => {
return {
default: vi.fn(() => fakeSocket),
Browsers: { macOS: vi.fn(() => ['macOS', 'Chrome', '']) },
DisconnectReason: {
loggedOut: 401,
badSession: 500,
connectionClosed: 428,
connectionLost: 408,
connectionReplaced: 440,
timedOut: 408,
restartRequired: 515,
},
makeCacheableSignalKeyStore: vi.fn((keys: unknown) => keys),
useMultiFileAuthState: vi.fn().mockResolvedValue({
state: {
creds: {},
keys: {},
},
saveCreds: vi.fn(),
}),
};
});
import { WhatsAppChannel, WhatsAppChannelOpts } from './whatsapp.js';
import { getLastGroupSync, updateChatName, setLastGroupSync } from '../db.js';
import { transcribeAudioMessage } from '../transcription.js';
// --- Test helpers ---
function createTestOpts(overrides?: Partial<WhatsAppChannelOpts>): WhatsAppChannelOpts {
return {
onMessage: vi.fn(),
onChatMetadata: vi.fn(),
registeredGroups: vi.fn(() => ({
'registered@g.us': {
name: 'Test Group',
folder: 'test-group',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
},
})),
...overrides,
};
}
function triggerConnection(state: string, extra?: Record<string, unknown>) {
fakeSocket._ev.emit('connection.update', { connection: state, ...extra });
}
function triggerDisconnect(statusCode: number) {
fakeSocket._ev.emit('connection.update', {
connection: 'close',
lastDisconnect: {
error: { output: { statusCode } },
},
});
}
async function triggerMessages(messages: unknown[]) {
fakeSocket._ev.emit('messages.upsert', { messages });
// Flush microtasks so the async messages.upsert handler completes
await new Promise((r) => setTimeout(r, 0));
}
// --- Tests ---
describe('WhatsAppChannel', () => {
beforeEach(() => {
fakeSocket = createFakeSocket();
vi.mocked(getLastGroupSync).mockReturnValue(null);
});
afterEach(() => {
vi.restoreAllMocks();
});
/**
* Helper: start connect, flush microtasks so event handlers are registered,
* then trigger the connection open event. Returns the resolved promise.
*/
async function connectChannel(channel: WhatsAppChannel): Promise<void> {
const p = channel.connect();
// Flush microtasks so connectInternal completes its await and registers handlers
await new Promise((r) => setTimeout(r, 0));
triggerConnection('open');
return p;
}
// --- Connection lifecycle ---
describe('connection lifecycle', () => {
it('resolves connect() when connection opens', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
expect(channel.isConnected()).toBe(true);
});
it('sets up LID to phone mapping on open', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
// The channel should have mapped the LID from sock.user
// We can verify by sending a message from a LID JID
// and checking the translated JID in the callback
});
it('flushes outgoing queue on reconnect', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
// Disconnect
(channel as any).connected = false;
// Queue a message while disconnected
await channel.sendMessage('test@g.us', 'Queued message');
expect(fakeSocket.sendMessage).not.toHaveBeenCalled();
// Reconnect
(channel as any).connected = true;
await (channel as any).flushOutgoingQueue();
// Group messages get prefixed when flushed
expect(fakeSocket.sendMessage).toHaveBeenCalledWith(
'test@g.us',
{ text: 'Andy: Queued message' },
);
});
it('disconnects cleanly', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await channel.disconnect();
expect(channel.isConnected()).toBe(false);
expect(fakeSocket.end).toHaveBeenCalled();
});
});
// --- QR code and auth ---
describe('authentication', () => {
it('exits process when QR code is emitted (no auth state)', async () => {
vi.useFakeTimers();
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
// Start connect but don't await (it won't resolve - process exits)
channel.connect().catch(() => {});
// Flush microtasks so connectInternal registers handlers
await vi.advanceTimersByTimeAsync(0);
// Emit QR code event
fakeSocket._ev.emit('connection.update', { qr: 'some-qr-data' });
// Advance timer past the 1000ms setTimeout before exit
await vi.advanceTimersByTimeAsync(1500);
expect(mockExit).toHaveBeenCalledWith(1);
mockExit.mockRestore();
vi.useRealTimers();
});
});
// --- Reconnection behavior ---
describe('reconnection', () => {
it('reconnects on non-loggedOut disconnect', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
expect(channel.isConnected()).toBe(true);
// Disconnect with a non-loggedOut reason (e.g., connectionClosed = 428)
triggerDisconnect(428);
expect(channel.isConnected()).toBe(false);
// The channel should attempt to reconnect (calls connectInternal again)
});
it('exits on loggedOut disconnect', async () => {
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
// Disconnect with loggedOut reason (401)
triggerDisconnect(401);
expect(channel.isConnected()).toBe(false);
expect(mockExit).toHaveBeenCalledWith(0);
mockExit.mockRestore();
});
it('retries reconnection after 5s on failure', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
// Disconnect with stream error 515
triggerDisconnect(515);
// The channel sets a 5s retry — just verify it doesn't crash
await new Promise((r) => setTimeout(r, 100));
});
});
// --- Message handling ---
describe('message handling', () => {
it('delivers message for registered group', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await triggerMessages([
{
key: {
id: 'msg-1',
remoteJid: 'registered@g.us',
participant: '5551234@s.whatsapp.net',
fromMe: false,
},
message: { conversation: 'Hello Andy' },
pushName: 'Alice',
messageTimestamp: Math.floor(Date.now() / 1000),
},
]);
expect(opts.onChatMetadata).toHaveBeenCalledWith(
'registered@g.us',
expect.any(String),
undefined,
'whatsapp',
true,
);
expect(opts.onMessage).toHaveBeenCalledWith(
'registered@g.us',
expect.objectContaining({
id: 'msg-1',
content: 'Hello Andy',
sender_name: 'Alice',
is_from_me: false,
}),
);
});
it('only emits metadata for unregistered groups', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await triggerMessages([
{
key: {
id: 'msg-2',
remoteJid: 'unregistered@g.us',
participant: '5551234@s.whatsapp.net',
fromMe: false,
},
message: { conversation: 'Hello' },
pushName: 'Bob',
messageTimestamp: Math.floor(Date.now() / 1000),
},
]);
expect(opts.onChatMetadata).toHaveBeenCalledWith(
'unregistered@g.us',
expect.any(String),
undefined,
'whatsapp',
true,
);
expect(opts.onMessage).not.toHaveBeenCalled();
});
it('ignores status@broadcast messages', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await triggerMessages([
{
key: {
id: 'msg-3',
remoteJid: 'status@broadcast',
fromMe: false,
},
message: { conversation: 'Status update' },
messageTimestamp: Math.floor(Date.now() / 1000),
},
]);
expect(opts.onChatMetadata).not.toHaveBeenCalled();
expect(opts.onMessage).not.toHaveBeenCalled();
});
it('ignores messages with no content', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await triggerMessages([
{
key: {
id: 'msg-4',
remoteJid: 'registered@g.us',
fromMe: false,
},
message: null,
messageTimestamp: Math.floor(Date.now() / 1000),
},
]);
expect(opts.onMessage).not.toHaveBeenCalled();
});
it('extracts text from extendedTextMessage', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await triggerMessages([
{
key: {
id: 'msg-5',
remoteJid: 'registered@g.us',
participant: '5551234@s.whatsapp.net',
fromMe: false,
},
message: {
extendedTextMessage: { text: 'A reply message' },
},
pushName: 'Charlie',
messageTimestamp: Math.floor(Date.now() / 1000),
},
]);
expect(opts.onMessage).toHaveBeenCalledWith(
'registered@g.us',
expect.objectContaining({ content: 'A reply message' }),
);
});
it('extracts caption from imageMessage', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await triggerMessages([
{
key: {
id: 'msg-6',
remoteJid: 'registered@g.us',
participant: '5551234@s.whatsapp.net',
fromMe: false,
},
message: {
imageMessage: { caption: 'Check this photo', mimetype: 'image/jpeg' },
},
pushName: 'Diana',
messageTimestamp: Math.floor(Date.now() / 1000),
},
]);
expect(opts.onMessage).toHaveBeenCalledWith(
'registered@g.us',
expect.objectContaining({ content: 'Check this photo' }),
);
});
it('extracts caption from videoMessage', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await triggerMessages([
{
key: {
id: 'msg-7',
remoteJid: 'registered@g.us',
participant: '5551234@s.whatsapp.net',
fromMe: false,
},
message: {
videoMessage: { caption: 'Watch this', mimetype: 'video/mp4' },
},
pushName: 'Eve',
messageTimestamp: Math.floor(Date.now() / 1000),
},
]);
expect(opts.onMessage).toHaveBeenCalledWith(
'registered@g.us',
expect.objectContaining({ content: 'Watch this' }),
);
});
it('transcribes voice messages', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await triggerMessages([
{
key: {
id: 'msg-8',
remoteJid: 'registered@g.us',
participant: '5551234@s.whatsapp.net',
fromMe: false,
},
message: {
audioMessage: { mimetype: 'audio/ogg; codecs=opus', ptt: true },
},
pushName: 'Frank',
messageTimestamp: Math.floor(Date.now() / 1000),
},
]);
expect(transcribeAudioMessage).toHaveBeenCalled();
expect(opts.onMessage).toHaveBeenCalledTimes(1);
expect(opts.onMessage).toHaveBeenCalledWith(
'registered@g.us',
expect.objectContaining({ content: '[Voice: Hello this is a voice message]' }),
);
});
it('falls back when transcription returns null', async () => {
vi.mocked(transcribeAudioMessage).mockResolvedValueOnce(null);
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await triggerMessages([
{
key: {
id: 'msg-8b',
remoteJid: 'registered@g.us',
participant: '5551234@s.whatsapp.net',
fromMe: false,
},
message: {
audioMessage: { mimetype: 'audio/ogg; codecs=opus', ptt: true },
},
pushName: 'Frank',
messageTimestamp: Math.floor(Date.now() / 1000),
},
]);
expect(opts.onMessage).toHaveBeenCalledTimes(1);
expect(opts.onMessage).toHaveBeenCalledWith(
'registered@g.us',
expect.objectContaining({ content: '[Voice Message - transcription unavailable]' }),
);
});
it('falls back when transcription throws', async () => {
vi.mocked(transcribeAudioMessage).mockRejectedValueOnce(new Error('API error'));
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await triggerMessages([
{
key: {
id: 'msg-8c',
remoteJid: 'registered@g.us',
participant: '5551234@s.whatsapp.net',
fromMe: false,
},
message: {
audioMessage: { mimetype: 'audio/ogg; codecs=opus', ptt: true },
},
pushName: 'Frank',
messageTimestamp: Math.floor(Date.now() / 1000),
},
]);
expect(opts.onMessage).toHaveBeenCalledTimes(1);
expect(opts.onMessage).toHaveBeenCalledWith(
'registered@g.us',
expect.objectContaining({ content: '[Voice Message - transcription failed]' }),
);
});
it('uses sender JID when pushName is absent', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await triggerMessages([
{
key: {
id: 'msg-9',
remoteJid: 'registered@g.us',
participant: '5551234@s.whatsapp.net',
fromMe: false,
},
message: { conversation: 'No push name' },
// pushName is undefined
messageTimestamp: Math.floor(Date.now() / 1000),
},
]);
expect(opts.onMessage).toHaveBeenCalledWith(
'registered@g.us',
expect.objectContaining({ sender_name: '5551234' }),
);
});
});
// --- LID ↔ JID translation ---
describe('LID to JID translation', () => {
it('translates known LID to phone JID', async () => {
const opts = createTestOpts({
registeredGroups: vi.fn(() => ({
'1234567890@s.whatsapp.net': {
name: 'Self Chat',
folder: 'self-chat',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
},
})),
});
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
// The socket has lid '9876543210:1@lid' → phone '1234567890@s.whatsapp.net'
// Send a message from the LID
await triggerMessages([
{
key: {
id: 'msg-lid',
remoteJid: '9876543210@lid',
fromMe: false,
},
message: { conversation: 'From LID' },
pushName: 'Self',
messageTimestamp: Math.floor(Date.now() / 1000),
},
]);
// Should be translated to phone JID
expect(opts.onChatMetadata).toHaveBeenCalledWith(
'1234567890@s.whatsapp.net',
expect.any(String),
undefined,
'whatsapp',
false,
);
});
it('passes through non-LID JIDs unchanged', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await triggerMessages([
{
key: {
id: 'msg-normal',
remoteJid: 'registered@g.us',
participant: '5551234@s.whatsapp.net',
fromMe: false,
},
message: { conversation: 'Normal JID' },
pushName: 'Grace',
messageTimestamp: Math.floor(Date.now() / 1000),
},
]);
expect(opts.onChatMetadata).toHaveBeenCalledWith(
'registered@g.us',
expect.any(String),
undefined,
'whatsapp',
true,
);
});
it('passes through unknown LID JIDs unchanged', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await triggerMessages([
{
key: {
id: 'msg-unknown-lid',
remoteJid: '0000000000@lid',
fromMe: false,
},
message: { conversation: 'Unknown LID' },
pushName: 'Unknown',
messageTimestamp: Math.floor(Date.now() / 1000),
},
]);
// Unknown LID passes through unchanged
expect(opts.onChatMetadata).toHaveBeenCalledWith(
'0000000000@lid',
expect.any(String),
undefined,
'whatsapp',
false,
);
});
});
// --- Outgoing message queue ---
describe('outgoing message queue', () => {
it('sends message directly when connected', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await channel.sendMessage('test@g.us', 'Hello');
// Group messages get prefixed with assistant name
expect(fakeSocket.sendMessage).toHaveBeenCalledWith('test@g.us', { text: 'Andy: Hello' });
});
it('prefixes direct chat messages on shared number', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await channel.sendMessage('123@s.whatsapp.net', 'Hello');
// Shared number: DMs also get prefixed (needed for self-chat distinction)
expect(fakeSocket.sendMessage).toHaveBeenCalledWith('123@s.whatsapp.net', { text: 'Andy: Hello' });
});
it('queues message when disconnected', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
// Don't connect — channel starts disconnected
await channel.sendMessage('test@g.us', 'Queued');
expect(fakeSocket.sendMessage).not.toHaveBeenCalled();
});
it('queues message on send failure', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
// Make sendMessage fail
fakeSocket.sendMessage.mockRejectedValueOnce(new Error('Network error'));
await channel.sendMessage('test@g.us', 'Will fail');
// Should not throw, message queued for retry
// The queue should have the message
});
it('flushes multiple queued messages in order', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
// Queue messages while disconnected
await channel.sendMessage('test@g.us', 'First');
await channel.sendMessage('test@g.us', 'Second');
await channel.sendMessage('test@g.us', 'Third');
// Connect — flush happens automatically on open
await connectChannel(channel);
// Give the async flush time to complete
await new Promise((r) => setTimeout(r, 50));
expect(fakeSocket.sendMessage).toHaveBeenCalledTimes(3);
// Group messages get prefixed
expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(1, 'test@g.us', { text: 'Andy: First' });
expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(2, 'test@g.us', { text: 'Andy: Second' });
expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(3, 'test@g.us', { text: 'Andy: Third' });
});
});
// --- Group metadata sync ---
describe('group metadata sync', () => {
it('syncs group metadata on first connection', async () => {
fakeSocket.groupFetchAllParticipating.mockResolvedValue({
'group1@g.us': { subject: 'Group One' },
'group2@g.us': { subject: 'Group Two' },
});
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
// Wait for async sync to complete
await new Promise((r) => setTimeout(r, 50));
expect(fakeSocket.groupFetchAllParticipating).toHaveBeenCalled();
expect(updateChatName).toHaveBeenCalledWith('group1@g.us', 'Group One');
expect(updateChatName).toHaveBeenCalledWith('group2@g.us', 'Group Two');
expect(setLastGroupSync).toHaveBeenCalled();
});
it('skips sync when synced recently', async () => {
// Last sync was 1 hour ago (within 24h threshold)
vi.mocked(getLastGroupSync).mockReturnValue(
new Date(Date.now() - 60 * 60 * 1000).toISOString(),
);
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await new Promise((r) => setTimeout(r, 50));
expect(fakeSocket.groupFetchAllParticipating).not.toHaveBeenCalled();
});
it('forces sync regardless of cache', async () => {
vi.mocked(getLastGroupSync).mockReturnValue(
new Date(Date.now() - 60 * 60 * 1000).toISOString(),
);
fakeSocket.groupFetchAllParticipating.mockResolvedValue({
'group@g.us': { subject: 'Forced Group' },
});
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await channel.syncGroupMetadata(true);
expect(fakeSocket.groupFetchAllParticipating).toHaveBeenCalled();
expect(updateChatName).toHaveBeenCalledWith('group@g.us', 'Forced Group');
});
it('handles group sync failure gracefully', async () => {
fakeSocket.groupFetchAllParticipating.mockRejectedValue(
new Error('Network timeout'),
);
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
// Should not throw
await expect(channel.syncGroupMetadata(true)).resolves.toBeUndefined();
});
it('skips groups with no subject', async () => {
fakeSocket.groupFetchAllParticipating.mockResolvedValue({
'group1@g.us': { subject: 'Has Subject' },
'group2@g.us': { subject: '' },
'group3@g.us': {},
});
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
// Clear any calls from the automatic sync on connect
vi.mocked(updateChatName).mockClear();
await channel.syncGroupMetadata(true);
expect(updateChatName).toHaveBeenCalledTimes(1);
expect(updateChatName).toHaveBeenCalledWith('group1@g.us', 'Has Subject');
});
});
// --- JID ownership ---
describe('ownsJid', () => {
it('owns @g.us JIDs (WhatsApp groups)', () => {
const channel = new WhatsAppChannel(createTestOpts());
expect(channel.ownsJid('12345@g.us')).toBe(true);
});
it('owns @s.whatsapp.net JIDs (WhatsApp DMs)', () => {
const channel = new WhatsAppChannel(createTestOpts());
expect(channel.ownsJid('12345@s.whatsapp.net')).toBe(true);
});
it('does not own Telegram JIDs', () => {
const channel = new WhatsAppChannel(createTestOpts());
expect(channel.ownsJid('tg:12345')).toBe(false);
});
it('does not own unknown JID formats', () => {
const channel = new WhatsAppChannel(createTestOpts());
expect(channel.ownsJid('random-string')).toBe(false);
});
});
// --- Typing indicator ---
describe('setTyping', () => {
it('sends composing presence when typing', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await channel.setTyping('test@g.us', true);
expect(fakeSocket.sendPresenceUpdate).toHaveBeenCalledWith('composing', 'test@g.us');
});
it('sends paused presence when stopping', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await channel.setTyping('test@g.us', false);
expect(fakeSocket.sendPresenceUpdate).toHaveBeenCalledWith('paused', 'test@g.us');
});
it('handles typing indicator failure gracefully', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
fakeSocket.sendPresenceUpdate.mockRejectedValueOnce(new Error('Failed'));
// Should not throw
await expect(channel.setTyping('test@g.us', true)).resolves.toBeUndefined();
});
});
// --- Channel properties ---
describe('channel properties', () => {
it('has name "whatsapp"', () => {
const channel = new WhatsAppChannel(createTestOpts());
expect(channel.name).toBe('whatsapp');
});
it('does not expose prefixAssistantName (prefix handled internally)', () => {
const channel = new WhatsAppChannel(createTestOpts());
expect('prefixAssistantName' in channel).toBe(false);
});
});
});
@@ -1,26 +0,0 @@
# Intent: src/channels/whatsapp.test.ts modifications
## What changed
Added mock for the transcription module and 3 new test cases for voice message handling.
## Key sections
### Mocks (top of file)
- Added: `vi.mock('../transcription.js', ...)` with `isVoiceMessage` and `transcribeAudioMessage` mocks
- Added: `import { transcribeAudioMessage } from '../transcription.js'` for test assertions
### Test cases (inside "message handling" describe block)
- Changed: "handles message with no extractable text (e.g. voice note without caption)" → "transcribes voice messages"
- Now expects `[Voice: Hello this is a voice message]` instead of empty content
- Added: "falls back when transcription returns null" — expects `[Voice Message - transcription unavailable]`
- Added: "falls back when transcription throws" — expects `[Voice Message - transcription failed]`
## Invariants (must-keep)
- All existing test cases for text, extendedTextMessage, imageMessage, videoMessage unchanged
- All connection lifecycle tests unchanged
- All LID translation tests unchanged
- All outgoing queue tests unchanged
- All group metadata sync tests unchanged
- All ownsJid and setTyping tests unchanged
- All existing mocks (config, logger, db, fs, child_process, baileys) unchanged
- Test helpers (createTestOpts, triggerConnection, triggerDisconnect, triggerMessages, connectChannel) unchanged
@@ -1,356 +0,0 @@
import { exec } from 'child_process';
import fs from 'fs';
import path from 'path';
import makeWASocket, {
Browsers,
DisconnectReason,
WASocket,
fetchLatestWaWebVersion,
makeCacheableSignalKeyStore,
useMultiFileAuthState,
} from '@whiskeysockets/baileys';
import { ASSISTANT_HAS_OWN_NUMBER, ASSISTANT_NAME, STORE_DIR } from '../config.js';
import {
getLastGroupSync,
setLastGroupSync,
updateChatName,
} from '../db.js';
import { logger } from '../logger.js';
import { isVoiceMessage, transcribeAudioMessage } from '../transcription.js';
import { Channel, OnInboundMessage, OnChatMetadata, RegisteredGroup } from '../types.js';
const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
export interface WhatsAppChannelOpts {
onMessage: OnInboundMessage;
onChatMetadata: OnChatMetadata;
registeredGroups: () => Record<string, RegisteredGroup>;
}
export class WhatsAppChannel implements Channel {
name = 'whatsapp';
private sock!: WASocket;
private connected = false;
private lidToPhoneMap: Record<string, string> = {};
private outgoingQueue: Array<{ jid: string; text: string }> = [];
private flushing = false;
private groupSyncTimerStarted = false;
private opts: WhatsAppChannelOpts;
constructor(opts: WhatsAppChannelOpts) {
this.opts = opts;
}
async connect(): Promise<void> {
return new Promise<void>((resolve, reject) => {
this.connectInternal(resolve).catch(reject);
});
}
private async connectInternal(onFirstOpen?: () => void): Promise<void> {
const authDir = path.join(STORE_DIR, 'auth');
fs.mkdirSync(authDir, { recursive: true });
const { state, saveCreds } = await useMultiFileAuthState(authDir);
const { version } = await fetchLatestWaWebVersion({}).catch((err) => {
logger.warn({ err }, 'Failed to fetch latest WA Web version, using default');
return { version: undefined };
});
this.sock = makeWASocket({
version,
auth: {
creds: state.creds,
keys: makeCacheableSignalKeyStore(state.keys, logger),
},
printQRInTerminal: false,
logger,
browser: Browsers.macOS('Chrome'),
});
this.sock.ev.on('connection.update', (update) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
const msg =
'WhatsApp authentication required. Run /setup in Claude Code.';
logger.error(msg);
exec(
`osascript -e 'display notification "${msg}" with title "NanoClaw" sound name "Basso"'`,
);
setTimeout(() => process.exit(1), 1000);
}
if (connection === 'close') {
this.connected = false;
const reason = (lastDisconnect?.error as { output?: { statusCode?: number } })?.output?.statusCode;
const shouldReconnect = reason !== DisconnectReason.loggedOut;
logger.info({ reason, shouldReconnect, queuedMessages: this.outgoingQueue.length }, 'Connection closed');
if (shouldReconnect) {
logger.info('Reconnecting...');
this.connectInternal().catch((err) => {
logger.error({ err }, 'Failed to reconnect, retrying in 5s');
setTimeout(() => {
this.connectInternal().catch((err2) => {
logger.error({ err: err2 }, 'Reconnection retry failed');
});
}, 5000);
});
} else {
logger.info('Logged out. Run /setup to re-authenticate.');
process.exit(0);
}
} else if (connection === 'open') {
this.connected = true;
logger.info('Connected to WhatsApp');
// Announce availability so WhatsApp relays subsequent presence updates (typing indicators)
this.sock.sendPresenceUpdate('available').catch((err) => {
logger.warn({ err }, 'Failed to send presence update');
});
// Build LID to phone mapping from auth state for self-chat translation
if (this.sock.user) {
const phoneUser = this.sock.user.id.split(':')[0];
const lidUser = this.sock.user.lid?.split(':')[0];
if (lidUser && phoneUser) {
this.lidToPhoneMap[lidUser] = `${phoneUser}@s.whatsapp.net`;
logger.debug({ lidUser, phoneUser }, 'LID to phone mapping set');
}
}
// Flush any messages queued while disconnected
this.flushOutgoingQueue().catch((err) =>
logger.error({ err }, 'Failed to flush outgoing queue'),
);
// Sync group metadata on startup (respects 24h cache)
this.syncGroupMetadata().catch((err) =>
logger.error({ err }, 'Initial group sync failed'),
);
// Set up daily sync timer (only once)
if (!this.groupSyncTimerStarted) {
this.groupSyncTimerStarted = true;
setInterval(() => {
this.syncGroupMetadata().catch((err) =>
logger.error({ err }, 'Periodic group sync failed'),
);
}, GROUP_SYNC_INTERVAL_MS);
}
// Signal first connection to caller
if (onFirstOpen) {
onFirstOpen();
onFirstOpen = undefined;
}
}
});
this.sock.ev.on('creds.update', saveCreds);
this.sock.ev.on('messages.upsert', async ({ messages }) => {
for (const msg of messages) {
if (!msg.message) continue;
const rawJid = msg.key.remoteJid;
if (!rawJid || rawJid === 'status@broadcast') continue;
// Translate LID JID to phone JID if applicable
const chatJid = await this.translateJid(rawJid);
const timestamp = new Date(
Number(msg.messageTimestamp) * 1000,
).toISOString();
// Always notify about chat metadata for group discovery
const isGroup = chatJid.endsWith('@g.us');
this.opts.onChatMetadata(chatJid, timestamp, undefined, 'whatsapp', isGroup);
// Only deliver full message for registered groups
const groups = this.opts.registeredGroups();
if (groups[chatJid]) {
const content =
msg.message?.conversation ||
msg.message?.extendedTextMessage?.text ||
msg.message?.imageMessage?.caption ||
msg.message?.videoMessage?.caption ||
'';
// Skip protocol messages with no text content (encryption keys, read receipts, etc.)
// but allow voice messages through for transcription
if (!content && !isVoiceMessage(msg)) continue;
const sender = msg.key.participant || msg.key.remoteJid || '';
const senderName = msg.pushName || sender.split('@')[0];
const fromMe = msg.key.fromMe || false;
// Detect bot messages: with own number, fromMe is reliable
// since only the bot sends from that number.
// With shared number, bot messages carry the assistant name prefix
// (even in DMs/self-chat) so we check for that.
const isBotMessage = ASSISTANT_HAS_OWN_NUMBER
? fromMe
: content.startsWith(`${ASSISTANT_NAME}:`);
// Transcribe voice messages before storing
let finalContent = content;
if (isVoiceMessage(msg)) {
try {
const transcript = await transcribeAudioMessage(msg, this.sock);
if (transcript) {
finalContent = `[Voice: ${transcript}]`;
logger.info({ chatJid, length: transcript.length }, 'Transcribed voice message');
} else {
finalContent = '[Voice Message - transcription unavailable]';
}
} catch (err) {
logger.error({ err }, 'Voice transcription error');
finalContent = '[Voice Message - transcription failed]';
}
}
this.opts.onMessage(chatJid, {
id: msg.key.id || '',
chat_jid: chatJid,
sender,
sender_name: senderName,
content: finalContent,
timestamp,
is_from_me: fromMe,
is_bot_message: isBotMessage,
});
}
}
});
}
async sendMessage(jid: string, text: string): Promise<void> {
// Prefix bot messages with assistant name so users know who's speaking.
// On a shared number, prefix is also needed in DMs (including self-chat)
// to distinguish bot output from user messages.
// Skip only when the assistant has its own dedicated phone number.
const prefixed = ASSISTANT_HAS_OWN_NUMBER
? text
: `${ASSISTANT_NAME}: ${text}`;
if (!this.connected) {
this.outgoingQueue.push({ jid, text: prefixed });
logger.info({ jid, length: prefixed.length, queueSize: this.outgoingQueue.length }, 'WA disconnected, message queued');
return;
}
try {
await this.sock.sendMessage(jid, { text: prefixed });
logger.info({ jid, length: prefixed.length }, 'Message sent');
} catch (err) {
// If send fails, queue it for retry on reconnect
this.outgoingQueue.push({ jid, text: prefixed });
logger.warn({ jid, err, queueSize: this.outgoingQueue.length }, 'Failed to send, message queued');
}
}
isConnected(): boolean {
return this.connected;
}
ownsJid(jid: string): boolean {
return jid.endsWith('@g.us') || jid.endsWith('@s.whatsapp.net');
}
async disconnect(): Promise<void> {
this.connected = false;
this.sock?.end(undefined);
}
async setTyping(jid: string, isTyping: boolean): Promise<void> {
try {
const status = isTyping ? 'composing' : 'paused';
logger.debug({ jid, status }, 'Sending presence update');
await this.sock.sendPresenceUpdate(status, jid);
} catch (err) {
logger.debug({ jid, err }, 'Failed to update typing status');
}
}
/**
* Sync group metadata from WhatsApp.
* Fetches all participating groups and stores their names in the database.
* Called on startup, daily, and on-demand via IPC.
*/
async syncGroupMetadata(force = false): Promise<void> {
if (!force) {
const lastSync = getLastGroupSync();
if (lastSync) {
const lastSyncTime = new Date(lastSync).getTime();
if (Date.now() - lastSyncTime < GROUP_SYNC_INTERVAL_MS) {
logger.debug({ lastSync }, 'Skipping group sync - synced recently');
return;
}
}
}
try {
logger.info('Syncing group metadata from WhatsApp...');
const groups = await this.sock.groupFetchAllParticipating();
let count = 0;
for (const [jid, metadata] of Object.entries(groups)) {
if (metadata.subject) {
updateChatName(jid, metadata.subject);
count++;
}
}
setLastGroupSync();
logger.info({ count }, 'Group metadata synced');
} catch (err) {
logger.error({ err }, 'Failed to sync group metadata');
}
}
private async translateJid(jid: string): Promise<string> {
if (!jid.endsWith('@lid')) return jid;
const lidUser = jid.split('@')[0].split(':')[0];
// Check local cache first
const cached = this.lidToPhoneMap[lidUser];
if (cached) {
logger.debug({ lidJid: jid, phoneJid: cached }, 'Translated LID to phone JID (cached)');
return cached;
}
// Query Baileys' signal repository for the mapping
try {
const pn = await this.sock.signalRepository?.lidMapping?.getPNForLID(jid);
if (pn) {
const phoneJid = `${pn.split('@')[0].split(':')[0]}@s.whatsapp.net`;
this.lidToPhoneMap[lidUser] = phoneJid;
logger.info({ lidJid: jid, phoneJid }, 'Translated LID to phone JID (signalRepository)');
return phoneJid;
}
} catch (err) {
logger.debug({ err, jid }, 'Failed to resolve LID via signalRepository');
}
return jid;
}
private async flushOutgoingQueue(): Promise<void> {
if (this.flushing || this.outgoingQueue.length === 0) return;
this.flushing = true;
try {
logger.info({ count: this.outgoingQueue.length }, 'Flushing outgoing message queue');
while (this.outgoingQueue.length > 0) {
const item = this.outgoingQueue.shift()!;
// Send directly — queued items are already prefixed by sendMessage
await this.sock.sendMessage(item.jid, { text: item.text });
logger.info({ jid: item.jid, length: item.text.length }, 'Queued message sent');
}
} finally {
this.flushing = false;
}
}
}
@@ -1,27 +0,0 @@
# Intent: src/channels/whatsapp.ts modifications
## What changed
Added voice message transcription support. When a WhatsApp voice note (PTT audio) arrives, it is downloaded and transcribed via OpenAI Whisper before being stored as message content.
## Key sections
### Imports (top of file)
- Added: `isVoiceMessage`, `transcribeAudioMessage` from `../transcription.js`
### messages.upsert handler (inside connectInternal)
- Added: `let finalContent = content` variable to allow voice transcription to override text content
- Added: `isVoiceMessage(msg)` check after content extraction
- Added: try/catch block calling `transcribeAudioMessage(msg, this.sock)`
- Success: `finalContent = '[Voice: <transcript>]'`
- Null result: `finalContent = '[Voice Message - transcription unavailable]'`
- Error: `finalContent = '[Voice Message - transcription failed]'`
- Changed: `this.opts.onMessage()` call uses `finalContent` instead of `content`
## Invariants (must-keep)
- All existing message handling (conversation, extendedTextMessage, imageMessage, videoMessage) unchanged
- Connection lifecycle (connect, reconnect, disconnect) unchanged
- LID translation logic unchanged
- Outgoing message queue unchanged
- Group metadata sync unchanged
- sendMessage prefix logic unchanged
- setTyping, ownsJid, isConnected — all unchanged
@@ -1,123 +0,0 @@
import { describe, expect, it } from 'vitest';
import fs from 'fs';
import path from 'path';
describe('voice-transcription skill package', () => {
const skillDir = path.resolve(__dirname, '..');
it('has a valid manifest', () => {
const manifestPath = path.join(skillDir, 'manifest.yaml');
expect(fs.existsSync(manifestPath)).toBe(true);
const content = fs.readFileSync(manifestPath, 'utf-8');
expect(content).toContain('skill: voice-transcription');
expect(content).toContain('version: 1.0.0');
expect(content).toContain('openai');
expect(content).toContain('OPENAI_API_KEY');
});
it('has all files declared in adds', () => {
const transcriptionFile = path.join(skillDir, 'add', 'src', 'transcription.ts');
expect(fs.existsSync(transcriptionFile)).toBe(true);
const content = fs.readFileSync(transcriptionFile, 'utf-8');
expect(content).toContain('transcribeAudioMessage');
expect(content).toContain('isVoiceMessage');
expect(content).toContain('transcribeWithOpenAI');
expect(content).toContain('downloadMediaMessage');
expect(content).toContain('readEnvFile');
});
it('has all files declared in modifies', () => {
const whatsappFile = path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.ts');
const whatsappTestFile = path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.test.ts');
expect(fs.existsSync(whatsappFile)).toBe(true);
expect(fs.existsSync(whatsappTestFile)).toBe(true);
});
it('has intent files for modified files', () => {
expect(fs.existsSync(path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.ts.intent.md'))).toBe(true);
expect(fs.existsSync(path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.test.ts.intent.md'))).toBe(true);
});
it('modified whatsapp.ts preserves core structure', () => {
const content = fs.readFileSync(
path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.ts'),
'utf-8',
);
// Core class and methods preserved
expect(content).toContain('class WhatsAppChannel');
expect(content).toContain('implements Channel');
expect(content).toContain('async connect()');
expect(content).toContain('async sendMessage(');
expect(content).toContain('isConnected()');
expect(content).toContain('ownsJid(');
expect(content).toContain('async disconnect()');
expect(content).toContain('async setTyping(');
expect(content).toContain('async syncGroupMetadata(');
expect(content).toContain('private async translateJid(');
expect(content).toContain('private async flushOutgoingQueue(');
// Core imports preserved
expect(content).toContain('ASSISTANT_HAS_OWN_NUMBER');
expect(content).toContain('ASSISTANT_NAME');
expect(content).toContain('STORE_DIR');
});
it('modified whatsapp.ts includes transcription integration', () => {
const content = fs.readFileSync(
path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.ts'),
'utf-8',
);
// Transcription imports
expect(content).toContain("import { isVoiceMessage, transcribeAudioMessage } from '../transcription.js'");
// Voice message handling
expect(content).toContain('isVoiceMessage(msg)');
expect(content).toContain('transcribeAudioMessage(msg, this.sock)');
expect(content).toContain('finalContent');
expect(content).toContain('[Voice:');
expect(content).toContain('[Voice Message - transcription unavailable]');
expect(content).toContain('[Voice Message - transcription failed]');
});
it('modified whatsapp.test.ts includes transcription mock and tests', () => {
const content = fs.readFileSync(
path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.test.ts'),
'utf-8',
);
// Transcription mock
expect(content).toContain("vi.mock('../transcription.js'");
expect(content).toContain('isVoiceMessage');
expect(content).toContain('transcribeAudioMessage');
// Voice transcription test cases
expect(content).toContain('transcribes voice messages');
expect(content).toContain('falls back when transcription returns null');
expect(content).toContain('falls back when transcription throws');
expect(content).toContain('[Voice: Hello this is a voice message]');
});
it('modified whatsapp.test.ts preserves all existing test sections', () => {
const content = fs.readFileSync(
path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.test.ts'),
'utf-8',
);
// All existing test describe blocks preserved
expect(content).toContain("describe('connection lifecycle'");
expect(content).toContain("describe('authentication'");
expect(content).toContain("describe('reconnection'");
expect(content).toContain("describe('message handling'");
expect(content).toContain("describe('LID to JID translation'");
expect(content).toContain("describe('outgoing message queue'");
expect(content).toContain("describe('group metadata sync'");
expect(content).toContain("describe('ownsJid'");
expect(content).toContain("describe('setTyping'");
expect(content).toContain("describe('channel properties'");
});
});
+50 -23
View File
@@ -40,41 +40,56 @@ Otherwise (macOS, desktop Linux, or WSL) → AskUserQuestion: How do you want to
If they chose pairing code:
AskUserQuestion: What is your phone number? (Include country code without +, e.g., 1234567890)
AskUserQuestion: What is your phone number? (Digits only — country code followed by your 10-digit number, no + prefix, spaces, or dashes. Example: 14155551234 where 1 is the US country code and 4155551234 is the phone number.)
## Phase 2: Verify Code
## Phase 2: Apply Code Changes
Apply the skill to install the WhatsApp channel code and dependencies:
Check if `src/channels/whatsapp.ts` already exists. If it does, skip to Phase 3 (Authentication).
### Ensure channel remote
```bash
npx tsx scripts/apply-skill.ts .claude/skills/add-whatsapp
git remote -v
```
Verify the code was placed correctly:
If `whatsapp` is missing, add it:
```bash
test -f src/channels/whatsapp.ts && echo "WhatsApp channel code present" || echo "ERROR: WhatsApp channel code missing — re-run skill apply"
git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
```
### Verify dependencies
### Merge the skill branch
```bash
node -e "require('@whiskeysockets/baileys')" 2>/dev/null && echo "Baileys installed" || echo "Installing Baileys..."
git fetch whatsapp main
git merge whatsapp/main || {
git checkout --theirs package-lock.json
git add package-lock.json
git merge --continue
}
```
If not installed:
```bash
npm install @whiskeysockets/baileys qrcode qrcode-terminal
```
### Validate build
This merges in:
- `src/channels/whatsapp.ts` (WhatsAppChannel class with self-registration via `registerChannel`)
- `src/channels/whatsapp.test.ts` (41 unit tests)
- `src/whatsapp-auth.ts` (standalone WhatsApp authentication script)
- `setup/whatsapp-auth.ts` (WhatsApp auth setup step)
- `import './whatsapp.js'` appended to the channel barrel file `src/channels/index.ts`
- `'whatsapp-auth'` step added to `setup/index.ts`
- `@whiskeysockets/baileys`, `qrcode`, `qrcode-terminal` npm dependencies in `package.json`
- `ASSISTANT_HAS_OWN_NUMBER` in `.env.example`
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
### Validate code changes
```bash
npm install
npm run build
npx vitest run src/channels/whatsapp.test.ts
```
Build must be clean before proceeding.
All tests must pass and build must be clean before proceeding.
## Phase 3: Authentication
@@ -115,21 +130,33 @@ Tell the user to run `npm run auth` in another terminal, then:
For pairing code:
Tell the user to have WhatsApp open on **Settings > Linked Devices > Link a Device**, ready to tap **"Link with phone number instead"** — the code expires in ~60 seconds and must be entered immediately.
Run the auth process in the background and poll `store/pairing-code.txt` for the code:
```bash
npx tsx setup/index.ts --step whatsapp-auth -- --method pairing-code --phone <their-phone-number>
rm -f store/pairing-code.txt && npx tsx setup/index.ts --step whatsapp-auth -- --method pairing-code --phone <their-phone-number> > /tmp/wa-auth.log 2>&1 &
```
(Bash timeout: 150000ms). Display PAIRING_CODE from output.
Then immediately poll for the code (do NOT wait for the background command to finish):
Tell the user:
```bash
for i in $(seq 1 20); do [ -f store/pairing-code.txt ] && cat store/pairing-code.txt && break; sleep 1; done
```
> A pairing code will appear. **Enter it within 60 seconds** — codes expire quickly.
Display the code to the user the moment it appears. Tell them:
> **Enter this code now** — it expires in ~60 seconds.
>
> 1. Open WhatsApp > **Settings** > **Linked Devices** > **Link a Device**
> 2. Tap **Link with phone number instead**
> 3. Enter the code immediately
>
> If the code expires, re-run the command — a new code will be generated.
After the user enters the code, poll for authentication to complete:
```bash
for i in $(seq 1 60); do grep -q 'AUTH_STATUS: authenticated' /tmp/wa-auth.log 2>/dev/null && echo "authenticated" && break; grep -q 'AUTH_STATUS: failed' /tmp/wa-auth.log 2>/dev/null && echo "failed" && break; sleep 2; done
```
**If failed:** qr_timeout → re-run. logged_out → delete `store/auth/` and re-run. 515 → re-run. timeout → ask user, offer retry.
@@ -281,7 +308,7 @@ rm -rf store/auth/ && npx tsx src/whatsapp-auth.ts --pairing-code --phone <phone
```
Enter the code **immediately** when it appears. Also ensure:
1. Phone number includes country code without `+` (e.g., `1234567890`)
1. Phone number is digits only — country code + number, no `+` prefix (e.g., `14155551234` where `1` is country code, `4155551234` is the number)
2. Phone has internet access
3. WhatsApp is updated to the latest version
@@ -1,358 +0,0 @@
/**
* Step: whatsapp-auth — WhatsApp interactive auth (QR code / pairing code).
*/
import { execSync, spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import { logger } from '../src/logger.js';
import { openBrowser, isHeadless } from './platform.js';
import { emitStatus } from './status.js';
const QR_AUTH_TEMPLATE = `<!DOCTYPE html>
<html><head><title>NanoClaw - WhatsApp Auth</title>
<meta http-equiv="refresh" content="3">
<style>
body { font-family: -apple-system, sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: white; border-radius: 16px; padding: 40px; box-shadow: 0 4px 24px rgba(0,0,0,0.1); text-align: center; max-width: 400px; }
h2 { margin: 0 0 8px; }
.timer { font-size: 18px; color: #666; margin: 12px 0; }
.timer.urgent { color: #e74c3c; font-weight: bold; }
.instructions { color: #666; font-size: 14px; margin-top: 16px; }
svg { width: 280px; height: 280px; }
</style></head><body>
<div class="card">
<h2>Scan with WhatsApp</h2>
<div class="timer" id="timer">Expires in <span id="countdown">60</span>s</div>
<div id="qr">{{QR_SVG}}</div>
<div class="instructions">Settings \\u2192 Linked Devices \\u2192 Link a Device</div>
</div>
<script>
var startKey = 'nanoclaw_qr_start';
var start = localStorage.getItem(startKey);
if (!start) { start = Date.now().toString(); localStorage.setItem(startKey, start); }
var elapsed = Math.floor((Date.now() - parseInt(start)) / 1000);
var remaining = Math.max(0, 60 - elapsed);
var countdown = document.getElementById('countdown');
var timer = document.getElementById('timer');
countdown.textContent = remaining;
if (remaining <= 10) timer.classList.add('urgent');
if (remaining <= 0) {
timer.textContent = 'QR code expired \\u2014 a new one will appear shortly';
timer.classList.add('urgent');
localStorage.removeItem(startKey);
}
</script></body></html>`;
const SUCCESS_HTML = `<!DOCTYPE html>
<html><head><title>NanoClaw - Connected!</title>
<style>
body { font-family: -apple-system, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: white; border-radius: 16px; padding: 40px; box-shadow: 0 4px 24px rgba(0,0,0,0.1); text-align: center; max-width: 400px; }
h2 { color: #27ae60; margin: 0 0 8px; }
p { color: #666; }
.check { font-size: 64px; margin-bottom: 16px; }
</style></head><body>
<div class="card">
<div class="check">&#10003;</div>
<h2>Connected to WhatsApp</h2>
<p>You can close this tab.</p>
</div>
<script>localStorage.removeItem('nanoclaw_qr_start');</script>
</body></html>`;
function parseArgs(args: string[]): { method: string; phone: string } {
let method = '';
let phone = '';
for (let i = 0; i < args.length; i++) {
if (args[i] === '--method' && args[i + 1]) {
method = args[i + 1];
i++;
}
if (args[i] === '--phone' && args[i + 1]) {
phone = args[i + 1];
i++;
}
}
return { method, phone };
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function readFileSafe(filePath: string): string {
try {
return fs.readFileSync(filePath, 'utf-8');
} catch {
return '';
}
}
function getPhoneNumber(projectRoot: string): string {
try {
const creds = JSON.parse(
fs.readFileSync(
path.join(projectRoot, 'store', 'auth', 'creds.json'),
'utf-8',
),
);
if (creds.me?.id) {
return creds.me.id.split(':')[0].split('@')[0];
}
} catch {
// Not available yet
}
return '';
}
function emitAuthStatus(
method: string,
authStatus: string,
status: string,
extra: Record<string, string> = {},
): void {
const fields: Record<string, string> = {
AUTH_METHOD: method,
AUTH_STATUS: authStatus,
...extra,
STATUS: status,
LOG: 'logs/setup.log',
};
emitStatus('AUTH_WHATSAPP', fields);
}
export async function run(args: string[]): Promise<void> {
const projectRoot = process.cwd();
const { method, phone } = parseArgs(args);
const statusFile = path.join(projectRoot, 'store', 'auth-status.txt');
const qrFile = path.join(projectRoot, 'store', 'qr-data.txt');
if (!method) {
emitAuthStatus('unknown', 'failed', 'failed', {
ERROR: 'missing_method_flag',
});
process.exit(4);
}
// qr-terminal is a manual flow
if (method === 'qr-terminal') {
emitAuthStatus('qr-terminal', 'manual', 'manual', {
PROJECT_PATH: projectRoot,
});
return;
}
if (method === 'pairing-code' && !phone) {
emitAuthStatus('pairing-code', 'failed', 'failed', {
ERROR: 'missing_phone_number',
});
process.exit(4);
}
if (!['qr-browser', 'pairing-code'].includes(method)) {
emitAuthStatus(method, 'failed', 'failed', { ERROR: 'unknown_method' });
process.exit(4);
}
// Clean stale state
logger.info({ method }, 'Starting channel authentication');
try {
fs.rmSync(path.join(projectRoot, 'store', 'auth'), {
recursive: true,
force: true,
});
} catch {
/* ok */
}
try {
fs.unlinkSync(qrFile);
} catch {
/* ok */
}
try {
fs.unlinkSync(statusFile);
} catch {
/* ok */
}
// Start auth process in background
const authArgs =
method === 'pairing-code'
? ['src/whatsapp-auth.ts', '--pairing-code', '--phone', phone]
: ['src/whatsapp-auth.ts'];
const authProc = spawn('npx', ['tsx', ...authArgs], {
cwd: projectRoot,
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
});
const logFile = path.join(projectRoot, 'logs', 'setup.log');
const logStream = fs.createWriteStream(logFile, { flags: 'a' });
authProc.stdout?.pipe(logStream);
authProc.stderr?.pipe(logStream);
// Cleanup on exit
const cleanup = () => {
try {
authProc.kill();
} catch {
/* ok */
}
};
process.on('exit', cleanup);
try {
if (method === 'qr-browser') {
await handleQrBrowser(projectRoot, statusFile, qrFile);
} else {
await handlePairingCode(projectRoot, statusFile, phone);
}
} finally {
cleanup();
process.removeListener('exit', cleanup);
}
}
async function handleQrBrowser(
projectRoot: string,
statusFile: string,
qrFile: string,
): Promise<void> {
// Poll for QR data (15s)
let qrReady = false;
for (let i = 0; i < 15; i++) {
const statusContent = readFileSafe(statusFile);
if (statusContent === 'already_authenticated') {
emitAuthStatus('qr-browser', 'already_authenticated', 'success');
return;
}
if (fs.existsSync(qrFile)) {
qrReady = true;
break;
}
await sleep(1000);
}
if (!qrReady) {
emitAuthStatus('qr-browser', 'failed', 'failed', { ERROR: 'qr_timeout' });
process.exit(3);
}
// Generate QR SVG and HTML
const qrData = fs.readFileSync(qrFile, 'utf-8');
try {
const svg = execSync(
`node -e "const QR=require('qrcode');const data='${qrData}';QR.toString(data,{type:'svg'},(e,s)=>{if(e)process.exit(1);process.stdout.write(s)})"`,
{ cwd: projectRoot, encoding: 'utf-8' },
);
const html = QR_AUTH_TEMPLATE.replace('{{QR_SVG}}', svg);
const htmlPath = path.join(projectRoot, 'store', 'qr-auth.html');
fs.writeFileSync(htmlPath, html);
// Open in browser (cross-platform)
if (!isHeadless()) {
const opened = openBrowser(htmlPath);
if (!opened) {
logger.warn(
'Could not open browser — display QR in terminal as fallback',
);
}
} else {
logger.info(
'Headless environment — QR HTML saved but browser not opened',
);
}
} catch (err) {
logger.error({ err }, 'Failed to generate QR HTML');
}
// Poll for completion (120s)
await pollAuthCompletion('qr-browser', statusFile, projectRoot);
}
async function handlePairingCode(
projectRoot: string,
statusFile: string,
phone: string,
): Promise<void> {
// Poll for pairing code (15s)
let pairingCode = '';
for (let i = 0; i < 15; i++) {
const statusContent = readFileSafe(statusFile);
if (statusContent === 'already_authenticated') {
emitAuthStatus('pairing-code', 'already_authenticated', 'success');
return;
}
if (statusContent.startsWith('pairing_code:')) {
pairingCode = statusContent.replace('pairing_code:', '');
break;
}
if (statusContent.startsWith('failed:')) {
emitAuthStatus('pairing-code', 'failed', 'failed', {
ERROR: statusContent.replace('failed:', ''),
});
process.exit(1);
}
await sleep(1000);
}
if (!pairingCode) {
emitAuthStatus('pairing-code', 'failed', 'failed', {
ERROR: 'pairing_code_timeout',
});
process.exit(3);
}
// Emit pairing code immediately so the caller can display it to the user
emitAuthStatus('pairing-code', 'pairing_code_ready', 'waiting', {
PAIRING_CODE: pairingCode,
});
// Poll for completion (120s)
await pollAuthCompletion(
'pairing-code',
statusFile,
projectRoot,
pairingCode,
);
}
async function pollAuthCompletion(
method: string,
statusFile: string,
projectRoot: string,
pairingCode?: string,
): Promise<void> {
const extra: Record<string, string> = {};
if (pairingCode) extra.PAIRING_CODE = pairingCode;
for (let i = 0; i < 60; i++) {
const content = readFileSafe(statusFile);
if (content === 'authenticated' || content === 'already_authenticated') {
// Write success page if qr-auth.html exists
const htmlPath = path.join(projectRoot, 'store', 'qr-auth.html');
if (fs.existsSync(htmlPath)) {
fs.writeFileSync(htmlPath, SUCCESS_HTML);
}
const phoneNumber = getPhoneNumber(projectRoot);
if (phoneNumber) extra.PHONE_NUMBER = phoneNumber;
emitAuthStatus(method, content, 'success', extra);
return;
}
if (content.startsWith('failed:')) {
const error = content.replace('failed:', '');
emitAuthStatus(method, 'failed', 'failed', { ERROR: error, ...extra });
process.exit(1);
}
await sleep(2000);
}
emitAuthStatus(method, 'failed', 'failed', { ERROR: 'timeout', ...extra });
process.exit(3);
}
@@ -1,950 +0,0 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { EventEmitter } from 'events';
// --- Mocks ---
// Mock config
vi.mock('../config.js', () => ({
STORE_DIR: '/tmp/nanoclaw-test-store',
ASSISTANT_NAME: 'Andy',
ASSISTANT_HAS_OWN_NUMBER: false,
}));
// Mock logger
vi.mock('../logger.js', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
// Mock db
vi.mock('../db.js', () => ({
getLastGroupSync: vi.fn(() => null),
setLastGroupSync: vi.fn(),
updateChatName: vi.fn(),
}));
// Mock fs
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs');
return {
...actual,
default: {
...actual,
existsSync: vi.fn(() => true),
mkdirSync: vi.fn(),
},
};
});
// Mock child_process (used for osascript notification)
vi.mock('child_process', () => ({
exec: vi.fn(),
}));
// Build a fake WASocket that's an EventEmitter with the methods we need
function createFakeSocket() {
const ev = new EventEmitter();
const sock = {
ev: {
on: (event: string, handler: (...args: unknown[]) => void) => {
ev.on(event, handler);
},
},
user: {
id: '1234567890:1@s.whatsapp.net',
lid: '9876543210:1@lid',
},
sendMessage: vi.fn().mockResolvedValue(undefined),
sendPresenceUpdate: vi.fn().mockResolvedValue(undefined),
groupFetchAllParticipating: vi.fn().mockResolvedValue({}),
end: vi.fn(),
// Expose the event emitter for triggering events in tests
_ev: ev,
};
return sock;
}
let fakeSocket: ReturnType<typeof createFakeSocket>;
// Mock Baileys
vi.mock('@whiskeysockets/baileys', () => {
return {
default: vi.fn(() => fakeSocket),
Browsers: { macOS: vi.fn(() => ['macOS', 'Chrome', '']) },
DisconnectReason: {
loggedOut: 401,
badSession: 500,
connectionClosed: 428,
connectionLost: 408,
connectionReplaced: 440,
timedOut: 408,
restartRequired: 515,
},
fetchLatestWaWebVersion: vi
.fn()
.mockResolvedValue({ version: [2, 3000, 0] }),
normalizeMessageContent: vi.fn((content: unknown) => content),
makeCacheableSignalKeyStore: vi.fn((keys: unknown) => keys),
useMultiFileAuthState: vi.fn().mockResolvedValue({
state: {
creds: {},
keys: {},
},
saveCreds: vi.fn(),
}),
};
});
import { WhatsAppChannel, WhatsAppChannelOpts } from './whatsapp.js';
import { getLastGroupSync, updateChatName, setLastGroupSync } from '../db.js';
// --- Test helpers ---
function createTestOpts(
overrides?: Partial<WhatsAppChannelOpts>,
): WhatsAppChannelOpts {
return {
onMessage: vi.fn(),
onChatMetadata: vi.fn(),
registeredGroups: vi.fn(() => ({
'registered@g.us': {
name: 'Test Group',
folder: 'test-group',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
},
})),
...overrides,
};
}
function triggerConnection(state: string, extra?: Record<string, unknown>) {
fakeSocket._ev.emit('connection.update', { connection: state, ...extra });
}
function triggerDisconnect(statusCode: number) {
fakeSocket._ev.emit('connection.update', {
connection: 'close',
lastDisconnect: {
error: { output: { statusCode } },
},
});
}
async function triggerMessages(messages: unknown[]) {
fakeSocket._ev.emit('messages.upsert', { messages });
// Flush microtasks so the async messages.upsert handler completes
await new Promise((r) => setTimeout(r, 0));
}
// --- Tests ---
describe('WhatsAppChannel', () => {
beforeEach(() => {
fakeSocket = createFakeSocket();
vi.mocked(getLastGroupSync).mockReturnValue(null);
});
afterEach(() => {
vi.restoreAllMocks();
});
/**
* Helper: start connect, flush microtasks so event handlers are registered,
* then trigger the connection open event. Returns the resolved promise.
*/
async function connectChannel(channel: WhatsAppChannel): Promise<void> {
const p = channel.connect();
// Flush microtasks so connectInternal completes its await and registers handlers
await new Promise((r) => setTimeout(r, 0));
triggerConnection('open');
return p;
}
// --- Version fetch ---
describe('version fetch', () => {
it('connects with fetched version', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
const { fetchLatestWaWebVersion } =
await import('@whiskeysockets/baileys');
expect(fetchLatestWaWebVersion).toHaveBeenCalledWith({});
});
it('falls back gracefully when version fetch fails', async () => {
const { fetchLatestWaWebVersion } =
await import('@whiskeysockets/baileys');
vi.mocked(fetchLatestWaWebVersion).mockRejectedValueOnce(
new Error('network error'),
);
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
// Should still connect successfully despite fetch failure
expect(channel.isConnected()).toBe(true);
});
});
// --- Connection lifecycle ---
describe('connection lifecycle', () => {
it('resolves connect() when connection opens', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
expect(channel.isConnected()).toBe(true);
});
it('sets up LID to phone mapping on open', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
// The channel should have mapped the LID from sock.user
// We can verify by sending a message from a LID JID
// and checking the translated JID in the callback
});
it('flushes outgoing queue on reconnect', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
// Disconnect
(channel as any).connected = false;
// Queue a message while disconnected
await channel.sendMessage('test@g.us', 'Queued message');
expect(fakeSocket.sendMessage).not.toHaveBeenCalled();
// Reconnect
(channel as any).connected = true;
await (channel as any).flushOutgoingQueue();
// Group messages get prefixed when flushed
expect(fakeSocket.sendMessage).toHaveBeenCalledWith('test@g.us', {
text: 'Andy: Queued message',
});
});
it('disconnects cleanly', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await channel.disconnect();
expect(channel.isConnected()).toBe(false);
expect(fakeSocket.end).toHaveBeenCalled();
});
});
// --- QR code and auth ---
describe('authentication', () => {
it('exits process when QR code is emitted (no auth state)', async () => {
vi.useFakeTimers();
const mockExit = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
// Start connect but don't await (it won't resolve - process exits)
channel.connect().catch(() => {});
// Flush microtasks so connectInternal registers handlers
await vi.advanceTimersByTimeAsync(0);
// Emit QR code event
fakeSocket._ev.emit('connection.update', { qr: 'some-qr-data' });
// Advance timer past the 1000ms setTimeout before exit
await vi.advanceTimersByTimeAsync(1500);
expect(mockExit).toHaveBeenCalledWith(1);
mockExit.mockRestore();
vi.useRealTimers();
});
});
// --- Reconnection behavior ---
describe('reconnection', () => {
it('reconnects on non-loggedOut disconnect', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
expect(channel.isConnected()).toBe(true);
// Disconnect with a non-loggedOut reason (e.g., connectionClosed = 428)
triggerDisconnect(428);
expect(channel.isConnected()).toBe(false);
// The channel should attempt to reconnect (calls connectInternal again)
});
it('exits on loggedOut disconnect', async () => {
const mockExit = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
// Disconnect with loggedOut reason (401)
triggerDisconnect(401);
expect(channel.isConnected()).toBe(false);
expect(mockExit).toHaveBeenCalledWith(0);
mockExit.mockRestore();
});
it('retries reconnection after 5s on failure', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
// Disconnect with stream error 515
triggerDisconnect(515);
// The channel sets a 5s retry — just verify it doesn't crash
await new Promise((r) => setTimeout(r, 100));
});
});
// --- Message handling ---
describe('message handling', () => {
it('delivers message for registered group', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await triggerMessages([
{
key: {
id: 'msg-1',
remoteJid: 'registered@g.us',
participant: '5551234@s.whatsapp.net',
fromMe: false,
},
message: { conversation: 'Hello Andy' },
pushName: 'Alice',
messageTimestamp: Math.floor(Date.now() / 1000),
},
]);
expect(opts.onChatMetadata).toHaveBeenCalledWith(
'registered@g.us',
expect.any(String),
undefined,
'whatsapp',
true,
);
expect(opts.onMessage).toHaveBeenCalledWith(
'registered@g.us',
expect.objectContaining({
id: 'msg-1',
content: 'Hello Andy',
sender_name: 'Alice',
is_from_me: false,
}),
);
});
it('only emits metadata for unregistered groups', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await triggerMessages([
{
key: {
id: 'msg-2',
remoteJid: 'unregistered@g.us',
participant: '5551234@s.whatsapp.net',
fromMe: false,
},
message: { conversation: 'Hello' },
pushName: 'Bob',
messageTimestamp: Math.floor(Date.now() / 1000),
},
]);
expect(opts.onChatMetadata).toHaveBeenCalledWith(
'unregistered@g.us',
expect.any(String),
undefined,
'whatsapp',
true,
);
expect(opts.onMessage).not.toHaveBeenCalled();
});
it('ignores status@broadcast messages', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await triggerMessages([
{
key: {
id: 'msg-3',
remoteJid: 'status@broadcast',
fromMe: false,
},
message: { conversation: 'Status update' },
messageTimestamp: Math.floor(Date.now() / 1000),
},
]);
expect(opts.onChatMetadata).not.toHaveBeenCalled();
expect(opts.onMessage).not.toHaveBeenCalled();
});
it('ignores messages with no content', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await triggerMessages([
{
key: {
id: 'msg-4',
remoteJid: 'registered@g.us',
fromMe: false,
},
message: null,
messageTimestamp: Math.floor(Date.now() / 1000),
},
]);
expect(opts.onMessage).not.toHaveBeenCalled();
});
it('extracts text from extendedTextMessage', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await triggerMessages([
{
key: {
id: 'msg-5',
remoteJid: 'registered@g.us',
participant: '5551234@s.whatsapp.net',
fromMe: false,
},
message: {
extendedTextMessage: { text: 'A reply message' },
},
pushName: 'Charlie',
messageTimestamp: Math.floor(Date.now() / 1000),
},
]);
expect(opts.onMessage).toHaveBeenCalledWith(
'registered@g.us',
expect.objectContaining({ content: 'A reply message' }),
);
});
it('extracts caption from imageMessage', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await triggerMessages([
{
key: {
id: 'msg-6',
remoteJid: 'registered@g.us',
participant: '5551234@s.whatsapp.net',
fromMe: false,
},
message: {
imageMessage: {
caption: 'Check this photo',
mimetype: 'image/jpeg',
},
},
pushName: 'Diana',
messageTimestamp: Math.floor(Date.now() / 1000),
},
]);
expect(opts.onMessage).toHaveBeenCalledWith(
'registered@g.us',
expect.objectContaining({ content: 'Check this photo' }),
);
});
it('extracts caption from videoMessage', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await triggerMessages([
{
key: {
id: 'msg-7',
remoteJid: 'registered@g.us',
participant: '5551234@s.whatsapp.net',
fromMe: false,
},
message: {
videoMessage: { caption: 'Watch this', mimetype: 'video/mp4' },
},
pushName: 'Eve',
messageTimestamp: Math.floor(Date.now() / 1000),
},
]);
expect(opts.onMessage).toHaveBeenCalledWith(
'registered@g.us',
expect.objectContaining({ content: 'Watch this' }),
);
});
it('handles message with no extractable text (e.g. voice note without caption)', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await triggerMessages([
{
key: {
id: 'msg-8',
remoteJid: 'registered@g.us',
participant: '5551234@s.whatsapp.net',
fromMe: false,
},
message: {
audioMessage: { mimetype: 'audio/ogg; codecs=opus', ptt: true },
},
pushName: 'Frank',
messageTimestamp: Math.floor(Date.now() / 1000),
},
]);
// Skipped — no text content to process
expect(opts.onMessage).not.toHaveBeenCalled();
});
it('uses sender JID when pushName is absent', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await triggerMessages([
{
key: {
id: 'msg-9',
remoteJid: 'registered@g.us',
participant: '5551234@s.whatsapp.net',
fromMe: false,
},
message: { conversation: 'No push name' },
// pushName is undefined
messageTimestamp: Math.floor(Date.now() / 1000),
},
]);
expect(opts.onMessage).toHaveBeenCalledWith(
'registered@g.us',
expect.objectContaining({ sender_name: '5551234' }),
);
});
});
// --- LID ↔ JID translation ---
describe('LID to JID translation', () => {
it('translates known LID to phone JID', async () => {
const opts = createTestOpts({
registeredGroups: vi.fn(() => ({
'1234567890@s.whatsapp.net': {
name: 'Self Chat',
folder: 'self-chat',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
},
})),
});
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
// The socket has lid '9876543210:1@lid' → phone '1234567890@s.whatsapp.net'
// Send a message from the LID
await triggerMessages([
{
key: {
id: 'msg-lid',
remoteJid: '9876543210@lid',
fromMe: false,
},
message: { conversation: 'From LID' },
pushName: 'Self',
messageTimestamp: Math.floor(Date.now() / 1000),
},
]);
// Should be translated to phone JID
expect(opts.onChatMetadata).toHaveBeenCalledWith(
'1234567890@s.whatsapp.net',
expect.any(String),
undefined,
'whatsapp',
false,
);
});
it('passes through non-LID JIDs unchanged', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await triggerMessages([
{
key: {
id: 'msg-normal',
remoteJid: 'registered@g.us',
participant: '5551234@s.whatsapp.net',
fromMe: false,
},
message: { conversation: 'Normal JID' },
pushName: 'Grace',
messageTimestamp: Math.floor(Date.now() / 1000),
},
]);
expect(opts.onChatMetadata).toHaveBeenCalledWith(
'registered@g.us',
expect.any(String),
undefined,
'whatsapp',
true,
);
});
it('passes through unknown LID JIDs unchanged', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await triggerMessages([
{
key: {
id: 'msg-unknown-lid',
remoteJid: '0000000000@lid',
fromMe: false,
},
message: { conversation: 'Unknown LID' },
pushName: 'Unknown',
messageTimestamp: Math.floor(Date.now() / 1000),
},
]);
// Unknown LID passes through unchanged
expect(opts.onChatMetadata).toHaveBeenCalledWith(
'0000000000@lid',
expect.any(String),
undefined,
'whatsapp',
false,
);
});
});
// --- Outgoing message queue ---
describe('outgoing message queue', () => {
it('sends message directly when connected', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await channel.sendMessage('test@g.us', 'Hello');
// Group messages get prefixed with assistant name
expect(fakeSocket.sendMessage).toHaveBeenCalledWith('test@g.us', {
text: 'Andy: Hello',
});
});
it('prefixes direct chat messages on shared number', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await channel.sendMessage('123@s.whatsapp.net', 'Hello');
// Shared number: DMs also get prefixed (needed for self-chat distinction)
expect(fakeSocket.sendMessage).toHaveBeenCalledWith(
'123@s.whatsapp.net',
{ text: 'Andy: Hello' },
);
});
it('queues message when disconnected', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
// Don't connect — channel starts disconnected
await channel.sendMessage('test@g.us', 'Queued');
expect(fakeSocket.sendMessage).not.toHaveBeenCalled();
});
it('queues message on send failure', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
// Make sendMessage fail
fakeSocket.sendMessage.mockRejectedValueOnce(new Error('Network error'));
await channel.sendMessage('test@g.us', 'Will fail');
// Should not throw, message queued for retry
// The queue should have the message
});
it('flushes multiple queued messages in order', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
// Queue messages while disconnected
await channel.sendMessage('test@g.us', 'First');
await channel.sendMessage('test@g.us', 'Second');
await channel.sendMessage('test@g.us', 'Third');
// Connect — flush happens automatically on open
await connectChannel(channel);
// Give the async flush time to complete
await new Promise((r) => setTimeout(r, 50));
expect(fakeSocket.sendMessage).toHaveBeenCalledTimes(3);
// Group messages get prefixed
expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(1, 'test@g.us', {
text: 'Andy: First',
});
expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(2, 'test@g.us', {
text: 'Andy: Second',
});
expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(3, 'test@g.us', {
text: 'Andy: Third',
});
});
});
// --- Group metadata sync ---
describe('group metadata sync', () => {
it('syncs group metadata on first connection', async () => {
fakeSocket.groupFetchAllParticipating.mockResolvedValue({
'group1@g.us': { subject: 'Group One' },
'group2@g.us': { subject: 'Group Two' },
});
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
// Wait for async sync to complete
await new Promise((r) => setTimeout(r, 50));
expect(fakeSocket.groupFetchAllParticipating).toHaveBeenCalled();
expect(updateChatName).toHaveBeenCalledWith('group1@g.us', 'Group One');
expect(updateChatName).toHaveBeenCalledWith('group2@g.us', 'Group Two');
expect(setLastGroupSync).toHaveBeenCalled();
});
it('skips sync when synced recently', async () => {
// Last sync was 1 hour ago (within 24h threshold)
vi.mocked(getLastGroupSync).mockReturnValue(
new Date(Date.now() - 60 * 60 * 1000).toISOString(),
);
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await new Promise((r) => setTimeout(r, 50));
expect(fakeSocket.groupFetchAllParticipating).not.toHaveBeenCalled();
});
it('forces sync regardless of cache', async () => {
vi.mocked(getLastGroupSync).mockReturnValue(
new Date(Date.now() - 60 * 60 * 1000).toISOString(),
);
fakeSocket.groupFetchAllParticipating.mockResolvedValue({
'group@g.us': { subject: 'Forced Group' },
});
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await channel.syncGroupMetadata(true);
expect(fakeSocket.groupFetchAllParticipating).toHaveBeenCalled();
expect(updateChatName).toHaveBeenCalledWith('group@g.us', 'Forced Group');
});
it('handles group sync failure gracefully', async () => {
fakeSocket.groupFetchAllParticipating.mockRejectedValue(
new Error('Network timeout'),
);
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
// Should not throw
await expect(channel.syncGroupMetadata(true)).resolves.toBeUndefined();
});
it('skips groups with no subject', async () => {
fakeSocket.groupFetchAllParticipating.mockResolvedValue({
'group1@g.us': { subject: 'Has Subject' },
'group2@g.us': { subject: '' },
'group3@g.us': {},
});
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
// Clear any calls from the automatic sync on connect
vi.mocked(updateChatName).mockClear();
await channel.syncGroupMetadata(true);
expect(updateChatName).toHaveBeenCalledTimes(1);
expect(updateChatName).toHaveBeenCalledWith('group1@g.us', 'Has Subject');
});
});
// --- JID ownership ---
describe('ownsJid', () => {
it('owns @g.us JIDs (WhatsApp groups)', () => {
const channel = new WhatsAppChannel(createTestOpts());
expect(channel.ownsJid('12345@g.us')).toBe(true);
});
it('owns @s.whatsapp.net JIDs (WhatsApp DMs)', () => {
const channel = new WhatsAppChannel(createTestOpts());
expect(channel.ownsJid('12345@s.whatsapp.net')).toBe(true);
});
it('does not own Telegram JIDs', () => {
const channel = new WhatsAppChannel(createTestOpts());
expect(channel.ownsJid('tg:12345')).toBe(false);
});
it('does not own unknown JID formats', () => {
const channel = new WhatsAppChannel(createTestOpts());
expect(channel.ownsJid('random-string')).toBe(false);
});
});
// --- Typing indicator ---
describe('setTyping', () => {
it('sends composing presence when typing', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await channel.setTyping('test@g.us', true);
expect(fakeSocket.sendPresenceUpdate).toHaveBeenCalledWith(
'composing',
'test@g.us',
);
});
it('sends paused presence when stopping', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
await channel.setTyping('test@g.us', false);
expect(fakeSocket.sendPresenceUpdate).toHaveBeenCalledWith(
'paused',
'test@g.us',
);
});
it('handles typing indicator failure gracefully', async () => {
const opts = createTestOpts();
const channel = new WhatsAppChannel(opts);
await connectChannel(channel);
fakeSocket.sendPresenceUpdate.mockRejectedValueOnce(new Error('Failed'));
// Should not throw
await expect(
channel.setTyping('test@g.us', true),
).resolves.toBeUndefined();
});
});
// --- Channel properties ---
describe('channel properties', () => {
it('has name "whatsapp"', () => {
const channel = new WhatsAppChannel(createTestOpts());
expect(channel.name).toBe('whatsapp');
});
it('does not expose prefixAssistantName (prefix handled internally)', () => {
const channel = new WhatsAppChannel(createTestOpts());
expect('prefixAssistantName' in channel).toBe(false);
});
});
});
@@ -1,391 +0,0 @@
import { exec } from 'child_process';
import fs from 'fs';
import path from 'path';
import makeWASocket, {
Browsers,
DisconnectReason,
WASocket,
fetchLatestWaWebVersion,
makeCacheableSignalKeyStore,
normalizeMessageContent,
useMultiFileAuthState,
} from '@whiskeysockets/baileys';
import {
ASSISTANT_HAS_OWN_NUMBER,
ASSISTANT_NAME,
STORE_DIR,
} from '../config.js';
import { getLastGroupSync, setLastGroupSync, updateChatName } from '../db.js';
import { logger } from '../logger.js';
import {
Channel,
OnInboundMessage,
OnChatMetadata,
RegisteredGroup,
} from '../types.js';
import { registerChannel, ChannelOpts } from './registry.js';
const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
export interface WhatsAppChannelOpts {
onMessage: OnInboundMessage;
onChatMetadata: OnChatMetadata;
registeredGroups: () => Record<string, RegisteredGroup>;
}
export class WhatsAppChannel implements Channel {
name = 'whatsapp';
private sock!: WASocket;
private connected = false;
private lidToPhoneMap: Record<string, string> = {};
private outgoingQueue: Array<{ jid: string; text: string }> = [];
private flushing = false;
private groupSyncTimerStarted = false;
private opts: WhatsAppChannelOpts;
constructor(opts: WhatsAppChannelOpts) {
this.opts = opts;
}
async connect(): Promise<void> {
return new Promise<void>((resolve, reject) => {
this.connectInternal(resolve).catch(reject);
});
}
private async connectInternal(onFirstOpen?: () => void): Promise<void> {
const authDir = path.join(STORE_DIR, 'auth');
fs.mkdirSync(authDir, { recursive: true });
const { state, saveCreds } = await useMultiFileAuthState(authDir);
const { version } = await fetchLatestWaWebVersion({}).catch((err) => {
logger.warn(
{ err },
'Failed to fetch latest WA Web version, using default',
);
return { version: undefined };
});
this.sock = makeWASocket({
version,
auth: {
creds: state.creds,
keys: makeCacheableSignalKeyStore(state.keys, logger),
},
printQRInTerminal: false,
logger,
browser: Browsers.macOS('Chrome'),
});
this.sock.ev.on('connection.update', (update) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
const msg =
'WhatsApp authentication required. Run /setup in Claude Code.';
logger.error(msg);
exec(
`osascript -e 'display notification "${msg}" with title "NanoClaw" sound name "Basso"'`,
);
setTimeout(() => process.exit(1), 1000);
}
if (connection === 'close') {
this.connected = false;
const reason = (
lastDisconnect?.error as { output?: { statusCode?: number } }
)?.output?.statusCode;
const shouldReconnect = reason !== DisconnectReason.loggedOut;
logger.info(
{
reason,
shouldReconnect,
queuedMessages: this.outgoingQueue.length,
},
'Connection closed',
);
if (shouldReconnect) {
logger.info('Reconnecting...');
this.connectInternal().catch((err) => {
logger.error({ err }, 'Failed to reconnect, retrying in 5s');
setTimeout(() => {
this.connectInternal().catch((err2) => {
logger.error({ err: err2 }, 'Reconnection retry failed');
});
}, 5000);
});
} else {
logger.info('Logged out. Run /setup to re-authenticate.');
process.exit(0);
}
} else if (connection === 'open') {
this.connected = true;
logger.info('Connected to WhatsApp');
// Announce availability so WhatsApp relays subsequent presence updates (typing indicators)
this.sock.sendPresenceUpdate('available').catch((err) => {
logger.warn({ err }, 'Failed to send presence update');
});
// Build LID to phone mapping from auth state for self-chat translation
if (this.sock.user) {
const phoneUser = this.sock.user.id.split(':')[0];
const lidUser = this.sock.user.lid?.split(':')[0];
if (lidUser && phoneUser) {
this.lidToPhoneMap[lidUser] = `${phoneUser}@s.whatsapp.net`;
logger.debug({ lidUser, phoneUser }, 'LID to phone mapping set');
}
}
// Flush any messages queued while disconnected
this.flushOutgoingQueue().catch((err) =>
logger.error({ err }, 'Failed to flush outgoing queue'),
);
// Sync group metadata on startup (respects 24h cache)
this.syncGroupMetadata().catch((err) =>
logger.error({ err }, 'Initial group sync failed'),
);
// Set up daily sync timer (only once)
if (!this.groupSyncTimerStarted) {
this.groupSyncTimerStarted = true;
setInterval(() => {
this.syncGroupMetadata().catch((err) =>
logger.error({ err }, 'Periodic group sync failed'),
);
}, GROUP_SYNC_INTERVAL_MS);
}
// Signal first connection to caller
if (onFirstOpen) {
onFirstOpen();
onFirstOpen = undefined;
}
}
});
this.sock.ev.on('creds.update', saveCreds);
this.sock.ev.on('messages.upsert', async ({ messages }) => {
for (const msg of messages) {
if (!msg.message) continue;
// Unwrap container types (viewOnceMessageV2, ephemeralMessage,
// editedMessage, etc.) so that conversation, extendedTextMessage,
// imageMessage, etc. are accessible at the top level.
const normalized = normalizeMessageContent(msg.message);
if (!normalized) continue;
const rawJid = msg.key.remoteJid;
if (!rawJid || rawJid === 'status@broadcast') continue;
// Translate LID JID to phone JID if applicable
const chatJid = await this.translateJid(rawJid);
const timestamp = new Date(
Number(msg.messageTimestamp) * 1000,
).toISOString();
// Always notify about chat metadata for group discovery
const isGroup = chatJid.endsWith('@g.us');
this.opts.onChatMetadata(
chatJid,
timestamp,
undefined,
'whatsapp',
isGroup,
);
// Only deliver full message for registered groups
const groups = this.opts.registeredGroups();
if (groups[chatJid]) {
const content =
normalized.conversation ||
normalized.extendedTextMessage?.text ||
normalized.imageMessage?.caption ||
normalized.videoMessage?.caption ||
'';
// Skip protocol messages with no text content (encryption keys, read receipts, etc.)
if (!content) continue;
const sender = msg.key.participant || msg.key.remoteJid || '';
const senderName = msg.pushName || sender.split('@')[0];
const fromMe = msg.key.fromMe || false;
// Detect bot messages: with own number, fromMe is reliable
// since only the bot sends from that number.
// With shared number, bot messages carry the assistant name prefix
// (even in DMs/self-chat) so we check for that.
const isBotMessage = ASSISTANT_HAS_OWN_NUMBER
? fromMe
: content.startsWith(`${ASSISTANT_NAME}:`);
this.opts.onMessage(chatJid, {
id: msg.key.id || '',
chat_jid: chatJid,
sender,
sender_name: senderName,
content,
timestamp,
is_from_me: fromMe,
is_bot_message: isBotMessage,
});
}
}
});
}
async sendMessage(jid: string, text: string): Promise<void> {
// Prefix bot messages with assistant name so users know who's speaking.
// On a shared number, prefix is also needed in DMs (including self-chat)
// to distinguish bot output from user messages.
// Skip only when the assistant has its own dedicated phone number.
const prefixed = ASSISTANT_HAS_OWN_NUMBER
? text
: `${ASSISTANT_NAME}: ${text}`;
if (!this.connected) {
this.outgoingQueue.push({ jid, text: prefixed });
logger.info(
{ jid, length: prefixed.length, queueSize: this.outgoingQueue.length },
'WA disconnected, message queued',
);
return;
}
try {
await this.sock.sendMessage(jid, { text: prefixed });
logger.info({ jid, length: prefixed.length }, 'Message sent');
} catch (err) {
// If send fails, queue it for retry on reconnect
this.outgoingQueue.push({ jid, text: prefixed });
logger.warn(
{ jid, err, queueSize: this.outgoingQueue.length },
'Failed to send, message queued',
);
}
}
isConnected(): boolean {
return this.connected;
}
ownsJid(jid: string): boolean {
return jid.endsWith('@g.us') || jid.endsWith('@s.whatsapp.net');
}
async disconnect(): Promise<void> {
this.connected = false;
this.sock?.end(undefined);
}
async setTyping(jid: string, isTyping: boolean): Promise<void> {
try {
const status = isTyping ? 'composing' : 'paused';
logger.debug({ jid, status }, 'Sending presence update');
await this.sock.sendPresenceUpdate(status, jid);
} catch (err) {
logger.debug({ jid, err }, 'Failed to update typing status');
}
}
async syncGroups(force: boolean): Promise<void> {
return this.syncGroupMetadata(force);
}
/**
* Sync group metadata from WhatsApp.
* Fetches all participating groups and stores their names in the database.
* Called on startup, daily, and on-demand via IPC.
*/
async syncGroupMetadata(force = false): Promise<void> {
if (!force) {
const lastSync = getLastGroupSync();
if (lastSync) {
const lastSyncTime = new Date(lastSync).getTime();
if (Date.now() - lastSyncTime < GROUP_SYNC_INTERVAL_MS) {
logger.debug({ lastSync }, 'Skipping group sync - synced recently');
return;
}
}
}
try {
logger.info('Syncing group metadata from WhatsApp...');
const groups = await this.sock.groupFetchAllParticipating();
let count = 0;
for (const [jid, metadata] of Object.entries(groups)) {
if (metadata.subject) {
updateChatName(jid, metadata.subject);
count++;
}
}
setLastGroupSync();
logger.info({ count }, 'Group metadata synced');
} catch (err) {
logger.error({ err }, 'Failed to sync group metadata');
}
}
private async translateJid(jid: string): Promise<string> {
if (!jid.endsWith('@lid')) return jid;
const lidUser = jid.split('@')[0].split(':')[0];
// Check local cache first
const cached = this.lidToPhoneMap[lidUser];
if (cached) {
logger.debug(
{ lidJid: jid, phoneJid: cached },
'Translated LID to phone JID (cached)',
);
return cached;
}
// Query Baileys' signal repository for the mapping
try {
const pn = await this.sock.signalRepository?.lidMapping?.getPNForLID(jid);
if (pn) {
const phoneJid = `${pn.split('@')[0].split(':')[0]}@s.whatsapp.net`;
this.lidToPhoneMap[lidUser] = phoneJid;
logger.info(
{ lidJid: jid, phoneJid },
'Translated LID to phone JID (signalRepository)',
);
return phoneJid;
}
} catch (err) {
logger.debug({ err, jid }, 'Failed to resolve LID via signalRepository');
}
return jid;
}
private async flushOutgoingQueue(): Promise<void> {
if (this.flushing || this.outgoingQueue.length === 0) return;
this.flushing = true;
try {
logger.info(
{ count: this.outgoingQueue.length },
'Flushing outgoing message queue',
);
while (this.outgoingQueue.length > 0) {
const item = this.outgoingQueue.shift()!;
// Send directly — queued items are already prefixed by sendMessage
await this.sock.sendMessage(item.jid, { text: item.text });
logger.info(
{ jid: item.jid, length: item.text.length },
'Queued message sent',
);
}
} finally {
this.flushing = false;
}
}
}
registerChannel('whatsapp', (opts: ChannelOpts) => new WhatsAppChannel(opts));
@@ -1,180 +0,0 @@
/**
* WhatsApp Authentication Script
*
* Run this during setup to authenticate with WhatsApp.
* Displays QR code, waits for scan, saves credentials, then exits.
*
* Usage: npx tsx src/whatsapp-auth.ts
*/
import fs from 'fs';
import path from 'path';
import pino from 'pino';
import qrcode from 'qrcode-terminal';
import readline from 'readline';
import makeWASocket, {
Browsers,
DisconnectReason,
fetchLatestWaWebVersion,
makeCacheableSignalKeyStore,
useMultiFileAuthState,
} from '@whiskeysockets/baileys';
const AUTH_DIR = './store/auth';
const QR_FILE = './store/qr-data.txt';
const STATUS_FILE = './store/auth-status.txt';
const logger = pino({
level: 'warn', // Quiet logging - only show errors
});
// Check for --pairing-code flag and phone number
const usePairingCode = process.argv.includes('--pairing-code');
const phoneArg = process.argv.find((_, i, arr) => arr[i - 1] === '--phone');
function askQuestion(prompt: string): Promise<string> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(prompt, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
async function connectSocket(
phoneNumber?: string,
isReconnect = false,
): Promise<void> {
const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
if (state.creds.registered && !isReconnect) {
fs.writeFileSync(STATUS_FILE, 'already_authenticated');
console.log('✓ Already authenticated with WhatsApp');
console.log(
' To re-authenticate, delete the store/auth folder and run again.',
);
process.exit(0);
}
const { version } = await fetchLatestWaWebVersion({}).catch((err) => {
logger.warn(
{ err },
'Failed to fetch latest WA Web version, using default',
);
return { version: undefined };
});
const sock = makeWASocket({
version,
auth: {
creds: state.creds,
keys: makeCacheableSignalKeyStore(state.keys, logger),
},
printQRInTerminal: false,
logger,
browser: Browsers.macOS('Chrome'),
});
if (usePairingCode && phoneNumber && !state.creds.me) {
// Request pairing code after a short delay for connection to initialize
// Only on first connect (not reconnect after 515)
setTimeout(async () => {
try {
const code = await sock.requestPairingCode(phoneNumber!);
console.log(`\n🔗 Your pairing code: ${code}\n`);
console.log(' 1. Open WhatsApp on your phone');
console.log(' 2. Tap Settings → Linked Devices → Link a Device');
console.log(' 3. Tap "Link with phone number instead"');
console.log(` 4. Enter this code: ${code}\n`);
fs.writeFileSync(STATUS_FILE, `pairing_code:${code}`);
} catch (err: any) {
console.error('Failed to request pairing code:', err.message);
process.exit(1);
}
}, 3000);
}
sock.ev.on('connection.update', (update) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
// Write raw QR data to file so the setup skill can render it
fs.writeFileSync(QR_FILE, qr);
console.log('Scan this QR code with WhatsApp:\n');
console.log(' 1. Open WhatsApp on your phone');
console.log(' 2. Tap Settings → Linked Devices → Link a Device');
console.log(' 3. Point your camera at the QR code below\n');
qrcode.generate(qr, { small: true });
}
if (connection === 'close') {
const reason = (lastDisconnect?.error as any)?.output?.statusCode;
if (reason === DisconnectReason.loggedOut) {
fs.writeFileSync(STATUS_FILE, 'failed:logged_out');
console.log('\n✗ Logged out. Delete store/auth and try again.');
process.exit(1);
} else if (reason === DisconnectReason.timedOut) {
fs.writeFileSync(STATUS_FILE, 'failed:qr_timeout');
console.log('\n✗ QR code timed out. Please try again.');
process.exit(1);
} else if (reason === 515) {
// 515 = stream error, often happens after pairing succeeds but before
// registration completes. Reconnect to finish the handshake.
console.log('\n⟳ Stream error (515) after pairing — reconnecting...');
connectSocket(phoneNumber, true);
} else {
fs.writeFileSync(STATUS_FILE, `failed:${reason || 'unknown'}`);
console.log('\n✗ Connection failed. Please try again.');
process.exit(1);
}
}
if (connection === 'open') {
fs.writeFileSync(STATUS_FILE, 'authenticated');
// Clean up QR file now that we're connected
try {
fs.unlinkSync(QR_FILE);
} catch {}
console.log('\n✓ Successfully authenticated with WhatsApp!');
console.log(' Credentials saved to store/auth/');
console.log(' You can now start the NanoClaw service.\n');
// Give it a moment to save credentials, then exit
setTimeout(() => process.exit(0), 1000);
}
});
sock.ev.on('creds.update', saveCreds);
}
async function authenticate(): Promise<void> {
fs.mkdirSync(AUTH_DIR, { recursive: true });
// Clean up any stale QR/status files from previous runs
try {
fs.unlinkSync(QR_FILE);
} catch {}
try {
fs.unlinkSync(STATUS_FILE);
} catch {}
let phoneNumber = phoneArg;
if (usePairingCode && !phoneNumber) {
phoneNumber = await askQuestion(
'Enter your phone number (with country code, no + or spaces, e.g. 14155551234): ',
);
}
console.log('Starting WhatsApp authentication...\n');
await connectSocket(phoneNumber);
}
authenticate().catch((err) => {
console.error('Authentication failed:', err.message);
process.exit(1);
});
-23
View File
@@ -1,23 +0,0 @@
skill: whatsapp
version: 1.0.0
description: "WhatsApp channel via Baileys (Multi-Device Web API)"
core_version: 0.1.0
adds:
- src/channels/whatsapp.ts
- src/channels/whatsapp.test.ts
- src/whatsapp-auth.ts
- setup/whatsapp-auth.ts
modifies:
- src/channels/index.ts
- setup/index.ts
structured:
npm_dependencies:
"@whiskeysockets/baileys": "^7.0.0-rc.9"
"qrcode": "^1.5.4"
"qrcode-terminal": "^0.12.0"
"@types/qrcode-terminal": "^0.12.0"
env_additions:
- ASSISTANT_HAS_OWN_NUMBER
conflicts: []
depends: []
test: "npx vitest run src/channels/whatsapp.test.ts"
@@ -1,60 +0,0 @@
/**
* Setup CLI entry point.
* Usage: npx tsx setup/index.ts --step <name> [args...]
*/
import { logger } from '../src/logger.js';
import { emitStatus } from './status.js';
const STEPS: Record<
string,
() => Promise<{ run: (args: string[]) => Promise<void> }>
> = {
environment: () => import('./environment.js'),
channels: () => import('./channels.js'),
container: () => import('./container.js'),
'whatsapp-auth': () => import('./whatsapp-auth.js'),
groups: () => import('./groups.js'),
register: () => import('./register.js'),
mounts: () => import('./mounts.js'),
service: () => import('./service.js'),
verify: () => import('./verify.js'),
};
async function main(): Promise<void> {
const args = process.argv.slice(2);
const stepIdx = args.indexOf('--step');
if (stepIdx === -1 || !args[stepIdx + 1]) {
console.error(
`Usage: npx tsx setup/index.ts --step <${Object.keys(STEPS).join('|')}> [args...]`,
);
process.exit(1);
}
const stepName = args[stepIdx + 1];
const stepArgs = args.filter(
(a, i) => i !== stepIdx && i !== stepIdx + 1 && a !== '--',
);
const loader = STEPS[stepName];
if (!loader) {
console.error(`Unknown step: ${stepName}`);
console.error(`Available steps: ${Object.keys(STEPS).join(', ')}`);
process.exit(1);
}
try {
const mod = await loader();
await mod.run(stepArgs);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error({ err, step: stepName }, 'Setup step failed');
emitStatus(stepName.toUpperCase(), {
STATUS: 'failed',
ERROR: message,
});
process.exit(1);
}
}
main();
@@ -1 +0,0 @@
Add `'whatsapp-auth': () => import('./whatsapp-auth.js'),` to the setup STEPS map so the WhatsApp authentication step is available during setup.
@@ -1,13 +0,0 @@
// Channel self-registration barrel file.
// Each import triggers the channel module's registerChannel() call.
// discord
// gmail
// slack
// telegram
// whatsapp
import './whatsapp.js';
@@ -1,7 +0,0 @@
# Intent: Add WhatsApp channel import
Add `import './whatsapp.js';` to the channel barrel file so the WhatsApp
module self-registers with the channel registry on startup.
This is an append-only change — existing import lines for other channels
must be preserved.
@@ -1,70 +0,0 @@
import { describe, expect, it } from 'vitest';
import fs from 'fs';
import path from 'path';
describe('whatsapp skill package', () => {
const skillDir = path.resolve(__dirname, '..');
it('has a valid manifest', () => {
const manifestPath = path.join(skillDir, 'manifest.yaml');
expect(fs.existsSync(manifestPath)).toBe(true);
const content = fs.readFileSync(manifestPath, 'utf-8');
expect(content).toContain('skill: whatsapp');
expect(content).toContain('version: 1.0.0');
expect(content).toContain('@whiskeysockets/baileys');
});
it('has all files declared in adds', () => {
const channelFile = path.join(skillDir, 'add', 'src', 'channels', 'whatsapp.ts');
expect(fs.existsSync(channelFile)).toBe(true);
const content = fs.readFileSync(channelFile, 'utf-8');
expect(content).toContain('class WhatsAppChannel');
expect(content).toContain('implements Channel');
expect(content).toContain("registerChannel('whatsapp'");
// Test file for the channel
const testFile = path.join(skillDir, 'add', 'src', 'channels', 'whatsapp.test.ts');
expect(fs.existsSync(testFile)).toBe(true);
const testContent = fs.readFileSync(testFile, 'utf-8');
expect(testContent).toContain("describe('WhatsAppChannel'");
// Auth script (runtime)
const authFile = path.join(skillDir, 'add', 'src', 'whatsapp-auth.ts');
expect(fs.existsSync(authFile)).toBe(true);
// Auth setup step
const setupAuthFile = path.join(skillDir, 'add', 'setup', 'whatsapp-auth.ts');
expect(fs.existsSync(setupAuthFile)).toBe(true);
const setupAuthContent = fs.readFileSync(setupAuthFile, 'utf-8');
expect(setupAuthContent).toContain('WhatsApp interactive auth');
});
it('has all files declared in modifies', () => {
// Channel barrel file
const indexFile = path.join(skillDir, 'modify', 'src', 'channels', 'index.ts');
expect(fs.existsSync(indexFile)).toBe(true);
const indexContent = fs.readFileSync(indexFile, 'utf-8');
expect(indexContent).toContain("import './whatsapp.js'");
// Setup index (adds whatsapp-auth step)
const setupIndexFile = path.join(skillDir, 'modify', 'setup', 'index.ts');
expect(fs.existsSync(setupIndexFile)).toBe(true);
const setupIndexContent = fs.readFileSync(setupIndexFile, 'utf-8');
expect(setupIndexContent).toContain("'whatsapp-auth'");
});
it('has intent files for modified files', () => {
expect(
fs.existsSync(path.join(skillDir, 'modify', 'src', 'channels', 'index.ts.intent.md')),
).toBe(true);
expect(
fs.existsSync(path.join(skillDir, 'modify', 'setup', 'index.ts.intent.md')),
).toBe(true);
});
});
+137
View File
@@ -0,0 +1,137 @@
---
name: channel-formatting
description: Convert Claude's Markdown output to each channel's native text syntax before delivery. Adds zero-dependency formatting for WhatsApp, Telegram, and Slack (marker substitution). Also ships a Signal rich-text helper (parseSignalStyles) used by the Signal skill.
---
# Channel Formatting
This skill wires channel-aware Markdown conversion into the outbound pipeline so Claude's
responses render natively on each platform — no more literal `**asterisks**` in WhatsApp or
Telegram.
| Channel | Transformation |
|---------|---------------|
| WhatsApp | `**bold**``*bold*`, `*italic*``_italic_`, headings → bold, links → `text (url)` |
| Telegram | same as WhatsApp, but `[text](url)` links are preserved (Markdown v1 renders them natively) |
| Slack | same as WhatsApp, but links become `<url\|text>` |
| Discord | passthrough (Discord already renders Markdown) |
| Signal | passthrough for `parseTextStyles`; `parseSignalStyles` in `src/text-styles.ts` produces plain text + native `textStyle` ranges for use by the Signal skill |
Code blocks (fenced and inline) are always protected — their content is never transformed.
## Phase 1: Pre-flight
### Check if already applied
```bash
test -f src/text-styles.ts && echo "already applied" || echo "not yet applied"
```
If `already applied`, skip to Phase 3 (Verify).
## Phase 2: Apply Code Changes
### Ensure the upstream remote
```bash
git remote -v
```
If an `upstream` remote pointing to `https://github.com/qwibitai/nanoclaw.git` is missing,
add it:
```bash
git remote add upstream https://github.com/qwibitai/nanoclaw.git
```
### Merge the skill branch
```bash
git fetch upstream skill/channel-formatting
git merge upstream/skill/channel-formatting
```
If there are merge conflicts on `package-lock.json`, resolve them by accepting the incoming
version and continuing:
```bash
git checkout --theirs package-lock.json
git add package-lock.json
git merge --continue
```
For any other conflict, read the conflicted file and reconcile both sides manually.
This merge adds:
- `src/text-styles.ts``parseTextStyles(text, channel)` for marker substitution and
`parseSignalStyles(text)` for Signal native rich text
- `src/router.ts``formatOutbound` gains an optional `channel` parameter; when provided
it calls `parseTextStyles` after stripping `<internal>` tags
- `src/index.ts` — both outbound `sendMessage` paths pass `channel.name` to `formatOutbound`
- `src/formatting.test.ts` — test coverage for both functions across all channels
### Validate
```bash
npm install
npm run build
npx vitest run src/formatting.test.ts
```
All 73 tests should pass and the build should be clean before continuing.
## Phase 3: Verify
### Rebuild and restart
```bash
npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
### Spot-check formatting
Send a message through any registered WhatsApp or Telegram chat that will trigger a
response from Claude. Ask something that will produce formatted output, such as:
> Summarise the three main advantages of TypeScript using bullet points and **bold** headings.
Confirm that the response arrives with native bold (`*text*`) rather than raw double
asterisks.
### Check logs if needed
```bash
tail -f logs/nanoclaw.log
```
## Signal Skill Integration
If you have the Signal skill installed, `src/channels/signal.ts` can import
`parseSignalStyles` from the newly present `src/text-styles.ts`:
```typescript
import { parseSignalStyles, SignalTextStyle } from '../text-styles.js';
```
`parseSignalStyles` returns `{ text: string, textStyle: SignalTextStyle[] }` where
`textStyle` is an array of `{ style, start, length }` objects suitable for the
`signal-cli` JSON-RPC `textStyles` parameter (format: `"start:length:STYLE"`).
## Removal
```bash
# Remove the new file
rm src/text-styles.ts
# Revert router.ts to remove the channel param
git diff upstream/main src/router.ts # review changes
git checkout upstream/main -- src/router.ts
# Revert the index.ts sendMessage call sites to plain formatOutbound(rawText)
# (edit manually or: git checkout upstream/main -- src/index.ts)
npm run build
```
+131
View File
@@ -0,0 +1,131 @@
---
name: claw
description: Install the claw CLI tool — run NanoClaw agent containers from the command line without opening a chat app.
---
# claw — NanoClaw CLI
`claw` is a Python CLI that sends prompts directly to a NanoClaw agent container from the terminal. It reads registered groups from the NanoClaw database, picks up secrets from `.env`, and pipes a JSON payload into a container run — no chat app required.
## What it does
- Send a prompt to any registered group by name, folder, or JID
- Default target is the main group (no `-g` needed for most use)
- Resume a previous session with `-s <session-id>`
- Read prompts from stdin (`--pipe`) for scripting and piping
- List all registered groups with `--list-groups`
- Auto-detects `container` or `docker` runtime (or override with `--runtime`)
- Prints the agent's response to stdout; session ID to stderr
- Verbose mode (`-v`) shows the command, redacted payload, and exit code
## Prerequisites
- Python 3.8 or later
- NanoClaw installed with a built and tagged container image (`nanoclaw-agent:latest`)
- Either `container` (Apple Container, macOS 15+) or `docker` available in `PATH`
## Install
Run this skill from within the NanoClaw directory. The script auto-detects its location, so the symlink always points to the right place.
### 1. Copy the script
```bash
mkdir -p scripts
cp "${CLAUDE_SKILL_DIR}/scripts/claw" scripts/claw
chmod +x scripts/claw
```
### 2. Symlink into PATH
```bash
mkdir -p ~/bin
ln -sf "$(pwd)/scripts/claw" ~/bin/claw
```
Make sure `~/bin` is in `PATH`. Add this to `~/.zshrc` or `~/.bashrc` if needed:
```bash
export PATH="$HOME/bin:$PATH"
```
Then reload the shell:
```bash
source ~/.zshrc # or ~/.bashrc
```
### 3. Verify
```bash
claw --list-groups
```
You should see registered groups. If NanoClaw isn't running or the database doesn't exist yet, the list will be empty — that's fine.
## Usage Examples
```bash
# Send a prompt to the main group
claw "What's on my calendar today?"
# Send to a specific group by name (fuzzy match)
claw -g "family" "Remind everyone about dinner at 7"
# Send to a group by exact JID
claw -j "120363336345536173@g.us" "Hello"
# Resume a previous session
claw -s abc123 "Continue where we left off"
# Read prompt from stdin
echo "Summarize this" | claw --pipe -g dev
# Pipe a file
cat report.txt | claw --pipe "Summarize this report"
# List all registered groups
claw --list-groups
# Force a specific runtime
claw --runtime docker "Hello"
# Use a custom image tag (e.g. after rebuilding with a new tag)
claw --image nanoclaw-agent:dev "Hello"
# Verbose mode (debug info, secrets redacted)
claw -v "Hello"
# Custom timeout for long-running tasks
claw --timeout 600 "Run the full analysis"
```
## Troubleshooting
### "neither 'container' nor 'docker' found"
Install Docker Desktop or Apple Container (macOS 15+), or pass `--runtime` explicitly.
### "no secrets found in .env"
The script auto-detects your NanoClaw directory and reads `.env` from it. Check that the file exists and contains at least one of: `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`.
### Container times out
The default timeout is 300 seconds. For longer tasks, pass `--timeout 600` (or higher). If the container consistently hangs, check that your `nanoclaw-agent:latest` image is up to date by running `./container/build.sh`.
### "group not found"
Run `claw --list-groups` to see what's registered. Group lookup does a fuzzy partial match on name and folder — if your query matches multiple groups, you'll get an error listing the ambiguous matches.
### Container crashes mid-stream
Containers run with `--rm` so they are automatically removed. If the agent crashes before emitting the output sentinel, `claw` falls back to printing raw stdout. Use `-v` to see what the container produced. Rebuild the image with `./container/build.sh` if crashes are consistent.
### Override the NanoClaw directory
If `claw` can't find your database or `.env`, set the `NANOCLAW_DIR` environment variable:
```bash
export NANOCLAW_DIR=/path/to/your/nanoclaw
```
+374
View File
@@ -0,0 +1,374 @@
#!/usr/bin/env python3
"""
claw — NanoClaw CLI
Run a NanoClaw agent container from the command line.
Usage:
claw "What is 2+2?"
claw -g <channel_name> "Review this code"
claw -g "<channel name with spaces>" "What's the latest issue?"
claw -j "<chatJid>" "Hello"
claw -g <channel_name> -s <session-id> "Continue"
claw --list-groups
echo "prompt text" | claw --pipe -g <channel_name>
cat prompt.txt | claw --pipe
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sqlite3
import subprocess
import sys
import threading
from pathlib import Path
# ── Globals ─────────────────────────────────────────────────────────────────
VERBOSE = False
def dbg(*args):
if VERBOSE:
print("»", *args, file=sys.stderr)
# ── Config ──────────────────────────────────────────────────────────────────
def _find_nanoclaw_dir() -> Path:
"""Locate the NanoClaw installation directory.
Resolution order:
1. NANOCLAW_DIR env var
2. The directory containing this script (if it looks like a NanoClaw install)
3. ~/src/nanoclaw (legacy default)
"""
if env := os.environ.get("NANOCLAW_DIR"):
return Path(env).expanduser()
# If this script lives inside the NanoClaw tree (e.g. scripts/claw), walk up
here = Path(__file__).resolve()
for parent in [here.parent, here.parent.parent]:
if (parent / "store" / "messages.db").exists() or (parent / ".env").exists():
return parent
return Path.home() / "src" / "nanoclaw"
NANOCLAW_DIR = _find_nanoclaw_dir()
DB_PATH = NANOCLAW_DIR / "store" / "messages.db"
ENV_FILE = NANOCLAW_DIR / ".env"
IMAGE = "nanoclaw-agent:latest"
SECRET_KEYS = [
"CLAUDE_CODE_OAUTH_TOKEN",
"ANTHROPIC_API_KEY",
"ANTHROPIC_BASE_URL",
"ANTHROPIC_AUTH_TOKEN",
"OLLAMA_HOST",
]
# ── Helpers ──────────────────────────────────────────────────────────────────
def detect_runtime(preference: str | None) -> str:
if preference:
dbg(f"runtime: forced to {preference}")
return preference
for rt in ("container", "docker"):
result = subprocess.run(["which", rt], capture_output=True)
if result.returncode == 0:
dbg(f"runtime: auto-detected {rt} at {result.stdout.decode().strip()}")
return rt
sys.exit("error: neither 'container' nor 'docker' found. Install one or pass --runtime.")
def read_secrets(env_file: Path) -> dict:
secrets = {}
if not env_file.exists():
return secrets
for line in env_file.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
key, _, val = line.partition("=")
key = key.strip()
if key in SECRET_KEYS:
secrets[key] = val.strip()
return secrets
def get_groups(db: Path) -> list[dict]:
conn = sqlite3.connect(db)
rows = conn.execute(
"SELECT jid, name, folder, is_main FROM registered_groups ORDER BY name"
).fetchall()
conn.close()
return [{"jid": r[0], "name": r[1], "folder": r[2], "is_main": bool(r[3])} for r in rows]
def find_group(groups: list[dict], query: str) -> dict | None:
q = query.lower()
# Exact name match
for g in groups:
if g["name"].lower() == q or g["folder"].lower() == q:
return g
# Partial match
matches = [g for g in groups if q in g["name"].lower() or q in g["folder"].lower()]
if len(matches) == 1:
return matches[0]
if len(matches) > 1:
names = ", ".join(f'"{g["name"]}"' for g in matches)
sys.exit(f"error: ambiguous group '{query}'. Matches: {names}")
return None
def build_mounts(folder: str, is_main: bool) -> list[tuple[str, str, bool]]:
"""Return list of (host_path, container_path, readonly) tuples."""
groups_dir = NANOCLAW_DIR / "groups"
data_dir = NANOCLAW_DIR / "data"
sessions_dir = data_dir / "sessions" / folder
ipc_dir = data_dir / "ipc" / folder
# Ensure required dirs exist
group_dir = groups_dir / folder
group_dir.mkdir(parents=True, exist_ok=True)
(sessions_dir / ".claude").mkdir(parents=True, exist_ok=True)
for sub in ("messages", "tasks", "input"):
(ipc_dir / sub).mkdir(parents=True, exist_ok=True)
agent_runner_src = sessions_dir / "agent-runner-src"
project_agent_runner = NANOCLAW_DIR / "container" / "agent-runner" / "src"
if not agent_runner_src.exists() and project_agent_runner.exists():
import shutil
shutil.copytree(project_agent_runner, agent_runner_src)
mounts: list[tuple[str, str, bool]] = []
if is_main:
mounts.append((str(NANOCLAW_DIR), "/workspace/project", True))
mounts.append((str(group_dir), "/workspace/group", False))
mounts.append((str(sessions_dir / ".claude"), "/home/node/.claude", False))
mounts.append((str(ipc_dir), "/workspace/ipc", False))
if agent_runner_src.exists():
mounts.append((str(agent_runner_src), "/app/src", False))
return mounts
def run_container(runtime: str, image: str, payload: dict,
folder: str | None = None, is_main: bool = False,
timeout: int = 300) -> None:
cmd = [runtime, "run", "-i", "--rm"]
if folder:
for host, container, readonly in build_mounts(folder, is_main):
if readonly:
cmd += ["--mount", f"type=bind,source={host},target={container},readonly"]
else:
cmd += ["-v", f"{host}:{container}"]
cmd.append(image)
dbg(f"cmd: {' '.join(cmd)}")
# Show payload sans secrets
if VERBOSE:
safe = {k: v for k, v in payload.items() if k != "secrets"}
safe["secrets"] = {k: "***" for k in payload.get("secrets", {})}
dbg(f"payload: {json.dumps(safe, indent=2)}")
proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
dbg(f"container pid: {proc.pid}")
# Write JSON payload and close stdin
proc.stdin.write(json.dumps(payload).encode())
proc.stdin.close()
dbg("stdin closed, waiting for response...")
stdout_lines: list[str] = []
stderr_lines: list[str] = []
done = threading.Event()
def stream_stderr():
for raw in proc.stderr:
line = raw.decode(errors="replace").rstrip()
if line.startswith("npm notice"):
continue
stderr_lines.append(line)
print(line, file=sys.stderr)
def stream_stdout():
for raw in proc.stdout:
line = raw.decode(errors="replace").rstrip()
stdout_lines.append(line)
dbg(f"stdout: {line}")
# Kill the container as soon as we see the closing sentinel —
# the Node.js event loop often keeps the process alive indefinitely.
if line.strip() == "---NANOCLAW_OUTPUT_END---":
dbg("output sentinel found, terminating container")
done.set()
try:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
dbg("graceful stop timed out, force killing container")
proc.kill()
except ProcessLookupError:
pass
return
t_err = threading.Thread(target=stream_stderr, daemon=True)
t_out = threading.Thread(target=stream_stdout, daemon=True)
t_err.start()
t_out.start()
# Wait for sentinel or timeout
if not done.wait(timeout=timeout):
# Also check if process exited naturally
t_out.join(timeout=2)
if not done.is_set():
proc.kill()
sys.exit(f"error: container timed out after {timeout}s (no output sentinel received)")
t_err.join(timeout=5)
t_out.join(timeout=5)
proc.wait()
dbg(f"container done (rc={proc.returncode}), {len(stdout_lines)} stdout lines")
stdout = "\n".join(stdout_lines)
# Parse output block
match = re.search(
r"---NANOCLAW_OUTPUT_START---\n(.*?)\n---NANOCLAW_OUTPUT_END---",
stdout,
re.DOTALL,
)
success = False
if match:
try:
data = json.loads(match.group(1))
status = data.get("status", "unknown")
if status == "success":
print(data.get("result", ""))
session_id = data.get("newSessionId") or data.get("sessionId")
if session_id:
print(f"\n[session: {session_id}]", file=sys.stderr)
success = True
else:
print(f"[{status}] {data.get('result', '')}", file=sys.stderr)
sys.exit(1)
except json.JSONDecodeError:
print(match.group(1))
else:
# No structured output — print raw stdout
print(stdout)
if success:
return
if proc.returncode not in (0, None):
sys.exit(proc.returncode)
# ── Main ─────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
prog="claw",
description="Run a NanoClaw agent from the command line.",
)
parser.add_argument("prompt", nargs="?", help="Prompt to send")
parser.add_argument("-g", "--group", help="Group name or folder (fuzzy match)")
parser.add_argument("-j", "--jid", help="Chat JID (exact)")
parser.add_argument("-s", "--session", help="Session ID to resume")
parser.add_argument("-p", "--pipe", action="store_true",
help="Read prompt from stdin (can be combined with a prompt arg as prefix)")
parser.add_argument("--runtime", choices=["docker", "container"],
help="Container runtime (default: auto-detect)")
parser.add_argument("--image", default=IMAGE, help=f"Container image (default: {IMAGE})")
parser.add_argument("--list-groups", action="store_true", help="List registered groups and exit")
parser.add_argument("--raw", action="store_true", help="Print raw JSON output")
parser.add_argument("--timeout", type=int, default=300, metavar="SECS",
help="Max seconds to wait for a response (default: 300)")
parser.add_argument("-v", "--verbose", action="store_true",
help="Show debug info: cmd, payload (secrets redacted), stdout lines, exit code")
args = parser.parse_args()
global VERBOSE
VERBOSE = args.verbose
groups = get_groups(DB_PATH) if DB_PATH.exists() else []
if args.list_groups:
print(f"{'NAME':<35} {'FOLDER':<30} {'JID'}")
print("-" * 100)
for g in groups:
main_tag = " [main]" if g["is_main"] else ""
print(f"{g['name']:<35} {g['folder']:<30} {g['jid']}{main_tag}")
return
# Resolve prompt: --pipe reads stdin, optionally prepended with positional arg
if args.pipe or (not sys.stdin.isatty() and not args.prompt):
stdin_text = sys.stdin.read().strip()
if args.prompt:
prompt = f"{args.prompt}\n\n{stdin_text}"
else:
prompt = stdin_text
else:
prompt = args.prompt
if not prompt:
parser.print_help()
sys.exit(1)
# Resolve group → jid
jid = args.jid
group_name = None
group_folder = None
is_main = False
if args.group:
g = find_group(groups, args.group)
if g is None:
sys.exit(f"error: group '{args.group}' not found. Run --list-groups to see options.")
jid = g["jid"]
group_name = g["name"]
group_folder = g["folder"]
is_main = g["is_main"]
elif not jid:
# Default: main group
mains = [g for g in groups if g["is_main"]]
if mains:
jid = mains[0]["jid"]
group_name = mains[0]["name"]
group_folder = mains[0]["folder"]
is_main = True
else:
sys.exit("error: no group specified and no main group found. Use -g or -j.")
runtime = detect_runtime(args.runtime)
secrets = read_secrets(ENV_FILE)
if not secrets:
print("warning: no secrets found in .env — agent may not be authenticated", file=sys.stderr)
payload: dict = {
"prompt": prompt,
"chatJid": jid,
"isMain": is_main,
"secrets": secrets,
}
if group_name:
payload["groupFolder"] = group_name
if args.session:
payload["sessionId"] = args.session
payload["resumeAt"] = "latest"
print(f"[{group_name or jid}] running via {runtime}...", file=sys.stderr)
run_container(runtime, args.image, payload,
folder=group_folder, is_main=is_main,
timeout=args.timeout)
if __name__ == "__main__":
main()
@@ -41,10 +41,6 @@ Apple Container requires macOS. It does not work on Linux.
### Check if already applied
Read `.nanoclaw/state.yaml`. If `convert-to-apple-container` is in `applied_skills`, skip to Phase 3 (Verify). The code changes are already in place.
### Check current runtime
```bash
grep "CONTAINER_RUNTIME_BIN" src/container-runtime.ts
```
@@ -53,37 +49,33 @@ If it already shows `'container'`, the runtime is already Apple Container. Skip
## Phase 2: Apply Code Changes
Run the skills engine to apply this skill's code package. The package files are in this directory alongside this SKILL.md.
### Initialize skills system (if needed)
If `.nanoclaw/` directory doesn't exist yet:
### Ensure upstream remote
```bash
npx tsx scripts/apply-skill.ts --init
git remote -v
```
Or call `initSkillsSystem()` from `skills-engine/migrate.ts`.
### Apply the skill
If `upstream` is missing, add it:
```bash
npx tsx scripts/apply-skill.ts .claude/skills/convert-to-apple-container
git remote add upstream https://github.com/qwibitai/nanoclaw.git
```
This deterministically:
- Replaces `src/container-runtime.ts` with the Apple Container implementation
- Replaces `src/container-runtime.test.ts` with Apple Container-specific tests
- Updates `src/container-runner.ts` with .env shadow mount fix and privilege dropping
- Updates `container/Dockerfile` with entrypoint that shadows .env via `mount --bind`
- Updates `container/build.sh` to default to `container` runtime
- Records the application in `.nanoclaw/state.yaml`
### Merge the skill branch
If the apply reports merge conflicts, read the intent files:
- `modify/src/container-runtime.ts.intent.md` — what changed and invariants
- `modify/src/container-runner.ts.intent.md` — .env shadow and privilege drop changes
- `modify/container/Dockerfile.intent.md` — entrypoint changes for .env shadowing
- `modify/container/build.sh.intent.md` — what changed for build script
```bash
git fetch upstream skill/apple-container
git merge upstream/skill/apple-container
```
This merges in:
- `src/container-runtime.ts` — Apple Container implementation (replaces Docker)
- `src/container-runtime.test.ts` — Apple Container-specific tests
- `src/container-runner.ts` — .env shadow mount fix and privilege dropping
- `container/Dockerfile` — entrypoint that shadows .env via `mount --bind`
- `container/build.sh` — default runtime set to `container`
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
### Validate code changes
@@ -1,15 +0,0 @@
skill: convert-to-apple-container
version: 1.1.0
description: "Switch container runtime from Docker to Apple Container (macOS)"
core_version: 0.1.0
adds: []
modifies:
- src/container-runtime.ts
- src/container-runtime.test.ts
- src/container-runner.ts
- container/build.sh
- container/Dockerfile
structured: {}
conflicts: []
depends: []
test: "npx vitest run src/container-runtime.test.ts"
@@ -1,31 +0,0 @@
# Intent: container/Dockerfile modifications
## What changed
Updated the entrypoint script to shadow `.env` inside the container and drop privileges at runtime, replacing the Docker-style host-side file mount approach.
## Why
Apple Container (VirtioFS) only supports directory mounts, not file mounts. The Docker approach of mounting `/dev/null` over `.env` from the host causes `VZErrorDomain Code=2 "A directory sharing device configuration is invalid"`. The fix moves the shadowing into the entrypoint using `mount --bind` (which works inside the Linux VM).
## Key sections
### Entrypoint script
- Added: `mount --bind /dev/null /workspace/project/.env` when running as root and `.env` exists
- Added: Privilege drop via `setpriv --reuid=$RUN_UID --regid=$RUN_GID --clear-groups` for main-group containers
- Added: `chown` of `/tmp/input.json` and `/tmp/dist` to target user before dropping privileges
- Removed: `USER node` directive — main containers start as root to perform the bind mount, then drop privileges in the entrypoint. Non-main containers still get `--user` from the host.
### Dual-path execution
- Root path (main containers): shadow .env → compile → capture stdin → chown → setpriv drop → exec node
- Non-root path (other containers): compile → capture stdin → exec node
## Invariants
- The entrypoint still reads JSON from stdin and runs the agent-runner
- The compiled output goes to `/tmp/dist` (read-only after build)
- `node_modules` is symlinked, not copied
- Non-main containers are unaffected (they arrive as non-root via `--user`)
## Must-keep
- The `set -e` at the top
- The stdin capture to `/tmp/input.json` (required because setpriv can't forward stdin piping)
- The `chmod -R a-w /tmp/dist` (prevents agent from modifying its own runner)
- The `chown -R node:node /workspace` in the build step
@@ -1,23 +0,0 @@
#!/bin/bash
# Build the NanoClaw agent container image
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
IMAGE_NAME="nanoclaw-agent"
TAG="${1:-latest}"
CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-container}"
echo "Building NanoClaw agent container image..."
echo "Image: ${IMAGE_NAME}:${TAG}"
${CONTAINER_RUNTIME} build -t "${IMAGE_NAME}:${TAG}" .
echo ""
echo "Build complete!"
echo "Image: ${IMAGE_NAME}:${TAG}"
echo ""
echo "Test with:"
echo " echo '{\"prompt\":\"What is 2+2?\",\"groupFolder\":\"test\",\"chatJid\":\"test@g.us\",\"isMain\":false}' | ${CONTAINER_RUNTIME} run -i ${IMAGE_NAME}:${TAG}"
@@ -1,17 +0,0 @@
# Intent: container/build.sh modifications
## What changed
Changed the default container runtime from `docker` to `container` (Apple Container CLI).
## Key sections
- `CONTAINER_RUNTIME` default: `docker``container`
- All build/run commands use `${CONTAINER_RUNTIME}` variable (unchanged)
## Invariants
- The `CONTAINER_RUNTIME` environment variable override still works
- IMAGE_NAME and TAG logic unchanged
- Build and test echo commands unchanged
## Must-keep
- The `CONTAINER_RUNTIME` env var override pattern
- The test command echo at the end
@@ -1,33 +0,0 @@
# Intent: src/container-runner.ts modifications
## What changed
Updated `buildContainerArgs` to support Apple Container's .env shadowing mechanism. The function now accepts an `isMain` parameter and uses it to decide how container user identity is configured.
## Why
Apple Container (VirtioFS) only supports directory mounts, not file mounts. The previous approach of mounting `/dev/null` over `.env` from the host causes a `VZErrorDomain` crash. Instead, main-group containers now start as root so the entrypoint can `mount --bind /dev/null` over `.env` inside the Linux VM, then drop to the host user via `setpriv`.
## Key sections
### buildContainerArgs (signature change)
- Added: `isMain: boolean` parameter
- Main containers: passes `RUN_UID`/`RUN_GID` env vars instead of `--user`, so the container starts as root
- Non-main containers: unchanged, still uses `--user` flag
### buildVolumeMounts
- Removed: the `/dev/null``/workspace/project/.env` shadow mount (was in the committed `37228a9` fix)
- The .env shadowing is now handled inside the container entrypoint instead
### runContainerAgent (call site)
- Changed: `buildContainerArgs(mounts, containerName)``buildContainerArgs(mounts, containerName, input.isMain)`
## Invariants
- All exported interfaces unchanged: `ContainerInput`, `ContainerOutput`, `runContainerAgent`, `writeTasksSnapshot`, `writeGroupsSnapshot`, `AvailableGroup`
- Non-main containers behave identically (still get `--user` flag)
- Mount list for non-main containers is unchanged
- Secrets still passed via stdin, never mounted as files
- Output parsing (streaming + legacy) unchanged
## Must-keep
- The `isMain` parameter on `buildContainerArgs` (consumed by `runContainerAgent`)
- The `RUN_UID`/`RUN_GID` env vars for main containers (consumed by entrypoint.sh)
- The `--user` flag for non-main containers (file permission compatibility)
@@ -1,177 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock logger
vi.mock('./logger.js', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
// Mock child_process — store the mock fn so tests can configure it
const mockExecSync = vi.fn();
vi.mock('child_process', () => ({
execSync: (...args: unknown[]) => mockExecSync(...args),
}));
import {
CONTAINER_RUNTIME_BIN,
readonlyMountArgs,
stopContainer,
ensureContainerRuntimeRunning,
cleanupOrphans,
} from './container-runtime.js';
import { logger } from './logger.js';
beforeEach(() => {
vi.clearAllMocks();
});
// --- Pure functions ---
describe('readonlyMountArgs', () => {
it('returns --mount flag with type=bind and readonly', () => {
const args = readonlyMountArgs('/host/path', '/container/path');
expect(args).toEqual([
'--mount',
'type=bind,source=/host/path,target=/container/path,readonly',
]);
});
});
describe('stopContainer', () => {
it('returns stop command using CONTAINER_RUNTIME_BIN', () => {
expect(stopContainer('nanoclaw-test-123')).toBe(
`${CONTAINER_RUNTIME_BIN} stop nanoclaw-test-123`,
);
});
});
// --- ensureContainerRuntimeRunning ---
describe('ensureContainerRuntimeRunning', () => {
it('does nothing when runtime is already running', () => {
mockExecSync.mockReturnValueOnce('');
ensureContainerRuntimeRunning();
expect(mockExecSync).toHaveBeenCalledTimes(1);
expect(mockExecSync).toHaveBeenCalledWith(
`${CONTAINER_RUNTIME_BIN} system status`,
{ stdio: 'pipe' },
);
expect(logger.debug).toHaveBeenCalledWith('Container runtime already running');
});
it('auto-starts when system status fails', () => {
// First call (system status) fails
mockExecSync.mockImplementationOnce(() => {
throw new Error('not running');
});
// Second call (system start) succeeds
mockExecSync.mockReturnValueOnce('');
ensureContainerRuntimeRunning();
expect(mockExecSync).toHaveBeenCalledTimes(2);
expect(mockExecSync).toHaveBeenNthCalledWith(
2,
`${CONTAINER_RUNTIME_BIN} system start`,
{ stdio: 'pipe', timeout: 30000 },
);
expect(logger.info).toHaveBeenCalledWith('Container runtime started');
});
it('throws when both status and start fail', () => {
mockExecSync.mockImplementation(() => {
throw new Error('failed');
});
expect(() => ensureContainerRuntimeRunning()).toThrow(
'Container runtime is required but failed to start',
);
expect(logger.error).toHaveBeenCalled();
});
});
// --- cleanupOrphans ---
describe('cleanupOrphans', () => {
it('stops orphaned nanoclaw containers from JSON output', () => {
// Apple Container ls returns JSON
const lsOutput = JSON.stringify([
{ status: 'running', configuration: { id: 'nanoclaw-group1-111' } },
{ status: 'stopped', configuration: { id: 'nanoclaw-group2-222' } },
{ status: 'running', configuration: { id: 'nanoclaw-group3-333' } },
{ status: 'running', configuration: { id: 'other-container' } },
]);
mockExecSync.mockReturnValueOnce(lsOutput);
// stop calls succeed
mockExecSync.mockReturnValue('');
cleanupOrphans();
// ls + 2 stop calls (only running nanoclaw- containers)
expect(mockExecSync).toHaveBeenCalledTimes(3);
expect(mockExecSync).toHaveBeenNthCalledWith(
2,
`${CONTAINER_RUNTIME_BIN} stop nanoclaw-group1-111`,
{ stdio: 'pipe' },
);
expect(mockExecSync).toHaveBeenNthCalledWith(
3,
`${CONTAINER_RUNTIME_BIN} stop nanoclaw-group3-333`,
{ stdio: 'pipe' },
);
expect(logger.info).toHaveBeenCalledWith(
{ count: 2, names: ['nanoclaw-group1-111', 'nanoclaw-group3-333'] },
'Stopped orphaned containers',
);
});
it('does nothing when no orphans exist', () => {
mockExecSync.mockReturnValueOnce('[]');
cleanupOrphans();
expect(mockExecSync).toHaveBeenCalledTimes(1);
expect(logger.info).not.toHaveBeenCalled();
});
it('warns and continues when ls fails', () => {
mockExecSync.mockImplementationOnce(() => {
throw new Error('container not available');
});
cleanupOrphans(); // should not throw
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to clean up orphaned containers',
);
});
it('continues stopping remaining containers when one stop fails', () => {
const lsOutput = JSON.stringify([
{ status: 'running', configuration: { id: 'nanoclaw-a-1' } },
{ status: 'running', configuration: { id: 'nanoclaw-b-2' } },
]);
mockExecSync.mockReturnValueOnce(lsOutput);
// First stop fails
mockExecSync.mockImplementationOnce(() => {
throw new Error('already stopped');
});
// Second stop succeeds
mockExecSync.mockReturnValueOnce('');
cleanupOrphans(); // should not throw
expect(mockExecSync).toHaveBeenCalledTimes(3);
expect(logger.info).toHaveBeenCalledWith(
{ count: 2, names: ['nanoclaw-a-1', 'nanoclaw-b-2'] },
'Stopped orphaned containers',
);
});
});
@@ -1,85 +0,0 @@
/**
* Container runtime abstraction for NanoClaw.
* All runtime-specific logic lives here so swapping runtimes means changing one file.
*/
import { execSync } from 'child_process';
import { logger } from './logger.js';
/** The container runtime binary name. */
export const CONTAINER_RUNTIME_BIN = 'container';
/** Returns CLI args for a readonly bind mount. */
export function readonlyMountArgs(hostPath: string, containerPath: string): string[] {
return ['--mount', `type=bind,source=${hostPath},target=${containerPath},readonly`];
}
/** Returns the shell command to stop a container by name. */
export function stopContainer(name: string): string {
return `${CONTAINER_RUNTIME_BIN} stop ${name}`;
}
/** Ensure the container runtime is running, starting it if needed. */
export function ensureContainerRuntimeRunning(): void {
try {
execSync(`${CONTAINER_RUNTIME_BIN} system status`, { stdio: 'pipe' });
logger.debug('Container runtime already running');
} catch {
logger.info('Starting container runtime...');
try {
execSync(`${CONTAINER_RUNTIME_BIN} system start`, { stdio: 'pipe', timeout: 30000 });
logger.info('Container runtime started');
} catch (err) {
logger.error({ err }, 'Failed to start container runtime');
console.error(
'\n╔════════════════════════════════════════════════════════════════╗',
);
console.error(
'║ FATAL: Container runtime failed to start ║',
);
console.error(
'║ ║',
);
console.error(
'║ Agents cannot run without a container runtime. To fix: ║',
);
console.error(
'║ 1. Ensure Apple Container is installed ║',
);
console.error(
'║ 2. Run: container system start ║',
);
console.error(
'║ 3. Restart NanoClaw ║',
);
console.error(
'╚════════════════════════════════════════════════════════════════╝\n',
);
throw new Error('Container runtime is required but failed to start');
}
}
}
/** Kill orphaned NanoClaw containers from previous runs. */
export function cleanupOrphans(): void {
try {
const output = execSync(`${CONTAINER_RUNTIME_BIN} ls --format json`, {
stdio: ['pipe', 'pipe', 'pipe'],
encoding: 'utf-8',
});
const containers: { status: string; configuration: { id: string } }[] = JSON.parse(output || '[]');
const orphans = containers
.filter((c) => c.status === 'running' && c.configuration.id.startsWith('nanoclaw-'))
.map((c) => c.configuration.id);
for (const name of orphans) {
try {
execSync(stopContainer(name), { stdio: 'pipe' });
} catch { /* already stopped */ }
}
if (orphans.length > 0) {
logger.info({ count: orphans.length, names: orphans }, 'Stopped orphaned containers');
}
} catch (err) {
logger.warn({ err }, 'Failed to clean up orphaned containers');
}
}
@@ -1,32 +0,0 @@
# Intent: src/container-runtime.ts modifications
## What changed
Replaced Docker runtime with Apple Container runtime. This is a full file replacement — the exported API is identical, only the implementation differs.
## Key sections
### CONTAINER_RUNTIME_BIN
- Changed: `'docker'``'container'` (the Apple Container CLI binary)
### readonlyMountArgs
- Changed: Docker `-v host:container:ro` → Apple Container `--mount type=bind,source=...,target=...,readonly`
### ensureContainerRuntimeRunning
- Changed: `docker info``container system status` for checking
- Added: auto-start via `container system start` when not running (Apple Container supports this; Docker requires manual start)
- Changed: error message references Apple Container instead of Docker
### cleanupOrphans
- Changed: `docker ps --filter name=nanoclaw- --format '{{.Names}}'``container ls --format json` with JSON parsing
- Apple Container returns JSON with `{ status, configuration: { id } }` structure
## Invariants
- All five exports remain identical: `CONTAINER_RUNTIME_BIN`, `readonlyMountArgs`, `stopContainer`, `ensureContainerRuntimeRunning`, `cleanupOrphans`
- `stopContainer` implementation is unchanged (`<bin> stop <name>`)
- Logger usage pattern is unchanged
- Error handling pattern is unchanged
## Must-keep
- The exported function signatures (consumed by container-runner.ts and index.ts)
- The error box-drawing output format
- The orphan cleanup logic (find + stop pattern)
@@ -1,69 +0,0 @@
import { describe, expect, it } from 'vitest';
import fs from 'fs';
import path from 'path';
describe('convert-to-apple-container skill package', () => {
const skillDir = path.resolve(__dirname, '..');
it('has a valid manifest', () => {
const manifestPath = path.join(skillDir, 'manifest.yaml');
expect(fs.existsSync(manifestPath)).toBe(true);
const content = fs.readFileSync(manifestPath, 'utf-8');
expect(content).toContain('skill: convert-to-apple-container');
expect(content).toContain('version: 1.0.0');
expect(content).toContain('container-runtime.ts');
expect(content).toContain('container/build.sh');
});
it('has all modified files', () => {
const runtimeFile = path.join(skillDir, 'modify', 'src', 'container-runtime.ts');
expect(fs.existsSync(runtimeFile)).toBe(true);
const content = fs.readFileSync(runtimeFile, 'utf-8');
expect(content).toContain("CONTAINER_RUNTIME_BIN = 'container'");
expect(content).toContain('system status');
expect(content).toContain('system start');
expect(content).toContain('ls --format json');
const testFile = path.join(skillDir, 'modify', 'src', 'container-runtime.test.ts');
expect(fs.existsSync(testFile)).toBe(true);
const testContent = fs.readFileSync(testFile, 'utf-8');
expect(testContent).toContain('system status');
expect(testContent).toContain('--mount');
});
it('has intent files for modified sources', () => {
const runtimeIntent = path.join(skillDir, 'modify', 'src', 'container-runtime.ts.intent.md');
expect(fs.existsSync(runtimeIntent)).toBe(true);
const buildIntent = path.join(skillDir, 'modify', 'container', 'build.sh.intent.md');
expect(fs.existsSync(buildIntent)).toBe(true);
});
it('has build.sh with Apple Container default', () => {
const buildFile = path.join(skillDir, 'modify', 'container', 'build.sh');
expect(fs.existsSync(buildFile)).toBe(true);
const content = fs.readFileSync(buildFile, 'utf-8');
expect(content).toContain('CONTAINER_RUNTIME:-container');
expect(content).not.toContain('CONTAINER_RUNTIME:-docker');
});
it('uses Apple Container API patterns (not Docker)', () => {
const runtimeFile = path.join(skillDir, 'modify', 'src', 'container-runtime.ts');
const content = fs.readFileSync(runtimeFile, 'utf-8');
// Apple Container patterns
expect(content).toContain('system status');
expect(content).toContain('system start');
expect(content).toContain('ls --format json');
expect(content).toContain('type=bind,source=');
// Should NOT contain Docker patterns
expect(content).not.toContain('docker info');
expect(content).not.toContain("'-v'");
expect(content).not.toContain('--filter name=');
});
});
+3 -3
View File
@@ -10,9 +10,9 @@ This skill helps users add capabilities or modify behavior. Use AskUserQuestion
## Workflow
1. **Understand the request** - Ask clarifying questions
2. **Plan the changes** - Identify files to modify
3. **Implement** - Make changes directly to the code
4. **Test guidance** - Tell user how to verify
3. **Plan the changes** - Identify files to modify. If a skill exists for the request (e.g., `/add-telegram` for adding Telegram), invoke it instead of implementing manually.
4. **Implement** - Make changes directly to the code
5. **Test guidance** - Tell user how to verify
## Key Files
+276
View File
@@ -0,0 +1,276 @@
---
name: init-onecli
description: Install and initialize OneCLI Agent Vault. Migrates existing .env credentials to the vault. Use after /update-nanoclaw brings in OneCLI as a breaking change, or for first-time OneCLI setup.
---
# Initialize OneCLI Agent Vault
This skill installs OneCLI, configures the Agent Vault gateway, and migrates any existing `.env` credentials into it. Run this after `/update-nanoclaw` introduces OneCLI as a breaking change, or any time OneCLI needs to be set up from scratch.
**Principle:** When something is broken or missing, fix it. Don't tell the user to go fix it themselves unless it genuinely requires their manual action (e.g. pasting a token).
## Phase 1: Pre-flight
### Check if OneCLI is already working
```bash
onecli version 2>/dev/null
```
If the command succeeds, OneCLI is installed. Check if the gateway is reachable:
```bash
curl -sf http://127.0.0.1:10254/health
```
If both succeed, check for an Anthropic secret:
```bash
onecli secrets list
```
If an Anthropic secret exists, tell the user OneCLI is already configured and working. Use AskUserQuestion:
1. **Keep current setup** — description: "OneCLI is installed and has credentials configured. Nothing to do."
2. **Reconfigure** — description: "Start fresh — reinstall OneCLI and re-register credentials."
If they choose to keep, skip to Phase 5 (Verify). If they choose to reconfigure, continue.
### Check for native credential proxy
```bash
grep "credential-proxy" src/index.ts 2>/dev/null
```
If `startCredentialProxy` is imported, the native credential proxy skill is active. Tell the user: "You're currently using the native credential proxy (`.env`-based). This skill will switch you to OneCLI's Agent Vault, which adds per-agent policies and rate limits. Your `.env` credentials will be migrated to the vault."
Use AskUserQuestion:
1. **Continue** — description: "Switch to OneCLI Agent Vault."
2. **Cancel** — description: "Keep the native credential proxy."
If they cancel, stop.
### Check the codebase expects OneCLI
```bash
grep "@onecli-sh/sdk" package.json
```
If `@onecli-sh/sdk` is NOT in package.json, the codebase hasn't been updated to use OneCLI yet. Tell the user to run `/update-nanoclaw` first to get the OneCLI integration, then retry `/init-onecli`. Stop here.
## Phase 2: Install OneCLI
### Install the gateway and CLI
```bash
curl -fsSL onecli.sh/install | sh
curl -fsSL onecli.sh/cli/install | sh
```
Verify: `onecli version`
If the command is not found, the CLI was likely installed to `~/.local/bin/`. Add it to PATH:
```bash
export PATH="$HOME/.local/bin:$PATH"
grep -q '.local/bin' ~/.bashrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
grep -q '.local/bin' ~/.zshrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc
```
Re-verify with `onecli version`.
### Configure the CLI
Point the CLI at the local OneCLI instance:
```bash
onecli config set api-host http://127.0.0.1:10254
```
### Set ONECLI_URL in .env
```bash
grep -q 'ONECLI_URL' .env 2>/dev/null || echo 'ONECLI_URL=http://127.0.0.1:10254' >> .env
```
### Wait for gateway readiness
The gateway may take a moment to start after installation. Poll for up to 15 seconds:
```bash
for i in $(seq 1 15); do
curl -sf http://127.0.0.1:10254/health && break
sleep 1
done
```
If it never becomes healthy, check if the gateway process is running:
```bash
ps aux | grep -i onecli | grep -v grep
```
If it's not running, try starting it manually: `onecli start`. If that fails, show the error and stop — the user needs to debug their OneCLI installation.
## Phase 3: Migrate existing credentials
### Scan .env for credentials to migrate
Read the `.env` file and look for these credential variables:
| .env variable | OneCLI secret type | Host pattern |
|---|---|---|
| `ANTHROPIC_API_KEY` | `anthropic` | `api.anthropic.com` |
| `CLAUDE_CODE_OAUTH_TOKEN` | `anthropic` | `api.anthropic.com` |
| `ANTHROPIC_AUTH_TOKEN` | `anthropic` | `api.anthropic.com` |
Read `.env`:
```bash
cat .env
```
Parse the file for any of the credential variables listed above.
### If credentials found in .env
For each credential found, migrate it to OneCLI:
**Anthropic API key** (`ANTHROPIC_API_KEY=sk-ant-...`):
```bash
onecli secrets create --name Anthropic --type anthropic --value <key> --host-pattern api.anthropic.com
```
**Claude OAuth token** (`CLAUDE_CODE_OAUTH_TOKEN=...` or `ANTHROPIC_AUTH_TOKEN=...`):
```bash
onecli secrets create --name Anthropic --type anthropic --value <token> --host-pattern api.anthropic.com
```
After successful migration, remove the credential lines from `.env`. Use the Edit tool to remove only the credential variable lines (`ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_AUTH_TOKEN`). Keep all other `.env` entries intact (e.g. `ONECLI_URL`, `TELEGRAM_BOT_TOKEN`, channel tokens).
Verify the secret was registered:
```bash
onecli secrets list
```
Tell the user: "Migrated your Anthropic credentials from `.env` to the OneCLI Agent Vault. The raw keys have been removed from `.env` — they're now managed by OneCLI and will be injected at request time without entering containers."
### Offer to migrate other container-facing credentials
After handling Anthropic credentials (whether migrated or freshly registered), scan `.env` again for remaining credential variables that containers use for outbound API calls.
**Important:** Only migrate credentials that containers use via outbound HTTPS. Channel tokens (`TELEGRAM_BOT_TOKEN`, `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, `DISCORD_BOT_TOKEN`) are used by the NanoClaw host process to connect to messaging platforms — they must stay in `.env`.
Known container-facing credentials:
| .env variable | Secret name | Host pattern |
|---|---|---|
| `OPENAI_API_KEY` | `OpenAI` | `api.openai.com` |
| `PARALLEL_API_KEY` | `Parallel` | `api.parallel.ai` |
If any of these are found with non-empty values, present them to the user:
AskUserQuestion (multiSelect): "These credentials are used by container agents for outbound API calls. Moving them to the vault means agents never see the raw keys, and you can apply rate limits and policies."
- One option per credential found (e.g., "OPENAI_API_KEY" — description: "Used by voice transcription and other OpenAI integrations inside containers")
- **Skip — keep them in .env** — description: "Leave these in .env for now. You can move them later."
For each credential the user selects:
```bash
onecli secrets create --name <SecretName> --type api_key --value <value> --host-pattern <host>
```
If there are credential variables not in the table above that look container-facing (i.e. not a channel token), ask the user: "Is `<VARIABLE_NAME>` used by agents inside containers? If so, what API host does it authenticate against? (e.g., `api.example.com`)" — then migrate accordingly.
After migration, remove the migrated lines from `.env` using the Edit tool. Keep channel tokens and any credentials the user chose not to migrate.
Verify all secrets were registered:
```bash
onecli secrets list
```
### If no credentials found in .env
No migration needed. Proceed to register credentials fresh.
Check if OneCLI already has an Anthropic secret:
```bash
onecli secrets list
```
If an Anthropic secret already exists, skip to Phase 4.
Otherwise, register credentials using the same flow as `/setup`:
AskUserQuestion: Do you want to use your **Claude subscription** (Pro/Max) or an **Anthropic API key**?
1. **Claude subscription (Pro/Max)** — description: "Uses your existing Claude Pro or Max subscription. You'll run `claude setup-token` in another terminal to get your token."
2. **Anthropic API key** — description: "Pay-per-use API key from console.anthropic.com."
#### Subscription path
Tell the user to run `claude setup-token` in another terminal and copy the token it outputs. Do NOT collect the token in chat.
Once they have the token, AskUserQuestion with two options:
1. **Dashboard** — description: "Best if you have a browser on this machine. Open http://127.0.0.1:10254 and add the secret in the UI. Use type 'anthropic' and paste your token as the value."
2. **CLI** — description: "Best for remote/headless servers. Run: `onecli secrets create --name Anthropic --type anthropic --value YOUR_TOKEN --host-pattern api.anthropic.com`"
#### API key path
Tell the user to get an API key from https://console.anthropic.com/settings/keys if they don't have one.
AskUserQuestion with two options:
1. **Dashboard** — description: "Best if you have a browser on this machine. Open http://127.0.0.1:10254 and add the secret in the UI."
2. **CLI** — description: "Best for remote/headless servers. Run: `onecli secrets create --name Anthropic --type anthropic --value YOUR_KEY --host-pattern api.anthropic.com`"
#### After either path
Ask them to let you know when done.
**If the user's response happens to contain a token or key** (starts with `sk-ant-` or looks like a token): handle it gracefully — run the `onecli secrets create` command with that value on their behalf.
**After user confirms:** verify with `onecli secrets list` that an Anthropic secret exists. If not, ask again.
## Phase 4: Build and restart
```bash
npm run build
```
If build fails, diagnose and fix. Common issue: `@onecli-sh/sdk` not installed — run `npm install` first.
Restart the service:
- macOS (launchd): `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
- Linux (systemd): `systemctl --user restart nanoclaw`
- WSL/manual: stop and re-run `bash start-nanoclaw.sh`
## Phase 5: Verify
Check logs for successful OneCLI integration:
```bash
tail -30 logs/nanoclaw.log | grep -i "onecli\|gateway"
```
Expected: `OneCLI gateway config applied` messages when containers start.
If the service is running and a channel is configured, tell the user to send a test message to verify the agent responds.
Tell the user:
- OneCLI Agent Vault is now managing credentials
- Agents never see raw API keys — credentials are injected at the gateway level
- To manage secrets: `onecli secrets list`, or open http://127.0.0.1:10254
- To add rate limits or policies: `onecli rules create --help`
## Troubleshooting
**"OneCLI gateway not reachable" in logs:** The gateway isn't running. Check with `curl -sf http://127.0.0.1:10254/health`. Start it with `onecli start` if needed.
**Container gets no credentials:** Verify `ONECLI_URL` is set in `.env` and the gateway has an Anthropic secret (`onecli secrets list`).
**Old .env credentials still present:** This skill should have removed them. Double-check `.env` for `ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN`, or `ANTHROPIC_AUTH_TOKEN` and remove them manually if still present.
**Port 10254 already in use:** Another OneCLI instance may be running. Check with `lsof -i :10254` and kill the old process, or configure a different port.
+145 -14
View File
@@ -9,7 +9,46 @@ Run setup steps automatically. Only pause when user action is required (channel
**Principle:** When something is broken or missing, fix it. Don't tell the user to go fix it themselves unless it genuinely requires their manual action (e.g. authenticating a channel, pasting a secret token). If a dependency is missing, install it. If a service won't start, diagnose and repair. Ask the user for permission when needed, then do the work.
**UX Note:** Use `AskUserQuestion` for all user-facing questions.
**UX Note:** Use `AskUserQuestion` for multiple-choice questions only (e.g. "Docker or Apple Container?", "which channels?"). Do NOT use it when free-text input is needed (e.g. phone numbers, tokens, paths) — just ask the question in plain text and wait for the user's reply.
## 0. Git & Fork Setup
Check the git remote configuration to ensure the user has a fork and upstream is configured.
Run:
- `git remote -v`
**Case A — `origin` points to `qwibitai/nanoclaw` (user cloned directly):**
The user cloned instead of forking. AskUserQuestion: "You cloned NanoClaw directly. We recommend forking so you can push your customizations. Would you like to set up a fork?"
- Fork now (recommended) — walk them through it
- Continue without fork — they'll only have local changes
If fork: instruct the user to fork `qwibitai/nanoclaw` on GitHub (they need to do this in their browser), then ask them for their GitHub username. Run:
```bash
git remote rename origin upstream
git remote add origin https://github.com/<their-username>/nanoclaw.git
git push --force origin main
```
Verify with `git remote -v`.
If continue without fork: add upstream so they can still pull updates:
```bash
git remote add upstream https://github.com/qwibitai/nanoclaw.git
```
**Case B — `origin` points to user's fork, no `upstream` remote:**
Add upstream:
```bash
git remote add upstream https://github.com/qwibitai/nanoclaw.git
```
**Case C — both `origin` (user's fork) and `upstream` (qwibitai) exist:**
Already configured. Continue.
**Verify:** `git remote -v` should show `origin` → user's repo, `upstream``qwibitai/nanoclaw.git`.
## 1. Bootstrap (Node.js + Dependencies)
@@ -19,7 +58,7 @@ Run `bash setup.sh` and parse the status block.
- macOS: `brew install node@22` (if brew available) or install nvm then `nvm install 22`
- Linux: `curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt-get install -y nodejs`, or nvm
- After installing Node, re-run `bash setup.sh`
- If DEPS_OK=false → Read `logs/setup.log`. Try: delete `node_modules` and `package-lock.json`, re-run `bash setup.sh`. If native module build fails, install build tools (`xcode-select --install` on macOS, `build-essential` on Linux), then retry.
- If DEPS_OK=false → Read `logs/setup.log`. Try: delete `node_modules`, re-run `bash setup.sh`. If native module build fails, install build tools (`xcode-select --install` on macOS, `build-essential` on Linux), then retry.
- If NATIVE_OK=false → better-sqlite3 failed to load. Install build tools and re-run.
- Record PLATFORM and IS_WSL for later steps.
@@ -31,6 +70,13 @@ Run `npx tsx setup/index.ts --step environment` and parse the status block.
- If HAS_REGISTERED_GROUPS=true → note existing config, offer to skip or reconfigure
- Record APPLE_CONTAINER and DOCKER values for step 3
## 2a. Timezone
Run `npx tsx setup/index.ts --step timezone` and parse the status block.
- If NEEDS_USER_INPUT=true → The system timezone could not be autodetected (e.g. POSIX-style TZ like `IST-2`). AskUserQuestion: "What is your timezone?" with common options (America/New_York, Europe/London, Asia/Jerusalem, Asia/Tokyo) and an "Other" escape. Then re-run: `npx tsx setup/index.ts --step timezone -- --tz <their-answer>`.
- If STATUS=success → Timezone is configured. Note RESOLVED_TZ for reference.
## 3. Container Runtime
### 3a. Choose runtime
@@ -38,7 +84,10 @@ Run `npx tsx setup/index.ts --step environment` and parse the status block.
Check the preflight results for `APPLE_CONTAINER` and `DOCKER`, and the PLATFORM from step 1.
- PLATFORM=linux → Docker (only option)
- PLATFORM=macos + APPLE_CONTAINER=installed → Use `AskUserQuestion: Docker (cross-platform) or Apple Container (native macOS)?` If Apple Container, run `/convert-to-apple-container` now, then skip to 4c.
- PLATFORM=macos + APPLE_CONTAINER=installed → AskUserQuestion with two options:
1. **Docker (recommended)** — description: "Cross-platform, better credential management, well-tested."
2. **Apple Container (experimental)** — description: "Native macOS runtime. Requires advanced setup."
If Apple Container, run `/convert-to-apple-container` now, then skip to 3c.
- PLATFORM=macos + APPLE_CONTAINER=not_found → Docker
### 3a-docker. Install Docker
@@ -59,9 +108,9 @@ grep -q "CONTAINER_RUNTIME_BIN = 'container'" src/container-runtime.ts && echo "
**If NEEDS_CONVERSION**, the source code still uses Docker as the runtime. You MUST run the `/convert-to-apple-container` skill NOW, before proceeding to the build step.
**If ALREADY_CONVERTED**, the code already uses Apple Container. Continue to 4c.
**If ALREADY_CONVERTED**, the code already uses Apple Container. Continue to 3c.
**If the chosen runtime is Docker**, no conversion is needed. Continue to 4c.
**If the chosen runtime is Docker**, no conversion is needed. Continue to 3c.
### 3c. Build and test
@@ -73,15 +122,85 @@ Run `npx tsx setup/index.ts --step container -- --runtime <chosen>` and parse th
**If TEST_OK=false but BUILD_OK=true:** The image built but won't run. Check logs — common cause is runtime not fully started. Wait a moment and retry the test.
## 4. Claude Authentication (No Script)
## 4. Credential System
If HAS_ENV=true from step 2, read `.env` and check for `CLAUDE_CODE_OAUTH_TOKEN` or `ANTHROPIC_API_KEY`. If present, confirm with user: keep or reconfigure?
The credential system depends on the container runtime chosen in step 3.
AskUserQuestion: Claude subscription (Pro/Max) vs Anthropic API key?
### 4a. Docker → OneCLI
**Subscription:** Tell user to run `claude setup-token` in another terminal, copy the token, add `CLAUDE_CODE_OAUTH_TOKEN=<token>` to `.env`. Do NOT collect the token in chat.
Install OneCLI and its CLI tool:
**API key:** Tell user to add `ANTHROPIC_API_KEY=<key>` to `.env`.
```bash
curl -fsSL onecli.sh/install | sh
curl -fsSL onecli.sh/cli/install | sh
```
Verify both installed: `onecli version`. If the command is not found, the CLI was likely installed to `~/.local/bin/`. Add it to PATH for the current session and persist it:
```bash
export PATH="$HOME/.local/bin:$PATH"
# Persist for future sessions (append to shell profile if not already present)
grep -q '.local/bin' ~/.bashrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
grep -q '.local/bin' ~/.zshrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc
```
Then re-verify with `onecli version`.
Point the CLI at the local OneCLI instance (it defaults to the cloud service otherwise):
```bash
onecli config set api-host http://127.0.0.1:10254
```
Ensure `.env` has the OneCLI URL (create the file if it doesn't exist):
```bash
grep -q 'ONECLI_URL' .env 2>/dev/null || echo 'ONECLI_URL=http://127.0.0.1:10254' >> .env
```
Check if a secret already exists:
```bash
onecli secrets list
```
If an Anthropic secret is listed, confirm with user: keep or reconfigure? If keeping, skip to step 5.
AskUserQuestion: Do you want to use your **Claude subscription** (Pro/Max) or an **Anthropic API key**?
1. **Claude subscription (Pro/Max)** — description: "Uses your existing Claude Pro or Max subscription. You'll run `claude setup-token` in another terminal to get your token."
2. **Anthropic API key** — description: "Pay-per-use API key from console.anthropic.com."
#### Subscription path
Tell the user:
> Run `claude setup-token` in another terminal. It will output a token — copy it but don't paste it here.
Then stop and wait for the user to confirm they have the token. Do NOT proceed until they respond.
Once they confirm, they register it with OneCLI. AskUserQuestion with two options:
1. **Dashboard** — description: "Best if you have a browser on this machine. Open http://127.0.0.1:10254 and add the secret in the UI. Use type 'anthropic' and paste your token as the value."
2. **CLI** — description: "Best for remote/headless servers. Run: `onecli secrets create --name Anthropic --type anthropic --value YOUR_TOKEN --host-pattern api.anthropic.com`"
#### API key path
Tell the user to get an API key from https://console.anthropic.com/settings/keys if they don't have one.
Then AskUserQuestion with two options:
1. **Dashboard** — description: "Best if you have a browser on this machine. Open http://127.0.0.1:10254 and add the secret in the UI."
2. **CLI** — description: "Best for remote/headless servers. Run: `onecli secrets create --name Anthropic --type anthropic --value YOUR_KEY --host-pattern api.anthropic.com`"
#### After either path
Ask them to let you know when done.
**If the user's response happens to contain a token or key** (starts with `sk-ant-`): handle it gracefully — run the `onecli secrets create` command with that value on their behalf.
**After user confirms:** verify with `onecli secrets list` that an Anthropic secret exists. If not, ask again.
### 4b. Apple Container → Native Credential Proxy
Apple Container is not compatible with OneCLI. Invoke `/use-native-credential-proxy` to set up the built-in credential proxy instead. That skill handles credential collection, `.env` configuration, and verification.
## 5. Set Up Channels
@@ -101,13 +220,19 @@ For each selected channel, invoke its skill:
- **Discord:** Invoke `/add-discord`
Each skill will:
1. Install the channel code (via `apply-skill`)
1. Install the channel code (via `git merge` of the skill branch)
2. Collect credentials/tokens and write to `.env`
3. Authenticate (WhatsApp QR/pairing, or verify token-based connection)
4. Register the chat with the correct JID format
5. Build and verify
**After all channel skills complete**, continue to step 6.
**After all channel skills complete**, install dependencies and rebuild — channel merges may introduce new packages:
```bash
npm install && npm run build
```
If the build fails, read the error output and fix it (usually a missing dependency). Then continue to step 6.
## 6. Mount Allowlist
@@ -153,7 +278,7 @@ Run `npx tsx setup/index.ts --step verify` and parse the status block.
**If STATUS=failed, fix each:**
- SERVICE=stopped → `npm run build`, then restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) or `bash start-nanoclaw.sh` (WSL nohup)
- SERVICE=not_found → re-run step 7
- CREDENTIALS=missing → re-run step 4
- CREDENTIALS=missing → re-run step 4 (Docker: check `onecli secrets list`; Apple Container: check `.env` for credentials)
- CHANNEL_AUTH shows `not_found` for any channel → re-invoke that channel's skill (e.g. `/add-telegram`)
- REGISTERED_GROUPS=0 → re-invoke the channel skills from step 5
- MOUNT_ALLOWLIST=missing → `npx tsx setup/index.ts --step mounts -- --empty`
@@ -162,7 +287,7 @@ Tell user to test: send a message in their registered chat. Show: `tail -f logs/
## Troubleshooting
**Service not starting:** Check `logs/nanoclaw.error.log`. Common: wrong Node path (re-run step 7), missing `.env` (step 4), missing channel credentials (re-invoke channel skill).
**Service not starting:** Check `logs/nanoclaw.error.log`. Common: wrong Node path (re-run step 7), credential system not running (Docker: check `curl http://127.0.0.1:10254/api/health`; Apple Container: check `.env` credentials), missing channel credentials (re-invoke channel skill).
**Container agent fails ("Claude Code process exited with code 1"):** Ensure the container runtime is running — `open -a Docker` (macOS Docker), `container system start` (Apple Container), or `sudo systemctl start docker` (Linux). Check container logs in `groups/main/logs/container-*.log`.
@@ -171,3 +296,9 @@ Tell user to test: send a message in their registered chat. Show: `tail -f logs/
**Channel not connecting:** Verify the channel's credentials are set in `.env`. Channels auto-enable when their credentials are present. For WhatsApp: check `store/auth/creds.json` exists. For token-based channels: check token values in `.env`. Restart the service after any `.env` change.
**Unload service:** macOS: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist` | Linux: `systemctl --user stop nanoclaw`
## 9. Diagnostics
1. Use the Read tool to read `.claude/skills/setup/diagnostics.md`.
2. Follow every step in that file before completing setup.
+46
View File
@@ -0,0 +1,46 @@
# Diagnostics
Gather system info:
```bash
node -p "require('./package.json').version"
uname -s
uname -m
node -p "process.versions.node.split('.')[0]"
```
Write `/tmp/nanoclaw-diagnostics.json`. No paths, usernames, hostnames, or IP addresses.
```json
{
"api_key": "phc_fx1Hhx9ucz8GuaJC8LVZWO8u03yXZZJJ6ObS4yplnaP",
"event": "setup_complete",
"distinct_id": "<uuid>",
"properties": {
"success": true,
"nanoclaw_version": "1.2.21",
"os_platform": "darwin",
"arch": "arm64",
"node_major_version": 22,
"channels_selected": ["telegram", "whatsapp"],
"error_count": 0,
"failed_step": null
}
}
```
Show the entire JSON to the user and ask via AskUserQuestion: **Yes** / **No** / **Never ask again**
**Yes**:
```bash
curl -s -X POST https://us.i.posthog.com/capture/ -H 'Content-Type: application/json' -d @/tmp/nanoclaw-diagnostics.json
rm /tmp/nanoclaw-diagnostics.json
```
**No**: `rm /tmp/nanoclaw-diagnostics.json`
**Never ask again**:
1. Replace contents of `.claude/skills/setup/diagnostics.md` with `# Diagnostics — opted out`
2. Replace contents of `.claude/skills/update-nanoclaw/diagnostics.md` with `# Diagnostics — opted out`
3. Remove the `## 9. Diagnostics` section from `.claude/skills/setup/SKILL.md` and the `## Diagnostics` section from `.claude/skills/update-nanoclaw/SKILL.md`
4. `rm /tmp/nanoclaw-diagnostics.json`
+20 -3
View File
@@ -194,7 +194,7 @@ Parse the diff output for lines starting with `+[BREAKING]`. Each such line is o
```
If no `[BREAKING]` lines are found:
- Skip this step silently. Proceed to Step 7.
- Skip this step silently. Proceed to Step 7 (skill updates check).
If one or more `[BREAKING]` lines are found:
- Display a warning header to the user: "This update includes breaking changes that may require action:"
@@ -205,9 +205,20 @@ If one or more `[BREAKING]` lines are found:
- "Skip — I'll handle these manually"
- Set `multiSelect: true` so the user can pick multiple skills if there are several breaking changes.
- For each skill the user selects, invoke it using the Skill tool.
- After all selected skills complete (or if user chose Skip), proceed to Step 7.
- After all selected skills complete (or if user chose Skip), proceed to Step 7 (skill updates check).
# Step 7: Summary + rollback instructions
# Step 7: Check for skill updates
After the summary, check if skills are distributed as branches in this repo:
- `git branch -r --list 'upstream/skill/*'`
If any `upstream/skill/*` branches exist:
- Use AskUserQuestion to ask: "Upstream has skill branches. Would you like to check for skill updates?"
- Option 1: "Yes, check for updates" (description: "Runs /update-skills to check for and apply skill branch updates")
- Option 2: "No, skip" (description: "You can run /update-skills later any time")
- If user selects yes, invoke `/update-skills` using the Skill tool.
- After the skill completes (or if user selected no), proceed to Step 8.
# Step 8: Summary + rollback instructions
Show:
- Backup tag: the tag name created in Step 1
- New HEAD: `git rev-parse --short HEAD`
@@ -222,3 +233,9 @@ Tell the user:
- Restart the service to apply changes:
- If using launchd: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist && launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist`
- If running manually: restart `npm run dev`
## Diagnostics
1. Use the Read tool to read `.claude/skills/update-nanoclaw/diagnostics.md`.
2. Follow every step in that file before finishing.
@@ -0,0 +1,49 @@
# Diagnostics
Gather system info:
```bash
node -p "require('./package.json').version"
uname -s
uname -m
node -p "process.versions.node.split('.')[0]"
git log -1 --format=%ci HEAD@{1} 2>/dev/null || echo "unknown"
```
Write `/tmp/nanoclaw-diagnostics.json`. No paths, usernames, hostnames, or IP addresses.
```json
{
"api_key": "phc_fx1Hhx9ucz8GuaJC8LVZWO8u03yXZZJJ6ObS4yplnaP",
"event": "update_complete",
"distinct_id": "<uuid>",
"properties": {
"success": true,
"nanoclaw_version": "1.2.21",
"os_platform": "darwin",
"arch": "arm64",
"node_major_version": 22,
"version_age_days": 45,
"update_method": "merge",
"conflict_count": 0,
"breaking_changes_found": false,
"error_count": 0
}
}
```
Show the entire JSON to the user and ask via AskUserQuestion: **Yes** / **No** / **Never ask again**
**Yes**:
```bash
curl -s -X POST https://us.i.posthog.com/capture/ -H 'Content-Type: application/json' -d @/tmp/nanoclaw-diagnostics.json
rm /tmp/nanoclaw-diagnostics.json
```
**No**: `rm /tmp/nanoclaw-diagnostics.json`
**Never ask again**:
1. Replace contents of `.claude/skills/setup/diagnostics.md` with `# Diagnostics — opted out`
2. Replace contents of `.claude/skills/update-nanoclaw/diagnostics.md` with `# Diagnostics — opted out`
3. Remove the `## 9. Diagnostics` section from `.claude/skills/setup/SKILL.md` and the `## Diagnostics` section from `.claude/skills/update-nanoclaw/SKILL.md`
4. `rm /tmp/nanoclaw-diagnostics.json`
+130
View File
@@ -0,0 +1,130 @@
---
name: update-skills
description: Check for and apply updates to installed skill branches from upstream.
---
# About
Skills are distributed as git branches (`skill/*`). When you install a skill, you merge its branch into your repo. This skill checks upstream for newer commits on those skill branches and helps you update.
Run `/update-skills` in Claude Code.
## How it works
**Preflight**: checks for clean working tree and upstream remote.
**Detection**: fetches upstream, lists all `upstream/skill/*` branches, determines which ones you've previously merged (via merge-base), and checks if any have new commits.
**Selection**: presents a list of skills with available updates. You pick which to update.
**Update**: merges each selected skill branch, resolves conflicts if any, then validates with build + test.
---
# Goal
Help users update their installed skill branches from upstream without losing local customizations.
# Operating principles
- Never proceed with a dirty working tree.
- Only offer updates for skills the user has already merged (installed).
- Use git-native operations. Do not manually rewrite files except conflict markers.
- Keep token usage low: rely on `git` commands, only open files with actual conflicts.
# Step 0: Preflight
Run:
- `git status --porcelain`
If output is non-empty:
- Tell the user to commit or stash first, then stop.
Check remotes:
- `git remote -v`
If `upstream` is missing:
- Ask the user for the upstream repo URL (default: `https://github.com/qwibitai/nanoclaw.git`).
- `git remote add upstream <url>`
Fetch:
- `git fetch upstream --prune`
# Step 1: Detect installed skills with available updates
List all upstream skill branches:
- `git branch -r --list 'upstream/skill/*'`
For each `upstream/skill/<name>`:
1. Check if the user has merged this skill branch before:
- `git merge-base --is-ancestor upstream/skill/<name>~1 HEAD` — if this succeeds (exit 0) for any ancestor commit of the skill branch, the user has merged it at some point. A simpler check: `git log --oneline --merges --grep="skill/<name>" HEAD` to see if there's a merge commit referencing this branch.
- Alternative: `MERGE_BASE=$(git merge-base HEAD upstream/skill/<name>)` — if the merge base is NOT the initial commit and the merge base includes commits unique to the skill branch, it has been merged.
- Simplest reliable check: compare `git merge-base HEAD upstream/skill/<name>` with `git merge-base HEAD upstream/main`. If the skill merge-base is strictly ahead of (or different from) the main merge-base, the user has merged this skill.
2. Check if there are new commits on the skill branch not yet in HEAD:
- `git log --oneline HEAD..upstream/skill/<name>`
- If this produces output, there are updates available.
Build three lists:
- **Updates available**: skills that are merged AND have new commits
- **Up to date**: skills that are merged and have no new commits
- **Not installed**: skills that have never been merged
# Step 2: Present results
If no skills have updates available:
- Tell the user all installed skills are up to date. List them.
- If there are uninstalled skills, mention them briefly (e.g., "3 other skills available in upstream that you haven't installed").
- Stop here.
If updates are available:
- Show the list of skills with updates, including the number of new commits for each:
```
skill/<name>: 3 new commits
skill/<other>: 1 new commit
```
- Also show skills that are up to date (for context).
- Use AskUserQuestion with `multiSelect: true` to let the user pick which skills to update.
- One option per skill with updates, labeled with the skill name and commit count.
- Add an option: "Skip — don't update any skills now"
- If user selects Skip, stop here.
# Step 3: Apply updates
For each selected skill (process one at a time):
1. Tell the user which skill is being updated.
2. Run: `git merge upstream/skill/<name> --no-edit`
3. If the merge is clean, move to the next skill.
4. If conflicts occur:
- Run `git status` to identify conflicted files.
- For each conflicted file:
- Open the file.
- Resolve only conflict markers.
- Preserve intentional local customizations.
- `git add <file>`
- Complete the merge: `git commit --no-edit`
If a merge fails badly (e.g., cannot resolve conflicts):
- `git merge --abort`
- Tell the user this skill could not be auto-updated and they should resolve it manually.
- Continue with the remaining skills.
# Step 4: Validation
After all selected skills are merged:
- `npm run build`
- `npm test` (do not fail the flow if tests are not configured)
If build fails:
- Show the error.
- Only fix issues clearly caused by the merge (missing imports, type mismatches).
- Do not refactor unrelated code.
- If unclear, ask the user.
# Step 5: Summary
Show:
- Skills updated (list)
- Skills skipped or failed (if any)
- New HEAD: `git rev-parse --short HEAD`
- Any conflicts that were resolved (list files)
If the service is running, remind the user to restart it to pick up changes.
+152
View File
@@ -0,0 +1,152 @@
---
name: use-local-whisper
description: Use when the user wants local voice transcription instead of OpenAI Whisper API. Switches to whisper.cpp running on Apple Silicon. WhatsApp only for now. Requires voice-transcription skill to be applied first.
---
# Use Local Whisper
Switches voice transcription from OpenAI's Whisper API to local whisper.cpp. Runs entirely on-device — no API key, no network, no cost.
**Channel support:** Currently WhatsApp only. The transcription module (`src/transcription.ts`) uses Baileys types for audio download. Other channels (Telegram, Discord, etc.) would need their own audio-download logic before this skill can serve them.
**Note:** The Homebrew package is `whisper-cpp`, but the CLI binary it installs is `whisper-cli`.
## Prerequisites
- `voice-transcription` skill must be applied first (WhatsApp channel)
- macOS with Apple Silicon (M1+) recommended
- `whisper-cpp` installed: `brew install whisper-cpp` (provides the `whisper-cli` binary)
- `ffmpeg` installed: `brew install ffmpeg`
- A GGML model file downloaded to `data/models/`
## Phase 1: Pre-flight
### Check if already applied
Check if `src/transcription.ts` already uses `whisper-cli`:
```bash
grep 'whisper-cli' src/transcription.ts && echo "Already applied" || echo "Not applied"
```
If already applied, skip to Phase 3 (Verify).
### Check dependencies are installed
```bash
whisper-cli --help >/dev/null 2>&1 && echo "WHISPER_OK" || echo "WHISPER_MISSING"
ffmpeg -version >/dev/null 2>&1 && echo "FFMPEG_OK" || echo "FFMPEG_MISSING"
```
If missing, install via Homebrew:
```bash
brew install whisper-cpp ffmpeg
```
### Check for model file
```bash
ls data/models/ggml-*.bin 2>/dev/null || echo "NO_MODEL"
```
If no model exists, download the base model (148MB, good balance of speed and accuracy):
```bash
mkdir -p data/models
curl -L -o data/models/ggml-base.bin "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin"
```
For better accuracy at the cost of speed, use `ggml-small.bin` (466MB) or `ggml-medium.bin` (1.5GB).
## Phase 2: Apply Code Changes
### Ensure WhatsApp fork remote
```bash
git remote -v
```
If `whatsapp` is missing, add it:
```bash
git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
```
### Merge the skill branch
```bash
git fetch whatsapp skill/local-whisper
git merge whatsapp/skill/local-whisper || {
git checkout --theirs package-lock.json
git add package-lock.json
git merge --continue
}
```
This modifies `src/transcription.ts` to use the `whisper-cli` binary instead of the OpenAI API.
### Validate
```bash
npm run build
```
## Phase 3: Verify
### Ensure launchd PATH includes Homebrew
The NanoClaw launchd service runs with a restricted PATH. `whisper-cli` and `ffmpeg` are in `/opt/homebrew/bin/` (Apple Silicon) or `/usr/local/bin/` (Intel), which may not be in the plist's PATH.
Check the current PATH:
```bash
grep -A1 'PATH' ~/Library/LaunchAgents/com.nanoclaw.plist
```
If `/opt/homebrew/bin` is missing, add it to the `<string>` value inside the `PATH` key in the plist. Then reload:
```bash
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
```
### Build and restart
```bash
npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
```
### Test
Send a voice note in any registered group. The agent should receive it as `[Voice: <transcript>]`.
### Check logs
```bash
tail -f logs/nanoclaw.log | grep -i -E "voice|transcri|whisper"
```
Look for:
- `Transcribed voice message` — successful transcription
- `whisper.cpp transcription failed` — check model path, ffmpeg, or PATH
## Configuration
Environment variables (optional, set in `.env`):
| Variable | Default | Description |
|----------|---------|-------------|
| `WHISPER_BIN` | `whisper-cli` | Path to whisper.cpp binary |
| `WHISPER_MODEL` | `data/models/ggml-base.bin` | Path to GGML model file |
## Troubleshooting
**"whisper.cpp transcription failed"**: Ensure both `whisper-cli` and `ffmpeg` are in PATH. The launchd service uses a restricted PATH — see Phase 3 above. Test manually:
```bash
ffmpeg -f lavfi -i anullsrc=r=16000:cl=mono -t 1 -f wav /tmp/test.wav -y
whisper-cli -m data/models/ggml-base.bin -f /tmp/test.wav --no-timestamps -nt
```
**Transcription works in dev but not as service**: The launchd plist PATH likely doesn't include `/opt/homebrew/bin`. See "Ensure launchd PATH includes Homebrew" in Phase 3.
**Slow transcription**: The base model processes ~30s of audio in <1s on M1+. If slower, check CPU usage — another process may be competing.
**Wrong language**: whisper.cpp auto-detects language. To force a language, you can set `WHISPER_LANG` and modify `src/transcription.ts` to pass `-l $WHISPER_LANG`.
@@ -0,0 +1,167 @@
---
name: use-native-credential-proxy
description: Replace OneCLI gateway with the built-in credential proxy. For users who want simple .env-based credential management without installing OneCLI. Reads API key or OAuth token from .env and injects into container API requests.
---
# Use Native Credential Proxy
This skill replaces the OneCLI gateway with NanoClaw's built-in credential proxy. Containers get credentials injected via a local HTTP proxy that reads from `.env` — no external services needed.
## Phase 1: Pre-flight
### Check if already applied
Check if `src/credential-proxy.ts` is imported in `src/index.ts`:
```bash
grep "credential-proxy" src/index.ts
```
If it shows an import for `startCredentialProxy`, the native proxy is already active. Skip to Phase 3 (Setup).
### Check if OneCLI is active
```bash
grep "@onecli-sh/sdk" package.json
```
If `@onecli-sh/sdk` appears, OneCLI is the active credential provider. Proceed with Phase 2 to replace it.
If neither check matches, you may be on an older version. Run `/update-nanoclaw` first, then retry.
## Phase 2: Apply Code Changes
### Ensure upstream remote
```bash
git remote -v
```
If `upstream` is missing, add it:
```bash
git remote add upstream https://github.com/qwibitai/nanoclaw.git
```
### Merge the skill branch
```bash
git fetch upstream skill/native-credential-proxy
git merge upstream/skill/native-credential-proxy || {
git checkout --theirs package-lock.json
git add package-lock.json
git merge --continue
}
```
This merges in:
- `src/credential-proxy.ts` and `src/credential-proxy.test.ts` (the proxy implementation)
- Restored credential proxy usage in `src/index.ts`, `src/container-runner.ts`, `src/container-runtime.ts`, `src/config.ts`
- Removed `@onecli-sh/sdk` dependency
- Restored `CREDENTIAL_PROXY_PORT` config (default 3001)
- Restored platform-aware proxy bind address detection
- Reverted setup skill to `.env`-based credential instructions
If the merge reports conflicts beyond `package-lock.json`, resolve them by reading the conflicted files and understanding the intent of both sides.
### Update main group CLAUDE.md
Replace the OneCLI auth reference with the native proxy:
In `groups/main/CLAUDE.md`, replace:
> OneCLI manages credentials (including Anthropic auth) — run `onecli --help`.
with:
> The native credential proxy manages credentials (including Anthropic auth) via `.env` — see `src/credential-proxy.ts`.
### Validate code changes
```bash
npm install
npm run build
npx vitest run src/credential-proxy.test.ts src/container-runner.test.ts
```
All tests must pass and build must be clean before proceeding.
## Phase 3: Setup Credentials
AskUserQuestion: Do you want to use your **Claude subscription** (Pro/Max) or an **Anthropic API key**?
1. **Claude subscription (Pro/Max)** — description: "Uses your existing Claude Pro or Max subscription. You'll run `claude setup-token` in another terminal to get your token."
2. **Anthropic API key** — description: "Pay-per-use API key from console.anthropic.com."
### Subscription path
Tell the user to run `claude setup-token` in another terminal and copy the token it outputs. Do NOT collect the token in chat.
Once they have the token, add it to `.env`:
```bash
# Add to .env (create file if needed)
echo 'CLAUDE_CODE_OAUTH_TOKEN=<token>' >> .env
```
Note: `ANTHROPIC_AUTH_TOKEN` is also supported as a fallback.
### API key path
Tell the user to get an API key from https://console.anthropic.com/settings/keys if they don't have one.
Add it to `.env`:
```bash
echo 'ANTHROPIC_API_KEY=<key>' >> .env
```
### After either path
**If the user's response happens to contain a token or key** (starts with `sk-ant-` or looks like a token): write it to `.env` on their behalf using the appropriate variable name.
**Optional:** If the user needs a custom API endpoint, they can add `ANTHROPIC_BASE_URL=<url>` to `.env` (defaults to `https://api.anthropic.com`).
## Phase 4: Verify
1. Rebuild and restart:
```bash
npm run build
```
Then restart the service:
- macOS: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
- Linux: `systemctl --user restart nanoclaw`
- WSL/manual: stop and re-run `bash start-nanoclaw.sh`
2. Check logs for successful proxy startup:
```bash
tail -20 logs/nanoclaw.log | grep "Credential proxy"
```
Expected: `Credential proxy started` with port and auth mode.
3. Send a test message in the registered chat to verify the agent responds.
4. Note: after applying this skill, the OneCLI credential steps in `/setup` no longer apply. `.env` is now the credential source.
## Troubleshooting
**"Credential proxy upstream error" in logs:** Check that `.env` has a valid `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN`. Verify the API is reachable: `curl -s https://api.anthropic.com/v1/messages -H "x-api-key: test" | head`.
**Port 3001 already in use:** Set `CREDENTIAL_PROXY_PORT=<other port>` in `.env` or as an environment variable.
**Container can't reach proxy (Linux):** The proxy binds to the `docker0` bridge IP by default. If that interface doesn't exist (e.g. rootless Docker), set `CREDENTIAL_PROXY_HOST=0.0.0.0` as an environment variable.
**OAuth token expired (401 errors):** Re-run `claude setup-token` in a terminal and update the token in `.env`.
## Removal
To revert to OneCLI gateway:
1. Find the merge commit: `git log --oneline --merges -5`
2. Revert it: `git revert <merge-commit> -m 1` (undoes the skill branch merge, keeps your other changes)
3. `npm install` (re-adds `@onecli-sh/sdk`)
4. `npm run build`
5. Follow `/setup` step 4 to configure OneCLI credentials
6. Remove `ANTHROPIC_API_KEY` / `CLAUDE_CODE_OAUTH_TOKEN` from `.env`
+1 -5
View File
@@ -8,12 +8,8 @@
import { spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import pino from 'pino';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: { target: 'pino-pretty', options: { colorize: true } }
});
import { logger } from '../../../src/logger.js';
interface SkillResult {
success: boolean;
-1
View File
@@ -1 +0,0 @@
+7 -3
View File
@@ -1,14 +1,18 @@
<!-- contributing-guide: v1 -->
## Type of Change
- [ ] **Skill** - adds a new skill in `.claude/skills/`
- [ ] **Feature skill** - adds a channel or integration (source code changes + SKILL.md)
- [ ] **Utility skill** - adds a standalone tool (code files in `.claude/skills/<name>/`, no source changes)
- [ ] **Operational/container skill** - adds a workflow or agent skill (SKILL.md only, no source changes)
- [ ] **Fix** - bug fix or security fix to source code
- [ ] **Simplification** - reduces or simplifies source code
- [ ] **Documentation** - docs, README, or CONTRIBUTING changes only
## Description
## For Skills
- [ ] I have not made any changes to source code
- [ ] My skill contains instructions for Claude to follow (not pre-built code)
- [ ] SKILL.md contains instructions, not inline code (code goes in separate files)
- [ ] SKILL.md is under 500 lines
- [ ] I tested this skill on a fresh clone
+1
View File
@@ -7,6 +7,7 @@ on:
jobs:
bump-version:
if: github.repository == 'qwibitai/nanoclaw'
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v1
+35
View File
@@ -0,0 +1,35 @@
name: Label PR
on:
pull_request:
types: [opened, edited]
jobs:
label:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: actions/github-script@v7
with:
script: |
const body = context.payload.pull_request.body || '';
const labels = [];
if (body.includes('[x] **Feature skill**')) { labels.push('PR: Skill'); labels.push('PR: Feature'); }
else if (body.includes('[x] **Utility skill**')) labels.push('PR: Skill');
else if (body.includes('[x] **Operational/container skill**')) labels.push('PR: Skill');
else if (body.includes('[x] **Fix**')) labels.push('PR: Fix');
else if (body.includes('[x] **Simplification**')) labels.push('PR: Refactor');
else if (body.includes('[x] **Documentation**')) labels.push('PR: Docs');
if (body.includes('contributing-guide: v1')) labels.push('follows-guidelines');
if (labels.length > 0) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
labels,
});
}
-102
View File
@@ -1,102 +0,0 @@
name: Skill Drift Detection
# Runs after every push to main that touches source files.
# Validates every skill can still be cleanly applied, type-checked, and tested.
# If a skill drifts, attempts auto-fix via three-way merge of modify/ files,
# then opens a PR with the result (auto-fixed or with conflict markers).
on:
push:
branches: [main]
paths:
- 'src/**'
- 'container/**'
- 'package.json'
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
# ── Step 1: Check all skills against current main ─────────────────────
validate:
runs-on: ubuntu-latest
outputs:
drifted: ${{ steps.check.outputs.drifted }}
drifted_skills: ${{ steps.check.outputs.drifted_skills }}
results: ${{ steps.check.outputs.results }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Validate all skills against main
id: check
run: npx tsx scripts/validate-all-skills.ts
continue-on-error: true
# ── Step 2: Auto-fix and create PR ────────────────────────────────────
fix-drift:
needs: validate
if: needs.validate.outputs.drifted == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/checkout@v4
with:
token: ${{ steps.app-token.outputs.token }}
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Attempt auto-fix via three-way merge
id: fix
run: |
SKILLS=$(echo '${{ needs.validate.outputs.drifted_skills }}' | jq -r '.[]')
npx tsx scripts/fix-skill-drift.ts $SKILLS
- name: Create pull request
uses: peter-evans/create-pull-request@v7
with:
token: ${{ steps.app-token.outputs.token }}
branch: ci/fix-skill-drift
delete-branch: true
title: 'fix(skills): auto-update drifted skills'
body: |
## Skill Drift Detected
A push to `main` (${{ github.sha }}) changed source files that caused
the following skills to fail validation:
**Drifted:** ${{ needs.validate.outputs.drifted_skills }}
### Auto-fix results
${{ steps.fix.outputs.summary }}
### What to do
1. Review the changes to `.claude/skills/*/modify/` files
2. If there are conflict markers (`<<<<<<<`), resolve them
3. CI will run typecheck + tests on this PR automatically
4. Merge when green
---
*Auto-generated by [skill-drift CI](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})*
labels: skill-drift,automated
commit-message: 'fix(skills): auto-update drifted skill modify/ files'
-151
View File
@@ -1,151 +0,0 @@
name: Skill PR Validation
on:
pull_request:
branches: [main]
paths:
- '.claude/skills/**'
- 'skills-engine/**'
jobs:
# ── Job 1: Policy gate ────────────────────────────────────────────────
# Block PRs that add NEW skill files while also modifying source code.
# Skill PRs should contain instructions for Claude, not raw source edits.
policy-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check for mixed skill + source changes
run: |
ADDED_SKILLS=$(git diff --name-only --diff-filter=A origin/main...HEAD \
| grep '^\\.claude/skills/' || true)
CHANGED=$(git diff --name-only origin/main...HEAD)
SOURCE=$(echo "$CHANGED" \
| grep -E '^src/|^container/|^package\.json|^package-lock\.json' || true)
if [ -n "$ADDED_SKILLS" ] && [ -n "$SOURCE" ]; then
echo "::error::PRs that add new skills should not modify source files."
echo ""
echo "New skill files:"
echo "$ADDED_SKILLS"
echo ""
echo "Source files:"
echo "$SOURCE"
echo ""
echo "Please split into separate PRs. See CONTRIBUTING.md."
exit 1
fi
- name: Comment on failure
if: failure()
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `This PR adds a skill while also modifying source code. A skill PR should not change source files—the skill should contain **instructions** for Claude to follow.
If you're fixing a bug or simplifying code, please submit that as a separate PR.
See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/main/CONTRIBUTING.md) for details.`
})
# ── Job 2: Detect which skills changed ────────────────────────────────
detect-changed:
runs-on: ubuntu-latest
outputs:
skills: ${{ steps.detect.outputs.skills }}
has_skills: ${{ steps.detect.outputs.has_skills }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect changed skills
id: detect
run: |
CHANGED_SKILLS=$(git diff --name-only origin/main...HEAD \
| grep '^\\.claude/skills/' \
| sed 's|^\.claude/skills/||' \
| cut -d/ -f1 \
| sort -u \
| jq -R . | jq -s .)
echo "skills=$CHANGED_SKILLS" >> "$GITHUB_OUTPUT"
if [ "$CHANGED_SKILLS" = "[]" ]; then
echo "has_skills=false" >> "$GITHUB_OUTPUT"
else
echo "has_skills=true" >> "$GITHUB_OUTPUT"
fi
echo "Changed skills: $CHANGED_SKILLS"
# ── Job 3: Validate each changed skill in isolation ───────────────────
validate-skills:
needs: detect-changed
if: needs.detect-changed.outputs.has_skills == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
skill: ${{ fromJson(needs.detect-changed.outputs.skills) }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Initialize skills system
run: >-
npx tsx -e
"import { initNanoclawDir } from './skills-engine/index'; initNanoclawDir();"
- name: Apply skill
run: npx tsx scripts/apply-skill.ts ".claude/skills/${{ matrix.skill }}"
- name: Typecheck after apply
run: npx tsc --noEmit
- name: Run skill tests
run: |
TEST_CMD=$(npx tsx -e "
import { parse } from 'yaml';
import fs from 'fs';
const m = parse(fs.readFileSync('.claude/skills/${{ matrix.skill }}/manifest.yaml', 'utf-8'));
if (m.test) console.log(m.test);
")
if [ -n "$TEST_CMD" ]; then
echo "Running: $TEST_CMD"
eval "$TEST_CMD"
else
echo "No test command defined, skipping"
fi
# ── Summary gate for branch protection ────────────────────────────────
skill-validation-summary:
needs:
- policy-check
- detect-changed
- validate-skills
if: always()
runs-on: ubuntu-latest
steps:
- name: Check results
run: |
echo "policy-check: ${{ needs.policy-check.result }}"
echo "validate-skills: ${{ needs.validate-skills.result }}"
if [ "${{ needs.policy-check.result }}" = "failure" ]; then
echo "::error::Policy check failed"
exit 1
fi
if [ "${{ needs.validate-skills.result }}" = "failure" ]; then
echo "::error::Skill validation failed"
exit 1
fi
echo "All skill checks passed"
+1
View File
@@ -8,6 +8,7 @@ on:
jobs:
update-tokens:
if: github.repository == 'qwibitai/nanoclaw'
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v1
+139 -2
View File
@@ -2,6 +2,143 @@
All notable changes to NanoClaw will be documented in this file.
## [1.2.0](https://github.com/qwibitai/nanoclaw/compare/v1.1.6...v1.2.0)
For detailed release notes, see the [full changelog on the documentation site](https://docs.nanoclaw.dev/changelog).
[BREAKING] WhatsApp removed from core, now a skill. Run `/add-whatsapp` to re-add (existing auth/groups preserved).
## [1.2.35] - 2026-03-26
- [BREAKING] OneCLI Agent Vault replaces the built-in credential proxy. Existing `.env` credentials must be migrated to the vault. Run `/init-onecli` to install OneCLI and migrate credentials.
## [1.2.21] - 2026-03-22
- Added opt-in diagnostics via PostHog with explicit user consent (Yes / No / Never ask again)
## [1.2.20] - 2026-03-21
- Added ESLint configuration with error-handling rules
## [1.2.19] - 2026-03-19
- Reduced `docker stop` timeout for faster container restarts (`-t 1` flag)
## [1.2.18] - 2026-03-19
- User prompt content no longer logged on container errors — only input metadata
- Added Japanese README translation
## [1.2.17] - 2026-03-18
- Added `/capabilities` and `/status` container-agent skills
## [1.2.16] - 2026-03-18
- Tasks snapshot now refreshes immediately after IPC task mutations
## [1.2.15] - 2026-03-16
- Fixed remote-control prompt auto-accept to prevent immediate exit
- Added `KillMode=process` so remote-control survives service restarts
## [1.2.14] - 2026-03-14
- Added `/remote-control` command for host-level Claude Code access from within containers
## [1.2.13] - 2026-03-14
**Breaking:** Skills are now git branches, channels are separate fork repos.
- Skills live as `skill/*` git branches merged via `git merge`
- Added Docker Sandboxes support
- Fixed setup registration to use correct CLI commands
## [1.2.12] - 2026-03-08
- Added `/compact` skill for manual context compaction
- Enhanced container environment isolation via credential proxy
## [1.2.11] - 2026-03-08
- Added PDF reader, image vision, and WhatsApp reactions skills
- Fixed task container to close promptly when agent uses IPC-only messaging
## [1.2.10] - 2026-03-06
- Added `LIMIT` to unbounded message history queries for better performance
## [1.2.9] - 2026-03-06
- Agent prompts now include timezone context for accurate time references
## [1.2.8] - 2026-03-06
- Fixed misleading `send_message` tool description for scheduled tasks
## [1.2.7] - 2026-03-06
- Added `/add-ollama` skill for local model inference
- Added `update_task` tool and return task ID from `schedule_task`
## [1.2.6] - 2026-03-04
- Updated `claude-agent-sdk` to 0.2.68
## [1.2.5] - 2026-03-04
- CI formatting fix
## [1.2.4] - 2026-03-04
- Fixed `_chatJid` rename to `chatJid` in `onMessage` callback
## [1.2.3] - 2026-03-04
- Added sender allowlist for per-chat access control
## [1.2.2] - 2026-03-04
- Added `/use-local-whisper` skill for local voice transcription
- Atomic task claims prevent scheduled tasks from executing twice
## [1.2.1] - 2026-03-02
- Version bump (no functional changes)
## [1.2.0] - 2026-03-02
**Breaking:** WhatsApp removed from core, now a skill. Run `/add-whatsapp` to re-add.
- Channel registry: channels self-register at startup via `registerChannel()` factory pattern
- `isMain` flag replaces folder-name-based main group detection
- `ENABLED_CHANNELS` removed — channels detected by credential presence
- Prevent scheduled tasks from executing twice when container runtime exceeds poll interval
## [1.1.6] - 2026-03-01
- Added CJK font support for Chromium screenshots
## [1.1.5] - 2026-03-01
- Fixed wrapped WhatsApp message normalization
## [1.1.4] - 2026-03-01
- Added third-party model support
- Added `/update-nanoclaw` skill for syncing with upstream
## [1.1.3] - 2026-02-25
- Added `/add-slack` skill
- Restructured Gmail skill for new architecture
## [1.1.2] - 2026-02-24
- Improved error handling for WhatsApp Web version fetch
## [1.1.1] - 2026-02-24
- Added Qodo skills and codebase intelligence
- Fixed WhatsApp 405 connection failures
## [1.1.0] - 2026-02-23
- Added `/update` skill to pull upstream changes from within Claude Code
- Enhanced container environment isolation via credential proxy
+17 -1
View File
@@ -19,19 +19,35 @@ Single Node.js process with skill-based channel system. Channels (WhatsApp, Tele
| `src/task-scheduler.ts` | Runs scheduled tasks |
| `src/db.ts` | SQLite operations |
| `groups/{name}/CLAUDE.md` | Per-group memory (isolated) |
| `container/skills/agent-browser.md` | Browser automation tool (available to all agents via Bash) |
| `container/skills/` | Skills loaded inside agent containers (browser, status, formatting) |
## Secrets / Credentials / Proxy (OneCLI)
API keys, secret keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway — which handles secret injection into containers at request time, so no keys or tokens are ever passed to containers directly. Run `onecli --help`.
## Skills
Four types of skills exist in NanoClaw. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxonomy and guidelines.
- **Feature skills** — merge a `skill/*` branch to add capabilities (e.g. `/add-telegram`, `/add-slack`)
- **Utility skills** — ship code files alongside SKILL.md (e.g. `/claw`)
- **Operational skills** — instruction-only workflows, always on `main` (e.g. `/setup`, `/debug`)
- **Container skills** — loaded inside agent containers at runtime (`container/skills/`)
| Skill | When to Use |
|-------|-------------|
| `/setup` | First-time installation, authentication, service configuration |
| `/customize` | Adding channels, integrations, changing behavior |
| `/debug` | Container issues, logs, troubleshooting |
| `/update-nanoclaw` | Bring upstream NanoClaw updates into a customized install |
| `/init-onecli` | Install OneCLI Agent Vault and migrate `.env` credentials to it |
| `/qodo-pr-resolver` | Fetch and fix Qodo PR review issues interactively or in batch |
| `/get-qodo-rules` | Load org- and repo-level coding rules from Qodo before code tasks |
## Contributing
Before creating a PR, adding a skill, or preparing any contribution, you MUST read [CONTRIBUTING.md](CONTRIBUTING.md). It covers accepted change types, the four skill types and their guidelines, SKILL.md format rules, PR requirements, and the pre-submission checklist (searching for existing PRs/issues, testing, description format).
## Development
Run commands directly—don't tell the user to run them.

Some files were not shown because too many files have changed in this diff Show More