@peopl-health/nexus 1.1.5 → 1.1.7

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.
@@ -0,0 +1,202 @@
1
+ const { anthropicClient } = require('../config/llmConfig.js');
2
+ const fs = require('fs');
3
+ const mime = require('mime-types');
4
+
5
+
6
+ async function analyzeImage(imagePath) {
7
+ try {
8
+ // Skip WBMP images and stickers
9
+ if (imagePath.toLowerCase().includes('.wbmp') || imagePath.toLowerCase().includes('sticker')) {
10
+ console.log('Skipping WBMP image or sticker analysis:', imagePath);
11
+ return {
12
+ medical_analysis: 'NOT_MEDICAL',
13
+ medical_relevance: false,
14
+ has_table: false,
15
+ table_data: null
16
+ };
17
+ }
18
+
19
+ // Check MIME type
20
+ const mimeType = mime.lookup(imagePath) || 'image/jpeg';
21
+ if (mimeType === 'image/vnd.wap.wbmp' || mimeType.includes('sticker')) {
22
+ console.log('Skipping image with MIME type:', mimeType);
23
+ return {
24
+ medical_analysis: 'NOT_MEDICAL',
25
+ medical_relevance: false,
26
+ has_table: false,
27
+ table_data: null
28
+ };
29
+ }
30
+ // Read the image file and convert to base64
31
+ const imageBuffer = await fs.promises.readFile(imagePath);
32
+ const base64Image = imageBuffer.toString('base64');
33
+
34
+ // Create a more specific prompt for table detection and extraction
35
+ const tablePrompt = `Please analyze this image and respond in the following format:
36
+ 1. First, determine if there is a table in the image.
37
+ 2. If there is NO table, respond with exactly "NONE"
38
+ 3. If there IS a table:
39
+ - Extract all content from the table
40
+ - Format it as a markdown table
41
+ - Include all headers and data
42
+ - Preserve the exact text and numbers as shown
43
+ - Maintain the original column order
44
+ - Pay special attention to how values align vertically with test names in the original document
45
+ - Double-check each value's vertical alignment with its corresponding test name before entering it in the table
46
+ - Include all units and reference ranges exactly as written
47
+ - Double-check each value-to-test pairing before including it
48
+ - Do not summarize or modify the data
49
+
50
+ Only extract tables - ignore any other content in the image.`;
51
+
52
+ // Create the message with the image
53
+ const messageTable = await anthropicClient.messages.create({
54
+ model: 'claude-3-7-sonnet-20250219',
55
+ max_tokens: 1024,
56
+ messages: [
57
+ {
58
+ role: 'user',
59
+ content: [
60
+ {
61
+ type: 'image',
62
+ source: {
63
+ type: 'base64',
64
+ media_type: mime.lookup(imagePath) || 'image/jpeg',
65
+ data: base64Image,
66
+ },
67
+ },
68
+ {
69
+ type: 'text',
70
+ text: tablePrompt,
71
+ },
72
+ ],
73
+ },
74
+ ],
75
+ });
76
+
77
+ // Create a more specific prompt for table detection and extraction
78
+ const medImagePrompt = `
79
+ Eres un oncólogo clínico con experiencia. Se te proporcionará una imagen médica o laboratorio. Analízala y responde exactamente en este formato:
80
+
81
+ 1. Image Type:
82
+ - Especifica tipo de imagen médico (ej. X‑ray, CT, MRI, ecografía, biopsia digital, resultados de laboratorio, etc.) o "NOT_MEDICAL" si no es médica.
83
+ - Calidad de la imagen: clear / unclear / incomplete.
84
+
85
+ 2. Clinical Analysis:
86
+ - Hallazgos clave y anomalías.
87
+ - Estructuras normales significativas presentes.
88
+ - Medidas críticas (diámetros, densidades, volúmenes), si aplican.
89
+
90
+ 3. Action Items:
91
+ - Pruebas o vistas adicionales recomendadas.
92
+ - Limitaciones clave de la evaluación actual.
93
+
94
+ Si la imagen no permite ningún análisis útil, responde solo con: QUALITY_INSUFFICIENT.
95
+
96
+ ---
97
+
98
+ <OUTPUT_FORMAT>
99
+ 1. Image Type:
100
+ - ...
101
+ 2. Clinical Analysis:
102
+ - ...
103
+ 3. Action Items:
104
+ - ...
105
+ </OUTPUT_FORMAT>
106
+
107
+ <EJEMPLOS>
108
+ Ejemplo 1:
109
+ (Descripción breve si compartieras una RX de tórax)
110
+ 1. Image Type:
111
+ - X‑ray de tórax PA, clear
112
+ 2. Clinical Analysis:
113
+ - Líneas pleurales visibles, silueta cardíaca normal…
114
+ 3. Action Items:
115
+ - Sugiero proyección lateral para valorar consolidación…
116
+ </EJEMPLOS>
117
+ `;
118
+
119
+ // Create the message with the image
120
+ const messageMedImage = await anthropicClient.messages.create({
121
+ model: 'claude-3-7-sonnet-20250219',
122
+ max_tokens: 1024,
123
+ messages: [
124
+ {
125
+ role: 'user',
126
+ content: [
127
+ {
128
+ type: 'image',
129
+ source: {
130
+ type: 'base64',
131
+ media_type: mime.lookup(imagePath) || 'image/jpeg',
132
+ data: base64Image,
133
+ },
134
+ },
135
+ {
136
+ type: 'text',
137
+ text: medImagePrompt,
138
+ },
139
+ ],
140
+ },
141
+ ],
142
+ });
143
+
144
+ const relevancePrompt = `Please analyze this image and respond in this format:
145
+ Medical Relevance: [YES/NO]`;
146
+
147
+ // Create the message with the image
148
+ const messageRelevance = await anthropicClient.messages.create({
149
+ model: 'claude-3-7-sonnet-20250219',
150
+ max_tokens: 1024,
151
+ messages: [
152
+ {
153
+ role: 'user',
154
+ content: [
155
+ {
156
+ type: 'image',
157
+ source: {
158
+ type: 'base64',
159
+ media_type: mime.lookup(imagePath) || 'image/jpeg',
160
+ data: base64Image,
161
+ },
162
+ },
163
+ {
164
+ type: 'text',
165
+ text: relevancePrompt,
166
+ },
167
+ ],
168
+ },
169
+ ],
170
+ });
171
+
172
+ const messageTableStr = messageTable.content[0].text;
173
+ const messageRelevanceStr = messageRelevance.content[0].text;
174
+ const messageAnalysisStr = messageMedImage.content[0].text;
175
+ const table = (messageTableStr.trim() === 'NONE') ? null : messageTableStr.trim();
176
+ const isTable = (table === null) ? false : true;
177
+ const isRelevant = (messageRelevanceStr.includes('YES')) ? true : false;
178
+
179
+ return {medical_analysis: messageAnalysisStr, medical_relevance: isRelevant,
180
+ has_table: isTable, table_data: table};
181
+ } catch (error) {
182
+ console.error('Error analyzing image:', error);
183
+ throw error;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Helper to build prompts by replacing context data in template files
189
+ * @param {Object} conversationContext - Context object to inject into prompt
190
+ * @param {string} promptPath - Path to the prompt template file
191
+ * @returns {string} Formatted prompt with context data injected
192
+ */
193
+ const buildPrompt = (conversationContext, promptPath) => {
194
+ const promptTemplate = fs.readFileSync(promptPath, 'utf8');
195
+
196
+ return promptTemplate.replace('{{CONVERSATION_DATA}}', JSON.stringify(conversationContext, null, 2));
197
+ };
198
+
199
+ module.exports = {
200
+ analyzeImage,
201
+ buildPrompt
202
+ };
@@ -0,0 +1,72 @@
1
+ const AWS = require('../config/awsConfig.js');
2
+ const path = require('path');
3
+ const fs = require('fs');
4
+
5
+ async function uploadMediaToS3(buffer, messageID, titleFile, bucketName, contentType, messageType) {
6
+ const extension = getFileExtension(contentType);
7
+ const sanitizedTitle = titleFile ? sanitizeFileName(titleFile) : '';
8
+ const fileName = sanitizedTitle
9
+ ? `${messageType}/${messageID}_${sanitizedTitle}.${extension}`
10
+ : `${messageType}/${messageID}.${extension}`;
11
+ console.log(titleFile, messageType);
12
+
13
+ try {
14
+ await AWS.uploadBufferToS3(buffer, bucketName, fileName, contentType);
15
+ return fileName;
16
+ } catch (error) {
17
+ console.error('Failed to upload media to S3:', error.stack);
18
+ throw error;
19
+ }
20
+ }
21
+
22
+ async function saveMediaToLocal(mediaBuffer, messageId, contentType) {
23
+ const uploadDir = path.join(__dirname, '../uploads');
24
+
25
+ if (!fs.existsSync(uploadDir)) {
26
+ fs.mkdirSync(uploadDir, { recursive: true });
27
+ }
28
+
29
+ const fileExt = getFileExtension(contentType);
30
+ const filePath = path.join(uploadDir, `${messageId}.${fileExt}`);
31
+
32
+ return new Promise((resolve, reject) => {
33
+ fs.writeFile(filePath, mediaBuffer, (err) => {
34
+ if (err) reject(err);
35
+ else resolve(filePath);
36
+ });
37
+ });
38
+ }
39
+
40
+ function getFileExtension(contentType) {
41
+ const mimeToExt = {
42
+ 'image/jpeg': 'jpg',
43
+ 'image/png': 'png',
44
+ 'image/gif': 'gif',
45
+ 'image/webp': 'webp',
46
+ 'audio/mp4': 'm4a',
47
+ 'audio/mpeg': 'mp3',
48
+ 'audio/ogg': 'ogg',
49
+ 'video/mp4': 'mp4',
50
+ 'video/3gpp': '3gp',
51
+ 'application/pdf': 'pdf',
52
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
53
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
54
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
55
+ 'text/plain': 'txt'
56
+ };
57
+
58
+ return mimeToExt[contentType] || 'bin';
59
+ }
60
+
61
+ function sanitizeFileName(fileName) {
62
+ return fileName
63
+ .replace(/[^a-zA-Z0-9_-]/g, '')
64
+ .slice(0, 50);
65
+ }
66
+
67
+ module.exports = {
68
+ uploadMediaToS3,
69
+ saveMediaToLocal,
70
+ getFileExtension,
71
+ sanitizeFileName
72
+ };
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Helper to convert date strings to Date objects in MongoDB filters
3
+ * @param {Object} filter - Filter that may contain date strings
4
+ * @returns {Object} Filter with dates converted to Date objects
5
+ */
6
+ const convertDateStringsInFilter = (filter) => {
7
+ if (!filter || typeof filter !== 'object') {
8
+ return filter;
9
+ }
10
+
11
+ const convertedFilter = { ...filter };
12
+
13
+ // Recursive function to convert dates
14
+ const convertDates = (obj) => {
15
+ for (const key in obj) {
16
+ if (typeof obj[key] === 'object' && obj[key] !== null) {
17
+ if (obj[key].$gte || obj[key].$gt || obj[key].$lte || obj[key].$lt) {
18
+ // Convert date comparison operators
19
+ if (obj[key].$gte && typeof obj[key].$gte === 'string') {
20
+ obj[key].$gte = new Date(obj[key].$gte);
21
+ }
22
+ if (obj[key].$gt && typeof obj[key].$gt === 'string') {
23
+ obj[key].$gt = new Date(obj[key].$gt);
24
+ }
25
+ if (obj[key].$lte && typeof obj[key].$lte === 'string') {
26
+ obj[key].$lte = new Date(obj[key].$lte);
27
+ }
28
+ if (obj[key].$lt && typeof obj[key].$lt === 'string') {
29
+ obj[key].$lt = new Date(obj[key].$lt);
30
+ }
31
+ } else {
32
+ // Recursion for nested objects
33
+ convertDates(obj[key]);
34
+ }
35
+ }
36
+ }
37
+ };
38
+
39
+ convertDates(convertedFilter);
40
+ return convertedFilter;
41
+ };
42
+
43
+ module.exports = {
44
+ convertDateStringsInFilter
45
+ };
@@ -0,0 +1,22 @@
1
+ const qrcode = require('qrcode');
2
+
3
+
4
+ async function generateQRBuffer(text) {
5
+ try {
6
+ const qrBuffer = await qrcode.toBuffer(text, { type: 'image/png' });
7
+ return qrBuffer;
8
+ } catch (err) {
9
+ console.error('Error generating QR code:', err.stack);
10
+ throw err;
11
+ }
12
+ }
13
+
14
+ function bufferToBase64(buffer) {
15
+ return buffer.toString('base64');
16
+ }
17
+
18
+
19
+ module.exports = {
20
+ generateQRBuffer,
21
+ bufferToBase64
22
+ };
@@ -0,0 +1,138 @@
1
+ const { LegacyMessage } = require('../models/messageModel');
2
+
3
+ const axios = require('axios');
4
+ const { v4: uuidv4 } = require('uuid');
5
+
6
+
7
+ function convertTwilioToInternalFormat(twilioMessage) {
8
+ const from = twilioMessage.From || '';
9
+ const to = twilioMessage.To || '';
10
+ const fromMe = to === from;
11
+
12
+ return {
13
+ key: {
14
+ id: twilioMessage.MessageSid || uuidv4(),
15
+ fromMe: fromMe,
16
+ remoteJid: fromMe ? to : from
17
+ },
18
+ pushName: twilioMessage.ProfileName || '',
19
+ message: {
20
+ conversation: twilioMessage.Body || ''
21
+ },
22
+ messageTimestamp: Math.floor(Date.now() / 1000)
23
+ };
24
+ }
25
+
26
+
27
+ async function downloadMediaFromTwilio(mediaUrl, logger) {
28
+ try {
29
+ logger.info(`Downloading media from: ${mediaUrl}`);
30
+
31
+ const response = await axios({
32
+ method: 'GET',
33
+ url: mediaUrl,
34
+ responseType: 'arraybuffer',
35
+ headers: {
36
+ 'Authorization': `Basic ${Buffer.from(
37
+ `${process.env.TWILIO_ACCOUNT_SID}:${process.env.TWILIO_AUTH_TOKEN}`
38
+ ).toString('base64')}`
39
+ }
40
+ });
41
+
42
+ return Buffer.from(response.data);
43
+ } catch (error) {
44
+ logger.error(`Failed to download media: ${error.message}`);
45
+ throw error;
46
+ }
47
+ }
48
+
49
+
50
+ function getMediaTypeFromContentType(contentType) {
51
+ if (contentType.startsWith('image/')) return 'imageMessage';
52
+ if (contentType.startsWith('audio/')) return 'audioMessage';
53
+ if (contentType.startsWith('video/')) return 'videoMessage';
54
+ if (contentType.startsWith('application/')) return 'documentMessage';
55
+ return 'unknownMessage';
56
+ }
57
+
58
+
59
+ function extractTitle(message, mediaType) {
60
+ if (mediaType === 'documentMessage' && message.MediaUrl0) {
61
+ const urlParts = message.MediaUrl0.split('/');
62
+ return urlParts[urlParts.length - 1] || null;
63
+ }
64
+ return null;
65
+ }
66
+
67
+
68
+ async function isRecentMessage(chatId) {
69
+ const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
70
+
71
+ const recentMessage = await LegacyMessage.find({
72
+ $or: [{ group_id: chatId }, { numero: chatId }],
73
+ timestamp: { $gte: fiveMinutesAgo.toISOString() }
74
+ }).sort({ timestamp: -1 }).limit(1);
75
+
76
+ return !!recentMessage;
77
+ }
78
+
79
+
80
+ async function getLastMessages(chatId, n) {
81
+ const messages = await LegacyMessage.find({ numero: chatId })
82
+ .sort({ timestamp: -1 })
83
+ .limit(n)
84
+ .select('timestamp numero nombre_whatsapp body');
85
+
86
+ return messages.map(msg => `[${msg.timestamp}] ${msg.body}`);
87
+ }
88
+
89
+
90
+ async function downloadMedia(twilioMessage, logger) {
91
+ try {
92
+ const mediaUrl = twilioMessage.MediaUrl0;
93
+ if (!mediaUrl) {
94
+ throw new Error('No media URL provided');
95
+ }
96
+
97
+ logger.info(`Downloading media from: ${mediaUrl}`);
98
+
99
+ const response = await axios({
100
+ method: 'GET',
101
+ url: mediaUrl,
102
+ responseType: 'arraybuffer',
103
+ headers: {
104
+ 'Authorization': `Basic ${Buffer.from(
105
+ `${process.env.TWILIO_ACCOUNT_SID}:${process.env.TWILIO_AUTH_TOKEN}`
106
+ ).toString('base64')}`
107
+ }
108
+ });
109
+
110
+ return Buffer.from(response.data);
111
+ } catch (error) {
112
+ logger.error(`Error downloading media: ${error.message}`);
113
+ throw error;
114
+ }
115
+ }
116
+
117
+
118
+ const ensureWhatsAppFormat = (phoneNumber) => {
119
+ if (!phoneNumber) return null;
120
+
121
+ if (phoneNumber.startsWith('whatsapp:')) {
122
+ return phoneNumber;
123
+ }
124
+
125
+ return `whatsapp:${phoneNumber}`;
126
+ };
127
+
128
+
129
+ module.exports = {
130
+ convertTwilioToInternalFormat,
131
+ downloadMediaFromTwilio,
132
+ getMediaTypeFromContentType,
133
+ extractTitle,
134
+ isRecentMessage,
135
+ getLastMessages,
136
+ downloadMedia,
137
+ ensureWhatsAppFormat
138
+ };
@@ -0,0 +1,75 @@
1
+ const moment = require('moment-timezone');
2
+
3
+
4
+ function delay(ms) {
5
+ return new Promise(resolve => setTimeout(resolve, ms));
6
+ }
7
+
8
+
9
+ function formatCode(codeBase) {
10
+ console.log(`formatCode ${codeBase}`);
11
+
12
+ const [number, domain] = codeBase.split('@');
13
+
14
+ if (!number || !domain) {
15
+ throw new Error('Invalid code format: missing number or domain');
16
+ }
17
+
18
+ if (domain !== 's.whatsapp.net') {
19
+ throw new Error('Invalid domain: must be s.whatsapp.net');
20
+ }
21
+
22
+ let formattedNumber = number;
23
+
24
+ if (formattedNumber.endsWith('-2')) {
25
+ formattedNumber = formattedNumber.slice(0, -2);
26
+ }
27
+
28
+ if (formattedNumber.length === 10) {
29
+ formattedNumber = '52' + formattedNumber;
30
+ }
31
+
32
+ if (formattedNumber.startsWith('52') && formattedNumber.length === 12) {
33
+ formattedNumber = formattedNumber.substring(0, 2) + '1' + formattedNumber.substring(2);
34
+ }
35
+ return `${formattedNumber}@s.whatsapp.net`;
36
+ }
37
+
38
+
39
+ function calculateDelay(sendTime, timeZone) {
40
+ if (sendTime !== undefined && timeZone !== undefined) {
41
+ const sendMoment = moment.tz(sendTime, timeZone);
42
+
43
+ if (!sendMoment.isValid()) {
44
+ return { error: 'Invalid time format' };
45
+ }
46
+
47
+ // Get the current time and calculate the difference
48
+ const now = moment().tz(timeZone);
49
+ const randomDelay = Math.floor(Math.random() * 15001) + 15000;
50
+ const delay = sendMoment.diff(now) + randomDelay;
51
+
52
+ // Log the calculated details for debugging
53
+ console.log(
54
+ 'Scheduled Time:', sendMoment.format(),
55
+ 'Current Time:', now.format(),
56
+ 'Delay (minutes):', delay / 60000,
57
+ 'Remaining Seconds:', delay % 60000
58
+ );
59
+
60
+ if (delay <= 0) {
61
+ return 2500;
62
+ }
63
+
64
+ return delay;
65
+ } else {
66
+ return 2500;
67
+ }
68
+ }
69
+
70
+
71
+ module.exports = {
72
+ delay,
73
+ formatCode,
74
+ calculateDelay
75
+ };
@@ -0,0 +1,72 @@
1
+ const mongoose = require('mongoose');
2
+
3
+ const TemplateSchema = new mongoose.Schema({
4
+ sid: {
5
+ type: String,
6
+ required: true,
7
+ unique: true
8
+ },
9
+ name: {
10
+ type: String,
11
+ required: true
12
+ },
13
+ friendlyName: String,
14
+ category: {
15
+ type: String,
16
+ default: 'UTILITY',
17
+ enum: ['UTILITY', 'MARKETING', 'AUTHENTICATION']
18
+ },
19
+ language: {
20
+ type: String,
21
+ default: 'es'
22
+ },
23
+ status: {
24
+ type: String,
25
+ default: 'DRAFT',
26
+ enum: ['DRAFT', 'PENDING', 'APPROVED', 'REJECTED', 'UNKNOWN']
27
+ },
28
+ body: String,
29
+ footer: String,
30
+ variables: [
31
+ {
32
+ name: String,
33
+ description: String,
34
+ example: String
35
+ }
36
+ ],
37
+ buttons: [
38
+ {
39
+ type: {
40
+ type: String,
41
+ enum: ['quick_reply', 'url']
42
+ },
43
+ text: String,
44
+ url: String
45
+ }
46
+ ],
47
+ approvalRequest: {
48
+ sid: String,
49
+ status: String,
50
+ dateSubmitted: Date,
51
+ dateUpdated: Date,
52
+ rejectionReason: String
53
+ },
54
+ dateCreated: {
55
+ type: Date,
56
+ default: Date.now
57
+ },
58
+ lastUpdated: {
59
+ type: Date,
60
+ default: Date.now
61
+ }
62
+ });
63
+
64
+ // Pre-save middleware to update lastUpdated
65
+ TemplateSchema.pre('save', function(next) {
66
+ this.lastUpdated = new Date();
67
+ next();
68
+ });
69
+
70
+ const Template = mongoose.model('Template', TemplateSchema);
71
+
72
+ module.exports = Template;
@@ -70,6 +70,8 @@ const conversationController = require('../controllers/conversationController');
70
70
  const mediaController = require('../controllers/mediaController');
71
71
  const messageController = require('../controllers/messageController');
72
72
  const templateController = require('../controllers/templateController');
73
+ const templateFlowController = require('../controllers/templateFlowController');
74
+ const uploadController = require('../controllers/uploadController');
73
75
 
74
76
  // Built-in controllers mapping
75
77
  const builtInControllers = {
@@ -95,7 +97,7 @@ const builtInControllers = {
95
97
 
96
98
  // Media controllers
97
99
  getMediaController: mediaController.getMediaController,
98
- handleFileUpload: mediaController.handleFileUpload,
100
+ handleFileUpload: uploadController.handleFileUpload,
99
101
 
100
102
  // Message controllers
101
103
  sendMessageController: messageController.sendMessageController,
@@ -108,8 +110,8 @@ const builtInControllers = {
108
110
  getPredefinedTemplates: templateController.getPredefinedTemplates,
109
111
  getTemplate: templateController.getTemplate,
110
112
  getCompleteTemplate: templateController.getCompleteTemplate,
111
- createFlow: templateController.createFlow,
112
- deleteFlow: templateController.deleteFlow,
113
+ createFlow: templateFlowController.createFlow,
114
+ deleteFlow: templateFlowController.deleteFlow,
113
115
  submitForApproval: templateController.submitForApproval,
114
116
  checkApprovalStatus: templateController.checkApprovalStatus,
115
117
  deleteTemplate: templateController.deleteTemplate