import { execFile } from "child_process"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; import path from "path"; import { promisify } from "util"; import type { Page } from "playwright"; import { humanDelay } from "@/lib/utils"; import { draftImageAbsolutePath } from "@/lib/drafts/images"; import type { DebugRun } from "./debug"; import { captureDebugStep } from "./debug"; const execFileAsync = promisify(execFile); const ATTACH_TRIGGER_SELECTORS = [ '[role="dialog"] [aria-label*="附加"]', '[role="dialog"] [aria-label*="Attach"]', '[role="dialog"] [aria-label*="媒體"]', '[role="dialog"] [aria-label*="Media"]', '[role="dialog"] [aria-label*="照片"]', '[role="dialog"] [aria-label*="Photo"]', '[role="dialog"] [aria-label*="相簿"]', '[role="dialog"] [aria-label*="Album"]', '[role="dialog"] [aria-label*="圖片"]', '[role="dialog"] [aria-label*="Image"]', '[aria-label*="附加"]', '[aria-label*="Attach"]', '[aria-label*="媒體"]', '[aria-label*="Media"]', '[aria-label*="照片"]', '[aria-label*="Photo"]', '[aria-label*="相簿"]', '[aria-label*="Album"]', '[aria-label*="圖片"]', '[aria-label*="Image"]', '[aria-label*="從電腦"]', '[aria-label*="Upload"]', 'div[role="button"]:has-text("附加")', 'div[role="button"]:has-text("Attach")', ]; const IMAGE_PREVIEW_SELECTORS = [ '[role="dialog"] img[src^="blob:"]', '[role="dialog"] img[src^="data:"]', '[role="dialog"] video[src^="blob:"]', '[role="dialog"] [aria-label*="移除"]', '[role="dialog"] [aria-label*="Remove"]', '[role="dialog"] [aria-label*="刪除"]', '[role="dialog"] [aria-label*="Delete"]', '[role="dialog"] [data-testid*="media"]', ]; export interface PreparedUploadImage { path: string; cleanup?: () => Promise; } export async function prepareImageForBrowserUpload(imagePath: string): Promise { const absolute = draftImageAbsolutePath(imagePath); const ext = path.extname(imagePath).toLowerCase(); if (ext === ".jpg" || ext === ".jpeg" || ext === ".png") { return { path: absolute }; } if (process.platform === "darwin" && (ext === ".webp" || ext === ".gif")) { const tempDir = await mkdtemp(path.join(tmpdir(), "threadtools-img-")); const outPath = path.join(tempDir, "upload.jpg"); try { await execFileAsync("sips", ["-s", "format", "jpeg", absolute, "--out", outPath]); return { path: outPath, cleanup: async () => { await rm(tempDir, { recursive: true, force: true }); }, }; } catch { await rm(tempDir, { recursive: true, force: true }); } } return { path: absolute }; } async function dismissCropOrConfirmDialog(page: Page) { const labels = [/完成/, /Done/, /加入/, /Add/, /套用/, /Apply/, /確定/, /OK/]; for (const pattern of labels) { const btn = page.getByRole("button", { name: pattern }).first(); if (await btn.isVisible({ timeout: 400 }).catch(() => false)) { await btn.click().catch(() => undefined); await humanDelay(500, 900); } } } async function waitForImagePreview(page: Page, debug?: DebugRun | null, timeoutMs = 20000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { await dismissCropOrConfirmDialog(page); for (const selector of IMAGE_PREVIEW_SELECTORS) { if (await page.locator(selector).first().isVisible({ timeout: 400 }).catch(() => false)) { await captureDebugStep(page, debug, "attach-image-preview"); return true; } } await page.waitForTimeout(500); } return false; } async function uploadViaFileChooser( page: Page, imagePath: string, debug?: DebugRun | null ): Promise { for (const selector of ATTACH_TRIGGER_SELECTORS) { const trigger = page.locator(selector).first(); if (!(await trigger.isVisible({ timeout: 900 }).catch(() => false))) continue; try { const [fileChooser] = await Promise.all([ page.waitForEvent("filechooser", { timeout: 6000 }), trigger.click(), ]); await fileChooser.setFiles(imagePath); await humanDelay(800, 1400); if (await waitForImagePreview(page, debug)) return true; } catch { // try next trigger } } return false; } async function uploadViaHiddenInput( page: Page, imagePath: string, debug?: DebugRun | null ): Promise { const inputs = page.locator('input[type="file"]'); const count = await inputs.count(); for (let i = 0; i < count; i++) { const input = inputs.nth(i); try { await input.setInputFiles(imagePath); await humanDelay(800, 1400); if (await waitForImagePreview(page, debug)) return true; } catch { // try next input } } return false; } async function uploadViaToolbarEvaluate( page: Page, imagePath: string, debug?: DebugRun | null ): Promise { const clicked = await page.evaluate(() => { const labels = ["附加", "Attach", "媒體", "Media", "照片", "Photo", "相簿", "Album"]; for (const label of labels) { const node = document.querySelector(`[aria-label*="${label}"]`); if (node instanceof HTMLElement) { node.click(); return true; } } return false; }); if (!clicked) return false; try { const fileChooser = await page.waitForEvent("filechooser", { timeout: 5000 }); await fileChooser.setFiles(imagePath); await humanDelay(800, 1400); return waitForImagePreview(page, debug); } catch { return uploadViaHiddenInput(page, imagePath, debug); } } async function attachMultipleImages( page: Page, imagePaths: string[], debug?: DebugRun | null ): Promise { if (imagePaths.length <= 1) return false; for (const selector of ATTACH_TRIGGER_SELECTORS) { const trigger = page.locator(selector).first(); if (!(await trigger.isVisible({ timeout: 900 }).catch(() => false))) continue; try { const [fileChooser] = await Promise.all([ page.waitForEvent("filechooser", { timeout: 6000 }), trigger.click(), ]); await fileChooser.setFiles(imagePaths); await humanDelay(1200, 2000); if (await waitForImagePreview(page, debug, 25000)) { await captureDebugStep(page, debug, "attach-images-multi", { count: imagePaths.length }); return true; } } catch { // try next trigger } } return false; } export async function attachImages( page: Page, imagePaths: string[], debug?: DebugRun | null ): Promise { if (imagePaths.length === 0) return true; const prepared = await Promise.all(imagePaths.map((p) => prepareImageForBrowserUpload(p))); try { const paths = prepared.map((item) => item.path); if (paths.length > 1 && (await attachMultipleImages(page, paths, debug))) { return true; } for (const item of prepared) { const attached = await attachImage(page, item.path, debug); if (!attached) return false; await humanDelay(900, 1400); } return true; } finally { await Promise.all(prepared.map((item) => item.cleanup?.())); } } export async function attachImage( page: Page, imagePath: string, debug?: DebugRun | null ): Promise { await captureDebugStep(page, debug, "attach-image-start", { imagePath }); if (await uploadViaFileChooser(page, imagePath, debug)) { await captureDebugStep(page, debug, "attach-image-done"); return true; } if (await uploadViaHiddenInput(page, imagePath, debug)) { await captureDebugStep(page, debug, "attach-image-done"); return true; } if (await uploadViaToolbarEvaluate(page, imagePath, debug)) { await captureDebugStep(page, debug, "attach-image-done"); return true; } await captureDebugStep(page, debug, "attach-image-failed"); return false; }