@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
package/lib/preview.ts CHANGED
@@ -2,9 +2,11 @@
2
2
  * Telegram preview streaming helpers
3
3
  * Zones: telegram outbound, native rich markdown drafts
4
4
  * Owns safe draft preview selection, runtime updates, and preview finalization
5
+ * The streaming state machine is a pure transition function driven by one imperative shell per runtime.
5
6
  */
6
7
 
7
8
  import type { TelegramAssistantOutputPreparation } from "./activity.ts";
9
+ import * as Machine from "./machine.ts";
8
10
  import { stripTelegramCommentMarkupForPreview } from "./outbound-markup.ts";
9
11
  import {
10
12
  isTelegramMarkdownFenceClosing,
@@ -25,8 +27,6 @@ const TELEGRAM_DRAFT_PREVIEW_MAX_CHARS = 4096;
25
27
  // an accumulated passage rather than a single streamed word.
26
28
  const TELEGRAM_DRAFT_INTERVAL_MS = 2_000;
27
29
 
28
- export type TelegramDraftSupport = "unknown" | "supported";
29
-
30
30
  export interface TelegramPreviewState {
31
31
  mode: "draft";
32
32
  draftId?: number;
@@ -34,43 +34,30 @@ export interface TelegramPreviewState {
34
34
  lastSentText: string;
35
35
  }
36
36
 
37
- export interface TelegramPreviewRuntimeState extends TelegramPreviewState {
38
- flushPromise?: Promise<void>;
39
- flushRequested?: boolean;
40
- precedingFlush?: Promise<void>;
41
- publicationPromise?: Promise<void>;
42
- sealed?: boolean;
43
- nextDraftAt?: number;
44
- flushTimer?: ReturnType<typeof setTimeout>;
37
+ /** One immutable preview segment; a replacement segment gets a new id, so late results for an old one are dropped. */
38
+ export interface TelegramPreviewSegment extends Readonly<TelegramPreviewState> {
39
+ readonly id: number;
40
+ readonly sealed: boolean;
45
41
  }
46
42
 
47
43
  export type TelegramPreviewReplyMarkup = unknown;
48
44
 
49
- export interface TelegramPreviewRuntimeDeps {
50
- getState: () => TelegramPreviewRuntimeState | undefined;
51
- setState: (state: TelegramPreviewRuntimeState | undefined) => void;
52
- maxMessageLength: number;
53
- minDraftIntervalMs?: number;
54
- getDraftSupport: () => TelegramDraftSupport;
55
- setDraftSupport: (support: TelegramDraftSupport) => void;
56
- allocateDraftId: () => number;
57
- sendDraft: (
58
- chatId: number,
59
- draftId: number,
60
- text?: string,
61
- options?: {
62
- parse_mode?: string;
63
- entities?: unknown[];
64
- message_thread_id?: number;
65
- },
66
- ) => Promise<unknown>;
67
- canSend?: () => boolean;
68
- recordRuntimeEvent?: (
69
- category: string,
70
- error: unknown,
71
- details?: Record<string, unknown>,
72
- ) => void;
73
- }
45
+ type TelegramDraftSender = (
46
+ chatId: number,
47
+ draftId: number,
48
+ text?: string,
49
+ options?: {
50
+ parse_mode?: string;
51
+ entities?: unknown[];
52
+ message_thread_id?: number;
53
+ },
54
+ ) => Promise<unknown>;
55
+
56
+ type TelegramPreviewEventRecorder = (
57
+ category: string,
58
+ error: unknown,
59
+ details?: Record<string, unknown>,
60
+ ) => void;
74
61
 
75
62
  export interface TelegramPreviewActiveTurn {
76
63
  chatId: number;
@@ -81,34 +68,6 @@ export interface TelegramPreviewActiveTurn {
81
68
  guestQueryId?: string;
82
69
  }
83
70
 
84
- export interface TelegramAssistantMessagePreviewStartDeps<TMessage> {
85
- getActiveTurn: () => TelegramPreviewActiveTurn | undefined;
86
- isAssistantMessage: (message: TMessage) => boolean;
87
- getState: () => TelegramPreviewRuntimeState | undefined;
88
- setState: (state: TelegramPreviewRuntimeState | undefined) => void;
89
- createPreviewState: () => TelegramPreviewRuntimeState;
90
- canSend?: () => boolean;
91
- }
92
-
93
- export interface TelegramAssistantMessagePreviewUpdateDeps<TMessage> {
94
- getActiveTurn: () => TelegramPreviewActiveTurn | undefined;
95
- isAssistantMessage: (message: TMessage) => boolean;
96
- getState: () => TelegramPreviewRuntimeState | undefined;
97
- setState: (state: TelegramPreviewRuntimeState | undefined) => void;
98
- createPreviewState: () => TelegramPreviewRuntimeState;
99
- canSend?: () => boolean;
100
- getMessageText: (message: TMessage) => string;
101
- minDraftIntervalMs?: number;
102
- schedulePreviewFlush: (
103
- chatId: number,
104
- options?: { target?: TelegramTarget },
105
- ) => void;
106
- }
107
-
108
- export type TelegramAssistantMessagePreviewHookDeps<TMessage> =
109
- TelegramAssistantMessagePreviewStartDeps<TMessage> &
110
- TelegramAssistantMessagePreviewUpdateDeps<TMessage>;
111
-
112
71
  export interface TelegramAssistantMessagePreviewHookEvent<TMessage> {
113
72
  message: TMessage;
114
73
  }
@@ -125,79 +84,55 @@ export interface TelegramAssistantMessagePreviewHooks<TMessage> {
125
84
  export interface TelegramPreviewControllerDeps {
126
85
  getDefaultReplyToMessageId?: () => number | undefined;
127
86
  maxMessageLength?: number;
128
- initialDraftSupport?: TelegramDraftSupport;
129
- sendDraft: (
130
- chatId: number,
131
- draftId: number,
132
- text?: string,
133
- options?: {
134
- parse_mode?: string;
135
- entities?: unknown[];
136
- message_thread_id?: number;
137
- },
138
- ) => Promise<unknown>;
87
+ sendDraft: TelegramDraftSender;
139
88
  canSend?: () => boolean;
140
89
  maxDraftId?: number;
141
- recordRuntimeEvent?: (
142
- category: string,
143
- error: unknown,
144
- details?: Record<string, unknown>,
145
- ) => void;
90
+ /** Throttle between draft frames; defaults to the native draft cadence. */
91
+ minDraftIntervalMs?: number;
92
+ recordRuntimeEvent?: TelegramPreviewEventRecorder;
146
93
  }
147
94
 
95
+ type TelegramPreviewTargetOptions = { target?: TelegramTarget };
96
+
148
97
  export interface TelegramPreviewController {
98
+ /** Read-only view of the current segment; it never grants mutation. */
99
+ getState: () => TelegramPreviewSegment | undefined;
100
+ /** True while a draft request is in flight or a pending request waits behind a publication. */
101
+ isFlushing: () => boolean;
149
102
  seal: () => void;
150
103
  preparePublication: () => TelegramAssistantOutputPreparation | undefined;
151
104
  prepareClear: (
152
105
  chatId: number,
153
- options?: { target?: TelegramTarget; isDeliveryActive?: () => boolean },
106
+ options?: TelegramPreviewTargetOptions & {
107
+ isDeliveryActive?: () => boolean;
108
+ },
154
109
  ) => () => Promise<void>;
155
- getState: () => TelegramPreviewRuntimeState | undefined;
156
- setState: (state: TelegramPreviewRuntimeState | undefined) => void;
157
110
  setPendingText: (text: string) => void;
158
- createState: () => TelegramPreviewRuntimeState;
159
111
  resetState: () => void;
160
112
  invalidate: () => void;
161
113
  clear: (
162
114
  chatId: number,
163
- options?: { awaitFlush?: boolean; target?: TelegramTarget },
115
+ options?: TelegramPreviewTargetOptions & { awaitFlush?: boolean },
164
116
  ) => Promise<void>;
165
117
  flush: (
166
118
  chatId: number,
167
- options?: { target?: TelegramTarget },
119
+ options?: TelegramPreviewTargetOptions,
168
120
  ) => Promise<void>;
169
121
  scheduleFlush: (
170
122
  chatId: number,
171
- options?: { target?: TelegramTarget },
123
+ options?: TelegramPreviewTargetOptions,
172
124
  ) => void;
173
125
  finalize: (
174
126
  chatId: number,
175
127
  replyToMessageId?: number,
176
- options?: { target?: TelegramTarget },
128
+ options?: TelegramPreviewTargetOptions,
177
129
  ) => Promise<boolean>;
178
130
  }
179
131
 
180
- export type TelegramPreviewControllerRuntimeDeps =
181
- TelegramPreviewControllerDeps;
182
-
183
- export function createTelegramPreviewControllerRuntime(
184
- deps: TelegramPreviewControllerRuntimeDeps,
185
- ): TelegramPreviewController {
186
- return createTelegramPreviewController({
187
- getDefaultReplyToMessageId: deps.getDefaultReplyToMessageId,
188
- maxMessageLength: deps.maxMessageLength,
189
- initialDraftSupport: deps.initialDraftSupport,
190
- sendDraft: deps.sendDraft,
191
- canSend: deps.canSend,
192
- maxDraftId: deps.maxDraftId,
193
- recordRuntimeEvent: deps.recordRuntimeEvent,
194
- });
195
- }
196
-
197
132
  export interface TelegramAssistantPreviewRuntimeDeps<
198
133
  TMessage,
199
134
  TReplyMarkup = TelegramPreviewReplyMarkup,
200
- > extends TelegramPreviewControllerRuntimeDeps {
135
+ > extends TelegramPreviewControllerDeps {
201
136
  getActiveTurn: () => TelegramPreviewActiveTurn | undefined;
202
137
  isAssistantMessage: (message: TMessage) => boolean;
203
138
  getMessageText: (message: TMessage) => string;
@@ -209,12 +144,17 @@ export interface TelegramAssistantPreviewRuntimeDeps<
209
144
  ) => Promise<number | undefined>;
210
145
  }
211
146
 
147
+ type TelegramMarkdownFinalizer<TReplyMarkup> = (
148
+ chatId: number,
149
+ markdown: string,
150
+ replyToMessageId?: number,
151
+ options?: { replyMarkup?: TReplyMarkup; target?: TelegramTarget },
152
+ ) => Promise<boolean>;
153
+
212
154
  export interface TelegramPreparedPreviewDelivery<TReplyMarkup = unknown> {
213
155
  clearPreview: TelegramPreviewController["clear"];
214
156
  setPreviewPendingText: TelegramPreviewController["setPendingText"];
215
- finalizeMarkdownPreview: ReturnType<
216
- typeof createTelegramNativeMarkdownPreviewFinalizer<TReplyMarkup>
217
- >;
157
+ finalizeMarkdownPreview: TelegramMarkdownFinalizer<TReplyMarkup>;
218
158
  }
219
159
 
220
160
  export type TelegramAssistantPreviewRuntime<
@@ -225,237 +165,627 @@ export type TelegramAssistantPreviewRuntime<
225
165
  prepareDelivery: (
226
166
  isDeliveryActive: () => boolean,
227
167
  ) => TelegramPreparedPreviewDelivery<TReplyMarkup>;
228
- finalizeMarkdown: (
229
- chatId: number,
230
- markdown: string,
231
- replyToMessageId?: number,
232
- options?: { replyMarkup?: TReplyMarkup; target?: TelegramTarget },
233
- ) => Promise<boolean>;
168
+ finalizeMarkdown: TelegramMarkdownFinalizer<TReplyMarkup>;
234
169
  };
235
170
 
236
- function sealTelegramPreviewState(
237
- state: TelegramPreviewRuntimeState | undefined,
238
- ): void {
239
- if (!state) return;
240
- state.sealed = true;
241
- state.flushRequested = false;
242
- if (state.flushTimer) clearTimeout(state.flushTimer);
243
- state.flushTimer = undefined;
171
+ interface TelegramPreviewFlushRequest {
172
+ chatId: number;
173
+ target?: TelegramTarget;
244
174
  }
245
175
 
246
- export function createTelegramNativeMarkdownPreviewFinalizer<
247
- TReplyMarkup,
248
- >(deps: {
249
- getState: () => TelegramPreviewRuntimeState | undefined;
250
- clear: (
251
- chatId: number,
252
- options?: { awaitFlush?: boolean; target?: TelegramTarget },
253
- ) => Promise<void>;
254
- discard?: () => void;
255
- isDeliveryActive?: () => boolean;
256
- sendMarkdownReply: (
257
- chatId: number,
258
- replyToMessageId: number | undefined,
259
- markdown: string,
260
- options?: { replyMarkup?: TReplyMarkup; target?: TelegramTarget },
261
- ) => Promise<number | undefined>;
262
- }): (
263
- chatId: number,
264
- markdown: string,
265
- replyToMessageId?: number,
266
- options?: { replyMarkup?: TReplyMarkup; target?: TelegramTarget },
267
- ) => Promise<boolean> {
268
- return (...args) =>
269
- prepareTelegramNativeMarkdownPreviewFinalizer(deps)(...args);
270
- }
271
-
272
- function prepareTelegramNativeMarkdownPreviewFinalizer<TReplyMarkup>(
273
- deps: Parameters<
274
- typeof createTelegramNativeMarkdownPreviewFinalizer<TReplyMarkup>
275
- >[0],
276
- ): ReturnType<
277
- typeof createTelegramNativeMarkdownPreviewFinalizer<TReplyMarkup>
278
- > {
279
- const state = deps.getState();
280
- sealTelegramPreviewState(state);
281
- const inFlight = state?.flushPromise ?? state?.precedingFlush;
282
- return async (chatId, markdown, replyToMessageId, options) => {
283
- if (deps.isDeliveryActive?.() === false) return false;
284
- await inFlight?.catch(() => {});
285
- if (deps.getState() !== state || deps.isDeliveryActive?.() === false)
286
- return false;
287
- await deps.sendMarkdownReply(chatId, replyToMessageId, markdown, options);
288
- if (deps.getState() === state && deps.isDeliveryActive?.() !== false)
289
- deps.discard?.();
290
- return true;
291
- };
176
+ interface TelegramPreviewMachineConfig {
177
+ maxDraftId: number;
178
+ maxMessageLength: number;
179
+ minDraftIntervalMs: number;
292
180
  }
293
181
 
294
- export function createTelegramAssistantPreviewRuntime<
295
- TMessage,
296
- TReplyMarkup = TelegramPreviewReplyMarkup,
297
- >(
298
- deps: TelegramAssistantPreviewRuntimeDeps<TMessage, TReplyMarkup>,
299
- ): TelegramAssistantPreviewRuntime<TMessage, TReplyMarkup> {
300
- const controller = createTelegramPreviewControllerRuntime(deps);
301
- const finalizerDeps = {
302
- getState: controller.getState,
303
- clear: controller.clear,
304
- discard: () => controller.setState(undefined),
305
- sendMarkdownReply: deps.sendMarkdownReply,
182
+ /** The whole preview state; only `transitionTelegramPreview` produces a new one. */
183
+ export interface TelegramPreviewMachineState {
184
+ readonly segment: TelegramPreviewSegment | null;
185
+ /** Bumped by reset and invalidation; a result issued under an older generation is inert. */
186
+ readonly generation: number;
187
+ readonly nextSegmentId: number;
188
+ readonly nextDraftId: number;
189
+ readonly nextDraftAt: number;
190
+ /** Segment that owns the current generation's issued draft; old-generation requests do not block it. */
191
+ readonly inFlight: number | null;
192
+ /** The one coalesced flush request waiting behind an in-flight draft or an open publication. */
193
+ readonly pending: TelegramPreviewFlushRequest | null;
194
+ readonly publications: readonly number[];
195
+ /** The segment the throttle timer is armed for; the timer belongs to that segment alone. */
196
+ readonly timerSegmentId: number | null;
197
+ }
198
+
199
+ /** Impure inputs the runner samples at dispatch time, so transitions stay pure. */
200
+ interface TelegramPreviewClock {
201
+ now: number;
202
+ canSend: boolean;
203
+ }
204
+
205
+ export type TelegramPreviewEvent =
206
+ | { type: "reset" }
207
+ | { type: "invalidated" }
208
+ | { type: "segmentStarted" }
209
+ | { type: "segmentEnsured" }
210
+ | { type: "sealed" }
211
+ | { type: "discarded" }
212
+ | { type: "textSet"; text: string }
213
+ | { type: "draftWindowOpened"; at: number }
214
+ | ({
215
+ type: "flushRequested";
216
+ request: TelegramPreviewFlushRequest;
217
+ } & TelegramPreviewClock)
218
+ | ({
219
+ type: "timerFired";
220
+ request: TelegramPreviewFlushRequest;
221
+ segmentId: number;
222
+ } & TelegramPreviewClock)
223
+ | ({
224
+ type: "draftSettled";
225
+ segmentId: number;
226
+ generation: number;
227
+ /** Present only when Telegram accepted the draft. */
228
+ text?: string;
229
+ } & TelegramPreviewClock)
230
+ | { type: "publicationOpened"; id: number }
231
+ | ({ type: "publicationSettled"; id: number } & TelegramPreviewClock);
232
+
233
+ export type TelegramPreviewEffect =
234
+ | {
235
+ type: "sendDraft";
236
+ request: TelegramPreviewFlushRequest;
237
+ draftId: number;
238
+ text: string;
239
+ segmentId: number;
240
+ generation: number;
241
+ }
242
+ | {
243
+ type: "armTimer";
244
+ delay: number;
245
+ request: TelegramPreviewFlushRequest;
246
+ segmentId: number;
247
+ }
248
+ | { type: "cancelTimer" }
249
+ | { type: "clearClosedGate"; request: TelegramPreviewFlushRequest };
250
+
251
+ export interface TelegramPreviewStep {
252
+ state: TelegramPreviewMachineState;
253
+ effects: readonly TelegramPreviewEffect[];
254
+ }
255
+
256
+ export function createTelegramPreviewMachineState(): TelegramPreviewMachineState {
257
+ return {
258
+ segment: null,
259
+ generation: 0,
260
+ nextSegmentId: 0,
261
+ nextDraftId: 0,
262
+ nextDraftAt: 0,
263
+ inFlight: null,
264
+ pending: null,
265
+ publications: [],
266
+ timerSegmentId: null,
306
267
  };
268
+ }
269
+
270
+ const step = (
271
+ state: TelegramPreviewMachineState,
272
+ ...effects: TelegramPreviewEffect[]
273
+ ): TelegramPreviewStep => ({ state, effects });
274
+
275
+ /** Drops the armed throttle timer, if any; every caller replaces or seals its segment. */
276
+ const withoutTimer = (
277
+ state: TelegramPreviewMachineState,
278
+ ): TelegramPreviewStep =>
279
+ state.timerSegmentId === null
280
+ ? step(state)
281
+ : step({ ...state, timerSegmentId: null }, { type: "cancelTimer" });
282
+
283
+ /** A replaced segment takes its pending request and its throttle timer with it. */
284
+ function replaceSegment(
285
+ state: TelegramPreviewMachineState,
286
+ segment: TelegramPreviewSegment | null,
287
+ ): TelegramPreviewStep {
288
+ return withoutTimer({ ...state, segment, pending: null });
289
+ }
290
+
291
+ function startSegment(
292
+ state: TelegramPreviewMachineState,
293
+ carry: Partial<TelegramPreviewState> = {},
294
+ ): TelegramPreviewStep {
295
+ const id = state.nextSegmentId + 1;
296
+ return replaceSegment(
297
+ { ...state, nextSegmentId: id },
298
+ {
299
+ id,
300
+ mode: "draft",
301
+ pendingText: "",
302
+ lastSentText: "",
303
+ sealed: false,
304
+ ...carry,
305
+ },
306
+ );
307
+ }
308
+
309
+ /** One flush attempt for the current segment: gate, throttle, safe snapshot and draft id. */
310
+ function attemptFlush(
311
+ state: TelegramPreviewMachineState,
312
+ request: TelegramPreviewFlushRequest,
313
+ clock: TelegramPreviewClock,
314
+ config: TelegramPreviewMachineConfig,
315
+ ): TelegramPreviewStep {
316
+ const segment = state.segment;
317
+ if (!segment || segment.sealed) return step(state);
318
+ if (!clock.canSend) return step(state, { type: "clearClosedGate", request });
319
+ const delay = state.nextDraftAt - clock.now;
320
+ if (delay > 0) {
321
+ if (state.timerSegmentId !== null) return step(state);
322
+ return step(
323
+ { ...state, timerSegmentId: segment.id },
324
+ { type: "armTimer", delay, request, segmentId: segment.id },
325
+ );
326
+ }
327
+ const idle = withoutTimer(state);
328
+ const snapshot = buildTelegramNativeMarkdownPreviewSnapshot(
329
+ segment,
330
+ config.maxMessageLength,
331
+ );
332
+ if (!snapshot) return idle;
333
+ const draftId =
334
+ segment.draftId ??
335
+ allocateTelegramDraftId(state.nextDraftId, config.maxDraftId);
307
336
  return {
308
- ...controller,
309
- finalizeMarkdown:
310
- createTelegramNativeMarkdownPreviewFinalizer(finalizerDeps),
311
- prepareDelivery(isDeliveryActive) {
312
- const state = controller.getState();
313
- return {
314
- setPreviewPendingText(text) {
315
- if (controller.getState() === state && isDeliveryActive())
316
- controller.setPendingText(text);
317
- },
318
- clearPreview: async (chatId, options) => {
319
- if (controller.getState() !== state || !isDeliveryActive()) return;
320
- await controller.prepareClear(chatId, {
321
- ...options,
322
- isDeliveryActive,
323
- })();
324
- },
325
- finalizeMarkdownPreview: prepareTelegramNativeMarkdownPreviewFinalizer({
326
- ...finalizerDeps,
327
- isDeliveryActive,
328
- }),
329
- };
337
+ state: {
338
+ ...idle.state,
339
+ segment: { ...segment, draftId },
340
+ nextDraftId: segment.draftId === undefined ? draftId : state.nextDraftId,
341
+ nextDraftAt: clock.now + config.minDraftIntervalMs,
342
+ inFlight: segment.id,
330
343
  },
331
- ...createTelegramAssistantMessagePreviewHooks({
332
- getActiveTurn: deps.getActiveTurn,
333
- isAssistantMessage: deps.isAssistantMessage,
334
- getState: controller.getState,
335
- setState: controller.setState,
336
- createPreviewState: controller.createState,
337
- canSend: deps.canSend,
338
- getMessageText: deps.getMessageText,
339
- minDraftIntervalMs: TELEGRAM_DRAFT_INTERVAL_MS,
340
- schedulePreviewFlush: controller.scheduleFlush,
341
- }),
344
+ effects: [
345
+ ...idle.effects,
346
+ {
347
+ type: "sendDraft",
348
+ request,
349
+ draftId,
350
+ text: snapshot.text,
351
+ segmentId: segment.id,
352
+ generation: state.generation,
353
+ },
354
+ ],
342
355
  };
343
356
  }
344
357
 
345
- export function createTelegramPreviewController(
346
- deps: TelegramPreviewControllerDeps,
347
- ): TelegramPreviewController {
348
- let state: TelegramPreviewRuntimeState | undefined;
349
- let generation = 0;
350
- const maxDraftId = deps.maxDraftId ?? TELEGRAM_DRAFT_ID_MAX;
351
- const maxMessageLength =
352
- deps.maxMessageLength ?? TELEGRAM_DRAFT_PREVIEW_MAX_CHARS;
353
- let draftSupport = deps.initialDraftSupport ?? "unknown";
354
- let nextDraftId = 0;
355
- const setState = (
356
- nextState: TelegramPreviewRuntimeState | undefined,
357
- ): void => {
358
- if (state !== nextState) sealTelegramPreviewState(state);
359
- state = nextState;
358
+ /** Single flight: a request during an in-flight draft or behind a publication waits as the one pending request. */
359
+ function requestFlush(
360
+ state: TelegramPreviewMachineState,
361
+ request: TelegramPreviewFlushRequest,
362
+ clock: TelegramPreviewClock,
363
+ config: TelegramPreviewMachineConfig,
364
+ ): TelegramPreviewStep {
365
+ // Only a live, unsealed segment accepts flush requests, whatever the caller checked.
366
+ if (!state.segment || state.segment.sealed) return step(state);
367
+ if (state.inFlight !== null || state.publications.length > 0)
368
+ return step({ ...state, pending: request });
369
+ return attemptFlush(state, request, clock, config);
370
+ }
371
+
372
+ /** A coalesced request runs once the in-flight draft and every open publication have settled. */
373
+ function drainPending(
374
+ state: TelegramPreviewMachineState,
375
+ clock: TelegramPreviewClock,
376
+ config: TelegramPreviewMachineConfig,
377
+ ): TelegramPreviewStep {
378
+ const request = state.pending;
379
+ if (!request || state.inFlight !== null || state.publications.length > 0)
380
+ return step(state);
381
+ return attemptFlush({ ...state, pending: null }, request, clock, config);
382
+ }
383
+
384
+ export function transitionTelegramPreview(
385
+ state: TelegramPreviewMachineState,
386
+ event: TelegramPreviewEvent,
387
+ config: TelegramPreviewMachineConfig,
388
+ ): TelegramPreviewStep {
389
+ switch (event.type) {
390
+ case "reset":
391
+ case "invalidated": {
392
+ const replacement = {
393
+ ...state,
394
+ generation: state.generation + 1,
395
+ inFlight: null,
396
+ publications: [],
397
+ nextDraftAt: 0,
398
+ };
399
+ return event.type === "reset"
400
+ ? startSegment(replacement)
401
+ : replaceSegment(replacement, null);
402
+ }
403
+ case "segmentStarted": {
404
+ // Carry the previous delivery boundary; permanent text remains with its publication owner.
405
+ const draftId = state.segment?.draftId;
406
+ return startSegment(state, draftId === undefined ? {} : { draftId });
407
+ }
408
+ case "segmentEnsured":
409
+ return state.segment ? step(state) : startSegment(state);
410
+ case "sealed": {
411
+ const segment = state.segment;
412
+ return withoutTimer({
413
+ ...state,
414
+ segment:
415
+ segment && !segment.sealed ? { ...segment, sealed: true } : segment,
416
+ pending: null,
417
+ });
418
+ }
419
+ case "discarded":
420
+ return replaceSegment(state, null);
421
+ case "textSet":
422
+ return state.segment
423
+ ? step({
424
+ ...state,
425
+ segment: { ...state.segment, pendingText: event.text },
426
+ })
427
+ : step(state);
428
+ case "draftWindowOpened":
429
+ return step({
430
+ ...state,
431
+ nextDraftAt: Math.max(state.nextDraftAt, event.at),
432
+ });
433
+ case "flushRequested":
434
+ return requestFlush(state, event.request, event, config);
435
+ case "timerFired": {
436
+ if (
437
+ state.timerSegmentId !== event.segmentId ||
438
+ state.segment?.id !== event.segmentId
439
+ )
440
+ return step(state);
441
+ return requestFlush(
442
+ { ...state, timerSegmentId: null },
443
+ event.request,
444
+ event,
445
+ config,
446
+ );
447
+ }
448
+ case "draftSettled": {
449
+ if (
450
+ state.generation !== event.generation ||
451
+ state.inFlight !== event.segmentId
452
+ )
453
+ return step(state);
454
+ const segment = state.segment;
455
+ // A delivered draft updates only the segment and generation that issued it.
456
+ const applies =
457
+ event.text !== undefined &&
458
+ segment?.id === event.segmentId &&
459
+ event.canSend;
460
+ return drainPending(
461
+ {
462
+ ...state,
463
+ inFlight: null,
464
+ segment:
465
+ applies && segment
466
+ ? { ...segment, lastSentText: event.text! }
467
+ : segment,
468
+ },
469
+ event,
470
+ config,
471
+ );
472
+ }
473
+ case "publicationOpened":
474
+ return step({
475
+ ...state,
476
+ publications: [...state.publications, event.id],
477
+ });
478
+ case "publicationSettled":
479
+ if (!state.publications.includes(event.id)) return step(state);
480
+ return drainPending(
481
+ {
482
+ ...state,
483
+ publications: state.publications.filter((id) => id !== event.id),
484
+ },
485
+ event,
486
+ config,
487
+ );
488
+ }
489
+ }
490
+
491
+ /** True while a draft request is in flight, or, when publications block, a pending request waits behind one. */
492
+ export function isTelegramPreviewBusy(
493
+ state: TelegramPreviewMachineState,
494
+ blockedByPublication = true,
495
+ ): boolean {
496
+ return (
497
+ state.inFlight !== null ||
498
+ (blockedByPublication &&
499
+ state.pending !== null &&
500
+ state.publications.length > 0)
501
+ );
502
+ }
503
+
504
+ /**
505
+ * The imperative shell around `transitionTelegramPreview`: it samples the clock and send gate into events, runs
506
+ * effects, records every transport failure in one place and resolves promise-shaped ports through `waitFor`.
507
+ */
508
+ function createTelegramPreviewModel(deps: TelegramPreviewControllerDeps) {
509
+ const config: TelegramPreviewMachineConfig = {
510
+ maxDraftId: deps.maxDraftId ?? TELEGRAM_DRAFT_ID_MAX,
511
+ maxMessageLength: deps.maxMessageLength ?? TELEGRAM_DRAFT_PREVIEW_MAX_CHARS,
512
+ minDraftIntervalMs: deps.minDraftIntervalMs ?? TELEGRAM_DRAFT_INTERVAL_MS,
360
513
  };
361
- const getRuntimeDeps = (
362
- operationGeneration = generation,
363
- ): TelegramPreviewRuntimeDeps => ({
364
- getState: () => state,
365
- setState,
366
- maxMessageLength,
367
- minDraftIntervalMs: TELEGRAM_DRAFT_INTERVAL_MS,
368
- getDraftSupport: () => draftSupport,
369
- setDraftSupport: (support) => {
370
- draftSupport = support;
371
- },
372
- allocateDraftId: () => {
373
- nextDraftId = allocateTelegramDraftId(nextDraftId, maxDraftId);
374
- return nextDraftId;
375
- },
376
- sendDraft: deps.sendDraft,
377
- canSend: () =>
378
- operationGeneration === generation && (deps.canSend?.() ?? true),
379
- recordRuntimeEvent: deps.recordRuntimeEvent,
514
+ let timer: ReturnType<typeof setTimeout> | undefined;
515
+ let publicationIds = 0;
516
+ const clock = (): TelegramPreviewClock => ({
517
+ now: Date.now(),
518
+ canSend: deps.canSend?.() !== false,
380
519
  });
381
- return {
382
- getState: () => state,
383
- setState,
384
- setPendingText: (text) => {
385
- if (state) state.pendingText = text;
386
- },
387
- createState: () => createTelegramPreviewRuntimeState(),
388
- resetState: () => {
389
- generation += 1;
390
- setState({
391
- ...createTelegramPreviewRuntimeState(),
392
- nextDraftAt: state?.nextDraftAt,
520
+ const machine = Machine.createTelegramMachineRunner<
521
+ TelegramPreviewMachineState,
522
+ TelegramPreviewEvent,
523
+ TelegramPreviewEffect
524
+ >({
525
+ initial: createTelegramPreviewMachineState(),
526
+ transition: (state, event) =>
527
+ transitionTelegramPreview(state, event, config),
528
+ run: (effect) => run(effect),
529
+ });
530
+ const { dispatch, waitFor } = machine;
531
+ const whenIdle = (): Promise<void> => {
532
+ const generation = machine.state.generation;
533
+ return waitFor(
534
+ (current) =>
535
+ current.generation !== generation || !isTelegramPreviewBusy(current),
536
+ );
537
+ };
538
+ /** Captures predecessors only: a later draft/publication must never extend the boundary that blocks it. */
539
+ const whenBoundarySettles = (includePublications = true): Promise<void> => {
540
+ const { generation, inFlight, publications } = machine.state;
541
+ return waitFor(
542
+ (current) =>
543
+ current.generation !== generation ||
544
+ ((inFlight === null || current.inFlight !== inFlight) &&
545
+ (!includePublications ||
546
+ publications.every((id) => !current.publications.includes(id)))),
547
+ );
548
+ };
549
+ const canSendFor = (generation: number): boolean =>
550
+ machine.state.generation === generation && deps.canSend?.() !== false;
551
+
552
+ /** The one place preview transport failures become diagnostics; a failed call reports `false`. */
553
+ const callTransport = async (
554
+ phase: "draft" | "clear-draft",
555
+ request: TelegramPreviewFlushRequest,
556
+ draftId: number,
557
+ text: string | undefined,
558
+ ): Promise<unknown> => {
559
+ try {
560
+ return await deps.sendDraft(request.chatId, draftId, text, {
561
+ ...getTelegramTargetThreadParams(
562
+ request.target ?? { chatId: request.chatId },
563
+ ),
393
564
  });
394
- },
395
- invalidate: () => {
396
- generation += 1;
397
- setState(undefined);
398
- },
399
- seal: () => sealTelegramPreviewState(state),
400
- preparePublication: () => {
401
- if (!state) return undefined;
402
- sealTelegramPreviewState(state);
403
- const prior =
404
- state.publicationPromise ?? state.flushPromise ?? state.precedingFlush;
405
- let settle!: () => void;
406
- state.publicationPromise = new Promise<void>((resolve) => {
407
- settle = resolve;
565
+ } catch (error) {
566
+ deps.recordRuntimeEvent?.("preview", error, {
567
+ phase,
568
+ chatId: request.chatId,
569
+ draftId,
408
570
  });
571
+ return false;
572
+ }
573
+ };
574
+ const sendDraft = async (
575
+ effect: Extract<TelegramPreviewEffect, { type: "sendDraft" }>,
576
+ ): Promise<void> => {
577
+ const delivered = await callTransport(
578
+ "draft",
579
+ effect.request,
580
+ effect.draftId,
581
+ normalizeTelegramNativeMarkdown(effect.text),
582
+ );
583
+ dispatch({
584
+ type: "draftSettled",
585
+ segmentId: effect.segmentId,
586
+ generation: effect.generation,
587
+ ...(delivered === false ? {} : { text: effect.text }),
588
+ ...clock(),
589
+ });
590
+ };
591
+ const run = (effect: TelegramPreviewEffect): void => {
592
+ switch (effect.type) {
593
+ case "sendDraft":
594
+ void sendDraft(effect);
595
+ return;
596
+ case "armTimer":
597
+ timer = setTimeout(() => {
598
+ timer = undefined;
599
+ dispatch({
600
+ type: "timerFired",
601
+ request: effect.request,
602
+ segmentId: effect.segmentId,
603
+ ...clock(),
604
+ });
605
+ }, effect.delay);
606
+ timer.unref?.();
607
+ return;
608
+ case "cancelTimer":
609
+ if (timer) clearTimeout(timer);
610
+ timer = undefined;
611
+ return;
612
+ case "clearClosedGate":
613
+ void clear(effect.request.chatId, {
614
+ awaitFlush: false,
615
+ target: effect.request.target,
616
+ });
617
+ return;
618
+ }
619
+ };
620
+
621
+ const seal = (): void => {
622
+ dispatch({ type: "sealed" });
623
+ };
624
+
625
+ const clear = async (
626
+ chatId: number,
627
+ options: TelegramPreviewTargetOptions & {
628
+ awaitFlush?: boolean;
629
+ isDeliveryActive?: () => boolean;
630
+ } = {},
631
+ generation = machine.state.generation,
632
+ ): Promise<void> => {
633
+ const segment = machine.state.segment;
634
+ if (!segment || options.isDeliveryActive?.() === false) return;
635
+ seal();
636
+ if (machine.state.inFlight !== null && options.awaitFlush !== false) {
637
+ await whenBoundarySettles(false);
638
+ if (machine.state.segment?.id !== segment.id) return;
639
+ }
640
+ if (options.isDeliveryActive?.() === false) return;
641
+ const issued = machine.state.segment ?? segment;
642
+ dispatch({ type: "discarded" });
643
+ if (issued.draftId !== undefined && canSendFor(generation))
644
+ await callTransport(
645
+ "clear-draft",
646
+ { chatId, target: options.target },
647
+ issued.draftId,
648
+ undefined,
649
+ );
650
+ };
651
+
652
+ const flush = async (
653
+ chatId: number,
654
+ options: TelegramPreviewTargetOptions = {},
655
+ ): Promise<void> => {
656
+ if (!machine.state.segment || machine.state.segment.sealed) return;
657
+ dispatch({
658
+ type: "flushRequested",
659
+ request: { chatId, target: options.target },
660
+ ...clock(),
661
+ });
662
+ await whenIdle();
663
+ };
664
+
665
+ const prepareFinalizer = <TReplyMarkup>(input: {
666
+ sendMarkdownReply: TelegramAssistantPreviewRuntimeDeps<
667
+ unknown,
668
+ TReplyMarkup
669
+ >["sendMarkdownReply"];
670
+ isDeliveryActive?: () => boolean;
671
+ }): TelegramMarkdownFinalizer<TReplyMarkup> => {
672
+ const segmentId = machine.state.segment?.id;
673
+ const generation = machine.state.generation;
674
+ seal();
675
+ const predecessors = whenBoundarySettles();
676
+ return async (chatId, markdown, replyToMessageId, options) => {
677
+ if (input.isDeliveryActive?.() === false) return false;
678
+ await predecessors;
679
+ if (
680
+ machine.state.generation !== generation ||
681
+ machine.state.segment?.id !== segmentId ||
682
+ input.isDeliveryActive?.() === false
683
+ )
684
+ return false;
685
+ await input.sendMarkdownReply(
686
+ chatId,
687
+ replyToMessageId,
688
+ markdown,
689
+ options,
690
+ );
691
+ if (
692
+ machine.state.segment?.id === segmentId &&
693
+ input.isDeliveryActive?.() !== false
694
+ )
695
+ dispatch({ type: "discarded" });
696
+ return true;
697
+ };
698
+ };
699
+
700
+ const controller: TelegramPreviewController = {
701
+ getState: () => machine.state.segment ?? undefined,
702
+ isFlushing: () => isTelegramPreviewBusy(machine.state),
703
+ seal,
704
+ setPendingText: (text) => dispatch({ type: "textSet", text }),
705
+ resetState: () => dispatch({ type: "reset" }),
706
+ invalidate: () => dispatch({ type: "invalidated" }),
707
+ preparePublication: () => {
708
+ if (!machine.state.segment) return undefined;
709
+ seal();
710
+ const id = (publicationIds += 1);
711
+ const prior = whenBoundarySettles();
712
+ dispatch({ type: "publicationOpened", id });
713
+ let settled = false;
409
714
  return {
410
- wait: async () => {
411
- await prior?.catch(() => {});
715
+ wait: () => prior,
716
+ settle: () => {
717
+ if (settled) return;
718
+ settled = true;
719
+ dispatch({ type: "publicationSettled", id, ...clock() });
412
720
  },
413
- settle,
414
721
  };
415
722
  },
416
723
  prepareClear: (chatId, options) => {
417
- const admittedState = state;
418
- const runtime = getRuntimeDeps();
724
+ const admitted = machine.state.segment?.id;
725
+ const generation = machine.state.generation;
419
726
  return async () => {
420
- if (state !== admittedState) return;
421
- await clearTelegramPreview(chatId, runtime, {
422
- ...options,
423
- isDeliveryActive: () =>
424
- runtime.canSend?.() !== false &&
425
- options?.isDeliveryActive?.() !== false,
426
- });
727
+ if (machine.state.segment?.id !== admitted) return;
728
+ await clear(
729
+ chatId,
730
+ {
731
+ ...options,
732
+ isDeliveryActive: () =>
733
+ canSendFor(generation) && options?.isDeliveryActive?.() !== false,
734
+ },
735
+ generation,
736
+ );
427
737
  };
428
738
  },
429
- clear: (chatId, options) =>
430
- clearTelegramPreview(chatId, getRuntimeDeps(), options),
431
- flush: (chatId, options) =>
432
- flushTelegramPreview(chatId, getRuntimeDeps(), options),
739
+ clear: (chatId, options) => clear(chatId, options),
740
+ flush,
433
741
  scheduleFlush: (chatId, options) => {
434
- if (!state) return;
435
- void flushTelegramPreview(chatId, getRuntimeDeps(), options);
742
+ if (!machine.state.segment) return;
743
+ void flush(chatId, options);
744
+ },
745
+ finalize: async (chatId, _replyToMessageId, options) => {
746
+ const segment = machine.state.segment;
747
+ if (!segment) return false;
748
+ if (!canSendFor(machine.state.generation)) {
749
+ await clear(chatId, options);
750
+ return false;
751
+ }
752
+ await flush(chatId, options);
753
+ const current = machine.state.segment;
754
+ if (current?.id !== segment.id) return false;
755
+ const finalText = buildTelegramPreviewFinalText(current);
756
+ if (!finalText) {
757
+ await clear(chatId, options);
758
+ return false;
759
+ }
760
+ dispatch({ type: "discarded" });
761
+ return false;
436
762
  },
437
- finalize: (chatId, _replyToMessageId, options) =>
438
- finalizeTelegramPreview(chatId, getRuntimeDeps(), options),
439
763
  };
440
- }
441
-
442
- function createTelegramAssistantMessagePreviewHooks<TMessage>(
443
- deps: TelegramAssistantMessagePreviewHookDeps<TMessage>,
444
- ): TelegramAssistantMessagePreviewHooks<TMessage> {
445
764
  return {
446
- onMessageStart: async (
447
- event: TelegramAssistantMessagePreviewHookEvent<TMessage>,
448
- ): Promise<void> => {
449
- await handleTelegramAssistantMessagePreviewStart(event.message, deps);
765
+ controller,
766
+ prepareFinalizer,
767
+ startSegment: (): void => {
768
+ dispatch({ type: "segmentStarted" });
769
+ },
770
+ ensureSegment: (): TelegramPreviewSegment => {
771
+ dispatch({ type: "segmentEnsured" });
772
+ return machine.state.segment!;
450
773
  },
451
- onMessageUpdate: async (
452
- event: TelegramAssistantMessagePreviewHookEvent<TMessage>,
453
- ): Promise<void> => {
454
- await handleTelegramAssistantMessagePreviewUpdate(event.message, deps);
774
+ openDraftWindow: (at: number): void => {
775
+ dispatch({ type: "draftWindowOpened", at });
776
+ },
777
+ discard: (): void => {
778
+ dispatch({ type: "discarded" });
455
779
  },
456
780
  };
457
781
  }
458
782
 
783
+ export function createTelegramPreviewController(
784
+ deps: TelegramPreviewControllerDeps,
785
+ ): TelegramPreviewController {
786
+ return createTelegramPreviewModel(deps).controller;
787
+ }
788
+
459
789
  /**
460
790
  * Returns true when the active turn is a Telegram Guest Mode query. A guest
461
791
  * query allows exactly one answer within a limited Telegram response window,
@@ -467,72 +797,84 @@ export function shouldSuppressPreviewForGuestTurn(
467
797
  return !!turn?.guestQueryId;
468
798
  }
469
799
 
470
- async function handleTelegramAssistantMessagePreviewStart<TMessage>(
471
- message: TMessage,
472
- deps: TelegramAssistantMessagePreviewStartDeps<TMessage>,
473
- ): Promise<void> {
474
- const turn = deps.getActiveTurn();
475
- if (!turn || !deps.isAssistantMessage(message)) return;
476
- if (deps.canSend && !deps.canSend()) {
477
- deps.setState(undefined);
478
- return;
479
- }
480
- if (shouldSuppressPreviewForVoice(turn)) {
481
- deps.setState(undefined);
482
- return;
483
- }
484
- if (shouldSuppressPreviewForGuestTurn(turn)) {
485
- deps.setState(undefined);
486
- return;
487
- }
488
- const state = deps.getState();
489
- sealTelegramPreviewState(state);
490
- const next = deps.createPreviewState();
491
- // Carry the previous delivery boundary; permanent text remains with its publication owner.
492
- next.draftId = state?.draftId;
493
- next.nextDraftAt = state?.nextDraftAt;
494
- next.precedingFlush =
495
- state?.publicationPromise ?? state?.flushPromise ?? state?.precedingFlush;
496
- deps.setState(next);
497
- }
498
-
499
- async function handleTelegramAssistantMessagePreviewUpdate<TMessage>(
500
- message: TMessage,
501
- deps: TelegramAssistantMessagePreviewUpdateDeps<TMessage>,
502
- ): Promise<void> {
503
- const turn = deps.getActiveTurn();
504
- if (!turn || !deps.isAssistantMessage(message)) return;
505
- if (deps.canSend && !deps.canSend()) {
506
- deps.setState(undefined);
507
- return;
508
- }
509
- if (shouldSuppressPreviewForVoice(turn)) return;
510
- if (shouldSuppressPreviewForGuestTurn(turn)) return;
511
- let state = deps.getState();
512
- if (!state) {
513
- state = deps.createPreviewState();
514
- deps.setState(state);
515
- }
516
- if (state.sealed) return;
517
- const hadVisibleText = state.pendingText.length > 0;
518
- state.pendingText = stripTelegramCommentMarkupForPreview(
519
- deps.getMessageText(message),
520
- );
521
- // The first visible text of a preview segment opens an initial accumulation
522
- // window, so the segment's first frame cannot ship as a single word even
523
- // when the previous cadence boundary has already passed (fresh turn, slow
524
- // first token, or message rollover after tool work). Later deltas keep the
525
- // trailing deadline instead of sliding it on every update.
526
- const interval = deps.minDraftIntervalMs ?? 0;
527
- if (
528
- interval > 0 &&
529
- !hadVisibleText &&
530
- !state.lastSentText &&
531
- state.pendingText.length > 0
532
- ) {
533
- state.nextDraftAt = Math.max(state.nextDraftAt ?? 0, Date.now() + interval);
534
- }
535
- deps.schedulePreviewFlush(turn.chatId, { target: turn.target });
800
+ export function createTelegramAssistantPreviewRuntime<
801
+ TMessage,
802
+ TReplyMarkup = TelegramPreviewReplyMarkup,
803
+ >(
804
+ deps: TelegramAssistantPreviewRuntimeDeps<TMessage, TReplyMarkup>,
805
+ ): TelegramAssistantPreviewRuntime<TMessage, TReplyMarkup> {
806
+ const model = createTelegramPreviewModel(deps);
807
+ const { controller } = model;
808
+ const suppressed = (turn: TelegramPreviewActiveTurn): boolean =>
809
+ shouldSuppressPreviewForVoice(turn) ||
810
+ shouldSuppressPreviewForGuestTurn(turn);
811
+ return {
812
+ ...controller,
813
+ finalizeMarkdown: (...args) =>
814
+ model.prepareFinalizer<TReplyMarkup>({
815
+ sendMarkdownReply: deps.sendMarkdownReply,
816
+ })(...args),
817
+ prepareDelivery(isDeliveryActive) {
818
+ const admitted = controller.getState()?.id;
819
+ return {
820
+ setPreviewPendingText(text) {
821
+ if (controller.getState()?.id === admitted && isDeliveryActive())
822
+ controller.setPendingText(text);
823
+ },
824
+ clearPreview: async (chatId, options) => {
825
+ if (controller.getState()?.id !== admitted || !isDeliveryActive())
826
+ return;
827
+ await controller.prepareClear(chatId, {
828
+ ...options,
829
+ isDeliveryActive,
830
+ })();
831
+ },
832
+ finalizeMarkdownPreview: model.prepareFinalizer<TReplyMarkup>({
833
+ sendMarkdownReply: deps.sendMarkdownReply,
834
+ isDeliveryActive,
835
+ }),
836
+ };
837
+ },
838
+ onMessageStart: async ({ message }) => {
839
+ const turn = deps.getActiveTurn();
840
+ if (!turn || !deps.isAssistantMessage(message)) return;
841
+ if ((deps.canSend && !deps.canSend()) || suppressed(turn)) {
842
+ model.discard();
843
+ return;
844
+ }
845
+ model.startSegment();
846
+ },
847
+ onMessageUpdate: async ({ message }) => {
848
+ const turn = deps.getActiveTurn();
849
+ if (!turn || !deps.isAssistantMessage(message)) return;
850
+ if (deps.canSend && !deps.canSend()) {
851
+ model.discard();
852
+ return;
853
+ }
854
+ if (suppressed(turn)) return;
855
+ const segment = model.ensureSegment();
856
+ if (segment.sealed) return;
857
+ const hadVisibleText = segment.pendingText.length > 0;
858
+ const pendingText = stripTelegramCommentMarkupForPreview(
859
+ deps.getMessageText(message),
860
+ );
861
+ controller.setPendingText(pendingText);
862
+ // The first visible text of a preview segment opens an initial accumulation
863
+ // window, so the segment's first frame cannot ship as a single word even
864
+ // when the previous cadence boundary has already passed (fresh turn, slow
865
+ // first token, or message rollover after tool work). Later deltas keep the
866
+ // trailing deadline instead of sliding it on every update.
867
+ const interval = deps.minDraftIntervalMs ?? TELEGRAM_DRAFT_INTERVAL_MS;
868
+ if (
869
+ interval > 0 &&
870
+ !hadVisibleText &&
871
+ !segment.lastSentText &&
872
+ pendingText.length > 0
873
+ )
874
+ model.openDraftWindow(Date.now() + interval);
875
+ controller.scheduleFlush(turn.chatId, { target: turn.target });
876
+ },
877
+ };
536
878
  }
537
879
 
538
880
  export function buildTelegramPreviewFinalText(
@@ -543,14 +885,6 @@ export function buildTelegramPreviewFinalText(
543
885
  return state.lastSentText.trim() || undefined;
544
886
  }
545
887
 
546
- export function createTelegramPreviewRuntimeState(): TelegramPreviewRuntimeState {
547
- return {
548
- mode: "draft",
549
- pendingText: "",
550
- lastSentText: "",
551
- };
552
- }
553
-
554
888
  export function allocateTelegramDraftId(
555
889
  currentDraftId: number,
556
890
  maxDraftId: number,
@@ -558,56 +892,6 @@ export function allocateTelegramDraftId(
558
892
  return currentDraftId >= maxDraftId ? 1 : currentDraftId + 1;
559
893
  }
560
894
 
561
- interface TelegramNativeMarkdownPreviewSnapshot {
562
- text: string;
563
- }
564
-
565
- export function shouldUseTelegramDraftPreview(_options: {
566
- draftSupport: TelegramDraftSupport;
567
- snapshot?: TelegramNativeMarkdownPreviewSnapshot;
568
- }): boolean {
569
- return true;
570
- }
571
-
572
- export async function clearTelegramPreview(
573
- chatId: number,
574
- deps: TelegramPreviewRuntimeDeps,
575
- options: {
576
- awaitFlush?: boolean;
577
- target?: TelegramTarget;
578
- isDeliveryActive?: () => boolean;
579
- } = {},
580
- ): Promise<void> {
581
- const state = deps.getState();
582
- if (!state || options.isDeliveryActive?.() === false) return;
583
- sealTelegramPreviewState(state);
584
- const inFlight = state.flushPromise ?? state.precedingFlush;
585
- if (inFlight && options.awaitFlush !== false) {
586
- state.flushRequested = false;
587
- await inFlight.catch(() => {});
588
- if (deps.getState() !== state) return;
589
- }
590
- if (options.isDeliveryActive?.() === false) return;
591
- deps.setState(undefined);
592
- if (
593
- state.mode === "draft" &&
594
- state.draftId !== undefined &&
595
- deps.canSend?.() !== false
596
- ) {
597
- try {
598
- await deps.sendDraft(chatId, state.draftId, undefined, {
599
- ...getTelegramTargetThreadParams(options.target ?? { chatId }),
600
- });
601
- } catch (error) {
602
- deps.recordRuntimeEvent?.("preview", error, {
603
- phase: "clear-draft",
604
- chatId,
605
- draftId: state.draftId,
606
- });
607
- }
608
- }
609
- }
610
-
611
895
  interface TelegramDraftInlineState {
612
896
  codeTicks: number;
613
897
  htmlComment: boolean;
@@ -852,7 +1136,7 @@ export function getSafeTelegramRichMarkdownDraftPrefix(
852
1136
  function buildTelegramNativeMarkdownPreviewSnapshot(
853
1137
  state: TelegramPreviewState,
854
1138
  maxMessageLength: number,
855
- ): TelegramNativeMarkdownPreviewSnapshot | undefined {
1139
+ ): { text: string } | undefined {
856
1140
  const safeText = getSafeTelegramRichMarkdownDraftPrefix(
857
1141
  state.pendingText,
858
1142
  maxMessageLength,
@@ -860,138 +1144,3 @@ function buildTelegramNativeMarkdownPreviewSnapshot(
860
1144
  if (!safeText || safeText === state.lastSentText) return undefined;
861
1145
  return { text: safeText };
862
1146
  }
863
-
864
- async function performTelegramPreviewFlush(
865
- chatId: number,
866
- state: TelegramPreviewRuntimeState,
867
- deps: TelegramPreviewRuntimeDeps,
868
- options: { target?: TelegramTarget } = {},
869
- ): Promise<void> {
870
- if (deps.canSend && !deps.canSend()) {
871
- await clearTelegramPreview(chatId, deps, {
872
- awaitFlush: false,
873
- target: options.target,
874
- });
875
- return;
876
- }
877
- const snapshot = buildTelegramNativeMarkdownPreviewSnapshot(
878
- state,
879
- deps.maxMessageLength,
880
- );
881
- if (!snapshot) return;
882
- if (
883
- shouldUseTelegramDraftPreview({
884
- draftSupport: deps.getDraftSupport(),
885
- snapshot,
886
- })
887
- ) {
888
- const draftId = state.draftId ?? deps.allocateDraftId();
889
- state.draftId = draftId;
890
- state.nextDraftAt = Date.now() + (deps.minDraftIntervalMs ?? 0);
891
- try {
892
- const delivered = await deps.sendDraft(
893
- chatId,
894
- draftId,
895
- normalizeTelegramNativeMarkdown(snapshot.text),
896
- { ...getTelegramTargetThreadParams(options.target ?? { chatId }) },
897
- );
898
- if (
899
- delivered === false ||
900
- deps.getState() !== state ||
901
- deps.canSend?.() === false
902
- )
903
- return;
904
- deps.setDraftSupport("supported");
905
- state.mode = "draft";
906
- state.lastSentText = snapshot.text;
907
- return;
908
- } catch (error) {
909
- deps.recordRuntimeEvent?.("preview", error, {
910
- phase: "draft",
911
- chatId,
912
- draftId,
913
- });
914
- return;
915
- }
916
- }
917
- }
918
-
919
- export async function flushTelegramPreview(
920
- chatId: number,
921
- deps: TelegramPreviewRuntimeDeps,
922
- options: { target?: TelegramTarget } = {},
923
- ): Promise<void> {
924
- const state = deps.getState();
925
- if (!state || state.sealed) return;
926
- if (state.flushPromise) {
927
- state.flushRequested = true;
928
- await state.flushPromise;
929
- return;
930
- }
931
- state.flushPromise = (async () => {
932
- if (state.precedingFlush) {
933
- await state.precedingFlush.catch(() => {});
934
- state.precedingFlush = undefined;
935
- if (deps.getState() !== state || state.sealed) return;
936
- }
937
- do {
938
- state.flushRequested = false;
939
- const delay = (state.nextDraftAt ?? 0) - Date.now();
940
- if (delay > 0) {
941
- if (!state.flushTimer) {
942
- state.flushTimer = setTimeout(() => {
943
- state.flushTimer = undefined;
944
- if (deps.getState() === state && !state.sealed)
945
- void flushTelegramPreview(chatId, deps, options);
946
- }, delay);
947
- state.flushTimer.unref?.();
948
- }
949
- break;
950
- }
951
- if (state.flushTimer) clearTimeout(state.flushTimer);
952
- state.flushTimer = undefined;
953
- try {
954
- await performTelegramPreviewFlush(chatId, state, deps, options);
955
- } catch (error) {
956
- deps.recordRuntimeEvent?.("preview", error, {
957
- phase: "flush",
958
- chatId,
959
- draftId: state.draftId,
960
- });
961
- break;
962
- }
963
- } while (
964
- deps.getState() === state &&
965
- !state.sealed &&
966
- state.flushRequested
967
- );
968
- })();
969
- try {
970
- await state.flushPromise;
971
- } finally {
972
- if (deps.getState() === state) {
973
- state.flushPromise = undefined;
974
- }
975
- }
976
- }
977
-
978
- export async function finalizeTelegramPreview(
979
- chatId: number,
980
- deps: TelegramPreviewRuntimeDeps,
981
- options: { target?: TelegramTarget } = {},
982
- ): Promise<boolean> {
983
- const state = deps.getState();
984
- if (!state) return false;
985
- if (deps.canSend && !deps.canSend()) {
986
- await clearTelegramPreview(chatId, deps, options);
987
- return false;
988
- }
989
- await flushTelegramPreview(chatId, deps, options);
990
- const finalText = buildTelegramPreviewFinalText(state);
991
- if (!finalText) {
992
- await clearTelegramPreview(chatId, deps, options);
993
- return false;
994
- }
995
- deps.setState(undefined);
996
- return false;
997
- }