@xbibzlibrary/telebibz 0.1.19 → 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.
Files changed (80) hide show
  1. package/CHANGELOG.md +28 -8
  2. package/CONTRIBUTING.md +2 -2
  3. package/README.id.md +25 -5
  4. package/README.md +26 -6
  5. package/README.zh-CN.md +25 -5
  6. package/RELEASE_AUTOMATION.md +17 -5
  7. package/bin/telebibz.mjs +1 -1
  8. package/dist/src/api/client.d.ts +3 -1
  9. package/dist/src/api/client.d.ts.map +1 -1
  10. package/dist/src/api/client.js +3 -1
  11. package/dist/src/api/client.js.map +1 -1
  12. package/dist/src/api/transport.d.ts +3 -1
  13. package/dist/src/api/transport.d.ts.map +1 -1
  14. package/dist/src/api/transport.js +3 -2
  15. package/dist/src/api/transport.js.map +1 -1
  16. package/dist/src/branding/terminal.d.ts +62 -0
  17. package/dist/src/branding/terminal.d.ts.map +1 -1
  18. package/dist/src/branding/terminal.js +258 -0
  19. package/dist/src/branding/terminal.js.map +1 -1
  20. package/dist/src/cli.d.ts.map +1 -1
  21. package/dist/src/cli.js +7 -3
  22. package/dist/src/cli.js.map +1 -1
  23. package/dist/src/context/context.d.ts +24 -1
  24. package/dist/src/context/context.d.ts.map +1 -1
  25. package/dist/src/context/context.js +102 -8
  26. package/dist/src/context/context.js.map +1 -1
  27. package/dist/src/core/bot.d.ts +18 -1
  28. package/dist/src/core/bot.d.ts.map +1 -1
  29. package/dist/src/core/bot.js +106 -23
  30. package/dist/src/core/bot.js.map +1 -1
  31. package/dist/src/keyboard/index.d.ts +12 -0
  32. package/dist/src/keyboard/index.d.ts.map +1 -1
  33. package/dist/src/keyboard/index.js +13 -1
  34. package/dist/src/keyboard/index.js.map +1 -1
  35. package/dist/src/observability/logger.d.ts +28 -1
  36. package/dist/src/observability/logger.d.ts.map +1 -1
  37. package/dist/src/observability/logger.js +110 -0
  38. package/dist/src/observability/logger.js.map +1 -1
  39. package/dist/src/plugins/plugin.d.ts +2 -0
  40. package/dist/src/plugins/plugin.d.ts.map +1 -1
  41. package/dist/src/plugins/plugin.js +11 -4
  42. package/dist/src/plugins/plugin.js.map +1 -1
  43. package/dist/src/router/router.d.ts +19 -0
  44. package/dist/src/router/router.d.ts.map +1 -1
  45. package/dist/src/router/router.js +125 -23
  46. package/dist/src/router/router.js.map +1 -1
  47. package/dist/src/state/forms.d.ts +0 -1
  48. package/dist/src/state/forms.d.ts.map +1 -1
  49. package/dist/src/state/forms.js +27 -24
  50. package/dist/src/state/forms.js.map +1 -1
  51. package/dist/src/storage/storage.d.ts +10 -0
  52. package/dist/src/storage/storage.d.ts.map +1 -1
  53. package/dist/src/storage/storage.js +20 -2
  54. package/dist/src/storage/storage.js.map +1 -1
  55. package/dist/src/utils/text.d.ts +22 -0
  56. package/dist/src/utils/text.d.ts.map +1 -1
  57. package/dist/src/utils/text.js +0 -0
  58. package/dist/src/utils/text.js.map +1 -1
  59. package/dist/src/webhook/handler.d.ts +3 -0
  60. package/dist/src/webhook/handler.d.ts.map +1 -1
  61. package/dist/src/webhook/handler.js +85 -0
  62. package/dist/src/webhook/handler.js.map +1 -1
  63. package/dist-cjs/src/api/client.js +3 -1
  64. package/dist-cjs/src/api/transport.js +3 -2
  65. package/dist-cjs/src/branding/terminal.js +264 -1
  66. package/dist-cjs/src/cli.js +7 -3
  67. package/dist-cjs/src/context/context.js +102 -8
  68. package/dist-cjs/src/core/bot.js +104 -21
  69. package/dist-cjs/src/keyboard/index.js +13 -1
  70. package/dist-cjs/src/observability/logger.js +113 -1
  71. package/dist-cjs/src/plugins/plugin.js +11 -4
  72. package/dist-cjs/src/router/router.js +126 -24
  73. package/dist-cjs/src/state/forms.js +27 -24
  74. package/dist-cjs/src/storage/storage.js +20 -2
  75. package/dist-cjs/src/utils/text.js +0 -0
  76. package/dist-cjs/src/webhook/handler.js +86 -0
  77. package/docs/API.id.md +57 -3
  78. package/docs/API.md +59 -3
  79. package/docs/API.zh-CN.md +56 -2
  80. package/package.json +3 -3
@@ -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
- (0, terminal_js_1.printTerminalBranding)();
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
- retryDelayMs: config.polling?.retryDelayMs ?? 500,
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.logger.info("bot.created", { status: this.statusValue });
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.logger.info("bot.initializing");
96
- const animation = (0, terminal_js_1.startTerminalAnimation)("Connecting to Telegram and initializing bot");
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.logger.info("bot.initialized", { botId: this.me.id, username: this.me.username });
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
- animation.stop("Bot initialized; ready to start");
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
- animation.stop("Error: bot could not initialize");
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
- await this.init();
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.logger.info("bot.starting", { mode: options.mode });
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.logger.info("bot.stopped");
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.debug("update.received", { update: (0, logger_js_1.summarizeUpdate)(update, this.logger.includeUpdateContent) });
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.callback_query?.from?.id !== undefined)
207
- return `user:${update.callback_query.from.id}`;
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.methods.getUpdates({ offset: this.offset, limit: this.pollingOptions.limit, timeout, allowed_updates: allowedUpdates });
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
- async setup() { for (const plugin of this.plugins) {
43
- await plugin.install?.(this.api);
44
- await plugin.setup?.(this.api);
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.routes.push({ priority: -1_000_000 + this.sequence++, matcher: () => true, middleware });
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 command = text.slice(1).split(/[\s@]/, 1)[0] ?? "";
34
- return typeof name === "string"
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) => ctx.message?.text === value, ...middleware);
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) => testRegExp(expression, ctx.message?.text ?? ""), ...middleware);
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
- return typeof pattern === "string"
49
- ? pattern.endsWith("*")
50
- ? data.startsWith(pattern.slice(0, -1))
51
- : data === pattern
52
- : testRegExp(pattern, data);
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 ordered = [...this.routes].sort((left, right) => left.priority - right.priority);
71
- let matched = false;
72
- for (const route of ordered) {
73
- if (!(await route.matcher(ctx)))
74
- continue;
75
- matched = true;
76
- await (0, compose_js_1.compose)(route.middleware)(ctx);
77
- if (this.matchMode === "first")
78
- break;
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;