#1043 #1032;修复增量导出意外添加文件的问题;聊天页面支持右键显示资料卡;优化曾经的好友相关算法并新增黑名单;

This commit is contained in:
cc committed 2026-07-03 16:41:37 +08:00
1 parent 801ed16d5e
commit b87444dd64
30 files changed
+1615 -145

No files matched your search

+192 -34
View File
@@ -1,10 +1,7 @@
import { join } from 'path'
import { readdirSync, existsSync } from 'fs'
import { wcdbService } from './wcdbService'
import { ConfigService } from './config'
import { chatService, Message } from './chatService'
import { chatService } from './chatService'
import { ipcMain } from 'electron'
import { createHash } from 'crypto'
export interface BizAccount {
username: string
@@ -14,6 +11,24 @@ export interface BizAccount {
last_time: number
formatted_last_time: string
unread_count?: number
status?: 'active' | 'inactive'
health_reason?: string
stale_level?: 'none' | 'one_year' | 'two_year' | 'unknown'
days_since_last_article?: number
}
export interface BizAccountHealth {
summary: {
active_total: number
subscription_total: number
service_total: number
invalid_total: number
stale_one_year_total: number
stale_two_year_total: number
unknown_last_article_total: number
}
accounts: BizAccount[]
invalid_accounts: BizAccount[]
}
export interface BizMessage {
@@ -39,11 +54,48 @@ export interface BizPayRecord {
export class BizService {
private configService: ConfigService
private readonly builtinOfficialHelpers = new Set([
'gh_f0a92aa7146c' // 微信收款助手不出现在通讯录的公众号/服务号分组里
])
constructor() {
this.configService = new ConfigService()
}
private isVisibleOfficialAccount(username: string, localType: number): boolean {
return username.startsWith('gh_') && localType === 1 && !this.builtinOfficialHelpers.has(username)
}
private toInt(value: unknown, fallback: number = 0): number {
const parsed = Number.parseInt(String(value ?? ''), 10)
return Number.isFinite(parsed) ? parsed : fallback
}
private getRowString(row: Record<string, any>, keys: string[]): string {
for (const key of keys) {
const value = row[key]
if (value !== undefined && value !== null && String(value).trim()) return String(value).trim()
}
return ''
}
private formatBizTime(ts: number): string {
if (!ts) return ''
const date = new Date(ts * 1000)
const now = new Date()
const isToday = date.toDateString() === now.toDateString()
if (isToday) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
const yesterday = new Date(now)
yesterday.setDate(now.getDate() - 1)
if (date.toDateString() === yesterday.toDateString()) return '昨天'
const isThisYear = date.getFullYear() === now.getFullYear()
if (isThisYear) return `${date.getMonth() + 1}/${date.getDate()}`
return `${date.getFullYear().toString().slice(-2)}/${date.getMonth() + 1}/${date.getDate()}`
}
private extractXmlValue(xml: string, tagName: string): string {
const regex = new RegExp(`<${tagName}>([\\s\\S]*?)</${tagName}>`, 'i')
const match = regex.exec(xml)
@@ -99,10 +151,9 @@ export class BizService {
const enrichment = await chatService.enrichSessionsContactInfo(usernames)
const contactInfoMap = enrichment.success && enrichment.contacts ? enrichment.contacts : {}
const root = this.configService.get('dbPath')
const myWxid = this.configService.getMyWxidCleaned()
const accountWxid = account || myWxid
if (!root || !accountWxid) return []
if (!accountWxid) return []
const bizLatestTime: Record<string, number> = {}
const bizUnreadCount: Record<string, number> = {}
@@ -129,24 +180,6 @@ export class BizService {
console.error('获取 Sessions 失败:', e)
}
// 3. 格式化时间显示
const formatBizTime = (ts: number) => {
if (!ts) return ''
const date = new Date(ts * 1000)
const now = new Date()
const isToday = date.toDateString() === now.toDateString()
if (isToday) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
const yesterday = new Date(now)
yesterday.setDate(now.getDate() - 1)
if (date.toDateString() === yesterday.toDateString()) return '昨天'
const isThisYear = date.getFullYear() === now.getFullYear()
if (isThisYear) return `${date.getMonth() + 1}/${date.getDate()}`
return `${date.getFullYear().toString().slice(-2)}/${date.getMonth() + 1}/${date.getDate()}`
}
// 4. 组装数据
const result: BizAccount[] = officialContacts.map(contact => {
const uname = contact.username
@@ -158,20 +191,19 @@ export class BizService {
avatar: info?.avatarUrl || '',
type: 0,
last_time: lastTime,
formatted_last_time: formatBizTime(lastTime),
unread_count: bizUnreadCount[uname] || 0
formatted_last_time: this.formatBizTime(lastTime),
unread_count: bizUnreadCount[uname] || 0,
status: 'active',
stale_level: 'none'
}
})
// 5. 补充公众号类型 (订阅号/服务号)
const contactDbPath = join(root, accountWxid, 'db_storage', 'contact', 'contact.db')
if (existsSync(contactDbPath)) {
const bizInfoRes = await wcdbService.execQuery('contact', contactDbPath, 'SELECT username, type FROM biz_info')
if (bizInfoRes.success && bizInfoRes.rows) {
const typeMap: Record<string, number> = {}
for (const r of bizInfoRes.rows) typeMap[r.username] = r.type
for (const acc of result) if (typeMap[acc.username] !== undefined) acc.type = typeMap[acc.username]
}
const bizInfoRes = await wcdbService.execQuery('contact', null, 'SELECT username, type FROM biz_info')
if (bizInfoRes.success && bizInfoRes.rows) {
const typeMap: Record<string, number> = {}
for (const r of bizInfoRes.rows as Array<Record<string, any>>) typeMap[String(r.username || '')] = this.toInt(r.type)
for (const acc of result) if (typeMap[acc.username] !== undefined) acc.type = typeMap[acc.username]
}
// 6. 排序输出
@@ -188,6 +220,131 @@ export class BizService {
}
}
async listAccountHealth(account?: string): Promise<BizAccountHealth> {
const empty: BizAccountHealth = {
summary: {
active_total: 0,
subscription_total: 0,
service_total: 0,
invalid_total: 0,
stale_one_year_total: 0,
stale_two_year_total: 0,
unknown_last_article_total: 0
},
accounts: [],
invalid_accounts: []
}
try {
const contactsRes = await wcdbService.execQuery(
'contact',
null,
"SELECT username, nick_name, remark, alias, local_type, delete_flag, verify_flag, flag FROM contact WHERE username LIKE 'gh_%'"
)
if (!contactsRes.success || !Array.isArray(contactsRes.rows)) return empty
const bizInfoRes = await wcdbService.execQuery('contact', null, 'SELECT username, type FROM biz_info')
const typeMap: Record<string, number> = {}
if (bizInfoRes.success && Array.isArray(bizInfoRes.rows)) {
for (const row of bizInfoRes.rows as Array<Record<string, any>>) {
const username = String(row.username || '').trim()
if (username) typeMap[username] = this.toInt(row.type)
}
}
const rows = contactsRes.rows as Array<Record<string, any>>
const usernames = rows.map((row) => String(row.username || '').trim()).filter(Boolean)
const enrichment = await chatService.enrichSessionsContactInfo(usernames)
const contactInfoMap = enrichment.success && enrichment.contacts ? enrichment.contacts : {}
const latestTime: Record<string, number> = {}
const unreadCount: Record<string, number> = {}
try {
const sessionsRes = await chatService.getSessions()
if (sessionsRes.success && sessionsRes.sessions) {
for (const session of sessionsRes.sessions) {
const username = String(session.username || session.strUsrName || session.userName || session.id || '').trim()
if (!username) continue
const time = this.toInt(session.lastTimestamp || session.sortTimestamp || session.last_timestamp || session.sort_timestamp || session.nTime || session.timestamp)
if (time > 0) latestTime[username] = time
const unread = Number(session.unreadCount ?? session.unread_count ?? 0)
unreadCount[username] = Number.isFinite(unread) ? Math.max(0, Math.floor(unread)) : 0
}
}
} catch (e) {
console.error('获取 Sessions 失败:', e)
}
const nowSeconds = Math.floor(Date.now() / 1000)
const oneYearSeconds = 365 * 24 * 60 * 60
const accounts: BizAccount[] = []
const invalidAccounts: BizAccount[] = []
for (const row of rows) {
const username = String(row.username || '').trim()
if (!username || this.builtinOfficialHelpers.has(username)) continue
const hasBizInfo = typeMap[username] !== undefined
if (!hasBizInfo) continue
const localType = this.toInt(row.local_type ?? row.localType)
const info = contactInfoMap[username]
const lastTime = latestTime[username] || 0
const isActive = this.isVisibleOfficialAccount(username, localType)
let staleLevel: BizAccount['stale_level'] = 'none'
let daysSinceLastArticle: number | undefined
if (!lastTime) {
staleLevel = 'unknown'
} else {
daysSinceLastArticle = Math.max(0, Math.floor((nowSeconds - lastTime) / 86400))
if (nowSeconds - lastTime >= oneYearSeconds * 2) staleLevel = 'two_year'
else if (nowSeconds - lastTime >= oneYearSeconds) staleLevel = 'one_year'
}
const accountInfo: BizAccount = {
username,
name: info?.displayName || this.getRowString(row, ['remark', 'nick_name', 'alias']) || username,
avatar: info?.avatarUrl || '',
type: typeMap[username] ?? 3,
last_time: lastTime,
formatted_last_time: this.formatBizTime(lastTime),
unread_count: unreadCount[username] || 0,
status: isActive ? 'active' : 'inactive',
health_reason: isActive ? undefined : '不在微信通讯录的公众号/服务号有效分组中,可能是历史残留、已取消关注或已失效账号',
stale_level: staleLevel,
days_since_last_article: daysSinceLastArticle
}
if (isActive) accounts.push(accountInfo)
else invalidAccounts.push(accountInfo)
}
const sortAccounts = (items: BizAccount[]) => items.sort((a, b) => {
if (a.username === 'gh_3dfda90e39d6') return -1
if (b.username === 'gh_3dfda90e39d6') return 1
return (b.last_time || 0) - (a.last_time || 0) || a.name.localeCompare(b.name, 'zh-Hans-CN')
})
sortAccounts(accounts)
sortAccounts(invalidAccounts)
return {
summary: {
active_total: accounts.length,
subscription_total: accounts.filter((item) => item.type === 0).length,
service_total: accounts.filter((item) => item.type === 1).length,
invalid_total: invalidAccounts.length,
stale_one_year_total: accounts.filter((item) => item.stale_level === 'one_year' || item.stale_level === 'two_year').length,
stale_two_year_total: accounts.filter((item) => item.stale_level === 'two_year').length,
unknown_last_article_total: accounts.filter((item) => item.stale_level === 'unknown').length
},
accounts,
invalid_accounts: invalidAccounts
}
} catch (e) {
console.error('获取公众号健康状态失败:', e)
return empty
}
}
async listMessages(username: string, account?: string, limit: number = 20, offset: number = 0): Promise<BizMessage[]> {
try {
// 仅保留核心路径:利用 chatService 的自动路由能力
@@ -242,6 +399,7 @@ export class BizService {
registerHandlers() {
ipcMain.handle('biz:listAccounts', (_, account) => this.listAccounts(account))
ipcMain.handle('biz:listAccountHealth', (_, account) => this.listAccountHealth(account))
ipcMain.handle('biz:listMessages', (_, username, account, limit, offset) => this.listMessages(username, account, limit, offset))
ipcMain.handle('biz:listPayRecords', (_, account, limit, offset) => this.listPayRecords(account, limit, offset))
}
+248 -26
View File
@@ -170,9 +170,16 @@ export interface ContactInfo {
detailDescription?: string
region?: string
avatarUrl?: string
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
type: 'friend' | 'group' | 'official' | 'former_friend' | 'blocked' | 'other'
officialAccountKind?: 'subscription' | 'service' | 'enterprise' | 'unknown'
officialAccountType?: number
}
const BUILTIN_OFFICIAL_HELPER_USERNAMES = new Set([
'gh_f0a92aa7146c' // 微信收款助手不出现在通讯录的公众号/服务号分组里
])
const CONTACT_CLASSIFICATION_CACHE_VERSION = 'contact-classification-v5'
interface GetContactsOptions {
lite?: boolean
}
@@ -220,6 +227,7 @@ interface ExportTabCounts {
group: number
official: number
former_friend: number
blocked?: number
}
interface SessionDetailFast {
@@ -359,6 +367,8 @@ class ChatService {
private readonly messageCursorSessionLimit = 8
private avatarCache: Map<string, ContactCacheEntry>
private readonly avatarCacheTtlMs = 10 * 60 * 1000
private groupNicknameCache = new Map<string, { nicknames: Map<string, string>; updatedAt: number }>()
private readonly groupNicknameCacheTtlMs = 3 * 60 * 1000
private readonly defaultV1AesKey = 'cfcd208495d565ef'
private readonly contactCacheService: ContactCacheService
private readonly messageCacheService: MessageCacheService
@@ -1677,7 +1687,8 @@ class ChatService {
private: Number(result.counts.private || 0),
group: Number(result.counts.group || 0),
official: Number(result.counts.official || 0),
former_friend: Number(result.counts.former_friend || 0)
former_friend: Number(result.counts.former_friend || 0),
blocked: Number(result.counts.blocked || 0)
}
return { success: true, counts }
@@ -2053,7 +2064,7 @@ class ChatService {
private getContactsCacheScope(): string {
const dbPath = String(this.configService.get('dbPath') || '').trim()
const myWxid = String(this.configService.getMyWxidCleaned() || '').trim()
return `${dbPath}::${myWxid}`
return `${dbPath}::${myWxid}::${CONTACT_CLASSIFICATION_CACHE_VERSION}`
}
private cloneContacts(contacts: ContactInfo[]): ContactInfo[] {
@@ -2143,6 +2154,7 @@ class ChatService {
const transformStartedAt = Date.now()
const contacts: (ContactInfo & { lastContactTime: number })[] = []
let contactLabelNameMap = new Map<number, string>()
const officialAccountTypeMap = await this.getOfficialAccountTypeMap()
if (!isLiteMode) {
const labelMapStartedAt = Date.now()
contactLabelNameMap = await this.getContactLabelNameMap()
@@ -2153,11 +2165,18 @@ class ChatService {
if (!username) continue
let type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' = 'other'
let type: 'friend' | 'group' | 'official' | 'former_friend' | 'blocked' | 'other' = 'other'
const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0)
const flag = this.getRowInt(row, ['flag', 'contact_flag', 'contactFlag', 'WCDB_CT_flag'], 0)
const quanPin = String(this.getRowField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin']) || '').trim()
const alias = String(this.getRowField(row, ['alias', 'WCDB_CT_alias']) || '').trim()
const remark = String(this.getRowField(row, ['remark', 'WCDB_CT_remark']) || '').trim()
const loweredUsername = username.toLowerCase()
const isOpenimEnterprise = this.isEnterpriseOpenimUsername(username)
const isFormerFriendResidual = !username.startsWith('gh_') && (
(localType === 0 && quanPin) ||
(localType === 3 && flag !== 4 && (quanPin || alias || remark))
)
if (isOpenimEnterprise && !this.isAllowedEnterpriseOpenimByLocalType(username, localType)) {
continue
}
@@ -2165,16 +2184,18 @@ class ChatService {
if (username.endsWith('@chatroom')) {
type = 'group'
} else if (username.startsWith('gh_')) {
} else if (username.startsWith('gh_') && localType === 1 && !BUILTIN_OFFICIAL_HELPER_USERNAMES.has(username)) {
type = 'official'
} else if (!username.startsWith('gh_') && (flag & 8) === 8) {
type = 'blocked'
} else if (isFormerFriendResidual) {
type = 'former_friend'
} else if (isOpenimEnterprise) {
type = 'friend'
} else if (isVisibleWeixinContact) {
type = 'friend'
} else if (localType === 1 && !FRIEND_EXCLUDE_USERNAMES.has(username)) {
type = 'friend'
} else if (localType === 0 && quanPin) {
type = 'former_friend'
} else {
continue
}
@@ -2184,6 +2205,7 @@ class ChatService {
const description = isLiteMode ? '' : this.getContactDescription(row)
const detailDescription = isLiteMode ? '' : this.getContactSignature(row)
const region = isLiteMode ? '' : this.getContactRegion(row)
const officialAccountType = type === 'official' ? officialAccountTypeMap.get(username) : undefined
contacts.push({
username,
@@ -2197,6 +2219,8 @@ class ChatService {
region: region || undefined,
avatarUrl: undefined,
type,
officialAccountType,
officialAccountKind: type === 'official' ? this.getOfficialAccountKind(officialAccountType) : undefined,
lastContactTime: lastContactTimeMap.get(username) || 0
})
}
@@ -2572,6 +2596,7 @@ class ChatService {
if (normalized.length > 0) {
await this.repairEmojiMessages(normalized)
await this.resolveQuotedMessages(normalized, sessionId)
await this.enrichGroupMessageSenderProfiles(normalized, sessionId)
}
return {
@@ -2657,6 +2682,7 @@ class ChatService {
if (normalized.length > 0) {
await this.repairEmojiMessages(normalized)
await this.resolveQuotedMessages(normalized, sessionId)
await this.enrichGroupMessageSenderProfiles(normalized, sessionId)
}
return {
@@ -2824,6 +2850,7 @@ class ChatService {
if (fixPromises.length > 0) {
await Promise.allSettled(fixPromises)
}
await this.enrichGroupMessageSenderProfiles(normalized, sessionId)
return { success: true, messages: normalized }
} catch (e) {
@@ -2891,6 +2918,7 @@ class ChatService {
if (collected.length > 0) {
await this.repairEmojiMessages(collected)
await this.enrichGroupMessageSenderProfiles(collected, sessionId)
}
return { success: true, messages: collected }
}
@@ -3157,6 +3185,7 @@ class ChatService {
if (normalized.length > 0) {
await this.repairEmojiMessages(normalized)
await this.resolveQuotedMessages(normalized, sessionId)
await this.enrichGroupMessageSenderProfiles(normalized, sessionId)
}
return {
success: true,
@@ -3168,6 +3197,41 @@ class ChatService {
}
}
private async enrichGroupMessageSenderProfiles(messages: Message[], sessionId: string): Promise<void> {
const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId.endsWith('@chatroom') || messages.length === 0) return
const senderUsernames = Array.from(new Set(
messages
.map((message) => String(message.senderUsername || '').trim())
.filter(Boolean)
))
if (senderUsernames.length === 0) return
try {
const [groupNicknames, contactsResult] = await Promise.all([
this.getGroupNicknamesForRoom(normalizedSessionId),
this.enrichSessionsContactInfo(senderUsernames)
])
const contacts = contactsResult.success && contactsResult.contacts ? contactsResult.contacts : {}
for (const message of messages) {
const sender = String(message.senderUsername || '').trim()
if (!sender) continue
const groupNickname = this.resolveGroupNicknameByCandidates(
groupNicknames,
this.buildSenderGroupNicknameCandidates(sender)
)
const contact = contacts[sender]
message.senderDisplayName = groupNickname || contact?.displayName || message.senderDisplayName || sender
message.senderAvatarUrl = contact?.avatarUrl || message.senderAvatarUrl
}
} catch (error) {
console.warn('[ChatService] 补充群消息发送者资料失败:', error)
}
}
private getRowField(row: Record<string, any>, keys: string[]): any {
for (const key of keys) {
if (row[key] !== undefined && row[key] !== null) return row[key]
@@ -3423,6 +3487,45 @@ class ChatService {
return new Map(labelMap)
}
private async getOfficialAccountTypeMap(): Promise<Map<string, number>> {
const typeMap = new Map<string, number>()
try {
const tableResult = await wcdbService.execQuery(
'contact',
null,
"SELECT name FROM sqlite_master WHERE type='table' AND lower(name)='biz_info' LIMIT 1"
)
const tableName = tableResult.success && Array.isArray(tableResult.rows)
? String((tableResult.rows[0] as Record<string, any> | undefined)?.name || '').trim()
: ''
if (!tableName) return typeMap
const result = await wcdbService.execQuery(
'contact',
null,
`SELECT username, type FROM ${this.quoteSqlIdentifier(tableName)}`
)
if (!result.success || !Array.isArray(result.rows)) return typeMap
for (const row of result.rows as Array<Record<string, any>>) {
const username = String(this.getRowField(row, ['username', 'user_name', 'userName']) || '').trim()
if (!username) continue
const type = this.getRowInt(row, ['type', 'biz_type', 'bizType'], -1)
if (type >= 0) typeMap.set(username, type)
}
} catch (error) {
console.warn('读取 biz_info 公众号类型失败:', error)
}
return typeMap
}
private getOfficialAccountKind(type: number | undefined): 'subscription' | 'service' | 'enterprise' | 'unknown' {
if (type === 0) return 'subscription'
if (type === 1) return 'service'
if (type === 2 || type === 3) return 'enterprise'
return 'unknown'
}
private toExtraBufferBytes(row: Record<string, any>): Buffer | null {
const raw = this.getRowField(row, ['extra_buffer', 'extraBuffer'])
if (raw === undefined || raw === null) return null
@@ -3972,6 +4075,114 @@ class ChatService {
return [lowerRaw]
}
private getGroupNicknameCacheKey(chatroomId: string): string {
const dbPath = String(this.configService.get('dbPath') || '').trim()
const myWxid = String(this.configService.getMyWxidCleaned() || '').trim()
return `${dbPath}::${myWxid}::${String(chatroomId || '').trim()}`
}
private normalizeGroupNickname(value: unknown): string {
const trimmed = String(value || '').trim()
if (!trimmed) return ''
const cleaned = trimmed.replace(/[\x00-\x1F\x7F]/g, '').trim()
if (!cleaned) return ''
if (/^[,"'“”‘’,、]+$/.test(cleaned)) return ''
return cleaned
}
private normalizeGroupNicknameIdentity(value: unknown): string {
return String(value || '').trim().toLowerCase()
}
private buildGroupNicknameIdCandidates(values: Array<unknown>): string[] {
const candidates = new Set<string>()
for (const rawValue of values) {
const raw = String(rawValue || '').trim()
if (!raw) continue
candidates.add(raw)
for (const identityKey of this.buildIdentityKeys(raw)) {
if (identityKey) candidates.add(identityKey)
}
}
return Array.from(candidates)
}
private buildTrustedGroupNicknameMap(entries: Iterable<[string, string]>): Map<string, string> {
const buckets = new Map<string, Set<string>>()
for (const [memberIdRaw, nicknameRaw] of entries) {
const identity = this.normalizeGroupNicknameIdentity(memberIdRaw)
if (!identity) continue
const nickname = this.normalizeGroupNickname(nicknameRaw)
if (!nickname) continue
const bucket = buckets.get(identity)
if (bucket) {
bucket.add(nickname)
} else {
buckets.set(identity, new Set([nickname]))
}
}
const trusted = new Map<string, string>()
for (const [identity, nicknameSet] of buckets.entries()) {
if (nicknameSet.size === 1) {
trusted.set(identity, Array.from(nicknameSet)[0])
}
}
return trusted
}
private async getGroupNicknamesForRoom(chatroomId: string): Promise<Map<string, string>> {
const normalizedChatroomId = String(chatroomId || '').trim()
if (!normalizedChatroomId.endsWith('@chatroom')) return new Map<string, string>()
const cacheKey = this.getGroupNicknameCacheKey(normalizedChatroomId)
const cached = this.groupNicknameCache.get(cacheKey)
if (cached && Date.now() - cached.updatedAt <= this.groupNicknameCacheTtlMs) {
return cached.nicknames
}
try {
const result = await wcdbService.getGroupNicknames(normalizedChatroomId)
const nicknames = result.success && result.nicknames
? this.buildTrustedGroupNicknameMap(Object.entries(result.nicknames))
: new Map<string, string>()
this.groupNicknameCache.set(cacheKey, { nicknames, updatedAt: Date.now() })
return nicknames
} catch (error) {
console.warn('[ChatService] 获取群成员群名片失败:', error)
return new Map<string, string>()
}
}
private resolveGroupNicknameByCandidates(groupNicknames: Map<string, string>, candidates: Array<unknown>): string {
let resolved = ''
for (const candidate of this.buildGroupNicknameIdCandidates(candidates)) {
const normalizedId = this.normalizeGroupNicknameIdentity(candidate)
if (!normalizedId) continue
const nickname = this.normalizeGroupNickname(groupNicknames.get(normalizedId) || '')
if (!nickname) continue
if (!resolved) {
resolved = nickname
continue
}
if (resolved !== nickname) return ''
}
return resolved
}
private buildSenderGroupNicknameCandidates(username: string): string[] {
const normalizedUsername = String(username || '').trim()
const candidates: unknown[] = [normalizedUsername, this.cleanAccountDirName(normalizedUsername)]
const myWxid = String(this.configService.getMyWxidCleaned() || '').trim()
if (myWxid) {
const myIdentityKeys = new Set(this.buildIdentityKeys(myWxid))
if (this.buildIdentityKeys(normalizedUsername).some((key) => myIdentityKeys.has(key))) {
candidates.push(myWxid, this.cleanAccountDirName(myWxid))
}
}
return this.buildGroupNicknameIdCandidates(candidates)
}
private resolveMessageIsSend(rawIsSend: number | null, senderUsername?: string | null): {
isSend: number | null
selfMatched: boolean
@@ -7596,45 +7807,56 @@ class ChatService {
/**
* 获取联系人头像和显示名称(用于群聊消息)
*/
async getContactAvatar(username: string): Promise<{ avatarUrl?: string; displayName?: string } | null> {
if (!username) return null
async getContactAvatar(username: string, chatroomId?: string): Promise<{ avatarUrl?: string; displayName?: string } | null> {
const normalizedUsername = String(username || '').trim()
const normalizedChatroomId = String(chatroomId || '').trim()
if (!normalizedUsername) return null
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) return null
const cached = this.avatarCache.get(username)
const groupNicknames = normalizedChatroomId.endsWith('@chatroom')
? await this.getGroupNicknamesForRoom(normalizedChatroomId)
: new Map<string, string>()
const groupDisplayName = this.resolveGroupNicknameByCandidates(
groupNicknames,
this.buildSenderGroupNicknameCandidates(normalizedUsername)
)
const cached = this.avatarCache.get(normalizedUsername)
// 检查缓存是否有效,且头像不是错误的 hex 格式
const cachedAvatarUrl = this.shouldPersistAvatarUrl(cached?.avatarUrl) ? cached?.avatarUrl : undefined
if (cached && cachedAvatarUrl && Date.now() - cached.updatedAt < this.avatarCacheTtlMs) {
return { avatarUrl: cachedAvatarUrl, displayName: cached.displayName }
return { avatarUrl: cachedAvatarUrl, displayName: groupDisplayName || cached.displayName }
}
const contact = await this.getContact(username)
const avatarResult = await wcdbService.getAvatarUrls([username])
let avatarUrl = avatarResult.success && avatarResult.map ? avatarResult.map[username] : undefined
const contact = await this.getContact(normalizedUsername)
const avatarResult = await wcdbService.getAvatarUrls([normalizedUsername])
let avatarUrl = avatarResult.success && avatarResult.map ? avatarResult.map[normalizedUsername] : undefined
if (!this.isValidAvatarUrl(avatarUrl)) {
avatarUrl = undefined
}
if (!avatarUrl) {
const headImageAvatars = await this.getAvatarsFromHeadImageDb([username])
const fallbackAvatarUrl = headImageAvatars[username]
const headImageAvatars = await this.getAvatarsFromHeadImageDb([normalizedUsername])
const fallbackAvatarUrl = headImageAvatars[normalizedUsername]
if (this.isValidAvatarUrl(fallbackAvatarUrl)) {
avatarUrl = fallbackAvatarUrl
}
}
const displayName = contact?.remark || contact?.nickName || contact?.alias || cached?.displayName || username
const effectiveAvatarUrl = this.shouldPersistAvatarUrl(avatarUrl)
? avatarUrl
: cachedAvatarUrl
const contactDisplayName = contact?.remark || contact?.nickName || contact?.alias || cached?.displayName || normalizedUsername
const displayName = groupDisplayName || contactDisplayName
const cacheEntry: ContactCacheEntry = {
avatarUrl: this.shouldPersistAvatarUrl(avatarUrl)
? avatarUrl
: this.shouldPersistAvatarUrl(cached?.avatarUrl)
? cached?.avatarUrl
: undefined,
displayName,
avatarUrl: effectiveAvatarUrl,
displayName: contactDisplayName,
updatedAt: Date.now()
}
this.avatarCache.set(username, cacheEntry)
this.contactCacheService.setEntries({ [username]: cacheEntry })
return { avatarUrl, displayName }
this.avatarCache.set(normalizedUsername, cacheEntry)
this.contactCacheService.setEntries({ [normalizedUsername]: cacheEntry })
return { avatarUrl: effectiveAvatarUrl, displayName }
} catch {
return null
}
+15 -3
View File
@@ -9,6 +9,7 @@ interface ContactExportOptions {
friends: boolean
groups: boolean
officials: boolean
blocked?: boolean
}
selectedUsernames?: string[]
}
@@ -38,6 +39,7 @@ class ContactExportService {
if (c.type === 'friend' && !options.contactTypes.friends) return false
if (c.type === 'group' && !options.contactTypes.groups) return false
if (c.type === 'official' && !options.contactTypes.officials) return false
if (c.type === 'blocked' && !options.contactTypes.blocked) return false
return true
})
@@ -99,7 +101,10 @@ class ContactExportService {
signature: c.detailDescription,
detailDescription: c.detailDescription,
region: c.region,
type: c.type
type: c.type,
officialAccountKind: c.officialAccountKind,
officialAccountType: c.officialAccountType,
typeLabel: this.getTypeLabel(c)
}))
}
fs.writeFileSync(outputPath, JSON.stringify(data, null, 2), 'utf-8')
@@ -120,7 +125,7 @@ class ContactExportService {
c.description || '',
c.detailDescription || '',
c.region || '',
this.getTypeLabel(c.type)
this.getTypeLabel(c)
])
const escapeCell = (value: unknown) => `"${String(value ?? '').replace(/"/g, '""')}"`
@@ -170,11 +175,18 @@ class ContactExportService {
fs.writeFileSync(outputPath, vcards.join('\r\n\r\n'), 'utf-8')
}
private getTypeLabel(type: string): string {
private getTypeLabel(contactOrType: any): string {
const type = typeof contactOrType === 'string' ? contactOrType : String(contactOrType?.type || '')
if (type === 'official' && typeof contactOrType !== 'string') {
if (contactOrType.officialAccountKind === 'service') return '服务号'
if (contactOrType.officialAccountKind === 'enterprise') return '企业号'
return '公众号'
}
switch (type) {
case 'friend': return '好友'
case 'group': return '群聊'
case 'official': return '公众号'
case 'blocked': return '黑名单'
default: return '其他'
}
}
+20 -7
View File
@@ -1393,8 +1393,15 @@ export class ExportContext {
if (normalizedSessionId.startsWith('gh_')) return '公众号_'
const rawLocalType = contact?.local_type ?? contact?.localType ?? contact?.WCDB_CT_local_type;
const localType = Number.parseInt(String(rawLocalType ?? ''), 10);
const rawFlag = contact?.flag ?? contact?.contact_flag ?? contact?.contactFlag ?? contact?.WCDB_CT_flag;
const flag = Number.parseInt(String(rawFlag ?? '0'), 10);
const quanPin = String(contact?.quan_pin ?? contact?.quanPin ?? contact?.WCDB_CT_quan_pin ?? '').trim();
if (Number.isFinite(localType) && localType === 0 && quanPin) {
const alias = String(contact?.alias ?? contact?.WCDB_CT_alias ?? '').trim();
const remark = String(contact?.remark ?? contact?.WCDB_CT_remark ?? '').trim();
if (Number.isFinite(localType) && (
(localType === 0 && quanPin) ||
(localType === 3 && Number.isFinite(flag) && flag !== 4 && (quanPin || alias || remark))
)) {
return '曾经的好友_'
}
@@ -2912,9 +2919,13 @@ export class ExportContext {
destFileName = `${md5}${ext || '.gif'}`
} else if (kind === 'file') {
const safeBaseName = path.basename(String(msg?.fileName || path.basename(sourcePath))).replace(/[\\/:*?"<>|]/g, '_') || path.basename(sourcePath)
destFileName = `${String(msg?.localId || Date.now())}_${safeBaseName}`
destFileName = safeBaseName
}
let destPath = path.join(targetDir, destFileName)
if (kind === 'file' && this.resolveExportConflictStrategy(options) === 'rename') {
destPath = await reserveUniqueOutputPath(destPath, new Set<string>())
destFileName = path.basename(destPath)
}
const destPath = path.join(targetDir, destFileName)
if (path.resolve(sourcePath) !== path.resolve(destPath)) {
if (kind === 'image' || kind === 'emoji' || kind === 'video') {
const copied = await this.copyMediaWithCacheAndDedup(kind, sourcePath, destPath, options.control, options)
@@ -3945,10 +3956,12 @@ export class ExportContext {
return null
}
const safeBaseName = path.basename(fileNameRaw).replace(/[\\/:*?"<>|]/g, '_') || 'file'
const messageId = String(msg?.localId || Date.now())
const destFileName = `${messageId}_${safeBaseName}`
const destPath = path.join(fileDir, destFileName)
let destFileName = path.basename(fileNameRaw).replace(/[\\/:*?"<>|]/g, '_') || 'file'
let destPath = path.join(fileDir, destFileName)
if (this.resolveExportConflictStrategy(options) === 'rename') {
destPath = await reserveUniqueOutputPath(destPath, new Set<string>())
destFileName = path.basename(destPath)
}
const existedBeforeCopy = await pathExists(destPath)
if (existedBeforeCopy && this.shouldReuseExistingExportFile(options)) {
this.noteMediaTelemetry({ doneFiles: 1, dedupReuseFiles: 1 })
+21 -8
View File
@@ -691,14 +691,16 @@ export class WcdbCore {
return `SELECT * FROM contact WHERE username IN (${inList})`
}
private deriveContactTypeCounts(rows: Array<Record<string, any>>): { private: number; group: number; official: number; former_friend: number } {
private deriveContactTypeCounts(rows: Array<Record<string, any>>): { private: number; group: number; official: number; former_friend: number; blocked: number } {
const counts = {
private: 0,
group: 0,
official: 0,
former_friend: 0
former_friend: 0,
blocked: 0
}
const excludeNames = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage'])
const builtinOfficialHelpers = new Set(['gh_f0a92aa7146c'])
for (const row of rows || []) {
const username = this.pickFirstStringField(row, ['username', 'user_name', 'userName'])
@@ -706,16 +708,26 @@ export class WcdbCore {
const localTypeRaw = row.local_type ?? row.localType ?? row.WCDB_CT_local_type ?? 0
const localType = Number.isFinite(Number(localTypeRaw)) ? Math.floor(Number(localTypeRaw)) : 0
const flagRaw = row.flag ?? row.contact_flag ?? row.contactFlag ?? row.WCDB_CT_flag ?? 0
const flag = Number.isFinite(Number(flagRaw)) ? Math.floor(Number(flagRaw)) : 0
const quanPin = this.pickFirstStringField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin'])
const alias = this.pickFirstStringField(row, ['alias', 'WCDB_CT_alias'])
const remark = this.pickFirstStringField(row, ['remark', 'WCDB_CT_remark'])
const isFormerFriendResidual = !username.startsWith('gh_') && (
(localType === 0 && quanPin) ||
(localType === 3 && flag !== 4 && (quanPin || alias || remark))
)
if (username.endsWith('@chatroom')) {
counts.group += 1
} else if (username.startsWith('gh_')) {
} else if (username.startsWith('gh_') && localType === 1 && !builtinOfficialHelpers.has(username)) {
counts.official += 1
} else if (localType === 1 && !excludeNames.has(username)) {
counts.private += 1
} else if (localType === 0 && quanPin) {
} else if (!username.startsWith('gh_') && (flag & 8) === 8) {
counts.blocked += 1
} else if (isFormerFriendResidual) {
counts.former_friend += 1
} else if (localType === 1 && (flag & 8) === 0 && !excludeNames.has(username)) {
counts.private += 1
}
}
@@ -3468,7 +3480,7 @@ export class WcdbCore {
}
}
async getContactTypeCounts(): Promise<{ success: boolean; counts?: { private: number; group: number; official: number; former_friend: number }; error?: string }> {
async getContactTypeCounts(): Promise<{ success: boolean; counts?: { private: number; group: number; official: number; former_friend: number; blocked?: number }; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
const runFallback = async (reason: string) => {
const contactsResult = await this.getContactsCompact()
@@ -3494,7 +3506,8 @@ export class WcdbCore {
private: Number(raw.private || 0),
group: Number(raw.group || 0),
official: Number(raw.official || 0),
former_friend: Number(raw.former_friend || 0)
former_friend: Number(raw.former_friend || 0),
blocked: Number(raw.blocked || 0)
}
}
} catch (e) {
+1 -1
View File
@@ -426,7 +426,7 @@ export class WcdbService {
return this.callWorker('getContactStatus', { usernames })
}
async getContactTypeCounts(): Promise<{ success: boolean; counts?: { private: number; group: number; official: number; former_friend: number }; error?: string }> {
async getContactTypeCounts(): Promise<{ success: boolean; counts?: { private: number; group: number; official: number; former_friend: number; blocked?: number }; error?: string }> {
return this.callWorker('getContactTypeCounts')
}