@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.
- package/README.md +52 -19
- package/docs/README.md +2 -3
- package/docs/architecture.md +62 -31
- package/docs/locks.md +136 -0
- package/index.ts +323 -1880
- package/lib/api.ts +396 -60
- package/lib/attachments.ts +128 -16
- package/lib/commands.ts +648 -14
- package/lib/config.ts +169 -0
- package/lib/handlers.ts +474 -0
- package/lib/locks.ts +306 -0
- package/lib/media.ts +196 -46
- package/lib/menu.ts +920 -338
- package/lib/model.ts +647 -0
- package/lib/pi.ts +90 -0
- package/lib/polling.ts +240 -14
- package/lib/preview.ts +420 -25
- package/lib/queue.ts +1137 -110
- package/lib/registration.ts +214 -31
- package/lib/rendering.ts +560 -366
- package/lib/replies.ts +198 -8
- package/lib/routing.ts +217 -0
- package/lib/runtime.ts +475 -0
- package/lib/setup.ts +143 -1
- package/lib/status.ts +432 -13
- package/lib/turns.ts +217 -36
- package/lib/updates.ts +340 -109
- package/package.json +18 -3
- package/AGENTS.md +0 -91
- package/BACKLOG.md +0 -5
- package/CHANGELOG.md +0 -34
- package/lib/model-switch.ts +0 -62
- package/lib/types.ts +0 -137
- package/tests/api.test.ts +0 -331
- package/tests/attachments.test.ts +0 -132
- package/tests/commands.test.ts +0 -85
- package/tests/config.test.ts +0 -80
- package/tests/media.test.ts +0 -166
- package/tests/menu.test.ts +0 -676
- package/tests/polling.test.ts +0 -202
- package/tests/preview.test.ts +0 -480
- package/tests/queue.test.ts +0 -3245
- package/tests/registration.test.ts +0 -268
- package/tests/rendering.test.ts +0 -526
- package/tests/replies.test.ts +0 -142
- package/tests/turns.test.ts +0 -247
- 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
|
-
|
|
7
|
-
file_id: string;
|
|
8
|
-
file_size?: number;
|
|
9
|
-
}
|
|
6
|
+
import { basename, dirname } from "node:path";
|
|
10
7
|
|
|
11
|
-
|
|
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
|
|
10
|
+
export interface TelegramPhotoSize {
|
|
18
11
|
file_id: string;
|
|
19
|
-
|
|
20
|
-
mime_type?: string;
|
|
12
|
+
file_size?: number;
|
|
21
13
|
}
|
|
22
14
|
|
|
23
|
-
export interface
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
21
|
+
export type TelegramVideo = TelegramDocument;
|
|
22
|
+
export type TelegramAudio = TelegramDocument;
|
|
23
|
+
export type TelegramAnimation = TelegramDocument;
|
|
33
24
|
|
|
34
|
-
export interface
|
|
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
|
|
30
|
+
export interface TelegramSticker {
|
|
41
31
|
file_id: string;
|
|
42
32
|
}
|
|
43
33
|
|
|
44
|
-
export interface
|
|
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?:
|
|
50
|
-
document?:
|
|
51
|
-
video?:
|
|
52
|
-
audio?:
|
|
53
|
-
voice?:
|
|
54
|
-
animation?:
|
|
55
|
-
sticker?:
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
325
|
+
files: DownloadedTelegramFile[],
|
|
326
|
+
handlerOutputs: string[] = [],
|
|
197
327
|
): string {
|
|
198
328
|
let summary = rawText.length > 0 ? rawText : "(no text)";
|
|
199
|
-
|
|
200
|
-
|
|
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:
|
|
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
|
}
|