diff --git a/src/main/voice-service.ts b/src/main/voice-service.ts index 6ba99ba..8f7d7c8 100644 --- a/src/main/voice-service.ts +++ b/src/main/voice-service.ts @@ -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 { + 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 { + 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 +} diff --git a/src/shared/favorites.ts b/src/shared/favorites.ts new file mode 100644 index 0000000..444708c --- /dev/null +++ b/src/shared/favorites.ts @@ -0,0 +1,167 @@ +import type { ParsedContent } from './types' + +/** + * 收藏类型(`MM_FAV_ITEM_TYPE_*` / `MM_FAV_DATA_TYPE_*`)只读映射。 + * 名称来自 wechat.dylib 导出名;数字为枚举顺序推断,未知值原样回退。 + */ + +const FAV_TYPE_TEXT: Record = { + 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' + } +} diff --git a/tests/unit/favorites.test.ts b/tests/unit/favorites.test.ts new file mode 100644 index 0000000..cb83c44 --- /dev/null +++ b/tests/unit/favorites.test.ts @@ -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' + }) + }) +})