@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.
package/src/index.ts CHANGED
@@ -365,12 +365,69 @@ import {
365
365
  fetchGoogleCalendarTimeZone,
366
366
  fetchCalcomTimeZone
367
367
  } from './vendorTimeZone';
368
+ import {
369
+ WHATSAPP_TEMPLATE_CATEGORIES,
370
+ WHATSAPP_TEMPLATE_STATUS_APPROVED,
371
+ WHATSAPP_TEMPLATE_STATUSES_WAITING,
372
+ WHATSAPP_TEMPLATE_STATUSES_STOPPED,
373
+ WHATSAPP_TEMPLATE_NAME_MAX,
374
+ WHATSAPP_TEMPLATE_BODY_MAX,
375
+ WHATSAPP_TEMPLATE_HEADER_MAX,
376
+ WHATSAPP_TEMPLATE_FOOTER_MAX,
377
+ WHATSAPP_TEMPLATE_BUTTON_MAX,
378
+ WHATSAPP_TEMPLATE_BUTTONS_MAX,
379
+ WHATSAPP_TEMPLATE_VARIABLES_MAX,
380
+ WHATSAPP_TEMPLATE_LABEL_MAX,
381
+ WHATSAPP_TEMPLATE_EXAMPLE_MAX,
382
+ WHATSAPP_TEMPLATE_LANGUAGES,
383
+ whatsappTemplateVariables,
384
+ whatsappTemplateVariableCount,
385
+ checkWhatsappTemplate,
386
+ whatsappTemplateIssueText,
387
+ whatsappTemplateComponents,
388
+ whatsappTemplateContent,
389
+ fillWhatsappTemplate,
390
+ whatsappTemplateSendable,
391
+ cleanWhatsappTemplateParam,
392
+ whatsappSendError
393
+ } from './whatsappTemplates';
394
+ import type {
395
+ WhatsappTemplateCategory,
396
+ WhatsappTemplateDraft,
397
+ WhatsappTemplateContent,
398
+ WhatsappTemplateIssue,
399
+ WhatsappTemplateIssueCode
400
+ } from './whatsappTemplates';
368
401
  import { PlanLimitError, isPlanLimitError } from './planLimitError';
369
402
  import type { PlanLimitDetails } from './planLimitError';
370
403
  import type { PlanLimits } from './constants';
371
404
 
372
405
  export const utils = {
373
406
  Schema,
407
+ WHATSAPP_TEMPLATE_CATEGORIES,
408
+ WHATSAPP_TEMPLATE_STATUS_APPROVED,
409
+ WHATSAPP_TEMPLATE_STATUSES_WAITING,
410
+ WHATSAPP_TEMPLATE_STATUSES_STOPPED,
411
+ WHATSAPP_TEMPLATE_NAME_MAX,
412
+ WHATSAPP_TEMPLATE_BODY_MAX,
413
+ WHATSAPP_TEMPLATE_HEADER_MAX,
414
+ WHATSAPP_TEMPLATE_FOOTER_MAX,
415
+ WHATSAPP_TEMPLATE_BUTTON_MAX,
416
+ WHATSAPP_TEMPLATE_BUTTONS_MAX,
417
+ WHATSAPP_TEMPLATE_VARIABLES_MAX,
418
+ WHATSAPP_TEMPLATE_LABEL_MAX,
419
+ WHATSAPP_TEMPLATE_EXAMPLE_MAX,
420
+ WHATSAPP_TEMPLATE_LANGUAGES,
421
+ whatsappTemplateVariables,
422
+ whatsappTemplateVariableCount,
423
+ checkWhatsappTemplate,
424
+ whatsappTemplateIssueText,
425
+ whatsappTemplateComponents,
426
+ whatsappTemplateContent,
427
+ fillWhatsappTemplate,
428
+ whatsappTemplateSendable,
429
+ cleanWhatsappTemplateParam,
430
+ whatsappSendError,
374
431
  getEnv,
375
432
  fetcher,
376
433
  constants,
@@ -677,5 +734,10 @@ export type {
677
734
  SyncableResource,
678
735
  ResourceSyncProvider,
679
736
  PlanLimitDetails,
680
- PlanLimits
737
+ PlanLimits,
738
+ WhatsappTemplateCategory,
739
+ WhatsappTemplateDraft,
740
+ WhatsappTemplateContent,
741
+ WhatsappTemplateIssue,
742
+ WhatsappTemplateIssueCode
681
743
  };
package/src/schema.ts CHANGED
@@ -24,6 +24,18 @@ import {
24
24
  DATA_MAX_WRITE_OPERATIONS,
25
25
  DATA_ON_DELETE
26
26
  } from './dataTypes';
27
+ import {
28
+ WHATSAPP_TEMPLATE_BODY_MAX,
29
+ WHATSAPP_TEMPLATE_BUTTON_MAX,
30
+ WHATSAPP_TEMPLATE_BUTTONS_MAX,
31
+ WHATSAPP_TEMPLATE_CATEGORIES,
32
+ WHATSAPP_TEMPLATE_EXAMPLE_MAX,
33
+ WHATSAPP_TEMPLATE_FOOTER_MAX,
34
+ WHATSAPP_TEMPLATE_HEADER_MAX,
35
+ WHATSAPP_TEMPLATE_LABEL_MAX,
36
+ WHATSAPP_TEMPLATE_NAME_MAX,
37
+ WHATSAPP_TEMPLATE_VARIABLES_MAX
38
+ } from './whatsappTemplates';
27
39
 
28
40
  // A prompt title becomes a slash command; it must not collide with a command
29
41
  // the channel runner handles itself (e.g. `/link`).
@@ -1480,7 +1492,10 @@ const CUSTOM_CODE_TOOL = z.object({
1480
1492
  access: z.enum(['read', 'write']).optional(),
1481
1493
  tables: z.array(z.string().min(1).max(63)).max(50).optional()
1482
1494
  })
1483
- .optional()
1495
+ .optional(),
1496
+ // true: the function may message customers and chats through ctx.channels.
1497
+ // Left out, the broker refuses it.
1498
+ channels: z.boolean().optional()
1484
1499
  });
1485
1500
 
1486
1501
  // The manifest a `ganju deploy` (or the dashboard) uploads alongside a bundle.
@@ -1747,6 +1762,11 @@ const CUSTOM_CODE_BROKER_CHANNELS_SEND = z.object({
1747
1762
  text: z.string().min(1).max(4000).optional(),
1748
1763
  template: DATA_TRIGGER_TEMPLATE.optional()
1749
1764
  });
1765
+ // ctx.channels.templates: the approved WhatsApp templates of the project's
1766
+ // channels.
1767
+ const CUSTOM_CODE_BROKER_CHANNELS_TEMPLATES = z.object({
1768
+ call: z.string().min(1).max(4000)
1769
+ });
1750
1770
  const CUSTOM_CODE_BROKER_CHANNELS_POST = z.object({
1751
1771
  call: z.string().min(1).max(4000),
1752
1772
  channelId: z.string().min(1).max(100),
@@ -1997,6 +2017,60 @@ const CHANNEL_LIST_MESSAGES = z.object({
1997
2017
  organizationId: z.uuid()
1998
2018
  });
1999
2019
 
2020
+ const CHANNEL_WHATSAPP_PARAMS = z.object({
2021
+ channelId: z.uuid(),
2022
+ projectId: z.uuid(),
2023
+ userId: z.uuid(),
2024
+ organizationId: z.uuid()
2025
+ });
2026
+
2027
+ // A new access token, the business account's id, or both, for a WhatsApp
2028
+ // channel's message templates. The rest of its credentials stay as they are.
2029
+ const CHANNEL_WHATSAPP_ACCOUNT = z
2030
+ .strictObject({
2031
+ accessToken: z.string().trim().min(20).max(2000).optional(),
2032
+ businessAccountId: z
2033
+ .string()
2034
+ .trim()
2035
+ .regex(/^\d{5,30}$/, 'The business account id is a number')
2036
+ .optional()
2037
+ })
2038
+ .refine(
2039
+ v => v.accessToken !== undefined || v.businessAccountId !== undefined,
2040
+ 'Give a new access token, the business account id, or both'
2041
+ );
2042
+
2043
+ // A WhatsApp message template as the dashboard writes it. Meta's own rules
2044
+ // are checked with checkWhatsappTemplate before it is sent for review.
2045
+ const CHANNEL_WHATSAPP_TEMPLATE_CONTENT = z.strictObject({
2046
+ category: z.enum(WHATSAPP_TEMPLATE_CATEGORIES),
2047
+ header: z.string().max(WHATSAPP_TEMPLATE_HEADER_MAX).nullish(),
2048
+ body: z.string().trim().min(1).max(WHATSAPP_TEMPLATE_BODY_MAX),
2049
+ footer: z.string().max(WHATSAPP_TEMPLATE_FOOTER_MAX).nullish(),
2050
+ buttons: z
2051
+ .array(z.string().max(WHATSAPP_TEMPLATE_BUTTON_MAX))
2052
+ .max(WHATSAPP_TEMPLATE_BUTTONS_MAX)
2053
+ .optional(),
2054
+ // {{n}}'s example value and label are at n - 1.
2055
+ examples: z
2056
+ .array(z.string().max(WHATSAPP_TEMPLATE_EXAMPLE_MAX))
2057
+ .max(WHATSAPP_TEMPLATE_VARIABLES_MAX)
2058
+ .optional(),
2059
+ labels: z
2060
+ .array(z.string().max(WHATSAPP_TEMPLATE_LABEL_MAX))
2061
+ .max(WHATSAPP_TEMPLATE_VARIABLES_MAX)
2062
+ .optional()
2063
+ });
2064
+ const CHANNEL_WHATSAPP_TEMPLATE_CREATE = CHANNEL_WHATSAPP_TEMPLATE_CONTENT.extend(
2065
+ {
2066
+ name: z.string().trim().min(1).max(WHATSAPP_TEMPLATE_NAME_MAX),
2067
+ language: z.string().trim().min(2).max(10)
2068
+ }
2069
+ );
2070
+ const CHANNEL_WHATSAPP_TEMPLATE_PARAMS = CHANNEL_WHATSAPP_PARAMS.extend({
2071
+ templateId: z.uuid()
2072
+ });
2073
+
2000
2074
  const CHANNEL_CREATE_VIEW = CHANNEL_CREATE.omit({
2001
2075
  projectId: true,
2002
2076
  userId: true,
@@ -2161,6 +2235,11 @@ export const Schema = {
2161
2235
  CHANNEL_REMOVE,
2162
2236
  CHANNEL_LIST_CONVERSATIONS,
2163
2237
  CHANNEL_LIST_MESSAGES,
2238
+ CHANNEL_WHATSAPP_PARAMS,
2239
+ CHANNEL_WHATSAPP_ACCOUNT,
2240
+ CHANNEL_WHATSAPP_TEMPLATE_CREATE,
2241
+ CHANNEL_WHATSAPP_TEMPLATE_CONTENT,
2242
+ CHANNEL_WHATSAPP_TEMPLATE_PARAMS,
2164
2243
  ARTIFACT_UPDATE_RESOURCE_SHOW_SOURCE,
2165
2244
  ARTIFACT_UPDATE_RESOURCE_SYNC_INTERVAL,
2166
2245
  ARTIFACT_SYNC_RESOURCE,
@@ -2191,6 +2270,7 @@ export const Schema = {
2191
2270
  CUSTOM_CODE_BROKER_DATA,
2192
2271
  CUSTOM_CODE_BROKER_CHANNELS_SEND,
2193
2272
  CUSTOM_CODE_BROKER_CHANNELS_POST,
2273
+ CUSTOM_CODE_BROKER_CHANNELS_TEMPLATES,
2194
2274
  CUSTOM_CODE_BROKER_RESOURCE_SEARCH,
2195
2275
  CUSTOM_CODE_BROKER_RESOURCE_READ,
2196
2276
  CUSTOM_CODE_BROKER_RESOURCE_CREATE,
@@ -32,6 +32,7 @@ export const CUSTOM_CODE_BROKER_PATH_SEND_FILE = '/send-file';
32
32
  export const CUSTOM_CODE_BROKER_PATH_DATA = '/data';
33
33
  export const CUSTOM_CODE_BROKER_PATH_CHANNELS_SEND = '/channels/send';
34
34
  export const CUSTOM_CODE_BROKER_PATH_CHANNELS_POST = '/channels/post';
35
+ export const CUSTOM_CODE_BROKER_PATH_CHANNELS_TEMPLATES = '/channels/templates';
35
36
 
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
@@ -0,0 +1,465 @@
1
+ // WhatsApp message templates: the shape Meta's Graph API takes and returns,
2
+ // and the rules it rejects templates for most often, checked before one is
3
+ // sent for review. Imports nothing, so the dashboard runs the same checks.
4
+
5
+ export const WHATSAPP_TEMPLATE_CATEGORIES = ['UTILITY', 'MARKETING'] as const;
6
+ export type WhatsappTemplateCategory =
7
+ (typeof WHATSAPP_TEMPLATE_CATEGORIES)[number];
8
+
9
+ // What Meta reports. Only APPROVED templates can be sent.
10
+ export const WHATSAPP_TEMPLATE_STATUS_APPROVED = 'APPROVED';
11
+ // Still in Meta's hands: the hourly sync looks at these again.
12
+ export const WHATSAPP_TEMPLATE_STATUSES_WAITING = [
13
+ 'PENDING',
14
+ 'IN_APPEAL',
15
+ 'PENDING_DELETION'
16
+ ];
17
+ // An approved template Meta stopped: its owners are told.
18
+ export const WHATSAPP_TEMPLATE_STATUSES_STOPPED = ['PAUSED', 'DISABLED'];
19
+
20
+ export const WHATSAPP_TEMPLATE_NAME_MAX = 512;
21
+ export const WHATSAPP_TEMPLATE_BODY_MAX = 1024;
22
+ export const WHATSAPP_TEMPLATE_HEADER_MAX = 60;
23
+ export const WHATSAPP_TEMPLATE_FOOTER_MAX = 60;
24
+ export const WHATSAPP_TEMPLATE_BUTTON_MAX = 25;
25
+ export const WHATSAPP_TEMPLATE_BUTTONS_MAX = 10;
26
+ export const WHATSAPP_TEMPLATE_VARIABLES_MAX = 20;
27
+ export const WHATSAPP_TEMPLATE_LABEL_MAX = 40;
28
+ export const WHATSAPP_TEMPLATE_EXAMPLE_MAX = 200;
29
+ const NAME_PATTERN = /^[a-z0-9_]+$/;
30
+
31
+ // Meta's template languages. The first ones are the ones most owners use.
32
+ export const WHATSAPP_TEMPLATE_LANGUAGES: { code: string; name: string }[] = [
33
+ { code: 'es', name: 'Spanish' },
34
+ { code: 'es_MX', name: 'Spanish (Mexico)' },
35
+ { code: 'es_AR', name: 'Spanish (Argentina)' },
36
+ { code: 'es_ES', name: 'Spanish (Spain)' },
37
+ { code: 'en', name: 'English' },
38
+ { code: 'en_US', name: 'English (US)' },
39
+ { code: 'en_GB', name: 'English (UK)' },
40
+ { code: 'pt_BR', name: 'Portuguese (Brazil)' },
41
+ { code: 'pt_PT', name: 'Portuguese (Portugal)' },
42
+ { code: 'af', name: 'Afrikaans' },
43
+ { code: 'sq', name: 'Albanian' },
44
+ { code: 'ar', name: 'Arabic' },
45
+ { code: 'az', name: 'Azerbaijani' },
46
+ { code: 'bn', name: 'Bengali' },
47
+ { code: 'bg', name: 'Bulgarian' },
48
+ { code: 'ca', name: 'Catalan' },
49
+ { code: 'zh_CN', name: 'Chinese (China)' },
50
+ { code: 'zh_HK', name: 'Chinese (Hong Kong)' },
51
+ { code: 'zh_TW', name: 'Chinese (Taiwan)' },
52
+ { code: 'hr', name: 'Croatian' },
53
+ { code: 'cs', name: 'Czech' },
54
+ { code: 'da', name: 'Danish' },
55
+ { code: 'nl', name: 'Dutch' },
56
+ { code: 'et', name: 'Estonian' },
57
+ { code: 'fil', name: 'Filipino' },
58
+ { code: 'fi', name: 'Finnish' },
59
+ { code: 'fr', name: 'French' },
60
+ { code: 'ka', name: 'Georgian' },
61
+ { code: 'de', name: 'German' },
62
+ { code: 'el', name: 'Greek' },
63
+ { code: 'gu', name: 'Gujarati' },
64
+ { code: 'ha', name: 'Hausa' },
65
+ { code: 'he', name: 'Hebrew' },
66
+ { code: 'hi', name: 'Hindi' },
67
+ { code: 'hu', name: 'Hungarian' },
68
+ { code: 'id', name: 'Indonesian' },
69
+ { code: 'ga', name: 'Irish' },
70
+ { code: 'it', name: 'Italian' },
71
+ { code: 'ja', name: 'Japanese' },
72
+ { code: 'kn', name: 'Kannada' },
73
+ { code: 'kk', name: 'Kazakh' },
74
+ { code: 'rw_RW', name: 'Kinyarwanda' },
75
+ { code: 'ko', name: 'Korean' },
76
+ { code: 'ky_KG', name: 'Kyrgyz' },
77
+ { code: 'lo', name: 'Lao' },
78
+ { code: 'lv', name: 'Latvian' },
79
+ { code: 'lt', name: 'Lithuanian' },
80
+ { code: 'mk', name: 'Macedonian' },
81
+ { code: 'ms', name: 'Malay' },
82
+ { code: 'ml', name: 'Malayalam' },
83
+ { code: 'mr', name: 'Marathi' },
84
+ { code: 'nb', name: 'Norwegian' },
85
+ { code: 'fa', name: 'Persian' },
86
+ { code: 'pl', name: 'Polish' },
87
+ { code: 'pa', name: 'Punjabi' },
88
+ { code: 'ro', name: 'Romanian' },
89
+ { code: 'ru', name: 'Russian' },
90
+ { code: 'sr', name: 'Serbian' },
91
+ { code: 'sk', name: 'Slovak' },
92
+ { code: 'sl', name: 'Slovenian' },
93
+ { code: 'sw', name: 'Swahili' },
94
+ { code: 'sv', name: 'Swedish' },
95
+ { code: 'ta', name: 'Tamil' },
96
+ { code: 'te', name: 'Telugu' },
97
+ { code: 'th', name: 'Thai' },
98
+ { code: 'tr', name: 'Turkish' },
99
+ { code: 'uk', name: 'Ukrainian' },
100
+ { code: 'ur', name: 'Urdu' },
101
+ { code: 'uz', name: 'Uzbek' },
102
+ { code: 'vi', name: 'Vietnamese' },
103
+ { code: 'zu', name: 'Zulu' }
104
+ ];
105
+
106
+ /** A template as the owner writes it. `examples[i]` and `labels[i]` are {{i+1}}'s. */
107
+ export interface WhatsappTemplateDraft {
108
+ name: string;
109
+ language: string;
110
+ category: WhatsappTemplateCategory;
111
+ header?: string | null;
112
+ body: string;
113
+ footer?: string | null;
114
+ buttons?: string[];
115
+ examples?: string[];
116
+ labels?: string[];
117
+ }
118
+
119
+ /** A template's text, read back from Meta's components. */
120
+ export interface WhatsappTemplateContent {
121
+ header: string | null;
122
+ body: string;
123
+ footer: string | null;
124
+ buttons: string[];
125
+ examples: string[];
126
+ }
127
+
128
+ export type WhatsappTemplateIssueCode =
129
+ | 'name_format'
130
+ | 'name_long'
131
+ | 'language'
132
+ | 'category'
133
+ | 'body_empty'
134
+ | 'body_long'
135
+ | 'header_long'
136
+ | 'header_variables'
137
+ | 'header_lines'
138
+ | 'footer_long'
139
+ | 'footer_variables'
140
+ | 'button_empty'
141
+ | 'button_long'
142
+ | 'button_variables'
143
+ | 'buttons_many'
144
+ | 'buttons_repeated'
145
+ | 'variables_many'
146
+ | 'variables_order'
147
+ | 'variable_at_start'
148
+ | 'variable_at_end'
149
+ | 'variables_adjacent'
150
+ | 'variables_dense'
151
+ | 'example_missing';
152
+
153
+ export interface WhatsappTemplateIssue {
154
+ code: WhatsappTemplateIssueCode;
155
+ // The {{n}} it is about, when one is.
156
+ n?: number;
157
+ }
158
+
159
+ const VARIABLE = /\{\{\s*(\d+)\s*\}\}/g;
160
+
161
+ /** Each {{n}} in the text, in the order they appear. */
162
+ export const whatsappTemplateVariables = (text: string): number[] =>
163
+ Array.from(text.matchAll(VARIABLE), match => Number(match[1]));
164
+
165
+ /** How many variables the body has: its highest {{n}}. */
166
+ export const whatsappTemplateVariableCount = (body: string): number =>
167
+ Math.max(0, ...whatsappTemplateVariables(body));
168
+
169
+ const words = (text: string) =>
170
+ text
171
+ .replace(VARIABLE, ' ')
172
+ .split(/\s+/)
173
+ .filter(word => /[\p{L}\p{N}]/u.test(word)).length;
174
+
175
+ /**
176
+ * The rules Meta rejects templates for most often, checked before sending
177
+ * one for review. Meta's review has the last word; this only catches the
178
+ * common mistakes early.
179
+ */
180
+ export const checkWhatsappTemplate = (
181
+ draft: WhatsappTemplateDraft
182
+ ): WhatsappTemplateIssue[] => {
183
+ const issues: WhatsappTemplateIssue[] = [];
184
+ const add = (code: WhatsappTemplateIssueCode, n?: number) =>
185
+ issues.push(n === undefined ? { code } : { code, n });
186
+
187
+ if (!NAME_PATTERN.test(draft.name)) add('name_format');
188
+ if (draft.name.length > WHATSAPP_TEMPLATE_NAME_MAX) add('name_long');
189
+ if (!WHATSAPP_TEMPLATE_LANGUAGES.some(l => l.code === draft.language)) {
190
+ add('language');
191
+ }
192
+ if (!WHATSAPP_TEMPLATE_CATEGORIES.includes(draft.category)) add('category');
193
+
194
+ const header = draft.header?.trim() ?? '';
195
+ if (header.length > WHATSAPP_TEMPLATE_HEADER_MAX) add('header_long');
196
+ if (whatsappTemplateVariables(header).length > 0) add('header_variables');
197
+ if (/[\n\r]/.test(header)) add('header_lines');
198
+ const footer = draft.footer?.trim() ?? '';
199
+ if (footer.length > WHATSAPP_TEMPLATE_FOOTER_MAX) add('footer_long');
200
+ if (whatsappTemplateVariables(footer).length > 0) add('footer_variables');
201
+
202
+ const buttons = draft.buttons ?? [];
203
+ if (buttons.length > WHATSAPP_TEMPLATE_BUTTONS_MAX) add('buttons_many');
204
+ if (buttons.some(button => !button.trim())) add('button_empty');
205
+ if (
206
+ buttons.some(button => button.trim().length > WHATSAPP_TEMPLATE_BUTTON_MAX)
207
+ ) {
208
+ add('button_long');
209
+ }
210
+ if (buttons.some(button => whatsappTemplateVariables(button).length > 0)) {
211
+ add('button_variables');
212
+ }
213
+ const unique = new Set(buttons.map(button => button.trim().toLowerCase()));
214
+ if (unique.size < buttons.length) add('buttons_repeated');
215
+
216
+ const body = draft.body.trim();
217
+ if (!body) {
218
+ add('body_empty');
219
+ return issues;
220
+ }
221
+ if (body.length > WHATSAPP_TEMPLATE_BODY_MAX) add('body_long');
222
+
223
+ const found = whatsappTemplateVariables(body);
224
+ if (found.length === 0) return issues;
225
+ const count = Math.max(...found);
226
+ if (count > WHATSAPP_TEMPLATE_VARIABLES_MAX) add('variables_many');
227
+ // {{1}}, {{2}}, … with no gaps, each first used after the one before.
228
+ const firsts = [...new Set(found)];
229
+ if (firsts.some((n, i) => n !== i + 1)) add('variables_order');
230
+ if (/^\{\{\s*\d+\s*\}\}/.test(body)) add('variable_at_start');
231
+ if (/\{\{\s*\d+\s*\}\}[\s.!?…]*$/.test(body)) add('variable_at_end');
232
+ if (/\}\}\s*\{\{/.test(body)) add('variables_adjacent');
233
+ // Meta wants more words than variables; this is about the ratio it accepts.
234
+ if (words(body) < 2 * firsts.length + 1) add('variables_dense');
235
+ const examples = draft.examples ?? [];
236
+ for (let n = 1; n <= count; n++) {
237
+ if (!examples[n - 1]?.trim()) add('example_missing', n);
238
+ }
239
+ return issues;
240
+ };
241
+
242
+ const ISSUE_TEXT: Record<WhatsappTemplateIssueCode, string> = {
243
+ name_format: 'The name takes lowercase letters, digits and underscores only.',
244
+ name_long: `The name is at most ${WHATSAPP_TEMPLATE_NAME_MAX} characters.`,
245
+ language: "The language isn't one Meta supports.",
246
+ category: 'The category is Utility or Marketing.',
247
+ body_empty: 'The message needs a body.',
248
+ body_long: `The body is at most ${WHATSAPP_TEMPLATE_BODY_MAX} characters.`,
249
+ header_long: `The header is at most ${WHATSAPP_TEMPLATE_HEADER_MAX} characters.`,
250
+ header_variables: "The header can't have variables.",
251
+ header_lines: 'The header is one line.',
252
+ footer_long: `The footer is at most ${WHATSAPP_TEMPLATE_FOOTER_MAX} characters.`,
253
+ footer_variables: "The footer can't have variables.",
254
+ button_empty: 'A quick reply needs a text.',
255
+ button_long: `A quick reply is at most ${WHATSAPP_TEMPLATE_BUTTON_MAX} characters.`,
256
+ button_variables: "A quick reply can't have variables.",
257
+ buttons_many: `At most ${WHATSAPP_TEMPLATE_BUTTONS_MAX} quick replies.`,
258
+ buttons_repeated: 'Two quick replies say the same.',
259
+ variables_many: `At most ${WHATSAPP_TEMPLATE_VARIABLES_MAX} variables.`,
260
+ variables_order:
261
+ 'Variables are numbered {{1}}, {{2}}, … in the order they first appear, with no gaps.',
262
+ variable_at_start: 'Meta rejects a body that starts with a variable.',
263
+ variable_at_end: 'Meta rejects a body that ends with a variable.',
264
+ variables_adjacent:
265
+ 'Meta rejects two variables side by side; put words between them.',
266
+ variables_dense:
267
+ 'Too many variables for the length of the text; add words or remove variables.',
268
+ example_missing: 'Each variable needs an example value for Meta to review.'
269
+ };
270
+
271
+ export const whatsappTemplateIssueText = (issue: WhatsappTemplateIssue) =>
272
+ issue.n === undefined
273
+ ? ISSUE_TEXT[issue.code]
274
+ : `{{${issue.n}}}: ${ISSUE_TEXT[issue.code]}`;
275
+
276
+ /** The components Meta's create and edit calls take. */
277
+ export const whatsappTemplateComponents = (
278
+ draft: WhatsappTemplateDraft
279
+ ): Record<string, unknown>[] => {
280
+ const components: Record<string, unknown>[] = [];
281
+ const header = draft.header?.trim();
282
+ if (header) components.push({ type: 'HEADER', format: 'TEXT', text: header });
283
+ const body = draft.body.trim();
284
+ const count = whatsappTemplateVariableCount(body);
285
+ components.push({
286
+ type: 'BODY',
287
+ text: body,
288
+ ...(count > 0
289
+ ? {
290
+ example: {
291
+ body_text: [
292
+ Array.from({ length: count }, (_, i) =>
293
+ (draft.examples?.[i] ?? '').trim()
294
+ )
295
+ ]
296
+ }
297
+ }
298
+ : {})
299
+ });
300
+ const footer = draft.footer?.trim();
301
+ if (footer) components.push({ type: 'FOOTER', text: footer });
302
+ const buttons = (draft.buttons ?? []).map(b => b.trim()).filter(Boolean);
303
+ if (buttons.length > 0) {
304
+ components.push({
305
+ type: 'BUTTONS',
306
+ buttons: buttons.map(text => ({ type: 'QUICK_REPLY', text }))
307
+ });
308
+ }
309
+ return components;
310
+ };
311
+
312
+ /** A template's text, from the components Meta returns. */
313
+ export const whatsappTemplateContent = (
314
+ components: unknown
315
+ ): WhatsappTemplateContent => {
316
+ const content: WhatsappTemplateContent = {
317
+ header: null,
318
+ body: '',
319
+ footer: null,
320
+ buttons: [],
321
+ examples: []
322
+ };
323
+ if (!Array.isArray(components)) return content;
324
+ for (const component of components as Record<string, unknown>[]) {
325
+ const type = String(component?.type ?? '').toUpperCase();
326
+ const text = typeof component?.text === 'string' ? component.text : null;
327
+ if (type === 'HEADER' && component.format === 'TEXT') content.header = text;
328
+ if (type === 'BODY') {
329
+ content.body = text ?? '';
330
+ const example = component.example as
331
+ | { body_text?: unknown[][] }
332
+ | undefined;
333
+ const first = example?.body_text?.[0];
334
+ if (Array.isArray(first)) content.examples = first.map(v => String(v));
335
+ }
336
+ if (type === 'FOOTER') content.footer = text;
337
+ if (type === 'BUTTONS' && Array.isArray(component.buttons)) {
338
+ content.buttons = (component.buttons as Record<string, unknown>[])
339
+ .filter(b => b?.type === 'QUICK_REPLY' && typeof b.text === 'string')
340
+ .map(b => b.text as string);
341
+ }
342
+ }
343
+ return content;
344
+ };
345
+
346
+ /**
347
+ * Whether Ganju can send it: it fills the body's variables only. A media
348
+ * header, a header variable, or a button that takes a value (a URL with a
349
+ * variable, a code, a flow) needs more, and Meta refuses the send.
350
+ */
351
+ export const whatsappTemplateSendable = (components: unknown): boolean => {
352
+ if (!Array.isArray(components)) return false;
353
+ return (components as Record<string, unknown>[]).every(component => {
354
+ const type = String(component?.type ?? '').toUpperCase();
355
+ if (type === 'BODY' || type === 'FOOTER') return true;
356
+ if (type === 'HEADER') {
357
+ return (
358
+ component.format === 'TEXT' &&
359
+ whatsappTemplateVariables(String(component.text ?? '')).length === 0
360
+ );
361
+ }
362
+ if (type === 'BUTTONS') {
363
+ const buttons = Array.isArray(component.buttons)
364
+ ? (component.buttons as Record<string, unknown>[])
365
+ : [];
366
+ return buttons.every(button => {
367
+ const kind = String(button?.type ?? '').toUpperCase();
368
+ if (kind === 'QUICK_REPLY' || kind === 'PHONE_NUMBER') return true;
369
+ if (kind === 'URL') {
370
+ return (
371
+ whatsappTemplateVariables(String(button.url ?? '')).length === 0
372
+ );
373
+ }
374
+ return false;
375
+ });
376
+ }
377
+ return false;
378
+ });
379
+ };
380
+
381
+ /** The body with each {{n}} replaced, for previews and the message record. */
382
+ export const fillWhatsappTemplate = (body: string, values: string[]): string =>
383
+ body.replace(VARIABLE, (whole, n) => values[Number(n) - 1] ?? whole);
384
+
385
+ /**
386
+ * A value for a {{n}} as Meta accepts it: no line breaks or tabs, no long
387
+ * runs of spaces, and never empty.
388
+ */
389
+ export const cleanWhatsappTemplateParam = (value: unknown): string => {
390
+ const text = String(value ?? '')
391
+ .replace(/[\r\n\t]+/g, ' ')
392
+ .replace(/ {2,}/g, ' ')
393
+ .trim()
394
+ .slice(0, 1024);
395
+ return text || '-';
396
+ };
397
+
398
+ // Meta's error codes for a message that didn't go out, in words an owner can
399
+ // act on. `retry`: the same message may go through later.
400
+ const SEND_ERRORS: Record<number, { text: string; retry?: true }> = {
401
+ 1: { text: 'WhatsApp had a temporary problem.', retry: true },
402
+ 2: { text: 'WhatsApp had a temporary problem.', retry: true },
403
+ 10: { text: "The channel's access token isn't allowed to send messages." },
404
+ 190: {
405
+ text: "The channel's access token expired or was revoked; connect a new one in the channel's settings."
406
+ },
407
+ 200: { text: "The channel's access token isn't allowed to send messages." },
408
+ 368: {
409
+ text: 'Meta has temporarily blocked this WhatsApp Business account for policy violations.'
410
+ },
411
+ 130429: {
412
+ text: "The number's sending rate was reached; it will be tried again.",
413
+ retry: true
414
+ },
415
+ 131000: { text: 'WhatsApp had a temporary problem.', retry: true },
416
+ 131016: { text: 'WhatsApp is temporarily unavailable.', retry: true },
417
+ 131026: {
418
+ text: "WhatsApp couldn't deliver it: the customer may have blocked the business, left WhatsApp, or not accepted its latest terms."
419
+ },
420
+ 131031: { text: 'Meta locked this WhatsApp Business account.' },
421
+ 131042: {
422
+ text: "Meta refused it over a payment problem on the WhatsApp Business account; check its payment method in Meta's Business settings."
423
+ },
424
+ 131047: {
425
+ text: "Outside WhatsApp's 24-hour window: the customer last wrote more than 24 hours ago, so only an approved template reaches them."
426
+ },
427
+ 131048: {
428
+ text: "Meta limited this number's sending because of its quality: too many customers blocked or reported its messages."
429
+ },
430
+ 131049: {
431
+ text: 'Meta chose not to deliver this marketing message, to limit how many each customer gets.'
432
+ },
433
+ 131050: {
434
+ text: 'The customer turned off marketing messages from this business.'
435
+ },
436
+ 131056: {
437
+ text: 'Too many messages to this customer in a short time; it will be tried again.',
438
+ retry: true
439
+ },
440
+ 132000: {
441
+ text: "The number of values doesn't match the template's variables."
442
+ },
443
+ 132001: {
444
+ text: 'Meta has no approved template with that name in that language.'
445
+ },
446
+ 132005: { text: "The template's text is too long with the values put in." },
447
+ 132007: { text: "The template's text breaks WhatsApp's commerce policy." },
448
+ 132012: { text: "A value doesn't match what the template expects." },
449
+ 132015: { text: 'Meta paused this template because of its quality.' },
450
+ 132016: { text: 'Meta disabled this template because of its quality.' },
451
+ 132018: { text: 'A value has line breaks, tabs or too many spaces.' }
452
+ };
453
+
454
+ /** A Meta send error as an owner reads it, and whether a retry may help. */
455
+ export const whatsappSendError = (
456
+ code: number | undefined,
457
+ detail: string | undefined
458
+ ): { text: string; retry: boolean } => {
459
+ const known = code !== undefined ? SEND_ERRORS[code] : undefined;
460
+ if (known) return { text: known.text, retry: !!known.retry };
461
+ return {
462
+ text: detail ? detail.slice(0, 300) : 'WhatsApp refused the message.',
463
+ retry: true
464
+ };
465
+ };