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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bloby-bot",
3
- "version": "0.18.13",
3
+ "version": "0.18.15",
4
4
  "releaseNotes": [
5
5
  "1. react router implemented",
6
6
  "2. new workspace design",
@@ -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) => this.handleInboundMessage('whatsapp', 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) => this.handleInboundMessage('whatsapp', 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
- undefined,
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
- undefined,
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 (!rawText) continue;
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/marketplace"
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",