pi-braid-telegram 0.0.0-stage → 0.1.4

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.
@@ -0,0 +1,203 @@
1
+ /**
2
+ * document.ts — shared inbound document extraction (owner file attachments).
3
+ *
4
+ * Both inbound consumers (background poller in inbound-poller.ts and the
5
+ * agentic pull in telegram-inbox.ts) route accepted document messages through
6
+ * these helpers: resolve the single Telegram `Document` object to a download
7
+ * path via getFile, fetch the bytes, save them under the media dir, and
8
+ * produce the agent-facing delivery text carrying the absolute path (or, on
9
+ * failure, the file_id + an error note — never drop the message).
10
+ *
11
+ * A document message may additionally carry a caption (carried like the photo
12
+ * caption). Unlike photos (a size-ordered variant array), a document is a
13
+ * single object with a client-side `file_name` — the safe extension is derived
14
+ * from `file_name` (never from the destination-affecting name itself: the
15
+ * saved file is always `<mediaDir>/<update_id>.<safe-ext>`).
16
+ *
17
+ * Size guard: the Telegram Bot API getFile route refuses files above 20 MB —
18
+ * when the declared file_size exceeds the limit, materializeDocument refuses
19
+ * BEFORE any network call and surfaces the explicit reason (never silent,
20
+ * never a crash, never a dropped message).
21
+ *
22
+ * Pure + injectable-fetch driven, mirroring photo.ts/voice.ts: `fetchFn`
23
+ * defaults to globalThis.fetch, so tests substitute a recorded fixture fake
24
+ * with NO network.
25
+ */
26
+
27
+ import { mkdir, writeFile } from "node:fs/promises";
28
+ import { join } from "node:path";
29
+ import { defaultMediaDir, warnUnderivableRoot } from "./state-paths.js";
30
+ import {
31
+ getFile,
32
+ downloadFile,
33
+ type TelegramFetch,
34
+ } from "./telegram-client.js";
35
+
36
+ /** A Telegram `Document` object (single object, NOT an array — unlike photo). */
37
+ export interface TelegramDocument {
38
+ file_id: string;
39
+ file_unique_id?: string;
40
+ /** Client-side file name as sent by the owner's client (may be hostile). */
41
+ file_name?: string;
42
+ mime_type?: string;
43
+ file_size?: number;
44
+ }
45
+
46
+ /** Telegram Bot API cap: getFile refuses files above 20 MB. */
47
+ export const MAX_DOCUMENT_BYTES = 20 * 1024 * 1024;
48
+
49
+ /** True when a message carries a usable `document` object (document inbound). */
50
+ export function hasDocument(message: unknown): boolean {
51
+ if (message == null || typeof message !== "object") return false;
52
+ const document = (message as { document?: unknown }).document;
53
+ return (
54
+ document != null &&
55
+ typeof document === "object" &&
56
+ typeof (document as { file_id?: unknown }).file_id === "string"
57
+ );
58
+ }
59
+
60
+ /** Derive a safe short file extension from the client-side file_name (fallback bin). */
61
+ export function safeExtFromFileName(fileName?: string): string {
62
+ const fallback = "bin";
63
+ if (!fileName) return fallback;
64
+ // Strip path separators (both flavors) so a hostile name can never carry a
65
+ // traversal chain: only the basename after the last separator is considered.
66
+ const base = fileName.split(/[\\/]/).pop() ?? "";
67
+ const trimmed = base.trim();
68
+ const dot = trimmed.lastIndexOf(".");
69
+ // No extension, a dotfile (".bashrc" style), or a trailing dot — fallback.
70
+ if (dot <= 0 || dot >= trimmed.length - 1) return fallback;
71
+ const ext = trimmed.slice(dot + 1).toLowerCase();
72
+ if (/^[a-z0-9]{1,8}$/.test(ext)) return ext;
73
+ return fallback;
74
+ }
75
+
76
+ /** Outcome of materializeDocument: saved path, or a degraded file_id + error. */
77
+ export type MaterializeDocumentResult =
78
+ | { ok: true; fileId: string; filePath: string; filePathOnTelegram: string }
79
+ | { ok: false; fileId: string; error: string };
80
+
81
+ /**
82
+ * Download a document and save it to `<mediaDir>/<updateId>.<safe-ext from
83
+ * file_name>`. Never throws: an over-limit file_size (refused before any
84
+ * network call), a getFile / download / write failure returns
85
+ * `{ ok: false, fileId, error }` so the caller can degrade instead of drop.
86
+ */
87
+ export async function materializeDocument(opts: {
88
+ token: string;
89
+ updateId: number;
90
+ document: TelegramDocument;
91
+ fetchFn?: TelegramFetch;
92
+ baseUrl?: string;
93
+ mediaDir?: string;
94
+ }): Promise<MaterializeDocumentResult> {
95
+ const { token, updateId, document, fetchFn, baseUrl } = opts;
96
+ const fileId = document.file_id;
97
+ const mediaDir = opts.mediaDir ?? defaultMediaDir();
98
+ if (mediaDir === null) {
99
+ warnUnderivableRoot("document download refused");
100
+ return {
101
+ ok: false,
102
+ fileId,
103
+ error:
104
+ "braid media directory is underivable (BRAID_HOME unset and no absolute home); document not saved",
105
+ };
106
+ }
107
+
108
+ // Size guard: getFile refuses files above 20 MB — refuse BEFORE the network
109
+ // call when the message declares an over-limit file_size.
110
+ if (
111
+ typeof document.file_size === "number" &&
112
+ document.file_size > MAX_DOCUMENT_BYTES
113
+ ) {
114
+ return {
115
+ ok: false,
116
+ fileId,
117
+ error: `file too large: ${document.file_size} bytes (Telegram Bot API limit is ${MAX_DOCUMENT_BYTES} bytes)`,
118
+ };
119
+ }
120
+
121
+ const fileInfo = await getFile(token, fileId, { fetch: fetchFn, baseUrl });
122
+ const filePathOnTelegram = fileInfo.ok ? fileInfo.file?.file_path : undefined;
123
+ if (!fileInfo.ok || !filePathOnTelegram) {
124
+ return {
125
+ ok: false,
126
+ fileId,
127
+ error: fileInfo.error ?? "getFile returned no file_path",
128
+ };
129
+ }
130
+ // getFile also reports the size — enforce the same guard on it (the message
131
+ // may have omitted file_size).
132
+ const reportedSize = fileInfo.file?.file_size;
133
+ if (typeof reportedSize === "number" && reportedSize > MAX_DOCUMENT_BYTES) {
134
+ return {
135
+ ok: false,
136
+ fileId,
137
+ error: `file too large: ${reportedSize} bytes (Telegram Bot API limit is ${MAX_DOCUMENT_BYTES} bytes)`,
138
+ };
139
+ }
140
+
141
+ const fileDownload = await downloadFile(token, filePathOnTelegram, {
142
+ fetch: fetchFn,
143
+ baseUrl,
144
+ });
145
+ if (!fileDownload.ok || !fileDownload.bytes) {
146
+ return {
147
+ ok: false,
148
+ fileId,
149
+ error: fileDownload.error ?? "file download failed",
150
+ };
151
+ }
152
+
153
+ const dest = join(
154
+ mediaDir,
155
+ `${updateId}.${safeExtFromFileName(document.file_name)}`,
156
+ );
157
+ try {
158
+ await mkdir(mediaDir, { recursive: true });
159
+ await writeFile(dest, fileDownload.bytes);
160
+ } catch (error) {
161
+ return {
162
+ ok: false,
163
+ fileId,
164
+ error: `failed to save ${dest}: ${error instanceof Error ? error.message : String(error)}`,
165
+ };
166
+ }
167
+ return { ok: true, fileId, filePath: dest, filePathOnTelegram };
168
+ }
169
+
170
+ /** Options for formatDocumentText — the agent-facing delivery string. */
171
+ export interface DocumentDeliveryTextOptions {
172
+ /** Sender label, e.g. "@owner" or "id 123" (matches the whisper header). */
173
+ senderLabel: string;
174
+ /** Absolute path of the saved file (when the download succeeded). */
175
+ filePath?: string;
176
+ /** Telegram file_id (always present for a document message). */
177
+ fileId?: string;
178
+ /** Original client-side file_name, when Telegram provided one. */
179
+ name?: string;
180
+ /** Message caption (trimmed), if any. */
181
+ caption?: string;
182
+ /** Download/save failure note (degraded delivery). */
183
+ error?: string;
184
+ }
185
+
186
+ /**
187
+ * Build the delivered text the receiving agent can act. Downloaded form:
188
+ * 📄 Document from @owner: /abs/path.pdf (name: report.pdf) (caption: none)
189
+ * Degraded (download failed or over-limit refused) form — never dropped:
190
+ * 📄 Document from @owner: file_id <id> (name: report.pdf) (download failed: <error>) (caption: none)
191
+ */
192
+ export function formatDocumentText(opts: DocumentDeliveryTextOptions): string {
193
+ const namePart = opts.name ? ` (name: ${opts.name})` : "";
194
+ const captionPart = opts.caption
195
+ ? ` (caption: ${opts.caption})`
196
+ : " (caption: none)";
197
+ if (opts.filePath) {
198
+ return `📄 Document from ${opts.senderLabel}: ${opts.filePath}${namePart}${captionPart}`;
199
+ }
200
+ const filePart = opts.fileId ? `file_id ${opts.fileId}` : "file_id unknown";
201
+ const errorPart = opts.error ? ` (download failed: ${opts.error})` : "";
202
+ return `📄 Document from ${opts.senderLabel}: ${filePart}${errorPart}${namePart}${captionPart}`;
203
+ }