Files
WechatExplorer/tests/unit/personal-wechat-send-service.test.ts
T
Wxw-Gu cf9ea8a83b feat: 优化 macOS 微信发送消息能力
参考 wechat_chatter PR #36:
https://github.com/yincongcyincong/wechat_chatter/pull/36

补充 WeChat 4.1.11.53 的 GetService / CdnManager 地址,
并适配绑定后的发送能力检测逻辑。
2026-09-14 15:59:08 +08:00

449 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
unlinkSync,
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',
getPath: () => '/tmp/tracememo-test-user-data',
isPackaged: false
}
}))
import {
buildPersonalWechatVoiceDiagnostic,
buildWindowsWechatRequest,
buildPersonalWechatOneBotRequest,
findWechatImagePath,
findPersonalWechatRuntime,
normalizeWindowsWechatPort,
parseWindowsLoginStatus,
parseWindowsHookResponse,
parsePersonalWechatHookLog,
deriveMacSendCapabilities,
prepareWindowsImageFile,
WindowsHookHttpError
} 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('keeps voice diagnostics to the safe metadata allowlist', () => {
const diagnostic = buildPersonalWechatVoiceDiagnostic('request-1', 'completed', {
input_bytes: 10,
upload_result: '0',
error: undefined,
aesKey: 'secret',
cdnKey: 'secret',
token: 'secret',
runtime_log: 'private'
})
expect(diagnostic).toMatchObject({ request_id: 'request-1', upload_result: '0' })
expect(diagnostic).not.toHaveProperty('aesKey')
expect(diagnostic).not.toHaveProperty('cdnKey')
expect(diagnostic).not.toHaveProperty('token')
expect(diagnostic).not.toHaveProperty('runtime_log')
})
it('redacts secrets embedded in diagnostic errors', () => {
const diagnostic = buildPersonalWechatVoiceDiagnostic('request-2', 'failed', {
error:
'upload failed aesKey=secret-value Bearer bearer-secret token:token-value {"cdnKey":"json-secret"}'
})
expect(diagnostic.error).toContain('aesKey=[redacted]')
expect(diagnostic.error).toContain('Bearer [redacted]')
expect(diagnostic.error).toContain('token:[redacted]')
expect(diagnostic.error).not.toContain('secret-value')
expect(diagnostic.error).not.toContain('bearer-secret')
expect(diagnostic.error).not.toContain('token-value')
expect(diagnostic.error).not.toContain('json-secret')
})
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('Windows request', () => {
it('accepts only a valid configured local port', () => {
expect(normalizeWindowsWechatPort('')).toBeNull()
expect(normalizeWindowsWechatPort(' 4567 ')).toBe('4567')
expect(normalizeWindowsWechatPort('0')).toBeNull()
expect(normalizeWindowsWechatPort('65536')).toBeNull()
expect(normalizeWindowsWechatPort('port')).toBeNull()
})
it('requires ret=0 for message responses but accepts the status shape', () => {
expect(parseWindowsHookResponse('{"ret":0,"retmsg":"success"}', true)).toEqual({
ret: 0,
retmsg: 'success'
})
expect(parseWindowsHookResponse('{"IsLogin":0,"hWeixin":123}', false)).toEqual({
IsLogin: 0,
hWeixin: 123
})
expect(() => parseWindowsHookResponse('{"ret":1,"retmsg":"fail"}', true)).toThrow('fail')
expect(() => parseWindowsHookResponse('{"retmsg":"success"}', true)).toThrow('success')
expect(() => parseWindowsHookResponse('{}', true)).toThrow('ret=undefined')
expect(() => parseWindowsHookResponse('', true)).toThrow('空响应')
})
it('preserves the Windows Hook HTTP status and response body', () => {
const error = new WindowsHookHttpError(500, '{"ret":-9,"retmsg":"image failed"}')
expect(error.message).toBe('HTTP 500: {"ret":-9,"retmsg":"image failed"}')
expect(error.status).toBe(500)
expect(error.responseBody).toContain('image failed')
expect(new WindowsHookHttpError(500, '').message).toBe('HTTP 500')
})
it('allows Windows sending only for a strict logged-in status', () => {
expect(parseWindowsLoginStatus({ status: true })).toBe(true)
expect(parseWindowsLoginStatus({ status: false })).toBe(false)
expect(parseWindowsLoginStatus({ status: 1 })).toBe(false)
expect(parseWindowsLoginStatus({ status: 'true' })).toBe(false)
expect(parseWindowsLoginStatus({ IsLogin: 1, hWeixin: 123 })).toBe(false)
})
it('builds the unified text request', () => {
expect(
buildWindowsWechatRequest({
to: ' wxid_fixture ',
type: 'text',
text: ' 测试发送 ',
isGroup: false
})
).toEqual({
endpoint: '/SendMsg',
body: { toWxid: 'wxid_fixture', type: 'text', msg: '测试发送' }
})
})
it('builds the unified image request with a local path', () => {
expect(
buildWindowsWechatRequest({
to: 'room@chatroom',
type: 'image',
filePath: 'C:\\fixture\\image.png',
isGroup: true
})
).toEqual({
endpoint: '/SendMsg',
body: { toWxid: 'room@chatroom', type: 'image', msg: 'C:\\fixture\\image.png' }
})
})
it('builds the unified voice request with sender wxid and duration', () => {
expect(
buildWindowsWechatRequest(
{
to: 'wxid_fixture',
type: 'voice',
filePath: '/source/input.wav',
fromId: ' wxid_self ',
isGroup: false
},
{ filePath: 'C:\\Temp\\prepared.silk', durationMs: 1234 }
)
).toEqual({
endpoint: '/SendMsg',
body: {
toWxid: 'wxid_fixture',
type: 'voice',
msg: 'C:\\Temp\\prepared.silk',
fromWxid: 'wxid_self',
duration: 1234
}
})
})
it('copies adjacent non-ASCII image paths to ASCII temporary files', () => {
const root = temporaryDirectory()
const content = Buffer.from('fixture-image')
for (const fileName of ['测试图片_2026-08-31_经典.png', '测试图片_2026-08-31_经典版.png']) {
const source = join(root, fileName)
writeFileSync(source, content)
const prepared = prepareWindowsImageFile(source)
expect(prepared.temporary).toBe(true)
expect(prepared.filePath).not.toContain(fileName)
expect(prepared.filePath).toMatch(/^[\x20-\x7e]+$/)
expect(readFileSync(prepared.filePath)).toEqual(content)
unlinkSync(prepared.filePath)
}
})
it('keeps an ASCII image path unchanged', () => {
const root = temporaryDirectory()
const source = join(root, 'report.png')
writeFileSync(source, 'fixture-image')
expect(prepareWindowsImageFile(source)).toEqual({ filePath: source, temporary: false })
})
})
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('marks media ready from initialized hooks without an upload capture event', () => {
expect(
deriveMacSendCapabilities({
attached: true,
baseAddressReady: true,
textHookInstalled: true,
textHookReady: true,
imageHookInstalled: true,
imagePathBound: true
})
).toEqual({ canSend: true, canSendText: true, canSendImage: true, canSendVoice: true })
})
it('keeps sending unavailable until the native send context is captured', () => {
expect(
deriveMacSendCapabilities({
attached: true,
baseAddressReady: true,
textHookInstalled: true,
textHookReady: false,
imageHookInstalled: true,
imagePathBound: true
}).canSend
).toBe(false)
})
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 })
})
})