fluxy-bot 0.15.0 → 0.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/scripts/install.ps1 +11 -0
- package/scripts/install.sh +6 -0
- package/shared/config.ts +11 -0
- package/supervisor/channels/manager.ts +414 -0
- package/supervisor/channels/types.ts +57 -0
- package/supervisor/channels/whatsapp.ts +297 -0
- package/supervisor/fluxy-agent.ts +24 -4
- package/supervisor/index.ts +194 -0
- package/worker/prompts/fluxy-system-prompt.txt +88 -0
- package/workspace/client/src/App.tsx +1 -1
- package/workspace/client/src/components/deleteme_onboarding/WorkspaceTour.tsx +3 -2
- package/workspace/skills/whatsapp-support/.claude-plugin/plugin.json +5 -0
- package/workspace/skills/whatsapp-support/SKILL.md +21 -0
- package/workspace/skills/whatsapp-support/SUPPORT.md +41 -0
- package/workspace/skills/code-reviewer/.claude-plugin/plugin.json +0 -5
- package/workspace/skills/code-reviewer/SKILL.md +0 -36
- package/workspace/skills/daily-standup/.claude-plugin/plugin.json +0 -5
- package/workspace/skills/daily-standup/SKILL.md +0 -42
- package/workspace/skills/workspace-helper/.claude-plugin/plugin.json +0 -5
- package/workspace/skills/workspace-helper/SKILL.md +0 -55
|
@@ -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
|
|
137
|
-
let enrichedPrompt
|
|
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
|
-
|
|
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)}`;
|
package/supervisor/index.ts
CHANGED
|
@@ -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.
|