@llblab/pi-telegram 0.2.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 +90 -0
- package/BACKLOG.md +5 -0
- package/CHANGELOG.md +17 -0
- package/README.md +202 -0
- package/docs/README.md +9 -0
- package/docs/architecture.md +148 -0
- package/index.ts +1968 -0
- package/lib/api.ts +222 -0
- package/lib/attachments.ts +98 -0
- package/lib/media.ts +234 -0
- package/lib/menu.ts +951 -0
- package/lib/model-switch.ts +62 -0
- package/lib/polling.ts +122 -0
- package/lib/queue.ts +534 -0
- package/lib/registration.ts +163 -0
- package/lib/rendering.ts +697 -0
- package/lib/replies.ts +313 -0
- package/lib/setup.ts +41 -0
- package/lib/status.ts +109 -0
- package/lib/turns.ts +144 -0
- package/lib/updates.ts +397 -0
- package/package.json +40 -0
- package/screenshot.png +0 -0
- package/tests/api.test.ts +89 -0
- package/tests/attachments.test.ts +132 -0
- package/tests/config.test.ts +80 -0
- package/tests/media.test.ts +77 -0
- package/tests/menu.test.ts +645 -0
- package/tests/polling.test.ts +129 -0
- package/tests/queue.test.ts +2982 -0
- package/tests/registration.test.ts +268 -0
- package/tests/rendering.test.ts +308 -0
- package/tests/replies.test.ts +362 -0
- package/tests/turns.test.ts +132 -0
- package/tests/updates.test.ts +366 -0
package/lib/replies.ts
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram reply and preview domain helpers
|
|
3
|
+
* Owns preview text decisions, preview runtime behavior, rendered-message delivery, and plain or markdown reply sending
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { TelegramRenderedChunk, TelegramRenderMode } from "./rendering.ts";
|
|
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
|
+
export interface TelegramSentMessageLike {
|
|
186
|
+
message_id: number;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface TelegramReplyDeliveryDeps<TReplyMarkup> {
|
|
190
|
+
sendMessage: (body: {
|
|
191
|
+
chat_id: number;
|
|
192
|
+
text: string;
|
|
193
|
+
parse_mode?: "HTML";
|
|
194
|
+
reply_markup?: TReplyMarkup;
|
|
195
|
+
}) => Promise<TelegramSentMessageLike>;
|
|
196
|
+
editMessage: (body: {
|
|
197
|
+
chat_id: number;
|
|
198
|
+
message_id: number;
|
|
199
|
+
text: string;
|
|
200
|
+
parse_mode?: "HTML";
|
|
201
|
+
reply_markup?: TReplyMarkup;
|
|
202
|
+
}) => Promise<void>;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export interface TelegramReplyTransport<TReplyMarkup> {
|
|
206
|
+
sendRenderedChunks: (
|
|
207
|
+
chatId: number,
|
|
208
|
+
chunks: TelegramRenderedChunk[],
|
|
209
|
+
options?: { replyMarkup?: TReplyMarkup },
|
|
210
|
+
) => Promise<number | undefined>;
|
|
211
|
+
editRenderedMessage: (
|
|
212
|
+
chatId: number,
|
|
213
|
+
messageId: number,
|
|
214
|
+
chunks: TelegramRenderedChunk[],
|
|
215
|
+
options?: { replyMarkup?: TReplyMarkup },
|
|
216
|
+
) => Promise<number | undefined>;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function buildTelegramReplyTransport<TReplyMarkup>(
|
|
220
|
+
deps: TelegramReplyDeliveryDeps<TReplyMarkup>,
|
|
221
|
+
): TelegramReplyTransport<TReplyMarkup> {
|
|
222
|
+
return {
|
|
223
|
+
sendRenderedChunks: async (chatId, chunks, options) => {
|
|
224
|
+
return sendTelegramRenderedChunks(chatId, chunks, deps, options);
|
|
225
|
+
},
|
|
226
|
+
editRenderedMessage: async (chatId, messageId, chunks, options) => {
|
|
227
|
+
return editTelegramRenderedMessage(
|
|
228
|
+
chatId,
|
|
229
|
+
messageId,
|
|
230
|
+
chunks,
|
|
231
|
+
deps,
|
|
232
|
+
options,
|
|
233
|
+
);
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function sendTelegramRenderedChunks<TReplyMarkup>(
|
|
239
|
+
chatId: number,
|
|
240
|
+
chunks: TelegramRenderedChunk[],
|
|
241
|
+
deps: TelegramReplyDeliveryDeps<TReplyMarkup>,
|
|
242
|
+
options?: { replyMarkup?: TReplyMarkup },
|
|
243
|
+
): Promise<number | undefined> {
|
|
244
|
+
let lastMessageId: number | undefined;
|
|
245
|
+
for (const [index, chunk] of chunks.entries()) {
|
|
246
|
+
const sent = await deps.sendMessage({
|
|
247
|
+
chat_id: chatId,
|
|
248
|
+
text: chunk.text,
|
|
249
|
+
parse_mode: chunk.parseMode,
|
|
250
|
+
reply_markup:
|
|
251
|
+
index === chunks.length - 1 ? options?.replyMarkup : undefined,
|
|
252
|
+
});
|
|
253
|
+
lastMessageId = sent.message_id;
|
|
254
|
+
}
|
|
255
|
+
return lastMessageId;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export async function editTelegramRenderedMessage<TReplyMarkup>(
|
|
259
|
+
chatId: number,
|
|
260
|
+
messageId: number,
|
|
261
|
+
chunks: TelegramRenderedChunk[],
|
|
262
|
+
deps: TelegramReplyDeliveryDeps<TReplyMarkup>,
|
|
263
|
+
options?: { replyMarkup?: TReplyMarkup },
|
|
264
|
+
): Promise<number | undefined> {
|
|
265
|
+
if (chunks.length === 0) return messageId;
|
|
266
|
+
const [firstChunk, ...remainingChunks] = chunks;
|
|
267
|
+
await deps.editMessage({
|
|
268
|
+
chat_id: chatId,
|
|
269
|
+
message_id: messageId,
|
|
270
|
+
text: firstChunk.text,
|
|
271
|
+
parse_mode: firstChunk.parseMode,
|
|
272
|
+
reply_markup:
|
|
273
|
+
remainingChunks.length === 0 ? options?.replyMarkup : undefined,
|
|
274
|
+
});
|
|
275
|
+
if (remainingChunks.length > 0) {
|
|
276
|
+
return sendTelegramRenderedChunks(chatId, remainingChunks, deps, options);
|
|
277
|
+
}
|
|
278
|
+
return messageId;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// --- Reply Runtime ---
|
|
282
|
+
|
|
283
|
+
export interface TelegramReplyRuntimeDeps {
|
|
284
|
+
renderTelegramMessage: (
|
|
285
|
+
text: string,
|
|
286
|
+
options?: { mode?: TelegramRenderMode },
|
|
287
|
+
) => TelegramRenderedChunk[];
|
|
288
|
+
sendRenderedChunks: (
|
|
289
|
+
chunks: TelegramRenderedChunk[],
|
|
290
|
+
) => Promise<number | undefined>;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export async function sendTelegramPlainReply(
|
|
294
|
+
text: string,
|
|
295
|
+
deps: TelegramReplyRuntimeDeps,
|
|
296
|
+
options?: { parseMode?: "HTML" },
|
|
297
|
+
): Promise<number | undefined> {
|
|
298
|
+
const chunks = deps.renderTelegramMessage(text, {
|
|
299
|
+
mode: options?.parseMode === "HTML" ? "html" : "plain",
|
|
300
|
+
});
|
|
301
|
+
return deps.sendRenderedChunks(chunks);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export async function sendTelegramMarkdownReply(
|
|
305
|
+
markdown: string,
|
|
306
|
+
deps: TelegramReplyRuntimeDeps,
|
|
307
|
+
): Promise<number | undefined> {
|
|
308
|
+
const chunks = deps.renderTelegramMessage(markdown, { mode: "markdown" });
|
|
309
|
+
if (chunks.length === 0) {
|
|
310
|
+
return sendTelegramPlainReply(markdown, deps);
|
|
311
|
+
}
|
|
312
|
+
return deps.sendRenderedChunks(chunks);
|
|
313
|
+
}
|
package/lib/setup.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram setup prompt helpers
|
|
3
|
+
* Computes token-prefill defaults and prompt mode selection for /telegram-setup
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface TelegramBotTokenPromptSpec {
|
|
7
|
+
method: "input" | "editor";
|
|
8
|
+
value: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const TELEGRAM_BOT_TOKEN_INPUT_PLACEHOLDER = "123456:ABCDEF...";
|
|
12
|
+
export const TELEGRAM_BOT_TOKEN_ENV_VARS = [
|
|
13
|
+
"TELEGRAM_BOT_TOKEN",
|
|
14
|
+
"TELEGRAM_BOT_KEY",
|
|
15
|
+
"TELEGRAM_TOKEN",
|
|
16
|
+
"TELEGRAM_KEY",
|
|
17
|
+
] as const;
|
|
18
|
+
|
|
19
|
+
export function getTelegramBotTokenInputDefault(
|
|
20
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
21
|
+
configToken?: string,
|
|
22
|
+
): string {
|
|
23
|
+
const trimmedConfigToken = configToken?.trim();
|
|
24
|
+
if (trimmedConfigToken) return trimmedConfigToken;
|
|
25
|
+
for (const key of TELEGRAM_BOT_TOKEN_ENV_VARS) {
|
|
26
|
+
const value = env[key]?.trim();
|
|
27
|
+
if (value) return value;
|
|
28
|
+
}
|
|
29
|
+
return TELEGRAM_BOT_TOKEN_INPUT_PLACEHOLDER;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getTelegramBotTokenPromptSpec(
|
|
33
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
34
|
+
configToken?: string,
|
|
35
|
+
): TelegramBotTokenPromptSpec {
|
|
36
|
+
const value = getTelegramBotTokenInputDefault(env, configToken);
|
|
37
|
+
return {
|
|
38
|
+
method: value === TELEGRAM_BOT_TOKEN_INPUT_PLACEHOLDER ? "input" : "editor",
|
|
39
|
+
value,
|
|
40
|
+
};
|
|
41
|
+
}
|
package/lib/status.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram status rendering helpers
|
|
3
|
+
* Builds usage, cost, and context summaries for the interactive Telegram status view
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Model } from "@mariozechner/pi-ai";
|
|
7
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
8
|
+
|
|
9
|
+
export interface TelegramUsageStats {
|
|
10
|
+
totalInput: number;
|
|
11
|
+
totalOutput: number;
|
|
12
|
+
totalCacheRead: number;
|
|
13
|
+
totalCacheWrite: number;
|
|
14
|
+
totalCost: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function escapeHtml(text: string): string {
|
|
18
|
+
return text
|
|
19
|
+
.replace(/&/g, "&")
|
|
20
|
+
.replace(/</g, "<")
|
|
21
|
+
.replace(/>/g, ">");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function formatTokens(count: number): string {
|
|
25
|
+
if (count < 1000) return count.toString();
|
|
26
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
27
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
28
|
+
if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
|
|
29
|
+
return `${Math.round(count / 1000000)}M`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function collectUsageStats(ctx: ExtensionContext): TelegramUsageStats {
|
|
33
|
+
const stats: TelegramUsageStats = {
|
|
34
|
+
totalInput: 0,
|
|
35
|
+
totalOutput: 0,
|
|
36
|
+
totalCacheRead: 0,
|
|
37
|
+
totalCacheWrite: 0,
|
|
38
|
+
totalCost: 0,
|
|
39
|
+
};
|
|
40
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
41
|
+
if (entry.type !== "message" || entry.message.role !== "assistant") {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
stats.totalInput += entry.message.usage.input;
|
|
45
|
+
stats.totalOutput += entry.message.usage.output;
|
|
46
|
+
stats.totalCacheRead += entry.message.usage.cacheRead;
|
|
47
|
+
stats.totalCacheWrite += entry.message.usage.cacheWrite;
|
|
48
|
+
stats.totalCost += entry.message.usage.cost.total;
|
|
49
|
+
}
|
|
50
|
+
return stats;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function buildStatusRow(label: string, value: string): string {
|
|
54
|
+
return `<b>${escapeHtml(label)}:</b> <code>${escapeHtml(value)}</code>`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function buildUsageSummary(stats: TelegramUsageStats): string | undefined {
|
|
58
|
+
const tokenParts: string[] = [];
|
|
59
|
+
if (stats.totalInput) tokenParts.push(`↑${formatTokens(stats.totalInput)}`);
|
|
60
|
+
if (stats.totalOutput) tokenParts.push(`↓${formatTokens(stats.totalOutput)}`);
|
|
61
|
+
if (stats.totalCacheRead)
|
|
62
|
+
tokenParts.push(`R${formatTokens(stats.totalCacheRead)}`);
|
|
63
|
+
if (stats.totalCacheWrite)
|
|
64
|
+
tokenParts.push(`W${formatTokens(stats.totalCacheWrite)}`);
|
|
65
|
+
return tokenParts.length > 0 ? tokenParts.join(" ") : undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildCostSummary(
|
|
69
|
+
stats: TelegramUsageStats,
|
|
70
|
+
usesSubscription: boolean,
|
|
71
|
+
): string | undefined {
|
|
72
|
+
if (!stats.totalCost && !usesSubscription) return undefined;
|
|
73
|
+
return `$${stats.totalCost.toFixed(3)}${usesSubscription ? " (sub)" : ""}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function buildContextSummary(
|
|
77
|
+
ctx: ExtensionContext,
|
|
78
|
+
activeModel: Model<any> | undefined,
|
|
79
|
+
): string {
|
|
80
|
+
const usage = ctx.getContextUsage();
|
|
81
|
+
if (!usage) return "unknown";
|
|
82
|
+
const contextWindow = usage.contextWindow ?? activeModel?.contextWindow ?? 0;
|
|
83
|
+
const percent = usage.percent !== null ? `${usage.percent.toFixed(1)}%` : "?";
|
|
84
|
+
return `${percent}/${formatTokens(contextWindow)}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function buildStatusHtml(
|
|
88
|
+
ctx: ExtensionContext,
|
|
89
|
+
activeModel: Model<any> | undefined,
|
|
90
|
+
): string {
|
|
91
|
+
const stats = collectUsageStats(ctx);
|
|
92
|
+
const usesSubscription = activeModel
|
|
93
|
+
? ctx.modelRegistry.isUsingOAuth(activeModel)
|
|
94
|
+
: false;
|
|
95
|
+
const lines: string[] = [];
|
|
96
|
+
const usageSummary = buildUsageSummary(stats);
|
|
97
|
+
const costSummary = buildCostSummary(stats, usesSubscription);
|
|
98
|
+
if (usageSummary) {
|
|
99
|
+
lines.push(buildStatusRow("Usage", usageSummary));
|
|
100
|
+
}
|
|
101
|
+
if (costSummary) {
|
|
102
|
+
lines.push(buildStatusRow("Cost", costSummary));
|
|
103
|
+
}
|
|
104
|
+
lines.push(buildStatusRow("Context", buildContextSummary(ctx, activeModel)));
|
|
105
|
+
if (lines.length === 0) {
|
|
106
|
+
lines.push(buildStatusRow("Status", "No usage data yet."));
|
|
107
|
+
}
|
|
108
|
+
return lines.join("\n");
|
|
109
|
+
}
|
package/lib/turns.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram turn-building helpers
|
|
3
|
+
* Owns prompt-turn summary and content construction so queued Telegram turns are assembled consistently
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { basename } from "node:path";
|
|
7
|
+
|
|
8
|
+
import type { ImageContent, TextContent } from "@mariozechner/pi-ai";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
collectTelegramMessageIds,
|
|
12
|
+
formatTelegramHistoryText,
|
|
13
|
+
} from "./media.ts";
|
|
14
|
+
import type { PendingTelegramTurn } from "./queue.ts";
|
|
15
|
+
|
|
16
|
+
export interface TelegramTurnMessageLike {
|
|
17
|
+
message_id: number;
|
|
18
|
+
chat: { id: number };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DownloadedTelegramTurnFileLike {
|
|
22
|
+
path: string;
|
|
23
|
+
fileName: string;
|
|
24
|
+
isImage: boolean;
|
|
25
|
+
mimeType?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function truncateTelegramQueueSummary(
|
|
29
|
+
text: string,
|
|
30
|
+
maxWords = 5,
|
|
31
|
+
maxLength = 40,
|
|
32
|
+
): string {
|
|
33
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
34
|
+
if (!normalized) return "";
|
|
35
|
+
const words = normalized.split(" ");
|
|
36
|
+
let summary = words.slice(0, maxWords).join(" ");
|
|
37
|
+
if (summary.length === 0) summary = normalized;
|
|
38
|
+
if (summary.length > maxLength) {
|
|
39
|
+
summary = summary.slice(0, maxLength).trimEnd();
|
|
40
|
+
}
|
|
41
|
+
return summary.length < normalized.length || words.length > maxWords
|
|
42
|
+
? `${summary}…`
|
|
43
|
+
: summary;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function formatTelegramTurnStatusSummary(
|
|
47
|
+
rawText: string,
|
|
48
|
+
files: DownloadedTelegramTurnFileLike[],
|
|
49
|
+
): string {
|
|
50
|
+
const textSummary = truncateTelegramQueueSummary(rawText);
|
|
51
|
+
if (textSummary) return textSummary;
|
|
52
|
+
if (files.length === 1) {
|
|
53
|
+
const fileName = basename(
|
|
54
|
+
files[0]?.fileName || files[0]?.path || "attachment",
|
|
55
|
+
);
|
|
56
|
+
return `📎 ${truncateTelegramQueueSummary(fileName, 4, 32) || "attachment"}`;
|
|
57
|
+
}
|
|
58
|
+
if (files.length > 1) return `📎 ${files.length} attachments`;
|
|
59
|
+
return "(empty message)";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function buildTelegramTurnPrompt(options: {
|
|
63
|
+
telegramPrefix: string;
|
|
64
|
+
rawText: string;
|
|
65
|
+
files: DownloadedTelegramTurnFileLike[];
|
|
66
|
+
historyTurns?: Pick<PendingTelegramTurn, "historyText">[];
|
|
67
|
+
}): string {
|
|
68
|
+
let prompt = options.telegramPrefix;
|
|
69
|
+
if ((options.historyTurns?.length ?? 0) > 0) {
|
|
70
|
+
prompt +=
|
|
71
|
+
"\n\nEarlier Telegram messages arrived after an aborted turn. Treat them as prior user messages, in order:";
|
|
72
|
+
for (const [index, turn] of (options.historyTurns ?? []).entries()) {
|
|
73
|
+
prompt += `\n\n${index + 1}. ${turn.historyText}`;
|
|
74
|
+
}
|
|
75
|
+
prompt += "\n\nCurrent Telegram message:";
|
|
76
|
+
}
|
|
77
|
+
if (options.rawText.length > 0) {
|
|
78
|
+
prompt +=
|
|
79
|
+
(options.historyTurns?.length ?? 0) > 0
|
|
80
|
+
? `\n${options.rawText}`
|
|
81
|
+
: ` ${options.rawText}`;
|
|
82
|
+
}
|
|
83
|
+
if (options.files.length > 0) {
|
|
84
|
+
prompt += "\n\nTelegram attachments were saved locally:";
|
|
85
|
+
for (const file of options.files) {
|
|
86
|
+
prompt += `\n- ${file.path}`;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return prompt;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function buildTelegramPromptTurn(options: {
|
|
93
|
+
telegramPrefix: string;
|
|
94
|
+
messages: TelegramTurnMessageLike[];
|
|
95
|
+
historyTurns?: PendingTelegramTurn[];
|
|
96
|
+
queueOrder: number;
|
|
97
|
+
rawText: string;
|
|
98
|
+
files: DownloadedTelegramTurnFileLike[];
|
|
99
|
+
readBinaryFile: (path: string) => Promise<Uint8Array>;
|
|
100
|
+
inferImageMimeType: (path: string) => string | undefined;
|
|
101
|
+
}): Promise<PendingTelegramTurn> {
|
|
102
|
+
const firstMessage = options.messages[0];
|
|
103
|
+
if (!firstMessage) {
|
|
104
|
+
throw new Error("Missing Telegram message for turn creation");
|
|
105
|
+
}
|
|
106
|
+
const content: Array<TextContent | ImageContent> = [
|
|
107
|
+
{
|
|
108
|
+
type: "text",
|
|
109
|
+
text: buildTelegramTurnPrompt({
|
|
110
|
+
telegramPrefix: options.telegramPrefix,
|
|
111
|
+
rawText: options.rawText,
|
|
112
|
+
files: options.files,
|
|
113
|
+
historyTurns: options.historyTurns,
|
|
114
|
+
}),
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
for (const file of options.files) {
|
|
118
|
+
if (!file.isImage) continue;
|
|
119
|
+
const mediaType = file.mimeType || options.inferImageMimeType(file.path);
|
|
120
|
+
if (!mediaType) continue;
|
|
121
|
+
const buffer = await options.readBinaryFile(file.path);
|
|
122
|
+
content.push({
|
|
123
|
+
type: "image",
|
|
124
|
+
data: Buffer.from(buffer).toString("base64"),
|
|
125
|
+
mimeType: mediaType,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
kind: "prompt",
|
|
130
|
+
chatId: firstMessage.chat.id,
|
|
131
|
+
replyToMessageId: firstMessage.message_id,
|
|
132
|
+
sourceMessageIds: collectTelegramMessageIds(options.messages),
|
|
133
|
+
queueOrder: options.queueOrder,
|
|
134
|
+
queueLane: "default",
|
|
135
|
+
laneOrder: options.queueOrder,
|
|
136
|
+
queuedAttachments: [],
|
|
137
|
+
content,
|
|
138
|
+
historyText: formatTelegramHistoryText(options.rawText, options.files),
|
|
139
|
+
statusSummary: formatTelegramTurnStatusSummary(
|
|
140
|
+
options.rawText,
|
|
141
|
+
options.files,
|
|
142
|
+
),
|
|
143
|
+
};
|
|
144
|
+
}
|