Files
WeFlow/electron/services/chatService.ts

2740 lines
95 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 { join, dirname, basename, extname } from 'path'
import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync, unlinkSync } from 'fs'
import * as path from 'path'
import * as fs from 'fs'
import * as https from 'https'
import * as http from 'http'
import * as fzstd from 'fzstd'
import * as crypto from 'crypto'
import Database from 'better-sqlite3'
import { execFile } from 'child_process'
import { promisify } from 'util'
import { app } from 'electron'
const execFileAsync = promisify(execFile)
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
import { MessageCacheService } from './messageCacheService'
import { ContactCacheService, ContactCacheEntry } from './contactCacheService'
import { voiceTranscribeService } from './voiceTranscribeService'
type HardlinkState = {
db: Database.Database
imageTable?: string
dirTable?: string
}
export interface ChatSession {
username: string
type: number
unreadCount: number
summary: string
sortTimestamp: number // 用于排序
lastTimestamp: number // 用于显示时间
lastMsgType: number
displayName?: string
avatarUrl?: string
}
export interface Message {
localId: number
serverId: number
localType: number
createTime: number
sortSeq: number
isSend: number | null
senderUsername: string | null
parsedContent: string
rawContent: string
// 表情包相关
emojiCdnUrl?: string
emojiMd5?: string
emojiLocalPath?: string // 本地缓存 castle 路径
// 引用消息相关
quotedContent?: string
quotedSender?: string
// 图片/视频相关
imageMd5?: string
imageDatName?: string
aesKey?: string
encrypVer?: number
cdnThumbUrl?: string
voiceDurationSeconds?: number
}
export interface Contact {
username: string
alias: string
remark: string
nickName: string
}
// 表情包缓存
const emojiCache: Map<string, string> = new Map()
const emojiDownloading: Map<string, Promise<string | null>> = new Map()
class ChatService {
private configService: ConfigService
private connected = false
private messageCursors: Map<string, { cursor: number; fetched: number; batchSize: number }> = new Map()
private readonly messageBatchDefault = 50
private avatarCache: Map<string, ContactCacheEntry>
private readonly avatarCacheTtlMs = 10 * 60 * 1000
private readonly defaultV1AesKey = 'cfcd208495d565ef'
private hardlinkCache = new Map<string, HardlinkState>()
private readonly contactCacheService: ContactCacheService
private readonly messageCacheService: MessageCacheService
private voiceWavCache = new Map<string, Buffer>()
private voiceTranscriptCache = new Map<string, string>()
private voiceTranscriptPending = new Map<string, Promise<{ success: boolean; transcript?: string; error?: string }>>()
private readonly voiceCacheMaxEntries = 50
constructor() {
this.configService = new ConfigService()
this.contactCacheService = new ContactCacheService(this.configService.get('cachePath'))
const persisted = this.contactCacheService.getAllEntries()
this.avatarCache = new Map(Object.entries(persisted))
this.messageCacheService = new MessageCacheService(this.configService.get('cachePath'))
}
/**
* 清理账号目录名
*/
private cleanAccountDirName(dirName: string): string {
const trimmed = dirName.trim()
if (!trimmed) return trimmed
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
if (match) return match[1]
return trimmed
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
if (suffixMatch) return suffixMatch[1]
return trimmed
}
/**
* 连接数据库
*/
async connect(): Promise<{ success: boolean; error?: string }> {
try {
if (this.connected && wcdbService.isReady()) {
return { success: true }
}
const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath')
const decryptKey = this.configService.get('decryptKey')
if (!wxid) {
return { success: false, error: '请先在设置页面配置微信ID' }
}
if (!dbPath) {
return { success: false, error: '请先在设置页面配置数据库路径' }
}
if (!decryptKey) {
return { success: false, error: '请先在设置页面配置解密密钥' }
}
const cleanedWxid = this.cleanAccountDirName(wxid)
const openOk = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
if (!openOk) {
return { success: false, error: 'WCDB 打开失败,请检查路径和密钥' }
}
this.connected = true
return { success: true }
} catch (e) {
console.error('ChatService: 连接数据库失败:', e)
return { success: false, error: String(e) }
}
}
private async ensureConnected(): Promise<{ success: boolean; error?: string }> {
if (this.connected && wcdbService.isReady()) {
return { success: true }
}
const result = await this.connect()
if (!result.success) {
this.connected = false
return { success: false, error: result.error }
}
return { success: true }
}
/**
* 关闭数据库连接
*/
close(): void {
try {
for (const state of this.messageCursors.values()) {
wcdbService.closeMessageCursor(state.cursor)
}
this.messageCursors.clear()
wcdbService.close()
} catch (e) {
console.error('ChatService: 关闭数据库失败:', e)
}
this.connected = false
}
/**
* 获取会话列表(优化:先返回基础数据,不等待联系人信息加载)
*/
async getSessions(): Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }> {
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) {
return { success: false, error: connectResult.error }
}
const result = await wcdbService.getSessions()
if (!result.success || !result.sessions) {
return { success: false, error: result.error || '获取会话失败' }
}
const rows = result.sessions as Record<string, any>[]
if (rows.length > 0 && (rows[0]._error || rows[0]._info)) {
const info = rows[0]
const detail = info._error || info._info
const tableInfo = info.table ? ` table=${info.table}` : ''
const tables = info.tables ? ` tables=${info.tables}` : ''
const columns = info.columns ? ` columns=${info.columns}` : ''
return { success: false, error: `会话表异常: ${detail}${tableInfo}${tables}${columns}` }
}
// 转换为 ChatSession先加载缓存但不等待数据库查询
const sessions: ChatSession[] = []
const now = Date.now()
for (const row of rows) {
const username =
row.username ||
row.user_name ||
row.userName ||
row.usrName ||
row.UsrName ||
row.talker ||
row.talker_id ||
row.talkerId ||
''
if (!this.shouldKeepSession(username)) continue
const sortTs = parseInt(
row.sort_timestamp ||
row.sortTimestamp ||
row.sort_time ||
row.sortTime ||
'0',
10
)
const lastTs = parseInt(
row.last_timestamp ||
row.lastTimestamp ||
row.last_msg_time ||
row.lastMsgTime ||
String(sortTs),
10
)
const summary = this.cleanString(row.summary || row.digest || row.last_msg || row.lastMsg || '')
const lastMsgType = parseInt(row.last_msg_type || row.lastMsgType || '0', 10)
// 先尝试从缓存获取联系人信息(快速路径)
let displayName = username
let avatarUrl: string | undefined = undefined
const cached = this.avatarCache.get(username)
if (cached) {
displayName = cached.displayName || username
avatarUrl = cached.avatarUrl
}
sessions.push({
username,
type: parseInt(row.type || '0', 10),
unreadCount: parseInt(row.unread_count || row.unreadCount || row.unreadcount || '0', 10),
summary: summary || this.getMessageTypeLabel(lastMsgType),
sortTimestamp: sortTs,
lastTimestamp: lastTs,
lastMsgType,
displayName,
avatarUrl
})
}
// 不等待联系人信息加载,直接返回基础会话列表
// 前端可以异步调用 enrichSessionsWithContacts 来补充信息
return { success: true, sessions }
} catch (e) {
console.error('ChatService: 获取会话列表失败:', e)
return { success: false, error: String(e) }
}
}
/**
* 异步补充会话列表的联系人信息(公开方法,供前端调用)
*/
async enrichSessionsContactInfo(usernames: string[]): Promise<{
success: boolean
contacts?: Record<string, { displayName?: string; avatarUrl?: string }>
error?: string
}> {
try {
if (usernames.length === 0) {
return { success: true, contacts: {} }
}
const connectResult = await this.ensureConnected()
if (!connectResult.success) {
return { success: false, error: connectResult.error }
}
const now = Date.now()
const missing: string[] = []
const result: Record<string, { displayName?: string; avatarUrl?: string }> = {}
const updatedEntries: Record<string, ContactCacheEntry> = {}
// 检查缓存
for (const username of usernames) {
const cached = this.avatarCache.get(username)
if (cached && now - cached.updatedAt < this.avatarCacheTtlMs) {
result[username] = {
displayName: cached.displayName,
avatarUrl: cached.avatarUrl
}
} else {
missing.push(username)
}
}
// 批量查询缺失的联系人信息
if (missing.length > 0) {
const [displayNames, avatarUrls] = await Promise.all([
wcdbService.getDisplayNames(missing),
wcdbService.getAvatarUrls(missing)
])
for (const username of missing) {
const displayName = displayNames.success && displayNames.map ? displayNames.map[username] : undefined
const avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[username] : undefined
const cacheEntry: ContactCacheEntry = {
displayName: displayName || username,
avatarUrl,
updatedAt: now
}
result[username] = { displayName, avatarUrl }
// 更新缓存并记录持久化
this.avatarCache.set(username, cacheEntry)
updatedEntries[username] = cacheEntry
}
if (Object.keys(updatedEntries).length > 0) {
this.contactCacheService.setEntries(updatedEntries)
}
}
return { success: true, contacts: result }
} catch (e) {
console.error('ChatService: 补充联系人信息失败:', e)
return { success: false, error: String(e) }
}
}
/**
* 补充联系人信息(私有方法,保持向后兼容)
*/
private async enrichSessionsWithContacts(sessions: ChatSession[]): Promise<void> {
if (sessions.length === 0) return
try {
const usernames = sessions.map(s => s.username)
const result = await this.enrichSessionsContactInfo(usernames)
if (result.success && result.contacts) {
for (const session of sessions) {
const contact = result.contacts![session.username]
if (contact) {
if (contact.displayName) session.displayName = contact.displayName
if (contact.avatarUrl) session.avatarUrl = contact.avatarUrl
}
}
}
} catch (e) {
console.error('ChatService: 获取联系人信息失败:', e)
}
}
/**
* 获取消息列表(支持跨多个数据库合并,已优化)
*/
async getMessages(
sessionId: string,
offset: number = 0,
limit: number = 50
): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> {
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) {
return { success: false, error: connectResult.error || '数据库未连接' }
}
const batchSize = Math.max(1, limit || this.messageBatchDefault)
let state = this.messageCursors.get(sessionId)
// 只在以下情况重新创建游标:
// 1. 没有游标状态
// 2. offset 为 0 (重新加载会话)
// 3. batchSize 改变
const needNewCursor = !state || offset === 0 || state.batchSize !== batchSize
if (needNewCursor) {
console.log(`[ChatService] 创建新游标: sessionId=${sessionId}, offset=${offset}, batchSize=${batchSize}`)
// 关闭旧游标
if (state) {
try {
await wcdbService.closeMessageCursor(state.cursor)
} catch (e) {
console.warn('[ChatService] 关闭旧游标失败:', e)
}
}
// 创建新游标
const cursorResult = await wcdbService.openMessageCursor(sessionId, batchSize, false, 0, 0)
if (!cursorResult.success || !cursorResult.cursor) {
console.error('[ChatService] 打开消息游标失败:', cursorResult.error)
return { success: false, error: cursorResult.error || '打开消息游标失败' }
}
state = { cursor: cursorResult.cursor, fetched: 0, batchSize }
this.messageCursors.set(sessionId, state)
// 如果需要跳过消息(offset > 0),逐批获取但不返回
if (offset > 0) {
console.log(`[ChatService] 跳过消息: offset=${offset}`)
let skipped = 0
while (skipped < offset) {
const skipBatch = await wcdbService.fetchMessageBatch(state.cursor)
if (!skipBatch.success) {
console.error('[ChatService] 跳过消息批次失败:', skipBatch.error)
return { success: false, error: skipBatch.error || '跳过消息失败' }
}
if (!skipBatch.rows || skipBatch.rows.length === 0) {
console.log('[ChatService] 跳过时没有更多消息')
return { success: true, messages: [], hasMore: false }
}
skipped += skipBatch.rows.length
state.fetched += skipBatch.rows.length
if (!skipBatch.hasMore) {
console.log('[ChatService] 跳过时已到达末尾')
return { success: true, messages: [], hasMore: false }
}
}
console.log(`[ChatService] 跳过完成: skipped=${skipped}, fetched=${state.fetched}`)
}
} else if (state && offset !== state.fetched) {
// offset 与 fetched 不匹配,说明状态不一致
console.warn(`[ChatService] 游标状态不一致: offset=${offset}, fetched=${state.fetched}, 继续使用现有游标`)
// 不重新创建游标,而是继续使用现有游标
// 这样可以避免频繁重建导致的问题
}
// 确保 state 已初始化
if (!state) {
console.error('[ChatService] 游标状态未初始化')
return { success: false, error: '游标状态未初始化' }
}
// 获取当前批次的消息
console.log(`[ChatService] 获取消息批次: cursor=${state.cursor}, fetched=${state.fetched}`)
const batch = await wcdbService.fetchMessageBatch(state.cursor)
if (!batch.success) {
console.error('[ChatService] 获取消息批次失败:', batch.error)
return { success: false, error: batch.error || '获取消息失败' }
}
if (!batch.rows) {
console.error('[ChatService] 获取消息失败: 返回数据为空')
return { success: false, error: '获取消息失败: 返回数据为空' }
}
const rows = batch.rows as Record<string, any>[]
const hasMore = batch.hasMore === true
const normalized = this.normalizeMessageOrder(this.mapRowsToMessages(rows))
// 并发检查并修复缺失 CDN URL 的表情包
const fixPromises: Promise<void>[] = []
for (const msg of normalized) {
if (msg.localType === 47 && !msg.emojiCdnUrl && msg.emojiMd5) {
fixPromises.push(this.fallbackEmoticon(msg))
}
}
if (fixPromises.length > 0) {
await Promise.allSettled(fixPromises)
}
state.fetched += rows.length
this.messageCacheService.set(sessionId, normalized)
return { success: true, messages: normalized, hasMore }
} catch (e) {
console.error('ChatService: 获取消息失败:', e)
return { success: false, error: String(e) }
}
}
async getCachedSessionMessages(sessionId: string): Promise<{ success: boolean; messages?: Message[]; error?: string }> {
try {
if (!sessionId) return { success: true, messages: [] }
const entry = this.messageCacheService.get(sessionId)
if (!entry || !Array.isArray(entry.messages)) {
return { success: true, messages: [] }
}
return { success: true, messages: entry.messages.slice() }
} catch (error) {
console.error('ChatService: 获取缓存消息失败:', error)
return { success: false, error: String(error) }
}
}
/**
* 尝试从 emoticon.db / emotion.db 恢复表情包 CDN URL
*/
private async fallbackEmoticon(msg: Message): Promise<void> {
if (!msg.emojiMd5) return
try {
const dbPath = await this.findInternalEmoticonDb()
if (!dbPath) {
console.warn(`[ChatService] 表情包数据库未找到,无法恢复: md5=${msg.emojiMd5}`)
return
}
const urlResult = await wcdbService.getEmoticonCdnUrl(dbPath, msg.emojiMd5)
if (!urlResult.success) {
console.warn(`[ChatService] 表情包数据库查询失败: md5=${msg.emojiMd5}, db=${dbPath}`, urlResult.error)
return
}
if (urlResult.url) {
msg.emojiCdnUrl = urlResult.url
return
}
console.warn(`[ChatService] 表情包数据库未命中: md5=${msg.emojiMd5}, db=${dbPath}`)
} catch (e) {
console.error(`[ChatService] 恢复表情包失败: md5=${msg.emojiMd5}`, e)
}
}
/**
* 查找 emoticon.db 路径
*/
private async findInternalEmoticonDb(): Promise<string | null> {
const myWxid = this.configService.get('myWxid')
const rootDbPath = this.configService.get('dbPath')
if (!myWxid || !rootDbPath) return null
const accountDir = this.resolveAccountDir(rootDbPath, myWxid)
if (!accountDir) return null
const candidates = [
// 1. 标准结构: root/wxid/db_storage/emoticon
join(rootDbPath, myWxid, 'db_storage', 'emoticon', 'emoticon.db'),
join(rootDbPath, myWxid, 'db_storage', 'emotion', 'emoticon.db'),
]
for (const p of candidates) {
if (existsSync(p)) return p
}
return null
}
async getLatestMessages(sessionId: string, limit: number = this.messageBatchDefault): Promise<{ success: boolean; messages?: Message[]; error?: string }> {
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) {
return { success: false, error: connectResult.error || '数据库未连接' }
}
const batchSize = Math.max(1, limit)
const cursorResult = await wcdbService.openMessageCursor(sessionId, batchSize, false, 0, 0)
if (!cursorResult.success || !cursorResult.cursor) {
return { success: false, error: cursorResult.error || '打开消息游标失败' }
}
try {
const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor)
if (!batch.success || !batch.rows) {
return { success: false, error: batch.error || '获取消息失败' }
}
const normalized = this.normalizeMessageOrder(this.mapRowsToMessages(batch.rows as Record<string, any>[]))
// 并发检查并修复缺失 CDN URL 的表情包
const fixPromises: Promise<void>[] = []
for (const msg of normalized) {
if (msg.localType === 47 && !msg.emojiCdnUrl && msg.emojiMd5) {
fixPromises.push(this.fallbackEmoticon(msg))
}
}
if (fixPromises.length > 0) {
await Promise.allSettled(fixPromises)
}
return { success: true, messages: normalized }
} finally {
await wcdbService.closeMessageCursor(cursorResult.cursor)
}
} catch (e) {
console.error('ChatService: 获取最新消息失败:', e)
return { success: false, error: String(e) }
}
}
private normalizeMessageOrder(messages: Message[]): Message[] {
if (messages.length < 2) return messages
const first = messages[0]
const last = messages[messages.length - 1]
const firstKey = first.sortSeq || first.createTime || first.localId || 0
const lastKey = last.sortSeq || last.createTime || last.localId || 0
if (firstKey > lastKey) {
return [...messages].reverse()
}
return messages
}
private getRowField(row: Record<string, any>, keys: string[]): any {
for (const key of keys) {
if (row[key] !== undefined && row[key] !== null) return row[key]
}
const lowerMap = new Map<string, string>()
for (const actual of Object.keys(row)) {
lowerMap.set(actual.toLowerCase(), actual)
}
for (const key of keys) {
const actual = lowerMap.get(key.toLowerCase())
if (actual && row[actual] !== undefined && row[actual] !== null) {
return row[actual]
}
}
return undefined
}
private getRowInt(row: Record<string, any>, keys: string[], fallback = 0): number {
const raw = this.getRowField(row, keys)
if (raw === undefined || raw === null || raw === '') return fallback
const parsed = this.coerceRowNumber(raw)
return Number.isFinite(parsed) ? parsed : fallback
}
private coerceRowNumber(raw: any): number {
if (raw === undefined || raw === null) return NaN
if (typeof raw === 'number') return raw
if (typeof raw === 'bigint') return Number(raw)
if (Buffer.isBuffer(raw)) {
return parseInt(raw.toString('utf-8'), 10)
}
if (raw instanceof Uint8Array) {
return parseInt(Buffer.from(raw).toString('utf-8'), 10)
}
if (Array.isArray(raw)) {
return parseInt(Buffer.from(raw).toString('utf-8'), 10)
}
if (typeof raw === 'object') {
if ('value' in raw) return this.coerceRowNumber(raw.value)
if ('intValue' in raw) return this.coerceRowNumber(raw.intValue)
if ('low' in raw && 'high' in raw) {
try {
const low = BigInt(raw.low >>> 0)
const high = BigInt(raw.high >>> 0)
return Number((high << 32n) + low)
} catch {
return NaN
}
}
const text = raw.toString ? String(raw) : ''
if (text && text !== '[object Object]') {
const parsed = parseInt(text, 10)
return Number.isFinite(parsed) ? parsed : NaN
}
return NaN
}
const parsed = parseInt(String(raw), 10)
return Number.isFinite(parsed) ? parsed : NaN
}
private mapRowsToMessages(rows: Record<string, any>[]): Message[] {
const myWxid = this.configService.get('myWxid')
const cleanedWxid = myWxid ? this.cleanAccountDirName(myWxid) : null
const myWxidLower = myWxid ? myWxid.toLowerCase() : null
const cleanedWxidLower = cleanedWxid ? cleanedWxid.toLowerCase() : null
const messages: Message[] = []
for (const row of rows) {
const rawMessageContent = this.getRowField(row, [
'message_content',
'messageContent',
'content',
'msg_content',
'msgContent',
'WCDB_CT_message_content',
'WCDB_CT_messageContent'
]);
const rawCompressContent = this.getRowField(row, [
'compress_content',
'compressContent',
'compressed_content',
'WCDB_CT_compress_content',
'WCDB_CT_compressContent'
]);
const content = this.decodeMessageContent(rawMessageContent, rawCompressContent);
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1)
const isSendRaw = this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'])
let isSend = isSendRaw === null ? null : parseInt(isSendRaw, 10)
const senderUsername = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || null
const createTime = this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0)
if (senderUsername && (myWxidLower || cleanedWxidLower)) {
const senderLower = String(senderUsername).toLowerCase()
const expectedIsSend = (senderLower === myWxidLower || senderLower === cleanedWxidLower) ? 1 : 0
if (isSend === null) {
isSend = expectedIsSend
// [DEBUG] Issue #34: 记录 isSend 推断过程
if (expectedIsSend === 0 && localType === 1) {
// 仅在被判为接收且是文本消息时记录,避免刷屏
// console.log(`[ChatService] inferred isSend=0: sender=${senderUsername}, myWxid=${myWxid} (cleaned=${cleanedWxid})`)
}
}
} else if (senderUsername && !myWxid) {
// [DEBUG] Issue #34: 未配置 myWxid无法判断是否发送
if (messages.length < 5) {
console.warn(`[ChatService] Warning: myWxid not set. Cannot determine if message is sent by me. sender=${senderUsername}`)
}
}
let emojiCdnUrl: string | undefined
let emojiMd5: string | undefined
let quotedContent: string | undefined
let quotedSender: string | undefined
let imageMd5: string | undefined
let imageDatName: string | undefined
let aesKey: string | undefined
let encrypVer: number | undefined
let cdnThumbUrl: string | undefined
let voiceDurationSeconds: number | undefined
if (localType === 47 && content) {
const emojiInfo = this.parseEmojiInfo(content)
emojiCdnUrl = emojiInfo.cdnUrl
emojiMd5 = emojiInfo.md5
} else if (localType === 3 && content) {
const imageInfo = this.parseImageInfo(content)
imageMd5 = imageInfo.md5
aesKey = imageInfo.aesKey
encrypVer = imageInfo.encrypVer
cdnThumbUrl = imageInfo.cdnThumbUrl
imageDatName = this.parseImageDatNameFromRow(row)
} else if (localType === 34 && content) {
voiceDurationSeconds = this.parseVoiceDurationSeconds(content)
} else if (localType === 244813135921 || (content && content.includes('<type>57</type>'))) {
const quoteInfo = this.parseQuoteMessage(content)
quotedContent = quoteInfo.content
quotedSender = quoteInfo.sender
}
messages.push({
localId: this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0),
serverId: this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0),
localType,
createTime,
sortSeq: this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], createTime),
isSend,
senderUsername,
parsedContent: this.parseMessageContent(content, localType),
rawContent: content,
emojiCdnUrl,
emojiMd5,
quotedContent,
quotedSender,
imageMd5,
imageDatName,
voiceDurationSeconds,
aesKey,
encrypVer,
cdnThumbUrl
})
const last = messages[messages.length - 1]
if ((last.localType === 3 || last.localType === 34) && (last.localId === 0 || last.createTime === 0)) {
console.warn('[ChatService] message key missing', {
localType: last.localType,
localId: last.localId,
createTime: last.createTime,
rowKeys: Object.keys(row)
})
}
}
return messages
}
/**
* 解析消息内容
*/
private parseMessageContent(content: string, localType: number): string {
if (!content) {
return this.getMessageTypeLabel(localType)
}
// 尝试解码 Buffer
if (Buffer.isBuffer(content)) {
content = content.toString('utf-8')
}
content = this.decodeHtmlEntities(content)
content = this.cleanUtf16(content)
// 检查 XML type用于识别引用消息等
const xmlType = this.extractXmlValue(content, 'type')
switch (localType) {
case 1:
return this.stripSenderPrefix(content)
case 3:
return '[图片]'
case 34:
return '[语音消息]'
case 42:
return '[名片]'
case 43:
return '[视频]'
case 47:
return '[动画表情]'
case 48:
return '[位置]'
case 49:
return this.parseType49(content)
case 50:
return this.parseVoipMessage(content)
case 10000:
return this.cleanSystemMessage(content)
case 244813135921:
// 引用消息,提取 title
const title = this.extractXmlValue(content, 'title')
return title || '[引用消息]'
case 266287972401:
return '[拍一拍]'
case 81604378673:
return '[聊天记录]'
case 8594229559345:
return '[红包]'
case 8589934592049:
return '[转账]'
default:
// 检查是否是 type=57 的引用消息
if (xmlType === '57') {
const title = this.extractXmlValue(content, 'title')
return title || '[引用消息]'
}
// 尝试从 XML 提取通用 title
const genericTitle = this.extractXmlValue(content, 'title')
if (genericTitle && genericTitle.length > 0 && genericTitle.length < 100) {
return genericTitle
}
if (content.length > 200) {
return this.getMessageTypeLabel(localType)
}
return this.stripSenderPrefix(content) || this.getMessageTypeLabel(localType)
}
}
private parseType49(content: string): string {
const title = this.extractXmlValue(content, 'title')
const type = this.extractXmlValue(content, 'type')
if (title) {
switch (type) {
case '5':
case '49':
return `[链接] ${title}`
case '6':
return `[文件] ${title}`
case '33':
case '36':
return `[小程序] ${title}`
case '57':
// 引用消息title 就是回复的内容
return title
default:
return title
}
}
return '[消息]'
}
/**
* 解析表情包信息
*/
private parseEmojiInfo(content: string): { cdnUrl?: string; md5?: string } {
try {
// 提取 cdnurl
let cdnUrl: string | undefined
const cdnUrlMatch = /cdnurl\s*=\s*['"]([^'"]+)['"]/i.exec(content) || /cdnurl\s*=\s*([^'"]+?)(?=\s|\/|>)/i.exec(content)
if (cdnUrlMatch) {
cdnUrl = cdnUrlMatch[1].replace(/&amp;/g, '&')
if (cdnUrl.includes('%')) {
try {
cdnUrl = decodeURIComponent(cdnUrl)
} catch { }
}
}
// 如果没有 cdnurl尝试 thumburl
if (!cdnUrl) {
const thumbUrlMatch = /thumburl\s*=\s*['"]([^'"]+)['"]/i.exec(content) || /thumburl\s*=\s*([^'"]+?)(?=\s|\/|>)/i.exec(content)
if (thumbUrlMatch) {
cdnUrl = thumbUrlMatch[1].replace(/&amp;/g, '&')
if (cdnUrl.includes('%')) {
try {
cdnUrl = decodeURIComponent(cdnUrl)
} catch { }
}
}
}
// 提取 md5
const md5Match = /md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) || /md5\s*=\s*([a-fA-F0-9]+)/i.exec(content)
const md5 = md5Match ? md5Match[1] : undefined
// 不构造假 URL只返回真正的 cdnurl
// 没有 cdnUrl 时保持静默,交由后续回退逻辑处理
return { cdnUrl, md5 }
} catch (e) {
console.error('[ChatService] 表情包解析失败:', e, { xml: content })
return {}
}
}
/**
* 解析图片信息
*/
private parseImageInfo(content: string): { md5?: string; aesKey?: string; encrypVer?: number; cdnThumbUrl?: string } {
try {
const md5 =
this.extractXmlValue(content, 'md5') ||
this.extractXmlAttribute(content, 'img', 'md5') ||
undefined
const aesKey = this.extractXmlAttribute(content, 'img', 'aeskey') || undefined
const encrypVerStr = this.extractXmlAttribute(content, 'img', 'encrypver') || undefined
const cdnThumbUrl = this.extractXmlAttribute(content, 'img', 'cdnthumburl') || undefined
return {
md5,
aesKey,
encrypVer: encrypVerStr ? parseInt(encrypVerStr, 10) : undefined,
cdnThumbUrl
}
} catch {
return {}
}
}
/**
* 解析通话消息
* 格式: <voipmsg type="VoIPBubbleMsg"><VoIPBubbleMsg><msg><![CDATA[...]]></msg><room_type>0/1</room_type>...</VoIPBubbleMsg></voipmsg>
* room_type: 0 = 语音通话, 1 = 视频通话
* msg 状态: 通话时长 XX:XX, 对方无应答, 已取消, 已在其它设备接听, 对方已拒绝 等
*/
private parseVoipMessage(content: string): string {
try {
if (!content) return '[通话]'
// 提取 msg 内容(中文通话状态)
const msgMatch = /<msg><!\[CDATA\[(.*?)\]\]><\/msg>/i.exec(content)
const msg = msgMatch?.[1]?.trim() || ''
// 提取 room_type0=视频1=语音)
const roomTypeMatch = /<room_type>(\d+)<\/room_type>/i.exec(content)
const roomType = roomTypeMatch ? parseInt(roomTypeMatch[1], 10) : -1
// 构建通话类型标签
let callType: string
if (roomType === 0) {
callType = '视频通话'
} else if (roomType === 1) {
callType = '语音通话'
} else {
callType = '通话'
}
// 解析通话状态
if (msg.includes('通话时长')) {
// 已接听的通话,提取时长
const durationMatch = /通话时长\s*(\d{1,2}:\d{2}(?::\d{2})?)/i.exec(msg)
const duration = durationMatch?.[1] || ''
if (duration) {
return `[${callType}] ${duration}`
}
return `[${callType}] 已接听`
} else if (msg.includes('对方无应答')) {
return `[${callType}] 对方无应答`
} else if (msg.includes('已取消')) {
return `[${callType}] 已取消`
} else if (msg.includes('已在其它设备接听') || msg.includes('已在其他设备接听')) {
return `[${callType}] 已在其他设备接听`
} else if (msg.includes('对方已拒绝') || msg.includes('已拒绝')) {
return `[${callType}] 对方已拒绝`
} else if (msg.includes('忙线未接听') || msg.includes('忙线')) {
return `[${callType}] 忙线未接听`
} else if (msg.includes('未接听')) {
return `[${callType}] 未接听`
} else if (msg) {
// 其他状态直接使用 msg 内容
return `[${callType}] ${msg}`
}
return `[${callType}]`
} catch (e) {
console.error('[ChatService] Failed to parse VOIP message:', e)
return '[通话]'
}
}
private parseImageDatNameFromRow(row: Record<string, any>): string | undefined {
const packed = this.getRowField(row, [
'packed_info_data',
'packed_info',
'packedInfoData',
'packedInfo',
'PackedInfoData',
'PackedInfo',
'WCDB_CT_packed_info_data',
'WCDB_CT_packed_info',
'WCDB_CT_PackedInfoData',
'WCDB_CT_PackedInfo'
])
const buffer = this.decodePackedInfo(packed)
if (!buffer || buffer.length === 0) return undefined
const printable: number[] = []
for (const byte of buffer) {
if (byte >= 0x20 && byte <= 0x7e) {
printable.push(byte)
} else {
printable.push(0x20)
}
}
const text = Buffer.from(printable).toString('utf-8')
const match = /([0-9a-fA-F]{8,})(?:\.t)?\.dat/.exec(text)
if (match?.[1]) return match[1].toLowerCase()
const hexMatch = /([0-9a-fA-F]{16,})/.exec(text)
return hexMatch?.[1]?.toLowerCase()
}
private decodePackedInfo(raw: any): Buffer | null {
if (!raw) return null
if (Buffer.isBuffer(raw)) return raw
if (raw instanceof Uint8Array) return Buffer.from(raw)
if (Array.isArray(raw)) return Buffer.from(raw)
if (typeof raw === 'string') {
const trimmed = raw.trim()
if (/^[a-fA-F0-9]+$/.test(trimmed) && trimmed.length % 2 === 0) {
try {
return Buffer.from(trimmed, 'hex')
} catch { }
}
try {
return Buffer.from(trimmed, 'base64')
} catch { }
}
if (typeof raw === 'object' && Array.isArray(raw.data)) {
return Buffer.from(raw.data)
}
return null
}
private parseVoiceDurationSeconds(content: string): number | undefined {
if (!content) return undefined
const match = /(voicelength|length|time|playlength)\s*=\s*['"]?([0-9]+(?:\.[0-9]+)?)['"]?/i.exec(content)
if (!match) return undefined
const raw = parseFloat(match[2])
if (!Number.isFinite(raw) || raw <= 0) return undefined
if (raw > 1000) return Math.round(raw / 1000)
return Math.round(raw)
}
/**
* 解析引用消息
*/
private parseQuoteMessage(content: string): { content?: string; sender?: string } {
try {
// 提取 refermsg 部分
const referMsgStart = content.indexOf('<refermsg>')
const referMsgEnd = content.indexOf('</refermsg>')
if (referMsgStart === -1 || referMsgEnd === -1) {
return {}
}
const referMsgXml = content.substring(referMsgStart, referMsgEnd + 11)
// 提取发送者名称
let displayName = this.extractXmlValue(referMsgXml, 'displayname')
// 过滤掉 wxid
if (displayName && this.looksLikeWxid(displayName)) {
displayName = ''
}
// 提取引用内容
const referContent = this.extractXmlValue(referMsgXml, 'content')
const referType = this.extractXmlValue(referMsgXml, 'type')
// 根据类型渲染引用内容
let displayContent = referContent
switch (referType) {
case '1':
// 文本消息,清理可能的 wxid
displayContent = this.sanitizeQuotedContent(referContent)
break
case '3':
displayContent = '[图片]'
break
case '34':
displayContent = '[语音]'
break
case '43':
displayContent = '[视频]'
break
case '47':
displayContent = '[动画表情]'
break
case '49':
displayContent = '[链接]'
break
case '42':
displayContent = '[名片]'
break
case '48':
displayContent = '[位置]'
break
default:
if (!referContent || referContent.includes('wxid_')) {
displayContent = '[消息]'
} else {
displayContent = this.sanitizeQuotedContent(referContent)
}
}
return {
content: displayContent,
sender: displayName || undefined
}
} catch {
return {}
}
}
//手动查找 media_*.db 文件(当 WCDB DLL 不支持 listMediaDbs 时的 fallback
private async findMediaDbsManually(): Promise<string[]> {
try {
const dbPath = this.configService.get('dbPath')
const myWxid = this.configService.get('myWxid')
if (!dbPath || !myWxid) return []
// 可能的目录结构:
// 1. dbPath 直接指向 db_storage: D:\weixin\WeChat Files\wxid_xxx\db_storage
// 2. dbPath 指向账号目录: D:\weixin\WeChat Files\wxid_xxx
// 3. dbPath 指向 WeChat Files: D:\weixin\WeChat Files
// 4. dbPath 指向微信根目录: D:\weixin
// 5. dbPath 指向非标准目录: D:\weixin\xwechat_files
const searchDirs: string[] = []
// 尝试1: dbPath 本身就是 db_storage
if (basename(dbPath).toLowerCase() === 'db_storage') {
searchDirs.push(dbPath)
}
// 尝试2: dbPath/db_storage
const dbStorage1 = join(dbPath, 'db_storage')
if (existsSync(dbStorage1)) {
searchDirs.push(dbStorage1)
}
// 尝试3: dbPath/WeChat Files/[wxid]/db_storage
const wechatFiles = join(dbPath, 'WeChat Files')
if (existsSync(wechatFiles)) {
const wxidDir = join(wechatFiles, myWxid)
if (existsSync(wxidDir)) {
const dbStorage2 = join(wxidDir, 'db_storage')
if (existsSync(dbStorage2)) {
searchDirs.push(dbStorage2)
}
}
}
// 尝试4: 如果 dbPath 已经包含 WeChat Files直接在其中查找
if (dbPath.includes('WeChat Files')) {
const parts = dbPath.split(path.sep)
const wechatFilesIndex = parts.findIndex(p => p === 'WeChat Files')
if (wechatFilesIndex >= 0) {
const wechatFilesPath = parts.slice(0, wechatFilesIndex + 1).join(path.sep)
const wxidDir = join(wechatFilesPath, myWxid)
if (existsSync(wxidDir)) {
const dbStorage3 = join(wxidDir, 'db_storage')
if (existsSync(dbStorage3) && !searchDirs.includes(dbStorage3)) {
searchDirs.push(dbStorage3)
}
}
}
}
// 尝试5: 直接尝试 dbPath/[wxid]/db_storage (适用于 xwechat_files 等非标准目录名)
const wxidDirDirect = join(dbPath, myWxid)
if (existsSync(wxidDirDirect)) {
const dbStorage5 = join(wxidDirDirect, 'db_storage')
if (existsSync(dbStorage5) && !searchDirs.includes(dbStorage5)) {
searchDirs.push(dbStorage5)
}
}
// 在所有可能的目录中查找 media_*.db
const mediaDbFiles: string[] = []
for (const dir of searchDirs) {
if (!existsSync(dir)) continue
// 直接在当前目录查找
const entries = readdirSync(dir)
for (const entry of entries) {
if (entry.toLowerCase().startsWith('media_') && entry.toLowerCase().endsWith('.db')) {
const fullPath = join(dir, entry)
if (existsSync(fullPath) && statSync(fullPath).isFile()) {
if (!mediaDbFiles.includes(fullPath)) {
mediaDbFiles.push(fullPath)
}
}
}
}
// 也检查子目录(特别是 message 子目录)
for (const entry of entries) {
const subDir = join(dir, entry)
if (existsSync(subDir) && statSync(subDir).isDirectory()) {
try {
const subEntries = readdirSync(subDir)
for (const subEntry of subEntries) {
if (subEntry.toLowerCase().startsWith('media_') && subEntry.toLowerCase().endsWith('.db')) {
const fullPath = join(subDir, subEntry)
if (existsSync(fullPath) && statSync(fullPath).isFile()) {
if (!mediaDbFiles.includes(fullPath)) {
mediaDbFiles.push(fullPath)
}
}
}
}
} catch (e) {
// 忽略无法访问的子目录
}
}
}
}
return mediaDbFiles
} catch (e) {
console.error('[ChatService] 手动查找 media 数据库失败:', e)
return []
}
}
private getVoiceLookupCandidates(sessionId: string, msg: Message): string[] {
const candidates: string[] = []
const add = (value?: string | null) => {
const trimmed = value?.trim()
if (!trimmed) return
if (!candidates.includes(trimmed)) candidates.push(trimmed)
}
add(sessionId)
add(msg.senderUsername)
add(this.configService.get('myWxid'))
return candidates
}
private async resolveChatNameId(dbPath: string, senderWxid: string): Promise<number | null> {
const escaped = this.escapeSqlString(senderWxid)
const name2IdTable = await this.resolveName2IdTableName(dbPath)
if (!name2IdTable) return null
const info = await wcdbService.execQuery('media', dbPath, `PRAGMA table_info('${name2IdTable}')`)
if (!info.success || !info.rows) return null
const columns = info.rows.map((row) => String(row.name || row.Name || row.column || '')).filter(Boolean)
const lower = new Map(columns.map((col) => [col.toLowerCase(), col]))
const column = lower.get('name_id') || lower.get('id') || 'rowid'
const sql = `SELECT ${column} AS id FROM ${name2IdTable} WHERE user_name = '${escaped}' LIMIT 1`
const result = await wcdbService.execQuery('media', dbPath, sql)
if (!result.success || !result.rows || result.rows.length === 0) return null
const value = result.rows[0]?.id
if (value === null || value === undefined) return null
const parsed = typeof value === 'number' ? value : parseInt(String(value), 10)
return Number.isFinite(parsed) ? parsed : null
}
private decodeVoiceBlob(raw: any): Buffer | null {
if (!raw) return null
if (Buffer.isBuffer(raw)) return raw
if (raw instanceof Uint8Array) return Buffer.from(raw)
if (Array.isArray(raw)) return Buffer.from(raw)
if (typeof raw === 'string') {
const trimmed = raw.trim()
if (/^[a-fA-F0-9]+$/.test(trimmed) && trimmed.length % 2 === 0) {
try {
return Buffer.from(trimmed, 'hex')
} catch { }
}
try {
return Buffer.from(trimmed, 'base64')
} catch { }
}
if (typeof raw === 'object' && Array.isArray(raw.data)) {
return Buffer.from(raw.data)
}
return null
}
private async resolveVoiceInfoColumns(dbPath: string, tableName: string): Promise<{
dataColumn: string;
chatNameIdColumn?: string;
createTimeColumn?: string;
msgLocalIdColumn?: string;
} | null> {
const info = await wcdbService.execQuery('media', dbPath, `PRAGMA table_info('${tableName}')`)
if (!info.success || !info.rows) return null
const columns = info.rows.map((row) => String(row.name || row.Name || row.column || '')).filter(Boolean)
if (columns.length === 0) return null
const lower = new Map(columns.map((col) => [col.toLowerCase(), col]))
const dataColumn =
lower.get('voice_data') ||
lower.get('buf') ||
lower.get('voicebuf') ||
lower.get('data')
if (!dataColumn) return null
return {
dataColumn,
chatNameIdColumn: lower.get('chat_name_id') || lower.get('chatnameid') || lower.get('chat_nameid'),
createTimeColumn: lower.get('create_time') || lower.get('createtime') || lower.get('time'),
msgLocalIdColumn: lower.get('msg_local_id') || lower.get('msglocalid') || lower.get('localid')
}
}
private escapeSqlString(value: string): string {
return value.replace(/'/g, "''")
}
private async resolveVoiceInfoTableName(dbPath: string): Promise<string | null> {
// 1. 优先尝试标准表名 'VoiceInfo'
const checkStandard = await wcdbService.execQuery(
'media',
dbPath,
"SELECT name FROM sqlite_master WHERE type='table' AND name='VoiceInfo'"
)
if (checkStandard.success && checkStandard.rows && checkStandard.rows.length > 0) {
return 'VoiceInfo'
}
// 2. 只有在找不到标准表时,才尝试模糊匹配 (兼容性)
const result = await wcdbService.execQuery(
'media',
dbPath,
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'VoiceInfo%' ORDER BY name DESC LIMIT 1"
)
if (!result.success || !result.rows || result.rows.length === 0) return null
return result.rows[0]?.name || null
}
private async resolveName2IdTableName(dbPath: string): Promise<string | null> {
const result = await wcdbService.execQuery(
'media',
dbPath,
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Name2Id%' ORDER BY name DESC LIMIT 1"
)
if (!result.success || !result.rows || result.rows.length === 0) return null
return result.rows[0]?.name || null
}
/**
* 判断是否像 wxid
*/
private looksLikeWxid(text: string): boolean {
if (!text) return false
const trimmed = text.trim().toLowerCase()
if (trimmed.startsWith('wxid_')) return true
return /^wx[a-z0-9_-]{4,}$/.test(trimmed)
}
/**
* 清理引用内容中的 wxid
*/
private sanitizeQuotedContent(content: string): string {
if (!content) return ''
let result = content
// 去掉 wxid_xxx
result = result.replace(/wxid_[A-Za-z0-9_-]{3,}/g, '')
// 去掉开头的分隔符
result = result.replace(/^[\s:\-]+/, '')
// 折叠重复分隔符
result = result.replace(/[:]{2,}/g, ':')
result = result.replace(/^[\s:\-]+/, '')
// 标准化空白
result = result.replace(/\s+/g, ' ').trim()
return result
}
private getMessageTypeLabel(localType: number): string {
const labels: Record<number, string> = {
1: '[文本]',
3: '[图片]',
34: '[语音]',
42: '[名片]',
43: '[视频]',
47: '[动画表情]',
48: '[位置]',
49: '[链接]',
50: '[通话]',
10000: '[系统消息]',
244813135921: '[引用消息]',
266287972401: '[拍一拍]',
81604378673: '[聊天记录]',
154618822705: '[小程序]',
8594229559345: '[红包]',
8589934592049: '[转账]',
34359738417: '[文件]',
103079215153: '[文件]',
25769803825: '[文件]'
}
return labels[localType] || '[消息]'
}
private extractXmlValue(xml: string, tagName: string): string {
const regex = new RegExp(`<${tagName}>([\\s\\S]*?)</${tagName}>`, 'i')
const match = regex.exec(xml)
if (match) {
return match[1].replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
}
return ''
}
private extractXmlAttribute(xml: string, tagName: string, attrName: string): string {
const tagRegex = new RegExp(`<${tagName}[^>]*>`, 'i')
const tagMatch = tagRegex.exec(xml)
if (!tagMatch) return ''
const attrRegex = new RegExp(`${attrName}\\s*=\\s*['"]([^'"]*)['"]`, 'i')
const attrMatch = attrRegex.exec(tagMatch[0])
return attrMatch ? attrMatch[1] : ''
}
private cleanSystemMessage(content: string): string {
return content
.replace(/<img[^>]*>/gi, '')
.replace(/<\/?[a-zA-Z0-9_]+[^>]*>/g, '')
.replace(/\s+/g, ' ')
.trim() || '[系统消息]'
}
private stripSenderPrefix(content: string): string {
return content.replace(/^[\s]*([a-zA-Z0-9_-]+):(?!\/\/)\s*/, '')
}
private decodeHtmlEntities(content: string): string {
return content
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
}
private cleanString(str: string): string {
if (!str) return ''
if (Buffer.isBuffer(str)) {
str = str.toString('utf-8')
}
return this.cleanUtf16(String(str))
}
private cleanUtf16(input: string): string {
if (!input) return input
try {
const cleaned = input.replace(/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F-\x9F]/g, '')
const codeUnits = cleaned.split('').map((c) => c.charCodeAt(0))
const validUnits: number[] = []
for (let i = 0; i < codeUnits.length; i += 1) {
const unit = codeUnits[i]
if (unit >= 0xd800 && unit <= 0xdbff) {
if (i + 1 < codeUnits.length) {
const nextUnit = codeUnits[i + 1]
if (nextUnit >= 0xdc00 && nextUnit <= 0xdfff) {
validUnits.push(unit, nextUnit)
i += 1
continue
}
}
continue
}
if (unit >= 0xdc00 && unit <= 0xdfff) {
continue
}
validUnits.push(unit)
}
return String.fromCharCode(...validUnits)
} catch {
return input.replace(/[^\u0020-\u007E\u4E00-\u9FFF\u3000-\u303F]/g, '')
}
}
/**
* 解码消息内容(处理 BLOB 和压缩数据)
*/
private decodeMessageContent(messageContent: any, compressContent: any): string {
// 优先使用 compress_content
let content = this.decodeMaybeCompressed(compressContent, 'compress_content')
if (!content || content.length === 0) {
content = this.decodeMaybeCompressed(messageContent, 'message_content')
}
return content
}
/**
* 尝试解码可能压缩的内容
*/
private decodeMaybeCompressed(raw: any, fieldName: string = 'unknown'): string {
if (!raw) return ''
// console.log(`[ChatService] Decoding ${fieldName}: type=${typeof raw}`, raw)
// 如果是 Buffer/Uint8Array
if (Buffer.isBuffer(raw) || raw instanceof Uint8Array) {
return this.decodeBinaryContent(Buffer.from(raw), String(raw))
}
// 如果是字符串
if (typeof raw === 'string') {
if (raw.length === 0) return ''
// 检查是否是 hex 编码
if (this.looksLikeHex(raw)) {
const bytes = Buffer.from(raw, 'hex')
if (bytes.length > 0) {
const result = this.decodeBinaryContent(bytes, raw)
// console.log(`[ChatService] HEX decoded result: ${result}`)
return result
}
}
// 检查是否是 base64 编码
if (this.looksLikeBase64(raw)) {
try {
const bytes = Buffer.from(raw, 'base64')
return this.decodeBinaryContent(bytes, raw)
} catch { }
}
// 普通字符串
return raw
}
return ''
}
/**
* 解码二进制内容(处理 zstd 压缩)
*/
private decodeBinaryContent(data: Buffer, fallbackValue?: string): string {
if (data.length === 0) return ''
try {
// 检查是否是 zstd 压缩数据 (magic number: 0xFD2FB528)
if (data.length >= 4) {
const magicLE = data.readUInt32LE(0)
const magicBE = data.readUInt32BE(0)
if (magicLE === 0xFD2FB528 || magicBE === 0xFD2FB528) {
// zstd 压缩,需要解压
try {
const decompressed = fzstd.decompress(data)
return Buffer.from(decompressed).toString('utf-8')
} catch (e) {
console.error('zstd 解压失败:', e)
}
}
}
// 尝试直接 UTF-8 解码
const decoded = data.toString('utf-8')
// 检查是否有太多替换字符
const replacementCount = (decoded.match(/\uFFFD/g) || []).length
if (replacementCount < decoded.length * 0.2) {
return decoded.replace(/\uFFFD/g, '')
}
// 如果提供了 fallbackValue且解码结果看起来像二进制垃圾则返回 fallbackValue
if (fallbackValue && replacementCount > 0) {
// console.log(`[ChatService] Binary garbage detected, using fallback: ${fallbackValue}`)
return fallbackValue
}
// 尝试 latin1 解码
return data.toString('latin1')
} catch {
return fallbackValue || ''
}
}
/**
* 检查是否像 hex 编码
*/
private looksLikeHex(s: string): boolean {
if (s.length % 2 !== 0) return false
return /^[0-9a-fA-F]+$/.test(s)
}
/**
* 检查是否像 base64 编码
*/
private looksLikeBase64(s: string): boolean {
if (s.length % 4 !== 0) return false
return /^[A-Za-z0-9+/=]+$/.test(s)
}
private shouldKeepSession(username: string): boolean {
if (!username) return false
const lowered = username.toLowerCase()
if (lowered.includes('@placeholder') || lowered.includes('foldgroup')) return false
if (username.startsWith('gh_')) return false
const excludeList = [
'weixin', 'qqmail', 'fmessage', 'medianote', 'floatbottle',
'newsapp', 'brandsessionholder', 'brandservicesessionholder',
'notifymessage', 'opencustomerservicemsg', 'notification_messages',
'userexperience_alarm', 'helper_folders', 'placeholder_foldgroup',
'@helper_folders', '@placeholder_foldgroup'
]
for (const prefix of excludeList) {
if (username.startsWith(prefix) || username === prefix) return false
}
if (username.includes('@kefu.openim') || username.includes('@openim')) return false
if (username.includes('service_')) return false
return true
}
async getContact(username: string): Promise<Contact | null> {
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) return null
const result = await wcdbService.getContact(username)
if (!result.success || !result.contact) return null
return {
username: result.contact.username || username,
alias: result.contact.alias || '',
remark: result.contact.remark || '',
nickName: result.contact.nickName || ''
}
} catch {
return null
}
}
/**
* 获取联系人头像和显示名称(用于群聊消息)
*/
async getContactAvatar(username: string): Promise<{ avatarUrl?: string; displayName?: string } | null> {
if (!username) return null
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) return null
const cached = this.avatarCache.get(username)
if (cached && cached.avatarUrl && Date.now() - cached.updatedAt < this.avatarCacheTtlMs) {
return { avatarUrl: cached.avatarUrl, displayName: cached.displayName }
}
const contact = await this.getContact(username)
const avatarResult = await wcdbService.getAvatarUrls([username])
const avatarUrl = avatarResult.success && avatarResult.map ? avatarResult.map[username] : undefined
const displayName = contact?.remark || contact?.nickName || contact?.alias || cached?.displayName || username
const cacheEntry: ContactCacheEntry = {
avatarUrl,
displayName,
updatedAt: Date.now()
}
this.avatarCache.set(username, cacheEntry)
this.contactCacheService.setEntries({ [username]: cacheEntry })
return { avatarUrl, displayName }
} catch {
return null
}
}
/**
* 获取当前用户的头像 URL
*/
async getMyAvatarUrl(): Promise<{ success: boolean; avatarUrl?: string; error?: string }> {
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) {
return { success: false, error: connectResult.error }
}
const myWxid = this.configService.get('myWxid')
if (!myWxid) {
return { success: false, error: '未配置微信ID' }
}
const cleanedWxid = this.cleanAccountDirName(myWxid)
// 增加 'self' 作为兜底标识符,微信有时将个人信息存储在 'self' 记录中
const fetchList = Array.from(new Set([myWxid, cleanedWxid, 'self']))
console.log(`[ChatService] 尝试获取个人头像, wxids: ${JSON.stringify(fetchList)}`)
const result = await wcdbService.getAvatarUrls(fetchList)
if (result.success && result.map) {
// 按优先级尝试匹配
const avatarUrl = result.map[myWxid] || result.map[cleanedWxid] || result.map['self']
if (avatarUrl) {
console.log(`[ChatService] 成功获取个人头像: ${avatarUrl.substring(0, 50)}...`)
return { success: true, avatarUrl }
}
console.warn(`[ChatService] 未能在 contact.db 中找到个人头像, 请求列表: ${JSON.stringify(fetchList)}`)
return { success: true, avatarUrl: undefined }
}
console.error(`[ChatService] 查询个人头像失败: ${result.error || '未知错误'}`)
return { success: true, avatarUrl: undefined }
} catch (e) {
console.error('ChatService: 获取当前用户头像失败:', e)
return { success: false, error: String(e) }
}
}
/**
* 获取表情包缓存目录
*/
private getEmojiCacheDir(): string {
const cachePath = this.configService.get('cachePath')
if (cachePath) {
return join(cachePath, 'Emojis')
}
// 回退到默认目录
const documentsPath = app.getPath('documents')
return join(documentsPath, 'WeFlow', 'Emojis')
}
clearCaches(options?: { includeMessages?: boolean; includeContacts?: boolean; includeEmojis?: boolean }): { success: boolean; error?: string } {
const includeMessages = options?.includeMessages !== false
const includeContacts = options?.includeContacts !== false
const includeEmojis = options?.includeEmojis !== false
const errors: string[] = []
if (includeContacts) {
this.avatarCache.clear()
this.contactCacheService.clear()
}
if (includeMessages) {
this.messageCacheService.clear()
this.voiceWavCache.clear()
this.voiceTranscriptCache.clear()
this.voiceTranscriptPending.clear()
}
for (const state of this.hardlinkCache.values()) {
try {
state.db?.close()
} catch { }
}
this.hardlinkCache.clear()
if (includeEmojis) {
emojiCache.clear()
emojiDownloading.clear()
const emojiDir = this.getEmojiCacheDir()
try {
fs.rmSync(emojiDir, { recursive: true, force: true })
} catch (error) {
errors.push(String(error))
}
}
if (errors.length > 0) {
return { success: false, error: errors.join('; ') }
}
return { success: true }
}
/**
* 下载并缓存表情包
*/
async downloadEmoji(cdnUrl: string, md5?: string): Promise<{ success: boolean; localPath?: string; error?: string }> {
if (!cdnUrl) {
return { success: false, error: '无效的 CDN URL' }
}
// 生成缓存 key
const cacheKey = md5 || this.hashString(cdnUrl)
// 检查内存缓存
const cached = emojiCache.get(cacheKey)
if (cached && existsSync(cached)) {
// 读取文件并转为 data URL
const dataUrl = this.fileToDataUrl(cached)
if (dataUrl) {
return { success: true, localPath: dataUrl }
}
}
// 检查是否正在下载
const downloading = emojiDownloading.get(cacheKey)
if (downloading) {
const result = await downloading
if (result) {
const dataUrl = this.fileToDataUrl(result)
if (dataUrl) {
return { success: true, localPath: dataUrl }
}
}
return { success: false, error: '下载失败' }
}
// 确保缓存目录存在
const cacheDir = this.getEmojiCacheDir()
if (!existsSync(cacheDir)) {
mkdirSync(cacheDir, { recursive: true })
}
// 检查本地是否已有缓存文件
const extensions = ['.gif', '.png', '.webp', '.jpg', '.jpeg']
for (const ext of extensions) {
const filePath = join(cacheDir, `${cacheKey}${ext}`)
if (existsSync(filePath)) {
emojiCache.set(cacheKey, filePath)
const dataUrl = this.fileToDataUrl(filePath)
if (dataUrl) {
return { success: true, localPath: dataUrl }
}
}
}
// 开始下载
const downloadPromise = this.doDownloadEmoji(cdnUrl, cacheKey, cacheDir)
emojiDownloading.set(cacheKey, downloadPromise)
try {
const localPath = await downloadPromise
emojiDownloading.delete(cacheKey)
if (localPath) {
emojiCache.set(cacheKey, localPath)
const dataUrl = this.fileToDataUrl(localPath)
if (dataUrl) {
return { success: true, localPath: dataUrl }
}
}
return { success: false, error: '下载失败' }
} catch (e) {
console.error(`[ChatService] 表情包下载异常: url=${cdnUrl}, md5=${md5}`, e)
emojiDownloading.delete(cacheKey)
return { success: false, error: String(e) }
}
}
/**
* 将文件转为 data URL
*/
private fileToDataUrl(filePath: string): string | null {
try {
const ext = extname(filePath).toLowerCase()
const mimeTypes: Record<string, string> = {
'.gif': 'image/gif',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp'
}
const mimeType = mimeTypes[ext] || 'image/gif'
const data = readFileSync(filePath)
return `data:${mimeType};base64,${data.toString('base64')}`
} catch {
return null
}
}
/**
* 执行表情包下载
*/
private doDownloadEmoji(url: string, cacheKey: string, cacheDir: string): Promise<string | null> {
return new Promise((resolve) => {
const protocol = url.startsWith('https') ? https : http
const request = protocol.get(url, (response) => {
// 处理重定向
if (response.statusCode === 301 || response.statusCode === 302) {
const redirectUrl = response.headers.location
if (redirectUrl) {
this.doDownloadEmoji(redirectUrl, cacheKey, cacheDir).then(resolve)
return
}
}
if (response.statusCode !== 200) {
resolve(null)
return
}
const chunks: Buffer[] = []
response.on('data', (chunk) => chunks.push(chunk))
response.on('end', () => {
const buffer = Buffer.concat(chunks)
if (buffer.length === 0) {
resolve(null)
return
}
// 检测文件类型
const ext = this.detectImageExtension(buffer) || this.getExtFromUrl(url) || '.gif'
const filePath = join(cacheDir, `${cacheKey}${ext}`)
try {
writeFileSync(filePath, buffer)
resolve(filePath)
} catch {
resolve(null)
}
})
response.on('error', () => resolve(null))
})
request.on('error', () => resolve(null))
request.setTimeout(10000, () => {
request.destroy()
resolve(null)
})
})
}
/**
* 检测图片格式
*/
private detectImageExtension(buffer: Buffer): string | null {
if (buffer.length < 12) return null
// GIF
if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) {
return '.gif'
}
// PNG
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) {
return '.png'
}
// JPEG
if (buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) {
return '.jpg'
}
// WEBP
if (buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 &&
buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) {
return '.webp'
}
return null
}
/**
* 从 URL 获取扩展名
*/
private getExtFromUrl(url: string): string | null {
try {
const pathname = new URL(url).pathname
const ext = extname(pathname).toLowerCase()
if (['.gif', '.png', '.jpg', '.jpeg', '.webp'].includes(ext)) {
return ext
}
} catch { }
return null
}
/**
* 简单的字符串哈希
*/
private hashString(str: string): string {
let hash = 0
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i)
hash = ((hash << 5) - hash) + char
hash = hash & hash
}
return Math.abs(hash).toString(16)
}
/**
* 获取会话详情信息
*/
async getSessionDetail(sessionId: string): Promise<{
success: boolean
detail?: {
wxid: string
displayName: string
remark?: string
nickName?: string
alias?: string
avatarUrl?: string
messageCount: number
firstMessageTime?: number
latestMessageTime?: number
messageTables: { dbName: string; tableName: string; count: number }[]
}
error?: string
}> {
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) {
return { success: false, error: connectResult.error || '数据库未连接' }
}
let displayName = sessionId
let remark: string | undefined
let nickName: string | undefined
let alias: string | undefined
let avatarUrl: string | undefined
const contactResult = await wcdbService.getContact(sessionId)
if (contactResult.success && contactResult.contact) {
remark = contactResult.contact.remark || undefined
nickName = contactResult.contact.nickName || undefined
alias = contactResult.contact.alias || undefined
displayName = remark || nickName || alias || sessionId
}
const avatarResult = await wcdbService.getAvatarUrls([sessionId])
if (avatarResult.success && avatarResult.map) {
avatarUrl = avatarResult.map[sessionId]
}
const countResult = await wcdbService.getMessageCount(sessionId)
const totalMessageCount = countResult.success && countResult.count ? countResult.count : 0
let firstMessageTime: number | undefined
let latestMessageTime: number | undefined
const earliestCursor = await wcdbService.openMessageCursor(sessionId, 1, true, 0, 0)
if (earliestCursor.success && earliestCursor.cursor) {
const batch = await wcdbService.fetchMessageBatch(earliestCursor.cursor)
if (batch.success && batch.rows && batch.rows.length > 0) {
firstMessageTime = parseInt(batch.rows[0].create_time || '0', 10) || undefined
}
await wcdbService.closeMessageCursor(earliestCursor.cursor)
}
const latestCursor = await wcdbService.openMessageCursor(sessionId, 1, false, 0, 0)
if (latestCursor.success && latestCursor.cursor) {
const batch = await wcdbService.fetchMessageBatch(latestCursor.cursor)
if (batch.success && batch.rows && batch.rows.length > 0) {
latestMessageTime = parseInt(batch.rows[0].create_time || '0', 10) || undefined
}
await wcdbService.closeMessageCursor(latestCursor.cursor)
}
const messageTables: { dbName: string; tableName: string; count: number }[] = []
const tableStats = await wcdbService.getMessageTableStats(sessionId)
if (tableStats.success && tableStats.tables) {
for (const row of tableStats.tables) {
messageTables.push({
dbName: basename(row.db_path || ''),
tableName: row.table_name || '',
count: parseInt(row.count || '0', 10)
})
}
}
return {
success: true,
detail: {
wxid: sessionId,
displayName,
remark,
nickName,
alias,
avatarUrl,
messageCount: totalMessageCount,
firstMessageTime,
latestMessageTime,
messageTables
}
}
} catch (e) {
console.error('ChatService: 获取会话详情失败:', e)
return { success: false, error: String(e) }
}
}
/**
* 获取图片数据(解密后的)
*/
async getImageData(sessionId: string, msgId: string): Promise<{ success: boolean; data?: string; error?: string }> {
try {
const localId = parseInt(msgId, 10)
if (!this.connected) await this.connect()
// 1. 获取消息详情以拿到 MD5 和 AES Key
const msgResult = await this.getMessageByLocalId(sessionId, localId)
if (!msgResult.success || !msgResult.message) {
return { success: false, error: '未找到消息' }
}
const msg = msgResult.message
console.info('[ChatService][Image] request', {
sessionId,
localId: msg.localId,
imageMd5: msg.imageMd5,
imageDatName: msg.imageDatName
})
// 2. 确定搜索的基础名
const baseName = msg.imageMd5 || msg.imageDatName || String(msg.localId)
// 3. 查找 .dat 文件
const myWxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath')
if (!myWxid || !dbPath) return { success: false, error: '配置缺失' }
const accountDir = dirname(dirname(dbPath)) // dbPath 是 db_storage 里面的路径或同级
// 实际上 dbPath 指向 db_storageaccountDir 应该是其父目录
const actualAccountDir = this.resolveAccountDir(dbPath, myWxid)
if (!actualAccountDir) return { success: false, error: '无法定位账号目录' }
const datPath = await this.findDatFile(actualAccountDir, baseName, sessionId)
if (!datPath) return { success: false, error: '未找到图片源文件 (.dat)' }
console.info('[ChatService][Image] dat path', datPath)
// 4. 获取解密密钥
const xorKeyRaw = this.configService.get('imageXorKey')
const aesKeyRaw = this.configService.get('imageAesKey') || msg.aesKey
if (!xorKeyRaw) return { success: false, error: '未配置图片 XOR 密钥,请在设置中自动获取' }
const xorKey = this.parseXorKey(xorKeyRaw)
const data = readFileSync(datPath)
// 5. 解密
let decrypted: Buffer
const version = this.getDatVersion(data)
if (version === 0) {
decrypted = this.decryptDatV3(data, xorKey)
} else if (version === 1) {
const aesKey = this.asciiKey16(this.defaultV1AesKey)
decrypted = this.decryptDatV4(data, xorKey, aesKey)
} else {
const trimmed = String(aesKeyRaw ?? '').trim()
if (!trimmed || trimmed.length < 16) {
return { success: false, error: 'V4版本需要16字节AES密钥' }
}
const aesKey = this.asciiKey16(trimmed)
decrypted = this.decryptDatV4(data, xorKey, aesKey)
}
console.info('[ChatService][Image] decrypted bytes', decrypted.length)
// 返回 base64
return { success: true, data: decrypted.toString('base64') }
} catch (e) {
console.error('ChatService: getImageData 失败:', e)
return { success: false, error: String(e) }
}
}
async getVoiceData(sessionId: string, msgId: string): Promise<{ success: boolean; data?: string; error?: string }> {
try {
const localId = parseInt(msgId, 10)
const msgResult = await this.getMessageByLocalId(sessionId, localId)
if (!msgResult.success || !msgResult.message) return { success: false, error: '未找到该消息' }
const msg = msgResult.message
if (msg.isSend === 1) {
console.info('[ChatService][Voice] self-sent voice, continue decrypt flow')
}
const candidates = this.getVoiceLookupCandidates(sessionId, msg)
if (candidates.length === 0) {
return { success: false, error: '未找到语音关联账号' }
}
console.info('[ChatService][Voice] request', {
sessionId,
localId: msg.localId,
createTime: msg.createTime,
candidates
})
// 2. 查找所有的 media_*.db
let mediaDbs = await wcdbService.listMediaDbs()
// Fallback: 如果 WCDB DLL 不支持 listMediaDbs手动查找
if (!mediaDbs.success || !mediaDbs.data || mediaDbs.data.length === 0) {
const manualMediaDbs = await this.findMediaDbsManually()
if (manualMediaDbs.length > 0) {
mediaDbs = { success: true, data: manualMediaDbs }
} else {
return { success: false, error: '未找到媒体库文件 (media_*.db)' }
}
}
// 3. 在所有媒体库中查找该消息的语音数据
let silkData: Buffer | null = null
for (const dbPath of (mediaDbs.data || [])) {
const voiceTable = await this.resolveVoiceInfoTableName(dbPath)
if (!voiceTable) {
console.warn('[ChatService][Voice] voice table not found', dbPath)
continue
}
const columns = await this.resolveVoiceInfoColumns(dbPath, voiceTable)
if (!columns) {
console.warn('[ChatService][Voice] voice columns not found', { dbPath, voiceTable })
continue
}
for (const candidate of candidates) {
const chatNameId = await this.resolveChatNameId(dbPath, candidate)
// 策略 1: 使用 ChatNameId + CreateTime (最准确)
if (chatNameId) {
let whereClause = ''
if (columns.chatNameIdColumn && columns.createTimeColumn) {
whereClause = `${columns.chatNameIdColumn} = ${chatNameId} AND ${columns.createTimeColumn} = ${msg.createTime}`
const sql = `SELECT ${columns.dataColumn} AS data FROM ${voiceTable} WHERE ${whereClause} LIMIT 1`
const result = await wcdbService.execQuery('media', dbPath, sql)
if (result.success && result.rows && result.rows.length > 0) {
const raw = result.rows[0]?.data
const decoded = this.decodeVoiceBlob(raw)
if (decoded && decoded.length > 0) {
console.info('[ChatService][Voice] hit by createTime', { dbPath, voiceTable, whereClause, bytes: decoded.length })
silkData = decoded
break
}
}
}
}
// 策略 2: 使用 MsgLocalId (兜底,如果表支持)
if (columns.msgLocalIdColumn) {
const whereClause = `${columns.msgLocalIdColumn} = ${msg.localId}`
const sql = `SELECT ${columns.dataColumn} AS data FROM ${voiceTable} WHERE ${whereClause} LIMIT 1`
const result = await wcdbService.execQuery('media', dbPath, sql)
if (result.success && result.rows && result.rows.length > 0) {
const raw = result.rows[0]?.data
const decoded = this.decodeVoiceBlob(raw)
if (decoded && decoded.length > 0) {
console.info('[ChatService][Voice] hit by localId', { dbPath, voiceTable, whereClause, bytes: decoded.length })
silkData = decoded
break
}
}
}
}
if (silkData) break
}
if (!silkData) return { success: false, error: '未找到语音数据' }
// 4. 解码 Silk -> PCM -> WAV
const resourcesPath = app.isPackaged
? join(process.resourcesPath, 'resources')
: join(app.getAppPath(), 'resources')
const decoderPath = join(resourcesPath, 'silk_v3_decoder.exe')
if (!existsSync(decoderPath)) {
return { success: false, error: '找不到语音解码器 (silk_v3_decoder.exe)' }
}
console.info('[ChatService][Voice] decoder path', decoderPath)
const tempDir = app.getPath('temp')
const silkFile = join(tempDir, `voice_${msgId}.silk`)
const pcmFile = join(tempDir, `voice_${msgId}.pcm`)
try {
writeFileSync(silkFile, silkData)
// 执行解码: silk_v3_decoder.exe <silk> <pcm> -Fs_API 24000
console.info('[ChatService][Voice] executing decoder:', decoderPath, [silkFile, pcmFile])
const { stdout, stderr } = await execFileAsync(
decoderPath,
[silkFile, pcmFile, '-Fs_API', '24000'],
{ cwd: dirname(decoderPath) }
)
if (stdout && stdout.trim()) console.info('[ChatService][Voice] decoder stdout:', stdout)
if (stderr && stderr.trim()) console.warn('[ChatService][Voice] decoder stderr:', stderr)
if (!existsSync(pcmFile)) {
return { success: false, error: '语音解码失败' }
}
const pcmData = readFileSync(pcmFile)
const wavHeader = this.createWavHeader(pcmData.length, 24000, 1) // 微信语音通常 24kHz
const wavData = Buffer.concat([wavHeader, pcmData])
const cacheKey = this.getVoiceCacheKey(sessionId, msgId)
this.cacheVoiceWav(cacheKey, wavData)
return { success: true, data: wavData.toString('base64') }
} finally {
// 清理临时文件
try { if (existsSync(silkFile)) unlinkSync(silkFile) } catch { }
try { if (existsSync(pcmFile)) unlinkSync(pcmFile) } catch { }
}
} catch (e) {
console.error('ChatService: getVoiceData 失败:', e)
return { success: false, error: String(e) }
}
}
async getVoiceTranscript(sessionId: string, msgId: string): Promise<{ success: boolean; transcript?: string; error?: string }> {
const cacheKey = this.getVoiceCacheKey(sessionId, msgId)
const cached = this.voiceTranscriptCache.get(cacheKey)
if (cached) {
return { success: true, transcript: cached }
}
const pending = this.voiceTranscriptPending.get(cacheKey)
if (pending) {
return pending
}
const task = (async () => {
try {
let wavData = this.voiceWavCache.get(cacheKey)
if (!wavData) {
const voiceResult = await this.getVoiceData(sessionId, msgId)
if (!voiceResult.success || !voiceResult.data) {
return { success: false, error: voiceResult.error || '语音解码失败' }
}
wavData = Buffer.from(voiceResult.data, 'base64')
}
const result = await voiceTranscribeService.transcribeWavBuffer(wavData)
if (result.success && result.transcript) {
this.cacheVoiceTranscript(cacheKey, result.transcript)
}
return result
} catch (error) {
return { success: false, error: String(error) }
} finally {
this.voiceTranscriptPending.delete(cacheKey)
}
})()
this.voiceTranscriptPending.set(cacheKey, task)
return task
}
private createWavHeader(pcmLength: number, sampleRate: number = 24000, channels: number = 1): Buffer {
const header = Buffer.alloc(44)
header.write('RIFF', 0)
header.writeUInt32LE(36 + pcmLength, 4)
header.write('WAVE', 8)
header.write('fmt ', 12)
header.writeUInt32LE(16, 16)
header.writeUInt16LE(1, 20)
header.writeUInt16LE(channels, 22)
header.writeUInt32LE(sampleRate, 24)
header.writeUInt32LE(sampleRate * channels * 2, 28)
header.writeUInt16LE(channels * 2, 32)
header.writeUInt16LE(16, 34)
header.write('data', 36)
header.writeUInt32LE(pcmLength, 40)
return header
}
private getVoiceCacheKey(sessionId: string, msgId: string): string {
return `${sessionId}:${msgId}`
}
private cacheVoiceWav(cacheKey: string, wavData: Buffer): void {
this.voiceWavCache.set(cacheKey, wavData)
if (this.voiceWavCache.size > this.voiceCacheMaxEntries) {
const oldestKey = this.voiceWavCache.keys().next().value
if (oldestKey) this.voiceWavCache.delete(oldestKey)
}
}
private cacheVoiceTranscript(cacheKey: string, transcript: string): void {
this.voiceTranscriptCache.set(cacheKey, transcript)
if (this.voiceTranscriptCache.size > this.voiceCacheMaxEntries) {
const oldestKey = this.voiceTranscriptCache.keys().next().value
if (oldestKey) this.voiceTranscriptCache.delete(oldestKey)
}
}
async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> {
try {
console.info('[ChatService] getMessageById (SQL)', { sessionId, localId })
// 1. 获取该会话所在的消息表
// 注意:这里使用 getMessageTableStats 而不是 getMessageTables因为前者包含 db_path
const tableStats = await wcdbService.getMessageTableStats(sessionId)
if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) {
return { success: false, error: '未找到会话消息表' }
}
// 2. 遍历表查找消息 (通常只有一个主表,但可能有归档)
for (const tableInfo of tableStats.tables) {
const tableName = tableInfo.table_name || tableInfo.name
const dbPath = tableInfo.db_path
if (!tableName || !dbPath) continue
// 构造查询
const sql = `SELECT * FROM ${tableName} WHERE local_id = ${localId} LIMIT 1`
const result = await wcdbService.execQuery('message', dbPath, sql)
if (result.success && result.rows && result.rows.length > 0) {
const row = result.rows[0]
const message = this.parseMessage(row)
if (message.localId !== 0) {
console.info('[ChatService] getMessageById hit', { tableName, localId: message.localId })
return { success: true, message }
}
}
}
return { success: false, error: '未找到消息' }
} catch (e) {
console.error('ChatService: getMessageById 失败:', e)
return { success: false, error: String(e) }
}
}
private parseMessage(row: any): Message {
const rawContent = this.decodeMessageContent(
this.getRowField(row, [
'message_content',
'messageContent',
'content',
'msg_content',
'msgContent',
'WCDB_CT_message_content',
'WCDB_CT_messageContent'
]),
this.getRowField(row, [
'compress_content',
'compressContent',
'compressed_content',
'WCDB_CT_compress_content',
'WCDB_CT_compressContent'
])
)
// 这里复用 parseMessagesBatch 里面的解析逻辑,为了简单我这里先写个基础的
// 实际项目中建议抽取 parseRawMessage(row) 供多处使用
const msg: Message = {
localId: this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0),
serverId: this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0),
localType: this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0),
createTime: this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0),
sortSeq: this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0)),
isSend: this.getRowInt(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'], 0),
senderUsername: this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || null,
rawContent: rawContent,
parsedContent: this.parseMessageContent(rawContent, this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0))
}
if (msg.localId === 0 || msg.createTime === 0) {
const rawLocalId = this.getRowField(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'])
const rawCreateTime = this.getRowField(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'])
console.warn('[ChatService] parseMessage raw keys', {
rawLocalId,
rawLocalIdType: rawLocalId ? typeof rawLocalId : 'null',
val_local_id: row['local_id'],
val_create_time: row['create_time'],
rawCreateTime,
rawCreateTimeType: rawCreateTime ? typeof rawCreateTime : 'null'
})
}
// 图片/语音解析逻辑 (简化示例,实际应调用现有解析方法)
if (msg.localType === 3) { // Image
const imgInfo = this.parseImageInfo(rawContent)
Object.assign(msg, imgInfo)
msg.imageDatName = this.parseImageDatNameFromRow(row)
}
return msg
}
private async getMessageByLocalId(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> {
return this.getMessageById(sessionId, localId)
}
private resolveAccountDir(dbPath: string, wxid: string): string | null {
const normalized = dbPath.replace(/[\\\\/]+$/, '')
const dir = dirname(normalized)
if (basename(normalized).toLowerCase() === 'db_storage') return dir
if (basename(dir).toLowerCase() === 'db_storage') return dirname(dir)
return dir // 兜底
}
private async findDatFile(accountDir: string, baseName: string, sessionId?: string): Promise<string | null> {
const normalized = this.normalizeDatBase(baseName)
if (this.looksLikeMd5(normalized)) {
const hardlinkPath = this.resolveHardlinkPath(accountDir, normalized, sessionId)
if (hardlinkPath) return hardlinkPath
}
const searchPaths = [
join(accountDir, 'FileStorage', 'Image'),
join(accountDir, 'FileStorage', 'Image2'),
join(accountDir, 'FileStorage', 'MsgImg'),
join(accountDir, 'FileStorage', 'Video')
]
for (const searchPath of searchPaths) {
if (!existsSync(searchPath)) continue
const found = this.recursiveSearch(searchPath, baseName.toLowerCase(), 3)
if (found) return found
}
return null
}
private recursiveSearch(dir: string, pattern: string, maxDepth: number): string | null {
if (maxDepth < 0) return null
try {
const entries = readdirSync(dir)
// 优先匹配当前目录文件
for (const entry of entries) {
const fullPath = join(dir, entry)
const stats = statSync(fullPath)
if (stats.isFile()) {
const lowerEntry = entry.toLowerCase()
if (lowerEntry.includes(pattern) && lowerEntry.endsWith('.dat')) {
const baseLower = lowerEntry.slice(0, -4)
if (!this.hasImageVariantSuffix(baseLower)) continue
return fullPath
}
}
}
// 递归子目录
for (const entry of entries) {
const fullPath = join(dir, entry)
const stats = statSync(fullPath)
if (stats.isDirectory()) {
const found = this.recursiveSearch(fullPath, pattern, maxDepth - 1)
if (found) return found
}
}
} catch { }
return null
}
private looksLikeMd5(value: string): boolean {
return /^[a-fA-F0-9]{16,32}$/.test(value)
}
private normalizeDatBase(name: string): string {
let base = name.toLowerCase()
if (base.endsWith('.dat') || base.endsWith('.jpg')) {
base = base.slice(0, -4)
}
while (/[._][a-z]$/.test(base)) {
base = base.slice(0, -2)
}
return base
}
private hasXVariant(baseLower: string): boolean {
return /[._][a-z]$/.test(baseLower)
}
private resolveHardlinkPath(accountDir: string, md5: string, sessionId?: string): string | null {
try {
const hardlinkPath = join(accountDir, 'hardlink.db')
if (!existsSync(hardlinkPath)) return null
const state = this.getHardlinkState(accountDir, hardlinkPath)
if (!state.imageTable) return null
const row = state.db
.prepare(`SELECT dir1, dir2, file_name FROM ${state.imageTable} WHERE md5 = ? LIMIT 1`)
.get(md5) as { dir1?: string; dir2?: string; file_name?: string } | undefined
if (!row) return null
const dir1 = row.dir1 as string | undefined
const dir2 = row.dir2 as string | undefined
const fileName = row.file_name as string | undefined
if (!dir1 || !dir2 || !fileName) return null
const lowerFileName = fileName.toLowerCase()
if (lowerFileName.endsWith('.dat')) {
const baseLower = lowerFileName.slice(0, -4)
if (!this.hasXVariant(baseLower)) return null
}
let dirName = dir2
if (state.dirTable && sessionId) {
try {
const dirRow = state.db
.prepare(`SELECT dir_name FROM ${state.dirTable} WHERE dir_id = ? AND username = ? LIMIT 1`)
.get(dir2, sessionId) as { dir_name?: string } | undefined
if (dirRow?.dir_name) dirName = dirRow.dir_name as string
} catch { }
}
const fullPath = join(accountDir, dir1, dirName, fileName)
if (existsSync(fullPath)) return fullPath
const withDat = `${fullPath}.dat`
if (existsSync(withDat)) return withDat
} catch { }
return null
}
private getHardlinkState(accountDir: string, hardlinkPath: string): HardlinkState {
const cached = this.hardlinkCache.get(accountDir)
if (cached) return cached
const db = new Database(hardlinkPath, { readonly: true, fileMustExist: true })
const imageRow = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'image_hardlink_info%' ORDER BY name DESC LIMIT 1")
.get() as { name?: string } | undefined
const dirRow = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'dir2id%' LIMIT 1")
.get() as { name?: string } | undefined
const state: HardlinkState = {
db,
imageTable: imageRow?.name as string | undefined,
dirTable: dirRow?.name as string | undefined
}
this.hardlinkCache.set(accountDir, state)
return state
}
private getDatVersion(data: Buffer): number {
if (data.length < 6) return 0
const sigV1 = Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07])
const sigV2 = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
if (data.subarray(0, 6).equals(sigV1)) return 1
if (data.subarray(0, 6).equals(sigV2)) return 2
return 0
}
private decryptDatV3(data: Buffer, xorKey: number): Buffer {
const result = Buffer.alloc(data.length)
for (let i = 0; i < data.length; i++) {
result[i] = data[i] ^ xorKey
}
return result
}
private decryptDatV4(data: Buffer, xorKey: number, aesKey: Buffer): Buffer {
if (data.length < 0x0f) {
throw new Error('文件太小,无法解析')
}
const header = data.subarray(0, 0x0f)
const payload = data.subarray(0x0f)
const aesSize = this.bytesToInt32(header.subarray(6, 10))
const xorSize = this.bytesToInt32(header.subarray(10, 14))
const remainder = ((aesSize % 16) + 16) % 16
const alignedAesSize = aesSize + (16 - remainder)
if (alignedAesSize > payload.length) {
throw new Error('文件格式异常AES 数据长度超过文件实际长度')
}
const aesData = payload.subarray(0, alignedAesSize)
let unpadded: Buffer = Buffer.alloc(0)
if (aesData.length > 0) {
const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, Buffer.alloc(0))
decipher.setAutoPadding(false)
const decrypted = Buffer.concat([decipher.update(aesData), decipher.final()])
unpadded = this.strictRemovePadding(decrypted) as Buffer
}
const remaining = payload.subarray(alignedAesSize)
if (xorSize < 0 || xorSize > remaining.length) {
throw new Error('文件格式异常XOR 数据长度不合法')
}
let rawData: Buffer = Buffer.alloc(0)
let xoredData: Buffer = Buffer.alloc(0)
if (xorSize > 0) {
const rawLength = remaining.length - xorSize
if (rawLength < 0) {
throw new Error('文件格式异常原始数据长度小于XOR长度')
}
rawData = remaining.subarray(0, rawLength) as Buffer
const xorData = remaining.subarray(rawLength)
xoredData = Buffer.alloc(xorData.length)
for (let i = 0; i < xorData.length; i++) {
xoredData[i] = xorData[i] ^ xorKey
}
} else {
rawData = remaining as Buffer
xoredData = Buffer.alloc(0)
}
return Buffer.concat([unpadded, rawData, xoredData])
}
private strictRemovePadding(data: Buffer): Buffer {
if (!data.length) {
throw new Error('解密结果为空,填充非法')
}
const paddingLength = data[data.length - 1]
if (paddingLength === 0 || paddingLength > 16 || paddingLength > data.length) {
throw new Error('PKCS7 填充长度非法')
}
for (let i = data.length - paddingLength; i < data.length; i++) {
if (data[i] !== paddingLength) {
throw new Error('PKCS7 填充内容非法')
}
}
return data.subarray(0, data.length - paddingLength)
}
private bytesToInt32(bytes: Buffer): number {
if (bytes.length !== 4) {
throw new Error('需要4个字节')
}
return bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24)
}
private hasImageVariantSuffix(baseLower: string): boolean {
const suffixes = [
'.b',
'.h',
'.t',
'.c',
'.w',
'.l',
'_b',
'_h',
'_t',
'_c',
'_w',
'_l'
]
return suffixes.some((suffix) => baseLower.endsWith(suffix))
}
private asciiKey16(keyString: string): Buffer {
if (keyString.length < 16) {
throw new Error('AES密钥至少需要16个字符')
}
return Buffer.from(keyString, 'ascii').subarray(0, 16)
}
private parseXorKey(value: unknown): number {
if (typeof value === 'number' && Number.isFinite(value)) {
return value
}
const cleanHex = String(value ?? '').toLowerCase().replace(/^0x/, '')
if (!cleanHex) {
throw new Error('十六进制字符串不能为空')
}
const hex = cleanHex.length >= 2 ? cleanHex.substring(0, 2) : cleanHex
const parsed = parseInt(hex, 16)
if (Number.isNaN(parsed)) {
throw new Error('十六进制字符串不能为空')
}
return parsed
}
}
export const chatService = new ChatService()