@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.
- package/dist/constants.d.ts +18 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +50 -3
- package/dist/crypto.d.ts +2 -2
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js +1 -0
- package/dist/data.d.ts +6 -4
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +13 -3
- package/dist/dataApi.d.ts +54 -8
- package/dist/dataApi.d.ts.map +1 -1
- package/dist/dataApi.js +40 -7
- package/dist/dataAttachment.d.ts +8 -0
- package/dist/dataAttachment.d.ts.map +1 -0
- package/dist/dataAttachment.js +19 -0
- package/dist/dataCompiler.d.ts +3 -0
- package/dist/dataCompiler.d.ts.map +1 -1
- package/dist/dataCompiler.js +25 -1
- package/dist/dataDdl.d.ts.map +1 -1
- package/dist/dataDdl.js +5 -0
- package/dist/dataEngine.d.ts +9 -3
- package/dist/dataEngine.d.ts.map +1 -1
- package/dist/dataEngine.js +141 -5
- package/dist/dataIndexAdvice.d.ts +36 -0
- package/dist/dataIndexAdvice.d.ts.map +1 -0
- package/dist/dataIndexAdvice.js +89 -0
- package/dist/dataRpc.d.ts +12 -1
- package/dist/dataRpc.d.ts.map +1 -1
- package/dist/dataTools.js +3 -3
- package/dist/dataTriggers.d.ts +23 -0
- package/dist/dataTriggers.d.ts.map +1 -1
- package/dist/dataTriggers.js +46 -2
- package/dist/dataTypes.d.ts +46 -0
- package/dist/dataTypes.d.ts.map +1 -1
- package/dist/dataTypes.js +7 -1
- package/dist/index.d.ts +138 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +31 -0
- package/dist/localizeDataError.d.ts.map +1 -1
- package/dist/localizeDataError.js +64 -2
- package/dist/schema.d.ts +85 -2
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +94 -1
- package/dist/sdkConstants.d.ts +1 -0
- package/dist/sdkConstants.d.ts.map +1 -1
- package/dist/sdkConstants.js +2 -1
- package/dist/whatsappTemplates.d.ts +77 -0
- package/dist/whatsappTemplates.d.ts.map +1 -0
- package/dist/whatsappTemplates.js +402 -0
- package/dist/whatsappWebhook.d.ts +28 -0
- package/dist/whatsappWebhook.d.ts.map +1 -0
- package/dist/whatsappWebhook.js +30 -0
- package/package.json +1 -1
- package/src/constants.ts +57 -3
- package/src/crypto.ts +3 -2
- package/src/data.ts +14 -0
- package/src/dataApi.ts +44 -6
- package/src/dataAttachment.ts +23 -0
- package/src/dataCompiler.ts +37 -0
- package/src/dataDdl.ts +7 -0
- package/src/dataEngine.ts +195 -5
- package/src/dataIndexAdvice.ts +127 -0
- package/src/dataRpc.ts +17 -1
- package/src/dataTools.ts +3 -3
- package/src/dataTriggers.ts +71 -1
- package/src/dataTypes.ts +62 -0
- package/src/index.ts +74 -1
- package/src/localizeDataError.ts +101 -3
- package/src/schema.ts +115 -1
- package/src/sdkConstants.ts +1 -0
- package/src/whatsappTemplates.ts +465 -0
- 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,
|
package/src/sdkConstants.ts
CHANGED
|
@@ -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
|
+
};
|