@llblab/pi-telegram 0.2.8 → 0.2.9
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 +2 -1
- package/CHANGELOG.md +4 -0
- package/README.md +6 -6
- package/docs/architecture.md +11 -5
- package/index.ts +13 -10
- package/lib/preview.ts +212 -0
- package/lib/rendering.ts +583 -46
- package/lib/replies.ts +2 -181
- package/package.json +1 -1
- package/tests/menu.test.ts +46 -15
- package/tests/preview.test.ts +441 -0
- package/tests/queue.test.ts +3 -0
- package/tests/rendering.test.ts +124 -2
- package/tests/replies.test.ts +2 -222
- package/tests/updates.test.ts +10 -4
package/lib/replies.ts
CHANGED
|
@@ -1,187 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Telegram reply
|
|
3
|
-
* Owns
|
|
2
|
+
* Telegram reply delivery helpers
|
|
3
|
+
* Owns rendered-message delivery, reply transport wiring, and plain or markdown final replies
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { TelegramRenderedChunk, TelegramRenderMode } from "./rendering.ts";
|
|
7
7
|
|
|
8
|
-
// --- Preview ---
|
|
9
|
-
|
|
10
|
-
export interface TelegramPreviewStateLike {
|
|
11
|
-
mode: "draft" | "message";
|
|
12
|
-
draftId?: number;
|
|
13
|
-
messageId?: number;
|
|
14
|
-
pendingText: string;
|
|
15
|
-
lastSentText: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export interface TelegramPreviewRuntimeState extends TelegramPreviewStateLike {
|
|
19
|
-
flushTimer?: ReturnType<typeof setTimeout>;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface TelegramPreviewRuntimeDeps {
|
|
23
|
-
getState: () => TelegramPreviewRuntimeState | undefined;
|
|
24
|
-
setState: (state: TelegramPreviewRuntimeState | undefined) => void;
|
|
25
|
-
clearScheduledFlush: (state: TelegramPreviewRuntimeState) => void;
|
|
26
|
-
maxMessageLength: number;
|
|
27
|
-
renderPreviewText: (markdown: string) => string;
|
|
28
|
-
getDraftSupport: () => "unknown" | "supported" | "unsupported";
|
|
29
|
-
setDraftSupport: (support: "unknown" | "supported" | "unsupported") => void;
|
|
30
|
-
allocateDraftId: () => number;
|
|
31
|
-
sendDraft: (chatId: number, draftId: number, text: string) => Promise<void>;
|
|
32
|
-
sendMessage: (
|
|
33
|
-
chatId: number,
|
|
34
|
-
text: string,
|
|
35
|
-
) => Promise<TelegramSentMessageLike>;
|
|
36
|
-
editMessageText: (
|
|
37
|
-
chatId: number,
|
|
38
|
-
messageId: number,
|
|
39
|
-
text: string,
|
|
40
|
-
) => Promise<void>;
|
|
41
|
-
renderTelegramMessage: (
|
|
42
|
-
text: string,
|
|
43
|
-
options?: { mode?: TelegramRenderMode },
|
|
44
|
-
) => TelegramRenderedChunk[];
|
|
45
|
-
sendRenderedChunks: (
|
|
46
|
-
chatId: number,
|
|
47
|
-
chunks: TelegramRenderedChunk[],
|
|
48
|
-
) => Promise<number | undefined>;
|
|
49
|
-
editRenderedMessage: (
|
|
50
|
-
chatId: number,
|
|
51
|
-
messageId: number,
|
|
52
|
-
chunks: TelegramRenderedChunk[],
|
|
53
|
-
) => Promise<number | undefined>;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function buildTelegramPreviewFlushText(options: {
|
|
57
|
-
state: TelegramPreviewStateLike;
|
|
58
|
-
maxMessageLength: number;
|
|
59
|
-
renderPreviewText: (markdown: string) => string;
|
|
60
|
-
}): string | undefined {
|
|
61
|
-
const rawText = options.state.pendingText.trim();
|
|
62
|
-
const previewText = options.renderPreviewText(rawText).trim();
|
|
63
|
-
if (!previewText || previewText === options.state.lastSentText) {
|
|
64
|
-
return undefined;
|
|
65
|
-
}
|
|
66
|
-
return previewText.length > options.maxMessageLength
|
|
67
|
-
? previewText.slice(0, options.maxMessageLength)
|
|
68
|
-
: previewText;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export function buildTelegramPreviewFinalText(
|
|
72
|
-
state: TelegramPreviewStateLike,
|
|
73
|
-
): string | undefined {
|
|
74
|
-
const finalText = (state.pendingText.trim() || state.lastSentText).trim();
|
|
75
|
-
return finalText || undefined;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function shouldUseTelegramDraftPreview(options: {
|
|
79
|
-
draftSupport: "unknown" | "supported" | "unsupported";
|
|
80
|
-
}): boolean {
|
|
81
|
-
return options.draftSupport !== "unsupported";
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export async function clearTelegramPreview(
|
|
85
|
-
chatId: number,
|
|
86
|
-
deps: TelegramPreviewRuntimeDeps,
|
|
87
|
-
): Promise<void> {
|
|
88
|
-
const state = deps.getState();
|
|
89
|
-
if (!state) return;
|
|
90
|
-
deps.clearScheduledFlush(state);
|
|
91
|
-
deps.setState(undefined);
|
|
92
|
-
if (state.mode !== "draft" || state.draftId === undefined) return;
|
|
93
|
-
try {
|
|
94
|
-
await deps.sendDraft(chatId, state.draftId, "");
|
|
95
|
-
} catch {
|
|
96
|
-
// ignore
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export async function flushTelegramPreview(
|
|
101
|
-
chatId: number,
|
|
102
|
-
deps: TelegramPreviewRuntimeDeps,
|
|
103
|
-
): Promise<void> {
|
|
104
|
-
const state = deps.getState();
|
|
105
|
-
if (!state) return;
|
|
106
|
-
state.flushTimer = undefined;
|
|
107
|
-
const truncated = buildTelegramPreviewFlushText({
|
|
108
|
-
state,
|
|
109
|
-
maxMessageLength: deps.maxMessageLength,
|
|
110
|
-
renderPreviewText: deps.renderPreviewText,
|
|
111
|
-
});
|
|
112
|
-
if (!truncated) return;
|
|
113
|
-
if (shouldUseTelegramDraftPreview({ draftSupport: deps.getDraftSupport() })) {
|
|
114
|
-
const draftId = state.draftId ?? deps.allocateDraftId();
|
|
115
|
-
state.draftId = draftId;
|
|
116
|
-
try {
|
|
117
|
-
await deps.sendDraft(chatId, draftId, truncated);
|
|
118
|
-
deps.setDraftSupport("supported");
|
|
119
|
-
state.mode = "draft";
|
|
120
|
-
state.lastSentText = truncated;
|
|
121
|
-
return;
|
|
122
|
-
} catch {
|
|
123
|
-
deps.setDraftSupport("unsupported");
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
if (state.messageId === undefined) {
|
|
127
|
-
const sent = await deps.sendMessage(chatId, truncated);
|
|
128
|
-
state.messageId = sent.message_id;
|
|
129
|
-
state.mode = "message";
|
|
130
|
-
state.lastSentText = truncated;
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
await deps.editMessageText(chatId, state.messageId, truncated);
|
|
134
|
-
state.mode = "message";
|
|
135
|
-
state.lastSentText = truncated;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
export async function finalizeTelegramPreview(
|
|
139
|
-
chatId: number,
|
|
140
|
-
deps: TelegramPreviewRuntimeDeps,
|
|
141
|
-
): Promise<boolean> {
|
|
142
|
-
const state = deps.getState();
|
|
143
|
-
if (!state) return false;
|
|
144
|
-
await flushTelegramPreview(chatId, deps);
|
|
145
|
-
const finalText = buildTelegramPreviewFinalText(state);
|
|
146
|
-
if (!finalText) {
|
|
147
|
-
await clearTelegramPreview(chatId, deps);
|
|
148
|
-
return false;
|
|
149
|
-
}
|
|
150
|
-
if (state.mode === "draft") {
|
|
151
|
-
await deps.sendMessage(chatId, finalText);
|
|
152
|
-
await clearTelegramPreview(chatId, deps);
|
|
153
|
-
return true;
|
|
154
|
-
}
|
|
155
|
-
deps.setState(undefined);
|
|
156
|
-
return state.messageId !== undefined;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
export async function finalizeTelegramMarkdownPreview(
|
|
160
|
-
chatId: number,
|
|
161
|
-
markdown: string,
|
|
162
|
-
deps: TelegramPreviewRuntimeDeps,
|
|
163
|
-
): Promise<boolean> {
|
|
164
|
-
const state = deps.getState();
|
|
165
|
-
if (!state) return false;
|
|
166
|
-
await flushTelegramPreview(chatId, deps);
|
|
167
|
-
const chunks = deps.renderTelegramMessage(markdown, { mode: "markdown" });
|
|
168
|
-
if (chunks.length === 0) {
|
|
169
|
-
await clearTelegramPreview(chatId, deps);
|
|
170
|
-
return false;
|
|
171
|
-
}
|
|
172
|
-
if (state.mode === "draft") {
|
|
173
|
-
await deps.sendRenderedChunks(chatId, chunks);
|
|
174
|
-
await clearTelegramPreview(chatId, deps);
|
|
175
|
-
return true;
|
|
176
|
-
}
|
|
177
|
-
if (state.messageId === undefined) return false;
|
|
178
|
-
await deps.editRenderedMessage(chatId, state.messageId, chunks);
|
|
179
|
-
deps.setState(undefined);
|
|
180
|
-
return true;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// --- Delivery ---
|
|
184
|
-
|
|
185
8
|
export interface TelegramSentMessageLike {
|
|
186
9
|
message_id: number;
|
|
187
10
|
}
|
|
@@ -278,8 +101,6 @@ export async function editTelegramRenderedMessage<TReplyMarkup>(
|
|
|
278
101
|
return messageId;
|
|
279
102
|
}
|
|
280
103
|
|
|
281
|
-
// --- Reply Runtime ---
|
|
282
|
-
|
|
283
104
|
export interface TelegramReplyRuntimeDeps {
|
|
284
105
|
renderTelegramMessage: (
|
|
285
106
|
text: string,
|
package/package.json
CHANGED
package/tests/menu.test.ts
CHANGED
|
@@ -144,7 +144,9 @@ test("Menu helpers apply menu mutations and resolve model selections", () => {
|
|
|
144
144
|
assert.equal(state.page, 2);
|
|
145
145
|
assert.equal(applyTelegramModelPageSelection(state, "2"), "unchanged");
|
|
146
146
|
assert.equal(applyTelegramModelPageSelection(state, "bad"), "invalid");
|
|
147
|
-
assert.deepEqual(getTelegramModelSelection(state, "bad"), {
|
|
147
|
+
assert.deepEqual(getTelegramModelSelection(state, "bad"), {
|
|
148
|
+
kind: "invalid",
|
|
149
|
+
});
|
|
148
150
|
assert.deepEqual(getTelegramModelSelection(state, "9"), { kind: "missing" });
|
|
149
151
|
assert.equal(getTelegramModelSelection(state, "0").kind, "selected");
|
|
150
152
|
});
|
|
@@ -174,7 +176,11 @@ test("Menu helpers derive normalized menu pages without mutating state", () => {
|
|
|
174
176
|
|
|
175
177
|
test("Menu helpers build model callback plans for paging, selection, and restart modes", () => {
|
|
176
178
|
const modelA = { provider: "openai", id: "gpt-5", reasoning: true } as const;
|
|
177
|
-
const modelB = {
|
|
179
|
+
const modelB = {
|
|
180
|
+
provider: "anthropic",
|
|
181
|
+
id: "claude-3",
|
|
182
|
+
reasoning: false,
|
|
183
|
+
} as const;
|
|
178
184
|
const state = {
|
|
179
185
|
chatId: 1,
|
|
180
186
|
messageId: 2,
|
|
@@ -227,7 +233,8 @@ test("Menu helpers build model callback plans for paging, selection, and restart
|
|
|
227
233
|
kind: "switch-model",
|
|
228
234
|
selection: state.allModels[1],
|
|
229
235
|
mode: "restart-after-tool",
|
|
230
|
-
callbackText:
|
|
236
|
+
callbackText:
|
|
237
|
+
"Switched to claude-3. Restarting after the current tool finishes…",
|
|
231
238
|
},
|
|
232
239
|
);
|
|
233
240
|
assert.deepEqual(
|
|
@@ -254,14 +261,19 @@ test("Menu helpers route callback entry states before action handlers", async ()
|
|
|
254
261
|
events.push(`answer:${text ?? ""}`);
|
|
255
262
|
},
|
|
256
263
|
});
|
|
257
|
-
await handleTelegramMenuCallbackEntry(
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
264
|
+
await handleTelegramMenuCallbackEntry(
|
|
265
|
+
"callback-2",
|
|
266
|
+
"status:model",
|
|
267
|
+
undefined,
|
|
268
|
+
{
|
|
269
|
+
handleStatusAction: async () => false,
|
|
270
|
+
handleThinkingAction: async () => false,
|
|
271
|
+
handleModelAction: async () => false,
|
|
272
|
+
answerCallbackQuery: async (_id, text) => {
|
|
273
|
+
events.push(`answer:${text ?? ""}`);
|
|
274
|
+
},
|
|
263
275
|
},
|
|
264
|
-
|
|
276
|
+
);
|
|
265
277
|
await handleTelegramMenuCallbackEntry(
|
|
266
278
|
"callback-3",
|
|
267
279
|
"status:model",
|
|
@@ -296,7 +308,11 @@ test("Menu helpers route callback entry states before action handlers", async ()
|
|
|
296
308
|
test("Menu helpers execute model callback actions across update, switch, and restart paths", async () => {
|
|
297
309
|
const events: string[] = [];
|
|
298
310
|
const modelA = { provider: "openai", id: "gpt-5", reasoning: true } as const;
|
|
299
|
-
const modelB = {
|
|
311
|
+
const modelB = {
|
|
312
|
+
provider: "anthropic",
|
|
313
|
+
id: "claude-3",
|
|
314
|
+
reasoning: false,
|
|
315
|
+
} as const;
|
|
300
316
|
const state = {
|
|
301
317
|
chatId: 1,
|
|
302
318
|
messageId: 2,
|
|
@@ -530,8 +546,14 @@ test("Menu helpers build pure render payloads before transport", () => {
|
|
|
530
546
|
allModels: [{ model: modelA }],
|
|
531
547
|
mode: "status" as const,
|
|
532
548
|
} as unknown as TelegramModelMenuState;
|
|
533
|
-
const modelPayload = buildTelegramModelMenuRenderPayload(
|
|
534
|
-
|
|
549
|
+
const modelPayload = buildTelegramModelMenuRenderPayload(
|
|
550
|
+
state,
|
|
551
|
+
modelA as never,
|
|
552
|
+
);
|
|
553
|
+
const thinkingPayload = buildTelegramThinkingMenuRenderPayload(
|
|
554
|
+
modelA as never,
|
|
555
|
+
"medium",
|
|
556
|
+
);
|
|
535
557
|
const statusPayload = buildTelegramStatusMenuRenderPayload(
|
|
536
558
|
"<b>Status</b>",
|
|
537
559
|
modelA as never,
|
|
@@ -580,7 +602,12 @@ test("Menu helpers update and send interactive menu messages", async () => {
|
|
|
580
602
|
},
|
|
581
603
|
};
|
|
582
604
|
await updateTelegramModelMenuMessage(state, modelA as never, deps);
|
|
583
|
-
await updateTelegramThinkingMenuMessage(
|
|
605
|
+
await updateTelegramThinkingMenuMessage(
|
|
606
|
+
state,
|
|
607
|
+
modelA as never,
|
|
608
|
+
"medium",
|
|
609
|
+
deps,
|
|
610
|
+
);
|
|
584
611
|
await updateTelegramStatusMessage(
|
|
585
612
|
state,
|
|
586
613
|
"<b>Status</b>",
|
|
@@ -595,7 +622,11 @@ test("Menu helpers update and send interactive menu messages", async () => {
|
|
|
595
622
|
"medium",
|
|
596
623
|
deps,
|
|
597
624
|
);
|
|
598
|
-
const sentModelId = await sendTelegramModelMenuMessage(
|
|
625
|
+
const sentModelId = await sendTelegramModelMenuMessage(
|
|
626
|
+
state,
|
|
627
|
+
modelA as never,
|
|
628
|
+
deps,
|
|
629
|
+
);
|
|
599
630
|
assert.equal(sentStatusId, 99);
|
|
600
631
|
assert.equal(sentModelId, 99);
|
|
601
632
|
assert.equal(events[0], "edit:1:2:html:<b>Choose a model:</b>");
|