Compare commits

...

10 Commits

Author SHA1 Message Date
root
dc92c7fcf6 feat: chatbot mode — Claude CLI отвечает на сообщения в Telegram
Добавлен режим чат-бота: каждое текстовое сообщение в Telegram
обрабатывается через `claude -p` CLI с контекстом проекта (CLAUDE.md).
Поддержка продолжения диалога через --resume session_id.

Новое:
- src/chatbot.ts: модуль чат-бота (spawn, сессии, retry, split)
- Команды /chatbot (статус) и /chatreset (сброс диалога)
- Конфиг через CHATBOT_* переменные в .env
- Typing-индикатор, блокировка конкурентных запросов
- Безопасная отправка Markdown с fallback на plain text

Изменения в index.ts:
- Интеграция chatbot в bot.on('text') fallback
- handlerTimeout увеличен до 5 мин
- Очистка stale-сессий при отсутствии запущенного Claude

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:43:53 +00:00
RichardDillman
e26d94dcc2 Fix: Multi-line messages now captured in project routing
Added 's' flag (dotall) to regex so . matches newlines.
Previously only first line of multi-line messages was captured.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-07 16:31:22 -05:00
RichardDillman
93d65d9a34 Fix: Add -p flag when spawning Claude with a prompt
Without -p flag, Claude expects input from stdin and fails with:
"Input must be provided either through stdin or as a prompt argument"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-07 16:21:36 -05:00
RichardDillman
0ce65d7120 fix: load project .env vars and cleanup stale sessions
- Load project's .env file when spawning Claude so API credentials work
- Check isClaudeRunning() before routing messages to sessions
- Auto-cleanup stale sessions when Claude has exited
- Fixes ESO Logs API access from spawned Claude instances

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 18:22:46 -05:00
RichardDillman
0d277e4ae2 fix: add comprehensive debugging for Claude spawner Telegram output
Added detailed logging throughout the Claude spawning and output callback chain to diagnose why spawned Claude process output is logged but not sent to Telegram.

Changes:
- claude-spawner.ts: Added debug logs when onOutput callback is invoked, with try-catch error handling
- claude-spawner.ts: Added warning when no callback is provided
- index.ts: Added debug logs in all three spawn locations (/spawn command, auto-spawn, HTTP /spawn endpoint)
- index.ts: Added logging to show chatId availability and callback creation
- index.ts: Added detailed logging inside callbacks to trace Telegram sendMessage calls

This will help identify if the issue is:
1. Callback not being passed to spawner
2. Callback not being invoked by spawner
3. chatId not being set
4. Telegram API call failing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 17:22:28 -05:00
RichardDillman
abde877912 docs: comprehensive documentation for all three phases
Added complete documentation covering all InnerVoice features:

Feature Documentation:
- Core communication features
- Multi-project support and session tracking
- Message queue system for offline projects
- Remote Claude spawner and project registry

Usage Scenarios (6 detailed examples):
1. Single Active Project - Basic workflow
2. Multiple Active Projects - Cross-project messaging
3. Offline Project Queuing - Send work before Claude starts
4. Remote Claude Spawning - Start work remotely
5. Auto-Spawn Projects - Automatic project startup
6. Managing Multiple Projects - Full lifecycle management

Bot Commands:
- Complete reference for all Telegram commands
- Session management (/sessions, /queue)
- Project management (/register, /spawn, /kill, etc.)
- Bot control commands

MCP Tools Reference:
- Added telegram_check_queue documentation
- Updated all tool descriptions with project context

API Endpoints:
- Session management endpoints
- Queue management endpoints
- Project registry endpoints
- Claude spawner endpoints
- Complete request/response examples

Communication Patterns:
- Basic message flow
- Targeted messages to specific projects
- Notifications with project context
- Question/answer flow
- Queued message delivery

Updated Sections:
- Features section with categorized capabilities
- How It Works with multi-project context
- All usage scenarios with concrete examples
- Complete API reference with all new endpoints

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 17:37:20 -05:00
RichardDillman
bdfe51fedf feat(phase3): add remote Claude spawner and project registry
Phase 3 adds the ability to spawn Claude Code instances remotely from Telegram in registered projects.

New Features:
- Project registry system (~/.innervoice/projects.json)
- Remote Claude spawner with process management
- Auto-spawn capability for projects
- Full CRUD operations for projects via Telegram

Telegram Bot Commands:
- /projects - List all registered projects
- /register ProjectName /path [--auto-spawn]
- /unregister ProjectName
- /spawn ProjectName [initial prompt]
- /spawned - List running spawned processes
- /kill ProjectName

API Endpoints:
- GET /projects - List registered projects
- POST /projects/register - Register new project
- DELETE /projects/:name - Unregister project
- GET /projects/:name - Get project details
- POST /spawn - Spawn Claude in project
- POST /kill/:projectName - Kill spawned process
- GET /spawned - List all spawned processes
- GET /spawned/:projectName - Check if project running

Files Added:
- src/project-registry.ts - Project registry management
- src/claude-spawner.ts - Claude process spawning

Files Modified:
- src/index.ts - Added spawner integration, bot commands, API endpoints

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 17:33:30 -05:00
RichardDillman
fd750d9b50 feat(phase2): add message queue system for offline projects
Phase 2 Complete: Message queuing for offline/inactive projects

Queue Storage:
- File-based queue in ~/.innervoice/queues/
- Separate JSON file per project
- Persistent storage survives restarts
- Auto-cleanup of old delivered tasks (7 days)

Queue Manager (queue-manager.ts):
- enqueueTask() - Add task to project queue
- getPendingTasks() - Get undelivered tasks
- markTaskDelivered() - Mark task as complete
- getQueueSummary() - Get overview of all queues
- cleanupOldTasks() - Remove old delivered tasks

API Endpoints:
- POST /queue/add - Queue message for project
- GET /queue/:projectName - Get pending tasks
- POST /queue/:projectName/mark-delivered - Mark delivered
- GET /queue/summary - Get all project summaries

Telegram Bot Features:
- /queue command - Show all queued messages
- Project-targeted messages: "ProjectName: message"
- Auto-detect if project is online or offline
- Queue for offline, deliver immediately if online

MCP Tool:
- telegram_check_queue - Check for queued messages on startup
- Shows pending messages with timestamps
- Perfect for checking what happened while offline

Usage Scenarios:

1. Send to offline project:
   You: "ESO-MCP: Continue with roadmap"
   Bot: "📥 Message queued for ESO-MCP (offline)"

2. Open Claude in ESO-MCP:
   Claude auto-checks queue on startup
   Shows: "📬 You have 1 queued message: Continue with roadmap"

3. Check queue status:
   You: "/queue"
   Bot: Shows all projects with pending messages

This solves the "no one listening" problem - messages are stored
and delivered when Claude opens in that project.

Next: Phase 3 (remote Claude spawner)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 17:27:24 -05:00
RichardDillman
82f46c4569 feat(phase1): add project context and session tracking
Phase 1 Complete: Multi-project session management with context display

Session Tracking:
- Auto-generate unique session IDs for each Claude instance
- Track project name and path from working directory
- Register sessions automatically on MCP server startup
- Expire inactive sessions after 30 minutes
- Session heartbeat to track activity

Project Context in Messages:
- All notifications now show: 📁 ProjectName [#shortId]
- Session ID automatically included in all notify calls
- Project context helps identify which Claude sent the message
- Short session ID (first 7 chars) for easy reference

New API Endpoints:
- POST /session/register - Register/update a Claude session
- POST /session/heartbeat - Update session activity
- GET /sessions - List all active sessions with idle times

Telegram Bot Commands:
- /sessions - View all active Claude sessions
- Shows project name, session ID, and idle time
- Status indicators: 🟢 active, 🟡 idle
- Updated /help with new multi-project features

MCP Server Changes:
- getSessionId() - Generate/retrieve session ID
- getProjectInfo() - Extract project name from cwd
- registerSession() - Auto-register on startup
- Include sessionId in all notify API calls

This enables working with multiple Claude instances across different
projects simultaneously, with clear visual indication of which project
each message comes from.

Next: Phase 2 (message queuing) and Phase 3 (remote spawner)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 15:47:16 -05:00
RichardDillman
0584ac6c5f feat: make /afk command and permission hook globally available
Changed from per-project to global installation by default, making
InnerVoice features available across all projects automatically.

Global Installation:
- /afk slash command now in ~/.claude/commands/afk.md
- Permission hook now in ~/.claude/hooks/PermissionRequest.sh
- Both work in ALL projects without per-project setup

Install Script Updates:
- Added --global flag for global installation (recommended)
- Still supports per-project installation if needed
- Clear scope indication in output messages
- Updated uninstall instructions for both scopes

Documentation:
- Updated README to recommend global installation
- Added examples for both global and per-project setup
- Clearer uninstall instructions for each scope

Benefits:
- Users install once, works everywhere
- No per-project configuration needed
- Easier maintenance and updates
- Consistent behavior across all projects

Breaking Changes: None
- Per-project installation still supported
- Existing installations continue to work

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 15:13:01 -05:00
9 changed files with 2273 additions and 54 deletions

61
PHASE1-TESTING.md Normal file
View File

@@ -0,0 +1,61 @@
# Phase 1 Testing Guide
## What Was Built
✅ Multi-project session tracking with project context display
### Features
- Auto-generate unique session IDs for each Claude instance
- Track project name and path from working directory
- Show project context in all Telegram messages: `📁 ProjectName [#shortId]`
- `/sessions` command to list all active Claude instances
- Auto-expire inactive sessions after 30 minutes
## How to Test
### 1. Restart Claude Code
**Important:** You need to restart Claude Code to load the new MCP server code.
```bash
# Exit your current Claude Code sessions
# Then restart in your project
cd /path/to/your/project
claude
```
### 2. Test Project Context
Send a notification and you should see the project name:
```
📁 ESO-MCP [#1a2b3c4]
Your message here
```
### 3. Test Multi-Project Sessions
1. Open Claude in **ESO-MCP** project
2. Open another terminal and start Claude in **innervoice** project
3. In Telegram, type `/sessions`
4. You should see both projects listed:
```
Active Claude Sessions (2)
1. 🟢 ESO-MCP [#1a2b3c4]
Last active: 0m ago
2. 🟢 innervoice [#5d6e7f8]
Last active: 2m ago
```
### 4. Test Session Auto-Expire
Wait 30 minutes of inactivity, then run `/sessions` again.
Inactive sessions should be removed automatically.
## Known Issues
- You must restart Claude Code for changes to take effect
- Old MCP server processes won't pick up new code automatically
## Next: Phase 2
Message queue system for offline/inactive projects coming next!

505
README.md
View File

@@ -24,14 +24,33 @@ After trying email, SMS, and Google Chat integrations, Telegram emerged as the b
## Features ## Features
### Core Communication
- 💬 **Two-Way Communication** - Send messages to Claude, get responses back - 💬 **Two-Way Communication** - Send messages to Claude, get responses back
-**Question/Answer Flow** - Claude can ask you questions and wait for answers -**Question/Answer Flow** - Claude can ask you questions and wait for answers
- 📬 **Message Queue** - Messages queue up when Claude is busy, get answered ASAP
- 🔔 **Priority Notifications** - Different icons for info, success, warning, error, question - 🔔 **Priority Notifications** - Different icons for info, success, warning, error, question
- 🌐 **HTTP API** - Easy integration from any app/project - 🌐 **HTTP API** - Easy integration from any app/project
- 🚀 **Background Service** - Runs independently, always available - 🚀 **Background Service** - Runs independently, always available
- 🔧 **MCP Protocol** - Works as a standard MCP server in any Claude project - 🔧 **MCP Protocol** - Works as a standard MCP server in any Claude project
### Multi-Project Support
- 📁 **Project Context** - All messages show which project they're from
- 🎯 **Targeted Messages** - Send messages to specific projects: `ProjectName: your message`
- 📊 **Session Tracking** - Monitor active Claude sessions across projects
- 🔄 **Auto-Session Registration** - Projects auto-register when Claude starts
### Message Queue System
- 📬 **Offline Queuing** - Messages queue when projects are offline
- 📥 **Persistent Storage** - Queued messages survive restarts
-**Auto-Delivery** - Messages delivered when Claude starts in that project
- 🧹 **Auto-Cleanup** - Old messages expire automatically
### Remote Claude Spawner
- 🚀 **Remote Spawning** - Start Claude in any project from Telegram
- 📝 **Project Registry** - Register projects for easy remote access
- 🔄 **Auto-Spawn** - Optional auto-start when messages arrive
- 💀 **Process Management** - Track and kill spawned Claude instances
- 🎯 **Initial Prompts** - Start Claude with a specific task
## How It Works ## How It Works
This is a **standard MCP server** that works like any other MCP tool. Once installed and configured: This is a **standard MCP server** that works like any other MCP tool. Once installed and configured:
@@ -195,12 +214,13 @@ cd
### 6. Available Tools ### 6. Available Tools
Once configured, Claude can automatically use: Once configured, Claude can automatically use:
- `telegram_notify` - Send notifications - `telegram_notify` - Send notifications with project context
- `telegram_ask` - Ask questions and wait for answers - `telegram_ask` - Ask questions and wait for answers
- `telegram_get_messages` - Check for messages from you - `telegram_get_messages` - Check for messages from you
- `telegram_reply` - Reply to your messages - `telegram_reply` - Reply to your messages
- `telegram_check_health` - Check bridge status - `telegram_check_health` - Check bridge status
- `telegram_toggle_afk` - Toggle AFK mode (enable/disable notifications) - `telegram_toggle_afk` - Toggle AFK mode (enable/disable notifications)
- `telegram_check_queue` - Check for queued messages on startup
**View detailed tool info:** **View detailed tool info:**
```bash ```bash
@@ -228,10 +248,14 @@ The toggle state is preserved while the bridge is running, and you'll get a Tele
By default, AFK mode only sends notifications when Claude explicitly uses notification tools. If you want to receive Telegram alerts when **permission prompts** appear (so you know Claude is waiting for approval), install the permission hook: By default, AFK mode only sends notifications when Claude explicitly uses notification tools. If you want to receive Telegram alerts when **permission prompts** appear (so you know Claude is waiting for approval), install the permission hook:
**Recommended: Install Globally (works in all projects)**
```bash ```bash
# From the innervoice directory
cd /path/to/innervoice cd /path/to/innervoice
./scripts/install-hook.sh --global
```
**Or install per-project:**
```bash
# Install in a specific project # Install in a specific project
./scripts/install-hook.sh /path/to/your/project ./scripts/install-hook.sh /path/to/your/project
@@ -246,7 +270,9 @@ This will send you a Telegram message like:
> **Action:** Check scraped sets files > **Action:** Check scraped sets files
> Check your terminal to approve or deny. > Check your terminal to approve or deny.
**To uninstall:** Simply delete `.claude/hooks/PermissionRequest.sh` from your project. **To uninstall:**
- Global: `rm ~/.claude/hooks/PermissionRequest.sh`
- Per-project: `rm .claude/hooks/PermissionRequest.sh`
### 8. Verify Global Setup ### 8. Verify Global Setup
@@ -294,6 +320,206 @@ Restart Claude Code, then tell Claude:
Claude will automatically discover and use the `telegram_notify` tool! Claude will automatically discover and use the `telegram_notify` tool!
## Usage Scenarios
InnerVoice supports multiple usage patterns depending on your workflow:
### Scenario 1: Single Active Project
**Use Case:** You're actively working in one project
**How it works:**
1. Start Claude Code in your project
2. Claude auto-registers its session
3. All messages go to the active session
4. Messages show project context: `📁 MyProject [#abc1234]`
**Example:**
```
You in Telegram: "Check the test status"
Bot: 💬 Message received - responding...
Claude: "Running tests... ✅ All 42 tests passed!"
```
### Scenario 2: Multiple Active Projects
**Use Case:** Working across multiple projects simultaneously
**How it works:**
1. Start Claude in multiple projects (each auto-registers)
2. Send targeted messages: `ProjectName: your message`
3. View active sessions with `/sessions`
4. Each response shows its project context
**Example:**
```
You: "/sessions"
Bot: Active Claude Sessions (3)
1. 🟢 ESO-MCP [#abc1234]
Last active: 2m ago
2. 🟢 InnerVoice [#def5678]
Last active: 5m ago
3. 🟢 MyApp [#ghi9012]
Last active: 1m ago
You: "ESO-MCP: run the scraper"
Bot: 💬 Message sent to active session: ESO-MCP
Claude in ESO-MCP: 📁 ESO-MCP [#abc1234]
✅ Scraper started...
```
### Scenario 3: Offline Project Queuing
**Use Case:** Send work to a project before Claude is running
**How it works:**
1. Send: `ProjectName: your task`
2. If project offline, message queues automatically
3. Start Claude in that project
4. Claude checks queue on startup and processes tasks
**Example:**
```
You: "MyApp: fix the login bug"
Bot: 📥 Message queued for MyApp (offline)
It will be delivered when Claude starts in that project.
[Later, you start Claude in MyApp]
Claude: 📬 You have 1 queued message:
1. From Richard (2:30 PM)
fix the login bug
These messages were sent while you were offline.
[Claude proceeds to work on the task]
```
### Scenario 4: Remote Claude Spawning
**Use Case:** Start work remotely without opening your terminal
**Setup:**
```
# Register your projects once
You: "/register MyApp ~/code/myapp"
Bot: ✅ Project registered successfully!
📁 MyApp
📍 /Users/you/code/myapp
⏸️ Manual spawn only
Spawn with: /spawn MyApp
```
**Daily Usage:**
```
You: "/spawn MyApp Fix the login bug"
Bot: ⏳ Starting Claude in MyApp...
✅ Claude started in MyApp with prompt: "Fix the login bug"
PID: 12345
You can now send messages to it: MyApp: your message
[Claude automatically starts working on the bug]
Claude: 📁 MyApp [#abc1234]
🔍 Analyzing login flow...
✅ Bug fixed! The session timeout was too short.
```
### Scenario 5: Auto-Spawn Projects
**Use Case:** Projects that should start automatically when messaged
**Setup:**
```
You: "/register MyApp ~/code/myapp --auto-spawn"
Bot: ✅ Project registered successfully!
📁 MyApp
📍 /Users/you/code/myapp
🔄 Auto-spawn enabled
```
**Daily Usage:**
```
You: "MyApp: run the tests"
Bot: 🚀 Auto-spawning Claude in MyApp...
✅ Claude started in MyApp
PID: 12345
[Claude auto-starts and processes the message]
Claude: 📁 MyApp [#abc1234]
🧪 Running test suite...
✅ All 42 tests passed!
```
### Scenario 6: Managing Multiple Projects
**View all projects:**
```
You: "/projects"
Bot: Registered Projects (4)
1. 🟢 ESO-MCP 🔄
📍 /Users/you/code/eso-mcp
🕐 Last: 12/23/2025
2. ⚪ InnerVoice ⏸️
📍 /Users/you/code/innervoice
🕐 Last: 12/22/2025
3. 🟢 MyApp 🔄
📍 /Users/you/code/myapp
🕐 Last: 12/23/2025
4. ⚪ TestProject ⏸️
📍 /Users/you/code/test
🕐 Last: 12/20/2025
🟢 Running ⚪ Offline 🔄 Auto-spawn ⏸️ Manual
```
**Check running processes:**
```
You: "/spawned"
Bot: Spawned Claude Processes (2)
1. ESO-MCP
🆔 PID: 12345
⏱️ Running: 15m
💬 "run the scraper"
2. MyApp
🆔 PID: 12346
⏱️ Running: 5m
Kill with: /kill ProjectName
```
**Stop a project:**
```
You: "/kill MyApp"
Bot: 🛑 ✅ Claude process terminated in MyApp
```
## Telegram Bot Commands
Complete list of available bot commands:
### Session Management
- `/sessions` - List all active Claude sessions with status
- `/queue` - View queued messages for offline projects
### Project Management
- `/projects` - List all registered projects with status
- `/register ProjectName /path [--auto-spawn]` - Register a new project
- `/unregister ProjectName` - Remove a project from registry
- `/spawn ProjectName [prompt]` - Start Claude in a project
- `/spawned` - List all running spawned Claude processes
- `/kill ProjectName` - Terminate a spawned Claude process
### Bot Control
- `/start` - Initialize bot and save your chat ID
- `/help` - Show all available commands
- `/status` - Check bridge health and status
- `/test` - Send a test notification
### Message Syntax
- Regular message: Goes to active Claude (if only one running)
- `ProjectName: message` - Send to specific project
- If project offline, message automatically queues
## MCP Tools Reference ## MCP Tools Reference
Once configured, Claude can automatically use these tools: Once configured, Claude can automatically use these tools:
@@ -359,6 +585,25 @@ Toggle AFK (Away From Keyboard) mode - enables or disables Telegram notification
- Disable when actively working (avoid interruptions) - Disable when actively working (avoid interruptions)
- State is preserved while the bridge is running - State is preserved while the bridge is running
### `telegram_check_queue`
Check if there are queued messages for this project from when Claude was offline.
**No parameters required**
**Example Claude Usage:**
> "On startup, let me check for any queued messages."
> *Claude uses: `telegram_check_queue({})`*
**Returns:**
- List of messages sent while Claude was offline
- Includes sender, timestamp, and message content
- Messages are marked as delivered after retrieval
**When to use:**
- On startup to catch up on offline messages
- Proactively check for pending work
- After long idle periods
## Git Setup (For Sharing) ## Git Setup (For Sharing)
If you want to push this to your own Git repository: If you want to push this to your own Git repository:
@@ -436,32 +681,37 @@ await fetch('http://localhost:3456/notify', {
- `error` - ❌ Error occurred - `error` - ❌ Error occurred
- `question` - ❓ Needs your input - `question` - ❓ Needs your input
## Bot Commands ## How Communication Works
Type these in Telegram to control the bridge: ### Basic Message Flow
- `/start` - Initialize connection and save your chat ID
- `/help` - Show all available commands and how to use the bridge
- `/status` - Check bridge status (enabled, unread messages, pending questions)
- `/test` - Send a test notification to verify it's working
## How Two-Way Communication Works
### You → Claude
1. Send any message to the bot in Telegram 1. Send any message to the bot in Telegram
2. Bot acknowledges with "💬 Message received - responding..." 2. Bot acknowledges with "💬 Message received - responding..."
3. Claude checks messages and responds when available 3. Claude checks messages and responds when available
4. You get the response in Telegram 4. You get the response in Telegram with project context
### Claude → You (Notifications) ### Targeted Messages
Claude sends you updates via the `/notify` API endpoint with different priorities Send `ProjectName: your message` to communicate with a specific project:
- If project is running: Message delivered immediately
- If project is offline: Message queues automatically
### Claude → You (Questions) ### Notifications
1. Claude sends a question via `/ask` API Claude sends you updates via the `telegram_notify` tool with:
- Project context: `📁 ProjectName [#abc1234]`
- Priority icons: ✅ ⚠️ ❌ ❓
- Markdown formatting support
### Questions
1. Claude sends a question via `telegram_ask`
2. You see "❓ [question]" in Telegram 2. You see "❓ [question]" in Telegram
3. Your next message is automatically treated as the answer 3. Your next message is automatically treated as the answer
4. Claude receives your answer and continues 4. Claude receives your answer and continues
### Queued Messages
1. Send message to offline project
2. Message queues persistently
3. When Claude starts in that project, it checks the queue
4. Queued messages are delivered and processed
## Running as Background Service ## Running as Background Service
```bash ```bash
@@ -609,6 +859,221 @@ Get current notification state
} }
``` ```
### Session Management Endpoints
#### POST /session/register
Register or update a Claude session
**Request:**
```json
{
"sessionId": "unique-session-id",
"projectName": "MyProject",
"projectPath": "/path/to/project"
}
```
**Response:**
```json
{
"success": true,
"sessionId": "unique-session-id",
"projectName": "MyProject"
}
```
#### GET /sessions
List all active Claude sessions
**Response:**
```json
{
"sessions": [
{
"id": "1234567-abc",
"projectName": "MyProject",
"projectPath": "/path/to/project",
"startTime": "2025-11-23T10:00:00.000Z",
"lastActivity": "2025-11-23T10:30:00.000Z",
"status": "active",
"idleMinutes": 5
}
],
"count": 1
}
```
### Queue Management Endpoints
#### GET /queue/:projectName
Get pending messages for a project
**Response:**
```json
{
"projectName": "MyProject",
"tasks": [
{
"id": "task-123",
"projectName": "MyProject",
"message": "Fix the bug",
"from": "Richard",
"timestamp": "2025-11-23T09:00:00.000Z",
"priority": "normal",
"status": "pending"
}
],
"count": 1
}
```
#### GET /queue/summary
Get summary of all queued messages
**Response:**
```json
{
"summary": [
{
"projectName": "MyProject",
"pending": 2,
"delivered": 5,
"total": 7
}
],
"totalProjects": 1
}
```
### Project Registry Endpoints
#### GET /projects
List all registered projects
**Response:**
```json
{
"projects": [
{
"name": "MyProject",
"path": "/path/to/project",
"lastAccessed": "2025-11-23T10:00:00.000Z",
"autoSpawn": false,
"metadata": {
"description": "My project description",
"tags": ["web", "api"]
}
}
],
"count": 1
}
```
#### POST /projects/register
Register a new project
**Request:**
```json
{
"name": "MyProject",
"path": "/path/to/project",
"autoSpawn": false,
"description": "Optional description",
"tags": ["tag1", "tag2"]
}
```
**Response:**
```json
{
"success": true,
"project": {
"name": "MyProject",
"path": "/path/to/project",
"lastAccessed": "2025-11-23T10:00:00.000Z",
"autoSpawn": false
}
}
```
#### DELETE /projects/:name
Unregister a project
**Response:**
```json
{
"success": true,
"message": "Project MyProject unregistered"
}
```
### Claude Spawner Endpoints
#### POST /spawn
Spawn Claude in a registered project
**Request:**
```json
{
"projectName": "MyProject",
"initialPrompt": "Optional initial task"
}
```
**Response:**
```json
{
"success": true,
"message": "✅ Claude started in MyProject",
"pid": 12345
}
```
#### POST /kill/:projectName
Terminate a spawned Claude process
**Response:**
```json
{
"success": true,
"message": "✅ Claude process terminated in MyProject"
}
```
#### GET /spawned
List all spawned Claude processes
**Response:**
```json
{
"processes": [
{
"projectName": "MyProject",
"pid": 12345,
"startTime": "2025-11-23T10:00:00.000Z",
"initialPrompt": "Fix the bug",
"runningMinutes": 15
}
],
"count": 1
}
```
#### GET /spawned/:projectName
Check if Claude is running in a project
**Response:**
```json
{
"running": true,
"process": {
"projectName": "MyProject",
"pid": 12345,
"runningMinutes": 15
}
}
```
## Integration with ESO-MCP ## Integration with ESO-MCP
Add this helper to your ESO-MCP project: Add this helper to your ESO-MCP project:

View File

@@ -1,5 +1,5 @@
#!/bin/bash #!/bin/bash
# Install the InnerVoice permission notification hook in a project # Install the InnerVoice permission notification hook globally or per-project
set -e set -e
@@ -7,12 +7,22 @@ HOOK_NAME="PermissionRequest.sh"
INNERVOICE_DIR="$(cd "$(dirname "$0")/.." && pwd)" INNERVOICE_DIR="$(cd "$(dirname "$0")/.." && pwd)"
SOURCE_HOOK="$INNERVOICE_DIR/hooks/$HOOK_NAME" SOURCE_HOOK="$INNERVOICE_DIR/hooks/$HOOK_NAME"
# Check for --global flag
if [ "$1" = "--global" ] || [ "$1" = "-g" ]; then
TARGET_HOOK_DIR="$HOME/.claude/hooks"
SCOPE="globally (all projects)"
UNINSTALL_CMD="rm ~/.claude/hooks/$HOOK_NAME"
else
# Get target project directory (default to current directory) # Get target project directory (default to current directory)
TARGET_DIR="${1:-.}" TARGET_DIR="${1:-.}"
TARGET_HOOK_DIR="$TARGET_DIR/.claude/hooks" TARGET_HOOK_DIR="$TARGET_DIR/.claude/hooks"
SCOPE="in project: $TARGET_DIR"
UNINSTALL_CMD="rm $TARGET_HOOK_DIR/$HOOK_NAME"
fi
echo "📦 Installing InnerVoice Permission Notification Hook" echo "📦 Installing InnerVoice Permission Notification Hook"
echo "" echo ""
echo "Scope: $SCOPE"
echo "Source: $SOURCE_HOOK" echo "Source: $SOURCE_HOOK"
echo "Target: $TARGET_HOOK_DIR/$HOOK_NAME" echo "Target: $TARGET_HOOK_DIR/$HOOK_NAME"
echo "" echo ""
@@ -35,4 +45,4 @@ echo ""
echo "🔔 Now when you're in AFK mode, you'll get Telegram notifications" echo "🔔 Now when you're in AFK mode, you'll get Telegram notifications"
echo " whenever Claude requests permission for a tool." echo " whenever Claude requests permission for a tool."
echo "" echo ""
echo "To uninstall: rm $TARGET_HOOK_DIR/$HOOK_NAME" echo "To uninstall: $UNINSTALL_CMD"

306
src/chatbot.ts Normal file
View File

@@ -0,0 +1,306 @@
import { spawn } from 'child_process';
// --- Interfaces ---
interface ChatbotSession {
sessionId: string;
lastActivity: number;
messageCount: number;
}
interface ClaudeJsonResponse {
type: string;
subtype: string;
cost_usd: number;
is_error: boolean;
duration_ms: number;
num_turns: number;
result: string;
session_id: string;
}
// --- Configuration ---
const CHATBOT_ENABLED = () => process.env.CHATBOT_ENABLED !== 'false';
const CHATBOT_CWD = () => process.env.CHATBOT_CWD || '/home/fitcrm';
const CHATBOT_MAX_TURNS = () => process.env.CHATBOT_MAX_TURNS || '3';
const CHATBOT_TIMEOUT = () => parseInt(process.env.CHATBOT_TIMEOUT || '120000');
const CHATBOT_SESSION_TIMEOUT = () => parseInt(process.env.CHATBOT_SESSION_TIMEOUT || '1800000');
// --- State ---
const chatbotSessions = new Map<string, ChatbotSession>();
const activeLocks = new Set<string>();
let cleanupTimer: NodeJS.Timeout | null = null;
// --- Public API ---
export function isChatbotEnabled(): boolean {
return CHATBOT_ENABLED();
}
export function initChatbot(): void {
if (cleanupTimer) clearInterval(cleanupTimer);
cleanupTimer = setInterval(() => {
const now = Date.now();
const timeout = CHATBOT_SESSION_TIMEOUT();
for (const [chatId, session] of chatbotSessions.entries()) {
if (now - session.lastActivity > timeout) {
chatbotSessions.delete(chatId);
console.log(`[Chatbot] Expired session for chat ${chatId}`);
}
}
}, 5 * 60 * 1000);
console.log(`[Chatbot] Initialized (enabled: ${CHATBOT_ENABLED()}, cwd: ${CHATBOT_CWD()}, maxTurns: ${CHATBOT_MAX_TURNS()})`);
}
export async function handleChatbotMessage(
chatId: string,
message: string,
sendTyping: () => Promise<void>,
sendReply: (text: string, parseMode?: string) => Promise<void>
): Promise<void> {
if (activeLocks.has(chatId)) {
await sendReply('⏳ Подождите, обрабатываю предыдущее сообщение...');
return;
}
activeLocks.add(chatId);
const typingInterval = setInterval(async () => {
try { await sendTyping(); } catch { /* ignore */ }
}, 4000);
try {
await sendTyping();
const session = chatbotSessions.get(chatId);
const isExpired = session && (Date.now() - session.lastActivity > CHATBOT_SESSION_TIMEOUT());
const resumeId = session && !isExpired ? session.sessionId : undefined;
const response = await executeWithRetry(chatId, message, resumeId);
chatbotSessions.set(chatId, {
sessionId: response.session_id,
lastActivity: Date.now(),
messageCount: (session && !isExpired ? session.messageCount : 0) + 1,
});
const resultText = response.result || '(пустой ответ)';
const chunks = splitMessage(resultText);
for (const chunk of chunks) {
await safeSendReply(sendReply, chunk);
}
} catch (error: any) {
console.error('[Chatbot] Error:', error.message);
await safeSendReply(sendReply, `❌ Ошибка: ${error.message}`);
} finally {
clearInterval(typingInterval);
activeLocks.delete(chatId);
}
}
export function resetChatbotSession(chatId: string): boolean {
return chatbotSessions.delete(chatId);
}
export function getChatbotStatus(chatId: string): {
enabled: boolean;
hasSession: boolean;
messageCount: number;
sessionAge: number | null;
} {
const session = chatbotSessions.get(chatId);
return {
enabled: CHATBOT_ENABLED(),
hasSession: !!session,
messageCount: session?.messageCount || 0,
sessionAge: session ? Math.floor((Date.now() - session.lastActivity) / 60000) : null,
};
}
// --- Internal ---
function executeClaudeCli(message: string, resumeSessionId?: string): Promise<ClaudeJsonResponse> {
return new Promise((resolve, reject) => {
const args: string[] = [
'-p', message,
'--output-format', 'json',
'--max-turns', CHATBOT_MAX_TURNS(),
];
if (resumeSessionId) {
args.push('--resume', resumeSessionId);
}
// Clean env: remove all Claude Code session vars
const env = { ...process.env };
delete env.CLAUDECODE;
delete env.CLAUDE_CODE_ENTRYPOINT;
delete env.CLAUDE_SPAWNED;
delete env.INNERVOICE_SPAWNED;
console.log(`[Chatbot] Spawning: claude ${args.map((a, i) => i === 1 ? `"${a.substring(0, 40)}..."` : a).join(' ')}`);
const child = spawn('claude', args, {
cwd: CHATBOT_CWD(),
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data: Buffer) => {
stdout += data.toString();
});
child.stderr.on('data', (data: Buffer) => {
stderr += data.toString();
});
// Timeout handler
const timer = setTimeout(() => {
console.error(`[Chatbot] Timeout after ${CHATBOT_TIMEOUT()}ms, killing process`);
child.kill('SIGTERM');
setTimeout(() => child.kill('SIGKILL'), 5000);
}, CHATBOT_TIMEOUT());
child.on('error', (error) => {
clearTimeout(timer);
console.error(`[Chatbot] Spawn error: ${error.message}`);
reject(new Error(`Failed to start claude: ${error.message}`));
});
child.on('close', (code) => {
clearTimeout(timer);
console.log(`[Chatbot] Process exited with code ${code}`);
if (stderr.trim()) {
console.error(`[Chatbot] stderr: ${stderr.substring(0, 500)}`);
}
if (stdout.trim()) {
console.log(`[Chatbot] stdout (first 300): ${stdout.substring(0, 300)}`);
} else {
console.log(`[Chatbot] stdout: (empty)`);
}
// Try to parse response regardless of exit code
if (stdout.trim()) {
try {
const parsed = parseClaudeResponse(stdout);
if (parsed.is_error) {
reject(new Error(parsed.result || 'Claude returned an error'));
} else {
resolve(parsed);
}
return;
} catch (parseErr: any) {
console.error(`[Chatbot] Parse error: ${parseErr.message}`);
}
}
if (code !== 0) {
const errDetail = stderr.trim() || stdout.trim() || `exit code ${code}`;
reject(new Error(`Claude CLI failed: ${errDetail.substring(0, 200)}`));
} else {
reject(new Error('Empty response from Claude CLI'));
}
});
});
}
async function executeWithRetry(
chatId: string,
message: string,
resumeId?: string
): Promise<ClaudeJsonResponse> {
try {
return await executeClaudeCli(message, resumeId);
} catch (error) {
if (resumeId) {
console.log(`[Chatbot] Resume failed for ${chatId}, retrying fresh`);
chatbotSessions.delete(chatId);
return await executeClaudeCli(message, undefined);
}
throw error;
}
}
function parseClaudeResponse(stdout: string): ClaudeJsonResponse {
const trimmed = stdout.trim();
// Try the whole output as a single JSON
try {
return JSON.parse(trimmed);
} catch { /* continue */ }
// Try each line (may have streaming JSON lines)
const lines = trimmed.split('\n');
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i].trim();
if (!line) continue;
try {
const parsed = JSON.parse(line);
if (parsed.type === 'result') return parsed;
} catch { continue; }
}
// Last resort: find result JSON anywhere in output
const match = trimmed.match(/\{[^]*?"type"\s*:\s*"result"[^]*?\}/);
if (match) {
try {
return JSON.parse(match[0]);
} catch { /* continue */ }
}
throw new Error(`No valid JSON result in output (${trimmed.substring(0, 150)})`);
}
function splitMessage(text: string, maxLength: number = 4000): string[] {
if (text.length <= maxLength) return [text];
const chunks: string[] = [];
let remaining = text;
while (remaining.length > 0) {
if (remaining.length <= maxLength) {
chunks.push(remaining);
break;
}
let splitIdx = remaining.lastIndexOf('\n\n', maxLength);
if (splitIdx === -1 || splitIdx < maxLength * 0.3) {
splitIdx = remaining.lastIndexOf('\n', maxLength);
}
if (splitIdx === -1 || splitIdx < maxLength * 0.3) {
splitIdx = remaining.lastIndexOf(' ', maxLength);
}
if (splitIdx === -1 || splitIdx < maxLength * 0.3) {
splitIdx = maxLength;
}
chunks.push(remaining.substring(0, splitIdx));
remaining = remaining.substring(splitIdx).trimStart();
}
return chunks.slice(0, 5);
}
async function safeSendReply(
sendReply: (text: string, parseMode?: string) => Promise<void>,
text: string
): Promise<void> {
try {
await sendReply(text, 'Markdown');
} catch {
try {
await sendReply(text);
} catch (innerError) {
console.error('[Chatbot] Failed to send reply:', innerError);
}
}
}

216
src/claude-spawner.ts Normal file
View File

@@ -0,0 +1,216 @@
// Claude Spawner for InnerVoice
// Spawns Claude Code instances remotely from Telegram
import { spawn, ChildProcess } from 'child_process';
import { findProject, touchProject } from './project-registry.js';
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
// Load .env file from a directory and return as object
function loadEnvFile(dirPath: string): Record<string, string> {
const envPath = join(dirPath, '.env');
const envVars: Record<string, string> = {};
if (existsSync(envPath)) {
try {
const content = readFileSync(envPath, 'utf-8');
for (const line of content.split('\n')) {
const trimmed = line.trim();
// Skip comments and empty lines
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIndex = trimmed.indexOf('=');
if (eqIndex > 0) {
const key = trimmed.substring(0, eqIndex).trim();
let value = trimmed.substring(eqIndex + 1).trim();
// Remove quotes if present
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
envVars[key] = value;
}
}
console.log(`[SPAWN] Loaded ${Object.keys(envVars).length} env vars from ${envPath}`);
} catch (error) {
console.error(`[SPAWN] Failed to load .env from ${envPath}:`, error);
}
}
return envVars;
}
interface SpawnedProcess {
projectName: string;
process: ChildProcess;
startTime: Date;
initialPrompt?: string;
onOutput?: (data: string, isError: boolean) => void;
}
const activeProcesses = new Map<string, SpawnedProcess>();
// Spawn Claude in a project
export async function spawnClaude(
projectName: string,
initialPrompt?: string,
onOutput?: (data: string, isError: boolean) => void
): Promise<{ success: boolean; message: string; pid?: number }> {
// Check if already running
if (activeProcesses.has(projectName)) {
return {
success: false,
message: `Claude is already running in ${projectName}`
};
}
// Find project in registry
const project = await findProject(projectName);
if (!project) {
return {
success: false,
message: `Project "${projectName}" not found in registry. Register it first with: /register ProjectName /path/to/project`
};
}
try {
// Load project's .env file to pass to Claude
const projectEnv = loadEnvFile(project.path);
// Spawn Claude Code
// Use -p flag to pass prompt, otherwise Claude expects stdin
const args = initialPrompt ? ['-p', initialPrompt] : [];
const claudeProcess = spawn('claude', args, {
cwd: project.path,
stdio: ['ignore', 'pipe', 'pipe'],
detached: true,
env: {
...process.env,
...projectEnv, // Include project's .env variables
INNERVOICE_SPAWNED: '1' // Mark as spawned by InnerVoice
}
});
// Store process
activeProcesses.set(projectName, {
projectName,
process: claudeProcess,
startTime: new Date(),
initialPrompt,
onOutput
});
// Update last accessed
await touchProject(projectName);
// Handle output - log and optionally send to callback
if (claudeProcess.stdout) {
claudeProcess.stdout.on('data', (data) => {
const output = data.toString().trim();
console.log(`[${projectName}] ${output}`);
// Send to callback if provided
if (onOutput) {
console.log(`[DEBUG] Invoking onOutput callback for stdout in ${projectName}`);
try {
onOutput(output, false);
} catch (error) {
console.error(`[ERROR] onOutput callback failed for ${projectName}:`, error);
}
} else {
console.warn(`[WARN] No onOutput callback provided for ${projectName}`);
}
});
}
if (claudeProcess.stderr) {
claudeProcess.stderr.on('data', (data) => {
const output = data.toString().trim();
console.error(`[${projectName}] ${output}`);
// Send errors to callback if provided
if (onOutput) {
console.log(`[DEBUG] Invoking onOutput callback for stderr in ${projectName}`);
try {
onOutput(output, true);
} catch (error) {
console.error(`[ERROR] onOutput callback failed for ${projectName}:`, error);
}
}
});
}
// Handle exit
claudeProcess.on('exit', (code) => {
console.log(`🛑 Claude exited in ${projectName} (code: ${code})`);
activeProcesses.delete(projectName);
});
claudeProcess.on('error', (error) => {
console.error(`❌ Error spawning Claude in ${projectName}:`, error);
activeProcesses.delete(projectName);
});
// Unref so it doesn't keep Node running
claudeProcess.unref();
return {
success: true,
message: `✅ Claude started in ${projectName}${initialPrompt ? ` with prompt: "${initialPrompt}"` : ''}`,
pid: claudeProcess.pid
};
} catch (error: any) {
return {
success: false,
message: `Failed to spawn Claude: ${error.message}`
};
}
}
// Kill a spawned Claude process
export function killClaude(projectName: string): { success: boolean; message: string } {
const spawned = activeProcesses.get(projectName);
if (!spawned) {
return {
success: false,
message: `No active Claude process found for ${projectName}`
};
}
try {
spawned.process.kill('SIGTERM');
activeProcesses.delete(projectName);
return {
success: true,
message: `✅ Claude process terminated in ${projectName}`
};
} catch (error: any) {
return {
success: false,
message: `Failed to kill process: ${error.message}`
};
}
}
// List all spawned processes
export function listSpawnedProcesses(): Array<{
projectName: string;
pid?: number;
startTime: Date;
initialPrompt?: string;
runningMinutes: number;
}> {
return Array.from(activeProcesses.values()).map(sp => ({
projectName: sp.projectName,
pid: sp.process.pid,
startTime: sp.startTime,
initialPrompt: sp.initialPrompt,
runningMinutes: Math.floor((Date.now() - sp.startTime.getTime()) / 60000)
}));
}
// Check if Claude is running in a project
export function isClaudeRunning(projectName: string): boolean {
return activeProcesses.has(projectName);
}

View File

@@ -3,10 +3,39 @@ import express from 'express';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import fs from 'fs/promises'; import fs from 'fs/promises';
import path from 'path'; import path from 'path';
import {
enqueueTask,
getPendingTasks,
markTaskDelivered,
getQueueSummary,
cleanupOldTasks
} from './queue-manager.js';
import {
spawnClaude,
killClaude,
listSpawnedProcesses,
isClaudeRunning
} from './claude-spawner.js';
import {
registerProject,
unregisterProject,
findProject,
loadProjects,
validateProjectPath
} from './project-registry.js';
import {
handleChatbotMessage,
initChatbot,
isChatbotEnabled,
resetChatbotSession,
getChatbotStatus
} from './chatbot.js';
dotenv.config(); dotenv.config();
const bot = new Telegraf(process.env.TELEGRAM_BOT_TOKEN!); const bot = new Telegraf(process.env.TELEGRAM_BOT_TOKEN!, {
handlerTimeout: 300_000, // 5 min for chatbot responses
});
const app = express(); const app = express();
const PORT = parseInt(process.env.PORT || '3456'); const PORT = parseInt(process.env.PORT || '3456');
const HOST = process.env.HOST || 'localhost'; const HOST = process.env.HOST || 'localhost';
@@ -15,16 +44,51 @@ let ENABLED = process.env.ENABLED !== 'false'; // Now mutable for runtime toggli
let chatId: string | null = process.env.TELEGRAM_CHAT_ID || null; let chatId: string | null = process.env.TELEGRAM_CHAT_ID || null;
const envPath = path.join(process.cwd(), '.env'); const envPath = path.join(process.cwd(), '.env');
// Session tracking for multi-project support
interface ClaudeSession {
id: string;
projectName: string;
projectPath: string;
startTime: Date;
lastActivity: Date;
status: 'active' | 'idle';
}
const activeSessions = new Map<string, ClaudeSession>();
const SESSION_TIMEOUT = 30 * 60 * 1000; // 30 minutes of inactivity
// Message queue for two-way communication // Message queue for two-way communication
interface QueuedMessage { interface QueuedMessage {
from: string; from: string;
message: string; message: string;
timestamp: Date; timestamp: Date;
read: boolean; read: boolean;
sessionId?: string; // Target session for this message
} }
const messageQueue: QueuedMessage[] = []; const messageQueue: QueuedMessage[] = [];
const pendingQuestions = new Map<string, { resolve: (answer: string) => void; timeout: NodeJS.Timeout }>(); const pendingQuestions = new Map<string, {
resolve: (answer: string) => void;
timeout: NodeJS.Timeout;
sessionId?: string;
}>();
// Track the last session that sent a message (for auto-routing replies)
let lastMessageSession: string | null = null;
// Clean up expired sessions periodically
setInterval(() => {
const now = Date.now();
for (const [sessionId, session] of activeSessions.entries()) {
if (now - session.lastActivity.getTime() > SESSION_TIMEOUT) {
console.log(`🧹 Removing expired session: ${sessionId} (${session.projectName})`);
activeSessions.delete(sessionId);
}
}
}, 5 * 60 * 1000); // Check every 5 minutes
// Initialize chatbot module
initChatbot();
app.use(express.json()); app.use(express.json());
@@ -73,21 +137,29 @@ bot.command('status', async (ctx) => {
bot.command('help', async (ctx) => { bot.command('help', async (ctx) => {
await ctx.reply( await ctx.reply(
'*Claude Telegram Bridge - Commands*\n\n' + '*Claude Telegram Bridge - Commands*\n\n' +
'*Bot Commands:*\n' + '*Chatbot:*\n' +
'`/start` - Initialize and connect\n' + '• Send any message — Claude ответит как чат-бот\n' +
'`/help` - Show this help message\n' + '`/chatbot` - Статус чат-бота\n' +
'`/chatreset` - Сброс диалога\n\n' +
'*Session Management:*\n' +
'`/sessions` - List active Claude sessions\n' +
'`/queue` - View queued messages\n\n' +
'*Project Management:*\n' +
'`/projects` - List registered projects\n' +
'`/register` ProjectName /path [--auto-spawn]\n' +
'`/unregister` ProjectName\n' +
'`/spawn` ProjectName [prompt]\n' +
'`/spawned` - List spawned processes\n' +
'`/kill` ProjectName\n\n' +
'*Bot Control:*\n' +
'`/status` - Check bridge status\n' + '`/status` - Check bridge status\n' +
'`/test` - Send test notification\n\n' + '`/test` - Send test notification\n\n' +
'*How it works:*\n' + '*How it works:*\n' +
'• Send me any message - I forward it to Claude\n' + '• Send any message — chatbot responds via Claude CLI\n' +
'• Claude processes it and replies back\n' + '• Target specific project: `ProjectName: message`\n' +
'• When Claude asks a question, your next message answers it\n\n' + '• Messages show context: 📁 ProjectName [#abc1234]\n' +
'*Features:*\n' + '• Register projects for remote spawning\n' +
'✅ Two-way communication\n' + '• Messages queue when projects are offline',
'✅ Question/Answer flow\n' +
'✅ Progress notifications\n' +
'✅ Error alerts\n\n' +
'More info: See README in bridge folder',
{ parse_mode: 'Markdown' } { parse_mode: 'Markdown' }
); );
}); });
@@ -96,6 +168,270 @@ bot.command('test', async (ctx) => {
await ctx.reply('✅ Test notification received! Bridge is working.'); await ctx.reply('✅ Test notification received! Bridge is working.');
}); });
bot.command('chatbot', async (ctx) => {
const status = getChatbotStatus(ctx.chat.id.toString());
const enabledText = status.enabled ? '✅ Включен' : '⛔ Выключен';
const sessionText = status.hasSession
? `🗣 Активная сессия: ${status.messageCount} сообщений, последнее ${status.sessionAge} мин назад`
: '📭 Нет активной сессии';
await ctx.reply(
`*Chatbot Status*\n\n` +
`${enabledText}\n` +
`${sessionText}\n\n` +
`Сброс диалога: /chatreset`,
{ parse_mode: 'Markdown' }
);
});
bot.command('chatreset', async (ctx) => {
const deleted = resetChatbotSession(ctx.chat.id.toString());
if (deleted) {
await ctx.reply('🔄 Диалог сброшен. Следующее сообщение начнёт новую сессию.');
} else {
await ctx.reply('📭 Нет активной сессии для сброса.');
}
});
bot.command('sessions', async (ctx) => {
const sessions = Array.from(activeSessions.values());
if (sessions.length === 0) {
await ctx.reply('📭 No active Claude sessions');
return;
}
const sessionList = sessions.map((s, i) => {
const shortId = s.id.substring(0, 7);
const idleMinutes = Math.floor((Date.now() - s.lastActivity.getTime()) / 60000);
const statusEmoji = s.status === 'active' ? '🟢' : '🟡';
return `${i + 1}. ${statusEmoji} *${s.projectName}* [#${shortId}]\n Last active: ${idleMinutes}m ago`;
}).join('\n\n');
await ctx.reply(
`*Active Claude Sessions* (${sessions.length})\n\n${sessionList}\n\n_To send message to specific project: ProjectName: your message_`,
{ parse_mode: 'Markdown' }
);
});
bot.command('queue', async (ctx) => {
try {
const summary = await getQueueSummary();
if (summary.length === 0) {
await ctx.reply('📭 No queued messages');
return;
}
const queueList = summary.map((s, i) => {
return `${i + 1}. *${s.projectName}*\n 📥 ${s.pending} pending (${s.total} total)`;
}).join('\n\n');
await ctx.reply(
`*Queued Messages* (${summary.length} projects)\n\n${queueList}`,
{ parse_mode: 'Markdown' }
);
} catch (error: any) {
await ctx.reply(`❌ Error: ${error.message}`);
}
});
bot.command('projects', async (ctx) => {
try {
const projects = await loadProjects();
if (projects.length === 0) {
await ctx.reply('📭 No registered projects\n\nRegister with: `/register ProjectName /path/to/project`', { parse_mode: 'Markdown' });
return;
}
const projectList = projects.map((p, i) => {
const autoSpawnEmoji = p.autoSpawn ? '🔄' : '⏸️';
const lastAccessed = new Date(p.lastAccessed).toLocaleDateString();
const running = isClaudeRunning(p.name) ? '🟢' : '⚪';
return `${i + 1}. ${running} *${p.name}* ${autoSpawnEmoji}\n 📍 ${p.path}\n 🕐 Last: ${lastAccessed}`;
}).join('\n\n');
await ctx.reply(
`*Registered Projects* (${projects.length})\n\n${projectList}\n\n🟢 Running ⚪ Offline 🔄 Auto-spawn ⏸️ Manual`,
{ parse_mode: 'Markdown' }
);
} catch (error: any) {
await ctx.reply(`❌ Error: ${error.message}`);
}
});
bot.command('register', async (ctx) => {
const args = ctx.message.text.split(' ').slice(1);
if (args.length < 2) {
await ctx.reply(
'📝 *Register a Project*\n\n' +
'Usage: `/register ProjectName /path/to/project [--auto-spawn]`\n\n' +
'Example: `/register MyApp ~/code/myapp --auto-spawn`\n\n' +
'Options:\n' +
'• `--auto-spawn`: Auto-start Claude when messages arrive',
{ parse_mode: 'Markdown' }
);
return;
}
const projectName = args[0];
const projectPath = args[1].replace('~', process.env.HOME || '~');
const autoSpawn = args.includes('--auto-spawn');
try {
// Validate path exists
const isValid = await validateProjectPath(projectPath);
if (!isValid) {
await ctx.reply(`❌ Path does not exist or is not a directory: ${projectPath}`);
return;
}
await registerProject(projectName, projectPath, { autoSpawn });
await ctx.reply(
`✅ Project registered successfully!\n\n` +
`📁 *${projectName}*\n` +
`📍 ${projectPath}\n` +
`${autoSpawn ? '🔄 Auto-spawn enabled' : '⏸️ Manual spawn only'}\n\n` +
`Spawn with: \`/spawn ${projectName}\``,
{ parse_mode: 'Markdown' }
);
} catch (error: any) {
await ctx.reply(`❌ Registration failed: ${error.message}`);
}
});
bot.command('unregister', async (ctx) => {
const args = ctx.message.text.split(' ').slice(1);
if (args.length === 0) {
await ctx.reply('Usage: `/unregister ProjectName`', { parse_mode: 'Markdown' });
return;
}
const projectName = args[0];
try {
const success = await unregisterProject(projectName);
if (success) {
await ctx.reply(`✅ Project *${projectName}* unregistered`, { parse_mode: 'Markdown' });
} else {
await ctx.reply(`❌ Project *${projectName}* not found`, { parse_mode: 'Markdown' });
}
} catch (error: any) {
await ctx.reply(`❌ Error: ${error.message}`);
}
});
bot.command('spawn', async (ctx) => {
const args = ctx.message.text.split(' ').slice(1);
if (args.length === 0) {
await ctx.reply(
'🚀 *Spawn Claude in a Project*\n\n' +
'Usage: `/spawn ProjectName [prompt]`\n\n' +
'Example:\n' +
'`/spawn MyApp`\n' +
'`/spawn MyApp "Fix the login bug"`',
{ parse_mode: 'Markdown' }
);
return;
}
const projectName = args[0];
const initialPrompt = args.slice(1).join(' ') || undefined;
try {
await ctx.reply(`⏳ Starting Claude in *${projectName}*...`, { parse_mode: 'Markdown' });
// Create callback to send Claude output to Telegram
const outputCallback = async (data: string, isError: boolean) => {
console.log(`[CALLBACK] Received output for ${projectName}: ${data.substring(0, 100)}...`);
if (!chatId) {
console.error('[CALLBACK] No chatId available, cannot send to Telegram');
return;
}
try {
const emoji = isError ? '❌' : '🤖';
console.log(`[CALLBACK] Sending to Telegram chatId: ${chatId}`);
await bot.telegram.sendMessage(
chatId,
`📁 *${projectName}*\n${emoji} ${data}`,
{ parse_mode: 'Markdown' }
);
console.log(`[CALLBACK] Successfully sent to Telegram`);
} catch (error) {
console.error('[CALLBACK] Failed to send Claude output to Telegram:', error);
}
};
console.log(`[SPAWN] Creating callback for ${projectName}, chatId: ${chatId}`);
const result = await spawnClaude(projectName, initialPrompt, outputCallback);
if (result.success) {
await ctx.reply(
`${result.message}\n\n` +
`PID: ${result.pid}\n\n` +
`You can now send messages to it: \`${projectName}: your message\``,
{ parse_mode: 'Markdown' }
);
} else {
await ctx.reply(`${result.message}`);
}
} catch (error: any) {
await ctx.reply(`❌ Spawn failed: ${error.message}`);
}
});
bot.command('kill', async (ctx) => {
const args = ctx.message.text.split(' ').slice(1);
if (args.length === 0) {
await ctx.reply('Usage: `/kill ProjectName`', { parse_mode: 'Markdown' });
return;
}
const projectName = args[0];
try {
const result = killClaude(projectName);
if (result.success) {
await ctx.reply(`🛑 ${result.message}`);
} else {
await ctx.reply(`${result.message}`);
}
} catch (error: any) {
await ctx.reply(`❌ Error: ${error.message}`);
}
});
bot.command('spawned', async (ctx) => {
try {
const spawned = listSpawnedProcesses();
if (spawned.length === 0) {
await ctx.reply('📭 No spawned Claude processes');
return;
}
const spawnedList = spawned.map((s, i) => {
const prompt = s.initialPrompt ? `\n 💬 "${s.initialPrompt.substring(0, 50)}${s.initialPrompt.length > 50 ? '...' : ''}"` : '';
return `${i + 1}. *${s.projectName}*\n 🆔 PID: ${s.pid}\n ⏱️ Running: ${s.runningMinutes}m${prompt}`;
}).join('\n\n');
await ctx.reply(
`*Spawned Claude Processes* (${spawned.length})\n\n${spawnedList}\n\n_Kill with: /kill ProjectName_`,
{ parse_mode: 'Markdown' }
);
} catch (error: any) {
await ctx.reply(`❌ Error: ${error.message}`);
}
});
// Listen for any text messages from user // Listen for any text messages from user
bot.on('text', async (ctx) => { bot.on('text', async (ctx) => {
const message = ctx.message.text; const message = ctx.message.text;
@@ -114,18 +450,301 @@ bot.on('text', async (ctx) => {
return; return;
} }
// Add to message queue for processing // Check if message is targeted to a specific project: "ProjectName: message"
// Use 's' flag (dotall) so . matches newlines for multi-line messages
const projectMatch = message.match(/^([a-zA-Z0-9-_]+):\s*(.+)/s);
if (projectMatch) {
const [, targetProject, actualMessage] = projectMatch;
// Check if project has an active session
const activeSession = Array.from(activeSessions.values())
.find(s => s.projectName.toLowerCase() === targetProject.toLowerCase());
// Check if Claude is actually running (not just session registered)
const claudeActuallyRunning = isClaudeRunning(targetProject);
if (activeSession && claudeActuallyRunning) {
// Add to message queue with session ID
messageQueue.push({
from,
message: actualMessage,
timestamp: new Date(),
read: false,
sessionId: activeSession.id
});
await ctx.reply(`💬 Message sent to active session: *${activeSession.projectName}*`, { parse_mode: 'Markdown' });
} else {
// Clean up stale session if Claude exited
if (activeSession && !claudeActuallyRunning) {
console.log(`[CLEANUP] Removing stale session for ${activeSession.projectName} (Claude not running)`);
activeSessions.delete(activeSession.id);
}
// No active session - check if project is registered and should auto-spawn
try {
const project = await findProject(targetProject);
if (project && project.autoSpawn) {
// Auto-spawn Claude for this project
await ctx.reply(`⏳ Auto-spawning Claude for *${project.name}*...`, { parse_mode: 'Markdown' });
// Create callback to send Claude output to Telegram
const outputCallback = async (data: string, isError: boolean) => {
console.log(`[AUTO-SPAWN CALLBACK] Received output for ${project.name}: ${data.substring(0, 100)}...`);
if (!chatId) {
console.error('[AUTO-SPAWN CALLBACK] No chatId available, cannot send to Telegram');
return;
}
try {
const emoji = isError ? '❌' : '🤖';
console.log(`[AUTO-SPAWN CALLBACK] Sending to Telegram chatId: ${chatId}`);
await bot.telegram.sendMessage(
chatId,
`📁 *${project.name}*\n${emoji} ${data}`,
{ parse_mode: 'Markdown' }
);
console.log(`[AUTO-SPAWN CALLBACK] Successfully sent to Telegram`);
} catch (error) {
console.error('[AUTO-SPAWN CALLBACK] Failed to send Claude output to Telegram:', error);
}
};
console.log(`[AUTO-SPAWN] Creating callback for ${project.name}, chatId: ${chatId}`);
const result = await spawnClaude(project.name, actualMessage, outputCallback);
if (result.success) {
await ctx.reply(
`✅ Claude started for *${project.name}*\n\n` +
`PID: ${result.pid}\n` +
`💬 Your message was passed as the initial prompt.`,
{ parse_mode: 'Markdown' }
);
} else {
// Spawn failed - queue the message instead
await enqueueTask({
projectName: targetProject,
projectPath: project.path,
message: actualMessage,
from,
priority: 'normal',
timestamp: new Date()
});
await ctx.reply(`❌ Auto-spawn failed: ${result.message}\n\n📥 Message queued instead.`, { parse_mode: 'Markdown' });
}
} else if (project) {
// Project exists but auto-spawn disabled - just queue
await enqueueTask({
projectName: targetProject,
projectPath: project.path,
message: actualMessage,
from,
priority: 'normal',
timestamp: new Date()
});
await ctx.reply(
`📥 Message queued for *${project.name}* (offline)\n\n` +
`Auto-spawn is disabled. Start manually with: \`/spawn ${project.name}\``,
{ parse_mode: 'Markdown' }
);
} else {
// Project not registered
await enqueueTask({
projectName: targetProject,
projectPath: '/unknown',
message: actualMessage,
from,
priority: 'normal',
timestamp: new Date()
});
await ctx.reply(
`📥 Message queued for *${targetProject}* (not registered)\n\n` +
`Register with: \`/register ${targetProject} /path/to/project --auto-spawn\``,
{ parse_mode: 'Markdown' }
);
}
} catch (error: any) {
await ctx.reply(`❌ Failed to process message: ${error.message}`);
}
}
return;
}
// No project specified - check if we should auto-route to last session
if (lastMessageSession && activeSessions.has(lastMessageSession)) {
const session = activeSessions.get(lastMessageSession)!;
const claudeRunning = isClaudeRunning(session.projectName);
if (claudeRunning) {
messageQueue.push({
from,
message,
timestamp: new Date(),
read: false,
sessionId: lastMessageSession
});
await ctx.reply(`💬 Auto-routed to: 📁 *${session.projectName}* [#${lastMessageSession.substring(0, 7)}]`, { parse_mode: 'Markdown' });
console.log(`📥 Auto-routed to ${session.projectName}`);
return;
}
// Claude not actually running — clean up stale session
console.log(`[CLEANUP] Removing stale lastMessageSession for ${session.projectName}`);
activeSessions.delete(lastMessageSession);
lastMessageSession = null;
}
// No active project session — use chatbot if enabled
if (isChatbotEnabled()) {
const userChatId = ctx.chat.id.toString();
console.log(`🤖 Chatbot handling message from ${from}: "${message.substring(0, 50)}..."`);
// Fire-and-forget: don't block Telegraf's handler
handleChatbotMessage(
userChatId,
message,
async () => {
await ctx.sendChatAction('typing');
},
async (text: string, parseMode?: string) => {
await ctx.reply(text, parseMode ? { parse_mode: parseMode as any } : {});
}
).catch(err => console.error('[Chatbot] Async error:', err));
} else {
// Fallback: queue the message (original behavior)
messageQueue.push({ messageQueue.push({
from, from,
message, message,
timestamp: new Date(), timestamp: new Date(),
read: false read: false
}); });
await ctx.reply('💬 Message received - queued for processing.');
// Acknowledge receipt - Claude will respond when available
await ctx.reply('💬 Message received - responding...');
console.log('📥 Queued for Claude to process'); console.log('📥 Queued for Claude to process');
}
});
// Register or update a Claude session
app.post('/session/register', (req, res) => {
const { sessionId, projectName, projectPath } = req.body;
if (!sessionId || !projectName || !projectPath) {
return res.status(400).json({ error: 'sessionId, projectName, and projectPath are required' });
}
const now = new Date();
const existing = activeSessions.get(sessionId);
if (existing) {
// Update existing session
existing.lastActivity = now;
existing.status = 'active';
} else {
// Create new session
activeSessions.set(sessionId, {
id: sessionId,
projectName,
projectPath,
startTime: now,
lastActivity: now,
status: 'active'
});
console.log(`📝 Registered new session: ${sessionId} (${projectName})`);
}
res.json({ success: true, sessionId, projectName });
});
// Update session activity
app.post('/session/heartbeat', (req, res) => {
const { sessionId } = req.body;
if (!sessionId) {
return res.status(400).json({ error: 'sessionId is required' });
}
const session = activeSessions.get(sessionId);
if (session) {
session.lastActivity = new Date();
session.status = 'active';
res.json({ success: true });
} else {
res.status(404).json({ error: 'Session not found' });
}
});
// List active sessions
app.get('/sessions', (req, res) => {
const sessions = Array.from(activeSessions.values()).map(s => ({
id: s.id,
projectName: s.projectName,
projectPath: s.projectPath,
startTime: s.startTime,
lastActivity: s.lastActivity,
status: s.status,
idleMinutes: Math.floor((Date.now() - s.lastActivity.getTime()) / 60000)
}));
res.json({ sessions, count: sessions.length });
});
// Queue management endpoints
app.post('/queue/add', async (req, res) => {
const { projectName, projectPath, message, from, priority = 'normal' } = req.body;
if (!projectName || !message || !from) {
return res.status(400).json({ error: 'projectName, message, and from are required' });
}
try {
const task = await enqueueTask({
projectName,
projectPath: projectPath || '/unknown',
message,
from,
priority,
timestamp: new Date()
});
res.json({ success: true, taskId: task.id, task });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
app.get('/queue/:projectName', async (req, res) => {
const { projectName } = req.params;
try {
const tasks = await getPendingTasks(projectName);
res.json({ projectName, tasks, count: tasks.length });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
app.post('/queue/:projectName/mark-delivered', async (req, res) => {
const { projectName } = req.params;
const { taskId } = req.body;
if (!taskId) {
return res.status(400).json({ error: 'taskId is required' });
}
try {
await markTaskDelivered(projectName, taskId);
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
app.get('/queue/summary', async (req, res) => {
try {
const summary = await getQueueSummary();
res.json({ summary, totalProjects: summary.length });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
}); });
// HTTP endpoint for sending notifications // HTTP endpoint for sending notifications
@@ -140,7 +759,7 @@ app.post('/notify', async (req, res) => {
}); });
} }
const { message, priority = 'info', parseMode = 'Markdown' } = req.body; const { message, priority = 'info', parseMode = 'Markdown', sessionId } = req.body;
if (!message) { if (!message) {
return res.status(400).json({ error: 'Message is required' }); return res.status(400).json({ error: 'Message is required' });
@@ -156,9 +775,22 @@ app.post('/notify', async (req, res) => {
}; };
const emoji = emojiMap[priority] || ''; const emoji = emojiMap[priority] || '';
// Add project context if session ID provided
let projectContext = '';
if (sessionId) {
const session = activeSessions.get(sessionId);
if (session) {
session.lastActivity = new Date();
const shortId = sessionId.substring(0, 7);
projectContext = `📁 *${session.projectName}* [#${shortId}]\n`;
// Track this as the last session that sent a message
lastMessageSession = sessionId;
}
}
await bot.telegram.sendMessage( await bot.telegram.sendMessage(
chatId, chatId,
`${emoji} ${message}`, `${projectContext}${emoji} ${message}`,
{ parse_mode: parseMode as any } { parse_mode: parseMode as any }
); );
@@ -290,6 +922,145 @@ app.get('/status', (req, res) => {
}); });
}); });
// Project registry endpoints
app.get('/projects', async (req, res) => {
try {
const projects = await loadProjects();
res.json({ projects, count: projects.length });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
app.post('/projects/register', async (req, res) => {
const { name, path: projectPath, autoSpawn, description, tags } = req.body;
if (!name || !projectPath) {
return res.status(400).json({ error: 'name and path are required' });
}
try {
const isValid = await validateProjectPath(projectPath);
if (!isValid) {
return res.status(400).json({ error: 'Invalid path or not a directory' });
}
const project = await registerProject(name, projectPath, { autoSpawn, description, tags });
res.json({ success: true, project });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
app.delete('/projects/:name', async (req, res) => {
const { name } = req.params;
try {
const success = await unregisterProject(name);
if (success) {
res.json({ success: true, message: `Project ${name} unregistered` });
} else {
res.status(404).json({ error: 'Project not found' });
}
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
app.get('/projects/:name', async (req, res) => {
const { name } = req.params;
try {
const project = await findProject(name);
if (project) {
res.json({ project });
} else {
res.status(404).json({ error: 'Project not found' });
}
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Claude spawner endpoints
app.post('/spawn', async (req, res) => {
const { projectName, initialPrompt } = req.body;
if (!projectName) {
return res.status(400).json({ error: 'projectName is required' });
}
try {
// Create callback to send Claude output to Telegram
const outputCallback = chatId ? async (data: string, isError: boolean) => {
console.log(`[HTTP CALLBACK] Received output for ${projectName}: ${data.substring(0, 100)}...`);
try {
const emoji = isError ? '❌' : '🤖';
console.log(`[HTTP CALLBACK] Sending to Telegram chatId: ${chatId}`);
await bot.telegram.sendMessage(
chatId!,
`📁 *${projectName}*\n${emoji} ${data}`,
{ parse_mode: 'Markdown' }
);
console.log(`[HTTP CALLBACK] Successfully sent to Telegram`);
} catch (error) {
console.error('[HTTP CALLBACK] Failed to send Claude output to Telegram:', error);
}
} : undefined;
console.log(`[HTTP /spawn] Creating callback for ${projectName}, chatId: ${chatId}, hasCallback: ${!!outputCallback}`);
const result = await spawnClaude(projectName, initialPrompt, outputCallback);
if (result.success) {
res.json(result);
} else {
res.status(400).json(result);
}
} catch (error: any) {
res.status(500).json({ success: false, message: error.message });
}
});
app.post('/kill/:projectName', (req, res) => {
const { projectName } = req.params;
try {
const result = killClaude(projectName);
if (result.success) {
res.json(result);
} else {
res.status(404).json(result);
}
} catch (error: any) {
res.status(500).json({ success: false, message: error.message });
}
});
app.get('/spawned', (req, res) => {
try {
const spawned = listSpawnedProcesses();
res.json({ processes: spawned, count: spawned.length });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
app.get('/spawned/:projectName', (req, res) => {
const { projectName } = req.params;
try {
const running = isClaudeRunning(projectName);
if (running) {
const spawned = listSpawnedProcesses().find(p => p.projectName === projectName);
res.json({ running: true, process: spawned });
} else {
res.json({ running: false });
}
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Start bot // Start bot
bot.launch().then(() => { bot.launch().then(() => {
console.log('🤖 Telegram bot started'); console.log('🤖 Telegram bot started');

View File

@@ -20,6 +20,50 @@ const BRIDGE_HOST = new URL(BRIDGE_URL).hostname || 'localhost';
let bridgeProcess: ChildProcess | null = null; let bridgeProcess: ChildProcess | null = null;
// Session management
let currentSessionId: string | null = null;
let currentProjectName: string | null = null;
let currentProjectPath: string | null = null;
// Get or create session ID
function getSessionId(): string {
if (!currentSessionId) {
currentSessionId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
return currentSessionId;
}
// Get project name from current working directory
function getProjectInfo(): { name: string; path: string } {
const cwd = process.cwd();
const name = cwd.split('/').pop() || 'Unknown';
return { name, path: cwd };
}
// Register this session with the bridge
async function registerSession(): Promise<void> {
const sessionId = getSessionId();
const { name, path } = getProjectInfo();
currentProjectName = name;
currentProjectPath = path;
try {
await fetch(`${BRIDGE_URL}/session/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId,
projectName: name,
projectPath: path
})
});
console.error(`✅ Session registered: ${name} [${sessionId.substring(0, 7)}]`);
} catch (error) {
console.error('⚠️ Failed to register session:', error);
}
}
// Check if the bridge is running // Check if the bridge is running
async function isBridgeRunning(): Promise<boolean> { async function isBridgeRunning(): Promise<boolean> {
try { try {
@@ -174,6 +218,14 @@ const TOOLS: Tool[] = [
properties: {}, properties: {},
}, },
}, },
{
name: 'telegram_check_queue',
description: 'Check if there are any queued messages for this project. Use this on startup to see if the user sent any messages while Claude was offline.',
inputSchema: {
type: 'object',
properties: {},
},
},
]; ];
// Create the MCP server // Create the MCP server
@@ -208,7 +260,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
const response = await fetch(`${BRIDGE_URL}/notify`, { const response = await fetch(`${BRIDGE_URL}/notify`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, priority }), body: JSON.stringify({
message,
priority,
sessionId: getSessionId()
}),
}); });
if (!response.ok) { if (!response.ok) {
@@ -352,6 +408,44 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
}; };
} }
case 'telegram_check_queue': {
const { name: projectName } = getProjectInfo();
const response = await fetch(`${BRIDGE_URL}/queue/${encodeURIComponent(projectName)}`);
if (!response.ok) {
throw new Error('Failed to check queue');
}
const result: any = await response.json();
const tasks = result.tasks || [];
if (tasks.length === 0) {
return {
content: [
{
type: 'text',
text: '📭 No queued messages for this project',
},
],
};
}
const taskList = tasks.map((t: any, i: number) => {
const timestamp = new Date(t.timestamp).toLocaleString();
return `${i + 1}. From *${t.from}* (${timestamp})\n ${t.message}`;
}).join('\n\n');
return {
content: [
{
type: 'text',
text: `📬 You have ${tasks.length} queued message(s):\n\n${taskList}\n\n_These messages were sent while you were offline._`,
},
],
};
}
default: default:
throw new Error(`Unknown tool: ${name}`); throw new Error(`Unknown tool: ${name}`);
} }
@@ -395,6 +489,9 @@ async function main() {
// Ensure the bridge is running before starting the MCP server // Ensure the bridge is running before starting the MCP server
await ensureBridge(); await ensureBridge();
// Register this Claude session
await registerSession();
const transport = new StdioServerTransport(); const transport = new StdioServerTransport();
await server.connect(transport); await server.connect(transport);
console.error('🚀 Telegram MCP server running on stdio'); console.error('🚀 Telegram MCP server running on stdio');

148
src/project-registry.ts Normal file
View File

@@ -0,0 +1,148 @@
// Project Registry for InnerVoice
// Manages known projects and their locations for remote spawning
import fs from 'fs/promises';
import path from 'path';
import { existsSync } from 'fs';
const REGISTRY_PATH = path.join(process.env.HOME || '~', '.innervoice', 'projects.json');
export interface RegisteredProject {
name: string;
path: string;
lastAccessed: Date;
autoSpawn: boolean; // Auto-spawn when message received
metadata?: {
description?: string;
tags?: string[];
};
}
// Ensure registry file exists
async function ensureRegistry(): Promise<void> {
const dir = path.dirname(REGISTRY_PATH);
if (!existsSync(dir)) {
await fs.mkdir(dir, { recursive: true });
}
if (!existsSync(REGISTRY_PATH)) {
await fs.writeFile(REGISTRY_PATH, JSON.stringify([], null, 2));
}
}
// Load all registered projects
export async function loadProjects(): Promise<RegisteredProject[]> {
await ensureRegistry();
try {
const content = await fs.readFile(REGISTRY_PATH, 'utf-8');
const projects = JSON.parse(content);
// Convert date strings back to Date objects
return projects.map((p: any) => ({
...p,
lastAccessed: new Date(p.lastAccessed)
}));
} catch (error) {
console.error('Error loading project registry:', error);
return [];
}
}
// Save projects to registry
export async function saveProjects(projects: RegisteredProject[]): Promise<void> {
await ensureRegistry();
await fs.writeFile(REGISTRY_PATH, JSON.stringify(projects, null, 2));
}
// Register a new project
export async function registerProject(
name: string,
projectPath: string,
options?: { autoSpawn?: boolean; description?: string; tags?: string[] }
): Promise<RegisteredProject> {
const projects = await loadProjects();
// Check if already exists
const existing = projects.find(p => p.name.toLowerCase() === name.toLowerCase());
if (existing) {
// Update existing
existing.path = projectPath;
existing.lastAccessed = new Date();
if (options?.autoSpawn !== undefined) existing.autoSpawn = options.autoSpawn;
if (options?.description) {
existing.metadata = existing.metadata || {};
existing.metadata.description = options.description;
}
if (options?.tags) {
existing.metadata = existing.metadata || {};
existing.metadata.tags = options.tags;
}
await saveProjects(projects);
return existing;
}
// Create new
const newProject: RegisteredProject = {
name,
path: projectPath,
lastAccessed: new Date(),
autoSpawn: options?.autoSpawn ?? false,
metadata: {
description: options?.description,
tags: options?.tags
}
};
projects.push(newProject);
await saveProjects(projects);
console.log(`📝 Registered project: ${name} at ${projectPath}`);
return newProject;
}
// Unregister a project
export async function unregisterProject(name: string): Promise<boolean> {
const projects = await loadProjects();
const filtered = projects.filter(p => p.name.toLowerCase() !== name.toLowerCase());
if (filtered.length === projects.length) {
return false; // Not found
}
await saveProjects(filtered);
console.log(`🗑️ Unregistered project: ${name}`);
return true;
}
// Find a project by name
export async function findProject(name: string): Promise<RegisteredProject | null> {
const projects = await loadProjects();
return projects.find(p => p.name.toLowerCase() === name.toLowerCase()) || null;
}
// Update last accessed time
export async function touchProject(name: string): Promise<void> {
const projects = await loadProjects();
const project = projects.find(p => p.name.toLowerCase() === name.toLowerCase());
if (project) {
project.lastAccessed = new Date();
await saveProjects(projects);
}
}
// Get projects eligible for auto-spawn
export async function getAutoSpawnProjects(): Promise<RegisteredProject[]> {
const projects = await loadProjects();
return projects.filter(p => p.autoSpawn);
}
// Check if a project path exists
export async function validateProjectPath(projectPath: string): Promise<boolean> {
try {
const stats = await fs.stat(projectPath);
return stats.isDirectory();
} catch {
return false;
}
}

145
src/queue-manager.ts Normal file
View File

@@ -0,0 +1,145 @@
// Message Queue Manager for InnerVoice
// Stores messages for offline/inactive projects
import fs from 'fs/promises';
import path from 'path';
import { existsSync } from 'fs';
const QUEUE_DIR = path.join(process.env.HOME || '~', '.innervoice', 'queues');
export interface QueuedTask {
id: string;
projectName: string;
projectPath: string;
message: string;
from: string;
timestamp: Date;
priority: 'low' | 'normal' | 'high';
status: 'pending' | 'delivered' | 'expired';
}
// Ensure queue directory exists
async function ensureQueueDir(): Promise<void> {
if (!existsSync(QUEUE_DIR)) {
await fs.mkdir(QUEUE_DIR, { recursive: true });
}
}
// Get queue file path for a project
function getQueuePath(projectName: string): string {
const safeName = projectName.replace(/[^a-z0-9-_]/gi, '_').toLowerCase();
return path.join(QUEUE_DIR, `${safeName}.json`);
}
// Load queue for a project
export async function loadQueue(projectName: string): Promise<QueuedTask[]> {
await ensureQueueDir();
const queuePath = getQueuePath(projectName);
if (!existsSync(queuePath)) {
return [];
}
try {
const content = await fs.readFile(queuePath, 'utf-8');
const tasks = JSON.parse(content);
// Convert timestamp strings back to Date objects
return tasks.map((t: any) => ({
...t,
timestamp: new Date(t.timestamp)
}));
} catch (error) {
console.error(`Error loading queue for ${projectName}:`, error);
return [];
}
}
// Save queue for a project
export async function saveQueue(projectName: string, tasks: QueuedTask[]): Promise<void> {
await ensureQueueDir();
const queuePath = getQueuePath(projectName);
await fs.writeFile(queuePath, JSON.stringify(tasks, null, 2));
}
// Add a task to the queue
export async function enqueueTask(task: Omit<QueuedTask, 'id' | 'status'>): Promise<QueuedTask> {
const queue = await loadQueue(task.projectName);
const newTask: QueuedTask = {
...task,
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
status: 'pending'
};
queue.push(newTask);
await saveQueue(task.projectName, queue);
console.log(`📥 Queued task for ${task.projectName}: ${task.message.substring(0, 50)}...`);
return newTask;
}
// Get pending tasks for a project
export async function getPendingTasks(projectName: string): Promise<QueuedTask[]> {
const queue = await loadQueue(projectName);
return queue.filter(t => t.status === 'pending');
}
// Mark a task as delivered
export async function markTaskDelivered(projectName: string, taskId: string): Promise<void> {
const queue = await loadQueue(projectName);
const task = queue.find(t => t.id === taskId);
if (task) {
task.status = 'delivered';
await saveQueue(projectName, queue);
console.log(`✅ Task delivered: ${taskId}`);
}
}
// Clear delivered tasks older than N days
export async function cleanupOldTasks(projectName: string, daysOld: number = 7): Promise<number> {
const queue = await loadQueue(projectName);
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - daysOld);
const filtered = queue.filter(t => {
if (t.status === 'delivered' && t.timestamp < cutoff) {
return false; // Remove old delivered tasks
}
return true;
});
const removed = queue.length - filtered.length;
if (removed > 0) {
await saveQueue(projectName, filtered);
console.log(`🧹 Cleaned up ${removed} old tasks for ${projectName}`);
}
return removed;
}
// List all projects with queued tasks
export async function listProjectsWithQueues(): Promise<string[]> {
await ensureQueueDir();
const files = await fs.readdir(QUEUE_DIR);
return files
.filter(f => f.endsWith('.json'))
.map(f => f.replace('.json', '').replace(/_/g, '-'));
}
// Get queue summary for all projects
export async function getQueueSummary(): Promise<{ projectName: string; pending: number; total: number }[]> {
const projects = await listProjectsWithQueues();
const summaries = await Promise.all(
projects.map(async (projectName) => {
const queue = await loadQueue(projectName);
return {
projectName,
pending: queue.filter(t => t.status === 'pending').length,
total: queue.length
};
})
);
return summaries.filter(s => s.total > 0);
}