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