@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.
- package/package.json +49 -0
- package/src/drivers/line/index.ts +50 -0
- package/src/drivers/line/liff_client.ts +126 -0
- package/src/drivers/line/liff_sign_in_route.ts +112 -0
- package/src/drivers/line/line_beacon.ts +14 -0
- package/src/drivers/line/line_config.ts +55 -0
- package/src/drivers/line/line_driver.ts +202 -0
- package/src/drivers/line/line_flex.ts +117 -0
- package/src/drivers/line/line_liff.ts +102 -0
- package/src/drivers/line/line_message_mapper.ts +118 -0
- package/src/drivers/line/line_provider.ts +33 -0
- package/src/drivers/line/line_rich_menu.ts +68 -0
- package/src/drivers/line/line_webhook.ts +168 -0
- package/src/drivers/messenger/index.ts +16 -0
- package/src/drivers/messenger/messenger_config.ts +20 -0
- package/src/drivers/messenger/messenger_driver.ts +151 -0
- package/src/drivers/messenger/messenger_message_mapper.ts +146 -0
- package/src/drivers/messenger/messenger_profile.ts +43 -0
- package/src/drivers/messenger/messenger_provider.ts +31 -0
- package/src/drivers/messenger/messenger_webhook.ts +165 -0
- package/src/drivers/telegram/index.ts +23 -0
- package/src/drivers/telegram/telegram_config.ts +19 -0
- package/src/drivers/telegram/telegram_driver.ts +121 -0
- package/src/drivers/telegram/telegram_message_mapper.ts +147 -0
- package/src/drivers/telegram/telegram_provider.ts +32 -0
- package/src/drivers/telegram/telegram_web_app.ts +108 -0
- package/src/drivers/telegram/telegram_webhook.ts +200 -0
- package/src/drivers/whatsapp/flows/index.ts +15 -0
- package/src/drivers/whatsapp/flows/whatsapp_flow_builder.ts +55 -0
- package/src/drivers/whatsapp/flows/whatsapp_flow_crypto.ts +81 -0
- package/src/drivers/whatsapp/index.ts +14 -0
- package/src/drivers/whatsapp/whatsapp_config.ts +35 -0
- package/src/drivers/whatsapp/whatsapp_driver.ts +111 -0
- package/src/drivers/whatsapp/whatsapp_message_mapper.ts +157 -0
- package/src/drivers/whatsapp/whatsapp_provider.ts +31 -0
- package/src/drivers/whatsapp/whatsapp_webhook.ts +170 -0
- package/src/drivers/zalo/index.ts +16 -0
- package/src/drivers/zalo/zalo_config.ts +37 -0
- package/src/drivers/zalo/zalo_driver.ts +130 -0
- package/src/drivers/zalo/zalo_message_mapper.ts +146 -0
- package/src/drivers/zalo/zalo_mini_app.ts +17 -0
- package/src/drivers/zalo/zalo_provider.ts +31 -0
- package/src/drivers/zalo/zalo_webhook.ts +139 -0
- package/src/errors.ts +122 -0
- package/src/index.ts +52 -0
- package/src/instant_capabilities.ts +49 -0
- package/src/instant_driver.ts +109 -0
- package/src/instant_manager.ts +113 -0
- package/src/instant_provider.ts +45 -0
- package/src/internal/fetch_json.ts +58 -0
- package/src/internal/meta/meta_graph_client.ts +51 -0
- package/src/internal/meta/meta_signature.ts +22 -0
- package/src/internal/meta/meta_webhook_challenge.ts +19 -0
- package/src/message.ts +65 -0
- package/src/types.ts +32 -0
- 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
|
+
}
|