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.
- 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/admin.d.ts +10 -0
- package/dist/channels/admin.js +20 -0
- package/dist/channels/command-handler.d.ts +2 -10
- package/dist/channels/command-handler.js +90 -84
- package/dist/channels/discord.d.ts +4 -9
- package/dist/channels/discord.js +59 -42
- package/dist/channels/index.d.ts +1 -1
- package/dist/channels/loader.js +13 -4
- 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/send-helpers.d.ts +19 -0
- package/dist/channels/send-helpers.js +21 -0
- package/dist/channels/telegram.d.ts +21 -14
- package/dist/channels/telegram.js +214 -104
- package/dist/channels/types.d.ts +13 -38
- package/dist/channels/voice-state.d.ts +29 -0
- package/dist/channels/voice-state.js +45 -0
- package/dist/channels/webhook.d.ts +2 -5
- package/dist/channels/webhook.js +88 -29
- package/dist/channels/whatsapp.d.ts +9 -7
- package/dist/channels/whatsapp.js +141 -100
- 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 +85 -27
- 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.d.ts +5 -1
- package/dist/config.js +20 -22
- package/dist/daemon.js +113 -51
- package/dist/dashboard/html.js +100 -20
- package/dist/dashboard/routes.d.ts +17 -0
- package/dist/dashboard/routes.js +623 -0
- package/dist/dashboard/server.js +38 -489
- package/dist/db/connection.d.ts +29 -0
- package/dist/db/connection.js +37 -0
- package/dist/db/index.js +43 -11
- package/dist/db/migrations.js +114 -22
- package/dist/delegation/manager.js +10 -4
- package/dist/index.js +39 -59
- package/dist/knowledge/manager.js +26 -12
- package/dist/mcp/handlers.d.ts +37 -0
- package/dist/mcp/handlers.js +520 -0
- package/dist/mcp/server.js +44 -858
- package/dist/mcp/tool-definitions.d.ts +1225 -0
- package/dist/mcp/tool-definitions.js +412 -0
- 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.d.ts +2 -7
- package/dist/media/index.js +2 -2
- 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.d.ts +1 -1
- package/dist/observability/analytics.js +41 -16
- package/dist/projects/index.d.ts +3 -2
- package/dist/projects/index.js +2 -2
- package/dist/projects/manager.d.ts +1 -7
- package/dist/projects/manager.js +66 -42
- package/dist/projects/router.d.ts +12 -0
- package/dist/projects/router.js +98 -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 +21 -5
- package/dist/session/manager.js +166 -153
- 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 +3 -0
- package/dist/session/subprocess.js +54 -11
- package/dist/session/tab-store.d.ts +28 -0
- package/dist/session/tab-store.js +78 -0
- package/dist/tasks/scheduler.d.ts +13 -0
- package/dist/tasks/scheduler.js +97 -18
- package/dist/tasks/store.js +26 -12
- package/dist/timeline/logger.js +3 -1
- package/dist/timeline/query.js +15 -5
- package/dist/types.d.ts +49 -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 +2 -0
- package/dist/util/paths.js +16 -3
- package/dist/util/rate-limiter.js +8 -0
- package/dist/util/retry.js +1 -1
- package/dist/util/text.d.ts +21 -1
- package/dist/util/text.js +38 -8
- 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 +11 -5
- package/package.json +6 -1
- package/dist/session/tool-classifier.d.ts +0 -4
- package/dist/session/tool-classifier.js +0 -56
- package/dist/users/index.d.ts +0 -2
- package/dist/users/index.js +0 -1
- package/dist/users/service.d.ts +0 -17
- 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,
|
|
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 {
|
|
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 {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
230
|
+
"Send any message and I'll pass it to Claude Code.",
|
|
139
231
|
'',
|
|
140
232
|
'Quick tips:',
|
|
141
|
-
'\u2022
|
|
142
|
-
|
|
143
|
-
'\u2022
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
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
|
|
366
|
-
const preview =
|
|
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 {
|
|
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.
|
|
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
|
|
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
|
|
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, {
|
|
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
|
-
|
|
474
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
518
|
-
|
|
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 {
|
package/dist/channels/types.d.ts
CHANGED
|
@@ -1,35 +1,12 @@
|
|
|
1
1
|
import type { TabManager } from '../session/manager.js';
|
|
2
|
-
import type { BeecorkConfig } from '../types.js';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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 —
|
|
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
|
-
/**
|
|
58
|
-
|
|
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
|
+
}
|