bloby-bot 0.27.1 → 0.27.3

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/README.md CHANGED
@@ -328,7 +328,7 @@ Supervisor User's machine
328
328
  - Spawns: `cloudflared tunnel --url http://localhost:3000 --no-autoupdate`
329
329
  - Extracts tunnel URL from stdout (regex match for `*.trycloudflare.com`)
330
330
  - URL changes on every restart -- the relay provides the stable domain layer on top
331
- - Optionally register with Bloby Relay for a permanent `my.bloby.bot/username` or `bloby.bot/username` URL
331
+ - Optionally register with Bloby Relay for a permanent `open.bloby.bot/username` or `bloby.bot/username` URL
332
332
 
333
333
  #### Named Tunnel
334
334
 
@@ -389,7 +389,7 @@ Node.js/Express + http-proxy + MongoDB. Hosted on Railway. Only used when the us
389
389
  | Tier | Subdomain | Path shortcut | Cost |
390
390
  |---|---|---|---|
391
391
  | Premium | `bruno.bloby.bot` | `bloby.bot/bruno` | $5/mo |
392
- | Free | `bruno.my.bloby.bot` | `my.bloby.bot/bruno` | Free |
392
+ | Free | `bruno.open.bloby.bot` | `open.bloby.bot/bruno` | Free |
393
393
 
394
394
  Same username can exist on both tiers independently. Compound unique index on `username + tier`.
395
395
 
@@ -445,7 +445,7 @@ The CLI is the user-facing entry point. Commands:
445
445
 
446
446
  During init, the user is presented with an interactive arrow-key menu to choose their tunnel mode:
447
447
 
448
- - **Quick Tunnel** (Easy and Fast) -- Random CloudFlare tunnel URL on every start/update. Optionally use Bloby Relay for a permanent `my.bloby.bot/username` handle (free) or a premium `bloby.bot/username` handle ($5 one-time).
448
+ - **Quick Tunnel** (Easy and Fast) -- Random CloudFlare tunnel URL on every start/update. Optionally use Bloby Relay for a permanent `open.bloby.bot/username` handle (free) or a premium `bloby.bot/username` handle ($5 one-time).
449
449
  - **Named Tunnel** (Advanced) -- Persistent URL with your own domain. Requires a Cloudflare account + domain. Use a subdomain like `bot.yourdomain.com` or the root domain.
450
450
 
451
451
  If Named Tunnel is selected, `bloby init` immediately runs the named tunnel setup flow inline (same as `bloby tunnel setup`).
package/bin/cli.js CHANGED
@@ -277,7 +277,7 @@ function chooseTunnelMode() {
277
277
  tagColor: c.green,
278
278
  desc: [
279
279
  'Random CloudFlare tunnel URL on every start/update',
280
- `Optional: Use Bloby Relay Server and access your bot at ${c.reset}${c.pink}my.bloby.bot/YOURBOT${c.reset}${c.dim} (Free)`,
280
+ `Optional: Use Bloby Relay Server and access your bot at ${c.reset}${c.pink}open.bloby.bot/YOURBOT${c.reset}${c.dim} (Free)`,
281
281
  `Or use a premium handle like ${c.reset}${c.pink}bloby.bot/YOURBOT${c.reset}${c.dim} ($5 one-time fee)`,
282
282
  ],
283
283
  },
@@ -80,7 +80,7 @@ export async function runTunnelSetup() {
80
80
  {
81
81
  value: 'quick',
82
82
  label: `Quick Tunnel ${pc.green('[Easy and Fast]')}`,
83
- hint: pc.dim('Random CloudFlare tunnel URL on every start/update\n Optional: Use Bloby Relay Server and access your bot at my.bloby.bot/YOURBOT'),
83
+ hint: pc.dim('Random CloudFlare tunnel URL on every start/update\n Optional: Use Bloby Relay Server and access your bot at open.bloby.bot/YOURBOT'),
84
84
  },
85
85
  {
86
86
  value: 'named',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bloby-bot",
3
- "version": "0.27.1",
3
+ "version": "0.27.3",
4
4
  "releaseNotes": [
5
5
  "1. # voice note (PTT bubble)",
6
6
  "2. # audio file + caption",
package/shared/config.ts CHANGED
@@ -9,6 +9,8 @@ export interface ChannelConfig {
9
9
  admins?: string[];
10
10
  /** Active skill for customer-facing mode (folder name in workspace/skills/) */
11
11
  skill?: string;
12
+ /** Opt-in: process messages in group chats (default false). Channel mode ignores this. */
13
+ allowGroups?: boolean;
12
14
  }
13
15
 
14
16
  export interface BotConfig {
@@ -58,6 +58,13 @@ interface DebounceEntry {
58
58
  senderName?: string;
59
59
  fromMe: boolean;
60
60
  isSelfChat: boolean;
61
+ chatJid: string;
62
+ isGroup: boolean;
63
+ }
64
+
65
+ /** Per-conversation accumulator for streaming bot text → WhatsApp. */
66
+ export interface WaStreamState {
67
+ chunkBuf: string;
61
68
  }
62
69
 
63
70
  export class ChannelManager {
@@ -89,9 +96,9 @@ export class ChannelManager {
89
96
 
90
97
  log.info('[channels] Initializing WhatsApp channel...');
91
98
  const whatsapp = new WhatsAppChannel(
92
- (sender, senderName, text, fromMe, isSelfChat, images) => {
99
+ (sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, images) => {
93
100
  const attachments = images?.map((img) => ({ type: 'image' as const, mediaType: img.mediaType, data: img.data }));
94
- this.handleInboundMessage('whatsapp', sender, senderName, text, fromMe, isSelfChat, attachments);
101
+ this.handleInboundMessage('whatsapp', sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, attachments);
95
102
  },
96
103
  (status) => this.handleStatusChange(status),
97
104
  (audioBase64) => this.transcribeAudio(audioBase64),
@@ -310,6 +317,82 @@ export class ChannelManager {
310
317
  return `🤖 *${botName}:*\n\n${text}`;
311
318
  }
312
319
 
320
+ /** Allocate per-conv state for WhatsApp text streaming. Both the orchestrator
321
+ * (chat UI websocket) and the manager's own admin handler create one of these
322
+ * and feed each agent stream event through `routeWaStreamEvent` below. */
323
+ createWaStreamState(): WaStreamState {
324
+ return { chunkBuf: '' };
325
+ }
326
+
327
+ /** Centralized WhatsApp routing for streaming agent events.
328
+ *
329
+ * Decision rule:
330
+ * - If the manager has a queued reply target (set by `handleAdminMessage` when
331
+ * a WhatsApp inbound was the trigger) → send to that target.
332
+ * - Else if `fallbackMirrorJid` is provided (chat-UI initiated) → mirror to that JID
333
+ * (typically the user's own number for self-chat mirroring).
334
+ * - Else → don't send anything to WhatsApp.
335
+ *
336
+ * This method also updates the assistant context buffer when applicable.
337
+ * Call from any callback registered via startConversation. Safe to call from
338
+ * either the orchestrator or the manager's own callback — they will not
339
+ * double-send because only one callback is registered per live conversation.
340
+ */
341
+ routeWaStreamEvent(
342
+ state: WaStreamState,
343
+ type: string,
344
+ eventData: any,
345
+ fallbackMirrorJid: string | null,
346
+ botName: string,
347
+ ): void {
348
+ if (type === 'bot:token' && eventData?.token) {
349
+ state.chunkBuf += eventData.token;
350
+ return;
351
+ }
352
+
353
+ const fireSend = (jid: string, text: string, prefix: boolean) => {
354
+ const body = prefix ? this.formatBotReply(text, botName) : text;
355
+ this.sendMessage('whatsapp', jid, body).catch((err) => {
356
+ log.warn(`[channels] WA send failed (${jid}): ${err.message}`);
357
+ });
358
+ };
359
+
360
+ if (type === 'bot:tool' && state.chunkBuf.trim()) {
361
+ // Agent paused for a tool call — flush whatever text was streamed so the user
362
+ // sees progress before the tool result lands.
363
+ const target = this.waReplyQueue[0]; // peek; consume on bot:response
364
+ if (target) {
365
+ fireSend(target.rawSender, state.chunkBuf.trim(), true);
366
+ } else if (fallbackMirrorJid) {
367
+ fireSend(`${fallbackMirrorJid}@s.whatsapp.net`, state.chunkBuf.trim(), false);
368
+ }
369
+ state.chunkBuf = '';
370
+ return;
371
+ }
372
+
373
+ if (type === 'bot:response' && eventData?.content) {
374
+ const target = this.waReplyQueue.shift(); // consume the reply target for this turn
375
+ const remaining = state.chunkBuf.trim();
376
+
377
+ if (target) {
378
+ if (remaining) fireSend(target.rawSender, remaining, true);
379
+ } else if (remaining && fallbackMirrorJid) {
380
+ fireSend(`${fallbackMirrorJid}@s.whatsapp.net`, remaining, false);
381
+ }
382
+ state.chunkBuf = '';
383
+
384
+ // Append the assistant's reply into the per-chat context buffer so subsequent
385
+ // triggers in that chat see it as conversation history.
386
+ if (target?.assistantBufferKey) {
387
+ const buf = this.customerBuffers.get(target.assistantBufferKey);
388
+ if (buf) {
389
+ buf.push({ role: 'assistant', content: eventData.content });
390
+ if (buf.length > MAX_BUFFER_MESSAGES) buf.splice(0, buf.length - MAX_BUFFER_MESSAGES);
391
+ }
392
+ }
393
+ }
394
+ }
395
+
313
396
  private handleStatusChange(status: ChannelStatus) {
314
397
  for (const listener of this.statusListeners) {
315
398
  listener(status);
@@ -322,7 +405,11 @@ export class ChannelManager {
322
405
  return config.channels?.[channel];
323
406
  }
324
407
 
325
- /** Handle an incoming message from any channel — debounces rapid messages from the same sender */
408
+ /** Handle an incoming message from any channel — debounces rapid messages from the same sender.
409
+ *
410
+ * Per-mode behavior is decided here. To add a new mode: extend the gating block below
411
+ * (filter inbound) and the routing block in flushDebounce (route to admin/customer/assistant).
412
+ */
326
413
  private async handleInboundMessage(
327
414
  channel: ChannelType,
328
415
  sender: string,
@@ -330,6 +417,8 @@ export class ChannelManager {
330
417
  text: string,
331
418
  fromMe: boolean,
332
419
  isSelfChat: boolean,
420
+ chatJid: string,
421
+ isGroup: boolean,
333
422
  attachments?: InboundMessageAttachment[],
334
423
  ) {
335
424
  const channelConfig = this.getChannelConfig(channel);
@@ -337,6 +426,14 @@ export class ChannelManager {
337
426
 
338
427
  const mode = channelConfig.mode || 'channel';
339
428
 
429
+ // ── Group gating ──
430
+ // Channel mode is self-chat only — groups never apply.
431
+ // Other modes: opt-in via channelConfig.allowGroups (default false).
432
+ if (isGroup) {
433
+ if (mode === 'channel') return;
434
+ if (!channelConfig.allowGroups) return;
435
+ }
436
+
340
437
  // ── Channel mode: ONLY respond to self-chat ──
341
438
  if (mode === 'channel') {
342
439
  if (!fromMe || !isSelfChat) return;
@@ -350,8 +447,8 @@ export class ChannelManager {
350
447
  // Others' messages or my untriggered messages: store for context, don't invoke
351
448
  // My messages with @botname trigger: falls through to debounce → agent
352
449
  if (mode === 'assistant' && !(fromMe && isSelfChat)) {
353
- // Store every message for context (both mine and theirs)
354
- this.storeAssistantContext(channel, sender, senderName, text, fromMe);
450
+ // Store every message for context (both mine and theirs) — keyed by the chat (group or 1:1)
451
+ this.storeAssistantContext(channel, chatJid, senderName, text, fromMe);
355
452
 
356
453
  // Only continue if it's me AND the message contains the trigger
357
454
  const botName = loadConfig().username || 'bloby';
@@ -360,22 +457,23 @@ export class ChannelManager {
360
457
  // Falls through to debounce → flushDebounce → handleAssistantMessage
361
458
  }
362
459
 
363
- // Debounce: accumulate rapid messages from the same sender
364
- const debounceKey = `${channel}:${sender}`;
460
+ // Debounce: accumulate rapid messages from the same chat
461
+ // (key by chatJid so a group's rapid messages collapse into one turn)
462
+ const debounceKey = `${channel}:${chatJid}`;
365
463
  const existing = this.debounceBuffers.get(debounceKey);
366
464
 
367
465
  if (existing) {
368
- // Another message from the same sender — reset timer, append text
466
+ // Another message in the same chat — reset timer, append text
369
467
  clearTimeout(existing.timer);
370
468
  existing.messages.push(text);
371
469
  if (attachments?.length) existing.attachments.push(...attachments);
372
470
  existing.senderName = senderName || existing.senderName;
373
471
  existing.timer = setTimeout(() => this.flushDebounce(debounceKey), DEBOUNCE_MS);
374
- log.info(`[channels] Debounce: buffered message ${existing.messages.length} from ${sender}`);
472
+ log.info(`[channels] Debounce: buffered message ${existing.messages.length} from ${sender} in ${chatJid}`);
375
473
  return;
376
474
  }
377
475
 
378
- // First message from this sender — start debounce timer
476
+ // First message in this chat's debounce window
379
477
  const entry: DebounceEntry = {
380
478
  messages: [text],
381
479
  attachments: attachments ? [...attachments] : [],
@@ -384,6 +482,8 @@ export class ChannelManager {
384
482
  senderName,
385
483
  fromMe,
386
484
  isSelfChat,
485
+ chatJid,
486
+ isGroup,
387
487
  timer: setTimeout(() => this.flushDebounce(debounceKey), DEBOUNCE_MS),
388
488
  };
389
489
  this.debounceBuffers.set(debounceKey, entry);
@@ -395,7 +495,7 @@ export class ChannelManager {
395
495
  if (!entry) return;
396
496
  this.debounceBuffers.delete(key);
397
497
 
398
- const { channel, sender, senderName, fromMe, isSelfChat, messages, attachments } = entry;
498
+ const { channel, sender, senderName, fromMe, isSelfChat, chatJid, isGroup, messages, attachments } = entry;
399
499
  const combinedText = messages.join('\n');
400
500
 
401
501
  const channelConfig = this.getChannelConfig(channel);
@@ -403,16 +503,19 @@ export class ChannelManager {
403
503
 
404
504
  const mode = channelConfig.mode || 'channel';
405
505
 
506
+ // Reply identifier — strip JID suffix to get a stable id (phone for 1:1, group hash for groups)
507
+ const chatId = chatJid.replace(/@.*/, '');
508
+
406
509
  // Route based on mode and role
407
510
  if (mode === 'channel' || (mode === 'business' && fromMe && isSelfChat) || (mode === 'assistant' && fromMe && isSelfChat)) {
408
511
  // Admin (self-chat in any mode)
409
512
  const message: InboundMessage = {
410
513
  channel,
411
- sender: sender.replace(/@.*/, ''),
514
+ sender: chatId,
412
515
  senderName,
413
516
  role: 'admin',
414
517
  text: combinedText,
415
- rawSender: sender,
518
+ rawSender: chatJid,
416
519
  attachments: attachments.length > 0 ? attachments : undefined,
417
520
  };
418
521
 
@@ -424,10 +527,9 @@ export class ChannelManager {
424
527
  return;
425
528
  }
426
529
 
427
- // Assistant mode — triggered message in someone else's chat → route through admin (shared brain)
530
+ // Assistant mode — triggered message in someone else's chat (or a group) → route through admin (shared brain)
428
531
  if (mode === 'assistant') {
429
- const phone = sender.replace(/@.*/, '');
430
- const bufferKey = `${channel}:${phone}`;
532
+ const bufferKey = `${channel}:${chatId}`;
431
533
  const buffer = this.customerBuffers.get(bufferKey) || [];
432
534
 
433
535
  // Strip trigger prefix
@@ -445,7 +547,7 @@ export class ChannelManager {
445
547
  try {
446
548
  const customerDataDir = this.getSkillCustomerDataDir(channelConfig);
447
549
  if (customerDataDir) {
448
- const memoryPath = path.join(WORKSPACE_DIR, customerDataDir, `${phone}.md`);
550
+ const memoryPath = path.join(WORKSPACE_DIR, customerDataDir, `${chatId}.md`);
449
551
  if (fs.existsSync(memoryPath)) {
450
552
  contactMemory = fs.readFileSync(memoryPath, 'utf-8').trim();
451
553
  }
@@ -453,11 +555,12 @@ export class ChannelManager {
453
555
  } catch {}
454
556
 
455
557
  // Build enriched text: skill instructions + conversation context + command
558
+ const chatLabel = isGroup ? `group ${chatId}` : (senderName || chatId);
456
559
  let enrichedText = '';
457
560
  if (scriptPrompt) enrichedText += `# Assistant Skill Instructions\n${scriptPrompt}\n\n---\n`;
458
- if (contactMemory) enrichedText += `# Contact Memory (${phone})\n${contactMemory}\n\n---\n`;
561
+ if (contactMemory) enrichedText += `# Contact Memory (${chatId})\n${contactMemory}\n\n---\n`;
459
562
  if (buffer.length > 0) {
460
- enrichedText += `# Recent conversation with ${senderName || phone}\n`;
563
+ enrichedText += `# Recent conversation with ${chatLabel}\n`;
461
564
  enrichedText += buffer.map((m) => m.content).join('\n');
462
565
  enrichedText += '\n\n---\n';
463
566
  }
@@ -465,21 +568,21 @@ export class ChannelManager {
465
568
 
466
569
  const message: InboundMessage = {
467
570
  channel,
468
- sender: phone,
571
+ sender: chatId,
469
572
  senderName,
470
573
  role: 'assistant',
471
574
  text: enrichedText,
472
575
  displayText: cleanText,
473
- rawSender: sender,
576
+ rawSender: chatJid,
474
577
  attachments: attachments.length > 0 ? attachments : undefined,
475
578
  };
476
579
 
477
- log.info(`[channels] Assistant mode | triggered in chat with ${phone} | buffer=${buffer.length} msgs | "${cleanText.slice(0, 60)}"`);
580
+ log.info(`[channels] Assistant mode | triggered in ${isGroup ? 'group ' : 'chat with '}${chatId} | buffer=${buffer.length} msgs | "${cleanText.slice(0, 60)}"`);
478
581
  await this.handleAdminMessage(message);
479
582
  return;
480
583
  }
481
584
 
482
- // Business mode — incoming message
585
+ // Business mode — incoming message. Role is resolved against the actual sender JID (not the chat JID).
483
586
  const role = this.resolveBusinessRole(channelConfig, sender);
484
587
 
485
588
  const message: InboundMessage = {
@@ -488,7 +591,7 @@ export class ChannelManager {
488
591
  senderName,
489
592
  role,
490
593
  text: combinedText,
491
- rawSender: sender,
594
+ rawSender: chatJid,
492
595
  attachments: attachments.length > 0 ? attachments : undefined,
493
596
  };
494
597
 
@@ -594,54 +697,20 @@ export class ChannelManager {
594
697
  // Show "typing..." in the correct chat
595
698
  this.startTyping(msg.channel, msg.rawSender);
596
699
 
597
- // Track text chunks for WhatsApp — lives for the conversation lifetime
598
- let waChunkBuf = '';
700
+ // Per-conversation streaming state for WhatsApp routing
701
+ const waState = this.createWaStreamState();
599
702
 
600
703
  // Start a live conversation if one doesn't exist (shared with chat UI)
601
704
  if (!hasConversation(convId)) {
602
705
  log.info(`[channels] Starting live conversation for admin: ${convId}`);
603
706
 
604
707
  await startConversation(convId, model, (type, eventData) => {
605
- // Accumulate text tokens
606
- if (type === 'bot:token' && eventData.token) {
607
- waChunkBuf += eventData.token;
608
- }
609
-
610
- // Peek at the front of the reply queue (the target for the current response)
611
- const target = this.waReplyQueue[0];
612
- if (!target) return;
613
-
614
- // Agent paused to use a tool — send accumulated text as an intermediate WhatsApp message
615
- if (type === 'bot:tool' && waChunkBuf.trim()) {
616
- this.sendMessage(target.channel, target.rawSender, this.formatBotReply(waChunkBuf.trim(), botName)).catch((err) => {
617
- log.warn(`[channels] Failed to send WhatsApp chunk: ${err.message}`);
618
- });
619
- waChunkBuf = '';
620
- }
708
+ // WhatsApp routing — uses the manager's queue. No fallback mirror jid here:
709
+ // when no chat UI is open, we should only send to the chat that triggered us.
710
+ this.routeWaStreamEvent(waState, type, eventData, null, botName);
621
711
 
712
+ // Persist the assistant's reply to the conversation's DB
622
713
  if (type === 'bot:response' && eventData.content) {
623
- // Consume this target from the queue — this response is for it
624
- this.waReplyQueue.shift();
625
-
626
- // Send remaining text to the correct chat
627
- const remaining = waChunkBuf.trim();
628
- if (remaining) {
629
- this.sendMessage(target.channel, target.rawSender, this.formatBotReply(remaining, botName)).catch((err) => {
630
- log.warn(`[channels] Failed to send WhatsApp reply: ${err.message}`);
631
- });
632
- waChunkBuf = '';
633
- }
634
-
635
- // If this was an assistant response, store in the contact's context buffer
636
- if (target.assistantBufferKey) {
637
- const buf = this.customerBuffers.get(target.assistantBufferKey);
638
- if (buf) {
639
- buf.push({ role: 'assistant', content: eventData.content });
640
- if (buf.length > MAX_BUFFER_MESSAGES) buf.splice(0, buf.length - MAX_BUFFER_MESSAGES);
641
- }
642
- }
643
-
644
- // Save response to DB
645
714
  workerApi(`/api/conversations/${convId}/messages`, 'POST', {
646
715
  role: 'assistant',
647
716
  content: eventData.content,
@@ -649,7 +718,7 @@ export class ChannelManager {
649
718
  }).catch(() => {});
650
719
  }
651
720
 
652
- // Handle turn completion — restart backend if needed
721
+ // Handle turn completion — restart backend if file tools were used
653
722
  if (type === 'bot:turn-complete' && eventData.usedFileTools) {
654
723
  this.opts.restartBackend();
655
724
  }
@@ -807,23 +876,24 @@ export class ChannelManager {
807
876
  );
808
877
  }
809
878
 
810
- /** Store a message in the assistant context buffer (for conversation history when triggered) */
879
+ /** Store a message in the assistant context buffer (for conversation history when triggered).
880
+ * Keyed by the chat (not the sender) so groups accumulate one shared buffer per group.
881
+ */
811
882
  private storeAssistantContext(
812
883
  channel: ChannelType,
813
- sender: string,
884
+ chatJid: string,
814
885
  senderName: string | undefined,
815
886
  text: string,
816
887
  fromMe: boolean,
817
888
  ) {
818
- // Normalize key to phone number (strip @lid / @s.whatsapp.net) — must match handleAssistantMessage's agentKey
819
- const phone = sender.replace(/@.*/, '');
820
- const bufferKey = `${channel}:${phone}`;
889
+ const chatId = chatJid.replace(/@.*/, '');
890
+ const bufferKey = `${channel}:${chatId}`;
821
891
  let buffer = this.customerBuffers.get(bufferKey);
822
892
  if (!buffer) {
823
893
  buffer = [];
824
894
  this.customerBuffers.set(bufferKey, buffer);
825
895
  }
826
- const label = fromMe ? 'me' : (senderName || phone);
896
+ const label = fromMe ? 'me' : (senderName || chatId);
827
897
  buffer.push({ role: 'user', content: `[${label}]: ${text}` });
828
898
  if (buffer.length > MAX_BUFFER_MESSAGES) {
829
899
  buffer.splice(0, buffer.length - MAX_BUFFER_MESSAGES);
@@ -13,6 +13,8 @@ export interface ChannelConfig {
13
13
  admins?: string[];
14
14
  /** Active skill for customer-facing mode (folder name in workspace/skills/) */
15
15
  skill?: string;
16
+ /** Opt-in: process messages in group chats (default false). Channel mode ignores this. */
17
+ allowGroups?: boolean;
16
18
  }
17
19
 
18
20
  export interface InboundMessageAttachment {
@@ -29,8 +29,21 @@ export interface WhatsAppImageAttachment {
29
29
  data: string; // base64
30
30
  }
31
31
 
32
- /** Callback when a new message arrives */
33
- export type OnWhatsAppMessage = (sender: string, senderName: string | undefined, text: string, fromMe: boolean, isSelfChat: boolean, images?: WhatsAppImageAttachment[]) => void;
32
+ /** Callback when a new message arrives.
33
+ * - sender: who sent it (phone JID, translated from LID where possible)
34
+ * - chatJid: the conversation identifier (group JID for groups, peer JID for 1:1) — reply to this
35
+ * - isGroup: true when the chat is a WhatsApp group (@g.us)
36
+ */
37
+ export type OnWhatsAppMessage = (
38
+ sender: string,
39
+ senderName: string | undefined,
40
+ text: string,
41
+ fromMe: boolean,
42
+ isSelfChat: boolean,
43
+ chatJid: string,
44
+ isGroup: boolean,
45
+ images?: WhatsAppImageAttachment[],
46
+ ) => void;
34
47
 
35
48
  /** Callback to transcribe audio via whisper */
36
49
  export type TranscribeFn = (audioBase64: string) => Promise<string | null>;
@@ -416,9 +429,9 @@ export class WhatsAppChannel implements ChannelProvider {
416
429
  if (m.type !== 'notify') return;
417
430
 
418
431
  for (const msg of m.messages) {
419
- // Skip status broadcasts, group messages, and protocol messages
432
+ // Skip status broadcasts and protocol-only messages.
433
+ // Groups are passed through — the manager filters them based on channel config (allowGroups).
420
434
  if (msg.key.remoteJid === 'status@broadcast') continue;
421
- if (msg.key.remoteJid?.endsWith('@g.us')) continue; // group chats — ignore entirely
422
435
  if (msg.key.remoteJid?.endsWith('@newsletter')) continue; // channels/newsletters
423
436
  if (!msg.message) continue;
424
437
 
@@ -484,20 +497,28 @@ export class WhatsAppChannel implements ChannelProvider {
484
497
  const fromMe = msg.key.fromMe || false;
485
498
  const rawSender = msg.key.remoteJid || '';
486
499
  const participant = msg.key.participant || '';
500
+ const isGroup = rawSender.endsWith('@g.us');
501
+
502
+ // chatJid: where to reply (group JID for groups, peer JID otherwise).
503
+ const chatJid = rawSender;
487
504
 
488
- // The actual sender JID — use participant if available (newer protocol), fallback to remoteJid
489
- const actualSender = participant || rawSender;
505
+ // The actual sender JID:
506
+ // - groups: always `participant` (remoteJid is the group)
507
+ // - 1:1: `participant` if Baileys provided one (newer protocol), else remoteJid
508
+ const actualSender = isGroup
509
+ ? participant || rawSender
510
+ : (participant || rawSender);
490
511
 
491
- // Translate LID JIDs to phone JIDs
512
+ // Translate LID JIDs to phone JIDs (only handles our own LID)
492
513
  const sender = this.translateJid(actualSender);
493
514
  const pushName = msg.pushName || undefined;
494
515
 
495
- // Detect self-chat: remoteJid matches our own phone number AND no participant field
496
- const isSelfChat = !participant && this.ownPhoneJid !== null && this.translateJid(rawSender) === this.ownPhoneJid;
516
+ // Self-chat: only meaningful for 1:1 — remoteJid is our own number AND no participant.
517
+ const isSelfChat = !isGroup && !participant && this.ownPhoneJid !== null && this.translateJid(rawSender) === this.ownPhoneJid;
497
518
 
498
- log.info(`[whatsapp] Message from ${sender} (raw=${rawSender}, participant=${participant}, fromMe=${fromMe}, selfChat=${isSelfChat}, images=${images.length}): ${text.slice(0, 80)}`);
519
+ log.info(`[whatsapp] Message from ${sender} (chat=${chatJid}, group=${isGroup}, fromMe=${fromMe}, selfChat=${isSelfChat}, images=${images.length}): ${text.slice(0, 80)}`);
499
520
 
500
- this.onMessage(sender, pushName, text, fromMe, isSelfChat, images.length > 0 ? images : undefined);
521
+ this.onMessage(sender, pushName, text, fromMe, isSelfChat, chatJid, isGroup, images.length > 0 ? images : undefined);
501
522
  }
502
523
  });
503
524
  }
@@ -69,7 +69,7 @@ const MODELS: Record<string, { id: string; label: string }[]> = {
69
69
  // TOTAL_STEPS is dynamic — set inside the component based on isInitialSetup
70
70
 
71
71
  const HANDLES = [
72
- { tier: 'at', prefix: 'my.bloby.bot/', label: (n: string) => `my.bloby.bot/${n}`, badge: 'Free', badgeCls: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20', highlight: false },
72
+ { tier: 'at', prefix: 'open.bloby.bot/', label: (n: string) => `open.bloby.bot/${n}`, badge: 'Free', badgeCls: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20', highlight: false },
73
73
  { tier: 'premium', prefix: 'bloby.bot/', label: (n: string) => `bloby.bot/${n}`, badge: '$5', badgeCls: 'bg-[#AF27E3]/15 text-[#AF27E3] border-[#AF27E3]/20', highlight: true },
74
74
  ] as const;
75
75
 
@@ -1119,7 +1119,7 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
1119
1119
  </span>
1120
1120
  </div>
1121
1121
  <p className={`font-mono text-[13px] mt-1.5 ${freeSelected ? 'text-white/80' : 'text-white/40'}`}>
1122
- my.bloby.bot/{botName}
1122
+ open.bloby.bot/{botName}
1123
1123
  </p>
1124
1124
  </button>
1125
1125
  );
@@ -723,6 +723,7 @@ ${!connected ? `<script>
723
723
  if (data.mode) cfg.channels.whatsapp.mode = data.mode;
724
724
  if (data.admins !== undefined) cfg.channels.whatsapp.admins = data.admins;
725
725
  if (data.skill !== undefined) cfg.channels.whatsapp.skill = data.skill;
726
+ if (data.allowGroups !== undefined) cfg.channels.whatsapp.allowGroups = !!data.allowGroups;
726
727
  saveConfig(cfg);
727
728
  res.writeHead(200);
728
729
  res.end(JSON.stringify({ ok: true, config: cfg.channels.whatsapp }));
@@ -1396,14 +1397,13 @@ ${!connected ? `<script>
1396
1397
  if (!hasConversation(convId)) {
1397
1398
  log.info(`[orchestrator] Starting new live conversation...`);
1398
1399
 
1399
- // WhatsApp mirror state lives for the conversation lifetime
1400
- let waChunkBuf = '';
1400
+ // Per-conversation WhatsApp streaming state. The manager owns the
1401
+ // routing decision: if a WhatsApp inbound message is the trigger
1402
+ // for this turn, it sends to that chat; otherwise it falls back to
1403
+ // the self-chat mirror (the user's own number).
1404
+ const waState = channelManager.createWaStreamState();
1401
1405
 
1402
1406
  await startConversation(convId, freshConfig.ai.model, (type, eventData) => {
1403
- // Check WA mirror on each event (connection state may change)
1404
- const waStatus = channelManager.getStatus('whatsapp');
1405
- const waMirrorJid = waStatus?.connected ? waStatus.info?.phoneNumber : null;
1406
-
1407
1407
  // Track stream buffer for reconnecting clients
1408
1408
  if (type === 'bot:typing') {
1409
1409
  currentStreamConvId = convId;
@@ -1413,14 +1413,13 @@ ${!connected ? `<script>
1413
1413
 
1414
1414
  if (type === 'bot:token' && eventData.token) {
1415
1415
  currentStreamBuffer += eventData.token;
1416
- if (waMirrorJid) waChunkBuf += eventData.token;
1417
1416
  }
1418
1417
 
1419
- // WhatsApp mirror: send intermediate chunk when agent pauses for tool use
1420
- if (type === 'bot:tool' && waMirrorJid && waChunkBuf.trim()) {
1421
- channelManager.sendMessage('whatsapp', `${waMirrorJid}@s.whatsapp.net`, waChunkBuf.trim()).catch(() => {});
1422
- waChunkBuf = '';
1423
- }
1418
+ // Route streaming text to WhatsApp (if connected). Re-read mirror state
1419
+ // each time so reconnections / disconnects are picked up.
1420
+ const waStatus = channelManager.getStatus('whatsapp');
1421
+ const waMirrorJid = waStatus?.connected ? (waStatus.info?.phoneNumber as string | undefined) : undefined;
1422
+ channelManager.routeWaStreamEvent(waState, type, eventData, waMirrorJid ?? null, botName);
1424
1423
 
1425
1424
  // Agent finished a turn — handle backend restart + notify client
1426
1425
  if (type === 'bot:turn-complete') {
@@ -1463,12 +1462,6 @@ ${!connected ? `<script>
1463
1462
  if (type === 'bot:response') {
1464
1463
  currentStreamBuffer = '';
1465
1464
 
1466
- // WhatsApp mirror: send remaining chunk
1467
- if (waMirrorJid && waChunkBuf.trim()) {
1468
- channelManager.sendMessage('whatsapp', `${waMirrorJid}@s.whatsapp.net`, waChunkBuf.trim()).catch(() => {});
1469
- waChunkBuf = '';
1470
- }
1471
-
1472
1465
  (async () => {
1473
1466
  try {
1474
1467
  await workerApi(`/api/conversations/${convId}/messages`, 'POST', {
@@ -45,12 +45,34 @@ Your channel configuration is injected into your context (if any channels are co
45
45
 
46
46
  ## Modes
47
47
 
48
- **Channel Mode** (default): Your human's own WhatsApp number. Only self-chat (messages your human sends to themselves) triggers youmessages from other people are completely ignored. This is "just talk to me" mode.
48
+ The mode determines the routing/security policy for inbound messages. The core (supervisor) enforces ityour skill is what decides which mode is active. Switch via `/api/channels/whatsapp/configure`.
49
+
50
+ **Channel Mode** (default): Your human's own WhatsApp number. Only self-chat (messages your human sends to themselves) triggers you — messages from other people are completely ignored. This is "just talk to me" mode. Group chats are always ignored in channel mode regardless of `allowGroups`.
49
51
 
50
52
  **Business Mode**: Bloby has its own dedicated WhatsApp number. Numbers in the `admins` array get admin access (main system prompt). Everyone else is a customer and gets the support prompt from the active skill's SCRIPT.md.
51
53
 
52
54
  **Assistant Mode**: Your personal assistant inside your own conversations. Self-chat works as a normal admin channel. When other people message you, their messages are silently stored for context. When YOU type `@botname:` followed by a command in someone's chat, the agent activates with full conversation context and responds in that chat. The trigger uses the bot's configured name (from `config.json` `username` field) and is case-insensitive. Nobody else can trigger the agent — only you (the account owner). Uses the active skill's SCRIPT.md for the system prompt and `customer_data/` for per-contact memory.
53
55
 
56
+ ### Group chats (`allowGroups`)
57
+
58
+ Group chats are **off by default**. Enable per-channel with the `allowGroups` flag on `/configure`. When enabled:
59
+
60
+ - **Assistant mode**: messages in groups your human is part of are stored as conversation context (per-group, shared buffer). Your human can `@botname:` inside a group and you'll respond in that group with the full group context.
61
+ - **Business mode**: messages from group members are routed by `admins` (admin vs customer) like any other inbound. The group itself becomes the reply target.
62
+ - **Channel mode**: groups are always ignored — channel mode is self-chat only.
63
+
64
+ Reply target: in groups your reply goes to the group (everyone sees it), not to the individual sender. Per-group memory under `customer_data/` is keyed by the group's WhatsApp ID (not a phone number).
65
+
66
+ Toggle with:
67
+
68
+ ```bash
69
+ curl -s -X POST http://localhost:7400/api/channels/whatsapp/configure \
70
+ -H "Content-Type: application/json" \
71
+ -d '{"allowGroups":true}'
72
+ ```
73
+
74
+ Disable again with `{"allowGroups":false}`. Default is false — leave it off unless your human asks for it.
75
+
54
76
  ---
55
77
 
56
78
  ## Setup
@@ -105,6 +127,8 @@ curl -s -X POST http://localhost:7400/api/channels/whatsapp/configure \
105
127
 
106
128
  The trigger uses the bot's name from `config.json` `username` field (e.g. if username is "bloby", trigger is `@bloby:`). Only the account owner can trigger — other people's messages are context only.
107
129
 
130
+ To also let assistant mode operate in group chats your human is in, add `"allowGroups":true` to the configure call (see "Group chats" above).
131
+
108
132
  ### 3. Verify
109
133
 
110
134
  ```bash