diff --git a/adapters/common/attachment/__tests__/attachment-limits.test.ts b/adapters/common/attachment/__tests__/attachment-limits.test.ts new file mode 100644 index 00000000..aa497d0e --- /dev/null +++ b/adapters/common/attachment/__tests__/attachment-limits.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'bun:test' +import { + checkAttachmentLimit, + IMAGE_MAX_BYTES, + FILE_MAX_BYTES, + IMAGE_MIME_WHITELIST, +} from '../attachment-limits.js' + +describe('checkAttachmentLimit', () => { + it('accepts a 1 MB PNG image', () => { + const result = checkAttachmentLimit('image', 1024 * 1024, 'image/png') + expect(result.ok).toBe(true) + }) + + it('rejects an 11 MB image as too_large', () => { + const result = checkAttachmentLimit('image', 11 * 1024 * 1024, 'image/png') + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.reason).toBe('too_large') + expect(result.hint).toContain('10') + } + }) + + it('rejects an unsupported image mime', () => { + const result = checkAttachmentLimit('image', 500_000, 'image/svg+xml') + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toBe('unsupported_mime') + }) + + it('accepts a 10 MB PDF file', () => { + const result = checkAttachmentLimit('file', 10 * 1024 * 1024, 'application/pdf') + expect(result.ok).toBe(true) + }) + + it('rejects a 31 MB file as too_large', () => { + const result = checkAttachmentLimit('file', 31 * 1024 * 1024, 'application/pdf') + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toBe('too_large') + }) + + it('exposes the limits as exports', () => { + expect(IMAGE_MAX_BYTES).toBe(10 * 1024 * 1024) + expect(FILE_MAX_BYTES).toBe(30 * 1024 * 1024) + expect(IMAGE_MIME_WHITELIST).toContain('image/png') + }) +}) diff --git a/adapters/common/attachment/attachment-limits.ts b/adapters/common/attachment/attachment-limits.ts new file mode 100644 index 00000000..170ae231 --- /dev/null +++ b/adapters/common/attachment/attachment-limits.ts @@ -0,0 +1,59 @@ +/** + * Size and MIME restrictions for IM attachments. + * + * Limits chosen to sit safely under both Feishu (10 MB image / 30 MB file) + * and Telegram Bot API (10 MB image / 50 MB file), and under Claude API's + * own image size bounds. + */ + +export const IMAGE_MAX_BYTES = 10 * 1024 * 1024 // 10 MB +export const FILE_MAX_BYTES = 30 * 1024 * 1024 // 30 MB + +export const IMAGE_MIME_WHITELIST = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/heic', +] as const + +export type LimitCheckResult = + | { ok: true } + | { ok: false; reason: 'too_large' | 'unsupported_mime'; hint: string } + +function formatMb(bytes: number): string { + return (bytes / (1024 * 1024)).toFixed(1) +} + +export function checkAttachmentLimit( + kind: 'image' | 'file', + size: number, + mime?: string, +): LimitCheckResult { + if (kind === 'image') { + if (size > IMAGE_MAX_BYTES) { + return { + ok: false, + reason: 'too_large', + hint: `📎 图片过大(${formatMb(size)} MB),请控制在 10 MB 以内`, + } + } + if (mime && !IMAGE_MIME_WHITELIST.includes(mime as (typeof IMAGE_MIME_WHITELIST)[number])) { + return { + ok: false, + reason: 'unsupported_mime', + hint: `📎 暂不支持此图片格式(${mime})`, + } + } + return { ok: true } + } + // kind === 'file' + if (size > FILE_MAX_BYTES) { + return { + ok: false, + reason: 'too_large', + hint: `📎 文件过大(${formatMb(size)} MB),请控制在 30 MB 以内`, + } + } + return { ok: true } +} diff --git a/adapters/common/attachment/attachment-types.ts b/adapters/common/attachment/attachment-types.ts new file mode 100644 index 00000000..d5d152e5 --- /dev/null +++ b/adapters/common/attachment/attachment-types.ts @@ -0,0 +1,29 @@ +/** + * Shared attachment types for IM adapters. + */ + +import type { AttachmentRef } from '../ws-bridge.js' +export type { AttachmentRef } + +/** Platform tag — used for local staging subdir and telemetry. */ +export type ImPlatform = 'feishu' | 'telegram' + +/** Result of downloading an IM resource into the local stage dir. */ +export interface LocalAttachment { + kind: 'image' | 'file' + name: string // original filename, or synthesized if none + path: string // absolute path on disk (under ~/.claude/im-downloads) + size: number // bytes + mimeType: string // detected or provided + buffer: Buffer // raw bytes (kept so caller can choose base64 vs path) +} + +/** Pending outbound media found in Agent stream output. */ +export interface PendingUpload { + id: string // fingerprint, used for dedup + source: + | { kind: 'base64'; data: string; mime: string } + | { kind: 'path'; path: string; mime?: string } + | { kind: 'url'; url: string; mime?: string } + alt?: string +}