fluxy-bot 0.15.0 → 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.15.0",
3
+ "version": "0.15.1",
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
+ /** '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
+ }
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,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
+ }
@@ -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 = 'human' | 'customer';
7
+
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;
14
+ }
15
+
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;
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
+ }
@@ -0,0 +1,252 @@
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; // raw QR string data
34
+ private qrSvg: string | null = null; // SVG rendered QR
35
+ private onMessage: OnWhatsAppMessage;
36
+ private onStatusChange: (status: ChannelStatus) => void;
37
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
38
+ private intentionalDisconnect = false;
39
+
40
+ constructor(
41
+ onMessage: OnWhatsAppMessage,
42
+ onStatusChange: (status: ChannelStatus) => void,
43
+ ) {
44
+ this.onMessage = onMessage;
45
+ this.onStatusChange = onStatusChange;
46
+ }
47
+
48
+ async connect(): Promise<void> {
49
+ this.intentionalDisconnect = false;
50
+ await this.connectInternal();
51
+ }
52
+
53
+ async disconnect(): Promise<void> {
54
+ this.intentionalDisconnect = true;
55
+ if (this.reconnectTimer) {
56
+ clearTimeout(this.reconnectTimer);
57
+ this.reconnectTimer = null;
58
+ }
59
+ if (this.sock) {
60
+ this.sock.end(undefined);
61
+ this.sock = null;
62
+ }
63
+ this.connected = false;
64
+ this.qrData = null;
65
+ this.qrSvg = null;
66
+ this.emitStatus();
67
+ }
68
+
69
+ async sendMessage(to: string, text: string): Promise<void> {
70
+ if (!this.sock || !this.connected) {
71
+ log.warn('[whatsapp] Cannot send — not connected');
72
+ return;
73
+ }
74
+ // Normalize: ensure JID format (number@s.whatsapp.net)
75
+ const jid = to.includes('@') ? to : `${to.replace(/[^0-9]/g, '')}@s.whatsapp.net`;
76
+ await this.sock.sendMessage(jid, { text });
77
+ log.info(`[whatsapp] Sent message to ${jid}`);
78
+ }
79
+
80
+ getStatus(): ChannelStatus {
81
+ return {
82
+ channel: 'whatsapp',
83
+ connected: this.connected,
84
+ info: {
85
+ hasQr: !!this.qrData,
86
+ hasCredentials: this.hasCredentials(),
87
+ phoneNumber: this.sock?.user?.id?.split(':')[0] || null,
88
+ },
89
+ };
90
+ }
91
+
92
+ getQrCode(): string | null {
93
+ return this.qrSvg;
94
+ }
95
+
96
+ hasCredentials(): boolean {
97
+ return fs.existsSync(path.join(AUTH_DIR, 'creds.json'));
98
+ }
99
+
100
+ /** Delete stored credentials (for re-auth / logout) */
101
+ async deleteCredentials(): Promise<void> {
102
+ try {
103
+ if (fs.existsSync(AUTH_DIR)) {
104
+ fs.rmSync(AUTH_DIR, { recursive: true, force: true });
105
+ }
106
+ } catch (err: any) {
107
+ log.warn(`[whatsapp] Failed to delete credentials: ${err.message}`);
108
+ }
109
+ }
110
+
111
+ // ── Internal ──
112
+
113
+ private async connectInternal(): Promise<void> {
114
+ // Ensure auth directory exists
115
+ fs.mkdirSync(AUTH_DIR, { recursive: true });
116
+
117
+ const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
118
+
119
+ // Suppress Baileys' noisy logging
120
+ const logger = pino({ level: 'silent' }) as any;
121
+
122
+ let version: [number, number, number] | undefined;
123
+ try {
124
+ const result = await fetchLatestWaWebVersion({});
125
+ version = result.version;
126
+ } catch {
127
+ log.warn('[whatsapp] Could not fetch latest WA version — using default');
128
+ }
129
+
130
+ const sock = makeWASocket({
131
+ auth: {
132
+ creds: state.creds,
133
+ keys: makeCacheableSignalKeyStore(state.keys, logger),
134
+ },
135
+ version,
136
+ browser: Browsers.macOS('Chrome'),
137
+ printQRInTerminal: false,
138
+ logger,
139
+ generateHighQualityLinkPreview: false,
140
+ });
141
+
142
+ this.sock = sock;
143
+
144
+ // Persist credential updates
145
+ sock.ev.on('creds.update', saveCreds);
146
+
147
+ // Connection state changes
148
+ sock.ev.on('connection.update', async (update) => {
149
+ const { connection, lastDisconnect, qr } = update;
150
+
151
+ // QR code received — render to SVG
152
+ if (qr) {
153
+ this.qrData = qr;
154
+ try {
155
+ this.qrSvg = await QRCode.toString(qr, { type: 'svg' });
156
+ } catch {
157
+ this.qrSvg = null;
158
+ }
159
+ log.info('[whatsapp] QR code generated — waiting for scan');
160
+ this.emitStatus();
161
+ }
162
+
163
+ if (connection === 'open') {
164
+ this.connected = true;
165
+ this.qrData = null;
166
+ this.qrSvg = null;
167
+ log.ok(`[whatsapp] Connected as ${sock.user?.id}`);
168
+ this.emitStatus();
169
+ }
170
+
171
+ if (connection === 'close') {
172
+ this.connected = false;
173
+ this.qrData = null;
174
+ this.qrSvg = null;
175
+
176
+ const statusCode = (lastDisconnect?.error as any)?.output?.statusCode;
177
+ const reason = DisconnectReason[statusCode] || `code ${statusCode}`;
178
+ log.warn(`[whatsapp] Disconnected: ${reason}`);
179
+
180
+ if (this.intentionalDisconnect) return;
181
+
182
+ // Logged out (401) — credentials are invalid, user must re-scan
183
+ if (statusCode === DisconnectReason.loggedOut) {
184
+ log.warn('[whatsapp] Logged out — credentials cleared. Re-scan QR to reconnect.');
185
+ await this.deleteCredentials();
186
+ this.emitStatus();
187
+ return;
188
+ }
189
+
190
+ // Any other disconnect — try to reconnect
191
+ log.info('[whatsapp] Reconnecting in 5s...');
192
+ this.reconnectTimer = setTimeout(() => this.connectInternal(), 5000);
193
+ }
194
+ });
195
+
196
+ // Incoming messages
197
+ sock.ev.on('messages.upsert', (m: BaileysEventMap['messages.upsert']) => {
198
+ if (m.type !== 'notify') return;
199
+
200
+ for (const msg of m.messages) {
201
+ // Skip status broadcasts and protocol messages
202
+ if (msg.key.remoteJid === 'status@broadcast') continue;
203
+ if (!msg.message) continue;
204
+
205
+ // Extract text from various message types
206
+ const text = this.extractText(msg.message);
207
+ if (!text) continue;
208
+
209
+ const fromMe = msg.key.fromMe || false;
210
+ const sender = msg.key.remoteJid || '';
211
+ const pushName = msg.pushName || undefined;
212
+
213
+ log.info(`[whatsapp] Message from ${sender} (fromMe=${fromMe}): ${text.slice(0, 80)}`);
214
+
215
+ this.onMessage(sender, pushName, text, fromMe);
216
+ }
217
+ });
218
+ }
219
+
220
+ /** Extract text content from a Baileys message object */
221
+ private extractText(message: any): string | null {
222
+ if (!message) return null;
223
+
224
+ // Direct text
225
+ if (message.conversation) return message.conversation;
226
+ if (message.extendedTextMessage?.text) return message.extendedTextMessage.text;
227
+
228
+ // Image/video/document captions
229
+ if (message.imageMessage?.caption) return message.imageMessage.caption;
230
+ if (message.videoMessage?.caption) return message.videoMessage.caption;
231
+ if (message.documentMessage?.caption) return message.documentMessage.caption;
232
+
233
+ // View-once wrappers
234
+ if (message.viewOnceMessage?.message) return this.extractText(message.viewOnceMessage.message);
235
+ if (message.viewOnceMessageV2?.message) return this.extractText(message.viewOnceMessageV2.message);
236
+
237
+ // Ephemeral wrapper
238
+ if (message.ephemeralMessage?.message) return this.extractText(message.ephemeralMessage.message);
239
+
240
+ // Edited message
241
+ if (message.editedMessage?.message) return this.extractText(message.editedMessage.message);
242
+ if (message.protocolMessage?.editedMessage?.message) {
243
+ return this.extractText(message.protocolMessage.editedMessage.message);
244
+ }
245
+
246
+ return null;
247
+ }
248
+
249
+ private emitStatus() {
250
+ this.onStatusChange(this.getStatus());
251
+ }
252
+ }
@@ -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: 'shared' };
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 + human phone
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: 'shared' };
493
+ if (data.mode) cfg.channels.whatsapp.mode = data.mode;
494
+ if (data.humanPhone !== undefined) cfg.channels.whatsapp.humanPhone = data.humanPhone;
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,73 @@ 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
+ ### Channel Config
210
+
211
+ 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.
212
+
213
+ ### How Channels Work
214
+
215
+ When a message arrives via WhatsApp, the supervisor wraps it with context:
216
+ ```
217
+ [WhatsApp | 5511999888777 | customer | Alice]
218
+ Hi, I'd like to schedule an appointment.
219
+ ```
220
+
221
+ The format is: `[Channel | phone | role | name (optional)]`
222
+
223
+ - **role=human**: This is YOUR human (the owner). Use your normal personality, full capabilities, main system prompt.
224
+ - **role=customer**: This is someone else messaging. You're in **support mode** — follow the instructions from your active skill's SUPPORT.md.
225
+
226
+ ### Setting Up WhatsApp
227
+
228
+ When your human asks to configure WhatsApp:
229
+ 1. Start the connection: `curl -s -X POST http://localhost:3000/api/channels/whatsapp/connect`
230
+ 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
+ 3. They scan the QR with their WhatsApp app
232
+ 4. Once connected, configure the mode:
233
+ - **Shared number** (default): Your human's own WhatsApp. Messages they send to themselves trigger you.
234
+ - **Dedicated number**: A separate phone/SIM for you. Configure with: `curl -s -X POST http://localhost:3000/api/channels/whatsapp/configure -H "Content-Type: application/json" -d '{"mode":"dedicated","humanPhone":"+1234567890"}'`
235
+
236
+ ### Sending Messages
237
+
238
+ To send a WhatsApp message (during pulse, cron, or any time):
239
+ ```bash
240
+ curl -s -X POST http://localhost:3000/api/channels/send \
241
+ -H "Content-Type: application/json" \
242
+ -d '{"channel":"whatsapp","to":"5511999888777","text":"Your appointment is confirmed for tomorrow at 2pm."}'
243
+ ```
244
+
245
+ ### Customer Conversation Logs
246
+
247
+ When you finish a conversation with a **customer** via WhatsApp, save a summary to `whatsapp/{phone}.md`:
248
+ - Key details from the conversation
249
+ - Outcome (appointment scheduled, question answered, etc.)
250
+ - Any follow-ups needed
251
+ - Timestamp
252
+
253
+ This is your memory of that customer. Next time they message, read their file first.
254
+
255
+ ### Channel API Reference
256
+
257
+ | Endpoint | Method | Purpose |
258
+ |----------|--------|---------|
259
+ | `/api/channels/status` | GET | List all channel statuses |
260
+ | `/api/channels/whatsapp/qr` | GET | Get current QR code SVG |
261
+ | `/api/channels/whatsapp/qr-page` | GET | Standalone QR scanning page |
262
+ | `/api/channels/whatsapp/connect` | POST | Start WhatsApp (triggers QR if needed) |
263
+ | `/api/channels/whatsapp/disconnect` | POST | Disconnect WhatsApp |
264
+ | `/api/channels/whatsapp/logout` | POST | Disconnect + delete credentials |
265
+ | `/api/channels/whatsapp/configure` | POST | Set mode + human phone number |
266
+ | `/api/channels/send` | POST | Send message via any channel |
267
+
268
+ All endpoints are on `http://localhost:3000`.
269
+
270
+ ---
271
+
205
272
  ## Dashboard Linking
206
273
 
207
274
  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.
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "whatsapp-support",
3
+ "description": "WhatsApp customer support skill — handles incoming customer messages with a friendly support persona",
4
+ "version": "0.1.0"
5
+ }
@@ -0,0 +1,21 @@
1
+ # WhatsApp Support Skill
2
+
3
+ This skill enables customer support via WhatsApp.
4
+
5
+ ## Setup
6
+
7
+ 1. Ask your human to configure WhatsApp: `curl -s -X POST http://localhost:3000/api/channels/whatsapp/connect`
8
+ 2. Direct them to scan the QR code at `http://localhost:3000/api/channels/whatsapp/qr-page`
9
+ 3. Once connected, configure the mode if needed (shared or dedicated number)
10
+
11
+ ## Sending Messages
12
+
13
+ ```bash
14
+ curl -s -X POST http://localhost:3000/api/channels/send \
15
+ -H "Content-Type: application/json" \
16
+ -d '{"channel":"whatsapp","to":"PHONE_NUMBER","text":"Your message here"}'
17
+ ```
18
+
19
+ ## Customer Logs
20
+
21
+ After each customer conversation, save a summary to `whatsapp/{phone}.md` so you remember them next time.
@@ -0,0 +1,38 @@
1
+ # Support Mode
2
+
3
+ You are a friendly and helpful assistant responding to a customer via WhatsApp. You are NOT talking to your human/owner — you are talking to someone who reached out for help.
4
+
5
+ ## Your Behavior
6
+
7
+ - Be warm, professional, and concise — this is WhatsApp, not email. Keep messages short.
8
+ - Greet the customer by name if you know it (check `whatsapp/{phone}.md` for past interactions).
9
+ - Answer questions based on what you know from your memory files and any skill documents.
10
+ - If you don't know the answer, say so honestly and offer to have the owner follow up.
11
+ - Never reveal internal system details, file paths, or technical architecture.
12
+ - Never run destructive commands or modify critical files during customer interactions.
13
+
14
+ ## What You Can Do
15
+
16
+ - Answer FAQs and general questions
17
+ - Provide information from files in your workspace
18
+ - Look up data from your backend API (`/app/api/*`)
19
+ - Send follow-up messages via the channel API
20
+
21
+ ## What You Should NOT Do
22
+
23
+ - Share pricing or make commitments you're not sure about — defer to the owner
24
+ - Access or share other customers' information
25
+ - Run commands that modify the system (no Write/Edit to critical files)
26
+
27
+ ## Conversation Flow
28
+
29
+ 1. Greet the customer
30
+ 2. Understand what they need
31
+ 3. Help them or let them know the owner will follow up
32
+ 4. After the conversation, save a summary to `whatsapp/{phone}.md`
33
+
34
+ ## Message Style
35
+
36
+ - Use short paragraphs (1-2 sentences max per message)
37
+ - Use emojis sparingly — one or two is fine, don't overdo it
38
+ - Be direct — WhatsApp users expect quick responses
@@ -1,5 +0,0 @@
1
- {
2
- "name": "code-reviewer",
3
- "version": "1.0.0",
4
- "description": "Reviews code changes and provides improvement suggestions."
5
- }
@@ -1,36 +0,0 @@
1
- ---
2
- name: code-reviewer
3
- description: Reviews code changes, provides suggestions for improvement, identifies bugs, and enforces best practices. Use this skill when the user asks you to review code, check for issues, suggest improvements, or audit changes before committing.
4
- ---
5
-
6
- # Code Reviewer
7
-
8
- ## Overview
9
- This skill helps review code in the Fluxy workspace — identifying bugs, suggesting improvements, and enforcing best practices for the React + Express stack.
10
-
11
- ## When to Activate
12
- - User asks to "review", "check", or "audit" code
13
- - User asks for feedback on their changes
14
- - User asks about code quality, best practices, or potential issues
15
-
16
- ## Review Checklist
17
-
18
- ### Frontend (React + Tailwind)
19
- 1. Component structure: proper use of props, state, and effects
20
- 2. Performance: unnecessary re-renders, missing memoization
21
- 3. Accessibility: semantic HTML, ARIA attributes, keyboard navigation
22
- 4. Styling: consistent use of Tailwind classes, responsive design
23
- 5. Error handling: error boundaries, loading states, fallbacks
24
-
25
- ### Backend (Express + SQLite)
26
- 1. Route structure: proper HTTP methods, status codes, error responses
27
- 2. Input validation: sanitize user input, check required fields
28
- 3. Database: parameterized queries, proper error handling
29
- 4. Security: no exposed secrets, proper auth checks
30
- 5. Performance: avoid N+1 queries, use appropriate indexes
31
-
32
- ## Output Format
33
- When reviewing code, provide:
34
- - **Issues**: Bugs or potential problems (with severity)
35
- - **Suggestions**: Improvements that would help (with rationale)
36
- - **Praise**: Things done well (reinforces good patterns)
@@ -1,5 +0,0 @@
1
- {
2
- "name": "daily-standup",
3
- "version": "1.0.0",
4
- "description": "Generates daily standup summaries from recent workspace activity."
5
- }
@@ -1,42 +0,0 @@
1
- ---
2
- name: daily-standup
3
- description: Generates daily standup summaries by analyzing recent file changes, git history, and workspace activity. Use this skill when the user asks for a standup update, daily summary, progress report, or wants to know what changed recently.
4
- ---
5
-
6
- # Daily Standup
7
-
8
- ## Overview
9
- This skill generates concise daily standup reports by examining recent changes in the Fluxy workspace — git commits, file modifications, and project activity.
10
-
11
- ## When to Activate
12
- - User asks for a "standup", "daily update", or "progress report"
13
- - User asks "what changed recently?" or "what did I work on?"
14
- - User wants a summary of recent activity
15
-
16
- ## How to Generate a Standup
17
-
18
- 1. **Check git log** for recent commits (last 24 hours or since last standup)
19
- 2. **Check modified files** using git status and diff
20
- 3. **Identify patterns**: new features, bug fixes, refactors, documentation
21
-
22
- ## Output Format
23
-
24
- ### Daily Standup — {date}
25
-
26
- **Completed:**
27
- - List of completed work items based on commits and changes
28
-
29
- **In Progress:**
30
- - Uncommitted changes or partially completed work
31
-
32
- **Blockers:**
33
- - Any issues identified from error logs or failing tests
34
-
35
- **Next Steps:**
36
- - Suggested priorities based on the current state of the project
37
-
38
- ## Rules
39
- 1. Keep it concise — no more than 2-3 bullet points per section
40
- 2. Focus on what matters — skip trivial changes like formatting
41
- 3. Use plain language — avoid overly technical jargon
42
- 4. Link to specific files when helpful
@@ -1,5 +0,0 @@
1
- {
2
- "name": "workspace-helper",
3
- "version": "1.0.0",
4
- "description": "Helps manage and understand the Fluxy workspace structure."
5
- }
@@ -1,55 +0,0 @@
1
- ---
2
- name: workspace-helper
3
- description: Helps manage and understand the Fluxy workspace structure. Use this skill whenever the user asks about the project layout, file organization, where things are, how the workspace is structured, or needs help navigating the codebase. Also use when the user asks to scaffold new components, pages, or API routes.
4
- ---
5
-
6
- # Workspace Helper
7
-
8
- ## Overview
9
- This skill helps navigate and manage the Fluxy workspace — a full-stack app with a React + Vite + Tailwind frontend and an Express backend.
10
-
11
- ## Workspace Structure
12
-
13
- ```
14
- workspace/
15
- client/ React + Vite + Tailwind frontend
16
- index.html HTML shell, PWA manifest
17
- src/
18
- main.tsx React DOM entry
19
- App.tsx Root component with error boundary
20
- components/ UI components
21
- backend/
22
- index.ts Express server (port 3004, accessed at /app/api/*)
23
- .env Environment variables for the backend
24
- app.db SQLite database for workspace data
25
- files/ Uploaded file storage (audio, images, documents)
26
- ```
27
-
28
- ## Key Rules
29
-
30
- 1. The **frontend** is served by Vite with HMR — changes are picked up instantly
31
- 2. The **backend** runs on port 3004, proxied through `/app/api/*` — the `/app/api` prefix is stripped, so define routes as `/health` not `/app/api/health`
32
- 3. The backend auto-restarts when you edit files
33
- 4. You may ONLY modify files inside the `workspace/` directory
34
- 5. NEVER touch `supervisor/`, `worker/`, `shared/`, or `bin/`
35
-
36
- ## When Adding New Pages
37
-
38
- 1. Create the component in `client/src/components/`
39
- 2. Add a route in `client/src/App.tsx`
40
- 3. Use Tailwind for styling — no separate CSS files needed
41
-
42
- ## When Adding New API Routes
43
-
44
- 1. Add the route in `backend/index.ts`
45
- 2. Remember: routes are relative (e.g., `app.get('/my-route', ...)`)
46
- 3. The frontend calls them at `/app/api/my-route`
47
- 4. Use the existing `app.db` SQLite database if persistence is needed
48
-
49
- ## When Asked "Where is X?"
50
-
51
- Read the relevant files to find the answer. Start with:
52
- - Frontend components: `client/src/components/`
53
- - App entry: `client/src/App.tsx`
54
- - Backend routes: `backend/index.ts`
55
- - Environment config: `.env`