bloby-bot 0.25.0 → 0.25.2
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 +86 -147
package/package.json
CHANGED
|
@@ -70,6 +70,8 @@ export class ChannelManager {
|
|
|
70
70
|
private customerBuffers = new Map<string, BufferedMessage[]>();
|
|
71
71
|
/** Debounce buffers per sender (keyed by "channel:sender") */
|
|
72
72
|
private debounceBuffers = new Map<string, DebounceEntry>();
|
|
73
|
+
/** Dynamic reply target for the admin live conversation (updated before each pushMessage) */
|
|
74
|
+
private waReplyTarget: { channel: ChannelType; rawSender: string; assistantBufferKey?: string } | null = null;
|
|
73
75
|
|
|
74
76
|
constructor(opts: ChannelManagerOpts) {
|
|
75
77
|
this.opts = opts;
|
|
@@ -243,6 +245,11 @@ export class ChannelManager {
|
|
|
243
245
|
|
|
244
246
|
// ── Internal ──
|
|
245
247
|
|
|
248
|
+
/** Format a bot reply with the agent's name prefix (for admin & assistant messages, NOT customer) */
|
|
249
|
+
private formatBotReply(text: string, botName: string): string {
|
|
250
|
+
return `🤖 *${botName}:*\n\n\`${text}\``;
|
|
251
|
+
}
|
|
252
|
+
|
|
246
253
|
private handleStatusChange(status: ChannelStatus) {
|
|
247
254
|
for (const listener of this.statusListeners) {
|
|
248
255
|
listener(status);
|
|
@@ -286,9 +293,9 @@ export class ChannelManager {
|
|
|
286
293
|
// Store every message for context (both mine and theirs)
|
|
287
294
|
this.storeAssistantContext(channel, sender, senderName, text, fromMe);
|
|
288
295
|
|
|
289
|
-
// Only continue if it's me AND the message
|
|
296
|
+
// Only continue if it's me AND the message contains the trigger
|
|
290
297
|
const botName = loadConfig().username || 'bloby';
|
|
291
|
-
const triggerPattern = new RegExp(
|
|
298
|
+
const triggerPattern = new RegExp(`(?:^|\\n)\\s*@${botName}[:\\s]`, 'i');
|
|
292
299
|
if (!fromMe || !triggerPattern.test(text)) return;
|
|
293
300
|
// Falls through to debounce → flushDebounce → handleAssistantMessage
|
|
294
301
|
}
|
|
@@ -357,19 +364,57 @@ export class ChannelManager {
|
|
|
357
364
|
return;
|
|
358
365
|
}
|
|
359
366
|
|
|
360
|
-
// Assistant mode — triggered message in someone else's chat
|
|
367
|
+
// Assistant mode — triggered message in someone else's chat → route through admin (shared brain)
|
|
361
368
|
if (mode === 'assistant') {
|
|
369
|
+
const phone = sender.replace(/@.*/, '');
|
|
370
|
+
const bufferKey = `${channel}:${phone}`;
|
|
371
|
+
const buffer = this.customerBuffers.get(bufferKey) || [];
|
|
372
|
+
|
|
373
|
+
// Strip trigger prefix
|
|
374
|
+
const cfgBotName = loadConfig().username || 'bloby';
|
|
375
|
+
const triggerRegex = new RegExp(`@${cfgBotName}[:\\s]+`, 'i');
|
|
376
|
+
const triggerMatch = combinedText.match(triggerRegex);
|
|
377
|
+
let cleanText = combinedText;
|
|
378
|
+
if (triggerMatch && triggerMatch.index !== undefined) {
|
|
379
|
+
cleanText = combinedText.slice(triggerMatch.index + triggerMatch[0].length).trim();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Load skill context (SCRIPT.md + contact memory)
|
|
383
|
+
const scriptPrompt = this.loadActiveScript(channelConfig);
|
|
384
|
+
let contactMemory = '';
|
|
385
|
+
try {
|
|
386
|
+
const customerDataDir = this.getSkillCustomerDataDir(channelConfig);
|
|
387
|
+
if (customerDataDir) {
|
|
388
|
+
const memoryPath = path.join(WORKSPACE_DIR, customerDataDir, `${phone}.md`);
|
|
389
|
+
if (fs.existsSync(memoryPath)) {
|
|
390
|
+
contactMemory = fs.readFileSync(memoryPath, 'utf-8').trim();
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
} catch {}
|
|
394
|
+
|
|
395
|
+
// Build enriched text: skill instructions + conversation context + command
|
|
396
|
+
let enrichedText = '';
|
|
397
|
+
if (scriptPrompt) enrichedText += `# Assistant Skill Instructions\n${scriptPrompt}\n\n---\n`;
|
|
398
|
+
if (contactMemory) enrichedText += `# Contact Memory (${phone})\n${contactMemory}\n\n---\n`;
|
|
399
|
+
if (buffer.length > 0) {
|
|
400
|
+
enrichedText += `# Recent conversation with ${senderName || phone}\n`;
|
|
401
|
+
enrichedText += buffer.map((m) => m.content).join('\n');
|
|
402
|
+
enrichedText += '\n\n---\n';
|
|
403
|
+
}
|
|
404
|
+
enrichedText += cleanText;
|
|
405
|
+
|
|
362
406
|
const message: InboundMessage = {
|
|
363
407
|
channel,
|
|
364
|
-
sender:
|
|
408
|
+
sender: phone,
|
|
365
409
|
senderName,
|
|
366
410
|
role: 'assistant',
|
|
367
|
-
text:
|
|
411
|
+
text: enrichedText,
|
|
368
412
|
rawSender: sender,
|
|
369
413
|
attachments: attachments.length > 0 ? attachments : undefined,
|
|
370
414
|
};
|
|
371
|
-
|
|
372
|
-
|
|
415
|
+
|
|
416
|
+
log.info(`[channels] Assistant mode | triggered in chat with ${phone} | buffer=${buffer.length} msgs | "${cleanText.slice(0, 60)}"`);
|
|
417
|
+
await this.handleAdminMessage(message);
|
|
373
418
|
return;
|
|
374
419
|
}
|
|
375
420
|
|
|
@@ -410,7 +455,7 @@ export class ChannelManager {
|
|
|
410
455
|
return 'customer';
|
|
411
456
|
}
|
|
412
457
|
|
|
413
|
-
/** Handle message from an admin — mirrors to chat conversation, uses main system prompt */
|
|
458
|
+
/** Handle message from an admin (or assistant trigger) — mirrors to chat conversation, uses main system prompt */
|
|
414
459
|
private async handleAdminMessage(msg: InboundMessage) {
|
|
415
460
|
const { workerApi, broadcastBloby, getModel } = this.opts;
|
|
416
461
|
const model = getModel();
|
|
@@ -469,8 +514,10 @@ export class ChannelManager {
|
|
|
469
514
|
}
|
|
470
515
|
} catch {}
|
|
471
516
|
|
|
472
|
-
// Channel context —
|
|
473
|
-
const
|
|
517
|
+
// Channel context — dynamic role (admin for self-chat, assistant for triggered messages)
|
|
518
|
+
const roleTag = msg.senderName && msg.role === 'assistant'
|
|
519
|
+
? `${msg.role} | ${msg.senderName}` : msg.role;
|
|
520
|
+
const channelContext = `[WhatsApp | ${msg.sender} | ${roleTag}]\n`;
|
|
474
521
|
|
|
475
522
|
// Convert inbound attachments to agent format
|
|
476
523
|
const agentAttachments: AgentAttachment[] | undefined = msg.attachments?.map((att) => ({
|
|
@@ -480,7 +527,7 @@ export class ChannelManager {
|
|
|
480
527
|
data: att.data,
|
|
481
528
|
}));
|
|
482
529
|
|
|
483
|
-
// Show "typing..."
|
|
530
|
+
// Show "typing..." in the correct chat
|
|
484
531
|
this.startTyping(msg.channel, msg.rawSender);
|
|
485
532
|
|
|
486
533
|
// Track text chunks for WhatsApp — lives for the conversation lifetime
|
|
@@ -496,24 +543,37 @@ export class ChannelManager {
|
|
|
496
543
|
waChunkBuf += eventData.token;
|
|
497
544
|
}
|
|
498
545
|
|
|
546
|
+
// Use dynamic reply target (self-chat or contact's chat depending on latest push)
|
|
547
|
+
const target = this.waReplyTarget;
|
|
548
|
+
if (!target) return;
|
|
549
|
+
|
|
499
550
|
// Agent paused to use a tool — send accumulated text as an intermediate WhatsApp message
|
|
500
551
|
if (type === 'bot:tool' && waChunkBuf.trim()) {
|
|
501
|
-
this.sendMessage(
|
|
552
|
+
this.sendMessage(target.channel, target.rawSender, this.formatBotReply(waChunkBuf.trim(), botName)).catch((err) => {
|
|
502
553
|
log.warn(`[channels] Failed to send WhatsApp chunk: ${err.message}`);
|
|
503
554
|
});
|
|
504
555
|
waChunkBuf = '';
|
|
505
556
|
}
|
|
506
557
|
|
|
507
558
|
if (type === 'bot:response' && eventData.content) {
|
|
508
|
-
// Send remaining text
|
|
559
|
+
// Send remaining text to the correct chat
|
|
509
560
|
const remaining = waChunkBuf.trim();
|
|
510
561
|
if (remaining) {
|
|
511
|
-
this.sendMessage(
|
|
562
|
+
this.sendMessage(target.channel, target.rawSender, this.formatBotReply(remaining, botName)).catch((err) => {
|
|
512
563
|
log.warn(`[channels] Failed to send WhatsApp reply: ${err.message}`);
|
|
513
564
|
});
|
|
514
565
|
waChunkBuf = '';
|
|
515
566
|
}
|
|
516
567
|
|
|
568
|
+
// If this was an assistant response, store in the contact's context buffer
|
|
569
|
+
if (target.assistantBufferKey) {
|
|
570
|
+
const buf = this.customerBuffers.get(target.assistantBufferKey);
|
|
571
|
+
if (buf) {
|
|
572
|
+
buf.push({ role: 'assistant', content: eventData.content });
|
|
573
|
+
if (buf.length > MAX_BUFFER_MESSAGES) buf.splice(0, buf.length - MAX_BUFFER_MESSAGES);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
517
577
|
// Save response to DB
|
|
518
578
|
workerApi(`/api/conversations/${convId}/messages`, 'POST', {
|
|
519
579
|
role: 'assistant',
|
|
@@ -535,6 +595,13 @@ export class ChannelManager {
|
|
|
535
595
|
}, { botName, humanName }, recentMessages);
|
|
536
596
|
}
|
|
537
597
|
|
|
598
|
+
// Set reply target BEFORE pushing — callback reads this to know where to send
|
|
599
|
+
this.waReplyTarget = {
|
|
600
|
+
channel: msg.channel,
|
|
601
|
+
rawSender: msg.rawSender,
|
|
602
|
+
assistantBufferKey: msg.role === 'assistant' ? `${msg.channel}:${msg.sender}` : undefined,
|
|
603
|
+
};
|
|
604
|
+
|
|
538
605
|
// Push the message into the live conversation
|
|
539
606
|
const channelContent = channelContext + msg.text;
|
|
540
607
|
pushMessage(convId, channelContent, agentAttachments);
|
|
@@ -681,150 +748,22 @@ export class ChannelManager {
|
|
|
681
748
|
text: string,
|
|
682
749
|
fromMe: boolean,
|
|
683
750
|
) {
|
|
684
|
-
|
|
751
|
+
// Normalize key to phone number (strip @lid / @s.whatsapp.net) — must match handleAssistantMessage's agentKey
|
|
752
|
+
const phone = sender.replace(/@.*/, '');
|
|
753
|
+
const bufferKey = `${channel}:${phone}`;
|
|
685
754
|
let buffer = this.customerBuffers.get(bufferKey);
|
|
686
755
|
if (!buffer) {
|
|
687
756
|
buffer = [];
|
|
688
757
|
this.customerBuffers.set(bufferKey, buffer);
|
|
689
758
|
}
|
|
690
|
-
const label = fromMe ? 'me' : (senderName ||
|
|
759
|
+
const label = fromMe ? 'me' : (senderName || phone);
|
|
691
760
|
buffer.push({ role: 'user', content: `[${label}]: ${text}` });
|
|
692
761
|
if (buffer.length > MAX_BUFFER_MESSAGES) {
|
|
693
762
|
buffer.splice(0, buffer.length - MAX_BUFFER_MESSAGES);
|
|
694
763
|
}
|
|
764
|
+
log.info(`[channels] Assistant context stored: ${bufferKey} | ${buffer.length} msgs | [${label}]: "${text.slice(0, 60)}"`);
|
|
695
765
|
}
|
|
696
766
|
|
|
697
|
-
/** Handle a triggered assistant message — runs one-shot agent with conversation context */
|
|
698
|
-
private async handleAssistantMessage(msg: InboundMessage, channelConfig: ChannelConfig) {
|
|
699
|
-
const agentKey = `${msg.channel}:${msg.sender}`;
|
|
700
|
-
|
|
701
|
-
// Check concurrent limit
|
|
702
|
-
if (this.activeAgents.size >= MAX_CONCURRENT_AGENTS && !this.activeAgents.has(agentKey)) {
|
|
703
|
-
log.info(`[channels] Max concurrent agents reached — queuing assistant message for ${msg.sender}`);
|
|
704
|
-
this.messageQueue.push(msg);
|
|
705
|
-
return;
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
const { workerApi, getModel } = this.opts;
|
|
709
|
-
const model = getModel();
|
|
710
|
-
|
|
711
|
-
// Strip trigger prefix: "@bloby: do X" → "do X"
|
|
712
|
-
const config = loadConfig();
|
|
713
|
-
const botName = config.username || 'bloby';
|
|
714
|
-
const triggerRegex = new RegExp(`^@${botName}[:\\s]+`, 'i');
|
|
715
|
-
const cleanText = msg.text.replace(triggerRegex, '').trim();
|
|
716
|
-
|
|
717
|
-
// Load SCRIPT.md from configured skill
|
|
718
|
-
const scriptPrompt = this.loadActiveScript(channelConfig);
|
|
719
|
-
|
|
720
|
-
// Fetch agent name
|
|
721
|
-
let agentBotName = 'Bloby', humanName = 'Human';
|
|
722
|
-
try {
|
|
723
|
-
const status = await workerApi('/api/onboard/status');
|
|
724
|
-
agentBotName = status.agentName || 'Bloby';
|
|
725
|
-
humanName = status.userName || 'Human';
|
|
726
|
-
} catch {}
|
|
727
|
-
|
|
728
|
-
// Get conversation buffer (already populated by storeAssistantContext)
|
|
729
|
-
let buffer = this.customerBuffers.get(agentKey);
|
|
730
|
-
if (!buffer) {
|
|
731
|
-
buffer = [];
|
|
732
|
-
this.customerBuffers.set(agentKey, buffer);
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
// All buffered messages are context for the agent
|
|
736
|
-
const recentMessages: RecentMessage[] = buffer.map((m) => ({
|
|
737
|
-
role: m.role,
|
|
738
|
-
content: m.content,
|
|
739
|
-
}));
|
|
740
|
-
|
|
741
|
-
// Load per-contact memory from the skill's customer_data directory
|
|
742
|
-
let contactMemory = '';
|
|
743
|
-
try {
|
|
744
|
-
const customerDataDir = this.getSkillCustomerDataDir(channelConfig);
|
|
745
|
-
if (customerDataDir) {
|
|
746
|
-
const memoryPath = path.join(WORKSPACE_DIR, customerDataDir, `${msg.sender}.md`);
|
|
747
|
-
if (fs.existsSync(memoryPath)) {
|
|
748
|
-
contactMemory = fs.readFileSync(memoryPath, 'utf-8').trim();
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
} catch {}
|
|
752
|
-
|
|
753
|
-
// Build enriched script with contact memory
|
|
754
|
-
let enrichedScript = scriptPrompt;
|
|
755
|
-
if (contactMemory && enrichedScript) {
|
|
756
|
-
enrichedScript += `\n\n---\n# Contact History (${msg.sender})\n\n${contactMemory}`;
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
const channelContext = `[WhatsApp | ${msg.sender} | assistant${msg.senderName ? ` | ${msg.senderName}` : ''}]\n`;
|
|
760
|
-
|
|
761
|
-
// Convert inbound attachments to agent format
|
|
762
|
-
const agentAttachments: AgentAttachment[] | undefined = msg.attachments?.map((att) => ({
|
|
763
|
-
type: 'image' as const,
|
|
764
|
-
name: `whatsapp_image.${att.mediaType.split('/')[1] || 'jpg'}`,
|
|
765
|
-
mediaType: att.mediaType,
|
|
766
|
-
data: att.data,
|
|
767
|
-
}));
|
|
768
|
-
|
|
769
|
-
// Stable convId per contact
|
|
770
|
-
const convId = `channel-${agentKey}`;
|
|
771
|
-
|
|
772
|
-
this.activeAgents.set(agentKey, { sender: msg.sender, channel: msg.channel });
|
|
773
|
-
|
|
774
|
-
// Show "typing..." while the agent processes
|
|
775
|
-
this.startTyping(msg.channel, msg.rawSender);
|
|
776
|
-
|
|
777
|
-
// Track text chunks for WhatsApp
|
|
778
|
-
let waChunkBuf = '';
|
|
779
|
-
|
|
780
|
-
startBlobyAgentQuery(
|
|
781
|
-
convId,
|
|
782
|
-
channelContext + cleanText,
|
|
783
|
-
model,
|
|
784
|
-
(type, eventData) => {
|
|
785
|
-
// Accumulate text tokens
|
|
786
|
-
if (type === 'bot:token' && eventData.token) {
|
|
787
|
-
waChunkBuf += eventData.token;
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
// Agent paused to use a tool — send accumulated text as intermediate message
|
|
791
|
-
if (type === 'bot:tool' && waChunkBuf.trim()) {
|
|
792
|
-
this.sendMessage(msg.channel, msg.rawSender, waChunkBuf.trim()).catch((err) => {
|
|
793
|
-
log.warn(`[channels] Failed to send assistant chunk: ${err.message}`);
|
|
794
|
-
});
|
|
795
|
-
waChunkBuf = '';
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
if (type === 'bot:response' && eventData.content) {
|
|
799
|
-
// Add response to buffer for continuity across triggers
|
|
800
|
-
buffer!.push({ role: 'assistant', content: eventData.content });
|
|
801
|
-
if (buffer!.length > MAX_BUFFER_MESSAGES) {
|
|
802
|
-
buffer!.splice(0, buffer!.length - MAX_BUFFER_MESSAGES);
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
// Send remaining text
|
|
806
|
-
const remaining = waChunkBuf.trim();
|
|
807
|
-
if (remaining) {
|
|
808
|
-
this.sendMessage(msg.channel, msg.rawSender, remaining).catch((err) => {
|
|
809
|
-
log.warn(`[channels] Failed to send assistant reply: ${err.message}`);
|
|
810
|
-
});
|
|
811
|
-
waChunkBuf = '';
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
if (type === 'bot:done') {
|
|
816
|
-
this.activeAgents.delete(agentKey);
|
|
817
|
-
if (eventData.usedFileTools) this.opts.restartBackend();
|
|
818
|
-
this.processQueue();
|
|
819
|
-
}
|
|
820
|
-
},
|
|
821
|
-
agentAttachments,
|
|
822
|
-
undefined,
|
|
823
|
-
{ botName: agentBotName, humanName },
|
|
824
|
-
recentMessages,
|
|
825
|
-
enrichedScript,
|
|
826
|
-
);
|
|
827
|
-
}
|
|
828
767
|
|
|
829
768
|
/** Transcribe audio via the existing whisper endpoint */
|
|
830
769
|
private async transcribeAudio(audioBase64: string): Promise<string | null> {
|