@ganju/utils 0.0.17 → 0.0.20

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.
Files changed (57) hide show
  1. package/dist/constants.d.ts +8 -0
  2. package/dist/constants.d.ts.map +1 -1
  3. package/dist/constants.js +21 -4
  4. package/dist/crypto.d.ts +2 -2
  5. package/dist/crypto.d.ts.map +1 -1
  6. package/dist/crypto.js +1 -0
  7. package/dist/customCodeToken.d.ts +4 -0
  8. package/dist/customCodeToken.d.ts.map +1 -1
  9. package/dist/customCodeToken.js +9 -1
  10. package/dist/data.d.ts +2 -0
  11. package/dist/data.d.ts.map +1 -1
  12. package/dist/data.js +28 -1
  13. package/dist/dataApi.d.ts +133 -1
  14. package/dist/dataApi.d.ts.map +1 -1
  15. package/dist/dataApi.js +62 -1
  16. package/dist/dataDdl.d.ts.map +1 -1
  17. package/dist/dataDdl.js +3 -1
  18. package/dist/dataEngine.d.ts.map +1 -1
  19. package/dist/dataEngine.js +62 -0
  20. package/dist/dataRpc.d.ts +25 -0
  21. package/dist/dataRpc.d.ts.map +1 -1
  22. package/dist/dataTriggers.d.ts +203 -0
  23. package/dist/dataTriggers.d.ts.map +1 -0
  24. package/dist/dataTriggers.js +760 -0
  25. package/dist/dataTypes.d.ts +1 -0
  26. package/dist/dataTypes.d.ts.map +1 -1
  27. package/dist/index.d.ts +198 -2
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +27 -0
  30. package/dist/localizeDataError.d.ts +18 -0
  31. package/dist/localizeDataError.d.ts.map +1 -0
  32. package/dist/localizeDataError.js +359 -0
  33. package/dist/schema.d.ts +161 -0
  34. package/dist/schema.d.ts.map +1 -1
  35. package/dist/schema.js +146 -1
  36. package/dist/sdkConstants.d.ts +3 -0
  37. package/dist/sdkConstants.d.ts.map +1 -1
  38. package/dist/sdkConstants.js +4 -1
  39. package/dist/whatsappTemplates.d.ts +77 -0
  40. package/dist/whatsappTemplates.d.ts.map +1 -0
  41. package/dist/whatsappTemplates.js +402 -0
  42. package/package.json +1 -1
  43. package/src/constants.ts +28 -4
  44. package/src/crypto.ts +3 -2
  45. package/src/customCodeToken.ts +14 -1
  46. package/src/data.ts +40 -0
  47. package/src/dataApi.ts +78 -2
  48. package/src/dataDdl.ts +3 -1
  49. package/src/dataEngine.ts +70 -0
  50. package/src/dataRpc.ts +15 -0
  51. package/src/dataTriggers.ts +1134 -0
  52. package/src/dataTypes.ts +3 -0
  53. package/src/index.ts +65 -1
  54. package/src/localizeDataError.ts +593 -0
  55. package/src/schema.ts +170 -1
  56. package/src/sdkConstants.ts +3 -0
  57. package/src/whatsappTemplates.ts +465 -0
@@ -0,0 +1,402 @@
1
+ "use strict";
2
+ // WhatsApp message templates: the shape Meta's Graph API takes and returns,
3
+ // and the rules it rejects templates for most often, checked before one is
4
+ // sent for review. Imports nothing, so the dashboard runs the same checks.
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.whatsappSendError = exports.cleanWhatsappTemplateParam = exports.fillWhatsappTemplate = exports.whatsappTemplateSendable = exports.whatsappTemplateContent = exports.whatsappTemplateComponents = exports.whatsappTemplateIssueText = exports.checkWhatsappTemplate = exports.whatsappTemplateVariableCount = exports.whatsappTemplateVariables = exports.WHATSAPP_TEMPLATE_LANGUAGES = exports.WHATSAPP_TEMPLATE_EXAMPLE_MAX = exports.WHATSAPP_TEMPLATE_LABEL_MAX = exports.WHATSAPP_TEMPLATE_VARIABLES_MAX = exports.WHATSAPP_TEMPLATE_BUTTONS_MAX = exports.WHATSAPP_TEMPLATE_BUTTON_MAX = exports.WHATSAPP_TEMPLATE_FOOTER_MAX = exports.WHATSAPP_TEMPLATE_HEADER_MAX = exports.WHATSAPP_TEMPLATE_BODY_MAX = exports.WHATSAPP_TEMPLATE_NAME_MAX = exports.WHATSAPP_TEMPLATE_STATUSES_STOPPED = exports.WHATSAPP_TEMPLATE_STATUSES_WAITING = exports.WHATSAPP_TEMPLATE_STATUS_APPROVED = exports.WHATSAPP_TEMPLATE_CATEGORIES = void 0;
7
+ exports.WHATSAPP_TEMPLATE_CATEGORIES = ['UTILITY', 'MARKETING'];
8
+ // What Meta reports. Only APPROVED templates can be sent.
9
+ exports.WHATSAPP_TEMPLATE_STATUS_APPROVED = 'APPROVED';
10
+ // Still in Meta's hands: the hourly sync looks at these again.
11
+ exports.WHATSAPP_TEMPLATE_STATUSES_WAITING = [
12
+ 'PENDING',
13
+ 'IN_APPEAL',
14
+ 'PENDING_DELETION'
15
+ ];
16
+ // An approved template Meta stopped: its owners are told.
17
+ exports.WHATSAPP_TEMPLATE_STATUSES_STOPPED = ['PAUSED', 'DISABLED'];
18
+ exports.WHATSAPP_TEMPLATE_NAME_MAX = 512;
19
+ exports.WHATSAPP_TEMPLATE_BODY_MAX = 1024;
20
+ exports.WHATSAPP_TEMPLATE_HEADER_MAX = 60;
21
+ exports.WHATSAPP_TEMPLATE_FOOTER_MAX = 60;
22
+ exports.WHATSAPP_TEMPLATE_BUTTON_MAX = 25;
23
+ exports.WHATSAPP_TEMPLATE_BUTTONS_MAX = 10;
24
+ exports.WHATSAPP_TEMPLATE_VARIABLES_MAX = 20;
25
+ exports.WHATSAPP_TEMPLATE_LABEL_MAX = 40;
26
+ exports.WHATSAPP_TEMPLATE_EXAMPLE_MAX = 200;
27
+ const NAME_PATTERN = /^[a-z0-9_]+$/;
28
+ // Meta's template languages. The first ones are the ones most owners use.
29
+ exports.WHATSAPP_TEMPLATE_LANGUAGES = [
30
+ { code: 'es', name: 'Spanish' },
31
+ { code: 'es_MX', name: 'Spanish (Mexico)' },
32
+ { code: 'es_AR', name: 'Spanish (Argentina)' },
33
+ { code: 'es_ES', name: 'Spanish (Spain)' },
34
+ { code: 'en', name: 'English' },
35
+ { code: 'en_US', name: 'English (US)' },
36
+ { code: 'en_GB', name: 'English (UK)' },
37
+ { code: 'pt_BR', name: 'Portuguese (Brazil)' },
38
+ { code: 'pt_PT', name: 'Portuguese (Portugal)' },
39
+ { code: 'af', name: 'Afrikaans' },
40
+ { code: 'sq', name: 'Albanian' },
41
+ { code: 'ar', name: 'Arabic' },
42
+ { code: 'az', name: 'Azerbaijani' },
43
+ { code: 'bn', name: 'Bengali' },
44
+ { code: 'bg', name: 'Bulgarian' },
45
+ { code: 'ca', name: 'Catalan' },
46
+ { code: 'zh_CN', name: 'Chinese (China)' },
47
+ { code: 'zh_HK', name: 'Chinese (Hong Kong)' },
48
+ { code: 'zh_TW', name: 'Chinese (Taiwan)' },
49
+ { code: 'hr', name: 'Croatian' },
50
+ { code: 'cs', name: 'Czech' },
51
+ { code: 'da', name: 'Danish' },
52
+ { code: 'nl', name: 'Dutch' },
53
+ { code: 'et', name: 'Estonian' },
54
+ { code: 'fil', name: 'Filipino' },
55
+ { code: 'fi', name: 'Finnish' },
56
+ { code: 'fr', name: 'French' },
57
+ { code: 'ka', name: 'Georgian' },
58
+ { code: 'de', name: 'German' },
59
+ { code: 'el', name: 'Greek' },
60
+ { code: 'gu', name: 'Gujarati' },
61
+ { code: 'ha', name: 'Hausa' },
62
+ { code: 'he', name: 'Hebrew' },
63
+ { code: 'hi', name: 'Hindi' },
64
+ { code: 'hu', name: 'Hungarian' },
65
+ { code: 'id', name: 'Indonesian' },
66
+ { code: 'ga', name: 'Irish' },
67
+ { code: 'it', name: 'Italian' },
68
+ { code: 'ja', name: 'Japanese' },
69
+ { code: 'kn', name: 'Kannada' },
70
+ { code: 'kk', name: 'Kazakh' },
71
+ { code: 'rw_RW', name: 'Kinyarwanda' },
72
+ { code: 'ko', name: 'Korean' },
73
+ { code: 'ky_KG', name: 'Kyrgyz' },
74
+ { code: 'lo', name: 'Lao' },
75
+ { code: 'lv', name: 'Latvian' },
76
+ { code: 'lt', name: 'Lithuanian' },
77
+ { code: 'mk', name: 'Macedonian' },
78
+ { code: 'ms', name: 'Malay' },
79
+ { code: 'ml', name: 'Malayalam' },
80
+ { code: 'mr', name: 'Marathi' },
81
+ { code: 'nb', name: 'Norwegian' },
82
+ { code: 'fa', name: 'Persian' },
83
+ { code: 'pl', name: 'Polish' },
84
+ { code: 'pa', name: 'Punjabi' },
85
+ { code: 'ro', name: 'Romanian' },
86
+ { code: 'ru', name: 'Russian' },
87
+ { code: 'sr', name: 'Serbian' },
88
+ { code: 'sk', name: 'Slovak' },
89
+ { code: 'sl', name: 'Slovenian' },
90
+ { code: 'sw', name: 'Swahili' },
91
+ { code: 'sv', name: 'Swedish' },
92
+ { code: 'ta', name: 'Tamil' },
93
+ { code: 'te', name: 'Telugu' },
94
+ { code: 'th', name: 'Thai' },
95
+ { code: 'tr', name: 'Turkish' },
96
+ { code: 'uk', name: 'Ukrainian' },
97
+ { code: 'ur', name: 'Urdu' },
98
+ { code: 'uz', name: 'Uzbek' },
99
+ { code: 'vi', name: 'Vietnamese' },
100
+ { code: 'zu', name: 'Zulu' }
101
+ ];
102
+ const VARIABLE = /\{\{\s*(\d+)\s*\}\}/g;
103
+ /** Each {{n}} in the text, in the order they appear. */
104
+ const whatsappTemplateVariables = (text) => Array.from(text.matchAll(VARIABLE), match => Number(match[1]));
105
+ exports.whatsappTemplateVariables = whatsappTemplateVariables;
106
+ /** How many variables the body has: its highest {{n}}. */
107
+ const whatsappTemplateVariableCount = (body) => Math.max(0, ...(0, exports.whatsappTemplateVariables)(body));
108
+ exports.whatsappTemplateVariableCount = whatsappTemplateVariableCount;
109
+ const words = (text) => text
110
+ .replace(VARIABLE, ' ')
111
+ .split(/\s+/)
112
+ .filter(word => /[\p{L}\p{N}]/u.test(word)).length;
113
+ /**
114
+ * The rules Meta rejects templates for most often, checked before sending
115
+ * one for review. Meta's review has the last word; this only catches the
116
+ * common mistakes early.
117
+ */
118
+ const checkWhatsappTemplate = (draft) => {
119
+ const issues = [];
120
+ const add = (code, n) => issues.push(n === undefined ? { code } : { code, n });
121
+ if (!NAME_PATTERN.test(draft.name))
122
+ add('name_format');
123
+ if (draft.name.length > exports.WHATSAPP_TEMPLATE_NAME_MAX)
124
+ add('name_long');
125
+ if (!exports.WHATSAPP_TEMPLATE_LANGUAGES.some(l => l.code === draft.language)) {
126
+ add('language');
127
+ }
128
+ if (!exports.WHATSAPP_TEMPLATE_CATEGORIES.includes(draft.category))
129
+ add('category');
130
+ const header = draft.header?.trim() ?? '';
131
+ if (header.length > exports.WHATSAPP_TEMPLATE_HEADER_MAX)
132
+ add('header_long');
133
+ if ((0, exports.whatsappTemplateVariables)(header).length > 0)
134
+ add('header_variables');
135
+ if (/[\n\r]/.test(header))
136
+ add('header_lines');
137
+ const footer = draft.footer?.trim() ?? '';
138
+ if (footer.length > exports.WHATSAPP_TEMPLATE_FOOTER_MAX)
139
+ add('footer_long');
140
+ if ((0, exports.whatsappTemplateVariables)(footer).length > 0)
141
+ add('footer_variables');
142
+ const buttons = draft.buttons ?? [];
143
+ if (buttons.length > exports.WHATSAPP_TEMPLATE_BUTTONS_MAX)
144
+ add('buttons_many');
145
+ if (buttons.some(button => !button.trim()))
146
+ add('button_empty');
147
+ if (buttons.some(button => button.trim().length > exports.WHATSAPP_TEMPLATE_BUTTON_MAX)) {
148
+ add('button_long');
149
+ }
150
+ if (buttons.some(button => (0, exports.whatsappTemplateVariables)(button).length > 0)) {
151
+ add('button_variables');
152
+ }
153
+ const unique = new Set(buttons.map(button => button.trim().toLowerCase()));
154
+ if (unique.size < buttons.length)
155
+ add('buttons_repeated');
156
+ const body = draft.body.trim();
157
+ if (!body) {
158
+ add('body_empty');
159
+ return issues;
160
+ }
161
+ if (body.length > exports.WHATSAPP_TEMPLATE_BODY_MAX)
162
+ add('body_long');
163
+ const found = (0, exports.whatsappTemplateVariables)(body);
164
+ if (found.length === 0)
165
+ return issues;
166
+ const count = Math.max(...found);
167
+ if (count > exports.WHATSAPP_TEMPLATE_VARIABLES_MAX)
168
+ add('variables_many');
169
+ // {{1}}, {{2}}, … with no gaps, each first used after the one before.
170
+ const firsts = [...new Set(found)];
171
+ if (firsts.some((n, i) => n !== i + 1))
172
+ add('variables_order');
173
+ if (/^\{\{\s*\d+\s*\}\}/.test(body))
174
+ add('variable_at_start');
175
+ if (/\{\{\s*\d+\s*\}\}[\s.!?…]*$/.test(body))
176
+ add('variable_at_end');
177
+ if (/\}\}\s*\{\{/.test(body))
178
+ add('variables_adjacent');
179
+ // Meta wants more words than variables; this is about the ratio it accepts.
180
+ if (words(body) < 2 * firsts.length + 1)
181
+ add('variables_dense');
182
+ const examples = draft.examples ?? [];
183
+ for (let n = 1; n <= count; n++) {
184
+ if (!examples[n - 1]?.trim())
185
+ add('example_missing', n);
186
+ }
187
+ return issues;
188
+ };
189
+ exports.checkWhatsappTemplate = checkWhatsappTemplate;
190
+ const ISSUE_TEXT = {
191
+ name_format: 'The name takes lowercase letters, digits and underscores only.',
192
+ name_long: `The name is at most ${exports.WHATSAPP_TEMPLATE_NAME_MAX} characters.`,
193
+ language: "The language isn't one Meta supports.",
194
+ category: 'The category is Utility or Marketing.',
195
+ body_empty: 'The message needs a body.',
196
+ body_long: `The body is at most ${exports.WHATSAPP_TEMPLATE_BODY_MAX} characters.`,
197
+ header_long: `The header is at most ${exports.WHATSAPP_TEMPLATE_HEADER_MAX} characters.`,
198
+ header_variables: "The header can't have variables.",
199
+ header_lines: 'The header is one line.',
200
+ footer_long: `The footer is at most ${exports.WHATSAPP_TEMPLATE_FOOTER_MAX} characters.`,
201
+ footer_variables: "The footer can't have variables.",
202
+ button_empty: 'A quick reply needs a text.',
203
+ button_long: `A quick reply is at most ${exports.WHATSAPP_TEMPLATE_BUTTON_MAX} characters.`,
204
+ button_variables: "A quick reply can't have variables.",
205
+ buttons_many: `At most ${exports.WHATSAPP_TEMPLATE_BUTTONS_MAX} quick replies.`,
206
+ buttons_repeated: 'Two quick replies say the same.',
207
+ variables_many: `At most ${exports.WHATSAPP_TEMPLATE_VARIABLES_MAX} variables.`,
208
+ variables_order: 'Variables are numbered {{1}}, {{2}}, … in the order they first appear, with no gaps.',
209
+ variable_at_start: 'Meta rejects a body that starts with a variable.',
210
+ variable_at_end: 'Meta rejects a body that ends with a variable.',
211
+ variables_adjacent: 'Meta rejects two variables side by side; put words between them.',
212
+ variables_dense: 'Too many variables for the length of the text; add words or remove variables.',
213
+ example_missing: 'Each variable needs an example value for Meta to review.'
214
+ };
215
+ const whatsappTemplateIssueText = (issue) => issue.n === undefined
216
+ ? ISSUE_TEXT[issue.code]
217
+ : `{{${issue.n}}}: ${ISSUE_TEXT[issue.code]}`;
218
+ exports.whatsappTemplateIssueText = whatsappTemplateIssueText;
219
+ /** The components Meta's create and edit calls take. */
220
+ const whatsappTemplateComponents = (draft) => {
221
+ const components = [];
222
+ const header = draft.header?.trim();
223
+ if (header)
224
+ components.push({ type: 'HEADER', format: 'TEXT', text: header });
225
+ const body = draft.body.trim();
226
+ const count = (0, exports.whatsappTemplateVariableCount)(body);
227
+ components.push({
228
+ type: 'BODY',
229
+ text: body,
230
+ ...(count > 0
231
+ ? {
232
+ example: {
233
+ body_text: [
234
+ Array.from({ length: count }, (_, i) => (draft.examples?.[i] ?? '').trim())
235
+ ]
236
+ }
237
+ }
238
+ : {})
239
+ });
240
+ const footer = draft.footer?.trim();
241
+ if (footer)
242
+ components.push({ type: 'FOOTER', text: footer });
243
+ const buttons = (draft.buttons ?? []).map(b => b.trim()).filter(Boolean);
244
+ if (buttons.length > 0) {
245
+ components.push({
246
+ type: 'BUTTONS',
247
+ buttons: buttons.map(text => ({ type: 'QUICK_REPLY', text }))
248
+ });
249
+ }
250
+ return components;
251
+ };
252
+ exports.whatsappTemplateComponents = whatsappTemplateComponents;
253
+ /** A template's text, from the components Meta returns. */
254
+ const whatsappTemplateContent = (components) => {
255
+ const content = {
256
+ header: null,
257
+ body: '',
258
+ footer: null,
259
+ buttons: [],
260
+ examples: []
261
+ };
262
+ if (!Array.isArray(components))
263
+ return content;
264
+ for (const component of components) {
265
+ const type = String(component?.type ?? '').toUpperCase();
266
+ const text = typeof component?.text === 'string' ? component.text : null;
267
+ if (type === 'HEADER' && component.format === 'TEXT')
268
+ content.header = text;
269
+ if (type === 'BODY') {
270
+ content.body = text ?? '';
271
+ const example = component.example;
272
+ const first = example?.body_text?.[0];
273
+ if (Array.isArray(first))
274
+ content.examples = first.map(v => String(v));
275
+ }
276
+ if (type === 'FOOTER')
277
+ content.footer = text;
278
+ if (type === 'BUTTONS' && Array.isArray(component.buttons)) {
279
+ content.buttons = component.buttons
280
+ .filter(b => b?.type === 'QUICK_REPLY' && typeof b.text === 'string')
281
+ .map(b => b.text);
282
+ }
283
+ }
284
+ return content;
285
+ };
286
+ exports.whatsappTemplateContent = whatsappTemplateContent;
287
+ /**
288
+ * Whether Ganju can send it: it fills the body's variables only. A media
289
+ * header, a header variable, or a button that takes a value (a URL with a
290
+ * variable, a code, a flow) needs more, and Meta refuses the send.
291
+ */
292
+ const whatsappTemplateSendable = (components) => {
293
+ if (!Array.isArray(components))
294
+ return false;
295
+ return components.every(component => {
296
+ const type = String(component?.type ?? '').toUpperCase();
297
+ if (type === 'BODY' || type === 'FOOTER')
298
+ return true;
299
+ if (type === 'HEADER') {
300
+ return (component.format === 'TEXT' &&
301
+ (0, exports.whatsappTemplateVariables)(String(component.text ?? '')).length === 0);
302
+ }
303
+ if (type === 'BUTTONS') {
304
+ const buttons = Array.isArray(component.buttons)
305
+ ? component.buttons
306
+ : [];
307
+ return buttons.every(button => {
308
+ const kind = String(button?.type ?? '').toUpperCase();
309
+ if (kind === 'QUICK_REPLY' || kind === 'PHONE_NUMBER')
310
+ return true;
311
+ if (kind === 'URL') {
312
+ return ((0, exports.whatsappTemplateVariables)(String(button.url ?? '')).length === 0);
313
+ }
314
+ return false;
315
+ });
316
+ }
317
+ return false;
318
+ });
319
+ };
320
+ exports.whatsappTemplateSendable = whatsappTemplateSendable;
321
+ /** The body with each {{n}} replaced, for previews and the message record. */
322
+ const fillWhatsappTemplate = (body, values) => body.replace(VARIABLE, (whole, n) => values[Number(n) - 1] ?? whole);
323
+ exports.fillWhatsappTemplate = fillWhatsappTemplate;
324
+ /**
325
+ * A value for a {{n}} as Meta accepts it: no line breaks or tabs, no long
326
+ * runs of spaces, and never empty.
327
+ */
328
+ const cleanWhatsappTemplateParam = (value) => {
329
+ const text = String(value ?? '')
330
+ .replace(/[\r\n\t]+/g, ' ')
331
+ .replace(/ {2,}/g, ' ')
332
+ .trim()
333
+ .slice(0, 1024);
334
+ return text || '-';
335
+ };
336
+ exports.cleanWhatsappTemplateParam = cleanWhatsappTemplateParam;
337
+ // Meta's error codes for a message that didn't go out, in words an owner can
338
+ // act on. `retry`: the same message may go through later.
339
+ const SEND_ERRORS = {
340
+ 1: { text: 'WhatsApp had a temporary problem.', retry: true },
341
+ 2: { text: 'WhatsApp had a temporary problem.', retry: true },
342
+ 10: { text: "The channel's access token isn't allowed to send messages." },
343
+ 190: {
344
+ text: "The channel's access token expired or was revoked; connect a new one in the channel's settings."
345
+ },
346
+ 200: { text: "The channel's access token isn't allowed to send messages." },
347
+ 368: {
348
+ text: 'Meta has temporarily blocked this WhatsApp Business account for policy violations.'
349
+ },
350
+ 130429: {
351
+ text: "The number's sending rate was reached; it will be tried again.",
352
+ retry: true
353
+ },
354
+ 131000: { text: 'WhatsApp had a temporary problem.', retry: true },
355
+ 131016: { text: 'WhatsApp is temporarily unavailable.', retry: true },
356
+ 131026: {
357
+ text: "WhatsApp couldn't deliver it: the customer may have blocked the business, left WhatsApp, or not accepted its latest terms."
358
+ },
359
+ 131031: { text: 'Meta locked this WhatsApp Business account.' },
360
+ 131042: {
361
+ text: "Meta refused it over a payment problem on the WhatsApp Business account; check its payment method in Meta's Business settings."
362
+ },
363
+ 131047: {
364
+ text: "Outside WhatsApp's 24-hour window: the customer last wrote more than 24 hours ago, so only an approved template reaches them."
365
+ },
366
+ 131048: {
367
+ text: "Meta limited this number's sending because of its quality: too many customers blocked or reported its messages."
368
+ },
369
+ 131049: {
370
+ text: 'Meta chose not to deliver this marketing message, to limit how many each customer gets.'
371
+ },
372
+ 131050: {
373
+ text: 'The customer turned off marketing messages from this business.'
374
+ },
375
+ 131056: {
376
+ text: 'Too many messages to this customer in a short time; it will be tried again.',
377
+ retry: true
378
+ },
379
+ 132000: {
380
+ text: "The number of values doesn't match the template's variables."
381
+ },
382
+ 132001: {
383
+ text: 'Meta has no approved template with that name in that language.'
384
+ },
385
+ 132005: { text: "The template's text is too long with the values put in." },
386
+ 132007: { text: "The template's text breaks WhatsApp's commerce policy." },
387
+ 132012: { text: "A value doesn't match what the template expects." },
388
+ 132015: { text: 'Meta paused this template because of its quality.' },
389
+ 132016: { text: 'Meta disabled this template because of its quality.' },
390
+ 132018: { text: 'A value has line breaks, tabs or too many spaces.' }
391
+ };
392
+ /** A Meta send error as an owner reads it, and whether a retry may help. */
393
+ const whatsappSendError = (code, detail) => {
394
+ const known = code !== undefined ? SEND_ERRORS[code] : undefined;
395
+ if (known)
396
+ return { text: known.text, retry: !!known.retry };
397
+ return {
398
+ text: detail ? detail.slice(0, 300) : 'WhatsApp refused the message.',
399
+ retry: true
400
+ };
401
+ };
402
+ exports.whatsappSendError = whatsappSendError;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ganju/utils",
3
- "version": "0.0.17",
3
+ "version": "0.0.20",
4
4
  "description": "Shared constants, schemas and helpers for Ganju",
5
5
  "license": "Apache-2.0",
6
6
  "author": "MontoyaAndres <andresmontoyafcb@gmail.com>",
package/src/constants.ts CHANGED
@@ -23,6 +23,9 @@ import {
23
23
  CUSTOM_CODE_BROKER_PATH_RESOURCES_DELETE,
24
24
  CUSTOM_CODE_BROKER_PATH_SEND_FILE,
25
25
  CUSTOM_CODE_BROKER_PATH_DATA,
26
+ CUSTOM_CODE_BROKER_PATH_CHANNELS_SEND,
27
+ CUSTOM_CODE_BROKER_PATH_CHANNELS_POST,
28
+ CUSTOM_CODE_BROKER_PATH_CHANNELS_TEMPLATES,
26
29
  CUSTOM_CODE_HEALTH_TOOL,
27
30
  CUSTOM_CODE_MAX_LOGS,
28
31
  CUSTOM_CODE_MAX_LOG_LENGTH
@@ -2269,6 +2272,9 @@ interface PlanLimits {
2269
2272
  maxDataTablesPerDatabase: number | null;
2270
2273
  // Abuse backstop on data trigger runs a month. `null` = no backstop.
2271
2274
  triggerRunHardCap: number | null;
2275
+ // Email trigger runs a day per organization, each to at most 10 addresses.
2276
+ // They go out from Ganju's own domain. `null` = no cap.
2277
+ dailyTriggerEmailCap: number | null;
2272
2278
  }
2273
2279
 
2274
2280
  const PLAN_LIMITS: Record<
@@ -2313,7 +2319,8 @@ const PLAN_LIMITS: Record<
2313
2319
  canUseData: false,
2314
2320
  maxDataBytes: null,
2315
2321
  maxDataTablesPerDatabase: null,
2316
- triggerRunHardCap: 0
2322
+ triggerRunHardCap: 0,
2323
+ dailyTriggerEmailCap: 0
2317
2324
  },
2318
2325
  PRO: {
2319
2326
  maxProjects: null,
@@ -2344,7 +2351,8 @@ const PLAN_LIMITS: Record<
2344
2351
  canUseData: true,
2345
2352
  maxDataBytes: DATA_MAX_DATABASE_BYTES,
2346
2353
  maxDataTablesPerDatabase: DATA_MAX_TABLES_PER_DATABASE,
2347
- triggerRunHardCap: DATA_TRIGGER_RUN_HARD_CAP
2354
+ triggerRunHardCap: DATA_TRIGGER_RUN_HARD_CAP,
2355
+ dailyTriggerEmailCap: 500
2348
2356
  },
2349
2357
  ENTERPRISE: {
2350
2358
  maxProjects: null,
@@ -2375,7 +2383,8 @@ const PLAN_LIMITS: Record<
2375
2383
  canUseData: true,
2376
2384
  maxDataBytes: DATA_MAX_DATABASE_BYTES,
2377
2385
  maxDataTablesPerDatabase: DATA_MAX_TABLES_PER_DATABASE,
2378
- triggerRunHardCap: null
2386
+ triggerRunHardCap: null,
2387
+ dailyTriggerEmailCap: 5000
2379
2388
  }
2380
2389
  };
2381
2390
 
@@ -2422,9 +2431,17 @@ const BILLING_METER_EMBEDDED = 'ganju_embedded_storage';
2422
2431
  // of ours quotes.
2423
2432
  const BILLING_METER_TOOL_CALLS = 'ganju_custom_tool_calls';
2424
2433
 
2434
+ // Project data, reported in full (not past an allowance) and attached at $0:
2435
+ // measured before they are priced. Storage is the period's peak in whole MB,
2436
+ // rows and trigger runs are counts.
2437
+ const BILLING_METER_DATA_STORAGE = 'ganju_data_storage';
2438
+ const BILLING_METER_DATA_ROWS_WRITTEN = 'ganju_data_rows_written';
2439
+ const BILLING_METER_DATA_ROWS_READ = 'ganju_data_rows_read';
2440
+ const BILLING_METER_DATA_TRIGGER_RUNS = 'ganju_data_trigger_runs';
2441
+
2425
2442
  // The metadata property every meter aggregates (`sum` over it). Ingested events
2426
2443
  // carry the increment under this key, so it has to agree with the aggregation
2427
- // configured on all four meters — which is why it lives beside their names
2444
+ // configured on every meter — which is why it lives beside their names
2428
2445
  // rather than at the call site.
2429
2446
  const BILLING_METER_UNITS_KEY = 'units';
2430
2447
 
@@ -2599,6 +2616,10 @@ export const constants = {
2599
2616
  BILLING_METER_SHARED_MESSAGES,
2600
2617
  BILLING_METER_EMBEDDED,
2601
2618
  BILLING_METER_TOOL_CALLS,
2619
+ BILLING_METER_DATA_STORAGE,
2620
+ BILLING_METER_DATA_ROWS_WRITTEN,
2621
+ BILLING_METER_DATA_ROWS_READ,
2622
+ BILLING_METER_DATA_TRIGGER_RUNS,
2602
2623
  BILLING_METER_UNITS_KEY,
2603
2624
  POLAR_API_BASE,
2604
2625
  POLAR_SANDBOX_API_BASE,
@@ -3097,6 +3118,9 @@ export const constants = {
3097
3118
  CUSTOM_CODE_BROKER_PATH_RESOURCES_DELETE,
3098
3119
  CUSTOM_CODE_BROKER_PATH_SEND_FILE,
3099
3120
  CUSTOM_CODE_BROKER_PATH_DATA,
3121
+ CUSTOM_CODE_BROKER_PATH_CHANNELS_SEND,
3122
+ CUSTOM_CODE_BROKER_PATH_CHANNELS_POST,
3123
+ CUSTOM_CODE_BROKER_PATH_CHANNELS_TEMPLATES,
3100
3124
  CUSTOM_CODE_OUTBOUND_PARAM_ARTIFACT_ID,
3101
3125
  CUSTOM_CODE_OUTBOUND_PARAM_ALLOWED_HOSTS,
3102
3126
  CUSTOM_CODE_PLATFORM_HOSTS,
package/src/crypto.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { Context } from 'hono';
2
1
  import { xchacha20poly1305 } from '@noble/ciphers/chacha.js';
3
2
  import { getEnv } from './getEnv';
3
+ import type { EnvSource } from './getEnv';
4
4
  import { base64ToBytes, bytesToBase64 } from './base64';
5
5
 
6
6
  const PREFIX = 'enc:v1:';
@@ -8,7 +8,8 @@ const NONCE_BYTES = 24;
8
8
  const KEY_BYTES = 32;
9
9
  const KEY_ENV = 'CRYPTO_SECRET';
10
10
 
11
- export const getCredentialEncryptionKey = (c: Context) => {
11
+ // A request's context, or anything with the worker's env (a cron, a queue).
12
+ export const getCredentialEncryptionKey = (c: EnvSource) => {
12
13
  const rawKey = getEnv(c, KEY_ENV);
13
14
  if (!rawKey) {
14
15
  throw new Error(`Missing env: ${KEY_ENV}`);
@@ -193,6 +193,9 @@ export interface CustomCodeCallPayload {
193
193
  versionId: string;
194
194
  tool: string;
195
195
  exp: number;
196
+ // A run a database trigger started: its writes are the trigger's, never
197
+ // matched by that same trigger, and fire others one level deeper.
198
+ trigger?: { id: string; depth: number };
196
199
  }
197
200
 
198
201
  const callSecret = (secret: string) => `${secret}:call`;
@@ -207,7 +210,8 @@ export const mintCustomCodeCallToken = async (
207
210
  artifactId: payload.artifactId,
208
211
  versionId: payload.versionId,
209
212
  tool: payload.tool,
210
- exp: Math.ceil((issuedAt + payload.ttlMs) / 1000)
213
+ exp: Math.ceil((issuedAt + payload.ttlMs) / 1000),
214
+ ...(payload.trigger ? { trigger: payload.trigger } : {})
211
215
  };
212
216
  const encodedPayload = toBase64Url(
213
217
  bytesToBase64(encoder.encode(JSON.stringify(body)))
@@ -246,6 +250,15 @@ export const verifyCustomCodeCallToken = async (
246
250
  ) {
247
251
  return null;
248
252
  }
253
+ const trigger = candidate.trigger;
254
+ if (
255
+ trigger !== undefined &&
256
+ (typeof trigger?.id !== 'string' ||
257
+ !Number.isInteger(trigger.depth) ||
258
+ trigger.depth < 1)
259
+ ) {
260
+ return null;
261
+ }
249
262
  return candidate as CustomCodeCallPayload;
250
263
  } catch {
251
264
  return null;
package/src/data.ts CHANGED
@@ -106,3 +106,43 @@ export {
106
106
  dataAdminToolDefinition
107
107
  } from './dataTools';
108
108
  export type { DataToolDefinition, DataAdminAction } from './dataTools';
109
+ export {
110
+ DATA_TRIGGER_EVENTS,
111
+ DATA_TRIGGER_ACTIONS,
112
+ DATA_MAX_TRIGGERS,
113
+ DATA_TRIGGER_MAX_DEPTH,
114
+ DATA_TRIGGER_DEFAULT_RATE,
115
+ DATA_TRIGGER_MAX_RATE,
116
+ DATA_OUTBOX_BATCH,
117
+ DATA_TRIGGER_MAX_MESSAGE_BYTES,
118
+ utf8Bytes,
119
+ conditionMatches,
120
+ renderTriggerText,
121
+ normaliseTrigger,
122
+ listTriggers,
123
+ getTrigger,
124
+ saveTrigger,
125
+ dropTrigger,
126
+ setTriggerEnabled,
127
+ replaceManagedTriggers,
128
+ collectTriggerEvents,
129
+ readOutbox,
130
+ deleteOutbox,
131
+ outboxSize,
132
+ clearOutbox,
133
+ triggersUsingColumn,
134
+ renameColumnInTriggers
135
+ } from './dataTriggers';
136
+ export type {
137
+ DataTrigger,
138
+ DataTriggerAction,
139
+ DataTriggerActionType,
140
+ DataTriggerCondition,
141
+ DataTriggerEvent,
142
+ DataTriggerEventMessage,
143
+ DataTriggerInput,
144
+ DataTriggerTemplate,
145
+ DataTriggerTest,
146
+ DataTriggerWithSecret,
147
+ SavedTrigger
148
+ } from './dataTriggers';