mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-03 18:33:14 +08:00
fix: scope HTTP image media handles to database and conversation
This commit is contained in:
+5
-3
@@ -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,7 +85,7 @@ curl -H "$AUTH" "$BASE/chatlog?talker=技术交流群&time=2026-08-07"
|
||||
- `200`:请求成功;
|
||||
- `401`:缺少、错误或已失效的 Bearer Token;
|
||||
- `400`:参数或 JSON 请求体无效;
|
||||
- `422`:媒体 `messageId` 无效,或目标消息不是可读取的图片;
|
||||
- `422`:媒体标识无效,或目标消息不是可读取的图片;
|
||||
- `403`:浏览器 Origin 不在允许的 loopback 列表;
|
||||
- `404`:端点、会话或群聊不存在;
|
||||
- `503`:数据库或 Agent Hub 尚未就绪;
|
||||
@@ -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。
|
||||
|
||||
@@ -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 当作完整图片理解,也不要直接读取任意本地图片路径。
|
||||
@@ -134,6 +134,6 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
|
||||
- `401`:Token 缺失、错误或被轮换;请用户回 API Center 复制最新 Token。
|
||||
- `403`:浏览器 Origin 不在 loopback 允许列表;CLI/Agent 通常不带 Origin。
|
||||
- `404`:先用 `/resolve` 确认会话标识。
|
||||
- `422`:`messageId` 无效,或消息不是可读取的图片。
|
||||
- `422`:媒体标识无效,或消息不是可读取的图片。
|
||||
- `503`:用户还没有完成数据库连接或对应服务未就绪。
|
||||
- 空结果:缩小/扩大时间范围,确认账号和会话,再检查媒体或语音是否可读。
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { WechatDb, WechatMessage } from '../wechat-db'
|
||||
import {
|
||||
parseImageBufferDataUrlFromRow,
|
||||
@@ -156,6 +157,7 @@ export interface ImageMessageReference {
|
||||
}
|
||||
|
||||
const imageMessageReferences = new Map<string, ImageMessageReference | null>()
|
||||
let imageReferenceScope = randomUUID()
|
||||
|
||||
export function setChatDb(db: WechatDb | null): boolean {
|
||||
if (shutdownRequested) {
|
||||
@@ -166,6 +168,7 @@ export function setChatDb(db: WechatDb | null): boolean {
|
||||
dbRef = db
|
||||
contactSearchIndexCache = null
|
||||
imageMessageReferences.clear()
|
||||
imageReferenceScope = randomUUID()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -501,11 +504,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) {
|
||||
@@ -516,6 +536,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 &&
|
||||
|
||||
@@ -187,6 +187,32 @@ describe('Local API authentication', () => {
|
||||
expect(body.messages[0].contentData).not.toHaveProperty('aeskey')
|
||||
})
|
||||
|
||||
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('maps media lookup failures to stable API statuses', async () => {
|
||||
const handle = await startFixtureServer(
|
||||
() => VALID_TOKEN,
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
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)
|
||||
|
||||
function image(md5: string, overrides: Partial<WechatMessage> = {}): WechatMessage {
|
||||
return {
|
||||
mesLocalID: '56',
|
||||
mesDes: 1,
|
||||
messageType: '3',
|
||||
msgCreateTime: '1756000000',
|
||||
msgContent: `<msg><img md5="${md5}" /></msg>`,
|
||||
serverId: '9007199254740993123',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function connect(messages: Record<string, WechatMessage[]>): 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)
|
||||
}
|
||||
|
||||
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 firstId = mediaId(listMessages('first')[0])
|
||||
message.serverId = '9007199254740993123'
|
||||
expect(mediaId(listMessages('first')[0])).toBe(firstId)
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user