mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-04 03:01:42 +08:00
feat: map MM_FAV types to message cards and voice disk bypass V2
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import { createHash } from 'crypto'
|
||||
import { promises as fs } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { Wcdb4Client } from './wcdb4-client'
|
||||
import {
|
||||
createDefaultAudioDecoderRegistry,
|
||||
@@ -211,6 +213,17 @@ export class VoiceService {
|
||||
svrId || 0
|
||||
)
|
||||
if (!voiceResult.success || !voiceResult.hex) {
|
||||
const disk = await this.resolveFromDisk(sessionId, localId, createTime, svrId)
|
||||
if (disk?.length) {
|
||||
return {
|
||||
success: true,
|
||||
source: {
|
||||
data: disk,
|
||||
codec: 'silk',
|
||||
sourceHash: createHash('sha256').update(disk).digest('hex')
|
||||
}
|
||||
}
|
||||
}
|
||||
return { success: false, error: voiceResult.error || '获取语音数据失败' }
|
||||
}
|
||||
|
||||
@@ -239,6 +252,38 @@ export class VoiceService {
|
||||
return candidates
|
||||
}
|
||||
|
||||
/**
|
||||
* V2:旁路 `wcdb_get_voice_data`,在账号目录下找语音文件
|
||||
* (`GetMsgAudioPath` 同域:Message / MsgAndFiles / VoiceTemp)。
|
||||
*/
|
||||
private async resolveFromDisk(
|
||||
sessionId: string,
|
||||
localId: number,
|
||||
createTime: number,
|
||||
svrId?: string | number
|
||||
): Promise<Buffer | null> {
|
||||
const accountRoot = this.wcdb4Client.getAccountRoot?.()
|
||||
if (!accountRoot) return null
|
||||
const needles = [
|
||||
String(localId),
|
||||
String(createTime),
|
||||
svrId !== undefined && svrId !== null && String(svrId) !== '0' ? String(svrId) : '',
|
||||
sessionId.replace(/@chatroom$/, '')
|
||||
].filter(Boolean)
|
||||
const files = await listVoiceDiskCandidates(accountRoot)
|
||||
for (const file of files) {
|
||||
const name = file.toLowerCase()
|
||||
if (!needles.some((needle) => needle && name.includes(needle.toLowerCase()))) continue
|
||||
try {
|
||||
const data = await fs.readFile(file)
|
||||
if (data.length) return data
|
||||
} catch {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private decodeVoiceBlob(hex: string): Buffer | null {
|
||||
try {
|
||||
const hexClean = hex.replace(/\s+/g, '')
|
||||
@@ -274,3 +319,35 @@ export class VoiceService {
|
||||
return Buffer.concat([header, pcmData])
|
||||
}
|
||||
}
|
||||
|
||||
const VOICE_DISK_EXTS = new Set(['.aud', '.silk', '.amr'])
|
||||
const VOICE_DISK_DIRS = [
|
||||
'msg/Message',
|
||||
'msg/MsgAndFiles',
|
||||
'msg/VoiceTemp',
|
||||
'msg/History',
|
||||
'Message',
|
||||
'MsgAndFiles',
|
||||
'VoiceTemp'
|
||||
]
|
||||
|
||||
/** 语音磁盘候选路径(扩展名/目录与 GetMsgAudioPath 对齐)。 */
|
||||
export async function listVoiceDiskCandidates(accountRoot: string): Promise<string[]> {
|
||||
const out: string[] = []
|
||||
for (const rel of VOICE_DISK_DIRS) {
|
||||
const dir = join(accountRoot, rel)
|
||||
let names: string[]
|
||||
try {
|
||||
names = await fs.readdir(dir)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
for (const name of names) {
|
||||
const ext = name.slice(name.lastIndexOf('.')).toLowerCase()
|
||||
if (!VOICE_DISK_EXTS.has(ext)) continue
|
||||
out.push(join(dir, name))
|
||||
}
|
||||
if (out.length > 200) break
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { ParsedContent } from './types'
|
||||
|
||||
/**
|
||||
* 收藏类型(`MM_FAV_ITEM_TYPE_*` / `MM_FAV_DATA_TYPE_*`)只读映射。
|
||||
* 名称来自 wechat.dylib 导出名;数字为枚举顺序推断,未知值原样回退。
|
||||
*/
|
||||
|
||||
const FAV_TYPE_TEXT: Record<string, string> = {
|
||||
all: '全部',
|
||||
none: '未知收藏',
|
||||
txt: '文字',
|
||||
'1': '文字',
|
||||
img: '图片',
|
||||
'2': '图片',
|
||||
voice: '语音',
|
||||
chatvoice: '语音',
|
||||
'3': '语音',
|
||||
video: '视频',
|
||||
'4': '视频',
|
||||
webpage: '网页',
|
||||
'5': '网页',
|
||||
loc: '位置',
|
||||
location: '位置',
|
||||
'6': '位置',
|
||||
music: '音乐',
|
||||
'7': '音乐',
|
||||
file: '文件',
|
||||
'8': '文件',
|
||||
book: '书籍',
|
||||
'9': '书籍',
|
||||
goods: '商品',
|
||||
general_product: '商品',
|
||||
'10': '商品',
|
||||
card: '卡券',
|
||||
sharecard: '名片',
|
||||
record: '聊天记录',
|
||||
embeded_record: '聊天记录',
|
||||
tv: '视频',
|
||||
sight: '视频',
|
||||
music_mv: '音乐视频',
|
||||
note: '笔记',
|
||||
weapp: '小程序',
|
||||
liteapp: '小程序',
|
||||
finder: '视频号',
|
||||
finder_feed: '视频号',
|
||||
finder_live: '视频号直播',
|
||||
finder_video: '视频号',
|
||||
finder_name_card: '视频号名片',
|
||||
finder_shop_window_shared: '商品橱窗',
|
||||
ting: '听一听',
|
||||
ting_list: '听一听',
|
||||
collection: '合集',
|
||||
openimkf_sharecard: '客服名片'
|
||||
}
|
||||
|
||||
/** `MM_FAV_*` → 展示文案(未知原样返回)。 */
|
||||
export function describeFavoriteType(raw?: string | number): string | undefined {
|
||||
if (raw === undefined || raw === null || raw === '') return undefined
|
||||
const key = String(raw).trim().toLowerCase()
|
||||
if (!key) return undefined
|
||||
return FAV_TYPE_TEXT[key] || String(raw).trim()
|
||||
}
|
||||
|
||||
/** 收藏记录(只读展示字段;与 favorite.db 列名对齐时可再扩)。 */
|
||||
export type FavoriteRecord = {
|
||||
favId?: string | number
|
||||
/** `MM_FAV_ITEM_TYPE_*` 或 `MM_FAV_DATA_TYPE_*`。 */
|
||||
type?: string | number
|
||||
dataType?: string | number
|
||||
title?: string
|
||||
description?: string
|
||||
url?: string
|
||||
appName?: string
|
||||
poiname?: string
|
||||
label?: string
|
||||
lat?: number
|
||||
lng?: number
|
||||
md5?: string
|
||||
datName?: string
|
||||
duration?: number
|
||||
width?: number
|
||||
height?: number
|
||||
raw?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 把收藏记录收成现有 `ParsedContent` 形态,复用聊天卡片/导出。
|
||||
* 不做支付、不生成可点开资金动作。
|
||||
*/
|
||||
export function favoriteRecordToContent(record: FavoriteRecord): ParsedContent {
|
||||
const rawType = record.dataType ?? record.type
|
||||
const key = String(rawType ?? '').trim().toLowerCase()
|
||||
const label = describeFavoriteType(rawType) || '收藏'
|
||||
const title = record.title || label
|
||||
const description = record.description
|
||||
|
||||
if (key === 'txt' || key === 'text' || key === '1') {
|
||||
return { type: 'text', content: description || record.raw || title }
|
||||
}
|
||||
if (key === 'img' || key === 'image' || key === '2') {
|
||||
return {
|
||||
type: 'image',
|
||||
md5: record.md5,
|
||||
datName: record.datName
|
||||
}
|
||||
}
|
||||
if (key === 'voice' || key === 'chatvoice' || key === '3') {
|
||||
return { type: 'voice', duration: record.duration }
|
||||
}
|
||||
if (
|
||||
key === 'video' ||
|
||||
key === 'sight' ||
|
||||
key === 'tv' ||
|
||||
key === 'music_mv' ||
|
||||
key === '4'
|
||||
) {
|
||||
return {
|
||||
type: 'video',
|
||||
md5: record.md5,
|
||||
duration: record.duration,
|
||||
width: record.width,
|
||||
height: record.height
|
||||
}
|
||||
}
|
||||
if (key === 'loc' || key === 'location' || key === '6') {
|
||||
return {
|
||||
type: 'location',
|
||||
poiname: record.poiname || title,
|
||||
label: record.label || description,
|
||||
lat: record.lat ?? 0,
|
||||
lng: record.lng ?? 0
|
||||
}
|
||||
}
|
||||
if (key === 'weapp' || key === 'liteapp') {
|
||||
return {
|
||||
type: 'miniProgram',
|
||||
title,
|
||||
description,
|
||||
appName: record.appName || '小程序'
|
||||
}
|
||||
}
|
||||
if (key === 'record' || key === 'embeded_record') {
|
||||
return {
|
||||
type: 'forwardBundle',
|
||||
title: title || '聊天记录',
|
||||
description,
|
||||
items: []
|
||||
}
|
||||
}
|
||||
if (key === 'none' || key === '' || key === 'all') {
|
||||
return {
|
||||
type: 'system',
|
||||
content: `收藏 · ${label}`,
|
||||
raw: record.raw
|
||||
}
|
||||
}
|
||||
|
||||
// 网页 / 音乐 / 文件 / 商品 / 视频号 / 笔记 / 名片 / 合集 → 只读 share
|
||||
return {
|
||||
type: 'share',
|
||||
title,
|
||||
des: description,
|
||||
url: record.url || '',
|
||||
appname: record.appName || label,
|
||||
typeVal: key || 'favorite'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describeFavoriteType, favoriteRecordToContent } from '../../src/shared/favorites'
|
||||
|
||||
describe('favorites type map', () => {
|
||||
it('labels known MM_FAV names and numeric fallbacks', () => {
|
||||
expect(describeFavoriteType('TXT')).toBe('文字')
|
||||
expect(describeFavoriteType('IMG')).toBe('图片')
|
||||
expect(describeFavoriteType('FINDER_FEED')).toBe('视频号')
|
||||
expect(describeFavoriteType(2)).toBe('图片')
|
||||
expect(describeFavoriteType('99-custom')).toBe('99-custom')
|
||||
expect(describeFavoriteType(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps favorite records onto existing message cards', () => {
|
||||
expect(
|
||||
favoriteRecordToContent({ type: 'TXT', description: '收藏的文字' })
|
||||
).toEqual({ type: 'text', content: '收藏的文字' })
|
||||
expect(favoriteRecordToContent({ type: 'IMG', md5: 'ab'.repeat(16) })).toMatchObject({
|
||||
type: 'image',
|
||||
md5: 'ab'.repeat(16)
|
||||
})
|
||||
expect(favoriteRecordToContent({ type: 'VOICE', duration: 3 })).toEqual({
|
||||
type: 'voice',
|
||||
duration: 3
|
||||
})
|
||||
expect(
|
||||
favoriteRecordToContent({
|
||||
type: 'WEBPAGE',
|
||||
title: '文章',
|
||||
description: '摘要',
|
||||
url: 'https://example.com',
|
||||
appName: '公众号'
|
||||
})
|
||||
).toMatchObject({
|
||||
type: 'share',
|
||||
title: '文章',
|
||||
des: '摘要',
|
||||
url: 'https://example.com',
|
||||
appname: '公众号',
|
||||
typeVal: 'webpage'
|
||||
})
|
||||
expect(
|
||||
favoriteRecordToContent({ type: 'LOC', poiname: '公园', lat: 1.5, lng: 2.5 })
|
||||
).toMatchObject({
|
||||
type: 'location',
|
||||
poiname: '公园',
|
||||
lat: 1.5,
|
||||
lng: 2.5
|
||||
})
|
||||
expect(favoriteRecordToContent({ type: 'NOTE', title: '笔记' })).toMatchObject({
|
||||
type: 'share',
|
||||
typeVal: 'note'
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user