bloby-bot 0.25.1 → 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 +75 -153
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;
|
|
@@ -245,7 +247,7 @@ export class ChannelManager {
|
|
|
245
247
|
|
|
246
248
|
/** Format a bot reply with the agent's name prefix (for admin & assistant messages, NOT customer) */
|
|
247
249
|
private formatBotReply(text: string, botName: string): string {
|
|
248
|
-
return `🤖 *${botName}:*\n\n
|
|
250
|
+
return `🤖 *${botName}:*\n\n\`${text}\``;
|
|
249
251
|
}
|
|
250
252
|
|
|
251
253
|
private handleStatusChange(status: ChannelStatus) {
|
|
@@ -362,19 +364,57 @@ export class ChannelManager {
|
|
|
362
364
|
return;
|
|
363
365
|
}
|
|
364
366
|
|
|
365
|
-
// Assistant mode — triggered message in someone else's chat
|
|
367
|
+
// Assistant mode — triggered message in someone else's chat → route through admin (shared brain)
|
|
366
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
|
+
|
|
367
406
|
const message: InboundMessage = {
|
|
368
407
|
channel,
|
|
369
|
-
sender:
|
|
408
|
+
sender: phone,
|
|
370
409
|
senderName,
|
|
371
410
|
role: 'assistant',
|
|
372
|
-
text:
|
|
411
|
+
text: enrichedText,
|
|
373
412
|
rawSender: sender,
|
|
374
413
|
attachments: attachments.length > 0 ? attachments : undefined,
|
|
375
414
|
};
|
|
376
|
-
|
|
377
|
-
|
|
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);
|
|
378
418
|
return;
|
|
379
419
|
}
|
|
380
420
|
|
|
@@ -415,7 +455,7 @@ export class ChannelManager {
|
|
|
415
455
|
return 'customer';
|
|
416
456
|
}
|
|
417
457
|
|
|
418
|
-
/** 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 */
|
|
419
459
|
private async handleAdminMessage(msg: InboundMessage) {
|
|
420
460
|
const { workerApi, broadcastBloby, getModel } = this.opts;
|
|
421
461
|
const model = getModel();
|
|
@@ -474,8 +514,10 @@ export class ChannelManager {
|
|
|
474
514
|
}
|
|
475
515
|
} catch {}
|
|
476
516
|
|
|
477
|
-
// Channel context —
|
|
478
|
-
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`;
|
|
479
521
|
|
|
480
522
|
// Convert inbound attachments to agent format
|
|
481
523
|
const agentAttachments: AgentAttachment[] | undefined = msg.attachments?.map((att) => ({
|
|
@@ -485,7 +527,7 @@ export class ChannelManager {
|
|
|
485
527
|
data: att.data,
|
|
486
528
|
}));
|
|
487
529
|
|
|
488
|
-
// Show "typing..."
|
|
530
|
+
// Show "typing..." in the correct chat
|
|
489
531
|
this.startTyping(msg.channel, msg.rawSender);
|
|
490
532
|
|
|
491
533
|
// Track text chunks for WhatsApp — lives for the conversation lifetime
|
|
@@ -501,24 +543,37 @@ export class ChannelManager {
|
|
|
501
543
|
waChunkBuf += eventData.token;
|
|
502
544
|
}
|
|
503
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
|
+
|
|
504
550
|
// Agent paused to use a tool — send accumulated text as an intermediate WhatsApp message
|
|
505
551
|
if (type === 'bot:tool' && waChunkBuf.trim()) {
|
|
506
|
-
this.sendMessage(
|
|
552
|
+
this.sendMessage(target.channel, target.rawSender, this.formatBotReply(waChunkBuf.trim(), botName)).catch((err) => {
|
|
507
553
|
log.warn(`[channels] Failed to send WhatsApp chunk: ${err.message}`);
|
|
508
554
|
});
|
|
509
555
|
waChunkBuf = '';
|
|
510
556
|
}
|
|
511
557
|
|
|
512
558
|
if (type === 'bot:response' && eventData.content) {
|
|
513
|
-
// Send remaining text
|
|
559
|
+
// Send remaining text to the correct chat
|
|
514
560
|
const remaining = waChunkBuf.trim();
|
|
515
561
|
if (remaining) {
|
|
516
|
-
this.sendMessage(
|
|
562
|
+
this.sendMessage(target.channel, target.rawSender, this.formatBotReply(remaining, botName)).catch((err) => {
|
|
517
563
|
log.warn(`[channels] Failed to send WhatsApp reply: ${err.message}`);
|
|
518
564
|
});
|
|
519
565
|
waChunkBuf = '';
|
|
520
566
|
}
|
|
521
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
|
+
|
|
522
577
|
// Save response to DB
|
|
523
578
|
workerApi(`/api/conversations/${convId}/messages`, 'POST', {
|
|
524
579
|
role: 'assistant',
|
|
@@ -540,6 +595,13 @@ export class ChannelManager {
|
|
|
540
595
|
}, { botName, humanName }, recentMessages);
|
|
541
596
|
}
|
|
542
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
|
+
|
|
543
605
|
// Push the message into the live conversation
|
|
544
606
|
const channelContent = channelContext + msg.text;
|
|
545
607
|
pushMessage(convId, channelContent, agentAttachments);
|
|
@@ -702,146 +764,6 @@ export class ChannelManager {
|
|
|
702
764
|
log.info(`[channels] Assistant context stored: ${bufferKey} | ${buffer.length} msgs | [${label}]: "${text.slice(0, 60)}"`);
|
|
703
765
|
}
|
|
704
766
|
|
|
705
|
-
/** Handle a triggered assistant message — runs one-shot agent with conversation context */
|
|
706
|
-
private async handleAssistantMessage(msg: InboundMessage, channelConfig: ChannelConfig) {
|
|
707
|
-
const agentKey = `${msg.channel}:${msg.sender}`;
|
|
708
|
-
|
|
709
|
-
// Check concurrent limit
|
|
710
|
-
if (this.activeAgents.size >= MAX_CONCURRENT_AGENTS && !this.activeAgents.has(agentKey)) {
|
|
711
|
-
log.info(`[channels] Max concurrent agents reached — queuing assistant message for ${msg.sender}`);
|
|
712
|
-
this.messageQueue.push(msg);
|
|
713
|
-
return;
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
const { workerApi, getModel } = this.opts;
|
|
717
|
-
const model = getModel();
|
|
718
|
-
|
|
719
|
-
// Extract command after trigger: "Ok.\n\n@bloby: do X" → "do X"
|
|
720
|
-
const config = loadConfig();
|
|
721
|
-
const botName = config.username || 'bloby';
|
|
722
|
-
const triggerRegex = new RegExp(`@${botName}[:\\s]+`, 'i');
|
|
723
|
-
const triggerMatch = msg.text.match(triggerRegex);
|
|
724
|
-
let cleanText = msg.text;
|
|
725
|
-
if (triggerMatch && triggerMatch.index !== undefined) {
|
|
726
|
-
cleanText = msg.text.slice(triggerMatch.index + triggerMatch[0].length).trim();
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
// Load SCRIPT.md from configured skill
|
|
730
|
-
const scriptPrompt = this.loadActiveScript(channelConfig);
|
|
731
|
-
|
|
732
|
-
// Fetch agent name
|
|
733
|
-
let agentBotName = 'Bloby', humanName = 'Human';
|
|
734
|
-
try {
|
|
735
|
-
const status = await workerApi('/api/onboard/status');
|
|
736
|
-
agentBotName = status.agentName || 'Bloby';
|
|
737
|
-
humanName = status.userName || 'Human';
|
|
738
|
-
} catch {}
|
|
739
|
-
|
|
740
|
-
// Get conversation buffer (already populated by storeAssistantContext)
|
|
741
|
-
let buffer = this.customerBuffers.get(agentKey);
|
|
742
|
-
if (!buffer) {
|
|
743
|
-
buffer = [];
|
|
744
|
-
this.customerBuffers.set(agentKey, buffer);
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
log.info(`[channels] Assistant trigger: agentKey=${agentKey} | buffer=${buffer.length} msgs | cleanText="${cleanText.slice(0, 80)}"`);
|
|
748
|
-
if (buffer.length > 0) {
|
|
749
|
-
log.info(`[channels] Assistant context preview: ${buffer.slice(-5).map((m) => `[${m.role}] ${m.content.slice(0, 50)}`).join(' | ')}`);
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
// All buffered messages are context for the agent
|
|
753
|
-
const recentMessages: RecentMessage[] = buffer.map((m) => ({
|
|
754
|
-
role: m.role,
|
|
755
|
-
content: m.content,
|
|
756
|
-
}));
|
|
757
|
-
|
|
758
|
-
// Load per-contact memory from the skill's customer_data directory
|
|
759
|
-
let contactMemory = '';
|
|
760
|
-
try {
|
|
761
|
-
const customerDataDir = this.getSkillCustomerDataDir(channelConfig);
|
|
762
|
-
if (customerDataDir) {
|
|
763
|
-
const memoryPath = path.join(WORKSPACE_DIR, customerDataDir, `${msg.sender}.md`);
|
|
764
|
-
if (fs.existsSync(memoryPath)) {
|
|
765
|
-
contactMemory = fs.readFileSync(memoryPath, 'utf-8').trim();
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
} catch {}
|
|
769
|
-
|
|
770
|
-
// Build enriched script with contact memory
|
|
771
|
-
let enrichedScript = scriptPrompt;
|
|
772
|
-
if (contactMemory && enrichedScript) {
|
|
773
|
-
enrichedScript += `\n\n---\n# Contact History (${msg.sender})\n\n${contactMemory}`;
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
const channelContext = `[WhatsApp | ${msg.sender} | assistant${msg.senderName ? ` | ${msg.senderName}` : ''}]\n`;
|
|
777
|
-
|
|
778
|
-
// Convert inbound attachments to agent format
|
|
779
|
-
const agentAttachments: AgentAttachment[] | undefined = msg.attachments?.map((att) => ({
|
|
780
|
-
type: 'image' as const,
|
|
781
|
-
name: `whatsapp_image.${att.mediaType.split('/')[1] || 'jpg'}`,
|
|
782
|
-
mediaType: att.mediaType,
|
|
783
|
-
data: att.data,
|
|
784
|
-
}));
|
|
785
|
-
|
|
786
|
-
// Stable convId per contact
|
|
787
|
-
const convId = `channel-${agentKey}`;
|
|
788
|
-
|
|
789
|
-
this.activeAgents.set(agentKey, { sender: msg.sender, channel: msg.channel });
|
|
790
|
-
|
|
791
|
-
// Show "typing..." while the agent processes
|
|
792
|
-
this.startTyping(msg.channel, msg.rawSender);
|
|
793
|
-
|
|
794
|
-
// Track text chunks for WhatsApp
|
|
795
|
-
let waChunkBuf = '';
|
|
796
|
-
|
|
797
|
-
startBlobyAgentQuery(
|
|
798
|
-
convId,
|
|
799
|
-
channelContext + cleanText,
|
|
800
|
-
model,
|
|
801
|
-
(type, eventData) => {
|
|
802
|
-
// Accumulate text tokens
|
|
803
|
-
if (type === 'bot:token' && eventData.token) {
|
|
804
|
-
waChunkBuf += eventData.token;
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
// Agent paused to use a tool — send accumulated text as intermediate message
|
|
808
|
-
if (type === 'bot:tool' && waChunkBuf.trim()) {
|
|
809
|
-
this.sendMessage(msg.channel, msg.rawSender, this.formatBotReply(waChunkBuf.trim(), agentBotName)).catch((err) => {
|
|
810
|
-
log.warn(`[channels] Failed to send assistant chunk: ${err.message}`);
|
|
811
|
-
});
|
|
812
|
-
waChunkBuf = '';
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
if (type === 'bot:response' && eventData.content) {
|
|
816
|
-
// Add response to buffer for continuity across triggers
|
|
817
|
-
buffer!.push({ role: 'assistant', content: eventData.content });
|
|
818
|
-
if (buffer!.length > MAX_BUFFER_MESSAGES) {
|
|
819
|
-
buffer!.splice(0, buffer!.length - MAX_BUFFER_MESSAGES);
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
// Send remaining text
|
|
823
|
-
const remaining = waChunkBuf.trim();
|
|
824
|
-
if (remaining) {
|
|
825
|
-
this.sendMessage(msg.channel, msg.rawSender, this.formatBotReply(remaining, agentBotName)).catch((err) => {
|
|
826
|
-
log.warn(`[channels] Failed to send assistant reply: ${err.message}`);
|
|
827
|
-
});
|
|
828
|
-
waChunkBuf = '';
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
if (type === 'bot:done') {
|
|
833
|
-
this.activeAgents.delete(agentKey);
|
|
834
|
-
if (eventData.usedFileTools) this.opts.restartBackend();
|
|
835
|
-
this.processQueue();
|
|
836
|
-
}
|
|
837
|
-
},
|
|
838
|
-
agentAttachments,
|
|
839
|
-
undefined,
|
|
840
|
-
{ botName: agentBotName, humanName },
|
|
841
|
-
recentMessages,
|
|
842
|
-
enrichedScript,
|
|
843
|
-
);
|
|
844
|
-
}
|
|
845
767
|
|
|
846
768
|
/** Transcribe audio via the existing whisper endpoint */
|
|
847
769
|
private async transcribeAudio(audioBase64: string): Promise<string | null> {
|