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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bloby-bot",
3
- "version": "0.46.1",
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-rc.9",
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",
@@ -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
- /** FIFO queue of reply targets — one per pushMessage, consumed on each bot:response */
95
- private waReplyQueue: { channel: ChannelType; rawSender: string; assistantBufferKey?: string }[] = [];
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
- * Decision rule:
344
- * - If the manager has a queued reply target (set by `handleAdminMessage` when
345
- * a WhatsApp inbound was the trigger) send to that target.
346
- * - Else if `fallbackMirrorJid` is provided (chat-UI initiated) mirror to that JID
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
- * This method also updates the assistant context buffer when applicable.
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
- const fireSend = (jid: string, text: string, prefix: boolean) => {
368
- const body = prefix ? this.formatBotReply(text, botName) : text;
369
- this.sendMessage('whatsapp', jid, body).catch((err) => {
370
- log.warn(`[channels] WA send failed (${jid}): ${err.message}`);
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.waReplyQueue.shift(); // consume the reply target for this turn
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 subsequent
399
- // triggers in that chat see it as conversation history.
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 — uses the manager's queue. No fallback mirror jid here:
727
- // when no chat UI is open, we should only send to the chat that triggered us.
728
- this.routeWaStreamEvent(waState, type, eventData, null, botName);
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
- // Don't forward internal events to chat clients
751
- if (type === 'bot:conversation-ended') return;
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
- // Enqueue reply target BEFORE pushing callback consumes in FIFO order
759
- this.waReplyQueue.push({
760
- channel: msg.channel,
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
- pushMessage(convId, channelContent, agentAttachments, savedFiles);
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
- private translateJid(jid: string): string {
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 (only contains our own LID, mapped during connection)
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
- const chatJid = rawSender;
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 JIDs to phone JIDs (only handles our own 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 remoteJid is our own number AND no participant.
517
- const isSelfChat = !isGroup && !participant && this.ownPhoneJid !== null && this.translateJid(rawSender) === this.ownPhoneJid;
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(sender, pushName, text, fromMe, isSelfChat, chatJid, isGroup, images.length > 0 ? images : undefined);
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 20 messages)
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 = 20;
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 = 20;
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();
@@ -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, pushMessage, hasConversation, endConversation, endAllConversations,
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
- if (data.conversationId) convId = data.conversationId;
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 to WhatsApp (if connected). Re-read mirror state
1422
- // each time so reconnections / disconnects are picked up.
1423
- const waStatus = channelManager.getStatus('whatsapp');
1424
- const waMirrorJid = waStatus?.connected ? (waStatus.info?.phoneNumber as string | undefined) : undefined;
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
- (async () => {
1469
- try {
1470
- await workerApi(`/api/conversations/${convId}/messages`, 'POST', {
1471
- role: 'assistant', content: eventData.content, meta: { model: freshConfig.ai.model },
1472
- });
1473
- } catch (err: any) {
1474
- log.warn(`[bloby] DB persist bot response error: ${err.message}`);
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
- log.info(`[orchestrator] Pushing message into live conversation`);
1486
- pushMessage(convId, content, data.attachments, savedFiles);
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
- const msg = db.prepare('INSERT INTO messages (conversation_id, role, content, tokens_in, tokens_out, model, audio_data, attachments) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING *')
97
- .get(convId, role, content, meta?.tokens_in ?? null, meta?.tokens_out ?? null, meta?.model ?? null, meta?.audio_data ?? null, meta?.attachments ?? null);
98
- db.prepare('UPDATE conversations SET updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(convId);
99
- return msg as any;
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 * FROM messages WHERE conversation_id = ? ORDER BY created_at DESC LIMIT ?
185
- ) sub ORDER BY created_at ASC
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 ID
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 * FROM messages
194
- WHERE conversation_id = ? AND id < ?
195
- ORDER BY id DESC LIMIT ?
196
- ) sub ORDER BY id ASC
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, 100));
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, 100);
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.