feat: mac发送能力重构为libtmwechat

- 发送改走本地host, 在微信登录时触发
- 删除旧OneBot及对应UI控制面
This commit is contained in:
Wxw-Gu
2026-09-24 03:01:03 +08:00
parent 069d836dea
commit 7268188e40
50 changed files with 2442 additions and 4141 deletions
@@ -12,16 +12,17 @@ vi.mock('../../src/renderer/src/utils/runtime-environment', () => ({
}))
const getStatus = vi.fn()
const getRuntimeStatus = vi.fn()
const onRuntimeProgress = vi.fn(() => vi.fn())
const rebind = vi.fn()
const sendGeneratedTtsVoice = vi.fn()
const sendPersonalWechatMessage = vi.fn()
const getTextToSpeechSettings = vi.fn()
const listTextToSpeechVoices = vi.fn()
const synthesizeTextToSpeech = vi.fn()
const removeGeneratedTextToSpeechAudio = vi.fn()
const getPersonalWechatVoiceDiagnostic = vi.fn()
const copyText = vi.fn()
const getSettings = vi.fn()
const setSettings = vi.fn()
const contact = {
m_nsUsrName: 'fixture-room@chatroom',
@@ -38,8 +39,7 @@ const readyStatus = {
wechatRunning: true,
wechatPid: 4668,
boundWechatPid: 4668,
oneBotPid: 5401,
endpoint: '127.0.0.1:58080',
endpoint: '127.0.0.1:4290',
endpointReady: true,
wechatVersion: '4.1.11.53',
runtimeReady: true,
@@ -58,18 +58,6 @@ const readyStatus = {
message: '个人微信已绑定'
}
const readyRuntime = {
version: 'v0.0.18',
state: 'ready' as const,
downloadedBytes: 1,
totalBytes: 1,
progress: 1,
platform: 'darwin' as NodeJS.Platform,
architecture: 'arm64',
supported: true,
removable: true
}
function renderDialog(
props: Partial<React.ComponentProps<typeof PersonalWechatSendDialog>> = {}
): React.ReactElement {
@@ -85,13 +73,24 @@ async function startComposer(): Promise<void> {
describe('PersonalWechatSendDialog', () => {
beforeEach(() => {
getStatus.mockReset().mockResolvedValue(readyStatus)
getRuntimeStatus.mockReset().mockResolvedValue(readyRuntime)
onRuntimeProgress.mockReset().mockReturnValue(vi.fn())
rebind.mockReset().mockResolvedValue(readyStatus)
sendGeneratedTtsVoice.mockReset().mockResolvedValue({
action: { status: 'sent' },
status: readyStatus
})
sendPersonalWechatMessage.mockReset().mockResolvedValue({
success: true,
status: readyStatus,
postfixSent: true
})
getSettings.mockReset().mockResolvedValue({
settings: { reportImagePostfixText: '今日日报' },
settingsPath: '/tmp/settings.json'
})
setSettings.mockReset().mockResolvedValue({
settings: { reportImagePostfixText: '今日日报' },
settingsPath: '/tmp/settings.json'
})
getTextToSpeechSettings.mockReset().mockResolvedValue({
success: true,
settings: {
@@ -125,16 +124,17 @@ describe('PersonalWechatSendDialog', () => {
configurable: true,
value: {
getPersonalWechatSenderStatus: getStatus,
getPersonalWechatRuntimeStatus: getRuntimeStatus,
onPersonalWechatRuntimeProgress: onRuntimeProgress,
rebindPersonalWechatSender: rebind,
sendGeneratedTtsVoice,
sendPersonalWechatMessage,
getTextToSpeechSettings,
listTextToSpeechVoices,
synthesizeTextToSpeech,
removeGeneratedTextToSpeechAudio,
getPersonalWechatVoiceDiagnostic,
copyText
copyText,
getSettings,
setSettings
}
})
})
@@ -143,7 +143,6 @@ describe('PersonalWechatSendDialog', () => {
renderDialog()
await startComposer()
expect(screen.getByRole('dialog')).toHaveTextContent('文字转语音')
expect(screen.queryByRole('switch', { name: '保留 OneBot 进程' })).not.toBeInTheDocument()
expect(screen.queryByText('验证消息能力')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '生成语音' })).toBeDisabled()
})
@@ -167,6 +166,55 @@ describe('PersonalWechatSendDialog', () => {
expect(screen.getByLabelText('消息列表')).toHaveTextContent('你好 TraceMemo')
})
it('shows image send success feedback after the host confirms the send', async () => {
const user = userEvent.setup()
renderDialog({ initialImage: { path: '/tmp/report.png', name: '测试群日报.png' } })
expect(await screen.findByRole('textbox', { name: '发送后置词' })).toHaveValue('今日日报')
const sendButton = await screen.findByRole('button', { name: '发送日报图片' })
await user.click(sendButton)
await waitFor(() =>
expect(sendPersonalWechatMessage).toHaveBeenCalledWith({
type: 'image',
to: 'fixture-room@chatroom',
isGroup: true,
filePath: '/tmp/report.png',
postfixText: '今日日报'
})
)
expect(await screen.findByRole('status')).toHaveTextContent('日报图片和后置词发送成功')
})
it('persists a customized report postfix and sends it with the image request', async () => {
const user = userEvent.setup()
renderDialog({ initialImage: { path: '/tmp/report.png', name: '测试群日报.png' } })
const input = await screen.findByRole('textbox', { name: '发送后置词' })
await user.clear(input)
await user.type(input, '今日技术日报')
await user.tab()
await user.click(screen.getByRole('button', { name: '发送日报图片' }))
expect(setSettings).toHaveBeenCalledWith({ reportImagePostfixText: '今日技术日报' })
await waitFor(() =>
expect(sendPersonalWechatMessage).toHaveBeenCalledWith(
expect.objectContaining({ postfixText: '今日技术日报' })
)
)
})
it('shows an error when the report postfix cannot be persisted', async () => {
setSettings.mockRejectedValueOnce(new Error('设置保存失败'))
const user = userEvent.setup()
renderDialog({ initialImage: { path: '/tmp/report.png', name: '测试群日报.png' } })
const input = await screen.findByRole('textbox', { name: '发送后置词' })
await user.clear(input)
await user.type(input, '今日技术日报')
await user.tab()
expect(await screen.findByRole('alert')).toHaveTextContent('设置保存失败')
})
it('keeps the setup guide voice-only when voice capability is unavailable', async () => {
getStatus.mockResolvedValue({ ...readyStatus, canSend: false, canSendVoice: false })
renderDialog({ onOpenPersonalWechatSettings: vi.fn() })
@@ -1,5 +1,4 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PersonalWechatSendPage } from '../../src/renderer/src/features/settings/pages/PersonalWechatSendPage'
@@ -12,15 +11,7 @@ vi.mock('../../src/renderer/src/utils/runtime-environment', () => ({
const getCapability = vi.fn()
const getSenderStatus = vi.fn()
const getRuntimeStatus = vi.fn()
const onRuntimeProgress = vi.fn(() => vi.fn())
const downloadRuntime = vi.fn()
const cancelRuntimeDownload = vi.fn()
const removeRuntime = vi.fn()
const openRuntimeDirectory = vi.fn()
const rebindSender = vi.fn()
const getKeepOneBotProcess = vi.fn()
const setKeepOneBotProcess = vi.fn()
const senderStatus = {
state: 'online' as const,
@@ -30,8 +21,7 @@ const senderStatus = {
wechatRunning: true,
wechatPid: 4668,
boundWechatPid: 4668,
oneBotPid: 5401,
endpoint: '127.0.0.1:58080',
endpoint: '127.0.0.1:4290',
endpointReady: true,
wechatVersion: '4.1.11.53',
runtimeReady: true,
@@ -50,19 +40,6 @@ const senderStatus = {
message: '个人微信已绑定'
}
const readyRuntime = {
version: 'v0.0.18',
state: 'ready' as const,
downloadedBytes: 66_599_785,
totalBytes: 66_599_785,
progress: 1,
platform: 'darwin' as NodeJS.Platform,
architecture: 'arm64',
supported: true,
removable: true,
directory: '/tmp/personal-wechat-runtime'
}
const capability = {
supported: true,
ready: true,
@@ -76,85 +53,23 @@ describe('PersonalWechatSendPage on macOS', () => {
beforeEach(() => {
getCapability.mockReset().mockResolvedValue(capability)
getSenderStatus.mockReset().mockResolvedValue(senderStatus)
getRuntimeStatus.mockReset().mockResolvedValue(readyRuntime)
onRuntimeProgress.mockReset().mockReturnValue(vi.fn())
downloadRuntime.mockReset().mockResolvedValue({ success: true, status: readyRuntime })
cancelRuntimeDownload.mockReset().mockResolvedValue({ success: true })
removeRuntime.mockReset().mockResolvedValue({
...readyRuntime,
state: 'missing',
downloadedBytes: 0,
progress: 0,
removable: false,
directory: undefined
})
openRuntimeDirectory.mockReset().mockResolvedValue({ success: true })
rebindSender.mockReset().mockResolvedValue(senderStatus)
getKeepOneBotProcess.mockReset().mockResolvedValue(false)
setKeepOneBotProcess.mockReset().mockImplementation(async (keep: boolean) => keep)
vi.spyOn(window, 'confirm').mockReturnValue(true)
Object.defineProperty(window, 'api', {
configurable: true,
value: {
getPersonalWechatSendCapability: getCapability,
getPersonalWechatSenderStatus: getSenderStatus,
getPersonalWechatRuntimeStatus: getRuntimeStatus,
onPersonalWechatRuntimeProgress: onRuntimeProgress,
downloadPersonalWechatRuntime: downloadRuntime,
cancelPersonalWechatRuntimeDownload: cancelRuntimeDownload,
removePersonalWechatRuntime: removeRuntime,
openPersonalWechatRuntimeDirectory: openRuntimeDirectory,
rebindPersonalWechatSender: rebindSender,
getPersonalWechatKeepOneBotProcess: getKeepOneBotProcess,
setPersonalWechatKeepOneBotProcess: setKeepOneBotProcess
rebindPersonalWechatSender: rebindSender
}
})
})
it('keeps OneBot runtime management on the WeChat send page', async () => {
const user = userEvent.setup()
it('shows the native runtime readiness', async () => {
render(<PersonalWechatSendPage onNotice={vi.fn()} />)
expect(await screen.findByText('OneBot v0.0.18')).toBeVisible()
expect(await screen.findByText('微信消息发送已配置完成')).toBeVisible()
expect(screen.getAllByText('已就绪')).not.toHaveLength(0)
await user.click(screen.getByRole('button', { name: '打开目录' }))
expect(openRuntimeDirectory).toHaveBeenCalledOnce()
await user.click(screen.getByRole('button', { name: '卸载组件' }))
await waitFor(() => expect(removeRuntime).toHaveBeenCalledOnce())
await user.click(screen.getByRole('button', { name: '支持版本' }))
const versionsDialog = screen.getByRole('dialog', { name: '支持的微信版本' })
expect(versionsDialog).toBeVisible()
expect(versionsDialog).toHaveTextContent('4.1.11.53')
})
it('keeps the OneBot process setting on the macOS WeChat send page', async () => {
const user = userEvent.setup()
render(<PersonalWechatSendPage onNotice={vi.fn()} />)
const keepProcess = await screen.findByRole('switch', { name: '保留 OneBot 进程' })
expect(keepProcess).not.toBeChecked()
await user.click(keepProcess)
await waitFor(() => expect(setKeepOneBotProcess).toHaveBeenCalledWith(true))
expect(keepProcess).toBeChecked()
})
it('downloads a missing macOS runtime from the send page', async () => {
getRuntimeStatus.mockResolvedValue({
...readyRuntime,
state: 'missing',
downloadedBytes: 0,
progress: 0,
removable: false,
directory: undefined
})
const onNotice = vi.fn()
render(<PersonalWechatSendPage onNotice={onNotice} />)
await userEvent.setup().click(await screen.findByRole('button', { name: '下载组件' }))
await waitFor(() => expect(downloadRuntime).toHaveBeenCalledOnce())
expect(onNotice).toHaveBeenCalledWith('微信发送组件已准备好')
expect(screen.queryByText('查看支持的微信版本')).not.toBeInTheDocument()
expect(screen.queryByText('高级诊断')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,96 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PersonalWechatSetupGuide } from '../../src/renderer/src/components/chat/PersonalWechatSetupGuide'
import { PersonalWechatSendPage } from '../../src/renderer/src/features/settings/pages/PersonalWechatSendPage'
import type { PersonalWechatSenderStatus } from '../../src/shared/personal-wechat'
vi.mock('../../src/renderer/src/utils/runtime-environment', () => ({
isMac: true,
isWindows: false,
runtimePlatform: 'darwin',
supportsPersonalWechatSend: true
}))
const unavailableStatus: PersonalWechatSenderStatus = {
state: 'runtime_missing',
platform: 'darwin',
arch: 'arm64',
sipDisabled: true,
wechatRunning: false,
endpoint: 'http://127.0.0.1:4290',
endpointReady: false,
runtimeReady: false,
attachReady: false,
baseAddressReady: false,
textHookInstalled: false,
textHookReady: false,
imageHookInstalled: false,
imageHookReady: false,
messageListenerReady: false,
canSend: false,
canSendText: false,
canSendImage: false,
canSendVoice: false,
message: '暂无发送能力',
error: '暂无发送能力'
}
describe('personal WeChat unavailable product state', () => {
beforeEach(() => {
Object.defineProperty(window, 'api', {
configurable: true,
value: {
getPersonalWechatSendCapability: vi.fn(async () => ({
supported: true,
ready: false,
status: 'unconfigured',
capabilities: { text: false, image: false, voice: false },
senderStatus: unavailableStatus,
message: '暂无发送能力',
error: '暂无发送能力'
})),
getPersonalWechatSenderStatus: vi.fn(async () => unavailableStatus)
}
})
})
it('keeps the setup flow visible and disables binding when the runtime is unavailable', () => {
render(
<PersonalWechatSetupGuide
senderStatus={unavailableStatus}
binding={false}
detecting={false}
sessionBound={false}
onBind={vi.fn()}
onStartSending={vi.fn()}
/>
)
expect(screen.getByRole('heading', { name: '先准备好微信消息功能' })).toBeVisible()
expect(screen.getByRole('button', { name: '绑定微信' })).toBeDisabled()
expect(screen.getByRole('alert')).toHaveTextContent('发送能力属授权制,需要联系群主')
expect(screen.getByText(/发送能力属授权制,需要联系群主/)).toBeVisible()
expect(screen.getByRole('link', { name: '这里' })).toHaveAttribute(
'href',
'https://github.com/Wxw-Gu/TraceMemo#-交流与反馈'
)
expect(screen.queryByRole('button', { name: '下载运行时' })).not.toBeInTheDocument()
expect(screen.queryByText('查看支持的微信版本')).not.toBeInTheDocument()
expect(screen.queryByText('高级诊断')).not.toBeInTheDocument()
expect(screen.queryByText(/manifest|dylib|runtime missing|文件缺失/i)).not.toBeInTheDocument()
})
it('shows the disabled binding flow on the settings page', async () => {
render(<PersonalWechatSendPage onNotice={vi.fn()} />)
expect(await screen.findByRole('heading', { name: '先准备好微信消息功能' })).toBeVisible()
expect(screen.getByRole('button', { name: '绑定微信' })).toBeDisabled()
expect(screen.getByText(/发送能力属授权制,需要联系群主/)).toBeVisible()
expect(screen.getByRole('link', { name: '这里' })).toHaveAttribute(
'href',
'https://github.com/Wxw-Gu/TraceMemo#-交流与反馈'
)
expect(screen.queryByText(/manifest|dylib|runtime missing|文件缺失/i)).not.toBeInTheDocument()
})
})
+5 -1
View File
@@ -54,7 +54,7 @@ describe('daily report controls', () => {
sipDisabled: true,
wechatRunning: true,
runtimeReady: true,
endpoint: '127.0.0.1:58080',
endpoint: '127.0.0.1:4290',
endpointReady: true,
attachReady: true,
baseAddressReady: true,
@@ -618,6 +618,10 @@ describe('daily report controls', () => {
fireEvent.click(screen.getByRole('button', { name: '发送到当前群聊' }))
expect(await screen.findByRole('dialog', { name: '测试群' })).toBeVisible()
expect(screen.getByRole('heading', { name: '发送日报图片' })).toBeVisible()
expect(screen.queryByText('文字转语音')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '语音发送诊断' })).not.toBeInTheDocument()
expect(screen.getByRole('textbox', { name: '发送后置词' })).toHaveValue('今日日报')
const detect = screen.queryByRole('button', { name: '重新检测' })
if (detect) fireEvent.click(detect)
expect(screen.getByRole('region', { name: '日报图片发送' })).toBeVisible()
@@ -17,7 +17,6 @@ const openFishAudioApiKeys = vi.fn()
const getSettings = vi.fn()
const setSettings = vi.fn()
const getPersonalWechatSenderStatus = vi.fn()
const getPersonalWechatRuntimeStatus = vi.fn()
const checkPersonalWechatSenderStatus = vi.fn()
const textToSpeechSettings = {
@@ -70,7 +69,6 @@ describe('PersonalWechatSendPage on Windows', () => {
settings: { windowsWechatPort: '4567' }
})
getPersonalWechatSenderStatus.mockReset()
getPersonalWechatRuntimeStatus.mockReset().mockResolvedValue(null)
checkPersonalWechatSenderStatus.mockReset().mockResolvedValue(windowsStatus)
Object.defineProperty(window, 'api', {
configurable: true,
@@ -81,7 +79,6 @@ describe('PersonalWechatSendPage on Windows', () => {
getSettings,
setSettings,
getPersonalWechatSenderStatus,
getPersonalWechatRuntimeStatus,
checkPersonalWechatSenderStatus
}
})
@@ -128,7 +125,7 @@ describe('PersonalWechatSendPage on Windows', () => {
expect(onNotice).toHaveBeenCalledWith('微信发送能力端口已清除')
})
it('keeps the Windows send dialog free of macOS OneBot controls', async () => {
it('keeps the Windows send dialog free of macOS-only controls', async () => {
getPersonalWechatSenderStatus.mockResolvedValue({
...windowsStatus,
state: 'error',
@@ -156,9 +153,6 @@ describe('PersonalWechatSendPage on Windows', () => {
expect(
await screen.findByText('未检测到 Windows 微信发送能力,请先启动并登录微信')
).toBeVisible()
expect(getPersonalWechatRuntimeStatus).not.toHaveBeenCalled()
expect(screen.queryByText(/OneBot/i)).not.toBeInTheDocument()
expect(screen.queryByRole('switch', { name: '保留 OneBot 进程' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '语音发送诊断' })).not.toBeInTheDocument()
expect(screen.getByRole('heading', { name: '文字转语音' })).toBeVisible()
expect(screen.getByRole('region', { name: '文字转语音' })).toBeVisible()
@@ -188,7 +182,6 @@ describe('PersonalWechatSendPage on Windows', () => {
render(<TextToSpeechPage onNotice={vi.fn()} />)
expect(await screen.findByText('API 设置')).toBeVisible()
expect(screen.queryByText(/OneBot/i)).not.toBeInTheDocument()
expect(screen.queryByText('微信发送组件')).not.toBeInTheDocument()
})
@@ -196,7 +189,6 @@ describe('PersonalWechatSendPage on Windows', () => {
render(<PersonalWechatSendPage onNotice={vi.fn()} />)
expect(await screen.findByRole('spinbutton', { name: '微信发送能力端口' })).toBeVisible()
expect(screen.queryByRole('switch', { name: '保留 OneBot 进程' })).not.toBeInTheDocument()
})
it('shows the authorization notice with a README link on the Windows WeChat send page', async () => {
+4 -5
View File
@@ -148,12 +148,11 @@ describe('TextToSpeechPage', () => {
await waitFor(() => expect(saveSettings).toHaveBeenCalledWith({ clearApiKey: true }))
})
it('shows the macOS voice encoding environment entry without exposing OneBot controls', async () => {
it('shows the macOS voice encoding environment entry', async () => {
render(<TextToSpeechPage onNotice={vi.fn()} />)
expect(await screen.findByText('API 设置')).toBeVisible()
expect(screen.getByText('语音编码环境')).toBeVisible()
expect(screen.queryByText(/OneBot/i)).not.toBeInTheDocument()
expect(screen.queryByText('支持的微信版本')).not.toBeInTheDocument()
})
@@ -166,8 +165,8 @@ describe('TextToSpeechPage', () => {
python: { ready: true, executable: '/usr/local/bin/python3', version: '3.12.11' },
pilk: { ready: false, error: '未安装' },
ffmpeg: { ready: true, executable: '/runtime/ffmpeg', version: '6.1' },
encoder: 'go-silk' as const,
message: '语音编码环境不完整,OneBot 可能回退到 go-silk'
encoder: 'silk' as const,
message: '语音编码环境不完整,语音将回退到内置 SILK 编码'
}
const readyEnvironment = {
...incompleteEnvironment,
@@ -187,7 +186,7 @@ describe('TextToSpeechPage', () => {
fireEvent.click(screen.getByRole('button', { name: '安装 pilk' }))
await waitFor(() => expect(installPilk).toHaveBeenCalledTimes(1))
expect(onNotice).toHaveBeenCalledWith('语音编码环境已修复。重新启动语音发送服务后生效。')
expect(onNotice).toHaveBeenCalledWith('语音编码环境已修复。')
expect(await screen.findByText('✓ 编码环境正常')).toBeVisible()
})
})