Merge branch 'main' into fix/enable-linger-clean

This commit is contained in:
gavrielc
2026-03-25 22:26:22 +02:00
committed by GitHub
30 changed files with 755 additions and 1848 deletions

View File

@@ -40,7 +40,7 @@ Otherwise (macOS, desktop Linux, or WSL) → AskUserQuestion: How do you want to
If they chose pairing code: 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: Apply Code Changes ## Phase 2: Apply Code Changes
@@ -308,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: 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 2. Phone has internet access
3. WhatsApp is updated to the latest version 3. WhatsApp is updated to the latest version

View File

@@ -0,0 +1,137 @@
---
name: channel-formatting
description: Convert Claude's Markdown output to each channel's native text syntax before delivery. Adds zero-dependency formatting for WhatsApp, Telegram, and Slack (marker substitution). Also ships a Signal rich-text helper (parseSignalStyles) used by the Signal skill.
---
# Channel Formatting
This skill wires channel-aware Markdown conversion into the outbound pipeline so Claude's
responses render natively on each platform — no more literal `**asterisks**` in WhatsApp or
Telegram.
| Channel | Transformation |
|---------|---------------|
| WhatsApp | `**bold**``*bold*`, `*italic*``_italic_`, headings → bold, links flattened |
| Telegram | same as WhatsApp |
| 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
```

View File

@@ -287,4 +287,5 @@ Tell user to test: send a message in their registered chat. Show: `tail -f logs/
## 9. Diagnostics ## 9. Diagnostics
Send diagnostics data by following `.claude/skills/setup/diagnostics.md`. 1. Use the Read tool to read `.claude/skills/setup/diagnostics.md`.
2. Follow every step in that file before completing setup.

View File

@@ -237,4 +237,5 @@ Tell the user:
## Diagnostics ## Diagnostics
Send diagnostics data by following `.claude/skills/update-nanoclaw/diagnostics.md`. 1. Use the Read tool to read `.claude/skills/update-nanoclaw/diagnostics.md`.
2. Follow every step in that file before finishing.

View File

@@ -1,256 +0,0 @@
name: Sync upstream & merge-forward skill branches
on:
# Triggered by upstream repo via repository_dispatch
repository_dispatch:
types: [upstream-main-updated]
# Fallback: run on a schedule in case dispatch isn't configured
schedule:
- cron: '0 */6 * * *' # every 6 hours
# Also run when fork's main is pushed directly
push:
branches: [main]
workflow_dispatch:
permissions:
contents: write
issues: write
concurrency:
group: fork-sync
cancel-in-progress: true
jobs:
sync-and-merge:
if: github.repository != 'qwibitai/nanoclaw'
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:
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Sync with upstream main
id: sync
run: |
# Add upstream remote
git remote add upstream https://github.com/qwibitai/nanoclaw.git
git fetch upstream main
# Check if upstream has new commits
if git merge-base --is-ancestor upstream/main HEAD; then
echo "Already up to date with upstream main."
echo "synced=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Merge upstream main into fork's main
if ! git merge upstream/main --no-edit; then
# Auto-resolve trivial conflicts (lockfile, badge, package.json version)
CONFLICTED=$(git diff --name-only --diff-filter=U)
AUTO_RESOLVABLE=true
for f in $CONFLICTED; do
case "$f" in
package-lock.json|package.json|repo-tokens/badge.svg|.github/workflows/*)
git checkout --theirs "$f"
git add "$f"
;;
.env.example)
# Keep fork's channel-specific env vars
git checkout --ours "$f"
git add "$f"
;;
*)
AUTO_RESOLVABLE=false
;;
esac
done
if [ "$AUTO_RESOLVABLE" = false ]; then
echo "::error::Failed to merge upstream/main into fork main — non-trivial conflicts detected"
git merge --abort
echo "synced=false" >> "$GITHUB_OUTPUT"
echo "sync_failed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
git commit --no-edit
echo "Auto-resolved lockfile/badge/version conflicts"
fi
# Regenerate lockfile to match merged package.json
npm ci
if ! npm run build; then
echo "::error::Build failed after merging upstream/main"
git reset --hard "origin/main"
echo "synced=false" >> "$GITHUB_OUTPUT"
echo "sync_failed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if ! npm test 2>/dev/null; then
echo "::error::Tests failed after merging upstream/main"
git reset --hard "origin/main"
echo "synced=false" >> "$GITHUB_OUTPUT"
echo "sync_failed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
git push origin main
echo "synced=true" >> "$GITHUB_OUTPUT"
- name: Merge main into skill branches
id: merge
run: |
# Re-fetch to pick up any changes pushed since job start
git fetch origin
FAILED=""
SUCCEEDED=""
# List all remote skill branches
SKILL_BRANCHES=$(git branch -r --list 'origin/skill/*' | sed 's|origin/||' | xargs)
if [ -z "$SKILL_BRANCHES" ]; then
echo "No skill branches found."
exit 0
fi
for BRANCH in $SKILL_BRANCHES; do
SKILL_NAME=$(echo "$BRANCH" | sed 's|skill/||')
echo ""
echo "=== Processing $BRANCH ==="
git checkout -B "$BRANCH" "origin/$BRANCH"
if ! git merge main --no-edit; then
# Auto-resolve trivial conflicts
CONFLICTED=$(git diff --name-only --diff-filter=U)
CAN_AUTO=true
for f in $CONFLICTED; do
case "$f" in
package-lock.json|package.json|repo-tokens/badge.svg)
git checkout --theirs "$f"
git add "$f"
;;
*)
CAN_AUTO=false
;;
esac
done
if [ "$CAN_AUTO" = false ]; then
echo "::warning::Merge conflict in $BRANCH"
git merge --abort
FAILED="$FAILED $SKILL_NAME"
continue
fi
git commit --no-edit
fi
# Check if there's anything new to push
if git diff --quiet "origin/$BRANCH"; then
echo "$BRANCH is already up to date with main."
SUCCEEDED="$SUCCEEDED $SKILL_NAME"
continue
fi
npm ci
if ! npm run build; then
echo "::warning::Build failed for $BRANCH"
git reset --hard "origin/$BRANCH"
FAILED="$FAILED $SKILL_NAME"
continue
fi
if ! npm test 2>/dev/null; then
echo "::warning::Tests failed for $BRANCH"
git reset --hard "origin/$BRANCH"
FAILED="$FAILED $SKILL_NAME"
continue
fi
git push origin "$BRANCH"
SUCCEEDED="$SUCCEEDED $SKILL_NAME"
echo "$BRANCH merged and pushed successfully."
done
echo ""
echo "=== Results ==="
echo "Succeeded: $SUCCEEDED"
echo "Failed: $FAILED"
echo "failed=$FAILED" >> "$GITHUB_OUTPUT"
echo "succeeded=$SUCCEEDED" >> "$GITHUB_OUTPUT"
- name: Open issue for upstream sync failure
if: steps.sync.outputs.sync_failed == 'true'
uses: actions/github-script@v7
with:
script: |
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `Upstream sync failed — merge conflict or build failure`,
body: [
'The automated sync with `qwibitai/nanoclaw` main failed.',
'',
'This usually means upstream made changes that conflict with this fork\'s channel code.',
'',
'To resolve manually:',
'```bash',
'git fetch upstream main',
'git merge upstream/main',
'# resolve conflicts',
'npm run build && npm test',
'git push',
'```',
].join('\n'),
labels: ['upstream-sync']
});
- name: Open issue for failed skill merges
if: steps.merge.outputs.failed != ''
uses: actions/github-script@v7
with:
script: |
const failed = '${{ steps.merge.outputs.failed }}'.trim().split(/\s+/);
const body = [
`The merge-forward workflow failed to merge \`main\` into the following skill branches:`,
'',
...failed.map(s => `- \`skill/${s}\`: merge conflict, build failure, or test failure`),
'',
'Please resolve manually:',
'```bash',
...failed.map(s => [
`git checkout skill/${s}`,
`git merge main`,
`# resolve conflicts, then: git push`,
''
]).flat(),
'```',
].join('\n');
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `Merge-forward failed for ${failed.length} skill branch(es)`,
body,
labels: ['skill-maintenance']
});

View File

@@ -1,179 +0,0 @@
name: Merge-forward skill branches
on:
push:
branches: [main]
permissions:
contents: write
issues: write
jobs:
merge-forward:
if: github.repository == 'qwibitai/nanoclaw'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Merge main into each skill branch
id: merge
run: |
FAILED=""
SUCCEEDED=""
# List all remote skill branches
SKILL_BRANCHES=$(git branch -r --list 'origin/skill/*' | sed 's|origin/||' | xargs)
if [ -z "$SKILL_BRANCHES" ]; then
echo "No skill branches found."
exit 0
fi
for BRANCH in $SKILL_BRANCHES; do
SKILL_NAME=$(echo "$BRANCH" | sed 's|skill/||')
echo ""
echo "=== Processing $BRANCH ==="
# Checkout the skill branch
git checkout -B "$BRANCH" "origin/$BRANCH"
# Attempt merge
if ! git merge main --no-edit; then
# Auto-resolve trivial conflicts
CONFLICTED=$(git diff --name-only --diff-filter=U)
CAN_AUTO=true
for f in $CONFLICTED; do
case "$f" in
package-lock.json|package.json|repo-tokens/badge.svg)
git checkout --theirs "$f"
git add "$f"
;;
*)
CAN_AUTO=false
;;
esac
done
if [ "$CAN_AUTO" = false ]; then
echo "::warning::Merge conflict in $BRANCH"
git merge --abort
FAILED="$FAILED $SKILL_NAME"
continue
fi
git commit --no-edit
fi
# Check if there's anything new to push
if git diff --quiet "origin/$BRANCH"; then
echo "$BRANCH is already up to date with main."
SUCCEEDED="$SUCCEEDED $SKILL_NAME"
continue
fi
# Install deps and validate
npm ci
if ! npm run build; then
echo "::warning::Build failed for $BRANCH"
git reset --hard "origin/$BRANCH"
FAILED="$FAILED $SKILL_NAME"
continue
fi
if ! npm test 2>/dev/null; then
echo "::warning::Tests failed for $BRANCH"
git reset --hard "origin/$BRANCH"
FAILED="$FAILED $SKILL_NAME"
continue
fi
# Push the updated branch
git push origin "$BRANCH"
SUCCEEDED="$SUCCEEDED $SKILL_NAME"
echo "$BRANCH merged and pushed successfully."
done
echo ""
echo "=== Results ==="
echo "Succeeded: $SUCCEEDED"
echo "Failed: $FAILED"
# Export for issue creation
echo "failed=$FAILED" >> "$GITHUB_OUTPUT"
echo "succeeded=$SUCCEEDED" >> "$GITHUB_OUTPUT"
- name: Open issue for failed merges
if: steps.merge.outputs.failed != ''
uses: actions/github-script@v7
with:
script: |
const failed = '${{ steps.merge.outputs.failed }}'.trim().split(/\s+/);
const sha = context.sha.substring(0, 7);
const body = [
`The merge-forward workflow failed to merge \`main\` (${sha}) into the following skill branches:`,
'',
...failed.map(s => `- \`skill/${s}\`: merge conflict, build failure, or test failure`),
'',
'Please resolve manually:',
'```bash',
...failed.map(s => [
`git checkout skill/${s}`,
`git merge main`,
`# resolve conflicts, then: git push`,
''
]).flat(),
'```',
'',
`Triggered by push to main: ${context.sha}`
].join('\n');
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `Merge-forward failed for ${failed.length} skill branch(es) after ${sha}`,
body,
labels: ['skill-maintenance']
});
- name: Notify channel forks
if: always()
uses: actions/github-script@v7
with:
github-token: ${{ secrets.FORK_DISPATCH_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const forks = [
'nanoclaw-whatsapp',
'nanoclaw-telegram',
'nanoclaw-discord',
'nanoclaw-slack',
'nanoclaw-gmail',
'nanoclaw-docker-sandboxes',
'nanoclaw-docker-sandbox',
'nanoclaw-docker-sandbox-windows',
];
const sha = context.sha.substring(0, 7);
for (const repo of forks) {
try {
await github.rest.repos.createDispatchEvent({
owner: 'qwibitai',
repo,
event_type: 'upstream-main-updated',
client_payload: { sha: context.sha },
});
console.log(`Notified ${repo}`);
} catch (e) {
console.log(`Failed to notify ${repo}: ${e.message}`);
}
}

View File

@@ -2,7 +2,139 @@
All notable changes to NanoClaw will be documented in this file. All notable changes to NanoClaw will be documented in this file.
## [1.2.0](https://github.com/qwibitai/nanoclaw/compare/v1.1.6...v1.2.0) For detailed release notes, see the [full changelog on the documentation site](https://docs.nanoclaw.dev/changelog).
[BREAKING] WhatsApp removed from core, now a skill. Run `/add-whatsapp` to re-add (existing auth/groups preserved). ## [1.2.21] - 2026-03-22
- **fix:** Prevent scheduled tasks from executing twice when container runtime exceeds poll interval (#138, #669)
- Added opt-in diagnostics via PostHog with explicit user consent (Yes / No / Never ask again)
## [1.2.20] - 2026-03-21
- Added ESLint configuration with error-handling rules
## [1.2.19] - 2026-03-19
- Reduced `docker stop` timeout for faster container restarts (`-t 1` flag)
## [1.2.18] - 2026-03-19
- User prompt content no longer logged on container errors — only input metadata
- Added Japanese README translation
## [1.2.17] - 2026-03-18
- Added `/capabilities` and `/status` container-agent skills
## [1.2.16] - 2026-03-18
- Tasks snapshot now refreshes immediately after IPC task mutations
## [1.2.15] - 2026-03-16
- Fixed remote-control prompt auto-accept to prevent immediate exit
- Added `KillMode=process` so remote-control survives service restarts
## [1.2.14] - 2026-03-14
- Added `/remote-control` command for host-level Claude Code access from within containers
## [1.2.13] - 2026-03-14
**Breaking:** Skills are now git branches, channels are separate fork repos.
- Skills live as `skill/*` git branches merged via `git merge`
- Added Docker Sandboxes support
- Fixed setup registration to use correct CLI commands
## [1.2.12] - 2026-03-08
- Added `/compact` skill for manual context compaction
- Enhanced container environment isolation via credential proxy
## [1.2.11] - 2026-03-08
- Added PDF reader, image vision, and WhatsApp reactions skills
- Fixed task container to close promptly when agent uses IPC-only messaging
## [1.2.10] - 2026-03-06
- Added `LIMIT` to unbounded message history queries for better performance
## [1.2.9] - 2026-03-06
- Agent prompts now include timezone context for accurate time references
## [1.2.8] - 2026-03-06
- Fixed misleading `send_message` tool description for scheduled tasks
## [1.2.7] - 2026-03-06
- Added `/add-ollama` skill for local model inference
- Added `update_task` tool and return task ID from `schedule_task`
## [1.2.6] - 2026-03-04
- Updated `claude-agent-sdk` to 0.2.68
## [1.2.5] - 2026-03-04
- CI formatting fix
## [1.2.4] - 2026-03-04
- Fixed `_chatJid` rename to `chatJid` in `onMessage` callback
## [1.2.3] - 2026-03-04
- Added sender allowlist for per-chat access control
## [1.2.2] - 2026-03-04
- Added `/use-local-whisper` skill for local voice transcription
- Atomic task claims prevent scheduled tasks from executing twice
## [1.2.1] - 2026-03-02
- Version bump (no functional changes)
## [1.2.0] - 2026-03-02
**Breaking:** WhatsApp removed from core, now a skill. Run `/add-whatsapp` to re-add.
- Channel registry: channels self-register at startup via `registerChannel()` factory pattern
- `isMain` flag replaces folder-name-based main group detection
- `ENABLED_CHANNELS` removed — channels detected by credential presence
- Prevent scheduled tasks from executing twice when container runtime exceeds poll interval
## [1.1.6] - 2026-03-01
- Added CJK font support for Chromium screenshots
## [1.1.5] - 2026-03-01
- Fixed wrapped WhatsApp message normalization
## [1.1.4] - 2026-03-01
- Added third-party model support
- Added `/update-nanoclaw` skill for syncing with upstream
## [1.1.3] - 2026-02-25
- Added `/add-slack` skill
- Restructured Gmail skill for new architecture
## [1.1.2] - 2026-02-24
- Improved error handling for WhatsApp Web version fetch
## [1.1.1] - 2026-02-24
- Added Qodo skills and codebase intelligence
- Fixed WhatsApp 405 connection failures
## [1.1.0] - 2026-02-23
- Added `/update` skill to pull upstream changes from within Claude Code
- Enhanced container environment isolation via credential proxy

View File

@@ -13,3 +13,4 @@ Thanks to everyone who has contributed to NanoClaw!
- [baijunjie](https://github.com/baijunjie) — BaiJunjie - [baijunjie](https://github.com/baijunjie) — BaiJunjie
- [Michaelliv](https://github.com/Michaelliv) — Michael - [Michaelliv](https://github.com/Michaelliv) — Michael
- [kk17](https://github.com/kk17) — Kyle Zhike Chen - [kk17](https://github.com/kk17) — Kyle Zhike Chen
- [flobo3](https://github.com/flobo3) — Flo

View File

@@ -8,6 +8,7 @@
<p align="center"> <p align="center">
<a href="https://nanoclaw.dev">nanoclaw.dev</a>&nbsp; • &nbsp; <a href="https://nanoclaw.dev">nanoclaw.dev</a>&nbsp; • &nbsp;
<a href="https://docs.nanoclaw.dev">docs</a>&nbsp; • &nbsp;
<a href="README_zh.md">中文</a>&nbsp; • &nbsp; <a href="README_zh.md">中文</a>&nbsp; • &nbsp;
<a href="README_ja.md">日本語</a>&nbsp; • &nbsp; <a href="README_ja.md">日本語</a>&nbsp; • &nbsp;
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a>&nbsp; • &nbsp; <a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a>&nbsp; • &nbsp;
@@ -121,7 +122,7 @@ Skills we'd like to see:
## Requirements ## Requirements
- macOS or Linux - macOS, Linux, or Windows (via WSL2)
- Node.js 20+ - Node.js 20+
- [Claude Code](https://claude.ai/download) - [Claude Code](https://claude.ai/download)
- [Apple Container](https://github.com/apple/container) (macOS) or [Docker](https://docker.com/products/docker-desktop) (macOS/Linux) - [Apple Container](https://github.com/apple/container) (macOS) or [Docker](https://docker.com/products/docker-desktop) (macOS/Linux)
@@ -134,7 +135,7 @@ Channels --> SQLite --> Polling loop --> Container (Claude Agent SDK) --> Respon
Single Node.js process. Channels are added via skills and self-register at startup — the orchestrator connects whichever ones have credentials present. Agents execute in isolated Linux containers with filesystem isolation. Only mounted directories are accessible. Per-group message queue with concurrency control. IPC via filesystem. Single Node.js process. Channels are added via skills and self-register at startup — the orchestrator connects whichever ones have credentials present. Agents execute in isolated Linux containers with filesystem isolation. Only mounted directories are accessible. Per-group message queue with concurrency control. IPC via filesystem.
For the full architecture details, see [docs/SPEC.md](docs/SPEC.md). For the full architecture details, see the [documentation site](https://docs.nanoclaw.dev/concepts/architecture).
Key files: Key files:
- `src/index.ts` - Orchestrator: state, message loop, agent invocation - `src/index.ts` - Orchestrator: state, message loop, agent invocation
@@ -153,13 +154,13 @@ Key files:
Docker provides cross-platform support (macOS, Linux and even Windows via WSL2) and a mature ecosystem. On macOS, you can optionally switch to Apple Container via `/convert-to-apple-container` for a lighter-weight native runtime. For additional isolation, [Docker Sandboxes](docs/docker-sandboxes.md) run each container inside a micro VM. Docker provides cross-platform support (macOS, Linux and even Windows via WSL2) and a mature ecosystem. On macOS, you can optionally switch to Apple Container via `/convert-to-apple-container` for a lighter-weight native runtime. For additional isolation, [Docker Sandboxes](docs/docker-sandboxes.md) run each container inside a micro VM.
**Can I run this on Linux?** **Can I run this on Linux or Windows?**
Yes. Docker is the default runtime and works on both macOS and Linux. Just run `/setup`. Yes. Docker is the default runtime and works on macOS, Linux, and Windows (via WSL2). Just run `/setup`.
**Is this secure?** **Is this secure?**
Agents run in containers, not behind application-level permission checks. They can only access explicitly mounted directories. You should still review what you're running, but the codebase is small enough that you actually can. See [docs/SECURITY.md](docs/SECURITY.md) for the full security model. Agents run in containers, not behind application-level permission checks. They can only access explicitly mounted directories. You should still review what you're running, but the codebase is small enough that you actually can. See the [security documentation](https://docs.nanoclaw.dev/concepts/security) for the full security model.
**Why no configuration files?** **Why no configuration files?**
@@ -203,7 +204,7 @@ Questions? Ideas? [Join the Discord](https://discord.gg/VDdww8qS42).
## Changelog ## Changelog
See [CHANGELOG.md](CHANGELOG.md) for breaking changes and migration notes. See [CHANGELOG.md](CHANGELOG.md) for breaking changes, or the [full release history](https://docs.nanoclaw.dev/changelog) on the documentation site.
## License ## License

View File

@@ -16,6 +16,7 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { execFile } from 'child_process';
import { query, HookCallback, PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk'; import { query, HookCallback, PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
@@ -27,6 +28,7 @@ interface ContainerInput {
isMain: boolean; isMain: boolean;
isScheduledTask?: boolean; isScheduledTask?: boolean;
assistantName?: string; assistantName?: string;
script?: string;
} }
interface ContainerOutput { interface ContainerOutput {
@@ -464,6 +466,55 @@ async function runQuery(
return { newSessionId, lastAssistantUuid, closedDuringQuery }; return { newSessionId, lastAssistantUuid, closedDuringQuery };
} }
interface ScriptResult {
wakeAgent: boolean;
data?: unknown;
}
const SCRIPT_TIMEOUT_MS = 30_000;
async function runScript(script: string): Promise<ScriptResult | null> {
const scriptPath = '/tmp/task-script.sh';
fs.writeFileSync(scriptPath, script, { mode: 0o755 });
return new Promise((resolve) => {
execFile('bash', [scriptPath], {
timeout: SCRIPT_TIMEOUT_MS,
maxBuffer: 1024 * 1024,
env: process.env,
}, (error, stdout, stderr) => {
if (stderr) {
log(`Script stderr: ${stderr.slice(0, 500)}`);
}
if (error) {
log(`Script error: ${error.message}`);
return resolve(null);
}
// Parse last non-empty line of stdout as JSON
const lines = stdout.trim().split('\n');
const lastLine = lines[lines.length - 1];
if (!lastLine) {
log('Script produced no output');
return resolve(null);
}
try {
const result = JSON.parse(lastLine);
if (typeof result.wakeAgent !== 'boolean') {
log(`Script output missing wakeAgent boolean: ${lastLine.slice(0, 200)}`);
return resolve(null);
}
resolve(result as ScriptResult);
} catch {
log(`Script output is not valid JSON: ${lastLine.slice(0, 200)}`);
resolve(null);
}
});
});
}
async function main(): Promise<void> { async function main(): Promise<void> {
let containerInput: ContainerInput; let containerInput: ContainerInput;
@@ -505,6 +556,26 @@ async function main(): Promise<void> {
prompt += '\n' + pending.join('\n'); prompt += '\n' + pending.join('\n');
} }
// Script phase: run script before waking agent
if (containerInput.script && containerInput.isScheduledTask) {
log('Running task script...');
const scriptResult = await runScript(containerInput.script);
if (!scriptResult || !scriptResult.wakeAgent) {
const reason = scriptResult ? 'wakeAgent=false' : 'script error/no output';
log(`Script decided not to wake agent: ${reason}`);
writeOutput({
status: 'success',
result: null,
});
return;
}
// Script says wake agent — enrich prompt with script data
log(`Script wakeAgent=true, enriching prompt with data`);
prompt = `[SCHEDULED TASK]\n\nScript output:\n${JSON.stringify(scriptResult.data, null, 2)}\n\nInstructions:\n${containerInput.prompt}`;
}
// Query loop: run query → wait for IPC message → run new query → repeat // Query loop: run query → wait for IPC message → run new query → repeat
let resumeAt: string | undefined; let resumeAt: string | undefined;
try { try {

View File

@@ -91,6 +91,7 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone):
schedule_value: z.string().describe('cron: "*/5 * * * *" | interval: milliseconds like "300000" | once: local timestamp like "2026-02-01T15:30:00" (no Z suffix!)'), schedule_value: z.string().describe('cron: "*/5 * * * *" | interval: milliseconds like "300000" | once: local timestamp like "2026-02-01T15:30:00" (no Z suffix!)'),
context_mode: z.enum(['group', 'isolated']).default('group').describe('group=runs with chat history and memory, isolated=fresh session (include context in prompt)'), context_mode: z.enum(['group', 'isolated']).default('group').describe('group=runs with chat history and memory, isolated=fresh session (include context in prompt)'),
target_group_jid: z.string().optional().describe('(Main group only) JID of the group to schedule the task for. Defaults to the current group.'), target_group_jid: z.string().optional().describe('(Main group only) JID of the group to schedule the task for. Defaults to the current group.'),
script: z.string().optional().describe('Optional bash script to run before waking the agent. Script must output JSON on the last line of stdout: { "wakeAgent": boolean, "data"?: any }. If wakeAgent is false, the agent is not called. Test your script with bash -c "..." before scheduling.'),
}, },
async (args) => { async (args) => {
// Validate schedule_value before writing IPC // Validate schedule_value before writing IPC
@@ -136,6 +137,7 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone):
type: 'schedule_task', type: 'schedule_task',
taskId, taskId,
prompt: args.prompt, prompt: args.prompt,
script: args.script || undefined,
schedule_type: args.schedule_type, schedule_type: args.schedule_type,
schedule_value: args.schedule_value, schedule_value: args.schedule_value,
context_mode: args.context_mode || 'group', context_mode: args.context_mode || 'group',
@@ -255,6 +257,7 @@ server.tool(
prompt: z.string().optional().describe('New prompt for the task'), prompt: z.string().optional().describe('New prompt for the task'),
schedule_type: z.enum(['cron', 'interval', 'once']).optional().describe('New schedule type'), schedule_type: z.enum(['cron', 'interval', 'once']).optional().describe('New schedule type'),
schedule_value: z.string().optional().describe('New schedule value (see schedule_task for format)'), schedule_value: z.string().optional().describe('New schedule value (see schedule_task for format)'),
script: z.string().optional().describe('New script for the task. Set to empty string to remove the script.'),
}, },
async (args) => { async (args) => {
// Validate schedule_value if provided // Validate schedule_value if provided
@@ -288,6 +291,7 @@ server.tool(
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
if (args.prompt !== undefined) data.prompt = args.prompt; if (args.prompt !== undefined) data.prompt = args.prompt;
if (args.script !== undefined) data.script = args.script;
if (args.schedule_type !== undefined) data.schedule_type = args.schedule_type; if (args.schedule_type !== undefined) data.schedule_type = args.schedule_type;
if (args.schedule_value !== undefined) data.schedule_value = args.schedule_value; if (args.schedule_value !== undefined) data.schedule_value = args.schedule_value;

View File

@@ -19,16 +19,16 @@ launchctl list | grep nanoclaw
# Expected: PID 0 com.nanoclaw (PID = running, "-" = not running, non-zero exit = crashed) # Expected: PID 0 com.nanoclaw (PID = running, "-" = not running, non-zero exit = crashed)
# 2. Any running containers? # 2. Any running containers?
container ls --format '{{.Names}} {{.Status}}' 2>/dev/null | grep nanoclaw docker ps --format '{{.Names}} {{.Status}}' 2>/dev/null | grep nanoclaw
# 3. Any stopped/orphaned containers? # 3. Any stopped/orphaned containers?
container ls -a --format '{{.Names}} {{.Status}}' 2>/dev/null | grep nanoclaw docker ps -a --format '{{.Names}} {{.Status}}' 2>/dev/null | grep nanoclaw
# 4. Recent errors in service log? # 4. Recent errors in service log?
grep -E 'ERROR|WARN' logs/nanoclaw.log | tail -20 grep -E 'ERROR|WARN' logs/nanoclaw.log | tail -20
# 5. Is WhatsApp connected? (look for last connection event) # 5. Are channels connected? (look for last connection event)
grep -E 'Connected to WhatsApp|Connection closed|connection.*close' logs/nanoclaw.log | tail -5 grep -E 'Connected|Connection closed|connection.*close|channel.*ready' logs/nanoclaw.log | tail -5
# 6. Are groups loaded? # 6. Are groups loaded?
grep 'groupCount' logs/nanoclaw.log | tail -3 grep 'groupCount' logs/nanoclaw.log | tail -3
@@ -77,7 +77,7 @@ grep -E 'Scheduling retry|retry|Max retries' logs/nanoclaw.log | tail -10
## Agent Not Responding ## Agent Not Responding
```bash ```bash
# Check if messages are being received from WhatsApp # Check if messages are being received from channels
grep 'New messages' logs/nanoclaw.log | tail -10 grep 'New messages' logs/nanoclaw.log | tail -10
# Check if messages are being processed (container spawned) # Check if messages are being processed (container spawned)
@@ -107,10 +107,10 @@ sqlite3 store/messages.db "SELECT name, container_config FROM registered_groups;
# Test-run a container to check mounts (dry run) # Test-run a container to check mounts (dry run)
# Replace <group-folder> with the group's folder name # Replace <group-folder> with the group's folder name
container run -i --rm --entrypoint ls nanoclaw-agent:latest /workspace/extra/ docker run -i --rm --entrypoint ls nanoclaw-agent:latest /workspace/extra/
``` ```
## WhatsApp Auth Issues ## Channel Auth Issues
```bash ```bash
# Check if QR code was requested (means auth expired) # Check if QR code was requested (means auth expired)

15
docs/README.md Normal file
View File

@@ -0,0 +1,15 @@
# NanoClaw Documentation
The official documentation is at **[docs.nanoclaw.dev](https://docs.nanoclaw.dev)**.
The files in this directory are original design documents and developer references. For the most current and accurate information, use the documentation site.
| This directory | Documentation site |
|---|---|
| [SPEC.md](SPEC.md) | [Architecture](https://docs.nanoclaw.dev/concepts/architecture) |
| [SECURITY.md](SECURITY.md) | [Security model](https://docs.nanoclaw.dev/concepts/security) |
| [REQUIREMENTS.md](REQUIREMENTS.md) | [Introduction](https://docs.nanoclaw.dev/introduction) |
| [skills-as-branches.md](skills-as-branches.md) | [Skills system](https://docs.nanoclaw.dev/integrations/skills-system) |
| [DEBUG_CHECKLIST.md](DEBUG_CHECKLIST.md) | [Troubleshooting](https://docs.nanoclaw.dev/advanced/troubleshooting) |
| [docker-sandboxes.md](docker-sandboxes.md) | [Docker Sandboxes](https://docs.nanoclaw.dev/advanced/docker-sandboxes) |
| [APPLE-CONTAINER-NETWORKING.md](APPLE-CONTAINER-NETWORKING.md) | [Container runtime](https://docs.nanoclaw.dev/advanced/container-runtime) |

View File

@@ -22,9 +22,9 @@ The entire codebase should be something you can read and understand. One Node.js
Instead of application-level permission systems trying to prevent agents from accessing things, agents run in actual Linux containers. The isolation is at the OS level. Agents can only see what's explicitly mounted. Bash access is safe because commands run inside the container, not on your Mac. Instead of application-level permission systems trying to prevent agents from accessing things, agents run in actual Linux containers. The isolation is at the OS level. Agents can only see what's explicitly mounted. Bash access is safe because commands run inside the container, not on your Mac.
### Built for One User ### Built for the Individual User
This isn't a framework or a platform. It's working software for my specific needs. I use WhatsApp and Email, so it supports WhatsApp and Email. I don't use Telegram, so it doesn't support Telegram. I add the integrations I actually want, not every possible integration. This isn't a framework or a platform. It's software that fits each user's exact needs. You fork the repo, add the channels you want (WhatsApp, Telegram, Discord, Slack, Gmail), and end up with clean code that does exactly what you need.
### Customization = Code Changes ### Customization = Code Changes
@@ -44,41 +44,31 @@ When people contribute, they shouldn't add "Telegram support alongside WhatsApp.
## RFS (Request for Skills) ## RFS (Request for Skills)
Skills we'd love contributors to build: Skills we'd like to see contributed:
### Communication Channels ### Communication Channels
Skills to add or switch to different messaging platforms: - `/add-signal` - Add Signal as a channel
- `/add-telegram` - Add Telegram as an input channel - `/add-matrix` - Add Matrix integration
- `/add-slack` - Add Slack as an input channel
- `/add-discord` - Add Discord as an input channel
- `/add-sms` - Add SMS via Twilio or similar
- `/convert-to-telegram` - Replace WhatsApp with Telegram entirely
### Container Runtime > **Note:** Telegram, Slack, Discord, Gmail, and Apple Container skills already exist. See the [skills documentation](https://docs.nanoclaw.dev/integrations/skills-system) for the full list.
The project uses Docker by default (cross-platform). For macOS users who prefer Apple Container:
- `/convert-to-apple-container` - Switch from Docker to Apple Container (macOS-only)
### Platform Support
- `/setup-linux` - Make the full setup work on Linux (depends on Docker conversion)
- `/setup-windows` - Windows support via WSL2 + Docker
--- ---
## Vision ## Vision
A personal Claude assistant accessible via WhatsApp, with minimal custom code. A personal Claude assistant accessible via messaging, with minimal custom code.
**Core components:** **Core components:**
- **Claude Agent SDK** as the core agent - **Claude Agent SDK** as the core agent
- **Containers** for isolated agent execution (Linux VMs) - **Containers** for isolated agent execution (Linux VMs)
- **WhatsApp** as the primary I/O channel - **Multi-channel messaging** (WhatsApp, Telegram, Discord, Slack, Gmail) — add exactly the channels you need
- **Persistent memory** per conversation and globally - **Persistent memory** per conversation and globally
- **Scheduled tasks** that run Claude and can message back - **Scheduled tasks** that run Claude and can message back
- **Web access** for search and browsing - **Web access** for search and browsing
- **Browser automation** via agent-browser - **Browser automation** via agent-browser
**Implementation approach:** **Implementation approach:**
- Use existing tools (WhatsApp connector, Claude Agent SDK, MCP servers) - Use existing tools (channel libraries, Claude Agent SDK, MCP servers)
- Minimal glue code - Minimal glue code
- File-based systems where possible (CLAUDE.md for memory, folders for groups) - File-based systems where possible (CLAUDE.md for memory, folders for groups)
@@ -87,7 +77,7 @@ A personal Claude assistant accessible via WhatsApp, with minimal custom code.
## Architecture Decisions ## Architecture Decisions
### Message Routing ### Message Routing
- A router listens to WhatsApp and routes messages based on configuration - A router listens to connected channels and routes messages based on configuration
- Only messages from registered groups are processed - Only messages from registered groups are processed
- Trigger: `@Andy` prefix (case insensitive), configurable via `ASSISTANT_NAME` env var - Trigger: `@Andy` prefix (case insensitive), configurable via `ASSISTANT_NAME` env var
- Unregistered groups are ignored completely - Unregistered groups are ignored completely
@@ -136,10 +126,11 @@ A personal Claude assistant accessible via WhatsApp, with minimal custom code.
## Integration Points ## Integration Points
### WhatsApp ### Channels
- Using baileys library for WhatsApp Web connection - WhatsApp (baileys), Telegram (grammy), Discord (discord.js), Slack (@slack/bolt), Gmail (googleapis)
- Each channel lives in a separate fork repo and is added via skills (e.g., `/add-whatsapp`, `/add-telegram`)
- Messages stored in SQLite, polled by router - Messages stored in SQLite, polled by router
- QR code authentication during setup - Channels self-register at startup — unconfigured channels are skipped with a warning
### Scheduler ### Scheduler
- Built-in scheduler runs on the host, spawns containers for task execution - Built-in scheduler runs on the host, spawns containers for task execution
@@ -170,12 +161,12 @@ A personal Claude assistant accessible via WhatsApp, with minimal custom code.
- Each user gets a custom setup matching their exact needs - Each user gets a custom setup matching their exact needs
### Skills ### Skills
- `/setup` - Install dependencies, authenticate WhatsApp, configure scheduler, start services - `/setup` - Install dependencies, configure channels, start services
- `/customize` - General-purpose skill for adding capabilities (new channels like Telegram, new integrations, behavior changes) - `/customize` - General-purpose skill for adding capabilities
- `/update` - Pull upstream changes, merge with customizations, run migrations - `/update-nanoclaw` - Pull upstream changes, merge with customizations
### Deployment ### Deployment
- Runs on local Mac via launchd - Runs on macOS (launchd), Linux (systemd), or Windows (WSL2)
- Single Node.js process handles everything - Single Node.js process handles everything
--- ---

View File

@@ -7,7 +7,7 @@
| Main group | Trusted | Private self-chat, admin control | | Main group | Trusted | Private self-chat, admin control |
| Non-main groups | Untrusted | Other users may be malicious | | Non-main groups | Untrusted | Other users may be malicious |
| Container agents | Sandboxed | Isolated execution environment | | Container agents | Sandboxed | Isolated execution environment |
| WhatsApp messages | User input | Potential prompt injection | | Incoming messages | User input | Potential prompt injection |
## Security Boundaries ## Security Boundaries
@@ -76,7 +76,7 @@ Real API credentials **never enter containers**. Instead, the host runs an HTTP
5. Agents cannot discover real credentials — not in environment, stdin, files, or `/proc` 5. Agents cannot discover real credentials — not in environment, stdin, files, or `/proc`
**NOT Mounted:** **NOT Mounted:**
- WhatsApp session (`store/auth/`) - host only - Channel auth sessions (`store/auth/`) - host only
- Mount allowlist - external, never mounted - Mount allowlist - external, never mounted
- Any credentials matching blocked patterns - Any credentials matching blocked patterns
- `.env` is shadowed with `/dev/null` in the project root mount - `.env` is shadowed with `/dev/null` in the project root mount
@@ -97,7 +97,7 @@ Real API credentials **never enter containers**. Instead, the host runs an HTTP
``` ```
┌──────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────┐
│ UNTRUSTED ZONE │ │ UNTRUSTED ZONE │
WhatsApp Messages (potentially malicious) │ Incoming Messages (potentially malicious)
└────────────────────────────────┬─────────────────────────────────┘ └────────────────────────────────┬─────────────────────────────────┘
▼ Trigger check, input escaping ▼ Trigger check, input escaping

View File

@@ -262,3 +262,42 @@ When scheduling tasks for other groups, use the `target_group_jid` parameter wit
- `schedule_task(prompt: "...", schedule_type: "cron", schedule_value: "0 9 * * 1", target_group_jid: "120363336345536173@g.us")` - `schedule_task(prompt: "...", schedule_type: "cron", schedule_value: "0 9 * * 1", target_group_jid: "120363336345536173@g.us")`
The task will run in that group's context with access to their files and memory. The task will run in that group's context with access to their files and memory.
---
## Task Scripts
When scheduling tasks that check a condition before acting (new PRs, website changes, API status), use the `script` parameter. The script runs first — if there's nothing to do, you don't wake up.
### How it works
1. You provide a bash `script` alongside the `prompt` when scheduling
2. When the task fires, the script runs first (30-second timeout)
3. Script prints JSON to stdout: `{ "wakeAgent": true/false, "data": {...} }`
4. If `wakeAgent: false` — nothing happens, task waits for next run
5. If `wakeAgent: true` — you wake up and receive the script's data + prompt
### Always test your script first
Before scheduling, run the script in your sandbox to verify it works:
```bash
bash -c 'node --input-type=module -e "
const r = await fetch(\"https://api.github.com/repos/owner/repo/pulls?state=open\");
const prs = await r.json();
console.log(JSON.stringify({ wakeAgent: prs.length > 0, data: prs.slice(0, 5) }));
"'
```
### When NOT to use scripts
If a task requires your judgment every time (daily briefings, reminders, reports), skip the script — just use a regular prompt.
### Frequent task guidance
If a user wants tasks running more than ~2x daily and a script can't reduce agent wake-ups:
- Explain that each wake-up uses API credits and risks rate limits
- Suggest restructuring with a script that checks the condition first
- If the user needs an LLM to evaluate data, suggest using an API key with direct Anthropic API calls inside the script
- Help the user find the minimum viable frequency

95
package-lock.json generated
View File

@@ -1,17 +1,16 @@
{ {
"name": "nanoclaw", "name": "nanoclaw",
"version": "1.2.25", "version": "1.2.31",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nanoclaw", "name": "nanoclaw",
"version": "1.2.25", "version": "1.2.31",
"dependencies": { "dependencies": {
"@onecli-sh/sdk": "^0.2.0", "@onecli-sh/sdk": "^0.2.0",
"better-sqlite3": "^11.8.1", "better-sqlite3": "11.10.0",
"cron-parser": "^5.5.0", "cron-parser": "5.5.0",
"grammy": "^1.39.3",
"pino": "^9.6.0", "pino": "^9.6.0",
"pino-pretty": "^13.0.0", "pino-pretty": "^13.0.0",
"yaml": "^2.8.2", "yaml": "^2.8.2",
@@ -696,12 +695,6 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@grammyjs/types": {
"version": "3.25.0",
"resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.25.0.tgz",
"integrity": "sha512-iN9i5p+8ZOu9OMxWNcguojQfz4K/PDyMPOnL7PPCON+SoA/F8OKMH3uR7CVUkYfdNe0GCz8QOzAWrnqusQYFOg==",
"license": "MIT"
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -1609,18 +1602,6 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
} }
}, },
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.16.0", "version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -1911,6 +1892,7 @@
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@@ -2198,15 +2180,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/expand-template": { "node_modules/expand-template": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
@@ -2391,21 +2364,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/grammy": {
"version": "1.41.1",
"resolved": "https://registry.npmjs.org/grammy/-/grammy-1.41.1.tgz",
"integrity": "sha512-wcHAQ1e7svL3fJMpDchcQVcWUmywhuepOOjHUHmMmWAwUJEIyK5ea5sbSjZd+Gy1aMpZeP8VYJa+4tP+j1YptQ==",
"license": "MIT",
"dependencies": {
"@grammyjs/types": "3.25.0",
"abort-controller": "^3.0.0",
"debug": "^4.4.3",
"node-fetch": "^2.7.0"
},
"engines": {
"node": "^12.20.0 || >=14.13.1"
}
},
"node_modules/has-flag": { "node_modules/has-flag": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -2756,6 +2714,7 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
@@ -2801,26 +2760,6 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/obug": { "node_modules/obug": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
@@ -3543,12 +3482,6 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/ts-api-utils": { "node_modules/ts-api-utils": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -3817,22 +3750,6 @@
} }
} }
}, },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "nanoclaw", "name": "nanoclaw",
"version": "1.2.25", "version": "1.2.31",
"description": "Personal Claude assistant. Lightweight, secure, customizable.", "description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
@@ -22,13 +22,12 @@
}, },
"dependencies": { "dependencies": {
"@onecli-sh/sdk": "^0.2.0", "@onecli-sh/sdk": "^0.2.0",
"better-sqlite3": "^11.8.1", "better-sqlite3": "11.10.0",
"cron-parser": "^5.5.0", "cron-parser": "5.5.0",
"pino": "^9.6.0", "pino": "^9.6.0",
"pino-pretty": "^13.0.0", "pino-pretty": "^13.0.0",
"yaml": "^2.8.2", "yaml": "^2.8.2",
"zod": "^4.3.6", "zod": "^4.3.6"
"grammy": "^1.39.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.35.0", "@eslint/js": "^9.35.0",

View File

@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="97" height="20" role="img" aria-label="42.4k tokens, 21% of context window"> <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="97" height="20" role="img" aria-label="41.2k tokens, 21% of context window">
<title>42.4k tokens, 21% of context window</title> <title>41.2k tokens, 21% of context window</title>
<linearGradient id="s" x2="0" y2="100%"> <linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/> <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/> <stop offset="1" stop-opacity=".1"/>
@@ -15,8 +15,8 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11"> <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text> <text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
<text x="26" y="14">tokens</text> <text x="26" y="14">tokens</text>
<text aria-hidden="true" x="74" y="15" fill="#010101" fill-opacity=".3">42.4k</text> <text aria-hidden="true" x="74" y="15" fill="#010101" fill-opacity=".3">41.2k</text>
<text x="74" y="14">42.4k</text> <text x="74" y="14">41.2k</text>
</g> </g>
</g> </g>
</a> </a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,4 +1,7 @@
import { describe, it, expect, beforeEach } from 'vitest'; import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, describe, it, expect, beforeEach } from 'vitest';
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
@@ -6,7 +9,7 @@ import Database from 'better-sqlite3';
* Tests for the register step. * Tests for the register step.
* *
* Verifies: parameterized SQL (no injection), file templating, * Verifies: parameterized SQL (no injection), file templating,
* apostrophe in names, .env updates. * apostrophe in names, .env updates, CLAUDE.md template copy.
*/ */
function createTestDb(): Database.Database { function createTestDb(): Database.Database {
@@ -255,3 +258,207 @@ describe('file templating', () => {
expect(envContent).toContain('ASSISTANT_NAME="Nova"'); expect(envContent).toContain('ASSISTANT_NAME="Nova"');
}); });
}); });
describe('CLAUDE.md template copy', () => {
let tmpDir: string;
let groupsDir: string;
// Replicates register.ts template copy + name update logic
function simulateRegister(
folder: string,
isMain: boolean,
assistantName = 'Andy',
): void {
const folderDir = path.join(groupsDir, folder);
fs.mkdirSync(path.join(folderDir, 'logs'), { recursive: true });
// Template copy — never overwrite existing (register.ts lines 119-135)
const dest = path.join(folderDir, 'CLAUDE.md');
if (!fs.existsSync(dest)) {
const templatePath = isMain
? path.join(groupsDir, 'main', 'CLAUDE.md')
: path.join(groupsDir, 'global', 'CLAUDE.md');
if (fs.existsSync(templatePath)) {
fs.copyFileSync(templatePath, dest);
}
}
// Name update across all groups (register.ts lines 140-165)
if (assistantName !== 'Andy') {
const mdFiles = fs
.readdirSync(groupsDir)
.map((d) => path.join(groupsDir, d, 'CLAUDE.md'))
.filter((f) => fs.existsSync(f));
for (const mdFile of mdFiles) {
let content = fs.readFileSync(mdFile, 'utf-8');
content = content.replace(/^# Andy$/m, `# ${assistantName}`);
content = content.replace(
/You are Andy/g,
`You are ${assistantName}`,
);
fs.writeFileSync(mdFile, content);
}
}
}
function readGroupMd(folder: string): string {
return fs.readFileSync(
path.join(groupsDir, folder, 'CLAUDE.md'),
'utf-8',
);
}
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-register-test-'));
groupsDir = path.join(tmpDir, 'groups');
fs.mkdirSync(path.join(groupsDir, 'main'), { recursive: true });
fs.mkdirSync(path.join(groupsDir, 'global'), { recursive: true });
fs.writeFileSync(
path.join(groupsDir, 'main', 'CLAUDE.md'),
'# Andy\n\nYou are Andy, a personal assistant.\n\n## Admin Context\n\nThis is the **main channel**.',
);
fs.writeFileSync(
path.join(groupsDir, 'global', 'CLAUDE.md'),
'# Andy\n\nYou are Andy, a personal assistant.',
);
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('copies global template for non-main group', () => {
simulateRegister('telegram_dev-team', false);
const content = readGroupMd('telegram_dev-team');
expect(content).toContain('You are Andy');
expect(content).not.toContain('Admin Context');
});
it('copies main template for main group', () => {
simulateRegister('whatsapp_main', true);
expect(readGroupMd('whatsapp_main')).toContain('Admin Context');
});
it('each channel can have its own main with admin context', () => {
simulateRegister('whatsapp_main', true);
simulateRegister('telegram_main', true);
simulateRegister('slack_main', true);
simulateRegister('discord_main', true);
for (const folder of [
'whatsapp_main',
'telegram_main',
'slack_main',
'discord_main',
]) {
const content = readGroupMd(folder);
expect(content).toContain('Admin Context');
expect(content).toContain('You are Andy');
}
});
it('non-main groups across channels get global template', () => {
simulateRegister('whatsapp_main', true);
simulateRegister('telegram_friends', false);
simulateRegister('slack_engineering', false);
simulateRegister('discord_general', false);
expect(readGroupMd('whatsapp_main')).toContain('Admin Context');
for (const folder of [
'telegram_friends',
'slack_engineering',
'discord_general',
]) {
const content = readGroupMd(folder);
expect(content).toContain('You are Andy');
expect(content).not.toContain('Admin Context');
}
});
it('custom name propagates to all channels and groups', () => {
// Register multiple channels, last one sets custom name
simulateRegister('whatsapp_main', true);
simulateRegister('telegram_main', true);
simulateRegister('slack_devs', false);
// Final registration triggers name update across all
simulateRegister('discord_main', true, 'Luna');
for (const folder of [
'main',
'global',
'whatsapp_main',
'telegram_main',
'slack_devs',
'discord_main',
]) {
const content = readGroupMd(folder);
expect(content).toContain('# Luna');
expect(content).toContain('You are Luna');
expect(content).not.toContain('Andy');
}
});
it('never overwrites existing CLAUDE.md on re-registration', () => {
simulateRegister('slack_main', true);
// User customizes the file extensively (persona, workspace, rules)
const mdPath = path.join(groupsDir, 'slack_main', 'CLAUDE.md');
fs.writeFileSync(
mdPath,
'# Gambi\n\nCustom persona with workspace rules and family context.',
);
// Re-registering same folder (e.g. re-running /add-slack)
simulateRegister('slack_main', true);
const content = readGroupMd('slack_main');
expect(content).toContain('Custom persona');
expect(content).not.toContain('Admin Context');
});
it('never overwrites when non-main becomes main (isMain changes)', () => {
// User registers a family group as non-main
simulateRegister('whatsapp_casa', false);
// User extensively customizes it (PARA system, task management, etc.)
const mdPath = path.join(groupsDir, 'whatsapp_casa', 'CLAUDE.md');
fs.writeFileSync(
mdPath,
'# Casa\n\nFamily group with PARA system, task management, shopping lists.',
);
// Later, user promotes to main (no trigger required) — CLAUDE.md must be preserved
simulateRegister('whatsapp_casa', true);
const content = readGroupMd('whatsapp_casa');
expect(content).toContain('PARA system');
expect(content).not.toContain('Admin Context');
});
it('preserves custom CLAUDE.md across channels when changing main', () => {
// Real-world scenario: WhatsApp main + customized Discord research channel
simulateRegister('whatsapp_main', true);
simulateRegister('discord_main', false);
const discordPath = path.join(groupsDir, 'discord_main', 'CLAUDE.md');
fs.writeFileSync(
discordPath,
'# Gambi HQ — Research Assistant\n\nResearch workflows for Laura and Ethan.',
);
// Discord becomes main too — custom content must survive
simulateRegister('discord_main', true);
expect(readGroupMd('discord_main')).toContain('Research Assistant');
// WhatsApp main also untouched
expect(readGroupMd('whatsapp_main')).toContain('Admin Context');
});
it('handles missing templates gracefully', () => {
fs.unlinkSync(path.join(groupsDir, 'global', 'CLAUDE.md'));
fs.unlinkSync(path.join(groupsDir, 'main', 'CLAUDE.md'));
simulateRegister('discord_general', false);
expect(
fs.existsSync(path.join(groupsDir, 'discord_general', 'CLAUDE.md')),
).toBe(false);
});
});

View File

@@ -116,6 +116,30 @@ export async function run(args: string[]): Promise<void> {
recursive: true, recursive: true,
}); });
// Create CLAUDE.md in the new group folder from template if it doesn't exist.
// The agent runs with CWD=/workspace/group and loads CLAUDE.md from there.
// Never overwrite an existing CLAUDE.md — users customize these extensively
// (persona, workspace structure, communication rules, family context, etc.)
// and a stock template replacement would destroy that work.
const groupClaudeMdPath = path.join(
projectRoot,
'groups',
parsed.folder,
'CLAUDE.md',
);
if (!fs.existsSync(groupClaudeMdPath)) {
const templatePath = parsed.isMain
? path.join(projectRoot, 'groups', 'main', 'CLAUDE.md')
: path.join(projectRoot, 'groups', 'global', 'CLAUDE.md');
if (fs.existsSync(templatePath)) {
fs.copyFileSync(templatePath, groupClaudeMdPath);
logger.info(
{ file: groupClaudeMdPath, template: templatePath },
'Created CLAUDE.md from template',
);
}
}
// Update assistant name in CLAUDE.md files if different from default // Update assistant name in CLAUDE.md files if different from default
let nameUpdated = false; let nameUpdated = false;
if (parsed.assistantName !== 'Andy') { if (parsed.assistantName !== 'Andy') {
@@ -124,10 +148,11 @@ export async function run(args: string[]): Promise<void> {
'Updating assistant name', 'Updating assistant name',
); );
const mdFiles = [ const groupsDir = path.join(projectRoot, 'groups');
path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'), const mdFiles = fs
path.join(projectRoot, 'groups', parsed.folder, 'CLAUDE.md'), .readdirSync(groupsDir)
]; .map((d) => path.join(groupsDir, d, 'CLAUDE.md'))
.filter((f) => fs.existsSync(f));
for (const mdFile of mdFiles) { for (const mdFile of mdFiles) {
if (fs.existsSync(mdFile)) { if (fs.existsSync(mdFile)) {

View File

@@ -8,6 +8,5 @@
// slack // slack
// telegram // telegram
import './telegram.js';
// whatsapp // whatsapp

View File

@@ -1,949 +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 bot commands (/chatid, /ping) but passes other / messages through', async () => {
const opts = createTestOpts();
const channel = new TelegramChannel('test-token', opts);
await channel.connect();
// Bot commands should be skipped
const ctx1 = createTextCtx({ text: '/chatid' });
await triggerTextMessage(ctx1);
expect(opts.onMessage).not.toHaveBeenCalled();
expect(opts.onChatMetadata).not.toHaveBeenCalled();
const ctx2 = createTextCtx({ text: '/ping' });
await triggerTextMessage(ctx2);
expect(opts.onMessage).not.toHaveBeenCalled();
// Non-bot /commands should flow through
const ctx3 = createTextCtx({ text: '/remote-control' });
await triggerTextMessage(ctx3);
expect(opts.onMessage).toHaveBeenCalledTimes(1);
expect(opts.onMessage).toHaveBeenCalledWith(
'tg:100200300',
expect.objectContaining({ content: '/remote-control' }),
);
});
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',
{ parse_mode: 'Markdown' },
);
});
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',
{ parse_mode: 'Markdown' },
);
});
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),
{ parse_mode: 'Markdown' },
);
expect(currentBot().api.sendMessage).toHaveBeenNthCalledWith(
2,
'100200300',
'x'.repeat(904),
{ parse_mode: 'Markdown' },
);
});
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');
});
});
});

View File

@@ -1,304 +0,0 @@
import https from 'https';
import { Api, 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>;
}
/**
* Send a message with Telegram Markdown parse mode, falling back to plain text.
* Claude's output naturally matches Telegram's Markdown v1 format:
* *bold*, _italic_, `code`, ```code blocks```, [links](url)
*/
async function sendTelegramMessage(
api: { sendMessage: Api['sendMessage'] },
chatId: string | number,
text: string,
options: { message_thread_id?: number } = {},
): Promise<void> {
try {
await api.sendMessage(chatId, text, {
...options,
parse_mode: 'Markdown',
});
} catch (err) {
// Fallback: send as plain text if Markdown parsing fails
logger.debug({ err }, 'Markdown send failed, falling back to plain text');
await api.sendMessage(chatId, text, options);
}
}
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, {
client: {
baseFetchConfig: { agent: https.globalAgent, compress: true },
},
});
// 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.`);
});
// Telegram bot commands handled above — skip them in the general handler
// so they don't also get stored as messages. All other /commands flow through.
const TELEGRAM_BOT_COMMANDS = new Set(['chatid', 'ping']);
this.bot.on('message:text', async (ctx) => {
if (ctx.message.text.startsWith('/')) {
const cmd = ctx.message.text.slice(1).split(/[\s@]/)[0].toLowerCase();
if (TELEGRAM_BOT_COMMANDS.has(cmd)) 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 sendTelegramMessage(this.bot.api, numericId, text);
} else {
for (let i = 0; i < text.length; i += MAX_LENGTH) {
await sendTelegramMessage(
this.bot.api,
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);
});

View File

@@ -42,6 +42,7 @@ export interface ContainerInput {
isMain: boolean; isMain: boolean;
isScheduledTask?: boolean; isScheduledTask?: boolean;
assistantName?: string; assistantName?: string;
script?: string;
} }
export interface ContainerOutput { export interface ContainerOutput {
@@ -191,8 +192,17 @@ function buildVolumeMounts(
group.folder, group.folder,
'agent-runner-src', 'agent-runner-src',
); );
if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) { if (fs.existsSync(agentRunnerSrc)) {
fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true }); const srcIndex = path.join(agentRunnerSrc, 'index.ts');
const cachedIndex = path.join(groupAgentRunnerDir, 'index.ts');
const needsCopy =
!fs.existsSync(groupAgentRunnerDir) ||
!fs.existsSync(cachedIndex) ||
(fs.existsSync(srcIndex) &&
fs.statSync(srcIndex).mtimeMs > fs.statSync(cachedIndex).mtimeMs);
if (needsCopy) {
fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true });
}
} }
mounts.push({ mounts.push({
hostPath: groupAgentRunnerDir, hostPath: groupAgentRunnerDir,
@@ -667,6 +677,7 @@ export function writeTasksSnapshot(
id: string; id: string;
groupFolder: string; groupFolder: string;
prompt: string; prompt: string;
script?: string | null;
schedule_type: string; schedule_type: string;
schedule_value: string; schedule_value: string;
status: string; status: string;

View File

@@ -93,6 +93,13 @@ function createSchema(database: Database.Database): void {
/* column already exists */ /* column already exists */
} }
// Add script column if it doesn't exist (migration for existing DBs)
try {
database.exec(`ALTER TABLE scheduled_tasks ADD COLUMN script TEXT`);
} catch {
/* column already exists */
}
// Add is_bot_message column if it doesn't exist (migration for existing DBs) // Add is_bot_message column if it doesn't exist (migration for existing DBs)
try { try {
database.exec( database.exec(
@@ -368,14 +375,15 @@ export function createTask(
): void { ): void {
db.prepare( db.prepare(
` `
INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at) INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, script, schedule_type, schedule_value, context_mode, next_run, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
).run( ).run(
task.id, task.id,
task.group_folder, task.group_folder,
task.chat_jid, task.chat_jid,
task.prompt, task.prompt,
task.script || null,
task.schedule_type, task.schedule_type,
task.schedule_value, task.schedule_value,
task.context_mode || 'isolated', task.context_mode || 'isolated',
@@ -410,7 +418,12 @@ export function updateTask(
updates: Partial< updates: Partial<
Pick< Pick<
ScheduledTask, ScheduledTask,
'prompt' | 'schedule_type' | 'schedule_value' | 'next_run' | 'status' | 'prompt'
| 'script'
| 'schedule_type'
| 'schedule_value'
| 'next_run'
| 'status'
> >
>, >,
): void { ): void {
@@ -421,6 +434,10 @@ export function updateTask(
fields.push('prompt = ?'); fields.push('prompt = ?');
values.push(updates.prompt); values.push(updates.prompt);
} }
if (updates.script !== undefined) {
fields.push('script = ?');
values.push(updates.script || null);
}
if (updates.schedule_type !== undefined) { if (updates.schedule_type !== undefined) {
fields.push('schedule_type = ?'); fields.push('schedule_type = ?');
values.push(updates.schedule_type); values.push(updates.schedule_type);

View File

@@ -5,6 +5,7 @@ import { OneCLI } from '@onecli-sh/sdk';
import { import {
ASSISTANT_NAME, ASSISTANT_NAME,
GROUPS_DIR,
IDLE_TIMEOUT, IDLE_TIMEOUT,
ONECLI_URL, ONECLI_URL,
POLL_INTERVAL, POLL_INTERVAL,
@@ -133,6 +134,26 @@ function registerGroup(jid: string, group: RegisteredGroup): void {
// Create group folder // Create group folder
fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true }); fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
// Copy CLAUDE.md template into the new group folder so agents have
// identity and instructions from the first run. (Fixes #1391)
const groupMdFile = path.join(groupDir, 'CLAUDE.md');
if (!fs.existsSync(groupMdFile)) {
const templateFile = path.join(
GROUPS_DIR,
group.isMain ? 'main' : 'global',
'CLAUDE.md',
);
if (fs.existsSync(templateFile)) {
let content = fs.readFileSync(templateFile, 'utf-8');
if (ASSISTANT_NAME !== 'Andy') {
content = content.replace(/^# Andy$/m, `# ${ASSISTANT_NAME}`);
content = content.replace(/You are Andy/g, `You are ${ASSISTANT_NAME}`);
}
fs.writeFileSync(groupMdFile, content);
logger.info({ folder: group.folder }, 'Created CLAUDE.md from template');
}
}
// Ensure a corresponding OneCLI agent exists (best-effort, non-blocking) // Ensure a corresponding OneCLI agent exists (best-effort, non-blocking)
ensureOneCLIAgent(jid, group); ensureOneCLIAgent(jid, group);

View File

@@ -162,6 +162,7 @@ export async function processTaskIpc(
schedule_type?: string; schedule_type?: string;
schedule_value?: string; schedule_value?: string;
context_mode?: string; context_mode?: string;
script?: string;
groupFolder?: string; groupFolder?: string;
chatJid?: string; chatJid?: string;
targetJid?: string; targetJid?: string;
@@ -260,6 +261,7 @@ export async function processTaskIpc(
group_folder: targetFolder, group_folder: targetFolder,
chat_jid: targetJid, chat_jid: targetJid,
prompt: data.prompt, prompt: data.prompt,
script: data.script || null,
schedule_type: scheduleType, schedule_type: scheduleType,
schedule_value: data.schedule_value, schedule_value: data.schedule_value,
context_mode: contextMode, context_mode: contextMode,
@@ -352,6 +354,7 @@ export async function processTaskIpc(
const updates: Parameters<typeof updateTask>[1] = {}; const updates: Parameters<typeof updateTask>[1] = {};
if (data.prompt !== undefined) updates.prompt = data.prompt; if (data.prompt !== undefined) updates.prompt = data.prompt;
if (data.script !== undefined) updates.script = data.script || null;
if (data.schedule_type !== undefined) if (data.schedule_type !== undefined)
updates.schedule_type = data.schedule_type as updates.schedule_type = data.schedule_type as
| 'cron' | 'cron'

View File

@@ -139,6 +139,7 @@ async function runTask(
id: t.id, id: t.id,
groupFolder: t.group_folder, groupFolder: t.group_folder,
prompt: t.prompt, prompt: t.prompt,
script: t.script,
schedule_type: t.schedule_type, schedule_type: t.schedule_type,
schedule_value: t.schedule_value, schedule_value: t.schedule_value,
status: t.status, status: t.status,
@@ -179,6 +180,7 @@ async function runTask(
isMain, isMain,
isScheduledTask: true, isScheduledTask: true,
assistantName: ASSISTANT_NAME, assistantName: ASSISTANT_NAME,
script: task.script || undefined,
}, },
(proc, containerName) => (proc, containerName) =>
deps.onProcess(task.chat_jid, proc, containerName, task.group_folder), deps.onProcess(task.chat_jid, proc, containerName, task.group_folder),

View File

@@ -58,6 +58,7 @@ export interface ScheduledTask {
group_folder: string; group_folder: string;
chat_jid: string; chat_jid: string;
prompt: string; prompt: string;
script?: string | null;
schedule_type: 'cron' | 'interval' | 'once'; schedule_type: 'cron' | 'interval' | 'once';
schedule_value: string; schedule_value: string;
context_mode: 'group' | 'isolated'; context_mode: 'group' | 'isolated';