@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/config.ts ADDED
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Telegram bridge config and pairing helpers
3
+ * Owns persisted bot/session pairing state, local config storage, authorization policy, and first-user pairing side effects
4
+ */
5
+
6
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+
10
+ const AGENT_DIR = join(homedir(), ".pi", "agent");
11
+ const CONFIG_PATH = join(AGENT_DIR, "telegram.json");
12
+
13
+ export interface TelegramConfig {
14
+ botToken?: string;
15
+ botUsername?: string;
16
+ botId?: number;
17
+ allowedUserId?: number;
18
+ lastUpdateId?: number;
19
+ }
20
+
21
+ export interface TelegramConfigStore {
22
+ get: () => TelegramConfig;
23
+ set: (config: TelegramConfig) => void;
24
+ update: (mutate: (config: TelegramConfig) => void) => void;
25
+ getBotToken: () => string | undefined;
26
+ hasBotToken: () => boolean;
27
+ getAllowedUserId: () => number | undefined;
28
+ setAllowedUserId: (userId: number) => void;
29
+ load: () => Promise<void>;
30
+ persist: (config?: TelegramConfig) => Promise<void>;
31
+ }
32
+
33
+ export interface TelegramConfigStoreOptions {
34
+ initialConfig?: TelegramConfig;
35
+ agentDir?: string;
36
+ configPath?: string;
37
+ }
38
+
39
+ export async function readTelegramConfig(
40
+ configPath: string,
41
+ ): Promise<TelegramConfig> {
42
+ try {
43
+ const content = await readFile(configPath, "utf8");
44
+ return JSON.parse(content) as TelegramConfig;
45
+ } catch {
46
+ return {};
47
+ }
48
+ }
49
+
50
+ export async function writeTelegramConfig(
51
+ agentDir: string,
52
+ configPath: string,
53
+ config: TelegramConfig,
54
+ ): Promise<void> {
55
+ await mkdir(agentDir, { recursive: true });
56
+ await writeFile(configPath, JSON.stringify(config, null, "\t") + "\n", {
57
+ encoding: "utf8",
58
+ mode: 0o600,
59
+ });
60
+ await chmod(configPath, 0o600);
61
+ }
62
+
63
+ export function createTelegramConfigStore(
64
+ options: TelegramConfigStoreOptions = {},
65
+ ): TelegramConfigStore {
66
+ let config: TelegramConfig = options.initialConfig ?? {};
67
+ const agentDir = options.agentDir ?? AGENT_DIR;
68
+ const configPath = options.configPath ?? CONFIG_PATH;
69
+ return {
70
+ get: () => config,
71
+ set: (nextConfig) => {
72
+ config = nextConfig;
73
+ },
74
+ update: (mutate) => {
75
+ mutate(config);
76
+ },
77
+ getBotToken: () => config.botToken,
78
+ hasBotToken: () => !!config.botToken,
79
+ getAllowedUserId: () => config.allowedUserId,
80
+ setAllowedUserId: (userId) => {
81
+ config.allowedUserId = userId;
82
+ },
83
+ load: async () => {
84
+ config = await readTelegramConfig(configPath);
85
+ },
86
+ persist: async (nextConfig = config) => {
87
+ await writeTelegramConfig(agentDir, configPath, nextConfig);
88
+ },
89
+ };
90
+ }
91
+
92
+ export type TelegramAuthorizationState =
93
+ | { kind: "pair"; userId: number }
94
+ | { kind: "allow" }
95
+ | { kind: "deny" };
96
+
97
+ export interface TelegramUserPairingDeps<TContext> {
98
+ allowedUserId?: number;
99
+ ctx: TContext;
100
+ setAllowedUserId: (userId: number) => void;
101
+ persistConfig: () => Promise<void>;
102
+ updateStatus: (ctx: TContext) => void;
103
+ }
104
+
105
+ export interface TelegramUserPairingRuntimeDeps<TContext> {
106
+ getAllowedUserId: () => number | undefined;
107
+ setAllowedUserId: (userId: number) => void;
108
+ persistConfig: () => Promise<void>;
109
+ updateStatus: (ctx: TContext) => void;
110
+ }
111
+
112
+ export interface TelegramUserPairingRuntime<TContext> {
113
+ pairIfNeeded: (userId: number, ctx: TContext) => Promise<boolean>;
114
+ }
115
+
116
+ export function getTelegramAuthorizationState(
117
+ userId: number,
118
+ allowedUserId?: number,
119
+ ): TelegramAuthorizationState {
120
+ if (allowedUserId === undefined) {
121
+ return { kind: "pair", userId };
122
+ }
123
+ if (userId === allowedUserId) {
124
+ return { kind: "allow" };
125
+ }
126
+ return { kind: "deny" };
127
+ }
128
+
129
+ export async function pairTelegramUserIfNeeded<TContext>(
130
+ userId: number,
131
+ deps: TelegramUserPairingDeps<TContext>,
132
+ ): Promise<boolean> {
133
+ const authorization = getTelegramAuthorizationState(
134
+ userId,
135
+ deps.allowedUserId,
136
+ );
137
+ if (authorization.kind !== "pair") return false;
138
+ deps.setAllowedUserId(authorization.userId);
139
+ await deps.persistConfig();
140
+ deps.updateStatus(deps.ctx);
141
+ return true;
142
+ }
143
+
144
+ export function createTelegramUserPairingRuntime<TContext>(
145
+ deps: TelegramUserPairingRuntimeDeps<TContext>,
146
+ ): TelegramUserPairingRuntime<TContext> {
147
+ return {
148
+ pairIfNeeded: (userId, ctx) =>
149
+ pairTelegramUserIfNeeded(userId, {
150
+ allowedUserId: deps.getAllowedUserId(),
151
+ ctx,
152
+ setAllowedUserId: deps.setAllowedUserId,
153
+ persistConfig: deps.persistConfig,
154
+ updateStatus: deps.updateStatus,
155
+ }),
156
+ };
157
+ }
package/lib/media.ts CHANGED
@@ -1,57 +1,92 @@
1
1
  /**
2
2
  * Telegram media and text extraction helpers
3
- * Normalizes inbound Telegram messages into reusable file, text, id, and history metadata
3
+ * Normalizes inbound Telegram messages into reusable file, text, id, history, and media-group metadata
4
4
  */
5
5
 
6
- export interface TelegramPhotoSizeLike {
6
+ const TELEGRAM_MEDIA_GROUP_DEBOUNCE_MS = 1200;
7
+
8
+ export interface TelegramPhotoSize {
7
9
  file_id: string;
8
10
  file_size?: number;
9
11
  }
10
12
 
11
- export interface TelegramDocumentLike {
13
+ export interface TelegramDocument {
12
14
  file_id: string;
13
15
  file_name?: string;
14
16
  mime_type?: string;
15
17
  }
16
18
 
17
- export interface TelegramVideoLike {
19
+ export type TelegramVideo = TelegramDocument;
20
+ export type TelegramAudio = TelegramDocument;
21
+ export type TelegramAnimation = TelegramDocument;
22
+
23
+ export interface TelegramVoice {
18
24
  file_id: string;
19
- file_name?: string;
20
25
  mime_type?: string;
21
26
  }
22
27
 
23
- export interface TelegramAudioLike {
28
+ export interface TelegramSticker {
24
29
  file_id: string;
25
- file_name?: string;
26
- mime_type?: string;
27
30
  }
28
31
 
29
- export interface TelegramVoiceLike {
30
- file_id: string;
31
- mime_type?: string;
32
+ export interface TelegramMediaMessage {
33
+ message_id: number;
34
+ text?: string;
35
+ caption?: string;
36
+ media_group_id?: string;
37
+ photo?: TelegramPhotoSize[];
38
+ document?: TelegramDocument;
39
+ video?: TelegramVideo;
40
+ audio?: TelegramAudio;
41
+ voice?: TelegramVoice;
42
+ animation?: TelegramAnimation;
43
+ sticker?: TelegramSticker;
32
44
  }
33
45
 
34
- export interface TelegramAnimationLike {
35
- file_id: string;
36
- file_name?: string;
37
- mime_type?: string;
46
+ export interface TelegramMediaGroupMessage {
47
+ message_id: number;
48
+ chat: { id: number };
49
+ media_group_id?: string;
38
50
  }
39
51
 
40
- export interface TelegramStickerLike {
41
- file_id: string;
52
+ export interface TelegramMediaGroupState<TMessage> {
53
+ messages: TMessage[];
54
+ flushTimer?: ReturnType<typeof setTimeout>;
42
55
  }
43
56
 
44
- export interface TelegramMessageLike {
45
- message_id: number;
46
- text?: string;
47
- caption?: string;
48
- photo?: TelegramPhotoSizeLike[];
49
- document?: TelegramDocumentLike;
50
- video?: TelegramVideoLike;
51
- audio?: TelegramAudioLike;
52
- voice?: TelegramVoiceLike;
53
- animation?: TelegramAnimationLike;
54
- sticker?: TelegramStickerLike;
57
+ export interface TelegramMediaGroupController<
58
+ TMessage extends TelegramMediaGroupMessage,
59
+ > {
60
+ queueMessage: (options: {
61
+ message: TMessage;
62
+ dispatchMessages: (messages: TMessage[]) => void;
63
+ }) => boolean;
64
+ removeMessages: (messageIds: number[]) => number;
65
+ clear: () => void;
66
+ }
67
+
68
+ export interface TelegramMediaGroupDispatchRuntimeDeps<
69
+ TMessage extends TelegramMediaGroupMessage,
70
+ TContext,
71
+ > {
72
+ mediaGroups: TelegramMediaGroupController<TMessage>;
73
+ dispatchMessages: (messages: TMessage[], ctx: TContext) => Promise<void>;
74
+ }
75
+
76
+ export interface TelegramMediaGroupDispatchRuntime<
77
+ TMessage extends TelegramMediaGroupMessage,
78
+ TContext,
79
+ > {
80
+ handleMessage: (message: TMessage, ctx: TContext) => Promise<void>;
81
+ }
82
+
83
+ export interface TelegramMediaGroupControllerOptions {
84
+ debounceMs?: number;
85
+ setTimer?: (
86
+ callback: () => void,
87
+ ms: number,
88
+ ) => ReturnType<typeof setTimeout>;
89
+ clearTimer?: (timer: ReturnType<typeof setTimeout>) => void;
55
90
  }
56
91
 
57
92
  export interface TelegramFileInfo {
@@ -61,8 +96,22 @@ export interface TelegramFileInfo {
61
96
  isImage: boolean;
62
97
  }
63
98
 
64
- export interface DownloadedTelegramFileLike {
99
+ export interface DownloadedTelegramFile {
100
+ path: string;
101
+ fileName?: string;
102
+ isImage?: boolean;
103
+ mimeType?: string;
104
+ }
105
+
106
+ export interface DownloadedTelegramMessageFile {
65
107
  path: string;
108
+ fileName: string;
109
+ isImage: boolean;
110
+ mimeType?: string;
111
+ }
112
+
113
+ export interface DownloadTelegramMessageFilesDeps {
114
+ downloadFile: (fileId: string, fileName: string) => Promise<string>;
66
115
  }
67
116
 
68
117
  export function guessExtensionFromMime(
@@ -94,37 +143,147 @@ export function guessMediaType(path: string): string | undefined {
94
143
  return undefined;
95
144
  }
96
145
 
97
- export function isImageMimeType(mimeType: string | undefined): boolean {
146
+ function isImageMimeType(mimeType: string | undefined): boolean {
98
147
  return mimeType?.toLowerCase().startsWith("image/") ?? false;
99
148
  }
100
149
 
101
150
  export function extractTelegramMessageText(
102
- message: TelegramMessageLike,
151
+ message: TelegramMediaMessage,
103
152
  ): string {
104
153
  return (message.text || message.caption || "").trim();
105
154
  }
106
155
 
107
156
  export function extractTelegramMessagesText(
108
- messages: TelegramMessageLike[],
157
+ messages: TelegramMediaMessage[],
109
158
  ): string {
110
159
  return messages.map(extractTelegramMessageText).filter(Boolean).join("\n\n");
111
160
  }
112
161
 
113
162
  export function extractFirstTelegramMessageText(
114
- messages: TelegramMessageLike[],
163
+ messages: TelegramMediaMessage[],
115
164
  ): string {
116
165
  return messages.map(extractTelegramMessageText).find(Boolean) ?? "";
117
166
  }
118
167
 
119
168
  export function collectTelegramMessageIds(
120
- messages: TelegramMessageLike[],
169
+ messages: TelegramMediaMessage[],
121
170
  ): number[] {
122
171
  return [...new Set(messages.map((message) => message.message_id))];
123
172
  }
124
173
 
174
+ export function getTelegramMediaGroupKey(
175
+ message: TelegramMediaGroupMessage,
176
+ ): string | undefined {
177
+ if (!message.media_group_id) return undefined;
178
+ return `${message.chat.id}:${message.media_group_id}`;
179
+ }
180
+
181
+ export function removePendingTelegramMediaGroupMessages<
182
+ TMessage extends TelegramMediaGroupMessage,
183
+ >(
184
+ groups: Map<string, TelegramMediaGroupState<TMessage>>,
185
+ messageIds: number[],
186
+ clearTimer: (timer: ReturnType<typeof setTimeout>) => void,
187
+ ): number {
188
+ if (messageIds.length === 0 || groups.size === 0) return 0;
189
+ const deletedMessageIds = new Set(messageIds);
190
+ let removedGroups = 0;
191
+ for (const [key, state] of groups.entries()) {
192
+ if (
193
+ !state.messages.some((message) =>
194
+ deletedMessageIds.has(message.message_id),
195
+ )
196
+ ) {
197
+ continue;
198
+ }
199
+ if (state.flushTimer) clearTimer(state.flushTimer);
200
+ groups.delete(key);
201
+ removedGroups += 1;
202
+ }
203
+ return removedGroups;
204
+ }
205
+
206
+ export function queueTelegramMediaGroupMessage<
207
+ TMessage extends TelegramMediaGroupMessage,
208
+ >(options: {
209
+ message: TMessage;
210
+ groups: Map<string, TelegramMediaGroupState<TMessage>>;
211
+ debounceMs: number;
212
+ setTimer: (callback: () => void, ms: number) => ReturnType<typeof setTimeout>;
213
+ clearTimer: (timer: ReturnType<typeof setTimeout>) => void;
214
+ dispatchMessages: (messages: TMessage[]) => void;
215
+ }): boolean {
216
+ const key = getTelegramMediaGroupKey(options.message);
217
+ if (!key) return false;
218
+ const existing = options.groups.get(key) ?? { messages: [] };
219
+ existing.messages.push(options.message);
220
+ if (existing.flushTimer) options.clearTimer(existing.flushTimer);
221
+ existing.flushTimer = options.setTimer(() => {
222
+ const state = options.groups.get(key);
223
+ options.groups.delete(key);
224
+ if (!state) return;
225
+ options.dispatchMessages(state.messages);
226
+ }, options.debounceMs);
227
+ options.groups.set(key, existing);
228
+ return true;
229
+ }
230
+
231
+ export function createTelegramMediaGroupController<
232
+ TMessage extends TelegramMediaGroupMessage,
233
+ >(
234
+ options: TelegramMediaGroupControllerOptions = {},
235
+ ): TelegramMediaGroupController<TMessage> {
236
+ const groups = new Map<string, TelegramMediaGroupState<TMessage>>();
237
+ const debounceMs = options.debounceMs ?? TELEGRAM_MEDIA_GROUP_DEBOUNCE_MS;
238
+ const setTimer =
239
+ options.setTimer ??
240
+ ((callback: () => void, ms: number): ReturnType<typeof setTimeout> =>
241
+ setTimeout(callback, ms));
242
+ const clearTimer = options.clearTimer ?? clearTimeout;
243
+ return {
244
+ queueMessage: ({ message, dispatchMessages }) =>
245
+ queueTelegramMediaGroupMessage({
246
+ message,
247
+ groups,
248
+ debounceMs,
249
+ setTimer,
250
+ clearTimer,
251
+ dispatchMessages,
252
+ }),
253
+ removeMessages: (messageIds) =>
254
+ removePendingTelegramMediaGroupMessages(groups, messageIds, clearTimer),
255
+ clear: () => {
256
+ for (const state of groups.values()) {
257
+ if (state.flushTimer) clearTimer(state.flushTimer);
258
+ }
259
+ groups.clear();
260
+ },
261
+ };
262
+ }
263
+
264
+ export function createTelegramMediaGroupDispatchRuntime<
265
+ TMessage extends TelegramMediaGroupMessage,
266
+ TContext,
267
+ >(
268
+ deps: TelegramMediaGroupDispatchRuntimeDeps<TMessage, TContext>,
269
+ ): TelegramMediaGroupDispatchRuntime<TMessage, TContext> {
270
+ return {
271
+ handleMessage: async (message, ctx) => {
272
+ const queuedMediaGroup = deps.mediaGroups.queueMessage({
273
+ message,
274
+ dispatchMessages: (messages) => {
275
+ void deps.dispatchMessages(messages, ctx);
276
+ },
277
+ });
278
+ if (queuedMediaGroup) return;
279
+ await deps.dispatchMessages([message], ctx);
280
+ },
281
+ };
282
+ }
283
+
125
284
  export function formatTelegramHistoryText(
126
285
  rawText: string,
127
- files: DownloadedTelegramFileLike[],
286
+ files: DownloadedTelegramFile[],
128
287
  ): string {
129
288
  let summary = rawText.length > 0 ? rawText : "(no text)";
130
289
  if (files.length > 0) {
@@ -136,8 +295,24 @@ export function formatTelegramHistoryText(
136
295
  return summary;
137
296
  }
138
297
 
298
+ export async function downloadTelegramMessageFiles(
299
+ messages: TelegramMediaMessage[],
300
+ deps: DownloadTelegramMessageFilesDeps,
301
+ ): Promise<DownloadedTelegramMessageFile[]> {
302
+ const downloaded: DownloadedTelegramMessageFile[] = [];
303
+ for (const file of collectTelegramFileInfos(messages)) {
304
+ downloaded.push({
305
+ path: await deps.downloadFile(file.file_id, file.fileName),
306
+ fileName: file.fileName,
307
+ isImage: file.isImage,
308
+ mimeType: file.mimeType,
309
+ });
310
+ }
311
+ return downloaded;
312
+ }
313
+
139
314
  export function collectTelegramFileInfos(
140
- messages: TelegramMessageLike[],
315
+ messages: TelegramMediaMessage[],
141
316
  ): TelegramFileInfo[] {
142
317
  const files: TelegramFileInfo[] = [];
143
318
  for (const message of messages) {