bloby-bot 0.60.1 → 0.62.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-bloby/assets/{bloby-DO7g-v11.js → bloby-B7qT-PZC.js} +6 -6
- package/dist-bloby/assets/{globals-CwR3dDCz.js → globals-CZKHtKd_.js} +2 -2
- package/dist-bloby/assets/globals-DJAJNwWb.css +2 -0
- package/dist-bloby/assets/{highlighted-body-OFNGDK62-C2Wmb17B.js → highlighted-body-OFNGDK62-BmTRKsNK.js} +1 -1
- package/dist-bloby/assets/mermaid-GHXKKRXX-DjyzBtrR.js +1 -0
- package/dist-bloby/assets/{onboard-DcGLkITd.js → onboard-QaSIN03X.js} +1 -1
- package/dist-bloby/bloby.html +3 -3
- package/dist-bloby/onboard.html +3 -3
- package/package.json +4 -3
- package/shared/config.ts +24 -0
- package/supervisor/channels/manager.ts +111 -12
- package/supervisor/channels/telegram.ts +360 -0
- package/supervisor/channels/types.ts +5 -1
- package/supervisor/channels/whatsapp.ts +4 -5
- package/supervisor/chat/OnboardWizard.tsx +17 -0
- package/supervisor/chat/src/components/Chat/MessageBubble.tsx +29 -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 +395 -0
- package/supervisor/workspace-guard.js +3 -3
- package/workspace/skills/telegram/.claude-plugin/plugin.json +6 -0
- package/workspace/skills/telegram/SKILL.md +229 -0
- package/workspace/skills/telegram/skill.json +15 -0
- package/dist-bloby/assets/globals-CF0bs396.css +0 -2
- package/dist-bloby/assets/mermaid-GHXKKRXX-CILe07ZG.js +0 -1
|
@@ -0,0 +1,360 @@
|
|
|
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 @BotFather bot token (pasted at connect time) and long-polls
|
|
6
|
+
* `getUpdates` straight against api.telegram.org — no relay anywhere. Every
|
|
7
|
+
* inbound/outbound message (including media) flows Bloby ↔ Telegram directly.
|
|
8
|
+
* Outbound HTTPS only: works behind NAT, no public URL, no relay egress.
|
|
9
|
+
*
|
|
10
|
+
* It is lighter than the WhatsApp/Baileys provider (no reverse-engineered
|
|
11
|
+
* protocol, no QR, no auth-state files) — just a token string and a poll loop.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { loadConfig } from '../../shared/config.js';
|
|
15
|
+
import { log } from '../../shared/logger.js';
|
|
16
|
+
import type { ChannelProvider, ChannelStatus, ChannelType } from './types.js';
|
|
17
|
+
|
|
18
|
+
const TG_API = 'https://api.telegram.org';
|
|
19
|
+
const POLL_TIMEOUT_S = 25; // long-poll hold time
|
|
20
|
+
const MAX_MESSAGE_CHARS = 4096; // Telegram hard limit per sendMessage
|
|
21
|
+
const TYPING_REFRESH_MS = 5_000; // Telegram "typing" expires ~5s
|
|
22
|
+
|
|
23
|
+
/** Image extracted from an inbound Telegram message. */
|
|
24
|
+
export interface TelegramImageAttachment {
|
|
25
|
+
mediaType: string;
|
|
26
|
+
data: string; // base64
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Normalized inbound message handed to the ChannelManager. */
|
|
30
|
+
export interface TelegramInbound {
|
|
31
|
+
/** Chat id (string form of the numeric Telegram chat.id). Reply target. */
|
|
32
|
+
chatId: string;
|
|
33
|
+
/** Sender's numeric Telegram user id (string). */
|
|
34
|
+
fromUserId: string;
|
|
35
|
+
/** Display name (first name / @username) if available. */
|
|
36
|
+
senderName?: string;
|
|
37
|
+
text: string;
|
|
38
|
+
isGroup: boolean;
|
|
39
|
+
messageId?: number;
|
|
40
|
+
images?: TelegramImageAttachment[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type OnTelegramMessage = (msg: TelegramInbound) => void;
|
|
44
|
+
export type TranscribeFn = (audioBase64: string) => Promise<string | null>;
|
|
45
|
+
|
|
46
|
+
export class TelegramChannel implements ChannelProvider {
|
|
47
|
+
readonly type: ChannelType = 'telegram';
|
|
48
|
+
|
|
49
|
+
private token: string | null = null;
|
|
50
|
+
private botUsername: string | null = null;
|
|
51
|
+
private connected = false;
|
|
52
|
+
private offset = 0;
|
|
53
|
+
private intentionalDisconnect = false;
|
|
54
|
+
private pollAbort: AbortController | null = null;
|
|
55
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
56
|
+
private typingIntervals = new Map<string, ReturnType<typeof setInterval>>();
|
|
57
|
+
|
|
58
|
+
private onMessage: OnTelegramMessage;
|
|
59
|
+
private onStatusChange: (status: ChannelStatus) => void;
|
|
60
|
+
private transcribe: TranscribeFn | null;
|
|
61
|
+
|
|
62
|
+
constructor(
|
|
63
|
+
onMessage: OnTelegramMessage,
|
|
64
|
+
onStatusChange: (status: ChannelStatus) => void,
|
|
65
|
+
transcribe?: TranscribeFn,
|
|
66
|
+
) {
|
|
67
|
+
this.onMessage = onMessage;
|
|
68
|
+
this.onStatusChange = onStatusChange;
|
|
69
|
+
this.transcribe = transcribe || null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private api(method: string): string {
|
|
73
|
+
return `${TG_API}/bot${this.token}/${method}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
hasCredentials(): boolean {
|
|
77
|
+
return !!loadConfig().channels?.telegram?.botToken;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
getQrCode(): string | null {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
getStatus(): ChannelStatus {
|
|
85
|
+
return {
|
|
86
|
+
channel: 'telegram',
|
|
87
|
+
connected: this.connected,
|
|
88
|
+
info: {
|
|
89
|
+
botUsername: this.botUsername || loadConfig().channels?.telegram?.botUsername || null,
|
|
90
|
+
linked: this.hasCredentials(),
|
|
91
|
+
hasCredentials: this.hasCredentials(),
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async connect(): Promise<void> {
|
|
97
|
+
this.intentionalDisconnect = false;
|
|
98
|
+
const cfg = loadConfig().channels?.telegram;
|
|
99
|
+
if (!cfg?.botToken) {
|
|
100
|
+
log.warn('[telegram] No bot token configured — not connecting');
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
this.token = cfg.botToken;
|
|
104
|
+
this.botUsername = cfg.botUsername || null;
|
|
105
|
+
|
|
106
|
+
// getMe confirms the token and learns the @username.
|
|
107
|
+
try {
|
|
108
|
+
const me = await this.call('getMe', {});
|
|
109
|
+
if (me?.username) this.botUsername = me.username;
|
|
110
|
+
log.ok(`[telegram] Connected as @${this.botUsername || 'unknown'} (id=${me?.id || '?'})`);
|
|
111
|
+
} catch (err: any) {
|
|
112
|
+
log.warn(`[telegram] getMe failed: ${err.message} — will retry in poll loop`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
this.connected = true;
|
|
116
|
+
this.emitStatus();
|
|
117
|
+
this.pollLoop(); // fire and forget
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async disconnect(): Promise<void> {
|
|
121
|
+
this.intentionalDisconnect = true;
|
|
122
|
+
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
|
|
123
|
+
if (this.pollAbort) { try { this.pollAbort.abort(); } catch {} this.pollAbort = null; }
|
|
124
|
+
for (const interval of this.typingIntervals.values()) clearInterval(interval);
|
|
125
|
+
this.typingIntervals.clear();
|
|
126
|
+
this.connected = false;
|
|
127
|
+
this.emitStatus();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Outbound ──────────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
async sendMessage(to: string, text: string): Promise<void> {
|
|
133
|
+
if (!this.token) { log.warn('[telegram] Cannot send — no token'); return; }
|
|
134
|
+
this.stopTyping(to);
|
|
135
|
+
// Telegram caps messages at 4096 chars — split on the limit.
|
|
136
|
+
for (const chunk of splitMessage(text, MAX_MESSAGE_CHARS)) {
|
|
137
|
+
try {
|
|
138
|
+
await this.call('sendMessage', { chat_id: to, text: chunk, link_preview_options: { is_disabled: true } });
|
|
139
|
+
} catch (err: any) {
|
|
140
|
+
log.warn(`[telegram] sendMessage to ${to} failed: ${err.message}`);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
log.info(`[telegram] Sent message to ${to} (${text.length} chars)`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Send an image natively (used by ChannelManager for <BlobyImage> tags). */
|
|
148
|
+
async sendImage(to: string, image: Buffer, caption?: string, mimetype?: string): Promise<void> {
|
|
149
|
+
if (!this.token) { log.warn('[telegram] Cannot send image — no token'); return; }
|
|
150
|
+
this.stopTyping(to);
|
|
151
|
+
try {
|
|
152
|
+
const form = new FormData();
|
|
153
|
+
form.append('chat_id', to);
|
|
154
|
+
if (caption) form.append('caption', caption.slice(0, 1024));
|
|
155
|
+
const ext = (mimetype?.split('/')[1] || 'png').replace('jpeg', 'jpg');
|
|
156
|
+
form.append('photo', new Blob([new Uint8Array(image)], { type: mimetype || 'image/png' }), `image.${ext}`);
|
|
157
|
+
const r = await fetch(this.api('sendPhoto'), { method: 'POST', body: form });
|
|
158
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
159
|
+
log.info(`[telegram] Sent image to ${to}`);
|
|
160
|
+
} catch (err: any) {
|
|
161
|
+
log.warn(`[telegram] sendImage to ${to} failed: ${err.message}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Show "typing…" — refreshes every 5s since Telegram's indicator expires. */
|
|
166
|
+
startTyping(to: string): void {
|
|
167
|
+
if (!this.token || !this.connected) return;
|
|
168
|
+
this.stopTyping(to);
|
|
169
|
+
let ticks = 0;
|
|
170
|
+
const send = () => {
|
|
171
|
+
// Cap the refresh (~2 min) so a turn that ends without a reply can't leave a stuck indicator.
|
|
172
|
+
if (++ticks > 24) { this.stopTyping(to); return; }
|
|
173
|
+
this.call('sendChatAction', { chat_id: to, action: 'typing' }).catch(() => {});
|
|
174
|
+
};
|
|
175
|
+
send();
|
|
176
|
+
this.typingIntervals.set(to, setInterval(send, TYPING_REFRESH_MS));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
stopTyping(to: string): void {
|
|
180
|
+
const interval = this.typingIntervals.get(to);
|
|
181
|
+
if (interval) { clearInterval(interval); this.typingIntervals.delete(to); }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Inbound poll loop ─────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
private async pollLoop(): Promise<void> {
|
|
187
|
+
while (!this.intentionalDisconnect) {
|
|
188
|
+
this.pollAbort = new AbortController();
|
|
189
|
+
try {
|
|
190
|
+
const updates = await this.call('getUpdates', {
|
|
191
|
+
offset: this.offset,
|
|
192
|
+
timeout: POLL_TIMEOUT_S,
|
|
193
|
+
allowed_updates: ['message'],
|
|
194
|
+
}, this.pollAbort.signal, (POLL_TIMEOUT_S + 10) * 1000);
|
|
195
|
+
|
|
196
|
+
if (Array.isArray(updates)) {
|
|
197
|
+
for (const update of updates) {
|
|
198
|
+
this.offset = Math.max(this.offset, (update.update_id || 0) + 1);
|
|
199
|
+
try { await this.handleUpdate(update); }
|
|
200
|
+
catch (err: any) { log.warn(`[telegram] handleUpdate error: ${err.message}`); }
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
} catch (err: any) {
|
|
204
|
+
if (this.intentionalDisconnect) break;
|
|
205
|
+
// Watchdog timeout (the long-poll exceeded its deadline without a response —
|
|
206
|
+
// e.g. a silently dropped connection). A disconnect-driven abort is caught by
|
|
207
|
+
// the intentionalDisconnect check above, so any AbortError here is the timeout:
|
|
208
|
+
// re-open a fresh long-poll immediately rather than treating it as fatal.
|
|
209
|
+
if (err?.name === 'AbortError') continue;
|
|
210
|
+
// 409 = another getUpdates/webhook is consuming this bot — do not hot-loop.
|
|
211
|
+
if (String(err.message).includes('409')) {
|
|
212
|
+
log.warn('[telegram] getUpdates conflict (409) — another consumer holds this bot. Stopping poll.');
|
|
213
|
+
this.connected = false;
|
|
214
|
+
this.emitStatus();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
log.warn(`[telegram] getUpdates error: ${err.message} — retrying in 5s`);
|
|
218
|
+
await sleep(5000);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private async handleUpdate(update: any): Promise<void> {
|
|
224
|
+
const message = update.message;
|
|
225
|
+
if (!message) return;
|
|
226
|
+
|
|
227
|
+
const from = message.from || {};
|
|
228
|
+
if (from.is_bot) return; // ignore other bots (our own sends never come back here anyway)
|
|
229
|
+
|
|
230
|
+
const chat = message.chat || {};
|
|
231
|
+
const chatId = String(chat.id);
|
|
232
|
+
const fromUserId = String(from.id ?? '');
|
|
233
|
+
const isGroup = chat.type === 'group' || chat.type === 'supergroup';
|
|
234
|
+
const senderName = from.first_name
|
|
235
|
+
? (from.last_name ? `${from.first_name} ${from.last_name}` : from.first_name)
|
|
236
|
+
: (from.username || undefined);
|
|
237
|
+
|
|
238
|
+
let rawText: string = message.text || message.caption || '';
|
|
239
|
+
const images: TelegramImageAttachment[] = [];
|
|
240
|
+
|
|
241
|
+
// Photo: download the largest available size.
|
|
242
|
+
if (Array.isArray(message.photo) && message.photo.length > 0) {
|
|
243
|
+
const largest = message.photo[message.photo.length - 1];
|
|
244
|
+
const img = await this.downloadFile(largest.file_id).catch(() => null);
|
|
245
|
+
if (img) images.push({ mediaType: 'image/jpeg', data: img.toString('base64') });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Voice note / audio: download + transcribe.
|
|
249
|
+
const voice = message.voice || message.audio;
|
|
250
|
+
if (!rawText && voice?.file_id) {
|
|
251
|
+
if (!this.transcribe) {
|
|
252
|
+
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.');
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const buf = await this.downloadFile(voice.file_id).catch(() => null);
|
|
256
|
+
if (buf) {
|
|
257
|
+
const transcript = await this.transcribe(buf.toString('base64')).catch(() => null);
|
|
258
|
+
if (transcript) {
|
|
259
|
+
rawText = transcript;
|
|
260
|
+
log.info(`[telegram] Transcribed voice: "${rawText.slice(0, 80)}"`);
|
|
261
|
+
} else {
|
|
262
|
+
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.");
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (!rawText && images.length === 0) return;
|
|
269
|
+
if (!rawText && images.length > 0) rawText = '(image)';
|
|
270
|
+
|
|
271
|
+
const text = escapeMessageText(rawText);
|
|
272
|
+
|
|
273
|
+
log.info(`[telegram] Message from ${fromUserId} (chat=${chatId}, group=${isGroup}, images=${images.length}): ${text.slice(0, 80)}`);
|
|
274
|
+
|
|
275
|
+
this.onMessage({
|
|
276
|
+
chatId,
|
|
277
|
+
fromUserId,
|
|
278
|
+
senderName,
|
|
279
|
+
text,
|
|
280
|
+
isGroup,
|
|
281
|
+
messageId: message.message_id,
|
|
282
|
+
images: images.length > 0 ? images : undefined,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Resolve a Telegram file_id to its bytes (getFile → download from the file CDN). */
|
|
287
|
+
private async downloadFile(fileId: string): Promise<Buffer | null> {
|
|
288
|
+
const file = await this.call('getFile', { file_id: fileId });
|
|
289
|
+
const filePath = file?.file_path;
|
|
290
|
+
if (!filePath) return null;
|
|
291
|
+
const r = await fetch(`${TG_API}/file/bot${this.token}/${filePath}`);
|
|
292
|
+
if (!r.ok) throw new Error(`file download HTTP ${r.status}`);
|
|
293
|
+
const buf = Buffer.from(await r.arrayBuffer());
|
|
294
|
+
log.info(`[telegram] Downloaded file (${Math.round(buf.length / 1024)}KB)`);
|
|
295
|
+
return buf;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Call a Bot API method, returning `result` or throwing on `ok:false`. */
|
|
299
|
+
private async call(method: string, params: Record<string, any>, signal?: AbortSignal, timeoutMs = 30_000): Promise<any> {
|
|
300
|
+
// Always apply the watchdog timeout, AND honor the caller's abort signal (disconnect)
|
|
301
|
+
// when present — abort whichever fires first. The previous version dropped the timeout
|
|
302
|
+
// whenever a signal was passed, leaving the long-poll with no deadline.
|
|
303
|
+
const ctrl = new AbortController();
|
|
304
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
305
|
+
const onExternalAbort = () => ctrl.abort();
|
|
306
|
+
if (signal) {
|
|
307
|
+
if (signal.aborted) ctrl.abort();
|
|
308
|
+
else signal.addEventListener('abort', onExternalAbort, { once: true });
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
const r = await fetch(this.api(method), {
|
|
312
|
+
method: 'POST',
|
|
313
|
+
headers: { 'Content-Type': 'application/json' },
|
|
314
|
+
body: JSON.stringify(params),
|
|
315
|
+
signal: ctrl.signal,
|
|
316
|
+
});
|
|
317
|
+
const data = await r.json().catch(() => ({}));
|
|
318
|
+
if (!r.ok || !data.ok) {
|
|
319
|
+
throw new Error(`${method} → ${r.status} ${data.description || ''}`.trim());
|
|
320
|
+
}
|
|
321
|
+
return data.result;
|
|
322
|
+
} finally {
|
|
323
|
+
clearTimeout(timer);
|
|
324
|
+
if (signal) signal.removeEventListener('abort', onExternalAbort);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private emitStatus() {
|
|
329
|
+
this.onStatusChange(this.getStatus());
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
function sleep(ms: number): Promise<void> {
|
|
336
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Split a long message into <=limit-char chunks, preferring newline boundaries. */
|
|
340
|
+
function splitMessage(text: string, limit: number): string[] {
|
|
341
|
+
if (text.length <= limit) return [text];
|
|
342
|
+
const chunks: string[] = [];
|
|
343
|
+
let rest = text;
|
|
344
|
+
while (rest.length > limit) {
|
|
345
|
+
let cut = rest.lastIndexOf('\n', limit);
|
|
346
|
+
if (cut < limit * 0.5) cut = limit; // no good newline — hard cut
|
|
347
|
+
chunks.push(rest.slice(0, cut));
|
|
348
|
+
rest = rest.slice(cut).replace(/^\n/, '');
|
|
349
|
+
}
|
|
350
|
+
if (rest) chunks.push(rest);
|
|
351
|
+
return chunks;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Mirror WhatsApp's anti-injection escaping so message content can't fake channel/role tags. */
|
|
355
|
+
function escapeMessageText(text: string): string {
|
|
356
|
+
return text
|
|
357
|
+
.replace(/\[Telegram\s*\|/gi, '(Telegram|')
|
|
358
|
+
.replace(/\[WhatsApp\s*\|/gi, '(WhatsApp|')
|
|
359
|
+
.replace(/\[\s*(admin|customer)\s*\]/gi, '($1)');
|
|
360
|
+
}
|
|
@@ -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). */
|
|
@@ -518,11 +518,10 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
518
518
|
}
|
|
519
519
|
}
|
|
520
520
|
|
|
521
|
-
// Skip if no text AND no images
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
if (!rawText && images.length > 0) {
|
|
521
|
+
// Skip if no text AND no images; otherwise default text for image-only
|
|
522
|
+
// messages. Collapsing both branches also narrows `rawText` to `string`.
|
|
523
|
+
if (!rawText) {
|
|
524
|
+
if (images.length === 0) continue;
|
|
526
525
|
rawText = '(image)';
|
|
527
526
|
}
|
|
528
527
|
|
|
@@ -60,6 +60,8 @@ const PROVIDERS = [
|
|
|
60
60
|
|
|
61
61
|
const MODELS: Record<string, { id: string; label: string }[]> = {
|
|
62
62
|
anthropic: [
|
|
63
|
+
{ id: 'claude-opus-4-8[1m]', label: 'Opus 4.8 (1M context)' },
|
|
64
|
+
{ id: 'claude-opus-4-8', label: 'Opus 4.8' },
|
|
63
65
|
{ id: 'claude-opus-4-7[1m]', label: 'Opus 4.7 (1M context)' },
|
|
64
66
|
{ id: 'claude-opus-4-7', label: 'Opus 4.7' },
|
|
65
67
|
{ id: 'claude-sonnet-4-6', label: 'Sonnet 4.6 (1M context)' },
|
|
@@ -3513,6 +3515,21 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
|
|
|
3513
3515
|
{/* ── Auth flow: Anthropic ── */}
|
|
3514
3516
|
{provider === 'anthropic' && (
|
|
3515
3517
|
<div className="space-y-2.5">
|
|
3518
|
+
{/* Anthropic third-party usage policy notice — shown to anyone considering Claude. */}
|
|
3519
|
+
<div className="rounded-xl border border-amber-500/20 bg-amber-500/[0.06] px-4 py-3.5">
|
|
3520
|
+
<div className="flex items-center gap-2 mb-2">
|
|
3521
|
+
<TriangleAlert className="h-4 w-4 text-amber-400 shrink-0" />
|
|
3522
|
+
<h3 className="text-[12.5px] font-semibold text-amber-200/90">Anthropic Third-Party App Policy Update</h3>
|
|
3523
|
+
</div>
|
|
3524
|
+
<div className="space-y-2 text-amber-100/70 text-[12px] leading-relaxed">
|
|
3525
|
+
<p>Starting June 15, 2026, Anthropic will provide a separate Third-Party App credit equal to the amount you pay for your subscription.</p>
|
|
3526
|
+
<p>For example, if you have the Max 5x plan at $100/month, you will receive $100 in credits to use with third-party tools like Bloby.</p>
|
|
3527
|
+
<p>Unfortunately, this is only a fraction of the usage Bloby users had before. We don't control Anthropic's rules, but we do need to follow them.</p>
|
|
3528
|
+
<p>The best alternative right now is a <span className="font-medium text-amber-100/90">ChatGPT subscription</span>, which also offers $100 and $200 plans with much higher usage limits for Bloby.</p>
|
|
3529
|
+
<p>In the short term, Bloby will be optimized for ChatGPT. In the long term, we are building our own model harness so Bloby has more control, more flexibility, and does not depend too heavily on providers that can change their rules at any moment.</p>
|
|
3530
|
+
</div>
|
|
3531
|
+
</div>
|
|
3532
|
+
|
|
3516
3533
|
{isConnected && (
|
|
3517
3534
|
<div className="space-y-2.5">
|
|
3518
3535
|
<div className="bg-emerald-500/8 border border-emerald-500/15 rounded-lg px-3.5 py-2.5">
|
|
@@ -88,6 +88,14 @@ function preprocessContent(text: string): string {
|
|
|
88
88
|
);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
const telegramLink = '[connect-telegram](/api/channels/telegram/pair-page)';
|
|
92
|
+
if (!out.includes(telegramLink)) {
|
|
93
|
+
out = out.replace(
|
|
94
|
+
/`?(?:http:\/\/localhost:\d+)?\/api\/channels\/telegram\/pair-page`?/g,
|
|
95
|
+
telegramLink,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
91
99
|
return out;
|
|
92
100
|
}
|
|
93
101
|
|
|
@@ -381,6 +389,27 @@ export default function MessageBubble({ role, content, timestamp, hasAttachments
|
|
|
381
389
|
</a>
|
|
382
390
|
);
|
|
383
391
|
}
|
|
392
|
+
if (href?.includes('/api/channels/telegram/pair-page')) {
|
|
393
|
+
return (
|
|
394
|
+
<a
|
|
395
|
+
href={href}
|
|
396
|
+
target="_blank"
|
|
397
|
+
rel="noopener noreferrer"
|
|
398
|
+
className="flex items-center gap-3 my-2 px-3.5 py-2.5 rounded-xl bg-[#229ED9]/10 border border-[#229ED9]/20 hover:bg-[#229ED9]/15 transition-colors no-underline"
|
|
399
|
+
>
|
|
400
|
+
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-[#229ED9] shrink-0">
|
|
401
|
+
<svg viewBox="0 0 24 24" className="w-4 h-4 fill-white">
|
|
402
|
+
<path d="M9.78 18.65l.28-4.23 7.68-6.92c.34-.31-.07-.46-.52-.19L7.74 13.3 3.64 12c-.88-.25-.89-.86.2-1.3l15.97-6.16c.73-.33 1.43.18 1.15 1.3l-2.72 12.81c-.19.91-.74 1.13-1.5.71l-4.13-3.05-1.99 1.93c-.23.23-.42.42-.83.42z"/>
|
|
403
|
+
</svg>
|
|
404
|
+
</div>
|
|
405
|
+
<div className="flex flex-col min-w-0">
|
|
406
|
+
<span className="text-[#229ED9] font-medium text-sm">Click here to connect Telegram</span>
|
|
407
|
+
<span className="text-muted-foreground text-xs">Opens the Telegram pairing page</span>
|
|
408
|
+
</div>
|
|
409
|
+
<ExternalLink className="w-3.5 h-3.5 text-muted-foreground/50 ml-auto shrink-0" />
|
|
410
|
+
</a>
|
|
411
|
+
);
|
|
412
|
+
}
|
|
384
413
|
return (
|
|
385
414
|
<a
|
|
386
415
|
href={href}
|
|
@@ -213,6 +213,11 @@ async function buildConversationOptions(
|
|
|
213
213
|
|
|
214
214
|
return {
|
|
215
215
|
model,
|
|
216
|
+
// Reasoning effort. 'high' = deep reasoning while staying more token-efficient
|
|
217
|
+
// than the CLI's xhigh default on Opus 4.7/4.8 — meaningful given Anthropic's
|
|
218
|
+
// tighter third-party usage limits. Supported on Opus 4.6+/Sonnet 4.6; silently
|
|
219
|
+
// ignored by models without effort support.
|
|
220
|
+
effort: 'high',
|
|
216
221
|
cwd: WORKSPACE_DIR,
|
|
217
222
|
permissionMode: 'bypassPermissions',
|
|
218
223
|
allowDangerouslySkipPermissions: true,
|
|
@@ -648,6 +653,7 @@ export async function startBlobyAgentQuery(
|
|
|
648
653
|
prompt: sdkPrompt,
|
|
649
654
|
options: {
|
|
650
655
|
model,
|
|
656
|
+
effort: 'high', // see buildConversationOptions — token-efficient deep reasoning
|
|
651
657
|
cwd: WORKSPACE_DIR,
|
|
652
658
|
permissionMode: 'bypassPermissions',
|
|
653
659
|
allowDangerouslySkipPermissions: true,
|
|
@@ -762,6 +768,7 @@ export async function runAgentQuery(req: AgentQueryRequest): Promise<AgentQueryR
|
|
|
762
768
|
prompt: req.message,
|
|
763
769
|
options: {
|
|
764
770
|
cwd: WORKSPACE_DIR,
|
|
771
|
+
effort: 'high', // see buildConversationOptions — token-efficient deep reasoning
|
|
765
772
|
permissionMode: 'bypassPermissions',
|
|
766
773
|
allowDangerouslySkipPermissions: true,
|
|
767
774
|
maxTurns,
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { log } from '../../../shared/logger.js';
|
|
14
14
|
import { WORKSPACE_DIR } from '../../../shared/paths.js';
|
|
15
|
-
import type { SavedFile } from '
|
|
15
|
+
import type { SavedFile } from '../../file-saver.js';
|
|
16
16
|
import { assembleSystemPrompt } from '../../../worker/prompts/prompt-assembler.js';
|
|
17
17
|
import fs from 'fs';
|
|
18
18
|
import path from 'path';
|
|
@@ -12,7 +12,14 @@ export function safeResolve(cwd: string, requested: string): string {
|
|
|
12
12
|
if (!requested || typeof requested !== 'string') {
|
|
13
13
|
throw new Error('Missing file path');
|
|
14
14
|
}
|
|
15
|
-
|
|
15
|
+
// Canonicalize cwd (resolves symlinks) so the traversal check below compares real
|
|
16
|
+
// paths. Falls back to a plain resolve when cwd doesn't exist yet (realpath throws).
|
|
17
|
+
let root: string;
|
|
18
|
+
try {
|
|
19
|
+
root = fs.realpathSync.native(cwd);
|
|
20
|
+
} catch {
|
|
21
|
+
root = path.resolve(cwd);
|
|
22
|
+
}
|
|
16
23
|
const abs = path.isAbsolute(requested)
|
|
17
24
|
? path.normalize(requested)
|
|
18
25
|
: path.normalize(path.join(root, requested));
|