bloby-bot 0.42.0 → 0.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/supervisor/channels/manager.ts +7 -60
- package/supervisor/channels/whatsapp.ts +22 -43
- package/supervisor/index.ts +7 -30
- package/worker/db.ts +0 -3
- package/worker/index.ts +1 -8
package/package.json
CHANGED
|
@@ -116,7 +116,6 @@ export class ChannelManager {
|
|
|
116
116
|
},
|
|
117
117
|
(status) => this.handleStatusChange(status),
|
|
118
118
|
(audioBase64) => this.transcribeAudio(audioBase64),
|
|
119
|
-
(fromMe, isSelfChat, isGroup) => this.shouldProcessWhatsAppAudio(fromMe, isSelfChat, isGroup),
|
|
120
119
|
);
|
|
121
120
|
this.providers.set('whatsapp', whatsapp);
|
|
122
121
|
|
|
@@ -141,7 +140,6 @@ export class ChannelManager {
|
|
|
141
140
|
},
|
|
142
141
|
(status) => this.handleStatusChange(status),
|
|
143
142
|
(audioBase64) => this.transcribeAudio(audioBase64),
|
|
144
|
-
(fromMe, isSelfChat, isGroup) => this.shouldProcessWhatsAppAudio(fromMe, isSelfChat, isGroup),
|
|
145
143
|
);
|
|
146
144
|
this.providers.set('whatsapp', whatsapp);
|
|
147
145
|
provider = whatsapp;
|
|
@@ -421,35 +419,6 @@ export class ChannelManager {
|
|
|
421
419
|
return config.channels?.[channel];
|
|
422
420
|
}
|
|
423
421
|
|
|
424
|
-
/** Decide whether an inbound WhatsApp audio is worth transcribing.
|
|
425
|
-
* Mirrors the gates in handleInboundMessage so we don't burn Whisper calls
|
|
426
|
-
* (or, worse, leak the bot via "Whisper not enabled" replies) on messages
|
|
427
|
-
* that would be filtered out anyway.
|
|
428
|
-
*
|
|
429
|
-
* Audio carries no `@bloby` text trigger, so in assistant mode we only
|
|
430
|
-
* transcribe when the audio is admin's self-chat command. */
|
|
431
|
-
private shouldProcessWhatsAppAudio(fromMe: boolean, isSelfChat: boolean, isGroup: boolean): boolean {
|
|
432
|
-
const channelConfig = this.getChannelConfig('whatsapp');
|
|
433
|
-
if (!channelConfig) return false;
|
|
434
|
-
|
|
435
|
-
const mode = channelConfig.mode || 'channel';
|
|
436
|
-
|
|
437
|
-
// Group gating mirrors handleInboundMessage.
|
|
438
|
-
if (isGroup) {
|
|
439
|
-
if (mode === 'channel') return false;
|
|
440
|
-
if (!channelConfig.allowGroups) return false;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
if (mode === 'channel') return fromMe && isSelfChat;
|
|
444
|
-
if (mode === 'assistant') return fromMe && isSelfChat;
|
|
445
|
-
if (mode === 'business') {
|
|
446
|
-
// Outbound non-self-chat messages are filtered out — same as handleInboundMessage.
|
|
447
|
-
if (fromMe && !isSelfChat) return false;
|
|
448
|
-
return true;
|
|
449
|
-
}
|
|
450
|
-
return false;
|
|
451
|
-
}
|
|
452
|
-
|
|
453
422
|
/** Handle an incoming message from any channel — debounces rapid messages from the same sender.
|
|
454
423
|
*
|
|
455
424
|
* Per-mode behavior is decided here. To add a new mode: extend the gating block below
|
|
@@ -669,25 +638,14 @@ export class ChannelManager {
|
|
|
669
638
|
const { workerApi, broadcastBloby, getModel } = this.opts;
|
|
670
639
|
const model = getModel();
|
|
671
640
|
|
|
672
|
-
// Get or create conversation (shared with chat for mirroring)
|
|
673
|
-
// The current_conversation setting can desync from the DB (e.g. a chat-UI
|
|
674
|
-
// re-mount writing back a stale cached id, or a conv that was never
|
|
675
|
-
// persisted in the first place). Verify it actually exists before using it,
|
|
676
|
-
// otherwise we'd push messages into a ghost conv and FK-fail on every write.
|
|
641
|
+
// Get or create conversation (shared with chat for mirroring)
|
|
677
642
|
let convId: string | undefined;
|
|
678
643
|
try {
|
|
679
644
|
const ctx = await workerApi('/api/context/current');
|
|
680
645
|
if (ctx.conversationId) {
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
convId = ctx.conversationId;
|
|
684
|
-
} else {
|
|
685
|
-
log.warn(`[channels] current_conversation=${ctx.conversationId} is stale (no DB row) — creating fresh`);
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
if (!convId) {
|
|
646
|
+
convId = ctx.conversationId;
|
|
647
|
+
} else {
|
|
689
648
|
const conv = await workerApi('/api/conversations', 'POST', { title: 'WhatsApp', model });
|
|
690
|
-
if (!conv?.id) throw new Error(`POST /api/conversations returned no id: ${JSON.stringify(conv)}`);
|
|
691
649
|
convId = conv.id;
|
|
692
650
|
await workerApi('/api/context/set', 'POST', { conversationId: convId });
|
|
693
651
|
}
|
|
@@ -699,20 +657,15 @@ export class ChannelManager {
|
|
|
699
657
|
// Use display text for DB/chat (hides enriched agent context from the UI)
|
|
700
658
|
const displayContent = msg.displayText || msg.text;
|
|
701
659
|
|
|
702
|
-
// Save user message to DB
|
|
703
|
-
// without throwing, so check the response body — silent drops here are
|
|
704
|
-
// the original cause of "messages not appearing in UI" reports.
|
|
660
|
+
// Save user message to DB
|
|
705
661
|
try {
|
|
706
|
-
|
|
662
|
+
await workerApi(`/api/conversations/${convId}/messages`, 'POST', {
|
|
707
663
|
role: 'user',
|
|
708
664
|
content: displayContent,
|
|
709
665
|
meta: { model, channel: msg.channel },
|
|
710
666
|
});
|
|
711
|
-
if (result?.error) {
|
|
712
|
-
log.warn(`[channels] CRITICAL: user message NOT persisted (convId=${convId}): ${result.error}`);
|
|
713
|
-
}
|
|
714
667
|
} catch (err: any) {
|
|
715
|
-
log.warn(`[channels]
|
|
668
|
+
log.warn(`[channels] DB persist error: ${err.message}`);
|
|
716
669
|
}
|
|
717
670
|
|
|
718
671
|
// Broadcast to chat clients (mirroring)
|
|
@@ -780,13 +733,7 @@ export class ChannelManager {
|
|
|
780
733
|
role: 'assistant',
|
|
781
734
|
content: eventData.content,
|
|
782
735
|
meta: { model },
|
|
783
|
-
}).
|
|
784
|
-
if (result?.error) {
|
|
785
|
-
log.warn(`[channels] CRITICAL: assistant reply NOT persisted (convId=${convId}): ${result.error}`);
|
|
786
|
-
}
|
|
787
|
-
}).catch((err: any) => {
|
|
788
|
-
log.warn(`[channels] CRITICAL: assistant reply NOT persisted (convId=${convId}): ${err.message}`);
|
|
789
|
-
});
|
|
736
|
+
}).catch(() => {});
|
|
790
737
|
}
|
|
791
738
|
|
|
792
739
|
// Handle turn completion — restart backend if file tools were used,
|
|
@@ -48,16 +48,6 @@ export type OnWhatsAppMessage = (
|
|
|
48
48
|
/** Callback to transcribe audio via whisper */
|
|
49
49
|
export type TranscribeFn = (audioBase64: string) => Promise<string | null>;
|
|
50
50
|
|
|
51
|
-
/** Callback that decides whether an audio message warrants transcription.
|
|
52
|
-
* Returning false makes the channel silently skip the audio (no Whisper call,
|
|
53
|
-
* no "Whisper not enabled" reply) — used to avoid leaking the bot in modes
|
|
54
|
-
* where the message would be filtered out downstream anyway. */
|
|
55
|
-
export type ShouldTranscribeAudioFn = (
|
|
56
|
-
fromMe: boolean,
|
|
57
|
-
isSelfChat: boolean,
|
|
58
|
-
isGroup: boolean,
|
|
59
|
-
) => boolean;
|
|
60
|
-
|
|
61
51
|
export class WhatsAppChannel implements ChannelProvider {
|
|
62
52
|
readonly type: ChannelType = 'whatsapp';
|
|
63
53
|
|
|
@@ -68,7 +58,6 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
68
58
|
private onMessage: OnWhatsAppMessage;
|
|
69
59
|
private onStatusChange: (status: ChannelStatus) => void;
|
|
70
60
|
private transcribe: TranscribeFn | null = null;
|
|
71
|
-
private shouldTranscribeAudio: ShouldTranscribeAudioFn | null = null;
|
|
72
61
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
73
62
|
private intentionalDisconnect = false;
|
|
74
63
|
|
|
@@ -87,12 +76,10 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
87
76
|
onMessage: OnWhatsAppMessage,
|
|
88
77
|
onStatusChange: (status: ChannelStatus) => void,
|
|
89
78
|
transcribe?: TranscribeFn,
|
|
90
|
-
shouldTranscribeAudio?: ShouldTranscribeAudioFn,
|
|
91
79
|
) {
|
|
92
80
|
this.onMessage = onMessage;
|
|
93
81
|
this.onStatusChange = onStatusChange;
|
|
94
82
|
this.transcribe = transcribe || null;
|
|
95
|
-
this.shouldTranscribeAudio = shouldTranscribeAudio || null;
|
|
96
83
|
}
|
|
97
84
|
|
|
98
85
|
async connect(): Promise<void> {
|
|
@@ -454,29 +441,6 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
454
441
|
continue;
|
|
455
442
|
}
|
|
456
443
|
|
|
457
|
-
// Resolve sender/chat identity up front so audio gating can consult mode/role.
|
|
458
|
-
const fromMe = msg.key.fromMe || false;
|
|
459
|
-
const rawSender = msg.key.remoteJid || '';
|
|
460
|
-
const participant = msg.key.participant || '';
|
|
461
|
-
const isGroup = rawSender.endsWith('@g.us');
|
|
462
|
-
|
|
463
|
-
// chatJid: where to reply (group JID for groups, peer JID otherwise).
|
|
464
|
-
const chatJid = rawSender;
|
|
465
|
-
|
|
466
|
-
// The actual sender JID:
|
|
467
|
-
// - groups: always `participant` (remoteJid is the group)
|
|
468
|
-
// - 1:1: `participant` if Baileys provided one (newer protocol), else remoteJid
|
|
469
|
-
const actualSender = isGroup
|
|
470
|
-
? participant || rawSender
|
|
471
|
-
: (participant || rawSender);
|
|
472
|
-
|
|
473
|
-
// Translate LID JIDs to phone JIDs (only handles our own LID)
|
|
474
|
-
const sender = this.translateJid(actualSender);
|
|
475
|
-
const pushName = msg.pushName || undefined;
|
|
476
|
-
|
|
477
|
-
// Self-chat: only meaningful for 1:1 — remoteJid is our own number AND no participant.
|
|
478
|
-
const isSelfChat = !isGroup && !participant && this.ownPhoneJid !== null && this.translateJid(rawSender) === this.ownPhoneJid;
|
|
479
|
-
|
|
480
444
|
// Extract text — or transcribe audio if it's a voice note
|
|
481
445
|
let rawText = this.extractText(msg.message);
|
|
482
446
|
const images: WhatsAppImageAttachment[] = [];
|
|
@@ -495,13 +459,6 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
495
459
|
}
|
|
496
460
|
|
|
497
461
|
if (!rawText && this.isAudioMessage(msg.message)) {
|
|
498
|
-
// Mode-aware gate: don't transcribe (and don't reveal the bot with a
|
|
499
|
-
// "Whisper not enabled" reply) when the message would be filtered out
|
|
500
|
-
// downstream — e.g. a friend's voice note in assistant mode.
|
|
501
|
-
if (this.shouldTranscribeAudio && !this.shouldTranscribeAudio(fromMe, isSelfChat, isGroup)) {
|
|
502
|
-
log.info(`[whatsapp] Audio skipped by mode gate (fromMe=${fromMe}, selfChat=${isSelfChat}, group=${isGroup})`);
|
|
503
|
-
continue;
|
|
504
|
-
}
|
|
505
462
|
// Voice note / audio — download and transcribe
|
|
506
463
|
if (!this.transcribe) {
|
|
507
464
|
log.info('[whatsapp] Audio message received but no transcribe function configured — skipping');
|
|
@@ -537,6 +494,28 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
537
494
|
// Escape special characters to prevent prompt injection via message content
|
|
538
495
|
const text = this.escapeMessageText(rawText);
|
|
539
496
|
|
|
497
|
+
const fromMe = msg.key.fromMe || false;
|
|
498
|
+
const rawSender = msg.key.remoteJid || '';
|
|
499
|
+
const participant = msg.key.participant || '';
|
|
500
|
+
const isGroup = rawSender.endsWith('@g.us');
|
|
501
|
+
|
|
502
|
+
// chatJid: where to reply (group JID for groups, peer JID otherwise).
|
|
503
|
+
const chatJid = rawSender;
|
|
504
|
+
|
|
505
|
+
// The actual sender JID:
|
|
506
|
+
// - groups: always `participant` (remoteJid is the group)
|
|
507
|
+
// - 1:1: `participant` if Baileys provided one (newer protocol), else remoteJid
|
|
508
|
+
const actualSender = isGroup
|
|
509
|
+
? participant || rawSender
|
|
510
|
+
: (participant || rawSender);
|
|
511
|
+
|
|
512
|
+
// Translate LID JIDs to phone JIDs (only handles our own LID)
|
|
513
|
+
const sender = this.translateJid(actualSender);
|
|
514
|
+
const pushName = msg.pushName || undefined;
|
|
515
|
+
|
|
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;
|
|
518
|
+
|
|
540
519
|
log.info(`[whatsapp] Message from ${sender} (chat=${chatJid}, group=${isGroup}, fromMe=${fromMe}, selfChat=${isSelfChat}, images=${images.length}): ${text.slice(0, 80)}`);
|
|
541
520
|
|
|
542
521
|
this.onMessage(sender, pushName, text, fromMe, isSelfChat, chatJid, isGroup, images.length > 0 ? images : undefined);
|
package/supervisor/index.ts
CHANGED
|
@@ -1325,33 +1325,16 @@ ${!connected ? `<script>
|
|
|
1325
1325
|
}
|
|
1326
1326
|
|
|
1327
1327
|
try {
|
|
1328
|
-
//
|
|
1329
|
-
// before reusing it. clientConvs and current_conversation can both
|
|
1330
|
-
// hold stale ids (e.g. after a manual DB swap or a chat-UI
|
|
1331
|
-
// re-mount writing back a cached id) — pushing into a ghost conv
|
|
1332
|
-
// FK-fails silently and loses every message.
|
|
1328
|
+
// Check if we have an existing conversation for this client
|
|
1333
1329
|
let dbConvId = clientConvs.get(ws);
|
|
1334
|
-
if (dbConvId) {
|
|
1335
|
-
const verify = await workerApi(`/api/conversations/${dbConvId}/exists`);
|
|
1336
|
-
if (!verify?.exists) {
|
|
1337
|
-
log.warn(`[bloby] cached convId ${dbConvId} is stale (no DB row) — discarding`);
|
|
1338
|
-
dbConvId = undefined;
|
|
1339
|
-
clientConvs.delete(ws);
|
|
1340
|
-
}
|
|
1341
|
-
}
|
|
1342
1330
|
if (!dbConvId) {
|
|
1331
|
+
// Check if there's a current conversation set in settings
|
|
1343
1332
|
const ctx = await workerApi('/api/context/current');
|
|
1344
1333
|
if (ctx.conversationId) {
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
} else {
|
|
1349
|
-
log.warn(`[bloby] current_conversation=${ctx.conversationId} is stale (no DB row) — creating fresh`);
|
|
1350
|
-
}
|
|
1351
|
-
}
|
|
1352
|
-
if (!dbConvId) {
|
|
1334
|
+
dbConvId = ctx.conversationId;
|
|
1335
|
+
} else {
|
|
1336
|
+
// Create a new conversation
|
|
1353
1337
|
const conv = await workerApi('/api/conversations', 'POST', { title: content.slice(0, 80), model: freshConfig.ai.model });
|
|
1354
|
-
if (!conv?.id) throw new Error(`POST /api/conversations returned no id: ${JSON.stringify(conv)}`);
|
|
1355
1338
|
dbConvId = conv.id;
|
|
1356
1339
|
await workerApi('/api/context/set', 'POST', { conversationId: dbConvId });
|
|
1357
1340
|
}
|
|
@@ -1370,12 +1353,9 @@ ${!connected ? `<script>
|
|
|
1370
1353
|
type: f.type, name: f.name, mediaType: f.mediaType, filePath: f.relPath,
|
|
1371
1354
|
})));
|
|
1372
1355
|
}
|
|
1373
|
-
|
|
1356
|
+
await workerApi(`/api/conversations/${convId}/messages`, 'POST', {
|
|
1374
1357
|
role: 'user', content, meta,
|
|
1375
1358
|
});
|
|
1376
|
-
if (result?.error) {
|
|
1377
|
-
log.warn(`[bloby] CRITICAL: user message NOT persisted (convId=${convId}): ${result.error}`);
|
|
1378
|
-
}
|
|
1379
1359
|
|
|
1380
1360
|
// Broadcast user message to other clients
|
|
1381
1361
|
broadcastBlobyExcept(ws, 'chat:sync', {
|
|
@@ -1487,12 +1467,9 @@ ${!connected ? `<script>
|
|
|
1487
1467
|
|
|
1488
1468
|
(async () => {
|
|
1489
1469
|
try {
|
|
1490
|
-
|
|
1470
|
+
await workerApi(`/api/conversations/${convId}/messages`, 'POST', {
|
|
1491
1471
|
role: 'assistant', content: eventData.content, meta: { model: freshConfig.ai.model },
|
|
1492
1472
|
});
|
|
1493
|
-
if (result?.error) {
|
|
1494
|
-
log.warn(`[bloby] CRITICAL: assistant reply NOT persisted (convId=${convId}): ${result.error}`);
|
|
1495
|
-
}
|
|
1496
1473
|
} catch (err: any) {
|
|
1497
1474
|
log.warn(`[bloby] DB persist bot response error: ${err.message}`);
|
|
1498
1475
|
}
|
package/worker/db.ts
CHANGED
|
@@ -90,9 +90,6 @@ export function listConversations(limit = 50) {
|
|
|
90
90
|
export function deleteConversation(id: string) {
|
|
91
91
|
db.prepare('DELETE FROM conversations WHERE id = ?').run(id);
|
|
92
92
|
}
|
|
93
|
-
export function conversationExists(id: string): boolean {
|
|
94
|
-
return !!db.prepare('SELECT 1 FROM conversations WHERE id = ?').get(id);
|
|
95
|
-
}
|
|
96
93
|
|
|
97
94
|
// Messages
|
|
98
95
|
export function addMessage(convId: string, role: string, content: string, meta?: { tokens_in?: number; tokens_out?: number; model?: string; audio_data?: string; attachments?: string }) {
|
package/worker/index.ts
CHANGED
|
@@ -5,7 +5,7 @@ import path from 'path';
|
|
|
5
5
|
import { loadConfig, saveConfig } from '../shared/config.js';
|
|
6
6
|
import { paths, WORKSPACE_DIR } from '../shared/paths.js';
|
|
7
7
|
import { log } from '../shared/logger.js';
|
|
8
|
-
import { initDb, closeDb, listConversations, createConversation, deleteConversation,
|
|
8
|
+
import { initDb, closeDb, listConversations, createConversation, deleteConversation, getMessages, addMessage, getSetting, getAllSettings, setSetting, createSession, getSession, deleteExpiredSessions, getRecentMessages, getMessagesBefore, addPushSubscription, removePushSubscription, getAllPushSubscriptions, getPushSubscriptionByEndpoint, createTrustedDevice, getTrustedDevice, updateDeviceLastSeen, listTrustedDevices, deleteTrustedDevice, deleteExpiredDevices, deleteAllTrustedDevices } from './db.js';
|
|
9
9
|
import webpush from 'web-push';
|
|
10
10
|
import { TOTP } from 'otpauth';
|
|
11
11
|
import QRCode from 'qrcode';
|
|
@@ -120,9 +120,6 @@ app.get('/api/conversations/:id', (req, res) => {
|
|
|
120
120
|
const msgs = getMessages(req.params.id);
|
|
121
121
|
res.json({ id: req.params.id, messages: msgs });
|
|
122
122
|
});
|
|
123
|
-
app.get('/api/conversations/:id/exists', (req, res) => {
|
|
124
|
-
res.json({ exists: conversationExists(req.params.id) });
|
|
125
|
-
});
|
|
126
123
|
app.post('/api/conversations', (req, res) => {
|
|
127
124
|
const { title, model } = req.body || {};
|
|
128
125
|
const conv = createConversation(title, model);
|
|
@@ -131,10 +128,6 @@ app.post('/api/conversations', (req, res) => {
|
|
|
131
128
|
app.post('/api/conversations/:id/messages', (req, res) => {
|
|
132
129
|
const { role, content, meta } = req.body || {};
|
|
133
130
|
if (!role || !content) { res.status(400).json({ error: 'Missing role or content' }); return; }
|
|
134
|
-
if (!conversationExists(req.params.id)) {
|
|
135
|
-
res.status(404).json({ error: 'conversation_not_found', conversationId: req.params.id });
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
131
|
const msg = addMessage(req.params.id, role, content, meta);
|
|
139
132
|
res.json(msg);
|
|
140
133
|
});
|