@xbibzlibrary/telebibz 0.1.18 → 0.2.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 +28 -8
- package/CONTRIBUTING.md +8 -3
- package/README.id.md +50 -10
- package/README.md +50 -12
- package/README.zh-CN.md +50 -10
- package/RELEASE_AUTOMATION.md +17 -5
- package/SHOWCASE.md +29 -0
- 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 +3 -1
- package/dist/src/api/transport.d.ts.map +1 -1
- package/dist/src/api/transport.js +3 -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/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 +18 -1
- package/dist/src/core/bot.d.ts.map +1 -1
- package/dist/src/core/bot.js +106 -23
- package/dist/src/core/bot.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/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 +3 -2
- package/dist-cjs/src/branding/terminal.js +264 -1
- package/dist-cjs/src/cli.js +7 -3
- package/dist-cjs/src/context/context.js +102 -8
- package/dist-cjs/src/core/bot.js +104 -21
- 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/text.js +0 -0
- package/dist-cjs/src/webhook/handler.js +86 -0
- package/docs/API.id.md +57 -3
- package/docs/API.md +59 -3
- package/docs/API.zh-CN.md +56 -2
- package/docs/GETTING_STARTED.id.md +85 -0
- package/docs/GETTING_STARTED.md +85 -0
- package/docs/GETTING_STARTED.zh-CN.md +85 -0
- package/docs/README.md +12 -4
- package/examples/README.md +37 -0
- package/examples/minimal.ts +12 -0
- package/examples/tsconfig.json +9 -0
- package/examples/webhook.ts +42 -0
- package/examples/wizard-registration.ts +42 -0
- package/package.json +22 -5
package/dist-cjs/src/core/bot.js
CHANGED
|
@@ -27,36 +27,49 @@ class Bot {
|
|
|
27
27
|
pollingAbort;
|
|
28
28
|
offset = 0;
|
|
29
29
|
me;
|
|
30
|
+
brandingEnabled;
|
|
31
|
+
/** Branding effects run only on an interactive TTY; structured logs stay untouched otherwise. */
|
|
32
|
+
brandingActive;
|
|
33
|
+
activeBanner;
|
|
34
|
+
errorHandler;
|
|
30
35
|
constructor(options) {
|
|
31
36
|
const config = typeof options === "string" ? { token: options } : options;
|
|
32
37
|
if (!config.token || !/^\d+:[\w-]+$/.test(config.token))
|
|
33
38
|
throw new Error("A valid Telegram bot token is required.");
|
|
34
39
|
this.token = config.token;
|
|
35
|
-
|
|
40
|
+
this.brandingEnabled = config.branding ?? true;
|
|
41
|
+
this.brandingActive = this.brandingEnabled && process.stdout.isTTY === true;
|
|
36
42
|
this.logger = config.logger instanceof logger_js_1.Logger ? config.logger : (0, logger_js_1.createLogger)(config.logger);
|
|
37
43
|
this.router = new router_js_1.Router(config.router);
|
|
38
44
|
this.session = config.session ?? new storage_js_1.MemoryStorage();
|
|
39
45
|
this.services = { ...(config.services ?? {}) };
|
|
40
46
|
const transport = config.transport ?? new transport_js_1.FetchTransport({ baseUrl: `${config.apiBaseUrl ?? "https://api.telegram.org"}/bot${config.token}`, ...(config.transportOptions ?? {}) });
|
|
41
47
|
this.api = new client_js_1.ApiClient({
|
|
42
|
-
transport,
|
|
43
48
|
hooks: {
|
|
44
49
|
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
50
|
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
51
|
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
52
|
},
|
|
53
|
+
transport,
|
|
48
54
|
});
|
|
49
55
|
this.plugins = new plugin_js_1.PluginManager(this);
|
|
50
56
|
this.pollingOptions = {
|
|
51
|
-
timeout: config.polling?.timeout ?? 30,
|
|
52
|
-
limit: config.polling?.limit ?? 100,
|
|
53
57
|
allowedUpdates: config.polling?.allowedUpdates ?? [],
|
|
54
|
-
|
|
58
|
+
limit: config.polling?.limit ?? 100,
|
|
55
59
|
maxRetryDelayMs: config.polling?.maxRetryDelayMs ?? 30_000,
|
|
60
|
+
retryDelayMs: config.polling?.retryDelayMs ?? 500,
|
|
61
|
+
timeout: config.polling?.timeout ?? 30,
|
|
56
62
|
};
|
|
57
|
-
this.
|
|
63
|
+
this.startupLog("bot.created", { status: this.statusValue });
|
|
58
64
|
void this.events.emit("bot:created", { bot: this });
|
|
59
65
|
}
|
|
66
|
+
/** Startup info logs are demoted to debug while the branding sequence owns the terminal. */
|
|
67
|
+
startupLog(event, context) {
|
|
68
|
+
if (this.brandingActive)
|
|
69
|
+
this.logger.debug(event, context);
|
|
70
|
+
else
|
|
71
|
+
this.logger.info(event, context);
|
|
72
|
+
}
|
|
60
73
|
get status() { return this.statusValue; }
|
|
61
74
|
get botInfo() { return this.me; }
|
|
62
75
|
use(...middleware) { this.middlewares.push(...middleware); return this; }
|
|
@@ -64,6 +77,21 @@ class Bot {
|
|
|
64
77
|
callback(pattern, handler) { this.router.callback(pattern, handler); return this; }
|
|
65
78
|
onText(text, handler) { this.router.text(text, handler); return this; }
|
|
66
79
|
onRegex(expression, handler) { this.router.regex(expression, handler); return this; }
|
|
80
|
+
/** Registers a handler for update types: `bot.on("message:photo", handler)` or `bot.on(["message:text", "callback_query:data"], handler)`. */
|
|
81
|
+
on(filter, handler) { this.router.on(filter, handler); return this; }
|
|
82
|
+
/** Registers a handler for exact text or a regular expression, mirroring familiar frameworks. */
|
|
83
|
+
hears(trigger, handler) {
|
|
84
|
+
if (trigger instanceof RegExp)
|
|
85
|
+
this.router.regex(trigger, handler);
|
|
86
|
+
else
|
|
87
|
+
this.router.text(trigger, handler);
|
|
88
|
+
return this;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Sets the error boundary for update handlers. When set, handler failures are
|
|
92
|
+
* passed here instead of rejecting `handleUpdate()` (webhooks answer 200).
|
|
93
|
+
*/
|
|
94
|
+
catch(handler) { this.errorHandler = handler; return this; }
|
|
67
95
|
usePlugin(plugin) { this.plugins.use(plugin); return this; }
|
|
68
96
|
/**
|
|
69
97
|
* Routes subsequent messages to the active Wizard step for the same chat/user.
|
|
@@ -92,20 +120,29 @@ class Bot {
|
|
|
92
120
|
async init() {
|
|
93
121
|
if (this.statusValue === "initialized" || this.statusValue === "running")
|
|
94
122
|
return this;
|
|
95
|
-
this.
|
|
96
|
-
const
|
|
123
|
+
this.startupLog("bot.initializing", {});
|
|
124
|
+
const standaloneBanner = this.brandingActive && !this.activeBanner;
|
|
125
|
+
const animation = this.brandingActive ? undefined : (0, terminal_js_1.startTerminalAnimation)("Connecting to Telegram and initializing bot");
|
|
126
|
+
if (standaloneBanner)
|
|
127
|
+
(0, terminal_js_1.printTeleBibzBanner)({ subtitle: "Connecting to Telegram..." });
|
|
97
128
|
try {
|
|
98
129
|
this.me = await this.api.methods.getMe();
|
|
99
130
|
this.statusValue = "initialized";
|
|
100
|
-
this.
|
|
131
|
+
this.startupLog("bot.initialized", { botId: this.me.id, username: this.me.username });
|
|
101
132
|
await this.events.emit("bot:initialized", { bot: this });
|
|
102
133
|
await this.plugins.setup();
|
|
103
134
|
await this.plugins.start();
|
|
104
|
-
|
|
135
|
+
if (standaloneBanner)
|
|
136
|
+
(0, terminal_js_1.printStatusLine)(`✓ Bot initialized as @${this.me.username ?? this.me.id}`);
|
|
137
|
+
else
|
|
138
|
+
animation?.stop("Bot initialized; ready to start");
|
|
105
139
|
return this;
|
|
106
140
|
}
|
|
107
141
|
catch (error) {
|
|
108
|
-
|
|
142
|
+
if (standaloneBanner)
|
|
143
|
+
(0, terminal_js_1.printStatusLine)(`✗ Bot could not initialize: ${error instanceof Error ? error.message : String(error)}`);
|
|
144
|
+
else
|
|
145
|
+
animation?.stop("Error: bot could not initialize");
|
|
109
146
|
throw error;
|
|
110
147
|
}
|
|
111
148
|
}
|
|
@@ -113,15 +150,35 @@ class Bot {
|
|
|
113
150
|
async launch(options = { mode: "polling" }) {
|
|
114
151
|
if (options.mode !== "polling")
|
|
115
152
|
throw new Error("Use createWebhookHandler() for webhook mode.");
|
|
116
|
-
|
|
153
|
+
const runBrandingSequence = this.brandingActive && !this.activeBanner && (this.statusValue === "created" || this.statusValue === "stopped");
|
|
154
|
+
if (runBrandingSequence) {
|
|
155
|
+
await (0, terminal_js_1.runStartupSequence)();
|
|
156
|
+
this.activeBanner = (0, terminal_js_1.startTeleBibzBanner)({ subtitle: "Connecting to Telegram..." });
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
await this.init();
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
if (this.activeBanner) {
|
|
163
|
+
this.activeBanner.stop(`Connection failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
164
|
+
this.activeBanner = undefined;
|
|
165
|
+
}
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
if (this.activeBanner) {
|
|
169
|
+
this.activeBanner.stop(`Connected as @${this.me?.username ?? this.me?.id}`);
|
|
170
|
+
this.activeBanner = undefined;
|
|
171
|
+
}
|
|
117
172
|
if (this.statusValue === "running")
|
|
118
173
|
return;
|
|
119
174
|
this.statusValue = "starting";
|
|
120
|
-
this.
|
|
175
|
+
this.startupLog("bot.starting", { mode: options.mode });
|
|
121
176
|
await this.events.emit("bot:starting", { bot: this });
|
|
122
177
|
this.pollingAbort = new AbortController();
|
|
123
178
|
this.statusValue = "running";
|
|
124
179
|
await this.events.emit("bot:started", { bot: this });
|
|
180
|
+
if (this.brandingActive)
|
|
181
|
+
(0, terminal_js_1.printStatusLine)("Listening for updates...");
|
|
125
182
|
await this.poll(options.timeout ?? this.pollingOptions.timeout, options.allowedUpdates ?? this.pollingOptions.allowedUpdates, this.pollingAbort.signal);
|
|
126
183
|
}
|
|
127
184
|
async stop() {
|
|
@@ -134,7 +191,10 @@ class Bot {
|
|
|
134
191
|
await this.plugins.stop();
|
|
135
192
|
await this.plugins.dispose();
|
|
136
193
|
this.statusValue = "stopped";
|
|
137
|
-
this.
|
|
194
|
+
if (this.brandingActive)
|
|
195
|
+
(0, terminal_js_1.printStatusLine)("Bot stopped.");
|
|
196
|
+
else
|
|
197
|
+
this.logger.info("bot.stopped");
|
|
138
198
|
await this.events.emit("bot:stopped", { bot: this });
|
|
139
199
|
}
|
|
140
200
|
async restart() { await this.stop(); await this.start(); }
|
|
@@ -151,7 +211,7 @@ class Bot {
|
|
|
151
211
|
async setCommands(commands, scope, languageCode) { return this.api.call("setMyCommands", { commands, scope, language_code: languageCode }); }
|
|
152
212
|
async deleteCommands(scope, languageCode) { return this.api.call("deleteMyCommands", { scope, language_code: languageCode }); }
|
|
153
213
|
async handleUpdate(update) {
|
|
154
|
-
this.logger.
|
|
214
|
+
this.logger.incoming((0, logger_js_1.describeIncomingUpdate)(update));
|
|
155
215
|
const message = update.message
|
|
156
216
|
?? update.edited_message
|
|
157
217
|
?? update.channel_post
|
|
@@ -166,7 +226,7 @@ class Bot {
|
|
|
166
226
|
await this.init();
|
|
167
227
|
if (!this.me)
|
|
168
228
|
return;
|
|
169
|
-
const ctx = new context_js_1.Context({ update, api: this.api, session, services: this.services });
|
|
229
|
+
const ctx = new context_js_1.Context({ update, api: this.api, session, services: this.services, me: this.me });
|
|
170
230
|
await this.events.emit("update", { update });
|
|
171
231
|
if (message) {
|
|
172
232
|
await this.events.emit("message", { message });
|
|
@@ -189,22 +249,42 @@ class Bot {
|
|
|
189
249
|
this.logger.error("update.handler_error", { update: (0, logger_js_1.summarizeUpdate)(update, this.logger.includeUpdateContent), error });
|
|
190
250
|
await this.events.emit("update:error", { update, error });
|
|
191
251
|
await this.events.emit("bot:error", { bot: this, error });
|
|
252
|
+
if (this.errorHandler) {
|
|
253
|
+
// With an error boundary registered, the failure is considered handled:
|
|
254
|
+
// webhooks answer 200 and polling continues without rethrowing.
|
|
255
|
+
await this.errorHandler(error, ctx);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
192
258
|
throw error;
|
|
193
259
|
}
|
|
194
260
|
}
|
|
195
261
|
conversationKey(update) {
|
|
262
|
+
if (update.callback_query) {
|
|
263
|
+
const chat = update.callback_query.message?.chat;
|
|
264
|
+
const userId = update.callback_query.from.id;
|
|
265
|
+
if (chat?.id !== undefined)
|
|
266
|
+
return `${chat.id}:${userId}`;
|
|
267
|
+
return `user:${userId}`;
|
|
268
|
+
}
|
|
269
|
+
if (update.inline_query?.from?.id !== undefined)
|
|
270
|
+
return `user:${update.inline_query.from.id}`;
|
|
271
|
+
if (update.chosen_inline_result?.from?.id !== undefined)
|
|
272
|
+
return `user:${update.chosen_inline_result.from.id}`;
|
|
196
273
|
const message = update.message
|
|
197
274
|
?? update.edited_message
|
|
198
275
|
?? update.channel_post
|
|
199
276
|
?? update.edited_channel_post
|
|
200
277
|
?? update.business_message
|
|
201
278
|
?? update.edited_business_message
|
|
202
|
-
?? update.guest_message
|
|
203
|
-
?? update.callback_query?.message;
|
|
279
|
+
?? update.guest_message;
|
|
204
280
|
if (message?.chat?.id !== undefined)
|
|
205
281
|
return `${message.chat.id}:${message.from?.id ?? "anonymous"}`;
|
|
206
|
-
if (update.
|
|
207
|
-
return
|
|
282
|
+
if (update.chat_member?.chat?.id !== undefined)
|
|
283
|
+
return `${update.chat_member.chat.id}:${update.chat_member.from.id}`;
|
|
284
|
+
if (update.my_chat_member?.chat?.id !== undefined)
|
|
285
|
+
return `${update.my_chat_member.chat.id}:${update.my_chat_member.from.id}`;
|
|
286
|
+
if (update.chat_join_request?.chat?.id !== undefined)
|
|
287
|
+
return `${update.chat_join_request.chat.id}:${update.chat_join_request.from.id}`;
|
|
208
288
|
return `update:${update.update_id}`;
|
|
209
289
|
}
|
|
210
290
|
async waitForRetry(delayMs, signal) {
|
|
@@ -225,9 +305,12 @@ class Bot {
|
|
|
225
305
|
}
|
|
226
306
|
async poll(timeout, allowedUpdates, signal) {
|
|
227
307
|
let delay = this.pollingOptions.retryDelayMs;
|
|
308
|
+
// Telegram holds a long-poll connection open for `timeout` seconds, so the
|
|
309
|
+
// request timeout must exceed it to avoid aborting a healthy connection.
|
|
310
|
+
const requestTimeoutMs = timeout * 1_000 + 10_000;
|
|
228
311
|
while (!signal.aborted) {
|
|
229
312
|
try {
|
|
230
|
-
const updates = await this.api.
|
|
313
|
+
const updates = await this.api.request("getUpdates", { offset: this.offset, limit: this.pollingOptions.limit, timeout, allowed_updates: allowedUpdates }, signal, { timeoutMs: requestTimeoutMs });
|
|
231
314
|
delay = this.pollingOptions.retryDelayMs;
|
|
232
315
|
for (const update of updates) {
|
|
233
316
|
this.offset = Math.max(this.offset, update.update_id + 1);
|
|
@@ -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)
|
|
@@ -1,12 +1,57 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.Router = void 0;
|
|
3
|
+
exports.Router = exports.UPDATE_FILTER_TYPES = void 0;
|
|
4
4
|
const compose_js_1 = require("../middleware/compose.js");
|
|
5
5
|
function testRegExp(expression, value) {
|
|
6
6
|
expression.lastIndex = 0;
|
|
7
7
|
return expression.test(value);
|
|
8
8
|
}
|
|
9
|
+
/** Update types accepted by `on()` filters, mirroring the Telegram `Update` object. */
|
|
10
|
+
exports.UPDATE_FILTER_TYPES = [
|
|
11
|
+
"message",
|
|
12
|
+
"edited_message",
|
|
13
|
+
"channel_post",
|
|
14
|
+
"edited_channel_post",
|
|
15
|
+
"business_connection",
|
|
16
|
+
"business_message",
|
|
17
|
+
"edited_business_message",
|
|
18
|
+
"deleted_business_messages",
|
|
19
|
+
"guest_message",
|
|
20
|
+
"message_reaction",
|
|
21
|
+
"message_reaction_count",
|
|
22
|
+
"inline_query",
|
|
23
|
+
"chosen_inline_result",
|
|
24
|
+
"callback_query",
|
|
25
|
+
"shipping_query",
|
|
26
|
+
"pre_checkout_query",
|
|
27
|
+
"purchased_paid_media",
|
|
28
|
+
"poll",
|
|
29
|
+
"poll_answer",
|
|
30
|
+
"my_chat_member",
|
|
31
|
+
"chat_member",
|
|
32
|
+
"chat_join_request",
|
|
33
|
+
"chat_boost",
|
|
34
|
+
"removed_chat_boost",
|
|
35
|
+
];
|
|
36
|
+
const updateFilterTypes = new Set(exports.UPDATE_FILTER_TYPES);
|
|
37
|
+
function assertValidFilter(filter) {
|
|
38
|
+
const type = filter.split(":", 1)[0] ?? "";
|
|
39
|
+
if (!updateFilterTypes.has(type))
|
|
40
|
+
throw new TypeError(`Unknown update type in filter "${filter}". Expected one of: ${exports.UPDATE_FILTER_TYPES.join(", ")}.`);
|
|
41
|
+
}
|
|
42
|
+
function matchesFilter(ctx, filter) {
|
|
43
|
+
const separator = filter.indexOf(":");
|
|
44
|
+
const type = separator < 0 ? filter : filter.slice(0, separator);
|
|
45
|
+
const field = separator < 0 ? undefined : filter.slice(separator + 1);
|
|
46
|
+
const payload = ctx.update[type];
|
|
47
|
+
if (payload === undefined || payload === null)
|
|
48
|
+
return false;
|
|
49
|
+
if (field === undefined || field === "")
|
|
50
|
+
return true;
|
|
51
|
+
return typeof payload === "object" && payload[field] !== undefined;
|
|
52
|
+
}
|
|
9
53
|
class Router {
|
|
54
|
+
middlewares = [];
|
|
10
55
|
routes = [];
|
|
11
56
|
sequence = 0;
|
|
12
57
|
matchMode;
|
|
@@ -16,7 +61,7 @@ class Router {
|
|
|
16
61
|
use(...middleware) {
|
|
17
62
|
if (middleware.length === 0)
|
|
18
63
|
return this;
|
|
19
|
-
this.
|
|
64
|
+
this.middlewares.push(...middleware);
|
|
20
65
|
return this;
|
|
21
66
|
}
|
|
22
67
|
route(matcher, ...middleware) {
|
|
@@ -27,29 +72,61 @@ class Router {
|
|
|
27
72
|
}
|
|
28
73
|
command(name, ...middleware) {
|
|
29
74
|
return this.route(async (ctx) => {
|
|
30
|
-
const text = ctx.message?.text;
|
|
75
|
+
const text = ctx.message?.text ?? ctx.message?.caption;
|
|
31
76
|
if (!text?.startsWith("/"))
|
|
32
77
|
return false;
|
|
33
|
-
const
|
|
34
|
-
|
|
78
|
+
const parts = text.slice(1).split(/\s+/);
|
|
79
|
+
const commandWithBot = parts[0] ?? "";
|
|
80
|
+
const [command, botUsername] = commandWithBot.split("@");
|
|
81
|
+
if (botUsername && ctx.me?.username && botUsername.toLowerCase() !== ctx.me.username.toLowerCase()) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
const matched = typeof name === "string"
|
|
35
85
|
? command === name.replace(/^\//, "")
|
|
36
|
-
: testRegExp(name, command);
|
|
86
|
+
: testRegExp(name, command ?? "");
|
|
87
|
+
if (matched) {
|
|
88
|
+
ctx.args = parts.slice(1);
|
|
89
|
+
}
|
|
90
|
+
return matched;
|
|
37
91
|
}, ...middleware);
|
|
38
92
|
}
|
|
39
93
|
text(value, ...middleware) {
|
|
40
|
-
return this.route((ctx) =>
|
|
94
|
+
return this.route((ctx) => {
|
|
95
|
+
const text = ctx.message?.text ?? ctx.message?.caption;
|
|
96
|
+
return text === value;
|
|
97
|
+
}, ...middleware);
|
|
41
98
|
}
|
|
42
99
|
regex(expression, ...middleware) {
|
|
43
|
-
return this.route((ctx) =>
|
|
100
|
+
return this.route((ctx) => {
|
|
101
|
+
const text = ctx.message?.text ?? ctx.message?.caption ?? "";
|
|
102
|
+
const match = text.match(expression);
|
|
103
|
+
if (match) {
|
|
104
|
+
ctx.match = match;
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}, ...middleware);
|
|
44
109
|
}
|
|
45
110
|
callback(pattern, ...middleware) {
|
|
46
111
|
return this.route((ctx) => {
|
|
47
112
|
const data = ctx.callbackQuery?.data ?? "";
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
113
|
+
if (typeof pattern === "string") {
|
|
114
|
+
if (pattern.endsWith("*")) {
|
|
115
|
+
const prefix = pattern.slice(0, -1);
|
|
116
|
+
if (data.startsWith(prefix)) {
|
|
117
|
+
ctx.params = { ...ctx.params, wildcard: data.slice(prefix.length) };
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
return data === pattern;
|
|
123
|
+
}
|
|
124
|
+
const match = data.match(pattern);
|
|
125
|
+
if (match) {
|
|
126
|
+
ctx.match = match;
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
53
130
|
}, ...middleware);
|
|
54
131
|
}
|
|
55
132
|
chat(chatId, ...middleware) {
|
|
@@ -58,6 +135,19 @@ class Router {
|
|
|
58
135
|
return id !== undefined && (id === chatId || String(id) === String(chatId));
|
|
59
136
|
}, ...middleware);
|
|
60
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Registers handlers for update types, with optional payload narrowing:
|
|
140
|
+
* `on("message")`, `on("message:photo")`, `on("callback_query:data")`,
|
|
141
|
+
* or an array such as `["message:text", "callback_query:data"]`.
|
|
142
|
+
*/
|
|
143
|
+
on(filter, ...middleware) {
|
|
144
|
+
const filters = (Array.isArray(filter) ? filter : [filter]).map((value) => String(value));
|
|
145
|
+
if (filters.length === 0)
|
|
146
|
+
throw new Error("At least one update filter is required.");
|
|
147
|
+
for (const value of filters)
|
|
148
|
+
assertValidFilter(value);
|
|
149
|
+
return this.route((ctx) => filters.some((value) => matchesFilter(ctx, value)), ...middleware);
|
|
150
|
+
}
|
|
61
151
|
predicate(matcher, ...middleware) {
|
|
62
152
|
return this.route(matcher, ...middleware);
|
|
63
153
|
}
|
|
@@ -67,18 +157,30 @@ class Router {
|
|
|
67
157
|
});
|
|
68
158
|
}
|
|
69
159
|
async handle(ctx, terminal) {
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
160
|
+
const routeDispatcher = async (currentCtx, next) => {
|
|
161
|
+
const ordered = [...this.routes].sort((left, right) => left.priority - right.priority);
|
|
162
|
+
let matched = false;
|
|
163
|
+
for (const route of ordered) {
|
|
164
|
+
if (!(await route.matcher(currentCtx)))
|
|
165
|
+
continue;
|
|
166
|
+
matched = true;
|
|
167
|
+
await (0, compose_js_1.compose)(route.middleware)(currentCtx);
|
|
168
|
+
if (this.matchMode === "first")
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
if (!matched) {
|
|
172
|
+
if (terminal)
|
|
173
|
+
await terminal();
|
|
174
|
+
else
|
|
175
|
+
await next();
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
if (this.middlewares.length === 0) {
|
|
179
|
+
await routeDispatcher(ctx, async () => { });
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
await (0, compose_js_1.compose)([...this.middlewares, routeDispatcher])(ctx);
|
|
79
183
|
}
|
|
80
|
-
if (!matched)
|
|
81
|
-
await terminal?.();
|
|
82
184
|
}
|
|
83
185
|
}
|
|
84
186
|
exports.Router = Router;
|