@peopl-health/nexus 1.3.1 → 1.4.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/README.md CHANGED
@@ -24,8 +24,23 @@ await nexus.initialize({
24
24
  authToken: process.env.TWILIO_AUTH_TOKEN,
25
25
  phoneNumber: process.env.TWILIO_PHONE_NUMBER
26
26
  },
27
- storage: 'mongo', // or 'noop', or your custom adapter/instance
28
- storageConfig: { mongoUri: process.env.MONGODB_URI }
27
+ // Storage (MongoStorage) and Mongo convenience
28
+ storage: 'mongo',
29
+ storageConfig: { dbName: 'nexus' }, // other options
30
+ mongoUri: process.env.MONGODB_URI, // convenience: passed into storageConfig.mongoUri
31
+
32
+ // Media convenience (inject only bucket name)
33
+ media: { bucketName: process.env.AWS_S3_BUCKET_NAME },
34
+
35
+ // Airtable convenience (pick default base or pass a Base ID)
36
+ airtable: {
37
+ base: 'calendar', // friendly key or base ID
38
+ apiKey: process.env.AIRTABLE_API_KEY
39
+ },
40
+
41
+ // Optional LLM (OpenAI)
42
+ llm: 'openai',
43
+ llmConfig: { apiKey: process.env.OPENAI_API_KEY }
29
44
  });
30
45
 
31
46
  // Built‑in routes (assistant, conversation, media, message, template)
@@ -130,3 +145,64 @@ See:
130
145
  - examples/basic-usage.js
131
146
  - examples/assistants/
132
147
 
148
+
149
+
150
+ ## Configuration
151
+
152
+ You can configure Nexus via environment variables or at runtime using simple injection helpers. For production apps, prefer passing options or DI, and use envs as a fallback.
153
+
154
+ - Providers/AI
155
+ - TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER
156
+ - OPENAI_API_KEY
157
+
158
+ - Mongo
159
+ - MONGODB_URI (or pass `mongoUri` when calling `initializeMongoDB()`)
160
+
161
+ - Airtable
162
+ - AIRTABLE_API_KEY
163
+ - AIRTABLE_BASE_ID (or specific IDs below)
164
+ - AIRTABLE_CALENDAR_ID, AIRTABLE_CONFIG_ID, AIRTABLE_HISTORIAL_CLINICO_ID
165
+ - AIRTABLE_LOGGING_ID, AIRTABLE_MONITOREO_ID, AIRTABLE_PROGRAMA_JUNTAS_ID
166
+ - AIRTABLE_SYMPTOMS_ID, AIRTABLE_WEBINARS_LEADS_ID
167
+
168
+ - AWS (S3)
169
+ - AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION (default: us-east-1)
170
+ - AWS_S3_BUCKET_NAME (or inject via `configureMediaController`)
171
+
172
+ - Misc
173
+ - NODE_ENV (affects logging and helpers)
174
+ - USER_DB_MONGO (used as author in message controller)
175
+
176
+ Injection points (dependency injection)
177
+
178
+ - Message scheduling (use your Agenda/Bull model + scheduler):
179
+ ```js
180
+ const { configureMessageController } = require('@peopl-health/nexus/lib/controllers/messageController');
181
+ const { AgendaMessage } = require('./src/models/agendaMessageModel');
182
+ const { sendScheduledMessage: appSchedule } = require('./src/messaging/scheduledMessageService');
183
+
184
+ const provider = nexus.getMessaging().getProvider(); // e.g., TwilioProvider
185
+ configureMessageController({
186
+ ScheduledMessage: AgendaMessage, // must expose create/find/findById/deleteOne
187
+ sendScheduledMessage: (saved) => appSchedule(provider.twilioClient, saved),
188
+ // Optional: only if you use bulk‑airtable
189
+ getRecordByFilter: require('@peopl-health/nexus/lib/services/airtableService').getRecordByFilter
190
+ });
191
+ ```
192
+
193
+ - Media (inject only your bucket name; AWS SDK is loaded by the lib):
194
+ ```js
195
+ const { configureMediaController } = require('@peopl-health/nexus/lib/controllers/mediaController');
196
+ configureMediaController({ bucketName: process.env.AWS_S3_BUCKET_NAME });
197
+ ```
198
+
199
+ - Storage settings (MongoStorage only):
200
+ ```js
201
+ // Store once; Nexus auto-injects media bucket from storage at startup
202
+ await nexus.getStorage().setConfig('media.bucketName', process.env.AWS_S3_BUCKET_NAME);
203
+ ```
204
+
205
+ Tips
206
+ - Connect Mongo before `app.listen()` to avoid Mongoose buffering timeouts.
207
+ - If you initialize OpenAI, pass `llm: 'openai'` and `llmConfig: { apiKey }` to `nexus.initialize`.
208
+ - Use the event bus + middleware for custom routing and transformations without forking the default handlers.
@@ -161,7 +161,7 @@ class TwilioProvider extends MessageProvider {
161
161
  const links = (content && content.links) || {};
162
162
  let approvalsUrl = links.approvals || links.approvalRequests || links.approval_requests;
163
163
  if (!approvalsUrl) {
164
- approvalsUrl = 'https://content.twilio.com/v1/Content/' + sid + '/ApprovalRequests';
164
+ return { content, approvalRequest: null, warning: 'Approval endpoint not provided by Twilio' };
165
165
  }
166
166
  const resp = await axios.get(approvalsUrl, { auth: { username: this.accountSid, password: this.authToken } });
167
167
  const data = resp && resp.data ? resp.data : null;
@@ -188,7 +188,11 @@ class TwilioProvider extends MessageProvider {
188
188
  const links = (content && content.links) || {};
189
189
  let approvalsUrl = links.approvals || links.approvalRequests || links.approval_requests;
190
190
  if (!approvalsUrl) {
191
- approvalsUrl = 'https://content.twilio.com/v1/Content/' + contentSid + '/ApprovalRequests';
191
+ return {
192
+ success: false,
193
+ contentSid,
194
+ warning: 'Twilio account does not expose an approvals endpoint. Approval must be handled manually in the Console.'
195
+ };
192
196
  }
193
197
  const payload = {
194
198
  name,
@@ -200,7 +204,11 @@ class TwilioProvider extends MessageProvider {
200
204
  const resp = await axios.post(approvalsUrl, payload, { auth: { username: this.accountSid, password: this.authToken } });
201
205
  return { success: true, contentSid, approvalRequest: resp.data || null };
202
206
  } catch (error) {
203
- throw new Error(`Failed to submit for approval: ${error.message}`);
207
+ return {
208
+ success: false,
209
+ contentSid,
210
+ warning: `Failed to submit for approval via API: ${error.message}`
211
+ };
204
212
  }
205
213
  }
206
214
 
@@ -1,18 +1,19 @@
1
1
  const Airtable = require('airtable');
2
+ const runtimeConfig = require('./runtimeConfig');
2
3
 
3
4
  const airtableConfig = {
4
- apiKey: process.env.AIRTABLE_API_KEY,
5
+ apiKey: runtimeConfig.get('AIRTABLE_API_KEY'),
5
6
  };
6
7
 
7
8
  // Configurable base IDs - users can override via environment variables
8
- const Calendar_ID = process.env.AIRTABLE_CALENDAR_ID || 'appIjEstWR6972tbF';
9
- const Config_ID = process.env.AIRTABLE_CONFIG_ID || 'app9K4EvGI8McC8jF';
10
- const Historial_Clinico_ID = process.env.AIRTABLE_HISTORIAL_CLINICO_ID || 'appdUpGUS06XIzVnY';
11
- const Logging_ID = process.env.AIRTABLE_LOGGING_ID || 'appQ7YhzfebRDbSPJ';
12
- const Monitoreo_ID = process.env.AIRTABLE_MONITOREO_ID || 'appdvraKSdp0XVn5n';
13
- const Programa_Juntas_ID = process.env.AIRTABLE_PROGRAMA_JUNTAS_ID || 'appKFWzkcDEWlrXBE';
14
- const Symptoms_ID = process.env.AIRTABLE_SYMPTOMS_ID || 'appQRhZlQ9tMfYZWJ';
15
- const Webinars_Leads_ID = process.env.AIRTABLE_WEBINARS_LEADS_ID || 'appzjpVXTI0TgqGPq';
9
+ const Calendar_ID = require('./runtimeConfig').get('AIRTABLE_CALENDAR_ID') || 'appIjEstWR6972tbF';
10
+ const Config_ID = require('./runtimeConfig').get('AIRTABLE_CONFIG_ID') || 'app9K4EvGI8McC8jF';
11
+ const Historial_Clinico_ID = require('./runtimeConfig').get('AIRTABLE_HISTORIAL_CLINICO_ID') || 'appdUpGUS06XIzVnY';
12
+ const Logging_ID = require('./runtimeConfig').get('AIRTABLE_LOGGING_ID') || 'appQ7YhzfebRDbSPJ';
13
+ const Monitoreo_ID = require('./runtimeConfig').get('AIRTABLE_MONITOREO_ID') || 'appdvraKSdp0XVn5n';
14
+ const Programa_Juntas_ID = require('./runtimeConfig').get('AIRTABLE_PROGRAMA_JUNTAS_ID') || 'appKFWzkcDEWlrXBE';
15
+ const Symptoms_ID = require('./runtimeConfig').get('AIRTABLE_SYMPTOMS_ID') || 'appQRhZlQ9tMfYZWJ';
16
+ const Webinars_Leads_ID = require('./runtimeConfig').get('AIRTABLE_WEBINARS_LEADS_ID') || 'appzjpVXTI0TgqGPq';
16
17
 
17
18
  // Initialize Airtable only if API key is provided
18
19
  let airtable = null;
@@ -20,6 +21,17 @@ if (airtableConfig.apiKey) {
20
21
  airtable = new Airtable({ apiKey: airtableConfig.apiKey });
21
22
  }
22
23
 
24
+ const BASE_MAP = {
25
+ calendar: Calendar_ID,
26
+ config: Config_ID,
27
+ historial: Historial_Clinico_ID,
28
+ logging: Logging_ID,
29
+ monitoreo: Monitoreo_ID,
30
+ programa: Programa_Juntas_ID,
31
+ symptoms: Symptoms_ID,
32
+ webinars: Webinars_Leads_ID
33
+ };
34
+
23
35
  module.exports = {
24
36
  airtable,
25
37
  config: airtableConfig,
@@ -33,13 +45,17 @@ module.exports = {
33
45
  Webinars_Leads_ID,
34
46
 
35
47
  // Helper function to get base by ID
36
- getBase: (baseId = process.env.AIRTABLE_BASE_ID) => {
48
+ getBase: (baseKeyOrId = require('./runtimeConfig').get('AIRTABLE_BASE_ID')) => {
37
49
  if (!airtable) {
38
50
  throw new Error('Airtable not configured. Please set AIRTABLE_API_KEY environment variable.');
39
51
  }
40
- if (!baseId) {
41
- throw new Error('Airtable base ID not provided. Please set AIRTABLE_BASE_ID environment variable or pass baseId parameter.');
52
+ let resolved = baseKeyOrId;
53
+ if (resolved && BASE_MAP[String(resolved).toLowerCase()]) {
54
+ resolved = BASE_MAP[String(resolved).toLowerCase()];
55
+ }
56
+ if (!resolved) {
57
+ throw new Error('Airtable base identifier not provided. Pass a base ID or one of: ' + Object.keys(BASE_MAP).join(', ') + '.');
42
58
  }
43
- return airtable.base(baseId);
59
+ return airtable.base(resolved);
44
60
  }
45
61
  };
@@ -0,0 +1,15 @@
1
+ const overrides = new Map();
2
+
3
+ function set(key, value) {
4
+ if (!key) return;
5
+ overrides.set(String(key), value);
6
+ }
7
+
8
+ function get(key, fallback = undefined) {
9
+ const k = String(key);
10
+ if (overrides.has(k)) return overrides.get(k);
11
+ return process.env[k] !== undefined ? process.env[k] : fallback;
12
+ }
13
+
14
+ module.exports = { set, get };
15
+
@@ -1,4 +1,3 @@
1
- // Use mongoose models directly to avoid conflicts
2
1
  const mongoose = require('mongoose');
3
2
  const { fetchConversationData, processConversations } = require('../services/conversationService');
4
3
  const { sendMessage } = require('../core/NexusMessaging');
@@ -1,11 +1,4 @@
1
- // Optional AWS config - will be undefined if not available
2
- let downloadFileFromS3, s3;
3
- try {
4
- downloadFileFromS3 = require('../config/awsConfig')?.downloadFileFromS3;
5
- s3 = require('../config/awsConfig')?.s3;
6
- } catch (e) {
7
- // AWS config not available
8
- }
1
+ const { s3, downloadFileFromS3 } = require('../config/awsConfig');
9
2
  const bucketName = process.env.AWS_S3_BUCKET_NAME;
10
3
 
11
4
 
@@ -24,6 +17,14 @@ const getMediaController = async (req, res) => {
24
17
  });
25
18
  }
26
19
 
20
+ // Validate configuration
21
+ if (!downloadFileFromS3 || typeof downloadFileFromS3 !== 'function') {
22
+ return res.status(500).json({ success: false, error: 'downloadFileFromS3 not configured. Call configureMediaController() to inject it.' });
23
+ }
24
+ if (!bucketName) {
25
+ return res.status(500).json({ success: false, error: 'AWS_S3_BUCKET_NAME not configured. Pass bucketName to configureMediaController() or set env.' });
26
+ }
27
+
27
28
  let mediaKey = key;
28
29
 
29
30
  console.log(`[MediaController] Final S3 key to fetch: ${mediaKey}`);
@@ -32,36 +33,37 @@ const getMediaController = async (req, res) => {
32
33
 
33
34
  if (!fileData || !fileData.Body) {
34
35
  console.error(`[MediaController] Media not found in S3: ${key}`);
35
-
36
- try {
37
- const prefix = key.split('/')[0];
38
- console.log(`[MediaController] Checking S3 for objects with prefix: ${prefix}/`);
39
-
40
- s3.listObjectsV2({
41
- Bucket: bucketName,
42
- Prefix: prefix + '/',
43
- MaxKeys: 10
44
- }).promise()
45
- .then(listData => {
46
- if (listData.Contents && listData.Contents.length > 0) {
47
- console.log(`[MediaController] Found ${listData.Contents.length} objects with similar prefix:`);
48
- listData.Contents.forEach(item => {
49
- console.log(`[MediaController] - ${item.Key} (${item.Size} bytes)`);
50
- if (item.Key.includes(key.split('/').pop().substring(0, 10))) {
51
- console.log(`[MediaController] !!! POTENTIAL MATCH: ${item.Key}`);
52
- }
53
- });
54
- } else {
55
- console.log(`[MediaController] No objects found with prefix: ${prefix}/`);
56
- }
57
- })
58
- .catch(listErr => {
59
- console.error(`[MediaController] Error listing objects: ${listErr.message}`);
60
- });
61
- } catch (listErr) {
62
- console.error(`[MediaController] Error setting up bucket listing: ${listErr.message}`);
36
+
37
+ if (s3) {
38
+ try {
39
+ const prefix = key.split('/') [0];
40
+ console.log(`[MediaController] Checking S3 for objects with prefix: ${prefix}/`);
41
+ s3.listObjectsV2({
42
+ Bucket: bucketName,
43
+ Prefix: prefix + '/',
44
+ MaxKeys: 10
45
+ }).promise()
46
+ .then(listData => {
47
+ if (listData.Contents && listData.Contents.length > 0) {
48
+ console.log(`[MediaController] Found ${listData.Contents.length} objects with similar prefix:`);
49
+ listData.Contents.forEach(item => {
50
+ console.log(`[MediaController] - ${item.Key} (${item.Size} bytes)`);
51
+ if (item.Key.includes(key.split('/').pop().substring(0, 10))) {
52
+ console.log(`[MediaController] !!! POTENTIAL MATCH: ${item.Key}`);
53
+ }
54
+ });
55
+ } else {
56
+ console.log(`[MediaController] No objects found with prefix: ${prefix}/`);
57
+ }
58
+ })
59
+ .catch(listErr => {
60
+ console.error(`[MediaController] Error listing objects: ${listErr.message}`);
61
+ });
62
+ } catch (listErr) {
63
+ console.error(`[MediaController] Error setting up bucket listing: ${listErr.message}`);
64
+ }
63
65
  }
64
-
66
+
65
67
  return res.status(404).json({
66
68
  success: false,
67
69
  error: 'Media not found in S3',
@@ -102,4 +104,4 @@ const getMediaController = async (req, res) => {
102
104
 
103
105
  module.exports = {
104
106
  getMediaController
105
- };
107
+ };
@@ -1,23 +1,84 @@
1
1
  const { Message } = require('../models/messageModel.js');
2
+ const { ScheduledMessage: DefaultScheduledMessage } = require('../models/agendaMessageModel.js');
3
+ const {
4
+ sendMessage: defaultSendMessage,
5
+ sendScheduledMessage: defaultSendScheduledMessage
6
+ } = require('../core/NexusMessaging');
7
+ const { getRecordByFilter: defaultGetRecordByFilter } = require('../services/airtableService');
8
+ const runtimeConfig = require('../config/runtimeConfig');
9
+ const moment = require('moment-timezone');
2
10
 
3
- // Import from Nexus core
4
- const { sendMessage } = require('../core/NexusMessaging');
11
+ const dependencies = {
12
+ ScheduledMessage: DefaultScheduledMessage,
13
+ getRecordByFilter: defaultGetRecordByFilter,
14
+ sendScheduledMessage: defaultSendScheduledMessage,
15
+ sendMessage: defaultSendMessage
16
+ };
5
17
 
6
- // Injectable dependencies with safe defaults
7
- let injected = {
8
- ScheduledMessage: null,
9
- getRecordByFilter: null,
10
- sendScheduledMessage: null
18
+ const configureMessageController = (overrides = {}) => {
19
+ if (overrides.ScheduledMessage) dependencies.ScheduledMessage = overrides.ScheduledMessage;
20
+ if (overrides.getRecordByFilter) dependencies.getRecordByFilter = overrides.getRecordByFilter;
21
+ if (overrides.sendScheduledMessage) dependencies.sendScheduledMessage = overrides.sendScheduledMessage;
22
+ if (overrides.sendMessage) dependencies.sendMessage = overrides.sendMessage;
23
+ return { ...dependencies };
11
24
  };
12
25
 
13
- // Allow consumers to inject their model and services
14
- const configureMessageController = ({ ScheduledMessage, getRecordByFilter, sendScheduledMessage } = {}) => {
15
- if (ScheduledMessage) injected.ScheduledMessage = ScheduledMessage;
16
- if (getRecordByFilter) injected.getRecordByFilter = getRecordByFilter;
17
- if (sendScheduledMessage) injected.sendScheduledMessage = sendScheduledMessage;
26
+ const ensureDependency = (res, condition, errorMessage) => {
27
+ if (condition) return true;
28
+ res.status(500).json({ success: false, error: errorMessage });
29
+ return false;
18
30
  };
19
31
 
20
- const moment = require('moment-timezone');
32
+ const persistScheduledMessage = async (Model, payload) => {
33
+ if (Model && typeof Model.create === 'function') {
34
+ return await Model.create(payload);
35
+ }
36
+ if (typeof Model === 'function') {
37
+ const instance = new Model(payload);
38
+ if (typeof instance.save === 'function') {
39
+ await instance.save();
40
+ return instance;
41
+ }
42
+ }
43
+ throw new Error('ScheduledMessage model must expose create() or constructor+save()');
44
+ };
45
+
46
+ const pickMessageId = (result, fallbackDoc) => {
47
+ if (result) {
48
+ const id = result.sid || result.messageId || result.id || result._id;
49
+ if (id) return id;
50
+ }
51
+
52
+ if (fallbackDoc) {
53
+ const docId = fallbackDoc.wa_id || fallbackDoc.sid || fallbackDoc.id || fallbackDoc._id;
54
+ if (docId) return typeof docId === 'object' && docId.toString ? docId.toString() : docId;
55
+ }
56
+
57
+ return null;
58
+ };
59
+
60
+ const normalizeCode = (code) => {
61
+ if (!code || typeof code !== 'string') return code;
62
+
63
+ const trimmed = code.trim();
64
+ if (trimmed.startsWith('whatsapp:')) {
65
+ return trimmed;
66
+ }
67
+
68
+ if (trimmed.includes('@')) {
69
+ return trimmed;
70
+ }
71
+
72
+ if (trimmed.startsWith('+')) {
73
+ return `whatsapp:${trimmed}`;
74
+ }
75
+
76
+ if (/^\d+$/.test(trimmed)) {
77
+ return `whatsapp:+${trimmed}`;
78
+ }
79
+
80
+ return trimmed;
81
+ };
21
82
 
22
83
  const sendMessageController = async (req, res) => {
23
84
  const {
@@ -31,38 +92,43 @@ const sendMessageController = async (req, res) => {
31
92
  contentSid = null,
32
93
  variables = null
33
94
  } = req.body || {};
34
- const author = process.env.USER_DB_MONGO;
95
+ const author = runtimeConfig.get('USER_DB_MONGO');
35
96
  const sendMoment = sendTime ? moment.tz(sendTime, timeZone) + 2500 : new Date();
36
97
 
98
+ const ScheduledMessageModel = dependencies.ScheduledMessage;
99
+ if (!ensureDependency(res, ScheduledMessageModel, 'ScheduledMessage model not configured. Call configureMessageController() to inject it.')) return;
100
+
101
+ const hasScheduler = typeof dependencies.sendScheduledMessage === 'function';
102
+ const hasDirectSend = typeof dependencies.sendMessage === 'function';
103
+ if (!ensureDependency(res, hasScheduler || hasDirectSend, 'No messaging provider configured. Ensure Nexus.initialize() completed before using the message controllers.')) return;
104
+
37
105
  try {
38
- if (!injected.ScheduledMessage || typeof injected.ScheduledMessage.create !== 'function') {
39
- return res.status(500).json({ success: false, error: 'ScheduledMessage model not configured. Call configureMessageController() to inject it.' });
40
- }
41
- const messageData = {
106
+ const payload = {
42
107
  fileUrl,
43
108
  message,
44
109
  fileType,
45
110
  timeZone: timeZone === '' ? null : timeZone,
46
111
  sendTime: sendMoment,
47
- contentSid: contentSid,
112
+ contentSid,
48
113
  hidePreview,
49
- code,
114
+ code: normalizeCode(code),
50
115
  author,
51
116
  extraDelay: 0,
52
117
  variables
53
118
  };
54
- await injected.ScheduledMessage.create(messageData);
55
- console.log('Sending message with data:', messageData);
56
-
57
- const result = await sendMessage(messageData);
58
-
119
+
120
+ const scheduledRecord = await persistScheduledMessage(ScheduledMessageModel, payload);
121
+ const result = hasScheduler
122
+ ? await dependencies.sendScheduledMessage(scheduledRecord)
123
+ : await dependencies.sendMessage(payload);
124
+
59
125
  res.status(200).json({
60
- success: true,
61
- message: 'Message sent successfully',
62
- messageId: result?.sid || result?.id
126
+ status: 200,
127
+ response: hasScheduler ? 'Message scheduled to be sent once!' : 'Message sent successfully',
128
+ messageId: pickMessageId(result, scheduledRecord)
63
129
  });
64
130
  } catch (err) {
65
- console.error('Error scheduling individual message:', err.message);
131
+ console.error('Error scheduling individual message:', err);
66
132
  res.status(500).json({ status: false, error: err.message });
67
133
  }
68
134
  };
@@ -72,70 +138,71 @@ const sendBulkMessageController = async (req, res) => {
72
138
  fileUrl,
73
139
  message,
74
140
  fileType,
75
- codes,
141
+ codes = [],
76
142
  sendTime = new Date(),
77
143
  timeZone = 'Etc/GMT',
78
144
  hidePreview = false,
79
145
  contentSid = null,
80
146
  variables = null
81
147
  } = req.body || {};
82
- const author = process.env.USER_DB_MONGO;
83
- const sendMoment = sendTime ? moment.tz(sendTime, timeZone) + 20*1000 : new Date();
148
+ const author = runtimeConfig.get('USER_DB_MONGO');
149
+ const sendMoment = sendTime ? moment.tz(sendTime, timeZone) + 20 * 1000 : new Date();
150
+
151
+ const ScheduledMessageModel = dependencies.ScheduledMessage;
152
+ if (!ensureDependency(res, ScheduledMessageModel, 'ScheduledMessage model not configured. Call configureMessageController() to inject it.')) return;
153
+
154
+ const hasScheduler = typeof dependencies.sendScheduledMessage === 'function';
155
+ const hasDirectSend = typeof dependencies.sendMessage === 'function';
156
+ if (!ensureDependency(res, hasScheduler || hasDirectSend, 'No messaging provider configured. Ensure Nexus.initialize() completed before using the message controllers.')) return;
84
157
 
85
158
  try {
86
- if (!injected.ScheduledMessage || typeof injected.ScheduledMessage.create !== 'function') {
87
- return res.status(500).json({ success: false, error: 'ScheduledMessage model not configured. Call configureMessageController() to inject it.' });
88
- }
89
- if (!injected.sendScheduledMessage || typeof injected.sendScheduledMessage !== 'function') {
90
- return res.status(500).json({ success: false, error: 'sendScheduledMessage not configured. Call configureMessageController() to inject it.' });
91
- }
92
159
  let numSend = 0;
93
160
  let extraDelay = 0;
94
- let curMessage = message;
95
- const scheduledMessages = [];
96
- for (const code of codes) {
97
- const scheduledMessage = new injected.ScheduledMessage({
161
+ const scheduledPayloads = [];
162
+
163
+ for (const recipient of codes) {
164
+ const payload = {
98
165
  fileUrl,
99
- message: curMessage,
166
+ message,
100
167
  fileType,
101
168
  timeZone: timeZone === '' ? null : timeZone,
102
169
  sendTime: new Date(sendMoment + extraDelay),
103
- contentSid: contentSid,
170
+ contentSid,
104
171
  hidePreview,
105
- code,
172
+ code: normalizeCode(recipient),
106
173
  author,
107
174
  extraDelay,
108
175
  variables
109
- });
110
-
111
- // Add to scheduledMessages for both saving and scheduling
112
- scheduledMessages.push(scheduledMessage);
176
+ };
113
177
 
114
- // Increment delay and message counter
178
+ scheduledPayloads.push(payload);
115
179
  extraDelay += Math.floor(Math.random() * 5001) + 5000;
116
180
  numSend += 1;
117
181
  }
118
182
 
119
- // Schedule all messages with Agenda in parallel
120
183
  const sentMessages = await Promise.all(
121
- scheduledMessages.map(async (message) => {
122
- const savedMessage = await injected.ScheduledMessage.create(message);
123
- return injected.sendScheduledMessage(savedMessage);
184
+ scheduledPayloads.map(async (payload) => {
185
+ const savedMessage = await persistScheduledMessage(ScheduledMessageModel, payload);
186
+ const result = hasScheduler
187
+ ? await dependencies.sendScheduledMessage(savedMessage)
188
+ : await dependencies.sendMessage(payload);
189
+ return { result, scheduled: savedMessage };
124
190
  })
125
191
  );
126
192
 
127
193
  console.log(`Send bulk of ${numSend} messages`);
128
194
 
129
- // Extract message IDs
130
- const messageIds = sentMessages.map(msg => msg ? msg.sid : null).filter(id => id !== null);
195
+ const messageIds = sentMessages
196
+ .map(({ result, scheduled }) => pickMessageId(result, scheduled))
197
+ .filter((id) => id !== null);
131
198
 
132
- res.status(200).json({
133
- status: true,
199
+ res.status(200).json({
200
+ status: true,
134
201
  response: 'Bulk message sent',
135
- messageIds: messageIds
202
+ messageIds
136
203
  });
137
204
  } catch (err) {
138
- console.log(err.message);
205
+ console.error('Error sending bulk messages:', err);
139
206
  res.status(500).send(err.message);
140
207
  }
141
208
  };
@@ -155,8 +222,18 @@ const sendBulkMessageAirtableController = async (req, res) => {
155
222
  condition = '1',
156
223
  variables = null
157
224
  } = req.body || {};
158
- const author = process.env.USER_DB_MONGO;
159
- const sendMoment = sendTime ? moment.tz(sendTime, timeZone) + 20*1000 : new Date();
225
+ const author = runtimeConfig.get('USER_DB_MONGO');
226
+ const sendMoment = sendTime ? moment.tz(sendTime, timeZone) + 20 * 1000 : new Date();
227
+
228
+ const ScheduledMessageModel = dependencies.ScheduledMessage;
229
+ if (!ensureDependency(res, ScheduledMessageModel, 'ScheduledMessage model not configured. Call configureMessageController() to inject it.')) return;
230
+
231
+ const hasScheduler = typeof dependencies.sendScheduledMessage === 'function';
232
+ const hasDirectSend = typeof dependencies.sendMessage === 'function';
233
+ if (!ensureDependency(res, hasScheduler || hasDirectSend, 'No messaging provider configured. Ensure Nexus.initialize() completed before using the message controllers.')) return;
234
+
235
+ const airtableFetcher = dependencies.getRecordByFilter;
236
+ if (!ensureDependency(res, typeof airtableFetcher === 'function', 'Airtable getRecordByFilter not configured. Call configureMessageController() to inject it.')) return;
160
237
 
161
238
  const regex = /\[(.*?)\]/g;
162
239
  const envVariables = [];
@@ -166,24 +243,14 @@ const sendBulkMessageAirtableController = async (req, res) => {
166
243
  }
167
244
 
168
245
  try {
169
- if (!injected.getRecordByFilter || typeof injected.getRecordByFilter !== 'function') {
170
- return res.status(500).json({ success: false, error: 'Airtable getRecordByFilter not configured. Call configureMessageController() to inject it.' });
171
- }
172
- if (!injected.ScheduledMessage || typeof injected.ScheduledMessage.create !== 'function') {
173
- return res.status(500).json({ success: false, error: 'ScheduledMessage model not configured. Call configureMessageController() to inject it.' });
174
- }
175
- if (!injected.sendScheduledMessage || typeof injected.sendScheduledMessage !== 'function') {
176
- return res.status(500).json({ success: false, error: 'sendScheduledMessage not configured. Call configureMessageController() to inject it.' });
177
- }
178
- const rows = await injected.getRecordByFilter(baseId, tableName, condition);
246
+ const rows = await airtableFetcher(baseId, tableName, condition);
179
247
  let extraDelay = 0;
180
- let curMessage = message;
181
248
  const sentPhones = new Set();
182
- const scheduledMessages = [];
249
+ const scheduledPayloads = [];
250
+
251
+ for (const row of rows || []) {
252
+ let customMessage = message;
183
253
 
184
- for (const row of rows) {
185
- let customMessage = curMessage;
186
-
187
254
  for (const envVar of envVariables) {
188
255
  let value = row[envVar];
189
256
  if (Array.isArray(value)) value = value[0];
@@ -195,54 +262,57 @@ const sendBulkMessageAirtableController = async (req, res) => {
195
262
  let code = row[columnPhone];
196
263
  if (Array.isArray(code)) code = code[0];
197
264
  if (!code) continue;
198
- if (!code.includes('@g.us')) code = `${code}@s.whatsapp.net`;
265
+ code = normalizeCode(code);
199
266
 
200
267
  if (sentPhones.has(code)) continue;
201
268
  sentPhones.add(code);
202
269
 
203
- // Prepare message object
204
- const scheduledMessage = {
270
+ const payload = {
205
271
  fileUrl,
206
272
  message: customMessage,
207
273
  fileType,
208
274
  timeZone: timeZone === '' ? null : timeZone,
209
275
  sendTime: new Date(sendMoment + extraDelay),
210
- contentSid: contentSid,
276
+ contentSid,
211
277
  hidePreview,
212
278
  code,
213
279
  author,
214
280
  variables
215
281
  };
216
-
217
- scheduledMessages.push(scheduledMessage);
282
+
283
+ scheduledPayloads.push(payload);
218
284
  extraDelay += Math.floor(Math.random() * 5001) + 5000;
219
285
  }
220
286
 
221
287
  const sentMessages = await Promise.all(
222
- scheduledMessages.map(async (message) => {
223
- const savedMessage = await injected.ScheduledMessage.create(message);
224
- return injected.sendScheduledMessage(savedMessage);
288
+ scheduledPayloads.map(async (payload) => {
289
+ const savedMessage = await persistScheduledMessage(ScheduledMessageModel, payload);
290
+ const result = hasScheduler
291
+ ? await dependencies.sendScheduledMessage(savedMessage)
292
+ : await dependencies.sendMessage(payload);
293
+ return { result, scheduled: savedMessage };
225
294
  })
226
295
  );
227
296
 
228
- // Extract message IDs
229
- const messageIds = sentMessages.map(msg => msg ? msg.sid : null).filter(id => id !== null);
297
+ const messageIds = sentMessages
298
+ .map(({ result, scheduled }) => pickMessageId(result, scheduled))
299
+ .filter((id) => id !== null);
230
300
 
231
- console.log(`Iterate over ${rows.length} rows`);
301
+ console.log(`Iterate over ${(rows || []).length} rows`);
232
302
 
233
- res.status(200).json({
234
- status: true,
303
+ res.status(200).json({
304
+ status: true,
235
305
  response: 'Airtable message sent',
236
- messageIds: messageIds
306
+ messageIds
237
307
  });
238
308
  } catch (err) {
239
- console.log(err.message);
309
+ console.error('Error sending Airtable bulk messages:', err);
240
310
  res.status(500).send(err.message);
241
311
  }
242
312
  };
243
313
 
244
314
  const getLastInteractionController = async (req, res) => {
245
- const { code } = req.body;
315
+ const { code } = req.query;
246
316
 
247
317
  try {
248
318
  const lastMessage = await Message.findOne({
@@ -255,7 +325,7 @@ const getLastInteractionController = async (req, res) => {
255
325
  if (!lastMessage) {
256
326
  return res.status(404).send({ message: 'No messages found for the provided code.' });
257
327
  }
258
-
328
+
259
329
  const createdAt = new Date(lastMessage.createdAt);
260
330
  const now = new Date();
261
331
  const timeDiffMs = now - createdAt;
@@ -274,4 +344,4 @@ module.exports = {
274
344
  sendBulkMessageAirtableController,
275
345
  getLastInteractionController,
276
346
  configureMessageController
277
- };
347
+ };
@@ -1,11 +1,3 @@
1
- // Nexus provider will be injected - templates only work with Twilio
2
- let nexusProvider = null;
3
-
4
- // Configure Nexus provider
5
- const configureNexusProvider = (provider) => {
6
- nexusProvider = provider;
7
- };
8
-
9
1
  // Check if provider supports templates
10
2
  const checkTemplateSupport = () => {
11
3
  if (!nexusProvider) {
@@ -20,10 +12,29 @@ const checkTemplateSupport = () => {
20
12
 
21
13
  const { handleApiError } = require('../utils/errorHandler');
22
14
 
23
- const { Template } = require('../templates/templateStructure');
15
+ const {
16
+ Template,
17
+ configureNexusProvider: configureTemplateProvider
18
+ } = require('../templates/templateStructure');
24
19
  const predefinedTemplates = require('../templates/predefinedTemplates');
25
20
 
26
21
 
22
+ // Nexus provider will be injected - templates only work with Twilio
23
+ let nexusProvider = null;
24
+
25
+ // Configure Nexus provider
26
+ const configureNexusProvider = (provider) => {
27
+ nexusProvider = provider;
28
+ if (typeof configureTemplateProvider === 'function') {
29
+ try {
30
+ configureTemplateProvider(provider);
31
+ } catch (err) {
32
+ console.warn('[templateController] Failed to propagate provider to template structure:', err?.message || err);
33
+ }
34
+ }
35
+ };
36
+
37
+
27
38
  const getTemplateModel = () => {
28
39
  // Require the concrete model; enforce presence instead of stubbing
29
40
  try {
@@ -441,7 +441,25 @@ class NexusMessaging {
441
441
  }
442
442
  }
443
443
 
444
- const defaultInstance = new NexusMessaging();
444
+ let defaultInstance = new NexusMessaging();
445
+
446
+ const setDefaultInstance = (instance) => {
447
+ if (!instance) {
448
+ throw new Error('setDefaultInstance requires a NexusMessaging instance');
449
+ }
450
+
451
+ const isCompatible = typeof instance.sendMessage === 'function' &&
452
+ typeof instance.sendScheduledMessage === 'function' &&
453
+ typeof instance.processIncomingMessage === 'function';
454
+
455
+ if (!isCompatible) {
456
+ throw new Error('setDefaultInstance received an incompatible object');
457
+ }
458
+
459
+ defaultInstance = instance;
460
+ };
461
+
462
+ const getDefaultInstance = () => defaultInstance;
445
463
 
446
464
  const sendMessage = async (messageData) => {
447
465
  return await defaultInstance.sendMessage(messageData);
@@ -454,5 +472,7 @@ const sendScheduledMessage = async (scheduledMessage) => {
454
472
  module.exports = {
455
473
  NexusMessaging,
456
474
  sendMessage,
457
- sendScheduledMessage
475
+ sendScheduledMessage,
476
+ setDefaultInstance,
477
+ getDefaultInstance
458
478
  };
@@ -1,5 +1,5 @@
1
1
  const { downloadFileFromS3, generatePresignedUrl } = require('../config/awsConfig.js');
2
- const { openaiClient } = require('../config/llmConfig.js');
2
+ const llmConfig = require('../config/llmConfig.js');
3
3
 
4
4
  const { Message } = require('../models/messageModel.js');
5
5
 
@@ -16,7 +16,9 @@ const mode = process.env.NODE_ENV || 'dev';
16
16
 
17
17
  async function checkRunStatus(assistant, thread_id, run_id, retryCount = 0, maxRetries = 30) {
18
18
  try {
19
- const run = await openaiClient.beta.threads.runs.retrieve(thread_id, run_id);
19
+ const client = llmConfig.openaiClient;
20
+ if (!client) throw new Error('OpenAI client not configured');
21
+ const run = await client.beta.threads.runs.retrieve(thread_id, run_id);
20
22
  console.log(`Status: ${run.status} ${thread_id} ${run_id} (attempt ${retryCount + 1})`);
21
23
 
22
24
  if (run.status === 'failed' || run.status === 'expired' || run.status === 'incomplete') {
@@ -47,7 +49,9 @@ async function checkRunStatus(assistant, thread_id, run_id, retryCount = 0, maxR
47
49
 
48
50
  async function checkIfFinished(text) {
49
51
  try {
50
- const completion = await openaiClient.chat.completions.create({
52
+ const client = llmConfig.openaiClient;
53
+ if (!client) throw new Error('OpenAI client not configured');
54
+ const completion = await client.chat.completions.create({
51
55
  model: 'gpt-4o-mini',
52
56
  messages: [
53
57
  {
@@ -210,7 +214,9 @@ async function processMessage(code, reply, thread) {
210
214
  const imageAnalysis = await analyzeImage(fileName);
211
215
  console.log(imageAnalysis);
212
216
  const invalidAnalysis = ['NOT_MEDICAL', 'QUALITY_INSUFFICIENT'];
213
- url = await generatePresignedUrl(reply.media.bucketName, reply.media.key);
217
+ if (imageAnalysis.medical_relevance) {
218
+ url = await generatePresignedUrl(reply.media.bucketName, reply.media.key);
219
+ }
214
220
  if (imageAnalysis.has_table) {
215
221
  messagesChat.push({
216
222
  type: 'text',
@@ -223,7 +229,9 @@ async function processMessage(code, reply, thread) {
223
229
  });
224
230
  } else {
225
231
  console.log('Add attachment');
226
- const file = await openaiClient.files.create({
232
+ const client = llmConfig.openaiClient;
233
+ if (!client) throw new Error('OpenAI client not configured');
234
+ const file = await client.files.create({
227
235
  file: fs.createReadStream(fileName),
228
236
  purpose: 'vision',
229
237
  });
@@ -233,7 +241,9 @@ async function processMessage(code, reply, thread) {
233
241
  });
234
242
  }
235
243
  } else if (fileName.includes('audio')) {
236
- const audioTranscript = await openaiClient.audio.transcriptions.create({
244
+ const client = llmConfig.openaiClient;
245
+ if (!client) throw new Error('OpenAI client not configured');
246
+ const audioTranscript = await client.audio.transcriptions.create({
237
247
  model: 'whisper-1',
238
248
  file: fs.createReadStream(fileName),
239
249
  response_format: 'text',
@@ -251,7 +261,9 @@ async function processMessage(code, reply, thread) {
251
261
  console.log('messagesChat', messagesChat);
252
262
  console.log('attachments', attachments);
253
263
 
254
- await openaiClient.beta.threads.messages.create(thread.thread_id, {
264
+ const client = llmConfig.openaiClient;
265
+ if (!client) throw new Error('OpenAI client not configured');
266
+ await client.beta.threads.messages.create(thread.thread_id, {
255
267
  role: 'user',
256
268
  content: messagesChat,
257
269
  attachments: attachments
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- const { NexusMessaging } = require('./core/NexusMessaging');
1
+ const { NexusMessaging, setDefaultInstance } = require('./core/NexusMessaging');
2
2
  const { MongoStorage } = require('./storage/MongoStorage');
3
3
  const { MessageParser } = require('./utils/messageParser');
4
4
  const { DefaultLLMProvider } = require('./utils/defaultLLMProvider');
@@ -6,6 +6,8 @@ const { loadNexusConfig } = require('./config/configLoader');
6
6
  const templateController = require('./controllers/templateController');
7
7
  const templateFlowController = require('./controllers/templateFlowController');
8
8
  const interactive = require('./interactive');
9
+ const runtimeConfig = require('./config/runtimeConfig');
10
+ const llmConfigModule = require('./config/llmConfig');
9
11
  const {
10
12
  configureLLMProvider: configureAssistantsLLM,
11
13
  registerAssistant,
@@ -22,6 +24,11 @@ class Nexus {
22
24
  constructor(config = {}) {
23
25
  this.config = config;
24
26
  this.messaging = new NexusMessaging(config.messaging || {});
27
+ try {
28
+ setDefaultInstance(this.messaging);
29
+ } catch (err) {
30
+ console.warn('[Nexus] Failed to set default messaging instance:', err?.message || err);
31
+ }
25
32
  this.storage = null;
26
33
  this.messageParser = null;
27
34
  this.llmProvider = null;
@@ -75,6 +82,23 @@ class Nexus {
75
82
  console.warn('Warning: failed to auto-configure template providers:', e?.message || e);
76
83
  }
77
84
 
85
+
86
+ // Convenience: handle mongoUri early (before storage connect)
87
+ try {
88
+ if (options.mongoUri) {
89
+ if (storage === 'mongo') {
90
+ if (typeof storageConfig === 'object' && !storageConfig.mongoUri) {
91
+ storageConfig.mongoUri = options.mongoUri;
92
+ }
93
+ } else {
94
+ // If not using MongoStorage but a URI is provided, initialize default mongoose connection
95
+ await this.messaging.initializeMongoDB(options.mongoUri);
96
+ }
97
+ }
98
+ } catch (dbErr) {
99
+ console.warn('[Nexus] mongo convenience warning:', dbErr?.message || dbErr);
100
+ }
101
+
78
102
  // Initialize storage if provided
79
103
  if (storage) {
80
104
  try {
@@ -104,6 +128,39 @@ class Nexus {
104
128
  // Initialize default LLM provider if requested
105
129
  if (llm === 'openai') {
106
130
  this.llmProvider = new DefaultLLMProvider(llmConfig);
131
+ try {
132
+ if (this.llmProvider && typeof this.llmProvider.getClient === 'function') {
133
+ llmConfigModule.openaiClient = this.llmProvider.getClient();
134
+ }
135
+ } catch (err) {
136
+ console.warn('[Nexus] Failed to expose OpenAI client:', err?.message || err);
137
+ }
138
+
139
+ // Convenience: handle common top-level config for mongo, media bucket, airtable
140
+ try {
141
+ // Mongo URI passthrough for storage=mongo
142
+ if (options.mongoUri && storage === 'mongo') {
143
+ if (typeof storageConfig === 'object') {
144
+ storageConfig.mongoUri = storageConfig.mongoUri || options.mongoUri;
145
+ }
146
+ }
147
+
148
+ // Media bucket (overrides storage setting)
149
+ if (options.media && options.media.bucketName) {
150
+ runtimeConfig.set('AWS_S3_BUCKET_NAME', options.media.bucketName);
151
+ }
152
+
153
+ // Airtable base default (accepts alias like 'calendar' or an ID)
154
+ if (options.airtable && options.airtable.base) {
155
+ runtimeConfig.set('AIRTABLE_BASE_ID', options.airtable.base);
156
+ }
157
+ if (options.airtable && options.airtable.apiKey) {
158
+ runtimeConfig.set('AIRTABLE_API_KEY', options.airtable.apiKey);
159
+ }
160
+ } catch (cfgErr) {
161
+ console.warn('[Nexus] convenience config warning:', cfgErr?.message || cfgErr);
162
+ }
163
+
107
164
  }
108
165
 
109
166
 
@@ -25,6 +25,7 @@ function toTwilioContent(spec) {
25
25
 
26
26
  if (type === 'flow') {
27
27
  content.types['twilio/flows'] = {
28
+ type: flow?.type || spec.flowType || 'FLOW',
28
29
  body: flow?.body || body || '',
29
30
  button_text: flow?.buttonText || spec.buttonText || undefined,
30
31
  subtitle: flow?.subtitle || spec.subtitle || undefined,
@@ -52,9 +52,40 @@ messageSchema.pre('save', function (next) {
52
52
  const Message = mongoose.model('Message', messageSchema);
53
53
 
54
54
  async function insertMessage(values) {
55
- const msg = new Message(values);
56
- await msg.save();
57
- return msg;
55
+ try {
56
+ const skipNumbers = [
57
+ '5215592261426@s.whatsapp.net',
58
+ '5215547411345@s.whatsapp.net',
59
+ '51985959446@s.whatsapp.net'
60
+ ];
61
+ const messageData = {
62
+ nombre_whatsapp: values.nombre_whatsapp,
63
+ numero: values.numero,
64
+ body: values.body,
65
+ timestamp: values.timestamp,
66
+ message_id: values.message_id,
67
+ is_group: values.is_group,
68
+ is_media: values.is_media,
69
+ group_id: values.group_id,
70
+ reply_id: values.reply_id,
71
+ from_me: values.from_me,
72
+ processed: skipNumbers.includes(values.numero),
73
+ media: values.media ? values.media : null,
74
+ content_sid: values.content_sid || null,
75
+ template_variables: values.template_variables || null
76
+ };
77
+
78
+ await Message.findOneAndUpdate(
79
+ { message_id: values.message_id, body: values.body },
80
+ { $setOnInsert: messageData },
81
+ { upsert: true, new: true }
82
+ );
83
+
84
+ console.log('Message inserted or updated successfully');
85
+ } catch (err) {
86
+ console.error('Error inserting message:', err);
87
+ throw err;
88
+ }
58
89
  }
59
90
 
60
91
  function formatTimestamp(unixTimestamp) {
@@ -62,7 +93,7 @@ function formatTimestamp(unixTimestamp) {
62
93
  return date.toLocaleString('sv-MX', {
63
94
  timeZone: 'America/Mexico_City',
64
95
  hour12: false,
65
- }).replace(' ', 'T').slice(0, 19);
96
+ }).replace(' ', 'T').slice(0, 19);
66
97
  }
67
98
 
68
99
  function getMessageValues(message, content, reply, is_media) {
@@ -90,10 +121,27 @@ function getMessageValues(message, content, reply, is_media) {
90
121
  };
91
122
  }
92
123
 
124
+ async function getContactDisplayName(contactNumber) {
125
+ try {
126
+ const latestMessage = await Message.findOne({ numero: contactNumber })
127
+ .sort({ timestamp: -1 })
128
+ .select('nombre_whatsapp');
129
+
130
+ if (latestMessage && latestMessage.nombre_whatsapp && latestMessage.nombre_whatsapp.trim() !== '') {
131
+ return latestMessage.nombre_whatsapp;
132
+ } else {
133
+ return contactNumber;
134
+ }
135
+ } catch (error) {
136
+ console.error(`Error fetching display name for ${contactNumber}:`, error);
137
+ return contactNumber;
138
+ }
139
+ }
140
+
93
141
  module.exports = {
94
142
  Message,
95
- // Backward-compatible helper used by helpers
96
143
  insertMessage,
97
144
  getMessageValues,
98
- formatTimestamp
145
+ formatTimestamp,
146
+ getContactDisplayName
99
147
  };
@@ -32,7 +32,7 @@ const messageRouteDefinitions = {
32
32
  'POST /send': 'sendMessageController',
33
33
  'POST /send-bulk': 'sendBulkMessageController',
34
34
  'POST /send-bulk-airtable': 'sendBulkMessageAirtableController',
35
- 'POST /get-last': 'getLastInteractionController'
35
+ 'GET /last': 'getLastInteractionController'
36
36
  };
37
37
 
38
38
  const templateRouteDefinitions = {
@@ -1,4 +1,8 @@
1
- const { Historial_Clinico_ID } = require('../config/airtableConfig.js');
1
+ const { Historial_Clinico_ID, Monitoreo_ID } = require('../config/airtableConfig.js');
2
+ const AWS = require('../config/awsConfig.js');
3
+ const { combineImagesToPDF, cleanupFiles } = require('../helpers/filesHelper.js');
4
+ const { addRecord } = require('../services/airtableService.js');
5
+ const runtimeConfig = require('../config/runtimeConfig');
2
6
 
3
7
  let llmProvider = null;
4
8
  const configureLLMProvider = (provider) => {
@@ -243,6 +247,25 @@ const replyAssistant = async function (code, message_ = null, thread_ = null, ru
243
247
 
244
248
  if (urls.length > 0) {
245
249
  console.log('urls', urls);
250
+ const { pdfBuffer, processedFiles } = await combineImagesToPDF({ code });
251
+ console.log('AFTER COMBINED IN BUFFER', processedFiles);
252
+ const key = `${code}-${Date.now()}-combined.pdf`;
253
+ const bucket = runtimeConfig.get('AWS_S3_BUCKET_NAME');
254
+ if (bucket && pdfBuffer) {
255
+ await AWS.uploadBufferToS3(pdfBuffer, bucket, key, 'application/pdf');
256
+ const url = await AWS.generatePresignedUrl(bucket, key);
257
+ const curRow = await getCurRow(Monitoreo_ID, code);
258
+ const customer_id = curRow?.[0]?.recordID || curRow?.[0]?.record_id || curRow?.[0]?.id || null;
259
+ console.log('customer_id:', customer_id);
260
+ try {
261
+ await addRecord(Monitoreo_ID, 'estudios', [{ fields: { estudios: urls, combined_estudios: [{ url }], patient_id: customer_id ? [customer_id] : [] } }]);
262
+ } catch (e) {
263
+ console.warn('Failed to add Airtable estudios record:', e?.message || e);
264
+ }
265
+ }
266
+ if (processedFiles && processedFiles.length) {
267
+ await cleanupFiles(processedFiles);
268
+ }
246
269
  }
247
270
 
248
271
  thread = await getThread(code);
@@ -1,4 +1,5 @@
1
1
  const mongoose = require('mongoose');
2
+ const runtimeConfig = require('../config/runtimeConfig');
2
3
 
3
4
  /**
4
5
  * MongoDB storage interface for messages and interactions
@@ -16,21 +17,7 @@ class MongoStorage {
16
17
  }
17
18
 
18
19
  createSchemas() {
19
- const messageSchema = new mongoose.Schema({
20
- messageId: String,
21
- numero: String,
22
- body: String,
23
- timestamp: String,
24
- isGroup: { type: Boolean, default: false },
25
- isMedia: { type: Boolean, default: false },
26
- fromMe: { type: Boolean, default: false },
27
- contentSid: String,
28
- isTemplate: { type: Boolean, default: false },
29
- templateVariables: String,
30
- provider: String,
31
- createdAt: { type: Date, default: Date.now }
32
- });
33
-
20
+ const { Message } = require('../models/messageModel');
34
21
  const interactionSchema = new mongoose.Schema({
35
22
  messageId: String,
36
23
  numero: String,
@@ -54,7 +41,7 @@ class MongoStorage {
54
41
  });
55
42
 
56
43
  return {
57
- Message: mongoose.models.Message || mongoose.model('Message', messageSchema),
44
+ Message,
58
45
  Interaction: mongoose.models.Interaction || mongoose.model('Interaction', interactionSchema),
59
46
  Thread: mongoose.models.Thread || mongoose.model('Thread', threadSchema)
60
47
  };
@@ -74,28 +61,52 @@ class MongoStorage {
74
61
 
75
62
  async saveMessage(messageData) {
76
63
  try {
77
- const message = new this.schemas.Message({
78
- messageId: messageData.messageId,
79
- numero: messageData.to || messageData.from,
80
- body: messageData.message || messageData.body,
81
- timestamp: this.formatTimestamp(messageData.timestamp),
82
- isGroup: messageData.isGroup || false,
83
- isMedia: messageData.fileType && messageData.fileType !== 'text',
84
- fromMe: messageData.fromMe || false,
85
- contentSid: messageData.contentSid,
86
- isTemplate: !!messageData.contentSid,
87
- templateVariables: messageData.variables ? JSON.stringify(messageData.variables) : null,
88
- provider: messageData.provider
89
- });
90
-
91
- await message.save();
92
- return message;
64
+ const values = this.buildLegacyMessageValues(messageData);
65
+ const { insertMessage } = require('../models/messageModel');
66
+ await insertMessage(values);
67
+ return values;
93
68
  } catch (error) {
94
69
  console.error('Error saving message:', error);
95
70
  throw error;
96
71
  }
97
72
  }
98
73
 
74
+ buildLegacyMessageValues(messageData = {}) {
75
+ const numero = messageData.to || messageData.code || messageData.numero || messageData.from;
76
+ const normalizedNumero = typeof numero === 'string' ? numero : '';
77
+ const isGroup = normalizedNumero.includes('@g.us');
78
+ const isMedia = messageData.isMedia === true || (messageData.fileType && messageData.fileType !== 'text');
79
+ const now = new Date();
80
+ const timestamp = now.toISOString();
81
+ const nombre = messageData.nombre_whatsapp || messageData.author || messageData.fromName || runtimeConfig.get('USER_DB_MONGO') || process.env.USER_DB_MONGO || 'Nexus';
82
+ const textBody = messageData.message || messageData.body || (messageData.contentSid ? `[Template:${messageData.contentSid}]` : isMedia ? `[Media:${messageData.fileType || 'attachment'}]` : '');
83
+ const providerId = messageData.messageId || messageData.sid || messageData.id || messageData._id || `pending-${now.getTime()}-${Math.floor(Math.random()*1000)}`;
84
+
85
+ const media = messageData.media || (messageData.fileUrl ? {
86
+ url: messageData.fileUrl,
87
+ mediaType: messageData.fileType === 'text' ? null : (messageData.fileType || 'document'),
88
+ fileName: messageData.fileName || null,
89
+ contentType: messageData.contentType || null,
90
+ metadata: messageData.mediaMetadata || null
91
+ } : null);
92
+
93
+ return {
94
+ nombre_whatsapp: nombre,
95
+ numero: normalizedNumero,
96
+ body: textBody,
97
+ timestamp,
98
+ message_id: providerId,
99
+ is_group: isGroup,
100
+ is_media: isMedia,
101
+ group_id: isGroup ? normalizedNumero : null,
102
+ reply_id: messageData.reply_id || messageData.replyId || null,
103
+ from_me: messageData.fromMe !== undefined ? messageData.fromMe : true,
104
+ media,
105
+ content_sid: messageData.contentSid || null,
106
+ template_variables: messageData.variables ? JSON.stringify(messageData.variables) : null
107
+ };
108
+ }
109
+
99
110
  async saveInteractive(interactionData) {
100
111
  try {
101
112
  const interaction = new this.schemas.Interaction({
@@ -43,19 +43,21 @@ class Template {
43
43
  }
44
44
 
45
45
  addBodyVariation(text, variableDescriptions = []) {
46
+ const normalizedVariables = Template.normalizeVariables(variableDescriptions);
46
47
  this.variations.push({
47
48
  text: text,
48
- variables: variableDescriptions
49
+ variables: normalizedVariables
49
50
  });
50
51
  return this;
51
52
  }
52
53
 
53
54
  setBody(text, variableDescriptions = []) {
54
- this.addBodyVariation(text, variableDescriptions);
55
+ const normalizedVariables = Template.normalizeVariables(variableDescriptions);
56
+ this.addBodyVariation(text, normalizedVariables);
55
57
 
56
58
  const selectedVariation = this.variations.length > 1 ?
57
59
  this.variations[Math.floor(Math.random() * this.variations.length)] :
58
- { text, variables: variableDescriptions };
60
+ { text, variables: normalizedVariables };
59
61
 
60
62
  const enhancedText = `${selectedVariation.text}`;
61
63
 
@@ -196,6 +198,49 @@ class Template {
196
198
  const createdTemplate = await nexusProvider.createTemplate(twilioFormat);
197
199
  return createdTemplate;
198
200
  }
201
+
202
+ static normalizeVariables(variableDescriptions = []) {
203
+ if (!variableDescriptions) return [];
204
+
205
+ if (Array.isArray(variableDescriptions)) {
206
+ return variableDescriptions;
207
+ }
208
+
209
+ if (typeof variableDescriptions === 'object') {
210
+ return Object.entries(variableDescriptions)
211
+ .sort(([a], [b]) => {
212
+ const na = Number(a);
213
+ const nb = Number(b);
214
+ if (!Number.isNaN(na) && !Number.isNaN(nb)) {
215
+ return na - nb;
216
+ }
217
+ return a.localeCompare(b);
218
+ })
219
+ .map(([key, value], index) => {
220
+ if (typeof value === 'string') {
221
+ return {
222
+ name: `var_${key}`,
223
+ description: value,
224
+ example: `Ejemplo ${key}`
225
+ };
226
+ }
227
+ if (value && typeof value === 'object') {
228
+ return {
229
+ name: value.name || `var_${key}`,
230
+ description: value.description || `Variable ${key}`,
231
+ example: value.example || value.default || `Ejemplo ${key}`
232
+ };
233
+ }
234
+ return {
235
+ name: `var_${key}`,
236
+ description: `Variable ${key}`,
237
+ example: `Ejemplo ${key}`
238
+ };
239
+ });
240
+ }
241
+
242
+ return [];
243
+ }
199
244
  }
200
245
 
201
246
  module.exports = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "publishConfig": {
6
6
  "access": "public"