diff --git a/docs/agent/api.md b/docs/agent/api.md index c7e2fe3..5ff6d27 100644 --- a/docs/agent/api.md +++ b/docs/agent/api.md @@ -36,6 +36,7 @@ curl -H "Authorization: Bearer $TRACEMEMO_API_TOKEN" \ | GET | `/api/v1/chatroom` | 群聊列表 | `keyword` | | GET | `/api/v1/recent_chat` | 最近会话 | `limit`,默认 50 | | GET | `/api/v1/chatlog` | 指定会话的聊天记录 | 必填 `talker`;可选 `time` 或 `startTime`/`endTime` | +| GET | `/api/v1/media/{messageId}` | 获取图片消息的二进制资源 | 使用 `/chatlog` 返回的图片消息 `id` | | GET | `/api/v1/group_snapshot` | 群成员快照 | 必填 `md5` | | GET | `/api/v1/resolve` | 将昵称、wxid 或 md5 解析为会话 | 必填 `q` | | POST | `/api/v1/report` | 将结构化日报渲染为 HTML 与 PNG | `GroupReportExportRequest` JSON | @@ -84,6 +85,7 @@ curl -H "$AUTH" "$BASE/chatlog?talker=技术交流群&time=2026-08-07" - `200`:请求成功; - `401`:缺少、错误或已失效的 Bearer Token; - `400`:参数或 JSON 请求体无效; +- `422`:媒体 `messageId` 无效,或目标消息不是可读取的图片; - `403`:浏览器 Origin 不在允许的 loopback 列表; - `404`:端点、会话或群聊不存在; - `503`:数据库或 Agent Hub 尚未就绪; @@ -91,6 +93,22 @@ curl -H "$AUTH" "$BASE/chatlog?talker=技术交流群&time=2026-08-07" 成功响应会返回端点对应的 JSON 对象,例如 `chatlog` 包含 `contact`、`query`、`count` 和 `messages`,`contact` 返回 `count` 与 `contacts`。 +图片消息在 `messages` 中保留原有字段,并额外提供 `media`: + +```json +{ + "type": "图片", + "content": "", + "media": { + "type": "image", + "available": true, + "url": "/api/v1/media/msg_xxx" + } +} +``` + +当用户要求查看或理解图片时,使用 `media.url` 获取 `image/jpeg`、`image/png` 等真实二进制;不要根据 `[图片]` 猜测内容,也不要向 API 传入本地路径。 + ## 与 MCP 的关系 当前实现没有把 `6131` 暴露为 MCP Server。需要在 Agent 中使用时,请安装随应用提供的 Reader Skill,并让 Skill 通过普通 HTTP 请求调用本 API。 diff --git a/docs/skill/tracememo-reader/SKILL.md b/docs/skill/tracememo-reader/SKILL.md index cd4ce54..b538d34 100644 --- a/docs/skill/tracememo-reader/SKILL.md +++ b/docs/skill/tracememo-reader/SKILL.md @@ -1,6 +1,6 @@ --- name: tracememo-reader -description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的微信聊天数据。当用户要求查看微信消息、查找联系人或群聊、总结聊天、生成群聊总结时使用。此 Skill 由本机 TraceMemo 提供数据,不是 MCP Server。 +description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的微信聊天数据和图片媒体。当用户要求查看微信消息、查找联系人或群聊、总结聊天、查看或理解图片、生成群聊总结时使用。此 Skill 由本机 TraceMemo 提供数据,不是 MCP Server。 --- # TraceMemo Reader @@ -35,6 +35,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的 | GET | `/chatroom` | 群聊列表;可传 `keyword` | | GET | `/recent_chat` | 最近会话;可传 `limit` | | GET | `/chatlog` | 会话消息;必填 `talker`,可传 `time` 或时间戳范围 | +| GET | `/media/{messageId}` | 获取图片消息的真实图片二进制资源 | | GET | `/group_snapshot` | 群成员快照;必填 `md5` | | GET | `/resolve` | 昵称、wxid、md5 解析;必填 `q` | | POST | `/report` | 将已有日报结构渲染为 HTML/PNG | @@ -52,6 +53,29 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的 - 根据多条消息整理出的总结; - 没有来源支持的推断。 +## 媒体消息 + +当 `/chatlog` 返回图片消息时: + +1. 如果用户只是询问图片消息是否存在,不需要获取图片。 +2. 如果用户要求查看、识别、理解或分析图片,使用该消息 `media.url`(`/media/{messageId}`)获取真实图片。 +3. 不要根据 `[图片]`、消息文本或文件名猜测图片内容。 +4. 获取成功后,将图片交给当前 Agent 的视觉能力。 +5. 如果图片获取失败,明确说明无法读取图片。 +6. 不要声称看到了没有成功获取的图片。 +7. 不要向用户暴露 Token、本地文件路径或数据库路径。 + +### 图片分析 + +用户:“看看张三昨天发的那张截图。” + +1. 调用 `/health`;必要时调用 `/current_time`。 +2. 调用 `/resolve`,再调用 `/chatlog` 找到 `type` 为图片的消息。 +3. 调用 `/media/{messageId}`,将返回的图片交给 Vision。 +4. 必要时读取图片消息前后若干条消息,结合聊天上下文回答。 + +不要只根据 `[图片]` 猜测内容,不要把一次 OCR 当作完整图片理解,也不要直接读取任意本地图片路径。 + ## 隐私和安全 只读取用户请求所需的会话和时间范围。不要把完整聊天数据库、密钥或 Token 暴露给用户。Reader API 本身不自动把聊天转发到外部服务器,但当前 Agent 可能会把工具结果交给其配置的模型;如有疑问,提醒用户检查 Agent 的数据策略。 @@ -61,5 +85,6 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的 - `401`:Token 缺失、错误或被轮换;请用户回 API Center 复制最新 Token。 - `403`:浏览器 Origin 不在 loopback 允许列表;CLI/Agent 通常不带 Origin。 - `404`:先用 `/resolve` 确认会话标识。 +- `422`:`messageId` 无效,或消息不是可读取的图片。 - `503`:用户还没有完成数据库连接或对应服务未就绪。 - 空结果:缩小/扩大时间范围,确认账号和会话,再检查媒体或语音是否可读。 diff --git a/src/main/http-media-service.ts b/src/main/http-media-service.ts new file mode 100644 index 0000000..e46d070 --- /dev/null +++ b/src/main/http-media-service.ts @@ -0,0 +1,84 @@ +import { loadSettings } from './services/settings-store' +import { getChatDb, getImageMessageReference, isReady } from './services/chat-service' +import { ImageDecryptService } from './image-decrypt-service' +import { ImageKeyConfigService } from './services/image-key-config-service' + +export type HttpImageResult = { + buffer: Buffer + mimeType: string +} + +export class HttpMediaError extends Error { + constructor( + public readonly code: 'NOT_READY' | 'NOT_FOUND' | 'NOT_IMAGE' | 'READ_FAILED', + message: string + ) { + super(message) + this.name = 'HttpMediaError' + } +} + +let imageService: ImageDecryptService | null = null +let imageServiceKey = '' +let imageServiceClient: ReturnType< + NonNullable>['getWcdb4Client'] +> | null = null + +function getImageService(): ImageDecryptService { + const config = new ImageKeyConfigService() + const imageConfig = config.getConfig() + const aesKey = imageConfig.aesKey || '' + const xorKey = imageConfig.xorKey || '0x40' + const key = `${xorKey}:${aesKey}` + const client = getChatDb()?.getWcdb4Client() || null + if (!imageService || imageServiceKey !== key || imageServiceClient !== client) { + imageService = new ImageDecryptService(xorKey, aesKey, client, loadSettings().dbRoot) + imageServiceKey = key + imageServiceClient = client + } + return imageService +} + +function decodeDataUrl(value: string): HttpImageResult | null { + const match = /^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i.exec(value) + if (!match) return null + return { mimeType: match[1].toLowerCase(), buffer: Buffer.from(match[2], 'base64') } +} + +export async function readImageMedia(messageId: string): Promise { + if (!isReady()) throw new HttpMediaError('NOT_READY', 'TraceMemo 数据库未初始化') + const reference = getImageMessageReference(messageId) + if (!reference) throw new HttpMediaError('NOT_FOUND', '未找到图片消息') + if (!reference.imageMd5 && !reference.imageDatName) { + throw new HttpMediaError('NOT_IMAGE', '消息不是可读取的图片消息') + } + + const service = getImageService() + const filePath = await service.findImageFileAsync(reference.imageMd5, reference.imageDatName, { + allowThumbnail: false, + sessionId: reference.sessionId, + createTime: reference.createTime + }) + const fallbackPath = + filePath || + (await service.findImageFileAsync(reference.imageMd5, reference.imageDatName, { + allowThumbnail: true, + sessionId: reference.sessionId, + createTime: reference.createTime + })) + if (!fallbackPath) throw new HttpMediaError('NOT_FOUND', '图片文件不存在') + + const decoded = await service.decryptImageToBase64WithFallbackAsync(fallbackPath, true) + if (!decoded) throw new HttpMediaError('READ_FAILED', '图片读取或解密失败') + const image = decodeDataUrl(decoded.data) + if (!image || image.buffer.length === 0) { + throw new HttpMediaError('READ_FAILED', '图片数据无效') + } + return image +} + +export function resetHttpMediaService(): void { + imageService = null + imageServiceKey = '' + imageServiceClient = null +} diff --git a/src/main/http-server.ts b/src/main/http-server.ts index 72c67ec..2d44752 100644 --- a/src/main/http-server.ts +++ b/src/main/http-server.ts @@ -14,6 +14,7 @@ import { generateAgentGroupReport } from './services/agent-group-report-service' import { agentHubService } from './services/agent-hub-service' import { safeError, safeLog, safeWarn } from './safe-log' import { apiTokenStore } from './api-token-store' +import { HttpMediaError, readImageMedia, type HttpImageResult } from './http-media-service' export const DEFAULT_HTTP_HOST = '127.0.0.1' export const DEFAULT_HTTP_PORT = 6131 @@ -33,6 +34,7 @@ interface RouteContext { export interface HttpServerOptions { tokenProvider?: () => string | null + mediaProvider?: (messageId: string) => Promise } type RouteHandler = (ctx: RouteContext) => void | Promise @@ -90,6 +92,26 @@ function sendError(res: ServerResponse, status: number, message: string, extra?: sendJson(res, status, { error: message, status, ...(extra ? { details: extra } : {}) }) } +function sendBinary(res: ServerResponse, status: number, result: HttpImageResult): void { + res.writeHead(status, { + 'Content-Type': result.mimeType, + 'Content-Length': result.buffer.length, + 'Cache-Control': 'private, no-store', + 'X-Content-Type-Options': 'nosniff' + }) + res.end(result.buffer) +} + +function sanitizeChatlogMessage(message: Record): Record { + const contentData = message.contentData + if (!contentData || typeof contentData !== 'object' || !('aeskey' in contentData)) { + return message + } + const safeContentData = { ...(contentData as Record) } + delete safeContentData.aeskey + return { ...message, contentData: safeContentData } +} + function readBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = [] @@ -252,7 +274,9 @@ const routes: Record = { contact: resolved, query: { talker, time: timeParam, startTime, endTime }, count: messages.length, - messages + messages: messages.map((message) => + sanitizeChatlogMessage(message as unknown as Record) + ) }) }, @@ -343,12 +367,63 @@ const routes: Record = { } } +const MEDIA_ROUTE_PREFIX = '/api/v1/media/' + +function createMediaRoute( + mediaProvider: (messageId: string) => Promise +): RouteHandler { + return async ({ req, res, url }) => { + if (req.method !== 'GET' && req.method !== 'HEAD') { + return sendError(res, 405, '需要 GET 请求') + } + const encodedMessageId = url.pathname.slice(MEDIA_ROUTE_PREFIX.length) + let messageId: string + try { + messageId = decodeURIComponent(encodedMessageId) + } catch { + return sendError(res, 422, 'messageId 格式无效') + } + if (!messageId || messageId.includes('/') || messageId.includes('\\')) { + return sendError(res, 422, 'messageId 格式无效') + } + try { + const result = await mediaProvider(messageId) + if (req.method === 'HEAD') { + res.writeHead(200, { + 'Content-Type': result.mimeType, + 'Content-Length': result.buffer.length, + 'Cache-Control': 'private, no-store', + 'X-Content-Type-Options': 'nosniff' + }) + res.end() + return + } + sendBinary(res, 200, result) + } catch (error) { + if (error instanceof HttpMediaError) { + const status = + error.code === 'NOT_READY' + ? 503 + : error.code === 'NOT_IMAGE' + ? 422 + : error.code === 'NOT_FOUND' + ? 404 + : 500 + return sendError(res, status, error.message) + } + safeError('[HttpServer] media request failed:', error) + return sendError(res, 500, '图片读取失败') + } + } +} + export function startHttpServer( host: string = DEFAULT_HTTP_HOST, port: number = DEFAULT_HTTP_PORT, options: HttpServerOptions = {} ): Promise { const tokenProvider = options.tokenProvider || (() => apiTokenStore.getTokenForAuthentication()) + const mediaProvider = options.mediaProvider || readImageMedia return new Promise((resolve, reject) => { const server: Server = http.createServer(async (req, res) => { try { @@ -360,7 +435,11 @@ export function startHttpServer( res.writeHead(204) return res.end() } - const handler = routes[url.pathname] + const handler = + routes[url.pathname] || + (url.pathname.startsWith(MEDIA_ROUTE_PREFIX) + ? createMediaRoute(mediaProvider) + : undefined) if (!handler) { return sendError(res, 404, `端点不存在: ${url.pathname}`) } diff --git a/src/main/services/chat-service.ts b/src/main/services/chat-service.ts index 114f654..e495f22 100644 --- a/src/main/services/chat-service.ts +++ b/src/main/services/chat-service.ts @@ -59,6 +59,11 @@ export interface FormattedMessage { name?: string senderId?: string contentData?: ReturnType + media?: { + type: 'image' + available: boolean + url: string + } voiceDataUrl?: string voiceDuration?: number voiceTranscript?: string @@ -122,6 +127,16 @@ function normalizeMsgType(value: string | number | undefined): number { let dbRef: WechatDb | null = null let shutdownRequested = false +export interface ImageMessageReference { + messageId: string + sessionId: string + imageMd5?: string + imageDatName?: string + createTime?: number +} + +const imageMessageReferences = new Map() + export function setChatDb(db: WechatDb | null): boolean { if (shutdownRequested) { db?.close() @@ -129,9 +144,17 @@ export function setChatDb(db: WechatDb | null): boolean { } dbRef?.close() dbRef = db + imageMessageReferences.clear() return true } +export function getImageMessageReference(messageId: string): ImageMessageReference | null { + if (!dbRef) return null + const normalizedId = String(messageId || '').trim() + if (!normalizedId) return null + return imageMessageReferences.get(normalizedId) || null +} + export async function closeChatDbForQuit(): Promise { shutdownRequested = true const current = dbRef @@ -405,10 +428,44 @@ function listSourceMessages( const recoveredFromRecallJournal = Boolean(msg['_wxe_recovered'] || msg.raw?.['_wxe_recovered']) - return { - id: recoveredFromRecallJournal + const messageId = String( + recoveredFromRecallJournal ? `recovered:${msg.mesLocalID || msg.serverId || createTime}` - : msg.mesLocalID || Math.random().toString(), + : msg.mesLocalID || Math.random().toString() + ) + const imageContent = contentData?.type === 'image' ? contentData : undefined + const media = imageContent + ? { + type: 'image' as const, + available: Boolean(imageContent.md5 || imageContent.datName), + url: `/api/v1/media/${encodeURIComponent(messageId)}` + } + : undefined + if (imageContent && media) { + const reference: ImageMessageReference = { + messageId, + sessionId: username || '', + imageMd5: imageContent.md5, + imageDatName: imageContent.datName, + createTime + } + const previous = imageMessageReferences.get(messageId) + if ( + previous && + (previous.sessionId !== reference.sessionId || + previous.imageMd5 !== reference.imageMd5 || + previous.imageDatName !== reference.imageDatName) + ) { + // Local message ids can repeat between conversations. Never resolve an + // ambiguous id to the wrong account or image. + imageMessageReferences.set(messageId, null) + } else if (previous !== null) { + imageMessageReferences.set(messageId, reference) + } + } + + return { + id: messageId, from: contentData?.type === 'system' ? 'system' : isMine ? 'assistant' : 'user', isSender: isMine, type: displayType, @@ -422,7 +479,8 @@ function listSourceMessages( serverId: typeof msg.serverId === 'string' ? msg.serverId : undefined, createTime, recoveredFromRecallJournal, - contentData + contentData, + media } }) diff --git a/src/shared/types.ts b/src/shared/types.ts index e2e908d..be98d74 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -24,6 +24,11 @@ export interface Message { name?: string senderId?: string contentData?: ParsedContent + media?: { + type: 'image' + available: boolean + url: string + } voiceDataUrl?: string voiceDuration?: number voiceTranscript?: string diff --git a/tests/integration/local-api-auth.test.ts b/tests/integration/local-api-auth.test.ts index bc6dc2f..75bea93 100644 --- a/tests/integration/local-api-auth.test.ts +++ b/tests/integration/local-api-auth.test.ts @@ -12,6 +12,19 @@ const fixture = vi.hoisted(() => ({ type: 'user' as const } ], + chatlogMessages: [ + { + id: 'message:1', + type: '图片', + content: '', + contentData: { type: 'image', md5: 'fixture-md5', aeskey: 'do-not-return' }, + media: { + type: 'image', + available: true, + url: '/api/v1/media/message%3A1' + } + } + ], testSend: vi.fn(async () => ({ success: true, status: 'sent' })) })) @@ -27,7 +40,7 @@ vi.mock('electron', () => ({ vi.mock('../../src/main/services/chat-service', () => ({ isReady: () => true, listContacts: () => fixture.contacts, - listMessages: () => [], + listMessages: () => fixture.chatlogMessages, getGroupSnapshot: () => ({ members: [] }), listRecentChat: () => [], resolveMd5: () => fixture.contacts[0] @@ -55,6 +68,7 @@ vi.mock('../../src/main/services/agent-hub-service', () => ({ import { apiTokenStore } from '../../src/main/api-token-store' import { apiServer, startHttpServer, type HttpServerHandle } from '../../src/main/http-server' +import { HttpMediaError } from '../../src/main/http-media-service' import { buildLocalApiCurlCommand, testLocalApiRequest @@ -68,9 +82,10 @@ function baseUrl(handle: HttpServerHandle): string { } async function startFixtureServer( - tokenProvider = (): string => VALID_TOKEN + tokenProvider = (): string => VALID_TOKEN, + mediaProvider?: (messageId: string) => Promise<{ buffer: Buffer; mimeType: string }> ): Promise { - const handle = await startHttpServer('127.0.0.1', 0, { tokenProvider }) + const handle = await startHttpServer('127.0.0.1', 0, { tokenProvider, mediaProvider }) handles.push(handle) return handle } @@ -118,6 +133,7 @@ describe('Local API authentication', () => { ['GET', '/api/v1/chatroom'], ['GET', '/api/v1/recent_chat'], ['GET', '/api/v1/chatlog'], + ['GET', '/api/v1/media/message-1'], ['GET', '/api/v1/group_snapshot'], ['GET', '/api/v1/resolve'], ['POST', '/api/v1/report'], @@ -133,6 +149,58 @@ describe('Local API authentication', () => { expect(response.status).toBe(401) }) + it('returns image bytes from the authenticated media route without exposing a path', async () => { + const provider = vi.fn(async (messageId: string) => { + expect(messageId).toBe('message:1') + return { buffer: Buffer.from([0xff, 0xd8, 0xff, 0xd9]), mimeType: 'image/jpeg' } + }) + const handle = await startFixtureServer(() => VALID_TOKEN, provider) + const response = await fetch( + `${baseUrl(handle)}/api/v1/media/${encodeURIComponent('message:1')}`, + { + headers: { Authorization: `Bearer ${VALID_TOKEN}` } + } + ) + + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toContain('image/jpeg') + expect(Buffer.from(await response.arrayBuffer())).toEqual(Buffer.from([0xff, 0xd8, 0xff, 0xd9])) + expect(provider).toHaveBeenCalledOnce() + expect(JSON.stringify(response.headers)).not.toMatch(/path|token|database/i) + }) + + it('adds media metadata to chatlog while redacting image keys', async () => { + const handle = await startFixtureServer() + const response = await fetch(`${baseUrl(handle)}/api/v1/chatlog?talker=测试联系人`, { + headers: { Authorization: `Bearer ${VALID_TOKEN}` } + }) + expect(response.status).toBe(200) + const body = await response.json() + expect(body.messages[0]).toMatchObject({ + id: 'message:1', + media: { + type: 'image', + available: true, + url: '/api/v1/media/message%3A1' + } + }) + expect(body.messages[0].contentData).not.toHaveProperty('aeskey') + }) + + it('maps media lookup failures to stable API statuses', async () => { + const handle = await startFixtureServer( + () => VALID_TOKEN, + async () => { + throw new HttpMediaError('NOT_IMAGE', '消息不是可读取的图片消息') + } + ) + const response = await fetch(`${baseUrl(handle)}/api/v1/media/message-1`, { + headers: { Authorization: `Bearer ${VALID_TOKEN}` } + }) + expect(response.status).toBe(422) + await expect(response.json()).resolves.toMatchObject({ status: 422 }) + }) + it.each(['Basic xxx', 'Bearer', 'bearer xxx', 'Bearer xxx', 'xxx'])( 'rejects the invalid Authorization format %s', async (authorization) => { diff --git a/tests/unit/http-media-service.test.ts b/tests/unit/http-media-service.test.ts new file mode 100644 index 0000000..fd89191 --- /dev/null +++ b/tests/unit/http-media-service.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest' + +const fixture = vi.hoisted(() => ({ + ready: true, + reference: { + messageId: 'message-1', + sessionId: 'wxid_fixture', + imageMd5: '0123456789abcdef0123456789abcdef', + createTime: 1_756_000_000 + }, + findImageFileAsync: vi.fn(async () => '/private/fixture/image.dat'), + decryptImageToBase64WithFallbackAsync: vi.fn(async () => ({ + data: 'data:image/png;base64,iVBORw0KGgo=', + filePath: '/private/fixture/image.dat' + })) +})) + +vi.mock('../../src/main/services/chat-service', () => ({ + isReady: () => fixture.ready, + getChatDb: () => ({ getWcdb4Client: () => ({}) }), + getImageMessageReference: (messageId: string) => + messageId === fixture.reference.messageId ? fixture.reference : null +})) + +vi.mock('../../src/main/services/settings-store', () => ({ + loadSettings: () => ({ dbRoot: '/private/fixture' }) +})) + +vi.mock('../../src/main/services/image-key-config-service', () => ({ + ImageKeyConfigService: class { + getConfig(): { xorKey: string; aesKey: string } { + return { xorKey: '0x40', aesKey: 'fixture-aes-key' } + } + } +})) + +vi.mock('../../src/main/image-decrypt-service', () => ({ + ImageDecryptService: class { + findImageFileAsync = fixture.findImageFileAsync + decryptImageToBase64WithFallbackAsync = fixture.decryptImageToBase64WithFallbackAsync + } +})) + +import { HttpMediaError, readImageMedia } from '../../src/main/http-media-service' + +describe('HTTP media service', () => { + it('resolves a registered image message through the existing decrypt service', async () => { + await expect(readImageMedia('message-1')).resolves.toEqual({ + mimeType: 'image/png', + buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + }) + expect(fixture.findImageFileAsync).toHaveBeenCalledWith( + fixture.reference.imageMd5, + undefined, + expect.objectContaining({ + allowThumbnail: false, + sessionId: fixture.reference.sessionId, + createTime: fixture.reference.createTime + }) + ) + }) + + it('does not resolve an unregistered message id', async () => { + await expect(readImageMedia('unknown')).rejects.toMatchObject({ + code: 'NOT_FOUND' + }) + }) + + it('reports database readiness separately', async () => { + fixture.ready = false + await expect(readImageMedia('message-1')).rejects.toMatchObject({ + code: 'NOT_READY' + }) + fixture.ready = true + }) +})