feat: 本地api增加图片理解

This commit is contained in:
Wxw-Gu
2026-08-26 15:21:05 +08:00
parent 1e25ae4065
commit fc42b18f4a
8 changed files with 423 additions and 10 deletions
+71 -3
View File
@@ -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) => {
+76
View File
@@ -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
})
})