diff --git a/src/bot.ts b/src/bot.ts index 0f7291b..7745f3b 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -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 = { + "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 Promise> = {}; - 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; @@ -69,5 +99,20 @@ export function createBot(token: string, ownerId: number): Bot { // 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 " }, + { command: "save", description: "Save bookmark: /save " }, + { command: "switch", description: "Switch session: /switch " }, + { command: "remove", description: "Remove bookmark: /remove " }, + ]); + + // Show keyboard on first interaction + bot.use(async (ctx, next) => { + await next(); + }); + return bot; } + +export { mainKeyboard }; diff --git a/src/handlers/commands.ts b/src/handlers/commands.ts index 37ceee0..0057ea7 100644 --- a/src/handlers/commands.ts +++ b/src/handlers/commands.ts @@ -1,4 +1,4 @@ -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"; @@ -7,11 +7,12 @@ export function registerCommands( manager: SessionManager, claude: ClaudeService, onCommand: Record Promise>, + keyboard: Keyboard, ): void { onCommand["start"] = async (ctx) => { const arg = ctx.match as string; if (!arg) { - await ctx.reply("Usage: /start "); + await ctx.reply("Usage: /start ", { reply_markup: keyboard }); return; } @@ -87,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; } @@ -98,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) => { @@ -146,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 ", { 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 — start new session", - " /stop [name] — stop session", - " /stopall — stop all sessions", - " /switch — switch active session", - " /sessions — list sessions", - " /status — current session info", + "Use buttons below or commands:", "", - "Project bookmarks:", - " /save — save bookmark", - " /remove — remove bookmark", - " /projects — list bookmarks", + "/start — start new session", + "/stop [name] — stop session", + "/switch — switch active session", + "/save — save bookmark", + "/remove — remove bookmark", "", "Any other text is sent to the active session.", ].join("\n"), + { reply_markup: keyboard }, ); }; }