@iletai/nzb 1.7.0 → 1.7.3

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.
@@ -57,6 +57,18 @@ export function registerMessageHandler(bot, getBot) {
57
57
  }
58
58
  };
59
59
  await startTyping();
60
+ // Safety timeout — force-stop typing if orchestrator hangs
61
+ const MAX_TYPING_MS = 120_000; // 2 minutes
62
+ const typingTimeout = setTimeout(() => {
63
+ if (!typingStopped) {
64
+ console.log("[nzb] Typing indicator timeout, force stopping");
65
+ stopTyping();
66
+ }
67
+ }, MAX_TYPING_MS);
68
+ const stopTypingAndClearTimeout = () => {
69
+ stopTyping();
70
+ clearTimeout(typingTimeout);
71
+ };
60
72
  // Progressive streaming state — all Telegram API calls are serialized through editChain
61
73
  // to prevent duplicate placeholder messages and race conditions
62
74
  let placeholderMsgId;
@@ -104,7 +116,7 @@ export function registerMessageHandler(bot, getBot) {
104
116
  const msg = await ctx.reply(safeText, { reply_parameters: replyParams });
105
117
  placeholderMsgId = msg.message_id;
106
118
  // Stop typing once placeholder is visible — edits serve as the indicator now
107
- stopTyping();
119
+ stopTypingAndClearTimeout();
108
120
  }
109
121
  catch {
110
122
  return;
@@ -125,7 +137,9 @@ export function registerMessageHandler(bot, getBot) {
125
137
  }
126
138
  lastEditedText = safeText;
127
139
  })
128
- .catch(() => { });
140
+ .catch((err) => {
141
+ console.error("[nzb] Edit chain error:", err instanceof Error ? err.message : err);
142
+ });
129
143
  };
130
144
  const onToolEvent = (event) => {
131
145
  console.log(`[nzb] Bot received tool event: ${event.type} ${event.toolName}`);
@@ -198,90 +212,75 @@ export function registerMessageHandler(bot, getBot) {
198
212
  userPrompt = `[Replying to: "${quoted}"]\n\n${userPrompt}`;
199
213
  }
200
214
  }
201
- sendToOrchestrator(userPrompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done, meta) => {
202
- if (done) {
203
- finalized = true;
204
- stopTyping();
205
- const assistantLogId = meta?.assistantLogId;
206
- const elapsed = ((Date.now() - handlerStartTime) / 1000).toFixed(1);
207
- void logInfo(`✅ Response done (${elapsed}s, ${toolHistory.length} tools, ${text.length} chars)`);
208
- // Return the edit chain so callers can await final delivery
209
- return editChain.then(async () => {
210
- try {
211
- // Format error messages with a distinct visual
212
- const isError = text.startsWith("Error:");
213
- if (isError) {
214
- void logError(`Response error: ${text.slice(0, 200)}`);
215
- const errorText = `⚠️ ${text}`;
216
- const errorKb = new InlineKeyboard().text("🔄 Retry", "retry").text("📖 Explain", "explain_error");
217
- if (placeholderMsgId) {
215
+ try {
216
+ sendToOrchestrator(userPrompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done, meta) => {
217
+ if (done) {
218
+ finalized = true;
219
+ stopTypingAndClearTimeout();
220
+ const assistantLogId = meta?.assistantLogId;
221
+ const elapsed = ((Date.now() - handlerStartTime) / 1000).toFixed(1);
222
+ void logInfo(`✅ Response done (${elapsed}s, ${toolHistory.length} tools, ${text.length} chars)`);
223
+ // Return the edit chain so callers can await final delivery
224
+ return editChain.then(async () => {
225
+ try {
226
+ // Format error messages with a distinct visual
227
+ const isError = text.startsWith("Error:");
228
+ if (isError) {
229
+ void logError(`Response error: ${text.slice(0, 200)}`);
230
+ const errorText = `⚠️ ${text}`;
231
+ const errorKb = new InlineKeyboard().text("🔄 Retry", "retry").text("📖 Explain", "explain_error");
232
+ if (placeholderMsgId) {
233
+ try {
234
+ await editSafe(getBot().api, chatId, placeholderMsgId, errorText, { reply_markup: errorKb });
235
+ return;
236
+ }
237
+ catch {
238
+ /* fall through */
239
+ }
240
+ }
218
241
  try {
219
- await editSafe(getBot().api, chatId, placeholderMsgId, errorText, { reply_markup: errorKb });
220
- return;
242
+ await ctx.reply(errorText, { reply_parameters: replyParams, reply_markup: errorKb });
221
243
  }
222
244
  catch {
223
- /* fall through */
245
+ /* nothing more we can do */
224
246
  }
247
+ return;
225
248
  }
226
- try {
227
- await ctx.reply(errorText, { reply_parameters: replyParams, reply_markup: errorKb });
228
- }
229
- catch {
230
- /* nothing more we can do */
249
+ let textWithMeta = text;
250
+ if (usageInfo && config.usageMode !== "off") {
251
+ const fmtTokens = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}K` : String(n));
252
+ const parts = [];
253
+ if (config.usageMode === "full" && usageInfo.model)
254
+ parts.push(usageInfo.model);
255
+ parts.push(`⬆${fmtTokens(usageInfo.inputTokens)} ⬇${fmtTokens(usageInfo.outputTokens)}`);
256
+ const totalTokens = usageInfo.inputTokens + usageInfo.outputTokens;
257
+ parts.push(`Σ${fmtTokens(totalTokens)}`);
258
+ if (config.usageMode === "full" && usageInfo.duration)
259
+ parts.push(`${(usageInfo.duration / 1000).toFixed(1)}s`);
260
+ textWithMeta += `\n\n📊 ${parts.join(" · ")}`;
231
261
  }
232
- return;
233
- }
234
- let textWithMeta = text;
235
- if (usageInfo && config.usageMode !== "off") {
236
- const fmtTokens = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}K` : String(n));
237
- const parts = [];
238
- if (config.usageMode === "full" && usageInfo.model)
239
- parts.push(usageInfo.model);
240
- parts.push(`⬆${fmtTokens(usageInfo.inputTokens)} ⬇${fmtTokens(usageInfo.outputTokens)}`);
241
- const totalTokens = usageInfo.inputTokens + usageInfo.outputTokens;
242
- parts.push(`Σ${fmtTokens(totalTokens)}`);
243
- if (config.usageMode === "full" && usageInfo.duration)
244
- parts.push(`${(usageInfo.duration / 1000).toFixed(1)}s`);
245
- textWithMeta += `\n\n📊 ${parts.join(" · ")}`;
246
- }
247
- const formatted = toTelegramHTML(textWithMeta);
248
- let fullFormatted = formatted;
249
- if (config.showReasoning && toolHistory.length > 0) {
250
- const expandable = formatToolSummaryExpandable(toolHistory.map((t) => ({ name: t.name, durationMs: t.durationMs, detail: t.detail })), {
251
- elapsedMs: Date.now() - handlerStartTime,
252
- model: usageInfo?.model,
253
- inputTokens: usageInfo?.inputTokens,
254
- outputTokens: usageInfo?.outputTokens,
255
- });
256
- fullFormatted += expandable;
257
- }
258
- const chunks = chunkMessage(fullFormatted);
259
- const fallbackChunks = chunkMessage(textWithMeta);
260
- // Build smart suggestion buttons based on response content
261
- const smartKb = createSmartSuggestionsWithContext(text, ctx.message.text, 4);
262
- // Single chunk: edit placeholder in place
263
- if (placeholderMsgId && chunks.length === 1) {
264
- try {
265
- await editSafe(getBot().api, chatId, placeholderMsgId, chunks[0], {
266
- parse_mode: "HTML",
267
- reply_markup: smartKb,
262
+ const formatted = toTelegramHTML(textWithMeta);
263
+ let fullFormatted = formatted;
264
+ if (config.showReasoning && toolHistory.length > 0) {
265
+ const expandable = formatToolSummaryExpandable(toolHistory.map((t) => ({ name: t.name, durationMs: t.durationMs, detail: t.detail })), {
266
+ elapsedMs: Date.now() - handlerStartTime,
267
+ model: usageInfo?.model,
268
+ inputTokens: usageInfo?.inputTokens,
269
+ outputTokens: usageInfo?.outputTokens,
268
270
  });
269
- try {
270
- await getBot().api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
271
- }
272
- catch { }
273
- if (assistantLogId) {
274
- try {
275
- const { setConversationTelegramMsgId } = await import("../../store/db.js");
276
- setConversationTelegramMsgId(assistantLogId, placeholderMsgId);
277
- }
278
- catch { }
279
- }
280
- return;
271
+ fullFormatted += expandable;
281
272
  }
282
- catch (err) {
283
- // "message is not modified" is harmless — placeholder already has this content
284
- if (isMessageNotModifiedError(err)) {
273
+ const chunks = chunkMessage(fullFormatted);
274
+ const fallbackChunks = chunkMessage(textWithMeta);
275
+ // Build smart suggestion buttons based on response content
276
+ const smartKb = createSmartSuggestionsWithContext(text, ctx.message.text, 4);
277
+ // Single chunk: edit placeholder in place
278
+ if (placeholderMsgId && chunks.length === 1) {
279
+ try {
280
+ await editSafe(getBot().api, chatId, placeholderMsgId, chunks[0], {
281
+ parse_mode: "HTML",
282
+ reply_markup: smartKb,
283
+ });
285
284
  try {
286
285
  await getBot().api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
287
286
  }
@@ -295,8 +294,45 @@ export function registerMessageHandler(bot, getBot) {
295
294
  }
296
295
  return;
297
296
  }
298
- // HTML parse error — try plain text fallback
299
- if (isHtmlParseError(err)) {
297
+ catch (err) {
298
+ // "message is not modified" is harmless — placeholder already has this content
299
+ if (isMessageNotModifiedError(err)) {
300
+ try {
301
+ await getBot().api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
302
+ }
303
+ catch { }
304
+ if (assistantLogId) {
305
+ try {
306
+ const { setConversationTelegramMsgId } = await import("../../store/db.js");
307
+ setConversationTelegramMsgId(assistantLogId, placeholderMsgId);
308
+ }
309
+ catch { }
310
+ }
311
+ return;
312
+ }
313
+ // HTML parse error — try plain text fallback
314
+ if (isHtmlParseError(err)) {
315
+ try {
316
+ await editSafe(getBot().api, chatId, placeholderMsgId, fallbackChunks[0], {
317
+ reply_markup: smartKb,
318
+ });
319
+ try {
320
+ await getBot().api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
321
+ }
322
+ catch { }
323
+ if (assistantLogId) {
324
+ try {
325
+ const { setConversationTelegramMsgId } = await import("../../store/db.js");
326
+ setConversationTelegramMsgId(assistantLogId, placeholderMsgId);
327
+ }
328
+ catch { }
329
+ }
330
+ return;
331
+ }
332
+ catch {
333
+ /* fall through to send new messages */
334
+ }
335
+ }
300
336
  try {
301
337
  await editSafe(getBot().api, chatId, placeholderMsgId, fallbackChunks[0], {
302
338
  reply_markup: smartKb,
@@ -318,136 +354,135 @@ export function registerMessageHandler(bot, getBot) {
318
354
  /* fall through to send new messages */
319
355
  }
320
356
  }
321
- try {
322
- await editSafe(getBot().api, chatId, placeholderMsgId, fallbackChunks[0], {
323
- reply_markup: smartKb,
324
- });
325
- try {
326
- await getBot().api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
327
- }
328
- catch { }
329
- if (assistantLogId) {
330
- try {
331
- const { setConversationTelegramMsgId } = await import("../../store/db.js");
332
- setConversationTelegramMsgId(assistantLogId, placeholderMsgId);
333
- }
334
- catch { }
335
- }
336
- return;
357
+ }
358
+ // Multi-chunk or edit fallthrough: send new chunks FIRST, then delete placeholder
359
+ const totalChunks = chunks.length;
360
+ let firstSentMsgId;
361
+ const sendChunk = async (chunk, fallback, index) => {
362
+ const isFirst = index === 0 && !placeholderMsgId;
363
+ const isLast = index === totalChunks - 1;
364
+ // Pagination header for multi-chunk messages
365
+ const pageTag = totalChunks > 1 ? `📄 ${index + 1}/${totalChunks}\n` : "";
366
+ // Trim chunk if pageTag pushes it over the limit
367
+ let safeChunk = chunk;
368
+ if (pageTag.length + safeChunk.length > TELEGRAM_MAX_LENGTH) {
369
+ safeChunk = safeChunk.slice(0, TELEGRAM_MAX_LENGTH - pageTag.length - 4) + " ⋯";
337
370
  }
338
- catch {
339
- /* fall through to send new messages */
371
+ let safeFallback = fallback;
372
+ if (pageTag.length + safeFallback.length > TELEGRAM_MAX_LENGTH) {
373
+ safeFallback = safeFallback.slice(0, TELEGRAM_MAX_LENGTH - pageTag.length - 4) + " ⋯";
340
374
  }
341
- }
342
- }
343
- // Multi-chunk or edit fallthrough: send new chunks FIRST, then delete placeholder
344
- const totalChunks = chunks.length;
345
- let firstSentMsgId;
346
- const sendChunk = async (chunk, fallback, index) => {
347
- const isFirst = index === 0 && !placeholderMsgId;
348
- const isLast = index === totalChunks - 1;
349
- // Pagination header for multi-chunk messages
350
- const pageTag = totalChunks > 1 ? `📄 ${index + 1}/${totalChunks}\n` : "";
351
- // Trim chunk if pageTag pushes it over the limit
352
- let safeChunk = chunk;
353
- if (pageTag.length + safeChunk.length > TELEGRAM_MAX_LENGTH) {
354
- safeChunk = safeChunk.slice(0, TELEGRAM_MAX_LENGTH - pageTag.length - 4) + " ⋯";
355
- }
356
- let safeFallback = fallback;
357
- if (pageTag.length + safeFallback.length > TELEGRAM_MAX_LENGTH) {
358
- safeFallback = safeFallback.slice(0, TELEGRAM_MAX_LENGTH - pageTag.length - 4) + " ⋯";
359
- }
360
- const opts = {
361
- parse_mode: "HTML",
362
- ...(isFirst ? { reply_parameters: replyParams } : {}),
363
- ...(isLast && smartKb ? { reply_markup: smartKb } : {}),
364
- };
365
- const fallbackOpts = {
366
- ...(isFirst ? { reply_parameters: replyParams } : {}),
367
- ...(isLast && smartKb ? { reply_markup: smartKb } : {}),
375
+ const opts = {
376
+ parse_mode: "HTML",
377
+ ...(isFirst ? { reply_parameters: replyParams } : {}),
378
+ ...(isLast && smartKb ? { reply_markup: smartKb } : {}),
379
+ };
380
+ const fallbackOpts = {
381
+ ...(isFirst ? { reply_parameters: replyParams } : {}),
382
+ ...(isLast && smartKb ? { reply_markup: smartKb } : {}),
383
+ };
384
+ const sent = await ctx
385
+ .reply(pageTag + safeChunk, opts)
386
+ .catch(() => ctx.reply(pageTag + safeFallback, fallbackOpts));
387
+ if (index === 0 && sent)
388
+ firstSentMsgId = sent.message_id;
368
389
  };
369
- const sent = await ctx
370
- .reply(pageTag + safeChunk, opts)
371
- .catch(() => ctx.reply(pageTag + safeFallback, fallbackOpts));
372
- if (index === 0 && sent)
373
- firstSentMsgId = sent.message_id;
374
- };
375
- let sendSucceeded = false;
376
- try {
377
- for (let i = 0; i < chunks.length; i++) {
378
- if (i > 0)
379
- await new Promise((r) => setTimeout(r, 300));
380
- await sendChunk(chunks[i], fallbackChunks[i] ?? chunks[i], i);
381
- }
382
- sendSucceeded = true;
383
- }
384
- catch {
390
+ let sendSucceeded = false;
385
391
  try {
386
- for (let i = 0; i < fallbackChunks.length; i++) {
392
+ for (let i = 0; i < chunks.length; i++) {
387
393
  if (i > 0)
388
394
  await new Promise((r) => setTimeout(r, 300));
389
- const pageTag = fallbackChunks.length > 1 ? `📄 ${i + 1}/${fallbackChunks.length}\n` : "";
390
- const sent = await ctx.reply(pageTag + fallbackChunks[i], i === 0 ? { reply_parameters: replyParams } : {});
391
- if (i === 0 && sent)
392
- firstSentMsgId = sent.message_id;
395
+ await sendChunk(chunks[i], fallbackChunks[i] ?? chunks[i], i);
393
396
  }
394
397
  sendSucceeded = true;
395
398
  }
396
399
  catch {
397
- /* nothing more we can do */
400
+ try {
401
+ for (let i = 0; i < fallbackChunks.length; i++) {
402
+ if (i > 0)
403
+ await new Promise((r) => setTimeout(r, 300));
404
+ const pageTag = fallbackChunks.length > 1 ? `📄 ${i + 1}/${fallbackChunks.length}\n` : "";
405
+ const sent = await ctx.reply(pageTag + fallbackChunks[i], i === 0 ? { reply_parameters: replyParams } : {});
406
+ if (i === 0 && sent)
407
+ firstSentMsgId = sent.message_id;
408
+ }
409
+ sendSucceeded = true;
410
+ }
411
+ catch {
412
+ /* nothing more we can do */
413
+ }
398
414
  }
399
- }
400
- // Only delete placeholder AFTER new messages sent successfully
401
- if (placeholderMsgId && sendSucceeded) {
415
+ // Only delete placeholder AFTER new messages sent successfully
416
+ if (placeholderMsgId && sendSucceeded) {
417
+ try {
418
+ await getBot().api.deleteMessage(chatId, placeholderMsgId);
419
+ }
420
+ catch {
421
+ /* ignore — placeholder stays but user has the real message */
422
+ }
423
+ }
424
+ // Track bot message ID for reply-to context lookups
425
+ const botMsgId = firstSentMsgId ?? placeholderMsgId;
426
+ if (assistantLogId && botMsgId) {
427
+ try {
428
+ const { setConversationTelegramMsgId } = await import("../../store/db.js");
429
+ setConversationTelegramMsgId(assistantLogId, botMsgId);
430
+ }
431
+ catch { }
432
+ }
433
+ // React ✅ on the user's original message to signal completion
402
434
  try {
403
- await getBot().api.deleteMessage(chatId, placeholderMsgId);
435
+ await getBot().api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
404
436
  }
405
437
  catch {
406
- /* ignore placeholder stays but user has the real message */
438
+ /* reactions may not be available */
407
439
  }
408
440
  }
409
- // Track bot message ID for reply-to context lookups
410
- const botMsgId = firstSentMsgId ?? placeholderMsgId;
411
- if (assistantLogId && botMsgId) {
412
- try {
413
- const { setConversationTelegramMsgId } = await import("../../store/db.js");
414
- setConversationTelegramMsgId(assistantLogId, botMsgId);
415
- }
416
- catch { }
441
+ finally {
442
+ placeholderMsgId = undefined;
443
+ lastEditedText = "";
417
444
  }
418
- // React ✅ on the user's original message to signal completion
419
- try {
420
- await getBot().api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
445
+ });
446
+ }
447
+ else {
448
+ // Progressive streaming: update placeholder periodically with delta threshold
449
+ const now = Date.now();
450
+ const textDelta = Math.abs(text.length - lastEditedText.length);
451
+ if (now - lastEditTime >= EDIT_INTERVAL_MS && textDelta >= MIN_EDIT_DELTA) {
452
+ lastEditTime = now;
453
+ // Show beginning + end for context instead of just the tail
454
+ let preview;
455
+ if (text.length > 4000) {
456
+ preview = text.slice(0, 1800) + "\n\n⋯\n\n" + text.slice(-1800);
421
457
  }
422
- catch {
423
- /* reactions may not be available */
458
+ else {
459
+ preview = text;
424
460
  }
461
+ const statusLine = currentToolName ? `🔧 ${currentToolName}\n\n` : "";
462
+ enqueueEdit(statusLine + preview);
425
463
  }
426
- finally {
427
- placeholderMsgId = undefined;
428
- lastEditedText = "";
429
- }
430
- });
431
- }
432
- else {
433
- // Progressive streaming: update placeholder periodically with delta threshold
434
- const now = Date.now();
435
- const textDelta = Math.abs(text.length - lastEditedText.length);
436
- if (now - lastEditTime >= EDIT_INTERVAL_MS && textDelta >= MIN_EDIT_DELTA) {
437
- lastEditTime = now;
438
- // Show beginning + end for context instead of just the tail
439
- let preview;
440
- if (text.length > 4000) {
441
- preview = text.slice(0, 1800) + "\n\n⋯\n\n" + text.slice(-1800);
442
- }
443
- else {
444
- preview = text;
445
- }
446
- const statusLine = currentToolName ? `🔧 ${currentToolName}\n\n` : "";
447
- enqueueEdit(statusLine + preview);
464
+ }
465
+ }, onToolEvent, onUsage);
466
+ }
467
+ catch (err) {
468
+ console.error("[nzb] sendToOrchestrator threw:", err instanceof Error ? err.message : err);
469
+ // Attempt to notify user of the failure
470
+ try {
471
+ const errorText = `⚠️ Error: Failed to process message. Please try again.`;
472
+ if (placeholderMsgId) {
473
+ await editSafe(getBot().api, chatId, placeholderMsgId, errorText);
474
+ }
475
+ else {
476
+ await ctx.reply(errorText, { reply_parameters: replyParams });
448
477
  }
449
478
  }
450
- }, onToolEvent, onUsage);
479
+ catch {
480
+ /* nothing more we can do */
481
+ }
482
+ }
483
+ finally {
484
+ stopTypingAndClearTimeout();
485
+ }
451
486
  });
452
487
  }
453
488
  //# sourceMappingURL=streaming.js.map
@@ -82,16 +82,37 @@ export function buildSmartSuggestions(response, prompt, maxButtons = 4) {
82
82
  }
83
83
  // In-memory store for pending smart suggestion prompts (TTL: 5 minutes)
84
84
  const pendingPrompts = new Map();
85
+ const SUGGESTION_MAX_AGE_MS = 5 * 60_000;
86
+ const SUGGESTION_CLEANUP_INTERVAL_MS = 60_000;
87
+ const MAX_PENDING_PROMPTS = 1000;
88
+ // Periodic cleanup to prevent unbounded memory growth
89
+ setInterval(() => {
90
+ const cutoff = Date.now() - SUGGESTION_MAX_AGE_MS;
91
+ for (const [key, value] of pendingPrompts) {
92
+ if (value.timestamp < cutoff) {
93
+ pendingPrompts.delete(key);
94
+ }
95
+ }
96
+ }, SUGGESTION_CLEANUP_INTERVAL_MS).unref();
85
97
  /** Store the context for smart suggestion callbacks. */
86
98
  export function storeSuggestionContext(callbackPrefix, timeKey, prompt, response) {
87
99
  const key = `${callbackPrefix}:${timeKey}`;
88
100
  pendingPrompts.set(key, { prompt, response, timestamp: Date.now() });
89
101
  // Cleanup old entries (older than 5 minutes)
90
- const cutoff = Date.now() - 5 * 60_000;
102
+ const cutoff = Date.now() - SUGGESTION_MAX_AGE_MS;
91
103
  for (const [k, v] of pendingPrompts) {
92
104
  if (v.timestamp < cutoff)
93
105
  pendingPrompts.delete(k);
94
106
  }
107
+ // Enforce max size to prevent unbounded growth
108
+ if (pendingPrompts.size > MAX_PENDING_PROMPTS) {
109
+ const entries = Array.from(pendingPrompts.entries())
110
+ .sort((a, b) => a[1].timestamp - b[1].timestamp);
111
+ const toRemove = entries.slice(0, pendingPrompts.size - MAX_PENDING_PROMPTS);
112
+ for (const [rmKey] of toRemove) {
113
+ pendingPrompts.delete(rmKey);
114
+ }
115
+ }
95
116
  }
96
117
  /** Register callback handlers for smart suggestion buttons. */
97
118
  export function registerSmartSuggestionHandlers(bot) {
@@ -23,8 +23,8 @@ export async function sendLog(level, message) {
23
23
  try {
24
24
  await botRef.api.sendMessage(config.logChannelId, header + body, { parse_mode: "HTML" });
25
25
  }
26
- catch {
27
- // best-effort don't crash if log channel is unreachable
26
+ catch (err) {
27
+ console.error("[nzb] Log channel send failed:", err instanceof Error ? err.message : err);
28
28
  }
29
29
  }
30
30
  /** Convenience wrappers */