@llblab/pi-telegram 0.19.2 → 0.20.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/lib/media.ts CHANGED
@@ -33,11 +33,36 @@ export interface TelegramRichMessage {
33
33
  blocks?: unknown[];
34
34
  }
35
35
 
36
+ export interface TelegramMessageUser {
37
+ id?: number;
38
+ is_bot?: boolean;
39
+ first_name?: string;
40
+ last_name?: string;
41
+ username?: string;
42
+ }
43
+
44
+ export interface TelegramMessageForwardOrigin {
45
+ type?: string;
46
+ sender_user?: TelegramMessageUser;
47
+ sender_user_name?: string;
48
+ sender_chat?: { title?: string; username?: string; id?: number };
49
+ chat?: { title?: string; username?: string; id?: number };
50
+ author_signature?: string;
51
+ }
52
+
36
53
  export interface TelegramReplyToMessage {
37
54
  message_id?: number;
55
+ from?: TelegramMessageUser;
38
56
  text?: string;
39
57
  caption?: string;
40
58
  rich_message?: TelegramRichMessage;
59
+ photo?: TelegramPhotoSize[];
60
+ document?: TelegramDocument;
61
+ video?: TelegramVideo;
62
+ audio?: TelegramAudio;
63
+ voice?: TelegramVoice;
64
+ animation?: TelegramAnimation;
65
+ sticker?: TelegramSticker;
41
66
  }
42
67
 
43
68
  export interface TelegramSticker {
@@ -46,6 +71,10 @@ export interface TelegramSticker {
46
71
 
47
72
  export interface TelegramMediaMessage {
48
73
  message_id: number;
74
+ from?: TelegramMessageUser;
75
+ forward_origin?: TelegramMessageForwardOrigin;
76
+ forward_from?: TelegramMessageUser;
77
+ forward_sender_name?: string;
49
78
  text?: string;
50
79
  caption?: string;
51
80
  rich_message?: TelegramRichMessage;
@@ -277,6 +306,39 @@ function truncateTelegramReplyContextText(text: string): string {
277
306
  return `${text.slice(0, TELEGRAM_REPLY_CONTEXT_MAX_LENGTH).trimEnd()}…`;
278
307
  }
279
308
 
309
+ function formatTelegramUser(user: TelegramMessageUser | undefined): string | undefined {
310
+ if (!user) return undefined;
311
+ if (user.username) return user.username;
312
+ if (typeof user.id === "number") return String(user.id);
313
+ const name = [user.first_name, user.last_name].filter(Boolean).join(" ").trim();
314
+ return name || undefined;
315
+ }
316
+
317
+ function formatTelegramForwardOriginIdentifier(
318
+ message: TelegramMediaMessage,
319
+ ): string | undefined {
320
+ const origin = message.forward_origin;
321
+ const user = origin?.sender_user ?? message.forward_from;
322
+ if (user?.username) return user.username;
323
+ if (typeof user?.id === "number") return String(user.id);
324
+ const chat = origin?.sender_chat ?? origin?.chat;
325
+ if (chat?.username) return chat.username;
326
+ if (typeof chat?.id === "number") return String(chat.id);
327
+ return origin?.sender_user_name ?? message.forward_sender_name;
328
+ }
329
+
330
+ export function extractTelegramForwardContextText(
331
+ message: TelegramMediaMessage,
332
+ allowedUserId?: number,
333
+ ): string {
334
+ const originUser = message.forward_origin?.sender_user ?? message.forward_from;
335
+ const isOwnerOrigin =
336
+ typeof allowedUserId === "number" && originUser?.id === allowedUserId;
337
+ const origin = formatTelegramForwardOriginIdentifier(message);
338
+ if (!origin || isOwnerOrigin) return "";
339
+ return `from: ${origin}`;
340
+ }
341
+
280
342
  export function extractTelegramReplyContextText(
281
343
  message: TelegramMediaMessage,
282
344
  ): string {
@@ -289,13 +351,44 @@ export function extractTelegramReplyContextText(
289
351
  return quoted ? truncateTelegramReplyContextText(quoted) : "";
290
352
  }
291
353
 
354
+ export function buildTelegramReplyContextBlock(
355
+ message: TelegramMediaMessage,
356
+ replyFiles: Pick<DownloadedTelegramFile, "path">[] = [],
357
+ ): string {
358
+ const from = formatTelegramUser(message.reply_to_message?.from);
359
+ const header = from ? `[reply|from:${from}]` : "[reply]";
360
+ const text = extractTelegramReplyContextText(message);
361
+ const dirs = [...new Set(replyFiles.map((file) => dirname(file.path)))];
362
+ const sameDir = dirs.length === 1;
363
+ const attachmentHeader = sameDir
364
+ ? `[attachments${from ? `|from:${from}` : ""}] ${dirs[0]}`
365
+ : `[attachments${from ? `|from:${from}` : ""}]`;
366
+ const fileLines = sameDir
367
+ ? replyFiles.map((file) => `- /${basename(file.path)}`)
368
+ : replyFiles.map((file) => `- ${file.path}`);
369
+ const replyBlock = text ? `${header} ${text}` : header;
370
+ if (fileLines.length > 0) {
371
+ return `${replyBlock}\n\n${attachmentHeader}\n${fileLines.join("\n")}`;
372
+ }
373
+ if (text) return replyBlock;
374
+ return "";
375
+ }
376
+
292
377
  export function appendTelegramReplyContext(
293
378
  text: string,
294
379
  replyContext: string,
295
380
  ): string {
296
381
  if (!replyContext) return text;
297
- const replyBlock = `[reply] ${replyContext}`;
298
- return text ? `${text}\n\n${replyBlock}` : `_\n\n${replyBlock}`;
382
+ return text ? `${text}\n\n${replyContext}` : `_\n\n${replyContext}`;
383
+ }
384
+
385
+ export function appendTelegramForwardContext(
386
+ text: string,
387
+ forwardContext: string,
388
+ ): string {
389
+ if (!forwardContext) return text;
390
+ const forwardBlock = `[forward|${forwardContext.replace(/:\s+/g, ":")}]`;
391
+ return text ? `\n\n${forwardBlock} ${text}` : `\n\n${forwardBlock}`;
299
392
  }
300
393
 
301
394
  export function extractTelegramMessagePromptText(
@@ -303,7 +396,7 @@ export function extractTelegramMessagePromptText(
303
396
  ): string {
304
397
  return appendTelegramReplyContext(
305
398
  extractTelegramMessageText(message),
306
- extractTelegramReplyContextText(message),
399
+ buildTelegramReplyContextBlock(message),
307
400
  );
308
401
  }
309
402
 
@@ -321,7 +414,7 @@ export function extractTelegramMessagesPromptText(
321
414
  if (!firstMessage) return text;
322
415
  return appendTelegramReplyContext(
323
416
  text,
324
- extractTelegramReplyContextText(firstMessage),
417
+ buildTelegramReplyContextBlock(firstMessage),
325
418
  );
326
419
  }
327
420
 
@@ -192,7 +192,7 @@ export function createTelegramButtonPromptTurn(options: {
192
192
  replyToMessageId: options.replyToMessageId,
193
193
  sourceMessageIds: [options.replyToMessageId],
194
194
  queueOrder: options.queueOrder,
195
- queueLane: "default",
195
+ queueLane: "priority",
196
196
  laneOrder: options.queueOrder,
197
197
  queuedAttachments: [],
198
198
  content: [{ type: "text", text: prompt }],
package/lib/outbound.ts CHANGED
@@ -6,8 +6,9 @@
6
6
 
7
7
  import { randomUUID } from "node:crypto";
8
8
  import { mkdir } from "node:fs/promises";
9
- import { homedir } from "node:os";
10
- import { join, resolve } from "node:path";
9
+ import { join } from "node:path";
10
+
11
+ import { resolveTelegramTempDir } from "./paths.ts";
11
12
 
12
13
  import {
13
14
  planTelegramButtonReply,
@@ -66,8 +67,7 @@ export function recordTelegramRuntimeEvent(
66
67
  }
67
68
 
68
69
  export type TelegramOutboundCommandTemplateConfig =
69
- | string
70
- | CommandTemplateObjectConfig;
70
+ string | CommandTemplateObjectConfig;
71
71
  export interface TelegramOutboundHandlerConfig extends CommandTemplateObjectConfig {
72
72
  type?: string;
73
73
  match?: string | string[];
@@ -448,10 +448,7 @@ function getVoiceReplyTemplateValues(
448
448
  }
449
449
 
450
450
  function getDefaultTelegramVoiceTempDir(): string {
451
- const agentDir = process.env.PI_CODING_AGENT_DIR
452
- ? resolve(process.env.PI_CODING_AGENT_DIR)
453
- : join(homedir(), ".pi", "agent");
454
- return join(agentDir, "tmp", "telegram");
451
+ return resolveTelegramTempDir();
455
452
  }
456
453
 
457
454
  async function generateTelegramVoiceReplyFileWithHandler(
package/lib/paths.ts ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Telegram bridge path resolution for Pi-compatible runtimes
3
+ * Zones: telemetry paths, filesystem, runtime identity
4
+ * Owns agent-dir detection and extension-local path derivation
5
+ *
6
+ * This domain is pure/path-only: it resolves directories and file paths
7
+ * from environment and runtime identity. It does not read config, manage
8
+ * state, or import broader Telegram domains.
9
+ */
10
+ import { homedir } from "node:os";
11
+ import { join, resolve } from "node:path";
12
+
13
+ export interface TelegramAgentDirResolutionInput {
14
+ env?: Partial<Pick<NodeJS.ProcessEnv, "PI_CODING_AGENT_DIR">>;
15
+ execPath?: string;
16
+ argv?: readonly string[];
17
+ }
18
+
19
+ /**
20
+ * Resolve the agent data directory for the current Pi-compatible runtime.
21
+ *
22
+ * Precedence:
23
+ * 1. `PI_CODING_AGENT_DIR` env variable, when explicitly set.
24
+ * 2. Detect Pi-compatible runtime identity from the executable or argv[1]
25
+ * (e.g. OMP vs standard Pi agent).
26
+ * 3. Fallback: `~/.pi/agent`.
27
+ */
28
+ export function resolveAgentDir(
29
+ input: TelegramAgentDirResolutionInput = {},
30
+ ): string {
31
+ const env = input.env ?? process.env;
32
+ if (env.PI_CODING_AGENT_DIR) return resolve(env.PI_CODING_AGENT_DIR);
33
+ const execPath = input.execPath ?? process.execPath;
34
+ const argv = input.argv ?? process.argv;
35
+ const execBasename = execPath.toLowerCase().split(/[\\/]/u).pop() ?? "";
36
+ const argv1Last = (argv[1] ?? "").toLowerCase().split(/[\\/]/u).pop() ?? "";
37
+ if (execBasename.startsWith("omp") || argv1Last.startsWith("omp")) {
38
+ return join(homedir(), ".omp", "agent");
39
+ }
40
+ return join(homedir(), ".pi", "agent");
41
+ }
42
+
43
+ /** Telegram bridge configuration file (<agentDir>/telegram.json). */
44
+ export function resolveTelegramConfigPath(): string {
45
+ return join(resolveAgentDir(), "telegram.json");
46
+ }
47
+
48
+ /** Telegram singleton lock file (<agentDir>/locks.json). */
49
+ export function resolveTelegramLocksPath(): string {
50
+ return join(resolveAgentDir(), "locks.json");
51
+ }
52
+
53
+ /** Telegram bridge temporary directory (<agentDir>/tmp/telegram). */
54
+ export function resolveTelegramTempDir(agentDir = resolveAgentDir()): string {
55
+ return join(agentDir, "tmp", "telegram");
56
+ }
57
+
58
+ export function getTelegramProfilePathSuffix(profileName?: string): string {
59
+ return profileName ? `.${profileName.replace(/[^a-zA-Z0-9._-]+/g, "_")}` : "";
60
+ }
61
+
62
+ export function resolveTelegramProfileTempFilePath(
63
+ baseName: string,
64
+ extension: string,
65
+ agentDir = resolveAgentDir(),
66
+ profileName?: string,
67
+ ): string {
68
+ return join(
69
+ resolveTelegramTempDir(agentDir),
70
+ `${baseName}${getTelegramProfilePathSuffix(profileName)}.${extension}`,
71
+ );
72
+ }
73
+
74
+ /** Runtime event log (<agentDir>/tmp/telegram/logs.jsonl). */
75
+ export function resolveTelegramRuntimeLogPath(): string {
76
+ return resolveTelegramProfileTempFilePath("logs", "jsonl");
77
+ }
@@ -116,11 +116,13 @@ export function getTelegramPromptTemplateCommands(
116
116
  if (!telegramCommand) continue;
117
117
  if (reservedNames.has(telegramCommand)) continue;
118
118
  if (seen.has(telegramCommand)) continue;
119
+ const sourcePath = command.sourceInfo?.path;
120
+ if (!sourcePath) continue;
119
121
  seen.add(telegramCommand);
120
122
  promptCommands.push({
121
123
  command: telegramCommand,
122
124
  description: command.description,
123
- path: command.sourceInfo.path,
125
+ path: sourcePath,
124
126
  });
125
127
  }
126
128
  return promptCommands.sort((a, b) => a.command.localeCompare(b.command));
package/lib/prompts.ts CHANGED
@@ -15,7 +15,7 @@ Telegram bridge available. Do not use it from local/TUI prompts unless explicitl
15
15
 
16
16
  const TELEGRAM_TURN_SYSTEM_PROMPT_SUFFIX = `
17
17
 
18
- Telegram turn note: If context was compacted or you need the pi-telegram bridge contract, call tool \`telegram_help\`.`;
18
+ Telegram turn note: If context was compacted or you need the pi-telegram bridge contract, call tool \`telegram_help\`; hidden comments are valid only for explicit \`telegram_voice\` or \`telegram_button\` actions with payload.`;
19
19
 
20
20
  const TELEGRAM_HELP_TEXT = `--- TELEGRAM BRIDGE HELP ---
21
21
 
package/lib/queue.ts CHANGED
@@ -273,6 +273,37 @@ export function appendTelegramQueueItem<
273
273
  return [...items, item];
274
274
  }
275
275
 
276
+ function getTelegramPromptTextSignature(item: PendingTelegramTurn): string {
277
+ return item.content
278
+ .filter((entry): entry is TelegramPromptTextContent => entry.type === "text")
279
+ .map((entry) => entry.text)
280
+ .join("\n");
281
+ }
282
+
283
+ function isDuplicateTelegramPromptTurn(
284
+ left: PendingTelegramTurn,
285
+ right: PendingTelegramTurn,
286
+ ): boolean {
287
+ return (
288
+ left.chatId === right.chatId &&
289
+ left.target?.threadId === right.target?.threadId &&
290
+ left.replyToMessageId === right.replyToMessageId &&
291
+ getTelegramPromptTextSignature(left) === getTelegramPromptTextSignature(right)
292
+ );
293
+ }
294
+
295
+ export function appendTelegramPromptTurnOnce<TContext = unknown>(
296
+ items: TelegramQueueItem<TContext>[],
297
+ turn: PendingTelegramTurn,
298
+ ): { items: TelegramQueueItem<TContext>[]; appended: boolean } {
299
+ assertTelegramQueueItemAdmissionValid(turn);
300
+ const duplicate = items.some(
301
+ (item) => isPendingTelegramTurn(item) && isDuplicateTelegramPromptTurn(item, turn),
302
+ );
303
+ if (duplicate) return { items, appended: false };
304
+ return { items: [...items, turn], appended: true };
305
+ }
306
+
276
307
  export function compareTelegramQueueItems<TContext = unknown>(
277
308
  left: TelegramQueueItem<TContext>,
278
309
  right: TelegramQueueItem<TContext>,
@@ -2051,7 +2082,7 @@ export function executeTelegramQueueDispatchPlan<TContext = unknown>(
2051
2082
  }
2052
2083
  deps.onPromptDispatchStart(plan.item.chatId);
2053
2084
  try {
2054
- deps.sendUserMessage(plan.item.content, TELEGRAM_PROMPT_FOLLOW_UP_DELIVERY);
2085
+ deps.sendUserMessage(plan.item.content);
2055
2086
  } catch (error) {
2056
2087
  const message = getTelegramQueueErrorMessage(error);
2057
2088
  deps.onPromptDispatchFailure(message);
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,