@xbibzlibrary/telebibz 0.1.19 → 0.3.1
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 +42 -8
- package/CONTRIBUTING.md +2 -2
- package/README.id.md +44 -5
- package/README.md +45 -6
- package/README.zh-CN.md +44 -5
- package/RELEASE_AUTOMATION.md +17 -5
- package/bin/telebibz.mjs +1 -1
- package/dist/src/api/client.d.ts +3 -1
- package/dist/src/api/client.d.ts.map +1 -1
- package/dist/src/api/client.js +3 -1
- package/dist/src/api/client.js.map +1 -1
- package/dist/src/api/transport.d.ts +18 -1
- package/dist/src/api/transport.d.ts.map +1 -1
- package/dist/src/api/transport.js +26 -2
- package/dist/src/api/transport.js.map +1 -1
- package/dist/src/branding/terminal.d.ts +62 -0
- package/dist/src/branding/terminal.d.ts.map +1 -1
- package/dist/src/branding/terminal.js +258 -0
- package/dist/src/branding/terminal.js.map +1 -1
- package/dist/src/broadcast/broadcast.d.ts +50 -0
- package/dist/src/broadcast/broadcast.d.ts.map +1 -0
- package/dist/src/broadcast/broadcast.js +56 -0
- package/dist/src/broadcast/broadcast.js.map +1 -0
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +7 -3
- package/dist/src/cli.js.map +1 -1
- package/dist/src/context/context.d.ts +24 -1
- package/dist/src/context/context.d.ts.map +1 -1
- package/dist/src/context/context.js +102 -8
- package/dist/src/context/context.js.map +1 -1
- package/dist/src/core/bot.d.ts +61 -2
- package/dist/src/core/bot.d.ts.map +1 -1
- package/dist/src/core/bot.js +180 -31
- package/dist/src/core/bot.js.map +1 -1
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/keyboard/index.d.ts +12 -0
- package/dist/src/keyboard/index.d.ts.map +1 -1
- package/dist/src/keyboard/index.js +13 -1
- package/dist/src/keyboard/index.js.map +1 -1
- package/dist/src/observability/logger.d.ts +28 -1
- package/dist/src/observability/logger.d.ts.map +1 -1
- package/dist/src/observability/logger.js +110 -0
- package/dist/src/observability/logger.js.map +1 -1
- package/dist/src/plugins/plugin.d.ts +2 -0
- package/dist/src/plugins/plugin.d.ts.map +1 -1
- package/dist/src/plugins/plugin.js +11 -4
- package/dist/src/plugins/plugin.js.map +1 -1
- package/dist/src/router/router.d.ts +19 -0
- package/dist/src/router/router.d.ts.map +1 -1
- package/dist/src/router/router.js +125 -23
- package/dist/src/router/router.js.map +1 -1
- package/dist/src/state/forms.d.ts +0 -1
- package/dist/src/state/forms.d.ts.map +1 -1
- package/dist/src/state/forms.js +27 -24
- package/dist/src/state/forms.js.map +1 -1
- package/dist/src/storage/storage.d.ts +10 -0
- package/dist/src/storage/storage.d.ts.map +1 -1
- package/dist/src/storage/storage.js +20 -2
- package/dist/src/storage/storage.js.map +1 -1
- package/dist/src/utils/concurrency.d.ts +25 -0
- package/dist/src/utils/concurrency.d.ts.map +1 -0
- package/dist/src/utils/concurrency.js +52 -0
- package/dist/src/utils/concurrency.js.map +1 -0
- package/dist/src/utils/text.d.ts +22 -0
- package/dist/src/utils/text.d.ts.map +1 -1
- package/dist/src/utils/text.js +0 -0
- package/dist/src/utils/text.js.map +1 -1
- package/dist/src/webhook/handler.d.ts +3 -0
- package/dist/src/webhook/handler.d.ts.map +1 -1
- package/dist/src/webhook/handler.js +85 -0
- package/dist/src/webhook/handler.js.map +1 -1
- package/dist-cjs/src/api/client.js +3 -1
- package/dist-cjs/src/api/transport.js +26 -2
- package/dist-cjs/src/branding/terminal.js +264 -1
- package/dist-cjs/src/broadcast/broadcast.js +58 -0
- package/dist-cjs/src/cli.js +7 -3
- package/dist-cjs/src/context/context.js +102 -8
- package/dist-cjs/src/core/bot.js +178 -29
- package/dist-cjs/src/index.js +2 -0
- package/dist-cjs/src/keyboard/index.js +13 -1
- package/dist-cjs/src/observability/logger.js +113 -1
- package/dist-cjs/src/plugins/plugin.js +11 -4
- package/dist-cjs/src/router/router.js +126 -24
- package/dist-cjs/src/state/forms.js +27 -24
- package/dist-cjs/src/storage/storage.js +20 -2
- package/dist-cjs/src/utils/concurrency.js +57 -0
- package/dist-cjs/src/utils/text.js +0 -0
- package/dist-cjs/src/webhook/handler.js +86 -0
- package/docs/API.id.md +120 -4
- package/docs/API.md +122 -4
- package/docs/API.zh-CN.md +119 -3
- package/package.json +3 -3
package/dist-cjs/src/core/bot.js
CHANGED
|
@@ -12,6 +12,8 @@ const plugin_js_1 = require("../plugins/plugin.js");
|
|
|
12
12
|
const logger_js_1 = require("../observability/logger.js");
|
|
13
13
|
const terminal_js_1 = require("../branding/terminal.js");
|
|
14
14
|
const conversation_js_1 = require("../state/conversation.js");
|
|
15
|
+
const broadcast_js_1 = require("../broadcast/broadcast.js");
|
|
16
|
+
const concurrency_js_1 = require("../utils/concurrency.js");
|
|
15
17
|
class Bot {
|
|
16
18
|
api;
|
|
17
19
|
router;
|
|
@@ -27,36 +29,56 @@ class Bot {
|
|
|
27
29
|
pollingAbort;
|
|
28
30
|
offset = 0;
|
|
29
31
|
me;
|
|
32
|
+
/** Caps how many updates run at once (default: unlimited). */
|
|
33
|
+
updateLimiter;
|
|
34
|
+
/** Per-chat processing chains: parallel across chats, ordered within a chat. */
|
|
35
|
+
chatChains = new Map();
|
|
36
|
+
/** Memoized init so a burst of updates triggers exactly one getMe call. */
|
|
37
|
+
initOnce;
|
|
38
|
+
brandingEnabled;
|
|
39
|
+
/** Branding effects run only on an interactive TTY; structured logs stay untouched otherwise. */
|
|
40
|
+
brandingActive;
|
|
41
|
+
activeBanner;
|
|
42
|
+
errorHandler;
|
|
30
43
|
constructor(options) {
|
|
31
44
|
const config = typeof options === "string" ? { token: options } : options;
|
|
32
45
|
if (!config.token || !/^\d+:[\w-]+$/.test(config.token))
|
|
33
46
|
throw new Error("A valid Telegram bot token is required.");
|
|
34
47
|
this.token = config.token;
|
|
35
|
-
|
|
48
|
+
this.brandingEnabled = config.branding ?? true;
|
|
49
|
+
this.brandingActive = this.brandingEnabled && process.stdout.isTTY === true;
|
|
36
50
|
this.logger = config.logger instanceof logger_js_1.Logger ? config.logger : (0, logger_js_1.createLogger)(config.logger);
|
|
37
51
|
this.router = new router_js_1.Router(config.router);
|
|
38
52
|
this.session = config.session ?? new storage_js_1.MemoryStorage();
|
|
39
53
|
this.services = { ...(config.services ?? {}) };
|
|
40
54
|
const transport = config.transport ?? new transport_js_1.FetchTransport({ baseUrl: `${config.apiBaseUrl ?? "https://api.telegram.org"}/bot${config.token}`, ...(config.transportOptions ?? {}) });
|
|
41
55
|
this.api = new client_js_1.ApiClient({
|
|
42
|
-
transport,
|
|
43
56
|
hooks: {
|
|
44
57
|
onRequest: (context) => { this.logger.trace("api.request", { method: context.method, payload: context.payload }); return this.events.emit("api:request", { method: context.method, payload: context.payload }); },
|
|
45
58
|
onResponse: (context) => { this.logger.debug("api.response", { method: context.method, durationMs: context.durationMs ?? 0, ok: context.response?.ok }); return this.events.emit("api:response", { method: context.method, durationMs: context.durationMs ?? 0, response: context.response }); },
|
|
46
59
|
onError: (context) => { this.logger.error("api.error", { method: context.method, durationMs: context.durationMs ?? 0, error: context.error }); return this.events.emit("api:error", { method: context.method, durationMs: context.durationMs ?? 0, error: context.error }); },
|
|
47
60
|
},
|
|
61
|
+
transport,
|
|
48
62
|
});
|
|
49
63
|
this.plugins = new plugin_js_1.PluginManager(this);
|
|
64
|
+
this.updateLimiter = new concurrency_js_1.Limiter(config.updates?.concurrency ?? Infinity);
|
|
50
65
|
this.pollingOptions = {
|
|
51
|
-
timeout: config.polling?.timeout ?? 30,
|
|
52
|
-
limit: config.polling?.limit ?? 100,
|
|
53
66
|
allowedUpdates: config.polling?.allowedUpdates ?? [],
|
|
54
|
-
|
|
67
|
+
limit: config.polling?.limit ?? 100,
|
|
55
68
|
maxRetryDelayMs: config.polling?.maxRetryDelayMs ?? 30_000,
|
|
69
|
+
retryDelayMs: config.polling?.retryDelayMs ?? 500,
|
|
70
|
+
timeout: config.polling?.timeout ?? 30,
|
|
56
71
|
};
|
|
57
|
-
this.
|
|
72
|
+
this.startupLog("bot.created", { status: this.statusValue });
|
|
58
73
|
void this.events.emit("bot:created", { bot: this });
|
|
59
74
|
}
|
|
75
|
+
/** Startup info logs are demoted to debug while the branding sequence owns the terminal. */
|
|
76
|
+
startupLog(event, context) {
|
|
77
|
+
if (this.brandingActive)
|
|
78
|
+
this.logger.debug(event, context);
|
|
79
|
+
else
|
|
80
|
+
this.logger.info(event, context);
|
|
81
|
+
}
|
|
60
82
|
get status() { return this.statusValue; }
|
|
61
83
|
get botInfo() { return this.me; }
|
|
62
84
|
use(...middleware) { this.middlewares.push(...middleware); return this; }
|
|
@@ -64,6 +86,21 @@ class Bot {
|
|
|
64
86
|
callback(pattern, handler) { this.router.callback(pattern, handler); return this; }
|
|
65
87
|
onText(text, handler) { this.router.text(text, handler); return this; }
|
|
66
88
|
onRegex(expression, handler) { this.router.regex(expression, handler); return this; }
|
|
89
|
+
/** Registers a handler for update types: `bot.on("message:photo", handler)` or `bot.on(["message:text", "callback_query:data"], handler)`. */
|
|
90
|
+
on(filter, handler) { this.router.on(filter, handler); return this; }
|
|
91
|
+
/** Registers a handler for exact text or a regular expression, mirroring familiar frameworks. */
|
|
92
|
+
hears(trigger, handler) {
|
|
93
|
+
if (trigger instanceof RegExp)
|
|
94
|
+
this.router.regex(trigger, handler);
|
|
95
|
+
else
|
|
96
|
+
this.router.text(trigger, handler);
|
|
97
|
+
return this;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Sets the error boundary for update handlers. When set, handler failures are
|
|
101
|
+
* passed here instead of rejecting `handleUpdate()` (webhooks answer 200).
|
|
102
|
+
*/
|
|
103
|
+
catch(handler) { this.errorHandler = handler; return this; }
|
|
67
104
|
usePlugin(plugin) { this.plugins.use(plugin); return this; }
|
|
68
105
|
/**
|
|
69
106
|
* Routes subsequent messages to the active Wizard step for the same chat/user.
|
|
@@ -92,20 +129,29 @@ class Bot {
|
|
|
92
129
|
async init() {
|
|
93
130
|
if (this.statusValue === "initialized" || this.statusValue === "running")
|
|
94
131
|
return this;
|
|
95
|
-
this.
|
|
96
|
-
const
|
|
132
|
+
this.startupLog("bot.initializing", {});
|
|
133
|
+
const standaloneBanner = this.brandingActive && !this.activeBanner;
|
|
134
|
+
const animation = this.brandingActive ? undefined : (0, terminal_js_1.startTerminalAnimation)("Connecting to Telegram and initializing bot");
|
|
135
|
+
if (standaloneBanner)
|
|
136
|
+
(0, terminal_js_1.printTeleBibzBanner)({ subtitle: "Connecting to Telegram..." });
|
|
97
137
|
try {
|
|
98
138
|
this.me = await this.api.methods.getMe();
|
|
99
139
|
this.statusValue = "initialized";
|
|
100
|
-
this.
|
|
140
|
+
this.startupLog("bot.initialized", { botId: this.me.id, username: this.me.username });
|
|
101
141
|
await this.events.emit("bot:initialized", { bot: this });
|
|
102
142
|
await this.plugins.setup();
|
|
103
143
|
await this.plugins.start();
|
|
104
|
-
|
|
144
|
+
if (standaloneBanner)
|
|
145
|
+
(0, terminal_js_1.printStatusLine)(`✓ Bot initialized as @${this.me.username ?? this.me.id}`);
|
|
146
|
+
else
|
|
147
|
+
animation?.stop("Bot initialized; ready to start");
|
|
105
148
|
return this;
|
|
106
149
|
}
|
|
107
150
|
catch (error) {
|
|
108
|
-
|
|
151
|
+
if (standaloneBanner)
|
|
152
|
+
(0, terminal_js_1.printStatusLine)(`✗ Bot could not initialize: ${error instanceof Error ? error.message : String(error)}`);
|
|
153
|
+
else
|
|
154
|
+
animation?.stop("Error: bot could not initialize");
|
|
109
155
|
throw error;
|
|
110
156
|
}
|
|
111
157
|
}
|
|
@@ -113,15 +159,35 @@ class Bot {
|
|
|
113
159
|
async launch(options = { mode: "polling" }) {
|
|
114
160
|
if (options.mode !== "polling")
|
|
115
161
|
throw new Error("Use createWebhookHandler() for webhook mode.");
|
|
116
|
-
|
|
162
|
+
const runBrandingSequence = this.brandingActive && !this.activeBanner && (this.statusValue === "created" || this.statusValue === "stopped");
|
|
163
|
+
if (runBrandingSequence) {
|
|
164
|
+
await (0, terminal_js_1.runStartupSequence)();
|
|
165
|
+
this.activeBanner = (0, terminal_js_1.startTeleBibzBanner)({ subtitle: "Connecting to Telegram..." });
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
await this.init();
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
if (this.activeBanner) {
|
|
172
|
+
this.activeBanner.stop(`Connection failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
173
|
+
this.activeBanner = undefined;
|
|
174
|
+
}
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
if (this.activeBanner) {
|
|
178
|
+
this.activeBanner.stop(`Connected as @${this.me?.username ?? this.me?.id}`);
|
|
179
|
+
this.activeBanner = undefined;
|
|
180
|
+
}
|
|
117
181
|
if (this.statusValue === "running")
|
|
118
182
|
return;
|
|
119
183
|
this.statusValue = "starting";
|
|
120
|
-
this.
|
|
184
|
+
this.startupLog("bot.starting", { mode: options.mode });
|
|
121
185
|
await this.events.emit("bot:starting", { bot: this });
|
|
122
186
|
this.pollingAbort = new AbortController();
|
|
123
187
|
this.statusValue = "running";
|
|
124
188
|
await this.events.emit("bot:started", { bot: this });
|
|
189
|
+
if (this.brandingActive)
|
|
190
|
+
(0, terminal_js_1.printStatusLine)("Listening for updates...");
|
|
125
191
|
await this.poll(options.timeout ?? this.pollingOptions.timeout, options.allowedUpdates ?? this.pollingOptions.allowedUpdates, this.pollingAbort.signal);
|
|
126
192
|
}
|
|
127
193
|
async stop() {
|
|
@@ -134,7 +200,10 @@ class Bot {
|
|
|
134
200
|
await this.plugins.stop();
|
|
135
201
|
await this.plugins.dispose();
|
|
136
202
|
this.statusValue = "stopped";
|
|
137
|
-
this.
|
|
203
|
+
if (this.brandingActive)
|
|
204
|
+
(0, terminal_js_1.printStatusLine)("Bot stopped.");
|
|
205
|
+
else
|
|
206
|
+
this.logger.info("bot.stopped");
|
|
138
207
|
await this.events.emit("bot:stopped", { bot: this });
|
|
139
208
|
}
|
|
140
209
|
async restart() { await this.stop(); await this.start(); }
|
|
@@ -150,8 +219,69 @@ class Bot {
|
|
|
150
219
|
async getMe() { const me = await this.api.methods.getMe(); this.me = me; return me; }
|
|
151
220
|
async setCommands(commands, scope, languageCode) { return this.api.call("setMyCommands", { commands, scope, language_code: languageCode }); }
|
|
152
221
|
async deleteCommands(scope, languageCode) { return this.api.call("deleteMyCommands", { scope, language_code: languageCode }); }
|
|
222
|
+
/**
|
|
223
|
+
* Handles a single update. Updates for different chats run in parallel;
|
|
224
|
+
* updates for the same chat are processed strictly in arrival order so
|
|
225
|
+
* sessions, wizards, and conversations never interleave. Rejects for this
|
|
226
|
+
* update's failure (as before) without affecting other updates.
|
|
227
|
+
*/
|
|
153
228
|
async handleUpdate(update) {
|
|
154
|
-
|
|
229
|
+
const key = this.conversationKey(update);
|
|
230
|
+
const previous = this.chatChains.get(key);
|
|
231
|
+
const run = (previous ?? Promise.resolve()).catch(() => undefined).then(() => this.processUpdate(update));
|
|
232
|
+
const tail = run.then(() => undefined, () => undefined);
|
|
233
|
+
this.chatChains.set(key, tail);
|
|
234
|
+
void tail.then(() => {
|
|
235
|
+
if (this.chatChains.get(key) === tail)
|
|
236
|
+
this.chatChains.delete(key);
|
|
237
|
+
});
|
|
238
|
+
await run;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Handles a whole batch of updates at once: every chat in the batch is
|
|
242
|
+
* processed immediately (parallel across chats, ordered per chat), so a
|
|
243
|
+
* burst of 1000 messages is not stuck behind one slow handler. Individual
|
|
244
|
+
* handler failures are logged, emitted as `update:error`, and passed to the
|
|
245
|
+
* `catch()` error boundary; they never reject this promise.
|
|
246
|
+
*/
|
|
247
|
+
async handleUpdates(updates) {
|
|
248
|
+
await Promise.all(updates.map(async (update) => {
|
|
249
|
+
try {
|
|
250
|
+
await this.handleUpdate(update);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
// Already logged and emitted by processUpdate; the polling loop must
|
|
254
|
+
// keep flowing no matter how many handlers failed.
|
|
255
|
+
}
|
|
256
|
+
}));
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Sends to many chats in parallel — built for broadcasts to 1000+ users.
|
|
260
|
+
* There is no proactive cooldown: every chat is attempted at once (up to
|
|
261
|
+
* `concurrency`). When Telegram answers 429, the send is retried
|
|
262
|
+
* automatically after exactly the `retry_after` delay Telegram ordered, so
|
|
263
|
+
* bursts deliver completely instead of failing.
|
|
264
|
+
*/
|
|
265
|
+
async broadcast(chatIds, send, options) {
|
|
266
|
+
return (0, broadcast_js_1.runBroadcast)(chatIds, send, options);
|
|
267
|
+
}
|
|
268
|
+
/** Runs init() once even when many updates arrive concurrently. */
|
|
269
|
+
ensureInitialized() {
|
|
270
|
+
if (this.me)
|
|
271
|
+
return Promise.resolve();
|
|
272
|
+
if (!this.initOnce) {
|
|
273
|
+
this.initOnce = this.init().then(() => { this.initOnce = undefined; }, (error) => {
|
|
274
|
+
this.initOnce = undefined;
|
|
275
|
+
throw error;
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
return this.initOnce;
|
|
279
|
+
}
|
|
280
|
+
async processUpdate(update) {
|
|
281
|
+
await this.updateLimiter.run(() => this.runUpdate(update));
|
|
282
|
+
}
|
|
283
|
+
async runUpdate(update) {
|
|
284
|
+
this.logger.incoming((0, logger_js_1.describeIncomingUpdate)(update));
|
|
155
285
|
const message = update.message
|
|
156
286
|
?? update.edited_message
|
|
157
287
|
?? update.channel_post
|
|
@@ -163,10 +293,10 @@ class Bot {
|
|
|
163
293
|
const key = this.conversationKey(update);
|
|
164
294
|
const session = await this.session.get(key) ?? {};
|
|
165
295
|
if (!this.me)
|
|
166
|
-
await this.
|
|
296
|
+
await this.ensureInitialized();
|
|
167
297
|
if (!this.me)
|
|
168
298
|
return;
|
|
169
|
-
const ctx = new context_js_1.Context({ update, api: this.api, session, services: this.services });
|
|
299
|
+
const ctx = new context_js_1.Context({ update, api: this.api, session, services: this.services, me: this.me });
|
|
170
300
|
await this.events.emit("update", { update });
|
|
171
301
|
if (message) {
|
|
172
302
|
await this.events.emit("message", { message });
|
|
@@ -189,22 +319,42 @@ class Bot {
|
|
|
189
319
|
this.logger.error("update.handler_error", { update: (0, logger_js_1.summarizeUpdate)(update, this.logger.includeUpdateContent), error });
|
|
190
320
|
await this.events.emit("update:error", { update, error });
|
|
191
321
|
await this.events.emit("bot:error", { bot: this, error });
|
|
322
|
+
if (this.errorHandler) {
|
|
323
|
+
// With an error boundary registered, the failure is considered handled:
|
|
324
|
+
// webhooks answer 200 and polling continues without rethrowing.
|
|
325
|
+
await this.errorHandler(error, ctx);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
192
328
|
throw error;
|
|
193
329
|
}
|
|
194
330
|
}
|
|
195
331
|
conversationKey(update) {
|
|
332
|
+
if (update.callback_query) {
|
|
333
|
+
const chat = update.callback_query.message?.chat;
|
|
334
|
+
const userId = update.callback_query.from.id;
|
|
335
|
+
if (chat?.id !== undefined)
|
|
336
|
+
return `${chat.id}:${userId}`;
|
|
337
|
+
return `user:${userId}`;
|
|
338
|
+
}
|
|
339
|
+
if (update.inline_query?.from?.id !== undefined)
|
|
340
|
+
return `user:${update.inline_query.from.id}`;
|
|
341
|
+
if (update.chosen_inline_result?.from?.id !== undefined)
|
|
342
|
+
return `user:${update.chosen_inline_result.from.id}`;
|
|
196
343
|
const message = update.message
|
|
197
344
|
?? update.edited_message
|
|
198
345
|
?? update.channel_post
|
|
199
346
|
?? update.edited_channel_post
|
|
200
347
|
?? update.business_message
|
|
201
348
|
?? update.edited_business_message
|
|
202
|
-
?? update.guest_message
|
|
203
|
-
?? update.callback_query?.message;
|
|
349
|
+
?? update.guest_message;
|
|
204
350
|
if (message?.chat?.id !== undefined)
|
|
205
351
|
return `${message.chat.id}:${message.from?.id ?? "anonymous"}`;
|
|
206
|
-
if (update.
|
|
207
|
-
return
|
|
352
|
+
if (update.chat_member?.chat?.id !== undefined)
|
|
353
|
+
return `${update.chat_member.chat.id}:${update.chat_member.from.id}`;
|
|
354
|
+
if (update.my_chat_member?.chat?.id !== undefined)
|
|
355
|
+
return `${update.my_chat_member.chat.id}:${update.my_chat_member.from.id}`;
|
|
356
|
+
if (update.chat_join_request?.chat?.id !== undefined)
|
|
357
|
+
return `${update.chat_join_request.chat.id}:${update.chat_join_request.from.id}`;
|
|
208
358
|
return `update:${update.update_id}`;
|
|
209
359
|
}
|
|
210
360
|
async waitForRetry(delayMs, signal) {
|
|
@@ -225,20 +375,19 @@ class Bot {
|
|
|
225
375
|
}
|
|
226
376
|
async poll(timeout, allowedUpdates, signal) {
|
|
227
377
|
let delay = this.pollingOptions.retryDelayMs;
|
|
378
|
+
// Telegram holds a long-poll connection open for `timeout` seconds, so the
|
|
379
|
+
// request timeout must exceed it to avoid aborting a healthy connection.
|
|
380
|
+
const requestTimeoutMs = timeout * 1_000 + 10_000;
|
|
228
381
|
while (!signal.aborted) {
|
|
229
382
|
try {
|
|
230
|
-
const updates = await this.api.
|
|
383
|
+
const updates = await this.api.request("getUpdates", { offset: this.offset, limit: this.pollingOptions.limit, timeout, allowed_updates: allowedUpdates }, signal, { timeoutMs: requestTimeoutMs });
|
|
231
384
|
delay = this.pollingOptions.retryDelayMs;
|
|
385
|
+
// Confirm the whole batch first, then process it: updates run in
|
|
386
|
+
// parallel across chats (ordered per chat) instead of one by one.
|
|
232
387
|
for (const update of updates) {
|
|
233
388
|
this.offset = Math.max(this.offset, update.update_id + 1);
|
|
234
|
-
try {
|
|
235
|
-
await this.handleUpdate(update);
|
|
236
|
-
}
|
|
237
|
-
catch {
|
|
238
|
-
// The update has already advanced the offset. Continue with the rest of
|
|
239
|
-
// the batch instead of reconnecting or replaying a failed handler.
|
|
240
|
-
}
|
|
241
389
|
}
|
|
390
|
+
await this.handleUpdates(updates);
|
|
242
391
|
}
|
|
243
392
|
catch (error) {
|
|
244
393
|
if (signal.aborted)
|
package/dist-cjs/src/index.js
CHANGED
|
@@ -28,6 +28,8 @@ __exportStar(require("./queue/queue.js"), exports);
|
|
|
28
28
|
__exportStar(require("./plugins/plugin.js"), exports);
|
|
29
29
|
__exportStar(require("./webhook/handler.js"), exports);
|
|
30
30
|
__exportStar(require("./utils/text.js"), exports);
|
|
31
|
+
__exportStar(require("./utils/concurrency.js"), exports);
|
|
32
|
+
__exportStar(require("./broadcast/broadcast.js"), exports);
|
|
31
33
|
__exportStar(require("./state/conversation.js"), exports);
|
|
32
34
|
__exportStar(require("./state/forms.js"), exports);
|
|
33
35
|
__exportStar(require("./state/menu.js"), exports);
|
|
@@ -11,6 +11,10 @@ class InlineKeyboard {
|
|
|
11
11
|
webApp(text, url) { return this.button({ text, web_app: { url } }); }
|
|
12
12
|
pay(text = "Pay") { return this.button({ text, pay: true }); }
|
|
13
13
|
copy(text, copiedText) { return this.button({ text, copy_text: { text: copiedText } }); }
|
|
14
|
+
switchInline(text, query = "") { return this.button({ text, switch_inline_query: query }); }
|
|
15
|
+
switchInlineCurrent(text, query = "") { return this.button({ text, switch_inline_query_current_chat: query }); }
|
|
16
|
+
login(text, url, options = {}) { return this.button({ text, login_url: { url, ...options } }); }
|
|
17
|
+
game(text) { return this.button({ text, callback_game: {} }); }
|
|
14
18
|
button(button) { this.ensureRow().push(validateInlineButton(button)); return this; }
|
|
15
19
|
row(...buttons) { this.rows.push(buttons.map(validateInlineButton)); return this; }
|
|
16
20
|
conditional(condition, factory) { return condition ? factory(this) : this; }
|
|
@@ -25,17 +29,25 @@ class InlineKeyboard {
|
|
|
25
29
|
exports.InlineKeyboard = InlineKeyboard;
|
|
26
30
|
class ReplyKeyboard {
|
|
27
31
|
rows = [];
|
|
32
|
+
options = {};
|
|
28
33
|
text(text) { return this.button({ text }); }
|
|
29
34
|
contact(text) { return this.button({ text, request_contact: true }); }
|
|
30
35
|
location(text) { return this.button({ text, request_location: true }); }
|
|
31
36
|
poll(text, type) { return this.button({ text, request_poll: type ? { type } : {} }); }
|
|
32
37
|
webApp(text, url) { return this.button({ text, web_app: { url } }); }
|
|
38
|
+
requestUsers(text, options = {}) { return this.button({ text, request_users: options }); }
|
|
39
|
+
requestChat(text, options = {}) { return this.button({ text, request_chat: options }); }
|
|
40
|
+
resized(resize = true) { this.options.resize_keyboard = resize; return this; }
|
|
41
|
+
oneTime(oneTime = true) { this.options.one_time_keyboard = oneTime; return this; }
|
|
42
|
+
persistent(persistent = true) { this.options.is_persistent = persistent; return this; }
|
|
43
|
+
placeholder(text) { this.options.input_field_placeholder = text; return this; }
|
|
44
|
+
selective(selective = true) { this.options.selective = selective; return this; }
|
|
33
45
|
button(button) { this.ensureRow().push(button); return this; }
|
|
34
46
|
row(...buttons) { this.rows.push([...buttons]); return this; }
|
|
35
47
|
grid(buttons, columns) { if (!Number.isInteger(columns) || columns < 1)
|
|
36
48
|
throw new RangeError("columns must be a positive integer"); for (let index = 0; index < buttons.length; index += columns)
|
|
37
49
|
this.rows.push(buttons.slice(index, index + columns)); return this; }
|
|
38
|
-
build(options = {}) { return { keyboard: this.rows.map((row) => [...row]), ...options }; }
|
|
50
|
+
build(options = {}) { return { keyboard: this.rows.map((row) => [...row]), ...this.options, ...options }; }
|
|
39
51
|
asReplyMarkup() { return this.build(); }
|
|
40
52
|
ensureRow() { const row = this.rows.at(-1); if (row)
|
|
41
53
|
return row; const next = []; this.rows.push(next); return next; }
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.Logger = void 0;
|
|
3
|
+
exports.Logger = exports.INCOMING_TEXT_LIMIT = void 0;
|
|
4
4
|
exports.redact = redact;
|
|
5
|
+
exports.formatLocalStamp = formatLocalStamp;
|
|
6
|
+
exports.describeIncomingUpdate = describeIncomingUpdate;
|
|
5
7
|
exports.summarizeUpdate = summarizeUpdate;
|
|
6
8
|
exports.createLogger = createLogger;
|
|
7
9
|
const priorities = { silent: 99, error: 0, warn: 1, info: 2, debug: 3, trace: 4 };
|
|
@@ -88,6 +90,84 @@ function updateType(update) {
|
|
|
88
90
|
return "poll_answer";
|
|
89
91
|
return "unknown";
|
|
90
92
|
}
|
|
93
|
+
exports.INCOMING_TEXT_LIMIT = 50;
|
|
94
|
+
function padNumber(value) { return String(value).padStart(2, "0"); }
|
|
95
|
+
/** Formats a date as `dd/mm/yyyy hh:mm:ss` in local time. */
|
|
96
|
+
function formatLocalStamp(date = new Date()) {
|
|
97
|
+
return `${padNumber(date.getDate())}/${padNumber(date.getMonth() + 1)}/${date.getFullYear()} ${padNumber(date.getHours())}:${padNumber(date.getMinutes())}:${padNumber(date.getSeconds())}`;
|
|
98
|
+
}
|
|
99
|
+
function truncateForDisplay(value, limit = exports.INCOMING_TEXT_LIMIT) {
|
|
100
|
+
if (value.length <= limit)
|
|
101
|
+
return { text: value, truncated: false };
|
|
102
|
+
return { text: `${value.slice(0, limit)}…`, truncated: true };
|
|
103
|
+
}
|
|
104
|
+
function nicknameFromUser(user) {
|
|
105
|
+
if (!user)
|
|
106
|
+
return undefined;
|
|
107
|
+
const name = [user.first_name, user.last_name].filter(Boolean).join(" ").trim();
|
|
108
|
+
return name.length > 0 ? name : user.username ?? String(user.id);
|
|
109
|
+
}
|
|
110
|
+
function messageMediaType(message) {
|
|
111
|
+
for (const key of ["photo", "video", "video_note", "animation", "document", "audio", "voice", "sticker", "location", "venue", "contact", "dice", "poll", "story", "paid_media"]) {
|
|
112
|
+
if (message[key] !== undefined)
|
|
113
|
+
return key;
|
|
114
|
+
}
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Builds the human-readable description of an incoming update used for the
|
|
119
|
+
* `[ => ] Message From ...` terminal lines. Text messages and commands are
|
|
120
|
+
* truncated to 50 characters; callback button data is shown in full.
|
|
121
|
+
*/
|
|
122
|
+
function describeIncomingUpdate(update) {
|
|
123
|
+
const callback = update.callback_query;
|
|
124
|
+
if (callback) {
|
|
125
|
+
const result = { kind: "Callback", fromId: callback.from.id, nickname: nicknameFromUser(callback.from), contentLabel: "Data", content: callback.data };
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
const message = (update.message ?? update.edited_message ?? update.channel_post ?? update.edited_channel_post ?? update.business_message ?? update.edited_business_message ?? update.guest_message ?? update.edited_guest_message);
|
|
129
|
+
if (message) {
|
|
130
|
+
const from = message.from;
|
|
131
|
+
const nickname = nicknameFromUser(from) ?? (message.chat.title !== undefined ? String(message.chat.title) : message.chat.id !== undefined ? String(message.chat.id) : undefined);
|
|
132
|
+
const input = { kind: "Message", fromId: from?.id ?? message.chat.id, nickname };
|
|
133
|
+
const text = message.text;
|
|
134
|
+
if (text !== undefined) {
|
|
135
|
+
const truncated = truncateForDisplay(text);
|
|
136
|
+
input.contentLabel = "Text";
|
|
137
|
+
input.content = truncated.text;
|
|
138
|
+
input.truncated = truncated.truncated;
|
|
139
|
+
}
|
|
140
|
+
else if (message.caption !== undefined) {
|
|
141
|
+
const truncated = truncateForDisplay(message.caption);
|
|
142
|
+
input.contentLabel = "Caption";
|
|
143
|
+
input.content = truncated.text;
|
|
144
|
+
input.truncated = truncated.truncated;
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
const media = messageMediaType(message);
|
|
148
|
+
if (media !== undefined)
|
|
149
|
+
input.contentLabel = media === "photo" ? "Photo" : media.replace(/_/g, " ").replace(/^./, (character) => character.toUpperCase());
|
|
150
|
+
}
|
|
151
|
+
return input;
|
|
152
|
+
}
|
|
153
|
+
const inline = update.inline_query;
|
|
154
|
+
if (inline) {
|
|
155
|
+
const truncated = truncateForDisplay(inline.query);
|
|
156
|
+
return { kind: "Inline Query", fromId: inline.from.id, nickname: nicknameFromUser(inline.from), contentLabel: "Query", content: truncated.text, truncated: truncated.truncated };
|
|
157
|
+
}
|
|
158
|
+
const chosen = update.chosen_inline_result;
|
|
159
|
+
if (chosen)
|
|
160
|
+
return { kind: "Inline Result", fromId: chosen.from.id, nickname: nicknameFromUser(chosen.from), contentLabel: "Result", content: chosen.result_id };
|
|
161
|
+
const chatScoped = update.chat_member ?? update.my_chat_member ?? update.chat_join_request;
|
|
162
|
+
if (chatScoped) {
|
|
163
|
+
return { kind: "Update", fromId: chatScoped.from.id, nickname: nicknameFromUser(chatScoped.from), contentLabel: "Chat", content: chatScoped.chat.title ?? String(chatScoped.chat.id) };
|
|
164
|
+
}
|
|
165
|
+
const reaction = update.message_reaction;
|
|
166
|
+
const reactor = nicknameFromUser(reaction?.from);
|
|
167
|
+
if (reactor)
|
|
168
|
+
return { kind: "Reaction", fromId: reaction?.from?.id, nickname: reactor };
|
|
169
|
+
return { kind: "Update", context: { type: updateType(update) } };
|
|
170
|
+
}
|
|
91
171
|
function summarizeUpdate(update, includeContent = false) {
|
|
92
172
|
const callback = update.callback_query;
|
|
93
173
|
const message = (update.message ?? update.edited_message ?? update.channel_post ?? update.edited_channel_post ?? update.business_message ?? update.edited_business_message ?? update.guest_message ?? update.edited_guest_message ?? callback?.message);
|
|
@@ -134,6 +214,10 @@ function createDefaultSink(options, redactKeys) {
|
|
|
134
214
|
const color = options.color ?? Boolean(stream.isTTY && !process.env.NO_COLOR);
|
|
135
215
|
const format = options.format ?? "pretty";
|
|
136
216
|
return (entry) => {
|
|
217
|
+
if (typeof entry.text === "string") {
|
|
218
|
+
stream.write(`${redactString(entry.text)}\n`);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
137
221
|
const safeEntry = redact(entry, redactKeys);
|
|
138
222
|
const line = formatEntry(safeEntry, color, format);
|
|
139
223
|
stream.write(`${line}\n`);
|
|
@@ -163,6 +247,34 @@ class Logger {
|
|
|
163
247
|
info(event, context) { this.write("info", event, context); }
|
|
164
248
|
warn(event, context) { this.write("warn", event, context); }
|
|
165
249
|
error(event, context) { this.write("error", event, context); }
|
|
250
|
+
/**
|
|
251
|
+
* Logs an incoming update as the human-readable terminal line:
|
|
252
|
+
* `[ => ] Message From {id} {nickname} {dd/mm/yyyy} {hh:mm:ss}` followed by
|
|
253
|
+
* an indented content line. In `json` format it emits a structured entry
|
|
254
|
+
* with the event name `update.received`.
|
|
255
|
+
*/
|
|
256
|
+
incoming(input) {
|
|
257
|
+
if (priorities.info > priorities[this.level])
|
|
258
|
+
return;
|
|
259
|
+
if (this.format === "json") {
|
|
260
|
+
const context = { kind: input.kind, ...(input.fromId !== undefined ? { fromId: input.fromId } : {}), ...(input.nickname !== undefined ? { nickname: input.nickname } : {}), ...(input.content !== undefined ? { content: input.content } : {}), ...(input.truncated ? { truncated: true } : {}), ...input.context };
|
|
261
|
+
this.write("info", "update.received", context);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const stamp = formatLocalStamp();
|
|
265
|
+
const identity = [input.fromId !== undefined ? String(input.fromId) : undefined, input.nickname].filter(Boolean).join(" ");
|
|
266
|
+
const arrow = this.color ? `\u001b[36m\u001b[1m[ => ]\u001b[0m` : "[ => ]";
|
|
267
|
+
const kind = this.color ? `\u001b[1m${input.kind}\u001b[0m` : input.kind;
|
|
268
|
+
const header = `${arrow} ${kind} From ${identity} ${this.color ? `\u001b[2m${stamp}\u001b[0m` : stamp}`;
|
|
269
|
+
const lines = [redactString(header)];
|
|
270
|
+
if (input.content !== undefined || input.contentLabel !== undefined) {
|
|
271
|
+
const label = input.contentLabel ?? "Content";
|
|
272
|
+
const value = input.content ?? "";
|
|
273
|
+
const contentLine = ` ↳ ${label}: ${value}`;
|
|
274
|
+
lines.push(this.color ? `\u001b[2m${redactString(contentLine)}\u001b[0m` : redactString(contentLine));
|
|
275
|
+
}
|
|
276
|
+
this.sink({ timestamp: new Date().toISOString(), level: "info", event: "update.received", text: lines.join("\n") });
|
|
277
|
+
}
|
|
166
278
|
write(level, event, context = {}) {
|
|
167
279
|
if (priorities[level] > priorities[this.level])
|
|
168
280
|
return;
|
|
@@ -13,6 +13,7 @@ exports.ServiceContainer = ServiceContainer;
|
|
|
13
13
|
class PluginManager {
|
|
14
14
|
plugins = [];
|
|
15
15
|
api;
|
|
16
|
+
setupComplete = false;
|
|
16
17
|
constructor(bot) {
|
|
17
18
|
const host = bot;
|
|
18
19
|
this.api = {
|
|
@@ -39,10 +40,16 @@ class PluginManager {
|
|
|
39
40
|
}
|
|
40
41
|
use(plugin) { if (this.plugins.some((existing) => existing.name === plugin.name))
|
|
41
42
|
throw new Error(`Plugin already registered: ${plugin.name}`); this.plugins.push(plugin); return this; }
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
/** Installs plugins once; re-running setup after a restart must not double-register middleware. */
|
|
44
|
+
async setup() {
|
|
45
|
+
if (this.setupComplete)
|
|
46
|
+
return;
|
|
47
|
+
for (const plugin of this.plugins) {
|
|
48
|
+
await plugin.install?.(this.api);
|
|
49
|
+
await plugin.setup?.(this.api);
|
|
50
|
+
}
|
|
51
|
+
this.setupComplete = true;
|
|
52
|
+
}
|
|
46
53
|
async start() { for (const plugin of this.plugins)
|
|
47
54
|
await plugin.onStart?.(this.api); }
|
|
48
55
|
async update(context) { for (const plugin of this.plugins)
|