@llblab/pi-telegram 0.22.0 → 0.23.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/threads.ts CHANGED
@@ -44,7 +44,13 @@ export interface TelegramThreadNameInput {
44
44
  }
45
45
 
46
46
  export type TelegramTopicTargetStatus =
47
- "active" | "offline" | "stale" | "pending" | "starting" | "failed";
47
+ | "active"
48
+ | "offline"
49
+ | "stale"
50
+ | "pending"
51
+ | "starting"
52
+ | "probe-required"
53
+ | "failed";
48
54
 
49
55
  export type TelegramTopicSyncStatus = "open" | "closed" | "deleted" | "unknown";
50
56
 
@@ -438,6 +444,60 @@ export function getTelegramTopicTargetsPath(
438
444
  return getTelegramStatePath(agentDir, profileName);
439
445
  }
440
446
 
447
+ const TELEGRAM_LEADER_SESSION_HANDOFF_KEY =
448
+ "__piTelegramLeaderSessionHandoff";
449
+ export const TELEGRAM_LEADER_SESSION_HANDOFF_TTL_MS = 30_000;
450
+
451
+ export interface TelegramLeaderSessionHandoff {
452
+ pid: number;
453
+ instanceId: string;
454
+ createdAtMs: number;
455
+ profileKey: string;
456
+ target: TelegramTarget & { threadId: number };
457
+ slot?: string;
458
+ threadName?: string;
459
+ }
460
+
461
+ export function getTelegramLeaderSessionHandoff():
462
+ | TelegramLeaderSessionHandoff
463
+ | undefined {
464
+ const value = (globalThis as Record<string, unknown>)[
465
+ TELEGRAM_LEADER_SESSION_HANDOFF_KEY
466
+ ];
467
+ if (!value || typeof value !== "object") return undefined;
468
+ const handoff = value as Partial<TelegramLeaderSessionHandoff>;
469
+ if (
470
+ typeof handoff.pid !== "number" ||
471
+ typeof handoff.instanceId !== "string" ||
472
+ typeof handoff.createdAtMs !== "number" ||
473
+ typeof handoff.profileKey !== "string" ||
474
+ typeof handoff.target?.chatId !== "number" ||
475
+ typeof handoff.target.threadId !== "number"
476
+ ) {
477
+ return undefined;
478
+ }
479
+ return handoff as TelegramLeaderSessionHandoff;
480
+ }
481
+
482
+ export function setTelegramLeaderSessionHandoff(
483
+ handoff: TelegramLeaderSessionHandoff | undefined,
484
+ ): void {
485
+ const store = globalThis as Record<string, unknown>;
486
+ if (!handoff) delete store[TELEGRAM_LEADER_SESSION_HANDOFF_KEY];
487
+ else store[TELEGRAM_LEADER_SESSION_HANDOFF_KEY] = handoff;
488
+ }
489
+
490
+ export function isTelegramLeaderSessionHandoffFresh(
491
+ handoff: TelegramLeaderSessionHandoff | undefined,
492
+ options: { pid?: number; nowMs?: number; ttlMs?: number } = {},
493
+ ): handoff is TelegramLeaderSessionHandoff {
494
+ if (!handoff) return false;
495
+ const pid = options.pid ?? process.pid;
496
+ const nowMs = options.nowMs ?? Date.now();
497
+ const ttlMs = options.ttlMs ?? TELEGRAM_LEADER_SESSION_HANDOFF_TTL_MS;
498
+ return handoff.pid === pid && nowMs - handoff.createdAtMs <= ttlMs;
499
+ }
500
+
441
501
  export function getTelegramThreadOwnerKey(owner: TelegramThreadOwner): string {
442
502
  switch (owner.kind) {
443
503
  case "leader": {
@@ -593,6 +653,7 @@ function normalizeRecord(
593
653
  status !== "stale" &&
594
654
  status !== "pending" &&
595
655
  status !== "starting" &&
656
+ status !== "probe-required" &&
596
657
  status !== "failed"
597
658
  )
598
659
  return undefined;
@@ -655,6 +716,10 @@ function isCurrentThreadRecord(record: TelegramTopicTargetRecord): boolean {
655
716
  );
656
717
  }
657
718
 
719
+ function isPersistedThreadRecord(record: TelegramTopicTargetRecord): boolean {
720
+ return isCurrentThreadRecord(record) || record.status === "probe-required";
721
+ }
722
+
658
723
  function normalizeIdentityRecord(
659
724
  value: unknown,
660
725
  ): TelegramThreadIdentityRecord | undefined {
@@ -882,7 +947,7 @@ function parseTopicTargetFile(value: unknown): TelegramTopicTargetFile {
882
947
  .map((record) => normalizeRecord(record))
883
948
  .filter(
884
949
  (record): record is TelegramTopicTargetRecord =>
885
- !!record && isCurrentThreadRecord(record),
950
+ !!record && isPersistedThreadRecord(record),
886
951
  );
887
952
  return {
888
953
  version: 1,
@@ -1172,7 +1237,7 @@ export function createTelegramTopicTargetStore(
1172
1237
  isPendingProvisionLiveOrTargeted(provision, nowMs),
1173
1238
  );
1174
1239
  const currentRecords = Array.from(records.values())
1175
- .filter(isCurrentThreadRecord)
1240
+ .filter(isPersistedThreadRecord)
1176
1241
  .map(cloneRecord);
1177
1242
  records = new Map(
1178
1243
  currentRecords.map((record) => [
@@ -1411,7 +1476,7 @@ export function createTelegramTopicTargetStore(
1411
1476
  records.delete(getRecordOwnerKey(existing));
1412
1477
  }
1413
1478
  }
1414
- if (!isCurrentThreadRecord(next)) {
1479
+ if (!isPersistedThreadRecord(next)) {
1415
1480
  rememberIdentity(next);
1416
1481
  records.delete(nextOwnerKey);
1417
1482
  markDirty();
@@ -1996,6 +2061,51 @@ export async function provisionOwnBusTopic(
1996
2061
  instanceId: deps.instanceId,
1997
2062
  ...(deps.telegramProfile ? { telegramProfile: deps.telegramProfile } : {}),
1998
2063
  };
2064
+ const leaderSessionHandoff = getTelegramLeaderSessionHandoff();
2065
+ if (
2066
+ isTelegramLeaderSessionHandoffFresh(leaderSessionHandoff) &&
2067
+ leaderSessionHandoff.profileKey === profileKey
2068
+ ) {
2069
+ const existingHandoffRecord = deps.store
2070
+ .list()
2071
+ .find((record) => targetMatches(record.target, leaderSessionHandoff.target));
2072
+ deps.store.upsert({
2073
+ profileKey,
2074
+ owner: currentLeaderOwner,
2075
+ target: { ...leaderSessionHandoff.target },
2076
+ status: "active",
2077
+ createdAtMs: existingHandoffRecord?.createdAtMs ?? leaderSessionHandoff.createdAtMs,
2078
+ updatedAtMs: nowMs,
2079
+ threadName:
2080
+ existingHandoffRecord?.threadName ?? leaderSessionHandoff.threadName,
2081
+ instanceId: deps.instanceId,
2082
+ slot: existingHandoffRecord?.slot ?? leaderSessionHandoff.slot,
2083
+ ...(existingHandoffRecord?.syncStatus
2084
+ ? { syncStatus: existingHandoffRecord.syncStatus }
2085
+ : {}),
2086
+ ...(existingHandoffRecord?.lastSyncObservedAtMs !== undefined
2087
+ ? {
2088
+ lastSyncObservedAtMs:
2089
+ existingHandoffRecord.lastSyncObservedAtMs,
2090
+ }
2091
+ : {}),
2092
+ lastReconcileAction: "leader-session-handoff-restored",
2093
+ });
2094
+ await deps.store.persist();
2095
+ setTelegramLeaderSessionHandoff(undefined);
2096
+ deps.recordEvent("bus", "Bus leader session handoff restored", {
2097
+ phase: "leader-session-handoff-restore",
2098
+ chatId: leaderSessionHandoff.target.chatId,
2099
+ threadId: leaderSessionHandoff.target.threadId,
2100
+ slot: existingHandoffRecord?.slot ?? leaderSessionHandoff.slot,
2101
+ threadName:
2102
+ existingHandoffRecord?.threadName ?? leaderSessionHandoff.threadName,
2103
+ previousInstanceId: leaderSessionHandoff.instanceId,
2104
+ instanceId: deps.instanceId,
2105
+ });
2106
+ } else if (leaderSessionHandoff) {
2107
+ setTelegramLeaderSessionHandoff(undefined);
2108
+ }
1999
2109
  const recordsBeforePreviousLeaderCleanup = deps.store.list();
2000
2110
  const previousLeaderCleanupPlan = ThreadReconciler.planThreadReconciliation({
2001
2111
  nowMs,
@@ -2051,7 +2161,7 @@ export async function provisionOwnBusTopic(
2051
2161
  continue;
2052
2162
  }
2053
2163
  const previousLeaderCleanupStartedAtMs = Date.now();
2054
- await ThreadReconciler.applyThreadReconciliationPlan(
2164
+ const cleanup = await ThreadReconciler.applyThreadReconciliationPlan(
2055
2165
  { actions: [action] },
2056
2166
  {
2057
2167
  callApi: deps.callApi,
@@ -2097,6 +2207,11 @@ export async function provisionOwnBusTopic(
2097
2207
  "Telegram leader ownership changed during topic reconciliation.",
2098
2208
  );
2099
2209
  }
2210
+ if (cleanup.incompleteActions?.length) {
2211
+ throw new Error(
2212
+ "Previous Telegram leader topic deletion was not confirmed.",
2213
+ );
2214
+ }
2100
2215
  deps.store.markStaleByTarget(record.target);
2101
2216
  deps.store.reserveThread({
2102
2217
  target: record.target,
@@ -2256,6 +2371,7 @@ export interface TelegramCurrentInstanceThreadRuntime {
2256
2371
  findRecord(): TelegramTopicTargetRecord | undefined;
2257
2372
  getRecord(): TelegramTopicTargetRecord | undefined;
2258
2373
  getIdentity(target?: TelegramTarget): TelegramInstanceThreadIdentityCandidate;
2374
+ getRestorationIdentity(): TelegramInstanceThreadIdentityCandidate;
2259
2375
  }
2260
2376
 
2261
2377
  export function createTelegramCurrentInstanceThreadRuntime(deps: {
@@ -2301,6 +2417,14 @@ export function createTelegramCurrentInstanceThreadRuntime(deps: {
2301
2417
  record,
2302
2418
  });
2303
2419
  },
2420
+ getRestorationIdentity() {
2421
+ const follower = deps.getFollower();
2422
+ return resolveTelegramInstanceThreadIdentity({
2423
+ follower: follower?.registered ? follower : undefined,
2424
+ leader: deps.getLeader(),
2425
+ record: findRecord(),
2426
+ });
2427
+ },
2304
2428
  };
2305
2429
  }
2306
2430
 
@@ -2657,25 +2781,9 @@ export function createTelegramTopicTargetProvisioner(
2657
2781
  };
2658
2782
  assertLeaderEpoch("start");
2659
2783
  normalizeCurrentThreadNameSlots(deps.store);
2660
- let existing = deps.store.getByProfileKey(request.profileKey);
2784
+ const existing = deps.store.getByProfileKey(request.profileKey);
2661
2785
  const isManualFollowerRequest = request.owner?.kind === "manual-follower";
2662
- if (
2663
- isManualFollowerRequest &&
2664
- existing &&
2665
- isCurrentThreadRecord(existing)
2666
- ) {
2667
- deps.store.markStaleByTarget(
2668
- existing.target,
2669
- "unknown",
2670
- "Manual follower runtime was replaced before reconnect.",
2671
- );
2672
- deps.store.forgetIdentityByProfileKey(request.profileKey);
2673
- existing = undefined;
2674
- }
2675
- const identity =
2676
- isManualFollowerRequest && !existing
2677
- ? undefined
2678
- : deps.store.getIdentityByProfileKey(request.profileKey);
2786
+ const identity = deps.store.getIdentityByProfileKey(request.profileKey);
2679
2787
  const nowMs = getNowMs();
2680
2788
  if (existing && isCurrentThreadRecord(existing)) {
2681
2789
  const slot = existing.slot ?? deps.store.allocateSlot(request.profileKey);
package/lib/turns.ts CHANGED
@@ -9,9 +9,11 @@ import { basename, dirname, join } from "node:path";
9
9
 
10
10
  import {
11
11
  buildTelegramReplyContextBlock,
12
+ collectTelegramFileInfos,
12
13
  collectTelegramMessageIds,
13
14
  downloadTelegramMessageFiles,
14
15
  extractTelegramForwardContextText,
16
+ extractTelegramMessageText,
15
17
  extractTelegramMessagesPromptText,
16
18
  extractTelegramMessagesText,
17
19
  formatTelegramHistoryText,
@@ -132,11 +134,36 @@ function appendTelegramAttachmentSection(
132
134
  return `${prefix}${header}\n${items.map((item) => `- ${item}`).join("\n")}`;
133
135
  }
134
136
 
135
- function appendTelegramSourceContext(text: string, sourceContext: string | undefined): string {
137
+ function appendTelegramSourceContext(
138
+ text: string,
139
+ sourceContext: string | undefined,
140
+ ): string {
136
141
  if (!sourceContext) return text;
137
142
  return text ? `${text}\n\n${sourceContext}` : sourceContext;
138
143
  }
139
144
 
145
+
146
+ function buildTelegramForwardContextBlock(options: {
147
+ context: string;
148
+ text: string;
149
+ files: DownloadedTelegramTurnFile[];
150
+ }): string {
151
+ const metadata = options.context.replace(/:\s+/g, ":");
152
+ const from = metadata.match(/^from:(.+)$/)?.[1];
153
+ let block = `[forward|${metadata}]${options.text ? ` ${options.text}` : ""}`;
154
+ if (options.files.length === 0) return block;
155
+ const dirs = [...new Set(options.files.map((file) => dirname(file.path)))];
156
+ const sameDir = dirs.length === 1;
157
+ const header = `[attachments${from ? `|from:${from}` : ""}]${
158
+ sameDir ? ` ${dirs[0]}` : ""
159
+ }`;
160
+ const items = sameDir
161
+ ? options.files.map((file) => `/${basename(file.path)}`)
162
+ : options.files.map((file) => file.path);
163
+ block += `\n\n${header}\n${items.map((item) => `- ${item}`).join("\n")}`;
164
+ return block;
165
+ }
166
+
140
167
  function appendTelegramPromptText(prompt: string, rawText: string): string {
141
168
  if (!rawText) return prompt;
142
169
  if (rawText.startsWith("\n")) return `${prompt}${rawText}`;
@@ -165,6 +192,7 @@ export function buildTelegramTurnPrompt(options: {
165
192
  rawText: string;
166
193
  files: DownloadedTelegramTurnFile[];
167
194
  promptFiles?: DownloadedTelegramTurnFile[];
195
+ displayFiles?: DownloadedTelegramTurnFile[];
168
196
  handlerOutputs?: string[];
169
197
  sourceContext?: string;
170
198
  historyTurns?: Pick<PendingTelegramTurn, "historyText">[];
@@ -186,14 +214,15 @@ export function buildTelegramTurnPrompt(options: {
186
214
  ? `${prompt}\n${options.rawText}`
187
215
  : appendTelegramPromptText(prompt, options.rawText);
188
216
  }
189
- const promptFiles = options.promptFiles ?? options.files;
190
- prompt = appendTelegramAttachmentSection(prompt, promptFiles);
191
- prompt = appendTelegramSourceContext(prompt, options.sourceContext);
217
+ const displayFiles =
218
+ options.displayFiles ?? options.promptFiles ?? options.files;
219
+ prompt = appendTelegramAttachmentSection(prompt, displayFiles);
192
220
  prompt = appendTelegramListSection(
193
221
  prompt,
194
222
  "outputs",
195
223
  options.handlerOutputs ?? [],
196
224
  );
225
+ prompt = appendTelegramSourceContext(prompt, options.sourceContext);
197
226
  if (options.voiceContext) {
198
227
  prompt = appendTelegramVoiceContext(prompt, options.voiceContext);
199
228
  }
@@ -375,6 +404,7 @@ export interface BuildTelegramPromptTurnOptions {
375
404
  statusText?: string;
376
405
  files: DownloadedTelegramTurnFile[];
377
406
  promptFiles?: DownloadedTelegramTurnFile[];
407
+ displayFiles?: DownloadedTelegramTurnFile[];
378
408
  handlerOutputs?: string[];
379
409
  sourceContext?: string;
380
410
  timeLine?: string | null;
@@ -433,9 +463,26 @@ export function createTelegramPromptTurnRuntimeBuilder<
433
463
  const replyContext = firstMessage
434
464
  ? buildTelegramReplyContextBlock(firstMessage, replyFiles)
435
465
  : "";
436
- const forwardContext = firstMessage
437
- ? extractTelegramForwardContextText(firstMessage, deps.getAllowedUserId?.())
438
- : "";
466
+ const forwardEntries = messages.flatMap((message) => {
467
+ const context = extractTelegramForwardContextText(
468
+ message,
469
+ deps.getAllowedUserId?.(),
470
+ );
471
+ return context
472
+ ? [
473
+ {
474
+ context,
475
+ text: extractTelegramMessageText(message),
476
+ message,
477
+ fileNames: new Set(
478
+ collectTelegramFileInfos(message.rich_message ? [message] : []).map(
479
+ (file) => file.fileName,
480
+ ),
481
+ ),
482
+ },
483
+ ]
484
+ : [];
485
+ });
439
486
  const files = await downloadTelegramMessageFiles(messages, {
440
487
  downloadFile: deps.downloadFile,
441
488
  });
@@ -444,13 +491,51 @@ export function createTelegramPromptTurnRuntimeBuilder<
444
491
  : { rawText, promptFiles: files };
445
492
  const sourceBlocks: string[] = [];
446
493
  let promptRawText = processed.rawText;
447
- if (forwardContext) {
494
+ const forwardedFilePaths = new Set<string>();
495
+ const getForwardFiles = (entry: (typeof forwardEntries)[number]) =>
496
+ (processed.promptFiles ?? files).filter((file) => {
497
+ if (!entry.fileNames.has(file.fileName)) return false;
498
+ forwardedFilePaths.add(file.path);
499
+ return true;
500
+ });
501
+ if (forwardEntries.length === 1 && messages.length === 1) {
502
+ const [forward] = forwardEntries;
448
503
  sourceBlocks.push(
449
- `[forward|${forwardContext.replace(/:\s+/g, ":")}]${
450
- processed.rawText ? ` ${processed.rawText}` : ""
451
- }`,
504
+ buildTelegramForwardContextBlock({
505
+ context: forward!.context,
506
+ text: processed.rawText,
507
+ files: getForwardFiles(forward!),
508
+ }),
452
509
  );
453
510
  promptRawText = "";
511
+ } else if (forwardEntries.length > 0 && processed.rawText === rawText) {
512
+ const forwardedMessages = new Set(
513
+ forwardEntries.map((entry) => entry.message),
514
+ );
515
+ promptRawText = messages
516
+ .filter((message) => !forwardedMessages.has(message))
517
+ .map(extractTelegramMessageText)
518
+ .filter(Boolean)
519
+ .join("\n\n");
520
+ sourceBlocks.push(
521
+ ...forwardEntries.map((entry) =>
522
+ buildTelegramForwardContextBlock({
523
+ context: entry.context,
524
+ text: entry.text,
525
+ files: getForwardFiles(entry),
526
+ }),
527
+ ),
528
+ );
529
+ } else if (forwardEntries.length > 0) {
530
+ sourceBlocks.push(
531
+ ...forwardEntries.map((entry) =>
532
+ buildTelegramForwardContextBlock({
533
+ context: entry.context,
534
+ text: "",
535
+ files: getForwardFiles(entry),
536
+ }),
537
+ ),
538
+ );
454
539
  }
455
540
  if (replyContext) sourceBlocks.push(replyContext);
456
541
  const sourceContext = sourceBlocks.join("\n\n");
@@ -475,6 +560,9 @@ export function createTelegramPromptTurnRuntimeBuilder<
475
560
  statusText: processed.rawText,
476
561
  files,
477
562
  promptFiles: processed.promptFiles,
563
+ displayFiles: (processed.promptFiles ?? files).filter(
564
+ (file) => !forwardedFilePaths.has(file.path),
565
+ ),
478
566
  handlerOutputs: processed.handlerOutputs,
479
567
  timeLine,
480
568
  inferImageMimeType: guessMediaType,
@@ -522,6 +610,7 @@ export async function buildTelegramPromptTurn(
522
610
  rawText: options.rawText,
523
611
  files: options.files,
524
612
  promptFiles: options.promptFiles,
613
+ displayFiles: options.displayFiles,
525
614
  handlerOutputs: options.handlerOutputs,
526
615
  sourceContext: options.sourceContext,
527
616
  historyTurns: options.historyTurns,
@@ -566,14 +655,14 @@ export async function buildTelegramPromptTurn(
566
655
  historyText: appendTelegramSourceContext(
567
656
  formatTelegramHistoryText(
568
657
  options.rawText,
569
- options.promptFiles ?? options.files,
658
+ options.displayFiles ?? options.promptFiles ?? options.files,
570
659
  options.handlerOutputs,
571
660
  ),
572
661
  options.sourceContext,
573
662
  ),
574
663
  statusSummary: formatTelegramTurnStatusSummary(
575
664
  options.statusText ?? options.rawText,
576
- options.promptFiles ?? options.files,
665
+ options.displayFiles ?? options.promptFiles ?? options.files,
577
666
  options.handlerOutputs,
578
667
  ),
579
668
  // Voice tagging (used for preview suppression and prompt guidance)
package/lib/updates.ts CHANGED
@@ -249,6 +249,7 @@ export function getAuthorizedTelegramGuestMessage(
249
249
 
250
250
  export interface TelegramMessageOwnershipView {
251
251
  instanceId: string;
252
+ ownerGeneration?: string;
252
253
  }
253
254
 
254
255
  export type TelegramMessageOwnershipLookup = (
@@ -258,6 +259,7 @@ export type TelegramMessageOwnershipLookup = (
258
259
 
259
260
  export interface TelegramTargetOwnershipView {
260
261
  instanceId: string;
262
+ ownerGeneration?: string;
261
263
  }
262
264
 
263
265
  export type TelegramTargetOwnershipLookup = (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"