@llblab/pi-telegram 0.17.4 → 0.18.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.
Files changed (62) hide show
  1. package/AGENTS.md +67 -32
  2. package/BACKLOG.md +59 -14
  3. package/CHANGELOG.md +40 -15
  4. package/README.md +63 -35
  5. package/docs/README.md +3 -1
  6. package/docs/architecture.md +55 -23
  7. package/docs/callback-namespaces.md +1 -1
  8. package/docs/inbound.md +1 -1
  9. package/docs/locks.md +0 -2
  10. package/docs/multi-instance-bus.md +483 -0
  11. package/docs/outbound.md +4 -3
  12. package/docs/public-api.md +12 -10
  13. package/docs/sections.md +2 -2
  14. package/docs/ui-style.md +76 -0
  15. package/index.ts +789 -32
  16. package/lib/bindings.ts +68 -12
  17. package/lib/bus-api.ts +314 -0
  18. package/lib/bus-follower.ts +853 -0
  19. package/lib/bus-leader.ts +915 -0
  20. package/lib/bus.ts +866 -0
  21. package/lib/command-templates.ts +9 -11
  22. package/lib/commands.ts +133 -47
  23. package/lib/config.ts +53 -5
  24. package/lib/lifecycle.ts +23 -7
  25. package/lib/locks.ts +230 -66
  26. package/lib/media.ts +30 -2
  27. package/lib/menu-model.ts +48 -17
  28. package/lib/menu-queue.ts +51 -20
  29. package/lib/menu-settings.ts +9 -5
  30. package/lib/menu-status.ts +3 -0
  31. package/lib/menu-thinking.ts +3 -0
  32. package/lib/menu.ts +67 -26
  33. package/lib/outbound-attachments.ts +102 -17
  34. package/lib/outbound-buttons.ts +6 -2
  35. package/lib/outbound-voice.ts +31 -11
  36. package/lib/outbound.ts +6 -4
  37. package/lib/ownership.ts +119 -0
  38. package/lib/pi.ts +26 -3
  39. package/lib/polling.ts +477 -7
  40. package/lib/preview.ts +141 -88
  41. package/lib/prompt-templates.ts +3 -3
  42. package/lib/prompts.ts +80 -30
  43. package/lib/queue.ts +193 -91
  44. package/lib/rendering.ts +0 -25
  45. package/lib/replies.ts +187 -55
  46. package/lib/routing.ts +1673 -9
  47. package/lib/runtime-log.ts +123 -0
  48. package/lib/runtime.ts +84 -12
  49. package/lib/sections.ts +28 -21
  50. package/lib/setup.ts +1 -1
  51. package/lib/status.ts +532 -9
  52. package/lib/sync.ts +618 -0
  53. package/lib/target.ts +49 -0
  54. package/lib/telegram-api.ts +405 -40
  55. package/lib/text-groups.ts +5 -1
  56. package/lib/thread-reconciler.ts +915 -0
  57. package/lib/threads.ts +2205 -0
  58. package/lib/turns.ts +48 -3
  59. package/lib/updates.ts +355 -32
  60. package/package.json +24 -2
  61. package/screenshot.png +0 -0
  62. package/docs/telegram-bot-api-rich-messages.md +0 -890
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Telegram runtime JSONL diagnostics log
3
+ * Zones: telegram diagnostics, filesystem, session observability
4
+ * Owns session-local append-only runtime evidence for debugging without becoming routing state
5
+ */
6
+
7
+ import { existsSync, mkdirSync, statSync, writeFileSync, appendFile } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { dirname, join, resolve } from "node:path";
10
+
11
+ export interface TelegramRuntimeJsonlEvent {
12
+ at: number;
13
+ category: string;
14
+ message: string;
15
+ details?: Record<string, unknown>;
16
+ }
17
+
18
+ export interface TelegramRuntimeJsonlLogOptions {
19
+ path?: string;
20
+ maxBytes?: number;
21
+ getNowMs?: () => number;
22
+ }
23
+
24
+ export interface TelegramRuntimeJsonlLog {
25
+ path: string;
26
+ reset: (reason: string, scope?: Record<string, unknown>) => void;
27
+ resetIfScopeChanged: (
28
+ scopeKey: string,
29
+ reason: string,
30
+ scope?: Record<string, unknown>,
31
+ ) => void;
32
+ record: (event: TelegramRuntimeJsonlEvent) => void;
33
+ }
34
+
35
+ const DEFAULT_MAX_LOG_BYTES = 5 * 1024 * 1024;
36
+
37
+ function getAgentDir(): string {
38
+ return process.env.PI_CODING_AGENT_DIR
39
+ ? resolve(process.env.PI_CODING_AGENT_DIR)
40
+ : join(homedir(), ".pi", "agent");
41
+ }
42
+
43
+ export function getTelegramRuntimeLogPath(agentDir = getAgentDir()): string {
44
+ return join(agentDir, "tmp", "telegram", "logs.jsonl");
45
+ }
46
+
47
+ function safeJsonLine(value: unknown): string {
48
+ return JSON.stringify(value, (_key, item) => {
49
+ if (item instanceof Error) return item.message;
50
+ if (typeof item === "bigint") return item.toString();
51
+ if (typeof item === "function" || typeof item === "symbol") return undefined;
52
+ return item;
53
+ });
54
+ }
55
+
56
+ export function createTelegramRuntimeJsonlLog(
57
+ options: TelegramRuntimeJsonlLogOptions = {},
58
+ ): TelegramRuntimeJsonlLog {
59
+ const path = options.path ?? getTelegramRuntimeLogPath();
60
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_LOG_BYTES;
61
+ const getNowMs = options.getNowMs ?? Date.now;
62
+ let scopeKey: string | undefined;
63
+ let pending: Promise<void> = Promise.resolve();
64
+
65
+ const ensureParent = () => {
66
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
67
+ };
68
+
69
+ const writeReset = (reason: string, scope?: Record<string, unknown>) => {
70
+ ensureParent();
71
+ writeFileSync(
72
+ path,
73
+ safeJsonLine({
74
+ at: getNowMs(),
75
+ kind: "reset",
76
+ reason,
77
+ scope,
78
+ }) + "\n",
79
+ { mode: 0o600 },
80
+ );
81
+ };
82
+
83
+ const appendLine = (line: string) => {
84
+ pending = pending
85
+ .catch(() => undefined)
86
+ .then(async () => {
87
+ ensureParent();
88
+ if (existsSync(path) && statSync(path).size > maxBytes) {
89
+ writeReset("max-bytes", { maxBytes });
90
+ }
91
+ await new Promise<void>((resolve, reject) => {
92
+ appendFile(path, line, { mode: 0o600 }, (error) => {
93
+ if (error) reject(error);
94
+ else resolve();
95
+ });
96
+ });
97
+ });
98
+ };
99
+
100
+ return {
101
+ path,
102
+ reset(reason, scope) {
103
+ scopeKey = scope ? safeJsonLine(scope) : undefined;
104
+ try {
105
+ writeReset(reason, scope);
106
+ } catch {
107
+ // Diagnostics must never break Telegram runtime behavior.
108
+ }
109
+ },
110
+ resetIfScopeChanged(nextScopeKey, reason, scope) {
111
+ if (scopeKey === nextScopeKey) return;
112
+ scopeKey = nextScopeKey;
113
+ try {
114
+ writeReset(reason, scope);
115
+ } catch {
116
+ // Diagnostics must never break Telegram runtime behavior.
117
+ }
118
+ },
119
+ record(event) {
120
+ appendLine(safeJsonLine({ kind: "event", ...event }) + "\n");
121
+ },
122
+ };
123
+ }
package/lib/runtime.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Owns small session-local runtime primitives that are shared by orchestration but are not specific to queueing, rendering, polling, or Telegram transport
5
5
  */
6
6
 
7
- const TELEGRAM_TYPING_ACTION_INTERVAL_MS = 2500;
7
+ const TELEGRAM_TYPING_ACTION_INTERVAL_MS = 1500;
8
8
  const TELEGRAM_TYPING_IDLE_DRAIN_MAX_MS = 250;
9
9
 
10
10
  export interface TelegramRuntimeQueueCounters {
@@ -26,6 +26,8 @@ export interface TelegramBridgeRuntimeState
26
26
  abortHandler?: () => void;
27
27
  typingInterval?: ReturnType<typeof setInterval>;
28
28
  typingInFlight?: Promise<void>;
29
+ typingLoopDeps?: TelegramTypingLoopDeps;
30
+ typingLoopKey?: string;
29
31
  }
30
32
 
31
33
  export interface TelegramRuntimeQueuePort {
@@ -318,10 +320,29 @@ export function abortTelegramTurn(state: TelegramBridgeRuntimeState): boolean {
318
320
  return true;
319
321
  }
320
322
 
323
+ export interface TelegramTypingLoopTarget {
324
+ chatId: number;
325
+ threadId?: number;
326
+ }
327
+
328
+ function getTelegramTypingLoopThreadParams(
329
+ target: TelegramTypingLoopTarget | undefined,
330
+ ): { message_thread_id?: number } | undefined {
331
+ const threadId = target?.threadId;
332
+ return Number.isInteger(threadId)
333
+ ? { message_thread_id: threadId }
334
+ : undefined;
335
+ }
336
+
321
337
  export interface TelegramTypingLoopDeps {
322
338
  chatId: number | undefined;
339
+ target?: TelegramTypingLoopTarget;
323
340
  intervalMs: number;
324
- sendTypingAction: (chatId: number) => Promise<unknown>;
341
+ sendTypingAction: (
342
+ chatId: number,
343
+ options?: { message_thread_id?: number },
344
+ ) => Promise<unknown>;
345
+ sendAggregateTypingAction?: (chatId: number) => Promise<unknown>;
325
346
  }
326
347
 
327
348
  export interface TelegramRuntimeEventRecorderPort {
@@ -356,21 +377,34 @@ export interface TelegramTypingLoopStarterDeps<
356
377
  > extends TelegramRuntimeEventRecorderPort {
357
378
  typing: TelegramRuntimeTypingPort;
358
379
  getDefaultChatId: () => number | undefined;
359
- sendTypingAction: (chatId: number) => Promise<unknown>;
380
+ sendTypingAction: (
381
+ chatId: number,
382
+ options?: { message_thread_id?: number },
383
+ ) => Promise<unknown>;
384
+ sendAggregateTypingAction?: (chatId: number) => Promise<unknown>;
360
385
  updateStatus: (ctx: TContext, error?: string) => void;
361
386
  intervalMs?: number;
362
387
  }
363
388
 
364
389
  export function createTelegramTypingLoopStarter<TContext>(
365
390
  deps: TelegramTypingLoopStarterDeps<TContext>,
366
- ): (ctx: TContext, chatId?: number) => void {
367
- return (ctx, chatId) => {
391
+ ): (
392
+ ctx: TContext,
393
+ chatId?: number,
394
+ options?: { target?: TelegramTypingLoopTarget },
395
+ ) => void {
396
+ return (ctx, chatId, options) => {
368
397
  deps.typing.start({
369
398
  chatId: chatId ?? deps.getDefaultChatId(),
399
+ target: options?.target,
370
400
  intervalMs: deps.intervalMs ?? TELEGRAM_TYPING_ACTION_INTERVAL_MS,
371
401
  sendTypingAction: async (targetChatId) => {
372
402
  try {
373
- await deps.sendTypingAction(targetChatId);
403
+ const threadParams = getTelegramTypingLoopThreadParams(options?.target);
404
+ await deps.sendTypingAction(targetChatId, threadParams);
405
+ if (threadParams?.message_thread_id !== undefined) {
406
+ await deps.sendAggregateTypingAction?.(targetChatId);
407
+ }
374
408
  } catch (error) {
375
409
  const message =
376
410
  error instanceof Error ? error.message : String(error);
@@ -389,14 +423,33 @@ export function createTelegramTypingLoopStarter<TContext>(
389
423
  };
390
424
  }
391
425
 
426
+ function getTelegramTypingLoopKey(deps: TelegramTypingLoopDeps): string {
427
+ const threadId = deps.target?.threadId;
428
+ return `${deps.chatId ?? 0}:${Number.isInteger(threadId) ? threadId : "all"}`;
429
+ }
430
+
392
431
  export function startTelegramTypingLoop(
393
432
  state: TelegramBridgeRuntimeState,
394
433
  deps: TelegramTypingLoopDeps,
395
434
  ): boolean {
396
- if (state.typingInterval || deps.chatId === undefined || deps.chatId === 0)
397
- return false;
435
+ if (deps.chatId === undefined || deps.chatId === 0) return false;
436
+ const previousKey = state.typingLoopKey;
437
+ const nextKey = getTelegramTypingLoopKey(deps);
438
+ state.typingLoopDeps = deps;
439
+ state.typingLoopKey = nextKey;
398
440
  const sendTyping = (): void => {
399
- const typing = Promise.resolve(deps.sendTypingAction(deps.chatId as number))
441
+ const activeDeps = state.typingLoopDeps;
442
+ if (!activeDeps || activeDeps.chatId === undefined || activeDeps.chatId === 0)
443
+ return;
444
+ const targetChatId = activeDeps.chatId;
445
+ const threadParams = getTelegramTypingLoopThreadParams(activeDeps.target);
446
+ const typing = Promise.resolve()
447
+ .then(async () => {
448
+ await activeDeps.sendTypingAction(targetChatId, threadParams);
449
+ if (threadParams?.message_thread_id !== undefined) {
450
+ await activeDeps.sendAggregateTypingAction?.(targetChatId);
451
+ }
452
+ })
400
453
  .then(() => undefined)
401
454
  .catch(() => undefined);
402
455
  state.typingInFlight = typing;
@@ -404,6 +457,11 @@ export function startTelegramTypingLoop(
404
457
  if (state.typingInFlight === typing) state.typingInFlight = undefined;
405
458
  });
406
459
  };
460
+ if (state.typingInterval) {
461
+ if (previousKey === nextKey) return false;
462
+ sendTyping();
463
+ return true;
464
+ }
407
465
  sendTyping();
408
466
  state.typingInterval = setInterval(sendTyping, deps.intervalMs);
409
467
  state.typingInterval.unref?.();
@@ -416,6 +474,8 @@ export function stopTelegramTypingLoop(
416
474
  if (!state.typingInterval) return false;
417
475
  clearInterval(state.typingInterval);
418
476
  state.typingInterval = undefined;
477
+ state.typingLoopDeps = undefined;
478
+ state.typingLoopKey = undefined;
419
479
  return true;
420
480
  }
421
481
 
@@ -478,7 +538,11 @@ export interface TelegramPromptDispatchLifecycleDeps<
478
538
  "setDispatchPending" | "clearDispatchPending"
479
539
  >;
480
540
  typing: Pick<TelegramRuntimeTypingPort, "stop">;
481
- startTypingLoop: (ctx: TContext, chatId?: number) => void;
541
+ startTypingLoop: (
542
+ ctx: TContext,
543
+ chatId?: number,
544
+ options?: { target?: TelegramTypingLoopTarget },
545
+ ) => void;
482
546
  updateStatus: (ctx: TContext, error?: string) => void;
483
547
  }
484
548
 
@@ -488,13 +552,21 @@ export interface TelegramPromptDispatchRuntimeDeps<
488
552
  lifecycle: TelegramPromptDispatchLifecycleDeps<TContext>["lifecycle"];
489
553
  typing: TelegramRuntimeTypingPort;
490
554
  getDefaultChatId: () => number | undefined;
491
- sendTypingAction: (chatId: number) => Promise<unknown>;
555
+ sendTypingAction: (
556
+ chatId: number,
557
+ options?: { message_thread_id?: number },
558
+ ) => Promise<unknown>;
559
+ sendAggregateTypingAction?: (chatId: number) => Promise<unknown>;
492
560
  updateStatus: (ctx: TContext, error?: string) => void;
493
561
  intervalMs?: number;
494
562
  }
495
563
 
496
564
  export interface TelegramPromptDispatchRuntime<TContext> {
497
- startTypingLoop: (ctx: TContext, chatId?: number) => void;
565
+ startTypingLoop: (
566
+ ctx: TContext,
567
+ chatId?: number,
568
+ options?: { target?: TelegramTypingLoopTarget },
569
+ ) => void;
498
570
  onPromptDispatchStart: (ctx: TContext, chatId?: number) => void;
499
571
  onPromptDispatchFailure: (ctx: TContext, message: string) => void;
500
572
  }
package/lib/sections.ts CHANGED
@@ -136,9 +136,16 @@ export interface TelegramSectionSettingsRow {
136
136
 
137
137
  // --- Runtime Port Builders ---
138
138
 
139
+ /** @internal */
140
+ export interface TelegramSectionTarget {
141
+ chatId: number;
142
+ threadId?: number;
143
+ }
144
+
139
145
  /** @internal */
140
146
  export interface TelegramSectionRuntimeDeps {
141
147
  answerCallbackQuery: (id: string, text?: string) => Promise<void>;
148
+ target?: TelegramSectionTarget;
142
149
  editInteractiveMessage: (
143
150
  chatId: number,
144
151
  messageId: number,
@@ -151,6 +158,7 @@ export interface TelegramSectionRuntimeDeps {
151
158
  text: string,
152
159
  mode: "markdown" | "html" | "plain",
153
160
  replyMarkup: TelegramInlineKeyboardMarkup,
161
+ options?: { target?: TelegramSectionTarget },
154
162
  ) => Promise<number | undefined>;
155
163
  enqueuePrompt: (prompt: string) => Promise<void>;
156
164
  deleteMessage: (chatId: number, messageId: number) => Promise<void>;
@@ -191,6 +199,7 @@ function buildTelegramSectionContext(
191
199
  view.text,
192
200
  view.parseMode ?? "html",
193
201
  view.replyMarkup ?? { inline_keyboard: [] },
202
+ deps.target ? { target: deps.target } : undefined,
194
203
  )
195
204
  .then(() => {}),
196
205
  enqueuePrompt: deps.enqueuePrompt,
@@ -239,6 +248,7 @@ function buildTelegramSectionCallbackContext(
239
248
  view.text,
240
249
  view.parseMode ?? "html",
241
250
  view.replyMarkup ?? { inline_keyboard: [] },
251
+ deps.target ? { target: deps.target } : undefined,
242
252
  )
243
253
  .then(() => {}),
244
254
  enqueuePrompt: deps.enqueuePrompt,
@@ -299,11 +309,6 @@ export function getTelegramSectionDiagnostics(): TelegramSectionDiagnostic[] {
299
309
 
300
310
  // --- Registry ---
301
311
 
302
- const MAIN_MENU_ROW = {
303
- text: "⬆️ Main menu",
304
- callback_data: "menu:back",
305
- } as const;
306
-
307
312
  const BACK_NAV_ROW = {
308
313
  text: "⬆️ Back",
309
314
  } as const;
@@ -353,7 +358,7 @@ export function createTelegramExtensionSectionRegistry(): TelegramSectionRegistr
353
358
  >();
354
359
  let nextToken = 0;
355
360
 
356
- function register(section: TelegramSectionRegistration): () => void {
361
+ const register = (section: TelegramSectionRegistration): () => void => {
357
362
  const duplicate = [...sections.values()].find((s) => s.id === section.id);
358
363
  if (duplicate) {
359
364
  throw new Error(`Telegram section id already registered: ${section.id}`);
@@ -371,22 +376,22 @@ export function createTelegramExtensionSectionRegistry(): TelegramSectionRegistr
371
376
  sections.delete(token);
372
377
  errors.delete(token);
373
378
  };
374
- }
379
+ };
375
380
 
376
- function getSections(): RegisteredTelegramSection[] {
381
+ const getSections = (): RegisteredTelegramSection[] => {
377
382
  return [...sections.values()].sort((a, b) => {
378
383
  if (a.order !== b.order) return a.order - b.order;
379
384
  return a.id.localeCompare(b.id);
380
385
  });
381
- }
386
+ };
382
387
 
383
- function getByToken(
388
+ const getByToken = (
384
389
  token: TelegramSectionToken,
385
- ): RegisteredTelegramSection | undefined {
390
+ ): RegisteredTelegramSection | undefined => {
386
391
  return sections.get(token);
387
- }
392
+ };
388
393
 
389
- function getDiagnostics(): TelegramSectionDiagnostic[] {
394
+ const getDiagnostics = (): TelegramSectionDiagnostic[] => {
390
395
  return getSections().map((s) => ({
391
396
  id: s.id,
392
397
  token: s.token,
@@ -394,26 +399,26 @@ export function createTelegramExtensionSectionRegistry(): TelegramSectionRegistr
394
399
  status: errors.has(s.token) ? "error" : "active",
395
400
  lastError: errors.get(s.token)?.message,
396
401
  }));
397
- }
402
+ };
398
403
 
399
- function recordError(
404
+ const recordError = (
400
405
  token: TelegramSectionToken,
401
406
  message: string,
402
407
  source = "runtime",
403
- ): void {
408
+ ): void => {
404
409
  if (sections.has(token)) errors.set(token, { message, source });
405
- }
410
+ };
406
411
 
407
- function clearError(token: TelegramSectionToken, source?: string): void {
412
+ const clearError = (token: TelegramSectionToken, source?: string): void => {
408
413
  const current = errors.get(token);
409
414
  if (!source || !current || current.source === source) errors.delete(token);
410
- }
415
+ };
411
416
 
412
- function clear(): void {
417
+ const clear = (): void => {
413
418
  sections.clear();
414
419
  errors.clear();
415
420
  nextToken = 0;
416
- }
421
+ };
417
422
 
418
423
  return {
419
424
  register,
@@ -506,6 +511,7 @@ export function parseTelegramSectionCallback(
506
511
  /** @internal */
507
512
  export interface TelegramSectionCallbackHandlerDeps {
508
513
  answerCallbackQuery: (id: string, text?: string) => Promise<void>;
514
+ target?: TelegramSectionTarget;
509
515
  editInteractiveMessage: (
510
516
  chatId: number,
511
517
  messageId: number,
@@ -518,6 +524,7 @@ export interface TelegramSectionCallbackHandlerDeps {
518
524
  text: string,
519
525
  mode: "markdown" | "html" | "plain",
520
526
  replyMarkup: TelegramInlineKeyboardMarkup,
527
+ options?: { target?: TelegramSectionTarget },
521
528
  ) => Promise<number | undefined>;
522
529
  enqueuePrompt: (prompt: string) => Promise<void>;
523
530
  deleteMessage: (chatId: number, messageId: number) => Promise<void>;
package/lib/setup.ts CHANGED
@@ -162,7 +162,7 @@ export async function runTelegramSetup(
162
162
  export function createTelegramSetupPromptRuntime<
163
163
  TContext extends TelegramSetupPromptContext,
164
164
  >(deps: TelegramSetupPromptRuntimeDeps<TContext>) {
165
- return async function promptForConfig(ctx: TContext): Promise<void> {
165
+ return async (ctx: TContext): Promise<void> => {
166
166
  if (!ctx.hasUI || !deps.setupGuard.start()) return;
167
167
  try {
168
168
  const nextConfig = await runTelegramSetup({