beecork 1.4.11 → 1.6.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 (138) 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/admin.d.ts +10 -0
  6. package/dist/channels/admin.js +20 -0
  7. package/dist/channels/command-handler.d.ts +2 -10
  8. package/dist/channels/command-handler.js +90 -84
  9. package/dist/channels/discord.d.ts +4 -9
  10. package/dist/channels/discord.js +59 -42
  11. package/dist/channels/index.d.ts +1 -1
  12. package/dist/channels/loader.js +13 -4
  13. package/dist/channels/pipeline.js +14 -5
  14. package/dist/channels/registry.d.ts +17 -1
  15. package/dist/channels/registry.js +33 -4
  16. package/dist/channels/send-helpers.d.ts +19 -0
  17. package/dist/channels/send-helpers.js +21 -0
  18. package/dist/channels/telegram.d.ts +21 -14
  19. package/dist/channels/telegram.js +214 -104
  20. package/dist/channels/types.d.ts +13 -38
  21. package/dist/channels/voice-state.d.ts +29 -0
  22. package/dist/channels/voice-state.js +45 -0
  23. package/dist/channels/webhook.d.ts +2 -5
  24. package/dist/channels/webhook.js +88 -29
  25. package/dist/channels/whatsapp.d.ts +9 -7
  26. package/dist/channels/whatsapp.js +141 -100
  27. package/dist/cli/capabilities.js +4 -4
  28. package/dist/cli/channel.js +16 -6
  29. package/dist/cli/commands.js +12 -9
  30. package/dist/cli/doctor.js +85 -27
  31. package/dist/cli/handoff.d.ts +7 -14
  32. package/dist/cli/handoff.js +9 -44
  33. package/dist/cli/mcp.js +5 -5
  34. package/dist/cli/media.js +21 -8
  35. package/dist/cli/setup.js +9 -8
  36. package/dist/cli/store.js +29 -12
  37. package/dist/config.d.ts +5 -1
  38. package/dist/config.js +20 -22
  39. package/dist/daemon.js +113 -51
  40. package/dist/dashboard/html.js +100 -20
  41. package/dist/dashboard/routes.d.ts +17 -0
  42. package/dist/dashboard/routes.js +623 -0
  43. package/dist/dashboard/server.js +38 -489
  44. package/dist/db/connection.d.ts +29 -0
  45. package/dist/db/connection.js +37 -0
  46. package/dist/db/index.js +43 -11
  47. package/dist/db/migrations.js +114 -22
  48. package/dist/delegation/manager.js +10 -4
  49. package/dist/index.js +39 -59
  50. package/dist/knowledge/manager.js +26 -12
  51. package/dist/mcp/handlers.d.ts +37 -0
  52. package/dist/mcp/handlers.js +520 -0
  53. package/dist/mcp/server.js +44 -858
  54. package/dist/mcp/tool-definitions.d.ts +1225 -0
  55. package/dist/mcp/tool-definitions.js +412 -0
  56. package/dist/mcp/validate.d.ts +23 -0
  57. package/dist/mcp/validate.js +65 -0
  58. package/dist/media/factory.js +18 -14
  59. package/dist/media/generators/dall-e.js +2 -2
  60. package/dist/media/generators/kling.js +4 -4
  61. package/dist/media/generators/lyria.js +1 -1
  62. package/dist/media/generators/nano-banana.d.ts +1 -1
  63. package/dist/media/generators/nano-banana.js +2 -2
  64. package/dist/media/generators/poll-util.js +4 -4
  65. package/dist/media/generators/recraft.js +3 -3
  66. package/dist/media/generators/runway.js +4 -4
  67. package/dist/media/generators/stable-diffusion.js +2 -2
  68. package/dist/media/generators/veo.js +1 -1
  69. package/dist/media/index.d.ts +2 -7
  70. package/dist/media/index.js +2 -2
  71. package/dist/media/store.d.ts +7 -0
  72. package/dist/media/store.js +18 -4
  73. package/dist/media/types.d.ts +22 -0
  74. package/dist/notifications/index.d.ts +2 -4
  75. package/dist/notifications/index.js +6 -19
  76. package/dist/notifications/ntfy.js +3 -3
  77. package/dist/observability/analytics.d.ts +1 -1
  78. package/dist/observability/analytics.js +41 -16
  79. package/dist/projects/index.d.ts +3 -2
  80. package/dist/projects/index.js +2 -2
  81. package/dist/projects/manager.d.ts +1 -7
  82. package/dist/projects/manager.js +66 -42
  83. package/dist/projects/router.d.ts +12 -0
  84. package/dist/projects/router.js +98 -45
  85. package/dist/service/install.js +15 -5
  86. package/dist/service/windows.js +1 -1
  87. package/dist/session/budget-guard.d.ts +20 -0
  88. package/dist/session/budget-guard.js +31 -0
  89. package/dist/session/circuit-breaker.d.ts +5 -3
  90. package/dist/session/circuit-breaker.js +45 -20
  91. package/dist/session/context-compactor.d.ts +32 -0
  92. package/dist/session/context-compactor.js +45 -0
  93. package/dist/session/context-monitor.js +2 -2
  94. package/dist/session/handoff.d.ts +21 -0
  95. package/dist/session/handoff.js +50 -0
  96. package/dist/session/manager.d.ts +21 -5
  97. package/dist/session/manager.js +166 -153
  98. package/dist/session/memory-store.d.ts +29 -0
  99. package/dist/session/memory-store.js +45 -0
  100. package/dist/session/message-queue.d.ts +28 -0
  101. package/dist/session/message-queue.js +52 -0
  102. package/dist/session/pending-dispatcher.d.ts +31 -0
  103. package/dist/session/pending-dispatcher.js +120 -0
  104. package/dist/session/pending-store.d.ts +60 -0
  105. package/dist/session/pending-store.js +118 -0
  106. package/dist/session/stale-session.d.ts +31 -0
  107. package/dist/session/stale-session.js +45 -0
  108. package/dist/session/subprocess.d.ts +3 -0
  109. package/dist/session/subprocess.js +54 -11
  110. package/dist/session/tab-store.d.ts +28 -0
  111. package/dist/session/tab-store.js +78 -0
  112. package/dist/tasks/scheduler.d.ts +13 -0
  113. package/dist/tasks/scheduler.js +97 -18
  114. package/dist/tasks/store.js +26 -12
  115. package/dist/timeline/logger.js +3 -1
  116. package/dist/timeline/query.js +15 -5
  117. package/dist/types.d.ts +49 -9
  118. package/dist/util/auto-heal.js +15 -5
  119. package/dist/util/install-info.js +3 -1
  120. package/dist/util/logger.d.ts +1 -1
  121. package/dist/util/logger.js +63 -24
  122. package/dist/util/paths.d.ts +2 -0
  123. package/dist/util/paths.js +16 -3
  124. package/dist/util/rate-limiter.js +8 -0
  125. package/dist/util/retry.js +1 -1
  126. package/dist/util/text.d.ts +21 -1
  127. package/dist/util/text.js +38 -8
  128. package/dist/voice/index.js +5 -1
  129. package/dist/voice/stt.js +14 -6
  130. package/dist/voice/tts.js +1 -1
  131. package/dist/watchers/scheduler.js +11 -5
  132. package/package.json +6 -1
  133. package/dist/session/tool-classifier.d.ts +0 -4
  134. package/dist/session/tool-classifier.js +0 -56
  135. package/dist/users/index.d.ts +0 -2
  136. package/dist/users/index.js +0 -1
  137. package/dist/users/service.d.ts +0 -17
  138. package/dist/users/service.js +0 -46
@@ -1,41 +1,50 @@
1
1
  import TelegramBot from 'node-telegram-bot-api';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { chunkText, timeAgo, parseTabMessage } from '../util/text.js';
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 { getAdminUserId } from '../config.js';
7
+ import { sendChunkedResponse } from './send-helpers.js';
8
8
  import { getLogsDir } from '../util/paths.js';
9
9
  import { saveMedia, isOversized } from '../media/store.js';
10
10
  import { inboundLimiter, groupLimiter } from '../util/rate-limiter.js';
11
11
  import { processInboundMessage } from './pipeline.js';
12
- import { initVoiceProviders } from '../voice/index.js';
13
- const DEFAULT_GROUP_CONFIG = { activationMode: 'mention', maxResponsesPerMinute: 3, tabPerGroup: true };
14
- /** Format tab status for Telegram display */
15
- export function formatTabStatus(tabs) {
16
- if (tabs.length === 0)
17
- return 'No tabs.';
18
- return tabs.map(t => {
19
- const ago = timeAgo(t.lastActivityAt);
20
- return `• ${t.name} [${t.status}] ${ago}`;
21
- }).join('\n');
12
+ import { isChannelAdmin } from './admin.js';
13
+ import { VoiceState } from './voice-state.js';
14
+ const DEFAULT_GROUP_CONFIG = {
15
+ activationMode: 'mention',
16
+ maxResponsesPerMinute: 3,
17
+ tabPerGroup: true,
18
+ };
19
+ /**
20
+ * Strip Telegram bot tokens out of strings before logging. Telegram embeds
21
+ * the token in the URL path (e.g. https://api.telegram.org/bot1234:abc.../method),
22
+ * so on fetch errors the message/cause can leak the token to disk.
23
+ */
24
+ function sanitizeBotToken(text) {
25
+ return text.replace(/bot\d+:[A-Za-z0-9_-]+/g, 'bot<REDACTED>');
22
26
  }
23
27
  export class TelegramChannel {
24
28
  id = 'telegram';
25
29
  name = 'Telegram';
26
30
  maxMessageLength = 4096;
27
- supportsStreaming = true;
28
- supportsMedia = true;
29
31
  bot;
30
32
  ctx;
31
33
  activeChatIds = new Set();
32
- sttProvider = null;
33
- ttsProvider = null;
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();
38
+ voice = new VoiceState('telegram');
34
39
  botUserId = null;
35
40
  botUsername = null;
36
41
  mutedGroups = new Set();
37
42
  welcomeSent = new Set();
38
- sttWarmedUp = false;
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;
39
48
  constructor(ctx) {
40
49
  this.ctx = ctx;
41
50
  this.bot = new TelegramBot(ctx.config.telegram.token, {
@@ -50,18 +59,33 @@ export class TelegramChannel {
50
59
  this.bot.sendMessage = this.bot.sendMessage.bind(this.bot);
51
60
  }
52
61
  async start() {
53
- // 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`.
54
65
  try {
55
66
  await this.bot.deleteWebHook({ drop_pending_updates: true });
56
67
  }
57
68
  catch (err) {
58
69
  logger.error('Failed to clear pending updates, starting anyway:', err);
59
70
  }
60
- // Initialize voice providers
61
- const { stt, tts } = initVoiceProviders(this.ctx.config.voice);
62
- this.sttProvider = stt;
63
- this.ttsProvider = tts;
71
+ // Initialize voice providers (STT + TTS)
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
+ });
64
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
+ }
65
89
  // Cache bot identity for group mention detection
66
90
  try {
67
91
  const me = await this.bot.getMe();
@@ -74,11 +98,32 @@ export class TelegramChannel {
74
98
  this.setupHandlers();
75
99
  logger.info('Telegram bot started (polling mode, cleared pending updates)');
76
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
+ }
77
122
  stop() {
78
123
  this.bot.stopPolling();
79
124
  logger.info('Telegram bot stopped');
80
125
  }
81
- async sendMessage(peerId, text, options) {
126
+ async sendMessage(peerId, text, _options) {
82
127
  const chatId = Number(peerId);
83
128
  const chunks = chunkText(text);
84
129
  for (const chunk of chunks) {
@@ -101,7 +146,57 @@ export class TelegramChannel {
101
146
  await this.bot.sendMessage(userId, message);
102
147
  this.activeChatIds.add(userId);
103
148
  }
104
- catch { /* User hasn't started conversation yet */ }
149
+ catch (err) {
150
+ // Differentiate: 400 "chat not found" means the user has not started a
151
+ // conversation with the bot yet — silently skip. Anything else (rate
152
+ // limit, bot blocked, network) is a real delivery failure worth logging.
153
+ const errAny = err;
154
+ const status = errAny?.response?.statusCode;
155
+ const isChatNotFound = status === 400 || /chat not found/i.test(errAny?.message || '');
156
+ if (!isChatNotFound) {
157
+ logger.warn(`Telegram notify to ${userId} failed (status=${status ?? '?'}):`, sanitizeBotToken(errAny?.message || String(err)));
158
+ }
159
+ }
160
+ }
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
+ }
105
200
  }
106
201
  }
107
202
  async setTyping(peerId, active) {
@@ -111,9 +206,6 @@ export class TelegramChannel {
111
206
  });
112
207
  }
113
208
  }
114
- onMessage(_handler) {
115
- // Messages are handled directly in setupHandlers()
116
- }
117
209
  // ─── Private ───
118
210
  setupHandlers() {
119
211
  this.bot.on('message', async (msg) => {
@@ -135,14 +227,14 @@ export class TelegramChannel {
135
227
  const welcomeText = msg.text?.trim() || '';
136
228
  await this.bot.sendMessage(chatId, [
137
229
  '\uD83D\uDC4B Welcome to Beecork!\n',
138
- 'Send any message and I\'ll pass it to Claude Code.',
230
+ "Send any message and I'll pass it to Claude Code.",
139
231
  '',
140
232
  'Quick tips:',
141
- '\u2022 `/tab name message` \u2014 organize work into tabs',
142
- '\u2022 `/tabs` \u2014 see what\'s running',
143
- '\u2022 `/stop name` \u2014 stop a tab',
233
+ '\u2022 /tab name message \u2014 organize work into tabs',
234
+ "\u2022 /tabs \u2014 see what's running",
235
+ '\u2022 /stop name \u2014 stop a tab',
144
236
  '',
145
- 'Let\'s get started! Send me something.',
237
+ "Let's get started! Send me something.",
146
238
  ].join('\n'));
147
239
  // Don't return - let the actual message be processed too (unless it was just /start)
148
240
  if (welcomeText === '/start')
@@ -175,7 +267,9 @@ export class TelegramChannel {
175
267
  shouldActivate = !!isReplyToBot;
176
268
  break;
177
269
  case 'keyword':
178
- 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;
179
273
  break;
180
274
  case 'always':
181
275
  shouldActivate = true;
@@ -201,39 +295,63 @@ export class TelegramChannel {
201
295
  if (msg.photo) {
202
296
  const photo = msg.photo[msg.photo.length - 1];
203
297
  downloadTasks.push(this.downloadTelegramFile(photo.file_id, 'jpg')
204
- .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)
205
306
  .catch(() => null));
206
307
  }
207
308
  if (msg.voice) {
208
309
  downloadTasks.push(this.downloadTelegramFile(msg.voice.file_id, 'ogg')
209
- .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)
210
311
  .catch(() => null));
211
312
  }
212
313
  if (msg.audio) {
213
314
  downloadTasks.push(this.downloadTelegramFile(msg.audio.file_id, 'mp3')
214
- .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)
215
323
  .catch(() => null));
216
324
  }
217
325
  if (msg.document) {
218
326
  const ext = msg.document.file_name?.split('.').pop() || 'bin';
219
327
  downloadTasks.push(this.downloadTelegramFile(msg.document.file_id, ext)
220
- .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)
221
336
  .catch(() => null));
222
337
  }
223
338
  if (msg.video) {
224
339
  downloadTasks.push(this.downloadTelegramFile(msg.video.file_id, 'mp4')
225
- .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)
226
347
  .catch(() => null));
227
348
  }
228
349
  const downloadResults = await Promise.allSettled(downloadTasks);
229
350
  const media = downloadResults
230
351
  .filter((r) => r.status === 'fulfilled' && r.value !== null)
231
- .map(r => r.value);
352
+ .map((r) => r.value);
232
353
  // Transcribe voice messages if STT is configured
233
- if (this.sttProvider) {
234
- const { transcribeVoiceMessages } = await import('../voice/index.js');
235
- this.sttWarmedUp = await transcribeVoiceMessages(media, this.sttProvider, 'telegram', this.sttWarmedUp);
236
- }
354
+ await this.voice.transcribe(media);
237
355
  // Skip if no text AND no media
238
356
  if (!text && media.length === 0)
239
357
  return;
@@ -255,7 +373,11 @@ export class TelegramChannel {
255
373
  }
256
374
  catch (err) {
257
375
  logger.error('Telegram: error handling message:', err);
258
- await this.bot.sendMessage(chatId, 'Something went wrong processing your message. Check daemon logs for details.');
376
+ // Wrap the fallback send so a Telegram outage doesn't escalate to an
377
+ // unhandledRejection on the message-event handler.
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));
259
381
  }
260
382
  });
261
383
  }
@@ -271,35 +393,8 @@ export class TelegramChannel {
271
393
  await this.bot.sendMessage(chatId, 'Beecork unmuted in this group.');
272
394
  return;
273
395
  }
274
- if (text === '/history' || text.startsWith('/history ')) {
275
- const dateArg = text.slice(9).trim();
276
- const { getTimeline, formatTimeline } = await import('../timeline/index.js');
277
- let date;
278
- if (dateArg === 'yesterday') {
279
- date = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
280
- }
281
- else if (dateArg) {
282
- date = dateArg;
283
- }
284
- else {
285
- date = new Date().toISOString().slice(0, 10);
286
- }
287
- const events = getTimeline({ date, limit: 30 });
288
- await this.sendResponse(chatId, formatTimeline(events));
289
- return;
290
- }
291
- if (text === '/knowledge') {
292
- const { getAllKnowledge, formatKnowledgeForContext } = await import('../knowledge/index.js');
293
- const entries = getAllKnowledge();
294
- if (entries.length === 0) {
295
- await this.bot.sendMessage(chatId, 'No knowledge stored yet. Beecork learns from your conversations.');
296
- return;
297
- }
298
- const formatted = formatKnowledgeForContext(entries);
299
- await this.sendResponse(chatId, formatted.slice(0, 4000));
300
- return;
301
- }
302
- // Shared command handler (covers /tabs, /stop, /tab, /projects, /project, /newproject, /close, /fresh, /register, /link, /users, /cost, /activity, /handoff)
396
+ // /history and /knowledge now handled by the shared command handler.
397
+ // Shared command handler (covers /tabs, /stop, /tab, /projects, /project, /newproject, /close, /fresh, /cost, /activity, /handoff, /history, /knowledge)
303
398
  const { handleSharedCommand } = await import('./command-handler.js');
304
399
  const result = await handleSharedCommand({
305
400
  userId: String(userId || 'default'),
@@ -345,9 +440,6 @@ export class TelegramChannel {
345
440
  this.bot.sendMessage(chatId, `Still working on your request...`).catch(() => { });
346
441
  }, 120000);
347
442
  try {
348
- let responseText;
349
- let responseError;
350
- let responseTab;
351
443
  // Telegram-specific: streaming message edits
352
444
  let streamMsgId = null;
353
445
  let streamBuffer = '';
@@ -362,8 +454,8 @@ export class TelegramChannel {
362
454
  return;
363
455
  lastEditTime = now;
364
456
  try {
365
- const prefix = effectiveTabForStream !== 'default' ? `[${effectiveTabForStream}] ` : '';
366
- 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);
367
459
  if (!streamMsgId) {
368
460
  const sent = await this.bot.sendMessage(chatId, preview);
369
461
  streamMsgId = sent.message_id;
@@ -372,7 +464,9 @@ export class TelegramChannel {
372
464
  await this.bot.editMessageText(preview, { chat_id: chatId, message_id: streamMsgId });
373
465
  }
374
466
  }
375
- catch { /* edit failures are non-critical */ }
467
+ catch {
468
+ /* edit failures are non-critical */
469
+ }
376
470
  };
377
471
  // Shared pipeline handles: routing, media prompt, progress, sendMessage, TTS
378
472
  const pipelineResult = await processInboundMessage({
@@ -381,7 +475,7 @@ export class TelegramChannel {
381
475
  channelId: 'telegram',
382
476
  tabManager: this.ctx.tabManager,
383
477
  voiceReplyMode: this.ctx.config.voice?.replyMode,
384
- ttsProvider: this.ttsProvider,
478
+ ttsProvider: this.voice.tts,
385
479
  userId: String(chatId),
386
480
  sendProgress: (msg) => {
387
481
  this.bot.sendMessage(chatId, msg).catch(() => { });
@@ -397,9 +491,9 @@ export class TelegramChannel {
397
491
  }
398
492
  // Update the effective tab for stream prefix (now known)
399
493
  effectiveTabForStream = pipelineResult.tabName;
400
- responseText = pipelineResult.responseText;
401
- responseError = pipelineResult.isError;
402
- responseTab = pipelineResult.tabName;
494
+ const responseText = pipelineResult.responseText;
495
+ const responseError = pipelineResult.isError;
496
+ const responseTab = pipelineResult.tabName;
403
497
  // Telegram-specific: if streaming was active and no error, edit the final message
404
498
  if (streamMsgId && !responseError) {
405
499
  clearInterval(typingInterval);
@@ -412,8 +506,7 @@ export class TelegramChannel {
412
506
  return;
413
507
  }
414
508
  try {
415
- const prefix = responseTab !== 'default' ? `[${responseTab}] ` : '';
416
- const finalText = prefix + responseText;
509
+ const finalText = formatTabbedResponse(responseText, responseTab);
417
510
  if (finalText.length <= 4096) {
418
511
  await this.bot.editMessageText(finalText, { chat_id: chatId, message_id: streamMsgId });
419
512
  }
@@ -457,41 +550,55 @@ export class TelegramChannel {
457
550
  }
458
551
  }
459
552
  async sendResponse(chatId, text, tabName) {
460
- const prefix = tabName && tabName !== 'default' ? `[${tabName}] ` : '';
461
- const fullText = prefix + text;
553
+ const fullText = formatTabbedResponse(text, tabName);
462
554
  const chunks = chunkText(fullText);
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.
463
558
  if (chunks.length > 10) {
464
559
  for (let i = 0; i < 3; i++) {
465
560
  await this.sendWithRetry(chatId, chunks[i]);
466
561
  }
467
562
  const tmpPath = path.join(getLogsDir(), `response-${Date.now()}.txt`);
468
563
  fs.writeFileSync(tmpPath, fullText);
469
- 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
+ });
470
567
  fs.unlinkSync(tmpPath);
471
568
  return;
472
569
  }
473
- for (const chunk of chunks) {
474
- await this.sendWithRetry(chatId, chunk);
475
- }
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
+ });
476
580
  }
477
581
  async sendWithRetry(chatId, text) {
582
+ // Wrapped call used by the >10-chunk fallback path. retryWithBackoff +
583
+ // delivery-failures.log on permanent failure.
478
584
  try {
479
- await retryWithBackoff(async () => {
480
- try {
481
- await this.bot.sendMessage(chatId, text, { parse_mode: 'Markdown' });
482
- }
483
- catch {
484
- await this.bot.sendMessage(chatId, text);
485
- }
486
- }, [1000, 5000, 15000], 'telegram-send');
585
+ await this.sendWithRetryRaw(chatId, text);
487
586
  }
488
587
  catch (err) {
489
588
  const failLog = path.join(getLogsDir(), 'delivery-failures.log');
490
- const entry = `[${new Date().toISOString()}] chatId=${chatId} error=${err instanceof Error ? err.message : err} text=${text.slice(0, 200)}\n`;
589
+ const sanitizedErr = sanitizeBotToken(err instanceof Error ? err.message : String(err));
590
+ const entry = `[${new Date().toISOString()}] chatId=${chatId} error=${sanitizedErr} text=${text.slice(0, 200)}\n`;
491
591
  fs.appendFileSync(failLog, entry);
492
592
  logger.error(`Delivery failed after retries for chat ${chatId}`);
493
593
  }
494
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
+ }
495
602
  async downloadTelegramFile(fileId, extension) {
496
603
  const fileInfo = await this.bot.getFile(fileId);
497
604
  if (!fileInfo.file_path)
@@ -511,12 +618,15 @@ export class TelegramChannel {
511
618
  isAllowed(userId) {
512
619
  if (!userId)
513
620
  return false;
514
- 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);
515
626
  }
516
627
  isAdmin(userId) {
517
- if (!userId)
518
- return false;
519
- return userId === getAdminUserId();
628
+ const cfg = this.ctx.config.telegram;
629
+ return isChannelAdmin(cfg.allowedUserIds, userId, cfg.adminUserId);
520
630
  }
521
631
  async setReaction(chatId, messageId, emoji) {
522
632
  try {
@@ -1,35 +1,12 @@
1
1
  import type { TabManager } from '../session/manager.js';
2
- import type { BeecorkConfig } from '../types.js';
3
- /** A media file attached to a message */
4
- export interface MediaAttachment {
5
- type: 'image' | 'audio' | 'video' | 'document' | 'voice';
6
- mimeType: string;
7
- filePath: string;
8
- fileName?: string;
9
- duration?: number;
10
- caption?: string;
11
- }
12
- /** An inbound message from any channel */
13
- export interface InboundMessage {
14
- channelId: string;
15
- peerId: string;
16
- text?: string;
17
- media?: MediaAttachment[];
18
- replyTo?: string;
19
- isGroup: boolean;
20
- groupId?: string;
21
- isMentioned?: boolean;
22
- isReply?: boolean;
23
- messageId: string;
24
- raw: unknown;
25
- }
26
- /** Options for sending a message */
27
- export interface SendOptions {
28
- parseMode?: 'markdown' | 'plain';
29
- replyToMessageId?: string;
30
- }
31
- /** Handler for inbound messages */
32
- export type InboundMessageHandler = (message: InboundMessage) => Promise<void>;
2
+ import type { BeecorkConfig, MediaAttachment } from '../types.js';
3
+ export type { MediaAttachment };
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>;
33
10
  /** The Channel interface — all channels must implement this */
34
11
  export interface Channel {
35
12
  /** Unique channel identifier (e.g., 'telegram', 'whatsapp', 'discord') */
@@ -38,24 +15,22 @@ export interface Channel {
38
15
  readonly name: string;
39
16
  /** Maximum message length in characters */
40
17
  readonly maxMessageLength: number;
41
- /** Whether this channel supports live streaming of responses */
42
- readonly supportsStreaming: boolean;
43
- /** Whether this channel supports media attachments */
44
- readonly supportsMedia: boolean;
45
18
  /** Start the channel (connect, start polling, etc.) */
46
19
  start(): Promise<void>;
47
20
  /** Stop the channel gracefully */
48
21
  stop(): void;
49
22
  /** Send a text message to a specific peer */
50
23
  sendMessage(peerId: string, text: string, options?: SendOptions): Promise<void>;
51
- /** Send a media attachment to a peer (optional — check supportsMedia) */
24
+ /** Send a media attachment to a peer (optional — channels implement when supported) */
52
25
  sendMedia?(peerId: string, media: MediaAttachment): Promise<void>;
53
26
  /** Send a notification to all configured recipients */
54
27
  sendNotification(message: string, urgent?: boolean): Promise<void>;
55
28
  /** Set typing indicator for a peer */
56
29
  setTyping(peerId: string, active: boolean): Promise<void>;
57
- /** Register the inbound message handlercalled by the registry */
58
- 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>;
59
34
  }
60
35
  /** Context passed to channels during construction */
61
36
  export interface ChannelContext {
@@ -0,0 +1,29 @@
1
+ import type { STTProvider } from '../voice/stt.js';
2
+ import type { TTSProvider } from '../voice/tts.js';
3
+ import type { VoiceConfig, BeecorkConfig } from '../types.js';
4
+ import type { MediaAttachment } from '../types.js';
5
+ /**
6
+ * Per-channel STT/TTS state. Used to be duplicated across Telegram, WhatsApp,
7
+ * and Discord; consolidated here so init + warmup + transcription happen the
8
+ * same way across all 3.
9
+ *
10
+ * Discord historically only called warmup() and did NOT transcribe — preserve
11
+ * that behavior unless the caller explicitly asks for transcription.
12
+ */
13
+ export declare class VoiceState {
14
+ private readonly channelId;
15
+ stt: STTProvider | null;
16
+ tts: TTSProvider | null;
17
+ private warmedUp;
18
+ constructor(channelId: string);
19
+ init(config: BeecorkConfig | {
20
+ voice?: VoiceConfig;
21
+ }): void;
22
+ /** One-shot warmup (no media). Used by Discord today. */
23
+ warmup(): Promise<void>;
24
+ /**
25
+ * Transcribe voice attachments in-place (mutates the caption fields).
26
+ * Returns the updated warmed-up flag. No-op if STT isn't configured.
27
+ */
28
+ transcribe(media: MediaAttachment[]): Promise<void>;
29
+ }