bloby-bot 0.18.13 → 0.18.15
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 +1 -1
- package/supervisor/channels/manager.ts +35 -6
- package/supervisor/channels/types.ts +8 -0
- package/supervisor/channels/whatsapp.ts +50 -4
- package/supervisor/chat/bloby-main.tsx +1 -1
- package/worker/prompts/bloby-system-prompt.txt +4 -0
- package/worker/prompts/prompt-fragments.json +1 -1
package/package.json
CHANGED
|
@@ -22,7 +22,8 @@ import { WORKSPACE_DIR } from '../../shared/paths.js';
|
|
|
22
22
|
import { log } from '../../shared/logger.js';
|
|
23
23
|
import { startBlobyAgentQuery, type RecentMessage } from '../bloby-agent.js';
|
|
24
24
|
import { WhatsAppChannel } from './whatsapp.js';
|
|
25
|
-
import type { ChannelConfig, ChannelProvider, ChannelStatus, ChannelType, InboundMessage, SenderRole } from './types.js';
|
|
25
|
+
import type { ChannelConfig, ChannelProvider, ChannelStatus, ChannelType, InboundMessage, InboundMessageAttachment, SenderRole } from './types.js';
|
|
26
|
+
import type { AgentAttachment } from '../bloby-agent.js';
|
|
26
27
|
|
|
27
28
|
const MAX_CONCURRENT_AGENTS = 5;
|
|
28
29
|
const MAX_BUFFER_MESSAGES = 30;
|
|
@@ -47,6 +48,7 @@ interface BufferedMessage {
|
|
|
47
48
|
|
|
48
49
|
interface DebounceEntry {
|
|
49
50
|
messages: string[];
|
|
51
|
+
attachments: InboundMessageAttachment[];
|
|
50
52
|
timer: ReturnType<typeof setTimeout>;
|
|
51
53
|
channel: ChannelType;
|
|
52
54
|
sender: string;
|
|
@@ -82,7 +84,10 @@ export class ChannelManager {
|
|
|
82
84
|
|
|
83
85
|
log.info('[channels] Initializing WhatsApp channel...');
|
|
84
86
|
const whatsapp = new WhatsAppChannel(
|
|
85
|
-
(sender, senderName, text, fromMe, isSelfChat) =>
|
|
87
|
+
(sender, senderName, text, fromMe, isSelfChat, images) => {
|
|
88
|
+
const attachments = images?.map((img) => ({ type: 'image' as const, mediaType: img.mediaType, data: img.data }));
|
|
89
|
+
this.handleInboundMessage('whatsapp', sender, senderName, text, fromMe, isSelfChat, attachments);
|
|
90
|
+
},
|
|
86
91
|
(status) => this.handleStatusChange(status),
|
|
87
92
|
(audioBase64) => this.transcribeAudio(audioBase64),
|
|
88
93
|
);
|
|
@@ -103,7 +108,10 @@ export class ChannelManager {
|
|
|
103
108
|
let provider = this.providers.get('whatsapp');
|
|
104
109
|
if (!provider) {
|
|
105
110
|
const whatsapp = new WhatsAppChannel(
|
|
106
|
-
(sender, senderName, text, fromMe, isSelfChat) =>
|
|
111
|
+
(sender, senderName, text, fromMe, isSelfChat, images) => {
|
|
112
|
+
const attachments = images?.map((img) => ({ type: 'image' as const, mediaType: img.mediaType, data: img.data }));
|
|
113
|
+
this.handleInboundMessage('whatsapp', sender, senderName, text, fromMe, isSelfChat, attachments);
|
|
114
|
+
},
|
|
107
115
|
(status) => this.handleStatusChange(status),
|
|
108
116
|
(audioBase64) => this.transcribeAudio(audioBase64),
|
|
109
117
|
);
|
|
@@ -197,6 +205,7 @@ export class ChannelManager {
|
|
|
197
205
|
text: string,
|
|
198
206
|
fromMe: boolean,
|
|
199
207
|
isSelfChat: boolean,
|
|
208
|
+
attachments?: InboundMessageAttachment[],
|
|
200
209
|
) {
|
|
201
210
|
const channelConfig = this.getChannelConfig(channel);
|
|
202
211
|
if (!channelConfig) return;
|
|
@@ -219,6 +228,7 @@ export class ChannelManager {
|
|
|
219
228
|
// Another message from the same sender — reset timer, append text
|
|
220
229
|
clearTimeout(existing.timer);
|
|
221
230
|
existing.messages.push(text);
|
|
231
|
+
if (attachments?.length) existing.attachments.push(...attachments);
|
|
222
232
|
existing.senderName = senderName || existing.senderName;
|
|
223
233
|
existing.timer = setTimeout(() => this.flushDebounce(debounceKey), DEBOUNCE_MS);
|
|
224
234
|
log.info(`[channels] Debounce: buffered message ${existing.messages.length} from ${sender}`);
|
|
@@ -228,6 +238,7 @@ export class ChannelManager {
|
|
|
228
238
|
// First message from this sender — start debounce timer
|
|
229
239
|
const entry: DebounceEntry = {
|
|
230
240
|
messages: [text],
|
|
241
|
+
attachments: attachments ? [...attachments] : [],
|
|
231
242
|
channel,
|
|
232
243
|
sender,
|
|
233
244
|
senderName,
|
|
@@ -244,7 +255,7 @@ export class ChannelManager {
|
|
|
244
255
|
if (!entry) return;
|
|
245
256
|
this.debounceBuffers.delete(key);
|
|
246
257
|
|
|
247
|
-
const { channel, sender, senderName, fromMe, isSelfChat, messages } = entry;
|
|
258
|
+
const { channel, sender, senderName, fromMe, isSelfChat, messages, attachments } = entry;
|
|
248
259
|
const combinedText = messages.join('\n');
|
|
249
260
|
|
|
250
261
|
const channelConfig = this.getChannelConfig(channel);
|
|
@@ -262,6 +273,7 @@ export class ChannelManager {
|
|
|
262
273
|
role: 'admin',
|
|
263
274
|
text: combinedText,
|
|
264
275
|
rawSender: sender,
|
|
276
|
+
attachments: attachments.length > 0 ? attachments : undefined,
|
|
265
277
|
};
|
|
266
278
|
|
|
267
279
|
const modeLabel = mode === 'channel' ? 'Channel mode | self-chat' : 'Business mode | self-chat | admin';
|
|
@@ -280,6 +292,7 @@ export class ChannelManager {
|
|
|
280
292
|
role,
|
|
281
293
|
text: combinedText,
|
|
282
294
|
rawSender: sender,
|
|
295
|
+
attachments: attachments.length > 0 ? attachments : undefined,
|
|
283
296
|
};
|
|
284
297
|
|
|
285
298
|
log.info(`[channels] Business mode | ${message.sender} | role=${role} | "${combinedText.slice(0, 60)}"`);
|
|
@@ -368,6 +381,14 @@ export class ChannelManager {
|
|
|
368
381
|
// Channel context — tells the agent this is a WhatsApp message, respond naturally
|
|
369
382
|
const channelContext = `[WhatsApp | ${msg.sender} | admin]\n`;
|
|
370
383
|
|
|
384
|
+
// Convert inbound attachments to agent format
|
|
385
|
+
const agentAttachments: AgentAttachment[] | undefined = msg.attachments?.map((att) => ({
|
|
386
|
+
type: 'image' as const,
|
|
387
|
+
name: `whatsapp_image.${att.mediaType.split('/')[1] || 'jpg'}`,
|
|
388
|
+
mediaType: att.mediaType,
|
|
389
|
+
data: att.data,
|
|
390
|
+
}));
|
|
391
|
+
|
|
371
392
|
// Show "typing..." while the agent processes
|
|
372
393
|
this.startTyping(msg.channel, msg.rawSender);
|
|
373
394
|
|
|
@@ -419,7 +440,7 @@ export class ChannelManager {
|
|
|
419
440
|
this.opts.restartBackend();
|
|
420
441
|
}
|
|
421
442
|
},
|
|
422
|
-
|
|
443
|
+
agentAttachments,
|
|
423
444
|
undefined,
|
|
424
445
|
{ botName, humanName },
|
|
425
446
|
recentMessages,
|
|
@@ -485,6 +506,14 @@ export class ChannelManager {
|
|
|
485
506
|
|
|
486
507
|
const channelContext = `[WhatsApp | ${msg.sender} | customer${msg.senderName ? ` | ${msg.senderName}` : ''}]\n`;
|
|
487
508
|
|
|
509
|
+
// Convert inbound attachments to agent format
|
|
510
|
+
const agentAttachments: AgentAttachment[] | undefined = msg.attachments?.map((att) => ({
|
|
511
|
+
type: 'image' as const,
|
|
512
|
+
name: `whatsapp_image.${att.mediaType.split('/')[1] || 'jpg'}`,
|
|
513
|
+
mediaType: att.mediaType,
|
|
514
|
+
data: att.data,
|
|
515
|
+
}));
|
|
516
|
+
|
|
488
517
|
// Stable convId per customer (not per message)
|
|
489
518
|
const convId = `channel-${agentKey}`;
|
|
490
519
|
|
|
@@ -543,7 +572,7 @@ export class ChannelManager {
|
|
|
543
572
|
this.processQueue();
|
|
544
573
|
}
|
|
545
574
|
},
|
|
546
|
-
|
|
575
|
+
agentAttachments,
|
|
547
576
|
undefined,
|
|
548
577
|
{ botName, humanName },
|
|
549
578
|
recentMessages,
|
|
@@ -15,6 +15,12 @@ export interface ChannelConfig {
|
|
|
15
15
|
skill?: string;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
export interface InboundMessageAttachment {
|
|
19
|
+
type: 'image';
|
|
20
|
+
mediaType: string;
|
|
21
|
+
data: string; // base64
|
|
22
|
+
}
|
|
23
|
+
|
|
18
24
|
export interface InboundMessage {
|
|
19
25
|
channel: ChannelType;
|
|
20
26
|
/** Sender identifier (phone number) */
|
|
@@ -27,6 +33,8 @@ export interface InboundMessage {
|
|
|
27
33
|
text: string;
|
|
28
34
|
/** Raw sender JID (channel-specific format, used for replies) */
|
|
29
35
|
rawSender: string;
|
|
36
|
+
/** Image attachments */
|
|
37
|
+
attachments?: InboundMessageAttachment[];
|
|
30
38
|
}
|
|
31
39
|
|
|
32
40
|
export interface OutboundMessage {
|
|
@@ -23,8 +23,14 @@ import type { ChannelProvider, ChannelStatus, ChannelType } from './types.js';
|
|
|
23
23
|
|
|
24
24
|
const AUTH_DIR = path.join(DATA_DIR, 'channels', 'whatsapp', 'auth');
|
|
25
25
|
|
|
26
|
+
/** Image attachment extracted from a WhatsApp message */
|
|
27
|
+
export interface WhatsAppImageAttachment {
|
|
28
|
+
mediaType: string;
|
|
29
|
+
data: string; // base64
|
|
30
|
+
}
|
|
31
|
+
|
|
26
32
|
/** Callback when a new message arrives */
|
|
27
|
-
export type OnWhatsAppMessage = (sender: string, senderName: string | undefined, text: string, fromMe: boolean, isSelfChat: boolean) => void;
|
|
33
|
+
export type OnWhatsAppMessage = (sender: string, senderName: string | undefined, text: string, fromMe: boolean, isSelfChat: boolean, images?: WhatsAppImageAttachment[]) => void;
|
|
28
34
|
|
|
29
35
|
/** Callback to transcribe audio via whisper */
|
|
30
36
|
export type TranscribeFn = (audioBase64: string) => Promise<string | null>;
|
|
@@ -332,6 +338,20 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
332
338
|
|
|
333
339
|
// Extract text — or transcribe audio if it's a voice note
|
|
334
340
|
let rawText = this.extractText(msg.message);
|
|
341
|
+
const images: WhatsAppImageAttachment[] = [];
|
|
342
|
+
|
|
343
|
+
// Download image if present
|
|
344
|
+
if (this.isImageMessage(msg.message)) {
|
|
345
|
+
try {
|
|
346
|
+
const buffer = await downloadMediaMessage(msg, 'buffer', {}) as Buffer;
|
|
347
|
+
const mimeType = this.getImageMimeType(msg.message) || 'image/jpeg';
|
|
348
|
+
const base64 = buffer.toString('base64');
|
|
349
|
+
images.push({ mediaType: mimeType, data: base64 });
|
|
350
|
+
log.info(`[whatsapp] Downloaded image (${Math.round(buffer.length / 1024)}KB, ${mimeType})`);
|
|
351
|
+
} catch (err: any) {
|
|
352
|
+
log.warn(`[whatsapp] Image download failed: ${err.message}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
335
355
|
|
|
336
356
|
if (!rawText && this.isAudioMessage(msg.message)) {
|
|
337
357
|
// Voice note / audio — download and transcribe
|
|
@@ -356,7 +376,13 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
356
376
|
}
|
|
357
377
|
}
|
|
358
378
|
|
|
359
|
-
if
|
|
379
|
+
// Skip if no text AND no images
|
|
380
|
+
if (!rawText && images.length === 0) continue;
|
|
381
|
+
|
|
382
|
+
// Use a default text for image-only messages
|
|
383
|
+
if (!rawText && images.length > 0) {
|
|
384
|
+
rawText = '(image)';
|
|
385
|
+
}
|
|
360
386
|
|
|
361
387
|
// Escape special characters to prevent prompt injection via message content
|
|
362
388
|
const text = this.escapeMessageText(rawText);
|
|
@@ -375,9 +401,9 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
375
401
|
// Detect self-chat: remoteJid matches our own phone number AND no participant field
|
|
376
402
|
const isSelfChat = !participant && this.ownPhoneJid !== null && this.translateJid(rawSender) === this.ownPhoneJid;
|
|
377
403
|
|
|
378
|
-
log.info(`[whatsapp] Message from ${sender} (raw=${rawSender}, participant=${participant}, fromMe=${fromMe}, selfChat=${isSelfChat}): ${text.slice(0, 80)}`);
|
|
404
|
+
log.info(`[whatsapp] Message from ${sender} (raw=${rawSender}, participant=${participant}, fromMe=${fromMe}, selfChat=${isSelfChat}, images=${images.length}): ${text.slice(0, 80)}`);
|
|
379
405
|
|
|
380
|
-
this.onMessage(sender, pushName, text, fromMe, isSelfChat);
|
|
406
|
+
this.onMessage(sender, pushName, text, fromMe, isSelfChat, images.length > 0 ? images : undefined);
|
|
381
407
|
}
|
|
382
408
|
});
|
|
383
409
|
}
|
|
@@ -411,6 +437,26 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
411
437
|
return null;
|
|
412
438
|
}
|
|
413
439
|
|
|
440
|
+
/** Check if a message contains an image */
|
|
441
|
+
private isImageMessage(message: any): boolean {
|
|
442
|
+
if (!message) return false;
|
|
443
|
+
if (message.imageMessage) return true;
|
|
444
|
+
if (message.viewOnceMessage?.message?.imageMessage) return true;
|
|
445
|
+
if (message.viewOnceMessageV2?.message?.imageMessage) return true;
|
|
446
|
+
if (message.ephemeralMessage?.message?.imageMessage) return true;
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Extract the MIME type from an image message */
|
|
451
|
+
private getImageMimeType(message: any): string | null {
|
|
452
|
+
if (!message) return null;
|
|
453
|
+
if (message.imageMessage?.mimetype) return message.imageMessage.mimetype;
|
|
454
|
+
if (message.viewOnceMessage?.message?.imageMessage?.mimetype) return message.viewOnceMessage.message.imageMessage.mimetype;
|
|
455
|
+
if (message.viewOnceMessageV2?.message?.imageMessage?.mimetype) return message.viewOnceMessageV2.message.imageMessage.mimetype;
|
|
456
|
+
if (message.ephemeralMessage?.message?.imageMessage?.mimetype) return message.ephemeralMessage.message.imageMessage.mimetype;
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
|
|
414
460
|
/** Check if a message contains audio (voice note or audio file) */
|
|
415
461
|
private isAudioMessage(message: any): boolean {
|
|
416
462
|
if (!message) return false;
|
|
@@ -391,7 +391,7 @@ function BlobyApp() {
|
|
|
391
391
|
<span className="text-[13px] font-semibold font-mono tracking-tight">${walletBalance}</span>
|
|
392
392
|
<div className="w-px h-4 bg-white/[0.08] mx-2" />
|
|
393
393
|
<a
|
|
394
|
-
href="https://www.bloby.bot/
|
|
394
|
+
href="https://www.bloby.bot/dashboard"
|
|
395
395
|
target="_blank"
|
|
396
396
|
rel="noopener noreferrer"
|
|
397
397
|
className="text-[11px] font-semibold text-primary bg-primary/10 hover:bg-primary/20 px-2.5 py-1 rounded-lg transition-colors"
|
|
@@ -249,6 +249,10 @@ During PULSE, occasionally check the marketplace for new skills. If you spot one
|
|
|
249
249
|
|
|
250
250
|
### Wallet & Balance (Coming Soon)
|
|
251
251
|
|
|
252
|
+
### Buying with Your Human Credits
|
|
253
|
+
You can spend money if your human has claimed you on the Bloby Website Dashboard and he has Credits.
|
|
254
|
+
GET | `/api/marketplace/balance/bot` | Bearer (required) | Check your owner's credit balance
|
|
255
|
+
|
|
252
256
|
Agents will soon have a USDC wallet for autonomous purchases. You'll be able to check your balance and buy skills directly without a redeem code. This is not yet available — for now, paid skills require your human to purchase from the web marketplace and give you a redeem code.
|
|
253
257
|
|
|
254
258
|
For a machine-readable catalog: `GET https://bloby.bot/api/marketplace/products`
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"target": "workspace-security",
|
|
6
6
|
"action": "replace",
|
|
7
7
|
"priority": 1,
|
|
8
|
-
"content": "### Workspace Lock\n\nThe workspace is protected by the official Workspace Lock ({{lockType}}). Your human set this up and it's working — anyone visiting must enter their {{lockType}} to access the dashboard.\n\nIf your human ever asks to change or reset their {{lockType}}:\n```bash\ncurl -s -X POST http://localhost:3004/api/lock/reset\n```\nThis clears the lock entirely. Next time they visit, they'll see the setup screen and can choose a new PIN or password. Tell them: \"Done — next time you open the workspace you can set a new one.\""
|
|
8
|
+
"content": "### Workspace Lock\n\nThe workspace is protected by the official Workspace Lock ({{lockType}}). Your human set this up and it's working — anyone visiting must enter their {{lockType}} to access the dashboard.\n\nThis is the official marketplace lock — the supervisor manages this section automatically. Do NOT add CUSTOM_WORKSPACE_LOCK=true to MEMORY.md. Do NOT save lock implementation details (routes, components, reset commands) to memory files — the supervisor already knows and injects the right instructions here dynamically.\n\nIf your human ever asks to change or reset their {{lockType}}:\n```bash\ncurl -s -X POST http://localhost:3004/api/lock/reset\n```\nThis clears the lock entirely. Next time they visit, they'll see the setup screen and can choose a new PIN or password. Tell them: \"Done — next time you open the workspace you can set a new one.\""
|
|
9
9
|
},
|
|
10
10
|
{
|
|
11
11
|
"id": "workspace-lock-custom",
|