@hywkp/test-openclaw-sider 1.0.2

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,983 @@
1
+ import os from "node:os";
2
+ import { readdir, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { createHash } from "node:crypto";
5
+ import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload";
6
+ import { loadWebMedia } from "openclaw/plugin-sdk/web-media";
7
+ import { formatAuthorizationHeader } from "./auth.js";
8
+ import { SIDER_USER_AGENT } from "./user-agent.js";
9
+
10
+ export type SiderUploadAccount = {
11
+ accountId: string;
12
+ gatewayUrl: string;
13
+ sendTimeoutMs: number;
14
+ token?: string;
15
+ };
16
+
17
+ export type SiderPart = {
18
+ id?: string;
19
+ type: string;
20
+ spec_version?: number;
21
+ payload?: unknown;
22
+ meta?: unknown;
23
+ };
24
+
25
+ type SiderReplyLike = {
26
+ text?: string;
27
+ mediaUrl?: string;
28
+ mediaUrls?: string[];
29
+ };
30
+
31
+ type SiderUploadLogger = {
32
+ debug?: (message: string, data?: Record<string, unknown>) => void;
33
+ warn?: (message: string, data?: Record<string, unknown>) => void;
34
+ };
35
+
36
+ type BuildSiderPartsParams = {
37
+ account: SiderUploadAccount;
38
+ sessionId: string;
39
+ payload: SiderReplyLike;
40
+ mediaLocalRoots?: readonly string[];
41
+ logger?: SiderUploadLogger;
42
+ };
43
+
44
+ type UploadedSiderFile = {
45
+ fileId: string;
46
+ objectKey: string;
47
+ fileName: string;
48
+ mimeType: string;
49
+ size: number;
50
+ sha256: string;
51
+ mediaKind: "image" | "file";
52
+ };
53
+
54
+ type PreparedSiderMedia = {
55
+ buffer: Buffer;
56
+ contentType?: string;
57
+ fileName?: string;
58
+ kind?: string;
59
+ sourceLabel?: string;
60
+ };
61
+
62
+ const DEFAULT_MIME = "application/octet-stream";
63
+
64
+ const MIME_BY_EXTENSION: Record<string, string> = {
65
+ ".jpg": "image/jpeg",
66
+ ".jpeg": "image/jpeg",
67
+ ".png": "image/png",
68
+ ".gif": "image/gif",
69
+ ".webp": "image/webp",
70
+ ".bmp": "image/bmp",
71
+ ".svg": "image/svg+xml",
72
+ ".pdf": "application/pdf",
73
+ ".txt": "text/plain",
74
+ ".json": "application/json",
75
+ ".zip": "application/zip",
76
+ };
77
+
78
+ const EXTENSION_BY_MIME: Record<string, string> = {
79
+ "image/jpeg": ".jpg",
80
+ "image/png": ".png",
81
+ "image/gif": ".gif",
82
+ "image/webp": ".webp",
83
+ "image/bmp": ".bmp",
84
+ "image/svg+xml": ".svg",
85
+ "application/pdf": ".pdf",
86
+ "text/plain": ".txt",
87
+ "application/json": ".json",
88
+ "application/zip": ".zip",
89
+ };
90
+
91
+ const ATTACHMENT_REF_PREFIX = "attachment://";
92
+ const MARKDOWN_IMAGE_RE = /!\[[^\]]*]\(([^)\r\n]+)\)/g;
93
+ const MARKDOWN_LINK_RE = /\[[^\]]*]\(([^)\r\n]+)\)/g;
94
+ const LOCAL_PATH_PREFIX_RE = /^(\/|\.\/|\.\.\/|~\/|[a-zA-Z]:[\\/]|\\\\)/;
95
+ const SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
96
+ const FILE_EXT_RE = /\.[a-z0-9]{1,10}$/i;
97
+ const GENERATED_ATTACHMENT_SUFFIX_RE =
98
+ /---[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}(?:\.[a-z0-9]{1,10})?$/i;
99
+
100
+ function toRecord(value: unknown): Record<string, unknown> | null {
101
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
102
+ return null;
103
+ }
104
+ return value as Record<string, unknown>;
105
+ }
106
+
107
+ function normalizeMime(raw?: string): string | undefined {
108
+ const value = raw?.trim().toLowerCase();
109
+ if (!value) {
110
+ return undefined;
111
+ }
112
+ const normalized = value.split(";")[0]?.trim();
113
+ return normalized || undefined;
114
+ }
115
+
116
+ function extFromPath(raw: string): string {
117
+ const qPos = raw.indexOf("?");
118
+ const sanitized = qPos >= 0 ? raw.slice(0, qPos) : raw;
119
+ const slashPos = Math.max(sanitized.lastIndexOf("/"), sanitized.lastIndexOf("\\"));
120
+ const last = slashPos >= 0 ? sanitized.slice(slashPos + 1) : sanitized;
121
+ const dotPos = last.lastIndexOf(".");
122
+ return dotPos >= 0 ? last.slice(dotPos).toLowerCase() : "";
123
+ }
124
+
125
+ function fileNameFromMediaUrl(raw: string): string | undefined {
126
+ try {
127
+ if (/^https?:\/\//i.test(raw) || /^file:\/\//i.test(raw)) {
128
+ const pathname = new URL(raw).pathname;
129
+ const seg = pathname.split("/").filter(Boolean).pop();
130
+ return seg || undefined;
131
+ }
132
+ } catch {
133
+ // no-op
134
+ }
135
+ const qPos = raw.indexOf("?");
136
+ const sanitized = qPos >= 0 ? raw.slice(0, qPos) : raw;
137
+ const seg = sanitized.split(/[\\/]/).filter(Boolean).pop();
138
+ return seg || undefined;
139
+ }
140
+
141
+ function normalizeFileName(raw: string | undefined, fallback: string): string {
142
+ const base = (raw ?? "").trim().split(/[\\/]/).filter(Boolean).pop() ?? "";
143
+ const cleaned = base.replace(/[\u0000-\u001f]/g, "");
144
+ if (!cleaned || cleaned === "." || cleaned === "..") {
145
+ return fallback;
146
+ }
147
+ return cleaned.slice(0, 255);
148
+ }
149
+
150
+ function inferMimeFromFileName(fileName: string): string | undefined {
151
+ const ext = extFromPath(fileName);
152
+ return MIME_BY_EXTENSION[ext];
153
+ }
154
+
155
+ function inferFallbackFileName(params: { mimeType: string; mediaKind: "image" | "file" }): string {
156
+ const ext = EXTENSION_BY_MIME[params.mimeType];
157
+ if (ext) {
158
+ return `attachment${ext}`;
159
+ }
160
+ return params.mediaKind === "image" ? "attachment.png" : "attachment.bin";
161
+ }
162
+
163
+ function sha256Hex(buffer: Buffer): string {
164
+ return createHash("sha256").update(buffer).digest("hex");
165
+ }
166
+
167
+ function resolveGatewayApiUrl(gatewayUrl: string, path: string): string {
168
+ const url = new URL(gatewayUrl);
169
+ const basePath = url.pathname.replace(/\/+$/, "");
170
+ const suffix = path.startsWith("/") ? path : `/${path}`;
171
+ url.pathname = `${basePath}${suffix}`;
172
+ url.search = "";
173
+ url.hash = "";
174
+ return url.toString();
175
+ }
176
+
177
+ async function readResponseErrorBody(response: Response): Promise<string> {
178
+ try {
179
+ const text = (await response.text()).trim();
180
+ return text;
181
+ } catch {
182
+ return "";
183
+ }
184
+ }
185
+
186
+ function describeError(error: unknown): string {
187
+ if (error instanceof Error) {
188
+ return error.message || String(error);
189
+ }
190
+ return String(error);
191
+ }
192
+
193
+ function stripQueryAndHash(raw: string): string {
194
+ return raw.split(/[?#]/, 1)[0] ?? raw;
195
+ }
196
+
197
+ function parseMarkdownLinkTarget(rawTarget: string): string | undefined {
198
+ const trimmed = rawTarget.trim();
199
+ if (!trimmed) {
200
+ return undefined;
201
+ }
202
+ let target = trimmed;
203
+ const wrappedInAngles = target.startsWith("<") && target.endsWith(">");
204
+ if (wrappedInAngles) {
205
+ target = target.slice(1, -1).trim();
206
+ }
207
+ if (!wrappedInAngles) {
208
+ const whitespaceIndex = target.search(/\s/);
209
+ if (whitespaceIndex > 0) {
210
+ target = target.slice(0, whitespaceIndex);
211
+ }
212
+ }
213
+ target = target.trim();
214
+ return target || undefined;
215
+ }
216
+
217
+ function shouldTreatMarkdownLinkAsMediaTarget(target: string): boolean {
218
+ const normalized = target.trim();
219
+ if (!normalized) {
220
+ return false;
221
+ }
222
+ if (normalized.toLowerCase().startsWith(ATTACHMENT_REF_PREFIX)) {
223
+ return true;
224
+ }
225
+ if (/^file:\/\//i.test(normalized)) {
226
+ return true;
227
+ }
228
+ if (LOCAL_PATH_PREFIX_RE.test(normalized)) {
229
+ return true;
230
+ }
231
+ if (!SCHEME_RE.test(normalized)) {
232
+ if (normalized.includes("/") || normalized.includes("\\")) {
233
+ return true;
234
+ }
235
+ if (FILE_EXT_RE.test(stripQueryAndHash(normalized))) {
236
+ return true;
237
+ }
238
+ }
239
+ return false;
240
+ }
241
+
242
+ function extractMarkdownMediaUrls(text: string | undefined): string[] {
243
+ if (!text) {
244
+ return [];
245
+ }
246
+ const urls: string[] = [];
247
+ for (const match of text.matchAll(MARKDOWN_IMAGE_RE)) {
248
+ const target = parseMarkdownLinkTarget(match[1] ?? "");
249
+ if (target) {
250
+ urls.push(target);
251
+ }
252
+ }
253
+ for (const match of text.matchAll(MARKDOWN_LINK_RE)) {
254
+ const start = match.index ?? 0;
255
+ if (start > 0 && text[start - 1] === "!") {
256
+ continue;
257
+ }
258
+ const target = parseMarkdownLinkTarget(match[1] ?? "");
259
+ if (target && shouldTreatMarkdownLinkAsMediaTarget(target)) {
260
+ urls.push(target);
261
+ }
262
+ }
263
+ return urls;
264
+ }
265
+
266
+ function dedupeMediaUrls(values: readonly string[]): string[] {
267
+ const seen = new Set<string>();
268
+ const deduped: string[] = [];
269
+ for (const raw of values) {
270
+ const trimmed = raw.trim();
271
+ if (!trimmed || seen.has(trimmed)) {
272
+ continue;
273
+ }
274
+ seen.add(trimmed);
275
+ deduped.push(trimmed);
276
+ }
277
+ return deduped;
278
+ }
279
+
280
+ function isPathWithinRoot(rootDir: string, candidatePath: string): boolean {
281
+ const relative = path.relative(rootDir, candidatePath);
282
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
283
+ }
284
+
285
+ function isPathWithinAnyRoot(filePath: string, roots?: readonly string[]): boolean {
286
+ if (!roots?.length) {
287
+ return false;
288
+ }
289
+ for (const rootEntry of roots) {
290
+ const trimmedRoot = rootEntry?.trim();
291
+ if (!trimmedRoot) {
292
+ continue;
293
+ }
294
+ if (isPathWithinRoot(path.resolve(trimmedRoot), filePath)) {
295
+ return true;
296
+ }
297
+ }
298
+ return false;
299
+ }
300
+
301
+ async function isExistingFile(filePath: string): Promise<boolean> {
302
+ try {
303
+ const stats = await stat(filePath);
304
+ return stats.isFile();
305
+ } catch {
306
+ return false;
307
+ }
308
+ }
309
+
310
+ function looksLikeGeneratedAttachmentName(params: {
311
+ requestedFileName: string;
312
+ candidateName: string;
313
+ }): boolean {
314
+ const requested = params.requestedFileName.trim();
315
+ const candidate = params.candidateName.trim();
316
+ if (!requested || !candidate) {
317
+ return false;
318
+ }
319
+ if (requested === candidate) {
320
+ return true;
321
+ }
322
+ if (!GENERATED_ATTACHMENT_SUFFIX_RE.test(candidate)) {
323
+ return false;
324
+ }
325
+ const requestedExt = path.extname(requested).toLowerCase();
326
+ const requestedBase = requestedExt ? requested.slice(0, -requestedExt.length) : requested;
327
+ if (!requestedBase) {
328
+ return false;
329
+ }
330
+ if (!candidate.startsWith(`${requestedBase}---`)) {
331
+ return false;
332
+ }
333
+ if (requestedExt && !candidate.toLowerCase().endsWith(requestedExt)) {
334
+ return false;
335
+ }
336
+ return true;
337
+ }
338
+
339
+ function buildAttachmentLookupDirs(rootDir: string): string[] {
340
+ return Array.from(
341
+ new Set([
342
+ rootDir,
343
+ path.join(rootDir, "tmp"),
344
+ path.join(rootDir, "out"),
345
+ path.join(rootDir, "outbound"),
346
+ path.join(rootDir, "inbound"),
347
+ path.join(rootDir, "media"),
348
+ path.join(rootDir, "media", "outbound"),
349
+ path.join(rootDir, "media", "inbound"),
350
+ path.join(rootDir, "workspace"),
351
+ path.join(rootDir, "sandboxes"),
352
+ ]),
353
+ );
354
+ }
355
+
356
+ async function resolveStoredAttachmentByFileName(params: {
357
+ rootDir: string;
358
+ fileName: string;
359
+ }): Promise<string | undefined> {
360
+ let bestPath: string | undefined;
361
+ let bestMtimeMs = -1;
362
+ for (const dir of buildAttachmentLookupDirs(params.rootDir)) {
363
+ if (!isPathWithinRoot(params.rootDir, dir)) {
364
+ continue;
365
+ }
366
+ let entries: string[] | undefined;
367
+ try {
368
+ entries = await readdir(dir, { encoding: "utf8" });
369
+ } catch {
370
+ continue;
371
+ }
372
+ if (!entries) {
373
+ continue;
374
+ }
375
+ for (const entryName of entries) {
376
+ if (
377
+ !looksLikeGeneratedAttachmentName({
378
+ requestedFileName: params.fileName,
379
+ candidateName: entryName,
380
+ })
381
+ ) {
382
+ continue;
383
+ }
384
+ const candidate = path.join(dir, entryName);
385
+ if (!isPathWithinRoot(params.rootDir, candidate)) {
386
+ continue;
387
+ }
388
+ try {
389
+ const fileStat = await stat(candidate);
390
+ if (!fileStat.isFile()) {
391
+ continue;
392
+ }
393
+ if (fileStat.mtimeMs >= bestMtimeMs) {
394
+ bestMtimeMs = fileStat.mtimeMs;
395
+ bestPath = candidate;
396
+ }
397
+ } catch {
398
+ continue;
399
+ }
400
+ }
401
+ }
402
+ return bestPath;
403
+ }
404
+
405
+ async function resolveAttachmentRefToLocalPath(params: {
406
+ attachmentRef: string;
407
+ mediaLocalRoots?: readonly string[];
408
+ }): Promise<string | undefined> {
409
+ const raw = params.attachmentRef.slice(ATTACHMENT_REF_PREFIX.length).trim();
410
+ if (!raw || !params.mediaLocalRoots?.length) {
411
+ return undefined;
412
+ }
413
+ let decoded: string;
414
+ try {
415
+ decoded = decodeURIComponent(raw);
416
+ } catch {
417
+ return undefined;
418
+ }
419
+ const decodedPath = stripQueryAndHash(decoded).trim();
420
+ if (!decodedPath) {
421
+ return undefined;
422
+ }
423
+ if (path.isAbsolute(decodedPath)) {
424
+ const absoluteCandidate = path.resolve(decodedPath);
425
+ if (
426
+ isPathWithinAnyRoot(absoluteCandidate, params.mediaLocalRoots) &&
427
+ (await isExistingFile(absoluteCandidate))
428
+ ) {
429
+ return absoluteCandidate;
430
+ }
431
+ }
432
+
433
+ const normalizedSlash = decodedPath.replace(/\\/g, "/").replace(/^\/+/, "");
434
+ if (!normalizedSlash) {
435
+ return undefined;
436
+ }
437
+ const segments = normalizedSlash.split("/").filter(Boolean);
438
+ if (segments.length === 0 || segments.some((segment) => segment === "." || segment === "..")) {
439
+ return undefined;
440
+ }
441
+ const relativePath = segments.join(path.sep);
442
+ const fileNameOnly = segments[segments.length - 1];
443
+ for (const rootEntry of params.mediaLocalRoots) {
444
+ const trimmedRoot = rootEntry?.trim();
445
+ if (!trimmedRoot) {
446
+ continue;
447
+ }
448
+ const rootDir = path.resolve(trimmedRoot);
449
+ const directCandidate = path.resolve(rootDir, relativePath);
450
+ if (isPathWithinRoot(rootDir, directCandidate) && (await isExistingFile(directCandidate))) {
451
+ return directCandidate;
452
+ }
453
+ if (!fileNameOnly) {
454
+ continue;
455
+ }
456
+ const fallbackCandidates = [
457
+ path.resolve(rootDir, fileNameOnly),
458
+ path.resolve(rootDir, "tmp", fileNameOnly),
459
+ path.resolve(rootDir, "out", fileNameOnly),
460
+ path.resolve(rootDir, "outbound", fileNameOnly),
461
+ path.resolve(rootDir, "inbound", fileNameOnly),
462
+ path.resolve(rootDir, "media", fileNameOnly),
463
+ path.resolve(rootDir, "media", "outbound", fileNameOnly),
464
+ path.resolve(rootDir, "media", "inbound", fileNameOnly),
465
+ path.resolve(rootDir, "workspace", fileNameOnly),
466
+ path.resolve(rootDir, "sandboxes", fileNameOnly),
467
+ ];
468
+ for (const candidate of fallbackCandidates) {
469
+ if (!isPathWithinRoot(rootDir, candidate)) {
470
+ continue;
471
+ }
472
+ if (await isExistingFile(candidate)) {
473
+ return candidate;
474
+ }
475
+ }
476
+ const generatedCandidate = await resolveStoredAttachmentByFileName({
477
+ rootDir,
478
+ fileName: fileNameOnly,
479
+ });
480
+ if (generatedCandidate) {
481
+ return generatedCandidate;
482
+ }
483
+ }
484
+ return undefined;
485
+ }
486
+
487
+ function stripSentMarkdownMediaLinks(
488
+ text: string | undefined,
489
+ sentSourceUrls: ReadonlySet<string>,
490
+ ): string {
491
+ const input = text ?? "";
492
+ if (!input.trim()) {
493
+ return "";
494
+ }
495
+ if (sentSourceUrls.size === 0) {
496
+ return input.trim();
497
+ }
498
+ const strippedImages = input.replace(MARKDOWN_IMAGE_RE, (match, rawTarget: string) => {
499
+ const target = parseMarkdownLinkTarget(rawTarget);
500
+ if (!target || !sentSourceUrls.has(target)) {
501
+ return match;
502
+ }
503
+ return "";
504
+ });
505
+ const strippedLinks = strippedImages.replace(
506
+ MARKDOWN_LINK_RE,
507
+ (match: string, rawTarget: string, offset: number, source: string) => {
508
+ if (offset > 0 && source[offset - 1] === "!") {
509
+ return match;
510
+ }
511
+ const target = parseMarkdownLinkTarget(rawTarget);
512
+ if (!target || !sentSourceUrls.has(target)) {
513
+ return match;
514
+ }
515
+ return "";
516
+ },
517
+ );
518
+ return strippedLinks
519
+ .replace(/[ \t]+\n/g, "\n")
520
+ .replace(/\n{3,}/g, "\n\n")
521
+ .trim();
522
+ }
523
+
524
+ async function postJson<T>(params: {
525
+ url: string;
526
+ body: Record<string, unknown>;
527
+ timeoutMs: number;
528
+ authorization?: string;
529
+ }): Promise<T> {
530
+ const headers: Record<string, string> = {
531
+ "Content-Type": "application/json",
532
+ "User-Agent": SIDER_USER_AGENT,
533
+ };
534
+ if (params.authorization?.trim()) {
535
+ headers.Authorization = formatAuthorizationHeader(params.authorization);
536
+ }
537
+ const response = await fetch(params.url, {
538
+ method: "POST",
539
+ headers,
540
+ body: JSON.stringify(params.body),
541
+ signal: AbortSignal.timeout(params.timeoutMs),
542
+ });
543
+ const text = await response.text();
544
+ if (!response.ok) {
545
+ const hint = text ? `: ${text.slice(0, 300)}` : "";
546
+ throw new Error(`HTTP ${response.status} ${response.statusText}${hint}`);
547
+ }
548
+ let parsed: unknown = {};
549
+ if (text.trim()) {
550
+ try {
551
+ parsed = JSON.parse(text) as unknown;
552
+ } catch (error) {
553
+ throw new Error(`invalid JSON response from ${params.url}: ${String(error)}`);
554
+ }
555
+ }
556
+ return parsed as T;
557
+ }
558
+
559
+ function parseFileInitResult(value: unknown): { fileId: string; objectKey: string; uploadUrl: string } {
560
+ const record = toRecord(value);
561
+ if (!record) {
562
+ throw new Error("files/init returned invalid response");
563
+ }
564
+ const fileId = typeof record.file_id === "string" ? record.file_id.trim() : "";
565
+ const objectKey = typeof record.object_key === "string" ? record.object_key.trim() : "";
566
+ const uploadUrl = typeof record.upload_url === "string" ? record.upload_url.trim() : "";
567
+ if (!fileId || !objectKey || !uploadUrl) {
568
+ throw new Error("files/init response missing file_id/object_key/upload_url");
569
+ }
570
+ return { fileId, objectKey, uploadUrl };
571
+ }
572
+
573
+ async function initFileUpload(params: {
574
+ account: SiderUploadAccount;
575
+ sessionId: string;
576
+ fileName: string;
577
+ size: number;
578
+ mimeType: string;
579
+ sha256: string;
580
+ logger?: SiderUploadLogger;
581
+ }): Promise<{ fileId: string; objectKey: string; uploadUrl: string }> {
582
+ const url = resolveGatewayApiUrl(params.account.gatewayUrl, "/v1/files/init");
583
+ try {
584
+ const response = await postJson<unknown>({
585
+ url,
586
+ timeoutMs: params.account.sendTimeoutMs,
587
+ authorization: params.account.token,
588
+ body: {
589
+ session_id: params.sessionId,
590
+ name: params.fileName,
591
+ size: params.size,
592
+ mime: params.mimeType,
593
+ sha256: params.sha256,
594
+ },
595
+ });
596
+ return parseFileInitResult(response);
597
+ } catch (error) {
598
+ params.logger?.warn?.("sider files/init failed", {
599
+ accountId: params.account.accountId,
600
+ sessionId: params.sessionId,
601
+ fileName: params.fileName,
602
+ size: params.size,
603
+ mimeType: params.mimeType,
604
+ sha256: params.sha256,
605
+ error: String(error),
606
+ });
607
+ throw new Error(`sider files/init failed: ${describeError(error)}`, {
608
+ cause: error instanceof Error ? error : undefined,
609
+ });
610
+ }
611
+ }
612
+
613
+ async function uploadToObjectStore(params: {
614
+ uploadUrl: string;
615
+ mimeType: string;
616
+ sha256: string;
617
+ buffer: Buffer;
618
+ timeoutMs: number;
619
+ logger?: SiderUploadLogger;
620
+ accountId?: string;
621
+ sessionId?: string;
622
+ fileId?: string;
623
+ objectKey?: string;
624
+ fileName?: string;
625
+ }): Promise<void> {
626
+ const headers: Record<string, string> = {
627
+ "User-Agent": SIDER_USER_AGENT,
628
+ };
629
+ if (params.mimeType) {
630
+ headers["Content-Type"] = params.mimeType;
631
+ }
632
+ if (params.sha256) {
633
+ // Required by gateway presign input (PutObjectInput.Metadata["sha256"]).
634
+ headers["x-amz-meta-sha256"] = params.sha256;
635
+ }
636
+ try {
637
+ const response = await fetch(params.uploadUrl, {
638
+ method: "PUT",
639
+ headers,
640
+ body: new Uint8Array(params.buffer),
641
+ signal: AbortSignal.timeout(params.timeoutMs),
642
+ });
643
+ if (!response.ok) {
644
+ const detail = await readResponseErrorBody(response);
645
+ const hint = detail ? `: ${detail.slice(0, 300)}` : "";
646
+ throw new Error(`upload failed (${response.status} ${response.statusText})${hint}`);
647
+ }
648
+ } catch (error) {
649
+ params.logger?.warn?.("sider object upload failed", {
650
+ accountId: params.accountId,
651
+ sessionId: params.sessionId,
652
+ fileId: params.fileId,
653
+ objectKey: params.objectKey,
654
+ fileName: params.fileName,
655
+ mimeType: params.mimeType,
656
+ size: params.buffer.length,
657
+ hasSha256Header: Boolean(params.sha256),
658
+ error: String(error),
659
+ });
660
+ const label = params.fileName ?? params.objectKey ?? "attachment";
661
+ throw new Error(`sider object upload failed (${label}): ${describeError(error)}`, {
662
+ cause: error instanceof Error ? error : undefined,
663
+ });
664
+ }
665
+ }
666
+
667
+ async function completeFileUploadBestEffort(params: {
668
+ account: SiderUploadAccount;
669
+ sessionId: string;
670
+ fileId: string;
671
+ objectKey: string;
672
+ size: number;
673
+ sha256: string;
674
+ logger?: SiderUploadLogger;
675
+ }): Promise<void> {
676
+ const url = resolveGatewayApiUrl(params.account.gatewayUrl, "/v1/files/complete");
677
+ try {
678
+ await postJson<unknown>({
679
+ url,
680
+ timeoutMs: params.account.sendTimeoutMs,
681
+ authorization: params.account.token,
682
+ body: {
683
+ session_id: params.sessionId,
684
+ file_id: params.fileId,
685
+ object_key: params.objectKey,
686
+ size: params.size,
687
+ sha256: params.sha256,
688
+ },
689
+ });
690
+ } catch (error) {
691
+ params.logger?.warn?.("sider files/complete failed (ignored)", {
692
+ accountId: params.account.accountId,
693
+ sessionId: params.sessionId,
694
+ fileId: params.fileId,
695
+ objectKey: params.objectKey,
696
+ error: String(error),
697
+ });
698
+ }
699
+ }
700
+
701
+ function buildSiderFilePart(params: {
702
+ uploaded: UploadedSiderFile;
703
+ sourceLabel?: string;
704
+ }): SiderPart {
705
+ return {
706
+ type: "core.file",
707
+ spec_version: 1,
708
+ payload: {
709
+ file_id: params.uploaded.fileId,
710
+ object_key: params.uploaded.objectKey,
711
+ name: params.uploaded.fileName,
712
+ size: params.uploaded.size,
713
+ mime: params.uploaded.mimeType,
714
+ sha256: params.uploaded.sha256,
715
+ upload_status: "uploaded",
716
+ },
717
+ meta: {
718
+ ...(params.sourceLabel ? { source_media_url: params.sourceLabel } : {}),
719
+ media_kind: params.uploaded.mediaKind,
720
+ },
721
+ };
722
+ }
723
+
724
+ async function uploadPreparedMediaToSider(params: {
725
+ account: SiderUploadAccount;
726
+ sessionId: string;
727
+ media: PreparedSiderMedia;
728
+ logger?: SiderUploadLogger;
729
+ }): Promise<UploadedSiderFile> {
730
+ const contentType = normalizeMime(params.media.contentType);
731
+ const inferredKind: "image" | "file" =
732
+ params.media.kind === "image" || contentType?.startsWith("image/") ? "image" : "file";
733
+ const sourceName = params.media.fileName || fileNameFromMediaUrl(params.media.sourceLabel ?? "");
734
+ const fallbackName = inferFallbackFileName({
735
+ mimeType: contentType ?? DEFAULT_MIME,
736
+ mediaKind: inferredKind,
737
+ });
738
+ const fileName = normalizeFileName(sourceName, fallbackName);
739
+ const mimeType = contentType || inferMimeFromFileName(fileName) || DEFAULT_MIME;
740
+ const size = params.media.buffer.length;
741
+ if (size <= 0) {
742
+ const suffix = params.media.sourceLabel ? ` from ${params.media.sourceLabel}` : "";
743
+ throw new Error(`empty media buffer${suffix}`);
744
+ }
745
+ const sha256 = sha256Hex(params.media.buffer);
746
+ const initResult = await initFileUpload({
747
+ account: params.account,
748
+ sessionId: params.sessionId,
749
+ fileName,
750
+ size,
751
+ mimeType,
752
+ sha256,
753
+ logger: params.logger,
754
+ });
755
+ params.logger?.debug?.("sider files/init success", {
756
+ accountId: params.account.accountId,
757
+ sessionId: params.sessionId,
758
+ fileId: initResult.fileId,
759
+ objectKey: initResult.objectKey,
760
+ size,
761
+ mimeType,
762
+ });
763
+ await uploadToObjectStore({
764
+ uploadUrl: initResult.uploadUrl,
765
+ mimeType,
766
+ sha256,
767
+ buffer: params.media.buffer,
768
+ timeoutMs: params.account.sendTimeoutMs,
769
+ logger: params.logger,
770
+ accountId: params.account.accountId,
771
+ sessionId: params.sessionId,
772
+ fileId: initResult.fileId,
773
+ objectKey: initResult.objectKey,
774
+ fileName,
775
+ });
776
+ await completeFileUploadBestEffort({
777
+ account: params.account,
778
+ sessionId: params.sessionId,
779
+ fileId: initResult.fileId,
780
+ objectKey: initResult.objectKey,
781
+ size,
782
+ sha256,
783
+ logger: params.logger,
784
+ });
785
+ return {
786
+ fileId: initResult.fileId,
787
+ objectKey: initResult.objectKey,
788
+ fileName,
789
+ mimeType,
790
+ size,
791
+ sha256,
792
+ mediaKind: inferredKind,
793
+ };
794
+ }
795
+
796
+ async function uploadMediaToSider(params: {
797
+ account: SiderUploadAccount;
798
+ sessionId: string;
799
+ mediaUrl: string;
800
+ sourceLabel?: string;
801
+ mediaLocalRoots?: readonly string[];
802
+ logger?: SiderUploadLogger;
803
+ }): Promise<UploadedSiderFile> {
804
+ const loaded = await loadWebMedia(params.mediaUrl, undefined, {
805
+ localRoots: params.mediaLocalRoots,
806
+ });
807
+ return await uploadPreparedMediaToSider({
808
+ account: params.account,
809
+ sessionId: params.sessionId,
810
+ media: {
811
+ buffer: loaded.buffer,
812
+ contentType: loaded.contentType,
813
+ fileName: loaded.fileName || fileNameFromMediaUrl(params.sourceLabel ?? params.mediaUrl),
814
+ kind: loaded.kind,
815
+ sourceLabel: params.sourceLabel ?? params.mediaUrl,
816
+ },
817
+ logger: params.logger,
818
+ });
819
+ }
820
+
821
+ type ResolvedOutboundMediaItem = {
822
+ loadUrl: string;
823
+ sourceLabel: string;
824
+ };
825
+
826
+ function resolveOutboundMediaLoadUrl(sourceLabel: string): string {
827
+ const trimmed = sourceLabel.trim();
828
+ if (!trimmed) {
829
+ return trimmed;
830
+ }
831
+ if (/^https?:\/\//i.test(trimmed) || /^data:/i.test(trimmed)) {
832
+ return trimmed;
833
+ }
834
+ if (/^file:\/\//i.test(trimmed)) {
835
+ try {
836
+ return path.resolve(new URL(trimmed).pathname);
837
+ } catch {
838
+ return trimmed;
839
+ }
840
+ }
841
+ if (trimmed === "~") {
842
+ return os.homedir();
843
+ }
844
+ if (trimmed.startsWith("~/")) {
845
+ return path.join(os.homedir(), trimmed.slice(2));
846
+ }
847
+ if (path.isAbsolute(trimmed) || /^[A-Za-z]:[\\/]/.test(trimmed)) {
848
+ return path.resolve(trimmed);
849
+ }
850
+ if (!SCHEME_RE.test(trimmed)) {
851
+ return path.resolve(trimmed);
852
+ }
853
+ return trimmed;
854
+ }
855
+
856
+ async function resolveOutboundMediaItems(
857
+ params: Pick<BuildSiderPartsParams, "payload" | "mediaLocalRoots" | "logger">,
858
+ ): Promise<ResolvedOutboundMediaItem[]> {
859
+ const candidateUrls = dedupeMediaUrls([
860
+ ...resolveOutboundMediaUrls(params.payload),
861
+ ...extractMarkdownMediaUrls(params.payload.text),
862
+ ]);
863
+ const mediaItems: ResolvedOutboundMediaItem[] = [];
864
+ for (const sourceLabel of candidateUrls) {
865
+ if (sourceLabel.toLowerCase().startsWith(ATTACHMENT_REF_PREFIX)) {
866
+ const localPath = await resolveAttachmentRefToLocalPath({
867
+ attachmentRef: sourceLabel,
868
+ mediaLocalRoots: params.mediaLocalRoots,
869
+ });
870
+ if (!localPath) {
871
+ params.logger?.warn?.("sider skipped unresolved attachment:// media reference", {
872
+ mediaUrl: sourceLabel,
873
+ mediaLocalRootsCount: params.mediaLocalRoots?.length ?? 0,
874
+ });
875
+ continue;
876
+ }
877
+ mediaItems.push({
878
+ loadUrl: localPath,
879
+ sourceLabel,
880
+ });
881
+ continue;
882
+ }
883
+ mediaItems.push({
884
+ loadUrl: resolveOutboundMediaLoadUrl(sourceLabel),
885
+ sourceLabel,
886
+ });
887
+ }
888
+ return mediaItems;
889
+ }
890
+
891
+ export async function buildSiderPartFromInlineAttachment(params: {
892
+ account: SiderUploadAccount;
893
+ sessionId: string;
894
+ buffer: Buffer;
895
+ contentType?: string;
896
+ fileName?: string;
897
+ mediaKind?: "image" | "file";
898
+ sourceLabel?: string;
899
+ logger?: SiderUploadLogger;
900
+ }): Promise<SiderPart> {
901
+ const uploaded = await uploadPreparedMediaToSider({
902
+ account: params.account,
903
+ sessionId: params.sessionId,
904
+ media: {
905
+ buffer: params.buffer,
906
+ contentType: params.contentType,
907
+ fileName: params.fileName,
908
+ kind: params.mediaKind,
909
+ sourceLabel: params.sourceLabel,
910
+ },
911
+ logger: params.logger,
912
+ });
913
+ return buildSiderFilePart({
914
+ uploaded,
915
+ sourceLabel: params.sourceLabel,
916
+ });
917
+ }
918
+
919
+ export async function buildSiderPartsFromReplyPayload(
920
+ params: BuildSiderPartsParams,
921
+ ): Promise<SiderPart[]> {
922
+ const mediaItems = await resolveOutboundMediaItems({
923
+ payload: params.payload,
924
+ mediaLocalRoots: params.mediaLocalRoots,
925
+ logger: params.logger,
926
+ });
927
+
928
+ const uploadedMediaParts: SiderPart[] = [];
929
+ const uploadFailures: Array<{ sourceLabel: string; error: string }> = [];
930
+ const sentSourceUrls = new Set<string>();
931
+ for (const media of mediaItems) {
932
+ try {
933
+ const uploaded = await uploadMediaToSider({
934
+ account: params.account,
935
+ sessionId: params.sessionId,
936
+ mediaUrl: media.loadUrl,
937
+ sourceLabel: media.sourceLabel,
938
+ mediaLocalRoots: params.mediaLocalRoots,
939
+ logger: params.logger,
940
+ });
941
+ uploadedMediaParts.push(
942
+ buildSiderFilePart({
943
+ uploaded,
944
+ sourceLabel: media.sourceLabel,
945
+ }),
946
+ );
947
+ sentSourceUrls.add(media.sourceLabel);
948
+ } catch (error) {
949
+ const describedError = describeError(error);
950
+ uploadFailures.push({
951
+ sourceLabel: media.sourceLabel,
952
+ error: describedError,
953
+ });
954
+ params.logger?.warn?.("sider skipped outbound media after upload failure", {
955
+ accountId: params.account.accountId,
956
+ sessionId: params.sessionId,
957
+ mediaUrl: media.sourceLabel,
958
+ resolvedMediaUrl: media.loadUrl,
959
+ error: describedError,
960
+ });
961
+ }
962
+ }
963
+
964
+ const parts: SiderPart[] = [];
965
+ const text = stripSentMarkdownMediaLinks(params.payload.text, sentSourceUrls);
966
+ if (text) {
967
+ parts.push({
968
+ type: "core.text",
969
+ spec_version: 1,
970
+ payload: { text },
971
+ });
972
+ }
973
+ parts.push(...uploadedMediaParts);
974
+
975
+ if (parts.length === 0 && uploadFailures.length > 0) {
976
+ const lastFailure = uploadFailures[uploadFailures.length - 1];
977
+ throw new Error(
978
+ `sider failed to upload outbound media ${lastFailure?.sourceLabel ?? ""}: ${lastFailure?.error ?? "unknown error"}`.trim(),
979
+ );
980
+ }
981
+
982
+ return parts;
983
+ }