feat: 新增MAC微信发送消息 文字转语音 功能

This commit is contained in:
Wxw-Gu
2026-08-17 18:16:14 +08:00
parent a52da455c3
commit 15811c820c
48 changed files with 7240 additions and 22 deletions
@@ -0,0 +1,211 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PersonalWechatSendDialog } from '../../src/renderer/src/components/chat/PersonalWechatSendDialog'
const getStatus = vi.fn()
const rebind = vi.fn()
const selectImage = vi.fn()
const selectVoice = vi.fn()
const sendMessage = vi.fn()
const getTextToSpeechSettings = vi.fn()
const listTextToSpeechVoices = vi.fn()
const synthesizeTextToSpeech = vi.fn()
const removeGeneratedTextToSpeechAudio = vi.fn()
const contact = {
m_nsUsrName: 'fixture-room@chatroom',
m_nsNickName: '技术交流群',
md5: 'fixture-md5',
type: 'group' as const
}
const readyStatus = {
state: 'online' as const,
platform: 'darwin',
arch: 'arm64',
sipDisabled: true,
wechatRunning: true,
wechatPid: 4668,
boundWechatPid: 4668,
oneBotPid: 5401,
endpoint: '127.0.0.1:58080',
endpointReady: true,
wechatVersion: '4.1.11.53',
runtimeReady: true,
attachReady: true,
baseAddress: '0x114ef8000',
baseAddressReady: true,
textHookInstalled: true,
textHookReady: true,
imageHookInstalled: true,
imageHookReady: true,
messageListenerReady: true,
canSend: true,
canSendText: true,
canSendImage: true,
canSendVoice: true,
message: '个人微信已绑定'
}
describe('PersonalWechatSendDialog', () => {
beforeEach(() => {
getStatus.mockReset().mockResolvedValue(readyStatus)
rebind.mockReset().mockResolvedValue(readyStatus)
selectImage.mockReset().mockResolvedValue({
canceled: false,
path: '/Users/fixture/test.png',
name: 'test.png'
})
selectVoice.mockReset().mockResolvedValue({
canceled: false,
path: '/Users/fixture/test.silk',
name: 'test.silk'
})
sendMessage.mockReset().mockResolvedValue({ success: true, status: readyStatus })
getTextToSpeechSettings.mockReset().mockResolvedValue({
success: true,
settings: {
provider: 'fish-audio',
hasApiKey: true,
encryptionAvailable: true,
selectedVoiceId: 'fish-warm-female',
outputFormat: 'mp3',
model: 's2.1-pro-free',
phase: 'ready'
},
voices: []
})
listTextToSpeechVoices.mockReset().mockResolvedValue({
success: true,
items: [
{
id: 'fish-warm-female',
name: '暖阳女声',
description: '自然温和',
tags: ['女声'],
languages: ['中文'],
source: 'fish-audio'
}
],
total: 1,
pageNumber: 1,
pageSize: 24,
hasMore: false
})
synthesizeTextToSpeech.mockReset().mockResolvedValue({
success: true,
filePath: '/tmp/generated.mp3',
audioDataUrl: 'data:audio/mpeg;base64,fixture'
})
removeGeneratedTextToSpeechAudio.mockReset().mockResolvedValue({ success: true })
Object.defineProperty(window, 'api', {
configurable: true,
value: {
getPersonalWechatSenderStatus: getStatus,
rebindPersonalWechatSender: rebind,
selectPersonalWechatImage: selectImage,
selectPersonalWechatVoice: selectVoice,
sendPersonalWechatMessage: sendMessage,
getTextToSpeechSettings,
listTextToSpeechVoices,
synthesizeTextToSpeech,
removeGeneratedTextToSpeechAudio
}
})
})
it('switches to voice mode and sends only the selected audio file', async () => {
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
await screen.findByText('技术交流群')
fireEvent.click(screen.getByRole('radio', { name: '语音' }))
fireEvent.click(screen.getByRole('radio', { name: '选择本地文件' }))
fireEvent.click(screen.getByRole('button', { name: '选择语音' }))
expect(await screen.findByText('test.silk')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '测试发送语音到群聊' }))
await waitFor(() =>
expect(sendMessage).toHaveBeenCalledWith({
type: 'voice',
to: 'fixture-room@chatroom',
filePath: '/Users/fixture/test.silk',
isGroup: true
})
)
})
it('shows the selected TTS voice and keeps generation separate from sending', async () => {
const openSettings = vi.fn()
render(
<PersonalWechatSendDialog
contact={contact}
isGroupChat
onClose={vi.fn()}
onOpenTextToSpeechSettings={openSettings}
/>
)
await screen.findByText('技术交流群')
fireEvent.click(screen.getByRole('radio', { name: '语音' }))
expect(await screen.findByText('暖阳女声')).toBeInTheDocument()
expect(screen.getByRole('textbox', { name: '要生成的文字' })).toHaveValue('1')
expect(screen.getByRole('button', { name: '生成语音' })).toBeEnabled()
fireEvent.click(screen.getByRole('button', { name: '前往文字转语音设置' }))
expect(openSettings).toHaveBeenCalledTimes(1)
expect(sendMessage).not.toHaveBeenCalled()
})
it('shows binding diagnostics and only offers image and voice modes', async () => {
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
expect(await screen.findByText('PID 4668')).toBeInTheDocument()
expect(screen.getByText('PID 5401 · 绑定 4668')).toBeInTheDocument()
expect(screen.getByText('0x114ef8000')).toBeInTheDocument()
expect(screen.getByText('已捕获,可发送')).toBeInTheDocument()
expect(screen.getByRole('radio', { name: '图片' })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByRole('radio', { name: '语音' })).toBeVisible()
expect(screen.queryByRole('radio', { name: '文字' })).not.toBeInTheDocument()
})
it('switches to image mode and sends only the selected image', async () => {
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
await screen.findByText('技术交流群')
fireEvent.click(screen.getByRole('radio', { name: '图片' }))
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(
screen.getByText(
'如果想测试图片,请先在微信中给任意好友手动发送一张普通图片,再点击重新检测。',
{ exact: false }
)
).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '选择图片' }))
expect(await screen.findByText('test.png')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '测试发送图片到群聊' }))
await waitFor(() =>
expect(sendMessage).toHaveBeenCalledWith({
type: 'image',
to: 'fixture-room@chatroom',
filePath: '/Users/fixture/test.png',
isGroup: true
})
)
})
it('offers a safe explicit rebind action', async () => {
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
await screen.findByText('技术交流群')
fireEvent.click(screen.getByRole('button', { name: '尝试重新绑定' }))
await waitFor(() => expect(rebind).toHaveBeenCalledTimes(1))
})
it('blocks image sending until the media Hook and image directory are initialized', async () => {
getStatus.mockResolvedValue({
...readyStatus,
state: 'hook_not_ready',
imageHookReady: false,
canSendImage: false,
message: '请先手动发送图片'
})
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
expect(await screen.findByText('已绑定,等待消息初始化')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '测试发送图片到群聊' })).toBeDisabled()
})
})
+112 -3
View File
@@ -41,6 +41,44 @@ describe('daily report controls', () => {
value: {
getAppLogPath: vi.fn(async () => ''),
revealAppLog: vi.fn(async () => undefined),
getPersonalWechatSenderStatus: vi.fn(async () => ({
state: 'online',
platform: 'darwin',
arch: 'arm64',
sipDisabled: true,
wechatRunning: true,
runtimeReady: true,
endpoint: '127.0.0.1:58080',
endpointReady: true,
attachReady: true,
baseAddressReady: true,
textHookInstalled: true,
textHookReady: true,
imageHookInstalled: true,
imageHookReady: true,
messageListenerReady: true,
canSend: true,
canSendText: true,
canSendImage: true,
canSendVoice: true,
message: '个人微信已绑定'
})),
getTextToSpeechSettings: vi.fn(async () => ({
success: true,
settings: {
provider: 'fish-audio',
hasApiKey: false,
hasStoredApiKey: false,
hasEnvironmentApiKey: false,
keySource: 'missing',
encryptionAvailable: true,
selectedVoiceId: '',
outputFormat: 'mp3',
model: 's2.1-pro-free',
phase: 'ready'
},
voices: []
})),
listAIProviders: vi.fn(async () => ({ success: true, providers: [] })),
getGroupSnapshot: vi.fn(async () => ({
members: [
@@ -396,7 +434,7 @@ describe('daily report controls', () => {
globalThis.ResizeObserver = originalResizeObserver
})
it('keeps secondary report actions inside More and labels both AI model roles', () => {
it('keeps the file action inside More and labels both AI model roles', () => {
render(
<>
<ReportToolbar
@@ -431,15 +469,86 @@ describe('daily report controls', () => {
</>
)
expect(screen.queryByRole('button', { name: '生成微信卡片' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '更多' }))
expect(screen.getByRole('button', { name: '生成微信卡片' })).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: '更多' }))
expect(screen.getByRole('button', { name: '打开文件夹' })).toBeVisible()
expect(screen.getByText('文字模型')).toBeVisible()
expect(screen.getByText('DeepSeek Chat')).toBeVisible()
expect(screen.getByText('图片模型')).toBeVisible()
expect(screen.getByText('gpt-5.6-sol')).toBeVisible()
})
it('opens the current group send dialog with the report PNG preselected', async () => {
const report: GeneratedReportRecord = {
id: 'report-send',
contactId: groupContact.md5,
contactName: groupContact.m_nsNickName,
dateRange: '今天',
messageCount: 10,
generatedAt: '2026-08-17T10:00:00.000Z',
reportDate: '2026-08-17',
htmlStatus: 'ready',
pngStatus: 'ready',
generatedImage: 'data:image/png;base64,fixture',
pngPath: '/Users/fixture/测试群日报.png'
}
render(
<ReportViewer
report={report}
hasReports
onBackToConfigure={vi.fn()}
onRegenerate={vi.fn()}
onCopyImage={vi.fn(async () => ({ success: true }))}
onReveal={vi.fn(async () => ({ success: true }))}
onSwitchTemplate={vi.fn(async () => ({ success: true }))}
sendTarget={groupContact}
personalWechatSendSupported
/>
)
fireEvent.click(screen.getByRole('button', { name: '发送到当前群聊' }))
expect(await screen.findByRole('dialog', { name: '个人微信测试发送' })).toBeVisible()
expect(screen.getByRole('radio', { name: '图片' })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByText('测试群日报.png')).toBeVisible()
expect(screen.getByText('/Users/fixture/测试群日报.png')).toBeVisible()
})
it('disables current group sending outside macOS with a readable hover hint', () => {
const report: GeneratedReportRecord = {
id: 'report-send-disabled',
contactId: groupContact.md5,
contactName: groupContact.m_nsNickName,
dateRange: '今天',
messageCount: 10,
generatedAt: '2026-08-17T10:00:00.000Z',
reportDate: '2026-08-17',
htmlStatus: 'ready',
pngStatus: 'ready',
generatedImage: 'data:image/png;base64,fixture',
pngPath: '/Users/fixture/测试群日报.png'
}
render(
<ReportViewer
report={report}
hasReports
onBackToConfigure={vi.fn()}
onRegenerate={vi.fn()}
onCopyImage={vi.fn(async () => ({ success: true }))}
onReveal={vi.fn(async () => ({ success: true }))}
onSwitchTemplate={vi.fn(async () => ({ success: true }))}
sendTarget={groupContact}
personalWechatSendSupported={false}
/>
)
const button = screen.getByRole('button', { name: '发送到当前群聊' })
expect(button).toBeDisabled()
expect(button.parentElement).toHaveAttribute('title', '仅支持 macOS')
})
it('switches templates from the top toolbar using the saved report snapshot', async () => {
const onSwitchTemplate = vi.fn(async () => ({ success: true }))
const report: GeneratedReportRecord = {
@@ -0,0 +1,129 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TextToSpeechPage } from '../../src/renderer/src/features/settings/pages/TextToSpeechPage'
const getSettings = vi.fn()
const saveSettings = vi.fn()
const listVoices = vi.fn()
const openApiKeys = vi.fn()
const getRuntimeStatus = vi.fn()
const onRuntimeProgress = vi.fn(() => vi.fn())
const response = {
success: true,
settings: {
provider: 'fish-audio' as const,
hasApiKey: true,
hasStoredApiKey: true,
hasEnvironmentApiKey: false,
keySource: 'secure-storage' as const,
encryptionAvailable: true,
selectedVoiceId: 'fish-warm-female',
outputFormat: 'mp3' as const,
model: 's2.1-pro-free' as const,
phase: 'ready' as const
},
voices: [
{
id: 'fish-warm-female',
name: '暖阳女声',
description: '自然温和,适合日常对话',
tags: ['女声', '自然', '普通话'],
languages: ['中文'],
source: 'fish-audio' as const
},
{
id: 'fish-calm-male',
name: '沉稳男声',
description: '低沉清晰,适合知识说明',
tags: ['男声', '沉稳', '普通话'],
languages: ['中文'],
source: 'fish-audio' as const
}
]
}
describe('TextToSpeechPage', () => {
beforeEach(() => {
getSettings.mockReset().mockResolvedValue(response)
listVoices.mockReset().mockResolvedValue({
success: true,
items: response.voices,
total: response.voices.length,
pageNumber: 1,
pageSize: 24,
hasMore: false
})
saveSettings.mockReset().mockImplementation(async (request) => ({
...response,
settings: {
...response.settings,
hasApiKey: Boolean(request.apiKey),
selectedVoiceId: request.selectedVoiceId || response.settings.selectedVoiceId,
model: request.model || response.settings.model
}
}))
openApiKeys.mockReset().mockResolvedValue({ success: true })
getRuntimeStatus.mockReset().mockResolvedValue({
version: 'v0.0.18',
state: 'ready',
downloadedBytes: 100,
totalBytes: 100,
progress: 1,
platform: 'darwin',
architecture: 'arm64',
supported: true,
removable: true
})
onRuntimeProgress.mockReset().mockReturnValue(vi.fn())
Object.defineProperty(window, 'api', {
configurable: true,
value: {
getTextToSpeechSettings: getSettings,
saveTextToSpeechSettings: saveSettings,
listTextToSpeechVoices: listVoices,
openFishAudioApiKeys: openApiKeys,
getPersonalWechatRuntimeStatus: getRuntimeStatus,
onPersonalWechatRuntimeProgress: onRuntimeProgress
}
})
})
it('searches, selects and securely saves an API key with a voice', async () => {
const onNotice = vi.fn()
render(<TextToSpeechPage onNotice={onNotice} />)
expect(await screen.findAllByText('暖阳女声')).not.toHaveLength(0)
fireEvent.change(screen.getByPlaceholderText('按音色名称搜索'), {
target: { value: '沉稳' }
})
fireEvent.click(screen.getByRole('button', { name: '搜索' }))
await waitFor(() =>
expect(listVoices).toHaveBeenCalledWith({
pageNumber: 1,
pageSize: 24,
title: '沉稳',
tags: []
})
)
fireEvent.click(screen.getByRole('radio', { name: /沉稳男声/ }))
fireEvent.change(screen.getByPlaceholderText('已安全保存;输入新 Key 可替换'), {
target: { value: 'fish-fixture-key' }
})
fireEvent.click(screen.getByRole('button', { name: '保存 Key' }))
await waitFor(() =>
expect(saveSettings).toHaveBeenCalledWith({
apiKey: 'fish-fixture-key'
})
)
expect(onNotice).toHaveBeenCalledWith('API Key 已安全保存')
})
it('opens the official Fish Audio API key page through the main process', async () => {
render(<TextToSpeechPage onNotice={vi.fn()} />)
await screen.findByText('微信发送组件')
fireEvent.click(screen.getByRole('button', { name: '前往 api.fish.audio 获取 Key' }))
await waitFor(() => expect(openApiKeys).toHaveBeenCalledTimes(1))
})
})