@strav/instant 1.0.0-alpha.42

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 (56) hide show
  1. package/package.json +49 -0
  2. package/src/drivers/line/index.ts +50 -0
  3. package/src/drivers/line/liff_client.ts +126 -0
  4. package/src/drivers/line/liff_sign_in_route.ts +112 -0
  5. package/src/drivers/line/line_beacon.ts +14 -0
  6. package/src/drivers/line/line_config.ts +55 -0
  7. package/src/drivers/line/line_driver.ts +202 -0
  8. package/src/drivers/line/line_flex.ts +117 -0
  9. package/src/drivers/line/line_liff.ts +102 -0
  10. package/src/drivers/line/line_message_mapper.ts +118 -0
  11. package/src/drivers/line/line_provider.ts +33 -0
  12. package/src/drivers/line/line_rich_menu.ts +68 -0
  13. package/src/drivers/line/line_webhook.ts +168 -0
  14. package/src/drivers/messenger/index.ts +16 -0
  15. package/src/drivers/messenger/messenger_config.ts +20 -0
  16. package/src/drivers/messenger/messenger_driver.ts +151 -0
  17. package/src/drivers/messenger/messenger_message_mapper.ts +146 -0
  18. package/src/drivers/messenger/messenger_profile.ts +43 -0
  19. package/src/drivers/messenger/messenger_provider.ts +31 -0
  20. package/src/drivers/messenger/messenger_webhook.ts +165 -0
  21. package/src/drivers/telegram/index.ts +23 -0
  22. package/src/drivers/telegram/telegram_config.ts +19 -0
  23. package/src/drivers/telegram/telegram_driver.ts +121 -0
  24. package/src/drivers/telegram/telegram_message_mapper.ts +147 -0
  25. package/src/drivers/telegram/telegram_provider.ts +32 -0
  26. package/src/drivers/telegram/telegram_web_app.ts +108 -0
  27. package/src/drivers/telegram/telegram_webhook.ts +200 -0
  28. package/src/drivers/whatsapp/flows/index.ts +15 -0
  29. package/src/drivers/whatsapp/flows/whatsapp_flow_builder.ts +55 -0
  30. package/src/drivers/whatsapp/flows/whatsapp_flow_crypto.ts +81 -0
  31. package/src/drivers/whatsapp/index.ts +14 -0
  32. package/src/drivers/whatsapp/whatsapp_config.ts +35 -0
  33. package/src/drivers/whatsapp/whatsapp_driver.ts +111 -0
  34. package/src/drivers/whatsapp/whatsapp_message_mapper.ts +157 -0
  35. package/src/drivers/whatsapp/whatsapp_provider.ts +31 -0
  36. package/src/drivers/whatsapp/whatsapp_webhook.ts +170 -0
  37. package/src/drivers/zalo/index.ts +16 -0
  38. package/src/drivers/zalo/zalo_config.ts +37 -0
  39. package/src/drivers/zalo/zalo_driver.ts +130 -0
  40. package/src/drivers/zalo/zalo_message_mapper.ts +146 -0
  41. package/src/drivers/zalo/zalo_mini_app.ts +17 -0
  42. package/src/drivers/zalo/zalo_provider.ts +31 -0
  43. package/src/drivers/zalo/zalo_webhook.ts +139 -0
  44. package/src/errors.ts +122 -0
  45. package/src/index.ts +52 -0
  46. package/src/instant_capabilities.ts +49 -0
  47. package/src/instant_driver.ts +109 -0
  48. package/src/instant_manager.ts +113 -0
  49. package/src/instant_provider.ts +45 -0
  50. package/src/internal/fetch_json.ts +58 -0
  51. package/src/internal/meta/meta_graph_client.ts +51 -0
  52. package/src/internal/meta/meta_signature.ts +22 -0
  53. package/src/internal/meta/meta_webhook_challenge.ts +19 -0
  54. package/src/message.ts +65 -0
  55. package/src/types.ts +32 -0
  56. package/src/webhook_event.ts +93 -0
@@ -0,0 +1,81 @@
1
+ /**
2
+ * WhatsApp Flows data-exchange crypto.
3
+ *
4
+ * On every data-exchange request, Meta posts:
5
+ * { encrypted_flow_data, encrypted_aes_key, initial_vector }
6
+ * all base64. The server:
7
+ * 1. Decrypts `encrypted_aes_key` with its RSA-OAEP-SHA256
8
+ * private key → 16-byte AES key.
9
+ * 2. AES-128-GCM decrypts `encrypted_flow_data` with the AES
10
+ * key and the provided IV; the last 16 bytes of ciphertext
11
+ * are the GCM tag.
12
+ * 3. Re-encrypts the response with the same AES key, but the
13
+ * IV bytewise-flipped (`b ^ 0xff`).
14
+ *
15
+ * This module exposes the two operations as pure functions —
16
+ * the driver layer wires them into a route.
17
+ */
18
+
19
+ import { createDecipheriv, createCipheriv, createPrivateKey, privateDecrypt, constants } from 'node:crypto'
20
+
21
+ export interface FlowExchangeRequest {
22
+ encrypted_flow_data: string
23
+ encrypted_aes_key: string
24
+ initial_vector: string
25
+ }
26
+
27
+ export interface DecryptedFlowExchange {
28
+ /** Decrypted JSON payload Meta posted. */
29
+ payload: Record<string, unknown>
30
+ /** AES key + IV for encrypting the response — pass to `encryptFlowResponse`. */
31
+ aesKey: Buffer
32
+ iv: Buffer
33
+ }
34
+
35
+ export interface FlowsCryptoOptions {
36
+ privateKeyPem: string
37
+ passphrase?: string
38
+ }
39
+
40
+ export function decryptFlowRequest(
41
+ body: FlowExchangeRequest,
42
+ options: FlowsCryptoOptions,
43
+ ): DecryptedFlowExchange {
44
+ const privateKey = createPrivateKey({
45
+ key: options.privateKeyPem,
46
+ ...(options.passphrase ? { passphrase: options.passphrase } : {}),
47
+ })
48
+ const aesKey = privateDecrypt(
49
+ {
50
+ key: privateKey,
51
+ padding: constants.RSA_PKCS1_OAEP_PADDING,
52
+ oaepHash: 'sha256',
53
+ },
54
+ Buffer.from(body.encrypted_aes_key, 'base64'),
55
+ )
56
+ const iv = Buffer.from(body.initial_vector, 'base64')
57
+ const cipherText = Buffer.from(body.encrypted_flow_data, 'base64')
58
+ const tagLength = 16
59
+ const encrypted = cipherText.subarray(0, cipherText.length - tagLength)
60
+ const tag = cipherText.subarray(cipherText.length - tagLength)
61
+ const decipher = createDecipheriv('aes-128-gcm', aesKey, iv)
62
+ decipher.setAuthTag(tag)
63
+ const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()])
64
+ const payload = JSON.parse(decrypted.toString('utf8')) as Record<string, unknown>
65
+ return { payload, aesKey, iv }
66
+ }
67
+
68
+ export function encryptFlowResponse(
69
+ response: Record<string, unknown>,
70
+ aesKey: Buffer,
71
+ iv: Buffer,
72
+ ): string {
73
+ const flippedIv = Buffer.from(iv.map((b) => b ^ 0xff))
74
+ const cipher = createCipheriv('aes-128-gcm', aesKey, flippedIv)
75
+ const encrypted = Buffer.concat([
76
+ cipher.update(JSON.stringify(response), 'utf8'),
77
+ cipher.final(),
78
+ ])
79
+ const tag = cipher.getAuthTag()
80
+ return Buffer.concat([encrypted, tag]).toString('base64')
81
+ }
@@ -0,0 +1,14 @@
1
+ // Public API of `@strav/instant/whatsapp`.
2
+
3
+ export type {
4
+ WhatsAppFlowsConfig,
5
+ WhatsAppProviderConfig,
6
+ } from './whatsapp_config.ts'
7
+ export { WhatsAppDriver, type WhatsAppDriverOptions } from './whatsapp_driver.ts'
8
+ export { toWhatsAppPayload } from './whatsapp_message_mapper.ts'
9
+ export { WhatsAppInstantProvider } from './whatsapp_provider.ts'
10
+ export {
11
+ parseWhatsAppWebhook,
12
+ verifyWhatsAppChallenge,
13
+ verifyWhatsAppSignature,
14
+ } from './whatsapp_webhook.ts'
@@ -0,0 +1,35 @@
1
+ /**
2
+ * `WhatsAppProviderConfig` — config shape consumed by
3
+ * `WhatsAppInstantProvider`.
4
+ *
5
+ * `phoneNumberId` is the WABA-issued numeric id (not the E.164
6
+ * number). `accessToken` is the long-lived system-user token.
7
+ * `appSecret` is used to verify Meta's X-Hub signature.
8
+ * `verifyToken` is the value Meta sends as `hub.verify_token`
9
+ * on the GET-verify handshake.
10
+ *
11
+ * `flows` is required only when the app uses WhatsApp Flows for
12
+ * data-exchange endpoints — Meta encrypts the body with an
13
+ * ECC-derived AES key, and the driver needs the private key to
14
+ * decrypt and re-sign responses.
15
+ */
16
+
17
+ import type { ProviderConfig } from '../../types.ts'
18
+
19
+ export interface WhatsAppFlowsConfig {
20
+ /** Passphrase protecting `privateKeyPem`, if any. */
21
+ passphrase?: string
22
+ /** PEM-encoded RSA-2048 / EC private key registered with Meta. */
23
+ privateKeyPem: string
24
+ }
25
+
26
+ export interface WhatsAppProviderConfig extends ProviderConfig {
27
+ driver: 'whatsapp'
28
+ phoneNumberId: string
29
+ accessToken: string
30
+ appSecret: string
31
+ verifyToken: string
32
+ /** Defaults to `v20.0`. */
33
+ apiVersion?: string
34
+ flows?: WhatsAppFlowsConfig
35
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * `WhatsAppDriver` — `InstantDriver` for the WhatsApp Cloud API.
3
+ *
4
+ * Wraps `MetaGraphClient` for `/messages` posts. No `reply` /
5
+ * `multicast` / `broadcast` — the Cloud API has no native batch
6
+ * fan-out; apps loop `push` with their own rate-limit policy.
7
+ *
8
+ * `profile` returns whatever name the inbound contact carried —
9
+ * Cloud has no per-user profile endpoint comparable to LINE.
10
+ * Apps that need names should read them off webhook payloads
11
+ * and cache.
12
+ */
13
+
14
+ import { InstantProviderError } from '../../errors.ts'
15
+ import type { InstantCapability } from '../../instant_capabilities.ts'
16
+ import type { InstantDriver, UserProfile, WebhookOps } from '../../instant_driver.ts'
17
+ import type { OutgoingMessage, SendResult } from '../../message.ts'
18
+ import { MetaGraphClient } from '../../internal/meta/meta_graph_client.ts'
19
+ import type { WhatsAppProviderConfig } from './whatsapp_config.ts'
20
+ import { toWhatsAppPayload } from './whatsapp_message_mapper.ts'
21
+ import {
22
+ parseWhatsAppWebhook,
23
+ verifyWhatsAppChallenge,
24
+ verifyWhatsAppSignature,
25
+ } from './whatsapp_webhook.ts'
26
+
27
+ const DEFAULT_CAPABILITIES: ReadonlySet<InstantCapability> = new Set<InstantCapability>([
28
+ 'send.text',
29
+ 'send.image',
30
+ 'send.video',
31
+ 'send.audio',
32
+ 'send.file',
33
+ 'send.location',
34
+ 'send.sticker',
35
+ 'send.interactive',
36
+ 'send.templateParams',
37
+ 'send.template',
38
+ 'send.quickReplies',
39
+ 'push',
40
+ 'miniApp',
41
+ 'webhook.signature',
42
+ 'webhook.parse',
43
+ ])
44
+
45
+ export interface WhatsAppDriverOptions {
46
+ instanceName: string
47
+ config: WhatsAppProviderConfig
48
+ /** Inject a pre-built graph client (tests). */
49
+ client?: MetaGraphClient
50
+ }
51
+
52
+ export class WhatsAppDriver implements InstantDriver {
53
+ readonly name = 'whatsapp'
54
+ readonly instanceName: string
55
+ readonly capabilities = DEFAULT_CAPABILITIES
56
+ readonly webhook: WebhookOps
57
+ private readonly client: MetaGraphClient
58
+ private readonly phoneNumberId: string
59
+
60
+ constructor(options: WhatsAppDriverOptions) {
61
+ const { instanceName, config } = options
62
+ requireField(config.phoneNumberId, 'phoneNumberId', instanceName)
63
+ requireField(config.accessToken, 'accessToken', instanceName)
64
+ requireField(config.appSecret, 'appSecret', instanceName)
65
+ requireField(config.verifyToken, 'verifyToken', instanceName)
66
+ this.instanceName = instanceName
67
+ this.phoneNumberId = config.phoneNumberId
68
+ this.client =
69
+ options.client ??
70
+ new MetaGraphClient({
71
+ accessToken: config.accessToken,
72
+ ...(config.apiVersion ? { apiVersion: config.apiVersion } : {}),
73
+ provider: 'whatsapp',
74
+ })
75
+ const appSecret = config.appSecret
76
+ const verifyToken = config.verifyToken
77
+ this.webhook = {
78
+ verifySignature: (rawBody, header) => verifyWhatsAppSignature(rawBody, header, appSecret),
79
+ parse: (rawBody) => parseWhatsAppWebhook(rawBody),
80
+ verifyChallenge: (params) => verifyWhatsAppChallenge(params, verifyToken),
81
+ }
82
+ }
83
+
84
+ async send(to: string, message: OutgoingMessage): Promise<SendResult> {
85
+ return this.push(to, message)
86
+ }
87
+
88
+ async push(to: string, message: OutgoingMessage): Promise<SendResult> {
89
+ const body = toWhatsAppPayload(to, message)
90
+ const response = await this.client.post<{ messages?: Array<{ id: string }> }>(
91
+ `/${this.phoneNumberId}/messages`,
92
+ body,
93
+ 'send',
94
+ )
95
+ return {
96
+ provider: 'whatsapp',
97
+ accepted: true,
98
+ ...(response.messages?.[0]?.id ? { messageId: response.messages[0].id } : {}),
99
+ raw: response,
100
+ }
101
+ }
102
+ }
103
+
104
+ function requireField(value: string | undefined, field: string, instanceName: string): void {
105
+ if (!value) {
106
+ throw new InstantProviderError(
107
+ `WhatsAppDriver: \`${field}\` is required for provider "${instanceName}".`,
108
+ { provider: 'whatsapp', operation: 'init', status: 500 },
109
+ )
110
+ }
111
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Map `OutgoingMessage` → WhatsApp Cloud `/messages` payload.
3
+ *
4
+ * WhatsApp's send endpoint takes one message per call — the
5
+ * mapper returns the message body and the driver POSTs once.
6
+ * Text + attachment → caller decides; the mapper emits one
7
+ * payload per call. Apps that want both send the text first
8
+ * and the attachment second (or the other way around).
9
+ *
10
+ * Provider-native escape hatches via `raw`:
11
+ * - `raw.template?: { name, language, components }` → `template` HSM
12
+ * - `raw.list?: { header?, body, footer?, button, sections[] }` → `interactive.list`
13
+ * - `raw.flow?: { token, screen, cta, mode? }` → `interactive.flow`
14
+ * - `raw.contextMessageId?: string` → `context.message_id` (in-thread reply)
15
+ */
16
+
17
+ import { InstantProviderError } from '../../errors.ts'
18
+ import type { Attachment, OutgoingMessage, QuickReply } from '../../message.ts'
19
+
20
+ interface WhatsAppRawExtras {
21
+ template?: { name: string; language: string; components?: unknown[] }
22
+ list?: {
23
+ header?: string
24
+ body: string
25
+ footer?: string
26
+ button: string
27
+ sections: Array<{ title?: string; rows: Array<{ id: string; title: string; description?: string }> }>
28
+ }
29
+ flow?: { token: string; screen?: string; cta: string; mode?: 'draft' | 'published'; data?: Record<string, unknown> }
30
+ contextMessageId?: string
31
+ }
32
+
33
+ export function toWhatsAppPayload(to: string, message: OutgoingMessage): Record<string, unknown> {
34
+ const raw = (message.raw ?? {}) as WhatsAppRawExtras
35
+ const base: Record<string, unknown> = {
36
+ messaging_product: 'whatsapp',
37
+ recipient_type: 'individual',
38
+ to,
39
+ ...(raw.contextMessageId ? { context: { message_id: raw.contextMessageId } } : {}),
40
+ }
41
+
42
+ if (raw.template) {
43
+ return {
44
+ ...base,
45
+ type: 'template',
46
+ template: {
47
+ name: raw.template.name,
48
+ language: { code: raw.template.language },
49
+ ...(raw.template.components ? { components: raw.template.components } : {}),
50
+ },
51
+ }
52
+ }
53
+
54
+ if (raw.flow) {
55
+ return {
56
+ ...base,
57
+ type: 'interactive',
58
+ interactive: {
59
+ type: 'flow',
60
+ body: { text: message.text ?? '' },
61
+ action: {
62
+ name: 'flow',
63
+ parameters: {
64
+ flow_token: raw.flow.token,
65
+ flow_cta: raw.flow.cta,
66
+ flow_action: 'navigate',
67
+ ...(raw.flow.screen ? { flow_action_payload: { screen: raw.flow.screen, ...(raw.flow.data ? { data: raw.flow.data } : {}) } } : {}),
68
+ mode: raw.flow.mode ?? 'published',
69
+ },
70
+ },
71
+ },
72
+ }
73
+ }
74
+
75
+ if (raw.list) {
76
+ return {
77
+ ...base,
78
+ type: 'interactive',
79
+ interactive: {
80
+ type: 'list',
81
+ ...(raw.list.header ? { header: { type: 'text', text: raw.list.header } } : {}),
82
+ body: { text: raw.list.body },
83
+ ...(raw.list.footer ? { footer: { text: raw.list.footer } } : {}),
84
+ action: { button: raw.list.button, sections: raw.list.sections },
85
+ },
86
+ }
87
+ }
88
+
89
+ if (message.quickReplies && message.quickReplies.length > 0) {
90
+ return {
91
+ ...base,
92
+ type: 'interactive',
93
+ interactive: buildInteractiveButtons(message.text, message.quickReplies),
94
+ }
95
+ }
96
+
97
+ const attachment = message.attachments?.[0]
98
+ if (attachment) return { ...base, ...attachmentPayload(attachment, message.text) }
99
+
100
+ if (!message.text) {
101
+ throw new InstantProviderError(
102
+ 'WhatsApp send requires `text`, an attachment, or a `raw.template`/`raw.list`/`raw.flow`.',
103
+ { provider: 'whatsapp', operation: 'send', status: 400 },
104
+ )
105
+ }
106
+
107
+ return { ...base, type: 'text', text: { body: message.text } }
108
+ }
109
+
110
+ function buildInteractiveButtons(
111
+ text: string | undefined,
112
+ quickReplies: QuickReply[],
113
+ ): Record<string, unknown> {
114
+ if (quickReplies.length > 3) {
115
+ throw new InstantProviderError(
116
+ 'WhatsApp interactive buttons support at most 3 quick replies. Use `raw.list` for longer menus.',
117
+ { provider: 'whatsapp', operation: 'send', status: 400 },
118
+ )
119
+ }
120
+ const buttons = quickReplies.map((qr, i) => ({
121
+ type: 'reply',
122
+ reply: { id: qr.action.type === 'postback' ? qr.action.data : `qr_${i}`, title: qr.label.slice(0, 20) },
123
+ }))
124
+ return {
125
+ type: 'button',
126
+ body: { text: text ?? '' },
127
+ action: { buttons },
128
+ }
129
+ }
130
+
131
+ function attachmentPayload(a: Attachment, text: string | undefined): Record<string, unknown> {
132
+ switch (a.type) {
133
+ case 'image':
134
+ return { type: 'image', image: { link: a.url, ...(text ? { caption: text } : {}) } }
135
+ case 'video':
136
+ return { type: 'video', video: { link: a.url, ...(text ? { caption: text } : {}) } }
137
+ case 'audio':
138
+ return { type: 'audio', audio: { link: a.url } }
139
+ case 'file':
140
+ return {
141
+ type: 'document',
142
+ document: { link: a.url, ...(a.fileName ? { filename: a.fileName } : {}), ...(text ? { caption: text } : {}) },
143
+ }
144
+ case 'location':
145
+ return {
146
+ type: 'location',
147
+ location: {
148
+ latitude: a.latitude,
149
+ longitude: a.longitude,
150
+ ...(a.title ? { name: a.title } : {}),
151
+ ...(a.address ? { address: a.address } : {}),
152
+ },
153
+ }
154
+ case 'sticker':
155
+ return { type: 'sticker', sticker: { id: a.stickerId } }
156
+ }
157
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `WhatsAppInstantProvider` — `ServiceProvider` that registers
3
+ * the WhatsApp Cloud driver factory on the `InstantManager`.
4
+ */
5
+
6
+ import { type Application, ServiceProvider } from '@strav/kernel'
7
+ import { InstantConfigError } from '../../errors.ts'
8
+ import { InstantManager } from '../../instant_manager.ts'
9
+ import type { WhatsAppProviderConfig } from './whatsapp_config.ts'
10
+ import { WhatsAppDriver } from './whatsapp_driver.ts'
11
+
12
+ export class WhatsAppInstantProvider extends ServiceProvider {
13
+ override readonly name = 'instant-whatsapp'
14
+ override readonly dependencies = ['instant']
15
+
16
+ override register(app: Application): void {
17
+ const manager = app.resolve(InstantManager)
18
+ manager.extend('whatsapp', ({ instanceName, config }) => {
19
+ const cfg = config as WhatsAppProviderConfig
20
+ for (const field of ['phoneNumberId', 'accessToken', 'appSecret', 'verifyToken'] as const) {
21
+ if (!cfg[field]) {
22
+ throw new InstantConfigError(
23
+ `WhatsAppInstantProvider: \`${field}\` is required for provider "${instanceName}".`,
24
+ { context: { instanceName, field } },
25
+ )
26
+ }
27
+ }
28
+ return new WhatsAppDriver({ instanceName, config: cfg })
29
+ })
30
+ }
31
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * WhatsApp webhook — signature, GET-handshake, and parser.
3
+ *
4
+ * Signature delegates to the shared Meta verifier
5
+ * (`X-Hub-Signature-256` over the raw body).
6
+ *
7
+ * Parser walks `entry[].changes[].value.messages[]` and emits
8
+ * normalized `WebhookEvent`s. Status callbacks
9
+ * (`value.statuses[]`) and contacts/order events map to
10
+ * `UnknownEvent`.
11
+ */
12
+
13
+ import { WebhookSignatureError } from '../../errors.ts'
14
+ import { verifyMetaChallenge } from '../../internal/meta/meta_webhook_challenge.ts'
15
+ import { verifyMetaSignature } from '../../internal/meta/meta_signature.ts'
16
+ import type {
17
+ LocationMessageEvent,
18
+ MediaMessageEvent,
19
+ PostbackEvent,
20
+ StickerMessageEvent,
21
+ TextMessageEvent,
22
+ UnknownEvent,
23
+ WebhookEvent,
24
+ WebhookEventBase,
25
+ } from '../../webhook_event.ts'
26
+
27
+ export function verifyWhatsAppSignature(
28
+ rawBody: string,
29
+ header: string | null | undefined,
30
+ appSecret: string,
31
+ ): boolean {
32
+ return verifyMetaSignature(rawBody, header, appSecret)
33
+ }
34
+
35
+ export function verifyWhatsAppChallenge(
36
+ params: Record<string, string | undefined>,
37
+ verifyToken: string,
38
+ ): string | null {
39
+ return verifyMetaChallenge(params, verifyToken)
40
+ }
41
+
42
+ interface WAPayload {
43
+ object?: string
44
+ entry?: WAEntry[]
45
+ }
46
+ interface WAEntry {
47
+ id: string
48
+ changes?: WAChange[]
49
+ }
50
+ interface WAChange {
51
+ field: string
52
+ value?: WAValue
53
+ }
54
+ interface WAValue {
55
+ messaging_product?: string
56
+ metadata?: { phone_number_id: string; display_phone_number: string }
57
+ contacts?: Array<{ wa_id: string; profile?: { name?: string } }>
58
+ messages?: WAMessage[]
59
+ statuses?: unknown[]
60
+ }
61
+ interface WAMessage {
62
+ id: string
63
+ from: string
64
+ timestamp: string
65
+ type: string
66
+ text?: { body: string }
67
+ image?: { id: string; mime_type?: string }
68
+ video?: { id: string; mime_type?: string }
69
+ audio?: { id: string; mime_type?: string }
70
+ document?: { id: string; mime_type?: string; filename?: string }
71
+ sticker?: { id: string }
72
+ location?: { latitude: number; longitude: number; name?: string; address?: string }
73
+ interactive?: {
74
+ type: 'button_reply' | 'list_reply' | 'nfm_reply'
75
+ button_reply?: { id: string; title: string }
76
+ list_reply?: { id: string; title: string; description?: string }
77
+ nfm_reply?: { response_json: string; name?: string }
78
+ }
79
+ button?: { payload: string; text: string }
80
+ }
81
+
82
+ export function parseWhatsAppWebhook(rawBody: string): WebhookEvent[] {
83
+ let payload: WAPayload
84
+ try {
85
+ payload = JSON.parse(rawBody) as WAPayload
86
+ } catch (cause) {
87
+ throw new WebhookSignatureError('WhatsApp webhook body is not valid JSON.', { cause })
88
+ }
89
+ const events: WebhookEvent[] = []
90
+ for (const entry of payload.entry ?? []) {
91
+ for (const change of entry.changes ?? []) {
92
+ const value = change.value
93
+ if (!value) continue
94
+ for (const m of value.messages ?? []) {
95
+ const event = mapMessage(m, value)
96
+ if (event) events.push(event)
97
+ }
98
+ }
99
+ }
100
+ return events
101
+ }
102
+
103
+ function mapMessage(m: WAMessage, value: WAValue): WebhookEvent | undefined {
104
+ const base: WebhookEventBase = {
105
+ provider: 'whatsapp',
106
+ userId: m.from,
107
+ timestamp: new Date(Number.parseInt(m.timestamp, 10) * 1000),
108
+ source: 'user',
109
+ raw: { message: m, contacts: value.contacts ?? [] },
110
+ }
111
+ switch (m.type) {
112
+ case 'text': {
113
+ if (!m.text) break
114
+ const e: TextMessageEvent = { ...base, type: 'message.text', text: m.text.body, messageId: m.id }
115
+ return e
116
+ }
117
+ case 'image':
118
+ case 'video':
119
+ case 'audio':
120
+ case 'document': {
121
+ const mapped =
122
+ m.type === 'image'
123
+ ? 'message.image'
124
+ : m.type === 'video'
125
+ ? 'message.video'
126
+ : m.type === 'audio'
127
+ ? 'message.audio'
128
+ : 'message.file'
129
+ const e: MediaMessageEvent = { ...base, type: mapped, messageId: m.id }
130
+ return e
131
+ }
132
+ case 'sticker': {
133
+ const e: StickerMessageEvent = {
134
+ ...base,
135
+ type: 'message.sticker',
136
+ messageId: m.id,
137
+ ...(m.sticker?.id ? { stickerId: m.sticker.id } : {}),
138
+ }
139
+ return e
140
+ }
141
+ case 'location': {
142
+ if (!m.location) break
143
+ const e: LocationMessageEvent = {
144
+ ...base,
145
+ type: 'message.location',
146
+ messageId: m.id,
147
+ latitude: m.location.latitude,
148
+ longitude: m.location.longitude,
149
+ ...(m.location.name ? { title: m.location.name } : {}),
150
+ ...(m.location.address ? { address: m.location.address } : {}),
151
+ }
152
+ return e
153
+ }
154
+ case 'interactive': {
155
+ const data =
156
+ m.interactive?.button_reply?.id ??
157
+ m.interactive?.list_reply?.id ??
158
+ m.interactive?.nfm_reply?.response_json ??
159
+ ''
160
+ const e: PostbackEvent = { ...base, type: 'postback', data }
161
+ return e
162
+ }
163
+ case 'button': {
164
+ const e: PostbackEvent = { ...base, type: 'postback', data: m.button?.payload ?? '' }
165
+ return e
166
+ }
167
+ }
168
+ const fallback: UnknownEvent = { ...base, type: 'unknown' }
169
+ return fallback
170
+ }
@@ -0,0 +1,16 @@
1
+ // Public API of `@strav/instant/zalo`.
2
+
3
+ export type {
4
+ ZaloProviderConfig,
5
+ ZaloTokenStore,
6
+ ZnsTemplateMeta,
7
+ } from './zalo_config.ts'
8
+ export { ZaloDriver, type ZaloDriverOptions } from './zalo_driver.ts'
9
+ export {
10
+ toZaloEnvelope,
11
+ type ZaloMessageEnvelope,
12
+ type ZaloMessageType,
13
+ } from './zalo_message_mapper.ts'
14
+ export { miniAppDeepLink } from './zalo_mini_app.ts'
15
+ export { ZaloInstantProvider } from './zalo_provider.ts'
16
+ export { parseZaloWebhook, verifyZaloSignature, type ZaloSignatureOptions } from './zalo_webhook.ts'
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `ZaloProviderConfig` — config shape consumed by
3
+ * `ZaloInstantProvider`.
4
+ *
5
+ * Zalo OA access tokens are short-lived (~1h) and must be
6
+ * refreshed using the long-lived `refreshToken`. Apps that
7
+ * deploy long-running workers should pass a `tokenStore` so
8
+ * the driver can persist refreshed credentials across
9
+ * processes; otherwise the in-memory copy is the only
10
+ * source-of-truth and refreshing is the app's responsibility.
11
+ */
12
+
13
+ import type { ProviderConfig } from '../../types.ts'
14
+
15
+ export interface ZaloTokenStore {
16
+ load(): Promise<{ accessToken: string; refreshToken?: string } | null>
17
+ save(creds: { accessToken: string; refreshToken?: string }): Promise<void>
18
+ }
19
+
20
+ export interface ZnsTemplateMeta {
21
+ templateId: string
22
+ /** Documented param keys for clearer downstream type errors. */
23
+ params: readonly string[]
24
+ }
25
+
26
+ export interface ZaloProviderConfig extends ProviderConfig {
27
+ driver: 'zalo'
28
+ oaId: string
29
+ accessToken: string
30
+ appId: string
31
+ appSecret: string
32
+ refreshToken?: string
33
+ znsTemplates?: Record<string, ZnsTemplateMeta>
34
+ tokenStore?: ZaloTokenStore
35
+ /** Override OA API base (defaults to `https://openapi.zalo.me`). */
36
+ apiBaseURL?: string
37
+ }