@grinev/opencode-telegram-bot 0.19.1 → 0.19.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.
@@ -3,9 +3,9 @@ import { readFile } from "node:fs/promises";
3
3
  import { cleanupBotRuntime, createBot } from "../bot/index.js";
4
4
  import { config } from "../config.js";
5
5
  import { opencodeAutoRestartService } from "../opencode/auto-restart.js";
6
+ import { refreshSessionCacheIfOpencodeReady } from "../opencode/ready-refresh.js";
6
7
  import { loadSettings } from "../settings/manager.js";
7
8
  import { scheduledTaskRuntime } from "../scheduled-task/runtime.js";
8
- import { warmupSessionDirectoryCache } from "../session/cache-manager.js";
9
9
  import { reconcileStoredModelSelection } from "../model/manager.js";
10
10
  import { getRuntimeMode } from "../runtime/mode.js";
11
11
  import { getRuntimePaths } from "../runtime/paths.js";
@@ -40,8 +40,10 @@ export async function startBotApp() {
40
40
  logger.debug(`[Runtime] Application start mode: ${mode}`);
41
41
  await loadSettings();
42
42
  await reconcileStoredModelSelection();
43
- await opencodeAutoRestartService.start();
44
- await warmupSessionDirectoryCache();
43
+ const autoRestartHandledStartup = await opencodeAutoRestartService.start();
44
+ if (!autoRestartHandledStartup) {
45
+ await refreshSessionCacheIfOpencodeReady("startup");
46
+ }
45
47
  const bot = createBot();
46
48
  await scheduledTaskRuntime.initialize(bot);
47
49
  let shutdownStarted = false;
@@ -1,6 +1,7 @@
1
1
  import { config } from "../../config.js";
2
2
  import { opencodeClient } from "../../opencode/client.js";
3
3
  import { resolveLocalOpencodeTarget, startLocalOpencodeServer } from "../../opencode/process.js";
4
+ import { refreshSessionCacheAfterOpencodeReady } from "../../opencode/ready-refresh.js";
4
5
  import { logger } from "../../utils/logger.js";
5
6
  import { t } from "../../i18n/index.js";
6
7
  import { editBotText } from "../utils/telegram-text.js";
@@ -42,6 +43,7 @@ export async function opencodeStartCommand(ctx) {
42
43
  const { data, error } = await opencodeClient.global.health();
43
44
  if (!error && data?.healthy) {
44
45
  await ctx.reply(t("opencode_start.already_running", { version: data.version || t("common.unknown") }));
46
+ await refreshSessionCacheAfterOpencodeReady("opencode_start_already_running");
45
47
  return;
46
48
  }
47
49
  }
@@ -88,6 +90,7 @@ export async function opencodeStartCommand(ctx) {
88
90
  }),
89
91
  });
90
92
  logger.info(`[Bot] OpenCode server started successfully, PID=${pid}, port=${localTarget.port}`);
93
+ await refreshSessionCacheAfterOpencodeReady("opencode_start_success");
91
94
  }
92
95
  catch (err) {
93
96
  logger.error("[Bot] Error in /opencode-start command:", err);
@@ -6,6 +6,7 @@ const MARKDOWN_PARSE_ERROR_MARKERS = [
6
6
  "entity beginning",
7
7
  "bad request: can't parse",
8
8
  ];
9
+ const TELEGRAM_ENTITY_URL_ERROR_MARKERS = ["entity url", "wrong http url", "url host is empty"];
9
10
  const MARKDOWN_V2_RESERVED_CHARS = new Set([
10
11
  "_",
11
12
  "*",
@@ -91,6 +92,13 @@ export function isTelegramMarkdownParseError(error) {
91
92
  }
92
93
  return MARKDOWN_PARSE_ERROR_MARKERS.some((marker) => errorText.includes(marker));
93
94
  }
95
+ export function isTelegramEntityUrlError(error) {
96
+ const errorText = getErrorText(error);
97
+ if (!errorText) {
98
+ return false;
99
+ }
100
+ return TELEGRAM_ENTITY_URL_ERROR_MARKERS.some((marker) => errorText.includes(marker));
101
+ }
94
102
  export async function sendMessageWithMarkdownFallback({ api, chatId, text, rawFallbackText, options, parseMode, }) {
95
103
  if (!parseMode) {
96
104
  return api.sendMessage(chatId, text, options);
@@ -104,10 +112,7 @@ export async function sendMessageWithMarkdownFallback({ api, chatId, text, rawFa
104
112
  return await api.sendMessage(chatId, text, markdownOptions);
105
113
  }
106
114
  catch (error) {
107
- if (!isTelegramMarkdownParseError(error)) {
108
- throw error;
109
- }
110
- if (parseMode === "MarkdownV2") {
115
+ if (parseMode === "MarkdownV2" && isTelegramMarkdownParseError(error)) {
111
116
  const escapedText = escapeTelegramMarkdownV2(text);
112
117
  if (escapedText !== text) {
113
118
  logger.warn("[Bot] Markdown parse failed, retrying message with escaped MarkdownV2", error);
@@ -115,15 +120,12 @@ export async function sendMessageWithMarkdownFallback({ api, chatId, text, rawFa
115
120
  return await api.sendMessage(chatId, escapedText, markdownOptions);
116
121
  }
117
122
  catch (escapedError) {
118
- if (!isTelegramMarkdownParseError(escapedError)) {
119
- throw escapedError;
120
- }
121
- logger.warn("[Bot] Escaped Markdown parse failed, retrying message in raw mode", escapedError);
123
+ logger.warn("[Bot] Escaped Markdown send failed, retrying message in raw mode", escapedError);
122
124
  return api.sendMessage(chatId, fallbackText, stripMarkdownFormattingOptions(options));
123
125
  }
124
126
  }
125
127
  }
126
- logger.warn("[Bot] Markdown parse failed, retrying message in raw mode", error);
128
+ logger.warn("[Bot] Formatted message send failed, retrying message in raw mode", error);
127
129
  return api.sendMessage(chatId, fallbackText, stripMarkdownFormattingOptions(options));
128
130
  }
129
131
  }
@@ -140,10 +142,7 @@ export async function editMessageWithMarkdownFallback({ api, chatId, messageId,
140
142
  return await api.editMessageText(chatId, messageId, text, markdownOptions);
141
143
  }
142
144
  catch (error) {
143
- if (!isTelegramMarkdownParseError(error)) {
144
- throw error;
145
- }
146
- if (parseMode === "MarkdownV2") {
145
+ if (parseMode === "MarkdownV2" && isTelegramMarkdownParseError(error)) {
147
146
  const escapedText = escapeTelegramMarkdownV2(text);
148
147
  if (escapedText !== text) {
149
148
  logger.warn("[Bot] Markdown parse failed, retrying edited message with escaped MarkdownV2", error);
@@ -151,15 +150,12 @@ export async function editMessageWithMarkdownFallback({ api, chatId, messageId,
151
150
  return await api.editMessageText(chatId, messageId, escapedText, markdownOptions);
152
151
  }
153
152
  catch (escapedError) {
154
- if (!isTelegramMarkdownParseError(escapedError)) {
155
- throw escapedError;
156
- }
157
- logger.warn("[Bot] Escaped Markdown parse failed, retrying edited message in raw mode", escapedError);
153
+ logger.warn("[Bot] Escaped Markdown edit failed, retrying edited message in raw mode", escapedError);
158
154
  return api.editMessageText(chatId, messageId, fallbackText, stripMarkdownFormattingOptions(options));
159
155
  }
160
156
  }
161
157
  }
162
- logger.warn("[Bot] Markdown parse failed, retrying edited message in raw mode", error);
158
+ logger.warn("[Bot] Formatted message edit failed, retrying edited message in raw mode", error);
163
159
  return api.editMessageText(chatId, messageId, fallbackText, stripMarkdownFormattingOptions(options));
164
160
  }
165
161
  }
@@ -1,5 +1,5 @@
1
1
  import { logger } from "../../utils/logger.js";
2
- import { editMessageWithMarkdownFallback, isTelegramMarkdownParseError, sendMessageWithMarkdownFallback, } from "./send-with-markdown-fallback.js";
2
+ import { editMessageWithMarkdownFallback, sendMessageWithMarkdownFallback, } from "./send-with-markdown-fallback.js";
3
3
  function resolveParseMode(format) {
4
4
  if (format === "markdown_v2") {
5
5
  return "MarkdownV2";
@@ -56,10 +56,7 @@ export async function sendRenderedBotPart({ api, chatId, part, options, }) {
56
56
  };
57
57
  }
58
58
  catch (error) {
59
- if (!isTelegramMarkdownParseError(error)) {
60
- throw error;
61
- }
62
- logger.warn("[Bot] Entity payload rejected, retrying assistant message part in raw mode", error);
59
+ logger.warn("[Bot] Entity payload send failed, retrying assistant message part in raw mode", error);
63
60
  const sentMessage = await api.sendMessage(chatId, part.fallbackText, rawOptions);
64
61
  logger.debug("[Bot] Assistant message part sent in raw fallback mode", {
65
62
  fallbackTextLength: part.fallbackText.length,
@@ -95,10 +92,7 @@ export async function editRenderedBotPart({ api, chatId, messageId, part, option
95
92
  };
96
93
  }
97
94
  catch (error) {
98
- if (!isTelegramMarkdownParseError(error)) {
99
- throw error;
100
- }
101
- logger.warn("[Bot] Entity payload rejected, retrying assistant edit part in raw mode", error);
95
+ logger.warn("[Bot] Entity payload edit failed, retrying assistant edit part in raw mode", error);
102
96
  await api.editMessageText(chatId, messageId, part.fallbackText, rawOptions);
103
97
  logger.debug("[Bot] Assistant edit part applied in raw fallback mode", {
104
98
  messageId,
@@ -4,6 +4,12 @@ import { opencodeClient } from "../opencode/client.js";
4
4
  import { logger } from "../utils/logger.js";
5
5
  import path from "node:path";
6
6
  const MODEL_CATALOG_CACHE_TTL_MS = 10 * 60 * 1000;
7
+ const SERVER_UNAVAILABLE_ERROR_MARKERS = [
8
+ "fetch failed",
9
+ "econnrefused",
10
+ "connection refused",
11
+ "connect refused",
12
+ ];
7
13
  let cachedValidModelKeys = null;
8
14
  let modelCatalogCacheExpiresAt = 0;
9
15
  let modelCatalogFetchInFlight = null;
@@ -34,8 +40,63 @@ function filterModelsByCatalog(models, validModelKeys) {
34
40
  }
35
41
  return models.filter((model) => validModelKeys.has(getModelKey(model.providerID, model.modelID)));
36
42
  }
37
- async function getValidModelKeys() {
38
- if (cachedValidModelKeys && Date.now() < modelCatalogCacheExpiresAt) {
43
+ function hasServerUnavailableMarker(value) {
44
+ const lower = value.toLowerCase();
45
+ return SERVER_UNAVAILABLE_ERROR_MARKERS.some((marker) => lower.includes(marker));
46
+ }
47
+ function isServerUnavailableError(error) {
48
+ const queue = [error];
49
+ const seen = new Set();
50
+ while (queue.length > 0) {
51
+ const current = queue.pop();
52
+ if (!current || seen.has(current)) {
53
+ continue;
54
+ }
55
+ seen.add(current);
56
+ if (typeof current === "string") {
57
+ if (hasServerUnavailableMarker(current)) {
58
+ return true;
59
+ }
60
+ continue;
61
+ }
62
+ if (current instanceof Error) {
63
+ if (hasServerUnavailableMarker(`${current.name}: ${current.message}`)) {
64
+ return true;
65
+ }
66
+ const errorWithCause = current;
67
+ if (errorWithCause.cause) {
68
+ queue.push(errorWithCause.cause);
69
+ }
70
+ continue;
71
+ }
72
+ if (typeof current === "object") {
73
+ const value = current;
74
+ if (typeof value.code === "string" && hasServerUnavailableMarker(value.code)) {
75
+ return true;
76
+ }
77
+ if (typeof value.message === "string" && hasServerUnavailableMarker(value.message)) {
78
+ return true;
79
+ }
80
+ if (value.cause) {
81
+ queue.push(value.cause);
82
+ }
83
+ }
84
+ }
85
+ return false;
86
+ }
87
+ function logModelCatalogRefreshFailure(error, type) {
88
+ if (isServerUnavailableError(error)) {
89
+ logger.warn("[ModelManager] OpenCode server is not running; skipping model catalog refresh");
90
+ return;
91
+ }
92
+ if (type === "error") {
93
+ logger.warn("[ModelManager] Failed to refresh model catalog:", error);
94
+ return;
95
+ }
96
+ logger.warn("[ModelManager] Error refreshing model catalog:", error);
97
+ }
98
+ async function getValidModelKeys(options) {
99
+ if (!options?.force && cachedValidModelKeys && Date.now() < modelCatalogCacheExpiresAt) {
39
100
  logger.debug(`[ModelManager] Model catalog cache hit: models=${cachedValidModelKeys.size}, ttlMs=${modelCatalogCacheExpiresAt - Date.now()}`);
40
101
  return cachedValidModelKeys;
41
102
  }
@@ -48,7 +109,7 @@ async function getValidModelKeys() {
48
109
  logger.debug("[ModelManager] Refreshing model catalog from OpenCode API");
49
110
  const response = await opencodeClient.config.providers();
50
111
  if (response.error || !response.data) {
51
- logger.warn("[ModelManager] Failed to refresh model catalog:", response.error);
112
+ logModelCatalogRefreshFailure(response.error, "error");
52
113
  if (cachedValidModelKeys) {
53
114
  logger.warn("[ModelManager] Using stale model catalog cache after refresh failure");
54
115
  return cachedValidModelKeys;
@@ -67,7 +128,7 @@ async function getValidModelKeys() {
67
128
  return cachedValidModelKeys;
68
129
  }
69
130
  catch (err) {
70
- logger.warn("[ModelManager] Error refreshing model catalog:", err);
131
+ logModelCatalogRefreshFailure(err, "exception");
71
132
  if (cachedValidModelKeys) {
72
133
  logger.warn("[ModelManager] Using stale model catalog cache after refresh exception");
73
134
  return cachedValidModelKeys;
@@ -171,12 +232,12 @@ export async function getModelSelectionLists() {
171
232
  * Validate stored selected model against OpenCode providers catalog.
172
233
  * If selected model is unavailable, fallback to env default model.
173
234
  */
174
- export async function reconcileStoredModelSelection() {
235
+ export async function reconcileStoredModelSelection(options) {
175
236
  const currentModel = getCurrentModel();
176
237
  if (!currentModel?.providerID || !currentModel.modelID) {
177
238
  return;
178
239
  }
179
- const validModelKeys = await getValidModelKeys();
240
+ const validModelKeys = await getValidModelKeys({ force: options?.forceCatalogRefresh });
180
241
  if (!validModelKeys) {
181
242
  logger.warn("[ModelManager] Skipping stored model validation: model catalog unavailable");
182
243
  return;
@@ -2,6 +2,7 @@ import { config } from "../config.js";
2
2
  import { logger } from "../utils/logger.js";
3
3
  import { opencodeClient } from "./client.js";
4
4
  import { resolveLocalOpencodeTarget, startLocalOpencodeServer, } from "./process.js";
5
+ import { refreshSessionCacheAfterOpencodeReady } from "./ready-refresh.js";
5
6
  const SERVER_READY_TIMEOUT_MS = 10000;
6
7
  const SERVER_READY_POLL_INTERVAL_MS = 500;
7
8
  function sleep(ms) {
@@ -31,14 +32,15 @@ export class OpencodeAutoRestartService {
31
32
  localTarget = null;
32
33
  started = false;
33
34
  checkInProgress = false;
35
+ serverWasHealthy = false;
34
36
  async start() {
35
37
  if (this.started || !config.opencode.autoRestartEnabled) {
36
- return;
38
+ return false;
37
39
  }
38
40
  const localTarget = resolveLocalOpencodeTarget(config.opencode.apiUrl);
39
41
  if (!localTarget) {
40
42
  logger.warn(`[OpenCodeAutoRestart] Disabled because OPENCODE_API_URL is not local: ${config.opencode.apiUrl}`);
41
- return;
43
+ return false;
42
44
  }
43
45
  this.started = true;
44
46
  this.localTarget = localTarget;
@@ -48,6 +50,7 @@ export class OpencodeAutoRestartService {
48
50
  void this.checkAndRestart("interval");
49
51
  }, config.opencode.monitorIntervalSec * 1000);
50
52
  this.timer.unref?.();
53
+ return true;
51
54
  }
52
55
  stop() {
53
56
  if (this.timer) {
@@ -56,6 +59,7 @@ export class OpencodeAutoRestartService {
56
59
  }
57
60
  this.started = false;
58
61
  this.localTarget = null;
62
+ this.serverWasHealthy = false;
59
63
  }
60
64
  async checkAndRestart(reason) {
61
65
  if (this.checkInProgress || !this.localTarget) {
@@ -65,8 +69,13 @@ export class OpencodeAutoRestartService {
65
69
  try {
66
70
  if (await isOpencodeServerHealthy()) {
67
71
  logger.debug(`[OpenCodeAutoRestart] Health-check succeeded: reason=${reason}`);
72
+ if (!this.serverWasHealthy) {
73
+ this.serverWasHealthy = true;
74
+ await refreshSessionCacheAfterOpencodeReady(`auto_restart_${reason}`);
75
+ }
68
76
  return;
69
77
  }
78
+ this.serverWasHealthy = false;
70
79
  logger.warn(`[OpenCodeAutoRestart] OpenCode server is unavailable, starting local server: reason=${reason}, port=${this.localTarget.port}`);
71
80
  const childProcess = startLocalOpencodeServer(this.localTarget);
72
81
  childProcess.once("error", (error) => {
@@ -80,6 +89,8 @@ export class OpencodeAutoRestartService {
80
89
  return;
81
90
  }
82
91
  logger.info(`[OpenCodeAutoRestart] OpenCode server recovered: pid=${pid ?? "unknown"}, port=${this.localTarget.port}`);
92
+ this.serverWasHealthy = true;
93
+ await refreshSessionCacheAfterOpencodeReady(`auto_restart_${reason}`);
83
94
  }
84
95
  catch (error) {
85
96
  logger.error("[OpenCodeAutoRestart] Failed to check or restart OpenCode server", error);
@@ -0,0 +1,37 @@
1
+ import { reconcileStoredModelSelection } from "../model/manager.js";
2
+ import { warmupSessionDirectoryCache } from "../session/cache-manager.js";
3
+ import { logger } from "../utils/logger.js";
4
+ import { opencodeClient } from "./client.js";
5
+ async function isOpencodeServerHealthy() {
6
+ try {
7
+ const { data, error } = await opencodeClient.global.health();
8
+ return !error && data?.healthy === true;
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }
14
+ export async function refreshSessionCacheAfterOpencodeReady(reason) {
15
+ try {
16
+ await warmupSessionDirectoryCache();
17
+ logger.debug(`[OpenCodeReady] Session cache refreshed: reason=${reason}`);
18
+ }
19
+ catch (error) {
20
+ logger.warn(`[OpenCodeReady] Failed to refresh session cache: reason=${reason}`, error);
21
+ }
22
+ try {
23
+ await reconcileStoredModelSelection({ forceCatalogRefresh: true });
24
+ logger.debug(`[OpenCodeReady] Model catalog refreshed: reason=${reason}`);
25
+ }
26
+ catch (error) {
27
+ logger.warn(`[OpenCodeReady] Failed to refresh model catalog: reason=${reason}`, error);
28
+ }
29
+ }
30
+ export async function refreshSessionCacheIfOpencodeReady(reason) {
31
+ if (!(await isOpencodeServerHealthy())) {
32
+ logger.warn(`[OpenCodeReady] OpenCode server is not running; skipping session cache refresh: reason=${reason}`);
33
+ return false;
34
+ }
35
+ await refreshSessionCacheAfterOpencodeReady(reason);
36
+ return true;
37
+ }
@@ -1,4 +1,4 @@
1
- import { validateTelegramEntities } from "./validator.js";
1
+ import { isLoopbackTelegramHttpUrl, isValidTelegramTextLinkUrl, validateTelegramEntities, } from "./validator.js";
2
2
  const ENTITY_TYPE_PRIORITY = {
3
3
  bold: 1,
4
4
  italic: 2,
@@ -32,18 +32,15 @@ function pushEntity(state, entity) {
32
32
  }
33
33
  state.entities.push(entity);
34
34
  }
35
- function isTelegramTextLinkUrl(url) {
36
- try {
37
- const parsed = new URL(url);
38
- return ["http:", "https:", "tg:", "mailto:"].includes(parsed.protocol);
39
- }
40
- catch {
41
- return false;
42
- }
43
- }
44
35
  function isLocalReferenceUrl(url) {
45
36
  return url.startsWith("#") || url.startsWith("/") || url.startsWith("./") || url.startsWith("../");
46
37
  }
38
+ function appendPlainLinkTarget(state, offset, url) {
39
+ if (!url || state.text.slice(offset) === url) {
40
+ return;
41
+ }
42
+ appendText(state, ` (${url})`);
43
+ }
47
44
  function renderIntoState(state, nodes) {
48
45
  for (const node of nodes) {
49
46
  switch (node.type) {
@@ -93,9 +90,9 @@ function renderIntoState(state, nodes) {
93
90
  case "link": {
94
91
  const offset = state.text.length;
95
92
  renderIntoState(state, node.text);
96
- if (!isTelegramTextLinkUrl(node.url)) {
97
- if (isLocalReferenceUrl(node.url)) {
98
- appendText(state, ` (${node.url})`);
93
+ if (!isValidTelegramTextLinkUrl(node.url)) {
94
+ if (isLocalReferenceUrl(node.url) || isLoopbackTelegramHttpUrl(node.url)) {
95
+ appendPlainLinkTarget(state, offset, node.url);
99
96
  break;
100
97
  }
101
98
  }
@@ -43,10 +43,36 @@ function isPartialOverlap(left, right) {
43
43
  function isStyleEntity(entity) {
44
44
  return STYLE_ENTITY_TYPES.has(entity.type);
45
45
  }
46
- function isValidLinkUrl(url) {
46
+ function isLoopbackHttpHostname(hostname) {
47
+ const normalized = hostname.toLowerCase().replace(/^\[(.*)]$/, "$1").replace(/\.$/, "");
48
+ return (normalized === "localhost" ||
49
+ normalized.endsWith(".localhost") ||
50
+ normalized === "0.0.0.0" ||
51
+ normalized.startsWith("127.") ||
52
+ normalized === "::1");
53
+ }
54
+ export function isLoopbackTelegramHttpUrl(url) {
55
+ try {
56
+ const parsed = new URL(url);
57
+ return ["http:", "https:"].includes(parsed.protocol) && isLoopbackHttpHostname(parsed.hostname);
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ }
63
+ export function isValidTelegramTextLinkUrl(url) {
64
+ if (/\s/.test(url)) {
65
+ return false;
66
+ }
47
67
  try {
48
68
  const parsed = new URL(url);
49
- return ["http:", "https:", "tg:", "mailto:"].includes(parsed.protocol);
69
+ if (!["http:", "https:", "tg:", "mailto:"].includes(parsed.protocol)) {
70
+ return false;
71
+ }
72
+ if (["http:", "https:"].includes(parsed.protocol)) {
73
+ return Boolean(parsed.hostname) && !isLoopbackTelegramHttpUrl(url);
74
+ }
75
+ return true;
50
76
  }
51
77
  catch {
52
78
  return false;
@@ -82,7 +108,7 @@ function validateEntityShape(textLength, entity, entityIndex) {
82
108
  entityIndex,
83
109
  });
84
110
  }
85
- if (entity.type === "text_link" && !isValidLinkUrl(entity.url)) {
111
+ if (entity.type === "text_link" && !isValidTelegramTextLinkUrl(entity.url)) {
86
112
  issues.push({
87
113
  code: "invalid_link_url",
88
114
  message: `Invalid Telegram text_link URL: ${entity.url}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grinev/opencode-telegram-bot",
3
- "version": "0.19.1",
3
+ "version": "0.19.2",
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",