fluxy-bot 0.13.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fluxy-bot",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "releaseNotes": [
5
5
  "1. react router implemented",
6
6
  "2. new workspace design",
package/shared/config.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import fs from 'fs';
2
2
  import { paths, DATA_DIR } from './paths.js';
3
3
 
4
+ export type ChannelType = 'chat' | 'whatsapp' | 'telegram';
5
+
4
6
  export interface BotConfig {
5
7
  port: number;
6
8
  username: string;
@@ -26,6 +28,16 @@ export interface BotConfig {
26
28
  address: string;
27
29
  };
28
30
  tunnelUrl?: string;
31
+ channels?: {
32
+ whatsapp?: { enabled: boolean };
33
+ telegram?: { enabled: boolean; botToken?: string };
34
+ };
35
+ customerMode?: {
36
+ enabled: boolean;
37
+ systemPromptFile?: string;
38
+ businessName?: string;
39
+ businessDescription?: string;
40
+ };
29
41
  }
30
42
 
31
43
  const DEFAULTS: BotConfig = {
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Chat Channel — wraps the existing WebSocket chat as a Channel.
3
+ * Chat is ALWAYS the owner channel. This adapter bridges the existing
4
+ * WS broadcast system with the Channel interface.
5
+ */
6
+
7
+ import type { Channel, ChannelRouter, OutgoingMessage } from './types.js';
8
+
9
+ export interface ChatChannelOpts {
10
+ broadcastFluxy: (type: string, data: any) => void;
11
+ }
12
+
13
+ export class ChatChannel implements Channel {
14
+ readonly type = 'chat' as const;
15
+ private opts: ChatChannelOpts;
16
+
17
+ constructor(opts: ChatChannelOpts) {
18
+ this.opts = opts;
19
+ }
20
+
21
+ async initialize(_router: ChannelRouter): Promise<void> {
22
+ // Chat channel is always initialized — WS server is managed by supervisor
23
+ }
24
+
25
+ async sendMessage(_to: string, message: OutgoingMessage): Promise<void> {
26
+ // Route through existing broadcast system
27
+ this.opts.broadcastFluxy('chat:sync', {
28
+ conversationId: message.conversationKey,
29
+ message: {
30
+ role: 'assistant',
31
+ content: message.content,
32
+ timestamp: new Date().toISOString(),
33
+ },
34
+ });
35
+ }
36
+
37
+ ownsConversation(conversationKey: string): boolean {
38
+ return conversationKey.startsWith('chat:');
39
+ }
40
+
41
+ async shutdown(): Promise<void> {
42
+ // WS server lifecycle is managed by supervisor
43
+ }
44
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Channel registry — initializes and registers all enabled channels.
3
+ */
4
+
5
+ import type { BotConfig } from '../../shared/config.js';
6
+ import { log } from '../../shared/logger.js';
7
+ import { initRoleResolver } from './role-resolver.js';
8
+ import { ChannelRouter, type ChannelRouterOpts } from './router.js';
9
+ import { ChatChannel } from './chat-channel.js';
10
+
11
+ export { ChannelRouter } from './router.js';
12
+ export type { Channel, ChannelType, SenderRole, SenderIdentity, IncomingMessage, OutgoingMessage } from './types.js';
13
+
14
+ /**
15
+ * Create and initialize the channel router with all enabled channels.
16
+ */
17
+ export async function initializeChannels(
18
+ config: BotConfig,
19
+ opts: ChannelRouterOpts,
20
+ ): Promise<ChannelRouter> {
21
+ // Initialize the role resolver with worker API access
22
+ initRoleResolver({ workerApi: opts.workerApi });
23
+
24
+ // Create the router
25
+ const router = new ChannelRouter(opts);
26
+
27
+ // Chat channel is always registered
28
+ const chatChannel = new ChatChannel({ broadcastFluxy: opts.broadcastFluxy });
29
+ router.registerChannel(chatChannel);
30
+ await chatChannel.initialize(router);
31
+
32
+ // WhatsApp channel — registered if enabled in config
33
+ // The actual implementation is done by Fluxy as a skill.
34
+ // See docs/whatsapp-channel-guide.md for implementation details.
35
+ if (config.channels?.whatsapp?.enabled) {
36
+ log.info('[channels] WhatsApp is enabled in config — waiting for channel implementation to register');
37
+ // The WhatsApp channel will self-register via router.registerChannel()
38
+ // when the skill implementation calls it during initialization.
39
+ }
40
+
41
+ // Telegram channel — placeholder for future implementation
42
+ if (config.channels?.telegram?.enabled) {
43
+ log.info('[channels] Telegram is enabled in config — waiting for channel implementation to register');
44
+ }
45
+
46
+ log.ok(`[channels] Initialized with ${router.getAllChannels().length} channel(s)`);
47
+ return router;
48
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Role resolver for multi-channel access control.
3
+ * Chat is ALWAYS owner. Other channels look up the contacts DB.
4
+ */
5
+
6
+ import type { ChannelType, SenderRole } from './types.js';
7
+
8
+ export interface RoleResolverOpts {
9
+ workerApi: (path: string, method?: string, body?: any) => Promise<any>;
10
+ }
11
+
12
+ let opts: RoleResolverOpts | null = null;
13
+
14
+ export function initRoleResolver(resolverOpts: RoleResolverOpts) {
15
+ opts = resolverOpts;
16
+ }
17
+
18
+ /**
19
+ * Resolve the role of a sender.
20
+ * Chat channel is ALWAYS owner — no lookup needed.
21
+ * Other channels check the contacts DB via the worker API.
22
+ */
23
+ export async function resolveRole(channelType: ChannelType, senderId: string): Promise<SenderRole> {
24
+ // Chat is sacred — always the owner
25
+ if (channelType === 'chat') return 'owner';
26
+
27
+ if (!opts) return 'customer';
28
+
29
+ try {
30
+ const result = await opts.workerApi(
31
+ `/api/contacts/resolve?channel_type=${encodeURIComponent(channelType)}&identifier=${encodeURIComponent(senderId)}`
32
+ );
33
+ if (result?.role && ['owner', 'admin', 'customer'].includes(result.role)) {
34
+ return result.role as SenderRole;
35
+ }
36
+ } catch {
37
+ // If lookup fails, default to customer (safest)
38
+ }
39
+
40
+ return 'customer';
41
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Channel Router — central orchestrator for multi-channel message handling.
3
+ * Receives messages from any channel, resolves roles, picks the right agent,
4
+ * and routes responses back to the originating channel.
5
+ */
6
+
7
+ import { log } from '../../shared/logger.js';
8
+ import { resolveRole } from './role-resolver.js';
9
+ import { startFluxyAgentQuery, startCustomerAgentQuery, type RecentMessage, type CustomerContext } from '../fluxy-agent.js';
10
+ import type { Channel, ChannelType, IncomingMessage, OutgoingMessage, SenderRole, ChannelRouter as IChannelRouter } from './types.js';
11
+ import { loadConfig } from '../../shared/config.js';
12
+
13
+ export interface ChannelRouterOpts {
14
+ workerApi: (path: string, method?: string, body?: any) => Promise<any>;
15
+ broadcastFluxy: (type: string, data: any) => void;
16
+ getModel: () => string;
17
+ restartBackend: () => void;
18
+ }
19
+
20
+ // Per-conversationKey lock to allow concurrent queries across channels
21
+ const activeChannelQueries = new Map<string, boolean>();
22
+
23
+ export class ChannelRouter implements IChannelRouter {
24
+ private channels = new Map<ChannelType, Channel>();
25
+ private opts: ChannelRouterOpts;
26
+
27
+ constructor(opts: ChannelRouterOpts) {
28
+ this.opts = opts;
29
+ }
30
+
31
+ registerChannel(channel: Channel): void {
32
+ this.channels.set(channel.type, channel);
33
+ log.info(`[router] Registered channel: ${channel.type}`);
34
+ }
35
+
36
+ getChannel(type: ChannelType): Channel | undefined {
37
+ return this.channels.get(type);
38
+ }
39
+
40
+ /** Get all registered channels (for shutdown, etc.) */
41
+ getAllChannels(): Channel[] {
42
+ return Array.from(this.channels.values());
43
+ }
44
+
45
+ getChannelForConversation(conversationKey: string): Channel | undefined {
46
+ const channelType = conversationKey.split(':')[0] as ChannelType;
47
+ return this.channels.get(channelType);
48
+ }
49
+
50
+ /**
51
+ * Handle an incoming message from any channel.
52
+ * Resolves role, creates/reuses conversation, dispatches to the right agent.
53
+ */
54
+ async handleIncoming(message: IncomingMessage): Promise<void> {
55
+ const { sender, content } = message;
56
+ const { conversationKey, channelType, senderId, displayName } = sender;
57
+
58
+ // Resolve role (chat is always owner, others check contacts DB)
59
+ const role = await resolveRole(channelType, senderId);
60
+ sender.role = role;
61
+
62
+ log.info(`[router] Incoming from ${channelType}:${senderId} (${displayName}) role=${role} key=${conversationKey}`);
63
+
64
+ // Prevent duplicate queries for the same conversation key
65
+ if (activeChannelQueries.get(conversationKey)) {
66
+ log.warn(`[router] Query already active for ${conversationKey}, skipping`);
67
+ return;
68
+ }
69
+ activeChannelQueries.set(conversationKey, true);
70
+
71
+ try {
72
+ const model = this.opts.getModel();
73
+
74
+ // Get or create a conversation in the DB for this conversation key
75
+ const conv = await this.opts.workerApi('/api/conversations', 'POST', {
76
+ title: content.slice(0, 80),
77
+ model,
78
+ });
79
+ const convId = conv.id;
80
+
81
+ // Save user message to DB
82
+ await this.opts.workerApi(`/api/conversations/${convId}/messages`, 'POST', {
83
+ role: 'user',
84
+ content,
85
+ meta: { model },
86
+ });
87
+
88
+ // Fetch recent messages for context
89
+ let recentMessages: RecentMessage[] = [];
90
+ try {
91
+ const recentRaw = await this.opts.workerApi(`/api/conversations/${convId}/messages/recent?limit=20`) as any[];
92
+ if (Array.isArray(recentRaw)) {
93
+ const filtered = recentRaw.filter((m: any) => m.role === 'user' || m.role === 'assistant');
94
+ if (filtered.length > 0) {
95
+ recentMessages = filtered.slice(0, -1).map((m: any) => ({
96
+ role: m.role as 'user' | 'assistant',
97
+ content: m.content,
98
+ }));
99
+ }
100
+ }
101
+ } catch {}
102
+
103
+ // Get the originating channel for response routing
104
+ const channel = this.channels.get(channelType);
105
+
106
+ if (role === 'owner' || role === 'admin') {
107
+ await this.dispatchOwnerQuery(convId, conversationKey, content, model, channel, senderId, recentMessages, channelType);
108
+ } else {
109
+ await this.dispatchCustomerQuery(convId, conversationKey, content, model, channel, senderId, displayName, recentMessages, channelType);
110
+ }
111
+ } catch (err: any) {
112
+ log.warn(`[router] Error handling message from ${conversationKey}: ${err.message}`);
113
+ } finally {
114
+ activeChannelQueries.delete(conversationKey);
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Dispatch a query for owner/admin — full Fluxy capabilities.
120
+ */
121
+ private async dispatchOwnerQuery(
122
+ convId: string,
123
+ conversationKey: string,
124
+ content: string,
125
+ model: string,
126
+ channel: Channel | undefined,
127
+ senderId: string,
128
+ recentMessages: RecentMessage[],
129
+ channelType: ChannelType,
130
+ ): Promise<void> {
131
+ // Fetch bot/human names
132
+ let botName = 'Fluxy', humanName = 'Human';
133
+ try {
134
+ const status = await this.opts.workerApi('/api/onboard/status');
135
+ botName = status.agentName || 'Fluxy';
136
+ humanName = status.userName || 'Human';
137
+ } catch {}
138
+
139
+ return new Promise<void>((resolve) => {
140
+ startFluxyAgentQuery(convId, content, model, (type, eventData) => {
141
+ if (type === 'bot:response' && eventData.content) {
142
+ // Save to DB
143
+ this.opts.workerApi(`/api/conversations/${convId}/messages`, 'POST', {
144
+ role: 'assistant', content: eventData.content, meta: { model },
145
+ }).catch(() => {});
146
+
147
+ // Route response back to originating channel
148
+ if (channel && channelType !== 'chat') {
149
+ channel.sendMessage(senderId, { content: eventData.content, conversationKey }).catch((err) => {
150
+ log.warn(`[router] Failed to send response to ${channelType}: ${err.message}`);
151
+ });
152
+ // Also broadcast to chat UI so owner sees the exchange
153
+ this.opts.broadcastFluxy('chat:sync', {
154
+ conversationId: convId,
155
+ message: { role: 'assistant', content: eventData.content, timestamp: new Date().toISOString(), channel: channelType },
156
+ });
157
+ }
158
+ }
159
+
160
+ if (type === 'bot:done') {
161
+ if (eventData.usedFileTools) {
162
+ this.opts.restartBackend();
163
+ }
164
+ resolve();
165
+ }
166
+ }, undefined, undefined, { botName, humanName }, recentMessages);
167
+ });
168
+ }
169
+
170
+ /**
171
+ * Dispatch a query for customer — restricted capabilities.
172
+ */
173
+ private async dispatchCustomerQuery(
174
+ convId: string,
175
+ conversationKey: string,
176
+ content: string,
177
+ model: string,
178
+ channel: Channel | undefined,
179
+ senderId: string,
180
+ displayName: string,
181
+ recentMessages: RecentMessage[],
182
+ channelType: ChannelType,
183
+ ): Promise<void> {
184
+ const config = loadConfig();
185
+ const customerMode = config.customerMode || {};
186
+
187
+ const customerContext: CustomerContext = {
188
+ senderName: displayName,
189
+ senderIdentifier: senderId,
190
+ channelType,
191
+ businessName: customerMode.businessName || 'Our Business',
192
+ businessDescription: customerMode.businessDescription || '',
193
+ };
194
+
195
+ return new Promise<void>((resolve) => {
196
+ startCustomerAgentQuery(conversationKey, content, model, (type, eventData) => {
197
+ if (type === 'bot:response' && eventData.content) {
198
+ // Save to DB
199
+ this.opts.workerApi(`/api/conversations/${convId}/messages`, 'POST', {
200
+ role: 'assistant', content: eventData.content, meta: { model },
201
+ }).catch(() => {});
202
+
203
+ // Route response back to originating channel
204
+ if (channel) {
205
+ channel.sendMessage(senderId, { content: eventData.content, conversationKey }).catch((err) => {
206
+ log.warn(`[router] Failed to send customer response to ${channelType}: ${err.message}`);
207
+ });
208
+ }
209
+ }
210
+
211
+ if (type === 'bot:done') {
212
+ resolve();
213
+ }
214
+ }, customerContext, recentMessages);
215
+ });
216
+ }
217
+
218
+ /**
219
+ * Route a pulse/cron message to ALL owner-role channels.
220
+ * Customer channels NEVER receive autonomous messages.
221
+ */
222
+ async routePulseMessage(content: string, title?: string, _priority?: string): Promise<void> {
223
+ for (const [type, channel] of this.channels) {
224
+ // Chat is handled by existing broadcastFluxy — skip to avoid duplicates
225
+ if (type === 'chat') continue;
226
+
227
+ try {
228
+ // Find all owner contacts for this channel type
229
+ const contacts = await this.opts.workerApi('/api/contacts') as any[];
230
+ const ownerContacts = (contacts || []).filter(
231
+ (c: any) => c.channel_type === type && c.role === 'owner'
232
+ );
233
+
234
+ for (const contact of ownerContacts) {
235
+ await channel.sendMessage(contact.identifier, {
236
+ content: title ? `**${title}**\n\n${content}` : content,
237
+ conversationKey: `${type}:${contact.identifier}`,
238
+ });
239
+ }
240
+ } catch (err: any) {
241
+ log.warn(`[router] Failed to route pulse to ${type}: ${err.message}`);
242
+ }
243
+ }
244
+ }
245
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Multi-channel communication types.
3
+ * Defines the channel abstraction, message formats, and role system.
4
+ */
5
+
6
+ export type ChannelType = 'chat' | 'whatsapp' | 'telegram';
7
+ export type SenderRole = 'owner' | 'admin' | 'customer';
8
+
9
+ export interface SenderIdentity {
10
+ channelType: ChannelType;
11
+ senderId: string; // phone number, telegram ID, 'owner' for chat
12
+ displayName: string;
13
+ role: SenderRole;
14
+ conversationKey: string; // "whatsapp:+5511999999999", "chat:owner"
15
+ }
16
+
17
+ export interface IncomingMessage {
18
+ sender: SenderIdentity;
19
+ content: string;
20
+ timestamp: number;
21
+ attachments?: Array<{
22
+ type: 'image' | 'file' | 'audio';
23
+ name: string;
24
+ mediaType: string;
25
+ data: string; // base64
26
+ }>;
27
+ }
28
+
29
+ export interface OutgoingMessage {
30
+ content: string;
31
+ conversationKey: string;
32
+ }
33
+
34
+ // Forward reference — ChannelRouter is imported by channel implementations
35
+ export interface ChannelRouter {
36
+ handleIncoming(message: IncomingMessage): Promise<void>;
37
+ routePulseMessage(content: string, title?: string, priority?: string): Promise<void>;
38
+ getChannelForConversation(conversationKey: string): Channel | undefined;
39
+ }
40
+
41
+ export interface Channel {
42
+ readonly type: ChannelType;
43
+
44
+ /** Initialize the channel (connect socket, start webhook listener, etc.) */
45
+ initialize(router: ChannelRouter): Promise<void>;
46
+
47
+ /** Send a message back through this channel */
48
+ sendMessage(to: string, message: OutgoingMessage): Promise<void>;
49
+
50
+ /** Check if this channel owns a given conversation key */
51
+ ownsConversation(conversationKey: string): boolean;
52
+
53
+ /** Graceful shutdown */
54
+ shutdown(): Promise<void>;
55
+ }
@@ -12,6 +12,16 @@ import type { SavedFile } from './file-saver.js';
12
12
  import { getClaudeAccessToken } from '../worker/claude-auth.js';
13
13
 
14
14
  const PROMPT_FILE = path.join(import.meta.dirname, '..', 'worker', 'prompts', 'fluxy-system-prompt.txt');
15
+ const CUSTOMER_PROMPT_FILE = path.join(import.meta.dirname, '..', 'worker', 'prompts', 'customer-system-prompt.txt');
16
+
17
+ /** Default tools disallowed for customer conversations. Edit this list to control customer access. */
18
+ const CUSTOMER_DISALLOWED_TOOLS = [
19
+ 'Bash',
20
+ // Add more tools here to restrict customer access, e.g.:
21
+ // 'Write',
22
+ // 'Edit',
23
+ // 'Agent',
24
+ ];
15
25
 
16
26
  export interface RecentMessage {
17
27
  role: 'user' | 'assistant';
@@ -160,12 +170,24 @@ export async function startFluxyAgentQuery(
160
170
 
161
171
  try {
162
172
  // Auto-discover all skill plugins in workspace/skills/ — any folder with a valid plugin.json is loaded
173
+ // Skills use a flat structure on disk (SKILL.md at the root), but the SDK expects
174
+ // skills/{name}/SKILL.md — we bridge the gap with symlinks created on discovery.
163
175
  const skillsDir = path.join(PKG_DIR, 'workspace', 'skills');
164
176
  const plugins: { type: 'local'; path: string }[] = [];
165
177
  try {
166
178
  for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
167
179
  if (entry.isDirectory() && fs.existsSync(path.join(skillsDir, entry.name, '.claude-plugin', 'plugin.json'))) {
168
180
  plugins.push({ type: 'local' as const, path: path.join(skillsDir, entry.name) });
181
+
182
+ // Bridge flat SKILL.md → nested path the SDK expects (via symlink)
183
+ const skillName = entry.name;
184
+ const flatSkillMd = path.join(skillsDir, skillName, 'SKILL.md');
185
+ const sdkDir = path.join(skillsDir, skillName, 'skills', skillName);
186
+ const sdkSkillMd = path.join(sdkDir, 'SKILL.md');
187
+ if (fs.existsSync(flatSkillMd) && !fs.existsSync(sdkSkillMd)) {
188
+ fs.mkdirSync(sdkDir, { recursive: true });
189
+ fs.symlinkSync(flatSkillMd, sdkSkillMd);
190
+ }
169
191
  }
170
192
  }
171
193
  } catch {}
@@ -277,6 +299,141 @@ export async function startFluxyAgentQuery(
277
299
  }
278
300
  }
279
301
 
302
+ /**
303
+ * Read the customer-facing system prompt, replacing placeholders.
304
+ */
305
+ function readCustomerSystemPrompt(ctx: CustomerContext): string {
306
+ try {
307
+ const raw = fs.readFileSync(CUSTOMER_PROMPT_FILE, 'utf-8').trim();
308
+ if (!raw) return `You are a helpful business assistant for ${ctx.businessName}.`;
309
+ return raw
310
+ .replace(/\$BUSINESS_NAME/g, ctx.businessName)
311
+ .replace(/\$BUSINESS_DESCRIPTION/g, ctx.businessDescription)
312
+ .replace(/\$SENDER_NAME/g, ctx.senderName)
313
+ .replace(/\$CHANNEL/g, ctx.channelType)
314
+ .replace(/\$DISALLOWED_TOOLS/g, CUSTOMER_DISALLOWED_TOOLS.join(', ') || '(none)');
315
+ } catch {
316
+ return `You are a helpful business assistant for ${ctx.businessName}.`;
317
+ }
318
+ }
319
+
320
+ export interface CustomerContext {
321
+ senderName: string;
322
+ senderIdentifier: string;
323
+ channelType: string;
324
+ businessName: string;
325
+ businessDescription: string;
326
+ }
327
+
328
+ /**
329
+ * Run an Agent SDK query for a customer conversation.
330
+ * Restricted: different system prompt, limited tools, no memory files, no plugins, no MCP.
331
+ */
332
+ export async function startCustomerAgentQuery(
333
+ conversationKey: string,
334
+ prompt: string,
335
+ model: string,
336
+ onMessage: (type: string, data: any) => void,
337
+ customerContext: CustomerContext,
338
+ recentMessages?: RecentMessage[],
339
+ ): Promise<void> {
340
+ const oauthToken = await getClaudeAccessToken();
341
+ if (!oauthToken) {
342
+ onMessage('bot:error', { conversationId: conversationKey, error: 'Claude OAuth token not found.' });
343
+ return;
344
+ }
345
+
346
+ const abortController = new AbortController();
347
+ let customerPrompt = readCustomerSystemPrompt(customerContext);
348
+
349
+ if (recentMessages?.length) {
350
+ customerPrompt = customerPrompt.replace(
351
+ '$CONVERSATION_HISTORY',
352
+ formatConversationHistory(recentMessages),
353
+ );
354
+ } else {
355
+ customerPrompt = customerPrompt.replace('$CONVERSATION_HISTORY', '(none)');
356
+ }
357
+
358
+ activeQueries.set(conversationKey, { abortController });
359
+
360
+ let fullText = '';
361
+ let stderrBuf = '';
362
+
363
+ try {
364
+ const claudeQuery = query({
365
+ prompt,
366
+ options: {
367
+ model,
368
+ cwd: WORKSPACE_DIR,
369
+ permissionMode: 'bypassPermissions',
370
+ allowDangerouslySkipPermissions: true,
371
+ disallowedTools: CUSTOMER_DISALLOWED_TOOLS,
372
+ maxTurns: 10,
373
+ abortController,
374
+ systemPrompt: customerPrompt,
375
+ // No plugins, no MCP servers for customers
376
+ stderr: (chunk: string) => { stderrBuf += chunk; },
377
+ env: {
378
+ ...process.env as Record<string, string>,
379
+ CLAUDE_CODE_OAUTH_TOKEN: oauthToken,
380
+ CLAUDE_CODE_BUBBLEWRAP: '1',
381
+ },
382
+ },
383
+ });
384
+
385
+ onMessage('bot:typing', { conversationId: conversationKey });
386
+
387
+ for await (const msg of claudeQuery) {
388
+ if (abortController.signal.aborted) break;
389
+
390
+ switch (msg.type) {
391
+ case 'assistant': {
392
+ const assistantMsg = msg.message;
393
+ if (!assistantMsg?.content) break;
394
+ for (const block of assistantMsg.content) {
395
+ if (block.type === 'text' && block.text) {
396
+ if (fullText && !fullText.endsWith('\n')) {
397
+ fullText += '\n\n';
398
+ onMessage('bot:token', { conversationId: conversationKey, token: '\n\n' });
399
+ }
400
+ fullText += block.text;
401
+ onMessage('bot:token', { conversationId: conversationKey, token: block.text });
402
+ } else if (block.type === 'tool_use') {
403
+ onMessage('bot:tool', { conversationId: conversationKey, name: block.name, input: block.input });
404
+ }
405
+ }
406
+ break;
407
+ }
408
+ case 'result': {
409
+ if (fullText) {
410
+ onMessage('bot:response', { conversationId: conversationKey, content: fullText });
411
+ fullText = '';
412
+ } else if (msg.subtype?.startsWith('error')) {
413
+ const errorText = (msg as any).errors?.join('; ') || 'Agent query failed';
414
+ onMessage('bot:error', { conversationId: conversationKey, error: errorText });
415
+ }
416
+ break;
417
+ }
418
+ }
419
+ }
420
+
421
+ if (fullText && !abortController.signal.aborted) {
422
+ onMessage('bot:response', { conversationId: conversationKey, content: fullText });
423
+ }
424
+ } catch (err: any) {
425
+ if (!abortController.signal.aborted) {
426
+ const detail = stderrBuf.trim();
427
+ const msg = detail ? `${err.message}\n\nCLI stderr:\n${detail}` : err.message;
428
+ log.warn(`Customer agent error (${conversationKey}): ${msg}`);
429
+ onMessage('bot:error', { conversationId: conversationKey, error: msg });
430
+ }
431
+ } finally {
432
+ activeQueries.delete(conversationKey);
433
+ onMessage('bot:done', { conversationId: conversationKey, usedFileTools: false });
434
+ }
435
+ }
436
+
280
437
  /** Stop an in-flight query */
281
438
  export function stopFluxyAgentQuery(conversationId: string): void {
282
439
  const q = activeQueries.get(conversationId);
@@ -17,6 +17,7 @@ import { startFluxyAgentQuery, stopFluxyAgentQuery, type RecentMessage } from '.
17
17
  import { ensureFileDirs, saveAttachment, type SavedFile } from './file-saver.js';
18
18
  import { startViteDevServers, stopViteDevServers } from './vite-dev.js';
19
19
  import { startScheduler, stopScheduler } from './scheduler.js';
20
+ import { initializeChannels, type ChannelRouter } from './channels/index.js';
20
21
  import { execSync, spawn as cpSpawn } from 'child_process';
21
22
  import crypto from 'crypto';
22
23
 
@@ -1034,6 +1035,25 @@ export async function startSupervisor() {
1034
1035
  // Spawn backend (worker runs in-process)
1035
1036
  spawnBackend(backendPort);
1036
1037
 
1038
+ // Initialize multi-channel router
1039
+ let channelRouter: ChannelRouter | null = null;
1040
+ (async () => {
1041
+ try {
1042
+ channelRouter = await initializeChannels(config, {
1043
+ workerApi,
1044
+ broadcastFluxy,
1045
+ restartBackend: async () => {
1046
+ resetBackendRestarts();
1047
+ await stopBackend();
1048
+ spawnBackend(backendPort);
1049
+ },
1050
+ getModel: () => loadConfig().ai.model,
1051
+ });
1052
+ } catch (err: any) {
1053
+ log.warn(`[channels] Initialization failed: ${err.message}`);
1054
+ }
1055
+ })();
1056
+
1037
1057
  // Start pulse/cron scheduler
1038
1058
  startScheduler({
1039
1059
  broadcastFluxy,
@@ -1044,6 +1064,7 @@ export async function startSupervisor() {
1044
1064
  spawnBackend(backendPort);
1045
1065
  },
1046
1066
  getModel: () => loadConfig().ai.model,
1067
+ channelRouter: () => channelRouter,
1047
1068
  });
1048
1069
 
1049
1070
  // Watch workspace files for changes — auto-restart backend
@@ -1247,9 +1268,18 @@ export async function startSupervisor() {
1247
1268
  }, 30_000);
1248
1269
  }
1249
1270
 
1271
+ // Expose channel router for external channel registration (e.g., WhatsApp skill)
1272
+ (globalThis as any).__fluxyChannelRouter = () => channelRouter;
1273
+
1250
1274
  // Shutdown
1251
1275
  const shutdown = async () => {
1252
1276
  log.info('Shutting down...');
1277
+ // Shutdown all channels gracefully
1278
+ if (channelRouter) {
1279
+ for (const channel of channelRouter.getAllChannels()) {
1280
+ try { await channel.shutdown(); } catch {}
1281
+ }
1282
+ }
1253
1283
  stopScheduler();
1254
1284
  backendWatcher.close();
1255
1285
  workspaceWatcher.close();
@@ -33,6 +33,7 @@ interface SchedulerOpts {
33
33
  workerApi: (path: string, method?: string, body?: any) => Promise<any>;
34
34
  restartBackend: () => void;
35
35
  getModel: () => string;
36
+ channelRouter?: () => import('./channels/router.js').ChannelRouter | null;
36
37
  }
37
38
 
38
39
  // State
@@ -201,6 +202,14 @@ function triggerAgent(prompt: string, label: string, onComplete?: () => void) {
201
202
  }).catch((err: any) => {
202
203
  log.warn(`[scheduler] Push send failed: ${err.message}`);
203
204
  });
205
+
206
+ // Route pulse/cron messages to all owner channels (WhatsApp, Telegram, etc.)
207
+ const router = schedulerOpts?.channelRouter?.();
208
+ if (router) {
209
+ router.routePulseMessage(messageContent, titleMatch?.[1]).catch((err: any) => {
210
+ log.warn(`[scheduler] Channel routing failed: ${err.message}`);
211
+ });
212
+ }
204
213
  }
205
214
  }
206
215
 
package/worker/db.ts CHANGED
@@ -48,6 +48,16 @@ CREATE TABLE IF NOT EXISTS trusted_devices (
48
48
  last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
49
49
  );
50
50
  CREATE INDEX IF NOT EXISTS idx_td_token ON trusted_devices(token);
51
+ CREATE TABLE IF NOT EXISTS contacts (
52
+ id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))),
53
+ channel_type TEXT NOT NULL,
54
+ identifier TEXT NOT NULL,
55
+ display_name TEXT,
56
+ role TEXT NOT NULL DEFAULT 'customer' CHECK (role IN ('owner', 'admin', 'customer')),
57
+ notes TEXT,
58
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
59
+ UNIQUE(channel_type, identifier)
60
+ );
51
61
  `;
52
62
 
53
63
  let db: Database.Database;
@@ -76,6 +86,18 @@ export function initDb(): void {
76
86
  if (!msgCols2.some((c) => c.name === 'attachments')) {
77
87
  db.exec('ALTER TABLE messages ADD COLUMN attachments TEXT');
78
88
  }
89
+
90
+ // Migration: add channel columns to conversations (multi-channel support)
91
+ const convCols = db.prepare("PRAGMA table_info(conversations)").all() as { name: string }[];
92
+ if (!convCols.some((c) => c.name === 'channel_type')) {
93
+ db.exec("ALTER TABLE conversations ADD COLUMN channel_type TEXT DEFAULT 'chat'");
94
+ }
95
+ if (!convCols.some((c) => c.name === 'conversation_key')) {
96
+ db.exec('ALTER TABLE conversations ADD COLUMN conversation_key TEXT');
97
+ }
98
+ if (!convCols.some((c) => c.name === 'sender_role')) {
99
+ db.exec("ALTER TABLE conversations ADD COLUMN sender_role TEXT DEFAULT 'owner'");
100
+ }
79
101
  }
80
102
 
81
103
  export function closeDb(): void { db?.close(); }
@@ -196,3 +218,43 @@ export function getMessagesBefore(convId: string, beforeId: string, limit = 20)
196
218
  ) sub ORDER BY id ASC
197
219
  `).all(convId, beforeId, limit);
198
220
  }
221
+
222
+ // ── Contacts (multi-channel role management) ──
223
+
224
+ export function listContacts() {
225
+ return db.prepare('SELECT * FROM contacts ORDER BY created_at DESC').all();
226
+ }
227
+
228
+ export function resolveContact(channelType: string, identifier: string): { role: string } | undefined {
229
+ return db.prepare('SELECT role FROM contacts WHERE channel_type = ? AND identifier = ?').get(channelType, identifier) as any;
230
+ }
231
+
232
+ export function addContact(channelType: string, identifier: string, role: string, displayName?: string, notes?: string) {
233
+ return db.prepare(
234
+ 'INSERT INTO contacts (channel_type, identifier, role, display_name, notes) VALUES (?, ?, ?, ?, ?) RETURNING *'
235
+ ).get(channelType, identifier, role, displayName ?? null, notes ?? null) as any;
236
+ }
237
+
238
+ export function updateContact(id: string, updates: { role?: string; display_name?: string; notes?: string }) {
239
+ const sets: string[] = [];
240
+ const vals: any[] = [];
241
+ if (updates.role !== undefined) { sets.push('role = ?'); vals.push(updates.role); }
242
+ if (updates.display_name !== undefined) { sets.push('display_name = ?'); vals.push(updates.display_name); }
243
+ if (updates.notes !== undefined) { sets.push('notes = ?'); vals.push(updates.notes); }
244
+ if (!sets.length) return;
245
+ vals.push(id);
246
+ db.prepare(`UPDATE contacts SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
247
+ }
248
+
249
+ export function deleteContact(id: string) {
250
+ db.prepare('DELETE FROM contacts WHERE id = ?').run(id);
251
+ }
252
+
253
+ // Find or create a conversation for a given conversation key
254
+ export function getOrCreateConversation(conversationKey: string, channelType: string, senderRole: string, title?: string, model?: string) {
255
+ const existing = db.prepare('SELECT * FROM conversations WHERE conversation_key = ? ORDER BY updated_at DESC LIMIT 1').get(conversationKey) as any;
256
+ if (existing) return existing;
257
+ return db.prepare(
258
+ 'INSERT INTO conversations (title, model, channel_type, conversation_key, sender_role) VALUES (?, ?, ?, ?, ?) RETURNING *'
259
+ ).get(title ?? 'Chat', model ?? null, channelType, conversationKey, senderRole) as any;
260
+ }
package/worker/index.ts CHANGED
@@ -5,7 +5,7 @@ import path from 'path';
5
5
  import { loadConfig, saveConfig } from '../shared/config.js';
6
6
  import { paths, WORKSPACE_DIR } from '../shared/paths.js';
7
7
  import { log } from '../shared/logger.js';
8
- import { initDb, closeDb, listConversations, createConversation, deleteConversation, getMessages, addMessage, getSetting, getAllSettings, setSetting, createSession, getSession, deleteExpiredSessions, getRecentMessages, getMessagesBefore, addPushSubscription, removePushSubscription, getAllPushSubscriptions, getPushSubscriptionByEndpoint, createTrustedDevice, getTrustedDevice, updateDeviceLastSeen, listTrustedDevices, deleteTrustedDevice, deleteExpiredDevices, deleteAllTrustedDevices } from './db.js';
8
+ import { initDb, closeDb, listConversations, createConversation, deleteConversation, getMessages, addMessage, getSetting, getAllSettings, setSetting, createSession, getSession, deleteExpiredSessions, getRecentMessages, getMessagesBefore, addPushSubscription, removePushSubscription, getAllPushSubscriptions, getPushSubscriptionByEndpoint, createTrustedDevice, getTrustedDevice, updateDeviceLastSeen, listTrustedDevices, deleteTrustedDevice, deleteExpiredDevices, deleteAllTrustedDevices, listContacts, resolveContact, addContact, updateContact, deleteContact } from './db.js';
9
9
  import webpush from 'web-push';
10
10
  import { TOTP } from 'otpauth';
11
11
  import QRCode from 'qrcode';
@@ -877,6 +877,47 @@ app.post('/api/whisper/transcribe', express.json({ limit: '10mb' }), async (req,
877
877
  }
878
878
  });
879
879
 
880
+ // ── Contacts (multi-channel role management) ──
881
+
882
+ app.get('/api/contacts', (_req, res) => {
883
+ res.json(listContacts());
884
+ });
885
+
886
+ app.get('/api/contacts/resolve', (req, res) => {
887
+ const channelType = req.query.channel_type as string;
888
+ const identifier = req.query.identifier as string;
889
+ if (!channelType || !identifier) { res.status(400).json({ error: 'Missing channel_type or identifier' }); return; }
890
+ const contact = resolveContact(channelType, identifier);
891
+ res.json({ role: contact?.role || 'customer' });
892
+ });
893
+
894
+ app.post('/api/contacts', (req, res) => {
895
+ const { channel_type, identifier, role, display_name, notes } = req.body || {};
896
+ if (!channel_type || !identifier || !role) { res.status(400).json({ error: 'Missing channel_type, identifier, or role' }); return; }
897
+ if (!['owner', 'admin', 'customer'].includes(role)) { res.status(400).json({ error: 'Invalid role' }); return; }
898
+ try {
899
+ const contact = addContact(channel_type, identifier, role, display_name, notes);
900
+ res.json(contact);
901
+ } catch (err: any) {
902
+ if (err.message?.includes('UNIQUE constraint')) {
903
+ res.status(409).json({ error: 'Contact already exists for this channel and identifier' });
904
+ } else {
905
+ res.status(500).json({ error: err.message });
906
+ }
907
+ }
908
+ });
909
+
910
+ app.put('/api/contacts/:id', (req, res) => {
911
+ const { role, display_name, notes } = req.body || {};
912
+ updateContact(req.params.id, { role, display_name, notes });
913
+ res.json({ ok: true });
914
+ });
915
+
916
+ app.delete('/api/contacts/:id', (req, res) => {
917
+ deleteContact(req.params.id);
918
+ res.json({ ok: true });
919
+ });
920
+
880
921
  // Serve stored files (audio, images, documents)
881
922
  app.use('/api/files', express.static(paths.files));
882
923
 
@@ -0,0 +1,46 @@
1
+ # Identity
2
+
3
+ You are $BUSINESS_NAME's assistant. You are helpful, professional, and friendly.
4
+
5
+ You are talking to $SENDER_NAME via $CHANNEL.
6
+
7
+ ---
8
+
9
+ # What You Can Do
10
+
11
+ - Answer questions about the business, products, and services
12
+ - Help with support inquiries and troubleshooting
13
+ - Provide information and guidance
14
+ - Schedule appointments or take notes for follow-up
15
+ - Be conversational and helpful
16
+
17
+ # What You Cannot Do
18
+
19
+ - Access internal systems, files, or databases
20
+ - Run commands or modify anything on the server
21
+ - Access personal information about the business owner
22
+ - Make promises about pricing, refunds, or policies unless explicitly stated in your context
23
+ - Reveal any technical details about how you work internally
24
+
25
+ # Business Context
26
+
27
+ $BUSINESS_DESCRIPTION
28
+
29
+ # Behavior Guidelines
30
+
31
+ - Be warm and professional — you represent the business
32
+ - If you don't know the answer, say so honestly and offer to have someone follow up
33
+ - Keep responses concise and focused
34
+ - If the customer seems frustrated, acknowledge their feelings before problem-solving
35
+ - Never pretend to be a human — if asked, say you're an AI assistant for $BUSINESS_NAME
36
+ - Do not discuss topics unrelated to the business unless the customer is making casual conversation
37
+
38
+ # Disallowed Tools
39
+
40
+ The following tools are NOT available to you in this context:
41
+ $DISALLOWED_TOOLS
42
+
43
+ ---
44
+
45
+ # Recent Conversation
46
+ $CONVERSATION_HISTORY