@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.
- package/dist/constants.d.ts +8 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +21 -4
- package/dist/crypto.d.ts +2 -2
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js +1 -0
- package/dist/customCodeToken.d.ts +4 -0
- package/dist/customCodeToken.d.ts.map +1 -1
- package/dist/customCodeToken.js +9 -1
- package/dist/data.d.ts +2 -0
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +28 -1
- package/dist/dataApi.d.ts +133 -1
- package/dist/dataApi.d.ts.map +1 -1
- package/dist/dataApi.js +62 -1
- package/dist/dataDdl.d.ts.map +1 -1
- package/dist/dataDdl.js +3 -1
- package/dist/dataEngine.d.ts.map +1 -1
- package/dist/dataEngine.js +62 -0
- package/dist/dataRpc.d.ts +25 -0
- package/dist/dataRpc.d.ts.map +1 -1
- package/dist/dataTriggers.d.ts +203 -0
- package/dist/dataTriggers.d.ts.map +1 -0
- package/dist/dataTriggers.js +760 -0
- package/dist/dataTypes.d.ts +1 -0
- package/dist/dataTypes.d.ts.map +1 -1
- package/dist/index.d.ts +198 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +27 -0
- package/dist/localizeDataError.d.ts +18 -0
- package/dist/localizeDataError.d.ts.map +1 -0
- package/dist/localizeDataError.js +359 -0
- package/dist/schema.d.ts +161 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +146 -1
- package/dist/sdkConstants.d.ts +3 -0
- package/dist/sdkConstants.d.ts.map +1 -1
- package/dist/sdkConstants.js +4 -1
- package/dist/whatsappTemplates.d.ts +77 -0
- package/dist/whatsappTemplates.d.ts.map +1 -0
- package/dist/whatsappTemplates.js +402 -0
- package/package.json +1 -1
- package/src/constants.ts +28 -4
- package/src/crypto.ts +3 -2
- package/src/customCodeToken.ts +14 -1
- package/src/data.ts +40 -0
- package/src/dataApi.ts +78 -2
- package/src/dataDdl.ts +3 -1
- package/src/dataEngine.ts +70 -0
- package/src/dataRpc.ts +15 -0
- package/src/dataTriggers.ts +1134 -0
- package/src/dataTypes.ts +3 -0
- package/src/index.ts +65 -1
- package/src/localizeDataError.ts +593 -0
- package/src/schema.ts +170 -1
- package/src/sdkConstants.ts +3 -0
- 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
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
|
|
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
|
-
|
|
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}`);
|
package/src/customCodeToken.ts
CHANGED
|
@@ -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';
|