openmates 0.11.0-alpha.3 → 0.11.0-alpha.30

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,242 @@
1
+ // src/uploadService.ts
2
+ import { readFileSync } from "fs";
3
+ import { basename, extname } from "path";
4
+ var UPLOAD_MAX_ATTEMPTS = 3;
5
+ var UPLOAD_RETRY_DELAY_MS = 2e3;
6
+ var PROFILE_IMAGE_MAX_SIZE_BYTES = 300 * 1024;
7
+ function getUploadUrl(apiUrl) {
8
+ try {
9
+ const url = new URL(apiUrl);
10
+ if (url.hostname === "localhost") return "http://localhost:8001";
11
+ } catch {
12
+ }
13
+ return "https://upload.openmates.org";
14
+ }
15
+ function getUploadOrigin(apiUrl) {
16
+ try {
17
+ const url = new URL(apiUrl);
18
+ if (url.hostname === "localhost") return "http://localhost:5173";
19
+ if (url.hostname.startsWith("api.")) {
20
+ return `${url.protocol}//app.${url.hostname.slice(4)}`;
21
+ }
22
+ } catch {
23
+ }
24
+ return "https://app.openmates.org";
25
+ }
26
+ function getUploadMimeType(filename) {
27
+ const ext = extname(filename).toLowerCase();
28
+ switch (ext) {
29
+ case ".jpg":
30
+ case ".jpeg":
31
+ return "image/jpeg";
32
+ case ".png":
33
+ return "image/png";
34
+ case ".webp":
35
+ return "image/webp";
36
+ case ".gif":
37
+ return "image/gif";
38
+ case ".heic":
39
+ return "image/heic";
40
+ case ".heif":
41
+ return "image/heif";
42
+ case ".bmp":
43
+ return "image/bmp";
44
+ case ".tif":
45
+ case ".tiff":
46
+ return "image/tiff";
47
+ case ".svg":
48
+ return "image/svg+xml";
49
+ case ".pdf":
50
+ return "application/pdf";
51
+ case ".mp3":
52
+ return "audio/mpeg";
53
+ case ".m4a":
54
+ case ".mp4":
55
+ return "audio/mp4";
56
+ case ".wav":
57
+ return "audio/wav";
58
+ case ".webm":
59
+ return "audio/webm";
60
+ case ".ogg":
61
+ case ".oga":
62
+ return "audio/ogg";
63
+ case ".aac":
64
+ return "audio/aac";
65
+ default:
66
+ return "application/octet-stream";
67
+ }
68
+ }
69
+ async function uploadFile(filePath, session) {
70
+ const filename = basename(filePath);
71
+ const fileBytes = readFileSync(filePath);
72
+ const uploadUrl = `${getUploadUrl(session.apiUrl)}/v1/upload/file`;
73
+ const origin = getUploadOrigin(session.apiUrl);
74
+ const cookies = [];
75
+ if (session.cookies?.auth_refresh_token) {
76
+ cookies.push(`auth_refresh_token=${session.cookies.auth_refresh_token}`);
77
+ }
78
+ let response;
79
+ let lastError;
80
+ for (let attempt = 1; attempt <= UPLOAD_MAX_ATTEMPTS; attempt++) {
81
+ try {
82
+ const blob = new Blob([fileBytes], { type: getUploadMimeType(filename) });
83
+ const formData = new FormData();
84
+ formData.append("file", blob, filename);
85
+ response = await fetch(uploadUrl, {
86
+ method: "POST",
87
+ body: formData,
88
+ headers: {
89
+ Origin: origin,
90
+ ...cookies.length > 0 ? { Cookie: cookies.join("; ") } : {}
91
+ },
92
+ signal: AbortSignal.timeout(10 * 60 * 1e3)
93
+ // 10-minute timeout
94
+ });
95
+ break;
96
+ } catch (error) {
97
+ lastError = error;
98
+ if (attempt === UPLOAD_MAX_ATTEMPTS) break;
99
+ await new Promise((resolve) => setTimeout(resolve, UPLOAD_RETRY_DELAY_MS));
100
+ }
101
+ }
102
+ if (!response) {
103
+ const message = lastError instanceof Error ? lastError.message : String(lastError);
104
+ throw new Error(message || "Upload request failed.");
105
+ }
106
+ if (!response.ok) {
107
+ const status = response.status;
108
+ let errorMessage;
109
+ switch (status) {
110
+ case 401:
111
+ errorMessage = "Authentication failed. Run `openmates login` to re-authenticate.";
112
+ break;
113
+ case 413:
114
+ errorMessage = "File too large (maximum 100 MB).";
115
+ break;
116
+ case 415:
117
+ errorMessage = "Unsupported file type.";
118
+ break;
119
+ case 422: {
120
+ const body = await response.text().catch(() => "");
121
+ errorMessage = body.includes("malware") ? "File rejected: malware detected." : body.includes("content_safety") ? "File rejected: content safety violation." : `Upload validation failed: ${body}`;
122
+ break;
123
+ }
124
+ case 429:
125
+ errorMessage = "Upload rate limit exceeded. Try again in a minute.";
126
+ break;
127
+ default:
128
+ errorMessage = `Upload failed (HTTP ${status}).`;
129
+ }
130
+ throw new Error(errorMessage);
131
+ }
132
+ const data = await response.json();
133
+ return data;
134
+ }
135
+ async function transcribeUploadedAudio(uploadResult, filename, session, options = {}) {
136
+ const s3Key = uploadResult.files?.original?.s3_key ?? Object.values(uploadResult.files ?? {})[0]?.s3_key;
137
+ if (!s3Key) {
138
+ throw new Error("Upload succeeded but no audio file key was returned.");
139
+ }
140
+ const cookies = [];
141
+ if (session.cookies?.auth_refresh_token) {
142
+ cookies.push(`auth_refresh_token=${session.cookies.auth_refresh_token}`);
143
+ }
144
+ const requestItem = {
145
+ id: options.requestId ?? uploadResult.embed_id,
146
+ embed_id: uploadResult.embed_id,
147
+ s3_key: s3Key,
148
+ s3_base_url: uploadResult.s3_base_url,
149
+ aes_key: uploadResult.aes_key,
150
+ aes_nonce: uploadResult.aes_nonce,
151
+ vault_wrapped_aes_key: uploadResult.vault_wrapped_aes_key,
152
+ filename,
153
+ mime_type: uploadResult.content_type
154
+ };
155
+ if (options.chatId) {
156
+ requestItem.chat_id = options.chatId;
157
+ }
158
+ const response = await fetch(
159
+ `${session.apiUrl.replace(/\/$/, "")}/v1/apps/audio/skills/transcribe`,
160
+ {
161
+ method: "POST",
162
+ headers: {
163
+ Accept: "application/json",
164
+ "Content-Type": "application/json",
165
+ ...cookies.length > 0 ? { Cookie: cookies.join("; ") } : {}
166
+ },
167
+ body: JSON.stringify({ requests: [requestItem] }),
168
+ signal: AbortSignal.timeout(10 * 60 * 1e3)
169
+ }
170
+ );
171
+ if (!response.ok) {
172
+ let detail = `Transcription failed (HTTP ${response.status}).`;
173
+ try {
174
+ const data2 = await response.json();
175
+ detail = data2.detail ?? data2.error ?? detail;
176
+ } catch {
177
+ }
178
+ throw new Error(detail);
179
+ }
180
+ const data = await response.json();
181
+ if (data.success === false) {
182
+ throw new Error(data.error ?? "Transcription failed.");
183
+ }
184
+ const requestId = options.requestId ?? uploadResult.embed_id;
185
+ const group = data.data?.results?.find((item) => item.id === requestId) ?? data.data?.results?.[0];
186
+ const result = group?.results?.[0];
187
+ if (!result) {
188
+ throw new Error(group?.error ?? "Transcription response did not include a result.");
189
+ }
190
+ if (result.error) {
191
+ throw new Error(result.error);
192
+ }
193
+ return {
194
+ transcript: result.transcript ?? null,
195
+ transcript_original: result.transcript_original ?? null,
196
+ transcript_corrected: result.transcript_corrected ?? null,
197
+ use_corrected: result.use_corrected ?? null,
198
+ correction_model: result.correction_model ?? null,
199
+ model: result.model ?? null
200
+ };
201
+ }
202
+ function getProfileImageMime(filename) {
203
+ const ext = extname(filename).toLowerCase();
204
+ if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
205
+ if (ext === ".png") return "image/png";
206
+ throw new Error("Profile images must be JPEG or PNG files.");
207
+ }
208
+ async function uploadProfileImage(filePath, session) {
209
+ const filename = basename(filePath);
210
+ const fileBytes = readFileSync(filePath);
211
+ const contentType = getProfileImageMime(filename);
212
+ if (fileBytes.byteLength > PROFILE_IMAGE_MAX_SIZE_BYTES) {
213
+ throw new Error("Profile image must be 300 KB or smaller. Resize/compress the image and try again.");
214
+ }
215
+ const uploadUrl = `${getUploadUrl(session.apiUrl)}/v1/upload/profile-image`;
216
+ const origin = getUploadOrigin(session.apiUrl);
217
+ const cookies = [];
218
+ if (session.cookies?.auth_refresh_token) {
219
+ cookies.push(`auth_refresh_token=${session.cookies.auth_refresh_token}`);
220
+ }
221
+ const formData = new FormData();
222
+ formData.append("file", new Blob([fileBytes], { type: contentType }), filename);
223
+ const response = await fetch(uploadUrl, {
224
+ method: "POST",
225
+ body: formData,
226
+ headers: {
227
+ Origin: origin,
228
+ ...cookies.length > 0 ? { Cookie: cookies.join("; ") } : {}
229
+ }
230
+ });
231
+ const data = await response.json().catch(() => ({}));
232
+ if (!response.ok && !data.status) {
233
+ throw new Error(data.detail ?? `Profile image upload failed (HTTP ${response.status}).`);
234
+ }
235
+ return data;
236
+ }
237
+
238
+ export {
239
+ uploadFile,
240
+ transcribeUploadedAudio,
241
+ uploadProfileImage
242
+ };