@grinev/opencode-telegram-bot 0.13.2 → 0.14.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.
@@ -6,18 +6,44 @@ import { t } from "../i18n/index.js";
6
6
  import { getCurrentProject } from "../settings/manager.js";
7
7
  const TELEGRAM_MESSAGE_LIMIT = 4096;
8
8
  const MARKDOWN_V2_RESERVED_CHARS = /([_\*\[\]\(\)~`>#+\-=|{}.!\\])/g;
9
- function splitText(text, maxLength) {
9
+ function endsWithOddTrailingBackslashes(text, start, end) {
10
+ let backslashCount = 0;
11
+ for (let index = end - 1; index >= start; index--) {
12
+ if (text[index] !== "\\") {
13
+ break;
14
+ }
15
+ backslashCount += 1;
16
+ }
17
+ return backslashCount % 2 === 1;
18
+ }
19
+ function resolveSplitEndIndex(text, currentIndex, maxLength, options) {
20
+ const hardLimit = Math.min(text.length, currentIndex + maxLength);
21
+ if (hardLimit >= text.length) {
22
+ return text.length;
23
+ }
24
+ let endIndex = hardLimit;
25
+ const breakPoint = text.lastIndexOf("\n", endIndex);
26
+ if (breakPoint > currentIndex) {
27
+ endIndex = breakPoint + 1;
28
+ }
29
+ if (!options?.avoidTrailingMarkdownEscape) {
30
+ return endIndex;
31
+ }
32
+ while (endIndex > currentIndex && endsWithOddTrailingBackslashes(text, currentIndex, endIndex)) {
33
+ endIndex -= 1;
34
+ }
35
+ return endIndex > currentIndex ? endIndex : hardLimit;
36
+ }
37
+ function splitText(text, maxLength, options) {
10
38
  const parts = [];
11
39
  let currentIndex = 0;
12
40
  while (currentIndex < text.length) {
13
- let endIndex = currentIndex + maxLength;
14
- if (endIndex >= text.length) {
15
- parts.push(text.slice(currentIndex));
16
- break;
17
- }
18
- const breakPoint = text.lastIndexOf("\n", endIndex);
19
- if (breakPoint > currentIndex) {
20
- endIndex = breakPoint + 1;
41
+ const endIndex = resolveSplitEndIndex(text, currentIndex, maxLength, options);
42
+ if (endIndex <= currentIndex) {
43
+ const fallbackEnd = Math.min(text.length, currentIndex + 1);
44
+ parts.push(text.slice(currentIndex, fallbackEnd));
45
+ currentIndex = fallbackEnd;
46
+ continue;
21
47
  }
22
48
  parts.push(text.slice(currentIndex, endIndex));
23
49
  currentIndex = endIndex;
@@ -192,7 +218,9 @@ export function formatSummaryWithMode(text, mode, maxLength = TELEGRAM_MESSAGE_L
192
218
  }
193
219
  if (mode === "markdown") {
194
220
  const converted = formatMarkdownForTelegram(trimmed);
195
- const convertedParts = splitText(converted, normalizedMaxLength);
221
+ const convertedParts = splitText(converted, normalizedMaxLength, {
222
+ avoidTrailingMarkdownEscape: true,
223
+ });
196
224
  for (const convertedPart of convertedParts) {
197
225
  const normalizedPart = convertedPart.trim();
198
226
  if (normalizedPart) {
@@ -343,7 +371,7 @@ export function formatToolInfo(toolInfo) {
343
371
  const todos = toolInfo.metadata.todos;
344
372
  const toolIcon = getToolIcon(tool);
345
373
  const todosList = formatTodos(todos);
346
- return `${toolIcon} ${tool} (${todos.length})\n${todosList}`;
374
+ return `${toolIcon} ${tool} (${todos.length})\n\n${todosList}`;
347
375
  }
348
376
  let details = title || getToolDetails(tool, input);
349
377
  const toolIcon = getToolIcon(tool);
@@ -393,6 +421,17 @@ export function formatToolInfo(toolInfo) {
393
421
  }
394
422
  return `${toolIcon} ${description}${tool}${detailsStr}${lineInfo}`;
395
423
  }
424
+ export function formatCompactToolInfo(toolInfo, maxLength = 64, fallback = "-") {
425
+ const formatted = formatToolInfo(toolInfo);
426
+ const normalized = formatted?.replace(/\s*\n+\s*/g, " ").trim() ?? "";
427
+ if (!normalized) {
428
+ return fallback;
429
+ }
430
+ if (normalized.length <= maxLength) {
431
+ return normalized;
432
+ }
433
+ return `${normalized.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
434
+ }
396
435
  function countLines(text) {
397
436
  return text.split("\n").length;
398
437
  }
@@ -0,0 +1,63 @@
1
+ import { formatModelDisplayName } from "../pinned/format.js";
2
+ import { t } from "../i18n/index.js";
3
+ import { formatCompactToolInfo } from "./formatter.js";
4
+ function formatToolStep(subagent) {
5
+ if (!subagent.currentTool) {
6
+ return "";
7
+ }
8
+ const toolInfo = {
9
+ sessionId: subagent.sessionId ?? subagent.parentSessionId,
10
+ messageId: subagent.cardId,
11
+ callId: subagent.cardId,
12
+ tool: subagent.currentTool,
13
+ state: {
14
+ status: "running",
15
+ input: subagent.currentToolInput ?? {},
16
+ title: subagent.currentToolTitle,
17
+ metadata: {},
18
+ time: { start: subagent.updatedAt },
19
+ },
20
+ input: subagent.currentToolInput,
21
+ title: subagent.currentToolTitle,
22
+ metadata: {},
23
+ hasFileAttachment: false,
24
+ };
25
+ const formatted = formatCompactToolInfo(toolInfo, 128, "").trim();
26
+ const firstSpaceIndex = formatted.indexOf(" ");
27
+ if (firstSpaceIndex >= 0 && formatted.slice(firstSpaceIndex + 1) === subagent.currentTool) {
28
+ return "";
29
+ }
30
+ return formatted;
31
+ }
32
+ function formatSubagentActivity(subagent) {
33
+ if (subagent.status === "completed") {
34
+ return `✅ ${t("subagent.completed")}`;
35
+ }
36
+ if (subagent.status === "error") {
37
+ const message = subagent.terminalMessage?.trim() || t("subagent.failed");
38
+ return `❌ ${message}`;
39
+ }
40
+ const toolStep = formatToolStep(subagent);
41
+ if (toolStep) {
42
+ return toolStep;
43
+ }
44
+ return `⚙️ ${t("subagent.working")}`;
45
+ }
46
+ async function formatSubagentCard(subagent) {
47
+ const modelName = formatModelDisplayName(subagent.providerID, subagent.modelID);
48
+ const lines = [
49
+ `🧩 ${t("subagent.line.task", { task: subagent.description })}`,
50
+ t("subagent.line.agent", { agent: subagent.agent }),
51
+ t("pinned.line.model", { model: modelName }),
52
+ "",
53
+ formatSubagentActivity(subagent),
54
+ ];
55
+ return lines.join("\n");
56
+ }
57
+ export async function renderSubagentCards(subagents) {
58
+ if (subagents.length === 0) {
59
+ return "";
60
+ }
61
+ const parts = await Promise.all(subagents.map((subagent) => formatSubagentCard(subagent)));
62
+ return parts.filter(Boolean).join("\n\n");
63
+ }
@@ -1,50 +1,15 @@
1
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
2
  export class ToolMessageBatcher {
15
- intervalSeconds;
16
3
  sendText;
17
4
  sendFile;
18
- queues = new Map();
19
- timers = new Map();
20
5
  sessionTasks = new Map();
21
6
  generation = 0;
22
7
  constructor(options) {
23
- this.intervalSeconds = normalizeIntervalSeconds(options.intervalSeconds);
24
8
  this.sendText = options.sendText;
25
9
  this.sendFile = options.sendFile;
26
10
  }
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
11
  enqueue(sessionId, message) {
47
- this.enqueueTextInternal(sessionId, message);
12
+ this.sendTextNow(sessionId, message, "enqueue");
48
13
  }
49
14
  sendTextNow(sessionId, message, reason) {
50
15
  const normalizedMessage = message.trim();
@@ -52,80 +17,38 @@ export class ToolMessageBatcher {
52
17
  return;
53
18
  }
54
19
  const expectedGeneration = this.generation;
55
- logger.debug(`[ToolBatcher] Sending immediate text message outside queue: session=${sessionId}, reason=${reason}`);
20
+ logger.debug(`[ToolBatcher] Sending text message: session=${sessionId}, reason=${reason}`);
56
21
  void this.enqueueTask(sessionId, () => this.sendTextSafe(sessionId, normalizedMessage, reason, expectedGeneration));
57
22
  }
58
23
  enqueueUniqueByPrefix(sessionId, message, prefix) {
59
- this.enqueueTextInternal(sessionId, message, prefix);
24
+ void prefix;
25
+ this.sendTextNow(sessionId, message, "enqueue_unique_by_prefix");
60
26
  }
61
27
  enqueueFile(sessionId, fileData) {
62
28
  if (!sessionId) {
63
29
  return;
64
30
  }
65
- if (this.intervalSeconds === 0) {
66
- const expectedGeneration = this.generation;
67
- logger.debug(`[ToolBatcher] Sending immediate file message: session=${sessionId}`);
68
- void this.enqueueTask(sessionId, () => this.sendFileSafe(sessionId, fileData, "immediate", expectedGeneration));
69
- return;
70
- }
71
- const queue = this.queues.get(sessionId) ?? [];
72
- queue.push({ kind: "file", fileData });
73
- this.queues.set(sessionId, queue);
74
- logger.debug(`[ToolBatcher] Queued file message: session=${sessionId}, queueSize=${queue.length}, interval=${this.intervalSeconds}s`);
75
- this.ensureTimer(sessionId);
31
+ const expectedGeneration = this.generation;
32
+ logger.debug(`[ToolBatcher] Sending file message: session=${sessionId}`);
33
+ void this.enqueueTask(sessionId, () => this.sendFileSafe(sessionId, fileData, "enqueue_file", expectedGeneration));
76
34
  }
77
35
  async flushSession(sessionId, reason) {
78
- await this.enqueueTask(sessionId, () => this.flushSessionInternal(sessionId, reason));
36
+ void reason;
37
+ await (this.sessionTasks.get(sessionId) ?? Promise.resolve());
79
38
  }
80
39
  async flushAll(reason) {
81
- for (const sessionId of Array.from(this.timers.keys())) {
82
- this.clearTimer(sessionId);
83
- }
84
- const sessionIds = Array.from(this.queues.keys());
85
- for (const sessionId of sessionIds) {
86
- await this.flushSession(sessionId, reason);
40
+ void reason;
41
+ for (const task of this.sessionTasks.values()) {
42
+ await task;
87
43
  }
88
44
  }
89
45
  clearSession(sessionId, reason) {
90
46
  this.generation++;
91
- this.clearTimer(sessionId);
92
- if (this.queues.delete(sessionId)) {
93
- logger.debug(`[ToolBatcher] Cleared session queue: session=${sessionId}, reason=${reason}`);
94
- }
47
+ logger.debug(`[ToolBatcher] Cleared session sends: session=${sessionId}, reason=${reason}`);
95
48
  }
96
49
  clearAll(reason) {
97
50
  this.generation++;
98
- for (const timer of this.timers.values()) {
99
- clearTimeout(timer);
100
- }
101
- const queuedSessions = this.queues.size;
102
- this.timers.clear();
103
- this.queues.clear();
104
- if (queuedSessions > 0) {
105
- logger.debug(`[ToolBatcher] Cleared all queued tool messages: sessions=${queuedSessions}, reason=${reason}`);
106
- }
107
- }
108
- clearTimer(sessionId) {
109
- const timer = this.timers.get(sessionId);
110
- if (!timer) {
111
- return;
112
- }
113
- clearTimeout(timer);
114
- this.timers.delete(sessionId);
115
- }
116
- ensureTimer(sessionId) {
117
- if (this.timers.has(sessionId)) {
118
- return;
119
- }
120
- this.restartTimer(sessionId);
121
- }
122
- restartTimer(sessionId) {
123
- this.clearTimer(sessionId);
124
- const timer = setTimeout(() => {
125
- this.timers.delete(sessionId);
126
- void this.flushSession(sessionId, "interval_elapsed");
127
- }, this.intervalSeconds * 1000);
128
- this.timers.set(sessionId, timer);
51
+ logger.debug(`[ToolBatcher] Cleared all pending tool sends: reason=${reason}`);
129
52
  }
130
53
  enqueueTask(sessionId, task) {
131
54
  const previousTask = this.sessionTasks.get(sessionId) ?? Promise.resolve();
@@ -140,53 +63,6 @@ export class ToolMessageBatcher {
140
63
  this.sessionTasks.set(sessionId, nextTask);
141
64
  return nextTask;
142
65
  }
143
- enqueueTextInternal(sessionId, message, uniquePrefix) {
144
- const normalizedMessage = message.trim();
145
- if (!sessionId || normalizedMessage.length === 0) {
146
- return;
147
- }
148
- if (this.intervalSeconds === 0) {
149
- const expectedGeneration = this.generation;
150
- logger.debug(`[ToolBatcher] Sending immediate text message: session=${sessionId}`);
151
- void this.enqueueTask(sessionId, () => this.sendTextSafe(sessionId, normalizedMessage, "immediate", expectedGeneration));
152
- return;
153
- }
154
- const normalizedPrefix = uniquePrefix?.trim();
155
- const queue = this.queues.get(sessionId) ?? [];
156
- if (normalizedPrefix) {
157
- const existingUniqueMessage = queue.find((item) => item.kind === "text" && item.text.startsWith(normalizedPrefix));
158
- if (existingUniqueMessage) {
159
- existingUniqueMessage.text = normalizedMessage;
160
- this.queues.set(sessionId, queue);
161
- logger.debug(`[ToolBatcher] Updated queued unique text message: session=${sessionId}, prefix=${normalizedPrefix}, interval=${this.intervalSeconds}s`);
162
- this.ensureTimer(sessionId);
163
- return;
164
- }
165
- }
166
- queue.push({ kind: "text", text: normalizedMessage });
167
- this.queues.set(sessionId, queue);
168
- logger.debug(`[ToolBatcher] Queued text message: session=${sessionId}, queueSize=${queue.length}, interval=${this.intervalSeconds}s`);
169
- this.ensureTimer(sessionId);
170
- }
171
- async flushSessionInternal(sessionId, reason) {
172
- const expectedGeneration = this.generation;
173
- this.clearTimer(sessionId);
174
- const queuedItems = this.queues.get(sessionId);
175
- if (!queuedItems || queuedItems.length === 0) {
176
- return;
177
- }
178
- this.queues.delete(sessionId);
179
- const flushItems = this.buildFlushItems(queuedItems);
180
- logger.debug(`[ToolBatcher] Flushing ${queuedItems.length} queued items as ${flushItems.length} Telegram sends (session=${sessionId}, reason=${reason})`);
181
- for (const item of flushItems) {
182
- if (item.kind === "text") {
183
- await this.sendTextSafe(sessionId, item.text, reason, expectedGeneration);
184
- }
185
- else {
186
- await this.sendFileSafe(sessionId, item.fileData, reason, expectedGeneration);
187
- }
188
- }
189
- }
190
66
  async sendTextSafe(sessionId, text, reason, expectedGeneration) {
191
67
  if (this.generation !== expectedGeneration) {
192
68
  logger.debug(`[ToolBatcher] Dropping stale tool text message: session=${sessionId}, reason=${reason}`);
@@ -211,75 +87,4 @@ export class ToolMessageBatcher {
211
87
  logger.error(`[ToolBatcher] Failed to send tool file message: session=${sessionId}, reason=${reason}`, err);
212
88
  }
213
89
  }
214
- buildFlushItems(entries) {
215
- const result = [];
216
- const textBuffer = [];
217
- const flushTextBuffer = () => {
218
- if (textBuffer.length === 0) {
219
- return;
220
- }
221
- const packedTextMessages = this.packMessages(textBuffer);
222
- for (const text of packedTextMessages) {
223
- result.push({ kind: "text", text });
224
- }
225
- textBuffer.length = 0;
226
- };
227
- for (const entry of entries) {
228
- if (entry.kind === "text") {
229
- textBuffer.push(entry.text);
230
- }
231
- else {
232
- flushTextBuffer();
233
- result.push({ kind: "file", fileData: entry.fileData });
234
- }
235
- }
236
- flushTextBuffer();
237
- return result;
238
- }
239
- packMessages(messages) {
240
- const normalizedEntries = messages
241
- .flatMap((message) => this.splitLongText(message, TELEGRAM_MESSAGE_MAX_LENGTH))
242
- .filter((entry) => entry.length > 0);
243
- if (normalizedEntries.length === 0) {
244
- return [];
245
- }
246
- const result = [];
247
- let current = "";
248
- for (const entry of normalizedEntries) {
249
- if (!current) {
250
- current = entry;
251
- continue;
252
- }
253
- const candidate = `${current}\n\n${entry}`;
254
- if (candidate.length <= TELEGRAM_MESSAGE_MAX_LENGTH) {
255
- current = candidate;
256
- continue;
257
- }
258
- result.push(current);
259
- current = entry;
260
- }
261
- if (current) {
262
- result.push(current);
263
- }
264
- return result;
265
- }
266
- splitLongText(text, limit) {
267
- if (text.length <= limit) {
268
- return [text];
269
- }
270
- const chunks = [];
271
- let remaining = text;
272
- while (remaining.length > limit) {
273
- let splitIndex = remaining.lastIndexOf("\n", limit);
274
- if (splitIndex <= 0 || splitIndex < Math.floor(limit * 0.5)) {
275
- splitIndex = limit;
276
- }
277
- chunks.push(remaining.slice(0, splitIndex));
278
- remaining = remaining.slice(splitIndex).replace(/^\n+/, "");
279
- }
280
- if (remaining.length > 0) {
281
- chunks.push(remaining);
282
- }
283
- return chunks;
284
- }
285
90
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grinev/opencode-telegram-bot",
3
- "version": "0.13.2",
3
+ "version": "0.14.0",
4
4
  "description": "Telegram bot client for OpenCode to run and monitor coding tasks from chat.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",