abs-zalo-bot 0.2.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/.env.example +48 -0
- package/LICENSE +21 -0
- package/README.md +435 -0
- package/SECURITY.md +22 -0
- package/config/bots.example.json +33 -0
- package/config.toml +48 -0
- package/mcp/run.sh +7 -0
- package/mcp/server.js +255 -0
- package/package.json +76 -0
- package/scripts/battle-smoke.js +118 -0
- package/scripts/corpus-stats.js +23 -0
- package/scripts/probe-history.js +76 -0
- package/scripts/public-gate.js +216 -0
- package/scripts/setup.js +233 -0
- package/src/abs_telemetry.js +60 -0
- package/src/ask.js +7 -0
- package/src/backfill.js +364 -0
- package/src/bot_registry.js +180 -0
- package/src/brand.js +31 -0
- package/src/cli.js +268 -0
- package/src/commands.js +265 -0
- package/src/config.js +223 -0
- package/src/digest.js +330 -0
- package/src/discovery.js +185 -0
- package/src/hermes_client.js +302 -0
- package/src/inbound_router.js +295 -0
- package/src/keepalive.js +375 -0
- package/src/oa_adapter.js +284 -0
- package/src/oa_auto_reply.js +75 -0
- package/src/oa_policy.js +32 -0
- package/src/oa_webhook.js +95 -0
- package/src/onboarding.js +102 -0
- package/src/ops_report.js +135 -0
- package/src/policy.js +320 -0
- package/src/privacy.js +38 -0
- package/src/schema.js +163 -0
- package/src/server.js +604 -0
- package/src/store.js +972 -0
- package/src/zalo_runtime.js +480 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_OA_TOKEN_URL = "https://oauth.zaloapp.com/v4/oa/access_token";
|
|
4
|
+
export const DEFAULT_OA_MESSAGE_URL = "https://openapi.zalo.me/v3.0/oa/message/cs";
|
|
5
|
+
const TOKEN_SKEW_MS = 60_000;
|
|
6
|
+
const MAX_TEXT_LENGTH = 3_500;
|
|
7
|
+
|
|
8
|
+
function stableHash(parts) {
|
|
9
|
+
return crypto
|
|
10
|
+
.createHash("sha256")
|
|
11
|
+
.update(parts.filter((part) => part !== undefined && part !== null).map(String).join("|"), "utf8")
|
|
12
|
+
.digest("hex");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function asNonEmptyString(value) {
|
|
16
|
+
const text = String(value ?? "").trim();
|
|
17
|
+
return text || "";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function retryableStatus(status) {
|
|
21
|
+
return status === 408 || status === 425 || status === 429 || status >= 500;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function safeStatus(response) {
|
|
25
|
+
return Number.isFinite(Number(response?.status)) ? Number(response.status) : 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function readJson(response) {
|
|
29
|
+
try {
|
|
30
|
+
return await response.json();
|
|
31
|
+
} catch {
|
|
32
|
+
try {
|
|
33
|
+
const text = await response.text();
|
|
34
|
+
return text ? JSON.parse(text) : {};
|
|
35
|
+
} catch {
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class OaProviderError extends Error {
|
|
42
|
+
constructor(code, status = 0, retryable = false) {
|
|
43
|
+
super(code);
|
|
44
|
+
// Keep operational metadata non-enumerable so an accidental JSON response
|
|
45
|
+
// cannot expose provider payloads or credentials.
|
|
46
|
+
Object.defineProperties(this, {
|
|
47
|
+
name: { value: "OaProviderError", enumerable: false, configurable: true },
|
|
48
|
+
code: { value: code, enumerable: false },
|
|
49
|
+
status: { value: status, enumerable: false },
|
|
50
|
+
retryable: { value: Boolean(retryable), enumerable: false },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class OaAdapter {
|
|
56
|
+
constructor({
|
|
57
|
+
botId,
|
|
58
|
+
tenantId,
|
|
59
|
+
appId,
|
|
60
|
+
appSecret,
|
|
61
|
+
refreshToken,
|
|
62
|
+
tokenUrl = DEFAULT_OA_TOKEN_URL,
|
|
63
|
+
messageUrl = DEFAULT_OA_MESSAGE_URL,
|
|
64
|
+
fetchImpl = globalThis.fetch,
|
|
65
|
+
now = () => Date.now(),
|
|
66
|
+
tokenSkewMs = TOKEN_SKEW_MS,
|
|
67
|
+
} = {}) {
|
|
68
|
+
this.botId = asNonEmptyString(botId);
|
|
69
|
+
this.tenantId = asNonEmptyString(tenantId);
|
|
70
|
+
this.appId = asNonEmptyString(appId);
|
|
71
|
+
this.appSecret = asNonEmptyString(appSecret);
|
|
72
|
+
this.refreshToken = asNonEmptyString(refreshToken);
|
|
73
|
+
this.tokenUrl = asNonEmptyString(tokenUrl) || DEFAULT_OA_TOKEN_URL;
|
|
74
|
+
this.messageUrl = asNonEmptyString(messageUrl) || DEFAULT_OA_MESSAGE_URL;
|
|
75
|
+
this.fetchImpl = fetchImpl;
|
|
76
|
+
this.now = now;
|
|
77
|
+
this.tokenSkewMs = Math.max(0, Number(tokenSkewMs) || TOKEN_SKEW_MS);
|
|
78
|
+
this.cachedToken = null;
|
|
79
|
+
this.rotatedRefreshToken = null;
|
|
80
|
+
this.state = "disconnected";
|
|
81
|
+
this.lastErrorCode = "";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
endpoints() {
|
|
85
|
+
return { token_url: this.tokenUrl, message_url: this.messageUrl };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
status() {
|
|
89
|
+
const result = {
|
|
90
|
+
bot_id: this.botId,
|
|
91
|
+
tenant_id: this.tenantId,
|
|
92
|
+
adapter: "zalo_oa",
|
|
93
|
+
state: this.state,
|
|
94
|
+
ready: Boolean(this.cachedToken && this.cachedToken.expiresAt > this.now()),
|
|
95
|
+
};
|
|
96
|
+
if (this.lastErrorCode) result.last_error = this.lastErrorCode;
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
getRotatedRefreshToken() {
|
|
101
|
+
return this.rotatedRefreshToken;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
_validateCredentials() {
|
|
105
|
+
if (!this.botId || !this.tenantId || !this.appId || !this.appSecret || !this.refreshToken) {
|
|
106
|
+
throw new OaProviderError("credential_missing", 0, false);
|
|
107
|
+
}
|
|
108
|
+
if (typeof this.fetchImpl !== "function") {
|
|
109
|
+
throw new OaProviderError("config_invalid", 0, false);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
_cachedTokenIsFresh() {
|
|
114
|
+
return Boolean(
|
|
115
|
+
this.cachedToken &&
|
|
116
|
+
Number.isFinite(this.cachedToken.expiresAt) &&
|
|
117
|
+
this.cachedToken.expiresAt - this.now() > this.tokenSkewMs,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async getAccessToken() {
|
|
122
|
+
if (this._cachedTokenIsFresh()) return this.cachedToken.accessToken;
|
|
123
|
+
this._validateCredentials();
|
|
124
|
+
|
|
125
|
+
const form = new URLSearchParams({
|
|
126
|
+
app_id: this.appId,
|
|
127
|
+
grant_type: "refresh_token",
|
|
128
|
+
refresh_token: this.refreshToken,
|
|
129
|
+
secret_key: this.appSecret,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
let response;
|
|
133
|
+
try {
|
|
134
|
+
response = await this.fetchImpl(this.tokenUrl, {
|
|
135
|
+
method: "POST",
|
|
136
|
+
headers: {
|
|
137
|
+
accept: "application/json",
|
|
138
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
139
|
+
},
|
|
140
|
+
body: form.toString(),
|
|
141
|
+
});
|
|
142
|
+
} catch {
|
|
143
|
+
this.state = "error";
|
|
144
|
+
this.lastErrorCode = "token_refresh_failed";
|
|
145
|
+
throw new OaProviderError("token_refresh_failed", 0, true);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const status = safeStatus(response);
|
|
149
|
+
const body = await readJson(response);
|
|
150
|
+
const accessToken = asNonEmptyString(body?.access_token);
|
|
151
|
+
const expiresIn = Number(body?.expires_in);
|
|
152
|
+
if (!response?.ok || !accessToken || !Number.isFinite(expiresIn) || expiresIn <= 0) {
|
|
153
|
+
this.state = "error";
|
|
154
|
+
this.lastErrorCode = "token_refresh_failed";
|
|
155
|
+
throw new OaProviderError("token_refresh_failed", status, retryableStatus(status));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
this.cachedToken = {
|
|
159
|
+
accessToken,
|
|
160
|
+
expiresAt: this.now() + expiresIn * 1_000,
|
|
161
|
+
};
|
|
162
|
+
const rotated = asNonEmptyString(body?.refresh_token);
|
|
163
|
+
if (rotated) this.rotatedRefreshToken = rotated;
|
|
164
|
+
this.state = "connected";
|
|
165
|
+
this.lastErrorCode = "";
|
|
166
|
+
return accessToken;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async sendText({ recipient_id: recipientId, text, correlation_id: correlationId = "" } = {}) {
|
|
170
|
+
const recipient = asNonEmptyString(recipientId);
|
|
171
|
+
const message = String(text ?? "");
|
|
172
|
+
if (!recipient) throw new Error("recipient_required");
|
|
173
|
+
if (!message.trim()) throw new Error("text_required");
|
|
174
|
+
if (message.length > MAX_TEXT_LENGTH) throw new Error("text_too_long");
|
|
175
|
+
|
|
176
|
+
const accessToken = await this.getAccessToken();
|
|
177
|
+
let response;
|
|
178
|
+
try {
|
|
179
|
+
response = await this.fetchImpl(this.messageUrl, {
|
|
180
|
+
method: "POST",
|
|
181
|
+
headers: {
|
|
182
|
+
accept: "application/json",
|
|
183
|
+
"content-type": "application/json",
|
|
184
|
+
access_token: accessToken,
|
|
185
|
+
},
|
|
186
|
+
body: JSON.stringify({
|
|
187
|
+
recipient: { user_id: recipient },
|
|
188
|
+
message: { text: message },
|
|
189
|
+
}),
|
|
190
|
+
});
|
|
191
|
+
} catch {
|
|
192
|
+
this.state = "error";
|
|
193
|
+
this.lastErrorCode = "provider_error";
|
|
194
|
+
throw new OaProviderError("provider_error", 0, true);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const status = safeStatus(response);
|
|
198
|
+
const body = await readJson(response);
|
|
199
|
+
if (!response?.ok) {
|
|
200
|
+
this.state = "error";
|
|
201
|
+
this.lastErrorCode = "provider_error";
|
|
202
|
+
throw new OaProviderError("provider_error", status, retryableStatus(status));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
this.state = "connected";
|
|
206
|
+
this.lastErrorCode = "";
|
|
207
|
+
return {
|
|
208
|
+
ok: true,
|
|
209
|
+
adapter: "zalo_oa",
|
|
210
|
+
bot_id: this.botId,
|
|
211
|
+
tenant_id: this.tenantId,
|
|
212
|
+
recipient_id: recipient,
|
|
213
|
+
provider_message_id: asNonEmptyString(
|
|
214
|
+
body?.message_id ?? body?.data?.message_id ?? body?.message?.message_id,
|
|
215
|
+
) || null,
|
|
216
|
+
correlation_id: asNonEmptyString(correlationId) || null,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function parseTimestamp(value) {
|
|
222
|
+
const numeric = Number(value);
|
|
223
|
+
if (Number.isFinite(numeric) && numeric > 0) {
|
|
224
|
+
const milliseconds = numeric < 10_000_000_000 ? numeric * 1_000 : numeric;
|
|
225
|
+
return new Date(milliseconds).toISOString();
|
|
226
|
+
}
|
|
227
|
+
const parsed = Date.parse(String(value ?? ""));
|
|
228
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : new Date(0).toISOString();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Normalize only text events. This function is deliberately pure: it never
|
|
233
|
+
* calls an AI provider or sends a reply.
|
|
234
|
+
*/
|
|
235
|
+
export function normalizeOaWebhookEvent({ botId, tenantId, payload } = {}) {
|
|
236
|
+
const configuredBotId = asNonEmptyString(botId);
|
|
237
|
+
const configuredTenantId = asNonEmptyString(tenantId);
|
|
238
|
+
if (!configuredBotId || !configuredTenantId || !payload || typeof payload !== "object") return null;
|
|
239
|
+
|
|
240
|
+
const eventType = asNonEmptyString(payload.event_name ?? payload.event ?? payload.event_type);
|
|
241
|
+
if (eventType !== "user_send_text") return null;
|
|
242
|
+
|
|
243
|
+
const providerBotId = asNonEmptyString(payload.bot_id ?? payload.oa_id ?? payload.recipient?.id);
|
|
244
|
+
if (providerBotId && providerBotId !== configuredBotId && payload.bot_id) return null;
|
|
245
|
+
|
|
246
|
+
const message = payload.message && typeof payload.message === "object" ? payload.message : {};
|
|
247
|
+
const sender = payload.sender && typeof payload.sender === "object" ? payload.sender : {};
|
|
248
|
+
const senderId = asNonEmptyString(
|
|
249
|
+
sender.id ?? sender.user_id ?? message.from_id ?? message.fromId ?? payload.from_id,
|
|
250
|
+
);
|
|
251
|
+
const text = String(message.text ?? message.content ?? payload.text ?? "");
|
|
252
|
+
if (!senderId || !text.trim()) return null;
|
|
253
|
+
|
|
254
|
+
const providerMessageId = asNonEmptyString(
|
|
255
|
+
message.message_id ?? message.msg_id ?? payload.message_id,
|
|
256
|
+
);
|
|
257
|
+
const receivedAt = parseTimestamp(payload.timestamp ?? payload.time ?? message.timestamp);
|
|
258
|
+
const eventId = stableHash([
|
|
259
|
+
configuredBotId,
|
|
260
|
+
configuredTenantId,
|
|
261
|
+
eventType,
|
|
262
|
+
providerMessageId,
|
|
263
|
+
senderId,
|
|
264
|
+
receivedAt,
|
|
265
|
+
text,
|
|
266
|
+
]);
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
event_id: eventId,
|
|
270
|
+
bot_id: configuredBotId,
|
|
271
|
+
tenant_id: configuredTenantId,
|
|
272
|
+
adapter: "zalo_oa",
|
|
273
|
+
event_type: eventType,
|
|
274
|
+
sender_id: senderId,
|
|
275
|
+
sender_name: asNonEmptyString(sender.name ?? sender.display_name ?? message.sender_name) || "",
|
|
276
|
+
text,
|
|
277
|
+
is_self: false,
|
|
278
|
+
received_at: receivedAt,
|
|
279
|
+
raw_metadata: {
|
|
280
|
+
provider_message_id: providerMessageId || null,
|
|
281
|
+
provider_event: eventType,
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { createOaAdapterMap, getBot } from './bot_registry.js';
|
|
2
|
+
|
|
3
|
+
function text(value) {
|
|
4
|
+
return String(value ?? '').trim();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function readAnswer(payload) {
|
|
8
|
+
return text(payload?.answer).slice(0, 3_500);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// This worker is reachable only from an already verified, normalized OA event.
|
|
12
|
+
// It never offers a public send endpoint, so a reply cannot be triggered without
|
|
13
|
+
// a customer message arriving at the signed webhook first.
|
|
14
|
+
export function createOaAutoReplyWorker({
|
|
15
|
+
registry,
|
|
16
|
+
adapters = createOaAdapterMap({ registry }),
|
|
17
|
+
publicChatUrl = process.env.ABS_BRAIN_CHAT_URL || '',
|
|
18
|
+
publicBridgeToken = process.env.ABS_BRAIN_TOKEN || '',
|
|
19
|
+
fetchImpl = globalThis.fetch,
|
|
20
|
+
now = () => Date.now(),
|
|
21
|
+
} = {}) {
|
|
22
|
+
const seenEvents = new Set();
|
|
23
|
+
const recentBySender = new Map();
|
|
24
|
+
|
|
25
|
+
function allowedInbound(bot, event) {
|
|
26
|
+
if (!bot || bot.enabled === false || bot.adapter !== 'zalo_oa') return { allow: false, reason: 'bot_disabled' };
|
|
27
|
+
if (bot.policy?.mode !== 'inbound_auto_reply') return { allow: false, reason: 'inbound_auto_reply_disabled' };
|
|
28
|
+
if (!text(event?.sender_id) || !text(event?.text)) return { allow: false, reason: 'event_invalid' };
|
|
29
|
+
if (!text(publicBridgeToken)) return { allow: false, reason: 'public_bridge_token_missing' };
|
|
30
|
+
const key = `${bot.bot_id}:${event.sender_id}`;
|
|
31
|
+
const hour = 60 * 60_000;
|
|
32
|
+
const history = (recentBySender.get(key) || []).filter((at) => now() - at < hour);
|
|
33
|
+
const limit = Math.max(1, Number(bot.policy?.max_messages_per_hour) || 20);
|
|
34
|
+
if (history.length >= limit) return { allow: false, reason: 'sender_rate_limited' };
|
|
35
|
+
recentBySender.set(key, [...history, now()]);
|
|
36
|
+
return { allow: true, key };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function handle(event) {
|
|
40
|
+
const bot = getBot(registry, event?.bot_id);
|
|
41
|
+
if (seenEvents.has(event?.event_id)) return { ok: true, skipped: 'duplicate_event' };
|
|
42
|
+
const decision = allowedInbound(bot, event);
|
|
43
|
+
if (!decision.allow) return { ok: true, skipped: decision.reason };
|
|
44
|
+
try {
|
|
45
|
+
const response = await fetchImpl(publicChatUrl, {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: {
|
|
48
|
+
'content-type': 'application/json',
|
|
49
|
+
[process.env.ABS_BRAIN_TOKEN_HEADER || 'x-abs-token']: publicBridgeToken,
|
|
50
|
+
[process.env.ABS_BRAIN_ID_HEADER || 'x-abs-external-id']: `zalo-oa:${bot.bot_id}:${event.sender_id}`,
|
|
51
|
+
},
|
|
52
|
+
body: JSON.stringify({ message: event.text, channel: 'zalo-oa' }),
|
|
53
|
+
});
|
|
54
|
+
const payload = await response.json().catch(() => ({}));
|
|
55
|
+
const answer = readAnswer(payload);
|
|
56
|
+
if (!response.ok || !answer) return { ok: false, error: 'public_chat_unavailable' };
|
|
57
|
+
|
|
58
|
+
const adapter = adapters.get(bot.bot_id);
|
|
59
|
+
if (!adapter) return { ok: false, error: 'oa_adapter_unavailable' };
|
|
60
|
+
const delivery = await adapter.sendText({
|
|
61
|
+
recipient_id: event.sender_id,
|
|
62
|
+
text: answer,
|
|
63
|
+
correlation_id: event.event_id,
|
|
64
|
+
});
|
|
65
|
+
// Mark only after provider acceptance. A temporary chat/provider failure
|
|
66
|
+
// must not make the customer's message permanently unanswerable.
|
|
67
|
+
seenEvents.add(event.event_id);
|
|
68
|
+
return { ok: true, delivered: true, delivery };
|
|
69
|
+
} catch (error) {
|
|
70
|
+
return { ok: false, error: 'auto_reply_failed' };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { handle };
|
|
75
|
+
}
|
package/src/oa_policy.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const MAX_TEXT_LENGTH = 3_500;
|
|
2
|
+
|
|
3
|
+
function clean(value) {
|
|
4
|
+
return String(value ?? "").trim();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function evaluateOaOutbound({ bot, recipientId, text } = {}) {
|
|
8
|
+
const mode = clean(bot?.policy?.mode || "disabled").toLowerCase();
|
|
9
|
+
const recipient = clean(recipientId);
|
|
10
|
+
const body = String(text ?? "");
|
|
11
|
+
|
|
12
|
+
if (!bot || bot.enabled === false) return { allow: false, reason: "bot_disabled", mode };
|
|
13
|
+
if (mode === "disabled") return { allow: false, reason: "bot_disabled", mode };
|
|
14
|
+
if (mode === "paused") return { allow: false, reason: "bot_paused", mode };
|
|
15
|
+
if (!recipient) return { allow: false, reason: "recipient_required", mode };
|
|
16
|
+
if (!body.trim()) return { allow: false, reason: "text_required", mode };
|
|
17
|
+
if (body.length > MAX_TEXT_LENGTH) return { allow: false, reason: "text_too_long", mode };
|
|
18
|
+
if (mode !== "approved_send") {
|
|
19
|
+
return { allow: false, reason: "outbound_requires_approval", mode };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const allowlist = Array.isArray(bot.policy?.allow_user_ids)
|
|
23
|
+
? bot.policy.allow_user_ids.map(clean).filter(Boolean)
|
|
24
|
+
: [];
|
|
25
|
+
if (!allowlist.length) return { allow: false, reason: "recipient_allowlist_empty", mode };
|
|
26
|
+
if (!allowlist.includes(recipient)) {
|
|
27
|
+
return { allow: false, reason: "recipient_not_allowlisted", mode };
|
|
28
|
+
}
|
|
29
|
+
return { allow: true, reason: "approved", mode };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const OA_MAX_TEXT_LENGTH = MAX_TEXT_LENGTH;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { getBot } from "./bot_registry.js";
|
|
4
|
+
import { normalizeOaWebhookEvent } from "./oa_adapter.js";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_MAX_BODY_BYTES = 256 * 1024;
|
|
7
|
+
|
|
8
|
+
function bytesOf(rawBody) {
|
|
9
|
+
if (rawBody == null) return 0;
|
|
10
|
+
if (Buffer.isBuffer(rawBody)) return rawBody.byteLength;
|
|
11
|
+
return Buffer.byteLength(String(rawBody), "utf8");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function rawBytes(rawBody, body) {
|
|
15
|
+
if (rawBody != null) return Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody), "utf8");
|
|
16
|
+
return Buffer.from(JSON.stringify(body ?? {}), "utf8");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function signatureFrom(req) {
|
|
20
|
+
return String(
|
|
21
|
+
req?.headers?.["x-zalo-signature"] ??
|
|
22
|
+
req?.headers?.["x-signature"] ??
|
|
23
|
+
req?.headers?.["x-zalo-webhook-signature"] ??
|
|
24
|
+
"",
|
|
25
|
+
).trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function verifyWebhookSignature({ rawBody, signature, secret } = {}) {
|
|
29
|
+
const configuredSecret = String(secret ?? "");
|
|
30
|
+
if (!configuredSecret) return true;
|
|
31
|
+
const provided = String(signature ?? "").trim().toLowerCase();
|
|
32
|
+
if (!provided || !/^[a-f0-9]{64}$/.test(provided)) return false;
|
|
33
|
+
const expected = crypto.createHmac("sha256", configuredSecret).update(rawBytes(rawBody)).digest("hex");
|
|
34
|
+
const left = Buffer.from(provided, "hex");
|
|
35
|
+
const right = Buffer.from(expected, "hex");
|
|
36
|
+
return left.length === right.length && crypto.timingSafeEqual(left, right);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function reply(res, statusCode, payload) {
|
|
40
|
+
if (typeof res?.status === "function" && typeof res?.json === "function") {
|
|
41
|
+
return res.status(statusCode).json(payload);
|
|
42
|
+
}
|
|
43
|
+
if (res) {
|
|
44
|
+
res.statusCode = statusCode;
|
|
45
|
+
res.payload = payload;
|
|
46
|
+
}
|
|
47
|
+
return res;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Express-compatible OA webhook handler. It performs ingress validation and
|
|
52
|
+
* normalization only. The caller decides whether/how to enqueue a workflow.
|
|
53
|
+
*/
|
|
54
|
+
export function createOaWebhookHandler({
|
|
55
|
+
registry,
|
|
56
|
+
webhookSecret = process.env.ZALO_OA_WEBHOOK_SECRET || "",
|
|
57
|
+
onEvent = async () => {},
|
|
58
|
+
maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
|
|
59
|
+
} = {}) {
|
|
60
|
+
return async function oaWebhookHandler(req, res) {
|
|
61
|
+
const botId = String(req?.params?.bot_id || "").trim();
|
|
62
|
+
const bot = getBot(registry, botId);
|
|
63
|
+
if (!bot) return reply(res, 404, { ok: false, error: "bot_not_found" });
|
|
64
|
+
if (!bot.enabled) return reply(res, 409, { ok: false, error: "bot_disabled" });
|
|
65
|
+
|
|
66
|
+
const rawBody = req?.rawBody ?? req?.bodyRaw ?? null;
|
|
67
|
+
const bodySize = bytesOf(rawBody) || bytesOf(req?.body ? JSON.stringify(req.body) : "");
|
|
68
|
+
if (bodySize > Math.max(1, Number(maxBodyBytes) || DEFAULT_MAX_BODY_BYTES)) {
|
|
69
|
+
return reply(res, 413, { ok: false, error: "webhook_body_too_large" });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!verifyWebhookSignature({ rawBody: rawBody ?? JSON.stringify(req?.body ?? {}), signature: signatureFrom(req), secret: webhookSecret })) {
|
|
73
|
+
return reply(res, 401, { ok: false, error: "webhook_signature_invalid" });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const event = normalizeOaWebhookEvent({
|
|
77
|
+
botId: bot.bot_id,
|
|
78
|
+
tenantId: bot.tenant_id,
|
|
79
|
+
payload: req?.body,
|
|
80
|
+
});
|
|
81
|
+
if (!event) return reply(res, 200, { ok: true, ignored: true });
|
|
82
|
+
|
|
83
|
+
// Acknowledge first. Queue/worker failures must not cause provider retries
|
|
84
|
+
// to duplicate a message; errors are exposed through the caller's queue.
|
|
85
|
+
const response = reply(res, 200, { ok: true, event_id: event.event_id });
|
|
86
|
+
try {
|
|
87
|
+
await Promise.resolve(onEvent(event));
|
|
88
|
+
} catch {
|
|
89
|
+
// Deliberately do not rewrite an already acknowledged provider response.
|
|
90
|
+
}
|
|
91
|
+
return response;
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const OA_WEBHOOK_MAX_BODY_BYTES = DEFAULT_MAX_BODY_BYTES;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
const DEFAULT_HOST = "127.0.0.1";
|
|
2
|
+
const DEFAULT_PORT = 3871;
|
|
3
|
+
const DEFAULT_ACCOUNT = "default";
|
|
4
|
+
const DEFAULT_LOCAL_PORT = 13871;
|
|
5
|
+
|
|
6
|
+
function port(value, name, fallback) {
|
|
7
|
+
const parsed = Number(value ?? fallback);
|
|
8
|
+
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
|
|
9
|
+
throw new Error(`${name} must be an integer between 1 and 65535`);
|
|
10
|
+
}
|
|
11
|
+
return parsed;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function text(value, fallback = "") {
|
|
15
|
+
const result = String(value ?? fallback).trim();
|
|
16
|
+
return result || fallback;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function shellArg(value) {
|
|
20
|
+
const raw = text(value);
|
|
21
|
+
if (!raw || /[\r\n]/.test(raw)) throw new Error("SSH value cannot be empty or multiline");
|
|
22
|
+
if (/^[A-Za-z0-9._:@%+-]+$/.test(raw)) return raw;
|
|
23
|
+
return `'${raw.replaceAll("'", "'\\''")}'`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function baseUrl(value, fallback) {
|
|
27
|
+
const raw = text(value, fallback).replace(/\/+$/, "");
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
parsed = new URL(raw);
|
|
31
|
+
} catch {
|
|
32
|
+
throw new Error("dashboard URL must be an absolute http(s) URL");
|
|
33
|
+
}
|
|
34
|
+
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
35
|
+
throw new Error("dashboard URL must use http or https");
|
|
36
|
+
}
|
|
37
|
+
return parsed.toString().replace(/\/+$/, "");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function buildSshTunnelCommand({
|
|
41
|
+
localPort = DEFAULT_LOCAL_PORT,
|
|
42
|
+
remotePort = DEFAULT_PORT,
|
|
43
|
+
sshUser,
|
|
44
|
+
sshHost,
|
|
45
|
+
} = {}) {
|
|
46
|
+
const local = port(localPort, "localPort", DEFAULT_LOCAL_PORT);
|
|
47
|
+
const remote = port(remotePort, "remotePort", DEFAULT_PORT);
|
|
48
|
+
const user = shellArg(sshUser || "user");
|
|
49
|
+
const host = shellArg(sshHost || "your-vps-host");
|
|
50
|
+
return `ssh -N -L ${local}:127.0.0.1:${remote} ${user}@${host}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function buildOnboardingInfo({
|
|
54
|
+
host = DEFAULT_HOST,
|
|
55
|
+
port: dashboardPort = DEFAULT_PORT,
|
|
56
|
+
accountId = DEFAULT_ACCOUNT,
|
|
57
|
+
localPort = DEFAULT_LOCAL_PORT,
|
|
58
|
+
sshUser = "user",
|
|
59
|
+
sshHost = "your-vps-host",
|
|
60
|
+
publicUrl = "",
|
|
61
|
+
} = {}) {
|
|
62
|
+
const bindHost = text(host, DEFAULT_HOST);
|
|
63
|
+
const remotePort = port(dashboardPort, "port", DEFAULT_PORT);
|
|
64
|
+
const account = encodeURIComponent(text(accountId, DEFAULT_ACCOUNT));
|
|
65
|
+
const localHost = bindHost === "0.0.0.0" || bindHost === "::" ? DEFAULT_HOST : bindHost;
|
|
66
|
+
const dashboardUrl = `http://${localHost}:${remotePort}`;
|
|
67
|
+
const connectUrl = `${dashboardUrl}/connect`;
|
|
68
|
+
const tunnelCommand = buildSshTunnelCommand({
|
|
69
|
+
localPort,
|
|
70
|
+
remotePort,
|
|
71
|
+
sshUser,
|
|
72
|
+
sshHost,
|
|
73
|
+
});
|
|
74
|
+
const publicBase = publicUrl ? baseUrl(publicUrl, dashboardUrl) : "";
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
dashboard_url: dashboardUrl,
|
|
78
|
+
connect_url: connectUrl,
|
|
79
|
+
qr_api_url: `${dashboardUrl}/api/accounts/${account}/qr`,
|
|
80
|
+
qr_location: "browser",
|
|
81
|
+
note: "Open the connect URL in a browser. The QR is never printed in logs or sent to chat.",
|
|
82
|
+
vps: {
|
|
83
|
+
browser_url: `http://127.0.0.1:${localPort}/connect`,
|
|
84
|
+
ssh_tunnel_command: tunnelCommand,
|
|
85
|
+
...(publicBase ? { public_url: `${publicBase}/connect` } : {}),
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function onboardingFromEnv(env = process.env) {
|
|
91
|
+
return buildOnboardingInfo({
|
|
92
|
+
host: env.HOST || env.DASHBOARD_BIND || DEFAULT_HOST,
|
|
93
|
+
port: env.PORT || env.DASHBOARD_PORT || DEFAULT_PORT,
|
|
94
|
+
accountId: env.DEFAULT_ACCOUNT_ID || DEFAULT_ACCOUNT,
|
|
95
|
+
localPort: env.QR_LOCAL_PORT || DEFAULT_LOCAL_PORT,
|
|
96
|
+
sshUser: env.DASHBOARD_SSH_USER || env.SSH_USER || "user",
|
|
97
|
+
sshHost: env.DASHBOARD_SSH_HOST || env.SSH_HOST || "your-vps-host",
|
|
98
|
+
publicUrl: env.DASHBOARD_PUBLIC_URL || "",
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export { DEFAULT_LOCAL_PORT };
|