fluxy-bot 0.13.6 → 0.15.1

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.13.6",
3
+ "version": "0.15.1",
4
4
  "releaseNotes": [
5
5
  "1. react router implemented",
6
6
  "2. new workspace design",
@@ -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,7 +1,13 @@
1
1
  import fs from 'fs';
2
2
  import { paths, DATA_DIR } from './paths.js';
3
3
 
4
- export type ChannelType = 'chat' | 'whatsapp' | 'telegram';
4
+ export interface ChannelConfig {
5
+ enabled: boolean;
6
+ /** 'shared' = user's own number, 'dedicated' = Fluxy has its own number */
7
+ mode: 'shared' | 'dedicated';
8
+ /** The human/owner's phone number (used for role resolution in dedicated mode) */
9
+ humanPhone?: string;
10
+ }
5
11
 
6
12
  export interface BotConfig {
7
13
  port: number;
@@ -27,17 +33,10 @@ export interface BotConfig {
27
33
  privateKey: string;
28
34
  address: string;
29
35
  };
30
- tunnelUrl?: string;
31
36
  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;
37
+ whatsapp?: ChannelConfig;
40
38
  };
39
+ tunnelUrl?: string;
41
40
  }
42
41
 
43
42
  const DEFAULTS: BotConfig = {
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Channel Manager — orchestrates multi-channel messaging.
3
+ *
4
+ * Responsibilities:
5
+ * - Manages channel providers (WhatsApp, Telegram, etc.)
6
+ * - Resolves sender role (human vs customer)
7
+ * - Routes inbound messages to the agent with appropriate system prompt
8
+ * - Routes outbound messages from agent/API back to channels
9
+ * - Manages parallel agent instances for customer conversations
10
+ */
11
+
12
+ import fs from 'fs';
13
+ import path from 'path';
14
+ import { loadConfig } from '../../shared/config.js';
15
+ import { WORKSPACE_DIR } from '../../shared/paths.js';
16
+ import { log } from '../../shared/logger.js';
17
+ import { startFluxyAgentQuery, type RecentMessage } from '../fluxy-agent.js';
18
+ import { WhatsAppChannel } from './whatsapp.js';
19
+ import type { ChannelConfig, ChannelProvider, ChannelStatus, ChannelType, InboundMessage, SenderRole } from './types.js';
20
+
21
+ const MAX_CONCURRENT_AGENTS = 5;
22
+
23
+ interface ChannelManagerOpts {
24
+ broadcastFluxy: (type: string, data: any) => void;
25
+ workerApi: (path: string, method?: string, body?: any) => Promise<any>;
26
+ restartBackend: () => void;
27
+ getModel: () => string;
28
+ }
29
+
30
+ interface ActiveAgentQuery {
31
+ sender: string;
32
+ channel: ChannelType;
33
+ abortController?: AbortController;
34
+ }
35
+
36
+ export class ChannelManager {
37
+ private providers = new Map<ChannelType, ChannelProvider>();
38
+ private opts: ChannelManagerOpts;
39
+ private activeAgents = new Map<string, ActiveAgentQuery>();
40
+ private messageQueue: InboundMessage[] = [];
41
+ private statusListeners: ((status: ChannelStatus) => void)[] = [];
42
+
43
+ constructor(opts: ChannelManagerOpts) {
44
+ this.opts = opts;
45
+ }
46
+
47
+ /** Initialize channels based on config */
48
+ async init(): Promise<void> {
49
+ const config = loadConfig();
50
+ const channelConfigs = (config as any).channels as Record<string, ChannelConfig> | undefined;
51
+
52
+ if (!channelConfigs?.whatsapp?.enabled) {
53
+ log.info('[channels] WhatsApp not enabled — skipping');
54
+ return;
55
+ }
56
+
57
+ log.info('[channels] Initializing WhatsApp channel...');
58
+ const whatsapp = new WhatsAppChannel(
59
+ (sender, senderName, text, fromMe) => this.handleInboundMessage('whatsapp', sender, senderName, text, fromMe),
60
+ (status) => this.handleStatusChange(status),
61
+ );
62
+ this.providers.set('whatsapp', whatsapp);
63
+
64
+ // Auto-connect if credentials exist (previously linked)
65
+ if (whatsapp.hasCredentials()) {
66
+ try {
67
+ await whatsapp.connect();
68
+ } catch (err: any) {
69
+ log.warn(`[channels] WhatsApp auto-connect failed: ${err.message}`);
70
+ }
71
+ }
72
+ }
73
+
74
+ /** Start WhatsApp connection (triggers QR flow if no credentials) */
75
+ async connectWhatsApp(): Promise<void> {
76
+ let provider = this.providers.get('whatsapp');
77
+ if (!provider) {
78
+ // Create provider on-demand if not initialized
79
+ const whatsapp = new WhatsAppChannel(
80
+ (sender, senderName, text, fromMe) => this.handleInboundMessage('whatsapp', sender, senderName, text, fromMe),
81
+ (status) => this.handleStatusChange(status),
82
+ );
83
+ this.providers.set('whatsapp', whatsapp);
84
+ provider = whatsapp;
85
+ }
86
+ await provider.connect();
87
+ }
88
+
89
+ /** Disconnect a specific channel */
90
+ async disconnectChannel(type: ChannelType): Promise<void> {
91
+ const provider = this.providers.get(type);
92
+ if (provider) {
93
+ await provider.disconnect();
94
+ this.providers.delete(type);
95
+ }
96
+ }
97
+
98
+ /** Disconnect all channels */
99
+ async disconnectAll(): Promise<void> {
100
+ for (const [, provider] of this.providers) {
101
+ await provider.disconnect();
102
+ }
103
+ this.providers.clear();
104
+ }
105
+
106
+ /** Send a message via a specific channel */
107
+ async sendMessage(channel: ChannelType, to: string, text: string): Promise<void> {
108
+ const provider = this.providers.get(channel);
109
+ if (!provider) throw new Error(`Channel ${channel} not available`);
110
+ await provider.sendMessage(to, text);
111
+ }
112
+
113
+ /** Get status of all channels */
114
+ getStatuses(): ChannelStatus[] {
115
+ return Array.from(this.providers.values()).map((p) => p.getStatus());
116
+ }
117
+
118
+ /** Get status of a specific channel */
119
+ getStatus(type: ChannelType): ChannelStatus | null {
120
+ return this.providers.get(type)?.getStatus() || null;
121
+ }
122
+
123
+ /** Get QR code SVG for a channel */
124
+ getQrCode(type: ChannelType): string | null {
125
+ return this.providers.get(type)?.getQrCode() || null;
126
+ }
127
+
128
+ /** Register a listener for status changes (used for WS broadcasting) */
129
+ onStatusChange(listener: (status: ChannelStatus) => void) {
130
+ this.statusListeners.push(listener);
131
+ }
132
+
133
+ /** Delete WhatsApp credentials and disconnect */
134
+ async logoutWhatsApp(): Promise<void> {
135
+ const provider = this.providers.get('whatsapp') as WhatsAppChannel | undefined;
136
+ if (provider) {
137
+ await provider.disconnect();
138
+ await provider.deleteCredentials();
139
+ this.providers.delete('whatsapp');
140
+ }
141
+ }
142
+
143
+ // ── Internal ──
144
+
145
+ private handleStatusChange(status: ChannelStatus) {
146
+ for (const listener of this.statusListeners) {
147
+ listener(status);
148
+ }
149
+ }
150
+
151
+ /** Resolve sender role based on channel config */
152
+ private resolveRole(channel: ChannelType, sender: string, fromMe: boolean): SenderRole {
153
+ const config = loadConfig();
154
+ const channelConfigs = (config as any).channels as Record<string, ChannelConfig> | undefined;
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';
172
+ }
173
+
174
+ /** Handle an incoming message from any channel */
175
+ private async handleInboundMessage(
176
+ channel: ChannelType,
177
+ sender: string,
178
+ senderName: string | undefined,
179
+ text: string,
180
+ fromMe: boolean,
181
+ ) {
182
+ const role = this.resolveRole(channel, sender, fromMe);
183
+
184
+ const message: InboundMessage = {
185
+ channel,
186
+ sender: sender.replace(/@.*/, ''),
187
+ senderName,
188
+ role,
189
+ text,
190
+ rawSender: sender,
191
+ };
192
+
193
+ log.info(`[channels] Inbound ${channel} | ${message.sender} | role=${role} | "${text.slice(0, 60)}"`);
194
+
195
+ if (role === 'human') {
196
+ await this.handleHumanMessage(message);
197
+ } else {
198
+ await this.handleCustomerMessage(message);
199
+ }
200
+ }
201
+
202
+ /** Handle message from the human/owner — mirrors to chat conversation */
203
+ private async handleHumanMessage(msg: InboundMessage) {
204
+ const { workerApi, broadcastFluxy, getModel } = this.opts;
205
+ const model = getModel();
206
+
207
+ // Get or create the human's WhatsApp conversation (mirrored with chat)
208
+ let convId: string | undefined;
209
+ try {
210
+ const ctx = await workerApi('/api/context/current');
211
+ if (ctx.conversationId) {
212
+ convId = ctx.conversationId;
213
+ } else {
214
+ const conv = await workerApi('/api/conversations', 'POST', {
215
+ title: `WhatsApp`,
216
+ model,
217
+ });
218
+ convId = conv.id;
219
+ await workerApi('/api/context/set', 'POST', { conversationId: convId });
220
+ }
221
+ } catch (err: any) {
222
+ log.warn(`[channels] Failed to get/create conversation: ${err.message}`);
223
+ return;
224
+ }
225
+
226
+ // Save user message to DB
227
+ try {
228
+ await workerApi(`/api/conversations/${convId}/messages`, 'POST', {
229
+ role: 'user',
230
+ content: msg.text,
231
+ meta: { model, channel: msg.channel },
232
+ });
233
+ } catch (err: any) {
234
+ log.warn(`[channels] DB persist error: ${err.message}`);
235
+ }
236
+
237
+ // Broadcast to chat clients (mirroring)
238
+ broadcastFluxy('chat:sync', {
239
+ conversationId: convId,
240
+ message: { role: 'user', content: msg.text, timestamp: new Date().toISOString() },
241
+ });
242
+
243
+ // Fetch agent/user names and recent messages
244
+ let botName = 'Fluxy', humanName = 'Human';
245
+ let recentMessages: RecentMessage[] = [];
246
+ try {
247
+ const [status, recentRaw] = await Promise.all([
248
+ workerApi('/api/onboard/status'),
249
+ workerApi(`/api/conversations/${convId}/messages/recent?limit=20`),
250
+ ]);
251
+ botName = status.agentName || 'Fluxy';
252
+ humanName = status.userName || 'Human';
253
+ if (Array.isArray(recentRaw)) {
254
+ const filtered = recentRaw.filter((m: any) => m.role === 'user' || m.role === 'assistant');
255
+ if (filtered.length > 0) {
256
+ recentMessages = filtered.slice(0, -1).map((m: any) => ({
257
+ role: m.role as 'user' | 'assistant',
258
+ content: m.content,
259
+ }));
260
+ }
261
+ }
262
+ } catch {}
263
+
264
+ // Run agent with main system prompt (same as chat)
265
+ const channelContext = `[WhatsApp | ${msg.sender} | human]\n`;
266
+
267
+ startFluxyAgentQuery(
268
+ convId,
269
+ channelContext + msg.text,
270
+ model,
271
+ (type, eventData) => {
272
+ if (type === 'bot:response' && eventData.content) {
273
+ // Send response back via WhatsApp
274
+ this.sendMessage(msg.channel, msg.rawSender, eventData.content).catch((err) => {
275
+ log.warn(`[channels] Failed to send WhatsApp reply: ${err.message}`);
276
+ });
277
+
278
+ // Save to DB
279
+ workerApi(`/api/conversations/${convId}/messages`, 'POST', {
280
+ role: 'assistant',
281
+ content: eventData.content,
282
+ meta: { model },
283
+ }).catch(() => {});
284
+ }
285
+
286
+ // Mirror streaming to chat clients
287
+ if (type === 'bot:token' || type === 'bot:response' || type === 'bot:typing' || type === 'bot:tool') {
288
+ broadcastFluxy(type, eventData);
289
+ }
290
+
291
+ if (type === 'bot:done' && eventData.usedFileTools) {
292
+ this.opts.restartBackend();
293
+ }
294
+ },
295
+ undefined,
296
+ undefined,
297
+ { botName, humanName },
298
+ recentMessages,
299
+ );
300
+ }
301
+
302
+ /** Handle message from a customer — runs support agent in parallel */
303
+ private async handleCustomerMessage(msg: InboundMessage) {
304
+ const agentKey = `${msg.channel}:${msg.sender}`;
305
+
306
+ // Check concurrent limit
307
+ if (this.activeAgents.size >= MAX_CONCURRENT_AGENTS && !this.activeAgents.has(agentKey)) {
308
+ // Queue the message
309
+ log.info(`[channels] Max concurrent agents reached — queuing message from ${msg.sender}`);
310
+ this.messageQueue.push(msg);
311
+ return;
312
+ }
313
+
314
+ const { workerApi, getModel } = this.opts;
315
+ const model = getModel();
316
+
317
+ // Load support system prompt from skill
318
+ const supportPrompt = this.loadSupportPrompt();
319
+
320
+ // Fetch agent/user names
321
+ let botName = 'Fluxy', humanName = 'Human';
322
+ try {
323
+ const status = await workerApi('/api/onboard/status');
324
+ botName = status.agentName || 'Fluxy';
325
+ humanName = status.userName || 'Human';
326
+ } catch {}
327
+
328
+ // Build channel context
329
+ const channelContext = `[WhatsApp | ${msg.sender} | customer${msg.senderName ? ` | ${msg.senderName}` : ''}]\n`;
330
+ const convId = `channel-${agentKey}-${Date.now()}`;
331
+
332
+ this.activeAgents.set(agentKey, { sender: msg.sender, channel: msg.channel });
333
+
334
+ startFluxyAgentQuery(
335
+ convId,
336
+ channelContext + msg.text,
337
+ model,
338
+ (type, eventData) => {
339
+ if (type === 'bot:response' && eventData.content) {
340
+ // Send response back via WhatsApp
341
+ this.sendMessage(msg.channel, msg.rawSender, eventData.content).catch((err) => {
342
+ log.warn(`[channels] Failed to send customer reply: ${err.message}`);
343
+ });
344
+ }
345
+
346
+ if (type === 'bot:done') {
347
+ this.activeAgents.delete(agentKey);
348
+
349
+ if (eventData.usedFileTools) {
350
+ this.opts.restartBackend();
351
+ }
352
+
353
+ // Process queued messages
354
+ this.processQueue();
355
+ }
356
+ },
357
+ undefined,
358
+ undefined,
359
+ { botName, humanName },
360
+ undefined,
361
+ supportPrompt,
362
+ );
363
+ }
364
+
365
+ /** Load customer-facing system prompt from skills */
366
+ private loadSupportPrompt(): string | undefined {
367
+ // Look for SUPPORT.md in any skill directory
368
+ const skillsDir = path.join(WORKSPACE_DIR, 'skills');
369
+ try {
370
+ for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
371
+ if (!entry.isDirectory()) continue;
372
+ const supportPath = path.join(skillsDir, entry.name, 'SUPPORT.md');
373
+ if (fs.existsSync(supportPath)) {
374
+ const content = fs.readFileSync(supportPath, 'utf-8').trim();
375
+ if (content) {
376
+ log.info(`[channels] Loaded support prompt from skill: ${entry.name}`);
377
+ return content;
378
+ }
379
+ }
380
+ }
381
+ } catch {}
382
+ return undefined;
383
+ }
384
+
385
+ /** Process queued messages when an agent slot frees up */
386
+ private processQueue() {
387
+ while (this.messageQueue.length > 0 && this.activeAgents.size < MAX_CONCURRENT_AGENTS) {
388
+ const queued = this.messageQueue.shift()!;
389
+ log.info(`[channels] Processing queued message from ${queued.sender}`);
390
+ this.handleCustomerMessage(queued);
391
+ }
392
+ }
393
+ }
@@ -1,55 +1,57 @@
1
1
  /**
2
- * Multi-channel communication types.
3
- * Defines the channel abstraction, message formats, and role system.
2
+ * Shared types for the multi-channel messaging system.
4
3
  */
5
4
 
6
- export type ChannelType = 'chat' | 'whatsapp' | 'telegram';
7
- export type SenderRole = 'owner' | 'admin' | 'customer';
5
+ export type ChannelType = 'whatsapp' | 'telegram';
6
+ export type SenderRole = 'human' | 'customer';
8
7
 
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"
8
+ export interface ChannelConfig {
9
+ enabled: boolean;
10
+ /** 'shared' = user's own number, 'dedicated' = Fluxy has its own number */
11
+ mode: 'shared' | 'dedicated';
12
+ /** The human/owner's phone number (used for role resolution in dedicated mode) */
13
+ humanPhone?: string;
15
14
  }
16
15
 
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
- }>;
16
+ export interface InboundMessage {
17
+ channel: ChannelType;
18
+ /** Sender identifier (phone number / JID) */
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) */
27
+ rawSender: string;
27
28
  }
28
29
 
29
- export interface OutgoingMessage {
30
- content: string;
31
- conversationKey: string;
30
+ export interface OutboundMessage {
31
+ channel: ChannelType;
32
+ to: string;
33
+ text: string;
32
34
  }
33
35
 
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;
36
+ export interface ChannelStatus {
37
+ channel: ChannelType;
38
+ connected: boolean;
39
+ /** Additional info like phone number, QR state, etc. */
40
+ info?: Record<string, any>;
39
41
  }
40
42
 
41
- export interface Channel {
43
+ export interface ChannelProvider {
42
44
  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>;
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;
55
57
  }