Compare commits
10 Commits
737bc4b8d7
...
8165126101
| Author | SHA1 | Date | |
|---|---|---|---|
| 8165126101 | |||
| 164cafec73 | |||
| eaa542b11f | |||
| 6afb703a85 | |||
| 8bccf6ce88 | |||
| 2e28476a52 | |||
| 95548a9530 | |||
| 5c777d69c5 | |||
| 3e7f20668c | |||
| 0f8b6a347c |
@@ -1,3 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.claude/
|
||||
*.log
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,196 @@
|
||||
# Telegram Bot для управления сессиями Claude Code
|
||||
|
||||
## Обзор
|
||||
|
||||
Персональный Telegram-бот для управления сессиями Claude Code, запущенными на локальном сервере. Позволяет создавать, останавливать, переключать сессии для разных проектов и вести полноценный диалог с Claude через Telegram.
|
||||
|
||||
## Решения
|
||||
|
||||
- **Подход:** Claude Agent SDK (`@anthropic-ai/claude-agent-sdk`) — программный контроль сессий из одного процесса
|
||||
- **Пользователь:** один (персональный бот)
|
||||
- **Взаимодействие:** полноценный чат — сообщения в Telegram отправляются как промпты в активную сессию
|
||||
- **Управление проектами:** быстрый старт по пути + именованные закладки
|
||||
- **Мультисессия:** одна активная сессия получает сообщения, переключение через `/switch` или inline-кнопки
|
||||
- **Вывод:** только финальный результат (без стриминга промежуточных шагов)
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
┌─────────────┐ Telegram Bot API ┌──────────────────────┐
|
||||
│ Telegram │◄───────────────────────►│ Bot Server │
|
||||
│ (владелец) │ │ (Bun + TypeScript) │
|
||||
└─────────────┘ │ │
|
||||
│ ┌────────────────┐ │
|
||||
│ │ Session Manager │ │
|
||||
│ │ - activeSession│ │
|
||||
│ │ - sessions map │ │
|
||||
│ │ - projects map │ │
|
||||
│ └───────┬────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────▼────────┐ │
|
||||
│ │ Claude Agent SDK│ │
|
||||
│ │ query({ │ │
|
||||
│ │ prompt, │ │
|
||||
│ │ cwd, │ │
|
||||
│ │ resume, │ │
|
||||
│ │ allowedTools │ │
|
||||
│ │ }) │ │
|
||||
│ └────────────────┘ │
|
||||
└──────────────────────┘
|
||||
│
|
||||
┌──────────▼──────────┐
|
||||
│ Файловая система │
|
||||
│ сервера (проекты) │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
**Три компонента:**
|
||||
|
||||
1. **Telegram Bot** (grammy) — принимает сообщения, отправляет ответы, inline-кнопки
|
||||
2. **Session Manager** — CRUD сессий, переключение, закладки проектов, персистентность
|
||||
3. **Claude Agent SDK** — выполнение запросов, управление session ID, resume
|
||||
|
||||
**Стек:** TypeScript, Bun, grammy, @anthropic-ai/claude-agent-sdk
|
||||
|
||||
## Команды бота
|
||||
|
||||
### Управление сессиями
|
||||
|
||||
| Команда | Описание |
|
||||
|---------|----------|
|
||||
| `/start <path или name>` | Создать новую сессию для проекта |
|
||||
| `/stop` | Остановить текущую активную сессию |
|
||||
| `/stop <name>` | Остановить конкретную сессию |
|
||||
| `/stopall` | Остановить все сессии |
|
||||
| `/switch <name>` | Переключиться на другую активную сессию |
|
||||
| `/sessions` | Список активных сессий с inline-кнопками для переключения |
|
||||
| `/status` | Статус текущей сессии (idle/busy, проект, cwd) |
|
||||
|
||||
### Управление проектами (закладки)
|
||||
|
||||
| Команда | Описание |
|
||||
|---------|----------|
|
||||
| `/save <name> <path>` | Сохранить закладку проекта |
|
||||
| `/remove <name>` | Удалить закладку |
|
||||
| `/projects` | Список закладок с inline-кнопками для запуска |
|
||||
|
||||
### Прочее
|
||||
|
||||
| Команда | Описание |
|
||||
|---------|----------|
|
||||
| `/help` | Справка по командам |
|
||||
|
||||
### Обычные сообщения
|
||||
|
||||
Текст без `/` отправляется как промпт в текущую активную сессию.
|
||||
|
||||
### Inline-кнопки
|
||||
|
||||
- `/sessions` — кнопки с именами сессий для быстрого переключения
|
||||
- `/projects` — кнопки "Запустить <name>" для быстрого старта сессии
|
||||
|
||||
### Обработка ошибок
|
||||
|
||||
- Сообщение без активной сессии → "Нет активной сессии. Используй /start"
|
||||
- Сообщение пока сессия busy → "Сессия занята, дождись ответа"
|
||||
- `/start` с уже существующим именем → предложить `/switch`
|
||||
|
||||
## Модель данных
|
||||
|
||||
Состояние хранится в `~/.claude-telegram-bot/state.json`:
|
||||
|
||||
```typescript
|
||||
interface AppState {
|
||||
// Закладки проектов
|
||||
projects: Record<string, string>; // name → absolute path
|
||||
|
||||
// Активные сессии
|
||||
sessions: Record<string, { // name → session info
|
||||
sessionId: string; // ID из Claude Agent SDK
|
||||
cwd: string; // рабочая директория
|
||||
status: "idle" | "busy";
|
||||
}>;
|
||||
|
||||
// Какая сессия сейчас принимает сообщения
|
||||
activeSession: string | null; // name из sessions
|
||||
}
|
||||
```
|
||||
|
||||
**Персистентность:**
|
||||
- Записывается при каждом изменении
|
||||
- При старте бота загружается из файла
|
||||
- `sessions` при рестарте: sessionId сохраняются, при следующем сообщении пробуем resume — если не удаётся, сообщаем пользователю
|
||||
- `projects` переживают перезапуск полностью
|
||||
|
||||
**Именование сессий:**
|
||||
- `/start /d/my-project` → имя из последнего сегмента пути: `my-project`
|
||||
- `/start mybot` (закладка) → имя = имя закладки
|
||||
- Конфликт имён → суффикс: `my-project-2`
|
||||
|
||||
## Обработка сообщений
|
||||
|
||||
```
|
||||
Telegram сообщение
|
||||
│
|
||||
├─ начинается с "/" → парсинг команды → выполнение
|
||||
│
|
||||
└─ обычный текст → отправка в активную сессию:
|
||||
1. Проверить activeSession != null
|
||||
2. Проверить session.status === "idle"
|
||||
3. Поставить status = "busy"
|
||||
4. Отправить typing индикатор
|
||||
5. query({ prompt: text, resume: sessionId })
|
||||
6. Собрать финальный ResultMessage
|
||||
7. Отправить ответ (разбить на чанки если > 4096 символов)
|
||||
8. Поставить status = "idle"
|
||||
При ошибке → сообщение об ошибке, вернуть status = "idle"
|
||||
```
|
||||
|
||||
**Разбивка длинных ответов:**
|
||||
- Лимит Telegram — 4096 символов
|
||||
- Разбиваем по границам `\n\n`, затем `\n`
|
||||
- Markdown-форматирование в каждом чанке
|
||||
|
||||
## Безопасность
|
||||
|
||||
- **Allowlist по chat_id** — бот отвечает только владельцу (TELEGRAM_OWNER_ID), остальных игнорирует
|
||||
- **Permissions для Claude Agent SDK** — `permissionMode: "bypassPermissions"` (персональный бот, интерактивный approval нецелесообразен)
|
||||
- **Без валидации путей** — единственный пользователь, ограничение файловой системы не нужно
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Файл `.env`:
|
||||
|
||||
```
|
||||
TELEGRAM_BOT_TOKEN=<токен от BotFather>
|
||||
TELEGRAM_OWNER_ID=<твой Telegram user ID>
|
||||
ANTHROPIC_API_KEY=<API ключ Anthropic>
|
||||
```
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
claude-telegram-bot/
|
||||
├── src/
|
||||
│ ├── index.ts # Точка входа
|
||||
│ ├── bot.ts # grammy Bot, middleware owner check, хендлеры
|
||||
│ ├── handlers/
|
||||
│ │ ├── commands.ts # Хендлеры команд
|
||||
│ │ └── message.ts # Текстовые сообщения → Claude
|
||||
│ ├── services/
|
||||
│ │ ├── session-manager.ts # CRUD сессий, переключение
|
||||
│ │ └── claude.ts # Обёртка Claude Agent SDK
|
||||
│ ├── state/
|
||||
│ │ └── store.ts # Загрузка/сохранение state.json
|
||||
│ └── utils/
|
||||
│ └── telegram.ts # Разбивка сообщений, форматирование
|
||||
├── .env
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Git
|
||||
|
||||
- Репозиторий: `http://localhost:3000/admin/claude-telegram-bot`
|
||||
- Инициализация git при создании проекта, push в remote по завершении
|
||||
@@ -0,0 +1,130 @@
|
||||
# Master Test Plan — Claude Telegram Bot
|
||||
|
||||
Compiled from per-block test plans produced during implementation.
|
||||
|
||||
---
|
||||
|
||||
## Block 1: Project Scaffolding
|
||||
|
||||
- [ ] 1.1: `git log --oneline` shows initial commit
|
||||
- [ ] 1.2: `bun run src/index.ts` starts without errors (with valid .env)
|
||||
- [ ] 1.3: `.env` is NOT tracked by git
|
||||
- [ ] 1.4: `git show --stat HEAD~8` shows exactly: .gitignore, bun.lock, package.json, src/index.ts, tsconfig.json
|
||||
- [ ] 1.5: `node_modules/` exists with dependencies installed
|
||||
|
||||
## Block 2: State Store (`src/state/store.ts`)
|
||||
|
||||
- [ ] 2.1: `new Store()` returns default state `{ projects: {}, sessions: {}, activeSession: null }` when no state file exists
|
||||
- [ ] 2.2: `store.save()` creates `~/.claude-telegram-bot/state.json` with valid JSON
|
||||
- [ ] 2.3: Save then load round-trips correctly — write state, create new Store, verify data matches
|
||||
- [ ] 2.4: `save()` auto-creates `~/.claude-telegram-bot/` directory if missing
|
||||
- [ ] 2.5: TypeScript rejects invalid `SessionInfo.status` values (only "idle" | "busy")
|
||||
|
||||
## Block 3: Session Manager (`src/services/session-manager.ts`)
|
||||
|
||||
- [ ] 3.1: `activeSession` returns `null` when no session started
|
||||
- [ ] 3.2: `createSession` sets session data, marks as active, and persists
|
||||
- [ ] 3.3: `stopSession` removes session, reassigns active to remaining (or null)
|
||||
- [ ] 3.4: `stopSession` returns `false` for unknown session name
|
||||
- [ ] 3.5: `stopAll` clears all sessions, sets activeSession to null, returns count
|
||||
- [ ] 3.6: `switchSession` updates activeSession and returns true for valid name
|
||||
- [ ] 3.7: `switchSession` returns false for unknown name
|
||||
- [ ] 3.8: `setStatus` updates only status field, no-ops for unknown session
|
||||
- [ ] 3.9: `resolveProjectPath` returns bookmarked path when name matches a project
|
||||
- [ ] 3.10: `resolveProjectPath` extracts basename from path for non-bookmarked input
|
||||
- [ ] 3.11: `generateName` appends `-2`, `-3` on name conflicts
|
||||
- [ ] 3.12: `saveProject` / `removeProject` CRUD works correctly
|
||||
- [ ] 3.13: Every mutating method calls `store.save()` exactly once
|
||||
|
||||
## Block 4: Claude Service (`src/services/claude.ts`)
|
||||
|
||||
- [ ] 4.1: `createSession` returns non-empty `sessionId` with valid prompt and cwd
|
||||
- [ ] 4.2: `createSession` returns non-empty `result` string
|
||||
- [ ] 4.3: `sendMessage` with valid sessionId resumes context and returns result
|
||||
- [ ] 4.4: Session context is preserved — sendMessage result reflects prior conversation
|
||||
- [ ] 4.5: Invalid sessionId in `sendMessage` throws or returns empty string gracefully
|
||||
- [ ] 4.6: `cwd` is respected — Claude tools resolve paths relative to it
|
||||
- [ ] 4.7: Multi-message stream handled — only final result captured
|
||||
|
||||
## Block 5: Telegram Utilities (`src/utils/telegram.ts`)
|
||||
|
||||
- [ ] 5.1: Short message (<=4096 chars) returns single-element array
|
||||
- [ ] 5.2: Exactly 4096 chars returns single-element array (boundary)
|
||||
- [ ] 5.3: Long message with `\n\n` splits at paragraph boundary
|
||||
- [ ] 5.4: Long message with only `\n` splits at line boundary
|
||||
- [ ] 5.5: Long message with no whitespace hard-splits at 4096
|
||||
- [ ] 5.6: All chunks are <= 4096 characters
|
||||
- [ ] 5.7: No data loss — concatenated chunks reconstruct original content
|
||||
- [ ] 5.8: `escapeMarkdown` returns input unchanged
|
||||
|
||||
## Block 6: Command Handlers (`src/handlers/commands.ts`)
|
||||
|
||||
- [ ] 6.1: `/start` without argument shows usage
|
||||
- [ ] 6.2: `/start <path>` with existing session name suggests `/switch`
|
||||
- [ ] 6.3: `/start <path>` creates Claude session, registers in manager, replies with result
|
||||
- [ ] 6.4: `/stop` with no active session replies "Nothing to stop"
|
||||
- [ ] 6.5: `/stop <name>` removes session, shows next active if any
|
||||
- [ ] 6.6: `/stop <name>` for unknown session replies "not found"
|
||||
- [ ] 6.7: `/stopall` stops all, reports count
|
||||
- [ ] 6.8: `/switch` without argument shows usage
|
||||
- [ ] 6.9: `/switch <name>` switches and confirms
|
||||
- [ ] 6.10: `/switch <name>` for unknown session shows error + hint
|
||||
- [ ] 6.11: `/sessions` with no sessions replies "No active sessions"
|
||||
- [ ] 6.12: `/sessions` lists sessions with `►` marker and inline keyboard
|
||||
- [ ] 6.13: `/status` with no session replies "No active session"
|
||||
- [ ] 6.14: `/status` shows name, path, status
|
||||
- [ ] 6.15: `/save` with <2 args shows usage
|
||||
- [ ] 6.16: `/save <name> <path>` saves and confirms
|
||||
- [ ] 6.17: `/remove <name>` removes or says "not found"
|
||||
- [ ] 6.18: `/projects` shows list with inline "Start" buttons
|
||||
- [ ] 6.19: `/help` shows full command reference
|
||||
|
||||
## Block 7: Message Handler (`src/handlers/message.ts`)
|
||||
|
||||
- [ ] 7.1: Non-text message ignored silently
|
||||
- [ ] 7.2: Text with no active session shows "No active session" message
|
||||
- [ ] 7.3: Text when session busy shows "Session is busy" message
|
||||
- [ ] 7.4: Status set to "busy" before Claude call, "idle" after (including on error)
|
||||
- [ ] 7.5: Typing indicator sent before Claude call
|
||||
- [ ] 7.6: Response split into chunks and sent sequentially
|
||||
- [ ] 7.7: Empty Claude response sends "(empty response)"
|
||||
- [ ] 7.8: Claude error caught, error message sent, status reset to idle
|
||||
|
||||
## Block 8: Bot Setup (`src/bot.ts`)
|
||||
|
||||
- [ ] 8.1: `createBot` returns Bot instance without errors
|
||||
- [ ] 8.2: Non-owner messages silently dropped by middleware
|
||||
- [ ] 8.3: Owner messages reach handlers
|
||||
- [ ] 8.4: `switch:` callback switches session and confirms
|
||||
- [ ] 8.5: `start:` callback for unknown project answers "not found"
|
||||
- [ ] 8.6: `start:` callback for existing session switches instead of creating
|
||||
- [ ] 8.7: `start:` callback for new project creates session via Claude
|
||||
- [ ] 8.8: Claude error in `start:` callback sends error message
|
||||
- [ ] 8.9: All imports are static, `createBot` is synchronous
|
||||
|
||||
## Block 9: Entry Point (`src/index.ts`)
|
||||
|
||||
- [ ] 9.1: Missing TELEGRAM_BOT_TOKEN throws with clear message
|
||||
- [ ] 9.2: Missing TELEGRAM_OWNER_ID throws with clear message
|
||||
- [ ] 9.3: Missing ANTHROPIC_API_KEY throws with clear message
|
||||
- [ ] 9.4: All vars present — bot starts, logs "Bot started successfully"
|
||||
- [ ] 9.5: TELEGRAM_OWNER_ID converted to number
|
||||
- [ ] 9.6: SIGINT triggers graceful shutdown with "Shutting down..." log
|
||||
- [ ] 9.7: `.env` file loaded by dotenv/config
|
||||
|
||||
---
|
||||
|
||||
## Integration / Smoke Test
|
||||
|
||||
- [ ] E2E-1: `/help` — shows command list
|
||||
- [ ] E2E-2: `/start /d/claude_telegram_bot` — creates session, Claude responds
|
||||
- [ ] E2E-3: `What files are in this directory?` — Claude lists project files
|
||||
- [ ] E2E-4: `/status` — shows session name, path, idle status
|
||||
- [ ] E2E-5: `/sessions` — shows list with inline button
|
||||
- [ ] E2E-6: Click inline button — switches session
|
||||
- [ ] E2E-7: `/save mybot /d/claude_telegram_bot` — saves bookmark
|
||||
- [ ] E2E-8: `/projects` — shows bookmark with "Start" button
|
||||
- [ ] E2E-9: `/stop` — stops session
|
||||
- [ ] E2E-10: `/sessions` — shows "No active sessions"
|
||||
- [ ] E2E-11: Send text without session — shows "No active session" error
|
||||
- [ ] E2E-12: Message from non-owner — silently ignored
|
||||
+55
-11
@@ -1,10 +1,27 @@
|
||||
import { Bot, type Context } from "grammy";
|
||||
import { Bot, type Context, Keyboard } from "grammy";
|
||||
import { Store } from "./state/store.js";
|
||||
import { SessionManager } from "./services/session-manager.js";
|
||||
import { ClaudeService } from "./services/claude.js";
|
||||
import { registerCommands } from "./handlers/commands.js";
|
||||
import { createMessageHandler } from "./handlers/message.js";
|
||||
|
||||
// Persistent reply keyboard
|
||||
const mainKeyboard = new Keyboard()
|
||||
.text("Sessions").text("Projects").text("Status").row()
|
||||
.text("Stop").text("Stop All").text("Help")
|
||||
.resized()
|
||||
.persistent();
|
||||
|
||||
// Button text → command name mapping
|
||||
const BUTTON_COMMANDS: Record<string, string> = {
|
||||
"Sessions": "sessions",
|
||||
"Projects": "projects",
|
||||
"Status": "status",
|
||||
"Stop": "stop",
|
||||
"Stop All": "stopall",
|
||||
"Help": "help",
|
||||
};
|
||||
|
||||
export function createBot(token: string, ownerId: number): Bot {
|
||||
const bot = new Bot(token);
|
||||
|
||||
@@ -20,12 +37,25 @@ export function createBot(token: string, ownerId: number): Bot {
|
||||
|
||||
// Register command handlers
|
||||
const commands: Record<string, (ctx: Context) => Promise<void>> = {};
|
||||
registerCommands(manager, claude, commands);
|
||||
registerCommands(manager, claude, commands, mainKeyboard);
|
||||
|
||||
for (const [name, handler] of Object.entries(commands)) {
|
||||
bot.command(name, handler);
|
||||
}
|
||||
|
||||
// Handle reply keyboard button presses
|
||||
bot.on("message:text", async (ctx, next) => {
|
||||
const text = ctx.message.text;
|
||||
const commandName = BUTTON_COMMANDS[text];
|
||||
if (commandName && commands[commandName]) {
|
||||
// Simulate empty match for commands that don't need args
|
||||
(ctx as any).match = "";
|
||||
await commands[commandName](ctx);
|
||||
return;
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
// Callback queries for inline buttons
|
||||
bot.on("callback_query:data", async (ctx) => {
|
||||
const data = ctx.callbackQuery.data;
|
||||
@@ -54,21 +84,35 @@ export function createBot(token: string, ownerId: number): Bot {
|
||||
await ctx.answerCallbackQuery({ text: `Starting "${resolved.name}"...` });
|
||||
await ctx.reply(`Starting session "${resolved.name}" in ${resolved.path}...`);
|
||||
|
||||
try {
|
||||
const { sessionId, result } = await claude.createSession(
|
||||
"You are now connected via Telegram. Briefly confirm you're ready and state the project directory.",
|
||||
resolved.path,
|
||||
);
|
||||
claude.createSession(
|
||||
"You are now connected via Telegram. Briefly confirm you're ready and state the project directory.",
|
||||
resolved.path,
|
||||
).then(({ sessionId, result }) => {
|
||||
manager.createSession(resolved.name, resolved.path, sessionId);
|
||||
await ctx.reply(`Session "${resolved.name}" created.\n\n${result}`);
|
||||
} catch (err) {
|
||||
await ctx.reply(`Failed to create session: ${err}`);
|
||||
}
|
||||
ctx.reply(`Session "${resolved.name}" created.\n\n${result}`);
|
||||
}).catch((err) => {
|
||||
ctx.reply(`Failed to create session: ${err}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Plain text messages
|
||||
bot.on("message:text", createMessageHandler(manager, claude));
|
||||
|
||||
// Send main keyboard on /start (Telegram's built-in start) with no args
|
||||
bot.api.setMyCommands([
|
||||
{ command: "start", description: "Start a session: /start <path|name>" },
|
||||
{ command: "save", description: "Save bookmark: /save <name> <path>" },
|
||||
{ command: "switch", description: "Switch session: /switch <name>" },
|
||||
{ command: "remove", description: "Remove bookmark: /remove <name>" },
|
||||
]);
|
||||
|
||||
// Show keyboard on first interaction
|
||||
bot.use(async (ctx, next) => {
|
||||
await next();
|
||||
});
|
||||
|
||||
return bot;
|
||||
}
|
||||
|
||||
export { mainKeyboard };
|
||||
|
||||
+34
-30
@@ -1,4 +1,5 @@
|
||||
import { type Context, InlineKeyboard } from "grammy";
|
||||
import { type Context, InlineKeyboard, type Keyboard } from "grammy";
|
||||
import { existsSync } from "fs";
|
||||
import { SessionManager } from "../services/session-manager.js";
|
||||
import { ClaudeService } from "../services/claude.js";
|
||||
|
||||
@@ -6,11 +7,12 @@ export function registerCommands(
|
||||
manager: SessionManager,
|
||||
claude: ClaudeService,
|
||||
onCommand: Record<string, (ctx: Context) => Promise<void>>,
|
||||
keyboard: Keyboard,
|
||||
): void {
|
||||
onCommand["start"] = async (ctx) => {
|
||||
const arg = ctx.match as string;
|
||||
if (!arg) {
|
||||
await ctx.reply("Usage: /start <path or project name>");
|
||||
await ctx.reply("Usage: /start <path or project name>", { reply_markup: keyboard });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -20,6 +22,12 @@ export function registerCommands(
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate that directory exists (skip for bookmarks — already validated on save)
|
||||
if (!existsSync(resolved.path)) {
|
||||
await ctx.reply(`Directory not found: ${resolved.path}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (manager.getAllSessions()[resolved.name]) {
|
||||
await ctx.reply(
|
||||
`Session "${resolved.name}" already exists. Use /switch ${resolved.name}`,
|
||||
@@ -29,16 +37,16 @@ export function registerCommands(
|
||||
|
||||
await ctx.reply(`Starting session "${resolved.name}" in ${resolved.path}...`);
|
||||
|
||||
try {
|
||||
const { sessionId, result } = await claude.createSession(
|
||||
"You are now connected via Telegram. Briefly confirm you're ready and state the project directory.",
|
||||
resolved.path,
|
||||
);
|
||||
// Run Claude session creation without blocking the bot
|
||||
claude.createSession(
|
||||
"Ты подключён через Telegram. Коротко подтверди готовность и укажи рабочую директорию проекта.",
|
||||
resolved.path,
|
||||
).then(({ sessionId, result }) => {
|
||||
manager.createSession(resolved.name, resolved.path, sessionId);
|
||||
await ctx.reply(`Session "${resolved.name}" created.\n\n${result}`);
|
||||
} catch (err) {
|
||||
await ctx.reply(`Failed to create session: ${err}`);
|
||||
}
|
||||
ctx.reply(`Session "${resolved.name}" created.\n\n${result}`);
|
||||
}).catch((err) => {
|
||||
ctx.reply(`Failed to create session: ${err}`);
|
||||
});
|
||||
};
|
||||
|
||||
onCommand["stop"] = async (ctx) => {
|
||||
@@ -80,7 +88,7 @@ export function registerCommands(
|
||||
const sessions = manager.getAllSessions();
|
||||
const names = Object.keys(sessions);
|
||||
if (names.length === 0) {
|
||||
await ctx.reply("No active sessions.");
|
||||
await ctx.reply("No active sessions.", { reply_markup: keyboard });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -91,12 +99,12 @@ export function registerCommands(
|
||||
return `${marker} ${name} — ${s.cwd} (${s.status})`;
|
||||
});
|
||||
|
||||
const keyboard = new InlineKeyboard();
|
||||
const inlineKb = new InlineKeyboard();
|
||||
for (const name of names) {
|
||||
keyboard.text(name, `switch:${name}`);
|
||||
inlineKb.text(name, `switch:${name}`);
|
||||
}
|
||||
|
||||
await ctx.reply(lines.join("\n"), { reply_markup: keyboard });
|
||||
await ctx.reply(lines.join("\n"), { reply_markup: inlineKb });
|
||||
};
|
||||
|
||||
onCommand["status"] = async (ctx) => {
|
||||
@@ -139,37 +147,33 @@ export function registerCommands(
|
||||
const projects = manager.getProjects();
|
||||
const names = Object.keys(projects);
|
||||
if (names.length === 0) {
|
||||
await ctx.reply("No saved projects.");
|
||||
await ctx.reply("No saved projects. Use /save <name> <path>", { reply_markup: keyboard });
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = names.map((n) => `${n} — ${projects[n]}`);
|
||||
const keyboard = new InlineKeyboard();
|
||||
const inlineKb = new InlineKeyboard();
|
||||
for (const name of names) {
|
||||
keyboard.text(`Start ${name}`, `start:${name}`);
|
||||
inlineKb.text(`Start ${name}`, `start:${name}`);
|
||||
}
|
||||
|
||||
await ctx.reply(lines.join("\n"), { reply_markup: keyboard });
|
||||
await ctx.reply(lines.join("\n"), { reply_markup: inlineKb });
|
||||
};
|
||||
|
||||
onCommand["help"] = async (ctx) => {
|
||||
await ctx.reply(
|
||||
[
|
||||
"Session commands:",
|
||||
" /start <path|name> — start new session",
|
||||
" /stop [name] — stop session",
|
||||
" /stopall — stop all sessions",
|
||||
" /switch <name> — switch active session",
|
||||
" /sessions — list sessions",
|
||||
" /status — current session info",
|
||||
"Use buttons below or commands:",
|
||||
"",
|
||||
"Project bookmarks:",
|
||||
" /save <name> <path> — save bookmark",
|
||||
" /remove <name> — remove bookmark",
|
||||
" /projects — list bookmarks",
|
||||
"/start <path|name> — start new session",
|
||||
"/stop [name] — stop session",
|
||||
"/switch <name> — switch active session",
|
||||
"/save <name> <path> — save bookmark",
|
||||
"/remove <name> — remove bookmark",
|
||||
"",
|
||||
"Any other text is sent to the active session.",
|
||||
].join("\n"),
|
||||
{ reply_markup: keyboard },
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,18 +28,17 @@ export function createMessageHandler(
|
||||
manager.setStatus(activeName, "busy");
|
||||
await ctx.replyWithChatAction("typing");
|
||||
|
||||
try {
|
||||
const result = await claude.sendMessage(text, session.sessionId);
|
||||
// Run Claude call without blocking the bot's event loop
|
||||
claude.sendMessage(text, session.sessionId, session.cwd).then(async (result) => {
|
||||
const response = result || "(empty response)";
|
||||
const chunks = splitMessage(response);
|
||||
|
||||
for (const chunk of chunks) {
|
||||
await ctx.reply(chunk);
|
||||
}
|
||||
} catch (err) {
|
||||
await ctx.reply(`Error: ${err}`);
|
||||
} finally {
|
||||
}).catch((err) => {
|
||||
ctx.reply(`Error: ${err}`);
|
||||
}).finally(() => {
|
||||
manager.setStatus(activeName, "idle");
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
+76
-36
@@ -1,52 +1,92 @@
|
||||
import { query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { spawn } from "child_process";
|
||||
|
||||
export interface ClaudeResult {
|
||||
sessionId: string;
|
||||
result: string;
|
||||
}
|
||||
|
||||
interface CliJsonResult {
|
||||
result?: string;
|
||||
session_id?: string;
|
||||
is_error?: boolean;
|
||||
}
|
||||
|
||||
function runClaude(args: string[], cwd: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log(`[claude] Running: npx claude-code ${args.join(" ")}`);
|
||||
console.log(`[claude] CWD: ${cwd}`);
|
||||
|
||||
const proc = spawn("npx", ["--yes", "@anthropic-ai/claude-code", ...args], {
|
||||
cwd,
|
||||
shell: true,
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
proc.stdout.on("data", (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr.on("data", (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("close", (code: number | null) => {
|
||||
console.log(`[claude] Exit code: ${code}`);
|
||||
console.log(`[claude] Stdout length: ${stdout.length}`);
|
||||
if (stderr) console.log(`[claude] Stderr: ${stderr.slice(0, 500)}`);
|
||||
|
||||
if (code !== 0 && !stdout) {
|
||||
reject(new Error(stderr || `claude exited with code ${code}`));
|
||||
} else {
|
||||
resolve(stdout);
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
console.error(`[claude] Spawn error:`, err);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const APPEND_PROMPT = "Always respond in Russian. You are a helpful coding assistant connected via Telegram. Engage fully with user requests - analyze, suggest, implement. Never ask the user to repeat or clarify when the request is clear enough to act on.";
|
||||
|
||||
export class ClaudeService {
|
||||
async createSession(prompt: string, cwd: string): Promise<ClaudeResult> {
|
||||
let sessionId = "";
|
||||
let result = "";
|
||||
const raw = await runClaude(
|
||||
["-p", prompt, "--output-format", "json", "--dangerously-skip-permissions", "--append-system-prompt", APPEND_PROMPT],
|
||||
cwd,
|
||||
);
|
||||
|
||||
for await (const message of query({
|
||||
prompt,
|
||||
options: {
|
||||
cwd,
|
||||
permissionMode: "bypassPermissions",
|
||||
allowedTools: [
|
||||
"Read", "Write", "Edit", "Bash", "Glob", "Grep",
|
||||
"WebSearch", "WebFetch", "Agent",
|
||||
],
|
||||
},
|
||||
})) {
|
||||
if (message.type === "system" && message.subtype === "init") {
|
||||
sessionId = message.session_id;
|
||||
}
|
||||
if ("result" in message && typeof message.result === "string") {
|
||||
result = message.result;
|
||||
}
|
||||
}
|
||||
const lastLine = raw.trim().split("\n").pop()!;
|
||||
console.log(`[claude] createSession raw last line (first 300 chars): ${lastLine.slice(0, 300)}`);
|
||||
const parsed = JSON.parse(lastLine) as CliJsonResult;
|
||||
console.log(`[claude] createSession result: "${parsed.result?.slice(0, 200)}"`);
|
||||
console.log(`[claude] createSession session_id: ${parsed.session_id}`);
|
||||
|
||||
return { sessionId, result };
|
||||
return {
|
||||
sessionId: parsed.session_id ?? "",
|
||||
result: parsed.result ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
async sendMessage(prompt: string, sessionId: string): Promise<string> {
|
||||
let result = "";
|
||||
async sendMessage(prompt: string, sessionId: string, cwd: string): Promise<string> {
|
||||
console.log(`[claude] sendMessage prompt: "${prompt.slice(0, 100)}"`);
|
||||
console.log(`[claude] sendMessage sessionId: ${sessionId}`);
|
||||
|
||||
for await (const message of query({
|
||||
prompt,
|
||||
options: {
|
||||
resume: sessionId,
|
||||
permissionMode: "bypassPermissions",
|
||||
},
|
||||
})) {
|
||||
if ("result" in message && typeof message.result === "string") {
|
||||
result = message.result;
|
||||
}
|
||||
}
|
||||
const raw = await runClaude(
|
||||
["-p", prompt, "--output-format", "json", "--resume", sessionId, "--dangerously-skip-permissions", "--append-system-prompt", APPEND_PROMPT],
|
||||
cwd,
|
||||
);
|
||||
|
||||
return result;
|
||||
const lastLine = raw.trim().split("\n").pop()!;
|
||||
console.log(`[claude] sendMessage raw last line (first 300 chars): ${lastLine.slice(0, 300)}`);
|
||||
const parsed = JSON.parse(lastLine) as CliJsonResult;
|
||||
console.log(`[claude] sendMessage result: "${parsed.result?.slice(0, 200)}"`);
|
||||
|
||||
return parsed.result ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Store, type SessionInfo } from "../state/store.js";
|
||||
import { basename } from "path";
|
||||
import { normalizePath } from "../utils/telegram.js";
|
||||
|
||||
export class SessionManager {
|
||||
constructor(private store: Store) {}
|
||||
@@ -19,12 +20,15 @@ export class SessionManager {
|
||||
}
|
||||
|
||||
resolveProjectPath(nameOrPath: string): { name: string; path: string } | null {
|
||||
// Check bookmarks first
|
||||
const bookmarked = this.store.data.projects[nameOrPath];
|
||||
if (bookmarked) {
|
||||
return { name: nameOrPath, path: bookmarked };
|
||||
}
|
||||
const name = this.generateName(basename(nameOrPath));
|
||||
return { name, path: nameOrPath };
|
||||
// Treat as path — normalize for Windows
|
||||
const normalizedPath = normalizePath(nameOrPath);
|
||||
const name = this.generateName(basename(normalizedPath));
|
||||
return { name, path: normalizedPath };
|
||||
}
|
||||
|
||||
private generateName(base: string): string {
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { resolve } from "path";
|
||||
|
||||
const MAX_LENGTH = 4096;
|
||||
|
||||
/**
|
||||
* Convert Unix-style /d/path to Windows D:/path if on Windows.
|
||||
* Passes through normal paths unchanged.
|
||||
*/
|
||||
export function normalizePath(inputPath: string): string {
|
||||
// Convert /d/... → D:/...
|
||||
const match = inputPath.match(/^\/([a-zA-Z])\/(.*)/);
|
||||
if (match) {
|
||||
return resolve(`${match[1].toUpperCase()}:/${match[2]}`);
|
||||
}
|
||||
return resolve(inputPath);
|
||||
}
|
||||
|
||||
export function splitMessage(text: string): string[] {
|
||||
if (text.length <= MAX_LENGTH) return [text];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user