#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

+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
}