bloby-bot 0.60.0 → 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.
Files changed (28) hide show
  1. package/dist-bloby/assets/{bloby-8GjzRxjC.js → bloby-DO7g-v11.js} +4 -4
  2. package/dist-bloby/assets/globals-CF0bs396.css +2 -0
  3. package/dist-bloby/assets/{globals-D-b6XZqk.js → globals-CwR3dDCz.js} +2 -2
  4. package/dist-bloby/assets/{highlighted-body-OFNGDK62-DrKKm93B.js → highlighted-body-OFNGDK62-C2Wmb17B.js} +1 -1
  5. package/dist-bloby/assets/mermaid-GHXKKRXX-CILe07ZG.js +1 -0
  6. package/dist-bloby/assets/{onboard-DJNuzfZA.js → onboard-DcGLkITd.js} +1 -1
  7. package/dist-bloby/bloby.html +3 -3
  8. package/dist-bloby/onboard.html +3 -3
  9. package/package.json +4 -3
  10. package/shared/config.ts +25 -0
  11. package/supervisor/channels/manager.ts +112 -12
  12. package/supervisor/channels/telegram.ts +361 -0
  13. package/supervisor/channels/types.ts +5 -1
  14. package/supervisor/channels/whatsapp.ts +4 -5
  15. package/supervisor/chat/OnboardWizard.tsx +163 -110
  16. package/supervisor/harnesses/claude.ts +7 -0
  17. package/supervisor/harnesses/pi/index.ts +1 -1
  18. package/supervisor/harnesses/pi/tools/path-safety.ts +8 -1
  19. package/supervisor/index.ts +334 -7
  20. package/supervisor/workspace-guard.js +3 -3
  21. package/worker/prompts/bloby-system-prompt-codex.txt +2 -2
  22. package/worker/prompts/bloby-system-prompt-pi.txt +2 -2
  23. package/worker/prompts/bloby-system-prompt.txt +2 -2
  24. package/workspace/skills/telegram/.claude-plugin/plugin.json +6 -0
  25. package/workspace/skills/telegram/SKILL.md +230 -0
  26. package/workspace/skills/telegram/skill.json +15 -0
  27. package/dist-bloby/assets/globals-eJ7lScsq.css +0 -2
  28. package/dist-bloby/assets/mermaid-GHXKKRXX-CxqocSKs.js +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bloby-bot",
3
- "version": "0.60.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.154",
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.135.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 WhatsApp: ${err.message}`);
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
- return config.channels?.[channel];
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: 'WhatsApp', model });
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 = `[WhatsApp | ${msg.sender} | ${earlyRoleTag}]\n`;
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 = `[WhatsApp | ${msg.sender} | customer${msg.senderName ? ` | ${msg.senderName}` : ''}]\n`;
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). */