#1043 #1032;修复增量导出意外添加文件的问题;聊天页面支持右键显示资料卡;优化曾经的好友相关算法并新增黑名单;
This commit is contained in:
1 parent
801ed16d5e
commit
b87444dd64
30 files changed
+1615
-145
No files matched your search
+192
-34
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 '其他'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user