mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-04 03:01:42 +08:00
feat: 本地api增加图片理解
This commit is contained in:
@@ -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。
|
||||
|
||||
@@ -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`:用户还没有完成数据库连接或对应服务未就绪。
|
||||
- 空结果:缩小/扩大时间范围,确认账号和会话,再检查媒体或语音是否可读。
|
||||
|
||||
@@ -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<ReturnType<typeof getChatDb>>['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<HttpImageResult> {
|
||||
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
|
||||
}
|
||||
+81
-2
@@ -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<HttpImageResult>
|
||||
}
|
||||
|
||||
type RouteHandler = (ctx: RouteContext) => void | Promise<void>
|
||||
@@ -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<string, unknown>): Record<string, unknown> {
|
||||
const contentData = message.contentData
|
||||
if (!contentData || typeof contentData !== 'object' || !('aeskey' in contentData)) {
|
||||
return message
|
||||
}
|
||||
const safeContentData = { ...(contentData as Record<string, unknown>) }
|
||||
delete safeContentData.aeskey
|
||||
return { ...message, contentData: safeContentData }
|
||||
}
|
||||
|
||||
function readBody(req: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = []
|
||||
@@ -252,7 +274,9 @@ const routes: Record<string, RouteHandler> = {
|
||||
contact: resolved,
|
||||
query: { talker, time: timeParam, startTime, endTime },
|
||||
count: messages.length,
|
||||
messages
|
||||
messages: messages.map((message) =>
|
||||
sanitizeChatlogMessage(message as unknown as Record<string, unknown>)
|
||||
)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -343,12 +367,63 @@ const routes: Record<string, RouteHandler> = {
|
||||
}
|
||||
}
|
||||
|
||||
const MEDIA_ROUTE_PREFIX = '/api/v1/media/'
|
||||
|
||||
function createMediaRoute(
|
||||
mediaProvider: (messageId: string) => Promise<HttpImageResult>
|
||||
): 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<HttpServerHandle> {
|
||||
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}`)
|
||||
}
|
||||
|
||||
@@ -59,6 +59,11 @@ export interface FormattedMessage {
|
||||
name?: string
|
||||
senderId?: string
|
||||
contentData?: ReturnType<typeof parseMessageContent>
|
||||
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<string, ImageMessageReference | null>()
|
||||
|
||||
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<boolean> {
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<HttpServerHandle> {
|
||||
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) => {
|
||||
|
||||
@@ -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<HttpMediaError>({
|
||||
code: 'NOT_FOUND'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports database readiness separately', async () => {
|
||||
fixture.ready = false
|
||||
await expect(readImageMedia('message-1')).rejects.toMatchObject<HttpMediaError>({
|
||||
code: 'NOT_READY'
|
||||
})
|
||||
fixture.ready = true
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user