@llblab/pi-telegram 0.19.3 → 0.20.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/lib/routing.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { readFile } from "node:fs/promises";
8
+ import { basename, dirname } from "node:path";
8
9
  import * as Commands from "./commands.ts";
9
10
  import type { TelegramConfigStore } from "./config.ts";
10
11
  import type { TelegramSectionRegistry } from "./sections.ts";
@@ -20,6 +21,31 @@ import * as TextGroups from "./text-groups.ts";
20
21
  import * as ThreadReconciler from "./thread-reconciler.ts";
21
22
  import * as Turns from "./turns.ts";
22
23
 
24
+ function formatTelegramPromptPeer(user: { id?: unknown; username?: unknown } | undefined): string | undefined {
25
+ if (!user) return undefined;
26
+ if (typeof user.username === "string" && user.username.length > 0) return user.username;
27
+ return typeof user.id === "number" ? String(user.id) : undefined;
28
+ }
29
+
30
+ function appendTelegramSourceAttachmentSection(
31
+ text: string,
32
+ from: string | undefined,
33
+ files: Pick<Media.DownloadedTelegramFile, "path">[],
34
+ ): string {
35
+ if (files.length === 0) return text;
36
+ const dirs = [...new Set(files.map((file) => dirname(file.path)))];
37
+ const sameDir = dirs.length === 1;
38
+ const source = from ? `|from:${from}` : "";
39
+ const header = sameDir
40
+ ? `[attachments${source}] ${dirs[0]}`
41
+ : `[attachments${source}]`;
42
+ const items = sameDir
43
+ ? files.map((file) => `/${basename(file.path)}`)
44
+ : files.map((file) => file.path);
45
+ const prefix = text.length > 0 ? `${text}\n\n` : "";
46
+ return `${prefix}${header}\n${items.map((item) => `- ${item}`).join("\n")}`;
47
+ }
48
+
23
49
  function getContextCwd(ctx: unknown): string | undefined {
24
50
  if (!ctx || typeof ctx !== "object") return undefined;
25
51
  const cwd = (ctx as { cwd?: unknown }).cwd;
@@ -1248,22 +1274,25 @@ export function createTelegramInboundRouteRuntime<
1248
1274
  if (typeof chatId !== "number" || typeof messageId !== "number")
1249
1275
  return;
1250
1276
  const queueOrder = deps.bridgeRuntime.queue.allocateItemOrder();
1251
- deps.queueMutationRuntime.append(
1252
- OutboundHandlers.createTelegramButtonPromptTurn({
1253
- chatId,
1254
- target:
1255
- typeof buttonQuery.message?.message_thread_id === "number"
1256
- ? {
1257
- chatId,
1258
- threadId: buttonQuery.message.message_thread_id,
1259
- }
1260
- : { chatId },
1261
- replyToMessageId: messageId,
1262
- queueOrder,
1263
- action,
1264
- }),
1265
- context,
1277
+ const turn = OutboundHandlers.createTelegramButtonPromptTurn({
1278
+ chatId,
1279
+ target:
1280
+ typeof buttonQuery.message?.message_thread_id === "number"
1281
+ ? {
1282
+ chatId,
1283
+ threadId: buttonQuery.message.message_thread_id,
1284
+ }
1285
+ : { chatId },
1286
+ replyToMessageId: messageId,
1287
+ queueOrder,
1288
+ action,
1289
+ });
1290
+ const result = Queue.appendTelegramPromptTurnOnce(
1291
+ deps.telegramQueueStore.getQueuedItems(),
1292
+ turn,
1266
1293
  );
1294
+ if (!result.appended) return;
1295
+ deps.telegramQueueStore.setQueuedItems(result.items);
1267
1296
  deps.updateStatus(context);
1268
1297
  requestDispatchNextQueuedTelegramTurn(context);
1269
1298
  },
@@ -1323,15 +1352,39 @@ export function createTelegramInboundRouteRuntime<
1323
1352
  );
1324
1353
  if (handledBySettings) return;
1325
1354
  const callbackData = query.data;
1326
- if (
1327
- deps.sendUserMessage &&
1328
- callbackData &&
1329
- !isTelegramOwnedCallbackData(callbackData)
1330
- ) {
1331
- deps.sendUserMessage(
1332
- `[callback] ${callbackData}`,
1333
- Queue.TELEGRAM_PROMPT_FOLLOW_UP_DELIVERY,
1334
- );
1355
+ if (callbackData && !isTelegramOwnedCallbackData(callbackData)) {
1356
+ const chatId = query.message?.chat?.id;
1357
+ const messageId = query.message?.message_id;
1358
+ if (typeof chatId === "number" && typeof messageId === "number") {
1359
+ const queueOrder = deps.bridgeRuntime.queue.allocateItemOrder();
1360
+ const target =
1361
+ typeof query.message?.message_thread_id === "number"
1362
+ ? { chatId, threadId: query.message.message_thread_id }
1363
+ : { chatId };
1364
+ const turn: Queue.PendingTelegramTurn = {
1365
+ kind: "prompt",
1366
+ chatId,
1367
+ target,
1368
+ replyToMessageId: messageId,
1369
+ sourceMessageIds: [messageId],
1370
+ queueOrder,
1371
+ queueLane: "priority",
1372
+ laneOrder: queueOrder,
1373
+ queuedAttachments: [],
1374
+ content: [{ type: "text", text: `[callback] ${callbackData}` }],
1375
+ historyText: callbackData,
1376
+ statusSummary: callbackData,
1377
+ };
1378
+ const result = Queue.appendTelegramPromptTurnOnce(
1379
+ deps.telegramQueueStore.getQueuedItems(),
1380
+ turn,
1381
+ );
1382
+ if (result.appended) {
1383
+ deps.telegramQueueStore.setQueuedItems(result.items);
1384
+ deps.updateStatus(ctx);
1385
+ requestDispatchNextQueuedTelegramTurn(ctx);
1386
+ }
1387
+ }
1335
1388
  await deps.answerCallbackQuery(query.id);
1336
1389
  return;
1337
1390
  }
@@ -1345,6 +1398,7 @@ export function createTelegramInboundRouteRuntime<
1345
1398
  downloadFile: deps.downloadFile,
1346
1399
  processAttachments: deps.inboundHandlerRuntime.process,
1347
1400
  resolveTimeLine: deps.resolveTimeLine,
1401
+ getAllowedUserId: deps.configStore.getAllowedUserId,
1348
1402
 
1349
1403
  // Voice policy for the current turn. Missing config still behaves as manual,
1350
1404
  // but only explicit telegram.json voice.replyMode is shown in prompt context.
@@ -1757,30 +1811,37 @@ export function createTelegramInboundRouteRuntime<
1757
1811
  const text = guestMessage.text ?? "";
1758
1812
  const gm = guestMessage as unknown as Record<string, unknown>;
1759
1813
  // Build telegram prefix with guest context
1760
- const fromRaw = gm.from as Record<string, unknown> | undefined;
1761
- const fromName =
1762
- (fromRaw?.username as string) || (fromRaw?.first_name as string) || "";
1763
1814
  const chatRaw = gm.chat as Record<string, unknown>;
1764
1815
  const chatTitle = chatRaw?.title as string | undefined;
1765
1816
  const chatType = chatRaw?.type as string;
1817
+ const fromRaw = gm.from as Record<string, unknown> | undefined;
1818
+ const replyMsg = gm.reply_to_message as Record<string, unknown> | undefined;
1819
+ const replyFromRaw = replyMsg?.from as Record<string, unknown> | undefined;
1820
+ const fromPeer = formatTelegramPromptPeer(fromRaw);
1821
+ const replyPeer = formatTelegramPromptPeer(replyFromRaw);
1822
+ const fromIsOwner = fromRaw?.id === deps.configStore.getAllowedUserId();
1823
+ const guestPeer = chatType === "private" && fromIsOwner && replyPeer
1824
+ ? replyPeer
1825
+ : fromPeer;
1766
1826
  const prefixParts = ["telegram"];
1767
- if (fromName) prefixParts.push(`from:${fromName}`);
1768
1827
  if (chatType !== "private" && chatTitle) {
1769
1828
  prefixParts.push(`guest:${chatTitle}`);
1829
+ } else if (chatType === "private" && guestPeer) {
1830
+ prefixParts.push(`guest:${guestPeer}`);
1770
1831
  }
1771
1832
  const telegramPrefix = `[${prefixParts.join("|")}]`;
1772
1833
  // Extract reply context
1773
- const replyMsg = gm.reply_to_message as Record<string, unknown> | undefined;
1774
1834
  const replyText = replyMsg
1775
1835
  ? ((replyMsg.text as string) || (replyMsg.caption as string) || "").trim()
1776
1836
  : "";
1777
- const replyFrom = replyMsg
1778
- ? ((replyMsg.from as Record<string, unknown> | undefined)?.username as
1779
- | string
1780
- | undefined)
1781
- : undefined;
1782
1837
  // Download files, run inbound handlers
1783
1838
  const guestMsg = guestMessage as unknown as Media.TelegramMediaMessage;
1839
+ const replyFiles = guestMsg.reply_to_message
1840
+ ? await Media.downloadTelegramMessageFiles(
1841
+ [guestMsg.reply_to_message as Media.TelegramMediaMessage],
1842
+ { downloadFile: deps.downloadFile },
1843
+ )
1844
+ : [];
1784
1845
  const files = await Media.downloadTelegramMessageFiles([guestMsg], {
1785
1846
  downloadFile: deps.downloadFile,
1786
1847
  });
@@ -1789,13 +1850,16 @@ export function createTelegramInboundRouteRuntime<
1789
1850
  text,
1790
1851
  ctx,
1791
1852
  );
1792
- let rawText = processed.rawText || text;
1793
- // Append reply context after handler processing
1794
- if (replyText) {
1795
- const replyBlock = replyFrom
1796
- ? `[reply|from:${replyFrom}] ${replyText}`
1797
- : `[reply] ${replyText}`;
1798
- rawText = `${rawText}\n\n${replyBlock}`;
1853
+ const rawText = processed.rawText || text;
1854
+ let sourceContext = "";
1855
+ if (replyMsg) {
1856
+ const replyHeader = replyPeer ? `[reply|from:${replyPeer}]` : "[reply]";
1857
+ const replyBlock = replyText ? `${replyHeader} ${replyText}` : replyHeader;
1858
+ sourceContext = appendTelegramSourceAttachmentSection(
1859
+ replyBlock,
1860
+ replyPeer,
1861
+ replyFiles,
1862
+ );
1799
1863
  }
1800
1864
  const promptText = Turns.buildTelegramTurnPrompt({
1801
1865
  telegramPrefix,
@@ -1803,6 +1867,7 @@ export function createTelegramInboundRouteRuntime<
1803
1867
  files,
1804
1868
  promptFiles: processed.promptFiles,
1805
1869
  handlerOutputs: processed.handlerOutputs,
1870
+ sourceContext,
1806
1871
  });
1807
1872
  const order = deps.bridgeRuntime.queue.allocateItemOrder();
1808
1873
  const content: Queue.TelegramPromptContent[] = [
package/lib/status.ts CHANGED
@@ -180,6 +180,7 @@ export type TelegramBridgeBusLifecyclePhase = "electing";
180
180
  export interface TelegramBridgeStatusLineState {
181
181
  hasBotToken?: boolean;
182
182
  botUsername?: string;
183
+ activeProfileName?: string;
183
184
  allowedUserId?: number;
184
185
  botThreadMode?: "unknown" | "enabled" | "disabled";
185
186
  botThreadModeUpdatedAtMs?: number;
@@ -257,6 +258,7 @@ export interface TelegramBridgeStatusRuntimeDeps<
257
258
  > {
258
259
  statusKey?: string;
259
260
  getConfig: () => TelegramBridgeStatusConfig;
261
+ getActiveProfileName?: () => string | undefined;
260
262
  isPollingActive: () => boolean;
261
263
  getActiveSourceMessageIds: () => number[] | undefined;
262
264
  hasActiveTurn: () => boolean;
@@ -621,6 +623,7 @@ export function createTelegramBridgeStatusRuntime<
621
623
  return {
622
624
  hasBotToken: Boolean(config.botToken),
623
625
  botUsername: config.botUsername,
626
+ activeProfileName: deps.getActiveProfileName?.(),
624
627
  allowedUserId: config.allowedUserId,
625
628
  botThreadMode: botThreadMode?.threadMode,
626
629
  botThreadModeUpdatedAtMs: botThreadMode?.updatedAtMs,
@@ -770,7 +773,7 @@ export function buildTelegramStatusBarText(
770
773
  if (state.busLifecyclePhase === "electing")
771
774
  return `${label} ${theme.fg("warning", "electing")}${queued}`;
772
775
  if (!state.pollingActive && state.busRole !== "follower")
773
- return `${label} ${theme.fg("muted", "disconnected")}${queued}`;
776
+ return `${theme.fg("accent", "telegram")} ${theme.fg("muted", "disconnected")}${queued}`;
774
777
  if (state.compactionInProgress) {
775
778
  return `${label} ${theme.fg("warning", "compacting")}${queued}`;
776
779
  }
@@ -1065,6 +1068,7 @@ function buildTelegramBridgeCompactStatusLines(
1065
1068
  return [
1066
1069
  "connection:",
1067
1070
  `- bot: ${formatTelegramBridgeBotStatus(state)}`,
1071
+ ...(state.activeProfileName ? [`- profile: ${state.activeProfileName}`] : []),
1068
1072
  `- user: ${state.allowedUserId ?? "not paired"}`,
1069
1073
  ...(state.botThreadMode ? [`- thread mode: ${state.botThreadMode}`] : []),
1070
1074
  ...(state.busRole ? [`- role: ${state.busRole}`] : []),
@@ -1119,6 +1123,7 @@ export function buildTelegramBridgeDiagnosticStatusLines(
1119
1123
  return [
1120
1124
  "connection:",
1121
1125
  `- bot: ${formatTelegramBridgeBotStatus(state)}`,
1126
+ ...(state.activeProfileName ? [`- profile: ${state.activeProfileName}`] : []),
1122
1127
  `- allowed user: ${state.allowedUserId ?? "not paired"}`,
1123
1128
  ...(state.botThreadMode
1124
1129
  ? [
package/lib/sync.ts CHANGED
@@ -24,6 +24,7 @@ export interface TelegramLeaderThreadSyncDeps {
24
24
  getAllowedUserId: () => number | undefined;
25
25
  instanceId: string;
26
26
  cwd?: string;
27
+ telegramProfile?: string;
27
28
  forceFreshUnnamed?: boolean;
28
29
  getNowMs?: () => number;
29
30
  getRandom?: () => number;
@@ -110,7 +111,7 @@ export interface TelegramLeaderHealthRuntime {
110
111
  export interface TelegramManualThreadDisconnectDeps<TSyncState> {
111
112
  instanceId: string;
112
113
  getCurrentThreadRecord: () =>
113
- | { target: TelegramTarget; instanceId?: string }
114
+ | { target: TelegramTarget; instanceId?: string; owner?: { kind?: string } }
114
115
  | undefined;
115
116
  topicTargetStore: Pick<
116
117
  TelegramTopicTargetStore,
@@ -160,23 +161,26 @@ export function createTelegramManualThreadDisconnectHandler<
160
161
  return async () => {
161
162
  const currentRecord = deps.getCurrentThreadRecord();
162
163
  if (currentRecord?.target.threadId) {
163
- await ThreadReconciler.applyThreadReconciliationPlan(
164
- ThreadReconciler.planDisconnectedInstanceThreadCleanup({
165
- target: currentRecord.target as TelegramTarget & { threadId: number },
166
- instanceId: deps.instanceId,
167
- }),
168
- {
169
- callApi(method, body) {
170
- return deps.callApi(method, body);
171
- },
172
- persist() {
173
- return deps.topicTargetStore.persist();
164
+ const isManualFollower = currentRecord.owner?.kind === "manual-follower";
165
+ if (!isManualFollower) {
166
+ await ThreadReconciler.applyThreadReconciliationPlan(
167
+ ThreadReconciler.planDisconnectedInstanceThreadCleanup({
168
+ target: currentRecord.target as TelegramTarget & { threadId: number },
169
+ instanceId: deps.instanceId,
170
+ }),
171
+ {
172
+ callApi(method, body) {
173
+ return deps.callApi(method, body);
174
+ },
175
+ persist() {
176
+ return deps.topicTargetStore.persist();
177
+ },
178
+ recordRuntimeEvent: deps.recordRuntimeEvent,
174
179
  },
175
- recordRuntimeEvent: deps.recordRuntimeEvent,
176
- },
177
- );
178
- if (deps.topicTargetStore.markOfflineByInstanceId(deps.instanceId) > 0) {
179
- await deps.topicTargetStore.persist();
180
+ );
181
+ if (deps.topicTargetStore.markOfflineByInstanceId(deps.instanceId) > 0) {
182
+ await deps.topicTargetStore.persist();
183
+ }
180
184
  }
181
185
  const leaderTarget = deps.getLeaderTarget();
182
186
  if (
@@ -360,6 +364,7 @@ export async function ensureTelegramLeaderThreadBinding(
360
364
  getAllowedUserId: deps.getAllowedUserId,
361
365
  instanceId: deps.instanceId,
362
366
  cwd: deps.cwd,
367
+ telegramProfile: deps.telegramProfile,
363
368
  getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
364
369
  getThreadReconciliationMachineState:
365
370
  deps.getThreadReconciliationMachineState,
@@ -10,8 +10,8 @@ import { randomUUID } from "node:crypto";
10
10
  import { createWriteStream, openAsBlob } from "node:fs";
11
11
  import { mkdir, readdir, stat, unlink, writeFile } from "node:fs/promises";
12
12
  import { request as requestHttps } from "node:https";
13
- import { homedir } from "node:os";
14
- import { join, resolve } from "node:path";
13
+ import { join } from "node:path";
14
+ import { resolveTelegramTempDir } from "./paths.ts";
15
15
  import { Readable, Transform } from "node:stream";
16
16
  import { pipeline } from "node:stream/promises";
17
17
 
@@ -34,10 +34,7 @@ export function getTelegramInboundFileByteLimitFromEnv(
34
34
  }
35
35
 
36
36
  function getTelegramApiTempDir(): string {
37
- const agentDir = process.env.PI_CODING_AGENT_DIR
38
- ? resolve(process.env.PI_CODING_AGENT_DIR)
39
- : join(homedir(), ".pi", "agent");
40
- return join(agentDir, "tmp", "telegram");
37
+ return resolveTelegramTempDir();
41
38
  }
42
39
  const TELEGRAM_TEMP_FILE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
43
40
  const TELEGRAM_INBOUND_FILE_MAX_BYTES = getTelegramInboundFileByteLimitFromEnv(
@@ -47,10 +44,7 @@ const TELEGRAM_INBOUND_FILE_MAX_BYTES = getTelegramInboundFileByteLimitFromEnv(
47
44
  );
48
45
 
49
46
  export type TelegramNetworkFamilyPolicy =
50
- | "auto"
51
- | "ipv4"
52
- | "ipv6"
53
- | "ipv4-fallback";
47
+ "auto" | "ipv4" | "ipv6" | "ipv4-fallback";
54
48
 
55
49
  const TELEGRAM_NETWORK_FAMILY_ENV = "PI_TELEGRAM_NETWORK_FAMILY";
56
50
  const TELEGRAM_NETWORK_FAMILY_VALUES = new Set<TelegramNetworkFamilyPolicy>([
@@ -1123,10 +1117,15 @@ export function createTelegramAssistantDraftSender(deps: {
1123
1117
  if (text === undefined || deps.getAssistantRenderingMode() === "rich") {
1124
1118
  return sendNativeDraft(chatId, draftId, text, options);
1125
1119
  }
1126
- return deps.sendMessageDraft(chatId, draftId, deps.renderMarkdownToHtmlDraft(text), {
1127
- ...options,
1128
- parse_mode: "HTML",
1129
- });
1120
+ return deps.sendMessageDraft(
1121
+ chatId,
1122
+ draftId,
1123
+ deps.renderMarkdownToHtmlDraft(text),
1124
+ {
1125
+ ...options,
1126
+ parse_mode: "HTML",
1127
+ },
1128
+ );
1130
1129
  };
1131
1130
  }
1132
1131