@llblab/pi-telegram 0.54.3 → 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.
- package/AGENTS.md +4 -2
- package/BACKLOG.md +1 -1
- package/CHANGELOG.md +21 -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 +5 -1
- package/dist/lib/bindings.js +9 -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 +49 -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 +5 -3
- package/dist/lib/bus.js +13 -10
- package/dist/lib/delivery.d.ts +4 -0
- package/dist/lib/delivery.js +48 -15
- package/dist/lib/extension.js +56 -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 +14 -2
- package/dist/lib/routing.js +546 -207
- 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 +20 -5
- package/dist/lib/threads.js +111 -32
- package/dist/lib/turns.d.ts +2 -0
- package/dist/lib/turns.js +6 -1
- package/dist/lib/updates.d.ts +11 -4
- package/dist/lib/updates.js +54 -19
- package/dist/package.json +1 -1
- package/docs/activity.md +1 -1
- package/docs/architecture.md +16 -15
- package/docs/delivery.md +7 -5
- package/docs/multi-instance-bus.md +18 -8
- package/docs/public-api.md +2 -2
- package/docs/ui-style.md +8 -6
- package/lib/activity.ts +29 -5
- package/lib/bindings.ts +13 -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 +17 -15
- package/lib/delivery.ts +47 -19
- package/lib/extension.ts +59 -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 +644 -283
- 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 +130 -38
- package/lib/turns.ts +8 -1
- package/lib/updates.ts +58 -21
- package/package.json +1 -1
package/dist/lib/preview.js
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
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
|
*/
|
|
7
|
+
import * as Machine from "./machine.js";
|
|
6
8
|
import { stripTelegramCommentMarkupForPreview } from "./outbound-markup.js";
|
|
7
9
|
import { isTelegramMarkdownFenceClosing, normalizeTelegramNativeMarkdown, parseTelegramMarkdownFenceOpening, } from "./replies.js";
|
|
8
10
|
import { getTelegramTargetThreadParams, } from "./target.js";
|
|
@@ -13,188 +15,412 @@ const TELEGRAM_DRAFT_PREVIEW_MAX_CHARS = 4096;
|
|
|
13
15
|
// segment holds its first frame for one full interval so the opening frame is
|
|
14
16
|
// an accumulated passage rather than a single streamed word.
|
|
15
17
|
const TELEGRAM_DRAFT_INTERVAL_MS = 2_000;
|
|
16
|
-
export function
|
|
17
|
-
return
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (!state)
|
|
29
|
-
return;
|
|
30
|
-
state.sealed = true;
|
|
31
|
-
state.flushRequested = false;
|
|
32
|
-
if (state.flushTimer)
|
|
33
|
-
clearTimeout(state.flushTimer);
|
|
34
|
-
state.flushTimer = undefined;
|
|
18
|
+
export function createTelegramPreviewMachineState() {
|
|
19
|
+
return {
|
|
20
|
+
segment: null,
|
|
21
|
+
generation: 0,
|
|
22
|
+
nextSegmentId: 0,
|
|
23
|
+
nextDraftId: 0,
|
|
24
|
+
nextDraftAt: 0,
|
|
25
|
+
inFlight: null,
|
|
26
|
+
pending: null,
|
|
27
|
+
publications: [],
|
|
28
|
+
timerSegmentId: null,
|
|
29
|
+
};
|
|
35
30
|
}
|
|
36
|
-
|
|
37
|
-
|
|
31
|
+
const step = (state, ...effects) => ({ state, effects });
|
|
32
|
+
/** Drops the armed throttle timer, if any; every caller replaces or seals its segment. */
|
|
33
|
+
const withoutTimer = (state) => state.timerSegmentId === null
|
|
34
|
+
? step(state)
|
|
35
|
+
: step({ ...state, timerSegmentId: null }, { type: "cancelTimer" });
|
|
36
|
+
/** A replaced segment takes its pending request and its throttle timer with it. */
|
|
37
|
+
function replaceSegment(state, segment) {
|
|
38
|
+
return withoutTimer({ ...state, segment, pending: null });
|
|
38
39
|
}
|
|
39
|
-
function
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
await deps.sendMarkdownReply(chatId, replyToMessageId, markdown, options);
|
|
50
|
-
if (deps.getState() === state && deps.isDeliveryActive?.() !== false)
|
|
51
|
-
deps.discard?.();
|
|
52
|
-
return true;
|
|
53
|
-
};
|
|
40
|
+
function startSegment(state, carry = {}) {
|
|
41
|
+
const id = state.nextSegmentId + 1;
|
|
42
|
+
return replaceSegment({ ...state, nextSegmentId: id }, {
|
|
43
|
+
id,
|
|
44
|
+
mode: "draft",
|
|
45
|
+
pendingText: "",
|
|
46
|
+
lastSentText: "",
|
|
47
|
+
sealed: false,
|
|
48
|
+
...carry,
|
|
49
|
+
});
|
|
54
50
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
51
|
+
/** One flush attempt for the current segment: gate, throttle, safe snapshot and draft id. */
|
|
52
|
+
function attemptFlush(state, request, clock, config) {
|
|
53
|
+
const segment = state.segment;
|
|
54
|
+
if (!segment || segment.sealed)
|
|
55
|
+
return step(state);
|
|
56
|
+
if (!clock.canSend)
|
|
57
|
+
return step(state, { type: "clearClosedGate", request });
|
|
58
|
+
const delay = state.nextDraftAt - clock.now;
|
|
59
|
+
if (delay > 0) {
|
|
60
|
+
if (state.timerSegmentId !== null)
|
|
61
|
+
return step(state);
|
|
62
|
+
return step({ ...state, timerSegmentId: segment.id }, { type: "armTimer", delay, request, segmentId: segment.id });
|
|
63
|
+
}
|
|
64
|
+
const idle = withoutTimer(state);
|
|
65
|
+
const snapshot = buildTelegramNativeMarkdownPreviewSnapshot(segment, config.maxMessageLength);
|
|
66
|
+
if (!snapshot)
|
|
67
|
+
return idle;
|
|
68
|
+
const draftId = segment.draftId ??
|
|
69
|
+
allocateTelegramDraftId(state.nextDraftId, config.maxDraftId);
|
|
63
70
|
return {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (controller.getState() === state && isDeliveryActive())
|
|
71
|
-
controller.setPendingText(text);
|
|
72
|
-
},
|
|
73
|
-
clearPreview: async (chatId, options) => {
|
|
74
|
-
if (controller.getState() !== state || !isDeliveryActive())
|
|
75
|
-
return;
|
|
76
|
-
await controller.prepareClear(chatId, {
|
|
77
|
-
...options,
|
|
78
|
-
isDeliveryActive,
|
|
79
|
-
})();
|
|
80
|
-
},
|
|
81
|
-
finalizeMarkdownPreview: prepareTelegramNativeMarkdownPreviewFinalizer({
|
|
82
|
-
...finalizerDeps,
|
|
83
|
-
isDeliveryActive,
|
|
84
|
-
}),
|
|
85
|
-
};
|
|
71
|
+
state: {
|
|
72
|
+
...idle.state,
|
|
73
|
+
segment: { ...segment, draftId },
|
|
74
|
+
nextDraftId: segment.draftId === undefined ? draftId : state.nextDraftId,
|
|
75
|
+
nextDraftAt: clock.now + config.minDraftIntervalMs,
|
|
76
|
+
inFlight: segment.id,
|
|
86
77
|
},
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
78
|
+
effects: [
|
|
79
|
+
...idle.effects,
|
|
80
|
+
{
|
|
81
|
+
type: "sendDraft",
|
|
82
|
+
request,
|
|
83
|
+
draftId,
|
|
84
|
+
text: snapshot.text,
|
|
85
|
+
segmentId: segment.id,
|
|
86
|
+
generation: state.generation,
|
|
87
|
+
},
|
|
88
|
+
],
|
|
98
89
|
};
|
|
99
90
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
91
|
+
/** Single flight: a request during an in-flight draft or behind a publication waits as the one pending request. */
|
|
92
|
+
function requestFlush(state, request, clock, config) {
|
|
93
|
+
// Only a live, unsealed segment accepts flush requests, whatever the caller checked.
|
|
94
|
+
if (!state.segment || state.segment.sealed)
|
|
95
|
+
return step(state);
|
|
96
|
+
if (state.inFlight !== null || state.publications.length > 0)
|
|
97
|
+
return step({ ...state, pending: request });
|
|
98
|
+
return attemptFlush(state, request, clock, config);
|
|
99
|
+
}
|
|
100
|
+
/** A coalesced request runs once the in-flight draft and every open publication have settled. */
|
|
101
|
+
function drainPending(state, clock, config) {
|
|
102
|
+
const request = state.pending;
|
|
103
|
+
if (!request || state.inFlight !== null || state.publications.length > 0)
|
|
104
|
+
return step(state);
|
|
105
|
+
return attemptFlush({ ...state, pending: null }, request, clock, config);
|
|
106
|
+
}
|
|
107
|
+
export function transitionTelegramPreview(state, event, config) {
|
|
108
|
+
switch (event.type) {
|
|
109
|
+
case "reset":
|
|
110
|
+
case "invalidated": {
|
|
111
|
+
const replacement = {
|
|
112
|
+
...state,
|
|
113
|
+
generation: state.generation + 1,
|
|
114
|
+
inFlight: null,
|
|
115
|
+
publications: [],
|
|
116
|
+
nextDraftAt: 0,
|
|
117
|
+
};
|
|
118
|
+
return event.type === "reset"
|
|
119
|
+
? startSegment(replacement)
|
|
120
|
+
: replaceSegment(replacement, null);
|
|
121
|
+
}
|
|
122
|
+
case "segmentStarted": {
|
|
123
|
+
// Carry the previous delivery boundary; permanent text remains with its publication owner.
|
|
124
|
+
const draftId = state.segment?.draftId;
|
|
125
|
+
return startSegment(state, draftId === undefined ? {} : { draftId });
|
|
126
|
+
}
|
|
127
|
+
case "segmentEnsured":
|
|
128
|
+
return state.segment ? step(state) : startSegment(state);
|
|
129
|
+
case "sealed": {
|
|
130
|
+
const segment = state.segment;
|
|
131
|
+
return withoutTimer({
|
|
132
|
+
...state,
|
|
133
|
+
segment: segment && !segment.sealed ? { ...segment, sealed: true } : segment,
|
|
134
|
+
pending: null,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
case "discarded":
|
|
138
|
+
return replaceSegment(state, null);
|
|
139
|
+
case "textSet":
|
|
140
|
+
return state.segment
|
|
141
|
+
? step({
|
|
142
|
+
...state,
|
|
143
|
+
segment: { ...state.segment, pendingText: event.text },
|
|
144
|
+
})
|
|
145
|
+
: step(state);
|
|
146
|
+
case "draftWindowOpened":
|
|
147
|
+
return step({
|
|
148
|
+
...state,
|
|
149
|
+
nextDraftAt: Math.max(state.nextDraftAt, event.at),
|
|
150
|
+
});
|
|
151
|
+
case "flushRequested":
|
|
152
|
+
return requestFlush(state, event.request, event, config);
|
|
153
|
+
case "timerFired": {
|
|
154
|
+
if (state.timerSegmentId !== event.segmentId ||
|
|
155
|
+
state.segment?.id !== event.segmentId)
|
|
156
|
+
return step(state);
|
|
157
|
+
return requestFlush({ ...state, timerSegmentId: null }, event.request, event, config);
|
|
158
|
+
}
|
|
159
|
+
case "draftSettled": {
|
|
160
|
+
if (state.generation !== event.generation ||
|
|
161
|
+
state.inFlight !== event.segmentId)
|
|
162
|
+
return step(state);
|
|
163
|
+
const segment = state.segment;
|
|
164
|
+
// A delivered draft updates only the segment and generation that issued it.
|
|
165
|
+
const applies = event.text !== undefined &&
|
|
166
|
+
segment?.id === event.segmentId &&
|
|
167
|
+
event.canSend;
|
|
168
|
+
return drainPending({
|
|
169
|
+
...state,
|
|
170
|
+
inFlight: null,
|
|
171
|
+
segment: applies && segment
|
|
172
|
+
? { ...segment, lastSentText: event.text }
|
|
173
|
+
: segment,
|
|
174
|
+
}, event, config);
|
|
175
|
+
}
|
|
176
|
+
case "publicationOpened":
|
|
177
|
+
return step({
|
|
178
|
+
...state,
|
|
179
|
+
publications: [...state.publications, event.id],
|
|
180
|
+
});
|
|
181
|
+
case "publicationSettled":
|
|
182
|
+
if (!state.publications.includes(event.id))
|
|
183
|
+
return step(state);
|
|
184
|
+
return drainPending({
|
|
185
|
+
...state,
|
|
186
|
+
publications: state.publications.filter((id) => id !== event.id),
|
|
187
|
+
}, event, config);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/** True while a draft request is in flight, or, when publications block, a pending request waits behind one. */
|
|
191
|
+
export function isTelegramPreviewBusy(state, blockedByPublication = true) {
|
|
192
|
+
return (state.inFlight !== null ||
|
|
193
|
+
(blockedByPublication &&
|
|
194
|
+
state.pending !== null &&
|
|
195
|
+
state.publications.length > 0));
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* The imperative shell around `transitionTelegramPreview`: it samples the clock and send gate into events, runs
|
|
199
|
+
* effects, records every transport failure in one place and resolves promise-shaped ports through `waitFor`.
|
|
200
|
+
*/
|
|
201
|
+
function createTelegramPreviewModel(deps) {
|
|
202
|
+
const config = {
|
|
203
|
+
maxDraftId: deps.maxDraftId ?? TELEGRAM_DRAFT_ID_MAX,
|
|
204
|
+
maxMessageLength: deps.maxMessageLength ?? TELEGRAM_DRAFT_PREVIEW_MAX_CHARS,
|
|
205
|
+
minDraftIntervalMs: deps.minDraftIntervalMs ?? TELEGRAM_DRAFT_INTERVAL_MS,
|
|
111
206
|
};
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
getDraftSupport: () => draftSupport,
|
|
118
|
-
setDraftSupport: (support) => {
|
|
119
|
-
draftSupport = support;
|
|
120
|
-
},
|
|
121
|
-
allocateDraftId: () => {
|
|
122
|
-
nextDraftId = allocateTelegramDraftId(nextDraftId, maxDraftId);
|
|
123
|
-
return nextDraftId;
|
|
124
|
-
},
|
|
125
|
-
sendDraft: deps.sendDraft,
|
|
126
|
-
canSend: () => operationGeneration === generation && (deps.canSend?.() ?? true),
|
|
127
|
-
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
207
|
+
let timer;
|
|
208
|
+
let publicationIds = 0;
|
|
209
|
+
const clock = () => ({
|
|
210
|
+
now: Date.now(),
|
|
211
|
+
canSend: deps.canSend?.() !== false,
|
|
128
212
|
});
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
213
|
+
const machine = Machine.createTelegramMachineRunner({
|
|
214
|
+
initial: createTelegramPreviewMachineState(),
|
|
215
|
+
transition: (state, event) => transitionTelegramPreview(state, event, config),
|
|
216
|
+
run: (effect) => run(effect),
|
|
217
|
+
});
|
|
218
|
+
const { dispatch, waitFor } = machine;
|
|
219
|
+
const whenIdle = () => {
|
|
220
|
+
const generation = machine.state.generation;
|
|
221
|
+
return waitFor((current) => current.generation !== generation || !isTelegramPreviewBusy(current));
|
|
222
|
+
};
|
|
223
|
+
/** Captures predecessors only: a later draft/publication must never extend the boundary that blocks it. */
|
|
224
|
+
const whenBoundarySettles = (includePublications = true) => {
|
|
225
|
+
const { generation, inFlight, publications } = machine.state;
|
|
226
|
+
return waitFor((current) => current.generation !== generation ||
|
|
227
|
+
((inFlight === null || current.inFlight !== inFlight) &&
|
|
228
|
+
(!includePublications ||
|
|
229
|
+
publications.every((id) => !current.publications.includes(id)))));
|
|
230
|
+
};
|
|
231
|
+
const canSendFor = (generation) => machine.state.generation === generation && deps.canSend?.() !== false;
|
|
232
|
+
/** The one place preview transport failures become diagnostics; a failed call reports `false`. */
|
|
233
|
+
const callTransport = async (phase, request, draftId, text) => {
|
|
234
|
+
try {
|
|
235
|
+
return await deps.sendDraft(request.chatId, draftId, text, {
|
|
236
|
+
...getTelegramTargetThreadParams(request.target ?? { chatId: request.chatId }),
|
|
142
237
|
});
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
deps.recordRuntimeEvent?.("preview", error, {
|
|
241
|
+
phase,
|
|
242
|
+
chatId: request.chatId,
|
|
243
|
+
draftId,
|
|
244
|
+
});
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
const sendDraft = async (effect) => {
|
|
249
|
+
const delivered = await callTransport("draft", effect.request, effect.draftId, normalizeTelegramNativeMarkdown(effect.text));
|
|
250
|
+
dispatch({
|
|
251
|
+
type: "draftSettled",
|
|
252
|
+
segmentId: effect.segmentId,
|
|
253
|
+
generation: effect.generation,
|
|
254
|
+
...(delivered === false ? {} : { text: effect.text }),
|
|
255
|
+
...clock(),
|
|
256
|
+
});
|
|
257
|
+
};
|
|
258
|
+
const run = (effect) => {
|
|
259
|
+
switch (effect.type) {
|
|
260
|
+
case "sendDraft":
|
|
261
|
+
void sendDraft(effect);
|
|
262
|
+
return;
|
|
263
|
+
case "armTimer":
|
|
264
|
+
timer = setTimeout(() => {
|
|
265
|
+
timer = undefined;
|
|
266
|
+
dispatch({
|
|
267
|
+
type: "timerFired",
|
|
268
|
+
request: effect.request,
|
|
269
|
+
segmentId: effect.segmentId,
|
|
270
|
+
...clock(),
|
|
271
|
+
});
|
|
272
|
+
}, effect.delay);
|
|
273
|
+
timer.unref?.();
|
|
274
|
+
return;
|
|
275
|
+
case "cancelTimer":
|
|
276
|
+
if (timer)
|
|
277
|
+
clearTimeout(timer);
|
|
278
|
+
timer = undefined;
|
|
279
|
+
return;
|
|
280
|
+
case "clearClosedGate":
|
|
281
|
+
void clear(effect.request.chatId, {
|
|
282
|
+
awaitFlush: false,
|
|
283
|
+
target: effect.request.target,
|
|
284
|
+
});
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
const seal = () => {
|
|
289
|
+
dispatch({ type: "sealed" });
|
|
290
|
+
};
|
|
291
|
+
const clear = async (chatId, options = {}, generation = machine.state.generation) => {
|
|
292
|
+
const segment = machine.state.segment;
|
|
293
|
+
if (!segment || options.isDeliveryActive?.() === false)
|
|
294
|
+
return;
|
|
295
|
+
seal();
|
|
296
|
+
if (machine.state.inFlight !== null && options.awaitFlush !== false) {
|
|
297
|
+
await whenBoundarySettles(false);
|
|
298
|
+
if (machine.state.segment?.id !== segment.id)
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (options.isDeliveryActive?.() === false)
|
|
302
|
+
return;
|
|
303
|
+
const issued = machine.state.segment ?? segment;
|
|
304
|
+
dispatch({ type: "discarded" });
|
|
305
|
+
if (issued.draftId !== undefined && canSendFor(generation))
|
|
306
|
+
await callTransport("clear-draft", { chatId, target: options.target }, issued.draftId, undefined);
|
|
307
|
+
};
|
|
308
|
+
const flush = async (chatId, options = {}) => {
|
|
309
|
+
if (!machine.state.segment || machine.state.segment.sealed)
|
|
310
|
+
return;
|
|
311
|
+
dispatch({
|
|
312
|
+
type: "flushRequested",
|
|
313
|
+
request: { chatId, target: options.target },
|
|
314
|
+
...clock(),
|
|
315
|
+
});
|
|
316
|
+
await whenIdle();
|
|
317
|
+
};
|
|
318
|
+
const prepareFinalizer = (input) => {
|
|
319
|
+
const segmentId = machine.state.segment?.id;
|
|
320
|
+
const generation = machine.state.generation;
|
|
321
|
+
seal();
|
|
322
|
+
const predecessors = whenBoundarySettles();
|
|
323
|
+
return async (chatId, markdown, replyToMessageId, options) => {
|
|
324
|
+
if (input.isDeliveryActive?.() === false)
|
|
325
|
+
return false;
|
|
326
|
+
await predecessors;
|
|
327
|
+
if (machine.state.generation !== generation ||
|
|
328
|
+
machine.state.segment?.id !== segmentId ||
|
|
329
|
+
input.isDeliveryActive?.() === false)
|
|
330
|
+
return false;
|
|
331
|
+
await input.sendMarkdownReply(chatId, replyToMessageId, markdown, options);
|
|
332
|
+
if (machine.state.segment?.id === segmentId &&
|
|
333
|
+
input.isDeliveryActive?.() !== false)
|
|
334
|
+
dispatch({ type: "discarded" });
|
|
335
|
+
return true;
|
|
336
|
+
};
|
|
337
|
+
};
|
|
338
|
+
const controller = {
|
|
339
|
+
getState: () => machine.state.segment ?? undefined,
|
|
340
|
+
isFlushing: () => isTelegramPreviewBusy(machine.state),
|
|
341
|
+
seal,
|
|
342
|
+
setPendingText: (text) => dispatch({ type: "textSet", text }),
|
|
343
|
+
resetState: () => dispatch({ type: "reset" }),
|
|
344
|
+
invalidate: () => dispatch({ type: "invalidated" }),
|
|
149
345
|
preparePublication: () => {
|
|
150
|
-
if (!state)
|
|
346
|
+
if (!machine.state.segment)
|
|
151
347
|
return undefined;
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
});
|
|
348
|
+
seal();
|
|
349
|
+
const id = (publicationIds += 1);
|
|
350
|
+
const prior = whenBoundarySettles();
|
|
351
|
+
dispatch({ type: "publicationOpened", id });
|
|
352
|
+
let settled = false;
|
|
158
353
|
return {
|
|
159
|
-
wait:
|
|
160
|
-
|
|
354
|
+
wait: () => prior,
|
|
355
|
+
settle: () => {
|
|
356
|
+
if (settled)
|
|
357
|
+
return;
|
|
358
|
+
settled = true;
|
|
359
|
+
dispatch({ type: "publicationSettled", id, ...clock() });
|
|
161
360
|
},
|
|
162
|
-
settle,
|
|
163
361
|
};
|
|
164
362
|
},
|
|
165
363
|
prepareClear: (chatId, options) => {
|
|
166
|
-
const
|
|
167
|
-
const
|
|
364
|
+
const admitted = machine.state.segment?.id;
|
|
365
|
+
const generation = machine.state.generation;
|
|
168
366
|
return async () => {
|
|
169
|
-
if (state !==
|
|
367
|
+
if (machine.state.segment?.id !== admitted)
|
|
170
368
|
return;
|
|
171
|
-
await
|
|
369
|
+
await clear(chatId, {
|
|
172
370
|
...options,
|
|
173
|
-
isDeliveryActive: () =>
|
|
174
|
-
|
|
175
|
-
});
|
|
371
|
+
isDeliveryActive: () => canSendFor(generation) && options?.isDeliveryActive?.() !== false,
|
|
372
|
+
}, generation);
|
|
176
373
|
};
|
|
177
374
|
},
|
|
178
|
-
clear: (chatId, options) =>
|
|
179
|
-
flush
|
|
375
|
+
clear: (chatId, options) => clear(chatId, options),
|
|
376
|
+
flush,
|
|
180
377
|
scheduleFlush: (chatId, options) => {
|
|
181
|
-
if (!state)
|
|
378
|
+
if (!machine.state.segment)
|
|
182
379
|
return;
|
|
183
|
-
void
|
|
380
|
+
void flush(chatId, options);
|
|
381
|
+
},
|
|
382
|
+
finalize: async (chatId, _replyToMessageId, options) => {
|
|
383
|
+
const segment = machine.state.segment;
|
|
384
|
+
if (!segment)
|
|
385
|
+
return false;
|
|
386
|
+
if (!canSendFor(machine.state.generation)) {
|
|
387
|
+
await clear(chatId, options);
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
await flush(chatId, options);
|
|
391
|
+
const current = machine.state.segment;
|
|
392
|
+
if (current?.id !== segment.id)
|
|
393
|
+
return false;
|
|
394
|
+
const finalText = buildTelegramPreviewFinalText(current);
|
|
395
|
+
if (!finalText) {
|
|
396
|
+
await clear(chatId, options);
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
dispatch({ type: "discarded" });
|
|
400
|
+
return false;
|
|
184
401
|
},
|
|
185
|
-
finalize: (chatId, _replyToMessageId, options) => finalizeTelegramPreview(chatId, getRuntimeDeps(), options),
|
|
186
402
|
};
|
|
187
|
-
}
|
|
188
|
-
function createTelegramAssistantMessagePreviewHooks(deps) {
|
|
189
403
|
return {
|
|
190
|
-
|
|
191
|
-
|
|
404
|
+
controller,
|
|
405
|
+
prepareFinalizer,
|
|
406
|
+
startSegment: () => {
|
|
407
|
+
dispatch({ type: "segmentStarted" });
|
|
408
|
+
},
|
|
409
|
+
ensureSegment: () => {
|
|
410
|
+
dispatch({ type: "segmentEnsured" });
|
|
411
|
+
return machine.state.segment;
|
|
192
412
|
},
|
|
193
|
-
|
|
194
|
-
|
|
413
|
+
openDraftWindow: (at) => {
|
|
414
|
+
dispatch({ type: "draftWindowOpened", at });
|
|
415
|
+
},
|
|
416
|
+
discard: () => {
|
|
417
|
+
dispatch({ type: "discarded" });
|
|
195
418
|
},
|
|
196
419
|
};
|
|
197
420
|
}
|
|
421
|
+
export function createTelegramPreviewController(deps) {
|
|
422
|
+
return createTelegramPreviewModel(deps).controller;
|
|
423
|
+
}
|
|
198
424
|
/**
|
|
199
425
|
* Returns true when the active turn is a Telegram Guest Mode query. A guest
|
|
200
426
|
* query allows exactly one answer within a limited Telegram response window,
|
|
@@ -203,66 +429,77 @@ function createTelegramAssistantMessagePreviewHooks(deps) {
|
|
|
203
429
|
export function shouldSuppressPreviewForGuestTurn(turn) {
|
|
204
430
|
return !!turn?.guestQueryId;
|
|
205
431
|
}
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
432
|
+
export function createTelegramAssistantPreviewRuntime(deps) {
|
|
433
|
+
const model = createTelegramPreviewModel(deps);
|
|
434
|
+
const { controller } = model;
|
|
435
|
+
const suppressed = (turn) => shouldSuppressPreviewForVoice(turn) ||
|
|
436
|
+
shouldSuppressPreviewForGuestTurn(turn);
|
|
437
|
+
return {
|
|
438
|
+
...controller,
|
|
439
|
+
finalizeMarkdown: (...args) => model.prepareFinalizer({
|
|
440
|
+
sendMarkdownReply: deps.sendMarkdownReply,
|
|
441
|
+
})(...args),
|
|
442
|
+
prepareDelivery(isDeliveryActive) {
|
|
443
|
+
const admitted = controller.getState()?.id;
|
|
444
|
+
return {
|
|
445
|
+
setPreviewPendingText(text) {
|
|
446
|
+
if (controller.getState()?.id === admitted && isDeliveryActive())
|
|
447
|
+
controller.setPendingText(text);
|
|
448
|
+
},
|
|
449
|
+
clearPreview: async (chatId, options) => {
|
|
450
|
+
if (controller.getState()?.id !== admitted || !isDeliveryActive())
|
|
451
|
+
return;
|
|
452
|
+
await controller.prepareClear(chatId, {
|
|
453
|
+
...options,
|
|
454
|
+
isDeliveryActive,
|
|
455
|
+
})();
|
|
456
|
+
},
|
|
457
|
+
finalizeMarkdownPreview: model.prepareFinalizer({
|
|
458
|
+
sendMarkdownReply: deps.sendMarkdownReply,
|
|
459
|
+
isDeliveryActive,
|
|
460
|
+
}),
|
|
461
|
+
};
|
|
462
|
+
},
|
|
463
|
+
onMessageStart: async ({ message }) => {
|
|
464
|
+
const turn = deps.getActiveTurn();
|
|
465
|
+
if (!turn || !deps.isAssistantMessage(message))
|
|
466
|
+
return;
|
|
467
|
+
if ((deps.canSend && !deps.canSend()) || suppressed(turn)) {
|
|
468
|
+
model.discard();
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
model.startSegment();
|
|
472
|
+
},
|
|
473
|
+
onMessageUpdate: async ({ message }) => {
|
|
474
|
+
const turn = deps.getActiveTurn();
|
|
475
|
+
if (!turn || !deps.isAssistantMessage(message))
|
|
476
|
+
return;
|
|
477
|
+
if (deps.canSend && !deps.canSend()) {
|
|
478
|
+
model.discard();
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (suppressed(turn))
|
|
482
|
+
return;
|
|
483
|
+
const segment = model.ensureSegment();
|
|
484
|
+
if (segment.sealed)
|
|
485
|
+
return;
|
|
486
|
+
const hadVisibleText = segment.pendingText.length > 0;
|
|
487
|
+
const pendingText = stripTelegramCommentMarkupForPreview(deps.getMessageText(message));
|
|
488
|
+
controller.setPendingText(pendingText);
|
|
489
|
+
// The first visible text of a preview segment opens an initial accumulation
|
|
490
|
+
// window, so the segment's first frame cannot ship as a single word even
|
|
491
|
+
// when the previous cadence boundary has already passed (fresh turn, slow
|
|
492
|
+
// first token, or message rollover after tool work). Later deltas keep the
|
|
493
|
+
// trailing deadline instead of sliding it on every update.
|
|
494
|
+
const interval = deps.minDraftIntervalMs ?? TELEGRAM_DRAFT_INTERVAL_MS;
|
|
495
|
+
if (interval > 0 &&
|
|
496
|
+
!hadVisibleText &&
|
|
497
|
+
!segment.lastSentText &&
|
|
498
|
+
pendingText.length > 0)
|
|
499
|
+
model.openDraftWindow(Date.now() + interval);
|
|
500
|
+
controller.scheduleFlush(turn.chatId, { target: turn.target });
|
|
501
|
+
},
|
|
502
|
+
};
|
|
266
503
|
}
|
|
267
504
|
export function buildTelegramPreviewFinalText(state) {
|
|
268
505
|
const finalText = state.pendingText.trim();
|
|
@@ -270,51 +507,9 @@ export function buildTelegramPreviewFinalText(state) {
|
|
|
270
507
|
return finalText;
|
|
271
508
|
return state.lastSentText.trim() || undefined;
|
|
272
509
|
}
|
|
273
|
-
export function createTelegramPreviewRuntimeState() {
|
|
274
|
-
return {
|
|
275
|
-
mode: "draft",
|
|
276
|
-
pendingText: "",
|
|
277
|
-
lastSentText: "",
|
|
278
|
-
};
|
|
279
|
-
}
|
|
280
510
|
export function allocateTelegramDraftId(currentDraftId, maxDraftId) {
|
|
281
511
|
return currentDraftId >= maxDraftId ? 1 : currentDraftId + 1;
|
|
282
512
|
}
|
|
283
|
-
export function shouldUseTelegramDraftPreview(_options) {
|
|
284
|
-
return true;
|
|
285
|
-
}
|
|
286
|
-
export async function clearTelegramPreview(chatId, deps, options = {}) {
|
|
287
|
-
const state = deps.getState();
|
|
288
|
-
if (!state || options.isDeliveryActive?.() === false)
|
|
289
|
-
return;
|
|
290
|
-
sealTelegramPreviewState(state);
|
|
291
|
-
const inFlight = state.flushPromise ?? state.precedingFlush;
|
|
292
|
-
if (inFlight && options.awaitFlush !== false) {
|
|
293
|
-
state.flushRequested = false;
|
|
294
|
-
await inFlight.catch(() => { });
|
|
295
|
-
if (deps.getState() !== state)
|
|
296
|
-
return;
|
|
297
|
-
}
|
|
298
|
-
if (options.isDeliveryActive?.() === false)
|
|
299
|
-
return;
|
|
300
|
-
deps.setState(undefined);
|
|
301
|
-
if (state.mode === "draft" &&
|
|
302
|
-
state.draftId !== undefined &&
|
|
303
|
-
deps.canSend?.() !== false) {
|
|
304
|
-
try {
|
|
305
|
-
await deps.sendDraft(chatId, state.draftId, undefined, {
|
|
306
|
-
...getTelegramTargetThreadParams(options.target ?? { chatId }),
|
|
307
|
-
});
|
|
308
|
-
}
|
|
309
|
-
catch (error) {
|
|
310
|
-
deps.recordRuntimeEvent?.("preview", error, {
|
|
311
|
-
phase: "clear-draft",
|
|
312
|
-
chatId,
|
|
313
|
-
draftId: state.draftId,
|
|
314
|
-
});
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
513
|
function createTelegramDraftInlineState() {
|
|
319
514
|
return {
|
|
320
515
|
codeTicks: 0,
|
|
@@ -519,116 +714,3 @@ function buildTelegramNativeMarkdownPreviewSnapshot(state, maxMessageLength) {
|
|
|
519
714
|
return undefined;
|
|
520
715
|
return { text: safeText };
|
|
521
716
|
}
|
|
522
|
-
async function performTelegramPreviewFlush(chatId, state, deps, options = {}) {
|
|
523
|
-
if (deps.canSend && !deps.canSend()) {
|
|
524
|
-
await clearTelegramPreview(chatId, deps, {
|
|
525
|
-
awaitFlush: false,
|
|
526
|
-
target: options.target,
|
|
527
|
-
});
|
|
528
|
-
return;
|
|
529
|
-
}
|
|
530
|
-
const snapshot = buildTelegramNativeMarkdownPreviewSnapshot(state, deps.maxMessageLength);
|
|
531
|
-
if (!snapshot)
|
|
532
|
-
return;
|
|
533
|
-
if (shouldUseTelegramDraftPreview({
|
|
534
|
-
draftSupport: deps.getDraftSupport(),
|
|
535
|
-
snapshot,
|
|
536
|
-
})) {
|
|
537
|
-
const draftId = state.draftId ?? deps.allocateDraftId();
|
|
538
|
-
state.draftId = draftId;
|
|
539
|
-
state.nextDraftAt = Date.now() + (deps.minDraftIntervalMs ?? 0);
|
|
540
|
-
try {
|
|
541
|
-
const delivered = await deps.sendDraft(chatId, draftId, normalizeTelegramNativeMarkdown(snapshot.text), { ...getTelegramTargetThreadParams(options.target ?? { chatId }) });
|
|
542
|
-
if (delivered === false ||
|
|
543
|
-
deps.getState() !== state ||
|
|
544
|
-
deps.canSend?.() === false)
|
|
545
|
-
return;
|
|
546
|
-
deps.setDraftSupport("supported");
|
|
547
|
-
state.mode = "draft";
|
|
548
|
-
state.lastSentText = snapshot.text;
|
|
549
|
-
return;
|
|
550
|
-
}
|
|
551
|
-
catch (error) {
|
|
552
|
-
deps.recordRuntimeEvent?.("preview", error, {
|
|
553
|
-
phase: "draft",
|
|
554
|
-
chatId,
|
|
555
|
-
draftId,
|
|
556
|
-
});
|
|
557
|
-
return;
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
export async function flushTelegramPreview(chatId, deps, options = {}) {
|
|
562
|
-
const state = deps.getState();
|
|
563
|
-
if (!state || state.sealed)
|
|
564
|
-
return;
|
|
565
|
-
if (state.flushPromise) {
|
|
566
|
-
state.flushRequested = true;
|
|
567
|
-
await state.flushPromise;
|
|
568
|
-
return;
|
|
569
|
-
}
|
|
570
|
-
state.flushPromise = (async () => {
|
|
571
|
-
if (state.precedingFlush) {
|
|
572
|
-
await state.precedingFlush.catch(() => { });
|
|
573
|
-
state.precedingFlush = undefined;
|
|
574
|
-
if (deps.getState() !== state || state.sealed)
|
|
575
|
-
return;
|
|
576
|
-
}
|
|
577
|
-
do {
|
|
578
|
-
state.flushRequested = false;
|
|
579
|
-
const delay = (state.nextDraftAt ?? 0) - Date.now();
|
|
580
|
-
if (delay > 0) {
|
|
581
|
-
if (!state.flushTimer) {
|
|
582
|
-
state.flushTimer = setTimeout(() => {
|
|
583
|
-
state.flushTimer = undefined;
|
|
584
|
-
if (deps.getState() === state && !state.sealed)
|
|
585
|
-
void flushTelegramPreview(chatId, deps, options);
|
|
586
|
-
}, delay);
|
|
587
|
-
state.flushTimer.unref?.();
|
|
588
|
-
}
|
|
589
|
-
break;
|
|
590
|
-
}
|
|
591
|
-
if (state.flushTimer)
|
|
592
|
-
clearTimeout(state.flushTimer);
|
|
593
|
-
state.flushTimer = undefined;
|
|
594
|
-
try {
|
|
595
|
-
await performTelegramPreviewFlush(chatId, state, deps, options);
|
|
596
|
-
}
|
|
597
|
-
catch (error) {
|
|
598
|
-
deps.recordRuntimeEvent?.("preview", error, {
|
|
599
|
-
phase: "flush",
|
|
600
|
-
chatId,
|
|
601
|
-
draftId: state.draftId,
|
|
602
|
-
});
|
|
603
|
-
break;
|
|
604
|
-
}
|
|
605
|
-
} while (deps.getState() === state &&
|
|
606
|
-
!state.sealed &&
|
|
607
|
-
state.flushRequested);
|
|
608
|
-
})();
|
|
609
|
-
try {
|
|
610
|
-
await state.flushPromise;
|
|
611
|
-
}
|
|
612
|
-
finally {
|
|
613
|
-
if (deps.getState() === state) {
|
|
614
|
-
state.flushPromise = undefined;
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
export async function finalizeTelegramPreview(chatId, deps, options = {}) {
|
|
619
|
-
const state = deps.getState();
|
|
620
|
-
if (!state)
|
|
621
|
-
return false;
|
|
622
|
-
if (deps.canSend && !deps.canSend()) {
|
|
623
|
-
await clearTelegramPreview(chatId, deps, options);
|
|
624
|
-
return false;
|
|
625
|
-
}
|
|
626
|
-
await flushTelegramPreview(chatId, deps, options);
|
|
627
|
-
const finalText = buildTelegramPreviewFinalText(state);
|
|
628
|
-
if (!finalText) {
|
|
629
|
-
await clearTelegramPreview(chatId, deps, options);
|
|
630
|
-
return false;
|
|
631
|
-
}
|
|
632
|
-
deps.setState(undefined);
|
|
633
|
-
return false;
|
|
634
|
-
}
|