engagelab-otp 1.0.0
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/README.md +136 -0
- package/package.json +36 -0
- package/src/index.d.ts +93 -0
- package/src/index.js +318 -0
package/README.md
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# engagelab-otp · Node.js
|
|
2
|
+
|
|
3
|
+
Official Node.js SDK for [EngageLab](https://www.engagelab.com) OTP.
|
|
4
|
+
Zero dependencies. Node.js 14+. TypeScript declarations included.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install engagelab-otp
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Quick start
|
|
13
|
+
|
|
14
|
+
```js
|
|
15
|
+
const { OTPClient } = require('engagelab-otp');
|
|
16
|
+
|
|
17
|
+
const otp = new OTPClient(
|
|
18
|
+
process.env.ENGAGELAB_DEV_KEY,
|
|
19
|
+
process.env.ENGAGELAB_DEV_SECRET
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
// Platform-generated OTP — easiest path
|
|
23
|
+
const { message_id } = await otp.send('+6591234567', 'your-template-id', {}, 'en');
|
|
24
|
+
const { verified } = await otp.verify(message_id, userTypedCode);
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Two send modes
|
|
28
|
+
|
|
29
|
+
| Mode | Method | When to use |
|
|
30
|
+
|------------------------|-----------------|-------------|
|
|
31
|
+
| Platform-generated | `otp.send()` | EngageLab generates and stores the code. You only call `verify()` later. Simplest path. |
|
|
32
|
+
| Caller-generated | `otp.sendCustom()` | You generate the code yourself. EngageLab is just the carrier. Use when you need the code in your own DB. |
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
// Platform-generated
|
|
36
|
+
const r = await otp.send('+6591234567', 'tpl-id', { name: 'Alice' }, 'en');
|
|
37
|
+
const v = await otp.verify(r.message_id, '123456');
|
|
38
|
+
|
|
39
|
+
// Caller-generated
|
|
40
|
+
await otp.sendCustom('+6591234567', 'custom-tpl', { code: '482910', name: 'Alice' });
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Webhook callbacks
|
|
44
|
+
|
|
45
|
+
EngageLab signs callbacks with `X-CALLBACK-ID` (HMAC-SHA256). The SDK verifies signatures and parses events for you.
|
|
46
|
+
|
|
47
|
+
> Whitelist source IPs in your firewall: `119.8.170.74`, `114.119.180.30`
|
|
48
|
+
|
|
49
|
+
```js
|
|
50
|
+
const express = require('express');
|
|
51
|
+
const { WebhookVerifier } = require('engagelab-otp');
|
|
52
|
+
|
|
53
|
+
const app = express();
|
|
54
|
+
app.use(express.json());
|
|
55
|
+
|
|
56
|
+
const verifier = new WebhookVerifier({
|
|
57
|
+
username: process.env.ENGAGELAB_WEBHOOK_USERNAME,
|
|
58
|
+
secret: process.env.ENGAGELAB_WEBHOOK_SECRET,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
app.post('/webhook', verifier.middleware(async (events) => {
|
|
62
|
+
for (const e of events) {
|
|
63
|
+
if (e.kind !== 'message_status') continue;
|
|
64
|
+
|
|
65
|
+
if (!e.is_terminal) {
|
|
66
|
+
// mid-flight (e.g. fallback in progress) — wait, do not act
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (e.status === 'delivered') await markDelivered(e.message_id);
|
|
71
|
+
else if (e.status === 'verified') await markVerified(e.message_id);
|
|
72
|
+
else await handleFailure(e);
|
|
73
|
+
}
|
|
74
|
+
}));
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Event types
|
|
78
|
+
|
|
79
|
+
`parseEvents()` returns one of four typed objects:
|
|
80
|
+
|
|
81
|
+
| `kind` | When | Key fields |
|
|
82
|
+
|------------------|---------------------------------------|------------|
|
|
83
|
+
| `message_status` | per-message lifecycle | `message_id`, `status`, `is_terminal`, `current_send_channel`, `error_code` |
|
|
84
|
+
| `notification` | account-level alert | `event` (`insufficient_balance`, …), `data` |
|
|
85
|
+
| `uplink` | inbound user reply | `from`, `to`, `body` |
|
|
86
|
+
| `system_event` | console action audit | `event` (`account_login`, …) |
|
|
87
|
+
|
|
88
|
+
### Message status enum
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
plan · target_valid · target_invalid
|
|
92
|
+
sent · sent_failed
|
|
93
|
+
delivered · delivered_failed
|
|
94
|
+
verified · verified_failed · verified_timeout
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`is_terminal === true` for: `delivered`, `delivered_failed`, `sent_failed`, `verified*`, `target_invalid`.
|
|
98
|
+
|
|
99
|
+
## Error handling
|
|
100
|
+
|
|
101
|
+
```js
|
|
102
|
+
const { EngagelabError } = require('engagelab-otp');
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
await otp.send('+6591234567', 'tpl', {});
|
|
106
|
+
} catch (err) {
|
|
107
|
+
if (err instanceof EngagelabError) {
|
|
108
|
+
if (err.retryable) {
|
|
109
|
+
// HTTP 429/5xx, or API codes 1000/5001/5016
|
|
110
|
+
// → exponential backoff
|
|
111
|
+
} else {
|
|
112
|
+
// Permanent failure — fix your call or notify the user
|
|
113
|
+
console.log(err.code, err.httpStatus, err.message);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Examples
|
|
120
|
+
|
|
121
|
+
See [`examples/`](./examples) for runnable code:
|
|
122
|
+
|
|
123
|
+
- `01-send-and-verify.js` — platform-generated OTP, full flow
|
|
124
|
+
- `02-send-custom.js` — caller-generated code, single + bulk
|
|
125
|
+
- `03-webhook-express.js` — receive callbacks via Express middleware
|
|
126
|
+
- `04-error-handling.js` — retry strategy and error categorization
|
|
127
|
+
|
|
128
|
+
## Run tests
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
npm test
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "engagelab-otp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Official Node.js SDK for EngageLab OTP — send, verify, and parse webhook callbacks",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"types": "src/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"src/",
|
|
9
|
+
"README.md",
|
|
10
|
+
"LICENSE"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=14"
|
|
14
|
+
},
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/engagelab/engagelab-otp-node.git"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://www.engagelab.com",
|
|
21
|
+
"keywords": [
|
|
22
|
+
"engagelab",
|
|
23
|
+
"otp",
|
|
24
|
+
"sms",
|
|
25
|
+
"verification",
|
|
26
|
+
"webhook",
|
|
27
|
+
"two-factor",
|
|
28
|
+
"2fa"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "node test/test.js"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"engagelab-otp": "file:engagelab-otp-1.0.0.tgz"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export interface SendResult {
|
|
2
|
+
message_id: string;
|
|
3
|
+
send_channel: 'sms' | 'whatsapp' | 'email' | 'voice';
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface VerifyResult {
|
|
7
|
+
message_id: string;
|
|
8
|
+
verify_code: string;
|
|
9
|
+
verified: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type MessageStatus =
|
|
13
|
+
| 'plan'
|
|
14
|
+
| 'target_valid' | 'target_invalid'
|
|
15
|
+
| 'sent' | 'sent_failed' | 'sent_fail'
|
|
16
|
+
| 'delivered' | 'delivered_failed'
|
|
17
|
+
| 'verified' | 'verified_failed' | 'verified_timeout';
|
|
18
|
+
|
|
19
|
+
export interface MessageStatusEvent {
|
|
20
|
+
kind: 'message_status';
|
|
21
|
+
message_id: string;
|
|
22
|
+
to: string;
|
|
23
|
+
server: string;
|
|
24
|
+
channel: string;
|
|
25
|
+
timestamp: number;
|
|
26
|
+
custom_args: Record<string, unknown>;
|
|
27
|
+
status: MessageStatus;
|
|
28
|
+
is_terminal: boolean;
|
|
29
|
+
current_send_channel?: string;
|
|
30
|
+
template_key?: string;
|
|
31
|
+
business_id?: string;
|
|
32
|
+
msg_time?: number;
|
|
33
|
+
billing: { cost: number; currency: string } | null;
|
|
34
|
+
error_code: number;
|
|
35
|
+
error_message?: string;
|
|
36
|
+
raw: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface NotificationEvent {
|
|
40
|
+
kind: 'notification';
|
|
41
|
+
server: string;
|
|
42
|
+
timestamp: number;
|
|
43
|
+
event: 'insufficient_verification_rate' | 'insufficient_balance' | 'template_audit_result' | string;
|
|
44
|
+
data: Record<string, unknown>;
|
|
45
|
+
raw: unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface UplinkEvent {
|
|
49
|
+
kind: 'uplink';
|
|
50
|
+
server: string;
|
|
51
|
+
timestamp: number;
|
|
52
|
+
message_id: string;
|
|
53
|
+
business_id: string;
|
|
54
|
+
event: 'uplink_message' | string;
|
|
55
|
+
data: { from: string; to: string; body: string; message_sid: string; account_sid: string };
|
|
56
|
+
raw: unknown;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface SystemEvent {
|
|
60
|
+
kind: 'system_event';
|
|
61
|
+
server: string;
|
|
62
|
+
timestamp: number;
|
|
63
|
+
event: 'account_login' | 'key_manage' | 'template_manage' | 'msg_history' | 'api_call' | string;
|
|
64
|
+
data: Record<string, unknown>;
|
|
65
|
+
raw: unknown;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type CallbackEvent = MessageStatusEvent | NotificationEvent | UplinkEvent | SystemEvent;
|
|
69
|
+
|
|
70
|
+
export class EngagelabError extends Error {
|
|
71
|
+
code: number;
|
|
72
|
+
httpStatus: number;
|
|
73
|
+
retryable: boolean;
|
|
74
|
+
}
|
|
75
|
+
export class WebhookSignatureError extends Error {}
|
|
76
|
+
|
|
77
|
+
export const TERMINAL_STATUSES: Set<MessageStatus>;
|
|
78
|
+
|
|
79
|
+
export class OTPClient {
|
|
80
|
+
constructor(devKey: string, devSecret: string, opts?: { timeout?: number });
|
|
81
|
+
send(to: string, templateId: string, params?: Record<string, string>, language?: string): Promise<SendResult>;
|
|
82
|
+
sendCustom(to: string | string[], templateId: string, params?: Record<string, string>): Promise<SendResult>;
|
|
83
|
+
verify(messageId: string, code: string): Promise<VerifyResult>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export class WebhookVerifier {
|
|
87
|
+
constructor(opts: { username: string; secret: string; toleranceSeconds?: number });
|
|
88
|
+
verify(headerValue: string, now?: number): true;
|
|
89
|
+
parseEvents(body: string | object): CallbackEvent[];
|
|
90
|
+
middleware(
|
|
91
|
+
handler: (events: CallbackEvent[], req: any) => Promise<void> | void
|
|
92
|
+
): (req: any, res: any) => Promise<void>;
|
|
93
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const https = require('https');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const { URL } = require('url');
|
|
6
|
+
|
|
7
|
+
const SDK_VERSION = '1.0.0';
|
|
8
|
+
|
|
9
|
+
// ─── Errors ───────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
const RETRYABLE_API_CODES = new Set([1000, 5001, 5016]);
|
|
12
|
+
const RETRYABLE_HTTP = new Set([429, 500, 502, 503, 504]);
|
|
13
|
+
|
|
14
|
+
class EngagelabError extends Error {
|
|
15
|
+
constructor(message, code = 0, httpStatus = 0) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = 'EngagelabError';
|
|
18
|
+
this.code = code;
|
|
19
|
+
this.httpStatus = httpStatus;
|
|
20
|
+
this.retryable = RETRYABLE_API_CODES.has(code) || RETRYABLE_HTTP.has(httpStatus);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
class WebhookSignatureError extends Error {
|
|
25
|
+
constructor(message) { super(message); this.name = 'WebhookSignatureError'; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ─── HTTP helper ──────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
function request({ url, auth, body, timeout }) {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const u = new URL(url);
|
|
33
|
+
const payload = JSON.stringify(body);
|
|
34
|
+
const req = https.request({
|
|
35
|
+
hostname: u.hostname,
|
|
36
|
+
port: u.port || 443,
|
|
37
|
+
path: u.pathname + u.search,
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: {
|
|
40
|
+
'Content-Type': 'application/json',
|
|
41
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
42
|
+
'Authorization': auth,
|
|
43
|
+
'User-Agent': `engagelab-otp-node/${SDK_VERSION}`,
|
|
44
|
+
},
|
|
45
|
+
timeout: timeout || 10000,
|
|
46
|
+
}, (res) => {
|
|
47
|
+
let data = '';
|
|
48
|
+
res.on('data', c => { data += c; });
|
|
49
|
+
res.on('end', () => {
|
|
50
|
+
let json;
|
|
51
|
+
try { json = JSON.parse(data); } catch (_) { json = { message: data }; }
|
|
52
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
53
|
+
resolve(json);
|
|
54
|
+
} else {
|
|
55
|
+
reject(new EngagelabError(json.message || 'Unknown error', json.code || 0, res.statusCode));
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
req.on('timeout', () => { req.destroy(); reject(new EngagelabError('Request timed out', 0, 0)); });
|
|
60
|
+
req.on('error', reject);
|
|
61
|
+
req.write(payload);
|
|
62
|
+
req.end();
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── OTPClient ────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Client for the EngageLab OTP API.
|
|
70
|
+
* Covers both modes:
|
|
71
|
+
* - send() → platform generates the code (POST /v1/messages)
|
|
72
|
+
* - sendCustom() → caller supplies the code (POST /v1/custom-messages)
|
|
73
|
+
* - verify() → verify user-entered code (POST /v1/verifications)
|
|
74
|
+
*/
|
|
75
|
+
class OTPClient {
|
|
76
|
+
/**
|
|
77
|
+
* @param {string} devKey
|
|
78
|
+
* @param {string} devSecret
|
|
79
|
+
* @param {object} [opts]
|
|
80
|
+
* @param {number} [opts.timeout=10000]
|
|
81
|
+
*/
|
|
82
|
+
constructor(devKey, devSecret, opts = {}) {
|
|
83
|
+
if (!devKey || !devSecret) throw new Error('devKey and devSecret are required');
|
|
84
|
+
this._auth = 'Basic ' + Buffer.from(`${devKey}:${devSecret}`).toString('base64');
|
|
85
|
+
this._timeout = opts.timeout || 10000;
|
|
86
|
+
this._base = 'https://otp.api.engagelab.cc/v1';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
_post(path, body) {
|
|
90
|
+
return request({ url: this._base + path, auth: this._auth, timeout: this._timeout, body });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Send an OTP — platform generates the code.
|
|
95
|
+
* @param {string} to E.164 phone or email
|
|
96
|
+
* @param {string} templateId
|
|
97
|
+
* @param {object} [params] extra template variables (no `code`, platform fills it)
|
|
98
|
+
* @param {string} [language='default'] default | zh_CN | zh_HK | en | ja | th | es
|
|
99
|
+
* @returns {Promise<{ message_id: string, send_channel: string }>}
|
|
100
|
+
*/
|
|
101
|
+
send(to, templateId, params = {}, language = 'default') {
|
|
102
|
+
if (!to) throw new Error('to is required');
|
|
103
|
+
if (!templateId) throw new Error('templateId is required');
|
|
104
|
+
return this._post('/messages', {
|
|
105
|
+
to,
|
|
106
|
+
template: { id: templateId, language, params },
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Send a custom message — caller supplies all template variables (including `code`).
|
|
112
|
+
* @param {string|string[]} to
|
|
113
|
+
* @param {string} templateId
|
|
114
|
+
* @param {object} [params]
|
|
115
|
+
* @returns {Promise<{ message_id: string, send_channel: string }>}
|
|
116
|
+
*/
|
|
117
|
+
sendCustom(to, templateId, params = {}) {
|
|
118
|
+
if (!to) throw new Error('to is required');
|
|
119
|
+
if (!templateId) throw new Error('templateId is required');
|
|
120
|
+
return this._post('/custom-messages', {
|
|
121
|
+
to,
|
|
122
|
+
template: { id: templateId, params },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Verify a code the user entered.
|
|
128
|
+
* @param {string} messageId
|
|
129
|
+
* @param {string} code
|
|
130
|
+
* @returns {Promise<{ message_id: string, verify_code: string, verified: boolean }>}
|
|
131
|
+
*/
|
|
132
|
+
verify(messageId, code) {
|
|
133
|
+
if (!messageId) throw new Error('messageId is required');
|
|
134
|
+
if (!code) throw new Error('code is required');
|
|
135
|
+
return this._post('/verifications', {
|
|
136
|
+
message_id: messageId,
|
|
137
|
+
verify_code: String(code),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ─── WebhookVerifier ──────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
const TERMINAL_STATUSES = new Set([
|
|
145
|
+
'delivered',
|
|
146
|
+
'delivered_failed',
|
|
147
|
+
'sent_failed',
|
|
148
|
+
'sent_fail',
|
|
149
|
+
'verified',
|
|
150
|
+
'verified_failed',
|
|
151
|
+
'verified_timeout',
|
|
152
|
+
'target_invalid',
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Verifies and parses EngageLab OTP webhook callbacks.
|
|
157
|
+
*
|
|
158
|
+
* EngageLab signs each callback with header X-CALLBACK-ID:
|
|
159
|
+
* timestamp={ts};nonce={n};username={u};signature={sig}
|
|
160
|
+
* where signature = HMAC-SHA256(secret, `${ts}${nonce}${username}`).hexdigest()
|
|
161
|
+
*
|
|
162
|
+
* Source webhook IPs to whitelist: 119.8.170.74, 114.119.180.30
|
|
163
|
+
*/
|
|
164
|
+
class WebhookVerifier {
|
|
165
|
+
/**
|
|
166
|
+
* @param {object} opts
|
|
167
|
+
* @param {string} opts.username
|
|
168
|
+
* @param {string} opts.secret
|
|
169
|
+
* @param {number} [opts.toleranceSeconds=300]
|
|
170
|
+
*/
|
|
171
|
+
constructor({ username, secret, toleranceSeconds = 300 } = {}) {
|
|
172
|
+
if (!username || !secret) throw new Error('username and secret are required');
|
|
173
|
+
this._username = username;
|
|
174
|
+
this._secret = secret;
|
|
175
|
+
this._tol = toleranceSeconds;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Verify the X-CALLBACK-ID header. Throws WebhookSignatureError on failure.
|
|
180
|
+
*/
|
|
181
|
+
verify(headerValue, now = Math.floor(Date.now() / 1000)) {
|
|
182
|
+
if (!headerValue) throw new WebhookSignatureError('missing X-CALLBACK-ID header');
|
|
183
|
+
|
|
184
|
+
const parts = {};
|
|
185
|
+
for (const seg of headerValue.split(';')) {
|
|
186
|
+
const idx = seg.indexOf('=');
|
|
187
|
+
if (idx > 0) parts[seg.slice(0, idx).trim()] = seg.slice(idx + 1).trim();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const { timestamp, nonce, username, signature } = parts;
|
|
191
|
+
if (!timestamp || !nonce || !username || !signature) {
|
|
192
|
+
throw new WebhookSignatureError('malformed X-CALLBACK-ID header');
|
|
193
|
+
}
|
|
194
|
+
if (username !== this._username) {
|
|
195
|
+
throw new WebhookSignatureError('username mismatch');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const ts = parseInt(timestamp, 10);
|
|
199
|
+
if (!Number.isFinite(ts) || Math.abs(now - ts) > this._tol) {
|
|
200
|
+
throw new WebhookSignatureError('timestamp outside tolerance window');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const expected = crypto
|
|
204
|
+
.createHmac('sha256', this._secret)
|
|
205
|
+
.update(`${timestamp}${nonce}${username}`)
|
|
206
|
+
.digest('hex');
|
|
207
|
+
|
|
208
|
+
const a = Buffer.from(expected, 'hex');
|
|
209
|
+
const b = Buffer.from(signature, 'hex');
|
|
210
|
+
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
|
|
211
|
+
throw new WebhookSignatureError('signature mismatch');
|
|
212
|
+
}
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Parse a callback body into typed events.
|
|
218
|
+
*/
|
|
219
|
+
parseEvents(body) {
|
|
220
|
+
const obj = typeof body === 'string' ? JSON.parse(body) : body;
|
|
221
|
+
const rows = Array.isArray(obj.rows) ? obj.rows : [];
|
|
222
|
+
return rows.map(parseRow).filter(Boolean);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Express-style middleware factory.
|
|
227
|
+
* app.post('/webhook', verifier.middleware(handler))
|
|
228
|
+
*/
|
|
229
|
+
middleware(handler) {
|
|
230
|
+
return async (req, res) => {
|
|
231
|
+
try {
|
|
232
|
+
this.verify(req.headers['x-callback-id'] || req.headers['X-CALLBACK-ID']);
|
|
233
|
+
} catch (err) {
|
|
234
|
+
return res.status(401).end();
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
const events = this.parseEvents(req.body);
|
|
238
|
+
await handler(events, req);
|
|
239
|
+
} catch (err) {
|
|
240
|
+
// Always 200 on internal errors to avoid retry storms.
|
|
241
|
+
if (typeof console !== 'undefined') console.error('engagelab webhook handler error:', err);
|
|
242
|
+
}
|
|
243
|
+
res.status(200).end();
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ─── Internal: row → typed event ──────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
function parseRow(row) {
|
|
251
|
+
if (!row || typeof row !== 'object') return null;
|
|
252
|
+
|
|
253
|
+
if (row.status && typeof row.status.message_status === 'string') {
|
|
254
|
+
const s = row.status;
|
|
255
|
+
const sd = s.status_data || {};
|
|
256
|
+
const ed = s.error_detail || {};
|
|
257
|
+
return {
|
|
258
|
+
kind: 'message_status',
|
|
259
|
+
message_id: row.message_id,
|
|
260
|
+
to: row.to,
|
|
261
|
+
server: row.server,
|
|
262
|
+
channel: row.channel,
|
|
263
|
+
timestamp: row.itime,
|
|
264
|
+
custom_args: row.custom_args || {},
|
|
265
|
+
status: s.message_status,
|
|
266
|
+
is_terminal: TERMINAL_STATUSES.has(s.message_status),
|
|
267
|
+
current_send_channel: sd.current_send_channel,
|
|
268
|
+
template_key: sd.template_key,
|
|
269
|
+
business_id: sd.business_id,
|
|
270
|
+
msg_time: sd.msg_time,
|
|
271
|
+
billing: s.billing || null,
|
|
272
|
+
error_code: s.error_code || 0,
|
|
273
|
+
error_message: ed.message,
|
|
274
|
+
raw: row,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
if (row.notification) {
|
|
278
|
+
return {
|
|
279
|
+
kind: 'notification',
|
|
280
|
+
server: row.server,
|
|
281
|
+
timestamp: row.itime,
|
|
282
|
+
event: row.notification.event,
|
|
283
|
+
data: row.notification.notification_data,
|
|
284
|
+
raw: row,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
if (row.response) {
|
|
288
|
+
return {
|
|
289
|
+
kind: 'uplink',
|
|
290
|
+
server: row.server,
|
|
291
|
+
timestamp: row.itime,
|
|
292
|
+
message_id: row.message_id,
|
|
293
|
+
business_id: row.business_id,
|
|
294
|
+
event: row.response.event,
|
|
295
|
+
data: row.response.response_data,
|
|
296
|
+
raw: row,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
if (row.system_event) {
|
|
300
|
+
return {
|
|
301
|
+
kind: 'system_event',
|
|
302
|
+
server: row.server,
|
|
303
|
+
timestamp: row.itime,
|
|
304
|
+
event: row.system_event.event,
|
|
305
|
+
data: row.system_event.data,
|
|
306
|
+
raw: row,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
module.exports = {
|
|
313
|
+
OTPClient,
|
|
314
|
+
WebhookVerifier,
|
|
315
|
+
EngagelabError,
|
|
316
|
+
WebhookSignatureError,
|
|
317
|
+
TERMINAL_STATUSES,
|
|
318
|
+
};
|