@llblab/pi-telegram 0.54.4 → 0.55.0

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/AGENTS.md +4 -2
  2. package/BACKLOG.md +1 -1
  3. package/CHANGELOG.md +11 -0
  4. package/README.md +5 -3
  5. package/dist/lib/activity.d.ts +5 -1
  6. package/dist/lib/activity.js +31 -2
  7. package/dist/lib/bindings.d.ts +3 -1
  8. package/dist/lib/bindings.js +8 -2
  9. package/dist/lib/bus-api.d.ts +2 -2
  10. package/dist/lib/bus-api.js +7 -2
  11. package/dist/lib/bus-follower.d.ts +49 -3
  12. package/dist/lib/bus-follower.js +313 -146
  13. package/dist/lib/bus-leader.d.ts +3 -0
  14. package/dist/lib/bus-leader.js +99 -18
  15. package/dist/lib/bus.d.ts +5 -3
  16. package/dist/lib/bus.js +13 -10
  17. package/dist/lib/delivery.d.ts +4 -0
  18. package/dist/lib/delivery.js +48 -15
  19. package/dist/lib/extension.js +48 -6
  20. package/dist/lib/lifecycle.d.ts +68 -0
  21. package/dist/lib/lifecycle.js +178 -58
  22. package/dist/lib/machine.d.ts +23 -0
  23. package/dist/lib/machine.js +37 -0
  24. package/dist/lib/media.d.ts +114 -28
  25. package/dist/lib/media.js +292 -119
  26. package/dist/lib/model.d.ts +54 -15
  27. package/dist/lib/model.js +98 -53
  28. package/dist/lib/polling.js +2 -0
  29. package/dist/lib/preview.d.ts +128 -113
  30. package/dist/lib/preview.js +448 -366
  31. package/dist/lib/queue.d.ts +2 -2
  32. package/dist/lib/queue.js +3 -3
  33. package/dist/lib/routing.d.ts +6 -1
  34. package/dist/lib/routing.js +334 -210
  35. package/dist/lib/runtime.d.ts +77 -11
  36. package/dist/lib/runtime.js +174 -92
  37. package/dist/lib/sections.js +0 -1
  38. package/dist/lib/telegram-api.d.ts +11 -9
  39. package/dist/lib/telegram-api.js +53 -4
  40. package/dist/lib/text-groups.d.ts +0 -26
  41. package/dist/lib/text-groups.js +51 -98
  42. package/dist/lib/thread-display.js +14 -6
  43. package/dist/lib/threads.d.ts +18 -5
  44. package/dist/lib/threads.js +107 -30
  45. package/dist/lib/turns.d.ts +2 -0
  46. package/dist/lib/turns.js +6 -1
  47. package/dist/lib/updates.d.ts +11 -4
  48. package/dist/lib/updates.js +54 -19
  49. package/dist/package.json +1 -1
  50. package/docs/activity.md +1 -1
  51. package/docs/architecture.md +12 -11
  52. package/docs/delivery.md +7 -5
  53. package/docs/multi-instance-bus.md +18 -8
  54. package/docs/public-api.md +2 -2
  55. package/docs/ui-style.md +2 -2
  56. package/lib/activity.ts +29 -5
  57. package/lib/bindings.ts +9 -1
  58. package/lib/bus-api.ts +11 -3
  59. package/lib/bus-follower.ts +417 -203
  60. package/lib/bus-leader.ts +88 -14
  61. package/lib/bus.ts +17 -15
  62. package/lib/delivery.ts +47 -19
  63. package/lib/extension.ts +51 -9
  64. package/lib/lifecycle.ts +229 -55
  65. package/lib/machine.ts +60 -0
  66. package/lib/media.ts +438 -172
  67. package/lib/model.ts +169 -74
  68. package/lib/polling.ts +2 -0
  69. package/lib/preview.ts +715 -566
  70. package/lib/queue.ts +5 -5
  71. package/lib/routing.ts +325 -238
  72. package/lib/runtime.ts +260 -104
  73. package/lib/sections.ts +0 -2
  74. package/lib/telegram-api.ts +61 -8
  75. package/lib/text-groups.ts +75 -136
  76. package/lib/thread-display.ts +15 -4
  77. package/lib/threads.ts +120 -36
  78. package/lib/turns.ts +8 -1
  79. package/lib/updates.ts +58 -21
  80. package/package.json +1 -1
@@ -3,6 +3,7 @@
3
3
  * Zones: telegram inbound, media groups, filesystem paths
4
4
  * Normalizes inbound Telegram messages into reusable file, text, id, history, and media-group metadata
5
5
  */
6
+ import * as Machine from "./machine.ts";
6
7
  export interface TelegramPhotoSize {
7
8
  file_id: string;
8
9
  file_size?: number;
@@ -96,16 +97,6 @@ export interface TelegramMediaGroupMessage {
96
97
  message_thread_id?: number;
97
98
  media_group_id?: string;
98
99
  }
99
- export interface TelegramMediaGroupState<TMessage, TContext = unknown> {
100
- messages: TMessage[];
101
- context?: TContext;
102
- flushTimer?: ReturnType<typeof setTimeout>;
103
- dispatching?: boolean;
104
- dispatchPromise?: Promise<void>;
105
- dispatchNow?: () => Promise<void>;
106
- suspended?: boolean;
107
- reschedule?: () => void;
108
- }
109
100
  export interface TelegramMediaGroupController<TMessage extends TelegramMediaGroupMessage, TContext = unknown> {
110
101
  queueMessage: (options: {
111
102
  message: TMessage;
@@ -186,35 +177,130 @@ export declare function extractFirstTelegramMessageText(messages: TelegramMediaM
186
177
  export declare function hasTelegramMessagesPromptContent(messages: TelegramMediaMessage[]): boolean;
187
178
  export declare function collectTelegramMessageIds(messages: TelegramMediaMessage[]): number[];
188
179
  export declare function getTelegramMediaGroupKey(message: TelegramMediaGroupMessage): string | undefined;
189
- export declare function removePendingTelegramMediaGroupMessages<TMessage extends TelegramMediaGroupMessage>(groups: Map<string, TelegramMediaGroupState<TMessage, unknown>>, messageIds: number[], clearTimer: (timer: ReturnType<typeof setTimeout>) => void): number;
190
- export declare function queueTelegramMediaGroupMessage<TMessage extends TelegramMediaGroupMessage, TContext = unknown>(options: {
180
+ /** One pending input group; only `transitionTelegramPendingGroups` produces a new one. */
181
+ export interface TelegramPendingGroup<TMessage, TContext, TMeta = undefined> {
182
+ readonly messages: readonly TMessage[];
183
+ readonly context?: TContext;
184
+ /** Domain data carried with the group, such as a text group's forward-comment pairing side. */
185
+ readonly meta?: TMeta;
186
+ readonly timerId: number | null;
187
+ /** The batch in flight; its messages leave the group only once that exact dispatch succeeds. */
188
+ readonly dispatch: {
189
+ readonly id: number;
190
+ readonly messageIds: readonly number[];
191
+ } | null;
192
+ readonly suspended: boolean;
193
+ /** The next batch ends at this message, so later messages wait for the batch after it, whatever is in flight. */
194
+ readonly boundaryId?: number;
195
+ }
196
+ export interface TelegramPendingGroupsState<TMessage, TContext, TMeta> {
197
+ readonly groups: ReadonlyMap<string, TelegramPendingGroup<TMessage, TContext, TMeta>>;
198
+ readonly nextTimerId: number;
199
+ readonly nextDispatchId: number;
200
+ }
201
+ export interface TelegramPendingGroupsConfig {
202
+ debounceMs: number;
203
+ /** A group without a context waits (text) instead of dispatching without one (media). */
204
+ requireContext: boolean;
205
+ }
206
+ export type TelegramPendingGroupsEvent<TMessage, TContext, TMeta> = {
207
+ type: "queued";
208
+ key: string;
191
209
  message: TMessage;
192
210
  context?: TContext;
193
- groups: Map<string, TelegramMediaGroupState<TMessage, TContext>>;
194
- debounceMs: number;
195
- setTimer: (callback: () => void, ms: number) => ReturnType<typeof setTimeout>;
196
- clearTimer: (timer: ReturnType<typeof setTimeout>) => void;
197
- dispatchMessages: (messages: TMessage[], ctx?: TContext) => unknown | Promise<unknown>;
198
- }): boolean;
199
- /** Timer and dispatch lifecycle shared by delayed media-group and text-group coalescing. */
200
- export interface TelegramPendingGroupState<TMessage, TContext> {
211
+ meta?: TMeta;
212
+ delayMs: number;
213
+ /** `always` restarts the debounce; `ifIdle` arms it only when no timer, dispatch or suspension holds it. */
214
+ rearm: "always" | "ifIdle";
215
+ }
216
+ /** Ends the next batch at the group's current last message; later messages wait for the batch after it. */
217
+ | {
218
+ type: "batchClosed";
219
+ key: string;
220
+ } | {
221
+ type: "timerFired";
222
+ key: string;
223
+ timerId: number;
224
+ } | {
225
+ type: "dispatchSettled";
226
+ key: string;
227
+ dispatchId: number;
228
+ ok: boolean;
229
+ } | {
230
+ type: "flushRequested";
231
+ key: string;
232
+ } | {
233
+ type: "suspended";
234
+ } | {
235
+ type: "resumed";
236
+ context: TContext;
237
+ } | {
238
+ type: "removed";
239
+ messageIds: readonly number[];
240
+ } | {
241
+ type: "keyCleared";
242
+ key: string;
243
+ } | {
244
+ type: "cleared";
245
+ };
246
+ export type TelegramPendingGroupsEffect<TMessage, TContext> = {
247
+ type: "armTimer";
248
+ key: string;
249
+ timerId: number;
250
+ delayMs: number;
251
+ } | {
252
+ type: "cancelTimer";
253
+ timerId: number;
254
+ } | {
255
+ type: "dispatch";
256
+ key: string;
257
+ dispatchId: number;
201
258
  messages: TMessage[];
202
259
  context?: TContext;
203
- flushTimer?: ReturnType<typeof setTimeout>;
204
- dispatching?: boolean;
205
- suspended?: boolean;
206
- reschedule?: () => void;
207
- dispatchNow?: () => Promise<void>;
260
+ };
261
+ export interface TelegramPendingGroupsStep<TMessage, TContext, TMeta> extends Machine.TelegramMachineStep<TelegramPendingGroupsState<TMessage, TContext, TMeta>, TelegramPendingGroupsEffect<TMessage, TContext>> {
262
+ /** `removed`: how many groups were dropped. */
263
+ result: number;
208
264
  }
209
- export interface TelegramPendingGroupLifecycle<TContext> {
265
+ export declare function createTelegramPendingGroupsState<TMessage, TContext, TMeta = undefined>(): TelegramPendingGroupsState<TMessage, TContext, TMeta>;
266
+ /**
267
+ * Pure transition for delayed input groups shared by media albums and split text. A group dispatches one batch at a
268
+ * time; messages that arrive meanwhile wait for the next batch, a failure keeps the batch for a retry, and suspension
269
+ * keeps every admitted message for the replacement context.
270
+ */
271
+ export declare function transitionTelegramPendingGroups<TMessage extends {
272
+ message_id: number;
273
+ }, TContext, TMeta>(state: TelegramPendingGroupsState<TMessage, TContext, TMeta>, event: TelegramPendingGroupsEvent<TMessage, TContext, TMeta>, config: TelegramPendingGroupsConfig): TelegramPendingGroupsStep<TMessage, TContext, TMeta>;
274
+ export interface TelegramPendingGroups<TMessage, TContext, TMeta> {
275
+ /** Read-only view of one group; it never grants mutation. */
276
+ get: (key: string) => TelegramPendingGroup<TMessage, TContext, TMeta> | undefined;
277
+ queue: (event: Omit<Extract<TelegramPendingGroupsEvent<TMessage, TContext, TMeta>, {
278
+ type: "queued";
279
+ }>, "type"> & {
280
+ dispatchMessages: (messages: TMessage[], ctx?: TContext) => unknown | Promise<unknown>;
281
+ }) => void;
282
+ clearKey: (key: string) => void;
283
+ closeBatch: (key: string) => void;
284
+ removeMessages: (messageIds: number[]) => number;
285
+ /** Dispatches the group holding this message now; resolves once its batch settles, rejecting if it failed. */
210
286
  flushMessage: (messageId: number) => Promise<boolean>;
211
287
  suspend: () => void;
212
288
  resume: (context: TContext) => void;
213
289
  clear: () => void;
214
290
  }
215
- export declare function createTelegramPendingGroupLifecycle<TMessage extends {
291
+ /**
292
+ * The shell around `transitionTelegramPendingGroups`: it owns timer handles and each key's latest dispatcher, turns
293
+ * every dispatch outcome, including a synchronous throw, into `dispatchSettled`, and keeps each batch's promise for
294
+ * flush callers.
295
+ */
296
+ export declare function createTelegramPendingGroups<TMessage extends {
216
297
  message_id: number;
217
- }, TContext>(groups: Map<string, TelegramPendingGroupState<TMessage, TContext>>, clearTimer: (timer: ReturnType<typeof setTimeout>) => void): TelegramPendingGroupLifecycle<TContext>;
298
+ }, TContext, TMeta = undefined>(options: {
299
+ debounceMs: number;
300
+ requireContext: boolean;
301
+ setTimer: (callback: () => void, ms: number) => ReturnType<typeof setTimeout>;
302
+ clearTimer: (timer: ReturnType<typeof setTimeout>) => void;
303
+ }): TelegramPendingGroups<TMessage, TContext, TMeta>;
218
304
  export declare function createTelegramMediaGroupController<TMessage extends TelegramMediaGroupMessage, TContext = unknown>(options?: TelegramMediaGroupControllerOptions): TelegramMediaGroupController<TMessage, TContext>;
219
305
  export declare function createTelegramMediaGroupDispatchRuntime<TMessage extends TelegramMediaGroupMessage, TContext>(deps: TelegramMediaGroupDispatchRuntimeDeps<TMessage, TContext>): TelegramMediaGroupDispatchRuntime<TMessage, TContext>;
220
306
  /** Pure `[title]` bullet section appended after a blank line; empty items leave text unchanged. */
package/dist/lib/media.js CHANGED
@@ -4,6 +4,7 @@
4
4
  * Normalizes inbound Telegram messages into reusable file, text, id, history, and media-group metadata
5
5
  */
6
6
  import { basename, dirname } from "node:path";
7
+ import * as Machine from "./machine.js";
7
8
  const TELEGRAM_MEDIA_GROUP_DEBOUNCE_MS = 1200;
8
9
  const TELEGRAM_REPLY_CONTEXT_MAX_LENGTH = 1000;
9
10
  function guessExtensionFromMime(mimeType, fallback) {
@@ -237,145 +238,317 @@ export function getTelegramMediaGroupKey(message) {
237
238
  : "private";
238
239
  return `${message.chat.id}:${threadKey}:${message.media_group_id}`;
239
240
  }
240
- export function removePendingTelegramMediaGroupMessages(groups, messageIds, clearTimer) {
241
- if (messageIds.length === 0 || groups.size === 0)
242
- return 0;
243
- const deletedMessageIds = new Set(messageIds);
244
- let removedGroups = 0;
245
- for (const [key, state] of groups.entries()) {
246
- if (!state.messages.some((message) => deletedMessageIds.has(message.message_id))) {
247
- continue;
248
- }
249
- if (state.flushTimer)
250
- clearTimer(state.flushTimer);
241
+ export function createTelegramPendingGroupsState() {
242
+ return { groups: new Map(), nextTimerId: 0, nextDispatchId: 0 };
243
+ }
244
+ /**
245
+ * Pure transition for delayed input groups shared by media albums and split text. A group dispatches one batch at a
246
+ * time; messages that arrive meanwhile wait for the next batch, a failure keeps the batch for a retry, and suspension
247
+ * keeps every admitted message for the replacement context.
248
+ */
249
+ export function transitionTelegramPendingGroups(state, event, config) {
250
+ const groups = new Map(state.groups);
251
+ const effects = [];
252
+ let { nextTimerId, nextDispatchId } = state;
253
+ let result = 0;
254
+ const cancelTimer = (group) => {
255
+ if (group.timerId === null)
256
+ return group;
257
+ effects.push({ type: "cancelTimer", timerId: group.timerId });
258
+ return { ...group, timerId: null };
259
+ };
260
+ const armTimer = (key, group, delayMs) => {
261
+ if (group.suspended)
262
+ return group;
263
+ nextTimerId += 1;
264
+ effects.push({ type: "armTimer", key, timerId: nextTimerId, delayMs });
265
+ return { ...group, timerId: nextTimerId };
266
+ };
267
+ const startDispatch = (key, group) => {
268
+ if (group.dispatch)
269
+ return group;
270
+ if (config.requireContext && group.context === undefined)
271
+ return group;
272
+ const boundary = group.boundaryId === undefined
273
+ ? -1
274
+ : group.messages.findIndex((message) => message.message_id === group.boundaryId);
275
+ const batch = boundary < 0
276
+ ? [...group.messages]
277
+ : group.messages.slice(0, boundary + 1);
278
+ nextDispatchId += 1;
279
+ effects.push({
280
+ type: "dispatch",
281
+ key,
282
+ dispatchId: nextDispatchId,
283
+ messages: batch,
284
+ ...(group.context === undefined ? {} : { context: group.context }),
285
+ });
286
+ const { boundaryId: _consumed, ...rest } = group;
287
+ return {
288
+ ...rest,
289
+ dispatch: {
290
+ id: nextDispatchId,
291
+ messageIds: batch.map((message) => message.message_id),
292
+ },
293
+ };
294
+ };
295
+ const drop = (key, group) => {
296
+ cancelTimer(group);
251
297
  groups.delete(key);
252
- removedGroups += 1;
253
- }
254
- return removedGroups;
255
- }
256
- export function queueTelegramMediaGroupMessage(options) {
257
- const key = getTelegramMediaGroupKey(options.message);
258
- if (!key)
259
- return false;
260
- const existing = options.groups.get(key) ?? { messages: [] };
261
- const duplicateIndex = existing.messages.findIndex((message) => message.message_id === options.message.message_id);
262
- if (duplicateIndex >= 0) {
263
- existing.messages[duplicateIndex] = options.message;
264
- }
265
- else {
266
- existing.messages.push(options.message);
298
+ };
299
+ switch (event.type) {
300
+ case "queued": {
301
+ let group = groups.get(event.key) ?? {
302
+ messages: [],
303
+ timerId: null,
304
+ dispatch: null,
305
+ suspended: false,
306
+ };
307
+ const duplicate = group.messages.findIndex((message) => message.message_id === event.message.message_id);
308
+ group = {
309
+ ...group,
310
+ messages: duplicate < 0
311
+ ? [...group.messages, event.message]
312
+ : group.messages.map((message, index) => index === duplicate ? event.message : message),
313
+ context: event.context,
314
+ meta: event.meta,
315
+ };
316
+ const held = group.suspended || group.dispatch !== null || group.timerId !== null;
317
+ if (event.rearm === "always" || !held)
318
+ group = armTimer(event.key, cancelTimer(group), event.delayMs);
319
+ groups.set(event.key, group);
320
+ break;
321
+ }
322
+ case "batchClosed": {
323
+ const group = groups.get(event.key);
324
+ const last = group?.messages.at(-1);
325
+ if (group && last)
326
+ groups.set(event.key, { ...group, boundaryId: last.message_id });
327
+ break;
328
+ }
329
+ case "timerFired": {
330
+ const group = groups.get(event.key);
331
+ if (!group || group.timerId !== event.timerId)
332
+ break;
333
+ groups.set(event.key, startDispatch(event.key, { ...group, timerId: null }));
334
+ break;
335
+ }
336
+ case "dispatchSettled": {
337
+ const group = groups.get(event.key);
338
+ if (!group || group.dispatch?.id !== event.dispatchId)
339
+ break;
340
+ const sent = new Set(group.dispatch.messageIds);
341
+ const remaining = event.ok
342
+ ? group.messages.filter((message) => !sent.has(message.message_id))
343
+ : group.messages;
344
+ if (remaining.length === 0) {
345
+ drop(event.key, group);
346
+ break;
347
+ }
348
+ const settled = { ...group, messages: remaining, dispatch: null };
349
+ groups.set(event.key, settled.timerId === null
350
+ ? armTimer(event.key, settled, config.debounceMs)
351
+ : settled);
352
+ break;
353
+ }
354
+ case "flushRequested": {
355
+ const group = groups.get(event.key);
356
+ if (!group)
357
+ break;
358
+ groups.set(event.key, startDispatch(event.key, cancelTimer(group)));
359
+ break;
360
+ }
361
+ case "suspended":
362
+ for (const [key, group] of groups)
363
+ groups.set(key, { ...cancelTimer(group), suspended: true });
364
+ break;
365
+ case "resumed":
366
+ for (const [key, group] of groups) {
367
+ const resumed = {
368
+ ...group,
369
+ context: event.context,
370
+ suspended: false,
371
+ };
372
+ groups.set(key, resumed.dispatch === null && resumed.timerId === null
373
+ ? armTimer(key, resumed, config.debounceMs)
374
+ : resumed);
375
+ }
376
+ break;
377
+ case "removed": {
378
+ const removed = new Set(event.messageIds);
379
+ for (const [key, group] of groups) {
380
+ if (!group.messages.some((message) => removed.has(message.message_id)))
381
+ continue;
382
+ drop(key, group);
383
+ result += 1;
384
+ }
385
+ break;
386
+ }
387
+ case "keyCleared": {
388
+ const group = groups.get(event.key);
389
+ if (group)
390
+ drop(event.key, group);
391
+ break;
392
+ }
393
+ case "cleared":
394
+ for (const [key, group] of groups)
395
+ drop(key, group);
396
+ break;
267
397
  }
268
- existing.context = options.context;
269
- const dispatchQueued = () => {
270
- existing.flushTimer = undefined;
271
- const state = options.groups.get(key);
272
- if (!state)
273
- return Promise.resolve();
274
- if (state.dispatching)
275
- return state.dispatchPromise ?? Promise.resolve();
276
- const dispatchedMessages = [...state.messages];
277
- const dispatchedIds = new Set(dispatchedMessages.map((message) => message.message_id));
278
- state.dispatching = true;
279
- const operation = Promise.resolve(options.dispatchMessages(dispatchedMessages, state.context)).then(() => {
280
- if (options.groups.get(key) !== state)
281
- return;
282
- state.messages = state.messages.filter((message) => !dispatchedIds.has(message.message_id));
283
- state.dispatching = false;
284
- state.dispatchPromise = undefined;
285
- if (state.messages.length === 0)
286
- options.groups.delete(key);
287
- else if (!state.flushTimer)
288
- scheduleDispatch();
289
- }, (error) => {
290
- if (options.groups.get(key) === state) {
291
- state.dispatching = false;
292
- state.dispatchPromise = undefined;
293
- if (!state.flushTimer)
294
- scheduleDispatch();
398
+ return { state: { groups, nextTimerId, nextDispatchId }, effects, result };
399
+ }
400
+ /**
401
+ * The shell around `transitionTelegramPendingGroups`: it owns timer handles and each key's latest dispatcher, turns
402
+ * every dispatch outcome, including a synchronous throw, into `dispatchSettled`, and keeps each batch's promise for
403
+ * flush callers.
404
+ */
405
+ export function createTelegramPendingGroups(options) {
406
+ const config = {
407
+ debounceMs: options.debounceMs,
408
+ requireContext: options.requireContext,
409
+ };
410
+ const timers = new Map();
411
+ const dispatchers = new Map();
412
+ const batches = new Map();
413
+ const machine = Machine.createTelegramMachineRunner({
414
+ initial: createTelegramPendingGroupsState(),
415
+ transition: (state, event) => transitionTelegramPendingGroups(state, event, config),
416
+ run(effect) {
417
+ switch (effect.type) {
418
+ case "armTimer": {
419
+ const timer = options.setTimer(() => {
420
+ timers.delete(effect.timerId);
421
+ machine.dispatch({
422
+ type: "timerFired",
423
+ key: effect.key,
424
+ timerId: effect.timerId,
425
+ });
426
+ }, effect.delayMs);
427
+ timer.unref?.();
428
+ timers.set(effect.timerId, timer);
429
+ return;
430
+ }
431
+ case "cancelTimer": {
432
+ const timer = timers.get(effect.timerId);
433
+ timers.delete(effect.timerId);
434
+ if (timer !== undefined)
435
+ options.clearTimer(timer);
436
+ return;
437
+ }
438
+ case "dispatch": {
439
+ const settle = (ok) => {
440
+ batches.delete(effect.dispatchId);
441
+ machine.dispatch({
442
+ type: "dispatchSettled",
443
+ key: effect.key,
444
+ dispatchId: effect.dispatchId,
445
+ ok,
446
+ });
447
+ };
448
+ let outcome;
449
+ try {
450
+ // Synchronous on purpose: a fired timer hands its batch to admission in the same turn.
451
+ outcome = dispatchers.get(effect.key)?.(effect.messages, effect.context);
452
+ }
453
+ catch (error) {
454
+ outcome = Promise.reject(error);
455
+ }
456
+ const batch = Promise.resolve(outcome).then(() => settle(true), (error) => {
457
+ settle(false);
458
+ throw error;
459
+ });
460
+ batch.catch(() => undefined);
461
+ batches.set(effect.dispatchId, batch);
462
+ return;
463
+ }
295
464
  }
296
- throw error;
297
- });
298
- state.dispatchPromise = operation;
299
- return operation;
465
+ },
466
+ });
467
+ const dispatch = (event) => {
468
+ const step = machine.dispatch(event);
469
+ for (const key of dispatchers.keys())
470
+ if (!machine.state.groups.has(key))
471
+ dispatchers.delete(key);
472
+ return step.result;
300
473
  };
301
- const scheduleDispatch = () => {
302
- if (existing.suspended)
303
- return;
304
- existing.flushTimer = options.setTimer(() => {
305
- void dispatchQueued().catch(() => undefined);
306
- }, options.debounceMs);
307
- existing.flushTimer.unref?.();
474
+ const keyOf = (messageId) => {
475
+ for (const [key, group] of machine.state.groups)
476
+ if (group.messages.some((message) => message.message_id === messageId))
477
+ return key;
478
+ return undefined;
479
+ };
480
+ const awaitBatch = async (key) => {
481
+ const id = machine.state.groups.get(key)?.dispatch?.id;
482
+ if (id !== undefined)
483
+ await batches.get(id);
308
484
  };
309
- existing.reschedule = scheduleDispatch;
310
- existing.dispatchNow = dispatchQueued;
311
- if (existing.flushTimer)
312
- options.clearTimer(existing.flushTimer);
313
- scheduleDispatch();
314
- options.groups.set(key, existing);
315
- return true;
316
- }
317
- export function createTelegramPendingGroupLifecycle(groups, clearTimer) {
318
485
  return {
486
+ get: (key) => machine.state.groups.get(key),
487
+ queue({ dispatchMessages, ...event }) {
488
+ dispatchers.set(event.key, dispatchMessages);
489
+ dispatch({ type: "queued", ...event });
490
+ },
491
+ clearKey: (key) => {
492
+ dispatch({ type: "keyCleared", key });
493
+ },
494
+ closeBatch: (key) => {
495
+ dispatch({ type: "batchClosed", key });
496
+ },
497
+ removeMessages: (messageIds) => dispatch({ type: "removed", messageIds }),
319
498
  async flushMessage(messageId) {
320
- for (const state of groups.values()) {
321
- if (!state.messages.some((message) => message.message_id === messageId)) {
322
- continue;
323
- }
324
- if (state.flushTimer)
325
- clearTimer(state.flushTimer);
326
- state.flushTimer = undefined;
327
- await state.dispatchNow?.();
328
- if (state.messages.some((message) => message.message_id === messageId) &&
329
- !state.dispatching) {
330
- await state.dispatchNow?.();
331
- }
332
- return true;
499
+ const key = keyOf(messageId);
500
+ if (key === undefined)
501
+ return false;
502
+ dispatch({ type: "flushRequested", key });
503
+ await awaitBatch(key);
504
+ // The batch in flight may have predated this message; send what is left once it settles.
505
+ if (keyOf(messageId) === key &&
506
+ !machine.state.groups.get(key)?.dispatch) {
507
+ dispatch({ type: "flushRequested", key });
508
+ await awaitBatch(key);
333
509
  }
334
- return false;
510
+ return true;
335
511
  },
336
512
  suspend: () => {
337
- for (const state of groups.values()) {
338
- state.suspended = true;
339
- if (state.flushTimer)
340
- clearTimer(state.flushTimer);
341
- state.flushTimer = undefined;
342
- }
513
+ dispatch({ type: "suspended" });
343
514
  },
344
515
  resume: (context) => {
345
- for (const state of groups.values()) {
346
- state.context = context;
347
- state.suspended = false;
348
- if (!state.dispatching && !state.flushTimer)
349
- state.reschedule?.();
350
- }
516
+ dispatch({ type: "resumed", context });
351
517
  },
352
518
  clear: () => {
353
- for (const state of groups.values()) {
354
- if (state.flushTimer)
355
- clearTimer(state.flushTimer);
356
- }
357
- groups.clear();
519
+ dispatch({ type: "cleared" });
358
520
  },
359
521
  };
360
522
  }
361
523
  export function createTelegramMediaGroupController(options = {}) {
362
- const groups = new Map();
363
524
  const debounceMs = options.debounceMs ?? TELEGRAM_MEDIA_GROUP_DEBOUNCE_MS;
364
- const setTimer = options.setTimer ??
365
- ((callback, ms) => setTimeout(callback, ms));
366
- const clearTimer = options.clearTimer ?? clearTimeout;
525
+ const groups = createTelegramPendingGroups({
526
+ debounceMs,
527
+ requireContext: false,
528
+ setTimer: options.setTimer ??
529
+ ((callback, ms) => setTimeout(callback, ms)),
530
+ clearTimer: options.clearTimer ?? clearTimeout,
531
+ });
367
532
  return {
368
- queueMessage: ({ message, context, dispatchMessages }) => queueTelegramMediaGroupMessage({
369
- message,
370
- context,
371
- groups,
372
- debounceMs,
373
- setTimer,
374
- clearTimer,
375
- dispatchMessages,
376
- }),
377
- removeMessages: (messageIds) => removePendingTelegramMediaGroupMessages(groups, messageIds, clearTimer),
378
- ...createTelegramPendingGroupLifecycle(groups, clearTimer),
533
+ queueMessage: ({ message, context, dispatchMessages }) => {
534
+ const key = getTelegramMediaGroupKey(message);
535
+ if (!key)
536
+ return false;
537
+ groups.queue({
538
+ key,
539
+ message,
540
+ ...(context === undefined ? {} : { context }),
541
+ delayMs: debounceMs,
542
+ rearm: "always",
543
+ dispatchMessages,
544
+ });
545
+ return true;
546
+ },
547
+ removeMessages: groups.removeMessages,
548
+ flushMessage: groups.flushMessage,
549
+ suspend: groups.suspend,
550
+ resume: groups.resume,
551
+ clear: groups.clear,
379
552
  };
380
553
  }
381
554
  export function createTelegramMediaGroupDispatchRuntime(deps) {