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.
@@ -0,0 +1,297 @@
1
+ /**
2
+ * WhatsApp channel provider using Baileys (WhiskeySockets).
3
+ * Handles connection, QR code flow, message send/receive, and auth persistence.
4
+ */
5
+
6
+ import makeWASocket, {
7
+ useMultiFileAuthState,
8
+ makeCacheableSignalKeyStore,
9
+ fetchLatestWaWebVersion,
10
+ DisconnectReason,
11
+ Browsers,
12
+ type WASocket,
13
+ type BaileysEventMap,
14
+ } from '@whiskeysockets/baileys';
15
+ import fs from 'fs';
16
+ import path from 'path';
17
+ import QRCode from 'qrcode';
18
+ import pino from 'pino';
19
+ import { DATA_DIR } from '../../shared/paths.js';
20
+ import { log } from '../../shared/logger.js';
21
+ import type { ChannelProvider, ChannelStatus, ChannelType } from './types.js';
22
+
23
+ const AUTH_DIR = path.join(DATA_DIR, 'channels', 'whatsapp', 'auth');
24
+
25
+ /** Callback when a new message arrives */
26
+ export type OnWhatsAppMessage = (sender: string, senderName: string | undefined, text: string, fromMe: boolean) => void;
27
+
28
+ export class WhatsAppChannel implements ChannelProvider {
29
+ readonly type: ChannelType = 'whatsapp';
30
+
31
+ private sock: WASocket | null = null;
32
+ private connected = false;
33
+ private qrData: string | null = null;
34
+ private qrSvg: string | null = null;
35
+ private onMessage: OnWhatsAppMessage;
36
+ private onStatusChange: (status: ChannelStatus) => void;
37
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
38
+ private intentionalDisconnect = false;
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
+
45
+ constructor(
46
+ onMessage: OnWhatsAppMessage,
47
+ onStatusChange: (status: ChannelStatus) => void,
48
+ ) {
49
+ this.onMessage = onMessage;
50
+ this.onStatusChange = onStatusChange;
51
+ }
52
+
53
+ async connect(): Promise<void> {
54
+ this.intentionalDisconnect = false;
55
+ await this.connectInternal();
56
+ }
57
+
58
+ async disconnect(): Promise<void> {
59
+ this.intentionalDisconnect = true;
60
+ if (this.reconnectTimer) {
61
+ clearTimeout(this.reconnectTimer);
62
+ this.reconnectTimer = null;
63
+ }
64
+ if (this.sock) {
65
+ this.sock.end(undefined);
66
+ this.sock = null;
67
+ }
68
+ this.connected = false;
69
+ this.qrData = null;
70
+ this.qrSvg = null;
71
+ this.emitStatus();
72
+ }
73
+
74
+ async sendMessage(to: string, text: string): Promise<void> {
75
+ if (!this.sock || !this.connected) {
76
+ log.warn('[whatsapp] Cannot send — not connected');
77
+ return;
78
+ }
79
+ // Normalize: ensure JID format (number@s.whatsapp.net)
80
+ const jid = to.includes('@') ? to : `${to.replace(/[^0-9]/g, '')}@s.whatsapp.net`;
81
+ await this.sock.sendMessage(jid, { text });
82
+ log.info(`[whatsapp] Sent message to ${jid}`);
83
+ }
84
+
85
+ getStatus(): ChannelStatus {
86
+ return {
87
+ channel: 'whatsapp',
88
+ connected: this.connected,
89
+ info: {
90
+ hasQr: !!this.qrData,
91
+ hasCredentials: this.hasCredentials(),
92
+ phoneNumber: this.sock?.user?.id?.split(':')[0] || null,
93
+ },
94
+ };
95
+ }
96
+
97
+ getQrCode(): string | null {
98
+ return this.qrSvg;
99
+ }
100
+
101
+ hasCredentials(): boolean {
102
+ return fs.existsSync(path.join(AUTH_DIR, 'creds.json'));
103
+ }
104
+
105
+ /** Delete stored credentials (for re-auth / logout) */
106
+ async deleteCredentials(): Promise<void> {
107
+ try {
108
+ if (fs.existsSync(AUTH_DIR)) {
109
+ fs.rmSync(AUTH_DIR, { recursive: true, force: true });
110
+ }
111
+ } catch (err: any) {
112
+ log.warn(`[whatsapp] Failed to delete credentials: ${err.message}`);
113
+ }
114
+ }
115
+
116
+ // ── Internal ──
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
+
154
+ private async connectInternal(): Promise<void> {
155
+ // Ensure auth directory exists
156
+ fs.mkdirSync(AUTH_DIR, { recursive: true });
157
+
158
+ const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
159
+
160
+ // Suppress Baileys' noisy logging
161
+ const logger = pino({ level: 'silent' }) as any;
162
+
163
+ let version: [number, number, number] | undefined;
164
+ try {
165
+ const result = await fetchLatestWaWebVersion({});
166
+ version = result.version;
167
+ } catch {
168
+ log.warn('[whatsapp] Could not fetch latest WA version — using default');
169
+ }
170
+
171
+ const sock = makeWASocket({
172
+ auth: {
173
+ creds: state.creds,
174
+ keys: makeCacheableSignalKeyStore(state.keys, logger),
175
+ },
176
+ version,
177
+ browser: Browsers.macOS('Chrome'),
178
+ printQRInTerminal: false,
179
+ logger,
180
+ generateHighQualityLinkPreview: false,
181
+ });
182
+
183
+ this.sock = sock;
184
+
185
+ // Persist credential updates
186
+ sock.ev.on('creds.update', saveCreds);
187
+
188
+ // Connection state changes
189
+ sock.ev.on('connection.update', async (update) => {
190
+ const { connection, lastDisconnect, qr } = update;
191
+
192
+ // QR code received — render to SVG
193
+ if (qr) {
194
+ this.qrData = qr;
195
+ try {
196
+ this.qrSvg = await QRCode.toString(qr, { type: 'svg' });
197
+ } catch {
198
+ this.qrSvg = null;
199
+ }
200
+ log.info('[whatsapp] QR code generated — waiting for scan');
201
+ this.emitStatus();
202
+ }
203
+
204
+ if (connection === 'open') {
205
+ this.connected = true;
206
+ this.qrData = null;
207
+ this.qrSvg = null;
208
+ this.buildLidMap();
209
+ log.ok(`[whatsapp] Connected as ${sock.user?.id}`);
210
+ this.emitStatus();
211
+ }
212
+
213
+ if (connection === 'close') {
214
+ this.connected = false;
215
+ this.qrData = null;
216
+ this.qrSvg = null;
217
+
218
+ const statusCode = (lastDisconnect?.error as any)?.output?.statusCode;
219
+ const reason = DisconnectReason[statusCode] || `code ${statusCode}`;
220
+ log.warn(`[whatsapp] Disconnected: ${reason}`);
221
+
222
+ if (this.intentionalDisconnect) return;
223
+
224
+ // Logged out (401) — credentials are invalid, user must re-scan
225
+ if (statusCode === DisconnectReason.loggedOut) {
226
+ log.warn('[whatsapp] Logged out — credentials cleared. Re-scan QR to reconnect.');
227
+ await this.deleteCredentials();
228
+ this.emitStatus();
229
+ return;
230
+ }
231
+
232
+ // Any other disconnect — try to reconnect
233
+ log.info('[whatsapp] Reconnecting in 5s...');
234
+ this.reconnectTimer = setTimeout(() => this.connectInternal(), 5000);
235
+ }
236
+ });
237
+
238
+ // Incoming messages
239
+ sock.ev.on('messages.upsert', (m: BaileysEventMap['messages.upsert']) => {
240
+ if (m.type !== 'notify') return;
241
+
242
+ for (const msg of m.messages) {
243
+ // Skip status broadcasts and protocol messages
244
+ if (msg.key.remoteJid === 'status@broadcast') continue;
245
+ if (!msg.message) continue;
246
+
247
+ // Extract text from various message types
248
+ const text = this.extractText(msg.message);
249
+ if (!text) continue;
250
+
251
+ const fromMe = msg.key.fromMe || false;
252
+ const rawSender = msg.key.remoteJid || '';
253
+
254
+ // Translate LID JIDs to phone JIDs
255
+ const sender = this.translateJid(rawSender);
256
+ const pushName = msg.pushName || undefined;
257
+
258
+ log.info(`[whatsapp] Message from ${sender} (raw=${rawSender}, fromMe=${fromMe}): ${text.slice(0, 80)}`);
259
+
260
+ this.onMessage(sender, pushName, text, fromMe);
261
+ }
262
+ });
263
+ }
264
+
265
+ /** Extract text content from a Baileys message object */
266
+ private extractText(message: any): string | null {
267
+ if (!message) return null;
268
+
269
+ // Direct text
270
+ if (message.conversation) return message.conversation;
271
+ if (message.extendedTextMessage?.text) return message.extendedTextMessage.text;
272
+
273
+ // Image/video/document captions
274
+ if (message.imageMessage?.caption) return message.imageMessage.caption;
275
+ if (message.videoMessage?.caption) return message.videoMessage.caption;
276
+ if (message.documentMessage?.caption) return message.documentMessage.caption;
277
+
278
+ // View-once wrappers
279
+ if (message.viewOnceMessage?.message) return this.extractText(message.viewOnceMessage.message);
280
+ if (message.viewOnceMessageV2?.message) return this.extractText(message.viewOnceMessageV2.message);
281
+
282
+ // Ephemeral wrapper
283
+ if (message.ephemeralMessage?.message) return this.extractText(message.ephemeralMessage.message);
284
+
285
+ // Edited message
286
+ if (message.editedMessage?.message) return this.extractText(message.editedMessage.message);
287
+ if (message.protocolMessage?.editedMessage?.message) {
288
+ return this.extractText(message.protocolMessage.editedMessage.message);
289
+ }
290
+
291
+ return null;
292
+ }
293
+
294
+ private emitStatus() {
295
+ this.onStatusChange(this.getStatus());
296
+ }
297
+ }
@@ -122,6 +122,8 @@ export async function startFluxyAgentQuery(
122
122
  savedFiles?: SavedFile[],
123
123
  names?: { botName: string; humanName: string },
124
124
  recentMessages?: RecentMessage[],
125
+ /** Override system prompt (used for customer-facing channel messages via SUPPORT.md) */
126
+ supportPrompt?: string,
125
127
  ): Promise<void> {
126
128
  const oauthToken = await getClaudeAccessToken();
127
129
  if (!oauthToken) {
@@ -130,13 +132,31 @@ export async function startFluxyAgentQuery(
130
132
  }
131
133
 
132
134
  const abortController = new AbortController();
133
- const basePrompt = readSystemPrompt(names?.botName, names?.humanName);
134
135
  const memoryFiles = readMemoryFiles();
135
136
 
136
- // Build enriched system prompt with memory files and conversation history
137
- let enrichedPrompt = basePrompt;
137
+ // Build enriched system prompt use support prompt for customer-facing channels
138
+ let enrichedPrompt: string;
139
+ if (supportPrompt) {
140
+ // Customer-facing: use the skill's SUPPORT.md as the base prompt
141
+ enrichedPrompt = supportPrompt;
142
+ enrichedPrompt += `\n\n---\n# Your Identity\n\n## MYSELF.md\n${memoryFiles.myself}`;
143
+ enrichedPrompt += `\n\n## MEMORY.md\n${memoryFiles.memory}`;
144
+ } else {
145
+ // Human-facing: use the full main system prompt
146
+ const basePrompt = readSystemPrompt(names?.botName, names?.humanName);
147
+ enrichedPrompt = basePrompt;
148
+ enrichedPrompt += `\n\n---\n# Your Memory Files\n\n## MYSELF.md\n${memoryFiles.myself}\n\n## MYHUMAN.md\n${memoryFiles.myhuman}\n\n## MEMORY.md\n${memoryFiles.memory}\n\n---\n# Your Config Files\n\n## PULSE.json\n${memoryFiles.pulse}\n\n## CRONS.json\n${memoryFiles.crons}`;
149
+ }
138
150
 
139
- enrichedPrompt += `\n\n---\n# Your Memory Files\n\n## MYSELF.md\n${memoryFiles.myself}\n\n## MYHUMAN.md\n${memoryFiles.myhuman}\n\n## MEMORY.md\n${memoryFiles.memory}\n\n---\n# Your Config Files\n\n## PULSE.json\n${memoryFiles.pulse}\n\n## CRONS.json\n${memoryFiles.crons}`;
151
+ // Inject channel config so the agent knows about active channels
152
+ try {
153
+ const { loadConfig: loadCfg } = await import('../shared/config.js');
154
+ const cfg = loadCfg();
155
+ const channels = (cfg as any).channels;
156
+ if (channels) {
157
+ enrichedPrompt += `\n\n---\n# Channel Config\n\`\`\`json\n${JSON.stringify(channels, null, 2)}\n\`\`\``;
158
+ }
159
+ } catch {}
140
160
 
141
161
  if (recentMessages?.length) {
142
162
  enrichedPrompt += `\n\n---\n# Recent Conversation\n${formatConversationHistory(recentMessages)}`;
@@ -19,6 +19,7 @@ import { startViteDevServers, stopViteDevServers } from './vite-dev.js';
19
19
  import { startScheduler, stopScheduler } from './scheduler.js';
20
20
  import { execSync, spawn as cpSpawn } from 'child_process';
21
21
  import crypto from 'crypto';
22
+ import { ChannelManager } from './channels/manager.js';
22
23
 
23
24
  const DIST_FLUXY = path.join(PKG_DIR, 'dist-fluxy');
24
25
 
@@ -304,6 +305,14 @@ export async function startSupervisor() {
304
305
  'GET /api/portal/totp/status',
305
306
  'GET /api/portal/login/totp',
306
307
  'POST /api/portal/devices/revoke',
308
+ 'GET /api/channels/status',
309
+ 'GET /api/channels/whatsapp/qr',
310
+ 'GET /api/channels/whatsapp/qr-page',
311
+ 'POST /api/channels/whatsapp/connect',
312
+ 'POST /api/channels/whatsapp/disconnect',
313
+ 'POST /api/channels/whatsapp/logout',
314
+ 'POST /api/channels/whatsapp/configure',
315
+ 'POST /api/channels/send',
307
316
  ];
308
317
 
309
318
  function isExemptRoute(method: string, url: string): boolean {
@@ -366,6 +375,163 @@ export async function startSupervisor() {
366
375
  return;
367
376
  }
368
377
 
378
+ // ── Channel API routes (handled by supervisor, not worker) ──
379
+ if (req.url?.startsWith('/api/channels')) {
380
+ const channelPath = req.url.split('?')[0];
381
+ res.setHeader('Content-Type', 'application/json');
382
+ res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
383
+
384
+ // GET /api/channels/status — all channel statuses
385
+ if (req.method === 'GET' && channelPath === '/api/channels/status') {
386
+ res.writeHead(200);
387
+ res.end(JSON.stringify(channelManager.getStatuses()));
388
+ return;
389
+ }
390
+
391
+ // GET /api/channels/whatsapp/qr — raw QR SVG data
392
+ if (req.method === 'GET' && channelPath === '/api/channels/whatsapp/qr') {
393
+ const qr = channelManager.getQrCode('whatsapp');
394
+ res.writeHead(200);
395
+ res.end(JSON.stringify({ qr }));
396
+ return;
397
+ }
398
+
399
+ // GET /api/channels/whatsapp/qr-page — standalone QR scanning page
400
+ if (req.method === 'GET' && channelPath === '/api/channels/whatsapp/qr-page') {
401
+ res.setHeader('Content-Type', 'text/html');
402
+ const qr = channelManager.getQrCode('whatsapp');
403
+ const status = channelManager.getStatus('whatsapp');
404
+ const connected = status?.connected || false;
405
+ res.writeHead(200);
406
+ res.end(`<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
407
+ <title>WhatsApp QR</title>
408
+ <style>
409
+ body{background:#222122;color:#fff;font-family:system-ui,-apple-system,sans-serif;display:flex;flex-direction:column;align-items:center;justify-content:center;height:100dvh;margin:0}
410
+ .qr-container{background:#fff;border-radius:16px;padding:24px;max-width:300px}
411
+ .qr-container svg{width:100%;height:auto}
412
+ .status{margin-top:20px;font-size:14px;opacity:0.7}
413
+ .connected{color:#4ade80;font-size:18px;font-weight:600}
414
+ </style></head><body>
415
+ ${connected
416
+ ? '<div class="connected">Connected!</div><p class="status">WhatsApp is linked. You can close this page.</p>'
417
+ : qr
418
+ ? `<div class="qr-container">${qr}</div><p class="status">Scan with WhatsApp to link</p>`
419
+ : '<p class="status">Starting WhatsApp... Refresh in a moment.</p>'}
420
+ ${!connected ? '<script>setTimeout(()=>location.reload(),4000)</script>' : ''}
421
+ </body></html>`);
422
+ return;
423
+ }
424
+
425
+ // POST /api/channels/whatsapp/connect — start WhatsApp connection (triggers QR)
426
+ if (req.method === 'POST' && channelPath === '/api/channels/whatsapp/connect') {
427
+ (async () => {
428
+ try {
429
+ // Enable WhatsApp in config
430
+ const cfg = loadConfig();
431
+ if (!cfg.channels) cfg.channels = {};
432
+ if (!cfg.channels.whatsapp) cfg.channels.whatsapp = { enabled: true, mode: 'channel' };
433
+ cfg.channels.whatsapp.enabled = true;
434
+ saveConfig(cfg);
435
+
436
+ await channelManager.connectWhatsApp();
437
+ res.writeHead(200);
438
+ res.end(JSON.stringify({ ok: true }));
439
+ } catch (err: any) {
440
+ res.writeHead(500);
441
+ res.end(JSON.stringify({ error: err.message }));
442
+ }
443
+ })();
444
+ return;
445
+ }
446
+
447
+ // POST /api/channels/whatsapp/disconnect — disconnect WhatsApp
448
+ if (req.method === 'POST' && channelPath === '/api/channels/whatsapp/disconnect') {
449
+ (async () => {
450
+ try {
451
+ await channelManager.disconnectChannel('whatsapp');
452
+ const cfg = loadConfig();
453
+ if (cfg.channels?.whatsapp) cfg.channels.whatsapp.enabled = false;
454
+ saveConfig(cfg);
455
+ res.writeHead(200);
456
+ res.end(JSON.stringify({ ok: true }));
457
+ } catch (err: any) {
458
+ res.writeHead(500);
459
+ res.end(JSON.stringify({ error: err.message }));
460
+ }
461
+ })();
462
+ return;
463
+ }
464
+
465
+ // POST /api/channels/whatsapp/logout — disconnect + delete credentials
466
+ if (req.method === 'POST' && channelPath === '/api/channels/whatsapp/logout') {
467
+ (async () => {
468
+ try {
469
+ await channelManager.logoutWhatsApp();
470
+ const cfg = loadConfig();
471
+ if (cfg.channels?.whatsapp) cfg.channels.whatsapp.enabled = false;
472
+ saveConfig(cfg);
473
+ res.writeHead(200);
474
+ res.end(JSON.stringify({ ok: true }));
475
+ } catch (err: any) {
476
+ res.writeHead(500);
477
+ res.end(JSON.stringify({ error: err.message }));
478
+ }
479
+ })();
480
+ return;
481
+ }
482
+
483
+ // POST /api/channels/whatsapp/configure — set mode + admins
484
+ if (req.method === 'POST' && channelPath === '/api/channels/whatsapp/configure') {
485
+ let body = '';
486
+ req.on('data', (chunk: Buffer) => { body += chunk.toString(); });
487
+ req.on('end', () => {
488
+ try {
489
+ const data = JSON.parse(body);
490
+ const cfg = loadConfig();
491
+ if (!cfg.channels) cfg.channels = {};
492
+ if (!cfg.channels.whatsapp) cfg.channels.whatsapp = { enabled: true, mode: 'channel' };
493
+ if (data.mode) cfg.channels.whatsapp.mode = data.mode;
494
+ if (data.admins !== undefined) cfg.channels.whatsapp.admins = data.admins;
495
+ saveConfig(cfg);
496
+ res.writeHead(200);
497
+ res.end(JSON.stringify({ ok: true, config: cfg.channels.whatsapp }));
498
+ } catch (err: any) {
499
+ res.writeHead(400);
500
+ res.end(JSON.stringify({ error: err.message }));
501
+ }
502
+ });
503
+ return;
504
+ }
505
+
506
+ // POST /api/channels/send — send a message via any channel
507
+ if (req.method === 'POST' && channelPath === '/api/channels/send') {
508
+ let body = '';
509
+ req.on('data', (chunk: Buffer) => { body += chunk.toString(); });
510
+ req.on('end', async () => {
511
+ try {
512
+ const { channel, to, text } = JSON.parse(body);
513
+ if (!channel || !to || !text) {
514
+ res.writeHead(400);
515
+ res.end(JSON.stringify({ error: 'Missing channel, to, or text' }));
516
+ return;
517
+ }
518
+ await channelManager.sendMessage(channel, to, text);
519
+ res.writeHead(200);
520
+ res.end(JSON.stringify({ ok: true }));
521
+ } catch (err: any) {
522
+ res.writeHead(500);
523
+ res.end(JSON.stringify({ error: err.message }));
524
+ }
525
+ });
526
+ return;
527
+ }
528
+
529
+ // Fallback for unknown channel routes
530
+ res.writeHead(404);
531
+ res.end(JSON.stringify({ error: 'Not found' }));
532
+ return;
533
+ }
534
+
369
535
  // API routes → handled in-process by worker Express app
370
536
  if (req.url?.startsWith('/api')) {
371
537
  // Internal supervisor calls (workerApi) bypass auth — they carry a per-process secret
@@ -1046,6 +1212,33 @@ export async function startSupervisor() {
1046
1212
  getModel: () => loadConfig().ai.model,
1047
1213
  });
1048
1214
 
1215
+ // Initialize channel manager (WhatsApp, Telegram, etc.)
1216
+ const channelManager = new ChannelManager({
1217
+ broadcastFluxy,
1218
+ workerApi,
1219
+ restartBackend: async () => {
1220
+ resetBackendRestarts();
1221
+ await stopBackend();
1222
+ spawnBackend(backendPort);
1223
+ },
1224
+ getModel: () => loadConfig().ai.model,
1225
+ });
1226
+
1227
+ // Broadcast channel status changes to all connected chat clients
1228
+ channelManager.onStatusChange((status) => {
1229
+ broadcastFluxy('channel:status', status);
1230
+ // Also broadcast QR code updates
1231
+ if (status.info?.hasQr) {
1232
+ const qr = channelManager.getQrCode(status.channel);
1233
+ if (qr) broadcastFluxy('channel:qr', { channel: status.channel, qr });
1234
+ }
1235
+ });
1236
+
1237
+ // Auto-init channels (will connect WhatsApp if previously configured)
1238
+ channelManager.init().catch((err) => {
1239
+ log.warn(`[channels] Init failed: ${err.message}`);
1240
+ });
1241
+
1049
1242
  // Watch workspace files for changes — auto-restart backend
1050
1243
  // Catches edits from VS Code, CLI, or any external tool.
1051
1244
  // During agent turns, defers to bot:done (avoids mid-turn restarts).
@@ -1250,6 +1443,7 @@ export async function startSupervisor() {
1250
1443
  // Shutdown
1251
1444
  const shutdown = async () => {
1252
1445
  log.info('Shutting down...');
1446
+ await channelManager.disconnectAll();
1253
1447
  stopScheduler();
1254
1448
  backendWatcher.close();
1255
1449
  workspaceWatcher.close();
@@ -202,6 +202,94 @@ Complex cron tasks can have detailed instruction files in `tasks/{cron-id}.md`.
202
202
 
203
203
  ---
204
204
 
205
+ ## Channels (WhatsApp, Telegram, etc.)
206
+
207
+ You can communicate through messaging channels beyond the chat bubble. Currently supported: **WhatsApp**.
208
+
209
+ ### CRITICAL: How WhatsApp Responses Work
210
+
211
+ **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.
212
+
213
+ **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.
214
+
215
+ **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.
216
+
217
+ ### Channel Config
218
+
219
+ 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
+
221
+ ### How Channels Work
222
+
223
+ When a message arrives via WhatsApp, the supervisor wraps it with context:
224
+ ```
225
+ [WhatsApp | 5511999888777 | customer | Alice]
226
+ Hi, I'd like to schedule an appointment.
227
+ ```
228
+
229
+ The format is: `[Channel | phone | role | name (optional)]`
230
+
231
+ - **role=admin**: This is your human or an authorized admin. Use your normal personality, full capabilities, main system prompt.
232
+ - **role=customer**: This is someone else messaging. You're in **support mode** — follow the instructions from your active skill's SUPPORT.md.
233
+
234
+ ### WhatsApp Modes
235
+
236
+ **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.
237
+
238
+ **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).
239
+
240
+ ### Setting Up WhatsApp
241
+
242
+ When your human asks to configure WhatsApp:
243
+ 1. Start the connection: `curl -s -X POST http://localhost:3000/api/channels/whatsapp/connect`
244
+ 2. Tell them to open the QR page: `http://localhost:3000/api/channels/whatsapp/qr-page` (or create a dashboard page that embeds it)
245
+ 3. They scan the QR with their WhatsApp app
246
+ 4. The default mode is **channel** (self-chat only)
247
+
248
+ To switch to **business mode** with admin numbers:
249
+ ```bash
250
+ curl -s -X POST http://localhost:3000/api/channels/whatsapp/configure \
251
+ -H "Content-Type: application/json" \
252
+ -d '{"mode":"business","admins":["+17865551234","+5511999887766"]}'
253
+ ```
254
+
255
+ ### Sending Proactive Messages
256
+
257
+ To INITIATE a WhatsApp message (during pulse, cron, or when you want to reach out first):
258
+ ```bash
259
+ curl -s -X POST http://localhost:3000/api/channels/send \
260
+ -H "Content-Type: application/json" \
261
+ -d '{"channel":"whatsapp","to":"5511999888777","text":"Your appointment is confirmed for tomorrow at 2pm."}'
262
+ ```
263
+
264
+ **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.
265
+
266
+ ### Customer Conversation Logs
267
+
268
+ When you finish a conversation with a **customer** via WhatsApp, save a summary to `whatsapp/{phone}.md`:
269
+ - Key details from the conversation
270
+ - Outcome (appointment scheduled, question answered, etc.)
271
+ - Any follow-ups needed
272
+ - Timestamp
273
+
274
+ This is your memory of that customer. Next time they message, read their file first.
275
+
276
+ ### Channel API Reference
277
+
278
+ | Endpoint | Method | Purpose |
279
+ |----------|--------|---------|
280
+ | `/api/channels/status` | GET | List all channel statuses |
281
+ | `/api/channels/whatsapp/qr` | GET | Get current QR code SVG |
282
+ | `/api/channels/whatsapp/qr-page` | GET | Standalone QR scanning page |
283
+ | `/api/channels/whatsapp/connect` | POST | Start WhatsApp (triggers QR if needed) |
284
+ | `/api/channels/whatsapp/disconnect` | POST | Disconnect WhatsApp |
285
+ | `/api/channels/whatsapp/logout` | POST | Disconnect + delete credentials |
286
+ | `/api/channels/whatsapp/configure` | POST | Set mode + admins array |
287
+ | `/api/channels/send` | POST | Send proactive message via any channel |
288
+
289
+ All endpoints are on `http://localhost:3000`.
290
+
291
+ ---
292
+
205
293
  ## Dashboard Linking
206
294
 
207
295
  When your human gives you a claim code (format: XXXX-XXXX-XXXX-XXXX) to link you to their fluxy.bot dashboard, read your relay token from `~/.fluxy/config.json` (field: `relay.token`) and verify it: `curl -s -X POST https://api.fluxy.bot/api/claim/verify -H "Content-Type: application/json" -H "Authorization: Bearer <relay_token>" -d '{"code":"<THE_CODE>"}'`. Tell your human whether it succeeded or failed.
@@ -161,7 +161,7 @@ export default function App() {
161
161
  <Route path="*" element={<DashboardPage />} />
162
162
  </Routes>
163
163
  </DashboardLayout>
164
- <WorkspaceTour />
164
+ <WorkspaceTour disabled={showOnboard} />
165
165
  </ErrorBoundary>
166
166
 
167
167
  {showOnboard && (