@grinev/opencode-telegram-bot 0.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/.env.example +34 -0
  2. package/LICENSE +21 -0
  3. package/README.md +72 -0
  4. package/dist/agent/manager.js +92 -0
  5. package/dist/agent/types.js +26 -0
  6. package/dist/app/start-bot-app.js +26 -0
  7. package/dist/bot/commands/agent.js +16 -0
  8. package/dist/bot/commands/definitions.js +20 -0
  9. package/dist/bot/commands/help.js +7 -0
  10. package/dist/bot/commands/model.js +16 -0
  11. package/dist/bot/commands/models.js +37 -0
  12. package/dist/bot/commands/new.js +58 -0
  13. package/dist/bot/commands/opencode-start.js +87 -0
  14. package/dist/bot/commands/opencode-stop.js +46 -0
  15. package/dist/bot/commands/projects.js +104 -0
  16. package/dist/bot/commands/server-restart.js +23 -0
  17. package/dist/bot/commands/server-start.js +23 -0
  18. package/dist/bot/commands/sessions.js +240 -0
  19. package/dist/bot/commands/start.js +40 -0
  20. package/dist/bot/commands/status.js +63 -0
  21. package/dist/bot/commands/stop.js +92 -0
  22. package/dist/bot/handlers/agent.js +96 -0
  23. package/dist/bot/handlers/context.js +112 -0
  24. package/dist/bot/handlers/model.js +115 -0
  25. package/dist/bot/handlers/permission.js +158 -0
  26. package/dist/bot/handlers/question.js +294 -0
  27. package/dist/bot/handlers/variant.js +126 -0
  28. package/dist/bot/index.js +573 -0
  29. package/dist/bot/middleware/auth.js +30 -0
  30. package/dist/bot/utils/keyboard.js +66 -0
  31. package/dist/cli/args.js +97 -0
  32. package/dist/cli.js +90 -0
  33. package/dist/config.js +46 -0
  34. package/dist/index.js +26 -0
  35. package/dist/keyboard/manager.js +171 -0
  36. package/dist/keyboard/types.js +1 -0
  37. package/dist/model/manager.js +123 -0
  38. package/dist/model/types.js +26 -0
  39. package/dist/opencode/client.js +13 -0
  40. package/dist/opencode/events.js +79 -0
  41. package/dist/opencode/server.js +104 -0
  42. package/dist/permission/manager.js +78 -0
  43. package/dist/permission/types.js +1 -0
  44. package/dist/pinned/manager.js +610 -0
  45. package/dist/pinned/types.js +1 -0
  46. package/dist/pinned-message/service.js +54 -0
  47. package/dist/process/manager.js +273 -0
  48. package/dist/process/types.js +1 -0
  49. package/dist/project/manager.js +28 -0
  50. package/dist/question/manager.js +143 -0
  51. package/dist/question/types.js +1 -0
  52. package/dist/runtime/bootstrap.js +278 -0
  53. package/dist/runtime/mode.js +74 -0
  54. package/dist/runtime/paths.js +37 -0
  55. package/dist/session/manager.js +10 -0
  56. package/dist/session/state.js +24 -0
  57. package/dist/settings/manager.js +99 -0
  58. package/dist/status/formatter.js +44 -0
  59. package/dist/summary/aggregator.js +427 -0
  60. package/dist/summary/formatter.js +226 -0
  61. package/dist/utils/formatting.js +237 -0
  62. package/dist/utils/logger.js +59 -0
  63. package/dist/utils/safe-background-task.js +33 -0
  64. package/dist/variant/manager.js +103 -0
  65. package/dist/variant/types.js +1 -0
  66. package/package.json +63 -0
@@ -0,0 +1,96 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { selectAgent, getAvailableAgents, fetchCurrentAgent } from "../../agent/manager.js";
3
+ import { getAgentDisplayName, getAgentEmoji } from "../../agent/types.js";
4
+ import { getStoredModel } from "../../model/manager.js";
5
+ import { formatVariantForButton } from "../../variant/manager.js";
6
+ import { logger } from "../../utils/logger.js";
7
+ import { createMainKeyboard } from "../utils/keyboard.js";
8
+ import { pinnedMessageManager } from "../../pinned/manager.js";
9
+ import { keyboardManager } from "../../keyboard/manager.js";
10
+ /**
11
+ * Handle agent selection callback
12
+ * @param ctx grammY context
13
+ * @returns true if handled, false otherwise
14
+ */
15
+ export async function handleAgentSelect(ctx) {
16
+ const callbackQuery = ctx.callbackQuery;
17
+ if (!callbackQuery?.data || !callbackQuery.data.startsWith("agent:")) {
18
+ return false;
19
+ }
20
+ logger.debug(`[AgentHandler] Received callback: ${callbackQuery.data}`);
21
+ try {
22
+ if (ctx.chat) {
23
+ keyboardManager.initialize(ctx.api, ctx.chat.id);
24
+ }
25
+ if (pinnedMessageManager.getContextLimit() === 0) {
26
+ await pinnedMessageManager.refreshContextLimit();
27
+ }
28
+ const agentName = callbackQuery.data.replace("agent:", "");
29
+ // Select agent and persist
30
+ selectAgent(agentName);
31
+ // Update keyboard manager state
32
+ keyboardManager.updateAgent(agentName);
33
+ // Update Reply Keyboard with new agent, current model, and context
34
+ const currentModel = getStoredModel();
35
+ const contextInfo = pinnedMessageManager.getContextInfo() ??
36
+ (pinnedMessageManager.getContextLimit() > 0
37
+ ? { tokensUsed: 0, tokensLimit: pinnedMessageManager.getContextLimit() }
38
+ : null);
39
+ keyboardManager.updateModel(currentModel);
40
+ if (contextInfo) {
41
+ keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit);
42
+ }
43
+ const state = keyboardManager.getState();
44
+ const variantName = state?.variantName ?? formatVariantForButton(currentModel.variant || "default");
45
+ const keyboard = createMainKeyboard(agentName, currentModel, contextInfo ?? undefined, variantName);
46
+ const displayName = getAgentDisplayName(agentName);
47
+ // Send confirmation message with updated keyboard
48
+ await ctx.answerCallbackQuery({ text: `Режим изменен: ${displayName}` });
49
+ await ctx.reply(`✅ Режим изменен на: ${displayName}`, {
50
+ reply_markup: keyboard,
51
+ });
52
+ // Delete the inline menu message
53
+ await ctx.deleteMessage().catch(() => { });
54
+ return true;
55
+ }
56
+ catch (err) {
57
+ logger.error("[AgentHandler] Error handling agent select:", err);
58
+ await ctx.answerCallbackQuery({ text: "Ошибка при смене режима" }).catch(() => { });
59
+ return false;
60
+ }
61
+ }
62
+ /**
63
+ * Build inline keyboard with available agents
64
+ * @param currentAgent Current agent name for highlighting
65
+ * @returns InlineKeyboard with agent selection buttons
66
+ */
67
+ export async function buildAgentSelectionMenu(currentAgent) {
68
+ const keyboard = new InlineKeyboard();
69
+ const agents = await getAvailableAgents();
70
+ if (agents.length === 0) {
71
+ logger.warn("[AgentHandler] No available agents found");
72
+ return keyboard;
73
+ }
74
+ // Add button for each agent
75
+ agents.forEach((agent) => {
76
+ const emoji = getAgentEmoji(agent.name);
77
+ const isActive = agent.name === currentAgent;
78
+ const label = isActive
79
+ ? `✅ ${emoji} ${agent.name.toUpperCase()}`
80
+ : `${emoji} ${agent.name.charAt(0).toUpperCase() + agent.name.slice(1)}`;
81
+ keyboard.text(label, `agent:${agent.name}`).row();
82
+ });
83
+ return keyboard;
84
+ }
85
+ /**
86
+ * Show agent selection menu
87
+ * @param ctx grammY context
88
+ */
89
+ export async function showAgentSelectionMenu(ctx) {
90
+ const currentAgent = await fetchCurrentAgent();
91
+ const keyboard = await buildAgentSelectionMenu(currentAgent);
92
+ const text = currentAgent
93
+ ? `Текущий режим: ${getAgentDisplayName(currentAgent)}\n\nВыберите режим:`
94
+ : "Выберите режим работы:";
95
+ await ctx.reply(text, { reply_markup: keyboard });
96
+ }
@@ -0,0 +1,112 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { getCurrentSession } from "../../session/manager.js";
3
+ import { opencodeClient } from "../../opencode/client.js";
4
+ import { getStoredModel } from "../../model/manager.js";
5
+ import { logger } from "../../utils/logger.js";
6
+ /**
7
+ * Build inline keyboard with compact confirmation menu
8
+ * @returns InlineKeyboard with "Yes" and "Cancel" buttons
9
+ */
10
+ export function buildCompactConfirmationMenu() {
11
+ const keyboard = new InlineKeyboard();
12
+ keyboard.text("✅ Да, сжать контекст", "compact:confirm").row();
13
+ keyboard.text("❌ Отмена", "compact:cancel").row();
14
+ return keyboard;
15
+ }
16
+ /**
17
+ * Handle context button press (text message from Reply Keyboard)
18
+ * Shows inline menu with compact confirmation
19
+ * @param ctx grammY context
20
+ */
21
+ export async function handleContextButtonPress(ctx) {
22
+ logger.debug("[ContextHandler] Context button pressed");
23
+ const session = getCurrentSession();
24
+ if (!session) {
25
+ await ctx.reply("⚠️ Нет активной сессии. Создайте сессию командой /new");
26
+ return;
27
+ }
28
+ const keyboard = buildCompactConfirmationMenu();
29
+ await ctx.reply(`📊 Сжатие контекста для сессии "${session.title}"\n\n` +
30
+ `Это уменьшит использование контекста, удалив старые сообщения из истории. ` +
31
+ `Текущая задача не будет прервана.\n\n` +
32
+ `Продолжить?`, { reply_markup: keyboard });
33
+ }
34
+ /**
35
+ * Handle compact confirmation callback
36
+ * Calls OpenCode API to compact the session
37
+ * @param ctx grammY context
38
+ */
39
+ export async function handleCompactConfirm(ctx) {
40
+ const callbackQuery = ctx.callbackQuery;
41
+ if (!callbackQuery?.data || callbackQuery.data !== "compact:confirm") {
42
+ return false;
43
+ }
44
+ logger.debug("[ContextHandler] Compact confirmed");
45
+ try {
46
+ const session = getCurrentSession();
47
+ if (!session) {
48
+ await ctx.answerCallbackQuery({ text: "Сессия не найдена" });
49
+ await ctx.reply("⚠️ Нет активной сессии");
50
+ await ctx.deleteMessage().catch(() => { });
51
+ return true;
52
+ }
53
+ // Answer callback query and delete menu immediately
54
+ await ctx.answerCallbackQuery({ text: "Сжатие контекста..." });
55
+ await ctx.deleteMessage().catch(() => { });
56
+ // Send progress message
57
+ const progressMessage = await ctx.reply("⏳ Сжимаю контекст...");
58
+ // Show typing indicator
59
+ await ctx.api.sendChatAction(ctx.chat.id, "typing");
60
+ const storedModel = getStoredModel();
61
+ logger.debug(`[ContextHandler] Calling summarize with sessionID=${session.id}, directory=${session.directory}, model=${storedModel.providerID}/${storedModel.modelID}`);
62
+ // Call summarize API (AI compaction)
63
+ const { error } = await opencodeClient.session.summarize({
64
+ sessionID: session.id,
65
+ directory: session.directory,
66
+ providerID: storedModel.providerID,
67
+ modelID: storedModel.modelID,
68
+ });
69
+ if (error) {
70
+ logger.error("[ContextHandler] Compact failed:", error);
71
+ // Update progress message to show error
72
+ await ctx.api
73
+ .editMessageText(ctx.chat.id, progressMessage.message_id, "❌ Ошибка при сжатии контекста")
74
+ .catch(() => { });
75
+ return true;
76
+ }
77
+ logger.info(`[ContextHandler] Session compacted: ${session.id}`);
78
+ // Update progress message to show success
79
+ await ctx.api
80
+ .editMessageText(ctx.chat.id, progressMessage.message_id, "✅ Контекст успешно сжат")
81
+ .catch(() => { });
82
+ return true;
83
+ }
84
+ catch (err) {
85
+ logger.error("[ContextHandler] Compact exception:", err);
86
+ await ctx.answerCallbackQuery({ text: "Ошибка" }).catch(() => { });
87
+ await ctx.reply("❌ Ошибка при сжатии контекста");
88
+ await ctx.deleteMessage().catch(() => { });
89
+ return false;
90
+ }
91
+ }
92
+ /**
93
+ * Handle compact cancel callback
94
+ * @param ctx grammY context
95
+ */
96
+ export async function handleCompactCancel(ctx) {
97
+ const callbackQuery = ctx.callbackQuery;
98
+ if (!callbackQuery?.data || callbackQuery.data !== "compact:cancel") {
99
+ return false;
100
+ }
101
+ logger.debug("[ContextHandler] Compact cancelled");
102
+ try {
103
+ await ctx.answerCallbackQuery({ text: "Отменено" });
104
+ // Delete the inline menu message
105
+ await ctx.deleteMessage().catch(() => { });
106
+ return true;
107
+ }
108
+ catch (err) {
109
+ logger.error("[ContextHandler] Cancel error:", err);
110
+ return false;
111
+ }
112
+ }
@@ -0,0 +1,115 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { selectModel, getFavoriteModels, fetchCurrentModel } from "../../model/manager.js";
3
+ import { formatModelForDisplay } from "../../model/types.js";
4
+ import { formatVariantForButton } from "../../variant/manager.js";
5
+ import { logger } from "../../utils/logger.js";
6
+ import { createMainKeyboard } from "../utils/keyboard.js";
7
+ import { getStoredAgent } from "../../agent/manager.js";
8
+ import { pinnedMessageManager } from "../../pinned/manager.js";
9
+ import { keyboardManager } from "../../keyboard/manager.js";
10
+ /**
11
+ * Handle model selection callback
12
+ * @param ctx grammY context
13
+ * @returns true if handled, false otherwise
14
+ */
15
+ export async function handleModelSelect(ctx) {
16
+ const callbackQuery = ctx.callbackQuery;
17
+ if (!callbackQuery?.data || !callbackQuery.data.startsWith("model:")) {
18
+ return false;
19
+ }
20
+ logger.debug(`[ModelHandler] Received callback: ${callbackQuery.data}`);
21
+ try {
22
+ if (ctx.chat) {
23
+ keyboardManager.initialize(ctx.api, ctx.chat.id);
24
+ }
25
+ // Parse callback data: "model:providerID:modelID"
26
+ const parts = callbackQuery.data.split(":");
27
+ if (parts.length < 3) {
28
+ logger.error(`[ModelHandler] Invalid callback data format: ${callbackQuery.data}`);
29
+ return false;
30
+ }
31
+ const providerID = parts[1];
32
+ const modelID = parts.slice(2).join(":"); // Handle model IDs that may contain ":"
33
+ const modelInfo = {
34
+ providerID,
35
+ modelID,
36
+ variant: "default", // Reset to default when switching models
37
+ };
38
+ // Select model and persist
39
+ selectModel(modelInfo);
40
+ // Update keyboard manager state (may not be initialized if no session selected)
41
+ keyboardManager.updateModel(modelInfo);
42
+ // Refresh context limit for new model
43
+ await pinnedMessageManager.refreshContextLimit();
44
+ // Update Reply Keyboard with new model and context
45
+ const currentAgent = getStoredAgent();
46
+ const contextInfo = pinnedMessageManager.getContextInfo() ??
47
+ (pinnedMessageManager.getContextLimit() > 0
48
+ ? { tokensUsed: 0, tokensLimit: pinnedMessageManager.getContextLimit() }
49
+ : null);
50
+ if (contextInfo) {
51
+ keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit);
52
+ }
53
+ const variantName = formatVariantForButton(modelInfo.variant || "default");
54
+ const keyboard = createMainKeyboard(currentAgent, modelInfo, contextInfo ?? undefined, variantName);
55
+ const displayName = formatModelForDisplay(modelInfo.providerID, modelInfo.modelID);
56
+ // Send confirmation message with updated keyboard
57
+ await ctx.answerCallbackQuery({ text: `Модель изменена: ${displayName}` });
58
+ await ctx.reply(`✅ Модель изменена на: ${displayName}`, {
59
+ reply_markup: keyboard,
60
+ });
61
+ // Delete the inline menu message
62
+ await ctx.deleteMessage().catch(() => { });
63
+ return true;
64
+ }
65
+ catch (err) {
66
+ logger.error("[ModelHandler] Error handling model select:", err);
67
+ await ctx.answerCallbackQuery({ text: "Ошибка при смене модели" }).catch(() => { });
68
+ return false;
69
+ }
70
+ }
71
+ /**
72
+ * Build inline keyboard with favorite models
73
+ * @param currentModel Current model for highlighting
74
+ * @returns InlineKeyboard with model selection buttons
75
+ */
76
+ export async function buildModelSelectionMenu(currentModel) {
77
+ const keyboard = new InlineKeyboard();
78
+ const favorites = await getFavoriteModels();
79
+ if (favorites.length === 0) {
80
+ logger.warn("[ModelHandler] No favorite models found");
81
+ return keyboard;
82
+ }
83
+ // Add button for each favorite model
84
+ favorites.forEach((model) => {
85
+ const isActive = currentModel &&
86
+ model.providerID === currentModel.providerID &&
87
+ model.modelID === currentModel.modelID;
88
+ // Inline buttons use full model ID without truncation
89
+ const label = `${model.providerID}/${model.modelID}`;
90
+ const labelWithCheck = isActive ? `✅ ${label}` : label;
91
+ keyboard.text(labelWithCheck, `model:${model.providerID}:${model.modelID}`).row();
92
+ });
93
+ return keyboard;
94
+ }
95
+ /**
96
+ * Show model selection menu
97
+ * @param ctx grammY context
98
+ */
99
+ export async function showModelSelectionMenu(ctx) {
100
+ try {
101
+ const currentModel = fetchCurrentModel();
102
+ const keyboard = await buildModelSelectionMenu(currentModel);
103
+ if (keyboard.inline_keyboard.length === 0) {
104
+ await ctx.reply("⚠️ Нет доступных моделей");
105
+ return;
106
+ }
107
+ const displayName = formatModelForDisplay(currentModel.providerID, currentModel.modelID);
108
+ const text = `Текущая модель: ${displayName}\n\nВыберите модель:`;
109
+ await ctx.reply(text, { reply_markup: keyboard });
110
+ }
111
+ catch (err) {
112
+ logger.error("[ModelHandler] Error showing model menu:", err);
113
+ await ctx.reply("🔴 Не удалось получить список моделей");
114
+ }
115
+ }
@@ -0,0 +1,158 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { permissionManager } from "../../permission/manager.js";
3
+ import { opencodeClient } from "../../opencode/client.js";
4
+ import { getCurrentProject } from "../../settings/manager.js";
5
+ import { summaryAggregator } from "../../summary/aggregator.js";
6
+ import { logger } from "../../utils/logger.js";
7
+ import { safeBackgroundTask } from "../../utils/safe-background-task.js";
8
+ // Permission type display names
9
+ const PERMISSION_NAMES = {
10
+ bash: "Bash",
11
+ edit: "Edit",
12
+ write: "Write",
13
+ read: "Read",
14
+ webfetch: "Web Fetch",
15
+ websearch: "Web Search",
16
+ glob: "File Search",
17
+ grep: "Content Search",
18
+ list: "List Directory",
19
+ task: "Task",
20
+ lsp: "LSP",
21
+ };
22
+ // Permission type emojis
23
+ const PERMISSION_EMOJIS = {
24
+ bash: "⚡",
25
+ edit: "✏️",
26
+ write: "📝",
27
+ read: "📖",
28
+ webfetch: "🌐",
29
+ websearch: "🔍",
30
+ glob: "📁",
31
+ grep: "🔎",
32
+ list: "📂",
33
+ task: "⚙️",
34
+ lsp: "🔧",
35
+ };
36
+ /**
37
+ * Handle permission callback from inline buttons
38
+ */
39
+ export async function handlePermissionCallback(ctx) {
40
+ const data = ctx.callbackQuery?.data;
41
+ if (!data)
42
+ return false;
43
+ if (!data.startsWith("permission:")) {
44
+ return false;
45
+ }
46
+ logger.debug(`[PermissionHandler] Received callback: ${data}`);
47
+ if (!permissionManager.isActive()) {
48
+ await ctx.answerCallbackQuery({ text: "Запрос разрешения неактивен", show_alert: true });
49
+ return true;
50
+ }
51
+ const parts = data.split(":");
52
+ const action = parts[1];
53
+ try {
54
+ await handlePermissionReply(ctx, action);
55
+ }
56
+ catch (err) {
57
+ logger.error("[PermissionHandler] Error handling callback:", err);
58
+ await ctx.answerCallbackQuery({ text: "Ошибка при обработке", show_alert: true });
59
+ }
60
+ return true;
61
+ }
62
+ /**
63
+ * Handle permission reply (once/always/reject)
64
+ */
65
+ async function handlePermissionReply(ctx, reply) {
66
+ const requestID = permissionManager.getRequestID();
67
+ const currentProject = getCurrentProject();
68
+ const chatId = ctx.chat?.id;
69
+ if (!requestID || !currentProject || !chatId) {
70
+ await ctx.answerCallbackQuery({ text: "Ошибка: нет активного запроса", show_alert: true });
71
+ return;
72
+ }
73
+ // Reply labels for user feedback
74
+ const replyLabels = {
75
+ once: "Разрешено однократно",
76
+ always: "Разрешено всегда",
77
+ reject: "Отклонено",
78
+ };
79
+ await ctx.answerCallbackQuery({ text: replyLabels[reply] });
80
+ // Delete the permission message
81
+ await ctx.deleteMessage().catch(() => { });
82
+ // Stop typing indicator since we're responding
83
+ summaryAggregator.stopTypingIndicator();
84
+ logger.info(`[PermissionHandler] Sending permission reply: ${reply}, requestID=${requestID}`);
85
+ // CRITICAL: Fire-and-forget! Do not block the handler
86
+ safeBackgroundTask({
87
+ taskName: "permission.reply",
88
+ task: () => opencodeClient.permission.reply({
89
+ requestID,
90
+ directory: currentProject.worktree,
91
+ reply,
92
+ }),
93
+ onSuccess: ({ error }) => {
94
+ if (error) {
95
+ logger.error("[PermissionHandler] Failed to send permission reply:", error);
96
+ if (ctx.api && chatId) {
97
+ void ctx.api
98
+ .sendMessage(chatId, "❌ Не удалось отправить ответ на запрос разрешения")
99
+ .catch(() => { });
100
+ }
101
+ return;
102
+ }
103
+ logger.info("[PermissionHandler] Permission reply sent successfully");
104
+ },
105
+ });
106
+ permissionManager.clear();
107
+ }
108
+ /**
109
+ * Show permission request message with inline buttons
110
+ */
111
+ export async function showPermissionRequest(bot, chatId, request) {
112
+ logger.debug(`[PermissionHandler] Showing permission request: ${request.permission}`);
113
+ permissionManager.startPermission(request);
114
+ const text = formatPermissionText(request);
115
+ const keyboard = buildPermissionKeyboard();
116
+ try {
117
+ const message = await bot.sendMessage(chatId, text, {
118
+ reply_markup: keyboard,
119
+ parse_mode: "Markdown",
120
+ });
121
+ logger.debug(`[PermissionHandler] Message sent, messageId=${message.message_id}`);
122
+ permissionManager.setMessageId(message.message_id);
123
+ summaryAggregator.stopTypingIndicator();
124
+ }
125
+ catch (err) {
126
+ logger.error("[PermissionHandler] Failed to send permission message:", err);
127
+ throw err;
128
+ }
129
+ }
130
+ /**
131
+ * Format permission request text
132
+ */
133
+ function formatPermissionText(request) {
134
+ const emoji = PERMISSION_EMOJIS[request.permission] || "🔐";
135
+ const name = PERMISSION_NAMES[request.permission] || request.permission;
136
+ let text = `${emoji} **Запрос разрешения: ${name}**\n\n`;
137
+ // Show patterns (commands/files)
138
+ if (request.patterns.length > 0) {
139
+ request.patterns.forEach((pattern) => {
140
+ // Escape backticks for Markdown code
141
+ const escapedPattern = pattern.replace(/`/g, "\\`");
142
+ text += `\`${escapedPattern}\`\n`;
143
+ });
144
+ }
145
+ return text;
146
+ }
147
+ /**
148
+ * Build inline keyboard with permission buttons
149
+ */
150
+ function buildPermissionKeyboard() {
151
+ const keyboard = new InlineKeyboard();
152
+ // Single row with all 3 buttons
153
+ keyboard
154
+ .text("✅ Разрешить", "permission:once")
155
+ .text("🔓 Всегда", "permission:always")
156
+ .text("❌ Отклонить", "permission:reject");
157
+ return keyboard;
158
+ }