@xbibzlibrary/telebibz 0.3.2 → 0.4.2
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 +34 -1
- package/README.id.md +65 -5
- package/README.md +46 -6
- package/README.zh-CN.md +75 -15
- package/dist/src/api/client.d.ts.map +1 -1
- package/dist/src/api/client.js +12 -0
- package/dist/src/api/client.js.map +1 -1
- package/dist/src/context/context.d.ts +46 -1
- package/dist/src/context/context.d.ts.map +1 -1
- package/dist/src/context/context.js +107 -0
- package/dist/src/context/context.js.map +1 -1
- package/dist/src/core/bot.d.ts +48 -3
- package/dist/src/core/bot.d.ts.map +1 -1
- package/dist/src/core/bot.js +95 -7
- package/dist/src/core/bot.js.map +1 -1
- package/dist/src/core/webhook-reply.d.ts +34 -0
- package/dist/src/core/webhook-reply.d.ts.map +1 -0
- package/dist/src/core/webhook-reply.js +37 -0
- package/dist/src/core/webhook-reply.js.map +1 -0
- package/dist/src/index.d.ts +2 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/observability/logger.d.ts.map +1 -1
- package/dist/src/observability/logger.js +4 -1
- package/dist/src/observability/logger.js.map +1 -1
- package/dist/src/webhook/handler.d.ts +8 -0
- package/dist/src/webhook/handler.d.ts.map +1 -1
- package/dist/src/webhook/handler.js +26 -6
- package/dist/src/webhook/handler.js.map +1 -1
- package/dist-cjs/src/api/client.js +12 -0
- package/dist-cjs/src/context/context.js +107 -0
- package/dist-cjs/src/core/bot.js +97 -8
- package/dist-cjs/src/core/webhook-reply.js +42 -0
- package/dist-cjs/src/index.js +7 -1
- package/dist-cjs/src/observability/logger.js +4 -1
- package/dist-cjs/src/webhook/handler.js +26 -6
- package/docs/API.id.md +48 -4
- package/docs/API.md +48 -4
- package/docs/API.zh-CN.md +48 -4
- package/package.json +1 -1
package/dist-cjs/src/core/bot.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.Bot = void 0;
|
|
3
|
+
exports.Bot = exports.UpdateTimeoutError = void 0;
|
|
4
4
|
const client_js_1 = require("../api/client.js");
|
|
5
5
|
const transport_js_1 = require("../api/transport.js");
|
|
6
6
|
const context_js_1 = require("../context/context.js");
|
|
@@ -14,6 +14,18 @@ const terminal_js_1 = require("../branding/terminal.js");
|
|
|
14
14
|
const conversation_js_1 = require("../state/conversation.js");
|
|
15
15
|
const broadcast_js_1 = require("../broadcast/broadcast.js");
|
|
16
16
|
const concurrency_js_1 = require("../utils/concurrency.js");
|
|
17
|
+
const webhook_reply_js_1 = require("./webhook-reply.js");
|
|
18
|
+
const node_async_hooks_1 = require("node:async_hooks");
|
|
19
|
+
/** Thrown when a single update exceeds `handlerTimeout`; the handler keeps running in the background. */
|
|
20
|
+
class UpdateTimeoutError extends Error {
|
|
21
|
+
name = "UpdateTimeoutError";
|
|
22
|
+
updateId;
|
|
23
|
+
constructor(updateId, timeoutMs) {
|
|
24
|
+
super(`Update ${updateId} handler timed out after ${timeoutMs}ms`);
|
|
25
|
+
this.updateId = updateId;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
exports.UpdateTimeoutError = UpdateTimeoutError;
|
|
17
29
|
class Bot {
|
|
18
30
|
api;
|
|
19
31
|
router;
|
|
@@ -31,8 +43,18 @@ class Bot {
|
|
|
31
43
|
me;
|
|
32
44
|
/** Caps how many updates run at once (default: unlimited). */
|
|
33
45
|
updateLimiter;
|
|
46
|
+
/** Per-update timeout in ms; `Infinity` disables. */
|
|
47
|
+
handlerTimeoutMs;
|
|
48
|
+
/** Context class instantiated per update (default `Context`). */
|
|
49
|
+
contextType;
|
|
34
50
|
/** Per-chat processing chains: parallel across chats, ordered within a chat. */
|
|
35
51
|
chatChains = new Map();
|
|
52
|
+
/**
|
|
53
|
+
* Identifies the update chain executing in the current async context so
|
|
54
|
+
* `stop()` called from inside a handler never deadlocks waiting on itself
|
|
55
|
+
* (Telegraf allows `bot.stop()` from within a handler).
|
|
56
|
+
*/
|
|
57
|
+
currentUpdateChain = new node_async_hooks_1.AsyncLocalStorage();
|
|
36
58
|
/** Memoized init so a burst of updates triggers exactly one getMe call. */
|
|
37
59
|
initOnce;
|
|
38
60
|
brandingEnabled;
|
|
@@ -62,6 +84,8 @@ class Bot {
|
|
|
62
84
|
});
|
|
63
85
|
this.plugins = new plugin_js_1.PluginManager(this);
|
|
64
86
|
this.updateLimiter = new concurrency_js_1.Limiter(config.updates?.concurrency ?? Infinity);
|
|
87
|
+
this.handlerTimeoutMs = config.handlerTimeout ?? 90_000;
|
|
88
|
+
this.contextType = config.contextType ?? context_js_1.Context;
|
|
65
89
|
this.pollingOptions = {
|
|
66
90
|
allowedUpdates: config.polling?.allowedUpdates ?? [],
|
|
67
91
|
limit: config.polling?.limit ?? 100,
|
|
@@ -84,6 +108,8 @@ class Bot {
|
|
|
84
108
|
use(...middleware) { this.middlewares.push(...middleware); return this; }
|
|
85
109
|
command(name, handler) { this.router.command(name, handler); return this; }
|
|
86
110
|
callback(pattern, handler) { this.router.callback(pattern, handler); return this; }
|
|
111
|
+
/** Telegraf-style alias for `callback()`: registers a handler for callback-query button data. */
|
|
112
|
+
action(pattern, handler) { this.router.callback(pattern, handler); return this; }
|
|
87
113
|
onText(text, handler) { this.router.text(text, handler); return this; }
|
|
88
114
|
onRegex(expression, handler) { this.router.regex(expression, handler); return this; }
|
|
89
115
|
/** Registers a handler for update types: `bot.on("message:photo", handler)` or `bot.on(["message:text", "callback_query:data"], handler)`. */
|
|
@@ -155,7 +181,7 @@ class Bot {
|
|
|
155
181
|
throw error;
|
|
156
182
|
}
|
|
157
183
|
}
|
|
158
|
-
async start() { await this.launch({ mode: "polling" }); }
|
|
184
|
+
async start(options = {}) { await this.launch({ mode: "polling", ...options }); }
|
|
159
185
|
async launch(options = { mode: "polling" }) {
|
|
160
186
|
if (options.mode !== "polling")
|
|
161
187
|
throw new Error("Use createWebhookHandler() for webhook mode.");
|
|
@@ -180,6 +206,11 @@ class Bot {
|
|
|
180
206
|
}
|
|
181
207
|
if (this.statusValue === "running")
|
|
182
208
|
return;
|
|
209
|
+
if (options.dropPendingUpdates) {
|
|
210
|
+
// Same mechanism Telegraf uses: drop everything Telegram is holding for
|
|
211
|
+
// this bot before the first getUpdates call.
|
|
212
|
+
await this.api.call("deleteWebhook", { drop_pending_updates: true });
|
|
213
|
+
}
|
|
183
214
|
this.statusValue = "starting";
|
|
184
215
|
this.startupLog("bot.starting", { mode: options.mode });
|
|
185
216
|
await this.events.emit("bot:starting", { bot: this });
|
|
@@ -197,6 +228,9 @@ class Bot {
|
|
|
197
228
|
await this.events.emit("bot:stopping", { bot: this });
|
|
198
229
|
this.pollingAbort?.abort();
|
|
199
230
|
this.pollingAbort = undefined;
|
|
231
|
+
// Graceful shutdown: wait for updates still being processed (bounded by
|
|
232
|
+
// handlerTimeout) so sessions finish writing before plugins are disposed.
|
|
233
|
+
await this.drainInFlightUpdates();
|
|
200
234
|
await this.plugins.stop();
|
|
201
235
|
await this.plugins.dispose();
|
|
202
236
|
this.statusValue = "stopped";
|
|
@@ -206,6 +240,17 @@ class Bot {
|
|
|
206
240
|
this.logger.info("bot.stopped");
|
|
207
241
|
await this.events.emit("bot:stopped", { bot: this });
|
|
208
242
|
}
|
|
243
|
+
/** Waits (bounded by `handlerTimeout`) for updates that are still processing. */
|
|
244
|
+
async drainInFlightUpdates() {
|
|
245
|
+
// A handler calling stop() must not wait on its own chain (deadlock); it
|
|
246
|
+
// keeps running in the background, exactly like Telegraf.
|
|
247
|
+
const current = this.currentUpdateChain.getStore();
|
|
248
|
+
const inFlight = [...this.chatChains.values()].filter((chain) => chain !== current);
|
|
249
|
+
if (inFlight.length === 0)
|
|
250
|
+
return;
|
|
251
|
+
this.logger.info("bot.draining_updates", { inFlight: inFlight.length });
|
|
252
|
+
await this.withTimeout(Promise.allSettled(inFlight), this.handlerTimeoutMs, -1);
|
|
253
|
+
}
|
|
209
254
|
async restart() { await this.stop(); await this.start(); }
|
|
210
255
|
async health() {
|
|
211
256
|
try {
|
|
@@ -224,18 +269,62 @@ class Bot {
|
|
|
224
269
|
* updates for the same chat are processed strictly in arrival order so
|
|
225
270
|
* sessions, wizards, and conversations never interleave. Rejects for this
|
|
226
271
|
* update's failure (as before) without affecting other updates.
|
|
272
|
+
*
|
|
273
|
+
* `options.webhookReply` installs a Telegraf-style responder: the first
|
|
274
|
+
* outgoing API call during this update is answered through the webhook HTTP
|
|
275
|
+
* response instead of a separate request, and resolves with `true` because
|
|
276
|
+
* Telegram never sends the method result back to a webhook response.
|
|
227
277
|
*/
|
|
228
|
-
async handleUpdate(update) {
|
|
278
|
+
async handleUpdate(update, options = {}) {
|
|
229
279
|
const key = this.conversationKey(update);
|
|
230
280
|
const previous = this.chatChains.get(key);
|
|
231
|
-
const
|
|
281
|
+
const execute = options.webhookReply === undefined
|
|
282
|
+
? () => this.processUpdate(update)
|
|
283
|
+
: () => (0, webhook_reply_js_1.runWithWebhookReply)(options.webhookReply, () => this.processUpdate(update));
|
|
284
|
+
// The chain waits for the real completion so same-chat ordering holds
|
|
285
|
+
// even when the caller-facing await below is released by a timeout. The
|
|
286
|
+
// chain is installed as the current AsyncLocalStorage value so a handler
|
|
287
|
+
// calling bot.stop() is excluded from the drain set.
|
|
288
|
+
const run = (previous ?? Promise.resolve()).catch(() => undefined).then(() => this.currentUpdateChain.run(tail, execute));
|
|
232
289
|
const tail = run.then(() => undefined, () => undefined);
|
|
233
290
|
this.chatChains.set(key, tail);
|
|
234
291
|
void tail.then(() => {
|
|
235
292
|
if (this.chatChains.get(key) === tail)
|
|
236
293
|
this.chatChains.delete(key);
|
|
237
294
|
});
|
|
238
|
-
|
|
295
|
+
try {
|
|
296
|
+
await this.withTimeout(run, this.handlerTimeoutMs, update.update_id);
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
if (!(error instanceof UpdateTimeoutError))
|
|
300
|
+
throw error;
|
|
301
|
+
// A timed-out update follows the same error flow as a failed handler;
|
|
302
|
+
// the handler itself keeps running to completion in the background.
|
|
303
|
+
this.logger.error("update.handler_timeout", { updateId: update.update_id, timeoutMs: this.handlerTimeoutMs });
|
|
304
|
+
await this.events.emit("update:error", { update, error });
|
|
305
|
+
await this.events.emit("bot:error", { bot: this, error });
|
|
306
|
+
if (this.errorHandler) {
|
|
307
|
+
await this.errorHandler(error, new context_js_1.Context({ update, api: this.api, session: {}, services: this.services, me: this.me }));
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
throw error;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
/** Rejects with `UpdateTimeoutError` after `timeoutMs` unless `promise` settles first; `timeoutMs <= 0` or a non-finite value disables the guard. */
|
|
314
|
+
async withTimeout(promise, timeoutMs, updateId) {
|
|
315
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
|
|
316
|
+
return promise;
|
|
317
|
+
let timer;
|
|
318
|
+
const timeout = new Promise((_, reject) => {
|
|
319
|
+
timer = setTimeout(() => reject(new UpdateTimeoutError(updateId, timeoutMs)), timeoutMs);
|
|
320
|
+
});
|
|
321
|
+
try {
|
|
322
|
+
return await Promise.race([promise, timeout]);
|
|
323
|
+
}
|
|
324
|
+
finally {
|
|
325
|
+
if (timer !== undefined)
|
|
326
|
+
clearTimeout(timer);
|
|
327
|
+
}
|
|
239
328
|
}
|
|
240
329
|
/**
|
|
241
330
|
* Handles a whole batch of updates at once: every chat in the batch is
|
|
@@ -265,12 +354,12 @@ class Bot {
|
|
|
265
354
|
async broadcast(chatIds, send, options) {
|
|
266
355
|
return (0, broadcast_js_1.runBroadcast)(chatIds, send, options);
|
|
267
356
|
}
|
|
268
|
-
/** Runs init() once even when many updates arrive concurrently. */
|
|
357
|
+
/** Runs init() once even when many updates arrive concurrently; never claims a webhook reply slot. */
|
|
269
358
|
ensureInitialized() {
|
|
270
359
|
if (this.me)
|
|
271
360
|
return Promise.resolve();
|
|
272
361
|
if (!this.initOnce) {
|
|
273
|
-
this.initOnce = this.init().then(() => { this.initOnce = undefined; }, (error) => {
|
|
362
|
+
this.initOnce = (0, webhook_reply_js_1.runWithoutWebhookReply)(() => this.init()).then(() => { this.initOnce = undefined; }, (error) => {
|
|
274
363
|
this.initOnce = undefined;
|
|
275
364
|
throw error;
|
|
276
365
|
});
|
|
@@ -296,7 +385,7 @@ class Bot {
|
|
|
296
385
|
await this.ensureInitialized();
|
|
297
386
|
if (!this.me)
|
|
298
387
|
return;
|
|
299
|
-
const ctx = new
|
|
388
|
+
const ctx = new this.contextType({ update, api: this.api, session, services: this.services, me: this.me });
|
|
300
389
|
await this.events.emit("update", { update });
|
|
301
390
|
if (message) {
|
|
302
391
|
await this.events.emit("message", { message });
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runWithWebhookReply = runWithWebhookReply;
|
|
4
|
+
exports.runWithoutWebhookReply = runWithoutWebhookReply;
|
|
5
|
+
exports.claimWebhookReply = claimWebhookReply;
|
|
6
|
+
exports.hasWebhookReply = hasWebhookReply;
|
|
7
|
+
const node_async_hooks_1 = require("node:async_hooks");
|
|
8
|
+
const storage = new node_async_hooks_1.AsyncLocalStorage();
|
|
9
|
+
/** Runs `fn` with a webhook reply sink active for every API call inside it. */
|
|
10
|
+
function runWithWebhookReply(sink, fn) {
|
|
11
|
+
return storage.run({ sink, claimed: false, suppressed: false }, fn);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Runs `fn` with webhook replies suppressed (library-internal calls such as
|
|
15
|
+
* the lazy `getMe` initialization must never claim the response slot).
|
|
16
|
+
*/
|
|
17
|
+
function runWithoutWebhookReply(fn) {
|
|
18
|
+
const state = storage.getStore();
|
|
19
|
+
if (!state)
|
|
20
|
+
return fn();
|
|
21
|
+
return storage.run({ ...state, suppressed: true }, fn);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Claims the webhook reply for `method`/`payload` if a sink is active and not
|
|
25
|
+
* yet used. Returns the synthesized transport response the caller should
|
|
26
|
+
* resolve with, or `undefined` when the call must go through the transport.
|
|
27
|
+
*/
|
|
28
|
+
function claimWebhookReply(method, payload) {
|
|
29
|
+
const state = storage.getStore();
|
|
30
|
+
if (!state || state.claimed || state.suppressed)
|
|
31
|
+
return undefined;
|
|
32
|
+
state.claimed = true;
|
|
33
|
+
state.sink({ method, ...(payload ?? {}) });
|
|
34
|
+
// Telegram never sends the method result back to the webhook response, so
|
|
35
|
+
// the caller resolves with a synthetic success (same as Telegraf).
|
|
36
|
+
return { status: 200, data: { ok: true, result: true } };
|
|
37
|
+
}
|
|
38
|
+
/** True while an unclaimed webhook reply is available (diagnostics/testing). */
|
|
39
|
+
function hasWebhookReply() {
|
|
40
|
+
const state = storage.getStore();
|
|
41
|
+
return state !== undefined && !state.claimed;
|
|
42
|
+
}
|
package/dist-cjs/src/index.js
CHANGED
|
@@ -14,8 +14,9 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.printTerminalBranding = exports.buildTerminalBranding = void 0;
|
|
17
|
+
exports.startTeleBibzBanner = exports.runStartupSequence = exports.printStatusLine = exports.paintRainbow = exports.printTeleBibzBanner = exports.printTerminalBranding = exports.buildTerminalBranding = void 0;
|
|
18
18
|
__exportStar(require("./core/bot.js"), exports);
|
|
19
|
+
__exportStar(require("./core/webhook-reply.js"), exports);
|
|
19
20
|
__exportStar(require("./core/events.js"), exports);
|
|
20
21
|
__exportStar(require("./api/index.js"), exports);
|
|
21
22
|
__exportStar(require("./context/context.js"), exports);
|
|
@@ -37,4 +38,9 @@ __exportStar(require("./telegram-features.js"), exports);
|
|
|
37
38
|
var terminal_js_1 = require("./branding/terminal.js");
|
|
38
39
|
Object.defineProperty(exports, "buildTerminalBranding", { enumerable: true, get: function () { return terminal_js_1.buildTerminalBranding; } });
|
|
39
40
|
Object.defineProperty(exports, "printTerminalBranding", { enumerable: true, get: function () { return terminal_js_1.printTerminalBranding; } });
|
|
41
|
+
Object.defineProperty(exports, "printTeleBibzBanner", { enumerable: true, get: function () { return terminal_js_1.printTeleBibzBanner; } });
|
|
42
|
+
Object.defineProperty(exports, "paintRainbow", { enumerable: true, get: function () { return terminal_js_1.paintRainbow; } });
|
|
43
|
+
Object.defineProperty(exports, "printStatusLine", { enumerable: true, get: function () { return terminal_js_1.printStatusLine; } });
|
|
44
|
+
Object.defineProperty(exports, "runStartupSequence", { enumerable: true, get: function () { return terminal_js_1.runStartupSequence; } });
|
|
45
|
+
Object.defineProperty(exports, "startTeleBibzBanner", { enumerable: true, get: function () { return terminal_js_1.startTeleBibzBanner; } });
|
|
40
46
|
__exportStar(require("./observability/logger.js"), exports);
|
|
@@ -6,7 +6,10 @@ exports.formatLocalStamp = formatLocalStamp;
|
|
|
6
6
|
exports.describeIncomingUpdate = describeIncomingUpdate;
|
|
7
7
|
exports.summarizeUpdate = summarizeUpdate;
|
|
8
8
|
exports.createLogger = createLogger;
|
|
9
|
-
|
|
9
|
+
// Higher number = more verbose. `silent` must sort BELOW `error` so that every
|
|
10
|
+
// message level is filtered out (a value above all others would let every
|
|
11
|
+
// message through, including errors).
|
|
12
|
+
const priorities = { silent: -1, error: 0, warn: 1, info: 2, debug: 3, trace: 4 };
|
|
10
13
|
const defaultRedactKeys = ["token", "secret", "password", "authorization", "cookie", "private_key", "api_key", "npm_token", "bot_token"];
|
|
11
14
|
const ANSI = {
|
|
12
15
|
reset: "\u001b[0m",
|
|
@@ -3,6 +3,19 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.createWebhookHandler = createWebhookHandler;
|
|
4
4
|
exports.webhookCallback = webhookCallback;
|
|
5
5
|
const node_crypto_1 = require("node:crypto");
|
|
6
|
+
/** Runs the update, optionally claiming the webhook response for the first API call. */
|
|
7
|
+
async function processWithOptionalReply(bot, update, sink) {
|
|
8
|
+
if (sink === undefined) {
|
|
9
|
+
await bot.handleUpdate(update);
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
await bot.handleUpdate(update, { webhookReply: sink });
|
|
13
|
+
}
|
|
14
|
+
function replyResponse(payload) {
|
|
15
|
+
if (payload !== undefined)
|
|
16
|
+
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
|
|
17
|
+
return new Response("OK", { status: 200 });
|
|
18
|
+
}
|
|
6
19
|
function createWebhookHandler(bot, options = {}) {
|
|
7
20
|
const maxBodyBytes = options.maxBodyBytes ?? 1_048_576;
|
|
8
21
|
return async (request) => {
|
|
@@ -23,8 +36,10 @@ function createWebhookHandler(bot, options = {}) {
|
|
|
23
36
|
const update = JSON.parse(new TextDecoder().decode(raw));
|
|
24
37
|
if (!Number.isInteger(update.update_id))
|
|
25
38
|
return new Response("Bad Request", { status: 400 });
|
|
26
|
-
|
|
27
|
-
|
|
39
|
+
let replyPayload;
|
|
40
|
+
const sink = options.webhookReply === true ? (payload) => { replyPayload = payload; } : undefined;
|
|
41
|
+
await processWithOptionalReply(bot, update, sink);
|
|
42
|
+
return replyResponse(replyPayload);
|
|
28
43
|
}
|
|
29
44
|
catch (error) {
|
|
30
45
|
await options.onError?.(error);
|
|
@@ -37,9 +52,9 @@ function webhookCallback(bot, _framework = "express", options = {}) {
|
|
|
37
52
|
return async (req, res) => {
|
|
38
53
|
const rawReq = req;
|
|
39
54
|
const rawRes = res;
|
|
40
|
-
const sendResponse = (status, message) => {
|
|
55
|
+
const sendResponse = (status, message, contentType = "text/plain") => {
|
|
41
56
|
if (rawRes && typeof rawRes.writeHead === "function" && typeof rawRes.end === "function") {
|
|
42
|
-
rawRes.writeHead(status, { "Content-Type":
|
|
57
|
+
rawRes.writeHead(status, { "Content-Type": contentType });
|
|
43
58
|
rawRes.end(message);
|
|
44
59
|
return;
|
|
45
60
|
}
|
|
@@ -99,8 +114,13 @@ function webhookCallback(bot, _framework = "express", options = {}) {
|
|
|
99
114
|
sendResponse(400, "Bad Request");
|
|
100
115
|
return;
|
|
101
116
|
}
|
|
102
|
-
|
|
103
|
-
|
|
117
|
+
let replyPayload;
|
|
118
|
+
const sink = options.webhookReply === true ? (payload) => { replyPayload = payload; } : undefined;
|
|
119
|
+
await processWithOptionalReply(bot, update, sink);
|
|
120
|
+
if (replyPayload !== undefined)
|
|
121
|
+
sendResponse(200, JSON.stringify(replyPayload), "application/json");
|
|
122
|
+
else
|
|
123
|
+
sendResponse(200, "OK");
|
|
104
124
|
}
|
|
105
125
|
catch (error) {
|
|
106
126
|
await options.onError?.(error);
|
package/docs/API.id.md
CHANGED
|
@@ -75,6 +75,8 @@ type BotStatus =
|
|
|
75
75
|
| `polling.retryDelayMs` | `number` | `500` | Delay awal ketika polling gagal. |
|
|
76
76
|
| `polling.maxRetryDelayMs` | `number` | `30000` | Batas maksimum delay reconnect. |
|
|
77
77
|
| `updates.concurrency` | `number` | `Infinity` | Batas jumlah update yang diproses bersamaan. Update selalu berjalan paralel antar chat dan tetap berurutan di dalam satu chat, sehingga burst 1000+ pesan tertangani sekaligus. |
|
|
78
|
+
| `handlerTimeout` | `number` | `90000` | Timeout pemrosesan per update dalam ms (`Infinity` untuk menonaktifkan). Saat timeout, alur error update berjalan (`update:error`, `bot:error`, boundary `catch()`) dan `handleUpdate()` melempar `UpdateTimeoutError`, sementara handler tetap berjalan sampai selesai di background. |
|
|
79
|
+
| `contextType` | `new (options: ContextOptions<S>) => Context<S>` | `Context` | Subclass `Context` kustom yang diinstansiasi untuk setiap update (`contextType` milik Telegraf). |
|
|
78
80
|
|
|
79
81
|
### Konstruktor `Bot`
|
|
80
82
|
|
|
@@ -199,10 +201,11 @@ launch(options?: {
|
|
|
199
201
|
mode: "polling";
|
|
200
202
|
timeout?: number;
|
|
201
203
|
allowedUpdates?: string[];
|
|
204
|
+
dropPendingUpdates?: boolean;
|
|
202
205
|
}): Promise<void>
|
|
203
206
|
```
|
|
204
207
|
|
|
205
|
-
Menjalankan bot dalam mode polling. Saat mulai, lifecycle berpindah melalui `starting` lalu `running`, kemudian loop `getUpdates()` memproses setiap batch update secara konkuren: update dari chat berbeda berjalan paralel, sedangkan update dari chat yang sama menjaga urutan kedatangannya. Kegagalan polling memancarkan `polling:reconnect` dan menggunakan backoff eksponensial.
|
|
208
|
+
Menjalankan bot dalam mode polling. Saat mulai, lifecycle berpindah melalui `starting` lalu `running`, kemudian loop `getUpdates()` memproses setiap batch update secara konkuren: update dari chat berbeda berjalan paralel, sedangkan update dari chat yang sama menjaga urutan kedatangannya. Kegagalan polling memancarkan `polling:reconnect` dan menggunakan backoff eksponensial. `dropPendingUpdates: true` (juga tersedia di `bot.start()`) membuang semua update yang ditahan Telegram sebelum panggilan `getUpdates` pertama, memakai mekanisme `deleteWebhook({ drop_pending_updates: true })` yang sama dengan Telegraf.
|
|
206
209
|
|
|
207
210
|
Mode selain `"polling"` melempar error dan menyarankan penggunaan `createWebhookHandler()` untuk webhook.
|
|
208
211
|
|
|
@@ -274,12 +277,14 @@ Jalan pintas ke `deleteMyCommands`.
|
|
|
274
277
|
### `bot.handleUpdate(update)`
|
|
275
278
|
|
|
276
279
|
```ts
|
|
277
|
-
handleUpdate(update: Update): Promise<void>
|
|
280
|
+
handleUpdate(update: Update, options?: { webhookReply?: WebhookReplySink }): Promise<void>
|
|
278
281
|
```
|
|
279
282
|
|
|
280
|
-
Memproses satu update secara manual. Method menentukan kunci session dari `chat.id` dan `from.id`, membuat `Context
|
|
283
|
+
Memproses satu update secara manual. Method menentukan kunci session dari `chat.id` dan `from.id`, membuat `Context` (dari `contextType` yang dikonfigurasi), memancarkan event `update` dan `message`, menjalankan middleware lalu router, dan menyimpan session setelah pipeline selesai.
|
|
281
284
|
|
|
282
|
-
Update dari chat berbeda diproses paralel; update dari chat yang sama diserialisasi sesuai urutan kedatangan, sehingga session, wizard, dan conversation tidak pernah saling tumpang tindih dan penulisan session tidak pernah hilang. Burst update konkuren hanya memicu satu inisialisasi `getMe`.
|
|
285
|
+
Update dari chat berbeda diproses paralel; update dari chat yang sama diserialisasi sesuai urutan kedatangan, sehingga session, wizard, dan conversation tidak pernah saling tumpang tindih dan penulisan session tidak pernah hilang. Burst update konkuren hanya memicu satu inisialisasi `getMe`. Seluruh proses per update dijaga `handlerTimeout` (default 90 detik, sama dengan Telegraf): saat timeout, error mengalir lewat `update:error`/`bot:error` dan boundary `catch()`, dan `handleUpdate()` melempar `UpdateTimeoutError` sementara handler tetap berjalan di background.
|
|
286
|
+
|
|
287
|
+
`options.webhookReply` memasang responder ala Telegraf: panggilan API keluar pertama selama update ini dijawab lewat respons HTTP webhook, bukan request terpisah, dan resolve dengan `true` (Telegram tidak pernah mengirim hasil method kembali ke respons webhook).
|
|
283
288
|
|
|
284
289
|
Error pipeline mengubah status bot menjadi `error`, memancarkan `bot:error`, lalu dilempar kembali.
|
|
285
290
|
|
|
@@ -327,6 +332,25 @@ for (const failure of report.failures) console.warn(`Gagal: ${failure.chatId}
|
|
|
327
332
|
| `BroadcastReport.durationMs` | `number` | — | Durasi total sesi broadcast. |
|
|
328
333
|
| `BroadcastReport.failures` | `BroadcastFailure[]` | — | Catatan per chat `{ chatId, attempts, error, errorKind }`. |
|
|
329
334
|
|
|
335
|
+
### `UpdateTimeoutError` dan helper webhook-reply
|
|
336
|
+
|
|
337
|
+
```ts
|
|
338
|
+
class UpdateTimeoutError extends Error {
|
|
339
|
+
readonly name = "UpdateTimeoutError";
|
|
340
|
+
readonly updateId: number;
|
|
341
|
+
}
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
Dilempar oleh `handleUpdate()` ketika satu update melebihi `handlerTimeout`. Handler itu sendiri tetap berjalan; error juga mengalir lewat `update:error`, `bot:error`, dan boundary `catch()`.
|
|
345
|
+
|
|
346
|
+
```ts
|
|
347
|
+
type WebhookReplySink = (payload: Record<string, unknown>) => void;
|
|
348
|
+
runWithWebhookReply(sink, fn): Promise<T> // memasang responder untuk semua panggilan API di dalam fn
|
|
349
|
+
runWithoutWebhookReply(fn): Promise<T> // panggilan internal library yang tidak pernah mengklaim slot
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
Diekspor agar webhook server kustom bisa memasang webhook reply dengan cara yang sama seperti `createWebhookHandler`.
|
|
353
|
+
|
|
330
354
|
### Contoh bot minimal
|
|
331
355
|
|
|
332
356
|
```ts
|
|
@@ -736,6 +760,23 @@ new Context<S>(options: ContextOptions<S>): Context<S>
|
|
|
736
760
|
|
|
737
761
|
Semua pengirim `replyWith*` menerima parameter native Telegram sebagai `extra` dan otomatis me-quote message yang masuk. `reply_parameters` pada `extra` digabung dengan `message_id` otomatis, bukan menggantikannya. `reply`, `send`, `getChat`, dan beberapa helper lain melempar error ketika update tidak memiliki chat yang diperlukan. `edit` dan `delete` membutuhkan chat serta message.
|
|
738
762
|
|
|
763
|
+
### Method admin, chat, dan forum pada Context (paritas penuh Telegraf)
|
|
764
|
+
|
|
765
|
+
Semua method di bawah beraksi pada chat update (`ctx.chat`) dan menerima parameter native Telegram lewat `extra`; semuanya melempar error jelas bila update tidak memiliki chat. Gunakan `ctx.api.methods.*` untuk menargetkan chat lain.
|
|
766
|
+
|
|
767
|
+
| Grup | Method |
|
|
768
|
+
|---|---|
|
|
769
|
+
| Moderasi | `banChatMember(userId, untilDate?, extra?)`, `unbanChatMember(userId, onlyIfBanned?, extra?)`, `restrictChatMember(userId, permissions, untilDate?, extra?)`, `promoteChatMember(userId, extra?)`, `banChatSenderChat(senderChatId, extra?)`, `unbanChatSenderChat(senderChatId, extra?)` |
|
|
770
|
+
| Manajemen chat | `setChatTitle(title)`, `setChatDescription(description?)`, `setChatPhoto(photo)`, `deleteChatPhoto()`, `setChatPermissions(permissions, extra?)`, `leaveChat()`, `unpinAllChatMessages(extra?)`, `setChatStickerSet(name)`, `deleteChatStickerSet()` |
|
|
771
|
+
| Info chat & member | `getChatAdministrators(): Promise<ChatMember[]>`, `getChatMemberCount(): Promise<number>`, `getChatMember(userId): Promise<ChatMember>` |
|
|
772
|
+
| Invite link | `exportChatInviteLink(): Promise<string>`, `createChatInviteLink(extra?)`, `editChatInviteLink(inviteLink, extra?)`, `revokeChatInviteLink(inviteLink)` |
|
|
773
|
+
| Join request | `approveChatJoinRequest(userId)`, `declineChatJoinRequest(userId)` |
|
|
774
|
+
| Poll & live location | `replyWithQuiz(question, options, extra?)` (sendPoll dengan `type: "quiz"`), `stopPoll(messageId?, extra?)`, `editMessageLiveLocation(latitude?, longitude?, extra?)`, `stopMessageLiveLocation(extra?)` |
|
|
775
|
+
| Game & pembayaran | `replyWithGame(gameShortName, extra?)`, `setGameScore(userId, score, extra?)`, `getGameHighScores(userId?, extra?)`, `replyWithInvoice(title, description, payload, providerToken, currency, prices, extra?)` |
|
|
776
|
+
| Forum topic | `createForumTopic(name, extra?)`, `editForumTopic(extra?)`, `closeForumTopic(threadId?)`, `reopenForumTopic(threadId?)`, `deleteForumTopic(threadId?)`, `unpinAllForumTopicMessages(threadId?)`, `getForumTopicIconStickers()`, `editGeneralForumTopic(name)`, `closeGeneralForumTopic()`, `reopenGeneralForumTopic()`, `hideGeneralForumTopic()`, `unhideGeneralForumTopic()` |
|
|
777
|
+
|
|
778
|
+
`threadId` default ke `message_thread_id` message context. `replyWithQuiz`, `replyWithGame`, dan `replyWithInvoice` me-quote message masuk seperti semua pengirim `replyWith*`.
|
|
779
|
+
|
|
739
780
|
---
|
|
740
781
|
|
|
741
782
|
## 5. Middleware dan router
|
|
@@ -1131,9 +1172,12 @@ interface WebhookOptions {
|
|
|
1131
1172
|
secretToken?: string;
|
|
1132
1173
|
maxBodyBytes?: number;
|
|
1133
1174
|
onError?: (error: unknown) => void | Promise<void>;
|
|
1175
|
+
webhookReply?: boolean;
|
|
1134
1176
|
}
|
|
1135
1177
|
```
|
|
1136
1178
|
|
|
1179
|
+
`webhookReply` (default `false`) mengaktifkan webhook reply ala Telegraf: saat memproses update, panggilan API keluar pertama dijawab lewat respons HTTP webhook itu sendiri (`{"method":"sendMessage", ...}`), sehingga Telegram mengeksekusi method tanpa request kedua. Panggilan itu resolve dengan `true` karena Telegram tidak pernah mengirim hasil method kembali ke respons webhook; setiap panggilan berikutnya tetap lewat transport seperti biasa. Inisialisasi `getMe` malas tidak pernah mengklaim slot tersebut. Berbeda dengan Telegraf, fitur ini opt-in agar deployment webhook yang sudah ada mempertahankan perilakunya persis.
|
|
1180
|
+
|
|
1137
1181
|
### `createWebhookHandler(bot, options?)`
|
|
1138
1182
|
|
|
1139
1183
|
```ts
|
package/docs/API.md
CHANGED
|
@@ -74,6 +74,8 @@ type BotStatus =
|
|
|
74
74
|
| `polling.retryDelayMs` | `number` | `500` | Initial delay when polling fails. |
|
|
75
75
|
| `polling.maxRetryDelayMs` | `number` | `30000` | Maximum reconnect delay. |
|
|
76
76
|
| `updates.concurrency` | `number` | `Infinity` | Cap on how many updates are processed at the same time. Updates always run in parallel across chats and stay ordered within a single chat, so bursts of 1000+ messages are handled at once. |
|
|
77
|
+
| `handlerTimeout` | `number` | `90000` | Per-update processing timeout in ms (`Infinity` disables). On timeout the update error flow runs (`update:error`, `bot:error`, `catch()` boundary) and `handleUpdate()` rejects with `UpdateTimeoutError`, while the handler keeps running to completion in the background. |
|
|
78
|
+
| `contextType` | `new (options: ContextOptions<S>) => Context<S>` | `Context` | Custom `Context` subclass instantiated for every update (Telegraf's `contextType`). |
|
|
77
79
|
|
|
78
80
|
### Constructor `Bot`
|
|
79
81
|
|
|
@@ -216,10 +218,11 @@ launch(options?: {
|
|
|
216
218
|
mode: "polling";
|
|
217
219
|
timeout?: number;
|
|
218
220
|
allowedUpdates?: string[];
|
|
221
|
+
dropPendingUpdates?: boolean;
|
|
219
222
|
}): Promise<void>
|
|
220
223
|
```
|
|
221
224
|
|
|
222
|
-
Runs the bot in polling mode. On start, the lifecycle moves through `starting` to `running`, then the `getUpdates()` loop processes each batch of updates concurrently: updates for different chats run in parallel while updates for the same chat keep their arrival order. Polling failures emit `polling:reconnect` and use exponential backoff.
|
|
225
|
+
Runs the bot in polling mode. On start, the lifecycle moves through `starting` to `running`, then the `getUpdates()` loop processes each batch of updates concurrently: updates for different chats run in parallel while updates for the same chat keep their arrival order. Polling failures emit `polling:reconnect` and use exponential backoff. `dropPendingUpdates: true` (also on `bot.start()`) drops everything Telegram is holding for the bot before the first `getUpdates` call, using the same `deleteWebhook({ drop_pending_updates: true })` mechanism Telegraf uses.
|
|
223
226
|
|
|
224
227
|
Modes other than `"polling"` throw an error and suggest using `createWebhookHandler()` for webhooks.
|
|
225
228
|
|
|
@@ -291,12 +294,14 @@ Shortcut to `deleteMyCommands`.
|
|
|
291
294
|
### `bot.handleUpdate(update)`
|
|
292
295
|
|
|
293
296
|
```ts
|
|
294
|
-
handleUpdate(update: Update): Promise<void>
|
|
297
|
+
handleUpdate(update: Update, options?: { webhookReply?: WebhookReplySink }): Promise<void>
|
|
295
298
|
```
|
|
296
299
|
|
|
297
|
-
Processes a single update manually. The method determines the session key from `chat.id` and `from.id`, creates a `Context
|
|
300
|
+
Processes a single update manually. The method determines the session key from `chat.id` and `from.id`, creates a `Context` (of the configured `contextType`), emits `update` and `message` events, runs middleware then the router, and saves the session after the pipeline completes.
|
|
298
301
|
|
|
299
|
-
Updates for different chats are processed in parallel; updates for the same chat are serialized in arrival order, so sessions, wizards, and conversations never interleave and session writes are never lost. A burst of concurrent updates triggers exactly one `getMe` initialization.
|
|
302
|
+
Updates for different chats are processed in parallel; updates for the same chat are serialized in arrival order, so sessions, wizards, and conversations never interleave and session writes are never lost. A burst of concurrent updates triggers exactly one `getMe` initialization. The whole per-update run is guarded by `handlerTimeout` (default 90s, matching Telegraf): on timeout the error flows through `update:error`/`bot:error` and the `catch()` boundary, and `handleUpdate()` rejects with `UpdateTimeoutError` while the handler keeps running in the background.
|
|
303
|
+
|
|
304
|
+
`options.webhookReply` installs a Telegraf-style responder: the first outgoing API call during this update is answered through the webhook HTTP response instead of a separate request, and resolves with `true` (Telegram never sends the method result back to a webhook response).
|
|
300
305
|
|
|
301
306
|
Pipeline errors set the bot status to `error`, emit `bot:error`, and then rethrow the error.
|
|
302
307
|
|
|
@@ -344,6 +349,25 @@ for (const failure of report.failures) console.warn(`Failed: ${failure.chatId}
|
|
|
344
349
|
| `BroadcastReport.durationMs` | `number` | — | Wall-clock duration of the run. |
|
|
345
350
|
| `BroadcastReport.failures` | `BroadcastFailure[]` | — | Per-chat `{ chatId, attempts, error, errorKind }` records. |
|
|
346
351
|
|
|
352
|
+
### `UpdateTimeoutError` and webhook-reply helpers
|
|
353
|
+
|
|
354
|
+
```ts
|
|
355
|
+
class UpdateTimeoutError extends Error {
|
|
356
|
+
readonly name = "UpdateTimeoutError";
|
|
357
|
+
readonly updateId: number;
|
|
358
|
+
}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
Rejected by `handleUpdate()` when a single update exceeds `handlerTimeout`. The handler itself keeps running; the error also flows through `update:error`, `bot:error`, and the `catch()` boundary.
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
type WebhookReplySink = (payload: Record<string, unknown>) => void;
|
|
365
|
+
runWithWebhookReply(sink, fn): Promise<T> // sets the responder for every API call inside fn
|
|
366
|
+
runWithoutWebhookReply(fn): Promise<T> // library-internal calls that never claim the slot
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
Exported so custom webhook servers can wire webhook replies the same way `createWebhookHandler` does.
|
|
370
|
+
|
|
347
371
|
### Minimal bot example
|
|
348
372
|
|
|
349
373
|
```ts
|
|
@@ -754,6 +778,23 @@ new Context<S>(options: ContextOptions<S>): Context<S>
|
|
|
754
778
|
|
|
755
779
|
All `replyWith*` senders accept the native Telegram parameters as `extra` and automatically quote the incoming message. Passing `reply_parameters` in `extra` merges with the automatic `message_id` instead of replacing it. `reply`, `send`, `getChat`, and some other helpers throw an error when the update does not have the required chat. `edit` and `delete` require both chat and message.
|
|
756
780
|
|
|
781
|
+
### Context admin, chat, and forum methods (full Telegraf parity)
|
|
782
|
+
|
|
783
|
+
Every method below acts on the update's chat (`ctx.chat`) and accepts native Telegram parameters through `extra`; they throw a clear error when the update has no chat. Use `ctx.api.methods.*` to target a different chat.
|
|
784
|
+
|
|
785
|
+
| Group | Methods |
|
|
786
|
+
|---|---|
|
|
787
|
+
| Moderation | `banChatMember(userId, untilDate?, extra?)`, `unbanChatMember(userId, onlyIfBanned?, extra?)`, `restrictChatMember(userId, permissions, untilDate?, extra?)`, `promoteChatMember(userId, extra?)`, `banChatSenderChat(senderChatId, extra?)`, `unbanChatSenderChat(senderChatId, extra?)` |
|
|
788
|
+
| Chat management | `setChatTitle(title)`, `setChatDescription(description?)`, `setChatPhoto(photo)`, `deleteChatPhoto()`, `setChatPermissions(permissions, extra?)`, `leaveChat()`, `unpinAllChatMessages(extra?)`, `setChatStickerSet(name)`, `deleteChatStickerSet()` |
|
|
789
|
+
| Chat & member info | `getChatAdministrators(): Promise<ChatMember[]>`, `getChatMemberCount(): Promise<number>`, `getChatMember(userId): Promise<ChatMember>` |
|
|
790
|
+
| Invite links | `exportChatInviteLink(): Promise<string>`, `createChatInviteLink(extra?)`, `editChatInviteLink(inviteLink, extra?)`, `revokeChatInviteLink(inviteLink)` |
|
|
791
|
+
| Join requests | `approveChatJoinRequest(userId)`, `declineChatJoinRequest(userId)` |
|
|
792
|
+
| Polls & live location | `replyWithQuiz(question, options, extra?)` (sendPoll with `type: "quiz"`), `stopPoll(messageId?, extra?)`, `editMessageLiveLocation(latitude?, longitude?, extra?)`, `stopMessageLiveLocation(extra?)` |
|
|
793
|
+
| Games & payments | `replyWithGame(gameShortName, extra?)`, `setGameScore(userId, score, extra?)`, `getGameHighScores(userId?, extra?)`, `replyWithInvoice(title, description, payload, providerToken, currency, prices, extra?)` |
|
|
794
|
+
| Forum topics | `createForumTopic(name, extra?)`, `editForumTopic(extra?)`, `closeForumTopic(threadId?)`, `reopenForumTopic(threadId?)`, `deleteForumTopic(threadId?)`, `unpinAllForumTopicMessages(threadId?)`, `getForumTopicIconStickers()`, `editGeneralForumTopic(name)`, `closeGeneralForumTopic()`, `reopenGeneralForumTopic()`, `hideGeneralForumTopic()`, `unhideGeneralForumTopic()` |
|
|
795
|
+
|
|
796
|
+
`threadId` defaults to the context message's `message_thread_id`. `replyWithQuiz`, `replyWithGame`, and `replyWithInvoice` quote the incoming message like every `replyWith*` sender.
|
|
797
|
+
|
|
757
798
|
---
|
|
758
799
|
|
|
759
800
|
## 5. Middleware and router
|
|
@@ -1149,9 +1190,12 @@ interface WebhookOptions {
|
|
|
1149
1190
|
secretToken?: string;
|
|
1150
1191
|
maxBodyBytes?: number;
|
|
1151
1192
|
onError?: (error: unknown) => void | Promise<void>;
|
|
1193
|
+
webhookReply?: boolean;
|
|
1152
1194
|
}
|
|
1153
1195
|
```
|
|
1154
1196
|
|
|
1197
|
+
`webhookReply` (default `false`) enables Telegraf-style webhook replies: while handling an update, the first outgoing API call is answered through the webhook HTTP response itself (`{"method":"sendMessage", ...}`), so Telegram executes the method without a second request. That call resolves with `true` because Telegram never sends the method result back to a webhook response; every later call goes through the transport as usual. The lazy `getMe` initialization never claims the slot. Unlike Telegraf, this is opt-in so existing webhook deployments keep their exact behavior.
|
|
1198
|
+
|
|
1155
1199
|
### `createWebhookHandler(bot, options?)`
|
|
1156
1200
|
|
|
1157
1201
|
```ts
|