@orchestero/codex-gateway 0.0.3
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/CHANGELOG.md +54 -0
- package/lib/agent-configs.ts +191 -0
- package/lib/chat-bot.ts +287 -0
- package/lib/codex-app-server.ts +450 -0
- package/lib/codex-gateway-server.ts +77 -0
- package/lib/main.ts +12 -0
- package/lib/pg-pool.ts +7 -0
- package/lib/pg-state.ts +35 -0
- package/lib/pg-url.ts +2 -0
- package/lib/zalo-adapter.ts +836 -0
- package/package.json +1 -0
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import type {
|
|
3
|
+
Adapter,
|
|
4
|
+
AdapterPostableMessage,
|
|
5
|
+
Attachment,
|
|
6
|
+
ChatInstance,
|
|
7
|
+
FetchResult,
|
|
8
|
+
FormattedContent,
|
|
9
|
+
Logger,
|
|
10
|
+
RawMessage,
|
|
11
|
+
StreamChunk,
|
|
12
|
+
StreamOptions,
|
|
13
|
+
ThreadInfo,
|
|
14
|
+
WebhookOptions,
|
|
15
|
+
} from "chat";
|
|
16
|
+
import {
|
|
17
|
+
ConsoleLogger,
|
|
18
|
+
Message,
|
|
19
|
+
markdownToPlainText,
|
|
20
|
+
parseMarkdown,
|
|
21
|
+
toPlainText,
|
|
22
|
+
} from "chat";
|
|
23
|
+
|
|
24
|
+
type ZaloAdapterConfig = {
|
|
25
|
+
botToken: string;
|
|
26
|
+
logger?: Logger;
|
|
27
|
+
longPolling?: ZaloLongPollingConfig;
|
|
28
|
+
mode?: ZaloAdapterMode;
|
|
29
|
+
secretToken?: string;
|
|
30
|
+
userName?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type ZaloAdapterMode = "auto" | "webhook" | "polling";
|
|
34
|
+
|
|
35
|
+
type ZaloLongPollingConfig = {
|
|
36
|
+
deleteWebhook?: boolean;
|
|
37
|
+
retryDelayMs?: number;
|
|
38
|
+
timeout?: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type ResolvedZaloLongPollingConfig = {
|
|
42
|
+
deleteWebhook: boolean;
|
|
43
|
+
retryDelayMs: number;
|
|
44
|
+
timeout: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type ZaloThreadId = {
|
|
48
|
+
chatId: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type ZaloChatType = "GROUP" | "PRIVATE";
|
|
52
|
+
|
|
53
|
+
type ZaloInboundMessage = {
|
|
54
|
+
caption?: string;
|
|
55
|
+
chat: {
|
|
56
|
+
chat_type: ZaloChatType;
|
|
57
|
+
id: string;
|
|
58
|
+
};
|
|
59
|
+
date: number;
|
|
60
|
+
from: {
|
|
61
|
+
display_name: string;
|
|
62
|
+
id: string;
|
|
63
|
+
is_bot: boolean;
|
|
64
|
+
};
|
|
65
|
+
message_id: string;
|
|
66
|
+
photo?: string;
|
|
67
|
+
photo_url?: string;
|
|
68
|
+
sticker?: string;
|
|
69
|
+
text?: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
type ZaloWebhookPayload = {
|
|
73
|
+
event_name: string;
|
|
74
|
+
message?: ZaloInboundMessage;
|
|
75
|
+
update_id?: number;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
type ZaloRawMessage = {
|
|
79
|
+
message: ZaloInboundMessage;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
type ZaloGetMeResponse = {
|
|
83
|
+
account_name?: string;
|
|
84
|
+
display_name?: string;
|
|
85
|
+
id: string;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
type ZaloSendResponse = {
|
|
89
|
+
date?: number;
|
|
90
|
+
message_id?: number | string;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
type ZaloWebhookInfoResponse = {
|
|
94
|
+
url?: string;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
type ZaloApiResponse<T> = {
|
|
98
|
+
description?: string;
|
|
99
|
+
error_code?: number;
|
|
100
|
+
ok: boolean;
|
|
101
|
+
result?: T;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
class ZaloApiResponseError extends Error {
|
|
105
|
+
constructor(
|
|
106
|
+
readonly method: string,
|
|
107
|
+
readonly code: number | "unknown",
|
|
108
|
+
description: string,
|
|
109
|
+
) {
|
|
110
|
+
super(`Zalo API error in ${method} (${code}): ${description}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const apiBaseUrl = "https://bot-api.zaloplatforms.com";
|
|
115
|
+
const defaultPollingRetryDelayMs = 1000;
|
|
116
|
+
const maxPollingBackoffMs = 30_000;
|
|
117
|
+
const messageLimit = 2000;
|
|
118
|
+
const defaultPollingTimeoutSeconds = 30;
|
|
119
|
+
const secretTokenHeader = "x-bot-api-secret-token";
|
|
120
|
+
|
|
121
|
+
function sleep(delayMs: number, signal: AbortSignal) {
|
|
122
|
+
return new Promise((resolve) => {
|
|
123
|
+
const timeout = setTimeout(resolve, delayMs);
|
|
124
|
+
signal.addEventListener(
|
|
125
|
+
"abort",
|
|
126
|
+
() => {
|
|
127
|
+
clearTimeout(timeout);
|
|
128
|
+
resolve(undefined);
|
|
129
|
+
},
|
|
130
|
+
{ once: true },
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
136
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function getRecord(value: unknown, key: string) {
|
|
140
|
+
if (!isRecord(value)) {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const nextValue = value[key];
|
|
145
|
+
if (!isRecord(nextValue)) {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return nextValue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function getString(value: unknown, key: string) {
|
|
153
|
+
if (!isRecord(value)) {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const nextValue = value[key];
|
|
158
|
+
if (typeof nextValue !== "string") {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return nextValue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function getStringId(value: unknown, key: string) {
|
|
166
|
+
if (!isRecord(value)) {
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const nextValue = value[key];
|
|
171
|
+
if (typeof nextValue === "string") {
|
|
172
|
+
return nextValue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (typeof nextValue === "number" && Number.isFinite(nextValue)) {
|
|
176
|
+
return String(nextValue);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function formatId(value: number | string | undefined) {
|
|
181
|
+
return value === undefined ? "" : String(value);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function getNumber(value: unknown, key: string) {
|
|
185
|
+
if (!isRecord(value)) {
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const nextValue = value[key];
|
|
190
|
+
if (typeof nextValue !== "number") {
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return nextValue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function splitMessage(text: string): [string, ...string[]] {
|
|
198
|
+
if (text.length <= messageLimit) {
|
|
199
|
+
return [text];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const chunks: [string, ...string[]] = [text.slice(0, messageLimit)];
|
|
203
|
+
let startIndex = messageLimit;
|
|
204
|
+
|
|
205
|
+
while (startIndex < text.length) {
|
|
206
|
+
chunks.push(text.slice(startIndex, startIndex + messageLimit));
|
|
207
|
+
startIndex += messageLimit;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return chunks;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function parseInboundMessage(value: unknown): ZaloInboundMessage | undefined {
|
|
214
|
+
const chat = getRecord(value, "chat");
|
|
215
|
+
const from = getRecord(value, "from");
|
|
216
|
+
const chatType = getString(chat, "chat_type");
|
|
217
|
+
const chatId = getStringId(chat, "id");
|
|
218
|
+
const date = getNumber(value, "date");
|
|
219
|
+
const displayName = getString(from, "display_name");
|
|
220
|
+
const fromId = getStringId(from, "id");
|
|
221
|
+
const isBot = isRecord(from) ? from.is_bot : undefined;
|
|
222
|
+
const messageId = getStringId(value, "message_id");
|
|
223
|
+
|
|
224
|
+
if (
|
|
225
|
+
(chatType !== "GROUP" && chatType !== "PRIVATE") ||
|
|
226
|
+
!chatId ||
|
|
227
|
+
typeof date !== "number" ||
|
|
228
|
+
!displayName ||
|
|
229
|
+
!fromId ||
|
|
230
|
+
typeof isBot !== "boolean" ||
|
|
231
|
+
!messageId
|
|
232
|
+
) {
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
caption: getString(value, "caption"),
|
|
238
|
+
chat: {
|
|
239
|
+
chat_type: chatType,
|
|
240
|
+
id: chatId,
|
|
241
|
+
},
|
|
242
|
+
date,
|
|
243
|
+
from: {
|
|
244
|
+
display_name: displayName,
|
|
245
|
+
id: fromId,
|
|
246
|
+
is_bot: isBot,
|
|
247
|
+
},
|
|
248
|
+
message_id: messageId,
|
|
249
|
+
photo: getString(value, "photo"),
|
|
250
|
+
photo_url: getString(value, "photo_url"),
|
|
251
|
+
sticker: getString(value, "sticker"),
|
|
252
|
+
text: getString(value, "text"),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function parsePayload(value: unknown): ZaloWebhookPayload | undefined {
|
|
257
|
+
const payload = getRecord(value, "result") || value;
|
|
258
|
+
const eventName = getString(payload, "event_name");
|
|
259
|
+
const updateId = getNumber(payload, "update_id");
|
|
260
|
+
if (!eventName) {
|
|
261
|
+
if (typeof updateId === "number") {
|
|
262
|
+
return { event_name: "unknown", update_id: updateId };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
event_name: eventName,
|
|
270
|
+
message: parseInboundMessage(getRecord(payload, "message")),
|
|
271
|
+
update_id: updateId,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function getPayloads(value: unknown) {
|
|
276
|
+
if (Array.isArray(value)) {
|
|
277
|
+
return value
|
|
278
|
+
.map(parsePayload)
|
|
279
|
+
.filter((payload): payload is ZaloWebhookPayload => Boolean(payload));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const payload = parsePayload(value);
|
|
283
|
+
return payload ? [payload] : [];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function isPollingTimeout(error: unknown) {
|
|
287
|
+
return isZaloApiResponseError(error, "getUpdates", 408);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function isZaloApiResponseError(error: unknown, method: string, code: number) {
|
|
291
|
+
return (
|
|
292
|
+
error instanceof ZaloApiResponseError &&
|
|
293
|
+
error.method === method &&
|
|
294
|
+
error.code === code
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function clampInteger(
|
|
299
|
+
value: number | undefined,
|
|
300
|
+
fallback: number,
|
|
301
|
+
minimum = 1,
|
|
302
|
+
) {
|
|
303
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
304
|
+
return fallback;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return Math.max(minimum, Math.trunc(value));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function isLikelyServerlessRuntime() {
|
|
311
|
+
return Boolean(
|
|
312
|
+
process.env.VERCEL ||
|
|
313
|
+
process.env.AWS_LAMBDA_FUNCTION_NAME ||
|
|
314
|
+
process.env.AWS_EXECUTION_ENV?.includes("AWS_Lambda") ||
|
|
315
|
+
process.env.FUNCTIONS_WORKER_RUNTIME ||
|
|
316
|
+
process.env.NETLIFY ||
|
|
317
|
+
process.env.K_SERVICE,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function renderPostableMessage(message: AdapterPostableMessage) {
|
|
322
|
+
if (typeof message === "string") {
|
|
323
|
+
return message;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if ("raw" in message) {
|
|
327
|
+
return message.raw;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if ("markdown" in message) {
|
|
331
|
+
return markdownToPlainText(message.markdown);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if ("ast" in message) {
|
|
335
|
+
return toPlainText(message.ast);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
throw new Error("Zalo does not support this message type.");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function createZaloAdapter(
|
|
342
|
+
config: ZaloAdapterConfig,
|
|
343
|
+
): Adapter<ZaloThreadId, ZaloRawMessage> {
|
|
344
|
+
const logger = config.logger ?? new ConsoleLogger("info").child("zalo");
|
|
345
|
+
const mode = config.mode ?? "auto";
|
|
346
|
+
if (mode !== "auto" && mode !== "polling" && mode !== "webhook") {
|
|
347
|
+
throw new Error(
|
|
348
|
+
`Invalid Zalo mode: ${mode}. Expected "auto", "polling", or "webhook".`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const userName = config.userName?.trim() || "bot";
|
|
353
|
+
let botUserId: string | undefined;
|
|
354
|
+
let chat: ChatInstance | undefined;
|
|
355
|
+
let pollingAbortController: AbortController | undefined;
|
|
356
|
+
let pollingActive = false;
|
|
357
|
+
let pollingTask: Promise<void> | undefined;
|
|
358
|
+
let warnedNoWebhookVerification = false;
|
|
359
|
+
|
|
360
|
+
async function apiRequest<T>(
|
|
361
|
+
method: string,
|
|
362
|
+
body?: unknown,
|
|
363
|
+
options?: { signal?: AbortSignal },
|
|
364
|
+
) {
|
|
365
|
+
const response = await fetch(
|
|
366
|
+
`${apiBaseUrl}/bot${config.botToken}/${method}`,
|
|
367
|
+
{
|
|
368
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
369
|
+
headers: { "Content-Type": "application/json" },
|
|
370
|
+
method: "POST",
|
|
371
|
+
signal: options?.signal,
|
|
372
|
+
},
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
if (!response.ok) {
|
|
376
|
+
throw new Error(
|
|
377
|
+
`Zalo API error in ${method}: ${response.status} ${await response.text()}`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const data = (await response.json()) as ZaloApiResponse<T>;
|
|
382
|
+
if (!data.ok) {
|
|
383
|
+
throw new ZaloApiResponseError(
|
|
384
|
+
method,
|
|
385
|
+
data.error_code ?? "unknown",
|
|
386
|
+
data.description ?? "unknown error",
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return data.result;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function encodeThreadId(threadId: ZaloThreadId) {
|
|
394
|
+
return `zalo:${threadId.chatId}`;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function decodeThreadId(threadId: string) {
|
|
398
|
+
if (!threadId.startsWith("zalo:")) {
|
|
399
|
+
throw new Error(`Invalid Zalo thread ID: ${threadId}`);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const chatId = threadId.slice(5);
|
|
403
|
+
if (!chatId) {
|
|
404
|
+
throw new Error(`Invalid Zalo thread ID format: ${threadId}`);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return { chatId };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function parseMessage(raw: ZaloRawMessage) {
|
|
411
|
+
const { message } = raw;
|
|
412
|
+
const attachments: Attachment[] = [];
|
|
413
|
+
const photoURL = message.photo_url || message.photo;
|
|
414
|
+
const text =
|
|
415
|
+
message.text || message.caption || (photoURL ? "[Image]" : "[Sticker]");
|
|
416
|
+
|
|
417
|
+
if (photoURL) {
|
|
418
|
+
attachments.push({ type: "image", url: photoURL });
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return new Message<ZaloRawMessage>({
|
|
422
|
+
attachments,
|
|
423
|
+
author: {
|
|
424
|
+
fullName: message.from.display_name,
|
|
425
|
+
isBot: message.from.is_bot,
|
|
426
|
+
isMe: message.from.id === botUserId,
|
|
427
|
+
userId: message.from.id,
|
|
428
|
+
userName: message.from.display_name,
|
|
429
|
+
},
|
|
430
|
+
formatted: parseMarkdown(text),
|
|
431
|
+
id: message.message_id,
|
|
432
|
+
metadata: {
|
|
433
|
+
dateSent: new Date(message.date),
|
|
434
|
+
edited: false,
|
|
435
|
+
},
|
|
436
|
+
raw,
|
|
437
|
+
text,
|
|
438
|
+
threadId: encodeThreadId({ chatId: message.chat.id }),
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function processPayload(
|
|
443
|
+
payload: ZaloWebhookPayload,
|
|
444
|
+
options?: WebhookOptions,
|
|
445
|
+
) {
|
|
446
|
+
if (!chat || !payload.message) {
|
|
447
|
+
logger.debug("Ignored Zalo event.", { eventName: payload.event_name });
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (
|
|
452
|
+
payload.event_name !== "message.text.received" &&
|
|
453
|
+
payload.event_name !== "message.image.received" &&
|
|
454
|
+
payload.event_name !== "message.sticker.received"
|
|
455
|
+
) {
|
|
456
|
+
logger.debug("Ignored unsupported Zalo event.", {
|
|
457
|
+
eventName: payload.event_name,
|
|
458
|
+
messageId: payload.message.message_id,
|
|
459
|
+
});
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const threadId = encodeThreadId({ chatId: payload.message.chat.id });
|
|
464
|
+
chat.processMessage(
|
|
465
|
+
adapter,
|
|
466
|
+
threadId,
|
|
467
|
+
parseMessage({ message: payload.message }),
|
|
468
|
+
options,
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function verifySecretToken(request: Request) {
|
|
473
|
+
const token = request.headers.get(secretTokenHeader);
|
|
474
|
+
if (!token || !config.secretToken) {
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
try {
|
|
479
|
+
return timingSafeEqual(
|
|
480
|
+
Buffer.from(token),
|
|
481
|
+
Buffer.from(config.secretToken),
|
|
482
|
+
);
|
|
483
|
+
} catch {
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function deleteWebhook() {
|
|
489
|
+
try {
|
|
490
|
+
await apiRequest("deleteWebhook");
|
|
491
|
+
} catch (error) {
|
|
492
|
+
if (isZaloApiResponseError(error, "deleteWebhook", 400)) {
|
|
493
|
+
logger.debug(
|
|
494
|
+
"Zalo webhook reset skipped because no webhook is configured.",
|
|
495
|
+
);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
throw error;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
logger.info("Zalo webhook reset.");
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function fetchWebhookInfo() {
|
|
506
|
+
try {
|
|
507
|
+
return await apiRequest<ZaloWebhookInfoResponse>("getWebhookInfo");
|
|
508
|
+
} catch (error) {
|
|
509
|
+
if (isZaloApiResponseError(error, "getWebhookInfo", 404)) {
|
|
510
|
+
logger.debug(
|
|
511
|
+
"Zalo webhook info not found; assuming no webhook is configured.",
|
|
512
|
+
);
|
|
513
|
+
return {};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
logger.warn("Failed to fetch Zalo webhook info.", {
|
|
517
|
+
error: String(error),
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async function resolveRuntimeMode() {
|
|
523
|
+
if (mode === "webhook") {
|
|
524
|
+
return "webhook";
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (mode === "polling") {
|
|
528
|
+
return "polling";
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const webhookInfo = await fetchWebhookInfo();
|
|
532
|
+
if (!webhookInfo) {
|
|
533
|
+
logger.warn(
|
|
534
|
+
"Zalo auto mode could not verify webhook status; keeping webhook mode.",
|
|
535
|
+
);
|
|
536
|
+
return "webhook";
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (typeof webhookInfo.url === "string" && webhookInfo.url.trim()) {
|
|
540
|
+
logger.debug("Zalo auto mode selected webhook mode.", {
|
|
541
|
+
webhookUrl: webhookInfo.url,
|
|
542
|
+
});
|
|
543
|
+
return "webhook";
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (isLikelyServerlessRuntime()) {
|
|
547
|
+
logger.warn(
|
|
548
|
+
"Zalo auto mode detected serverless runtime without webhook URL; keeping webhook mode.",
|
|
549
|
+
);
|
|
550
|
+
return "webhook";
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
logger.info("Zalo auto mode selected polling mode.");
|
|
554
|
+
return "polling";
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function resolvePollingConfig(
|
|
558
|
+
override?: ZaloLongPollingConfig,
|
|
559
|
+
): ResolvedZaloLongPollingConfig {
|
|
560
|
+
const merged = { ...config.longPolling, ...override };
|
|
561
|
+
return {
|
|
562
|
+
deleteWebhook: merged.deleteWebhook ?? true,
|
|
563
|
+
retryDelayMs: clampInteger(
|
|
564
|
+
merged.retryDelayMs,
|
|
565
|
+
defaultPollingRetryDelayMs,
|
|
566
|
+
),
|
|
567
|
+
timeout: clampInteger(merged.timeout, defaultPollingTimeoutSeconds),
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
async function sendMessage(
|
|
572
|
+
threadId: string,
|
|
573
|
+
chatId: string,
|
|
574
|
+
text: string,
|
|
575
|
+
): Promise<RawMessage<ZaloRawMessage>> {
|
|
576
|
+
const response = await apiRequest<ZaloSendResponse>("sendMessage", {
|
|
577
|
+
chat_id: chatId,
|
|
578
|
+
text,
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
return {
|
|
582
|
+
id: formatId(response?.message_id),
|
|
583
|
+
raw: {
|
|
584
|
+
message: {
|
|
585
|
+
chat: { chat_type: "PRIVATE", id: chatId },
|
|
586
|
+
date: response?.date ?? Date.now(),
|
|
587
|
+
from: {
|
|
588
|
+
display_name: userName,
|
|
589
|
+
id: botUserId || "",
|
|
590
|
+
is_bot: true,
|
|
591
|
+
},
|
|
592
|
+
message_id: formatId(response?.message_id),
|
|
593
|
+
text,
|
|
594
|
+
},
|
|
595
|
+
},
|
|
596
|
+
threadId,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async function sendMessages(threadId: string, chatId: string, text: string) {
|
|
601
|
+
const [firstChunk, ...remainingChunks] = splitMessage(text);
|
|
602
|
+
let message = await sendMessage(threadId, chatId, firstChunk);
|
|
603
|
+
|
|
604
|
+
for (const chunk of remainingChunks) {
|
|
605
|
+
message = await sendMessage(threadId, chatId, chunk);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return message;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async function pollingLoop(config: ResolvedZaloLongPollingConfig) {
|
|
612
|
+
let offset: number | undefined;
|
|
613
|
+
let consecutiveFailures = 0;
|
|
614
|
+
|
|
615
|
+
while (pollingActive) {
|
|
616
|
+
pollingAbortController = new AbortController();
|
|
617
|
+
|
|
618
|
+
try {
|
|
619
|
+
const body =
|
|
620
|
+
typeof offset === "number"
|
|
621
|
+
? { offset, timeout: String(config.timeout) }
|
|
622
|
+
: { timeout: String(config.timeout) };
|
|
623
|
+
const result = await apiRequest<unknown>("getUpdates", body, {
|
|
624
|
+
signal: pollingAbortController.signal,
|
|
625
|
+
});
|
|
626
|
+
consecutiveFailures = 0;
|
|
627
|
+
|
|
628
|
+
for (const payload of getPayloads(result)) {
|
|
629
|
+
if (typeof payload.update_id === "number") {
|
|
630
|
+
const nextOffset = payload.update_id + 1;
|
|
631
|
+
offset =
|
|
632
|
+
typeof offset === "number"
|
|
633
|
+
? Math.max(offset, nextOffset)
|
|
634
|
+
: nextOffset;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
try {
|
|
638
|
+
processPayload(payload);
|
|
639
|
+
} catch (error) {
|
|
640
|
+
logger.warn("Failed to process Zalo polled update.", {
|
|
641
|
+
error: String(error),
|
|
642
|
+
updateId: payload.update_id,
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
} catch (error) {
|
|
647
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (isPollingTimeout(error)) {
|
|
652
|
+
consecutiveFailures = 0;
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
consecutiveFailures += 1;
|
|
657
|
+
const retryDelayMs = Math.min(
|
|
658
|
+
config.retryDelayMs * 2 ** (consecutiveFailures - 1),
|
|
659
|
+
maxPollingBackoffMs,
|
|
660
|
+
);
|
|
661
|
+
logger.warn("Zalo polling request failed.", {
|
|
662
|
+
consecutiveFailures,
|
|
663
|
+
error: String(error),
|
|
664
|
+
retryDelayMs,
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
if (!pollingActive) {
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
await sleep(retryDelayMs, pollingAbortController.signal);
|
|
672
|
+
} finally {
|
|
673
|
+
pollingAbortController = undefined;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
async function startPolling(config?: ZaloLongPollingConfig) {
|
|
679
|
+
if (pollingActive) {
|
|
680
|
+
logger.debug("Zalo polling already active.");
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const resolvedConfig = resolvePollingConfig(config);
|
|
685
|
+
pollingActive = true;
|
|
686
|
+
try {
|
|
687
|
+
if (resolvedConfig.deleteWebhook) {
|
|
688
|
+
await deleteWebhook();
|
|
689
|
+
}
|
|
690
|
+
} catch (error) {
|
|
691
|
+
pollingActive = false;
|
|
692
|
+
throw error;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
logger.info("Zalo polling started.", { timeout: resolvedConfig.timeout });
|
|
696
|
+
pollingTask = pollingLoop(resolvedConfig).finally(() => {
|
|
697
|
+
pollingAbortController = undefined;
|
|
698
|
+
pollingActive = false;
|
|
699
|
+
pollingTask = undefined;
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
async function stopPolling() {
|
|
704
|
+
if (!pollingActive) {
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
pollingActive = false;
|
|
709
|
+
pollingAbortController?.abort();
|
|
710
|
+
await pollingTask;
|
|
711
|
+
logger.info("Zalo polling stopped.");
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const adapter: Adapter<ZaloThreadId, ZaloRawMessage> = {
|
|
715
|
+
addReaction: async () => {
|
|
716
|
+
throw new Error("Zalo does not support reactions.");
|
|
717
|
+
},
|
|
718
|
+
get botUserId() {
|
|
719
|
+
return botUserId;
|
|
720
|
+
},
|
|
721
|
+
channelIdFromThreadId: (threadId) => threadId,
|
|
722
|
+
decodeThreadId,
|
|
723
|
+
deleteMessage: async () => {
|
|
724
|
+
throw new Error("Zalo does not support deleting messages.");
|
|
725
|
+
},
|
|
726
|
+
disconnect: stopPolling,
|
|
727
|
+
editMessage: async () => {
|
|
728
|
+
throw new Error("Zalo does not support editing messages.");
|
|
729
|
+
},
|
|
730
|
+
encodeThreadId,
|
|
731
|
+
fetchMessages: async (): Promise<FetchResult<ZaloRawMessage>> => ({
|
|
732
|
+
messages: [],
|
|
733
|
+
}),
|
|
734
|
+
fetchThread: async (threadId): Promise<ThreadInfo> => ({
|
|
735
|
+
channelId: threadId,
|
|
736
|
+
channelName: `Zalo: ${decodeThreadId(threadId).chatId}`,
|
|
737
|
+
id: threadId,
|
|
738
|
+
isDM: true,
|
|
739
|
+
metadata: { chatId: decodeThreadId(threadId).chatId },
|
|
740
|
+
}),
|
|
741
|
+
handleWebhook: async (request, options) => {
|
|
742
|
+
if (config.secretToken && !verifySecretToken(request)) {
|
|
743
|
+
logger.warn("Zalo webhook rejected due to invalid secret token.");
|
|
744
|
+
return new Response("Unauthorized", { status: 401 });
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
if (!config.secretToken && !warnedNoWebhookVerification) {
|
|
748
|
+
warnedNoWebhookVerification = true;
|
|
749
|
+
logger.warn(
|
|
750
|
+
"Zalo webhook verification is disabled. Set secretToken to verify incoming requests.",
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
let payload: ZaloWebhookPayload | undefined;
|
|
755
|
+
try {
|
|
756
|
+
payload = parsePayload(await request.json());
|
|
757
|
+
} catch {
|
|
758
|
+
return new Response("Invalid JSON", { status: 400 });
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (payload) {
|
|
762
|
+
try {
|
|
763
|
+
processPayload(payload, options);
|
|
764
|
+
} catch (error) {
|
|
765
|
+
logger.warn("Failed to process Zalo webhook update.", {
|
|
766
|
+
error: String(error),
|
|
767
|
+
eventName: payload.event_name,
|
|
768
|
+
updateId: payload.update_id,
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
return new Response("OK", { status: 200 });
|
|
774
|
+
},
|
|
775
|
+
initialize: async (chatInstance) => {
|
|
776
|
+
chat = chatInstance;
|
|
777
|
+
const me = await apiRequest<ZaloGetMeResponse>("getMe");
|
|
778
|
+
botUserId = me?.id;
|
|
779
|
+
logger.info("Zalo adapter initialized.", {
|
|
780
|
+
accountName: me?.account_name,
|
|
781
|
+
botUserId,
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
const runtimeMode = await resolveRuntimeMode();
|
|
785
|
+
if (runtimeMode === "polling") {
|
|
786
|
+
const pollingConfig =
|
|
787
|
+
mode === "auto"
|
|
788
|
+
? { ...config.longPolling, deleteWebhook: false }
|
|
789
|
+
: config.longPolling;
|
|
790
|
+
await startPolling(pollingConfig);
|
|
791
|
+
}
|
|
792
|
+
},
|
|
793
|
+
isDM: () => true,
|
|
794
|
+
name: "zalo",
|
|
795
|
+
openDM: async (userId) => encodeThreadId({ chatId: userId }),
|
|
796
|
+
parseMessage,
|
|
797
|
+
persistThreadHistory: true,
|
|
798
|
+
postMessage: async (threadId, message) => {
|
|
799
|
+
const { chatId } = decodeThreadId(threadId);
|
|
800
|
+
return sendMessages(threadId, chatId, renderPostableMessage(message));
|
|
801
|
+
},
|
|
802
|
+
removeReaction: async () => {
|
|
803
|
+
throw new Error("Zalo does not support reactions.");
|
|
804
|
+
},
|
|
805
|
+
renderFormatted: (content: FormattedContent) => toPlainText(content),
|
|
806
|
+
startTyping: async (threadId) => {
|
|
807
|
+
const { chatId } = decodeThreadId(threadId);
|
|
808
|
+
await apiRequest("sendChatAction", {
|
|
809
|
+
action: "typing",
|
|
810
|
+
chat_id: chatId,
|
|
811
|
+
});
|
|
812
|
+
},
|
|
813
|
+
stream: async (
|
|
814
|
+
threadId,
|
|
815
|
+
textStream: AsyncIterable<string | StreamChunk>,
|
|
816
|
+
_options?: StreamOptions,
|
|
817
|
+
): Promise<RawMessage<ZaloRawMessage>> => {
|
|
818
|
+
let accumulated = "";
|
|
819
|
+
|
|
820
|
+
for await (const chunk of textStream) {
|
|
821
|
+
if (typeof chunk === "string") {
|
|
822
|
+
accumulated += chunk;
|
|
823
|
+
} else if (chunk.type === "markdown_text") {
|
|
824
|
+
accumulated += chunk.text;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
return adapter.postMessage(threadId, { markdown: accumulated });
|
|
829
|
+
},
|
|
830
|
+
userName,
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
return adapter;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
export { createZaloAdapter };
|