@xbibzlibrary/telebibz 3.1.5 → 3.1.7

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/README.id.md CHANGED
@@ -202,6 +202,7 @@ jadi `BotError` dan mengalir ke `opts.onError` atau reporter manusiawi.
202
202
  ```js
203
203
  const bot = new TeleBibz('TOKEN', {
204
204
  allowedUpdates: [...], // batasi tipe update (default: tipe umum + Business)
205
+ maxConcurrentUpdates: 256, // batas update aktif (default: 256)
205
206
  onError: (err, ctx) {}, // handle error sendiri (default: reporter manusiawi)
206
207
  silent: false, // tanpa banner boot
207
208
  dropPending: false, // buang update lama saat start
package/README.md CHANGED
@@ -201,6 +201,7 @@ and flows to `opts.onError` or the humanized reporter.
201
201
  ```js
202
202
  const bot = new TeleBibz('TOKEN', {
203
203
  allowedUpdates: [...], // limit update types (default: common + Business types)
204
+ maxConcurrentUpdates: 256, // active update limit (default: 256)
204
205
  onError: (err, ctx) {}, // custom error handler (default: humanized reporter)
205
206
  silent: false, // hide boot banner
206
207
  dropPending: false, // discard old updates on start
package/index.d.ts CHANGED
@@ -68,6 +68,8 @@ export interface SessionOptions {
68
68
  }
69
69
  export interface TeleBibzOptions {
70
70
  allowedUpdates?: string[];
71
+ /** Maximum active updates per bot process. Default: 256. Updates sharing a session key are serialized. */
72
+ maxConcurrentUpdates?: number;
71
73
  onError?: (err: unknown, ctx?: Context) => unknown;
72
74
  silent?: boolean;
73
75
  dropPending?: boolean;
@@ -146,8 +148,8 @@ export class Composer {
146
148
  hears(match: string | RegExp, ...middleware: Handler[]): this;
147
149
  command(names: string | string[], ...middleware: Handler[]): this;
148
150
  callbackQuery(triggers: string | RegExp | Array<string | RegExp>, ...middleware: Handler[]): this;
149
- filter(predicate: (ctx: Context) => unknown, ...middleware: Handler[]): Composer;
150
- drop(predicate: (ctx: Context) => unknown, ...middleware: Handler[]): Composer;
151
+ filter(predicate: (ctx: Context) => unknown, ...middleware: Array<Handler | Composer>): Composer;
152
+ drop(predicate: (ctx: Context) => unknown, ...middleware: Array<Handler | Composer>): Composer;
151
153
  branch(predicate: (ctx: Context) => unknown, yes: any, no: any): this;
152
154
  route(router: string | ((ctx: Context) => unknown), routes: Record<string, any>): this;
153
155
  lazy(factory: (ctx: Context) => any): this;
@@ -162,11 +164,18 @@ export class TeleBibz {
162
164
  constructor(token: string, opts?: TeleBibzOptions);
163
165
  api: ApiClient;
164
166
  botInfo?: any;
165
- use(...middleware: Handler[]): this;
167
+ use(...middleware: Array<Handler | Composer>): this;
166
168
  cmd(names: string | string[], ...middleware: Handler[]): this;
167
169
  hears(match: string | RegExp, ...middleware: Handler[]): this;
168
170
  action(triggers: string | RegExp | Array<string | RegExp>, ...middleware: Handler[]): this;
169
171
  on(filter: string | string[], ...middleware: Handler[]): this;
172
+ inlineQuery(trigger: string | RegExp, ...middleware: Handler[]): this;
173
+ branch(predicate: (ctx: Context) => unknown, yes: Handler | Composer, no: Handler | Composer): this;
174
+ filter(predicate: (ctx: Context) => unknown, ...middleware: Array<Handler | Composer>): this;
175
+ drop(predicate: (ctx: Context) => unknown, ...middleware: Array<Handler | Composer>): this;
176
+ route(router: string | ((ctx: Context) => unknown), routes: Record<string, Handler | Composer>): this;
177
+ lazy(factory: (ctx: Context) => Handler | Handler[] | Composer | Promise<Handler | Handler[] | Composer>): this;
178
+ fork(...middleware: Array<Handler | Composer>): this;
170
179
  start(replies: string | Handler): this;
171
180
  wizard(id: string, def: WizardDef, bindCommand?: boolean): this;
172
181
  wizardStart(ctx: Context, id: string): Promise<unknown>;
package/lib/runner.js CHANGED
@@ -1,18 +1,39 @@
1
- // lib/runner.js — long polling tahan banting (retry 409 & jaringan, backoff halus).
1
+ // lib/runner.js — long polling tahan banting dengan dispatch konkuren.
2
2
  'use strict';
3
3
 
4
4
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
5
5
 
6
6
  /**
7
- * Loop getUpdates; memanggil handleUpdate per update.
8
- * stop() mengamaninya keluar loop dengan bersih.
9
- * Perilaku:
10
- * - error jaringan (fetch gagal) → retry 1 dtk
11
- * - 409 (instance lain masih polling) → log callback onConflict + retry 5 dtk
12
- * - error API lain → oper ke onFatal (default: berhenti bersih)
7
+ * Loop getUpdates dengan backpressure. Update dipanggil tanpa menunggu satu sama
8
+ * lain; handleUpdate mengatur batas worker dan urutan per session key. Promise
9
+ * yang masih berjalan tetap dilacak agar stop() dapat melakukan graceful drain.
13
10
  */
14
- async function pollLoop({ api, handleUpdate, allowedUpdates, dropPending, onConflict, onFatal, shouldStop, conflictDelay = 5000 }) {
11
+ async function pollLoop({
12
+ api,
13
+ handleUpdate,
14
+ allowedUpdates,
15
+ dropPending,
16
+ onConflict,
17
+ onFatal,
18
+ shouldStop,
19
+ conflictDelay = 5000,
20
+ maxInFlightUpdates = 800,
21
+ }) {
15
22
  let offset = 0;
23
+ const inFlight = new Set();
24
+ const inFlightLimit = Number.isSafeInteger(maxInFlightUpdates) && maxInFlightUpdates > 0
25
+ ? maxInFlightUpdates
26
+ : 800;
27
+
28
+ const dispatch = (update) => {
29
+ let task;
30
+ task = Promise.resolve()
31
+ .then(() => handleUpdate(update))
32
+ .catch(() => { /* error sudah ditangani bot; jangan hentikan poller */ })
33
+ .then(() => inFlight.delete(task));
34
+ inFlight.add(task);
35
+ };
36
+
16
37
  if (dropPending) {
17
38
  try {
18
39
  const u = await api.callApi('getUpdates', { offset: -1, timeout: 0, allowed_updates: allowedUpdates });
@@ -20,34 +41,44 @@ async function pollLoop({ api, handleUpdate, allowedUpdates, dropPending, onConf
20
41
  } catch { /* abaikan — loop utama yang menangani */ }
21
42
  }
22
43
 
23
- while (!shouldStop()) {
24
- let updates;
25
- try {
26
- updates = await api.callApi('getUpdates', { offset, timeout: 25, allowed_updates: allowedUpdates });
27
- if (!Array.isArray(updates)) updates = [];
28
- } catch (err) {
29
- const code = err && (err.error_code || err.code);
30
- if (shouldStop()) break;
31
- if (code === 409) { if (onConflict) await onConflict(err); await sleep(conflictDelay); continue; }
32
- if (code === 429) {
33
- const retryAfter = Number(err && err.parameters && err.parameters.retry_after);
34
- await sleep(Number.isFinite(retryAfter) && retryAfter >= 0 ? retryAfter * 1000 : 1000);
35
- continue;
44
+ try {
45
+ while (!shouldStop()) {
46
+ let updates;
47
+ try {
48
+ updates = await api.callApi('getUpdates', { offset, timeout: 25, allowed_updates: allowedUpdates });
49
+ if (!Array.isArray(updates)) updates = [];
50
+ } catch (err) {
51
+ const code = err && (err.error_code || err.code);
52
+ if (shouldStop()) break;
53
+ if (code === 409) { if (onConflict) await onConflict(err); await sleep(conflictDelay); continue; }
54
+ if (code === 429) {
55
+ const retryAfter = Number(err && err.parameters && err.parameters.retry_after);
56
+ await sleep(Number.isFinite(retryAfter) && retryAfter >= 0 ? retryAfter * 1000 : 1000);
57
+ continue;
58
+ }
59
+ if (Number.isInteger(code) && code >= 500 && code < 600) { await sleep(1000); continue; }
60
+ const networkCode = err && ['ECONNABORTED', 'ETIMEDOUT', 'ECONNRESET', 'EAI_AGAIN', 'ENOTFOUND', 'EPIPE', 'ENETUNREACH', 'ECONNREFUSED'].includes(err.code);
61
+ if (!err || err.network || networkCode || !err.error_code) {
62
+ await sleep(1000); continue; // jaringan putus/time-out: retry, bukan fatal
63
+ }
64
+ if (onFatal) await onFatal(err);
65
+ break;
36
66
  }
37
- if (Number.isInteger(code) && code >= 500 && code < 600) { await sleep(1000); continue; }
38
- const networkCode = err && ['ECONNABORTED', 'ETIMEDOUT', 'ECONNRESET', 'EAI_AGAIN', 'ENOTFOUND', 'EPIPE', 'ENETUNREACH', 'ECONNREFUSED'].includes(err.code);
39
- if (!err || err.network || networkCode || !err.error_code) {
40
- await sleep(1000); continue; // jaringan putus/time-out: retry, bukan fatal
41
- }
42
- if (onFatal) await onFatal(err);
43
- break;
44
- }
45
67
 
46
- for (const u of updates) {
47
- if (shouldStop()) break;
48
- offset = u.update_id + 1;
49
- try { await handleUpdate(u); } catch { /* sudah ditangani error handler bot */ }
68
+ for (const update of updates) {
69
+ if (shouldStop()) break;
70
+ while (inFlight.size >= inFlightLimit) {
71
+ await Promise.race(inFlight);
72
+ if (shouldStop()) break;
73
+ }
74
+ if (shouldStop()) break;
75
+ offset = update.update_id + 1;
76
+ dispatch(update);
77
+ }
50
78
  }
79
+ } finally {
80
+ // Stop/fatal error menghentikan intake, bukan memutus handler yang sedang berjalan.
81
+ await Promise.all([...inFlight]);
51
82
  }
52
83
  }
53
84
 
package/lib/session.js CHANGED
@@ -6,22 +6,41 @@
6
6
  * - default getKey: from.id + chat.id (khas), fallback chat.id.
7
7
  * - default storage: Map di memori. Boleh kirim storage custom { read(key), write(key,val), delete(key) }.
8
8
  */
9
- function session(opts = {}) {
10
- const initial = opts.initial || (() => ({}));
11
- const store = opts.storage || new Map();
12
- const getKey = opts.getKey || ((ctx) => {
13
- const from = ctx.from && ctx.from.id;
14
- const chat = ctx.chat && ctx.chat.id;
9
+ const SESSION_KEY_CACHE = Symbol('telebibz.sessionKeyCache');
10
+
11
+ function getSessionKey(ctx, opts = {}) {
12
+ const cached = ctx[SESSION_KEY_CACHE];
13
+ if (cached) {
14
+ if (cached.failed) throw cached.error;
15
+ return cached.key;
16
+ }
17
+
18
+ const getKey = opts.getKey || ((current) => {
19
+ const from = current.from && current.from.id;
20
+ const chat = current.chat && current.chat.id;
15
21
  if (from !== undefined && chat !== undefined) return `${from}:${chat}`;
16
22
  return chat !== undefined ? String(chat) : undefined;
17
23
  });
24
+ try {
25
+ const key = getKey(ctx);
26
+ Object.defineProperty(ctx, SESSION_KEY_CACHE, { value: { key, failed: false } });
27
+ return key;
28
+ } catch (error) {
29
+ Object.defineProperty(ctx, SESSION_KEY_CACHE, { value: { error, failed: true } });
30
+ throw error;
31
+ }
32
+ }
33
+
34
+ function session(opts = {}) {
35
+ const initial = opts.initial || (() => ({}));
36
+ const store = opts.storage || new Map();
18
37
 
19
38
  const read = (k) => (store.read ? store.read(k) : store.get(k));
20
39
  const write = (k, v) => (store.write ? store.write(k, v) : store.set(k, v));
21
40
  const del = (k) => (store.delete ? store.delete(k) : store.delete(k));
22
41
 
23
42
  return async (ctx, next) => {
24
- const key = getKey(ctx);
43
+ const key = getSessionKey(ctx, opts);
25
44
  if (key === undefined) { ctx.session = {}; return next(); }
26
45
  let s = await read(key);
27
46
  if (s === undefined || s === null) s = initial();
@@ -41,4 +60,4 @@ function session(opts = {}) {
41
60
  };
42
61
  }
43
62
 
44
- module.exports = { session };
63
+ module.exports = { session, getSessionKey };
package/lib/telebibz.js CHANGED
@@ -4,10 +4,11 @@
4
4
  const { makeApi } = require('./api');
5
5
  const { Context } = require('./context');
6
6
  const { Composer } = require('./composer');
7
- const { session } = require('./session');
7
+ const { session, getSessionKey } = require('./session');
8
8
  const { matchInlineQuery } = require('./inline-query');
9
9
  const { Menu, MenuContainer } = require('./menus');
10
10
  const { pollLoop } = require('./runner');
11
+ const { UpdateProcessor } = require('./update-processor');
11
12
  const wizard = require('./wizard');
12
13
  const log = require('./logger');
13
14
  const { humanize } = require('./errors');
@@ -22,11 +23,13 @@ const DEFAULT_UPDATES = [
22
23
  'purchased_paid_media', 'poll', 'poll_answer', 'my_chat_member', 'chat_member',
23
24
  'chat_join_request', 'chat_boost', 'removed_chat_boost', 'stopped_message_generation',
24
25
  ];
26
+ const DEFAULT_MAX_CONCURRENT_UPDATES = 256;
25
27
 
26
28
  class TeleBibz {
27
29
  /**
28
30
  * new TeleBibz('123:ABC', {
29
- * allowedUpdates: [...], onError(err, ctx), silent, dropPending,
31
+ * allowedUpdates: [...], maxConcurrentUpdates: 256,
32
+ * onError(err, ctx), silent, dropPending,
30
33
  * transport: (method, payload) => Promise<result>, // ← suntik untuk test
31
34
  * session: {...opsi session}, apiRoot: 'https://api.telegram.org'
32
35
  * })
@@ -35,8 +38,14 @@ class TeleBibz {
35
38
  if (!token || !/^\d+:[\w-]+$/.test(token)) {
36
39
  throw new Error('TeleBibz: token tidak valid. Ambil dari @BotFather → /newbot.');
37
40
  }
41
+ const maxConcurrentUpdates = opts.maxConcurrentUpdates ?? DEFAULT_MAX_CONCURRENT_UPDATES;
42
+ if (!Number.isSafeInteger(maxConcurrentUpdates) || maxConcurrentUpdates < 1) {
43
+ throw new RangeError('TeleBibz: maxConcurrentUpdates harus bilangan bulat positif.');
44
+ }
38
45
  this.opts = opts;
39
46
  this.token = token;
47
+ this.maxConcurrentUpdates = maxConcurrentUpdates;
48
+ this._updateProcessor = new UpdateProcessor(maxConcurrentUpdates);
40
49
  this.api = makeApi(token, opts.transport, opts);
41
50
  this.botInfo = undefined;
42
51
 
@@ -109,8 +118,25 @@ class TeleBibz {
109
118
  /** Tangani SATU update mentah (webhook/serverless/test). */
110
119
  async handleUpdate(update) {
111
120
  const ctx = new Context(update, this.api, this.botInfo);
112
- try { await this._root.run(ctx); }
113
- catch (err) { await this._report({ error: err, ctx }); }
121
+ await this._updateProcessor.process(this._updateKey(ctx), async () => {
122
+ try { await this._root.run(ctx); }
123
+ catch (err) { await this._report({ error: err, ctx }); }
124
+ });
125
+ }
126
+
127
+ /** Updates dengan session key sama berjalan berurutan; key berbeda berjalan paralel. */
128
+ _updateKey(ctx) {
129
+ let key;
130
+ try { key = getSessionKey(ctx, this.opts.session || {}); }
131
+ catch { /* session middleware reports the cached getKey error */ }
132
+ if (key !== undefined) return `session:${String(key)}`;
133
+
134
+ // Updates without a session key (for example inline queries) still keep a
135
+ // single sender's stateful handlers ordered when sender information exists.
136
+ const fromId = ctx.from && ctx.from.id;
137
+ if (fromId !== undefined) return `user:${String(fromId)}`;
138
+ const chatId = ctx.chat && ctx.chat.id;
139
+ return chatId === undefined ? undefined : `chat:${String(chatId)}`;
114
140
  }
115
141
 
116
142
  /** Nyalakan long polling (dengan retry 409 & backoff jaringan). */
@@ -130,6 +156,7 @@ class TeleBibz {
130
156
  api: this.api,
131
157
  handleUpdate: (u) => this.handleUpdate(u),
132
158
  allowedUpdates: this.opts.allowedUpdates || DEFAULT_UPDATES,
159
+ maxInFlightUpdates: Math.max(100, this.maxConcurrentUpdates * 4),
133
160
  dropPending: launchOpts.dropPending ?? this.opts.dropPending ?? false,
134
161
  shouldStop: () => this._stopping,
135
162
  conflictDelay: launchOpts.conflictDelay ?? 5000,
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Execute independent updates concurrently while keeping a session key ordered.
5
+ * A Promise returned by process() resolves only after that update is complete.
6
+ */
7
+ class UpdateProcessor {
8
+ constructor(maxConcurrentUpdates = 256) {
9
+ this.maxConcurrentUpdates = maxConcurrentUpdates;
10
+ this.active = 0;
11
+ this.pending = [];
12
+ this.activeKeys = new Set();
13
+ }
14
+
15
+ process(key, handler) {
16
+ return new Promise((resolve, reject) => {
17
+ this.pending.push({ key: normalizeKey(key), handler, resolve, reject });
18
+ this._drain();
19
+ });
20
+ }
21
+
22
+ _drain() {
23
+ while (this.active < this.maxConcurrentUpdates) {
24
+ // Preserve FIFO ordering for the same session, but skip a busy key so
25
+ // updates from other users can continue through the queue.
26
+ const index = this.pending.findIndex((task) => task.key === undefined || !this.activeKeys.has(task.key));
27
+ if (index === -1) return;
28
+
29
+ const [task] = this.pending.splice(index, 1);
30
+ if (task.key !== undefined) this.activeKeys.add(task.key);
31
+ this.active++;
32
+
33
+ Promise.resolve()
34
+ .then(task.handler)
35
+ .then(
36
+ (value) => task.resolve(value),
37
+ (error) => task.reject(error),
38
+ )
39
+ .then(() => {
40
+ this.active--;
41
+ if (task.key !== undefined) this.activeKeys.delete(task.key);
42
+ this._drain();
43
+ });
44
+ }
45
+ }
46
+ }
47
+
48
+ function normalizeKey(key) {
49
+ if (key === undefined) return undefined;
50
+ if (key === null) return 'null:null';
51
+ const type = typeof key;
52
+ if (!['string', 'number', 'bigint', 'boolean'].includes(type)) return undefined;
53
+ return `${type}:${String(key)}`;
54
+ }
55
+
56
+ module.exports = { UpdateProcessor };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xbibzlibrary/telebibz",
3
- "version": "3.1.5",
3
+ "version": "3.1.7",
4
4
  "description": "Telegram Bot API 10.3 framework: all 185 methods with typed payloads, rich messages, live photos, drafts, ephemeral messages, inline queries, sessions, webhooks, menus and wizards. Xbibz Technology ID",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -36,7 +36,11 @@
36
36
  },
37
37
  "scripts": {
38
38
  "test": "node test/all.test.js && node test/audit.test.js",
39
- "typecheck": "tsc --project tsconfig.types.json"
39
+ "typecheck": "tsc --project tsconfig.types.json",
40
+ "docs:dev": "vitepress dev docs --host 0.0.0.0",
41
+ "docs:gen-api": "node scripts/generate-api-methods.js",
42
+ "docs:build": "npm run docs:gen-api && vitepress build docs",
43
+ "docs:preview": "vitepress preview docs --host 0.0.0.0"
40
44
  },
41
45
  "files": [
42
46
  "index.js",
@@ -51,6 +55,20 @@
51
55
  "CHANGELOG.md"
52
56
  ],
53
57
  "devDependencies": {
54
- "typescript": "^7.0.2"
58
+ "@lucide/vue": "^1.50.0",
59
+ "@tailwindcss/vite": "^4.3.3",
60
+ "@vueuse/core": "^14.4.0",
61
+ "class-variance-authority": "^0.7.1",
62
+ "clsx": "^2.1.1",
63
+ "reka-ui": "^2.10.5",
64
+ "tailwind-merge": "^3.7.0",
65
+ "tailwindcss": "^4.3.3",
66
+ "tw-animate-css": "^1.4.0",
67
+ "typescript": "^7.0.2",
68
+ "vitepress": "^1.6.4"
69
+ },
70
+ "overrides": {
71
+ "vite": "^6.4.3",
72
+ "esbuild": "^0.25.0"
55
73
  }
56
74
  }
package/test/all.test.js CHANGED
@@ -6,7 +6,7 @@ const { TeleBibz, btn, url, kb, webApp, copy, humanize, File, InlineKeyboard,
6
6
  Menu, MenuContainer, iq, autoRetry, throttler, limiter, InputMediaBuilder } = require('..');
7
7
 
8
8
  let N = 0, OK = 0;
9
- const TOTAL = 30;
9
+ const TOTAL = 33;
10
10
  const t = (nama, fn) => {
11
11
  const done = () => { N++; if (N === TOTAL) { console.log(`\n${OK}/${N} lulus`); process.exit(OK === N ? 0 : 1); } };
12
12
  Promise.resolve()
@@ -16,7 +16,7 @@ const t = (nama, fn) => {
16
16
  };
17
17
 
18
18
  /* ===== harness: bot dengan transport palsu (merekam panggilan API) ===== */
19
- function buatBot(overrides = {}) {
19
+ function buatBot(overrides = {}, options = {}) {
20
20
  const calls = [];
21
21
  const transport = async (method, payload = {}) => {
22
22
  calls.push({ method, payload });
@@ -25,7 +25,7 @@ function buatBot(overrides = {}) {
25
25
  if (overrides[method]) return overrides[method](payload);
26
26
  return true;
27
27
  };
28
- const bot = new TeleBibz('123456:TESTTOKEN-TESTTOKEN-TESTTOKEN-TESTOKEN', { silent: true, transport });
28
+ const bot = new TeleBibz('123456:TESTTOKEN-TESTTOKEN-TESTTOKEN-TESTOKEN', { ...options, silent: true, transport });
29
29
  return { bot, calls, me: null };
30
30
  }
31
31
  async function boot(b) { await b.init(); }
@@ -202,6 +202,68 @@ t('launch: getUpdates 409 → retry halus; stop() resolve runPromise', async ()
202
202
  assert.ok(polls >= 2, `harus retry minimal sekali, polls=${polls}`);
203
203
  });
204
204
 
205
+ /* ===== Concurrency: 256 pengguna berbeda diproses tanpa serial menunggu ===== */
206
+ t('handleUpdate menjalankan 256 pengguna secara konkuren secara default', async () => {
207
+ const { bot } = buatBot();
208
+ await boot(bot);
209
+ const target = 256;
210
+ let active = 0, peak = 0, started = 0, release;
211
+ const gate = new Promise((resolve) => { release = resolve; });
212
+ const fallback = setTimeout(release, 1000);
213
+ bot.on(':text', async () => {
214
+ active++;
215
+ started++;
216
+ peak = Math.max(peak, active);
217
+ if (started === target) release();
218
+ await gate;
219
+ active--;
220
+ });
221
+ const updates = Array.from({ length: target }, (_, i) => ({
222
+ update_id: 10000 + i,
223
+ message: { message_id: i + 1, date: 1, text: `user-${i}`, from: { id: i + 1 }, chat: { id: i + 1, type: 'private' } },
224
+ }));
225
+ await Promise.all(updates.map((update) => bot.handleUpdate(update)));
226
+ clearTimeout(fallback);
227
+ assert.equal(started, target);
228
+ assert.equal(peak, target, 'semua 256 update harus dapat berjalan bersamaan');
229
+ });
230
+
231
+ t('maxConcurrentUpdates membatasi worker update dan memvalidasi nilai', async () => {
232
+ const { bot } = buatBot({}, { maxConcurrentUpdates: 3 });
233
+ await boot(bot);
234
+ let active = 0, peak = 0;
235
+ bot.on(':text', async () => {
236
+ active++;
237
+ peak = Math.max(peak, active);
238
+ await new Promise((resolve) => setTimeout(resolve, 5));
239
+ active--;
240
+ });
241
+ const updates = Array.from({ length: 12 }, (_, i) => ({
242
+ update_id: 20000 + i,
243
+ message: { message_id: i + 1, date: 1, text: `user-${i}`, from: { id: i + 1 }, chat: { id: i + 1, type: 'private' } },
244
+ }));
245
+ await Promise.all(updates.map((update) => bot.handleUpdate(update)));
246
+ assert.equal(peak, 3);
247
+ assert.throws(() => new TeleBibz('123456:TESTTOKEN', { maxConcurrentUpdates: 0 }), /maxConcurrentUpdates/);
248
+ });
249
+
250
+ t('update dari session key sama tetap berurutan untuk menjaga state', async () => {
251
+ const { bot } = buatBot();
252
+ await boot(bot);
253
+ const order = [];
254
+ bot.on(':text', async (ctx) => {
255
+ order.push(`start:${ctx.msg.text}`);
256
+ await new Promise((resolve) => setTimeout(resolve, 5));
257
+ order.push(`end:${ctx.msg.text}`);
258
+ });
259
+ const makeUpdate = (update_id, text) => ({
260
+ update_id,
261
+ message: { message_id: update_id, date: 1, text, from: { id: 700 }, chat: { id: 700, type: 'private' } },
262
+ });
263
+ await Promise.all([bot.handleUpdate(makeUpdate(1, 'first')), bot.handleUpdate(makeUpdate(2, 'second'))]);
264
+ assert.deepEqual(order, ['start:first', 'end:first', 'start:second', 'end:second']);
265
+ });
266
+
205
267
  /* ===== 15. Proxy API generik: metode apapun ===== */
206
268
  t('Proxy: api.metodeBebas(payload) → callApi mentah', async () => {
207
269
  const { bot, calls } = buatBot(); await boot(bot);
@@ -77,6 +77,30 @@ async function main() {
77
77
  assert.equal(data.get('7:7').count, 2);
78
78
  });
79
79
 
80
+ await test('session.getKey dihitung sekali dan menjaga urutan lintas update', async () => {
81
+ let keyCalls = 0;
82
+ const storage = new Map();
83
+ const bot = new TeleBibz('123456:TESTTOKEN', {
84
+ silent: true,
85
+ transport: async () => true,
86
+ session: { storage, getKey: (ctx) => { keyCalls++; return String(ctx.from.id); } },
87
+ });
88
+ const observed = [];
89
+ bot.on(':text', async (ctx) => {
90
+ observed.push(ctx.session.count || 0);
91
+ await new Promise((resolve) => setTimeout(resolve, 2));
92
+ ctx.session.count = (ctx.session.count || 0) + 1;
93
+ });
94
+ const makeUpdate = (update_id, chatId) => ({
95
+ update_id,
96
+ message: { message_id: update_id, date: 1, text: 'increment', from: { id: 7 }, chat: { id: chatId, type: 'group' } },
97
+ });
98
+ await Promise.all([bot.handleUpdate(makeUpdate(1, 70)), bot.handleUpdate(makeUpdate(2, 71))]);
99
+ assert.equal(keyCalls, 2, 'getKey hanya perlu dipanggil sekali untuk setiap update');
100
+ assert.deepEqual(observed, [0, 1]);
101
+ assert.equal(storage.get('7').count, 2);
102
+ });
103
+
80
104
  await test('guest_message diteruskan ke Context dan answerGuestQuery membentuk payload resmi', async () => {
81
105
  const calls = [];
82
106
  const bot = new TeleBibz('123456:TESTTOKEN', { silent: true, transport: async (m, p) => { calls.push({ m, p }); return true; } });
@@ -135,6 +159,35 @@ async function main() {
135
159
  assert.equal(fatals, 0);
136
160
  });
137
161
 
162
+ await test('poller dispatch batch secara konkuren dan menunggu handler saat stop', async () => {
163
+ const updates = Array.from({ length: 12 }, (_, i) => ({ update_id: i + 1 }));
164
+ let polls = 0, stopped = false, active = 0, peak = 0, entered = 0, release;
165
+ const gate = new Promise((resolve) => { release = resolve; });
166
+ const fallback = setTimeout(release, 1000);
167
+ const api = { callApi: async () => {
168
+ polls++;
169
+ if (polls === 1) return updates;
170
+ stopped = true;
171
+ return [];
172
+ } };
173
+ await pollLoop({
174
+ api,
175
+ handleUpdate: async () => {
176
+ active++;
177
+ entered++;
178
+ peak = Math.max(peak, active);
179
+ if (entered === updates.length) release();
180
+ await gate;
181
+ active--;
182
+ },
183
+ allowedUpdates: [], dropPending: false, shouldStop: () => stopped, maxInFlightUpdates: updates.length,
184
+ });
185
+ clearTimeout(fallback);
186
+ assert.equal(entered, updates.length);
187
+ assert.equal(peak, updates.length, 'poller tidak boleh menunggu satu update sebelum dispatch berikutnya');
188
+ assert.equal(active, 0, 'pollLoop menyelesaikan graceful drain sebelum return');
189
+ });
190
+
138
191
  await test('throttler tetap menjalankan antrean setelah satu request gagal', async () => {
139
192
  const { throttler } = require('../lib/ratelimit');
140
193
  const calls = [];
@@ -1,6 +1,16 @@
1
- import { TeleBibz, rich, type TelegramMethodName, type TelegramMethodPayload, TelegramTypes } from '..';
1
+ import { Composer, TeleBibz, rich, type TelegramMethodName, type TelegramMethodPayload, TelegramTypes } from '..';
2
2
 
3
3
  const bot = new TeleBibz('123456:TESTTOKEN', { silent: true });
4
+ const privateRoutes = new Composer();
5
+ privateRoutes.filter((ctx) => ctx.chat?.type === 'private').command('profile', (ctx) => ctx.reply('profile'));
6
+ bot.use(privateRoutes);
7
+ bot.filter((ctx) => ctx.chat?.type === 'private', (ctx) => ctx.reply('private filter'));
8
+ bot.drop((ctx) => ctx.from?.is_bot === true, (ctx) => ctx.reply('drop route'));
9
+ bot.branch((ctx) => Boolean(ctx.chat), (ctx) => ctx.reply('chat'), (ctx) => ctx.reply('no chat'));
10
+ bot.route((ctx) => ctx.chat?.type ?? 'unknown', { private: (ctx) => ctx.reply('private') });
11
+ bot.lazy(() => (ctx) => ctx.reply('lazy'));
12
+ bot.fork((ctx) => ctx.reply('fork'));
13
+ bot.inlineQuery('*', (ctx) => ctx.answerInlineQuery([]));
4
14
  const message = rich.blocks([
5
15
  rich.heading('Typed rich content', 2),
6
16
  rich.paragraph(['Status: ', rich.bold('ready')]),