@llblab/pi-telegram 0.2.10 → 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
@@ -3,59 +3,47 @@
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
+ const TELEGRAM_MEDIA_GROUP_DEBOUNCE_MS = 1200;
10
7
 
11
- export interface TelegramDocumentLike {
8
+ export interface TelegramPhotoSize {
12
9
  file_id: string;
13
- file_name?: string;
14
- mime_type?: string;
15
- }
16
-
17
- export interface TelegramVideoLike {
18
- file_id: string;
19
- file_name?: string;
20
- mime_type?: string;
10
+ file_size?: number;
21
11
  }
22
12
 
23
- export interface TelegramAudioLike {
13
+ export interface TelegramDocument {
24
14
  file_id: string;
25
15
  file_name?: string;
26
16
  mime_type?: string;
27
17
  }
28
18
 
29
- export interface TelegramVoiceLike {
30
- file_id: string;
31
- mime_type?: string;
32
- }
19
+ export type TelegramVideo = TelegramDocument;
20
+ export type TelegramAudio = TelegramDocument;
21
+ export type TelegramAnimation = TelegramDocument;
33
22
 
34
- export interface TelegramAnimationLike {
23
+ export interface TelegramVoice {
35
24
  file_id: string;
36
- file_name?: string;
37
25
  mime_type?: string;
38
26
  }
39
27
 
40
- export interface TelegramStickerLike {
28
+ export interface TelegramSticker {
41
29
  file_id: string;
42
30
  }
43
31
 
44
- export interface TelegramMessageLike {
32
+ export interface TelegramMediaMessage {
45
33
  message_id: number;
46
34
  text?: string;
47
35
  caption?: string;
48
36
  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;
37
+ photo?: TelegramPhotoSize[];
38
+ document?: TelegramDocument;
39
+ video?: TelegramVideo;
40
+ audio?: TelegramAudio;
41
+ voice?: TelegramVoice;
42
+ animation?: TelegramAnimation;
43
+ sticker?: TelegramSticker;
56
44
  }
57
45
 
58
- export interface TelegramMediaGroupMessageLike {
46
+ export interface TelegramMediaGroupMessage {
59
47
  message_id: number;
60
48
  chat: { id: number };
61
49
  media_group_id?: string;
@@ -66,6 +54,41 @@ export interface TelegramMediaGroupState<TMessage> {
66
54
  flushTimer?: ReturnType<typeof setTimeout>;
67
55
  }
68
56
 
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;
90
+ }
91
+
69
92
  export interface TelegramFileInfo {
70
93
  file_id: string;
71
94
  fileName: string;
@@ -73,8 +96,22 @@ export interface TelegramFileInfo {
73
96
  isImage: boolean;
74
97
  }
75
98
 
76
- export interface DownloadedTelegramFileLike {
99
+ export interface DownloadedTelegramFile {
77
100
  path: string;
101
+ fileName?: string;
102
+ isImage?: boolean;
103
+ mimeType?: string;
104
+ }
105
+
106
+ export interface DownloadedTelegramMessageFile {
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>;
78
115
  }
79
116
 
80
117
  export function guessExtensionFromMime(
@@ -106,43 +143,43 @@ export function guessMediaType(path: string): string | undefined {
106
143
  return undefined;
107
144
  }
108
145
 
109
- export function isImageMimeType(mimeType: string | undefined): boolean {
146
+ function isImageMimeType(mimeType: string | undefined): boolean {
110
147
  return mimeType?.toLowerCase().startsWith("image/") ?? false;
111
148
  }
112
149
 
113
150
  export function extractTelegramMessageText(
114
- message: TelegramMessageLike,
151
+ message: TelegramMediaMessage,
115
152
  ): string {
116
153
  return (message.text || message.caption || "").trim();
117
154
  }
118
155
 
119
156
  export function extractTelegramMessagesText(
120
- messages: TelegramMessageLike[],
157
+ messages: TelegramMediaMessage[],
121
158
  ): string {
122
159
  return messages.map(extractTelegramMessageText).filter(Boolean).join("\n\n");
123
160
  }
124
161
 
125
162
  export function extractFirstTelegramMessageText(
126
- messages: TelegramMessageLike[],
163
+ messages: TelegramMediaMessage[],
127
164
  ): string {
128
165
  return messages.map(extractTelegramMessageText).find(Boolean) ?? "";
129
166
  }
130
167
 
131
168
  export function collectTelegramMessageIds(
132
- messages: TelegramMessageLike[],
169
+ messages: TelegramMediaMessage[],
133
170
  ): number[] {
134
171
  return [...new Set(messages.map((message) => message.message_id))];
135
172
  }
136
173
 
137
174
  export function getTelegramMediaGroupKey(
138
- message: TelegramMediaGroupMessageLike,
175
+ message: TelegramMediaGroupMessage,
139
176
  ): string | undefined {
140
177
  if (!message.media_group_id) return undefined;
141
178
  return `${message.chat.id}:${message.media_group_id}`;
142
179
  }
143
180
 
144
181
  export function removePendingTelegramMediaGroupMessages<
145
- TMessage extends TelegramMediaGroupMessageLike,
182
+ TMessage extends TelegramMediaGroupMessage,
146
183
  >(
147
184
  groups: Map<string, TelegramMediaGroupState<TMessage>>,
148
185
  messageIds: number[],
@@ -167,7 +204,7 @@ export function removePendingTelegramMediaGroupMessages<
167
204
  }
168
205
 
169
206
  export function queueTelegramMediaGroupMessage<
170
- TMessage extends TelegramMediaGroupMessageLike,
207
+ TMessage extends TelegramMediaGroupMessage,
171
208
  >(options: {
172
209
  message: TMessage;
173
210
  groups: Map<string, TelegramMediaGroupState<TMessage>>;
@@ -191,9 +228,62 @@ export function queueTelegramMediaGroupMessage<
191
228
  return true;
192
229
  }
193
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
+
194
284
  export function formatTelegramHistoryText(
195
285
  rawText: string,
196
- files: DownloadedTelegramFileLike[],
286
+ files: DownloadedTelegramFile[],
197
287
  ): string {
198
288
  let summary = rawText.length > 0 ? rawText : "(no text)";
199
289
  if (files.length > 0) {
@@ -205,8 +295,24 @@ export function formatTelegramHistoryText(
205
295
  return summary;
206
296
  }
207
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
+
208
314
  export function collectTelegramFileInfos(
209
- messages: TelegramMessageLike[],
315
+ messages: TelegramMediaMessage[],
210
316
  ): TelegramFileInfo[] {
211
317
  const files: TelegramFileInfo[] = [];
212
318
  for (const message of messages) {