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,285 @@
1
+ import { logger } from "../../utils/logger.js";
2
+ const TELEGRAM_MESSAGE_SAFE_LENGTH = 4000;
3
+ function getErrorMessage(error) {
4
+ if (error instanceof Error) {
5
+ return error.message;
6
+ }
7
+ return String(error);
8
+ }
9
+ function getRetryAfterMs(error) {
10
+ const message = getErrorMessage(error);
11
+ if (!/\b429\b/.test(message)) {
12
+ return null;
13
+ }
14
+ const retryMatch = message.match(/retry after\s+(\d+)/i);
15
+ if (!retryMatch) {
16
+ return null;
17
+ }
18
+ const seconds = Number.parseInt(retryMatch[1], 10);
19
+ if (!Number.isFinite(seconds) || seconds <= 0) {
20
+ return null;
21
+ }
22
+ return seconds * 1000;
23
+ }
24
+ function delay(ms) {
25
+ return new Promise((resolve) => {
26
+ setTimeout(resolve, ms);
27
+ });
28
+ }
29
+ function splitLongText(text, limit) {
30
+ if (text.length <= limit) {
31
+ return [text];
32
+ }
33
+ const chunks = [];
34
+ let remaining = text;
35
+ while (remaining.length > limit) {
36
+ let splitIndex = remaining.lastIndexOf("\n", limit);
37
+ if (splitIndex <= 0 || splitIndex < Math.floor(limit * 0.5)) {
38
+ splitIndex = limit;
39
+ }
40
+ chunks.push(remaining.slice(0, splitIndex));
41
+ remaining = remaining.slice(splitIndex).replace(/^\n+/, "");
42
+ }
43
+ if (remaining.length > 0) {
44
+ chunks.push(remaining);
45
+ }
46
+ return chunks;
47
+ }
48
+ function buildParts(entries) {
49
+ const text = entries
50
+ .map((entry) => entry.text.trim())
51
+ .filter(Boolean)
52
+ .join("\n\n");
53
+ if (!text) {
54
+ return [];
55
+ }
56
+ return splitLongText(text, TELEGRAM_MESSAGE_SAFE_LENGTH).filter(Boolean);
57
+ }
58
+ export class ToolCallStreamer {
59
+ throttleMs;
60
+ sendText;
61
+ editText;
62
+ deleteText;
63
+ states = new Map();
64
+ allStates = new Set();
65
+ constructor(options) {
66
+ this.throttleMs = Math.max(0, Math.floor(options.throttleMs));
67
+ this.sendText = options.sendText;
68
+ this.editText = options.editText;
69
+ this.deleteText = options.deleteText;
70
+ }
71
+ append(sessionId, text) {
72
+ const normalizedText = text.trim();
73
+ if (!sessionId || !normalizedText) {
74
+ return;
75
+ }
76
+ const state = this.getOrCreateState(sessionId);
77
+ state.entries.push({ text: normalizedText });
78
+ state.latestParts = buildParts(state.entries);
79
+ this.ensureTimer(state);
80
+ }
81
+ replaceByPrefix(sessionId, prefix, text) {
82
+ const normalizedPrefix = prefix.trim();
83
+ const normalizedText = text.trim();
84
+ if (!sessionId || !normalizedPrefix || !normalizedText) {
85
+ return;
86
+ }
87
+ const state = this.getOrCreateState(sessionId);
88
+ const existingEntry = state.entries.find((entry) => entry.prefix === normalizedPrefix);
89
+ if (existingEntry) {
90
+ existingEntry.text = normalizedText;
91
+ }
92
+ else {
93
+ state.entries.push({ prefix: normalizedPrefix, text: normalizedText });
94
+ }
95
+ state.latestParts = buildParts(state.entries);
96
+ this.ensureTimer(state);
97
+ }
98
+ async flushSession(sessionId, reason) {
99
+ const state = this.states.get(sessionId);
100
+ if (!state) {
101
+ return;
102
+ }
103
+ this.clearTimer(state);
104
+ await this.enqueueTask(state, () => this.syncState(state, reason));
105
+ }
106
+ async breakSession(sessionId, reason) {
107
+ const state = this.states.get(sessionId);
108
+ if (!state) {
109
+ return;
110
+ }
111
+ state.isBreaking = true;
112
+ this.getOrCreateState(sessionId);
113
+ this.clearTimer(state);
114
+ await this.enqueueTask(state, () => this.syncState(state, reason));
115
+ this.cancelState(state);
116
+ this.removeState(state);
117
+ logger.debug(`[ToolCallStreamer] Broke session stream: session=${sessionId}, reason=${reason}`);
118
+ }
119
+ clearSession(sessionId, reason) {
120
+ let clearedAny = false;
121
+ for (const state of Array.from(this.allStates)) {
122
+ if (state.sessionId !== sessionId) {
123
+ continue;
124
+ }
125
+ this.cancelState(state);
126
+ this.removeState(state);
127
+ clearedAny = true;
128
+ }
129
+ if (clearedAny) {
130
+ logger.debug(`[ToolCallStreamer] Cleared session stream: session=${sessionId}, reason=${reason}`);
131
+ }
132
+ }
133
+ clearAll(reason) {
134
+ for (const state of Array.from(this.allStates)) {
135
+ this.cancelState(state);
136
+ }
137
+ const count = this.allStates.size;
138
+ this.states.clear();
139
+ this.allStates.clear();
140
+ if (count > 0) {
141
+ logger.debug(`[ToolCallStreamer] Cleared all streams: count=${count}, reason=${reason}`);
142
+ }
143
+ }
144
+ getOrCreateState(sessionId) {
145
+ const existing = this.states.get(sessionId);
146
+ if (existing && !existing.isBroken && !existing.cancelled && !existing.isBreaking) {
147
+ return existing;
148
+ }
149
+ if (existing && (existing.isBroken || existing.cancelled)) {
150
+ this.clearTimer(existing);
151
+ this.removeState(existing);
152
+ }
153
+ const state = {
154
+ sessionId,
155
+ entries: [],
156
+ latestParts: [],
157
+ lastSentParts: [],
158
+ telegramMessageIds: [],
159
+ timer: null,
160
+ task: Promise.resolve(true),
161
+ cancelled: false,
162
+ isBroken: false,
163
+ isBreaking: false,
164
+ fatalErrorMessage: null,
165
+ fatalErrorLogged: false,
166
+ };
167
+ this.states.set(sessionId, state);
168
+ this.allStates.add(state);
169
+ return state;
170
+ }
171
+ clearTimer(state) {
172
+ if (!state.timer) {
173
+ return;
174
+ }
175
+ clearTimeout(state.timer);
176
+ state.timer = null;
177
+ }
178
+ ensureTimer(state) {
179
+ if (state.timer || state.isBroken || state.cancelled) {
180
+ return;
181
+ }
182
+ if (this.throttleMs === 0) {
183
+ void this.enqueueTask(state, () => this.syncState(state, "immediate")).catch((error) => {
184
+ logger.error(`[ToolCallStreamer] Immediate sync failed: session=${state.sessionId}`, error);
185
+ });
186
+ return;
187
+ }
188
+ state.timer = setTimeout(() => {
189
+ state.timer = null;
190
+ void this.enqueueTask(state, () => this.syncState(state, "throttle_elapsed")).catch((error) => {
191
+ logger.error(`[ToolCallStreamer] Throttled sync failed: session=${state.sessionId}`, error);
192
+ });
193
+ }, this.throttleMs);
194
+ }
195
+ enqueueTask(state, task) {
196
+ const nextTask = state.task.catch(() => false).then(task);
197
+ state.task = nextTask;
198
+ return nextTask;
199
+ }
200
+ cancelState(state) {
201
+ state.cancelled = true;
202
+ this.clearTimer(state);
203
+ }
204
+ removeState(state) {
205
+ if (this.states.get(state.sessionId) === state) {
206
+ this.states.delete(state.sessionId);
207
+ }
208
+ this.allStates.delete(state);
209
+ }
210
+ async syncState(state, reason) {
211
+ if (state.cancelled) {
212
+ return false;
213
+ }
214
+ if (state.isBroken) {
215
+ return false;
216
+ }
217
+ while (!state.isBroken && !state.cancelled) {
218
+ const parts = state.latestParts;
219
+ const unchanged = parts.length === state.lastSentParts.length &&
220
+ parts.every((part, index) => state.lastSentParts[index] === part);
221
+ if (unchanged) {
222
+ return state.telegramMessageIds.length > 0;
223
+ }
224
+ if (parts.length === 0) {
225
+ return state.telegramMessageIds.length > 0;
226
+ }
227
+ try {
228
+ await this.syncMessages(state, parts);
229
+ if (state.cancelled) {
230
+ return false;
231
+ }
232
+ logger.debug(`[ToolCallStreamer] Stream synced: session=${state.sessionId}, reason=${reason}, parts=${parts.length}`);
233
+ return true;
234
+ }
235
+ catch (error) {
236
+ const retryAfterMs = getRetryAfterMs(error);
237
+ if (retryAfterMs === null) {
238
+ this.markStreamBroken(state, error, reason);
239
+ return false;
240
+ }
241
+ const delayMs = Math.max(this.throttleMs, retryAfterMs);
242
+ logger.warn(`[ToolCallStreamer] Stream sync rate-limited, retrying in ${delayMs}ms: session=${state.sessionId}, reason=${reason}`, error);
243
+ await delay(delayMs);
244
+ }
245
+ }
246
+ return false;
247
+ }
248
+ markStreamBroken(state, error, reason) {
249
+ state.isBroken = true;
250
+ state.fatalErrorMessage = getErrorMessage(error);
251
+ if (state.fatalErrorLogged) {
252
+ return;
253
+ }
254
+ state.fatalErrorLogged = true;
255
+ logger.error(`[ToolCallStreamer] Stream marked as broken: session=${state.sessionId}, reason=${reason}, error=${state.fatalErrorMessage}`, error);
256
+ }
257
+ async syncMessages(state, parts) {
258
+ for (let index = 0; index < parts.length; index++) {
259
+ if (state.cancelled) {
260
+ return;
261
+ }
262
+ const text = parts[index];
263
+ const currentMessageId = state.telegramMessageIds[index];
264
+ if (currentMessageId) {
265
+ await this.editText(state.sessionId, currentMessageId, text);
266
+ state.lastSentParts[index] = text;
267
+ continue;
268
+ }
269
+ const messageId = await this.sendText(state.sessionId, text);
270
+ state.telegramMessageIds[index] = messageId;
271
+ state.lastSentParts[index] = text;
272
+ }
273
+ for (let index = state.telegramMessageIds.length - 1; index >= parts.length; index--) {
274
+ if (state.cancelled) {
275
+ return;
276
+ }
277
+ const messageId = state.telegramMessageIds[index];
278
+ if (messageId) {
279
+ await this.deleteText(state.sessionId, messageId);
280
+ }
281
+ state.telegramMessageIds.pop();
282
+ state.lastSentParts.pop();
283
+ }
284
+ }
285
+ }
@@ -0,0 +1,15 @@
1
+ import { foregroundSessionState } from "../../scheduled-task/foreground-state.js";
2
+ import { t } from "../../i18n/index.js";
3
+ export function isForegroundBusy() {
4
+ return foregroundSessionState.isBusy();
5
+ }
6
+ export async function replyBusyBlocked(ctx) {
7
+ const message = t("interaction.blocked.finish_current");
8
+ if (ctx.callbackQuery) {
9
+ await ctx.answerCallbackQuery({ text: message }).catch(() => { });
10
+ return;
11
+ }
12
+ if (ctx.chat) {
13
+ await ctx.reply(message).catch(() => { });
14
+ }
15
+ }
@@ -0,0 +1,21 @@
1
+ import { BOT_COMMANDS } from "../commands/definitions.js";
2
+ const KNOWN_COMMANDS = new Set(["start", ...BOT_COMMANDS.map((item) => item.command)]);
3
+ export function extractCommandName(text) {
4
+ const trimmed = text.trim();
5
+ if (!trimmed.startsWith("/")) {
6
+ return null;
7
+ }
8
+ const token = trimmed.split(/\s+/)[0];
9
+ const withoutSlash = token.slice(1);
10
+ if (!withoutSlash) {
11
+ return null;
12
+ }
13
+ const withoutMention = withoutSlash.split("@")[0].toLowerCase();
14
+ if (!withoutMention) {
15
+ return null;
16
+ }
17
+ return withoutMention;
18
+ }
19
+ export function isKnownCommand(commandName) {
20
+ return KNOWN_COMMANDS.has(commandName);
21
+ }
@@ -0,0 +1,91 @@
1
+ import { config } from "../../config.js";
2
+ import { logger } from "../../utils/logger.js";
3
+ const TELEGRAM_FILE_URL_BASE = "https://api.telegram.org/file/bot";
4
+ const MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024; // 20MB Telegram limit
5
+ /**
6
+ * Download a photo from Telegram servers
7
+ * @param api Grammy API instance
8
+ * @param fileId Telegram file_id
9
+ * @returns Downloaded photo buffer and path
10
+ */
11
+ export async function downloadTelegramFile(api, fileId) {
12
+ logger.debug(`[FileDownload] Getting file info for fileId=${fileId}`);
13
+ const file = await api.getFile(fileId);
14
+ if (!file.file_path) {
15
+ throw new Error("File path not available from Telegram");
16
+ }
17
+ if (file.file_size && file.file_size > MAX_FILE_SIZE_BYTES) {
18
+ const sizeMb = (file.file_size / (1024 * 1024)).toFixed(2);
19
+ throw new Error(`File too large: ${sizeMb}MB (max 20MB)`);
20
+ }
21
+ const fileUrl = `${TELEGRAM_FILE_URL_BASE}${config.telegram.token}/${file.file_path}`;
22
+ logger.debug(`[FileDownload] Downloading from ${fileUrl.replace(config.telegram.token, "***")}`);
23
+ const fetchOptions = {};
24
+ // Use proxy if configured
25
+ if (config.telegram.proxyUrl) {
26
+ const { HttpsProxyAgent } = await import("https-proxy-agent");
27
+ fetchOptions.agent = new HttpsProxyAgent(config.telegram.proxyUrl);
28
+ }
29
+ const response = await fetch(fileUrl, fetchOptions);
30
+ if (!response.ok) {
31
+ throw new Error(`Failed to download file: ${response.status} ${response.statusText}`);
32
+ }
33
+ const arrayBuffer = await response.arrayBuffer();
34
+ const buffer = Buffer.from(arrayBuffer);
35
+ logger.debug(`[FileDownload] Downloaded ${buffer.length} bytes`);
36
+ return {
37
+ buffer,
38
+ filePath: file.file_path,
39
+ };
40
+ }
41
+ /**
42
+ * Convert buffer to base64 data URI
43
+ * @param buffer File buffer
44
+ * @param mimeType MIME type (e.g., "image/jpeg")
45
+ * @returns Data URI string
46
+ */
47
+ export function toDataUri(buffer, mimeType) {
48
+ const base64 = buffer.toString("base64");
49
+ return `data:${mimeType};base64,${base64}`;
50
+ }
51
+ /**
52
+ * Check if photo size is within limits
53
+ * @param fileSize Photo size in bytes
54
+ * @param maxSizeKb Maximum size in KB (from config)
55
+ * @returns true if within limit
56
+ */
57
+ export function isFileSizeAllowed(fileSize, maxSizeKb) {
58
+ if (!fileSize) {
59
+ return true; // Unknown size, allow (will be checked on download)
60
+ }
61
+ const maxBytes = maxSizeKb * 1024;
62
+ return fileSize <= maxBytes;
63
+ }
64
+ /**
65
+ * Get human-readable photo size
66
+ */
67
+ export function formatFileSize(bytes) {
68
+ if (bytes < 1024) {
69
+ return `${bytes}B`;
70
+ }
71
+ if (bytes < 1024 * 1024) {
72
+ return `${(bytes / 1024).toFixed(1)}KB`;
73
+ }
74
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
75
+ }
76
+ const APPLICATION_TEXT_MIME_TYPES = new Set([
77
+ "application/json",
78
+ "application/xml",
79
+ "application/javascript",
80
+ "application/x-yaml",
81
+ "application/sql",
82
+ ]);
83
+ export function isTextMimeType(mimeType) {
84
+ if (!mimeType) {
85
+ return false;
86
+ }
87
+ if (mimeType.startsWith("text/")) {
88
+ return true;
89
+ }
90
+ return APPLICATION_TEXT_MIME_TYPES.has(mimeType);
91
+ }
@@ -0,0 +1,52 @@
1
+ import { logger } from "../../utils/logger.js";
2
+ import { sendTtsVoiceMessage, cleanTextForTts } from "../handlers/tts.js";
3
+ export async function finalizeAssistantResponse({ sessionId, messageId, messageText, responseStreamer, flushPendingServiceMessages, prepareStreamingPayload, formatSummary, formatRawSummary, resolveFormat, getReplyKeyboard, sendText, deleteMessages, api, chatId, enableTts = true, }) {
4
+ let streamedMessageIds = [];
5
+ const preparedStreamPayload = prepareStreamingPayload(messageText);
6
+ if (preparedStreamPayload) {
7
+ preparedStreamPayload.sendOptions = { disable_notification: true };
8
+ preparedStreamPayload.editOptions = undefined;
9
+ }
10
+ const result = await responseStreamer.complete(sessionId, messageId, preparedStreamPayload ?? undefined);
11
+ if (result.streamed) {
12
+ streamedMessageIds = result.telegramMessageIds;
13
+ }
14
+ await flushPendingServiceMessages();
15
+ // When the response was streamed, delete the streamed messages and re-send
16
+ // via the non-streamed path so the reply keyboard carries the latest context.
17
+ if (streamedMessageIds.length > 0) {
18
+ try {
19
+ await deleteMessages(streamedMessageIds);
20
+ }
21
+ catch (err) {
22
+ logger.warn("[FinalizeResponse] Failed to delete streamed messages, sending with keyboard anyway:", err);
23
+ }
24
+ }
25
+ const parts = formatSummary(messageText);
26
+ const rawParts = formatRawSummary(messageText);
27
+ const format = resolveFormat();
28
+ for (let partIndex = 0; partIndex < parts.length; partIndex++) {
29
+ const part = parts[partIndex];
30
+ const rawFallbackText = rawParts[partIndex];
31
+ const keyboard = getReplyKeyboard();
32
+ const options = keyboard ? { reply_markup: keyboard } : undefined;
33
+ await sendText(part, rawFallbackText, options, format);
34
+ }
35
+ // TTS: Send voice message if TTS is enabled and configured
36
+ if (enableTts && api && chatId) {
37
+ try {
38
+ // Clean text for TTS (remove code blocks, markdown, etc.)
39
+ const cleanedText = cleanTextForTts(messageText);
40
+ if (cleanedText.length > 0) {
41
+ // Send TTS in background - don't block the response
42
+ sendTtsVoiceMessage(api, chatId, cleanedText).catch((err) => {
43
+ logger.warn("[FinalizeResponse] TTS failed:", err);
44
+ });
45
+ }
46
+ }
47
+ catch (err) {
48
+ logger.warn("[FinalizeResponse] TTS error:", err);
49
+ }
50
+ }
51
+ return false;
52
+ }
@@ -0,0 +1,69 @@
1
+ import { Keyboard } from "grammy";
2
+ import { getAgentDisplayName } from "../../agent/types.js";
3
+ import { formatModelForButton } from "../../model/types.js";
4
+ import { t } from "../../i18n/index.js";
5
+ /**
6
+ * Format token count for display (e.g., 150000 -> "150K", 1500000 -> "1.5M")
7
+ */
8
+ function formatTokenCount(count) {
9
+ if (count >= 1000000) {
10
+ return `${(count / 1000000).toFixed(1)}M`;
11
+ }
12
+ else if (count >= 1000) {
13
+ return `${Math.round(count / 1000)}K`;
14
+ }
15
+ return count.toString();
16
+ }
17
+ /**
18
+ * Format context information for button
19
+ */
20
+ function formatContextForButton(contextInfo) {
21
+ const used = formatTokenCount(contextInfo.tokensUsed);
22
+ const limit = formatTokenCount(contextInfo.tokensLimit);
23
+ const percent = Math.round((contextInfo.tokensUsed / contextInfo.tokensLimit) * 100);
24
+ return t("keyboard.context", { used, limit, percent });
25
+ }
26
+ /**
27
+ * Create Reply Keyboard with agent, model, variant, and context indicators
28
+ * @param currentAgent Current agent name (e.g., "build", "plan")
29
+ * @param currentModel Current model info
30
+ * @param contextInfo Optional context information (tokens used/limit)
31
+ * @param variantName Optional variant display name (e.g., "💭 Default")
32
+ * @returns Reply Keyboard with agent and context in row 1, model and variant in row 2
33
+ */
34
+ export function createMainKeyboard(currentAgent, currentModel, contextInfo, variantName) {
35
+ const keyboard = new Keyboard();
36
+ const agentText = getAgentDisplayName(currentAgent);
37
+ // Format model with compact provider/model text and icon
38
+ const modelText = formatModelForButton(currentModel.providerID, currentModel.modelID);
39
+ // Context text - show "0" if no data available
40
+ const contextText = contextInfo
41
+ ? formatContextForButton(contextInfo)
42
+ : t("keyboard.context_empty");
43
+ // Variant text - default to "💭 Default" if not provided
44
+ const variantText = variantName || t("keyboard.variant_default");
45
+ // Row 1: agent and context buttons
46
+ keyboard.text(agentText).text(contextText).row();
47
+ // Row 2: model and variant buttons
48
+ keyboard.text(modelText).text(variantText).row();
49
+ return keyboard.resized().persistent();
50
+ }
51
+ /**
52
+ * Create Reply Keyboard with agent mode indicator
53
+ * @param currentAgent Current agent name (e.g., "build", "plan")
54
+ * @returns Reply Keyboard with single button showing current mode
55
+ * @deprecated Use createMainKeyboard instead
56
+ */
57
+ export function createAgentKeyboard(currentAgent) {
58
+ const keyboard = new Keyboard();
59
+ const displayName = getAgentDisplayName(currentAgent);
60
+ // Single button with current agent mode
61
+ keyboard.text(displayName).row();
62
+ return keyboard.resized().persistent();
63
+ }
64
+ /**
65
+ * Remove Reply Keyboard (for cleanup)
66
+ */
67
+ export function removeKeyboard() {
68
+ return { remove_keyboard: true };
69
+ }