1 parent
45e653841d
commit
290efee495
12 files changed
+146
-30
No files matched your search
+2
-1
@@ -29,6 +29,7 @@ test/
|
||||
# Build output
|
||||
out
|
||||
release
|
||||
tmp/
|
||||
|
||||
# Database
|
||||
*.db
|
||||
@@ -79,4 +80,4 @@ weflow-web-offical
|
||||
/scripts/syncwcdb.py
|
||||
/scripts/syncWedecrypt.py
|
||||
/scripts/syncWelive.py
|
||||
/WeLive
|
||||
/WeLive
|
||||
@@ -166,6 +166,7 @@ export interface ContactInfo {
|
||||
nickname?: string
|
||||
alias?: string
|
||||
labels?: string[]
|
||||
description?: string
|
||||
detailDescription?: string
|
||||
region?: string
|
||||
avatarUrl?: string
|
||||
@@ -2180,6 +2181,7 @@ class ChatService {
|
||||
|
||||
const displayName = row.remark || row.nick_name || row.alias || username
|
||||
const labels = isLiteMode ? [] : this.parseContactLabels(row, contactLabelNameMap)
|
||||
const description = isLiteMode ? '' : this.getContactDescription(row)
|
||||
const detailDescription = isLiteMode ? '' : this.getContactSignature(row)
|
||||
const region = isLiteMode ? '' : this.getContactRegion(row)
|
||||
|
||||
@@ -2190,6 +2192,7 @@ class ChatService {
|
||||
nickname: row.nick_name || undefined,
|
||||
alias: row.alias || undefined,
|
||||
labels: labels.length > 0 ? labels : undefined,
|
||||
description: description || undefined,
|
||||
detailDescription: detailDescription || undefined,
|
||||
region: region || undefined,
|
||||
avatarUrl: undefined,
|
||||
@@ -3568,6 +3571,22 @@ class ChatService {
|
||||
return []
|
||||
}
|
||||
|
||||
private getContactDescription(row: Record<string, any>): string {
|
||||
const normalize = (raw: unknown): string => {
|
||||
const text = String(raw || '').replace(/\u0000/g, '').trim()
|
||||
if (!text) return ''
|
||||
const lower = text.toLowerCase()
|
||||
if (lower === '-' || lower === '--' || lower === '—' || lower === 'null' || lower === 'undefined' || lower === 'none') {
|
||||
return ''
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
return normalize(this.getRowField(row, [
|
||||
'description', 'desc', 'contact_description', 'contactDescription', 'detail_description', 'detailDescription'
|
||||
]))
|
||||
}
|
||||
|
||||
private getContactSignature(row: Record<string, any>): string {
|
||||
const normalize = (raw: unknown): string => {
|
||||
const text = String(raw || '').replace(/\u0000/g, '').trim()
|
||||
@@ -3579,23 +3598,26 @@ class ChatService {
|
||||
return text
|
||||
}
|
||||
|
||||
// contact.description/detail_description 是微信联系人“描述/备忘”类字段,不是个性签名。
|
||||
// 真正的个性签名优先来自 native 解析出的 signature,或 extra_buffer field 4。
|
||||
const value = this.getRowField(row, [
|
||||
'signature', 'sign', 'personal_signature', 'personalSignature', 'profile', 'introduction',
|
||||
'detail_description', 'detailDescription', 'description', 'desc', 'contact_description', 'contactDescription'
|
||||
'signature', 'sign', 'personal_signature', 'personalSignature'
|
||||
])
|
||||
const direct = normalize(value)
|
||||
if (direct) return direct
|
||||
|
||||
const signatures = this.extractExtraBufferTopLevelFieldStrings(row, 4)
|
||||
for (const signature of signatures) {
|
||||
const text = normalize(signature)
|
||||
if (!text) continue
|
||||
return text
|
||||
}
|
||||
|
||||
for (const [key, rawValue] of Object.entries(row)) {
|
||||
const normalizedKey = key.toLowerCase()
|
||||
const isCandidate =
|
||||
normalizedKey.includes('sign') ||
|
||||
normalizedKey.includes('signature') ||
|
||||
normalizedKey.includes('profile') ||
|
||||
normalizedKey.includes('intro') ||
|
||||
normalizedKey.includes('description') ||
|
||||
normalizedKey.includes('detail') ||
|
||||
normalizedKey.includes('desc')
|
||||
normalizedKey.includes('signature')
|
||||
if (!isCandidate) continue
|
||||
if (
|
||||
normalizedKey.includes('avatar') ||
|
||||
@@ -3608,14 +3630,6 @@ class ChatService {
|
||||
if (text) return text
|
||||
}
|
||||
|
||||
// contact.extra_buffer field 4: 个性签名兜底
|
||||
const signatures = this.extractExtraBufferTopLevelFieldStrings(row, 4)
|
||||
for (const signature of signatures) {
|
||||
const text = normalize(signature)
|
||||
if (!text) continue
|
||||
return text
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -10246,14 +10260,14 @@ class ChatService {
|
||||
(!message.senderUsername || message.isSend === null)
|
||||
|
||||
if (needsDetailHydration && sessionId) {
|
||||
const detail = await this.getMessageById(sessionId, message.localId)
|
||||
if (detail.success && detail.message) {
|
||||
const detail = await this.getSearchDetailHydration(sessionId, message)
|
||||
if (detail) {
|
||||
message = {
|
||||
...message,
|
||||
...detail.message,
|
||||
parsedContent: message.parsedContent || detail.message.parsedContent,
|
||||
rawContent: message.rawContent || detail.message.rawContent,
|
||||
content: message.content || detail.message.content
|
||||
...detail,
|
||||
parsedContent: message.parsedContent || detail.parsedContent,
|
||||
rawContent: message.rawContent || detail.rawContent,
|
||||
content: message.content || detail.content
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10273,6 +10287,50 @@ class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
private async getSearchDetailHydration(sessionId: string, searchHit: Message): Promise<Message | null> {
|
||||
const localId = Number(searchHit.localId || 0)
|
||||
if (!Number.isFinite(localId) || localId <= 0) return null
|
||||
|
||||
const detail = await this.getMessageById(sessionId, localId)
|
||||
if (detail.success && detail.message && this.isSameMessageIdentity(searchHit, detail.message)) {
|
||||
return detail.message
|
||||
}
|
||||
|
||||
const createTime = this.normalizeTimestampSeconds(Number(searchHit.createTime || 0))
|
||||
if (createTime <= 0) return null
|
||||
|
||||
try {
|
||||
const nearby = await this.getMessages(sessionId, 0, 80, createTime - 1, createTime + 1, false)
|
||||
if (!nearby.success || !Array.isArray(nearby.messages)) return null
|
||||
return nearby.messages.find((message) => this.isSameMessageIdentity(searchHit, message)) || null
|
||||
} catch (error) {
|
||||
console.warn('[ChatService] search detail hydration fallback failed:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private isSameMessageIdentity(expected: Message, candidate: Message): boolean {
|
||||
const expectedLocalId = Number(expected.localId || 0)
|
||||
const candidateLocalId = Number(candidate.localId || 0)
|
||||
if (expectedLocalId > 0 && candidateLocalId > 0 && expectedLocalId !== candidateLocalId) {
|
||||
return false
|
||||
}
|
||||
|
||||
const expectedCreateTime = this.normalizeTimestampSeconds(Number(expected.createTime || 0))
|
||||
const candidateCreateTime = this.normalizeTimestampSeconds(Number(candidate.createTime || 0))
|
||||
if (expectedCreateTime > 0 && candidateCreateTime > 0 && expectedCreateTime !== candidateCreateTime) {
|
||||
return false
|
||||
}
|
||||
|
||||
const expectedDbPath = String(expected._db_path || '').trim()
|
||||
const candidateDbPath = String(candidate._db_path || '').trim()
|
||||
if (expectedDbPath && candidateDbPath && expectedDbPath !== candidateDbPath) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private normalizeTimestampSeconds(value: number): number {
|
||||
const numeric = Number(value || 0)
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) return 0
|
||||
@@ -11909,10 +11967,13 @@ class ChatService {
|
||||
|
||||
private async parseMessage(row: any, options?: { source?: 'search' | 'detail'; sessionId?: string }): Promise<Message> {
|
||||
const sourceInfo = this.getMessageSourceInfo(row)
|
||||
const rawContent = this.decodeMessageContent(
|
||||
let rawContent = this.decodeMessageContent(
|
||||
row.message_content,
|
||||
row.compress_content
|
||||
)
|
||||
if (!rawContent && row.content !== undefined && row.content !== null) {
|
||||
rawContent = String(row.content)
|
||||
}
|
||||
// 这里复用 parseMessagesBatch 里面的解析逻辑,为了简单我这里先写个基础的
|
||||
// 实际项目中建议抽取 parseRawMessage(row) 供多处使用
|
||||
const localId = this.getRowInt(row, ['local_id'], 0)
|
||||
|
||||
@@ -95,7 +95,10 @@ class ContactExportService {
|
||||
nickname: c.nickname,
|
||||
alias: c.alias,
|
||||
labels: Array.isArray(c.labels) ? c.labels : [],
|
||||
description: c.description,
|
||||
signature: c.detailDescription,
|
||||
detailDescription: c.detailDescription,
|
||||
region: c.region,
|
||||
type: c.type
|
||||
}))
|
||||
}
|
||||
@@ -106,7 +109,7 @@ class ContactExportService {
|
||||
* 导出为CSV格式
|
||||
*/
|
||||
private async exportToCSV(contacts: any[], outputPath: string): Promise<void> {
|
||||
const headers = ['用户名', '显示名称', '备注', '昵称', '微信号', '标签', '详细描述', '类型']
|
||||
const headers = ['用户名', '显示名称', '备注', '昵称', '微信号', '标签', '描述', '个性签名', '地区', '类型']
|
||||
const rows = contacts.map(c => [
|
||||
c.username || '',
|
||||
c.displayName || '',
|
||||
@@ -114,13 +117,16 @@ class ContactExportService {
|
||||
c.nickname || '',
|
||||
c.alias || '',
|
||||
Array.isArray(c.labels) ? c.labels.join(' | ') : '',
|
||||
c.description || '',
|
||||
c.detailDescription || '',
|
||||
c.region || '',
|
||||
this.getTypeLabel(c.type)
|
||||
])
|
||||
const escapeCell = (value: unknown) => `"${String(value ?? '').replace(/"/g, '""')}"`
|
||||
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
|
||||
...rows.map(row => row.map(escapeCell).join(','))
|
||||
].join('\n')
|
||||
|
||||
fs.writeFileSync(outputPath, '\uFEFF' + csvContent, 'utf-8') // 添加BOM以支持Excel
|
||||
@@ -146,7 +152,9 @@ class ContactExportService {
|
||||
const noteParts = [
|
||||
c.remark ? String(c.remark) : '',
|
||||
Array.isArray(c.labels) && c.labels.length > 0 ? `标签: ${c.labels.join(', ')}` : '',
|
||||
c.detailDescription ? `详细描述: ${c.detailDescription}` : ''
|
||||
c.description ? `描述: ${c.description}` : '',
|
||||
c.detailDescription ? `个性签名: ${c.detailDescription}` : '',
|
||||
c.region ? `地区: ${c.region}` : ''
|
||||
].filter(Boolean)
|
||||
if (noteParts.length > 0) {
|
||||
lines.push(`NOTE:${noteParts.join('\\n')}`)
|
||||
|
||||
@@ -919,13 +919,17 @@ class HttpService {
|
||||
const detail = await wcdbService.getMessageById(talker, localId)
|
||||
if (detail.success && detail.message) {
|
||||
const hydrated = chatService.mapRowsToMessagesForApi([detail.message], talker)[0]
|
||||
if (hydrated?.senderUsername) {
|
||||
if (!hydrated || !this.isSameMessageIdentity(msg, hydrated)) {
|
||||
state.consecutiveMiss += 1
|
||||
continue
|
||||
}
|
||||
if (hydrated.senderUsername) {
|
||||
msg.senderUsername = hydrated.senderUsername
|
||||
}
|
||||
if ((msg.isSend === null || msg.isSend === undefined) && hydrated?.isSend !== undefined) {
|
||||
if ((msg.isSend === null || msg.isSend === undefined) && hydrated.isSend !== undefined) {
|
||||
msg.isSend = hydrated.isSend
|
||||
}
|
||||
if (!msg.rawContent && hydrated?.rawContent) {
|
||||
if (!msg.rawContent && hydrated.rawContent) {
|
||||
msg.rawContent = hydrated.rawContent
|
||||
}
|
||||
if (msg.senderUsername) {
|
||||
@@ -952,6 +956,38 @@ class HttpService {
|
||||
await Promise.all(Array.from({ length: workerCount }, () => runWorker()))
|
||||
}
|
||||
|
||||
private normalizeTimestampSeconds(value: unknown): number {
|
||||
let normalized = Number(value || 0)
|
||||
if (!Number.isFinite(normalized) || normalized <= 0) return 0
|
||||
normalized = Math.floor(normalized)
|
||||
while (normalized > 10000000000) {
|
||||
normalized = Math.floor(normalized / 1000)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
private isSameMessageIdentity(expected: Message, candidate: Message): boolean {
|
||||
const expectedLocalId = Number(expected.localId || 0)
|
||||
const candidateLocalId = Number(candidate.localId || 0)
|
||||
if (expectedLocalId > 0 && candidateLocalId > 0 && expectedLocalId !== candidateLocalId) {
|
||||
return false
|
||||
}
|
||||
|
||||
const expectedCreateTime = this.normalizeTimestampSeconds(expected.createTime)
|
||||
const candidateCreateTime = this.normalizeTimestampSeconds(candidate.createTime)
|
||||
if (expectedCreateTime > 0 && candidateCreateTime > 0 && expectedCreateTime !== candidateCreateTime) {
|
||||
return false
|
||||
}
|
||||
|
||||
const expectedDbPath = String(expected._db_path || '').trim()
|
||||
const candidateDbPath = String(candidate._db_path || '').trim()
|
||||
if (expectedDbPath && candidateDbPath && expectedDbPath !== candidateDbPath) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private parseBooleanParam(url: URL, keys: string[], defaultValue: boolean = false): boolean {
|
||||
for (const key of keys) {
|
||||
const raw = url.searchParams.get(key)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -399,6 +399,7 @@ function ContactsPage() {
|
||||
nickname: contact.nickname,
|
||||
alias: contact.alias,
|
||||
labels: contact.labels,
|
||||
description: contact.description,
|
||||
detailDescription: contact.detailDescription,
|
||||
region: contact.region,
|
||||
type: contact.type
|
||||
@@ -660,6 +661,9 @@ function ContactsPage() {
|
||||
selectedContact.labels && selectedContact.labels.length > 0
|
||||
? { key: 'labels', label: '标签', value: selectedContact.labels.join('、') }
|
||||
: null,
|
||||
selectedContact.description
|
||||
? { key: 'description', label: '描述', value: selectedContact.description }
|
||||
: null,
|
||||
selectedContact.detailDescription
|
||||
? { key: 'signature', label: '个性签名', value: selectedContact.detailDescription }
|
||||
: null,
|
||||
|
||||
@@ -128,6 +128,7 @@ export function useContactsLoader(): ContactsLoaderResult {
|
||||
nickname: c.nickname,
|
||||
alias: c.alias,
|
||||
labels: c.labels,
|
||||
description: c.description,
|
||||
detailDescription: c.detailDescription,
|
||||
region: c.region
|
||||
})))
|
||||
@@ -222,6 +223,7 @@ export function useContactsLoader(): ContactsLoaderResult {
|
||||
nickname: c.nickname,
|
||||
alias: c.alias,
|
||||
labels: c.labels,
|
||||
description: c.description,
|
||||
detailDescription: c.detailDescription,
|
||||
region: c.region
|
||||
})))
|
||||
|
||||
@@ -1002,6 +1002,7 @@ export interface ContactsListCacheContact {
|
||||
nickname?: string
|
||||
alias?: string
|
||||
labels?: string[]
|
||||
description?: string
|
||||
detailDescription?: string
|
||||
region?: string
|
||||
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||
@@ -1528,6 +1529,7 @@ export async function getContactsListCache(scopeKey: string): Promise<ContactsLi
|
||||
labels: Array.isArray(item.labels)
|
||||
? Array.from(new Set(item.labels.map((label) => String(label || '').trim()).filter(Boolean)))
|
||||
: undefined,
|
||||
description: typeof item.description === 'string' ? (item.description.trim() || undefined) : undefined,
|
||||
detailDescription: typeof item.detailDescription === 'string' ? (item.detailDescription.trim() || undefined) : undefined,
|
||||
region: typeof item.region === 'string' ? (item.region.trim() || undefined) : undefined,
|
||||
type: (type === 'friend' || type === 'group' || type === 'official' || type === 'former_friend' || type === 'other')
|
||||
@@ -1567,6 +1569,7 @@ export async function setContactsListCache(scopeKey: string, contacts: ContactsL
|
||||
labels: Array.isArray(contact?.labels)
|
||||
? Array.from(new Set(contact.labels.map((label) => String(label || '').trim()).filter(Boolean)))
|
||||
: undefined,
|
||||
description: contact?.description ? (String(contact.description).trim() || undefined) : undefined,
|
||||
detailDescription: contact?.detailDescription ? (String(contact.detailDescription).trim() || undefined) : undefined,
|
||||
region: contact?.region ? (String(contact.region).trim() || undefined) : undefined,
|
||||
type
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface ContactInfo {
|
||||
nickname?: string
|
||||
alias?: string
|
||||
labels?: string[]
|
||||
description?: string
|
||||
detailDescription?: string
|
||||
region?: string
|
||||
avatarUrl?: string
|
||||
|
||||
Reference in new issue
Block a user