@ramxvnn/bridge 0.1.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/LICENSE +21 -0
- package/README.md +176 -0
- package/dist/src/cli.d.ts +9 -0
- package/dist/src/cli.js +85 -0
- package/dist/src/client.d.ts +37 -0
- package/dist/src/client.js +36 -0
- package/dist/src/commands/doctor.d.ts +19 -0
- package/dist/src/commands/doctor.js +175 -0
- package/dist/src/commands/hermes.d.ts +33 -0
- package/dist/src/commands/hermes.js +197 -0
- package/dist/src/commands/init.d.ts +9 -0
- package/dist/src/commands/init.js +138 -0
- package/dist/src/commands/mcp.d.ts +34 -0
- package/dist/src/commands/mcp.js +210 -0
- package/dist/src/commands/pair.d.ts +7 -0
- package/dist/src/commands/pair.js +77 -0
- package/dist/src/commands/revoke.d.ts +10 -0
- package/dist/src/commands/revoke.js +62 -0
- package/dist/src/commands/run.d.ts +22 -0
- package/dist/src/commands/run.js +139 -0
- package/dist/src/index.d.ts +20 -0
- package/dist/src/index.js +29 -0
- package/dist/src/lib/bindings.d.ts +115 -0
- package/dist/src/lib/bindings.js +177 -0
- package/dist/src/lib/config.d.ts +80 -0
- package/dist/src/lib/config.js +174 -0
- package/dist/src/lib/connect-agent.d.ts +74 -0
- package/dist/src/lib/connect-agent.js +140 -0
- package/dist/src/lib/frameworks.d.ts +92 -0
- package/dist/src/lib/frameworks.js +155 -0
- package/dist/src/lib/hermes-config.d.ts +100 -0
- package/dist/src/lib/hermes-config.js +151 -0
- package/dist/src/lib/mcp-tools.d.ts +54 -0
- package/dist/src/lib/mcp-tools.js +133 -0
- package/dist/src/lib/pair-flow.d.ts +32 -0
- package/dist/src/lib/pair-flow.js +70 -0
- package/dist/src/lib/ramx.d.ts +205 -0
- package/dist/src/lib/ramx.js +212 -0
- package/dist/src/lib/trial.d.ts +40 -0
- package/dist/src/lib/trial.js +80 -0
- package/dist/src/lib/ui.d.ts +80 -0
- package/dist/src/lib/ui.js +176 -0
- package/package.json +69 -0
- package/runtime/VENDORED.md +4 -0
- package/runtime/core/commands.js +128 -0
- package/runtime/core/config.js +107 -0
- package/runtime/core/policy.js +56 -0
- package/runtime/core/ramx-client.js +110 -0
- package/runtime/core/redact.js +76 -0
- package/runtime/core/types.js +25 -0
- package/runtime/main.js +111 -0
- package/runtime/transports/discord/index.js +307 -0
- package/runtime/transports/line-official/index.js +137 -0
- package/runtime/transports/shared/webhook-server.js +101 -0
- package/runtime/transports/telegram/index.js +150 -0
- package/runtime/transports/zalo-oa/index.js +192 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal HTTP server shared by the webhook transports (Zalo OA, LINE).
|
|
3
|
+
*
|
|
4
|
+
* Telegram and Discord reach out; Zalo OA and LINE push in. Those two need an
|
|
5
|
+
* HTTP endpoint on the operator's own infrastructure — **not** on RAM/X. That
|
|
6
|
+
* distinction matters enough to repeat in every README: the public callback
|
|
7
|
+
* URL a platform console asks for points at the machine running this runtime.
|
|
8
|
+
*
|
|
9
|
+
* Two details are load-bearing:
|
|
10
|
+
*
|
|
11
|
+
* 1. The RAW body is captured and handed to the verifier untouched. Both
|
|
12
|
+
* platforms sign the exact bytes they sent; re-serializing parsed JSON
|
|
13
|
+
* changes whitespace and breaks verification (LINE's docs are explicit
|
|
14
|
+
* that any modification makes a legitimate request indistinguishable from
|
|
15
|
+
* a tampered one).
|
|
16
|
+
* 2. Verification happens BEFORE parsing. An unverified body is attacker
|
|
17
|
+
* input, so nothing touches it until the signature holds.
|
|
18
|
+
*/
|
|
19
|
+
import { createServer } from 'node:http';
|
|
20
|
+
import { log, redact } from '../../core/redact.js';
|
|
21
|
+
/** Reads the request body as raw text, refusing anything oversized. */
|
|
22
|
+
export function readRawBody(req, maxBytes) {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const chunks = [];
|
|
25
|
+
let total = 0;
|
|
26
|
+
req.on('data', (chunk) => {
|
|
27
|
+
total += chunk.length;
|
|
28
|
+
if (total > maxBytes) {
|
|
29
|
+
reject(new Error('Request body too large'));
|
|
30
|
+
req.destroy();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
chunks.push(chunk);
|
|
34
|
+
});
|
|
35
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
36
|
+
req.on('error', reject);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
export class WebhookServer {
|
|
40
|
+
server;
|
|
41
|
+
options;
|
|
42
|
+
constructor(options) {
|
|
43
|
+
this.options = { maxBodyBytes: 1024 * 1024, ...options };
|
|
44
|
+
}
|
|
45
|
+
async start() {
|
|
46
|
+
const { port, path, handler, maxBodyBytes } = this.options;
|
|
47
|
+
this.server = createServer((req, res) => {
|
|
48
|
+
void (async () => {
|
|
49
|
+
try {
|
|
50
|
+
// A health path is genuinely useful when this runs behind a proxy.
|
|
51
|
+
if (req.method === 'GET' && req.url === '/healthz') {
|
|
52
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
53
|
+
res.end('ok');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const url = (req.url ?? '').split('?')[0];
|
|
57
|
+
if (req.method !== 'POST' || url !== path) {
|
|
58
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
59
|
+
res.end('not found');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const rawBody = await readRawBody(req, maxBodyBytes);
|
|
63
|
+
const headers = {};
|
|
64
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
65
|
+
if (typeof v === 'string')
|
|
66
|
+
headers[k.toLowerCase()] = v;
|
|
67
|
+
}
|
|
68
|
+
const result = await handler({ rawBody, headers });
|
|
69
|
+
res.writeHead(result.status, { 'Content-Type': 'text/plain' });
|
|
70
|
+
res.end(result.body ?? '');
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
// Never echo the error to the caller: it can carry request detail.
|
|
74
|
+
log('error', 'Webhook request failed', { error: redact(err) });
|
|
75
|
+
if (!res.headersSent) {
|
|
76
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
77
|
+
res.end('error');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
})();
|
|
81
|
+
});
|
|
82
|
+
await new Promise((resolve, reject) => {
|
|
83
|
+
this.server.once('error', reject);
|
|
84
|
+
this.server.listen(port, () => {
|
|
85
|
+
log('info', 'Webhook server listening', { port, path });
|
|
86
|
+
resolve();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/** The bound port. Differs from the configured one when port 0 was used. */
|
|
91
|
+
address() {
|
|
92
|
+
const addr = this.server?.address();
|
|
93
|
+
return addr && typeof addr === 'object' ? addr.port : null;
|
|
94
|
+
}
|
|
95
|
+
async stop() {
|
|
96
|
+
if (!this.server)
|
|
97
|
+
return;
|
|
98
|
+
await new Promise((resolve) => this.server.close(() => resolve()));
|
|
99
|
+
this.server = undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram transport — long polling via getUpdates.
|
|
3
|
+
*
|
|
4
|
+
* WHY LONG POLLING AND NOT A WEBHOOK
|
|
5
|
+
* ----------------------------------
|
|
6
|
+
* Telegram's two delivery mechanisms are mutually exclusive: setting a webhook
|
|
7
|
+
* makes getUpdates fail, and vice versa. For a reference runtime somebody
|
|
8
|
+
* clones and runs in five minutes, long polling wins on every axis that
|
|
9
|
+
* matters — no public HTTPS endpoint, no reverse proxy, no certificate, no
|
|
10
|
+
* inbound firewall rule — so it behaves identically on a laptop, in Docker and
|
|
11
|
+
* on a VPS. (Contrast the Zalo OA and LINE adapters, which have no choice:
|
|
12
|
+
* those platforms only deliver by webhook.)
|
|
13
|
+
*
|
|
14
|
+
* Nothing here knows RAM/X exists.
|
|
15
|
+
*/
|
|
16
|
+
import { log, registerSecret, redact } from '../../core/redact.js';
|
|
17
|
+
import { defaultSleep, } from '../../core/types.js';
|
|
18
|
+
/**
|
|
19
|
+
* Advances the polling offset.
|
|
20
|
+
*
|
|
21
|
+
* Telegram redelivers any update whose id is >= the offset we ask for, so the
|
|
22
|
+
* next offset must be (highest id seen + 1). Getting this wrong is how a
|
|
23
|
+
* runtime double-posts after a reconnect, which is exactly the failure mode
|
|
24
|
+
* `/ramx_post` must not have.
|
|
25
|
+
*/
|
|
26
|
+
export function nextOffset(currentOffset, updates) {
|
|
27
|
+
let highest = currentOffset - 1;
|
|
28
|
+
for (const u of updates) {
|
|
29
|
+
if (typeof u.update_id === 'number' && u.update_id > highest)
|
|
30
|
+
highest = u.update_id;
|
|
31
|
+
}
|
|
32
|
+
return highest + 1;
|
|
33
|
+
}
|
|
34
|
+
/** Exponential backoff with a ceiling, so an outage cannot hot-loop. */
|
|
35
|
+
export function backoffMs(consecutiveFailures, baseMs = 1000, maxMs = 60_000) {
|
|
36
|
+
return Math.min(baseMs * 2 ** Math.max(0, consecutiveFailures - 1), maxMs);
|
|
37
|
+
}
|
|
38
|
+
export class TelegramTransport {
|
|
39
|
+
name = 'telegram';
|
|
40
|
+
botToken;
|
|
41
|
+
apiBase;
|
|
42
|
+
pollTimeoutSeconds;
|
|
43
|
+
fetchImpl;
|
|
44
|
+
sleep;
|
|
45
|
+
running = false;
|
|
46
|
+
/** Bounded in tests so the loop terminates; unset means run until stopped. */
|
|
47
|
+
maxCycles;
|
|
48
|
+
constructor(config, deps = {}) {
|
|
49
|
+
this.botToken = config.botToken;
|
|
50
|
+
this.apiBase = config.apiBase.replace(/\/+$/, '');
|
|
51
|
+
this.pollTimeoutSeconds = config.pollTimeoutSeconds;
|
|
52
|
+
this.fetchImpl = deps.fetchImpl ?? fetch;
|
|
53
|
+
this.sleep = deps.sleep ?? defaultSleep;
|
|
54
|
+
this.maxCycles = deps.maxCycles;
|
|
55
|
+
registerSecret(this.botToken);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The bot token sits in the URL path, so this string is a secret. It is
|
|
59
|
+
* never logged: every log path here goes through redact().
|
|
60
|
+
*/
|
|
61
|
+
url(method) {
|
|
62
|
+
return `${this.apiBase}/bot${this.botToken}/${method}`;
|
|
63
|
+
}
|
|
64
|
+
async call(method, body) {
|
|
65
|
+
const res = await this.fetchImpl(this.url(method), {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: { 'Content-Type': 'application/json' },
|
|
68
|
+
body: JSON.stringify(body),
|
|
69
|
+
// Longer than Telegram's own long-poll window.
|
|
70
|
+
signal: AbortSignal.timeout((this.pollTimeoutSeconds + 15) * 1000),
|
|
71
|
+
});
|
|
72
|
+
const payload = (await res.json().catch(() => ({})));
|
|
73
|
+
if (!payload.ok) {
|
|
74
|
+
const err = new Error(`Telegram ${method} failed: ${payload.description ?? `HTTP ${res.status}`}`);
|
|
75
|
+
err.retryAfterSeconds = payload.parameters?.retry_after;
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
78
|
+
return payload.result;
|
|
79
|
+
}
|
|
80
|
+
async getUpdates(offset) {
|
|
81
|
+
return this.call('getUpdates', {
|
|
82
|
+
offset,
|
|
83
|
+
timeout: this.pollTimeoutSeconds,
|
|
84
|
+
allowed_updates: ['message'],
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
async sendMessage(chatId, text) {
|
|
88
|
+
// Telegram rejects messages over 4096 characters outright.
|
|
89
|
+
const safe = text.length > 4000 ? `${text.slice(0, 3990)}\n…(truncated)` : text;
|
|
90
|
+
await this.call('sendMessage', {
|
|
91
|
+
chat_id: chatId,
|
|
92
|
+
text: safe,
|
|
93
|
+
disable_web_page_preview: true,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
async getMyUsername() {
|
|
97
|
+
const me = await this.call('getMe', {});
|
|
98
|
+
return me.username ?? null;
|
|
99
|
+
}
|
|
100
|
+
async start(handler) {
|
|
101
|
+
this.running = true;
|
|
102
|
+
let offset = 0;
|
|
103
|
+
let consecutiveFailures = 0;
|
|
104
|
+
let cycles = 0;
|
|
105
|
+
while (this.running && (this.maxCycles === undefined || cycles < this.maxCycles)) {
|
|
106
|
+
cycles += 1;
|
|
107
|
+
try {
|
|
108
|
+
const updates = await this.getUpdates(offset);
|
|
109
|
+
consecutiveFailures = 0;
|
|
110
|
+
for (const update of updates) {
|
|
111
|
+
const message = update.message;
|
|
112
|
+
if (!message?.text)
|
|
113
|
+
continue;
|
|
114
|
+
try {
|
|
115
|
+
await handler({
|
|
116
|
+
transport: this.name,
|
|
117
|
+
text: message.text,
|
|
118
|
+
actorRef: `chat:${message.chat.id}`,
|
|
119
|
+
respond: (text) => this.sendMessage(message.chat.id, text),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
// One bad update must not stall the loop or block the offset.
|
|
124
|
+
log('error', 'Failed to handle update', {
|
|
125
|
+
update_id: update.update_id,
|
|
126
|
+
error: redact(err),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// Only after the whole batch is processed. A crash mid-batch
|
|
131
|
+
// redelivers rather than silently skipping.
|
|
132
|
+
offset = nextOffset(offset, updates);
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
consecutiveFailures += 1;
|
|
136
|
+
const hinted = err.retryAfterSeconds;
|
|
137
|
+
const waitMs = hinted ? hinted * 1000 : backoffMs(consecutiveFailures);
|
|
138
|
+
log('warn', 'Telegram poll failed; backing off', {
|
|
139
|
+
attempt: consecutiveFailures,
|
|
140
|
+
waitMs,
|
|
141
|
+
error: redact(err),
|
|
142
|
+
});
|
|
143
|
+
await this.sleep(waitMs);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
async stop() {
|
|
148
|
+
this.running = false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zalo Official Account transport — signed webhook.
|
|
3
|
+
*
|
|
4
|
+
* ⚠ VERIFICATION STATUS — READ BEFORE PRODUCTION USE
|
|
5
|
+
* ---------------------------------------------------
|
|
6
|
+
* Unlike the LINE adapter, the signature scheme below could NOT be confirmed
|
|
7
|
+
* against Zalo's official API reference: developers.zalo.me renders its docs
|
|
8
|
+
* client-side and does not serve them to a plain fetch. What is implemented
|
|
9
|
+
* here comes from Zalo's own developer community threads, which consistently
|
|
10
|
+
* describe:
|
|
11
|
+
*
|
|
12
|
+
* header X-ZEvent-Signature
|
|
13
|
+
* value "mac=<hex>"
|
|
14
|
+
* algorithm SHA-256 (a plain hash of concatenated values, NOT an HMAC)
|
|
15
|
+
* data appId + <body> + timestamp + OA secret key
|
|
16
|
+
*
|
|
17
|
+
* THREE variants appear across those sources, and none is authoritative:
|
|
18
|
+
*
|
|
19
|
+
* A. sha256(appId + body + timestamp + OASecretKey) <- default
|
|
20
|
+
* B. sha256(oaId + body + timestamp + OASecretKey)
|
|
21
|
+
* C. sha256( body + timestamp + OASecretKey) <- no id prefix
|
|
22
|
+
*
|
|
23
|
+
* plus a second axis: whether `body` is the RAW request bytes or
|
|
24
|
+
* `JSON.stringify()` of the parsed body.
|
|
25
|
+
*
|
|
26
|
+
* All of it is configurable rather than guessed:
|
|
27
|
+
*
|
|
28
|
+
* ZALO_SIGNATURE_ID=<oa id> -> variant B
|
|
29
|
+
* ZALO_SIGNATURE_ID=none -> variant C
|
|
30
|
+
* ZALO_SIGNATURE_BODY=canonical -> re-serialized JSON instead of raw
|
|
31
|
+
*
|
|
32
|
+
* Raw is the default because it is the only form that is actually
|
|
33
|
+
* well-defined; re-serializing changes whitespace and is not byte-stable.
|
|
34
|
+
*
|
|
35
|
+
* A wrong choice fails CLOSED — every legitimate webhook is rejected — so this
|
|
36
|
+
* cannot silently accept forgeries. But it does mean the adapter must be
|
|
37
|
+
* validated against a real OA before anyone relies on it. See the README.
|
|
38
|
+
*
|
|
39
|
+
* The callback URL configured in the Zalo App console points at the machine
|
|
40
|
+
* running THIS process. It is not a RAM/X endpoint, and RAM/X never sees the
|
|
41
|
+
* OA secret key or access token.
|
|
42
|
+
*/
|
|
43
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
44
|
+
import { log, registerSecret, redact } from '../../core/redact.js';
|
|
45
|
+
import { WebhookServer } from '../shared/webhook-server.js';
|
|
46
|
+
/** Text-bearing user events. Delivery receipts and the like are ignored. */
|
|
47
|
+
export const ZALO_TEXT_EVENTS = ['user_send_text'];
|
|
48
|
+
/**
|
|
49
|
+
* Computes the expected MAC. Exported so the tests can pin the exact
|
|
50
|
+
* concatenation order rather than asserting only pass/fail.
|
|
51
|
+
*/
|
|
52
|
+
export function computeZaloMac(signatureId, body, timestamp, oaSecretKey) {
|
|
53
|
+
return createHash('sha256')
|
|
54
|
+
.update(`${signatureId}${body}${timestamp}${oaSecretKey}`, 'utf8')
|
|
55
|
+
.digest('hex');
|
|
56
|
+
}
|
|
57
|
+
export function verifyZaloSignature(input) {
|
|
58
|
+
const { rawBody, signatureId, oaSecretKey, received } = input;
|
|
59
|
+
if (!received)
|
|
60
|
+
return false;
|
|
61
|
+
// The header is documented as "mac=<hex>"; tolerate a bare hex value too.
|
|
62
|
+
const presented = received.startsWith('mac=') ? received.slice(4) : received;
|
|
63
|
+
if (!/^[0-9a-f]{64}$/i.test(presented))
|
|
64
|
+
return false;
|
|
65
|
+
let parsed;
|
|
66
|
+
try {
|
|
67
|
+
parsed = JSON.parse(rawBody);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
// The timestamp is taken from the signed body itself, so it cannot be
|
|
73
|
+
// swapped independently of the signature.
|
|
74
|
+
const timestamp = parsed.timestamp === undefined ? '' : String(parsed.timestamp);
|
|
75
|
+
if (!timestamp)
|
|
76
|
+
return false;
|
|
77
|
+
const body = input.bodyMode === 'canonical' ? JSON.stringify(parsed) : rawBody;
|
|
78
|
+
const expected = computeZaloMac(signatureId, body, timestamp, oaSecretKey);
|
|
79
|
+
const a = Buffer.from(expected, 'utf8');
|
|
80
|
+
const b = Buffer.from(presented.toLowerCase(), 'utf8');
|
|
81
|
+
if (a.length !== b.length)
|
|
82
|
+
return false;
|
|
83
|
+
return timingSafeEqual(a, b);
|
|
84
|
+
}
|
|
85
|
+
export function isZaloTextEvent(event) {
|
|
86
|
+
return (typeof event.event_name === 'string' &&
|
|
87
|
+
ZALO_TEXT_EVENTS.includes(event.event_name) &&
|
|
88
|
+
typeof event.message?.text === 'string' &&
|
|
89
|
+
event.message.text.length > 0);
|
|
90
|
+
}
|
|
91
|
+
export class ZaloOaTransport {
|
|
92
|
+
name = 'zalo_oa';
|
|
93
|
+
config;
|
|
94
|
+
signatureId;
|
|
95
|
+
bodyMode;
|
|
96
|
+
fetchImpl;
|
|
97
|
+
server;
|
|
98
|
+
resolveStopped;
|
|
99
|
+
constructor(config, deps = {}) {
|
|
100
|
+
this.config = config;
|
|
101
|
+
// 'none' selects variant C (no id prefix). An empty string would be
|
|
102
|
+
// ambiguous with "unset", so the sentinel is explicit.
|
|
103
|
+
this.signatureId =
|
|
104
|
+
deps.signatureId === 'none' ? '' : (deps.signatureId ?? config.appId);
|
|
105
|
+
this.bodyMode = deps.bodyMode ?? 'raw';
|
|
106
|
+
this.fetchImpl = deps.fetchImpl ?? fetch;
|
|
107
|
+
registerSecret(config.oaSecretKey);
|
|
108
|
+
registerSecret(config.accessToken);
|
|
109
|
+
}
|
|
110
|
+
/** Sends a message to a user. The access token goes in a header, never a URL. */
|
|
111
|
+
async sendText(userId, text) {
|
|
112
|
+
const res = await this.fetchImpl(`${this.config.apiBase.replace(/\/+$/, '')}/message/cs`, {
|
|
113
|
+
method: 'POST',
|
|
114
|
+
headers: {
|
|
115
|
+
access_token: this.config.accessToken,
|
|
116
|
+
'Content-Type': 'application/json',
|
|
117
|
+
},
|
|
118
|
+
body: JSON.stringify({ recipient: { user_id: userId }, message: { text } }),
|
|
119
|
+
signal: AbortSignal.timeout(15_000),
|
|
120
|
+
});
|
|
121
|
+
if (!res.ok) {
|
|
122
|
+
throw new Error(`Zalo OA send failed with HTTP ${res.status}`);
|
|
123
|
+
}
|
|
124
|
+
// Zalo reports application errors in the body with HTTP 200.
|
|
125
|
+
const payload = (await res.json().catch(() => ({})));
|
|
126
|
+
if (typeof payload.error === 'number' && payload.error !== 0) {
|
|
127
|
+
throw new Error(`Zalo OA send returned error ${payload.error}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Processes one webhook delivery. Exposed for tests. */
|
|
131
|
+
async handleWebhook(rawBody, headers, handler) {
|
|
132
|
+
const verified = verifyZaloSignature({
|
|
133
|
+
rawBody,
|
|
134
|
+
signatureId: this.signatureId,
|
|
135
|
+
oaSecretKey: this.config.oaSecretKey,
|
|
136
|
+
received: headers['x-zevent-signature'],
|
|
137
|
+
bodyMode: this.bodyMode,
|
|
138
|
+
});
|
|
139
|
+
if (!verified) {
|
|
140
|
+
log('warn', 'Rejected Zalo OA webhook with an invalid signature');
|
|
141
|
+
return { status: 401, body: 'invalid signature' };
|
|
142
|
+
}
|
|
143
|
+
let event;
|
|
144
|
+
try {
|
|
145
|
+
event = JSON.parse(rawBody);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return { status: 400, body: 'invalid json' };
|
|
149
|
+
}
|
|
150
|
+
if (!isZaloTextEvent(event)) {
|
|
151
|
+
// Not an error — Zalo sends many event types this runtime ignores.
|
|
152
|
+
return { status: 200, body: 'ignored' };
|
|
153
|
+
}
|
|
154
|
+
const senderId = event.sender?.id;
|
|
155
|
+
try {
|
|
156
|
+
await handler({
|
|
157
|
+
transport: this.name,
|
|
158
|
+
text: event.message.text,
|
|
159
|
+
actorRef: `zalo:${senderId ?? 'unknown'}`,
|
|
160
|
+
respond: async (text) => {
|
|
161
|
+
if (!senderId)
|
|
162
|
+
return;
|
|
163
|
+
await this.sendText(senderId, text);
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
log('error', 'Failed to handle Zalo OA event', { error: redact(err) });
|
|
169
|
+
}
|
|
170
|
+
return { status: 200, body: 'ok' };
|
|
171
|
+
}
|
|
172
|
+
async start(handler) {
|
|
173
|
+
this.server = new WebhookServer({
|
|
174
|
+
port: this.config.webhookPort,
|
|
175
|
+
path: this.config.webhookPath,
|
|
176
|
+
handler: (req) => this.handleWebhook(req.rawBody, req.headers, handler),
|
|
177
|
+
});
|
|
178
|
+
await this.server.start();
|
|
179
|
+
log('info', 'Zalo OA transport ready (signed webhook)', {
|
|
180
|
+
path: this.config.webhookPath,
|
|
181
|
+
port: this.config.webhookPort,
|
|
182
|
+
signatureBodyMode: this.bodyMode,
|
|
183
|
+
});
|
|
184
|
+
await new Promise((resolve) => {
|
|
185
|
+
this.resolveStopped = resolve;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
async stop() {
|
|
189
|
+
await this.server?.stop();
|
|
190
|
+
this.resolveStopped?.();
|
|
191
|
+
}
|
|
192
|
+
}
|