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.
- package/dist/capabilities/index.d.ts +1 -1
- package/dist/capabilities/index.js +1 -1
- package/dist/capabilities/manager.js +13 -9
- package/dist/capabilities/packs.js +3 -1
- package/dist/channels/command-handler.js +46 -14
- package/dist/channels/discord.d.ts +3 -6
- package/dist/channels/discord.js +40 -23
- package/dist/channels/index.d.ts +1 -1
- package/dist/channels/loader.js +13 -3
- package/dist/channels/pipeline.js +14 -5
- package/dist/channels/registry.d.ts +17 -1
- package/dist/channels/registry.js +33 -4
- package/dist/channels/telegram.d.ts +20 -5
- package/dist/channels/telegram.js +177 -42
- package/dist/channels/types.d.ts +11 -28
- package/dist/channels/voice-state.js +3 -1
- package/dist/channels/webhook.d.ts +1 -4
- package/dist/channels/webhook.js +26 -11
- package/dist/channels/whatsapp.d.ts +8 -4
- package/dist/channels/whatsapp.js +65 -29
- package/dist/cli/capabilities.js +4 -4
- package/dist/cli/channel.js +16 -6
- package/dist/cli/commands.js +12 -9
- package/dist/cli/doctor.js +80 -25
- package/dist/cli/handoff.d.ts +7 -14
- package/dist/cli/handoff.js +9 -44
- package/dist/cli/mcp.js +5 -5
- package/dist/cli/media.js +21 -8
- package/dist/cli/setup.js +9 -8
- package/dist/cli/store.js +29 -12
- package/dist/config.js +5 -10
- package/dist/daemon.js +88 -38
- package/dist/dashboard/html.js +80 -12
- package/dist/dashboard/routes.js +143 -79
- package/dist/dashboard/server.js +5 -1
- package/dist/db/connection.d.ts +29 -0
- package/dist/db/connection.js +37 -0
- package/dist/db/index.js +30 -12
- package/dist/db/migrations.js +84 -28
- package/dist/delegation/manager.js +10 -4
- package/dist/index.js +39 -59
- package/dist/knowledge/manager.js +26 -12
- package/dist/mcp/handlers.js +126 -57
- package/dist/mcp/server.js +20 -10
- package/dist/mcp/tool-definitions.js +68 -20
- package/dist/mcp/validate.d.ts +23 -0
- package/dist/mcp/validate.js +65 -0
- package/dist/media/factory.js +18 -14
- package/dist/media/generators/dall-e.js +2 -2
- package/dist/media/generators/kling.js +4 -4
- package/dist/media/generators/lyria.js +1 -1
- package/dist/media/generators/nano-banana.d.ts +1 -1
- package/dist/media/generators/nano-banana.js +2 -2
- package/dist/media/generators/poll-util.js +4 -4
- package/dist/media/generators/recraft.js +3 -3
- package/dist/media/generators/runway.js +4 -4
- package/dist/media/generators/stable-diffusion.js +2 -2
- package/dist/media/generators/veo.js +1 -1
- package/dist/media/index.js +1 -1
- package/dist/media/store.d.ts +7 -0
- package/dist/media/store.js +18 -4
- package/dist/media/types.d.ts +22 -0
- package/dist/notifications/index.d.ts +2 -4
- package/dist/notifications/index.js +6 -19
- package/dist/notifications/ntfy.js +3 -3
- package/dist/observability/analytics.js +35 -13
- package/dist/projects/index.d.ts +1 -1
- package/dist/projects/index.js +1 -1
- package/dist/projects/manager.d.ts +0 -4
- package/dist/projects/manager.js +51 -28
- package/dist/projects/router.d.ts +2 -0
- package/dist/projects/router.js +70 -45
- package/dist/service/install.js +15 -5
- package/dist/service/windows.js +1 -1
- package/dist/session/budget-guard.d.ts +20 -0
- package/dist/session/budget-guard.js +31 -0
- package/dist/session/circuit-breaker.d.ts +5 -3
- package/dist/session/circuit-breaker.js +45 -20
- package/dist/session/context-compactor.d.ts +32 -0
- package/dist/session/context-compactor.js +45 -0
- package/dist/session/context-monitor.js +2 -2
- package/dist/session/handoff.d.ts +21 -0
- package/dist/session/handoff.js +50 -0
- package/dist/session/manager.d.ts +17 -5
- package/dist/session/manager.js +153 -146
- package/dist/session/memory-store.d.ts +29 -0
- package/dist/session/memory-store.js +45 -0
- package/dist/session/message-queue.d.ts +28 -0
- package/dist/session/message-queue.js +52 -0
- package/dist/session/pending-dispatcher.d.ts +31 -0
- package/dist/session/pending-dispatcher.js +120 -0
- package/dist/session/pending-store.d.ts +60 -0
- package/dist/session/pending-store.js +118 -0
- package/dist/session/stale-session.d.ts +31 -0
- package/dist/session/stale-session.js +45 -0
- package/dist/session/subprocess.d.ts +2 -0
- package/dist/session/subprocess.js +33 -11
- package/dist/session/tab-store.js +4 -3
- package/dist/tasks/scheduler.d.ts +7 -0
- package/dist/tasks/scheduler.js +46 -6
- package/dist/tasks/store.js +20 -6
- package/dist/timeline/logger.js +3 -1
- package/dist/timeline/query.js +9 -3
- package/dist/types.d.ts +34 -9
- package/dist/util/auto-heal.js +15 -5
- package/dist/util/install-info.js +3 -1
- package/dist/util/logger.d.ts +1 -1
- package/dist/util/logger.js +63 -24
- package/dist/util/paths.d.ts +1 -0
- package/dist/util/paths.js +12 -2
- package/dist/util/retry.js +1 -1
- package/dist/util/text.js +13 -7
- package/dist/voice/index.js +5 -1
- package/dist/voice/stt.js +14 -6
- package/dist/voice/tts.js +1 -1
- package/dist/watchers/scheduler.js +9 -2
- package/package.json +18 -13
- package/dist/session/tool-classifier.d.ts +0 -4
- 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 = {
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
234
|
+
"\u2022 /tabs \u2014 see what's running",
|
|
148
235
|
'\u2022 /stop name \u2014 stop a tab',
|
|
149
236
|
'',
|
|
150
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
263
|
-
.
|
|
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
|
|
344
|
-
const preview =
|
|
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 {
|
|
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
|
|
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
|
|
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, {
|
|
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
|
-
|
|
452
|
-
|
|
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
|
|
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
|
-
|
|
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;
|
package/dist/channels/types.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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 —
|
|
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
|
-
/**
|
|
50
|
-
|
|
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 {
|
|
@@ -1,16 +1,13 @@
|
|
|
1
|
-
import type { Channel, ChannelContext,
|
|
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>;
|
package/dist/channels/webhook.js
CHANGED
|
@@ -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
|
-
})
|
|
148
|
+
})
|
|
149
|
+
.then((result) => {
|
|
137
150
|
if (result.isError && this.ctx.notifyCallback) {
|
|
138
|
-
this.ctx
|
|
151
|
+
this.ctx
|
|
152
|
+
.notifyCallback(`Webhook async failed for "${tabName}": ${result.responseText}`)
|
|
153
|
+
.catch(() => { });
|
|
139
154
|
}
|
|
140
|
-
})
|
|
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
|
|
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,
|
|
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
|
}
|