@peopl-health/nexus 2.2.9 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/adapters/TwilioProvider.js +1 -7
- package/lib/helpers/assistantHelper.js +92 -269
- package/lib/helpers/baileysHelper.js +1 -11
- package/lib/helpers/filesHelper.js +75 -32
- package/lib/helpers/mediaHelper.js +4 -10
- package/lib/helpers/messageHelper.js +136 -0
- package/lib/helpers/processHelper.js +238 -0
- package/lib/helpers/threadHelper.js +73 -0
- package/lib/helpers/twilioHelper.js +2 -14
- package/lib/models/messageModel.js +0 -1
- package/lib/observability/index.js +184 -0
- package/lib/observability/telemetry.js +118 -0
- package/lib/providers/OpenAIResponsesProvider.js +0 -3
- package/lib/services/assistantService.js +106 -202
- package/lib/storage/MongoStorage.js +0 -2
- package/lib/utils/logger.js +91 -4
- package/lib/utils/sanitizer.js +62 -0
- package/lib/utils/tracingDecorator.js +48 -0
- package/package.json +13 -1
|
@@ -108,12 +108,6 @@ class TwilioProvider extends MessageProvider {
|
|
|
108
108
|
const result = await this.twilioClient.messages.create(messageParams);
|
|
109
109
|
if (this.messageStorage && typeof this.messageStorage.saveMessage === 'function') {
|
|
110
110
|
try {
|
|
111
|
-
console.log('[TwilioProvider] Persisting outbound message', {
|
|
112
|
-
code: formattedCode,
|
|
113
|
-
from: formattedFrom,
|
|
114
|
-
hasMedia: Boolean(messageParams.mediaUrl && messageParams.mediaUrl.length),
|
|
115
|
-
hasTemplate: Boolean(messageParams.contentSid)
|
|
116
|
-
});
|
|
117
111
|
await this.messageStorage.saveMessage({
|
|
118
112
|
...messageData,
|
|
119
113
|
code: formattedCode,
|
|
@@ -122,7 +116,7 @@ class TwilioProvider extends MessageProvider {
|
|
|
122
116
|
provider: 'twilio',
|
|
123
117
|
timestamp: new Date(),
|
|
124
118
|
fromMe: true,
|
|
125
|
-
processed:
|
|
119
|
+
processed: false
|
|
126
120
|
});
|
|
127
121
|
console.log('[TwilioProvider] Message persisted successfully', { messageId: result.sid });
|
|
128
122
|
} catch (storageError) {
|
|
@@ -1,18 +1,12 @@
|
|
|
1
|
-
const { downloadFileFromS3, generatePresignedUrl } = require('../config/awsConfig.js');
|
|
2
1
|
const llmConfig = require('../config/llmConfig.js');
|
|
3
2
|
|
|
4
|
-
const {
|
|
5
|
-
|
|
6
|
-
const {
|
|
7
|
-
const { analyzeImage } = require('../helpers/llmsHelper.js');
|
|
3
|
+
const { Thread } = require('../models/threadModel.js');
|
|
4
|
+
const { createProvider } = require('../providers/createProvider.js');
|
|
5
|
+
const { withTracing } = require('../utils/tracingDecorator');
|
|
8
6
|
|
|
9
7
|
const { getRecordByFilter } = require('../services/airtableService.js');
|
|
10
8
|
|
|
11
|
-
const
|
|
12
|
-
const path = require('path');
|
|
13
|
-
const moment = require('moment-timezone');
|
|
14
|
-
|
|
15
|
-
const mode = process.env.NODE_ENV || 'dev';
|
|
9
|
+
const DEFAULT_MAX_RETRIES = parseInt(process.env.MAX_RETRIES || '30', 10);
|
|
16
10
|
|
|
17
11
|
async function checkIfFinished(text) {
|
|
18
12
|
try {
|
|
@@ -33,292 +27,121 @@ async function checkIfFinished(text) {
|
|
|
33
27
|
|
|
34
28
|
return completion.choices[0].message.content;
|
|
35
29
|
} catch (error) {
|
|
36
|
-
console.error('Error checking run status:', error);
|
|
30
|
+
console.error('[checkIfFinished] Error checking run status:', error);
|
|
37
31
|
}
|
|
38
32
|
}
|
|
39
33
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
query.numero = { $nin: ['5215592261426@s.whatsapp.net', '5215547411345@s.whatsapp.net'] };
|
|
46
|
-
} else {
|
|
47
|
-
query.numero = code;
|
|
48
|
-
query.is_group = false;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const lastMessages = await Message.find(query).sort({ createdAt: -1 });
|
|
52
|
-
console.log('[getLastMessages] lastMessages', lastMessages.map(msg => msg.body).join('\n\n'));
|
|
53
|
-
|
|
54
|
-
if (lastMessages.length === 0) return [];
|
|
55
|
-
|
|
56
|
-
let patientReply = [];
|
|
57
|
-
for (const message of lastMessages) {
|
|
58
|
-
patientReply.push(message);
|
|
59
|
-
await Message.updateOne(
|
|
60
|
-
{ message_id: message.message_id, timestamp: message.timestamp },
|
|
61
|
-
{ $set: { processed: true } }
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
console.log('[getLastMessages] Marked', patientReply.length, 'messages as processed');
|
|
66
|
-
return patientReply.reverse();
|
|
67
|
-
} catch (error) {
|
|
68
|
-
console.error('Error getting the last user messages:', error);
|
|
69
|
-
return [];
|
|
34
|
+
function getCurRow(baseID, code) {
|
|
35
|
+
if (code.endsWith('@g.us')) {
|
|
36
|
+
return getRecordByFilter(baseID, 'estado_general', `FIND("${code}", {Group ID})`);
|
|
37
|
+
} else {
|
|
38
|
+
return getRecordByFilter(baseID, 'estado_general', `FIND("${code.split('@')[0]}", {whatsapp_id})`);
|
|
70
39
|
}
|
|
71
40
|
}
|
|
72
41
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const formattedMessages = lastMessages
|
|
81
|
-
.reverse()
|
|
82
|
-
.map(message => formatMessage(message))
|
|
83
|
-
.filter(formatted => formatted !== null) // Filter out any null results from formatMessage
|
|
84
|
-
.join('\n\n');
|
|
85
|
-
|
|
86
|
-
console.log('[getLastNMessages] Fetched last messages:', formattedMessages);
|
|
87
|
-
|
|
88
|
-
return formattedMessages;
|
|
89
|
-
} catch (error) {
|
|
90
|
-
console.error('Error retrieving the last user messages:', error);
|
|
91
|
-
return '';
|
|
42
|
+
const runAssistantAndWait = async ({
|
|
43
|
+
thread,
|
|
44
|
+
assistant,
|
|
45
|
+
runConfig = {}
|
|
46
|
+
}) => {
|
|
47
|
+
if (!thread || !thread.getConversationId()) {
|
|
48
|
+
throw new Error('runAssistantAndWait requires a thread with a valid thread_id or conversation_id');
|
|
92
49
|
}
|
|
93
|
-
}
|
|
94
50
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
let name = reply.nombre_whatsapp;
|
|
98
|
-
for (const [key, value] of Object.entries(numbers)) {
|
|
99
|
-
console.log(key, value, reply.numero);
|
|
100
|
-
if (value[reply.numero]) {
|
|
101
|
-
role = key;
|
|
102
|
-
name = value[reply.numero];
|
|
103
|
-
break;
|
|
104
|
-
}
|
|
51
|
+
if (!assistant) {
|
|
52
|
+
throw new Error('runAssistantAndWait requires an assistant instance');
|
|
105
53
|
}
|
|
106
|
-
if (mode === 'prod') {
|
|
107
|
-
if (reply.from_me) {
|
|
108
|
-
role = 'asistente';
|
|
109
|
-
name = 'Pipo';
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
return { role, name };
|
|
113
|
-
};
|
|
114
54
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// Normalize timestamp: convert any format to a Date object, then to ISO string
|
|
122
|
-
let dateObj;
|
|
123
|
-
|
|
124
|
-
if (reply.timestamp instanceof Date) {
|
|
125
|
-
dateObj = reply.timestamp;
|
|
126
|
-
} else if (typeof reply.timestamp === 'number') {
|
|
127
|
-
const ms = reply.timestamp < 1e12 ? reply.timestamp * 1000 : reply.timestamp;
|
|
128
|
-
dateObj = new Date(ms);
|
|
129
|
-
} else {
|
|
130
|
-
dateObj = new Date(reply.timestamp);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
if (isNaN(dateObj.getTime())) {
|
|
134
|
-
console.warn('[formatMessage] Invalid timestamp:', reply.timestamp);
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const isoString = dateObj.toISOString();
|
|
139
|
-
|
|
140
|
-
// Convert timestamp to Mexico City timezone with Spanish format
|
|
141
|
-
// Format: martes, 30 de septiembre de 2025 a las 8:30 AM
|
|
142
|
-
const mexicoCityTime = moment(isoString)
|
|
143
|
-
.tz('America/Mexico_City')
|
|
144
|
-
.locale('es')
|
|
145
|
-
.format('dddd, D [de] MMMM [de] YYYY [a las] h:mm A');
|
|
146
|
-
|
|
147
|
-
return `[${mexicoCityTime}] ${reply.body}`;
|
|
148
|
-
} catch (error) {
|
|
149
|
-
console.error('[formatMessage] Error formatting message:', error?.message || error, 'timestamp:', reply.timestamp);
|
|
150
|
-
return null;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
55
|
+
const provider = createProvider({ variant: process.env.VARIANT || 'assistants' });
|
|
56
|
+
const { polling, tools: configTools, ...conversationConfig } = runConfig || {};
|
|
57
|
+
const variant = provider.getVariant ? provider.getVariant() : (process.env.VARIANT || 'assistants');
|
|
58
|
+
const tools = assistant.getToolSchemas ? assistant.getToolSchemas() : (configTools || []);
|
|
153
59
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
timestamp: reply.timestamp,
|
|
158
|
-
media: { $ne: null }
|
|
159
|
-
});
|
|
60
|
+
const runConfigWithAssistant = variant === 'responses'
|
|
61
|
+
? { ...conversationConfig, assistant }
|
|
62
|
+
: conversationConfig;
|
|
160
63
|
|
|
161
|
-
|
|
64
|
+
let run = await provider.runConversation({
|
|
65
|
+
threadId: thread.getConversationId(),
|
|
66
|
+
assistantId: thread.getAssistantId(),
|
|
67
|
+
tools: tools.length > 0 ? tools : undefined,
|
|
68
|
+
...runConfigWithAssistant,
|
|
69
|
+
});
|
|
162
70
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
71
|
+
const filter = thread.code ? { code: thread.code, active: true } : null;
|
|
72
|
+
if (filter) {
|
|
73
|
+
await Thread.updateOne(filter, { $set: { run_id: run.id } });
|
|
166
74
|
}
|
|
167
75
|
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
const [subType, fileName] = key.split('/');
|
|
171
|
-
const sourceFile = `${code}-${subType}-${fileName}`;
|
|
172
|
-
const downloadPath = path.join(__dirname, 'assets', 'tmp', sourceFile);
|
|
173
|
-
console.log(bucketName, key);
|
|
174
|
-
await downloadFileFromS3(bucketName, key, downloadPath);
|
|
175
|
-
|
|
176
|
-
const fileNames = (subType === 'document' || subType === 'application')
|
|
177
|
-
? await convertPdfToImages(sourceFile.split('.')[0])
|
|
178
|
-
: [downloadPath];
|
|
76
|
+
const maxRetries = polling?.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
77
|
+
let completed = false;
|
|
179
78
|
|
|
180
|
-
if (subType === 'document' || subType === 'application') {
|
|
181
|
-
await fs.promises.unlink(downloadPath);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
console.log(fileNames);
|
|
185
|
-
|
|
186
|
-
return fileNames;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
async function processIndividualMessage(code, reply, provider, thread) {
|
|
190
|
-
const tempFiles = [];
|
|
191
79
|
try {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
if (formattedMessage) {
|
|
199
|
-
messagesChat.push({ type: 'text', text: formattedMessage });
|
|
80
|
+
console.log('[runAssistantAndWait] RUN ID', run.id, 'THREAD ID', thread.getConversationId(), 'ASSISTANT ID', thread.getAssistantId());
|
|
81
|
+
({run, completed} = await provider.checkRunStatus(assistant, thread.getConversationId(), run.id, 0, maxRetries));
|
|
82
|
+
} finally {
|
|
83
|
+
if (filter) {
|
|
84
|
+
await Thread.updateOne(filter, { $set: { run_id: null } });
|
|
200
85
|
}
|
|
86
|
+
}
|
|
201
87
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const fileNames = await downloadMediaAndCreateFile(code, reply);
|
|
206
|
-
tempFiles.push(...fileNames);
|
|
207
|
-
for (const fileName of fileNames) {
|
|
208
|
-
console.log(fileName);
|
|
209
|
-
// Skip WBMP images and stickers
|
|
210
|
-
if (fileName.toLowerCase().includes('.wbmp') || fileName.toLowerCase().includes('sticker')) {
|
|
211
|
-
console.log('Skipping WBMP image or sticker:', fileName);
|
|
212
|
-
continue;
|
|
213
|
-
}
|
|
214
|
-
if (fileName.includes('image') || fileName.includes('document') || fileName.includes('application')) {
|
|
215
|
-
let imageAnalysis = null;
|
|
216
|
-
try {
|
|
217
|
-
imageAnalysis = await analyzeImage(fileName);
|
|
218
|
-
} catch (error) {
|
|
219
|
-
console.warn('[assistantHelper] analyzeImage failed:', error?.message || error);
|
|
220
|
-
}
|
|
221
|
-
console.log(imageAnalysis);
|
|
222
|
-
const invalidAnalysis = ['NOT_MEDICAL', 'QUALITY_INSUFFICIENT'];
|
|
223
|
-
if (imageAnalysis?.medical_relevance) {
|
|
224
|
-
url = await generatePresignedUrl(reply.media.bucketName, reply.media.key);
|
|
225
|
-
}
|
|
226
|
-
if (imageAnalysis?.has_table && imageAnalysis.table_data) {
|
|
227
|
-
messagesChat.push({
|
|
228
|
-
type: 'text',
|
|
229
|
-
text: imageAnalysis.table_data,
|
|
230
|
-
});
|
|
231
|
-
} else if (imageAnalysis?.medical_analysis && !invalidAnalysis.some(tag => imageAnalysis.medical_analysis.includes(tag))) {
|
|
232
|
-
messagesChat.push({
|
|
233
|
-
type: 'text',
|
|
234
|
-
text: imageAnalysis.medical_analysis,
|
|
235
|
-
});
|
|
236
|
-
} else {
|
|
237
|
-
console.log('Add attachment');
|
|
238
|
-
/*const file = await provider.uploadFile({
|
|
239
|
-
file: fs.createReadStream(fileName),
|
|
240
|
-
purpose: 'vision',
|
|
241
|
-
});
|
|
242
|
-
messagesChat.push({
|
|
243
|
-
type: 'image_file',
|
|
244
|
-
image_file: { file_id: file.id },
|
|
245
|
-
});*/
|
|
246
|
-
messagesChat.push({
|
|
247
|
-
type: 'text',
|
|
248
|
-
text: imageAnalysis.description,
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
} else if (fileName.includes('audio')) {
|
|
252
|
-
const audioTranscript = await provider.transcribeAudio({
|
|
253
|
-
file: fs.createReadStream(fileName),
|
|
254
|
-
responseFormat: 'text',
|
|
255
|
-
language: 'es'
|
|
256
|
-
});
|
|
257
|
-
const transcriptText = audioTranscript?.text || audioTranscript;
|
|
258
|
-
messagesChat.push({
|
|
259
|
-
type: 'text',
|
|
260
|
-
text: transcriptText,
|
|
261
|
-
});
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
}
|
|
88
|
+
if (!completed) {
|
|
89
|
+
return { run: run, completed: false, output: '' };
|
|
90
|
+
}
|
|
265
91
|
|
|
266
|
-
|
|
92
|
+
const output = await provider.getRunText({ threadId: thread.getConversationId(), runId: run.id, fallback: '' });
|
|
267
93
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
await provider.addMessage({
|
|
271
|
-
threadId,
|
|
272
|
-
role: 'assistant',
|
|
273
|
-
content: messagesChat
|
|
274
|
-
});
|
|
275
|
-
} else if (reply.origin === 'patient') {
|
|
276
|
-
await provider.addMessage({
|
|
277
|
-
threadId,
|
|
278
|
-
role: 'user',
|
|
279
|
-
content: messagesChat
|
|
280
|
-
});
|
|
281
|
-
}
|
|
94
|
+
return { completed: true, output };
|
|
95
|
+
};
|
|
282
96
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
97
|
+
const executeAssistantAttempt = async (thread, assistant, runConfig, attemptNumber) => {
|
|
98
|
+
const result = await runAssistantAndWait({
|
|
99
|
+
thread,
|
|
100
|
+
assistant,
|
|
101
|
+
runConfig
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
console.log(`[executeAssistantAttempt] Attempt ${attemptNumber}: completed=${result.completed}, output=${result.output || '(empty)'}`);
|
|
105
|
+
|
|
106
|
+
return result;
|
|
107
|
+
};
|
|
287
108
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
} finally {
|
|
292
|
-
if (tempFiles.length > 0) {
|
|
293
|
-
await Promise.all(tempFiles.map(async (filePath) => {
|
|
294
|
-
try {
|
|
295
|
-
await fs.promises.unlink(filePath);
|
|
296
|
-
} catch (error) {
|
|
297
|
-
if (error?.code !== 'ENOENT') {
|
|
298
|
-
console.warn('[processIndividualMessage] Failed to remove temp file:', filePath, error?.message || error);
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
}));
|
|
302
|
-
}
|
|
109
|
+
const runAssistantWithRetries = async (thread, assistant, runConfig, patientReply = null) => {
|
|
110
|
+
if (patientReply) {
|
|
111
|
+
assistant.setReplies(patientReply);
|
|
303
112
|
}
|
|
304
|
-
}
|
|
305
113
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
114
|
+
let run, output, completed;
|
|
115
|
+
let retries = 0;
|
|
116
|
+
const maxRetries = DEFAULT_MAX_RETRIES;
|
|
117
|
+
|
|
118
|
+
do {
|
|
119
|
+
retries++;
|
|
120
|
+
({ run, output, completed } = await withTracing(
|
|
121
|
+
executeAssistantAttempt,
|
|
122
|
+
'assistant_attempt',
|
|
123
|
+
(thread, assistant, runConfig, attemptNumber) => ({
|
|
124
|
+
'attempt.number': attemptNumber,
|
|
125
|
+
'attempt.is_retry': attemptNumber > 1,
|
|
126
|
+
'attempt.max_attempts': maxRetries
|
|
127
|
+
})
|
|
128
|
+
)(thread, assistant, runConfig, retries));
|
|
129
|
+
|
|
130
|
+
if (completed && output) break;
|
|
131
|
+
if (retries < maxRetries) await new Promise(resolve => setTimeout(resolve, 2000));
|
|
132
|
+
} while (retries < maxRetries && (!completed || !output));
|
|
133
|
+
|
|
134
|
+
if (run?.last_error) console.log('[runAssistantWithRetries] RUN LAST ERROR:', run.last_error);
|
|
135
|
+
console.log('[runAssistantWithRetries] RUN STATUS', completed);
|
|
136
|
+
console.log('[runAssistantWithRetries] OUTPUT', output);
|
|
137
|
+
|
|
138
|
+
return { run, output, completed, retries };
|
|
139
|
+
};
|
|
314
140
|
|
|
315
141
|
module.exports = {
|
|
316
142
|
checkIfFinished,
|
|
317
|
-
getLastMessages,
|
|
318
|
-
getLastNMessages,
|
|
319
|
-
getPatientRoleAndName,
|
|
320
143
|
getCurRow,
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
144
|
+
runAssistantAndWait,
|
|
145
|
+
runAssistantWithRetries,
|
|
146
|
+
executeAssistantAttempt
|
|
324
147
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const { Message, insertMessage, getMessageValues } = require('../models/messageModel.js');
|
|
2
2
|
const { uploadMediaToS3 } = require('./mediaHelper.js');
|
|
3
|
+
const { isRecentMessage } = require('./messageHelper.js');
|
|
3
4
|
const { downloadMediaMessage } = require('baileys');
|
|
4
5
|
|
|
5
6
|
|
|
@@ -105,17 +106,6 @@ function extractContentTypeAndReply(message, messageType) {
|
|
|
105
106
|
return { content, contentType, reply };
|
|
106
107
|
}
|
|
107
108
|
|
|
108
|
-
async function isRecentMessage(chatId) {
|
|
109
|
-
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
|
|
110
|
-
|
|
111
|
-
const recentMessage = await Message.find({
|
|
112
|
-
$or: [{ group_id: chatId }, { numero: chatId }],
|
|
113
|
-
createdAt: { $gte: fiveMinutesAgo }
|
|
114
|
-
}).sort({ createdAt: -1 }).limit(1);
|
|
115
|
-
|
|
116
|
-
return !!recentMessage;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
109
|
async function getLastMessages(chatId, n) {
|
|
120
110
|
const messages = await Message.find({ group_id: chatId })
|
|
121
111
|
.sort({ createdAt: -1 })
|
|
@@ -1,36 +1,41 @@
|
|
|
1
1
|
const { PDFDocument } = require('pdf-lib');
|
|
2
|
-
const {
|
|
3
|
-
const fs = require('fs');
|
|
2
|
+
const { execFile } = require('child_process');
|
|
3
|
+
const fs = require('fs').promises;
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const sharp = require('sharp');
|
|
6
6
|
|
|
7
|
+
const { downloadFileFromS3 } = require('../config/awsConfig.js');
|
|
8
|
+
const { Message } = require('../models/messageModel.js');
|
|
9
|
+
const { sanitizeFilename } = require('../utils/sanitizer.js');
|
|
7
10
|
|
|
8
11
|
async function convertPdfToImages(pdfName) {
|
|
9
12
|
const outputDir = path.join(__dirname, 'assets', 'tmp');
|
|
10
|
-
|
|
13
|
+
|
|
14
|
+
const sanitizedName = sanitizeFilename(pdfName);
|
|
15
|
+
const pdfPath = path.join(outputDir, `${sanitizedName}.pdf`);
|
|
16
|
+
const outputPattern = path.join(outputDir, sanitizedName);
|
|
11
17
|
|
|
12
|
-
|
|
13
|
-
fs.mkdirSync(outputDir, { recursive: true });
|
|
14
|
-
}
|
|
18
|
+
await fs.promises.mkdir(outputDir, { recursive: true });
|
|
15
19
|
|
|
16
20
|
return new Promise((resolve, reject) => {
|
|
17
|
-
const
|
|
18
|
-
console.log(
|
|
19
|
-
|
|
21
|
+
const args = ['-jpeg', pdfPath, outputPattern];
|
|
22
|
+
console.log('[convertPdfToImages] Running: pdftoppm', args.join(' '));
|
|
23
|
+
|
|
24
|
+
execFile('pdftoppm', args, (error, stdout, stderr) => {
|
|
20
25
|
if (error) {
|
|
21
|
-
return reject(`Error splitting PDF: ${stderr}`);
|
|
26
|
+
return reject(new Error(`Error splitting PDF: ${stderr || error.message}`));
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
fs.readdir(outputDir, (err, files) => {
|
|
25
30
|
if (err) {
|
|
26
|
-
return reject(`Error reading output directory: ${err.message}`);
|
|
31
|
+
return reject(new Error(`Error reading output directory: ${err.message}`));
|
|
27
32
|
}
|
|
28
33
|
|
|
29
|
-
const
|
|
30
|
-
.filter(file => file.startsWith(
|
|
34
|
+
const jpgFiles = files
|
|
35
|
+
.filter(file => file.startsWith(sanitizedName) && file.endsWith('.jpg'))
|
|
31
36
|
.map(file => path.join(outputDir, file));
|
|
32
37
|
|
|
33
|
-
resolve(
|
|
38
|
+
resolve(jpgFiles);
|
|
34
39
|
});
|
|
35
40
|
});
|
|
36
41
|
});
|
|
@@ -43,11 +48,9 @@ async function combineImagesToPDF(config) {
|
|
|
43
48
|
sortNumerically = true
|
|
44
49
|
} = config;
|
|
45
50
|
|
|
46
|
-
// Get all files in the directory
|
|
47
51
|
const inputDir = path.join(__dirname, 'assets', 'tmp');
|
|
48
52
|
const files = await fs.promises.readdir(inputDir);
|
|
49
53
|
|
|
50
|
-
// Filter for image files with the specified extensions
|
|
51
54
|
const imageFiles = files.filter(file => {
|
|
52
55
|
const ext = path.extname(file).toLowerCase().substring(1);
|
|
53
56
|
const hasValidExtension = extensions.includes(ext);
|
|
@@ -55,7 +58,6 @@ async function combineImagesToPDF(config) {
|
|
|
55
58
|
return hasValidExtension && hasPrefix;
|
|
56
59
|
});
|
|
57
60
|
|
|
58
|
-
// Sort files
|
|
59
61
|
if (sortNumerically) {
|
|
60
62
|
imageFiles.sort((a, b) => {
|
|
61
63
|
const aMatch = a.match(/\d+/g);
|
|
@@ -71,11 +73,9 @@ async function combineImagesToPDF(config) {
|
|
|
71
73
|
|
|
72
74
|
console.log(`Found ${imageFiles.length} image files to combine`);
|
|
73
75
|
|
|
74
|
-
// Create a new PDF document
|
|
75
76
|
const pdfDoc = await PDFDocument.create();
|
|
76
77
|
const processedFiles = [];
|
|
77
78
|
|
|
78
|
-
// Process each image file
|
|
79
79
|
for (const [index, file] of imageFiles.entries()) {
|
|
80
80
|
try {
|
|
81
81
|
const filePath = path.join(inputDir, file);
|
|
@@ -89,8 +89,7 @@ async function combineImagesToPDF(config) {
|
|
|
89
89
|
const { width, height } = await sharp(imageBuffer).metadata();
|
|
90
90
|
const img = await pdfDoc.embedPng(pngBuffer);
|
|
91
91
|
const page = pdfDoc.addPage([width, height]);
|
|
92
|
-
|
|
93
|
-
// Draw the image on the page
|
|
92
|
+
|
|
94
93
|
page.drawImage(img, {
|
|
95
94
|
x: 0,
|
|
96
95
|
y: 0,
|
|
@@ -112,23 +111,67 @@ async function combineImagesToPDF(config) {
|
|
|
112
111
|
};
|
|
113
112
|
}
|
|
114
113
|
|
|
115
|
-
|
|
116
|
-
files.
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
114
|
+
const cleanupFiles = async (files) => {
|
|
115
|
+
if (!files || files.length === 0) return;
|
|
116
|
+
|
|
117
|
+
await Promise.all(files.map(async (filePath) => {
|
|
118
|
+
try {
|
|
119
|
+
await fs.promises.unlink(filePath);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (error?.code !== 'ENOENT') {
|
|
122
|
+
const safeFileName = filePath ? filePath.split('/').pop().replace(/^[^-]+-[^-]+-/, 'xxx-xxx-') : 'unknown';
|
|
123
|
+
console.warn(`[cleanupFiles] Error deleting ${safeFileName}:`, error?.message || String(error));
|
|
123
124
|
}
|
|
124
|
-
} else {
|
|
125
|
-
console.warn(`File not found: ${file}`);
|
|
126
125
|
}
|
|
126
|
+
}));
|
|
127
|
+
|
|
128
|
+
console.log(`[cleanupFiles] Cleaned up ${files.length} files`);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
async function downloadMediaAndCreateFile(code, reply) {
|
|
132
|
+
const resultMedia = await Message.findOne({
|
|
133
|
+
message_id: reply.message_id,
|
|
134
|
+
timestamp: reply.timestamp,
|
|
135
|
+
media: { $ne: null }
|
|
127
136
|
});
|
|
137
|
+
|
|
138
|
+
if (!resultMedia) return [];
|
|
139
|
+
|
|
140
|
+
if (!resultMedia.media || !resultMedia.media.key) {
|
|
141
|
+
console.log('[downloadMediaAndCreateFile] No valid media found for message:', reply.message_id);
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const { bucketName, key } = resultMedia.media;
|
|
146
|
+
if (!bucketName || !key) return [];
|
|
147
|
+
|
|
148
|
+
const [subType, fileName] = key.split('/');
|
|
149
|
+
|
|
150
|
+
const sanitizedCode = sanitizeFilename(code);
|
|
151
|
+
const sanitizedSubType = sanitizeFilename(subType);
|
|
152
|
+
const sanitizedFileName = sanitizeFilename(fileName);
|
|
153
|
+
|
|
154
|
+
const sourceFile = `${sanitizedCode}-${sanitizedSubType}-${sanitizedFileName}`;
|
|
155
|
+
const downloadPath = path.join(__dirname, 'assets', 'tmp', sourceFile);
|
|
156
|
+
|
|
157
|
+
await fs.promises.mkdir(path.dirname(downloadPath), { recursive: true });
|
|
158
|
+
await downloadFileFromS3(bucketName, key, downloadPath);
|
|
159
|
+
|
|
160
|
+
const { name: baseName } = path.parse(sourceFile);
|
|
161
|
+
const fileNames = (subType === 'document' || subType === 'application')
|
|
162
|
+
? await convertPdfToImages(baseName)
|
|
163
|
+
: [downloadPath];
|
|
164
|
+
|
|
165
|
+
if (subType === 'document' || subType === 'application') {
|
|
166
|
+
await fs.promises.unlink(downloadPath);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return fileNames;
|
|
128
170
|
}
|
|
129
171
|
|
|
130
172
|
module.exports = {
|
|
131
173
|
convertPdfToImages,
|
|
132
174
|
combineImagesToPDF,
|
|
133
|
-
cleanupFiles
|
|
175
|
+
cleanupFiles,
|
|
176
|
+
downloadMediaAndCreateFile
|
|
134
177
|
};
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
const AWS = require('../config/awsConfig.js');
|
|
2
1
|
const path = require('path');
|
|
3
2
|
const fs = require('fs');
|
|
3
|
+
const AWS = require('../config/awsConfig.js');
|
|
4
|
+
const { sanitizeMediaFilename } = require('../utils/sanitizer.js');
|
|
4
5
|
|
|
5
6
|
async function uploadMediaToS3(buffer, messageID, titleFile, bucketName, contentType, messageType) {
|
|
6
7
|
const extension = getFileExtension(contentType);
|
|
7
|
-
const sanitizedTitle = titleFile ?
|
|
8
|
+
const sanitizedTitle = titleFile ? sanitizeMediaFilename(titleFile) : '';
|
|
8
9
|
const fileName = sanitizedTitle
|
|
9
10
|
? `${messageType}/${messageID}_${sanitizedTitle}.${extension}`
|
|
10
11
|
: `${messageType}/${messageID}.${extension}`;
|
|
@@ -58,15 +59,8 @@ function getFileExtension(contentType) {
|
|
|
58
59
|
return mimeToExt[contentType] || 'bin';
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
function sanitizeFileName(fileName) {
|
|
62
|
-
return fileName
|
|
63
|
-
.replace(/[^a-zA-Z0-9_-]/g, '')
|
|
64
|
-
.slice(0, 50);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
62
|
module.exports = {
|
|
68
63
|
uploadMediaToS3,
|
|
69
64
|
saveMediaToLocal,
|
|
70
|
-
getFileExtension
|
|
71
|
-
sanitizeFileName
|
|
65
|
+
getFileExtension
|
|
72
66
|
};
|