@llblab/pi-telegram 0.54.4 → 0.55.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -2
- package/BACKLOG.md +1 -1
- package/CHANGELOG.md +16 -0
- package/README.md +5 -3
- package/dist/lib/activity.d.ts +5 -1
- package/dist/lib/activity.js +31 -2
- package/dist/lib/bindings.d.ts +3 -1
- package/dist/lib/bindings.js +8 -2
- package/dist/lib/bus-api.d.ts +2 -2
- package/dist/lib/bus-api.js +7 -2
- package/dist/lib/bus-follower.d.ts +50 -3
- package/dist/lib/bus-follower.js +313 -146
- package/dist/lib/bus-leader.d.ts +3 -0
- package/dist/lib/bus-leader.js +99 -18
- package/dist/lib/bus.d.ts +7 -3
- package/dist/lib/bus.js +24 -16
- package/dist/lib/delivery.d.ts +4 -0
- package/dist/lib/delivery.js +48 -15
- package/dist/lib/extension.js +48 -6
- package/dist/lib/lifecycle.d.ts +68 -0
- package/dist/lib/lifecycle.js +178 -58
- package/dist/lib/machine.d.ts +23 -0
- package/dist/lib/machine.js +37 -0
- package/dist/lib/media.d.ts +114 -28
- package/dist/lib/media.js +292 -119
- package/dist/lib/model.d.ts +54 -15
- package/dist/lib/model.js +98 -53
- package/dist/lib/polling.js +2 -0
- package/dist/lib/preview.d.ts +128 -113
- package/dist/lib/preview.js +448 -366
- package/dist/lib/queue.d.ts +2 -2
- package/dist/lib/queue.js +3 -3
- package/dist/lib/routing.d.ts +7 -2
- package/dist/lib/routing.js +585 -229
- package/dist/lib/runtime.d.ts +77 -11
- package/dist/lib/runtime.js +174 -92
- package/dist/lib/sections.js +0 -1
- package/dist/lib/telegram-api.d.ts +11 -9
- package/dist/lib/telegram-api.js +53 -4
- package/dist/lib/text-groups.d.ts +0 -26
- package/dist/lib/text-groups.js +51 -98
- package/dist/lib/thread-display.js +14 -6
- package/dist/lib/threads.d.ts +18 -5
- package/dist/lib/threads.js +107 -30
- package/dist/lib/turns.d.ts +6 -0
- package/dist/lib/turns.js +12 -1
- package/dist/lib/updates.d.ts +13 -4
- package/dist/lib/updates.js +54 -19
- package/dist/package.json +1 -1
- package/docs/activity.md +1 -1
- package/docs/architecture.md +14 -11
- package/docs/delivery.md +7 -5
- package/docs/multi-instance-bus.md +20 -8
- package/docs/public-api.md +2 -2
- package/docs/ui-style.md +2 -2
- package/lib/activity.ts +29 -5
- package/lib/bindings.ts +9 -1
- package/lib/bus-api.ts +11 -3
- package/lib/bus-follower.ts +417 -203
- package/lib/bus-leader.ts +88 -14
- package/lib/bus.ts +30 -18
- package/lib/delivery.ts +47 -19
- package/lib/extension.ts +51 -9
- package/lib/lifecycle.ts +229 -55
- package/lib/machine.ts +60 -0
- package/lib/media.ts +438 -172
- package/lib/model.ts +169 -74
- package/lib/polling.ts +2 -0
- package/lib/preview.ts +715 -566
- package/lib/queue.ts +5 -5
- package/lib/routing.ts +614 -257
- package/lib/runtime.ts +260 -104
- package/lib/sections.ts +0 -2
- package/lib/telegram-api.ts +61 -8
- package/lib/text-groups.ts +75 -136
- package/lib/thread-display.ts +15 -4
- package/lib/threads.ts +120 -36
- package/lib/turns.ts +15 -1
- package/lib/updates.ts +60 -21
- package/package.json +1 -1
package/dist/lib/media.d.ts
CHANGED
|
@@ -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
|
-
|
|
190
|
-
export
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
|
510
|
+
return true;
|
|
335
511
|
},
|
|
336
512
|
suspend: () => {
|
|
337
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
365
|
-
|
|
366
|
-
|
|
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 }) =>
|
|
369
|
-
message
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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) {
|