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 +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 +393 -0
- package/supervisor/channels/types.ts +57 -0
- package/supervisor/channels/whatsapp.ts +252 -0
- package/supervisor/fluxy-agent.ts +24 -4
- package/supervisor/index.ts +194 -0
- package/worker/prompts/fluxy-system-prompt.txt +67 -0
- 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 +38 -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fluxy-bot",
|
|
3
|
-
"version": "0.15.
|
|
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",
|
package/scripts/install.ps1
CHANGED
|
@@ -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)) {
|
package/scripts/install.sh
CHANGED
|
@@ -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
|
|
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: '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,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,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,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,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`
|