fluxy-bot 0.13.6 → 0.15.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 -2
- package/shared/config.ts +0 -12
- package/supervisor/fluxy-agent.ts +0 -145
- package/supervisor/index.ts +0 -30
- package/supervisor/scheduler.ts +0 -9
- package/worker/db.ts +0 -62
- package/worker/index.ts +1 -42
- package/supervisor/channels/chat-channel.ts +0 -44
- package/supervisor/channels/index.ts +0 -56
- package/supervisor/channels/role-resolver.ts +0 -41
- package/supervisor/channels/router.ts +0 -245
- package/supervisor/channels/types.ts +0 -55
- package/supervisor/channels/whatsapp-channel.ts +0 -177
- package/worker/prompts/customer-system-prompt.txt +0 -46
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fluxy-bot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"releaseNotes": [
|
|
5
5
|
"1. react router implemented",
|
|
6
6
|
"2. new workspace design",
|
|
@@ -55,7 +55,6 @@
|
|
|
55
55
|
"@clack/prompts": "^1.1.0",
|
|
56
56
|
"@tailwindcss/vite": "^4.2.0",
|
|
57
57
|
"@vitejs/plugin-react": "^6.0.1",
|
|
58
|
-
"@whiskeysockets/baileys": "^7.0.0-rc.9",
|
|
59
58
|
"better-sqlite3": "^12.6.2",
|
|
60
59
|
"class-variance-authority": "^0.7.1",
|
|
61
60
|
"clsx": "^2.1.1",
|
package/shared/config.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import { paths, DATA_DIR } from './paths.js';
|
|
3
3
|
|
|
4
|
-
export type ChannelType = 'chat' | 'whatsapp' | 'telegram';
|
|
5
|
-
|
|
6
4
|
export interface BotConfig {
|
|
7
5
|
port: number;
|
|
8
6
|
username: string;
|
|
@@ -28,16 +26,6 @@ export interface BotConfig {
|
|
|
28
26
|
address: string;
|
|
29
27
|
};
|
|
30
28
|
tunnelUrl?: string;
|
|
31
|
-
channels?: {
|
|
32
|
-
whatsapp?: { enabled: boolean };
|
|
33
|
-
telegram?: { enabled: boolean; botToken?: string };
|
|
34
|
-
};
|
|
35
|
-
customerMode?: {
|
|
36
|
-
enabled: boolean;
|
|
37
|
-
systemPromptFile?: string;
|
|
38
|
-
businessName?: string;
|
|
39
|
-
businessDescription?: string;
|
|
40
|
-
};
|
|
41
29
|
}
|
|
42
30
|
|
|
43
31
|
const DEFAULTS: BotConfig = {
|
|
@@ -12,16 +12,6 @@ import type { SavedFile } from './file-saver.js';
|
|
|
12
12
|
import { getClaudeAccessToken } from '../worker/claude-auth.js';
|
|
13
13
|
|
|
14
14
|
const PROMPT_FILE = path.join(import.meta.dirname, '..', 'worker', 'prompts', 'fluxy-system-prompt.txt');
|
|
15
|
-
const CUSTOMER_PROMPT_FILE = path.join(import.meta.dirname, '..', 'worker', 'prompts', 'customer-system-prompt.txt');
|
|
16
|
-
|
|
17
|
-
/** Default tools disallowed for customer conversations. Edit this list to control customer access. */
|
|
18
|
-
const CUSTOMER_DISALLOWED_TOOLS = [
|
|
19
|
-
'Bash',
|
|
20
|
-
// Add more tools here to restrict customer access, e.g.:
|
|
21
|
-
// 'Write',
|
|
22
|
-
// 'Edit',
|
|
23
|
-
// 'Agent',
|
|
24
|
-
];
|
|
25
15
|
|
|
26
16
|
export interface RecentMessage {
|
|
27
17
|
role: 'user' | 'assistant';
|
|
@@ -299,141 +289,6 @@ export async function startFluxyAgentQuery(
|
|
|
299
289
|
}
|
|
300
290
|
}
|
|
301
291
|
|
|
302
|
-
/**
|
|
303
|
-
* Read the customer-facing system prompt, replacing placeholders.
|
|
304
|
-
*/
|
|
305
|
-
function readCustomerSystemPrompt(ctx: CustomerContext): string {
|
|
306
|
-
try {
|
|
307
|
-
const raw = fs.readFileSync(CUSTOMER_PROMPT_FILE, 'utf-8').trim();
|
|
308
|
-
if (!raw) return `You are a helpful business assistant for ${ctx.businessName}.`;
|
|
309
|
-
return raw
|
|
310
|
-
.replace(/\$BUSINESS_NAME/g, ctx.businessName)
|
|
311
|
-
.replace(/\$BUSINESS_DESCRIPTION/g, ctx.businessDescription)
|
|
312
|
-
.replace(/\$SENDER_NAME/g, ctx.senderName)
|
|
313
|
-
.replace(/\$CHANNEL/g, ctx.channelType)
|
|
314
|
-
.replace(/\$DISALLOWED_TOOLS/g, CUSTOMER_DISALLOWED_TOOLS.join(', ') || '(none)');
|
|
315
|
-
} catch {
|
|
316
|
-
return `You are a helpful business assistant for ${ctx.businessName}.`;
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
export interface CustomerContext {
|
|
321
|
-
senderName: string;
|
|
322
|
-
senderIdentifier: string;
|
|
323
|
-
channelType: string;
|
|
324
|
-
businessName: string;
|
|
325
|
-
businessDescription: string;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
/**
|
|
329
|
-
* Run an Agent SDK query for a customer conversation.
|
|
330
|
-
* Restricted: different system prompt, limited tools, no memory files, no plugins, no MCP.
|
|
331
|
-
*/
|
|
332
|
-
export async function startCustomerAgentQuery(
|
|
333
|
-
conversationKey: string,
|
|
334
|
-
prompt: string,
|
|
335
|
-
model: string,
|
|
336
|
-
onMessage: (type: string, data: any) => void,
|
|
337
|
-
customerContext: CustomerContext,
|
|
338
|
-
recentMessages?: RecentMessage[],
|
|
339
|
-
): Promise<void> {
|
|
340
|
-
const oauthToken = await getClaudeAccessToken();
|
|
341
|
-
if (!oauthToken) {
|
|
342
|
-
onMessage('bot:error', { conversationId: conversationKey, error: 'Claude OAuth token not found.' });
|
|
343
|
-
return;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
const abortController = new AbortController();
|
|
347
|
-
let customerPrompt = readCustomerSystemPrompt(customerContext);
|
|
348
|
-
|
|
349
|
-
if (recentMessages?.length) {
|
|
350
|
-
customerPrompt = customerPrompt.replace(
|
|
351
|
-
'$CONVERSATION_HISTORY',
|
|
352
|
-
formatConversationHistory(recentMessages),
|
|
353
|
-
);
|
|
354
|
-
} else {
|
|
355
|
-
customerPrompt = customerPrompt.replace('$CONVERSATION_HISTORY', '(none)');
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
activeQueries.set(conversationKey, { abortController });
|
|
359
|
-
|
|
360
|
-
let fullText = '';
|
|
361
|
-
let stderrBuf = '';
|
|
362
|
-
|
|
363
|
-
try {
|
|
364
|
-
const claudeQuery = query({
|
|
365
|
-
prompt,
|
|
366
|
-
options: {
|
|
367
|
-
model,
|
|
368
|
-
cwd: WORKSPACE_DIR,
|
|
369
|
-
permissionMode: 'bypassPermissions',
|
|
370
|
-
allowDangerouslySkipPermissions: true,
|
|
371
|
-
disallowedTools: CUSTOMER_DISALLOWED_TOOLS,
|
|
372
|
-
maxTurns: 10,
|
|
373
|
-
abortController,
|
|
374
|
-
systemPrompt: customerPrompt,
|
|
375
|
-
// No plugins, no MCP servers for customers
|
|
376
|
-
stderr: (chunk: string) => { stderrBuf += chunk; },
|
|
377
|
-
env: {
|
|
378
|
-
...process.env as Record<string, string>,
|
|
379
|
-
CLAUDE_CODE_OAUTH_TOKEN: oauthToken,
|
|
380
|
-
CLAUDE_CODE_BUBBLEWRAP: '1',
|
|
381
|
-
},
|
|
382
|
-
},
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
onMessage('bot:typing', { conversationId: conversationKey });
|
|
386
|
-
|
|
387
|
-
for await (const msg of claudeQuery) {
|
|
388
|
-
if (abortController.signal.aborted) break;
|
|
389
|
-
|
|
390
|
-
switch (msg.type) {
|
|
391
|
-
case 'assistant': {
|
|
392
|
-
const assistantMsg = msg.message;
|
|
393
|
-
if (!assistantMsg?.content) break;
|
|
394
|
-
for (const block of assistantMsg.content) {
|
|
395
|
-
if (block.type === 'text' && block.text) {
|
|
396
|
-
if (fullText && !fullText.endsWith('\n')) {
|
|
397
|
-
fullText += '\n\n';
|
|
398
|
-
onMessage('bot:token', { conversationId: conversationKey, token: '\n\n' });
|
|
399
|
-
}
|
|
400
|
-
fullText += block.text;
|
|
401
|
-
onMessage('bot:token', { conversationId: conversationKey, token: block.text });
|
|
402
|
-
} else if (block.type === 'tool_use') {
|
|
403
|
-
onMessage('bot:tool', { conversationId: conversationKey, name: block.name, input: block.input });
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
break;
|
|
407
|
-
}
|
|
408
|
-
case 'result': {
|
|
409
|
-
if (fullText) {
|
|
410
|
-
onMessage('bot:response', { conversationId: conversationKey, content: fullText });
|
|
411
|
-
fullText = '';
|
|
412
|
-
} else if (msg.subtype?.startsWith('error')) {
|
|
413
|
-
const errorText = (msg as any).errors?.join('; ') || 'Agent query failed';
|
|
414
|
-
onMessage('bot:error', { conversationId: conversationKey, error: errorText });
|
|
415
|
-
}
|
|
416
|
-
break;
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
if (fullText && !abortController.signal.aborted) {
|
|
422
|
-
onMessage('bot:response', { conversationId: conversationKey, content: fullText });
|
|
423
|
-
}
|
|
424
|
-
} catch (err: any) {
|
|
425
|
-
if (!abortController.signal.aborted) {
|
|
426
|
-
const detail = stderrBuf.trim();
|
|
427
|
-
const msg = detail ? `${err.message}\n\nCLI stderr:\n${detail}` : err.message;
|
|
428
|
-
log.warn(`Customer agent error (${conversationKey}): ${msg}`);
|
|
429
|
-
onMessage('bot:error', { conversationId: conversationKey, error: msg });
|
|
430
|
-
}
|
|
431
|
-
} finally {
|
|
432
|
-
activeQueries.delete(conversationKey);
|
|
433
|
-
onMessage('bot:done', { conversationId: conversationKey, usedFileTools: false });
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
|
|
437
292
|
/** Stop an in-flight query */
|
|
438
293
|
export function stopFluxyAgentQuery(conversationId: string): void {
|
|
439
294
|
const q = activeQueries.get(conversationId);
|
package/supervisor/index.ts
CHANGED
|
@@ -17,7 +17,6 @@ import { startFluxyAgentQuery, stopFluxyAgentQuery, type RecentMessage } from '.
|
|
|
17
17
|
import { ensureFileDirs, saveAttachment, type SavedFile } from './file-saver.js';
|
|
18
18
|
import { startViteDevServers, stopViteDevServers } from './vite-dev.js';
|
|
19
19
|
import { startScheduler, stopScheduler } from './scheduler.js';
|
|
20
|
-
import { initializeChannels, type ChannelRouter } from './channels/index.js';
|
|
21
20
|
import { execSync, spawn as cpSpawn } from 'child_process';
|
|
22
21
|
import crypto from 'crypto';
|
|
23
22
|
|
|
@@ -1035,25 +1034,6 @@ export async function startSupervisor() {
|
|
|
1035
1034
|
// Spawn backend (worker runs in-process)
|
|
1036
1035
|
spawnBackend(backendPort);
|
|
1037
1036
|
|
|
1038
|
-
// Initialize multi-channel router
|
|
1039
|
-
let channelRouter: ChannelRouter | null = null;
|
|
1040
|
-
(async () => {
|
|
1041
|
-
try {
|
|
1042
|
-
channelRouter = await initializeChannels(config, {
|
|
1043
|
-
workerApi,
|
|
1044
|
-
broadcastFluxy,
|
|
1045
|
-
restartBackend: async () => {
|
|
1046
|
-
resetBackendRestarts();
|
|
1047
|
-
await stopBackend();
|
|
1048
|
-
spawnBackend(backendPort);
|
|
1049
|
-
},
|
|
1050
|
-
getModel: () => loadConfig().ai.model,
|
|
1051
|
-
});
|
|
1052
|
-
} catch (err: any) {
|
|
1053
|
-
log.warn(`[channels] Initialization failed: ${err.message}`);
|
|
1054
|
-
}
|
|
1055
|
-
})();
|
|
1056
|
-
|
|
1057
1037
|
// Start pulse/cron scheduler
|
|
1058
1038
|
startScheduler({
|
|
1059
1039
|
broadcastFluxy,
|
|
@@ -1064,7 +1044,6 @@ export async function startSupervisor() {
|
|
|
1064
1044
|
spawnBackend(backendPort);
|
|
1065
1045
|
},
|
|
1066
1046
|
getModel: () => loadConfig().ai.model,
|
|
1067
|
-
channelRouter: () => channelRouter,
|
|
1068
1047
|
});
|
|
1069
1048
|
|
|
1070
1049
|
// Watch workspace files for changes — auto-restart backend
|
|
@@ -1268,18 +1247,9 @@ export async function startSupervisor() {
|
|
|
1268
1247
|
}, 30_000);
|
|
1269
1248
|
}
|
|
1270
1249
|
|
|
1271
|
-
// Expose channel router for external channel registration (e.g., WhatsApp skill)
|
|
1272
|
-
(globalThis as any).__fluxyChannelRouter = () => channelRouter;
|
|
1273
|
-
|
|
1274
1250
|
// Shutdown
|
|
1275
1251
|
const shutdown = async () => {
|
|
1276
1252
|
log.info('Shutting down...');
|
|
1277
|
-
// Shutdown all channels gracefully
|
|
1278
|
-
if (channelRouter) {
|
|
1279
|
-
for (const channel of channelRouter.getAllChannels()) {
|
|
1280
|
-
try { await channel.shutdown(); } catch {}
|
|
1281
|
-
}
|
|
1282
|
-
}
|
|
1283
1253
|
stopScheduler();
|
|
1284
1254
|
backendWatcher.close();
|
|
1285
1255
|
workspaceWatcher.close();
|
package/supervisor/scheduler.ts
CHANGED
|
@@ -33,7 +33,6 @@ interface SchedulerOpts {
|
|
|
33
33
|
workerApi: (path: string, method?: string, body?: any) => Promise<any>;
|
|
34
34
|
restartBackend: () => void;
|
|
35
35
|
getModel: () => string;
|
|
36
|
-
channelRouter?: () => import('./channels/router.js').ChannelRouter | null;
|
|
37
36
|
}
|
|
38
37
|
|
|
39
38
|
// State
|
|
@@ -202,14 +201,6 @@ function triggerAgent(prompt: string, label: string, onComplete?: () => void) {
|
|
|
202
201
|
}).catch((err: any) => {
|
|
203
202
|
log.warn(`[scheduler] Push send failed: ${err.message}`);
|
|
204
203
|
});
|
|
205
|
-
|
|
206
|
-
// Route pulse/cron messages to all owner channels (WhatsApp, Telegram, etc.)
|
|
207
|
-
const router = schedulerOpts?.channelRouter?.();
|
|
208
|
-
if (router) {
|
|
209
|
-
router.routePulseMessage(messageContent, titleMatch?.[1]).catch((err: any) => {
|
|
210
|
-
log.warn(`[scheduler] Channel routing failed: ${err.message}`);
|
|
211
|
-
});
|
|
212
|
-
}
|
|
213
204
|
}
|
|
214
205
|
}
|
|
215
206
|
|
package/worker/db.ts
CHANGED
|
@@ -48,16 +48,6 @@ CREATE TABLE IF NOT EXISTS trusted_devices (
|
|
|
48
48
|
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
49
49
|
);
|
|
50
50
|
CREATE INDEX IF NOT EXISTS idx_td_token ON trusted_devices(token);
|
|
51
|
-
CREATE TABLE IF NOT EXISTS contacts (
|
|
52
|
-
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))),
|
|
53
|
-
channel_type TEXT NOT NULL,
|
|
54
|
-
identifier TEXT NOT NULL,
|
|
55
|
-
display_name TEXT,
|
|
56
|
-
role TEXT NOT NULL DEFAULT 'customer' CHECK (role IN ('owner', 'admin', 'customer')),
|
|
57
|
-
notes TEXT,
|
|
58
|
-
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
59
|
-
UNIQUE(channel_type, identifier)
|
|
60
|
-
);
|
|
61
51
|
`;
|
|
62
52
|
|
|
63
53
|
let db: Database.Database;
|
|
@@ -86,18 +76,6 @@ export function initDb(): void {
|
|
|
86
76
|
if (!msgCols2.some((c) => c.name === 'attachments')) {
|
|
87
77
|
db.exec('ALTER TABLE messages ADD COLUMN attachments TEXT');
|
|
88
78
|
}
|
|
89
|
-
|
|
90
|
-
// Migration: add channel columns to conversations (multi-channel support)
|
|
91
|
-
const convCols = db.prepare("PRAGMA table_info(conversations)").all() as { name: string }[];
|
|
92
|
-
if (!convCols.some((c) => c.name === 'channel_type')) {
|
|
93
|
-
db.exec("ALTER TABLE conversations ADD COLUMN channel_type TEXT DEFAULT 'chat'");
|
|
94
|
-
}
|
|
95
|
-
if (!convCols.some((c) => c.name === 'conversation_key')) {
|
|
96
|
-
db.exec('ALTER TABLE conversations ADD COLUMN conversation_key TEXT');
|
|
97
|
-
}
|
|
98
|
-
if (!convCols.some((c) => c.name === 'sender_role')) {
|
|
99
|
-
db.exec("ALTER TABLE conversations ADD COLUMN sender_role TEXT DEFAULT 'owner'");
|
|
100
|
-
}
|
|
101
79
|
}
|
|
102
80
|
|
|
103
81
|
export function closeDb(): void { db?.close(); }
|
|
@@ -218,43 +196,3 @@ export function getMessagesBefore(convId: string, beforeId: string, limit = 20)
|
|
|
218
196
|
) sub ORDER BY id ASC
|
|
219
197
|
`).all(convId, beforeId, limit);
|
|
220
198
|
}
|
|
221
|
-
|
|
222
|
-
// ── Contacts (multi-channel role management) ──
|
|
223
|
-
|
|
224
|
-
export function listContacts() {
|
|
225
|
-
return db.prepare('SELECT * FROM contacts ORDER BY created_at DESC').all();
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
export function resolveContact(channelType: string, identifier: string): { role: string } | undefined {
|
|
229
|
-
return db.prepare('SELECT role FROM contacts WHERE channel_type = ? AND identifier = ?').get(channelType, identifier) as any;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
export function addContact(channelType: string, identifier: string, role: string, displayName?: string, notes?: string) {
|
|
233
|
-
return db.prepare(
|
|
234
|
-
'INSERT INTO contacts (channel_type, identifier, role, display_name, notes) VALUES (?, ?, ?, ?, ?) RETURNING *'
|
|
235
|
-
).get(channelType, identifier, role, displayName ?? null, notes ?? null) as any;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
export function updateContact(id: string, updates: { role?: string; display_name?: string; notes?: string }) {
|
|
239
|
-
const sets: string[] = [];
|
|
240
|
-
const vals: any[] = [];
|
|
241
|
-
if (updates.role !== undefined) { sets.push('role = ?'); vals.push(updates.role); }
|
|
242
|
-
if (updates.display_name !== undefined) { sets.push('display_name = ?'); vals.push(updates.display_name); }
|
|
243
|
-
if (updates.notes !== undefined) { sets.push('notes = ?'); vals.push(updates.notes); }
|
|
244
|
-
if (!sets.length) return;
|
|
245
|
-
vals.push(id);
|
|
246
|
-
db.prepare(`UPDATE contacts SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
export function deleteContact(id: string) {
|
|
250
|
-
db.prepare('DELETE FROM contacts WHERE id = ?').run(id);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// Find or create a conversation for a given conversation key
|
|
254
|
-
export function getOrCreateConversation(conversationKey: string, channelType: string, senderRole: string, title?: string, model?: string) {
|
|
255
|
-
const existing = db.prepare('SELECT * FROM conversations WHERE conversation_key = ? ORDER BY updated_at DESC LIMIT 1').get(conversationKey) as any;
|
|
256
|
-
if (existing) return existing;
|
|
257
|
-
return db.prepare(
|
|
258
|
-
'INSERT INTO conversations (title, model, channel_type, conversation_key, sender_role) VALUES (?, ?, ?, ?, ?) RETURNING *'
|
|
259
|
-
).get(title ?? 'Chat', model ?? null, channelType, conversationKey, senderRole) as any;
|
|
260
|
-
}
|
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, getMessages, addMessage, getSetting, getAllSettings, setSetting, createSession, getSession, deleteExpiredSessions, getRecentMessages, getMessagesBefore, addPushSubscription, removePushSubscription, getAllPushSubscriptions, getPushSubscriptionByEndpoint, createTrustedDevice, getTrustedDevice, updateDeviceLastSeen, listTrustedDevices, deleteTrustedDevice, deleteExpiredDevices, deleteAllTrustedDevices
|
|
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';
|
|
@@ -877,47 +877,6 @@ app.post('/api/whisper/transcribe', express.json({ limit: '10mb' }), async (req,
|
|
|
877
877
|
}
|
|
878
878
|
});
|
|
879
879
|
|
|
880
|
-
// ── Contacts (multi-channel role management) ──
|
|
881
|
-
|
|
882
|
-
app.get('/api/contacts', (_req, res) => {
|
|
883
|
-
res.json(listContacts());
|
|
884
|
-
});
|
|
885
|
-
|
|
886
|
-
app.get('/api/contacts/resolve', (req, res) => {
|
|
887
|
-
const channelType = req.query.channel_type as string;
|
|
888
|
-
const identifier = req.query.identifier as string;
|
|
889
|
-
if (!channelType || !identifier) { res.status(400).json({ error: 'Missing channel_type or identifier' }); return; }
|
|
890
|
-
const contact = resolveContact(channelType, identifier);
|
|
891
|
-
res.json({ role: contact?.role || 'customer' });
|
|
892
|
-
});
|
|
893
|
-
|
|
894
|
-
app.post('/api/contacts', (req, res) => {
|
|
895
|
-
const { channel_type, identifier, role, display_name, notes } = req.body || {};
|
|
896
|
-
if (!channel_type || !identifier || !role) { res.status(400).json({ error: 'Missing channel_type, identifier, or role' }); return; }
|
|
897
|
-
if (!['owner', 'admin', 'customer'].includes(role)) { res.status(400).json({ error: 'Invalid role' }); return; }
|
|
898
|
-
try {
|
|
899
|
-
const contact = addContact(channel_type, identifier, role, display_name, notes);
|
|
900
|
-
res.json(contact);
|
|
901
|
-
} catch (err: any) {
|
|
902
|
-
if (err.message?.includes('UNIQUE constraint')) {
|
|
903
|
-
res.status(409).json({ error: 'Contact already exists for this channel and identifier' });
|
|
904
|
-
} else {
|
|
905
|
-
res.status(500).json({ error: err.message });
|
|
906
|
-
}
|
|
907
|
-
}
|
|
908
|
-
});
|
|
909
|
-
|
|
910
|
-
app.put('/api/contacts/:id', (req, res) => {
|
|
911
|
-
const { role, display_name, notes } = req.body || {};
|
|
912
|
-
updateContact(req.params.id, { role, display_name, notes });
|
|
913
|
-
res.json({ ok: true });
|
|
914
|
-
});
|
|
915
|
-
|
|
916
|
-
app.delete('/api/contacts/:id', (req, res) => {
|
|
917
|
-
deleteContact(req.params.id);
|
|
918
|
-
res.json({ ok: true });
|
|
919
|
-
});
|
|
920
|
-
|
|
921
880
|
// Serve stored files (audio, images, documents)
|
|
922
881
|
app.use('/api/files', express.static(paths.files));
|
|
923
882
|
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Chat Channel — wraps the existing WebSocket chat as a Channel.
|
|
3
|
-
* Chat is ALWAYS the owner channel. This adapter bridges the existing
|
|
4
|
-
* WS broadcast system with the Channel interface.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import type { Channel, ChannelRouter, OutgoingMessage } from './types.js';
|
|
8
|
-
|
|
9
|
-
export interface ChatChannelOpts {
|
|
10
|
-
broadcastFluxy: (type: string, data: any) => void;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export class ChatChannel implements Channel {
|
|
14
|
-
readonly type = 'chat' as const;
|
|
15
|
-
private opts: ChatChannelOpts;
|
|
16
|
-
|
|
17
|
-
constructor(opts: ChatChannelOpts) {
|
|
18
|
-
this.opts = opts;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
async initialize(_router: ChannelRouter): Promise<void> {
|
|
22
|
-
// Chat channel is always initialized — WS server is managed by supervisor
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
async sendMessage(_to: string, message: OutgoingMessage): Promise<void> {
|
|
26
|
-
// Route through existing broadcast system
|
|
27
|
-
this.opts.broadcastFluxy('chat:sync', {
|
|
28
|
-
conversationId: message.conversationKey,
|
|
29
|
-
message: {
|
|
30
|
-
role: 'assistant',
|
|
31
|
-
content: message.content,
|
|
32
|
-
timestamp: new Date().toISOString(),
|
|
33
|
-
},
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
ownsConversation(conversationKey: string): boolean {
|
|
38
|
-
return conversationKey.startsWith('chat:');
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
async shutdown(): Promise<void> {
|
|
42
|
-
// WS server lifecycle is managed by supervisor
|
|
43
|
-
}
|
|
44
|
-
}
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Channel registry — initializes and registers all enabled channels.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import type { BotConfig } from '../../shared/config.js';
|
|
6
|
-
import { log } from '../../shared/logger.js';
|
|
7
|
-
import { initRoleResolver } from './role-resolver.js';
|
|
8
|
-
import { ChannelRouter, type ChannelRouterOpts } from './router.js';
|
|
9
|
-
import { ChatChannel } from './chat-channel.js';
|
|
10
|
-
|
|
11
|
-
export { ChannelRouter } from './router.js';
|
|
12
|
-
export type { Channel, ChannelType, SenderRole, SenderIdentity, IncomingMessage, OutgoingMessage } from './types.js';
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Create and initialize the channel router with all enabled channels.
|
|
16
|
-
*/
|
|
17
|
-
export async function initializeChannels(
|
|
18
|
-
config: BotConfig,
|
|
19
|
-
opts: ChannelRouterOpts,
|
|
20
|
-
): Promise<ChannelRouter> {
|
|
21
|
-
// Initialize the role resolver with worker API access
|
|
22
|
-
initRoleResolver({ workerApi: opts.workerApi });
|
|
23
|
-
|
|
24
|
-
// Create the router
|
|
25
|
-
const router = new ChannelRouter(opts);
|
|
26
|
-
|
|
27
|
-
// Chat channel is always registered
|
|
28
|
-
const chatChannel = new ChatChannel({ broadcastFluxy: opts.broadcastFluxy });
|
|
29
|
-
router.registerChannel(chatChannel);
|
|
30
|
-
await chatChannel.initialize(router);
|
|
31
|
-
|
|
32
|
-
// WhatsApp channel — dynamic import so supervisor starts even without baileys installed
|
|
33
|
-
if (config.channels?.whatsapp?.enabled) {
|
|
34
|
-
try {
|
|
35
|
-
const { WhatsAppChannel } = await import('./whatsapp-channel.js');
|
|
36
|
-
const waChannel = new WhatsAppChannel((qr) => {
|
|
37
|
-
opts.broadcastFluxy('whatsapp:qr', { qr });
|
|
38
|
-
});
|
|
39
|
-
router.registerChannel(waChannel);
|
|
40
|
-
await waChannel.initialize(router);
|
|
41
|
-
} catch (err: any) {
|
|
42
|
-
log.warn(`[channels] WhatsApp initialization failed: ${err.message}`);
|
|
43
|
-
if (err.code === 'ERR_MODULE_NOT_FOUND') {
|
|
44
|
-
log.warn('[channels] Install baileys: npm install @whiskeysockets/baileys');
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Telegram channel — placeholder for future implementation
|
|
50
|
-
if (config.channels?.telegram?.enabled) {
|
|
51
|
-
log.info('[channels] Telegram is enabled in config — waiting for channel implementation to register');
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
log.ok(`[channels] Initialized with ${router.getAllChannels().length} channel(s)`);
|
|
55
|
-
return router;
|
|
56
|
-
}
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Role resolver for multi-channel access control.
|
|
3
|
-
* Chat is ALWAYS owner. Other channels look up the contacts DB.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import type { ChannelType, SenderRole } from './types.js';
|
|
7
|
-
|
|
8
|
-
export interface RoleResolverOpts {
|
|
9
|
-
workerApi: (path: string, method?: string, body?: any) => Promise<any>;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
let opts: RoleResolverOpts | null = null;
|
|
13
|
-
|
|
14
|
-
export function initRoleResolver(resolverOpts: RoleResolverOpts) {
|
|
15
|
-
opts = resolverOpts;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Resolve the role of a sender.
|
|
20
|
-
* Chat channel is ALWAYS owner — no lookup needed.
|
|
21
|
-
* Other channels check the contacts DB via the worker API.
|
|
22
|
-
*/
|
|
23
|
-
export async function resolveRole(channelType: ChannelType, senderId: string): Promise<SenderRole> {
|
|
24
|
-
// Chat is sacred — always the owner
|
|
25
|
-
if (channelType === 'chat') return 'owner';
|
|
26
|
-
|
|
27
|
-
if (!opts) return 'customer';
|
|
28
|
-
|
|
29
|
-
try {
|
|
30
|
-
const result = await opts.workerApi(
|
|
31
|
-
`/api/contacts/resolve?channel_type=${encodeURIComponent(channelType)}&identifier=${encodeURIComponent(senderId)}`
|
|
32
|
-
);
|
|
33
|
-
if (result?.role && ['owner', 'admin', 'customer'].includes(result.role)) {
|
|
34
|
-
return result.role as SenderRole;
|
|
35
|
-
}
|
|
36
|
-
} catch {
|
|
37
|
-
// If lookup fails, default to customer (safest)
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
return 'customer';
|
|
41
|
-
}
|
|
@@ -1,245 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Channel Router — central orchestrator for multi-channel message handling.
|
|
3
|
-
* Receives messages from any channel, resolves roles, picks the right agent,
|
|
4
|
-
* and routes responses back to the originating channel.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { log } from '../../shared/logger.js';
|
|
8
|
-
import { resolveRole } from './role-resolver.js';
|
|
9
|
-
import { startFluxyAgentQuery, startCustomerAgentQuery, type RecentMessage, type CustomerContext } from '../fluxy-agent.js';
|
|
10
|
-
import type { Channel, ChannelType, IncomingMessage, OutgoingMessage, SenderRole, ChannelRouter as IChannelRouter } from './types.js';
|
|
11
|
-
import { loadConfig } from '../../shared/config.js';
|
|
12
|
-
|
|
13
|
-
export interface ChannelRouterOpts {
|
|
14
|
-
workerApi: (path: string, method?: string, body?: any) => Promise<any>;
|
|
15
|
-
broadcastFluxy: (type: string, data: any) => void;
|
|
16
|
-
getModel: () => string;
|
|
17
|
-
restartBackend: () => void;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
// Per-conversationKey lock to allow concurrent queries across channels
|
|
21
|
-
const activeChannelQueries = new Map<string, boolean>();
|
|
22
|
-
|
|
23
|
-
export class ChannelRouter implements IChannelRouter {
|
|
24
|
-
private channels = new Map<ChannelType, Channel>();
|
|
25
|
-
private opts: ChannelRouterOpts;
|
|
26
|
-
|
|
27
|
-
constructor(opts: ChannelRouterOpts) {
|
|
28
|
-
this.opts = opts;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
registerChannel(channel: Channel): void {
|
|
32
|
-
this.channels.set(channel.type, channel);
|
|
33
|
-
log.info(`[router] Registered channel: ${channel.type}`);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
getChannel(type: ChannelType): Channel | undefined {
|
|
37
|
-
return this.channels.get(type);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Get all registered channels (for shutdown, etc.) */
|
|
41
|
-
getAllChannels(): Channel[] {
|
|
42
|
-
return Array.from(this.channels.values());
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
getChannelForConversation(conversationKey: string): Channel | undefined {
|
|
46
|
-
const channelType = conversationKey.split(':')[0] as ChannelType;
|
|
47
|
-
return this.channels.get(channelType);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Handle an incoming message from any channel.
|
|
52
|
-
* Resolves role, creates/reuses conversation, dispatches to the right agent.
|
|
53
|
-
*/
|
|
54
|
-
async handleIncoming(message: IncomingMessage): Promise<void> {
|
|
55
|
-
const { sender, content } = message;
|
|
56
|
-
const { conversationKey, channelType, senderId, displayName } = sender;
|
|
57
|
-
|
|
58
|
-
// Resolve role (chat is always owner, others check contacts DB)
|
|
59
|
-
const role = await resolveRole(channelType, senderId);
|
|
60
|
-
sender.role = role;
|
|
61
|
-
|
|
62
|
-
log.info(`[router] Incoming from ${channelType}:${senderId} (${displayName}) role=${role} key=${conversationKey}`);
|
|
63
|
-
|
|
64
|
-
// Prevent duplicate queries for the same conversation key
|
|
65
|
-
if (activeChannelQueries.get(conversationKey)) {
|
|
66
|
-
log.warn(`[router] Query already active for ${conversationKey}, skipping`);
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
activeChannelQueries.set(conversationKey, true);
|
|
70
|
-
|
|
71
|
-
try {
|
|
72
|
-
const model = this.opts.getModel();
|
|
73
|
-
|
|
74
|
-
// Get or create a conversation in the DB for this conversation key
|
|
75
|
-
const conv = await this.opts.workerApi('/api/conversations', 'POST', {
|
|
76
|
-
title: content.slice(0, 80),
|
|
77
|
-
model,
|
|
78
|
-
});
|
|
79
|
-
const convId = conv.id;
|
|
80
|
-
|
|
81
|
-
// Save user message to DB
|
|
82
|
-
await this.opts.workerApi(`/api/conversations/${convId}/messages`, 'POST', {
|
|
83
|
-
role: 'user',
|
|
84
|
-
content,
|
|
85
|
-
meta: { model },
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
// Fetch recent messages for context
|
|
89
|
-
let recentMessages: RecentMessage[] = [];
|
|
90
|
-
try {
|
|
91
|
-
const recentRaw = await this.opts.workerApi(`/api/conversations/${convId}/messages/recent?limit=20`) as any[];
|
|
92
|
-
if (Array.isArray(recentRaw)) {
|
|
93
|
-
const filtered = recentRaw.filter((m: any) => m.role === 'user' || m.role === 'assistant');
|
|
94
|
-
if (filtered.length > 0) {
|
|
95
|
-
recentMessages = filtered.slice(0, -1).map((m: any) => ({
|
|
96
|
-
role: m.role as 'user' | 'assistant',
|
|
97
|
-
content: m.content,
|
|
98
|
-
}));
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
} catch {}
|
|
102
|
-
|
|
103
|
-
// Get the originating channel for response routing
|
|
104
|
-
const channel = this.channels.get(channelType);
|
|
105
|
-
|
|
106
|
-
if (role === 'owner' || role === 'admin') {
|
|
107
|
-
await this.dispatchOwnerQuery(convId, conversationKey, content, model, channel, senderId, recentMessages, channelType);
|
|
108
|
-
} else {
|
|
109
|
-
await this.dispatchCustomerQuery(convId, conversationKey, content, model, channel, senderId, displayName, recentMessages, channelType);
|
|
110
|
-
}
|
|
111
|
-
} catch (err: any) {
|
|
112
|
-
log.warn(`[router] Error handling message from ${conversationKey}: ${err.message}`);
|
|
113
|
-
} finally {
|
|
114
|
-
activeChannelQueries.delete(conversationKey);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Dispatch a query for owner/admin — full Fluxy capabilities.
|
|
120
|
-
*/
|
|
121
|
-
private async dispatchOwnerQuery(
|
|
122
|
-
convId: string,
|
|
123
|
-
conversationKey: string,
|
|
124
|
-
content: string,
|
|
125
|
-
model: string,
|
|
126
|
-
channel: Channel | undefined,
|
|
127
|
-
senderId: string,
|
|
128
|
-
recentMessages: RecentMessage[],
|
|
129
|
-
channelType: ChannelType,
|
|
130
|
-
): Promise<void> {
|
|
131
|
-
// Fetch bot/human names
|
|
132
|
-
let botName = 'Fluxy', humanName = 'Human';
|
|
133
|
-
try {
|
|
134
|
-
const status = await this.opts.workerApi('/api/onboard/status');
|
|
135
|
-
botName = status.agentName || 'Fluxy';
|
|
136
|
-
humanName = status.userName || 'Human';
|
|
137
|
-
} catch {}
|
|
138
|
-
|
|
139
|
-
return new Promise<void>((resolve) => {
|
|
140
|
-
startFluxyAgentQuery(convId, content, model, (type, eventData) => {
|
|
141
|
-
if (type === 'bot:response' && eventData.content) {
|
|
142
|
-
// Save to DB
|
|
143
|
-
this.opts.workerApi(`/api/conversations/${convId}/messages`, 'POST', {
|
|
144
|
-
role: 'assistant', content: eventData.content, meta: { model },
|
|
145
|
-
}).catch(() => {});
|
|
146
|
-
|
|
147
|
-
// Route response back to originating channel
|
|
148
|
-
if (channel && channelType !== 'chat') {
|
|
149
|
-
channel.sendMessage(senderId, { content: eventData.content, conversationKey }).catch((err) => {
|
|
150
|
-
log.warn(`[router] Failed to send response to ${channelType}: ${err.message}`);
|
|
151
|
-
});
|
|
152
|
-
// Also broadcast to chat UI so owner sees the exchange
|
|
153
|
-
this.opts.broadcastFluxy('chat:sync', {
|
|
154
|
-
conversationId: convId,
|
|
155
|
-
message: { role: 'assistant', content: eventData.content, timestamp: new Date().toISOString(), channel: channelType },
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
if (type === 'bot:done') {
|
|
161
|
-
if (eventData.usedFileTools) {
|
|
162
|
-
this.opts.restartBackend();
|
|
163
|
-
}
|
|
164
|
-
resolve();
|
|
165
|
-
}
|
|
166
|
-
}, undefined, undefined, { botName, humanName }, recentMessages);
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Dispatch a query for customer — restricted capabilities.
|
|
172
|
-
*/
|
|
173
|
-
private async dispatchCustomerQuery(
|
|
174
|
-
convId: string,
|
|
175
|
-
conversationKey: string,
|
|
176
|
-
content: string,
|
|
177
|
-
model: string,
|
|
178
|
-
channel: Channel | undefined,
|
|
179
|
-
senderId: string,
|
|
180
|
-
displayName: string,
|
|
181
|
-
recentMessages: RecentMessage[],
|
|
182
|
-
channelType: ChannelType,
|
|
183
|
-
): Promise<void> {
|
|
184
|
-
const config = loadConfig();
|
|
185
|
-
const customerMode = config.customerMode || {};
|
|
186
|
-
|
|
187
|
-
const customerContext: CustomerContext = {
|
|
188
|
-
senderName: displayName,
|
|
189
|
-
senderIdentifier: senderId,
|
|
190
|
-
channelType,
|
|
191
|
-
businessName: customerMode.businessName || 'Our Business',
|
|
192
|
-
businessDescription: customerMode.businessDescription || '',
|
|
193
|
-
};
|
|
194
|
-
|
|
195
|
-
return new Promise<void>((resolve) => {
|
|
196
|
-
startCustomerAgentQuery(conversationKey, content, model, (type, eventData) => {
|
|
197
|
-
if (type === 'bot:response' && eventData.content) {
|
|
198
|
-
// Save to DB
|
|
199
|
-
this.opts.workerApi(`/api/conversations/${convId}/messages`, 'POST', {
|
|
200
|
-
role: 'assistant', content: eventData.content, meta: { model },
|
|
201
|
-
}).catch(() => {});
|
|
202
|
-
|
|
203
|
-
// Route response back to originating channel
|
|
204
|
-
if (channel) {
|
|
205
|
-
channel.sendMessage(senderId, { content: eventData.content, conversationKey }).catch((err) => {
|
|
206
|
-
log.warn(`[router] Failed to send customer response to ${channelType}: ${err.message}`);
|
|
207
|
-
});
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
if (type === 'bot:done') {
|
|
212
|
-
resolve();
|
|
213
|
-
}
|
|
214
|
-
}, customerContext, recentMessages);
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Route a pulse/cron message to ALL owner-role channels.
|
|
220
|
-
* Customer channels NEVER receive autonomous messages.
|
|
221
|
-
*/
|
|
222
|
-
async routePulseMessage(content: string, title?: string, _priority?: string): Promise<void> {
|
|
223
|
-
for (const [type, channel] of this.channels) {
|
|
224
|
-
// Chat is handled by existing broadcastFluxy — skip to avoid duplicates
|
|
225
|
-
if (type === 'chat') continue;
|
|
226
|
-
|
|
227
|
-
try {
|
|
228
|
-
// Find all owner contacts for this channel type
|
|
229
|
-
const contacts = await this.opts.workerApi('/api/contacts') as any[];
|
|
230
|
-
const ownerContacts = (contacts || []).filter(
|
|
231
|
-
(c: any) => c.channel_type === type && c.role === 'owner'
|
|
232
|
-
);
|
|
233
|
-
|
|
234
|
-
for (const contact of ownerContacts) {
|
|
235
|
-
await channel.sendMessage(contact.identifier, {
|
|
236
|
-
content: title ? `**${title}**\n\n${content}` : content,
|
|
237
|
-
conversationKey: `${type}:${contact.identifier}`,
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
|
-
} catch (err: any) {
|
|
241
|
-
log.warn(`[router] Failed to route pulse to ${type}: ${err.message}`);
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
}
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Multi-channel communication types.
|
|
3
|
-
* Defines the channel abstraction, message formats, and role system.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
export type ChannelType = 'chat' | 'whatsapp' | 'telegram';
|
|
7
|
-
export type SenderRole = 'owner' | 'admin' | 'customer';
|
|
8
|
-
|
|
9
|
-
export interface SenderIdentity {
|
|
10
|
-
channelType: ChannelType;
|
|
11
|
-
senderId: string; // phone number, telegram ID, 'owner' for chat
|
|
12
|
-
displayName: string;
|
|
13
|
-
role: SenderRole;
|
|
14
|
-
conversationKey: string; // "whatsapp:+5511999999999", "chat:owner"
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export interface IncomingMessage {
|
|
18
|
-
sender: SenderIdentity;
|
|
19
|
-
content: string;
|
|
20
|
-
timestamp: number;
|
|
21
|
-
attachments?: Array<{
|
|
22
|
-
type: 'image' | 'file' | 'audio';
|
|
23
|
-
name: string;
|
|
24
|
-
mediaType: string;
|
|
25
|
-
data: string; // base64
|
|
26
|
-
}>;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface OutgoingMessage {
|
|
30
|
-
content: string;
|
|
31
|
-
conversationKey: string;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Forward reference — ChannelRouter is imported by channel implementations
|
|
35
|
-
export interface ChannelRouter {
|
|
36
|
-
handleIncoming(message: IncomingMessage): Promise<void>;
|
|
37
|
-
routePulseMessage(content: string, title?: string, priority?: string): Promise<void>;
|
|
38
|
-
getChannelForConversation(conversationKey: string): Channel | undefined;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export interface Channel {
|
|
42
|
-
readonly type: ChannelType;
|
|
43
|
-
|
|
44
|
-
/** Initialize the channel (connect socket, start webhook listener, etc.) */
|
|
45
|
-
initialize(router: ChannelRouter): Promise<void>;
|
|
46
|
-
|
|
47
|
-
/** Send a message back through this channel */
|
|
48
|
-
sendMessage(to: string, message: OutgoingMessage): Promise<void>;
|
|
49
|
-
|
|
50
|
-
/** Check if this channel owns a given conversation key */
|
|
51
|
-
ownsConversation(conversationKey: string): boolean;
|
|
52
|
-
|
|
53
|
-
/** Graceful shutdown */
|
|
54
|
-
shutdown(): Promise<void>;
|
|
55
|
-
}
|
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* WhatsApp Channel — Baileys adapter for WhatsApp Web.
|
|
3
|
-
* Receives messages, routes through ChannelRouter, sends responses back.
|
|
4
|
-
* Session persisted in ~/.fluxy/whatsapp-session/.
|
|
5
|
-
*
|
|
6
|
-
* Baileys is imported dynamically so the supervisor starts even if
|
|
7
|
-
* @whiskeysockets/baileys is not installed.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import path from 'path';
|
|
11
|
-
import { DATA_DIR } from '../../shared/paths.js';
|
|
12
|
-
import { log } from '../../shared/logger.js';
|
|
13
|
-
import type { Channel, ChannelRouter, OutgoingMessage } from './types.js';
|
|
14
|
-
|
|
15
|
-
const SESSION_DIR = path.join(DATA_DIR, 'whatsapp-session');
|
|
16
|
-
const MAX_RECONNECT_ATTEMPTS = 5;
|
|
17
|
-
const RECONNECT_BASE_DELAY = 5_000; // 5s, doubles each retry
|
|
18
|
-
|
|
19
|
-
export class WhatsAppChannel implements Channel {
|
|
20
|
-
readonly type = 'whatsapp' as const;
|
|
21
|
-
private sock: any = null;
|
|
22
|
-
private router: ChannelRouter | null = null;
|
|
23
|
-
private onQR?: (qr: string) => void;
|
|
24
|
-
private reconnectAttempts = 0;
|
|
25
|
-
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
26
|
-
private shuttingDown = false;
|
|
27
|
-
|
|
28
|
-
constructor(onQR?: (qr: string) => void) {
|
|
29
|
-
this.onQR = onQR;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
async initialize(router: ChannelRouter): Promise<void> {
|
|
33
|
-
this.router = router;
|
|
34
|
-
try {
|
|
35
|
-
await this.connect();
|
|
36
|
-
} catch (err: any) {
|
|
37
|
-
// Don't let baileys crash the supervisor — log and move on
|
|
38
|
-
log.warn(`[whatsapp] Initial connection failed: ${err.message}`);
|
|
39
|
-
log.warn('[whatsapp] WhatsApp will remain inactive. Restart Fluxy after scanning QR to retry.');
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
private async connect(): Promise<void> {
|
|
44
|
-
if (this.shuttingDown) return;
|
|
45
|
-
|
|
46
|
-
const baileys = await import('@whiskeysockets/baileys');
|
|
47
|
-
const makeWASocket = baileys.default;
|
|
48
|
-
const { useMultiFileAuthState, DisconnectReason } = baileys;
|
|
49
|
-
|
|
50
|
-
const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR);
|
|
51
|
-
|
|
52
|
-
this.sock = makeWASocket({
|
|
53
|
-
auth: state,
|
|
54
|
-
// printQRInTerminal is deprecated in baileys v7 — we handle QR via connection.update
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
this.sock.ev.on('creds.update', saveCreds);
|
|
58
|
-
|
|
59
|
-
this.sock.ev.on('connection.update', (update: any) => {
|
|
60
|
-
const { connection, lastDisconnect, qr } = update;
|
|
61
|
-
|
|
62
|
-
// Handle QR code — surface it to chat UI
|
|
63
|
-
if (qr) {
|
|
64
|
-
log.info('[whatsapp] QR code received — scan with your phone to link');
|
|
65
|
-
if (this.onQR) this.onQR(qr);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
if (connection === 'close') {
|
|
69
|
-
const statusCode = (lastDisconnect?.error as any)?.output?.statusCode;
|
|
70
|
-
|
|
71
|
-
// These are the ONLY codes worth retrying — transient network/server issues
|
|
72
|
-
// DisconnectReason enum: connectionClosed=428, connectionLost=408, timedOut=408,
|
|
73
|
-
// restartRequired=515, unavailableService=503
|
|
74
|
-
const TRANSIENT_CODES = new Set([408, 428, 503, 515]);
|
|
75
|
-
|
|
76
|
-
if (!TRANSIENT_CODES.has(statusCode)) {
|
|
77
|
-
// Non-transient: loggedOut(401), badSession(500), forbidden(403),
|
|
78
|
-
// connectionReplaced(440), multideviceMismatch(411), or unknown like 405
|
|
79
|
-
log.warn(`[whatsapp] Connection closed (${statusCode}) — not recoverable by retrying.`);
|
|
80
|
-
log.warn('[whatsapp] WhatsApp inactive. Scan QR code and restart Fluxy to connect.');
|
|
81
|
-
this.sock = null;
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
this.reconnectAttempts++;
|
|
86
|
-
|
|
87
|
-
if (this.reconnectAttempts > MAX_RECONNECT_ATTEMPTS) {
|
|
88
|
-
log.warn(`[whatsapp] Max reconnect attempts (${MAX_RECONNECT_ATTEMPTS}) reached. Giving up.`);
|
|
89
|
-
log.warn('[whatsapp] Restart Fluxy to retry.');
|
|
90
|
-
this.sock = null;
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const delay = RECONNECT_BASE_DELAY * Math.pow(2, this.reconnectAttempts - 1);
|
|
95
|
-
log.warn(`[whatsapp] Transient failure (${statusCode}). Reconnecting in ${delay / 1000}s (attempt ${this.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);
|
|
96
|
-
|
|
97
|
-
this.reconnectTimer = setTimeout(() => {
|
|
98
|
-
this.reconnectTimer = null;
|
|
99
|
-
this.connect().catch((err) => {
|
|
100
|
-
log.warn(`[whatsapp] Reconnect failed: ${err.message}`);
|
|
101
|
-
});
|
|
102
|
-
}, delay);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
if (connection === 'open') {
|
|
106
|
-
log.ok('[whatsapp] Connected to WhatsApp');
|
|
107
|
-
this.reconnectAttempts = 0; // Reset on successful connection
|
|
108
|
-
}
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
this.sock.ev.on('messages.upsert', ({ messages, type }: any) => {
|
|
112
|
-
if (type !== 'notify') return;
|
|
113
|
-
|
|
114
|
-
for (const msg of messages) {
|
|
115
|
-
if (msg.key.fromMe) continue;
|
|
116
|
-
if (msg.key.remoteJid === 'status@broadcast') continue;
|
|
117
|
-
|
|
118
|
-
const remoteJid = msg.key.remoteJid!;
|
|
119
|
-
const senderId = remoteJid.replace('@s.whatsapp.net', '').replace('@g.us', '');
|
|
120
|
-
|
|
121
|
-
const content =
|
|
122
|
-
msg.message?.conversation ||
|
|
123
|
-
msg.message?.extendedTextMessage?.text ||
|
|
124
|
-
msg.message?.imageMessage?.caption ||
|
|
125
|
-
'';
|
|
126
|
-
|
|
127
|
-
if (!content) continue;
|
|
128
|
-
|
|
129
|
-
const displayName = msg.pushName || senderId;
|
|
130
|
-
const conversationKey = `whatsapp:${senderId}`;
|
|
131
|
-
|
|
132
|
-
log.info(`[whatsapp] Message from ${displayName} (${senderId}): ${content.slice(0, 80)}`);
|
|
133
|
-
|
|
134
|
-
this.router!.handleIncoming({
|
|
135
|
-
sender: {
|
|
136
|
-
channelType: 'whatsapp',
|
|
137
|
-
senderId,
|
|
138
|
-
displayName,
|
|
139
|
-
role: 'customer',
|
|
140
|
-
conversationKey,
|
|
141
|
-
},
|
|
142
|
-
content,
|
|
143
|
-
timestamp: (msg.messageTimestamp as number) * 1000 || Date.now(),
|
|
144
|
-
}).catch((err) => {
|
|
145
|
-
log.warn(`[whatsapp] Router error: ${err.message}`);
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
});
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
async sendMessage(to: string, message: OutgoingMessage): Promise<void> {
|
|
152
|
-
if (!this.sock) {
|
|
153
|
-
log.warn('[whatsapp] Cannot send — not connected');
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const jid = to.includes('@') ? to : `${to}@s.whatsapp.net`;
|
|
158
|
-
await this.sock.sendMessage(jid, { text: message.content });
|
|
159
|
-
log.info(`[whatsapp] Sent to ${to}: ${message.content.slice(0, 80)}`);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
ownsConversation(conversationKey: string): boolean {
|
|
163
|
-
return conversationKey.startsWith('whatsapp:');
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
async shutdown(): Promise<void> {
|
|
167
|
-
this.shuttingDown = true;
|
|
168
|
-
if (this.reconnectTimer) {
|
|
169
|
-
clearTimeout(this.reconnectTimer);
|
|
170
|
-
this.reconnectTimer = null;
|
|
171
|
-
}
|
|
172
|
-
if (this.sock) {
|
|
173
|
-
this.sock.end(undefined);
|
|
174
|
-
this.sock = null;
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
}
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
# Identity
|
|
2
|
-
|
|
3
|
-
You are $BUSINESS_NAME's assistant. You are helpful, professional, and friendly.
|
|
4
|
-
|
|
5
|
-
You are talking to $SENDER_NAME via $CHANNEL.
|
|
6
|
-
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
# What You Can Do
|
|
10
|
-
|
|
11
|
-
- Answer questions about the business, products, and services
|
|
12
|
-
- Help with support inquiries and troubleshooting
|
|
13
|
-
- Provide information and guidance
|
|
14
|
-
- Schedule appointments or take notes for follow-up
|
|
15
|
-
- Be conversational and helpful
|
|
16
|
-
|
|
17
|
-
# What You Cannot Do
|
|
18
|
-
|
|
19
|
-
- Access internal systems, files, or databases
|
|
20
|
-
- Run commands or modify anything on the server
|
|
21
|
-
- Access personal information about the business owner
|
|
22
|
-
- Make promises about pricing, refunds, or policies unless explicitly stated in your context
|
|
23
|
-
- Reveal any technical details about how you work internally
|
|
24
|
-
|
|
25
|
-
# Business Context
|
|
26
|
-
|
|
27
|
-
$BUSINESS_DESCRIPTION
|
|
28
|
-
|
|
29
|
-
# Behavior Guidelines
|
|
30
|
-
|
|
31
|
-
- Be warm and professional — you represent the business
|
|
32
|
-
- If you don't know the answer, say so honestly and offer to have someone follow up
|
|
33
|
-
- Keep responses concise and focused
|
|
34
|
-
- If the customer seems frustrated, acknowledge their feelings before problem-solving
|
|
35
|
-
- Never pretend to be a human — if asked, say you're an AI assistant for $BUSINESS_NAME
|
|
36
|
-
- Do not discuss topics unrelated to the business unless the customer is making casual conversation
|
|
37
|
-
|
|
38
|
-
# Disallowed Tools
|
|
39
|
-
|
|
40
|
-
The following tools are NOT available to you in this context:
|
|
41
|
-
$DISALLOWED_TOOLS
|
|
42
|
-
|
|
43
|
-
---
|
|
44
|
-
|
|
45
|
-
# Recent Conversation
|
|
46
|
-
$CONVERSATION_HISTORY
|