@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,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
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
const { PDFDocument } = require('pdf-lib');
|
|
2
|
+
const { exec } = require('child_process');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const sharp = require('sharp');
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async function convertPdfToImages(pdfName) {
|
|
9
|
+
const outputDir = path.join(__dirname, 'assets', 'tmp');
|
|
10
|
+
const pdfPath = `${outputDir}/${pdfName}.pdf`;
|
|
11
|
+
|
|
12
|
+
if (!fs.existsSync(outputDir)) {
|
|
13
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const command = `pdftoppm -jpeg ${pdfPath} ${outputDir}/${pdfName}`;
|
|
18
|
+
console.log(command);
|
|
19
|
+
exec(command, (error, stdout, stderr) => {
|
|
20
|
+
if (error) {
|
|
21
|
+
return reject(`Error splitting PDF: ${stderr}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
fs.readdir(outputDir, (err, files) => {
|
|
25
|
+
if (err) {
|
|
26
|
+
return reject(`Error reading output directory: ${err.message}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const pngFiles = files
|
|
30
|
+
.filter(file => file.startsWith(pdfName) && file.endsWith('.jpg'))
|
|
31
|
+
.map(file => path.join(outputDir, file));
|
|
32
|
+
|
|
33
|
+
resolve(pngFiles);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function combineImagesToPDF(config) {
|
|
40
|
+
const {
|
|
41
|
+
code,
|
|
42
|
+
extensions = ['jpg', 'jpeg', 'png', 'tiff'],
|
|
43
|
+
sortNumerically = true
|
|
44
|
+
} = config;
|
|
45
|
+
|
|
46
|
+
// Get all files in the directory
|
|
47
|
+
const inputDir = path.join(__dirname, 'assets', 'tmp');
|
|
48
|
+
const files = await fs.promises.readdir(inputDir);
|
|
49
|
+
|
|
50
|
+
// Filter for image files with the specified extensions
|
|
51
|
+
const imageFiles = files.filter(file => {
|
|
52
|
+
const ext = path.extname(file).toLowerCase().substring(1);
|
|
53
|
+
const hasValidExtension = extensions.includes(ext);
|
|
54
|
+
const hasPrefix = code ? file.startsWith(code) : true;
|
|
55
|
+
return hasValidExtension && hasPrefix;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Sort files
|
|
59
|
+
if (sortNumerically) {
|
|
60
|
+
imageFiles.sort((a, b) => {
|
|
61
|
+
const aMatch = a.match(/\d+/g);
|
|
62
|
+
const bMatch = b.match(/\d+/g);
|
|
63
|
+
|
|
64
|
+
if (!aMatch || !bMatch) return a.localeCompare(b);
|
|
65
|
+
|
|
66
|
+
return parseInt(aMatch[0]) - parseInt(bMatch[0]);
|
|
67
|
+
});
|
|
68
|
+
} else {
|
|
69
|
+
imageFiles.sort();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
console.log(`Found ${imageFiles.length} image files to combine`);
|
|
73
|
+
|
|
74
|
+
// Create a new PDF document
|
|
75
|
+
const pdfDoc = await PDFDocument.create();
|
|
76
|
+
const processedFiles = [];
|
|
77
|
+
|
|
78
|
+
// Process each image file
|
|
79
|
+
for (const [index, file] of imageFiles.entries()) {
|
|
80
|
+
try {
|
|
81
|
+
const filePath = path.join(inputDir, file);
|
|
82
|
+
console.log(`Processing file ${index + 1}/${imageFiles.length}: ${file}`);
|
|
83
|
+
|
|
84
|
+
const imageBuffer = await fs.promises.readFile(filePath);
|
|
85
|
+
const pngBuffer = await sharp(imageBuffer)
|
|
86
|
+
.toFormat('png')
|
|
87
|
+
.toBuffer();
|
|
88
|
+
|
|
89
|
+
const { width, height } = await sharp(imageBuffer).metadata();
|
|
90
|
+
const img = await pdfDoc.embedPng(pngBuffer);
|
|
91
|
+
const page = pdfDoc.addPage([width, height]);
|
|
92
|
+
|
|
93
|
+
// Draw the image on the page
|
|
94
|
+
page.drawImage(img, {
|
|
95
|
+
x: 0,
|
|
96
|
+
y: 0,
|
|
97
|
+
width: width,
|
|
98
|
+
height: height,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
processedFiles.push(filePath);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
console.error(`Error processing file ${file}:`, error);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
console.log('Combined PDF created successfully');
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
pdfBuffer: await pdfDoc.save(),
|
|
111
|
+
processedFiles
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function cleanupFiles(files) {
|
|
116
|
+
files.forEach(file => {
|
|
117
|
+
if (fs.existsSync(file)) {
|
|
118
|
+
try {
|
|
119
|
+
fs.unlinkSync(file);
|
|
120
|
+
console.log(`Deleted: ${file}`);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
console.error(`Error deleting ${file}:`, error);
|
|
123
|
+
}
|
|
124
|
+
} else {
|
|
125
|
+
console.warn(`File not found: ${file}`);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = {
|
|
131
|
+
convertPdfToImages,
|
|
132
|
+
combineImagesToPDF,
|
|
133
|
+
cleanupFiles
|
|
134
|
+
};
|