@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/bindings.ts CHANGED
@@ -73,8 +73,8 @@ export function registerTelegramCommandsAndTools({
73
73
  getDefaultChatId,
74
74
  getDefaultTarget,
75
75
  canSendDirect,
76
- updateStatus,
77
76
  recordRuntimeEvent,
77
+ updateStatus,
78
78
  }: TelegramCommandsAndToolsBindingDeps): void {
79
79
  OutboundAttachments.registerTelegramOutboundAttachmentTool(pi, {
80
80
  getActiveTurn: activeTurnRuntime.get,
@@ -96,22 +96,72 @@ export function registerTelegramCommandsAndTools({
96
96
  });
97
97
  Prompts.registerTelegramHelpTool(pi);
98
98
  Commands.registerTelegramBridgeCommands(pi, {
99
- promptForConfig: Setup.createTelegramSetupPromptRuntime({
100
- getConfig: configStore.get,
101
- setConfig: configStore.set,
102
- setupGuard: setup,
103
- getMe: TelegramApi.fetchTelegramBotIdentity,
104
- persistConfig,
105
- startPolling: lockedPollingRuntime.start,
106
- updateStatus,
107
- recordRuntimeEvent,
108
- }),
99
+ promptForConfig: async (ctx, profileName) => {
100
+ const nextProfileName = profileName ?? undefined;
101
+ if (profileName && !Config.isValidTelegramProfileName(profileName)) {
102
+ ctx.ui.notify(`Invalid Telegram profile name: ${profileName}`, "error");
103
+ return;
104
+ }
105
+ const previousProfileName = configStore.getActiveProfileName();
106
+ if (previousProfileName !== nextProfileName) {
107
+ await (stopPolling ?? lockedPollingRuntime.stop)();
108
+ }
109
+ if (!profileName) {
110
+ configStore.activateProfile(undefined);
111
+ } else {
112
+ const storedConfig = configStore.getStoredConfig();
113
+ if (!storedConfig.profiles?.[profileName]) {
114
+ configStore.set({
115
+ ...storedConfig,
116
+ profiles: {
117
+ ...(storedConfig.profiles ?? {}),
118
+ [profileName]: { botToken: "" },
119
+ },
120
+ });
121
+ }
122
+ configStore.activateProfile(profileName);
123
+ }
124
+ const runSetup = Setup.createTelegramSetupPromptRuntime({
125
+ getConfig: configStore.get,
126
+ setConfig: configStore.set,
127
+ setupGuard: setup,
128
+ getMe: TelegramApi.fetchTelegramBotIdentity,
129
+ persistConfig,
130
+ startPolling: lockedPollingRuntime.start,
131
+ updateStatus,
132
+ recordRuntimeEvent,
133
+ });
134
+ await runSetup(ctx);
135
+ if (profileName) {
136
+ ctx.ui.notify(`Profile "${profileName}" saved and connected.`, "info");
137
+ }
138
+ },
109
139
  getStatusLines,
110
140
  reloadConfig: configStore.load,
111
141
  hasBotToken: configStore.hasBotToken,
112
142
  startPolling: lockedPollingRuntime.start,
113
143
  stopPolling: stopPolling ?? lockedPollingRuntime.stop,
114
144
  updateStatus,
145
+ getProfileNames: () =>
146
+ Config.getTelegramProfileNames(configStore.getStoredConfig()),
147
+ activateDefaultProfileConfig: async () => {
148
+ await configStore.load();
149
+ const previousProfileName = configStore.getActiveProfileName();
150
+ if (previousProfileName) {
151
+ await (stopPolling ?? lockedPollingRuntime.stop)();
152
+ }
153
+ configStore.activateProfile(undefined);
154
+ },
155
+ activateProfileConfig: async (_ctx, profileName) => {
156
+ await configStore.load();
157
+ if (!Config.isValidTelegramProfileName(profileName)) return false;
158
+ const previousProfileName = configStore.getActiveProfileName();
159
+ if (previousProfileName !== profileName) {
160
+ await (stopPolling ?? lockedPollingRuntime.stop)();
161
+ }
162
+ if (!configStore.activateProfile(profileName)) return false;
163
+ return true;
164
+ },
115
165
  });
116
166
  }
117
167
 
@@ -90,6 +90,7 @@ export interface TelegramBusFollowerSessionReplacementSuspenderDeps {
90
90
  registrationState: Pick<TelegramBusFollowerRegistrationState, "isRegistered">;
91
91
  instanceId: string;
92
92
  suspendPolling: () => Promise<void>;
93
+ onFollowerSessionDisconnect?: () => Promise<void> | void;
93
94
  recordRuntimeEvent: (
94
95
  category: string,
95
96
  error: unknown,
@@ -387,24 +388,42 @@ function isTelegramStaleContextError(error: unknown): boolean {
387
388
  );
388
389
  }
389
390
 
391
+ export async function markTelegramFollowerThreadStaleForSessionDisconnect(input: {
392
+ topicTargetStore: Threads.TelegramTopicTargetStore;
393
+ target: TelegramTarget;
394
+ }): Promise<void> {
395
+ await input.topicTargetStore.load();
396
+ const record = input.topicTargetStore.list().find(
397
+ (candidate) =>
398
+ candidate.target.chatId === input.target.chatId &&
399
+ candidate.target.threadId === input.target.threadId,
400
+ );
401
+ const marked = input.topicTargetStore.markStaleByTarget(
402
+ input.target,
403
+ "unknown",
404
+ "Follower session reloaded before reconnect.",
405
+ );
406
+ const forgotIdentity = record?.profileKey
407
+ ? input.topicTargetStore.forgetIdentityByProfileKey(record.profileKey)
408
+ : false;
409
+ if (marked || forgotIdentity) await input.topicTargetStore.persist();
410
+ }
411
+
390
412
  export function createTelegramBusFollowerSessionReplacementSuspender(
391
413
  deps: TelegramBusFollowerSessionReplacementSuspenderDeps,
392
414
  ): () => Promise<void> {
393
415
  return async () => {
394
416
  if (deps.registrationState.isRegistered()) {
395
- setTelegramFollowerSessionHandoff({
396
- pid: (deps.getPid ?? (() => process.pid))(),
397
- instanceId: deps.instanceId,
398
- createdAtMs: (deps.getNowMs ?? Date.now)(),
399
- });
417
+ setTelegramFollowerSessionHandoff(undefined);
418
+ await deps.onFollowerSessionDisconnect?.();
400
419
  deps.recordRuntimeEvent(
401
420
  "bus",
402
- "Telegram follower registration preserved",
421
+ "Telegram follower registration stopped for session replacement",
403
422
  {
404
- phase: "follower-session-preserve",
423
+ phase: "follower-session-disconnect",
424
+ instanceId: deps.instanceId,
405
425
  },
406
426
  );
407
- return;
408
427
  }
409
428
  await deps.suspendPolling();
410
429
  };
package/lib/bus-leader.ts CHANGED
@@ -54,6 +54,7 @@ export interface TelegramBusLeaderTargetProvisionerDeps<TContext> {
54
54
  getAllowedUserId: () => number | undefined;
55
55
  instanceId: string;
56
56
  getCwd?: (ctx: TContext) => string | undefined;
57
+ getTelegramProfile?: () => string | undefined;
57
58
  shouldForceFreshUnnamed?: () => boolean;
58
59
  topicTargetStore: Threads.TelegramTopicTargetStore;
59
60
  callApi: <TResponse>(
@@ -315,6 +316,10 @@ export function createTelegramBusFollowerTargetProvisioner(
315
316
  (TelegramTarget & { slot?: string; threadName?: string }) | undefined
316
317
  > {
317
318
  const getNowMs = deps.getNowMs ?? Date.now;
319
+ const pendingRegistrations = new Map<
320
+ string,
321
+ Promise<(TelegramTarget & { slot?: string; threadName?: string }) | undefined>
322
+ >();
318
323
  return async (registration) => {
319
324
  const registrationStartedAtMs = Date.now();
320
325
  const chatId = deps.getAllowedUserId();
@@ -337,6 +342,9 @@ export function createTelegramBusFollowerTargetProvisioner(
337
342
  registration.profileKey ?? `manual:${registration.instanceId}`;
338
343
  const followerOwner =
339
344
  Threads.getTelegramThreadOwnerFromProfileKey(followerProfileKey);
345
+ const registrationKey = followerProfileKey || registration.instanceId;
346
+ const pendingRegistration = pendingRegistrations.get(registrationKey);
347
+ if (pendingRegistration) return pendingRegistration;
340
348
  const provisionTarget = async () => {
341
349
  deps.onProvisioningStart?.();
342
350
  try {
@@ -356,6 +364,9 @@ export function createTelegramBusFollowerTargetProvisioner(
356
364
  deps.onProvisioningEnd?.();
357
365
  }
358
366
  };
367
+ const runRegistration = async (): Promise<
368
+ (TelegramTarget & { slot?: string; threadName?: string }) | undefined
369
+ > => {
359
370
  let result = await provisionTarget();
360
371
  deps.setSyncState(
361
372
  Sync.markTelegramSyncSliceFresh(deps.getSyncState(), "target-bindings", {
@@ -363,48 +374,14 @@ export function createTelegramBusFollowerTargetProvisioner(
363
374
  action: "follower-register",
364
375
  }),
365
376
  );
366
- let connectedAnnouncement =
367
- createTelegramBusInstanceLifecycleAnnouncement({
368
- target: result.target,
369
- threadName: result.record.threadName,
370
- slot: result.record.slot,
371
- state: "connected",
372
- });
373
- let connectedAnnouncementSent = false;
374
- if (result.reused && connectedAnnouncement) {
375
- try {
376
- await deps.callApi("sendMessage", {
377
- chat_id: connectedAnnouncement.target.chatId,
378
- message_thread_id: connectedAnnouncement.target.threadId,
379
- text: connectedAnnouncement.text,
380
- parse_mode: connectedAnnouncement.parseMode,
381
- });
382
- connectedAnnouncementSent = true;
383
- } catch (error) {
384
- deps.recordRuntimeEvent("telegram", error, {
385
- phase: "follower-topic-reuse-probe",
386
- instanceId: registration.instanceId,
387
- chatId: result.target.chatId,
388
- threadId: result.target.threadId,
377
+ const connectedAnnouncement = result.reused
378
+ ? undefined
379
+ : createTelegramBusInstanceLifecycleAnnouncement({
380
+ target: result.target,
381
+ threadName: result.record.threadName,
382
+ slot: result.record.slot,
383
+ state: "connected",
389
384
  });
390
- if (Threads.isTelegramTopicTargetStaleError(error)) {
391
- deps.topicTargetStore.markStaleByTarget(
392
- result.target,
393
- "deleted",
394
- "Follower registration found the reusable thread target stale.",
395
- );
396
- await deps.topicTargetStore.persist();
397
- result = await provisionTarget();
398
- connectedAnnouncement = createTelegramBusInstanceLifecycleAnnouncement({
399
- target: result.target,
400
- threadName: result.record.threadName,
401
- slot: result.record.slot,
402
- state: "connected",
403
- });
404
- connectedAnnouncementSent = false;
405
- }
406
- }
407
- }
408
385
  recordSlowTelegramBusFollowerRegistrationStep(deps, {
409
386
  phase: "follower-register-critical",
410
387
  elapsedMs: Date.now() - registrationStartedAtMs,
@@ -414,7 +391,7 @@ export function createTelegramBusFollowerTargetProvisioner(
414
391
  });
415
392
  scheduleTelegramBusLeaderBackgroundTask(async () => {
416
393
  const backgroundStartedAtMs = Date.now();
417
- if (connectedAnnouncement && !connectedAnnouncementSent) {
394
+ if (connectedAnnouncement) {
418
395
  try {
419
396
  await deps.callApi("sendMessage", {
420
397
  chat_id: connectedAnnouncement.target.chatId,
@@ -486,6 +463,12 @@ export function createTelegramBusFollowerTargetProvisioner(
486
463
  slot: result.record.slot,
487
464
  threadName: result.record.threadName,
488
465
  };
466
+ };
467
+ const registrationPromise = runRegistration().finally(() => {
468
+ pendingRegistrations.delete(registrationKey);
469
+ });
470
+ pendingRegistrations.set(registrationKey, registrationPromise);
471
+ return registrationPromise;
489
472
  };
490
473
  }
491
474
 
@@ -516,6 +499,7 @@ export function createTelegramBusLeaderTargetProvisioner<TContext>(
516
499
  getAllowedUserId: deps.getAllowedUserId,
517
500
  instanceId: deps.instanceId,
518
501
  cwd: deps.getCwd?.(ctx),
502
+ telegramProfile: deps.getTelegramProfile?.(),
519
503
  forceFreshUnnamed: deps.shouldForceFreshUnnamed?.(),
520
504
  getNowMs,
521
505
  getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
package/lib/bus.ts CHANGED
@@ -13,8 +13,8 @@ import {
13
13
  type Server,
14
14
  type Socket,
15
15
  } from "node:net";
16
- import { homedir, platform as getPlatform } from "node:os";
17
- import { dirname, join, resolve } from "node:path";
16
+ import { platform as getPlatform } from "node:os";
17
+ import { dirname } from "node:path";
18
18
 
19
19
  import {
20
20
  classifyTelegramBusTransportError,
@@ -30,21 +30,16 @@ import {
30
30
  type TelegramBusTransportRetryPolicy,
31
31
  } from "./bus-transport.ts";
32
32
  import type { TelegramTarget } from "./target.ts";
33
+ import { resolveAgentDir } from "./paths.ts";
33
34
 
34
35
  export type TelegramBusRole = "leader" | "follower";
35
36
 
36
- function getAgentDir(): string {
37
- return process.env.PI_CODING_AGENT_DIR
38
- ? resolve(process.env.PI_CODING_AGENT_DIR)
39
- : join(homedir(), ".pi", "agent");
40
- }
41
-
42
37
  export function createTelegramBusAuthSecret(): string {
43
38
  return randomBytes(32).toString("base64url");
44
39
  }
45
40
 
46
41
  export function getTelegramBusSocketPath(
47
- agentDir = getAgentDir(),
42
+ agentDir = resolveAgentDir(),
48
43
  platform = getPlatform(),
49
44
  ): string {
50
45
  return getTelegramBusLeaderEndpoint({ agentDir, platform });
@@ -52,7 +47,7 @@ export function getTelegramBusSocketPath(
52
47
 
53
48
  export function getTelegramBusFollowerSocketPath(
54
49
  instanceId: string,
55
- agentDir = getAgentDir(),
50
+ agentDir = resolveAgentDir(),
56
51
  platform = getPlatform(),
57
52
  ): string {
58
53
  return getTelegramBusFollowerEndpoint({ agentDir, platform, instanceId });
@@ -155,7 +150,9 @@ export function isTelegramFollowerApiCallAllowed(input: {
155
150
  const messageId = (body as Record<string, unknown>).message_id;
156
151
  const parsedMessageId =
157
152
  typeof messageId === "number" ? messageId : Number(messageId);
158
- return Number.isInteger(parsedMessageId) && matchesId(messageId, parsedMessageId);
153
+ return (
154
+ Number.isInteger(parsedMessageId) && matchesId(messageId, parsedMessageId)
155
+ );
159
156
  };
160
157
  const isBotCommandRegistration = (body: unknown): boolean => {
161
158
  if (!body || typeof body !== "object" || Array.isArray(body)) return false;
@@ -185,7 +182,8 @@ export function isTelegramFollowerApiCallAllowed(input: {
185
182
  if (apiMethod === "getMe") return true;
186
183
  if (apiMethod === "setMyCommands")
187
184
  return isBotCommandRegistration(input.args[1]);
188
- if (apiMethod === "sendChatAction") return isTargetChatScoped(input.args[1]);
185
+ if (apiMethod === "sendChatAction")
186
+ return isTargetChatScoped(input.args[1]);
189
187
  if (apiMethod === "deleteMessage" || apiMethod === "editMessageText") {
190
188
  return isTargetMessageScoped(input.args[1]);
191
189
  }
@@ -351,9 +349,7 @@ export interface TelegramBusLocalServerDeps {
351
349
  handleEnvelope: (
352
350
  envelope: TelegramBusEnvelope,
353
351
  ) =>
354
- | Promise<TelegramBusEnvelope | undefined>
355
- | TelegramBusEnvelope
356
- | undefined;
352
+ Promise<TelegramBusEnvelope | undefined> | TelegramBusEnvelope | undefined;
357
353
  recordTransportEvent?: TelegramBusTransportEventRecorder;
358
354
  }
359
355
 
@@ -454,7 +450,9 @@ export function createTelegramBusForeignOwnedUpdateForwarder<
454
450
 
455
451
  export interface TelegramBusFollowerThreadRestoreHandlerDeps {
456
452
  followerRegistry: Pick<TelegramBusFollowerRegistry, "get" | "register">;
457
- followerTargetController: ReturnType<typeof createTelegramBusFollowerTargetController>;
453
+ followerTargetController: ReturnType<
454
+ typeof createTelegramBusFollowerTargetController
455
+ >;
458
456
  onRestored?: () => void;
459
457
  }
460
458
 
@@ -517,11 +515,7 @@ export function createTelegramBusFollowerThreadRestoreHandler(
517
515
  target: TelegramTarget & { threadId: number };
518
516
  oldTarget?: TelegramTarget & { threadId: number };
519
517
  }) => Promise<boolean> {
520
- return async ({
521
- record,
522
- target,
523
- oldTarget,
524
- }) => {
518
+ return async ({ record, target, oldTarget }) => {
525
519
  if (!record.instanceId) return false;
526
520
  const follower = deps.followerRegistry.get(record.instanceId);
527
521
  if (!follower) return false;
@@ -637,7 +631,10 @@ export function createTelegramBusLocalServer(
637
631
  activeServer.close(() => resolve()),
638
632
  );
639
633
  }
640
- if (!isTelegramBusPipePath(deps.socketPath) && existsSync(deps.socketPath)) {
634
+ if (
635
+ !isTelegramBusPipePath(deps.socketPath) &&
636
+ existsSync(deps.socketPath)
637
+ ) {
641
638
  unlinkSync(deps.socketPath);
642
639
  }
643
640
  deps.recordTransportEvent?.(
package/lib/commands.ts CHANGED
@@ -295,7 +295,7 @@ export interface TelegramBridgeCommandStartPollingResult {
295
295
  }
296
296
 
297
297
  export interface TelegramBridgeCommandRegistrationDeps {
298
- promptForConfig: (ctx: ExtensionCommandContext) => Promise<void>;
298
+ promptForConfig: (ctx: ExtensionCommandContext, profileName?: string) => Promise<void>;
299
299
  getStatusLines: (options?: TelegramBridgeStatusLineOptions) => string[];
300
300
  reloadConfig: () => Promise<void>;
301
301
  hasBotToken: () => boolean;
@@ -308,6 +308,19 @@ export interface TelegramBridgeCommandRegistrationDeps {
308
308
  | TelegramBridgeCommandStartPollingResult;
309
309
  stopPolling: () => Promise<void | string>;
310
310
  updateStatus: (ctx: ExtensionCommandContext) => void;
311
+ getProfileNames?: () => string[];
312
+ activateDefaultProfileConfig?: (ctx: ExtensionCommandContext) => Promise<void>;
313
+ activateProfileConfig?: (
314
+ ctx: ExtensionCommandContext,
315
+ profileName: string,
316
+ ) => Promise<boolean>;
317
+ }
318
+
319
+ function parseTelegramProfileArg(args: string): string | undefined {
320
+ const word = args.trim().split(/\s+/)[0];
321
+ if (!word || word.length === 0) return undefined;
322
+ if (word.startsWith("-")) return undefined;
323
+ return word;
311
324
  }
312
325
 
313
326
  function formatTelegramTakeoverTitle(ctx: ExtensionCommandContext): string {
@@ -331,9 +344,9 @@ export function registerTelegramBridgeCommands(
331
344
  deps: TelegramBridgeCommandRegistrationDeps,
332
345
  ): void {
333
346
  pi.registerCommand("telegram-setup", {
334
- description: "Configure Telegram bot token",
335
- handler: async (_args, ctx) => {
336
- await deps.promptForConfig(ctx);
347
+ description: "Configure Telegram bot token. Use /telegram-setup <name> for named profiles.",
348
+ handler: async (args, ctx) => {
349
+ await deps.promptForConfig(ctx, parseTelegramProfileArg(args));
337
350
  },
338
351
  });
339
352
  pi.registerCommand("telegram-status", {
@@ -346,11 +359,31 @@ export function registerTelegramBridgeCommands(
346
359
  },
347
360
  });
348
361
  pi.registerCommand("telegram-connect", {
349
- description: "Start the Telegram bridge in this Pi session",
350
- handler: async (_args, ctx) => {
351
- await deps.reloadConfig();
362
+ description: "Start the Telegram bridge. Use /telegram-connect <name> for named profiles.",
363
+ handler: async (args, ctx) => {
364
+ const profileName = parseTelegramProfileArg(args);
365
+ if (profileName && deps.activateProfileConfig) {
366
+ const ok = await deps.activateProfileConfig(ctx, profileName);
367
+ if (!ok) {
368
+ ctx.ui.notify(`Profile "${profileName}" not found.`, "error");
369
+ deps.updateStatus(ctx);
370
+ return;
371
+ }
372
+ ctx.ui.notify(`Activated profile "${profileName}".`, "info");
373
+ } else {
374
+ await (deps.activateDefaultProfileConfig?.(ctx) ?? deps.reloadConfig());
375
+ }
352
376
  if (!deps.hasBotToken()) {
353
- await deps.promptForConfig(ctx);
377
+ const profileNames = deps.getProfileNames?.() ?? [];
378
+ if (!profileName && profileNames.length > 0) {
379
+ ctx.ui.notify(
380
+ `No default Telegram profile configured. Available profiles: ${profileNames.join(", ")}. Use /telegram-connect <profileName> or /telegram-setup to create a default profile.`,
381
+ "info",
382
+ );
383
+ deps.updateStatus(ctx);
384
+ return;
385
+ }
386
+ await deps.promptForConfig(ctx, profileName);
354
387
  return;
355
388
  }
356
389
  let result = await deps.startPolling(ctx, {