@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/index.ts CHANGED
@@ -16,17 +16,26 @@ import type {
16
16
  import { SettingsManager } from "@mariozechner/pi-coding-agent";
17
17
 
18
18
  import {
19
+ cleanupTelegramTempFiles,
19
20
  createTelegramApiClient,
20
21
  readTelegramConfig,
21
22
  writeTelegramConfig,
22
23
  type TelegramConfig,
23
24
  } from "./lib/api.ts";
24
25
  import { sendQueuedTelegramAttachments } from "./lib/attachments.ts";
26
+ import {
27
+ buildTelegramCommandAction,
28
+ executeTelegramCommandAction,
29
+ parseTelegramCommand,
30
+ } from "./lib/commands.ts";
25
31
  import {
26
32
  collectTelegramFileInfos,
27
33
  extractFirstTelegramMessageText,
28
34
  extractTelegramMessagesText,
29
35
  guessMediaType,
36
+ queueTelegramMediaGroupMessage,
37
+ removePendingTelegramMediaGroupMessages,
38
+ type TelegramMediaGroupState,
30
39
  } from "./lib/media.ts";
31
40
  import {
32
41
  buildTelegramModelMenuState,
@@ -52,6 +61,13 @@ import {
52
61
  shouldTriggerPendingTelegramModelSwitchAbort,
53
62
  } from "./lib/model-switch.ts";
54
63
  import { runTelegramPollLoop } from "./lib/polling.ts";
64
+ import {
65
+ clearTelegramPreview,
66
+ finalizeTelegramMarkdownPreview,
67
+ finalizeTelegramPreview,
68
+ flushTelegramPreview,
69
+ type TelegramPreviewRuntimeState,
70
+ } from "./lib/preview.ts";
55
71
  import {
56
72
  buildTelegramAgentEndPlan,
57
73
  buildTelegramAgentStartPlan,
@@ -86,13 +102,6 @@ import {
86
102
  renderTelegramMessage,
87
103
  type TelegramRenderMode,
88
104
  } from "./lib/rendering.ts";
89
- import {
90
- clearTelegramPreview,
91
- finalizeTelegramMarkdownPreview,
92
- finalizeTelegramPreview,
93
- flushTelegramPreview,
94
- type TelegramPreviewRuntimeState,
95
- } from "./lib/preview.ts";
96
105
  import {
97
106
  buildTelegramReplyTransport,
98
107
  sendTelegramMarkdownReply,
@@ -106,159 +115,24 @@ import { buildStatusHtml } from "./lib/status.ts";
106
115
  import {
107
116
  buildTelegramPromptTurn,
108
117
  truncateTelegramQueueSummary,
118
+ updateTelegramPromptTurnText,
109
119
  } from "./lib/turns.ts";
120
+ import type {
121
+ TelegramApiResponse,
122
+ TelegramBotCommand,
123
+ TelegramCallbackQuery,
124
+ TelegramMessage,
125
+ TelegramMessageReactionUpdated,
126
+ TelegramSentMessage,
127
+ TelegramUpdate,
128
+ TelegramUser,
129
+ } from "./lib/types.ts";
110
130
  import {
111
131
  collectTelegramReactionEmojis,
112
132
  executeTelegramUpdate,
113
133
  getTelegramAuthorizationState,
114
134
  } from "./lib/updates.ts";
115
135
 
116
- // --- Telegram API Types ---
117
-
118
- interface TelegramApiResponse<T> {
119
- ok: boolean;
120
- result?: T;
121
- description?: string;
122
- error_code?: number;
123
- }
124
-
125
- interface TelegramUser {
126
- id: number;
127
- is_bot: boolean;
128
- first_name: string;
129
- username?: string;
130
- }
131
-
132
- interface TelegramChat {
133
- id: number;
134
- type: string;
135
- }
136
-
137
- interface TelegramPhotoSize {
138
- file_id: string;
139
- file_size?: number;
140
- }
141
-
142
- interface TelegramDocument {
143
- file_id: string;
144
- file_name?: string;
145
- mime_type?: string;
146
- file_size?: number;
147
- }
148
-
149
- interface TelegramVideo {
150
- file_id: string;
151
- file_name?: string;
152
- mime_type?: string;
153
- file_size?: number;
154
- }
155
-
156
- interface TelegramAudio {
157
- file_id: string;
158
- file_name?: string;
159
- mime_type?: string;
160
- file_size?: number;
161
- }
162
-
163
- interface TelegramVoice {
164
- file_id: string;
165
- mime_type?: string;
166
- file_size?: number;
167
- }
168
-
169
- interface TelegramAnimation {
170
- file_id: string;
171
- file_name?: string;
172
- mime_type?: string;
173
- file_size?: number;
174
- }
175
-
176
- interface TelegramSticker {
177
- file_id: string;
178
- emoji?: string;
179
- }
180
-
181
- interface TelegramFileInfo {
182
- file_id: string;
183
- fileName: string;
184
- mimeType?: string;
185
- isImage: boolean;
186
- }
187
-
188
- interface TelegramMessage {
189
- message_id: number;
190
- chat: TelegramChat;
191
- from?: TelegramUser;
192
- text?: string;
193
- caption?: string;
194
- media_group_id?: string;
195
- photo?: TelegramPhotoSize[];
196
- document?: TelegramDocument;
197
- video?: TelegramVideo;
198
- audio?: TelegramAudio;
199
- voice?: TelegramVoice;
200
- animation?: TelegramAnimation;
201
- sticker?: TelegramSticker;
202
- }
203
-
204
- interface TelegramCallbackQuery {
205
- id: string;
206
- from: TelegramUser;
207
- message?: TelegramMessage;
208
- data?: string;
209
- }
210
-
211
- interface TelegramReactionTypeEmoji {
212
- type: "emoji";
213
- emoji: string;
214
- }
215
-
216
- interface TelegramReactionTypeCustomEmoji {
217
- type: "custom_emoji";
218
- custom_emoji_id: string;
219
- }
220
-
221
- interface TelegramReactionTypePaid {
222
- type: "paid";
223
- }
224
-
225
- type TelegramReactionType =
226
- | TelegramReactionTypeEmoji
227
- | TelegramReactionTypeCustomEmoji
228
- | TelegramReactionTypePaid;
229
-
230
- interface TelegramMessageReactionUpdated {
231
- chat: TelegramChat;
232
- message_id: number;
233
- user?: TelegramUser;
234
- actor_chat?: TelegramChat;
235
- old_reaction: TelegramReactionType[];
236
- new_reaction: TelegramReactionType[];
237
- date: number;
238
- }
239
-
240
- interface TelegramUpdate {
241
- update_id: number;
242
- message?: TelegramMessage;
243
- edited_message?: TelegramMessage;
244
- callback_query?: TelegramCallbackQuery;
245
- message_reaction?: TelegramMessageReactionUpdated;
246
- deleted_business_messages?: { message_ids?: unknown };
247
- }
248
-
249
- interface TelegramGetFileResult {
250
- file_path: string;
251
- }
252
-
253
- interface TelegramSentMessage {
254
- message_id: number;
255
- }
256
-
257
- interface TelegramBotCommand {
258
- command: string;
259
- description: string;
260
- }
261
-
262
136
  // --- Extension State Types ---
263
137
 
264
138
  interface DownloadedTelegramFile {
@@ -272,19 +146,30 @@ type ActiveTelegramTurn = PendingTelegramTurn;
272
146
 
273
147
  type TelegramPreviewState = TelegramPreviewRuntimeState;
274
148
 
275
- interface TelegramMediaGroupState {
276
- messages: TelegramMessage[];
277
- flushTimer?: ReturnType<typeof setTimeout>;
149
+ interface StoredTelegramModelMenuState {
150
+ state: TelegramModelMenuState;
151
+ updatedAt: number;
152
+ }
153
+
154
+ interface CachedTelegramModelMenuInputs {
155
+ expiresAt: number;
156
+ availableModels: Model<any>[];
157
+ configuredScopedModelPatterns: string[];
158
+ cliScopedModelPatterns?: string[];
278
159
  }
279
160
 
280
161
  const AGENT_DIR = join(homedir(), ".pi", "agent");
281
162
  const CONFIG_PATH = join(AGENT_DIR, "telegram.json");
282
163
  const TEMP_DIR = join(AGENT_DIR, "tmp", "telegram");
164
+ const TELEGRAM_TEMP_FILE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
283
165
  const TELEGRAM_PREFIX = "[telegram]";
284
166
  const MAX_ATTACHMENTS_PER_TURN = 10;
285
167
  const PREVIEW_THROTTLE_MS = 750;
286
168
  const TELEGRAM_DRAFT_ID_MAX = 2_147_483_647;
287
169
  const TELEGRAM_MEDIA_GROUP_DEBOUNCE_MS = 1200;
170
+ const TELEGRAM_MODEL_MENU_CACHE_TTL_MS = 5000;
171
+ const TELEGRAM_MODEL_MENU_STATE_TTL_MS = 10 * 60 * 1000;
172
+ const MAX_STORED_TELEGRAM_MODEL_MENUS = 50;
288
173
  const SYSTEM_PROMPT_SUFFIX = `
289
174
 
290
175
  Telegram bridge extension is active.
@@ -303,17 +188,6 @@ function sanitizeFileName(name: string): string {
303
188
  return name.replace(/[^a-zA-Z0-9._-]+/g, "_");
304
189
  }
305
190
 
306
- function parseTelegramCommand(
307
- text: string,
308
- ): { name: string; args: string } | undefined {
309
- const trimmed = text.trim();
310
- if (!trimmed.startsWith("/")) return undefined;
311
- const [head, ...tail] = trimmed.split(/\s+/);
312
- const name = head.slice(1).split("@")[0]?.toLowerCase();
313
- if (!name) return undefined;
314
- return { name, args: tail.join(" ").trim() };
315
- }
316
-
317
191
  function getCliScopedModelPatterns(): string[] | undefined {
318
192
  const args = process.argv.slice(2);
319
193
  for (let i = 0; i < args.length; i++) {
@@ -396,8 +270,12 @@ export default function (pi: ExtensionAPI) {
396
270
  let draftSupport: "unknown" | "supported" | "unsupported" = "unknown";
397
271
  let nextDraftId = 0;
398
272
  let currentTelegramModel: Model<any> | undefined;
399
- const mediaGroups = new Map<string, TelegramMediaGroupState>();
400
- const modelMenus = new Map<number, TelegramModelMenuState>();
273
+ const mediaGroups = new Map<
274
+ string,
275
+ TelegramMediaGroupState<TelegramMessage>
276
+ >();
277
+ const modelMenus = new Map<number, StoredTelegramModelMenuState>();
278
+ let cachedModelMenuInputs: CachedTelegramModelMenuInputs | undefined;
401
279
 
402
280
  // --- Runtime State ---
403
281
 
@@ -892,24 +770,73 @@ export default function (pi: ExtensionAPI) {
892
770
 
893
771
  // --- Interactive Menu State & Builders ---
894
772
 
895
- async function getModelMenuState(
896
- chatId: number,
773
+ function pruneStoredModelMenus(now = Date.now()): void {
774
+ for (const [messageId, entry] of modelMenus.entries()) {
775
+ if (now - entry.updatedAt <= TELEGRAM_MODEL_MENU_STATE_TTL_MS) continue;
776
+ modelMenus.delete(messageId);
777
+ }
778
+ while (modelMenus.size > MAX_STORED_TELEGRAM_MODEL_MENUS) {
779
+ const oldestMessageId = modelMenus.keys().next().value as
780
+ | number
781
+ | undefined;
782
+ if (oldestMessageId === undefined) return;
783
+ modelMenus.delete(oldestMessageId);
784
+ }
785
+ }
786
+
787
+ function storeModelMenuState(state: TelegramModelMenuState): void {
788
+ pruneStoredModelMenus();
789
+ modelMenus.set(state.messageId, { state, updatedAt: Date.now() });
790
+ }
791
+
792
+ function getStoredModelMenuState(
793
+ messageId: number | undefined,
794
+ ): TelegramModelMenuState | undefined {
795
+ if (messageId === undefined) return undefined;
796
+ pruneStoredModelMenus();
797
+ const entry = modelMenus.get(messageId);
798
+ if (!entry) return undefined;
799
+ modelMenus.delete(messageId);
800
+ entry.updatedAt = Date.now();
801
+ modelMenus.set(messageId, entry);
802
+ return entry.state;
803
+ }
804
+
805
+ async function getCachedModelMenuInputs(
897
806
  ctx: ExtensionContext,
898
- ): Promise<TelegramModelMenuState> {
807
+ ): Promise<CachedTelegramModelMenuInputs> {
808
+ const now = Date.now();
809
+ if (cachedModelMenuInputs && cachedModelMenuInputs.expiresAt > now) {
810
+ return cachedModelMenuInputs;
811
+ }
899
812
  const settingsManager = SettingsManager.create(ctx.cwd);
900
813
  await settingsManager.reload();
901
814
  ctx.modelRegistry.refresh();
902
- const activeModel = getCurrentTelegramModel(ctx);
903
815
  const availableModels = ctx.modelRegistry.getAvailable();
904
- const cliScopedModels = getCliScopedModelPatterns();
905
- const configuredScopedModels =
906
- cliScopedModels ?? settingsManager.getEnabledModels() ?? [];
816
+ const cliScopedModelPatterns = getCliScopedModelPatterns();
817
+ const configuredScopedModelPatterns =
818
+ cliScopedModelPatterns ?? settingsManager.getEnabledModels() ?? [];
819
+ cachedModelMenuInputs = {
820
+ expiresAt: now + TELEGRAM_MODEL_MENU_CACHE_TTL_MS,
821
+ availableModels,
822
+ configuredScopedModelPatterns,
823
+ cliScopedModelPatterns,
824
+ };
825
+ return cachedModelMenuInputs;
826
+ }
827
+
828
+ async function getModelMenuState(
829
+ chatId: number,
830
+ ctx: ExtensionContext,
831
+ ): Promise<TelegramModelMenuState> {
832
+ const activeModel = getCurrentTelegramModel(ctx);
833
+ const inputs = await getCachedModelMenuInputs(ctx);
907
834
  return buildTelegramModelMenuState({
908
835
  chatId,
909
836
  activeModel,
910
- availableModels,
911
- configuredScopedModelPatterns: configuredScopedModels,
912
- cliScopedModelPatterns: cliScopedModels ?? undefined,
837
+ availableModels: inputs.availableModels,
838
+ configuredScopedModelPatterns: inputs.configuredScopedModelPatterns,
839
+ cliScopedModelPatterns: inputs.cliScopedModelPatterns,
913
840
  });
914
841
  }
915
842
 
@@ -1012,7 +939,7 @@ export default function (pi: ExtensionAPI) {
1012
939
  if (messageId === undefined) return;
1013
940
  state.messageId = messageId;
1014
941
  state.mode = "status";
1015
- modelMenus.set(messageId, state);
942
+ storeModelMenuState(state);
1016
943
  }
1017
944
 
1018
945
  function canOfferInFlightTelegramModelSwitch(ctx: ExtensionContext): boolean {
@@ -1150,7 +1077,7 @@ export default function (pi: ExtensionAPI) {
1150
1077
  if (messageId === undefined) return;
1151
1078
  state.messageId = messageId;
1152
1079
  state.mode = "model";
1153
- modelMenus.set(messageId, state);
1080
+ storeModelMenuState(state);
1154
1081
  }
1155
1082
 
1156
1083
  async function handleStatusCallbackAction(
@@ -1251,33 +1178,24 @@ export default function (pi: ExtensionAPI) {
1251
1178
  ctx: ExtensionContext,
1252
1179
  ): Promise<void> {
1253
1180
  const messageId = query.message?.message_id;
1254
- await handleTelegramMenuCallbackEntry(
1255
- query.id,
1256
- query.data,
1257
- messageId ? modelMenus.get(messageId) : undefined,
1258
- {
1259
- handleStatusAction: async () => {
1260
- const state = messageId ? modelMenus.get(messageId) : undefined;
1261
- if (!state) return false;
1262
- return handleStatusCallbackAction(query, state, ctx);
1263
- },
1264
- handleThinkingAction: async () => {
1265
- const state = messageId ? modelMenus.get(messageId) : undefined;
1266
- if (!state) return false;
1267
- return handleThinkingCallbackAction(query, state, ctx);
1268
- },
1269
- handleModelAction: async () => {
1270
- const state = messageId ? modelMenus.get(messageId) : undefined;
1271
- if (!state) return false;
1272
- return handleModelCallbackAction(query, state, ctx);
1273
- },
1274
- answerCallbackQuery,
1181
+ const state = getStoredModelMenuState(messageId);
1182
+ await handleTelegramMenuCallbackEntry(query.id, query.data, state, {
1183
+ handleStatusAction: async () => {
1184
+ if (!state) return false;
1185
+ return handleStatusCallbackAction(query, state, ctx);
1275
1186
  },
1276
- );
1187
+ handleThinkingAction: async () => {
1188
+ if (!state) return false;
1189
+ return handleThinkingCallbackAction(query, state, ctx);
1190
+ },
1191
+ handleModelAction: async () => {
1192
+ if (!state) return false;
1193
+ return handleModelCallbackAction(query, state, ctx);
1194
+ },
1195
+ answerCallbackQuery,
1196
+ });
1277
1197
  }
1278
1198
 
1279
- // --- Status Rendering ---
1280
-
1281
1199
  // --- Turn Queue & Message Dispatch ---
1282
1200
 
1283
1201
  async function buildTelegramFiles(
@@ -1305,19 +1223,11 @@ export default function (pi: ExtensionAPI) {
1305
1223
  }
1306
1224
 
1307
1225
  function removePendingMediaGroupMessages(messageIds: number[]): void {
1308
- if (messageIds.length === 0 || mediaGroups.size === 0) return;
1309
- const deletedMessageIds = new Set(messageIds);
1310
- for (const [key, state] of mediaGroups.entries()) {
1311
- if (
1312
- !state.messages.some((message) =>
1313
- deletedMessageIds.has(message.message_id),
1314
- )
1315
- ) {
1316
- continue;
1317
- }
1318
- if (state.flushTimer) clearTimeout(state.flushTimer);
1319
- mediaGroups.delete(key);
1320
- }
1226
+ removePendingTelegramMediaGroupMessages(
1227
+ mediaGroups,
1228
+ messageIds,
1229
+ clearTimeout,
1230
+ );
1321
1231
  }
1322
1232
 
1323
1233
  function removeQueuedTelegramTurnsByMessageIds(
@@ -1572,19 +1482,18 @@ export default function (pi: ExtensionAPI) {
1572
1482
  message: TelegramMessage,
1573
1483
  ctx: ExtensionContext,
1574
1484
  ): Promise<boolean> {
1575
- if (!commandName) return false;
1576
- const handlers: Partial<Record<string, () => Promise<void>>> = {
1577
- stop: () => handleStopCommand(message, ctx),
1578
- compact: () => handleCompactCommand(message, ctx),
1579
- status: () => handleStatusCommand(message, ctx),
1580
- model: () => handleModelCommand(message, ctx),
1581
- help: () => handleHelpCommand(message, commandName, ctx),
1582
- start: () => handleHelpCommand(message, commandName, ctx),
1583
- };
1584
- const handler = handlers[commandName];
1585
- if (!handler) return false;
1586
- await handler();
1587
- return true;
1485
+ return executeTelegramCommandAction(
1486
+ buildTelegramCommandAction(commandName),
1487
+ message,
1488
+ ctx,
1489
+ {
1490
+ handleStop: handleStopCommand,
1491
+ handleCompact: handleCompactCommand,
1492
+ handleStatus: handleStatusCommand,
1493
+ handleModel: handleModelCommand,
1494
+ handleHelp: handleHelpCommand,
1495
+ },
1496
+ );
1588
1497
  }
1589
1498
 
1590
1499
  async function enqueueTelegramTurn(
@@ -1615,25 +1524,53 @@ export default function (pi: ExtensionAPI) {
1615
1524
  await enqueueTelegramTurn(messages, ctx);
1616
1525
  }
1617
1526
 
1618
- async function handleAuthorizedTelegramMessage(
1527
+ function updateQueuedTelegramTurnFromEditedMessage(
1528
+ message: TelegramMessage,
1529
+ ctx: ExtensionContext,
1530
+ ): boolean {
1531
+ const messageText = extractTelegramMessagesText([message]);
1532
+ let changed = false;
1533
+ queuedTelegramItems = queuedTelegramItems.map((item) => {
1534
+ if (
1535
+ item.kind !== "prompt" ||
1536
+ message.message_id === undefined ||
1537
+ !item.sourceMessageIds.includes(message.message_id)
1538
+ ) {
1539
+ return item;
1540
+ }
1541
+ changed = true;
1542
+ return updateTelegramPromptTurnText({
1543
+ turn: item,
1544
+ telegramPrefix: TELEGRAM_PREFIX,
1545
+ rawText: messageText,
1546
+ });
1547
+ });
1548
+ if (changed) updateStatus(ctx);
1549
+ return changed;
1550
+ }
1551
+
1552
+ async function handleAuthorizedTelegramEditedMessage(
1619
1553
  message: TelegramMessage,
1620
1554
  ctx: ExtensionContext,
1621
1555
  ): Promise<void> {
1622
- if (message.media_group_id) {
1623
- const key = `${message.chat.id}:${message.media_group_id}`;
1624
- const existing = mediaGroups.get(key) ?? { messages: [] };
1625
- existing.messages.push(message);
1626
- if (existing.flushTimer) clearTimeout(existing.flushTimer);
1627
- existing.flushTimer = setTimeout(() => {
1628
- const state = mediaGroups.get(key);
1629
- mediaGroups.delete(key);
1630
- if (!state) return;
1631
- void dispatchAuthorizedTelegramMessages(state.messages, ctx);
1632
- }, TELEGRAM_MEDIA_GROUP_DEBOUNCE_MS);
1633
- mediaGroups.set(key, existing);
1634
- return;
1635
- }
1556
+ updateQueuedTelegramTurnFromEditedMessage(message, ctx);
1557
+ }
1636
1558
 
1559
+ async function handleAuthorizedTelegramMessage(
1560
+ message: TelegramMessage,
1561
+ ctx: ExtensionContext,
1562
+ ): Promise<void> {
1563
+ const queuedMediaGroup = queueTelegramMediaGroupMessage({
1564
+ message,
1565
+ groups: mediaGroups,
1566
+ debounceMs: TELEGRAM_MEDIA_GROUP_DEBOUNCE_MS,
1567
+ setTimer: setTimeout,
1568
+ clearTimer: clearTimeout,
1569
+ dispatchMessages: (messages) => {
1570
+ void dispatchAuthorizedTelegramMessages(messages, ctx);
1571
+ },
1572
+ });
1573
+ if (queuedMediaGroup) return;
1637
1574
  await dispatchAuthorizedTelegramMessages([message], ctx);
1638
1575
  }
1639
1576
 
@@ -1684,6 +1621,12 @@ export default function (pi: ExtensionAPI) {
1684
1621
  nextCtx,
1685
1622
  );
1686
1623
  },
1624
+ handleAuthorizedTelegramEditedMessage: async (message, nextCtx) => {
1625
+ await handleAuthorizedTelegramEditedMessage(
1626
+ message as TelegramMessage,
1627
+ nextCtx,
1628
+ );
1629
+ },
1687
1630
  });
1688
1631
  }
1689
1632
 
@@ -1797,6 +1740,7 @@ export default function (pi: ExtensionAPI) {
1797
1740
  sessionStartState.telegramTurnDispatchPending;
1798
1741
  compactionInProgress = sessionStartState.compactionInProgress;
1799
1742
  await mkdir(TEMP_DIR, { recursive: true });
1743
+ await cleanupTelegramTempFiles(TEMP_DIR, TELEGRAM_TEMP_FILE_MAX_AGE_MS);
1800
1744
  updateStatus(ctx);
1801
1745
  },
1802
1746
  onSessionShutdown: async (_event, _ctx) => {
@@ -1817,6 +1761,7 @@ export default function (pi: ExtensionAPI) {
1817
1761
  }
1818
1762
  mediaGroups.clear();
1819
1763
  modelMenus.clear();
1764
+ cachedModelMenuInputs = undefined;
1820
1765
  if (activeTelegramTurn) {
1821
1766
  await clearPreview(activeTelegramTurn.chatId);
1822
1767
  }