@theotherwillembotha/node-red-whatsapp 0.0.55 → 0.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/build/Nodes.html CHANGED
@@ -7,173 +7,418 @@
7
7
  You have been warned.
8
8
 
9
9
  -->
10
-
11
10
  <!-- WhatsappAccountConfigNode -->
11
+
12
+ <style>
13
+ .theotherwillembotha-compact-table li {
14
+ padding-top: 4px;
15
+ padding-bottom: 4px;
16
+ }
17
+ </style>
18
+
19
+
20
+ <script type="text/javascript">
21
+ // a simple client for interfaceing with the WhatsappService on the backend. Note: this needs to be moved to its own file.
22
+ let waService = {
23
+ unlinkAccount : function(nodeID, localConnectionId){
24
+ return new Promise((resolve, fail) => {
25
+
26
+ let request = JSON.stringify({
27
+ nodeID: nodeID,
28
+ localConnectionId: localConnectionId
29
+ });
30
+
31
+ $.ajax({
32
+ url: "whatsapp/unlinkaccount",
33
+ type: "POST",
34
+ data: request,
35
+ contentType: "application/json; charset=utf-8",
36
+ success: (response) => {
37
+ resolve(response);
38
+ },
39
+ error: (jqXHR, textStatus, errorThrown) => {
40
+ fail(errorThrown);
41
+ }
42
+ });
43
+ });
44
+ },
45
+
46
+ getConnectionDetails:function(nodeID, localConnectionId){
47
+ return new Promise((resolve, fail) => {
48
+ let request = JSON.stringify({
49
+ nodeID: nodeID,
50
+ localConnectionId: localConnectionId
51
+ });
52
+
53
+ $.ajax({
54
+ url: "whatsapp/connectiondetails",
55
+ type: "POST",
56
+ data: request,
57
+ contentType: "application/json; charset=utf-8",
58
+ success: (response) => {
59
+ resolve(response);
60
+ },
61
+ error: (jqXHR, textStatus, errorThrown) => {
62
+ fail(errorThrown);
63
+ }
64
+ });
65
+ });
66
+ },
67
+
68
+ createGroup:function(nodeID, localConnectionId, name){
69
+ console.log("create group! " + name);
70
+
71
+ return new Promise((resolve, fail) => {
72
+ let request = JSON.stringify({
73
+ nodeID: nodeID,
74
+ localConnectionId: localConnectionId,
75
+ groupName: name
76
+ });
77
+
78
+ $.ajax({
79
+ url: "whatsapp/creategroup",
80
+ type: "POST",
81
+ data: request,
82
+ contentType: "application/json; charset=utf-8",
83
+ success: (response) => {
84
+ resolve(response);
85
+ },
86
+ error: (jqXHR, textStatus, errorThrown) => {
87
+ fail(errorThrown);
88
+ }
89
+ });
90
+ });
91
+ },
92
+
93
+ getGroups:function(nodeID, localConnectionId){
94
+ return new Promise((resolve, fail) => {
95
+ let request = JSON.stringify({
96
+ nodeID: nodeID,
97
+ localConnectionId: localConnectionId
98
+ });
99
+
100
+ $.ajax({
101
+ url: "whatsapp/getgroups",
102
+ type: "POST",
103
+ data: request,
104
+ contentType: "application/json; charset=utf-8",
105
+ success: (response) => {
106
+ resolve(response);
107
+ },
108
+ error: (jqXHR, textStatus, errorThrown) => {
109
+ fail(errorThrown);
110
+ }
111
+ });
112
+ });
113
+ },
114
+
115
+ addUserToGroup:function(nodeID, localConnectionId, userId, groupId, role){
116
+ return new Promise((resolve, fail) => {
117
+ let request = JSON.stringify({
118
+ nodeID: nodeID,
119
+ localConnectionId: localConnectionId,
120
+ userId: userId,
121
+ groupId: groupId,
122
+ role:role
123
+ });
124
+
125
+ $.ajax({
126
+ url: "whatsapp/groupadduser",
127
+ type: "POST",
128
+ data: request,
129
+ contentType: "application/json; charset=utf-8",
130
+ success: (response) => {
131
+ resolve(response);
132
+ },
133
+ error: (jqXHR, textStatus, errorThrown) => {
134
+ fail(errorThrown);
135
+ }
136
+ });
137
+ });
138
+ },
139
+
140
+ updateUserInGroup:function(nodeID, localConnectionId, userId, groupId, role){
141
+ console.log("update user in group.");
142
+ return new Promise((resolve, fail) => {
143
+ let request = JSON.stringify({
144
+ nodeID: nodeID,
145
+ localConnectionId: localConnectionId,
146
+ userId: userId,
147
+ groupId: groupId,
148
+ role:role
149
+ });
150
+
151
+ $.ajax({
152
+ url: "whatsapp/groupupdateuser",
153
+ type: "POST",
154
+ data: request,
155
+ contentType: "application/json; charset=utf-8",
156
+ success: (response) => {
157
+ resolve(response);
158
+ },
159
+ error: (jqXHR, textStatus, errorThrown) => {
160
+ fail(errorThrown);
161
+ }
162
+ });
163
+ });
164
+
165
+ },
166
+
167
+ removeUserFromGroup:function(nodeID, localConnectionId, userId, groupId){
168
+ console.log("removeUserFromGroup", nodeID, localConnectionId, userId, groupId);
169
+ return new Promise((resolve, fail) => {
170
+ let request = JSON.stringify({
171
+ nodeID: nodeID,
172
+ localConnectionId: localConnectionId,
173
+ userId: userId,
174
+ groupId: groupId
175
+ });
176
+
177
+ $.ajax({
178
+ url: "whatsapp/groupremoveuser",
179
+ type: "POST",
180
+ data: request,
181
+ contentType: "application/json; charset=utf-8",
182
+ success: (response) => {
183
+ resolve(response);
184
+ },
185
+ error: (jqXHR, textStatus, errorThrown) => {
186
+ fail(errorThrown);
187
+ }
188
+ });
189
+ });
190
+ },
191
+
192
+ getAccounts: function() {
193
+ return new Promise((resolve, fail) => {
194
+ $.ajax({
195
+ url: "whatsapp/accounts",
196
+ type: "GET",
197
+ success: (response) => { resolve(response); },
198
+ error: (jqXHR, textStatus, errorThrown) => { fail(errorThrown); }
199
+ });
200
+ });
201
+ },
202
+ }
203
+ </script>
204
+
205
+
206
+ <!-- WhatsappGroupConfigNode -->
207
+
208
+ <style>
209
+
210
+ </style>
211
+
212
+
213
+ <!-- WhatsappSendMessageNode -->
214
+
215
+ <style>
216
+ .wa-send-action-fields .form-row { margin-bottom: 3px; }
217
+ </style>
218
+ <script>
219
+ var WA_SEND_TYPES = {
220
+ text: {
221
+ label: "Text",
222
+ icon: "fa-font",
223
+ fields: [
224
+ { key: "value", label: "Text", types: ["msg", "flow", "global", "str"] }
225
+ ]
226
+ },
227
+ image: {
228
+ label: "Image",
229
+ icon: "fa-image",
230
+ fields: [
231
+ { key: "value", label: "Image", types: ["msg", "flow", "global"] }
232
+ ]
233
+ },
234
+ video: {
235
+ label: "Video",
236
+ icon: "fa-video-camera",
237
+ fields: [
238
+ { key: "value", label: "Video", types: ["msg", "flow", "global"] }
239
+ ]
240
+ },
241
+ document: {
242
+ label: "Document",
243
+ icon: "fa-file",
244
+ fields: [
245
+ { key: "value", label: "File", types: ["msg", "flow", "global"] },
246
+ { key: "documentName", label: "Name", types: ["msg", "flow", "global", "str"] },
247
+ { key: "documentMimetype", label: "Mime Type", types: ["msg", "flow", "global", "str"] }
248
+ ]
249
+ }
250
+ };
251
+ </script>
252
+
253
+
254
+ <!-- logger -->
255
+
12
256
  <style>
13
- .theotherwillembotha-compact-table li {
14
- padding-top: 4px;
15
- padding-bottom: 4px;
257
+ .editorgroupborder {
258
+ border-width:1px;
259
+ border-style:solid;
260
+ border-color:lightgray;
261
+ padding: 3px;
262
+ margin-top: 2px;
263
+ margin-bottom: 2px;
264
+ }
265
+
266
+ .editorsectionheading {
267
+ font-weight: 600;
268
+ margin-bottom:1px !important;
269
+ margin-top:6px !important;
270
+ }
271
+
272
+ .nomargin {
273
+ margin-bottom:1px !important;
274
+ margin-top:1px !important;
275
+ }
276
+
277
+ .slidecontainer {
278
+ width: 100%;
279
+ }
280
+
281
+ .slider {
282
+ -webkit-appearance: none;
283
+ appearance: none;
284
+ width: 100%;
285
+ height: 25px;
286
+ background: #d3d3d3;
287
+ outline: none;
288
+ opacity: 0.7;
289
+ -webkit-transition: .2s;
290
+ transition: opacity .2s;
291
+ }
292
+
293
+ .slider:hover {
294
+ opacity: 1;
295
+ }
296
+
297
+ .slider::-webkit-slider-thumb {
298
+ -webkit-appearance: none;
299
+ appearance: none;
300
+ width: 25px;
301
+ height: 25px;
302
+ background: #04AA6D;
303
+ cursor: pointer;
304
+ }
305
+
306
+ .slider::-moz-range-thumb {
307
+ width: 25px;
308
+ height: 25px;
309
+ background: #04AA6D;
310
+ cursor: pointer;
311
+ }
312
+
313
+ .towb_editorlabel {
314
+ width: 120px !important;
315
+ }
316
+ input.towb_editorfield, select.towb_editorfield, textarea.towb_editorfield {
317
+ width: 70% !important;
318
+ }
319
+ input.towb_editorfield_short, select.towb_editorfield_short {
320
+ width: 30% !important;
16
321
  }
17
322
 
18
323
  </style>
324
+
19
325
  <script type="text/javascript">
20
- // a simple client for interfaceing with the WhatsappService on the backend. Note: this needs to be moved to its own file.
21
- let waService = {
22
- unlinkAccount: function(nodeID, localConnectionId) {
23
- return new Promise((resolve, fail) => {
24
- let request = JSON.stringify({
25
- nodeID: nodeID,
26
- localConnectionId: localConnectionId
27
- });
28
- $.ajax({
29
- url: "whatsapp/unlinkaccount",
30
- type: "POST",
31
- data: request,
32
- contentType: "application/json; charset=utf-8",
33
- success: (response) => {
34
- resolve(response);
35
- },
36
- error: (jqXHR, textStatus, errorThrown) => {
37
- fail(errorThrown);
38
- }
39
- });
40
- });
41
- },
42
- getConnectionDetails: function(nodeID, localConnectionId) {
43
- return new Promise((resolve, fail) => {
44
- let request = JSON.stringify({
45
- nodeID: nodeID,
46
- localConnectionId: localConnectionId
47
- });
48
- $.ajax({
49
- url: "whatsapp/connectiondetails",
50
- type: "POST",
51
- data: request,
52
- contentType: "application/json; charset=utf-8",
53
- success: (response) => {
54
- resolve(response);
55
- },
56
- error: (jqXHR, textStatus, errorThrown) => {
57
- fail(errorThrown);
58
- }
59
- });
60
- });
61
- },
62
- createGroup: function(nodeID, localConnectionId, name) {
63
- console.log("create group! " + name);
64
- return new Promise((resolve, fail) => {
65
- let request = JSON.stringify({
66
- nodeID: nodeID,
67
- localConnectionId: localConnectionId,
68
- groupName: name
69
- });
70
- $.ajax({
71
- url: "whatsapp/creategroup",
72
- type: "POST",
73
- data: request,
74
- contentType: "application/json; charset=utf-8",
75
- success: (response) => {
76
- resolve(response);
77
- },
78
- error: (jqXHR, textStatus, errorThrown) => {
79
- fail(errorThrown);
80
- }
81
- });
82
- });
83
- },
84
- getGroups: function(nodeID, localConnectionId) {
85
- return new Promise((resolve, fail) => {
86
- let request = JSON.stringify({
87
- nodeID: nodeID,
88
- localConnectionId: localConnectionId
89
- });
90
- $.ajax({
91
- url: "whatsapp/getgroups",
92
- type: "POST",
93
- data: request,
94
- contentType: "application/json; charset=utf-8",
95
- success: (response) => {
96
- resolve(response);
97
- },
98
- error: (jqXHR, textStatus, errorThrown) => {
99
- fail(errorThrown);
100
- }
101
- });
102
- });
103
- },
104
- addUserToGroup: function(nodeID, localConnectionId, userId, groupId, role) {
105
- return new Promise((resolve, fail) => {
106
- let request = JSON.stringify({
107
- nodeID: nodeID,
108
- localConnectionId: localConnectionId,
109
- userId: userId,
110
- groupId: groupId,
111
- role: role
112
- });
113
- $.ajax({
114
- url: "whatsapp/groupadduser",
115
- type: "POST",
116
- data: request,
117
- contentType: "application/json; charset=utf-8",
118
- success: (response) => {
119
- resolve(response);
326
+
327
+ // CSS
328
+ dropdownStyles = `
329
+ .loggertype-dropdown {
330
+ position: absolute;
331
+ background: white;
332
+ border: 1px solid #ccc;
333
+ border-radius: 4px;
334
+ box-shadow: 0 2px 8px rgba(0,0,0,0.15);
335
+ max-height: 150px;
336
+ overflow-y: auto;
337
+ z-index: 1000;
338
+ min-width: 150px;
339
+ }
340
+ .loggertype-dropdown .dropdown-item {
341
+ padding: 6px 12px;
342
+ cursor: pointer;
343
+ }
344
+ .loggertype-dropdown .dropdown-item:hover {
345
+ background: #f0f0f0;
346
+ }
347
+ `;
348
+ $('<style>').text(dropdownStyles).appendTo('head');
349
+
350
+ </script>
351
+
352
+
353
+
354
+
355
+ <!-- WhatsappDynamicSendMessageNode -->
356
+
357
+ <style>
358
+ .wa-dynamic-send-action-fields .form-row { margin-bottom: 3px; }
359
+ </style>
360
+ <script>
361
+ // WA_SEND_TYPES is defined by WhatsappSendMessageNode.html (included in the same page).
362
+ // Guard against load-order variation with a var declaration — var re-declarations are safe.
363
+ if (typeof WA_SEND_TYPES === "undefined") {
364
+ var WA_SEND_TYPES = {
365
+ text: {
366
+ label: "Text",
367
+ icon: "fa-font",
368
+ fields: [
369
+ { key: "value", label: "Text", types: ["msg", "flow", "global", "str"] }
370
+ ]
120
371
  },
121
- error: (jqXHR, textStatus, errorThrown) => {
122
- fail(errorThrown);
123
- }
124
- });
125
- });
126
- },
127
- updateUserInGroup: function(nodeID, localConnectionId, userId, groupId, role) {
128
- console.log("update user in group.");
129
- return new Promise((resolve, fail) => {
130
- let request = JSON.stringify({
131
- nodeID: nodeID,
132
- localConnectionId: localConnectionId,
133
- userId: userId,
134
- groupId: groupId,
135
- role: role
136
- });
137
- $.ajax({
138
- url: "whatsapp/groupupdateuser",
139
- type: "POST",
140
- data: request,
141
- contentType: "application/json; charset=utf-8",
142
- success: (response) => {
143
- resolve(response);
372
+ image: {
373
+ label: "Image",
374
+ icon: "fa-image",
375
+ fields: [
376
+ { key: "value", label: "Image", types: ["msg", "flow", "global"] }
377
+ ]
144
378
  },
145
- error: (jqXHR, textStatus, errorThrown) => {
146
- fail(errorThrown);
147
- }
148
- });
149
- });
150
- },
151
- removeUserFromGroup: function(nodeID, localConnectionId, userId, groupId) {
152
- console.log("removeUserFromGroup", nodeID, localConnectionId, userId, groupId);
153
- return new Promise((resolve, fail) => {
154
- let request = JSON.stringify({
155
- nodeID: nodeID,
156
- localConnectionId: localConnectionId,
157
- userId: userId,
158
- groupId: groupId
159
- });
160
- $.ajax({
161
- url: "whatsapp/groupremoveuser",
162
- type: "POST",
163
- data: request,
164
- contentType: "application/json; charset=utf-8",
165
- success: (response) => {
166
- resolve(response);
379
+ video: {
380
+ label: "Video",
381
+ icon: "fa-video-camera",
382
+ fields: [
383
+ { key: "value", label: "Video", types: ["msg", "flow", "global"] }
384
+ ]
167
385
  },
168
- error: (jqXHR, textStatus, errorThrown) => {
169
- fail(errorThrown);
386
+ document: {
387
+ label: "Document",
388
+ icon: "fa-file",
389
+ fields: [
390
+ { key: "value", label: "File", types: ["msg", "flow", "global"] },
391
+ { key: "documentName", label: "Name", types: ["msg", "flow", "global", "str"] },
392
+ { key: "documentMimetype", label: "Mime Type", types: ["msg", "flow", "global", "str"] }
393
+ ]
170
394
  }
171
- });
172
- });
173
- },
395
+ };
174
396
  }
397
+ </script>
175
398
 
176
- </script>
399
+
400
+ <!-- WhatsappReceiveMessageNode -->
401
+
402
+ <style>
403
+ </style>
404
+ <script>
405
+ const WA_ACCEPT_FIELDS = [
406
+ { id: "Text", icon: "fa-font", label: "Text", defaultEnabled: true },
407
+ { id: "ExtendedText", icon: "fa-align-left", label: "Extended Text", defaultEnabled: true },
408
+ { id: "Image", icon: "fa-image", label: "Image", defaultEnabled: true },
409
+ { id: "Video", icon: "fa-video-camera", label: "Video", defaultEnabled: true },
410
+ { id: "Album", icon: "fa-th-large", label: "Album", defaultEnabled: true },
411
+ { id: "Document", icon: "fa-file", label: "Document", defaultEnabled: false },
412
+ { id: "Contact", icon: "fa-address-card", label: "Contact", defaultEnabled: false },
413
+ { id: "Template", icon: "fa-th-list", label: "Template", defaultEnabled: false },
414
+ { id: "Location", icon: "fa-map-marker", label: "Location", defaultEnabled: false },
415
+ { id: "Event", icon: "fa-calendar", label: "Event", defaultEnabled: false },
416
+ { id: "EventResponse", icon: "fa-calendar-check-o", label: "Event Response", defaultEnabled: false },
417
+ { id: "Sticker", icon: "fa-sticky-note-o", label: "Sticker", defaultEnabled: false },
418
+ ];
419
+ </script>
420
+
421
+
177
422
  <script type="text/javascript">
178
423
  RED.nodes.registerType('WhatsappAccountConfigNode', {
179
424
  category: 'config',
@@ -199,8 +444,9 @@
199
444
  {
200
445
  let node = this;
201
446
  let $btn = $("#wa-account-link-btn");
447
+ let isLinked = document.getElementById("node-config-input-accountLinked").checked;
202
448
  // if the account is already linked, show the Unlink state
203
- if (document.getElementById("node-config-input-accountLinked").checked) {
449
+ if (isLinked) {
204
450
  $btn.html('<i class="fa fa-unlink"></i> Unlink Account');
205
451
  }
206
452
  // if we don't have a local connectionId for this node, generate one.
@@ -208,10 +454,44 @@
208
454
  $("#node-config-input-localConnectionId").val(crypto.randomUUID());
209
455
  }
210
456
  let localConnectionId = $("#node-config-input-localConnectionId").val();
211
- $btn.on("click", () => {
457
+ // --- Claim Account section ---
458
+ // Only offer claiming if this node is not already linked.
459
+ if (!isLinked) {
460
+ waService.getAccounts().then(function(accounts) {
461
+ let unclaimed = accounts.filter(function(a) {
462
+ return !a.claimed;
463
+ });
464
+ if (unclaimed.length === 0) return;
465
+ let $claimSelect = $("#wa-claim-select");
466
+ $claimSelect.empty();
467
+ unclaimed.forEach(function(account) {
468
+ let label = account.name && account.phoneNumber ? account.name + " (+" + account.phoneNumber + ")" : account.name || account.phoneNumber || account.key;
469
+ if (!account.connected) label += " \u26a0 disconnected";
470
+ $('<option>').val(account.key).text(label).appendTo($claimSelect);
471
+ });
472
+ $("#wa-claim-section").show();
473
+ $("#wa-or-separator").show();
474
+ }).catch(function() {
475
+ /* backend not ready — ignore */ });
476
+ }
477
+ $("#wa-claim-btn").on("click", function() {
478
+ let claimedKey = $("#wa-claim-select").val();
479
+ if (!claimedKey) return;
480
+ // adopt the claimed account's localConnectionId and mark as linked
481
+ $("#node-config-input-localConnectionId").val(claimedKey);
482
+ $("#node-config-input-accountLinked").prop("checked", true);
483
+ $("#wa-claim-section").hide();
484
+ $("#wa-or-separator").hide();
485
+ $btn.html('<i class="fa fa-unlink"></i> Unlink Account');
486
+ $("#wa-account-qrcode").html("<p>\u2714 Account claimed. Deploy to activate.</p>");
487
+ });
488
+ // --- Link Account button ---
489
+ $btn.on("click", function() {
212
490
  $btn.prop("disabled", true);
491
+ // re-read localConnectionId in case it was overridden by a claim
492
+ localConnectionId = $("#node-config-input-localConnectionId").val();
213
493
  if (document.getElementById("node-config-input-accountLinked").checked) {
214
- waService.unlinkAccount(node.id, localConnectionId).then(response => {
494
+ waService.unlinkAccount(node.id, localConnectionId).then(function(response) {
215
495
  $btn.prop("disabled", false);
216
496
  $btn.html('<i class="fa fa-link"></i> Link Account');
217
497
  document.getElementById("node-config-input-accountLinked").checked = false;
@@ -283,6 +563,21 @@
283
563
  <input type="checkbox" id="node-config-input-accountLinked">
284
564
  </div>
285
565
 
566
+ <div class="form-row" id="wa-claim-section" style="display:none">
567
+ <label><i class="fa fa-mobile"></i> Claim</label>
568
+ <div style="width:70%; display:inline-flex; align-items:center;">
569
+ <select id="wa-claim-select" style="flex-grow:1; min-width:0;"></select>
570
+ <button type="button" id="wa-claim-btn" class="red-ui-button" style="margin-left:8px; flex-shrink:0;">
571
+ <i class="fa fa-check"></i> Claim
572
+ </button>
573
+ </div>
574
+ </div>
575
+
576
+ <div class="form-row" id="wa-or-separator" style="display:none">
577
+ <label></label>
578
+ <span style="color:#aaa; font-style:italic;">— or link a new account —</span>
579
+ </div>
580
+
286
581
  <div class="form-row">
287
582
  <label></label>
288
583
  <button type="button" id="wa-account-link-btn" class="red-ui-button">
@@ -318,9 +613,7 @@ To unlink the device, open the editor and click **Unlink Account**, then remove
318
613
 
319
614
  Credentials are persisted to `/data/whatsapp/<localConnectionId>/` if the `/data` directory exists (standard Node-RED Docker image), otherwise to `./whatsapp/<localConnectionId>/` relative to the Node-RED working directory.
320
615
  </script>
321
- <!-- WhatsappGroupConfigNode -->
322
- <style>
323
- </style>
616
+
324
617
  <script type="text/javascript">
325
618
  RED.nodes.registerType('WhatsappGroupConfigNode', {
326
619
  category: 'config',
@@ -591,8 +884,9 @@ Credentials are persisted to `/data/whatsapp/<localConnectionId>/` if the `/data
591
884
  options: []
592
885
  }]
593
886
  });
594
- // set initial button state
887
+ // set initial button state and load groups for the already-selected account
595
888
  setCreateButtonEnabled(accountConfigInput.val());
889
+ updateGroupList(accountConfigInput.val());
596
890
  // show inline create section
597
891
  $("#wa-group-new-btn").on("click", () => {
598
892
  $("#wa-group-new-row").hide();
@@ -705,8 +999,6 @@ Credentials are persisted to `/data/whatsapp/<localConnectionId>/` if the `/data
705
999
  </div>
706
1000
  </div>
707
1001
 
708
-
709
-
710
1002
  </div>
711
1003
  </script>
712
1004
 
@@ -725,119 +1017,7 @@ References a WhatsApp group within a linked account. Used as the target for Send
725
1017
 
726
1018
  Click **Create New Group** to create a new WhatsApp group from within Node-RED. The new group will appear in the group dropdown once created.
727
1019
  </script>
728
- <!-- WhatsappSendMessageNode -->
729
- <style>
730
- </style>
731
- <script>
732
- const WA_SEND_FIELDS = [{
733
- id: "text",
734
- icon: "fa-font",
735
- label: "Send Text",
736
- types: ["msg", "flow", "global", "str"]
737
- }, {
738
- id: "image",
739
- icon: "fa-image",
740
- label: "Send Image",
741
- types: ["msg", "flow", "global"]
742
- }, ];
743
-
744
- </script>
745
- <!-- logger -->
746
- <style>
747
- .editorgroupborder {
748
- border-width: 1px;
749
- border-style: solid;
750
- border-color: lightgray;
751
- padding: 3px;
752
- margin-top: 2px;
753
- margin-bottom: 2px;
754
- }
755
-
756
- .editorsectionheading {
757
- font-weight: 600;
758
- margin-bottom: 1px !important;
759
- margin-top: 6px !important;
760
- }
761
-
762
- .nomargin {
763
- margin-bottom: 1px !important;
764
- margin-top: 1px !important;
765
- }
766
-
767
- .slidecontainer {
768
- width: 100%;
769
- }
770
-
771
- .slider {
772
- -webkit-appearance: none;
773
- appearance: none;
774
- width: 100%;
775
- height: 25px;
776
- background: #d3d3d3;
777
- outline: none;
778
- opacity: 0.7;
779
- -webkit-transition: .2s;
780
- transition: opacity .2s;
781
- }
782
-
783
- .slider:hover {
784
- opacity: 1;
785
- }
786
-
787
- .slider::-webkit-slider-thumb {
788
- -webkit-appearance: none;
789
- appearance: none;
790
- width: 25px;
791
- height: 25px;
792
- background: #04AA6D;
793
- cursor: pointer;
794
- }
795
-
796
- .slider::-moz-range-thumb {
797
- width: 25px;
798
- height: 25px;
799
- background: #04AA6D;
800
- cursor: pointer;
801
- }
802
-
803
- .towb_editorlabel {
804
- width: 120px !important;
805
- }
806
-
807
- .towb_editorfield {
808
- width: 70% !important;
809
- }
810
-
811
- .towb_editorfield_short {
812
- width: 30% !important;
813
- }
814
-
815
- </style>
816
- <script type="text/javascript">
817
- // CSS
818
- dropdownStyles = `
819
- .loggertype-dropdown {
820
- position: absolute;
821
- background: white;
822
- border: 1px solid #ccc;
823
- border-radius: 4px;
824
- box-shadow: 0 2px 8px rgba(0,0,0,0.15);
825
- max-height: 150px;
826
- overflow-y: auto;
827
- z-index: 1000;
828
- min-width: 150px;
829
- }
830
- .loggertype-dropdown .dropdown-item {
831
- padding: 6px 12px;
832
- cursor: pointer;
833
- }
834
- .loggertype-dropdown .dropdown-item:hover {
835
- background: #f0f0f0;
836
- }
837
- `;
838
- $('<style>').text(dropdownStyles).appendTo('head');
839
1020
 
840
- </script>
841
1021
  <script type="text/javascript">
842
1022
  RED.nodes.registerType('WhatsappSendMessageNode', {
843
1023
  category: 'whatsapp',
@@ -865,10 +1045,8 @@ Click **Create New Group** to create a new WhatsApp group from within Node-RED.
865
1045
  required: false,
866
1046
  validate: function(value) {
867
1047
  try {
868
- var payloads = JSON.parse(value || "[]");
869
- return payloads.every(function(p) {
870
- return !p.enabled || (p.value !== undefined && p.value !== "");
871
- });
1048
+ JSON.parse(value || "[]");
1049
+ return true;
872
1050
  } catch (e) {
873
1051
  return false;
874
1052
  }
@@ -894,59 +1072,91 @@ Click **Create New Group** to create a new WhatsApp group from within Node-RED.
894
1072
  required: true
895
1073
  },
896
1074
  metricsReference: {
897
- type: 'MetricsConfigNode',
898
- required: false
1075
+ required: false,
1076
+ value: '',
1077
+ type: 'DelegatedConfigReferenceNode'
899
1078
  },
900
1079
  },
901
1080
  oneditprepare: function() {
902
1081
  // **** WhatsappSendMessageNode **** //
903
1082
  {
904
1083
  let node = this;
905
- let payloads = [];
1084
+ let actionList = $("#wa-send-actions-list");
1085
+ actionList.editableList({
1086
+ sortable: true,
1087
+ removable: true,
1088
+ height: "auto",
1089
+ scrollOnAdd: true,
1090
+ addButton: "Add Message",
1091
+ addItem: function(container, i, action) {
1092
+ action = action || {};
1093
+ let type = action.type || "text";
1094
+ container.css({
1095
+ padding: "6px 4px"
1096
+ });
1097
+ // ── Type row ──────────────────────────────────────────────────────
1098
+ let typeRow = $('<div style="display:flex; align-items:center; gap:8px; margin-bottom:5px;">');
1099
+ let iconEl = $('<i style="width:14px; text-align:center; flex-shrink:0;">').addClass("fa " + (WA_SEND_TYPES[type] ? WA_SEND_TYPES[type].icon : "fa-comment"));
1100
+ let typeSelect = $('<select class="wa-send-action-type" style="flex:0 0 auto;">');
1101
+ Object.keys(WA_SEND_TYPES).forEach(function(key) {
1102
+ typeSelect.append($("<option>").val(key).text(WA_SEND_TYPES[key].label));
1103
+ });
1104
+ typeSelect.val(type);
1105
+ typeRow.append(iconEl).append(typeSelect);
1106
+ container.append(typeRow);
1107
+ // ── Fields container ──────────────────────────────────────────────
1108
+ let fieldsContainer = $('<div class="wa-send-action-fields">');
1109
+ container.append(fieldsContainer);
1110
+ let renderFields = function(selectedType) {
1111
+ fieldsContainer.empty();
1112
+ let typeDef = WA_SEND_TYPES[selectedType];
1113
+ if (!typeDef) return;
1114
+ iconEl.removeClass().addClass("fa " + typeDef.icon);
1115
+ typeDef.fields.forEach(function(field) {
1116
+ let fieldRow = $('<div class="form-row nomargin" style="display:flex; align-items:center; gap:6px;">');
1117
+ fieldRow.append($('<label style="width:70px; margin:0; font-size:0.85em; flex-shrink:0;">').text(field.label));
1118
+ let input = $('<input type="text">').addClass("wa-send-field-" + field.key);
1119
+ fieldRow.append(input);
1120
+ fieldsContainer.append(fieldRow);
1121
+ input.typedInput({
1122
+ types: field.types
1123
+ }).typedInput("type", action[field.key + "Type"] || field.types[0]).typedInput("value", action[field.key] || "");
1124
+ fieldRow.find(".red-ui-typedInput-container").css({
1125
+ flex: "1",
1126
+ "min-width": "0",
1127
+ width: ""
1128
+ });
1129
+ });
1130
+ };
1131
+ renderFields(type);
1132
+ typeSelect.on("change", function() {
1133
+ // Reset saved values when the type changes so stale data isn't carried over.
1134
+ action = {
1135
+ type: $(this).val()
1136
+ };
1137
+ renderFields($(this).val());
1138
+ });
1139
+ }
1140
+ });
1141
+ let actions = [];
906
1142
  try {
907
- payloads = JSON.parse(node.payloads || "[]");
1143
+ actions = JSON.parse(node.payloads || "[]");
908
1144
  } catch (e) {}
909
- WA_SEND_FIELDS.forEach(function(field) {
910
- let saved = payloads.find(function(p) {
911
- return p.id === field.id;
912
- }) || {
913
- enabled: false,
914
- type: field.types[0],
915
- value: ""
916
- };
917
- let row = $('<div class="form-row nomargin">' + '<label style="width:120px;"><i class="fa ' + field.icon + '"></i> ' + field.label + '</label>' + '<div id="wa-send-row-' + field.id + '" style="display:inline-flex; align-items:center; gap:6px; vertical-align:middle; width:calc(100% - 128px);">' + '<input type="checkbox" id="wa-send-enabled-' + field.id + '" style="width:auto; margin:0; flex-shrink:0;">' + '<input type="text" id="wa-send-value-' + field.id + '">' + '</div>' + '</div>');
918
- $("#wa-send-payload-rows").append(row);
919
- $("#wa-send-value-" + field.id).typedInput({
920
- types: field.types
921
- }).typedInput("type", saved.type).typedInput("value", saved.value);
922
- // typedInput inserts its container as a sibling of the (now hidden) input, making it
923
- // a direct flex child — apply flex:1 so it expands to fill the remaining row width.
924
- $("#wa-send-row-" + field.id + " .red-ui-typedInput-container").css({
925
- "flex": "1",
926
- "min-width": "0",
927
- "width": ""
928
- });
929
- $("#wa-send-enabled-" + field.id).prop("checked", saved.enabled);
930
- // live validation: highlight the typedInput when checkbox is checked but value is empty.
931
- var checkFieldValid = function() {
932
- var enabled = $("#wa-send-enabled-" + field.id).prop("checked");
933
- var value = $("#wa-send-value-" + field.id).typedInput("value");
934
- var invalid = enabled && (!value || value === "");
935
- $("#wa-send-row-" + field.id + " .red-ui-typedInput-container").toggleClass("input-error", invalid);
936
- };
937
- $("#wa-send-enabled-" + field.id).on("change", checkFieldValid);
938
- $("#wa-send-value-" + field.id).on("change", checkFieldValid);
939
- checkFieldValid();
940
- });
1145
+ if (actions.length > 0) {
1146
+ actionList.editableList("addItems", actions);
1147
+ }
941
1148
  }
942
1149
  // **** logger **** //
943
1150
  {
944
1151
  let node = this;
1152
+ let templateConfig = {};
945
1153
  // controls.
946
1154
  let logEnabled = $("#node-input-logEnabled");
947
1155
  let logTemplateEnabled = $("#node-input-logTemplateOverrideEnabled");
948
1156
  let logTemplateEnabledSection = $("#section_logTemplateOverrideEnabled");
949
1157
  let logEnabledSection = $("#section_logEnabled");
1158
+ let loggerSection = $("#section_loggerconfig");
1159
+ loggerSection.hide();
950
1160
  // functions.
951
1161
  let getLoggerTypes = async function() {
952
1162
  let types = [];
@@ -1070,43 +1280,756 @@ Click **Create New Group** to create a new WhatsApp group from within Node-RED.
1070
1280
  loggerEditButton.removeClass("disabled")
1071
1281
  }
1072
1282
  });
1073
- // lastly, make sure that we set the correct value on the logger selector.
1074
- updateLoggerSelectorList().then(() => {
1075
- loggerSelector.val(node.logger);
1076
- loggerSelector.trigger('change');
1283
+ // Show the logger section only if at least one logger type is registered.
1284
+ // If nodered_logging (or any other logger provider) is not installed, the section stays hidden.
1285
+ getLoggerTypes().then(loggerTypes => {
1286
+ if (loggerTypes.length === 0) return;
1287
+ loggerSection.show();
1288
+ updateLoggerSelectorList().then(() => {
1289
+ loggerSelector.val(node.logger);
1290
+ loggerSelector.trigger('change');
1291
+ });
1292
+ });
1293
+ }
1294
+ // **** metrics **** //
1295
+ {
1296
+ let node = this;
1297
+ let templateConfig = {};
1298
+ let metricsEnabled = $("#node-input-metricsEnabled");
1299
+ let metricsSection = $("#section_metricsconfig");
1300
+ let metricsContainer = metricsEnabled.closest(".editorgroupborder");
1301
+ let metricsEnabledSection = metricsContainer.find("#section_metricsEnabled");
1302
+ metricsSection.hide();
1303
+ let getMetricsProviderTypes = async function() {
1304
+ let types = [];
1305
+ await $.ajax({
1306
+ url: "nodetypeservice/find?tag=MetricsProvider",
1307
+ type: "GET",
1308
+ contentType: "application/json; charset=utf-8",
1309
+ success: (response) => {
1310
+ types = response;
1311
+ },
1312
+ error: (jqXHR, textStatus, errorThrown) => {
1313
+ console.log(jqXHR, textStatus, errorThrown);
1314
+ },
1315
+ });
1316
+ return types;
1317
+ };
1318
+ let getMetricsProviders = async () => {
1319
+ let providers = [];
1320
+ let providerTypes = (await getMetricsProviderTypes()).map(t => t.id);
1321
+ RED.nodes.eachConfig(cfg => {
1322
+ if (providerTypes.includes(cfg.type)) {
1323
+ providers.push({
1324
+ id: cfg.id,
1325
+ name: cfg.label(),
1326
+ type: cfg.type
1327
+ });
1328
+ }
1329
+ });
1330
+ return providers;
1331
+ };
1332
+ let updateMetricsSelectorList = async () => {
1333
+ metricsSelector.empty();
1334
+ (await getMetricsProviders()).forEach(provider => {
1335
+ $('<option value="' + provider.id + '">' + provider.name + '</option>').appendTo(metricsSelector);
1336
+ });
1337
+ $('<option value="_ADD_">none</option>').appendTo(metricsSelector);
1338
+ };
1339
+ metricsEnabled.on('change', function() {
1340
+ if (metricsEnabled.prop("checked")) {
1341
+ metricsEnabledSection.show();
1342
+ } else {
1343
+ metricsEnabledSection.hide();
1344
+ }
1345
+ });
1346
+ let metricsSelector = metricsContainer.find("#metrics-selector");
1347
+ let metricsEditButton = metricsContainer.find("#metrics-edit-btn");
1348
+ let metricsAddButton = metricsContainer.find("#metrics-add-btn");
1349
+ metricsEditButton.on('click', async function(event) {
1350
+ event.stopPropagation();
1351
+ if (metricsEditButton.hasClass("disabled")) return;
1352
+ let selectedProvider = (await getMetricsProviders()).find(p => p.id === metricsSelector.val());
1353
+ if (selectedProvider) {
1354
+ RED.editor.editConfig("#metrics-selector", selectedProvider.type, selectedProvider.id);
1355
+ }
1356
+ });
1357
+ metricsAddButton.on('click', function(event) {
1358
+ event.stopPropagation();
1359
+ let $btn = $(event.currentTarget);
1360
+ let btnOffset = $btn.offset();
1361
+ let btnH = $btn.outerHeight();
1362
+ let btnW = $btn.outerWidth();
1363
+ $('.metricstype-dropdown').remove();
1364
+ getMetricsProviderTypes().then(providerTypes => {
1365
+ let $dropdown = $('<div class="metricstype-dropdown red-ui-editor"></div>').css({
1366
+ position: 'absolute',
1367
+ zIndex: 1000
1368
+ });
1369
+ providerTypes.forEach(providerType => {
1370
+ $('<div class="dropdown-item"></div>').text(providerType.name).data('value', providerType.id).css({
1371
+ padding: '4px 8px',
1372
+ cursor: 'pointer'
1373
+ }).appendTo($dropdown);
1374
+ });
1375
+ $('body').append($dropdown);
1376
+ $dropdown.css({
1377
+ top: btnOffset.top + btnH,
1378
+ left: Math.max(0, btnOffset.left + btnW - $dropdown.outerWidth())
1379
+ });
1380
+ $(document).on('mousedown.metricsdropdown', function(e) {
1381
+ if (!$(e.target).closest('.metricstype-dropdown').length) {
1382
+ $dropdown.remove();
1383
+ $(document).off('mousedown.metricsdropdown');
1384
+ }
1385
+ });
1386
+ $dropdown.on('click', '.dropdown-item', function(e) {
1387
+ e.stopPropagation();
1388
+ const selectedValue = $(this).data('value');
1389
+ $dropdown.remove();
1390
+ RED.editor.editConfig("#metrics-selector", selectedValue, "_ADD_");
1391
+ });
1392
+ });
1393
+ });
1394
+ metricsSelector.on("focus", updateMetricsSelectorList);
1395
+ metricsSelector.on('change', function() {
1396
+ if ("_ADD_" === metricsSelector.val()) {
1397
+ metricsEditButton.addClass("disabled");
1398
+ } else {
1399
+ metricsEditButton.removeClass("disabled");
1400
+ }
1401
+ });
1402
+ // Show the metrics section only if at least one metrics provider type is registered.
1403
+ // If nodered_prometheus (or any other provider) is not installed, the section stays hidden.
1404
+ getMetricsProviderTypes().then(providerTypes => {
1405
+ if (providerTypes.length === 0) return;
1406
+ metricsSection.show();
1407
+ updateMetricsSelectorList().then(() => {
1408
+ metricsSelector.val(node.metricsReference);
1409
+ metricsSelector.trigger('change');
1410
+ });
1411
+ });
1412
+ }
1413
+ },
1414
+ oneditsave: function() {
1415
+ // **** WhatsappSendMessageNode **** //
1416
+ {
1417
+ let actions = [];
1418
+ $("#wa-send-actions-list").editableList("items").each(function() {
1419
+ let container = $(this);
1420
+ let type = container.find(".wa-send-action-type").val();
1421
+ let typeDef = WA_SEND_TYPES[type];
1422
+ if (!typeDef) return;
1423
+ let action = {
1424
+ type: type
1425
+ };
1426
+ typeDef.fields.forEach(function(field) {
1427
+ let input = container.find(".wa-send-field-" + field.key);
1428
+ if (input.length) {
1429
+ action[field.key] = input.typedInput("value");
1430
+ action[field.key + "Type"] = input.typedInput("type");
1431
+ }
1432
+ });
1433
+ actions.push(action);
1434
+ });
1435
+ $("#node-input-payloads").val(JSON.stringify(actions));
1436
+ }
1437
+ // **** logger **** //
1438
+ {
1439
+ let node = this;
1440
+ let templateConfig = {};
1441
+ node = this;
1442
+ var selectedLogger = $("#logger-selector").val();
1443
+ node.logger = (selectedLogger && selectedLogger !== "_ADD_") ? selectedLogger : '';
1444
+ node.logTemplateOverride = node.logTemplateOverrideEditor.getValue();
1445
+ node.logTemplateOverrideEditor.destroy();
1446
+ delete node.logTemplateOverrideEditor;
1447
+ }
1448
+ // **** metrics **** //
1449
+ {
1450
+ let node = this;
1451
+ let templateConfig = {};
1452
+ node = this;
1453
+ var selectedProvider = $("#metrics-selector").val();
1454
+ node.metricsReference = (selectedProvider && selectedProvider !== "_ADD_") ? selectedProvider : '';
1455
+ }
1456
+ },
1457
+ oneditcancel: function() {},
1458
+ oneditdelete: function() {},
1459
+ });
1460
+
1461
+ </script>
1462
+
1463
+
1464
+ <script type="text/html" data-template-name='WhatsappSendMessageNode'>
1465
+ <div id='section_WhatsappSendMessageNode'>
1466
+ <div class="form-row">
1467
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
1468
+ <input type="text" id="node-input-name" />
1469
+ </div>
1470
+
1471
+ <div class="form-row nomargin">
1472
+ <label for="node-input-groupConfig" style="width:120px;"><i class="fa fa-users"></i> Group</label>
1473
+ <input type="text" id="node-input-groupConfig" placeholder="config">
1474
+ </div>
1475
+
1476
+ <div class="form-row editorsectionheading">
1477
+ <i class="w-16 fa fa-paper-plane"></i> <span>Messages</span>
1478
+ </div>
1479
+
1480
+ <div class="editorgroupborder">
1481
+ <ol id="wa-send-actions-list"></ol>
1482
+ </div>
1483
+
1484
+ <input type="hidden" id="node-input-payloads">
1485
+
1486
+
1487
+ </div>
1488
+
1489
+ <div id='section_logger'>
1490
+ <div id="section_loggerconfig">
1491
+ <div class="form-row editorsectionheading">
1492
+ <i class="w-16 fa fa-eye"></i> <span>Logging</span>
1493
+ </div>
1494
+ <div class="editorgroupborder">
1495
+ <div class="form-row nomargin logEnableCheck">
1496
+ <input type="checkbox" id="node-input-logEnabled" placeholder="logEnabled" value="false" style="margin:8px 0 10px 102px; width:20px;">
1497
+ <label style="width:auto" for="node-input-logEnabled"><span>Enable Logging</span></label>
1498
+ </div>
1499
+
1500
+ <div id="section_logEnabled">
1501
+ <div class="form-row nomargin">
1502
+ <label for="logger-selector">Logger</label>
1503
+ <div style="width: 70%; display: inline-flex;">
1504
+ <select id="logger-selector" style="flex-grow: 1;" class="">
1505
+ <option value="_ADD_">none</option>
1506
+ </select>
1507
+ <a id="logger-edit-btn" class="red-ui-button disabled" style="margin-left: 10px;">
1508
+ <i class="fa fa-pencil"></i>
1509
+ </a>
1510
+ <a id="logger-add-btn" class="red-ui-button" style="margin-left: 10px;">
1511
+ <i class="fa fa-plus"></i>
1512
+ </a>
1513
+ </div>
1514
+ </div>
1515
+
1516
+ <div class="form-row nomargin">
1517
+ <input type="checkbox" id="node-input-logTemplateOverrideEnabled" placeholder="" value="false" style="margin:8px 0 10px 102px; width:20px;">
1518
+ <label style="width:auto" for="node-input-logTemplateOverrideEnabled"><span>Override Template</span></label>
1519
+ </div>
1520
+
1521
+ <div id="section_logTemplateOverrideEnabled">
1522
+ <div class="form-row">
1523
+ <label class="towb_editorlabel" for="node-input-logTemplateOverrideEditor"><i class="fa fa-code"></i> Template</label>
1524
+ <div style="height: 250px; min-height:150px;" class="node-text-editor" id="node-input-logTemplateOverrideEditor"></div>
1525
+ </div>
1526
+ </div>
1527
+ </div>
1528
+ </div>
1529
+ </div>
1530
+
1531
+ </div>
1532
+
1533
+ <div id='section_metrics'>
1534
+ <div id="section_metricsconfig">
1535
+ <div class="form-row editorsectionheading">
1536
+ <i class="w-16 fa fa-eye"></i> <span>Metrics</span>
1537
+ </div>
1538
+ <div class="editorgroupborder">
1539
+ <div class="form-row nomargin metricsEnableCheck">
1540
+ <input type="checkbox" id="node-input-metricsEnabled" value="false" style="margin:8px 0 10px 102px; width:20px;" />
1541
+ <label style="width:auto" for="node-input-metricsEnabled"><span>Enable Metrics</span></label>
1542
+ </div>
1543
+
1544
+ <div id="section_metricsEnabled">
1545
+ <div class="form-row nomargin">
1546
+ <label class="towb_editorlabel" for="metrics-selector">Metric Provider</label>
1547
+ <div style="width: 70%; display: inline-flex;">
1548
+ <select id="metrics-selector" style="flex-grow: 1; min-width: 0;">
1549
+ <option value="_ADD_">none</option>
1550
+ </select>
1551
+ <a id="metrics-edit-btn" class="red-ui-button disabled" style="margin-left: 10px; flex-shrink: 0;">
1552
+ <i class="fa fa-pencil"></i>
1553
+ </a>
1554
+ <a id="metrics-add-btn" class="red-ui-button" style="margin-left: 10px; flex-shrink: 0;">
1555
+ <i class="fa fa-plus"></i>
1556
+ </a>
1557
+ </div>
1558
+ </div>
1559
+ </div>
1560
+ </div>
1561
+ </div>
1562
+
1563
+ </div>
1564
+ </script>
1565
+
1566
+ <script type="text/markdown" data-help-name='WhatsappSendMessageNode'>
1567
+ > **Early development** — this package is still maturing. Some features may be incomplete and diagnostic log output is intentionally verbose for now.
1568
+
1569
+ Sends one or more messages to a WhatsApp group when triggered.
1570
+
1571
+ ### Properties
1572
+
1573
+ : *name* (string) : Display label for this node.
1574
+ : *group* (config) : The WhatsApp Group config node to send to.
1575
+
1576
+ ### Messages
1577
+
1578
+ Each row in the **Messages** list is sent as a separate WhatsApp message, in order, when the node is triggered. Rows can be reordered by dragging and removed with the delete button.
1579
+
1580
+ Select the message type from the dropdown, then fill in the value fields:
1581
+
1582
+ - **Text** — a single text value (`msg`, `flow`, `global`, `str` with Handlebars support for `str`)
1583
+ - **Image** — a Buffer containing the image data (`msg`, `flow`, `global`)
1584
+ - **Video** — a Buffer containing the video data (`msg`, `flow`, `global`)
1585
+ - **Document** — three fields: the file Buffer, an optional filename, and an optional MIME type (defaults to `application/octet-stream`)
1586
+
1587
+ ### Inputs
1588
+
1589
+ : *msg* : Trigger message. `msg`-type field values are resolved against this message.
1590
+
1591
+ ### Logging
1592
+ : *enable logging* (boolean) : if checked, then the node will produce logging output to the specified logger.
1593
+ : *logger* (logconfig) : the endppoint that the node will log events to.
1594
+ : *override template* (logconfig) : if checked, a custom logging template can be provided for the log message.
1595
+ : *logger template* (mustache) : a template in mustache format to generate a message for logging purposes (see LoggerConfig node for more information)
1596
+
1597
+ ### Metrics
1598
+ : *enable metrics* (boolean) : if checked, then the node will produce metrics output to the specified metrics provider.
1599
+ : *metric provider* (metricsconfig) : the metrics backend that this node will report to.
1600
+ </script>
1601
+
1602
+ <script type="text/javascript">
1603
+ RED.nodes.registerType('WhatsappDynamicSendMessageNode', {
1604
+ category: 'whatsapp',
1605
+ icon: 'whatsapp.png',
1606
+ color: '#E6FFDA',
1607
+ label: function() {
1608
+ return this.name
1609
+ },
1610
+ paletteLabel: 'WhatsApp Dynamic Send',
1611
+ inputs: 1,
1612
+ inputLabels: (i) => 'message',
1613
+ outputs: 0,
1614
+ outputLabels: (i) => [][i],
1615
+ defaults: {
1616
+ name: {
1617
+ value: 'Dynamic Send WhatsApp Message',
1618
+ required: true
1619
+ },
1620
+ accountConfig: {
1621
+ type: 'WhatsappAccountConfigNode',
1622
+ required: true
1623
+ },
1624
+ recipient: {
1625
+ value: 'payload.sender.id'
1626
+ },
1627
+ recipientType: {
1628
+ value: 'msg'
1629
+ },
1630
+ payloads: {
1631
+ value: '[]',
1632
+ required: false,
1633
+ validate: function(value) {
1634
+ try {
1635
+ JSON.parse(value || "[]");
1636
+ return true;
1637
+ } catch (e) {
1638
+ return false;
1639
+ }
1640
+ }
1641
+ },
1642
+ logEnabled: {
1643
+ value: false,
1644
+ required: true
1645
+ },
1646
+ logger: {
1647
+ required: false,
1648
+ value: '',
1649
+ type: 'DelegatedConfigReferenceNode'
1650
+ },
1651
+ logTemplateOverrideEnabled: {
1652
+ value: false
1653
+ },
1654
+ logTemplateOverride: {
1655
+ value: 'message:{{msg}}'
1656
+ },
1657
+ metricsEnabled: {
1658
+ value: false,
1659
+ required: true
1660
+ },
1661
+ metricsReference: {
1662
+ required: false,
1663
+ value: '',
1664
+ type: 'DelegatedConfigReferenceNode'
1665
+ },
1666
+ },
1667
+ oneditprepare: function() {
1668
+ // **** WhatsappDynamicSendMessageNode **** //
1669
+ {
1670
+ let node = this;
1671
+ // ── Recipient typed input ─────────────────────────────────────────────────
1672
+ $("#node-input-recipient").typedInput({
1673
+ types: ["msg", "flow", "global", "str"],
1674
+ typeField: "#node-input-recipientType"
1675
+ });
1676
+ // ── Messages editableList ─────────────────────────────────────────────────
1677
+ let actionList = $("#wa-dynamic-send-actions-list");
1678
+ actionList.editableList({
1679
+ sortable: true,
1680
+ removable: true,
1681
+ height: "auto",
1682
+ scrollOnAdd: true,
1683
+ addButton: "Add Message",
1684
+ addItem: function(container, i, action) {
1685
+ action = action || {};
1686
+ let type = action.type || "text";
1687
+ container.css({
1688
+ padding: "6px 4px"
1689
+ });
1690
+ // Type row
1691
+ let typeRow = $('<div style="display:flex; align-items:center; gap:8px; margin-bottom:5px;">');
1692
+ let iconEl = $('<i style="width:14px; text-align:center; flex-shrink:0;">').addClass("fa " + (WA_SEND_TYPES[type] ? WA_SEND_TYPES[type].icon : "fa-comment"));
1693
+ let typeSelect = $('<select class="wa-dynamic-send-action-type" style="flex:0 0 auto;">');
1694
+ Object.keys(WA_SEND_TYPES).forEach(function(key) {
1695
+ typeSelect.append($("<option>").val(key).text(WA_SEND_TYPES[key].label));
1696
+ });
1697
+ typeSelect.val(type);
1698
+ typeRow.append(iconEl).append(typeSelect);
1699
+ container.append(typeRow);
1700
+ // Fields container
1701
+ let fieldsContainer = $('<div class="wa-dynamic-send-action-fields">');
1702
+ container.append(fieldsContainer);
1703
+ let renderFields = function(selectedType) {
1704
+ fieldsContainer.empty();
1705
+ let typeDef = WA_SEND_TYPES[selectedType];
1706
+ if (!typeDef) return;
1707
+ iconEl.removeClass().addClass("fa " + typeDef.icon);
1708
+ typeDef.fields.forEach(function(field) {
1709
+ let fieldRow = $('<div class="form-row nomargin" style="display:flex; align-items:center; gap:6px;">');
1710
+ fieldRow.append($('<label style="width:70px; margin:0; font-size:0.85em; flex-shrink:0;">').text(field.label));
1711
+ let input = $('<input type="text">').addClass("wa-dynamic-send-field-" + field.key);
1712
+ fieldRow.append(input);
1713
+ fieldsContainer.append(fieldRow);
1714
+ input.typedInput({
1715
+ types: field.types
1716
+ }).typedInput("type", action[field.key + "Type"] || field.types[0]).typedInput("value", action[field.key] || "");
1717
+ fieldRow.find(".red-ui-typedInput-container").css({
1718
+ flex: "1",
1719
+ "min-width": "0",
1720
+ width: ""
1721
+ });
1722
+ });
1723
+ };
1724
+ renderFields(type);
1725
+ typeSelect.on("change", function() {
1726
+ action = {
1727
+ type: $(this).val()
1728
+ };
1729
+ renderFields($(this).val());
1730
+ });
1731
+ }
1732
+ });
1733
+ let actions = [];
1734
+ try {
1735
+ actions = JSON.parse(node.payloads || "[]");
1736
+ } catch (e) {}
1737
+ if (actions.length > 0) {
1738
+ actionList.editableList("addItems", actions);
1739
+ }
1740
+ }
1741
+ // **** logger **** //
1742
+ {
1743
+ let node = this;
1744
+ let templateConfig = {};
1745
+ // controls.
1746
+ let logEnabled = $("#node-input-logEnabled");
1747
+ let logTemplateEnabled = $("#node-input-logTemplateOverrideEnabled");
1748
+ let logTemplateEnabledSection = $("#section_logTemplateOverrideEnabled");
1749
+ let logEnabledSection = $("#section_logEnabled");
1750
+ let loggerSection = $("#section_loggerconfig");
1751
+ loggerSection.hide();
1752
+ // functions.
1753
+ let getLoggerTypes = async function() {
1754
+ let types = [];
1755
+ await $.ajax({
1756
+ url: "nodetypeservice/find?tag=LoggerType",
1757
+ type: "GET",
1758
+ contentType: "application/json; charset=utf-8",
1759
+ data: JSON.stringify({
1760
+ id: node.id
1761
+ }),
1762
+ success: (response) => {
1763
+ types = response;
1764
+ },
1765
+ error: (jqXHR, textStatus, errorThrown) => {
1766
+ console.log(jqXHR, textStatus, errorThrown);
1767
+ },
1768
+ });
1769
+ return types;
1770
+ }
1771
+ let getLoggers = async () => {
1772
+ let loggers = [];
1773
+ // get the list of types again.
1774
+ let loggerTypes = (await getLoggerTypes()).map(type => type.id);
1775
+ // add all of the known loggers.
1776
+ RED.nodes.eachConfig(cfg => {
1777
+ if (loggerTypes.includes(cfg.type)) {
1778
+ loggers.push({
1779
+ id: cfg.id,
1780
+ name: cfg.label(),
1781
+ type: cfg.type
1782
+ });
1783
+ }
1784
+ });
1785
+ return loggers;
1786
+ };
1787
+ let updateLoggerSelectorList = async () => {
1788
+ loggerSelector.empty();
1789
+ (await getLoggers()).forEach(logger => {
1790
+ $('<option value="' + logger.id + '">' + logger.name + '</option>').appendTo(loggerSelector);
1791
+ });
1792
+ $('<option value="_ADD_">none</option>').appendTo(loggerSelector);
1793
+ }
1794
+ logEnabled.on('change', function() {
1795
+ if (logEnabled.prop("checked")) {
1796
+ logEnabledSection.show();
1797
+ node._def.defaults.logger.required = true;
1798
+ } else {
1799
+ logEnabledSection.hide()
1800
+ node._def.defaults.logger.required = false;
1801
+ }
1802
+ });
1803
+ logTemplateEnabled.on('change', function() {
1804
+ if (logTemplateEnabled.prop("checked")) {
1805
+ logTemplateEnabledSection.show();
1806
+ node._def.defaults.logTemplateOverride.required = true;
1807
+ } else {
1808
+ logTemplateEnabledSection.hide()
1809
+ node._def.defaults.logTemplateOverride.required = false;
1810
+ }
1811
+ });
1812
+ node.logTemplateOverrideEditor = RED.editor.createEditor({
1813
+ id: 'node-input-logTemplateOverrideEditor',
1814
+ mode: 'ace/mode/handlebars',
1815
+ value: node.logTemplateOverride
1816
+ });
1817
+ let loggerSelector = $("#logger-selector");
1818
+ let loggerEditButton = $("#logger-edit-btn");
1819
+ let loggerAddButton = $("#logger-add-btn");
1820
+ loggerEditButton.on('click', async function(event) {
1821
+ event.stopPropagation();
1822
+ if (loggerEditButton.hasClass("disabled")) {
1823
+ return;
1824
+ }
1825
+ // figure out what type of logger this is.
1826
+ let selectedLogger = (await getLoggers()).find(logger => logger.id === loggerSelector.val());
1827
+ RED.editor.editConfig("#logger-selector", selectedLogger.type, selectedLogger.id);
1828
+ });
1829
+ loggerAddButton.on('click', function(event) {
1830
+ event.stopPropagation();
1831
+ let $btn = $(event.target);
1832
+ // Remove any existing dropdown
1833
+ $('.loggertype-dropdown').remove();
1834
+ getLoggerTypes().then(loggerTypes => {
1835
+ let $dropdown = $('<div class="loggertype-dropdown red-ui-editor"></div>');
1836
+ loggerTypes.forEach(loggerType => {
1837
+ $('<div class="dropdown-item"></div>').text(loggerType.name).data('value', loggerType.id).css({
1838
+ padding: '4px 8px',
1839
+ cursor: 'pointer'
1840
+ }).appendTo($dropdown);
1841
+ });
1842
+ // Append first so outerWidth() is measurable, then position so the
1843
+ // right edge of the dropdown aligns with the right edge of the button.
1844
+ $('body').append($dropdown);
1845
+ $dropdown.css({
1846
+ top: $btn.offset().top + $btn.outerHeight(),
1847
+ left: $btn.offset().left + $btn.outerWidth() - $dropdown.outerWidth()
1848
+ });
1849
+ $(document).on('mousedown.dropdown', function(e) {
1850
+ if (!$(e.target).closest('.loggertype-dropdown').length) {
1851
+ $dropdown.remove();
1852
+ $(document).off('mousedown.dropdown'); // Clean up the listener
1853
+ }
1854
+ });
1855
+ // Handle item selection
1856
+ $dropdown.on('click', '.dropdown-item', function(e) {
1857
+ e.stopPropagation();
1858
+ const selectedValue = $(this).data('value');
1859
+ const selectedText = $(this).text();
1860
+ // Do whatever you need with the selection
1861
+ console.log('Selected:', selectedValue, selectedText);
1862
+ $dropdown.remove();
1863
+ RED.editor.editConfig("#logger-selector", selectedValue, "_ADD_");
1864
+ });
1865
+ })
1866
+ });
1867
+ loggerSelector.on("focus", updateLoggerSelectorList);
1868
+ loggerSelector.on('change', function(event) {
1869
+ if ("_ADD_" === loggerSelector.val()) {
1870
+ loggerEditButton.addClass("disabled")
1871
+ } else {
1872
+ loggerEditButton.removeClass("disabled")
1873
+ }
1874
+ });
1875
+ // Show the logger section only if at least one logger type is registered.
1876
+ // If nodered_logging (or any other logger provider) is not installed, the section stays hidden.
1877
+ getLoggerTypes().then(loggerTypes => {
1878
+ if (loggerTypes.length === 0) return;
1879
+ loggerSection.show();
1880
+ updateLoggerSelectorList().then(() => {
1881
+ loggerSelector.val(node.logger);
1882
+ loggerSelector.trigger('change');
1883
+ });
1077
1884
  });
1078
1885
  }
1079
1886
  // **** metrics **** //
1080
1887
  {
1081
1888
  let node = this;
1889
+ let templateConfig = {};
1082
1890
  let metricsEnabled = $("#node-input-metricsEnabled");
1083
- let metricsEnabledSection = $("#section_metricsEnabled");
1891
+ let metricsSection = $("#section_metricsconfig");
1892
+ let metricsContainer = metricsEnabled.closest(".editorgroupborder");
1893
+ let metricsEnabledSection = metricsContainer.find("#section_metricsEnabled");
1894
+ metricsSection.hide();
1895
+ let getMetricsProviderTypes = async function() {
1896
+ let types = [];
1897
+ await $.ajax({
1898
+ url: "nodetypeservice/find?tag=MetricsProvider",
1899
+ type: "GET",
1900
+ contentType: "application/json; charset=utf-8",
1901
+ success: (response) => {
1902
+ types = response;
1903
+ },
1904
+ error: (jqXHR, textStatus, errorThrown) => {
1905
+ console.log(jqXHR, textStatus, errorThrown);
1906
+ },
1907
+ });
1908
+ return types;
1909
+ };
1910
+ let getMetricsProviders = async () => {
1911
+ let providers = [];
1912
+ let providerTypes = (await getMetricsProviderTypes()).map(t => t.id);
1913
+ RED.nodes.eachConfig(cfg => {
1914
+ if (providerTypes.includes(cfg.type)) {
1915
+ providers.push({
1916
+ id: cfg.id,
1917
+ name: cfg.label(),
1918
+ type: cfg.type
1919
+ });
1920
+ }
1921
+ });
1922
+ return providers;
1923
+ };
1924
+ let updateMetricsSelectorList = async () => {
1925
+ metricsSelector.empty();
1926
+ (await getMetricsProviders()).forEach(provider => {
1927
+ $('<option value="' + provider.id + '">' + provider.name + '</option>').appendTo(metricsSelector);
1928
+ });
1929
+ $('<option value="_ADD_">none</option>').appendTo(metricsSelector);
1930
+ };
1084
1931
  metricsEnabled.on('change', function() {
1085
1932
  if (metricsEnabled.prop("checked")) {
1086
1933
  metricsEnabledSection.show();
1087
- node._def.defaults.metricsReference.required = true;
1088
1934
  } else {
1089
- metricsEnabledSection.hide()
1090
- node._def.defaults.metricsReference.required = false;
1935
+ metricsEnabledSection.hide();
1936
+ }
1937
+ });
1938
+ let metricsSelector = metricsContainer.find("#metrics-selector");
1939
+ let metricsEditButton = metricsContainer.find("#metrics-edit-btn");
1940
+ let metricsAddButton = metricsContainer.find("#metrics-add-btn");
1941
+ metricsEditButton.on('click', async function(event) {
1942
+ event.stopPropagation();
1943
+ if (metricsEditButton.hasClass("disabled")) return;
1944
+ let selectedProvider = (await getMetricsProviders()).find(p => p.id === metricsSelector.val());
1945
+ if (selectedProvider) {
1946
+ RED.editor.editConfig("#metrics-selector", selectedProvider.type, selectedProvider.id);
1947
+ }
1948
+ });
1949
+ metricsAddButton.on('click', function(event) {
1950
+ event.stopPropagation();
1951
+ let $btn = $(event.currentTarget);
1952
+ let btnOffset = $btn.offset();
1953
+ let btnH = $btn.outerHeight();
1954
+ let btnW = $btn.outerWidth();
1955
+ $('.metricstype-dropdown').remove();
1956
+ getMetricsProviderTypes().then(providerTypes => {
1957
+ let $dropdown = $('<div class="metricstype-dropdown red-ui-editor"></div>').css({
1958
+ position: 'absolute',
1959
+ zIndex: 1000
1960
+ });
1961
+ providerTypes.forEach(providerType => {
1962
+ $('<div class="dropdown-item"></div>').text(providerType.name).data('value', providerType.id).css({
1963
+ padding: '4px 8px',
1964
+ cursor: 'pointer'
1965
+ }).appendTo($dropdown);
1966
+ });
1967
+ $('body').append($dropdown);
1968
+ $dropdown.css({
1969
+ top: btnOffset.top + btnH,
1970
+ left: Math.max(0, btnOffset.left + btnW - $dropdown.outerWidth())
1971
+ });
1972
+ $(document).on('mousedown.metricsdropdown', function(e) {
1973
+ if (!$(e.target).closest('.metricstype-dropdown').length) {
1974
+ $dropdown.remove();
1975
+ $(document).off('mousedown.metricsdropdown');
1976
+ }
1977
+ });
1978
+ $dropdown.on('click', '.dropdown-item', function(e) {
1979
+ e.stopPropagation();
1980
+ const selectedValue = $(this).data('value');
1981
+ $dropdown.remove();
1982
+ RED.editor.editConfig("#metrics-selector", selectedValue, "_ADD_");
1983
+ });
1984
+ });
1985
+ });
1986
+ metricsSelector.on("focus", updateMetricsSelectorList);
1987
+ metricsSelector.on('change', function() {
1988
+ if ("_ADD_" === metricsSelector.val()) {
1989
+ metricsEditButton.addClass("disabled");
1990
+ } else {
1991
+ metricsEditButton.removeClass("disabled");
1091
1992
  }
1092
1993
  });
1994
+ // Show the metrics section only if at least one metrics provider type is registered.
1995
+ // If nodered_prometheus (or any other provider) is not installed, the section stays hidden.
1996
+ getMetricsProviderTypes().then(providerTypes => {
1997
+ if (providerTypes.length === 0) return;
1998
+ metricsSection.show();
1999
+ updateMetricsSelectorList().then(() => {
2000
+ metricsSelector.val(node.metricsReference);
2001
+ metricsSelector.trigger('change');
2002
+ });
2003
+ });
1093
2004
  }
1094
2005
  },
1095
2006
  oneditsave: function() {
1096
- // **** WhatsappSendMessageNode **** //
2007
+ // **** WhatsappDynamicSendMessageNode **** //
1097
2008
  {
1098
- let payloads = WA_SEND_FIELDS.map(function(field) {
1099
- return {
1100
- id: field.id,
1101
- enabled: $("#wa-send-enabled-" + field.id).prop("checked"),
1102
- type: $("#wa-send-value-" + field.id).typedInput("type"),
1103
- value: $("#wa-send-value-" + field.id).typedInput("value"),
2009
+ let actions = [];
2010
+ $("#wa-dynamic-send-actions-list").editableList("items").each(function() {
2011
+ let container = $(this);
2012
+ let type = container.find(".wa-dynamic-send-action-type").val();
2013
+ let typeDef = WA_SEND_TYPES[type];
2014
+ if (!typeDef) return;
2015
+ let action = {
2016
+ type: type
1104
2017
  };
2018
+ typeDef.fields.forEach(function(field) {
2019
+ let input = container.find(".wa-dynamic-send-field-" + field.key);
2020
+ if (input.length) {
2021
+ action[field.key] = input.typedInput("value");
2022
+ action[field.key + "Type"] = input.typedInput("type");
2023
+ }
2024
+ });
2025
+ actions.push(action);
1105
2026
  });
1106
- $("#node-input-payloads").val(JSON.stringify(payloads));
2027
+ $("#node-input-payloads").val(JSON.stringify(actions));
1107
2028
  }
1108
2029
  // **** logger **** //
1109
2030
  {
2031
+ let node = this;
2032
+ let templateConfig = {};
1110
2033
  node = this;
1111
2034
  var selectedLogger = $("#logger-selector").val();
1112
2035
  node.logger = (selectedLogger && selectedLogger !== "_ADD_") ? selectedLogger : '';
@@ -1115,7 +2038,13 @@ Click **Create New Group** to create a new WhatsApp group from within Node-RED.
1115
2038
  delete node.logTemplateOverrideEditor;
1116
2039
  }
1117
2040
  // **** metrics **** //
1118
- {}
2041
+ {
2042
+ let node = this;
2043
+ let templateConfig = {};
2044
+ node = this;
2045
+ var selectedProvider = $("#metrics-selector").val();
2046
+ node.metricsReference = (selectedProvider && selectedProvider !== "_ADD_") ? selectedProvider : '';
2047
+ }
1119
2048
  },
1120
2049
  oneditcancel: function() {},
1121
2050
  oneditdelete: function() {},
@@ -1124,23 +2053,36 @@ Click **Create New Group** to create a new WhatsApp group from within Node-RED.
1124
2053
  </script>
1125
2054
 
1126
2055
 
1127
- <script type="text/html" data-template-name='WhatsappSendMessageNode'>
1128
- <div id='section_WhatsappSendMessageNode'>
2056
+ <script type="text/html" data-template-name='WhatsappDynamicSendMessageNode'>
2057
+ <div id='section_WhatsappDynamicSendMessageNode'>
1129
2058
  <div class="form-row">
1130
2059
  <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
1131
2060
  <input type="text" id="node-input-name" />
1132
2061
  </div>
1133
2062
 
1134
- <div class="form-row nomargin">
1135
- <label for="node-input-groupConfig" style="width:120px;"><i class="fa fa-users"></i> Group</label>
1136
- <input type="text" id="node-input-groupConfig" placeholder="config">
2063
+ <div class="form-row editorsectionheading">
2064
+ <i class="w-16 fa fa-envelope"></i> <span>WhatsApp Account</span>
2065
+ </div>
2066
+
2067
+ <div class="editorgroupborder">
2068
+ <div class="form-row nomargin">
2069
+ <label for="node-input-accountConfig" style="width:120px;"><i class="fa fa-user"></i> Account</label>
2070
+ <input type="text" id="node-input-accountConfig" placeholder="config">
2071
+ </div>
2072
+
2073
+ <div class="form-row nomargin">
2074
+ <label for="node-input-recipient" style="width:120px;"><i class="fa fa-paper-plane"></i> Recipient</label>
2075
+ <input type="text" id="node-input-recipient" placeholder="payload.sender.id">
2076
+ <input type="hidden" id="node-input-recipientType">
2077
+ </div>
1137
2078
  </div>
1138
2079
 
1139
2080
  <div class="form-row editorsectionheading">
1140
- <i class="w-16 fa fa-paper-plane"></i> <span>Send</span>
2081
+ <i class="w-16 fa fa-paper-plane"></i> <span>Messages</span>
1141
2082
  </div>
1142
2083
 
1143
- <div class="editorgroupborder" id="wa-send-payload-rows">
2084
+ <div class="editorgroupborder">
2085
+ <ol id="wa-dynamic-send-actions-list"></ol>
1144
2086
  </div>
1145
2087
 
1146
2088
  <input type="hidden" id="node-input-payloads">
@@ -1205,8 +2147,18 @@ Click **Create New Group** to create a new WhatsApp group from within Node-RED.
1205
2147
 
1206
2148
  <div id="section_metricsEnabled">
1207
2149
  <div class="form-row nomargin">
1208
- <label class="towb_editorlabel" for="node-input-metricsReference">Metric Collector</label>
1209
- <input id="node-input-metricsReference" placeholder="metrics" />
2150
+ <label class="towb_editorlabel" for="metrics-selector">Metric Provider</label>
2151
+ <div style="width: 70%; display: inline-flex;">
2152
+ <select id="metrics-selector" style="flex-grow: 1; min-width: 0;">
2153
+ <option value="_ADD_">none</option>
2154
+ </select>
2155
+ <a id="metrics-edit-btn" class="red-ui-button disabled" style="margin-left: 10px; flex-shrink: 0;">
2156
+ <i class="fa fa-pencil"></i>
2157
+ </a>
2158
+ <a id="metrics-add-btn" class="red-ui-button" style="margin-left: 10px; flex-shrink: 0;">
2159
+ <i class="fa fa-plus"></i>
2160
+ </a>
2161
+ </div>
1210
2162
  </div>
1211
2163
  </div>
1212
2164
  </div>
@@ -1215,26 +2167,28 @@ Click **Create New Group** to create a new WhatsApp group from within Node-RED.
1215
2167
  </div>
1216
2168
  </script>
1217
2169
 
1218
- <script type="text/markdown" data-help-name='WhatsappSendMessageNode'>
2170
+ <script type="text/markdown" data-help-name='WhatsappDynamicSendMessageNode'>
1219
2171
  > **Early development** — this package is still maturing. Some features may be incomplete and diagnostic log output is intentionally verbose for now.
1220
2172
 
1221
- Sends a message to a WhatsApp group.
2173
+ Sends one or more WhatsApp messages directly to a recipient resolved at runtime — for example, replying privately to the sender of a group message.
1222
2174
 
1223
2175
  ### Properties
1224
2176
 
1225
- : *name* (string) : Display label for this node.
1226
- : *group* (config) : The WhatsApp Group config node to send to.
2177
+ : *name* (string) : Display label for this node.
2178
+ : *account* (config) : The WhatsApp Account config node to send from.
2179
+ : *recipient* (typed) : The target JID resolved from the incoming message, flow, global context, or a static string. Defaults to `msg.payload.sender.id` — the JID of whoever sent the triggering message.
1227
2180
 
1228
- ### Send fields
2181
+ ### LID addressing
1229
2182
 
1230
- Each field has a checkbox to enable it and a typed value. Supported value types depend on the field:
2183
+ If the recipient JID ends with `@lid` (newer WhatsApp clients), the node automatically resolves it to the corresponding phone-number JID via the contact store before sending.
1231
2184
 
1232
- - **Send Text** — `msg`, `flow`, `global`, `str`
1233
- - **Send Image** — `msg`, `flow`, `global`
2185
+ ### Messages
2186
+
2187
+ Each row in the **Messages** list is sent as a separate WhatsApp message, in order. Supports Text, Image, Video, and Document (with optional filename and MIME type).
1234
2188
 
1235
2189
  ### Inputs
1236
2190
 
1237
- : *msg* : Trigger message. Field values are resolved against this message (for `msg` type) or from context.
2191
+ : *msg* : Trigger message. `msg`-type field values and the recipient are resolved against this message.
1238
2192
 
1239
2193
  ### Logging
1240
2194
  : *enable logging* (boolean) : if checked, then the node will produce logging output to the specified logger.
@@ -1244,166 +2198,9 @@ Each field has a checkbox to enable it and a typed value. Supported value types
1244
2198
 
1245
2199
  ### Metrics
1246
2200
  : *enable metrics* (boolean) : if checked, then the node will produce metrics output to the specified metrics provider.
1247
- : *metric collector* (metricconfig) : the collection point or grouping where this particular metric will be attached.
1248
- </script>
1249
- <!-- WhatsappReceiveMessageNode -->
1250
- <style>
1251
- </style>
1252
- <script>
1253
- const WA_ACCEPT_FIELDS = [{
1254
- id: "Text",
1255
- icon: "fa-font",
1256
- label: "Text",
1257
- defaultEnabled: true
1258
- }, {
1259
- id: "ExtendedText",
1260
- icon: "fa-align-left",
1261
- label: "Extended Text",
1262
- defaultEnabled: true
1263
- }, {
1264
- id: "Image",
1265
- icon: "fa-image",
1266
- label: "Image",
1267
- defaultEnabled: true
1268
- }, {
1269
- id: "Video",
1270
- icon: "fa-video-camera",
1271
- label: "Video",
1272
- defaultEnabled: true
1273
- }, {
1274
- id: "Album",
1275
- icon: "fa-th-large",
1276
- label: "Album",
1277
- defaultEnabled: true
1278
- }, {
1279
- id: "Document",
1280
- icon: "fa-file",
1281
- label: "Document",
1282
- defaultEnabled: false
1283
- }, {
1284
- id: "Contact",
1285
- icon: "fa-address-card",
1286
- label: "Contact",
1287
- defaultEnabled: false
1288
- }, {
1289
- id: "Template",
1290
- icon: "fa-th-list",
1291
- label: "Template",
1292
- defaultEnabled: false
1293
- }, {
1294
- id: "Interactive",
1295
- icon: "fa-hand-o-up",
1296
- label: "Interactive",
1297
- defaultEnabled: false
1298
- }, {
1299
- id: "Location",
1300
- icon: "fa-map-marker",
1301
- label: "Location",
1302
- defaultEnabled: false
1303
- }, {
1304
- id: "LiveLocation",
1305
- icon: "fa-location-arrow",
1306
- label: "Live Location",
1307
- defaultEnabled: false
1308
- }, ];
1309
-
2201
+ : *metric provider* (metricsconfig) : the metrics backend that this node will report to.
1310
2202
  </script>
1311
- <!-- logger -->
1312
- <style>
1313
- .editorgroupborder {
1314
- border-width: 1px;
1315
- border-style: solid;
1316
- border-color: lightgray;
1317
- padding: 3px;
1318
- margin-top: 2px;
1319
- margin-bottom: 2px;
1320
- }
1321
-
1322
- .editorsectionheading {
1323
- font-weight: 600;
1324
- margin-bottom: 1px !important;
1325
- margin-top: 6px !important;
1326
- }
1327
-
1328
- .nomargin {
1329
- margin-bottom: 1px !important;
1330
- margin-top: 1px !important;
1331
- }
1332
-
1333
- .slidecontainer {
1334
- width: 100%;
1335
- }
1336
-
1337
- .slider {
1338
- -webkit-appearance: none;
1339
- appearance: none;
1340
- width: 100%;
1341
- height: 25px;
1342
- background: #d3d3d3;
1343
- outline: none;
1344
- opacity: 0.7;
1345
- -webkit-transition: .2s;
1346
- transition: opacity .2s;
1347
- }
1348
-
1349
- .slider:hover {
1350
- opacity: 1;
1351
- }
1352
-
1353
- .slider::-webkit-slider-thumb {
1354
- -webkit-appearance: none;
1355
- appearance: none;
1356
- width: 25px;
1357
- height: 25px;
1358
- background: #04AA6D;
1359
- cursor: pointer;
1360
- }
1361
-
1362
- .slider::-moz-range-thumb {
1363
- width: 25px;
1364
- height: 25px;
1365
- background: #04AA6D;
1366
- cursor: pointer;
1367
- }
1368
-
1369
- .towb_editorlabel {
1370
- width: 120px !important;
1371
- }
1372
-
1373
- .towb_editorfield {
1374
- width: 70% !important;
1375
- }
1376
2203
 
1377
- .towb_editorfield_short {
1378
- width: 30% !important;
1379
- }
1380
-
1381
- </style>
1382
- <script type="text/javascript">
1383
- // CSS
1384
- dropdownStyles = `
1385
- .loggertype-dropdown {
1386
- position: absolute;
1387
- background: white;
1388
- border: 1px solid #ccc;
1389
- border-radius: 4px;
1390
- box-shadow: 0 2px 8px rgba(0,0,0,0.15);
1391
- max-height: 150px;
1392
- overflow-y: auto;
1393
- z-index: 1000;
1394
- min-width: 150px;
1395
- }
1396
- .loggertype-dropdown .dropdown-item {
1397
- padding: 6px 12px;
1398
- cursor: pointer;
1399
- }
1400
- .loggertype-dropdown .dropdown-item:hover {
1401
- background: #f0f0f0;
1402
- }
1403
- `;
1404
- $('<style>').text(dropdownStyles).appendTo('head');
1405
-
1406
- </script>
1407
2204
  <script type="text/javascript">
1408
2205
  RED.nodes.registerType('WhatsappReceiveMessageNode', {
1409
2206
  category: 'whatsapp',
@@ -1458,8 +2255,9 @@ Each field has a checkbox to enable it and a typed value. Supported value types
1458
2255
  required: true
1459
2256
  },
1460
2257
  metricsReference: {
1461
- type: 'MetricsConfigNode',
1462
- required: false
2258
+ required: false,
2259
+ value: '',
2260
+ type: 'DelegatedConfigReferenceNode'
1463
2261
  },
1464
2262
  },
1465
2263
  oneditprepare: function() {
@@ -1490,11 +2288,14 @@ Each field has a checkbox to enable it and a typed value. Supported value types
1490
2288
  // **** logger **** //
1491
2289
  {
1492
2290
  let node = this;
2291
+ let templateConfig = {};
1493
2292
  // controls.
1494
2293
  let logEnabled = $("#node-input-logEnabled");
1495
2294
  let logTemplateEnabled = $("#node-input-logTemplateOverrideEnabled");
1496
2295
  let logTemplateEnabledSection = $("#section_logTemplateOverrideEnabled");
1497
2296
  let logEnabledSection = $("#section_logEnabled");
2297
+ let loggerSection = $("#section_loggerconfig");
2298
+ loggerSection.hide();
1498
2299
  // functions.
1499
2300
  let getLoggerTypes = async function() {
1500
2301
  let types = [];
@@ -1618,26 +2419,135 @@ Each field has a checkbox to enable it and a typed value. Supported value types
1618
2419
  loggerEditButton.removeClass("disabled")
1619
2420
  }
1620
2421
  });
1621
- // lastly, make sure that we set the correct value on the logger selector.
1622
- updateLoggerSelectorList().then(() => {
1623
- loggerSelector.val(node.logger);
1624
- loggerSelector.trigger('change');
2422
+ // Show the logger section only if at least one logger type is registered.
2423
+ // If nodered_logging (or any other logger provider) is not installed, the section stays hidden.
2424
+ getLoggerTypes().then(loggerTypes => {
2425
+ if (loggerTypes.length === 0) return;
2426
+ loggerSection.show();
2427
+ updateLoggerSelectorList().then(() => {
2428
+ loggerSelector.val(node.logger);
2429
+ loggerSelector.trigger('change');
2430
+ });
1625
2431
  });
1626
2432
  }
1627
2433
  // **** metrics **** //
1628
2434
  {
1629
2435
  let node = this;
2436
+ let templateConfig = {};
1630
2437
  let metricsEnabled = $("#node-input-metricsEnabled");
1631
- let metricsEnabledSection = $("#section_metricsEnabled");
2438
+ let metricsSection = $("#section_metricsconfig");
2439
+ let metricsContainer = metricsEnabled.closest(".editorgroupborder");
2440
+ let metricsEnabledSection = metricsContainer.find("#section_metricsEnabled");
2441
+ metricsSection.hide();
2442
+ let getMetricsProviderTypes = async function() {
2443
+ let types = [];
2444
+ await $.ajax({
2445
+ url: "nodetypeservice/find?tag=MetricsProvider",
2446
+ type: "GET",
2447
+ contentType: "application/json; charset=utf-8",
2448
+ success: (response) => {
2449
+ types = response;
2450
+ },
2451
+ error: (jqXHR, textStatus, errorThrown) => {
2452
+ console.log(jqXHR, textStatus, errorThrown);
2453
+ },
2454
+ });
2455
+ return types;
2456
+ };
2457
+ let getMetricsProviders = async () => {
2458
+ let providers = [];
2459
+ let providerTypes = (await getMetricsProviderTypes()).map(t => t.id);
2460
+ RED.nodes.eachConfig(cfg => {
2461
+ if (providerTypes.includes(cfg.type)) {
2462
+ providers.push({
2463
+ id: cfg.id,
2464
+ name: cfg.label(),
2465
+ type: cfg.type
2466
+ });
2467
+ }
2468
+ });
2469
+ return providers;
2470
+ };
2471
+ let updateMetricsSelectorList = async () => {
2472
+ metricsSelector.empty();
2473
+ (await getMetricsProviders()).forEach(provider => {
2474
+ $('<option value="' + provider.id + '">' + provider.name + '</option>').appendTo(metricsSelector);
2475
+ });
2476
+ $('<option value="_ADD_">none</option>').appendTo(metricsSelector);
2477
+ };
1632
2478
  metricsEnabled.on('change', function() {
1633
2479
  if (metricsEnabled.prop("checked")) {
1634
2480
  metricsEnabledSection.show();
1635
- node._def.defaults.metricsReference.required = true;
1636
2481
  } else {
1637
- metricsEnabledSection.hide()
1638
- node._def.defaults.metricsReference.required = false;
2482
+ metricsEnabledSection.hide();
2483
+ }
2484
+ });
2485
+ let metricsSelector = metricsContainer.find("#metrics-selector");
2486
+ let metricsEditButton = metricsContainer.find("#metrics-edit-btn");
2487
+ let metricsAddButton = metricsContainer.find("#metrics-add-btn");
2488
+ metricsEditButton.on('click', async function(event) {
2489
+ event.stopPropagation();
2490
+ if (metricsEditButton.hasClass("disabled")) return;
2491
+ let selectedProvider = (await getMetricsProviders()).find(p => p.id === metricsSelector.val());
2492
+ if (selectedProvider) {
2493
+ RED.editor.editConfig("#metrics-selector", selectedProvider.type, selectedProvider.id);
2494
+ }
2495
+ });
2496
+ metricsAddButton.on('click', function(event) {
2497
+ event.stopPropagation();
2498
+ let $btn = $(event.currentTarget);
2499
+ let btnOffset = $btn.offset();
2500
+ let btnH = $btn.outerHeight();
2501
+ let btnW = $btn.outerWidth();
2502
+ $('.metricstype-dropdown').remove();
2503
+ getMetricsProviderTypes().then(providerTypes => {
2504
+ let $dropdown = $('<div class="metricstype-dropdown red-ui-editor"></div>').css({
2505
+ position: 'absolute',
2506
+ zIndex: 1000
2507
+ });
2508
+ providerTypes.forEach(providerType => {
2509
+ $('<div class="dropdown-item"></div>').text(providerType.name).data('value', providerType.id).css({
2510
+ padding: '4px 8px',
2511
+ cursor: 'pointer'
2512
+ }).appendTo($dropdown);
2513
+ });
2514
+ $('body').append($dropdown);
2515
+ $dropdown.css({
2516
+ top: btnOffset.top + btnH,
2517
+ left: Math.max(0, btnOffset.left + btnW - $dropdown.outerWidth())
2518
+ });
2519
+ $(document).on('mousedown.metricsdropdown', function(e) {
2520
+ if (!$(e.target).closest('.metricstype-dropdown').length) {
2521
+ $dropdown.remove();
2522
+ $(document).off('mousedown.metricsdropdown');
2523
+ }
2524
+ });
2525
+ $dropdown.on('click', '.dropdown-item', function(e) {
2526
+ e.stopPropagation();
2527
+ const selectedValue = $(this).data('value');
2528
+ $dropdown.remove();
2529
+ RED.editor.editConfig("#metrics-selector", selectedValue, "_ADD_");
2530
+ });
2531
+ });
2532
+ });
2533
+ metricsSelector.on("focus", updateMetricsSelectorList);
2534
+ metricsSelector.on('change', function() {
2535
+ if ("_ADD_" === metricsSelector.val()) {
2536
+ metricsEditButton.addClass("disabled");
2537
+ } else {
2538
+ metricsEditButton.removeClass("disabled");
1639
2539
  }
1640
2540
  });
2541
+ // Show the metrics section only if at least one metrics provider type is registered.
2542
+ // If nodered_prometheus (or any other provider) is not installed, the section stays hidden.
2543
+ getMetricsProviderTypes().then(providerTypes => {
2544
+ if (providerTypes.length === 0) return;
2545
+ metricsSection.show();
2546
+ updateMetricsSelectorList().then(() => {
2547
+ metricsSelector.val(node.metricsReference);
2548
+ metricsSelector.trigger('change');
2549
+ });
2550
+ });
1641
2551
  }
1642
2552
  },
1643
2553
  oneditsave: function() {
@@ -1653,6 +2563,8 @@ Each field has a checkbox to enable it and a typed value. Supported value types
1653
2563
  }
1654
2564
  // **** logger **** //
1655
2565
  {
2566
+ let node = this;
2567
+ let templateConfig = {};
1656
2568
  node = this;
1657
2569
  var selectedLogger = $("#logger-selector").val();
1658
2570
  node.logger = (selectedLogger && selectedLogger !== "_ADD_") ? selectedLogger : '';
@@ -1661,7 +2573,13 @@ Each field has a checkbox to enable it and a typed value. Supported value types
1661
2573
  delete node.logTemplateOverrideEditor;
1662
2574
  }
1663
2575
  // **** metrics **** //
1664
- {}
2576
+ {
2577
+ let node = this;
2578
+ let templateConfig = {};
2579
+ node = this;
2580
+ var selectedProvider = $("#metrics-selector").val();
2581
+ node.metricsReference = (selectedProvider && selectedProvider !== "_ADD_") ? selectedProvider : '';
2582
+ }
1665
2583
  },
1666
2584
  oneditcancel: function() {},
1667
2585
  oneditdelete: function() {},
@@ -1770,8 +2688,18 @@ Each field has a checkbox to enable it and a typed value. Supported value types
1770
2688
 
1771
2689
  <div id="section_metricsEnabled">
1772
2690
  <div class="form-row nomargin">
1773
- <label class="towb_editorlabel" for="node-input-metricsReference">Metric Collector</label>
1774
- <input id="node-input-metricsReference" placeholder="metrics" />
2691
+ <label class="towb_editorlabel" for="metrics-selector">Metric Provider</label>
2692
+ <div style="width: 70%; display: inline-flex;">
2693
+ <select id="metrics-selector" style="flex-grow: 1; min-width: 0;">
2694
+ <option value="_ADD_">none</option>
2695
+ </select>
2696
+ <a id="metrics-edit-btn" class="red-ui-button disabled" style="margin-left: 10px; flex-shrink: 0;">
2697
+ <i class="fa fa-pencil"></i>
2698
+ </a>
2699
+ <a id="metrics-add-btn" class="red-ui-button" style="margin-left: 10px; flex-shrink: 0;">
2700
+ <i class="fa fa-plus"></i>
2701
+ </a>
2702
+ </div>
1775
2703
  </div>
1776
2704
  </div>
1777
2705
  </div>
@@ -1808,7 +2736,6 @@ Each message type can be individually enabled or disabled. By default Text, Exte
1808
2736
 
1809
2737
  ### Metrics
1810
2738
  : *enable metrics* (boolean) : if checked, then the node will produce metrics output to the specified metrics provider.
1811
- : *metric collector* (metricconfig) : the collection point or grouping where this particular metric will be attached.
2739
+ : *metric provider* (metricsconfig) : the metrics backend that this node will report to.
1812
2740
  </script>
1813
-
1814
2741