| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs' |
| import axios from 'axios' |
| import { randomUUID } from 'crypto' |
| import { mkdir, writeFile } from 'fs/promises' |
| import { basename, join } from 'path' |
| import { z } from 'zod/v4' |
| import { getSessionId } from '../bootstrap/state.js' |
| import { logForDebugging } from '../utils/debug.js' |
| import { getClaudeConfigHomeDir } from '../utils/envUtils.js' |
| import { lazySchema } from '../utils/lazySchema.js' |
| import { getBridgeAccessToken, getBridgeBaseUrl } from './bridgeConfig.js' |
|
|
| const DOWNLOAD_TIMEOUT_MS = 30_000 |
|
|
| function debug(msg: string): void { |
| logForDebugging(`[bridge:inbound-attach] ${msg}`) |
| } |
|
|
| const attachmentSchema = lazySchema(() => |
| z.object({ |
| file_uuid: z.string(), |
| file_name: z.string(), |
| }), |
| ) |
| const attachmentsArraySchema = lazySchema(() => z.array(attachmentSchema())) |
|
|
| export type InboundAttachment = z.infer<ReturnType<typeof attachmentSchema>> |
|
|
| |
| export function extractInboundAttachments(msg: unknown): InboundAttachment[] { |
| if (typeof msg !== 'object' || msg === null || !('file_attachments' in msg)) { |
| return [] |
| } |
| const parsed = attachmentsArraySchema().safeParse(msg.file_attachments) |
| return parsed.success ? parsed.data : [] |
| } |
|
|
| |
| |
| |
| |
| |
| function sanitizeFileName(name: string): string { |
| const base = basename(name).replace(/[^a-zA-Z0-9._-]/g, '_') |
| return base || 'attachment' |
| } |
|
|
| function uploadsDir(): string { |
| return join(getClaudeConfigHomeDir(), 'uploads', getSessionId()) |
| } |
|
|
| |
| |
| |
| |
| async function resolveOne(att: InboundAttachment): Promise<string | undefined> { |
| const token = getBridgeAccessToken() |
| if (!token) { |
| debug('skip: no oauth token') |
| return undefined |
| } |
|
|
| let data: Buffer |
| try { |
| |
| |
| |
| |
| const url = `${getBridgeBaseUrl()}/api/oauth/files/${encodeURIComponent(att.file_uuid)}/content` |
| const response = await axios.get(url, { |
| headers: { Authorization: `Bearer ${token}` }, |
| responseType: 'arraybuffer', |
| timeout: DOWNLOAD_TIMEOUT_MS, |
| validateStatus: () => true, |
| }) |
| if (response.status !== 200) { |
| debug(`fetch ${att.file_uuid} failed: status=${response.status}`) |
| return undefined |
| } |
| data = Buffer.from(response.data) |
| } catch (e) { |
| debug(`fetch ${att.file_uuid} threw: ${e}`) |
| return undefined |
| } |
|
|
| |
| |
| const safeName = sanitizeFileName(att.file_name) |
| const prefix = ( |
| att.file_uuid.slice(0, 8) || randomUUID().slice(0, 8) |
| ).replace(/[^a-zA-Z0-9_-]/g, '_') |
| const dir = uploadsDir() |
| const outPath = join(dir, `${prefix}-${safeName}`) |
|
|
| try { |
| await mkdir(dir, { recursive: true }) |
| await writeFile(outPath, data) |
| } catch (e) { |
| debug(`write ${outPath} failed: ${e}`) |
| return undefined |
| } |
|
|
| debug(`resolved ${att.file_uuid} → ${outPath} (${data.length} bytes)`) |
| return outPath |
| } |
|
|
| |
| |
| |
| |
| export async function resolveInboundAttachments( |
| attachments: InboundAttachment[], |
| ): Promise<string> { |
| if (attachments.length === 0) return '' |
| debug(`resolving ${attachments.length} attachment(s)`) |
| const paths = await Promise.all(attachments.map(resolveOne)) |
| const ok = paths.filter((p): p is string => p !== undefined) |
| if (ok.length === 0) return '' |
| |
| |
| return ok.map(p => `@"${p}"`).join(' ') + ' ' |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export function prependPathRefs( |
| content: string | Array<ContentBlockParam>, |
| prefix: string, |
| ): string | Array<ContentBlockParam> { |
| if (!prefix) return content |
| if (typeof content === 'string') return prefix + content |
| const i = content.findLastIndex(b => b.type === 'text') |
| if (i !== -1) { |
| const b = content[i]! |
| if (b.type === 'text') { |
| return [ |
| ...content.slice(0, i), |
| { ...b, text: prefix + b.text }, |
| ...content.slice(i + 1), |
| ] |
| } |
| } |
| |
| return [...content, { type: 'text', text: prefix.trimEnd() }] |
| } |
|
|
| |
| |
| |
| |
| export async function resolveAndPrepend( |
| msg: unknown, |
| content: string | Array<ContentBlockParam>, |
| ): Promise<string | Array<ContentBlockParam>> { |
| const attachments = extractInboundAttachments(msg) |
| if (attachments.length === 0) return content |
| const prefix = await resolveInboundAttachments(attachments) |
| return prependPathRefs(content, prefix) |
| } |
|
|