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.
- package/.env.example +116 -0
- package/LICENSE +21 -0
- package/README.md +250 -0
- package/dist/agent/manager.js +88 -0
- package/dist/agent/types.js +26 -0
- package/dist/app/start-bot-app.js +49 -0
- package/dist/bot/commands/abort.js +121 -0
- package/dist/bot/commands/commands.js +480 -0
- package/dist/bot/commands/definitions.js +27 -0
- package/dist/bot/commands/help.js +10 -0
- package/dist/bot/commands/models.js +38 -0
- package/dist/bot/commands/new.js +70 -0
- package/dist/bot/commands/opencode-start.js +101 -0
- package/dist/bot/commands/opencode-stop.js +44 -0
- package/dist/bot/commands/projects.js +223 -0
- package/dist/bot/commands/rename.js +139 -0
- package/dist/bot/commands/sessions.js +351 -0
- package/dist/bot/commands/start.js +43 -0
- package/dist/bot/commands/status.js +95 -0
- package/dist/bot/commands/task.js +399 -0
- package/dist/bot/commands/tasklist.js +220 -0
- package/dist/bot/commands/voice.js +145 -0
- package/dist/bot/handlers/agent.js +118 -0
- package/dist/bot/handlers/context.js +100 -0
- package/dist/bot/handlers/document.js +65 -0
- package/dist/bot/handlers/inline-menu.js +119 -0
- package/dist/bot/handlers/model.js +143 -0
- package/dist/bot/handlers/permission.js +235 -0
- package/dist/bot/handlers/prompt.js +240 -0
- package/dist/bot/handlers/question.js +390 -0
- package/dist/bot/handlers/tts.js +89 -0
- package/dist/bot/handlers/variant.js +138 -0
- package/dist/bot/handlers/voice.js +173 -0
- package/dist/bot/index.js +977 -0
- package/dist/bot/message-patterns.js +4 -0
- package/dist/bot/middleware/auth.js +30 -0
- package/dist/bot/middleware/interaction-guard.js +95 -0
- package/dist/bot/middleware/unknown-command.js +22 -0
- package/dist/bot/streaming/response-streamer.js +286 -0
- package/dist/bot/streaming/tool-call-streamer.js +285 -0
- package/dist/bot/utils/busy-guard.js +15 -0
- package/dist/bot/utils/commands.js +21 -0
- package/dist/bot/utils/file-download.js +91 -0
- package/dist/bot/utils/finalize-assistant-response.js +52 -0
- package/dist/bot/utils/keyboard.js +69 -0
- package/dist/bot/utils/send-with-markdown-fallback.js +165 -0
- package/dist/bot/utils/telegram-text.js +28 -0
- package/dist/bot/utils/thinking-message.js +8 -0
- package/dist/cli/args.js +98 -0
- package/dist/cli.js +80 -0
- package/dist/config.js +97 -0
- package/dist/i18n/de.js +357 -0
- package/dist/i18n/en.js +357 -0
- package/dist/i18n/es.js +357 -0
- package/dist/i18n/fr.js +357 -0
- package/dist/i18n/index.js +109 -0
- package/dist/i18n/ru.js +357 -0
- package/dist/i18n/zh.js +357 -0
- package/dist/index.js +26 -0
- package/dist/interaction/busy.js +8 -0
- package/dist/interaction/cleanup.js +32 -0
- package/dist/interaction/guard.js +140 -0
- package/dist/interaction/manager.js +106 -0
- package/dist/interaction/types.js +1 -0
- package/dist/keyboard/manager.js +172 -0
- package/dist/keyboard/types.js +1 -0
- package/dist/model/capabilities.js +62 -0
- package/dist/model/context-limit.js +57 -0
- package/dist/model/manager.js +259 -0
- package/dist/model/types.js +24 -0
- package/dist/opencode/client.js +13 -0
- package/dist/opencode/events.js +140 -0
- package/dist/permission/manager.js +100 -0
- package/dist/permission/types.js +1 -0
- package/dist/pinned/format.js +29 -0
- package/dist/pinned/manager.js +682 -0
- package/dist/pinned/types.js +1 -0
- package/dist/process/manager.js +273 -0
- package/dist/process/types.js +1 -0
- package/dist/project/manager.js +88 -0
- package/dist/question/manager.js +176 -0
- package/dist/question/types.js +1 -0
- package/dist/rename/manager.js +53 -0
- package/dist/runtime/bootstrap.js +350 -0
- package/dist/runtime/mode.js +74 -0
- package/dist/runtime/paths.js +37 -0
- package/dist/scheduled-task/creation-manager.js +113 -0
- package/dist/scheduled-task/display.js +239 -0
- package/dist/scheduled-task/executor.js +87 -0
- package/dist/scheduled-task/foreground-state.js +32 -0
- package/dist/scheduled-task/next-run.js +207 -0
- package/dist/scheduled-task/runtime.js +368 -0
- package/dist/scheduled-task/schedule-parser.js +169 -0
- package/dist/scheduled-task/store.js +65 -0
- package/dist/scheduled-task/types.js +19 -0
- package/dist/session/cache-manager.js +455 -0
- package/dist/session/manager.js +10 -0
- package/dist/settings/manager.js +158 -0
- package/dist/stt/client.js +97 -0
- package/dist/summary/aggregator.js +1136 -0
- package/dist/summary/formatter.js +491 -0
- package/dist/summary/subagent-formatter.js +63 -0
- package/dist/summary/tool-message-batcher.js +90 -0
- package/dist/tts/client.js +130 -0
- package/dist/utils/error-format.js +29 -0
- package/dist/utils/logger.js +127 -0
- package/dist/utils/safe-background-task.js +33 -0
- package/dist/utils/telegram-rate-limit-retry.js +93 -0
- package/dist/variant/manager.js +103 -0
- package/dist/variant/types.js +1 -0
- 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
|
+
}
|