新的提交
This commit is contained in:
commit
01641834de
188 files changed
+34865
No files matched your search
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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(/&/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(/&/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(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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()
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user