bloby-bot 0.46.0 → 0.46.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js CHANGED
@@ -5,7 +5,7 @@ import fs from 'fs';
5
5
  import path from 'path';
6
6
  import os from 'os';
7
7
  import readline from 'readline';
8
- import { fileURLToPath } from 'url';
8
+ import { fileURLToPath, pathToFileURL } from 'url';
9
9
 
10
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
11
 
@@ -2010,6 +2010,40 @@ async function passwordReset() {
2010
2010
  // body to stdout (and the Payment-Receipt / X-PAYMENT-RESPONSE header to
2011
2011
  // stderr) so it composes with shell pipelines like curl.
2012
2012
 
2013
+ // x402-fetch pulls in ~520 MB of browser-wallet SDKs (WalletConnect, MetaMask,
2014
+ // Coinbase, Reown, Solana, etc.) that we never use. Installing it as a regular
2015
+ // dep OOMs t4g.small (2 GB) during npm install. So we lazy-install on first
2016
+ // `bloby x402` use into an isolated tools dir.
2017
+ async function ensureX402Module() {
2018
+ // Already installed in the main tree? (dev convenience)
2019
+ try { return await import('x402-fetch'); } catch {}
2020
+
2021
+ const toolsDir = path.join(DATA_DIR, '.x402-tools');
2022
+ const installed = path.join(toolsDir, 'node_modules', 'x402-fetch', 'dist', 'esm', 'index.mjs');
2023
+
2024
+ if (!fs.existsSync(installed)) {
2025
+ console.log(` ${c.dim}Installing x402-fetch (one-time, ~30s)…${c.reset}`);
2026
+ fs.mkdirSync(toolsDir, { recursive: true });
2027
+ const toolsPkg = path.join(toolsDir, 'package.json');
2028
+ if (!fs.existsSync(toolsPkg)) {
2029
+ fs.writeFileSync(toolsPkg, JSON.stringify({
2030
+ name: 'bloby-x402-tools', private: true, version: '0.0.0',
2031
+ }, null, 2));
2032
+ }
2033
+ try {
2034
+ execSync(
2035
+ 'npm install --omit=dev --no-audit --no-fund --no-progress --prefer-offline --loglevel=error x402-fetch@^1.2.0',
2036
+ { cwd: toolsDir, stdio: 'inherit' },
2037
+ );
2038
+ } catch {
2039
+ console.error(` ${c.red}✗${c.reset} Failed to install x402-fetch. Check network and retry.`);
2040
+ process.exit(1);
2041
+ }
2042
+ }
2043
+
2044
+ return import(pathToFileURL(installed).href);
2045
+ }
2046
+
2013
2047
  async function x402Pay(rest) {
2014
2048
  if (!rest.length || rest.includes('-h') || rest.includes('--help')) {
2015
2049
  console.log(`
@@ -2026,6 +2060,8 @@ async function x402Pay(rest) {
2026
2060
  Example:
2027
2061
  bloby x402 https://bloby.bot/api/marketplace/buy-base/<id> \\
2028
2062
  -X POST -H "X-Bloby-Token: $RELAY_TOKEN"
2063
+
2064
+ First-call note: installs x402-fetch on demand (~30s, one-time).
2029
2065
  `);
2030
2066
  process.exit(rest.length ? 0 : 1);
2031
2067
  }
@@ -2059,7 +2095,7 @@ async function x402Pay(rest) {
2059
2095
  const { createWalletClient, http } = await import('viem');
2060
2096
  const { privateKeyToAccount } = await import('viem/accounts');
2061
2097
  const { base } = await import('viem/chains');
2062
- const { wrapFetchWithPayment, decodeXPaymentResponse } = await import('x402-fetch');
2098
+ const { wrapFetchWithPayment, decodeXPaymentResponse } = await ensureX402Module();
2063
2099
 
2064
2100
  const pk = cfg.wallet.privateKey.startsWith('0x') ? cfg.wallet.privateKey : `0x${cfg.wallet.privateKey}`;
2065
2101
  const account = privateKeyToAccount(pk);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bloby-bot",
3
- "version": "0.46.0",
3
+ "version": "0.46.2",
4
4
  "releaseNotes": [
5
5
  "1. # voice note (PTT bubble)",
6
6
  "2. # audio file + caption",
@@ -52,13 +52,13 @@
52
52
  "dev:docs": "cd ./docs && npx fumapress"
53
53
  },
54
54
  "dependencies": {
55
- "@anthropic-ai/claude-agent-sdk": "^0.2.112",
55
+ "@anthropic-ai/claude-agent-sdk": "^0.2.138",
56
56
  "@clack/prompts": "^1.1.0",
57
- "@openai/codex": "^0.128.0",
57
+ "@openai/codex": "^0.130.0",
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",
@@ -87,7 +87,6 @@
87
87
  "vite": "^8.0.3",
88
88
  "web-push": "^3.6.7",
89
89
  "ws": "^8.19.0",
90
- "x402-fetch": "^1.2.0",
91
90
  "zustand": "^5.0.11"
92
91
  },
93
92
  "devDependencies": {
@@ -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
  }
@@ -422,10 +422,11 @@ export async function startConversation(
422
422
  summary: sysMsg.summary,
423
423
  usage: sysMsg.usage,
424
424
  });
425
- // Sub-agent completion may have written files
426
- if (sysMsg.status === 'completed') {
427
- onMessage('bot:turn-complete', { conversationId, usedFileTools: true });
428
- }
425
+ // Don't emit bot:turn-complete here. Sub-agent completion is a progress
426
+ // signal, not a turn boundary — the parent agent will continue, and its
427
+ // real `result` event (above) is the only true turn end. The parent's
428
+ // `usedTools` Set already captures sub-agent tool_use blocks, so file
429
+ // edits made inside a sub-agent are still reflected in usedFileTools.
429
430
  }
430
431
  break;
431
432
  }
@@ -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 = '';
@@ -1418,11 +1450,10 @@ ${!connected ? `<script>
1418
1450
  currentStreamBuffer += eventData.token;
1419
1451
  }
1420
1452
 
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);
1453
+ // Route streaming text via the manager's per-conversation routing FIFO.
1454
+ // The destination was decided at pushWithRouting time this is purely a
1455
+ // dispatcher and cannot bleed events to the wrong surface.
1456
+ channelManager.routeWaStreamEvent(waState, type, eventData, botName);
1426
1457
 
1427
1458
  // Agent finished a turn — handle backend restart + notify client
1428
1459
  if (type === 'bot:turn-complete') {
@@ -1458,6 +1489,7 @@ ${!connected ? `<script>
1458
1489
  agentQueryActive = false;
1459
1490
  currentStreamConvId = null;
1460
1491
  currentStreamBuffer = '';
1492
+ channelManager.clearRoutes(convId);
1461
1493
  return;
1462
1494
  }
1463
1495
 
@@ -1481,9 +1513,21 @@ ${!connected ? `<script>
1481
1513
  }, { botName, humanName }, recentMessages);
1482
1514
  }
1483
1515
 
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);
1516
+ // Push the user message into the live conversation with a pinned routing
1517
+ // target. Chat-bubble responses are broadcast to all WS clients regardless;
1518
+ // the WhatsApp self-chat mirror (if connected) is the optional secondary
1519
+ // destination, baked in at push time so it cannot drift to a different chat.
1520
+ const waStatus = channelManager.getStatus('whatsapp');
1521
+ const ownPhone = waStatus?.connected ? (waStatus.info?.phoneNumber as string | undefined) : undefined;
1522
+ const waMirrorTo = ownPhone ? `${ownPhone}@s.whatsapp.net` : undefined;
1523
+ log.info(`[orchestrator] Pushing message into live conversation (waMirror=${waMirrorTo || 'none'})`);
1524
+ channelManager.pushWithRouting(
1525
+ convId,
1526
+ { surface: 'chat', waSendTo: waMirrorTo, isSelfChat: true },
1527
+ content,
1528
+ data.attachments,
1529
+ savedFiles,
1530
+ );
1487
1531
  })();
1488
1532
  return;
1489
1533
  }
@@ -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**:
@@ -46,29 +46,17 @@
46
46
  </script>
47
47
  <script type="module" src="/src/main.tsx"></script>
48
48
  <script>
49
+ // Register the SW but DO NOT auto-reload on controllerchange.
50
+ // The old pattern (skipWaiting + claim + controllerchange → location.reload)
51
+ // caused surprise reloads on mobile PWAs: iOS aggressively re-checks sw.js
52
+ // and any byte-level variation in the response (esp. via Vite dev) trips a
53
+ // new SW activation → controllerchange → unwanted reload. The new SW
54
+ // silently takes over for future fetches; the user gets the new build on
55
+ // their next natural refresh.
49
56
  if('serviceWorker' in navigator && location.port !== '5173'){
50
- var swRefreshing=false;
51
- navigator.serviceWorker.addEventListener('controllerchange',function(){
52
- if(swRefreshing)return;
53
- swRefreshing=true;
54
- var s=document.getElementById('splash');
55
- if(s){s.style.transition='none';s.style.display='block';s.style.opacity='1'}
56
- location.reload();
57
+ navigator.serviceWorker.register('/sw.js').catch(function(err){
58
+ console.error('[sw-reg] registration failed:', err);
57
59
  });
58
- navigator.serviceWorker.register('/sw.js').then(function(r){
59
- r.update();
60
- if(r.waiting){
61
- r.waiting.postMessage({type:'SKIP_WAITING'});
62
- }
63
- r.addEventListener('updatefound',function(){
64
- var w=r.installing;
65
- if(w)w.addEventListener('statechange',function(){
66
- if(w.state==='installed'&&navigator.serviceWorker.controller){
67
- w.postMessage({type:'SKIP_WAITING'});
68
- }
69
- });
70
- });
71
- }).catch(function(err){ console.error('[sw-reg] registration failed:', err); });
72
60
  }
73
61
  </script>
74
62
  <script src="/bloby/widget.js"></script>
@@ -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.