@ganju/utils 0.0.18 → 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.
@@ -1 +1 @@
1
- {"version":3,"file":"sdkConstants.d.ts","sourceRoot":"","sources":["../src/sdkConstants.ts"],"names":[],"mappings":"AAgBA,eAAO,MAAM,yBAAyB,qBAAqB,CAAC;AAC5D,eAAO,MAAM,0BAA0B,iBAAiB,CAAC;AAKzD,eAAO,MAAM,yBAAyB,kCAAkC,CAAC;AACzE,eAAO,MAAM,kCAAkC,gBAAgB,CAAC;AAChE,eAAO,MAAM,8BAA8B,YAAY,CAAC;AACxD,eAAO,MAAM,wCAAwC,sBAAsB,CAAC;AAC5E,eAAO,MAAM,sCAAsC,oBAAoB,CAAC;AACxE,eAAO,MAAM,sCAAsC,oBAAoB,CAAC;AACxE,eAAO,MAAM,wCAAwC,sBAAsB,CAAC;AAC5E,eAAO,MAAM,wCAAwC,sBAAsB,CAAC;AAC5E,eAAO,MAAM,iCAAiC,eAAe,CAAC;AAC9D,eAAO,MAAM,4BAA4B,UAAU,CAAC;AACpD,eAAO,MAAM,qCAAqC,mBAAmB,CAAC;AACtE,eAAO,MAAM,qCAAqC,mBAAmB,CAAC;AAYtE,eAAO,MAAM,2BAA2B,yBAAyB,CAAC;AAOlE,eAAO,MAAM,uBAAuB,mBAAmB,CAAC;AAKxD,eAAO,MAAM,oBAAoB,KAAK,CAAC;AACvC,eAAO,MAAM,0BAA0B,OAAQ,CAAC"}
1
+ {"version":3,"file":"sdkConstants.d.ts","sourceRoot":"","sources":["../src/sdkConstants.ts"],"names":[],"mappings":"AAgBA,eAAO,MAAM,yBAAyB,qBAAqB,CAAC;AAC5D,eAAO,MAAM,0BAA0B,iBAAiB,CAAC;AAKzD,eAAO,MAAM,yBAAyB,kCAAkC,CAAC;AACzE,eAAO,MAAM,kCAAkC,gBAAgB,CAAC;AAChE,eAAO,MAAM,8BAA8B,YAAY,CAAC;AACxD,eAAO,MAAM,wCAAwC,sBAAsB,CAAC;AAC5E,eAAO,MAAM,sCAAsC,oBAAoB,CAAC;AACxE,eAAO,MAAM,sCAAsC,oBAAoB,CAAC;AACxE,eAAO,MAAM,wCAAwC,sBAAsB,CAAC;AAC5E,eAAO,MAAM,wCAAwC,sBAAsB,CAAC;AAC5E,eAAO,MAAM,iCAAiC,eAAe,CAAC;AAC9D,eAAO,MAAM,4BAA4B,UAAU,CAAC;AACpD,eAAO,MAAM,qCAAqC,mBAAmB,CAAC;AACtE,eAAO,MAAM,qCAAqC,mBAAmB,CAAC;AACtE,eAAO,MAAM,0CAA0C,wBAAwB,CAAC;AAYhF,eAAO,MAAM,2BAA2B,yBAAyB,CAAC;AAOlE,eAAO,MAAM,uBAAuB,mBAAmB,CAAC;AAKxD,eAAO,MAAM,oBAAoB,KAAK,CAAC;AACvC,eAAO,MAAM,0BAA0B,OAAQ,CAAC"}
@@ -11,7 +11,7 @@
11
11
  // The rule for this file: nothing lands here unless the SDK reads it at runtime,
12
12
  // and it imports nothing.
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
- exports.CUSTOM_CODE_MAX_LOG_LENGTH = exports.CUSTOM_CODE_MAX_LOGS = exports.CUSTOM_CODE_HEALTH_TOOL = exports.CUSTOM_CODE_BINDING_VERSION = exports.CUSTOM_CODE_BROKER_PATH_CHANNELS_POST = exports.CUSTOM_CODE_BROKER_PATH_CHANNELS_SEND = exports.CUSTOM_CODE_BROKER_PATH_DATA = exports.CUSTOM_CODE_BROKER_PATH_SEND_FILE = exports.CUSTOM_CODE_BROKER_PATH_RESOURCES_DELETE = exports.CUSTOM_CODE_BROKER_PATH_RESOURCES_CREATE = exports.CUSTOM_CODE_BROKER_PATH_RESOURCES_LIST = exports.CUSTOM_CODE_BROKER_PATH_RESOURCES_READ = exports.CUSTOM_CODE_BROKER_PATH_RESOURCES_SEARCH = exports.CUSTOM_CODE_BROKER_PATH_SECRET = exports.CUSTOM_CODE_BROKER_PATH_CONNECTION = exports.CUSTOM_CODE_BROKER_ORIGIN = exports.CUSTOM_CODE_BINDING_BROKER = exports.CUSTOM_CODE_BINDING_TOKEN = void 0;
14
+ exports.CUSTOM_CODE_MAX_LOG_LENGTH = exports.CUSTOM_CODE_MAX_LOGS = exports.CUSTOM_CODE_HEALTH_TOOL = exports.CUSTOM_CODE_BINDING_VERSION = exports.CUSTOM_CODE_BROKER_PATH_CHANNELS_TEMPLATES = exports.CUSTOM_CODE_BROKER_PATH_CHANNELS_POST = exports.CUSTOM_CODE_BROKER_PATH_CHANNELS_SEND = exports.CUSTOM_CODE_BROKER_PATH_DATA = exports.CUSTOM_CODE_BROKER_PATH_SEND_FILE = exports.CUSTOM_CODE_BROKER_PATH_RESOURCES_DELETE = exports.CUSTOM_CODE_BROKER_PATH_RESOURCES_CREATE = exports.CUSTOM_CODE_BROKER_PATH_RESOURCES_LIST = exports.CUSTOM_CODE_BROKER_PATH_RESOURCES_READ = exports.CUSTOM_CODE_BROKER_PATH_RESOURCES_SEARCH = exports.CUSTOM_CODE_BROKER_PATH_SECRET = exports.CUSTOM_CODE_BROKER_PATH_CONNECTION = exports.CUSTOM_CODE_BROKER_ORIGIN = exports.CUSTOM_CODE_BINDING_BROKER = exports.CUSTOM_CODE_BINDING_TOKEN = void 0;
15
15
  // Bindings injected into every user script at upload time. The token is a
16
16
  // secret_text binding rotated on each publish; the broker is a service binding.
17
17
  // Names are part of the SDK's contract with the script, so they can't be
@@ -33,6 +33,7 @@ exports.CUSTOM_CODE_BROKER_PATH_SEND_FILE = '/send-file';
33
33
  exports.CUSTOM_CODE_BROKER_PATH_DATA = '/data';
34
34
  exports.CUSTOM_CODE_BROKER_PATH_CHANNELS_SEND = '/channels/send';
35
35
  exports.CUSTOM_CODE_BROKER_PATH_CHANNELS_POST = '/channels/post';
36
+ exports.CUSTOM_CODE_BROKER_PATH_CHANNELS_TEMPLATES = '/channels/templates';
36
37
  // Injected at upload time and echoed back by the health probe below. Uploading a
37
38
  // script into the dispatch namespace is not read-your-writes: dispatching to the
38
39
  // name immediately afterwards can still reach the previous edition. Tool names
@@ -0,0 +1,77 @@
1
+ export declare const WHATSAPP_TEMPLATE_CATEGORIES: readonly ["UTILITY", "MARKETING"];
2
+ export type WhatsappTemplateCategory = (typeof WHATSAPP_TEMPLATE_CATEGORIES)[number];
3
+ export declare const WHATSAPP_TEMPLATE_STATUS_APPROVED = "APPROVED";
4
+ export declare const WHATSAPP_TEMPLATE_STATUSES_WAITING: string[];
5
+ export declare const WHATSAPP_TEMPLATE_STATUSES_STOPPED: string[];
6
+ export declare const WHATSAPP_TEMPLATE_NAME_MAX = 512;
7
+ export declare const WHATSAPP_TEMPLATE_BODY_MAX = 1024;
8
+ export declare const WHATSAPP_TEMPLATE_HEADER_MAX = 60;
9
+ export declare const WHATSAPP_TEMPLATE_FOOTER_MAX = 60;
10
+ export declare const WHATSAPP_TEMPLATE_BUTTON_MAX = 25;
11
+ export declare const WHATSAPP_TEMPLATE_BUTTONS_MAX = 10;
12
+ export declare const WHATSAPP_TEMPLATE_VARIABLES_MAX = 20;
13
+ export declare const WHATSAPP_TEMPLATE_LABEL_MAX = 40;
14
+ export declare const WHATSAPP_TEMPLATE_EXAMPLE_MAX = 200;
15
+ export declare const WHATSAPP_TEMPLATE_LANGUAGES: {
16
+ code: string;
17
+ name: string;
18
+ }[];
19
+ /** A template as the owner writes it. `examples[i]` and `labels[i]` are {{i+1}}'s. */
20
+ export interface WhatsappTemplateDraft {
21
+ name: string;
22
+ language: string;
23
+ category: WhatsappTemplateCategory;
24
+ header?: string | null;
25
+ body: string;
26
+ footer?: string | null;
27
+ buttons?: string[];
28
+ examples?: string[];
29
+ labels?: string[];
30
+ }
31
+ /** A template's text, read back from Meta's components. */
32
+ export interface WhatsappTemplateContent {
33
+ header: string | null;
34
+ body: string;
35
+ footer: string | null;
36
+ buttons: string[];
37
+ examples: string[];
38
+ }
39
+ export type WhatsappTemplateIssueCode = 'name_format' | 'name_long' | 'language' | 'category' | 'body_empty' | 'body_long' | 'header_long' | 'header_variables' | 'header_lines' | 'footer_long' | 'footer_variables' | 'button_empty' | 'button_long' | 'button_variables' | 'buttons_many' | 'buttons_repeated' | 'variables_many' | 'variables_order' | 'variable_at_start' | 'variable_at_end' | 'variables_adjacent' | 'variables_dense' | 'example_missing';
40
+ export interface WhatsappTemplateIssue {
41
+ code: WhatsappTemplateIssueCode;
42
+ n?: number;
43
+ }
44
+ /** Each {{n}} in the text, in the order they appear. */
45
+ export declare const whatsappTemplateVariables: (text: string) => number[];
46
+ /** How many variables the body has: its highest {{n}}. */
47
+ export declare const whatsappTemplateVariableCount: (body: string) => number;
48
+ /**
49
+ * The rules Meta rejects templates for most often, checked before sending
50
+ * one for review. Meta's review has the last word; this only catches the
51
+ * common mistakes early.
52
+ */
53
+ export declare const checkWhatsappTemplate: (draft: WhatsappTemplateDraft) => WhatsappTemplateIssue[];
54
+ export declare const whatsappTemplateIssueText: (issue: WhatsappTemplateIssue) => string;
55
+ /** The components Meta's create and edit calls take. */
56
+ export declare const whatsappTemplateComponents: (draft: WhatsappTemplateDraft) => Record<string, unknown>[];
57
+ /** A template's text, from the components Meta returns. */
58
+ export declare const whatsappTemplateContent: (components: unknown) => WhatsappTemplateContent;
59
+ /**
60
+ * Whether Ganju can send it: it fills the body's variables only. A media
61
+ * header, a header variable, or a button that takes a value (a URL with a
62
+ * variable, a code, a flow) needs more, and Meta refuses the send.
63
+ */
64
+ export declare const whatsappTemplateSendable: (components: unknown) => boolean;
65
+ /** The body with each {{n}} replaced, for previews and the message record. */
66
+ export declare const fillWhatsappTemplate: (body: string, values: string[]) => string;
67
+ /**
68
+ * A value for a {{n}} as Meta accepts it: no line breaks or tabs, no long
69
+ * runs of spaces, and never empty.
70
+ */
71
+ export declare const cleanWhatsappTemplateParam: (value: unknown) => string;
72
+ /** A Meta send error as an owner reads it, and whether a retry may help. */
73
+ export declare const whatsappSendError: (code: number | undefined, detail: string | undefined) => {
74
+ text: string;
75
+ retry: boolean;
76
+ };
77
+ //# sourceMappingURL=whatsappTemplates.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"whatsappTemplates.d.ts","sourceRoot":"","sources":["../src/whatsappTemplates.ts"],"names":[],"mappings":"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"}
@@ -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.18",
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
@@ -25,6 +25,7 @@ import {
25
25
  CUSTOM_CODE_BROKER_PATH_DATA,
26
26
  CUSTOM_CODE_BROKER_PATH_CHANNELS_SEND,
27
27
  CUSTOM_CODE_BROKER_PATH_CHANNELS_POST,
28
+ CUSTOM_CODE_BROKER_PATH_CHANNELS_TEMPLATES,
28
29
  CUSTOM_CODE_HEALTH_TOOL,
29
30
  CUSTOM_CODE_MAX_LOGS,
30
31
  CUSTOM_CODE_MAX_LOG_LENGTH
@@ -2271,6 +2272,9 @@ interface PlanLimits {
2271
2272
  maxDataTablesPerDatabase: number | null;
2272
2273
  // Abuse backstop on data trigger runs a month. `null` = no backstop.
2273
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;
2274
2278
  }
2275
2279
 
2276
2280
  const PLAN_LIMITS: Record<
@@ -2315,7 +2319,8 @@ const PLAN_LIMITS: Record<
2315
2319
  canUseData: false,
2316
2320
  maxDataBytes: null,
2317
2321
  maxDataTablesPerDatabase: null,
2318
- triggerRunHardCap: 0
2322
+ triggerRunHardCap: 0,
2323
+ dailyTriggerEmailCap: 0
2319
2324
  },
2320
2325
  PRO: {
2321
2326
  maxProjects: null,
@@ -2346,7 +2351,8 @@ const PLAN_LIMITS: Record<
2346
2351
  canUseData: true,
2347
2352
  maxDataBytes: DATA_MAX_DATABASE_BYTES,
2348
2353
  maxDataTablesPerDatabase: DATA_MAX_TABLES_PER_DATABASE,
2349
- triggerRunHardCap: DATA_TRIGGER_RUN_HARD_CAP
2354
+ triggerRunHardCap: DATA_TRIGGER_RUN_HARD_CAP,
2355
+ dailyTriggerEmailCap: 500
2350
2356
  },
2351
2357
  ENTERPRISE: {
2352
2358
  maxProjects: null,
@@ -2377,7 +2383,8 @@ const PLAN_LIMITS: Record<
2377
2383
  canUseData: true,
2378
2384
  maxDataBytes: DATA_MAX_DATABASE_BYTES,
2379
2385
  maxDataTablesPerDatabase: DATA_MAX_TABLES_PER_DATABASE,
2380
- triggerRunHardCap: null
2386
+ triggerRunHardCap: null,
2387
+ dailyTriggerEmailCap: 5000
2381
2388
  }
2382
2389
  };
2383
2390
 
@@ -3113,6 +3120,7 @@ export const constants = {
3113
3120
  CUSTOM_CODE_BROKER_PATH_DATA,
3114
3121
  CUSTOM_CODE_BROKER_PATH_CHANNELS_SEND,
3115
3122
  CUSTOM_CODE_BROKER_PATH_CHANNELS_POST,
3123
+ CUSTOM_CODE_BROKER_PATH_CHANNELS_TEMPLATES,
3116
3124
  CUSTOM_CODE_OUTBOUND_PARAM_ARTIFACT_ID,
3117
3125
  CUSTOM_CODE_OUTBOUND_PARAM_ALLOWED_HOSTS,
3118
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}`);
package/src/dataApi.ts CHANGED
@@ -328,9 +328,9 @@ export const DATA_API_ROUTES = {
328
328
  tag: 'Triggers',
329
329
  summary: 'Save a trigger',
330
330
  description:
331
- 'Runs one action when rows of `table` are inserted, updated or deleted, and `when` holds: `{ "status": { "changedTo": "READY" } }` fires on the transition only; `changedFrom`, `changed: true` and `equals` test a column too, and `all` / `any` combine them. The action is one of `function` (a function of this project, given `{ event, table, row, previous, actor, eventId, triggerId }`), `webhook` (an https URL, sent a POST signed in `x-ganju-signature`), `chat` (`{ channelId, chatId, text }`), `email` (`{ to, subject, text }`) or `customer` (`{ text, template? }`, to the row\'s customer on a customer-scoped table). Texts take `{{field}}` from the row, inserted as plain text. Saving under an existing name replaces it, unless `replace` is false; with no `name`, it always creates a new trigger, named from the table, event and action and numbered past those taken. Left out, `enabled` keeps a saved trigger on or off as it was. A new webhook answers with its signing `secret`, shown this once. Delivery is at least once: every event carries an `eventId`.',
331
+ 'Runs one action when rows of `table` are inserted, updated or deleted, and `when` holds: `{ "status": { "changedTo": "READY" } }` fires on the transition only; `changedFrom`, `changed: true` and `equals` test a column too, and `all` / `any` combine them. The action is one of `function` (a function of this project, given `{ event, table, row, previous, actor, eventId, triggerId }`), `webhook` (an https URL, sent a POST signed in `x-ganju-signature`), `chat` (`{ channelId, chatId, text }`), `email` (`{ to, subject, text }`) or `customer` (`{ text, template? }`, to the row\'s customer on a customer-scoped table). Texts take `{{field}}` from the row, inserted as plain text. Saving under an existing name replaces it, unless `replace` is false; with no `name`, it always creates a new trigger, named from the table, event and action and numbered past those taken. Left out, `enabled` keeps a saved trigger on or off as it was. A new webhook answers with its signing `secret`, shown this once. An `email` goes to the organization\'s members; any other address is sent a link to confirm first, and those still waiting come back as `confirming`. Delivery is at least once: every event carries an `eventId`.',
332
332
  body: DATA_TRIGGER_INPUT,
333
- returns: '{ trigger, secret? }',
333
+ returns: '{ trigger, secret?, confirming? }',
334
334
  status: 201
335
335
  },
336
336
  updateTrigger: {
@@ -356,9 +356,9 @@ export const DATA_API_ROUTES = {
356
356
  tag: 'Triggers',
357
357
  summary: 'Deploy the triggers of ganju.json',
358
358
  description:
359
- "What `ganju deploy` sends: every trigger listed is saved as managed by the CLI, and the CLI's earlier ones not listed are deleted. Triggers made in the dashboard are left alone. All or nothing. New webhooks' secrets come back once, by name.",
359
+ "What `ganju deploy` sends: every trigger listed is saved as managed by the CLI, and the CLI's earlier ones not listed are deleted. Triggers made in the dashboard are left alone. All or nothing. New webhooks' secrets come back once, by name, and email addresses still to confirm as `confirming`.",
360
360
  body: DATA_TRIGGER_DEPLOY_BODY,
361
- returns: '{ triggers, removed, secrets }'
361
+ returns: '{ triggers, removed, secrets, confirming? }'
362
362
  },
363
363
  listTriggerRuns: {
364
364
  method: 'get',