@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.
@@ -3,16 +3,72 @@
3
3
  * Owns attachment queueing and attachment delivery so Telegram file output stays in one domain module
4
4
  */
5
5
 
6
+ import { stat } from "node:fs/promises";
6
7
  import { basename } from "node:path";
7
8
 
8
- import { guessMediaType } from "./media.ts";
9
- import type { PendingTelegramTurn } from "./queue.ts";
9
+ import { buildTelegramMultipartReplyParameters } from "./replies.ts";
10
+
11
+ export const TELEGRAM_OUTBOUND_ATTACHMENT_DEFAULT_MAX_BYTES = 50 * 1024 * 1024;
12
+
13
+ export function getTelegramAttachmentByteLimitFromEnv(
14
+ env: NodeJS.ProcessEnv,
15
+ names: string[],
16
+ defaultValue = TELEGRAM_OUTBOUND_ATTACHMENT_DEFAULT_MAX_BYTES,
17
+ ): number {
18
+ for (const name of names) {
19
+ const rawValue = env[name]?.trim();
20
+ if (!rawValue) continue;
21
+ const parsed = Number(rawValue);
22
+ if (Number.isSafeInteger(parsed) && parsed > 0) return parsed;
23
+ }
24
+ return defaultValue;
25
+ }
26
+
27
+ export const TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES =
28
+ getTelegramAttachmentByteLimitFromEnv(process.env, [
29
+ "PI_TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES",
30
+ "TELEGRAM_MAX_ATTACHMENT_SIZE_BYTES",
31
+ ]);
10
32
 
11
33
  export interface TelegramAttachmentToolResult {
12
34
  content: Array<{ type: "text"; text: string }>;
13
35
  details: { paths: string[] };
14
36
  }
15
37
 
38
+ export interface TelegramQueuedAttachmentView {
39
+ path: string;
40
+ fileName: string;
41
+ }
42
+
43
+ export interface TelegramAttachmentQueueTargetView {
44
+ queuedAttachments: TelegramQueuedAttachmentView[];
45
+ }
46
+
47
+ export interface TelegramQueuedAttachmentTurnView extends TelegramAttachmentQueueTargetView {
48
+ chatId: number;
49
+ replyToMessageId: number;
50
+ }
51
+
52
+ function isTelegramPhotoAttachmentPath(path: string): boolean {
53
+ const normalized = path.toLowerCase();
54
+ return (
55
+ normalized.endsWith(".jpg") ||
56
+ normalized.endsWith(".jpeg") ||
57
+ normalized.endsWith(".png") ||
58
+ normalized.endsWith(".webp") ||
59
+ normalized.endsWith(".gif")
60
+ );
61
+ }
62
+
63
+ function formatTelegramAttachmentSizeLimitError(
64
+ size: number,
65
+ maxSize: number,
66
+ path?: string,
67
+ ): string {
68
+ const message = `Attachment exceeds size limit (${size} bytes > ${maxSize} bytes)`;
69
+ return path ? `${message}: ${path}` : message;
70
+ }
71
+
16
72
  export interface TelegramQueuedAttachmentDeliveryDeps {
17
73
  sendMultipart: (
18
74
  method: string,
@@ -26,39 +82,61 @@ export interface TelegramQueuedAttachmentDeliveryDeps {
26
82
  replyToMessageId: number,
27
83
  text: string,
28
84
  ) => Promise<unknown>;
85
+ recordRuntimeEvent?: (
86
+ category: string,
87
+ error: unknown,
88
+ details?: Record<string, unknown>,
89
+ ) => void;
90
+ statPath?: (path: string) => Promise<{ size: number }>;
91
+ maxAttachmentSizeBytes?: number;
29
92
  }
30
93
 
31
94
  export async function queueTelegramAttachments(options: {
32
- activeTurn: PendingTelegramTurn | undefined;
95
+ activeTurn: TelegramAttachmentQueueTargetView | undefined;
33
96
  paths: string[];
34
97
  maxAttachmentsPerTurn: number;
35
- statPath: (path: string) => Promise<{ isFile(): boolean }>;
98
+ maxAttachmentSizeBytes?: number;
99
+ statPath?: (path: string) => Promise<{ isFile(): boolean; size?: number }>;
36
100
  }): Promise<TelegramAttachmentToolResult> {
37
101
  if (!options.activeTurn) {
38
102
  throw new Error(
39
103
  "telegram_attach can only be used while replying to an active Telegram turn",
40
104
  );
41
105
  }
42
- const added: string[] = [];
106
+ if (
107
+ options.activeTurn.queuedAttachments.length + options.paths.length >
108
+ options.maxAttachmentsPerTurn
109
+ ) {
110
+ throw new Error(
111
+ `Attachment limit reached (${options.maxAttachmentsPerTurn})`,
112
+ );
113
+ }
114
+ const pendingAttachments: TelegramQueuedAttachmentView[] = [];
43
115
  for (const inputPath of options.paths) {
44
- const stats = await options.statPath(inputPath);
116
+ const stats = await (options.statPath ?? stat)(inputPath);
45
117
  if (!stats.isFile()) {
46
118
  throw new Error(`Not a file: ${inputPath}`);
47
119
  }
48
120
  if (
49
- options.activeTurn.queuedAttachments.length >=
50
- options.maxAttachmentsPerTurn
121
+ options.maxAttachmentSizeBytes !== undefined &&
122
+ stats.size !== undefined &&
123
+ stats.size > options.maxAttachmentSizeBytes
51
124
  ) {
52
125
  throw new Error(
53
- `Attachment limit reached (${options.maxAttachmentsPerTurn})`,
126
+ formatTelegramAttachmentSizeLimitError(
127
+ stats.size,
128
+ options.maxAttachmentSizeBytes,
129
+ inputPath,
130
+ ),
54
131
  );
55
132
  }
56
- options.activeTurn.queuedAttachments.push({
133
+ pendingAttachments.push({
57
134
  path: inputPath,
58
135
  fileName: basename(inputPath),
59
136
  });
60
- added.push(inputPath);
61
137
  }
138
+ options.activeTurn.queuedAttachments.push(...pendingAttachments);
139
+ const added = pendingAttachments.map((attachment) => attachment.path);
62
140
  return {
63
141
  content: [
64
142
  {
@@ -70,24 +148,58 @@ export async function queueTelegramAttachments(options: {
70
148
  };
71
149
  }
72
150
 
151
+ export function createTelegramQueuedAttachmentSender(
152
+ deps: TelegramQueuedAttachmentDeliveryDeps,
153
+ ) {
154
+ return async function sendQueuedAttachments(
155
+ turn: TelegramQueuedAttachmentTurnView,
156
+ ): Promise<void> {
157
+ await sendQueuedTelegramAttachments(turn, {
158
+ ...deps,
159
+ maxAttachmentSizeBytes:
160
+ deps.maxAttachmentSizeBytes ?? TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES,
161
+ });
162
+ };
163
+ }
164
+
73
165
  export async function sendQueuedTelegramAttachments(
74
- turn: PendingTelegramTurn,
166
+ turn: TelegramQueuedAttachmentTurnView,
75
167
  deps: TelegramQueuedAttachmentDeliveryDeps,
76
168
  ): Promise<void> {
77
169
  for (const attachment of turn.queuedAttachments) {
78
170
  try {
79
- const mediaType = guessMediaType(attachment.path);
80
- const method = mediaType ? "sendPhoto" : "sendDocument";
81
- const fieldName = mediaType ? "photo" : "document";
171
+ if (deps.maxAttachmentSizeBytes !== undefined) {
172
+ const stats = await (deps.statPath ?? stat)(attachment.path);
173
+ if (stats.size > deps.maxAttachmentSizeBytes) {
174
+ throw new Error(
175
+ formatTelegramAttachmentSizeLimitError(
176
+ stats.size,
177
+ deps.maxAttachmentSizeBytes,
178
+ ),
179
+ );
180
+ }
181
+ }
182
+ const isPhoto = isTelegramPhotoAttachmentPath(attachment.path);
183
+ const method = isPhoto ? "sendPhoto" : "sendDocument";
184
+ const fieldName = isPhoto ? "photo" : "document";
185
+ const replyParameters = buildTelegramMultipartReplyParameters(
186
+ turn.replyToMessageId,
187
+ );
82
188
  await deps.sendMultipart(
83
189
  method,
84
- { chat_id: String(turn.chatId) },
190
+ {
191
+ chat_id: String(turn.chatId),
192
+ ...(replyParameters ? { reply_parameters: replyParameters } : {}),
193
+ },
85
194
  fieldName,
86
195
  attachment.path,
87
196
  attachment.fileName,
88
197
  );
89
198
  } catch (error) {
90
199
  const message = error instanceof Error ? error.message : String(error);
200
+ deps.recordRuntimeEvent?.("attachment", error, {
201
+ fileName: attachment.fileName,
202
+ });
91
203
  await deps.sendTextReply(
92
204
  turn.chatId,
93
205
  turn.replyToMessageId,