Files
WechatExplorer/src/main/services/chat-service.ts
T

485 lines
14 KiB
TypeScript

import { WechatDb, WechatMessage } from '../wechat-db'
import {
parseImageDatNameFromRow,
parseMessageContent,
parseStickerMessageFromRow
} from '../message-parser'
import type {
DatabaseKeyValidationCode,
DatabaseKeyValidationResult
} from '../../shared/database-key'
export function getCurrentKey(): string {
if (!dbRef) return ''
try {
return dbRef.getWcdb4Client().getKey()
} catch {
return ''
}
}
export function getCurrentAccountRoot(): string {
if (!dbRef) return ''
try {
return dbRef.getWcdb4Client().getAccountRoot()
} catch {
return ''
}
}
export interface FormattedContact {
m_nsUsrName: string
m_nsNickName: string
md5: string
type: 'user' | 'group'
avatar?: string
}
export interface FormattedMessage {
id: string
from: string
type: string
datetime: string
content: string
isSender: boolean
img?: string
name?: string
senderId?: string
contentData?: ReturnType<typeof parseMessageContent>
voiceDataUrl?: string
voiceDuration?: number
localId?: number
createTime?: number
sessionId?: string
}
export interface GroupSnapshot {
roomId: string
memberCount: number
members: {
wxid: string
nickname: string
groupNickname: string
wechatNickname: string
remark: string
avatar: string
}[]
}
const MSG_TYPE_DICT: Record<number, string> = {
1: '普通文本',
3: '图片',
34: '语音',
42: '名片',
43: '视频',
47: '表情包',
48: '位置',
49: '分享消息',
50: '通话',
10000: '系统消息'
}
function normalizeMsgType(value: string | number | undefined): number {
const raw = String(value ?? '').trim()
if (!raw) return 0
try {
const parsed = BigInt(raw)
const low32 = Number(parsed & 0xffffffffn)
return low32 || Number(parsed)
} catch {
const parsed = Number(raw)
if (!Number.isFinite(parsed)) return 0
return parsed > 0xffffffff ? parsed >>> 0 : parsed
}
}
let dbRef: WechatDb | null = null
export function setChatDb(db: WechatDb | null): void {
dbRef?.close()
dbRef = db
}
export function getChatDb(): WechatDb | null {
return dbRef
}
export function isReady(): boolean {
return dbRef !== null
}
export function listContacts(filter?: string): FormattedContact[] {
if (!dbRef) return []
const contacts: FormattedContact[] = []
const groupContacts = dbRef.getAllGroupContacts()
const userList = dbRef.getUserList(filter)
const existingMd5s = new Set<string>()
for (const user of userList) {
const md5 = dbRef.md5(user.m_nsUsrName)
const isGroup = user.m_nsUsrName.endsWith('@chatroom')
existingMd5s.add(md5)
contacts.push({
m_nsUsrName: user.m_nsUsrName,
m_nsNickName: user.nickname || '未知用户',
md5,
type: isGroup ? 'group' : 'user',
avatar: typeof user.avatar === 'string' ? user.avatar : undefined
})
}
const chatTables = dbRef.getAllChatTables()
for (const table of chatTables) {
if (!table.name.startsWith('Chat_')) continue
const md5 = table.name.substring(5)
if (existingMd5s.has(md5)) continue
if (groupContacts[md5]) {
contacts.push({
m_nsUsrName: `Group_${md5}`,
m_nsNickName: groupContacts[md5],
md5,
type: 'group'
})
} else {
contacts.push({
m_nsUsrName: `Unknown_${md5}`,
m_nsNickName: `Chat_${md5}`,
md5,
type: 'user'
})
}
}
return contacts
}
export function getContactAvatars(usernames: string[]): Record<string, string> {
if (!dbRef) return {}
const normalized = Array.from(
new Set((usernames || []).map((username) => String(username || '').trim()).filter(Boolean))
)
if (normalized.length === 0) return {}
return dbRef.getWcdb4Client().getAvatarUrls(normalized)
}
export function listMessages(
userMd5: string,
startTime?: number,
endTime?: number,
options?: { limit?: number }
): FormattedMessage[] {
if (!dbRef) return []
const startedAt = Date.now()
const wcdb4Client = dbRef.getWcdb4Client()
const username = wcdb4Client.getUsernameByMd5(userMd5)
const isGroupChat = Boolean(username?.endsWith('@chatroom'))
console.log(
`[ChatService] listMessages begin md5=${userMd5} username=${username || ''} start=${startTime || 0} end=${endTime || 0} limit=${options?.limit || 0}`
)
const rawMessages = dbRef.getUserMessages(userMd5, startTime, endTime, options)
console.log(
`[ChatService] listMessages native done md5=${userMd5} raw=${rawMessages.length} cost=${Date.now() - startedAt}ms`
)
const formatted = rawMessages.map((msg: WechatMessage) => {
const rawMsgType = parseInt(msg.messageType)
const msgType = normalizeMsgType(msg.messageType)
const createTime = parseInt(msg.msgCreateTime)
const date = new Date(createTime * 1000)
const isMine = msg.mesDes !== 1
const localId = parseInt(msg.mesLocalID) || 0
let content = msg.msgContent
let img = ''
let name = ''
let senderId = typeof msg.sender === 'string' ? msg.sender : ''
if (isMine) {
name = typeof msg.senderNickname === 'string' ? msg.senderNickname : ''
} else {
if (typeof msg.senderAvatar === 'string') img = msg.senderAvatar
if (typeof msg.senderNickname === 'string') name = msg.senderNickname
}
if (isGroupChat && content && typeof content === 'string') {
const colonIndex = content.indexOf(':')
if (colonIndex > 0) {
const potentialSenderId = content.substring(0, colonIndex).trim()
if (/^[a-zA-Z0-9_@.-]{3,64}$/.test(potentialSenderId)) {
senderId = senderId || potentialSenderId
name = name || potentialSenderId
content = content.substring(colonIndex + 1).replace(/^\s+/, '')
}
}
}
if (!isMine && !name && senderId) name = senderId
let contentData: ReturnType<typeof parseMessageContent> | undefined
let displayType = MSG_TYPE_DICT[msgType] || msg.messageType
const inferredMsgType =
typeof content === 'string' &&
/<appmsg\b|<refermsg\b|&lt;appmsg\b|&lt;refermsg\b/i.test(content)
? 49
: msgType
if ([3, 42, 47, 48, 49, 50, 10000, 10002].includes(inferredMsgType)) {
try {
const parsed =
inferredMsgType === 47
? parseStickerMessageFromRow(msg, content)
: parseMessageContent(content, inferredMsgType)
if (parsed.type === 'system') {
content = parsed.content
contentData = parsed
} else if (parsed.type !== 'unknown') {
content = ''
}
if (parsed.type === 'image') {
const imageDatName = parseImageDatNameFromRow(msg)
contentData = { ...parsed, datName: parsed.datName || imageDatName }
} else if (parsed.type !== 'system') {
if (parsed.type === 'sticker' && !parsed.url && parsed.md5) {
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
}
contentData = parsed
}
if (inferredMsgType !== msgType || rawMsgType !== msgType) {
displayType = MSG_TYPE_DICT[inferredMsgType] || displayType
}
} catch {
// ignore parse errors
}
}
if (!contentData && typeof content === 'string' && /^[0-9a-fA-F]{64,}$/.test(content.trim())) {
const parsed = parseStickerMessageFromRow(msg, content)
if (parsed.type === 'sticker') {
if (!parsed.url && parsed.md5) {
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
}
content = ''
contentData = parsed
displayType = '表情包'
}
}
if (msgType === 34) content = '[语音消息]'
return {
id: msg.mesLocalID || Math.random().toString(),
from: contentData?.type === 'system' ? 'system' : isMine ? 'assistant' : 'user',
isSender: isMine,
type: displayType,
datetime: date.toLocaleString('zh-CN', { hour12: false }),
content,
img,
name,
senderId,
sessionId: username,
localId,
createTime,
contentData
}
})
console.log(
`[ChatService] listMessages end md5=${userMd5} formatted=${formatted.length} cost=${Date.now() - startedAt}ms`
)
return formatted
}
export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
if (!dbRef) return null
const wcdb4Client = dbRef.getWcdb4Client()
const roomId = wcdb4Client.getUsernameByMd5(userMd5)
if (!roomId || !roomId.endsWith('@chatroom')) return null
const members = wcdb4Client
.getGroupMembers(roomId)
.filter((member) => member?.m_nsUsrName)
.map((member) => ({
wxid: member.m_nsUsrName,
nickname: member.nickname || '',
groupNickname: member.groupNickname || '',
wechatNickname: member.wechatNickname || '',
remark: member.remark || '',
avatar: member.m_nsHeadImgUrl || ''
}))
return { roomId, memberCount: members.length, members }
}
export function searchMessages(keyword: string): string | null {
if (!dbRef) return null
return dbRef.searchAllMessages(keyword)
}
export function listRecentChat(limit = 50): FormattedContact[] {
const contacts = listContacts()
return contacts.slice(0, limit)
}
export function resolveMd5(query: string): FormattedContact | null {
const trimmed = query.trim()
if (!trimmed) return null
const lower = trimmed.toLowerCase()
const contacts = listContacts()
const exact = contacts.find(
(c) =>
c.md5 === trimmed ||
c.m_nsUsrName.toLowerCase() === lower ||
c.m_nsNickName.toLowerCase() === lower
)
if (exact) return exact
const partial = contacts.find(
(c) =>
c.m_nsNickName.toLowerCase().includes(lower) || c.m_nsUsrName.toLowerCase().includes(lower)
)
return partial || null
}
export interface SelfAccountInfo {
wxid: string
nickname: string
avatar?: string
accountRoot: string
}
export function getSelfAccountInfo(): SelfAccountInfo | null {
if (!dbRef) return null
const wcdb = dbRef.getWcdb4Client()
const accountRoot = wcdb.getAccountRoot()
const usernameCandidates = wcdb.getMyUsernameCandidates()
const primaryUsername = usernameCandidates[0] ?? ''
const wxid =
primaryUsername && primaryUsername.toLowerCase().startsWith('wxid_')
? primaryUsername
: wcdb.getUsernameByMd5(wcdb.md5(accountRoot.split('/').pop() || '')) || primaryUsername
let nickname = ''
let avatar: string | undefined
try {
avatar = wcdb.getMyAvatarUrl()
} catch {
avatar = undefined
}
if (usernameCandidates.length) {
const contacts = listContacts()
const self = contacts.find(
(c) =>
usernameCandidates.includes(c.m_nsUsrName) ||
(c.type === 'user' && usernameCandidates.some((u) => c.m_nsUsrName.includes(u)))
)
if (self) {
nickname = self.m_nsNickName
avatar = avatar || self.avatar
}
}
return {
wxid: wxid || primaryUsername || '',
nickname: nickname || wxid || '我',
avatar,
accountRoot
}
}
export function testConnection(key: string, accountRoot?: string): DatabaseKeyValidationResult {
const probeKey = key.replace(/^0x/i, '').trim()
if (!/^[0-9a-f]{64}$/i.test(probeKey)) {
return {
success: false,
code: 'INVALID_FORMAT',
error: '密钥格式不正确'
}
}
let probe: WechatDb | null = null
let ownsProbe = false
try {
const current = dbRef
const canReuseCurrent =
current &&
getCurrentKey().replace(/^0x/i, '').trim() === probeKey &&
(!accountRoot || getCurrentAccountRoot() === accountRoot)
const validationDb = canReuseCurrent
? current
: accountRoot
? new WechatDb(probeKey, accountRoot)
: new WechatDb(probeKey)
probe = validationDb
ownsProbe = validationDb !== current
const client = validationDb.getWcdb4Client()
const contacts = client.getSessions()
const messages = client.getChatTables()
if (contacts[0]) client.getMessages(contacts[0].username, undefined, undefined, { limit: 1 })
return {
success: true,
accountRoot: client.getAccountRoot(),
wxid: (client.getMyUsernameCandidates?.() ?? [])[0] || '',
contacts: { available: true, count: contacts.length },
messages: { available: true, count: messages.length }
}
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
const code = mapConnectionError(detail)
return {
success: false,
code,
error: DATABASE_KEY_ERROR_MESSAGES[code]
}
} finally {
try {
// macOS native shutdown is process-wide. Do not tear down the active reader
// when validating a replacement key; the new runtime connection takes over on save.
if (ownsProbe && !(process.platform === 'darwin' && dbRef)) probe?.close()
} catch {
// Validation probes are best-effort closed without exposing native details.
}
}
}
const DATABASE_KEY_ERROR_MESSAGES: Record<DatabaseKeyValidationCode, string> = {
INVALID_FORMAT: '密钥格式不正确',
DATABASE_OPEN_FAILED: '无法打开数据库',
ACCOUNT_MISMATCH: '密钥与当前账号不匹配',
ROOT_UNAVAILABLE: '当前数据库目录不可用',
DATABASE_FILE_MISSING: '数据库文件缺失',
UNKNOWN_VALIDATION_ERROR: '未知验证错误'
}
function mapConnectionError(detail: string): DatabaseKeyValidationCode {
const normalized = detail.toLowerCase()
if (normalized.includes('-1005') || normalized.includes('不匹配')) return 'ACCOUNT_MISMATCH'
if (normalized.includes('session.db') || normalized.includes('数据库文件')) {
return 'DATABASE_FILE_MISSING'
}
if (
normalized.includes('数据目录') ||
normalized.includes('账号目录') ||
normalized.includes('db_storage')
) {
return 'ROOT_UNAVAILABLE'
}
if (normalized.includes('wcdb_open_account') || normalized.includes('open')) {
return 'DATABASE_OPEN_FAILED'
}
return 'UNKNOWN_VALIDATION_ERROR'
}
export function reopenWithRoot(accountRoot: string): boolean {
if (!dbRef) return false
const key = getCurrentKey()
if (!key) return false
try {
const next = new WechatDb(key, accountRoot)
setChatDb(next)
return true
} catch (error) {
console.error('[ChatService] reopen with root failed:', error)
return false
}
}