mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 19:47:08 +08:00
fix: 修复语音首播并完善消息解析与会话兼容性
(cherry picked from commit 214090192f5dfc91cdec0269bad434d3e22394d8)
This commit is contained in:
+3
-6
@@ -125,10 +125,7 @@ const COLD_IMAGE_LOAD_GAP_MS = 100
|
||||
const MAX_CONCURRENT_COLD_IMAGE_LOADS = 2
|
||||
|
||||
function pumpColdImageLoads(): void {
|
||||
if (
|
||||
activeColdImageLoads >= MAX_CONCURRENT_COLD_IMAGE_LOADS ||
|
||||
coldImageLoadQueue.length === 0
|
||||
) {
|
||||
if (activeColdImageLoads >= MAX_CONCURRENT_COLD_IMAGE_LOADS || coldImageLoadQueue.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -768,8 +765,8 @@ app.whenReady().then(async () => {
|
||||
return contacts
|
||||
})
|
||||
|
||||
ipcMain.handle('db:getContactAvatars', (_, usernames: string[]) => {
|
||||
const avatars = chat.getContactAvatars(usernames)
|
||||
ipcMain.handle('db:getContactAvatars', async (_, usernames: string[]) => {
|
||||
const avatars = await chat.getContactAvatars(usernames)
|
||||
if (chat.isReady()) mergeBootstrapAvatars(chat.getCurrentAccountRoot(), avatars)
|
||||
return avatars
|
||||
})
|
||||
|
||||
+112
-5
@@ -16,6 +16,19 @@ type ShareContent = {
|
||||
appname?: string
|
||||
typeVal?: string
|
||||
}
|
||||
type ForwardedMessageItem = {
|
||||
messageType: number
|
||||
sender?: string
|
||||
sentAt?: string
|
||||
text: string
|
||||
nested?: ForwardedMessageItem[]
|
||||
}
|
||||
type ForwardBundleContent = {
|
||||
type: 'forwardBundle'
|
||||
title: string
|
||||
description?: string
|
||||
items: ForwardedMessageItem[]
|
||||
}
|
||||
type MiniProgramContent = {
|
||||
type: 'miniProgram'
|
||||
title: string
|
||||
@@ -82,7 +95,7 @@ type SystemContent = {
|
||||
recallTime?: number
|
||||
}
|
||||
}
|
||||
type UnknownContent = { type: 'unknown'; raw: string }
|
||||
type UnknownContent = { type: 'unknown'; raw: string; messageType?: string | number }
|
||||
|
||||
export type ParsedContent =
|
||||
| TextContent
|
||||
@@ -90,6 +103,7 @@ export type ParsedContent =
|
||||
| LocationContent
|
||||
| CardContent
|
||||
| ShareContent
|
||||
| ForwardBundleContent
|
||||
| MiniProgramContent
|
||||
| RedPacketContent
|
||||
| VoipContent
|
||||
@@ -108,6 +122,10 @@ export function parseMessageContent(content: string, messageType: number): Parse
|
||||
const normalized = content.trim()
|
||||
|
||||
switch (messageType) {
|
||||
case 1:
|
||||
return { type: 'text', content: normalized }
|
||||
case 34:
|
||||
return { type: 'voice' }
|
||||
case 3:
|
||||
return parseImageMessage(normalized)
|
||||
case 42:
|
||||
@@ -126,7 +144,7 @@ export function parseMessageContent(content: string, messageType: number): Parse
|
||||
case 10002:
|
||||
return parseSystemMessage(normalized)
|
||||
default:
|
||||
return { type: 'text', content: normalized }
|
||||
return { type: 'unknown', raw: normalized, messageType }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,6 +430,9 @@ function parseLocationMessage(content: string): ParsedContent {
|
||||
|
||||
function parseShareMessage(content: string): ParsedContent {
|
||||
const appMsgType = extractAppMsgType(content)
|
||||
if (appMsgType === '19' || /<recorditem\b|<dataitem\b/i.test(content)) {
|
||||
return parseForwardBundle(content)
|
||||
}
|
||||
if (appMsgType === '47' || /<(?:emoji|sticker|emoticon)\b/i.test(content)) {
|
||||
const sticker = parseStickerMessage(content)
|
||||
if (sticker.type === 'sticker') return sticker
|
||||
@@ -469,6 +490,94 @@ function parseShareMessage(content: string): ParsedContent {
|
||||
return { type: 'share', title, des, url, appname, typeVal }
|
||||
}
|
||||
|
||||
function parseForwardBundle(content: string): ForwardBundleContent {
|
||||
const normalized = decodeXmlEntities(stripChatroomPrefix(content))
|
||||
const title = decodeXmlEntities(extractXmlValue(normalized, 'title')) || '聊天记录'
|
||||
const description = decodeXmlEntities(extractXmlValue(normalized, 'des')) || undefined
|
||||
const containers = Array.from(
|
||||
normalized.matchAll(/<recorditem\b[^>]*>([\s\S]*?)<\/recorditem>/gi),
|
||||
(match) => match[1] || ''
|
||||
)
|
||||
const sources = containers.length ? containers : [normalized]
|
||||
const items = dedupeForwardedItems(sources.flatMap((source) => parseForwardedItems(source)))
|
||||
return { type: 'forwardBundle', title, description, items }
|
||||
}
|
||||
|
||||
function parseForwardedItems(container: string, depth = 0): ForwardedMessageItem[] {
|
||||
if (!container || depth > 4) return []
|
||||
const variants = new Set<string>([container, decodeXmlEntities(container)])
|
||||
for (const match of container.matchAll(/<!\[CDATA\[([\s\S]*?)\]\]>/g)) {
|
||||
if (match[1]) variants.add(decodeXmlEntities(match[1]))
|
||||
}
|
||||
|
||||
const items: ForwardedMessageItem[] = []
|
||||
for (const variant of variants) {
|
||||
for (const match of variant.matchAll(/<dataitem\b([^>]*)>([\s\S]*?)<\/dataitem>/gi)) {
|
||||
const attributes = match[1] || ''
|
||||
const body = match[2] || ''
|
||||
const attrType = /datatype\s*=\s*["']?(\d+)/i.exec(attributes)?.[1]
|
||||
const messageType = Number.parseInt(attrType || extractXmlValue(body, 'datatype') || '0', 10)
|
||||
const sender = decodeXmlEntities(extractXmlValue(body, 'sourcename')) || undefined
|
||||
const sentAt = extractXmlValue(body, 'sourcetime') || undefined
|
||||
const title = decodeXmlEntities(extractXmlValue(body, 'datatitle'))
|
||||
const description = decodeXmlEntities(
|
||||
extractXmlValue(body, 'datadesc') || extractXmlValue(body, 'content')
|
||||
)
|
||||
const nestedXml = extractXmlBody(body, 'recordxml')
|
||||
const nested =
|
||||
messageType === 17 && nestedXml
|
||||
? parseForwardedItems(decodeXmlEntities(nestedXml), depth + 1)
|
||||
: undefined
|
||||
const text = description || title || forwardedTypeLabel(messageType)
|
||||
if (!sender && !text && !nested?.length) continue
|
||||
items.push({
|
||||
messageType: Number.isFinite(messageType) ? messageType : 0,
|
||||
sender,
|
||||
sentAt,
|
||||
text: text || '[消息]',
|
||||
nested: nested?.length ? nested : undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
return dedupeForwardedItems(items)
|
||||
}
|
||||
|
||||
function dedupeForwardedItems(items: ForwardedMessageItem[]): ForwardedMessageItem[] {
|
||||
const seen = new Set<string>()
|
||||
return items.filter((item) => {
|
||||
const key = `${item.messageType}|${item.sender || ''}|${item.sentAt || ''}|${item.text}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function forwardedTypeLabel(messageType: number): string {
|
||||
switch (messageType) {
|
||||
case 3:
|
||||
return '[图片]'
|
||||
case 34:
|
||||
return '[语音]'
|
||||
case 43:
|
||||
return '[视频]'
|
||||
case 47:
|
||||
return '[表情包]'
|
||||
case 8:
|
||||
case 49:
|
||||
return '[文件或分享]'
|
||||
case 17:
|
||||
return '[聊天记录]'
|
||||
default:
|
||||
return '[消息]'
|
||||
}
|
||||
}
|
||||
|
||||
function extractXmlBody(xml: string, tagName: string): string {
|
||||
const match = new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\\/${tagName}>`, 'i').exec(xml)
|
||||
if (!match?.[1]) return ''
|
||||
return match[1].replace(/^<!\[CDATA\[([\s\S]*?)\]\]>$/, '$1').trim()
|
||||
}
|
||||
|
||||
function parseQuoteMessage(content: string): {
|
||||
content?: string
|
||||
sender?: string
|
||||
@@ -713,9 +822,7 @@ export function parseImageDatNameFromRow(row: Record<string, unknown>): string |
|
||||
return hexMatch?.[1]?.toLowerCase()
|
||||
}
|
||||
|
||||
export function parseImageBufferDataUrlFromRow(
|
||||
row: Record<string, unknown>
|
||||
): string | undefined {
|
||||
export function parseImageBufferDataUrlFromRow(row: Record<string, unknown>): string | undefined {
|
||||
const raw = pickRowString(row, [
|
||||
'ImgBuf',
|
||||
'imgBuf',
|
||||
|
||||
@@ -37,6 +37,8 @@ export interface FormattedContact {
|
||||
avatar?: string
|
||||
wechatNickname?: string
|
||||
remark?: string
|
||||
isFolded?: boolean
|
||||
isMuted?: boolean
|
||||
}
|
||||
|
||||
export interface FormattedMessage {
|
||||
@@ -140,7 +142,9 @@ export function listContacts(filter?: string): FormattedContact[] {
|
||||
type: isGroup ? 'group' : 'user',
|
||||
avatar: typeof user.avatar === 'string' ? user.avatar : undefined,
|
||||
wechatNickname: user.wechatNickname,
|
||||
remark: user.remark
|
||||
remark: user.remark,
|
||||
isFolded: user.isFolded,
|
||||
isMuted: user.isMuted
|
||||
})
|
||||
}
|
||||
|
||||
@@ -174,17 +178,20 @@ export function listContacts(filter?: string): FormattedContact[] {
|
||||
|
||||
export async function listContactsAsync(filter?: string): Promise<FormattedContact[]> {
|
||||
if (!dbRef) return []
|
||||
await dbRef.getWcdb4Client().getSessionsAsync({ hydrateDisplayNames: false })
|
||||
await dbRef.getWcdb4Client().getSessionsAsync({
|
||||
hydrateDisplayNames: false,
|
||||
hydrateStatuses: true
|
||||
})
|
||||
return listContacts(filter)
|
||||
}
|
||||
|
||||
export function getContactAvatars(usernames: string[]): Record<string, string> {
|
||||
export async function getContactAvatars(usernames: string[]): Promise<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)
|
||||
return dbRef.getWcdb4Client().getAvatarUrlsAsync(normalized)
|
||||
}
|
||||
|
||||
function listSourceMessages(
|
||||
@@ -251,7 +258,13 @@ function listSourceMessages(
|
||||
const patContent =
|
||||
system.type === 'system'
|
||||
? { ...system, pat: true }
|
||||
: { type: 'system' as const, content: String(content || '').replace(/<[^>]+>/g, '').trim(), pat: true }
|
||||
: {
|
||||
type: 'system' as const,
|
||||
content: String(content || '')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.trim(),
|
||||
pat: true
|
||||
}
|
||||
contentData = patContent
|
||||
content = patContent.content
|
||||
displayType = '系统消息'
|
||||
@@ -265,11 +278,9 @@ function listSourceMessages(
|
||||
try {
|
||||
const isQuotePayload = /<refermsg\b/i.test(content)
|
||||
const hasStickerPayload =
|
||||
/<(?:emoji|sticker|emoticon)\b/i.test(content) ||
|
||||
/<type>\s*47\s*<\/type>/i.test(content)
|
||||
/<(?:emoji|sticker|emoticon)\b/i.test(content) || /<type>\s*47\s*<\/type>/i.test(content)
|
||||
const rowSticker =
|
||||
inferredMsgType === 47 ||
|
||||
(inferredMsgType === 49 && !isQuotePayload && hasStickerPayload)
|
||||
inferredMsgType === 47 || (inferredMsgType === 49 && !isQuotePayload && hasStickerPayload)
|
||||
? parseStickerMessageFromRow(msg, content)
|
||||
: undefined
|
||||
const parsedContent = parseMessageContent(content, inferredMsgType)
|
||||
@@ -301,7 +312,7 @@ function listSourceMessages(
|
||||
if (parsed.type === 'system') {
|
||||
content = parsed.content
|
||||
contentData = parsed
|
||||
} else if (parsed.type !== 'unknown') {
|
||||
} else {
|
||||
content = ''
|
||||
}
|
||||
if (parsed.type === 'image') {
|
||||
@@ -311,8 +322,7 @@ function listSourceMessages(
|
||||
contentData = {
|
||||
...parsed,
|
||||
thumbDatName: parsed.thumbDatName || parseImageDatNameFromRow(msg),
|
||||
thumbDataUrl:
|
||||
parsed.thumbDataUrl || parseImageBufferDataUrlFromRow(msg.raw || msg)
|
||||
thumbDataUrl: parsed.thumbDataUrl || parseImageBufferDataUrlFromRow(msg.raw || msg)
|
||||
}
|
||||
} else if (parsed.type !== 'system') {
|
||||
if (parsed.type === 'sticker' && !parsed.url && parsed.md5) {
|
||||
@@ -327,6 +337,11 @@ function listSourceMessages(
|
||||
if (parsed.type === 'sticker') displayType = '表情包'
|
||||
if (parsed.type === 'miniProgram') displayType = '小程序'
|
||||
if (parsed.type === 'redPacket') displayType = '微信红包'
|
||||
if (parsed.type === 'forwardBundle') displayType = '合并转发'
|
||||
if (parsed.type === 'unknown') {
|
||||
displayType = '不支持的消息'
|
||||
contentData = { ...parsed, messageType: msgType }
|
||||
}
|
||||
if (parsed.type === 'share') {
|
||||
if (parsed.typeVal === '5') displayType = '公众号链接'
|
||||
if (parsed.typeVal === '6') displayType = '文件'
|
||||
@@ -351,6 +366,12 @@ function listSourceMessages(
|
||||
}
|
||||
}
|
||||
|
||||
if (!contentData && !MSG_TYPE_DICT[msgType] && msgType !== 0) {
|
||||
contentData = { type: 'unknown', raw: rawContent, messageType: msgType }
|
||||
content = ''
|
||||
displayType = '不支持的消息'
|
||||
}
|
||||
|
||||
if (msgType === 34) content = '[语音消息]'
|
||||
|
||||
const recoveredFromRecallJournal = Boolean(msg['_wxe_recovered'] || msg.raw?.['_wxe_recovered'])
|
||||
@@ -403,13 +424,7 @@ export async function listMessagesAsync(
|
||||
): Promise<FormattedMessage[]> {
|
||||
if (!dbRef) return []
|
||||
const rawMessages = await dbRef.getUserMessagesAsync(userMd5, startTime, endTime, options)
|
||||
const sourceMessages = listSourceMessages(
|
||||
userMd5,
|
||||
startTime,
|
||||
endTime,
|
||||
options,
|
||||
rawMessages
|
||||
)
|
||||
const sourceMessages = listSourceMessages(userMd5, startTime, endTime, options, rawMessages)
|
||||
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
|
||||
recordRecallArchiveMessages(userMd5, username, sourceMessages)
|
||||
return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit)
|
||||
|
||||
@@ -5,8 +5,15 @@ import https from 'https'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { Wcdb4Client } from './wcdb4-client'
|
||||
import { classifyStickerHttpFailure, StickerFailureCode } from '../shared/sticker'
|
||||
|
||||
type StickerResult = { success: boolean; data?: string; error?: string }
|
||||
type StickerResult = {
|
||||
success: boolean
|
||||
data?: string
|
||||
error?: string
|
||||
failureCode?: StickerFailureCode
|
||||
httpStatus?: number
|
||||
}
|
||||
|
||||
const downloadCache = new Map<string, Promise<StickerResult>>()
|
||||
|
||||
@@ -129,15 +136,24 @@ export class StickerService {
|
||||
const redirectUrl = response.headers.location
|
||||
if (redirectUrl && [301, 302, 303, 307, 308].includes(Number(response.statusCode || 0))) {
|
||||
const nextUrl = new URL(redirectUrl, url).toString()
|
||||
response.resume()
|
||||
this.downloadToDataUrl(nextUrl, cacheKey, redirectCount + 1).then(resolve)
|
||||
return
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
const statusCode = Number(response.statusCode || 0)
|
||||
const failure = classifyStickerHttpFailure(statusCode, url)
|
||||
response.resume()
|
||||
console.warn(
|
||||
`[StickerService] download failed: HTTP ${response.statusCode}; md5=${cacheKey}; url=${url}`
|
||||
`[StickerService] download failed code=${failure.code} status=${statusCode} md5=${cacheKey} host=${this.getUrlHost(url)}`
|
||||
)
|
||||
resolve({ success: false, error: `表情包下载失败: HTTP ${response.statusCode}` })
|
||||
resolve({
|
||||
success: false,
|
||||
error: failure.message,
|
||||
failureCode: failure.code,
|
||||
httpStatus: statusCode
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -198,6 +214,14 @@ export class StickerService {
|
||||
}
|
||||
}
|
||||
|
||||
private getUrlHost(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname || 'unknown'
|
||||
} catch {
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
private toDataUrl(buffer: Buffer, ext: string): string {
|
||||
const mimeTypes: Record<string, string> = {
|
||||
'.gif': 'image/gif',
|
||||
|
||||
+158
-19
@@ -12,6 +12,8 @@ export interface Wcdb4Session {
|
||||
avatar?: string
|
||||
wechatNickname?: string
|
||||
remark?: string
|
||||
isFolded?: boolean
|
||||
isMuted?: boolean
|
||||
raw: Record<string, unknown>
|
||||
}
|
||||
|
||||
@@ -34,6 +36,7 @@ export interface Wcdb4MessageQueryOptions {
|
||||
|
||||
export interface Wcdb4SessionQueryOptions {
|
||||
hydrateDisplayNames?: boolean
|
||||
hydrateStatuses?: boolean
|
||||
}
|
||||
|
||||
type Wcdb4MessageStore = {
|
||||
@@ -177,9 +180,12 @@ export function bootstrapWcdbNativeAsync(
|
||||
) => number
|
||||
const resourceRoots = Array.from(
|
||||
new Set(
|
||||
[libDir, path.dirname(libDir), process.env.WCDB_RESOURCES_PATH || '', ...getResourceRoots()].filter(
|
||||
Boolean
|
||||
)
|
||||
[
|
||||
libDir,
|
||||
path.dirname(libDir),
|
||||
process.env.WCDB_RESOURCES_PATH || '',
|
||||
...getResourceRoots()
|
||||
].filter(Boolean)
|
||||
)
|
||||
)
|
||||
let initOk = false
|
||||
@@ -242,12 +248,15 @@ export class Wcdb4Client {
|
||||
private handle: number | null = null
|
||||
private displayNameCache = new Map<string, string>()
|
||||
private avatarCache = new Map<string, string>()
|
||||
private sessionStatusCache = new Map<string, { isFolded: boolean; isMuted: boolean }>()
|
||||
private groupNicknameCache = new Map<string, Map<string, string>>()
|
||||
private cachedSessions: Wcdb4Session[] | null = null
|
||||
private cachedChatTables: { name: string; db_number: string }[] | null = null
|
||||
private sessionsInFlight: Promise<Wcdb4Session[]> | null = null
|
||||
private sessionDisplayNamesInFlight: Promise<void> | null = null
|
||||
private sessionDisplayNamesHydrated = false
|
||||
private sessionStatusesInFlight: Promise<void> | null = null
|
||||
private sessionStatusesUpdatedAt = 0
|
||||
private sessionCacheGeneration = 0
|
||||
|
||||
private wcdbShutdown: (() => number) | null = null
|
||||
@@ -276,6 +285,12 @@ export class Wcdb4Client {
|
||||
private wcdbGetAvatarUrls:
|
||||
| ((handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number)
|
||||
| null = null
|
||||
private wcdbGetContactStatus:
|
||||
| ((handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number)
|
||||
| null = null
|
||||
private wcdbGetHeadImageBuffers:
|
||||
| ((handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number)
|
||||
| null = null
|
||||
private wcdbExecQuery:
|
||||
| ((handle: number, kind: string, dbPath: string, sql: string, outJson: WcdbVoidOut) => number)
|
||||
| null = null
|
||||
@@ -565,8 +580,11 @@ export class Wcdb4Client {
|
||||
this.handle = null
|
||||
this.cachedSessions = null
|
||||
this.sessionDisplayNamesHydrated = false
|
||||
this.sessionStatusesInFlight = null
|
||||
this.sessionStatusesUpdatedAt = 0
|
||||
this.displayNameCache.clear()
|
||||
this.avatarCache.clear()
|
||||
this.sessionStatusCache.clear()
|
||||
this.groupNicknameCache.clear()
|
||||
}
|
||||
|
||||
@@ -720,16 +738,11 @@ export class Wcdb4Client {
|
||||
.map((row) => this.normalizeSession(row))
|
||||
.filter((session) => session.username)
|
||||
|
||||
this.hydrateDisplayNames(
|
||||
sessions
|
||||
.filter((session) => this.shouldHydrateSessionDisplayName(session))
|
||||
.map((session) => session.username)
|
||||
)
|
||||
this.cachedSessions = sessions.map((session) => ({
|
||||
...session,
|
||||
nickname: this.displayNameCache.get(session.username) || session.nickname || session.username
|
||||
}))
|
||||
this.sessionDisplayNamesHydrated = true
|
||||
this.sessionDisplayNamesHydrated = false
|
||||
|
||||
return this.cachedSessions
|
||||
}
|
||||
@@ -738,11 +751,13 @@ export class Wcdb4Client {
|
||||
const hydrateDisplayNames = options.hydrateDisplayNames !== false
|
||||
if (this.cachedSessions) {
|
||||
if (hydrateDisplayNames) await this.ensureSessionDisplayNamesAsync()
|
||||
if (options.hydrateStatuses) await this.refreshSessionStatusesAsync()
|
||||
return this.cachedSessions
|
||||
}
|
||||
if (this.sessionsInFlight) {
|
||||
await this.sessionsInFlight
|
||||
if (hydrateDisplayNames) await this.ensureSessionDisplayNamesAsync()
|
||||
if (options.hydrateStatuses) await this.refreshSessionStatusesAsync()
|
||||
return this.cachedSessions || []
|
||||
}
|
||||
if (!this.wcdbGetSessions) return []
|
||||
@@ -765,9 +780,59 @@ export class Wcdb4Client {
|
||||
if (this.sessionsInFlight === request) this.sessionsInFlight = null
|
||||
}
|
||||
if (hydrateDisplayNames) await this.ensureSessionDisplayNamesAsync()
|
||||
if (options.hydrateStatuses) await this.refreshSessionStatusesAsync()
|
||||
return this.cachedSessions || []
|
||||
}
|
||||
|
||||
private async refreshSessionStatusesAsync(): Promise<void> {
|
||||
if (Date.now() - this.sessionStatusesUpdatedAt < 5 * 60 * 1000) return
|
||||
if (this.sessionStatusesInFlight) {
|
||||
await this.sessionStatusesInFlight
|
||||
return
|
||||
}
|
||||
const sessions = this.cachedSessions
|
||||
if (!sessions?.length || !this.wcdbGetContactStatus) return
|
||||
const groupUsernames = sessions
|
||||
.map((session) => session.username)
|
||||
.filter((username) => username.endsWith('@chatroom'))
|
||||
if (!groupUsernames.length) {
|
||||
this.sessionStatusesUpdatedAt = Date.now()
|
||||
return
|
||||
}
|
||||
const request = (async (): Promise<void> => {
|
||||
try {
|
||||
const map = await this.callJsonAsync<
|
||||
Record<string, { isFolded?: boolean; isMuted?: boolean }>
|
||||
>(
|
||||
this.wcdbGetContactStatus as unknown as KoffiAsyncFunction,
|
||||
JSON.stringify(groupUsernames)
|
||||
)
|
||||
for (const username of groupUsernames) {
|
||||
const status = map?.[username]
|
||||
this.sessionStatusCache.set(username, {
|
||||
isFolded: Boolean(status?.isFolded),
|
||||
isMuted: Boolean(status?.isMuted)
|
||||
})
|
||||
}
|
||||
if (this.cachedSessions) {
|
||||
this.cachedSessions = this.cachedSessions.map((session) => {
|
||||
const status = this.sessionStatusCache.get(session.username)
|
||||
return status ? { ...session, ...status } : session
|
||||
})
|
||||
}
|
||||
this.sessionStatusesUpdatedAt = Date.now()
|
||||
} catch (error) {
|
||||
console.warn('[WCDB4] session status lookup failed:', error)
|
||||
}
|
||||
})()
|
||||
this.sessionStatusesInFlight = request
|
||||
try {
|
||||
await request
|
||||
} finally {
|
||||
if (this.sessionStatusesInFlight === request) this.sessionStatusesInFlight = null
|
||||
}
|
||||
}
|
||||
|
||||
invalidateSessionCache(): void {
|
||||
this.sessionCacheGeneration += 1
|
||||
this.cachedSessions = null
|
||||
@@ -1122,6 +1187,52 @@ export class Wcdb4Client {
|
||||
return result
|
||||
}
|
||||
|
||||
async getAvatarUrlsAsync(usernames: string[]): Promise<Record<string, string>> {
|
||||
const normalized = this.uniq(usernames)
|
||||
await this.hydrateAvatarUrlsAsync(normalized)
|
||||
const localCandidates = normalized.filter((username) => {
|
||||
const avatar = this.avatarCache.get(username)
|
||||
return !avatar || !avatar.startsWith('data:')
|
||||
})
|
||||
if (localCandidates.length && this.wcdbGetHeadImageBuffers) {
|
||||
try {
|
||||
const buffers = await this.callJsonAsync<Record<string, string>>(
|
||||
this.wcdbGetHeadImageBuffers as unknown as KoffiAsyncFunction,
|
||||
JSON.stringify(localCandidates)
|
||||
)
|
||||
for (const [username, hex] of Object.entries(buffers || {})) {
|
||||
const avatar = this.avatarHexToDataUrl(hex)
|
||||
if (avatar) this.avatarCache.set(username, avatar)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[WCDB4] local avatar fallback failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const result: Record<string, string> = {}
|
||||
for (const username of normalized) {
|
||||
const avatar = this.avatarCache.get(username)
|
||||
if (avatar) result[username] = avatar
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private avatarHexToDataUrl(value: string): string | undefined {
|
||||
const hex = String(value || '').trim()
|
||||
if (!hex || hex.length % 2 !== 0 || !/^[a-f0-9]+$/i.test(hex)) return undefined
|
||||
const buffer = Buffer.from(hex, 'hex')
|
||||
let mime = 'image/jpeg'
|
||||
if (buffer.length >= 8 && buffer.subarray(1, 4).toString('ascii') === 'PNG') mime = 'image/png'
|
||||
if (
|
||||
buffer.length >= 12 &&
|
||||
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
) {
|
||||
mime = 'image/webp'
|
||||
}
|
||||
return `data:${mime};base64,${buffer.toString('base64')}`
|
||||
}
|
||||
|
||||
getMyGroupNickname(chatroomId: string): string | undefined {
|
||||
const groupNicknames = this.getGroupNicknames(chatroomId)
|
||||
for (const candidate of this.getMyUsernameCandidates()) {
|
||||
@@ -1488,9 +1599,10 @@ export class Wcdb4Client {
|
||||
const nicknames = new Map<string, string>()
|
||||
if (!this.wcdbGetGroupNicknames || !chatroomId) return nicknames
|
||||
|
||||
const rows = await this.callJsonAsync<
|
||||
Record<string, string> | Record<string, unknown>[]
|
||||
>(this.wcdbGetGroupNicknames as unknown as KoffiAsyncFunction, chatroomId)
|
||||
const rows = await this.callJsonAsync<Record<string, string> | Record<string, unknown>[]>(
|
||||
this.wcdbGetGroupNicknames as unknown as KoffiAsyncFunction,
|
||||
chatroomId
|
||||
)
|
||||
this.readStringMap(rows, [
|
||||
'nickname',
|
||||
'nickName',
|
||||
@@ -1756,6 +1868,22 @@ export class Wcdb4Client {
|
||||
this.wcdbGetAvatarUrls = null
|
||||
}
|
||||
|
||||
try {
|
||||
this.wcdbGetContactStatus = lib.func(
|
||||
'int32 wcdb_get_contact_status(int64 handle, const char* usernamesJson, _Out_ void** outJson)'
|
||||
) as (handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number
|
||||
} catch {
|
||||
this.wcdbGetContactStatus = null
|
||||
}
|
||||
|
||||
try {
|
||||
this.wcdbGetHeadImageBuffers = lib.func(
|
||||
'int32 wcdb_get_head_image_buffers(int64 handle, const char* usernamesJson, _Out_ void** outJson)'
|
||||
) as (handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number
|
||||
} catch {
|
||||
this.wcdbGetHeadImageBuffers = null
|
||||
}
|
||||
|
||||
try {
|
||||
this.wcdbExecQuery = lib.func(
|
||||
'int32 wcdb_exec_query(int64 handle, const char* kind, const char* path, const char* sql, _Out_ void** outJson)'
|
||||
@@ -2079,7 +2207,16 @@ export class Wcdb4Client {
|
||||
'contactRemark',
|
||||
'contact_remark'
|
||||
])
|
||||
return { username, nickname, wechatNickname, remark, raw: row }
|
||||
const status = this.sessionStatusCache.get(username)
|
||||
return {
|
||||
username,
|
||||
nickname,
|
||||
wechatNickname,
|
||||
remark,
|
||||
isFolded: status?.isFolded,
|
||||
isMuted: status?.isMuted,
|
||||
raw: row
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeMessage(row: Record<string, unknown>): Wcdb4Message {
|
||||
@@ -2208,9 +2345,10 @@ export class Wcdb4Client {
|
||||
const missing = this.uniq(usernames).filter((username) => !this.displayNameCache.has(username))
|
||||
if (missing.length === 0) return
|
||||
try {
|
||||
const rows = await this.callJsonAsync<
|
||||
Record<string, string> | Record<string, unknown>[]
|
||||
>(this.wcdbGetDisplayNames as unknown as KoffiAsyncFunction, JSON.stringify(missing))
|
||||
const rows = await this.callJsonAsync<Record<string, string> | Record<string, unknown>[]>(
|
||||
this.wcdbGetDisplayNames as unknown as KoffiAsyncFunction,
|
||||
JSON.stringify(missing)
|
||||
)
|
||||
this.readStringMap(rows, [
|
||||
'nickname',
|
||||
'displayName',
|
||||
@@ -2290,9 +2428,10 @@ export class Wcdb4Client {
|
||||
const missing = this.uniq(usernames).filter((username) => !this.avatarCache.has(username))
|
||||
if (missing.length === 0) return
|
||||
try {
|
||||
const rows = await this.callJsonAsync<
|
||||
Record<string, string> | Record<string, unknown>[]
|
||||
>(this.wcdbGetAvatarUrls as unknown as KoffiAsyncFunction, JSON.stringify(missing))
|
||||
const rows = await this.callJsonAsync<Record<string, string> | Record<string, unknown>[]>(
|
||||
this.wcdbGetAvatarUrls as unknown as KoffiAsyncFunction,
|
||||
JSON.stringify(missing)
|
||||
)
|
||||
this.readStringMap(rows, [
|
||||
'avatarUrl',
|
||||
'avatar_url',
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface UserContact {
|
||||
avatar?: string
|
||||
wechatNickname?: string
|
||||
remark?: string
|
||||
isFolded?: boolean
|
||||
isMuted?: boolean
|
||||
}
|
||||
|
||||
export interface WechatMessage {
|
||||
@@ -80,7 +82,9 @@ export class WechatDb {
|
||||
nickname: session.nickname || session.username,
|
||||
avatar: session.avatar,
|
||||
wechatNickname: session.wechatNickname,
|
||||
remark: session.remark
|
||||
remark: session.remark,
|
||||
isFolded: session.isFolded,
|
||||
isMuted: session.isMuted
|
||||
}))
|
||||
.filter((contact) => {
|
||||
if (!keyword) return true
|
||||
@@ -176,12 +180,7 @@ export class WechatDb {
|
||||
this.ensureChatTableMapping()
|
||||
const username = this.chatMd5ToUsername.get(userMd5)
|
||||
if (!username) return []
|
||||
const messages = await this.wcdb4Client.getMessagesAsync(
|
||||
username,
|
||||
startTime,
|
||||
endTime,
|
||||
options
|
||||
)
|
||||
const messages = await this.wcdb4Client.getMessagesAsync(username, startTime, endTime, options)
|
||||
return messages.map((message) => ({ ...message, ...message.raw }))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user