opencode-telegram-group-topics-bot 0.11.2

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 (101) hide show
  1. package/.env.example +74 -0
  2. package/LICENSE +21 -0
  3. package/README.md +305 -0
  4. package/dist/agent/manager.js +60 -0
  5. package/dist/agent/types.js +26 -0
  6. package/dist/app/start-bot-app.js +47 -0
  7. package/dist/bot/commands/abort.js +116 -0
  8. package/dist/bot/commands/commands.js +389 -0
  9. package/dist/bot/commands/constants.js +20 -0
  10. package/dist/bot/commands/definitions.js +25 -0
  11. package/dist/bot/commands/help.js +27 -0
  12. package/dist/bot/commands/models.js +38 -0
  13. package/dist/bot/commands/new.js +247 -0
  14. package/dist/bot/commands/opencode-start.js +85 -0
  15. package/dist/bot/commands/opencode-stop.js +44 -0
  16. package/dist/bot/commands/projects.js +304 -0
  17. package/dist/bot/commands/rename.js +173 -0
  18. package/dist/bot/commands/sessions.js +491 -0
  19. package/dist/bot/commands/start.js +67 -0
  20. package/dist/bot/commands/status.js +138 -0
  21. package/dist/bot/constants.js +49 -0
  22. package/dist/bot/handlers/agent.js +127 -0
  23. package/dist/bot/handlers/context.js +125 -0
  24. package/dist/bot/handlers/document.js +65 -0
  25. package/dist/bot/handlers/inline-menu.js +124 -0
  26. package/dist/bot/handlers/model.js +152 -0
  27. package/dist/bot/handlers/permission.js +281 -0
  28. package/dist/bot/handlers/prompt.js +263 -0
  29. package/dist/bot/handlers/question.js +285 -0
  30. package/dist/bot/handlers/variant.js +147 -0
  31. package/dist/bot/handlers/voice.js +173 -0
  32. package/dist/bot/index.js +945 -0
  33. package/dist/bot/message-patterns.js +4 -0
  34. package/dist/bot/middleware/auth.js +30 -0
  35. package/dist/bot/middleware/interaction-guard.js +80 -0
  36. package/dist/bot/middleware/unknown-command.js +22 -0
  37. package/dist/bot/scope.js +222 -0
  38. package/dist/bot/telegram-constants.js +3 -0
  39. package/dist/bot/telegram-rate-limiter.js +263 -0
  40. package/dist/bot/utils/commands.js +21 -0
  41. package/dist/bot/utils/file-download.js +91 -0
  42. package/dist/bot/utils/keyboard.js +85 -0
  43. package/dist/bot/utils/send-with-markdown-fallback.js +57 -0
  44. package/dist/bot/utils/session-error-filter.js +34 -0
  45. package/dist/bot/utils/topic-link.js +29 -0
  46. package/dist/cli/args.js +98 -0
  47. package/dist/cli.js +80 -0
  48. package/dist/config.js +103 -0
  49. package/dist/i18n/de.js +330 -0
  50. package/dist/i18n/en.js +330 -0
  51. package/dist/i18n/es.js +330 -0
  52. package/dist/i18n/index.js +102 -0
  53. package/dist/i18n/ru.js +330 -0
  54. package/dist/i18n/zh.js +330 -0
  55. package/dist/index.js +28 -0
  56. package/dist/interaction/cleanup.js +24 -0
  57. package/dist/interaction/constants.js +25 -0
  58. package/dist/interaction/guard.js +100 -0
  59. package/dist/interaction/manager.js +113 -0
  60. package/dist/interaction/types.js +1 -0
  61. package/dist/keyboard/manager.js +115 -0
  62. package/dist/keyboard/types.js +1 -0
  63. package/dist/model/capabilities.js +62 -0
  64. package/dist/model/manager.js +257 -0
  65. package/dist/model/types.js +24 -0
  66. package/dist/opencode/client.js +13 -0
  67. package/dist/opencode/events.js +159 -0
  68. package/dist/opencode/prompt-submit-error.js +101 -0
  69. package/dist/permission/manager.js +92 -0
  70. package/dist/permission/types.js +1 -0
  71. package/dist/pinned/manager.js +405 -0
  72. package/dist/pinned/types.js +1 -0
  73. package/dist/process/manager.js +273 -0
  74. package/dist/process/types.js +1 -0
  75. package/dist/project/manager.js +88 -0
  76. package/dist/question/manager.js +186 -0
  77. package/dist/question/types.js +1 -0
  78. package/dist/rename/manager.js +64 -0
  79. package/dist/runtime/bootstrap.js +350 -0
  80. package/dist/runtime/mode.js +74 -0
  81. package/dist/runtime/paths.js +37 -0
  82. package/dist/runtime/process-error-handlers.js +24 -0
  83. package/dist/session/cache-manager.js +455 -0
  84. package/dist/session/manager.js +87 -0
  85. package/dist/settings/manager.js +283 -0
  86. package/dist/stt/client.js +64 -0
  87. package/dist/summary/aggregator.js +625 -0
  88. package/dist/summary/formatter.js +417 -0
  89. package/dist/summary/tool-message-batcher.js +277 -0
  90. package/dist/topic/colors.js +8 -0
  91. package/dist/topic/constants.js +10 -0
  92. package/dist/topic/manager.js +161 -0
  93. package/dist/topic/title-constants.js +2 -0
  94. package/dist/topic/title-format.js +10 -0
  95. package/dist/topic/title-sync.js +17 -0
  96. package/dist/utils/error-format.js +29 -0
  97. package/dist/utils/logger.js +175 -0
  98. package/dist/utils/safe-background-task.js +33 -0
  99. package/dist/variant/manager.js +103 -0
  100. package/dist/variant/types.js +1 -0
  101. package/package.json +76 -0
@@ -0,0 +1,417 @@
1
+ import * as path from "path";
2
+ import { convert } from "telegram-markdown-v2";
3
+ import { config } from "../config.js";
4
+ import { logger } from "../utils/logger.js";
5
+ import { t } from "../i18n/index.js";
6
+ import { getCurrentProject } from "../settings/manager.js";
7
+ const TELEGRAM_MESSAGE_LIMIT = 4096;
8
+ function splitText(text, maxLength) {
9
+ const parts = [];
10
+ let currentIndex = 0;
11
+ while (currentIndex < text.length) {
12
+ let endIndex = currentIndex + maxLength;
13
+ if (endIndex >= text.length) {
14
+ parts.push(text.slice(currentIndex));
15
+ break;
16
+ }
17
+ const breakPoint = text.lastIndexOf("\n", endIndex);
18
+ if (breakPoint > currentIndex) {
19
+ endIndex = breakPoint + 1;
20
+ }
21
+ parts.push(text.slice(currentIndex, endIndex));
22
+ currentIndex = endIndex;
23
+ }
24
+ return parts;
25
+ }
26
+ function isCodeFenceLine(line) {
27
+ return line.trimStart().startsWith("```");
28
+ }
29
+ function isHorizontalRuleLine(line) {
30
+ const normalized = line.trim();
31
+ if (!normalized) {
32
+ return false;
33
+ }
34
+ return /^([-*_])(?:\s*\1){2,}$/.test(normalized);
35
+ }
36
+ function isHeadingLine(line) {
37
+ return /^\s{0,3}#{1,6}\s+\S/.test(line);
38
+ }
39
+ function normalizeHeadingLine(line) {
40
+ const match = line.match(/^\s{0,3}#{1,6}\s+(.+?)\s*$/);
41
+ if (!match) {
42
+ return line;
43
+ }
44
+ return `**${match[1]}**`;
45
+ }
46
+ function normalizeChecklistLine(line) {
47
+ const match = line.match(/^(\s*)(?:[-+*]|\d+\.)\s+\[( |x|X)\]\s+(.*)$/);
48
+ if (!match) {
49
+ return null;
50
+ }
51
+ const marker = match[2].toLowerCase() === "x" ? "✅" : "🔲";
52
+ return `${match[1]}${marker} ${match[3]}`;
53
+ }
54
+ function preprocessMarkdownForTelegram(text) {
55
+ const lines = text.split("\n");
56
+ const output = [];
57
+ let inCodeFence = false;
58
+ let inQuote = false;
59
+ for (let index = 0; index < lines.length; index++) {
60
+ const line = lines[index];
61
+ if (isCodeFenceLine(line)) {
62
+ inCodeFence = !inCodeFence;
63
+ inQuote = false;
64
+ output.push(line);
65
+ continue;
66
+ }
67
+ if (inCodeFence) {
68
+ output.push(line);
69
+ continue;
70
+ }
71
+ if (!line.trim()) {
72
+ inQuote = false;
73
+ output.push(line);
74
+ continue;
75
+ }
76
+ if (isHeadingLine(line)) {
77
+ output.push(normalizeHeadingLine(line));
78
+ inQuote = false;
79
+ continue;
80
+ }
81
+ if (isHorizontalRuleLine(line)) {
82
+ output.push("──────────");
83
+ inQuote = false;
84
+ continue;
85
+ }
86
+ const trimmedLeft = line.trimStart();
87
+ if (trimmedLeft.startsWith(">")) {
88
+ inQuote = true;
89
+ const quoteContent = trimmedLeft.replace(/^>\s?/, "");
90
+ const normalizedChecklistInQuote = normalizeChecklistLine(quoteContent);
91
+ output.push(normalizedChecklistInQuote ? `> ${normalizedChecklistInQuote.trimStart()}` : trimmedLeft);
92
+ continue;
93
+ }
94
+ const normalizedChecklist = normalizeChecklistLine(line);
95
+ if (normalizedChecklist) {
96
+ output.push(inQuote ? `> ${normalizedChecklist.trimStart()}` : normalizedChecklist);
97
+ continue;
98
+ }
99
+ if (inQuote) {
100
+ output.push(`> ${trimmedLeft}`);
101
+ continue;
102
+ }
103
+ output.push(line);
104
+ }
105
+ return output.join("\n");
106
+ }
107
+ export function normalizePathForDisplay(filePath, projectWorktree) {
108
+ const normalizedPath = filePath.replace(/\\/g, "/");
109
+ const worktree = projectWorktree ?? getCurrentProject()?.worktree;
110
+ if (!worktree) {
111
+ return normalizedPath;
112
+ }
113
+ const normalizedWorktree = worktree.replace(/\\/g, "/").replace(/\/+$/, "");
114
+ if (!normalizedWorktree) {
115
+ return normalizedPath;
116
+ }
117
+ const pathForCompare = process.platform === "win32" ? normalizedPath.toLowerCase() : normalizedPath;
118
+ const worktreeForCompare = process.platform === "win32" ? normalizedWorktree.toLowerCase() : normalizedWorktree;
119
+ if (pathForCompare === worktreeForCompare) {
120
+ return ".";
121
+ }
122
+ const worktreePrefix = `${worktreeForCompare}/`;
123
+ if (pathForCompare.startsWith(worktreePrefix)) {
124
+ return normalizedPath.slice(normalizedWorktree.length + 1);
125
+ }
126
+ return normalizedPath;
127
+ }
128
+ export function formatSummary(text) {
129
+ return formatSummaryWithMode(text, config.bot.messageFormatMode);
130
+ }
131
+ export function getAssistantParseMode() {
132
+ if (config.bot.messageFormatMode === "markdown") {
133
+ return "MarkdownV2";
134
+ }
135
+ return undefined;
136
+ }
137
+ function formatMarkdownForTelegram(text) {
138
+ try {
139
+ const preprocessed = preprocessMarkdownForTelegram(text);
140
+ return convert(preprocessed, "keep");
141
+ }
142
+ catch (error) {
143
+ logger.warn("[Formatter] Failed to convert markdown summary, falling back to raw text", error);
144
+ return text;
145
+ }
146
+ }
147
+ export function formatSummaryWithMode(text, mode) {
148
+ if (!text || text.trim().length === 0) {
149
+ return [];
150
+ }
151
+ const parts = splitText(text, TELEGRAM_MESSAGE_LIMIT);
152
+ const formattedParts = [];
153
+ for (const part of parts) {
154
+ const trimmed = part.trim();
155
+ if (!trimmed) {
156
+ continue;
157
+ }
158
+ if (mode === "markdown") {
159
+ const converted = formatMarkdownForTelegram(trimmed);
160
+ const convertedParts = splitText(converted, TELEGRAM_MESSAGE_LIMIT);
161
+ for (const convertedPart of convertedParts) {
162
+ const normalizedPart = convertedPart.trim();
163
+ if (normalizedPart) {
164
+ formattedParts.push(normalizedPart);
165
+ }
166
+ }
167
+ continue;
168
+ }
169
+ if (parts.length > 1) {
170
+ formattedParts.push(`\`\`\`\n${trimmed}\n\`\`\``);
171
+ }
172
+ else {
173
+ formattedParts.push(trimmed);
174
+ }
175
+ }
176
+ return formattedParts;
177
+ }
178
+ function getToolDetails(tool, input, projectWorktree) {
179
+ if (!input) {
180
+ return "";
181
+ }
182
+ // First, check fields specific to known tools
183
+ switch (tool) {
184
+ case "read":
185
+ case "edit":
186
+ case "write":
187
+ case "apply_patch":
188
+ const filePath = input.path || input.filePath;
189
+ if (typeof filePath === "string")
190
+ return normalizePathForDisplay(filePath, projectWorktree);
191
+ break;
192
+ case "bash":
193
+ if (typeof input.command === "string")
194
+ return input.command;
195
+ break;
196
+ case "grep":
197
+ case "glob":
198
+ if (typeof input.pattern === "string")
199
+ return input.pattern;
200
+ break;
201
+ }
202
+ // Generic search for MCP and other tools
203
+ // Look for common fields: query, url, name, prompt
204
+ const commonFields = ["query", "url", "name", "prompt", "text"];
205
+ for (const field of commonFields) {
206
+ if (typeof input[field] === "string") {
207
+ return input[field];
208
+ }
209
+ }
210
+ // If nothing matched but string fields exist, take the first one (except description)
211
+ for (const [key, value] of Object.entries(input)) {
212
+ if (key !== "description" && typeof value === "string" && value.length > 0) {
213
+ return value;
214
+ }
215
+ }
216
+ return "";
217
+ }
218
+ function getToolIcon(tool) {
219
+ switch (tool) {
220
+ case "read":
221
+ return "📖";
222
+ case "write":
223
+ return "✍️";
224
+ case "edit":
225
+ return "✏️";
226
+ case "apply_patch":
227
+ return "🩹";
228
+ case "bash":
229
+ return "💻";
230
+ case "glob":
231
+ return "📁";
232
+ case "grep":
233
+ return "🔍";
234
+ case "task":
235
+ return "🤖";
236
+ case "question":
237
+ return "❓";
238
+ case "todoread":
239
+ return "📋";
240
+ case "todowrite":
241
+ return "📝";
242
+ case "webfetch":
243
+ return "🌐";
244
+ case "web-search_tavily_search":
245
+ return "🔎";
246
+ case "web-search_tavily_extract":
247
+ return "📄";
248
+ case "skill":
249
+ return "🎓";
250
+ default:
251
+ return "🛠️";
252
+ }
253
+ }
254
+ function formatTodos(todos) {
255
+ const MAX_TODOS = 20;
256
+ const statusToMarker = {
257
+ completed: "✅",
258
+ in_progress: "🔄",
259
+ pending: "🔲",
260
+ };
261
+ const formattedTodos = [];
262
+ for (let i = 0; i < Math.min(todos.length, MAX_TODOS); i++) {
263
+ const todo = todos[i];
264
+ const marker = statusToMarker[todo.status] ?? "🔲";
265
+ formattedTodos.push(`${marker} ${todo.content}`);
266
+ }
267
+ let result = formattedTodos.join("\n");
268
+ if (todos.length > MAX_TODOS) {
269
+ result += `\n${t("tool.todo.overflow", { count: todos.length - MAX_TODOS })}`;
270
+ }
271
+ return result;
272
+ }
273
+ function formatDiffLineInfo(filediff) {
274
+ const parts = [];
275
+ if (filediff.additions && filediff.additions > 0)
276
+ parts.push(`+${filediff.additions}`);
277
+ if (filediff.deletions && filediff.deletions > 0)
278
+ parts.push(`-${filediff.deletions}`);
279
+ return parts.length > 0 ? ` (${parts.join(" ")})` : "";
280
+ }
281
+ function countDiffChangesFromText(text) {
282
+ let additions = 0;
283
+ let deletions = 0;
284
+ for (const line of text.split("\n")) {
285
+ if (line.startsWith("+") && !line.startsWith("+++")) {
286
+ additions++;
287
+ continue;
288
+ }
289
+ if (line.startsWith("-") && !line.startsWith("---")) {
290
+ deletions++;
291
+ }
292
+ }
293
+ return { additions, deletions };
294
+ }
295
+ function extractFirstUpdatedFileFromTitle(title) {
296
+ for (const rawLine of title.split("\n")) {
297
+ const line = rawLine.trim();
298
+ if (line.length >= 3 && line[1] === " " && /[AMDURC]/.test(line[0])) {
299
+ return line.slice(2).trim();
300
+ }
301
+ }
302
+ return "";
303
+ }
304
+ export function formatToolInfo(toolInfo, projectWorktree) {
305
+ const { tool, input, title } = toolInfo;
306
+ logger.debug(`[Formatter] formatToolInfo: tool=${tool}, hasMetadata=${!!toolInfo.metadata}, hasFilediff=${!!toolInfo.metadata?.filediff}`);
307
+ if (tool === "todowrite" && toolInfo.metadata?.todos) {
308
+ const todos = toolInfo.metadata.todos;
309
+ const toolIcon = getToolIcon(tool);
310
+ const todosList = formatTodos(todos);
311
+ return `${toolIcon} ${tool} (${todos.length})\n${todosList}`;
312
+ }
313
+ let details = title || getToolDetails(tool, input, projectWorktree);
314
+ const toolIcon = getToolIcon(tool);
315
+ let description = "";
316
+ if (input && typeof input.description === "string") {
317
+ description = `${input.description}\n`;
318
+ }
319
+ if (tool === "bash" && input && typeof input.command === "string") {
320
+ details = input.command;
321
+ }
322
+ if (tool === "apply_patch") {
323
+ const filediff = toolInfo.metadata && "filediff" in toolInfo.metadata
324
+ ? toolInfo.metadata.filediff
325
+ : undefined;
326
+ if (filediff?.file) {
327
+ details = normalizePathForDisplay(filediff.file, projectWorktree);
328
+ }
329
+ else if (title) {
330
+ const fileFromTitle = extractFirstUpdatedFileFromTitle(title);
331
+ if (fileFromTitle) {
332
+ details = normalizePathForDisplay(fileFromTitle, projectWorktree);
333
+ }
334
+ }
335
+ }
336
+ const detailsStr = details ? ` ${details}` : "";
337
+ let lineInfo = "";
338
+ if (tool === "write" && input && "content" in input && typeof input.content === "string") {
339
+ const lines = countLines(input.content);
340
+ lineInfo = ` (+${lines})`;
341
+ }
342
+ if ((tool === "edit" || tool === "apply_patch") &&
343
+ toolInfo.metadata &&
344
+ "filediff" in toolInfo.metadata) {
345
+ const filediff = toolInfo.metadata.filediff;
346
+ logger.debug("[Formatter] Diff metadata:", JSON.stringify(toolInfo.metadata, null, 2));
347
+ lineInfo = formatDiffLineInfo(filediff);
348
+ }
349
+ if (tool === "apply_patch" && !lineInfo) {
350
+ const diffText = toolInfo.metadata && typeof toolInfo.metadata.diff === "string"
351
+ ? toolInfo.metadata.diff
352
+ : input && typeof input.patchText === "string"
353
+ ? input.patchText
354
+ : "";
355
+ if (diffText) {
356
+ lineInfo = formatDiffLineInfo(countDiffChangesFromText(diffText));
357
+ }
358
+ }
359
+ return `${toolIcon} ${description}${tool}${detailsStr}${lineInfo}`;
360
+ }
361
+ function countLines(text) {
362
+ return text.split("\n").length;
363
+ }
364
+ function formatDiff(diff) {
365
+ const lines = diff.split("\n");
366
+ const formattedLines = [];
367
+ for (const line of lines) {
368
+ if (line.startsWith("@@")) {
369
+ continue;
370
+ }
371
+ if (line.startsWith("---") || line.startsWith("+++")) {
372
+ continue;
373
+ }
374
+ if (line.startsWith("Index:")) {
375
+ continue;
376
+ }
377
+ if (line.startsWith("===") && line.includes("=")) {
378
+ continue;
379
+ }
380
+ if (line.startsWith("\\ No newline")) {
381
+ continue;
382
+ }
383
+ if (line.startsWith(" ")) {
384
+ formattedLines.push(" " + line.slice(1));
385
+ }
386
+ else if (line.startsWith("+")) {
387
+ formattedLines.push("+ " + line.slice(1));
388
+ }
389
+ else if (line.startsWith("-")) {
390
+ formattedLines.push("- " + line.slice(1));
391
+ }
392
+ else {
393
+ formattedLines.push(line);
394
+ }
395
+ }
396
+ return formattedLines.join("\n");
397
+ }
398
+ export function prepareCodeFile(content, filePath, operation, projectWorktree) {
399
+ const displayPath = normalizePathForDisplay(filePath, projectWorktree);
400
+ let processedContent = content;
401
+ if (operation === "edit") {
402
+ processedContent = formatDiff(content);
403
+ }
404
+ const sizeKb = Buffer.byteLength(processedContent, "utf8") / 1024;
405
+ if (sizeKb > config.files.maxFileSizeKb) {
406
+ logger.debug(`[Formatter] File too large: ${displayPath} (${sizeKb.toFixed(2)} KB > ${config.files.maxFileSizeKb} KB)`);
407
+ return null;
408
+ }
409
+ const header = operation === "write"
410
+ ? t("tool.file_header.write", { path: displayPath })
411
+ : t("tool.file_header.edit", { path: displayPath });
412
+ const fullContent = header + processedContent;
413
+ const buffer = Buffer.from(fullContent, "utf8");
414
+ const basename = path.basename(filePath);
415
+ const filename = `${operation}_${basename}.txt`;
416
+ return { buffer, filename, caption: "" };
417
+ }
@@ -0,0 +1,277 @@
1
+ import { logger } from "../utils/logger.js";
2
+ const DEFAULT_INTERVAL_SECONDS = 5;
3
+ const TELEGRAM_MESSAGE_MAX_LENGTH = 4096;
4
+ function normalizeIntervalSeconds(value) {
5
+ if (!Number.isFinite(value)) {
6
+ return DEFAULT_INTERVAL_SECONDS;
7
+ }
8
+ const normalized = Math.floor(value);
9
+ if (normalized < 0) {
10
+ return DEFAULT_INTERVAL_SECONDS;
11
+ }
12
+ return normalized;
13
+ }
14
+ export class ToolMessageBatcher {
15
+ intervalSeconds;
16
+ sendText;
17
+ sendFile;
18
+ queues = new Map();
19
+ timers = new Map();
20
+ sessionTasks = new Map();
21
+ generation = 0;
22
+ constructor(options) {
23
+ this.intervalSeconds = normalizeIntervalSeconds(options.intervalSeconds);
24
+ this.sendText = options.sendText;
25
+ this.sendFile = options.sendFile;
26
+ }
27
+ setIntervalSeconds(nextIntervalSeconds) {
28
+ const normalized = normalizeIntervalSeconds(nextIntervalSeconds);
29
+ if (this.intervalSeconds === normalized) {
30
+ return;
31
+ }
32
+ this.intervalSeconds = normalized;
33
+ logger.info(`[ToolBatcher] Interval updated: ${normalized}s`);
34
+ if (normalized === 0) {
35
+ void this.flushAll("interval_updated");
36
+ return;
37
+ }
38
+ const sessionIds = Array.from(this.queues.keys());
39
+ for (const sessionId of sessionIds) {
40
+ this.restartTimer(sessionId);
41
+ }
42
+ }
43
+ getIntervalSeconds() {
44
+ return this.intervalSeconds;
45
+ }
46
+ enqueue(sessionId, message) {
47
+ this.enqueueTextInternal(sessionId, message);
48
+ }
49
+ enqueueUniqueByPrefix(sessionId, message, prefix) {
50
+ this.enqueueTextInternal(sessionId, message, prefix);
51
+ }
52
+ enqueueFile(sessionId, fileData) {
53
+ if (!sessionId) {
54
+ return;
55
+ }
56
+ if (this.intervalSeconds === 0) {
57
+ const expectedGeneration = this.generation;
58
+ logger.debug(`[ToolBatcher] Sending immediate file message: session=${sessionId}`);
59
+ void this.enqueueTask(sessionId, () => this.sendFileSafe(sessionId, fileData, "immediate", expectedGeneration));
60
+ return;
61
+ }
62
+ const queue = this.queues.get(sessionId) ?? [];
63
+ queue.push({ kind: "file", fileData });
64
+ this.queues.set(sessionId, queue);
65
+ logger.debug(`[ToolBatcher] Queued file message: session=${sessionId}, queueSize=${queue.length}, interval=${this.intervalSeconds}s`);
66
+ this.ensureTimer(sessionId);
67
+ }
68
+ async flushSession(sessionId, reason) {
69
+ await this.enqueueTask(sessionId, () => this.flushSessionInternal(sessionId, reason));
70
+ }
71
+ async flushAll(reason) {
72
+ for (const sessionId of Array.from(this.timers.keys())) {
73
+ this.clearTimer(sessionId);
74
+ }
75
+ const sessionIds = Array.from(this.queues.keys());
76
+ for (const sessionId of sessionIds) {
77
+ await this.flushSession(sessionId, reason);
78
+ }
79
+ }
80
+ clearSession(sessionId, reason) {
81
+ this.generation++;
82
+ this.clearTimer(sessionId);
83
+ if (this.queues.delete(sessionId)) {
84
+ logger.debug(`[ToolBatcher] Cleared session queue: session=${sessionId}, reason=${reason}`);
85
+ }
86
+ }
87
+ clearAll(reason) {
88
+ this.generation++;
89
+ for (const timer of this.timers.values()) {
90
+ clearTimeout(timer);
91
+ }
92
+ const queuedSessions = this.queues.size;
93
+ this.timers.clear();
94
+ this.queues.clear();
95
+ if (queuedSessions > 0) {
96
+ logger.debug(`[ToolBatcher] Cleared all queued tool messages: sessions=${queuedSessions}, reason=${reason}`);
97
+ }
98
+ }
99
+ clearTimer(sessionId) {
100
+ const timer = this.timers.get(sessionId);
101
+ if (!timer) {
102
+ return;
103
+ }
104
+ clearTimeout(timer);
105
+ this.timers.delete(sessionId);
106
+ }
107
+ ensureTimer(sessionId) {
108
+ if (this.timers.has(sessionId)) {
109
+ return;
110
+ }
111
+ this.restartTimer(sessionId);
112
+ }
113
+ restartTimer(sessionId) {
114
+ this.clearTimer(sessionId);
115
+ const timer = setTimeout(() => {
116
+ this.timers.delete(sessionId);
117
+ void this.flushSession(sessionId, "interval_elapsed");
118
+ }, this.intervalSeconds * 1000);
119
+ this.timers.set(sessionId, timer);
120
+ }
121
+ enqueueTask(sessionId, task) {
122
+ const previousTask = this.sessionTasks.get(sessionId) ?? Promise.resolve();
123
+ const nextTask = previousTask
124
+ .catch(() => undefined)
125
+ .then(task)
126
+ .finally(() => {
127
+ if (this.sessionTasks.get(sessionId) === nextTask) {
128
+ this.sessionTasks.delete(sessionId);
129
+ }
130
+ });
131
+ this.sessionTasks.set(sessionId, nextTask);
132
+ return nextTask;
133
+ }
134
+ enqueueTextInternal(sessionId, message, uniquePrefix) {
135
+ const normalizedMessage = message.trim();
136
+ if (!sessionId || normalizedMessage.length === 0) {
137
+ return;
138
+ }
139
+ if (this.intervalSeconds === 0) {
140
+ const expectedGeneration = this.generation;
141
+ logger.debug(`[ToolBatcher] Sending immediate text message: session=${sessionId}`);
142
+ void this.enqueueTask(sessionId, () => this.sendTextSafe(sessionId, normalizedMessage, "immediate", expectedGeneration));
143
+ return;
144
+ }
145
+ const normalizedPrefix = uniquePrefix?.trim();
146
+ const queue = this.queues.get(sessionId) ?? [];
147
+ if (normalizedPrefix) {
148
+ const existingUniqueMessage = queue.find((item) => item.kind === "text" && item.text.startsWith(normalizedPrefix));
149
+ if (existingUniqueMessage) {
150
+ existingUniqueMessage.text = normalizedMessage;
151
+ this.queues.set(sessionId, queue);
152
+ logger.debug(`[ToolBatcher] Updated queued unique text message: session=${sessionId}, prefix=${normalizedPrefix}, interval=${this.intervalSeconds}s`);
153
+ this.ensureTimer(sessionId);
154
+ return;
155
+ }
156
+ }
157
+ queue.push({ kind: "text", text: normalizedMessage });
158
+ this.queues.set(sessionId, queue);
159
+ logger.debug(`[ToolBatcher] Queued text message: session=${sessionId}, queueSize=${queue.length}, interval=${this.intervalSeconds}s`);
160
+ this.ensureTimer(sessionId);
161
+ }
162
+ async flushSessionInternal(sessionId, reason) {
163
+ const expectedGeneration = this.generation;
164
+ this.clearTimer(sessionId);
165
+ const queuedItems = this.queues.get(sessionId);
166
+ if (!queuedItems || queuedItems.length === 0) {
167
+ return;
168
+ }
169
+ this.queues.delete(sessionId);
170
+ const flushItems = this.buildFlushItems(queuedItems);
171
+ logger.debug(`[ToolBatcher] Flushing ${queuedItems.length} queued items as ${flushItems.length} Telegram sends (session=${sessionId}, reason=${reason})`);
172
+ for (const item of flushItems) {
173
+ if (item.kind === "text") {
174
+ await this.sendTextSafe(sessionId, item.text, reason, expectedGeneration);
175
+ }
176
+ else {
177
+ await this.sendFileSafe(sessionId, item.fileData, reason, expectedGeneration);
178
+ }
179
+ }
180
+ }
181
+ async sendTextSafe(sessionId, text, reason, expectedGeneration) {
182
+ if (this.generation !== expectedGeneration) {
183
+ logger.debug(`[ToolBatcher] Dropping stale tool text message: session=${sessionId}, reason=${reason}`);
184
+ return;
185
+ }
186
+ try {
187
+ await this.sendText(sessionId, text);
188
+ }
189
+ catch (err) {
190
+ logger.error(`[ToolBatcher] Failed to send tool text message: session=${sessionId}, reason=${reason}`, err);
191
+ }
192
+ }
193
+ async sendFileSafe(sessionId, fileData, reason, expectedGeneration) {
194
+ if (this.generation !== expectedGeneration) {
195
+ logger.debug(`[ToolBatcher] Dropping stale tool file message: session=${sessionId}, reason=${reason}`);
196
+ return;
197
+ }
198
+ try {
199
+ await this.sendFile(sessionId, fileData);
200
+ }
201
+ catch (err) {
202
+ logger.error(`[ToolBatcher] Failed to send tool file message: session=${sessionId}, reason=${reason}`, err);
203
+ }
204
+ }
205
+ buildFlushItems(entries) {
206
+ const result = [];
207
+ const textBuffer = [];
208
+ const flushTextBuffer = () => {
209
+ if (textBuffer.length === 0) {
210
+ return;
211
+ }
212
+ const packedTextMessages = this.packMessages(textBuffer);
213
+ for (const text of packedTextMessages) {
214
+ result.push({ kind: "text", text });
215
+ }
216
+ textBuffer.length = 0;
217
+ };
218
+ for (const entry of entries) {
219
+ if (entry.kind === "text") {
220
+ textBuffer.push(entry.text);
221
+ }
222
+ else {
223
+ flushTextBuffer();
224
+ result.push({ kind: "file", fileData: entry.fileData });
225
+ }
226
+ }
227
+ flushTextBuffer();
228
+ return result;
229
+ }
230
+ packMessages(messages) {
231
+ const normalizedEntries = messages
232
+ .flatMap((message) => this.splitLongText(message, TELEGRAM_MESSAGE_MAX_LENGTH))
233
+ .filter((entry) => entry.length > 0);
234
+ if (normalizedEntries.length === 0) {
235
+ return [];
236
+ }
237
+ const result = [];
238
+ let current = "";
239
+ const joinSeparator = normalizedEntries.length >= 8 ? "\n" : "\n\n";
240
+ for (const entry of normalizedEntries) {
241
+ if (!current) {
242
+ current = entry;
243
+ continue;
244
+ }
245
+ const candidate = `${current}${joinSeparator}${entry}`;
246
+ if (candidate.length <= TELEGRAM_MESSAGE_MAX_LENGTH) {
247
+ current = candidate;
248
+ continue;
249
+ }
250
+ result.push(current);
251
+ current = entry;
252
+ }
253
+ if (current) {
254
+ result.push(current);
255
+ }
256
+ return result;
257
+ }
258
+ splitLongText(text, limit) {
259
+ if (text.length <= limit) {
260
+ return [text];
261
+ }
262
+ const chunks = [];
263
+ let remaining = text;
264
+ while (remaining.length > limit) {
265
+ let splitIndex = remaining.lastIndexOf("\n", limit);
266
+ if (splitIndex <= 0 || splitIndex < Math.floor(limit * 0.5)) {
267
+ splitIndex = limit;
268
+ }
269
+ chunks.push(remaining.slice(0, splitIndex));
270
+ remaining = remaining.slice(splitIndex).replace(/^\n+/, "");
271
+ }
272
+ if (remaining.length > 0) {
273
+ chunks.push(remaining);
274
+ }
275
+ return chunks;
276
+ }
277
+ }
@@ -0,0 +1,8 @@
1
+ export const TOPIC_COLORS = {
2
+ BLUE: 0x6fb9f0,
3
+ YELLOW: 0xffd67e,
4
+ PURPLE: 0xcb86db,
5
+ GREEN: 0x8eee98,
6
+ PINK: 0xff93b2,
7
+ RED: 0xfb6f5f,
8
+ };