@grinev/opencode-telegram-bot 0.13.1 → 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.
@@ -3,7 +3,9 @@ import { opencodeClient } from "../opencode/client.js";
3
3
  import { getCurrentSession } from "../session/manager.js";
4
4
  import { getCurrentProject, getPinnedMessageId, setPinnedMessageId, clearPinnedMessageId, } from "../settings/manager.js";
5
5
  import { getStoredModel } from "../model/manager.js";
6
+ import { getModelContextLimit } from "../model/context-limit.js";
6
7
  import { t } from "../i18n/index.js";
8
+ import { DEFAULT_CONTEXT_LIMIT, formatContextLine, formatCostLine, formatModelDisplayName, } from "./format.js";
7
9
  class PinnedMessageManager {
8
10
  api = null;
9
11
  chatId = null;
@@ -147,6 +149,22 @@ class PinnedMessageManager {
147
149
  await this.refreshSessionTitle();
148
150
  await this.updatePinnedMessage();
149
151
  }
152
+ /**
153
+ * Update tokens in memory without triggering an API call.
154
+ * Used for intermediate (non-completed) message.updated events
155
+ * to keep pinned state in sync with keyboardManager.
156
+ */
157
+ updateTokensSilent(tokens) {
158
+ this.state.tokensUsed = tokens.input + tokens.cacheRead;
159
+ logger.debug(`[PinnedManager] Tokens updated (silent): ${this.state.tokensUsed}/${this.state.tokensLimit}`);
160
+ }
161
+ /**
162
+ * Refresh the pinned message with current in-memory state.
163
+ * Used at thinking time to push accumulated silent updates to Telegram.
164
+ */
165
+ async refresh() {
166
+ await this.updatePinnedMessage();
167
+ }
150
168
  /**
151
169
  * Called when cost info is received from SSE events
152
170
  */
@@ -162,6 +180,12 @@ class PinnedMessageManager {
162
180
  setOnKeyboardUpdate(callback) {
163
181
  this.onKeyboardUpdateCallback = callback;
164
182
  logger.debug("[PinnedManager] Keyboard update callback registered");
183
+ // Fire immediately with current state to fix race condition:
184
+ // onSessionChange may have already run before this callback was registered.
185
+ const limit = this.state.tokensLimit > 0 ? this.state.tokensLimit : this.contextLimit || 0;
186
+ if (limit > 0) {
187
+ callback(this.state.tokensUsed, limit);
188
+ }
165
189
  }
166
190
  /**
167
191
  * Get current context information
@@ -412,38 +436,13 @@ class PinnedMessageManager {
412
436
  async fetchContextLimit() {
413
437
  try {
414
438
  const model = getStoredModel();
415
- if (!model.providerID || !model.modelID) {
416
- logger.warn("[PinnedManager] No model configured, using default limit");
417
- this.contextLimit = 200000;
418
- this.state.tokensLimit = this.contextLimit;
419
- return;
420
- }
421
- const { data: providersData, error } = await opencodeClient.config.providers();
422
- if (error || !providersData) {
423
- logger.warn("[PinnedManager] Failed to fetch providers, using default limit");
424
- this.contextLimit = 200000;
425
- this.state.tokensLimit = this.contextLimit;
426
- return;
427
- }
428
- // Find the model in providers
429
- for (const provider of providersData.providers) {
430
- if (provider.id === model.providerID) {
431
- const modelInfo = provider.models[model.modelID];
432
- if (modelInfo?.limit?.context) {
433
- this.contextLimit = modelInfo.limit.context;
434
- this.state.tokensLimit = this.contextLimit;
435
- logger.debug(`[PinnedManager] Context limit: ${this.contextLimit}`);
436
- return;
437
- }
438
- }
439
- }
440
- logger.warn("[PinnedManager] Model not found in providers, using default limit");
441
- this.contextLimit = 200000;
439
+ this.contextLimit = await getModelContextLimit(model.providerID, model.modelID);
442
440
  this.state.tokensLimit = this.contextLimit;
441
+ logger.debug(`[PinnedManager] Context limit: ${this.contextLimit}`);
443
442
  }
444
443
  catch (err) {
445
444
  logger.error("[PinnedManager] Error fetching context limit:", err);
446
- this.contextLimit = 200000;
445
+ this.contextLimit = DEFAULT_CONTEXT_LIMIT;
447
446
  this.state.tokensLimit = this.contextLimit;
448
447
  }
449
448
  }
@@ -451,28 +450,16 @@ class PinnedMessageManager {
451
450
  * Format the pinned message text
452
451
  */
453
452
  formatMessage() {
454
- const percentage = this.state.tokensLimit > 0
455
- ? Math.round((this.state.tokensUsed / this.state.tokensLimit) * 100)
456
- : 0;
457
- const tokensFormatted = this.formatTokenCount(this.state.tokensUsed);
458
- const limitFormatted = this.formatTokenCount(this.state.tokensLimit);
459
- // Get current model info
460
453
  const currentModel = getStoredModel();
461
- const modelName = currentModel.providerID && currentModel.modelID
462
- ? `${currentModel.providerID}/${currentModel.modelID}`
463
- : t("pinned.unknown");
454
+ const modelName = formatModelDisplayName(currentModel.providerID, currentModel.modelID);
464
455
  const lines = [
465
456
  `${this.state.sessionTitle}`,
466
457
  t("pinned.line.project", { project: this.state.projectName }),
467
458
  t("pinned.line.model", { model: modelName }),
468
- t("pinned.line.context", {
469
- used: tokensFormatted,
470
- limit: limitFormatted,
471
- percent: percentage,
472
- }),
459
+ formatContextLine(this.state.tokensUsed, this.state.tokensLimit),
473
460
  ];
474
461
  if (this.state.cost !== undefined && this.state.cost !== null) {
475
- lines.push(t("pinned.line.cost", { cost: `$${this.state.cost.toFixed(2)}` }));
462
+ lines.push(formatCostLine(this.state.cost));
476
463
  }
477
464
  if (this.state.changedFiles.length > 0) {
478
465
  const maxFiles = 10;
@@ -496,18 +483,6 @@ class PinnedMessageManager {
496
483
  }
497
484
  return lines.join("\n");
498
485
  }
499
- /**
500
- * Format token count (e.g., 150000 -> "150K")
501
- */
502
- formatTokenCount(count) {
503
- if (count >= 1000000) {
504
- return `${(count / 1000000).toFixed(1)}M`;
505
- }
506
- else if (count >= 1000) {
507
- return `${Math.round(count / 1000)}K`;
508
- }
509
- return count.toString();
510
- }
511
486
  /**
512
487
  * Create and pin a new status message
513
488
  */
@@ -1,4 +1,5 @@
1
1
  import { config } from "../config.js";
2
+ import { escapePlainTextForTelegramMarkdownV2, formatSummaryWithMode, } from "../summary/formatter.js";
2
3
  import { t } from "../i18n/index.js";
3
4
  import { logger } from "../utils/logger.js";
4
5
  import { safeBackgroundTask } from "../utils/safe-background-task.js";
@@ -7,28 +8,30 @@ import { executeScheduledTask } from "./executor.js";
7
8
  import { foregroundSessionState } from "./foreground-state.js";
8
9
  import { computeNextRunAt, isTaskDue } from "./next-run.js";
9
10
  import { getScheduledTask, listScheduledTasks, removeScheduledTask, replaceScheduledTasks, updateScheduledTask, } from "./store.js";
10
- const TELEGRAM_MESSAGE_LIMIT = 4096;
11
11
  const MAX_TIMER_DELAY_MS = 2_147_483_647;
12
+ const TELEGRAM_MESSAGE_LIMIT = 4096;
12
13
  const TASK_DESCRIPTION_PREVIEW_LENGTH = 64;
13
14
  const RESTART_INTERRUPTED_ERROR = "Interrupted by bot restart during scheduled task execution.";
14
- function splitTelegramText(text) {
15
- if (text.length <= TELEGRAM_MESSAGE_LIMIT) {
16
- return [text];
15
+ function getScheduledTaskDeliveryFormat() {
16
+ return config.bot.messageFormatMode === "markdown" ? "markdown_v2" : "raw";
17
+ }
18
+ function buildScheduledTaskSuccessMessageParts(delivery) {
19
+ if (!delivery.resultText) {
20
+ return [delivery.notificationText];
17
21
  }
18
- const parts = [];
19
- let remaining = text;
20
- while (remaining.length > TELEGRAM_MESSAGE_LIMIT) {
21
- let splitIndex = remaining.lastIndexOf("\n", TELEGRAM_MESSAGE_LIMIT);
22
- if (splitIndex <= 0 || splitIndex < Math.floor(TELEGRAM_MESSAGE_LIMIT * 0.5)) {
23
- splitIndex = TELEGRAM_MESSAGE_LIMIT;
24
- }
25
- parts.push(remaining.slice(0, splitIndex).trim());
26
- remaining = remaining.slice(splitIndex).trimStart();
22
+ if (config.bot.messageFormatMode !== "markdown") {
23
+ return formatSummaryWithMode(`${delivery.notificationText}\n\n${delivery.resultText}`, config.bot.messageFormatMode);
24
+ }
25
+ const header = escapePlainTextForTelegramMarkdownV2(delivery.notificationText);
26
+ const resultParts = formatSummaryWithMode(delivery.resultText, config.bot.messageFormatMode);
27
+ if (resultParts.length === 0) {
28
+ return [header];
27
29
  }
28
- if (remaining.trim()) {
29
- parts.push(remaining.trim());
30
+ const firstPart = `${header}\n\n${resultParts[0]}`;
31
+ if (firstPart.length <= TELEGRAM_MESSAGE_LIMIT) {
32
+ return [firstPart, ...resultParts.slice(1)];
30
33
  }
31
- return parts;
34
+ return [header, ...resultParts];
32
35
  }
33
36
  function normalizeTaskPrompt(prompt) {
34
37
  const normalized = prompt.replace(/\s+/g, " ").trim();
@@ -44,10 +47,10 @@ function buildSuccessDelivery(task, runAt, resultText) {
44
47
  prompt: task.prompt,
45
48
  runAt,
46
49
  status: "success",
47
- messageText: t("task.run.success", {
50
+ notificationText: t("task.run.success", {
48
51
  description: normalizeTaskPrompt(task.prompt),
49
- result: resultText,
50
52
  }),
53
+ resultText,
51
54
  };
52
55
  }
53
56
  function buildErrorDelivery(task, runAt, errorMessage) {
@@ -57,7 +60,7 @@ function buildErrorDelivery(task, runAt, errorMessage) {
57
60
  prompt: task.prompt,
58
61
  runAt,
59
62
  status: "error",
60
- messageText: t("task.run.error", {
63
+ notificationText: t("task.run.error", {
61
64
  description: normalizeTaskPrompt(task.prompt),
62
65
  error: errorMessage,
63
66
  }),
@@ -342,13 +345,16 @@ export class ScheduledTaskRuntime {
342
345
  return false;
343
346
  }
344
347
  try {
345
- const messageParts = splitTelegramText(delivery.messageText);
348
+ const messageParts = delivery.status === "success"
349
+ ? buildScheduledTaskSuccessMessageParts(delivery)
350
+ : [delivery.notificationText];
351
+ const format = delivery.status === "success" ? getScheduledTaskDeliveryFormat() : "raw";
346
352
  for (const part of messageParts) {
347
353
  await sendBotText({
348
354
  api: this.botApi,
349
355
  chatId: this.chatId,
350
356
  text: part,
351
- format: "raw",
357
+ format,
352
358
  });
353
359
  }
354
360
  return true;