fluxy-bot 0.15.0 → 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fluxy-bot",
3
- "version": "0.15.0",
3
+ "version": "0.15.2",
4
4
  "releaseNotes": [
5
5
  "1. react router implemented",
6
6
  "2. new workspace design",
@@ -55,6 +55,7 @@
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",
58
59
  "better-sqlite3": "^12.6.2",
59
60
  "class-variance-authority": "^0.7.1",
60
61
  "clsx": "^2.1.1",
@@ -244,6 +244,17 @@ function Install-Fluxy {
244
244
  } catch {}
245
245
  Pop-Location
246
246
 
247
+ # Install workspace dependencies (rebuilds native modules for this platform)
248
+ $wsDir = Join-Path $FLUXY_HOME "workspace"
249
+ if (Test-Path (Join-Path $wsDir "package.json")) {
250
+ Write-Down "Installing workspace dependencies..."
251
+ Push-Location $wsDir
252
+ try {
253
+ & $NPM install --omit=dev 2>$null
254
+ } catch {}
255
+ Pop-Location
256
+ }
257
+
247
258
  # Verify
248
259
  $cliPath = Join-Path $FLUXY_HOME "bin\cli.js"
249
260
  if (-not (Test-Path $cliPath)) {
@@ -203,6 +203,12 @@ install_fluxy() {
203
203
  printf " ${BLUE}↓${RESET} Installing dependencies...\n"
204
204
  (cd "$FLUXY_HOME" && "$NPM" install --omit=dev 2>/dev/null)
205
205
 
206
+ # Install workspace dependencies (rebuilds native modules for this platform)
207
+ if [ -f "$FLUXY_HOME/workspace/package.json" ]; then
208
+ printf " ${BLUE}↓${RESET} Installing workspace dependencies...\n"
209
+ (cd "$FLUXY_HOME/workspace" && "$NPM" install --omit=dev 2>/dev/null)
210
+ fi
211
+
206
212
  # Verify
207
213
  if [ ! -f "$FLUXY_HOME/bin/cli.js" ]; then
208
214
  printf " ${RED}✗${RESET} Installation failed\n"
package/shared/config.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  import fs from 'fs';
2
2
  import { paths, DATA_DIR } from './paths.js';
3
3
 
4
+ export interface ChannelConfig {
5
+ enabled: boolean;
6
+ /** 'channel' = just talk to me (self-chat only), 'business' = admin/customer support */
7
+ mode: 'channel' | 'business';
8
+ /** Phone numbers with admin access (owner, secretary, etc.) — business mode only */
9
+ admins?: string[];
10
+ }
11
+
4
12
  export interface BotConfig {
5
13
  port: number;
6
14
  username: string;
@@ -25,6 +33,9 @@ export interface BotConfig {
25
33
  privateKey: string;
26
34
  address: string;
27
35
  };
36
+ channels?: {
37
+ whatsapp?: ChannelConfig;
38
+ };
28
39
  tunnelUrl?: string;
29
40
  }
30
41
 
@@ -0,0 +1,414 @@
1
+ /**
2
+ * Channel Manager — orchestrates multi-channel messaging.
3
+ *
4
+ * Responsibilities:
5
+ * - Manages channel providers (WhatsApp, Telegram, etc.)
6
+ * - Resolves sender role (admin vs customer) based on mode
7
+ * - Routes inbound messages to the agent with appropriate system prompt
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.
16
+ */
17
+
18
+ import fs from 'fs';
19
+ import path from 'path';
20
+ import { loadConfig } from '../../shared/config.js';
21
+ import { WORKSPACE_DIR } from '../../shared/paths.js';
22
+ import { log } from '../../shared/logger.js';
23
+ import { startFluxyAgentQuery, type RecentMessage } from '../fluxy-agent.js';
24
+ import { WhatsAppChannel } from './whatsapp.js';
25
+ import type { ChannelConfig, ChannelProvider, ChannelStatus, ChannelType, InboundMessage, SenderRole } from './types.js';
26
+
27
+ const MAX_CONCURRENT_AGENTS = 5;
28
+
29
+ interface ChannelManagerOpts {
30
+ broadcastFluxy: (type: string, data: any) => void;
31
+ workerApi: (path: string, method?: string, body?: any) => Promise<any>;
32
+ restartBackend: () => void;
33
+ getModel: () => string;
34
+ }
35
+
36
+ interface ActiveAgentQuery {
37
+ sender: string;
38
+ channel: ChannelType;
39
+ }
40
+
41
+ export class ChannelManager {
42
+ private providers = new Map<ChannelType, ChannelProvider>();
43
+ private opts: ChannelManagerOpts;
44
+ private activeAgents = new Map<string, ActiveAgentQuery>();
45
+ private messageQueue: InboundMessage[] = [];
46
+ private statusListeners: ((status: ChannelStatus) => void)[] = [];
47
+
48
+ constructor(opts: ChannelManagerOpts) {
49
+ this.opts = opts;
50
+ }
51
+
52
+ /** Initialize channels based on config */
53
+ async init(): Promise<void> {
54
+ const config = loadConfig();
55
+ const channelConfigs = config.channels;
56
+
57
+ if (!channelConfigs?.whatsapp?.enabled) {
58
+ log.info('[channels] WhatsApp not enabled — skipping');
59
+ return;
60
+ }
61
+
62
+ log.info('[channels] Initializing WhatsApp channel...');
63
+ const whatsapp = new WhatsAppChannel(
64
+ (sender, senderName, text, fromMe) => this.handleInboundMessage('whatsapp', sender, senderName, text, fromMe),
65
+ (status) => this.handleStatusChange(status),
66
+ );
67
+ this.providers.set('whatsapp', whatsapp);
68
+
69
+ // Auto-connect if credentials exist (previously linked)
70
+ if (whatsapp.hasCredentials()) {
71
+ try {
72
+ await whatsapp.connect();
73
+ } catch (err: any) {
74
+ log.warn(`[channels] WhatsApp auto-connect failed: ${err.message}`);
75
+ }
76
+ }
77
+ }
78
+
79
+ /** Start WhatsApp connection (triggers QR flow if no credentials) */
80
+ async connectWhatsApp(): Promise<void> {
81
+ let provider = this.providers.get('whatsapp');
82
+ if (!provider) {
83
+ const whatsapp = new WhatsAppChannel(
84
+ (sender, senderName, text, fromMe) => this.handleInboundMessage('whatsapp', sender, senderName, text, fromMe),
85
+ (status) => this.handleStatusChange(status),
86
+ );
87
+ this.providers.set('whatsapp', whatsapp);
88
+ provider = whatsapp;
89
+ }
90
+ await provider.connect();
91
+ }
92
+
93
+ /** Disconnect a specific channel */
94
+ async disconnectChannel(type: ChannelType): Promise<void> {
95
+ const provider = this.providers.get(type);
96
+ if (provider) {
97
+ await provider.disconnect();
98
+ this.providers.delete(type);
99
+ }
100
+ }
101
+
102
+ /** Disconnect all channels */
103
+ async disconnectAll(): Promise<void> {
104
+ for (const [, provider] of this.providers) {
105
+ await provider.disconnect();
106
+ }
107
+ this.providers.clear();
108
+ }
109
+
110
+ /** Send a message via a specific channel */
111
+ async sendMessage(channel: ChannelType, to: string, text: string): Promise<void> {
112
+ const provider = this.providers.get(channel);
113
+ if (!provider) throw new Error(`Channel ${channel} not available`);
114
+ await provider.sendMessage(to, text);
115
+ }
116
+
117
+ /** Get status of all channels */
118
+ getStatuses(): ChannelStatus[] {
119
+ return Array.from(this.providers.values()).map((p) => p.getStatus());
120
+ }
121
+
122
+ /** Get status of a specific channel */
123
+ getStatus(type: ChannelType): ChannelStatus | null {
124
+ return this.providers.get(type)?.getStatus() || null;
125
+ }
126
+
127
+ /** Get QR code SVG for a channel */
128
+ getQrCode(type: ChannelType): string | null {
129
+ return this.providers.get(type)?.getQrCode() || null;
130
+ }
131
+
132
+ /** Register a listener for status changes (used for WS broadcasting) */
133
+ onStatusChange(listener: (status: ChannelStatus) => void) {
134
+ this.statusListeners.push(listener);
135
+ }
136
+
137
+ /** Delete WhatsApp credentials and disconnect */
138
+ async logoutWhatsApp(): Promise<void> {
139
+ const provider = this.providers.get('whatsapp') as WhatsAppChannel | undefined;
140
+ if (provider) {
141
+ await provider.disconnect();
142
+ await provider.deleteCredentials();
143
+ this.providers.delete('whatsapp');
144
+ }
145
+ }
146
+
147
+ // ── Internal ──
148
+
149
+ private handleStatusChange(status: ChannelStatus) {
150
+ for (const listener of this.statusListeners) {
151
+ listener(status);
152
+ }
153
+ }
154
+
155
+ /** Get the channel config, re-reading from disk each time */
156
+ private getChannelConfig(channel: ChannelType): ChannelConfig | undefined {
157
+ const config = loadConfig();
158
+ return config.channels?.[channel];
159
+ }
160
+
161
+ /** Handle an incoming message from any channel */
162
+ private async handleInboundMessage(
163
+ channel: ChannelType,
164
+ sender: string,
165
+ senderName: string | undefined,
166
+ text: string,
167
+ fromMe: boolean,
168
+ ) {
169
+ const channelConfig = this.getChannelConfig(channel);
170
+ if (!channelConfig) return;
171
+
172
+ const mode = channelConfig.mode || 'channel';
173
+
174
+ // ── Channel mode: only respond to self-chat (fromMe=true) ──
175
+ if (mode === 'channel') {
176
+ if (!fromMe) {
177
+ // Ignore messages from other people — this is the user's personal WhatsApp
178
+ return;
179
+ }
180
+
181
+ const message: InboundMessage = {
182
+ channel,
183
+ sender: sender.replace(/@.*/, ''),
184
+ senderName,
185
+ role: 'admin',
186
+ text,
187
+ rawSender: sender,
188
+ };
189
+
190
+ log.info(`[channels] Channel mode | self-chat | "${text.slice(0, 60)}"`);
191
+ await this.handleAdminMessage(message);
192
+ return;
193
+ }
194
+
195
+ // ── Business mode: resolve role based on admins array ──
196
+ const role = this.resolveBusinessRole(channelConfig, sender, fromMe);
197
+
198
+ const message: InboundMessage = {
199
+ channel,
200
+ sender: sender.replace(/@.*/, ''),
201
+ senderName,
202
+ role,
203
+ text,
204
+ rawSender: sender,
205
+ };
206
+
207
+ log.info(`[channels] Business mode | ${message.sender} | role=${role} | "${text.slice(0, 60)}"`);
208
+
209
+ if (role === 'admin') {
210
+ await this.handleAdminMessage(message);
211
+ } else {
212
+ await this.handleCustomerMessage(message);
213
+ }
214
+ }
215
+
216
+ /** Resolve role in business mode — check admins array */
217
+ private resolveBusinessRole(config: ChannelConfig, sender: string, fromMe: boolean): SenderRole {
218
+ // fromMe is always admin (the number Fluxy is connected with)
219
+ if (fromMe) return 'admin';
220
+
221
+ // Check admins array
222
+ if (config.admins?.length) {
223
+ const senderPhone = sender.replace(/@.*/, '').replace(/[^0-9]/g, '');
224
+ for (const admin of config.admins) {
225
+ const adminPhone = admin.replace(/[^0-9]/g, '');
226
+ if (senderPhone === adminPhone || senderPhone.endsWith(adminPhone) || adminPhone.endsWith(senderPhone)) {
227
+ return 'admin';
228
+ }
229
+ }
230
+ }
231
+
232
+ return 'customer';
233
+ }
234
+
235
+ /** Handle message from an admin — mirrors to chat conversation, uses main system prompt */
236
+ private async handleAdminMessage(msg: InboundMessage) {
237
+ const { workerApi, broadcastFluxy, getModel } = this.opts;
238
+ const model = getModel();
239
+
240
+ // Get or create conversation (shared with chat for mirroring)
241
+ let convId: string | undefined;
242
+ try {
243
+ const ctx = await workerApi('/api/context/current');
244
+ if (ctx.conversationId) {
245
+ convId = ctx.conversationId;
246
+ } else {
247
+ const conv = await workerApi('/api/conversations', 'POST', { title: 'WhatsApp', model });
248
+ convId = conv.id;
249
+ await workerApi('/api/context/set', 'POST', { conversationId: convId });
250
+ }
251
+ } catch (err: any) {
252
+ log.warn(`[channels] Failed to get/create conversation: ${err.message}`);
253
+ return;
254
+ }
255
+
256
+ // Save user message to DB
257
+ try {
258
+ await workerApi(`/api/conversations/${convId}/messages`, 'POST', {
259
+ role: 'user',
260
+ content: msg.text,
261
+ meta: { model, channel: msg.channel },
262
+ });
263
+ } catch (err: any) {
264
+ log.warn(`[channels] DB persist error: ${err.message}`);
265
+ }
266
+
267
+ // Broadcast to chat clients (mirroring)
268
+ broadcastFluxy('chat:sync', {
269
+ conversationId: convId,
270
+ message: { role: 'user', content: msg.text, timestamp: new Date().toISOString() },
271
+ });
272
+
273
+ // Fetch names and recent messages
274
+ let botName = 'Fluxy', humanName = 'Human';
275
+ let recentMessages: RecentMessage[] = [];
276
+ try {
277
+ const [status, recentRaw] = await Promise.all([
278
+ workerApi('/api/onboard/status'),
279
+ workerApi(`/api/conversations/${convId}/messages/recent?limit=20`),
280
+ ]);
281
+ botName = status.agentName || 'Fluxy';
282
+ humanName = status.userName || 'Human';
283
+ if (Array.isArray(recentRaw)) {
284
+ const filtered = recentRaw.filter((m: any) => m.role === 'user' || m.role === 'assistant');
285
+ if (filtered.length > 0) {
286
+ recentMessages = filtered.slice(0, -1).map((m: any) => ({
287
+ role: m.role as 'user' | 'assistant',
288
+ content: m.content,
289
+ }));
290
+ }
291
+ }
292
+ } catch {}
293
+
294
+ // Channel context — tells the agent this is a WhatsApp message, respond naturally
295
+ const channelContext = `[WhatsApp | ${msg.sender} | admin]\n`;
296
+
297
+ startFluxyAgentQuery(
298
+ convId,
299
+ channelContext + msg.text,
300
+ model,
301
+ (type, eventData) => {
302
+ if (type === 'bot:response' && eventData.content) {
303
+ // Send agent's response back via WhatsApp
304
+ this.sendMessage(msg.channel, msg.rawSender, eventData.content).catch((err) => {
305
+ log.warn(`[channels] Failed to send WhatsApp reply: ${err.message}`);
306
+ });
307
+
308
+ // Save to DB
309
+ workerApi(`/api/conversations/${convId}/messages`, 'POST', {
310
+ role: 'assistant',
311
+ content: eventData.content,
312
+ meta: { model },
313
+ }).catch(() => {});
314
+ }
315
+
316
+ // Mirror streaming to chat clients
317
+ if (type === 'bot:token' || type === 'bot:response' || type === 'bot:typing' || type === 'bot:tool') {
318
+ broadcastFluxy(type, eventData);
319
+ }
320
+
321
+ if (type === 'bot:done' && eventData.usedFileTools) {
322
+ this.opts.restartBackend();
323
+ }
324
+ },
325
+ undefined,
326
+ undefined,
327
+ { botName, humanName },
328
+ recentMessages,
329
+ );
330
+ }
331
+
332
+ /** Handle message from a customer — runs support agent in parallel */
333
+ private async handleCustomerMessage(msg: InboundMessage) {
334
+ const agentKey = `${msg.channel}:${msg.sender}`;
335
+
336
+ // Check concurrent limit
337
+ if (this.activeAgents.size >= MAX_CONCURRENT_AGENTS && !this.activeAgents.has(agentKey)) {
338
+ log.info(`[channels] Max concurrent agents reached — queuing message from ${msg.sender}`);
339
+ this.messageQueue.push(msg);
340
+ return;
341
+ }
342
+
343
+ const { workerApi, getModel } = this.opts;
344
+ const model = getModel();
345
+
346
+ // Load support system prompt from skill
347
+ const supportPrompt = this.loadSupportPrompt();
348
+
349
+ // Fetch agent name
350
+ let botName = 'Fluxy', humanName = 'Human';
351
+ try {
352
+ const status = await workerApi('/api/onboard/status');
353
+ botName = status.agentName || 'Fluxy';
354
+ humanName = status.userName || 'Human';
355
+ } catch {}
356
+
357
+ const channelContext = `[WhatsApp | ${msg.sender} | customer${msg.senderName ? ` | ${msg.senderName}` : ''}]\n`;
358
+ const convId = `channel-${agentKey}-${Date.now()}`;
359
+
360
+ this.activeAgents.set(agentKey, { sender: msg.sender, channel: msg.channel });
361
+
362
+ startFluxyAgentQuery(
363
+ convId,
364
+ channelContext + msg.text,
365
+ model,
366
+ (type, eventData) => {
367
+ if (type === 'bot:response' && eventData.content) {
368
+ this.sendMessage(msg.channel, msg.rawSender, eventData.content).catch((err) => {
369
+ log.warn(`[channels] Failed to send customer reply: ${err.message}`);
370
+ });
371
+ }
372
+
373
+ if (type === 'bot:done') {
374
+ this.activeAgents.delete(agentKey);
375
+ if (eventData.usedFileTools) this.opts.restartBackend();
376
+ this.processQueue();
377
+ }
378
+ },
379
+ undefined,
380
+ undefined,
381
+ { botName, humanName },
382
+ undefined,
383
+ supportPrompt,
384
+ );
385
+ }
386
+
387
+ /** Load customer-facing system prompt from skills */
388
+ private loadSupportPrompt(): string | undefined {
389
+ const skillsDir = path.join(WORKSPACE_DIR, 'skills');
390
+ try {
391
+ for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
392
+ if (!entry.isDirectory()) continue;
393
+ const supportPath = path.join(skillsDir, entry.name, 'SUPPORT.md');
394
+ if (fs.existsSync(supportPath)) {
395
+ const content = fs.readFileSync(supportPath, 'utf-8').trim();
396
+ if (content) {
397
+ log.info(`[channels] Loaded support prompt from skill: ${entry.name}`);
398
+ return content;
399
+ }
400
+ }
401
+ }
402
+ } catch {}
403
+ return undefined;
404
+ }
405
+
406
+ /** Process queued messages when an agent slot frees up */
407
+ private processQueue() {
408
+ while (this.messageQueue.length > 0 && this.activeAgents.size < MAX_CONCURRENT_AGENTS) {
409
+ const queued = this.messageQueue.shift()!;
410
+ log.info(`[channels] Processing queued message from ${queued.sender}`);
411
+ this.handleCustomerMessage(queued);
412
+ }
413
+ }
414
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Shared types for the multi-channel messaging system.
3
+ */
4
+
5
+ export type ChannelType = 'whatsapp' | 'telegram';
6
+ export type SenderRole = 'admin' | 'customer';
7
+
8
+ export interface ChannelConfig {
9
+ enabled: boolean;
10
+ /** 'channel' = just talk to me (self-chat only), 'business' = admin/customer support */
11
+ mode: 'channel' | 'business';
12
+ /** Phone numbers with admin access (owner, secretary, etc.) — business mode only */
13
+ admins?: string[];
14
+ }
15
+
16
+ export interface InboundMessage {
17
+ channel: ChannelType;
18
+ /** Sender identifier (phone number) */
19
+ sender: string;
20
+ /** Sender display name if available */
21
+ senderName?: string;
22
+ /** Resolved role of the sender */
23
+ role: SenderRole;
24
+ /** Message text content */
25
+ text: string;
26
+ /** Raw sender JID (channel-specific format, used for replies) */
27
+ rawSender: string;
28
+ }
29
+
30
+ export interface OutboundMessage {
31
+ channel: ChannelType;
32
+ to: string;
33
+ text: string;
34
+ }
35
+
36
+ export interface ChannelStatus {
37
+ channel: ChannelType;
38
+ connected: boolean;
39
+ /** Additional info like phone number, QR state, etc. */
40
+ info?: Record<string, any>;
41
+ }
42
+
43
+ export interface ChannelProvider {
44
+ readonly type: ChannelType;
45
+ /** Start the channel connection (may trigger QR flow) */
46
+ connect(): Promise<void>;
47
+ /** Disconnect and clean up */
48
+ disconnect(): Promise<void>;
49
+ /** Send a text message */
50
+ sendMessage(to: string, text: string): Promise<void>;
51
+ /** Get current connection status */
52
+ getStatus(): ChannelStatus;
53
+ /** Get current QR code data (base64 SVG) or null if not in QR state */
54
+ getQrCode(): string | null;
55
+ /** Whether auth credentials exist (previously connected) */
56
+ hasCredentials(): boolean;
57
+ }