@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.
Files changed (95) hide show
  1. package/CHANGELOG.md +42 -8
  2. package/CONTRIBUTING.md +2 -2
  3. package/README.id.md +44 -5
  4. package/README.md +45 -6
  5. package/README.zh-CN.md +44 -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 +18 -1
  13. package/dist/src/api/transport.d.ts.map +1 -1
  14. package/dist/src/api/transport.js +26 -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/broadcast/broadcast.d.ts +50 -0
  21. package/dist/src/broadcast/broadcast.d.ts.map +1 -0
  22. package/dist/src/broadcast/broadcast.js +56 -0
  23. package/dist/src/broadcast/broadcast.js.map +1 -0
  24. package/dist/src/cli.d.ts.map +1 -1
  25. package/dist/src/cli.js +7 -3
  26. package/dist/src/cli.js.map +1 -1
  27. package/dist/src/context/context.d.ts +24 -1
  28. package/dist/src/context/context.d.ts.map +1 -1
  29. package/dist/src/context/context.js +102 -8
  30. package/dist/src/context/context.js.map +1 -1
  31. package/dist/src/core/bot.d.ts +61 -2
  32. package/dist/src/core/bot.d.ts.map +1 -1
  33. package/dist/src/core/bot.js +180 -31
  34. package/dist/src/core/bot.js.map +1 -1
  35. package/dist/src/index.d.ts +2 -0
  36. package/dist/src/index.d.ts.map +1 -1
  37. package/dist/src/index.js +2 -0
  38. package/dist/src/index.js.map +1 -1
  39. package/dist/src/keyboard/index.d.ts +12 -0
  40. package/dist/src/keyboard/index.d.ts.map +1 -1
  41. package/dist/src/keyboard/index.js +13 -1
  42. package/dist/src/keyboard/index.js.map +1 -1
  43. package/dist/src/observability/logger.d.ts +28 -1
  44. package/dist/src/observability/logger.d.ts.map +1 -1
  45. package/dist/src/observability/logger.js +110 -0
  46. package/dist/src/observability/logger.js.map +1 -1
  47. package/dist/src/plugins/plugin.d.ts +2 -0
  48. package/dist/src/plugins/plugin.d.ts.map +1 -1
  49. package/dist/src/plugins/plugin.js +11 -4
  50. package/dist/src/plugins/plugin.js.map +1 -1
  51. package/dist/src/router/router.d.ts +19 -0
  52. package/dist/src/router/router.d.ts.map +1 -1
  53. package/dist/src/router/router.js +125 -23
  54. package/dist/src/router/router.js.map +1 -1
  55. package/dist/src/state/forms.d.ts +0 -1
  56. package/dist/src/state/forms.d.ts.map +1 -1
  57. package/dist/src/state/forms.js +27 -24
  58. package/dist/src/state/forms.js.map +1 -1
  59. package/dist/src/storage/storage.d.ts +10 -0
  60. package/dist/src/storage/storage.d.ts.map +1 -1
  61. package/dist/src/storage/storage.js +20 -2
  62. package/dist/src/storage/storage.js.map +1 -1
  63. package/dist/src/utils/concurrency.d.ts +25 -0
  64. package/dist/src/utils/concurrency.d.ts.map +1 -0
  65. package/dist/src/utils/concurrency.js +52 -0
  66. package/dist/src/utils/concurrency.js.map +1 -0
  67. package/dist/src/utils/text.d.ts +22 -0
  68. package/dist/src/utils/text.d.ts.map +1 -1
  69. package/dist/src/utils/text.js +0 -0
  70. package/dist/src/utils/text.js.map +1 -1
  71. package/dist/src/webhook/handler.d.ts +3 -0
  72. package/dist/src/webhook/handler.d.ts.map +1 -1
  73. package/dist/src/webhook/handler.js +85 -0
  74. package/dist/src/webhook/handler.js.map +1 -1
  75. package/dist-cjs/src/api/client.js +3 -1
  76. package/dist-cjs/src/api/transport.js +26 -2
  77. package/dist-cjs/src/branding/terminal.js +264 -1
  78. package/dist-cjs/src/broadcast/broadcast.js +58 -0
  79. package/dist-cjs/src/cli.js +7 -3
  80. package/dist-cjs/src/context/context.js +102 -8
  81. package/dist-cjs/src/core/bot.js +178 -29
  82. package/dist-cjs/src/index.js +2 -0
  83. package/dist-cjs/src/keyboard/index.js +13 -1
  84. package/dist-cjs/src/observability/logger.js +113 -1
  85. package/dist-cjs/src/plugins/plugin.js +11 -4
  86. package/dist-cjs/src/router/router.js +126 -24
  87. package/dist-cjs/src/state/forms.js +27 -24
  88. package/dist-cjs/src/storage/storage.js +20 -2
  89. package/dist-cjs/src/utils/concurrency.js +57 -0
  90. package/dist-cjs/src/utils/text.js +0 -0
  91. package/dist-cjs/src/webhook/handler.js +86 -0
  92. package/docs/API.id.md +120 -4
  93. package/docs/API.md +122 -4
  94. package/docs/API.zh-CN.md +119 -3
  95. package/package.json +3 -3
@@ -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
- (0, terminal_js_1.printTerminalBranding)();
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
- retryDelayMs: config.polling?.retryDelayMs ?? 500,
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.logger.info("bot.created", { status: this.statusValue });
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.logger.info("bot.initializing");
96
- const animation = (0, terminal_js_1.startTerminalAnimation)("Connecting to Telegram and initializing bot");
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.logger.info("bot.initialized", { botId: this.me.id, username: this.me.username });
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
- animation.stop("Bot initialized; ready to start");
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
- animation.stop("Error: bot could not initialize");
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
- await this.init();
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.logger.info("bot.starting", { mode: options.mode });
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.logger.info("bot.stopped");
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
- this.logger.debug("update.received", { update: (0, logger_js_1.summarizeUpdate)(update, this.logger.includeUpdateContent) });
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.init();
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.callback_query?.from?.id !== undefined)
207
- return `user:${update.callback_query.from.id}`;
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.methods.getUpdates({ offset: this.offset, limit: this.pollingOptions.limit, timeout, allowed_updates: allowedUpdates });
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)
@@ -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
- 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)