@iletai/nzb 1.6.4 → 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.
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { InlineKeyboard } from "grammy";
2
3
  import { config } from "../../config.js";
3
4
  import { getQueueSize, sendToOrchestrator } from "../../copilot/orchestrator.js";
@@ -56,6 +57,18 @@ export function registerMessageHandler(bot, getBot) {
56
57
  }
57
58
  };
58
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
+ };
59
72
  // Progressive streaming state — all Telegram API calls are serialized through editChain
60
73
  // to prevent duplicate placeholder messages and race conditions
61
74
  let placeholderMsgId;
@@ -75,6 +88,7 @@ export function registerMessageHandler(bot, getBot) {
75
88
  // prevents push notification spam for very short initial chunks (inspired by OpenClaw's draft-stream).
76
89
  const MIN_INITIAL_CHARS = 80;
77
90
  const handlerStartTime = Date.now();
91
+ const requestId = randomUUID();
78
92
  const enqueueEdit = (text) => {
79
93
  if (finalized || text === lastEditedText)
80
94
  return;
@@ -102,13 +116,15 @@ export function registerMessageHandler(bot, getBot) {
102
116
  const msg = await ctx.reply(safeText, { reply_parameters: replyParams });
103
117
  placeholderMsgId = msg.message_id;
104
118
  // Stop typing once placeholder is visible — edits serve as the indicator now
105
- stopTyping();
119
+ stopTypingAndClearTimeout();
106
120
  }
107
121
  catch {
108
122
  return;
109
123
  }
110
124
  }
111
125
  else {
126
+ if (finalized)
127
+ return;
112
128
  try {
113
129
  await editSafe(getBot().api, chatId, placeholderMsgId, safeText);
114
130
  }
@@ -121,7 +137,9 @@ export function registerMessageHandler(bot, getBot) {
121
137
  }
122
138
  lastEditedText = safeText;
123
139
  })
124
- .catch(() => { });
140
+ .catch((err) => {
141
+ console.error("[nzb] Edit chain error:", err instanceof Error ? err.message : err);
142
+ });
125
143
  };
126
144
  const onToolEvent = (event) => {
127
145
  console.log(`[nzb] Bot received tool event: ${event.type} ${event.toolName}`);
@@ -194,106 +212,73 @@ export function registerMessageHandler(bot, getBot) {
194
212
  userPrompt = `[Replying to: "${quoted}"]\n\n${userPrompt}`;
195
213
  }
196
214
  }
197
- sendToOrchestrator(userPrompt, { type: "telegram", chatId, messageId: userMessageId }, (text, done, meta) => {
198
- if (done) {
199
- finalized = true;
200
- stopTyping();
201
- const assistantLogId = meta?.assistantLogId;
202
- const elapsed = ((Date.now() - handlerStartTime) / 1000).toFixed(1);
203
- void logInfo(`✅ Response done (${elapsed}s, ${toolHistory.length} tools, ${text.length} chars)`);
204
- // Return the edit chain so callers can await final delivery
205
- return editChain.then(async () => {
206
- // Format error messages with a distinct visual
207
- const isError = text.startsWith("Error:");
208
- if (isError) {
209
- void logError(`Response error: ${text.slice(0, 200)}`);
210
- const errorText = `⚠️ ${text}`;
211
- const errorKb = new InlineKeyboard().text("🔄 Retry", "retry").text("📖 Explain", "explain_error");
212
- if (placeholderMsgId) {
213
- try {
214
- await editSafe(getBot().api, chatId, placeholderMsgId, errorText, { reply_markup: errorKb });
215
- return;
216
- }
217
- catch {
218
- /* fall through */
219
- }
220
- }
221
- try {
222
- await ctx.reply(errorText, { reply_parameters: replyParams, reply_markup: errorKb });
223
- }
224
- catch {
225
- /* nothing more we can do */
226
- }
227
- return;
228
- }
229
- let textWithMeta = text;
230
- if (usageInfo && config.usageMode !== "off") {
231
- const fmtTokens = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}K` : String(n));
232
- const parts = [];
233
- if (config.usageMode === "full" && usageInfo.model)
234
- parts.push(usageInfo.model);
235
- parts.push(`⬆${fmtTokens(usageInfo.inputTokens)} ⬇${fmtTokens(usageInfo.outputTokens)}`);
236
- const totalTokens = usageInfo.inputTokens + usageInfo.outputTokens;
237
- parts.push(`Σ${fmtTokens(totalTokens)}`);
238
- if (config.usageMode === "full" && usageInfo.duration)
239
- parts.push(`${(usageInfo.duration / 1000).toFixed(1)}s`);
240
- textWithMeta += `\n\n📊 ${parts.join(" · ")}`;
241
- }
242
- const formatted = toTelegramHTML(textWithMeta);
243
- let fullFormatted = formatted;
244
- if (config.showReasoning && toolHistory.length > 0) {
245
- const expandable = formatToolSummaryExpandable(toolHistory.map((t) => ({ name: t.name, durationMs: t.durationMs, detail: t.detail })), {
246
- elapsedMs: Date.now() - handlerStartTime,
247
- model: usageInfo?.model,
248
- inputTokens: usageInfo?.inputTokens,
249
- outputTokens: usageInfo?.outputTokens,
250
- });
251
- fullFormatted += expandable;
252
- }
253
- const chunks = chunkMessage(fullFormatted);
254
- const fallbackChunks = chunkMessage(textWithMeta);
255
- // Build smart suggestion buttons based on response content
256
- const smartKb = createSmartSuggestionsWithContext(text, ctx.message.text, 4);
257
- // Single chunk: edit placeholder in place
258
- if (placeholderMsgId && chunks.length === 1) {
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 () => {
259
225
  try {
260
- await editSafe(getBot().api, chatId, placeholderMsgId, chunks[0], {
261
- parse_mode: "HTML",
262
- reply_markup: smartKb,
263
- });
264
- try {
265
- await getBot().api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
266
- }
267
- catch { }
268
- if (assistantLogId) {
269
- try {
270
- const { setConversationTelegramMsgId } = await import("../../store/db.js");
271
- setConversationTelegramMsgId(assistantLogId, placeholderMsgId);
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
+ }
272
240
  }
273
- catch { }
274
- }
275
- return;
276
- }
277
- catch (err) {
278
- // "message is not modified" is harmless — placeholder already has this content
279
- if (isMessageNotModifiedError(err)) {
280
241
  try {
281
- await getBot().api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
242
+ await ctx.reply(errorText, { reply_parameters: replyParams, reply_markup: errorKb });
282
243
  }
283
- catch { }
284
- if (assistantLogId) {
285
- try {
286
- const { setConversationTelegramMsgId } = await import("../../store/db.js");
287
- setConversationTelegramMsgId(assistantLogId, placeholderMsgId);
288
- }
289
- catch { }
244
+ catch {
245
+ /* nothing more we can do */
290
246
  }
291
247
  return;
292
248
  }
293
- // HTML parse error — try plain text fallback
294
- if (isHtmlParseError(err)) {
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(" · ")}`;
261
+ }
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,
270
+ });
271
+ fullFormatted += expandable;
272
+ }
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) {
295
279
  try {
296
- await editSafe(getBot().api, chatId, placeholderMsgId, fallbackChunks[0], {
280
+ await editSafe(getBot().api, chatId, placeholderMsgId, chunks[0], {
281
+ parse_mode: "HTML",
297
282
  reply_markup: smartKb,
298
283
  });
299
284
  try {
@@ -309,135 +294,195 @@ export function registerMessageHandler(bot, getBot) {
309
294
  }
310
295
  return;
311
296
  }
312
- catch {
313
- /* fall through to send new messages */
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
+ }
336
+ try {
337
+ await editSafe(getBot().api, chatId, placeholderMsgId, fallbackChunks[0], {
338
+ reply_markup: smartKb,
339
+ });
340
+ try {
341
+ await getBot().api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
342
+ }
343
+ catch { }
344
+ if (assistantLogId) {
345
+ try {
346
+ const { setConversationTelegramMsgId } = await import("../../store/db.js");
347
+ setConversationTelegramMsgId(assistantLogId, placeholderMsgId);
348
+ }
349
+ catch { }
350
+ }
351
+ return;
352
+ }
353
+ catch {
354
+ /* fall through to send new messages */
355
+ }
314
356
  }
315
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) + " ⋯";
370
+ }
371
+ let safeFallback = fallback;
372
+ if (pageTag.length + safeFallback.length > TELEGRAM_MAX_LENGTH) {
373
+ safeFallback = safeFallback.slice(0, TELEGRAM_MAX_LENGTH - pageTag.length - 4) + " ⋯";
374
+ }
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;
389
+ };
390
+ let sendSucceeded = false;
316
391
  try {
317
- await editSafe(getBot().api, chatId, placeholderMsgId, fallbackChunks[0], {
318
- reply_markup: smartKb,
319
- });
320
- try {
321
- await getBot().api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
392
+ for (let i = 0; i < chunks.length; i++) {
393
+ if (i > 0)
394
+ await new Promise((r) => setTimeout(r, 300));
395
+ await sendChunk(chunks[i], fallbackChunks[i] ?? chunks[i], i);
322
396
  }
323
- catch { }
324
- if (assistantLogId) {
325
- try {
326
- const { setConversationTelegramMsgId } = await import("../../store/db.js");
327
- setConversationTelegramMsgId(assistantLogId, placeholderMsgId);
397
+ sendSucceeded = true;
398
+ }
399
+ catch {
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;
328
408
  }
329
- catch { }
409
+ sendSucceeded = true;
410
+ }
411
+ catch {
412
+ /* nothing more we can do */
330
413
  }
331
- return;
332
414
  }
333
- catch {
334
- /* fall through to send new messages */
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
+ }
335
423
  }
336
- }
337
- }
338
- // Multi-chunk or edit fallthrough: send new chunks FIRST, then delete placeholder
339
- const totalChunks = chunks.length;
340
- let firstSentMsgId;
341
- const sendChunk = async (chunk, fallback, index) => {
342
- const isFirst = index === 0 && !placeholderMsgId;
343
- const isLast = index === totalChunks - 1;
344
- // Pagination header for multi-chunk messages
345
- const pageTag = totalChunks > 1 ? `📄 ${index + 1}/${totalChunks}\n` : "";
346
- // Trim chunk if pageTag pushes it over the limit
347
- let safeChunk = chunk;
348
- if (pageTag.length + safeChunk.length > TELEGRAM_MAX_LENGTH) {
349
- safeChunk = safeChunk.slice(0, TELEGRAM_MAX_LENGTH - pageTag.length - 4) + " ⋯";
350
- }
351
- let safeFallback = fallback;
352
- if (pageTag.length + safeFallback.length > TELEGRAM_MAX_LENGTH) {
353
- safeFallback = safeFallback.slice(0, TELEGRAM_MAX_LENGTH - pageTag.length - 4) + " ⋯";
354
- }
355
- const opts = {
356
- parse_mode: "HTML",
357
- ...(isFirst ? { reply_parameters: replyParams } : {}),
358
- ...(isLast && smartKb ? { reply_markup: smartKb } : {}),
359
- };
360
- const fallbackOpts = {
361
- ...(isFirst ? { reply_parameters: replyParams } : {}),
362
- ...(isLast && smartKb ? { reply_markup: smartKb } : {}),
363
- };
364
- const sent = await ctx
365
- .reply(pageTag + safeChunk, opts)
366
- .catch(() => ctx.reply(pageTag + safeFallback, fallbackOpts));
367
- if (index === 0 && sent)
368
- firstSentMsgId = sent.message_id;
369
- };
370
- let sendSucceeded = false;
371
- try {
372
- for (let i = 0; i < chunks.length; i++) {
373
- if (i > 0)
374
- await new Promise((r) => setTimeout(r, 300));
375
- await sendChunk(chunks[i], fallbackChunks[i] ?? chunks[i], i);
376
- }
377
- sendSucceeded = true;
378
- }
379
- catch {
380
- try {
381
- for (let i = 0; i < fallbackChunks.length; i++) {
382
- if (i > 0)
383
- await new Promise((r) => setTimeout(r, 300));
384
- const pageTag = fallbackChunks.length > 1 ? `📄 ${i + 1}/${fallbackChunks.length}\n` : "";
385
- const sent = await ctx.reply(pageTag + fallbackChunks[i], i === 0 ? { reply_parameters: replyParams } : {});
386
- if (i === 0 && sent)
387
- firstSentMsgId = sent.message_id;
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
434
+ try {
435
+ await getBot().api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
436
+ }
437
+ catch {
438
+ /* reactions may not be available */
388
439
  }
389
- sendSucceeded = true;
390
- }
391
- catch {
392
- /* nothing more we can do */
393
440
  }
394
- }
395
- // Only delete placeholder AFTER new messages sent successfully
396
- if (placeholderMsgId && sendSucceeded) {
397
- try {
398
- await getBot().api.deleteMessage(chatId, placeholderMsgId);
441
+ finally {
442
+ placeholderMsgId = undefined;
443
+ lastEditedText = "";
399
444
  }
400
- catch {
401
- /* ignore — placeholder stays but user has the real message */
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);
402
457
  }
403
- }
404
- // Track bot message ID for reply-to context lookups
405
- const botMsgId = firstSentMsgId ?? placeholderMsgId;
406
- if (assistantLogId && botMsgId) {
407
- try {
408
- const { setConversationTelegramMsgId } = await import("../../store/db.js");
409
- setConversationTelegramMsgId(assistantLogId, botMsgId);
458
+ else {
459
+ preview = text;
410
460
  }
411
- catch { }
412
- }
413
- // React ✅ on the user's original message to signal completion
414
- try {
415
- await getBot().api.setMessageReaction(chatId, userMessageId, [{ type: "emoji", emoji: "👍" }]);
416
- }
417
- catch {
418
- /* reactions may not be available */
419
- }
420
- });
421
- }
422
- else {
423
- // Progressive streaming: update placeholder periodically with delta threshold
424
- const now = Date.now();
425
- const textDelta = Math.abs(text.length - lastEditedText.length);
426
- if (now - lastEditTime >= EDIT_INTERVAL_MS && textDelta >= MIN_EDIT_DELTA) {
427
- lastEditTime = now;
428
- // Show beginning + end for context instead of just the tail
429
- let preview;
430
- if (text.length > 4000) {
431
- preview = text.slice(0, 1800) + "\n\n⋯\n\n" + text.slice(-1800);
432
- }
433
- else {
434
- preview = text;
461
+ const statusLine = currentToolName ? `🔧 ${currentToolName}\n\n` : "";
462
+ enqueueEdit(statusLine + preview);
435
463
  }
436
- const statusLine = currentToolName ? `🔧 ${currentToolName}\n\n` : "";
437
- 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 });
438
477
  }
439
478
  }
440
- }, onToolEvent, onUsage);
479
+ catch {
480
+ /* nothing more we can do */
481
+ }
482
+ }
483
+ finally {
484
+ stopTypingAndClearTimeout();
485
+ }
441
486
  });
442
487
  }
443
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 */