@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,294 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { questionManager } from "../../question/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
+ const MAX_BUTTON_LENGTH = 60;
9
+ export async function handleQuestionCallback(ctx) {
10
+ const data = ctx.callbackQuery?.data;
11
+ if (!data)
12
+ return false;
13
+ if (!data.startsWith("question:")) {
14
+ return false;
15
+ }
16
+ logger.debug(`[QuestionHandler] Received callback: ${data}`);
17
+ if (!questionManager.isActive()) {
18
+ await ctx.answerCallbackQuery({ text: "Опрос неактивен", show_alert: true });
19
+ return true;
20
+ }
21
+ const parts = data.split(":");
22
+ const action = parts[1];
23
+ const questionIndex = parseInt(parts[2], 10);
24
+ try {
25
+ switch (action) {
26
+ case "select":
27
+ await handleSelectOption(ctx, questionIndex, parseInt(parts[3], 10));
28
+ break;
29
+ case "submit":
30
+ await handleSubmitAnswer(ctx, questionIndex);
31
+ break;
32
+ case "custom":
33
+ await handleCustomAnswer(ctx, questionIndex);
34
+ break;
35
+ case "cancel":
36
+ await handleCancelPoll(ctx);
37
+ break;
38
+ }
39
+ }
40
+ catch (err) {
41
+ logger.error("[QuestionHandler] Error handling callback:", err);
42
+ await ctx.answerCallbackQuery({ text: "Ошибка при обработке", show_alert: true });
43
+ }
44
+ return true;
45
+ }
46
+ async function handleSelectOption(ctx, questionIndex, optionIndex) {
47
+ logger.debug(`[QuestionHandler] handleSelectOption: qIndex=${questionIndex}, oIndex=${optionIndex}`);
48
+ const question = questionManager.getCurrentQuestion();
49
+ if (!question) {
50
+ logger.debug("[QuestionHandler] No current question");
51
+ return;
52
+ }
53
+ questionManager.selectOption(questionIndex, optionIndex);
54
+ if (question.multiple) {
55
+ logger.debug("[QuestionHandler] Multiple choice mode, updating message");
56
+ await updateQuestionMessage(ctx);
57
+ await ctx.answerCallbackQuery();
58
+ }
59
+ else {
60
+ logger.debug("[QuestionHandler] Single choice mode, moving to next question");
61
+ await ctx.answerCallbackQuery();
62
+ const answer = questionManager.getSelectedAnswer(questionIndex);
63
+ logger.debug(`[QuestionHandler] Selected answer for question ${questionIndex}: ${answer}`);
64
+ // Удаляем сообщение с вопросом перед показом следующего
65
+ await ctx.deleteMessage().catch(() => { });
66
+ // НЕ отправляем ответ сразу - переходим к следующему вопросу
67
+ // Все ответы будут отправлены вместе когда пользователь ответит на все вопросы
68
+ await showNextQuestion(ctx);
69
+ }
70
+ }
71
+ async function handleSubmitAnswer(ctx, questionIndex) {
72
+ const answer = questionManager.getSelectedAnswer(questionIndex);
73
+ if (!answer) {
74
+ await ctx.answerCallbackQuery({
75
+ text: "Выберите хотя бы один вариант",
76
+ show_alert: true,
77
+ });
78
+ return;
79
+ }
80
+ logger.debug(`[QuestionHandler] Submit answer for question ${questionIndex}: ${answer}`);
81
+ await ctx.answerCallbackQuery();
82
+ // Удаляем сообщение с вопросом перед показом следующего
83
+ await ctx.deleteMessage().catch(() => { });
84
+ // НЕ отправляем ответ сразу - переходим к следующему вопросу
85
+ // Все ответы будут отправлены вместе когда пользователь ответит на все вопросы
86
+ await showNextQuestion(ctx);
87
+ }
88
+ async function handleCustomAnswer(ctx, _questionIndex) {
89
+ await ctx.answerCallbackQuery({
90
+ text: "Введите свой ответ сообщением",
91
+ show_alert: true,
92
+ });
93
+ }
94
+ async function handleCancelPoll(ctx) {
95
+ questionManager.cancel();
96
+ await ctx.editMessageText("❌ Опрос отменен");
97
+ await ctx.answerCallbackQuery();
98
+ }
99
+ async function updateQuestionMessage(ctx) {
100
+ const question = questionManager.getCurrentQuestion();
101
+ if (!question) {
102
+ logger.debug("[QuestionHandler] updateQuestionMessage: no current question");
103
+ return;
104
+ }
105
+ const text = formatQuestionText(question);
106
+ const keyboard = buildQuestionKeyboard(question, questionManager.getSelectedOptions(questionManager.getCurrentIndex()));
107
+ logger.debug("[QuestionHandler] Updating question message");
108
+ try {
109
+ await ctx.editMessageText(text, {
110
+ reply_markup: keyboard,
111
+ parse_mode: "Markdown",
112
+ });
113
+ }
114
+ catch (err) {
115
+ logger.error("[QuestionHandler] Failed to update message:", err);
116
+ }
117
+ }
118
+ export async function showCurrentQuestion(bot, chatId) {
119
+ const question = questionManager.getCurrentQuestion();
120
+ if (!question) {
121
+ await showPollSummary(bot, chatId);
122
+ return;
123
+ }
124
+ logger.debug(`[QuestionHandler] Showing question: ${question.header} - ${question.question}`);
125
+ const text = formatQuestionText(question);
126
+ const keyboard = buildQuestionKeyboard(question, questionManager.getSelectedOptions(questionManager.getCurrentIndex()));
127
+ logger.debug(`[QuestionHandler] Sending message with keyboard, chatId=${chatId}`);
128
+ try {
129
+ const message = await bot.sendMessage(chatId, text, {
130
+ reply_markup: keyboard,
131
+ parse_mode: "Markdown",
132
+ });
133
+ logger.debug(`[QuestionHandler] Message sent, messageId=${message.message_id}`);
134
+ questionManager.addMessageId(message.message_id);
135
+ summaryAggregator.stopTypingIndicator();
136
+ }
137
+ catch (err) {
138
+ logger.error("[QuestionHandler] Failed to send question message:", err);
139
+ throw err;
140
+ }
141
+ }
142
+ export async function handleQuestionTextAnswer(ctx) {
143
+ const text = ctx.message?.text;
144
+ if (!text)
145
+ return;
146
+ const currentIndex = questionManager.getCurrentIndex();
147
+ if (questionManager.hasCustomAnswer(currentIndex)) {
148
+ await ctx.reply("Ответ уже получен, подождите...");
149
+ return;
150
+ }
151
+ logger.debug(`[QuestionHandler] Custom text answer for question ${currentIndex}: ${text}`);
152
+ questionManager.setCustomAnswer(currentIndex, text);
153
+ // Удаляем предыдущее сообщение с вопросом
154
+ const messageIds = questionManager.getMessageIds();
155
+ if (messageIds.length > 0 && ctx.chat) {
156
+ const lastMessageId = messageIds[messageIds.length - 1];
157
+ await ctx.api.deleteMessage(ctx.chat.id, lastMessageId).catch(() => { });
158
+ }
159
+ // НЕ отправляем ответ сразу - переходим к следующему вопросу
160
+ // Все ответы будут отправлены вместе когда пользователь ответит на все вопросы
161
+ await showNextQuestion(ctx);
162
+ }
163
+ async function showNextQuestion(ctx) {
164
+ questionManager.nextQuestion();
165
+ if (!ctx.chat) {
166
+ return;
167
+ }
168
+ if (questionManager.hasNextQuestion()) {
169
+ await showCurrentQuestion(ctx.api, ctx.chat.id);
170
+ }
171
+ else {
172
+ await showPollSummary(ctx.api, ctx.chat.id);
173
+ }
174
+ }
175
+ async function showPollSummary(bot, chatId) {
176
+ const answers = questionManager.getAllAnswers();
177
+ const totalQuestions = questionManager.getTotalQuestions();
178
+ logger.info(`[QuestionHandler] Poll completed: ${answers.length}/${totalQuestions} questions answered`);
179
+ // Отправляем все ответы в OpenCode API
180
+ await sendAllAnswersToAgent(bot, chatId);
181
+ if (answers.length === 0) {
182
+ await bot.sendMessage(chatId, "✅ Опрос завершен (без ответов)");
183
+ }
184
+ else {
185
+ const summary = formatAnswersSummary(answers);
186
+ await bot.sendMessage(chatId, summary);
187
+ }
188
+ questionManager.clear();
189
+ logger.debug("[QuestionHandler] Poll completed and cleared");
190
+ }
191
+ async function sendAllAnswersToAgent(bot, chatId) {
192
+ const currentProject = getCurrentProject();
193
+ const requestID = questionManager.getRequestID();
194
+ const totalQuestions = questionManager.getTotalQuestions();
195
+ if (!currentProject) {
196
+ logger.error("[QuestionHandler] No project for sending answers");
197
+ await bot.sendMessage(chatId, "❌ Нет активного проекта");
198
+ return;
199
+ }
200
+ if (!requestID) {
201
+ logger.error("[QuestionHandler] No requestID for sending answers");
202
+ await bot.sendMessage(chatId, "❌ Нет активного запроса");
203
+ return;
204
+ }
205
+ // Собираем ответы на все вопросы
206
+ // Формат: Array<Array<string>> - для каждого вопроса массив строк (выбранные опции)
207
+ const allAnswers = [];
208
+ for (let i = 0; i < totalQuestions; i++) {
209
+ const customAnswer = questionManager.getCustomAnswer(i);
210
+ const selectedAnswer = questionManager.getSelectedAnswer(i);
211
+ // Приоритет: custom answer > selected options
212
+ const answer = customAnswer || selectedAnswer || "";
213
+ if (answer) {
214
+ // Split by newlines if multiple options were selected (in multiple choice mode)
215
+ // Each option is formatted as "* Label: Description"
216
+ const answerParts = answer.split("\n").filter((part) => part.trim());
217
+ allAnswers.push(answerParts);
218
+ }
219
+ else {
220
+ // Empty answer for unanswered questions
221
+ allAnswers.push([]);
222
+ }
223
+ }
224
+ logger.info(`[QuestionHandler] Sending all ${totalQuestions} answers to agent via question.reply: requestID=${requestID}`);
225
+ logger.debug(`[QuestionHandler] Answers payload:`, JSON.stringify(allAnswers, null, 2));
226
+ // КРИТИЧНО: Fire-and-forget! Не ждём завершения question.reply,
227
+ // иначе может заблокировать следующие updates
228
+ safeBackgroundTask({
229
+ taskName: "question.reply",
230
+ task: () => opencodeClient.question.reply({
231
+ requestID,
232
+ directory: currentProject.worktree,
233
+ answers: allAnswers,
234
+ }),
235
+ onSuccess: ({ error }) => {
236
+ if (error) {
237
+ logger.error("[QuestionHandler] Failed to send answers via question.reply:", error);
238
+ void bot.sendMessage(chatId, "❌ Не удалось отправить ответы агенту").catch(() => { });
239
+ return;
240
+ }
241
+ logger.info("[QuestionHandler] All answers sent to agent successfully via question.reply");
242
+ },
243
+ });
244
+ }
245
+ function formatQuestionText(question) {
246
+ const currentIndex = questionManager.getCurrentIndex();
247
+ const totalQuestions = questionManager.getTotalQuestions();
248
+ const progressText = totalQuestions > 0 ? `${currentIndex + 1}/${totalQuestions}` : "";
249
+ const headerTitle = [progressText, question.header].filter(Boolean).join(" ");
250
+ const header = headerTitle ? `**${headerTitle}**\n\n` : "";
251
+ const multiple = question.multiple ? "\n*Можно выбрать несколько вариантов*" : "";
252
+ return `${header}${question.question}${multiple}`;
253
+ }
254
+ function buildQuestionKeyboard(question, selectedOptions) {
255
+ const keyboard = new InlineKeyboard();
256
+ const questionIndex = questionManager.getCurrentIndex();
257
+ logger.debug(`[QuestionHandler] Building keyboard for question ${questionIndex}`);
258
+ question.options.forEach((option, index) => {
259
+ const isSelected = selectedOptions.has(index);
260
+ const icon = isSelected ? "✅ " : "";
261
+ const buttonText = formatButtonText(option.label, option.description, icon);
262
+ const callbackData = `question:select:${questionIndex}:${index}`;
263
+ logger.debug(`[QuestionHandler] Button ${index}: "${buttonText}" -> "${callbackData}"`);
264
+ keyboard.text(buttonText, callbackData).row();
265
+ });
266
+ if (question.multiple) {
267
+ keyboard.text("✅ Готово", `question:submit:${questionIndex}`);
268
+ logger.debug(`[QuestionHandler] Added submit button`);
269
+ }
270
+ keyboard.text("🔤 Свой ответ", `question:custom:${questionIndex}`);
271
+ logger.debug(`[QuestionHandler] Added custom answer button`);
272
+ keyboard.text("❌ Отмена", `question:cancel:${questionIndex}`);
273
+ logger.debug(`[QuestionHandler] Added cancel button`);
274
+ logger.debug(`[QuestionHandler] Final keyboard: ${JSON.stringify(keyboard.inline_keyboard)}`);
275
+ return keyboard;
276
+ }
277
+ function formatButtonText(label, description, icon) {
278
+ let text = `${icon}${label}`;
279
+ if (description && icon === "") {
280
+ text += ` - ${description}`;
281
+ }
282
+ if (text.length > MAX_BUTTON_LENGTH) {
283
+ text = text.substring(0, MAX_BUTTON_LENGTH - 3) + "...";
284
+ }
285
+ return text;
286
+ }
287
+ function formatAnswersSummary(answers) {
288
+ let summary = "✅ Опрос завершен!\n\n";
289
+ answers.forEach((item, index) => {
290
+ summary += `Вопрос ${index + 1}:\n${item.question}\n\n`;
291
+ summary += `Ответ:\n${item.answer}\n\n`;
292
+ });
293
+ return summary;
294
+ }
@@ -0,0 +1,126 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { getAvailableVariants, getCurrentVariant, setCurrentVariant, formatVariantForDisplay, formatVariantForButton, } from "../../variant/manager.js";
3
+ import { getStoredModel } from "../../model/manager.js";
4
+ import { getStoredAgent } from "../../agent/manager.js";
5
+ import { logger } from "../../utils/logger.js";
6
+ import { keyboardManager } from "../../keyboard/manager.js";
7
+ import { pinnedMessageManager } from "../../pinned/manager.js";
8
+ import { createMainKeyboard } from "../utils/keyboard.js";
9
+ /**
10
+ * Handle variant selection callback
11
+ * @param ctx grammY context
12
+ * @returns true if handled, false otherwise
13
+ */
14
+ export async function handleVariantSelect(ctx) {
15
+ const callbackQuery = ctx.callbackQuery;
16
+ if (!callbackQuery?.data || !callbackQuery.data.startsWith("variant:")) {
17
+ return false;
18
+ }
19
+ logger.debug(`[VariantHandler] Received callback: ${callbackQuery.data}`);
20
+ try {
21
+ if (ctx.chat) {
22
+ keyboardManager.initialize(ctx.api, ctx.chat.id);
23
+ }
24
+ if (pinnedMessageManager.getContextLimit() === 0) {
25
+ await pinnedMessageManager.refreshContextLimit();
26
+ }
27
+ // Parse callback data: "variant:variantId"
28
+ const variantId = callbackQuery.data.replace("variant:", "");
29
+ // Get current model
30
+ const currentModel = getStoredModel();
31
+ if (!currentModel.providerID || !currentModel.modelID) {
32
+ logger.error("[VariantHandler] No model selected");
33
+ await ctx.answerCallbackQuery({ text: "Ошибка: модель не выбрана" });
34
+ return false;
35
+ }
36
+ // Set variant
37
+ setCurrentVariant(variantId);
38
+ // Re-read model after variant update
39
+ const updatedModel = getStoredModel();
40
+ // Update keyboard manager state
41
+ keyboardManager.updateModel(updatedModel);
42
+ keyboardManager.updateVariant(variantId);
43
+ // Build keyboard with correct context info
44
+ const currentAgent = getStoredAgent();
45
+ const contextInfo = pinnedMessageManager.getContextInfo() ??
46
+ (pinnedMessageManager.getContextLimit() > 0
47
+ ? { tokensUsed: 0, tokensLimit: pinnedMessageManager.getContextLimit() }
48
+ : null);
49
+ if (contextInfo) {
50
+ keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit);
51
+ }
52
+ const variantName = formatVariantForButton(variantId);
53
+ const keyboard = createMainKeyboard(currentAgent, updatedModel, contextInfo ?? undefined, variantName);
54
+ // Send confirmation message with updated keyboard
55
+ const displayName = formatVariantForDisplay(variantId);
56
+ await ctx.answerCallbackQuery({ text: `Variant изменен: ${displayName}` });
57
+ await ctx.reply(`✅ Variant изменен на: ${displayName}`, {
58
+ reply_markup: keyboard,
59
+ });
60
+ // Delete the inline menu message
61
+ await ctx.deleteMessage().catch(() => { });
62
+ return true;
63
+ }
64
+ catch (err) {
65
+ logger.error("[VariantHandler] Error handling variant select:", err);
66
+ await ctx.answerCallbackQuery({ text: "Ошибка при смене variant" }).catch(() => { });
67
+ return false;
68
+ }
69
+ }
70
+ /**
71
+ * Build inline keyboard with available variants
72
+ * @param currentVariant Current variant for highlighting
73
+ * @param providerID Provider ID
74
+ * @param modelID Model ID
75
+ * @returns InlineKeyboard with variant selection buttons
76
+ */
77
+ export async function buildVariantSelectionMenu(currentVariant, providerID, modelID) {
78
+ const keyboard = new InlineKeyboard();
79
+ const variants = await getAvailableVariants(providerID, modelID);
80
+ if (variants.length === 0) {
81
+ logger.warn("[VariantHandler] No variants found");
82
+ return keyboard;
83
+ }
84
+ // Filter only active variants (not disabled)
85
+ const activeVariants = variants.filter((v) => !v.disabled);
86
+ if (activeVariants.length === 0) {
87
+ logger.warn("[VariantHandler] No active variants found");
88
+ // If no active variants, show default at least
89
+ keyboard.text("✅ Default", "variant:default").row();
90
+ return keyboard;
91
+ }
92
+ // Add button for each variant (one per row)
93
+ activeVariants.forEach((variant) => {
94
+ const isActive = variant.id === currentVariant;
95
+ const label = formatVariantForDisplay(variant.id);
96
+ const labelWithCheck = isActive ? `✅ ${label}` : label;
97
+ keyboard.text(labelWithCheck, `variant:${variant.id}`).row();
98
+ });
99
+ return keyboard;
100
+ }
101
+ /**
102
+ * Show variant selection menu
103
+ * @param ctx grammY context
104
+ */
105
+ export async function showVariantSelectionMenu(ctx) {
106
+ try {
107
+ const currentModel = getStoredModel();
108
+ if (!currentModel.providerID || !currentModel.modelID) {
109
+ await ctx.reply("⚠️ Сначала выберите модель");
110
+ return;
111
+ }
112
+ const currentVariant = getCurrentVariant();
113
+ const keyboard = await buildVariantSelectionMenu(currentVariant, currentModel.providerID, currentModel.modelID);
114
+ if (keyboard.inline_keyboard.length === 0) {
115
+ await ctx.reply("⚠️ Нет доступных вариантов");
116
+ return;
117
+ }
118
+ const displayName = formatVariantForDisplay(currentVariant);
119
+ const text = `Текущий variant: ${displayName}\n\nВыберите variant:`;
120
+ await ctx.reply(text, { reply_markup: keyboard });
121
+ }
122
+ catch (err) {
123
+ logger.error("[VariantHandler] Error showing variant menu:", err);
124
+ await ctx.reply("🔴 Не удалось получить список вариантов");
125
+ }
126
+ }