mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-18 20:19:09 +08:00
feat: 新增MAC微信发送消息 文字转语音 功能
This commit is contained in:
@@ -56,7 +56,18 @@ describe('message grouping and dates', () => {
|
||||
})
|
||||
|
||||
describe('search cache', () => {
|
||||
beforeEach(() => localStorage.clear())
|
||||
beforeEach(() => {
|
||||
const values = new Map<string, string>()
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
clear: () => values.clear(),
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
removeItem: (key: string) => values.delete(key),
|
||||
setItem: (key: string, value: string) => values.set(key, String(value))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes the key and survives invalid persisted state', () => {
|
||||
const key = buildSearchCacheKey('global', '', '7d', ' Windows 性能 ')
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join, sep } from 'path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getAppPath: () => '/fixture/app',
|
||||
isPackaged: false
|
||||
}
|
||||
}))
|
||||
|
||||
import {
|
||||
buildPersonalWechatOneBotRequest,
|
||||
findWechatImagePath,
|
||||
findPersonalWechatRuntime,
|
||||
parsePersonalWechatHookLog
|
||||
} from '../../src/main/services/personal-wechat-send-service'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
function temporaryDirectory(): string {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'wechat-personal-runtime-'))
|
||||
temporaryDirectories.push(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (temporaryDirectories.length > 0) {
|
||||
rmSync(temporaryDirectories.pop()!, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('personal WeChat OneBot request', () => {
|
||||
it('builds a private text message request', () => {
|
||||
expect(
|
||||
buildPersonalWechatOneBotRequest({
|
||||
to: ' wxid_fixture ',
|
||||
type: 'text',
|
||||
text: ' 测试发送 ',
|
||||
isGroup: false
|
||||
})
|
||||
).toEqual({
|
||||
endpoint: '/send_private_msg',
|
||||
body: {
|
||||
user_id: 'wxid_fixture',
|
||||
message: [{ type: 'text', data: { text: '测试发送' } }]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the group endpoint for chatroom targets', () => {
|
||||
expect(
|
||||
buildPersonalWechatOneBotRequest({
|
||||
to: 'fixture-room@chatroom',
|
||||
type: 'text',
|
||||
text: '群聊测试',
|
||||
isGroup: false
|
||||
})
|
||||
).toEqual({
|
||||
endpoint: '/send_group_msg',
|
||||
body: {
|
||||
group_id: 'fixture-room@chatroom',
|
||||
message: [{ type: 'text', data: { text: '群聊测试' } }]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('builds a base64 image request without mixing text content', () => {
|
||||
expect(
|
||||
buildPersonalWechatOneBotRequest(
|
||||
{ to: 'fixture-room@chatroom', type: 'image', filePath: '/tmp/test.png', isGroup: false },
|
||||
'aGVsbG8='
|
||||
)
|
||||
).toEqual({
|
||||
endpoint: '/send_group_msg',
|
||||
body: {
|
||||
group_id: 'fixture-room@chatroom',
|
||||
message: [{ type: 'image', data: { file: 'base64://aGVsbG8=' } }]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('builds a base64 voice record request', () => {
|
||||
expect(
|
||||
buildPersonalWechatOneBotRequest(
|
||||
{ to: 'filehelper', type: 'voice', filePath: '/tmp/test.silk', isGroup: false },
|
||||
'dm9pY2U='
|
||||
)
|
||||
).toEqual({
|
||||
endpoint: '/send_private_msg',
|
||||
body: {
|
||||
user_id: 'filehelper',
|
||||
message: [{ type: 'record', data: { file: 'base64://dm9pY2U=' } }]
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('personal WeChat runtime discovery', () => {
|
||||
it('uses the newest current-month WeChat image directory', () => {
|
||||
const root = temporaryDirectory()
|
||||
const oldPath = join(root, 'account', 'temp', 'old', '2026-08', 'Img')
|
||||
const latestPath = join(root, 'account', 'temp', 'latest', '2026-08', 'Img')
|
||||
mkdirSync(oldPath, { recursive: true })
|
||||
mkdirSync(latestPath, { recursive: true })
|
||||
utimesSync(oldPath, new Date('2026-08-01'), new Date('2026-08-01'))
|
||||
utimesSync(latestPath, new Date('2026-08-05'), new Date('2026-08-05'))
|
||||
|
||||
expect(findWechatImagePath(root, new Date('2026-08-06'))).toBe(`${latestPath}${sep}`)
|
||||
})
|
||||
|
||||
it('supports the current ImageTemp layout after a WeChat relogin', () => {
|
||||
const root = temporaryDirectory()
|
||||
const imageTempPath = join(root, 'account', 'temp', 'ImageTemp', '2026-08')
|
||||
mkdirSync(imageTempPath, { recursive: true })
|
||||
expect(findWechatImagePath(root, new Date('2026-08-06'))).toBe(`${imageTempPath}${sep}`)
|
||||
})
|
||||
|
||||
it('derives the current ImageTemp directory before the month folder exists', () => {
|
||||
const root = temporaryDirectory()
|
||||
const tempRoot = join(root, 'account', 'temp')
|
||||
mkdirSync(tempRoot, { recursive: true })
|
||||
|
||||
expect(findWechatImagePath(root, new Date('2026-08-06'))).toBe(
|
||||
`${join(tempRoot, 'ImageTemp', '2026-08')}${sep}`
|
||||
)
|
||||
})
|
||||
|
||||
it('finds the nested release archive layout', () => {
|
||||
const root = temporaryDirectory()
|
||||
mkdirSync(join(root, 'onebot'), { recursive: true })
|
||||
mkdirSync(join(root, 'wechat_version'), { recursive: true })
|
||||
writeFileSync(join(root, 'onebot', 'onebot'), 'fixture')
|
||||
writeFileSync(join(root, 'onebot', 'script.js'), 'fixture')
|
||||
|
||||
expect(findPersonalWechatRuntime([root])).toEqual({
|
||||
root,
|
||||
executable: join(root, 'onebot', 'onebot'),
|
||||
workingDirectory: join(root, 'onebot'),
|
||||
configDirectory: join(root, 'wechat_version'),
|
||||
logPath: join(root, 'onebot', 'log', 'macos.log')
|
||||
})
|
||||
})
|
||||
|
||||
it('finds a flat runtime layout and rejects incomplete directories', () => {
|
||||
const incomplete = temporaryDirectory()
|
||||
writeFileSync(join(incomplete, 'onebot'), 'fixture')
|
||||
|
||||
const root = temporaryDirectory()
|
||||
mkdirSync(join(root, 'wechat_version'), { recursive: true })
|
||||
writeFileSync(join(root, 'onebot'), 'fixture')
|
||||
writeFileSync(join(root, 'script.js'), 'fixture')
|
||||
|
||||
expect(findPersonalWechatRuntime([incomplete, root])).toEqual({
|
||||
root,
|
||||
executable: join(root, 'onebot'),
|
||||
workingDirectory: root,
|
||||
configDirectory: join(root, 'wechat_version'),
|
||||
logPath: join(root, 'log', 'macos.log')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('personal WeChat hook diagnostics', () => {
|
||||
it('does not treat a listening service as hook-ready after req2buf scan failure', () => {
|
||||
expect(
|
||||
parsePersonalWechatHookLog(
|
||||
[
|
||||
JSON.stringify({ time: '2026-08-06T14:08:10+08:00', payload: '[+] HTTP 服务启动在' }),
|
||||
JSON.stringify({
|
||||
time: '2026-08-06T14:08:11+08:00',
|
||||
err: "Error: [-] Cannot find 'req2buf' keyword in a range > 100MB"
|
||||
})
|
||||
].join('\n')
|
||||
)
|
||||
).toMatchObject({ readiness: 'failed' })
|
||||
})
|
||||
|
||||
it('requires StartTask capture before reporting ready', () => {
|
||||
expect(
|
||||
parsePersonalWechatHookLog(
|
||||
JSON.stringify({ payload: '[+] Dynamic Text Message Setup Complete.' })
|
||||
)
|
||||
).toMatchObject({ readiness: 'initializing', textHookInstalled: true })
|
||||
expect(
|
||||
parsePersonalWechatHookLog(
|
||||
JSON.stringify({ payload: '[+] 捕获到 StartTask 调用,X0:0x1, Payload: 0x2' })
|
||||
)
|
||||
).toMatchObject({ readiness: 'ready', textHookReady: true })
|
||||
})
|
||||
|
||||
it('resets stale hook results when a new WeChat process attach starts', () => {
|
||||
expect(
|
||||
parsePersonalWechatHookLog(
|
||||
[
|
||||
JSON.stringify({ payload: '[+] 捕获到 StartTask 调用,X0:0x1, Payload: 0x2' }),
|
||||
JSON.stringify({ message: '使用指定的微信进程 PID', PID: 123 }),
|
||||
JSON.stringify({ payload: '[+] Dynamic Text Message Setup Complete.' })
|
||||
].join('\n')
|
||||
)
|
||||
).toMatchObject({
|
||||
readiness: 'initializing',
|
||||
boundWechatPid: 123,
|
||||
textHookInstalled: true,
|
||||
textHookReady: false
|
||||
})
|
||||
})
|
||||
|
||||
it('tracks base, image Hook and message listener diagnostics', () => {
|
||||
expect(
|
||||
parsePersonalWechatHookLog(
|
||||
[
|
||||
JSON.stringify({ message: '使用指定的微信进程 PID', PID: 4668 }),
|
||||
JSON.stringify({ message: '成功 Attach 微信进程', PID: 4668 }),
|
||||
JSON.stringify({ payload: '[+] Base address from range: 0x114ef8000' }),
|
||||
JSON.stringify({ payload: '[+] Dynamic Text Message Setup Complete.' }),
|
||||
JSON.stringify({ payload: '[+] 图片上传 Hook Setup Complete.' }),
|
||||
JSON.stringify({ payload: '[+] 捕获到图片上传上下文,uploadGlobalX0:0x1' }),
|
||||
JSON.stringify({ message: '发送数据' })
|
||||
].join('\n')
|
||||
)
|
||||
).toMatchObject({
|
||||
attached: true,
|
||||
baseAddress: '0x114ef8000',
|
||||
textHookInstalled: true,
|
||||
imageHookInstalled: true,
|
||||
imageHookReady: true,
|
||||
messageListenerReady: true,
|
||||
boundWechatPid: 4668
|
||||
})
|
||||
})
|
||||
|
||||
it('recognizes a successful legacy image send without the new Hook marker', () => {
|
||||
expect(
|
||||
parsePersonalWechatHookLog(
|
||||
JSON.stringify({ type: 'send_image', result: '1', message: '发送图片任务执行结果' })
|
||||
)
|
||||
).toMatchObject({ imageHookInstalled: true, imageHookReady: true })
|
||||
})
|
||||
|
||||
it('recognizes the real onebot two-record image result format', () => {
|
||||
expect(
|
||||
parsePersonalWechatHookLog(
|
||||
[
|
||||
JSON.stringify({ task_id: 536870915, type: 'send_image' }),
|
||||
JSON.stringify({
|
||||
result: '1',
|
||||
task_id: 536870915,
|
||||
message: '📩 发送图片任务执行结果'
|
||||
})
|
||||
].join('\n')
|
||||
)
|
||||
).toMatchObject({ imageHookInstalled: true, imageHookReady: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SETTINGS_NAVIGATION } from '../../src/renderer/src/features/settings/model/settingsNavigation'
|
||||
|
||||
describe('settings navigation', () => {
|
||||
it('places text-to-speech under intelligent capabilities', () => {
|
||||
const intelligent = SETTINGS_NAVIGATION.find((group) => group.label === '智能能力')
|
||||
expect(intelligent?.items.map((item) => item.id)).toEqual([
|
||||
'voice-recognition',
|
||||
'text-to-speech',
|
||||
'ai-model'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
keyGet: vi.fn(),
|
||||
keySave: vi.fn(),
|
||||
keyClear: vi.fn(),
|
||||
loadSettings: vi.fn(),
|
||||
updateSettings: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../src/main/ai-provider-key-store', () => ({
|
||||
AIProviderKeyStore: class {
|
||||
get = mocks.keyGet
|
||||
save = mocks.keySave
|
||||
clear = mocks.keyClear
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../../src/main/services/settings-store', () => ({
|
||||
loadSettings: mocks.loadSettings,
|
||||
updateSettings: mocks.updateSettings
|
||||
}))
|
||||
|
||||
import { TextToSpeechSettingsService } from '../../src/main/services/text-to-speech-settings-service'
|
||||
|
||||
describe('TextToSpeechSettingsService', () => {
|
||||
beforeEach(() => {
|
||||
mocks.keyGet.mockReset().mockReturnValue({
|
||||
success: true,
|
||||
available: true,
|
||||
key: undefined
|
||||
})
|
||||
mocks.keySave.mockReset().mockReturnValue({ success: true })
|
||||
mocks.keyClear.mockReset().mockReturnValue({ success: true })
|
||||
mocks.loadSettings.mockReset().mockReturnValue({ ttsSelectedVoiceId: 'demo-warm-female' })
|
||||
mocks.updateSettings.mockReset()
|
||||
})
|
||||
|
||||
it('returns an empty dynamic voice list without exposing a missing key', () => {
|
||||
const result = new TextToSpeechSettingsService().get()
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.settings.hasApiKey).toBe(false)
|
||||
expect(result.settings.selectedVoiceId).toBe('')
|
||||
expect(result.voices).toEqual([])
|
||||
})
|
||||
|
||||
it('stores the key securely and persists the selected voice', () => {
|
||||
mocks.keyGet.mockReturnValue({ success: true, available: true, key: 'stored' })
|
||||
const result = new TextToSpeechSettingsService().save({
|
||||
apiKey: ' fish-key ',
|
||||
selectedVoiceId: 'demo-calm-male'
|
||||
})
|
||||
expect(mocks.keySave).toHaveBeenCalledWith('fish-audio-tts', 'fish-key')
|
||||
expect(mocks.updateSettings).toHaveBeenCalledWith({
|
||||
ttsSelectedVoiceId: 'demo-calm-male'
|
||||
})
|
||||
expect(result.settings.hasApiKey).toBe(true)
|
||||
})
|
||||
|
||||
it('persists a remotely loaded voice id without local demo validation', () => {
|
||||
const result = new TextToSpeechSettingsService().save({
|
||||
selectedVoiceId: 'fish-model-id'
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
expect(mocks.updateSettings).toHaveBeenCalledWith({
|
||||
ttsSelectedVoiceId: 'fish-model-id'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -36,7 +36,7 @@ describe('WechatDb normalized messages', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('scans without time bounds, then filters, deduplicates and sorts in application code', async () => {
|
||||
it('passes time bounds, then filters, deduplicates and sorts in application code', async () => {
|
||||
const row = (id: string, createTime: number): WechatMessage => ({
|
||||
mesLocalID: id,
|
||||
serverId: `server-${id}`,
|
||||
@@ -49,13 +49,20 @@ describe('WechatDb normalized messages', () => {
|
||||
const start = Math.floor(new Date(2025, 0, 1).getTime() / 1000)
|
||||
const end = Math.floor(new Date(2025, 0, 4).getTime() / 1000)
|
||||
const shardBoundaryMessage = row('jan-2-boundary', start + 32 * 60 * 60)
|
||||
const getMessagesAsync = vi.fn(async () => [
|
||||
const rows = [
|
||||
row('before-range', start - 1),
|
||||
row('newest', start + 48 * 60 * 60),
|
||||
shardBoundaryMessage,
|
||||
{ ...shardBoundaryMessage },
|
||||
row('after-range', end + 1)
|
||||
])
|
||||
]
|
||||
const getMessagesAsync = vi.fn(
|
||||
async (_username: string, startTime?: number, endTime?: number) =>
|
||||
rows.filter((message) => {
|
||||
const createTime = Number(message.msgCreateTime)
|
||||
return (!startTime || createTime >= startTime) && (!endTime || createTime <= endTime)
|
||||
})
|
||||
)
|
||||
const db = Object.assign(Object.create(WechatDb.prototype), {
|
||||
wcdb4Client: { getMessagesAsync },
|
||||
chatMd5ToUsername: new Map([['fixture-md5', 'fixture-user']]),
|
||||
@@ -65,7 +72,7 @@ describe('WechatDb normalized messages', () => {
|
||||
const messages = await db.getUserMessagesForExport('fixture-md5', start, end)
|
||||
|
||||
expect(getMessagesAsync).toHaveBeenCalledOnce()
|
||||
expect(getMessagesAsync).toHaveBeenCalledWith('fixture-user')
|
||||
expect(getMessagesAsync).toHaveBeenCalledWith('fixture-user', start, end)
|
||||
expect(messages.map((message) => message.mesLocalID)).toEqual(['jan-2-boundary', 'newest'])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user