mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 19:47:08 +08:00
feat: 修改群聊日报生成与群成员信息展示
- 支持结构化 AI 群聊日报及移动端长图导出 - 支持日报时间范围和消息类型筛选 - 支持群成员及当前用户群昵称解析 - 移除消息加载更多并优化图片懒加载 - 增强头像处理与敏感密钥日志保护
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import {
|
||||
GroupReportExportRequest,
|
||||
GroupReportExportResult,
|
||||
ReportHeat
|
||||
} from '../shared/group-report'
|
||||
|
||||
const TEMPLATE_NAME = 'mobile_daily_report.html'
|
||||
|
||||
const escapeHtml = (value: unknown): string =>
|
||||
String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
const sanitizeFileName = (value: string): string =>
|
||||
value
|
||||
.replace(/[\\/:*?"<>|]/g, '_')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim() || '未命名群聊'
|
||||
|
||||
const hashName = (name: string): number => {
|
||||
let hash = 0
|
||||
for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0
|
||||
return hash
|
||||
}
|
||||
|
||||
const fallbackAvatar = (name: string): string => {
|
||||
const hue = hashName(name) % 360
|
||||
const initial = escapeHtml(Array.from(name.trim())[0] || '?')
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96"><rect width="96" height="96" rx="18" fill="hsl(${hue} 45% 82%)"/><text x="48" y="58" text-anchor="middle" font-family="-apple-system,BlinkMacSystemFont,PingFang SC,sans-serif" font-size="38" fill="hsl(${hue} 35% 28%)">${initial}</text></svg>`
|
||||
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
|
||||
}
|
||||
|
||||
const imageMimeType = (contentType: string | null, source: string): string => {
|
||||
if (contentType?.startsWith('image/')) return contentType.split(';')[0]
|
||||
const extension = path.extname(source).toLowerCase()
|
||||
if (extension === '.png') return 'image/png'
|
||||
if (extension === '.webp') return 'image/webp'
|
||||
if (extension === '.gif') return 'image/gif'
|
||||
return 'image/jpeg'
|
||||
}
|
||||
|
||||
const embedAvatar = async (source: string | undefined, name: string): Promise<string> => {
|
||||
if (!source) return fallbackAvatar(name)
|
||||
if (/^data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=]+$/i.test(source)) return source
|
||||
|
||||
try {
|
||||
if (/^https?:\/\//i.test(source)) {
|
||||
const response = await fetch(source, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 WechatExplorer',
|
||||
Referer: 'https://weixin.qq.com/'
|
||||
},
|
||||
signal: AbortSignal.timeout(8000)
|
||||
})
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
const mime = imageMimeType(response.headers.get('content-type'), source)
|
||||
return `data:${mime};base64,${Buffer.from(await response.arrayBuffer()).toString('base64')}`
|
||||
}
|
||||
|
||||
const localPath = source.startsWith('file://') ? new URL(source) : source
|
||||
const buffer = await fs.readFile(localPath)
|
||||
return `data:${imageMimeType(null, source)};base64,${buffer.toString('base64')}`
|
||||
} catch (error) {
|
||||
console.warn(`[GroupReport] avatar fallback for ${name}:`, error)
|
||||
return fallbackAvatar(name)
|
||||
}
|
||||
}
|
||||
|
||||
const templatePath = (): string => {
|
||||
const candidates = [
|
||||
path.join(process.resourcesPath, 'resources', TEMPLATE_NAME),
|
||||
path.join(app.getAppPath(), 'resources', TEMPLATE_NAME),
|
||||
path.join(process.cwd(), 'resources', TEMPLATE_NAME)
|
||||
]
|
||||
const found = candidates.find((candidate) => fs.existsSync(candidate))
|
||||
if (!found) throw new Error(`日报模板不存在: ${candidates.join(' | ')}`)
|
||||
return found
|
||||
}
|
||||
|
||||
const heatClass = (heat: ReportHeat): string => {
|
||||
if (heat === '高') return 'hot'
|
||||
if (heat === '低') return 'blue'
|
||||
return ''
|
||||
}
|
||||
|
||||
const replacePlaceholder = (html: string, key: string, value: string): string =>
|
||||
html.replaceAll(`{{${key}}}`, value)
|
||||
|
||||
const renderReportHtml = async (request: GroupReportExportRequest): Promise<string> => {
|
||||
const { report, metadata } = request
|
||||
const avatarNames = new Set<string>(metadata.heroParticipants)
|
||||
report.topics.forEach((topic) => topic.participants.forEach((name) => avatarNames.add(name)))
|
||||
report.importantMessages.forEach((message) => avatarNames.add(message.sender))
|
||||
report.quotes.forEach((quote) =>
|
||||
quote.messages.forEach((message) => avatarNames.add(message.sender))
|
||||
)
|
||||
report.analytics.topSpeakers.forEach((speaker) => avatarNames.add(speaker.name))
|
||||
|
||||
const avatars = new Map<string, string>()
|
||||
await Promise.all(
|
||||
Array.from(avatarNames).map(async (name) => {
|
||||
avatars.set(name, await embedAvatar(metadata.avatars[name], name))
|
||||
})
|
||||
)
|
||||
const avatar = (name: string): string => avatars.get(name) || fallbackAvatar(name)
|
||||
|
||||
const heroNames = metadata.heroParticipants.slice(0, 4)
|
||||
while (heroNames.length < 4) heroNames.push(metadata.groupName)
|
||||
const heroAvatars = heroNames
|
||||
.map((name) => `<img src="${avatar(name)}" alt="${escapeHtml(name)}">`)
|
||||
.join('')
|
||||
|
||||
const topicCards = report.topics
|
||||
.map(
|
||||
(topic) => `<div class="card topic-card">
|
||||
<div class="topic-title-row"><h3>${escapeHtml(topic.title)}</h3><span class="heat ${heatClass(topic.heat)}">${escapeHtml(topic.heat)}热</span></div>
|
||||
<div class="topic-meta">${escapeHtml(topic.timeRange)}</div>
|
||||
<p>${escapeHtml(topic.summary)}</p>
|
||||
${topic.conclusion ? `<p class="muted">${escapeHtml(topic.conclusion)}</p>` : ''}
|
||||
<div class="participants">${topic.participants
|
||||
.slice(0, 5)
|
||||
.map(
|
||||
(name) =>
|
||||
`<span class="person-chip"><img src="${avatar(name)}" alt=""><b>${escapeHtml(name)}</b></span>`
|
||||
)
|
||||
.join('')}</div>
|
||||
<div class="keywords">${topic.keywords.map((word) => `<span>${escapeHtml(word)}</span>`).join('')}</div>
|
||||
</div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const resourceItems = report.resources
|
||||
.map(
|
||||
(resource) =>
|
||||
`<div class="resource"><b>${escapeHtml(resource.title)}</b>${resource.sender ? ` · ${escapeHtml(resource.sender)}` : ''}<br>${escapeHtml(resource.description)}</div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const importantMessages = report.importantMessages
|
||||
.map(
|
||||
(message) => `<div class="important-card">
|
||||
<img class="avatar" src="${avatar(message.sender)}" alt="">
|
||||
<div class="important-body"><div class="important-meta"><b>${escapeHtml(message.sender)}</b><span>${escapeHtml(message.time)}</span></div>
|
||||
<div class="important-text">${escapeHtml(message.content)}</div><div class="important-note">${escapeHtml(message.note)}</div></div>
|
||||
</div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const quoteBlocks = report.quotes
|
||||
.map(
|
||||
(quote) =>
|
||||
`<div class="chat-block">${quote.messages
|
||||
.map(
|
||||
(
|
||||
message
|
||||
) => `<div class="chat-msg"><img class="chat-avatar" src="${avatar(message.sender)}" alt=""><div>
|
||||
<div class="chat-name">${escapeHtml(message.sender)}</div><div class="chat-bubble">${escapeHtml(message.content)}</div>
|
||||
</div></div>`
|
||||
)
|
||||
.join('')}<div class="quote-note">${escapeHtml(quote.note)}</div></div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const qaCards = report.qa
|
||||
.map(
|
||||
(item) =>
|
||||
`<div class="qa-card"><b>Q:${escapeHtml(item.question)}</b><div>A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}</div></div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const maxHeat = Math.max(1, ...report.analytics.topicHeat.map((item) => item.score))
|
||||
const heatBars = report.analytics.topicHeat
|
||||
.map(
|
||||
(item) =>
|
||||
`<div class="bar-row"><span>${escapeHtml(item.topic)}</span><div class="bar"><i style="width:${Math.max(8, Math.round((item.score / maxHeat) * 100))}%"></i></div></div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const rankItems = report.analytics.topSpeakers
|
||||
.slice(0, 5)
|
||||
.map(
|
||||
(speaker, index) =>
|
||||
`<div class="rank"><img src="${avatar(speaker.name)}" alt=""><b>${index + 1}. ${escapeHtml(speaker.name)}</b><span>${Math.max(0, speaker.count)} 条</span></div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const cloudTags = report.keywords
|
||||
.slice(0, 15)
|
||||
.map(
|
||||
(word, index) =>
|
||||
`<span class="${index < 2 ? 'xl' : index < 5 ? 'lg' : index < 9 ? 'md' : ''}">${escapeHtml(word)}</span>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
let html = await fs.readFile(templatePath(), 'utf8')
|
||||
const values: Record<string, string> = {
|
||||
REPORT_TITLE: escapeHtml(`${metadata.groupName}日报`),
|
||||
GROUP_NAME: escapeHtml(metadata.groupName),
|
||||
DATE_RANGE: escapeHtml(metadata.dateRange),
|
||||
RECORD_NOTE: escapeHtml(`${metadata.recordNote} ${report.overview}`.trim()),
|
||||
HERO_AVATARS: heroAvatars,
|
||||
MESSAGE_COUNT: String(metadata.messageCount),
|
||||
ACTIVE_USERS: String(metadata.activeUsers),
|
||||
TIME_SPAN: escapeHtml(metadata.timeSpan),
|
||||
TOPIC_COUNT: String(report.topics.length),
|
||||
TOPIC_CARDS: topicCards,
|
||||
RESOURCES_EMPTY_CLASS: report.resources.length ? '' : 'empty-section',
|
||||
RESOURCE_ITEMS: resourceItems,
|
||||
MESSAGES_EMPTY_CLASS: report.importantMessages.length ? '' : 'empty-section',
|
||||
IMPORTANT_MESSAGES: importantMessages,
|
||||
QUOTES_EMPTY_CLASS: report.quotes.length ? '' : 'empty-section',
|
||||
QUOTE_BLOCKS: quoteBlocks,
|
||||
QA_EMPTY_CLASS: report.qa.length ? '' : 'empty-section',
|
||||
QA_CARDS: qaCards,
|
||||
HEAT_BARS: heatBars,
|
||||
RANK_ITEMS: rankItems,
|
||||
ACTIVITY_TIMELINE: escapeHtml(report.analytics.activeTimeline),
|
||||
CLOUD_TAGS: cloudTags,
|
||||
GENERATED_AT: escapeHtml(metadata.generatedAt),
|
||||
FOOTER_NOTE: escapeHtml(metadata.footerNote)
|
||||
}
|
||||
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
|
||||
return html
|
||||
}
|
||||
|
||||
const captureFullPage = async (htmlPath: string, pngPath: string): Promise<string> => {
|
||||
const reportWindow = new BrowserWindow({
|
||||
show: false,
|
||||
width: 430,
|
||||
height: 800,
|
||||
frame: false,
|
||||
backgroundColor: '#f3f5f7',
|
||||
webPreferences: { sandbox: true }
|
||||
})
|
||||
|
||||
try {
|
||||
await reportWindow.loadFile(htmlPath)
|
||||
await reportWindow.webContents.executeJavaScript(`Promise.all([
|
||||
document.fonts.ready,
|
||||
...Array.from(document.images).map((img) => img.complete ? Promise.resolve() : new Promise((resolve) => {
|
||||
img.addEventListener('load', resolve, { once: true });
|
||||
img.addEventListener('error', resolve, { once: true });
|
||||
}))
|
||||
])`)
|
||||
reportWindow.webContents.debugger.attach('1.3')
|
||||
const metrics = (await reportWindow.webContents.debugger.sendCommand(
|
||||
'Page.getLayoutMetrics'
|
||||
)) as { cssContentSize: { width: number; height: number } }
|
||||
const width = Math.max(430, Math.ceil(metrics.cssContentSize.width))
|
||||
const height = Math.ceil(metrics.cssContentSize.height)
|
||||
const screenshot = (await reportWindow.webContents.debugger.sendCommand(
|
||||
'Page.captureScreenshot',
|
||||
{
|
||||
format: 'png',
|
||||
captureBeyondViewport: true,
|
||||
fromSurface: true,
|
||||
clip: { x: 0, y: 0, width, height, scale: 1 }
|
||||
}
|
||||
)) as { data: string }
|
||||
const png = Buffer.from(screenshot.data, 'base64')
|
||||
if (png.length < 1000) throw new Error('生成的日报图片为空')
|
||||
await fs.writeFile(pngPath, png)
|
||||
return `data:image/png;base64,${screenshot.data}`
|
||||
} finally {
|
||||
if (reportWindow.webContents.debugger.isAttached()) {
|
||||
reportWindow.webContents.debugger.detach()
|
||||
}
|
||||
reportWindow.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
export const exportGroupReport = async (
|
||||
request: GroupReportExportRequest
|
||||
): Promise<GroupReportExportResult> => {
|
||||
try {
|
||||
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
|
||||
await fs.ensureDir(outputDir)
|
||||
const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_可视化长图`
|
||||
const htmlPath = path.join(outputDir, `${baseName}.html`)
|
||||
const pngPath = path.join(outputDir, `${baseName}.png`)
|
||||
const html = await renderReportHtml(request)
|
||||
await fs.writeFile(htmlPath, html, 'utf8')
|
||||
const imageDataUrl = await captureFullPage(htmlPath, pngPath)
|
||||
return { success: true, htmlPath, pngPath, imageDataUrl }
|
||||
} catch (error) {
|
||||
console.error('[GroupReport] export failed:', error)
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
@@ -236,8 +236,6 @@ export class ImageDecryptService {
|
||||
version,
|
||||
'file:',
|
||||
datPath,
|
||||
'xorKey:',
|
||||
this.xorKey,
|
||||
'aesKey present:',
|
||||
!!this.aesKey
|
||||
)
|
||||
@@ -258,7 +256,6 @@ export class ImageDecryptService {
|
||||
return null
|
||||
}
|
||||
const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16)
|
||||
console.log('[ImageDecrypt] AES key bytes:', key.toString('hex'), 'length:', key.length)
|
||||
decrypted = this.decryptDatV4(datPath, key)
|
||||
}
|
||||
|
||||
|
||||
+28
-12
@@ -11,6 +11,8 @@ import {
|
||||
parseStickerMessageFromRow
|
||||
} from './message-parser'
|
||||
import { ImageDecryptService } from './image-decrypt-service'
|
||||
import { exportGroupReport } from './group-report-service'
|
||||
import { GroupReportExportRequest } from '../shared/group-report'
|
||||
|
||||
let wechatDb: WechatDb | null = null
|
||||
let voiceService: VoiceService | null = null
|
||||
@@ -53,8 +55,8 @@ function normalizeMsgType(value: string | number | undefined): number {
|
||||
function createWindow(): void {
|
||||
// 创建浏览器窗口
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 670,
|
||||
width: 1400,
|
||||
height: 800,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
...(process.platform === 'linux' ? { icon } : {}),
|
||||
@@ -102,9 +104,7 @@ app.whenReady().then(() => {
|
||||
ipcMain.handle('db:init', (_, key: string) => {
|
||||
try {
|
||||
const trimmedKey = String(key || '').trim()
|
||||
console.log(
|
||||
`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length} keyPreview=${trimmedKey.slice(0, 6)}...${trimmedKey.slice(-6)}`
|
||||
)
|
||||
console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
|
||||
wechatDb = new WechatDb(key)
|
||||
const wcdb4Client = wechatDb.getWcdb4Client()
|
||||
if (wcdb4Client) {
|
||||
@@ -175,6 +175,10 @@ app.whenReady().then(() => {
|
||||
const rawMessages = wechatDb.getUserMessages(userMd5, startTime, endTime)
|
||||
const groupMembers = wechatDb.getGroupMembersForChat(userMd5)
|
||||
const myAvatar = wechatDb.getMyAvatarUrl()
|
||||
const myGroupNickname =
|
||||
username?.endsWith('@chatroom') && wcdb4Client
|
||||
? wcdb4Client.getMyGroupNickname(username)
|
||||
: undefined
|
||||
|
||||
return rawMessages.map((msg: WechatMessage) => {
|
||||
const rawMsgType = parseInt(msg.messageType)
|
||||
@@ -187,13 +191,12 @@ app.whenReady().then(() => {
|
||||
let content = msg.msgContent
|
||||
let img = ''
|
||||
let name = ''
|
||||
if (isMine && myAvatar) {
|
||||
img = myAvatar
|
||||
} else if (typeof msg.senderAvatar === 'string') {
|
||||
img = msg.senderAvatar
|
||||
}
|
||||
if (typeof msg.senderNickname === 'string') {
|
||||
name = msg.senderNickname
|
||||
if (isMine) {
|
||||
if (myAvatar) img = myAvatar
|
||||
name = myGroupNickname || (typeof msg.senderNickname === 'string' ? msg.senderNickname : '')
|
||||
} else {
|
||||
if (typeof msg.senderAvatar === 'string') img = msg.senderAvatar
|
||||
if (typeof msg.senderNickname === 'string') name = msg.senderNickname
|
||||
}
|
||||
// 检查内容是否以 wxid 开头并包含冒号
|
||||
// 示例: wxid_xxxx:\nContent 或 wxid_xxxx:Content
|
||||
@@ -343,6 +346,19 @@ app.whenReady().then(() => {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('report:export', async (_, request: GroupReportExportRequest) => {
|
||||
return exportGroupReport(request)
|
||||
})
|
||||
|
||||
ipcMain.handle('report:reveal', async (_, filePath: string) => {
|
||||
try {
|
||||
shell.showItemInFolder(filePath)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'db:getVoiceData',
|
||||
async (_, sessionId: string, localId: number, createTime: number, svrId?: string | number) => {
|
||||
|
||||
+77
-10
@@ -65,6 +65,7 @@ export class Wcdb4Client {
|
||||
private initialized = false
|
||||
private displayNameCache = new Map<string, string>()
|
||||
private avatarCache = new Map<string, string>()
|
||||
private groupNicknameCache = new Map<string, Map<string, string>>()
|
||||
private cachedSessions: Wcdb4Session[] | null = null
|
||||
|
||||
private wcdbInit: (() => number) | null = null
|
||||
@@ -96,6 +97,9 @@ export class Wcdb4Client {
|
||||
private wcdbGetGroupMembers:
|
||||
| ((handle: number, chatroomId: string, outJson: WcdbVoidOut) => number)
|
||||
| null = null
|
||||
private wcdbGetGroupNicknames:
|
||||
| ((handle: number, chatroomId: string, outJson: WcdbVoidOut) => number)
|
||||
| null = null
|
||||
private wcdbOpenMessageCursor:
|
||||
| ((
|
||||
handle: number,
|
||||
@@ -234,6 +238,7 @@ export class Wcdb4Client {
|
||||
this.cachedSessions = null
|
||||
this.displayNameCache.clear()
|
||||
this.avatarCache.clear()
|
||||
this.groupNicknameCache.clear()
|
||||
}
|
||||
|
||||
getSessions(): Wcdb4Session[] {
|
||||
@@ -291,12 +296,7 @@ export class Wcdb4Client {
|
||||
}
|
||||
|
||||
getMyAvatarUrl(): string | undefined {
|
||||
const rawAccountName = path.basename(this.accountRoot)
|
||||
const candidates = this.uniq([
|
||||
this.wxid,
|
||||
rawAccountName,
|
||||
Wcdb4Client.cleanAccountDirName(rawAccountName)
|
||||
])
|
||||
const candidates = this.getMyUsernameCandidates()
|
||||
this.hydrateAvatarUrls(candidates)
|
||||
|
||||
for (const candidate of candidates) {
|
||||
@@ -307,6 +307,15 @@ export class Wcdb4Client {
|
||||
return undefined
|
||||
}
|
||||
|
||||
getMyGroupNickname(chatroomId: string): string | undefined {
|
||||
const groupNicknames = this.getGroupNicknames(chatroomId)
|
||||
for (const candidate of this.getMyUsernameCandidates()) {
|
||||
const nickname = groupNicknames.get(candidate)
|
||||
if (nickname) return nickname
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
private getMessagesByCursor(
|
||||
username: string,
|
||||
startTime?: number,
|
||||
@@ -413,11 +422,12 @@ export class Wcdb4Client {
|
||||
if (!this.wcdbGetGroupMembers || !chatroomId) return []
|
||||
|
||||
try {
|
||||
const groupNicknames = this.getGroupNicknames(chatroomId)
|
||||
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
||||
this.wcdbGetGroupMembers!(handle, chatroomId, outJson)
|
||||
)
|
||||
|
||||
return (Array.isArray(rows) ? rows : []).map((row) => {
|
||||
const members = (Array.isArray(rows) ? rows : []).map((row) => {
|
||||
const username = this.pickString(row, [
|
||||
'username',
|
||||
'userName',
|
||||
@@ -425,10 +435,15 @@ export class Wcdb4Client {
|
||||
'member_username',
|
||||
'm_nsUsrName'
|
||||
])
|
||||
const nickname = this.pickString(row, [
|
||||
const memberNickname = this.pickString(row, [
|
||||
'nickname',
|
||||
'nickName',
|
||||
'displayName',
|
||||
'display_name',
|
||||
'groupNickname',
|
||||
'group_nickname',
|
||||
'roomNickname',
|
||||
'room_nickname',
|
||||
'remark',
|
||||
'm_nsNickName'
|
||||
])
|
||||
@@ -440,21 +455,60 @@ export class Wcdb4Client {
|
||||
])
|
||||
|
||||
if (username) {
|
||||
if (nickname) this.displayNameCache.set(username, nickname)
|
||||
if (avatar) this.avatarCache.set(username, avatar)
|
||||
}
|
||||
|
||||
return {
|
||||
m_nsUsrName: username,
|
||||
nickname: nickname || username,
|
||||
nickname: groupNicknames.get(username) || memberNickname,
|
||||
m_nsHeadImgUrl: avatar
|
||||
}
|
||||
})
|
||||
|
||||
const missingDisplayNames = members
|
||||
.filter((member) => !member.nickname)
|
||||
.map((member) => member.m_nsUsrName)
|
||||
.filter(Boolean)
|
||||
this.hydrateDisplayNames(missingDisplayNames)
|
||||
return members.map((member) => ({
|
||||
...member,
|
||||
nickname:
|
||||
member.nickname || this.displayNameCache.get(member.m_nsUsrName) || member.m_nsUsrName,
|
||||
m_nsHeadImgUrl: member.m_nsHeadImgUrl || this.avatarCache.get(member.m_nsUsrName) || ''
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
getGroupNicknames(chatroomId: string): Map<string, string> {
|
||||
const cached = this.groupNicknameCache.get(chatroomId)
|
||||
if (cached) return cached
|
||||
|
||||
const nicknames = new Map<string, string>()
|
||||
if (!this.wcdbGetGroupNicknames || !chatroomId) return nicknames
|
||||
|
||||
try {
|
||||
const rows = this.callJson<Record<string, string> | Record<string, unknown>[]>(
|
||||
(handle, outJson) => this.wcdbGetGroupNicknames!(handle, chatroomId, outJson)
|
||||
)
|
||||
this.readStringMap(rows, [
|
||||
'nickname',
|
||||
'nickName',
|
||||
'displayName',
|
||||
'display_name',
|
||||
'groupNickname',
|
||||
'group_nickname',
|
||||
'name'
|
||||
]).forEach((nickname, username) => nicknames.set(username, nickname))
|
||||
this.groupNicknameCache.set(chatroomId, nicknames)
|
||||
} catch (error) {
|
||||
console.warn(`[WCDB4] failed to get group nicknames for ${chatroomId}:`, error)
|
||||
}
|
||||
|
||||
return nicknames
|
||||
}
|
||||
|
||||
async getVoiceData(
|
||||
sessionId: string,
|
||||
createTime: number,
|
||||
@@ -650,6 +704,14 @@ export class Wcdb4Client {
|
||||
this.wcdbGetGroupMembers = null
|
||||
}
|
||||
|
||||
try {
|
||||
this.wcdbGetGroupNicknames = lib.func(
|
||||
'int32 wcdb_get_group_nicknames(int64 handle, const char* chatroomId, _Out_ void** outJson)'
|
||||
) as (handle: number, chatroomId: string, outJson: WcdbVoidOut) => number
|
||||
} catch {
|
||||
this.wcdbGetGroupNicknames = null
|
||||
}
|
||||
|
||||
try {
|
||||
this.wcdbOpenMessageCursor = lib.func(
|
||||
'int32 wcdb_open_message_cursor(int64 handle, const char* sessionId, int32 batchSize, int32 ascending, int32 beginTimestamp, int32 endTimestamp, _Out_ int64* outCursor)'
|
||||
@@ -1168,6 +1230,11 @@ export class Wcdb4Client {
|
||||
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)))
|
||||
}
|
||||
|
||||
private getMyUsernameCandidates(): string[] {
|
||||
const rawAccountName = path.basename(this.accountRoot)
|
||||
return this.uniq([this.wxid, rawAccountName, Wcdb4Client.cleanAccountDirName(rawAccountName)])
|
||||
}
|
||||
|
||||
private normalizeTimestamp(input: number): number {
|
||||
if (!input || input <= 0) return 0
|
||||
const normalized = input > 1e12 ? Math.floor(input / 1000) : Math.floor(input)
|
||||
|
||||
@@ -53,7 +53,7 @@ export class WechatDb {
|
||||
|
||||
constructor(rawKey: string) {
|
||||
this.rawKey = rawKey
|
||||
console.log(`Initializing WechatDb with key: ${rawKey}`)
|
||||
console.log(`Initializing WechatDb with key length: ${rawKey.trim().length}`)
|
||||
|
||||
if (this.tryOpenWechat4()) {
|
||||
this.chatDb = this.getChatDbNumber()
|
||||
|
||||
Reference in New Issue
Block a user