bloby-bot 0.60.1 → 0.61.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/package.json +4 -3
- package/shared/config.ts +25 -0
- package/supervisor/channels/manager.ts +112 -12
- package/supervisor/channels/telegram.ts +361 -0
- package/supervisor/channels/types.ts +5 -1
- package/supervisor/channels/whatsapp.ts +4 -5
- package/supervisor/chat/OnboardWizard.tsx +17 -0
- package/supervisor/harnesses/claude.ts +7 -0
- package/supervisor/harnesses/pi/index.ts +1 -1
- package/supervisor/harnesses/pi/tools/path-safety.ts +8 -1
- package/supervisor/index.ts +313 -0
- package/supervisor/workspace-guard.js +3 -3
- package/workspace/skills/telegram/.claude-plugin/plugin.json +6 -0
- package/workspace/skills/telegram/SKILL.md +230 -0
- package/workspace/skills/telegram/skill.json +15 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bloby-bot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.61.0",
|
|
4
4
|
"releaseNotes": [
|
|
5
5
|
"1. Fix: image (and audio) attachments now render in chat again — /api/files is fetched with the auth token instead of a raw <img> src that 401'd after the endpoint hardening",
|
|
6
6
|
"2. Affects chat thumbnails, the image lightbox, voice-note playback, and agent image cards",
|
|
@@ -52,11 +52,11 @@
|
|
|
52
52
|
"sync:pi-models": "tsx scripts/sync-pi-models.ts"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@anthropic-ai/claude-agent-sdk": "^0.3.
|
|
55
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.169",
|
|
56
56
|
"@anthropic-ai/sdk": "^0.100.0",
|
|
57
57
|
"@clack/prompts": "^1.1.0",
|
|
58
58
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
59
|
-
"@openai/codex": "^0.
|
|
59
|
+
"@openai/codex": "^0.138.0",
|
|
60
60
|
"@streamdown/code": "^1.1.1",
|
|
61
61
|
"@tailwindcss/vite": "^4.2.0",
|
|
62
62
|
"@vitejs/plugin-react": "^6.0.1",
|
|
@@ -98,6 +98,7 @@
|
|
|
98
98
|
"@types/qrcode": "^1.5.5",
|
|
99
99
|
"@types/react": "^19.2.14",
|
|
100
100
|
"@types/react-dom": "^19.2.3",
|
|
101
|
+
"@types/web-push": "^3.6.4",
|
|
101
102
|
"@types/ws": "^8.18.1",
|
|
102
103
|
"concurrently": "^9.2.1",
|
|
103
104
|
"typescript": "^5.9.3"
|
package/shared/config.ts
CHANGED
|
@@ -25,6 +25,30 @@ export interface AlexaChannelConfig {
|
|
|
25
25
|
sharedSecret?: string;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
export interface TelegramChannelConfig {
|
|
29
|
+
enabled: boolean;
|
|
30
|
+
/** Same semantics as WhatsApp: 'channel' = just talk to me (owner DM only), 'business' = admin/customer,
|
|
31
|
+
* 'assistant' = personal assistant in conversations. */
|
|
32
|
+
mode: 'channel' | 'business' | 'assistant';
|
|
33
|
+
/** Telegram NUMERIC user IDs (not phone numbers) with admin access — business mode only. */
|
|
34
|
+
admins?: string[];
|
|
35
|
+
/** Active skill for customer-facing mode (folder name in workspace/skills/). */
|
|
36
|
+
skill?: string;
|
|
37
|
+
/** Opt-in: process messages in group chats (default false). Channel mode ignores this. */
|
|
38
|
+
allowGroups?: boolean;
|
|
39
|
+
/** Assistant mode only — see ChannelConfig.allowOthersToTrigger. DANGEROUS when true. */
|
|
40
|
+
allowOthersToTrigger?: boolean;
|
|
41
|
+
/** The bot's own access token (Bot API). Provisioned via the relay manager bot (Telegram
|
|
42
|
+
* "Managed Bots") at pairing, or supplied directly (BYO @BotFather). Held locally — the Bloby
|
|
43
|
+
* long-polls Telegram DIRECTLY with this token; the relay is NOT in the message path. */
|
|
44
|
+
botToken?: string;
|
|
45
|
+
/** The bot's @username (no @). For display + deep links. */
|
|
46
|
+
botUsername?: string;
|
|
47
|
+
/** Telegram user_id of the human who created/owns the bot. Treated as the admin/"self" identity:
|
|
48
|
+
* in channel mode only this user's 1:1 DMs reach the agent. */
|
|
49
|
+
ownerUserId?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
28
52
|
export interface BotConfig {
|
|
29
53
|
port: number;
|
|
30
54
|
username: string;
|
|
@@ -52,6 +76,7 @@ export interface BotConfig {
|
|
|
52
76
|
channels?: {
|
|
53
77
|
whatsapp?: ChannelConfig;
|
|
54
78
|
alexa?: AlexaChannelConfig;
|
|
79
|
+
telegram?: TelegramChannelConfig;
|
|
55
80
|
};
|
|
56
81
|
tunnelUrl?: string;
|
|
57
82
|
}
|
|
@@ -20,12 +20,13 @@
|
|
|
20
20
|
|
|
21
21
|
import fs from 'fs';
|
|
22
22
|
import path from 'path';
|
|
23
|
-
import { loadConfig } from '../../shared/config.js';
|
|
23
|
+
import { loadConfig, saveConfig } from '../../shared/config.js';
|
|
24
24
|
import { WORKSPACE_DIR } from '../../shared/paths.js';
|
|
25
25
|
import { log } from '../../shared/logger.js';
|
|
26
26
|
import { startBlobyAgentQuery, startConversation, pushMessage, hasConversation, type RecentMessage } from '../bloby-agent.js';
|
|
27
27
|
import { WhatsAppChannel } from './whatsapp.js';
|
|
28
28
|
import { AlexaChannel } from './alexa.js';
|
|
29
|
+
import { TelegramChannel, type TelegramInbound } from './telegram.js';
|
|
29
30
|
import type { ChannelConfig, ChannelProvider, ChannelStatus, ChannelType, InboundMessage, InboundMessageAttachment, RoutingTarget, SenderRole } from './types.js';
|
|
30
31
|
import type { AgentAttachment } from '../bloby-agent.js';
|
|
31
32
|
import { saveAttachment, type SavedFile } from '../file-saver.js';
|
|
@@ -157,6 +158,63 @@ export class ChannelManager {
|
|
|
157
158
|
this.providers.set('alexa', alexa);
|
|
158
159
|
await alexa.connect();
|
|
159
160
|
}
|
|
161
|
+
|
|
162
|
+
// Telegram — only when a child bot token has been provisioned (via the relay
|
|
163
|
+
// manager bot) or supplied (BYO @BotFather). The provider long-polls Telegram
|
|
164
|
+
// directly; the relay is out of the message path from here on.
|
|
165
|
+
if (channelConfigs?.telegram?.enabled && channelConfigs.telegram.botToken && !this.providers.has('telegram')) {
|
|
166
|
+
log.info('[channels] Initializing Telegram channel...');
|
|
167
|
+
const telegram = new TelegramChannel(
|
|
168
|
+
(msg) => this.handleTelegramMessage(msg),
|
|
169
|
+
(status) => this.handleStatusChange(status),
|
|
170
|
+
(audioBase64) => this.transcribeAudio(audioBase64),
|
|
171
|
+
);
|
|
172
|
+
this.providers.set('telegram', telegram);
|
|
173
|
+
try {
|
|
174
|
+
await telegram.connect();
|
|
175
|
+
} catch (err: any) {
|
|
176
|
+
log.warn(`[channels] Telegram connect failed: ${err.message}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Map a Telegram inbound onto the shared channel pipeline.
|
|
182
|
+
*
|
|
183
|
+
* Telegram has no "self-chat" like WhatsApp. Instead the bot OWNER (the human who created the
|
|
184
|
+
* bot) is the "self" identity: fromMe=true and (in 1:1) isSelfChat=true. This makes channel mode
|
|
185
|
+
* respond only to the owner's DMs and makes assistant-mode `@botname` triggers work — reusing the
|
|
186
|
+
* exact mode/debounce/role gating in handleInboundMessage with no special-casing. */
|
|
187
|
+
private handleTelegramMessage(msg: TelegramInbound) {
|
|
188
|
+
let ownerUserId = loadConfig().channels?.telegram?.ownerUserId;
|
|
189
|
+
// Trust-on-first-use: if provisioning didn't capture the creator's id, adopt the first
|
|
190
|
+
// person to DM this freshly-minted private bot as the owner (its @username is random, so in
|
|
191
|
+
// practice only the owner knows it). Guarantees channel mode never silently ignores its owner.
|
|
192
|
+
if (!ownerUserId && !msg.isGroup && msg.fromUserId) {
|
|
193
|
+
ownerUserId = msg.fromUserId;
|
|
194
|
+
const cfg = loadConfig();
|
|
195
|
+
if (cfg.channels?.telegram) {
|
|
196
|
+
cfg.channels.telegram.ownerUserId = ownerUserId;
|
|
197
|
+
saveConfig(cfg);
|
|
198
|
+
log.info(`[channels] Telegram owner adopted (trust-on-first-use): userId=${ownerUserId}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const isOwner = !!ownerUserId && msg.fromUserId === String(ownerUserId);
|
|
202
|
+
const attachments = msg.images?.map((img) => ({ type: 'image' as const, mediaType: img.mediaType, data: img.data }));
|
|
203
|
+
// Sanitize the attacker-controlled display name so it can't fake a `[Telegram | … | admin]`
|
|
204
|
+
// context tag or inject newlines into the agent's context.
|
|
205
|
+
const safeName = msg.senderName ? msg.senderName.replace(/[\[\]|\r\n]/g, ' ').slice(0, 64).trim() || undefined : undefined;
|
|
206
|
+
this.handleInboundMessage(
|
|
207
|
+
'telegram',
|
|
208
|
+
msg.fromUserId,
|
|
209
|
+
safeName,
|
|
210
|
+
msg.text,
|
|
211
|
+
isOwner, // fromMe — the owner is treated as "me"
|
|
212
|
+
isOwner && !msg.isGroup, // isSelfChat — owner's 1:1 DM is the personal channel
|
|
213
|
+
msg.chatId, // chatJid (reply target)
|
|
214
|
+
msg.isGroup,
|
|
215
|
+
attachments,
|
|
216
|
+
undefined, // inboundKey — Baileys-only (reactions not in Telegram v1)
|
|
217
|
+
);
|
|
160
218
|
}
|
|
161
219
|
|
|
162
220
|
/** Start WhatsApp connection (triggers QR flow if no credentials) */
|
|
@@ -222,8 +280,8 @@ export class ChannelManager {
|
|
|
222
280
|
await provider.sendMessage(to, processed);
|
|
223
281
|
}
|
|
224
282
|
|
|
225
|
-
// Send images natively via WhatsApp
|
|
226
|
-
if (images.length > 0 && provider instanceof WhatsAppChannel) {
|
|
283
|
+
// Send images natively via WhatsApp / Telegram (both expose sendImage)
|
|
284
|
+
if (images.length > 0 && (provider instanceof WhatsAppChannel || provider instanceof TelegramChannel)) {
|
|
227
285
|
for (const img of images) {
|
|
228
286
|
try {
|
|
229
287
|
const resolved = this.resolveMediaFile(img.src);
|
|
@@ -233,7 +291,7 @@ export class ChannelManager {
|
|
|
233
291
|
log.warn(`[channels] Image file not found in any location: ${img.src}`);
|
|
234
292
|
}
|
|
235
293
|
} catch (err: any) {
|
|
236
|
-
log.warn(`[channels] Failed to send image via
|
|
294
|
+
log.warn(`[channels] Failed to send image via ${channel}: ${err.message}`);
|
|
237
295
|
}
|
|
238
296
|
}
|
|
239
297
|
}
|
|
@@ -257,6 +315,16 @@ export class ChannelManager {
|
|
|
257
315
|
|
|
258
316
|
const mimetype = media.mimetype || resolved.mimetype;
|
|
259
317
|
|
|
318
|
+
// Telegram supports images via the provider's sendImage (other media types not wired in v1).
|
|
319
|
+
if (provider instanceof TelegramChannel) {
|
|
320
|
+
if (media.type === 'image') {
|
|
321
|
+
await provider.sendImage(to, resolved.buffer, caption, mimetype);
|
|
322
|
+
} else {
|
|
323
|
+
throw new Error(`Telegram supports image media only (got ${media.type})`);
|
|
324
|
+
}
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
260
328
|
if (!(provider instanceof WhatsAppChannel)) {
|
|
261
329
|
throw new Error(`Channel ${channel} does not support media`);
|
|
262
330
|
}
|
|
@@ -361,6 +429,11 @@ export class ChannelManager {
|
|
|
361
429
|
return `🤖 *${botName}:*\n\n${text}`;
|
|
362
430
|
}
|
|
363
431
|
|
|
432
|
+
/** Human-facing channel label used in the `[Channel | ...]` context tag the agent sees. */
|
|
433
|
+
private channelLabel(channel: ChannelType): string {
|
|
434
|
+
return channel === 'telegram' ? 'Telegram' : channel === 'alexa' ? 'Alexa' : 'WhatsApp';
|
|
435
|
+
}
|
|
436
|
+
|
|
364
437
|
/** Allocate per-conv state for WhatsApp text streaming. Both the orchestrator
|
|
365
438
|
* (chat UI websocket) and the manager's own admin handler create one of these
|
|
366
439
|
* and feed each agent stream event through `routeWaStreamEvent` below. */
|
|
@@ -566,6 +639,14 @@ export class ChannelManager {
|
|
|
566
639
|
/** Deliver a streamed chunk to the WhatsApp side of a routing target.
|
|
567
640
|
* No-op when the target has no `waSendTo` (e.g., chat-UI turn with WA disconnected). */
|
|
568
641
|
private sendStreamChunk(target: RoutingTarget | undefined, text: string, botName: string): void {
|
|
642
|
+
// Telegram: the bot is its own contact, so no "🤖 Bot:" prefix — send the agent's text as-is.
|
|
643
|
+
if (target?.surface === 'telegram') {
|
|
644
|
+
if (!target.telegramChatId) return;
|
|
645
|
+
this.sendMessage('telegram', target.telegramChatId, text).catch((err) =>
|
|
646
|
+
log.warn(`[channels] Telegram send failed (${target.telegramChatId}): ${err.message}`),
|
|
647
|
+
);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
569
650
|
if (!target?.waSendTo) return;
|
|
570
651
|
// Prefix only when the trigger came from WhatsApp AND it isn't the user's own self-chat —
|
|
571
652
|
// the user doesn't need to see "🤖 Bot:" before their own bot's reply in their own chat.
|
|
@@ -585,7 +666,11 @@ export class ChannelManager {
|
|
|
585
666
|
/** Get the channel config, re-reading from disk each time */
|
|
586
667
|
private getChannelConfig(channel: ChannelType): ChannelConfig | undefined {
|
|
587
668
|
const config = loadConfig();
|
|
588
|
-
|
|
669
|
+
// Only ever called for the ChannelConfig-shaped providers (whatsapp/telegram),
|
|
670
|
+
// both of which carry `mode`. Alexa's config has a different shape (no `mode`)
|
|
671
|
+
// and is handled via its own handleAlexaInbound path, so narrowing the stored
|
|
672
|
+
// channel union to ChannelConfig here is safe.
|
|
673
|
+
return config.channels?.[channel] as ChannelConfig | undefined;
|
|
589
674
|
}
|
|
590
675
|
|
|
591
676
|
/** Robust "is this the account owner's own self-chat?" check.
|
|
@@ -803,7 +888,7 @@ export class ChannelManager {
|
|
|
803
888
|
}
|
|
804
889
|
|
|
805
890
|
// Business mode — incoming message. Role is resolved against the actual sender JID (not the chat JID).
|
|
806
|
-
const role = this.resolveBusinessRole(channelConfig, sender);
|
|
891
|
+
const role = this.resolveBusinessRole(channelConfig, sender, channel);
|
|
807
892
|
|
|
808
893
|
const message: InboundMessage = {
|
|
809
894
|
channel,
|
|
@@ -827,8 +912,14 @@ export class ChannelManager {
|
|
|
827
912
|
}
|
|
828
913
|
|
|
829
914
|
/** Resolve role in business mode — check admins array */
|
|
830
|
-
private resolveBusinessRole(config: ChannelConfig, sender: string): SenderRole {
|
|
915
|
+
private resolveBusinessRole(config: ChannelConfig, sender: string, channel: ChannelType): SenderRole {
|
|
831
916
|
if (config.admins?.length) {
|
|
917
|
+
// Telegram admins are EXACT numeric user ids — no suffix/country-code fuzz (that's
|
|
918
|
+
// phone-number tolerance and would wrongly grant admin on a numeric-id suffix collision).
|
|
919
|
+
if (channel === 'telegram') {
|
|
920
|
+
const senderId = sender.replace(/@.*/, '').trim();
|
|
921
|
+
return config.admins.some((a) => String(a).trim() === senderId) ? 'admin' : 'customer';
|
|
922
|
+
}
|
|
832
923
|
const senderPhone = sender.replace(/@.*/, '').replace(/[^0-9]/g, '');
|
|
833
924
|
for (const admin of config.admins) {
|
|
834
925
|
const adminPhone = admin.replace(/[^0-9]/g, '');
|
|
@@ -853,7 +944,7 @@ export class ChannelManager {
|
|
|
853
944
|
if (ctx.conversationId) {
|
|
854
945
|
convId = ctx.conversationId;
|
|
855
946
|
} else {
|
|
856
|
-
const conv = await workerApi('/api/conversations', 'POST', { title:
|
|
947
|
+
const conv = await workerApi('/api/conversations', 'POST', { title: this.channelLabel(msg.channel), model });
|
|
857
948
|
convId = conv.id;
|
|
858
949
|
await workerApi('/api/context/set', 'POST', { conversationId: convId });
|
|
859
950
|
}
|
|
@@ -861,13 +952,20 @@ export class ChannelManager {
|
|
|
861
952
|
log.warn(`[channels] Failed to get/create conversation: ${err.message}`);
|
|
862
953
|
return;
|
|
863
954
|
}
|
|
955
|
+
// Mirrors handleAlexaInbound's guard: never proceed with an undefined convId
|
|
956
|
+
// (e.g. the conversation API returned no id) — that would push the turn into a
|
|
957
|
+
// broken conversation key and silently drop the reply.
|
|
958
|
+
if (!convId) {
|
|
959
|
+
log.warn('[channels] No conversation id resolved — dropping inbound message');
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
864
962
|
|
|
865
963
|
// Use display text for DB/chat (hides enriched agent context from the UI).
|
|
866
964
|
// Prepend the channel tag so the UI can detect the source for icons.
|
|
867
965
|
const rawDisplay = msg.displayText || msg.text;
|
|
868
966
|
const earlyRoleTag = msg.senderName && msg.role === 'assistant'
|
|
869
967
|
? `${msg.role} | ${msg.senderName}` : msg.role;
|
|
870
|
-
const channelTag = `[
|
|
968
|
+
const channelTag = `[${this.channelLabel(msg.channel)} | ${msg.sender} | ${earlyRoleTag}]\n`;
|
|
871
969
|
const displayContent = channelTag + rawDisplay;
|
|
872
970
|
|
|
873
971
|
// Save user message to DB
|
|
@@ -992,9 +1090,11 @@ export class ChannelManager {
|
|
|
992
1090
|
// The agent's reply for THIS specific input will go to msg.rawSender — no other
|
|
993
1091
|
// surface can hijack it via the FIFO ordering of the shared conversation.
|
|
994
1092
|
const channelContent = channelContext + msg.text;
|
|
1093
|
+
const isTelegram = msg.channel === 'telegram';
|
|
995
1094
|
const target: RoutingTarget = {
|
|
996
|
-
surface: 'whatsapp',
|
|
997
|
-
waSendTo: msg.rawSender,
|
|
1095
|
+
surface: isTelegram ? 'telegram' : 'whatsapp',
|
|
1096
|
+
waSendTo: isTelegram ? undefined : msg.rawSender,
|
|
1097
|
+
telegramChatId: isTelegram ? msg.rawSender : undefined,
|
|
998
1098
|
isGroup: msg.isGroup,
|
|
999
1099
|
// Self-chat in 1:1: don't prefix "🤖 Bot:" — it's the user's own chat with themselves.
|
|
1000
1100
|
isSelfChat: msg.role === 'admin' && !msg.isGroup,
|
|
@@ -1210,7 +1310,7 @@ export class ChannelManager {
|
|
|
1210
1310
|
}
|
|
1211
1311
|
} catch {}
|
|
1212
1312
|
|
|
1213
|
-
const channelContext = `[
|
|
1313
|
+
const channelContext = `[${this.channelLabel(msg.channel)} | ${msg.sender} | customer${msg.senderName ? ` | ${msg.senderName}` : ''}]\n`;
|
|
1214
1314
|
|
|
1215
1315
|
// Convert inbound attachments to agent format
|
|
1216
1316
|
const agentAttachments: AgentAttachment[] | undefined = msg.attachments?.map((att) => ({
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram channel provider — DIRECT Bot API, no relay in the message path.
|
|
3
|
+
*
|
|
4
|
+
* Unlike Alexa (relay-mediated, degenerate provider), this provider holds the
|
|
5
|
+
* user's OWN bot token and long-polls `getUpdates` straight against
|
|
6
|
+
* api.telegram.org. After the relay provisions the token at pairing time (via
|
|
7
|
+
* Telegram "Managed Bots"), the relay is gone — every inbound/outbound message
|
|
8
|
+
* (including media) flows Bloby ↔ Telegram directly. Outbound HTTPS only: works
|
|
9
|
+
* behind NAT, no public URL, no relay egress.
|
|
10
|
+
*
|
|
11
|
+
* It is lighter than the WhatsApp/Baileys provider (no reverse-engineered
|
|
12
|
+
* protocol, no QR, no auth-state files) — just a token string and a poll loop.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { loadConfig } from '../../shared/config.js';
|
|
16
|
+
import { log } from '../../shared/logger.js';
|
|
17
|
+
import type { ChannelProvider, ChannelStatus, ChannelType } from './types.js';
|
|
18
|
+
|
|
19
|
+
const TG_API = 'https://api.telegram.org';
|
|
20
|
+
const POLL_TIMEOUT_S = 25; // long-poll hold time
|
|
21
|
+
const MAX_MESSAGE_CHARS = 4096; // Telegram hard limit per sendMessage
|
|
22
|
+
const TYPING_REFRESH_MS = 5_000; // Telegram "typing" expires ~5s
|
|
23
|
+
|
|
24
|
+
/** Image extracted from an inbound Telegram message. */
|
|
25
|
+
export interface TelegramImageAttachment {
|
|
26
|
+
mediaType: string;
|
|
27
|
+
data: string; // base64
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Normalized inbound message handed to the ChannelManager. */
|
|
31
|
+
export interface TelegramInbound {
|
|
32
|
+
/** Chat id (string form of the numeric Telegram chat.id). Reply target. */
|
|
33
|
+
chatId: string;
|
|
34
|
+
/** Sender's numeric Telegram user id (string). */
|
|
35
|
+
fromUserId: string;
|
|
36
|
+
/** Display name (first name / @username) if available. */
|
|
37
|
+
senderName?: string;
|
|
38
|
+
text: string;
|
|
39
|
+
isGroup: boolean;
|
|
40
|
+
messageId?: number;
|
|
41
|
+
images?: TelegramImageAttachment[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type OnTelegramMessage = (msg: TelegramInbound) => void;
|
|
45
|
+
export type TranscribeFn = (audioBase64: string) => Promise<string | null>;
|
|
46
|
+
|
|
47
|
+
export class TelegramChannel implements ChannelProvider {
|
|
48
|
+
readonly type: ChannelType = 'telegram';
|
|
49
|
+
|
|
50
|
+
private token: string | null = null;
|
|
51
|
+
private botUsername: string | null = null;
|
|
52
|
+
private connected = false;
|
|
53
|
+
private offset = 0;
|
|
54
|
+
private intentionalDisconnect = false;
|
|
55
|
+
private pollAbort: AbortController | null = null;
|
|
56
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
57
|
+
private typingIntervals = new Map<string, ReturnType<typeof setInterval>>();
|
|
58
|
+
|
|
59
|
+
private onMessage: OnTelegramMessage;
|
|
60
|
+
private onStatusChange: (status: ChannelStatus) => void;
|
|
61
|
+
private transcribe: TranscribeFn | null;
|
|
62
|
+
|
|
63
|
+
constructor(
|
|
64
|
+
onMessage: OnTelegramMessage,
|
|
65
|
+
onStatusChange: (status: ChannelStatus) => void,
|
|
66
|
+
transcribe?: TranscribeFn,
|
|
67
|
+
) {
|
|
68
|
+
this.onMessage = onMessage;
|
|
69
|
+
this.onStatusChange = onStatusChange;
|
|
70
|
+
this.transcribe = transcribe || null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private api(method: string): string {
|
|
74
|
+
return `${TG_API}/bot${this.token}/${method}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
hasCredentials(): boolean {
|
|
78
|
+
return !!loadConfig().channels?.telegram?.botToken;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
getQrCode(): string | null {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
getStatus(): ChannelStatus {
|
|
86
|
+
return {
|
|
87
|
+
channel: 'telegram',
|
|
88
|
+
connected: this.connected,
|
|
89
|
+
info: {
|
|
90
|
+
botUsername: this.botUsername || loadConfig().channels?.telegram?.botUsername || null,
|
|
91
|
+
linked: this.hasCredentials(),
|
|
92
|
+
hasCredentials: this.hasCredentials(),
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async connect(): Promise<void> {
|
|
98
|
+
this.intentionalDisconnect = false;
|
|
99
|
+
const cfg = loadConfig().channels?.telegram;
|
|
100
|
+
if (!cfg?.botToken) {
|
|
101
|
+
log.warn('[telegram] No bot token configured — not connecting');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
this.token = cfg.botToken;
|
|
105
|
+
this.botUsername = cfg.botUsername || null;
|
|
106
|
+
|
|
107
|
+
// getMe confirms the token and learns the @username.
|
|
108
|
+
try {
|
|
109
|
+
const me = await this.call('getMe', {});
|
|
110
|
+
if (me?.username) this.botUsername = me.username;
|
|
111
|
+
log.ok(`[telegram] Connected as @${this.botUsername || 'unknown'} (id=${me?.id || '?'})`);
|
|
112
|
+
} catch (err: any) {
|
|
113
|
+
log.warn(`[telegram] getMe failed: ${err.message} — will retry in poll loop`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
this.connected = true;
|
|
117
|
+
this.emitStatus();
|
|
118
|
+
this.pollLoop(); // fire and forget
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async disconnect(): Promise<void> {
|
|
122
|
+
this.intentionalDisconnect = true;
|
|
123
|
+
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
|
|
124
|
+
if (this.pollAbort) { try { this.pollAbort.abort(); } catch {} this.pollAbort = null; }
|
|
125
|
+
for (const interval of this.typingIntervals.values()) clearInterval(interval);
|
|
126
|
+
this.typingIntervals.clear();
|
|
127
|
+
this.connected = false;
|
|
128
|
+
this.emitStatus();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Outbound ──────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
async sendMessage(to: string, text: string): Promise<void> {
|
|
134
|
+
if (!this.token) { log.warn('[telegram] Cannot send — no token'); return; }
|
|
135
|
+
this.stopTyping(to);
|
|
136
|
+
// Telegram caps messages at 4096 chars — split on the limit.
|
|
137
|
+
for (const chunk of splitMessage(text, MAX_MESSAGE_CHARS)) {
|
|
138
|
+
try {
|
|
139
|
+
await this.call('sendMessage', { chat_id: to, text: chunk, link_preview_options: { is_disabled: true } });
|
|
140
|
+
} catch (err: any) {
|
|
141
|
+
log.warn(`[telegram] sendMessage to ${to} failed: ${err.message}`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
log.info(`[telegram] Sent message to ${to} (${text.length} chars)`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Send an image natively (used by ChannelManager for <BlobyImage> tags). */
|
|
149
|
+
async sendImage(to: string, image: Buffer, caption?: string, mimetype?: string): Promise<void> {
|
|
150
|
+
if (!this.token) { log.warn('[telegram] Cannot send image — no token'); return; }
|
|
151
|
+
this.stopTyping(to);
|
|
152
|
+
try {
|
|
153
|
+
const form = new FormData();
|
|
154
|
+
form.append('chat_id', to);
|
|
155
|
+
if (caption) form.append('caption', caption.slice(0, 1024));
|
|
156
|
+
const ext = (mimetype?.split('/')[1] || 'png').replace('jpeg', 'jpg');
|
|
157
|
+
form.append('photo', new Blob([new Uint8Array(image)], { type: mimetype || 'image/png' }), `image.${ext}`);
|
|
158
|
+
const r = await fetch(this.api('sendPhoto'), { method: 'POST', body: form });
|
|
159
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
160
|
+
log.info(`[telegram] Sent image to ${to}`);
|
|
161
|
+
} catch (err: any) {
|
|
162
|
+
log.warn(`[telegram] sendImage to ${to} failed: ${err.message}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Show "typing…" — refreshes every 5s since Telegram's indicator expires. */
|
|
167
|
+
startTyping(to: string): void {
|
|
168
|
+
if (!this.token || !this.connected) return;
|
|
169
|
+
this.stopTyping(to);
|
|
170
|
+
let ticks = 0;
|
|
171
|
+
const send = () => {
|
|
172
|
+
// Cap the refresh (~2 min) so a turn that ends without a reply can't leave a stuck indicator.
|
|
173
|
+
if (++ticks > 24) { this.stopTyping(to); return; }
|
|
174
|
+
this.call('sendChatAction', { chat_id: to, action: 'typing' }).catch(() => {});
|
|
175
|
+
};
|
|
176
|
+
send();
|
|
177
|
+
this.typingIntervals.set(to, setInterval(send, TYPING_REFRESH_MS));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
stopTyping(to: string): void {
|
|
181
|
+
const interval = this.typingIntervals.get(to);
|
|
182
|
+
if (interval) { clearInterval(interval); this.typingIntervals.delete(to); }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── Inbound poll loop ─────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
private async pollLoop(): Promise<void> {
|
|
188
|
+
while (!this.intentionalDisconnect) {
|
|
189
|
+
this.pollAbort = new AbortController();
|
|
190
|
+
try {
|
|
191
|
+
const updates = await this.call('getUpdates', {
|
|
192
|
+
offset: this.offset,
|
|
193
|
+
timeout: POLL_TIMEOUT_S,
|
|
194
|
+
allowed_updates: ['message'],
|
|
195
|
+
}, this.pollAbort.signal, (POLL_TIMEOUT_S + 10) * 1000);
|
|
196
|
+
|
|
197
|
+
if (Array.isArray(updates)) {
|
|
198
|
+
for (const update of updates) {
|
|
199
|
+
this.offset = Math.max(this.offset, (update.update_id || 0) + 1);
|
|
200
|
+
try { await this.handleUpdate(update); }
|
|
201
|
+
catch (err: any) { log.warn(`[telegram] handleUpdate error: ${err.message}`); }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
} catch (err: any) {
|
|
205
|
+
if (this.intentionalDisconnect) break;
|
|
206
|
+
// Watchdog timeout (the long-poll exceeded its deadline without a response —
|
|
207
|
+
// e.g. a silently dropped connection). A disconnect-driven abort is caught by
|
|
208
|
+
// the intentionalDisconnect check above, so any AbortError here is the timeout:
|
|
209
|
+
// re-open a fresh long-poll immediately rather than treating it as fatal.
|
|
210
|
+
if (err?.name === 'AbortError') continue;
|
|
211
|
+
// 409 = another getUpdates/webhook is consuming this bot — do not hot-loop.
|
|
212
|
+
if (String(err.message).includes('409')) {
|
|
213
|
+
log.warn('[telegram] getUpdates conflict (409) — another consumer holds this bot. Stopping poll.');
|
|
214
|
+
this.connected = false;
|
|
215
|
+
this.emitStatus();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
log.warn(`[telegram] getUpdates error: ${err.message} — retrying in 5s`);
|
|
219
|
+
await sleep(5000);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private async handleUpdate(update: any): Promise<void> {
|
|
225
|
+
const message = update.message;
|
|
226
|
+
if (!message) return;
|
|
227
|
+
|
|
228
|
+
const from = message.from || {};
|
|
229
|
+
if (from.is_bot) return; // ignore other bots (our own sends never come back here anyway)
|
|
230
|
+
|
|
231
|
+
const chat = message.chat || {};
|
|
232
|
+
const chatId = String(chat.id);
|
|
233
|
+
const fromUserId = String(from.id ?? '');
|
|
234
|
+
const isGroup = chat.type === 'group' || chat.type === 'supergroup';
|
|
235
|
+
const senderName = from.first_name
|
|
236
|
+
? (from.last_name ? `${from.first_name} ${from.last_name}` : from.first_name)
|
|
237
|
+
: (from.username || undefined);
|
|
238
|
+
|
|
239
|
+
let rawText: string = message.text || message.caption || '';
|
|
240
|
+
const images: TelegramImageAttachment[] = [];
|
|
241
|
+
|
|
242
|
+
// Photo: download the largest available size.
|
|
243
|
+
if (Array.isArray(message.photo) && message.photo.length > 0) {
|
|
244
|
+
const largest = message.photo[message.photo.length - 1];
|
|
245
|
+
const img = await this.downloadFile(largest.file_id).catch(() => null);
|
|
246
|
+
if (img) images.push({ mediaType: 'image/jpeg', data: img.toString('base64') });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Voice note / audio: download + transcribe.
|
|
250
|
+
const voice = message.voice || message.audio;
|
|
251
|
+
if (!rawText && voice?.file_id) {
|
|
252
|
+
if (!this.transcribe) {
|
|
253
|
+
await this.sendMessage(chatId, 'Voice transcription is off — add an OpenAI API key in your Bloby chat settings (the three-dots menu) to enable it.');
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const buf = await this.downloadFile(voice.file_id).catch(() => null);
|
|
257
|
+
if (buf) {
|
|
258
|
+
const transcript = await this.transcribe(buf.toString('base64')).catch(() => null);
|
|
259
|
+
if (transcript) {
|
|
260
|
+
rawText = transcript;
|
|
261
|
+
log.info(`[telegram] Transcribed voice: "${rawText.slice(0, 80)}"`);
|
|
262
|
+
} else {
|
|
263
|
+
await this.sendMessage(chatId, "I couldn't transcribe that voice message — if this keeps happening, add an OpenAI API key in your Bloby chat settings (the three-dots menu) to enable voice transcription.");
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (!rawText && images.length === 0) return;
|
|
270
|
+
if (!rawText && images.length > 0) rawText = '(image)';
|
|
271
|
+
|
|
272
|
+
const text = escapeMessageText(rawText);
|
|
273
|
+
|
|
274
|
+
log.info(`[telegram] Message from ${fromUserId} (chat=${chatId}, group=${isGroup}, images=${images.length}): ${text.slice(0, 80)}`);
|
|
275
|
+
|
|
276
|
+
this.onMessage({
|
|
277
|
+
chatId,
|
|
278
|
+
fromUserId,
|
|
279
|
+
senderName,
|
|
280
|
+
text,
|
|
281
|
+
isGroup,
|
|
282
|
+
messageId: message.message_id,
|
|
283
|
+
images: images.length > 0 ? images : undefined,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Resolve a Telegram file_id to its bytes (getFile → download from the file CDN). */
|
|
288
|
+
private async downloadFile(fileId: string): Promise<Buffer | null> {
|
|
289
|
+
const file = await this.call('getFile', { file_id: fileId });
|
|
290
|
+
const filePath = file?.file_path;
|
|
291
|
+
if (!filePath) return null;
|
|
292
|
+
const r = await fetch(`${TG_API}/file/bot${this.token}/${filePath}`);
|
|
293
|
+
if (!r.ok) throw new Error(`file download HTTP ${r.status}`);
|
|
294
|
+
const buf = Buffer.from(await r.arrayBuffer());
|
|
295
|
+
log.info(`[telegram] Downloaded file (${Math.round(buf.length / 1024)}KB)`);
|
|
296
|
+
return buf;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Call a Bot API method, returning `result` or throwing on `ok:false`. */
|
|
300
|
+
private async call(method: string, params: Record<string, any>, signal?: AbortSignal, timeoutMs = 30_000): Promise<any> {
|
|
301
|
+
// Always apply the watchdog timeout, AND honor the caller's abort signal (disconnect)
|
|
302
|
+
// when present — abort whichever fires first. The previous version dropped the timeout
|
|
303
|
+
// whenever a signal was passed, leaving the long-poll with no deadline.
|
|
304
|
+
const ctrl = new AbortController();
|
|
305
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
306
|
+
const onExternalAbort = () => ctrl.abort();
|
|
307
|
+
if (signal) {
|
|
308
|
+
if (signal.aborted) ctrl.abort();
|
|
309
|
+
else signal.addEventListener('abort', onExternalAbort, { once: true });
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
const r = await fetch(this.api(method), {
|
|
313
|
+
method: 'POST',
|
|
314
|
+
headers: { 'Content-Type': 'application/json' },
|
|
315
|
+
body: JSON.stringify(params),
|
|
316
|
+
signal: ctrl.signal,
|
|
317
|
+
});
|
|
318
|
+
const data = await r.json().catch(() => ({}));
|
|
319
|
+
if (!r.ok || !data.ok) {
|
|
320
|
+
throw new Error(`${method} → ${r.status} ${data.description || ''}`.trim());
|
|
321
|
+
}
|
|
322
|
+
return data.result;
|
|
323
|
+
} finally {
|
|
324
|
+
clearTimeout(timer);
|
|
325
|
+
if (signal) signal.removeEventListener('abort', onExternalAbort);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private emitStatus() {
|
|
330
|
+
this.onStatusChange(this.getStatus());
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
function sleep(ms: number): Promise<void> {
|
|
337
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Split a long message into <=limit-char chunks, preferring newline boundaries. */
|
|
341
|
+
function splitMessage(text: string, limit: number): string[] {
|
|
342
|
+
if (text.length <= limit) return [text];
|
|
343
|
+
const chunks: string[] = [];
|
|
344
|
+
let rest = text;
|
|
345
|
+
while (rest.length > limit) {
|
|
346
|
+
let cut = rest.lastIndexOf('\n', limit);
|
|
347
|
+
if (cut < limit * 0.5) cut = limit; // no good newline — hard cut
|
|
348
|
+
chunks.push(rest.slice(0, cut));
|
|
349
|
+
rest = rest.slice(cut).replace(/^\n/, '');
|
|
350
|
+
}
|
|
351
|
+
if (rest) chunks.push(rest);
|
|
352
|
+
return chunks;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Mirror WhatsApp's anti-injection escaping so message content can't fake channel/role tags. */
|
|
356
|
+
function escapeMessageText(text: string): string {
|
|
357
|
+
return text
|
|
358
|
+
.replace(/\[Telegram\s*\|/gi, '(Telegram|')
|
|
359
|
+
.replace(/\[WhatsApp\s*\|/gi, '(WhatsApp|')
|
|
360
|
+
.replace(/\[\s*(admin|customer)\s*\]/gi, '($1)');
|
|
361
|
+
}
|
|
@@ -78,13 +78,17 @@ export interface RoutingTarget {
|
|
|
78
78
|
/** Which surface triggered this turn. Drives whether the WA reply carries a "🤖 Bot:" prefix.
|
|
79
79
|
* 'workspace' is a dashboard surface like 'chat' (broadcast-driven, optional WA self-chat mirror)
|
|
80
80
|
* but isolated for telemetry / future per-surface routing. */
|
|
81
|
-
surface: 'chat' | 'whatsapp' | 'alexa' | 'workspace';
|
|
81
|
+
surface: 'chat' | 'whatsapp' | 'alexa' | 'telegram' | 'workspace';
|
|
82
82
|
/** WhatsApp JID to deliver the reply to.
|
|
83
83
|
* - 'whatsapp' surface → the originating chat JID (group or peer).
|
|
84
84
|
* - 'chat' surface → optionally the user's own number (self-chat mirror), or undefined.
|
|
85
85
|
* When undefined, no WhatsApp send happens — the reply only reaches the dashboard via broadcast.
|
|
86
86
|
*/
|
|
87
87
|
waSendTo?: string;
|
|
88
|
+
/** Telegram chat id to deliver the reply to ('telegram' surface). */
|
|
89
|
+
telegramChatId?: string;
|
|
90
|
+
/** Telegram message id of the inbound that triggered this turn (for reply-to / reactions). */
|
|
91
|
+
telegramMessageId?: number;
|
|
88
92
|
isGroup?: boolean;
|
|
89
93
|
isSelfChat?: boolean;
|
|
90
94
|
/** When set, the assistant's reply is appended to this customer buffer (assistant-mode context). */
|