mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
fix: 修复语音首播并完善消息解析与会话兼容性
(cherry picked from commit 214090192f5dfc91cdec0269bad434d3e22394d8)
This commit is contained in:
@@ -28,6 +28,7 @@
|
|||||||
"start": "electron-vite preview",
|
"start": "electron-vite preview",
|
||||||
"dev": "node scripts/ensure-env.cjs && node scripts/build-wechat-connector.cjs && electron-vite dev",
|
"dev": "node scripts/ensure-env.cjs && node scripts/build-wechat-connector.cjs && electron-vite dev",
|
||||||
"test:wechat-connector": "go -C services/wechat-connector test ./... && go -C services/wechat-connector vet ./...",
|
"test:wechat-connector": "go -C services/wechat-connector test ./... && go -C services/wechat-connector vet ./...",
|
||||||
|
"test:stability": "node --experimental-strip-types --test tests/stability-compat.test.mjs",
|
||||||
"build:wechat-connector": "node scripts/build-wechat-connector.cjs",
|
"build:wechat-connector": "node scripts/build-wechat-connector.cjs",
|
||||||
"build:wechat-connector:win": "node scripts/build-wechat-connector.cjs --platform win32 --arch x64,arm64",
|
"build:wechat-connector:win": "node scripts/build-wechat-connector.cjs --platform win32 --arch x64,arm64",
|
||||||
"build:wechat-connector:mac": "node scripts/build-wechat-connector.cjs --platform darwin --arch x64,arm64",
|
"build:wechat-connector:mac": "node scripts/build-wechat-connector.cjs --platform darwin --arch x64,arm64",
|
||||||
|
|||||||
+3
-6
@@ -125,10 +125,7 @@ const COLD_IMAGE_LOAD_GAP_MS = 100
|
|||||||
const MAX_CONCURRENT_COLD_IMAGE_LOADS = 2
|
const MAX_CONCURRENT_COLD_IMAGE_LOADS = 2
|
||||||
|
|
||||||
function pumpColdImageLoads(): void {
|
function pumpColdImageLoads(): void {
|
||||||
if (
|
if (activeColdImageLoads >= MAX_CONCURRENT_COLD_IMAGE_LOADS || coldImageLoadQueue.length === 0) {
|
||||||
activeColdImageLoads >= MAX_CONCURRENT_COLD_IMAGE_LOADS ||
|
|
||||||
coldImageLoadQueue.length === 0
|
|
||||||
) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -768,8 +765,8 @@ app.whenReady().then(async () => {
|
|||||||
return contacts
|
return contacts
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('db:getContactAvatars', (_, usernames: string[]) => {
|
ipcMain.handle('db:getContactAvatars', async (_, usernames: string[]) => {
|
||||||
const avatars = chat.getContactAvatars(usernames)
|
const avatars = await chat.getContactAvatars(usernames)
|
||||||
if (chat.isReady()) mergeBootstrapAvatars(chat.getCurrentAccountRoot(), avatars)
|
if (chat.isReady()) mergeBootstrapAvatars(chat.getCurrentAccountRoot(), avatars)
|
||||||
return avatars
|
return avatars
|
||||||
})
|
})
|
||||||
|
|||||||
+112
-5
@@ -16,6 +16,19 @@ type ShareContent = {
|
|||||||
appname?: string
|
appname?: string
|
||||||
typeVal?: string
|
typeVal?: string
|
||||||
}
|
}
|
||||||
|
type ForwardedMessageItem = {
|
||||||
|
messageType: number
|
||||||
|
sender?: string
|
||||||
|
sentAt?: string
|
||||||
|
text: string
|
||||||
|
nested?: ForwardedMessageItem[]
|
||||||
|
}
|
||||||
|
type ForwardBundleContent = {
|
||||||
|
type: 'forwardBundle'
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
items: ForwardedMessageItem[]
|
||||||
|
}
|
||||||
type MiniProgramContent = {
|
type MiniProgramContent = {
|
||||||
type: 'miniProgram'
|
type: 'miniProgram'
|
||||||
title: string
|
title: string
|
||||||
@@ -82,7 +95,7 @@ type SystemContent = {
|
|||||||
recallTime?: number
|
recallTime?: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
type UnknownContent = { type: 'unknown'; raw: string }
|
type UnknownContent = { type: 'unknown'; raw: string; messageType?: string | number }
|
||||||
|
|
||||||
export type ParsedContent =
|
export type ParsedContent =
|
||||||
| TextContent
|
| TextContent
|
||||||
@@ -90,6 +103,7 @@ export type ParsedContent =
|
|||||||
| LocationContent
|
| LocationContent
|
||||||
| CardContent
|
| CardContent
|
||||||
| ShareContent
|
| ShareContent
|
||||||
|
| ForwardBundleContent
|
||||||
| MiniProgramContent
|
| MiniProgramContent
|
||||||
| RedPacketContent
|
| RedPacketContent
|
||||||
| VoipContent
|
| VoipContent
|
||||||
@@ -108,6 +122,10 @@ export function parseMessageContent(content: string, messageType: number): Parse
|
|||||||
const normalized = content.trim()
|
const normalized = content.trim()
|
||||||
|
|
||||||
switch (messageType) {
|
switch (messageType) {
|
||||||
|
case 1:
|
||||||
|
return { type: 'text', content: normalized }
|
||||||
|
case 34:
|
||||||
|
return { type: 'voice' }
|
||||||
case 3:
|
case 3:
|
||||||
return parseImageMessage(normalized)
|
return parseImageMessage(normalized)
|
||||||
case 42:
|
case 42:
|
||||||
@@ -126,7 +144,7 @@ export function parseMessageContent(content: string, messageType: number): Parse
|
|||||||
case 10002:
|
case 10002:
|
||||||
return parseSystemMessage(normalized)
|
return parseSystemMessage(normalized)
|
||||||
default:
|
default:
|
||||||
return { type: 'text', content: normalized }
|
return { type: 'unknown', raw: normalized, messageType }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,6 +430,9 @@ function parseLocationMessage(content: string): ParsedContent {
|
|||||||
|
|
||||||
function parseShareMessage(content: string): ParsedContent {
|
function parseShareMessage(content: string): ParsedContent {
|
||||||
const appMsgType = extractAppMsgType(content)
|
const appMsgType = extractAppMsgType(content)
|
||||||
|
if (appMsgType === '19' || /<recorditem\b|<dataitem\b/i.test(content)) {
|
||||||
|
return parseForwardBundle(content)
|
||||||
|
}
|
||||||
if (appMsgType === '47' || /<(?:emoji|sticker|emoticon)\b/i.test(content)) {
|
if (appMsgType === '47' || /<(?:emoji|sticker|emoticon)\b/i.test(content)) {
|
||||||
const sticker = parseStickerMessage(content)
|
const sticker = parseStickerMessage(content)
|
||||||
if (sticker.type === 'sticker') return sticker
|
if (sticker.type === 'sticker') return sticker
|
||||||
@@ -469,6 +490,94 @@ function parseShareMessage(content: string): ParsedContent {
|
|||||||
return { type: 'share', title, des, url, appname, typeVal }
|
return { type: 'share', title, des, url, appname, typeVal }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseForwardBundle(content: string): ForwardBundleContent {
|
||||||
|
const normalized = decodeXmlEntities(stripChatroomPrefix(content))
|
||||||
|
const title = decodeXmlEntities(extractXmlValue(normalized, 'title')) || '聊天记录'
|
||||||
|
const description = decodeXmlEntities(extractXmlValue(normalized, 'des')) || undefined
|
||||||
|
const containers = Array.from(
|
||||||
|
normalized.matchAll(/<recorditem\b[^>]*>([\s\S]*?)<\/recorditem>/gi),
|
||||||
|
(match) => match[1] || ''
|
||||||
|
)
|
||||||
|
const sources = containers.length ? containers : [normalized]
|
||||||
|
const items = dedupeForwardedItems(sources.flatMap((source) => parseForwardedItems(source)))
|
||||||
|
return { type: 'forwardBundle', title, description, items }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseForwardedItems(container: string, depth = 0): ForwardedMessageItem[] {
|
||||||
|
if (!container || depth > 4) return []
|
||||||
|
const variants = new Set<string>([container, decodeXmlEntities(container)])
|
||||||
|
for (const match of container.matchAll(/<!\[CDATA\[([\s\S]*?)\]\]>/g)) {
|
||||||
|
if (match[1]) variants.add(decodeXmlEntities(match[1]))
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: ForwardedMessageItem[] = []
|
||||||
|
for (const variant of variants) {
|
||||||
|
for (const match of variant.matchAll(/<dataitem\b([^>]*)>([\s\S]*?)<\/dataitem>/gi)) {
|
||||||
|
const attributes = match[1] || ''
|
||||||
|
const body = match[2] || ''
|
||||||
|
const attrType = /datatype\s*=\s*["']?(\d+)/i.exec(attributes)?.[1]
|
||||||
|
const messageType = Number.parseInt(attrType || extractXmlValue(body, 'datatype') || '0', 10)
|
||||||
|
const sender = decodeXmlEntities(extractXmlValue(body, 'sourcename')) || undefined
|
||||||
|
const sentAt = extractXmlValue(body, 'sourcetime') || undefined
|
||||||
|
const title = decodeXmlEntities(extractXmlValue(body, 'datatitle'))
|
||||||
|
const description = decodeXmlEntities(
|
||||||
|
extractXmlValue(body, 'datadesc') || extractXmlValue(body, 'content')
|
||||||
|
)
|
||||||
|
const nestedXml = extractXmlBody(body, 'recordxml')
|
||||||
|
const nested =
|
||||||
|
messageType === 17 && nestedXml
|
||||||
|
? parseForwardedItems(decodeXmlEntities(nestedXml), depth + 1)
|
||||||
|
: undefined
|
||||||
|
const text = description || title || forwardedTypeLabel(messageType)
|
||||||
|
if (!sender && !text && !nested?.length) continue
|
||||||
|
items.push({
|
||||||
|
messageType: Number.isFinite(messageType) ? messageType : 0,
|
||||||
|
sender,
|
||||||
|
sentAt,
|
||||||
|
text: text || '[消息]',
|
||||||
|
nested: nested?.length ? nested : undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dedupeForwardedItems(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeForwardedItems(items: ForwardedMessageItem[]): ForwardedMessageItem[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
return items.filter((item) => {
|
||||||
|
const key = `${item.messageType}|${item.sender || ''}|${item.sentAt || ''}|${item.text}`
|
||||||
|
if (seen.has(key)) return false
|
||||||
|
seen.add(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function forwardedTypeLabel(messageType: number): string {
|
||||||
|
switch (messageType) {
|
||||||
|
case 3:
|
||||||
|
return '[图片]'
|
||||||
|
case 34:
|
||||||
|
return '[语音]'
|
||||||
|
case 43:
|
||||||
|
return '[视频]'
|
||||||
|
case 47:
|
||||||
|
return '[表情包]'
|
||||||
|
case 8:
|
||||||
|
case 49:
|
||||||
|
return '[文件或分享]'
|
||||||
|
case 17:
|
||||||
|
return '[聊天记录]'
|
||||||
|
default:
|
||||||
|
return '[消息]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractXmlBody(xml: string, tagName: string): string {
|
||||||
|
const match = new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\\/${tagName}>`, 'i').exec(xml)
|
||||||
|
if (!match?.[1]) return ''
|
||||||
|
return match[1].replace(/^<!\[CDATA\[([\s\S]*?)\]\]>$/, '$1').trim()
|
||||||
|
}
|
||||||
|
|
||||||
function parseQuoteMessage(content: string): {
|
function parseQuoteMessage(content: string): {
|
||||||
content?: string
|
content?: string
|
||||||
sender?: string
|
sender?: string
|
||||||
@@ -713,9 +822,7 @@ export function parseImageDatNameFromRow(row: Record<string, unknown>): string |
|
|||||||
return hexMatch?.[1]?.toLowerCase()
|
return hexMatch?.[1]?.toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseImageBufferDataUrlFromRow(
|
export function parseImageBufferDataUrlFromRow(row: Record<string, unknown>): string | undefined {
|
||||||
row: Record<string, unknown>
|
|
||||||
): string | undefined {
|
|
||||||
const raw = pickRowString(row, [
|
const raw = pickRowString(row, [
|
||||||
'ImgBuf',
|
'ImgBuf',
|
||||||
'imgBuf',
|
'imgBuf',
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export interface FormattedContact {
|
|||||||
avatar?: string
|
avatar?: string
|
||||||
wechatNickname?: string
|
wechatNickname?: string
|
||||||
remark?: string
|
remark?: string
|
||||||
|
isFolded?: boolean
|
||||||
|
isMuted?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FormattedMessage {
|
export interface FormattedMessage {
|
||||||
@@ -140,7 +142,9 @@ export function listContacts(filter?: string): FormattedContact[] {
|
|||||||
type: isGroup ? 'group' : 'user',
|
type: isGroup ? 'group' : 'user',
|
||||||
avatar: typeof user.avatar === 'string' ? user.avatar : undefined,
|
avatar: typeof user.avatar === 'string' ? user.avatar : undefined,
|
||||||
wechatNickname: user.wechatNickname,
|
wechatNickname: user.wechatNickname,
|
||||||
remark: user.remark
|
remark: user.remark,
|
||||||
|
isFolded: user.isFolded,
|
||||||
|
isMuted: user.isMuted
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,17 +178,20 @@ export function listContacts(filter?: string): FormattedContact[] {
|
|||||||
|
|
||||||
export async function listContactsAsync(filter?: string): Promise<FormattedContact[]> {
|
export async function listContactsAsync(filter?: string): Promise<FormattedContact[]> {
|
||||||
if (!dbRef) return []
|
if (!dbRef) return []
|
||||||
await dbRef.getWcdb4Client().getSessionsAsync({ hydrateDisplayNames: false })
|
await dbRef.getWcdb4Client().getSessionsAsync({
|
||||||
|
hydrateDisplayNames: false,
|
||||||
|
hydrateStatuses: true
|
||||||
|
})
|
||||||
return listContacts(filter)
|
return listContacts(filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getContactAvatars(usernames: string[]): Record<string, string> {
|
export async function getContactAvatars(usernames: string[]): Promise<Record<string, string>> {
|
||||||
if (!dbRef) return {}
|
if (!dbRef) return {}
|
||||||
const normalized = Array.from(
|
const normalized = Array.from(
|
||||||
new Set((usernames || []).map((username) => String(username || '').trim()).filter(Boolean))
|
new Set((usernames || []).map((username) => String(username || '').trim()).filter(Boolean))
|
||||||
)
|
)
|
||||||
if (normalized.length === 0) return {}
|
if (normalized.length === 0) return {}
|
||||||
return dbRef.getWcdb4Client().getAvatarUrls(normalized)
|
return dbRef.getWcdb4Client().getAvatarUrlsAsync(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
function listSourceMessages(
|
function listSourceMessages(
|
||||||
@@ -251,7 +258,13 @@ function listSourceMessages(
|
|||||||
const patContent =
|
const patContent =
|
||||||
system.type === 'system'
|
system.type === 'system'
|
||||||
? { ...system, pat: true }
|
? { ...system, pat: true }
|
||||||
: { type: 'system' as const, content: String(content || '').replace(/<[^>]+>/g, '').trim(), pat: true }
|
: {
|
||||||
|
type: 'system' as const,
|
||||||
|
content: String(content || '')
|
||||||
|
.replace(/<[^>]+>/g, '')
|
||||||
|
.trim(),
|
||||||
|
pat: true
|
||||||
|
}
|
||||||
contentData = patContent
|
contentData = patContent
|
||||||
content = patContent.content
|
content = patContent.content
|
||||||
displayType = '系统消息'
|
displayType = '系统消息'
|
||||||
@@ -265,11 +278,9 @@ function listSourceMessages(
|
|||||||
try {
|
try {
|
||||||
const isQuotePayload = /<refermsg\b/i.test(content)
|
const isQuotePayload = /<refermsg\b/i.test(content)
|
||||||
const hasStickerPayload =
|
const hasStickerPayload =
|
||||||
/<(?:emoji|sticker|emoticon)\b/i.test(content) ||
|
/<(?:emoji|sticker|emoticon)\b/i.test(content) || /<type>\s*47\s*<\/type>/i.test(content)
|
||||||
/<type>\s*47\s*<\/type>/i.test(content)
|
|
||||||
const rowSticker =
|
const rowSticker =
|
||||||
inferredMsgType === 47 ||
|
inferredMsgType === 47 || (inferredMsgType === 49 && !isQuotePayload && hasStickerPayload)
|
||||||
(inferredMsgType === 49 && !isQuotePayload && hasStickerPayload)
|
|
||||||
? parseStickerMessageFromRow(msg, content)
|
? parseStickerMessageFromRow(msg, content)
|
||||||
: undefined
|
: undefined
|
||||||
const parsedContent = parseMessageContent(content, inferredMsgType)
|
const parsedContent = parseMessageContent(content, inferredMsgType)
|
||||||
@@ -301,7 +312,7 @@ function listSourceMessages(
|
|||||||
if (parsed.type === 'system') {
|
if (parsed.type === 'system') {
|
||||||
content = parsed.content
|
content = parsed.content
|
||||||
contentData = parsed
|
contentData = parsed
|
||||||
} else if (parsed.type !== 'unknown') {
|
} else {
|
||||||
content = ''
|
content = ''
|
||||||
}
|
}
|
||||||
if (parsed.type === 'image') {
|
if (parsed.type === 'image') {
|
||||||
@@ -311,8 +322,7 @@ function listSourceMessages(
|
|||||||
contentData = {
|
contentData = {
|
||||||
...parsed,
|
...parsed,
|
||||||
thumbDatName: parsed.thumbDatName || parseImageDatNameFromRow(msg),
|
thumbDatName: parsed.thumbDatName || parseImageDatNameFromRow(msg),
|
||||||
thumbDataUrl:
|
thumbDataUrl: parsed.thumbDataUrl || parseImageBufferDataUrlFromRow(msg.raw || msg)
|
||||||
parsed.thumbDataUrl || parseImageBufferDataUrlFromRow(msg.raw || msg)
|
|
||||||
}
|
}
|
||||||
} else if (parsed.type !== 'system') {
|
} else if (parsed.type !== 'system') {
|
||||||
if (parsed.type === 'sticker' && !parsed.url && parsed.md5) {
|
if (parsed.type === 'sticker' && !parsed.url && parsed.md5) {
|
||||||
@@ -327,6 +337,11 @@ function listSourceMessages(
|
|||||||
if (parsed.type === 'sticker') displayType = '表情包'
|
if (parsed.type === 'sticker') displayType = '表情包'
|
||||||
if (parsed.type === 'miniProgram') displayType = '小程序'
|
if (parsed.type === 'miniProgram') displayType = '小程序'
|
||||||
if (parsed.type === 'redPacket') displayType = '微信红包'
|
if (parsed.type === 'redPacket') displayType = '微信红包'
|
||||||
|
if (parsed.type === 'forwardBundle') displayType = '合并转发'
|
||||||
|
if (parsed.type === 'unknown') {
|
||||||
|
displayType = '不支持的消息'
|
||||||
|
contentData = { ...parsed, messageType: msgType }
|
||||||
|
}
|
||||||
if (parsed.type === 'share') {
|
if (parsed.type === 'share') {
|
||||||
if (parsed.typeVal === '5') displayType = '公众号链接'
|
if (parsed.typeVal === '5') displayType = '公众号链接'
|
||||||
if (parsed.typeVal === '6') displayType = '文件'
|
if (parsed.typeVal === '6') displayType = '文件'
|
||||||
@@ -351,6 +366,12 @@ function listSourceMessages(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!contentData && !MSG_TYPE_DICT[msgType] && msgType !== 0) {
|
||||||
|
contentData = { type: 'unknown', raw: rawContent, messageType: msgType }
|
||||||
|
content = ''
|
||||||
|
displayType = '不支持的消息'
|
||||||
|
}
|
||||||
|
|
||||||
if (msgType === 34) content = '[语音消息]'
|
if (msgType === 34) content = '[语音消息]'
|
||||||
|
|
||||||
const recoveredFromRecallJournal = Boolean(msg['_wxe_recovered'] || msg.raw?.['_wxe_recovered'])
|
const recoveredFromRecallJournal = Boolean(msg['_wxe_recovered'] || msg.raw?.['_wxe_recovered'])
|
||||||
@@ -403,13 +424,7 @@ export async function listMessagesAsync(
|
|||||||
): Promise<FormattedMessage[]> {
|
): Promise<FormattedMessage[]> {
|
||||||
if (!dbRef) return []
|
if (!dbRef) return []
|
||||||
const rawMessages = await dbRef.getUserMessagesAsync(userMd5, startTime, endTime, options)
|
const rawMessages = await dbRef.getUserMessagesAsync(userMd5, startTime, endTime, options)
|
||||||
const sourceMessages = listSourceMessages(
|
const sourceMessages = listSourceMessages(userMd5, startTime, endTime, options, rawMessages)
|
||||||
userMd5,
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
options,
|
|
||||||
rawMessages
|
|
||||||
)
|
|
||||||
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
|
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
|
||||||
recordRecallArchiveMessages(userMd5, username, sourceMessages)
|
recordRecallArchiveMessages(userMd5, username, sourceMessages)
|
||||||
return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit)
|
return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit)
|
||||||
|
|||||||
@@ -5,8 +5,15 @@ import https from 'https'
|
|||||||
import os from 'os'
|
import os from 'os'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { Wcdb4Client } from './wcdb4-client'
|
import { Wcdb4Client } from './wcdb4-client'
|
||||||
|
import { classifyStickerHttpFailure, StickerFailureCode } from '../shared/sticker'
|
||||||
|
|
||||||
type StickerResult = { success: boolean; data?: string; error?: string }
|
type StickerResult = {
|
||||||
|
success: boolean
|
||||||
|
data?: string
|
||||||
|
error?: string
|
||||||
|
failureCode?: StickerFailureCode
|
||||||
|
httpStatus?: number
|
||||||
|
}
|
||||||
|
|
||||||
const downloadCache = new Map<string, Promise<StickerResult>>()
|
const downloadCache = new Map<string, Promise<StickerResult>>()
|
||||||
|
|
||||||
@@ -129,15 +136,24 @@ export class StickerService {
|
|||||||
const redirectUrl = response.headers.location
|
const redirectUrl = response.headers.location
|
||||||
if (redirectUrl && [301, 302, 303, 307, 308].includes(Number(response.statusCode || 0))) {
|
if (redirectUrl && [301, 302, 303, 307, 308].includes(Number(response.statusCode || 0))) {
|
||||||
const nextUrl = new URL(redirectUrl, url).toString()
|
const nextUrl = new URL(redirectUrl, url).toString()
|
||||||
|
response.resume()
|
||||||
this.downloadToDataUrl(nextUrl, cacheKey, redirectCount + 1).then(resolve)
|
this.downloadToDataUrl(nextUrl, cacheKey, redirectCount + 1).then(resolve)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.statusCode !== 200) {
|
if (response.statusCode !== 200) {
|
||||||
|
const statusCode = Number(response.statusCode || 0)
|
||||||
|
const failure = classifyStickerHttpFailure(statusCode, url)
|
||||||
|
response.resume()
|
||||||
console.warn(
|
console.warn(
|
||||||
`[StickerService] download failed: HTTP ${response.statusCode}; md5=${cacheKey}; url=${url}`
|
`[StickerService] download failed code=${failure.code} status=${statusCode} md5=${cacheKey} host=${this.getUrlHost(url)}`
|
||||||
)
|
)
|
||||||
resolve({ success: false, error: `表情包下载失败: HTTP ${response.statusCode}` })
|
resolve({
|
||||||
|
success: false,
|
||||||
|
error: failure.message,
|
||||||
|
failureCode: failure.code,
|
||||||
|
httpStatus: statusCode
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,6 +214,14 @@ export class StickerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getUrlHost(url: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname || 'unknown'
|
||||||
|
} catch {
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private toDataUrl(buffer: Buffer, ext: string): string {
|
private toDataUrl(buffer: Buffer, ext: string): string {
|
||||||
const mimeTypes: Record<string, string> = {
|
const mimeTypes: Record<string, string> = {
|
||||||
'.gif': 'image/gif',
|
'.gif': 'image/gif',
|
||||||
|
|||||||
+158
-19
@@ -12,6 +12,8 @@ export interface Wcdb4Session {
|
|||||||
avatar?: string
|
avatar?: string
|
||||||
wechatNickname?: string
|
wechatNickname?: string
|
||||||
remark?: string
|
remark?: string
|
||||||
|
isFolded?: boolean
|
||||||
|
isMuted?: boolean
|
||||||
raw: Record<string, unknown>
|
raw: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +36,7 @@ export interface Wcdb4MessageQueryOptions {
|
|||||||
|
|
||||||
export interface Wcdb4SessionQueryOptions {
|
export interface Wcdb4SessionQueryOptions {
|
||||||
hydrateDisplayNames?: boolean
|
hydrateDisplayNames?: boolean
|
||||||
|
hydrateStatuses?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type Wcdb4MessageStore = {
|
type Wcdb4MessageStore = {
|
||||||
@@ -177,9 +180,12 @@ export function bootstrapWcdbNativeAsync(
|
|||||||
) => number
|
) => number
|
||||||
const resourceRoots = Array.from(
|
const resourceRoots = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
[libDir, path.dirname(libDir), process.env.WCDB_RESOURCES_PATH || '', ...getResourceRoots()].filter(
|
[
|
||||||
Boolean
|
libDir,
|
||||||
)
|
path.dirname(libDir),
|
||||||
|
process.env.WCDB_RESOURCES_PATH || '',
|
||||||
|
...getResourceRoots()
|
||||||
|
].filter(Boolean)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
let initOk = false
|
let initOk = false
|
||||||
@@ -242,12 +248,15 @@ export class Wcdb4Client {
|
|||||||
private handle: number | null = null
|
private handle: number | null = null
|
||||||
private displayNameCache = new Map<string, string>()
|
private displayNameCache = new Map<string, string>()
|
||||||
private avatarCache = new Map<string, string>()
|
private avatarCache = new Map<string, string>()
|
||||||
|
private sessionStatusCache = new Map<string, { isFolded: boolean; isMuted: boolean }>()
|
||||||
private groupNicknameCache = new Map<string, Map<string, string>>()
|
private groupNicknameCache = new Map<string, Map<string, string>>()
|
||||||
private cachedSessions: Wcdb4Session[] | null = null
|
private cachedSessions: Wcdb4Session[] | null = null
|
||||||
private cachedChatTables: { name: string; db_number: string }[] | null = null
|
private cachedChatTables: { name: string; db_number: string }[] | null = null
|
||||||
private sessionsInFlight: Promise<Wcdb4Session[]> | null = null
|
private sessionsInFlight: Promise<Wcdb4Session[]> | null = null
|
||||||
private sessionDisplayNamesInFlight: Promise<void> | null = null
|
private sessionDisplayNamesInFlight: Promise<void> | null = null
|
||||||
private sessionDisplayNamesHydrated = false
|
private sessionDisplayNamesHydrated = false
|
||||||
|
private sessionStatusesInFlight: Promise<void> | null = null
|
||||||
|
private sessionStatusesUpdatedAt = 0
|
||||||
private sessionCacheGeneration = 0
|
private sessionCacheGeneration = 0
|
||||||
|
|
||||||
private wcdbShutdown: (() => number) | null = null
|
private wcdbShutdown: (() => number) | null = null
|
||||||
@@ -276,6 +285,12 @@ export class Wcdb4Client {
|
|||||||
private wcdbGetAvatarUrls:
|
private wcdbGetAvatarUrls:
|
||||||
| ((handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number)
|
| ((handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number)
|
||||||
| null = null
|
| null = null
|
||||||
|
private wcdbGetContactStatus:
|
||||||
|
| ((handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number)
|
||||||
|
| null = null
|
||||||
|
private wcdbGetHeadImageBuffers:
|
||||||
|
| ((handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number)
|
||||||
|
| null = null
|
||||||
private wcdbExecQuery:
|
private wcdbExecQuery:
|
||||||
| ((handle: number, kind: string, dbPath: string, sql: string, outJson: WcdbVoidOut) => number)
|
| ((handle: number, kind: string, dbPath: string, sql: string, outJson: WcdbVoidOut) => number)
|
||||||
| null = null
|
| null = null
|
||||||
@@ -565,8 +580,11 @@ export class Wcdb4Client {
|
|||||||
this.handle = null
|
this.handle = null
|
||||||
this.cachedSessions = null
|
this.cachedSessions = null
|
||||||
this.sessionDisplayNamesHydrated = false
|
this.sessionDisplayNamesHydrated = false
|
||||||
|
this.sessionStatusesInFlight = null
|
||||||
|
this.sessionStatusesUpdatedAt = 0
|
||||||
this.displayNameCache.clear()
|
this.displayNameCache.clear()
|
||||||
this.avatarCache.clear()
|
this.avatarCache.clear()
|
||||||
|
this.sessionStatusCache.clear()
|
||||||
this.groupNicknameCache.clear()
|
this.groupNicknameCache.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -720,16 +738,11 @@ export class Wcdb4Client {
|
|||||||
.map((row) => this.normalizeSession(row))
|
.map((row) => this.normalizeSession(row))
|
||||||
.filter((session) => session.username)
|
.filter((session) => session.username)
|
||||||
|
|
||||||
this.hydrateDisplayNames(
|
|
||||||
sessions
|
|
||||||
.filter((session) => this.shouldHydrateSessionDisplayName(session))
|
|
||||||
.map((session) => session.username)
|
|
||||||
)
|
|
||||||
this.cachedSessions = sessions.map((session) => ({
|
this.cachedSessions = sessions.map((session) => ({
|
||||||
...session,
|
...session,
|
||||||
nickname: this.displayNameCache.get(session.username) || session.nickname || session.username
|
nickname: this.displayNameCache.get(session.username) || session.nickname || session.username
|
||||||
}))
|
}))
|
||||||
this.sessionDisplayNamesHydrated = true
|
this.sessionDisplayNamesHydrated = false
|
||||||
|
|
||||||
return this.cachedSessions
|
return this.cachedSessions
|
||||||
}
|
}
|
||||||
@@ -738,11 +751,13 @@ export class Wcdb4Client {
|
|||||||
const hydrateDisplayNames = options.hydrateDisplayNames !== false
|
const hydrateDisplayNames = options.hydrateDisplayNames !== false
|
||||||
if (this.cachedSessions) {
|
if (this.cachedSessions) {
|
||||||
if (hydrateDisplayNames) await this.ensureSessionDisplayNamesAsync()
|
if (hydrateDisplayNames) await this.ensureSessionDisplayNamesAsync()
|
||||||
|
if (options.hydrateStatuses) await this.refreshSessionStatusesAsync()
|
||||||
return this.cachedSessions
|
return this.cachedSessions
|
||||||
}
|
}
|
||||||
if (this.sessionsInFlight) {
|
if (this.sessionsInFlight) {
|
||||||
await this.sessionsInFlight
|
await this.sessionsInFlight
|
||||||
if (hydrateDisplayNames) await this.ensureSessionDisplayNamesAsync()
|
if (hydrateDisplayNames) await this.ensureSessionDisplayNamesAsync()
|
||||||
|
if (options.hydrateStatuses) await this.refreshSessionStatusesAsync()
|
||||||
return this.cachedSessions || []
|
return this.cachedSessions || []
|
||||||
}
|
}
|
||||||
if (!this.wcdbGetSessions) return []
|
if (!this.wcdbGetSessions) return []
|
||||||
@@ -765,9 +780,59 @@ export class Wcdb4Client {
|
|||||||
if (this.sessionsInFlight === request) this.sessionsInFlight = null
|
if (this.sessionsInFlight === request) this.sessionsInFlight = null
|
||||||
}
|
}
|
||||||
if (hydrateDisplayNames) await this.ensureSessionDisplayNamesAsync()
|
if (hydrateDisplayNames) await this.ensureSessionDisplayNamesAsync()
|
||||||
|
if (options.hydrateStatuses) await this.refreshSessionStatusesAsync()
|
||||||
return this.cachedSessions || []
|
return this.cachedSessions || []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async refreshSessionStatusesAsync(): Promise<void> {
|
||||||
|
if (Date.now() - this.sessionStatusesUpdatedAt < 5 * 60 * 1000) return
|
||||||
|
if (this.sessionStatusesInFlight) {
|
||||||
|
await this.sessionStatusesInFlight
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const sessions = this.cachedSessions
|
||||||
|
if (!sessions?.length || !this.wcdbGetContactStatus) return
|
||||||
|
const groupUsernames = sessions
|
||||||
|
.map((session) => session.username)
|
||||||
|
.filter((username) => username.endsWith('@chatroom'))
|
||||||
|
if (!groupUsernames.length) {
|
||||||
|
this.sessionStatusesUpdatedAt = Date.now()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const request = (async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const map = await this.callJsonAsync<
|
||||||
|
Record<string, { isFolded?: boolean; isMuted?: boolean }>
|
||||||
|
>(
|
||||||
|
this.wcdbGetContactStatus as unknown as KoffiAsyncFunction,
|
||||||
|
JSON.stringify(groupUsernames)
|
||||||
|
)
|
||||||
|
for (const username of groupUsernames) {
|
||||||
|
const status = map?.[username]
|
||||||
|
this.sessionStatusCache.set(username, {
|
||||||
|
isFolded: Boolean(status?.isFolded),
|
||||||
|
isMuted: Boolean(status?.isMuted)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (this.cachedSessions) {
|
||||||
|
this.cachedSessions = this.cachedSessions.map((session) => {
|
||||||
|
const status = this.sessionStatusCache.get(session.username)
|
||||||
|
return status ? { ...session, ...status } : session
|
||||||
|
})
|
||||||
|
}
|
||||||
|
this.sessionStatusesUpdatedAt = Date.now()
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[WCDB4] session status lookup failed:', error)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
this.sessionStatusesInFlight = request
|
||||||
|
try {
|
||||||
|
await request
|
||||||
|
} finally {
|
||||||
|
if (this.sessionStatusesInFlight === request) this.sessionStatusesInFlight = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
invalidateSessionCache(): void {
|
invalidateSessionCache(): void {
|
||||||
this.sessionCacheGeneration += 1
|
this.sessionCacheGeneration += 1
|
||||||
this.cachedSessions = null
|
this.cachedSessions = null
|
||||||
@@ -1122,6 +1187,52 @@ export class Wcdb4Client {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAvatarUrlsAsync(usernames: string[]): Promise<Record<string, string>> {
|
||||||
|
const normalized = this.uniq(usernames)
|
||||||
|
await this.hydrateAvatarUrlsAsync(normalized)
|
||||||
|
const localCandidates = normalized.filter((username) => {
|
||||||
|
const avatar = this.avatarCache.get(username)
|
||||||
|
return !avatar || !avatar.startsWith('data:')
|
||||||
|
})
|
||||||
|
if (localCandidates.length && this.wcdbGetHeadImageBuffers) {
|
||||||
|
try {
|
||||||
|
const buffers = await this.callJsonAsync<Record<string, string>>(
|
||||||
|
this.wcdbGetHeadImageBuffers as unknown as KoffiAsyncFunction,
|
||||||
|
JSON.stringify(localCandidates)
|
||||||
|
)
|
||||||
|
for (const [username, hex] of Object.entries(buffers || {})) {
|
||||||
|
const avatar = this.avatarHexToDataUrl(hex)
|
||||||
|
if (avatar) this.avatarCache.set(username, avatar)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[WCDB4] local avatar fallback failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Record<string, string> = {}
|
||||||
|
for (const username of normalized) {
|
||||||
|
const avatar = this.avatarCache.get(username)
|
||||||
|
if (avatar) result[username] = avatar
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private avatarHexToDataUrl(value: string): string | undefined {
|
||||||
|
const hex = String(value || '').trim()
|
||||||
|
if (!hex || hex.length % 2 !== 0 || !/^[a-f0-9]+$/i.test(hex)) return undefined
|
||||||
|
const buffer = Buffer.from(hex, 'hex')
|
||||||
|
let mime = 'image/jpeg'
|
||||||
|
if (buffer.length >= 8 && buffer.subarray(1, 4).toString('ascii') === 'PNG') mime = 'image/png'
|
||||||
|
if (
|
||||||
|
buffer.length >= 12 &&
|
||||||
|
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||||
|
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||||
|
) {
|
||||||
|
mime = 'image/webp'
|
||||||
|
}
|
||||||
|
return `data:${mime};base64,${buffer.toString('base64')}`
|
||||||
|
}
|
||||||
|
|
||||||
getMyGroupNickname(chatroomId: string): string | undefined {
|
getMyGroupNickname(chatroomId: string): string | undefined {
|
||||||
const groupNicknames = this.getGroupNicknames(chatroomId)
|
const groupNicknames = this.getGroupNicknames(chatroomId)
|
||||||
for (const candidate of this.getMyUsernameCandidates()) {
|
for (const candidate of this.getMyUsernameCandidates()) {
|
||||||
@@ -1488,9 +1599,10 @@ export class Wcdb4Client {
|
|||||||
const nicknames = new Map<string, string>()
|
const nicknames = new Map<string, string>()
|
||||||
if (!this.wcdbGetGroupNicknames || !chatroomId) return nicknames
|
if (!this.wcdbGetGroupNicknames || !chatroomId) return nicknames
|
||||||
|
|
||||||
const rows = await this.callJsonAsync<
|
const rows = await this.callJsonAsync<Record<string, string> | Record<string, unknown>[]>(
|
||||||
Record<string, string> | Record<string, unknown>[]
|
this.wcdbGetGroupNicknames as unknown as KoffiAsyncFunction,
|
||||||
>(this.wcdbGetGroupNicknames as unknown as KoffiAsyncFunction, chatroomId)
|
chatroomId
|
||||||
|
)
|
||||||
this.readStringMap(rows, [
|
this.readStringMap(rows, [
|
||||||
'nickname',
|
'nickname',
|
||||||
'nickName',
|
'nickName',
|
||||||
@@ -1756,6 +1868,22 @@ export class Wcdb4Client {
|
|||||||
this.wcdbGetAvatarUrls = null
|
this.wcdbGetAvatarUrls = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.wcdbGetContactStatus = lib.func(
|
||||||
|
'int32 wcdb_get_contact_status(int64 handle, const char* usernamesJson, _Out_ void** outJson)'
|
||||||
|
) as (handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number
|
||||||
|
} catch {
|
||||||
|
this.wcdbGetContactStatus = null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.wcdbGetHeadImageBuffers = lib.func(
|
||||||
|
'int32 wcdb_get_head_image_buffers(int64 handle, const char* usernamesJson, _Out_ void** outJson)'
|
||||||
|
) as (handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number
|
||||||
|
} catch {
|
||||||
|
this.wcdbGetHeadImageBuffers = null
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.wcdbExecQuery = lib.func(
|
this.wcdbExecQuery = lib.func(
|
||||||
'int32 wcdb_exec_query(int64 handle, const char* kind, const char* path, const char* sql, _Out_ void** outJson)'
|
'int32 wcdb_exec_query(int64 handle, const char* kind, const char* path, const char* sql, _Out_ void** outJson)'
|
||||||
@@ -2079,7 +2207,16 @@ export class Wcdb4Client {
|
|||||||
'contactRemark',
|
'contactRemark',
|
||||||
'contact_remark'
|
'contact_remark'
|
||||||
])
|
])
|
||||||
return { username, nickname, wechatNickname, remark, raw: row }
|
const status = this.sessionStatusCache.get(username)
|
||||||
|
return {
|
||||||
|
username,
|
||||||
|
nickname,
|
||||||
|
wechatNickname,
|
||||||
|
remark,
|
||||||
|
isFolded: status?.isFolded,
|
||||||
|
isMuted: status?.isMuted,
|
||||||
|
raw: row
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeMessage(row: Record<string, unknown>): Wcdb4Message {
|
private normalizeMessage(row: Record<string, unknown>): Wcdb4Message {
|
||||||
@@ -2208,9 +2345,10 @@ export class Wcdb4Client {
|
|||||||
const missing = this.uniq(usernames).filter((username) => !this.displayNameCache.has(username))
|
const missing = this.uniq(usernames).filter((username) => !this.displayNameCache.has(username))
|
||||||
if (missing.length === 0) return
|
if (missing.length === 0) return
|
||||||
try {
|
try {
|
||||||
const rows = await this.callJsonAsync<
|
const rows = await this.callJsonAsync<Record<string, string> | Record<string, unknown>[]>(
|
||||||
Record<string, string> | Record<string, unknown>[]
|
this.wcdbGetDisplayNames as unknown as KoffiAsyncFunction,
|
||||||
>(this.wcdbGetDisplayNames as unknown as KoffiAsyncFunction, JSON.stringify(missing))
|
JSON.stringify(missing)
|
||||||
|
)
|
||||||
this.readStringMap(rows, [
|
this.readStringMap(rows, [
|
||||||
'nickname',
|
'nickname',
|
||||||
'displayName',
|
'displayName',
|
||||||
@@ -2290,9 +2428,10 @@ export class Wcdb4Client {
|
|||||||
const missing = this.uniq(usernames).filter((username) => !this.avatarCache.has(username))
|
const missing = this.uniq(usernames).filter((username) => !this.avatarCache.has(username))
|
||||||
if (missing.length === 0) return
|
if (missing.length === 0) return
|
||||||
try {
|
try {
|
||||||
const rows = await this.callJsonAsync<
|
const rows = await this.callJsonAsync<Record<string, string> | Record<string, unknown>[]>(
|
||||||
Record<string, string> | Record<string, unknown>[]
|
this.wcdbGetAvatarUrls as unknown as KoffiAsyncFunction,
|
||||||
>(this.wcdbGetAvatarUrls as unknown as KoffiAsyncFunction, JSON.stringify(missing))
|
JSON.stringify(missing)
|
||||||
|
)
|
||||||
this.readStringMap(rows, [
|
this.readStringMap(rows, [
|
||||||
'avatarUrl',
|
'avatarUrl',
|
||||||
'avatar_url',
|
'avatar_url',
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export interface UserContact {
|
|||||||
avatar?: string
|
avatar?: string
|
||||||
wechatNickname?: string
|
wechatNickname?: string
|
||||||
remark?: string
|
remark?: string
|
||||||
|
isFolded?: boolean
|
||||||
|
isMuted?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WechatMessage {
|
export interface WechatMessage {
|
||||||
@@ -80,7 +82,9 @@ export class WechatDb {
|
|||||||
nickname: session.nickname || session.username,
|
nickname: session.nickname || session.username,
|
||||||
avatar: session.avatar,
|
avatar: session.avatar,
|
||||||
wechatNickname: session.wechatNickname,
|
wechatNickname: session.wechatNickname,
|
||||||
remark: session.remark
|
remark: session.remark,
|
||||||
|
isFolded: session.isFolded,
|
||||||
|
isMuted: session.isMuted
|
||||||
}))
|
}))
|
||||||
.filter((contact) => {
|
.filter((contact) => {
|
||||||
if (!keyword) return true
|
if (!keyword) return true
|
||||||
@@ -176,12 +180,7 @@ export class WechatDb {
|
|||||||
this.ensureChatTableMapping()
|
this.ensureChatTableMapping()
|
||||||
const username = this.chatMd5ToUsername.get(userMd5)
|
const username = this.chatMd5ToUsername.get(userMd5)
|
||||||
if (!username) return []
|
if (!username) return []
|
||||||
const messages = await this.wcdb4Client.getMessagesAsync(
|
const messages = await this.wcdb4Client.getMessagesAsync(username, startTime, endTime, options)
|
||||||
username,
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
options
|
|
||||||
)
|
|
||||||
return messages.map((message) => ({ ...message, ...message.raw }))
|
return messages.map((message) => ({ ...message, ...message.raw }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+7
-1
@@ -221,7 +221,13 @@ declare global {
|
|||||||
getSticker: (
|
getSticker: (
|
||||||
cdnUrl?: string,
|
cdnUrl?: string,
|
||||||
md5?: string
|
md5?: string
|
||||||
) => Promise<{ success: boolean; data?: string; error?: string }>
|
) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
data?: string
|
||||||
|
error?: string
|
||||||
|
failureCode?: import('../shared/sticker').StickerFailureCode
|
||||||
|
httpStatus?: number
|
||||||
|
}>
|
||||||
startExport: (request: ExportRequest) => Promise<ExportResult>
|
startExport: (request: ExportRequest) => Promise<ExportResult>
|
||||||
cancelExport: (jobId: string) => Promise<{ success: boolean }>
|
cancelExport: (jobId: string) => Promise<{ success: boolean }>
|
||||||
revealExport: (path: string) => Promise<{ success: boolean; error?: string }>
|
revealExport: (path: string) => Promise<{ success: boolean; error?: string }>
|
||||||
|
|||||||
@@ -601,8 +601,11 @@ function App(): React.ReactElement {
|
|||||||
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
|
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
|
||||||
setIsDatabaseConnected(true)
|
setIsDatabaseConnected(true)
|
||||||
setDbKeyStatus('已连接数据库')
|
setDbKeyStatus('已连接数据库')
|
||||||
// Cached contacts/self info are enough for startup. Native refresh is
|
// The cached list paints first. Refresh lightweight session flags and
|
||||||
// intentionally user-triggered so it cannot freeze the first session.
|
// missing avatars after the database is connected.
|
||||||
|
void loadContacts({ waitForAvatars: false }).catch((error) => {
|
||||||
|
console.warn('[Startup] background contact refresh failed:', error)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.warn('[Startup] background database init failed:', error)
|
console.warn('[Startup] background database init failed:', error)
|
||||||
|
|||||||
@@ -24,13 +24,11 @@ export function RichMessageBubble({
|
|||||||
return <CardBubble data={contentData} />
|
return <CardBubble data={contentData} />
|
||||||
case 'share':
|
case 'share':
|
||||||
return <ShareBubble data={contentData} />
|
return <ShareBubble data={contentData} />
|
||||||
|
case 'forwardBundle':
|
||||||
|
return <ForwardBundleBubble data={contentData} />
|
||||||
case 'miniProgram':
|
case 'miniProgram':
|
||||||
return (
|
return (
|
||||||
<MiniProgramBubble
|
<MiniProgramBubble data={contentData} sessionId={sessionId} onImageClick={onImageClick} />
|
||||||
data={contentData}
|
|
||||||
sessionId={sessionId}
|
|
||||||
onImageClick={onImageClick}
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
case 'redPacket':
|
case 'redPacket':
|
||||||
return <RedPacketBubble data={contentData} />
|
return <RedPacketBubble data={contentData} />
|
||||||
@@ -44,8 +42,11 @@ export function RichMessageBubble({
|
|||||||
return <SystemBubble data={contentData} />
|
return <SystemBubble data={contentData} />
|
||||||
case 'unknown':
|
case 'unknown':
|
||||||
return (
|
return (
|
||||||
<div className="message-text">
|
<div className="unsupported-message">
|
||||||
{renderWechatEmojiText((contentData as { raw?: string }).raw || '[未知消息]')}
|
<strong>暂不支持此消息</strong>
|
||||||
|
<span>
|
||||||
|
消息类型 {(contentData as { messageType?: string | number }).messageType || '未知'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
default:
|
default:
|
||||||
@@ -53,6 +54,56 @@ export function RichMessageBubble({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ForwardBundleBubble({
|
||||||
|
data
|
||||||
|
}: {
|
||||||
|
data: Extract<ParsedContent, { type: 'forwardBundle' }>
|
||||||
|
}): JSX.Element {
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
const visibleItems = expanded ? data.items : data.items.slice(0, 3)
|
||||||
|
const hiddenCount = Math.max(0, data.items.length - visibleItems.length)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="forward-bundle-message">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="forward-bundle-header"
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
>
|
||||||
|
<span>{data.title || '聊天记录'}</span>
|
||||||
|
<small>
|
||||||
|
{data.items.length ? `${data.items.length} 条消息` : data.description || '聊天记录'}
|
||||||
|
</small>
|
||||||
|
</button>
|
||||||
|
<div className="forward-bundle-list">
|
||||||
|
{visibleItems.length ? (
|
||||||
|
visibleItems.map((item, index) => (
|
||||||
|
<div
|
||||||
|
className="forward-bundle-item"
|
||||||
|
key={`${item.sender || ''}-${item.sentAt || ''}-${index}`}
|
||||||
|
>
|
||||||
|
{item.sender && <b>{item.sender}</b>}
|
||||||
|
<span>{renderWechatEmojiText(item.text, 24)}</span>
|
||||||
|
{item.nested?.length ? <small>包含 {item.nested.length} 条聊天记录</small> : null}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="forward-bundle-empty">暂未解析到可展示的记录</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(hiddenCount > 0 || expanded) && data.items.length > 3 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="forward-bundle-toggle"
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
>
|
||||||
|
{expanded ? '收起' : `展开其余 ${hiddenCount} 条`}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function LocationBubble({
|
function LocationBubble({
|
||||||
data
|
data
|
||||||
}: {
|
}: {
|
||||||
@@ -161,12 +212,7 @@ function MiniProgramBubble({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : data.iconUrl ? (
|
) : data.iconUrl ? (
|
||||||
<img
|
<img className="mini-program-icon" src={data.iconUrl} alt="" referrerPolicy="no-referrer" />
|
||||||
className="mini-program-icon"
|
|
||||||
src={data.iconUrl}
|
|
||||||
alt=""
|
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
/>
|
|
||||||
) : null}
|
) : null}
|
||||||
<div className="mini-program-footer">
|
<div className="mini-program-footer">
|
||||||
<span aria-hidden>⌁</span>
|
<span aria-hidden>⌁</span>
|
||||||
@@ -242,6 +288,7 @@ function StickerBubble({
|
|||||||
)
|
)
|
||||||
const [loading, setLoading] = useState(Boolean(sourceUrl || md5) && !displayUrl)
|
const [loading, setLoading] = useState(Boolean(sourceUrl || md5) && !displayUrl)
|
||||||
const [error, setError] = useState(false)
|
const [error, setError] = useState(false)
|
||||||
|
const [errorText, setErrorText] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!cacheKey || displayUrl || error) return
|
if (!cacheKey || displayUrl || error) return
|
||||||
@@ -255,12 +302,17 @@ function StickerBubble({
|
|||||||
stickerDataUrlCache.set(cacheKey, result.data)
|
stickerDataUrlCache.set(cacheKey, result.data)
|
||||||
setDisplayUrl(result.data)
|
setDisplayUrl(result.data)
|
||||||
setError(false)
|
setError(false)
|
||||||
|
setErrorText('')
|
||||||
} else {
|
} else {
|
||||||
setError(true)
|
setError(true)
|
||||||
|
setErrorText(result.error || '表情包未缓存')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setError(true)
|
if (!cancelled) {
|
||||||
|
setError(true)
|
||||||
|
setErrorText('表情包加载失败')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setLoading(false)
|
if (!cancelled) setLoading(false)
|
||||||
@@ -289,7 +341,7 @@ function StickerBubble({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sticker-message">
|
<div className="sticker-message">
|
||||||
<div className="sticker-placeholder">{error ? '表情包未缓存' : '表情包'}</div>
|
<div className="sticker-placeholder">{error ? errorText || '表情包未缓存' : '表情包'}</div>
|
||||||
{md5 && <div className="sticker-md5">MD5: {md5}</div>}
|
{md5 && <div className="sticker-md5">MD5: {md5}</div>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,15 +16,16 @@ export function VoicePlayer({
|
|||||||
sessionId,
|
sessionId,
|
||||||
localId,
|
localId,
|
||||||
createTime,
|
createTime,
|
||||||
svrId
|
svrId,
|
||||||
|
duration
|
||||||
}: VoicePlayerProps): JSX.Element {
|
}: VoicePlayerProps): JSX.Element {
|
||||||
const [isPlaying, setIsPlaying] = useState(false)
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
const [audioUrl, setAudioUrl] = useState<string | null>(null)
|
const [audioUrl, setAudioUrl] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [audioDuration, setAudioDuration] = useState<number | undefined>(undefined)
|
const [audioDuration, setAudioDuration] = useState<number | undefined>(duration)
|
||||||
const [shouldAutoPlay, setShouldAutoPlay] = useState(false)
|
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||||
|
const objectUrlRef = useRef<string | null>(null)
|
||||||
|
|
||||||
const stopCurrentAndPlay = useCallback((audio: HTMLAudioElement) => {
|
const stopCurrentAndPlay = useCallback((audio: HTMLAudioElement) => {
|
||||||
if (globalCurrentAudio && globalCurrentAudio !== audio) {
|
if (globalCurrentAudio && globalCurrentAudio !== audio) {
|
||||||
@@ -35,126 +36,113 @@ export function VoicePlayer({
|
|||||||
globalCurrentAudio = audio
|
globalCurrentAudio = audio
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handlePlayPause = useCallback(async () => {
|
const playAudio = useCallback(
|
||||||
// 如果还没有音频数据,先获取
|
async (audio: HTMLAudioElement): Promise<void> => {
|
||||||
if (!audioUrl && !loading) {
|
|
||||||
setLoading(true)
|
|
||||||
setShouldAutoPlay(true)
|
|
||||||
console.log('[VoicePlayer] fetching voice data:', { sessionId, localId, createTime })
|
|
||||||
try {
|
|
||||||
const result = await window.api.getVoiceData(sessionId, localId, createTime, svrId)
|
|
||||||
console.log('[VoicePlayer] got result:', result)
|
|
||||||
if (result.success && result.data) {
|
|
||||||
console.log('[VoicePlayer] setting audioUrl, data length:', result.data.length)
|
|
||||||
// 使用 Blob URL 替代 data URL,绕过 CSP 限制
|
|
||||||
const byteCharacters = atob(result.data)
|
|
||||||
const byteNumbers = new Array(byteCharacters.length)
|
|
||||||
for (let i = 0; i < byteCharacters.length; i++) {
|
|
||||||
byteNumbers[i] = byteCharacters.charCodeAt(i)
|
|
||||||
}
|
|
||||||
const byteArray = new Uint8Array(byteNumbers)
|
|
||||||
const blob = new Blob([byteArray], { type: 'audio/wav' })
|
|
||||||
const blobUrl = URL.createObjectURL(blob)
|
|
||||||
console.log('[VoicePlayer] created blob URL:', blobUrl)
|
|
||||||
setAudioUrl(blobUrl)
|
|
||||||
} else {
|
|
||||||
console.log('[VoicePlayer] getVoiceData failed:', result.error)
|
|
||||||
setError(result.error || '获取语音数据失败')
|
|
||||||
setShouldAutoPlay(false)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.log('[VoicePlayer] exception:', e)
|
|
||||||
setError('加载语音失败')
|
|
||||||
setShouldAutoPlay(false)
|
|
||||||
}
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!audioRef.current) {
|
|
||||||
console.log('[VoicePlayer] no audioRef')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const audio = audioRef.current
|
|
||||||
|
|
||||||
if (isPlaying) {
|
|
||||||
audio.pause()
|
|
||||||
setIsPlaying(false)
|
|
||||||
globalStopCallback = null
|
|
||||||
} else {
|
|
||||||
stopCurrentAndPlay(audio)
|
stopCurrentAndPlay(audio)
|
||||||
audio
|
try {
|
||||||
.play()
|
await audio.play()
|
||||||
.then(() => {
|
setError(null)
|
||||||
console.log('[VoicePlayer] play() succeeded')
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.log('[VoicePlayer] play() failed:', e)
|
|
||||||
})
|
|
||||||
setIsPlaying(true)
|
|
||||||
globalStopCallback = () => {
|
|
||||||
setIsPlaying(false)
|
|
||||||
audio.currentTime = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [audioUrl, loading, isPlaying, sessionId, localId, createTime, svrId, stopCurrentAndPlay])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!audioUrl) return
|
|
||||||
|
|
||||||
let audio = audioRef.current
|
|
||||||
if (!audio) {
|
|
||||||
audio = new Audio(audioUrl)
|
|
||||||
audioRef.current = audio
|
|
||||||
}
|
|
||||||
|
|
||||||
const audioEl = audio!
|
|
||||||
|
|
||||||
audioEl.addEventListener('loadedmetadata', () => {
|
|
||||||
setAudioDuration(audioEl.duration)
|
|
||||||
console.log('[VoicePlayer] loadedmetadata, duration:', audioEl.duration)
|
|
||||||
})
|
|
||||||
|
|
||||||
audioEl.addEventListener('ended', () => {
|
|
||||||
setIsPlaying(false)
|
|
||||||
globalStopCallback = null
|
|
||||||
})
|
|
||||||
|
|
||||||
audioEl.addEventListener('timeupdate', () => {
|
|
||||||
if (audioEl.duration && isFinite(audioEl.duration)) {
|
|
||||||
setAudioDuration(audioEl.duration)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
audioEl.addEventListener('canplay', () => {
|
|
||||||
console.log('[VoicePlayer] canplay event, shouldAutoPlay:', shouldAutoPlay)
|
|
||||||
if (shouldAutoPlay && audioRef.current) {
|
|
||||||
setShouldAutoPlay(false)
|
|
||||||
stopCurrentAndPlay(audioRef.current)
|
|
||||||
audioRef.current.play()
|
|
||||||
setIsPlaying(true)
|
setIsPlaying(true)
|
||||||
globalStopCallback = () => {
|
globalStopCallback = () => {
|
||||||
setIsPlaying(false)
|
setIsPlaying(false)
|
||||||
if (audioRef.current) {
|
audio.currentTime = 0
|
||||||
audioRef.current.currentTime = 0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} catch (playError) {
|
||||||
|
if (globalCurrentAudio === audio) {
|
||||||
|
globalCurrentAudio = null
|
||||||
|
globalStopCallback = null
|
||||||
|
}
|
||||||
|
setIsPlaying(false)
|
||||||
|
setError('语音播放失败,请重试')
|
||||||
|
console.warn('[VoicePlayer] play failed:', playError)
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
|
[stopCurrentAndPlay]
|
||||||
|
)
|
||||||
|
|
||||||
return () => {
|
const createAudio = useCallback((blobUrl: string): HTMLAudioElement => {
|
||||||
if (audioRef.current) {
|
const audio = new Audio()
|
||||||
audioRef.current.pause()
|
audio.preload = 'auto'
|
||||||
audioRef.current.src = ''
|
audio.src = blobUrl
|
||||||
audioRef.current = null
|
audio.onloadedmetadata = () => {
|
||||||
}
|
if (Number.isFinite(audio.duration)) setAudioDuration(audio.duration)
|
||||||
if (globalCurrentAudio === audioRef.current) {
|
}
|
||||||
|
audio.ontimeupdate = () => {
|
||||||
|
if (Number.isFinite(audio.duration)) setAudioDuration(audio.duration)
|
||||||
|
}
|
||||||
|
audio.onended = () => {
|
||||||
|
setIsPlaying(false)
|
||||||
|
if (globalCurrentAudio === audio) {
|
||||||
globalCurrentAudio = null
|
globalCurrentAudio = null
|
||||||
globalStopCallback = null
|
globalStopCallback = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [audioUrl, shouldAutoPlay, stopCurrentAndPlay])
|
audioRef.current = audio
|
||||||
|
objectUrlRef.current = blobUrl
|
||||||
|
return audio
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handlePlayPause = useCallback(async () => {
|
||||||
|
if (loading) return
|
||||||
|
|
||||||
|
let audio = audioRef.current
|
||||||
|
if (!audio) {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const result = await window.api.getVoiceData(sessionId, localId, createTime, svrId)
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const byteCharacters = atob(result.data)
|
||||||
|
const byteArray = new Uint8Array(byteCharacters.length)
|
||||||
|
for (let i = 0; i < byteCharacters.length; i++) {
|
||||||
|
byteArray[i] = byteCharacters.charCodeAt(i)
|
||||||
|
}
|
||||||
|
const blob = new Blob([byteArray], { type: 'audio/wav' })
|
||||||
|
const blobUrl = URL.createObjectURL(blob)
|
||||||
|
setAudioUrl(blobUrl)
|
||||||
|
audio = createAudio(blobUrl)
|
||||||
|
await playAudio(audio)
|
||||||
|
} else {
|
||||||
|
setError(result.error || '获取语音数据失败')
|
||||||
|
}
|
||||||
|
} catch (loadError) {
|
||||||
|
console.warn('[VoicePlayer] load failed:', loadError)
|
||||||
|
setError('加载语音失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlaying) {
|
||||||
|
audio.pause()
|
||||||
|
setIsPlaying(false)
|
||||||
|
if (globalCurrentAudio === audio) {
|
||||||
|
globalCurrentAudio = null
|
||||||
|
globalStopCallback = null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await playAudio(audio)
|
||||||
|
}
|
||||||
|
}, [createAudio, createTime, isPlaying, loading, localId, playAudio, sessionId, svrId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
const audio = audioRef.current
|
||||||
|
if (audio) {
|
||||||
|
audio.pause()
|
||||||
|
audio.removeAttribute('src')
|
||||||
|
audio.load()
|
||||||
|
}
|
||||||
|
if (globalCurrentAudio === audio) {
|
||||||
|
globalCurrentAudio = null
|
||||||
|
globalStopCallback = null
|
||||||
|
}
|
||||||
|
if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current)
|
||||||
|
objectUrlRef.current = null
|
||||||
|
audioRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const formatDuration = (seconds: number | undefined): string => {
|
const formatDuration = (seconds: number | undefined): string => {
|
||||||
if (!seconds || !isFinite(seconds)) return '0:00'
|
if (!seconds || !isFinite(seconds)) return '0:00'
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ const RICH_MESSAGE_TYPES = [
|
|||||||
'引用消息',
|
'引用消息',
|
||||||
'通话',
|
'通话',
|
||||||
'表情包',
|
'表情包',
|
||||||
'系统消息'
|
'系统消息',
|
||||||
|
'合并转发',
|
||||||
|
'不支持的消息'
|
||||||
]
|
]
|
||||||
|
|
||||||
export function MessageBubble({
|
export function MessageBubble({
|
||||||
@@ -46,7 +48,8 @@ export function MessageBubble({
|
|||||||
const isVoice = message.type === '语音'
|
const isVoice = message.type === '语音'
|
||||||
const isImage = message.type === '图片'
|
const isImage = message.type === '图片'
|
||||||
const isVideo = message.type === '视频'
|
const isVideo = message.type === '视频'
|
||||||
const isRichMedia = RICH_MESSAGE_TYPES.includes(message.type)
|
const isRichMedia =
|
||||||
|
RICH_MESSAGE_TYPES.includes(message.type) || message.contentData?.type === 'unknown'
|
||||||
const hoverTime = formatMessageTime(message)
|
const hoverTime = formatMessageTime(message)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -63,6 +66,8 @@ export function MessageBubble({
|
|||||||
sessionId={message.sessionId}
|
sessionId={message.sessionId}
|
||||||
localId={message.localId || 0}
|
localId={message.localId || 0}
|
||||||
createTime={message.createTime || 0}
|
createTime={message.createTime || 0}
|
||||||
|
svrId={message.serverId}
|
||||||
|
duration={message.voiceDuration}
|
||||||
/>
|
/>
|
||||||
) : isImage && message.contentData && message.contentData.type === 'image' ? (
|
) : isImage && message.contentData && message.contentData.type === 'image' ? (
|
||||||
<ImageBubble
|
<ImageBubble
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react'
|
import React, { useState } from 'react'
|
||||||
import { Contact } from '../../../../shared/types'
|
import { Contact } from '../../../../shared/types'
|
||||||
|
|
||||||
interface ConversationItemProps {
|
interface ConversationItemProps {
|
||||||
@@ -16,6 +16,26 @@ export function ConversationItem({
|
|||||||
const wxid = contact.m_nsUsrName
|
const wxid = contact.m_nsUsrName
|
||||||
const displayName = nickname || wxid || '未命名会话'
|
const displayName = nickname || wxid || '未命名会话'
|
||||||
const initial = (displayName || wxid || '?').charAt(0)
|
const initial = (displayName || wxid || '?').charAt(0)
|
||||||
|
const [repairedAvatar, setRepairedAvatar] = useState<{ username: string; source: string }>()
|
||||||
|
const [failedAvatar, setFailedAvatar] = useState<{ username: string; source: string }>()
|
||||||
|
const repairedSource = repairedAvatar?.username === wxid ? repairedAvatar.source : undefined
|
||||||
|
const avatar = repairedSource || contact.avatar
|
||||||
|
const avatarFailed = failedAvatar?.username === wxid && failedAvatar.source === avatar
|
||||||
|
|
||||||
|
const handleAvatarError = (): void => {
|
||||||
|
if (!avatar || avatarFailed) return
|
||||||
|
setFailedAvatar({ username: wxid, source: avatar })
|
||||||
|
if (contact.type !== 'group' || avatar.startsWith('data:')) return
|
||||||
|
void window.api
|
||||||
|
.getContactAvatars([wxid])
|
||||||
|
.then((avatars) => {
|
||||||
|
const fallback = avatars[wxid]
|
||||||
|
if (!fallback || fallback === avatar) return
|
||||||
|
setRepairedAvatar({ username: wxid, source: fallback })
|
||||||
|
setFailedAvatar(undefined)
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -26,13 +46,14 @@ export function ConversationItem({
|
|||||||
>
|
>
|
||||||
<span className="conversation-item-active-mark" aria-hidden />
|
<span className="conversation-item-active-mark" aria-hidden />
|
||||||
<span className="conversation-item-avatar">
|
<span className="conversation-item-avatar">
|
||||||
{contact.avatar ? (
|
{avatar && !avatarFailed ? (
|
||||||
<img
|
<img
|
||||||
src={contact.avatar}
|
src={avatar}
|
||||||
alt={displayName}
|
alt={displayName}
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
|
onError={handleAvatarError}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
initial
|
initial
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export interface ConversationSidebarProps {
|
|||||||
onOpenSettings: () => void
|
onOpenSettings: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type SectionName = 'groups' | 'contacts'
|
type SectionName = 'groups' | 'folded' | 'contacts'
|
||||||
type ConversationRow =
|
type ConversationRow =
|
||||||
| { kind: 'header'; id: string; title: string; count: number; section: SectionName }
|
| { kind: 'header'; id: string; title: string; count: number; section: SectionName }
|
||||||
| { kind: 'contact'; id: string; contact: Contact }
|
| { kind: 'contact'; id: string; contact: Contact }
|
||||||
@@ -44,24 +44,67 @@ export function ConversationSidebar({
|
|||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
const [expandedSections, setExpandedSections] = useState<Record<SectionName, boolean>>({
|
const [expandedSections, setExpandedSections] = useState<Record<SectionName, boolean>>({
|
||||||
groups: true,
|
groups: true,
|
||||||
|
folded: false,
|
||||||
contacts: false
|
contacts: false
|
||||||
})
|
})
|
||||||
const listRef = useRef<HTMLDivElement>(null)
|
const listRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const groups = contacts.filter((contact) => contact.type === 'group')
|
const groups = contacts.filter((contact) => contact.type === 'group' && !contact.isFolded)
|
||||||
|
const foldedGroups = contacts.filter((contact) => contact.type === 'group' && contact.isFolded)
|
||||||
const users = contacts.filter((contact) => contact.type === 'user')
|
const users = contacts.filter((contact) => contact.type === 'user')
|
||||||
const rows = useMemo<ConversationRow[]>(
|
const rows = useMemo<ConversationRow[]>(
|
||||||
() => [
|
() => [
|
||||||
{ kind: 'header', id: 'groups-header', title: '群聊', count: groups.length, section: 'groups' },
|
{
|
||||||
|
kind: 'header',
|
||||||
|
id: 'groups-header',
|
||||||
|
title: '群聊',
|
||||||
|
count: groups.length,
|
||||||
|
section: 'groups'
|
||||||
|
},
|
||||||
...(expandedSections.groups
|
...(expandedSections.groups
|
||||||
? groups.map((contact) => ({ kind: 'contact' as const, id: `group-${contact.md5}`, contact }))
|
? groups.map((contact) => ({
|
||||||
|
kind: 'contact' as const,
|
||||||
|
id: `group-${contact.md5}`,
|
||||||
|
contact
|
||||||
|
}))
|
||||||
: []),
|
: []),
|
||||||
{ kind: 'header', id: 'contacts-header', title: '联系人', count: users.length, section: 'contacts' },
|
...(foldedGroups.length
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
kind: 'header' as const,
|
||||||
|
id: 'folded-header',
|
||||||
|
title: '折叠群聊',
|
||||||
|
count: foldedGroups.length,
|
||||||
|
section: 'folded' as const
|
||||||
|
},
|
||||||
|
...(expandedSections.folded
|
||||||
|
? foldedGroups.map((contact) => ({
|
||||||
|
kind: 'contact' as const,
|
||||||
|
id: `folded-${contact.md5}`,
|
||||||
|
contact
|
||||||
|
}))
|
||||||
|
: [])
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
kind: 'header',
|
||||||
|
id: 'contacts-header',
|
||||||
|
title: '联系人',
|
||||||
|
count: users.length,
|
||||||
|
section: 'contacts'
|
||||||
|
},
|
||||||
...(expandedSections.contacts
|
...(expandedSections.contacts
|
||||||
? users.map((contact) => ({ kind: 'contact' as const, id: `user-${contact.md5}`, contact }))
|
? users.map((contact) => ({ kind: 'contact' as const, id: `user-${contact.md5}`, contact }))
|
||||||
: [])
|
: [])
|
||||||
],
|
],
|
||||||
[expandedSections.contacts, expandedSections.groups, groups, users]
|
[
|
||||||
|
expandedSections.contacts,
|
||||||
|
expandedSections.folded,
|
||||||
|
expandedSections.groups,
|
||||||
|
foldedGroups,
|
||||||
|
groups,
|
||||||
|
users
|
||||||
|
]
|
||||||
)
|
)
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: rows.length,
|
count: rows.length,
|
||||||
@@ -84,7 +127,10 @@ export function ConversationSidebar({
|
|||||||
onSearchChange={handleSearchChange}
|
onSearchChange={handleSearchChange}
|
||||||
/>
|
/>
|
||||||
<div ref={listRef} className="conversation-list" aria-label="会话列表">
|
<div ref={listRef} className="conversation-list" aria-label="会话列表">
|
||||||
<div className="conversation-virtual-content" style={{ height: `${virtualizer.getTotalSize()}px` }}>
|
<div
|
||||||
|
className="conversation-virtual-content"
|
||||||
|
style={{ height: `${virtualizer.getTotalSize()}px` }}
|
||||||
|
>
|
||||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||||
const row = rows[virtualItem.index]
|
const row = rows[virtualItem.index]
|
||||||
if (!row) return null
|
if (!row) return null
|
||||||
@@ -95,9 +141,15 @@ export function ConversationSidebar({
|
|||||||
key={virtualItem.key}
|
key={virtualItem.key}
|
||||||
type="button"
|
type="button"
|
||||||
className="conversation-section-header conversation-virtual-row"
|
className="conversation-section-header conversation-virtual-row"
|
||||||
style={{ transform: `translateY(${virtualItem.start}px)`, height: `${virtualItem.size}px` }}
|
style={{
|
||||||
|
transform: `translateY(${virtualItem.start}px)`,
|
||||||
|
height: `${virtualItem.size}px`
|
||||||
|
}}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setExpandedSections((current) => ({ ...current, [row.section]: !current[row.section] }))
|
setExpandedSections((current) => ({
|
||||||
|
...current,
|
||||||
|
[row.section]: !current[row.section]
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className="conversation-section-chevron" aria-hidden="true">
|
<span className="conversation-section-chevron" aria-hidden="true">
|
||||||
@@ -105,7 +157,9 @@ export function ConversationSidebar({
|
|||||||
<path d={expanded ? 'M4 6l4 4 4-4' : 'M6 4l4 4-4 4'} />
|
<path d={expanded ? 'M4 6l4 4 4-4' : 'M6 4l4 4-4 4'} />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<span className="conversation-section-title">{row.title} ({row.count})</span>
|
<span className="conversation-section-title">
|
||||||
|
{row.title} ({row.count})
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -113,7 +167,10 @@ export function ConversationSidebar({
|
|||||||
<div
|
<div
|
||||||
key={virtualItem.key}
|
key={virtualItem.key}
|
||||||
className="conversation-virtual-row"
|
className="conversation-virtual-row"
|
||||||
style={{ transform: `translateY(${virtualItem.start}px)`, height: `${virtualItem.size}px` }}
|
style={{
|
||||||
|
transform: `translateY(${virtualItem.start}px)`,
|
||||||
|
height: `${virtualItem.size}px`
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ConversationItem
|
<ConversationItem
|
||||||
contact={row.contact}
|
contact={row.contact}
|
||||||
|
|||||||
@@ -329,3 +329,94 @@
|
|||||||
.voip-status {
|
.voip-status {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.forward-bundle-message {
|
||||||
|
width: min(320px, 56vw);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forward-bundle-header,
|
||||||
|
.forward-bundle-toggle {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forward-bundle-header {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 0 0 9px;
|
||||||
|
border-bottom: 1px solid var(--wxex-border);
|
||||||
|
|
||||||
|
span {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.forward-bundle-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 9px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forward-bundle-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: 3px 6px;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
|
||||||
|
b {
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
grid-column: 2;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.forward-bundle-empty,
|
||||||
|
.unsupported-message span {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forward-bundle-toggle {
|
||||||
|
padding: 8px 0 0;
|
||||||
|
border-top: 1px solid var(--wxex-border);
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsupported-message {
|
||||||
|
display: grid;
|
||||||
|
min-width: 150px;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
export type StickerFailureCode =
|
||||||
|
| 'link_expired'
|
||||||
|
| 'authentication_required'
|
||||||
|
| 'access_denied'
|
||||||
|
| 'resource_removed'
|
||||||
|
| 'rate_limited'
|
||||||
|
| 'http_error'
|
||||||
|
|
||||||
|
export interface StickerHttpFailure {
|
||||||
|
code: StickerFailureCode
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyStickerHttpFailure(
|
||||||
|
statusCode: number,
|
||||||
|
url: string,
|
||||||
|
now = Date.now()
|
||||||
|
): StickerHttpFailure {
|
||||||
|
if (statusCode === 401) {
|
||||||
|
return { code: 'authentication_required', message: '表情链接需要微信授权' }
|
||||||
|
}
|
||||||
|
if (statusCode === 403) {
|
||||||
|
const expiresAt = readExpiryTimestamp(url)
|
||||||
|
if (expiresAt !== undefined && expiresAt <= now) {
|
||||||
|
return { code: 'link_expired', message: '表情链接已过期' }
|
||||||
|
}
|
||||||
|
return { code: 'access_denied', message: '表情链接已失效或需要微信授权' }
|
||||||
|
}
|
||||||
|
if (statusCode === 404 || statusCode === 410) {
|
||||||
|
return { code: 'resource_removed', message: '表情资源已删除或失效' }
|
||||||
|
}
|
||||||
|
if (statusCode === 429) {
|
||||||
|
return { code: 'rate_limited', message: '表情下载请求过于频繁' }
|
||||||
|
}
|
||||||
|
return { code: 'http_error', message: `表情包下载失败: HTTP ${statusCode}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
function readExpiryTimestamp(value: string): number | undefined {
|
||||||
|
try {
|
||||||
|
const url = new URL(value)
|
||||||
|
for (const key of ['expire', 'expires', 'expiry', 'deadline']) {
|
||||||
|
const raw = url.searchParams.get(key)
|
||||||
|
if (!raw) continue
|
||||||
|
const numeric = Number(raw)
|
||||||
|
if (Number.isFinite(numeric) && numeric > 0) {
|
||||||
|
return numeric > 10_000_000_000 ? numeric : numeric * 1000
|
||||||
|
}
|
||||||
|
const parsed = Date.parse(raw)
|
||||||
|
if (Number.isFinite(parsed)) return parsed
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid URLs have no trustworthy expiry metadata.
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
+17
-1
@@ -6,6 +6,8 @@ export interface Contact {
|
|||||||
avatar?: string
|
avatar?: string
|
||||||
wechatNickname?: string
|
wechatNickname?: string
|
||||||
remark?: string
|
remark?: string
|
||||||
|
isFolded?: boolean
|
||||||
|
isMuted?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
@@ -53,6 +55,19 @@ type ShareContent = {
|
|||||||
appname?: string
|
appname?: string
|
||||||
typeVal?: string
|
typeVal?: string
|
||||||
}
|
}
|
||||||
|
export type ForwardedMessageItem = {
|
||||||
|
messageType: number
|
||||||
|
sender?: string
|
||||||
|
sentAt?: string
|
||||||
|
text: string
|
||||||
|
nested?: ForwardedMessageItem[]
|
||||||
|
}
|
||||||
|
type ForwardBundleContent = {
|
||||||
|
type: 'forwardBundle'
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
items: ForwardedMessageItem[]
|
||||||
|
}
|
||||||
type MiniProgramContent = {
|
type MiniProgramContent = {
|
||||||
type: 'miniProgram'
|
type: 'miniProgram'
|
||||||
title: string
|
title: string
|
||||||
@@ -119,7 +134,7 @@ type SystemContent = {
|
|||||||
recallTime?: number
|
recallTime?: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
type UnknownContent = { type: 'unknown'; raw: string }
|
type UnknownContent = { type: 'unknown'; raw: string; messageType?: string | number }
|
||||||
|
|
||||||
export type ParsedContent =
|
export type ParsedContent =
|
||||||
| TextContent
|
| TextContent
|
||||||
@@ -127,6 +142,7 @@ export type ParsedContent =
|
|||||||
| LocationContent
|
| LocationContent
|
||||||
| CardContent
|
| CardContent
|
||||||
| ShareContent
|
| ShareContent
|
||||||
|
| ForwardBundleContent
|
||||||
| MiniProgramContent
|
| MiniProgramContent
|
||||||
| RedPacketContent
|
| RedPacketContent
|
||||||
| VoipContent
|
| VoipContent
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { parseMessageContent } from '../src/main/message-parser.ts'
|
||||||
|
import { classifyStickerHttpFailure } from '../src/shared/sticker.ts'
|
||||||
|
|
||||||
|
test('merged forwarding messages expose expandable record items', () => {
|
||||||
|
const content = `
|
||||||
|
<msg><appmsg><title>项目讨论记录</title><type>19</type>
|
||||||
|
<recorditem><![CDATA[
|
||||||
|
<recordinfo>
|
||||||
|
<dataitem datatype="1">
|
||||||
|
<sourcename><![CDATA[张三]]></sourcename>
|
||||||
|
<sourcetime>2026-08-01 10:00</sourcetime>
|
||||||
|
<datadesc><![CDATA[第一条消息]]></datadesc>
|
||||||
|
</dataitem>
|
||||||
|
<dataitem datatype="3">
|
||||||
|
<sourcename><![CDATA[李四]]></sourcename>
|
||||||
|
<sourcetime>2026-08-01 10:01</sourcetime>
|
||||||
|
</dataitem>
|
||||||
|
</recordinfo>
|
||||||
|
]]></recorditem>
|
||||||
|
</appmsg></msg>`
|
||||||
|
|
||||||
|
const parsed = parseMessageContent(content, 49)
|
||||||
|
assert.equal(parsed.type, 'forwardBundle')
|
||||||
|
assert.equal(parsed.title, '项目讨论记录')
|
||||||
|
assert.deepEqual(
|
||||||
|
parsed.items.map((item) => [item.sender, item.text]),
|
||||||
|
[
|
||||||
|
['张三', '第一条消息'],
|
||||||
|
['李四', '[图片]']
|
||||||
|
]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('unknown message types are not misclassified as text', () => {
|
||||||
|
const parsed = parseMessageContent('<unsupported><payload>1</payload></unsupported>', 9999)
|
||||||
|
assert.equal(parsed.type, 'unknown')
|
||||||
|
assert.equal(parsed.messageType, 9999)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sticker 403 with expired timestamp is classified as an expired link', () => {
|
||||||
|
const result = classifyStickerHttpFailure(
|
||||||
|
403,
|
||||||
|
'https://example.invalid/sticker?expire=1700000000',
|
||||||
|
1_800_000_000_000
|
||||||
|
)
|
||||||
|
assert.equal(result.code, 'link_expired')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sticker authorization and removal failures remain distinct', () => {
|
||||||
|
assert.equal(
|
||||||
|
classifyStickerHttpFailure(401, 'https://example.invalid/sticker').code,
|
||||||
|
'authentication_required'
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
classifyStickerHttpFailure(403, 'https://example.invalid/sticker').code,
|
||||||
|
'access_denied'
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
classifyStickerHttpFailure(404, 'https://example.invalid/sticker').code,
|
||||||
|
'resource_removed'
|
||||||
|
)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user