diff --git a/docs/agent/api.md b/docs/agent/api.md index a4c8906..7fb32d3 100644 --- a/docs/agent/api.md +++ b/docs/agent/api.md @@ -36,7 +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/media/{mediaId}` | 获取图片消息的二进制资源 | 原样使用 `/chatlog` 返回的 `media.url`,不要用消息 `id` 拼接 | | GET | `/api/v1/group_snapshot` | 群成员快照 | 必填 `md5` | | GET | `/api/v1/resolve` | 将昵称、wxid 或 md5 解析为会话 | 必填 `q` | | POST | `/api/v1/report` | 将结构化日报渲染为 HTML 与 PNG | `GroupReportExportRequest` JSON | @@ -85,9 +85,9 @@ curl -H "$AUTH" "$BASE/chatlog?talker=技术交流群&time=2026-08-07" - `200`:请求成功; - `401`:缺少、错误或已失效的 Bearer Token; - `400`:参数或 JSON 请求体无效; -- `422`:媒体 `messageId` 无效,或目标消息不是可读取的图片; +- `422`:媒体标识格式错误,或目标消息不是可读取的图片(`NOT_IMAGE`); - `403`:浏览器 Origin 不在允许的 loopback 列表; -- `404`:端点、会话或群聊不存在; +- `404`:端点、会话或群聊不存在;媒体标识未登记、已过期、有歧义,或图片文件不存在(`NOT_FOUND`)。媒体请求遇到此状态时,先重新读取 `/chatlog` 并使用新的 `media.url`;若仍失败,再检查本地图片文件是否存在; - `503`:数据库或 Agent Hub 尚未就绪; - `500`:服务端处理或报告渲染失败。 @@ -102,13 +102,15 @@ curl -H "$AUTH" "$BASE/chatlog?talker=技术交流群&time=2026-08-07" "media": { "type": "image", "available": true, - "url": "/api/v1/media/msg_xxx" + "url": "/api/v1/media/image%3A0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" } } ``` 当用户要求查看或理解图片时,使用 `media.url` 获取 `image/jpeg`、`image/png` 等真实二进制;不要根据 `[图片]` 猜测内容,也不要向 API 传入本地路径。 +`media.url` 包含当前数据库连接内的独立媒体标识,不等同于消息 `id`。不同会话的消息 `id` 可能重复,调用方应原样使用返回的地址,不自行拼接或解析。重启、重连或切换账号后须重新读取 `/chatlog` 获取新地址;旧的纯消息 ID 地址仅在无歧义时兼容。`available` 只表示消息带有图片定位信息,不保证本地图片文件仍存在或可以解密。 + ## 与 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 6b5fffd..0127e67 100644 --- a/docs/skill/tracememo-reader/SKILL.md +++ b/docs/skill/tracememo-reader/SKILL.md @@ -35,7 +35,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的 | GET | `/chatroom` | 群聊列表;可传 `keyword` | | GET | `/recent_chat` | 最近会话;可传 `limit` | | GET | `/chatlog` | 会话消息;必填 `talker`,可传 `time` 或时间戳范围 | -| GET | `/media/{messageId}` | 获取图片消息的真实图片二进制资源 | +| GET | `/media/{mediaId}` | 按消息返回的 `media.url` 获取图片二进制资源 | | GET | `/group_snapshot` | 群成员快照;必填 `md5` | | GET | `/resolve` | 昵称、wxid、md5 解析;必填 `q` | | GET | `/wechat-personal/send-capability` | 个人微信图片发送能力状态 | @@ -107,7 +107,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的 当 `/chatlog` 返回图片消息时: 1. 如果用户只是询问图片消息是否存在,不需要获取图片。 -2. 如果用户要求查看、识别、理解或分析图片,使用该消息 `media.url`(`/media/{messageId}`)获取真实图片。 +2. 如果用户要求查看、识别、理解或分析图片,原样使用该消息 `media.url` 获取真实图片;不要用消息 `id` 自行拼接。媒体标识按数据库连接隔离,重启、重连或切换账号后须重新读取 `/chatlog` 获取地址。 3. 不要根据 `[图片]`、消息文本或文件名猜测图片内容。 4. 获取成功后,将图片交给当前 Agent 的视觉能力。 5. 如果图片获取失败,明确说明无法读取图片。 @@ -120,7 +120,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的 1. 调用 `/health`;必要时调用 `/current_time`。 2. 调用 `/resolve`,再调用 `/chatlog` 找到 `type` 为图片的消息。 -3. 调用 `/media/{messageId}`,将返回的图片交给 Vision。 +3. 请求该消息的 `media.url`,将返回的图片交给 Vision。 4. 必要时读取图片消息前后若干条消息,结合聊天上下文回答。 不要只根据 `[图片]` 猜测内容,不要把一次 OCR 当作完整图片理解,也不要直接读取任意本地图片路径。 @@ -133,7 +133,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的 - `401`:Token 缺失、错误或被轮换;请用户回 API Center 复制最新 Token。 - `403`:浏览器 Origin 不在 loopback 允许列表;CLI/Agent 通常不带 Origin。 -- `404`:先用 `/resolve` 确认会话标识。 -- `422`:`messageId` 无效,或消息不是可读取的图片。 +- `404`:会话查询失败时先用 `/resolve` 确认会话标识;媒体请求表示标识未登记、已过期、有歧义,或图片文件不存在(`NOT_FOUND`)。先重新读取 `/chatlog` 并使用新的 `media.url`;若仍失败,再检查本地图片文件是否存在。 +- `422`:媒体标识格式错误,或消息不是可读取的图片(`NOT_IMAGE`)。 - `503`:用户还没有完成数据库连接或对应服务未就绪。 - 空结果:缩小/扩大时间范围,确认账号和会话,再检查媒体或语音是否可读。 diff --git a/public/二维码.jpg b/public/二维码.jpg index 9c26aa4..78b2f0e 100644 Binary files a/public/二维码.jpg and b/public/二维码.jpg differ diff --git a/src/main/services/chat-service.ts b/src/main/services/chat-service.ts index 32c5969..eb57569 100644 --- a/src/main/services/chat-service.ts +++ b/src/main/services/chat-service.ts @@ -1,3 +1,4 @@ +import { createHash, randomUUID } from 'node:crypto' import { WechatDb, WechatMessage } from '../wechat-db' import { parseImageBufferDataUrlFromRow, @@ -156,7 +157,9 @@ export interface ImageMessageReference { } const imageMessageReferences = new Map() +let imageReferenceScope = randomUUID() +/** Replace the active database and invalidate connection-scoped lookup caches. */ export function setChatDb(db: WechatDb | null): boolean { if (shutdownRequested) { db?.close() @@ -166,6 +169,7 @@ export function setChatDb(db: WechatDb | null): boolean { dbRef = db contactSearchIndexCache = null imageMessageReferences.clear() + imageReferenceScope = randomUUID() return true } @@ -367,6 +371,7 @@ export async function getContactAvatars( return client.getAvatarUrlsAsync(normalized) } +/** Format source rows and register image handles without merging recall archives. */ function listSourceMessages( userMd5: string, startTime?: number, @@ -556,11 +561,28 @@ function listSourceMessages( : msg.mesLocalID || Math.random().toString() ) const imageContent = contentData?.type === 'image' ? contentData : undefined + // Local ids repeat across conversations. Scope media handles to this database + // connection and image without changing the message id used by other clients. + const mediaId = imageContent + ? `image:${createHash('sha256') + .update( + JSON.stringify([ + imageReferenceScope, + userMd5, + messageId, + String(msg.serverId || ''), + createTime, + imageContent.md5 || '', + imageContent.datName || '' + ]) + ) + .digest('hex')}` + : '' const media = imageContent ? { type: 'image' as const, available: Boolean(imageContent.md5 || imageContent.datName), - url: `/api/v1/media/${encodeURIComponent(messageId)}` + url: `/api/v1/media/${encodeURIComponent(mediaId)}` } : undefined if (imageContent && media) { @@ -571,6 +593,8 @@ function listSourceMessages( imageDatName: imageContent.datName, createTime } + imageMessageReferences.set(mediaId, reference) + // Keep old bare-id URLs working only while they are unambiguous. const previous = imageMessageReferences.get(messageId) if ( previous && @@ -598,7 +622,10 @@ function listSourceMessages( senderId, sessionId: username, localId, - serverId: typeof msg.serverId === 'string' ? msg.serverId : undefined, + serverId: + typeof msg.serverId === 'string' || typeof msg.serverId === 'bigint' + ? String(msg.serverId) + : undefined, createTime, recoveredFromRecallJournal, contentData, diff --git a/tests/integration/local-api-auth.test.ts b/tests/integration/local-api-auth.test.ts index deb3fb2..107b4ae 100644 --- a/tests/integration/local-api-auth.test.ts +++ b/tests/integration/local-api-auth.test.ts @@ -198,20 +198,64 @@ describe('Local API authentication', () => { expect(body.messages[0].contentData).not.toHaveProperty('aeskey') }) - it('maps media lookup failures to stable API statuses', async () => { + it('serves opaque media handles while preserving the chatlog message id', async () => { + const mediaId = `image:${'a'.repeat(64)}` + const previousUrl = fixture.chatlogMessages[0].media.url + fixture.chatlogMessages[0].media.url = `/api/v1/media/${encodeURIComponent(mediaId)}` + try { + const provider = vi.fn(async (id: string) => { + expect(id).toBe(mediaId) + return { buffer: Buffer.from([0xff, 0xd8, 0xff, 0xd9]), mimeType: 'image/jpeg' } + }) + const handle = await startFixtureServer(() => VALID_TOKEN, provider) + const headers = { Authorization: `Bearer ${VALID_TOKEN}` } + const chatlog = await fetch(`${baseUrl(handle)}/api/v1/chatlog?talker=fixture`, { headers }) + const body = await chatlog.json() + expect(body.messages[0].id).toBe('message:1') + expect(body.messages[0].media.url).not.toContain('message') + const response = await fetch(`${baseUrl(handle)}${body.messages[0].media.url}`, { headers }) + expect(response.status).toBe(200) + expect(Buffer.from(await response.arrayBuffer())).toEqual( + Buffer.from([0xff, 0xd8, 0xff, 0xd9]) + ) + expect(provider).toHaveBeenCalledOnce() + } finally { + fixture.chatlogMessages[0].media.url = previousUrl + } + }) + + it.each([ + ['NOT_FOUND', '未找到图片消息', 404], + ['NOT_FOUND', '图片文件不存在', 404], + ['NOT_IMAGE', '消息不是可读取的图片消息', 422] + ] as const)('maps %s (%s) to HTTP %s', async (code, message, status) => { const handle = await startFixtureServer( () => VALID_TOKEN, async () => { - throw new HttpMediaError('NOT_IMAGE', '消息不是可读取的图片消息') + throw new HttpMediaError(code, message) } ) - const response = await fetch(`${baseUrl(handle)}/api/v1/media/message-1`, { + const response = await fetch(`${baseUrl(handle)}/api/v1/media/image%3Aunresolved`, { headers: { Authorization: `Bearer ${VALID_TOKEN}` } }) - expect(response.status).toBe(422) - await expect(response.json()).resolves.toMatchObject({ status: 422 }) + expect(response.status).toBe(status) + await expect(response.json()).resolves.toMatchObject({ status, error: message }) }) + it.each(['%ZZ', 'image%2Finvalid', 'image%5Cinvalid'])( + 'rejects malformed media identifiers (%s) before lookup', + async (identifier) => { + const provider = vi.fn(async () => ({ buffer: Buffer.from('image'), mimeType: 'image/png' })) + const handle = await startFixtureServer(() => VALID_TOKEN, provider) + const response = await fetch(`${baseUrl(handle)}/api/v1/media/${identifier}`, { + headers: { Authorization: `Bearer ${VALID_TOKEN}` } + }) + expect(response.status).toBe(422) + await expect(response.json()).resolves.toMatchObject({ status: 422 }) + expect(provider).not.toHaveBeenCalled() + } + ) + it.each(['Basic xxx', 'Bearer', 'bearer xxx', 'Bearer xxx', 'xxx'])( 'rejects the invalid Authorization format %s', async (authorization) => { diff --git a/tests/unit/chat-service-media.test.ts b/tests/unit/chat-service-media.test.ts new file mode 100644 index 0000000..3b5b2e9 --- /dev/null +++ b/tests/unit/chat-service-media.test.ts @@ -0,0 +1,152 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { WechatDb, WechatMessage } from '../../src/main/wechat-db' + +vi.mock('../../src/main/services/recall-archive-service', () => ({ + recordRecallArchiveMessages: vi.fn(), + mergeRecallArchiveMessages: (_md5: string, messages: unknown[]) => messages +})) + +import { + getImageMessageReference, + listMessages, + listMessagesAsync, + setChatDb, + type FormattedMessage +} from '../../src/main/services/chat-service' + +const IMAGE_A = 'a'.repeat(32) +const IMAGE_B = 'b'.repeat(32) + +/** Create a synthetic image row with an intentionally reusable local message ID. */ +function image(md5: string, overrides: Partial = {}): WechatMessage { + return { + mesLocalID: '56', + mesDes: 1, + messageType: '3', + msgCreateTime: '1756000000', + msgContent: ``, + serverId: '9007199254740993123', + ...overrides + } +} + +/** Attach an in-memory fixture database without reading a real account. */ +function connect(messages: Record): void { + const client = { getUsernameByMd5: (md5: string) => `wxid_${md5}` } + setChatDb({ + close: vi.fn(), + getWcdb4Client: () => client, + getUserMessages: (md5: string) => messages[md5] || [], + getUserMessagesAsync: async (md5: string) => messages[md5] || [] + } as unknown as WechatDb) +} + +/** Extract the opaque handle after checking the formatted image metadata. */ +function mediaId(message: FormattedMessage): string { + expect(message.media).toMatchObject({ type: 'image', available: true }) + return decodeURIComponent(message.media!.url.slice('/api/v1/media/'.length)) +} + +describe('chat service image media handles', () => { + afterEach(() => setChatDb(null)) + + it('keeps colliding local ids readable across conversations and repeated reads', async () => { + connect({ first: [image(IMAGE_A)], second: [image(IMAGE_B)] }) + const first = listMessages('first')[0] + const firstId = mediaId(first) + expect(first.id).toBe('56') + expect(firstId).toMatch(/^image:[a-f0-9]{64}$/) + expect(firstId).not.toContain('wxid_first') + expect(getImageMessageReference('56')?.imageMd5).toBe(IMAGE_A) + + const second = (await listMessagesAsync('second'))[0] + const secondId = mediaId(second) + expect(second.id).toBe(first.id) + expect(secondId).not.toBe(firstId) + expect(getImageMessageReference(firstId)).toMatchObject({ + sessionId: 'wxid_first', + imageMd5: IMAGE_A + }) + expect(getImageMessageReference(secondId)).toMatchObject({ + sessionId: 'wxid_second', + imageMd5: IMAGE_B + }) + expect(getImageMessageReference('56')).toBeNull() + + expect(mediaId(listMessages('first')[0])).toBe(firstId) + expect(mediaId((await listMessagesAsync('second'))[0])).toBe(secondId) + expect(getImageMessageReference(firstId)?.imageMd5).toBe(IMAGE_A) + expect(getImageMessageReference(secondId)?.imageMd5).toBe(IMAGE_B) + expect(getImageMessageReference('56')).toBeNull() + }) + + it('distinguishes images with the same local id within one conversation', () => { + connect({ first: [image(IMAGE_A), image(IMAGE_B, { serverId: '9007199254740993124' })] }) + const [first, second] = listMessages('first').map(mediaId) + expect(first).not.toBe(second) + expect(getImageMessageReference(first)?.imageMd5).toBe(IMAGE_A) + expect(getImageMessageReference(second)?.imageMd5).toBe(IMAGE_B) + expect(getImageMessageReference('56')).toBeNull() + }) + + it('does not reuse media handles after reconnecting or switching accounts', () => { + const messages = { first: [image(IMAGE_A)] } + connect(messages) + const previousId = mediaId(listMessages('first')[0]) + setChatDb(null) + expect(getImageMessageReference(previousId)).toBeNull() + connect(messages) + const currentId = mediaId(listMessages('first')[0]) + expect(currentId).not.toBe(previousId) + expect(getImageMessageReference(previousId)).toBeNull() + expect(getImageMessageReference(currentId)?.imageMd5).toBe(IMAGE_A) + }) + + it('normalizes native server ids without losing integer precision', () => { + const message = image(IMAGE_A, { serverId: 9007199254740993123n }) + connect({ first: [message] }) + const first = listMessages('first')[0] + const firstId = mediaId(first) + expect(first.serverId).toBe('9007199254740993123') + expect(JSON.parse(JSON.stringify(first)).serverId).toBe('9007199254740993123') + message.serverId = '9007199254740993123' + const reread = listMessages('first')[0] + expect(reread.serverId).toBe('9007199254740993123') + expect(mediaId(reread)).toBe(firstId) + }) + + it.each([9007199254740992, null, undefined, false])( + 'omits unsupported server ID values (%s)', + (serverId) => { + connect({ first: [image(IMAGE_A, { serverId })] }) + const message = listMessages('first')[0] + expect(message.serverId).toBeUndefined() + expect(JSON.parse(JSON.stringify(message))).not.toHaveProperty('serverId') + } + ) + + it('scopes recovered images and supports images identified only by dat name', () => { + connect({ + first: [image(IMAGE_A, { _wxe_recovered: true })], + second: [image(IMAGE_B, { _wxe_recovered: true })], + third: [image('', { msgContent: JSON.stringify({ imageDatName: IMAGE_A }) })] + }) + const first = listMessages('first')[0] + const second = listMessages('second')[0] + expect(first.id).toBe('recovered:56') + expect(mediaId(second)).not.toBe(mediaId(first)) + expect(getImageMessageReference(mediaId(first))?.imageMd5).toBe(IMAGE_A) + expect(getImageMessageReference(mediaId(second))?.imageMd5).toBe(IMAGE_B) + expect(getImageMessageReference('recovered:56')).toBeNull() + const third = listMessages('third')[0] + expect(getImageMessageReference(mediaId(third))?.imageDatName).toBe(IMAGE_A) + }) + + it('does not add media handles to text messages', () => { + connect({ first: [image(IMAGE_A, { messageType: '1', msgContent: 'text' })] }) + const message = listMessages('first')[0] + expect(message.id).toBe('56') + expect(message.media).toBeUndefined() + expect(getImageMessageReference('56')).toBeNull() + }) +})