feat: add persistent reply keyboard with main action buttons

This commit is contained in:
neken
2026-04-10 16:02:24 +05:00
parent 0f8b6a347c
commit 3e7f20668c
2 changed files with 65 additions and 23 deletions
+47 -2
View File
@@ -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;
@@ -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 <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 };
+14 -17
View File
@@ -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<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;
}
@@ -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 <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:",
"Use buttons below or 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"),
{ reply_markup: keyboard },
);
};
}