@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,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ZaloDriver` — `InstantDriver` for the Zalo Official Account
|
|
3
|
+
* Messaging API. Uses `fetch` (no SDK dependency).
|
|
4
|
+
*
|
|
5
|
+
* Zalo's access token is short-lived. The driver re-uses
|
|
6
|
+
* `config.accessToken` as-is and lets the app refresh via
|
|
7
|
+
* `tokenStore`; refresh handling is out of scope for the
|
|
8
|
+
* driver itself (apps wire a cron + `tokenStore.save`).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { InstantProviderError } from '../../errors.ts'
|
|
12
|
+
import type { InstantCapability } from '../../instant_capabilities.ts'
|
|
13
|
+
import type { InstantDriver, UserProfile, WebhookOps } from '../../instant_driver.ts'
|
|
14
|
+
import type { OutgoingMessage, SendResult } from '../../message.ts'
|
|
15
|
+
import { fetchJson } from '../../internal/fetch_json.ts'
|
|
16
|
+
import type { ZaloProviderConfig } from './zalo_config.ts'
|
|
17
|
+
import { toZaloEnvelope, type ZaloMessageType } from './zalo_message_mapper.ts'
|
|
18
|
+
import { parseZaloWebhook, verifyZaloSignature } from './zalo_webhook.ts'
|
|
19
|
+
|
|
20
|
+
const DEFAULT_CAPABILITIES: ReadonlySet<InstantCapability> = new Set<InstantCapability>([
|
|
21
|
+
'send.text',
|
|
22
|
+
'send.image',
|
|
23
|
+
'send.video',
|
|
24
|
+
'send.audio',
|
|
25
|
+
'send.file',
|
|
26
|
+
'send.location',
|
|
27
|
+
'send.sticker',
|
|
28
|
+
'send.template',
|
|
29
|
+
'send.templateParams',
|
|
30
|
+
'send.quickReplies',
|
|
31
|
+
'push',
|
|
32
|
+
'profile',
|
|
33
|
+
'miniApp',
|
|
34
|
+
'webhook.signature',
|
|
35
|
+
'webhook.parse',
|
|
36
|
+
])
|
|
37
|
+
|
|
38
|
+
const ENDPOINT_PATH: Record<ZaloMessageType, string> = {
|
|
39
|
+
cs: '/v3.0/oa/message/cs',
|
|
40
|
+
transaction: '/v3.0/oa/message/transaction',
|
|
41
|
+
zns: '/v2.0/oa/message/zns',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ZaloDriverOptions {
|
|
45
|
+
instanceName: string
|
|
46
|
+
config: ZaloProviderConfig
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class ZaloDriver implements InstantDriver {
|
|
50
|
+
readonly name = 'zalo'
|
|
51
|
+
readonly instanceName: string
|
|
52
|
+
readonly capabilities = DEFAULT_CAPABILITIES
|
|
53
|
+
readonly webhook: WebhookOps
|
|
54
|
+
private accessToken: string
|
|
55
|
+
private readonly base: string
|
|
56
|
+
|
|
57
|
+
constructor(options: ZaloDriverOptions) {
|
|
58
|
+
const { instanceName, config } = options
|
|
59
|
+
for (const field of ['oaId', 'accessToken', 'appId', 'appSecret'] as const) {
|
|
60
|
+
if (!config[field]) {
|
|
61
|
+
throw new InstantProviderError(
|
|
62
|
+
`ZaloDriver: \`${field}\` is required for provider "${instanceName}".`,
|
|
63
|
+
{ provider: 'zalo', operation: 'init', status: 500 },
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
this.instanceName = instanceName
|
|
68
|
+
this.accessToken = config.accessToken
|
|
69
|
+
this.base = config.apiBaseURL ?? 'https://openapi.zalo.me'
|
|
70
|
+
const appId = config.appId
|
|
71
|
+
const appSecret = config.appSecret
|
|
72
|
+
this.webhook = {
|
|
73
|
+
verifySignature: (rawBody) => verifyZaloSignature(rawBody, { appId, appSecret }),
|
|
74
|
+
parse: (rawBody) => parseZaloWebhook(rawBody),
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
setAccessToken(token: string): void {
|
|
79
|
+
this.accessToken = token
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async send(to: string, message: OutgoingMessage): Promise<SendResult> {
|
|
83
|
+
return this.push(to, message)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async push(to: string, message: OutgoingMessage): Promise<SendResult> {
|
|
87
|
+
const envelope = toZaloEnvelope(to, message)
|
|
88
|
+
const response = await this.invoke<{ data?: { message_id?: string }; error?: number; message?: string }>(
|
|
89
|
+
ENDPOINT_PATH[envelope.endpoint],
|
|
90
|
+
envelope.body,
|
|
91
|
+
`send.${envelope.endpoint}`,
|
|
92
|
+
)
|
|
93
|
+
if (typeof response.error === 'number' && response.error !== 0) {
|
|
94
|
+
throw new InstantProviderError(`Zalo API returned error ${response.error}: ${response.message ?? ''}`.trim(), {
|
|
95
|
+
provider: 'zalo',
|
|
96
|
+
operation: `send.${envelope.endpoint}`,
|
|
97
|
+
context: { error: response.error, message: response.message },
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
provider: 'zalo',
|
|
102
|
+
accepted: true,
|
|
103
|
+
...(response.data?.message_id ? { messageId: response.data.message_id } : {}),
|
|
104
|
+
raw: response,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async profile(userId: string): Promise<UserProfile> {
|
|
109
|
+
const response = await this.invoke<{
|
|
110
|
+
data?: { user_id?: string; display_name?: string; avatar?: string }
|
|
111
|
+
}>('/v2.0/oa/getprofile', { user_id: userId }, 'profile')
|
|
112
|
+
const r = response.data ?? {}
|
|
113
|
+
return {
|
|
114
|
+
userId: r.user_id ?? userId,
|
|
115
|
+
...(r.display_name ? { displayName: r.display_name } : {}),
|
|
116
|
+
...(r.avatar ? { pictureUrl: r.avatar } : {}),
|
|
117
|
+
raw: response,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private invoke<T>(path: string, body: Record<string, unknown>, operation: string): Promise<T> {
|
|
122
|
+
return fetchJson<T>(`${this.base}${path}`, {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
headers: { access_token: this.accessToken, 'Content-Type': 'application/json' },
|
|
125
|
+
body: JSON.stringify(body),
|
|
126
|
+
provider: 'zalo',
|
|
127
|
+
operation,
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map `OutgoingMessage` → Zalo OA message body.
|
|
3
|
+
*
|
|
4
|
+
* Two endpoints:
|
|
5
|
+
* - `/v3.0/oa/message/cs` — customer-service (within 24h
|
|
6
|
+
* reply window). Default for `send`/`push`.
|
|
7
|
+
* - `/v3.0/oa/message/transaction` — transactional sends
|
|
8
|
+
* outside the window. Requires `raw.zns`-style template
|
|
9
|
+
* approval.
|
|
10
|
+
*
|
|
11
|
+
* ZNS (Zalo Notification Service) sends go through a different
|
|
12
|
+
* URL path; the driver handles routing. The mapper produces the
|
|
13
|
+
* body only.
|
|
14
|
+
*
|
|
15
|
+
* Provider-native escape hatches via `raw`:
|
|
16
|
+
* - `raw.zns?: { template_id, template_data }` → ZNS template send
|
|
17
|
+
* - `raw.messageType?: 'cs' | 'transaction'` (default 'cs')
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { InstantProviderError } from '../../errors.ts'
|
|
21
|
+
import type { Attachment, OutgoingMessage, QuickReply } from '../../message.ts'
|
|
22
|
+
|
|
23
|
+
export type ZaloMessageType = 'cs' | 'transaction' | 'zns'
|
|
24
|
+
|
|
25
|
+
export interface ZaloMessageEnvelope {
|
|
26
|
+
endpoint: ZaloMessageType
|
|
27
|
+
body: Record<string, unknown>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ZaloRawExtras {
|
|
31
|
+
zns?: { template_id: string; template_data: Record<string, string>; tracking_id?: string }
|
|
32
|
+
messageType?: 'cs' | 'transaction'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function toZaloEnvelope(to: string, message: OutgoingMessage): ZaloMessageEnvelope {
|
|
36
|
+
const raw = (message.raw ?? {}) as ZaloRawExtras
|
|
37
|
+
|
|
38
|
+
if (raw.zns) {
|
|
39
|
+
return {
|
|
40
|
+
endpoint: 'zns',
|
|
41
|
+
body: {
|
|
42
|
+
phone: to,
|
|
43
|
+
template_id: raw.zns.template_id,
|
|
44
|
+
template_data: raw.zns.template_data,
|
|
45
|
+
...(raw.zns.tracking_id ? { tracking_id: raw.zns.tracking_id } : {}),
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const endpoint: ZaloMessageType = raw.messageType ?? 'cs'
|
|
51
|
+
const recipient = { user_id: to }
|
|
52
|
+
const attachment = message.attachments?.[0]
|
|
53
|
+
const quickReplies = message.quickReplies
|
|
54
|
+
|
|
55
|
+
if (attachment) {
|
|
56
|
+
return {
|
|
57
|
+
endpoint,
|
|
58
|
+
body: { recipient, message: attachmentBody(attachment, message.text, quickReplies) },
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (quickReplies && quickReplies.length > 0) {
|
|
63
|
+
return {
|
|
64
|
+
endpoint,
|
|
65
|
+
body: {
|
|
66
|
+
recipient,
|
|
67
|
+
message: {
|
|
68
|
+
text: message.text ?? '',
|
|
69
|
+
attachment: { type: 'template', payload: listTemplate(quickReplies) },
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!message.text) {
|
|
76
|
+
throw new InstantProviderError(
|
|
77
|
+
'Zalo send requires `text`, an attachment, or `raw.zns`.',
|
|
78
|
+
{ provider: 'zalo', operation: 'send', status: 400 },
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { endpoint, body: { recipient, message: { text: message.text } } }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function attachmentBody(
|
|
86
|
+
a: Attachment,
|
|
87
|
+
text: string | undefined,
|
|
88
|
+
quickReplies: QuickReply[] | undefined,
|
|
89
|
+
): Record<string, unknown> {
|
|
90
|
+
const baseText = text ?? ''
|
|
91
|
+
switch (a.type) {
|
|
92
|
+
case 'image':
|
|
93
|
+
return {
|
|
94
|
+
text: baseText,
|
|
95
|
+
attachment: { type: 'template', payload: { template_type: 'media', elements: [{ media_type: 'image', url: a.url }] } },
|
|
96
|
+
}
|
|
97
|
+
case 'video':
|
|
98
|
+
return {
|
|
99
|
+
text: baseText,
|
|
100
|
+
attachment: { type: 'template', payload: { template_type: 'media', elements: [{ media_type: 'video', url: a.url }] } },
|
|
101
|
+
}
|
|
102
|
+
case 'file':
|
|
103
|
+
return {
|
|
104
|
+
text: baseText,
|
|
105
|
+
attachment: { type: 'file', payload: { url: a.url } },
|
|
106
|
+
}
|
|
107
|
+
case 'audio':
|
|
108
|
+
// Zalo OA has no first-class audio bubble — surface as a file.
|
|
109
|
+
return { text: baseText, attachment: { type: 'file', payload: { url: a.url } } }
|
|
110
|
+
case 'location':
|
|
111
|
+
return {
|
|
112
|
+
text: baseText || a.title || 'Location',
|
|
113
|
+
attachment: {
|
|
114
|
+
type: 'template',
|
|
115
|
+
payload: { template_type: 'request_user_info', elements: [{ id: 'location', text: a.title, address: a.address }] },
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
case 'sticker':
|
|
119
|
+
return { text: baseText, sticker: { id: a.stickerId } }
|
|
120
|
+
default:
|
|
121
|
+
return quickReplies
|
|
122
|
+
? { text: baseText, attachment: { type: 'template', payload: listTemplate(quickReplies) } }
|
|
123
|
+
: { text: baseText }
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function listTemplate(quickReplies: QuickReply[]): Record<string, unknown> {
|
|
128
|
+
return {
|
|
129
|
+
template_type: 'list',
|
|
130
|
+
elements: quickReplies.map((qr) => {
|
|
131
|
+
const element: Record<string, unknown> = { title: qr.label }
|
|
132
|
+
switch (qr.action.type) {
|
|
133
|
+
case 'postback':
|
|
134
|
+
element['default_action'] = { type: 'oa.query.hide', payload: qr.action.data }
|
|
135
|
+
break
|
|
136
|
+
case 'uri':
|
|
137
|
+
element['default_action'] = { type: 'oa.open.url', url: qr.action.uri }
|
|
138
|
+
break
|
|
139
|
+
case 'message':
|
|
140
|
+
element['default_action'] = { type: 'oa.query.show', payload: qr.action.text }
|
|
141
|
+
break
|
|
142
|
+
}
|
|
143
|
+
return element
|
|
144
|
+
}),
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zalo Mini App helpers.
|
|
3
|
+
*
|
|
4
|
+
* `miniAppDeepLink(appId, path, params)` builds a deep link
|
|
5
|
+
* that opens a Zalo Mini App on Android/iOS. Send via a list
|
|
6
|
+
* template with an `oa.open.url` action.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export function miniAppDeepLink(
|
|
10
|
+
appId: string,
|
|
11
|
+
path: string,
|
|
12
|
+
params: Record<string, string> = {},
|
|
13
|
+
): string {
|
|
14
|
+
const search = new URLSearchParams(params).toString()
|
|
15
|
+
const safePath = path.startsWith('/') ? path : `/${path}`
|
|
16
|
+
return `https://zalo.me/s/${encodeURIComponent(appId)}${safePath}${search ? `?${search}` : ''}`
|
|
17
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ZaloInstantProvider` — `ServiceProvider` that registers
|
|
3
|
+
* the Zalo OA 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 { ZaloProviderConfig } from './zalo_config.ts'
|
|
10
|
+
import { ZaloDriver } from './zalo_driver.ts'
|
|
11
|
+
|
|
12
|
+
export class ZaloInstantProvider extends ServiceProvider {
|
|
13
|
+
override readonly name = 'instant-zalo'
|
|
14
|
+
override readonly dependencies = ['instant']
|
|
15
|
+
|
|
16
|
+
override register(app: Application): void {
|
|
17
|
+
const manager = app.resolve(InstantManager)
|
|
18
|
+
manager.extend('zalo', ({ instanceName, config }) => {
|
|
19
|
+
const cfg = config as ZaloProviderConfig
|
|
20
|
+
for (const field of ['oaId', 'accessToken', 'appId', 'appSecret'] as const) {
|
|
21
|
+
if (!cfg[field]) {
|
|
22
|
+
throw new InstantConfigError(
|
|
23
|
+
`ZaloInstantProvider: \`${field}\` is required for provider "${instanceName}".`,
|
|
24
|
+
{ context: { instanceName, field } },
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return new ZaloDriver({ instanceName, config: cfg })
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zalo OA webhook — signature + parse.
|
|
3
|
+
*
|
|
4
|
+
* Signature: Zalo OA posts a `mac` field inside the JSON body
|
|
5
|
+
* computed as `HMAC_SHA256(<appId><event_name><timestamp>, appSecret)`
|
|
6
|
+
* (hex). Apps verify by recomputing and constant-time comparing.
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ The exact signature scheme has shifted between Zalo OA
|
|
9
|
+
* versions. The verifier is isolated to one function — if Zalo
|
|
10
|
+
* ships a v2 scheme, swap the formula here. The driver passes
|
|
11
|
+
* the raw body so the verifier can re-parse and read the
|
|
12
|
+
* relevant fields.
|
|
13
|
+
*
|
|
14
|
+
* Parse: maps `event_name` to the framework's WebhookEvent
|
|
15
|
+
* union. Anything unknown → UnknownEvent.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
19
|
+
import { WebhookSignatureError } from '../../errors.ts'
|
|
20
|
+
import type {
|
|
21
|
+
FollowEvent,
|
|
22
|
+
LocationMessageEvent,
|
|
23
|
+
MediaMessageEvent,
|
|
24
|
+
PostbackEvent,
|
|
25
|
+
StickerMessageEvent,
|
|
26
|
+
TextMessageEvent,
|
|
27
|
+
UnknownEvent,
|
|
28
|
+
WebhookEvent,
|
|
29
|
+
WebhookEventBase,
|
|
30
|
+
} from '../../webhook_event.ts'
|
|
31
|
+
|
|
32
|
+
export interface ZaloSignatureOptions {
|
|
33
|
+
appId: string
|
|
34
|
+
appSecret: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ZaloWebhookBody {
|
|
38
|
+
app_id?: string
|
|
39
|
+
event_name?: string
|
|
40
|
+
timestamp?: string
|
|
41
|
+
sender?: { id: string }
|
|
42
|
+
recipient?: { id: string }
|
|
43
|
+
message?: {
|
|
44
|
+
msg_id?: string
|
|
45
|
+
text?: string
|
|
46
|
+
attachments?: Array<{ type: string; payload?: Record<string, unknown> }>
|
|
47
|
+
}
|
|
48
|
+
mac?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function verifyZaloSignature(rawBody: string, options: ZaloSignatureOptions): boolean {
|
|
52
|
+
let parsed: ZaloWebhookBody
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(rawBody) as ZaloWebhookBody
|
|
55
|
+
} catch {
|
|
56
|
+
return false
|
|
57
|
+
}
|
|
58
|
+
const { mac, event_name, timestamp } = parsed
|
|
59
|
+
if (!mac || !event_name || !timestamp) return false
|
|
60
|
+
if (parsed.app_id && parsed.app_id !== options.appId) return false
|
|
61
|
+
const data = `${options.appId}${event_name}${timestamp}`
|
|
62
|
+
const expected = createHmac('sha256', options.appSecret).update(data).digest('hex')
|
|
63
|
+
if (mac.length !== expected.length) return false
|
|
64
|
+
return timingSafeEqual(Buffer.from(mac, 'hex'), Buffer.from(expected, 'hex'))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function parseZaloWebhook(rawBody: string): WebhookEvent[] {
|
|
68
|
+
let payload: ZaloWebhookBody
|
|
69
|
+
try {
|
|
70
|
+
payload = JSON.parse(rawBody) as ZaloWebhookBody
|
|
71
|
+
} catch (cause) {
|
|
72
|
+
throw new WebhookSignatureError('Zalo webhook body is not valid JSON.', { cause })
|
|
73
|
+
}
|
|
74
|
+
const event = mapEvent(payload)
|
|
75
|
+
return event ? [event] : []
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function mapEvent(p: ZaloWebhookBody): WebhookEvent | undefined {
|
|
79
|
+
if (!p.event_name) return undefined
|
|
80
|
+
const senderId = p.sender?.id
|
|
81
|
+
if (!senderId) return undefined
|
|
82
|
+
const base: WebhookEventBase = {
|
|
83
|
+
provider: 'zalo',
|
|
84
|
+
userId: senderId,
|
|
85
|
+
timestamp: p.timestamp ? new Date(Number.parseInt(p.timestamp, 10)) : new Date(),
|
|
86
|
+
source: 'user',
|
|
87
|
+
raw: p,
|
|
88
|
+
}
|
|
89
|
+
const messageId = p.message?.msg_id ?? ''
|
|
90
|
+
|
|
91
|
+
switch (p.event_name) {
|
|
92
|
+
case 'user_send_text': {
|
|
93
|
+
const e: TextMessageEvent = { ...base, type: 'message.text', text: p.message?.text ?? '', messageId }
|
|
94
|
+
return e
|
|
95
|
+
}
|
|
96
|
+
case 'user_send_image':
|
|
97
|
+
return { ...base, type: 'message.image', messageId } as MediaMessageEvent
|
|
98
|
+
case 'user_send_gif':
|
|
99
|
+
case 'user_send_video':
|
|
100
|
+
return { ...base, type: 'message.video', messageId } as MediaMessageEvent
|
|
101
|
+
case 'user_send_audio':
|
|
102
|
+
return { ...base, type: 'message.audio', messageId } as MediaMessageEvent
|
|
103
|
+
case 'user_send_file':
|
|
104
|
+
return { ...base, type: 'message.file', messageId } as MediaMessageEvent
|
|
105
|
+
case 'user_send_sticker': {
|
|
106
|
+
const e: StickerMessageEvent = { ...base, type: 'message.sticker', messageId }
|
|
107
|
+
return e
|
|
108
|
+
}
|
|
109
|
+
case 'user_send_location': {
|
|
110
|
+
const att = p.message?.attachments?.[0]?.payload as { coordinates?: { latitude: number; longitude: number } } | undefined
|
|
111
|
+
if (!att?.coordinates) return { ...base, type: 'unknown' }
|
|
112
|
+
const e: LocationMessageEvent = {
|
|
113
|
+
...base,
|
|
114
|
+
type: 'message.location',
|
|
115
|
+
messageId,
|
|
116
|
+
latitude: att.coordinates.latitude,
|
|
117
|
+
longitude: att.coordinates.longitude,
|
|
118
|
+
}
|
|
119
|
+
return e
|
|
120
|
+
}
|
|
121
|
+
case 'user_click_chatnow':
|
|
122
|
+
case 'user_submit_info':
|
|
123
|
+
case 'user_send_link': {
|
|
124
|
+
const data = (p.message?.attachments?.[0]?.payload?.['payload'] as string | undefined) ?? p.message?.text ?? ''
|
|
125
|
+
const e: PostbackEvent = { ...base, type: 'postback', data }
|
|
126
|
+
return e
|
|
127
|
+
}
|
|
128
|
+
case 'follow': {
|
|
129
|
+
const e: FollowEvent = { ...base, type: 'follow' }
|
|
130
|
+
return e
|
|
131
|
+
}
|
|
132
|
+
case 'unfollow': {
|
|
133
|
+
const e: FollowEvent = { ...base, type: 'unfollow' }
|
|
134
|
+
return e
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const fallback: UnknownEvent = { ...base, type: 'unknown' }
|
|
138
|
+
return fallback
|
|
139
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `InstantError` hierarchy — typed wrappers for failures across
|
|
3
|
+
* the instant-messaging stack. Vendor-native errors (LINE API
|
|
4
|
+
* failures, WhatsApp rejections) are preserved on `.cause` so
|
|
5
|
+
* apps can still `instanceof` the underlying type for retry /
|
|
6
|
+
* recovery logic; the wrapping just gives the framework a
|
|
7
|
+
* consistent `StravError` for the standard exception handler.
|
|
8
|
+
*
|
|
9
|
+
* Subclasses:
|
|
10
|
+
*
|
|
11
|
+
* - `InstantConfigError` — `config.instant` missing required
|
|
12
|
+
* fields. Thrown at boot from `InstantProvider`.
|
|
13
|
+
*
|
|
14
|
+
* - `ProviderUnsupportedError` — driver doesn't implement the
|
|
15
|
+
* requested operation (e.g. `messenger.flex(...)`). Thrown
|
|
16
|
+
* synchronously so apps fail fast.
|
|
17
|
+
*
|
|
18
|
+
* - `UnknownProviderError` — `instant.use('x')` for a name not
|
|
19
|
+
* configured. 400 — usually a config bug.
|
|
20
|
+
*
|
|
21
|
+
* - `WebhookSignatureError` — signature header missing or
|
|
22
|
+
* doesn't verify. Webhook route returns 400; LINE retries.
|
|
23
|
+
*
|
|
24
|
+
* - `InstantProviderError` — generic wrapper around a vendor
|
|
25
|
+
* exception that doesn't map to a more specific subclass.
|
|
26
|
+
* Preserves `.cause`; default status 502.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { StravError } from '@strav/kernel'
|
|
30
|
+
|
|
31
|
+
export class InstantError extends StravError {
|
|
32
|
+
constructor(
|
|
33
|
+
message: string,
|
|
34
|
+
options: {
|
|
35
|
+
code?: string
|
|
36
|
+
status?: number
|
|
37
|
+
context?: Record<string, unknown>
|
|
38
|
+
cause?: unknown
|
|
39
|
+
} = {},
|
|
40
|
+
) {
|
|
41
|
+
super(
|
|
42
|
+
message,
|
|
43
|
+
{ code: options.code ?? 'instant.error', status: options.status ?? 500 },
|
|
44
|
+
{
|
|
45
|
+
...(options.context ? { context: options.context } : {}),
|
|
46
|
+
...(options.cause !== undefined ? { cause: options.cause } : {}),
|
|
47
|
+
},
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class InstantConfigError extends InstantError {
|
|
53
|
+
constructor(message: string, options: { context?: Record<string, unknown> } = {}) {
|
|
54
|
+
super(message, {
|
|
55
|
+
code: 'instant.config',
|
|
56
|
+
status: 500,
|
|
57
|
+
...(options.context ? { context: options.context } : {}),
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class UnknownProviderError extends InstantError {
|
|
63
|
+
constructor(name: string, available: readonly string[]) {
|
|
64
|
+
super(
|
|
65
|
+
`Instant provider "${name}" is not configured. Available: ${available.join(', ') || '<none>'}.`,
|
|
66
|
+
{
|
|
67
|
+
code: 'instant.unknown_provider',
|
|
68
|
+
status: 400,
|
|
69
|
+
context: { requested: name, available },
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class ProviderUnsupportedError extends InstantError {
|
|
76
|
+
constructor(provider: string, operation: string, options: { reason?: string } = {}) {
|
|
77
|
+
const trailer = options.reason ? ` ${options.reason}` : ''
|
|
78
|
+
super(`Instant provider "${provider}" does not support "${operation}".${trailer}`, {
|
|
79
|
+
code: 'instant.provider_unsupported',
|
|
80
|
+
status: 400,
|
|
81
|
+
context: { provider, operation, ...(options.reason ? { reason: options.reason } : {}) },
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export class WebhookSignatureError extends InstantError {
|
|
87
|
+
constructor(
|
|
88
|
+
message: string,
|
|
89
|
+
options: { context?: Record<string, unknown>; cause?: unknown } = {},
|
|
90
|
+
) {
|
|
91
|
+
super(message, {
|
|
92
|
+
code: 'instant.webhook_signature',
|
|
93
|
+
status: 400,
|
|
94
|
+
...(options.context ? { context: options.context } : {}),
|
|
95
|
+
...(options.cause !== undefined ? { cause: options.cause } : {}),
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export class InstantProviderError extends InstantError {
|
|
101
|
+
constructor(
|
|
102
|
+
message: string,
|
|
103
|
+
options: {
|
|
104
|
+
provider: string
|
|
105
|
+
operation: string
|
|
106
|
+
context?: Record<string, unknown>
|
|
107
|
+
cause?: unknown
|
|
108
|
+
status?: number
|
|
109
|
+
},
|
|
110
|
+
) {
|
|
111
|
+
super(message, {
|
|
112
|
+
code: 'instant.provider_error',
|
|
113
|
+
status: options.status ?? 502,
|
|
114
|
+
context: {
|
|
115
|
+
provider: options.provider,
|
|
116
|
+
operation: options.operation,
|
|
117
|
+
...(options.context ?? {}),
|
|
118
|
+
},
|
|
119
|
+
...(options.cause !== undefined ? { cause: options.cause } : {}),
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Public API of `@strav/instant`.
|
|
2
|
+
//
|
|
3
|
+
// V1: provider-agnostic instant-messaging abstraction —
|
|
4
|
+
// normalized `OutgoingMessage` + `WebhookEvent` + multi-provider
|
|
5
|
+
// routing. The LINE driver ships as a subpath at
|
|
6
|
+
// `@strav/instant/line` with Flex builder, rich menus, beacons,
|
|
7
|
+
// and LIFF ID-token verification. WhatsApp + Messenger drivers
|
|
8
|
+
// come in follow-up slices.
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
InstantConfigError,
|
|
12
|
+
InstantError,
|
|
13
|
+
InstantProviderError,
|
|
14
|
+
ProviderUnsupportedError,
|
|
15
|
+
UnknownProviderError,
|
|
16
|
+
WebhookSignatureError,
|
|
17
|
+
} from './errors.ts'
|
|
18
|
+
export type { InstantCapability } from './instant_capabilities.ts'
|
|
19
|
+
export type {
|
|
20
|
+
InstantDriver,
|
|
21
|
+
InstantDriverFactory,
|
|
22
|
+
UserProfile,
|
|
23
|
+
WebhookOps,
|
|
24
|
+
} from './instant_driver.ts'
|
|
25
|
+
export {
|
|
26
|
+
InstantManager,
|
|
27
|
+
type InstantManagerOptions,
|
|
28
|
+
} from './instant_manager.ts'
|
|
29
|
+
export { InstantProvider } from './instant_provider.ts'
|
|
30
|
+
export type {
|
|
31
|
+
Attachment,
|
|
32
|
+
OutgoingMessage,
|
|
33
|
+
QuickReply,
|
|
34
|
+
SendResult,
|
|
35
|
+
} from './message.ts'
|
|
36
|
+
export type {
|
|
37
|
+
InstantConfig,
|
|
38
|
+
ProviderConfig,
|
|
39
|
+
} from './types.ts'
|
|
40
|
+
export type {
|
|
41
|
+
BeaconEvent,
|
|
42
|
+
FollowEvent,
|
|
43
|
+
JoinEvent,
|
|
44
|
+
LocationMessageEvent,
|
|
45
|
+
MediaMessageEvent,
|
|
46
|
+
PostbackEvent,
|
|
47
|
+
StickerMessageEvent,
|
|
48
|
+
TextMessageEvent,
|
|
49
|
+
UnknownEvent,
|
|
50
|
+
WebhookEvent,
|
|
51
|
+
WebhookEventBase,
|
|
52
|
+
} from './webhook_event.ts'
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `InstantCapability` — granular feature flags every driver declares
|
|
3
|
+
* in `driver.capabilities`. Apps that build provider-neutral flows
|
|
4
|
+
* branch on capability before calling:
|
|
5
|
+
*
|
|
6
|
+
* if (instant.use('line').capabilities.has('send.flex')) { ... }
|
|
7
|
+
*
|
|
8
|
+
* Granularity is intentionally fine — one flag per non-trivial
|
|
9
|
+
* surface, not one per group — so e.g. WhatsApp can support
|
|
10
|
+
* `send.template` without claiming `send.flex`, and LINE can
|
|
11
|
+
* support `richMenu` and `beacon` without any analogue elsewhere.
|
|
12
|
+
*
|
|
13
|
+
* Drivers omit a capability when they can't fulfil it faithfully.
|
|
14
|
+
* Apps reach into provider-specific subpath imports
|
|
15
|
+
* (`@strav/instant/line`) when they need behaviour that doesn't
|
|
16
|
+
* map to a common capability.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type InstantCapability =
|
|
20
|
+
// outbound content shapes
|
|
21
|
+
| 'send.text'
|
|
22
|
+
| 'send.image'
|
|
23
|
+
| 'send.video'
|
|
24
|
+
| 'send.audio'
|
|
25
|
+
| 'send.file'
|
|
26
|
+
| 'send.location'
|
|
27
|
+
| 'send.sticker'
|
|
28
|
+
| 'send.quickReplies'
|
|
29
|
+
| 'send.template'
|
|
30
|
+
| 'send.templateParams'
|
|
31
|
+
| 'send.interactive'
|
|
32
|
+
| 'send.flex'
|
|
33
|
+
// outbound endpoints
|
|
34
|
+
| 'reply'
|
|
35
|
+
| 'push'
|
|
36
|
+
| 'multicast'
|
|
37
|
+
| 'broadcast'
|
|
38
|
+
// identity + relationship
|
|
39
|
+
| 'profile'
|
|
40
|
+
| 'loadingIndicator'
|
|
41
|
+
// platform-specific surfaces
|
|
42
|
+
| 'richMenu'
|
|
43
|
+
| 'beacon'
|
|
44
|
+
| 'liff'
|
|
45
|
+
| 'miniApp'
|
|
46
|
+
| 'persistentMenu'
|
|
47
|
+
// inbound
|
|
48
|
+
| 'webhook.signature'
|
|
49
|
+
| 'webhook.parse'
|