新的提交

This commit is contained in:
cc committed 2026-01-10 13:01:37 +08:00
commit 01641834de
188 files changed
+34865

No files matched your search

+490
View File
@@ -0,0 +1,490 @@
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
export interface ChatStatistics {
totalMessages: number
textMessages: number
imageMessages: number
voiceMessages: number
videoMessages: number
emojiMessages: number
otherMessages: number
sentMessages: number
receivedMessages: number
firstMessageTime: number | null
lastMessageTime: number | null
activeDays: number
messageTypeCounts: Record<number, number>
}
export interface TimeDistribution {
hourlyDistribution: Record<number, number>
weekdayDistribution: Record<number, number>
monthlyDistribution: Record<string, number>
}
export interface ContactRanking {
username: string
displayName: string
avatarUrl?: string
messageCount: number
sentCount: number
receivedCount: number
lastMessageTime: number | null
}
class AnalyticsService {
private configService: ConfigService
private fallbackAggregateCache: { key: string; data: any; updatedAt: number } | null = null
private aggregateCache: { key: string; data: any; updatedAt: number } | null = null
private aggregatePromise: { key: string; promise: Promise<{ success: boolean; data?: any; source?: string; error?: string }> } | null = null
constructor() {
this.configService = new ConfigService()
}
private cleanAccountDirName(name: string): string {
const trimmed = name.trim()
if (!trimmed) return trimmed
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
if (match) return match[1]
return trimmed
}
return trimmed
}
private isPrivateSession(username: string, cleanedWxid: string): boolean {
if (!username) return false
if (username.toLowerCase() === cleanedWxid.toLowerCase()) return false
if (username.includes('@chatroom')) return false
if (username === 'filehelper') 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
}
private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> {
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 ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
if (!ok) return { success: false, error: 'WCDB 打开失败' }
return { success: true, cleanedWxid }
}
private async getPrivateSessions(
cleanedWxid: string
): Promise<{ usernames: string[]; numericIds: string[] }> {
const sessionResult = await wcdbService.getSessions()
if (!sessionResult.success || !sessionResult.sessions) {
return { usernames: [], numericIds: [] }
}
const rows = sessionResult.sessions as Record<string, any>[]
const sample = rows[0]
void sample
const sessions = rows.map((row) => {
const username = row.username || row.user_name || row.userName || ''
const idValue =
row.id ??
row.session_id ??
row.sessionId ??
row.sid ??
row.local_id ??
row.user_id ??
row.userId ??
row.chatroom_id ??
row.chatroomId ??
null
return { username, idValue }
})
const usernames = sessions.map((s) => s.username)
const privateSessions = sessions.filter((s) => this.isPrivateSession(s.username, cleanedWxid))
const privateUsernames = privateSessions.map((s) => s.username)
const numericIds = privateSessions
.map((s) => s.idValue)
.filter((id) => typeof id === 'number' || (typeof id === 'string' && /^\d+$/.test(id)))
.map((id) => String(id))
return { usernames: privateUsernames, numericIds }
}
private async iterateSessionMessages(
sessionId: string,
onRow: (row: Record<string, any>) => void,
beginTimestamp = 0,
endTimestamp = 0
): Promise<void> {
const cursorResult = await wcdbService.openMessageCursor(
sessionId,
500,
true,
beginTimestamp,
endTimestamp
)
if (!cursorResult.success || !cursorResult.cursor) return
try {
let hasMore = true
let batchCount = 0
while (hasMore) {
const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor)
if (!batch.success || !batch.rows) break
for (const row of batch.rows) {
onRow(row)
}
hasMore = batch.hasMore === true
// 每处理完一个批次,如果已经处理了较多数据,暂时让出执行权
batchCount++
if (batchCount % 10 === 0) {
await new Promise(resolve => setImmediate(resolve))
}
}
} finally {
await wcdbService.closeMessageCursor(cursorResult.cursor)
}
}
private setProgress(window: any, status: string, progress: number) {
if (window && !window.isDestroyed()) {
window.webContents.send('analytics:progress', { status, progress })
}
}
private buildAggregateCacheKey(sessionIds: string[], beginTimestamp: number, endTimestamp: number): string {
const sample = sessionIds.slice(0, 5).join(',')
return `${beginTimestamp}-${endTimestamp}-${sessionIds.length}-${sample}`
}
private async computeAggregateByCursor(sessionIds: string[], beginTimestamp = 0, endTimestamp = 0): Promise<any> {
const aggregate = {
total: 0,
sent: 0,
received: 0,
firstTime: 0,
lastTime: 0,
typeCounts: {} as Record<number, number>,
hourly: {} as Record<number, number>,
weekday: {} as Record<number, number>,
daily: {} as Record<string, number>,
monthly: {} as Record<string, number>,
sessions: {} as Record<string, { total: number; sent: number; received: number; lastTime: number }>,
idMap: {}
}
for (const sessionId of sessionIds) {
const sessionStat = { total: 0, sent: 0, received: 0, lastTime: 0 }
await this.iterateSessionMessages(sessionId, (row) => {
const createTime = parseInt(row.create_time || row.createTime || row.create_time_ms || '0', 10)
if (!createTime) return
if (beginTimestamp > 0 && createTime < beginTimestamp) return
if (endTimestamp > 0 && createTime > endTimestamp) return
const localType = parseInt(row.local_type || row.type || '1', 10)
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend ?? 0
const isSend = String(isSendRaw) === '1' || isSendRaw === 1 || isSendRaw === true
aggregate.total += 1
sessionStat.total += 1
aggregate.typeCounts[localType] = (aggregate.typeCounts[localType] || 0) + 1
if (isSend) {
aggregate.sent += 1
sessionStat.sent += 1
} else {
aggregate.received += 1
sessionStat.received += 1
}
if (aggregate.firstTime === 0 || createTime < aggregate.firstTime) {
aggregate.firstTime = createTime
}
if (createTime > aggregate.lastTime) {
aggregate.lastTime = createTime
}
if (createTime > sessionStat.lastTime) {
sessionStat.lastTime = createTime
}
const date = new Date(createTime * 1000)
const hour = date.getHours()
const weekday = date.getDay()
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
const dayKey = `${monthKey}-${String(date.getDate()).padStart(2, '0')}`
aggregate.hourly[hour] = (aggregate.hourly[hour] || 0) + 1
aggregate.weekday[weekday] = (aggregate.weekday[weekday] || 0) + 1
aggregate.monthly[monthKey] = (aggregate.monthly[monthKey] || 0) + 1
aggregate.daily[dayKey] = (aggregate.daily[dayKey] || 0) + 1
}, beginTimestamp, endTimestamp)
if (sessionStat.total > 0) {
aggregate.sessions[sessionId] = sessionStat
}
}
return aggregate
}
private async getAggregateWithFallback(
sessionIds: string[],
beginTimestamp = 0,
endTimestamp = 0,
window?: any
): Promise<{ success: boolean; data?: any; source?: string; error?: string }> {
const cacheKey = this.buildAggregateCacheKey(sessionIds, beginTimestamp, endTimestamp)
if (this.aggregateCache && this.aggregateCache.key === cacheKey) {
if (Date.now() - this.aggregateCache.updatedAt < 5 * 60 * 1000) {
return { success: true, data: this.aggregateCache.data, source: 'cache' }
}
}
if (this.aggregatePromise && this.aggregatePromise.key === cacheKey) {
return this.aggregatePromise.promise
}
const promise = (async () => {
const result = await wcdbService.getAggregateStats(sessionIds, beginTimestamp, endTimestamp)
if (result.success && result.data && result.data.total > 0) {
this.aggregateCache = { key: cacheKey, data: result.data, updatedAt: Date.now() }
return { success: true, data: result.data, source: 'dll' }
}
if (this.fallbackAggregateCache && this.fallbackAggregateCache.key === cacheKey) {
if (Date.now() - this.fallbackAggregateCache.updatedAt < 5 * 60 * 1000) {
return { success: true, data: this.fallbackAggregateCache.data, source: 'cursor-cache' }
}
}
if (window) {
this.setProgress(window, '原生聚合为0,使用游标统计...', 45)
}
const data = await this.computeAggregateByCursor(sessionIds, beginTimestamp, endTimestamp)
this.fallbackAggregateCache = { key: cacheKey, data, updatedAt: Date.now() }
this.aggregateCache = { key: cacheKey, data, updatedAt: Date.now() }
return { success: true, data, source: 'cursor' }
})()
this.aggregatePromise = { key: cacheKey, promise }
try {
return await promise
} finally {
if (this.aggregatePromise && this.aggregatePromise.key === cacheKey) {
this.aggregatePromise = null
}
}
}
private normalizeAggregateSessions(
sessions: Record<string, any> | undefined,
idMap: Record<string, string> | undefined
): Record<string, any> {
if (!sessions) return {}
if (!idMap) return sessions
const keys = Object.keys(sessions)
if (keys.length === 0) return sessions
const numericKeys = keys.every((k) => /^\d+$/.test(k))
if (!numericKeys) return sessions
const remapped: Record<string, any> = {}
for (const [id, stat] of Object.entries(sessions)) {
const username = idMap[id] || id
remapped[username] = stat
}
return remapped
}
private async logAggregateDiagnostics(sessionIds: string[]): Promise<void> {
const samples = sessionIds.slice(0, 5)
const results = await Promise.all(samples.map(async (sessionId) => {
const countResult = await wcdbService.getMessageCount(sessionId)
return { sessionId, success: countResult.success, count: countResult.count, error: countResult.error }
}))
void results
}
async getOverallStatistics(): Promise<{ success: boolean; data?: ChatStatistics; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const sessionInfo = await this.getPrivateSessions(conn.cleanedWxid)
if (sessionInfo.usernames.length === 0) {
return { success: false, error: '未找到消息会话' }
}
const { BrowserWindow } = require('electron')
const win = BrowserWindow.getAllWindows()[0]
this.setProgress(win, '正在执行原生数据聚合...', 30)
const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0, win)
if (!result.success || !result.data) {
return { success: false, error: result.error || '聚合统计失败' }
}
this.setProgress(win, '同步分析结果...', 90)
const d = result.data
if (d.total === 0 && sessionInfo.usernames.length > 0) {
await this.logAggregateDiagnostics(sessionInfo.usernames)
}
const textTypes = [1, 244813135921]
let textMessages = 0
for (const t of textTypes) textMessages += (d.typeCounts[t] || 0)
const imageMessages = d.typeCounts[3] || 0
const voiceMessages = d.typeCounts[34] || 0
const videoMessages = d.typeCounts[43] || 0
const emojiMessages = d.typeCounts[47] || 0
const otherMessages = d.total - textMessages - imageMessages - voiceMessages - videoMessages - emojiMessages
// 估算活跃天数(按月分布估算或从日期列表中提取,由于 C++ 只返回了月份映射,
// 我们这里暂时返回月份数作为参考,或者如果需要精确天数,原生层需要返回 Set 大小)
// 为了性能,我们先用月份数,或者后续再优化 C++ 返回 activeDays 计数。
// 当前 C++ 逻辑中 gs.monthly.size() 就是活跃月份。
const activeMonths = Object.keys(d.monthly).length
return {
success: true,
data: {
totalMessages: d.total,
textMessages,
imageMessages,
voiceMessages,
videoMessages,
emojiMessages,
otherMessages: Math.max(0, otherMessages),
sentMessages: d.sent,
receivedMessages: d.received,
firstMessageTime: d.firstTime || null,
lastMessageTime: d.lastTime || null,
activeDays: activeMonths * 20, // 粗略估算,或改为返回活跃月份
messageTypeCounts: d.typeCounts
}
}
} catch (e) {
return { success: false, error: String(e) }
}
}
async getContactRankings(limit: number = 20): Promise<{ success: boolean; data?: ContactRanking[]; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const sessionInfo = await this.getPrivateSessions(conn.cleanedWxid)
if (sessionInfo.usernames.length === 0) {
return { success: false, error: '未找到消息会话' }
}
const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0)
if (!result.success || !result.data) {
return { success: false, error: result.error || '聚合统计失败' }
}
const d = result.data
const sessions = this.normalizeAggregateSessions(d.sessions, d.idMap)
const usernames = Object.keys(sessions)
const [displayNames, avatarUrls] = await Promise.all([
wcdbService.getDisplayNames(usernames),
wcdbService.getAvatarUrls(usernames)
])
const rankings: ContactRanking[] = usernames
.map((username) => {
const stat = sessions[username]
const displayName = displayNames.success && displayNames.map
? (displayNames.map[username] || username)
: username
const avatarUrl = avatarUrls.success && avatarUrls.map
? avatarUrls.map[username]
: undefined
return {
username,
displayName,
avatarUrl,
messageCount: stat.total,
sentCount: stat.sent,
receivedCount: stat.received,
lastMessageTime: stat.lastTime || null
}
})
.sort((a, b) => b.messageCount - a.messageCount)
.slice(0, limit)
return { success: true, data: rankings }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getTimeDistribution(): Promise<{ success: boolean; data?: TimeDistribution; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const sessionInfo = await this.getPrivateSessions(conn.cleanedWxid)
if (sessionInfo.usernames.length === 0) {
return { success: false, error: '未找到消息会话' }
}
const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0)
if (!result.success || !result.data) {
return { success: false, error: result.error || '聚合统计失败' }
}
const d = result.data
// SQLite strftime('%w') 返回 0=Sun, 1=Mon...6=Sat
// 前端期望 1=Mon...7=Sun
const weekdayDistribution: Record<number, number> = {}
for (const [w, count] of Object.entries(d.weekday)) {
const sqliteW = parseInt(w, 10)
const jsW = sqliteW === 0 ? 7 : sqliteW
weekdayDistribution[jsW] = count as number
}
// 补全 24 小时
const hourlyDistribution: Record<number, number> = {}
for (let i = 0; i < 24; i++) {
hourlyDistribution[i] = d.hourly[i] || 0
}
return {
success: true,
data: {
hourlyDistribution,
weekdayDistribution,
monthlyDistribution: d.monthly
}
}
} catch (e) {
return { success: false, error: String(e) }
}
}
}
export const analyticsService = new AnalyticsService()
+928
View File
@@ -0,0 +1,928 @@
import { parentPort } from 'worker_threads'
import { wcdbService } from './wcdbService'
export interface TopContact {
username: string
displayName: string
avatarUrl?: string
messageCount: number
sentCount: number
receivedCount: number
}
export interface MonthlyTopFriend {
month: number
displayName: string
avatarUrl?: string
messageCount: number
}
export interface ChatPeakDay {
date: string
messageCount: number
topFriend?: string
topFriendCount?: number
}
export interface ActivityHeatmap {
data: number[][]
}
export interface AnnualReportData {
year: number
totalMessages: number
totalFriends: number
coreFriends: TopContact[]
monthlyTopFriends: MonthlyTopFriend[]
peakDay: ChatPeakDay | null
longestStreak: {
friendName: string
days: number
startDate: string
endDate: string
} | null
activityHeatmap: ActivityHeatmap
midnightKing: {
displayName: string
count: number
percentage: number
} | null
selfAvatarUrl?: string
mutualFriend: {
displayName: string
avatarUrl?: string
sentCount: number
receivedCount: number
ratio: number
} | null
socialInitiative: {
initiatedChats: number
receivedChats: number
initiativeRate: number
} | null
responseSpeed: {
avgResponseTime: number
fastestFriend: string
fastestTime: number
} | null
topPhrases: {
phrase: string
count: number
}[]
}
class AnnualReportService {
constructor() {
}
private broadcastProgress(status: string, progress: number) {
if (parentPort) {
parentPort.postMessage({
type: 'annualReport:progress',
data: { status, progress }
})
}
}
private reportProgress(status: string, progress: number, onProgress?: (status: string, progress: number) => void) {
if (onProgress) {
onProgress(status, progress)
return
}
this.broadcastProgress(status, progress)
}
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
}
private async ensureConnectedWithConfig(
dbPath: string,
decryptKey: string,
wxid: string
): Promise<{ success: boolean; cleanedWxid?: string; rawWxid?: string; error?: string }> {
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 ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
if (!ok) return { success: false, error: 'WCDB 打开失败' }
return { success: true, cleanedWxid, rawWxid: wxid }
}
private async getPrivateSessions(cleanedWxid: string): Promise<string[]> {
const sessionResult = await wcdbService.getSessions()
if (!sessionResult.success || !sessionResult.sessions) return []
const rows = sessionResult.sessions as Record<string, any>[]
const excludeList = [
'weixin', 'qqmail', 'fmessage', 'medianote', 'floatbottle',
'newsapp', 'brandsessionholder', 'brandservicesessionholder',
'notifymessage', 'opencustomerservicemsg', 'notification_messages',
'userexperience_alarm', 'helper_folders', 'placeholder_foldgroup',
'@helper_folders', '@placeholder_foldgroup'
]
return rows
.map((row) => row.username || row.user_name || row.userName || '')
.filter((username) => {
if (!username) return false
if (username.includes('@chatroom')) return false
if (username === 'filehelper') return false
if (username.startsWith('gh_')) return false
if (username.toLowerCase() === cleanedWxid.toLowerCase()) return false
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
})
}
private async getEdgeMessageTime(sessionId: string, ascending: boolean): Promise<number | null> {
const cursor = await wcdbService.openMessageCursor(sessionId, 1, ascending, 0, 0)
if (!cursor.success || !cursor.cursor) return null
try {
const batch = await wcdbService.fetchMessageBatch(cursor.cursor)
if (!batch.success || !batch.rows || batch.rows.length === 0) return null
const ts = parseInt(batch.rows[0].create_time || '0', 10)
return ts > 0 ? ts : null
} finally {
await wcdbService.closeMessageCursor(cursor.cursor)
}
}
private decodeMessageContent(messageContent: any, compressContent: any): string {
let content = this.decodeMaybeCompressed(compressContent)
if (!content || content.length === 0) {
content = this.decodeMaybeCompressed(messageContent)
}
return content
}
private decodeMaybeCompressed(raw: any): string {
if (!raw) return ''
if (typeof raw === 'string') {
if (raw.length === 0) return ''
if (this.looksLikeHex(raw)) {
const bytes = Buffer.from(raw, 'hex')
if (bytes.length > 0) return this.decodeBinaryContent(bytes)
}
if (this.looksLikeBase64(raw)) {
try {
const bytes = Buffer.from(raw, 'base64')
return this.decodeBinaryContent(bytes)
} catch {
return raw
}
}
return raw
}
return ''
}
private decodeBinaryContent(data: Buffer): string {
if (data.length === 0) return ''
try {
if (data.length >= 4) {
const magic = data.readUInt32LE(0)
if (magic === 0xFD2FB528) {
const fzstd = require('fzstd')
const decompressed = fzstd.decompress(data)
return Buffer.from(decompressed).toString('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, '')
}
return data.toString('latin1')
} catch {
return ''
}
}
private looksLikeHex(s: string): boolean {
if (s.length % 2 !== 0) return false
return /^[0-9a-fA-F]+$/.test(s)
}
private looksLikeBase64(s: string): boolean {
if (s.length % 4 !== 0) return false
return /^[A-Za-z0-9+/=]+$/.test(s)
}
private formatDateYmd(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
private async computeLongestStreak(
sessionIds: string[],
beginTimestamp: number,
endTimestamp: number,
onProgress?: (status: string, progress: number) => void,
progressStart: number = 0,
progressEnd: number = 0
): Promise<{ sessionId: string; days: number; start: Date | null; end: Date | null }> {
let bestSessionId = ''
let bestDays = 0
let bestStart: Date | null = null
let bestEnd: Date | null = null
let lastProgressAt = 0
let lastProgressSent = progressStart
const shouldReportProgress = onProgress && progressEnd > progressStart && sessionIds.length > 0
let apiTimeMs = 0
let jsTimeMs = 0
for (let i = 0; i < sessionIds.length; i++) {
const sessionId = sessionIds[i]
const openStart = Date.now()
const cursor = await wcdbService.openMessageCursorLite(sessionId, 2000, true, beginTimestamp, endTimestamp)
apiTimeMs += Date.now() - openStart
if (!cursor.success || !cursor.cursor) continue
let lastDayIndex: number | null = null
let currentStreak = 0
let currentStart: Date | null = null
let maxStreak = 0
let maxStart: Date | null = null
let maxEnd: Date | null = null
try {
let hasMore = true
while (hasMore) {
const fetchStart = Date.now()
const batch = await wcdbService.fetchMessageBatch(cursor.cursor)
apiTimeMs += Date.now() - fetchStart
if (!batch.success || !batch.rows) break
const processStart = Date.now()
for (const row of batch.rows) {
const createTime = parseInt(row.create_time || '0', 10)
if (!createTime) continue
const dt = new Date(createTime * 1000)
const dayDate = new Date(dt.getFullYear(), dt.getMonth(), dt.getDate())
const dayIndex = Math.floor(dayDate.getTime() / 86400000)
if (lastDayIndex !== null && dayIndex === lastDayIndex) continue
if (lastDayIndex !== null && dayIndex - lastDayIndex === 1) {
currentStreak++
} else {
currentStreak = 1
currentStart = dayDate
}
if (currentStreak > maxStreak) {
maxStreak = currentStreak
maxStart = currentStart
maxEnd = dayDate
}
lastDayIndex = dayIndex
}
jsTimeMs += Date.now() - processStart
hasMore = batch.hasMore === true
await new Promise(resolve => setImmediate(resolve))
}
} finally {
const closeStart = Date.now()
await wcdbService.closeMessageCursor(cursor.cursor)
apiTimeMs += Date.now() - closeStart
}
if (maxStreak > bestDays) {
bestDays = maxStreak
bestSessionId = sessionId
bestStart = maxStart
bestEnd = maxEnd
}
if (shouldReportProgress) {
const now = Date.now()
if (now - lastProgressAt > 250) {
const ratio = Math.min(1, (i + 1) / sessionIds.length)
const progress = Math.floor(progressStart + ratio * (progressEnd - progressStart))
if (progress > lastProgressSent) {
lastProgressSent = progress
lastProgressAt = now
const label = `${i + 1}/${sessionIds.length}`
const timing = (apiTimeMs > 0 || jsTimeMs > 0)
? `, DB ${(apiTimeMs / 1000).toFixed(1)}s / JS ${(jsTimeMs / 1000).toFixed(1)}s`
: ''
onProgress?.(`计算连续聊天... (${label}${timing})`, progress)
}
}
}
}
return { sessionId: bestSessionId, days: bestDays, start: bestStart, end: bestEnd }
}
async getAvailableYears(params: { dbPath: string; decryptKey: string; wxid: string }): Promise<{ success: boolean; data?: number[]; error?: string }> {
try {
const conn = await this.ensureConnectedWithConfig(params.dbPath, params.decryptKey, params.wxid)
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const sessionIds = await this.getPrivateSessions(conn.cleanedWxid)
if (sessionIds.length === 0) {
return { success: false, error: '未找到消息会话' }
}
const fastYears = await wcdbService.getAvailableYears(sessionIds)
if (fastYears.success && fastYears.data) {
return { success: true, data: fastYears.data }
}
const years = new Set<number>()
for (const sessionId of sessionIds) {
const first = await this.getEdgeMessageTime(sessionId, true)
const last = await this.getEdgeMessageTime(sessionId, false)
if (!first && !last) continue
const minYear = new Date((first || last || 0) * 1000).getFullYear()
const maxYear = new Date((last || first || 0) * 1000).getFullYear()
for (let y = minYear; y <= maxYear; y++) {
if (y >= 2010 && y <= new Date().getFullYear()) years.add(y)
}
}
const sortedYears = Array.from(years).sort((a, b) => b - a)
return { success: true, data: sortedYears }
} catch (e) {
return { success: false, error: String(e) }
}
}
async generateReportWithConfig(params: {
year: number
wxid: string
dbPath: string
decryptKey: string
onProgress?: (status: string, progress: number) => void
}): Promise<{ success: boolean; data?: AnnualReportData; error?: string }> {
try {
const { year, wxid, dbPath, decryptKey, onProgress } = params
this.reportProgress('正在连接数据库...', 5, onProgress)
const conn = await this.ensureConnectedWithConfig(dbPath, decryptKey, wxid)
if (!conn.success || !conn.cleanedWxid || !conn.rawWxid) return { success: false, error: conn.error }
const cleanedWxid = conn.cleanedWxid
const rawWxid = conn.rawWxid
const sessionIds = await this.getPrivateSessions(cleanedWxid)
if (sessionIds.length === 0) {
return { success: false, error: '未找到消息会话' }
}
this.reportProgress('加载会话列表...', 15, onProgress)
const startTime = Math.floor(new Date(year, 0, 1).getTime() / 1000)
const endTime = Math.floor(new Date(year, 11, 31, 23, 59, 59).getTime() / 1000)
let totalMessages = 0
const contactStats = new Map<string, { sent: number; received: number }>()
const monthlyStats = new Map<string, Map<number, number>>()
const dailyStats = new Map<string, number>()
const dailyContactStats = new Map<string, Map<string, number>>()
const heatmapData: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0))
const midnightStats = new Map<string, number>()
let longestStreakSessionId = ''
let longestStreakDays = 0
let longestStreakStart: Date | null = null
let longestStreakEnd: Date | null = null
const conversationStarts = new Map<string, { initiated: number; received: number }>()
const responseTimeStats = new Map<string, number[]>()
const phraseCount = new Map<string, number>()
const lastMessageTime = new Map<string, { time: number; isSent: boolean }>()
const CONVERSATION_GAP = 3600
this.reportProgress('统计会话消息...', 20, onProgress)
const result = await wcdbService.getAnnualReportStats(sessionIds, startTime, endTime)
if (!result.success || !result.data) {
return { success: false, error: result.error ? `基础统计失败: ${result.error}` : '基础统计失败' }
}
const d = result.data
totalMessages = d.total
this.reportProgress('汇总基础统计...', 25, onProgress)
const totalMessagesForProgress = totalMessages > 0 ? totalMessages : sessionIds.length
let processedMessages = 0
let lastProgressSent = 0
let lastProgressAt = 0
// 填充基础统计
for (const [sid, stat] of Object.entries(d.sessions)) {
const s = stat as any
contactStats.set(sid, { sent: s.sent, received: s.received })
const mMap = new Map<number, number>()
for (const [m, c] of Object.entries(s.monthly || {})) {
mMap.set(parseInt(m, 10), c as number)
}
monthlyStats.set(sid, mMap)
}
// 填充全局分布,并锁定峰值日期以减少逐日消息统计
let peakDayKey = ''
let peakDayCount = 0
for (const [day, count] of Object.entries(d.daily)) {
const c = count as number
dailyStats.set(day, c)
if (c > peakDayCount) {
peakDayCount = c
peakDayKey = day
}
}
let useSqlExtras = false
let responseStatsFromSql: Record<string, { avg?: number; fastest?: number; count?: number }> | null = null
let topPhrasesFromSql: { phrase: string; count: number }[] | null = null
let streakComputedInLoop = false
let peakDayBegin = 0
let peakDayEnd = 0
if (peakDayKey) {
const start = new Date(`${peakDayKey}T00:00:00`).getTime()
if (!Number.isNaN(start)) {
peakDayBegin = Math.floor(start / 1000)
peakDayEnd = peakDayBegin + 24 * 3600 - 1
}
}
this.reportProgress('加载扩展统计... (初始化)', 30, onProgress)
const extras = await wcdbService.getAnnualReportExtras(sessionIds, startTime, endTime, peakDayBegin, peakDayEnd)
if (extras.success && extras.data) {
this.reportProgress('加载扩展统计... (解析热力图)', 32, onProgress)
const extrasData = extras.data as any
const heatmap = extrasData.heatmap as number[][] | undefined
if (Array.isArray(heatmap) && heatmap.length === 7) {
for (let w = 0; w < 7; w++) {
if (Array.isArray(heatmap[w])) {
for (let h = 0; h < 24; h++) {
heatmapData[w][h] = heatmap[w][h] || 0
}
}
}
}
this.reportProgress('加载扩展统计... (解析夜聊统计)', 33, onProgress)
const midnight = extrasData.midnight as Record<string, number> | undefined
if (midnight) {
for (const [sid, count] of Object.entries(midnight)) {
midnightStats.set(sid, count as number)
}
}
this.reportProgress('加载扩展统计... (解析对话发起)', 34, onProgress)
const conversation = extrasData.conversation as Record<string, { initiated: number; received: number }> | undefined
if (conversation) {
for (const [sid, stats] of Object.entries(conversation)) {
conversationStarts.set(sid, { initiated: stats.initiated || 0, received: stats.received || 0 })
}
}
this.reportProgress('加载扩展统计... (解析响应速度)', 35, onProgress)
responseStatsFromSql = extrasData.response || null
this.reportProgress('加载扩展统计... (解析峰值日)', 36, onProgress)
const peakDayCounts = extrasData.peakDay as Record<string, number> | undefined
if (peakDayKey && peakDayCounts) {
const dayMap = new Map<string, number>()
for (const [sid, count] of Object.entries(peakDayCounts)) {
dayMap.set(sid, count as number)
}
if (dayMap.size > 0) {
dailyContactStats.set(peakDayKey, dayMap)
}
}
this.reportProgress('加载扩展统计... (解析常用语)', 37, onProgress)
const sqlPhrases = extrasData.topPhrases as { phrase: string; count: number }[] | undefined
if (Array.isArray(sqlPhrases) && sqlPhrases.length > 0) {
topPhrasesFromSql = sqlPhrases
}
const streak = extrasData.streak as { sessionId?: string; days?: number; startDate?: string; endDate?: string } | undefined
if (streak && streak.sessionId && streak.days && streak.days > 0) {
longestStreakSessionId = streak.sessionId
longestStreakDays = streak.days
longestStreakStart = streak.startDate ? new Date(`${streak.startDate}T00:00:00`) : null
longestStreakEnd = streak.endDate ? new Date(`${streak.endDate}T00:00:00`) : null
if (longestStreakStart && !Number.isNaN(longestStreakStart.getTime()) &&
longestStreakEnd && !Number.isNaN(longestStreakEnd.getTime())) {
streakComputedInLoop = true
}
}
useSqlExtras = true
this.reportProgress('加载扩展统计... (完成)', 40, onProgress)
} else if (!extras.success) {
const reason = extras.error ? ` (${extras.error})` : ''
this.reportProgress(`扩展统计失败,转入完整分析...${reason}`, 30, onProgress)
}
if (!useSqlExtras) {
// 注意:原生层目前未返回交叉维度 heatmapData[weekday][hour],
// 这里的 heatmapData 仍然需要通过下面的遍历来精确填充。
// 考虑到 Annual Report 需要一些复杂的序列特征(响应速度、对话发起)和文本特征(常用语),
// 我们仍然保留一次轻量级循环,但因为有了原生统计,我们可以分步进行,或者如果数据量极大则跳过某些步骤。
// 为保持功能完整,我们进行深度集成的轻量遍历:
for (let i = 0; i < sessionIds.length; i++) {
const sessionId = sessionIds[i]
const cursor = await wcdbService.openMessageCursorLite(sessionId, 1000, true, startTime, endTime)
if (!cursor.success || !cursor.cursor) continue
let lastDayIndex: number | null = null
let currentStreak = 0
let currentStart: Date | null = null
let maxStreak = 0
let maxStart: Date | null = null
let maxEnd: Date | null = null
try {
let hasMore = true
while (hasMore) {
const batch = await wcdbService.fetchMessageBatch(cursor.cursor)
if (!batch.success || !batch.rows) break
for (const row of batch.rows) {
const createTime = parseInt(row.create_time || '0', 10)
if (!createTime) continue
const isSendRaw = row.computed_is_send ?? row.is_send ?? '0'
const isSent = parseInt(isSendRaw, 10) === 1
const localType = parseInt(row.local_type || row.type || '1', 10)
// 响应速度 & 对话发起
if (!conversationStarts.has(sessionId)) {
conversationStarts.set(sessionId, { initiated: 0, received: 0 })
}
const convStats = conversationStarts.get(sessionId)!
const lastMsg = lastMessageTime.get(sessionId)
if (!lastMsg || (createTime - lastMsg.time) > CONVERSATION_GAP) {
if (isSent) convStats.initiated++
else convStats.received++
} else if (lastMsg.isSent !== isSent) {
if (isSent && !lastMsg.isSent) {
const responseTime = createTime - lastMsg.time
if (responseTime > 0 && responseTime < 86400) {
if (!responseTimeStats.has(sessionId)) responseTimeStats.set(sessionId, [])
responseTimeStats.get(sessionId)!.push(responseTime)
}
}
}
lastMessageTime.set(sessionId, { time: createTime, isSent })
// 常用语
if ((localType === 1 || localType === 244813135921) && isSent) {
const content = this.decodeMessageContent(row.message_content, row.compress_content)
const text = String(content).trim()
if (text.length >= 2 && text.length <= 20 &&
!text.includes('http') && !text.includes('<') &&
!text.startsWith('[') && !text.startsWith('<?xml')) {
phraseCount.set(text, (phraseCount.get(text) || 0) + 1)
}
}
// 交叉维度补全
const dt = new Date(createTime * 1000)
const weekdayIndex = dt.getDay() === 0 ? 6 : dt.getDay() - 1
heatmapData[weekdayIndex][dt.getHours()]++
const dayDate = new Date(dt.getFullYear(), dt.getMonth(), dt.getDate())
const dayIndex = Math.floor(dayDate.getTime() / 86400000)
if (lastDayIndex === null || dayIndex !== lastDayIndex) {
if (lastDayIndex !== null && dayIndex - lastDayIndex === 1) {
currentStreak++
} else {
currentStreak = 1
currentStart = dayDate
}
if (currentStreak > maxStreak) {
maxStreak = currentStreak
maxStart = currentStart
maxEnd = dayDate
}
lastDayIndex = dayIndex
}
if (dt.getHours() >= 0 && dt.getHours() < 6) {
midnightStats.set(sessionId, (midnightStats.get(sessionId) || 0) + 1)
}
if (peakDayKey) {
const dayKey = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`
if (dayKey === peakDayKey) {
if (!dailyContactStats.has(dayKey)) dailyContactStats.set(dayKey, new Map())
const dayContactMap = dailyContactStats.get(dayKey)!
dayContactMap.set(sessionId, (dayContactMap.get(sessionId) || 0) + 1)
}
}
if (totalMessagesForProgress > 0) {
processedMessages++
}
}
hasMore = batch.hasMore === true
const now = Date.now()
if (now - lastProgressAt > 200) {
let progress = 30
if (totalMessagesForProgress > 0) {
const ratio = Math.min(1, processedMessages / totalMessagesForProgress)
progress = 30 + Math.floor(ratio * 50)
} else {
const ratio = Math.min(1, (i + 1) / sessionIds.length)
progress = 30 + Math.floor(ratio * 50)
}
if (progress > lastProgressSent) {
lastProgressSent = progress
lastProgressAt = now
let label = `${i + 1}/${sessionIds.length}`
if (totalMessagesForProgress > 0) {
const done = Math.min(processedMessages, totalMessagesForProgress)
label = `${done}/${totalMessagesForProgress}`
}
this.reportProgress(`分析聊天记录... (${label})`, progress, onProgress)
}
}
await new Promise(resolve => setImmediate(resolve))
}
} finally {
await wcdbService.closeMessageCursor(cursor.cursor)
}
if (maxStreak > longestStreakDays) {
longestStreakDays = maxStreak
longestStreakSessionId = sessionId
longestStreakStart = maxStart
longestStreakEnd = maxEnd
}
}
streakComputedInLoop = true
}
if (!streakComputedInLoop) {
this.reportProgress('计算连续聊天...', 45, onProgress)
const streakResult = await this.computeLongestStreak(sessionIds, startTime, endTime, onProgress, 45, 75)
if (streakResult.days > longestStreakDays) {
longestStreakDays = streakResult.days
longestStreakSessionId = streakResult.sessionId
longestStreakStart = streakResult.start
longestStreakEnd = streakResult.end
}
}
this.reportProgress('整理联系人信息...', 85, onProgress)
const contactIds = Array.from(contactStats.keys())
const [displayNames, avatarUrls] = await Promise.all([
wcdbService.getDisplayNames(contactIds),
wcdbService.getAvatarUrls(contactIds)
])
const contactInfoMap = new Map<string, { displayName: string; avatarUrl?: string }>()
for (const sessionId of contactIds) {
contactInfoMap.set(sessionId, {
displayName: displayNames.success && displayNames.map ? (displayNames.map[sessionId] || sessionId) : sessionId,
avatarUrl: avatarUrls.success && avatarUrls.map ? avatarUrls.map[sessionId] : undefined
})
}
const selfAvatarResult = await wcdbService.getAvatarUrls([rawWxid, cleanedWxid])
const selfAvatarUrl = selfAvatarResult.success && selfAvatarResult.map
? (selfAvatarResult.map[rawWxid] || selfAvatarResult.map[cleanedWxid])
: undefined
const coreFriends: TopContact[] = Array.from(contactStats.entries())
.map(([sessionId, stats]) => {
const info = contactInfoMap.get(sessionId)
return {
username: sessionId,
displayName: info?.displayName || sessionId,
avatarUrl: info?.avatarUrl,
messageCount: stats.sent + stats.received,
sentCount: stats.sent,
receivedCount: stats.received
}
})
.sort((a, b) => b.messageCount - a.messageCount)
.slice(0, 3)
const monthlyTopFriends: MonthlyTopFriend[] = []
for (let month = 1; month <= 12; month++) {
let maxCount = 0
let topSessionId = ''
for (const [sessionId, monthMap] of monthlyStats.entries()) {
const count = monthMap.get(month) || 0
if (count > maxCount) {
maxCount = count
topSessionId = sessionId
}
}
const info = contactInfoMap.get(topSessionId)
monthlyTopFriends.push({
month,
displayName: info?.displayName || (topSessionId ? topSessionId : '暂无'),
avatarUrl: info?.avatarUrl,
messageCount: maxCount
})
}
let peakDay: ChatPeakDay | null = null
let maxDayCount = 0
for (const [day, count] of dailyStats.entries()) {
if (count > maxDayCount) {
maxDayCount = count
const dayContactMap = dailyContactStats.get(day)
let topFriend = ''
let topFriendCount = 0
if (dayContactMap) {
for (const [sessionId, c] of dayContactMap.entries()) {
if (c > topFriendCount) {
topFriendCount = c
topFriend = contactInfoMap.get(sessionId)?.displayName || sessionId
}
}
}
peakDay = { date: day, messageCount: count, topFriend, topFriendCount }
}
}
let midnightKing: AnnualReportData['midnightKing'] = null
const totalMidnight = Array.from(midnightStats.values()).reduce((a, b) => a + b, 0)
if (totalMidnight > 0) {
let maxMidnight = 0
let midnightSessionId = ''
for (const [sessionId, count] of midnightStats.entries()) {
if (count > maxMidnight) {
maxMidnight = count
midnightSessionId = sessionId
}
}
const info = contactInfoMap.get(midnightSessionId)
midnightKing = {
displayName: info?.displayName || midnightSessionId,
count: maxMidnight,
percentage: Math.round((maxMidnight / totalMidnight) * 1000) / 10
}
}
let longestStreak: AnnualReportData['longestStreak'] = null
if (longestStreakSessionId && longestStreakDays > 0 && longestStreakStart && longestStreakEnd) {
const info = contactInfoMap.get(longestStreakSessionId)
longestStreak = {
friendName: info?.displayName || longestStreakSessionId,
days: longestStreakDays,
startDate: this.formatDateYmd(longestStreakStart),
endDate: this.formatDateYmd(longestStreakEnd)
}
}
let mutualFriend: AnnualReportData['mutualFriend'] = null
let bestRatioDiff = Infinity
for (const [sessionId, stats] of contactStats.entries()) {
if (stats.sent >= 50 && stats.received >= 50) {
const ratio = stats.sent / stats.received
const ratioDiff = Math.abs(ratio - 1)
if (ratioDiff < bestRatioDiff) {
bestRatioDiff = ratioDiff
const info = contactInfoMap.get(sessionId)
mutualFriend = {
displayName: info?.displayName || sessionId,
avatarUrl: info?.avatarUrl,
sentCount: stats.sent,
receivedCount: stats.received,
ratio: Math.round(ratio * 100) / 100
}
}
}
}
let socialInitiative: AnnualReportData['socialInitiative'] = null
let totalInitiated = 0
let totalReceived = 0
for (const stats of conversationStarts.values()) {
totalInitiated += stats.initiated
totalReceived += stats.received
}
const totalConversations = totalInitiated + totalReceived
if (totalConversations > 0) {
socialInitiative = {
initiatedChats: totalInitiated,
receivedChats: totalReceived,
initiativeRate: Math.round((totalInitiated / totalConversations) * 1000) / 10
}
}
this.reportProgress('生成报告...', 95, onProgress)
let responseSpeed: AnnualReportData['responseSpeed'] = null
if (responseStatsFromSql && Object.keys(responseStatsFromSql).length > 0) {
let totalSum = 0
let totalCount = 0
let fastestFriendId = ''
let fastestAvgTime = Infinity
for (const [sessionId, stats] of Object.entries(responseStatsFromSql)) {
const count = stats.count || 0
const avg = stats.avg || 0
if (count <= 0 || avg <= 0) continue
totalSum += avg * count
totalCount += count
if (avg < fastestAvgTime) {
fastestAvgTime = avg
fastestFriendId = sessionId
}
}
if (totalCount > 0) {
const avgResponseTime = totalSum / totalCount
const fastestInfo = contactInfoMap.get(fastestFriendId)
responseSpeed = {
avgResponseTime: Math.round(avgResponseTime),
fastestFriend: fastestInfo?.displayName || fastestFriendId,
fastestTime: Math.round(fastestAvgTime)
}
}
} else {
const allResponseTimes: number[] = []
let fastestFriendId = ''
let fastestAvgTime = Infinity
for (const [sessionId, times] of responseTimeStats.entries()) {
if (times.length >= 10) {
allResponseTimes.push(...times)
const avgTime = times.reduce((a, b) => a + b, 0) / times.length
if (avgTime < fastestAvgTime) {
fastestAvgTime = avgTime
fastestFriendId = sessionId
}
}
}
if (allResponseTimes.length > 0) {
const avgResponseTime = allResponseTimes.reduce((a, b) => a + b, 0) / allResponseTimes.length
const fastestInfo = contactInfoMap.get(fastestFriendId)
responseSpeed = {
avgResponseTime: Math.round(avgResponseTime),
fastestFriend: fastestInfo?.displayName || fastestFriendId,
fastestTime: Math.round(fastestAvgTime)
}
}
}
const topPhrases = topPhrasesFromSql && topPhrasesFromSql.length > 0
? topPhrasesFromSql
: Array.from(phraseCount.entries())
.filter(([_, count]) => count >= 2)
.sort((a, b) => b[1] - a[1])
.slice(0, 32)
.map(([phrase, count]) => ({ phrase, count }))
const reportData: AnnualReportData = {
year,
totalMessages,
totalFriends: contactStats.size,
coreFriends,
monthlyTopFriends,
peakDay,
longestStreak,
activityHeatmap: { data: heatmapData },
midnightKing,
selfAvatarUrl,
mutualFriend,
socialInitiative,
responseSpeed,
topPhrases
}
return { success: true, data: reportData }
} catch (e) {
return { success: false, error: String(e) }
}
}
}
export const annualReportService = new AnnualReportService()
+2333
View File
@@ -0,0 +1,2333 @@
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'
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
}
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, { avatarUrl?: string; displayName?: string; updatedAt: number }> = new Map()
private readonly avatarCacheTtlMs = 10 * 60 * 1000
private readonly defaultV1AesKey = 'cfcd208495d565ef'
private hardlinkCache = new Map<string, HardlinkState>()
constructor() {
this.configService = new ConfigService()
}
/**
* 清理账号目录名
*/
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[] = []
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)
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: username
})
}
// 获取联系人信息
await this.enrichSessionsWithContacts(sessions)
return { success: true, sessions }
} 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 now = Date.now()
const missing: string[] = []
for (const session of sessions) {
const cached = this.avatarCache.get(session.username)
if (cached && now - cached.updatedAt < this.avatarCacheTtlMs) {
if (cached.displayName) session.displayName = cached.displayName
if (cached.avatarUrl) {
session.avatarUrl = cached.avatarUrl
continue
}
}
missing.push(session.username)
}
if (missing.length === 0) return
const missingSet = new Set(missing)
const [displayNames, avatarUrls] = await Promise.all([
wcdbService.getDisplayNames(missing),
wcdbService.getAvatarUrls(missing)
])
for (const session of sessions) {
if (!missingSet.has(session.username)) continue
const displayName = displayNames.success && displayNames.map ? displayNames.map[session.username] : undefined
const avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[session.username] : undefined
if (displayName) session.displayName = displayName
if (avatarUrl) session.avatarUrl = avatarUrl
this.avatarCache.set(session.username, {
displayName: session.displayName,
avatarUrl: session.avatarUrl,
updatedAt: now
})
}
} 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
return { success: true, messages: normalized, hasMore }
} catch (e) {
console.error('ChatService: 获取消息失败:', e)
return { success: false, error: String(e) }
}
}
/**
* 尝试从 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 content = 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'
])
)
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
}
}
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 '[通话]'
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 {}
}
}
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 {}
}
}
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)
if (!content || content.length === 0) {
content = this.decodeMaybeCompressed(messageContent)
}
return content
}
/**
* 尝试解码可能压缩的内容
*/
private decodeMaybeCompressed(raw: any): string {
if (!raw) return ''
// 如果是 Buffer/Uint8Array
if (Buffer.isBuffer(raw) || raw instanceof Uint8Array) {
return this.decodeBinaryContent(Buffer.from(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) {
return this.decodeBinaryContent(bytes)
}
}
// 检查是否是 base64 编码
if (this.looksLikeBase64(raw)) {
try {
const bytes = Buffer.from(raw, 'base64')
return this.decodeBinaryContent(bytes)
} catch { }
}
// 普通字符串
return raw
}
return ''
}
/**
* 解码二进制内容(处理 zstd 压缩)
*/
private decodeBinaryContent(data: Buffer): 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, '')
}
// 尝试 latin1 解码
return data.toString('latin1')
} catch {
return ''
}
}
/**
* 检查是否像 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
this.avatarCache.set(username, { avatarUrl, displayName, updatedAt: Date.now() })
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)
const result = await wcdbService.getAvatarUrls([myWxid, cleanedWxid])
if (result.success && result.map) {
const avatarUrl = result.map[myWxid] || result.map[cleanedWxid]
return { success: true, avatarUrl }
}
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')
}
/**
* 下载并缓存表情包
*/
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_storage,accountDir 应该是其父目录
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) {
return { success: false, error: '暂不支持解密自己发送的语音' }
}
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
const mediaDbs = await wcdbService.listMediaDbs()
if (!mediaDbs.success || !mediaDbs.data) return { success: false, error: '获取媒体库失败' }
console.info('[ChatService][Voice] media dbs', mediaDbs.data)
// 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])
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) }
}
}
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
}
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.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)
}
const remaining = payload.subarray(alignedAesSize)
if (xorSize < 0 || xorSize > remaining.length) {
throw new Error('文件格式异常:XOR 数据长度不合法')
}
let rawData = Buffer.alloc(0)
let xoredData = Buffer.alloc(0)
if (xorSize > 0) {
const rawLength = remaining.length - xorSize
if (rawLength < 0) {
throw new Error('文件格式异常:原始数据长度小于XOR长度')
}
rawData = remaining.subarray(0, rawLength)
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
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()
+63
View File
@@ -0,0 +1,63 @@
import Store from 'electron-store'
interface ConfigSchema {
// 数据库相关
dbPath: string // 数据库根目录 (xwechat_files)
decryptKey: string // 解密密钥
myWxid: string // 当前用户 wxid
onboardingDone: boolean
imageXorKey: number
imageAesKey: string
// 缓存相关
cachePath: string
lastOpenedDb: string
lastSession: string
// 界面相关
theme: 'light' | 'dark' | 'system'
themeId: string
language: string
logEnabled: boolean
}
export class ConfigService {
private store: Store<ConfigSchema>
constructor() {
this.store = new Store<ConfigSchema>({
name: 'WeFlow-config',
defaults: {
dbPath: '',
decryptKey: '',
myWxid: '',
onboardingDone: false,
imageXorKey: 0,
imageAesKey: '',
cachePath: '',
lastOpenedDb: '',
lastSession: '',
theme: 'system',
themeId: 'cloud-dancer',
language: 'zh-CN',
logEnabled: false
}
})
}
get<K extends keyof ConfigSchema>(key: K): ConfigSchema[K] {
return this.store.get(key)
}
set<K extends keyof ConfigSchema>(key: K, value: ConfigSchema[K]): void {
this.store.set(key, value)
}
getAll(): ConfigSchema {
return this.store.store
}
clear(): void {
this.store.clear()
}
}
+159
View File
@@ -0,0 +1,159 @@
import { join, basename } from 'path'
import { existsSync, readdirSync, statSync } from 'fs'
import { homedir } from 'os'
export interface WxidInfo {
wxid: string
modifiedTime: number
}
export class DbPathService {
/**
* 自动检测微信数据库根目录
*/
async autoDetect(): Promise<{ success: boolean; path?: string; error?: string }> {
try {
const possiblePaths: string[] = []
const home = homedir()
// 微信4.x 数据目录
possiblePaths.push(join(home, 'Documents', 'xwechat_files'))
// 旧版微信数据目录
possiblePaths.push(join(home, 'Documents', 'WeChat Files'))
for (const path of possiblePaths) {
if (existsSync(path)) {
const rootName = path.split(/[/\\]/).pop()?.toLowerCase()
if (rootName !== 'xwechat_files' && rootName !== 'wechat files') {
continue
}
// 检查是否有有效的账号目录
const accounts = this.findAccountDirs(path)
if (accounts.length > 0) {
return { success: true, path }
}
}
}
return { success: false, error: '未能自动检测到微信数据库目录' }
} catch (e) {
return { success: false, error: String(e) }
}
}
/**
* 查找账号目录(包含 db_storage 或图片目录)
*/
findAccountDirs(rootPath: string): string[] {
const accounts: string[] = []
try {
const entries = readdirSync(rootPath)
for (const entry of entries) {
const entryPath = join(rootPath, entry)
let stat: ReturnType<typeof statSync>
try {
stat = statSync(entryPath)
} catch {
continue
}
if (stat.isDirectory()) {
if (!this.isPotentialAccountName(entry)) continue
// 检查是否有有效账号目录结构
if (this.isAccountDir(entryPath)) {
accounts.push(entry)
}
}
}
} catch {}
return accounts
}
private isAccountDir(entryPath: string): boolean {
return (
existsSync(join(entryPath, 'db_storage')) ||
existsSync(join(entryPath, 'FileStorage', 'Image')) ||
existsSync(join(entryPath, 'FileStorage', 'Image2'))
)
}
private isPotentialAccountName(name: string): boolean {
const lower = name.toLowerCase()
if (lower.startsWith('all') || lower.startsWith('applet') || lower.startsWith('backup') || lower.startsWith('wmpf')) {
return false
}
return true
}
private getAccountModifiedTime(entryPath: string): number {
try {
const accountStat = statSync(entryPath)
let latest = accountStat.mtimeMs
const dbPath = join(entryPath, 'db_storage')
if (existsSync(dbPath)) {
const dbStat = statSync(dbPath)
latest = Math.max(latest, dbStat.mtimeMs)
}
const imagePath = join(entryPath, 'FileStorage', 'Image')
if (existsSync(imagePath)) {
const imageStat = statSync(imagePath)
latest = Math.max(latest, imageStat.mtimeMs)
}
const image2Path = join(entryPath, 'FileStorage', 'Image2')
if (existsSync(image2Path)) {
const image2Stat = statSync(image2Path)
latest = Math.max(latest, image2Stat.mtimeMs)
}
return latest
} catch {
return 0
}
}
/**
* 扫描 wxid 列表
*/
scanWxids(rootPath: string): WxidInfo[] {
const wxids: WxidInfo[] = []
try {
if (this.isAccountDir(rootPath)) {
const wxid = basename(rootPath)
const modifiedTime = this.getAccountModifiedTime(rootPath)
return [{ wxid, modifiedTime }]
}
const accounts = this.findAccountDirs(rootPath)
for (const account of accounts) {
const fullPath = join(rootPath, account)
const modifiedTime = this.getAccountModifiedTime(fullPath)
wxids.push({ wxid: account, modifiedTime })
}
} catch {}
return wxids.sort((a, b) => {
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
return a.wxid.localeCompare(b.wxid)
})
}
/**
* 获取默认数据库路径
*/
getDefaultPath(): string {
const home = homedir()
return join(home, 'Documents', 'xwechat_files')
}
}
export const dbPathService = new DbPathService()
+626
View File
@@ -0,0 +1,626 @@
import * as fs from 'fs'
import * as path from 'path'
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
// ChatLab 格式类型定义
interface ChatLabHeader {
version: string
exportedAt: number
generator: string
description?: string
}
interface ChatLabMeta {
name: string
platform: string
type: 'group' | 'private'
groupId?: string
}
interface ChatLabMember {
platformId: string
accountName: string
groupNickname?: string
avatar?: string
}
interface ChatLabMessage {
sender: string
accountName: string
groupNickname?: string
timestamp: number
type: number
content: string | null
}
interface ChatLabExport {
chatlab: ChatLabHeader
meta: ChatLabMeta
members: ChatLabMember[]
messages: ChatLabMessage[]
}
// 消息类型映射:微信 localType -> ChatLab type
const MESSAGE_TYPE_MAP: Record<number, number> = {
1: 0, // 文本 -> TEXT
3: 1, // 图片 -> IMAGE
34: 2, // 语音 -> VOICE
43: 3, // 视频 -> VIDEO
49: 7, // 链接/文件 -> LINK (需要进一步判断)
47: 5, // 表情包 -> EMOJI
48: 8, // 位置 -> LOCATION
42: 27, // 名片 -> CONTACT
50: 23, // 通话 -> CALL
10000: 80, // 系统消息 -> SYSTEM
}
export interface ExportOptions {
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'sql'
dateRange?: { start: number; end: number } | null
exportMedia?: boolean
exportAvatars?: boolean
}
export interface ExportProgress {
current: number
total: number
currentSession: string
phase: 'preparing' | 'exporting' | 'writing' | 'complete'
}
class ExportService {
private configService: ConfigService
private contactCache: Map<string, { displayName: string; avatarUrl?: string }> = new Map()
constructor() {
this.configService = new ConfigService()
}
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
}
private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> {
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 ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
if (!ok) return { success: false, error: 'WCDB 打开失败' }
return { success: true, cleanedWxid }
}
private async getContactInfo(username: string): Promise<{ displayName: string; avatarUrl?: string }> {
if (this.contactCache.has(username)) {
return this.contactCache.get(username)!
}
const [displayNames, avatarUrls] = await Promise.all([
wcdbService.getDisplayNames([username]),
wcdbService.getAvatarUrls([username])
])
const displayName = displayNames.success && displayNames.map
? (displayNames.map[username] || username)
: username
const avatarUrl = avatarUrls.success && avatarUrls.map
? avatarUrls.map[username]
: undefined
const info = { displayName, avatarUrl }
this.contactCache.set(username, info)
return info
}
/**
* 转换微信消息类型到 ChatLab 类型
*/
private convertMessageType(localType: number, content: string): number {
if (localType === 49) {
const typeMatch = /<type>(\d+)<\/type>/i.exec(content)
if (typeMatch) {
const subType = parseInt(typeMatch[1])
switch (subType) {
case 6: return 4 // 文件 -> FILE
case 33:
case 36: return 24 // 小程序 -> SHARE
case 57: return 25 // 引用回复 -> REPLY
default: return 7 // 链接 -> LINK
}
}
}
return MESSAGE_TYPE_MAP[localType] ?? 99
}
/**
* 解码消息内容
*/
private decodeMessageContent(messageContent: any, compressContent: any): string {
let content = this.decodeMaybeCompressed(compressContent)
if (!content || content.length === 0) {
content = this.decodeMaybeCompressed(messageContent)
}
return content
}
private decodeMaybeCompressed(raw: any): string {
if (!raw) return ''
if (typeof raw === 'string') {
if (raw.length === 0) return ''
if (this.looksLikeHex(raw)) {
const bytes = Buffer.from(raw, 'hex')
if (bytes.length > 0) return this.decodeBinaryContent(bytes)
}
if (this.looksLikeBase64(raw)) {
try {
const bytes = Buffer.from(raw, 'base64')
return this.decodeBinaryContent(bytes)
} catch {
return raw
}
}
return raw
}
return ''
}
private decodeBinaryContent(data: Buffer): string {
if (data.length === 0) return ''
try {
if (data.length >= 4) {
const magic = data.readUInt32LE(0)
if (magic === 0xFD2FB528) {
const fzstd = require('fzstd')
const decompressed = fzstd.decompress(data)
return Buffer.from(decompressed).toString('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, '')
}
return data.toString('latin1')
} catch {
return ''
}
}
private looksLikeHex(s: string): boolean {
if (s.length % 2 !== 0) return false
return /^[0-9a-fA-F]+$/.test(s)
}
private looksLikeBase64(s: string): boolean {
if (s.length % 4 !== 0) return false
return /^[A-Za-z0-9+/=]+$/.test(s)
}
/**
* 解析消息内容为可读文本
*/
private parseMessageContent(content: string, localType: number): string | null {
if (!content) return null
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: {
const title = this.extractXmlValue(content, 'title')
return title || '[链接]'
}
case 50: return '[通话]'
case 10000: return this.cleanSystemMessage(content)
default:
if (content.includes('<type>57</type>')) {
const title = this.extractXmlValue(content, 'title')
return title || '[引用消息]'
}
return this.stripSenderPrefix(content) || null
}
}
private stripSenderPrefix(content: string): string {
return content.replace(/^[\s]*([a-zA-Z0-9_-]+):(?!\/\/)/, '')
}
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 cleanSystemMessage(content: string): string {
return content
.replace(/<img[^>]*>/gi, '')
.replace(/<\/?[a-zA-Z0-9_]+[^>]*>/g, '')
.replace(/\s+/g, ' ')
.trim() || '[系统消息]'
}
/**
* 获取消息类型名称
*/
private getMessageTypeName(localType: number): string {
const typeNames: Record<number, string> = {
1: '文本消息',
3: '图片消息',
34: '语音消息',
42: '名片消息',
43: '视频消息',
47: '动画表情',
48: '位置消息',
49: '链接消息',
50: '通话消息',
10000: '系统消息'
}
return typeNames[localType] || '其他消息'
}
/**
* 格式化时间戳为可读字符串
*/
private formatTimestamp(timestamp: number): string {
const date = new Date(timestamp * 1000)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
private async collectMessages(
sessionId: string,
cleanedMyWxid: string,
dateRange?: { start: number; end: number } | null
): Promise<{ rows: any[]; memberSet: Map<string, ChatLabMember>; firstTime: number | null; lastTime: number | null }> {
const rows: any[] = []
const memberSet = new Map<string, ChatLabMember>()
let firstTime: number | null = null
let lastTime: number | null = null
const cursor = await wcdbService.openMessageCursor(
sessionId,
500,
true,
dateRange?.start || 0,
dateRange?.end || 0
)
if (!cursor.success || !cursor.cursor) {
return { rows, memberSet, firstTime, lastTime }
}
try {
let hasMore = true
while (hasMore) {
const batch = await wcdbService.fetchMessageBatch(cursor.cursor)
if (!batch.success || !batch.rows) break
for (const row of batch.rows) {
const createTime = parseInt(row.create_time || '0', 10)
if (dateRange) {
if (createTime < dateRange.start || createTime > dateRange.end) continue
}
const content = this.decodeMessageContent(row.message_content, row.compress_content)
const localType = parseInt(row.local_type || row.type || '1', 10)
const senderUsername = row.sender_username || ''
const isSendRaw = row.computed_is_send ?? row.is_send ?? '0'
const isSend = parseInt(isSendRaw, 10) === 1
const actualSender = isSend ? cleanedMyWxid : (senderUsername || sessionId)
const memberInfo = await this.getContactInfo(actualSender)
if (!memberSet.has(actualSender)) {
memberSet.set(actualSender, {
platformId: actualSender,
accountName: memberInfo.displayName
})
}
rows.push({
createTime,
localType,
content,
senderUsername: actualSender,
isSend
})
if (firstTime === null || createTime < firstTime) firstTime = createTime
if (lastTime === null || createTime > lastTime) lastTime = createTime
}
hasMore = batch.hasMore === true
}
} finally {
await wcdbService.closeMessageCursor(cursor.cursor)
}
return { rows, memberSet, firstTime, lastTime }
}
/**
* 导出单个会话为 ChatLab 格式
*/
async exportSessionToChatLab(
sessionId: string,
outputPath: string,
options: ExportOptions,
onProgress?: (progress: ExportProgress) => void
): Promise<{ success: boolean; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const cleanedMyWxid = conn.cleanedWxid
const isGroup = sessionId.includes('@chatroom')
const sessionInfo = await this.getContactInfo(sessionId)
onProgress?.({
current: 0,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'preparing'
})
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
const allMessages = collected.rows
allMessages.sort((a, b) => a.createTime - b.createTime)
onProgress?.({
current: 50,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting'
})
const chatLabMessages: ChatLabMessage[] = allMessages.map((msg) => {
const memberInfo = collected.memberSet.get(msg.senderUsername) || {
platformId: msg.senderUsername,
accountName: msg.senderUsername
}
return {
sender: msg.senderUsername,
accountName: memberInfo.accountName,
timestamp: msg.createTime,
type: this.convertMessageType(msg.localType, msg.content),
content: this.parseMessageContent(msg.content, msg.localType)
}
})
const chatLabExport: ChatLabExport = {
chatlab: {
version: '0.0.1',
exportedAt: Math.floor(Date.now() / 1000),
generator: 'WeFlow'
},
meta: {
name: sessionInfo.displayName,
platform: 'wechat',
type: isGroup ? 'group' : 'private',
...(isGroup && { groupId: sessionId })
},
members: Array.from(collected.memberSet.values()),
messages: chatLabMessages
}
onProgress?.({
current: 80,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'writing'
})
if (options.format === 'chatlab-jsonl') {
const lines: string[] = []
lines.push(JSON.stringify({
_type: 'header',
chatlab: chatLabExport.chatlab,
meta: chatLabExport.meta
}))
for (const member of chatLabExport.members) {
lines.push(JSON.stringify({ _type: 'member', ...member }))
}
for (const message of chatLabExport.messages) {
lines.push(JSON.stringify({ _type: 'message', ...message }))
}
fs.writeFileSync(outputPath, lines.join('\n'), 'utf-8')
} else {
fs.writeFileSync(outputPath, JSON.stringify(chatLabExport, null, 2), 'utf-8')
}
onProgress?.({
current: 100,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'complete'
})
return { success: true }
} catch (e) {
console.error('ExportService: 导出失败:', e)
return { success: false, error: String(e) }
}
}
/**
* 导出单个会话为详细 JSON 格式(原项目格式)
*/
async exportSessionToDetailedJson(
sessionId: string,
outputPath: string,
options: ExportOptions,
onProgress?: (progress: ExportProgress) => void
): Promise<{ success: boolean; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const cleanedMyWxid = conn.cleanedWxid
const isGroup = sessionId.includes('@chatroom')
const sessionInfo = await this.getContactInfo(sessionId)
const myInfo = await this.getContactInfo(cleanedMyWxid)
onProgress?.({
current: 0,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'preparing'
})
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
const allMessages: any[] = []
for (const msg of collected.rows) {
const senderInfo = await this.getContactInfo(msg.senderUsername)
const sourceMatch = /<msgsource>[\s\S]*?<\/msgsource>/i.exec(msg.content || '')
const source = sourceMatch ? sourceMatch[0] : ''
allMessages.push({
localId: allMessages.length + 1,
createTime: msg.createTime,
formattedTime: this.formatTimestamp(msg.createTime),
type: this.getMessageTypeName(msg.localType),
localType: msg.localType,
content: this.parseMessageContent(msg.content, msg.localType),
isSend: msg.isSend ? 1 : 0,
senderUsername: msg.senderUsername,
senderDisplayName: senderInfo.displayName,
source,
senderAvatarKey: msg.senderUsername
})
}
allMessages.sort((a, b) => a.createTime - b.createTime)
onProgress?.({
current: 70,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'writing'
})
const detailedExport = {
session: {
wxid: sessionId,
nickname: sessionInfo.displayName,
remark: sessionInfo.displayName,
displayName: sessionInfo.displayName,
type: isGroup ? '群聊' : '私聊',
lastTimestamp: collected.lastTime,
messageCount: allMessages.length
},
messages: allMessages
}
fs.writeFileSync(outputPath, JSON.stringify(detailedExport, null, 2), 'utf-8')
onProgress?.({
current: 100,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'complete'
})
return { success: true }
} catch (e) {
console.error('ExportService: 导出失败:', e)
return { success: false, error: String(e) }
}
}
/**
* 批量导出多个会话
*/
async exportSessions(
sessionIds: string[],
outputDir: string,
options: ExportOptions,
onProgress?: (progress: ExportProgress) => void
): Promise<{ success: boolean; successCount: number; failCount: number; error?: string }> {
let successCount = 0
let failCount = 0
try {
const conn = await this.ensureConnected()
if (!conn.success) {
return { success: false, successCount: 0, failCount: sessionIds.length, error: conn.error }
}
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true })
}
for (let i = 0; i < sessionIds.length; i++) {
const sessionId = sessionIds[i]
const sessionInfo = await this.getContactInfo(sessionId)
onProgress?.({
current: i + 1,
total: sessionIds.length,
currentSession: sessionInfo.displayName,
phase: 'exporting'
})
const safeName = sessionInfo.displayName.replace(/[<>:"/\\|?*]/g, '_')
let ext = '.json'
if (options.format === 'chatlab-jsonl') ext = '.jsonl'
const outputPath = path.join(outputDir, `${safeName}${ext}`)
let result: { success: boolean; error?: string }
if (options.format === 'json') {
result = await this.exportSessionToDetailedJson(sessionId, outputPath, options)
} else if (options.format === 'chatlab' || options.format === 'chatlab-jsonl') {
result = await this.exportSessionToChatLab(sessionId, outputPath, options)
} else {
result = { success: false, error: `不支持的格式: ${options.format}` }
}
if (result.success) {
successCount++
} else {
failCount++
console.error(`导出 ${sessionId} 失败:`, result.error)
}
}
onProgress?.({
current: sessionIds.length,
total: sessionIds.length,
currentSession: '',
phase: 'complete'
})
return { success: true, successCount, failCount }
} catch (e) {
return { success: false, successCount, failCount, error: String(e) }
}
}
}
export const exportService = new ExportService()
+251
View File
@@ -0,0 +1,251 @@
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
export interface GroupChatInfo {
username: string
displayName: string
memberCount: number
avatarUrl?: string
}
export interface GroupMember {
username: string
displayName: string
avatarUrl?: string
}
export interface GroupMessageRank {
member: GroupMember
messageCount: number
}
export interface GroupActiveHours {
hourlyDistribution: Record<number, number>
}
export interface MediaTypeCount {
type: number
name: string
count: number
}
export interface GroupMediaStats {
typeCounts: MediaTypeCount[]
total: number
}
class GroupAnalyticsService {
private configService: ConfigService
constructor() {
this.configService = new ConfigService()
}
private cleanAccountDirName(name: string): string {
const trimmed = name.trim()
if (!trimmed) return trimmed
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
if (match) return match[1]
}
return trimmed
}
private async ensureConnected(): Promise<{ success: boolean; error?: string }> {
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 ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
if (!ok) return { success: false, error: 'WCDB 打开失败' }
return { success: true }
}
async getGroupChats(): Promise<{ success: boolean; data?: GroupChatInfo[]; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success) return { success: false, error: conn.error }
const sessionResult = await wcdbService.getSessions()
if (!sessionResult.success || !sessionResult.sessions) {
return { success: false, error: sessionResult.error || '获取会话失败' }
}
const rows = sessionResult.sessions as Record<string, any>[]
const groupIds = rows
.map((row) => row.username || row.user_name || row.userName || '')
.filter((username) => username.includes('@chatroom'))
const [displayNames, avatarUrls, memberCounts] = await Promise.all([
wcdbService.getDisplayNames(groupIds),
wcdbService.getAvatarUrls(groupIds),
wcdbService.getGroupMemberCounts(groupIds)
])
const groups: GroupChatInfo[] = []
for (const groupId of groupIds) {
groups.push({
username: groupId,
displayName: displayNames.success && displayNames.map
? (displayNames.map[groupId] || groupId)
: groupId,
memberCount: memberCounts.success && memberCounts.map && typeof memberCounts.map[groupId] === 'number'
? memberCounts.map[groupId]
: 0,
avatarUrl: avatarUrls.success && avatarUrls.map ? avatarUrls.map[groupId] : undefined
})
}
groups.sort((a, b) => b.memberCount - a.memberCount)
return { success: true, data: groups }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getGroupMembers(chatroomId: string): Promise<{ success: boolean; data?: GroupMember[]; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success) return { success: false, error: conn.error }
const result = await wcdbService.getGroupMembers(chatroomId)
if (!result.success || !result.members) {
return { success: false, error: result.error || '获取群成员失败' }
}
const members = result.members as { username: string; avatarUrl?: string }[]
const usernames = members.map((m) => m.username)
const displayNames = await wcdbService.getDisplayNames(usernames)
const data: GroupMember[] = members.map((m) => ({
username: m.username,
displayName: displayNames.success && displayNames.map ? (displayNames.map[m.username] || m.username) : m.username,
avatarUrl: m.avatarUrl
}))
return { success: true, data }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getGroupMessageRanking(chatroomId: string, limit: number = 20, startTime?: number, endTime?: number): Promise<{ success: boolean; data?: GroupMessageRank[]; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success) return { success: false, error: conn.error }
const result = await wcdbService.getGroupStats(chatroomId, startTime || 0, endTime || 0)
if (!result.success || !result.data) return { success: false, error: result.error || '聚合失败' }
const d = result.data
const sessionData = d.sessions[chatroomId]
if (!sessionData || !sessionData.senders) return { success: true, data: [] }
const idMap = d.idMap || {}
const senderEntries = Object.entries(sessionData.senders as Record<string, number>)
const rankings: GroupMessageRank[] = senderEntries
.map(([id, count]) => {
const username = idMap[id] || id
return {
member: { username, displayName: username }, // Display name will be resolved below
messageCount: count
}
})
.sort((a, b) => b.messageCount - a.messageCount)
.slice(0, limit)
// 批量获取显示名称和头像
const usernames = rankings.map(r => r.member.username)
const [names, avatars] = await Promise.all([
wcdbService.getDisplayNames(usernames),
wcdbService.getAvatarUrls(usernames)
])
for (const rank of rankings) {
if (names.success && names.map && names.map[rank.member.username]) {
rank.member.displayName = names.map[rank.member.username]
}
if (avatars.success && avatars.map && avatars.map[rank.member.username]) {
rank.member.avatarUrl = avatars.map[rank.member.username]
}
}
return { success: true, data: rankings }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getGroupActiveHours(chatroomId: string, startTime?: number, endTime?: number): Promise<{ success: boolean; data?: GroupActiveHours; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success) return { success: false, error: conn.error }
const result = await wcdbService.getGroupStats(chatroomId, startTime || 0, endTime || 0)
if (!result.success || !result.data) return { success: false, error: result.error || '聚合失败' }
const hourlyDistribution: Record<number, number> = {}
for (let i = 0; i < 24; i++) {
hourlyDistribution[i] = result.data.hourly[i] || 0
}
return { success: true, data: { hourlyDistribution } }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getGroupMediaStats(chatroomId: string, startTime?: number, endTime?: number): Promise<{ success: boolean; data?: GroupMediaStats; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success) return { success: false, error: conn.error }
const result = await wcdbService.getGroupStats(chatroomId, startTime || 0, endTime || 0)
if (!result.success || !result.data) return { success: false, error: result.error || '聚合失败' }
const typeCountsRaw = result.data.typeCounts as Record<string, number>
const mainTypes = [1, 3, 34, 43, 47, 49]
const typeNames: Record<number, string> = {
1: '文本', 3: '图片', 34: '语音', 43: '视频', 47: '表情包', 49: '链接/文件'
}
const countsMap = new Map<number, number>()
let othersCount = 0
for (const [typeStr, count] of Object.entries(typeCountsRaw)) {
const type = parseInt(typeStr, 10)
if (mainTypes.includes(type)) {
countsMap.set(type, (countsMap.get(type) || 0) + count)
} else {
othersCount += count
}
}
const mediaCounts: MediaTypeCount[] = mainTypes
.map(type => ({
type,
name: typeNames[type],
count: countsMap.get(type) || 0
}))
.filter(item => item.count > 0)
if (othersCount > 0) {
mediaCounts.push({ type: -1, name: '其他', count: othersCount })
}
mediaCounts.sort((a, b) => b.count - a.count)
const total = mediaCounts.reduce((sum, item) => sum + item.count, 0)
return { success: true, data: { typeCounts: mediaCounts, total } }
} catch (e) {
return { success: false, error: String(e) }
}
}
}
export const groupAnalyticsService = new GroupAnalyticsService()
+1383
View File
@@ -0,0 +1,1383 @@
import { app, BrowserWindow } from 'electron'
import { basename, dirname, extname, join } from 'path'
import { pathToFileURL } from 'url'
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'fs'
import { writeFile } from 'fs/promises'
import crypto from 'crypto'
import { Worker } from 'worker_threads'
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
type DecryptResult = {
success: boolean
localPath?: string
error?: string
isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图)
}
type HardlinkState = {
imageTable?: string
dirTable?: string
}
export class ImageDecryptService {
private configService = new ConfigService()
private hardlinkCache = new Map<string, HardlinkState>()
private resolvedCache = new Map<string, string>()
private pending = new Map<string, Promise<DecryptResult>>()
private readonly defaultV1AesKey = 'cfcd208495d565ef'
private cacheIndexed = false
private cacheIndexing: Promise<void> | null = null
private updateFlags = new Map<string, boolean>()
private logInfo(message: string, meta?: Record<string, unknown>): void {
if (!this.configService.get('logEnabled')) return
if (meta) {
console.info(message, meta)
} else {
console.info(message)
}
}
async resolveCachedImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }): Promise<DecryptResult & { hasUpdate?: boolean }> {
await this.ensureCacheIndexed()
const cacheKeys = this.getCacheKeys(payload)
const cacheKey = cacheKeys[0]
if (!cacheKey) {
return { success: false, error: '缺少图片标识' }
}
for (const key of cacheKeys) {
const cached = this.resolvedCache.get(key)
if (cached && existsSync(cached) && this.isImageFile(cached)) {
const dataUrl = this.fileToDataUrl(cached)
const isThumb = this.isThumbnailPath(cached)
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
if (isThumb) {
this.triggerUpdateCheck(payload, key, cached)
} else {
this.updateFlags.delete(key)
}
this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(cached))
return { success: true, localPath: dataUrl || this.filePathToUrl(cached), hasUpdate }
}
if (cached && !this.isImageFile(cached)) {
this.resolvedCache.delete(key)
}
}
for (const key of cacheKeys) {
const existing = this.findCachedOutput(key, false, payload.sessionId)
if (existing) {
this.cacheResolvedPaths(key, payload.imageMd5, payload.imageDatName, existing)
const dataUrl = this.fileToDataUrl(existing)
const isThumb = this.isThumbnailPath(existing)
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
if (isThumb) {
this.triggerUpdateCheck(payload, key, existing)
} else {
this.updateFlags.delete(key)
}
this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(existing))
return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate }
}
}
return { success: false, error: '未找到缓存图片' }
}
async decryptImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }): Promise<DecryptResult> {
await this.ensureCacheIndexed()
const cacheKey = payload.imageMd5 || payload.imageDatName
if (!cacheKey) {
return { success: false, error: '缺少图片标识' }
}
if (!payload.force) {
const cached = this.resolvedCache.get(cacheKey)
if (cached && existsSync(cached) && this.isImageFile(cached)) {
const dataUrl = this.fileToDataUrl(cached)
const localPath = dataUrl || this.filePathToUrl(cached)
this.emitCacheResolved(payload, cacheKey, localPath)
return { success: true, localPath }
}
if (cached && !this.isImageFile(cached)) {
this.resolvedCache.delete(cacheKey)
}
}
const pending = this.pending.get(cacheKey)
if (pending) return pending
const task = this.decryptImageInternal(payload, cacheKey)
this.pending.set(cacheKey, task)
try {
return await task
} finally {
this.pending.delete(cacheKey)
}
}
private async decryptImageInternal(
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean },
cacheKey: string
): Promise<DecryptResult> {
try {
const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath')
if (!wxid || !dbPath) {
return { success: false, error: '未配置账号或数据库路径' }
}
const accountDir = this.resolveAccountDir(dbPath, wxid)
if (!accountDir) {
return { success: false, error: '未找到账号目录' }
}
const datPath = await this.resolveDatPath(
accountDir,
payload.imageMd5,
payload.imageDatName,
payload.sessionId,
{ allowThumbnail: !payload.force, skipResolvedCache: Boolean(payload.force) }
)
// 如果要求高清图但没找到,直接返回提示
if (!datPath && payload.force) {
return { success: false, error: '未找到高清图,请在微信中点开该图片查看后重试' }
}
if (!datPath) {
return { success: false, error: '未找到图片文件' }
}
if (!extname(datPath).toLowerCase().includes('dat')) {
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath)
const dataUrl = this.fileToDataUrl(datPath)
const localPath = dataUrl || this.filePathToUrl(datPath)
const isThumb = this.isThumbnailPath(datPath)
this.emitCacheResolved(payload, cacheKey, localPath)
return { success: true, localPath, isThumb }
}
// 查找已缓存的解密文件
const existing = this.findCachedOutput(cacheKey, payload.force, payload.sessionId)
if (existing) {
const isHd = this.isHdPath(existing)
// 如果要求高清但找到的是缩略图,继续解密高清图
if (!(payload.force && !isHd)) {
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existing)
const dataUrl = this.fileToDataUrl(existing)
const localPath = dataUrl || this.filePathToUrl(existing)
const isThumb = this.isThumbnailPath(existing)
this.emitCacheResolved(payload, cacheKey, localPath)
return { success: true, localPath, isThumb }
}
}
const xorKeyRaw = this.configService.get('imageXorKey') as unknown
// 支持十六进制格式(如 0x53)和十进制格式
let xorKey: number
if (typeof xorKeyRaw === 'number') {
xorKey = xorKeyRaw
} else {
const trimmed = String(xorKeyRaw ?? '').trim()
if (trimmed.toLowerCase().startsWith('0x')) {
xorKey = parseInt(trimmed, 16)
} else {
xorKey = parseInt(trimmed, 10)
}
}
if (Number.isNaN(xorKey) || (!xorKey && xorKey !== 0)) {
return { success: false, error: '未配置图片解密密钥' }
}
const aesKeyRaw = this.configService.get('imageAesKey')
const aesKey = this.resolveAesKey(aesKeyRaw)
const decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey)
const ext = this.detectImageExtension(decrypted) || '.jpg'
const outputPath = this.getCacheOutputPathFromDat(datPath, ext, payload.sessionId)
await writeFile(outputPath, decrypted)
const isThumb = this.isThumbnailPath(datPath)
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
if (!isThumb) {
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
}
const dataUrl = this.bufferToDataUrl(decrypted, ext)
const localPath = dataUrl || this.filePathToUrl(outputPath)
this.emitCacheResolved(payload, cacheKey, localPath)
return { success: true, localPath, isThumb }
} catch (e) {
return { success: false, error: String(e) }
}
}
private resolveAccountDir(dbPath: string, wxid: string): string | null {
const cleanedWxid = this.cleanAccountDirName(wxid)
const normalized = dbPath.replace(/[\\/]+$/, '')
const direct = join(normalized, cleanedWxid)
if (existsSync(direct)) return direct
if (this.isAccountDir(normalized)) return normalized
try {
const entries = readdirSync(normalized)
const lowerWxid = cleanedWxid.toLowerCase()
for (const entry of entries) {
const entryPath = join(normalized, entry)
if (!this.isDirectory(entryPath)) continue
const lowerEntry = entry.toLowerCase()
if (lowerEntry === lowerWxid || lowerEntry.startsWith(`${lowerWxid}_`)) {
if (this.isAccountDir(entryPath)) return entryPath
}
}
} catch {}
return null
}
/**
* 获取解密后的缓存目录(用于查找 hardlink.db)
*/
private getDecryptedCacheDir(wxid: string): string | null {
const cachePath = this.configService.get('cachePath')
if (!cachePath) return null
const cleanedWxid = this.cleanAccountDirName(wxid)
const cacheAccountDir = join(cachePath, cleanedWxid)
// 检查缓存目录下是否有 hardlink.db
if (existsSync(join(cacheAccountDir, 'hardlink.db'))) {
return cacheAccountDir
}
if (existsSync(join(cachePath, 'hardlink.db'))) {
return cachePath
}
const cacheHardlinkDir = join(cacheAccountDir, 'db_storage', 'hardlink')
if (existsSync(join(cacheHardlinkDir, 'hardlink.db'))) {
return cacheHardlinkDir
}
return null
}
private isAccountDir(dirPath: string): boolean {
return (
existsSync(join(dirPath, 'hardlink.db')) ||
existsSync(join(dirPath, 'db_storage')) ||
existsSync(join(dirPath, 'FileStorage', 'Image')) ||
existsSync(join(dirPath, 'FileStorage', 'Image2'))
)
}
private isDirectory(path: string): boolean {
try {
return statSync(path).isDirectory()
} catch {
return false
}
}
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
}
private async resolveDatPath(
accountDir: string,
imageMd5?: string,
imageDatName?: string,
sessionId?: string,
options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean }
): Promise<string | null> {
const allowThumbnail = options?.allowThumbnail ?? true
const skipResolvedCache = options?.skipResolvedCache ?? false
this.logInfo('[ImageDecrypt] resolveDatPath', {
accountDir,
imageMd5,
imageDatName,
sessionId,
allowThumbnail,
skipResolvedCache
})
// 优先通过 hardlink.db 查询
if (imageMd5) {
this.logInfo('[ImageDecrypt] hardlink lookup (md5)', { imageMd5, sessionId })
const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageMd5, sessionId)
if (hardlinkPath) {
const isThumb = this.isThumbnailPath(hardlinkPath)
if (allowThumbnail || !isThumb) {
this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5, path: hardlinkPath })
this.cacheDatPath(accountDir, imageMd5, hardlinkPath)
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
return hardlinkPath
}
// hardlink 找到的是缩略图,但要求高清图,直接返回 null,不再搜索
if (!allowThumbnail && isThumb) {
return null
}
}
this.logInfo('[ImageDecrypt] hardlink miss (md5)', { imageMd5 })
if (imageDatName && this.looksLikeMd5(imageDatName) && imageDatName !== imageMd5) {
this.logInfo('[ImageDecrypt] hardlink fallback (datName)', { imageDatName, sessionId })
const fallbackPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId)
if (fallbackPath) {
const isThumb = this.isThumbnailPath(fallbackPath)
if (allowThumbnail || !isThumb) {
this.logInfo('[ImageDecrypt] hardlink hit (datName)', { imageMd5: imageDatName, path: fallbackPath })
this.cacheDatPath(accountDir, imageDatName, fallbackPath)
return fallbackPath
}
if (!allowThumbnail && isThumb) {
return null
}
}
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
}
}
if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) {
this.logInfo('[ImageDecrypt] hardlink lookup (datName)', { imageDatName, sessionId })
const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId)
if (hardlinkPath) {
const isThumb = this.isThumbnailPath(hardlinkPath)
if (allowThumbnail || !isThumb) {
this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5: imageDatName, path: hardlinkPath })
this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
return hardlinkPath
}
// hardlink 找到的是缩略图,但要求高清图,直接返回 null
if (!allowThumbnail && isThumb) {
return null
}
}
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
}
// 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢)
if (!allowThumbnail) {
return null
}
if (!imageDatName) return null
if (!skipResolvedCache) {
const cached = this.resolvedCache.get(imageDatName)
if (cached && existsSync(cached)) {
if (allowThumbnail || !this.isThumbnailPath(cached)) return cached
}
}
const datPath = await this.searchDatFile(accountDir, imageDatName, allowThumbnail)
if (datPath) {
this.logInfo('[ImageDecrypt] searchDatFile hit', { imageDatName, path: datPath })
this.resolvedCache.set(imageDatName, datPath)
this.cacheDatPath(accountDir, imageDatName, datPath)
return datPath
}
const normalized = this.normalizeDatBase(imageDatName)
if (normalized !== imageDatName.toLowerCase()) {
const normalizedPath = await this.searchDatFile(accountDir, normalized, allowThumbnail)
if (normalizedPath) {
this.logInfo('[ImageDecrypt] searchDatFile hit (normalized)', { imageDatName, normalized, path: normalizedPath })
this.resolvedCache.set(imageDatName, normalizedPath)
this.cacheDatPath(accountDir, imageDatName, normalizedPath)
return normalizedPath
}
}
this.logInfo('[ImageDecrypt] resolveDatPath miss', { imageDatName, normalized })
return null
}
private async resolveThumbnailDatPath(
accountDir: string,
imageMd5?: string,
imageDatName?: string,
sessionId?: string
): Promise<string | null> {
if (imageMd5) {
const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageMd5, sessionId)
if (hardlinkPath && this.isThumbnailPath(hardlinkPath)) return hardlinkPath
}
if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) {
const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId)
if (hardlinkPath && this.isThumbnailPath(hardlinkPath)) return hardlinkPath
}
if (!imageDatName) return null
return this.searchDatFile(accountDir, imageDatName, true, true)
}
private async checkHasUpdate(
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string },
cacheKey: string,
cachedPath: string
): Promise<boolean> {
if (!cachedPath || !existsSync(cachedPath)) return false
const isThumbnail = this.isThumbnailPath(cachedPath)
if (!isThumbnail) return false
const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath')
if (!wxid || !dbPath) return false
const accountDir = this.resolveAccountDir(dbPath, wxid)
if (!accountDir) return false
const quickDir = this.getCachedDatDir(accountDir, payload.imageDatName, payload.imageMd5)
if (quickDir) {
const baseName = payload.imageDatName || payload.imageMd5 || cacheKey
const candidate = this.findNonThumbnailVariantInDir(quickDir, baseName)
if (candidate) {
return true
}
}
const thumbPath = await this.resolveThumbnailDatPath(
accountDir,
payload.imageMd5,
payload.imageDatName,
payload.sessionId
)
if (thumbPath) {
const baseName = payload.imageDatName || payload.imageMd5 || cacheKey
const candidate = this.findNonThumbnailVariantInDir(dirname(thumbPath), baseName)
if (candidate) {
return true
}
const searchHit = await this.searchDatFileInDir(dirname(thumbPath), baseName, false)
if (searchHit && this.isNonThumbnailVariantDat(searchHit)) {
return true
}
}
return false
}
private triggerUpdateCheck(
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string },
cacheKey: string,
cachedPath: string
): void {
if (this.updateFlags.get(cacheKey)) return
void this.checkHasUpdate(payload, cacheKey, cachedPath).then((hasUpdate) => {
if (!hasUpdate) return
this.updateFlags.set(cacheKey, true)
this.emitImageUpdate(payload, cacheKey)
}).catch(() => {})
}
private looksLikeMd5(value: string): boolean {
return /^[a-fA-F0-9]{16,32}$/.test(value)
}
private resolveHardlinkDbPath(accountDir: string): string | null {
const wxid = this.configService.get('myWxid')
const cacheDir = wxid ? this.getDecryptedCacheDir(wxid) : null
const candidates = [
join(accountDir, 'db_storage', 'hardlink', 'hardlink.db'),
join(accountDir, 'hardlink.db'),
cacheDir ? join(cacheDir, 'hardlink.db') : null
].filter(Boolean) as string[]
this.logInfo('[ImageDecrypt] hardlink db probe', { accountDir, cacheDir, candidates })
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate
}
this.logInfo('[ImageDecrypt] hardlink db missing', { accountDir, cacheDir, candidates })
return null
}
private async resolveHardlinkPath(accountDir: string, md5: string, _sessionId?: string): Promise<string | null> {
try {
const hardlinkPath = this.resolveHardlinkDbPath(accountDir)
if (!hardlinkPath) {
return null
}
const ready = await this.ensureWcdbReady()
if (!ready) {
this.logInfo('[ImageDecrypt] hardlink db not ready')
return null
}
const state = await this.getHardlinkState(accountDir, hardlinkPath)
if (!state.imageTable) {
this.logInfo('[ImageDecrypt] hardlink table missing', { hardlinkPath })
return null
}
const escapedMd5 = this.escapeSqlString(md5)
const rowResult = await wcdbService.execQuery(
'media',
hardlinkPath,
`SELECT dir1, dir2, file_name FROM ${state.imageTable} WHERE lower(md5) = lower('${escapedMd5}') LIMIT 1`
)
const row = rowResult.success && rowResult.rows ? rowResult.rows[0] : null
if (!row) {
this.logInfo('[ImageDecrypt] hardlink row miss', { md5, table: state.imageTable })
return null
}
const dir1 = this.getRowValue(row, 'dir1')
const dir2 = this.getRowValue(row, 'dir2')
const fileName = this.getRowValue(row, 'file_name') ?? this.getRowValue(row, 'fileName')
if (dir1 === undefined || dir2 === undefined || !fileName) {
this.logInfo('[ImageDecrypt] hardlink row incomplete', { row })
return null
}
const lowerFileName = fileName.toLowerCase()
if (lowerFileName.endsWith('.dat')) {
const baseLower = lowerFileName.slice(0, -4)
if (!this.isLikelyImageDatBase(baseLower) && !this.looksLikeMd5(baseLower)) {
this.logInfo('[ImageDecrypt] hardlink fileName rejected', { fileName })
return null
}
}
// dir1 和 dir2 是 rowid,需要从 dir2id 表查询对应的目录名
let dir1Name: string | null = null
let dir2Name: string | null = null
if (state.dirTable) {
try {
// 通过 rowid 查询目录名
const dir1Result = await wcdbService.execQuery(
'media',
hardlinkPath,
`SELECT username FROM ${state.dirTable} WHERE rowid = ${Number(dir1)} LIMIT 1`
)
if (dir1Result.success && dir1Result.rows && dir1Result.rows.length > 0) {
const value = this.getRowValue(dir1Result.rows[0], 'username')
if (value) dir1Name = String(value)
}
const dir2Result = await wcdbService.execQuery(
'media',
hardlinkPath,
`SELECT username FROM ${state.dirTable} WHERE rowid = ${Number(dir2)} LIMIT 1`
)
if (dir2Result.success && dir2Result.rows && dir2Result.rows.length > 0) {
const value = this.getRowValue(dir2Result.rows[0], 'username')
if (value) dir2Name = String(value)
}
} catch {
// ignore
}
}
if (!dir1Name || !dir2Name) {
this.logInfo('[ImageDecrypt] hardlink dir resolve miss', { dir1, dir2, dir1Name, dir2Name })
return null
}
// 构建路径: msg/attach/{dir1Name}/{dir2Name}/Img/{fileName}
const possiblePaths = [
join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'Img', fileName),
join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'mg', fileName),
join(accountDir, 'msg', 'attach', dir1Name, dir2Name, fileName),
]
for (const fullPath of possiblePaths) {
if (existsSync(fullPath)) {
this.logInfo('[ImageDecrypt] hardlink path hit', { fullPath })
return fullPath
}
}
this.logInfo('[ImageDecrypt] hardlink path miss', { possiblePaths })
return null
} catch {
// ignore
}
return null
}
private async getHardlinkState(accountDir: string, hardlinkPath: string): Promise<HardlinkState> {
const cached = this.hardlinkCache.get(hardlinkPath)
if (cached) return cached
const imageResult = await wcdbService.execQuery(
'media',
hardlinkPath,
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'image_hardlink_info%' ORDER BY name DESC LIMIT 1"
)
const dirResult = await wcdbService.execQuery(
'media',
hardlinkPath,
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'dir2id%' LIMIT 1"
)
const imageTable = imageResult.success && imageResult.rows && imageResult.rows.length > 0
? this.getRowValue(imageResult.rows[0], 'name')
: undefined
const dirTable = dirResult.success && dirResult.rows && dirResult.rows.length > 0
? this.getRowValue(dirResult.rows[0], 'name')
: undefined
const state: HardlinkState = {
imageTable: imageTable ? String(imageTable) : undefined,
dirTable: dirTable ? String(dirTable) : undefined
}
this.logInfo('[ImageDecrypt] hardlink state', { hardlinkPath, imageTable: state.imageTable, dirTable: state.dirTable })
this.hardlinkCache.set(hardlinkPath, state)
return state
}
private async ensureWcdbReady(): Promise<boolean> {
if (wcdbService.isReady()) return true
const dbPath = this.configService.get('dbPath')
const decryptKey = this.configService.get('decryptKey')
const wxid = this.configService.get('myWxid')
if (!dbPath || !decryptKey || !wxid) return false
const cleanedWxid = this.cleanAccountDirName(wxid)
return await wcdbService.open(dbPath, decryptKey, cleanedWxid)
}
private getRowValue(row: any, column: string): any {
if (!row) return undefined
if (Object.prototype.hasOwnProperty.call(row, column)) return row[column]
const target = column.toLowerCase()
for (const key of Object.keys(row)) {
if (key.toLowerCase() === target) return row[key]
}
return undefined
}
private escapeSqlString(value: string): string {
return value.replace(/'/g, "''")
}
private async searchDatFile(
accountDir: string,
datName: string,
allowThumbnail = true,
thumbOnly = false
): Promise<string | null> {
const key = `${accountDir}|${datName}`
const cached = this.resolvedCache.get(key)
if (cached && existsSync(cached)) {
if (allowThumbnail || !this.isThumbnailPath(cached)) return cached
}
const root = join(accountDir, 'msg', 'attach')
if (!existsSync(root)) return null
const found = await this.walkForDatInWorker(root, datName.toLowerCase(), 8, allowThumbnail, thumbOnly)
if (found) {
this.resolvedCache.set(key, found)
return found
}
return null
}
private async searchDatFileInDir(
dirPath: string,
datName: string,
allowThumbnail = true
): Promise<string | null> {
if (!existsSync(dirPath)) return null
return await this.walkForDatInWorker(dirPath, datName.toLowerCase(), 3, allowThumbnail, false)
}
private async walkForDatInWorker(
root: string,
datName: string,
maxDepth = 4,
allowThumbnail = true,
thumbOnly = false
): Promise<string | null> {
const workerPath = join(__dirname, 'imageSearchWorker.js')
return await new Promise((resolve) => {
const worker = new Worker(workerPath, {
workerData: { root, datName, maxDepth, allowThumbnail, thumbOnly }
})
const cleanup = () => {
worker.removeAllListeners()
}
worker.on('message', (msg: any) => {
if (msg && msg.type === 'done') {
cleanup()
void worker.terminate()
resolve(msg.path || null)
return
}
if (msg && msg.type === 'error') {
cleanup()
void worker.terminate()
resolve(null)
}
})
worker.on('error', () => {
cleanup()
void worker.terminate()
resolve(null)
})
})
}
private matchesDatName(fileName: string, datName: string): boolean {
const lower = fileName.toLowerCase()
const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
const normalizedBase = this.normalizeDatBase(base)
const normalizedTarget = this.normalizeDatBase(datName.toLowerCase())
if (normalizedBase === normalizedTarget) return true
const pattern = new RegExp(`^${datName}(?:[._][a-z])?\\.dat$`, 'i')
if (pattern.test(lower)) return true
return lower.endsWith('.dat') && lower.includes(datName)
}
private scoreDatName(fileName: string): number {
if (fileName.includes('.t.dat') || fileName.includes('_t.dat')) return 1
if (fileName.includes('.c.dat') || fileName.includes('_c.dat')) return 1
return 2
}
private isThumbnailDat(fileName: string): boolean {
return fileName.includes('.t.dat') || fileName.includes('_t.dat')
}
private hasXVariant(baseLower: string): boolean {
return /[._][a-z]$/.test(baseLower)
}
private isThumbnailPath(filePath: string): boolean {
const lower = basename(filePath).toLowerCase()
if (this.isThumbnailDat(lower)) return true
const ext = extname(lower)
const base = ext ? lower.slice(0, -ext.length) : lower
// 支持新命名 _thumb 和旧命名 _t
return base.endsWith('_t') || base.endsWith('_thumb')
}
private isHdPath(filePath: string): boolean {
const lower = basename(filePath).toLowerCase()
const ext = extname(lower)
const base = ext ? lower.slice(0, -ext.length) : lower
return base.endsWith('_hd') || base.endsWith('_h')
}
private hasImageVariantSuffix(baseLower: string): boolean {
return /[._][a-z]$/.test(baseLower)
}
private isLikelyImageDatBase(baseLower: string): boolean {
return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(baseLower)
}
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 sanitizeDirName(name: string): string {
const trimmed = name.trim()
if (!trimmed) return 'unknown'
return trimmed.replace(/[<>:"/\\|?*]/g, '_')
}
private resolveTimeDir(datPath: string): string {
const parts = datPath.split(/[\\/]+/)
for (const part of parts) {
if (/^\d{4}-\d{2}$/.test(part)) return part
}
try {
const stat = statSync(datPath)
const year = stat.mtime.getFullYear()
const month = String(stat.mtime.getMonth() + 1).padStart(2, '0')
return `${year}-${month}`
} catch {
return 'unknown-time'
}
}
private findCachedOutput(cacheKey: string, preferHd: boolean = false, sessionId?: string): string | null {
const root = this.getCacheRoot()
const normalizedKey = this.normalizeDatBase(cacheKey.toLowerCase())
const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
if (sessionId) {
const sessionDir = join(root, this.sanitizeDirName(sessionId))
if (existsSync(sessionDir)) {
try {
const sessionEntries = readdirSync(sessionDir)
for (const entry of sessionEntries) {
const timeDir = join(sessionDir, entry)
if (!this.isDirectory(timeDir)) continue
const hit = this.findCachedOutputInDir(timeDir, normalizedKey, extensions, preferHd)
if (hit) return hit
}
} catch {
// ignore
}
}
}
// 新目录结构: Images/{normalizedKey}/{normalizedKey}_thumb.jpg 或 _hd.jpg
const imageDir = join(root, normalizedKey)
if (existsSync(imageDir)) {
const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd)
if (hit) return hit
}
// 兼容旧的平铺结构
for (const ext of extensions) {
const candidate = join(root, `${cacheKey}${ext}`)
if (existsSync(candidate)) return candidate
}
for (const ext of extensions) {
const candidate = join(root, `${cacheKey}_t${ext}`)
if (existsSync(candidate)) return candidate
}
return null
}
private findCachedOutputInDir(
dirPath: string,
normalizedKey: string,
extensions: string[],
preferHd: boolean
): string | null {
for (const ext of extensions) {
if (preferHd) {
const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`)
if (existsSync(hdPath)) return hdPath
}
const thumbPath = join(dirPath, `${normalizedKey}_thumb${ext}`)
if (existsSync(thumbPath)) return thumbPath
if (!preferHd) {
const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`)
if (existsSync(hdPath)) return hdPath
}
}
return null
}
private getCacheOutputPathFromDat(datPath: string, ext: string, sessionId?: string): string {
const name = basename(datPath)
const lower = name.toLowerCase()
const base = lower.endsWith('.dat') ? name.slice(0, -4) : name
// 提取基础名称(去掉 _t, _h 等后缀)
const normalizedBase = this.normalizeDatBase(base)
// 判断是缩略图还是高清图
const isThumb = this.isThumbnailDat(lower)
const suffix = isThumb ? '_thumb' : '_hd'
const contactDir = this.sanitizeDirName(sessionId || 'unknown')
const timeDir = this.resolveTimeDir(datPath)
const outputDir = join(this.getCacheRoot(), contactDir, timeDir)
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true })
}
return join(outputDir, `${normalizedBase}${suffix}${ext}`)
}
private cacheResolvedPaths(cacheKey: string, imageMd5: string | undefined, imageDatName: string | undefined, outputPath: string): void {
this.resolvedCache.set(cacheKey, outputPath)
if (imageMd5 && imageMd5 !== cacheKey) {
this.resolvedCache.set(imageMd5, outputPath)
}
if (imageDatName && imageDatName !== cacheKey && imageDatName !== imageMd5) {
this.resolvedCache.set(imageDatName, outputPath)
}
}
private getCacheKeys(payload: { imageMd5?: string; imageDatName?: string }): string[] {
const keys: string[] = []
const addKey = (value?: string) => {
if (!value) return
const lower = value.toLowerCase()
if (!keys.includes(value)) keys.push(value)
if (!keys.includes(lower)) keys.push(lower)
const normalized = this.normalizeDatBase(lower)
if (normalized && !keys.includes(normalized)) keys.push(normalized)
}
addKey(payload.imageMd5)
if (payload.imageDatName && payload.imageDatName !== payload.imageMd5) {
addKey(payload.imageDatName)
}
return keys
}
private cacheDatPath(accountDir: string, datName: string, datPath: string): void {
const key = `${accountDir}|${datName}`
this.resolvedCache.set(key, datPath)
const normalized = this.normalizeDatBase(datName)
if (normalized && normalized !== datName.toLowerCase()) {
this.resolvedCache.set(`${accountDir}|${normalized}`, datPath)
}
}
private clearUpdateFlags(cacheKey: string, imageMd5?: string, imageDatName?: string): void {
this.updateFlags.delete(cacheKey)
if (imageMd5) this.updateFlags.delete(imageMd5)
if (imageDatName) this.updateFlags.delete(imageDatName)
}
private getCachedDatDir(accountDir: string, imageDatName?: string, imageMd5?: string): string | null {
const keys = [
imageDatName ? `${accountDir}|${imageDatName}` : null,
imageDatName ? `${accountDir}|${this.normalizeDatBase(imageDatName)}` : null,
imageMd5 ? `${accountDir}|${imageMd5}` : null
].filter(Boolean) as string[]
for (const key of keys) {
const cached = this.resolvedCache.get(key)
if (cached && existsSync(cached)) return dirname(cached)
}
return null
}
private findNonThumbnailVariantInDir(dirPath: string, baseName: string): string | null {
let entries: string[]
try {
entries = readdirSync(dirPath)
} catch {
return null
}
const target = this.normalizeDatBase(baseName.toLowerCase())
for (const entry of entries) {
const lower = entry.toLowerCase()
if (!lower.endsWith('.dat')) continue
if (this.isThumbnailDat(lower)) continue
if (!this.hasXVariant(lower.slice(0, -4))) continue
const baseLower = lower.slice(0, -4)
if (this.normalizeDatBase(baseLower) !== target) continue
return join(dirPath, entry)
}
return null
}
private isNonThumbnailVariantDat(datPath: string): boolean {
const lower = basename(datPath).toLowerCase()
if (!lower.endsWith('.dat')) return false
if (this.isThumbnailDat(lower)) return false
const baseLower = lower.slice(0, -4)
return this.hasXVariant(baseLower)
}
private emitImageUpdate(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, cacheKey: string): void {
const message = { cacheKey, imageMd5: payload.imageMd5, imageDatName: payload.imageDatName }
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
win.webContents.send('image:updateAvailable', message)
}
}
}
private emitCacheResolved(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, cacheKey: string, localPath: string): void {
const message = { cacheKey, imageMd5: payload.imageMd5, imageDatName: payload.imageDatName, localPath }
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
win.webContents.send('image:cacheResolved', message)
}
}
}
private async ensureCacheIndexed(): Promise<void> {
if (this.cacheIndexed) return
if (this.cacheIndexing) return this.cacheIndexing
this.cacheIndexing = new Promise((resolve) => {
const root = this.getCacheRoot()
try {
this.indexCacheDir(root, 2, 0)
} catch {
this.cacheIndexed = true
this.cacheIndexing = null
resolve()
return
}
this.cacheIndexed = true
this.cacheIndexing = null
resolve()
})
return this.cacheIndexing
}
private indexCacheDir(root: string, maxDepth: number, depth: number): void {
let entries: string[]
try {
entries = readdirSync(root)
} catch {
return
}
const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
for (const entry of entries) {
const fullPath = join(root, entry)
let stat: ReturnType<typeof statSync>
try {
stat = statSync(fullPath)
} catch {
continue
}
if (stat.isDirectory()) {
if (depth < maxDepth) {
this.indexCacheDir(fullPath, maxDepth, depth + 1)
}
continue
}
if (!stat.isFile()) continue
const lower = entry.toLowerCase()
const ext = extensions.find((item) => lower.endsWith(item))
if (!ext) continue
const base = entry.slice(0, -ext.length)
this.addCacheIndex(base, fullPath)
const normalized = this.normalizeDatBase(base)
if (normalized && normalized !== base.toLowerCase()) {
this.addCacheIndex(normalized, fullPath)
}
}
}
private addCacheIndex(key: string, path: string): void {
const normalizedKey = key.toLowerCase()
const existing = this.resolvedCache.get(normalizedKey)
if (existing) {
const existingIsThumb = this.isThumbnailPath(existing)
const candidateIsThumb = this.isThumbnailPath(path)
if (!existingIsThumb && candidateIsThumb) return
}
this.resolvedCache.set(normalizedKey, path)
}
private getCacheRoot(): string {
const configured = this.configService.get('cachePath')
const root = configured
? join(configured, 'Images')
: join(app.getPath('documents'), 'WeFlow', 'Images')
if (!existsSync(root)) {
mkdirSync(root, { recursive: true })
}
return root
}
private resolveAesKey(aesKeyRaw: string): Buffer | null {
const trimmed = aesKeyRaw?.trim() ?? ''
if (!trimmed) return null
return this.asciiKey16(trimmed)
}
private async decryptDatAuto(datPath: string, xorKey: number, aesKey: Buffer | null): Promise<Buffer> {
const version = this.getDatVersion(datPath)
if (version === 0) {
return this.decryptDatV3(datPath, xorKey)
}
if (version === 1) {
const key = this.asciiKey16(this.defaultV1AesKey)
return this.decryptDatV4(datPath, xorKey, key)
}
// version === 2
if (!aesKey || aesKey.length !== 16) {
throw new Error('请到设置配置图片解密密钥')
}
return this.decryptDatV4(datPath, xorKey, aesKey)
}
private getDatVersion(inputPath: string): number {
if (!existsSync(inputPath)) {
throw new Error('文件不存在')
}
const bytes = readFileSync(inputPath)
if (bytes.length < 6) {
return 0
}
const signature = bytes.subarray(0, 6)
if (this.compareBytes(signature, Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07]))) {
return 1
}
if (this.compareBytes(signature, Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]))) {
return 2
}
return 0
}
private decryptDatV3(inputPath: string, xorKey: number): Buffer {
const data = readFileSync(inputPath)
const out = Buffer.alloc(data.length)
for (let i = 0; i < data.length; i += 1) {
out[i] = data[i] ^ xorKey
}
return out
}
private decryptDatV4(inputPath: string, xorKey: number, aesKey: Buffer): Buffer {
const bytes = readFileSync(inputPath)
if (bytes.length < 0x0f) {
throw new Error('文件太小,无法解析')
}
const header = bytes.subarray(0, 0x0f)
const data = bytes.subarray(0x0f)
const aesSize = this.bytesToInt32(header.subarray(6, 10))
const xorSize = this.bytesToInt32(header.subarray(10, 14))
// AES 数据需要对齐到 16 字节(PKCS7 填充)
// 当 aesSize % 16 === 0 时,仍需要额外 16 字节的填充
const remainder = ((aesSize % 16) + 16) % 16
const alignedAesSize = aesSize + (16 - remainder)
if (alignedAesSize > data.length) {
throw new Error('文件格式异常:AES 数据长度超过文件实际长度')
}
const aesData = data.subarray(0, alignedAesSize)
let unpadded: Buffer = Buffer.alloc(0)
if (aesData.length > 0) {
const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, null)
decipher.setAutoPadding(false)
const decrypted = Buffer.concat([decipher.update(aesData), decipher.final()])
// 使用 PKCS7 填充移除
unpadded = this.strictRemovePadding(decrypted)
}
const remaining = data.subarray(alignedAesSize)
if (xorSize < 0 || xorSize > remaining.length) {
throw new Error('文件格式异常:XOR 数据长度不合法')
}
let rawData = Buffer.alloc(0)
let xoredData = Buffer.alloc(0)
if (xorSize > 0) {
const rawLength = remaining.length - xorSize
if (rawLength < 0) {
throw new Error('文件格式异常:原始数据长度小于XOR长度')
}
rawData = remaining.subarray(0, rawLength)
const xorData = remaining.subarray(rawLength)
xoredData = Buffer.alloc(xorData.length)
for (let i = 0; i < xorData.length; i += 1) {
xoredData[i] = xorData[i] ^ xorKey
}
} else {
rawData = remaining
xoredData = Buffer.alloc(0)
}
return Buffer.concat([unpadded, rawData, xoredData])
}
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)
}
asciiKey16(keyString: string): Buffer {
if (keyString.length < 16) {
throw new Error('AES密钥至少需要16个字符')
}
return Buffer.from(keyString, 'ascii').subarray(0, 16)
}
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 += 1) {
if (data[i] !== paddingLength) {
throw new Error('PKCS7 填充内容非法')
}
}
return data.subarray(0, data.length - paddingLength)
}
private detectImageExtension(buffer: Buffer): string | null {
if (buffer.length < 12) return null
if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) return '.gif'
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) return '.png'
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return '.jpg'
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
}
private bufferToDataUrl(buffer: Buffer, ext: string): string | null {
const mimeType = this.mimeFromExtension(ext)
if (!mimeType) return null
return `data:${mimeType};base64,${buffer.toString('base64')}`
}
private fileToDataUrl(filePath: string): string | null {
try {
const ext = extname(filePath).toLowerCase()
const mimeType = this.mimeFromExtension(ext)
if (!mimeType) return null
const data = readFileSync(filePath)
return `data:${mimeType};base64,${data.toString('base64')}`
} catch {
return null
}
}
private mimeFromExtension(ext: string): string | null {
switch (ext.toLowerCase()) {
case '.gif':
return 'image/gif'
case '.png':
return 'image/png'
case '.jpg':
case '.jpeg':
return 'image/jpeg'
case '.webp':
return 'image/webp'
default:
return null
}
}
private filePathToUrl(filePath: string): string {
const url = pathToFileURL(filePath).toString()
try {
const mtime = statSync(filePath).mtimeMs
return `${url}?v=${Math.floor(mtime)}`
} catch {
return url
}
}
private isImageFile(filePath: string): boolean {
const ext = extname(filePath).toLowerCase()
return ext === '.gif' || ext === '.png' || ext === '.jpg' || ext === '.jpeg' || ext === '.webp'
}
private compareBytes(a: Buffer, b: Buffer): boolean {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) return false
}
return true
}
// 保留原有的批量检测 XOR 密钥方法(用于兼容)
async batchDetectXorKey(dirPath: string, maxFiles: number = 100): Promise<number | null> {
const keyCount: Map<number, number> = new Map()
let filesChecked = 0
const V1_SIGNATURE = Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07])
const V2_SIGNATURE = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
const IMAGE_SIGNATURES: { [key: string]: Buffer } = {
jpg: Buffer.from([0xFF, 0xD8, 0xFF]),
png: Buffer.from([0x89, 0x50, 0x4E, 0x47]),
gif: Buffer.from([0x47, 0x49, 0x46, 0x38]),
bmp: Buffer.from([0x42, 0x4D]),
webp: Buffer.from([0x52, 0x49, 0x46, 0x46])
}
const detectXorKeyFromV3 = (header: Buffer): number | null => {
for (const [, signature] of Object.entries(IMAGE_SIGNATURES)) {
const xorKey = header[0] ^ signature[0]
let valid = true
for (let i = 0; i < signature.length && i < header.length; i++) {
if ((header[i] ^ xorKey) !== signature[i]) {
valid = false
break
}
}
if (valid) return xorKey
}
return null
}
const scanDir = (dir: string) => {
if (filesChecked >= maxFiles) return
try {
const entries = readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
if (filesChecked >= maxFiles) return
const fullPath = join(dir, entry.name)
if (entry.isDirectory()) {
scanDir(fullPath)
} else if (entry.name.endsWith('.dat')) {
try {
const header = Buffer.alloc(16)
const fd = require('fs').openSync(fullPath, 'r')
require('fs').readSync(fd, header, 0, 16, 0)
require('fs').closeSync(fd)
if (header.subarray(0, 6).equals(V1_SIGNATURE) || header.subarray(0, 6).equals(V2_SIGNATURE)) {
continue
}
const key = detectXorKeyFromV3(header)
if (key !== null) {
keyCount.set(key, (keyCount.get(key) || 0) + 1)
filesChecked++
}
} catch {}
}
}
} catch {}
}
scanDir(dirPath)
if (keyCount.size === 0) return null
let maxCount = 0
let mostCommonKey: number | null = null
keyCount.forEach((count, key) => {
if (count > maxCount) {
maxCount = count
mostCommonKey = key
}
})
return mostCommonKey
}
// 保留原有的解密到文件方法(用于兼容)
async decryptToFile(inputPath: string, outputPath: string, xorKey: number, aesKey?: Buffer): Promise<void> {
const version = this.getDatVersion(inputPath)
let decrypted: Buffer
if (version === 0) {
decrypted = this.decryptDatV3(inputPath, xorKey)
} else if (version === 1) {
const key = this.asciiKey16(this.defaultV1AesKey)
decrypted = this.decryptDatV4(inputPath, xorKey, key)
} else {
if (!aesKey || aesKey.length !== 16) {
throw new Error('V4版本需要16字节AES密钥')
}
decrypted = this.decryptDatV4(inputPath, xorKey, aesKey)
}
const outputDir = dirname(outputPath)
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true })
}
await writeFile(outputPath, decrypted)
}
}
export const imageDecryptService = new ImageDecryptService()
+66
View File
@@ -0,0 +1,66 @@
import { imageDecryptService } from './imageDecryptService'
type PreloadImagePayload = {
sessionId?: string
imageMd5?: string
imageDatName?: string
}
type PreloadTask = PreloadImagePayload & {
key: string
}
export class ImagePreloadService {
private queue: PreloadTask[] = []
private pending = new Set<string>()
private active = 0
private readonly maxConcurrent = 2
enqueue(payloads: PreloadImagePayload[]): void {
if (!Array.isArray(payloads) || payloads.length === 0) return
for (const payload of payloads) {
const cacheKey = payload.imageMd5 || payload.imageDatName
if (!cacheKey) continue
const key = `${payload.sessionId || 'unknown'}|${cacheKey}`
if (this.pending.has(key)) continue
this.pending.add(key)
this.queue.push({ ...payload, key })
}
this.processQueue()
}
private processQueue(): void {
while (this.active < this.maxConcurrent && this.queue.length > 0) {
const task = this.queue.shift()
if (!task) return
this.active += 1
void this.handleTask(task).finally(() => {
this.active -= 1
this.pending.delete(task.key)
this.processQueue()
})
}
}
private async handleTask(task: PreloadTask): Promise<void> {
const cacheKey = task.imageMd5 || task.imageDatName
if (!cacheKey) return
try {
const cached = await imageDecryptService.resolveCachedImage({
sessionId: task.sessionId,
imageMd5: task.imageMd5,
imageDatName: task.imageDatName
})
if (cached.success) return
await imageDecryptService.decryptImage({
sessionId: task.sessionId,
imageMd5: task.imageMd5,
imageDatName: task.imageDatName
})
} catch {
// ignore preload failures
}
}
}
export const imagePreloadService = new ImagePreloadService()
+928
View File
@@ -0,0 +1,928 @@
import { app } from 'electron'
import { join, dirname, basename } from 'path'
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
import { execFile, spawn } from 'child_process'
import { promisify } from 'util'
import crypto from 'crypto'
const execFileAsync = promisify(execFile)
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string }
export class KeyService {
private koffi: any = null
private lib: any = null
private initialized = false
private initHook: any = null
private pollKeyData: any = null
private getStatusMessage: any = null
private cleanupHook: any = null
private getLastErrorMsg: any = null
// Win32 APIs
private kernel32: any = null
private user32: any = null
private advapi32: any = null
// Kernel32
private OpenProcess: any = null
private CloseHandle: any = null
private VirtualQueryEx: any = null
private ReadProcessMemory: any = null
private MEMORY_BASIC_INFORMATION: any = null
private TerminateProcess: any = null
// User32
private EnumWindows: any = null
private GetWindowTextW: any = null
private GetWindowTextLengthW: any = null
private GetClassNameW: any = null
private GetWindowThreadProcessId: any = null
private IsWindowVisible: any = null
private EnumChildWindows: any = null
private WNDENUMPROC_PTR: any = null
// Advapi32
private RegOpenKeyExW: any = null
private RegQueryValueExW: any = null
private RegCloseKey: any = null
// Constants
private readonly PROCESS_ALL_ACCESS = 0x1F0FFF
private readonly PROCESS_TERMINATE = 0x0001
private readonly KEY_READ = 0x20019
private readonly HKEY_LOCAL_MACHINE = 0x80000002
private readonly HKEY_CURRENT_USER = 0x80000001
private readonly ERROR_SUCCESS = 0
private getDllPath(): string {
const resourcesPath = app.isPackaged
? join(process.resourcesPath, 'resources')
: join(app.getAppPath(), 'resources')
return join(resourcesPath, 'wx_key.dll')
}
private ensureLoaded(): boolean {
if (this.initialized) return true
try {
this.koffi = require('koffi')
const dllPath = this.getDllPath()
if (!existsSync(dllPath)) return false
this.lib = this.koffi.load(dllPath)
this.initHook = this.lib.func('bool InitializeHook(uint32 targetPid)')
this.pollKeyData = this.lib.func('bool PollKeyData(_Out_ char *keyBuffer, int bufferSize)')
this.getStatusMessage = this.lib.func('bool GetStatusMessage(_Out_ char *msgBuffer, int bufferSize, _Out_ int *outLevel)')
this.cleanupHook = this.lib.func('bool CleanupHook()')
this.getLastErrorMsg = this.lib.func('const char* GetLastErrorMsg()')
this.initialized = true
return true
} catch (e) {
console.error('加载 wx_key.dll 失败:', e)
return false
}
}
private ensureWin32(): boolean {
return process.platform === 'win32'
}
private ensureKernel32(): boolean {
if (this.kernel32) return true
try {
this.koffi = require('koffi')
this.kernel32 = this.koffi.load('kernel32.dll')
const HANDLE = this.koffi.pointer('HANDLE', this.koffi.opaque())
this.MEMORY_BASIC_INFORMATION = this.koffi.struct('MEMORY_BASIC_INFORMATION', {
BaseAddress: 'uint64',
AllocationBase: 'uint64',
AllocationProtect: 'uint32',
RegionSize: 'uint64',
State: 'uint32',
Protect: 'uint32',
Type: 'uint32'
})
// Use explicit definitions to avoid parser issues
this.OpenProcess = this.kernel32.func('OpenProcess', 'HANDLE', ['uint32', 'bool', 'uint32'])
this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['HANDLE'])
this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['HANDLE', 'uint32'])
this.VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'uint64', ['HANDLE', 'uint64', this.koffi.out(this.koffi.pointer(this.MEMORY_BASIC_INFORMATION)), 'uint64'])
this.ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['HANDLE', 'uint64', 'void*', 'uint64', this.koffi.out(this.koffi.pointer('uint64'))])
return true
} catch (e) {
console.error('初始化 kernel32 失败:', e)
return false
}
}
private decodeUtf8(buf: Buffer): string {
const nullIdx = buf.indexOf(0)
return buf.toString('utf8', 0, nullIdx > -1 ? nullIdx : undefined).trim()
}
private ensureUser32(): boolean {
if (this.user32) return true
try {
this.koffi = require('koffi')
this.user32 = this.koffi.load('user32.dll')
// Callbacks
// Define the prototype and its pointer type
const WNDENUMPROC = this.koffi.proto('bool __stdcall (void *hWnd, intptr_t lParam)')
this.WNDENUMPROC_PTR = this.koffi.pointer(WNDENUMPROC)
this.EnumWindows = this.user32.func('EnumWindows', 'bool', [this.WNDENUMPROC_PTR, 'intptr_t'])
this.EnumChildWindows = this.user32.func('EnumChildWindows', 'bool', ['void*', this.WNDENUMPROC_PTR, 'intptr_t'])
this.GetWindowTextW = this.user32.func('GetWindowTextW', 'int', ['void*', this.koffi.out('uint16*'), 'int'])
this.GetWindowTextLengthW = this.user32.func('GetWindowTextLengthW', 'int', ['void*'])
this.GetClassNameW = this.user32.func('GetClassNameW', 'int', ['void*', this.koffi.out('uint16*'), 'int'])
this.GetWindowThreadProcessId = this.user32.func('GetWindowThreadProcessId', 'uint32', ['void*', this.koffi.out('uint32*')])
this.IsWindowVisible = this.user32.func('IsWindowVisible', 'bool', ['void*'])
return true
} catch (e) {
console.error('初始化 user32 失败:', e)
return false
}
}
private ensureAdvapi32(): boolean {
if (this.advapi32) return true
try {
this.koffi = require('koffi')
this.advapi32 = this.koffi.load('advapi32.dll')
// Types
// Use intptr_t for HKEY to match system architecture (64-bit safe)
const HKEY = this.koffi.alias('HKEY', 'intptr_t')
const HKEY_PTR = this.koffi.pointer(HKEY)
this.RegOpenKeyExW = this.advapi32.func('RegOpenKeyExW', 'long', [HKEY, 'uint16*', 'uint32', 'uint32', this.koffi.out(HKEY_PTR)])
this.RegQueryValueExW = this.advapi32.func('RegQueryValueExW', 'long', [HKEY, 'uint16*', 'uint32*', this.koffi.out('uint32*'), this.koffi.out('uint8*'), this.koffi.out('uint32*')])
this.RegCloseKey = this.advapi32.func('RegCloseKey', 'long', [HKEY])
return true
} catch (e) {
console.error('初始化 advapi32 失败:', e)
return false
}
}
private decodeCString(ptr: any): string {
try {
if (typeof ptr === 'string') return ptr
return this.koffi.decode(ptr, 'char', -1)
} catch {
return ''
}
}
// --- WeChat Process & Path Finding ---
// Helper to read simple registry string
private readRegistryString(rootKey: number, subKey: string, valueName: string): string | null {
if (!this.ensureAdvapi32()) return null
// Convert strings to UTF-16 buffers
const subKeyBuf = Buffer.from(subKey + '\0', 'ucs2')
const valueNameBuf = valueName ? Buffer.from(valueName + '\0', 'ucs2') : null
const phkResult = Buffer.alloc(8) // Pointer size (64-bit safe)
if (this.RegOpenKeyExW(rootKey, subKeyBuf, 0, this.KEY_READ, phkResult) !== this.ERROR_SUCCESS) {
return null
}
const hKey = this.koffi.decode(phkResult, 'uintptr_t')
try {
const lpcbData = Buffer.alloc(4)
lpcbData.writeUInt32LE(0, 0) // First call to get size? No, RegQueryValueExW expects initialized size or null to get size.
// Usually we call it twice or just provide a big buffer.
// Let's call twice.
let ret = this.RegQueryValueExW(hKey, valueNameBuf, null, null, null, lpcbData)
if (ret !== this.ERROR_SUCCESS) return null
const size = lpcbData.readUInt32LE(0)
if (size === 0) return null
const dataBuf = Buffer.alloc(size)
ret = this.RegQueryValueExW(hKey, valueNameBuf, null, null, dataBuf, lpcbData)
if (ret !== this.ERROR_SUCCESS) return null
// Read UTF-16 string (remove null terminator)
let str = dataBuf.toString('ucs2')
if (str.endsWith('\0')) str = str.slice(0, -1)
return str
} finally {
this.RegCloseKey(hKey)
}
}
private async findWeChatInstallPath(): Promise<string | null> {
// 1. Registry - Uninstall Keys
const uninstallKeys = [
'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
'SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'
]
const roots = [this.HKEY_LOCAL_MACHINE, this.HKEY_CURRENT_USER]
// NOTE: Scanning subkeys in registry via Koffi is tedious (RegEnumKeyEx).
// Simplified strategy: Check common known registry keys first, then fallback to common paths.
// wx_key searches *all* subkeys of Uninstall, which is robust but complex to port quickly.
// Let's rely on specific Tencent keys first.
// 2. Tencent specific keys
const tencentKeys = [
'Software\\Tencent\\WeChat',
'Software\\WOW6432Node\\Tencent\\WeChat',
'Software\\Tencent\\Weixin',
]
for (const root of roots) {
for (const key of tencentKeys) {
const path = this.readRegistryString(root, key, 'InstallPath')
if (path && existsSync(join(path, 'Weixin.exe'))) return join(path, 'Weixin.exe')
if (path && existsSync(join(path, 'WeChat.exe'))) return join(path, 'WeChat.exe')
}
}
// 3. Uninstall key exact match (sometimes works)
for (const root of roots) {
for (const parent of uninstallKeys) {
// Try WeChat specific subkey
const path = this.readRegistryString(root, parent + '\\WeChat', 'InstallLocation')
if (path && existsSync(join(path, 'Weixin.exe'))) return join(path, 'Weixin.exe')
}
}
// 4. Common Paths
const drives = ['C', 'D', 'E', 'F']
const commonPaths = [
'Program Files\\Tencent\\WeChat\\WeChat.exe',
'Program Files (x86)\\Tencent\\WeChat\\WeChat.exe',
'Program Files\\Tencent\\Weixin\\Weixin.exe',
'Program Files (x86)\\Tencent\\Weixin\\Weixin.exe'
]
for (const drive of drives) {
for (const p of commonPaths) {
const full = join(drive + ':\\', p)
if (existsSync(full)) return full
}
}
return null
}
private async findPidByImageName(imageName: string): Promise<number | null> {
try {
const { stdout } = await execFileAsync('tasklist', ['/FI', `IMAGENAME eq ${imageName}`, '/FO', 'CSV', '/NH'])
const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
for (const line of lines) {
if (line.startsWith('INFO:')) continue
const parts = line.split('","').map((p) => p.replace(/^"|"$/g, ''))
if (parts[0]?.toLowerCase() === imageName.toLowerCase()) {
const pid = Number(parts[1])
if (!Number.isNaN(pid)) return pid
}
}
return null
} catch (e) {
console.error(`获取进程失败 (${imageName}):`, e)
return null
}
}
private async findWeChatPid(): Promise<number | null> {
const names = ['Weixin.exe', 'WeChat.exe']
for (const name of names) {
const pid = await this.findPidByImageName(name)
if (pid) return pid
}
const fallbackPid = await this.waitForWeChatWindow(5000)
return fallbackPid ?? null
}
private async killWeChatProcesses() {
try {
await execFileAsync('taskkill', ['/F', '/IM', 'Weixin.exe'])
await execFileAsync('taskkill', ['/F', '/IM', 'WeChat.exe'])
} catch (e) {
// Ignore if not found
}
await new Promise(r => setTimeout(r, 1000))
}
// --- Window Detection ---
private getWindowTitle(hWnd: any): string {
const len = this.GetWindowTextLengthW(hWnd)
if (len === 0) return ''
const buf = Buffer.alloc((len + 1) * 2)
this.GetWindowTextW(hWnd, buf, len + 1)
return buf.toString('ucs2', 0, len * 2)
}
private getClassName(hWnd: any): string {
const buf = Buffer.alloc(512)
const len = this.GetClassNameW(hWnd, buf, 256)
return buf.toString('ucs2', 0, len * 2)
}
private isWeChatWindowTitle(title: string): boolean {
const normalized = title.trim()
if (!normalized) return false
const lower = normalized.toLowerCase()
return normalized === '微信' || lower === 'wechat' || lower === 'weixin'
}
private async waitForWeChatWindow(timeoutMs = 25000): Promise<number | null> {
if (!this.ensureUser32()) return null
const startTime = Date.now()
while (Date.now() - startTime < timeoutMs) {
let foundPid: number | null = null
const enumWindowsCallback = this.koffi.register((hWnd: any, lParam: any) => {
if (!this.IsWindowVisible(hWnd)) return true
const title = this.getWindowTitle(hWnd)
if (!this.isWeChatWindowTitle(title)) return true
const pidBuf = Buffer.alloc(4)
this.GetWindowThreadProcessId(hWnd, pidBuf)
const pid = pidBuf.readUInt32LE(0)
if (pid) {
foundPid = pid
return false
}
return true
}, this.WNDENUMPROC_PTR)
this.EnumWindows(enumWindowsCallback, 0)
this.koffi.unregister(enumWindowsCallback)
if (foundPid) return foundPid
await new Promise(r => setTimeout(r, 500))
}
return null
}
private collectChildWindowInfos(parent: any): Array<{ title: string; className: string }> {
const children: Array<{ title: string; className: string }> = []
const enumChildCallback = this.koffi.register((hChild: any, lp: any) => {
const title = this.getWindowTitle(hChild).trim()
const className = this.getClassName(hChild).trim()
children.push({ title, className })
return true
}, this.WNDENUMPROC_PTR)
this.EnumChildWindows(parent, enumChildCallback, 0)
this.koffi.unregister(enumChildCallback)
return children
}
private hasReadyComponents(children: Array<{ title: string; className: string }>): boolean {
if (children.length === 0) return false
const readyTexts = ['聊天', '登录', '账号']
const readyClassMarkers = ['WeChat', 'Weixin', 'TXGuiFoundation', 'Qt5', 'ChatList', 'MainWnd', 'BrowserWnd', 'ListView']
const readyChildCountThreshold = 14
let classMatchCount = 0
let titleMatchCount = 0
let hasValidClassName = false
for (const child of children) {
const normalizedTitle = child.title.replace(/\s+/g, '')
if (normalizedTitle) {
if (readyTexts.some(marker => normalizedTitle.includes(marker))) {
return true
}
titleMatchCount += 1
}
const className = child.className
if (className) {
if (readyClassMarkers.some(marker => className.includes(marker))) {
return true
}
if (className.length > 5) {
classMatchCount += 1
hasValidClassName = true
}
}
}
if (classMatchCount >= 3 || titleMatchCount >= 2) return true
if (children.length >= readyChildCountThreshold) return true
if (hasValidClassName && children.length >= 5) return true
return false
}
private async waitForWeChatWindowComponents(pid: number, timeoutMs = 15000): Promise<boolean> {
if (!this.ensureUser32()) return true
const startTime = Date.now()
while (Date.now() - startTime < timeoutMs) {
let ready = false
const enumWindowsCallback = this.koffi.register((hWnd: any, lParam: any) => {
if (!this.IsWindowVisible(hWnd)) return true
const title = this.getWindowTitle(hWnd)
if (!this.isWeChatWindowTitle(title)) return true
const pidBuf = Buffer.alloc(4)
this.GetWindowThreadProcessId(hWnd, pidBuf)
const windowPid = pidBuf.readUInt32LE(0)
if (windowPid !== pid) return true
const children = this.collectChildWindowInfos(hWnd)
if (this.hasReadyComponents(children)) {
ready = true
return false
}
return true
}, this.WNDENUMPROC_PTR)
this.EnumWindows(enumWindowsCallback, 0)
this.koffi.unregister(enumWindowsCallback)
if (ready) return true
await new Promise(r => setTimeout(r, 500))
}
return true
}
// --- Main Methods ---
async autoGetDbKey(
timeoutMs = 60_000,
onStatus?: (message: string, level: number) => void
): Promise<DbKeyResult> {
if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' }
if (!this.ensureLoaded()) return { success: false, error: 'wx_key.dll 未加载' }
if (!this.ensureKernel32()) return { success: false, error: 'Kernel32 Init Failed' }
const logs: string[] = []
// 1. Find Path
onStatus?.('正在定位微信安装路径...', 0)
let wechatPath = await this.findWeChatInstallPath()
if (!wechatPath) {
const err = '未找到微信安装路径,请确认已安装PC微信'
onStatus?.(err, 2)
return { success: false, error: err }
}
// 2. Restart WeChat
onStatus?.('正在重启微信以进行获取...', 0)
await this.killWeChatProcesses()
// 3. Launch
onStatus?.('正在启动微信...', 0)
const sub = spawn(wechatPath, { detached: true, stdio: 'ignore' })
sub.unref()
// 4. Wait for Window & Get PID (Crucial change: discover PID from window)
onStatus?.('等待微信界面就绪...', 0)
const pid = await this.waitForWeChatWindow()
if (!pid) {
return { success: false, error: '启动微信失败或等待界面就绪超时' }
}
onStatus?.(`检测到微信窗口 (PID: ${pid}),正在获取...`, 0)
onStatus?.('正在检测微信界面组件...', 0)
await this.waitForWeChatWindowComponents(pid, 15000)
// 5. Inject
const ok = this.initHook(pid)
if (!ok) {
const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : ''
if (error) {
return { success: false, error }
}
const statusBuffer = Buffer.alloc(256)
const levelOut = [0]
const status = this.getStatusMessage && this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut)
? this.decodeUtf8(statusBuffer)
: ''
return { success: false, error: status || '初始化失败' }
}
const keyBuffer = Buffer.alloc(128)
const start = Date.now()
try {
while (Date.now() - start < timeoutMs) {
if (this.pollKeyData(keyBuffer, keyBuffer.length)) {
const key = this.decodeUtf8(keyBuffer)
if (key.length === 64) {
onStatus?.('密钥获取成功', 1)
return { success: true, key, logs }
}
}
for (let i = 0; i < 5; i++) {
const statusBuffer = Buffer.alloc(256)
const levelOut = [0]
if (!this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut)) {
break
}
const msg = this.decodeUtf8(statusBuffer)
const level = levelOut[0] ?? 0
if (msg) {
logs.push(msg)
onStatus?.(msg, level)
}
}
await new Promise((resolve) => setTimeout(resolve, 120))
}
} finally {
try {
this.cleanupHook()
} catch { }
}
return { success: false, error: '获取密钥超时', logs }
}
// --- Image Key Stuff (Legacy but kept) ---
private isAccountDir(dirPath: string): boolean {
return (
existsSync(join(dirPath, 'db_storage')) ||
existsSync(join(dirPath, 'FileStorage', 'Image')) ||
existsSync(join(dirPath, 'FileStorage', 'Image2'))
)
}
private isPotentialAccountName(name: string): boolean {
const lower = name.toLowerCase()
if (lower.startsWith('all') || lower.startsWith('applet') || lower.startsWith('backup') || lower.startsWith('wmpf')) {
return false
}
if (lower.startsWith('wxid_')) return true
if (/^\d+$/.test(name) && name.length >= 6) return true
return name.length > 5
}
private listAccountDirs(rootDir: string): string[] {
try {
const entries = readdirSync(rootDir)
const high: string[] = []
const low: string[] = []
for (const entry of entries) {
const fullPath = join(rootDir, entry)
try {
if (!statSync(fullPath).isDirectory()) continue
} catch {
continue
}
if (!this.isPotentialAccountName(entry)) {
continue
}
if (this.isAccountDir(fullPath)) {
high.push(fullPath)
} else {
low.push(fullPath)
}
}
return high.length ? high.sort() : low.sort()
} catch {
return []
}
}
private normalizeExistingDir(inputPath: string): string | null {
const trimmed = inputPath.replace(/[\\\\/]+$/, '')
if (!existsSync(trimmed)) return null
try {
const stats = statSync(trimmed)
if (stats.isFile()) {
return dirname(trimmed)
}
} catch {
return null
}
return trimmed
}
private resolveAccountDirFromPath(inputPath: string): string | null {
const normalized = this.normalizeExistingDir(inputPath)
if (!normalized) return null
if (this.isAccountDir(normalized)) return normalized
const lower = normalized.toLowerCase()
if (lower.endsWith('db_storage') || lower.endsWith('filestorage') || lower.endsWith('image') || lower.endsWith('image2')) {
const parent = dirname(normalized)
if (this.isAccountDir(parent)) return parent
const grandParent = dirname(parent)
if (this.isAccountDir(grandParent)) return grandParent
}
const candidates = this.listAccountDirs(normalized)
if (candidates.length) return candidates[0]
return null
}
private resolveAccountDir(manualDir?: string): string | null {
if (manualDir) {
const resolved = this.resolveAccountDirFromPath(manualDir)
if (resolved) return resolved
}
const userProfile = process.env.USERPROFILE
if (!userProfile) return null
const roots = [
join(userProfile, 'Documents', 'xwechat_files'),
join(userProfile, 'Documents', 'WeChat Files')
]
for (const root of roots) {
if (!existsSync(root)) continue
const candidates = this.listAccountDirs(root)
if (candidates.length) return candidates[0]
}
return null
}
private findTemplateDatFiles(rootDir: string): string[] {
const files: string[] = []
const stack = [rootDir]
const maxFiles = 32
while (stack.length && files.length < maxFiles) {
const dir = stack.pop() as string
let entries: string[]
try {
entries = readdirSync(dir)
} catch {
continue
}
for (const entry of entries) {
const fullPath = join(dir, entry)
let stats: any
try {
stats = statSync(fullPath)
} catch {
continue
}
if (stats.isDirectory()) {
stack.push(fullPath)
} else if (entry.endsWith('_t.dat')) {
files.push(fullPath)
if (files.length >= maxFiles) break
}
}
}
if (!files.length) return []
const dateReg = /(\d{4}-\d{2})/
files.sort((a, b) => {
const ma = a.match(dateReg)?.[1]
const mb = b.match(dateReg)?.[1]
if (ma && mb) return mb.localeCompare(ma)
return 0
})
return files.slice(0, 16)
}
private getXorKey(templateFiles: string[]): number | null {
const counts = new Map<string, number>()
for (const file of templateFiles) {
try {
const bytes = readFileSync(file)
if (bytes.length < 2) continue
const x = bytes[bytes.length - 2]
const y = bytes[bytes.length - 1]
const key = `${x}_${y}`
counts.set(key, (counts.get(key) ?? 0) + 1)
} catch { }
}
if (!counts.size) return null
let mostKey = ''
let mostCount = 0
for (const [key, count] of counts) {
if (count > mostCount) {
mostCount = count
mostKey = key
}
}
if (!mostKey) return null
const [xStr, yStr] = mostKey.split('_')
const x = Number(xStr)
const y = Number(yStr)
const xorKey = x ^ 0xFF
const check = y ^ 0xD9
return xorKey === check ? xorKey : null
}
private getCiphertextFromTemplate(templateFiles: string[]): Buffer | null {
for (const file of templateFiles) {
try {
const bytes = readFileSync(file)
if (bytes.length < 0x1f) continue
if (
bytes[0] === 0x07 &&
bytes[1] === 0x08 &&
bytes[2] === 0x56 &&
bytes[3] === 0x32 &&
bytes[4] === 0x08 &&
bytes[5] === 0x07
) {
return bytes.subarray(0x0f, 0x1f)
}
} catch { }
}
return null
}
private isAlphaNumAscii(byte: number): boolean {
return (byte >= 0x61 && byte <= 0x7a) || (byte >= 0x41 && byte <= 0x5a) || (byte >= 0x30 && byte <= 0x39)
}
private isUtf16AsciiKey(buf: Buffer, start: number): boolean {
if (start + 64 > buf.length) return false
for (let j = 0; j < 32; j++) {
const charByte = buf[start + j * 2]
const nullByte = buf[start + j * 2 + 1]
if (nullByte !== 0x00 || !this.isAlphaNumAscii(charByte)) {
return false
}
}
return true
}
private verifyKey(ciphertext: Buffer, keyBytes: Buffer): boolean {
try {
const key = keyBytes.subarray(0, 16)
const decipher = crypto.createDecipheriv('aes-128-ecb', key, null)
decipher.setAutoPadding(false)
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()])
return decrypted[0] === 0xff && decrypted[1] === 0xd8 && decrypted[2] === 0xff
} catch {
return false
}
}
private getMemoryRegions(hProcess: any): Array<[number, number]> {
const regions: Array<[number, number]> = []
const MEM_COMMIT = 0x1000
const MEM_PRIVATE = 0x20000
const MEM_MAPPED = 0x40000
const MEM_IMAGE = 0x1000000
const PAGE_NOACCESS = 0x01
const PAGE_GUARD = 0x100
let address = 0
const maxAddress = 0x7fffffffffff
while (address >= 0 && address < maxAddress) {
const info: any = {}
const result = this.VirtualQueryEx(hProcess, address, info, this.koffi.sizeof(this.MEMORY_BASIC_INFORMATION))
if (!result) break
const state = info.State
const protect = info.Protect
const type = info.Type
const regionSize = Number(info.RegionSize)
if (state === MEM_COMMIT && (protect & PAGE_NOACCESS) === 0 && (protect & PAGE_GUARD) === 0) {
if (type === MEM_PRIVATE || type === MEM_MAPPED || type === MEM_IMAGE) {
regions.push([Number(info.BaseAddress), regionSize])
}
}
const nextAddress = address + regionSize
if (nextAddress <= address) break
address = nextAddress
}
return regions
}
private readProcessMemory(hProcess: any, address: number, size: number): Buffer | null {
const buffer = Buffer.alloc(size)
const bytesRead = [0]
const ok = this.ReadProcessMemory(hProcess, address, buffer, size, bytesRead)
if (!ok || bytesRead[0] === 0) return null
return buffer.subarray(0, bytesRead[0])
}
private async getAesKeyFromMemory(pid: number, ciphertext: Buffer): Promise<string | null> {
if (!this.ensureKernel32()) return null
const hProcess = this.OpenProcess(this.PROCESS_ALL_ACCESS, false, pid)
if (!hProcess) return null
try {
const regions = this.getMemoryRegions(hProcess)
const chunkSize = 4 * 1024 * 1024
const overlap = 65
for (const [baseAddress, regionSize] of regions) {
if (regionSize > 100 * 1024 * 1024) continue
let offset = 0
let trailing: Buffer | null = null
while (offset < regionSize) {
const remaining = regionSize - offset
const currentChunkSize = remaining > chunkSize ? chunkSize : remaining
const chunk = this.readProcessMemory(hProcess, baseAddress + offset, currentChunkSize)
if (!chunk || !chunk.length) {
offset += currentChunkSize
trailing = null
continue
}
let dataToScan: Buffer
if (trailing && trailing.length) {
dataToScan = Buffer.concat([trailing, chunk])
} else {
dataToScan = chunk
}
for (let i = 0; i < dataToScan.length - 34; i++) {
if (this.isAlphaNumAscii(dataToScan[i])) continue
let valid = true
for (let j = 1; j <= 32; j++) {
if (!this.isAlphaNumAscii(dataToScan[i + j])) {
valid = false
break
}
}
if (valid && this.isAlphaNumAscii(dataToScan[i + 33])) {
valid = false
}
if (valid) {
const keyBytes = dataToScan.subarray(i + 1, i + 33)
if (this.verifyKey(ciphertext, keyBytes)) {
return keyBytes.toString('ascii')
}
}
}
for (let i = 0; i < dataToScan.length - 65; i++) {
if (!this.isUtf16AsciiKey(dataToScan, i)) continue
const keyBytes = Buffer.alloc(32)
for (let j = 0; j < 32; j++) {
keyBytes[j] = dataToScan[i + j * 2]
}
if (this.verifyKey(ciphertext, keyBytes)) {
return keyBytes.toString('ascii')
}
}
const start = dataToScan.length - overlap
trailing = dataToScan.subarray(start < 0 ? 0 : start)
offset += currentChunkSize
}
}
return null
} finally {
try {
this.CloseHandle(hProcess)
} catch { }
}
}
async autoGetImageKey(
manualDir?: string,
onProgress?: (message: string) => void
): Promise<ImageKeyResult> {
if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' }
if (!this.ensureLoaded()) return { success: false, error: 'wx_key.dll 未加载' }
if (!this.ensureKernel32()) return { success: false, error: '初始化系统 API 失败' }
onProgress?.('正在定位微信账号目录...')
const accountDir = this.resolveAccountDir(manualDir)
if (!accountDir) return { success: false, error: '未找到微信账号目录' }
onProgress?.('正在收集模板文件...')
const templateFiles = this.findTemplateDatFiles(accountDir)
if (!templateFiles.length) return { success: false, error: '未找到模板文件' }
onProgress?.('正在计算 XOR 密钥...')
const xorKey = this.getXorKey(templateFiles)
if (xorKey == null) return { success: false, error: '无法计算 XOR 密钥' }
onProgress?.('正在读取加密模板数据...')
const ciphertext = this.getCiphertextFromTemplate(templateFiles)
if (!ciphertext) return { success: false, error: '无法读取加密模板数据' }
const pid = await this.findWeChatPid()
if (!pid) return { success: false, error: '未检测到微信进程' }
onProgress?.('正在扫描内存获取 AES 密钥...')
const aesKey = await this.getAesKeyFromMemory(pid, ciphertext)
if (!aesKey) {
return {
success: false,
error: '未能从内存中获取 AES 密钥,请打开朋友圈图片后重试'
}
}
return { success: true, xorKey, aesKey: aesKey.slice(0, 16) }
}
}
+1210
View File
@@ -0,0 +1,1210 @@
import { join, dirname, basename } from 'path'
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
export class WcdbService {
private resourcesPath: string | null = null
private userDataPath: string | null = null
private logEnabled = false
private lib: any = null
private koffi: any = null
private initialized = false
private handle: number | null = null
private currentPath: string | null = null
private currentKey: string | null = null
private currentWxid: string | null = null
// 函数引用
private wcdbInit: any = null
private wcdbShutdown: any = null
private wcdbOpenAccount: any = null
private wcdbCloseAccount: any = null
private wcdbSetMyWxid: any = null
private wcdbFreeString: any = null
private wcdbGetSessions: any = null
private wcdbGetMessages: any = null
private wcdbGetMessageCount: any = null
private wcdbGetDisplayNames: any = null
private wcdbGetAvatarUrls: any = null
private wcdbGetGroupMemberCount: any = null
private wcdbGetGroupMemberCounts: any = null
private wcdbGetGroupMembers: any = null
private wcdbGetMessageTables: any = null
private wcdbGetMessageMeta: any = null
private wcdbGetContact: any = null
private wcdbGetMessageTableStats: any = null
private wcdbGetAggregateStats: any = null
private wcdbGetAvailableYears: any = null
private wcdbGetAnnualReportStats: any = null
private wcdbGetAnnualReportExtras: any = null
private wcdbGetGroupStats: any = null
private wcdbOpenMessageCursor: any = null
private wcdbOpenMessageCursorLite: any = null
private wcdbFetchMessageBatch: any = null
private wcdbCloseMessageCursor: any = null
private wcdbGetLogs: any = null
private wcdbExecQuery: any = null
private wcdbListMessageDbs: any = null
private wcdbListMediaDbs: any = null
private wcdbGetMessageById: any = null
private wcdbGetEmoticonCdnUrl: any = null
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
private readonly avatarCacheTtlMs = 10 * 60 * 1000
setPaths(resourcesPath: string, userDataPath: string): void {
this.resourcesPath = resourcesPath
this.userDataPath = userDataPath
}
setLogEnabled(enabled: boolean): void {
this.logEnabled = enabled
}
/**
* 获取 DLL 路径
*/
private getDllPath(): string {
const envDllPath = process.env.WCDB_DLL_PATH
if (envDllPath && envDllPath.length > 0) {
return envDllPath
}
const envResourcesPath = process.env.WCDB_RESOURCES_PATH
if (envResourcesPath && envResourcesPath.length > 0) {
return join(envResourcesPath, 'wcdb_api.dll')
}
if (this.resourcesPath && this.resourcesPath.length > 0) {
return join(this.resourcesPath, 'wcdb_api.dll')
}
const fallbackBase = process.resourcesPath || join(process.cwd(), 'resources')
return join(fallbackBase, 'wcdb_api.dll')
}
private isLogEnabled(): boolean {
if (process.env.WEFLOW_WORKER === '1') return false
if (process.env.WCDB_LOG_ENABLED === '1') return true
return this.logEnabled
}
private writeLog(message: string, force = false): void {
if (!force && !this.isLogEnabled()) return
try {
const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd()
const dir = join(base, 'logs')
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
const line = `[${new Date().toISOString()}] ${message}\n`
appendFileSync(join(dir, 'wcdb.log'), line, { encoding: 'utf8' })
} catch { }
}
/**
* 递归查找 session.db 文件
*/
private findSessionDb(dir: string, depth = 0): string | null {
if (depth > 5) return null
try {
const entries = readdirSync(dir)
for (const entry of entries) {
if (entry.toLowerCase() === 'session.db') {
const fullPath = join(dir, entry)
if (statSync(fullPath).isFile()) {
return fullPath
}
}
}
for (const entry of entries) {
const fullPath = join(dir, entry)
try {
if (statSync(fullPath).isDirectory()) {
const found = this.findSessionDb(fullPath, depth + 1)
if (found) return found
}
} catch { }
}
} catch (e) {
console.error('查找 session.db 失败:', e)
}
return null
}
private resolveDbStoragePath(basePath: string, wxid: string): string | null {
if (!basePath) return null
const normalized = basePath.replace(/[\\\\/]+$/, '')
if (normalized.toLowerCase().endsWith('db_storage') && existsSync(normalized)) {
return normalized
}
const direct = join(normalized, 'db_storage')
if (existsSync(direct)) {
return direct
}
if (wxid) {
const viaWxid = join(normalized, wxid, 'db_storage')
if (existsSync(viaWxid)) {
return viaWxid
}
// 兼容目录名包含额外后缀(如 wxid_xxx_1234)
try {
const entries = readdirSync(normalized)
const lowerWxid = wxid.toLowerCase()
const candidates = entries.filter((entry) => {
const entryPath = join(normalized, entry)
try {
if (!statSync(entryPath).isDirectory()) return false
} catch {
return false
}
const lowerEntry = entry.toLowerCase()
return lowerEntry === lowerWxid || lowerEntry.startsWith(`${lowerWxid}_`)
})
for (const entry of candidates) {
const candidate = join(normalized, entry, 'db_storage')
if (existsSync(candidate)) {
return candidate
}
}
} catch { }
}
return null
}
/**
* 初始化 WCDB
*/
async initialize(): Promise<boolean> {
if (this.initialized) return true
try {
this.koffi = require('koffi')
const dllPath = this.getDllPath()
if (!existsSync(dllPath)) {
console.error('WCDB DLL 不存在:', dllPath)
return false
}
this.lib = this.koffi.load(dllPath)
// 定义类型
// wcdb_status wcdb_init()
this.wcdbInit = this.lib.func('int32 wcdb_init()')
// wcdb_status wcdb_shutdown()
this.wcdbShutdown = this.lib.func('int32 wcdb_shutdown()')
// wcdb_status wcdb_open_account(const char* session_db_path, const char* hex_key, wcdb_handle* out_handle)
// wcdb_handle 是 int64_t
this.wcdbOpenAccount = this.lib.func('int32 wcdb_open_account(const char* path, const char* key, _Out_ int64* handle)')
// wcdb_status wcdb_close_account(wcdb_handle handle)
// C 接口是 int64, koffi 返回 handle 是 number 类型
this.wcdbCloseAccount = this.lib.func('int32 wcdb_close_account(int64 handle)')
// wcdb_status wcdb_set_my_wxid(wcdb_handle handle, const char* wxid)
try {
this.wcdbSetMyWxid = this.lib.func('int32 wcdb_set_my_wxid(int64 handle, const char* wxid)')
} catch {
this.wcdbSetMyWxid = null
}
// void wcdb_free_string(char* ptr)
this.wcdbFreeString = this.lib.func('void wcdb_free_string(void* ptr)')
// wcdb_status wcdb_get_sessions(wcdb_handle handle, char** out_json)
this.wcdbGetSessions = this.lib.func('int32 wcdb_get_sessions(int64 handle, _Out_ void** outJson)')
// wcdb_status wcdb_get_messages(wcdb_handle handle, const char* username, int32_t limit, int32_t offset, char** out_json)
this.wcdbGetMessages = this.lib.func('int32 wcdb_get_messages(int64 handle, const char* username, int32 limit, int32 offset, _Out_ void** outJson)')
// wcdb_status wcdb_get_message_count(wcdb_handle handle, const char* username, int32_t* out_count)
this.wcdbGetMessageCount = this.lib.func('int32 wcdb_get_message_count(int64 handle, const char* username, _Out_ int32* outCount)')
// wcdb_status wcdb_get_display_names(wcdb_handle handle, const char* usernames_json, char** out_json)
this.wcdbGetDisplayNames = this.lib.func('int32 wcdb_get_display_names(int64 handle, const char* usernamesJson, _Out_ void** outJson)')
// wcdb_status wcdb_get_avatar_urls(wcdb_handle handle, const char* usernames_json, char** out_json)
this.wcdbGetAvatarUrls = this.lib.func('int32 wcdb_get_avatar_urls(int64 handle, const char* usernamesJson, _Out_ void** outJson)')
// wcdb_status wcdb_get_group_member_count(wcdb_handle handle, const char* chatroom_id, int32_t* out_count)
this.wcdbGetGroupMemberCount = this.lib.func('int32 wcdb_get_group_member_count(int64 handle, const char* chatroomId, _Out_ int32* outCount)')
// wcdb_status wcdb_get_group_member_counts(wcdb_handle handle, const char* chatroom_ids_json, char** out_json)
try {
this.wcdbGetGroupMemberCounts = this.lib.func('int32 wcdb_get_group_member_counts(int64 handle, const char* chatroomIdsJson, _Out_ void** outJson)')
} catch {
this.wcdbGetGroupMemberCounts = null
}
// wcdb_status wcdb_get_group_members(wcdb_handle handle, const char* chatroom_id, char** out_json)
this.wcdbGetGroupMembers = this.lib.func('int32 wcdb_get_group_members(int64 handle, const char* chatroomId, _Out_ void** outJson)')
// wcdb_status wcdb_get_message_tables(wcdb_handle handle, const char* session_id, char** out_json)
this.wcdbGetMessageTables = this.lib.func('int32 wcdb_get_message_tables(int64 handle, const char* sessionId, _Out_ void** outJson)')
// wcdb_status wcdb_get_message_meta(wcdb_handle handle, const char* db_path, const char* table_name, int32_t limit, int32_t offset, char** out_json)
this.wcdbGetMessageMeta = this.lib.func('int32 wcdb_get_message_meta(int64 handle, const char* dbPath, const char* tableName, int32 limit, int32 offset, _Out_ void** outJson)')
// wcdb_status wcdb_get_contact(wcdb_handle handle, const char* username, char** out_json)
this.wcdbGetContact = this.lib.func('int32 wcdb_get_contact(int64 handle, const char* username, _Out_ void** outJson)')
// wcdb_status wcdb_get_message_table_stats(wcdb_handle handle, const char* session_id, char** out_json)
this.wcdbGetMessageTableStats = this.lib.func('int32 wcdb_get_message_table_stats(int64 handle, const char* sessionId, _Out_ void** outJson)')
// wcdb_status wcdb_get_aggregate_stats(wcdb_handle handle, const char* session_ids_json, int32_t begin_timestamp, int32_t end_timestamp, char** out_json)
this.wcdbGetAggregateStats = this.lib.func('int32 wcdb_get_aggregate_stats(int64 handle, const char* sessionIdsJson, int32 begin, int32 end, _Out_ void** outJson)')
// wcdb_status wcdb_get_available_years(wcdb_handle handle, const char* session_ids_json, char** out_json)
try {
this.wcdbGetAvailableYears = this.lib.func('int32 wcdb_get_available_years(int64 handle, const char* sessionIdsJson, _Out_ void** outJson)')
} catch {
this.wcdbGetAvailableYears = null
}
// wcdb_status wcdb_get_annual_report_stats(wcdb_handle handle, const char* session_ids_json, int32_t begin_timestamp, int32_t end_timestamp, char** out_json)
try {
this.wcdbGetAnnualReportStats = this.lib.func('int32 wcdb_get_annual_report_stats(int64 handle, const char* sessionIdsJson, int32 begin, int32 end, _Out_ void** outJson)')
} catch {
this.wcdbGetAnnualReportStats = null
}
// wcdb_status wcdb_get_annual_report_extras(wcdb_handle handle, const char* session_ids_json, int32_t begin_timestamp, int32_t end_timestamp, int32_t peak_day_begin, int32_t peak_day_end, char** out_json)
try {
this.wcdbGetAnnualReportExtras = this.lib.func('int32 wcdb_get_annual_report_extras(int64 handle, const char* sessionIdsJson, int32 begin, int32 end, int32 peakBegin, int32 peakEnd, _Out_ void** outJson)')
} catch {
this.wcdbGetAnnualReportExtras = null
}
// wcdb_status wcdb_get_group_stats(wcdb_handle handle, const char* chatroom_id, int32_t begin_timestamp, int32_t end_timestamp, char** out_json)
try {
this.wcdbGetGroupStats = this.lib.func('int32 wcdb_get_group_stats(int64 handle, const char* chatroomId, int32 begin, int32 end, _Out_ void** outJson)')
} catch {
this.wcdbGetGroupStats = null
}
// wcdb_status wcdb_open_message_cursor(wcdb_handle handle, const char* session_id, int32_t batch_size, int32_t ascending, int32_t begin_timestamp, int32_t end_timestamp, wcdb_cursor* out_cursor)
this.wcdbOpenMessageCursor = this.lib.func('int32 wcdb_open_message_cursor(int64 handle, const char* sessionId, int32 batchSize, int32 ascending, int32 beginTimestamp, int32 endTimestamp, _Out_ int64* outCursor)')
// wcdb_status wcdb_open_message_cursor_lite(wcdb_handle handle, const char* session_id, int32_t batch_size, int32_t ascending, int32_t begin_timestamp, int32_t end_timestamp, wcdb_cursor* out_cursor)
try {
this.wcdbOpenMessageCursorLite = this.lib.func('int32 wcdb_open_message_cursor_lite(int64 handle, const char* sessionId, int32 batchSize, int32 ascending, int32 beginTimestamp, int32 endTimestamp, _Out_ int64* outCursor)')
} catch {
this.wcdbOpenMessageCursorLite = null
}
// wcdb_status wcdb_fetch_message_batch(wcdb_handle handle, wcdb_cursor cursor, char** out_json, int32_t* out_has_more)
this.wcdbFetchMessageBatch = this.lib.func('int32 wcdb_fetch_message_batch(int64 handle, int64 cursor, _Out_ void** outJson, _Out_ int32* outHasMore)')
// wcdb_status wcdb_close_message_cursor(wcdb_handle handle, wcdb_cursor cursor)
this.wcdbCloseMessageCursor = this.lib.func('int32 wcdb_close_message_cursor(int64 handle, int64 cursor)')
// wcdb_status wcdb_get_logs(char** out_json)
this.wcdbGetLogs = this.lib.func('int32 wcdb_get_logs(_Out_ void** outJson)')
// wcdb_status wcdb_exec_query(wcdb_handle handle, const char* db_kind, const char* db_path, const char* sql, char** out_json)
this.wcdbExecQuery = this.lib.func('int32 wcdb_exec_query(int64 handle, const char* kind, const char* path, const char* sql, _Out_ void** outJson)')
// wcdb_status wcdb_get_emoticon_cdn_url(wcdb_handle handle, const char* db_path, const char* md5, char** out_url)
this.wcdbGetEmoticonCdnUrl = this.lib.func('int32 wcdb_get_emoticon_cdn_url(int64 handle, const char* dbPath, const char* md5, _Out_ void** outUrl)')
// wcdb_status wcdb_list_message_dbs(wcdb_handle handle, char** out_json)
this.wcdbListMessageDbs = this.lib.func('int32 wcdb_list_message_dbs(int64 handle, _Out_ void** outJson)')
// wcdb_status wcdb_list_media_dbs(wcdb_handle handle, char** out_json)
this.wcdbListMediaDbs = this.lib.func('int32 wcdb_list_media_dbs(int64 handle, _Out_ void** outJson)')
// wcdb_status wcdb_get_message_by_id(wcdb_handle handle, const char* session_id, int32 local_id, char** out_json)
this.wcdbGetMessageById = this.lib.func('int32 wcdb_get_message_by_id(int64 handle, const char* sessionId, int32 localId, _Out_ void** outJson)')
// 初始化
const initResult = this.wcdbInit()
if (initResult !== 0) {
console.error('WCDB 初始化失败:', initResult)
return false
}
this.initialized = true
return true
} catch (e) {
console.error('WCDB 初始化异常:', e)
return false
}
}
/**
* 测试数据库连接
*/
async testConnection(dbPath: string, hexKey: string, wxid: string): Promise<{ success: boolean; error?: string; sessionCount?: number }> {
try {
// 如果当前已经有相同参数的活动连接,直接返回成功
if (this.handle !== null &&
this.currentPath === dbPath &&
this.currentKey === hexKey &&
this.currentWxid === wxid) {
return { success: true, sessionCount: 0 }
}
if (!this.initialized) {
const initOk = await this.initialize()
if (!initOk) {
return { success: false, error: 'WCDB 初始化失败' }
}
}
// 构建 db_storage 目录路径
const dbStoragePath = this.resolveDbStoragePath(dbPath, wxid)
this.writeLog(`testConnection dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`)
if (!dbStoragePath || !existsSync(dbStoragePath)) {
return { success: false, error: `数据库目录不存在: ${dbPath}` }
}
// 递归查找 session.db
const sessionDbPath = this.findSessionDb(dbStoragePath)
this.writeLog(`testConnection sessionDb=${sessionDbPath || 'null'}`)
if (!sessionDbPath) {
return { success: false, error: `未找到 session.db 文件` }
}
// 分配输出参数内存
const handleOut = [0]
const result = this.wcdbOpenAccount(sessionDbPath, hexKey, handleOut)
if (result !== 0) {
await this.printLogs()
let errorMsg = '数据库打开失败'
if (result === -1) errorMsg = '参数错误'
else if (result === -2) errorMsg = '密钥错误'
else if (result === -3) errorMsg = '数据库打开失败'
this.writeLog(`testConnection openAccount failed code=${result}`)
return { success: false, error: `${errorMsg} (错误码: ${result})` }
}
const tempHandle = handleOut[0]
if (tempHandle <= 0) {
return { success: false, error: '无效的数据库句柄' }
}
// 测试成功,使用 shutdown 清理所有资源(包括测试句柄)
// 这会中断当前活动连接,但 testConnection 本应该是独立测试
try {
this.wcdbShutdown()
this.handle = null
this.currentPath = null
this.currentKey = null
this.currentWxid = null
this.initialized = false
} catch (closeErr) {
console.error('关闭测试数据库时出错:', closeErr)
}
return { success: true, sessionCount: 0 }
} catch (e) {
console.error('测试连接异常:', e)
this.writeLog(`testConnection exception: ${String(e)}`)
return { success: false, error: String(e) }
}
}
/**
* 打印 DLL 内部日志(仅在出错时调用)
*/
private async printLogs(force = false): Promise<void> {
try {
if (!this.wcdbGetLogs) return
const outPtr = [null as any]
const result = this.wcdbGetLogs(outPtr)
if (result === 0 && outPtr[0]) {
try {
const jsonStr = this.koffi.decode(outPtr[0], 'char', -1)
this.writeLog(`wcdb_logs: ${jsonStr}`, force)
this.wcdbFreeString(outPtr[0])
} catch (e) {
// ignore
}
}
} catch (e) {
console.error('获取日志失败:', e)
this.writeLog(`wcdb_logs failed: ${String(e)}`, force)
}
}
private decodeJsonPtr(outPtr: any): string | null {
if (!outPtr) return null
try {
const jsonStr = this.koffi.decode(outPtr, 'char', -1)
this.wcdbFreeString(outPtr)
return jsonStr
} catch (e) {
try { this.wcdbFreeString(outPtr) } catch { }
return null
}
}
private ensureReady(): boolean {
return this.initialized && this.handle !== null
}
private normalizeTimestamp(input: number): number {
if (!input || input <= 0) return 0
const asNumber = Number(input)
if (!Number.isFinite(asNumber)) return 0
// Treat >1e12 as milliseconds.
const seconds = asNumber > 1e12 ? Math.floor(asNumber / 1000) : Math.floor(asNumber)
const maxInt32 = 2147483647
return Math.min(Math.max(seconds, 0), maxInt32)
}
private normalizeRange(beginTimestamp: number, endTimestamp: number): { begin: number; end: number } {
const normalizedBegin = this.normalizeTimestamp(beginTimestamp)
let normalizedEnd = this.normalizeTimestamp(endTimestamp)
if (normalizedEnd <= 0) {
normalizedEnd = this.normalizeTimestamp(Date.now())
}
if (normalizedBegin > 0 && normalizedEnd < normalizedBegin) {
normalizedEnd = normalizedBegin
}
return { begin: normalizedBegin, end: normalizedEnd }
}
isReady(): boolean {
return this.ensureReady()
}
/**
* 打开数据库
*/
async open(dbPath: string, hexKey: string, wxid: string): Promise<boolean> {
try {
if (!this.initialized) {
const initOk = await this.initialize()
if (!initOk) return false
}
// 检查是否已经是当前连接的参数,如果是则直接返回成功,实现"始终保持链接"
if (this.handle !== null &&
this.currentPath === dbPath &&
this.currentKey === hexKey &&
this.currentWxid === wxid) {
return true
}
// 如果参数不同,则先关闭原来的连接
if (this.handle !== null) {
this.close()
// 重新初始化,因为 close 呼叫了 shutdown
const initOk = await this.initialize()
if (!initOk) return false
}
const dbStoragePath = this.resolveDbStoragePath(dbPath, wxid)
this.writeLog(`open dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`)
if (!dbStoragePath || !existsSync(dbStoragePath)) {
console.error('数据库目录不存在:', dbPath)
this.writeLog(`open failed: dbStorage not found for ${dbPath}`)
return false
}
const sessionDbPath = this.findSessionDb(dbStoragePath)
this.writeLog(`open sessionDb=${sessionDbPath || 'null'}`)
if (!sessionDbPath) {
console.error('未找到 session.db 文件')
this.writeLog('open failed: session.db not found')
return false
}
const handleOut = [0]
const result = this.wcdbOpenAccount(sessionDbPath, hexKey, handleOut)
if (result !== 0) {
console.error('打开数据库失败:', result)
await this.printLogs()
this.writeLog(`open failed: openAccount code=${result}`)
return false
}
const handle = handleOut[0]
if (handle <= 0) {
return false
}
this.handle = handle
this.currentPath = dbPath
this.currentKey = hexKey
this.currentWxid = wxid
this.initialized = true
if (this.wcdbSetMyWxid && wxid) {
try {
this.wcdbSetMyWxid(this.handle, wxid)
} catch (e) {
console.warn('设置 wxid 失败:', e)
}
}
this.writeLog(`open ok handle=${handle}`)
return true
} catch (e) {
console.error('打开数据库异常:', e)
this.writeLog(`open exception: ${String(e)}`)
return false
}
}
/**
* 关闭数据库
* 注意:wcdb_close_account 可能导致崩溃,使用 shutdown 代替
*/
close(): void {
if (this.handle !== null || this.initialized) {
try {
// 不调用 closeAccount,直接 shutdown
this.wcdbShutdown()
} catch (e) {
console.error('WCDB shutdown 出错:', e)
}
this.handle = null
this.currentPath = null
this.currentKey = null
this.currentWxid = null
this.initialized = false
}
}
/**
* 关闭服务(与 close 相同)
*/
shutdown(): void {
this.close()
}
/**
* 检查是否已连接
*/
isConnected(): boolean {
return this.initialized && this.handle !== null
}
async getSessions(): Promise<{ success: boolean; sessions?: any[]; error?: string }> {
if (!this.ensureReady()) {
this.writeLog('getSessions skipped: not connected')
return { success: false, error: 'WCDB 未连接' }
}
try {
const outPtr = [null as any]
const result = this.wcdbGetSessions(this.handle, outPtr)
if (result !== 0 || !outPtr[0]) {
this.writeLog(`getSessions failed: code=${result}`)
return { success: false, error: `获取会话失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析会话失败' }
this.writeLog(`getSessions ok size=${jsonStr.length}`)
const sessions = JSON.parse(jsonStr)
return { success: true, sessions }
} catch (e) {
this.writeLog(`getSessions exception: ${String(e)}`)
return { success: false, error: String(e) }
}
}
async getMessages(sessionId: string, limit: number, offset: number): Promise<{ success: boolean; messages?: any[]; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
const outPtr = [null as any]
const result = this.wcdbGetMessages(this.handle, sessionId, limit, offset, outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取消息失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析消息失败' }
const messages = JSON.parse(jsonStr)
return { success: true, messages }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getMessageCount(sessionId: string): Promise<{ success: boolean; count?: number; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
const outCount = [0]
const result = this.wcdbGetMessageCount(this.handle, sessionId, outCount)
if (result !== 0) {
return { success: false, error: `获取消息总数失败: ${result}` }
}
return { success: true, count: outCount[0] }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getDisplayNames(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
if (usernames.length === 0) return { success: true, map: {} }
try {
const outPtr = [null as any]
const result = this.wcdbGetDisplayNames(this.handle, JSON.stringify(usernames), outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取昵称失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析昵称失败' }
const map = JSON.parse(jsonStr)
return { success: true, map }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getAvatarUrls(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
if (usernames.length === 0) return { success: true, map: {} }
try {
const now = Date.now()
const resultMap: Record<string, string> = {}
const toFetch: string[] = []
const seen = new Set<string>()
for (const username of usernames) {
if (!username || seen.has(username)) continue
seen.add(username)
const cached = this.avatarUrlCache.get(username)
if (cached && cached.url && now - cached.updatedAt < this.avatarCacheTtlMs) {
resultMap[username] = cached.url
continue
}
toFetch.push(username)
}
if (toFetch.length === 0) {
return { success: true, map: resultMap }
}
const outPtr = [null as any]
const result = this.wcdbGetAvatarUrls(this.handle, JSON.stringify(toFetch), outPtr)
if (result !== 0 || !outPtr[0]) {
if (Object.keys(resultMap).length > 0) {
return { success: true, map: resultMap, error: `获取头像失败: ${result}` }
}
return { success: false, error: `获取头像失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析头像失败' }
const map = JSON.parse(jsonStr) as Record<string, string>
for (const username of toFetch) {
const url = map[username]
if (url) {
resultMap[username] = url
this.avatarUrlCache.set(username, { url, updatedAt: now })
}
}
return { success: true, map: resultMap }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getGroupMemberCount(chatroomId: string): Promise<{ success: boolean; count?: number; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
const outCount = [0]
const result = this.wcdbGetGroupMemberCount(this.handle, chatroomId, outCount)
if (result !== 0) {
return { success: false, error: `获取群成员数量失败: ${result}` }
}
return { success: true, count: outCount[0] }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getGroupMemberCounts(chatroomIds: string[]): Promise<{ success: boolean; map?: Record<string, number>; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
if (chatroomIds.length === 0) return { success: true, map: {} }
if (!this.wcdbGetGroupMemberCounts) {
const map: Record<string, number> = {}
for (const chatroomId of chatroomIds) {
const result = await this.getGroupMemberCount(chatroomId)
if (result.success && typeof result.count === 'number') {
map[chatroomId] = result.count
}
}
return { success: true, map }
}
try {
const outPtr = [null as any]
const result = this.wcdbGetGroupMemberCounts(this.handle, JSON.stringify(chatroomIds), outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取群成员数量失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析群成员数量失败' }
const map = JSON.parse(jsonStr)
return { success: true, map }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getGroupMembers(chatroomId: string): Promise<{ success: boolean; members?: any[]; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
const outPtr = [null as any]
const result = this.wcdbGetGroupMembers(this.handle, chatroomId, outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取群成员失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析群成员失败' }
const members = JSON.parse(jsonStr)
return { success: true, members }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getMessageTables(sessionId: string): Promise<{ success: boolean; tables?: any[]; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
const outPtr = [null as any]
const result = this.wcdbGetMessageTables(this.handle, sessionId, outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取消息表失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析消息表失败' }
const tables = JSON.parse(jsonStr)
return { success: true, tables }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getMessageTableStats(sessionId: string): Promise<{ success: boolean; tables?: any[]; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
const outPtr = [null as any]
const result = this.wcdbGetMessageTableStats(this.handle, sessionId, outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取表统计失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析表统计失败' }
const tables = JSON.parse(jsonStr)
return { success: true, tables }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getMessageMeta(dbPath: string, tableName: string, limit: number, offset: number): Promise<{ success: boolean; rows?: any[]; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
const outPtr = [null as any]
const result = this.wcdbGetMessageMeta(this.handle, dbPath, tableName, limit, offset, outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取消息元数据失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析消息元数据失败' }
const rows = JSON.parse(jsonStr)
return { success: true, rows }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getContact(username: string): Promise<{ success: boolean; contact?: any; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
const outPtr = [null as any]
const result = this.wcdbGetContact(this.handle, username, outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取联系人失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析联系人失败' }
const contact = JSON.parse(jsonStr)
return { success: true, contact }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getAggregateStats(sessionIds: string[], beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
const normalizedBegin = this.normalizeTimestamp(beginTimestamp)
let normalizedEnd = this.normalizeTimestamp(endTimestamp)
if (normalizedEnd <= 0) {
normalizedEnd = this.normalizeTimestamp(Date.now())
}
if (normalizedBegin > 0 && normalizedEnd < normalizedBegin) {
normalizedEnd = normalizedBegin
}
const callAggregate = (ids: string[]) => {
const idsAreNumeric = ids.length > 0 && ids.every((id) => /^\d+$/.test(id))
const payloadIds = idsAreNumeric ? ids.map((id) => Number(id)) : ids
const outPtr = [null as any]
const result = this.wcdbGetAggregateStats(this.handle, JSON.stringify(payloadIds), normalizedBegin, normalizedEnd, outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取聚合统计失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) {
return { success: false, error: '解析聚合统计失败' }
}
const data = JSON.parse(jsonStr)
return { success: true, data }
}
let result = callAggregate(sessionIds)
if (result.success && result.data && result.data.total === 0 && result.data.idMap) {
const idMap = result.data.idMap as Record<string, string>
const reverseMap: Record<string, string> = {}
for (const [id, name] of Object.entries(idMap)) {
if (!name) continue
reverseMap[name] = id
}
const numericIds = sessionIds
.map((id) => reverseMap[id])
.filter((id) => typeof id === 'string' && /^\d+$/.test(id))
if (numericIds.length > 0) {
const retry = callAggregate(numericIds)
if (retry.success && retry.data) {
result = retry
}
}
}
return result
} catch (e) {
return { success: false, error: String(e) }
}
}
async getAvailableYears(sessionIds: string[]): Promise<{ success: boolean; data?: number[]; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
if (!this.wcdbGetAvailableYears) {
return { success: false, error: '未支持获取年度列表' }
}
if (sessionIds.length === 0) return { success: true, data: [] }
try {
const outPtr = [null as any]
const result = this.wcdbGetAvailableYears(this.handle, JSON.stringify(sessionIds), outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取年度列表失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析年度列表失败' }
const data = JSON.parse(jsonStr)
return { success: true, data }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getAnnualReportStats(sessionIds: string[], beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
if (!this.wcdbGetAnnualReportStats) {
return this.getAggregateStats(sessionIds, beginTimestamp, endTimestamp)
}
try {
const { begin, end } = this.normalizeRange(beginTimestamp, endTimestamp)
const outPtr = [null as any]
const result = this.wcdbGetAnnualReportStats(this.handle, JSON.stringify(sessionIds), begin, end, outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取年度统计失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析年度统计失败' }
const data = JSON.parse(jsonStr)
return { success: true, data }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getAnnualReportExtras(
sessionIds: string[],
beginTimestamp: number = 0,
endTimestamp: number = 0,
peakDayBegin: number = 0,
peakDayEnd: number = 0
): Promise<{ success: boolean; data?: any; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
if (!this.wcdbGetAnnualReportExtras) {
return { success: false, error: '未支持年度扩展统计' }
}
if (sessionIds.length === 0) return { success: true, data: {} }
try {
const { begin, end } = this.normalizeRange(beginTimestamp, endTimestamp)
const outPtr = [null as any]
const result = this.wcdbGetAnnualReportExtras(
this.handle,
JSON.stringify(sessionIds),
begin,
end,
this.normalizeTimestamp(peakDayBegin),
this.normalizeTimestamp(peakDayEnd),
outPtr
)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取年度扩展统计失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析年度扩展统计失败' }
const data = JSON.parse(jsonStr)
return { success: true, data }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getGroupStats(chatroomId: string, beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
if (!this.wcdbGetGroupStats) {
return this.getAggregateStats([chatroomId], beginTimestamp, endTimestamp)
}
try {
const { begin, end } = this.normalizeRange(beginTimestamp, endTimestamp)
const outPtr = [null as any]
const result = this.wcdbGetGroupStats(this.handle, chatroomId, begin, end, outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取群聊统计失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析群聊统计失败' }
const data = JSON.parse(jsonStr)
return { success: true, data }
} catch (e) {
return { success: false, error: String(e) }
}
}
async openMessageCursor(sessionId: string, batchSize: number, ascending: boolean, beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; cursor?: number; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
const outCursor = [0]
const result = this.wcdbOpenMessageCursor(
this.handle,
sessionId,
batchSize,
ascending ? 1 : 0,
beginTimestamp,
endTimestamp,
outCursor
)
if (result !== 0 || outCursor[0] <= 0) {
await this.printLogs(true)
this.writeLog(
`openMessageCursor failed: sessionId=${sessionId} batchSize=${batchSize} ascending=${ascending ? 1 : 0} begin=${beginTimestamp} end=${endTimestamp} result=${result} cursor=${outCursor[0]}`,
true
)
return { success: false, error: `创建游标失败: ${result},请查看日志` }
}
return { success: true, cursor: outCursor[0] }
} catch (e) {
await this.printLogs(true)
this.writeLog(`openMessageCursor exception: ${String(e)}`, true)
return { success: false, error: '创建游标异常,请查看日志' }
}
}
async openMessageCursorLite(sessionId: string, batchSize: number, ascending: boolean, beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; cursor?: number; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
if (!this.wcdbOpenMessageCursorLite) {
return this.openMessageCursor(sessionId, batchSize, ascending, beginTimestamp, endTimestamp)
}
try {
const outCursor = [0]
const result = this.wcdbOpenMessageCursorLite(
this.handle,
sessionId,
batchSize,
ascending ? 1 : 0,
beginTimestamp,
endTimestamp,
outCursor
)
if (result !== 0 || outCursor[0] <= 0) {
await this.printLogs(true)
this.writeLog(
`openMessageCursorLite failed: sessionId=${sessionId} batchSize=${batchSize} ascending=${ascending ? 1 : 0} begin=${beginTimestamp} end=${endTimestamp} result=${result} cursor=${outCursor[0]}`,
true
)
return { success: false, error: `创建游标失败: ${result},请查看日志` }
}
return { success: true, cursor: outCursor[0] }
} catch (e) {
await this.printLogs(true)
this.writeLog(`openMessageCursorLite exception: ${String(e)}`, true)
return { success: false, error: '创建游标异常,请查看日志' }
}
}
async fetchMessageBatch(cursor: number): Promise<{ success: boolean; rows?: any[]; hasMore?: boolean; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
const outPtr = [null as any]
const outHasMore = [0]
const result = this.wcdbFetchMessageBatch(this.handle, cursor, outPtr, outHasMore)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取批次失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析批次失败' }
const rows = JSON.parse(jsonStr)
return { success: true, rows, hasMore: outHasMore[0] === 1 }
} catch (e) {
return { success: false, error: String(e) }
}
}
async closeMessageCursor(cursor: number): Promise<{ success: boolean; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
const result = this.wcdbCloseMessageCursor(this.handle, cursor)
if (result !== 0) {
return { success: false, error: `关闭游标失败: ${result}` }
}
return { success: true }
} catch (e) {
return { success: false, error: String(e) }
}
}
async execQuery(kind: string, path: string | null, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
const outPtr = [null as any]
const result = this.wcdbExecQuery(this.handle, kind, path, sql, outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `执行查询失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析查询结果失败' }
const rows = JSON.parse(jsonStr)
return { success: true, rows }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getEmoticonCdnUrl(dbPath: string, md5: string): Promise<{ success: boolean; url?: string; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
const outPtr = [null as any]
const result = this.wcdbGetEmoticonCdnUrl(this.handle, dbPath, md5, outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取表情 URL 失败: ${result}` }
}
const urlStr = this.decodeJsonPtr(outPtr[0])
if (urlStr === null) return { success: false, error: '解析表情 URL 失败' }
return { success: true, url: urlStr || undefined }
} catch (e) {
return { success: false, error: String(e) }
}
}
async listMessageDbs(): Promise<{ success: boolean; data?: string[]; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
try {
const outPtr = [null as any]
const result = this.wcdbListMessageDbs(this.handle, outPtr)
if (result !== 0 || !outPtr[0]) return { success: false, error: `获取消息库列表失败: ${result}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析消息库列表失败' }
const data = JSON.parse(jsonStr)
return { success: true, data }
} catch (e) {
return { success: false, error: String(e) }
}
}
async listMediaDbs(): Promise<{ success: boolean; data?: string[]; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
try {
const outPtr = [null as any]
const result = this.wcdbListMediaDbs(this.handle, outPtr)
if (result !== 0 || !outPtr[0]) return { success: false, error: `获取媒体库列表失败: ${result}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析媒体库列表失败' }
const data = JSON.parse(jsonStr)
return { success: true, data }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: any; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
try {
const outPtr = [null as any]
const result = this.wcdbGetMessageById(this.handle, sessionId, localId, outPtr)
if (result !== 0 || !outPtr[0]) return { success: false, error: `查询消息失败: ${result}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析消息失败' }
const message = JSON.parse(jsonStr)
// 处理 wcdb_get_message_by_id 返回空对象的情况
if (Object.keys(message).length === 0) return { success: false, error: '未找到消息' }
return { success: true, message }
} catch (e) {
return { success: false, error: String(e) }
}
}
}
export const wcdbService = new WcdbService()