diff --git a/.gitignore b/.gitignore index deed335..f597e35 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ node_modules/ dist/ .env +.claude/ +*.log diff --git a/docs/superpowers/plans/2026-04-10-telegram-bot-plan.md b/docs/superpowers/plans/2026-04-10-telegram-bot-plan.md new file mode 100644 index 0000000..e1331d2 --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-telegram-bot-plan.md @@ -0,0 +1,1036 @@ +# Claude Telegram Bot Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a Telegram bot that manages Claude Code sessions on a local server — start/stop/switch sessions per project and chat with Claude through Telegram. + +**Architecture:** Single Bun process with grammy for Telegram, Claude Agent SDK for session management, JSON file for state persistence. Owner-only access via chat_id allowlist. + +**Tech Stack:** TypeScript, Bun, grammy, @anthropic-ai/claude-agent-sdk + +--- + +## File Structure + +| File | Responsibility | +|------|----------------| +| `src/index.ts` | Entry point: load env, init store, create bot, start | +| `src/bot.ts` | grammy Bot instance, owner middleware, register handlers | +| `src/handlers/commands.ts` | All `/command` handlers with inline keyboard support | +| `src/handlers/message.ts` | Plain text → Claude session routing | +| `src/services/session-manager.ts` | Session CRUD, switching, project bookmarks, name generation | +| `src/services/claude.ts` | Claude Agent SDK wrapper: createSession, sendMessage | +| `src/state/store.ts` | Load/save state.json, AppState type definitions | +| `src/utils/telegram.ts` | Split long messages, format markdown for Telegram | +| `package.json` | Dependencies and scripts | +| `tsconfig.json` | TypeScript config for Bun | +| `.env` | Tokens and config (not committed) | +| `.gitignore` | Ignore node_modules, .env, dist | + +--- + +### Task 1: Project Scaffolding + +**Files:** +- Create: `package.json` +- Create: `tsconfig.json` +- Create: `.gitignore` +- Create: `.env` +- Create: `src/index.ts` (placeholder) + +- [ ] **Step 1: Initialize git repository** + +```bash +cd /d/claude_telegram_bot +git init +``` + +- [ ] **Step 2: Create package.json** + +```json +{ + "name": "claude-telegram-bot", + "version": "1.0.0", + "type": "module", + "scripts": { + "start": "bun run src/index.ts", + "dev": "bun --watch run src/index.ts" + }, + "dependencies": { + "grammy": "^1.35.0", + "@anthropic-ai/claude-agent-sdk": "^0.1.0", + "dotenv": "^16.4.0" + } +} +``` + +- [ ] **Step 3: Create tsconfig.json** + +```json +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "types": ["bun-types"] + }, + "include": ["src"] +} +``` + +- [ ] **Step 4: Create .gitignore** + +``` +node_modules/ +dist/ +.env +``` + +- [ ] **Step 5: Create .env with placeholder values** + +``` +TELEGRAM_BOT_TOKEN=your-telegram-bot-token +TELEGRAM_OWNER_ID=your-telegram-user-id +ANTHROPIC_API_KEY=your-anthropic-api-key +``` + +- [ ] **Step 6: Create placeholder entry point** + +Create `src/index.ts`: + +```typescript +console.log("Claude Telegram Bot starting..."); +``` + +- [ ] **Step 7: Install dependencies** + +```bash +cd /d/claude_telegram_bot +bun install +``` + +- [ ] **Step 8: Verify it runs** + +```bash +bun run src/index.ts +``` + +Expected: prints "Claude Telegram Bot starting..." + +- [ ] **Step 9: Commit** + +```bash +git add package.json tsconfig.json .gitignore src/index.ts bun.lock +git commit -m "feat: scaffold project with bun, grammy, claude-agent-sdk" +``` + +--- + +### Task 2: State Store + +**Files:** +- Create: `src/state/store.ts` + +- [ ] **Step 1: Implement Store class** + +Create `src/state/store.ts`: + +```typescript +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { dirname } from "path"; +import { homedir } from "os"; +import { join } from "path"; + +export interface SessionInfo { + sessionId: string; + cwd: string; + status: "idle" | "busy"; +} + +export interface AppState { + projects: Record; + sessions: Record; + activeSession: string | null; +} + +const STATE_DIR = join(homedir(), ".claude-telegram-bot"); +const STATE_FILE = join(STATE_DIR, "state.json"); + +const DEFAULT_STATE: AppState = { + projects: {}, + sessions: {}, + activeSession: null, +}; + +export class Store { + private state: AppState; + + constructor() { + this.state = this.load(); + } + + private load(): AppState { + if (!existsSync(STATE_FILE)) { + return { ...DEFAULT_STATE, projects: {}, sessions: {} }; + } + const raw = readFileSync(STATE_FILE, "utf-8"); + return JSON.parse(raw) as AppState; + } + + save(): void { + if (!existsSync(STATE_DIR)) { + mkdirSync(STATE_DIR, { recursive: true }); + } + writeFileSync(STATE_FILE, JSON.stringify(this.state, null, 2)); + } + + get data(): AppState { + return this.state; + } +} +``` + +- [ ] **Step 2: Verify it compiles** + +```bash +bun build src/state/store.ts --outdir /tmp/check --no-bundle 2>&1 | head -5 +``` + +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add src/state/store.ts +git commit -m "feat: add state store with JSON persistence" +``` + +--- + +### Task 3: Session Manager + +**Files:** +- Create: `src/services/session-manager.ts` + +- [ ] **Step 1: Implement SessionManager** + +Create `src/services/session-manager.ts`: + +```typescript +import { Store, type SessionInfo } from "../state/store.js"; +import { basename } from "path"; + +export class SessionManager { + constructor(private store: Store) {} + + get activeSession(): string | null { + return this.store.data.activeSession; + } + + get activeSessionInfo(): SessionInfo | null { + const name = this.store.data.activeSession; + if (!name) return null; + return this.store.data.sessions[name] ?? null; + } + + getAllSessions(): Record { + return this.store.data.sessions; + } + + 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 }; + } + // Treat as path + const name = this.generateName(basename(nameOrPath)); + return { name, path: nameOrPath }; + } + + private generateName(base: string): string { + if (!this.store.data.sessions[base]) return base; + let i = 2; + while (this.store.data.sessions[`${base}-${i}`]) i++; + return `${base}-${i}`; + } + + createSession(name: string, cwd: string, sessionId: string): void { + this.store.data.sessions[name] = { sessionId, cwd, status: "idle" }; + this.store.data.activeSession = name; + this.store.save(); + } + + stopSession(name: string): boolean { + if (!this.store.data.sessions[name]) return false; + delete this.store.data.sessions[name]; + if (this.store.data.activeSession === name) { + const remaining = Object.keys(this.store.data.sessions); + this.store.data.activeSession = remaining.length > 0 ? remaining[0] : null; + } + this.store.save(); + return true; + } + + stopAll(): number { + const count = Object.keys(this.store.data.sessions).length; + this.store.data.sessions = {}; + this.store.data.activeSession = null; + this.store.save(); + return count; + } + + switchSession(name: string): boolean { + if (!this.store.data.sessions[name]) return false; + this.store.data.activeSession = name; + this.store.save(); + return true; + } + + setStatus(name: string, status: "idle" | "busy"): void { + const session = this.store.data.sessions[name]; + if (session) { + session.status = status; + this.store.save(); + } + } + + // Project bookmarks + saveProject(name: string, path: string): void { + this.store.data.projects[name] = path; + this.store.save(); + } + + removeProject(name: string): boolean { + if (!this.store.data.projects[name]) return false; + delete this.store.data.projects[name]; + this.store.save(); + return true; + } + + getProjects(): Record { + return this.store.data.projects; + } +} +``` + +- [ ] **Step 2: Verify it compiles** + +```bash +bun build src/services/session-manager.ts --outdir /tmp/check --no-bundle 2>&1 | head -5 +``` + +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add src/services/session-manager.ts +git commit -m "feat: add session manager with CRUD, switching, bookmarks" +``` + +--- + +### Task 4: Claude Agent SDK Wrapper + +**Files:** +- Create: `src/services/claude.ts` + +- [ ] **Step 1: Implement ClaudeService** + +Create `src/services/claude.ts`: + +```typescript +import { query } from "@anthropic-ai/claude-agent-sdk"; + +export interface ClaudeResult { + sessionId: string; + result: string; +} + +export class ClaudeService { + async createSession(prompt: string, cwd: string): Promise { + let sessionId = ""; + let result = ""; + + 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; + } + } + + return { sessionId, result }; + } + + async sendMessage(prompt: string, sessionId: string): Promise { + let result = ""; + + for await (const message of query({ + prompt, + options: { + resume: sessionId, + permissionMode: "bypassPermissions", + }, + })) { + if ("result" in message && typeof message.result === "string") { + result = message.result; + } + } + + return result; + } +} +``` + +- [ ] **Step 2: Verify it compiles** + +```bash +bun build src/services/claude.ts --outdir /tmp/check --no-bundle 2>&1 | head -5 +``` + +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add src/services/claude.ts +git commit -m "feat: add Claude Agent SDK wrapper with session create/resume" +``` + +--- + +### Task 5: Telegram Utilities + +**Files:** +- Create: `src/utils/telegram.ts` + +- [ ] **Step 1: Implement message splitting and formatting** + +Create `src/utils/telegram.ts`: + +```typescript +const MAX_LENGTH = 4096; + +export function splitMessage(text: string): string[] { + if (text.length <= MAX_LENGTH) return [text]; + + const chunks: string[] = []; + let remaining = text; + + while (remaining.length > 0) { + if (remaining.length <= MAX_LENGTH) { + chunks.push(remaining); + break; + } + + // Try to split at paragraph boundary + let splitAt = remaining.lastIndexOf("\n\n", MAX_LENGTH); + if (splitAt === -1 || splitAt < MAX_LENGTH / 2) { + // Try line boundary + splitAt = remaining.lastIndexOf("\n", MAX_LENGTH); + } + if (splitAt === -1 || splitAt < MAX_LENGTH / 2) { + // Hard split + splitAt = MAX_LENGTH; + } + + chunks.push(remaining.slice(0, splitAt)); + remaining = remaining.slice(splitAt).trimStart(); + } + + return chunks; +} + +export function escapeMarkdown(text: string): string { + // grammy supports Markdown parse mode — minimal escaping needed + return text; +} +``` + +- [ ] **Step 2: Verify it compiles** + +```bash +bun build src/utils/telegram.ts --outdir /tmp/check --no-bundle 2>&1 | head -5 +``` + +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add src/utils/telegram.ts +git commit -m "feat: add telegram message splitting utility" +``` + +--- + +### Task 6: Command Handlers + +**Files:** +- Create: `src/handlers/commands.ts` + +- [ ] **Step 1: Implement all command handlers** + +Create `src/handlers/commands.ts`: + +```typescript +import { type Context, InlineKeyboard } from "grammy"; +import { SessionManager } from "../services/session-manager.js"; +import { ClaudeService } from "../services/claude.js"; + +export function registerCommands( + manager: SessionManager, + claude: ClaudeService, + onCommand: Record Promise>, +): void { + onCommand["start"] = async (ctx) => { + const arg = ctx.match as string; + if (!arg) { + await ctx.reply("Usage: /start "); + return; + } + + const resolved = manager.resolveProjectPath(arg); + if (!resolved) { + await ctx.reply("Could not resolve project path."); + return; + } + + // Check if session with this name already exists + if (manager.getAllSessions()[resolved.name]) { + await ctx.reply( + `Session "${resolved.name}" already exists. Use /switch ${resolved.name}`, + ); + return; + } + + 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, + ); + 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}`); + } + }; + + onCommand["stop"] = async (ctx) => { + const arg = ctx.match as string; + const name = arg || manager.activeSession; + if (!name) { + await ctx.reply("No active session. Nothing to stop."); + return; + } + if (manager.stopSession(name)) { + const active = manager.activeSession; + let msg = `Session "${name}" stopped.`; + if (active) msg += ` Switched to "${active}".`; + await ctx.reply(msg); + } else { + await ctx.reply(`Session "${name}" not found.`); + } + }; + + onCommand["stopall"] = async (ctx) => { + const count = manager.stopAll(); + await ctx.reply(`Stopped ${count} session(s).`); + }; + + onCommand["switch"] = async (ctx) => { + const arg = ctx.match as string; + if (!arg) { + await ctx.reply("Usage: /switch "); + return; + } + if (manager.switchSession(arg)) { + await ctx.reply(`Switched to "${arg}".`); + } else { + await ctx.reply(`Session "${arg}" not found. Use /sessions to see active sessions.`); + } + }; + + onCommand["sessions"] = async (ctx) => { + const sessions = manager.getAllSessions(); + const names = Object.keys(sessions); + if (names.length === 0) { + await ctx.reply("No active sessions."); + return; + } + + const active = manager.activeSession; + const lines = names.map((name) => { + const s = sessions[name]; + const marker = name === active ? "►" : " "; + return `${marker} ${name} — ${s.cwd} (${s.status})`; + }); + + const keyboard = new InlineKeyboard(); + for (const name of names) { + keyboard.text(name, `switch:${name}`); + } + + await ctx.reply(lines.join("\n"), { reply_markup: keyboard }); + }; + + onCommand["status"] = async (ctx) => { + const info = manager.activeSessionInfo; + if (!info) { + await ctx.reply("No active session."); + return; + } + const name = manager.activeSession!; + await ctx.reply( + `Session: ${name}\nPath: ${info.cwd}\nStatus: ${info.status}`, + ); + }; + + onCommand["save"] = async (ctx) => { + const arg = ctx.match as string; + const parts = arg.split(/\s+/, 2); + if (parts.length < 2) { + await ctx.reply("Usage: /save "); + return; + } + manager.saveProject(parts[0], parts[1]); + await ctx.reply(`Project "${parts[0]}" saved → ${parts[1]}`); + }; + + onCommand["remove"] = async (ctx) => { + const arg = ctx.match as string; + if (!arg) { + await ctx.reply("Usage: /remove "); + return; + } + if (manager.removeProject(arg)) { + await ctx.reply(`Project "${arg}" removed.`); + } else { + await ctx.reply(`Project "${arg}" not found.`); + } + }; + + onCommand["projects"] = async (ctx) => { + const projects = manager.getProjects(); + const names = Object.keys(projects); + if (names.length === 0) { + await ctx.reply("No saved projects."); + return; + } + + const lines = names.map((n) => `${n} — ${projects[n]}`); + const keyboard = new InlineKeyboard(); + for (const name of names) { + keyboard.text(`Start ${name}`, `start:${name}`); + } + + await ctx.reply(lines.join("\n"), { reply_markup: keyboard }); + }; + + onCommand["help"] = async (ctx) => { + await ctx.reply( + [ + "Session commands:", + " /start — start new session", + " /stop [name] — stop session", + " /stopall — stop all sessions", + " /switch — switch active session", + " /sessions — list sessions", + " /status — current session info", + "", + "Project bookmarks:", + " /save — save bookmark", + " /remove — remove bookmark", + " /projects — list bookmarks", + "", + "Any other text is sent to the active session.", + ].join("\n"), + ); + }; +} +``` + +- [ ] **Step 2: Verify it compiles** + +```bash +bun build src/handlers/commands.ts --outdir /tmp/check --no-bundle 2>&1 | head -5 +``` + +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add src/handlers/commands.ts +git commit -m "feat: add all command handlers with inline keyboards" +``` + +--- + +### Task 7: Message Handler + +**Files:** +- Create: `src/handlers/message.ts` + +- [ ] **Step 1: Implement text message handler** + +Create `src/handlers/message.ts`: + +```typescript +import { type Context } from "grammy"; +import { SessionManager } from "../services/session-manager.js"; +import { ClaudeService } from "../services/claude.js"; +import { splitMessage } from "../utils/telegram.js"; + +export function createMessageHandler( + manager: SessionManager, + claude: ClaudeService, +) { + return async (ctx: Context) => { + const text = ctx.message?.text; + if (!text) return; + + const activeName = manager.activeSession; + if (!activeName) { + await ctx.reply("No active session. Use /start to create one."); + return; + } + + const session = manager.activeSessionInfo; + if (!session) return; + + if (session.status === "busy") { + await ctx.reply("Session is busy, wait for the current request to finish."); + return; + } + + manager.setStatus(activeName, "busy"); + await ctx.replyWithChatAction("typing"); + + try { + const result = await claude.sendMessage(text, session.sessionId); + 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 { + manager.setStatus(activeName, "idle"); + } + }; +} +``` + +- [ ] **Step 2: Verify it compiles** + +```bash +bun build src/handlers/message.ts --outdir /tmp/check --no-bundle 2>&1 | head -5 +``` + +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add src/handlers/message.ts +git commit -m "feat: add message handler routing text to active Claude session" +``` + +--- + +### Task 8: Bot Setup + +**Files:** +- Create: `src/bot.ts` + +- [ ] **Step 1: Implement bot with middleware and handler registration** + +Create `src/bot.ts`: + +```typescript +import { Bot, type Context } from "grammy"; +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"; + +export function createBot(token: string, ownerId: number): Bot { + const bot = new Bot(token); + + // Owner-only middleware + bot.use(async (ctx, next) => { + if (ctx.from?.id !== ownerId) return; + await next(); + }); + + const store = (await import("./state/store.js")).Store; + const storeInstance = new store(); + const manager = new SessionManager(storeInstance); + const claude = new ClaudeService(); + + // Register command handlers + const commands: Record Promise> = {}; + registerCommands(manager, claude, commands); + + for (const [name, handler] of Object.entries(commands)) { + bot.command(name, handler); + } + + // Callback queries for inline buttons + bot.on("callback_query:data", async (ctx) => { + const data = ctx.callbackQuery.data; + + if (data.startsWith("switch:")) { + const name = data.slice("switch:".length); + if (manager.switchSession(name)) { + await ctx.answerCallbackQuery({ text: `Switched to "${name}"` }); + await ctx.reply(`Switched to "${name}".`); + } else { + await ctx.answerCallbackQuery({ text: `Session "${name}" not found` }); + } + } else if (data.startsWith("start:")) { + const name = data.slice("start:".length); + const resolved = manager.resolveProjectPath(name); + if (!resolved) { + await ctx.answerCallbackQuery({ text: "Project not found" }); + return; + } + if (manager.getAllSessions()[resolved.name]) { + manager.switchSession(resolved.name); + await ctx.answerCallbackQuery({ text: `Switched to "${resolved.name}"` }); + await ctx.reply(`Session "${resolved.name}" already exists. Switched to it.`); + return; + } + 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, + ); + 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}`); + } + } + }); + + // Plain text messages + bot.on("message:text", createMessageHandler(manager, claude)); + + return bot; +} +``` + +- [ ] **Step 2: Verify it compiles** + +```bash +bun build src/bot.ts --outdir /tmp/check --no-bundle 2>&1 | head -5 +``` + +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add src/bot.ts +git commit -m "feat: add bot setup with owner middleware, commands, callbacks" +``` + +--- + +### Task 9: Entry Point + +**Files:** +- Modify: `src/index.ts` + +- [ ] **Step 1: Implement entry point** + +Replace `src/index.ts` with: + +```typescript +import "dotenv/config"; +import { createBot } from "./bot.js"; + +const token = process.env.TELEGRAM_BOT_TOKEN; +const ownerId = process.env.TELEGRAM_OWNER_ID; +const apiKey = process.env.ANTHROPIC_API_KEY; + +if (!token) throw new Error("TELEGRAM_BOT_TOKEN is required"); +if (!ownerId) throw new Error("TELEGRAM_OWNER_ID is required"); +if (!apiKey) throw new Error("ANTHROPIC_API_KEY is required"); + +const bot = createBot(token, Number(ownerId)); + +bot.start({ + onStart: () => console.log("Bot started successfully"), +}); + +// Graceful shutdown +const shutdown = () => { + console.log("Shutting down..."); + bot.stop(); + process.exit(0); +}; + +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); +``` + +- [ ] **Step 2: Verify it compiles** + +```bash +bun build src/index.ts --outdir /tmp/check --no-bundle 2>&1 | head -5 +``` + +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add src/index.ts +git commit -m "feat: add entry point with env validation and graceful shutdown" +``` + +--- + +### Task 10: Fix Bot Initialization (async import) + +**Files:** +- Modify: `src/bot.ts` + +The `createBot` function uses a top-level `await import()` inside a non-async function. This needs to be restructured so that `Store` is imported statically and bot creation is clean. + +- [ ] **Step 1: Fix bot.ts to use static imports** + +Replace the dynamic import in `src/bot.ts`. Change: + +```typescript +const store = (await import("./state/store.js")).Store; +const storeInstance = new store(); +``` + +to use a static import at the top of the file and make `createBot` synchronous: + +```typescript +import { Store } from "./state/store.js"; +``` + +And inside `createBot`: + +```typescript +const storeInstance = new Store(); +``` + +- [ ] **Step 2: Verify full project compiles** + +```bash +bun build src/index.ts --outdir /tmp/check 2>&1 | head -5 +``` + +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add src/bot.ts +git commit -m "fix: use static import for Store in bot.ts" +``` + +--- + +### Task 11: Integration Test — Manual Smoke Test + +- [ ] **Step 1: Fill in real .env values** + +Edit `.env` with actual tokens: + +``` +TELEGRAM_BOT_TOKEN= +TELEGRAM_OWNER_ID= +ANTHROPIC_API_KEY= +``` + +- [ ] **Step 2: Start the bot** + +```bash +cd /d/claude_telegram_bot +bun run start +``` + +Expected: "Bot started successfully" + +- [ ] **Step 3: Test in Telegram** + +Send these messages to the bot in order: + +1. `/help` — should show command list +2. `/start /d/claude_telegram_bot` — should create session +3. `What files are in this directory?` — should get Claude response +4. `/status` — should show session info +5. `/sessions` — should show list with inline button +6. `/save mybot /d/claude_telegram_bot` — should save bookmark +7. `/projects` — should show bookmark with "Start" button +8. `/stop` — should stop session +9. Verify `/sessions` shows empty list + +- [ ] **Step 4: Commit any fixes from smoke test** + +```bash +git add -A +git commit -m "fix: address issues found in smoke test" +``` + +(Skip this step if no fixes needed.) + +--- + +### Task 12: Push to Git Remote + +- [ ] **Step 1: Create repository on Gitea** + +```bash +curl -X POST "http://localhost:3000/api/v1/user/repos" \ + -H "Content-Type: application/json" \ + -u "admin:gowtep-Wovdek-hehpu5" \ + -d '{"name":"claude-telegram-bot","description":"Telegram bot for managing Claude Code sessions","private":false}' +``` + +- [ ] **Step 2: Add remote and push** + +```bash +cd /d/claude_telegram_bot +git remote add origin http://admin:gowtep-Wovdek-hehpu5@localhost:3000/admin/claude-telegram-bot.git +git push -u origin main +``` + +Expected: successful push + +- [ ] **Step 3: Verify in browser** + +Open `http://localhost:3000/admin/claude-telegram-bot` — repository should show all files and commits. diff --git a/docs/superpowers/specs/2026-04-10-telegram-bot-design.md b/docs/superpowers/specs/2026-04-10-telegram-bot-design.md new file mode 100644 index 0000000..2d2d537 --- /dev/null +++ b/docs/superpowers/specs/2026-04-10-telegram-bot-design.md @@ -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 ` | Создать новую сессию для проекта | +| `/stop` | Остановить текущую активную сессию | +| `/stop ` | Остановить конкретную сессию | +| `/stopall` | Остановить все сессии | +| `/switch ` | Переключиться на другую активную сессию | +| `/sessions` | Список активных сессий с inline-кнопками для переключения | +| `/status` | Статус текущей сессии (idle/busy, проект, cwd) | + +### Управление проектами (закладки) + +| Команда | Описание | +|---------|----------| +| `/save ` | Сохранить закладку проекта | +| `/remove ` | Удалить закладку | +| `/projects` | Список закладок с inline-кнопками для запуска | + +### Прочее + +| Команда | Описание | +|---------|----------| +| `/help` | Справка по командам | + +### Обычные сообщения + +Текст без `/` отправляется как промпт в текущую активную сессию. + +### Inline-кнопки + +- `/sessions` — кнопки с именами сессий для быстрого переключения +- `/projects` — кнопки "Запустить " для быстрого старта сессии + +### Обработка ошибок + +- Сообщение без активной сессии → "Нет активной сессии. Используй /start" +- Сообщение пока сессия busy → "Сессия занята, дождись ответа" +- `/start` с уже существующим именем → предложить `/switch` + +## Модель данных + +Состояние хранится в `~/.claude-telegram-bot/state.json`: + +```typescript +interface AppState { + // Закладки проектов + projects: Record; // name → absolute path + + // Активные сессии + sessions: Record; + + // Какая сессия сейчас принимает сообщения + 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= +``` + +## Структура проекта + +``` +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 по завершении diff --git a/docs/test-plan.md b/docs/test-plan.md new file mode 100644 index 0000000..99f254b --- /dev/null +++ b/docs/test-plan.md @@ -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 ` with existing session name suggests `/switch` +- [ ] 6.3: `/start ` creates Claude session, registers in manager, replies with result +- [ ] 6.4: `/stop` with no active session replies "Nothing to stop" +- [ ] 6.5: `/stop ` removes session, shows next active if any +- [ ] 6.6: `/stop ` for unknown session replies "not found" +- [ ] 6.7: `/stopall` stops all, reports count +- [ ] 6.8: `/switch` without argument shows usage +- [ ] 6.9: `/switch ` switches and confirms +- [ ] 6.10: `/switch ` 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 ` saves and confirms +- [ ] 6.17: `/remove ` 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