@llblab/pi-telegram 0.2.9 → 0.3.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/pi.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * pi SDK adapter boundary
3
+ * Owns direct pi SDK imports and exposes narrow bridge-facing helpers/types for the extension composition layer
4
+ */
5
+
6
+ import {
7
+ type AgentEndEvent,
8
+ type AgentStartEvent,
9
+ type BeforeAgentStartEvent,
10
+ type ExtensionAPI,
11
+ type ExtensionCommandContext,
12
+ type ExtensionContext,
13
+ type SessionShutdownEvent,
14
+ type SessionStartEvent,
15
+ SettingsManager,
16
+ } from "@mariozechner/pi-coding-agent";
17
+
18
+ export type {
19
+ AgentEndEvent,
20
+ AgentStartEvent,
21
+ BeforeAgentStartEvent,
22
+ ExtensionAPI,
23
+ ExtensionCommandContext,
24
+ ExtensionContext,
25
+ SessionShutdownEvent,
26
+ SessionStartEvent,
27
+ };
28
+
29
+ export interface PiSettingsManager {
30
+ reload: () => Promise<void>;
31
+ getEnabledModels: () => string[] | undefined;
32
+ }
33
+
34
+ export interface PiExtensionApiRuntimePorts {
35
+ sendUserMessage: ExtensionAPI["sendUserMessage"];
36
+ getThinkingLevel: ExtensionAPI["getThinkingLevel"];
37
+ setThinkingLevel: ExtensionAPI["setThinkingLevel"];
38
+ setModel: ExtensionAPI["setModel"];
39
+ }
40
+
41
+ export function createExtensionApiRuntimePorts(
42
+ api: Pick<
43
+ ExtensionAPI,
44
+ "sendUserMessage" | "getThinkingLevel" | "setThinkingLevel" | "setModel"
45
+ >,
46
+ ): PiExtensionApiRuntimePorts {
47
+ return {
48
+ sendUserMessage: (content) => api.sendUserMessage(content),
49
+ getThinkingLevel: () => api.getThinkingLevel(),
50
+ setThinkingLevel: (level) => api.setThinkingLevel(level),
51
+ setModel: (model) => api.setModel(model),
52
+ };
53
+ }
54
+
55
+ export function createSettingsManager(cwd: string): PiSettingsManager {
56
+ return SettingsManager.create(cwd);
57
+ }
58
+
59
+ export function getExtensionContextModel(
60
+ ctx: ExtensionContext,
61
+ ): ExtensionContext["model"] {
62
+ return ctx.model;
63
+ }
64
+
65
+ export function isExtensionContextIdle(ctx: ExtensionContext): boolean {
66
+ return ctx.isIdle();
67
+ }
68
+
69
+ export function hasExtensionContextPendingMessages(
70
+ ctx: ExtensionContext,
71
+ ): boolean {
72
+ return ctx.hasPendingMessages();
73
+ }
74
+
75
+ export function compactExtensionContext(
76
+ ctx: ExtensionContext,
77
+ callbacks: Parameters<ExtensionContext["compact"]>[0],
78
+ ): ReturnType<ExtensionContext["compact"]> {
79
+ return ctx.compact(callbacks);
80
+ }
package/lib/polling.ts CHANGED
@@ -3,11 +3,12 @@
3
3
  * Owns polling request builders, stop conditions, and the long-poll loop runtime for Telegram updates
4
4
  */
5
5
 
6
- import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
7
-
8
- import type { TelegramConfig } from "./api.ts";
6
+ export interface TelegramPollingConfig {
7
+ botToken?: string;
8
+ lastUpdateId?: number;
9
+ }
9
10
 
10
- export interface TelegramUpdateLike {
11
+ export interface TelegramUpdate {
11
12
  update_id: number;
12
13
  }
13
14
 
@@ -47,7 +48,7 @@ export function buildTelegramLongPollRequest(lastUpdateId?: number): {
47
48
  }
48
49
 
49
50
  export function getLatestTelegramUpdateId(
50
- updates: TelegramUpdateLike[],
51
+ updates: TelegramUpdate[],
51
52
  ): number | undefined {
52
53
  return updates.at(-1)?.update_id;
53
54
  }
@@ -62,25 +63,241 @@ export function shouldStopTelegramPolling(
62
63
  );
63
64
  }
64
65
 
65
- export interface TelegramPollLoopDeps<TUpdate extends TelegramUpdateLike> {
66
- ctx: ExtensionContext;
66
+ export interface TelegramPollingStartState {
67
+ hasBotToken: boolean;
68
+ hasPollingPromise: boolean;
69
+ }
70
+
71
+ export interface TelegramPollingControllerState {
72
+ pollingPromise?: Promise<void>;
73
+ pollingController?: AbortController;
74
+ }
75
+
76
+ export function createTelegramPollingControllerState(): TelegramPollingControllerState {
77
+ return {};
78
+ }
79
+
80
+ export function isTelegramPollingControllerActive(
81
+ state: TelegramPollingControllerState,
82
+ ): boolean {
83
+ return !!state.pollingPromise;
84
+ }
85
+
86
+ export function createTelegramPollingActivityReader(
87
+ state: TelegramPollingControllerState,
88
+ ): () => boolean {
89
+ return () => isTelegramPollingControllerActive(state);
90
+ }
91
+
92
+ export interface TelegramPollingRuntimeDeps<TContext> {
93
+ hasBotToken: () => boolean;
94
+ getPollingPromise: () => Promise<void> | undefined;
95
+ setPollingPromise: (promise: Promise<void> | undefined) => void;
96
+ getPollingController: () => AbortController | undefined;
97
+ setPollingController: (controller: AbortController | undefined) => void;
98
+ stopTypingLoop: () => unknown;
99
+ runPollLoop: (ctx: TContext, signal: AbortSignal) => Promise<void>;
100
+ updateStatus: (ctx: TContext) => void;
101
+ createAbortController?: () => AbortController;
102
+ }
103
+
104
+ export type TelegramPollingControllerDeps<TContext> = Omit<
105
+ TelegramPollingRuntimeDeps<TContext>,
106
+ | "getPollingPromise"
107
+ | "setPollingPromise"
108
+ | "getPollingController"
109
+ | "setPollingController"
110
+ > & { state?: TelegramPollingControllerState };
111
+
112
+ export interface TelegramPollingController<TContext> {
113
+ isActive: () => boolean;
114
+ start: (ctx: TContext) => void;
115
+ stop: () => Promise<void>;
116
+ }
117
+
118
+ export interface TelegramPollingControllerRuntimeDeps<
119
+ TUpdate extends TelegramUpdate,
120
+ TContext = unknown,
121
+ > extends TelegramPollLoopRunnerDeps<TUpdate, TContext> {
122
+ state?: TelegramPollingControllerState;
123
+ hasBotToken: () => boolean;
124
+ stopTypingLoop: () => unknown;
125
+ createAbortController?: () => AbortController;
126
+ }
127
+
128
+ export function createTelegramPollingControllerRuntime<
129
+ TUpdate extends TelegramUpdate,
130
+ TContext = unknown,
131
+ >(
132
+ deps: TelegramPollingControllerRuntimeDeps<TUpdate, TContext>,
133
+ ): TelegramPollingController<TContext> {
134
+ return createTelegramPollingController({
135
+ state: deps.state,
136
+ hasBotToken: deps.hasBotToken,
137
+ stopTypingLoop: deps.stopTypingLoop,
138
+ runPollLoop: createTelegramPollLoopRunner<TUpdate, TContext>({
139
+ getConfig: deps.getConfig,
140
+ deleteWebhook: deps.deleteWebhook,
141
+ getUpdates: deps.getUpdates,
142
+ persistConfig: deps.persistConfig,
143
+ handleUpdate: deps.handleUpdate,
144
+ updateStatus: deps.updateStatus,
145
+ sleep: deps.sleep,
146
+ maxUpdateFailures: deps.maxUpdateFailures,
147
+ recordRuntimeEvent: deps.recordRuntimeEvent,
148
+ }),
149
+ updateStatus: deps.updateStatus,
150
+ createAbortController: deps.createAbortController,
151
+ });
152
+ }
153
+
154
+ export function createTelegramPollingController<TContext>(
155
+ deps: TelegramPollingControllerDeps<TContext>,
156
+ ): TelegramPollingController<TContext> {
157
+ const state = deps.state ?? createTelegramPollingControllerState();
158
+ const runtimeDeps: TelegramPollingRuntimeDeps<TContext> = {
159
+ ...deps,
160
+ getPollingPromise: () => state.pollingPromise,
161
+ setPollingPromise: (promise) => {
162
+ state.pollingPromise = promise;
163
+ },
164
+ getPollingController: () => state.pollingController,
165
+ setPollingController: (controller) => {
166
+ state.pollingController = controller;
167
+ },
168
+ };
169
+ return {
170
+ isActive: () => isTelegramPollingControllerActive(state),
171
+ start: (ctx) => startTelegramPollingRuntime(ctx, runtimeDeps),
172
+ stop: () => stopTelegramPollingRuntime(runtimeDeps),
173
+ };
174
+ }
175
+
176
+ export function shouldStartTelegramPolling(
177
+ state: TelegramPollingStartState,
178
+ ): boolean {
179
+ return state.hasBotToken && !state.hasPollingPromise;
180
+ }
181
+
182
+ export async function stopTelegramPollingRuntime<TContext>(
183
+ deps: TelegramPollingRuntimeDeps<TContext>,
184
+ ): Promise<void> {
185
+ deps.stopTypingLoop();
186
+ deps.getPollingController()?.abort();
187
+ deps.setPollingController(undefined);
188
+ await deps.getPollingPromise()?.catch(() => undefined);
189
+ deps.setPollingPromise(undefined);
190
+ }
191
+
192
+ export function startTelegramPollingRuntime<TContext>(
193
+ ctx: TContext,
194
+ deps: TelegramPollingRuntimeDeps<TContext>,
195
+ ): void {
196
+ if (
197
+ !shouldStartTelegramPolling({
198
+ hasBotToken: deps.hasBotToken(),
199
+ hasPollingPromise: !!deps.getPollingPromise(),
200
+ })
201
+ ) {
202
+ return;
203
+ }
204
+ const controller = deps.createAbortController?.() ?? new AbortController();
205
+ deps.setPollingController(controller);
206
+ const promise = deps.runPollLoop(ctx, controller.signal).finally(() => {
207
+ deps.setPollingPromise(undefined);
208
+ deps.setPollingController(undefined);
209
+ deps.updateStatus(ctx);
210
+ });
211
+ deps.setPollingPromise(promise);
212
+ deps.updateStatus(ctx);
213
+ }
214
+
215
+ export interface TelegramRuntimeEventRecorderPort {
216
+ recordRuntimeEvent?: (
217
+ category: string,
218
+ error: unknown,
219
+ details?: Record<string, unknown>,
220
+ ) => void;
221
+ }
222
+
223
+ export interface TelegramPollLoopDeps<
224
+ TUpdate extends TelegramUpdate,
225
+ TContext = unknown,
226
+ > extends TelegramRuntimeEventRecorderPort {
227
+ ctx: TContext;
67
228
  signal: AbortSignal;
68
- config: TelegramConfig;
69
- deleteWebhook: (signal: AbortSignal) => Promise<void>;
229
+ config: TelegramPollingConfig;
230
+ deleteWebhook: (signal: AbortSignal) => Promise<unknown>;
70
231
  getUpdates: (
71
232
  body: Record<string, unknown>,
72
233
  signal: AbortSignal,
73
234
  ) => Promise<TUpdate[]>;
74
235
  persistConfig: () => Promise<void>;
75
- handleUpdate: (update: TUpdate, ctx: ExtensionContext) => Promise<void>;
236
+ handleUpdate: (update: TUpdate, ctx: TContext) => Promise<void>;
76
237
  onErrorStatus: (message: string) => void;
77
238
  onStatusReset: () => void;
78
239
  sleep: (ms: number) => Promise<void>;
240
+ maxUpdateFailures?: number;
79
241
  }
80
242
 
81
- export async function runTelegramPollLoop<TUpdate extends TelegramUpdateLike>(
82
- deps: TelegramPollLoopDeps<TUpdate>,
83
- ): Promise<void> {
243
+ export interface TelegramPollLoopRunnerDeps<
244
+ TUpdate extends TelegramUpdate,
245
+ TContext = unknown,
246
+ > extends TelegramRuntimeEventRecorderPort {
247
+ getConfig: () => TelegramPollingConfig;
248
+ deleteWebhook: (signal: AbortSignal) => Promise<unknown>;
249
+ getUpdates: (
250
+ body: Record<string, unknown>,
251
+ signal: AbortSignal,
252
+ ) => Promise<TUpdate[]>;
253
+ persistConfig: () => Promise<void>;
254
+ handleUpdate: (update: TUpdate, ctx: TContext) => Promise<void>;
255
+ updateStatus: (ctx: TContext, message?: string) => void;
256
+ sleep?: (ms: number) => Promise<void>;
257
+ maxUpdateFailures?: number;
258
+ }
259
+
260
+ export function createTelegramPollLoopRunner<
261
+ TUpdate extends TelegramUpdate,
262
+ TContext = unknown,
263
+ >(
264
+ deps: TelegramPollLoopRunnerDeps<TUpdate, TContext>,
265
+ ): (ctx: TContext, signal: AbortSignal) => Promise<void> {
266
+ const sleep =
267
+ deps.sleep ??
268
+ ((ms: number) =>
269
+ new Promise<void>((resolve) => {
270
+ setTimeout(resolve, ms);
271
+ }));
272
+ return (ctx, signal) =>
273
+ runTelegramPollLoop({
274
+ ctx,
275
+ signal,
276
+ config: deps.getConfig(),
277
+ deleteWebhook: deps.deleteWebhook,
278
+ getUpdates: deps.getUpdates,
279
+ persistConfig: deps.persistConfig,
280
+ handleUpdate: deps.handleUpdate,
281
+ onErrorStatus: (message) => {
282
+ deps.updateStatus(ctx, message);
283
+ },
284
+ onStatusReset: () => {
285
+ deps.updateStatus(ctx);
286
+ },
287
+ sleep,
288
+ maxUpdateFailures: deps.maxUpdateFailures,
289
+ recordRuntimeEvent: deps.recordRuntimeEvent,
290
+ });
291
+ }
292
+
293
+ function getTelegramPollingErrorMessage(error: unknown): string {
294
+ return error instanceof Error ? error.message : String(error);
295
+ }
296
+
297
+ export async function runTelegramPollLoop<
298
+ TUpdate extends TelegramUpdate,
299
+ TContext = unknown,
300
+ >(deps: TelegramPollLoopDeps<TUpdate, TContext>): Promise<void> {
84
301
  if (!deps.config.botToken) return;
85
302
  try {
86
303
  await deps.deleteWebhook(deps.signal);
@@ -102,6 +319,9 @@ export async function runTelegramPollLoop<TUpdate extends TelegramUpdateLike>(
102
319
  // ignore
103
320
  }
104
321
  }
322
+ const maxUpdateFailures = Math.max(1, deps.maxUpdateFailures ?? 3);
323
+ const updateFailures = new Map<number, number>();
324
+ let handledUpdateFailureRethrown = false;
105
325
  while (!deps.signal.aborted) {
106
326
  try {
107
327
  const updates = await deps.getUpdates(
@@ -109,14 +329,40 @@ export async function runTelegramPollLoop<TUpdate extends TelegramUpdateLike>(
109
329
  deps.signal,
110
330
  );
111
331
  for (const update of updates) {
112
- deps.config.lastUpdateId = update.update_id;
113
- await deps.persistConfig();
114
- await deps.handleUpdate(update, deps.ctx);
332
+ try {
333
+ await deps.handleUpdate(update, deps.ctx);
334
+ deps.config.lastUpdateId = update.update_id;
335
+ updateFailures.delete(update.update_id);
336
+ await deps.persistConfig();
337
+ } catch (error) {
338
+ const failureCount = (updateFailures.get(update.update_id) ?? 0) + 1;
339
+ updateFailures.set(update.update_id, failureCount);
340
+ deps.recordRuntimeEvent?.("polling", error, {
341
+ phase: "handleUpdate",
342
+ updateId: update.update_id,
343
+ failureCount,
344
+ });
345
+ if (failureCount < maxUpdateFailures) {
346
+ handledUpdateFailureRethrown = true;
347
+ throw error;
348
+ }
349
+ const message = getTelegramPollingErrorMessage(error);
350
+ deps.onErrorStatus(
351
+ `skipping Telegram update ${update.update_id} after ${failureCount} failures: ${message}`,
352
+ );
353
+ deps.config.lastUpdateId = update.update_id;
354
+ updateFailures.delete(update.update_id);
355
+ await deps.persistConfig();
356
+ }
115
357
  }
116
358
  } catch (error) {
117
359
  if (shouldStopTelegramPolling(deps.signal.aborted, error)) return;
118
- const message = error instanceof Error ? error.message : String(error);
119
- deps.onErrorStatus(message);
360
+ if (handledUpdateFailureRethrown) {
361
+ handledUpdateFailureRethrown = false;
362
+ } else {
363
+ deps.recordRuntimeEvent?.("polling", error, { phase: "loop" });
364
+ }
365
+ deps.onErrorStatus(getTelegramPollingErrorMessage(error));
120
366
  await deps.sleep(3000);
121
367
  deps.onStatusReset();
122
368
  }