Compare commits
10 Commits
6dcb4a888c
...
dc92c7fcf6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc92c7fcf6 | ||
|
|
e26d94dcc2 | ||
|
|
93d65d9a34 | ||
|
|
0ce65d7120 | ||
|
|
0d277e4ae2 | ||
|
|
abde877912 | ||
|
|
bdfe51fedf | ||
|
|
fd750d9b50 | ||
|
|
82f46c4569 | ||
|
|
0584ac6c5f |
61
PHASE1-TESTING.md
Normal file
61
PHASE1-TESTING.md
Normal 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
505
README.md
@@ -24,14 +24,33 @@ After trying email, SMS, and Google Chat integrations, Telegram emerged as the b
|
||||
|
||||
## Features
|
||||
|
||||
### Core Communication
|
||||
- 💬 **Two-Way Communication** - Send messages to Claude, get responses back
|
||||
- ❓ **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
|
||||
- 🌐 **HTTP API** - Easy integration from any app/project
|
||||
- 🚀 **Background Service** - Runs independently, always available
|
||||
- 🔧 **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
|
||||
|
||||
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
|
||||
|
||||
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_get_messages` - Check for messages from you
|
||||
- `telegram_reply` - Reply to your messages
|
||||
- `telegram_check_health` - Check bridge status
|
||||
- `telegram_toggle_afk` - Toggle AFK mode (enable/disable notifications)
|
||||
- `telegram_check_queue` - Check for queued messages on startup
|
||||
|
||||
**View detailed tool info:**
|
||||
```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:
|
||||
|
||||
**Recommended: Install Globally (works in all projects)**
|
||||
```bash
|
||||
# From the innervoice directory
|
||||
cd /path/to/innervoice
|
||||
./scripts/install-hook.sh --global
|
||||
```
|
||||
|
||||
**Or install per-project:**
|
||||
```bash
|
||||
# Install in a specific 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
|
||||
> 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
|
||||
|
||||
@@ -294,6 +320,206 @@ Restart Claude Code, then tell Claude:
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
- 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)
|
||||
|
||||
If you want to push this to your own Git repository:
|
||||
@@ -436,32 +681,37 @@ await fetch('http://localhost:3456/notify', {
|
||||
- `error` - ❌ Error occurred
|
||||
- `question` - ❓ Needs your input
|
||||
|
||||
## Bot Commands
|
||||
## How Communication Works
|
||||
|
||||
Type these in Telegram to control the bridge:
|
||||
|
||||
- `/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
|
||||
### Basic Message Flow
|
||||
1. Send any message to the bot in Telegram
|
||||
2. Bot acknowledges with "💬 Message received - responding..."
|
||||
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)
|
||||
Claude sends you updates via the `/notify` API endpoint with different priorities
|
||||
### Targeted Messages
|
||||
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)
|
||||
1. Claude sends a question via `/ask` API
|
||||
### Notifications
|
||||
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
|
||||
3. Your next message is automatically treated as the answer
|
||||
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
|
||||
|
||||
```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
|
||||
|
||||
Add this helper to your ESO-MCP project:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Install the InnerVoice permission notification hook in a project
|
||||
# Install the InnerVoice permission notification hook globally or per-project
|
||||
|
||||
set -e
|
||||
|
||||
@@ -7,12 +7,22 @@ HOOK_NAME="PermissionRequest.sh"
|
||||
INNERVOICE_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
SOURCE_HOOK="$INNERVOICE_DIR/hooks/$HOOK_NAME"
|
||||
|
||||
# Get target project directory (default to current directory)
|
||||
TARGET_DIR="${1:-.}"
|
||||
TARGET_HOOK_DIR="$TARGET_DIR/.claude/hooks"
|
||||
# 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)
|
||||
TARGET_DIR="${1:-.}"
|
||||
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 ""
|
||||
echo "Scope: $SCOPE"
|
||||
echo "Source: $SOURCE_HOOK"
|
||||
echo "Target: $TARGET_HOOK_DIR/$HOOK_NAME"
|
||||
echo ""
|
||||
@@ -35,4 +45,4 @@ echo ""
|
||||
echo "🔔 Now when you're in AFK mode, you'll get Telegram notifications"
|
||||
echo " whenever Claude requests permission for a tool."
|
||||
echo ""
|
||||
echo "To uninstall: rm $TARGET_HOOK_DIR/$HOOK_NAME"
|
||||
echo "To uninstall: $UNINSTALL_CMD"
|
||||
|
||||
306
src/chatbot.ts
Normal file
306
src/chatbot.ts
Normal 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
216
src/claude-spawner.ts
Normal 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);
|
||||
}
|
||||
823
src/index.ts
823
src/index.ts
@@ -3,10 +3,39 @@ import express from 'express';
|
||||
import dotenv from 'dotenv';
|
||||
import fs from 'fs/promises';
|
||||
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();
|
||||
|
||||
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 PORT = parseInt(process.env.PORT || '3456');
|
||||
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;
|
||||
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
|
||||
interface QueuedMessage {
|
||||
from: string;
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
read: boolean;
|
||||
sessionId?: string; // Target session for this message
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
@@ -73,21 +137,29 @@ bot.command('status', async (ctx) => {
|
||||
bot.command('help', async (ctx) => {
|
||||
await ctx.reply(
|
||||
'*Claude Telegram Bridge - Commands*\n\n' +
|
||||
'*Bot Commands:*\n' +
|
||||
'`/start` - Initialize and connect\n' +
|
||||
'`/help` - Show this help message\n' +
|
||||
'*Chatbot:*\n' +
|
||||
'• Send any message — Claude ответит как чат-бот\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' +
|
||||
'`/test` - Send test notification\n\n' +
|
||||
'*How it works:*\n' +
|
||||
'• Send me any message - I forward it to Claude\n' +
|
||||
'• Claude processes it and replies back\n' +
|
||||
'• When Claude asks a question, your next message answers it\n\n' +
|
||||
'*Features:*\n' +
|
||||
'✅ Two-way communication\n' +
|
||||
'✅ Question/Answer flow\n' +
|
||||
'✅ Progress notifications\n' +
|
||||
'✅ Error alerts\n\n' +
|
||||
'More info: See README in bridge folder',
|
||||
'• Send any message — chatbot responds via Claude CLI\n' +
|
||||
'• Target specific project: `ProjectName: message`\n' +
|
||||
'• Messages show context: 📁 ProjectName [#abc1234]\n' +
|
||||
'• Register projects for remote spawning\n' +
|
||||
'• Messages queue when projects are offline',
|
||||
{ parse_mode: 'Markdown' }
|
||||
);
|
||||
});
|
||||
@@ -96,6 +168,270 @@ bot.command('test', async (ctx) => {
|
||||
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
|
||||
bot.on('text', async (ctx) => {
|
||||
const message = ctx.message.text;
|
||||
@@ -114,18 +450,301 @@ bot.on('text', async (ctx) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to message queue for processing
|
||||
messageQueue.push({
|
||||
from,
|
||||
message,
|
||||
timestamp: new Date(),
|
||||
read: false
|
||||
});
|
||||
// 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;
|
||||
|
||||
// Acknowledge receipt - Claude will respond when available
|
||||
await ctx.reply('💬 Message received - responding...');
|
||||
// Check if project has an active session
|
||||
const activeSession = Array.from(activeSessions.values())
|
||||
.find(s => s.projectName.toLowerCase() === targetProject.toLowerCase());
|
||||
|
||||
console.log('📥 Queued for Claude to process');
|
||||
// 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({
|
||||
from,
|
||||
message,
|
||||
timestamp: new Date(),
|
||||
read: false
|
||||
});
|
||||
await ctx.reply('💬 Message received - queued for processing.');
|
||||
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
|
||||
@@ -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) {
|
||||
return res.status(400).json({ error: 'Message is required' });
|
||||
@@ -156,9 +775,22 @@ app.post('/notify', async (req, res) => {
|
||||
};
|
||||
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(
|
||||
chatId,
|
||||
`${emoji} ${message}`,
|
||||
`${projectContext}${emoji} ${message}`,
|
||||
{ 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
|
||||
bot.launch().then(() => {
|
||||
console.log('🤖 Telegram bot started');
|
||||
|
||||
@@ -20,6 +20,50 @@ const BRIDGE_HOST = new URL(BRIDGE_URL).hostname || 'localhost';
|
||||
|
||||
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
|
||||
async function isBridgeRunning(): Promise<boolean> {
|
||||
try {
|
||||
@@ -174,6 +218,14 @@ const TOOLS: Tool[] = [
|
||||
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
|
||||
@@ -208,7 +260,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const response = await fetch(`${BRIDGE_URL}/notify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message, priority }),
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
priority,
|
||||
sessionId: getSessionId()
|
||||
}),
|
||||
});
|
||||
|
||||
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:
|
||||
throw new Error(`Unknown tool: ${name}`);
|
||||
}
|
||||
@@ -395,6 +489,9 @@ async function main() {
|
||||
// Ensure the bridge is running before starting the MCP server
|
||||
await ensureBridge();
|
||||
|
||||
// Register this Claude session
|
||||
await registerSession();
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error('🚀 Telegram MCP server running on stdio');
|
||||
|
||||
148
src/project-registry.ts
Normal file
148
src/project-registry.ts
Normal 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
145
src/queue-manager.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user