@llblab/pi-telegram 0.2.9 → 0.2.10

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/lib/preview.ts CHANGED
@@ -23,6 +23,8 @@ export interface TelegramPreviewStateLike {
23
23
 
24
24
  export interface TelegramPreviewRuntimeState extends TelegramPreviewStateLike {
25
25
  flushTimer?: ReturnType<typeof setTimeout>;
26
+ flushPromise?: Promise<void>;
27
+ flushRequested?: boolean;
26
28
  }
27
29
 
28
30
  export interface TelegramSentPreviewMessageLike {
@@ -105,13 +107,11 @@ export async function clearTelegramPreview(
105
107
  }
106
108
  }
107
109
 
108
- export async function flushTelegramPreview(
110
+ async function performTelegramPreviewFlush(
109
111
  chatId: number,
112
+ state: TelegramPreviewRuntimeState,
110
113
  deps: TelegramPreviewRuntimeDeps,
111
114
  ): Promise<void> {
112
- const state = deps.getState();
113
- if (!state) return;
114
- state.flushTimer = undefined;
115
115
  const snapshot = buildTelegramPreviewSnapshot({
116
116
  state,
117
117
  maxMessageLength: deps.maxMessageLength,
@@ -166,6 +166,33 @@ export async function flushTelegramPreview(
166
166
  state.lastSentStrategy = snapshot.strategy;
167
167
  }
168
168
 
169
+ export async function flushTelegramPreview(
170
+ chatId: number,
171
+ deps: TelegramPreviewRuntimeDeps,
172
+ ): Promise<void> {
173
+ const state = deps.getState();
174
+ if (!state) return;
175
+ if (state.flushPromise) {
176
+ state.flushRequested = true;
177
+ await state.flushPromise;
178
+ return;
179
+ }
180
+ state.flushTimer = undefined;
181
+ state.flushPromise = (async () => {
182
+ do {
183
+ state.flushRequested = false;
184
+ await performTelegramPreviewFlush(chatId, state, deps);
185
+ } while (deps.getState() === state && state.flushRequested);
186
+ })();
187
+ try {
188
+ await state.flushPromise;
189
+ } finally {
190
+ if (deps.getState() === state) {
191
+ state.flushPromise = undefined;
192
+ }
193
+ }
194
+ }
195
+
169
196
  export async function finalizeTelegramPreview(
170
197
  chatId: number,
171
198
  deps: TelegramPreviewRuntimeDeps,
package/lib/rendering.ts CHANGED
@@ -14,6 +14,10 @@ function escapeHtml(text: string): string {
14
14
  .replace(/>/g, "&gt;");
15
15
  }
16
16
 
17
+ function escapeHtmlAttribute(text: string): string {
18
+ return escapeHtml(text).replace(/"/g, "&quot;").replace(/'/g, "&#39;");
19
+ }
20
+
17
21
  // --- Plain Preview Rendering ---
18
22
 
19
23
  function splitPlainMarkdownLine(line: string, maxLength = 1500): string[] {
@@ -746,12 +750,12 @@ function renderInlineMarkdown(text: string): string {
746
750
  const renderedLabel =
747
751
  plainLabel.length > 0 ? plainLabel : link.destination;
748
752
  return makeToken(
749
- `<a href="${escapeHtml(link.destination)}">${escapeHtml(renderedLabel)}</a>`,
753
+ `<a href="${escapeHtmlAttribute(link.destination)}">${escapeHtml(renderedLabel)}</a>`,
750
754
  );
751
755
  },
752
756
  renderAutolink: (link) => {
753
757
  return makeToken(
754
- `<a href="${escapeHtml(link.destination)}">${escapeHtml(link.destination)}</a>`,
758
+ `<a href="${escapeHtmlAttribute(link.destination)}">${escapeHtml(link.destination)}</a>`,
755
759
  );
756
760
  },
757
761
  });
@@ -877,9 +881,14 @@ function renderMarkdownTextLines(block: string): string[] {
877
881
  return rendered;
878
882
  }
879
883
 
884
+ function sanitizeTelegramCodeLanguage(language: string): string {
885
+ return language.split(/\s+/)[0]?.replace(/[^A-Za-z0-9_+.-]/g, "") ?? "";
886
+ }
887
+
880
888
  function renderMarkdownCodeBlock(code: string, language?: string): string[] {
881
- const open = language
882
- ? `<pre><code class="language-${escapeHtml(language)}">`
889
+ const safeLanguage = language ? sanitizeTelegramCodeLanguage(language) : "";
890
+ const open = safeLanguage
891
+ ? `<pre><code class="language-${escapeHtmlAttribute(safeLanguage)}">`
883
892
  : "<pre><code>";
884
893
  const close = "</code></pre>";
885
894
  const maxContentLength = MAX_MESSAGE_LENGTH - open.length - close.length;
@@ -1236,6 +1245,94 @@ function chunkParagraphs(text: string): string[] {
1236
1245
  return chunks;
1237
1246
  }
1238
1247
 
1248
+ interface OpenHtmlTag {
1249
+ name: string;
1250
+ openTag: string;
1251
+ }
1252
+
1253
+ const TELEGRAM_VOID_HTML_TAGS = new Set(["br", "hr"]);
1254
+
1255
+ function getHtmlTagName(tag: string): string | undefined {
1256
+ return tag.match(/^<\/?\s*([a-zA-Z][\w-]*)/)?.[1]?.toLowerCase();
1257
+ }
1258
+
1259
+ function isHtmlClosingTag(tag: string): boolean {
1260
+ return /^<\//.test(tag);
1261
+ }
1262
+
1263
+ function isHtmlSelfClosingTag(tag: string): boolean {
1264
+ return /\/\s*>$/.test(tag);
1265
+ }
1266
+
1267
+ function getHtmlClosingTags(openTags: OpenHtmlTag[]): string {
1268
+ return [...openTags]
1269
+ .reverse()
1270
+ .map((tag) => `</${tag.name}>`)
1271
+ .join("");
1272
+ }
1273
+
1274
+ function getHtmlOpeningTags(openTags: OpenHtmlTag[]): string {
1275
+ return openTags.map((tag) => tag.openTag).join("");
1276
+ }
1277
+
1278
+ function updateOpenHtmlTags(tag: string, openTags: OpenHtmlTag[]): void {
1279
+ const name = getHtmlTagName(tag);
1280
+ if (!name || TELEGRAM_VOID_HTML_TAGS.has(name)) return;
1281
+ if (isHtmlClosingTag(tag)) {
1282
+ const index = openTags.map((openTag) => openTag.name).lastIndexOf(name);
1283
+ if (index !== -1) openTags.splice(index, 1);
1284
+ return;
1285
+ }
1286
+ if (isHtmlSelfClosingTag(tag)) return;
1287
+ openTags.push({ name, openTag: tag });
1288
+ }
1289
+
1290
+ function chunkHtmlPreservingTags(html: string): string[] {
1291
+ if (html.length <= MAX_MESSAGE_LENGTH) return [html];
1292
+ const chunks: string[] = [];
1293
+ const openTags: OpenHtmlTag[] = [];
1294
+ const tagPattern = /<\/?[a-zA-Z][^>]*>/g;
1295
+ let current = "";
1296
+ let index = 0;
1297
+ const flushCurrent = (): void => {
1298
+ if (current.length === 0) return;
1299
+ chunks.push(`${current}${getHtmlClosingTags(openTags)}`);
1300
+ current = getHtmlOpeningTags(openTags);
1301
+ };
1302
+ const appendText = (text: string): void => {
1303
+ let remaining = text;
1304
+ while (remaining.length > 0) {
1305
+ const closingTags = getHtmlClosingTags(openTags);
1306
+ const available =
1307
+ MAX_MESSAGE_LENGTH - current.length - closingTags.length;
1308
+ if (available <= 0) {
1309
+ flushCurrent();
1310
+ continue;
1311
+ }
1312
+ const slice = remaining.slice(0, available);
1313
+ current += slice;
1314
+ remaining = remaining.slice(slice.length);
1315
+ if (remaining.length > 0) flushCurrent();
1316
+ }
1317
+ };
1318
+ const appendTag = (tag: string): void => {
1319
+ const closingTags = getHtmlClosingTags(openTags);
1320
+ if (current.length + tag.length + closingTags.length > MAX_MESSAGE_LENGTH) {
1321
+ flushCurrent();
1322
+ }
1323
+ current += tag;
1324
+ updateOpenHtmlTags(tag, openTags);
1325
+ };
1326
+ for (const match of html.matchAll(tagPattern)) {
1327
+ appendText(html.slice(index, match.index));
1328
+ appendTag(match[0]);
1329
+ index = match.index + match[0].length;
1330
+ }
1331
+ appendText(html.slice(index));
1332
+ if (current.length > 0) chunks.push(current);
1333
+ return chunks;
1334
+ }
1335
+
1239
1336
  export function renderTelegramMessage(
1240
1337
  text: string,
1241
1338
  options?: { mode?: TelegramRenderMode },
@@ -1245,7 +1342,10 @@ export function renderTelegramMessage(
1245
1342
  return chunkParagraphs(text).map((chunk) => ({ text: chunk }));
1246
1343
  }
1247
1344
  if (mode === "html") {
1248
- return [{ text, parseMode: "HTML" }];
1345
+ return chunkHtmlPreservingTags(text).map((chunk) => ({
1346
+ text: chunk,
1347
+ parseMode: "HTML",
1348
+ }));
1249
1349
  }
1250
1350
  return renderMarkdownToTelegramHtmlChunks(text).map((chunk) => ({
1251
1351
  text: chunk,
package/lib/turns.ts CHANGED
@@ -89,6 +89,92 @@ export function buildTelegramTurnPrompt(options: {
89
89
  return prompt;
90
90
  }
91
91
 
92
+ function splitTelegramPromptAttachmentSuffix(prompt: string): {
93
+ promptWithoutAttachments: string;
94
+ attachmentSuffix: string;
95
+ attachmentFiles: DownloadedTelegramTurnFileLike[];
96
+ } {
97
+ const marker = "\n\nTelegram attachments were saved locally:";
98
+ const markerIndex = prompt.indexOf(marker);
99
+ if (markerIndex === -1) {
100
+ return {
101
+ promptWithoutAttachments: prompt,
102
+ attachmentSuffix: "",
103
+ attachmentFiles: [],
104
+ };
105
+ }
106
+ const promptWithoutAttachments = prompt.slice(0, markerIndex);
107
+ const attachmentSuffix = prompt.slice(markerIndex);
108
+ const attachmentFiles = attachmentSuffix
109
+ .split("\n")
110
+ .map((line) => line.match(/^- (.+)$/)?.[1]?.trim())
111
+ .filter((path): path is string => !!path)
112
+ .map((path) => ({ path, fileName: basename(path), isImage: false }));
113
+ return { promptWithoutAttachments, attachmentSuffix, attachmentFiles };
114
+ }
115
+
116
+ function buildEditedTelegramPromptText(options: {
117
+ existingPrompt: string;
118
+ telegramPrefix: string;
119
+ rawText: string;
120
+ }): { text: string; attachmentFiles: DownloadedTelegramTurnFileLike[] } {
121
+ const { promptWithoutAttachments, attachmentSuffix, attachmentFiles } =
122
+ splitTelegramPromptAttachmentSuffix(options.existingPrompt);
123
+ const currentMessageMarker = "Current Telegram message:";
124
+ const currentMessageIndex = promptWithoutAttachments.lastIndexOf(
125
+ currentMessageMarker,
126
+ );
127
+ if (currentMessageIndex !== -1) {
128
+ const prefix = promptWithoutAttachments.slice(
129
+ 0,
130
+ currentMessageIndex + currentMessageMarker.length,
131
+ );
132
+ const separator = options.rawText.length > 0 ? "\n" : "";
133
+ return {
134
+ text: `${prefix}${separator}${options.rawText}${attachmentSuffix}`,
135
+ attachmentFiles,
136
+ };
137
+ }
138
+ const promptText =
139
+ options.rawText.length > 0
140
+ ? `${options.telegramPrefix} ${options.rawText}`
141
+ : options.telegramPrefix;
142
+ return {
143
+ text: `${promptText}${attachmentSuffix}`,
144
+ attachmentFiles,
145
+ };
146
+ }
147
+
148
+ export function updateTelegramPromptTurnText(options: {
149
+ turn: PendingTelegramTurn;
150
+ telegramPrefix: string;
151
+ rawText: string;
152
+ }): PendingTelegramTurn {
153
+ let attachmentFiles: DownloadedTelegramTurnFileLike[] = [];
154
+ const nextContent = options.turn.content.map((block, index) => {
155
+ if (index !== 0 || block.type !== "text") return block;
156
+ const updated = buildEditedTelegramPromptText({
157
+ existingPrompt: block.text,
158
+ telegramPrefix: options.telegramPrefix,
159
+ rawText: options.rawText,
160
+ });
161
+ attachmentFiles = updated.attachmentFiles;
162
+ return {
163
+ ...block,
164
+ text: updated.text,
165
+ };
166
+ });
167
+ return {
168
+ ...options.turn,
169
+ content: nextContent,
170
+ historyText: formatTelegramHistoryText(options.rawText, attachmentFiles),
171
+ statusSummary: formatTelegramTurnStatusSummary(
172
+ options.rawText,
173
+ attachmentFiles,
174
+ ),
175
+ };
176
+ }
177
+
92
178
  export async function buildTelegramPromptTurn(options: {
93
179
  telegramPrefix: string;
94
180
  messages: TelegramTurnMessageLike[];
package/lib/types.ts ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Telegram Bot API transport shape types
3
+ * Centralizes Telegram update, message, callback, reaction, and response shapes used by the runtime
4
+ */
5
+
6
+ export interface TelegramApiResponse<T> {
7
+ ok: boolean;
8
+ result?: T;
9
+ description?: string;
10
+ error_code?: number;
11
+ }
12
+
13
+ export interface TelegramUser {
14
+ id: number;
15
+ is_bot: boolean;
16
+ first_name: string;
17
+ username?: string;
18
+ }
19
+
20
+ export interface TelegramChat {
21
+ id: number;
22
+ type: string;
23
+ }
24
+
25
+ export interface TelegramPhotoSize {
26
+ file_id: string;
27
+ file_size?: number;
28
+ }
29
+
30
+ export interface TelegramDocument {
31
+ file_id: string;
32
+ file_name?: string;
33
+ mime_type?: string;
34
+ file_size?: number;
35
+ }
36
+
37
+ export interface TelegramVideo {
38
+ file_id: string;
39
+ file_name?: string;
40
+ mime_type?: string;
41
+ file_size?: number;
42
+ }
43
+
44
+ export interface TelegramAudio {
45
+ file_id: string;
46
+ file_name?: string;
47
+ mime_type?: string;
48
+ file_size?: number;
49
+ }
50
+
51
+ export interface TelegramVoice {
52
+ file_id: string;
53
+ mime_type?: string;
54
+ file_size?: number;
55
+ }
56
+
57
+ export interface TelegramAnimation {
58
+ file_id: string;
59
+ file_name?: string;
60
+ mime_type?: string;
61
+ file_size?: number;
62
+ }
63
+
64
+ export interface TelegramSticker {
65
+ file_id: string;
66
+ emoji?: string;
67
+ }
68
+
69
+ export interface TelegramMessage {
70
+ message_id: number;
71
+ chat: TelegramChat;
72
+ from?: TelegramUser;
73
+ text?: string;
74
+ caption?: string;
75
+ media_group_id?: string;
76
+ photo?: TelegramPhotoSize[];
77
+ document?: TelegramDocument;
78
+ video?: TelegramVideo;
79
+ audio?: TelegramAudio;
80
+ voice?: TelegramVoice;
81
+ animation?: TelegramAnimation;
82
+ sticker?: TelegramSticker;
83
+ }
84
+
85
+ export interface TelegramCallbackQuery {
86
+ id: string;
87
+ from: TelegramUser;
88
+ message?: TelegramMessage;
89
+ data?: string;
90
+ }
91
+
92
+ export interface TelegramReactionTypeEmoji {
93
+ type: "emoji";
94
+ emoji: string;
95
+ }
96
+
97
+ export interface TelegramReactionTypeCustomEmoji {
98
+ type: "custom_emoji";
99
+ custom_emoji_id: string;
100
+ }
101
+
102
+ export interface TelegramReactionTypePaid {
103
+ type: "paid";
104
+ }
105
+
106
+ export type TelegramReactionType =
107
+ | TelegramReactionTypeEmoji
108
+ | TelegramReactionTypeCustomEmoji
109
+ | TelegramReactionTypePaid;
110
+
111
+ export interface TelegramMessageReactionUpdated {
112
+ chat: TelegramChat;
113
+ message_id: number;
114
+ user?: TelegramUser;
115
+ actor_chat?: TelegramChat;
116
+ old_reaction: TelegramReactionType[];
117
+ new_reaction: TelegramReactionType[];
118
+ date: number;
119
+ }
120
+
121
+ export interface TelegramUpdate {
122
+ update_id: number;
123
+ message?: TelegramMessage;
124
+ edited_message?: TelegramMessage;
125
+ callback_query?: TelegramCallbackQuery;
126
+ message_reaction?: TelegramMessageReactionUpdated;
127
+ deleted_business_messages?: { message_ids?: unknown };
128
+ }
129
+
130
+ export interface TelegramSentMessage {
131
+ message_id: number;
132
+ }
133
+
134
+ export interface TelegramBotCommand {
135
+ command: string;
136
+ description: string;
137
+ }
package/lib/updates.ts CHANGED
@@ -119,7 +119,22 @@ export function getAuthorizedTelegramCallbackQuery(
119
119
  export function getAuthorizedTelegramMessage(
120
120
  update: TelegramUpdateRoutingLike,
121
121
  ): TelegramMessageLike | undefined {
122
- const message = update.message || update.edited_message;
122
+ const message = update.message;
123
+ if (
124
+ !message ||
125
+ message.chat.type !== "private" ||
126
+ !message.from ||
127
+ message.from.is_bot
128
+ ) {
129
+ return undefined;
130
+ }
131
+ return message;
132
+ }
133
+
134
+ export function getAuthorizedTelegramEditedMessage(
135
+ update: TelegramUpdateRoutingLike,
136
+ ): TelegramMessageLike | undefined {
137
+ const message = update.edited_message;
123
138
  if (
124
139
  !message ||
125
140
  message.chat.type !== "private" ||
@@ -156,6 +171,11 @@ export type TelegramUpdateFlowAction =
156
171
  kind: "message";
157
172
  message: TelegramMessageLike & { from: TelegramUserLike };
158
173
  authorization: TelegramAuthorizationState;
174
+ }
175
+ | {
176
+ kind: "edited-message";
177
+ message: TelegramMessageLike & { from: TelegramUserLike };
178
+ authorization: TelegramAuthorizationState;
159
179
  };
160
180
 
161
181
  export function buildTelegramUpdateFlowAction(
@@ -191,6 +211,19 @@ export function buildTelegramUpdateFlowAction(
191
211
  ),
192
212
  };
193
213
  }
214
+ const editedMessage = getAuthorizedTelegramEditedMessage(update);
215
+ if (editedMessage?.from) {
216
+ return {
217
+ kind: "edited-message",
218
+ message: editedMessage as TelegramMessageLike & {
219
+ from: TelegramUserLike;
220
+ },
221
+ authorization: getTelegramAuthorizationState(
222
+ editedMessage.from.id,
223
+ allowedUserId,
224
+ ),
225
+ };
226
+ }
194
227
  return { kind: "ignore" };
195
228
  }
196
229
 
@@ -215,6 +248,12 @@ export type TelegramUpdateExecutionPlan =
215
248
  shouldPair: boolean;
216
249
  shouldNotifyPaired: boolean;
217
250
  shouldDeny: boolean;
251
+ }
252
+ | {
253
+ kind: "edited-message";
254
+ message: TelegramMessageLike & { from: TelegramUserLike };
255
+ shouldPair: boolean;
256
+ shouldDeny: boolean;
218
257
  };
219
258
 
220
259
  export function buildTelegramUpdateExecutionPlan(
@@ -242,6 +281,13 @@ export function buildTelegramUpdateExecutionPlan(
242
281
  shouldNotifyPaired: action.authorization.kind === "pair",
243
282
  shouldDeny: action.authorization.kind === "deny",
244
283
  };
284
+ case "edited-message":
285
+ return {
286
+ kind: "edited-message",
287
+ message: action.message,
288
+ shouldPair: action.authorization.kind === "pair",
289
+ shouldDeny: action.authorization.kind === "deny",
290
+ };
245
291
  }
246
292
  }
247
293
 
@@ -296,6 +342,13 @@ export interface TelegramUpdateRuntimeDeps {
296
342
  >["message"],
297
343
  ctx: ExtensionContext,
298
344
  ) => Promise<void>;
345
+ handleAuthorizedTelegramEditedMessage: (
346
+ message: Extract<
347
+ TelegramUpdateExecutionPlan,
348
+ { kind: "edited-message" }
349
+ >["message"],
350
+ ctx: ExtensionContext,
351
+ ) => Promise<void>;
299
352
  }
300
353
 
301
354
  function getTelegramCallbackQueryId(
@@ -368,7 +421,12 @@ export async function executeTelegramUpdatePlan(
368
421
  ? await deps.pairTelegramUserIfNeeded(plan.message.from.id, deps.ctx)
369
422
  : false;
370
423
  const replyTarget = getTelegramMessageReplyTarget(plan.message);
371
- if (pairedNow && plan.shouldNotifyPaired && replyTarget) {
424
+ if (
425
+ plan.kind === "message" &&
426
+ pairedNow &&
427
+ plan.shouldNotifyPaired &&
428
+ replyTarget
429
+ ) {
372
430
  await deps.sendTextReply(
373
431
  replyTarget.chatId,
374
432
  replyTarget.messageId,
@@ -385,5 +443,9 @@ export async function executeTelegramUpdatePlan(
385
443
  }
386
444
  return;
387
445
  }
446
+ if (plan.kind === "edited-message") {
447
+ await deps.handleAuthorizedTelegramEditedMessage(plan.message, deps.ctx);
448
+ return;
449
+ }
388
450
  await deps.handleAuthorizedTelegramMessage(plan.message, deps.ctx);
389
451
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "private": false,
5
5
  "description": "Better Telegram DM bridge extension for pi",
6
6
  "type": "module",