iosm-cli 0.3.3 → 0.3.5

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.
@@ -7,6 +7,7 @@ const COMMAND_MENU_TTL_MS = 5 * 60 * 1000;
7
7
  const MODELS_PAGE_SIZE = 8;
8
8
  const MODEL_MENU_TTL_MS = 2 * 60 * 1000;
9
9
  const LIVE_STATUS_ANIMATION_MAX_THROTTLE_MS = 850;
10
+ const TELEGRAM_API_MAX_429_RETRIES = 4;
10
11
  const INPUT_BUTTON_HUB = "🧭 Hub";
11
12
  const INPUT_BUTTON_START = "▶️ Start";
12
13
  const INPUT_BUTTON_NEW = "🆕 New";
@@ -128,29 +129,77 @@ class TelegramBotApi {
128
129
  return this.callMultipart("sendDocument", form);
129
130
  }
130
131
  async call(method, payload) {
131
- const response = await fetch(`${this.endpoint}/${method}`, {
132
- method: "POST",
133
- headers: {
134
- "content-type": "application/json",
135
- },
136
- body: JSON.stringify(payload),
137
- });
138
- const envelope = (await response.json());
139
- if (!response.ok || !envelope.ok || envelope.result === undefined) {
140
- throw new Error(`Telegram API ${method} failed: ${envelope.description ?? response.statusText} (${envelope.error_code ?? response.status})`);
132
+ for (let attempt = 0;; attempt++) {
133
+ const response = await fetch(`${this.endpoint}/${method}`, {
134
+ method: "POST",
135
+ headers: {
136
+ "content-type": "application/json",
137
+ },
138
+ body: JSON.stringify(payload),
139
+ });
140
+ const envelope = await this.readEnvelope(response);
141
+ if (response.ok && envelope.ok && envelope.result !== undefined) {
142
+ return envelope.result;
143
+ }
144
+ const errorCode = envelope.error_code ?? response.status;
145
+ const retryAfterMs = this.extractRetryAfterMs(response, envelope.description);
146
+ if (errorCode === 429 && retryAfterMs !== undefined && attempt < TELEGRAM_API_MAX_429_RETRIES) {
147
+ console.warn(`[telegram] ${method} rate-limited, retrying in ${Math.ceil(retryAfterMs / 1000)}s (${attempt + 1}/${TELEGRAM_API_MAX_429_RETRIES})`);
148
+ await sleepTimeout(retryAfterMs);
149
+ continue;
150
+ }
151
+ throw new Error(`Telegram API ${method} failed: ${envelope.description ?? response.statusText} (${errorCode})`);
141
152
  }
142
- return envelope.result;
143
153
  }
144
154
  async callMultipart(method, form) {
145
- const response = await fetch(`${this.endpoint}/${method}`, {
146
- method: "POST",
147
- body: form,
148
- });
149
- const envelope = (await response.json());
150
- if (!response.ok || !envelope.ok || envelope.result === undefined) {
151
- throw new Error(`Telegram API ${method} failed: ${envelope.description ?? response.statusText} (${envelope.error_code ?? response.status})`);
155
+ for (let attempt = 0;; attempt++) {
156
+ const response = await fetch(`${this.endpoint}/${method}`, {
157
+ method: "POST",
158
+ body: form,
159
+ });
160
+ const envelope = await this.readEnvelope(response);
161
+ if (response.ok && envelope.ok && envelope.result !== undefined) {
162
+ return envelope.result;
163
+ }
164
+ const errorCode = envelope.error_code ?? response.status;
165
+ const retryAfterMs = this.extractRetryAfterMs(response, envelope.description);
166
+ if (errorCode === 429 && retryAfterMs !== undefined && attempt < TELEGRAM_API_MAX_429_RETRIES) {
167
+ console.warn(`[telegram] ${method} rate-limited, retrying in ${Math.ceil(retryAfterMs / 1000)}s (${attempt + 1}/${TELEGRAM_API_MAX_429_RETRIES})`);
168
+ await sleepTimeout(retryAfterMs);
169
+ continue;
170
+ }
171
+ throw new Error(`Telegram API ${method} failed: ${envelope.description ?? response.statusText} (${errorCode})`);
152
172
  }
153
- return envelope.result;
173
+ }
174
+ async readEnvelope(response) {
175
+ try {
176
+ return (await response.json());
177
+ }
178
+ catch {
179
+ return {
180
+ ok: false,
181
+ description: response.statusText,
182
+ error_code: response.status,
183
+ };
184
+ }
185
+ }
186
+ extractRetryAfterMs(response, description) {
187
+ const headerValue = response.headers.get("retry-after");
188
+ if (headerValue) {
189
+ const headerSeconds = Number.parseInt(headerValue, 10);
190
+ if (Number.isFinite(headerSeconds) && headerSeconds > 0) {
191
+ return headerSeconds * 1000;
192
+ }
193
+ }
194
+ if (!description)
195
+ return undefined;
196
+ const match = /retry after\s+(\d+)/i.exec(description);
197
+ if (!match)
198
+ return undefined;
199
+ const seconds = Number.parseInt(match[1] ?? "", 10);
200
+ if (!Number.isFinite(seconds) || seconds <= 0)
201
+ return undefined;
202
+ return seconds * 1000;
154
203
  }
155
204
  }
156
205
  export async function runTelegramBridgeMode(options) {
@@ -218,13 +267,19 @@ class TelegramBridgeRuntime {
218
267
  void shutdown("SIGTERM");
219
268
  });
220
269
  this.rpcClient.onEvent((event) => {
221
- void this.onRpcEvent(event);
270
+ void this.onRpcEvent(event).catch((error) => {
271
+ console.error(`[telegram] rpc event handler error: ${error instanceof Error ? error.message : String(error)}`);
272
+ });
222
273
  });
223
274
  this.rpcClient.onExtensionUIRequest((request) => {
224
- void this.onRpcExtensionUiRequest(request);
275
+ void this.onRpcExtensionUiRequest(request).catch((error) => {
276
+ console.error(`[telegram] rpc extension ui handler error: ${error instanceof Error ? error.message : String(error)}`);
277
+ });
225
278
  });
226
279
  this.rpcClient.onRequiresConfirmation((event) => {
227
- void this.onRpcConfirmationRequest(event);
280
+ void this.onRpcConfirmationRequest(event).catch((error) => {
281
+ console.error(`[telegram] rpc confirmation handler error: ${error instanceof Error ? error.message : String(error)}`);
282
+ });
228
283
  });
229
284
  // Keep polling forever.
230
285
  for (;;) {
@@ -2030,6 +2085,53 @@ class TelegramBridgeRuntime {
2030
2085
  formatStartingStatus(prompt) {
2031
2086
  return [`⏳ ${APP_NAME} · start · 0s · q${this.queue.length}`, `“${this.formatPromptPreview(prompt, 56)}”`].join("\n");
2032
2087
  }
2088
+ isStatusMessageInvalidError(error) {
2089
+ const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
2090
+ return (message.includes("message_id_invalid") ||
2091
+ message.includes("message to edit not found") ||
2092
+ message.includes("message can't be edited"));
2093
+ }
2094
+ extractTelegramRetryAfterMs(error) {
2095
+ const message = error instanceof Error ? error.message : String(error);
2096
+ const match = /retry after\s+(\d+)/i.exec(message);
2097
+ if (!match)
2098
+ return undefined;
2099
+ const seconds = Number.parseInt(match[1] ?? "", 10);
2100
+ if (!Number.isFinite(seconds) || seconds <= 0)
2101
+ return undefined;
2102
+ return seconds * 1000;
2103
+ }
2104
+ scheduleStatusEditRetry(turnId, retryAfterMs) {
2105
+ if (!this.activeTurn || this.activeTurn.turnId !== turnId)
2106
+ return;
2107
+ if (this.activeTurn.statusEditPending)
2108
+ return;
2109
+ this.activeTurn.statusEditPending = true;
2110
+ this.activeTurn.statusEditTimer = setTimeout(() => {
2111
+ if (this.activeTurn && this.activeTurn.turnId === turnId) {
2112
+ this.activeTurn.statusEditPending = false;
2113
+ void this.editLiveStatus(true);
2114
+ }
2115
+ }, retryAfterMs);
2116
+ }
2117
+ async recreateLiveStatusMessage(target, statusText) {
2118
+ // Status message may be removed by user or become invalid in Telegram storage.
2119
+ // Recreate it and continue editing against the new message_id.
2120
+ if (!this.activeTurn || this.activeTurn.turnId !== target.turnId)
2121
+ return false;
2122
+ try {
2123
+ const fresh = await this.bot.sendMessage(target.chatId, statusText, {
2124
+ replyMarkup: { inline_keyboard: [] },
2125
+ });
2126
+ target.statusMessageId = fresh.message_id;
2127
+ target.lastStatusEditAt = Date.now();
2128
+ return true;
2129
+ }
2130
+ catch (error) {
2131
+ console.warn(`[telegram] status recreation failed: ${error instanceof Error ? error.message : String(error)}`);
2132
+ return false;
2133
+ }
2134
+ }
2033
2135
  formatLiveStatus(turn = this.activeTurn) {
2034
2136
  if (!turn)
2035
2137
  return "No active task.";
@@ -2074,10 +2176,21 @@ class TelegramBridgeRuntime {
2074
2176
  .then(() => {
2075
2177
  target.lastStatusEditAt = Date.now();
2076
2178
  })
2077
- .catch((error) => {
2179
+ .catch(async (error) => {
2078
2180
  const message = error instanceof Error ? error.message : String(error);
2079
2181
  // Ignore no-op edit errors and transient "message is not modified".
2080
2182
  if (!message.toLowerCase().includes("message is not modified")) {
2183
+ const retryAfterMs = this.extractTelegramRetryAfterMs(error);
2184
+ if (retryAfterMs) {
2185
+ console.warn(`[telegram] status edit rate-limited, retrying in ${Math.ceil(retryAfterMs / 1000)}s`);
2186
+ this.scheduleStatusEditRetry(turnId, retryAfterMs);
2187
+ return;
2188
+ }
2189
+ if (this.isStatusMessageInvalidError(error)) {
2190
+ const recovered = await this.recreateLiveStatusMessage(target, statusText);
2191
+ if (recovered)
2192
+ return;
2193
+ }
2081
2194
  console.warn(`[telegram] status edit failed: ${message}`);
2082
2195
  }
2083
2196
  })
@@ -2129,17 +2242,25 @@ class TelegramBridgeRuntime {
2129
2242
  replyMarkup: { inline_keyboard: [] },
2130
2243
  });
2131
2244
  }
2132
- catch {
2133
- // Best-effort.
2134
- }
2135
- if (options?.error) {
2136
- await this.bot.sendMessage(finishedTurn.chatId, `Task failed: ${options.error}`);
2245
+ catch (error) {
2246
+ // Best-effort, but recover from invalid status message ids to avoid losing final state.
2247
+ if (this.isStatusMessageInvalidError(error)) {
2248
+ await this.bot.sendMessage(finishedTurn.chatId, statusLabel).catch(() => { });
2249
+ }
2137
2250
  }
2138
- else if (finalText && finalText.trim().length > 0) {
2139
- await this.sendFinalOutput(finishedTurn.chatId, finalText);
2251
+ try {
2252
+ if (options?.error) {
2253
+ await this.bot.sendMessage(finishedTurn.chatId, `Task failed: ${options.error}`);
2254
+ }
2255
+ else if (finalText && finalText.trim().length > 0) {
2256
+ await this.sendFinalOutput(finishedTurn.chatId, finalText);
2257
+ }
2258
+ else if (!finishedTurn.aborted) {
2259
+ await this.bot.sendMessage(finishedTurn.chatId, "Task completed with no assistant text output.");
2260
+ }
2140
2261
  }
2141
- else if (!finishedTurn.aborted) {
2142
- await this.bot.sendMessage(finishedTurn.chatId, "Task completed with no assistant text output.");
2262
+ catch (error) {
2263
+ console.warn(`[telegram] final output delivery failed: ${error instanceof Error ? error.message : String(error)}`);
2143
2264
  }
2144
2265
  await this.drainQueue();
2145
2266
  }