beecork 1.5.0 → 1.7.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.
Files changed (119) hide show
  1. package/dist/capabilities/index.d.ts +1 -1
  2. package/dist/capabilities/index.js +1 -1
  3. package/dist/capabilities/manager.js +13 -9
  4. package/dist/capabilities/packs.js +3 -1
  5. package/dist/channels/command-handler.js +46 -14
  6. package/dist/channels/discord.d.ts +3 -6
  7. package/dist/channels/discord.js +40 -23
  8. package/dist/channels/index.d.ts +1 -1
  9. package/dist/channels/loader.js +13 -3
  10. package/dist/channels/pipeline.js +14 -5
  11. package/dist/channels/registry.d.ts +17 -1
  12. package/dist/channels/registry.js +33 -4
  13. package/dist/channels/telegram.d.ts +20 -5
  14. package/dist/channels/telegram.js +177 -42
  15. package/dist/channels/types.d.ts +11 -28
  16. package/dist/channels/voice-state.js +3 -1
  17. package/dist/channels/webhook.d.ts +1 -4
  18. package/dist/channels/webhook.js +26 -11
  19. package/dist/channels/whatsapp.d.ts +8 -4
  20. package/dist/channels/whatsapp.js +65 -29
  21. package/dist/cli/capabilities.js +4 -4
  22. package/dist/cli/channel.js +16 -6
  23. package/dist/cli/commands.js +12 -9
  24. package/dist/cli/doctor.js +80 -25
  25. package/dist/cli/handoff.d.ts +7 -14
  26. package/dist/cli/handoff.js +9 -44
  27. package/dist/cli/mcp.js +5 -5
  28. package/dist/cli/media.js +21 -8
  29. package/dist/cli/setup.js +9 -8
  30. package/dist/cli/store.js +29 -12
  31. package/dist/config.js +5 -10
  32. package/dist/daemon.js +88 -38
  33. package/dist/dashboard/html.js +80 -12
  34. package/dist/dashboard/routes.js +143 -79
  35. package/dist/dashboard/server.js +5 -1
  36. package/dist/db/connection.d.ts +29 -0
  37. package/dist/db/connection.js +37 -0
  38. package/dist/db/index.js +30 -12
  39. package/dist/db/migrations.js +84 -28
  40. package/dist/delegation/manager.js +10 -4
  41. package/dist/index.js +39 -59
  42. package/dist/knowledge/manager.js +26 -12
  43. package/dist/mcp/handlers.js +126 -57
  44. package/dist/mcp/server.js +20 -10
  45. package/dist/mcp/tool-definitions.js +68 -20
  46. package/dist/mcp/validate.d.ts +23 -0
  47. package/dist/mcp/validate.js +65 -0
  48. package/dist/media/factory.js +18 -14
  49. package/dist/media/generators/dall-e.js +2 -2
  50. package/dist/media/generators/kling.js +4 -4
  51. package/dist/media/generators/lyria.js +1 -1
  52. package/dist/media/generators/nano-banana.d.ts +1 -1
  53. package/dist/media/generators/nano-banana.js +2 -2
  54. package/dist/media/generators/poll-util.js +4 -4
  55. package/dist/media/generators/recraft.js +3 -3
  56. package/dist/media/generators/runway.js +4 -4
  57. package/dist/media/generators/stable-diffusion.js +2 -2
  58. package/dist/media/generators/veo.js +1 -1
  59. package/dist/media/index.js +1 -1
  60. package/dist/media/store.d.ts +7 -0
  61. package/dist/media/store.js +18 -4
  62. package/dist/media/types.d.ts +22 -0
  63. package/dist/notifications/index.d.ts +2 -4
  64. package/dist/notifications/index.js +6 -19
  65. package/dist/notifications/ntfy.js +3 -3
  66. package/dist/observability/analytics.js +35 -13
  67. package/dist/projects/index.d.ts +1 -1
  68. package/dist/projects/index.js +1 -1
  69. package/dist/projects/manager.d.ts +0 -4
  70. package/dist/projects/manager.js +51 -28
  71. package/dist/projects/router.d.ts +2 -0
  72. package/dist/projects/router.js +70 -45
  73. package/dist/service/install.js +15 -5
  74. package/dist/service/windows.js +1 -1
  75. package/dist/session/budget-guard.d.ts +20 -0
  76. package/dist/session/budget-guard.js +31 -0
  77. package/dist/session/circuit-breaker.d.ts +5 -3
  78. package/dist/session/circuit-breaker.js +45 -20
  79. package/dist/session/context-compactor.d.ts +32 -0
  80. package/dist/session/context-compactor.js +45 -0
  81. package/dist/session/context-monitor.js +2 -2
  82. package/dist/session/handoff.d.ts +21 -0
  83. package/dist/session/handoff.js +50 -0
  84. package/dist/session/manager.d.ts +17 -5
  85. package/dist/session/manager.js +153 -146
  86. package/dist/session/memory-store.d.ts +29 -0
  87. package/dist/session/memory-store.js +45 -0
  88. package/dist/session/message-queue.d.ts +28 -0
  89. package/dist/session/message-queue.js +52 -0
  90. package/dist/session/pending-dispatcher.d.ts +31 -0
  91. package/dist/session/pending-dispatcher.js +120 -0
  92. package/dist/session/pending-store.d.ts +60 -0
  93. package/dist/session/pending-store.js +118 -0
  94. package/dist/session/stale-session.d.ts +31 -0
  95. package/dist/session/stale-session.js +45 -0
  96. package/dist/session/subprocess.d.ts +2 -0
  97. package/dist/session/subprocess.js +33 -11
  98. package/dist/session/tab-store.js +4 -3
  99. package/dist/tasks/scheduler.d.ts +7 -0
  100. package/dist/tasks/scheduler.js +46 -6
  101. package/dist/tasks/store.js +20 -6
  102. package/dist/timeline/logger.js +3 -1
  103. package/dist/timeline/query.js +9 -3
  104. package/dist/types.d.ts +34 -9
  105. package/dist/util/auto-heal.js +15 -5
  106. package/dist/util/install-info.js +3 -1
  107. package/dist/util/logger.d.ts +1 -1
  108. package/dist/util/logger.js +63 -24
  109. package/dist/util/paths.d.ts +1 -0
  110. package/dist/util/paths.js +12 -2
  111. package/dist/util/retry.js +1 -1
  112. package/dist/util/text.js +13 -7
  113. package/dist/voice/index.js +5 -1
  114. package/dist/voice/stt.js +14 -6
  115. package/dist/voice/tts.js +1 -1
  116. package/dist/watchers/scheduler.js +9 -2
  117. package/package.json +18 -13
  118. package/dist/session/tool-classifier.d.ts +0 -4
  119. package/dist/session/tool-classifier.js +0 -56
@@ -4,13 +4,18 @@ import path from 'node:path';
4
4
  import { chunkText, parseTabMessage, formatTabbedResponse } from '../util/text.js';
5
5
  import { logger } from '../util/logger.js';
6
6
  import { retryWithBackoff } from '../util/retry.js';
7
+ import { sendChunkedResponse } from './send-helpers.js';
7
8
  import { getLogsDir } from '../util/paths.js';
8
9
  import { saveMedia, isOversized } from '../media/store.js';
9
10
  import { inboundLimiter, groupLimiter } from '../util/rate-limiter.js';
10
11
  import { processInboundMessage } from './pipeline.js';
11
12
  import { isChannelAdmin } from './admin.js';
12
13
  import { VoiceState } from './voice-state.js';
13
- const DEFAULT_GROUP_CONFIG = { activationMode: 'mention', maxResponsesPerMinute: 3, tabPerGroup: true };
14
+ const DEFAULT_GROUP_CONFIG = {
15
+ activationMode: 'mention',
16
+ maxResponsesPerMinute: 3,
17
+ tabPerGroup: true,
18
+ };
14
19
  /**
15
20
  * Strip Telegram bot tokens out of strings before logging. Telegram embeds
16
21
  * the token in the URL path (e.g. https://api.telegram.org/bot1234:abc.../method),
@@ -23,16 +28,23 @@ export class TelegramChannel {
23
28
  id = 'telegram';
24
29
  name = 'Telegram';
25
30
  maxMessageLength = 4096;
26
- supportsStreaming = true;
27
- supportsMedia = true;
28
31
  bot;
29
32
  ctx;
30
33
  activeChatIds = new Set();
34
+ // Set form of config.telegram.allowedUserIds for O(1) per-message membership
35
+ // checks. Built lazily in start() so config edits via reload (if added later)
36
+ // can call rebuildAllowedSet().
37
+ allowedUserIdSet = new Set();
31
38
  voice = new VoiceState('telegram');
32
39
  botUserId = null;
33
40
  botUsername = null;
34
41
  mutedGroups = new Set();
35
42
  welcomeSent = new Set();
43
+ // Polling-error tracking: warn the user once when the bot is silently broken
44
+ // (network drop, telegram 5xx, token revoked, etc.). Without this, the daemon
45
+ // looks fine but Telegram has stopped delivering inbound messages.
46
+ pollingErrorTimes = [];
47
+ pollingDegradedNotified = false;
36
48
  constructor(ctx) {
37
49
  this.ctx = ctx;
38
50
  this.bot = new TelegramBot(ctx.config.telegram.token, {
@@ -47,7 +59,9 @@ export class TelegramChannel {
47
59
  this.bot.sendMessage = this.bot.sendMessage.bind(this.bot);
48
60
  }
49
61
  async start() {
50
- // Clear pending updates from old sessions, then start polling
62
+ // Clear pending updates from old sessions, then start polling.
63
+ // node-telegram-bot-api's TS types don't include deleteWebHook, so we go
64
+ // through a typed shim rather than `as any`.
51
65
  try {
52
66
  await this.bot.deleteWebHook({ drop_pending_updates: true });
53
67
  }
@@ -56,7 +70,22 @@ export class TelegramChannel {
56
70
  }
57
71
  // Initialize voice providers (STT + TTS)
58
72
  this.voice.init(this.ctx.config);
73
+ // Subscribe to library-level error events BEFORE startPolling so transient
74
+ // failures don't disappear into the void. node-telegram-bot-api emits
75
+ // 'polling_error' on network/auth/5xx issues — without a listener these
76
+ // were previously silently dropped, leaving the channel dead with no signal.
77
+ this.bot.on('polling_error', (err) => {
78
+ logger.error('Telegram polling error:', sanitizeBotToken(err?.message || String(err)));
79
+ this.recordPollingError();
80
+ });
81
+ this.bot.on('error', (err) => {
82
+ logger.error('Telegram client error:', sanitizeBotToken(err?.message || String(err)));
83
+ });
59
84
  this.bot.startPolling();
85
+ this.allowedUserIdSet = new Set(this.ctx.config.telegram.allowedUserIds);
86
+ if (this.allowedUserIdSet.size === 0) {
87
+ logger.warn('Telegram allowedUserIds is empty — bot will reject all inbound messages until you add at least one user ID.');
88
+ }
60
89
  // Cache bot identity for group mention detection
61
90
  try {
62
91
  const me = await this.bot.getMe();
@@ -69,11 +98,32 @@ export class TelegramChannel {
69
98
  this.setupHandlers();
70
99
  logger.info('Telegram bot started (polling mode, cleared pending updates)');
71
100
  }
101
+ /**
102
+ * Track polling errors over a 60s rolling window. If we see 5+ errors in 60s
103
+ * we surface "polling degraded" exactly once via the notify callback so the
104
+ * user knows Telegram is broken instead of silently failing.
105
+ */
106
+ recordPollingError() {
107
+ const now = Date.now();
108
+ this.pollingErrorTimes.push(now);
109
+ this.pollingErrorTimes = this.pollingErrorTimes.filter((t) => now - t < 60_000);
110
+ if (this.pollingErrorTimes.length >= 5 && !this.pollingDegradedNotified) {
111
+ this.pollingDegradedNotified = true;
112
+ this.ctx
113
+ .notifyCallback?.('⚠️ Telegram polling degraded (5+ errors in 60s). Check daemon.log.')
114
+ .catch((err) => logger.warn('Failed to send polling-degraded notice:', err));
115
+ // Reset notification flag after 5 minutes so a sustained outage that
116
+ // recovers and reoccurs can re-notify.
117
+ setTimeout(() => {
118
+ this.pollingDegradedNotified = false;
119
+ }, 5 * 60_000).unref();
120
+ }
121
+ }
72
122
  stop() {
73
123
  this.bot.stopPolling();
74
124
  logger.info('Telegram bot stopped');
75
125
  }
76
- async sendMessage(peerId, text, options) {
126
+ async sendMessage(peerId, text, _options) {
77
127
  const chatId = Number(peerId);
78
128
  const chunks = chunkText(text);
79
129
  for (const chunk of chunks) {
@@ -109,6 +159,46 @@ export class TelegramChannel {
109
159
  }
110
160
  }
111
161
  }
162
+ /**
163
+ * Send a media file to every recipient the bot would notify. Used by the
164
+ * pending-message dispatcher to deliver media queued by MCP tools.
165
+ * Routes by attachment type to the right Telegram primitive (sendVoice for
166
+ * voice messages, sendPhoto for images, sendVideo for video, sendDocument
167
+ * for everything else).
168
+ */
169
+ async broadcastMedia(media) {
170
+ const recipients = new Set(this.activeChatIds);
171
+ for (const userId of this.ctx.config.telegram.allowedUserIds)
172
+ recipients.add(userId);
173
+ if (recipients.size === 0)
174
+ return;
175
+ const caption = media.caption ? { caption: media.caption.slice(0, 1024) } : undefined;
176
+ for (const chatId of recipients) {
177
+ try {
178
+ switch (media.type) {
179
+ case 'voice':
180
+ await this.bot.sendVoice(chatId, media.filePath, caption);
181
+ break;
182
+ case 'audio':
183
+ await this.bot.sendAudio(chatId, media.filePath, caption);
184
+ break;
185
+ case 'image':
186
+ await this.bot.sendPhoto(chatId, media.filePath, caption);
187
+ break;
188
+ case 'video':
189
+ await this.bot.sendVideo(chatId, media.filePath, caption);
190
+ break;
191
+ case 'document':
192
+ default:
193
+ await this.bot.sendDocument(chatId, media.filePath, caption);
194
+ break;
195
+ }
196
+ }
197
+ catch (err) {
198
+ logger.warn(`Telegram broadcastMedia to ${chatId} failed:`, sanitizeBotToken(err instanceof Error ? err.message : String(err)));
199
+ }
200
+ }
201
+ }
112
202
  async setTyping(peerId, active) {
113
203
  if (active) {
114
204
  await this.bot.sendChatAction(Number(peerId), 'typing').catch((err) => {
@@ -116,9 +206,6 @@ export class TelegramChannel {
116
206
  });
117
207
  }
118
208
  }
119
- onMessage(_handler) {
120
- // Messages are handled directly in setupHandlers()
121
- }
122
209
  // ─── Private ───
123
210
  setupHandlers() {
124
211
  this.bot.on('message', async (msg) => {
@@ -140,14 +227,14 @@ export class TelegramChannel {
140
227
  const welcomeText = msg.text?.trim() || '';
141
228
  await this.bot.sendMessage(chatId, [
142
229
  '\uD83D\uDC4B Welcome to Beecork!\n',
143
- 'Send any message and I\'ll pass it to Claude Code.',
230
+ "Send any message and I'll pass it to Claude Code.",
144
231
  '',
145
232
  'Quick tips:',
146
233
  '\u2022 /tab name message \u2014 organize work into tabs',
147
- '\u2022 /tabs \u2014 see what\'s running',
234
+ "\u2022 /tabs \u2014 see what's running",
148
235
  '\u2022 /stop name \u2014 stop a tab',
149
236
  '',
150
- 'Let\'s get started! Send me something.',
237
+ "Let's get started! Send me something.",
151
238
  ].join('\n'));
152
239
  // Don't return - let the actual message be processed too (unless it was just /start)
153
240
  if (welcomeText === '/start')
@@ -180,7 +267,9 @@ export class TelegramChannel {
180
267
  shouldActivate = !!isReplyToBot;
181
268
  break;
182
269
  case 'keyword':
183
- shouldActivate = groupConfig.keywords?.some(kw => text.toLowerCase().includes(kw.toLowerCase())) ?? false;
270
+ shouldActivate =
271
+ groupConfig.keywords?.some((kw) => text.toLowerCase().includes(kw.toLowerCase())) ??
272
+ false;
184
273
  break;
185
274
  case 'always':
186
275
  shouldActivate = true;
@@ -206,34 +295,61 @@ export class TelegramChannel {
206
295
  if (msg.photo) {
207
296
  const photo = msg.photo[msg.photo.length - 1];
208
297
  downloadTasks.push(this.downloadTelegramFile(photo.file_id, 'jpg')
209
- .then(fp => fp ? { type: 'image', mimeType: 'image/jpeg', filePath: fp, fileName: `photo-${photo.file_id}.jpg` } : null)
298
+ .then((fp) => fp
299
+ ? {
300
+ type: 'image',
301
+ mimeType: 'image/jpeg',
302
+ filePath: fp,
303
+ fileName: `photo-${photo.file_id}.jpg`,
304
+ }
305
+ : null)
210
306
  .catch(() => null));
211
307
  }
212
308
  if (msg.voice) {
213
309
  downloadTasks.push(this.downloadTelegramFile(msg.voice.file_id, 'ogg')
214
- .then(fp => fp ? { type: 'voice', mimeType: 'audio/ogg', filePath: fp, duration: msg.voice.duration } : null)
310
+ .then((fp) => fp ? { type: 'voice', mimeType: 'audio/ogg', filePath: fp } : null)
215
311
  .catch(() => null));
216
312
  }
217
313
  if (msg.audio) {
218
314
  downloadTasks.push(this.downloadTelegramFile(msg.audio.file_id, 'mp3')
219
- .then(fp => fp ? { type: 'audio', mimeType: msg.audio.mime_type || 'audio/mpeg', filePath: fp, fileName: msg.audio.title, duration: msg.audio.duration } : null)
315
+ .then((fp) => fp
316
+ ? {
317
+ type: 'audio',
318
+ mimeType: msg.audio.mime_type || 'audio/mpeg',
319
+ filePath: fp,
320
+ fileName: msg.audio.title,
321
+ }
322
+ : null)
220
323
  .catch(() => null));
221
324
  }
222
325
  if (msg.document) {
223
326
  const ext = msg.document.file_name?.split('.').pop() || 'bin';
224
327
  downloadTasks.push(this.downloadTelegramFile(msg.document.file_id, ext)
225
- .then(fp => fp ? { type: 'document', mimeType: msg.document.mime_type || 'application/octet-stream', filePath: fp, fileName: msg.document.file_name } : null)
328
+ .then((fp) => fp
329
+ ? {
330
+ type: 'document',
331
+ mimeType: msg.document.mime_type || 'application/octet-stream',
332
+ filePath: fp,
333
+ fileName: msg.document.file_name,
334
+ }
335
+ : null)
226
336
  .catch(() => null));
227
337
  }
228
338
  if (msg.video) {
229
339
  downloadTasks.push(this.downloadTelegramFile(msg.video.file_id, 'mp4')
230
- .then(fp => fp ? { type: 'video', mimeType: msg.video.mime_type || 'video/mp4', filePath: fp, duration: msg.video.duration } : null)
340
+ .then((fp) => fp
341
+ ? {
342
+ type: 'video',
343
+ mimeType: msg.video.mime_type || 'video/mp4',
344
+ filePath: fp,
345
+ }
346
+ : null)
231
347
  .catch(() => null));
232
348
  }
233
349
  const downloadResults = await Promise.allSettled(downloadTasks);
234
350
  const media = downloadResults
235
351
  .filter((r) => r.status === 'fulfilled' && r.value !== null)
236
- .map(r => r.value);
352
+ .map((r) => r.value);
237
353
  // Transcribe voice messages if STT is configured
238
354
  await this.voice.transcribe(media);
239
355
  // Skip if no text AND no media
@@ -259,8 +375,9 @@ export class TelegramChannel {
259
375
  logger.error('Telegram: error handling message:', err);
260
376
  // Wrap the fallback send so a Telegram outage doesn't escalate to an
261
377
  // unhandledRejection on the message-event handler.
262
- this.bot.sendMessage(chatId, 'Something went wrong processing your message. Check daemon logs for details.')
263
- .catch(sendErr => logger.error('Telegram: failed to send fallback error message:', sendErr));
378
+ this.bot
379
+ .sendMessage(chatId, 'Something went wrong processing your message. Check daemon logs for details.')
380
+ .catch((sendErr) => logger.error('Telegram: failed to send fallback error message:', sendErr));
264
381
  }
265
382
  });
266
383
  }
@@ -323,9 +440,6 @@ export class TelegramChannel {
323
440
  this.bot.sendMessage(chatId, `Still working on your request...`).catch(() => { });
324
441
  }, 120000);
325
442
  try {
326
- let responseText;
327
- let responseError;
328
- let responseTab;
329
443
  // Telegram-specific: streaming message edits
330
444
  let streamMsgId = null;
331
445
  let streamBuffer = '';
@@ -340,8 +454,8 @@ export class TelegramChannel {
340
454
  return;
341
455
  lastEditTime = now;
342
456
  try {
343
- const prefix = effectiveTabForStream !== 'default' ? `[${effectiveTabForStream}] ` : '';
344
- const preview = prefix + streamBuffer.slice(0, 4000) + (streamBuffer.length > 4000 ? '...' : '');
457
+ const truncated = streamBuffer.slice(0, 4000) + (streamBuffer.length > 4000 ? '...' : '');
458
+ const preview = formatTabbedResponse(truncated, effectiveTabForStream);
345
459
  if (!streamMsgId) {
346
460
  const sent = await this.bot.sendMessage(chatId, preview);
347
461
  streamMsgId = sent.message_id;
@@ -350,7 +464,9 @@ export class TelegramChannel {
350
464
  await this.bot.editMessageText(preview, { chat_id: chatId, message_id: streamMsgId });
351
465
  }
352
466
  }
353
- catch { /* edit failures are non-critical */ }
467
+ catch {
468
+ /* edit failures are non-critical */
469
+ }
354
470
  };
355
471
  // Shared pipeline handles: routing, media prompt, progress, sendMessage, TTS
356
472
  const pipelineResult = await processInboundMessage({
@@ -375,9 +491,9 @@ export class TelegramChannel {
375
491
  }
376
492
  // Update the effective tab for stream prefix (now known)
377
493
  effectiveTabForStream = pipelineResult.tabName;
378
- responseText = pipelineResult.responseText;
379
- responseError = pipelineResult.isError;
380
- responseTab = pipelineResult.tabName;
494
+ const responseText = pipelineResult.responseText;
495
+ const responseError = pipelineResult.isError;
496
+ const responseTab = pipelineResult.tabName;
381
497
  // Telegram-specific: if streaming was active and no error, edit the final message
382
498
  if (streamMsgId && !responseError) {
383
499
  clearInterval(typingInterval);
@@ -390,8 +506,7 @@ export class TelegramChannel {
390
506
  return;
391
507
  }
392
508
  try {
393
- const prefix = responseTab !== 'default' ? `[${responseTab}] ` : '';
394
- const finalText = prefix + responseText;
509
+ const finalText = formatTabbedResponse(responseText, responseTab);
395
510
  if (finalText.length <= 4096) {
396
511
  await this.bot.editMessageText(finalText, { chat_id: chatId, message_id: streamMsgId });
397
512
  }
@@ -437,28 +552,37 @@ export class TelegramChannel {
437
552
  async sendResponse(chatId, text, tabName) {
438
553
  const fullText = formatTabbedResponse(text, tabName);
439
554
  const chunks = chunkText(fullText);
440
- // Telegram-specific: if the response would be >10 chunks, send a preview + the rest as a file.
555
+ // Telegram-specific quirk: if the response would be >10 chunks, send a
556
+ // preview + the rest as a file. This runs BEFORE sendChunkedResponse so the
557
+ // helper is only invoked for normal-sized responses.
441
558
  if (chunks.length > 10) {
442
559
  for (let i = 0; i < 3; i++) {
443
560
  await this.sendWithRetry(chatId, chunks[i]);
444
561
  }
445
562
  const tmpPath = path.join(getLogsDir(), `response-${Date.now()}.txt`);
446
563
  fs.writeFileSync(tmpPath, fullText);
447
- await this.bot.sendDocument(chatId, tmpPath, { caption: `Full response (${chunks.length} chunks)` });
564
+ await this.bot.sendDocument(chatId, tmpPath, {
565
+ caption: `Full response (${chunks.length} chunks)`,
566
+ });
448
567
  fs.unlinkSync(tmpPath);
449
568
  return;
450
569
  }
451
- for (const chunk of chunks) {
452
- await this.sendWithRetry(chatId, chunk);
453
- }
570
+ // Use the shared chunked-send helper so prefix/chunk/retry logic stays
571
+ // identical across Telegram/Discord/WhatsApp. Quirky per-chunk error
572
+ // logging (delivery-failures.log) stays in sendWithRetry.
573
+ await sendChunkedResponse({
574
+ text,
575
+ tabName,
576
+ maxLength: this.maxMessageLength,
577
+ retryLabel: 'telegram-send',
578
+ sendChunk: (chunk) => this.sendWithRetryRaw(chatId, chunk),
579
+ });
454
580
  }
455
581
  async sendWithRetry(chatId, text) {
582
+ // Wrapped call used by the >10-chunk fallback path. retryWithBackoff +
583
+ // delivery-failures.log on permanent failure.
456
584
  try {
457
- await retryWithBackoff(
458
- // Send as plain text — Telegram's legacy "Markdown" parser silently mangles
459
- // underscores/asterisks in Claude's responses (code identifiers, names),
460
- // and Beecork has no escaping pass for it.
461
- () => this.bot.sendMessage(chatId, text), [1000, 5000, 15000], 'telegram-send');
585
+ await this.sendWithRetryRaw(chatId, text);
462
586
  }
463
587
  catch (err) {
464
588
  const failLog = path.join(getLogsDir(), 'delivery-failures.log');
@@ -468,6 +592,13 @@ export class TelegramChannel {
468
592
  logger.error(`Delivery failed after retries for chat ${chatId}`);
469
593
  }
470
594
  }
595
+ async sendWithRetryRaw(chatId, text) {
596
+ await retryWithBackoff(
597
+ // Send as plain text — Telegram's legacy "Markdown" parser silently mangles
598
+ // underscores/asterisks in Claude's responses (code identifiers, names),
599
+ // and Beecork has no escaping pass for it.
600
+ () => this.bot.sendMessage(chatId, text), [1000, 5000, 15000], 'telegram-send');
601
+ }
471
602
  async downloadTelegramFile(fileId, extension) {
472
603
  const fileInfo = await this.bot.getFile(fileId);
473
604
  if (!fileInfo.file_path)
@@ -487,7 +618,11 @@ export class TelegramChannel {
487
618
  isAllowed(userId) {
488
619
  if (!userId)
489
620
  return false;
490
- return this.ctx.config.telegram.allowedUserIds.includes(userId);
621
+ // Set-based O(1) membership check; explicit empty-set check keeps the
622
+ // fail-closed contract documented in code.
623
+ if (this.allowedUserIdSet.size === 0)
624
+ return false;
625
+ return this.allowedUserIdSet.has(userId);
491
626
  }
492
627
  isAdmin(userId) {
493
628
  const cfg = this.ctx.config.telegram;
@@ -1,27 +1,12 @@
1
1
  import type { TabManager } from '../session/manager.js';
2
2
  import type { BeecorkConfig, MediaAttachment } from '../types.js';
3
3
  export type { MediaAttachment };
4
- /** An inbound message from any channel */
5
- export interface InboundMessage {
6
- channelId: string;
7
- peerId: string;
8
- text?: string;
9
- media?: MediaAttachment[];
10
- replyTo?: string;
11
- isGroup: boolean;
12
- groupId?: string;
13
- isMentioned?: boolean;
14
- isReply?: boolean;
15
- messageId: string;
16
- raw: unknown;
17
- }
18
- /** Options for sending a message */
19
- export interface SendOptions {
20
- parseMode?: 'markdown' | 'plain';
21
- replyToMessageId?: string;
22
- }
23
- /** Handler for inbound messages */
24
- export type InboundMessageHandler = (message: InboundMessage) => Promise<void>;
4
+ /**
5
+ * Options for sending a message. Currently empty — channels do not honor
6
+ * markdown/reply-threading hints. Kept as a placeholder so future per-channel
7
+ * options can be added without touching every call site.
8
+ */
9
+ export type SendOptions = Record<string, never>;
25
10
  /** The Channel interface — all channels must implement this */
26
11
  export interface Channel {
27
12
  /** Unique channel identifier (e.g., 'telegram', 'whatsapp', 'discord') */
@@ -30,24 +15,22 @@ export interface Channel {
30
15
  readonly name: string;
31
16
  /** Maximum message length in characters */
32
17
  readonly maxMessageLength: number;
33
- /** Whether this channel supports live streaming of responses */
34
- readonly supportsStreaming: boolean;
35
- /** Whether this channel supports media attachments */
36
- readonly supportsMedia: boolean;
37
18
  /** Start the channel (connect, start polling, etc.) */
38
19
  start(): Promise<void>;
39
20
  /** Stop the channel gracefully */
40
21
  stop(): void;
41
22
  /** Send a text message to a specific peer */
42
23
  sendMessage(peerId: string, text: string, options?: SendOptions): Promise<void>;
43
- /** Send a media attachment to a peer (optional — check supportsMedia) */
24
+ /** Send a media attachment to a peer (optional — channels implement when supported) */
44
25
  sendMedia?(peerId: string, media: MediaAttachment): Promise<void>;
45
26
  /** Send a notification to all configured recipients */
46
27
  sendNotification(message: string, urgent?: boolean): Promise<void>;
47
28
  /** Set typing indicator for a peer */
48
29
  setTyping(peerId: string, active: boolean): Promise<void>;
49
- /** Register the inbound message handlercalled by the registry */
50
- onMessage(handler: InboundMessageHandler): void;
30
+ /** Broadcast a media file to every configured recipient used by the
31
+ * pending-message dispatcher to deliver MCP-queued media. Optional; channels
32
+ * that don't implement it fall back to sendNotification with a text summary. */
33
+ broadcastMedia?(media: MediaAttachment): Promise<void>;
51
34
  }
52
35
  /** Context passed to channels during construction */
53
36
  export interface ChannelContext {
@@ -27,7 +27,9 @@ export class VoiceState {
27
27
  try {
28
28
  this.stt.warmup?.();
29
29
  }
30
- catch { /* warmup is best-effort */ }
30
+ catch {
31
+ /* warmup is best-effort */
32
+ }
31
33
  this.warmedUp = true;
32
34
  }
33
35
  /**
@@ -1,16 +1,13 @@
1
- import type { Channel, ChannelContext, InboundMessageHandler, SendOptions } from './types.js';
1
+ import type { Channel, ChannelContext, SendOptions } from './types.js';
2
2
  export declare class WebhookChannel implements Channel {
3
3
  readonly id = "webhook";
4
4
  readonly name = "Webhook";
5
5
  readonly maxMessageLength: 100000;
6
- readonly supportsStreaming = false;
7
- readonly supportsMedia = false;
8
6
  private server;
9
7
  private ctx;
10
8
  constructor(ctx: ChannelContext);
11
9
  start(): Promise<void>;
12
10
  stop(): void;
13
- onMessage(_handler: InboundMessageHandler): void;
14
11
  sendMessage(_peerId: string, _text: string, _options?: SendOptions): Promise<void>;
15
12
  sendNotification(_message: string, _urgent?: boolean): Promise<void>;
16
13
  setTyping(_peerId: string, _active: boolean): Promise<void>;
@@ -21,8 +21,6 @@ export class WebhookChannel {
21
21
  id = 'webhook';
22
22
  name = 'Webhook';
23
23
  maxMessageLength = MESSAGE_LIMITS.WEBHOOK_PROMPT;
24
- supportsStreaming = false;
25
- supportsMedia = false;
26
24
  server = null;
27
25
  ctx;
28
26
  constructor(ctx) {
@@ -32,6 +30,16 @@ export class WebhookChannel {
32
30
  const config = this.getConfig();
33
31
  if (!config?.enabled)
34
32
  return;
33
+ // Fail-secure: a webhook running with no auth turns localhost-injected
34
+ // prompts (from any local process or any user on a shared host) into
35
+ // arbitrary claude --dangerously-skip-permissions runs. Require either
36
+ // an authToken or hmacSecret, OR an explicit allowUnauthLocalhost opt-in.
37
+ if (!config.authToken && !config.hmacSecret && !config.allowUnauthLocalhost) {
38
+ logger.error('Webhook channel refusing to start: no authToken or hmacSecret configured. ' +
39
+ 'Set one in ~/.beecork/config.json under webhook.authToken/hmacSecret, or ' +
40
+ 'explicitly opt in with webhook.allowUnauthLocalhost=true (NOT recommended on shared hosts).');
41
+ return;
42
+ }
35
43
  const port = config.port || 8374;
36
44
  this.server = http.createServer(async (req, res) => {
37
45
  // CORS headers for API clients
@@ -111,7 +119,9 @@ export class WebhookChannel {
111
119
  channelId: this.id,
112
120
  tabManager: this.ctx.tabManager,
113
121
  userId: remote,
114
- sendProgress: () => { },
122
+ sendProgress: () => {
123
+ /* webhook has no progress channel */
124
+ },
115
125
  overrideTabName: tabName,
116
126
  });
117
127
  res.writeHead(result.isError ? 500 : 200);
@@ -131,16 +141,24 @@ export class WebhookChannel {
131
141
  channelId: this.id,
132
142
  tabManager: this.ctx.tabManager,
133
143
  userId: remote,
134
- sendProgress: () => { },
144
+ sendProgress: () => {
145
+ /* webhook has no progress channel */
146
+ },
135
147
  overrideTabName: tabName,
136
- }).then(result => {
148
+ })
149
+ .then((result) => {
137
150
  if (result.isError && this.ctx.notifyCallback) {
138
- this.ctx.notifyCallback(`Webhook async failed for "${tabName}": ${result.responseText}`).catch(() => { });
151
+ this.ctx
152
+ .notifyCallback(`Webhook async failed for "${tabName}": ${result.responseText}`)
153
+ .catch(() => { });
139
154
  }
140
- }).catch(err => {
155
+ })
156
+ .catch((err) => {
141
157
  logger.error(`Webhook async processing failed for tab ${tabName}:`, err);
142
158
  if (this.ctx.notifyCallback) {
143
- this.ctx.notifyCallback(`Webhook async failed for "${tabName}": ${err instanceof Error ? err.message : String(err)}`).catch(() => { });
159
+ this.ctx
160
+ .notifyCallback(`Webhook async failed for "${tabName}": ${err instanceof Error ? err.message : String(err)}`)
161
+ .catch(() => { });
144
162
  }
145
163
  });
146
164
  res.writeHead(202);
@@ -164,9 +182,6 @@ export class WebhookChannel {
164
182
  }
165
183
  logger.info('Webhook channel stopped');
166
184
  }
167
- onMessage(_handler) {
168
- // Webhooks handle messages directly in the HTTP handler
169
- }
170
185
  async sendMessage(_peerId, _text, _options) {
171
186
  // Webhooks are request-response — responses are sent in the HTTP handler
172
187
  }
@@ -1,10 +1,8 @@
1
- import type { Channel, ChannelContext, InboundMessageHandler, SendOptions } from './types.js';
1
+ import type { Channel, ChannelContext, SendOptions } from './types.js';
2
2
  export declare class WhatsAppChannel implements Channel {
3
3
  readonly id = "whatsapp";
4
4
  readonly name = "WhatsApp";
5
5
  readonly maxMessageLength = 8192;
6
- readonly supportsStreaming = false;
7
- readonly supportsMedia = true;
8
6
  private sock;
9
7
  private ctx;
10
8
  private allowedNumbers;
@@ -13,12 +11,18 @@ export declare class WhatsAppChannel implements Channel {
13
11
  private readonly backoffDelays;
14
12
  private voice;
15
13
  constructor(ctx: ChannelContext);
14
+ /**
15
+ * Schedule the next reconnect with exponential backoff. Unlike the previous
16
+ * inline setTimeout, this path retries when `start()` itself rejects (auth
17
+ * failure, baileys init throw, etc.) instead of going permanently silent
18
+ * after a single failed attempt.
19
+ */
20
+ private scheduleReconnect;
16
21
  start(): Promise<void>;
17
22
  stop(): void;
18
23
  sendMessage(peerId: string, text: string, _options?: SendOptions): Promise<void>;
19
24
  sendNotification(message: string, _urgent?: boolean): Promise<void>;
20
25
  setTyping(peerId: string, active: boolean): Promise<void>;
21
- onMessage(_handler: InboundMessageHandler): void;
22
26
  private sendResponse;
23
27
  private isAllowed;
24
28
  }