bloby-bot 0.27.2 → 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/package.json
CHANGED
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,
|
|
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
|
|
364
|
-
|
|
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
|
|
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
|
|
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:
|
|
514
|
+
sender: chatId,
|
|
412
515
|
senderName,
|
|
413
516
|
role: 'admin',
|
|
414
517
|
text: combinedText,
|
|
415
|
-
rawSender:
|
|
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
|
|
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, `${
|
|
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 (${
|
|
561
|
+
if (contactMemory) enrichedText += `# Contact Memory (${chatId})\n${contactMemory}\n\n---\n`;
|
|
459
562
|
if (buffer.length > 0) {
|
|
460
|
-
enrichedText += `# Recent conversation with ${
|
|
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:
|
|
571
|
+
sender: chatId,
|
|
469
572
|
senderName,
|
|
470
573
|
role: 'assistant',
|
|
471
574
|
text: enrichedText,
|
|
472
575
|
displayText: cleanText,
|
|
473
|
-
rawSender:
|
|
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 ${
|
|
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:
|
|
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
|
-
//
|
|
598
|
-
|
|
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
|
-
//
|
|
606
|
-
|
|
607
|
-
|
|
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
|
|
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
|
-
|
|
884
|
+
chatJid: string,
|
|
814
885
|
senderName: string | undefined,
|
|
815
886
|
text: string,
|
|
816
887
|
fromMe: boolean,
|
|
817
888
|
) {
|
|
818
|
-
|
|
819
|
-
const
|
|
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 ||
|
|
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
|
-
|
|
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
|
|
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
|
|
489
|
-
|
|
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
|
-
//
|
|
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} (
|
|
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
|
}
|
package/supervisor/index.ts
CHANGED
|
@@ -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
|
|
1400
|
-
|
|
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
|
-
//
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
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
|
-
|
|
48
|
+
The mode determines the routing/security policy for inbound messages. The core (supervisor) enforces it — your 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
|