@ganju/utils 0.0.18 → 0.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/dist/constants.d.ts +18 -0
  2. package/dist/constants.d.ts.map +1 -1
  3. package/dist/constants.js +50 -3
  4. package/dist/crypto.d.ts +2 -2
  5. package/dist/crypto.d.ts.map +1 -1
  6. package/dist/crypto.js +1 -0
  7. package/dist/data.d.ts +6 -4
  8. package/dist/data.d.ts.map +1 -1
  9. package/dist/data.js +13 -3
  10. package/dist/dataApi.d.ts +54 -8
  11. package/dist/dataApi.d.ts.map +1 -1
  12. package/dist/dataApi.js +40 -7
  13. package/dist/dataAttachment.d.ts +8 -0
  14. package/dist/dataAttachment.d.ts.map +1 -0
  15. package/dist/dataAttachment.js +19 -0
  16. package/dist/dataCompiler.d.ts +3 -0
  17. package/dist/dataCompiler.d.ts.map +1 -1
  18. package/dist/dataCompiler.js +25 -1
  19. package/dist/dataDdl.d.ts.map +1 -1
  20. package/dist/dataDdl.js +5 -0
  21. package/dist/dataEngine.d.ts +9 -3
  22. package/dist/dataEngine.d.ts.map +1 -1
  23. package/dist/dataEngine.js +141 -5
  24. package/dist/dataIndexAdvice.d.ts +36 -0
  25. package/dist/dataIndexAdvice.d.ts.map +1 -0
  26. package/dist/dataIndexAdvice.js +89 -0
  27. package/dist/dataRpc.d.ts +12 -1
  28. package/dist/dataRpc.d.ts.map +1 -1
  29. package/dist/dataTools.js +3 -3
  30. package/dist/dataTriggers.d.ts +23 -0
  31. package/dist/dataTriggers.d.ts.map +1 -1
  32. package/dist/dataTriggers.js +46 -2
  33. package/dist/dataTypes.d.ts +46 -0
  34. package/dist/dataTypes.d.ts.map +1 -1
  35. package/dist/dataTypes.js +7 -1
  36. package/dist/index.d.ts +138 -4
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +31 -0
  39. package/dist/localizeDataError.d.ts.map +1 -1
  40. package/dist/localizeDataError.js +64 -2
  41. package/dist/schema.d.ts +85 -2
  42. package/dist/schema.d.ts.map +1 -1
  43. package/dist/schema.js +94 -1
  44. package/dist/sdkConstants.d.ts +1 -0
  45. package/dist/sdkConstants.d.ts.map +1 -1
  46. package/dist/sdkConstants.js +2 -1
  47. package/dist/whatsappTemplates.d.ts +77 -0
  48. package/dist/whatsappTemplates.d.ts.map +1 -0
  49. package/dist/whatsappTemplates.js +402 -0
  50. package/dist/whatsappWebhook.d.ts +28 -0
  51. package/dist/whatsappWebhook.d.ts.map +1 -0
  52. package/dist/whatsappWebhook.js +30 -0
  53. package/package.json +1 -1
  54. package/src/constants.ts +57 -3
  55. package/src/crypto.ts +3 -2
  56. package/src/data.ts +14 -0
  57. package/src/dataApi.ts +44 -6
  58. package/src/dataAttachment.ts +23 -0
  59. package/src/dataCompiler.ts +37 -0
  60. package/src/dataDdl.ts +7 -0
  61. package/src/dataEngine.ts +195 -5
  62. package/src/dataIndexAdvice.ts +127 -0
  63. package/src/dataRpc.ts +17 -1
  64. package/src/dataTools.ts +3 -3
  65. package/src/dataTriggers.ts +71 -1
  66. package/src/dataTypes.ts +62 -0
  67. package/src/index.ts +74 -1
  68. package/src/localizeDataError.ts +101 -3
  69. package/src/schema.ts +115 -1
  70. package/src/sdkConstants.ts +1 -0
  71. package/src/whatsappTemplates.ts +465 -0
  72. package/src/whatsappWebhook.ts +55 -0
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`).
@@ -406,6 +418,14 @@ const ARTIFACT_SYNC_ONE_DRIVE = z.object({
406
418
  organizationId: z.uuid()
407
419
  });
408
420
 
421
+ // A knowledge item uploaded from a Data row: which file column it was for,
422
+ // and whether it is indexed for search too (the column's searchable switch).
423
+ const DATA_ATTACHMENT_METADATA = z.object({
424
+ table: z.string().min(1).max(64),
425
+ column: z.string().min(1).max(64),
426
+ index: z.boolean()
427
+ });
428
+
409
429
  const ARTIFACT_CREATE_RESOURCE = z.object({
410
430
  title: z.string().min(3).max(200),
411
431
  uri: z.string(),
@@ -517,6 +537,15 @@ const ARTIFACT_GET_RESOURCE_BY_ID = z.object({
517
537
  organizationId: z.uuid()
518
538
  });
519
539
 
540
+ // The knowledge items a Data file column can link, newest first.
541
+ const ARTIFACT_PICK_RESOURCE = z.object({
542
+ projectId: z.uuid(),
543
+ userId: z.uuid(),
544
+ organizationId: z.uuid(),
545
+ q: z.string().trim().max(200).optional(),
546
+ limit: z.coerce.number().int().min(1).max(50).default(20)
547
+ });
548
+
520
549
  const ARTIFACT_REMOVE_RESOURCE = z.object({
521
550
  resourceId: z.uuid(),
522
551
  projectId: z.uuid(),
@@ -822,6 +851,17 @@ const DATA_TRIGGER_PATCH = z.strictObject({ enabled: z.boolean() });
822
851
  const DATA_TRIGGER_DEPLOY_BODY = z.strictObject({
823
852
  triggers: z.array(DATA_TRIGGER_INPUT).max(50)
824
853
  });
854
+ // A test of a "customer" action as the dialog has it, sent to the signed-in
855
+ // owner's own chat with the bot, built from one row (the newest by default).
856
+ const DATA_TRIGGER_TEST_SEND_BODY = z.strictObject({
857
+ table: DATA_IDENTIFIER,
858
+ customer: DATA_TRIGGER_INPUT.shape.customer.unwrap(),
859
+ rowId: z.number().int().min(1).optional(),
860
+ // Which of the owner's linked chats, when there is more than one.
861
+ participantId: z.string().min(1).max(64).optional(),
862
+ // Send the template even inside WhatsApp's 24-hour window.
863
+ asTemplate: z.boolean().optional()
864
+ });
825
865
  const DATA_TRIGGER_RUN_LIST_QUERY = z.object({
826
866
  limit: z.coerce.number().int().min(1).max(50).optional()
827
867
  });
@@ -836,6 +876,8 @@ const DATA_TRIGGER_PATH_PARAMS = DATA_DATABASE_GET.extend({ name: DATA_TRIGGER_N
836
876
  const DATA_TRIGGER_RUN_PARAMS = DATA_TRIGGER_PATH_PARAMS.extend({
837
877
  runId: z.string().min(1).max(64)
838
878
  });
879
+ // An index suggestion, by its id.
880
+ const DATA_INDEX_ADVICE_PARAMS = DATA_DATABASE_GET.extend({ adviceId: z.uuid() });
839
881
 
840
882
  const ARTIFACT_UPLOAD_RESOURCE_FILE = z.object({
841
883
  resourceId: z.uuid(),
@@ -1480,7 +1522,10 @@ const CUSTOM_CODE_TOOL = z.object({
1480
1522
  access: z.enum(['read', 'write']).optional(),
1481
1523
  tables: z.array(z.string().min(1).max(63)).max(50).optional()
1482
1524
  })
1483
- .optional()
1525
+ .optional(),
1526
+ // true: the function may message customers and chats through ctx.channels.
1527
+ // Left out, the broker refuses it.
1528
+ channels: z.boolean().optional()
1484
1529
  });
1485
1530
 
1486
1531
  // The manifest a `ganju deploy` (or the dashboard) uploads alongside a bundle.
@@ -1747,6 +1792,11 @@ const CUSTOM_CODE_BROKER_CHANNELS_SEND = z.object({
1747
1792
  text: z.string().min(1).max(4000).optional(),
1748
1793
  template: DATA_TRIGGER_TEMPLATE.optional()
1749
1794
  });
1795
+ // ctx.channels.templates: the approved WhatsApp templates of the project's
1796
+ // channels.
1797
+ const CUSTOM_CODE_BROKER_CHANNELS_TEMPLATES = z.object({
1798
+ call: z.string().min(1).max(4000)
1799
+ });
1750
1800
  const CUSTOM_CODE_BROKER_CHANNELS_POST = z.object({
1751
1801
  call: z.string().min(1).max(4000),
1752
1802
  channelId: z.string().min(1).max(100),
@@ -1997,6 +2047,60 @@ const CHANNEL_LIST_MESSAGES = z.object({
1997
2047
  organizationId: z.uuid()
1998
2048
  });
1999
2049
 
2050
+ const CHANNEL_WHATSAPP_PARAMS = z.object({
2051
+ channelId: z.uuid(),
2052
+ projectId: z.uuid(),
2053
+ userId: z.uuid(),
2054
+ organizationId: z.uuid()
2055
+ });
2056
+
2057
+ // A new access token, the business account's id, or both, for a WhatsApp
2058
+ // channel's message templates. The rest of its credentials stay as they are.
2059
+ const CHANNEL_WHATSAPP_ACCOUNT = z
2060
+ .strictObject({
2061
+ accessToken: z.string().trim().min(20).max(2000).optional(),
2062
+ businessAccountId: z
2063
+ .string()
2064
+ .trim()
2065
+ .regex(/^\d{5,30}$/, 'The business account id is a number')
2066
+ .optional()
2067
+ })
2068
+ .refine(
2069
+ v => v.accessToken !== undefined || v.businessAccountId !== undefined,
2070
+ 'Give a new access token, the business account id, or both'
2071
+ );
2072
+
2073
+ // A WhatsApp message template as the dashboard writes it. Meta's own rules
2074
+ // are checked with checkWhatsappTemplate before it is sent for review.
2075
+ const CHANNEL_WHATSAPP_TEMPLATE_CONTENT = z.strictObject({
2076
+ category: z.enum(WHATSAPP_TEMPLATE_CATEGORIES),
2077
+ header: z.string().max(WHATSAPP_TEMPLATE_HEADER_MAX).nullish(),
2078
+ body: z.string().trim().min(1).max(WHATSAPP_TEMPLATE_BODY_MAX),
2079
+ footer: z.string().max(WHATSAPP_TEMPLATE_FOOTER_MAX).nullish(),
2080
+ buttons: z
2081
+ .array(z.string().max(WHATSAPP_TEMPLATE_BUTTON_MAX))
2082
+ .max(WHATSAPP_TEMPLATE_BUTTONS_MAX)
2083
+ .optional(),
2084
+ // {{n}}'s example value and label are at n - 1.
2085
+ examples: z
2086
+ .array(z.string().max(WHATSAPP_TEMPLATE_EXAMPLE_MAX))
2087
+ .max(WHATSAPP_TEMPLATE_VARIABLES_MAX)
2088
+ .optional(),
2089
+ labels: z
2090
+ .array(z.string().max(WHATSAPP_TEMPLATE_LABEL_MAX))
2091
+ .max(WHATSAPP_TEMPLATE_VARIABLES_MAX)
2092
+ .optional()
2093
+ });
2094
+ const CHANNEL_WHATSAPP_TEMPLATE_CREATE = CHANNEL_WHATSAPP_TEMPLATE_CONTENT.extend(
2095
+ {
2096
+ name: z.string().trim().min(1).max(WHATSAPP_TEMPLATE_NAME_MAX),
2097
+ language: z.string().trim().min(2).max(10)
2098
+ }
2099
+ );
2100
+ const CHANNEL_WHATSAPP_TEMPLATE_PARAMS = CHANNEL_WHATSAPP_PARAMS.extend({
2101
+ templateId: z.uuid()
2102
+ });
2103
+
2000
2104
  const CHANNEL_CREATE_VIEW = CHANNEL_CREATE.omit({
2001
2105
  projectId: true,
2002
2106
  userId: true,
@@ -2076,6 +2180,8 @@ export const Schema = {
2076
2180
  ARTIFACT_GET_PROMPT,
2077
2181
  ARTIFACT_REMOVE_PROMPT,
2078
2182
  ARTIFACT_CREATE_RESOURCE,
2183
+ DATA_ATTACHMENT_METADATA,
2184
+ ARTIFACT_PICK_RESOURCE,
2079
2185
  ARTIFACT_CREATE_RESOURCE_VIEW,
2080
2186
  ARTIFACT_CREATE_WEBSITE,
2081
2187
  ARTIFACT_CREATE_WEBSITE_VIEW,
@@ -2122,9 +2228,11 @@ export const Schema = {
2122
2228
  DATA_TRIGGER_INPUT,
2123
2229
  DATA_TRIGGER_PATCH,
2124
2230
  DATA_TRIGGER_DEPLOY_BODY,
2231
+ DATA_TRIGGER_TEST_SEND_BODY,
2125
2232
  DATA_TRIGGER_RUN_LIST_QUERY,
2126
2233
  DATA_TRIGGER_PATH_PARAMS,
2127
2234
  DATA_TRIGGER_RUN_PARAMS,
2235
+ DATA_INDEX_ADVICE_PARAMS,
2128
2236
  DATA_COLUMN_PARAMS,
2129
2237
  DATA_ROW_PARAMS,
2130
2238
  ARTIFACT_REMOVE_RESOURCE,
@@ -2161,6 +2269,11 @@ export const Schema = {
2161
2269
  CHANNEL_REMOVE,
2162
2270
  CHANNEL_LIST_CONVERSATIONS,
2163
2271
  CHANNEL_LIST_MESSAGES,
2272
+ CHANNEL_WHATSAPP_PARAMS,
2273
+ CHANNEL_WHATSAPP_ACCOUNT,
2274
+ CHANNEL_WHATSAPP_TEMPLATE_CREATE,
2275
+ CHANNEL_WHATSAPP_TEMPLATE_CONTENT,
2276
+ CHANNEL_WHATSAPP_TEMPLATE_PARAMS,
2164
2277
  ARTIFACT_UPDATE_RESOURCE_SHOW_SOURCE,
2165
2278
  ARTIFACT_UPDATE_RESOURCE_SYNC_INTERVAL,
2166
2279
  ARTIFACT_SYNC_RESOURCE,
@@ -2191,6 +2304,7 @@ export const Schema = {
2191
2304
  CUSTOM_CODE_BROKER_DATA,
2192
2305
  CUSTOM_CODE_BROKER_CHANNELS_SEND,
2193
2306
  CUSTOM_CODE_BROKER_CHANNELS_POST,
2307
+ CUSTOM_CODE_BROKER_CHANNELS_TEMPLATES,
2194
2308
  CUSTOM_CODE_BROKER_RESOURCE_SEARCH,
2195
2309
  CUSTOM_CODE_BROKER_RESOURCE_READ,
2196
2310
  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
+ };