@whitewall/blip-sdk 0.0.174 → 0.0.175

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 (131) hide show
  1. package/README.md +13 -6
  2. package/dist/cjs/client.js +3 -0
  3. package/dist/cjs/client.js.map +1 -1
  4. package/dist/cjs/index.js +3 -0
  5. package/dist/cjs/index.js.map +1 -1
  6. package/dist/cjs/namespaces/account.js +21 -10
  7. package/dist/cjs/namespaces/account.js.map +1 -1
  8. package/dist/cjs/namespaces/analytics.js +54 -0
  9. package/dist/cjs/namespaces/analytics.js.map +1 -1
  10. package/dist/cjs/namespaces/desk.js +264 -45
  11. package/dist/cjs/namespaces/desk.js.map +1 -1
  12. package/dist/cjs/namespaces/plugins.js +1 -0
  13. package/dist/cjs/namespaces/plugins.js.map +1 -1
  14. package/dist/cjs/namespaces/portal.js +45 -0
  15. package/dist/cjs/namespaces/portal.js.map +1 -1
  16. package/dist/cjs/namespaces/tunnel.js +46 -0
  17. package/dist/cjs/namespaces/tunnel.js.map +1 -0
  18. package/dist/cjs/namespaces/whatsapp.js +2 -23
  19. package/dist/cjs/namespaces/whatsapp.js.map +1 -1
  20. package/dist/cjs/sender/multi/multisender.js +28 -9
  21. package/dist/cjs/sender/multi/multisender.js.map +1 -1
  22. package/dist/cjs/sender/sessionnegotiator.js +1 -1
  23. package/dist/cjs/sender/sessionnegotiator.js.map +1 -1
  24. package/dist/cjs/sender/tcp/tcpsender.js +35 -2
  25. package/dist/cjs/sender/tcp/tcpsender.js.map +1 -1
  26. package/dist/cjs/sender/websocket/websocketsender.js +1 -1
  27. package/dist/cjs/sender/websocket/websocketsender.js.map +1 -1
  28. package/dist/cjs/types/desk.js.map +1 -1
  29. package/dist/cjs/types/flow.js +344 -0
  30. package/dist/cjs/types/flow.js.map +1 -1
  31. package/dist/cjs/utils/desk.js +65 -0
  32. package/dist/cjs/utils/desk.js.map +1 -0
  33. package/dist/cjs/utils/thread.js +84 -0
  34. package/dist/cjs/utils/thread.js.map +1 -0
  35. package/dist/cjs/utils/whatsapp.js +268 -0
  36. package/dist/cjs/utils/whatsapp.js.map +1 -0
  37. package/dist/esm/client.js +3 -0
  38. package/dist/esm/client.js.map +1 -1
  39. package/dist/esm/index.js +3 -0
  40. package/dist/esm/index.js.map +1 -1
  41. package/dist/esm/namespaces/account.js +17 -9
  42. package/dist/esm/namespaces/account.js.map +1 -1
  43. package/dist/esm/namespaces/analytics.js +54 -0
  44. package/dist/esm/namespaces/analytics.js.map +1 -1
  45. package/dist/esm/namespaces/desk.js +264 -45
  46. package/dist/esm/namespaces/desk.js.map +1 -1
  47. package/dist/esm/namespaces/plugins.js +1 -0
  48. package/dist/esm/namespaces/plugins.js.map +1 -1
  49. package/dist/esm/namespaces/portal.js +45 -0
  50. package/dist/esm/namespaces/portal.js.map +1 -1
  51. package/dist/esm/namespaces/tunnel.js +42 -0
  52. package/dist/esm/namespaces/tunnel.js.map +1 -0
  53. package/dist/esm/namespaces/whatsapp.js +2 -23
  54. package/dist/esm/namespaces/whatsapp.js.map +1 -1
  55. package/dist/esm/sender/multi/multisender.js +28 -9
  56. package/dist/esm/sender/multi/multisender.js.map +1 -1
  57. package/dist/esm/sender/sessionnegotiator.js +1 -1
  58. package/dist/esm/sender/sessionnegotiator.js.map +1 -1
  59. package/dist/esm/sender/tcp/tcpsender.js +1 -1
  60. package/dist/esm/sender/tcp/tcpsender.js.map +1 -1
  61. package/dist/esm/sender/websocket/websocketsender.js +1 -1
  62. package/dist/esm/sender/websocket/websocketsender.js.map +1 -1
  63. package/dist/esm/types/desk.js.map +1 -1
  64. package/dist/esm/types/flow.js +343 -1
  65. package/dist/esm/types/flow.js.map +1 -1
  66. package/dist/esm/utils/desk.js +59 -0
  67. package/dist/esm/utils/desk.js.map +1 -0
  68. package/dist/esm/utils/thread.js +80 -0
  69. package/dist/esm/utils/thread.js.map +1 -0
  70. package/dist/esm/utils/whatsapp.js +263 -0
  71. package/dist/esm/utils/whatsapp.js.map +1 -0
  72. package/dist/types/client.d.ts +2 -0
  73. package/dist/types/client.d.ts.map +1 -1
  74. package/dist/types/index.d.ts +3 -0
  75. package/dist/types/index.d.ts.map +1 -1
  76. package/dist/types/namespaces/account.d.ts +1790 -2
  77. package/dist/types/namespaces/account.d.ts.map +1 -1
  78. package/dist/types/namespaces/analytics.d.ts +60 -0
  79. package/dist/types/namespaces/analytics.d.ts.map +1 -1
  80. package/dist/types/namespaces/desk.d.ts +124 -4
  81. package/dist/types/namespaces/desk.d.ts.map +1 -1
  82. package/dist/types/namespaces/plugins.d.ts +1 -0
  83. package/dist/types/namespaces/plugins.d.ts.map +1 -1
  84. package/dist/types/namespaces/portal.d.ts +45 -0
  85. package/dist/types/namespaces/portal.d.ts.map +1 -1
  86. package/dist/types/namespaces/tunnel.d.ts +28 -0
  87. package/dist/types/namespaces/tunnel.d.ts.map +1 -0
  88. package/dist/types/namespaces/whatsapp.d.ts +3 -2
  89. package/dist/types/namespaces/whatsapp.d.ts.map +1 -1
  90. package/dist/types/schemas/webhook.d.ts +2 -2
  91. package/dist/types/sender/gateway/customgatewaysender.d.ts +1 -1
  92. package/dist/types/sender/http/httpsender.d.ts +1 -1
  93. package/dist/types/sender/multi/multisender.d.ts +1 -0
  94. package/dist/types/sender/multi/multisender.d.ts.map +1 -1
  95. package/dist/types/sender/sender.d.ts +1 -1
  96. package/dist/types/sender/tcp/tcpsender.d.ts +1 -1
  97. package/dist/types/sender/websocket/websocketsender.d.ts +1 -1
  98. package/dist/types/types/desk.d.ts +57 -1
  99. package/dist/types/types/desk.d.ts.map +1 -1
  100. package/dist/types/types/flow.d.ts +3269 -142
  101. package/dist/types/types/flow.d.ts.map +1 -1
  102. package/dist/types/types/message.d.ts +1 -1
  103. package/dist/types/types/whatsapp.d.ts +10 -2
  104. package/dist/types/types/whatsapp.d.ts.map +1 -1
  105. package/dist/types/utils/desk.d.ts +6 -0
  106. package/dist/types/utils/desk.d.ts.map +1 -0
  107. package/dist/types/utils/thread.d.ts +4 -0
  108. package/dist/types/utils/thread.d.ts.map +1 -0
  109. package/dist/types/utils/whatsapp.d.ts +103 -0
  110. package/dist/types/utils/whatsapp.d.ts.map +1 -0
  111. package/package.json +8 -11
  112. package/src/client.ts +3 -0
  113. package/src/index.ts +3 -0
  114. package/src/namespaces/account.ts +20 -12
  115. package/src/namespaces/analytics.ts +154 -0
  116. package/src/namespaces/desk.ts +428 -77
  117. package/src/namespaces/plugins.ts +2 -0
  118. package/src/namespaces/portal.ts +110 -0
  119. package/src/namespaces/tunnel.ts +58 -0
  120. package/src/namespaces/whatsapp.ts +4 -39
  121. package/src/sender/multi/multisender.ts +38 -14
  122. package/src/sender/sessionnegotiator.ts +1 -1
  123. package/src/sender/tcp/tcpsender.ts +1 -1
  124. package/src/sender/websocket/websocketsender.ts +1 -1
  125. package/src/types/desk.ts +66 -1
  126. package/src/types/flow.ts +387 -246
  127. package/src/types/message.ts +1 -1
  128. package/src/types/whatsapp.ts +11 -2
  129. package/src/utils/desk.ts +78 -0
  130. package/src/utils/thread.ts +119 -0
  131. package/src/utils/whatsapp.ts +530 -0
@@ -0,0 +1,530 @@
1
+ import type { MessageTemplate, MessageTemplatePreview, MessageTemplateVariable } from '../types/whatsapp.ts'
2
+
3
+ type TemplateHeaderFormat = NonNullable<MessageTemplatePreview['headerFormat']>
4
+
5
+ type TemplateHeaderComponent = {
6
+ type: 'HEADER'
7
+ format: TemplateHeaderFormat
8
+ text?: string
9
+ example?: {
10
+ header_text?: Array<string>
11
+ header_handle?: Array<string>
12
+ }
13
+ }
14
+
15
+ type TemplateBodyComponent = {
16
+ type: 'BODY'
17
+ text: string
18
+ example?: {
19
+ body_text?: [Array<string>]
20
+ }
21
+ }
22
+
23
+ type TemplateFooterComponent = {
24
+ type: 'FOOTER'
25
+ text: string
26
+ }
27
+
28
+ type TemplateButton = {
29
+ type: string
30
+ text?: string
31
+ url?: string
32
+ phone_number?: string
33
+ example?: Array<string>
34
+ }
35
+
36
+ type TemplateButtonsComponent = {
37
+ type: 'BUTTONS'
38
+ buttons: Array<TemplateButton>
39
+ }
40
+
41
+ type TemplateComponent =
42
+ | TemplateHeaderComponent
43
+ | TemplateBodyComponent
44
+ | TemplateFooterComponent
45
+ | TemplateButtonsComponent
46
+ | {
47
+ type: string
48
+ [key: string]: unknown
49
+ }
50
+
51
+ const placeholderPattern = /{{(\d+)}}/g
52
+
53
+ export type CreateMessageTemplateTextHeaderInput = {
54
+ type: 'HEADER'
55
+ format: 'TEXT'
56
+ text: string
57
+ exampleText?: Array<string>
58
+ }
59
+
60
+ export type CreateMessageTemplateLocationHeaderInput = {
61
+ type: 'HEADER'
62
+ format: 'LOCATION'
63
+ }
64
+
65
+ export type CreateMessageTemplateMediaHeaderInput = {
66
+ type: 'HEADER'
67
+ format: 'IMAGE' | 'VIDEO' | 'DOCUMENT'
68
+ exampleHandle?: string
69
+ exampleUrl?: string
70
+ }
71
+
72
+ export type CreateMessageTemplateBodyInput = {
73
+ type: 'BODY'
74
+ text: string
75
+ examples?: Array<string>
76
+ }
77
+
78
+ export type CreateMessageTemplateFooterInput = {
79
+ type: 'FOOTER'
80
+ text: string
81
+ }
82
+
83
+ export type CreateMessageTemplateQuickReplyButtonInput = {
84
+ type: 'QUICK_REPLY'
85
+ text: string
86
+ }
87
+
88
+ export type CreateMessageTemplatePhoneNumberButtonInput = {
89
+ type: 'PHONE_NUMBER'
90
+ text: string
91
+ phoneNumber: string
92
+ }
93
+
94
+ export type CreateMessageTemplateUrlButtonInput = {
95
+ type: 'URL'
96
+ text: string
97
+ url: string
98
+ example?: Array<string>
99
+ }
100
+
101
+ export type CreateMessageTemplateButtonInput =
102
+ | CreateMessageTemplateQuickReplyButtonInput
103
+ | CreateMessageTemplatePhoneNumberButtonInput
104
+ | CreateMessageTemplateUrlButtonInput
105
+
106
+ export type CreateMessageTemplateButtonsInput = {
107
+ type: 'BUTTONS'
108
+ buttons: Array<CreateMessageTemplateButtonInput>
109
+ }
110
+
111
+ export type CreateMessageTemplateComponentInput =
112
+ | CreateMessageTemplateTextHeaderInput
113
+ | CreateMessageTemplateLocationHeaderInput
114
+ | CreateMessageTemplateMediaHeaderInput
115
+ | CreateMessageTemplateBodyInput
116
+ | CreateMessageTemplateFooterInput
117
+ | CreateMessageTemplateButtonsInput
118
+
119
+ type MessageTemplatePayloadButton =
120
+ | {
121
+ type: 'QUICK_REPLY'
122
+ text: string
123
+ }
124
+ | {
125
+ type: 'PHONE_NUMBER'
126
+ text: string
127
+ phone_number: string
128
+ }
129
+ | {
130
+ type: 'URL'
131
+ text: string
132
+ url: string
133
+ example?: Array<string>
134
+ }
135
+
136
+ type MessageTemplatePayloadComponent =
137
+ | {
138
+ type: 'HEADER'
139
+ format: 'TEXT'
140
+ text: string
141
+ example?: {
142
+ header_text: Array<string>
143
+ }
144
+ }
145
+ | {
146
+ type: 'HEADER'
147
+ format: 'LOCATION'
148
+ }
149
+ | {
150
+ type: 'HEADER'
151
+ format: 'IMAGE' | 'VIDEO' | 'DOCUMENT'
152
+ example: {
153
+ header_handle: Array<string>
154
+ }
155
+ }
156
+ | {
157
+ type: 'BODY'
158
+ text: string
159
+ example?: {
160
+ body_text: [Array<string>]
161
+ }
162
+ }
163
+ | {
164
+ type: 'FOOTER'
165
+ text: string
166
+ }
167
+ | {
168
+ type: 'BUTTONS'
169
+ buttons: Array<MessageTemplatePayloadButton>
170
+ }
171
+
172
+ export type UploadedMessageTemplateHeaderMedia = {
173
+ componentIndex: number
174
+ format: 'IMAGE' | 'VIDEO' | 'DOCUMENT'
175
+ sourceUrl: string
176
+ fileHandle: string
177
+ }
178
+
179
+ export async function createMessageTemplatePayload(
180
+ input: Pick<MessageTemplate, 'name' | 'category' | 'language'> & {
181
+ components: Array<CreateMessageTemplateComponentInput>
182
+ },
183
+ options?: {
184
+ uploadHeaderMedia?: (url: string) => Promise<{ fileHandle: string }>
185
+ },
186
+ ): Promise<{
187
+ template: Pick<MessageTemplate, 'name' | 'category' | 'language' | 'components'>
188
+ uploadedHeaderMedia: Array<UploadedMessageTemplateHeaderMedia>
189
+ }> {
190
+ validateComponentSet(input.components)
191
+
192
+ const uploadedHeaderMedia: Array<UploadedMessageTemplateHeaderMedia> = []
193
+ const components = sortTemplateComponents(
194
+ await Promise.all(
195
+ input.components.map((component, index) =>
196
+ normalizeTemplateComponent(component, index, uploadedHeaderMedia, options?.uploadHeaderMedia),
197
+ ),
198
+ ),
199
+ )
200
+
201
+ return {
202
+ template: {
203
+ name: input.name,
204
+ category: input.category,
205
+ language: input.language,
206
+ components: components as MessageTemplate['components'],
207
+ },
208
+ uploadedHeaderMedia,
209
+ }
210
+ }
211
+
212
+ export function getMessageTemplateVariables(components: Array<TemplateComponent>): Array<MessageTemplateVariable> {
213
+ const variables: Array<MessageTemplateVariable> = []
214
+
215
+ const header = components.find(isMediaHeaderComponent)
216
+ if (header) {
217
+ variables.push({ type: 'MEDIA', example: header.example?.header_handle?.[0] })
218
+ }
219
+
220
+ const body = components.find(isBodyComponent)
221
+ if (body?.example?.body_text?.[0]) {
222
+ variables.push(
223
+ ...body.example.body_text[0].map((example) => ({
224
+ type: 'BODY' as const,
225
+ example,
226
+ })),
227
+ )
228
+ } else if (body) {
229
+ variables.push(...getPlaceholderMatches(body.text).map(() => ({ type: 'BODY' as const })))
230
+ }
231
+
232
+ const buttons = components.find(isButtonsComponent)
233
+ const urlButtons = buttons?.buttons.filter((button) => button.type === 'URL') ?? []
234
+ for (const button of urlButtons) {
235
+ if (Array.isArray(button.example) && button.example.length > 0) {
236
+ variables.push(
237
+ ...button.example.map((example) => ({
238
+ type: 'BUTTON' as const,
239
+ example,
240
+ })),
241
+ )
242
+ continue
243
+ }
244
+
245
+ if (typeof button.url === 'string') {
246
+ variables.push(...getPlaceholderMatches(button.url).map(() => ({ type: 'BUTTON' as const })))
247
+ }
248
+ }
249
+
250
+ return variables
251
+ }
252
+
253
+ export function getMessageTemplatePreview(components: Array<TemplateComponent>): MessageTemplatePreview {
254
+ const header = components.find(isHeaderComponent)
255
+ const body = components.find(isBodyComponent)
256
+ const footer = components.find(isFooterComponent)
257
+ const buttons = components.find(isButtonsComponent)
258
+
259
+ return {
260
+ headerFormat: header?.format,
261
+ headerText: getHeaderText(header),
262
+ bodyText: body?.text,
263
+ footerText: footer?.text,
264
+ buttons: buttons?.buttons.map(formatButtonPreview) ?? [],
265
+ }
266
+ }
267
+
268
+ function getPlaceholderMatches(text: string): Array<string> {
269
+ return text.match(placeholderPattern) ?? []
270
+ }
271
+
272
+ function isHeaderComponent(component: TemplateComponent): component is TemplateHeaderComponent {
273
+ return component.type === 'HEADER'
274
+ }
275
+
276
+ function isMediaHeaderComponent(component: TemplateComponent): component is TemplateHeaderComponent {
277
+ return (
278
+ component.type === 'HEADER' &&
279
+ (component.format === 'IMAGE' || component.format === 'VIDEO' || component.format === 'DOCUMENT')
280
+ )
281
+ }
282
+
283
+ function isBodyComponent(component: TemplateComponent): component is TemplateBodyComponent {
284
+ return component.type === 'BODY'
285
+ }
286
+
287
+ function isFooterComponent(component: TemplateComponent): component is TemplateFooterComponent {
288
+ return component.type === 'FOOTER'
289
+ }
290
+
291
+ function isButtonsComponent(component: TemplateComponent): component is TemplateButtonsComponent {
292
+ return component.type === 'BUTTONS'
293
+ }
294
+
295
+ function getHeaderText(header?: TemplateHeaderComponent): string | undefined {
296
+ if (!header) {
297
+ return undefined
298
+ }
299
+
300
+ if (header.format === 'TEXT') {
301
+ return header.text ?? header.example?.header_text?.[0]
302
+ }
303
+
304
+ if (header.format === 'LOCATION') {
305
+ return 'location header'
306
+ }
307
+
308
+ return `${header.format.toLowerCase()} header`
309
+ }
310
+
311
+ function formatButtonPreview(button: TemplateButton): string {
312
+ const label = typeof button.text === 'string' && button.text.trim().length > 0 ? button.text : button.type
313
+
314
+ if (button.type === 'URL' && typeof button.url === 'string') {
315
+ return `URL: ${label} -> ${button.url}`
316
+ }
317
+
318
+ if (button.type === 'PHONE_NUMBER' && typeof button.phone_number === 'string') {
319
+ return `PHONE_NUMBER: ${label} -> ${button.phone_number}`
320
+ }
321
+
322
+ return `${button.type}: ${label}`
323
+ }
324
+
325
+ function validateComponentSet(components: Array<CreateMessageTemplateComponentInput>) {
326
+ const counts = components.reduce<Record<CreateMessageTemplateComponentInput['type'], number>>(
327
+ (accumulator, component) => {
328
+ accumulator[component.type] = (accumulator[component.type] ?? 0) + 1
329
+ return accumulator
330
+ },
331
+ {
332
+ HEADER: 0,
333
+ BODY: 0,
334
+ FOOTER: 0,
335
+ BUTTONS: 0,
336
+ },
337
+ )
338
+
339
+ if (counts.BODY !== 1) {
340
+ throw new Error('A WhatsApp message template must include exactly one BODY component.')
341
+ }
342
+
343
+ if (counts.HEADER > 1) {
344
+ throw new Error('A WhatsApp message template can include at most one HEADER component.')
345
+ }
346
+
347
+ if (counts.FOOTER > 1) {
348
+ throw new Error('A WhatsApp message template can include at most one FOOTER component.')
349
+ }
350
+
351
+ if (counts.BUTTONS > 1) {
352
+ throw new Error('A WhatsApp message template can include at most one BUTTONS component.')
353
+ }
354
+ }
355
+
356
+ async function normalizeTemplateComponent(
357
+ component: CreateMessageTemplateComponentInput,
358
+ componentIndex: number,
359
+ uploadedHeaderMedia: Array<UploadedMessageTemplateHeaderMedia>,
360
+ uploadHeaderMedia?: (url: string) => Promise<{ fileHandle: string }>,
361
+ ): Promise<MessageTemplatePayloadComponent> {
362
+ if (component.type === 'HEADER') {
363
+ if (component.format === 'TEXT') {
364
+ const placeholders = getPlaceholderIndexes(component.text, 'header text')
365
+ assertExamplesMatchPlaceholders(placeholders, component.exampleText, 'header text')
366
+
367
+ return {
368
+ type: 'HEADER',
369
+ format: 'TEXT',
370
+ text: component.text,
371
+ ...(placeholders.length > 0
372
+ ? {
373
+ example: {
374
+ header_text: component.exampleText ?? [],
375
+ },
376
+ }
377
+ : {}),
378
+ }
379
+ }
380
+
381
+ if (component.format === 'LOCATION') {
382
+ return {
383
+ type: 'HEADER',
384
+ format: 'LOCATION',
385
+ }
386
+ }
387
+
388
+ const sourceCount = Number(Boolean(component.exampleHandle)) + Number(Boolean(component.exampleUrl))
389
+ if (sourceCount !== 1) {
390
+ throw new Error('Provide exactly one of exampleHandle or exampleUrl for media headers.')
391
+ }
392
+
393
+ if (component.exampleUrl) {
394
+ if (!uploadHeaderMedia) {
395
+ throw new Error('An uploadHeaderMedia callback is required to use exampleUrl in media headers.')
396
+ }
397
+
398
+ const attachment = await uploadHeaderMedia(component.exampleUrl)
399
+ uploadedHeaderMedia.push({
400
+ componentIndex,
401
+ format: component.format,
402
+ sourceUrl: component.exampleUrl,
403
+ fileHandle: attachment.fileHandle,
404
+ })
405
+
406
+ return {
407
+ type: 'HEADER',
408
+ format: component.format,
409
+ example: {
410
+ header_handle: [attachment.fileHandle],
411
+ },
412
+ }
413
+ }
414
+
415
+ return {
416
+ type: 'HEADER',
417
+ format: component.format,
418
+ example: {
419
+ header_handle: [component.exampleHandle as string],
420
+ },
421
+ }
422
+ }
423
+
424
+ if (component.type === 'BODY') {
425
+ const placeholders = getPlaceholderIndexes(component.text, 'body text')
426
+ assertExamplesMatchPlaceholders(placeholders, component.examples, 'body text')
427
+
428
+ return {
429
+ type: 'BODY',
430
+ text: component.text,
431
+ ...(placeholders.length > 0
432
+ ? {
433
+ example: {
434
+ body_text: [component.examples ?? []],
435
+ },
436
+ }
437
+ : {}),
438
+ }
439
+ }
440
+
441
+ if (component.type === 'FOOTER') {
442
+ return {
443
+ type: 'FOOTER',
444
+ text: component.text,
445
+ }
446
+ }
447
+
448
+ return {
449
+ type: 'BUTTONS',
450
+ buttons: component.buttons.map(normalizeButton),
451
+ }
452
+ }
453
+
454
+ function normalizeButton(button: CreateMessageTemplateButtonInput): MessageTemplatePayloadButton {
455
+ if (button.type === 'QUICK_REPLY') {
456
+ return {
457
+ type: 'QUICK_REPLY',
458
+ text: button.text,
459
+ }
460
+ }
461
+
462
+ if (button.type === 'PHONE_NUMBER') {
463
+ return {
464
+ type: 'PHONE_NUMBER',
465
+ text: button.text,
466
+ phone_number: button.phoneNumber,
467
+ }
468
+ }
469
+
470
+ const placeholders = getPlaceholderIndexes(button.url, `URL button "${button.text}"`)
471
+ assertExamplesMatchPlaceholders(placeholders, button.example, `URL button "${button.text}"`)
472
+
473
+ return {
474
+ type: 'URL',
475
+ text: button.text,
476
+ url: button.url,
477
+ ...(placeholders.length > 0 ? { example: button.example ?? [] } : {}),
478
+ }
479
+ }
480
+
481
+ function getPlaceholderIndexes(text: string, fieldName: string): Array<number> {
482
+ const indexes = [...text.matchAll(placeholderPattern)].map((match) => Number.parseInt(match[1], 10))
483
+ if (indexes.length === 0) {
484
+ return []
485
+ }
486
+
487
+ const uniqueIndexes = [...new Set(indexes)].sort((left, right) => left - right)
488
+
489
+ for (let index = 0; index < uniqueIndexes.length; index += 1) {
490
+ const expected = index + 1
491
+ if (uniqueIndexes[index] !== expected) {
492
+ throw new Error(`Invalid placeholders in ${fieldName}. Variables must be sequential and start at {{1}}.`)
493
+ }
494
+ }
495
+
496
+ return uniqueIndexes
497
+ }
498
+
499
+ function assertExamplesMatchPlaceholders(
500
+ placeholders: Array<number>,
501
+ examples: Array<string> | undefined,
502
+ fieldName: string,
503
+ ) {
504
+ if (placeholders.length === 0) {
505
+ if (examples && examples.length > 0) {
506
+ throw new Error(`Examples were provided for ${fieldName}, but the text has no variables.`)
507
+ }
508
+
509
+ return
510
+ }
511
+
512
+ if (!examples || examples.length !== placeholders.length) {
513
+ throw new Error(
514
+ `Expected ${placeholders.length} example value(s) for ${fieldName} to match its template variables.`,
515
+ )
516
+ }
517
+ }
518
+
519
+ function sortTemplateComponents(
520
+ components: Array<MessageTemplatePayloadComponent>,
521
+ ): Array<MessageTemplatePayloadComponent> {
522
+ const order: Record<MessageTemplatePayloadComponent['type'], number> = {
523
+ HEADER: 0,
524
+ BODY: 1,
525
+ FOOTER: 2,
526
+ BUTTONS: 3,
527
+ }
528
+
529
+ return [...components].sort((left, right) => order[left.type] - order[right.type])
530
+ }