mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-06 22:00:49 +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/chatroom` | 群聊列表 | `keyword` |
|
||||||
| GET | `/api/v1/recent_chat` | 最近会话 | `limit`,默认 50 |
|
| GET | `/api/v1/recent_chat` | 最近会话 | `limit`,默认 50 |
|
||||||
| GET | `/api/v1/chatlog` | 指定会话的聊天记录 | 必填 `talker`;可选 `time` 或 `startTime`/`endTime` |
|
| 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/group_snapshot` | 群成员快照 | 必填 `md5` |
|
||||||
| GET | `/api/v1/resolve` | 将昵称、wxid 或 md5 解析为会话 | 必填 `q` |
|
| GET | `/api/v1/resolve` | 将昵称、wxid 或 md5 解析为会话 | 必填 `q` |
|
||||||
| POST | `/api/v1/report` | 将结构化日报渲染为 HTML 与 PNG | `GroupReportExportRequest` JSON |
|
| POST | `/api/v1/report` | 将结构化日报渲染为 HTML 与 PNG | `GroupReportExportRequest` JSON |
|
||||||
@@ -85,7 +85,7 @@ curl -H "$AUTH" "$BASE/chatlog?talker=技术交流群&time=2026-08-07"
|
|||||||
- `200`:请求成功;
|
- `200`:请求成功;
|
||||||
- `401`:缺少、错误或已失效的 Bearer Token;
|
- `401`:缺少、错误或已失效的 Bearer Token;
|
||||||
- `400`:参数或 JSON 请求体无效;
|
- `400`:参数或 JSON 请求体无效;
|
||||||
- `422`:媒体 `messageId` 无效,或目标消息不是可读取的图片;
|
- `422`:媒体标识无效,或目标消息不是可读取的图片;
|
||||||
- `403`:浏览器 Origin 不在允许的 loopback 列表;
|
- `403`:浏览器 Origin 不在允许的 loopback 列表;
|
||||||
- `404`:端点、会话或群聊不存在;
|
- `404`:端点、会话或群聊不存在;
|
||||||
- `503`:数据库或 Agent Hub 尚未就绪;
|
- `503`:数据库或 Agent Hub 尚未就绪;
|
||||||
@@ -102,13 +102,15 @@ curl -H "$AUTH" "$BASE/chatlog?talker=技术交流群&time=2026-08-07"
|
|||||||
"media": {
|
"media": {
|
||||||
"type": "image",
|
"type": "image",
|
||||||
"available": true,
|
"available": true,
|
||||||
"url": "/api/v1/media/msg_xxx"
|
"url": "/api/v1/media/image%3A0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
当用户要求查看或理解图片时,使用 `media.url` 获取 `image/jpeg`、`image/png` 等真实二进制;不要根据 `[图片]` 猜测内容,也不要向 API 传入本地路径。
|
当用户要求查看或理解图片时,使用 `media.url` 获取 `image/jpeg`、`image/png` 等真实二进制;不要根据 `[图片]` 猜测内容,也不要向 API 传入本地路径。
|
||||||
|
|
||||||
|
`media.url` 包含当前数据库连接内的独立媒体标识,不等同于消息 `id`。不同会话的消息 `id` 可能重复,调用方应原样使用返回的地址,不自行拼接或解析。重启、重连或切换账号后须重新读取 `/chatlog` 获取新地址;旧的纯消息 ID 地址仅在无歧义时兼容。`available` 只表示消息带有图片定位信息,不保证本地图片文件仍存在或可以解密。
|
||||||
|
|
||||||
## 与 MCP 的关系
|
## 与 MCP 的关系
|
||||||
|
|
||||||
当前实现没有把 `6131` 暴露为 MCP Server。需要在 Agent 中使用时,请安装随应用提供的 Reader Skill,并让 Skill 通过普通 HTTP 请求调用本 API。
|
当前实现没有把 `6131` 暴露为 MCP Server。需要在 Agent 中使用时,请安装随应用提供的 Reader Skill,并让 Skill 通过普通 HTTP 请求调用本 API。
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
|
|||||||
| GET | `/chatroom` | 群聊列表;可传 `keyword` |
|
| GET | `/chatroom` | 群聊列表;可传 `keyword` |
|
||||||
| GET | `/recent_chat` | 最近会话;可传 `limit` |
|
| GET | `/recent_chat` | 最近会话;可传 `limit` |
|
||||||
| GET | `/chatlog` | 会话消息;必填 `talker`,可传 `time` 或时间戳范围 |
|
| GET | `/chatlog` | 会话消息;必填 `talker`,可传 `time` 或时间戳范围 |
|
||||||
| GET | `/media/{messageId}` | 获取图片消息的真实图片二进制资源 |
|
| GET | `/media/{mediaId}` | 按消息返回的 `media.url` 获取图片二进制资源 |
|
||||||
| GET | `/group_snapshot` | 群成员快照;必填 `md5` |
|
| GET | `/group_snapshot` | 群成员快照;必填 `md5` |
|
||||||
| GET | `/resolve` | 昵称、wxid、md5 解析;必填 `q` |
|
| GET | `/resolve` | 昵称、wxid、md5 解析;必填 `q` |
|
||||||
| GET | `/wechat-personal/send-capability` | 个人微信图片发送能力状态 |
|
| GET | `/wechat-personal/send-capability` | 个人微信图片发送能力状态 |
|
||||||
@@ -107,7 +107,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
|
|||||||
当 `/chatlog` 返回图片消息时:
|
当 `/chatlog` 返回图片消息时:
|
||||||
|
|
||||||
1. 如果用户只是询问图片消息是否存在,不需要获取图片。
|
1. 如果用户只是询问图片消息是否存在,不需要获取图片。
|
||||||
2. 如果用户要求查看、识别、理解或分析图片,使用该消息 `media.url`(`/media/{messageId}`)获取真实图片。
|
2. 如果用户要求查看、识别、理解或分析图片,原样使用该消息 `media.url` 获取真实图片;不要用消息 `id` 自行拼接。媒体标识按数据库连接隔离,重启、重连或切换账号后须重新读取 `/chatlog` 获取地址。
|
||||||
3. 不要根据 `[图片]`、消息文本或文件名猜测图片内容。
|
3. 不要根据 `[图片]`、消息文本或文件名猜测图片内容。
|
||||||
4. 获取成功后,将图片交给当前 Agent 的视觉能力。
|
4. 获取成功后,将图片交给当前 Agent 的视觉能力。
|
||||||
5. 如果图片获取失败,明确说明无法读取图片。
|
5. 如果图片获取失败,明确说明无法读取图片。
|
||||||
@@ -120,7 +120,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
|
|||||||
|
|
||||||
1. 调用 `/health`;必要时调用 `/current_time`。
|
1. 调用 `/health`;必要时调用 `/current_time`。
|
||||||
2. 调用 `/resolve`,再调用 `/chatlog` 找到 `type` 为图片的消息。
|
2. 调用 `/resolve`,再调用 `/chatlog` 找到 `type` 为图片的消息。
|
||||||
3. 调用 `/media/{messageId}`,将返回的图片交给 Vision。
|
3. 请求该消息的 `media.url`,将返回的图片交给 Vision。
|
||||||
4. 必要时读取图片消息前后若干条消息,结合聊天上下文回答。
|
4. 必要时读取图片消息前后若干条消息,结合聊天上下文回答。
|
||||||
|
|
||||||
不要只根据 `[图片]` 猜测内容,不要把一次 OCR 当作完整图片理解,也不要直接读取任意本地图片路径。
|
不要只根据 `[图片]` 猜测内容,不要把一次 OCR 当作完整图片理解,也不要直接读取任意本地图片路径。
|
||||||
@@ -134,6 +134,6 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
|
|||||||
- `401`:Token 缺失、错误或被轮换;请用户回 API Center 复制最新 Token。
|
- `401`:Token 缺失、错误或被轮换;请用户回 API Center 复制最新 Token。
|
||||||
- `403`:浏览器 Origin 不在 loopback 允许列表;CLI/Agent 通常不带 Origin。
|
- `403`:浏览器 Origin 不在 loopback 允许列表;CLI/Agent 通常不带 Origin。
|
||||||
- `404`:先用 `/resolve` 确认会话标识。
|
- `404`:先用 `/resolve` 确认会话标识。
|
||||||
- `422`:`messageId` 无效,或消息不是可读取的图片。
|
- `422`:媒体标识无效,或消息不是可读取的图片。
|
||||||
- `503`:用户还没有完成数据库连接或对应服务未就绪。
|
- `503`:用户还没有完成数据库连接或对应服务未就绪。
|
||||||
- 空结果:缩小/扩大时间范围,确认账号和会话,再检查媒体或语音是否可读。
|
- 空结果:缩小/扩大时间范围,确认账号和会话,再检查媒体或语音是否可读。
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createHash, randomUUID } from 'node:crypto'
|
||||||
import { WechatDb, WechatMessage } from '../wechat-db'
|
import { WechatDb, WechatMessage } from '../wechat-db'
|
||||||
import {
|
import {
|
||||||
parseImageBufferDataUrlFromRow,
|
parseImageBufferDataUrlFromRow,
|
||||||
@@ -156,6 +157,7 @@ export interface ImageMessageReference {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const imageMessageReferences = new Map<string, ImageMessageReference | null>()
|
const imageMessageReferences = new Map<string, ImageMessageReference | null>()
|
||||||
|
let imageReferenceScope = randomUUID()
|
||||||
|
|
||||||
export function setChatDb(db: WechatDb | null): boolean {
|
export function setChatDb(db: WechatDb | null): boolean {
|
||||||
if (shutdownRequested) {
|
if (shutdownRequested) {
|
||||||
@@ -166,6 +168,7 @@ export function setChatDb(db: WechatDb | null): boolean {
|
|||||||
dbRef = db
|
dbRef = db
|
||||||
contactSearchIndexCache = null
|
contactSearchIndexCache = null
|
||||||
imageMessageReferences.clear()
|
imageMessageReferences.clear()
|
||||||
|
imageReferenceScope = randomUUID()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,11 +504,28 @@ function listSourceMessages(
|
|||||||
: msg.mesLocalID || Math.random().toString()
|
: msg.mesLocalID || Math.random().toString()
|
||||||
)
|
)
|
||||||
const imageContent = contentData?.type === 'image' ? contentData : undefined
|
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
|
const media = imageContent
|
||||||
? {
|
? {
|
||||||
type: 'image' as const,
|
type: 'image' as const,
|
||||||
available: Boolean(imageContent.md5 || imageContent.datName),
|
available: Boolean(imageContent.md5 || imageContent.datName),
|
||||||
url: `/api/v1/media/${encodeURIComponent(messageId)}`
|
url: `/api/v1/media/${encodeURIComponent(mediaId)}`
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
if (imageContent && media) {
|
if (imageContent && media) {
|
||||||
@@ -516,6 +536,8 @@ function listSourceMessages(
|
|||||||
imageDatName: imageContent.datName,
|
imageDatName: imageContent.datName,
|
||||||
createTime
|
createTime
|
||||||
}
|
}
|
||||||
|
imageMessageReferences.set(mediaId, reference)
|
||||||
|
// Keep old bare-id URLs working only while they are unambiguous.
|
||||||
const previous = imageMessageReferences.get(messageId)
|
const previous = imageMessageReferences.get(messageId)
|
||||||
if (
|
if (
|
||||||
previous &&
|
previous &&
|
||||||
|
|||||||
@@ -187,6 +187,32 @@ describe('Local API authentication', () => {
|
|||||||
expect(body.messages[0].contentData).not.toHaveProperty('aeskey')
|
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 () => {
|
it('maps media lookup failures to stable API statuses', async () => {
|
||||||
const handle = await startFixtureServer(
|
const handle = await startFixtureServer(
|
||||||
() => VALID_TOKEN,
|
() => 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