fluxy-bot 0.15.1 → 0.15.5
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/shared/config.ts +6 -4
- package/supervisor/channels/manager.ts +160 -76
- package/supervisor/channels/types.ts +9 -7
- package/supervisor/channels/whatsapp.ts +54 -6
- package/supervisor/fluxy-agent.ts +12 -19
- package/supervisor/index.ts +5 -4
- package/worker/prompts/fluxy-system-prompt.txt +61 -9
- package/workspace/client/src/App.tsx +1 -1
- package/workspace/client/src/components/deleteme_onboarding/WorkspaceTour.tsx +3 -2
- package/workspace/skills/whatsapp-support/{SUPPORT.md → SCRIPT.md} +4 -1
- package/workspace/skills/whatsapp-support/.claude-plugin/plugin.json +0 -5
package/package.json
CHANGED
package/shared/config.ts
CHANGED
|
@@ -3,10 +3,12 @@ import { paths, DATA_DIR } from './paths.js';
|
|
|
3
3
|
|
|
4
4
|
export interface ChannelConfig {
|
|
5
5
|
enabled: boolean;
|
|
6
|
-
/** '
|
|
7
|
-
mode: '
|
|
8
|
-
/**
|
|
9
|
-
|
|
6
|
+
/** 'channel' = just talk to me (self-chat only), 'business' = admin/customer mode */
|
|
7
|
+
mode: 'channel' | 'business';
|
|
8
|
+
/** Phone numbers with admin access (owner, secretary, etc.) — business mode only */
|
|
9
|
+
admins?: string[];
|
|
10
|
+
/** Active skill for customer-facing mode (folder name in workspace/skills/) */
|
|
11
|
+
skill?: string;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export interface BotConfig {
|
|
@@ -3,10 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Responsibilities:
|
|
5
5
|
* - Manages channel providers (WhatsApp, Telegram, etc.)
|
|
6
|
-
* - Resolves sender role (
|
|
6
|
+
* - Resolves sender role (admin vs customer) based on mode
|
|
7
7
|
* - Routes inbound messages to the agent with appropriate system prompt
|
|
8
|
-
* - Routes
|
|
9
|
-
* - Manages parallel agent instances for customer conversations
|
|
8
|
+
* - Routes agent responses back to channels
|
|
9
|
+
* - Manages parallel agent instances for customer conversations (business mode)
|
|
10
|
+
*
|
|
11
|
+
* Modes:
|
|
12
|
+
* - channel: Just talk to me. Only self-chat (fromMe=true) triggers the agent.
|
|
13
|
+
* All other messages are ignored — it's the user's personal WhatsApp.
|
|
14
|
+
* - business: Fluxy has its own number. Numbers in the admins array get the main
|
|
15
|
+
* system prompt. Everyone else gets the customer support prompt.
|
|
10
16
|
*/
|
|
11
17
|
|
|
12
18
|
import fs from 'fs';
|
|
@@ -19,6 +25,7 @@ import { WhatsAppChannel } from './whatsapp.js';
|
|
|
19
25
|
import type { ChannelConfig, ChannelProvider, ChannelStatus, ChannelType, InboundMessage, SenderRole } from './types.js';
|
|
20
26
|
|
|
21
27
|
const MAX_CONCURRENT_AGENTS = 5;
|
|
28
|
+
const MAX_BUFFER_MESSAGES = 30;
|
|
22
29
|
|
|
23
30
|
interface ChannelManagerOpts {
|
|
24
31
|
broadcastFluxy: (type: string, data: any) => void;
|
|
@@ -30,7 +37,11 @@ interface ChannelManagerOpts {
|
|
|
30
37
|
interface ActiveAgentQuery {
|
|
31
38
|
sender: string;
|
|
32
39
|
channel: ChannelType;
|
|
33
|
-
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface BufferedMessage {
|
|
43
|
+
role: 'user' | 'assistant';
|
|
44
|
+
content: string;
|
|
34
45
|
}
|
|
35
46
|
|
|
36
47
|
export class ChannelManager {
|
|
@@ -39,6 +50,8 @@ export class ChannelManager {
|
|
|
39
50
|
private activeAgents = new Map<string, ActiveAgentQuery>();
|
|
40
51
|
private messageQueue: InboundMessage[] = [];
|
|
41
52
|
private statusListeners: ((status: ChannelStatus) => void)[] = [];
|
|
53
|
+
/** In-memory conversation history per customer (keyed by "channel:phone") */
|
|
54
|
+
private customerBuffers = new Map<string, BufferedMessage[]>();
|
|
42
55
|
|
|
43
56
|
constructor(opts: ChannelManagerOpts) {
|
|
44
57
|
this.opts = opts;
|
|
@@ -47,7 +60,7 @@ export class ChannelManager {
|
|
|
47
60
|
/** Initialize channels based on config */
|
|
48
61
|
async init(): Promise<void> {
|
|
49
62
|
const config = loadConfig();
|
|
50
|
-
const channelConfigs =
|
|
63
|
+
const channelConfigs = config.channels;
|
|
51
64
|
|
|
52
65
|
if (!channelConfigs?.whatsapp?.enabled) {
|
|
53
66
|
log.info('[channels] WhatsApp not enabled — skipping');
|
|
@@ -56,7 +69,7 @@ export class ChannelManager {
|
|
|
56
69
|
|
|
57
70
|
log.info('[channels] Initializing WhatsApp channel...');
|
|
58
71
|
const whatsapp = new WhatsAppChannel(
|
|
59
|
-
(sender, senderName, text, fromMe) => this.handleInboundMessage('whatsapp', sender, senderName, text, fromMe),
|
|
72
|
+
(sender, senderName, text, fromMe, isSelfChat) => this.handleInboundMessage('whatsapp', sender, senderName, text, fromMe, isSelfChat),
|
|
60
73
|
(status) => this.handleStatusChange(status),
|
|
61
74
|
);
|
|
62
75
|
this.providers.set('whatsapp', whatsapp);
|
|
@@ -75,9 +88,8 @@ export class ChannelManager {
|
|
|
75
88
|
async connectWhatsApp(): Promise<void> {
|
|
76
89
|
let provider = this.providers.get('whatsapp');
|
|
77
90
|
if (!provider) {
|
|
78
|
-
// Create provider on-demand if not initialized
|
|
79
91
|
const whatsapp = new WhatsAppChannel(
|
|
80
|
-
(sender, senderName, text, fromMe) => this.handleInboundMessage('whatsapp', sender, senderName, text, fromMe),
|
|
92
|
+
(sender, senderName, text, fromMe, isSelfChat) => this.handleInboundMessage('whatsapp', sender, senderName, text, fromMe, isSelfChat),
|
|
81
93
|
(status) => this.handleStatusChange(status),
|
|
82
94
|
);
|
|
83
95
|
this.providers.set('whatsapp', whatsapp);
|
|
@@ -148,27 +160,10 @@ export class ChannelManager {
|
|
|
148
160
|
}
|
|
149
161
|
}
|
|
150
162
|
|
|
151
|
-
/**
|
|
152
|
-
private
|
|
163
|
+
/** Get the channel config, re-reading from disk each time */
|
|
164
|
+
private getChannelConfig(channel: ChannelType): ChannelConfig | undefined {
|
|
153
165
|
const config = loadConfig();
|
|
154
|
-
|
|
155
|
-
const channelConfig = channelConfigs?.[channel];
|
|
156
|
-
|
|
157
|
-
if (!channelConfig) return 'customer';
|
|
158
|
-
|
|
159
|
-
if (channelConfig.mode === 'shared') {
|
|
160
|
-
// Shared number mode: fromMe messages are from the human
|
|
161
|
-
return fromMe ? 'human' : 'customer';
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Dedicated number mode: check if sender matches human's phone
|
|
165
|
-
if (channelConfig.humanPhone) {
|
|
166
|
-
const senderPhone = sender.replace(/@.*/, '').replace(/[^0-9]/g, '');
|
|
167
|
-
const humanPhone = channelConfig.humanPhone.replace(/[^0-9]/g, '');
|
|
168
|
-
if (senderPhone === humanPhone) return 'human';
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
return 'customer';
|
|
166
|
+
return config.channels?.[channel];
|
|
172
167
|
}
|
|
173
168
|
|
|
174
169
|
/** Handle an incoming message from any channel */
|
|
@@ -178,8 +173,44 @@ export class ChannelManager {
|
|
|
178
173
|
senderName: string | undefined,
|
|
179
174
|
text: string,
|
|
180
175
|
fromMe: boolean,
|
|
176
|
+
isSelfChat: boolean,
|
|
181
177
|
) {
|
|
182
|
-
const
|
|
178
|
+
const channelConfig = this.getChannelConfig(channel);
|
|
179
|
+
if (!channelConfig) return;
|
|
180
|
+
|
|
181
|
+
const mode = channelConfig.mode || 'channel';
|
|
182
|
+
|
|
183
|
+
// ── Channel mode: ONLY respond to self-chat ──
|
|
184
|
+
if (mode === 'channel') {
|
|
185
|
+
if (!fromMe || !isSelfChat) {
|
|
186
|
+
// Ignore everything except self-chat messages
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const message: InboundMessage = {
|
|
191
|
+
channel,
|
|
192
|
+
sender: sender.replace(/@.*/, ''),
|
|
193
|
+
senderName,
|
|
194
|
+
role: 'admin',
|
|
195
|
+
text,
|
|
196
|
+
rawSender: sender,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
log.info(`[channels] Channel mode | self-chat | "${text.slice(0, 60)}"`);
|
|
200
|
+
await this.handleAdminMessage(message);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── Business mode: only respond to INCOMING messages (fromMe=false) ──
|
|
205
|
+
// fromMe=true means either:
|
|
206
|
+
// - Fluxy's own sent replies (would cause loops)
|
|
207
|
+
// - User typing on Fluxy's WhatsApp Web (not intended for the bot)
|
|
208
|
+
// Both should be ignored.
|
|
209
|
+
if (fromMe) {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const role = this.resolveBusinessRole(channelConfig, sender);
|
|
183
214
|
|
|
184
215
|
const message: InboundMessage = {
|
|
185
216
|
channel,
|
|
@@ -190,31 +221,43 @@ export class ChannelManager {
|
|
|
190
221
|
rawSender: sender,
|
|
191
222
|
};
|
|
192
223
|
|
|
193
|
-
log.info(`[channels]
|
|
224
|
+
log.info(`[channels] Business mode | ${message.sender} | role=${role} | "${text.slice(0, 60)}"`);
|
|
194
225
|
|
|
195
|
-
if (role === '
|
|
196
|
-
await this.
|
|
226
|
+
if (role === 'admin') {
|
|
227
|
+
await this.handleAdminMessage(message);
|
|
197
228
|
} else {
|
|
198
|
-
await this.handleCustomerMessage(message);
|
|
229
|
+
await this.handleCustomerMessage(message, channelConfig);
|
|
199
230
|
}
|
|
200
231
|
}
|
|
201
232
|
|
|
202
|
-
/**
|
|
203
|
-
private
|
|
233
|
+
/** Resolve role in business mode — check admins array */
|
|
234
|
+
private resolveBusinessRole(config: ChannelConfig, sender: string): SenderRole {
|
|
235
|
+
if (config.admins?.length) {
|
|
236
|
+
const senderPhone = sender.replace(/@.*/, '').replace(/[^0-9]/g, '');
|
|
237
|
+
for (const admin of config.admins) {
|
|
238
|
+
const adminPhone = admin.replace(/[^0-9]/g, '');
|
|
239
|
+
if (senderPhone === adminPhone || senderPhone.endsWith(adminPhone) || adminPhone.endsWith(senderPhone)) {
|
|
240
|
+
return 'admin';
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return 'customer';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Handle message from an admin — mirrors to chat conversation, uses main system prompt */
|
|
249
|
+
private async handleAdminMessage(msg: InboundMessage) {
|
|
204
250
|
const { workerApi, broadcastFluxy, getModel } = this.opts;
|
|
205
251
|
const model = getModel();
|
|
206
252
|
|
|
207
|
-
// Get or create
|
|
253
|
+
// Get or create conversation (shared with chat for mirroring)
|
|
208
254
|
let convId: string | undefined;
|
|
209
255
|
try {
|
|
210
256
|
const ctx = await workerApi('/api/context/current');
|
|
211
257
|
if (ctx.conversationId) {
|
|
212
258
|
convId = ctx.conversationId;
|
|
213
259
|
} else {
|
|
214
|
-
const conv = await workerApi('/api/conversations', 'POST', {
|
|
215
|
-
title: `WhatsApp`,
|
|
216
|
-
model,
|
|
217
|
-
});
|
|
260
|
+
const conv = await workerApi('/api/conversations', 'POST', { title: 'WhatsApp', model });
|
|
218
261
|
convId = conv.id;
|
|
219
262
|
await workerApi('/api/context/set', 'POST', { conversationId: convId });
|
|
220
263
|
}
|
|
@@ -240,7 +283,7 @@ export class ChannelManager {
|
|
|
240
283
|
message: { role: 'user', content: msg.text, timestamp: new Date().toISOString() },
|
|
241
284
|
});
|
|
242
285
|
|
|
243
|
-
// Fetch
|
|
286
|
+
// Fetch names and recent messages
|
|
244
287
|
let botName = 'Fluxy', humanName = 'Human';
|
|
245
288
|
let recentMessages: RecentMessage[] = [];
|
|
246
289
|
try {
|
|
@@ -261,8 +304,8 @@ export class ChannelManager {
|
|
|
261
304
|
}
|
|
262
305
|
} catch {}
|
|
263
306
|
|
|
264
|
-
//
|
|
265
|
-
const channelContext = `[WhatsApp | ${msg.sender} |
|
|
307
|
+
// Channel context — tells the agent this is a WhatsApp message, respond naturally
|
|
308
|
+
const channelContext = `[WhatsApp | ${msg.sender} | admin]\n`;
|
|
266
309
|
|
|
267
310
|
startFluxyAgentQuery(
|
|
268
311
|
convId,
|
|
@@ -270,7 +313,7 @@ export class ChannelManager {
|
|
|
270
313
|
model,
|
|
271
314
|
(type, eventData) => {
|
|
272
315
|
if (type === 'bot:response' && eventData.content) {
|
|
273
|
-
// Send response back via WhatsApp
|
|
316
|
+
// Send agent's response back via WhatsApp
|
|
274
317
|
this.sendMessage(msg.channel, msg.rawSender, eventData.content).catch((err) => {
|
|
275
318
|
log.warn(`[channels] Failed to send WhatsApp reply: ${err.message}`);
|
|
276
319
|
});
|
|
@@ -299,13 +342,12 @@ export class ChannelManager {
|
|
|
299
342
|
);
|
|
300
343
|
}
|
|
301
344
|
|
|
302
|
-
/** Handle message from a customer — runs support agent in parallel */
|
|
303
|
-
private async handleCustomerMessage(msg: InboundMessage) {
|
|
345
|
+
/** Handle message from a customer — runs support agent in parallel with conversation context */
|
|
346
|
+
private async handleCustomerMessage(msg: InboundMessage, channelConfig: ChannelConfig) {
|
|
304
347
|
const agentKey = `${msg.channel}:${msg.sender}`;
|
|
305
348
|
|
|
306
349
|
// Check concurrent limit
|
|
307
350
|
if (this.activeAgents.size >= MAX_CONCURRENT_AGENTS && !this.activeAgents.has(agentKey)) {
|
|
308
|
-
// Queue the message
|
|
309
351
|
log.info(`[channels] Max concurrent agents reached — queuing message from ${msg.sender}`);
|
|
310
352
|
this.messageQueue.push(msg);
|
|
311
353
|
return;
|
|
@@ -314,10 +356,10 @@ export class ChannelManager {
|
|
|
314
356
|
const { workerApi, getModel } = this.opts;
|
|
315
357
|
const model = getModel();
|
|
316
358
|
|
|
317
|
-
// Load
|
|
318
|
-
const
|
|
359
|
+
// Load the active skill's SCRIPT.md as the customer-facing system prompt
|
|
360
|
+
const scriptPrompt = this.loadActiveScript(channelConfig);
|
|
319
361
|
|
|
320
|
-
// Fetch agent
|
|
362
|
+
// Fetch agent name
|
|
321
363
|
let botName = 'Fluxy', humanName = 'Human';
|
|
322
364
|
try {
|
|
323
365
|
const status = await workerApi('/api/onboard/status');
|
|
@@ -325,19 +367,60 @@ export class ChannelManager {
|
|
|
325
367
|
humanName = status.userName || 'Human';
|
|
326
368
|
} catch {}
|
|
327
369
|
|
|
328
|
-
//
|
|
370
|
+
// Get or create conversation buffer for this customer
|
|
371
|
+
let buffer = this.customerBuffers.get(agentKey);
|
|
372
|
+
if (!buffer) {
|
|
373
|
+
buffer = [];
|
|
374
|
+
this.customerBuffers.set(agentKey, buffer);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Add the new user message to the buffer
|
|
378
|
+
buffer.push({ role: 'user', content: msg.text });
|
|
379
|
+
|
|
380
|
+
// Trim buffer to max size
|
|
381
|
+
if (buffer.length > MAX_BUFFER_MESSAGES) {
|
|
382
|
+
buffer.splice(0, buffer.length - MAX_BUFFER_MESSAGES);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Build recent messages for context (everything except the last one, which is the current message)
|
|
386
|
+
const recentMessages: RecentMessage[] = buffer.length > 1
|
|
387
|
+
? buffer.slice(0, -1).map((m) => ({ role: m.role, content: m.content }))
|
|
388
|
+
: [];
|
|
389
|
+
|
|
390
|
+
// Also load long-term memory from whatsapp/{phone}.md if it exists
|
|
391
|
+
let customerMemory = '';
|
|
392
|
+
try {
|
|
393
|
+
const memoryPath = path.join(WORKSPACE_DIR, 'whatsapp', `${msg.sender}.md`);
|
|
394
|
+
if (fs.existsSync(memoryPath)) {
|
|
395
|
+
customerMemory = fs.readFileSync(memoryPath, 'utf-8').trim();
|
|
396
|
+
}
|
|
397
|
+
} catch {}
|
|
398
|
+
|
|
329
399
|
const channelContext = `[WhatsApp | ${msg.sender} | customer${msg.senderName ? ` | ${msg.senderName}` : ''}]\n`;
|
|
330
|
-
|
|
400
|
+
|
|
401
|
+
// Stable convId per customer (not per message)
|
|
402
|
+
const convId = `channel-${agentKey}`;
|
|
331
403
|
|
|
332
404
|
this.activeAgents.set(agentKey, { sender: msg.sender, channel: msg.channel });
|
|
333
405
|
|
|
406
|
+
// Build an enriched script prompt with customer memory if available
|
|
407
|
+
let enrichedScript = scriptPrompt;
|
|
408
|
+
if (customerMemory && enrichedScript) {
|
|
409
|
+
enrichedScript += `\n\n---\n# Customer History (${msg.sender})\n\n${customerMemory}`;
|
|
410
|
+
}
|
|
411
|
+
|
|
334
412
|
startFluxyAgentQuery(
|
|
335
413
|
convId,
|
|
336
414
|
channelContext + msg.text,
|
|
337
415
|
model,
|
|
338
416
|
(type, eventData) => {
|
|
339
417
|
if (type === 'bot:response' && eventData.content) {
|
|
340
|
-
//
|
|
418
|
+
// Add assistant response to the buffer
|
|
419
|
+
buffer!.push({ role: 'assistant', content: eventData.content });
|
|
420
|
+
if (buffer!.length > MAX_BUFFER_MESSAGES) {
|
|
421
|
+
buffer!.splice(0, buffer!.length - MAX_BUFFER_MESSAGES);
|
|
422
|
+
}
|
|
423
|
+
|
|
341
424
|
this.sendMessage(msg.channel, msg.rawSender, eventData.content).catch((err) => {
|
|
342
425
|
log.warn(`[channels] Failed to send customer reply: ${err.message}`);
|
|
343
426
|
});
|
|
@@ -345,40 +428,39 @@ export class ChannelManager {
|
|
|
345
428
|
|
|
346
429
|
if (type === 'bot:done') {
|
|
347
430
|
this.activeAgents.delete(agentKey);
|
|
348
|
-
|
|
349
|
-
if (eventData.usedFileTools) {
|
|
350
|
-
this.opts.restartBackend();
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// Process queued messages
|
|
431
|
+
if (eventData.usedFileTools) this.opts.restartBackend();
|
|
354
432
|
this.processQueue();
|
|
355
433
|
}
|
|
356
434
|
},
|
|
357
435
|
undefined,
|
|
358
436
|
undefined,
|
|
359
437
|
{ botName, humanName },
|
|
360
|
-
|
|
361
|
-
|
|
438
|
+
recentMessages,
|
|
439
|
+
enrichedScript,
|
|
362
440
|
);
|
|
363
441
|
}
|
|
364
442
|
|
|
365
|
-
/** Load
|
|
366
|
-
private
|
|
367
|
-
|
|
368
|
-
|
|
443
|
+
/** Load SCRIPT.md from the active skill configured for this channel */
|
|
444
|
+
private loadActiveScript(channelConfig: ChannelConfig): string | undefined {
|
|
445
|
+
const skillName = channelConfig.skill;
|
|
446
|
+
if (!skillName) {
|
|
447
|
+
log.warn('[channels] No active skill configured — customer will get no script');
|
|
448
|
+
return undefined;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const scriptPath = path.join(WORKSPACE_DIR, 'skills', skillName, 'SCRIPT.md');
|
|
369
452
|
try {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
if (content) {
|
|
376
|
-
log.info(`[channels] Loaded support prompt from skill: ${entry.name}`);
|
|
377
|
-
return content;
|
|
378
|
-
}
|
|
453
|
+
if (fs.existsSync(scriptPath)) {
|
|
454
|
+
const content = fs.readFileSync(scriptPath, 'utf-8').trim();
|
|
455
|
+
if (content) {
|
|
456
|
+
log.info(`[channels] Loaded SCRIPT.md from skill: ${skillName}`);
|
|
457
|
+
return content;
|
|
379
458
|
}
|
|
380
459
|
}
|
|
381
|
-
|
|
460
|
+
log.warn(`[channels] SCRIPT.md not found in skill: ${skillName}`);
|
|
461
|
+
} catch (err: any) {
|
|
462
|
+
log.warn(`[channels] Failed to load SCRIPT.md from ${skillName}: ${err.message}`);
|
|
463
|
+
}
|
|
382
464
|
return undefined;
|
|
383
465
|
}
|
|
384
466
|
|
|
@@ -386,8 +468,10 @@ export class ChannelManager {
|
|
|
386
468
|
private processQueue() {
|
|
387
469
|
while (this.messageQueue.length > 0 && this.activeAgents.size < MAX_CONCURRENT_AGENTS) {
|
|
388
470
|
const queued = this.messageQueue.shift()!;
|
|
471
|
+
const config = this.getChannelConfig(queued.channel);
|
|
472
|
+
if (!config) continue;
|
|
389
473
|
log.info(`[channels] Processing queued message from ${queued.sender}`);
|
|
390
|
-
this.handleCustomerMessage(queued);
|
|
474
|
+
this.handleCustomerMessage(queued, config);
|
|
391
475
|
}
|
|
392
476
|
}
|
|
393
477
|
}
|
|
@@ -3,19 +3,21 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
export type ChannelType = 'whatsapp' | 'telegram';
|
|
6
|
-
export type SenderRole = '
|
|
6
|
+
export type SenderRole = 'admin' | 'customer';
|
|
7
7
|
|
|
8
8
|
export interface ChannelConfig {
|
|
9
9
|
enabled: boolean;
|
|
10
|
-
/** '
|
|
11
|
-
mode: '
|
|
12
|
-
/**
|
|
13
|
-
|
|
10
|
+
/** 'channel' = just talk to me (self-chat only), 'business' = admin/customer mode */
|
|
11
|
+
mode: 'channel' | 'business';
|
|
12
|
+
/** Phone numbers with admin access (owner, secretary, etc.) — business mode only */
|
|
13
|
+
admins?: string[];
|
|
14
|
+
/** Active skill for customer-facing mode (folder name in workspace/skills/) */
|
|
15
|
+
skill?: string;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
export interface InboundMessage {
|
|
17
19
|
channel: ChannelType;
|
|
18
|
-
/** Sender identifier (phone number
|
|
20
|
+
/** Sender identifier (phone number) */
|
|
19
21
|
sender: string;
|
|
20
22
|
/** Sender display name if available */
|
|
21
23
|
senderName?: string;
|
|
@@ -23,7 +25,7 @@ export interface InboundMessage {
|
|
|
23
25
|
role: SenderRole;
|
|
24
26
|
/** Message text content */
|
|
25
27
|
text: string;
|
|
26
|
-
/** Raw sender JID (channel-specific format) */
|
|
28
|
+
/** Raw sender JID (channel-specific format, used for replies) */
|
|
27
29
|
rawSender: string;
|
|
28
30
|
}
|
|
29
31
|
|
|
@@ -23,20 +23,25 @@ import type { ChannelProvider, ChannelStatus, ChannelType } from './types.js';
|
|
|
23
23
|
const AUTH_DIR = path.join(DATA_DIR, 'channels', 'whatsapp', 'auth');
|
|
24
24
|
|
|
25
25
|
/** Callback when a new message arrives */
|
|
26
|
-
export type OnWhatsAppMessage = (sender: string, senderName: string | undefined, text: string, fromMe: boolean) => void;
|
|
26
|
+
export type OnWhatsAppMessage = (sender: string, senderName: string | undefined, text: string, fromMe: boolean, isSelfChat: boolean) => void;
|
|
27
27
|
|
|
28
28
|
export class WhatsAppChannel implements ChannelProvider {
|
|
29
29
|
readonly type: ChannelType = 'whatsapp';
|
|
30
30
|
|
|
31
31
|
private sock: WASocket | null = null;
|
|
32
32
|
private connected = false;
|
|
33
|
-
private qrData: string | null = null;
|
|
34
|
-
private qrSvg: string | null = null;
|
|
33
|
+
private qrData: string | null = null;
|
|
34
|
+
private qrSvg: string | null = null;
|
|
35
35
|
private onMessage: OnWhatsAppMessage;
|
|
36
36
|
private onStatusChange: (status: ChannelStatus) => void;
|
|
37
37
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
38
38
|
private intentionalDisconnect = false;
|
|
39
39
|
|
|
40
|
+
/** Maps LID JIDs to phone JIDs (WhatsApp uses LIDs internally for self-chat) */
|
|
41
|
+
private lidToPhoneMap = new Map<string, string>();
|
|
42
|
+
/** Our own phone JID (number@s.whatsapp.net) */
|
|
43
|
+
private ownPhoneJid: string | null = null;
|
|
44
|
+
|
|
40
45
|
constructor(
|
|
41
46
|
onMessage: OnWhatsAppMessage,
|
|
42
47
|
onStatusChange: (status: ChannelStatus) => void,
|
|
@@ -110,6 +115,42 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
110
115
|
|
|
111
116
|
// ── Internal ──
|
|
112
117
|
|
|
118
|
+
/** Translate a JID from LID format to phone format if possible */
|
|
119
|
+
private translateJid(jid: string): string {
|
|
120
|
+
// If it's already a phone JID, return as-is
|
|
121
|
+
if (jid.endsWith('@s.whatsapp.net')) return jid;
|
|
122
|
+
|
|
123
|
+
// Check LID map
|
|
124
|
+
const mapped = this.lidToPhoneMap.get(jid);
|
|
125
|
+
if (mapped) return mapped;
|
|
126
|
+
|
|
127
|
+
// If it's a LID JID and we know our own phone, and this looks like a self-chat LID
|
|
128
|
+
// LID JIDs typically end with @lid or have a long numeric format
|
|
129
|
+
if (this.ownPhoneJid && (jid.includes('@lid') || jid.match(/^\d{15,}@/))) {
|
|
130
|
+
log.info(`[whatsapp] Unmapped LID ${jid} — assuming self-chat, using own phone JID`);
|
|
131
|
+
this.lidToPhoneMap.set(jid, this.ownPhoneJid);
|
|
132
|
+
return this.ownPhoneJid;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return jid;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Build the LID-to-phone mapping from sock.user */
|
|
139
|
+
private buildLidMap() {
|
|
140
|
+
if (!this.sock?.user) return;
|
|
141
|
+
|
|
142
|
+
const user = this.sock.user;
|
|
143
|
+
// user.id is "phone:device@s.whatsapp.net" — extract phone
|
|
144
|
+
const phone = user.id.split(':')[0];
|
|
145
|
+
this.ownPhoneJid = `${phone}@s.whatsapp.net`;
|
|
146
|
+
|
|
147
|
+
// user.lid (if available) is the LID JID
|
|
148
|
+
if ((user as any).lid) {
|
|
149
|
+
this.lidToPhoneMap.set((user as any).lid, this.ownPhoneJid);
|
|
150
|
+
log.info(`[whatsapp] LID map: ${(user as any).lid} → ${this.ownPhoneJid}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
113
154
|
private async connectInternal(): Promise<void> {
|
|
114
155
|
// Ensure auth directory exists
|
|
115
156
|
fs.mkdirSync(AUTH_DIR, { recursive: true });
|
|
@@ -164,6 +205,7 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
164
205
|
this.connected = true;
|
|
165
206
|
this.qrData = null;
|
|
166
207
|
this.qrSvg = null;
|
|
208
|
+
this.buildLidMap();
|
|
167
209
|
log.ok(`[whatsapp] Connected as ${sock.user?.id}`);
|
|
168
210
|
this.emitStatus();
|
|
169
211
|
}
|
|
@@ -207,12 +249,18 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
207
249
|
if (!text) continue;
|
|
208
250
|
|
|
209
251
|
const fromMe = msg.key.fromMe || false;
|
|
210
|
-
const
|
|
252
|
+
const rawSender = msg.key.remoteJid || '';
|
|
253
|
+
|
|
254
|
+
// Translate LID JIDs to phone JIDs
|
|
255
|
+
const sender = this.translateJid(rawSender);
|
|
211
256
|
const pushName = msg.pushName || undefined;
|
|
212
257
|
|
|
213
|
-
|
|
258
|
+
// Detect self-chat: remoteJid matches our own phone number
|
|
259
|
+
const isSelfChat = this.ownPhoneJid !== null && sender === this.ownPhoneJid;
|
|
260
|
+
|
|
261
|
+
log.info(`[whatsapp] Message from ${sender} (raw=${rawSender}, fromMe=${fromMe}, selfChat=${isSelfChat}): ${text.slice(0, 80)}`);
|
|
214
262
|
|
|
215
|
-
this.onMessage(sender, pushName, text, fromMe);
|
|
263
|
+
this.onMessage(sender, pushName, text, fromMe, isSelfChat);
|
|
216
264
|
}
|
|
217
265
|
});
|
|
218
266
|
}
|
|
@@ -7,7 +7,7 @@ import { query, type SDKMessage, type SDKUserMessage } from '@anthropic-ai/claud
|
|
|
7
7
|
import fs from 'fs';
|
|
8
8
|
import path from 'path';
|
|
9
9
|
import { log } from '../shared/logger.js';
|
|
10
|
-
import {
|
|
10
|
+
import { WORKSPACE_DIR } from '../shared/paths.js';
|
|
11
11
|
import type { SavedFile } from './file-saver.js';
|
|
12
12
|
import { getClaudeAccessToken } from '../worker/claude-auth.js';
|
|
13
13
|
|
|
@@ -178,29 +178,23 @@ export async function startFluxyAgentQuery(
|
|
|
178
178
|
const sdkPrompt: string | AsyncIterable<SDKUserMessage> =
|
|
179
179
|
attachments?.length ? buildMultiPartPrompt(prompt, attachments, savedFiles) : plainPrompt;
|
|
180
180
|
|
|
181
|
+
// Auto-discover skills — inject SKILL.md contents into system prompt (no SDK plugin system needed)
|
|
181
182
|
try {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
// skills/{name}/SKILL.md — we bridge the gap with symlinks created on discovery.
|
|
185
|
-
const skillsDir = path.join(PKG_DIR, 'workspace', 'skills');
|
|
186
|
-
const plugins: { type: 'local'; path: string }[] = [];
|
|
183
|
+
const skillsDir = path.join(WORKSPACE_DIR, 'skills');
|
|
184
|
+
const skillContents: string[] = [];
|
|
187
185
|
try {
|
|
188
186
|
for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
|
|
189
|
-
if (entry.isDirectory()
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const flatSkillMd = path.join(skillsDir, skillName, 'SKILL.md');
|
|
195
|
-
const sdkDir = path.join(skillsDir, skillName, 'skills', skillName);
|
|
196
|
-
const sdkSkillMd = path.join(sdkDir, 'SKILL.md');
|
|
197
|
-
if (fs.existsSync(flatSkillMd) && !fs.existsSync(sdkSkillMd)) {
|
|
198
|
-
fs.mkdirSync(sdkDir, { recursive: true });
|
|
199
|
-
fs.symlinkSync(flatSkillMd, sdkSkillMd);
|
|
200
|
-
}
|
|
187
|
+
if (!entry.isDirectory()) continue;
|
|
188
|
+
const skillMd = path.join(skillsDir, entry.name, 'SKILL.md');
|
|
189
|
+
if (fs.existsSync(skillMd)) {
|
|
190
|
+
const content = fs.readFileSync(skillMd, 'utf-8').trim();
|
|
191
|
+
if (content) skillContents.push(`## Skill: ${entry.name}\n\n${content}`);
|
|
201
192
|
}
|
|
202
193
|
}
|
|
203
194
|
} catch {}
|
|
195
|
+
if (skillContents.length) {
|
|
196
|
+
enrichedPrompt += `\n\n---\n# Installed Skills\n\n${skillContents.join('\n\n---\n\n')}`;
|
|
197
|
+
}
|
|
204
198
|
|
|
205
199
|
// Load MCP server config from workspace/MCP.json if it exists
|
|
206
200
|
// Format: { "server-name": { command, args, env }, ... } (object, not array)
|
|
@@ -231,7 +225,6 @@ export async function startFluxyAgentQuery(
|
|
|
231
225
|
maxTurns: 50,
|
|
232
226
|
abortController,
|
|
233
227
|
systemPrompt: enrichedPrompt,
|
|
234
|
-
plugins: plugins.length ? plugins : undefined,
|
|
235
228
|
mcpServers,
|
|
236
229
|
stderr: (chunk: string) => { stderrBuf += chunk; },
|
|
237
230
|
env: {
|
package/supervisor/index.ts
CHANGED
|
@@ -429,7 +429,7 @@ ${!connected ? '<script>setTimeout(()=>location.reload(),4000)</script>' : ''}
|
|
|
429
429
|
// Enable WhatsApp in config
|
|
430
430
|
const cfg = loadConfig();
|
|
431
431
|
if (!cfg.channels) cfg.channels = {};
|
|
432
|
-
if (!cfg.channels.whatsapp) cfg.channels.whatsapp = { enabled: true, mode: '
|
|
432
|
+
if (!cfg.channels.whatsapp) cfg.channels.whatsapp = { enabled: true, mode: 'channel' };
|
|
433
433
|
cfg.channels.whatsapp.enabled = true;
|
|
434
434
|
saveConfig(cfg);
|
|
435
435
|
|
|
@@ -480,7 +480,7 @@ ${!connected ? '<script>setTimeout(()=>location.reload(),4000)</script>' : ''}
|
|
|
480
480
|
return;
|
|
481
481
|
}
|
|
482
482
|
|
|
483
|
-
// POST /api/channels/whatsapp/configure — set mode +
|
|
483
|
+
// POST /api/channels/whatsapp/configure — set mode + admins
|
|
484
484
|
if (req.method === 'POST' && channelPath === '/api/channels/whatsapp/configure') {
|
|
485
485
|
let body = '';
|
|
486
486
|
req.on('data', (chunk: Buffer) => { body += chunk.toString(); });
|
|
@@ -489,9 +489,10 @@ ${!connected ? '<script>setTimeout(()=>location.reload(),4000)</script>' : ''}
|
|
|
489
489
|
const data = JSON.parse(body);
|
|
490
490
|
const cfg = loadConfig();
|
|
491
491
|
if (!cfg.channels) cfg.channels = {};
|
|
492
|
-
if (!cfg.channels.whatsapp) cfg.channels.whatsapp = { enabled: true, mode: '
|
|
492
|
+
if (!cfg.channels.whatsapp) cfg.channels.whatsapp = { enabled: true, mode: 'channel' };
|
|
493
493
|
if (data.mode) cfg.channels.whatsapp.mode = data.mode;
|
|
494
|
-
if (data.
|
|
494
|
+
if (data.admins !== undefined) cfg.channels.whatsapp.admins = data.admins;
|
|
495
|
+
if (data.skill !== undefined) cfg.channels.whatsapp.skill = data.skill;
|
|
495
496
|
saveConfig(cfg);
|
|
496
497
|
res.writeHead(200);
|
|
497
498
|
res.end(JSON.stringify({ ok: true, config: cfg.channels.whatsapp }));
|
|
@@ -202,10 +202,49 @@ Complex cron tasks can have detailed instruction files in `tasks/{cron-id}.md`.
|
|
|
202
202
|
|
|
203
203
|
---
|
|
204
204
|
|
|
205
|
+
## Skills
|
|
206
|
+
|
|
207
|
+
Skills live in `skills/` — each skill is a folder with instructions and resources:
|
|
208
|
+
|
|
209
|
+
```
|
|
210
|
+
skills/
|
|
211
|
+
whatsapp-clinic/
|
|
212
|
+
SKILL.md # Instructions for you (how to use this skill)
|
|
213
|
+
SCRIPT.md # Customer-facing prompt (loaded as system prompt in business mode)
|
|
214
|
+
files/ # RAG documents, FAQs, etc.
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Only ONE skill can be active for customer-facing mode at a time. The active skill is set in the channel config (`channels.whatsapp.skill`). When your human asks to switch skills, update the config:
|
|
218
|
+
```bash
|
|
219
|
+
curl -s -X POST http://localhost:3000/api/channels/whatsapp/configure \
|
|
220
|
+
-H "Content-Type: application/json" -d '{"skill":"whatsapp-clinic"}'
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
**IMPORTANT: When editing skill files, always use the full path inside the skill directory.**
|
|
224
|
+
- Correct: `skills/whatsapp-clinic/SCRIPT.md`
|
|
225
|
+
- Wrong: `SCRIPT.md` (this writes to workspace root!)
|
|
226
|
+
|
|
227
|
+
Your installed skills and their SKILL.md contents are injected below in your context. If your human asks you to update a skill's behavior or script, edit the files INSIDE `skills/{skill-name}/`.
|
|
228
|
+
|
|
229
|
+
**Separation of concerns:**
|
|
230
|
+
- `MYSELF.md`, `MYHUMAN.md`, `MEMORY.md` — about YOU and your human. Always yours.
|
|
231
|
+
- `skills/{name}/SCRIPT.md` — business logic for customer interactions. Belongs to the skill.
|
|
232
|
+
- `whatsapp/{phone}.md` — customer conversation logs. Your memory of each customer.
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
205
236
|
## Channels (WhatsApp, Telegram, etc.)
|
|
206
237
|
|
|
207
238
|
You can communicate through messaging channels beyond the chat bubble. Currently supported: **WhatsApp**.
|
|
208
239
|
|
|
240
|
+
### CRITICAL: How WhatsApp Responses Work
|
|
241
|
+
|
|
242
|
+
**Your text response IS the WhatsApp reply.** When you receive a message tagged with `[WhatsApp | ...]`, the supervisor takes whatever you respond with and sends it directly to WhatsApp. You do NOT need to use curl or `/api/channels/send` to reply — just respond normally as if you're talking to the person.
|
|
243
|
+
|
|
244
|
+
**Do NOT use `/api/channels/send` to reply to incoming WhatsApp messages.** That endpoint is ONLY for proactive messages (during pulse, cron, or when you want to initiate a conversation). If you use it to reply, the person will get duplicate messages.
|
|
245
|
+
|
|
246
|
+
**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.
|
|
247
|
+
|
|
209
248
|
### Channel Config
|
|
210
249
|
|
|
211
250
|
Your channel configuration is injected below (if any channels are configured). It comes from `~/.fluxy/config.json` — a file OUTSIDE your workspace that the supervisor manages.
|
|
@@ -220,8 +259,14 @@ Hi, I'd like to schedule an appointment.
|
|
|
220
259
|
|
|
221
260
|
The format is: `[Channel | phone | role | name (optional)]`
|
|
222
261
|
|
|
223
|
-
- **role=
|
|
224
|
-
- **role=customer**: This is someone else messaging.
|
|
262
|
+
- **role=admin**: This is your human or an authorized admin. Use your normal personality, full capabilities, main system prompt.
|
|
263
|
+
- **role=customer**: This is someone else messaging. Follow the instructions from the active skill's SCRIPT.md (loaded as your system prompt).
|
|
264
|
+
|
|
265
|
+
### WhatsApp Modes
|
|
266
|
+
|
|
267
|
+
**Channel Mode** (default): Your human's own WhatsApp number. Only self-chat triggers you — messages from other people are completely ignored. This is "just talk to me" mode.
|
|
268
|
+
|
|
269
|
+
**Business Mode**: Fluxy has its own dedicated number. Numbers in the `admins` array get admin access (main system prompt). Everyone else is a customer (support prompt).
|
|
225
270
|
|
|
226
271
|
### Setting Up WhatsApp
|
|
227
272
|
|
|
@@ -229,19 +274,26 @@ When your human asks to configure WhatsApp:
|
|
|
229
274
|
1. Start the connection: `curl -s -X POST http://localhost:3000/api/channels/whatsapp/connect`
|
|
230
275
|
2. Tell them to open the QR page: `http://localhost:3000/api/channels/whatsapp/qr-page` (or create a dashboard page that embeds it)
|
|
231
276
|
3. They scan the QR with their WhatsApp app
|
|
232
|
-
4.
|
|
233
|
-
|
|
234
|
-
|
|
277
|
+
4. The default mode is **channel** (self-chat only)
|
|
278
|
+
|
|
279
|
+
To switch to **business mode** with admin numbers:
|
|
280
|
+
```bash
|
|
281
|
+
curl -s -X POST http://localhost:3000/api/channels/whatsapp/configure \
|
|
282
|
+
-H "Content-Type: application/json" \
|
|
283
|
+
-d '{"mode":"business","admins":["+17865551234","+5511999887766"]}'
|
|
284
|
+
```
|
|
235
285
|
|
|
236
|
-
### Sending Messages
|
|
286
|
+
### Sending Proactive Messages
|
|
237
287
|
|
|
238
|
-
To
|
|
288
|
+
To INITIATE a WhatsApp message (during pulse, cron, or when you want to reach out first):
|
|
239
289
|
```bash
|
|
240
290
|
curl -s -X POST http://localhost:3000/api/channels/send \
|
|
241
291
|
-H "Content-Type: application/json" \
|
|
242
292
|
-d '{"channel":"whatsapp","to":"5511999888777","text":"Your appointment is confirmed for tomorrow at 2pm."}'
|
|
243
293
|
```
|
|
244
294
|
|
|
295
|
+
**Remember:** This is ONLY for starting new conversations or sending unprompted messages. When replying to an incoming message, just respond normally — the supervisor handles delivery.
|
|
296
|
+
|
|
245
297
|
### Customer Conversation Logs
|
|
246
298
|
|
|
247
299
|
When you finish a conversation with a **customer** via WhatsApp, save a summary to `whatsapp/{phone}.md`:
|
|
@@ -262,8 +314,8 @@ This is your memory of that customer. Next time they message, read their file fi
|
|
|
262
314
|
| `/api/channels/whatsapp/connect` | POST | Start WhatsApp (triggers QR if needed) |
|
|
263
315
|
| `/api/channels/whatsapp/disconnect` | POST | Disconnect WhatsApp |
|
|
264
316
|
| `/api/channels/whatsapp/logout` | POST | Disconnect + delete credentials |
|
|
265
|
-
| `/api/channels/whatsapp/configure` | POST | Set mode +
|
|
266
|
-
| `/api/channels/send` | POST | Send message via any channel |
|
|
317
|
+
| `/api/channels/whatsapp/configure` | POST | Set mode + admins array |
|
|
318
|
+
| `/api/channels/send` | POST | Send proactive message via any channel |
|
|
267
319
|
|
|
268
320
|
All endpoints are on `http://localhost:3000`.
|
|
269
321
|
|
|
@@ -14,8 +14,9 @@ import './tour-theme.css';
|
|
|
14
14
|
|
|
15
15
|
const TOUR_KEY = 'fluxy_workspace_tour_done';
|
|
16
16
|
|
|
17
|
-
export default function WorkspaceTour() {
|
|
17
|
+
export default function WorkspaceTour({ disabled = false }: { disabled?: boolean }) {
|
|
18
18
|
useEffect(() => {
|
|
19
|
+
if (disabled) return;
|
|
19
20
|
const val = localStorage.getItem(TOUR_KEY);
|
|
20
21
|
if (val === '1') return;
|
|
21
22
|
|
|
@@ -97,7 +98,7 @@ export default function WorkspaceTour() {
|
|
|
97
98
|
}, 800);
|
|
98
99
|
|
|
99
100
|
return () => clearTimeout(timer);
|
|
100
|
-
}, []);
|
|
101
|
+
}, [disabled]);
|
|
101
102
|
|
|
102
103
|
return null;
|
|
103
104
|
}
|
|
@@ -11,12 +11,15 @@ You are a friendly and helpful assistant responding to a customer via WhatsApp.
|
|
|
11
11
|
- Never reveal internal system details, file paths, or technical architecture.
|
|
12
12
|
- Never run destructive commands or modify critical files during customer interactions.
|
|
13
13
|
|
|
14
|
+
## CRITICAL: Response = Reply
|
|
15
|
+
|
|
16
|
+
Your text response IS the WhatsApp reply. The supervisor sends whatever you respond with directly to the customer. Do NOT use curl or `/api/channels/send` to reply — just respond naturally. That endpoint is only for proactive messages.
|
|
17
|
+
|
|
14
18
|
## What You Can Do
|
|
15
19
|
|
|
16
20
|
- Answer FAQs and general questions
|
|
17
21
|
- Provide information from files in your workspace
|
|
18
22
|
- Look up data from your backend API (`/app/api/*`)
|
|
19
|
-
- Send follow-up messages via the channel API
|
|
20
23
|
|
|
21
24
|
## What You Should NOT Do
|
|
22
25
|
|