@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/README.md +40 -26
- package/docs/architecture.md +62 -35
- package/index.ts +388 -1936
- package/lib/api.ts +647 -76
- package/lib/attachments.ts +128 -16
- package/lib/commands.ts +721 -0
- package/lib/config.ts +157 -0
- package/lib/media.ts +211 -36
- package/lib/menu.ts +920 -338
- package/lib/model.ts +647 -0
- package/lib/pi.ts +80 -0
- package/lib/polling.ts +264 -18
- package/lib/preview.ts +451 -29
- package/lib/queue.ts +1134 -110
- package/lib/registration.ts +127 -28
- package/lib/rendering.ts +575 -281
- package/lib/replies.ts +198 -8
- package/lib/runtime.ts +475 -0
- package/lib/setup.ts +129 -1
- package/lib/status.ts +428 -13
- package/lib/turns.ts +207 -17
- package/lib/updates.ts +392 -99
- package/package.json +18 -3
- package/AGENTS.md +0 -91
- package/BACKLOG.md +0 -5
- package/CHANGELOG.md +0 -23
- package/lib/model-switch.ts +0 -62
- package/tests/api.test.ts +0 -89
- package/tests/attachments.test.ts +0 -132
- package/tests/config.test.ts +0 -80
- package/tests/media.test.ts +0 -77
- package/tests/menu.test.ts +0 -676
- package/tests/polling.test.ts +0 -129
- package/tests/preview.test.ts +0 -441
- package/tests/queue.test.ts +0 -3245
- package/tests/registration.test.ts +0 -268
- package/tests/rendering.test.ts +0 -475
- package/tests/replies.test.ts +0 -142
- package/tests/turns.test.ts +0 -132
- package/tests/updates.test.ts +0 -357
package/lib/attachments.ts
CHANGED
|
@@ -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 {
|
|
9
|
-
|
|
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:
|
|
95
|
+
activeTurn: TelegramAttachmentQueueTargetView | undefined;
|
|
33
96
|
paths: string[];
|
|
34
97
|
maxAttachmentsPerTurn: number;
|
|
35
|
-
|
|
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
|
-
|
|
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.
|
|
50
|
-
|
|
121
|
+
options.maxAttachmentSizeBytes !== undefined &&
|
|
122
|
+
stats.size !== undefined &&
|
|
123
|
+
stats.size > options.maxAttachmentSizeBytes
|
|
51
124
|
) {
|
|
52
125
|
throw new Error(
|
|
53
|
-
|
|
126
|
+
formatTelegramAttachmentSizeLimitError(
|
|
127
|
+
stats.size,
|
|
128
|
+
options.maxAttachmentSizeBytes,
|
|
129
|
+
inputPath,
|
|
130
|
+
),
|
|
54
131
|
);
|
|
55
132
|
}
|
|
56
|
-
|
|
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:
|
|
166
|
+
turn: TelegramQueuedAttachmentTurnView,
|
|
75
167
|
deps: TelegramQueuedAttachmentDeliveryDeps,
|
|
76
168
|
): Promise<void> {
|
|
77
169
|
for (const attachment of turn.queuedAttachments) {
|
|
78
170
|
try {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
{
|
|
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,
|