@peopl-health/nexus 3.14.1 → 3.15.1

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/README.md CHANGED
@@ -175,21 +175,6 @@ You can configure Nexus via environment variables or at runtime using simple inj
175
175
 
176
176
  Injection points (dependency injection)
177
177
 
178
- - Message scheduling (use your Agenda/Bull model + scheduler):
179
- ```js
180
- const { configureMessageController } = require('@peopl-health/nexus/lib/controllers/messageController');
181
- const { AgendaMessage } = require('./src/models/agendaMessageModel');
182
- const { sendScheduledMessage: appSchedule } = require('./src/messaging/scheduledMessageService');
183
-
184
- const provider = nexus.getMessaging().getProvider(); // e.g., TwilioProvider
185
- configureMessageController({
186
- ScheduledMessage: AgendaMessage, // must expose create/find/findById/deleteOne
187
- sendScheduledMessage: (saved) => appSchedule(provider.twilioClient, saved),
188
- // Optional: only if you use bulk‑airtable
189
- getRecordByFilter: require('@peopl-health/nexus/lib/services/airtableService').getRecordByFilter
190
- });
191
- ```
192
-
193
178
  - Media (inject only your bucket name; AWS SDK is loaded by the lib):
194
179
  ```js
195
180
  const { configureMediaController } = require('@peopl-health/nexus/lib/controllers/mediaController');
@@ -95,8 +95,10 @@ class TwilioProvider extends MessageProvider {
95
95
 
96
96
  if (fileUrl && fileType !== 'text') {
97
97
  const mediaPrep = await this._prepareOutboundMedia(messageData, formattedCode);
98
- const outboundMediaUrl = mediaPrep.mediaUrl || fileUrl;
99
- messageParams.mediaUrl = [outboundMediaUrl];
98
+ if (!contentSid) {
99
+ const outboundMediaUrl = mediaPrep.mediaUrl || fileUrl;
100
+ messageParams.mediaUrl = [outboundMediaUrl];
101
+ }
100
102
  if (!messageParams.body || messageParams.body.trim() === '') {
101
103
  delete messageParams.body;
102
104
  }
@@ -3,48 +3,30 @@ const moment = require('moment-timezone');
3
3
  const { logger } = require('../utils/logger');
4
4
 
5
5
  const { Message } = require('../models/messageModel.js');
6
- const { ScheduledMessage: DefaultScheduledMessage } = require('../models/agendaMessageModel.js');
6
+ const { ScheduledMessage } = require('../models/agendaMessageModel.js');
7
7
  const FlowRouting = require('../models/flowRoutingModel.js');
8
8
 
9
9
  const { ensureWhatsAppFormat } = require('../helpers/twilioHelper');
10
10
  const { ensureFlowTokenInVariables } = require('../helpers/templateFlowControllerHelper');
11
11
 
12
- const { getRecordByFilter: defaultGetRecordByFilter } = require('../services/airtableService');
12
+ const { getRecordByFilter } = require('../services/airtableService');
13
13
 
14
- const {
15
- sendMessage: defaultSendMessage,
16
- sendScheduledMessage: defaultSendScheduledMessage,
17
- getDefaultInstance
18
- } = require('../core/NexusMessaging');
19
-
20
- const dependencies = {
21
- ScheduledMessage: DefaultScheduledMessage,
22
- getRecordByFilter: defaultGetRecordByFilter,
23
- sendScheduledMessage: defaultSendScheduledMessage,
24
- sendMessage: defaultSendMessage
25
- };
26
-
27
- const configureMessageController = (overrides = {}) => {
28
- Object.keys(overrides).forEach(key => {
29
- if (overrides[key]) dependencies[key] = overrides[key];
30
- });
31
- return { ...dependencies };
32
- };
14
+ const { sendMessage, sendScheduledMessage, getDefaultInstance } = require('../core/NexusMessaging');
33
15
 
34
16
  const _validateMessagingDeps = (res, requireAirtable = false) => {
35
- if (!dependencies.ScheduledMessage) {
17
+ if (!ScheduledMessage) {
36
18
  res.status(500).json({ success: false, error: 'ScheduledMessage model not configured' });
37
19
  return false;
38
20
  }
39
- if (!dependencies.sendScheduledMessage && !dependencies.sendMessage) {
21
+ if (!sendScheduledMessage && !sendMessage) {
40
22
  res.status(500).json({ success: false, error: 'No messaging provider configured' });
41
23
  return false;
42
24
  }
43
- if ((dependencies.sendMessage === defaultSendMessage || dependencies.sendScheduledMessage === defaultSendScheduledMessage) && !getDefaultInstance()) {
25
+ if (!getDefaultInstance()) {
44
26
  res.status(500).json({ success: false, error: 'NexusMessaging not initialized' });
45
27
  return false;
46
28
  }
47
- if (requireAirtable && !dependencies.getRecordByFilter) {
29
+ if (requireAirtable && !getRecordByFilter) {
48
30
  res.status(500).json({ success: false, error: 'Airtable getRecordByFilter not configured' });
49
31
  return false;
50
32
  }
@@ -56,11 +38,11 @@ const _pickMessageId = (result, fallback) =>
56
38
  fallback?.wa_id || fallback?.sid || fallback?.id || fallback?._id?.toString() || null;
57
39
 
58
40
  const _sendAndPersist = async (payload) => {
59
- const saved = await dependencies.ScheduledMessage.create(payload);
41
+ const saved = await ScheduledMessage.create(payload);
60
42
 
61
- const result = dependencies.sendScheduledMessage
62
- ? await dependencies.sendScheduledMessage(saved)
63
- : await dependencies.sendMessage({ ...payload, body: payload.message });
43
+ const result = sendScheduledMessage
44
+ ? await sendScheduledMessage(saved)
45
+ : await sendMessage({ ...payload, body: payload.message });
64
46
  return { result, saved };
65
47
  };
66
48
 
@@ -168,7 +150,7 @@ const sendBulkMessageAirtableController = async (req, res) => {
168
150
 
169
151
  try {
170
152
  const isFlowTemplate = contentSid && await FlowRouting.findOne({ contentSid }).lean();
171
- const rows = await dependencies.getRecordByFilter(baseId, tableName, condition);
153
+ const rows = await getRecordByFilter(baseId, tableName, condition);
172
154
  let extraDelay = 0;
173
155
  const sentPhones = new Set();
174
156
  const payloads = [];
@@ -235,12 +217,12 @@ const checkScheduledMessageStatusController = async (req, res) => {
235
217
  if (!contentSid || !code) {
236
218
  return res.status(400).json({ success: false, error: 'contentSid and code are required' });
237
219
  }
238
- if (!dependencies.ScheduledMessage) {
220
+ if (!ScheduledMessage) {
239
221
  return res.status(500).json({ success: false, error: 'ScheduledMessage model not configured' });
240
222
  }
241
223
 
242
224
  try {
243
- const msg = await dependencies.ScheduledMessage.findOne({ contentSid, code: ensureWhatsAppFormat(code) });
225
+ const msg = await ScheduledMessage.findOne({ contentSid, code: ensureWhatsAppFormat(code) });
244
226
  if (!msg) {
245
227
  return res.status(404).json({ success: false, error: 'ScheduledMessage not found' });
246
228
  }
@@ -278,6 +260,5 @@ module.exports = {
278
260
  sendBulkMessageAirtableController,
279
261
  getLastInteractionController,
280
262
  checkScheduledMessageStatusController,
281
- checkMessageStatusController,
282
- configureMessageController
263
+ checkMessageStatusController
283
264
  };
@@ -0,0 +1,29 @@
1
+ class BaseJob {
2
+ constructor({ queueAdapter, queueName } = {}) {
3
+ if (!queueAdapter) {
4
+ throw new Error(`${this.constructor.name} requires queueAdapter`);
5
+ }
6
+ if (!queueName) {
7
+ throw new Error(`${this.constructor.name} requires queueName`);
8
+ }
9
+
10
+ this.queueAdapter = queueAdapter;
11
+ this.queueName = queueName;
12
+
13
+ this.queueAdapter.process(queueName, (data) => this._process(data));
14
+ }
15
+
16
+ async _enqueue(payload, options = {}) {
17
+ return await this.queueAdapter.enqueue(this.queueName, payload, options);
18
+ }
19
+
20
+ async _cancelJob(jobId) {
21
+ return await this.queueAdapter.cancelJob(jobId);
22
+ }
23
+
24
+ async _process(_data) {
25
+ throw new Error(`${this.constructor.name}: _process(data) must be implemented`);
26
+ }
27
+ }
28
+
29
+ module.exports = { BaseJob };
@@ -0,0 +1,56 @@
1
+ const { logger } = require('../utils/logger');
2
+ const { BaseJob } = require('./BaseJob');
3
+
4
+ const QUEUE_NAME = 'scheduled-messages';
5
+
6
+ const DEFAULT_JOB_OPTIONS = {
7
+ attempts: 3,
8
+ backoff: { type: 'exponential', delay: 2000 },
9
+ removeOnComplete: true,
10
+ removeOnFail: 50
11
+ };
12
+
13
+ class ScheduledMessageJob extends BaseJob {
14
+ constructor({ queueAdapter } = {}) {
15
+ super({ queueAdapter, queueName: QUEUE_NAME });
16
+ }
17
+
18
+ async schedule({ scheduledMessageId, sendTime }) {
19
+ if (!scheduledMessageId) throw new Error('scheduledMessageId is required');
20
+ if (!sendTime) throw new Error('sendTime is required');
21
+
22
+ const delay = Math.max(0, new Date(sendTime).getTime() - Date.now());
23
+ const jobId = this._buildJobId(scheduledMessageId);
24
+
25
+ const enqueuedJobId = await this._enqueue(
26
+ { scheduledMessageId: String(scheduledMessageId) },
27
+ { ...DEFAULT_JOB_OPTIONS, delay, jobId }
28
+ );
29
+
30
+ logger.info('[ScheduledMessageJob] Enqueued', { scheduledMessageId, jobId: enqueuedJobId, delay, sendTime });
31
+
32
+ return { jobId: enqueuedJobId, delay };
33
+ }
34
+
35
+ async cancel(scheduledMessageId) {
36
+ if (!scheduledMessageId) throw new Error('scheduledMessageId is required');
37
+
38
+ const jobId = this._buildJobId(scheduledMessageId);
39
+ const cancelled = await this._cancelJob(jobId);
40
+
41
+ logger.info('[ScheduledMessageJob] Cancel requested', { scheduledMessageId, jobId, cancelled });
42
+
43
+ return cancelled;
44
+ }
45
+
46
+ _buildJobId(scheduledMessageId) {
47
+ return `scheduled-msg-${scheduledMessageId}`;
48
+ }
49
+
50
+ async _process(data) {
51
+ logger.warn('[ScheduledMessageJob] Processor stub invoked; real implementation pending', data);
52
+ return { success: false, reason: 'not_implemented' };
53
+ }
54
+ }
55
+
56
+ module.exports = { ScheduledMessageJob };
@@ -0,0 +1,45 @@
1
+ const { logger } = require('../utils/logger');
2
+ const { BaseJob } = require('./BaseJob');
3
+
4
+ const QUEUE_NAME = 'template-approval';
5
+ const DEFAULT_DELAY_MS = 15 * 60 * 1000;
6
+
7
+ const DEFAULT_JOB_OPTIONS = {
8
+ attempts: 3,
9
+ backoff: { type: 'exponential', delay: 5000 },
10
+ removeOnComplete: true,
11
+ removeOnFail: 50
12
+ };
13
+
14
+ class TemplateApprovalJob extends BaseJob {
15
+ constructor({ queueAdapter } = {}) {
16
+ super({ queueAdapter, queueName: QUEUE_NAME });
17
+ }
18
+
19
+ async schedule({ templateSid, messageId, originalMessageSid = null, attempt = 0, delayMs = DEFAULT_DELAY_MS }) {
20
+ if (!templateSid) throw new Error('templateSid is required');
21
+ if (!messageId) throw new Error('messageId is required');
22
+
23
+ const jobId = this._buildJobId(templateSid, attempt);
24
+
25
+ const enqueuedJobId = await this._enqueue(
26
+ { templateSid, messageId: String(messageId), originalMessageSid, attempt },
27
+ { ...DEFAULT_JOB_OPTIONS, delay: delayMs, jobId }
28
+ );
29
+
30
+ logger.info('[TemplateApprovalJob] Enqueued', { templateSid, messageId, attempt, jobId: enqueuedJobId, delayMs });
31
+
32
+ return { jobId: enqueuedJobId };
33
+ }
34
+
35
+ _buildJobId(templateSid, attempt) {
36
+ return `template-approval-${templateSid}-${attempt}`;
37
+ }
38
+
39
+ async _process(data) {
40
+ logger.warn('[TemplateApprovalJob] Processor stub invoked; real implementation pending', data);
41
+ return { success: false, reason: 'not_implemented' };
42
+ }
43
+ }
44
+
45
+ module.exports = { TemplateApprovalJob };
@@ -328,6 +328,7 @@ class OpenAIResponsesProvider {
328
328
  presetToolIds,
329
329
  presetId: resolvedPresetId,
330
330
  presetVersion: resolvedPresetVersion,
331
+ modelConfig,
331
332
  } = await composePrompt({
332
333
  presetId,
333
334
  presetVersion,
@@ -392,11 +393,15 @@ class OpenAIResponsesProvider {
392
393
  if (promptVersion) promptConfig.version = String(promptVersion);
393
394
 
394
395
  const apiCallConfig = {
395
- prompt: promptConfig,
396
+ ...modelConfig,
396
397
  instructions: instructions || additionalInstructions || devContent || '',
397
398
  metadata,
398
399
  tool_choice: prePromptResult?.toolChoice || toolChoice
399
400
  };
401
+
402
+ if (!resolvedPresetId) {
403
+ apiCallConfig.prompt = promptConfig;
404
+ }
400
405
  if (activeToolSchemas.length > 0) {
401
406
  apiCallConfig.tools = activeToolSchemas.map(schema => {
402
407
  if (schema.type === 'function' && schema.function) {
@@ -453,6 +458,7 @@ class OpenAIResponsesProvider {
453
458
  preset_id: resolvedPresetId,
454
459
  preset_version: resolvedPresetVersion,
455
460
  preset: resolvedPresetId ? { id: resolvedPresetId, version: resolvedPresetVersion } : null,
461
+ model_config: modelConfig || null,
456
462
  };
457
463
  return result;
458
464
  }
@@ -78,6 +78,7 @@ async function composePrompt({ presetId = null, presetVersion = null, promptId =
78
78
  let presetSnippetIds = null;
79
79
  let presetToolIds = null;
80
80
  let resolvedPresetVersion = null;
81
+ let modelConfig = null;
81
82
 
82
83
  async function applyPreset(id, version = null) {
83
84
  const preset = await fetchPreset(id, version);
@@ -88,6 +89,14 @@ async function composePrompt({ presetId = null, presetVersion = null, promptId =
88
89
  presetSnippetIds = preset.snippets || [];
89
90
  presetToolIds = preset.tools || [];
90
91
  resolvedPresetVersion = preset.version ? String(preset.version) : null;
92
+
93
+ if (preset.model_config) {
94
+ try {
95
+ modelConfig = JSON.parse(preset.model_config);
96
+ } catch (err) {
97
+ logger.error('[promptComposer] Invalid model_config JSON in preset', { presetId: id, error: err.message });
98
+ }
99
+ }
91
100
  const promptRecordId = preset.prompt?.[0];
92
101
  if (promptRecordId) {
93
102
  const promptRecords = await getRecordByFilter(Config_ID, 'responses', `RECORD_ID()="${promptRecordId}"`);
@@ -141,6 +150,7 @@ async function composePrompt({ presetId = null, presetVersion = null, promptId =
141
150
  presetToolIds,
142
151
  presetId: presetId || null,
143
152
  presetVersion: resolvedPresetVersion,
153
+ modelConfig,
144
154
  };
145
155
  }
146
156
 
@@ -53,6 +53,7 @@ class MongoStorage {
53
53
  body: values.body,
54
54
  media: values.media,
55
55
  type: values.interactive_type ? 'interactive' : values.media ? 'media' : 'message',
56
+ triggeredBy: values.triggeredBy,
56
57
  ...(messageData.frontendId && { frontendId: messageData.frontendId })
57
58
  }));
58
59
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "3.14.1",
3
+ "version": "3.15.1",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",