zardbot-telegram 1.0.0

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 (111) hide show
  1. package/.env.example +116 -0
  2. package/LICENSE +21 -0
  3. package/README.md +250 -0
  4. package/dist/agent/manager.js +88 -0
  5. package/dist/agent/types.js +26 -0
  6. package/dist/app/start-bot-app.js +49 -0
  7. package/dist/bot/commands/abort.js +121 -0
  8. package/dist/bot/commands/commands.js +480 -0
  9. package/dist/bot/commands/definitions.js +27 -0
  10. package/dist/bot/commands/help.js +10 -0
  11. package/dist/bot/commands/models.js +38 -0
  12. package/dist/bot/commands/new.js +70 -0
  13. package/dist/bot/commands/opencode-start.js +101 -0
  14. package/dist/bot/commands/opencode-stop.js +44 -0
  15. package/dist/bot/commands/projects.js +223 -0
  16. package/dist/bot/commands/rename.js +139 -0
  17. package/dist/bot/commands/sessions.js +351 -0
  18. package/dist/bot/commands/start.js +43 -0
  19. package/dist/bot/commands/status.js +95 -0
  20. package/dist/bot/commands/task.js +399 -0
  21. package/dist/bot/commands/tasklist.js +220 -0
  22. package/dist/bot/commands/voice.js +145 -0
  23. package/dist/bot/handlers/agent.js +118 -0
  24. package/dist/bot/handlers/context.js +100 -0
  25. package/dist/bot/handlers/document.js +65 -0
  26. package/dist/bot/handlers/inline-menu.js +119 -0
  27. package/dist/bot/handlers/model.js +143 -0
  28. package/dist/bot/handlers/permission.js +235 -0
  29. package/dist/bot/handlers/prompt.js +240 -0
  30. package/dist/bot/handlers/question.js +390 -0
  31. package/dist/bot/handlers/tts.js +89 -0
  32. package/dist/bot/handlers/variant.js +138 -0
  33. package/dist/bot/handlers/voice.js +173 -0
  34. package/dist/bot/index.js +977 -0
  35. package/dist/bot/message-patterns.js +4 -0
  36. package/dist/bot/middleware/auth.js +30 -0
  37. package/dist/bot/middleware/interaction-guard.js +95 -0
  38. package/dist/bot/middleware/unknown-command.js +22 -0
  39. package/dist/bot/streaming/response-streamer.js +286 -0
  40. package/dist/bot/streaming/tool-call-streamer.js +285 -0
  41. package/dist/bot/utils/busy-guard.js +15 -0
  42. package/dist/bot/utils/commands.js +21 -0
  43. package/dist/bot/utils/file-download.js +91 -0
  44. package/dist/bot/utils/finalize-assistant-response.js +52 -0
  45. package/dist/bot/utils/keyboard.js +69 -0
  46. package/dist/bot/utils/send-with-markdown-fallback.js +165 -0
  47. package/dist/bot/utils/telegram-text.js +28 -0
  48. package/dist/bot/utils/thinking-message.js +8 -0
  49. package/dist/cli/args.js +98 -0
  50. package/dist/cli.js +80 -0
  51. package/dist/config.js +97 -0
  52. package/dist/i18n/de.js +357 -0
  53. package/dist/i18n/en.js +357 -0
  54. package/dist/i18n/es.js +357 -0
  55. package/dist/i18n/fr.js +357 -0
  56. package/dist/i18n/index.js +109 -0
  57. package/dist/i18n/ru.js +357 -0
  58. package/dist/i18n/zh.js +357 -0
  59. package/dist/index.js +26 -0
  60. package/dist/interaction/busy.js +8 -0
  61. package/dist/interaction/cleanup.js +32 -0
  62. package/dist/interaction/guard.js +140 -0
  63. package/dist/interaction/manager.js +106 -0
  64. package/dist/interaction/types.js +1 -0
  65. package/dist/keyboard/manager.js +172 -0
  66. package/dist/keyboard/types.js +1 -0
  67. package/dist/model/capabilities.js +62 -0
  68. package/dist/model/context-limit.js +57 -0
  69. package/dist/model/manager.js +259 -0
  70. package/dist/model/types.js +24 -0
  71. package/dist/opencode/client.js +13 -0
  72. package/dist/opencode/events.js +140 -0
  73. package/dist/permission/manager.js +100 -0
  74. package/dist/permission/types.js +1 -0
  75. package/dist/pinned/format.js +29 -0
  76. package/dist/pinned/manager.js +682 -0
  77. package/dist/pinned/types.js +1 -0
  78. package/dist/process/manager.js +273 -0
  79. package/dist/process/types.js +1 -0
  80. package/dist/project/manager.js +88 -0
  81. package/dist/question/manager.js +176 -0
  82. package/dist/question/types.js +1 -0
  83. package/dist/rename/manager.js +53 -0
  84. package/dist/runtime/bootstrap.js +350 -0
  85. package/dist/runtime/mode.js +74 -0
  86. package/dist/runtime/paths.js +37 -0
  87. package/dist/scheduled-task/creation-manager.js +113 -0
  88. package/dist/scheduled-task/display.js +239 -0
  89. package/dist/scheduled-task/executor.js +87 -0
  90. package/dist/scheduled-task/foreground-state.js +32 -0
  91. package/dist/scheduled-task/next-run.js +207 -0
  92. package/dist/scheduled-task/runtime.js +368 -0
  93. package/dist/scheduled-task/schedule-parser.js +169 -0
  94. package/dist/scheduled-task/store.js +65 -0
  95. package/dist/scheduled-task/types.js +19 -0
  96. package/dist/session/cache-manager.js +455 -0
  97. package/dist/session/manager.js +10 -0
  98. package/dist/settings/manager.js +158 -0
  99. package/dist/stt/client.js +97 -0
  100. package/dist/summary/aggregator.js +1136 -0
  101. package/dist/summary/formatter.js +491 -0
  102. package/dist/summary/subagent-formatter.js +63 -0
  103. package/dist/summary/tool-message-batcher.js +90 -0
  104. package/dist/tts/client.js +130 -0
  105. package/dist/utils/error-format.js +29 -0
  106. package/dist/utils/logger.js +127 -0
  107. package/dist/utils/safe-background-task.js +33 -0
  108. package/dist/utils/telegram-rate-limit-retry.js +93 -0
  109. package/dist/variant/manager.js +103 -0
  110. package/dist/variant/types.js +1 -0
  111. package/package.json +79 -0
@@ -0,0 +1,390 @@
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 { getCurrentSession } from "../../session/manager.js";
6
+ import { summaryAggregator } from "../../summary/aggregator.js";
7
+ import { interactionManager } from "../../interaction/manager.js";
8
+ import { logger } from "../../utils/logger.js";
9
+ import { safeBackgroundTask } from "../../utils/safe-background-task.js";
10
+ import { t } from "../../i18n/index.js";
11
+ const MAX_BUTTON_LENGTH = 60;
12
+ function getCallbackMessageId(ctx) {
13
+ const message = ctx.callbackQuery?.message;
14
+ if (!message || !("message_id" in message)) {
15
+ return null;
16
+ }
17
+ const messageId = message.message_id;
18
+ return typeof messageId === "number" ? messageId : null;
19
+ }
20
+ function clearQuestionInteraction(reason) {
21
+ const state = interactionManager.getSnapshot();
22
+ if (state?.kind === "question") {
23
+ interactionManager.clear(reason);
24
+ }
25
+ }
26
+ function syncQuestionInteractionState(expectedInput, questionIndex, messageId) {
27
+ const metadata = {
28
+ questionIndex,
29
+ inputMode: expectedInput === "mixed" ? "custom" : "options",
30
+ };
31
+ const requestID = questionManager.getRequestID();
32
+ if (requestID) {
33
+ metadata.requestID = requestID;
34
+ }
35
+ if (messageId !== null) {
36
+ metadata.messageId = messageId;
37
+ }
38
+ const state = interactionManager.getSnapshot();
39
+ if (state?.kind === "question") {
40
+ interactionManager.transition({
41
+ expectedInput,
42
+ metadata,
43
+ });
44
+ return;
45
+ }
46
+ interactionManager.start({
47
+ kind: "question",
48
+ expectedInput,
49
+ metadata,
50
+ });
51
+ }
52
+ export async function handleQuestionCallback(ctx) {
53
+ const data = ctx.callbackQuery?.data;
54
+ if (!data)
55
+ return false;
56
+ if (!data.startsWith("question:")) {
57
+ return false;
58
+ }
59
+ logger.debug(`[QuestionHandler] Received callback: ${data}`);
60
+ if (!questionManager.isActive()) {
61
+ clearQuestionInteraction("question_inactive_callback");
62
+ await ctx.answerCallbackQuery({ text: t("question.inactive_callback"), show_alert: true });
63
+ return true;
64
+ }
65
+ const callbackMessageId = getCallbackMessageId(ctx);
66
+ if (!questionManager.isActiveMessage(callbackMessageId)) {
67
+ await ctx.answerCallbackQuery({ text: t("question.inactive_callback"), show_alert: true });
68
+ return true;
69
+ }
70
+ const parts = data.split(":");
71
+ const action = parts[1];
72
+ const questionIndex = parseInt(parts[2], 10);
73
+ if (Number.isNaN(questionIndex) || questionIndex !== questionManager.getCurrentIndex()) {
74
+ await ctx.answerCallbackQuery({ text: t("question.inactive_callback"), show_alert: true });
75
+ return true;
76
+ }
77
+ try {
78
+ switch (action) {
79
+ case "select":
80
+ {
81
+ const optionIndex = parseInt(parts[3], 10);
82
+ if (Number.isNaN(optionIndex)) {
83
+ await ctx.answerCallbackQuery({
84
+ text: t("question.processing_error_callback"),
85
+ show_alert: true,
86
+ });
87
+ break;
88
+ }
89
+ await handleSelectOption(ctx, questionIndex, optionIndex);
90
+ }
91
+ break;
92
+ case "submit":
93
+ await handleSubmitAnswer(ctx, questionIndex);
94
+ break;
95
+ case "custom":
96
+ await handleCustomAnswer(ctx, questionIndex);
97
+ break;
98
+ case "cancel":
99
+ await handleCancelPoll(ctx);
100
+ break;
101
+ default:
102
+ await ctx.answerCallbackQuery({
103
+ text: t("question.processing_error_callback"),
104
+ show_alert: true,
105
+ });
106
+ break;
107
+ }
108
+ }
109
+ catch (err) {
110
+ logger.error("[QuestionHandler] Error handling callback:", err);
111
+ await ctx.answerCallbackQuery({
112
+ text: t("question.processing_error_callback"),
113
+ show_alert: true,
114
+ });
115
+ }
116
+ return true;
117
+ }
118
+ async function handleSelectOption(ctx, questionIndex, optionIndex) {
119
+ logger.debug(`[QuestionHandler] handleSelectOption: qIndex=${questionIndex}, oIndex=${optionIndex}`);
120
+ const question = questionManager.getCurrentQuestion();
121
+ if (!question) {
122
+ logger.debug("[QuestionHandler] No current question");
123
+ return;
124
+ }
125
+ if (questionManager.isWaitingForCustomInput(questionIndex)) {
126
+ questionManager.clearCustomInput();
127
+ syncQuestionInteractionState("callback", questionIndex, questionManager.getActiveMessageId());
128
+ }
129
+ questionManager.selectOption(questionIndex, optionIndex);
130
+ if (question.multiple) {
131
+ logger.debug("[QuestionHandler] Multiple choice mode, updating message");
132
+ await updateQuestionMessage(ctx);
133
+ await ctx.answerCallbackQuery();
134
+ }
135
+ else {
136
+ logger.debug("[QuestionHandler] Single choice mode, moving to next question");
137
+ await ctx.answerCallbackQuery();
138
+ const answer = questionManager.getSelectedAnswer(questionIndex);
139
+ logger.debug(`[QuestionHandler] Selected answer for question ${questionIndex}: ${answer}`);
140
+ // Delete the question message before showing the next one
141
+ await ctx.deleteMessage().catch(() => { });
142
+ // DO NOT send the answer immediately - move to the next question
143
+ // All answers will be sent together after the user answers all questions
144
+ await showNextQuestion(ctx);
145
+ }
146
+ }
147
+ async function handleSubmitAnswer(ctx, questionIndex) {
148
+ if (questionManager.isWaitingForCustomInput(questionIndex)) {
149
+ questionManager.clearCustomInput();
150
+ syncQuestionInteractionState("callback", questionIndex, questionManager.getActiveMessageId());
151
+ }
152
+ const answer = questionManager.getSelectedAnswer(questionIndex);
153
+ if (!answer) {
154
+ await ctx.answerCallbackQuery({
155
+ text: t("question.select_one_required_callback"),
156
+ show_alert: true,
157
+ });
158
+ return;
159
+ }
160
+ logger.debug(`[QuestionHandler] Submit answer for question ${questionIndex}: ${answer}`);
161
+ await ctx.answerCallbackQuery();
162
+ // Delete the question message before showing the next one
163
+ await ctx.deleteMessage().catch(() => { });
164
+ // DO NOT send the answer immediately - move to the next question
165
+ // All answers will be sent together after the user answers all questions
166
+ await showNextQuestion(ctx);
167
+ }
168
+ async function handleCustomAnswer(ctx, questionIndex) {
169
+ questionManager.startCustomInput(questionIndex);
170
+ syncQuestionInteractionState("mixed", questionIndex, questionManager.getActiveMessageId());
171
+ await ctx.answerCallbackQuery({
172
+ text: t("question.enter_custom_callback"),
173
+ show_alert: true,
174
+ });
175
+ }
176
+ async function handleCancelPoll(ctx) {
177
+ questionManager.cancel();
178
+ clearQuestionInteraction("question_cancelled");
179
+ await ctx.editMessageText(t("question.cancelled")).catch(() => { });
180
+ await ctx.answerCallbackQuery();
181
+ questionManager.clear();
182
+ }
183
+ async function updateQuestionMessage(ctx) {
184
+ const question = questionManager.getCurrentQuestion();
185
+ if (!question) {
186
+ logger.debug("[QuestionHandler] updateQuestionMessage: no current question");
187
+ return;
188
+ }
189
+ const text = formatQuestionText(question);
190
+ const keyboard = buildQuestionKeyboard(question, questionManager.getSelectedOptions(questionManager.getCurrentIndex()));
191
+ logger.debug("[QuestionHandler] Updating question message");
192
+ try {
193
+ await ctx.editMessageText(text, {
194
+ reply_markup: keyboard,
195
+ });
196
+ }
197
+ catch (err) {
198
+ logger.error("[QuestionHandler] Failed to update message:", err);
199
+ }
200
+ }
201
+ export async function showCurrentQuestion(bot, chatId) {
202
+ const question = questionManager.getCurrentQuestion();
203
+ if (!question) {
204
+ await showPollSummary(bot, chatId);
205
+ return;
206
+ }
207
+ logger.debug(`[QuestionHandler] Showing question: ${question.header} - ${question.question}`);
208
+ const text = formatQuestionText(question);
209
+ const keyboard = buildQuestionKeyboard(question, questionManager.getSelectedOptions(questionManager.getCurrentIndex()));
210
+ logger.debug(`[QuestionHandler] Sending message with keyboard, chatId=${chatId}`);
211
+ try {
212
+ const message = await bot.sendMessage(chatId, text, {
213
+ reply_markup: keyboard,
214
+ });
215
+ logger.debug(`[QuestionHandler] Message sent, messageId=${message.message_id}`);
216
+ questionManager.addMessageId(message.message_id);
217
+ questionManager.setActiveMessageId(message.message_id);
218
+ syncQuestionInteractionState("callback", questionManager.getCurrentIndex(), questionManager.getActiveMessageId());
219
+ summaryAggregator.stopTypingIndicator();
220
+ }
221
+ catch (err) {
222
+ questionManager.clear();
223
+ clearQuestionInteraction("question_message_send_failed");
224
+ logger.error("[QuestionHandler] Failed to send question message:", err);
225
+ throw err;
226
+ }
227
+ }
228
+ export async function handleQuestionTextAnswer(ctx) {
229
+ const text = ctx.message?.text;
230
+ if (!text)
231
+ return;
232
+ const currentIndex = questionManager.getCurrentIndex();
233
+ if (!questionManager.isWaitingForCustomInput(currentIndex)) {
234
+ await ctx.reply(t("question.use_custom_button_first"));
235
+ return;
236
+ }
237
+ if (questionManager.hasCustomAnswer(currentIndex)) {
238
+ await ctx.reply(t("question.answer_already_received"));
239
+ return;
240
+ }
241
+ logger.debug(`[QuestionHandler] Custom text answer for question ${currentIndex}: ${text}`);
242
+ questionManager.setCustomAnswer(currentIndex, text);
243
+ questionManager.clearCustomInput();
244
+ // Delete the previous question message
245
+ const activeMessageId = questionManager.getActiveMessageId();
246
+ if (activeMessageId !== null && ctx.chat) {
247
+ await ctx.api.deleteMessage(ctx.chat.id, activeMessageId).catch(() => { });
248
+ }
249
+ // DO NOT send the answer immediately - move to the next question
250
+ // All answers will be sent together after the user answers all questions
251
+ await showNextQuestion(ctx);
252
+ }
253
+ async function showNextQuestion(ctx) {
254
+ questionManager.nextQuestion();
255
+ if (!ctx.chat) {
256
+ return;
257
+ }
258
+ if (questionManager.hasNextQuestion()) {
259
+ await showCurrentQuestion(ctx.api, ctx.chat.id);
260
+ }
261
+ else {
262
+ await showPollSummary(ctx.api, ctx.chat.id);
263
+ }
264
+ }
265
+ async function showPollSummary(bot, chatId) {
266
+ const answers = questionManager.getAllAnswers();
267
+ const totalQuestions = questionManager.getTotalQuestions();
268
+ logger.info(`[QuestionHandler] Poll completed: ${answers.length}/${totalQuestions} questions answered`);
269
+ // Send all answers to the OpenCode API
270
+ await sendAllAnswersToAgent(bot, chatId);
271
+ if (answers.length === 0) {
272
+ await bot.sendMessage(chatId, t("question.completed_no_answers"));
273
+ }
274
+ else {
275
+ const summary = formatAnswersSummary(answers);
276
+ await bot.sendMessage(chatId, summary);
277
+ }
278
+ clearQuestionInteraction("question_completed");
279
+ questionManager.clear();
280
+ logger.debug("[QuestionHandler] Poll completed and cleared");
281
+ }
282
+ async function sendAllAnswersToAgent(bot, chatId) {
283
+ const currentProject = getCurrentProject();
284
+ const currentSession = getCurrentSession();
285
+ const requestID = questionManager.getRequestID();
286
+ const totalQuestions = questionManager.getTotalQuestions();
287
+ const directory = currentSession?.directory ?? currentProject?.worktree;
288
+ if (!directory) {
289
+ logger.error("[QuestionHandler] No project for sending answers");
290
+ await bot.sendMessage(chatId, t("question.no_active_project"));
291
+ return;
292
+ }
293
+ if (!requestID) {
294
+ logger.error("[QuestionHandler] No requestID for sending answers");
295
+ await bot.sendMessage(chatId, t("question.no_active_request"));
296
+ return;
297
+ }
298
+ // Collect answers for all questions
299
+ // Format: Array<Array<string>> - for each question, an array of strings (selected options)
300
+ const allAnswers = [];
301
+ for (let i = 0; i < totalQuestions; i++) {
302
+ const customAnswer = questionManager.getCustomAnswer(i);
303
+ const selectedAnswer = questionManager.getSelectedAnswer(i);
304
+ // Priority: custom answer > selected options
305
+ const answer = customAnswer || selectedAnswer || "";
306
+ if (answer) {
307
+ // Split by newlines if multiple options were selected (in multiple choice mode)
308
+ // Each option is formatted as "* Label: Description"
309
+ const answerParts = answer.split("\n").filter((part) => part.trim());
310
+ allAnswers.push(answerParts);
311
+ }
312
+ else {
313
+ // Empty answer for unanswered questions
314
+ allAnswers.push([]);
315
+ }
316
+ }
317
+ logger.info(`[QuestionHandler] Sending all ${totalQuestions} answers to agent via question.reply: requestID=${requestID}`);
318
+ logger.debug(`[QuestionHandler] Answers payload:`, JSON.stringify(allAnswers, null, 2));
319
+ // CRITICAL: Fire-and-forget! Do not wait for question.reply to complete,
320
+ // otherwise it may block subsequent updates
321
+ safeBackgroundTask({
322
+ taskName: "question.reply",
323
+ task: () => opencodeClient.question.reply({
324
+ requestID,
325
+ directory,
326
+ answers: allAnswers,
327
+ }),
328
+ onSuccess: ({ error }) => {
329
+ if (error) {
330
+ logger.error("[QuestionHandler] Failed to send answers via question.reply:", error);
331
+ void bot.sendMessage(chatId, t("question.send_answers_error")).catch(() => { });
332
+ return;
333
+ }
334
+ logger.info("[QuestionHandler] All answers sent to agent successfully via question.reply");
335
+ },
336
+ });
337
+ }
338
+ function formatQuestionText(question) {
339
+ const currentIndex = questionManager.getCurrentIndex();
340
+ const totalQuestions = questionManager.getTotalQuestions();
341
+ const progressText = totalQuestions > 0 ? `${currentIndex + 1}/${totalQuestions}` : "";
342
+ const headerTitle = [progressText, question.header].filter(Boolean).join(" ");
343
+ const header = headerTitle ? `${headerTitle}\n\n` : "";
344
+ const multiple = question.multiple ? t("question.multi_hint") : "";
345
+ return `${header}${question.question}${multiple}`;
346
+ }
347
+ function buildQuestionKeyboard(question, selectedOptions) {
348
+ const keyboard = new InlineKeyboard();
349
+ const questionIndex = questionManager.getCurrentIndex();
350
+ logger.debug(`[QuestionHandler] Building keyboard for question ${questionIndex}`);
351
+ question.options.forEach((option, index) => {
352
+ const isSelected = selectedOptions.has(index);
353
+ const icon = isSelected ? "✅ " : "";
354
+ const buttonText = formatButtonText(option.label, option.description, icon);
355
+ const callbackData = `question:select:${questionIndex}:${index}`;
356
+ logger.debug(`[QuestionHandler] Button ${index}: "${buttonText}" -> "${callbackData}"`);
357
+ keyboard.text(buttonText, callbackData).row();
358
+ });
359
+ if (question.multiple) {
360
+ keyboard.text(t("question.button.submit"), `question:submit:${questionIndex}`).row();
361
+ logger.debug(`[QuestionHandler] Added submit button`);
362
+ }
363
+ keyboard.text(t("question.button.custom"), `question:custom:${questionIndex}`).row();
364
+ logger.debug(`[QuestionHandler] Added custom answer button`);
365
+ keyboard.text(t("question.button.cancel"), `question:cancel:${questionIndex}`);
366
+ logger.debug(`[QuestionHandler] Added cancel button`);
367
+ logger.debug(`[QuestionHandler] Final keyboard: ${JSON.stringify(keyboard.inline_keyboard)}`);
368
+ return keyboard;
369
+ }
370
+ function formatButtonText(label, description, icon) {
371
+ let text = `${icon}${label}`;
372
+ if (description && icon === "") {
373
+ text += ` - ${description}`;
374
+ }
375
+ if (text.length > MAX_BUTTON_LENGTH) {
376
+ text = text.substring(0, MAX_BUTTON_LENGTH - 3) + "...";
377
+ }
378
+ return text;
379
+ }
380
+ function formatAnswersSummary(answers) {
381
+ let summary = t("question.summary.title");
382
+ answers.forEach((item, index) => {
383
+ summary += t("question.summary.question", {
384
+ index: index + 1,
385
+ question: item.question,
386
+ });
387
+ summary += t("question.summary.answer", { answer: item.answer });
388
+ });
389
+ return summary;
390
+ }
@@ -0,0 +1,89 @@
1
+ import { InputFile } from "grammy";
2
+ import { isTtsConfigured, synthesizeSpeech } from "../../tts/client.js";
3
+ import { isTtsEnabled, getCurrentTtsVoice } from "../../settings/manager.js";
4
+ import { config } from "../../config.js";
5
+ import { logger } from "../../utils/logger.js";
6
+ import { t } from "../../i18n/index.js";
7
+ const MAX_TTS_TEXT_LENGTH = 4000; // Telegram voice message limit
8
+ /**
9
+ * Sends a voice message using TTS if configured and enabled.
10
+ * Called after the assistant text response is sent.
11
+ *
12
+ * @param api - Telegram API instance
13
+ * @param chatId - Chat ID to send to
14
+ * @param text - Text to speak
15
+ * @returns true if TTS was attempted, false if not configured/disabled
16
+ */
17
+ export async function sendTtsVoiceMessage(api, chatId, text) {
18
+ // Check if TTS is enabled at all
19
+ if (!isTtsEnabled()) {
20
+ return false;
21
+ }
22
+ // Check if TTS is configured
23
+ if (!isTtsConfigured()) {
24
+ return false;
25
+ }
26
+ // Get current voice
27
+ const voice = getCurrentTtsVoice() || config.tts.voice || "david-attenborough-original";
28
+ // Truncate text if too long
29
+ const truncatedText = text.length > MAX_TTS_TEXT_LENGTH
30
+ ? text.slice(0, MAX_TTS_TEXT_LENGTH) + "..."
31
+ : text;
32
+ // Skip empty text
33
+ if (!truncatedText.trim()) {
34
+ return false;
35
+ }
36
+ logger.debug(`[TTS] Synthesizing speech for ${truncatedText.length} chars with voice ${voice}`);
37
+ try {
38
+ const result = await synthesizeSpeech(truncatedText, voice);
39
+ // Send as voice message using InputFile with Buffer
40
+ await api.sendVoice(chatId, new InputFile(result.audio, "voice.ogg"), {
41
+ disable_notification: true,
42
+ });
43
+ logger.debug(`[TTS] Voice message sent: ${result.audio.length} bytes`);
44
+ return true;
45
+ }
46
+ catch (err) {
47
+ const errorMessage = err instanceof Error ? err.message : String(err);
48
+ logger.error(`[TTS] Failed to synthesize or send voice: ${errorMessage}`);
49
+ // Notify user of TTS failure (but don't interrupt flow)
50
+ try {
51
+ await api.sendMessage(chatId, t("tts.synthesis_error", { error: errorMessage }), {
52
+ disable_notification: true,
53
+ });
54
+ }
55
+ catch {
56
+ // Ignore send errors
57
+ }
58
+ return false;
59
+ }
60
+ }
61
+ /**
62
+ * Processes text response for TTS.
63
+ * Strips markdown, code blocks, and other non-speech content.
64
+ *
65
+ * @param text - Original text
66
+ * @returns Cleaned text suitable for TTS
67
+ */
68
+ export function cleanTextForTts(text) {
69
+ // Remove code blocks
70
+ let cleaned = text.replace(/```[\s\S]*?```/g, "");
71
+ // Remove inline code
72
+ cleaned = cleaned.replace(/`[^`]+`/g, "");
73
+ // Remove markdown headers
74
+ cleaned = cleaned.replace(/^#+\s.*$/gm, "");
75
+ // Remove markdown links but keep text
76
+ cleaned = cleaned.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
77
+ // Remove markdown bold/italic
78
+ cleaned = cleaned.replace(/[*_]{1,2}([^*_]+)[*_]{1,2}/g, "$1");
79
+ // Remove markdown lists markers
80
+ cleaned = cleaned.replace(/^[\s]*[-*+]\s/gm, "");
81
+ cleaned = cleaned.replace(/^[\s]*\d+\.\s/gm, "");
82
+ // Remove HTML tags
83
+ cleaned = cleaned.replace(/<[^>]+>/g, "");
84
+ // Collapse multiple newlines
85
+ cleaned = cleaned.replace(/\n{3,}/g, "\n\n");
86
+ // Collapse multiple spaces
87
+ cleaned = cleaned.replace(/ {2,}/g, " ");
88
+ return cleaned.trim();
89
+ }
@@ -0,0 +1,138 @@
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
+ import { clearActiveInlineMenu, ensureActiveInlineMenu, replyWithInlineMenu, } from "./inline-menu.js";
10
+ import { t } from "../../i18n/index.js";
11
+ /**
12
+ * Handle variant selection callback
13
+ * @param ctx grammY context
14
+ * @returns true if handled, false otherwise
15
+ */
16
+ export async function handleVariantSelect(ctx) {
17
+ const callbackQuery = ctx.callbackQuery;
18
+ if (!callbackQuery?.data || !callbackQuery.data.startsWith("variant:")) {
19
+ return false;
20
+ }
21
+ const isActiveMenu = await ensureActiveInlineMenu(ctx, "variant");
22
+ if (!isActiveMenu) {
23
+ return true;
24
+ }
25
+ logger.debug(`[VariantHandler] Received callback: ${callbackQuery.data}`);
26
+ try {
27
+ if (ctx.chat) {
28
+ keyboardManager.initialize(ctx.api, ctx.chat.id);
29
+ }
30
+ if (pinnedMessageManager.getContextLimit() === 0) {
31
+ await pinnedMessageManager.refreshContextLimit();
32
+ }
33
+ // Parse callback data: "variant:variantId"
34
+ const variantId = callbackQuery.data.replace("variant:", "");
35
+ // Get current model
36
+ const currentModel = getStoredModel();
37
+ if (!currentModel.providerID || !currentModel.modelID) {
38
+ logger.error("[VariantHandler] No model selected");
39
+ await ctx.answerCallbackQuery({ text: t("variant.model_not_selected_callback") });
40
+ return false;
41
+ }
42
+ // Set variant
43
+ setCurrentVariant(variantId);
44
+ // Re-read model after variant update
45
+ const updatedModel = getStoredModel();
46
+ // Update keyboard manager state
47
+ keyboardManager.updateModel(updatedModel);
48
+ keyboardManager.updateVariant(variantId);
49
+ // Build keyboard with correct context info
50
+ const currentAgent = getStoredAgent();
51
+ const contextInfo = pinnedMessageManager.getContextInfo() ??
52
+ (pinnedMessageManager.getContextLimit() > 0
53
+ ? { tokensUsed: 0, tokensLimit: pinnedMessageManager.getContextLimit() }
54
+ : null);
55
+ if (contextInfo) {
56
+ keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit);
57
+ }
58
+ const variantName = formatVariantForButton(variantId);
59
+ const keyboard = createMainKeyboard(currentAgent, updatedModel, contextInfo ?? undefined, variantName);
60
+ // Send confirmation message with updated keyboard
61
+ const displayName = formatVariantForDisplay(variantId);
62
+ clearActiveInlineMenu("variant_selected");
63
+ await ctx.answerCallbackQuery({ text: t("variant.changed_callback", { name: displayName }) });
64
+ await ctx.reply(t("variant.changed_message", { name: displayName }), {
65
+ reply_markup: keyboard,
66
+ });
67
+ // Delete the inline menu message
68
+ await ctx.deleteMessage().catch(() => { });
69
+ return true;
70
+ }
71
+ catch (err) {
72
+ clearActiveInlineMenu("variant_select_error");
73
+ logger.error("[VariantHandler] Error handling variant select:", err);
74
+ await ctx.answerCallbackQuery({ text: t("variant.change_error_callback") }).catch(() => { });
75
+ return false;
76
+ }
77
+ }
78
+ /**
79
+ * Build inline keyboard with available variants
80
+ * @param currentVariant Current variant for highlighting
81
+ * @param providerID Provider ID
82
+ * @param modelID Model ID
83
+ * @returns InlineKeyboard with variant selection buttons
84
+ */
85
+ export async function buildVariantSelectionMenu(currentVariant, providerID, modelID) {
86
+ const keyboard = new InlineKeyboard();
87
+ const variants = await getAvailableVariants(providerID, modelID);
88
+ if (variants.length === 0) {
89
+ logger.warn("[VariantHandler] No variants found");
90
+ return keyboard;
91
+ }
92
+ // Filter only active variants (not disabled)
93
+ const activeVariants = variants.filter((v) => !v.disabled);
94
+ if (activeVariants.length === 0) {
95
+ logger.warn("[VariantHandler] No active variants found");
96
+ // If no active variants, show default at least
97
+ keyboard.text(`✅ ${formatVariantForDisplay("default")}`, "variant:default").row();
98
+ return keyboard;
99
+ }
100
+ // Add button for each variant (one per row)
101
+ activeVariants.forEach((variant) => {
102
+ const isActive = variant.id === currentVariant;
103
+ const label = formatVariantForDisplay(variant.id);
104
+ const labelWithCheck = isActive ? `✅ ${label}` : label;
105
+ keyboard.text(labelWithCheck, `variant:${variant.id}`).row();
106
+ });
107
+ return keyboard;
108
+ }
109
+ /**
110
+ * Show variant selection menu
111
+ * @param ctx grammY context
112
+ */
113
+ export async function showVariantSelectionMenu(ctx) {
114
+ try {
115
+ const currentModel = getStoredModel();
116
+ if (!currentModel.providerID || !currentModel.modelID) {
117
+ await ctx.reply(t("variant.select_model_first"));
118
+ return;
119
+ }
120
+ const currentVariant = getCurrentVariant();
121
+ const keyboard = await buildVariantSelectionMenu(currentVariant, currentModel.providerID, currentModel.modelID);
122
+ if (keyboard.inline_keyboard.length === 0) {
123
+ await ctx.reply(t("variant.menu.empty"));
124
+ return;
125
+ }
126
+ const displayName = formatVariantForDisplay(currentVariant);
127
+ const text = t("variant.menu.current", { name: displayName });
128
+ await replyWithInlineMenu(ctx, {
129
+ menuKind: "variant",
130
+ text,
131
+ keyboard,
132
+ });
133
+ }
134
+ catch (err) {
135
+ logger.error("[VariantHandler] Error showing variant menu:", err);
136
+ await ctx.reply(t("variant.menu.error"));
137
+ }
138
+ }