bloby-bot 0.46.1 → 0.46.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 +2 -2
- package/scripts/postinstall.js +16 -0
- package/supervisor/channels/manager.ts +164 -59
- package/supervisor/channels/types.ts +32 -0
- package/supervisor/channels/whatsapp.ts +69 -9
- package/supervisor/chat/src/hooks/useBlobyChat.ts +4 -4
- package/supervisor/index.ts +78 -22
- package/worker/db.ts +27 -12
- package/worker/index.ts +2 -2
- package/worker/prompts/bloby-system-prompt.txt +2 -0
- package/workspace/skills/whatsapp/SKILL.md +27 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bloby-bot",
|
|
3
|
-
"version": "0.46.
|
|
3
|
+
"version": "0.46.3",
|
|
4
4
|
"releaseNotes": [
|
|
5
5
|
"1. # voice note (PTT bubble)",
|
|
6
6
|
"2. # audio file + caption",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"@streamdown/code": "^1.1.1",
|
|
59
59
|
"@tailwindcss/vite": "^4.2.0",
|
|
60
60
|
"@vitejs/plugin-react": "^6.0.1",
|
|
61
|
-
"@whiskeysockets/baileys": "^7.0.0-
|
|
61
|
+
"@whiskeysockets/baileys": "^7.0.0-rc11",
|
|
62
62
|
"better-sqlite3": "^12.6.2",
|
|
63
63
|
"class-variance-authority": "^0.7.1",
|
|
64
64
|
"clsx": "^2.1.1",
|
package/scripts/postinstall.js
CHANGED
|
@@ -77,6 +77,22 @@ try {
|
|
|
77
77
|
console.error('Warning: failed to install dependencies in ~/.bloby/ — run "cd ~/.bloby && npm install" manually');
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// ── Prune wrong-libc claude-agent-sdk native package ──
|
|
81
|
+
// The SDK's binary resolver tries the musl variant before glibc on Linux. If
|
|
82
|
+
// both optionalDependencies install (npm does that for arm64), a glibc system
|
|
83
|
+
// like Raspberry Pi OS picks the musl binary and fails to spawn it.
|
|
84
|
+
if (process.platform === 'linux') {
|
|
85
|
+
const muslLoader = `/lib/ld-musl-${process.arch === 'arm64' ? 'aarch64' : process.arch === 'x64' ? 'x86_64' : process.arch}.so.1`;
|
|
86
|
+
const systemIsMusl = fs.existsSync(muslLoader);
|
|
87
|
+
const unwanted = systemIsMusl
|
|
88
|
+
? `claude-agent-sdk-linux-${process.arch}`
|
|
89
|
+
: `claude-agent-sdk-linux-${process.arch}-musl`;
|
|
90
|
+
const unwantedPath = path.join(BLOBY_HOME, 'node_modules', '@anthropic-ai', unwanted);
|
|
91
|
+
if (fs.existsSync(unwantedPath)) {
|
|
92
|
+
fs.rmSync(unwantedPath, { recursive: true, force: true });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
80
96
|
// ── Install workspace dependencies (isolated from system deps) ──
|
|
81
97
|
// Native modules (better-sqlite3) must be built for the target platform —
|
|
82
98
|
// failures here cause backend crash loops, so surface them.
|
|
@@ -25,9 +25,10 @@ import { WORKSPACE_DIR } from '../../shared/paths.js';
|
|
|
25
25
|
import { log } from '../../shared/logger.js';
|
|
26
26
|
import { startBlobyAgentQuery, startConversation, pushMessage, hasConversation, type RecentMessage } from '../bloby-agent.js';
|
|
27
27
|
import { WhatsAppChannel } from './whatsapp.js';
|
|
28
|
-
import type { ChannelConfig, ChannelProvider, ChannelStatus, ChannelType, InboundMessage, InboundMessageAttachment, SenderRole } from './types.js';
|
|
28
|
+
import type { ChannelConfig, ChannelProvider, ChannelStatus, ChannelType, InboundMessage, InboundMessageAttachment, RoutingTarget, SenderRole } from './types.js';
|
|
29
29
|
import type { AgentAttachment } from '../bloby-agent.js';
|
|
30
30
|
import { saveAttachment, type SavedFile } from '../file-saver.js';
|
|
31
|
+
import type { WAMessageKey } from '@whiskeysockets/baileys';
|
|
31
32
|
|
|
32
33
|
const MAX_CONCURRENT_AGENTS = 5;
|
|
33
34
|
const MAX_BUFFER_MESSAGES = 30;
|
|
@@ -74,6 +75,8 @@ interface DebounceEntry {
|
|
|
74
75
|
isSelfChat: boolean;
|
|
75
76
|
chatJid: string;
|
|
76
77
|
isGroup: boolean;
|
|
78
|
+
/** Latest inbound message key in the batch (used for reactions/quotes on the freshest message). */
|
|
79
|
+
inboundKey?: WAMessageKey;
|
|
77
80
|
}
|
|
78
81
|
|
|
79
82
|
/** Per-conversation accumulator for streaming bot text → WhatsApp. */
|
|
@@ -91,8 +94,13 @@ export class ChannelManager {
|
|
|
91
94
|
private customerBuffers = new Map<string, BufferedMessage[]>();
|
|
92
95
|
/** Debounce buffers per sender (keyed by "channel:sender") */
|
|
93
96
|
private debounceBuffers = new Map<string, DebounceEntry>();
|
|
94
|
-
/**
|
|
95
|
-
|
|
97
|
+
/**
|
|
98
|
+
* Per-conversation FIFO of routing targets. One entry pushed for every user message,
|
|
99
|
+
* one consumed for every `bot:response`. This is the supervisor-enforced anti-bleed
|
|
100
|
+
* mechanism — the agent's reply is pinned to whatever surface triggered it, regardless
|
|
101
|
+
* of any mid-turn inbound from another channel.
|
|
102
|
+
*/
|
|
103
|
+
private routingQueues = new Map<string, RoutingTarget[]>();
|
|
96
104
|
|
|
97
105
|
constructor(opts: ChannelManagerOpts) {
|
|
98
106
|
this.opts = opts;
|
|
@@ -110,9 +118,9 @@ export class ChannelManager {
|
|
|
110
118
|
|
|
111
119
|
log.info('[channels] Initializing WhatsApp channel...');
|
|
112
120
|
const whatsapp = new WhatsAppChannel(
|
|
113
|
-
(sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, images) => {
|
|
121
|
+
(sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, images, inboundKey) => {
|
|
114
122
|
const attachments = images?.map((img) => ({ type: 'image' as const, mediaType: img.mediaType, data: img.data }));
|
|
115
|
-
this.handleInboundMessage('whatsapp', sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, attachments);
|
|
123
|
+
this.handleInboundMessage('whatsapp', sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, attachments, inboundKey);
|
|
116
124
|
},
|
|
117
125
|
(status) => this.handleStatusChange(status),
|
|
118
126
|
(audioBase64) => this.transcribeAudio(audioBase64),
|
|
@@ -134,9 +142,9 @@ export class ChannelManager {
|
|
|
134
142
|
let provider = this.providers.get('whatsapp');
|
|
135
143
|
if (!provider) {
|
|
136
144
|
const whatsapp = new WhatsAppChannel(
|
|
137
|
-
(sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, images) => {
|
|
145
|
+
(sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, images, inboundKey) => {
|
|
138
146
|
const attachments = images?.map((img) => ({ type: 'image' as const, mediaType: img.mediaType, data: img.data }));
|
|
139
|
-
this.handleInboundMessage('whatsapp', sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, attachments);
|
|
147
|
+
this.handleInboundMessage('whatsapp', sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, attachments, inboundKey);
|
|
140
148
|
},
|
|
141
149
|
(status) => this.handleStatusChange(status),
|
|
142
150
|
(audioBase64) => this.transcribeAudio(audioBase64),
|
|
@@ -338,65 +346,119 @@ export class ChannelManager {
|
|
|
338
346
|
return { chunkBuf: '' };
|
|
339
347
|
}
|
|
340
348
|
|
|
349
|
+
/** Push a user message into a live conversation and pin where the assistant's reply must go.
|
|
350
|
+
*
|
|
351
|
+
* THIS IS THE SINGLE SOURCE OF TRUTH for routing — every caller (chat-WS, channel inbound
|
|
352
|
+
* handlers, scheduler, etc.) MUST push via this method, never via the raw harness pushMessage.
|
|
353
|
+
*
|
|
354
|
+
* Each call enqueues exactly one routing target onto a per-conversation FIFO. Each
|
|
355
|
+
* `bot:response` consumes exactly one entry. Concurrent inbounds from different surfaces
|
|
356
|
+
* during the same turn cannot bleed into each other's replies — the SDK queues inputs in
|
|
357
|
+
* order, and the FIFO mirrors that ordering. Turns that end without a response (error /
|
|
358
|
+
* empty turn / aborted) drain the head entry in `routeWaStreamEvent` so the queue stays
|
|
359
|
+
* in sync. */
|
|
360
|
+
pushWithRouting(
|
|
361
|
+
convId: string,
|
|
362
|
+
target: RoutingTarget,
|
|
363
|
+
content: string,
|
|
364
|
+
attachments?: AgentAttachment[],
|
|
365
|
+
savedFiles?: SavedFile[],
|
|
366
|
+
): void {
|
|
367
|
+
let q = this.routingQueues.get(convId);
|
|
368
|
+
if (!q) {
|
|
369
|
+
q = [];
|
|
370
|
+
this.routingQueues.set(convId, q);
|
|
371
|
+
}
|
|
372
|
+
q.push(target);
|
|
373
|
+
pushMessage(convId, content, attachments, savedFiles);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Peek the head of the routing queue without consuming. Used during intermediate streaming
|
|
377
|
+
* flushes (bot:tool) so chunks land on the correct surface before the final response. */
|
|
378
|
+
private peekRoute(convId: string): RoutingTarget | undefined {
|
|
379
|
+
return this.routingQueues.get(convId)?.[0];
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Consume one entry from the routing queue. Called on `bot:response`, and as a safety net
|
|
383
|
+
* on `bot:turn-complete`/`bot:error` if no response fired (so the head doesn't bleed into
|
|
384
|
+
* the next turn). */
|
|
385
|
+
private consumeRoute(convId: string): RoutingTarget | undefined {
|
|
386
|
+
const q = this.routingQueues.get(convId);
|
|
387
|
+
if (!q || q.length === 0) return undefined;
|
|
388
|
+
const target = q.shift();
|
|
389
|
+
if (q.length === 0) this.routingQueues.delete(convId);
|
|
390
|
+
return target;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Drop all pending routes for a conversation — used when the live conversation ends.
|
|
394
|
+
* Accepts undefined for ergonomics in callers that hold a possibly-undefined convId. */
|
|
395
|
+
clearRoutes(convId: string | undefined): void {
|
|
396
|
+
if (!convId) return;
|
|
397
|
+
const q = this.routingQueues.get(convId);
|
|
398
|
+
if (q && q.length > 0) {
|
|
399
|
+
log.warn(`[channels] Discarding ${q.length} pending route(s) for ended conversation ${convId}`);
|
|
400
|
+
}
|
|
401
|
+
this.routingQueues.delete(convId);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Send a reaction emoji on the inbound message that triggered a turn. Used to acknowledge
|
|
405
|
+
* long-running work without spamming text. Pass `''` to remove a previous reaction. */
|
|
406
|
+
async reactToInbound(target: RoutingTarget, emoji: string): Promise<void> {
|
|
407
|
+
if (!target.inboundKey || !target.waSendTo) return;
|
|
408
|
+
const provider = this.providers.get('whatsapp');
|
|
409
|
+
if (provider instanceof WhatsAppChannel) {
|
|
410
|
+
await provider.sendReaction(target.waSendTo, target.inboundKey as WAMessageKey, emoji);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Direct reaction API (for the /api/channels/whatsapp/react endpoint). */
|
|
415
|
+
async reactToMessage(channel: ChannelType, chatJid: string, key: WAMessageKey, emoji: string): Promise<void> {
|
|
416
|
+
const provider = this.providers.get(channel);
|
|
417
|
+
if (!(provider instanceof WhatsAppChannel)) {
|
|
418
|
+
throw new Error(`Channel ${channel} does not support reactions`);
|
|
419
|
+
}
|
|
420
|
+
await provider.sendReaction(chatJid, key, emoji);
|
|
421
|
+
}
|
|
422
|
+
|
|
341
423
|
/** Centralized WhatsApp routing for streaming agent events.
|
|
342
424
|
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
* (typically the user's own number for self-chat mirroring).
|
|
348
|
-
* - Else → don't send anything to WhatsApp.
|
|
425
|
+
* Every event passes through here exactly once (only one onMessage callback is registered
|
|
426
|
+
* per live conversation, regardless of who started it). The routing decision is pure: it
|
|
427
|
+
* consults the per-conversation FIFO populated by `pushWithRouting`. No fallback, no
|
|
428
|
+
* implicit channel inference — the trigger surface owns the reply.
|
|
349
429
|
*
|
|
350
|
-
*
|
|
351
|
-
* Call from any callback registered via startConversation. Safe to call from
|
|
352
|
-
* either the orchestrator or the manager's own callback — they will not
|
|
353
|
-
* double-send because only one callback is registered per live conversation.
|
|
430
|
+
* Also keeps assistant-mode context buffers up to date.
|
|
354
431
|
*/
|
|
355
432
|
routeWaStreamEvent(
|
|
356
433
|
state: WaStreamState,
|
|
357
434
|
type: string,
|
|
358
435
|
eventData: any,
|
|
359
|
-
fallbackMirrorJid: string | null,
|
|
360
436
|
botName: string,
|
|
361
437
|
): void {
|
|
438
|
+
const convId = eventData?.conversationId as string | undefined;
|
|
439
|
+
|
|
362
440
|
if (type === 'bot:token' && eventData?.token) {
|
|
363
441
|
state.chunkBuf += eventData.token;
|
|
364
442
|
return;
|
|
365
443
|
}
|
|
366
444
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
};
|
|
373
|
-
|
|
374
|
-
if (type === 'bot:tool' && state.chunkBuf.trim()) {
|
|
375
|
-
// Agent paused for a tool call — flush whatever text was streamed so the user
|
|
376
|
-
// sees progress before the tool result lands.
|
|
377
|
-
const target = this.waReplyQueue[0]; // peek; consume on bot:response
|
|
378
|
-
if (target) {
|
|
379
|
-
fireSend(target.rawSender, state.chunkBuf.trim(), true);
|
|
380
|
-
} else if (fallbackMirrorJid) {
|
|
381
|
-
fireSend(`${fallbackMirrorJid}@s.whatsapp.net`, state.chunkBuf.trim(), false);
|
|
382
|
-
}
|
|
445
|
+
if (type === 'bot:tool' && state.chunkBuf.trim() && convId) {
|
|
446
|
+
// Agent paused for a tool call — flush streamed text so the user sees progress
|
|
447
|
+
// before the tool result lands. Peek (don't consume) — the final bot:response
|
|
448
|
+
// is what closes out the turn.
|
|
449
|
+
this.sendStreamChunk(this.peekRoute(convId), state.chunkBuf.trim(), botName);
|
|
383
450
|
state.chunkBuf = '';
|
|
384
451
|
return;
|
|
385
452
|
}
|
|
386
453
|
|
|
387
|
-
if (type === 'bot:response' && eventData?.content) {
|
|
388
|
-
const target = this.
|
|
454
|
+
if (type === 'bot:response' && eventData?.content && convId) {
|
|
455
|
+
const target = this.consumeRoute(convId);
|
|
389
456
|
const remaining = state.chunkBuf.trim();
|
|
390
|
-
|
|
391
|
-
if (target) {
|
|
392
|
-
if (remaining) fireSend(target.rawSender, remaining, true);
|
|
393
|
-
} else if (remaining && fallbackMirrorJid) {
|
|
394
|
-
fireSend(`${fallbackMirrorJid}@s.whatsapp.net`, remaining, false);
|
|
395
|
-
}
|
|
457
|
+
if (remaining) this.sendStreamChunk(target, remaining, botName);
|
|
396
458
|
state.chunkBuf = '';
|
|
397
459
|
|
|
398
|
-
// Append the assistant's reply into the per-chat context buffer so
|
|
399
|
-
//
|
|
460
|
+
// Append the assistant's reply into the per-chat context buffer so the next
|
|
461
|
+
// trigger in that chat sees it as conversation history.
|
|
400
462
|
if (target?.assistantBufferKey) {
|
|
401
463
|
const buf = this.customerBuffers.get(target.assistantBufferKey);
|
|
402
464
|
if (buf) {
|
|
@@ -404,9 +466,34 @@ export class ChannelManager {
|
|
|
404
466
|
if (buf.length > MAX_BUFFER_MESSAGES) buf.splice(0, buf.length - MAX_BUFFER_MESSAGES);
|
|
405
467
|
}
|
|
406
468
|
}
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Turn ended (or errored) without a bot:response — drain the head entry so it
|
|
473
|
+
// doesn't bleed into the next turn's reply. The SDK guarantees one response per
|
|
474
|
+
// pushed input; this safety net covers aborts, empty turns, and provider errors.
|
|
475
|
+
if ((type === 'bot:turn-complete' || type === 'bot:error') && convId) {
|
|
476
|
+
if (this.peekRoute(convId)) {
|
|
477
|
+
const dropped = this.consumeRoute(convId);
|
|
478
|
+
log.warn(`[channels] ${type} without bot:response — dropping pending route (surface=${dropped?.surface}, to=${dropped?.waSendTo || 'none'})`);
|
|
479
|
+
}
|
|
480
|
+
state.chunkBuf = '';
|
|
407
481
|
}
|
|
408
482
|
}
|
|
409
483
|
|
|
484
|
+
/** Deliver a streamed chunk to the WhatsApp side of a routing target.
|
|
485
|
+
* No-op when the target has no `waSendTo` (e.g., chat-UI turn with WA disconnected). */
|
|
486
|
+
private sendStreamChunk(target: RoutingTarget | undefined, text: string, botName: string): void {
|
|
487
|
+
if (!target?.waSendTo) return;
|
|
488
|
+
// Prefix only when the trigger came from WhatsApp AND it isn't the user's own self-chat —
|
|
489
|
+
// the user doesn't need to see "🤖 Bot:" before their own bot's reply in their own chat.
|
|
490
|
+
const usePrefix = target.surface === 'whatsapp' && !target.isSelfChat;
|
|
491
|
+
const body = usePrefix ? this.formatBotReply(text, botName) : text;
|
|
492
|
+
this.sendMessage('whatsapp', target.waSendTo, body).catch((err) =>
|
|
493
|
+
log.warn(`[channels] WA send failed (${target.waSendTo}): ${err.message}`),
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
410
497
|
private handleStatusChange(status: ChannelStatus) {
|
|
411
498
|
for (const listener of this.statusListeners) {
|
|
412
499
|
listener(status);
|
|
@@ -434,6 +521,7 @@ export class ChannelManager {
|
|
|
434
521
|
chatJid: string,
|
|
435
522
|
isGroup: boolean,
|
|
436
523
|
attachments?: InboundMessageAttachment[],
|
|
524
|
+
inboundKey?: WAMessageKey,
|
|
437
525
|
) {
|
|
438
526
|
const channelConfig = this.getChannelConfig(channel);
|
|
439
527
|
if (!channelConfig) return;
|
|
@@ -482,6 +570,7 @@ export class ChannelManager {
|
|
|
482
570
|
existing.messages.push(text);
|
|
483
571
|
if (attachments?.length) existing.attachments.push(...attachments);
|
|
484
572
|
existing.senderName = senderName || existing.senderName;
|
|
573
|
+
if (inboundKey) existing.inboundKey = inboundKey; // track the freshest message for reactions
|
|
485
574
|
existing.timer = setTimeout(() => this.flushDebounce(debounceKey), DEBOUNCE_MS);
|
|
486
575
|
log.info(`[channels] Debounce: buffered message ${existing.messages.length} from ${sender} in ${chatJid}`);
|
|
487
576
|
return;
|
|
@@ -498,6 +587,7 @@ export class ChannelManager {
|
|
|
498
587
|
isSelfChat,
|
|
499
588
|
chatJid,
|
|
500
589
|
isGroup,
|
|
590
|
+
inboundKey,
|
|
501
591
|
timer: setTimeout(() => this.flushDebounce(debounceKey), DEBOUNCE_MS),
|
|
502
592
|
};
|
|
503
593
|
this.debounceBuffers.set(debounceKey, entry);
|
|
@@ -509,7 +599,7 @@ export class ChannelManager {
|
|
|
509
599
|
if (!entry) return;
|
|
510
600
|
this.debounceBuffers.delete(key);
|
|
511
601
|
|
|
512
|
-
const { channel, sender, senderName, fromMe, isSelfChat, chatJid, isGroup, messages, attachments } = entry;
|
|
602
|
+
const { channel, sender, senderName, fromMe, isSelfChat, chatJid, isGroup, messages, attachments, inboundKey } = entry;
|
|
513
603
|
const combinedText = messages.join('\n');
|
|
514
604
|
|
|
515
605
|
const channelConfig = this.getChannelConfig(channel);
|
|
@@ -531,6 +621,8 @@ export class ChannelManager {
|
|
|
531
621
|
text: combinedText,
|
|
532
622
|
rawSender: chatJid,
|
|
533
623
|
attachments: attachments.length > 0 ? attachments : undefined,
|
|
624
|
+
inboundKey,
|
|
625
|
+
isGroup,
|
|
534
626
|
};
|
|
535
627
|
|
|
536
628
|
const modeLabel = mode === 'channel' ? 'Channel mode | self-chat'
|
|
@@ -589,6 +681,8 @@ export class ChannelManager {
|
|
|
589
681
|
displayText: cleanText,
|
|
590
682
|
rawSender: chatJid,
|
|
591
683
|
attachments: attachments.length > 0 ? attachments : undefined,
|
|
684
|
+
inboundKey,
|
|
685
|
+
isGroup,
|
|
592
686
|
};
|
|
593
687
|
|
|
594
688
|
log.info(`[channels] Assistant mode | triggered in ${isGroup ? 'group ' : 'chat with '}${chatId} | buffer=${buffer.length} msgs | "${cleanText.slice(0, 60)}"`);
|
|
@@ -607,6 +701,8 @@ export class ChannelManager {
|
|
|
607
701
|
text: combinedText,
|
|
608
702
|
rawSender: chatJid,
|
|
609
703
|
attachments: attachments.length > 0 ? attachments : undefined,
|
|
704
|
+
inboundKey,
|
|
705
|
+
isGroup,
|
|
610
706
|
};
|
|
611
707
|
|
|
612
708
|
log.info(`[channels] Business mode | ${message.sender} | role=${role} | "${combinedText.slice(0, 60)}"`);
|
|
@@ -723,9 +819,10 @@ export class ChannelManager {
|
|
|
723
819
|
log.info(`[channels] Starting live conversation for admin: ${convId}`);
|
|
724
820
|
|
|
725
821
|
await startConversation(convId, model, (type, eventData) => {
|
|
726
|
-
// WhatsApp routing —
|
|
727
|
-
//
|
|
728
|
-
|
|
822
|
+
// WhatsApp routing — purely queue-driven, no fallback mirror jid. The
|
|
823
|
+
// routing target carries the destination; whatever surface triggered the
|
|
824
|
+
// turn owns the reply.
|
|
825
|
+
this.routeWaStreamEvent(waState, type, eventData, botName);
|
|
729
826
|
|
|
730
827
|
// Persist the assistant's reply to the conversation's DB
|
|
731
828
|
if (type === 'bot:response' && eventData.content) {
|
|
@@ -747,24 +844,32 @@ export class ChannelManager {
|
|
|
747
844
|
return;
|
|
748
845
|
}
|
|
749
846
|
|
|
750
|
-
//
|
|
751
|
-
|
|
847
|
+
// Live conversation ended — drop any pending routes so a future
|
|
848
|
+
// conversation under the same convId starts clean.
|
|
849
|
+
if (type === 'bot:conversation-ended') {
|
|
850
|
+
this.clearRoutes(convId);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
752
853
|
|
|
753
854
|
// Mirror streaming + task events to chat clients
|
|
754
855
|
broadcastBloby(type, eventData);
|
|
755
856
|
}, { botName, humanName }, recentMessages);
|
|
756
857
|
}
|
|
757
858
|
|
|
758
|
-
//
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
rawSender: msg.rawSender,
|
|
762
|
-
assistantBufferKey: msg.role === 'assistant' ? `${msg.channel}:${msg.sender}` : undefined,
|
|
763
|
-
});
|
|
764
|
-
|
|
765
|
-
// Push the message into the live conversation
|
|
859
|
+
// Push into the live conversation with a pinned WhatsApp routing target.
|
|
860
|
+
// The agent's reply for THIS specific input will go to msg.rawSender — no other
|
|
861
|
+
// surface can hijack it via the FIFO ordering of the shared conversation.
|
|
766
862
|
const channelContent = channelContext + msg.text;
|
|
767
|
-
|
|
863
|
+
const target: RoutingTarget = {
|
|
864
|
+
surface: 'whatsapp',
|
|
865
|
+
waSendTo: msg.rawSender,
|
|
866
|
+
isGroup: msg.isGroup,
|
|
867
|
+
// Self-chat in 1:1: don't prefix "🤖 Bot:" — it's the user's own chat with themselves.
|
|
868
|
+
isSelfChat: msg.role === 'admin' && !msg.isGroup,
|
|
869
|
+
assistantBufferKey: msg.role === 'assistant' ? `${msg.channel}:${msg.sender}` : undefined,
|
|
870
|
+
inboundKey: msg.inboundKey,
|
|
871
|
+
};
|
|
872
|
+
this.pushWithRouting(convId, target, channelContent, agentAttachments, savedFiles);
|
|
768
873
|
}
|
|
769
874
|
|
|
770
875
|
/** Handle message from a customer — runs support agent in parallel with conversation context */
|
|
@@ -39,6 +39,10 @@ export interface InboundMessage {
|
|
|
39
39
|
rawSender: string;
|
|
40
40
|
/** Image attachments */
|
|
41
41
|
attachments?: InboundMessageAttachment[];
|
|
42
|
+
/** Original channel-native message key (Baileys WAMessageKey for WhatsApp). Opaque here. */
|
|
43
|
+
inboundKey?: unknown;
|
|
44
|
+
/** True when the chat is a group (for assistant-mode group routing). */
|
|
45
|
+
isGroup?: boolean;
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
export interface OutboundMessage {
|
|
@@ -54,6 +58,34 @@ export interface ChannelStatus {
|
|
|
54
58
|
info?: Record<string, any>;
|
|
55
59
|
}
|
|
56
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Per-turn routing target.
|
|
63
|
+
*
|
|
64
|
+
* Every user message pushed into a live conversation carries one of these so the
|
|
65
|
+
* agent's response goes back to the surface that triggered it — no FIFO ambiguity,
|
|
66
|
+
* no channel-bleed across concurrent turns.
|
|
67
|
+
*
|
|
68
|
+
* Created by the surface that pushes the user message (chat-WS or channel handler),
|
|
69
|
+
* consumed by the manager when the corresponding `bot:response` fires, discarded if
|
|
70
|
+
* the turn ends without a response (error / empty turn).
|
|
71
|
+
*/
|
|
72
|
+
export interface RoutingTarget {
|
|
73
|
+
/** Which surface triggered this turn. Drives whether the WA reply carries a "🤖 Bot:" prefix. */
|
|
74
|
+
surface: 'chat' | 'whatsapp';
|
|
75
|
+
/** WhatsApp JID to deliver the reply to.
|
|
76
|
+
* - 'whatsapp' surface → the originating chat JID (group or peer).
|
|
77
|
+
* - 'chat' surface → optionally the user's own number (self-chat mirror), or undefined.
|
|
78
|
+
* When undefined, no WhatsApp send happens — the reply only reaches the dashboard via broadcast.
|
|
79
|
+
*/
|
|
80
|
+
waSendTo?: string;
|
|
81
|
+
isGroup?: boolean;
|
|
82
|
+
isSelfChat?: boolean;
|
|
83
|
+
/** When set, the assistant's reply is appended to this customer buffer (assistant-mode context). */
|
|
84
|
+
assistantBufferKey?: string;
|
|
85
|
+
/** Original inbound WA message key — kept opaque here, used by the channel to react/quote. */
|
|
86
|
+
inboundKey?: unknown;
|
|
87
|
+
}
|
|
88
|
+
|
|
57
89
|
export interface ChannelProvider {
|
|
58
90
|
readonly type: ChannelType;
|
|
59
91
|
/** Start the channel connection (may trigger QR flow) */
|
|
@@ -12,6 +12,7 @@ import makeWASocket, {
|
|
|
12
12
|
Browsers,
|
|
13
13
|
type WASocket,
|
|
14
14
|
type BaileysEventMap,
|
|
15
|
+
type WAMessageKey,
|
|
15
16
|
} from '@whiskeysockets/baileys';
|
|
16
17
|
import fs from 'fs';
|
|
17
18
|
import path from 'path';
|
|
@@ -33,6 +34,7 @@ export interface WhatsAppImageAttachment {
|
|
|
33
34
|
* - sender: who sent it (phone JID, translated from LID where possible)
|
|
34
35
|
* - chatJid: the conversation identifier (group JID for groups, peer JID for 1:1) — reply to this
|
|
35
36
|
* - isGroup: true when the chat is a WhatsApp group (@g.us)
|
|
37
|
+
* - inboundKey: original Baileys message key — used to react/quote/ack the user's message
|
|
36
38
|
*/
|
|
37
39
|
export type OnWhatsAppMessage = (
|
|
38
40
|
sender: string,
|
|
@@ -43,6 +45,7 @@ export type OnWhatsAppMessage = (
|
|
|
43
45
|
chatJid: string,
|
|
44
46
|
isGroup: boolean,
|
|
45
47
|
images?: WhatsAppImageAttachment[],
|
|
48
|
+
inboundKey?: WAMessageKey,
|
|
46
49
|
) => void;
|
|
47
50
|
|
|
48
51
|
/** Callback to transcribe audio via whisper */
|
|
@@ -181,6 +184,25 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
181
184
|
log.info(`[whatsapp] Sent video to ${jid} (id=${result?.key?.id || 'unknown'})`);
|
|
182
185
|
}
|
|
183
186
|
|
|
187
|
+
/** Send an emoji reaction onto an existing message.
|
|
188
|
+
* Pass an empty string to remove a previously-sent reaction (Baileys convention). */
|
|
189
|
+
async sendReaction(chatJid: string, key: WAMessageKey, emoji: string): Promise<void> {
|
|
190
|
+
if (!this.sock || !this.connected) {
|
|
191
|
+
log.warn('[whatsapp] Cannot react — not connected');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (!key || !key.id) {
|
|
195
|
+
log.warn('[whatsapp] Cannot react — missing message key');
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
await this.sock.sendMessage(chatJid, { react: { text: emoji, key } });
|
|
200
|
+
log.info(`[whatsapp] Reacted "${emoji}" to ${key.id} in ${chatJid}`);
|
|
201
|
+
} catch (err: any) {
|
|
202
|
+
log.warn(`[whatsapp] Reaction failed: ${err.message}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
184
206
|
/** Send a document (PDF, zip, etc.) via WhatsApp */
|
|
185
207
|
async sendDocument(to: string, document: Buffer, fileName: string, mimetype?: string, caption?: string): Promise<void> {
|
|
186
208
|
if (!this.sock || !this.connected) {
|
|
@@ -286,15 +308,28 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
286
308
|
|
|
287
309
|
// ── Internal ──
|
|
288
310
|
|
|
289
|
-
/** Translate a JID from LID format to phone format if possible
|
|
290
|
-
|
|
311
|
+
/** Translate a JID from LID format to phone format if possible.
|
|
312
|
+
* Falls back to the supplied `alt` (Baileys 7+ `key.participantAlt`/`remoteJidAlt`) which
|
|
313
|
+
* carries the phone form when the primary identifier is a LID. */
|
|
314
|
+
private translateJid(jid: string, alt?: string | null): string {
|
|
291
315
|
// If it's already a phone JID, return as-is
|
|
292
316
|
if (jid.endsWith('@s.whatsapp.net')) return jid;
|
|
293
317
|
|
|
294
|
-
// Check LID map (
|
|
318
|
+
// Check learned LID map first (covers our own LID + any pairs we've seen)
|
|
295
319
|
const mapped = this.lidToPhoneMap.get(jid);
|
|
296
320
|
if (mapped) return mapped;
|
|
297
321
|
|
|
322
|
+
// Baileys 7 ships the alternate identifier on the message key. If the primary
|
|
323
|
+
// is a LID, the alt is the phone-number form — adopt it and learn the mapping
|
|
324
|
+
// for future messages on this conversation.
|
|
325
|
+
if (alt && alt.endsWith('@s.whatsapp.net')) {
|
|
326
|
+
this.lidToPhoneMap.set(jid, alt);
|
|
327
|
+
// Also map the bare LID number → phone, so different LID encodings collapse
|
|
328
|
+
const lidNum = jid.split(':')[0].split('@')[0];
|
|
329
|
+
if (lidNum) this.lidToPhoneMap.set(`${lidNum}@lid`, alt);
|
|
330
|
+
return alt;
|
|
331
|
+
}
|
|
332
|
+
|
|
298
333
|
// Unknown LID — don't guess. Return as-is so isSelfChat stays false.
|
|
299
334
|
return jid;
|
|
300
335
|
}
|
|
@@ -499,8 +534,16 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
499
534
|
const participant = msg.key.participant || '';
|
|
500
535
|
const isGroup = rawSender.endsWith('@g.us');
|
|
501
536
|
|
|
537
|
+
// Baileys 7 exposes the alternate identifier on the key — when the primary is a LID,
|
|
538
|
+
// the alt is the phone-number form (and vice versa). Use these to translate cleanly.
|
|
539
|
+
const remoteJidAlt = (msg.key as any).remoteJidAlt as string | undefined;
|
|
540
|
+
const participantAlt = (msg.key as any).participantAlt as string | undefined;
|
|
541
|
+
|
|
502
542
|
// chatJid: where to reply (group JID for groups, peer JID otherwise).
|
|
503
|
-
|
|
543
|
+
// For peer chats, prefer the phone-form JID so reactions/replies hit the canonical chat.
|
|
544
|
+
const chatJid = isGroup
|
|
545
|
+
? rawSender
|
|
546
|
+
: this.translateJid(rawSender, remoteJidAlt);
|
|
504
547
|
|
|
505
548
|
// The actual sender JID:
|
|
506
549
|
// - groups: always `participant` (remoteJid is the group)
|
|
@@ -508,17 +551,34 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
508
551
|
const actualSender = isGroup
|
|
509
552
|
? participant || rawSender
|
|
510
553
|
: (participant || rawSender);
|
|
554
|
+
const senderAlt = isGroup ? participantAlt : (participantAlt || remoteJidAlt);
|
|
511
555
|
|
|
512
|
-
// Translate LID
|
|
513
|
-
const sender = this.translateJid(actualSender);
|
|
556
|
+
// Translate LID → phone via the learned map + the message's `*Alt` fallback.
|
|
557
|
+
const sender = this.translateJid(actualSender, senderAlt);
|
|
514
558
|
const pushName = msg.pushName || undefined;
|
|
515
559
|
|
|
516
|
-
// Self-chat: only meaningful for 1:1
|
|
517
|
-
|
|
560
|
+
// Self-chat: only meaningful for 1:1. True when the (translated) chat JID is our own number
|
|
561
|
+
// AND no group participant. Both `participant` and `participantAlt` must be absent — Baileys
|
|
562
|
+
// sets `participant` on newer 1:1 messages too, so we additionally accept when the participant
|
|
563
|
+
// (or its alt) resolves to our own phone.
|
|
564
|
+
const participantResolved = participant ? this.translateJid(participant, senderAlt) : '';
|
|
565
|
+
const ownsChat = this.ownPhoneJid !== null && chatJid === this.ownPhoneJid;
|
|
566
|
+
const ownsParticipant = !participant || participantResolved === this.ownPhoneJid;
|
|
567
|
+
const isSelfChat = !isGroup && ownsChat && ownsParticipant;
|
|
518
568
|
|
|
519
569
|
log.info(`[whatsapp] Message from ${sender} (chat=${chatJid}, group=${isGroup}, fromMe=${fromMe}, selfChat=${isSelfChat}, images=${images.length}): ${text.slice(0, 80)}`);
|
|
520
570
|
|
|
521
|
-
this.onMessage(
|
|
571
|
+
this.onMessage(
|
|
572
|
+
sender,
|
|
573
|
+
pushName,
|
|
574
|
+
text,
|
|
575
|
+
fromMe,
|
|
576
|
+
isSelfChat,
|
|
577
|
+
chatJid,
|
|
578
|
+
isGroup,
|
|
579
|
+
images.length > 0 ? images : undefined,
|
|
580
|
+
msg.key,
|
|
581
|
+
);
|
|
522
582
|
}
|
|
523
583
|
});
|
|
524
584
|
}
|
|
@@ -81,14 +81,14 @@ export function useBlobyChat(ws: WsClient | null, triggerReload?: number, enable
|
|
|
81
81
|
};
|
|
82
82
|
}, []);
|
|
83
83
|
|
|
84
|
-
// Load current conversation from DB (last
|
|
84
|
+
// Load current conversation from DB (last 200 messages — pagination kicks in beyond that)
|
|
85
85
|
const loadFromDb = useCallback(async () => {
|
|
86
86
|
try {
|
|
87
87
|
const ctx = await authFetch('/api/context/current').then((r) => r.json());
|
|
88
88
|
if (!ctx.conversationId) return;
|
|
89
89
|
setConversationId(ctx.conversationId);
|
|
90
90
|
|
|
91
|
-
const limit =
|
|
91
|
+
const limit = 200;
|
|
92
92
|
const res = await authFetch(`/api/conversations/${ctx.conversationId}/messages?limit=${limit}`);
|
|
93
93
|
if (!res.ok) return;
|
|
94
94
|
const data = await res.json();
|
|
@@ -100,14 +100,14 @@ export function useBlobyChat(ws: WsClient | null, triggerReload?: number, enable
|
|
|
100
100
|
} catch { /* worker not ready yet */ }
|
|
101
101
|
}, [parseMessage]);
|
|
102
102
|
|
|
103
|
-
// Load older messages (cursor-based pagination)
|
|
103
|
+
// Load older messages (cursor-based pagination — cursor is the rowid-equivalent on the server)
|
|
104
104
|
const loadOlder = useCallback(async () => {
|
|
105
105
|
if (loadingOlder.current || !conversationIdRef.current) return;
|
|
106
106
|
loadingOlder.current = true;
|
|
107
107
|
try {
|
|
108
108
|
const oldestId = messages[0]?.id;
|
|
109
109
|
if (!oldestId) return;
|
|
110
|
-
const limit =
|
|
110
|
+
const limit = 100;
|
|
111
111
|
const res = await authFetch(`/api/conversations/${conversationIdRef.current}/messages?before=${oldestId}&limit=${limit}`);
|
|
112
112
|
if (!res.ok) return;
|
|
113
113
|
const data = await res.json();
|
package/supervisor/index.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { spawnBackend, stopBackend, getBackendPort, isBackendAlive, isBackendSto
|
|
|
15
15
|
import { handleAgentQuery, type AgentQueryRequest } from './agent-api.js';
|
|
16
16
|
import { updateTunnelUrl, startHeartbeat, stopHeartbeat, disconnect } from '../shared/relay.js';
|
|
17
17
|
import {
|
|
18
|
-
startConversation,
|
|
18
|
+
startConversation, hasConversation, endConversation, endAllConversations,
|
|
19
19
|
isConversationBusy, stopSubAgentTask,
|
|
20
20
|
startBlobyAgentQuery, stopBlobyAgentQuery,
|
|
21
21
|
warmUpForLiveConversation,
|
|
@@ -365,6 +365,7 @@ export async function startSupervisor() {
|
|
|
365
365
|
'POST /api/channels/whatsapp/logout',
|
|
366
366
|
'POST /api/channels/whatsapp/configure',
|
|
367
367
|
'POST /api/channels/whatsapp/pairing-code',
|
|
368
|
+
'POST /api/channels/whatsapp/react',
|
|
368
369
|
'POST /api/channels/send',
|
|
369
370
|
];
|
|
370
371
|
|
|
@@ -735,6 +736,37 @@ ${!connected ? `<script>
|
|
|
735
736
|
return;
|
|
736
737
|
}
|
|
737
738
|
|
|
739
|
+
// POST /api/channels/whatsapp/react — send (or remove) an emoji reaction on a WhatsApp message
|
|
740
|
+
if (req.method === 'POST' && channelPath === '/api/channels/whatsapp/react') {
|
|
741
|
+
let body = '';
|
|
742
|
+
req.on('data', (chunk: Buffer) => { body += chunk.toString(); });
|
|
743
|
+
req.on('end', async () => {
|
|
744
|
+
try {
|
|
745
|
+
const { chatJid, messageId, fromMe, participant, emoji } = JSON.parse(body) as {
|
|
746
|
+
chatJid?: string;
|
|
747
|
+
messageId?: string;
|
|
748
|
+
fromMe?: boolean;
|
|
749
|
+
participant?: string;
|
|
750
|
+
emoji?: string;
|
|
751
|
+
};
|
|
752
|
+
if (!chatJid || !messageId) {
|
|
753
|
+
res.writeHead(400);
|
|
754
|
+
res.end(JSON.stringify({ error: 'chatJid and messageId are required' }));
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
// emoji='' is intentionally supported — it removes a previous reaction (Baileys convention).
|
|
758
|
+
const key = { remoteJid: chatJid, id: messageId, fromMe: !!fromMe, participant } as any;
|
|
759
|
+
await channelManager.reactToMessage('whatsapp', chatJid, key, emoji ?? '👍');
|
|
760
|
+
res.writeHead(200);
|
|
761
|
+
res.end(JSON.stringify({ ok: true }));
|
|
762
|
+
} catch (err: any) {
|
|
763
|
+
res.writeHead(500);
|
|
764
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
|
|
738
770
|
// POST /api/channels/send — send a message (and/or media) via any channel
|
|
739
771
|
if (req.method === 'POST' && channelPath === '/api/channels/send') {
|
|
740
772
|
let body = '';
|
|
@@ -1295,7 +1327,11 @@ ${!connected ? `<script>
|
|
|
1295
1327
|
const data = msg.data || {};
|
|
1296
1328
|
const content = data.content;
|
|
1297
1329
|
if (!content) return;
|
|
1298
|
-
|
|
1330
|
+
// Note: we intentionally ignore data.conversationId from the client.
|
|
1331
|
+
// The server is the authority on which DB conversation this WS belongs to —
|
|
1332
|
+
// honoring a client-supplied id let stale browser state drive messages into
|
|
1333
|
+
// an orphan conv whose row had been deleted, causing FK failures on every
|
|
1334
|
+
// INSERT. Server resolution below (clientConvs → context.current → create).
|
|
1299
1335
|
|
|
1300
1336
|
// Re-read config on each message so post-onboard changes are picked up
|
|
1301
1337
|
const freshConfig = loadConfig();
|
|
@@ -1364,6 +1400,10 @@ ${!connected ? `<script>
|
|
|
1364
1400
|
});
|
|
1365
1401
|
} catch (err: any) {
|
|
1366
1402
|
log.warn(`[bloby] DB persist error: ${err.message}`);
|
|
1403
|
+
// Surface to all clients so they can flag the missing user bubble
|
|
1404
|
+
// instead of pretending it's saved. addMessage() in worker/db.ts is
|
|
1405
|
+
// self-healing for orphan convIds, so this should now be rare.
|
|
1406
|
+
broadcastBloby('chat:persist-error', { conversationId: convId, role: 'user', error: err.message });
|
|
1367
1407
|
}
|
|
1368
1408
|
|
|
1369
1409
|
// Fetch agent/user names and recent messages in parallel
|
|
@@ -1406,7 +1446,7 @@ ${!connected ? `<script>
|
|
|
1406
1446
|
// the self-chat mirror (the user's own number).
|
|
1407
1447
|
const waState = channelManager.createWaStreamState();
|
|
1408
1448
|
|
|
1409
|
-
await startConversation(convId, freshConfig.ai.model, (type, eventData) => {
|
|
1449
|
+
await startConversation(convId, freshConfig.ai.model, async (type, eventData) => {
|
|
1410
1450
|
// Track stream buffer for reconnecting clients
|
|
1411
1451
|
if (type === 'bot:typing') {
|
|
1412
1452
|
currentStreamConvId = convId;
|
|
@@ -1418,11 +1458,10 @@ ${!connected ? `<script>
|
|
|
1418
1458
|
currentStreamBuffer += eventData.token;
|
|
1419
1459
|
}
|
|
1420
1460
|
|
|
1421
|
-
// Route streaming text
|
|
1422
|
-
//
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
channelManager.routeWaStreamEvent(waState, type, eventData, waMirrorJid ?? null, botName);
|
|
1461
|
+
// Route streaming text via the manager's per-conversation routing FIFO.
|
|
1462
|
+
// The destination was decided at pushWithRouting time — this is purely a
|
|
1463
|
+
// dispatcher and cannot bleed events to the wrong surface.
|
|
1464
|
+
channelManager.routeWaStreamEvent(waState, type, eventData, botName);
|
|
1426
1465
|
|
|
1427
1466
|
// Agent finished a turn — handle backend restart + notify client
|
|
1428
1467
|
if (type === 'bot:turn-complete') {
|
|
@@ -1458,22 +1497,27 @@ ${!connected ? `<script>
|
|
|
1458
1497
|
agentQueryActive = false;
|
|
1459
1498
|
currentStreamConvId = null;
|
|
1460
1499
|
currentStreamBuffer = '';
|
|
1500
|
+
channelManager.clearRoutes(convId);
|
|
1461
1501
|
return;
|
|
1462
1502
|
}
|
|
1463
1503
|
|
|
1464
|
-
// Save assistant response to DB
|
|
1504
|
+
// Save assistant response to DB BEFORE broadcasting so a refresh
|
|
1505
|
+
// immediately after the bubble appears can't race the INSERT and lose
|
|
1506
|
+
// the message. addMessage() in worker/db.ts is self-healing —
|
|
1507
|
+
// it INSERT OR IGNOREs the parent conversation row first, so even an
|
|
1508
|
+
// orphan convId persists cleanly.
|
|
1465
1509
|
if (type === 'bot:response') {
|
|
1466
1510
|
currentStreamBuffer = '';
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
}
|
|
1476
|
-
}
|
|
1511
|
+
try {
|
|
1512
|
+
await workerApi(`/api/conversations/${convId}/messages`, 'POST', {
|
|
1513
|
+
role: 'assistant', content: eventData.content, meta: { model: freshConfig.ai.model },
|
|
1514
|
+
});
|
|
1515
|
+
} catch (err: any) {
|
|
1516
|
+
log.warn(`[bloby] DB persist bot response error: ${err.message}`);
|
|
1517
|
+
// Tell clients the bubble they're about to see is not durable —
|
|
1518
|
+
// they can flag/retry rather than silently losing it on refresh.
|
|
1519
|
+
broadcastBloby('chat:persist-error', { conversationId: convId, role: 'assistant', error: err.message });
|
|
1520
|
+
}
|
|
1477
1521
|
}
|
|
1478
1522
|
|
|
1479
1523
|
// Stream all events to every connected client
|
|
@@ -1481,9 +1525,21 @@ ${!connected ? `<script>
|
|
|
1481
1525
|
}, { botName, humanName }, recentMessages);
|
|
1482
1526
|
}
|
|
1483
1527
|
|
|
1484
|
-
// Push the user message into the live conversation
|
|
1485
|
-
|
|
1486
|
-
|
|
1528
|
+
// Push the user message into the live conversation with a pinned routing
|
|
1529
|
+
// target. Chat-bubble responses are broadcast to all WS clients regardless;
|
|
1530
|
+
// the WhatsApp self-chat mirror (if connected) is the optional secondary
|
|
1531
|
+
// destination, baked in at push time so it cannot drift to a different chat.
|
|
1532
|
+
const waStatus = channelManager.getStatus('whatsapp');
|
|
1533
|
+
const ownPhone = waStatus?.connected ? (waStatus.info?.phoneNumber as string | undefined) : undefined;
|
|
1534
|
+
const waMirrorTo = ownPhone ? `${ownPhone}@s.whatsapp.net` : undefined;
|
|
1535
|
+
log.info(`[orchestrator] Pushing message into live conversation (waMirror=${waMirrorTo || 'none'})`);
|
|
1536
|
+
channelManager.pushWithRouting(
|
|
1537
|
+
convId,
|
|
1538
|
+
{ surface: 'chat', waSendTo: waMirrorTo, isSelfChat: true },
|
|
1539
|
+
content,
|
|
1540
|
+
data.attachments,
|
|
1541
|
+
savedFiles,
|
|
1542
|
+
);
|
|
1487
1543
|
})();
|
|
1488
1544
|
return;
|
|
1489
1545
|
}
|
package/worker/db.ts
CHANGED
|
@@ -93,10 +93,18 @@ export function deleteConversation(id: string) {
|
|
|
93
93
|
|
|
94
94
|
// Messages
|
|
95
95
|
export function addMessage(convId: string, role: string, content: string, meta?: { tokens_in?: number; tokens_out?: number; model?: string; audio_data?: string; attachments?: string }) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
96
|
+
// Self-heal: if the conversation row is missing (orphan live convId, harness session
|
|
97
|
+
// drift, deleted parent, etc.), create it so the FK constraint never fires.
|
|
98
|
+
// Use the first user message as title; assistant-first stays NULL (filled by UI).
|
|
99
|
+
const tx = db.transaction(() => {
|
|
100
|
+
db.prepare('INSERT OR IGNORE INTO conversations (id, title, model) VALUES (?, ?, ?)')
|
|
101
|
+
.run(convId, role === 'user' ? content.slice(0, 80) : null, meta?.model ?? null);
|
|
102
|
+
const msg = db.prepare('INSERT INTO messages (conversation_id, role, content, tokens_in, tokens_out, model, audio_data, attachments) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING *')
|
|
103
|
+
.get(convId, role, content, meta?.tokens_in ?? null, meta?.tokens_out ?? null, meta?.model ?? null, meta?.audio_data ?? null, meta?.attachments ?? null);
|
|
104
|
+
db.prepare('UPDATE conversations SET updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(convId);
|
|
105
|
+
return msg;
|
|
106
|
+
});
|
|
107
|
+
return tx() as any;
|
|
100
108
|
}
|
|
101
109
|
export function getMessages(convId: string) {
|
|
102
110
|
return db.prepare('SELECT * FROM messages WHERE conversation_id = ? ORDER BY created_at ASC').all(convId);
|
|
@@ -177,22 +185,29 @@ export function deleteAllTrustedDevices() {
|
|
|
177
185
|
db.prepare('DELETE FROM trusted_devices').run();
|
|
178
186
|
}
|
|
179
187
|
|
|
180
|
-
// Recent messages (for context injection)
|
|
188
|
+
// Recent messages (for context injection).
|
|
189
|
+
// Order by rowid (monotonic insertion order) — created_at has 1-second resolution
|
|
190
|
+
// so rapid-fire messages can collide. rowid never does.
|
|
191
|
+
// rowid is a hidden column, so `SELECT *` omits it — we must alias it explicitly
|
|
192
|
+
// in the inner query for the outer ORDER BY to reach it.
|
|
181
193
|
export function getRecentMessages(convId: string, limit = 20) {
|
|
182
194
|
return db.prepare(`
|
|
183
195
|
SELECT * FROM (
|
|
184
|
-
SELECT
|
|
185
|
-
|
|
196
|
+
SELECT messages.*, messages.rowid AS _rid FROM messages
|
|
197
|
+
WHERE conversation_id = ? ORDER BY messages.rowid DESC LIMIT ?
|
|
198
|
+
) sub ORDER BY _rid ASC
|
|
186
199
|
`).all(convId, limit);
|
|
187
200
|
}
|
|
188
201
|
|
|
189
|
-
// Cursor-based pagination: messages before a given
|
|
202
|
+
// Cursor-based pagination: messages before a given message id.
|
|
203
|
+
// Use rowid for comparison — message.id is random hex so `id < ?` is meaningless.
|
|
190
204
|
export function getMessagesBefore(convId: string, beforeId: string, limit = 20) {
|
|
191
205
|
return db.prepare(`
|
|
192
206
|
SELECT * FROM (
|
|
193
|
-
SELECT
|
|
194
|
-
WHERE conversation_id = ?
|
|
195
|
-
|
|
196
|
-
|
|
207
|
+
SELECT messages.*, messages.rowid AS _rid FROM messages
|
|
208
|
+
WHERE conversation_id = ?
|
|
209
|
+
AND messages.rowid < (SELECT rowid FROM messages WHERE id = ?)
|
|
210
|
+
ORDER BY messages.rowid DESC LIMIT ?
|
|
211
|
+
) sub ORDER BY _rid ASC
|
|
197
212
|
`).all(convId, beforeId, limit);
|
|
198
213
|
}
|
package/worker/index.ts
CHANGED
|
@@ -133,12 +133,12 @@ app.post('/api/conversations/:id/messages', (req, res) => {
|
|
|
133
133
|
});
|
|
134
134
|
app.get('/api/conversations/:id/messages/recent', (req, res) => {
|
|
135
135
|
const limit = parseInt(req.query.limit as string) || 20;
|
|
136
|
-
const msgs = getRecentMessages(req.params.id, Math.min(limit,
|
|
136
|
+
const msgs = getRecentMessages(req.params.id, Math.min(limit, 1000));
|
|
137
137
|
res.json(msgs);
|
|
138
138
|
});
|
|
139
139
|
app.get('/api/conversations/:id/messages', (req, res) => {
|
|
140
140
|
const before = req.query.before as string;
|
|
141
|
-
const limit = Math.min(parseInt(req.query.limit as string) || 20,
|
|
141
|
+
const limit = Math.min(parseInt(req.query.limit as string) || 20, 1000);
|
|
142
142
|
if (before) {
|
|
143
143
|
res.json(getMessagesBefore(req.params.id, before, limit));
|
|
144
144
|
} else {
|
|
@@ -278,6 +278,8 @@ If your human asks you to update a skill's behavior, edit the files INSIDE `skil
|
|
|
278
278
|
|
|
279
279
|
You can communicate through messaging channels beyond the chat bubble. Channel support is provided by **skills** — if your human wants to use WhatsApp, Telegram, Discord, or any other channel, check the Bloby Marketplace for the corresponding skill. They install it, the skill teaches you everything you need to know about that channel.
|
|
280
280
|
|
|
281
|
+
**Channel discipline.** Every incoming message is tagged with a surface (e.g. `[WhatsApp | ... | role | name]`) — that tag is the truth about who you're talking to and where your reply will go. The supervisor pins each turn's reply to the surface that triggered it; concurrent inbounds from another channel cannot redirect this turn. **Don't infer the channel from prior messages, conversation drift, or what feels right** — read the tag on the current turn and respond accordingly. Chat-bubble content does not belong in a WhatsApp reply, and WhatsApp-specific context (group dynamics, customer back-and-forth, etc.) does not belong in a chat-bubble reply. If a tag isn't present, you're on the chat bubble. If you ever feel the urge to mention a different channel's content in your reply, stop and re-check the tag.
|
|
282
|
+
|
|
281
283
|
## Marketplace — Getting New Skills
|
|
282
284
|
|
|
283
285
|
Before building a skill from scratch, **always check the Bloby Marketplace first**:
|
|
@@ -18,6 +18,12 @@ None.
|
|
|
18
18
|
|
|
19
19
|
**Adjust your style for WhatsApp:** Keep messages shorter and more conversational than chat. No markdown headers, no code blocks unless asked. Think texting, not email.
|
|
20
20
|
|
|
21
|
+
### Routing is supervisor-enforced — you can't accidentally cross channels
|
|
22
|
+
|
|
23
|
+
Each incoming user message is tagged with a routing target at the moment it enters your conversation. The supervisor pins the agent's reply to whatever surface triggered it — chat-bubble messages land in chat, WhatsApp messages reply to the originating WhatsApp chat (group or 1:1). Concurrent inbounds from different surfaces during the same turn cannot leak into each other's replies. **You don't need to remember which channel you're talking on — the channel context tag (`[WhatsApp | ... | role | name]`) is the truth, and the supervisor handles delivery.**
|
|
24
|
+
|
|
25
|
+
If you ever see content in your context that doesn't match the surface you think you're on, trust the tag, not your assumption. Reply to what the tag says.
|
|
26
|
+
|
|
21
27
|
---
|
|
22
28
|
|
|
23
29
|
## How Messages Arrive
|
|
@@ -200,6 +206,26 @@ Up to 5 customer conversations can run in parallel. Additional messages queue au
|
|
|
200
206
|
|
|
201
207
|
---
|
|
202
208
|
|
|
209
|
+
## Reactions (emoji ack)
|
|
210
|
+
|
|
211
|
+
Use a reaction when you want to acknowledge a message without sending a full text reply — perfect for "I saw this, working on it" or quick approvals/disapprovals. Reactions are first-class in WhatsApp and don't clutter the chat.
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
curl -s -X POST http://localhost:7400/api/channels/whatsapp/react \
|
|
215
|
+
-H "Content-Type: application/json" \
|
|
216
|
+
-d '{"chatJid":"5511999888777@s.whatsapp.net","messageId":"3EB0...","fromMe":false,"emoji":"👀"}'
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
- `chatJid` — the WhatsApp JID where the message lives (group JID for groups, peer JID for 1:1). Include the suffix (`@s.whatsapp.net` or `@g.us`).
|
|
220
|
+
- `messageId` — the Baileys message id (the supervisor logs this on every inbound: look for `id=...` in the channel logs).
|
|
221
|
+
- `fromMe` — true if the message you're reacting to was sent by your number, false otherwise. Defaults to false.
|
|
222
|
+
- `participant` — optional; for group messages where you need to reference the original sender's JID.
|
|
223
|
+
- `emoji` — any single emoji. Pass `""` (empty string) to remove a previous reaction.
|
|
224
|
+
|
|
225
|
+
**When to react vs reply:** react for quick "saw it / on it / done" signals during long-running work; reply with text when there's something meaningful to say. Don't react AND reply with the same content — pick one.
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
203
229
|
## Account Management
|
|
204
230
|
|
|
205
231
|
**Disconnect** (keep credentials for later):
|
|
@@ -237,6 +263,7 @@ curl -s -X POST http://localhost:7400/api/channels/whatsapp/logout
|
|
|
237
263
|
| `/api/channels/whatsapp/logout` | POST | Disconnect + delete credentials |
|
|
238
264
|
| `/api/channels/whatsapp/configure` | POST | Set mode + admins + skill |
|
|
239
265
|
| `/api/channels/whatsapp/pairing-code` | POST | Get 8-char pairing code (mobile linking) |
|
|
266
|
+
| `/api/channels/whatsapp/react` | POST | Send/remove an emoji reaction on a message |
|
|
240
267
|
| `/api/channels/send` | POST | Send proactive message via channel |
|
|
241
268
|
|
|
242
269
|
All endpoints use `http://localhost:7400` for internal API calls (curl from your terminal). For URLs shown to your human, use relative paths (e.g. `/api/channels/whatsapp/qr-page`) — their browser is already on the correct domain.
|