@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.
- package/lib/adapters/BaileysProvider.js +1 -1
- package/lib/config/awsConfig.js +103 -0
- package/lib/config/llmConfig.js +4 -0
- package/lib/controllers/assistantController.js +9 -28
- package/lib/controllers/conversationController.js +13 -2
- package/lib/controllers/templateController.js +69 -34
- package/lib/controllers/templateFlowController.js +108 -0
- package/lib/controllers/uploadController.js +82 -0
- package/lib/core/NexusMessaging.js +15 -1
- package/lib/helpers/assistantHelper.js +292 -0
- package/lib/helpers/baileysHelper.js +149 -0
- package/lib/helpers/filesHelper.js +134 -0
- package/lib/helpers/llmsHelper.js +202 -0
- package/lib/helpers/mediaHelper.js +72 -0
- package/lib/helpers/mongoHelper.js +45 -0
- package/lib/helpers/qrHelper.js +22 -0
- package/lib/helpers/twilioHelper.js +138 -0
- package/lib/helpers/whatsappHelper.js +75 -0
- package/lib/models/templateModel.js +72 -0
- package/lib/routes/index.js +5 -3
- package/lib/services/assistantService.js +42 -46
- package/lib/templates/predefinedTemplates.js +81 -0
- package/lib/templates/templateStructure.js +204 -0
- package/lib/utils/errorHandler.js +8 -0
- package/package.json +5 -1
- package/lib/services/whatsappService.js +0 -23
|
@@ -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;
|
package/lib/routes/index.js
CHANGED
|
@@ -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:
|
|
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:
|
|
112
|
-
deleteFlow:
|
|
113
|
+
createFlow: templateFlowController.createFlow,
|
|
114
|
+
deleteFlow: templateFlowController.deleteFlow,
|
|
113
115
|
submitForApproval: templateController.submitForApproval,
|
|
114
116
|
checkApprovalStatus: templateController.checkApprovalStatus,
|
|
115
117
|
deleteTemplate: templateController.deleteTemplate
|