pi-telegram-plus 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +318 -0
- package/index.ts +327 -0
- package/lib/attachments.ts +154 -0
- package/lib/callback-protocol.ts +9 -0
- package/lib/command-parser.ts +15 -0
- package/lib/commands/auth.ts +365 -0
- package/lib/commands/info.ts +158 -0
- package/lib/commands/lifecycle.ts +65 -0
- package/lib/commands/model.ts +188 -0
- package/lib/commands/register.ts +40 -0
- package/lib/commands/session.ts +281 -0
- package/lib/commands/settings.ts +129 -0
- package/lib/commands/telegram-commands.ts +162 -0
- package/lib/commands/tg-config.ts +128 -0
- package/lib/config.ts +171 -0
- package/lib/controller.ts +406 -0
- package/lib/heartbeat.ts +95 -0
- package/lib/html.ts +6 -0
- package/lib/markdown.ts +132 -0
- package/lib/menu-commands.ts +72 -0
- package/lib/polling.ts +255 -0
- package/lib/renderer.ts +284 -0
- package/lib/session-capture.ts +95 -0
- package/lib/status.ts +46 -0
- package/lib/telegram-api.ts +327 -0
- package/lib/telegram-ui.ts +208 -0
- package/lib/text-split.ts +59 -0
- package/lib/types.ts +123 -0
- package/package.json +53 -0
- package/pi-host.d.ts +8 -0
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import type { ExtensionCommandContext, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { decodeUiCallback } from "./callback-protocol.ts";
|
|
3
|
+
import { parseLeadingCommand, normalizeLeadingCommand } from "./command-parser.ts";
|
|
4
|
+
import type {
|
|
5
|
+
CapturedAgentSession,
|
|
6
|
+
TelegramCallbackQuery,
|
|
7
|
+
TelegramDocument,
|
|
8
|
+
TelegramMessage,
|
|
9
|
+
TelegramMessageMode,
|
|
10
|
+
TelegramPhotoSize,
|
|
11
|
+
TelegramTransport,
|
|
12
|
+
TelegramTurn,
|
|
13
|
+
} from "./types.ts";
|
|
14
|
+
import type { TelegramUiRuntime } from "./telegram-ui.ts";
|
|
15
|
+
|
|
16
|
+
export type TelegramController = {
|
|
17
|
+
handleMessage(message: TelegramMessage): Promise<void>;
|
|
18
|
+
handleCallbackQuery(query: TelegramCallbackQuery): Promise<void>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function getIncomingDocumentName(document?: TelegramDocument): string {
|
|
22
|
+
if (!document) return "file";
|
|
23
|
+
return document.file_name || `${document.file_id}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type IncomingMediaSummaryEntry = {
|
|
27
|
+
type: string;
|
|
28
|
+
fileId: string;
|
|
29
|
+
fileName?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function extractTelegramMediaEntries(message: TelegramMessage): IncomingMediaSummaryEntry[] {
|
|
33
|
+
const entries: IncomingMediaSummaryEntry[] = [];
|
|
34
|
+
if (message.photo && message.photo.length > 0) {
|
|
35
|
+
const bestPhoto = [...message.photo].reduce<TelegramPhotoSize>((best, current) => {
|
|
36
|
+
const bestSize = best.file_size ?? -1;
|
|
37
|
+
const currentSize = current.file_size ?? -1;
|
|
38
|
+
return currentSize > bestSize ? current : best;
|
|
39
|
+
}, message.photo[0]);
|
|
40
|
+
entries.push({ type: "photo", fileId: bestPhoto.file_id, fileName: undefined });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const documentAttachments: Array<{ type: string; document?: TelegramDocument }> = [];
|
|
44
|
+
if (message.document) documentAttachments.push({ type: "document", document: message.document });
|
|
45
|
+
if (message.video) documentAttachments.push({ type: "video", document: message.video });
|
|
46
|
+
if (message.audio) documentAttachments.push({ type: "audio", document: message.audio });
|
|
47
|
+
if (message.voice) documentAttachments.push({ type: "voice", document: message.voice });
|
|
48
|
+
if (message.animation) documentAttachments.push({ type: "animation", document: message.animation });
|
|
49
|
+
if (message.sticker) documentAttachments.push({ type: "sticker", document: message.sticker });
|
|
50
|
+
for (const attachment of documentAttachments) {
|
|
51
|
+
if (!attachment.document) continue;
|
|
52
|
+
entries.push({
|
|
53
|
+
type: attachment.type,
|
|
54
|
+
fileId: attachment.document.file_id,
|
|
55
|
+
fileName: attachment.document.file_name,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return entries;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function buildTelegramIncomingMediaSummary(message: TelegramMessage): string {
|
|
63
|
+
const parts: string[] = [];
|
|
64
|
+
if (message.photo && message.photo.length > 0) {
|
|
65
|
+
parts.push(`- photo (${message.photo.length} photo frame(s))`);
|
|
66
|
+
}
|
|
67
|
+
const entries: Array<{ type: string; document?: TelegramDocument }> = [];
|
|
68
|
+
if (message.document) entries.push({ type: "document", document: message.document });
|
|
69
|
+
if (message.video) entries.push({ type: "video", document: message.video });
|
|
70
|
+
if (message.audio) entries.push({ type: "audio", document: message.audio });
|
|
71
|
+
if (message.voice) entries.push({ type: "voice", document: message.voice });
|
|
72
|
+
if (message.animation) entries.push({ type: "animation", document: message.animation });
|
|
73
|
+
if (message.sticker) entries.push({ type: "sticker", document: message.sticker });
|
|
74
|
+
for (const attachment of entries) {
|
|
75
|
+
if (!attachment.document) continue;
|
|
76
|
+
parts.push(`- ${attachment.type}: ${getIncomingDocumentName(attachment.document)}`);
|
|
77
|
+
}
|
|
78
|
+
return parts.length ? `[telegram attachment]\n${parts.join("\n")}` : "";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function formatSavedIncomingAttachmentLine(kind: string, fileId: string, fileName: string | undefined, path: string): string {
|
|
82
|
+
const savedName = fileName ? `${fileName} (${fileId})` : fileId;
|
|
83
|
+
return `- ${kind}: ${savedName} => ${path}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function formatIncomingAttachmentErrorLine(kind: string, fileId: string, fileName: string | undefined, reason: string): string {
|
|
87
|
+
const target = fileName ? `${fileName} (${fileId})` : fileId;
|
|
88
|
+
return `- ${kind}: ${target} -> failed to save (${reason})`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function formatIncomingAttachmentReceipt(state: "saved" | "failed", lines: string[]): string {
|
|
92
|
+
if (lines.length === 0) return "";
|
|
93
|
+
const title = state === "saved" ? "✅ Saved attachments (local paths):" : "⚠️ Attachments not saved:";
|
|
94
|
+
return `${title}\n${lines.join("\n")}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
export type TelegramCommandHandler = (args: string, ctx: ExtensionCommandContext) => Promise<void>;
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async function runWithTelegramUi<T>(deps: {
|
|
102
|
+
session: CapturedAgentSession;
|
|
103
|
+
ui: ExtensionUIContext;
|
|
104
|
+
run: () => Promise<T>;
|
|
105
|
+
}): Promise<T> {
|
|
106
|
+
const runner = deps.session.extensionRunner;
|
|
107
|
+
const previousUi = runner.getUIContext();
|
|
108
|
+
runner.setUIContext(deps.ui);
|
|
109
|
+
try {
|
|
110
|
+
return await deps.run();
|
|
111
|
+
} finally {
|
|
112
|
+
runner.setUIContext(previousUi);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function createTelegramController(deps: {
|
|
117
|
+
getSession: () => CapturedAgentSession | undefined;
|
|
118
|
+
transport: TelegramTransport;
|
|
119
|
+
ui: TelegramUiRuntime;
|
|
120
|
+
authorizeUser(userId: number | undefined): Promise<boolean>;
|
|
121
|
+
setActiveChatId(chatId: number): Promise<void>;
|
|
122
|
+
getBotUsername(): string | undefined;
|
|
123
|
+
getMessageMode: () => TelegramMessageMode;
|
|
124
|
+
telegramCommands: Map<string, TelegramCommandHandler>;
|
|
125
|
+
getActiveTurn(chatId: number): TelegramTurn | undefined;
|
|
126
|
+
beginTelegramTurn(chatId: number, replaceMessageId?: number): TelegramTurn | undefined;
|
|
127
|
+
endTelegramTurn(chatId: number, turn: TelegramTurn): void;
|
|
128
|
+
saveIncomingTelegramAttachment?: (fileId: string, fileName: string | undefined, kind: string) => Promise<string>;
|
|
129
|
+
}): TelegramController {
|
|
130
|
+
// Per-chat prompt queues: each chat chains its prompts sequentially.
|
|
131
|
+
const promptTails = new Map<number, Promise<void>>();
|
|
132
|
+
// Increasing generation per chat lets stop invalidate pending queue items.
|
|
133
|
+
const interruptGenerationByChat = new Map<number, number>();
|
|
134
|
+
|
|
135
|
+
const getOrCreateTail = (chatId: number): Promise<void> => promptTails.get(chatId) ?? Promise.resolve();
|
|
136
|
+
const setTail = (chatId: number, tail: Promise<void>) => promptTails.set(chatId, tail);
|
|
137
|
+
const getInterruptGeneration = (chatId: number): number => interruptGenerationByChat.get(chatId) ?? 0;
|
|
138
|
+
const bumpInterruptGeneration = (chatId: number): void => {
|
|
139
|
+
const next = getInterruptGeneration(chatId) + 1;
|
|
140
|
+
interruptGenerationByChat.set(chatId, next);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const fastInterrupt = async (chatId: number) => {
|
|
144
|
+
const session = deps.getSession();
|
|
145
|
+
if (!session) {
|
|
146
|
+
await deps.transport.sendText(chatId, "π session is not ready yet.");
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
bumpInterruptGeneration(chatId);
|
|
151
|
+
await deps.transport.sendText(chatId, "⏹️ Interrupt requested.");
|
|
152
|
+
const abortResult = (session as any).abort?.();
|
|
153
|
+
void abortResult?.catch?.(() => undefined);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const runPrompt = async (text: string, chatId: number, replaceMessageId?: number) => {
|
|
157
|
+
const session = deps.getSession();
|
|
158
|
+
if (!session) {
|
|
159
|
+
if (replaceMessageId !== undefined) await deps.transport.editText(chatId, replaceMessageId, "π session is not ready yet.");
|
|
160
|
+
else await deps.transport.sendText(chatId, "π session is not ready yet.");
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const mode = deps.getMessageMode();
|
|
165
|
+
const isSteer = mode === "steer" && session.isStreaming;
|
|
166
|
+
|
|
167
|
+
// In steer mode, reuse the existing active turn for this chat.
|
|
168
|
+
// Steer messages inject into a running stream — they must not acquire a
|
|
169
|
+
// new turn (which would fail since the chat already has one).
|
|
170
|
+
if (isSteer) {
|
|
171
|
+
const existingTurn = deps.getActiveTurn(chatId);
|
|
172
|
+
if (!existingTurn) {
|
|
173
|
+
// No active turn to steer into — fall back to a new prompt.
|
|
174
|
+
const turn = deps.beginTelegramTurn(chatId);
|
|
175
|
+
if (!turn) {
|
|
176
|
+
await deps.transport.sendText(chatId, "⏳ π is busy. Try again shortly.");
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const telegramUi = deps.ui.create(chatId);
|
|
180
|
+
try {
|
|
181
|
+
await runWithTelegramUi({
|
|
182
|
+
session,
|
|
183
|
+
ui: telegramUi,
|
|
184
|
+
run: () => session.prompt(text, { source: "interactive" }),
|
|
185
|
+
});
|
|
186
|
+
} finally {
|
|
187
|
+
deps.endTelegramTurn(chatId, turn);
|
|
188
|
+
}
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const telegramUi = deps.ui.create(chatId);
|
|
192
|
+
await runWithTelegramUi({
|
|
193
|
+
session,
|
|
194
|
+
ui: telegramUi,
|
|
195
|
+
run: () => session.prompt(text, { source: "interactive", streamingBehavior: "steer" as const }),
|
|
196
|
+
});
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const turn = deps.beginTelegramTurn(chatId, replaceMessageId);
|
|
201
|
+
if (!turn) {
|
|
202
|
+
// Chat already has an active turn — reject with a busy message.
|
|
203
|
+
if (replaceMessageId !== undefined) await deps.transport.editText(chatId, replaceMessageId, "⏳ π is busy. Try again shortly.");
|
|
204
|
+
else await deps.transport.sendText(chatId, "⏳ π is busy. Try again shortly.");
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const telegramUi = deps.ui.create(chatId);
|
|
209
|
+
try {
|
|
210
|
+
await runWithTelegramUi({
|
|
211
|
+
session,
|
|
212
|
+
ui: telegramUi,
|
|
213
|
+
run: () => session.prompt(text, { source: "interactive" }),
|
|
214
|
+
});
|
|
215
|
+
} finally {
|
|
216
|
+
deps.endTelegramTurn(chatId, turn);
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const submitText = async (text: string, chatId: number, replaceMessageId?: number) => {
|
|
221
|
+
const mode = deps.getMessageMode();
|
|
222
|
+
if (mode === "steer") {
|
|
223
|
+
const task = runPrompt(text, chatId, replaceMessageId);
|
|
224
|
+
void task.catch(() => undefined);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const generation = getInterruptGeneration(chatId);
|
|
229
|
+
|
|
230
|
+
// In queue mode, chain behind the previous prompt for this chat.
|
|
231
|
+
const task = getOrCreateTail(chatId)
|
|
232
|
+
.then(() => {
|
|
233
|
+
if (generation !== getInterruptGeneration(chatId)) return;
|
|
234
|
+
return runPrompt(text, chatId, replaceMessageId);
|
|
235
|
+
})
|
|
236
|
+
.catch(() => undefined);
|
|
237
|
+
setTail(chatId, task);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const runCommandHandler = (handler: TelegramCommandHandler, args: string, session: CapturedAgentSession, chatId: number) => {
|
|
241
|
+
// Commands run immediately — they do not acquire a turn and are never
|
|
242
|
+
// blocked by an active agent prompt. Fast config / status commands
|
|
243
|
+
// briefly borrow the UI context; the rendering pipeline still finds the
|
|
244
|
+
// existing prompt turn via getActiveTurn() and stream output normally.
|
|
245
|
+
//
|
|
246
|
+
// Do not await command handlers here: some commands open interactive
|
|
247
|
+
// prompts (e.g. /new confirmation), and awaiting them would stall the
|
|
248
|
+
// polling loop and block callback processing for that update.
|
|
249
|
+
const telegramUi = deps.ui.create(chatId);
|
|
250
|
+
void runWithTelegramUi({
|
|
251
|
+
session,
|
|
252
|
+
ui: telegramUi,
|
|
253
|
+
run: async () => handler(args, session.extensionRunner.createCommandContext()),
|
|
254
|
+
}).catch(() => undefined);
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const tryHandleSlashCommand = async (text: string, chatId: number): Promise<boolean> => {
|
|
258
|
+
const parsed = parseLeadingCommand(text);
|
|
259
|
+
if (!parsed) return false;
|
|
260
|
+
const session = deps.getSession();
|
|
261
|
+
if (!session) {
|
|
262
|
+
await deps.transport.sendText(chatId, "π session is not ready yet.");
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const name = parsed.name.toLowerCase();
|
|
267
|
+
const handler =
|
|
268
|
+
deps.telegramCommands.get(name)
|
|
269
|
+
?? deps.telegramCommands.get(name.replace(/_/g, "-"))
|
|
270
|
+
?? deps.telegramCommands.get(name.replace(/-/g, "_"));
|
|
271
|
+
if (handler) {
|
|
272
|
+
await runCommandHandler(handler, parsed.args, session, chatId);
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
const externalCommand =
|
|
276
|
+
session.extensionRunner.getCommand(name)
|
|
277
|
+
?? session.extensionRunner.getCommand(name.replace(/_/g, "-"))
|
|
278
|
+
?? session.extensionRunner.getCommand(name.replace(/-/g, "_"));
|
|
279
|
+
if (externalCommand) {
|
|
280
|
+
await runCommandHandler(externalCommand.handler, parsed.args, session, chatId);
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
return false;
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
async handleCallbackQuery(query) {
|
|
288
|
+
const chatId = query.message?.chat?.id;
|
|
289
|
+
if (typeof chatId !== "number") return;
|
|
290
|
+
await deps.transport.answerCallbackQuery(query.id).catch(() => undefined);
|
|
291
|
+
if (!(await deps.authorizeUser(query.from?.id))) return;
|
|
292
|
+
await deps.setActiveChatId(chatId);
|
|
293
|
+
|
|
294
|
+
const data = query.data ?? "";
|
|
295
|
+
const uiValue = decodeUiCallback(data);
|
|
296
|
+
if (uiValue !== undefined) {
|
|
297
|
+
const result = deps.ui.resolveInput(chatId, uiValue, query.message?.message_id, true);
|
|
298
|
+
if (!result.handled) {
|
|
299
|
+
await deps.transport.sendText(chatId, "This prompt is no longer active.");
|
|
300
|
+
} else {
|
|
301
|
+
// Remove inline keyboards only for terminal UI callbacks. Select
|
|
302
|
+
// pagination callbacks continue the same flow and immediately edit the
|
|
303
|
+
// same message with the next page of buttons; removing the keyboard here
|
|
304
|
+
// can race after that edit and strip the new page buttons.
|
|
305
|
+
const isPaginationCallback = /^f:[^:]+:p:\d+$/.test(uiValue);
|
|
306
|
+
const promptMessageId = result.promptMessageId ?? query.message?.message_id;
|
|
307
|
+
if (!isPaginationCallback && promptMessageId) void deps.transport.removeInlineKeyboard(chatId, promptMessageId);
|
|
308
|
+
}
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (data) {
|
|
313
|
+
const messageId = query.message?.message_id;
|
|
314
|
+
await submitText(data, chatId, messageId);
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
|
|
318
|
+
async handleMessage(message) {
|
|
319
|
+
const chatId = message.chat?.id;
|
|
320
|
+
if (typeof chatId !== "number") return;
|
|
321
|
+
if (!(await deps.authorizeUser(message.from?.id))) return;
|
|
322
|
+
await deps.setActiveChatId(chatId);
|
|
323
|
+
|
|
324
|
+
const rawText = message.text ?? message.caption ?? "";
|
|
325
|
+
const text = normalizeLeadingCommand(rawText, deps.getBotUsername());
|
|
326
|
+
const trimmed = text.trim();
|
|
327
|
+
const replyToInput = message.reply_to_message?.message_id;
|
|
328
|
+
if (trimmed === "/stop") {
|
|
329
|
+
// /stop cancellation priority: first try canceling a reply-targeted
|
|
330
|
+
// pending input, then any pending input, then abort the agent turn.
|
|
331
|
+
const cancelResult = deps.ui.resolveInput(chatId, undefined, replyToInput);
|
|
332
|
+
const cancelAnyResult = !cancelResult.handled ? deps.ui.resolveInput(chatId, undefined) : cancelResult;
|
|
333
|
+
if (cancelAnyResult.handled) {
|
|
334
|
+
if (cancelAnyResult.promptMessageId) void deps.transport.removeInlineKeyboard(chatId, cancelAnyResult.promptMessageId);
|
|
335
|
+
await deps.transport.sendText(chatId, "Cancelled.");
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
await fastInterrupt(chatId);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const mediaSummary = buildTelegramIncomingMediaSummary(message);
|
|
343
|
+
const hasMediaInput = mediaSummary.length > 0;
|
|
344
|
+
const hasTextInput = message.text !== undefined || message.caption !== undefined;
|
|
345
|
+
const incomingMedia = hasMediaInput ? extractTelegramMediaEntries(message) : [];
|
|
346
|
+
const downloadedMediaLines: string[] = [];
|
|
347
|
+
const failedMediaLines: string[] = [];
|
|
348
|
+
if (deps.saveIncomingTelegramAttachment && incomingMedia.length > 0) {
|
|
349
|
+
const mediaSaveResults = await Promise.allSettled(
|
|
350
|
+
incomingMedia.map((attachment) => deps.saveIncomingTelegramAttachment!(
|
|
351
|
+
attachment.fileId,
|
|
352
|
+
attachment.fileName,
|
|
353
|
+
attachment.type,
|
|
354
|
+
)),
|
|
355
|
+
);
|
|
356
|
+
for (let i = 0; i < incomingMedia.length; i++) {
|
|
357
|
+
const media = incomingMedia[i];
|
|
358
|
+
const saveResult = mediaSaveResults[i];
|
|
359
|
+
if (saveResult.status === "fulfilled") {
|
|
360
|
+
downloadedMediaLines.push(formatSavedIncomingAttachmentLine(media.type, media.fileId, media.fileName, saveResult.value));
|
|
361
|
+
} else {
|
|
362
|
+
const reason = saveResult.reason instanceof Error ? saveResult.reason.message : String(saveResult.reason);
|
|
363
|
+
failedMediaLines.push(formatIncomingAttachmentErrorLine(media.type, media.fileId, media.fileName, reason));
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
if (downloadedMediaLines.length > 0 || failedMediaLines.length > 0) {
|
|
367
|
+
const lines = [
|
|
368
|
+
formatIncomingAttachmentReceipt("saved", downloadedMediaLines),
|
|
369
|
+
formatIncomingAttachmentReceipt("failed", failedMediaLines),
|
|
370
|
+
].filter(Boolean);
|
|
371
|
+
await deps.transport.sendText(chatId, lines.join("\n\n")).catch(() => undefined);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const hasPromptInput = hasTextInput || hasMediaInput;
|
|
375
|
+
if (hasTextInput) {
|
|
376
|
+
const wasSensitive = deps.ui.isSensitiveInput(chatId, replyToInput);
|
|
377
|
+
const inputResult = deps.ui.resolveInput(chatId, rawText, replyToInput);
|
|
378
|
+
if (inputResult.handled) {
|
|
379
|
+
if (wasSensitive) await deps.transport.deleteMessage(chatId, message.message_id);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (hasPromptInput) {
|
|
384
|
+
const mediaBlock = (() => {
|
|
385
|
+
if (!hasMediaInput) return "";
|
|
386
|
+
const lines = [mediaSummary];
|
|
387
|
+
const statusLines = [...downloadedMediaLines, ...failedMediaLines];
|
|
388
|
+
if (statusLines.length > 0) {
|
|
389
|
+
lines.push(statusLines.join("\n"));
|
|
390
|
+
}
|
|
391
|
+
return `\n\n${lines.filter(Boolean).join("\n")}`;
|
|
392
|
+
})();
|
|
393
|
+
const handled = trimmed ? await tryHandleSlashCommand(text, chatId) : false;
|
|
394
|
+
if (!handled) {
|
|
395
|
+
const parsed = parseLeadingCommand(text);
|
|
396
|
+
const basePrompt = parsed
|
|
397
|
+
? `/${parsed.name.replace(/_/g, "-")}${parsed.args ? ` ${parsed.args}` : ""}`
|
|
398
|
+
: trimmed || "";
|
|
399
|
+
const promptText = `${basePrompt}${mediaBlock}`.trim();
|
|
400
|
+
await submitText(promptText, chatId);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
},
|
|
404
|
+
|
|
405
|
+
};
|
|
406
|
+
}
|
package/lib/heartbeat.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { formatTelegramStatusLine } from "./status.ts";
|
|
2
|
+
import type { TelegramConfig, TelegramTurn } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
const TYPING_REFRESH_MS = 4000;
|
|
5
|
+
const HEARTBEAT_MS = 2000;
|
|
6
|
+
|
|
7
|
+
export type StatusState = Parameters<typeof formatTelegramStatusLine>[1];
|
|
8
|
+
|
|
9
|
+
export type HeartbeatDeps = {
|
|
10
|
+
getConfig: () => TelegramConfig;
|
|
11
|
+
getActiveTurn: () => TelegramTurn | undefined;
|
|
12
|
+
sendChatAction: (chatId: number, action: string) => Promise<void>;
|
|
13
|
+
ensurePollingStarted: () => void;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function createHeartbeat(deps: HeartbeatDeps) {
|
|
17
|
+
let statusTimer: ReturnType<typeof setInterval> | undefined;
|
|
18
|
+
let typingTimer: ReturnType<typeof setInterval> | undefined;
|
|
19
|
+
let typingInFlight = false;
|
|
20
|
+
let typingGeneration = 0;
|
|
21
|
+
|
|
22
|
+
const sendTypingPulse = async (generation: number): Promise<void> => {
|
|
23
|
+
const turn = deps.getActiveTurn();
|
|
24
|
+
if (!turn || typingInFlight || generation !== typingGeneration) return;
|
|
25
|
+
typingInFlight = true;
|
|
26
|
+
try {
|
|
27
|
+
if (generation === typingGeneration && deps.getActiveTurn() === turn) {
|
|
28
|
+
await deps.sendChatAction(turn.chatId, "typing");
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
// Non-critical: the next pulse can retry while the turn is still processing.
|
|
32
|
+
} finally {
|
|
33
|
+
typingInFlight = false;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const stopTyping = (): void => {
|
|
38
|
+
typingGeneration++;
|
|
39
|
+
if (!typingTimer) return;
|
|
40
|
+
clearInterval(typingTimer);
|
|
41
|
+
typingTimer = undefined;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const syncTypingWithStatus = (state: StatusState): void => {
|
|
45
|
+
const turn = deps.getActiveTurn();
|
|
46
|
+
// Typing strictly mirrors activeTurns.size > 0 — the same condition
|
|
47
|
+
// that drives the TUI [Working...] status.
|
|
48
|
+
const shouldType = !!(state.processing && turn);
|
|
49
|
+
if (!shouldType) {
|
|
50
|
+
stopTyping();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const generation = typingGeneration;
|
|
54
|
+
void sendTypingPulse(generation);
|
|
55
|
+
if (!typingTimer) {
|
|
56
|
+
typingTimer = setInterval(() => void sendTypingPulse(generation), TYPING_REFRESH_MS);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const refreshStatus = (state: StatusState): void => {
|
|
61
|
+
// Called by the outer module which sets the TUI status line.
|
|
62
|
+
// We only sync typing from here.
|
|
63
|
+
syncTypingWithStatus(state);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const startStatusHeartbeat = (onHeartbeat: () => void): void => {
|
|
67
|
+
if (statusTimer) return;
|
|
68
|
+
statusTimer = setInterval(() => {
|
|
69
|
+
deps.ensurePollingStarted();
|
|
70
|
+
onHeartbeat();
|
|
71
|
+
}, HEARTBEAT_MS);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const stopStatusHeartbeat = (): void => {
|
|
75
|
+
if (!statusTimer) return;
|
|
76
|
+
clearInterval(statusTimer);
|
|
77
|
+
statusTimer = undefined;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const dispose = (): void => {
|
|
81
|
+
stopStatusHeartbeat();
|
|
82
|
+
stopTyping();
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
refreshStatus,
|
|
87
|
+
syncTypingWithStatus,
|
|
88
|
+
startStatusHeartbeat,
|
|
89
|
+
stopStatusHeartbeat,
|
|
90
|
+
stopTyping,
|
|
91
|
+
dispose,
|
|
92
|
+
/** Expose for external clearStatus which stops typing then clears the TUI line. */
|
|
93
|
+
stopTypingOnly: stopTyping,
|
|
94
|
+
};
|
|
95
|
+
}
|
package/lib/html.ts
ADDED
package/lib/markdown.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { marked } from "marked";
|
|
2
|
+
import type { Tokens } from "marked";
|
|
3
|
+
import { escapeHtml } from "./html.ts";
|
|
4
|
+
|
|
5
|
+
interface TelegramRendererContext {
|
|
6
|
+
parser: {
|
|
7
|
+
parse(tokens: unknown[]): string;
|
|
8
|
+
parseInline(tokens: unknown[]): string;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function inlineFromTokens(this: TelegramRendererContext, tokens?: unknown[], fallback = ""): string {
|
|
13
|
+
if (Array.isArray(tokens) && tokens.length > 0) {
|
|
14
|
+
return this.parser.parseInline(tokens as unknown[]);
|
|
15
|
+
}
|
|
16
|
+
return escapeHtml(fallback);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function blockFromTokens(this: TelegramRendererContext, tokens?: unknown[], fallback = ""): string {
|
|
20
|
+
if (Array.isArray(tokens) && tokens.length > 0) {
|
|
21
|
+
return this.parser.parse(tokens as unknown[]);
|
|
22
|
+
}
|
|
23
|
+
return escapeHtml(fallback);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const renderer = {
|
|
27
|
+
heading(this: TelegramRendererContext, { tokens }: Tokens.Heading): string {
|
|
28
|
+
return `<b>${inlineFromTokens.call(this, tokens)}</b>\n`;
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
paragraph(this: TelegramRendererContext, { tokens }: Tokens.Paragraph): string {
|
|
32
|
+
return `${inlineFromTokens.call(this, tokens)}\n`;
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
strong(this: TelegramRendererContext, { tokens }: Tokens.Strong): string {
|
|
36
|
+
return `<b>${inlineFromTokens.call(this, tokens)}</b>`;
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
em(this: TelegramRendererContext, { tokens }: Tokens.Em): string {
|
|
40
|
+
return `<i>${inlineFromTokens.call(this, tokens)}</i>`;
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
del(this: TelegramRendererContext, { tokens }: Tokens.Del): string {
|
|
44
|
+
return `<s>${inlineFromTokens.call(this, tokens)}</s>`;
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
codespan(this: TelegramRendererContext, { text }: Tokens.Codespan): string {
|
|
48
|
+
return `<code>${escapeHtml(text)}</code>`;
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
code(this: TelegramRendererContext, { text, lang }: Tokens.Code): string {
|
|
52
|
+
const language = lang ? ` class="language-${escapeHtml(lang)}"` : "";
|
|
53
|
+
return `<pre><code${language}>${escapeHtml(text)}\n</code></pre>`;
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
table(this: TelegramRendererContext, token: Tokens.Table): string {
|
|
57
|
+
const rows: string[] = [];
|
|
58
|
+
if (token.header.length > 0) {
|
|
59
|
+
rows.push(token.header.map((c) => inlineFromTokens.call(this, c.tokens, c.text)).join(" | "));
|
|
60
|
+
rows.push(token.header.map(() => "---").join("-+-"));
|
|
61
|
+
}
|
|
62
|
+
for (const row of token.rows) {
|
|
63
|
+
rows.push(row.map((c) => inlineFromTokens.call(this, c.tokens, c.text)).join(" | "));
|
|
64
|
+
}
|
|
65
|
+
return `<pre>${rows.join("\n")}</pre>\n`;
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
list(this: TelegramRendererContext, token: Tokens.List): string {
|
|
69
|
+
const start = typeof token.start === "number" ? token.start : 1;
|
|
70
|
+
const lines = token.items.map((item, index) => {
|
|
71
|
+
const bullet = token.ordered ? `${start + index}. ` : "• ";
|
|
72
|
+
return bullet + blockFromTokens.call(this, item.tokens, item.text);
|
|
73
|
+
});
|
|
74
|
+
return lines.join("\n") + "\n";
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
listitem(this: TelegramRendererContext, token: Tokens.ListItem): string {
|
|
78
|
+
return blockFromTokens.call(this, token.tokens, token.text);
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
blockquote(this: TelegramRendererContext, { tokens }: Tokens.Blockquote): string {
|
|
82
|
+
return `<blockquote>${this.parser.parse(tokens as unknown[])}</blockquote>`;
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
image(this: TelegramRendererContext, { text, title }: Tokens.Image): string {
|
|
86
|
+
return title ? `[${text}: ${title}]` : `[${text}]`;
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
marked.use({ renderer });
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Convert Markdown to Telegram-compatible HTML.
|
|
94
|
+
* Handles tables, lists, code blocks, blockquotes, and all inline
|
|
95
|
+
* formatting within the Telegram supported subset.
|
|
96
|
+
*/
|
|
97
|
+
export function markdownToTelegramHtml(markdown: string): string {
|
|
98
|
+
return (marked.parse(markdown, { async: false }) as string).replace(/\n+$/, "");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function escapeAttr(text: string): string {
|
|
102
|
+
return escapeHtml(text).replace(/"/g, """);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Convert inline Markdown to Telegram HTML.
|
|
107
|
+
* Kept for backward compatibility with single-line / simple conversions.
|
|
108
|
+
*/
|
|
109
|
+
export function inlineMarkdownToHtml(text: string): string {
|
|
110
|
+
const links: Array<{ label: string; url: string }> = [];
|
|
111
|
+
let processed = text.replace(/\[([^\]\n]+)]\((https?:\/\/[^\s)]+)\)/g, (_m, label, url) => {
|
|
112
|
+
const idx = links.length;
|
|
113
|
+
links.push({ label, url });
|
|
114
|
+
return `\x00LINK${idx}\x00`;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
let html = escapeHtml(processed);
|
|
118
|
+
|
|
119
|
+
html = html.replace(/\x00LINK(\d+)\x00/g, (_m, idxStr) => {
|
|
120
|
+
const idx = parseInt(idxStr, 10);
|
|
121
|
+
const link = links[idx];
|
|
122
|
+
if (!link) return "";
|
|
123
|
+
return `<a href="${escapeAttr(link.url)}">${escapeHtml(link.label)}</a>`;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
html = html.replace(/`([^`\n]+)`/g, (_m, code) => `<code>${code}</code>`);
|
|
127
|
+
html = html.replace(/\*\*([^*\n]+)\*\*/g, (_m, bold) => `<b>${bold}</b>`);
|
|
128
|
+
html = html.replace(/__([^_\n]+)__/g, (_m, bold) => `<b>${bold}</b>`);
|
|
129
|
+
html = html.replace(/(?<!\*)\*([^*\n]+)\*(?!\*)/g, (_m, italic) => `<i>${italic}</i>`);
|
|
130
|
+
html = html.replace(/_([^_\n]+)_/g, (_m, italic) => `<i>${italic}</i>`);
|
|
131
|
+
return html;
|
|
132
|
+
}
|