1037 lines
25 KiB
Markdown
1037 lines
25 KiB
Markdown
# 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<string, string>;
|
|
sessions: Record<string, SessionInfo>;
|
|
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<string, SessionInfo> {
|
|
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<string, string> {
|
|
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<ClaudeResult> {
|
|
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<string> {
|
|
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<string, (ctx: Context) => Promise<void>>,
|
|
): void {
|
|
onCommand["start"] = async (ctx) => {
|
|
const arg = ctx.match as string;
|
|
if (!arg) {
|
|
await ctx.reply("Usage: /start <path or project name>");
|
|
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 <session name>");
|
|
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 <name> <path>");
|
|
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 <name>");
|
|
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 <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",
|
|
"",
|
|
"Project bookmarks:",
|
|
" /save <name> <path> — save bookmark",
|
|
" /remove <name> — 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 <path or name> 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<string, (ctx: Context) => Promise<void>> = {};
|
|
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=<real token from BotFather>
|
|
TELEGRAM_OWNER_ID=<your Telegram user ID>
|
|
ANTHROPIC_API_KEY=<your 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.
|