@llblab/pi-telegram 0.2.10 → 0.4.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 (47) hide show
  1. package/README.md +52 -19
  2. package/docs/README.md +2 -3
  3. package/docs/architecture.md +62 -31
  4. package/docs/locks.md +136 -0
  5. package/index.ts +323 -1880
  6. package/lib/api.ts +396 -60
  7. package/lib/attachments.ts +128 -16
  8. package/lib/commands.ts +648 -14
  9. package/lib/config.ts +169 -0
  10. package/lib/handlers.ts +474 -0
  11. package/lib/locks.ts +306 -0
  12. package/lib/media.ts +196 -46
  13. package/lib/menu.ts +920 -338
  14. package/lib/model.ts +647 -0
  15. package/lib/pi.ts +90 -0
  16. package/lib/polling.ts +240 -14
  17. package/lib/preview.ts +420 -25
  18. package/lib/queue.ts +1137 -110
  19. package/lib/registration.ts +214 -31
  20. package/lib/rendering.ts +560 -366
  21. package/lib/replies.ts +198 -8
  22. package/lib/routing.ts +217 -0
  23. package/lib/runtime.ts +475 -0
  24. package/lib/setup.ts +143 -1
  25. package/lib/status.ts +432 -13
  26. package/lib/turns.ts +217 -36
  27. package/lib/updates.ts +340 -109
  28. package/package.json +18 -3
  29. package/AGENTS.md +0 -91
  30. package/BACKLOG.md +0 -5
  31. package/CHANGELOG.md +0 -34
  32. package/lib/model-switch.ts +0 -62
  33. package/lib/types.ts +0 -137
  34. package/tests/api.test.ts +0 -331
  35. package/tests/attachments.test.ts +0 -132
  36. package/tests/commands.test.ts +0 -85
  37. package/tests/config.test.ts +0 -80
  38. package/tests/media.test.ts +0 -166
  39. package/tests/menu.test.ts +0 -676
  40. package/tests/polling.test.ts +0 -202
  41. package/tests/preview.test.ts +0 -480
  42. package/tests/queue.test.ts +0 -3245
  43. package/tests/registration.test.ts +0 -268
  44. package/tests/rendering.test.ts +0 -526
  45. package/tests/replies.test.ts +0 -142
  46. package/tests/turns.test.ts +0 -247
  47. package/tests/updates.test.ts +0 -416
package/lib/locks.ts ADDED
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Telegram singleton lock helpers
3
+ * Owns shared locks.json access and Telegram bridge ownership semantics
4
+ */
5
+
6
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { dirname, join, resolve } from "node:path";
9
+
10
+ export const TELEGRAM_LOCK_KEY = "@llblab/pi-telegram";
11
+
12
+ function getAgentDir(): string {
13
+ return process.env.PI_CODING_AGENT_DIR
14
+ ? resolve(process.env.PI_CODING_AGENT_DIR)
15
+ : join(homedir(), ".pi", "agent");
16
+ }
17
+
18
+ function getLocksPath(): string {
19
+ return join(getAgentDir(), "locks.json");
20
+ }
21
+
22
+ export interface TelegramLockEntry {
23
+ pid: number;
24
+ cwd?: string;
25
+ }
26
+
27
+ export interface TelegramLockContext {
28
+ cwd: string;
29
+ }
30
+
31
+ export type TelegramLockState =
32
+ | { kind: "inactive" }
33
+ | { kind: "active-here"; lock: TelegramLockEntry }
34
+ | { kind: "active-elsewhere"; lock: TelegramLockEntry }
35
+ | { kind: "stale"; lock: TelegramLockEntry };
36
+
37
+ export interface TelegramLockAcquireOptions {
38
+ force?: boolean;
39
+ }
40
+
41
+ export type TelegramLockAcquireResult =
42
+ | { ok: true; lock: TelegramLockEntry; replacedStale: boolean }
43
+ | { ok: false; lock: TelegramLockEntry };
44
+
45
+ export interface TelegramLockRuntime<TContext extends TelegramLockContext> {
46
+ acquire: (
47
+ ctx: TContext,
48
+ options?: TelegramLockAcquireOptions,
49
+ ) => TelegramLockAcquireResult;
50
+ release: () => TelegramLockState;
51
+ getState: () => TelegramLockState;
52
+ getStatusLabel: () => string;
53
+ owns: (ctx?: TelegramLockContext) => boolean;
54
+ }
55
+
56
+ export interface TelegramLockRuntimeOptions {
57
+ key?: string;
58
+ locksPath?: string;
59
+ pid?: number;
60
+ isProcessAlive?: (pid: number) => boolean;
61
+ }
62
+
63
+ export interface TelegramLockedPollingStartOptions {
64
+ force?: boolean;
65
+ }
66
+
67
+ export type TelegramLockedPollingStartResult =
68
+ | { ok: true; message: string; canTakeover?: false }
69
+ | { ok: false; message: string; canTakeover?: boolean; owner?: string };
70
+
71
+ export interface TelegramLockedPollingRuntime<TContext extends TelegramLockContext> {
72
+ start: (
73
+ ctx: TContext,
74
+ options?: TelegramLockedPollingStartOptions,
75
+ ) => Promise<TelegramLockedPollingStartResult>;
76
+ stop: () => Promise<string>;
77
+ suspend: () => Promise<void>;
78
+ onSessionStart: (_event: unknown, ctx: TContext) => Promise<void>;
79
+ }
80
+
81
+ export interface TelegramLockedPollingRuntimeDeps<TContext extends TelegramLockContext> {
82
+ lock: TelegramLockRuntime<TContext>;
83
+ hasBotToken: () => boolean;
84
+ startPolling: (ctx: TContext) => void | Promise<void>;
85
+ stopPolling: () => Promise<void>;
86
+ updateStatus: (ctx: TContext) => void;
87
+ recordRuntimeEvent?: (category: string, error: unknown, details?: Record<string, unknown>) => void;
88
+ ownershipCheckMs?: number;
89
+ }
90
+
91
+ export function readLocks(path = getLocksPath()): Record<string, unknown> {
92
+ if (!existsSync(path)) return {};
93
+ try {
94
+ const value = JSON.parse(readFileSync(path, "utf8"));
95
+ return value && typeof value === "object" && !Array.isArray(value)
96
+ ? (value as Record<string, unknown>)
97
+ : {};
98
+ } catch {
99
+ return {};
100
+ }
101
+ }
102
+
103
+ export function writeLocks(path: string, locks: Record<string, unknown>): void {
104
+ mkdirSync(dirname(path), { recursive: true });
105
+ const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
106
+ try {
107
+ writeFileSync(tempPath, `${JSON.stringify(locks, null, 2)}\n`, "utf8");
108
+ renameSync(tempPath, path);
109
+ } catch (error) {
110
+ try {
111
+ unlinkSync(tempPath);
112
+ } catch {
113
+ /* best effort */
114
+ }
115
+ throw error;
116
+ }
117
+ }
118
+
119
+ export function parseTelegramLockEntry(value: unknown): TelegramLockEntry | undefined {
120
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
121
+ const record = value as Record<string, unknown>;
122
+ if (typeof record.pid !== "number") return undefined;
123
+ return {
124
+ pid: record.pid,
125
+ cwd: typeof record.cwd === "string" ? record.cwd : undefined,
126
+ };
127
+ }
128
+
129
+ export function isProcessAlive(pid: number): boolean {
130
+ if (!Number.isInteger(pid) || pid <= 0) return false;
131
+ try {
132
+ process.kill(pid, 0);
133
+ return true;
134
+ } catch (error) {
135
+ return (error as { code?: string }).code === "EPERM";
136
+ }
137
+ }
138
+
139
+ function formatLock(lock: TelegramLockEntry): string {
140
+ return lock.cwd ? `pid ${lock.pid}, cwd ${lock.cwd}` : `pid ${lock.pid}`;
141
+ }
142
+
143
+ function getLockState(lock: TelegramLockEntry | undefined, pid: number, isAlive: (pid: number) => boolean): TelegramLockState {
144
+ if (!lock) return { kind: "inactive" };
145
+ if (lock.pid === pid) return { kind: "active-here", lock };
146
+ if (isAlive(lock.pid)) return { kind: "active-elsewhere", lock };
147
+ return { kind: "stale", lock };
148
+ }
149
+
150
+ function ownsLockContext(
151
+ lock: TelegramLockEntry | undefined,
152
+ pid: number,
153
+ ctx?: TelegramLockContext,
154
+ ): boolean {
155
+ if (!lock || lock.pid !== pid) return false;
156
+ return !lock.cwd || !ctx || lock.cwd === ctx.cwd;
157
+ }
158
+
159
+ function snapshotLockContext(ctx: TelegramLockContext): TelegramLockContext {
160
+ return { cwd: ctx.cwd };
161
+ }
162
+
163
+ function formatLockState(state: TelegramLockState): string {
164
+ switch (state.kind) {
165
+ case "inactive":
166
+ return "inactive";
167
+ case "active-here":
168
+ return "active here";
169
+ case "active-elsewhere":
170
+ return `active elsewhere (${formatLock(state.lock)})`;
171
+ case "stale":
172
+ return `stale (${formatLock(state.lock)})`;
173
+ }
174
+ }
175
+
176
+ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
177
+ options: TelegramLockRuntimeOptions = {},
178
+ ): TelegramLockRuntime<TContext> {
179
+ const key = options.key ?? TELEGRAM_LOCK_KEY;
180
+ const locksPath = options.locksPath ?? getLocksPath();
181
+ const pid = options.pid ?? process.pid;
182
+ const isAlive = options.isProcessAlive ?? isProcessAlive;
183
+ const readLock = () => parseTelegramLockEntry(readLocks(locksPath)[key]);
184
+ const writeLock = (lock: TelegramLockEntry) => {
185
+ const locks = readLocks(locksPath);
186
+ locks[key] = lock;
187
+ writeLocks(locksPath, locks);
188
+ };
189
+ return {
190
+ acquire: (ctx, acquireOptions = {}) => {
191
+ const state = getLockState(readLock(), pid, isAlive);
192
+ if (state.kind === "active-elsewhere" && !acquireOptions.force)
193
+ return { ok: false, lock: state.lock };
194
+ const lock = { pid, cwd: ctx.cwd };
195
+ writeLock(lock);
196
+ return { ok: true, lock, replacedStale: state.kind === "stale" };
197
+ },
198
+ release: () => {
199
+ const state = getLockState(readLock(), pid, isAlive);
200
+ if (state.kind === "active-here" || state.kind === "stale") {
201
+ const locks = readLocks(locksPath);
202
+ delete locks[key];
203
+ writeLocks(locksPath, locks);
204
+ }
205
+ return state;
206
+ },
207
+ getState: () => getLockState(readLock(), pid, isAlive),
208
+ getStatusLabel: () => formatLockState(getLockState(readLock(), pid, isAlive)),
209
+ owns: (ctx) => ownsLockContext(readLock(), pid, ctx),
210
+ };
211
+ }
212
+
213
+ export function createTelegramLockedPollingRuntime<TContext extends TelegramLockContext>(
214
+ deps: TelegramLockedPollingRuntimeDeps<TContext>,
215
+ ): TelegramLockedPollingRuntime<TContext> {
216
+ let ownershipInterval: ReturnType<typeof setInterval> | undefined;
217
+ let ownershipStop: Promise<void> | undefined;
218
+ const ownershipCheckMs = deps.ownershipCheckMs ?? 1000;
219
+ const stopOwnershipWatcher = () => {
220
+ if (!ownershipInterval) return;
221
+ clearInterval(ownershipInterval);
222
+ ownershipInterval = undefined;
223
+ };
224
+ const updateStatusSafely = (ctx: TContext, phase: string) => {
225
+ try {
226
+ deps.updateStatus(ctx);
227
+ } catch (error) {
228
+ deps.recordRuntimeEvent?.("lock", error, { phase });
229
+ }
230
+ };
231
+ const suspendPolling = async () => {
232
+ stopOwnershipWatcher();
233
+ if (ownershipStop) {
234
+ await ownershipStop;
235
+ return;
236
+ }
237
+ await deps.stopPolling();
238
+ };
239
+ const stopAfterOwnershipLoss = (ctx: TContext) => {
240
+ if (ownershipStop) return;
241
+ stopOwnershipWatcher();
242
+ ownershipStop = deps.stopPolling()
243
+ .catch((error) => deps.recordRuntimeEvent?.("lock", error, { phase: "ownership-loss" }))
244
+ .finally(() => {
245
+ ownershipStop = undefined;
246
+ updateStatusSafely(ctx, "ownership-loss-status");
247
+ });
248
+ };
249
+ const startOwnershipWatcher = (ctx: TContext) => {
250
+ const owner = snapshotLockContext(ctx);
251
+ stopOwnershipWatcher();
252
+ ownershipInterval = setInterval(() => {
253
+ if (deps.lock.owns(owner)) return;
254
+ stopAfterOwnershipLoss(ctx);
255
+ }, ownershipCheckMs);
256
+ ownershipInterval.unref?.();
257
+ };
258
+ return {
259
+ start: async (ctx, options = {}) => {
260
+ if (!deps.hasBotToken()) return { ok: false, message: "Telegram bot is not configured." };
261
+ const acquired = deps.lock.acquire(ctx, options);
262
+ if (!acquired.ok) {
263
+ return {
264
+ ok: false,
265
+ canTakeover: true,
266
+ owner: formatLock(acquired.lock),
267
+ message: `Telegram bridge is active in another pi instance (${formatLock(acquired.lock)}).`,
268
+ };
269
+ }
270
+ await deps.startPolling(ctx);
271
+ startOwnershipWatcher(ctx);
272
+ deps.updateStatus(ctx);
273
+ const staleSuffix = acquired.replacedStale ? " Replaced stale lock." : "";
274
+ return { ok: true, message: `Telegram bridge connected.${staleSuffix}` };
275
+ },
276
+ stop: async () => {
277
+ await suspendPolling();
278
+ const state = deps.lock.release();
279
+ if (state.kind === "active-elsewhere") {
280
+ return `Telegram bridge is active in another pi instance (${formatLock(state.lock)}).`;
281
+ }
282
+ if (state.kind === "stale") return `Removed stale Telegram bridge lock (${formatLock(state.lock)}).`;
283
+ return "Telegram bridge disconnected.";
284
+ },
285
+ suspend: suspendPolling,
286
+ onSessionStart: async (_event, ctx) => {
287
+ if (!deps.hasBotToken()) return;
288
+ const ownsCurrentLock = deps.lock.owns(ctx);
289
+ const state = ownsCurrentLock ? undefined : deps.lock.getState();
290
+ const canResumeStaleSameCwd =
291
+ state?.kind === "stale" && state.lock.cwd === ctx.cwd;
292
+ if (!ownsCurrentLock && !canResumeStaleSameCwd) return;
293
+ try {
294
+ if (canResumeStaleSameCwd) {
295
+ const acquired = deps.lock.acquire(ctx);
296
+ if (!acquired.ok) return;
297
+ }
298
+ await deps.startPolling(ctx);
299
+ startOwnershipWatcher(ctx);
300
+ deps.updateStatus(ctx);
301
+ } catch (error) {
302
+ deps.recordRuntimeEvent?.("lock", error, { phase: "auto-start" });
303
+ }
304
+ },
305
+ };
306
+ }
package/lib/media.ts CHANGED
@@ -3,59 +3,49 @@
3
3
  * Normalizes inbound Telegram messages into reusable file, text, id, history, and media-group metadata
4
4
  */
5
5
 
6
- export interface TelegramPhotoSizeLike {
7
- file_id: string;
8
- file_size?: number;
9
- }
6
+ import { basename, dirname } from "node:path";
10
7
 
11
- export interface TelegramDocumentLike {
12
- file_id: string;
13
- file_name?: string;
14
- mime_type?: string;
15
- }
8
+ const TELEGRAM_MEDIA_GROUP_DEBOUNCE_MS = 1200;
16
9
 
17
- export interface TelegramVideoLike {
10
+ export interface TelegramPhotoSize {
18
11
  file_id: string;
19
- file_name?: string;
20
- mime_type?: string;
12
+ file_size?: number;
21
13
  }
22
14
 
23
- export interface TelegramAudioLike {
15
+ export interface TelegramDocument {
24
16
  file_id: string;
25
17
  file_name?: string;
26
18
  mime_type?: string;
27
19
  }
28
20
 
29
- export interface TelegramVoiceLike {
30
- file_id: string;
31
- mime_type?: string;
32
- }
21
+ export type TelegramVideo = TelegramDocument;
22
+ export type TelegramAudio = TelegramDocument;
23
+ export type TelegramAnimation = TelegramDocument;
33
24
 
34
- export interface TelegramAnimationLike {
25
+ export interface TelegramVoice {
35
26
  file_id: string;
36
- file_name?: string;
37
27
  mime_type?: string;
38
28
  }
39
29
 
40
- export interface TelegramStickerLike {
30
+ export interface TelegramSticker {
41
31
  file_id: string;
42
32
  }
43
33
 
44
- export interface TelegramMessageLike {
34
+ export interface TelegramMediaMessage {
45
35
  message_id: number;
46
36
  text?: string;
47
37
  caption?: string;
48
38
  media_group_id?: string;
49
- photo?: TelegramPhotoSizeLike[];
50
- document?: TelegramDocumentLike;
51
- video?: TelegramVideoLike;
52
- audio?: TelegramAudioLike;
53
- voice?: TelegramVoiceLike;
54
- animation?: TelegramAnimationLike;
55
- sticker?: TelegramStickerLike;
39
+ photo?: TelegramPhotoSize[];
40
+ document?: TelegramDocument;
41
+ video?: TelegramVideo;
42
+ audio?: TelegramAudio;
43
+ voice?: TelegramVoice;
44
+ animation?: TelegramAnimation;
45
+ sticker?: TelegramSticker;
56
46
  }
57
47
 
58
- export interface TelegramMediaGroupMessageLike {
48
+ export interface TelegramMediaGroupMessage {
59
49
  message_id: number;
60
50
  chat: { id: number };
61
51
  media_group_id?: string;
@@ -66,15 +56,76 @@ export interface TelegramMediaGroupState<TMessage> {
66
56
  flushTimer?: ReturnType<typeof setTimeout>;
67
57
  }
68
58
 
59
+ export interface TelegramMediaGroupController<
60
+ TMessage extends TelegramMediaGroupMessage,
61
+ > {
62
+ queueMessage: (options: {
63
+ message: TMessage;
64
+ dispatchMessages: (messages: TMessage[]) => void;
65
+ }) => boolean;
66
+ removeMessages: (messageIds: number[]) => number;
67
+ clear: () => void;
68
+ }
69
+
70
+ export interface TelegramMediaGroupDispatchRuntimeDeps<
71
+ TMessage extends TelegramMediaGroupMessage,
72
+ TContext,
73
+ > {
74
+ mediaGroups: TelegramMediaGroupController<TMessage>;
75
+ dispatchMessages: (messages: TMessage[], ctx: TContext) => Promise<void>;
76
+ }
77
+
78
+ export interface TelegramMediaGroupDispatchRuntime<
79
+ TMessage extends TelegramMediaGroupMessage,
80
+ TContext,
81
+ > {
82
+ handleMessage: (message: TMessage, ctx: TContext) => Promise<void>;
83
+ }
84
+
85
+ export interface TelegramMediaGroupControllerOptions {
86
+ debounceMs?: number;
87
+ setTimer?: (
88
+ callback: () => void,
89
+ ms: number,
90
+ ) => ReturnType<typeof setTimeout>;
91
+ clearTimer?: (timer: ReturnType<typeof setTimeout>) => void;
92
+ }
93
+
94
+ export type TelegramAttachmentKind =
95
+ | "photo"
96
+ | "document"
97
+ | "video"
98
+ | "audio"
99
+ | "voice"
100
+ | "animation"
101
+ | "sticker";
102
+
69
103
  export interface TelegramFileInfo {
70
104
  file_id: string;
71
105
  fileName: string;
72
106
  mimeType?: string;
107
+ kind: TelegramAttachmentKind;
73
108
  isImage: boolean;
74
109
  }
75
110
 
76
- export interface DownloadedTelegramFileLike {
111
+ export interface DownloadedTelegramFile {
77
112
  path: string;
113
+ fileName?: string;
114
+ isImage?: boolean;
115
+ mimeType?: string;
116
+ kind?: TelegramAttachmentKind;
117
+ }
118
+
119
+ export interface DownloadedTelegramMessageFile {
120
+ path: string;
121
+ fileName: string;
122
+ isImage: boolean;
123
+ mimeType?: string;
124
+ kind?: TelegramAttachmentKind;
125
+ }
126
+
127
+ export interface DownloadTelegramMessageFilesDeps {
128
+ downloadFile: (fileId: string, fileName: string) => Promise<string>;
78
129
  }
79
130
 
80
131
  export function guessExtensionFromMime(
@@ -106,43 +157,43 @@ export function guessMediaType(path: string): string | undefined {
106
157
  return undefined;
107
158
  }
108
159
 
109
- export function isImageMimeType(mimeType: string | undefined): boolean {
160
+ function isImageMimeType(mimeType: string | undefined): boolean {
110
161
  return mimeType?.toLowerCase().startsWith("image/") ?? false;
111
162
  }
112
163
 
113
164
  export function extractTelegramMessageText(
114
- message: TelegramMessageLike,
165
+ message: TelegramMediaMessage,
115
166
  ): string {
116
167
  return (message.text || message.caption || "").trim();
117
168
  }
118
169
 
119
170
  export function extractTelegramMessagesText(
120
- messages: TelegramMessageLike[],
171
+ messages: TelegramMediaMessage[],
121
172
  ): string {
122
173
  return messages.map(extractTelegramMessageText).filter(Boolean).join("\n\n");
123
174
  }
124
175
 
125
176
  export function extractFirstTelegramMessageText(
126
- messages: TelegramMessageLike[],
177
+ messages: TelegramMediaMessage[],
127
178
  ): string {
128
179
  return messages.map(extractTelegramMessageText).find(Boolean) ?? "";
129
180
  }
130
181
 
131
182
  export function collectTelegramMessageIds(
132
- messages: TelegramMessageLike[],
183
+ messages: TelegramMediaMessage[],
133
184
  ): number[] {
134
185
  return [...new Set(messages.map((message) => message.message_id))];
135
186
  }
136
187
 
137
188
  export function getTelegramMediaGroupKey(
138
- message: TelegramMediaGroupMessageLike,
189
+ message: TelegramMediaGroupMessage,
139
190
  ): string | undefined {
140
191
  if (!message.media_group_id) return undefined;
141
192
  return `${message.chat.id}:${message.media_group_id}`;
142
193
  }
143
194
 
144
195
  export function removePendingTelegramMediaGroupMessages<
145
- TMessage extends TelegramMediaGroupMessageLike,
196
+ TMessage extends TelegramMediaGroupMessage,
146
197
  >(
147
198
  groups: Map<string, TelegramMediaGroupState<TMessage>>,
148
199
  messageIds: number[],
@@ -167,7 +218,7 @@ export function removePendingTelegramMediaGroupMessages<
167
218
  }
168
219
 
169
220
  export function queueTelegramMediaGroupMessage<
170
- TMessage extends TelegramMediaGroupMessageLike,
221
+ TMessage extends TelegramMediaGroupMessage,
171
222
  >(options: {
172
223
  message: TMessage;
173
224
  groups: Map<string, TelegramMediaGroupState<TMessage>>;
@@ -191,22 +242,114 @@ export function queueTelegramMediaGroupMessage<
191
242
  return true;
192
243
  }
193
244
 
245
+ export function createTelegramMediaGroupController<
246
+ TMessage extends TelegramMediaGroupMessage,
247
+ >(
248
+ options: TelegramMediaGroupControllerOptions = {},
249
+ ): TelegramMediaGroupController<TMessage> {
250
+ const groups = new Map<string, TelegramMediaGroupState<TMessage>>();
251
+ const debounceMs = options.debounceMs ?? TELEGRAM_MEDIA_GROUP_DEBOUNCE_MS;
252
+ const setTimer =
253
+ options.setTimer ??
254
+ ((callback: () => void, ms: number): ReturnType<typeof setTimeout> =>
255
+ setTimeout(callback, ms));
256
+ const clearTimer = options.clearTimer ?? clearTimeout;
257
+ return {
258
+ queueMessage: ({ message, dispatchMessages }) =>
259
+ queueTelegramMediaGroupMessage({
260
+ message,
261
+ groups,
262
+ debounceMs,
263
+ setTimer,
264
+ clearTimer,
265
+ dispatchMessages,
266
+ }),
267
+ removeMessages: (messageIds) =>
268
+ removePendingTelegramMediaGroupMessages(groups, messageIds, clearTimer),
269
+ clear: () => {
270
+ for (const state of groups.values()) {
271
+ if (state.flushTimer) clearTimer(state.flushTimer);
272
+ }
273
+ groups.clear();
274
+ },
275
+ };
276
+ }
277
+
278
+ export function createTelegramMediaGroupDispatchRuntime<
279
+ TMessage extends TelegramMediaGroupMessage,
280
+ TContext,
281
+ >(
282
+ deps: TelegramMediaGroupDispatchRuntimeDeps<TMessage, TContext>,
283
+ ): TelegramMediaGroupDispatchRuntime<TMessage, TContext> {
284
+ return {
285
+ handleMessage: async (message, ctx) => {
286
+ const queuedMediaGroup = deps.mediaGroups.queueMessage({
287
+ message,
288
+ dispatchMessages: (messages) => {
289
+ void deps.dispatchMessages(messages, ctx);
290
+ },
291
+ });
292
+ if (queuedMediaGroup) return;
293
+ await deps.dispatchMessages([message], ctx);
294
+ },
295
+ };
296
+ }
297
+
298
+ function appendTelegramListSection(
299
+ text: string,
300
+ title: string,
301
+ items: string[],
302
+ ): string {
303
+ if (items.length === 0) return text;
304
+ const prefix = text.length > 0 ? `${text}\n\n` : "";
305
+ return `${prefix}[${title}]\n${items.map((item) => `- ${item}`).join("\n")}`;
306
+ }
307
+
308
+ function appendTelegramAttachmentSection(
309
+ text: string,
310
+ files: Pick<DownloadedTelegramFile, "path">[],
311
+ ): string {
312
+ if (files.length === 0) return text;
313
+ const dirs = [...new Set(files.map((file) => dirname(file.path)))];
314
+ const sameDir = dirs.length === 1;
315
+ const header = sameDir ? `[attachments] ${dirs[0]}` : "[attachments]";
316
+ const items = sameDir
317
+ ? files.map((file) => `/${basename(file.path)}`)
318
+ : files.map((file) => file.path);
319
+ const prefix = text.length > 0 ? `${text}\n\n` : "";
320
+ return `${prefix}${header}\n${items.map((item) => `- ${item}`).join("\n")}`;
321
+ }
322
+
194
323
  export function formatTelegramHistoryText(
195
324
  rawText: string,
196
- files: DownloadedTelegramFileLike[],
325
+ files: DownloadedTelegramFile[],
326
+ handlerOutputs: string[] = [],
197
327
  ): string {
198
328
  let summary = rawText.length > 0 ? rawText : "(no text)";
199
- if (files.length > 0) {
200
- summary += `\nAttachments:`;
201
- for (const file of files) {
202
- summary += `\n- ${file.path}`;
203
- }
204
- }
329
+ summary = appendTelegramAttachmentSection(summary, files);
330
+ summary = appendTelegramListSection(summary, "outputs", handlerOutputs);
205
331
  return summary;
206
332
  }
207
333
 
334
+ export async function downloadTelegramMessageFiles(
335
+ messages: TelegramMediaMessage[],
336
+ deps: DownloadTelegramMessageFilesDeps,
337
+ ): Promise<DownloadedTelegramMessageFile[]> {
338
+ const downloaded: DownloadedTelegramMessageFile[] = [];
339
+ for (const file of collectTelegramFileInfos(messages)) {
340
+ downloaded.push({
341
+ path: await deps.downloadFile(file.file_id, file.fileName),
342
+ fileName: file.fileName,
343
+ isImage: file.isImage,
344
+ mimeType: file.mimeType,
345
+ kind: file.kind,
346
+ });
347
+ }
348
+ return downloaded;
349
+ }
350
+
208
351
  export function collectTelegramFileInfos(
209
- messages: TelegramMessageLike[],
352
+ messages: TelegramMediaMessage[],
210
353
  ): TelegramFileInfo[] {
211
354
  const files: TelegramFileInfo[] = [];
212
355
  for (const message of messages) {
@@ -219,6 +362,7 @@ export function collectTelegramFileInfos(
219
362
  file_id: photo.file_id,
220
363
  fileName: `photo-${message.message_id}.jpg`,
221
364
  mimeType: "image/jpeg",
365
+ kind: "photo",
222
366
  isImage: true,
223
367
  });
224
368
  }
@@ -234,6 +378,7 @@ export function collectTelegramFileInfos(
234
378
  file_id: message.document.file_id,
235
379
  fileName,
236
380
  mimeType: message.document.mime_type,
381
+ kind: "document",
237
382
  isImage: isImageMimeType(message.document.mime_type),
238
383
  });
239
384
  }
@@ -248,6 +393,7 @@ export function collectTelegramFileInfos(
248
393
  file_id: message.video.file_id,
249
394
  fileName,
250
395
  mimeType: message.video.mime_type,
396
+ kind: "video",
251
397
  isImage: false,
252
398
  });
253
399
  }
@@ -262,6 +408,7 @@ export function collectTelegramFileInfos(
262
408
  file_id: message.audio.file_id,
263
409
  fileName,
264
410
  mimeType: message.audio.mime_type,
411
+ kind: "audio",
265
412
  isImage: false,
266
413
  });
267
414
  }
@@ -273,6 +420,7 @@ export function collectTelegramFileInfos(
273
420
  ".ogg",
274
421
  )}`,
275
422
  mimeType: message.voice.mime_type,
423
+ kind: "voice",
276
424
  isImage: false,
277
425
  });
278
426
  }
@@ -287,6 +435,7 @@ export function collectTelegramFileInfos(
287
435
  file_id: message.animation.file_id,
288
436
  fileName,
289
437
  mimeType: message.animation.mime_type,
438
+ kind: "animation",
290
439
  isImage: false,
291
440
  });
292
441
  }
@@ -295,6 +444,7 @@ export function collectTelegramFileInfos(
295
444
  file_id: message.sticker.file_id,
296
445
  fileName: `sticker-${message.message_id}.webp`,
297
446
  mimeType: "image/webp",
447
+ kind: "sticker",
298
448
  isImage: true,
299
449
  });
300
450
  }