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 +38 -2
- package/package.json +4 -5
- package/scripts/postinstall.js +16 -0
- package/supervisor/channels/manager.ts +164 -59
- package/supervisor/channels/types.ts +32 -0
- package/supervisor/channels/whatsapp.ts +69 -9
- package/supervisor/harnesses/claude.ts +5 -4
- package/supervisor/index.ts +53 -9
- package/worker/prompts/bloby-system-prompt.txt +2 -0
- package/workspace/client/index.html +9 -21
- package/workspace/skills/whatsapp/SKILL.md +27 -0
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
|
|
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.
|
|
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.
|
|
55
|
+
"@anthropic-ai/claude-agent-sdk": "^0.2.138",
|
|
56
56
|
"@clack/prompts": "^1.1.0",
|
|
57
|
-
"@openai/codex": "^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-
|
|
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": {
|
package/scripts/postinstall.js
CHANGED
|
@@ -77,6 +77,22 @@ try {
|
|
|
77
77
|
console.error('Warning: failed to install dependencies in ~/.bloby/ — run "cd ~/.bloby && npm install" manually');
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// ── Prune wrong-libc claude-agent-sdk native package ──
|
|
81
|
+
// The SDK's binary resolver tries the musl variant before glibc on Linux. If
|
|
82
|
+
// both optionalDependencies install (npm does that for arm64), a glibc system
|
|
83
|
+
// like Raspberry Pi OS picks the musl binary and fails to spawn it.
|
|
84
|
+
if (process.platform === 'linux') {
|
|
85
|
+
const muslLoader = `/lib/ld-musl-${process.arch === 'arm64' ? 'aarch64' : process.arch === 'x64' ? 'x86_64' : process.arch}.so.1`;
|
|
86
|
+
const systemIsMusl = fs.existsSync(muslLoader);
|
|
87
|
+
const unwanted = systemIsMusl
|
|
88
|
+
? `claude-agent-sdk-linux-${process.arch}`
|
|
89
|
+
: `claude-agent-sdk-linux-${process.arch}-musl`;
|
|
90
|
+
const unwantedPath = path.join(BLOBY_HOME, 'node_modules', '@anthropic-ai', unwanted);
|
|
91
|
+
if (fs.existsSync(unwantedPath)) {
|
|
92
|
+
fs.rmSync(unwantedPath, { recursive: true, force: true });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
80
96
|
// ── Install workspace dependencies (isolated from system deps) ──
|
|
81
97
|
// Native modules (better-sqlite3) must be built for the target platform —
|
|
82
98
|
// failures here cause backend crash loops, so surface them.
|
|
@@ -25,9 +25,10 @@ import { WORKSPACE_DIR } from '../../shared/paths.js';
|
|
|
25
25
|
import { log } from '../../shared/logger.js';
|
|
26
26
|
import { startBlobyAgentQuery, startConversation, pushMessage, hasConversation, type RecentMessage } from '../bloby-agent.js';
|
|
27
27
|
import { WhatsAppChannel } from './whatsapp.js';
|
|
28
|
-
import type { ChannelConfig, ChannelProvider, ChannelStatus, ChannelType, InboundMessage, InboundMessageAttachment, SenderRole } from './types.js';
|
|
28
|
+
import type { ChannelConfig, ChannelProvider, ChannelStatus, ChannelType, InboundMessage, InboundMessageAttachment, RoutingTarget, SenderRole } from './types.js';
|
|
29
29
|
import type { AgentAttachment } from '../bloby-agent.js';
|
|
30
30
|
import { saveAttachment, type SavedFile } from '../file-saver.js';
|
|
31
|
+
import type { WAMessageKey } from '@whiskeysockets/baileys';
|
|
31
32
|
|
|
32
33
|
const MAX_CONCURRENT_AGENTS = 5;
|
|
33
34
|
const MAX_BUFFER_MESSAGES = 30;
|
|
@@ -74,6 +75,8 @@ interface DebounceEntry {
|
|
|
74
75
|
isSelfChat: boolean;
|
|
75
76
|
chatJid: string;
|
|
76
77
|
isGroup: boolean;
|
|
78
|
+
/** Latest inbound message key in the batch (used for reactions/quotes on the freshest message). */
|
|
79
|
+
inboundKey?: WAMessageKey;
|
|
77
80
|
}
|
|
78
81
|
|
|
79
82
|
/** Per-conversation accumulator for streaming bot text → WhatsApp. */
|
|
@@ -91,8 +94,13 @@ export class ChannelManager {
|
|
|
91
94
|
private customerBuffers = new Map<string, BufferedMessage[]>();
|
|
92
95
|
/** Debounce buffers per sender (keyed by "channel:sender") */
|
|
93
96
|
private debounceBuffers = new Map<string, DebounceEntry>();
|
|
94
|
-
/**
|
|
95
|
-
|
|
97
|
+
/**
|
|
98
|
+
* Per-conversation FIFO of routing targets. One entry pushed for every user message,
|
|
99
|
+
* one consumed for every `bot:response`. This is the supervisor-enforced anti-bleed
|
|
100
|
+
* mechanism — the agent's reply is pinned to whatever surface triggered it, regardless
|
|
101
|
+
* of any mid-turn inbound from another channel.
|
|
102
|
+
*/
|
|
103
|
+
private routingQueues = new Map<string, RoutingTarget[]>();
|
|
96
104
|
|
|
97
105
|
constructor(opts: ChannelManagerOpts) {
|
|
98
106
|
this.opts = opts;
|
|
@@ -110,9 +118,9 @@ export class ChannelManager {
|
|
|
110
118
|
|
|
111
119
|
log.info('[channels] Initializing WhatsApp channel...');
|
|
112
120
|
const whatsapp = new WhatsAppChannel(
|
|
113
|
-
(sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, images) => {
|
|
121
|
+
(sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, images, inboundKey) => {
|
|
114
122
|
const attachments = images?.map((img) => ({ type: 'image' as const, mediaType: img.mediaType, data: img.data }));
|
|
115
|
-
this.handleInboundMessage('whatsapp', sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, attachments);
|
|
123
|
+
this.handleInboundMessage('whatsapp', sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, attachments, inboundKey);
|
|
116
124
|
},
|
|
117
125
|
(status) => this.handleStatusChange(status),
|
|
118
126
|
(audioBase64) => this.transcribeAudio(audioBase64),
|
|
@@ -134,9 +142,9 @@ export class ChannelManager {
|
|
|
134
142
|
let provider = this.providers.get('whatsapp');
|
|
135
143
|
if (!provider) {
|
|
136
144
|
const whatsapp = new WhatsAppChannel(
|
|
137
|
-
(sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, images) => {
|
|
145
|
+
(sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, images, inboundKey) => {
|
|
138
146
|
const attachments = images?.map((img) => ({ type: 'image' as const, mediaType: img.mediaType, data: img.data }));
|
|
139
|
-
this.handleInboundMessage('whatsapp', sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, attachments);
|
|
147
|
+
this.handleInboundMessage('whatsapp', sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, attachments, inboundKey);
|
|
140
148
|
},
|
|
141
149
|
(status) => this.handleStatusChange(status),
|
|
142
150
|
(audioBase64) => this.transcribeAudio(audioBase64),
|
|
@@ -338,65 +346,119 @@ export class ChannelManager {
|
|
|
338
346
|
return { chunkBuf: '' };
|
|
339
347
|
}
|
|
340
348
|
|
|
349
|
+
/** Push a user message into a live conversation and pin where the assistant's reply must go.
|
|
350
|
+
*
|
|
351
|
+
* THIS IS THE SINGLE SOURCE OF TRUTH for routing — every caller (chat-WS, channel inbound
|
|
352
|
+
* handlers, scheduler, etc.) MUST push via this method, never via the raw harness pushMessage.
|
|
353
|
+
*
|
|
354
|
+
* Each call enqueues exactly one routing target onto a per-conversation FIFO. Each
|
|
355
|
+
* `bot:response` consumes exactly one entry. Concurrent inbounds from different surfaces
|
|
356
|
+
* during the same turn cannot bleed into each other's replies — the SDK queues inputs in
|
|
357
|
+
* order, and the FIFO mirrors that ordering. Turns that end without a response (error /
|
|
358
|
+
* empty turn / aborted) drain the head entry in `routeWaStreamEvent` so the queue stays
|
|
359
|
+
* in sync. */
|
|
360
|
+
pushWithRouting(
|
|
361
|
+
convId: string,
|
|
362
|
+
target: RoutingTarget,
|
|
363
|
+
content: string,
|
|
364
|
+
attachments?: AgentAttachment[],
|
|
365
|
+
savedFiles?: SavedFile[],
|
|
366
|
+
): void {
|
|
367
|
+
let q = this.routingQueues.get(convId);
|
|
368
|
+
if (!q) {
|
|
369
|
+
q = [];
|
|
370
|
+
this.routingQueues.set(convId, q);
|
|
371
|
+
}
|
|
372
|
+
q.push(target);
|
|
373
|
+
pushMessage(convId, content, attachments, savedFiles);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Peek the head of the routing queue without consuming. Used during intermediate streaming
|
|
377
|
+
* flushes (bot:tool) so chunks land on the correct surface before the final response. */
|
|
378
|
+
private peekRoute(convId: string): RoutingTarget | undefined {
|
|
379
|
+
return this.routingQueues.get(convId)?.[0];
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Consume one entry from the routing queue. Called on `bot:response`, and as a safety net
|
|
383
|
+
* on `bot:turn-complete`/`bot:error` if no response fired (so the head doesn't bleed into
|
|
384
|
+
* the next turn). */
|
|
385
|
+
private consumeRoute(convId: string): RoutingTarget | undefined {
|
|
386
|
+
const q = this.routingQueues.get(convId);
|
|
387
|
+
if (!q || q.length === 0) return undefined;
|
|
388
|
+
const target = q.shift();
|
|
389
|
+
if (q.length === 0) this.routingQueues.delete(convId);
|
|
390
|
+
return target;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Drop all pending routes for a conversation — used when the live conversation ends.
|
|
394
|
+
* Accepts undefined for ergonomics in callers that hold a possibly-undefined convId. */
|
|
395
|
+
clearRoutes(convId: string | undefined): void {
|
|
396
|
+
if (!convId) return;
|
|
397
|
+
const q = this.routingQueues.get(convId);
|
|
398
|
+
if (q && q.length > 0) {
|
|
399
|
+
log.warn(`[channels] Discarding ${q.length} pending route(s) for ended conversation ${convId}`);
|
|
400
|
+
}
|
|
401
|
+
this.routingQueues.delete(convId);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Send a reaction emoji on the inbound message that triggered a turn. Used to acknowledge
|
|
405
|
+
* long-running work without spamming text. Pass `''` to remove a previous reaction. */
|
|
406
|
+
async reactToInbound(target: RoutingTarget, emoji: string): Promise<void> {
|
|
407
|
+
if (!target.inboundKey || !target.waSendTo) return;
|
|
408
|
+
const provider = this.providers.get('whatsapp');
|
|
409
|
+
if (provider instanceof WhatsAppChannel) {
|
|
410
|
+
await provider.sendReaction(target.waSendTo, target.inboundKey as WAMessageKey, emoji);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Direct reaction API (for the /api/channels/whatsapp/react endpoint). */
|
|
415
|
+
async reactToMessage(channel: ChannelType, chatJid: string, key: WAMessageKey, emoji: string): Promise<void> {
|
|
416
|
+
const provider = this.providers.get(channel);
|
|
417
|
+
if (!(provider instanceof WhatsAppChannel)) {
|
|
418
|
+
throw new Error(`Channel ${channel} does not support reactions`);
|
|
419
|
+
}
|
|
420
|
+
await provider.sendReaction(chatJid, key, emoji);
|
|
421
|
+
}
|
|
422
|
+
|
|
341
423
|
/** Centralized WhatsApp routing for streaming agent events.
|
|
342
424
|
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
* (typically the user's own number for self-chat mirroring).
|
|
348
|
-
* - Else → don't send anything to WhatsApp.
|
|
425
|
+
* Every event passes through here exactly once (only one onMessage callback is registered
|
|
426
|
+
* per live conversation, regardless of who started it). The routing decision is pure: it
|
|
427
|
+
* consults the per-conversation FIFO populated by `pushWithRouting`. No fallback, no
|
|
428
|
+
* implicit channel inference — the trigger surface owns the reply.
|
|
349
429
|
*
|
|
350
|
-
*
|
|
351
|
-
* Call from any callback registered via startConversation. Safe to call from
|
|
352
|
-
* either the orchestrator or the manager's own callback — they will not
|
|
353
|
-
* double-send because only one callback is registered per live conversation.
|
|
430
|
+
* Also keeps assistant-mode context buffers up to date.
|
|
354
431
|
*/
|
|
355
432
|
routeWaStreamEvent(
|
|
356
433
|
state: WaStreamState,
|
|
357
434
|
type: string,
|
|
358
435
|
eventData: any,
|
|
359
|
-
fallbackMirrorJid: string | null,
|
|
360
436
|
botName: string,
|
|
361
437
|
): void {
|
|
438
|
+
const convId = eventData?.conversationId as string | undefined;
|
|
439
|
+
|
|
362
440
|
if (type === 'bot:token' && eventData?.token) {
|
|
363
441
|
state.chunkBuf += eventData.token;
|
|
364
442
|
return;
|
|
365
443
|
}
|
|
366
444
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
};
|
|
373
|
-
|
|
374
|
-
if (type === 'bot:tool' && state.chunkBuf.trim()) {
|
|
375
|
-
// Agent paused for a tool call — flush whatever text was streamed so the user
|
|
376
|
-
// sees progress before the tool result lands.
|
|
377
|
-
const target = this.waReplyQueue[0]; // peek; consume on bot:response
|
|
378
|
-
if (target) {
|
|
379
|
-
fireSend(target.rawSender, state.chunkBuf.trim(), true);
|
|
380
|
-
} else if (fallbackMirrorJid) {
|
|
381
|
-
fireSend(`${fallbackMirrorJid}@s.whatsapp.net`, state.chunkBuf.trim(), false);
|
|
382
|
-
}
|
|
445
|
+
if (type === 'bot:tool' && state.chunkBuf.trim() && convId) {
|
|
446
|
+
// Agent paused for a tool call — flush streamed text so the user sees progress
|
|
447
|
+
// before the tool result lands. Peek (don't consume) — the final bot:response
|
|
448
|
+
// is what closes out the turn.
|
|
449
|
+
this.sendStreamChunk(this.peekRoute(convId), state.chunkBuf.trim(), botName);
|
|
383
450
|
state.chunkBuf = '';
|
|
384
451
|
return;
|
|
385
452
|
}
|
|
386
453
|
|
|
387
|
-
if (type === 'bot:response' && eventData?.content) {
|
|
388
|
-
const target = this.
|
|
454
|
+
if (type === 'bot:response' && eventData?.content && convId) {
|
|
455
|
+
const target = this.consumeRoute(convId);
|
|
389
456
|
const remaining = state.chunkBuf.trim();
|
|
390
|
-
|
|
391
|
-
if (target) {
|
|
392
|
-
if (remaining) fireSend(target.rawSender, remaining, true);
|
|
393
|
-
} else if (remaining && fallbackMirrorJid) {
|
|
394
|
-
fireSend(`${fallbackMirrorJid}@s.whatsapp.net`, remaining, false);
|
|
395
|
-
}
|
|
457
|
+
if (remaining) this.sendStreamChunk(target, remaining, botName);
|
|
396
458
|
state.chunkBuf = '';
|
|
397
459
|
|
|
398
|
-
// Append the assistant's reply into the per-chat context buffer so
|
|
399
|
-
//
|
|
460
|
+
// Append the assistant's reply into the per-chat context buffer so the next
|
|
461
|
+
// trigger in that chat sees it as conversation history.
|
|
400
462
|
if (target?.assistantBufferKey) {
|
|
401
463
|
const buf = this.customerBuffers.get(target.assistantBufferKey);
|
|
402
464
|
if (buf) {
|
|
@@ -404,9 +466,34 @@ export class ChannelManager {
|
|
|
404
466
|
if (buf.length > MAX_BUFFER_MESSAGES) buf.splice(0, buf.length - MAX_BUFFER_MESSAGES);
|
|
405
467
|
}
|
|
406
468
|
}
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Turn ended (or errored) without a bot:response — drain the head entry so it
|
|
473
|
+
// doesn't bleed into the next turn's reply. The SDK guarantees one response per
|
|
474
|
+
// pushed input; this safety net covers aborts, empty turns, and provider errors.
|
|
475
|
+
if ((type === 'bot:turn-complete' || type === 'bot:error') && convId) {
|
|
476
|
+
if (this.peekRoute(convId)) {
|
|
477
|
+
const dropped = this.consumeRoute(convId);
|
|
478
|
+
log.warn(`[channels] ${type} without bot:response — dropping pending route (surface=${dropped?.surface}, to=${dropped?.waSendTo || 'none'})`);
|
|
479
|
+
}
|
|
480
|
+
state.chunkBuf = '';
|
|
407
481
|
}
|
|
408
482
|
}
|
|
409
483
|
|
|
484
|
+
/** Deliver a streamed chunk to the WhatsApp side of a routing target.
|
|
485
|
+
* No-op when the target has no `waSendTo` (e.g., chat-UI turn with WA disconnected). */
|
|
486
|
+
private sendStreamChunk(target: RoutingTarget | undefined, text: string, botName: string): void {
|
|
487
|
+
if (!target?.waSendTo) return;
|
|
488
|
+
// Prefix only when the trigger came from WhatsApp AND it isn't the user's own self-chat —
|
|
489
|
+
// the user doesn't need to see "🤖 Bot:" before their own bot's reply in their own chat.
|
|
490
|
+
const usePrefix = target.surface === 'whatsapp' && !target.isSelfChat;
|
|
491
|
+
const body = usePrefix ? this.formatBotReply(text, botName) : text;
|
|
492
|
+
this.sendMessage('whatsapp', target.waSendTo, body).catch((err) =>
|
|
493
|
+
log.warn(`[channels] WA send failed (${target.waSendTo}): ${err.message}`),
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
410
497
|
private handleStatusChange(status: ChannelStatus) {
|
|
411
498
|
for (const listener of this.statusListeners) {
|
|
412
499
|
listener(status);
|
|
@@ -434,6 +521,7 @@ export class ChannelManager {
|
|
|
434
521
|
chatJid: string,
|
|
435
522
|
isGroup: boolean,
|
|
436
523
|
attachments?: InboundMessageAttachment[],
|
|
524
|
+
inboundKey?: WAMessageKey,
|
|
437
525
|
) {
|
|
438
526
|
const channelConfig = this.getChannelConfig(channel);
|
|
439
527
|
if (!channelConfig) return;
|
|
@@ -482,6 +570,7 @@ export class ChannelManager {
|
|
|
482
570
|
existing.messages.push(text);
|
|
483
571
|
if (attachments?.length) existing.attachments.push(...attachments);
|
|
484
572
|
existing.senderName = senderName || existing.senderName;
|
|
573
|
+
if (inboundKey) existing.inboundKey = inboundKey; // track the freshest message for reactions
|
|
485
574
|
existing.timer = setTimeout(() => this.flushDebounce(debounceKey), DEBOUNCE_MS);
|
|
486
575
|
log.info(`[channels] Debounce: buffered message ${existing.messages.length} from ${sender} in ${chatJid}`);
|
|
487
576
|
return;
|
|
@@ -498,6 +587,7 @@ export class ChannelManager {
|
|
|
498
587
|
isSelfChat,
|
|
499
588
|
chatJid,
|
|
500
589
|
isGroup,
|
|
590
|
+
inboundKey,
|
|
501
591
|
timer: setTimeout(() => this.flushDebounce(debounceKey), DEBOUNCE_MS),
|
|
502
592
|
};
|
|
503
593
|
this.debounceBuffers.set(debounceKey, entry);
|
|
@@ -509,7 +599,7 @@ export class ChannelManager {
|
|
|
509
599
|
if (!entry) return;
|
|
510
600
|
this.debounceBuffers.delete(key);
|
|
511
601
|
|
|
512
|
-
const { channel, sender, senderName, fromMe, isSelfChat, chatJid, isGroup, messages, attachments } = entry;
|
|
602
|
+
const { channel, sender, senderName, fromMe, isSelfChat, chatJid, isGroup, messages, attachments, inboundKey } = entry;
|
|
513
603
|
const combinedText = messages.join('\n');
|
|
514
604
|
|
|
515
605
|
const channelConfig = this.getChannelConfig(channel);
|
|
@@ -531,6 +621,8 @@ export class ChannelManager {
|
|
|
531
621
|
text: combinedText,
|
|
532
622
|
rawSender: chatJid,
|
|
533
623
|
attachments: attachments.length > 0 ? attachments : undefined,
|
|
624
|
+
inboundKey,
|
|
625
|
+
isGroup,
|
|
534
626
|
};
|
|
535
627
|
|
|
536
628
|
const modeLabel = mode === 'channel' ? 'Channel mode | self-chat'
|
|
@@ -589,6 +681,8 @@ export class ChannelManager {
|
|
|
589
681
|
displayText: cleanText,
|
|
590
682
|
rawSender: chatJid,
|
|
591
683
|
attachments: attachments.length > 0 ? attachments : undefined,
|
|
684
|
+
inboundKey,
|
|
685
|
+
isGroup,
|
|
592
686
|
};
|
|
593
687
|
|
|
594
688
|
log.info(`[channels] Assistant mode | triggered in ${isGroup ? 'group ' : 'chat with '}${chatId} | buffer=${buffer.length} msgs | "${cleanText.slice(0, 60)}"`);
|
|
@@ -607,6 +701,8 @@ export class ChannelManager {
|
|
|
607
701
|
text: combinedText,
|
|
608
702
|
rawSender: chatJid,
|
|
609
703
|
attachments: attachments.length > 0 ? attachments : undefined,
|
|
704
|
+
inboundKey,
|
|
705
|
+
isGroup,
|
|
610
706
|
};
|
|
611
707
|
|
|
612
708
|
log.info(`[channels] Business mode | ${message.sender} | role=${role} | "${combinedText.slice(0, 60)}"`);
|
|
@@ -723,9 +819,10 @@ export class ChannelManager {
|
|
|
723
819
|
log.info(`[channels] Starting live conversation for admin: ${convId}`);
|
|
724
820
|
|
|
725
821
|
await startConversation(convId, model, (type, eventData) => {
|
|
726
|
-
// WhatsApp routing —
|
|
727
|
-
//
|
|
728
|
-
|
|
822
|
+
// WhatsApp routing — purely queue-driven, no fallback mirror jid. The
|
|
823
|
+
// routing target carries the destination; whatever surface triggered the
|
|
824
|
+
// turn owns the reply.
|
|
825
|
+
this.routeWaStreamEvent(waState, type, eventData, botName);
|
|
729
826
|
|
|
730
827
|
// Persist the assistant's reply to the conversation's DB
|
|
731
828
|
if (type === 'bot:response' && eventData.content) {
|
|
@@ -747,24 +844,32 @@ export class ChannelManager {
|
|
|
747
844
|
return;
|
|
748
845
|
}
|
|
749
846
|
|
|
750
|
-
//
|
|
751
|
-
|
|
847
|
+
// Live conversation ended — drop any pending routes so a future
|
|
848
|
+
// conversation under the same convId starts clean.
|
|
849
|
+
if (type === 'bot:conversation-ended') {
|
|
850
|
+
this.clearRoutes(convId);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
752
853
|
|
|
753
854
|
// Mirror streaming + task events to chat clients
|
|
754
855
|
broadcastBloby(type, eventData);
|
|
755
856
|
}, { botName, humanName }, recentMessages);
|
|
756
857
|
}
|
|
757
858
|
|
|
758
|
-
//
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
rawSender: msg.rawSender,
|
|
762
|
-
assistantBufferKey: msg.role === 'assistant' ? `${msg.channel}:${msg.sender}` : undefined,
|
|
763
|
-
});
|
|
764
|
-
|
|
765
|
-
// Push the message into the live conversation
|
|
859
|
+
// Push into the live conversation with a pinned WhatsApp routing target.
|
|
860
|
+
// The agent's reply for THIS specific input will go to msg.rawSender — no other
|
|
861
|
+
// surface can hijack it via the FIFO ordering of the shared conversation.
|
|
766
862
|
const channelContent = channelContext + msg.text;
|
|
767
|
-
|
|
863
|
+
const target: RoutingTarget = {
|
|
864
|
+
surface: 'whatsapp',
|
|
865
|
+
waSendTo: msg.rawSender,
|
|
866
|
+
isGroup: msg.isGroup,
|
|
867
|
+
// Self-chat in 1:1: don't prefix "🤖 Bot:" — it's the user's own chat with themselves.
|
|
868
|
+
isSelfChat: msg.role === 'admin' && !msg.isGroup,
|
|
869
|
+
assistantBufferKey: msg.role === 'assistant' ? `${msg.channel}:${msg.sender}` : undefined,
|
|
870
|
+
inboundKey: msg.inboundKey,
|
|
871
|
+
};
|
|
872
|
+
this.pushWithRouting(convId, target, channelContent, agentAttachments, savedFiles);
|
|
768
873
|
}
|
|
769
874
|
|
|
770
875
|
/** Handle message from a customer — runs support agent in parallel with conversation context */
|
|
@@ -39,6 +39,10 @@ export interface InboundMessage {
|
|
|
39
39
|
rawSender: string;
|
|
40
40
|
/** Image attachments */
|
|
41
41
|
attachments?: InboundMessageAttachment[];
|
|
42
|
+
/** Original channel-native message key (Baileys WAMessageKey for WhatsApp). Opaque here. */
|
|
43
|
+
inboundKey?: unknown;
|
|
44
|
+
/** True when the chat is a group (for assistant-mode group routing). */
|
|
45
|
+
isGroup?: boolean;
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
export interface OutboundMessage {
|
|
@@ -54,6 +58,34 @@ export interface ChannelStatus {
|
|
|
54
58
|
info?: Record<string, any>;
|
|
55
59
|
}
|
|
56
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Per-turn routing target.
|
|
63
|
+
*
|
|
64
|
+
* Every user message pushed into a live conversation carries one of these so the
|
|
65
|
+
* agent's response goes back to the surface that triggered it — no FIFO ambiguity,
|
|
66
|
+
* no channel-bleed across concurrent turns.
|
|
67
|
+
*
|
|
68
|
+
* Created by the surface that pushes the user message (chat-WS or channel handler),
|
|
69
|
+
* consumed by the manager when the corresponding `bot:response` fires, discarded if
|
|
70
|
+
* the turn ends without a response (error / empty turn).
|
|
71
|
+
*/
|
|
72
|
+
export interface RoutingTarget {
|
|
73
|
+
/** Which surface triggered this turn. Drives whether the WA reply carries a "🤖 Bot:" prefix. */
|
|
74
|
+
surface: 'chat' | 'whatsapp';
|
|
75
|
+
/** WhatsApp JID to deliver the reply to.
|
|
76
|
+
* - 'whatsapp' surface → the originating chat JID (group or peer).
|
|
77
|
+
* - 'chat' surface → optionally the user's own number (self-chat mirror), or undefined.
|
|
78
|
+
* When undefined, no WhatsApp send happens — the reply only reaches the dashboard via broadcast.
|
|
79
|
+
*/
|
|
80
|
+
waSendTo?: string;
|
|
81
|
+
isGroup?: boolean;
|
|
82
|
+
isSelfChat?: boolean;
|
|
83
|
+
/** When set, the assistant's reply is appended to this customer buffer (assistant-mode context). */
|
|
84
|
+
assistantBufferKey?: string;
|
|
85
|
+
/** Original inbound WA message key — kept opaque here, used by the channel to react/quote. */
|
|
86
|
+
inboundKey?: unknown;
|
|
87
|
+
}
|
|
88
|
+
|
|
57
89
|
export interface ChannelProvider {
|
|
58
90
|
readonly type: ChannelType;
|
|
59
91
|
/** Start the channel connection (may trigger QR flow) */
|
|
@@ -12,6 +12,7 @@ import makeWASocket, {
|
|
|
12
12
|
Browsers,
|
|
13
13
|
type WASocket,
|
|
14
14
|
type BaileysEventMap,
|
|
15
|
+
type WAMessageKey,
|
|
15
16
|
} from '@whiskeysockets/baileys';
|
|
16
17
|
import fs from 'fs';
|
|
17
18
|
import path from 'path';
|
|
@@ -33,6 +34,7 @@ export interface WhatsAppImageAttachment {
|
|
|
33
34
|
* - sender: who sent it (phone JID, translated from LID where possible)
|
|
34
35
|
* - chatJid: the conversation identifier (group JID for groups, peer JID for 1:1) — reply to this
|
|
35
36
|
* - isGroup: true when the chat is a WhatsApp group (@g.us)
|
|
37
|
+
* - inboundKey: original Baileys message key — used to react/quote/ack the user's message
|
|
36
38
|
*/
|
|
37
39
|
export type OnWhatsAppMessage = (
|
|
38
40
|
sender: string,
|
|
@@ -43,6 +45,7 @@ export type OnWhatsAppMessage = (
|
|
|
43
45
|
chatJid: string,
|
|
44
46
|
isGroup: boolean,
|
|
45
47
|
images?: WhatsAppImageAttachment[],
|
|
48
|
+
inboundKey?: WAMessageKey,
|
|
46
49
|
) => void;
|
|
47
50
|
|
|
48
51
|
/** Callback to transcribe audio via whisper */
|
|
@@ -181,6 +184,25 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
181
184
|
log.info(`[whatsapp] Sent video to ${jid} (id=${result?.key?.id || 'unknown'})`);
|
|
182
185
|
}
|
|
183
186
|
|
|
187
|
+
/** Send an emoji reaction onto an existing message.
|
|
188
|
+
* Pass an empty string to remove a previously-sent reaction (Baileys convention). */
|
|
189
|
+
async sendReaction(chatJid: string, key: WAMessageKey, emoji: string): Promise<void> {
|
|
190
|
+
if (!this.sock || !this.connected) {
|
|
191
|
+
log.warn('[whatsapp] Cannot react — not connected');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (!key || !key.id) {
|
|
195
|
+
log.warn('[whatsapp] Cannot react — missing message key');
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
await this.sock.sendMessage(chatJid, { react: { text: emoji, key } });
|
|
200
|
+
log.info(`[whatsapp] Reacted "${emoji}" to ${key.id} in ${chatJid}`);
|
|
201
|
+
} catch (err: any) {
|
|
202
|
+
log.warn(`[whatsapp] Reaction failed: ${err.message}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
184
206
|
/** Send a document (PDF, zip, etc.) via WhatsApp */
|
|
185
207
|
async sendDocument(to: string, document: Buffer, fileName: string, mimetype?: string, caption?: string): Promise<void> {
|
|
186
208
|
if (!this.sock || !this.connected) {
|
|
@@ -286,15 +308,28 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
286
308
|
|
|
287
309
|
// ── Internal ──
|
|
288
310
|
|
|
289
|
-
/** Translate a JID from LID format to phone format if possible
|
|
290
|
-
|
|
311
|
+
/** Translate a JID from LID format to phone format if possible.
|
|
312
|
+
* Falls back to the supplied `alt` (Baileys 7+ `key.participantAlt`/`remoteJidAlt`) which
|
|
313
|
+
* carries the phone form when the primary identifier is a LID. */
|
|
314
|
+
private translateJid(jid: string, alt?: string | null): string {
|
|
291
315
|
// If it's already a phone JID, return as-is
|
|
292
316
|
if (jid.endsWith('@s.whatsapp.net')) return jid;
|
|
293
317
|
|
|
294
|
-
// Check LID map (
|
|
318
|
+
// Check learned LID map first (covers our own LID + any pairs we've seen)
|
|
295
319
|
const mapped = this.lidToPhoneMap.get(jid);
|
|
296
320
|
if (mapped) return mapped;
|
|
297
321
|
|
|
322
|
+
// Baileys 7 ships the alternate identifier on the message key. If the primary
|
|
323
|
+
// is a LID, the alt is the phone-number form — adopt it and learn the mapping
|
|
324
|
+
// for future messages on this conversation.
|
|
325
|
+
if (alt && alt.endsWith('@s.whatsapp.net')) {
|
|
326
|
+
this.lidToPhoneMap.set(jid, alt);
|
|
327
|
+
// Also map the bare LID number → phone, so different LID encodings collapse
|
|
328
|
+
const lidNum = jid.split(':')[0].split('@')[0];
|
|
329
|
+
if (lidNum) this.lidToPhoneMap.set(`${lidNum}@lid`, alt);
|
|
330
|
+
return alt;
|
|
331
|
+
}
|
|
332
|
+
|
|
298
333
|
// Unknown LID — don't guess. Return as-is so isSelfChat stays false.
|
|
299
334
|
return jid;
|
|
300
335
|
}
|
|
@@ -499,8 +534,16 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
499
534
|
const participant = msg.key.participant || '';
|
|
500
535
|
const isGroup = rawSender.endsWith('@g.us');
|
|
501
536
|
|
|
537
|
+
// Baileys 7 exposes the alternate identifier on the key — when the primary is a LID,
|
|
538
|
+
// the alt is the phone-number form (and vice versa). Use these to translate cleanly.
|
|
539
|
+
const remoteJidAlt = (msg.key as any).remoteJidAlt as string | undefined;
|
|
540
|
+
const participantAlt = (msg.key as any).participantAlt as string | undefined;
|
|
541
|
+
|
|
502
542
|
// chatJid: where to reply (group JID for groups, peer JID otherwise).
|
|
503
|
-
|
|
543
|
+
// For peer chats, prefer the phone-form JID so reactions/replies hit the canonical chat.
|
|
544
|
+
const chatJid = isGroup
|
|
545
|
+
? rawSender
|
|
546
|
+
: this.translateJid(rawSender, remoteJidAlt);
|
|
504
547
|
|
|
505
548
|
// The actual sender JID:
|
|
506
549
|
// - groups: always `participant` (remoteJid is the group)
|
|
@@ -508,17 +551,34 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
508
551
|
const actualSender = isGroup
|
|
509
552
|
? participant || rawSender
|
|
510
553
|
: (participant || rawSender);
|
|
554
|
+
const senderAlt = isGroup ? participantAlt : (participantAlt || remoteJidAlt);
|
|
511
555
|
|
|
512
|
-
// Translate LID
|
|
513
|
-
const sender = this.translateJid(actualSender);
|
|
556
|
+
// Translate LID → phone via the learned map + the message's `*Alt` fallback.
|
|
557
|
+
const sender = this.translateJid(actualSender, senderAlt);
|
|
514
558
|
const pushName = msg.pushName || undefined;
|
|
515
559
|
|
|
516
|
-
// Self-chat: only meaningful for 1:1
|
|
517
|
-
|
|
560
|
+
// Self-chat: only meaningful for 1:1. True when the (translated) chat JID is our own number
|
|
561
|
+
// AND no group participant. Both `participant` and `participantAlt` must be absent — Baileys
|
|
562
|
+
// sets `participant` on newer 1:1 messages too, so we additionally accept when the participant
|
|
563
|
+
// (or its alt) resolves to our own phone.
|
|
564
|
+
const participantResolved = participant ? this.translateJid(participant, senderAlt) : '';
|
|
565
|
+
const ownsChat = this.ownPhoneJid !== null && chatJid === this.ownPhoneJid;
|
|
566
|
+
const ownsParticipant = !participant || participantResolved === this.ownPhoneJid;
|
|
567
|
+
const isSelfChat = !isGroup && ownsChat && ownsParticipant;
|
|
518
568
|
|
|
519
569
|
log.info(`[whatsapp] Message from ${sender} (chat=${chatJid}, group=${isGroup}, fromMe=${fromMe}, selfChat=${isSelfChat}, images=${images.length}): ${text.slice(0, 80)}`);
|
|
520
570
|
|
|
521
|
-
this.onMessage(
|
|
571
|
+
this.onMessage(
|
|
572
|
+
sender,
|
|
573
|
+
pushName,
|
|
574
|
+
text,
|
|
575
|
+
fromMe,
|
|
576
|
+
isSelfChat,
|
|
577
|
+
chatJid,
|
|
578
|
+
isGroup,
|
|
579
|
+
images.length > 0 ? images : undefined,
|
|
580
|
+
msg.key,
|
|
581
|
+
);
|
|
522
582
|
}
|
|
523
583
|
});
|
|
524
584
|
}
|
|
@@ -422,10 +422,11 @@ export async function startConversation(
|
|
|
422
422
|
summary: sysMsg.summary,
|
|
423
423
|
usage: sysMsg.usage,
|
|
424
424
|
});
|
|
425
|
-
// Sub-agent completion
|
|
426
|
-
|
|
427
|
-
|
|
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
|
}
|
package/supervisor/index.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { spawnBackend, stopBackend, getBackendPort, isBackendAlive, isBackendSto
|
|
|
15
15
|
import { handleAgentQuery, type AgentQueryRequest } from './agent-api.js';
|
|
16
16
|
import { updateTunnelUrl, startHeartbeat, stopHeartbeat, disconnect } from '../shared/relay.js';
|
|
17
17
|
import {
|
|
18
|
-
startConversation,
|
|
18
|
+
startConversation, hasConversation, endConversation, endAllConversations,
|
|
19
19
|
isConversationBusy, stopSubAgentTask,
|
|
20
20
|
startBlobyAgentQuery, stopBlobyAgentQuery,
|
|
21
21
|
warmUpForLiveConversation,
|
|
@@ -365,6 +365,7 @@ export async function startSupervisor() {
|
|
|
365
365
|
'POST /api/channels/whatsapp/logout',
|
|
366
366
|
'POST /api/channels/whatsapp/configure',
|
|
367
367
|
'POST /api/channels/whatsapp/pairing-code',
|
|
368
|
+
'POST /api/channels/whatsapp/react',
|
|
368
369
|
'POST /api/channels/send',
|
|
369
370
|
];
|
|
370
371
|
|
|
@@ -735,6 +736,37 @@ ${!connected ? `<script>
|
|
|
735
736
|
return;
|
|
736
737
|
}
|
|
737
738
|
|
|
739
|
+
// POST /api/channels/whatsapp/react — send (or remove) an emoji reaction on a WhatsApp message
|
|
740
|
+
if (req.method === 'POST' && channelPath === '/api/channels/whatsapp/react') {
|
|
741
|
+
let body = '';
|
|
742
|
+
req.on('data', (chunk: Buffer) => { body += chunk.toString(); });
|
|
743
|
+
req.on('end', async () => {
|
|
744
|
+
try {
|
|
745
|
+
const { chatJid, messageId, fromMe, participant, emoji } = JSON.parse(body) as {
|
|
746
|
+
chatJid?: string;
|
|
747
|
+
messageId?: string;
|
|
748
|
+
fromMe?: boolean;
|
|
749
|
+
participant?: string;
|
|
750
|
+
emoji?: string;
|
|
751
|
+
};
|
|
752
|
+
if (!chatJid || !messageId) {
|
|
753
|
+
res.writeHead(400);
|
|
754
|
+
res.end(JSON.stringify({ error: 'chatJid and messageId are required' }));
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
// emoji='' is intentionally supported — it removes a previous reaction (Baileys convention).
|
|
758
|
+
const key = { remoteJid: chatJid, id: messageId, fromMe: !!fromMe, participant } as any;
|
|
759
|
+
await channelManager.reactToMessage('whatsapp', chatJid, key, emoji ?? '👍');
|
|
760
|
+
res.writeHead(200);
|
|
761
|
+
res.end(JSON.stringify({ ok: true }));
|
|
762
|
+
} catch (err: any) {
|
|
763
|
+
res.writeHead(500);
|
|
764
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
|
|
738
770
|
// POST /api/channels/send — send a message (and/or media) via any channel
|
|
739
771
|
if (req.method === 'POST' && channelPath === '/api/channels/send') {
|
|
740
772
|
let body = '';
|
|
@@ -1418,11 +1450,10 @@ ${!connected ? `<script>
|
|
|
1418
1450
|
currentStreamBuffer += eventData.token;
|
|
1419
1451
|
}
|
|
1420
1452
|
|
|
1421
|
-
// Route streaming text
|
|
1422
|
-
//
|
|
1423
|
-
|
|
1424
|
-
|
|
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
|
-
|
|
1486
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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.
|