@peopl-health/nexus 1.1.4 → 1.1.6

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.
@@ -15,7 +15,7 @@ class BaileysProvider extends MessageProvider {
15
15
  async initialize() {
16
16
  try {
17
17
  const { default: makeWASocket, useMultiFileAuthState } = require('baileys');
18
- const { useMongoDBAuthState } = require('../utils/mongoAuthConfig');
18
+ const { useMongoDBAuthState } = require('../config/mongoAuthConfig');
19
19
  const pino = require('pino');
20
20
 
21
21
  const logger = pino({ level: 'warn' });
@@ -0,0 +1,103 @@
1
+ const AWS = require('aws-sdk');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+
5
+ AWS.config.update({
6
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID,
7
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
8
+ region: 'us-east-1',
9
+ signatureVersion: 'v4'
10
+ });
11
+
12
+ const s3 = new AWS.S3();
13
+
14
+
15
+ async function uploadBufferToS3(buffer, bucketName, key, contentType, isWhatsAppMedia = false) {
16
+ const params = {
17
+ Bucket: bucketName,
18
+ Key: key,
19
+ Body: buffer,
20
+ ContentType: contentType,
21
+ Metadata: isWhatsAppMedia ? {
22
+ 'x-amz-meta-whatsapp': 'true',
23
+ 'x-amz-meta-expiry': new Date(new Date().getTime() + (7 * 24 * 60 * 60 * 1000)).toISOString() // 7 days expiry
24
+ } : {}
25
+ };
26
+
27
+ try {
28
+ const data = await s3.upload(params).promise();
29
+ console.log(`File uploaded successfully at ${data.Location}`);
30
+ return data.Location;
31
+ } catch (error) {
32
+ console.error(`Error uploading file: ${error.message}`);
33
+ throw error;
34
+ }
35
+ }
36
+
37
+ async function downloadFileFromS3(bucketName, key, downloadPath) {
38
+ console.log(`[S3] Attempting to download file from S3 - Bucket: ${bucketName}, Key: ${key}`);
39
+
40
+ const headParams = {
41
+ Bucket: bucketName,
42
+ Key: key
43
+ };
44
+
45
+ try {
46
+ await s3.headObject(headParams).promise();
47
+ console.log(`[S3] Key exists in bucket: ${key}`);
48
+ } catch (headErr) {
49
+ console.error(`[S3] Object check failed - Key: ${key}, Error: ${headErr.code} - ${headErr.message}`);
50
+ if (headErr.code === 'NotFound') {
51
+ console.log(`[S3] Key does not exist in bucket: ${key}`);
52
+ }
53
+ throw headErr;
54
+ }
55
+
56
+ const params = {
57
+ Bucket: bucketName,
58
+ Key: key
59
+ };
60
+
61
+ try {
62
+ console.log(`[S3] Downloading file data - Key: ${key}`);
63
+ const data = await s3.getObject(params).promise();
64
+ console.log(`[S3] Successfully retrieved file - Key: ${key}, ContentType: ${data.ContentType}, Size: ${data.ContentLength} bytes`);
65
+
66
+ if (downloadPath) {
67
+ const directory = path.dirname(downloadPath);
68
+ fs.mkdirSync(directory, { recursive: true });
69
+ fs.writeFileSync(downloadPath, data.Body);
70
+ console.log(`[S3] File saved to disk at: ${downloadPath}`);
71
+ return;
72
+ }
73
+
74
+ return data;
75
+ } catch (error) {
76
+ console.error(`[S3] Error downloading file - Key: ${key}, Error: ${error.code} - ${error.message}`);
77
+ throw error;
78
+ }
79
+ }
80
+
81
+ async function generatePresignedUrl(bucketName, key, expiration = 300) {
82
+ const params = {
83
+ Bucket: bucketName,
84
+ Key: key,
85
+ Expires: expiration
86
+ };
87
+
88
+ try {
89
+ const url = s3.getSignedUrlPromise('getObject', params);
90
+ console.log(`Presigned URL generated: ${url}`);
91
+ return url;
92
+ } catch (error) {
93
+ console.error(`Error generating presigned URL: ${error.message}`);
94
+ throw error;
95
+ }
96
+ }
97
+
98
+ module.exports = {
99
+ s3,
100
+ uploadBufferToS3,
101
+ downloadFileFromS3,
102
+ generatePresignedUrl
103
+ };
@@ -0,0 +1,4 @@
1
+ // Temporary stub for llmConfig - should be replaced with Nexus LLM provider
2
+ module.exports = {
3
+ openaiClient: null // This will be replaced with Nexus LLM provider
4
+ };
@@ -1,30 +1,11 @@
1
- // Optional config - will be undefined if not available
2
- let Config_ID;
3
- try {
4
- Config_ID = require('../config/airtableConfig')?.Config_ID;
5
- } catch (e) {
6
- Config_ID = null;
7
- }
8
-
9
- // Optional model imports
10
- let updateThreadActive, updateThreadStop, Thread;
11
- try {
12
- const threadModel = require('../models/threadModel');
13
- updateThreadActive = threadModel.updateThreadActive;
14
- updateThreadStop = threadModel.updateThreadStop;
15
- Thread = threadModel.Thread;
16
- } catch (e) {
17
- // Models not available
18
- }
19
-
20
- // Optional service imports - stub functions for missing services
21
- const getRecordByFilter = () => Promise.resolve(null);
22
- const createAssistant = () => Promise.resolve({ success: false, error: 'Service not available' });
23
- const addMsgAssistant = () => Promise.resolve({ success: false, error: 'Service not available' });
24
- const addInsAssistant = () => Promise.resolve({ success: false, error: 'Service not available' });
25
- const getThreadInfo = () => Promise.resolve(null);
26
- const switchAssistant = () => Promise.resolve({ success: false, error: 'Service not available' });
27
- const sendMessage = () => Promise.resolve({ success: false, error: 'Service not available' });
1
+ const { Config_ID } = require('../config/airtableConfig');
2
+
3
+ const { updateThreadActive, updateThreadStop, Thread } = require('../models/threadModel');
4
+
5
+ const { getRecordByFilter } = require('../services/airtableService');
6
+ const { createAssistant, addMsgAssistant, addInsAssistant } = require('../services/assistantService');
7
+ const { getThreadInfo, switchAssistant } = require('../services/assistantService');
8
+ const { sendMessage } = require('../core/NexusMessaging');
28
9
 
29
10
 
30
11
  const activeAssistantController = async (req, res) => {
@@ -96,7 +77,7 @@ const createAssistantController = async (req, res) => {
96
77
  };
97
78
 
98
79
  const getInfoAssistantController = async (req, res) => {
99
- const { code } = req.query;
80
+ const { code } = req.body;
100
81
 
101
82
  try {
102
83
  let threadInfo = await getThreadInfo(code);
@@ -325,4 +325,18 @@ class NexusMessaging {
325
325
  }
326
326
  }
327
327
 
328
- module.exports = { NexusMessaging };
328
+ const defaultInstance = new NexusMessaging();
329
+
330
+ const sendMessage = async (messageData) => {
331
+ return await defaultInstance.sendMessage(messageData);
332
+ };
333
+
334
+ const sendScheduledMessage = async (scheduledMessage) => {
335
+ return await defaultInstance.sendScheduledMessage(scheduledMessage);
336
+ };
337
+
338
+ module.exports = {
339
+ NexusMessaging,
340
+ sendMessage,
341
+ sendScheduledMessage
342
+ };
@@ -0,0 +1,292 @@
1
+ const { downloadFileFromS3, generatePresignedUrl } = require('../config/awsConfig.js');
2
+ const { openaiClient } = require('../config/llmConfig.js');
3
+
4
+ const { LegacyMessage } = require('../models/messageModel.js');
5
+
6
+ const { convertPdfToImages } = require('./filesHelper.js');
7
+ const { analyzeImage } = require('../helpers/llmsHelper.js');
8
+
9
+ const { getRecordByFilter } = require('../services/airtableService.js');
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+
14
+ const mode = process.env.NODE_ENV || 'dev';
15
+
16
+
17
+ async function checkRunStatus(assistant, thread_id, run_id, retryCount = 0, maxRetries = 30) {
18
+ try {
19
+ const run = await openaiClient.beta.threads.runs.retrieve(thread_id, run_id);
20
+ console.log(`Status: ${run.status} ${thread_id} ${run_id} (attempt ${retryCount + 1})`);
21
+
22
+ if (run.status === 'failed' || run.status === 'expired' || run.status === 'incomplete') {
23
+ console.log(`Run failed. ${run.status} `);
24
+ console.log('Error:');
25
+ console.log(run);
26
+ return false;
27
+ } else if (run.status === 'cancelled') {
28
+ console.log('cancelled');
29
+ return true;
30
+ } else if (run.status === 'requires_action') {
31
+ console.log('requires_action');
32
+ if (retryCount < maxRetries) await assistant.handleRequiresAction(run);
33
+ await new Promise(resolve => setTimeout(resolve, 5000));
34
+ return checkRunStatus(assistant, thread_id, run_id, maxRetries, maxRetries);
35
+ } else if (run.status !== 'completed') {
36
+ await new Promise(resolve => setTimeout(resolve, 1000));
37
+ return checkRunStatus(assistant, thread_id, run_id);
38
+ } else {
39
+ console.log('Run completed.');
40
+ return true;
41
+ }
42
+ } catch (error) {
43
+ console.error('Error checking run status:', error);
44
+ return false;
45
+ }
46
+ }
47
+
48
+ async function checkIfFinished(text) {
49
+ try {
50
+ const completion = await openaiClient.chat.completions.create({
51
+ model: 'gpt-4o-mini',
52
+ messages: [
53
+ {
54
+ role: 'user',
55
+ content: `Dado el siguiente dialogo, determina si el paciente desea finalizar la conversación.
56
+ Considera que la conversación ha terminado si el paciente: 1)Agradece o se despide, usando expresiones como 'gracias', 'adiós', 'hasta luego', etc.
57
+ 2)Indica explícitamente que no tiene más síntomas o preguntas, o que no necesita más ayuda.
58
+ 3)Expresa que no desea continuar. O el asistente envia recomendaciones o se despide.
59
+ Responde solo con 'Si' si la conversación ha terminado, o 'No' si aún continúa: Texto: "${text}`
60
+ }
61
+ ]
62
+ });
63
+
64
+ return completion.choices[0].message.content;
65
+ } catch (error) {
66
+ console.error('Error checking run status:', error);
67
+ }
68
+ }
69
+
70
+ async function getLastMessages(code) {
71
+ try {
72
+ let query = { processed: false };
73
+ if (code.endsWith('@g.us')) {
74
+ query.group_id = code;
75
+ query.numero = { $nin: ['5215592261426@s.whatsapp.net', '5215547411345@s.whatsapp.net'] };
76
+ } else {
77
+ query.numero = code;
78
+ query.is_group = false;
79
+ }
80
+
81
+ const lastMessages = await LegacyMessage.find(query).sort({ timestamp: -1 });
82
+ console.log('[getLastMessages] lastMessages', lastMessages.map(msg => msg.body).join('\n\n'));
83
+
84
+ if (lastMessages.length === 0) return [];
85
+
86
+ let patientReply = [];
87
+ for (const message of lastMessages) {
88
+ patientReply.push(message);
89
+ await LegacyMessage.updateOne(
90
+ { message_id: message.message_id, timestamp: message.timestamp },
91
+ { $set: { processed: true } }
92
+ );
93
+ }
94
+
95
+ return patientReply.reverse();
96
+ } catch (error) {
97
+ console.error('Error getting the last user messages:', error);
98
+ return [];
99
+ }
100
+ }
101
+
102
+ async function getLastNMessages(code, n) {
103
+ try {
104
+ const lastMessages = await LegacyMessage.find({ numero: code })
105
+ .sort({ timestamp: -1 })
106
+ .limit(n);
107
+
108
+ // Format each message and concatenate them with skip lines
109
+ const formattedMessages = lastMessages
110
+ .reverse()
111
+ .map(message => `[${message.timestamp}] ${message.body}`)
112
+ .join('\n\n');
113
+
114
+ console.log('[getLastNMessages] Fetched last messages:', formattedMessages);
115
+
116
+ return formattedMessages;
117
+ } catch (error) {
118
+ console.error('Error retrieving the last user messages:', error);
119
+ return '';
120
+ }
121
+ }
122
+
123
+ const getPatientRoleAndName = (reply, numbers) => {
124
+ let role = 'familiar';
125
+ let name = reply.nombre_whatsapp;
126
+ for (const [key, value] of Object.entries(numbers)) {
127
+ console.log(key, value, reply.numero);
128
+ if (value[reply.numero]) {
129
+ role = key;
130
+ name = value[reply.numero];
131
+ break;
132
+ }
133
+ }
134
+ if (mode === 'prod') {
135
+ if (reply.from_me) {
136
+ role = 'asistente';
137
+ name = 'Pipo';
138
+ }
139
+ }
140
+ return { role, name };
141
+ };
142
+
143
+ function formatMessage(reply) {
144
+ try {
145
+ return `[${reply.timestamp}] ${reply.body}`;
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+
151
+ async function downloadMediaAndCreateFile(code, reply) {
152
+ const resultMedia = await LegacyMessage.findOne({
153
+ message_id: reply.message_id,
154
+ timestamp: reply.timestamp,
155
+ media: { $ne: null }
156
+ });
157
+
158
+ if (!resultMedia) return [];
159
+
160
+ if (!resultMedia.media || !resultMedia.media.key) {
161
+ console.log('[downloadMediaAndCreateFile] No valid media found for message:', reply.message_id);
162
+ return [];
163
+ }
164
+
165
+ const { bucketName, key } = resultMedia.media;
166
+ if (!bucketName || !key) return [];
167
+ const [subType, fileName] = key.split('/');
168
+ const sourceFile = `${code}-${subType}-${fileName}`;
169
+ const downloadPath = path.join(__dirname, 'assets', 'tmp', sourceFile);
170
+ console.log(bucketName, key);
171
+ await downloadFileFromS3(bucketName, key, downloadPath);
172
+
173
+ const fileNames = (subType === 'document' || subType === 'application')
174
+ ? await convertPdfToImages(sourceFile.split('.')[0])
175
+ : [downloadPath];
176
+
177
+ if (subType === 'document' || subType === 'application') {
178
+ await fs.promises.unlink(downloadPath);
179
+ }
180
+
181
+ console.log(fileNames);
182
+
183
+ return fileNames;
184
+ }
185
+
186
+ async function processMessage(code, reply, thread) {
187
+ try {
188
+ const formattedMessage = formatMessage(reply);
189
+ const isNotAssistant = !reply.from_me;
190
+ let messagesChat = [];
191
+ let attachments = [];
192
+ let url = null;
193
+
194
+ if (formattedMessage) {
195
+ messagesChat.push({ type: 'text', text: formattedMessage });
196
+ }
197
+
198
+ // Handle media if present
199
+ if (reply.is_media) {
200
+ console.log('IS MEDIA', reply.is_media);
201
+ const fileNames = await downloadMediaAndCreateFile(code, reply);
202
+ for (const fileName of fileNames) {
203
+ console.log(fileName);
204
+ // Skip WBMP images and stickers
205
+ if (fileName.toLowerCase().includes('.wbmp') || fileName.toLowerCase().includes('sticker')) {
206
+ console.log('Skipping WBMP image or sticker:', fileName);
207
+ continue;
208
+ }
209
+ if (fileName.includes('image') || fileName.includes('document') || fileName.includes('application')) {
210
+ const imageAnalysis = await analyzeImage(fileName);
211
+ console.log(imageAnalysis);
212
+ const invalidAnalysis = ['NOT_MEDICAL', 'QUALITY_INSUFFICIENT'];
213
+ url = await generatePresignedUrl(reply.media.bucketName, reply.media.key);
214
+ if (imageAnalysis.has_table) {
215
+ messagesChat.push({
216
+ type: 'text',
217
+ text: imageAnalysis.table_data,
218
+ });
219
+ } else if (!invalidAnalysis.some(tag => imageAnalysis.medical_analysis.includes(tag))) {
220
+ messagesChat.push({
221
+ type: 'text',
222
+ text: imageAnalysis.medical_analysis,
223
+ });
224
+ } else {
225
+ console.log('Add attachment');
226
+ const file = await openaiClient.files.create({
227
+ file: fs.createReadStream(fileName),
228
+ purpose: 'vision',
229
+ });
230
+ messagesChat.push({
231
+ type: 'image_file',
232
+ image_file: { file_id: file.id },
233
+ });
234
+ }
235
+ } else if (fileName.includes('audio')) {
236
+ const audioTranscript = await openaiClient.audio.transcriptions.create({
237
+ model: 'whisper-1',
238
+ file: fs.createReadStream(fileName),
239
+ response_format: 'text',
240
+ language: 'es'
241
+ });
242
+ console.log('Inside AUDIO', audioTranscript);
243
+ messagesChat.push({
244
+ type: 'text',
245
+ text: audioTranscript,
246
+ });
247
+ }
248
+ }
249
+ }
250
+
251
+ console.log('messagesChat', messagesChat);
252
+ console.log('attachments', attachments);
253
+
254
+ await openaiClient.beta.threads.messages.create(thread.thread_id, {
255
+ role: 'user',
256
+ content: messagesChat,
257
+ attachments: attachments
258
+ });
259
+
260
+ console.log('Formatted message:', formattedMessage);
261
+
262
+ await LegacyMessage.updateOne(
263
+ { message_id: reply.message_id, timestamp: reply.timestamp },
264
+ { $set: { assistant_id: thread.assistant_id, thread_id: thread.thread_id } }
265
+ );
266
+
267
+ return {isNotAssistant, url};
268
+ } catch (err) {
269
+ console.log(`Error inside process message ${err}`);
270
+ }
271
+ }
272
+
273
+ function getCurRow(baseID, code) {
274
+ if (code.endsWith('@g.us')) {
275
+ return getRecordByFilter(baseID, 'estado_general', `FIND("${code}", {Group ID})`);
276
+ } else {
277
+ console.log(code, `FIND("${code.split('@')[0]}", {whatsapp_id})`);
278
+ return getRecordByFilter(baseID, 'estado_general', `FIND("${code.split('@')[0]}", {whatsapp_id})`);
279
+ }
280
+ }
281
+
282
+ module.exports = {
283
+ checkRunStatus,
284
+ checkIfFinished,
285
+ getLastMessages,
286
+ getLastNMessages,
287
+ getPatientRoleAndName,
288
+ getCurRow,
289
+ formatMessage,
290
+ downloadMediaAndCreateFile,
291
+ processMessage
292
+ };
@@ -0,0 +1,149 @@
1
+ const { LegacyMessage, insertMessage, getMessageValues } = require('../models/messageModel.js');
2
+ const { uploadMediaToS3 } = require('./mediaHelper.js');
3
+ const { downloadMediaMessage } = require('baileys');
4
+
5
+
6
+ async function processMessage(message, messageType) {
7
+ try {
8
+ const { content, reply } = extractMessageContent(message, messageType);
9
+ if (content != null && content < 1) return;
10
+
11
+ const values = getMessageValues(message, content, reply, false);
12
+ await insertMessage(values);
13
+
14
+ return values;
15
+ } catch (error) {
16
+ console.log('Failed to process message: %s', error.stack);
17
+ throw error;
18
+ }
19
+ }
20
+
21
+ async function processMediaMessage(message, logger, messageType, bucketName, sock) {
22
+ try {
23
+ const { content, contentType, reply } = extractContentTypeAndReply(message, messageType);
24
+ let values = getMessageValues(message, content, reply, true);
25
+ // Insert message into the message database
26
+
27
+ const messageID = message.key.id;
28
+ const mediaBuffer = await downloadMedia(message, logger, sock);
29
+ let titleFile = await extractMessageTitle(message, messageType);
30
+ titleFile = titleFile?.trim().replace(/\s+/g, '') || '';
31
+ const key = await uploadMediaToS3(mediaBuffer, messageID, titleFile, bucketName, contentType, messageType);
32
+ const valuesMedia = {contentType: contentType,
33
+ bucketName: bucketName,
34
+ key: key};
35
+ values['media'] = valuesMedia;
36
+
37
+ await insertMessage(values);
38
+
39
+ return values;
40
+ } catch (error) {
41
+ console.log('Failed to process message: %s', error.stack);
42
+ throw error;
43
+ }
44
+ }
45
+
46
+ function extractMessageContent(message, messageType) {
47
+ let content = null;
48
+ let reply = null;
49
+ switch (messageType) {
50
+ case 'conversation':
51
+ content = message.message.conversation;
52
+ break;
53
+ case 'extendedTextMessage':
54
+ content = message.message.extendedTextMessage.text;
55
+ if (message.message.extendedTextMessage.contextInfo != null) {
56
+ reply = message.message.extendedTextMessage.contextInfo.stanzaId;
57
+ }
58
+ break;
59
+ }
60
+ return { content, reply };
61
+ }
62
+
63
+ function extractMessageTitle(message, messageType) {
64
+ switch (messageType) {
65
+ case 'documentMessage':
66
+ return message.message.documentMessage.title;
67
+ case 'documentWithCaptionMessage':
68
+ return message.message.documentWithCaptionMessage.message.documentMessage.title;
69
+ default:
70
+ return null;
71
+ }
72
+ }
73
+
74
+ function extractContentTypeAndReply(message, messageType) {
75
+ let submessage = null;
76
+ switch (messageType) {
77
+ case 'imageMessage':
78
+ submessage = message.message.imageMessage;
79
+ break;
80
+ case 'audioMessage':
81
+ submessage = message.message.audioMessage;
82
+ break;
83
+ case 'videoMessage':
84
+ submessage = message.message.videoMessage;
85
+ break;
86
+ case 'documentMessage':
87
+ submessage = message.message.documentMessage;
88
+ break;
89
+ case 'documentWithCaptionMessage':
90
+ submessage = message.message.documentWithCaptionMessage.message.documentMessage;
91
+ break;
92
+ default:
93
+ break;
94
+ }
95
+
96
+ const content = submessage.caption;
97
+ const contentType = submessage.mimetype;
98
+ const reply = submessage.contextInfo ? submessage.contextInfo.stanzaId : null;
99
+
100
+ return { content, contentType, reply };
101
+ }
102
+
103
+ async function isRecentMessage(chatId) {
104
+ const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
105
+
106
+ const recentMessage = await LegacyMessage.find({
107
+ $or: [{ group_id: chatId }, { numero: chatId }],
108
+ timestamp: { $gte: fiveMinutesAgo.toISOString() }
109
+ }).sort({ timestamp: -1 }).limit(1);
110
+
111
+ return !!recentMessage;
112
+ }
113
+
114
+ async function getLastMessages(chatId, n) {
115
+ const messages = await LegacyMessage.find({ group_id: chatId })
116
+ .sort({ timestamp: -1 })
117
+ .limit(n)
118
+ .select('timestamp numero nombre_whatsapp body');
119
+
120
+ return messages.map(msg => `[${msg.timestamp}] ${msg.numero} ${msg.nombre_whatsapp}: ${msg.body}`);
121
+ }
122
+
123
+ async function downloadMedia(message, logger, sock) {
124
+ try {
125
+ const buffer = await downloadMediaMessage(
126
+ message,
127
+ 'buffer',
128
+ {},
129
+ {
130
+ logger,
131
+ reuploadRequest: sock.updateMediaMessage,
132
+ }
133
+ );
134
+ return buffer;
135
+ } catch (error) {
136
+ console.log('Failed to download media:', error.stack);
137
+ throw error;
138
+ }
139
+ }
140
+
141
+
142
+ module.exports = {
143
+ extractMessageContent,
144
+ processMessage,
145
+ processMediaMessage,
146
+ isRecentMessage,
147
+ getLastMessages,
148
+ downloadMedia
149
+ };