mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-04 10:14:47 +08:00
Compare commits
455 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96180436e9 | |||
| 934f063aff | |||
| 32a487b96b | |||
| 751a9ed2d1 | |||
| 22d7856ce0 | |||
| ca9333d48d | |||
| 6c289c3a80 | |||
| b8cf30830b | |||
| 5702760206 | |||
| 653390d9aa | |||
| 3381509e69 | |||
| 19ce90c663 | |||
| 0918f78a0c | |||
| 4fd75860cd | |||
| 5adc9497b3 | |||
| 1d5c38d15a | |||
| 75c2e1868f | |||
| f77f9ce2c4 | |||
| 27f9f0ca32 | |||
| 0c67fbf456 | |||
| 15e356a572 | |||
| 33b5627f42 | |||
| f69979fb9e | |||
| 54bf4543f2 | |||
| 36943fbcfd | |||
| 1488c5b251 | |||
| 22ab96ccac | |||
| 391b729623 | |||
| 3703c9decb | |||
| c5cb97b761 | |||
| 761d3a1b30 | |||
| cbb4da19c7 | |||
| b752e5cd34 | |||
| a74be06956 | |||
| d4a6b4a3b5 | |||
| 67020f9fbf | |||
| 9019e4e3b8 | |||
| 8a02170b21 | |||
| db3440f662 | |||
| b2a5a58f8a | |||
| 426ae0285e | |||
| 7ef1c4f5e0 | |||
| f60bb3c3d5 | |||
| 3608f05233 | |||
| 8f28cde41d | |||
| 032ba77a7f | |||
| e9db4d461d | |||
| 584114118d | |||
| bf11109825 | |||
| 6f93b20cd1 | |||
| f23a54aea0 | |||
| 6e0653f537 | |||
| ee599b9f0c | |||
| 7b337a7a07 | |||
| 3e2895987b | |||
| 22f5d55855 | |||
| 51f50bbe85 | |||
| 4c7bc80299 | |||
| 87e89147c9 | |||
| 7b0d79a6f3 | |||
| 468c6170a0 | |||
| 4c8b9cda93 | |||
| 78bfb8df85 | |||
| a86641f69e | |||
| 59c09effcb | |||
| 001ee6ec48 | |||
| 9d97f79476 | |||
| d675859c24 | |||
| 38009be263 | |||
| 3098f28b74 | |||
| 474346e214 | |||
| 29839464bf | |||
| a3fb3beb6a | |||
| 3ab833b4eb | |||
| 8c4ab36ef2 | |||
| 8bb8e036e4 | |||
| 37aee02b46 | |||
| 90af26a6b1 | |||
| fff37d590c | |||
| c3e9a892c2 | |||
| acb0abaf8b | |||
| 4f1b09fcb6 | |||
| fa4ace423c | |||
| e6e0c6fa9e | |||
| c5e0001637 | |||
| e73bf2f324 | |||
| c98205ca0d | |||
| 2faf1c6e19 | |||
| 842ec5fd30 | |||
| 017a72d57d | |||
| bd94c8144a | |||
| 6e602a1f5b | |||
| 415a1cfd44 | |||
| fee05f7ee8 | |||
| 877650541a | |||
| c923f07829 | |||
| f138f25c79 | |||
| e9e9e05290 | |||
| 5b7b0867da | |||
| e606eac91d | |||
| 8935e4f636 | |||
| f900670aaf | |||
| 62fc8c7708 | |||
| 7e7492ebba | |||
| 7b22e23761 | |||
| 2f472a8600 | |||
| 8f01a9a05e | |||
| a4591ab5e0 | |||
| 3332da03af | |||
| f5375972c4 | |||
| 0f01fe2c07 | |||
| a4fd4f2a2f | |||
| 4383e3e61a | |||
| 1f5cc760a7 | |||
| 722c8ee595 | |||
| 730ea0d713 | |||
| 637545dfca | |||
| 4588579622 | |||
| eda14f472b | |||
| a29ca0835c | |||
| 813e1c6fa4 | |||
| d25b79a5a9 | |||
| a41746530f | |||
| d398ba5ac6 | |||
| 8b53a95a5f | |||
| 4c6d9241d4 | |||
| 54a8648c95 | |||
| 87c3640cfc | |||
| e4f15b659e | |||
| 349b54ae9e | |||
| 9413ace113 | |||
| 2c447085b5 | |||
| 2cddefbef4 | |||
| 125757bc7d | |||
| 2483cb3e2a | |||
| c16d70cdf7 | |||
| f7979bfa11 | |||
| 271acf9101 | |||
| ab9613a2b0 | |||
| 68c59a1abf | |||
| b16fe4d9fc | |||
| 5f385974e7 | |||
| deb5389077 | |||
| 7bfd060536 | |||
| 255e139433 | |||
| 3699363eb7 | |||
| 3a26f69c7f | |||
| aae173d86f | |||
| 23e9e1c150 | |||
| 8b6e9d6cf6 | |||
| 77b7c658d6 | |||
| 5954dfb3e7 | |||
| 1f36232ef0 | |||
| e9e6d987ac | |||
| 608f935ad7 | |||
| b2fa85b04a | |||
| 7bba21af1e | |||
| 22c1186f16 | |||
| b71414957d | |||
| 6d4f972ad0 | |||
| 36a8ec643f | |||
| 28937938b2 | |||
| b8f6a9b794 | |||
| 89681a6d0d | |||
| fd444681ef | |||
| 72404968e1 | |||
| 115b0a3167 | |||
| 17c63b94a2 | |||
| ff4075d9cb | |||
| 8824a84afe | |||
| 627f13a83c | |||
| df76dc6797 | |||
| bb736f37f2 | |||
| f3644f123e | |||
| deece6bf35 | |||
| 9391304e70 | |||
| 31c03cf924 | |||
| 33ff3b8c03 | |||
| b112fafff4 | |||
| 300dcda9c9 | |||
| 0240f48751 | |||
| b7434b8a76 | |||
| 15b9aa99ff | |||
| 80f6fb2b9a | |||
| 5395b732a5 | |||
| cf5fa1daf0 | |||
| d4073a01c5 | |||
| d622a79fe2 | |||
| 6e5834ee3c | |||
| 093530a418 | |||
| 675a6d87a3 | |||
| e60eb6dea2 | |||
| 63f680d0be | |||
| 1b18d50ae4 | |||
| 4e3189da8f | |||
| 2c46d74066 | |||
| aeabfcc65a | |||
| 5d5b90448c | |||
| 341b8df0a2 | |||
| f375dd5011 | |||
| 6d4e251534 | |||
| 11847a1af0 | |||
| 616c1ae10a | |||
| 2142f03eaf | |||
| 4d853c5d38 | |||
| e26e1b3e68 | |||
| bf9b7d0311 | |||
| 57e520c7e1 | |||
| 69348510e9 | |||
| 2f1d7fe98b | |||
| b7f59da70a | |||
| 8d0baac892 | |||
| 17a72938be | |||
| e6df18ca8b | |||
| 1fff99ffb8 | |||
| 4511644d0d | |||
| 58faf624a3 | |||
| 7d640cb9f6 | |||
| 86063e0ea0 | |||
| 8fc42e4f82 | |||
| 7366b0d7db | |||
| 0015931e37 | |||
| d05a8dec49 | |||
| 35722801e3 | |||
| 07cf1fb8a5 | |||
| 14247d0b57 | |||
| d1ce15a4de | |||
| b0671ef9e6 | |||
| 81f6703102 | |||
| 5b24dd4d2e | |||
| d8cc230227 | |||
| 57085cc02e | |||
| 3207c35e50 | |||
| 07dc8c977c | |||
| b6e18688c2 | |||
| 5a12ddd4cb | |||
| 8dcc70cf5c | |||
| 01b6258f59 | |||
| 724fe7250d | |||
| 5f42646598 | |||
| ff16e93713 | |||
| 4f7efd3c67 | |||
| def3748d02 | |||
| d40affbdef | |||
| 2583af7ead | |||
| 7f6298a1bb | |||
| b7f8c20a25 | |||
| e9369617fb | |||
| 00ff0e00eb | |||
| 0d8f7f8668 | |||
| deee4b2a96 | |||
| 4f60be7803 | |||
| 02d51afe09 | |||
| a4fbc9d615 | |||
| f97394656c | |||
| 09d833c310 | |||
| f33c66b046 | |||
| e2423171e1 | |||
| e10b136df6 | |||
| 31ac74f5f2 | |||
| d96be5ddfd | |||
| fff32f3028 | |||
| d768a04843 | |||
| 1bb065e655 | |||
| 8c3979556a | |||
| ea7561a978 | |||
| ec1b14504b | |||
| cfc4b6c28e | |||
| 3cc30501dd | |||
| bf1e2a3819 | |||
| b2377bb390 | |||
| 18469294ce | |||
| dad98b0a8f | |||
| c3b19876eb | |||
| f1150ac624 | |||
| b30b5a6a8f | |||
| 30ebcaa61e | |||
| 3e41e54e10 | |||
| b7420c6562 | |||
| 656d7f7bff | |||
| 0ce11f6f4d | |||
| 1734be7259 | |||
| 8c1d5598ba | |||
| 3747dfeacc | |||
| 33874de175 | |||
| f04a8955aa | |||
| 4d9f0288ee | |||
| 91f17a11b2 | |||
| 972edd14f6 | |||
| b5d0f1e5aa | |||
| cf3d9dcbd5 | |||
| fd59ff0ec9 | |||
| 7a8c24b092 | |||
| e2e32219c9 | |||
| c78042e90e | |||
| 6dc23fda80 | |||
| cf899049f7 | |||
| c601aaa947 | |||
| fc2cc5368f | |||
| b7f1d48423 | |||
| a4dc3a7446 | |||
| 9f5aff99b6 | |||
| 42d098c3c1 | |||
| eb65121938 | |||
| 0f283cbdd3 | |||
| a516cc5cfe | |||
| 675acffeb1 | |||
| c75de24029 | |||
| d43d53244f | |||
| c71c7b7e83 | |||
| fe0a309325 | |||
| f2ed7fe490 | |||
| 96852f686e | |||
| e8326bae62 | |||
| e7d0ffb208 | |||
| d71ffaf7ef | |||
| 9200612dd1 | |||
| aa4f7a27ae | |||
| 0c495b0efe | |||
| f629f9361a | |||
| 5b5ee91aa7 | |||
| c8f03eddeb | |||
| 8b647410c6 | |||
| 2007471f4f | |||
| 4b53ce008b | |||
| 260812702c | |||
| 12ff2589fa | |||
| 924482870e | |||
| d49af91cc2 | |||
| de62ef6b3f | |||
| 8cbd715ee2 | |||
| 9e5dde6ebb | |||
| 9e90c0712e | |||
| fb66428eeb | |||
| 9b82611dc1 | |||
| 2317302745 | |||
| 4e7eb3e278 | |||
| 82206570d1 | |||
| c984e6f13d | |||
| 3d649c386e | |||
| 6f40ed148c | |||
| cb20038956 | |||
| e2b0d2d0aa | |||
| b247357e0d | |||
| 2640973b41 | |||
| e7318be0a2 | |||
| 662e81fc9e | |||
| 54a55affa4 | |||
| d1975462c4 | |||
| 696ae5e872 | |||
| 4dd27adb84 | |||
| c0902877fa | |||
| cc4f03a203 | |||
| 38ebb31e6d | |||
| fedfaf3f50 | |||
| df9ba0e5f9 | |||
| e6ff5c640c | |||
| 6f64b31d03 | |||
| c7391757ac | |||
| 3414625a6d | |||
| 2a90f98138 | |||
| 49595b9c70 | |||
| 48d352a142 | |||
| d81f8e1221 | |||
| f210fd5049 | |||
| 7d5f322ae6 | |||
| d000acc687 | |||
| 4bc232e513 | |||
| 7e9a698aa1 | |||
| c9d1569702 | |||
| 1f2e930d16 | |||
| 2dedd15ec7 | |||
| cb9fba8472 | |||
| f530806c96 | |||
| 845da49fa3 | |||
| 272cbcf18f | |||
| 5b20e2908a | |||
| 0cfdde46c6 | |||
| 089fcea474 | |||
| 04fb44e417 | |||
| 7061480ac0 | |||
| bd64fd667d | |||
| d8a1ee8c3c | |||
| 51ad949979 | |||
| 018deca3ef | |||
| 15ed3cf2a6 | |||
| 107f9742a9 | |||
| 5a0bda8d37 | |||
| 9a4fb61f6e | |||
| 5ca0633c27 | |||
| f0ac7fbb6d | |||
| d572bab5c6 | |||
| 207addfa19 | |||
| 2eb5871454 | |||
| 621fde8c75 | |||
| f41b399aa1 | |||
| 4dee68c230 | |||
| b913a37c21 | |||
| d487faf55a | |||
| e6ea914ef1 | |||
| 8564937d93 | |||
| 5118239cea | |||
| 5acab2c09d | |||
| 27e241c13e | |||
| 4afb5bd9f1 | |||
| e7852a45a5 | |||
| 13ce4aaf67 | |||
| 16559e9dbf | |||
| dfcdfcac11 | |||
| 8521e42f7b | |||
| d33e514d04 | |||
| a6dc297722 | |||
| 4cb13b2b60 | |||
| 83b91b3bf1 | |||
| 4ccc5c57f2 | |||
| a689a18dfa | |||
| ab9abbb21a | |||
| 5b2bafd7bb | |||
| 32dda34af4 | |||
| cfabdd816b | |||
| af937d6453 | |||
| be1991108b | |||
| 0b260ece57 | |||
| 1e89d61928 | |||
| cf99b833b0 | |||
| 74b02c8715 | |||
| 8c38a8c7ff | |||
| 24c6fa25b1 | |||
| 632713b208 | |||
| 47ad2e654c | |||
| 5a4e32623f | |||
| ec0e42b034 | |||
| ced0068738 | |||
| c48314d813 | |||
| 68123fdd81 | |||
| e14c8580af | |||
| 298c3eade4 | |||
| 6ad6328885 | |||
| 5955cd6ee5 | |||
| 590dc2193c | |||
| df2bac61f0 | |||
| 5aed615ac5 | |||
| 1436186c75 | |||
| 3d17e95c1b | |||
| cd3c2f06d4 | |||
| 4de981b9b9 | |||
| a5845a5cf4 | |||
| 32d5827fe4 | |||
| f794185c21 | |||
| 03f792bfce | |||
| 5e3d8b6c2c | |||
| 58dec06e4c | |||
| 8e664e6ba3 | |||
| 21a4eaf6a0 | |||
| e7bad73515 |
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"sandbox": {
|
||||
"network": {
|
||||
"allowedDomains": [
|
||||
"npm.registry.com",
|
||||
"us.i.posthog.com"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -107,11 +85,27 @@ All tests must pass (including the new gmail tests) and build must be clean befo
|
||||
ls -la ~/.gmail-mcp/ 2>/dev/null || echo "No Gmail config found"
|
||||
```
|
||||
|
||||
If `credentials.json` already exists, skip to "Build and restart" below.
|
||||
If `credentials.json` already exists with real tokens (not `onecli-managed` values), skip to "Build and restart" below.
|
||||
|
||||
### GCP Project Setup
|
||||
|
||||
Tell the user:
|
||||
Check if OneCLI is configured:
|
||||
|
||||
```bash
|
||||
grep -q 'ONECLI_URL=.' .env 2>/dev/null && echo "onecli" || echo "manual"
|
||||
```
|
||||
|
||||
**If OneCLI:** Tell the user to open `${ONECLI_URL}/connections?connect=gmail` to set up their Gmail connection. The dashboard walks them through creating a Google Cloud OAuth app and authorizing it. Ask them to let you know when done.
|
||||
|
||||
Once the user confirms, run:
|
||||
|
||||
```bash
|
||||
onecli apps get --provider gmail
|
||||
```
|
||||
|
||||
Check that `config.hasCredentials` is `true` or `connection` is not null. The response `hint` field has instructions and a docs URL for what stub credential files to create under `~/.gmail-mcp/`. Follow the hint — never overwrite existing files that don't contain `onecli-managed` values.
|
||||
|
||||
**If manual:** Tell the user:
|
||||
|
||||
> I need you to set up Google Cloud OAuth credentials:
|
||||
>
|
||||
@@ -226,7 +220,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 +231,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);
|
||||
});
|
||||
@@ -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'");
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
name: add-karpathy-llm-wiki
|
||||
description: Add a persistent wiki knowledge base to a NanoClaw group. Based on Karpathy's LLM Wiki pattern. Triggers on "add wiki", "wiki", "knowledge base", "llm wiki", "karpathy wiki".
|
||||
---
|
||||
|
||||
# Add Karpathy LLM Wiki
|
||||
|
||||
Set up a persistent wiki knowledge base on NanoClaw, based on Karpathy's LLM Wiki pattern.
|
||||
|
||||
## Step 1: Read the pattern
|
||||
|
||||
Read `${CLAUDE_SKILL_DIR}/llm-wiki.md` — this is the full LLM Wiki idea as written by Karpathy. Understand it thoroughly before proceeding. Summarize the core idea to the user briefly, then discuss what they want to build.
|
||||
|
||||
## Step 2: Choose a group
|
||||
|
||||
AskUserQuestion: "Which group should have the wiki?"
|
||||
|
||||
1. **Main group** — add to your existing main chat
|
||||
2. **Dedicated group** — create a new group just for the wiki
|
||||
3. **Other** — pick an existing group
|
||||
|
||||
If dedicated: ask which channel and chat, then register with `npx tsx setup/index.ts --step register`.
|
||||
|
||||
## Step 3: Design collaboratively
|
||||
|
||||
Discuss with the user based on the pattern:
|
||||
- What's the wiki's domain or topic?
|
||||
- What kinds of sources will they add? (URLs, PDFs, images, voice notes, books, transcripts)
|
||||
- Do they want the full three-layer architecture or a lighter version?
|
||||
- Any specific conventions they care about? (The pattern intentionally leaves this open.)
|
||||
|
||||
Based on this discussion, create three things:
|
||||
|
||||
### 3a. Directory structure
|
||||
|
||||
Create `wiki/` and `sources/` directories in the group folder. Create initial `index.md` and `log.md` per the pattern's Indexing and Logging section. Adapt to the user's domain.
|
||||
|
||||
### 3b. Container skill
|
||||
|
||||
Create a `container/skills/wiki/SKILL.md` tailored to this user's wiki. This is the schema layer from the pattern — it tells the agent how to maintain the wiki. Base it on the pattern's Operations section (ingest, query, lint) and the conventions you agreed on with the user. Don't over-prescribe — the pattern says "your LLM figures out the rest."
|
||||
|
||||
### 3c. Group CLAUDE.md
|
||||
|
||||
Edit the group's CLAUDE.md to add a wiki section. This is critical — it's what turns the agent into a wiki maintainer. It should:
|
||||
|
||||
- Explain the wiki system concisely: what it is, the three layers (sources, wiki, schema), the three operations (ingest, query, lint)
|
||||
- Index the key files and folders (`wiki/`, `sources/`, `wiki/index.md`, `wiki/log.md`)
|
||||
- Point to the container skill for detailed workflow
|
||||
- **Ingest discipline:** Be very explicit that when the user provides multiple files or points at a folder with many files, the agent MUST process them one at a time. For each file: read it, discuss takeaways, create/update all wiki pages (summary, entities, concepts, cross-references, index, log), and completely finish with that file before moving to the next. Never batch-read all files and then process them together — this produces shallow, generic pages instead of the deep integration the pattern requires.
|
||||
|
||||
## Step 4: Source handling capabilities
|
||||
|
||||
Based on the source types the user plans to ingest (discussed in Step 3), check whether the agent can already handle those formats — some are supported natively, others need a skill (e.g. `/add-image-vision`, `/add-pdf-reader`, `/add-voice-transcription`). If a needed capability isn't installed, check if there's an available skill for it and help the user get it set up.
|
||||
|
||||
### URL handling note
|
||||
|
||||
claude has built-in `WebFetch`, but it returns a summary, not the full document. For wiki ingestion of a URL where the full text matters, the container skill and CLAUDE.md should instruct claude to use bash commands to download full files instead. For example:
|
||||
|
||||
```bash
|
||||
curl -sLo sources/filename.pdf "<url>"
|
||||
```
|
||||
|
||||
If the document is a webpage, then claude can use fetch or `agent-browser` to open the page and extract full text if available. The container skill and CLAUDE.md should note this so claude gets full content for sources rather than summaries.
|
||||
|
||||
|
||||
## Step 5: Optional lint schedule
|
||||
|
||||
AskUserQuestion: "Want periodic wiki health checks?"
|
||||
|
||||
1. **Weekly**
|
||||
2. **Monthly**
|
||||
3. **Skip** — lint manually
|
||||
|
||||
If yes, create a NanoClaw scheduled task that runs in the wiki group. This is NOT a Claude Code cron job — it's a NanoClaw group task that runs in the agent container. Insert it into the SQLite database:
|
||||
|
||||
```bash
|
||||
npx tsx -e "
|
||||
const Database = require('better-sqlite3');
|
||||
const { CronExpressionParser } = require('cron-parser');
|
||||
const db = new Database('store/messages.db');
|
||||
const interval = CronExpressionParser.parse('<cron-expr>', { tz: process.env.TZ || 'UTC' });
|
||||
const nextRun = interval.next().toISOString();
|
||||
db.prepare('INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').run(
|
||||
'wiki-lint',
|
||||
'<group_folder>',
|
||||
'<chat_jid>',
|
||||
'Run a wiki lint pass per the wiki container skill. Check for contradictions, orphan pages, stale content, missing cross-references, and gaps. Report findings and offer to fix issues.',
|
||||
'cron',
|
||||
'<cron-expr>',
|
||||
'group',
|
||||
nextRun,
|
||||
'active',
|
||||
new Date().toISOString()
|
||||
);
|
||||
db.close();
|
||||
"
|
||||
```
|
||||
|
||||
Use the group's `folder` and `chat_jid` from the registered groups table. Cron expressions: `0 10 * * 0` (weekly Sunday 10am) or `0 10 1 * *` (monthly 1st at 10am).
|
||||
|
||||
## Step 6: Build and restart
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
./container/build.sh
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
Tell the user to test by sending a source to the wiki group.
|
||||
@@ -0,0 +1,75 @@
|
||||
# LLM Wiki
|
||||
|
||||
> Source: [karpathy/llm-wiki.md](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f)
|
||||
|
||||
A pattern for building personal knowledge bases using LLMs.
|
||||
|
||||
This is an idea file, designed to be copied to your own LLM Agent (e.g. OpenAI Codex, Claude Code, OpenCode / Pi, etc.). Its goal is to communicate the high-level idea, with your agent building out specifics through collaboration with you.
|
||||
|
||||
## The Core Idea
|
||||
|
||||
Most interactions with LLMs and documents follow RAG patterns: upload files, retrieve relevant chunks at query time, generate answers. The knowledge is re-derived on each question with no accumulation.
|
||||
|
||||
The concept here differs fundamentally. Rather than just retrieving from raw documents, the LLM incrementally builds and maintains a persistent wiki — a structured, interlinked markdown collection sitting between you and raw sources. When adding new material, the LLM reads it, extracts key information, and integrates it into existing wiki pages—updating entities, revising summaries, flagging contradictions, strengthening synthesis. Knowledge compiles once and stays current rather than re-deriving on every query.
|
||||
|
||||
The wiki becomes a persistent, compounding artifact. Cross-references already exist. Contradictions are flagged. Synthesis reflects everything read. The wiki enriches with every source added and question asked.
|
||||
|
||||
You source material and ask questions; the LLM maintains everything—summarizing, cross-referencing, filing, and organizing. The LLM acts as programmer; Obsidian serves as IDE; the wiki functions as codebase.
|
||||
|
||||
**Applications include:**
|
||||
- Personal: tracking goals, health, self-improvement
|
||||
- Research: deep dives over weeks/months
|
||||
- Reading: building companion wikis while progressing through books
|
||||
- Business/teams: internal wikis fed by Slack, transcripts, documents
|
||||
- Analysis: competitive research, due diligence, trip planning, hobby deep-dives
|
||||
|
||||
## Architecture
|
||||
|
||||
Three layers comprise the system:
|
||||
|
||||
**Raw sources** — immutable curated documents (articles, papers, images, data). The LLM reads but never modifies these.
|
||||
|
||||
**The wiki** — LLM-generated markdown directories containing summaries, entity pages, concept pages, comparisons, syntheses. The LLM owns this entirely, creating and updating pages while maintaining cross-references and consistency.
|
||||
|
||||
**The schema** — configuration document (e.g., CLAUDE.md) explaining wiki structure, conventions, and workflows for ingestion, querying, and maintenance. This key file transforms the LLM into disciplined wiki maintainer rather than generic chatbot.
|
||||
|
||||
## Operations
|
||||
|
||||
**Ingest:** Drop new sources into the raw collection; the LLM processes them. The agent reads sources, discusses takeaways, writes summaries, updates indexes, refreshes entity and concept pages, logs entries. Single sources might touch 10-15 wiki pages. Prefer ingesting individually while staying involved, though batch ingestion with less oversight is possible.
|
||||
|
||||
**Query:** Ask questions against the wiki. The LLM searches relevant pages, synthesizes answers with citations. Answers take various forms—markdown pages, comparison tables, slide decks, charts, canvas. Good answers can be filed back into the wiki as new pages—explorations compound in the knowledge base rather than disappearing into chat history.
|
||||
|
||||
**Lint:** Periodically health-check the wiki. Look for contradictions, stale claims superseded by newer sources, orphan pages lacking inbound links, important concepts lacking dedicated pages, missing cross-references, data gaps. The LLM suggests investigations and sources to pursue, keeping the wiki healthy as it grows.
|
||||
|
||||
## Indexing and Logging
|
||||
|
||||
Two special files help navigate the growing wiki:
|
||||
|
||||
**index.md** — content-oriented catalog of everything (each page with link, one-line summary, optional metadata like dates or source counts), organized by category. The LLM updates it on every ingest. When answering queries, read the index first to locate relevant pages before drilling deeper. This approach works surprisingly well at moderate scale (~100 sources, ~hundreds of pages) while avoiding embedding-based RAG infrastructure needs.
|
||||
|
||||
**log.md** — append-only chronological record of what happened and when (ingests, queries, lint passes). Each entry beginning with consistent prefix (e.g., `## [2026-04-02] ingest | Article Title`) becomes parseable with simple tools—`grep "^## \[" log.md | tail -5` yields last 5 entries. The log shows wiki evolution timeline and helps the LLM understand recent activity.
|
||||
|
||||
## Optional: CLI Tools
|
||||
|
||||
At scale, small tools help the LLM operate more efficiently. Search engine over wiki pages is most obvious—at small scale the index suffices, but as the wiki grows, proper search becomes necessary. qmd (https://github.com/tobi/qmd) offers local search with hybrid BM25/vector search and LLM re-ranking, entirely on-device. It includes both CLI (so LLMs can shell out) and MCP server (native tool integration). Build simpler custom search scripts as needs arise.
|
||||
|
||||
## Tips and Tricks
|
||||
|
||||
- **Obsidian Web Clipper** converts web articles to markdown for quick source collection
|
||||
- **Download images locally:** Set attachment folder in Obsidian Settings, bind download hotkey. All images store locally; LLM views and references directly instead of relying on potentially broken URLs
|
||||
- **Obsidian's graph view** visualizes wiki connectivity—what connects to what, hub pages, orphans
|
||||
- **Marp** provides markdown-based slide deck format with Obsidian plugin integration
|
||||
- **Dataview** plugin queries page frontmatter, generating dynamic tables/lists when LLM adds YAML frontmatter
|
||||
- The wiki is simply a git-backed markdown directory—version history, branching, collaboration included
|
||||
|
||||
## Why This Works
|
||||
|
||||
Knowledge base maintenance's tedious part is bookkeeping, not reading/thinking: updating cross-references, keeping summaries current, noting data contradictions, maintaining consistency across pages. Humans abandon wikis as maintenance burden outpaces value. LLMs don't bore, don't forget updates, can touch 15 files in one pass. Wiki maintenance becomes nearly free.
|
||||
|
||||
Humans curate sources, direct analysis, ask good questions, think about meaning. LLMs handle everything else.
|
||||
|
||||
This relates in spirit to Vannevar Bush's 1945 Memex—personal curated knowledge stores with associative document trails. Bush's vision resembled this more than what the web became: private, actively curated, with connections between documents as valuable as documents themselves. Bush couldn't solve maintenance; LLMs handle that.
|
||||
|
||||
## Note
|
||||
|
||||
This document intentionally remains abstract, describing the idea rather than specific implementation. Directory structure, schema conventions, page formats, tooling—all depend on domain, preferences, and LLM choice. Everything is optional and modular. Pick what's useful; ignore what isn't. Your sources might be text-only (no image handling needed). Your wiki might stay small enough that index files suffice (no search engine required). You might want different output formats entirely. Share this with your LLM agent and work collaboratively to instantiate a version fitting your needs. This document's sole purpose is communicating the pattern; your LLM figures out the rest.
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'");
|
||||
});
|
||||
});
|
||||
@@ -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">✓</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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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,49 +41,41 @@ 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
|
||||
```
|
||||
|
||||
If it already shows `'container'`, the runtime is already Apple Container. Skip to Phase 3.
|
||||
If it already shows `'container'`, the runtime is already Apple Container. Skip to Phase 4.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -94,7 +86,44 @@ npm run build
|
||||
|
||||
All tests must pass and build must be clean before proceeding.
|
||||
|
||||
## Phase 3: Verify
|
||||
## Phase 3: Credential proxy network binding
|
||||
|
||||
Apple Container uses a bridge network (bridge100) that only exists while containers are running. The credential proxy must start before any container, so it cannot bind to the bridge IP. It must bind to `0.0.0.0`, which exposes port 3001 on all network interfaces — anyone on your local network could route API requests through the proxy using your credentials.
|
||||
|
||||
Use AskUserQuestion to ask the user:
|
||||
|
||||
**"The credential proxy needs to bind to all interfaces (0.0.0.0). Is this Mac on a trusted private network?"**
|
||||
|
||||
Options:
|
||||
1. **Yes, private/home network** — description: "No firewall rule needed."
|
||||
2. **No, shared/public network** — description: "Add a macOS firewall rule to block external access to port 3001."
|
||||
|
||||
For both options, add `CREDENTIAL_PROXY_HOST=0.0.0.0` to `.env`:
|
||||
|
||||
```bash
|
||||
grep -q 'CREDENTIAL_PROXY_HOST' .env 2>/dev/null || echo 'CREDENTIAL_PROXY_HOST=0.0.0.0' >> .env
|
||||
```
|
||||
|
||||
If they chose the public network option, set up and persist the firewall rule:
|
||||
|
||||
```bash
|
||||
echo "block in on en0 proto tcp to any port 3001" | sudo pfctl -ef -
|
||||
```
|
||||
|
||||
```bash
|
||||
grep -q 'nanoclaw proxy' /etc/pf.conf 2>/dev/null || echo '# nanoclaw proxy — block LAN access to credential proxy
|
||||
block in on en0 proto tcp to any port 3001' | sudo tee -a /etc/pf.conf > /dev/null
|
||||
```
|
||||
|
||||
Verify the rule is working:
|
||||
|
||||
```bash
|
||||
curl -sf http://$(ipconfig getifaddr en0):3001 && echo "EXPOSED — rule not working" || echo "BLOCKED — rule active"
|
||||
```
|
||||
|
||||
If the verification shows "EXPOSED", warn the user and retry. If "BLOCKED", confirm success and continue.
|
||||
|
||||
## Phase 4: Verify
|
||||
|
||||
### Ensure Apple Container runtime is running
|
||||
|
||||
|
||||
@@ -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,68 +0,0 @@
|
||||
# NanoClaw Agent Container
|
||||
# Runs Claude Agent SDK in isolated Linux VM with browser automation
|
||||
|
||||
FROM node:22-slim
|
||||
|
||||
# Install system dependencies for Chromium
|
||||
RUN apt-get update && apt-get install -y \
|
||||
chromium \
|
||||
fonts-liberation \
|
||||
fonts-noto-color-emoji \
|
||||
libgbm1 \
|
||||
libnss3 \
|
||||
libatk-bridge2.0-0 \
|
||||
libgtk-3-0 \
|
||||
libx11-xcb1 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxrandr2 \
|
||||
libasound2 \
|
||||
libpangocairo-1.0-0 \
|
||||
libcups2 \
|
||||
libdrm2 \
|
||||
libxshmfence1 \
|
||||
curl \
|
||||
git \
|
||||
&& 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
|
||||
|
||||
# Install agent-browser and claude-code globally
|
||||
RUN npm install -g agent-browser @anthropic-ai/claude-code
|
||||
|
||||
# Create app directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files first for better caching
|
||||
COPY agent-runner/package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy source code
|
||||
COPY agent-runner/ ./
|
||||
|
||||
# Build TypeScript
|
||||
RUN npm run build
|
||||
|
||||
# Create workspace directories
|
||||
RUN mkdir -p /workspace/group /workspace/global /workspace/extra /workspace/ipc/messages /workspace/ipc/tasks /workspace/ipc/input
|
||||
|
||||
# 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
|
||||
|
||||
# Set ownership to node user (non-root) for writable directories
|
||||
RUN chown -R node:node /workspace && chmod 777 /home/node
|
||||
|
||||
# Set working directory to group workspace
|
||||
WORKDIR /workspace/group
|
||||
|
||||
# Entry point reads JSON from stdin, outputs JSON to stdout
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
@@ -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,694 +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 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 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,
|
||||
});
|
||||
|
||||
// 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,
|
||||
isMain: boolean,
|
||||
): 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) {
|
||||
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('-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, input.isMain);
|
||||
|
||||
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,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=');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
---
|
||||
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 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, the ONECLI_URL was output from the install script above:
|
||||
|
||||
```bash
|
||||
onecli config set api-host ${ONECLI_URL}
|
||||
```
|
||||
|
||||
### Set ONECLI_URL in .env
|
||||
|
||||
```bash
|
||||
grep -q 'ONECLI_URL' .env 2>/dev/null || echo 'ONECLI_URL=${ONECLI_URL}' >> .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 ${ONECLI_URL}/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 ${ONECLI_URL} 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 ${ONECLI_URL} 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 ${ONECLI_URL}
|
||||
- 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 ${ONECLI_URL}/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.
|
||||
@@ -0,0 +1,100 @@
|
||||
# Migrating OpenClaw Cron Jobs to NanoClaw Scheduled Tasks
|
||||
|
||||
This file is referenced by SKILL.md Phase 5 when cron jobs are detected.
|
||||
|
||||
**Before inserting tasks:** Read `src/db.ts` and search for `scheduled_tasks` to verify the current table schema. The schema below is a reference — if columns have been added, removed, or renamed, use the current schema from the source code.
|
||||
|
||||
Also verify the `createTask` function signature in `src/db.ts` — it may be simpler to call it via a script than raw SQL.
|
||||
|
||||
## OpenClaw Cron Job Format
|
||||
|
||||
Source: `<STATE_DIR>/cron/jobs.json` (from `src/cron/types.ts`). If the file format doesn't match what's described below, read the actual file and adapt — OpenClaw may have changed the schema.
|
||||
|
||||
The jobs file is `{ version: 1, jobs: CronJob[] }`. Each job has:
|
||||
- `id`, `name`, `description`, `enabled`, `deleteAfterRun`
|
||||
- `schedule`: `{ kind: "cron", expr: string, tz?: string }` | `{ kind: "every", everyMs: number }` | `{ kind: "at", at: string }`
|
||||
- `payload`: `{ kind: "agentTurn", message: string, model?, thinking?, timeoutSeconds? }` | `{ kind: "systemEvent", text: string }`
|
||||
- `sessionTarget`: `"main"` | `"isolated"` | `"current"` | `"session:<id>"`
|
||||
- `wakeMode`: `"next-heartbeat"` | `"now"`
|
||||
- `delivery`: `{ mode: "none" | "announce" | "webhook", channel?, to?, threadId?, bestEffort? }`
|
||||
- `failureAlert`: `{ after?: number, channel?, to?, cooldownMs? }` | `false`
|
||||
- `state`: runtime state (nextRunAtMs, lastRunStatus, consecutiveErrors, etc.)
|
||||
|
||||
## NanoClaw `scheduled_tasks` Table
|
||||
|
||||
Source: `src/db.ts`
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `id` | TEXT PK | Unique task ID |
|
||||
| `group_folder` | TEXT | Target group directory (e.g. `"main"`) |
|
||||
| `chat_jid` | TEXT | Target chat JID |
|
||||
| `prompt` | TEXT | Task instructions |
|
||||
| `script` | TEXT | Optional bash pre-check script |
|
||||
| `schedule_type` | TEXT | `"cron"`, `"interval"`, or `"once"` |
|
||||
| `schedule_value` | TEXT | Cron expr, ms interval, or ISO timestamp |
|
||||
| `context_mode` | TEXT | `"group"` or `"isolated"` (default) |
|
||||
| `next_run` | TEXT | ISO timestamp — must be computed at insert time |
|
||||
| `last_run` | TEXT | null initially |
|
||||
| `last_result` | TEXT | null initially |
|
||||
| `status` | TEXT | `"active"`, `"paused"`, or `"completed"` |
|
||||
| `created_at` | TEXT | ISO timestamp |
|
||||
|
||||
## Field Mapping
|
||||
|
||||
- `schedule.kind:"cron"` + `schedule.expr` → `schedule_type:"cron"`, `schedule_value:<expr>`
|
||||
- `schedule.kind:"every"` + `schedule.everyMs` → `schedule_type:"interval"`, `schedule_value:<ms as string>`
|
||||
- `schedule.kind:"at"` + `schedule.at` → `schedule_type:"once"`, `schedule_value:<ISO timestamp>`
|
||||
- `payload.message` or `payload.text` → `prompt`
|
||||
- `sessionTarget:"isolated"` → `context_mode:"isolated"`, `sessionTarget:"main"` or `"current"` → `context_mode:"group"`
|
||||
|
||||
## What Doesn't Map
|
||||
|
||||
- `delivery.mode:"webhook"` — NanoClaw has no webhook delivery. Discuss with the user: this could be implemented as a task `script` that runs `curl` to hit the webhook endpoint.
|
||||
- `failureAlert` — NanoClaw has no failure alert system. Note this to the user.
|
||||
- `wakeMode` — NanoClaw tasks always wake the agent immediately.
|
||||
- `payload.model`, `payload.thinking`, `payload.timeoutSeconds` — NanoClaw doesn't support per-task model/thinking config. These are handled by the SDK.
|
||||
- `deleteAfterRun` — NanoClaw `"once"` tasks are marked `"completed"` after running, not deleted.
|
||||
|
||||
## For Each Enabled Job
|
||||
|
||||
1. Show what it does: name, schedule, prompt, delivery mode
|
||||
2. Explain any differences (no retry config, no webhook delivery, no failure alerts)
|
||||
3. If `delivery.mode:"webhook"`: discuss with the user — a task `script` with `curl` often suffices
|
||||
4. Ask if they want to keep this task
|
||||
|
||||
## Inserting Tasks
|
||||
|
||||
Insert directly into the SQLite database. This requires groups to be registered first (Phase 1). Use the registered group's `folder` and `chat_jid`:
|
||||
|
||||
```bash
|
||||
npx tsx -e "
|
||||
const Database = require('better-sqlite3');
|
||||
const { CronExpressionParser } = require('cron-parser');
|
||||
const db = new Database('store/messages.db');
|
||||
// Compute next_run for cron tasks:
|
||||
// const interval = CronExpressionParser.parse('<expr>', { tz: process.env.TZ || 'UTC' });
|
||||
// const nextRun = interval.next().toISOString();
|
||||
db.prepare(\`INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, script, schedule_type, schedule_value, context_mode, next_run, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\`).run(
|
||||
'migrated-<original-id>',
|
||||
'<group_folder>',
|
||||
'<chat_jid>',
|
||||
'<mapped prompt>',
|
||||
null,
|
||||
'<mapped schedule_type>',
|
||||
'<mapped schedule_value>',
|
||||
'<mapped context_mode>',
|
||||
'<computed next_run ISO>',
|
||||
'active',
|
||||
new Date().toISOString()
|
||||
);
|
||||
db.close();
|
||||
"
|
||||
```
|
||||
|
||||
**Computing `next_run`:**
|
||||
- `cron` tasks: use `CronExpressionParser.parse(expr, { tz }).next().toISOString()`
|
||||
- `interval` tasks: `new Date(Date.now() + ms).toISOString()`
|
||||
- `once` tasks: `next_run` equals `schedule_value`
|
||||
|
||||
If groups haven't been registered yet (database doesn't exist), save the task details to `groups/main/openclaw-migration-tasks.md` with the exact SQL payloads, and tell the user: "These tasks will be created after `/setup` registers your groups."
|
||||
@@ -0,0 +1,447 @@
|
||||
---
|
||||
name: migrate-from-openclaw
|
||||
description: Migrate from OpenClaw to NanoClaw. Detects existing OpenClaw installation, extracts identity, channel credentials, scheduled tasks, and other config, then guides interactive migration. Triggers on "migrate from openclaw", "openclaw migration", "import from openclaw".
|
||||
---
|
||||
|
||||
# Migrate from OpenClaw
|
||||
|
||||
Guide the user through migrating their OpenClaw installation to NanoClaw. This is a conversation, not a batch job. Read OpenClaw state, discuss it with the user, make judgment calls together about what to bring over and how.
|
||||
|
||||
**Principle:** Never silently copy data. Read it, explain it, discuss where it belongs in NanoClaw's architecture, show proposed changes before applying. Credentials must be masked when displayed (first 4 + `...` + last 4 characters). Make judgment calls about what's core vs. reference material.
|
||||
|
||||
**UX:** Use `AskUserQuestion` for multiple-choice only. Use plain text for free-form input. Don't dump raw data — summarize and explain conversationally.
|
||||
|
||||
## Migration State File
|
||||
|
||||
Create `migration-state.md` in the project root at the start of Phase 0. Update it after each phase completes. This file is the single source of truth for the migration — if context is compacted or lost, re-read it to recover all decisions and progress.
|
||||
|
||||
Before starting any phase, re-read `migration-state.md` to ensure you have current state.
|
||||
|
||||
Sections to maintain (add data as each phase completes):
|
||||
|
||||
- **Progress** — checkbox list of phases (Phase 0–7)
|
||||
- **Discovery** — STATE_DIR, IDENTITY_NAME, channels, groups (with JID mappings), workspace files, cron job count, MCP servers
|
||||
- **Decisions** — assistant_name, group_model (shared/separate/main-only), main_group (folder + jid)
|
||||
- **Registered Groups** — table: folder, jid, channel, is_main
|
||||
- **Settings Migrated** — timezone, anthropic_credential (masked), sender_allowlist (created/skipped)
|
||||
- **Identity & Memory** — paths of files created, which CLAUDE.md was edited
|
||||
- **Channel Credentials** — table: channel, status, env_var
|
||||
- **Scheduled Tasks** — table: original_id, name, migrated/deferred
|
||||
- **Deferred / Not Applicable** — unsupported channels, discussed customizations, OpenClaw-only features
|
||||
|
||||
Keep it factual and terse — this is for machine recovery after compaction, not human reading. Delete the file at the end of Phase 7 (or offer to keep it as a record).
|
||||
|
||||
## Phase 0: Discovery
|
||||
|
||||
Run the discovery script to find and summarize the OpenClaw installation:
|
||||
|
||||
```bash
|
||||
npx tsx ${CLAUDE_SKILL_DIR}/scripts/discover-openclaw.ts
|
||||
```
|
||||
|
||||
If the user specifies a custom path, pass it: `--state-dir <path>`
|
||||
|
||||
Parse the status block. Key fields: STATUS, STATE_DIR, CHANNELS, WORKSPACE_FILES, DAILY_MEMORY_FILES, SKILL_COUNT, SKILLS, CRON_JOBS, MCP_SERVERS, IDENTITY_NAME, AGENT_COUNT, AGENT_IDS.
|
||||
|
||||
**Sanity-check the output:** The discovery script detects known structures but can silently miss data if OpenClaw's format has changed. Check `CONFIG_TOP_KEYS` and `CONFIG_CHANNEL_KEYS` — if you see keys the script didn't report on (e.g. a channel name not in CHANNELS, or a top-level section like `integrations` or `plugins`), read that section of the config directly with the Read tool. Also check `STATE_DIR_CONTENTS` for directories the script doesn't scan (e.g. unexpected folders alongside `workspace/`, `agents/`, `cron/`).
|
||||
|
||||
**If STATUS=not_found:** Tell the user no OpenClaw installation was detected at the standard locations (`~/.openclaw`, `~/.clawdbot`). Ask if they have a custom path. If not, exit.
|
||||
|
||||
**If STATUS=found:** Present a human-readable summary:
|
||||
|
||||
- "I found your OpenClaw installation at `<STATE_DIR>`."
|
||||
- Identity: name from IDENTITY.md (if found)
|
||||
- Workspace files: which of SOUL.md, USER.md, MEMORY.md, IDENTITY.md exist
|
||||
- Channels: list each, note which NanoClaw supports (whatsapp, telegram, slack, discord) and which it doesn't
|
||||
- Daily memory files: count (if any)
|
||||
- Skills: count and names (from workspace, shared, personal, project locations)
|
||||
- Cron jobs: count and names
|
||||
- MCP servers: count and names
|
||||
- Agents: count (relevant for Phase 1 groups discussion)
|
||||
|
||||
Then explain the key architectural differences. Don't dump a table — paraphrase conversationally:
|
||||
|
||||
- **Container isolation:** NanoClaw runs each agent in an isolated Linux container (Docker or Apple Container). OpenClaw runs everything in one process. This means stronger isolation but also means each group is its own sandbox.
|
||||
- **Group-based memory:** In OpenClaw, all groups under one agent share the same SOUL.md, MEMORY.md, and IDENTITY.md. In NanoClaw, each group has its own filesystem and CLAUDE.md. Shared state goes in `groups/global/CLAUDE.md` (mounted read-only into all non-main containers).
|
||||
- **Channel skills:** In OpenClaw, channels are configured in `openclaw.json`. In NanoClaw, channels are installed as code via skills (`/add-telegram`, `/add-whatsapp`, etc.) and configured through `.env` variables.
|
||||
- **Simpler config:** NanoClaw has no config file — behavior is in the code and `CLAUDE.md` files. Credentials live in `.env` or the OneCLI vault.
|
||||
|
||||
AskUserQuestion: "Ready to start migrating? I'll go through each area one at a time."
|
||||
1. **Yes, let's go** — proceed to Phase 1
|
||||
2. **Tell me more** — explain more about any area they ask about
|
||||
3. **Skip migration** — exit
|
||||
|
||||
## Phase 1: Groups and Architecture
|
||||
|
||||
**This discussion must happen before identity/memory, because the shared-vs-isolated decision determines where files go.**
|
||||
|
||||
If GROUP_COUNT > 0 or AGENT_COUNT > 1, this is a critical conversation. Even with just one group, explain the model difference so the user understands what they're getting into.
|
||||
|
||||
**OpenClaw model:** All groups routed to the same agent share one workspace — the same SOUL.md, MEMORY.md, IDENTITY.md, and tools. When you talk to the bot in your family chat or your work chat, it's the same agent with the same personality and memory. Only the session (conversation history) is separate per group.
|
||||
|
||||
**NanoClaw model:** Each group is a completely separate agent running in its own Linux container. Separate filesystem, separate memory, separate CLAUDE.md. The bot in your family chat and your work chat are different agents that don't know about each other — unless you explicitly share state via `groups/global/CLAUDE.md`, which is mounted read-only into all non-main containers.
|
||||
|
||||
Explain this conversationally. If the user only has one group, it's simple — just note the difference and move on. If they have multiple groups, discuss:
|
||||
|
||||
AskUserQuestion: "In OpenClaw, your groups shared the same personality and memory. In NanoClaw, each group is a fully separate agent. How would you like to handle this?"
|
||||
|
||||
1. **Shared personality (recommended if your groups had the same bot)** — "I'll put the shared personality, identity, and user context in `groups/global/CLAUDE.md`. Every group sees it. Each group can add its own customizations on top."
|
||||
2. **Fully separate** — "Each group gets its own independent personality and memory. Complete isolation between groups."
|
||||
3. **Just main group for now** — "Set up one group now. We can add others later."
|
||||
|
||||
Remember this choice — it determines where identity and memory files go in the next phase.
|
||||
|
||||
### Confirm assistant name
|
||||
|
||||
Before registering groups, confirm the assistant name — it's used for trigger patterns and CLAUDE.md templates.
|
||||
|
||||
IDENTITY_NAME from discovery gives the OpenClaw name. Ask the user: "Your OpenClaw assistant was named `<IDENTITY_NAME>`. Want to keep this name in NanoClaw?" If they want a different name, ask what it should be. If IDENTITY_NAME was empty, ask them to choose a name (default: "Andy").
|
||||
|
||||
The register step's `--assistant-name` flag writes `ASSISTANT_NAME` to `.env` and updates CLAUDE.md templates automatically — no manual `.env` write needed.
|
||||
|
||||
### Registering groups
|
||||
|
||||
The discovery script provides detected groups in the GROUPS field (format: `channel:id(name)=>nanoclaw_jid`). These are extracted from OpenClaw's session store and channel config.
|
||||
|
||||
For each group the user wants to bring over, pre-register it:
|
||||
|
||||
```bash
|
||||
npx tsx setup/index.ts --step register -- --jid "<nanoclaw_jid>" --name "<group_name>" --folder "<channel>_<slug>" --trigger "@<confirmed_name>" --channel <channel> --assistant-name "<confirmed_name>"
|
||||
```
|
||||
|
||||
Only pass `--assistant-name` on the first registration (it updates all CLAUDE.md templates globally).
|
||||
|
||||
Folder naming: `<channel>_<name-slug>` (e.g. `whatsapp_family-chat`, `telegram_dev-team`). Ask the user to confirm each group's name and folder.
|
||||
|
||||
For the first/primary group, add `--is-main --no-trigger-required`. Other groups default to requiring a trigger prefix.
|
||||
|
||||
**Important:** Registration requires the database to exist. If the environment step hasn't been run yet, run it first: `npx tsx setup/index.ts --step environment`. Registration also creates the group folder under `groups/` and copies the CLAUDE.md template.
|
||||
|
||||
Register groups from all channels — including channels NanoClaw doesn't yet support (signal, matrix, etc.). The registration stores the JID and metadata in the database, ready for when that channel is added later. Groups won't receive messages until their channel code is installed, but the registration, group folder, and CLAUDE.md will be ready.
|
||||
|
||||
## Phase 2: Settings from Config
|
||||
|
||||
Before identity/memory, extract settings from `openclaw.json` that map directly to NanoClaw setup. Read the config file with the Read tool (`<STATE_DIR>/openclaw.json` or `clawdbot.json`).
|
||||
|
||||
### Timezone
|
||||
|
||||
Check `agents.defaults.userTimezone` in the config. If present and it's a valid IANA timezone (e.g. `America/New_York`, `Asia/Jerusalem`), write it to `.env` as `TZ=<timezone>`. NanoClaw's setup step 2a reads `TZ` from `.env` (`src/config.ts:84-97`) and will skip the autodetection prompt.
|
||||
|
||||
### Anthropic Credentials
|
||||
|
||||
Check for Anthropic API keys or tokens in OpenClaw's auth system. OpenClaw stores credentials in `<STATE_DIR>/auth-profiles.json` or `<STATE_DIR>/agents/main/agent/auth-profiles.json` with this structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"profiles": {
|
||||
"anthropic:default": {
|
||||
"type": "api_key", // or "token" or "oauth"
|
||||
"provider": "anthropic",
|
||||
"key": "sk-ant-..." // for api_key type
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Profile IDs follow `provider:identifier` format. Look for any profile where `provider` is `"anthropic"`. The credential field depends on the `type`:
|
||||
- `type: "api_key"` → `key` field (or `keyRef` for SecretRef)
|
||||
- `type: "token"` → `token` field (or `tokenRef` for SecretRef)
|
||||
- `type: "oauth"` → `access` field (OAuth access token, may need refresh)
|
||||
|
||||
Also check:
|
||||
1. `<STATE_DIR>/.env` — for `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN`
|
||||
2. Config `models.providers` — for Anthropic provider entries with `apiKey`
|
||||
|
||||
If found, offer to save to `.env`. This pre-fills the NanoClaw setup credential step (step 4) so the user doesn't need to re-enter it. Use the same masking approach — show first 4 + last 4 characters, write the full value directly.
|
||||
|
||||
**Important:** If the credential uses `keyRef`/`tokenRef` with `source:"exec"` or `source:"file"`, explain that it can't be auto-extracted and the user will need to enter it during setup. For `type: "oauth"` credentials with an expiry in the past, warn the user the token may need to be refreshed during setup.
|
||||
|
||||
### Sender Allowlists
|
||||
|
||||
Read the channel configs for access control settings. OpenClaw stores these per-channel:
|
||||
- `channels.<channel>.allowFrom` — array of allowed sender IDs (E.164 for WhatsApp, numeric IDs for Telegram)
|
||||
- `channels.<channel>.dmPolicy` — `"open"`, `"allowlist"`, `"disabled"`
|
||||
- `channels.<channel>.groupPolicy` — `"open"`, `"allowlist"`, `"disabled"`
|
||||
- `channels.<channel>.groupAllowFrom` — array of allowed group member IDs
|
||||
|
||||
NanoClaw uses `~/.config/nanoclaw/sender-allowlist.json` with this format:
|
||||
```json
|
||||
{
|
||||
"default": { "allow": "*", "mode": "trigger" },
|
||||
"chats": {
|
||||
"<chat-jid>": {
|
||||
"allow": ["sender-id-1", "sender-id-2"],
|
||||
"mode": "trigger"
|
||||
}
|
||||
},
|
||||
"logDenied": true
|
||||
}
|
||||
```
|
||||
|
||||
Fields:
|
||||
- `allow`: `"*"` (all senders) or `string[]` (specific sender IDs)
|
||||
- `mode`: `"trigger"` (messages stored but trigger blocked for non-allowed senders) or `"drop"` (messages silently discarded before storage)
|
||||
- `logDenied`: optional boolean (default `true`), logs denied messages
|
||||
|
||||
If OpenClaw had allowlists configured, show the user what was set and offer to create the NanoClaw equivalent. Map:
|
||||
- `dmPolicy:"allowlist"` + `allowFrom` → per-chat entry with `"allow"` array, `"mode": "trigger"`
|
||||
- `groupPolicy:"allowlist"` + `groupAllowFrom` → per-group entry with `"allow"` array, `"mode": "trigger"`
|
||||
- `dmPolicy:"open"` → `"allow": "*"`
|
||||
- `dmPolicy:"disabled"` → per-chat entry with `"allow": []`, `"mode": "drop"` (or don't register that chat)
|
||||
|
||||
Create the directory and file:
|
||||
```bash
|
||||
mkdir -p ~/.config/nanoclaw
|
||||
```
|
||||
|
||||
Then write the JSON file. If no allowlists were configured, skip this.
|
||||
|
||||
### Container Timeout
|
||||
|
||||
Check `agents.defaults.timeoutSeconds` in the config. This is maximum total agent runtime (wall-clock). NanoClaw's equivalent is `CONTAINER_TIMEOUT` (env var, default 30 min), also configurable per-group via `containerConfig.timeout`. Note: NanoClaw also has a separate `IDLE_TIMEOUT` (max time without output) which resets on activity — OpenClaw has no equivalent.
|
||||
|
||||
If the OpenClaw value differs significantly from 30 minutes, note it for the user. They can set `CONTAINER_TIMEOUT=<ms>` in `.env` after setup.
|
||||
|
||||
## Phase 3: Identity and Memory
|
||||
|
||||
This phase is fully conversational — read files directly and discuss with the user. No script needed.
|
||||
|
||||
**Where files go depends on the Phase 1 (groups) decision:**
|
||||
- **Shared personality:** Core identity goes in `groups/global/CLAUDE.md` (seen by all groups). Group-specific customizations go in each group's own CLAUDE.md.
|
||||
- **Fully separate:** Everything goes in `groups/main/` (or each group's own folder).
|
||||
- **Just main group:** Everything goes in `groups/main/`.
|
||||
|
||||
### Find workspace files
|
||||
|
||||
The STATE_DIR from discovery tells you where OpenClaw lives. Look for workspace files at `<STATE_DIR>/workspace/`. If AGENT_COUNT > 1, also check `<STATE_DIR>/agents/*/workspace/` and ask which agent to migrate.
|
||||
|
||||
Use the Read tool to look at each file found.
|
||||
|
||||
### IDENTITY.md
|
||||
|
||||
Read `<STATE_DIR>/workspace/IDENTITY.md` if it exists. It uses a key:value format (name, emoji, creature, vibe, etc.).
|
||||
|
||||
The assistant name was already confirmed and written to `.env` in Phase 1. Here, focus on the rest of the identity — create an `identity.md` file with the full identity details (emoji, creature, vibe, personality traits, etc.). If shared personality was chosen in Phase 1, put it alongside `groups/global/CLAUDE.md`. Otherwise, put it in `groups/main/`.
|
||||
|
||||
### SOUL.md
|
||||
|
||||
Read `<STATE_DIR>/workspace/SOUL.md` if it exists. Then read `groups/main/CLAUDE.md`.
|
||||
|
||||
CLAUDE.md is always loaded into the agent's context — it's the agent's continuous instructions. Not everything from SOUL.md needs to be there. Discuss with the user what belongs where:
|
||||
|
||||
- **In CLAUDE.md (always loaded):** Core personality traits, communication style, key behavioral rules. Weave these into the existing CLAUDE.md structure — adjust the opening description under the `# <Name>` heading, modify the tone in the Communication section.
|
||||
- **In a separate soul file:** Detailed personality backstory, extended guidelines, creative writing style, philosophical grounding — things the agent can reference when relevant but don't need to consume context tokens on every turn.
|
||||
|
||||
**File placement depends on Phase 1 choice:**
|
||||
- Shared personality → edit `groups/global/CLAUDE.md` for the core traits, create `groups/global/soul.md` for the extended content. All groups will see both.
|
||||
- Separate / main only → edit `groups/main/CLAUDE.md`, create `groups/main/soul.md`.
|
||||
|
||||
Add a reference in the relevant CLAUDE.md: "Your personality and extended behavioral guidelines are in `soul.md`. Refer to it for identity questions or when crafting responses that need your full character."
|
||||
|
||||
Show proposed edits to the user before applying. This is a thoughtful merge, not a copy-paste.
|
||||
|
||||
### USER.md
|
||||
|
||||
Read `<STATE_DIR>/workspace/USER.md` if it exists.
|
||||
|
||||
Create `groups/main/user-context.md` with the user information. Add a reference in CLAUDE.md: "Information about your user is in `user-context.md`. Read it when you need context about who you're talking to."
|
||||
|
||||
Ask if they want any critical user facts (name, timezone, key preferences) directly in CLAUDE.md for always-on awareness.
|
||||
|
||||
### MEMORY.md
|
||||
|
||||
Read `<STATE_DIR>/workspace/MEMORY.md` if it exists.
|
||||
|
||||
Show the contents and discuss what's worth keeping. Some memory entries may be stale or OpenClaw-specific. Create `groups/main/memories.md` for relevant items. Add a reference in CLAUDE.md.
|
||||
|
||||
### Daily memory files (`workspace/memory/*.md`)
|
||||
|
||||
If DAILY_MEMORY_FILES > 0 in the discovery output, OpenClaw accumulated dated memory files (e.g. `2024-01-01.md`). These contain observations, facts, and context gathered over time.
|
||||
|
||||
AskUserQuestion: "You have N daily memory files from OpenClaw. How would you like to handle them?"
|
||||
|
||||
1. **Copy as-is (recommended for many files)** — "I'll create a `daily-memories/` folder in your group directory and copy them over. Your agent can reference them when needed."
|
||||
- Create the folder in the appropriate group directory (per Phase 1 decision)
|
||||
- Copy all `.md` files: `cp -r <workspace>/memory/*.md <group_dir>/daily-memories/`
|
||||
- Add a reference in CLAUDE.md: "Historical daily memory files from your previous system are in `daily-memories/`. Refer to them when you need context about past events or observations."
|
||||
|
||||
2. **Consolidate into memories** — "I'll read through them, extract the durable facts, and add them to your memories file. This reduces clutter but takes longer."
|
||||
- Read each file, extract entries worth keeping (skip transient observations, focus on durable facts about the user, preferences, recurring topics)
|
||||
- Consolidate into `memories.md`
|
||||
- Use sub-agents for large volumes (>10 files)
|
||||
|
||||
3. **Skip** — "Don't bring daily memories over."
|
||||
|
||||
### OpenClaw Skills
|
||||
|
||||
If SKILL_COUNT > 0 in discovery, OpenClaw had custom skills. The SKILL.md format is a shared standard — skills are directly portable.
|
||||
|
||||
The discovery reports skill names and source locations. For each skill, read just the YAML front matter (name + description at the top of SKILL.md) and present a list to the user: skill name, description, source location. Let the user select which ones to bring over.
|
||||
|
||||
For confirmed skills, copy the entire skill directory as-is:
|
||||
|
||||
```bash
|
||||
cp -r <skill_source_dir> container/skills/<skill_name>
|
||||
```
|
||||
|
||||
After all skills are copied, a container rebuild is needed — note this for post-migration: `./container/build.sh`.
|
||||
|
||||
### Config-registered plugins and skills
|
||||
|
||||
If CONFIG_PLUGIN_COUNT > 0 in discovery, OpenClaw had installed plugins/skills with API keys (e.g. `plugins.entries.brave`, `skills.entries.openai-whisper-api`). These are functional tools the agent had access to.
|
||||
|
||||
For each detected plugin, present the name to the user and discuss whether to set it up in NanoClaw. Read the OpenClaw config section to understand what it is, then:
|
||||
|
||||
1. **If NanoClaw has a matching skill** — check the available NanoClaw skills list for an equivalent (e.g. `/add-voice-transcription` for whisper). If found, save the API key to `.env` and invoke that skill.
|
||||
|
||||
2. **If the OpenClaw plugin was an MCP server** — read its config to find the exact package name and command. Install the same MCP server (e.g. `npx -y <exact-package-from-config>`). Don't search for or guess at MCP packages — only install what was explicitly configured.
|
||||
|
||||
3. **If the OpenClaw plugin was a CLI tool** — read the config to identify the exact tool. If it's an npm package, add it to the container's Dockerfile. Add a note to the group's CLAUDE.md that the tool is available and how to invoke it.
|
||||
|
||||
4. **If the plugin wraps an API** — discuss with the user what it did and offer to implement the equivalent: save the API key to `.env`, write a container skill with instructions for using the API, or wire it into the message flow if it's something automatic (e.g. voice transcription).
|
||||
|
||||
5. **If unclear** — discuss with the user what the plugin did and decide together. Don't install unknown packages or search for replacements — that's a supply chain risk.
|
||||
|
||||
For API keys, read the config value directly (don't display raw keys) and write to `.env`. The discovery script reports which plugins have keys but never extracts them.
|
||||
|
||||
### Other files (TOOLS.md, HEARTBEAT.md, BOOTSTRAP.md, AGENTS.md)
|
||||
|
||||
If these exist, briefly mention them and explain:
|
||||
- TOOLS.md: NanoClaw agents have their own tool discovery; this doesn't transfer
|
||||
- HEARTBEAT.md: NanoClaw uses scheduled tasks instead
|
||||
- BOOTSTRAP.md: NanoClaw uses CLAUDE.md and container skills instead
|
||||
- AGENTS.md: Already covered in the Phase 1 groups discussion
|
||||
|
||||
## Phase 4: Channel Credentials
|
||||
|
||||
For each channel found in the discovery results, handle it based on NanoClaw support:
|
||||
|
||||
### Supported channels (whatsapp, telegram, slack, discord)
|
||||
|
||||
Run the credential extraction script with `--write-env .env` so it writes credentials directly to NanoClaw's `.env` file. The script never emits raw credential values to stdout — only masked versions.
|
||||
|
||||
First, run without `--write-env` to preview:
|
||||
|
||||
```bash
|
||||
npx tsx ${CLAUDE_SKILL_DIR}/scripts/extract-channel-credentials.ts --state-dir <STATE_DIR> --channel <name>
|
||||
```
|
||||
|
||||
Parse the status block. Key fields: HAS_CREDENTIAL, CREDENTIAL_MASKED, NANOCLAW_ENV_VAR.
|
||||
|
||||
**If HAS_CREDENTIAL=false but the user expects a credential:** The extraction script may not recognize the config structure. Fall back to reading the channel section of `openclaw.json` directly with the Read tool and look for any field that contains a token or key value. Ask the user to confirm.
|
||||
|
||||
If HAS_CREDENTIAL=true: Show the masked credential (`CREDENTIAL_MASKED`). AskUserQuestion:
|
||||
1. **Use this credential** — run again with `--write-env .env` to save it
|
||||
2. **Enter a new one** — ask in plain text, write to `.env` manually
|
||||
3. **Skip this channel** — don't configure
|
||||
|
||||
If using the credential:
|
||||
|
||||
```bash
|
||||
npx tsx ${CLAUDE_SKILL_DIR}/scripts/extract-channel-credentials.ts --state-dir <STATE_DIR> --channel <name> --write-env .env
|
||||
```
|
||||
|
||||
The script writes the credential directly to `.env` using the correct NanoClaw variable name (e.g. `TELEGRAM_BOT_TOKEN`). Check the status block for `WRITTEN_TO` and `WRITTEN_COUNT` to confirm.
|
||||
|
||||
**Credential destination note:** Credentials are saved to `.env` for now. During `/setup`, the credential step will either keep them in `.env` (Apple Container) or migrate them to the OneCLI vault (Docker). The user doesn't need to worry about this now.
|
||||
|
||||
For Slack: there are two credentials (bot token + app token). The script handles both in one run — check `HAS_CREDENTIAL_2` and `NANOCLAW_ENV_VAR_2` in the status block.
|
||||
|
||||
**WhatsApp special case:** WhatsApp uses QR/pairing-code authentication, not a token. Do not copy auth state from OpenClaw — encryption sessions become stale after copying and messages fail to decrypt. Authentication will be handled during `/setup` via the `/add-whatsapp` skill (takes about 60 seconds with a pairing code). Just note that WhatsApp was configured and move on.
|
||||
|
||||
**Allowlist note:** If the channel had `allowFrom` or group policies, these were already handled in Phase 2 (sender allowlists). Mention that the allowlist file was created earlier.
|
||||
|
||||
### Unsupported channels (signal, matrix, irc, msteams, feishu, etc.)
|
||||
|
||||
Explain briefly: "NanoClaw doesn't have a `<channel>` integration yet, but channels are added over time via skills. Any groups from this channel were already registered in Phase 1 — they'll activate when the channel is added."
|
||||
|
||||
If there are credentials (tokens, keys) for the unsupported channel, offer to save them to `.env` with a descriptive variable name (e.g. `SIGNAL_ACCOUNT`, `MATRIX_ACCESS_TOKEN`) so they're available when the channel is eventually supported.
|
||||
|
||||
Don't invoke channel skills here — just prepare `.env` credentials. Channel code is installed during `/setup`.
|
||||
|
||||
## Phase 5: Scheduled Tasks
|
||||
|
||||
Read `<STATE_DIR>/cron/jobs.json` with the Read tool. If the file doesn't exist or has no jobs, skip this phase.
|
||||
|
||||
If jobs exist, read `${CLAUDE_SKILL_DIR}/MIGRATE_CRONS.md` for the full OpenClaw cron format, NanoClaw table schema, field mapping, and SQL insert template. Follow those instructions for each job.
|
||||
|
||||
## Phase 6: Webhooks, MCP, and Other Config
|
||||
|
||||
Read relevant sections from `<STATE_DIR>/openclaw.json` directly with the Read tool. This phase is fully conversational.
|
||||
|
||||
### MCP Servers
|
||||
|
||||
If MCP_SERVERS was non-empty in discovery, these can be ported. Claude Code supports MCP servers natively. Read the OpenClaw config's `mcp.servers` section to get each server's details (`command`, `args`, `env`, `url`).
|
||||
|
||||
MCP servers in NanoClaw are registered in the agent-runner source code. Before editing, grep for `mcpServers` in `container/agent-runner/src/` to find the current location — it's expected to be in `index.ts` in the `query()` options, but may have moved. For each OpenClaw MCP server the user wants to bring over:
|
||||
|
||||
1. Read its config: command, args, env, url
|
||||
2. **stdio servers** (have `command`): Add an entry to the `mcpServers` object in `container/agent-runner/src/index.ts`. The command runs inside the container, so it needs to be available there (Node.js/npx-based servers work; custom binaries would need to be added to the Dockerfile).
|
||||
3. **HTTP/SSE servers** (have `url`): These work if the URL is accessible from inside the container. Add them the same way.
|
||||
4. **Environment variables**: Any `env` values that reference secrets should be added to `.env` and passed through via `process.env.*` in the mcpServers entry.
|
||||
|
||||
After adding all MCP servers, a container rebuild is needed: `./container/build.sh`
|
||||
|
||||
Show the user each server and ask which to bring over. For servers that need custom binaries not available in the container, note them for manual setup.
|
||||
|
||||
### Webhooks and Endpoints
|
||||
|
||||
If the config has webhook sections (in `cron.webhook`, `cron.failureDestination`, or channel-specific webhooks):
|
||||
- Explain what they were used for
|
||||
- These don't map directly but NanoClaw can be customized to support them
|
||||
- Discuss the use case with the user and propose a solution if it's important to them
|
||||
- For simple webhook notifications: a task script with `curl` often suffices
|
||||
|
||||
### Other Config
|
||||
|
||||
Scan the config for notable sections and briefly mention anything that doesn't carry over:
|
||||
- **Exec approvals / command allowlist:** NanoClaw uses container isolation instead — the agent runs with `--dangerously-skip-permissions` inside a sandboxed container
|
||||
- **Human delay:** Not applicable in NanoClaw's container model
|
||||
- **Compaction:** Handled by Claude Code SDK automatically
|
||||
- **TTS:** Not built into NanoClaw
|
||||
- **Model configuration:** NanoClaw uses whatever Anthropic model the credential provides access to
|
||||
|
||||
Don't belabor these — just mention and move on.
|
||||
|
||||
## Phase 7: Summary
|
||||
|
||||
### Summary
|
||||
|
||||
Print a comprehensive summary:
|
||||
|
||||
**Migrated:**
|
||||
- Assistant name → `.env` ASSISTANT_NAME + CLAUDE.md templates updated
|
||||
- Groups → registered in database, folders created with CLAUDE.md templates
|
||||
- Timezone → `.env` TZ
|
||||
- Anthropic credential → `.env` (for setup to pick up)
|
||||
- Sender allowlists → `~/.config/nanoclaw/sender-allowlist.json`
|
||||
- Personality → CLAUDE.md (core) + `soul.md` (extended), placed per Phase 1 decision (global or per-group)
|
||||
- User context → `user-context.md`
|
||||
- Memories → `memories.md` + daily memory files (copied to `daily-memories/` or consolidated)
|
||||
- OpenClaw skills → copied to `container/skills/`
|
||||
- Channel credentials → `.env` (list which channels)
|
||||
- Scheduled tasks → inserted into database or noted for post-setup
|
||||
- MCP servers → registered in agent-runner
|
||||
|
||||
**Noted for later:**
|
||||
- Channel code installation (happens during `/setup`)
|
||||
- Task creation (if deferred due to no registered group yet)
|
||||
- Container rebuild needed (if skills or MCP servers were added): `./container/build.sh`
|
||||
|
||||
**Not applicable:**
|
||||
- Unsupported channels (list them — groups registered for future)
|
||||
- OpenClaw-specific features (exec approvals, human delay, TTS, model config, session reset policies, etc.)
|
||||
|
||||
**Discussed and deferred:**
|
||||
- List any customizations agreed on but not yet implemented
|
||||
|
||||
Remind: "Run `/setup` next to complete your NanoClaw installation. Channel credentials are already prepared in `.env`. When setup asks which channels to enable, select the ones we configured."
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Config parse error:** If `openclaw.json` fails to parse, it may use JSON5 features the parser doesn't handle. Ask the user to check the file for unusual syntax. As a fallback, the agent can read the file directly and work with it manually.
|
||||
|
||||
**Credential not found:** If a channel credential resolves to empty, it may use `source:"exec"` or `source:"file"` SecretRef. These can't be auto-extracted. Ask the user to provide the value directly.
|
||||
|
||||
**Multi-agent complexity:** If the user had many agents with different configs, focus on the primary/default agent first. Additional agents can be set up as separate NanoClaw groups later.
|
||||
@@ -0,0 +1,734 @@
|
||||
/**
|
||||
* Discover an existing OpenClaw installation and emit a structured summary.
|
||||
*
|
||||
* Usage: npx tsx .claude/skills/migrate-from-openclaw/scripts/discover-openclaw.ts [--state-dir <path>]
|
||||
*
|
||||
* Checks (in order): --state-dir arg, $OPENCLAW_STATE_DIR, ~/.openclaw, ~/.clawdbot
|
||||
* Parses openclaw.json (JSON5-tolerant), scans workspace for identity/memory files,
|
||||
* checks cron jobs, MCP servers, and channel credentials.
|
||||
*
|
||||
* Emits a status block on stdout:
|
||||
* === NANOCLAW MIGRATE: DISCOVERY ===
|
||||
* ...
|
||||
* === END ===
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON5-tolerant parser (no dependency)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseJson5(text: string): unknown {
|
||||
// Strip single-line comments (// ...) that aren't inside strings
|
||||
let cleaned = text.replace(
|
||||
/("(?:[^"\\]|\\.)*")|\/\/[^\n]*/g,
|
||||
(match, str) => (str ? str : ''),
|
||||
);
|
||||
// Strip block comments (/* ... */)
|
||||
cleaned = cleaned.replace(
|
||||
/("(?:[^"\\]|\\.)*")|\/\*[\s\S]*?\*\//g,
|
||||
(match, str) => (str ? str : ''),
|
||||
);
|
||||
// Strip trailing commas before } or ]
|
||||
cleaned = cleaned.replace(/,\s*([}\]])/g, '$1');
|
||||
return JSON.parse(cleaned);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status block emitter (mirrors setup/status.ts convention)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function emitStatus(fields: Record<string, string | number | boolean>): void {
|
||||
const lines = ['=== NANOCLAW MIGRATE: DISCOVERY ==='];
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
lines.push(`${key}: ${value}`);
|
||||
}
|
||||
lines.push('=== END ===');
|
||||
console.log(lines.join('\n'));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI arg parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseArgs(): { stateDir?: string } {
|
||||
const args = process.argv.slice(2);
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--state-dir' && args[i + 1]) {
|
||||
return { stateDir: args[i + 1] };
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function resolveStateDir(explicit?: string): string | null {
|
||||
const home = os.homedir();
|
||||
const candidates: string[] = [];
|
||||
|
||||
if (explicit) {
|
||||
// Expand ~ prefix
|
||||
const expanded = explicit.startsWith('~')
|
||||
? path.join(home, explicit.slice(1))
|
||||
: explicit;
|
||||
candidates.push(expanded);
|
||||
}
|
||||
|
||||
if (process.env.OPENCLAW_STATE_DIR) {
|
||||
candidates.push(process.env.OPENCLAW_STATE_DIR);
|
||||
}
|
||||
|
||||
candidates.push(path.join(home, '.openclaw'));
|
||||
candidates.push(path.join(home, '.clawdbot'));
|
||||
|
||||
for (const dir of candidates) {
|
||||
if (fs.existsSync(dir) && fs.statSync(dir).isDirectory()) {
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config loading
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function loadConfig(
|
||||
stateDir: string,
|
||||
): Record<string, unknown> | null {
|
||||
for (const name of ['openclaw.json', 'clawdbot.json']) {
|
||||
const configPath = path.join(stateDir, name);
|
||||
if (fs.existsSync(configPath)) {
|
||||
try {
|
||||
const raw = fs.readFileSync(configPath, 'utf-8');
|
||||
return parseJson5(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
// Try next name
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Channel detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ChannelInfo {
|
||||
name: string;
|
||||
hasCreds: boolean;
|
||||
}
|
||||
|
||||
const SUPPORTED_CHANNELS = new Set([
|
||||
'whatsapp',
|
||||
'telegram',
|
||||
'slack',
|
||||
'discord',
|
||||
]);
|
||||
|
||||
// Fields that indicate a credential is present for each channel
|
||||
const CREDENTIAL_FIELDS: Record<string, string[]> = {
|
||||
telegram: ['botToken'],
|
||||
discord: ['token'],
|
||||
slack: ['botToken', 'appToken'],
|
||||
whatsapp: [], // Auth-state based, no token
|
||||
signal: ['account'],
|
||||
imessage: [],
|
||||
matrix: ['homeserverUrl', 'accessToken'],
|
||||
irc: ['server'],
|
||||
msteams: ['appId'],
|
||||
feishu: ['appId'],
|
||||
googlechat: [],
|
||||
mattermost: ['token', 'url'],
|
||||
zalo: [],
|
||||
bluebubbles: ['url'],
|
||||
};
|
||||
|
||||
const ALL_KNOWN_CHANNELS = new Set([
|
||||
'whatsapp', 'telegram', 'slack', 'discord', 'signal',
|
||||
'imessage', 'matrix', 'irc', 'msteams', 'feishu',
|
||||
'googlechat', 'mattermost', 'zalo', 'bluebubbles',
|
||||
]);
|
||||
|
||||
function detectChannels(
|
||||
config: Record<string, unknown>,
|
||||
): ChannelInfo[] {
|
||||
// Check both config.channels.* (newer) and top-level config.* (older/legacy)
|
||||
const channelsSections: Record<string, unknown> = {};
|
||||
|
||||
// Source 1: channels.* (standard location)
|
||||
const nested = config.channels as Record<string, unknown> | undefined;
|
||||
if (nested) {
|
||||
for (const [k, v] of Object.entries(nested)) {
|
||||
if (v && typeof v === 'object') channelsSections[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
// Source 2: top-level keys matching known channel names (legacy format)
|
||||
for (const key of Object.keys(config)) {
|
||||
if (ALL_KNOWN_CHANNELS.has(key) && !channelsSections[key]) {
|
||||
const v = config[key];
|
||||
if (v && typeof v === 'object') channelsSections[key] = v;
|
||||
}
|
||||
}
|
||||
|
||||
const results: ChannelInfo[] = [];
|
||||
|
||||
for (const [name, section] of Object.entries(channelsSections)) {
|
||||
if (!section || typeof section !== 'object') continue;
|
||||
const ch = section as Record<string, unknown>;
|
||||
|
||||
// Check if any credential field is present and non-empty
|
||||
const credFields = CREDENTIAL_FIELDS[name] ?? [];
|
||||
let hasCreds = false;
|
||||
|
||||
for (const field of credFields) {
|
||||
const val = ch[field];
|
||||
if (val && (typeof val === 'string' || typeof val === 'object')) {
|
||||
hasCreds = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check accounts for multi-account setups
|
||||
if (!hasCreds && ch.accounts && typeof ch.accounts === 'object') {
|
||||
for (const acct of Object.values(
|
||||
ch.accounts as Record<string, unknown>,
|
||||
)) {
|
||||
if (!acct || typeof acct !== 'object') continue;
|
||||
const a = acct as Record<string, unknown>;
|
||||
for (const field of credFields) {
|
||||
if (
|
||||
a[field] &&
|
||||
(typeof a[field] === 'string' || typeof a[field] === 'object')
|
||||
) {
|
||||
hasCreds = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasCreds) break;
|
||||
}
|
||||
}
|
||||
|
||||
// WhatsApp: check for auth state directory instead of token
|
||||
if (name === 'whatsapp' && !hasCreds) {
|
||||
// Will be checked separately via agents directory
|
||||
hasCreds = false;
|
||||
}
|
||||
|
||||
results.push({ name, hasCreds });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workspace scanning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WORKSPACE_FILES = [
|
||||
'SOUL.md',
|
||||
'USER.md',
|
||||
'MEMORY.md',
|
||||
'IDENTITY.md',
|
||||
'TOOLS.md',
|
||||
'HEARTBEAT.md',
|
||||
'BOOTSTRAP.md',
|
||||
'AGENTS.md',
|
||||
];
|
||||
|
||||
function findWorkspace(stateDir: string, config: Record<string, unknown> | null): {
|
||||
dir: string | null;
|
||||
files: string[];
|
||||
} {
|
||||
// Check config-specified workspace path first (agent.workspace or agents.defaults.workspace)
|
||||
const configPaths: string[] = [];
|
||||
if (config) {
|
||||
const agentWs = (config.agent as Record<string, unknown> | undefined)?.workspace as string | undefined;
|
||||
if (agentWs) configPaths.push(agentWs.startsWith('~') ? path.join(os.homedir(), agentWs.slice(1)) : agentWs);
|
||||
const defaultsWs = ((config.agents as Record<string, unknown> | undefined)?.defaults as Record<string, unknown> | undefined)?.workspace as string | undefined;
|
||||
if (defaultsWs) configPaths.push(defaultsWs.startsWith('~') ? path.join(os.homedir(), defaultsWs.slice(1)) : defaultsWs);
|
||||
}
|
||||
|
||||
// Check config-specified paths, then default locations
|
||||
const candidates = [
|
||||
...configPaths,
|
||||
...['workspace', 'workspace.default'].map((n) => path.join(stateDir, n)),
|
||||
];
|
||||
|
||||
for (const ws of candidates) {
|
||||
if (fs.existsSync(ws) && fs.statSync(ws).isDirectory()) {
|
||||
const found = WORKSPACE_FILES.filter((f) =>
|
||||
fs.existsSync(path.join(ws, f)),
|
||||
);
|
||||
if (found.length > 0) {
|
||||
return { dir: ws, files: found };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check agent-specific workspaces
|
||||
const agentsDir = path.join(stateDir, 'agents');
|
||||
if (fs.existsSync(agentsDir)) {
|
||||
for (const agentId of fs.readdirSync(agentsDir)) {
|
||||
for (const wsName of ['workspace', 'workspace.default']) {
|
||||
const ws = path.join(agentsDir, agentId, wsName);
|
||||
if (fs.existsSync(ws) && fs.statSync(ws).isDirectory()) {
|
||||
const found = WORKSPACE_FILES.filter((f) =>
|
||||
fs.existsSync(path.join(ws, f)),
|
||||
);
|
||||
if (found.length > 0) {
|
||||
return { dir: ws, files: found };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { dir: null, files: [] };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Daily memory file detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function countDailyMemoryFiles(workspaceDir: string | null): number {
|
||||
if (!workspaceDir) return 0;
|
||||
const memoryDir = path.join(workspaceDir, 'memory');
|
||||
if (!fs.existsSync(memoryDir) || !fs.statSync(memoryDir).isDirectory()) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(memoryDir)
|
||||
.filter((f) => f.endsWith('.md'))
|
||||
.length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skills detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SkillInfo {
|
||||
name: string;
|
||||
source: string; // 'workspace' | 'shared' | 'personal' | 'project'
|
||||
path: string;
|
||||
}
|
||||
|
||||
function detectSkills(
|
||||
stateDir: string,
|
||||
workspaceDir: string | null,
|
||||
): SkillInfo[] {
|
||||
const skills: SkillInfo[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const scanDir = (dir: string, source: string) => {
|
||||
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return;
|
||||
try {
|
||||
for (const entry of fs.readdirSync(dir)) {
|
||||
const skillDir = path.join(dir, entry);
|
||||
if (!fs.statSync(skillDir).isDirectory()) continue;
|
||||
// A directory is a skill if it contains SKILL.md
|
||||
if (fs.existsSync(path.join(skillDir, 'SKILL.md'))) {
|
||||
if (seen.has(entry)) continue;
|
||||
seen.add(entry);
|
||||
skills.push({ name: entry, source, path: skillDir });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore read errors
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Workspace skills
|
||||
if (workspaceDir) {
|
||||
scanDir(path.join(workspaceDir, 'skills'), 'workspace');
|
||||
// 4. Project-level shared skills
|
||||
scanDir(path.join(workspaceDir, '.agents', 'skills'), 'project');
|
||||
}
|
||||
|
||||
// 2. Managed/shared skills
|
||||
scanDir(path.join(stateDir, 'skills'), 'shared');
|
||||
|
||||
// 3. Personal cross-project skills
|
||||
const personalSkills = path.join(os.homedir(), '.agents', 'skills');
|
||||
scanDir(personalSkills, 'personal');
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identity extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function extractIdentityName(stateDir: string, workspaceDir: string | null): string {
|
||||
if (!workspaceDir) return '';
|
||||
|
||||
const identityPath = path.join(workspaceDir, 'IDENTITY.md');
|
||||
if (!fs.existsSync(identityPath)) return '';
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(identityPath, 'utf-8');
|
||||
// IDENTITY.md uses key:value format, e.g. "name: Claw"
|
||||
const match = content.match(/^name:\s*(.+)/im);
|
||||
return match ? match[1].trim() : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function detectAgents(stateDir: string): string[] {
|
||||
const agentsDir = path.join(stateDir, 'agents');
|
||||
if (!fs.existsSync(agentsDir)) return [];
|
||||
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(agentsDir)
|
||||
.filter((f) => {
|
||||
const p = path.join(agentsDir, f);
|
||||
return fs.statSync(p).isDirectory() && !f.startsWith('.');
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group detection — from session store and channel config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface GroupInfo {
|
||||
channel: string;
|
||||
id: string; // Platform-specific ID (WhatsApp JID, Telegram chat ID, etc.)
|
||||
name: string;
|
||||
source: 'session' | 'config';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map OpenClaw session key channel:kind:id to NanoClaw JID format.
|
||||
* OpenClaw keys: "whatsapp:group:120...@g.us", "telegram:group:-10012345"
|
||||
* NanoClaw JIDs: "120...@g.us", "tg:-10012345", "dc:12345", "slack:C12345"
|
||||
*/
|
||||
function toNanoClawJid(channel: string, id: string): string {
|
||||
switch (channel) {
|
||||
case 'whatsapp':
|
||||
return id; // Already in JID format (120...@g.us)
|
||||
case 'telegram':
|
||||
return `tg:${id}`;
|
||||
case 'discord':
|
||||
return `dc:${id}`;
|
||||
case 'slack':
|
||||
return `slack:${id}`;
|
||||
default:
|
||||
return `${channel}:${id}`;
|
||||
}
|
||||
}
|
||||
|
||||
function detectGroups(
|
||||
stateDir: string,
|
||||
config: Record<string, unknown> | null,
|
||||
agents: string[],
|
||||
): GroupInfo[] {
|
||||
const groups: GroupInfo[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
// Source 1: Session store — scan for group session keys
|
||||
for (const agentId of agents) {
|
||||
const sessionsPath = path.join(
|
||||
stateDir,
|
||||
'agents',
|
||||
agentId,
|
||||
'sessions',
|
||||
'sessions.json',
|
||||
);
|
||||
if (!fs.existsSync(sessionsPath)) continue;
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(sessionsPath, 'utf-8');
|
||||
const data = JSON.parse(raw) as Record<string, unknown>;
|
||||
|
||||
// Sessions can be stored as an object with session keys, or as
|
||||
// { sessions: { key: entry } } or { entries: [...] }
|
||||
const entries =
|
||||
(data.sessions as Record<string, unknown>) ??
|
||||
(data.entries as Record<string, unknown>) ??
|
||||
data;
|
||||
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
// Match session keys like "whatsapp:group:120...@g.us"
|
||||
// or prefixed "agent:main:whatsapp:group:120...@g.us"
|
||||
// Also match DM sessions: "whatsapp:dm:number@s.whatsapp.net"
|
||||
const match = key.match(/(\w+):(group|dm|channel):(.+)$/i);
|
||||
if (!match) continue;
|
||||
|
||||
const [, channel, kind, id] = match;
|
||||
// Skip DM sessions for group detection — they're individual chats
|
||||
if (kind === 'dm') continue;
|
||||
const dedupKey = `${channel}:${id}`;
|
||||
if (seen.has(dedupKey)) continue;
|
||||
seen.add(dedupKey);
|
||||
|
||||
// Try to extract display name from session entry
|
||||
let name = '';
|
||||
if (value && typeof value === 'object') {
|
||||
const entry = value as Record<string, unknown>;
|
||||
name =
|
||||
(entry.displayName as string) ??
|
||||
(entry.label as string) ??
|
||||
(entry.subject as string) ??
|
||||
'';
|
||||
}
|
||||
|
||||
groups.push({
|
||||
channel,
|
||||
id,
|
||||
name: name || id,
|
||||
source: 'session',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// Source 2: Channel config — groups explicitly configured
|
||||
if (config) {
|
||||
const channels =
|
||||
(config.channels as Record<string, unknown> | undefined) ?? {};
|
||||
for (const [channelName, channelSection] of Object.entries(channels)) {
|
||||
if (!channelSection || typeof channelSection !== 'object') continue;
|
||||
const ch = channelSection as Record<string, unknown>;
|
||||
|
||||
// WhatsApp/Telegram: channels.<channel>.groups.<groupId>
|
||||
const configGroups = ch.groups as Record<string, unknown> | undefined;
|
||||
if (configGroups) {
|
||||
for (const groupId of Object.keys(configGroups)) {
|
||||
const dedupKey = `${channelName}:${groupId}`;
|
||||
if (seen.has(dedupKey)) continue;
|
||||
seen.add(dedupKey);
|
||||
groups.push({
|
||||
channel: channelName,
|
||||
id: groupId,
|
||||
name: groupId,
|
||||
source: 'config',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Discord: channels.discord.guilds.<guildId>
|
||||
if (channelName === 'discord') {
|
||||
const guilds = ch.guilds as Record<string, unknown> | undefined;
|
||||
if (guilds) {
|
||||
for (const guildId of Object.keys(guilds)) {
|
||||
const dedupKey = `discord:${guildId}`;
|
||||
if (seen.has(dedupKey)) continue;
|
||||
seen.add(dedupKey);
|
||||
groups.push({
|
||||
channel: 'discord',
|
||||
id: guildId,
|
||||
name: guildId,
|
||||
source: 'config',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cron job counting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function countCronJobs(stateDir: string): {
|
||||
count: number;
|
||||
summaries: string[];
|
||||
} {
|
||||
const jobsPath = path.join(stateDir, 'cron', 'jobs.json');
|
||||
if (!fs.existsSync(jobsPath)) return { count: 0, summaries: [] };
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(jobsPath, 'utf-8');
|
||||
const data = JSON.parse(raw) as {
|
||||
jobs?: Array<{ name?: string; enabled?: boolean }>;
|
||||
};
|
||||
const jobs = data.jobs ?? [];
|
||||
const summaries = jobs
|
||||
.filter((j) => j.enabled !== false)
|
||||
.map((j) => j.name || 'unnamed')
|
||||
.slice(0, 10);
|
||||
return { count: jobs.length, summaries };
|
||||
} catch {
|
||||
return { count: 0, summaries: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config-registered plugins and skills (with API keys)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ConfigPlugin {
|
||||
name: string;
|
||||
source: 'skills.entries' | 'plugins.entries';
|
||||
hasApiKey: boolean;
|
||||
}
|
||||
|
||||
function detectConfigPlugins(
|
||||
config: Record<string, unknown>,
|
||||
): ConfigPlugin[] {
|
||||
const results: ConfigPlugin[] = [];
|
||||
|
||||
// Check skills.entries (e.g. openai-whisper-api with apiKey)
|
||||
const skills = config.skills as Record<string, unknown> | undefined;
|
||||
const skillEntries = skills?.entries as Record<string, unknown> | undefined;
|
||||
if (skillEntries) {
|
||||
for (const [name, entry] of Object.entries(skillEntries)) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const e = entry as Record<string, unknown>;
|
||||
const hasKey = !!(e.apiKey || e.token || e.key);
|
||||
results.push({ name, source: 'skills.entries', hasApiKey: hasKey });
|
||||
}
|
||||
}
|
||||
|
||||
// Check plugins.entries (e.g. brave with config.webSearch.apiKey)
|
||||
const plugins = config.plugins as Record<string, unknown> | undefined;
|
||||
const pluginEntries = plugins?.entries as Record<string, unknown> | undefined;
|
||||
if (pluginEntries) {
|
||||
for (const [name, entry] of Object.entries(pluginEntries)) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
// Deep-search for apiKey in nested config
|
||||
const hasKey = JSON.stringify(entry).includes('apiKey');
|
||||
results.push({ name, source: 'plugins.entries', hasApiKey: hasKey });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP server detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function detectMcpServers(
|
||||
config: Record<string, unknown>,
|
||||
): string[] {
|
||||
const mcp = config.mcp as Record<string, unknown> | undefined;
|
||||
if (!mcp) return [];
|
||||
const servers = mcp.servers as Record<string, unknown> | undefined;
|
||||
if (!servers) return [];
|
||||
return Object.keys(servers);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function main(): void {
|
||||
const { stateDir: explicitDir } = parseArgs();
|
||||
const stateDir = resolveStateDir(explicitDir);
|
||||
|
||||
if (!stateDir) {
|
||||
emitStatus({ STATUS: 'not_found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const config = loadConfig(stateDir);
|
||||
const channels = config ? detectChannels(config) : [];
|
||||
const { dir: workspaceDir, files: workspaceFiles } =
|
||||
findWorkspace(stateDir, config);
|
||||
const identityName = extractIdentityName(stateDir, workspaceDir);
|
||||
const agents = detectAgents(stateDir);
|
||||
const groups = detectGroups(stateDir, config, agents);
|
||||
const { count: cronCount, summaries: cronSummaries } =
|
||||
countCronJobs(stateDir);
|
||||
const mcpServers = config ? detectMcpServers(config) : [];
|
||||
const dailyMemoryFiles = countDailyMemoryFiles(workspaceDir);
|
||||
const skills = detectSkills(stateDir, workspaceDir);
|
||||
const configPlugins = config ? detectConfigPlugins(config) : [];
|
||||
|
||||
// Format channels as "name(has_creds)" or "name(no_creds)"
|
||||
const channelList = channels
|
||||
.map((c) => `${c.name}(${c.hasCreds ? 'has_creds' : 'no_creds'})`)
|
||||
.join(',');
|
||||
|
||||
// Separate supported vs unsupported
|
||||
const unsupported = channels
|
||||
.filter((c) => !SUPPORTED_CHANNELS.has(c.name))
|
||||
.map((c) => c.name)
|
||||
.join(',');
|
||||
|
||||
// Format groups as "channel:id(name)" — also include NanoClaw JID mapping
|
||||
const groupList = groups
|
||||
.map(
|
||||
(g) =>
|
||||
`${g.channel}:${g.id}(${g.name})=>${toNanoClawJid(g.channel, g.id)}`,
|
||||
)
|
||||
.join('|');
|
||||
|
||||
// Format skills as "name(source)" list
|
||||
const skillList = skills
|
||||
.map((s) => `${s.name}(${s.source})`)
|
||||
.join(',');
|
||||
|
||||
// Dump raw top-level config keys so Claude can see what exists
|
||||
// beyond what this script specifically detects
|
||||
const configTopKeys = config ? Object.keys(config).sort().join(',') : 'none';
|
||||
const configChannelKeys = config?.channels
|
||||
? Object.keys(config.channels as Record<string, unknown>).sort().join(',')
|
||||
: 'none';
|
||||
|
||||
// List files/dirs at the state dir root for manual inspection
|
||||
let stateDirContents = 'unknown';
|
||||
try {
|
||||
stateDirContents = fs
|
||||
.readdirSync(stateDir)
|
||||
.filter((f) => !f.startsWith('.'))
|
||||
.sort()
|
||||
.join(',');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
emitStatus({
|
||||
STATUS: 'found',
|
||||
STATE_DIR: stateDir,
|
||||
CONFIG_FOUND: config !== null,
|
||||
CONFIG_TOP_KEYS: configTopKeys,
|
||||
CONFIG_CHANNEL_KEYS: configChannelKeys,
|
||||
STATE_DIR_CONTENTS: stateDirContents,
|
||||
CHANNELS: channelList || 'none',
|
||||
UNSUPPORTED_CHANNELS: unsupported || 'none',
|
||||
WORKSPACE_DIR: workspaceDir || 'not_found',
|
||||
WORKSPACE_FILES: workspaceFiles.join(',') || 'none',
|
||||
IDENTITY_NAME: identityName || 'unknown',
|
||||
AGENT_COUNT: agents.length,
|
||||
AGENT_IDS: agents.join(',') || 'none',
|
||||
GROUPS: groupList || 'none',
|
||||
GROUP_COUNT: groups.length,
|
||||
DAILY_MEMORY_FILES: dailyMemoryFiles,
|
||||
SKILL_COUNT: skills.length,
|
||||
SKILLS: skillList || 'none',
|
||||
CONFIG_PLUGINS: configPlugins.map((p) => `${p.name}(${p.source}${p.hasApiKey ? ',has_key' : ''})`).join(',') || 'none',
|
||||
CONFIG_PLUGIN_COUNT: configPlugins.length,
|
||||
CRON_JOBS: cronCount,
|
||||
CRON_SUMMARIES: cronSummaries.join('|') || 'none',
|
||||
MCP_SERVERS: mcpServers.join(',') || 'none',
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,476 @@
|
||||
/**
|
||||
* Extract a channel credential from an OpenClaw configuration and write it
|
||||
* directly to the NanoClaw .env file.
|
||||
*
|
||||
* Usage: npx tsx .claude/skills/migrate-from-openclaw/scripts/extract-channel-credentials.ts \
|
||||
* --channel telegram --state-dir ~/.openclaw --write-env .env
|
||||
*
|
||||
* Handles OpenClaw SecretRef formats:
|
||||
* - Plain string: "bot-token-value"
|
||||
* - Env template: "${TELEGRAM_BOT_TOKEN}"
|
||||
* - SecretRef object: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" }
|
||||
*
|
||||
* Also reads <state-dir>/.env for env-based secrets.
|
||||
*
|
||||
* Credential values are NEVER emitted to stdout — only masked versions.
|
||||
* When --write-env is provided, the script writes credentials directly to
|
||||
* the target .env file so the agent never sees raw secrets.
|
||||
*
|
||||
* Emits a status block on stdout:
|
||||
* === NANOCLAW MIGRATE: CREDENTIAL ===
|
||||
* ...
|
||||
* === END ===
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON5-tolerant parser (same as discover script)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseJson5(text: string): unknown {
|
||||
let cleaned = text.replace(
|
||||
/("(?:[^"\\]|\\.)*")|\/\/[^\n]*/g,
|
||||
(match, str) => (str ? str : ''),
|
||||
);
|
||||
cleaned = cleaned.replace(
|
||||
/("(?:[^"\\]|\\.)*")|\/\*[\s\S]*?\*\//g,
|
||||
(match, str) => (str ? str : ''),
|
||||
);
|
||||
cleaned = cleaned.replace(/,\s*([}\]])/g, '$1');
|
||||
return JSON.parse(cleaned);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline dotenv parser (reads key=value, skips comments)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseDotenv(filePath: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
if (!fs.existsSync(filePath)) return env;
|
||||
|
||||
const lines = fs.readFileSync(filePath, 'utf-8').split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eqIdx = trimmed.indexOf('=');
|
||||
if (eqIdx === -1) continue;
|
||||
const key = trimmed.slice(0, eqIdx).trim();
|
||||
let value = trimmed.slice(eqIdx + 1).trim();
|
||||
// Strip surrounding quotes
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status block emitter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function emitStatus(fields: Record<string, string | number | boolean>): void {
|
||||
const lines = ['=== NANOCLAW MIGRATE: CREDENTIAL ==='];
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
lines.push(`${key}: ${value}`);
|
||||
}
|
||||
lines.push('=== END ===');
|
||||
console.log(lines.join('\n'));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Credential masking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function maskCredential(value: string): string {
|
||||
if (value.length < 10) return '****';
|
||||
return `${value.slice(0, 4)}...${value.slice(-4)}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SecretRef resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SecretRef {
|
||||
source: string;
|
||||
provider?: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
function resolveSecretInput(
|
||||
value: unknown,
|
||||
dotenvVars: Record<string, string>,
|
||||
): { resolved: string | null; source: string; note?: string } {
|
||||
if (!value) return { resolved: null, source: 'missing' };
|
||||
|
||||
// Plain string
|
||||
if (typeof value === 'string') {
|
||||
// Check for env template: "${VAR_NAME}"
|
||||
const envMatch = value.match(/^\$\{([^}]+)\}$/);
|
||||
if (envMatch) {
|
||||
const envKey = envMatch[1];
|
||||
const envVal =
|
||||
dotenvVars[envKey] ?? process.env[envKey] ?? null;
|
||||
if (envVal) {
|
||||
return { resolved: envVal, source: 'env_template' };
|
||||
}
|
||||
return {
|
||||
resolved: null,
|
||||
source: 'env_template',
|
||||
note: `Environment variable ${envKey} not found`,
|
||||
};
|
||||
}
|
||||
|
||||
// Plain literal value
|
||||
return { resolved: value, source: 'plain' };
|
||||
}
|
||||
|
||||
// SecretRef object
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const ref = value as SecretRef;
|
||||
if (ref.source === 'env') {
|
||||
const envVal =
|
||||
dotenvVars[ref.id] ?? process.env[ref.id] ?? null;
|
||||
if (envVal) {
|
||||
return { resolved: envVal, source: 'env_ref' };
|
||||
}
|
||||
return {
|
||||
resolved: null,
|
||||
source: 'env_ref',
|
||||
note: `Environment variable ${ref.id} not found`,
|
||||
};
|
||||
}
|
||||
if (ref.source === 'file') {
|
||||
return {
|
||||
resolved: null,
|
||||
source: 'file_ref',
|
||||
note: `File-based secret (${ref.id}) — cannot auto-extract, add manually`,
|
||||
};
|
||||
}
|
||||
if (ref.source === 'exec') {
|
||||
return {
|
||||
resolved: null,
|
||||
source: 'exec_ref',
|
||||
note: `Exec-based secret (${ref.id}) — cannot auto-extract, add manually`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { resolved: null, source: 'unknown' };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Channel credential mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ChannelCredentialSpec {
|
||||
// Fields to look for in the channel config
|
||||
fields: string[];
|
||||
// Corresponding NanoClaw env var names
|
||||
envVars: string[];
|
||||
}
|
||||
|
||||
const CHANNEL_SPECS: Record<string, ChannelCredentialSpec> = {
|
||||
telegram: {
|
||||
fields: ['botToken'],
|
||||
envVars: ['TELEGRAM_BOT_TOKEN'],
|
||||
},
|
||||
discord: {
|
||||
fields: ['token'],
|
||||
envVars: ['DISCORD_BOT_TOKEN'],
|
||||
},
|
||||
slack: {
|
||||
fields: ['botToken', 'appToken'],
|
||||
envVars: ['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN'],
|
||||
},
|
||||
whatsapp: {
|
||||
fields: [], // Auth-state based, no token field
|
||||
envVars: [],
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI arg parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseArgs(): { channel: string; stateDir: string; writeEnv: string } {
|
||||
const args = process.argv.slice(2);
|
||||
let channel = '';
|
||||
let stateDir = '';
|
||||
let writeEnv = '';
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--channel' && args[i + 1]) {
|
||||
channel = args[++i].toLowerCase();
|
||||
}
|
||||
if (args[i] === '--state-dir' && args[i + 1]) {
|
||||
stateDir = args[++i];
|
||||
}
|
||||
if (args[i] === '--write-env' && args[i + 1]) {
|
||||
writeEnv = args[++i];
|
||||
}
|
||||
}
|
||||
|
||||
if (!channel) {
|
||||
console.error('Usage: --channel <name> --state-dir <path> [--write-env <path>]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Expand ~ prefix
|
||||
if (stateDir.startsWith('~')) {
|
||||
stateDir = path.join(os.homedir(), stateDir.slice(1));
|
||||
}
|
||||
|
||||
// Default state dir
|
||||
if (!stateDir) {
|
||||
const home = os.homedir();
|
||||
if (fs.existsSync(path.join(home, '.openclaw'))) {
|
||||
stateDir = path.join(home, '.openclaw');
|
||||
} else if (fs.existsSync(path.join(home, '.clawdbot'))) {
|
||||
stateDir = path.join(home, '.clawdbot');
|
||||
} else {
|
||||
console.error(
|
||||
'No OpenClaw directory found. Use --state-dir to specify.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
return { channel, stateDir, writeEnv };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// .env writer — appends or replaces a KEY=VALUE line
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function writeEnvVar(envPath: string, key: string, value: string): void {
|
||||
let content = '';
|
||||
if (fs.existsSync(envPath)) {
|
||||
content = fs.readFileSync(envPath, 'utf-8');
|
||||
}
|
||||
|
||||
const pattern = new RegExp(`^${key}=.*$`, 'm');
|
||||
const line = `${key}="${value}"`;
|
||||
|
||||
if (pattern.test(content)) {
|
||||
content = content.replace(pattern, line);
|
||||
} else {
|
||||
content = content.trimEnd() + (content ? '\n' : '') + line + '\n';
|
||||
}
|
||||
|
||||
fs.writeFileSync(envPath, content);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function main(): void {
|
||||
const { channel, stateDir, writeEnv } = parseArgs();
|
||||
const spec = CHANNEL_SPECS[channel];
|
||||
|
||||
// Load dotenv from state dir
|
||||
const dotenvVars = parseDotenv(path.join(stateDir, '.env'));
|
||||
|
||||
// Also check auth-profiles.json for API keys
|
||||
const authProfilesPath = path.join(stateDir, 'auth-profiles.json');
|
||||
let authProfiles: Record<string, unknown> = {};
|
||||
if (fs.existsSync(authProfilesPath)) {
|
||||
try {
|
||||
authProfiles = JSON.parse(
|
||||
fs.readFileSync(authProfilesPath, 'utf-8'),
|
||||
) as Record<string, unknown>;
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// WhatsApp special case: no token, auth-state based.
|
||||
// OpenClaw stores Baileys auth at <stateDir>/credentials/whatsapp/<accountId>/
|
||||
// using useMultiFileAuthState (same as NanoClaw). The files are directly compatible.
|
||||
if (channel === 'whatsapp') {
|
||||
const authPaths = [
|
||||
path.join(stateDir, 'credentials', 'whatsapp', 'default'),
|
||||
path.join(stateDir, 'credentials', 'whatsapp'),
|
||||
path.join(stateDir, 'wa-auth'),
|
||||
];
|
||||
|
||||
// Also scan credentials/whatsapp/ for any account subdirectory
|
||||
const waCredsDir = path.join(stateDir, 'credentials', 'whatsapp');
|
||||
if (fs.existsSync(waCredsDir)) {
|
||||
try {
|
||||
for (const entry of fs.readdirSync(waCredsDir)) {
|
||||
const candidate = path.join(waCredsDir, entry);
|
||||
if (fs.statSync(candidate).isDirectory()) {
|
||||
authPaths.push(candidate);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
let authStatePath = '';
|
||||
for (const p of authPaths) {
|
||||
// Look for creds.json inside the directory — that confirms valid Baileys auth state
|
||||
if (fs.existsSync(path.join(p, 'creds.json'))) {
|
||||
authStatePath = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
emitStatus({
|
||||
CHANNEL: 'whatsapp',
|
||||
HAS_CREDENTIAL: false,
|
||||
CREDENTIAL_SOURCE: 'auth_state',
|
||||
NOTE: authStatePath
|
||||
? `Baileys auth state found at ${authStatePath}. May not be portable across versions — recommend re-authenticating.`
|
||||
: 'No WhatsApp auth state found. Will need to authenticate during setup.',
|
||||
AUTH_STATE_PATH: authStatePath || 'not_found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Unknown channel
|
||||
if (!spec) {
|
||||
emitStatus({
|
||||
CHANNEL: channel,
|
||||
HAS_CREDENTIAL: false,
|
||||
NOTE: `Channel "${channel}" is not supported by NanoClaw. Supported: telegram, discord, slack, whatsapp.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Load OpenClaw config
|
||||
let config: Record<string, unknown> | null = null;
|
||||
for (const name of ['openclaw.json', 'clawdbot.json']) {
|
||||
const configPath = path.join(stateDir, name);
|
||||
if (fs.existsSync(configPath)) {
|
||||
try {
|
||||
config = parseJson5(
|
||||
fs.readFileSync(configPath, 'utf-8'),
|
||||
) as Record<string, unknown>;
|
||||
break;
|
||||
} catch {
|
||||
// Try next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
emitStatus({
|
||||
CHANNEL: channel,
|
||||
HAS_CREDENTIAL: false,
|
||||
NOTE: 'Could not load openclaw.json',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const channels =
|
||||
(config.channels as Record<string, unknown> | undefined) ?? {};
|
||||
const channelConfig =
|
||||
(channels[channel] as Record<string, unknown> | undefined) ?? {};
|
||||
|
||||
// Try to resolve each credential field
|
||||
const results: Array<{
|
||||
envVar: string;
|
||||
resolved: string | null;
|
||||
masked: string;
|
||||
source: string;
|
||||
note?: string;
|
||||
}> = [];
|
||||
|
||||
for (let i = 0; i < spec.fields.length; i++) {
|
||||
const field = spec.fields[i];
|
||||
const envVar = spec.envVars[i];
|
||||
|
||||
// Check top-level channel config first
|
||||
let rawValue = channelConfig[field];
|
||||
|
||||
// If not found, check first account
|
||||
if (!rawValue && channelConfig.accounts) {
|
||||
const accounts = channelConfig.accounts as Record<string, unknown>;
|
||||
const firstAccount = Object.values(accounts)[0] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (firstAccount) {
|
||||
rawValue = firstAccount[field];
|
||||
}
|
||||
}
|
||||
|
||||
const { resolved, source, note } = resolveSecretInput(
|
||||
rawValue,
|
||||
dotenvVars,
|
||||
);
|
||||
results.push({
|
||||
envVar,
|
||||
resolved,
|
||||
masked: resolved ? maskCredential(resolved) : '',
|
||||
source,
|
||||
note,
|
||||
});
|
||||
}
|
||||
|
||||
// Emit results for the primary credential
|
||||
const primary = results[0];
|
||||
if (!primary) {
|
||||
emitStatus({
|
||||
CHANNEL: channel,
|
||||
HAS_CREDENTIAL: false,
|
||||
NOTE: `No credential fields defined for ${channel}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If --write-env is set and credentials were resolved, write directly to .env.
|
||||
// Credential values never appear in stdout.
|
||||
let written = 0;
|
||||
if (writeEnv) {
|
||||
for (const r of results) {
|
||||
if (r.resolved) {
|
||||
writeEnvVar(writeEnv, r.envVar, r.resolved);
|
||||
written++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fields: Record<string, string | number | boolean> = {
|
||||
CHANNEL: channel,
|
||||
HAS_CREDENTIAL: !!primary.resolved,
|
||||
CREDENTIAL_SOURCE: primary.source,
|
||||
CREDENTIAL_MASKED: primary.masked || 'none',
|
||||
NANOCLAW_ENV_VAR: primary.envVar,
|
||||
};
|
||||
|
||||
if (writeEnv && written > 0) {
|
||||
fields.WRITTEN_TO = writeEnv;
|
||||
fields.WRITTEN_COUNT = written;
|
||||
}
|
||||
if (primary.note) {
|
||||
fields.NOTE = primary.note;
|
||||
}
|
||||
|
||||
// Additional credentials (e.g. Slack has botToken + appToken)
|
||||
if (results.length > 1) {
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
const extra = results[i];
|
||||
const suffix = `_${i + 1}`;
|
||||
fields[`HAS_CREDENTIAL${suffix}`] = !!extra.resolved;
|
||||
fields[`CREDENTIAL_SOURCE${suffix}`] = extra.source;
|
||||
fields[`CREDENTIAL_MASKED${suffix}`] = extra.masked || 'none';
|
||||
fields[`NANOCLAW_ENV_VAR${suffix}`] = extra.envVar;
|
||||
if (extra.note) {
|
||||
fields[`NOTE${suffix}`] = extra.note;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emitStatus(fields);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,484 @@
|
||||
---
|
||||
name: migrate-nanoclaw
|
||||
description: Extracts user customizations from a fork, generates a replayable migration guide, and upgrades to upstream by reapplying customizations on a clean base. Replaces merge-based upgrades with intent-based migration.
|
||||
---
|
||||
|
||||
# Context
|
||||
|
||||
NanoClaw users fork the repo and customize it — changing config values, editing source files, modifying personas, adding skills. When upstream ships updates or refactors, `git merge` produces painful conflicts because the same core files were changed on both sides.
|
||||
|
||||
This skill extracts the user's customizations into a migration guide — capturing both the intent (what they want) and the implementation details (how they did it, with code snippets, API calls, and specific configurations). On upgrade, it checks out clean upstream in a worktree, then reapplies customizations using the guide. No merge conflicts because there's nothing to merge.
|
||||
|
||||
The migration guide is markdown, not structured data. It needs to capture the full range of what a user might customize, with enough implementation detail that a fresh Claude session can reapply it without having seen the original code. Standard changes (config values, simple logic) can be described briefly. Non-standard changes (specific APIs, custom integrations, unusual patterns) need code snippets and precise instructions.
|
||||
|
||||
Two phases: **Extract** (build the migration guide) and **Upgrade** (use it). If a guide already exists, offer to skip to Upgrade.
|
||||
|
||||
# Principles
|
||||
|
||||
- Never proceed with a dirty working tree.
|
||||
- Always create a rollback point (backup branch + tag) before touching anything.
|
||||
- The migration guide is the source of truth, not diffs.
|
||||
- Use a worktree to validate before affecting the live install.
|
||||
- Data directories (`groups/`, `store/`, `data/`, `.env`) are never touched — only code.
|
||||
- Be helpful: offer to do things (stash, commit, stop services) rather than telling the user to do them.
|
||||
- **Use sub-agents for exploration.** Spawn haiku sub-agents to explore the codebase, trace skill merges, diff files, and identify customizations. This keeps the main context focused on the user conversation and decision-making.
|
||||
- **Always use absolute paths in worktrees.** The Bash tool resets the working directory between calls. Never use relative `cd .upgrade-worktree` — always use the full absolute path: `cd /absolute/path/.upgrade-worktree && <command>`. Store the worktree absolute path in a variable at creation time and reference it throughout.
|
||||
- **Balance exploration and asking.** Don't bombard the user with questions when you can figure things out from the code. Don't burn endless tokens exploring when the user could clarify in one sentence. Use sub-agents to explore first, then ask the user targeted questions about things that are ambiguous or where intent isn't clear from the code alone.
|
||||
- **Scale effort to complexity.** Not every migration needs the full process. Assess the scope early and take the lightest path that fits.
|
||||
|
||||
---
|
||||
|
||||
# Phase 1: Extract
|
||||
|
||||
## 1.0 Preflight
|
||||
|
||||
Run `git status --porcelain`. If non-empty, offer to stash or commit for them (AskUserQuestion: "Stash changes" / "Commit changes" / "I'll handle it"). If they want to commit, stage and commit with a descriptive message. If they want to stash, run `git stash push -m "pre-migration stash"`.
|
||||
|
||||
Check remotes with `git remote -v`. If `upstream` is missing, ask for the URL (default: `https://github.com/qwibitai/nanoclaw.git`), add it, then `git fetch upstream --prune`.
|
||||
|
||||
Detect upstream branch: check `git branch -r | grep upstream/` for `main` or `master`. Store as UPSTREAM_BRANCH.
|
||||
|
||||
## 1.1 Assess scope and determine path
|
||||
|
||||
Quickly assess the scale of divergence, check for an existing guide, and determine the right approach — all before asking the user anything.
|
||||
|
||||
```bash
|
||||
BASE=$(git merge-base HEAD upstream/$UPSTREAM_BRANCH)
|
||||
# Divergence stats
|
||||
git rev-list --count $BASE..upstream/$UPSTREAM_BRANCH # upstream commits
|
||||
git rev-list --count $BASE..HEAD # user commits
|
||||
git diff --name-only $BASE..HEAD | wc -l # user changed files
|
||||
git diff --stat $BASE..HEAD | tail -1 # insertions/deletions
|
||||
git diff --name-only $BASE..upstream/$UPSTREAM_BRANCH | wc -l # upstream changed files
|
||||
```
|
||||
|
||||
Check for existing guide: `.nanoclaw-migrations/guide.md` or `.nanoclaw-migrations/index.md`.
|
||||
|
||||
**Determine the tier based on the total diff from base:**
|
||||
|
||||
### Tier 1: Lightweight — suggest `/update-nanoclaw` instead
|
||||
|
||||
Conditions (any of):
|
||||
- Very few upstream changes (< ~5 commits) AND few user changes (< ~3 changed files)
|
||||
- User recently updated/migrated (merge-base is close to upstream HEAD)
|
||||
|
||||
Tell the user the scope is small and suggest `/update-nanoclaw` might be simpler. Let them choose.
|
||||
|
||||
### Tier 2: Standard
|
||||
|
||||
Conditions:
|
||||
- Moderate total diff (3-15 changed files, no large number of new files)
|
||||
- Manageable scope that fits in a single guide file
|
||||
|
||||
### Tier 3: Complex
|
||||
|
||||
Conditions (any of):
|
||||
- Many new files added (indicates many skills applied) — discount files that come purely from skill merges when assessing complexity; a fork with 3 skills and no other changes is simpler than it looks by file count alone
|
||||
- Deep source changes to core files (`src/index.ts`, `src/container-runner.ts`, etc.) beyond what skills introduced
|
||||
- Lots of insertions/deletions in user-authored code (not skill-merged code)
|
||||
- Many skills applied (3+) AND the user confirms or sub-agents find customizations on top of them
|
||||
|
||||
Use the full process: multiple sub-agents in parallel, directory-based guide, migration plan.
|
||||
|
||||
**Now combine the scope assessment with initial user input in one interaction.** Present the scope summary (how many commits, files, which tier) and ask (AskUserQuestion):
|
||||
|
||||
For Tier 1:
|
||||
- **Use /update-nanoclaw** — simpler merge-based approach
|
||||
- **Proceed with full migration** — continue
|
||||
|
||||
For Tier 2/3 (with or without existing guide):
|
||||
- If guide exists and is current: **Skip to upgrade** / **Update guide** (add new changes) / **Re-extract from scratch**
|
||||
- If guide exists but is stale: **Update guide** (recommended) / **Re-extract from scratch** / **Skip to upgrade anyway**
|
||||
- If no guide: **Yes, let me describe my customizations first** / **Just figure it out** / **A bit of both**
|
||||
|
||||
This single interaction replaces what were previously separate steps for scope assessment, user input, and existing guide check.
|
||||
|
||||
## 1.2 Update existing guide (if applicable)
|
||||
|
||||
If the user chose to update an existing guide rather than re-extract:
|
||||
|
||||
1. Read the existing guide
|
||||
2. Find commits made since the guide was generated (compare guide's recorded base hash against current HEAD)
|
||||
3. Spawn a haiku sub-agent to analyze only the new changes:
|
||||
> Diff HEAD against `<guide-recorded-hash>`. For each changed file, summarize what changed and why.
|
||||
4. Present the new changes to the user for confirmation
|
||||
5. Append new customizations to the existing guide, update the header hashes
|
||||
6. Skip to Phase 2
|
||||
|
||||
## 1.3 Explore the codebase
|
||||
|
||||
Spawn a haiku sub-agent (Agent tool, model: haiku) for initial exploration:
|
||||
|
||||
> Explore this NanoClaw fork to identify all changes from the upstream base. Run these commands and report back:
|
||||
>
|
||||
> 1. `git diff --name-only $BASE..HEAD` — all changed files
|
||||
> 2. `git log --oneline $BASE..HEAD` — all commits (look for skill branch merges like `Merge branch 'skill/*'`)
|
||||
> 3. `git branch -r --list 'upstream/skill/*'` — available upstream skill branches
|
||||
> 4. `ls .claude/skills/` — installed skills
|
||||
> 5. For each skill merge found, record the merge commit hash
|
||||
>
|
||||
> Report: (a) list of applied skills with their merge commit hashes, (b) list of all changed files, (c) any custom skill directories that don't match upstream branches.
|
||||
|
||||
From the sub-agent results, identify:
|
||||
- **Which files came purely from skill merges** — these will be reapplied by re-merging skill branches in Phase 2
|
||||
- **Everything else** — all remaining changes are customizations to analyze (whether they're on skill-touched files or not)
|
||||
|
||||
Don't try to distinguish "user modified a skill file" from "user made their own change" at this stage. The sub-agents in 1.4 will look at all non-skill changes together and surface what matters.
|
||||
|
||||
## 1.4 Analyze customizations
|
||||
|
||||
For each applied skill, ask the user in a single batched question (AskUserQuestion, multiSelect):
|
||||
|
||||
> "I found these applied skills. Select any you customized further after applying:"
|
||||
|
||||
Options: one per skill, plus "None — all used as-is".
|
||||
|
||||
Then spawn sub-agents to analyze all non-skill changes. For Tier 2, one or two agents. For Tier 3, run in parallel by area:
|
||||
|
||||
- **Config + build files** — one sub-agent
|
||||
- **Source files** (`src/*.ts`) — one sub-agent
|
||||
- **Skills the user flagged as modified** (or all of them for Tier 3) — one sub-agent per skill, comparing the user's current files against the skill merge commit version:
|
||||
```
|
||||
git diff <merge-commit-hash>..HEAD -- <files-touched-by-skill>
|
||||
```
|
||||
- **Container files** — one sub-agent (if changes exist)
|
||||
|
||||
Each sub-agent task:
|
||||
|
||||
> Read these diffs and the current file contents. For each change:
|
||||
> 1. `git diff $BASE..HEAD -- <file>` (or `git diff <skill-merge-hash>..HEAD -- <file>` for skill-modified files)
|
||||
> 2. Read the full current file for context
|
||||
> 3. Summarize: what changed, what the likely intent is
|
||||
> 4. Assess detail level: could a fresh Claude session reproduce this from intent alone, or does it need specific code snippets, API details, import paths?
|
||||
> 5. For non-standard changes, extract the key code, imports, API calls, and configurations verbatim.
|
||||
|
||||
**Inter-skill conflicts:** If multiple skills are applied, spawn an additional sub-agent to check for interactions between them. Look for:
|
||||
- Duplicate declarations (same variable/constant defined by two skill branches)
|
||||
- Conflicting approaches (one skill throws on missing env var, another provides a fallback)
|
||||
- Shared files modified by multiple skills
|
||||
|
||||
Document any findings in the "Skill Interactions" section of the migration guide so they can be resolved after skill branches are re-merged during upgrade.
|
||||
|
||||
## 1.5 Confirm with user
|
||||
|
||||
After sub-agents report back, compile the findings and present to the user.
|
||||
|
||||
For customizations where the intent is clear (config values, simple modifications): present as a batch for confirmation. Use AskUserQuestion with multiSelect to let the user flag any entries that need correction.
|
||||
|
||||
For customizations where the intent is ambiguous: ask specific questions. Don't ask "what did you do?" — instead ask "I see you added X in this file. Was this for Y or something else?"
|
||||
|
||||
The user can select "Other" on any question to provide their own description.
|
||||
|
||||
## 1.6 Migration plan (Tier 3 only)
|
||||
|
||||
For complex migrations, before writing the guide, create a migration plan:
|
||||
|
||||
- **Order of operations**: which customizations depend on others, which skills must be applied first
|
||||
- **Staging**: whether the migration should happen in stages (e.g. apply skills first, validate, then apply source customizations)
|
||||
- **Risk areas**: customizations that touch files heavily changed by upstream — these may need manual review
|
||||
- **Interactions**: customizations that interact with each other (e.g. a source change that depends on a skill, or two customizations that touch the same file)
|
||||
|
||||
Present the plan to the user for review before proceeding to the guide.
|
||||
|
||||
## 1.7 Write the migration guide
|
||||
|
||||
**Storage:** `.nanoclaw-migrations/guide.md` for Tier 2. `.nanoclaw-migrations/` directory with `index.md` and section files for Tier 3.
|
||||
|
||||
**Verification:** After writing the guide, read it back and verify:
|
||||
- Every referenced file path exists in the current codebase
|
||||
- Code snippets match what's actually in the files
|
||||
- No customizations from the analysis were accidentally omitted
|
||||
|
||||
The guide is structured markdown that a fresh Claude session can follow to reproduce this user's exact setup on a clean upstream checkout.
|
||||
|
||||
Structure:
|
||||
|
||||
```markdown
|
||||
# NanoClaw Migration Guide
|
||||
|
||||
Generated: <timestamp>
|
||||
Base: <BASE hash>
|
||||
HEAD at generation: <HEAD hash>
|
||||
Upstream: <upstream HEAD hash>
|
||||
|
||||
## Migration Plan
|
||||
|
||||
(Tier 3 only — big-picture overview of order, staging, risks)
|
||||
|
||||
## Applied Skills
|
||||
|
||||
List each skill with its branch name. These are reapplied by merging the upstream skill branch.
|
||||
|
||||
- `add-telegram` — branch `skill/telegram`
|
||||
- `add-voice-transcription` — branch `skill/voice-transcription`
|
||||
|
||||
Custom skills (user-created, not from upstream): `.claude/skills/my-custom-skill/` — copy as-is from main tree.
|
||||
|
||||
## Skill Interactions
|
||||
|
||||
(Document known conflicts or interactions between applied skills.
|
||||
When two or more skills modify the same file or depend on shared
|
||||
config, describe the conflict and how to resolve it after merging.
|
||||
Example: skill A and skill B both add a PROXY_BIND_HOST declaration —
|
||||
after merging both, deduplicate. Or: skill A throws if ENV_VAR is
|
||||
missing, but skill B provides a fallback — use the fallback version.)
|
||||
|
||||
## Modifications to Applied Skills
|
||||
|
||||
### <Skill name>: <what was modified>
|
||||
|
||||
**Intent:** ...
|
||||
|
||||
**Files:** ...
|
||||
|
||||
**How to apply:** (after the skill branch has been merged)
|
||||
|
||||
...
|
||||
|
||||
## Customizations
|
||||
|
||||
### <Descriptive title for customization>
|
||||
|
||||
**Intent:** What the user wants and why.
|
||||
|
||||
**Files:** Which files to modify.
|
||||
|
||||
**How to apply:**
|
||||
|
||||
<For standard changes, a brief description is enough.>
|
||||
|
||||
<For non-standard changes, include code snippets, API details,
|
||||
specific values, import paths — everything needed to reproduce
|
||||
without seeing the original diff.>
|
||||
|
||||
### <Next customization...>
|
||||
```
|
||||
|
||||
**Judging detail level:** For each customization, assess whether a fresh Claude session could reproduce it from intent alone:
|
||||
- **Standard changes** (config values, simple logic, well-known patterns): describe the intent and the target. Example: "Change `POLL_INTERVAL` in `src/config.ts` from 2000 to 1000."
|
||||
- **Non-standard changes** (specific API usage, custom integrations, unusual patterns, library-specific configurations): include the actual code snippets, import paths, API endpoints, configuration objects — everything needed to reproduce it without guessing.
|
||||
|
||||
Example entries at different detail levels:
|
||||
|
||||
**Standard (brief):**
|
||||
```markdown
|
||||
### Custom trigger word
|
||||
|
||||
**Intent:** Use `@Bob` instead of the default `@Andy`.
|
||||
|
||||
**Files:** `src/config.ts`
|
||||
|
||||
**How to apply:** Change the default value of `ASSISTANT_NAME` from `'Andy'` to `'Bob'`.
|
||||
```
|
||||
|
||||
**Non-standard (detailed):**
|
||||
```markdown
|
||||
### Spanish translation for outbound messages
|
||||
|
||||
**Intent:** All outbound messages are translated to Spanish before sending. Uses the DeepL API via the `deepl-node` package.
|
||||
|
||||
**Files:** `src/router.ts`, `package.json`
|
||||
|
||||
**How to apply:**
|
||||
|
||||
1. Add dependency: `npm install deepl-node`
|
||||
|
||||
2. In `src/router.ts`, add import at top:
|
||||
```typescript
|
||||
import * as deepl from 'deepl-node';
|
||||
const translator = new deepl.Translator(process.env.DEEPL_API_KEY!);
|
||||
```
|
||||
|
||||
3. In the `formatOutbound` function, before the return statement, add:
|
||||
```typescript
|
||||
const result = await translator.translateText(text, null, 'es');
|
||||
text = result.text;
|
||||
```
|
||||
Note: the function needs to be made async if it isn't already.
|
||||
```
|
||||
|
||||
After writing, offer to commit for the user:
|
||||
```bash
|
||||
git add .nanoclaw-migrations/
|
||||
git commit -m "chore: save migration guide"
|
||||
```
|
||||
|
||||
Ask (AskUserQuestion): "Migration guide saved. Want to upgrade now or later?"
|
||||
- **Upgrade now** — continue to Phase 2
|
||||
- **Later** — stop here
|
||||
|
||||
---
|
||||
|
||||
# Phase 2: Upgrade
|
||||
|
||||
## 2.0 Preflight
|
||||
|
||||
Same checks as 1.0 — clean tree (offer to stash/commit if dirty), upstream configured, fetch latest.
|
||||
|
||||
Read the migration guide. If missing, tell the user you need to extract customizations first and ask if they want to do that now.
|
||||
|
||||
**New-changes guard:** Compare the guide's "HEAD at generation" hash against current HEAD. If there are commits since the guide was generated, warn the user:
|
||||
|
||||
> "You've made changes since the migration guide was generated. These changes won't be included in the upgrade."
|
||||
|
||||
AskUserQuestion:
|
||||
- **Update the guide first** — go to step 1.2 to incorporate new changes
|
||||
- **Proceed anyway** — user accepts that recent changes will be lost
|
||||
- **Abort** — stop
|
||||
|
||||
## 2.1 Safety net
|
||||
|
||||
```bash
|
||||
HASH=$(git rev-parse --short HEAD)
|
||||
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||
git branch backup/pre-migrate-$HASH-$TIMESTAMP
|
||||
git tag pre-migrate-$HASH-$TIMESTAMP
|
||||
```
|
||||
|
||||
Save the tag name for rollback instructions at the end.
|
||||
|
||||
## 2.2 Preview upstream changes
|
||||
|
||||
```bash
|
||||
BASE=$(git merge-base HEAD upstream/$UPSTREAM_BRANCH)
|
||||
git log --oneline $BASE..upstream/$UPSTREAM_BRANCH
|
||||
git diff $BASE..upstream/$UPSTREAM_BRANCH -- CHANGELOG.md
|
||||
```
|
||||
|
||||
If there are `[BREAKING]` entries, show them and explain how they interact with the user's customizations from the migration guide.
|
||||
|
||||
Ask (AskUserQuestion) to proceed or abort.
|
||||
|
||||
## 2.3 Create upgrade worktree
|
||||
|
||||
```bash
|
||||
PROJECT_ROOT=$(pwd)
|
||||
git worktree add .upgrade-worktree upstream/$UPSTREAM_BRANCH --detach
|
||||
WORKTREE="$PROJECT_ROOT/.upgrade-worktree"
|
||||
```
|
||||
|
||||
Store `$PROJECT_ROOT` and `$WORKTREE` as absolute paths. Use `$WORKTREE` in all subsequent commands — never `cd .upgrade-worktree` with a relative path.
|
||||
|
||||
## 2.4 Reapply skills in worktree
|
||||
|
||||
For each skill listed in the migration guide's "Applied Skills" section:
|
||||
|
||||
1. Check if branch exists: `git branch -r --list "upstream/$branch"`
|
||||
2. If yes, merge it in the worktree:
|
||||
```bash
|
||||
cd "$WORKTREE" && git merge upstream/skill/<name> --no-edit
|
||||
```
|
||||
3. If missing, warn the user (skill may have been removed or renamed upstream).
|
||||
4. If any skill merge conflicts, stop and tell the user — the skill needs updating for the new upstream.
|
||||
|
||||
Copy any custom skills mentioned in the guide from the main tree into the worktree.
|
||||
|
||||
## 2.5 Reapply customizations in worktree
|
||||
|
||||
Work in `.upgrade-worktree/`. Follow each customization section in the migration guide, including "Modifications to Applied Skills."
|
||||
|
||||
For Tier 3 migrations with a migration plan, follow the plan's ordering and staging. If the plan calls for staged validation (e.g. validate after skills, then validate after source changes), do so.
|
||||
|
||||
For each customization:
|
||||
1. Read the "How to apply" instructions from the guide
|
||||
2. Read the target file(s) in the worktree to understand the current upstream version
|
||||
3. Apply the changes as described — use the code snippets and specific instructions from the guide
|
||||
4. If the target file has changed significantly from what the guide expects (function removed, file restructured, API changed), flag it and ask the user what to do
|
||||
5. Verify the file has no syntax errors or broken imports after each change
|
||||
|
||||
For behavior customizations (CLAUDE.md files): copy from the main tree. These are user content, not code.
|
||||
|
||||
## 2.6 Validate in worktree
|
||||
|
||||
```bash
|
||||
cd "$WORKTREE" && npm install && npm run build && npm test
|
||||
```
|
||||
|
||||
If build fails, show the error. Fix only issues caused by the migration. If unclear, ask the user.
|
||||
|
||||
## 2.7 Live test (optional)
|
||||
|
||||
Ask (AskUserQuestion):
|
||||
- **Test live** — stop service, run from worktree against real data, send a test message
|
||||
- **Skip** — trust the build, proceed to swap
|
||||
|
||||
If testing live:
|
||||
|
||||
1. Stop the service (do this directly):
|
||||
```bash
|
||||
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist 2>/dev/null || true
|
||||
```
|
||||
|
||||
2. Symlink data into the worktree:
|
||||
```bash
|
||||
ln -s "$PROJECT_ROOT/store" "$WORKTREE/store"
|
||||
ln -s "$PROJECT_ROOT/data" "$WORKTREE/data"
|
||||
ln -s "$PROJECT_ROOT/groups" "$WORKTREE/groups"
|
||||
ln -s "$PROJECT_ROOT/.env" "$WORKTREE/.env"
|
||||
```
|
||||
|
||||
3. Start from worktree: `cd "$WORKTREE" && npm run dev`
|
||||
|
||||
4. Ask the user to send a test message from their phone. Wait for them to confirm it works.
|
||||
|
||||
5. After confirmation, stop the dev server.
|
||||
|
||||
6. Clean up symlinks:
|
||||
```bash
|
||||
rm "$WORKTREE/store" "$WORKTREE/data" "$WORKTREE/groups" "$WORKTREE/.env"
|
||||
```
|
||||
|
||||
## 2.8 Swap into main tree
|
||||
|
||||
The swap must be done carefully — the worktree has the upgraded code, but main needs to point to it cleanly. Use absolute paths throughout.
|
||||
|
||||
```bash
|
||||
# 1. Capture the worktree HEAD before removing it
|
||||
WORKTREE_PATH=$(cd "$PROJECT_ROOT/.upgrade-worktree" && pwd)
|
||||
UPGRADE_COMMIT=$(git -C "$WORKTREE_PATH" rev-parse HEAD)
|
||||
|
||||
# 2. Copy the migration guide out of the worktree before removing it
|
||||
cp -r "$WORKTREE_PATH/.nanoclaw-migrations" /tmp/nanoclaw-migrations-backup 2>/dev/null || true
|
||||
|
||||
# 3. Remove the worktree
|
||||
git worktree remove "$WORKTREE_PATH" --force
|
||||
|
||||
# 4. Point the current branch at the upgraded commit
|
||||
git reset --hard $UPGRADE_COMMIT
|
||||
|
||||
# 5. Restore the migration guide and update its hashes
|
||||
cp -r /tmp/nanoclaw-migrations-backup/* .nanoclaw-migrations/ 2>/dev/null || true
|
||||
rm -rf /tmp/nanoclaw-migrations-backup
|
||||
```
|
||||
|
||||
Update the guide's header hashes to reflect the new state. Offer to commit:
|
||||
```bash
|
||||
git add .nanoclaw-migrations/
|
||||
git commit -m "chore: upgrade to upstream $(git rev-parse --short upstream/$UPSTREAM_BRANCH)"
|
||||
```
|
||||
|
||||
Do NOT use `git checkout -B` to create an intermediate branch — this caused issues in practice. The `git reset --hard` to the upgrade commit is the cleanest path since the backup tag already preserves the pre-upgrade state.
|
||||
|
||||
## 2.9 Post-upgrade
|
||||
|
||||
Run `npm install && npm run build` in the main tree to confirm.
|
||||
|
||||
Restart the service:
|
||||
```bash
|
||||
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
```
|
||||
|
||||
Show summary:
|
||||
- Previous version (backup tag)
|
||||
- New HEAD
|
||||
- Customizations reapplied (list from guide)
|
||||
- Skills reapplied
|
||||
- Rollback: `git reset --hard <backup-tag>`
|
||||
- Any customizations that needed manual adjustment
|
||||
|
||||
Offer to pop the stash if one was created in preflight: `git stash pop`
|
||||
|
||||
## Diagnostics
|
||||
|
||||
1. Use the Read tool to read `.claude/skills/migrate-nanoclaw/diagnostics.md`.
|
||||
2. Follow every step in that file before finishing.
|
||||
@@ -0,0 +1,52 @@
|
||||
# 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": "migrate_complete",
|
||||
"distinct_id": "<uuid>",
|
||||
"properties": {
|
||||
"success": true,
|
||||
"nanoclaw_version": "1.2.43",
|
||||
"os_platform": "darwin",
|
||||
"arch": "arm64",
|
||||
"node_major_version": 22,
|
||||
"migration_phase": "extract|upgrade|both",
|
||||
"tier": 2,
|
||||
"customization_count": 3,
|
||||
"skills_applied_count": 2,
|
||||
"skill_interaction_count": 0,
|
||||
"live_test": false,
|
||||
"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. Replace contents of `.claude/skills/migrate-nanoclaw/diagnostics.md` with `# Diagnostics — opted out`
|
||||
4. Remove the diagnostics sections from each corresponding SKILL.md
|
||||
5. `rm /tmp/nanoclaw-diagnostics.json`
|
||||
+182
-14
@@ -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,29 @@ 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
|
||||
|
||||
### OpenClaw Migration Detection
|
||||
|
||||
Check for an existing OpenClaw installation:
|
||||
|
||||
```bash
|
||||
ls -d ~/.openclaw 2>/dev/null || ls -d ~/.clawdbot 2>/dev/null
|
||||
```
|
||||
|
||||
If a directory is found, AskUserQuestion:
|
||||
|
||||
1. **Migrate now** — "Import identity, credentials, and settings from OpenClaw before continuing setup."
|
||||
2. **Fresh start** — "Skip migration and set up NanoClaw from scratch."
|
||||
3. **Migrate later** — "Continue setup now, run `/migrate-from-openclaw` anytime later."
|
||||
|
||||
If "Migrate now": invoke `/migrate-from-openclaw`, then return here and continue at step 2a (Timezone).
|
||||
|
||||
## 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 +100,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 +124,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 +138,106 @@ 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, the ONECLI_URL was output from the install script above:
|
||||
```bash
|
||||
onecli config set api-host ${ONECLI_URL}
|
||||
```
|
||||
|
||||
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=${ONECLI_URL}' >> .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 ${ONECLI_URL} 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 ${ONECLI_URL} 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. The credential proxy code is already included in the apple-container branch — do NOT invoke `/use-native-credential-proxy` (it would conflict with already-applied code).
|
||||
|
||||
Instead, just configure the credentials in `.env`:
|
||||
|
||||
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. 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."
|
||||
|
||||
For subscription: tell the user to run `claude setup-token` in another terminal. Stop and wait for the user to confirm they have completed this step successfully before proceeding.
|
||||
|
||||
Once confirmed, add the token to `.env`:
|
||||
```bash
|
||||
echo 'CLAUDE_CODE_OAUTH_TOKEN=<their-token>' >> .env
|
||||
```
|
||||
|
||||
For API key: add to `.env`:
|
||||
```bash
|
||||
echo 'ANTHROPIC_API_KEY=<their-key>' >> .env
|
||||
```
|
||||
|
||||
Verify the proxy starts: `npm run dev` should show "Credential proxy listening" in the logs.
|
||||
|
||||
## 5. Set Up Channels
|
||||
|
||||
@@ -101,13 +257,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 +315,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 +324,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 ${ONECLI_URL}/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 +333,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.
|
||||
|
||||
@@ -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]"
|
||||
```
|
||||
|
||||
Check if the user migrated from OpenClaw during this setup session (i.e. `/migrate-from-openclaw` was invoked). If you're unsure (e.g. after context compaction), check for `migration-state.md` in the project root — it exists during and sometimes after migration.
|
||||
|
||||
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"],
|
||||
"migrated_from_openclaw": false,
|
||||
"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`
|
||||
@@ -112,6 +112,8 @@ Bucket the upstream changed files:
|
||||
- **Build/config** (`package.json`, `package-lock.json`, `tsconfig*.json`, `container/`, `launchd/`): review needed
|
||||
- **Other**: docs, tests, misc
|
||||
|
||||
**Large drift check:** If the upstream commit count and age suggest the user has a lot of catching up to do, mention that `/migrate-nanoclaw` might be a better fit — it extracts customizations and reapplies them on clean upstream instead of merging. Offer it as an option but don't push.
|
||||
|
||||
Present these buckets to the user and ask them to choose one path using AskUserQuestion:
|
||||
- A) **Full update**: merge all upstream changes
|
||||
- B) **Selective update**: cherry-pick specific upstream commits
|
||||
@@ -188,13 +190,13 @@ After validation succeeds, check if the update introduced any breaking changes.
|
||||
Determine which CHANGELOG entries are new by diffing against the backup tag:
|
||||
- `git diff <backup-tag-from-step-1>..HEAD -- CHANGELOG.md`
|
||||
|
||||
Parse the diff output for lines starting with `+[BREAKING]`. Each such line is one breaking change entry. The format is:
|
||||
Parse the diff output for lines that contain `[BREAKING]` anywhere in the line. Each such line is one breaking change entry. The format is:
|
||||
```
|
||||
[BREAKING] <description>. Run `/<skill-name>` to <action>.
|
||||
```
|
||||
|
||||
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 +207,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 +235,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`
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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 +0,0 @@
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user