@grinev/opencode-telegram-bot 0.12.1 → 0.13.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.
@@ -26,12 +26,12 @@ function countDiffChangesFromText(text) {
26
26
  }
27
27
  class SummaryAggregator {
28
28
  currentSessionId = null;
29
- currentMessageParts = new Map();
30
- pendingParts = new Map();
29
+ textMessageStates = new Map();
31
30
  messages = new Map();
32
31
  messageCount = 0;
33
32
  lastUpdated = 0;
34
33
  onCompleteCallback = null;
34
+ onPartialCallback = null;
35
35
  onToolCallback = null;
36
36
  onToolFileCallback = null;
37
37
  onQuestionCallback = null;
@@ -48,9 +48,11 @@ class SummaryAggregator {
48
48
  onClearedCallback = null;
49
49
  processedToolStates = new Set();
50
50
  thinkingFiredForMessages = new Set();
51
+ knownTextPartIds = new Map();
51
52
  bot = null;
52
53
  chatId = null;
53
54
  typingTimer = null;
55
+ typingIndicatorEnabled = true;
54
56
  partHashes = new Map();
55
57
  setBotAndChatId(bot, chatId) {
56
58
  this.bot = bot;
@@ -59,6 +61,9 @@ class SummaryAggregator {
59
61
  setOnComplete(callback) {
60
62
  this.onCompleteCallback = callback;
61
63
  }
64
+ setOnPartial(callback) {
65
+ this.onPartialCallback = callback;
66
+ }
62
67
  setOnTool(callback) {
63
68
  this.onToolCallback = callback;
64
69
  }
@@ -101,7 +106,16 @@ class SummaryAggregator {
101
106
  setOnCleared(callback) {
102
107
  this.onClearedCallback = callback;
103
108
  }
109
+ setTypingIndicatorEnabled(enabled) {
110
+ this.typingIndicatorEnabled = enabled;
111
+ if (!enabled) {
112
+ this.stopTypingIndicator();
113
+ }
114
+ }
104
115
  startTypingIndicator() {
116
+ if (!this.typingIndicatorEnabled) {
117
+ return;
118
+ }
105
119
  if (this.typingTimer) {
106
120
  return;
107
121
  }
@@ -122,6 +136,11 @@ class SummaryAggregator {
122
136
  }
123
137
  }
124
138
  processEvent(event) {
139
+ const eventType = event.type;
140
+ if (eventType === "message.part.delta") {
141
+ this.handleMessagePartDelta(event);
142
+ return;
143
+ }
125
144
  // Log all question-related events for debugging
126
145
  if (event.type.startsWith("question.")) {
127
146
  logger.info(`[Aggregator] Question event: ${event.type}`, JSON.stringify(event.properties, null, 2));
@@ -181,10 +200,10 @@ class SummaryAggregator {
181
200
  clear() {
182
201
  this.stopTypingIndicator();
183
202
  this.currentSessionId = null;
184
- this.currentMessageParts.clear();
185
- this.pendingParts.clear();
203
+ this.textMessageStates.clear();
186
204
  this.messages.clear();
187
205
  this.partHashes.clear();
206
+ this.knownTextPartIds.clear();
188
207
  this.processedToolStates.clear();
189
208
  this.thinkingFiredForMessages.clear();
190
209
  this.messageCount = 0;
@@ -206,21 +225,26 @@ class SummaryAggregator {
206
225
  const messageID = info.id;
207
226
  this.messages.set(messageID, { role: info.role });
208
227
  if (info.role === "assistant") {
209
- if (!this.currentMessageParts.has(messageID)) {
210
- this.currentMessageParts.set(messageID, []);
228
+ if (!this.textMessageStates.has(messageID)) {
229
+ this.textMessageStates.set(messageID, {
230
+ orderedPartIds: [],
231
+ partTexts: new Map(),
232
+ optimisticUpdateCount: 0,
233
+ });
211
234
  this.messageCount++;
212
235
  this.startTypingIndicator();
213
236
  }
214
- const pending = this.pendingParts.get(messageID) || [];
215
- const current = this.currentMessageParts.get(messageID) || [];
216
- this.currentMessageParts.set(messageID, [...current, ...pending]);
217
- this.pendingParts.delete(messageID);
237
+ const textState = this.getOrCreateTextMessageState(messageID);
218
238
  const assistantMessage = info;
219
239
  const time = assistantMessage.time;
220
- if (time?.completed) {
221
- const parts = this.currentMessageParts.get(messageID) || [];
222
- const lastPart = parts[parts.length - 1] || "";
223
- logger.debug(`[Aggregator] Message part completed: messageId=${messageID}, textLength=${lastPart.length}, totalParts=${parts.length}, session=${this.currentSessionId}`);
240
+ const isCompleted = Boolean(time?.completed);
241
+ const messageText = this.getCombinedMessageText(messageID);
242
+ if (!isCompleted && textState.optimisticUpdateCount === 1) {
243
+ this.emitPartialText(info.sessionID, messageID, messageText);
244
+ }
245
+ if (isCompleted) {
246
+ const finalText = messageText;
247
+ logger.debug(`[Aggregator] Message part completed: messageId=${messageID}, textLength=${finalText.length}, totalParts=${textState.orderedPartIds.length}, session=${this.currentSessionId}`);
224
248
  // Extract and report tokens BEFORE onComplete so keyboard context is updated
225
249
  const assistantInfo = info;
226
250
  if (this.onTokensCallback && assistantInfo.tokens) {
@@ -240,14 +264,15 @@ class SummaryAggregator {
240
264
  logger.debug(`[Aggregator] Cost: $${assistantInfo.cost.toFixed(2)}`);
241
265
  this.onCostCallback(assistantInfo.cost);
242
266
  }
243
- if (this.onCompleteCallback && lastPart.length > 0) {
244
- this.onCompleteCallback(this.currentSessionId, lastPart);
267
+ if (this.onCompleteCallback && finalText.length > 0) {
268
+ this.onCompleteCallback(this.currentSessionId, messageID, finalText);
245
269
  }
246
- this.currentMessageParts.delete(messageID);
270
+ this.textMessageStates.delete(messageID);
247
271
  this.messages.delete(messageID);
248
272
  this.partHashes.delete(messageID);
249
- logger.debug(`[Aggregator] Message completed cleanup: remaining messages=${this.currentMessageParts.size}`);
250
- if (this.currentMessageParts.size === 0) {
273
+ this.knownTextPartIds.delete(messageID);
274
+ logger.debug(`[Aggregator] Message completed cleanup: remaining messages=${this.textMessageStates.size}`);
275
+ if (this.textMessageStates.size === 0) {
251
276
  logger.debug("[Aggregator] No more active messages, stopping typing indicator");
252
277
  this.stopTypingIndicator();
253
278
  }
@@ -262,6 +287,18 @@ class SummaryAggregator {
262
287
  }
263
288
  const messageID = part.messageID;
264
289
  const messageInfo = this.messages.get(messageID);
290
+ if (part.type === "text") {
291
+ this.registerKnownTextPart(messageID, part.id);
292
+ this.registerTextPart(messageID, part.id);
293
+ }
294
+ const deltaFromUpdated = event.properties.delta;
295
+ if (part.type === "text" &&
296
+ typeof deltaFromUpdated === "string" &&
297
+ deltaFromUpdated.length > 0) {
298
+ this.applyTextDelta(part.sessionID, messageID, part.id, deltaFromUpdated, part.text);
299
+ this.lastUpdated = Date.now();
300
+ return;
301
+ }
265
302
  if (part.type === "reasoning") {
266
303
  // Fire the thinking callback once per message on the first reasoning part.
267
304
  // This is the signal that the model is actually doing extended thinking.
@@ -277,29 +314,23 @@ class SummaryAggregator {
277
314
  }
278
315
  }
279
316
  else if (part.type === "text" && "text" in part && part.text) {
280
- const partHash = this.hashString(part.text);
281
- if (!this.partHashes.has(messageID)) {
282
- this.partHashes.set(messageID, new Set());
283
- }
284
- const hashes = this.partHashes.get(messageID);
285
- if (hashes.has(partHash)) {
317
+ const wasUpdated = messageInfo && messageInfo.role === "assistant"
318
+ ? this.setTextPartSnapshot(messageID, part.id, part.text)
319
+ : this.setOptimisticTextSnapshot(messageID, part.id, part.text);
320
+ if (!wasUpdated) {
286
321
  return;
287
322
  }
288
- hashes.add(partHash);
323
+ const fullText = this.getCombinedMessageText(messageID);
289
324
  if (messageInfo && messageInfo.role === "assistant") {
290
- if (!this.currentMessageParts.has(messageID)) {
291
- this.currentMessageParts.set(messageID, []);
292
- this.startTypingIndicator();
293
- }
294
- const parts = this.currentMessageParts.get(messageID);
295
- parts.push(part.text);
325
+ this.startTypingIndicator();
326
+ this.emitPartialText(part.sessionID, messageID, fullText);
296
327
  }
297
328
  else {
298
- if (!this.pendingParts.has(messageID)) {
299
- this.pendingParts.set(messageID, []);
329
+ const state = this.getOrCreateTextMessageState(messageID);
330
+ state.optimisticUpdateCount++;
331
+ if (state.optimisticUpdateCount >= 2) {
332
+ this.emitPartialText(part.sessionID, messageID, fullText);
300
333
  }
301
- const pending = this.pendingParts.get(messageID);
302
- pending.push(part.text);
303
334
  }
304
335
  }
305
336
  else if (part.type === "tool") {
@@ -360,6 +391,125 @@ class SummaryAggregator {
360
391
  }
361
392
  this.lastUpdated = Date.now();
362
393
  }
394
+ handleMessagePartDelta(event) {
395
+ const part = event.properties.part;
396
+ const sessionID = part?.sessionID || event.properties.sessionID;
397
+ const messageID = part?.messageID || event.properties.messageID;
398
+ const partID = part?.id || event.properties.partID || "text";
399
+ const partType = part?.type || event.properties.type;
400
+ const delta = event.properties.delta;
401
+ if (!sessionID || !messageID || typeof delta !== "string" || delta.length === 0) {
402
+ return;
403
+ }
404
+ if (partType && partType !== "text") {
405
+ return;
406
+ }
407
+ if (partType === "text") {
408
+ this.registerKnownTextPart(messageID, partID);
409
+ this.registerTextPart(messageID, partID);
410
+ }
411
+ else {
412
+ const knownTextIds = this.knownTextPartIds.get(messageID);
413
+ const isKnownTextPart = knownTextIds?.has(partID) ?? false;
414
+ const thinkingFired = this.thinkingFiredForMessages.has(messageID);
415
+ if (thinkingFired && !isKnownTextPart) {
416
+ return;
417
+ }
418
+ if (!thinkingFired && !isKnownTextPart) {
419
+ this.registerKnownTextPart(messageID, partID);
420
+ this.registerTextPart(messageID, partID);
421
+ }
422
+ }
423
+ this.applyTextDelta(sessionID, messageID, partID, delta, part?.text);
424
+ }
425
+ applyTextDelta(sessionID, messageID, partID, delta, fullTextHint) {
426
+ if (sessionID !== this.currentSessionId) {
427
+ return;
428
+ }
429
+ this.registerTextPart(messageID, partID);
430
+ const state = this.getOrCreateTextMessageState(messageID);
431
+ const previous = state.partTexts.get(partID) || "";
432
+ let accumulated = `${previous}${delta}`;
433
+ if (typeof fullTextHint === "string" && fullTextHint.length > accumulated.length) {
434
+ accumulated = fullTextHint;
435
+ }
436
+ state.partTexts.set(partID, accumulated);
437
+ const combined = this.getCombinedMessageText(messageID);
438
+ if (!combined.trim()) {
439
+ return;
440
+ }
441
+ this.startTypingIndicator();
442
+ this.emitPartialText(sessionID, messageID, combined);
443
+ }
444
+ emitPartialText(sessionId, messageId, messageText) {
445
+ if (!this.onPartialCallback || !messageText.trim()) {
446
+ return;
447
+ }
448
+ try {
449
+ this.onPartialCallback(sessionId, messageId, messageText);
450
+ }
451
+ catch (err) {
452
+ logger.error("[Aggregator] Error in partial callback:", err);
453
+ }
454
+ }
455
+ getOrCreateTextMessageState(messageID) {
456
+ const existing = this.textMessageStates.get(messageID);
457
+ if (existing) {
458
+ return existing;
459
+ }
460
+ const state = {
461
+ orderedPartIds: [],
462
+ partTexts: new Map(),
463
+ optimisticUpdateCount: 0,
464
+ };
465
+ this.textMessageStates.set(messageID, state);
466
+ return state;
467
+ }
468
+ registerKnownTextPart(messageID, partID) {
469
+ if (!this.knownTextPartIds.has(messageID)) {
470
+ this.knownTextPartIds.set(messageID, new Set());
471
+ }
472
+ this.knownTextPartIds.get(messageID).add(partID);
473
+ }
474
+ registerTextPart(messageID, partID) {
475
+ const state = this.getOrCreateTextMessageState(messageID);
476
+ if (!state.orderedPartIds.includes(partID)) {
477
+ state.orderedPartIds.push(partID);
478
+ }
479
+ }
480
+ setTextPartSnapshot(messageID, partID, text) {
481
+ const normalized = text;
482
+ const partHash = this.hashString(`${partID}\n${normalized}`);
483
+ if (!this.partHashes.has(messageID)) {
484
+ this.partHashes.set(messageID, new Set());
485
+ }
486
+ const hashes = this.partHashes.get(messageID);
487
+ if (hashes.has(partHash)) {
488
+ return false;
489
+ }
490
+ hashes.add(partHash);
491
+ this.registerTextPart(messageID, partID);
492
+ const state = this.getOrCreateTextMessageState(messageID);
493
+ state.partTexts.set(partID, normalized);
494
+ return true;
495
+ }
496
+ setOptimisticTextSnapshot(messageID, partID, text) {
497
+ const wasUpdated = this.setTextPartSnapshot(messageID, partID, text);
498
+ if (!wasUpdated) {
499
+ return false;
500
+ }
501
+ const state = this.getOrCreateTextMessageState(messageID);
502
+ state.orderedPartIds = [partID];
503
+ state.partTexts = new Map([[partID, text]]);
504
+ return true;
505
+ }
506
+ getCombinedMessageText(messageID) {
507
+ const state = this.textMessageStates.get(messageID);
508
+ if (!state) {
509
+ return "";
510
+ }
511
+ return state.orderedPartIds.map((partID) => state.partTexts.get(partID) || "").join("");
512
+ }
363
513
  prepareToolFileContext(tool, input, title, metadata) {
364
514
  if (tool === "write" && input) {
365
515
  const filePath = typeof input.filePath === "string" ? normalizePathForDisplay(input.filePath) : "";
@@ -144,11 +144,13 @@ function formatMarkdownForTelegram(text) {
144
144
  return text;
145
145
  }
146
146
  }
147
- export function formatSummaryWithMode(text, mode) {
147
+ export function formatSummaryWithMode(text, mode, maxLength = TELEGRAM_MESSAGE_LIMIT) {
148
148
  if (!text || text.trim().length === 0) {
149
149
  return [];
150
150
  }
151
- const parts = splitText(text, TELEGRAM_MESSAGE_LIMIT);
151
+ const normalizedMaxLength = Math.max(1, Math.floor(maxLength));
152
+ const rawTextLimit = mode === "raw" ? Math.max(1, normalizedMaxLength - "```\n\n```".length) : normalizedMaxLength;
153
+ const parts = splitText(text, rawTextLimit);
152
154
  const formattedParts = [];
153
155
  for (const part of parts) {
154
156
  const trimmed = part.trim();
@@ -157,7 +159,7 @@ export function formatSummaryWithMode(text, mode) {
157
159
  }
158
160
  if (mode === "markdown") {
159
161
  const converted = formatMarkdownForTelegram(trimmed);
160
- const convertedParts = splitText(converted, TELEGRAM_MESSAGE_LIMIT);
162
+ const convertedParts = splitText(converted, normalizedMaxLength);
161
163
  for (const convertedPart of convertedParts) {
162
164
  const normalizedPart = convertedPart.trim();
163
165
  if (normalizedPart) {
@@ -46,6 +46,15 @@ export class ToolMessageBatcher {
46
46
  enqueue(sessionId, message) {
47
47
  this.enqueueTextInternal(sessionId, message);
48
48
  }
49
+ sendTextNow(sessionId, message, reason) {
50
+ const normalizedMessage = message.trim();
51
+ if (!sessionId || normalizedMessage.length === 0) {
52
+ return;
53
+ }
54
+ const expectedGeneration = this.generation;
55
+ logger.debug(`[ToolBatcher] Sending immediate text message outside queue: session=${sessionId}, reason=${reason}`);
56
+ void this.enqueueTask(sessionId, () => this.sendTextSafe(sessionId, normalizedMessage, reason, expectedGeneration));
57
+ }
49
58
  enqueueUniqueByPrefix(sessionId, message, prefix) {
50
59
  this.enqueueTextInternal(sessionId, message, prefix);
51
60
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grinev/opencode-telegram-bot",
3
- "version": "0.12.1",
3
+ "version": "0.13.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",