@llblab/pi-telegram 0.4.0 → 0.5.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/commands.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Telegram command routing helpers
3
- * Owns slash-command normalization and command side-effect branching behind runtime ports
3
+ * Owns Telegram slash-command normalization, bot command metadata, and pi-side command registration behind runtime ports
4
4
  */
5
5
 
6
6
  import { pairTelegramUserIfNeeded } from "./config.ts";
7
+ import type { ExtensionAPI, ExtensionCommandContext } from "./pi.ts";
7
8
  import {
8
9
  createTelegramControlItemBuilder,
9
10
  createTelegramControlQueueController,
@@ -31,7 +32,10 @@ export const TELEGRAM_BOT_COMMANDS: readonly TelegramBotCommandDefinition[] = [
31
32
  },
32
33
  { command: "model", description: "Open the interactive model selector" },
33
34
  { command: "compact", description: "Compact the current pi session" },
34
- { command: "stop", description: "Abort the current pi task" },
35
+ {
36
+ command: "stop",
37
+ description: "Abort the current pi task and clear queued turns",
38
+ },
35
39
  ];
36
40
 
37
41
  export interface TelegramBotCommandRegistrationDeps {
@@ -52,22 +56,115 @@ export function createTelegramBotCommandRegistrar(
52
56
  return () => registerTelegramBotCommands(deps);
53
57
  }
54
58
 
59
+ export interface TelegramBridgeCommandStartPollingOptions {
60
+ force?: boolean;
61
+ }
62
+
63
+ export interface TelegramBridgeCommandStartPollingResult {
64
+ ok: boolean;
65
+ message?: string;
66
+ canTakeover?: boolean;
67
+ owner?: string;
68
+ }
69
+
70
+ export interface TelegramBridgeCommandRegistrationDeps {
71
+ promptForConfig: (ctx: ExtensionCommandContext) => Promise<void>;
72
+ getStatusLines: () => string[];
73
+ reloadConfig: () => Promise<void>;
74
+ hasBotToken: () => boolean;
75
+ startPolling: (
76
+ ctx: ExtensionCommandContext,
77
+ options?: TelegramBridgeCommandStartPollingOptions,
78
+ ) =>
79
+ | void
80
+ | Promise<void | TelegramBridgeCommandStartPollingResult>
81
+ | TelegramBridgeCommandStartPollingResult;
82
+ stopPolling: () => Promise<void | string>;
83
+ updateStatus: (ctx: ExtensionCommandContext) => void;
84
+ }
85
+
86
+ function formatTelegramTakeoverTitle(ctx: ExtensionCommandContext): string {
87
+ return ctx.ui.theme.fg("accent", "pi-telegram");
88
+ }
89
+
90
+ function formatTelegramTakeoverPrompt(
91
+ ctx: ExtensionCommandContext,
92
+ owner?: string,
93
+ ): string {
94
+ const theme = ctx.ui.theme;
95
+ const action = theme.fg("warning", "move singleton lock here?");
96
+ const from = theme.fg("muted", "from:");
97
+ const to = theme.fg("muted", "to:");
98
+ const source = owner ?? "another pi instance";
99
+ return `${action}\n\n${from} ${source}\n${to} ${ctx.cwd}`;
100
+ }
101
+
102
+ export function registerTelegramBridgeCommands(
103
+ pi: ExtensionAPI,
104
+ deps: TelegramBridgeCommandRegistrationDeps,
105
+ ): void {
106
+ pi.registerCommand("telegram-setup", {
107
+ description: "Configure Telegram bot token",
108
+ handler: async (_args, ctx) => {
109
+ await deps.promptForConfig(ctx);
110
+ },
111
+ });
112
+ pi.registerCommand("telegram-status", {
113
+ description: "Show Telegram bridge status",
114
+ handler: async (_args, ctx) => {
115
+ ctx.ui.notify(deps.getStatusLines().join("\n"), "info");
116
+ },
117
+ });
118
+ pi.registerCommand("telegram-connect", {
119
+ description: "Start the Telegram bridge in this pi session",
120
+ handler: async (_args, ctx) => {
121
+ await deps.reloadConfig();
122
+ if (!deps.hasBotToken()) {
123
+ await deps.promptForConfig(ctx);
124
+ return;
125
+ }
126
+ let result = await deps.startPolling(ctx);
127
+ if (result && !result.ok && result.canTakeover) {
128
+ const confirmed = await ctx.ui.confirm(
129
+ formatTelegramTakeoverTitle(ctx),
130
+ formatTelegramTakeoverPrompt(ctx, result.owner),
131
+ );
132
+ if (!confirmed) {
133
+ ctx.ui.notify("Telegram bridge takeover cancelled.", "info");
134
+ deps.updateStatus(ctx);
135
+ return;
136
+ }
137
+ result = await deps.startPolling(ctx, { force: true });
138
+ }
139
+ if (result?.message) {
140
+ ctx.ui.notify(result.message, result.ok ? "info" : "warning");
141
+ }
142
+ deps.updateStatus(ctx);
143
+ },
144
+ });
145
+ pi.registerCommand("telegram-disconnect", {
146
+ description: "Stop the Telegram bridge in this pi session",
147
+ handler: async (_args, ctx) => {
148
+ const message = await deps.stopPolling();
149
+ if (message) ctx.ui.notify(message, "info");
150
+ deps.updateStatus(ctx);
151
+ },
152
+ });
153
+ }
154
+
55
155
  export type TelegramCommandAction =
56
156
  | { kind: "ignore"; executionMode: "ignored" }
57
157
  | { kind: "stop"; executionMode: "immediate" }
58
158
  | { kind: "compact"; executionMode: "immediate" }
59
- | { kind: "status"; executionMode: "control-queue" }
60
- | { kind: "model"; executionMode: "control-queue" }
159
+ | { kind: "status"; executionMode: "immediate" }
160
+ | { kind: "model"; executionMode: "immediate" }
61
161
  | {
62
162
  kind: "help";
63
163
  commandName: "help" | "start";
64
164
  executionMode: "immediate";
65
165
  };
66
166
 
67
- export type TelegramCommandExecutionMode =
68
- | "ignored"
69
- | "immediate"
70
- | "control-queue";
167
+ export type TelegramCommandExecutionMode = "ignored" | "immediate";
71
168
 
72
169
  export interface TelegramCommandActionDeps<TMessage, TContext> {
73
170
  handleStop: (message: TMessage, ctx: TContext) => Promise<void>;
@@ -84,7 +181,7 @@ export interface TelegramCommandActionDeps<TMessage, TContext> {
84
181
  export interface TelegramStopCommandDeps {
85
182
  hasAbortHandler: () => boolean;
86
183
  clearPendingModelSwitch: () => void;
87
- hasQueuedTelegramItems: () => boolean;
184
+ clearQueuedTelegramItems: () => number;
88
185
  setPreserveQueuedTurnsAsHistory: (preserve: boolean) => void;
89
186
  abortCurrentTurn: () => void;
90
187
  updateStatus: () => void;
@@ -99,8 +196,7 @@ export interface TelegramRuntimeEventRecorderPort {
99
196
  ) => void;
100
197
  }
101
198
 
102
- export interface TelegramCompactCommandDeps
103
- extends TelegramRuntimeEventRecorderPort {
199
+ export interface TelegramCompactCommandDeps extends TelegramRuntimeEventRecorderPort {
104
200
  isIdle: () => boolean;
105
201
  hasPendingMessages: () => boolean;
106
202
  hasActiveTelegramTurn: () => boolean;
@@ -130,14 +226,6 @@ export interface TelegramHelpCommandDeps {
130
226
  export type TelegramControlCommandType =
131
227
  PendingTelegramControlItem<unknown>["controlType"];
132
228
 
133
- export interface TelegramQueuedControlCommandDeps<TContext> {
134
- enqueueControlItem: (
135
- controlType: TelegramControlCommandType,
136
- statusSummary: string,
137
- execute: (ctx: TContext) => Promise<void>,
138
- ) => void;
139
- }
140
-
141
229
  export interface TelegramCommandRuntimeMessage {
142
230
  chat: { id: number };
143
231
  message_id: number;
@@ -323,6 +411,7 @@ export interface TelegramCommandRuntimeDeps<
323
411
  hasAbortHandler: () => boolean;
324
412
  clearPendingModelSwitch: () => void;
325
413
  hasQueuedTelegramItems: () => boolean;
414
+ clearQueuedTelegramItems: (ctx: TContext) => number;
326
415
  setPreserveQueuedTurnsAsHistory: (preserve: boolean) => void;
327
416
  abortCurrentTurn: () => void;
328
417
  isIdle: (ctx: TContext) => boolean;
@@ -354,7 +443,7 @@ export interface TelegramCommandRuntimeDeps<
354
443
  }
355
444
 
356
445
  export const TELEGRAM_HELP_TEXT =
357
- "Send me a message and I will forward it to pi. Commands: /status, /model, /compact, /stop.";
446
+ "Send me a message and I will forward it to pi. Commands: /status, /model, /compact, /stop. /stop aborts the current run and clears queued Telegram turns.";
358
447
 
359
448
  function getTelegramCommandErrorMessage(error: unknown): string {
360
449
  return error instanceof Error ? error.message : String(error);
@@ -380,9 +469,9 @@ export function buildTelegramCommandAction(
380
469
  case "compact":
381
470
  return { kind: "compact", executionMode: "immediate" };
382
471
  case "status":
383
- return { kind: "status", executionMode: "control-queue" };
472
+ return { kind: "status", executionMode: "immediate" };
384
473
  case "model":
385
- return { kind: "model", executionMode: "control-queue" };
474
+ return { kind: "model", executionMode: "immediate" };
386
475
  case "help":
387
476
  case "start":
388
477
  return { kind: "help", commandName, executionMode: "immediate" };
@@ -397,20 +486,32 @@ export function getTelegramCommandExecutionMode(
397
486
  return action.executionMode;
398
487
  }
399
488
 
489
+ function formatTelegramQueuedTurnCount(count: number): string {
490
+ return count === 1 ? "1 queued turn" : `${count} queued turns`;
491
+ }
492
+
400
493
  export async function handleTelegramStopCommand(
401
494
  deps: TelegramStopCommandDeps,
402
495
  ): Promise<void> {
496
+ deps.clearPendingModelSwitch();
497
+ const clearedCount = deps.clearQueuedTelegramItems();
498
+ deps.setPreserveQueuedTurnsAsHistory(false);
403
499
  if (!deps.hasAbortHandler()) {
404
- await deps.sendTextReply("No active turn.");
500
+ const clearedSuffix =
501
+ clearedCount > 0
502
+ ? ` Cleared ${formatTelegramQueuedTurnCount(clearedCount)}.`
503
+ : "";
504
+ if (clearedCount > 0) deps.updateStatus();
505
+ await deps.sendTextReply(`No active turn.${clearedSuffix}`);
405
506
  return;
406
507
  }
407
- deps.clearPendingModelSwitch();
408
- if (deps.hasQueuedTelegramItems()) {
409
- deps.setPreserveQueuedTurnsAsHistory(true);
410
- }
411
508
  deps.abortCurrentTurn();
412
509
  deps.updateStatus();
413
- await deps.sendTextReply("Aborted current turn.");
510
+ const clearedSuffix =
511
+ clearedCount > 0
512
+ ? ` Cleared ${formatTelegramQueuedTurnCount(clearedCount)}.`
513
+ : "";
514
+ await deps.sendTextReply(`Aborted current turn.${clearedSuffix}`);
414
515
  }
415
516
 
416
517
  export async function handleTelegramCompactCommand(
@@ -483,20 +584,18 @@ export async function handleTelegramHelpCommand(
483
584
  });
484
585
  }
485
586
 
486
- export async function handleTelegramStatusCommand<TContext>(
487
- deps: TelegramQueuedControlCommandDeps<TContext> & {
488
- showStatus: (ctx: TContext) => Promise<void>;
489
- },
490
- ): Promise<void> {
491
- deps.enqueueControlItem("status", "⚡ status", deps.showStatus);
587
+ export async function handleTelegramStatusCommand<TContext>(deps: {
588
+ ctx: TContext;
589
+ showStatus: (ctx: TContext) => Promise<void>;
590
+ }): Promise<void> {
591
+ await deps.showStatus(deps.ctx);
492
592
  }
493
593
 
494
- export async function handleTelegramModelCommand<TContext>(
495
- deps: TelegramQueuedControlCommandDeps<TContext> & {
496
- openModelMenu: (ctx: TContext) => Promise<void>;
497
- },
498
- ): Promise<void> {
499
- deps.enqueueControlItem("model", "⚡ model", deps.openModelMenu);
594
+ export async function handleTelegramModelCommand<TContext>(deps: {
595
+ ctx: TContext;
596
+ openModelMenu: (ctx: TContext) => Promise<void>;
597
+ }): Promise<void> {
598
+ await deps.openModelMenu(deps.ctx);
500
599
  }
501
600
 
502
601
  export async function executeTelegramCommandAction<TMessage, TContext>(
@@ -573,6 +672,7 @@ export function createTelegramCommandHandlerTargetRuntime<
573
672
  hasAbortHandler: deps.hasAbortHandler,
574
673
  clearPendingModelSwitch: deps.clearPendingModelSwitch,
575
674
  hasQueuedTelegramItems: deps.hasQueuedTelegramItems,
675
+ clearQueuedTelegramItems: deps.clearQueuedTelegramItems,
576
676
  setPreserveQueuedTurnsAsHistory: deps.setPreserveQueuedTurnsAsHistory,
577
677
  abortCurrentTurn: deps.abortCurrentTurn,
578
678
  isIdle: deps.isIdle,
@@ -644,21 +744,6 @@ async function handleTelegramCommandRuntime<
644
744
  deps.sendTextReply(nextMessage, text);
645
745
  const updateStatusFor = (commandCtx: TContext) => () =>
646
746
  deps.updateStatus(commandCtx);
647
- const enqueueControlFor =
648
- (nextMessage: TMessage, commandCtx: TContext) =>
649
- (
650
- controlType: TelegramControlCommandType,
651
- statusSummary: string,
652
- execute: (ctx: TContext) => Promise<void>,
653
- ) => {
654
- deps.enqueueControlItem(
655
- nextMessage,
656
- commandCtx,
657
- controlType,
658
- statusSummary,
659
- execute,
660
- );
661
- };
662
747
  return executeTelegramCommandAction(
663
748
  buildTelegramCommandAction(commandName),
664
749
  message,
@@ -668,7 +753,8 @@ async function handleTelegramCommandRuntime<
668
753
  await handleTelegramStopCommand({
669
754
  hasAbortHandler: deps.hasAbortHandler,
670
755
  clearPendingModelSwitch: deps.clearPendingModelSwitch,
671
- hasQueuedTelegramItems: deps.hasQueuedTelegramItems,
756
+ clearQueuedTelegramItems: () =>
757
+ deps.clearQueuedTelegramItems(commandCtx),
672
758
  setPreserveQueuedTurnsAsHistory: deps.setPreserveQueuedTurnsAsHistory,
673
759
  abortCurrentTurn: deps.abortCurrentTurn,
674
760
  updateStatus: updateStatusFor(commandCtx),
@@ -694,13 +780,13 @@ async function handleTelegramCommandRuntime<
694
780
  },
695
781
  handleStatus: async (nextMessage, commandCtx) => {
696
782
  await handleTelegramStatusCommand<TContext>({
697
- enqueueControlItem: enqueueControlFor(nextMessage, commandCtx),
783
+ ctx: commandCtx,
698
784
  showStatus: (controlCtx) => deps.showStatus(nextMessage, controlCtx),
699
785
  });
700
786
  },
701
787
  handleModel: async (nextMessage, commandCtx) => {
702
788
  await handleTelegramModelCommand<TContext>({
703
- enqueueControlItem: enqueueControlFor(nextMessage, commandCtx),
789
+ ctx: commandCtx,
704
790
  openModelMenu: (controlCtx) =>
705
791
  deps.openModelMenu(nextMessage, controlCtx),
706
792
  });