Files
innervoice/src/index.ts
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

881 lines
24 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Telegraf } from 'telegraf';
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';
dotenv.config();
const bot = new Telegraf(process.env.TELEGRAM_BOT_TOKEN!);
const app = express();
const PORT = parseInt(process.env.PORT || '3456');
const HOST = process.env.HOST || 'localhost';
let ENABLED = process.env.ENABLED !== 'false'; // Now mutable for runtime toggling
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;
sessionId?: string;
}>();
// 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
app.use(express.json());
// Save chat ID to .env file
async function saveChatId(id: string) {
try {
const envContent = await fs.readFile(envPath, 'utf-8');
const updated = envContent.replace(
/TELEGRAM_CHAT_ID=.*/,
`TELEGRAM_CHAT_ID=${id}`
);
await fs.writeFile(envPath, updated);
console.log(`✅ Chat ID saved: ${id}`);
} catch (error) {
console.error('Failed to save chat ID:', error);
}
}
// Bot commands
bot.start(async (ctx) => {
chatId = ctx.chat.id.toString();
await saveChatId(chatId);
await ctx.reply(
'🤖 *Claude Telegram Bridge Active*\n\n' +
'I will now forward notifications from Claude Code and other apps.\n\n' +
'*Commands:*\n' +
'/status - Check bridge status\n' +
'/enable - Enable notifications\n' +
'/disable - Disable notifications\n' +
'/test - Send test notification',
{ parse_mode: 'Markdown' }
);
});
bot.command('status', async (ctx) => {
const status = ENABLED ? '✅ Enabled' : '⛔ Disabled';
await ctx.reply(
`*Bridge Status*\n\n` +
`Status: ${status}\n` +
`Chat ID: ${chatId}\n` +
`HTTP Server: http://${HOST}:${PORT}`,
{ parse_mode: 'Markdown' }
);
});
bot.command('help', async (ctx) => {
await ctx.reply(
'*Claude Telegram Bridge - Commands*\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 any message - forwards to active Claude\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\n\n' +
'More info: See README in bridge folder',
{ parse_mode: 'Markdown' }
);
});
bot.command('test', async (ctx) => {
await ctx.reply('✅ Test notification received! Bridge is working.');
});
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' });
const result = await spawnClaude(projectName, initialPrompt);
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;
const from = ctx.from.username || ctx.from.first_name;
console.log(`\n📨 Message from ${from}: "${message}"\n`);
// Check if this is an answer to a pending question
const questionId = Array.from(pendingQuestions.keys())[0];
if (questionId && pendingQuestions.has(questionId)) {
const { resolve, timeout } = pendingQuestions.get(questionId)!;
clearTimeout(timeout);
pendingQuestions.delete(questionId);
resolve(message);
await ctx.reply('✅ Answer received!');
return;
}
// Check if message is targeted to a specific project: "ProjectName: message"
const projectMatch = message.match(/^([a-zA-Z0-9-_]+):\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());
if (activeSession) {
// 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 {
// Queue for when project becomes active
try {
await enqueueTask({
projectName: targetProject,
projectPath: '/unknown',
message: actualMessage,
from,
priority: 'normal',
timestamp: new Date()
});
await ctx.reply(`📥 Message queued for *${targetProject}* (offline)\n\nIt will be delivered when Claude starts in that project.`, { parse_mode: 'Markdown' });
} catch (error: any) {
await ctx.reply(`❌ Failed to queue message: ${error.message}`);
}
}
return;
}
// No project specified - add to general message queue
messageQueue.push({
from,
message,
timestamp: new Date(),
read: false
});
// Acknowledge receipt - Claude will respond when available
await ctx.reply('💬 Message received - responding...');
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
app.post('/notify', async (req, res) => {
if (!ENABLED) {
return res.status(503).json({ error: 'Bridge is disabled' });
}
if (!chatId) {
return res.status(400).json({
error: 'No chat ID set. Please message the bot first with /start'
});
}
const { message, priority = 'info', parseMode = 'Markdown', sessionId } = req.body;
if (!message) {
return res.status(400).json({ error: 'Message is required' });
}
try {
const emojiMap: Record<string, string> = {
info: '',
success: '✅',
warning: '⚠️',
error: '❌',
question: '❓'
};
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`;
}
}
await bot.telegram.sendMessage(
chatId,
`${projectContext}${emoji} ${message}`,
{ parse_mode: parseMode as any }
);
res.json({ success: true, chatId });
} catch (error: any) {
console.error('Failed to send message:', error);
res.status(500).json({ error: error.message });
}
});
// Get unread messages
app.get('/messages', (req, res) => {
const unread = messageQueue.filter(m => !m.read);
res.json({ messages: unread, count: unread.length });
});
// Mark messages as read
app.post('/messages/read', (req, res) => {
const { count } = req.body;
const toMark = count || messageQueue.filter(m => !m.read).length;
let marked = 0;
for (const msg of messageQueue) {
if (!msg.read && marked < toMark) {
msg.read = true;
marked++;
}
}
res.json({ markedAsRead: marked });
});
// Send reply to user message
app.post('/reply', async (req, res) => {
if (!chatId) {
return res.status(400).json({ error: 'No chat ID set' });
}
const { message } = req.body;
if (!message) {
return res.status(400).json({ error: 'Message is required' });
}
try {
await bot.telegram.sendMessage(chatId, message, { parse_mode: 'Markdown' });
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Ask a question and wait for answer
app.post('/ask', async (req, res) => {
if (!chatId) {
return res.status(400).json({ error: 'No chat ID set' });
}
const { question, timeout = 300000 } = req.body; // 5 min default timeout
if (!question) {
return res.status(400).json({ error: 'Question is required' });
}
try {
const questionId = Date.now().toString();
// Send question to Telegram
await bot.telegram.sendMessage(chatId, `${question}`, { parse_mode: 'Markdown' });
// Wait for answer
const answer = await new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => {
pendingQuestions.delete(questionId);
reject(new Error('Timeout waiting for answer'));
}, timeout);
pendingQuestions.set(questionId, { resolve, timeout: timer });
});
res.json({ answer });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Health check
app.get('/health', (req, res) => {
res.json({
status: 'running',
enabled: ENABLED,
chatId: chatId ? 'set' : 'not set',
unreadMessages: messageQueue.filter(m => !m.read).length,
pendingQuestions: pendingQuestions.size
});
});
// Toggle enabled state
app.post('/toggle', async (req, res) => {
const previousState = ENABLED;
ENABLED = !ENABLED;
const statusMessage = ENABLED
? '🟢 InnerVoice notifications ENABLED - You will receive messages'
: '🔴 InnerVoice notifications DISABLED - Messages paused';
// Notify via Telegram if chat ID is set
if (chatId) {
try {
await bot.telegram.sendMessage(chatId, statusMessage, { parse_mode: 'Markdown' });
} catch (error) {
console.error('Failed to send toggle notification:', error);
}
}
res.json({
success: true,
enabled: ENABLED,
previousState,
message: statusMessage
});
});
// Get current enabled state
app.get('/status', (req, res) => {
res.json({
enabled: ENABLED,
message: ENABLED ? 'Notifications are ON' : 'Notifications are OFF (AFK mode)'
});
});
// 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 {
const result = await spawnClaude(projectName, initialPrompt);
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');
console.log('📱 Message your bot to get started');
});
// Start HTTP server
app.listen(PORT, HOST, () => {
console.log(`🌐 HTTP server running on http://${HOST}:${PORT}`);
console.log(`\n📋 Send notifications with:\n`);
console.log(`curl -X POST http://${HOST}:${PORT}/notify \\`);
console.log(` -H "Content-Type: application/json" \\`);
console.log(` -d '{"message": "Hello from Claude!", "priority": "info"}'`);
});
// Graceful shutdown
process.once('SIGINT', () => {
console.log('\n👋 Shutting down...');
bot.stop('SIGINT');
process.exit(0);
});
process.once('SIGTERM', () => {
bot.stop('SIGTERM');
process.exit(0);
});