/** * Save a remote HTTP(S) URL into Dropbox via `files/save_url` (Dropbox fetches the URL). * Use when MCP chat cannot carry large base64 (phone / MCP-only). Requires `confirm: true`. * Polls `files/save_url/check_job_status` when Dropbox returns an async job. * * @param params.path - Destination Dropbox path starting with `/` (prefer `/Apps/Kody/...`). * @param params.url - Public HTTP(S) URL Dropbox can fetch. * @param params.confirm - Required `true` to perform the mutating call. * @param params.dryRun - When true, return the planned request without calling Dropbox. * @param params.pollMs - Poll interval for async jobs (default 1500). * @param params.maxPolls - Max poll attempts (default 40). * @returns Dropbox file metadata (or dry-run / async failure details). * * @example * import uploadFromUrl from 'kody:@kentcdodds/dropbox/upload-from-url' * const result = await uploadFromUrl({ * path: '/Apps/Kody/inbox/photo.webp', * url: 'https://example.com/photo.webp', * confirm: true, * }) */ import { DropboxApiError, dropboxRpc, request } from './request.ts' import { APP_FOLDER_ROOT, suggestAppFolderPath } from './paths.ts' import type { DropboxRequestResult, JsonRecord } from './types.ts' import { optionalNumber, requireDropboxPath, requireRecord, requireString, } from './types.ts' export type UploadFromUrlInput = { path: string url: string confirm?: boolean dryRun?: boolean pollMs?: number maxPolls?: number } /** * Save a remote HTTP(S) URL into Dropbox via `files/save_url` (Dropbox fetches the URL). * Use when MCP chat cannot carry large base64 (phone / MCP-only). Requires `confirm: true`. * Polls `files/save_url/check_job_status` when Dropbox returns an async job. * * @param params.path - Destination Dropbox path starting with `/` (prefer `/Apps/Kody/...`). * @param params.url - Public HTTP(S) URL Dropbox can fetch. * @param params.confirm - Required `true` to perform the mutating call. * @param params.dryRun - When true, return the planned request without calling Dropbox. * @param params.pollMs - Poll interval for async jobs (default 1500). * @param params.maxPolls - Max poll attempts (default 40). * @returns Dropbox file metadata (or dry-run / async failure details). * * @example * import uploadFromUrl from 'kody:@kentcdodds/dropbox/upload-from-url' * const result = await uploadFromUrl({ * path: '/Apps/Kody/inbox/photo.webp', * url: 'https://example.com/photo.webp', * confirm: true, * }) */ export async function uploadFromUrl( input: UploadFromUrlInput, ): Promise { const path = requireDropboxPath(input.path) const url = requireString(input.url, 'url') if (!/^https?:\/\//i.test(url)) { throw new Error('url must be an http(s) URL Dropbox can fetch.') } const pollMs = optionalNumber(input.pollMs, 'pollMs') ?? 1500 const maxPolls = optionalNumber(input.maxPolls, 'maxPolls') ?? 40 if (!Number.isInteger(pollMs) || pollMs < 200 || pollMs > 30_000) { throw new Error('pollMs must be an integer from 200 through 30000.') } if (!Number.isInteger(maxPolls) || maxPolls < 1 || maxPolls > 120) { throw new Error('maxPolls must be an integer from 1 through 120.') } if (input.dryRun) { return { dryRun: true, endpoint: 'files/save_url', path, url, poll: 'files/save_url/check_job_status when async_job_id is returned', authorization: '[managed dropbox OAuth integration]', } } if (input.confirm !== true) { throw new Error( 'upload-from-url may mutate Dropbox. Pass confirm: true only after explicit user approval, or use dryRun: true.', ) } try { const started = await request({ endpoint: 'files/save_url', body: { path, url }, confirm: true, }) const body = unwrapBody(started) const tag = body['.tag'] if (tag === 'complete') { const { ['.tag']: _tag, ...metadata } = body return metadata } if (typeof body.async_job_id === 'string' && body.async_job_id) { return await pollSaveUrlJob(body.async_job_id, pollMs, maxPolls) } if (tag === 'async_job_id' && typeof body.async_job_id === 'string') { return await pollSaveUrlJob(body.async_job_id, pollMs, maxPolls) } return body } catch (error) { throw enhancePathError(error, path) } } async function pollSaveUrlJob( asyncJobId: string, pollMs: number, maxPolls: number, ): Promise { for (let attempt = 0; attempt < maxPolls; attempt += 1) { const status = await dropboxRpc('files/save_url/check_job_status', { async_job_id: asyncJobId, }) const tag = status['.tag'] if (tag === 'complete') { const { ['.tag']: _tag, ...metadata } = status return { ...metadata, async_job_id: asyncJobId } } if (tag === 'failed') { throw new Error(`files/save_url job failed: ${JSON.stringify(status)}`) } await sleep(pollMs) } throw new Error( `files/save_url job ${asyncJobId} still in progress after ${maxPolls} polls. Check later with files/save_url/check_job_status.`, ) } function unwrapBody(result: DropboxRequestResult | JsonRecord): JsonRecord { if (result && typeof result === 'object' && 'body' in result && 'ok' in result) { const body = (result as DropboxRequestResult).body if (!body || typeof body !== 'object' || Array.isArray(body)) { throw new Error('Unexpected files/save_url response body.') } return body as JsonRecord } return result as JsonRecord } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } function enhancePathError(error: unknown, path: string): Error { if (!(error instanceof DropboxApiError)) { return error instanceof Error ? error : new Error(String(error)) } const details = JSON.stringify(error.details ?? '') const looksLikePath = error.status === 409 || /path\/not_found|path\/malformed|not_found/i.test(details) || /path\/not_found|path\/malformed|not_found/i.test(error.message) if (!looksLikePath) return error const suggestion = suggestAppFolderPath(path) const hint = suggestion !== path ? ` Path may need the Kody app-folder prefix. Try "${suggestion}" (APP_FOLDER_ROOT is ${APP_FOLDER_ROOT}).` : ` Writable Kody files usually live under ${APP_FOLDER_ROOT}/….` return new DropboxApiError(`${error.message}.${hint}`, { status: error.status, statusText: error.statusText, requestId: error.requestId, details: error.details, }) } /** * Save a remote URL into Dropbox via files/save_url. * Requires `confirm: true`; use `dryRun: true` to preview. * @example * import uploadFromUrl from 'kody:@kentcdodds/dropbox/upload-from-url' * const result = await uploadFromUrl({ * path: '/Apps/Kody/inbox/photo.webp', * url: 'https://example.com/photo.webp', * confirm: true, * }) */ export default async function uploadFromUrlEntrypoint( params: Partial & Record = {}, ) { const input = requireRecord(params, 'upload-from-url') requireDropboxPath(input.path) requireString(input.url, 'url') return uploadFromUrl(input as unknown as UploadFromUrlInput) }