@peopl-health/nexus 1.1.8 → 1.3.0

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/lib/index.js CHANGED
@@ -1,9 +1,19 @@
1
1
  const { NexusMessaging } = require('./core/NexusMessaging');
2
- const { TwilioProvider } = require('./adapters/TwilioProvider');
3
- const { BaileysProvider } = require('./adapters/BaileysProvider');
4
2
  const { MongoStorage } = require('./storage/MongoStorage');
5
3
  const { MessageParser } = require('./utils/messageParser');
6
4
  const { DefaultLLMProvider } = require('./utils/defaultLLMProvider');
5
+ const { loadNexusConfig } = require('./config/configLoader');
6
+ const templateController = require('./controllers/templateController');
7
+ const templateFlowController = require('./controllers/templateFlowController');
8
+ const interactive = require('./interactive');
9
+ const {
10
+ configureLLMProvider: configureAssistantsLLM,
11
+ registerAssistant,
12
+ overrideGetAssistantById,
13
+ configureAssistants: setAssistantsConfig
14
+ } = require('./services/assistantService');
15
+ const { TwilioProvider } = require('./adapters/TwilioProvider');
16
+ const { BaileysProvider } = require('./adapters/BaileysProvider');
7
17
 
8
18
  /**
9
19
  * Main Nexus class that orchestrates all components
@@ -40,17 +50,50 @@ class Nexus {
40
50
  parser = 'MessageParser',
41
51
  parserConfig = {},
42
52
  llm = 'openai',
43
- llmConfig = {}
53
+ llmConfig = {},
54
+ assistants: assistantsOpt = undefined,
55
+ assistant: assistantOpt = undefined
44
56
  } = options;
45
57
 
46
58
  // Initialize messaging provider
47
59
  await this.messaging.initializeProvider(provider, providerConfig);
48
60
 
61
+ // Auto-configure template controllers with active provider when Twilio is used
62
+ try {
63
+ const activeProvider = typeof this.messaging.getProvider === 'function'
64
+ ? this.messaging.getProvider()
65
+ : null;
66
+ if (activeProvider && activeProvider.constructor && activeProvider.constructor.name === 'TwilioProvider') {
67
+ if (typeof templateController.configureNexusProvider === 'function') {
68
+ templateController.configureNexusProvider(activeProvider);
69
+ }
70
+ if (typeof templateFlowController.configureNexusProvider === 'function') {
71
+ templateFlowController.configureNexusProvider(activeProvider);
72
+ }
73
+ }
74
+ } catch (e) {
75
+ console.warn('Warning: failed to auto-configure template providers:', e?.message || e);
76
+ }
77
+
49
78
  // Initialize storage if provided
50
- if (storage === 'mongo') {
51
- this.storage = new MongoStorage(storageConfig);
52
- await this.storage.connect();
53
- this.messaging.setMessageStorage(this.storage);
79
+ if (storage) {
80
+ try {
81
+ const { createStorage } = require('./storage/registry');
82
+ if (typeof storage === 'string') {
83
+ this.storage = createStorage(storage, storageConfig || {});
84
+ } else if (typeof storage === 'object' && (storage.saveMessage || storage.saveInteractive)) {
85
+ this.storage = storage; // external adapter instance
86
+ } else {
87
+ // default to mongo if truthy but not recognized
88
+ this.storage = createStorage('mongo', storageConfig || {});
89
+ }
90
+ if (this.storage && typeof this.storage.connect === 'function') {
91
+ await this.storage.connect();
92
+ }
93
+ this.messaging.setMessageStorage(this.storage);
94
+ } catch (e) {
95
+ console.warn('Warning: storage initialization failed:', e?.message || e);
96
+ }
54
97
  }
55
98
 
56
99
  // Initialize message parser if provided
@@ -63,6 +106,31 @@ class Nexus {
63
106
  this.llmProvider = new DefaultLLMProvider(llmConfig);
64
107
  }
65
108
 
109
+
110
+ // Configure Assistants (registry + overrides)
111
+ const assistantsConfig = assistantsOpt || assistantOpt;
112
+ try {
113
+ if (this.llmProvider && typeof configureAssistantsLLM === 'function') {
114
+ // Provide the raw OpenAI client to the assistant service
115
+ configureAssistantsLLM(this.llmProvider.getClient());
116
+ }
117
+ if (assistantsConfig) {
118
+ if (assistantsConfig.registry && typeof assistantsConfig.registry === 'object') {
119
+ for (const [id, AssistantClass] of Object.entries(assistantsConfig.registry)) {
120
+ registerAssistant(id, AssistantClass);
121
+ }
122
+ }
123
+ if (typeof assistantsConfig.getAssistantById === 'function') {
124
+ overrideGetAssistantById(assistantsConfig.getAssistantById);
125
+ }
126
+ if (typeof setAssistantsConfig === 'function') {
127
+ setAssistantsConfig(assistantsConfig);
128
+ }
129
+ }
130
+ } catch (e) {
131
+ console.warn('Warning: failed to configure assistants:', e?.message || e);
132
+ }
133
+
66
134
  this.isInitialized = true;
67
135
  }
68
136
 
@@ -193,7 +261,14 @@ module.exports = {
193
261
  MessageParser,
194
262
  DefaultLLMProvider,
195
263
  routes,
196
- // Direct access to route utilities for convenience
197
264
  setupDefaultRoutes: routes.setupDefaultRoutes,
198
- createRouter: routes.createRouter
265
+ createRouter: routes.createRouter,
266
+ loadNexusConfig,
267
+ interactive,
268
+ registerFlow: interactive.registerFlow,
269
+ getFlow: interactive.getFlow,
270
+ listFlows: interactive.listFlows,
271
+ sendInteractive: interactive.sendInteractive,
272
+ registerInteractiveHandler: interactive.registerInteractiveHandler,
273
+ attachInteractiveRouter: interactive.attachInteractiveRouter
199
274
  };
@@ -0,0 +1,86 @@
1
+
2
+ const { toTwilioContent } = require('./twilioMapper');
3
+ const { registerFlow, getFlow, listFlows, registerInteractiveHandler, listInteractiveHandlers } = require('./registry');
4
+
5
+ async function sendInteractive(nexusOrMessaging, params) {
6
+ const { to, spec, id, variables } = params || {};
7
+ if (!nexusOrMessaging) throw new Error('sendInteractive requires a Nexus or NexusMessaging instance');
8
+ const messaging = typeof nexusOrMessaging.getMessaging === 'function' ? nexusOrMessaging.getMessaging() : nexusOrMessaging;
9
+ const provider = typeof messaging.getProvider === 'function' ? messaging.getProvider() : null;
10
+ if (!provider) throw new Error('No active provider');
11
+
12
+ const useSpec = spec || (id ? getFlow(id) : null);
13
+ if (!useSpec) throw new Error('Interactive spec not found');
14
+
15
+ // If user supplied a contentSid directly in spec, just send it
16
+ if (useSpec.contentSid) {
17
+ return await provider.sendMessage({ to, contentSid: useSpec.contentSid, variables });
18
+ }
19
+
20
+ // Twilio mapping
21
+ if (provider.constructor && provider.constructor.name === 'TwilioProvider') {
22
+ const content = toTwilioContent(useSpec);
23
+ const created = await provider.createTemplate(content);
24
+ return await provider.sendMessage({ to, contentSid: created.sid, variables });
25
+ }
26
+
27
+ // Baileys or others: not supported yet
28
+ throw new Error('Interactive/flows not supported for this provider');
29
+ }
30
+
31
+ module.exports = {
32
+ registerFlow,
33
+ getFlow,
34
+ listFlows,
35
+ sendInteractive,
36
+ registerInteractiveHandler,
37
+ attachInteractiveRouter
38
+ };
39
+
40
+
41
+ function _matchInteractive(match, interactive) {
42
+ if (!match) return true;
43
+ if (!interactive) return false;
44
+ // Type match (button, list, flow)
45
+ if (match.type && String(match.type).toLowerCase() !== String(interactive.type || '').toLowerCase()) return false;
46
+ // ID match (e.g., ListId)
47
+ if (match.id) {
48
+ const targetId = interactive.id || interactive.payload || interactive.title || '';
49
+ if (match.id instanceof RegExp) {
50
+ if (!match.id.test(String(targetId))) return false;
51
+ } else if (String(match.id) != String(targetId)) {
52
+ return false;
53
+ }
54
+ }
55
+ // Payload includes
56
+ if (match.payloadIncludes) {
57
+ const blob = JSON.stringify(interactive);
58
+ if (!blob.includes(match.payloadIncludes)) return false;
59
+ }
60
+ // Custom predicate
61
+ if (typeof match.predicate === 'function' && !match.predicate(interactive)) return false;
62
+ return true;
63
+ }
64
+
65
+ function attachInteractiveRouter(nexusOrMessaging) {
66
+ const messaging = typeof nexusOrMessaging.getMessaging === 'function' ? nexusOrMessaging.getMessaging() : nexusOrMessaging;
67
+ const bus = typeof messaging.getEventBus === 'function' ? messaging.getEventBus() : null;
68
+ if (!bus) throw new Error('Interactive router requires event bus support');
69
+
70
+ const handler = async (messageData) => {
71
+ const interactive = messageData && messageData.interactive ? messageData.interactive : null;
72
+ const items = listInteractiveHandlers();
73
+ for (const { match, handler } of items) {
74
+ try {
75
+ if (_matchInteractive(match, interactive)) {
76
+ await handler(messageData, messaging);
77
+ }
78
+ } catch (e) {
79
+ console.warn('Interactive handler error:', e && e.message || e);
80
+ }
81
+ }
82
+ };
83
+
84
+ bus.on('interactive:received', handler);
85
+ return () => bus.off && bus.off('interactive:received', handler);
86
+ }
@@ -0,0 +1,31 @@
1
+
2
+ // Simple in-memory registry for interactive specs (flows, quick replies)
3
+ const _flows = new Map();
4
+ const _handlers = [];
5
+
6
+ function registerFlow(id, spec) {
7
+ if (!id || typeof id !== 'string') throw new Error('registerFlow requires id');
8
+ if (!spec || typeof spec !== 'object') throw new Error('registerFlow requires spec object');
9
+ _flows.set(id, spec);
10
+ }
11
+
12
+ function getFlow(id) {
13
+ return _flows.get(id) || null;
14
+ }
15
+
16
+ function listFlows() {
17
+ return Array.from(_flows.entries()).map(([id, spec]) => ({ id, spec }));
18
+ }
19
+
20
+ module.exports = { registerFlow, getFlow, listFlows, registerInteractiveHandler, listInteractiveHandlers };
21
+
22
+
23
+ function registerInteractiveHandler(match, handler) {
24
+ if (typeof handler !== 'function') throw new Error('Handler must be a function');
25
+ const m = match || {};
26
+ _handlers.push({ match: m, handler });
27
+ }
28
+
29
+ function listInteractiveHandlers() {
30
+ return _handlers.slice();
31
+ }
@@ -0,0 +1,60 @@
1
+
2
+ // Map a provider-agnostic interactive spec to Twilio Content API payload
3
+
4
+ function toTwilioContent(spec) {
5
+ if (!spec || typeof spec !== 'object') throw new Error('Interactive spec must be an object');
6
+ const { type = 'text', language = 'es', body = '', footer, variables, buttons, flow } = spec;
7
+
8
+ const content = {
9
+ friendly_name: spec.friendlyName || (spec.name || ('interactive_' + Date.now())),
10
+ language,
11
+ variables: {},
12
+ types: {}
13
+ };
14
+
15
+ // Build variables map from array/object
16
+ if (Array.isArray(variables)) {
17
+ variables.forEach((v, idx) => {
18
+ content.variables[String(idx + 1)] = typeof v === 'string' ? v : (v.example || '');
19
+ });
20
+ } else if (variables && typeof variables === 'object') {
21
+ Object.keys(variables).forEach(k => {
22
+ content.variables[k] = variables[k];
23
+ });
24
+ }
25
+
26
+ if (type === 'flow') {
27
+ content.types['twilio/flows'] = {
28
+ body: flow?.body || body || '',
29
+ button_text: flow?.buttonText || spec.buttonText || undefined,
30
+ subtitle: flow?.subtitle || spec.subtitle || undefined,
31
+ pages: flow?.pages || spec.pages || []
32
+ };
33
+ } else if (type === 'quick-reply') {
34
+ const actions = (buttons || []).slice(0, 3).map((b, i) => ({
35
+ title: b.text || b.title || ('Button ' + (i + 1)),
36
+ id: b.id || ('button_' + (i + 1))
37
+ }));
38
+ content.types['twilio/quick-reply'] = {
39
+ body: body || '',
40
+ actions
41
+ };
42
+ // Also include base text body type
43
+ content.types['twilio/text'] = { body: body || '' };
44
+ } else {
45
+ // Plain text
46
+ content.types['twilio/text'] = { body: body || '' };
47
+ }
48
+
49
+ if (footer) {
50
+ ['twilio/text', 'twilio/quick-reply'].forEach(t => {
51
+ if (content.types[t]) {
52
+ content.types[t].footer = footer;
53
+ }
54
+ });
55
+ }
56
+
57
+ return content;
58
+ }
59
+
60
+ module.exports = { toTwilioContent };
@@ -51,6 +51,12 @@ messageSchema.pre('save', function (next) {
51
51
 
52
52
  const Message = mongoose.model('Message', messageSchema);
53
53
 
54
+ async function insertMessage(values) {
55
+ const msg = new Message(values);
56
+ await msg.save();
57
+ return msg;
58
+ }
59
+
54
60
  function formatTimestamp(unixTimestamp) {
55
61
  const date = new Date(unixTimestamp * 1000);
56
62
  return date.toLocaleString('sv-MX', {
@@ -86,6 +92,8 @@ function getMessageValues(message, content, reply, is_media) {
86
92
 
87
93
  module.exports = {
88
94
  Message,
95
+ // Backward-compatible helper used by helpers
96
+ insertMessage,
89
97
  getMessageValues,
90
98
  formatTimestamp
91
99
  };
@@ -31,7 +31,8 @@ const mediaRouteDefinitions = {
31
31
  const messageRouteDefinitions = {
32
32
  'POST /send': 'sendMessageController',
33
33
  'POST /send-bulk': 'sendBulkMessageController',
34
- 'POST /send-bulk-airtable': 'sendBulkMessageAirtableController'
34
+ 'POST /send-bulk-airtable': 'sendBulkMessageAirtableController',
35
+ 'POST /get-last': 'getLastInteractionController'
35
36
  };
36
37
 
37
38
  const templateRouteDefinitions = {
@@ -103,6 +104,7 @@ const builtInControllers = {
103
104
  sendMessageController: messageController.sendMessageController,
104
105
  sendBulkMessageController: messageController.sendBulkMessageController,
105
106
  sendBulkMessageAirtableController: messageController.sendBulkMessageAirtableController,
107
+ getLastInteractionController: messageController.getLastInteractionController,
106
108
 
107
109
  // Template controllers
108
110
  createTemplate: templateController.createTemplate,
@@ -3,6 +3,7 @@ const { airtable } = require('../config/airtableConfig');
3
3
 
4
4
  async function addRecord(baseID, tableName, fields) {
5
5
  try {
6
+ if (!airtable) throw new Error('Airtable not configured. Set AIRTABLE_API_KEY');
6
7
  const base = airtable.base(baseID);
7
8
  const record = await base(tableName).create(fields);
8
9
  console.log('Record added at', tableName);
@@ -17,6 +18,7 @@ async function addRecord(baseID, tableName, fields) {
17
18
  async function getRecords(baseID, tableName) {
18
19
  try {
19
20
  const records = [];
21
+ if (!airtable) throw new Error('Airtable not configured. Set AIRTABLE_API_KEY');
20
22
  const base = airtable.base(baseID);
21
23
  await base(tableName).select({
22
24
  maxRecords: 3
@@ -36,6 +38,7 @@ async function getRecords(baseID, tableName) {
36
38
  async function getRecordByFilter(baseID, tableName, filter, view = 'Grid view') {
37
39
  try {
38
40
  const records = [];
41
+ if (!airtable) throw new Error('Airtable not configured. Set AIRTABLE_API_KEY');
39
42
  const base = airtable.base(baseID);
40
43
  await base(tableName).select({
41
44
  filterByFormula: `${filter}`,
@@ -55,6 +58,7 @@ async function getRecordByFilter(baseID, tableName, filter, view = 'Grid view')
55
58
 
56
59
  async function updateRecordByFilter(baseID, tableName, filter, updateFields) {
57
60
  try {
61
+ if (!airtable) throw new Error('Airtable not configured. Set AIRTABLE_API_KEY');
58
62
  const base = airtable.base(baseID);
59
63
  const updatedRecords = [];
60
64
 
@@ -7,6 +7,7 @@ const configureLLMProvider = (provider) => {
7
7
 
8
8
  let assistantConfig = null;
9
9
  let assistantRegistry = {};
10
+ let customGetAssistantById = null;
10
11
 
11
12
  const { Message, formatTimestamp } = require('../models/messageModel.js');
12
13
  const { Thread } = require('../models/threadModel.js');
@@ -26,9 +27,21 @@ const registerAssistant = (assistantId, AssistantClass) => {
26
27
  assistantRegistry[assistantId] = AssistantClass;
27
28
  };
28
29
 
30
+ const overrideGetAssistantById = (resolverFn) => {
31
+ if (typeof resolverFn === 'function') {
32
+ customGetAssistantById = resolverFn;
33
+ }
34
+ };
35
+
29
36
  const getAssistantById = (assistant_id, thread) => {
37
+ if (customGetAssistantById) {
38
+ const inst = customGetAssistantById(assistant_id, thread);
39
+ if (inst) return inst;
40
+ }
41
+
30
42
  if (!assistantConfig) {
31
- throw new Error('Assistants not configured. Call configureAssistants() first.');
43
+ // Provide a more permissive default: allow registry usage without full config
44
+ assistantConfig = {};
32
45
  }
33
46
 
34
47
  const AssistantClass = assistantRegistry[assistant_id];
@@ -288,5 +301,6 @@ module.exports = {
288
301
  switchAssistant,
289
302
  configureAssistants,
290
303
  registerAssistant,
291
- configureLLMProvider
304
+ configureLLMProvider,
305
+ overrideGetAssistantById
292
306
  };
@@ -0,0 +1,19 @@
1
+
2
+ /**
3
+ * Noop storage adapter - implements the storage interface but does nothing.
4
+ */
5
+ class NoopStorage {
6
+ constructor(config = {}) {
7
+ this.config = config;
8
+ }
9
+ async connect() {}
10
+ async saveMessage() { return null; }
11
+ async saveInteractive() { return null; }
12
+ async getMessages() { return []; }
13
+ async getThread() { return null; }
14
+ async createThread() { return null; }
15
+ async updateThread() { return null; }
16
+ async disconnect() {}
17
+ }
18
+
19
+ module.exports = { NoopStorage };
@@ -0,0 +1,31 @@
1
+
2
+ const { MongoStorage } = require('./MongoStorage');
3
+ const { NoopStorage } = require('./NoopStorage');
4
+
5
+ const _storages = new Map();
6
+
7
+ function registerStorage(name, StorageClassOrFactory) {
8
+ if (!name || !StorageClassOrFactory) throw new Error('registerStorage requires a name and class/factory');
9
+ _storages.set(String(name).toLowerCase(), StorageClassOrFactory);
10
+ }
11
+
12
+ function getStorage(name) {
13
+ return _storages.get(String(name || '').toLowerCase());
14
+ }
15
+
16
+ function createStorage(name, config) {
17
+ const Entry = getStorage(name);
18
+ if (!Entry) throw new Error(`Unsupported storage: ${name}`);
19
+ if (typeof Entry === 'function' && /^class\s/.test(Function.prototype.toString.call(Entry))) {
20
+ return new Entry(config || {});
21
+ }
22
+ // factory function
23
+ if (typeof Entry === 'function') return Entry(config || {});
24
+ throw new Error('Invalid storage registry entry');
25
+ }
26
+
27
+ // Register built-ins
28
+ registerStorage('mongo', MongoStorage);
29
+ registerStorage('noop', NoopStorage);
30
+
31
+ module.exports = { registerStorage, getStorage, createStorage };
@@ -76,6 +76,4 @@ const predefinedTemplates = {
76
76
  }
77
77
  };
78
78
 
79
- module.exports = {
80
- predefinedTemplates
81
- };
79
+ module.exports = predefinedTemplates;
@@ -1,4 +1,4 @@
1
- const { OpenAI } = require('openai');
1
+ const OpenAI = require('openai');
2
2
 
3
3
  /**
4
4
  * Default LLM Provider using OpenAI
@@ -1,10 +1,8 @@
1
- const AssistantManager = require('./AssistantManager');
2
- const DefaultLLMProvider = require('./DefaultLLMProvider');
3
- const MessageParser = require('./MessageParser');
4
- const logger = require('./logger');
1
+ const { DefaultLLMProvider } = require('./defaultLLMProvider');
2
+ const { MessageParser } = require('./messageParser');
3
+ const { logger } = require('./logger');
5
4
 
6
5
  module.exports = {
7
- AssistantManager,
8
6
  DefaultLLMProvider,
9
7
  MessageParser,
10
8
  logger
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "1.1.8",
3
+ "version": "1.3.0",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -34,6 +34,7 @@
34
34
  "build": "tsc",
35
35
  "dev": "tsc --watch",
36
36
  "test": "jest",
37
+ "test:inband": "jest --runInBand",
37
38
  "lint": "eslint lib/**/*.js",
38
39
  "prepublishOnly": "npm test && npm run lint",
39
40
  "version": "npm run prepublishOnly && git add -A lib",