mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-22 15:09:04 +00:00
Merge branch 'dev' into main
This commit is contained in:
205
electron/main.ts
205
electron/main.ts
@@ -1,4 +1,4 @@
|
||||
import './preload-env'
|
||||
import './preload-env'
|
||||
import { app, BrowserWindow, ipcMain, nativeTheme, session, Tray, Menu, nativeImage } from 'electron'
|
||||
import { Worker } from 'worker_threads'
|
||||
import { randomUUID } from 'crypto'
|
||||
@@ -31,6 +31,7 @@ import { destroyNotificationWindow, registerNotificationHandlers, showNotificati
|
||||
import { httpService } from './services/httpService'
|
||||
import { messagePushService } from './services/messagePushService'
|
||||
import { insightService } from './services/insightService'
|
||||
import { normalizeWeiboCookieInput, weiboService } from './services/social/weiboService'
|
||||
import { bizService } from './services/bizService'
|
||||
|
||||
// 配置自动更新
|
||||
@@ -371,6 +372,7 @@ if (process.platform === 'darwin') {
|
||||
let mainWindowReady = false
|
||||
let shouldShowMain = true
|
||||
let isAppQuitting = false
|
||||
let shutdownPromise: Promise<void> | null = null
|
||||
let tray: Tray | null = null
|
||||
let isClosePromptVisible = false
|
||||
const chatHistoryPayloadStore = new Map<string, { sessionId: string; title?: string; recordList: any[] }>()
|
||||
@@ -1660,6 +1662,32 @@ function registerIpcHandlers() {
|
||||
return insightService.generateFootprintInsight(payload)
|
||||
})
|
||||
|
||||
ipcMain.handle('social:saveWeiboCookie', async (_, rawInput: string) => {
|
||||
try {
|
||||
if (!configService) {
|
||||
return { success: false, error: 'Config service is not initialized' }
|
||||
}
|
||||
const normalized = normalizeWeiboCookieInput(rawInput)
|
||||
configService.set('aiInsightWeiboCookie' as any, normalized as any)
|
||||
weiboService.clearCache()
|
||||
return { success: true, normalized, hasCookie: Boolean(normalized) }
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message || 'Failed to save Weibo cookie' }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('social:validateWeiboUid', async (_, uid: string) => {
|
||||
try {
|
||||
if (!configService) {
|
||||
return { success: false, error: 'Config service is not initialized' }
|
||||
}
|
||||
const cookie = String(configService.get('aiInsightWeiboCookie' as any) || '')
|
||||
return await weiboService.validateUid(uid, cookie)
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message || 'Failed to validate Weibo UID' }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('config:clear', async () => {
|
||||
if (isLaunchAtStartupSupported() && getSystemLaunchAtStartup()) {
|
||||
const result = setSystemLaunchAtStartup(false)
|
||||
@@ -1863,9 +1891,17 @@ function registerIpcHandlers() {
|
||||
downloadedHandler = null
|
||||
}
|
||||
|
||||
// 统一错误提示格式,避免出现 [object Object] 的 JSON 字符串
|
||||
const errorMessage = error.message || (typeof error === 'string' ? error : JSON.stringify(error))
|
||||
throw new Error(errorMessage)
|
||||
const errorCode = typeof error?.code === 'string' ? error.code : ''
|
||||
const rawErrorMessage =
|
||||
typeof error?.message === 'string'
|
||||
? error.message
|
||||
: (typeof error === 'string' ? error : JSON.stringify(error))
|
||||
|
||||
if (errorCode === 'ERR_UPDATER_ZIP_FILE_NOT_FOUND' || /ZIP file not provided/i.test(rawErrorMessage)) {
|
||||
throw new Error('当前发布版本缺少 macOS 自动更新所需的 ZIP 包,请联系开发者重新发布该版本')
|
||||
}
|
||||
|
||||
throw new Error(rawErrorMessage || '下载更新失败,请稍后重试')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2636,15 +2672,30 @@ function registerIpcHandlers() {
|
||||
// 私聊克隆
|
||||
|
||||
|
||||
ipcMain.handle('image:decrypt', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => {
|
||||
ipcMain.handle('image:decrypt', async (_, payload: {
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
createTime?: number
|
||||
force?: boolean
|
||||
preferFilePath?: boolean
|
||||
hardlinkOnly?: boolean
|
||||
disableUpdateCheck?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
suppressEvents?: boolean
|
||||
}) => {
|
||||
return imageDecryptService.decryptImage(payload)
|
||||
})
|
||||
ipcMain.handle('image:resolveCache', async (_, payload: {
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
createTime?: number
|
||||
preferFilePath?: boolean
|
||||
hardlinkOnly?: boolean
|
||||
disableUpdateCheck?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
suppressEvents?: boolean
|
||||
}) => {
|
||||
return imageDecryptService.resolveCachedImage(payload)
|
||||
})
|
||||
@@ -2652,17 +2703,84 @@ function registerIpcHandlers() {
|
||||
'image:resolveCacheBatch',
|
||||
async (
|
||||
_,
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
||||
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean }
|
||||
payloads: Array<{
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
createTime?: number
|
||||
preferFilePath?: boolean
|
||||
hardlinkOnly?: boolean
|
||||
suppressEvents?: boolean
|
||||
}>,
|
||||
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean; suppressEvents?: boolean }
|
||||
) => {
|
||||
const list = Array.isArray(payloads) ? payloads : []
|
||||
const rows = await Promise.all(list.map(async (payload) => {
|
||||
return imageDecryptService.resolveCachedImage({
|
||||
...payload,
|
||||
disableUpdateCheck: options?.disableUpdateCheck === true,
|
||||
allowCacheIndex: options?.allowCacheIndex !== false
|
||||
})
|
||||
}))
|
||||
if (list.length === 0) return { success: true, rows: [] }
|
||||
|
||||
const maxConcurrentRaw = Number(process.env.WEFLOW_IMAGE_RESOLVE_BATCH_CONCURRENCY || 10)
|
||||
const maxConcurrent = Number.isFinite(maxConcurrentRaw)
|
||||
? Math.max(1, Math.min(Math.floor(maxConcurrentRaw), 48))
|
||||
: 10
|
||||
const workerCount = Math.min(maxConcurrent, list.length)
|
||||
|
||||
const rows: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }> = new Array(list.length)
|
||||
let cursor = 0
|
||||
const dedupe = new Map<string, Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }>>()
|
||||
|
||||
const makeDedupeKey = (payload: typeof list[number]): string => {
|
||||
const sessionId = String(payload.sessionId || '').trim().toLowerCase()
|
||||
const imageMd5 = String(payload.imageMd5 || '').trim().toLowerCase()
|
||||
const imageDatName = String(payload.imageDatName || '').trim().toLowerCase()
|
||||
const createTime = Number(payload.createTime || 0) || 0
|
||||
const preferFilePath = payload.preferFilePath ?? options?.preferFilePath === true
|
||||
const hardlinkOnly = payload.hardlinkOnly ?? options?.hardlinkOnly === true
|
||||
const allowCacheIndex = options?.allowCacheIndex !== false
|
||||
const disableUpdateCheck = options?.disableUpdateCheck === true
|
||||
const suppressEvents = payload.suppressEvents ?? options?.suppressEvents === true
|
||||
return [
|
||||
sessionId,
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
String(createTime),
|
||||
preferFilePath ? 'pf1' : 'pf0',
|
||||
hardlinkOnly ? 'hl1' : 'hl0',
|
||||
allowCacheIndex ? 'ci1' : 'ci0',
|
||||
disableUpdateCheck ? 'du1' : 'du0',
|
||||
suppressEvents ? 'se1' : 'se0'
|
||||
].join('|')
|
||||
}
|
||||
|
||||
const resolveOne = (payload: typeof list[number]) => imageDecryptService.resolveCachedImage({
|
||||
...payload,
|
||||
preferFilePath: payload.preferFilePath ?? options?.preferFilePath === true,
|
||||
hardlinkOnly: payload.hardlinkOnly ?? options?.hardlinkOnly === true,
|
||||
disableUpdateCheck: options?.disableUpdateCheck === true,
|
||||
allowCacheIndex: options?.allowCacheIndex !== false,
|
||||
suppressEvents: payload.suppressEvents ?? options?.suppressEvents === true
|
||||
})
|
||||
|
||||
const worker = async () => {
|
||||
while (true) {
|
||||
const index = cursor
|
||||
cursor += 1
|
||||
if (index >= list.length) return
|
||||
const payload = list[index]
|
||||
const key = makeDedupeKey(payload)
|
||||
const existing = dedupe.get(key)
|
||||
if (existing) {
|
||||
rows[index] = await existing
|
||||
continue
|
||||
}
|
||||
const task = resolveOne(payload).catch((error) => ({
|
||||
success: false,
|
||||
error: String(error)
|
||||
}))
|
||||
dedupe.set(key, task)
|
||||
rows[index] = await task
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: workerCount }, () => worker()))
|
||||
return { success: true, rows }
|
||||
}
|
||||
)
|
||||
@@ -2670,12 +2788,19 @@ function registerIpcHandlers() {
|
||||
'image:preload',
|
||||
async (
|
||||
_,
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>,
|
||||
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
|
||||
) => {
|
||||
imagePreloadService.enqueue(payloads || [], options)
|
||||
return true
|
||||
})
|
||||
ipcMain.handle(
|
||||
'image:preloadHardlinkMd5s',
|
||||
async (_, md5List?: string[]) => {
|
||||
await imageDecryptService.preloadImageHardlinkMd5s(Array.isArray(md5List) ? md5List : [])
|
||||
return true
|
||||
}
|
||||
)
|
||||
|
||||
// Windows Hello
|
||||
ipcMain.handle('auth:hello', async (event, message?: string) => {
|
||||
@@ -3753,23 +3878,35 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
})
|
||||
|
||||
app.on('before-quit', async () => {
|
||||
isAppQuitting = true
|
||||
// 销毁 tray 图标
|
||||
if (tray) { try { tray.destroy() } catch {} tray = null }
|
||||
// 通知窗使用 hide 而非 close,退出时主动销毁,避免残留窗口阻塞进程退出。
|
||||
destroyNotificationWindow()
|
||||
insightService.stop()
|
||||
// 兜底:5秒后强制退出,防止某个异步任务卡住导致进程残留
|
||||
const forceExitTimer = setTimeout(() => {
|
||||
console.warn('[App] Force exit after timeout')
|
||||
app.exit(0)
|
||||
}, 5000)
|
||||
forceExitTimer.unref()
|
||||
// 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出
|
||||
try { await httpService.stop() } catch {}
|
||||
// 终止 wcdb Worker 线程,避免线程阻止进程退出
|
||||
try { await wcdbService.shutdown() } catch {}
|
||||
const shutdownAppServices = async (): Promise<void> => {
|
||||
if (shutdownPromise) return shutdownPromise
|
||||
shutdownPromise = (async () => {
|
||||
isAppQuitting = true
|
||||
// 销毁 tray 图标
|
||||
if (tray) { try { tray.destroy() } catch {} tray = null }
|
||||
// 通知窗使用 hide 而非 close,退出时主动销毁,避免残留窗口阻塞进程退出。
|
||||
destroyNotificationWindow()
|
||||
messagePushService.stop()
|
||||
insightService.stop()
|
||||
// 兜底:5秒后强制退出,防止某个异步任务卡住导致进程残留
|
||||
const forceExitTimer = setTimeout(() => {
|
||||
console.warn('[App] Force exit after timeout')
|
||||
app.exit(0)
|
||||
}, 5000)
|
||||
forceExitTimer.unref()
|
||||
try { await cloudControlService.stop() } catch {}
|
||||
// 停止 chatService(内部会关闭 cursor 与 DB),避免退出阶段仍触发监控回调
|
||||
try { chatService.close() } catch {}
|
||||
// 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出
|
||||
try { await httpService.stop() } catch {}
|
||||
// 终止 wcdb Worker 线程,避免线程阻止进程退出
|
||||
try { await wcdbService.shutdown() } catch {}
|
||||
})()
|
||||
return shutdownPromise
|
||||
}
|
||||
|
||||
app.on('before-quit', () => {
|
||||
void shutdownAppServices()
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
@@ -3777,3 +3914,7 @@ app.on('window-all-closed', () => {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
// 暴露给渲染进程的 API
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
@@ -286,24 +286,41 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
|
||||
// 图片解密
|
||||
image: {
|
||||
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) =>
|
||||
decrypt: (payload: {
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
createTime?: number
|
||||
force?: boolean
|
||||
preferFilePath?: boolean
|
||||
hardlinkOnly?: boolean
|
||||
disableUpdateCheck?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
suppressEvents?: boolean
|
||||
}) =>
|
||||
ipcRenderer.invoke('image:decrypt', payload),
|
||||
resolveCache: (payload: {
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
createTime?: number
|
||||
preferFilePath?: boolean
|
||||
hardlinkOnly?: boolean
|
||||
disableUpdateCheck?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
suppressEvents?: boolean
|
||||
}) =>
|
||||
ipcRenderer.invoke('image:resolveCache', payload),
|
||||
resolveCacheBatch: (
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
||||
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean }
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean; hardlinkOnly?: boolean }>,
|
||||
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean; suppressEvents?: boolean }
|
||||
) => ipcRenderer.invoke('image:resolveCacheBatch', payloads, options),
|
||||
preload: (
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>,
|
||||
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
|
||||
) => ipcRenderer.invoke('image:preload', payloads, options),
|
||||
preloadHardlinkMd5s: (md5List: string[]) =>
|
||||
ipcRenderer.invoke('image:preloadHardlinkMd5s', md5List),
|
||||
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {
|
||||
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload)
|
||||
ipcRenderer.on('image:updateAvailable', listener)
|
||||
@@ -540,5 +557,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }>
|
||||
mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }>
|
||||
}) => ipcRenderer.invoke('insight:generateFootprintInsight', payload)
|
||||
},
|
||||
|
||||
social: {
|
||||
saveWeiboCookie: (rawInput: string) => ipcRenderer.invoke('social:saveWeiboCookie', rawInput),
|
||||
validateWeiboUid: (uid: string) => ipcRenderer.invoke('social:validateWeiboUid', uid)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -486,7 +486,7 @@ class ChatService {
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
private toCodeOnlyMessage(rawMessage?: string, fallbackCode = -3999): string {
|
||||
private toCodeOnlyMessage(rawMessage?: string | null, fallbackCode = -3999): string {
|
||||
const code = this.extractErrorCode(rawMessage) ?? fallbackCode
|
||||
return `错误码: ${code}`
|
||||
}
|
||||
@@ -4336,9 +4336,9 @@ class ChatService {
|
||||
encrypVer = imageInfo.encrypVer
|
||||
cdnThumbUrl = imageInfo.cdnThumbUrl
|
||||
imageDatName = this.parseImageDatNameFromRow(row)
|
||||
} else if (localType === 43 && content) {
|
||||
// 视频消息
|
||||
videoMd5 = this.parseVideoMd5(content)
|
||||
} else if (localType === 43) {
|
||||
// 视频消息:优先从 packed_info_data 提取真实文件名(32位十六进制),再回退 XML
|
||||
videoMd5 = this.parseVideoFileNameFromRow(row, content)
|
||||
} else if (localType === 34 && content) {
|
||||
voiceDurationSeconds = this.parseVoiceDurationSeconds(content)
|
||||
} else if (localType === 42 && content) {
|
||||
@@ -4876,7 +4876,20 @@ class ChatService {
|
||||
}
|
||||
|
||||
private parseImageDatNameFromRow(row: Record<string, any>): string | undefined {
|
||||
const packed = row.packed_info_data
|
||||
const packed = this.getRowField(row, [
|
||||
'packed_info_data',
|
||||
'packedInfoData',
|
||||
'packed_info_blob',
|
||||
'packedInfoBlob',
|
||||
'packed_info',
|
||||
'packedInfo',
|
||||
'BytesExtra',
|
||||
'bytes_extra',
|
||||
'WCDB_CT_packed_info',
|
||||
'reserved0',
|
||||
'Reserved0',
|
||||
'WCDB_CT_Reserved0'
|
||||
])
|
||||
const buffer = this.decodePackedInfo(packed)
|
||||
if (!buffer || buffer.length === 0) return undefined
|
||||
const printable: number[] = []
|
||||
@@ -4894,6 +4907,81 @@ class ChatService {
|
||||
return hexMatch?.[1]?.toLowerCase()
|
||||
}
|
||||
|
||||
private parseVideoFileNameFromRow(row: Record<string, any>, content?: string): string | undefined {
|
||||
const packed = this.getRowField(row, [
|
||||
'packed_info_data',
|
||||
'packedInfoData',
|
||||
'packed_info_blob',
|
||||
'packedInfoBlob',
|
||||
'packed_info',
|
||||
'packedInfo',
|
||||
'BytesExtra',
|
||||
'bytes_extra',
|
||||
'WCDB_CT_packed_info',
|
||||
'reserved0',
|
||||
'Reserved0',
|
||||
'WCDB_CT_Reserved0'
|
||||
])
|
||||
const packedToken = this.extractVideoTokenFromPackedRaw(packed)
|
||||
if (packedToken) return packedToken
|
||||
|
||||
const byColumn = this.normalizeVideoFileToken(this.getRowField(row, [
|
||||
'video_md5',
|
||||
'videoMd5',
|
||||
'raw_md5',
|
||||
'rawMd5',
|
||||
'video_file_name',
|
||||
'videoFileName'
|
||||
]))
|
||||
if (byColumn) return byColumn
|
||||
|
||||
return this.normalizeVideoFileToken(this.parseVideoMd5(content || ''))
|
||||
}
|
||||
|
||||
private normalizeVideoFileToken(value: unknown): string | undefined {
|
||||
let text = String(value || '').trim().toLowerCase()
|
||||
if (!text) return undefined
|
||||
text = text.replace(/^.*[\\/]/, '')
|
||||
text = text.replace(/\.(?:mp4|mov|m4v|avi|mkv|flv|jpg|jpeg|png|gif|dat)$/i, '')
|
||||
text = text.replace(/_thumb$/, '')
|
||||
const directMatch = /^([a-f0-9]{16,64})(?:_raw)?$/i.exec(text)
|
||||
if (directMatch) {
|
||||
const suffix = /_raw$/i.test(text) ? '_raw' : ''
|
||||
return `${directMatch[1].toLowerCase()}${suffix}`
|
||||
}
|
||||
const preferred32 = /([a-f0-9]{32})(?![a-f0-9])/i.exec(text)
|
||||
if (preferred32?.[1]) return preferred32[1].toLowerCase()
|
||||
const generic = /([a-f0-9]{16,64})(?![a-f0-9])/i.exec(text)
|
||||
return generic?.[1]?.toLowerCase()
|
||||
}
|
||||
|
||||
private extractVideoTokenFromPackedRaw(raw: unknown): string | undefined {
|
||||
const buffer = this.decodePackedInfo(raw)
|
||||
if (!buffer || buffer.length === 0) return undefined
|
||||
const candidates: string[] = []
|
||||
let current = ''
|
||||
for (const byte of buffer) {
|
||||
const isHex =
|
||||
(byte >= 0x30 && byte <= 0x39) ||
|
||||
(byte >= 0x41 && byte <= 0x46) ||
|
||||
(byte >= 0x61 && byte <= 0x66)
|
||||
if (isHex) {
|
||||
current += String.fromCharCode(byte)
|
||||
continue
|
||||
}
|
||||
if (current.length >= 16) candidates.push(current)
|
||||
current = ''
|
||||
}
|
||||
if (current.length >= 16) candidates.push(current)
|
||||
if (candidates.length === 0) return undefined
|
||||
|
||||
const exact32 = candidates.find((item) => item.length === 32)
|
||||
if (exact32) return exact32.toLowerCase()
|
||||
|
||||
const fallback = candidates.find((item) => item.length >= 16 && item.length <= 64)
|
||||
return fallback?.toLowerCase()
|
||||
}
|
||||
|
||||
private decodePackedInfo(raw: any): Buffer | null {
|
||||
if (!raw) return null
|
||||
if (Buffer.isBuffer(raw)) return raw
|
||||
@@ -4901,9 +4989,10 @@ class ChatService {
|
||||
if (Array.isArray(raw)) return Buffer.from(raw)
|
||||
if (typeof raw === 'string') {
|
||||
const trimmed = raw.trim()
|
||||
if (/^[a-fA-F0-9]+$/.test(trimmed) && trimmed.length % 2 === 0) {
|
||||
const compactHex = trimmed.replace(/\s+/g, '')
|
||||
if (/^[a-fA-F0-9]+$/.test(compactHex) && compactHex.length % 2 === 0) {
|
||||
try {
|
||||
return Buffer.from(trimmed, 'hex')
|
||||
return Buffer.from(compactHex, 'hex')
|
||||
} catch { }
|
||||
}
|
||||
try {
|
||||
@@ -7105,13 +7194,23 @@ class ChatService {
|
||||
return { success: false, error: '未找到消息' }
|
||||
}
|
||||
const msg = msgResult.message
|
||||
const rawImageInfo = msg.rawContent ? this.parseImageInfo(msg.rawContent) : {}
|
||||
const imageMd5 = msg.imageMd5 || rawImageInfo.md5
|
||||
const imageDatName = msg.imageDatName
|
||||
|
||||
// 2. 使用 imageDecryptService 解密图片
|
||||
if (!imageMd5 && !imageDatName) {
|
||||
return { success: false, error: '图片缺少 md5/datName,无法定位原文件' }
|
||||
}
|
||||
|
||||
// 2. 使用 imageDecryptService 解密图片(仅使用真实图片标识)
|
||||
const result = await this.imageDecryptService.decryptImage({
|
||||
sessionId,
|
||||
imageMd5: msg.imageMd5,
|
||||
imageDatName: msg.imageDatName || String(msg.localId),
|
||||
force: false
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
createTime: msg.createTime,
|
||||
force: false,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true
|
||||
})
|
||||
|
||||
if (!result.success || !result.localPath) {
|
||||
@@ -8358,7 +8457,6 @@ class ChatService {
|
||||
if (normalized.length === 0) return []
|
||||
|
||||
// 规避 native options_json 可能存在的固定缓冲上限:按 payload 字节安全分块。
|
||||
// 这不是降级或裁剪范围,而是完整遍历所有群并做结果合并。
|
||||
const maxBytesRaw = Number(process.env.WEFLOW_MY_FOOTPRINT_GROUP_OPTIONS_MAX_BYTES || 900)
|
||||
const maxBytes = Number.isFinite(maxBytesRaw) && maxBytesRaw >= 512
|
||||
? Math.floor(maxBytesRaw)
|
||||
@@ -9325,7 +9423,7 @@ class ChatService {
|
||||
latest_ts: this.toSafeInt(item?.latest_ts, 0),
|
||||
anchor_local_id: this.toSafeInt(item?.anchor_local_id, 0),
|
||||
anchor_create_time: this.toSafeInt(item?.anchor_create_time, 0)
|
||||
})).filter((item) => item.session_id)
|
||||
})).filter((item: MyFootprintPrivateSession) => item.session_id)
|
||||
|
||||
const private_segments: MyFootprintPrivateSegment[] = privateSegmentsRaw.map((item: any) => ({
|
||||
session_id: String(item?.session_id || '').trim(),
|
||||
@@ -9344,7 +9442,7 @@ class ChatService {
|
||||
anchor_create_time: this.toSafeInt(item?.anchor_create_time, 0),
|
||||
displayName: String(item?.displayName || '').trim() || undefined,
|
||||
avatarUrl: String(item?.avatarUrl || '').trim() || undefined
|
||||
})).filter((item) => item.session_id && item.start_ts > 0)
|
||||
})).filter((item: MyFootprintPrivateSegment) => item.session_id && item.start_ts > 0)
|
||||
|
||||
const mentions: MyFootprintMentionItem[] = mentionsRaw.map((item: any) => ({
|
||||
session_id: String(item?.session_id || '').trim(),
|
||||
@@ -9353,13 +9451,13 @@ class ChatService {
|
||||
sender_username: String(item?.sender_username || '').trim(),
|
||||
message_content: String(item?.message_content || ''),
|
||||
source: String(item?.source || '')
|
||||
})).filter((item) => item.session_id)
|
||||
})).filter((item: MyFootprintMentionItem) => item.session_id)
|
||||
|
||||
const mention_groups: MyFootprintMentionGroup[] = mentionGroupsRaw.map((item: any) => ({
|
||||
session_id: String(item?.session_id || '').trim(),
|
||||
count: this.toSafeInt(item?.count, 0),
|
||||
latest_ts: this.toSafeInt(item?.latest_ts, 0)
|
||||
})).filter((item) => item.session_id)
|
||||
})).filter((item: MyFootprintMentionGroup) => item.session_id)
|
||||
|
||||
const diagnostics: MyFootprintDiagnostics = {
|
||||
truncated: Boolean(diagnosticsRaw.truncated),
|
||||
@@ -10481,6 +10579,8 @@ class ChatService {
|
||||
const imgInfo = this.parseImageInfo(rawContent)
|
||||
Object.assign(msg, imgInfo)
|
||||
msg.imageDatName = this.parseImageDatNameFromRow(row)
|
||||
} else if (msg.localType === 43) { // Video
|
||||
msg.videoMd5 = this.parseVideoFileNameFromRow(row, rawContent)
|
||||
} else if (msg.localType === 47) { // Emoji
|
||||
const emojiInfo = this.parseEmojiInfo(rawContent)
|
||||
msg.emojiCdnUrl = emojiInfo.cdnUrl
|
||||
|
||||
@@ -218,7 +218,7 @@ class CloudControlService {
|
||||
this.pages.add(pageName)
|
||||
}
|
||||
|
||||
stop() {
|
||||
async stop(): Promise<void> {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
@@ -230,7 +230,13 @@ class CloudControlService {
|
||||
this.circuitOpenedAt = 0
|
||||
this.nextDelayOverrideMs = null
|
||||
this.initialized = false
|
||||
wcdbService.cloudStop()
|
||||
if (wcdbService.isReady()) {
|
||||
try {
|
||||
await wcdbService.cloudStop()
|
||||
} catch {
|
||||
// 忽略停止失败,避免阻塞主进程退出
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getLogs() {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { join } from 'path'
|
||||
import { join } from 'path'
|
||||
import { app, safeStorage } from 'electron'
|
||||
import crypto from 'crypto'
|
||||
import Store from 'electron-store'
|
||||
import { expandHomePath } from '../utils/pathUtils'
|
||||
|
||||
// 加密前缀标记
|
||||
const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式)
|
||||
@@ -42,7 +43,6 @@ interface ConfigSchema {
|
||||
autoTranscribeVoice: boolean
|
||||
transcribeLanguages: string[]
|
||||
exportDefaultConcurrency: number
|
||||
exportDefaultImageDeepSearchOnMiss: boolean
|
||||
analyticsExcludedUsernames: string[]
|
||||
|
||||
// 安全相关
|
||||
@@ -77,12 +77,14 @@ interface ConfigSchema {
|
||||
aiModelApiBaseUrl: string
|
||||
aiModelApiKey: string
|
||||
aiModelApiModel: string
|
||||
aiModelApiMaxTokens: number
|
||||
aiInsightEnabled: boolean
|
||||
aiInsightApiBaseUrl: string
|
||||
aiInsightApiKey: string
|
||||
aiInsightApiModel: string
|
||||
aiInsightSilenceDays: number
|
||||
aiInsightAllowContext: boolean
|
||||
aiInsightAllowSocialContext: boolean
|
||||
aiInsightWhitelistEnabled: boolean
|
||||
aiInsightWhitelist: string[]
|
||||
/** 活跃分析冷却时间(分钟),0 表示无冷却 */
|
||||
@@ -114,7 +116,8 @@ const ENCRYPTED_STRING_KEYS: Set<string> = new Set([
|
||||
'authPassword',
|
||||
'httpApiToken',
|
||||
'aiModelApiKey',
|
||||
'aiInsightApiKey'
|
||||
'aiInsightApiKey',
|
||||
'aiInsightWeiboCookie'
|
||||
])
|
||||
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
|
||||
const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
|
||||
@@ -165,7 +168,6 @@ export class ConfigService {
|
||||
autoTranscribeVoice: false,
|
||||
transcribeLanguages: ['zh'],
|
||||
exportDefaultConcurrency: 4,
|
||||
exportDefaultImageDeepSearchOnMiss: true,
|
||||
analyticsExcludedUsernames: [],
|
||||
authEnabled: false,
|
||||
authPassword: '',
|
||||
@@ -192,21 +194,26 @@ export class ConfigService {
|
||||
aiModelApiBaseUrl: '',
|
||||
aiModelApiKey: '',
|
||||
aiModelApiModel: 'gpt-4o-mini',
|
||||
aiModelApiMaxTokens: 200,
|
||||
aiInsightEnabled: false,
|
||||
aiInsightApiBaseUrl: '',
|
||||
aiInsightApiKey: '',
|
||||
aiInsightApiModel: 'gpt-4o-mini',
|
||||
aiInsightSilenceDays: 3,
|
||||
aiInsightAllowContext: false,
|
||||
aiInsightAllowSocialContext: false,
|
||||
aiInsightWhitelistEnabled: false,
|
||||
aiInsightWhitelist: [],
|
||||
aiInsightCooldownMinutes: 120,
|
||||
aiInsightScanIntervalHours: 4,
|
||||
aiInsightContextCount: 40,
|
||||
aiInsightSocialContextCount: 3,
|
||||
aiInsightSystemPrompt: '',
|
||||
aiInsightTelegramEnabled: false,
|
||||
aiInsightTelegramToken: '',
|
||||
aiInsightTelegramChatIds: '',
|
||||
aiInsightWeiboCookie: '',
|
||||
aiInsightWeiboBindings: {},
|
||||
aiFootprintEnabled: false,
|
||||
aiFootprintSystemPrompt: '',
|
||||
aiInsightDebugLogEnabled: false
|
||||
@@ -291,6 +298,10 @@ export class ConfigService {
|
||||
return this.decryptWxidConfigs(raw as any) as ConfigSchema[K]
|
||||
}
|
||||
|
||||
if (key === 'dbPath' && typeof raw === 'string') {
|
||||
return expandHomePath(raw) as ConfigSchema[K]
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
@@ -298,6 +309,10 @@ export class ConfigService {
|
||||
let toStore = value
|
||||
const inLockMode = this.isLockMode() && this.unlockPassword
|
||||
|
||||
if (key === 'dbPath' && typeof value === 'string') {
|
||||
toStore = expandHomePath(value) as ConfigSchema[K]
|
||||
}
|
||||
|
||||
if (ENCRYPTED_BOOL_KEYS.has(key)) {
|
||||
const boolValue = value === true || value === 'true'
|
||||
// `false` 不需要写入 keychain,避免无意义触发 macOS 钥匙串弹窗
|
||||
@@ -827,3 +842,4 @@ export class ConfigService {
|
||||
this.unlockPassword = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { join, basename } from 'path'
|
||||
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||
import { homedir } from 'os'
|
||||
import { createDecipheriv } from 'crypto'
|
||||
import { expandHomePath } from '../utils/pathUtils'
|
||||
|
||||
export interface WxidInfo {
|
||||
wxid: string
|
||||
@@ -139,13 +140,14 @@ export class DbPathService {
|
||||
* 查找账号目录(包含 db_storage 或图片目录)
|
||||
*/
|
||||
findAccountDirs(rootPath: string): string[] {
|
||||
const resolvedRootPath = expandHomePath(rootPath)
|
||||
const accounts: string[] = []
|
||||
|
||||
try {
|
||||
const entries = readdirSync(rootPath)
|
||||
const entries = readdirSync(resolvedRootPath)
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(rootPath, entry)
|
||||
const entryPath = join(resolvedRootPath, entry)
|
||||
let stat: ReturnType<typeof statSync>
|
||||
try {
|
||||
stat = statSync(entryPath)
|
||||
@@ -216,13 +218,14 @@ export class DbPathService {
|
||||
* 扫描目录名候选(仅包含下划线的文件夹,排除 all_users)
|
||||
*/
|
||||
scanWxidCandidates(rootPath: string): WxidInfo[] {
|
||||
const resolvedRootPath = expandHomePath(rootPath)
|
||||
const wxids: WxidInfo[] = []
|
||||
|
||||
try {
|
||||
if (existsSync(rootPath)) {
|
||||
const entries = readdirSync(rootPath)
|
||||
if (existsSync(resolvedRootPath)) {
|
||||
const entries = readdirSync(resolvedRootPath)
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(rootPath, entry)
|
||||
const entryPath = join(resolvedRootPath, entry)
|
||||
let stat: ReturnType<typeof statSync>
|
||||
try { stat = statSync(entryPath) } catch { continue }
|
||||
if (!stat.isDirectory()) continue
|
||||
@@ -235,9 +238,9 @@ export class DbPathService {
|
||||
|
||||
|
||||
if (wxids.length === 0) {
|
||||
const rootName = basename(rootPath)
|
||||
const rootName = basename(resolvedRootPath)
|
||||
if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') {
|
||||
const rootStat = statSync(rootPath)
|
||||
const rootStat = statSync(resolvedRootPath)
|
||||
wxids.push({ wxid: rootName, modifiedTime: rootStat.mtimeMs })
|
||||
}
|
||||
}
|
||||
@@ -248,7 +251,7 @@ export class DbPathService {
|
||||
return a.wxid.localeCompare(b.wxid)
|
||||
});
|
||||
|
||||
const globalInfo = this.parseGlobalConfig(rootPath);
|
||||
const globalInfo = this.parseGlobalConfig(resolvedRootPath);
|
||||
if (globalInfo) {
|
||||
for (const w of sorted) {
|
||||
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {
|
||||
@@ -266,19 +269,20 @@ export class DbPathService {
|
||||
* 扫描 wxid 列表
|
||||
*/
|
||||
scanWxids(rootPath: string): WxidInfo[] {
|
||||
const resolvedRootPath = expandHomePath(rootPath)
|
||||
const wxids: WxidInfo[] = []
|
||||
|
||||
try {
|
||||
if (this.isAccountDir(rootPath)) {
|
||||
const wxid = basename(rootPath)
|
||||
const modifiedTime = this.getAccountModifiedTime(rootPath)
|
||||
if (this.isAccountDir(resolvedRootPath)) {
|
||||
const wxid = basename(resolvedRootPath)
|
||||
const modifiedTime = this.getAccountModifiedTime(resolvedRootPath)
|
||||
return [{ wxid, modifiedTime }]
|
||||
}
|
||||
|
||||
const accounts = this.findAccountDirs(rootPath)
|
||||
const accounts = this.findAccountDirs(resolvedRootPath)
|
||||
|
||||
for (const account of accounts) {
|
||||
const fullPath = join(rootPath, account)
|
||||
const fullPath = join(resolvedRootPath, account)
|
||||
const modifiedTime = this.getAccountModifiedTime(fullPath)
|
||||
wxids.push({ wxid: account, modifiedTime })
|
||||
}
|
||||
@@ -289,7 +293,7 @@ export class DbPathService {
|
||||
return a.wxid.localeCompare(b.wxid)
|
||||
});
|
||||
|
||||
const globalInfo = this.parseGlobalConfig(rootPath);
|
||||
const globalInfo = this.parseGlobalConfig(resolvedRootPath);
|
||||
if (globalInfo) {
|
||||
for (const w of sorted) {
|
||||
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {
|
||||
|
||||
@@ -108,7 +108,6 @@ export interface ExportOptions {
|
||||
sessionNameWithTypePrefix?: boolean
|
||||
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
|
||||
exportConcurrency?: number
|
||||
imageDeepSearchOnMiss?: boolean
|
||||
}
|
||||
|
||||
const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [
|
||||
@@ -443,8 +442,8 @@ class ExportService {
|
||||
let lastSessionId = ''
|
||||
let lastCollected = 0
|
||||
let lastExported = 0
|
||||
const MIN_PROGRESS_EMIT_INTERVAL_MS = 250
|
||||
const MESSAGE_PROGRESS_DELTA_THRESHOLD = 500
|
||||
const MIN_PROGRESS_EMIT_INTERVAL_MS = 400
|
||||
const MESSAGE_PROGRESS_DELTA_THRESHOLD = 1200
|
||||
|
||||
const commit = (progress: ExportProgress) => {
|
||||
onProgress(progress)
|
||||
@@ -1092,8 +1091,7 @@ class ExportService {
|
||||
private getImageMissingRunCacheKey(
|
||||
sessionId: string,
|
||||
imageMd5?: unknown,
|
||||
imageDatName?: unknown,
|
||||
imageDeepSearchOnMiss = true
|
||||
imageDatName?: unknown
|
||||
): string | null {
|
||||
const normalizedSessionId = String(sessionId || '').trim()
|
||||
const normalizedImageMd5 = String(imageMd5 || '').trim().toLowerCase()
|
||||
@@ -1105,8 +1103,7 @@ class ExportService {
|
||||
const secondaryToken = normalizedImageMd5 && normalizedImageDatName && normalizedImageDatName !== normalizedImageMd5
|
||||
? normalizedImageDatName
|
||||
: ''
|
||||
const lookupMode = imageDeepSearchOnMiss ? 'deep' : 'hardlink'
|
||||
return `${lookupMode}\u001f${normalizedSessionId}\u001f${primaryToken}\u001f${secondaryToken}`
|
||||
return `${normalizedSessionId}\u001f${primaryToken}\u001f${secondaryToken}`
|
||||
}
|
||||
|
||||
private normalizeEmojiMd5(value: unknown): string | undefined {
|
||||
@@ -3583,7 +3580,6 @@ class ExportService {
|
||||
exportVoiceAsText?: boolean
|
||||
includeVideoPoster?: boolean
|
||||
includeVoiceWithTranscript?: boolean
|
||||
imageDeepSearchOnMiss?: boolean
|
||||
dirCache?: Set<string>
|
||||
}
|
||||
): Promise<MediaExportItem | null> {
|
||||
@@ -3596,8 +3592,7 @@ class ExportService {
|
||||
sessionId,
|
||||
mediaRootDir,
|
||||
mediaRelativePrefix,
|
||||
options.dirCache,
|
||||
options.imageDeepSearchOnMiss !== false
|
||||
options.dirCache
|
||||
)
|
||||
if (result) {
|
||||
}
|
||||
@@ -3654,8 +3649,7 @@ class ExportService {
|
||||
sessionId: string,
|
||||
mediaRootDir: string,
|
||||
mediaRelativePrefix: string,
|
||||
dirCache?: Set<string>,
|
||||
imageDeepSearchOnMiss = true
|
||||
dirCache?: Set<string>
|
||||
): Promise<MediaExportItem | null> {
|
||||
try {
|
||||
const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images')
|
||||
@@ -3675,8 +3669,7 @@ class ExportService {
|
||||
const missingRunCacheKey = this.getImageMissingRunCacheKey(
|
||||
sessionId,
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
imageDeepSearchOnMiss
|
||||
imageDatName
|
||||
)
|
||||
if (missingRunCacheKey && this.mediaRunMissingImageKeys.has(missingRunCacheKey)) {
|
||||
return null
|
||||
@@ -3686,26 +3679,31 @@ class ExportService {
|
||||
sessionId,
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
createTime: msg.createTime,
|
||||
force: true, // 导出优先高清,失败再回退缩略图
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: !imageDeepSearchOnMiss
|
||||
hardlinkOnly: true,
|
||||
disableUpdateCheck: true,
|
||||
allowCacheIndex: !imageMd5,
|
||||
suppressEvents: true
|
||||
})
|
||||
|
||||
if (!result.success || !result.localPath) {
|
||||
console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`)
|
||||
if (!imageDeepSearchOnMiss) {
|
||||
console.log(`[Export] 未命中 hardlink(已关闭缺图深度搜索)→ 将显示 [图片] 占位符`)
|
||||
if (missingRunCacheKey) {
|
||||
this.mediaRunMissingImageKeys.add(missingRunCacheKey)
|
||||
}
|
||||
return null
|
||||
if (result.failureKind === 'decrypt_failed') {
|
||||
console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`)
|
||||
} else {
|
||||
console.log(`[Export] 图片本地无数据 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`)
|
||||
}
|
||||
// 尝试获取缩略图
|
||||
const thumbResult = await imageDecryptService.resolveCachedImage({
|
||||
sessionId,
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
preferFilePath: true
|
||||
createTime: msg.createTime,
|
||||
preferFilePath: true,
|
||||
disableUpdateCheck: true,
|
||||
allowCacheIndex: !imageMd5,
|
||||
suppressEvents: true
|
||||
})
|
||||
if (thumbResult.success && thumbResult.localPath) {
|
||||
console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`)
|
||||
@@ -3782,7 +3780,6 @@ class ExportService {
|
||||
|
||||
const md5Pattern = /^[a-f0-9]{32}$/i
|
||||
const imageMd5Set = new Set<string>()
|
||||
const videoMd5Set = new Set<string>()
|
||||
|
||||
let scanIndex = 0
|
||||
for (const msg of messages) {
|
||||
@@ -3802,19 +3799,12 @@ class ExportService {
|
||||
}
|
||||
}
|
||||
|
||||
if (options.exportVideos && msg?.localType === 43) {
|
||||
const videoMd5 = String(msg?.videoMd5 || '').trim().toLowerCase()
|
||||
if (videoMd5) videoMd5Set.add(videoMd5)
|
||||
}
|
||||
}
|
||||
|
||||
const preloadTasks: Array<Promise<void>> = []
|
||||
if (imageMd5Set.size > 0) {
|
||||
preloadTasks.push(imageDecryptService.preloadImageHardlinkMd5s(Array.from(imageMd5Set)))
|
||||
}
|
||||
if (videoMd5Set.size > 0) {
|
||||
preloadTasks.push(videoService.preloadVideoHardlinkMd5s(Array.from(videoMd5Set)))
|
||||
}
|
||||
if (preloadTasks.length === 0) return
|
||||
|
||||
await Promise.all(preloadTasks.map((task) => task.catch(() => { })))
|
||||
@@ -4104,6 +4094,95 @@ class ExportService {
|
||||
return tagMatch?.[1]?.toLowerCase()
|
||||
}
|
||||
|
||||
private decodePackedInfoBuffer(raw: unknown): Buffer | null {
|
||||
if (!raw) return null
|
||||
if (Buffer.isBuffer(raw)) return raw
|
||||
if (raw instanceof Uint8Array) return Buffer.from(raw)
|
||||
if (Array.isArray(raw)) return Buffer.from(raw)
|
||||
if (typeof raw === 'string') {
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) return null
|
||||
const compactHex = trimmed.replace(/\s+/g, '')
|
||||
if (/^[a-fA-F0-9]+$/.test(compactHex) && compactHex.length % 2 === 0) {
|
||||
try {
|
||||
return Buffer.from(compactHex, 'hex')
|
||||
} catch { }
|
||||
}
|
||||
try {
|
||||
const decoded = Buffer.from(trimmed, 'base64')
|
||||
if (decoded.length > 0) return decoded
|
||||
} catch { }
|
||||
return null
|
||||
}
|
||||
if (typeof raw === 'object' && raw !== null && Array.isArray((raw as any).data)) {
|
||||
return Buffer.from((raw as any).data)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private normalizeVideoFileToken(value: unknown): string | undefined {
|
||||
let text = String(value || '').trim().toLowerCase()
|
||||
if (!text) return undefined
|
||||
text = text.replace(/^.*[\\/]/, '')
|
||||
text = text.replace(/\.(?:mp4|mov|m4v|avi|mkv|flv|jpg|jpeg|png|gif|dat)$/i, '')
|
||||
text = text.replace(/_thumb$/, '')
|
||||
const direct = /^([a-f0-9]{16,64})(?:_raw)?$/i.exec(text)
|
||||
if (direct) {
|
||||
const suffix = /_raw$/i.test(text) ? '_raw' : ''
|
||||
return `${direct[1].toLowerCase()}${suffix}`
|
||||
}
|
||||
const preferred32 = /([a-f0-9]{32})(?![a-f0-9])/i.exec(text)
|
||||
if (preferred32?.[1]) return preferred32[1].toLowerCase()
|
||||
const fallback = /([a-f0-9]{16,64})(?![a-f0-9])/i.exec(text)
|
||||
return fallback?.[1]?.toLowerCase()
|
||||
}
|
||||
|
||||
private extractVideoFileNameFromPackedRaw(raw: unknown): string | undefined {
|
||||
const buffer = this.decodePackedInfoBuffer(raw)
|
||||
if (!buffer || buffer.length === 0) return undefined
|
||||
const candidates: string[] = []
|
||||
let current = ''
|
||||
for (const byte of buffer) {
|
||||
const isHex =
|
||||
(byte >= 0x30 && byte <= 0x39) ||
|
||||
(byte >= 0x41 && byte <= 0x46) ||
|
||||
(byte >= 0x61 && byte <= 0x66)
|
||||
if (isHex) {
|
||||
current += String.fromCharCode(byte)
|
||||
continue
|
||||
}
|
||||
if (current.length >= 16) candidates.push(current)
|
||||
current = ''
|
||||
}
|
||||
if (current.length >= 16) candidates.push(current)
|
||||
if (candidates.length === 0) return undefined
|
||||
|
||||
const exact32 = candidates.find((item) => item.length === 32)
|
||||
if (exact32) return exact32.toLowerCase()
|
||||
const fallback = candidates.find((item) => item.length >= 16 && item.length <= 64)
|
||||
return fallback?.toLowerCase()
|
||||
}
|
||||
|
||||
private extractVideoFileNameFromRow(row: Record<string, any>, content?: string): string | undefined {
|
||||
const packedRaw = this.getRowField(row, [
|
||||
'packed_info_data', 'packedInfoData',
|
||||
'packed_info_blob', 'packedInfoBlob',
|
||||
'packed_info', 'packedInfo',
|
||||
'BytesExtra', 'bytes_extra',
|
||||
'WCDB_CT_packed_info',
|
||||
'reserved0', 'Reserved0', 'WCDB_CT_Reserved0'
|
||||
])
|
||||
const byPacked = this.extractVideoFileNameFromPackedRaw(packedRaw)
|
||||
if (byPacked) return byPacked
|
||||
|
||||
const byColumn = this.normalizeVideoFileToken(this.getRowField(row, [
|
||||
'video_md5', 'videoMd5', 'raw_md5', 'rawMd5', 'video_file_name', 'videoFileName'
|
||||
]))
|
||||
if (byColumn) return byColumn
|
||||
|
||||
return this.normalizeVideoFileToken(this.extractVideoMd5(content || ''))
|
||||
}
|
||||
|
||||
private resolveFileAttachmentRoots(): string[] {
|
||||
const dbPath = String(this.configService.get('dbPath') || '').trim()
|
||||
const rawWxid = String(this.configService.get('myWxid') || '').trim()
|
||||
@@ -4569,7 +4648,7 @@ class ExportService {
|
||||
// 优先复用游标返回的字段,缺失时再回退到 XML 解析。
|
||||
imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined
|
||||
imageDatName = String(row.image_dat_name || row.imageDatName || '').trim() || undefined
|
||||
videoMd5 = String(row.video_md5 || row.videoMd5 || '').trim() || undefined
|
||||
videoMd5 = this.extractVideoFileNameFromRow(row, content)
|
||||
|
||||
if (localType === 3 && content) {
|
||||
// 图片消息
|
||||
@@ -4577,7 +4656,7 @@ class ExportService {
|
||||
imageDatName = imageDatName || this.extractImageDatName(content)
|
||||
} else if (localType === 43 && content) {
|
||||
// 视频消息
|
||||
videoMd5 = videoMd5 || this.extractVideoMd5(content)
|
||||
videoMd5 = videoMd5 || this.extractVideoFileNameFromRow(row, content)
|
||||
} else if (collectMode === 'full' && content && (localType === 49 || content.includes('<appmsg') || content.includes('<appmsg'))) {
|
||||
// 检查是否是聊天记录消息(type=19),兼容大 localType 的 appmsg
|
||||
const normalizedContent = this.normalizeAppMessageContent(content)
|
||||
@@ -4722,7 +4801,7 @@ class ExportService {
|
||||
}
|
||||
|
||||
if (msg.localType === 43) {
|
||||
const videoMd5 = String(row.video_md5 || row.videoMd5 || '').trim() || this.extractVideoMd5(content)
|
||||
const videoMd5 = this.extractVideoFileNameFromRow(row, content)
|
||||
if (videoMd5) msg.videoMd5 = videoMd5
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -5302,7 +5381,6 @@ class ExportService {
|
||||
maxFileSizeMb: options.maxFileSizeMb,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
includeVideoPoster: options.format === 'html',
|
||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||
dirCache: mediaDirCache
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
@@ -5813,7 +5891,6 @@ class ExportService {
|
||||
maxFileSizeMb: options.maxFileSizeMb,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
includeVideoPoster: options.format === 'html',
|
||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||
dirCache: mediaDirCache
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
@@ -6685,7 +6762,6 @@ class ExportService {
|
||||
maxFileSizeMb: options.maxFileSizeMb,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
includeVideoPoster: options.format === 'html',
|
||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||
dirCache: mediaDirCache
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
@@ -7436,7 +7512,6 @@ class ExportService {
|
||||
maxFileSizeMb: options.maxFileSizeMb,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
includeVideoPoster: options.format === 'html',
|
||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||
dirCache: mediaDirCache
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
@@ -7816,7 +7891,6 @@ class ExportService {
|
||||
maxFileSizeMb: options.maxFileSizeMb,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
includeVideoPoster: options.format === 'html',
|
||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||
dirCache: mediaDirCache
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
@@ -8240,7 +8314,6 @@ class ExportService {
|
||||
includeVideoPoster: options.format === 'html',
|
||||
includeVoiceWithTranscript: true,
|
||||
exportVideos: options.exportVideos,
|
||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||
dirCache: mediaDirCache
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
@@ -8949,12 +9022,14 @@ class ExportService {
|
||||
pendingSessionIds?: string[]
|
||||
successSessionIds?: string[]
|
||||
failedSessionIds?: string[]
|
||||
sessionOutputPaths?: Record<string, string>
|
||||
error?: string
|
||||
}> {
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
const successSessionIds: string[] = []
|
||||
const failedSessionIds: string[] = []
|
||||
const sessionOutputPaths: Record<string, string> = {}
|
||||
const progressEmitter = this.createProgressEmitter(onProgress)
|
||||
let attachMediaTelemetry = false
|
||||
const emitProgress = (progress: ExportProgress, options?: { force?: boolean }) => {
|
||||
@@ -9152,7 +9227,8 @@ class ExportService {
|
||||
stopped: true,
|
||||
pendingSessionIds: [...queue],
|
||||
successSessionIds,
|
||||
failedSessionIds
|
||||
failedSessionIds,
|
||||
sessionOutputPaths
|
||||
}
|
||||
}
|
||||
if (pauseRequested) {
|
||||
@@ -9163,7 +9239,8 @@ class ExportService {
|
||||
paused: true,
|
||||
pendingSessionIds: [...queue],
|
||||
successSessionIds,
|
||||
failedSessionIds
|
||||
failedSessionIds,
|
||||
sessionOutputPaths
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9274,6 +9351,7 @@ class ExportService {
|
||||
if (hasNoDataChange) {
|
||||
successCount++
|
||||
successSessionIds.push(sessionId)
|
||||
sessionOutputPaths[sessionId] = preferredOutputPath
|
||||
activeSessionRatios.delete(sessionId)
|
||||
completedCount++
|
||||
emitProgress({
|
||||
@@ -9319,6 +9397,7 @@ class ExportService {
|
||||
if (result.success) {
|
||||
successCount++
|
||||
successSessionIds.push(sessionId)
|
||||
sessionOutputPaths[sessionId] = outputPath
|
||||
if (typeof messageCountHint === 'number' && messageCountHint >= 0) {
|
||||
exportRecordService.saveRecord(sessionId, effectiveOptions.format, messageCountHint, {
|
||||
sourceLatestMessageTimestamp: typeof latestTimestampHint === 'number' && latestTimestampHint > 0
|
||||
@@ -9409,7 +9488,8 @@ class ExportService {
|
||||
stopped: true,
|
||||
pendingSessionIds,
|
||||
successSessionIds,
|
||||
failedSessionIds
|
||||
failedSessionIds,
|
||||
sessionOutputPaths
|
||||
}
|
||||
}
|
||||
if (pauseRequested && pendingSessionIds.length > 0) {
|
||||
@@ -9420,7 +9500,8 @@ class ExportService {
|
||||
paused: true,
|
||||
pendingSessionIds,
|
||||
successSessionIds,
|
||||
failedSessionIds
|
||||
failedSessionIds,
|
||||
sessionOutputPaths
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9433,7 +9514,7 @@ class ExportService {
|
||||
}, { force: true })
|
||||
progressEmitter.flush()
|
||||
|
||||
return { success: true, successCount, failCount, successSessionIds, failedSessionIds }
|
||||
return { success: true, successCount, failCount, successSessionIds, failedSessionIds, sessionOutputPaths }
|
||||
} catch (e) {
|
||||
progressEmitter.flush()
|
||||
return { success: false, successCount, failCount, error: String(e) }
|
||||
|
||||
@@ -1208,6 +1208,30 @@ class HttpService {
|
||||
const sessionDir = path.join(this.getApiMediaExportPath(), this.sanitizeFileName(talker, 'session'))
|
||||
this.ensureDir(sessionDir)
|
||||
|
||||
// 预热图片 hardlink 索引,减少逐条导出时的查找开销
|
||||
if (options.exportImages) {
|
||||
const imageMd5Set = new Set<string>()
|
||||
for (const msg of messages) {
|
||||
if (msg.localType !== 3) continue
|
||||
const imageMd5 = String(msg.imageMd5 || '').trim().toLowerCase()
|
||||
if (imageMd5) {
|
||||
imageMd5Set.add(imageMd5)
|
||||
continue
|
||||
}
|
||||
const imageDatName = String(msg.imageDatName || '').trim().toLowerCase()
|
||||
if (/^[a-f0-9]{32}$/i.test(imageDatName)) {
|
||||
imageMd5Set.add(imageDatName)
|
||||
}
|
||||
}
|
||||
if (imageMd5Set.size > 0) {
|
||||
try {
|
||||
await imageDecryptService.preloadImageHardlinkMd5s(Array.from(imageMd5Set))
|
||||
} catch {
|
||||
// ignore preload failures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
const exported = await this.exportMediaForMessage(msg, talker, sessionDir, options)
|
||||
if (exported) {
|
||||
@@ -1230,27 +1254,54 @@ class HttpService {
|
||||
sessionId: talker,
|
||||
imageMd5: msg.imageMd5,
|
||||
imageDatName: msg.imageDatName,
|
||||
force: true
|
||||
createTime: msg.createTime,
|
||||
force: true,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
disableUpdateCheck: true,
|
||||
suppressEvents: true
|
||||
})
|
||||
if (result.success && result.localPath) {
|
||||
let imagePath = result.localPath
|
||||
|
||||
let imagePath = result.success ? result.localPath : undefined
|
||||
if (!imagePath) {
|
||||
try {
|
||||
const cached = await imageDecryptService.resolveCachedImage({
|
||||
sessionId: talker,
|
||||
imageMd5: msg.imageMd5,
|
||||
imageDatName: msg.imageDatName,
|
||||
createTime: msg.createTime,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
disableUpdateCheck: true,
|
||||
suppressEvents: true
|
||||
})
|
||||
if (cached.success && cached.localPath) {
|
||||
imagePath = cached.localPath
|
||||
}
|
||||
} catch {
|
||||
// ignore resolve failures
|
||||
}
|
||||
}
|
||||
|
||||
if (imagePath) {
|
||||
if (imagePath.startsWith('data:')) {
|
||||
const base64Match = imagePath.match(/^data:[^;]+;base64,(.+)$/)
|
||||
if (base64Match) {
|
||||
const imageBuffer = Buffer.from(base64Match[1], 'base64')
|
||||
const ext = this.detectImageExt(imageBuffer)
|
||||
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
|
||||
const fileName = `${fileBase}${ext}`
|
||||
const targetDir = path.join(sessionDir, 'images')
|
||||
const fullPath = path.join(targetDir, fileName)
|
||||
this.ensureDir(targetDir)
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
fs.writeFileSync(fullPath, imageBuffer)
|
||||
}
|
||||
const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}`
|
||||
return { kind: 'image', fileName, fullPath, relativePath }
|
||||
if (!base64Match) return null
|
||||
const imageBuffer = Buffer.from(base64Match[1], 'base64')
|
||||
const ext = this.detectImageExt(imageBuffer)
|
||||
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
|
||||
const fileName = `${fileBase}${ext}`
|
||||
const targetDir = path.join(sessionDir, 'images')
|
||||
const fullPath = path.join(targetDir, fileName)
|
||||
this.ensureDir(targetDir)
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
fs.writeFileSync(fullPath, imageBuffer)
|
||||
}
|
||||
} else if (fs.existsSync(imagePath)) {
|
||||
const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}`
|
||||
return { kind: 'image', fileName, fullPath, relativePath }
|
||||
}
|
||||
|
||||
if (fs.existsSync(imagePath)) {
|
||||
const imageBuffer = fs.readFileSync(imagePath)
|
||||
const ext = this.detectImageExt(imageBuffer)
|
||||
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ type PreloadImagePayload = {
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
createTime?: number
|
||||
}
|
||||
|
||||
type PreloadOptions = {
|
||||
@@ -74,15 +75,24 @@ export class ImagePreloadService {
|
||||
sessionId: task.sessionId,
|
||||
imageMd5: task.imageMd5,
|
||||
imageDatName: task.imageDatName,
|
||||
createTime: task.createTime,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
disableUpdateCheck: !task.allowDecrypt,
|
||||
allowCacheIndex: task.allowCacheIndex
|
||||
allowCacheIndex: task.allowCacheIndex,
|
||||
suppressEvents: true
|
||||
})
|
||||
if (cached.success) return
|
||||
if (!task.allowDecrypt) return
|
||||
await imageDecryptService.decryptImage({
|
||||
sessionId: task.sessionId,
|
||||
imageMd5: task.imageMd5,
|
||||
imageDatName: task.imageDatName
|
||||
imageDatName: task.imageDatName,
|
||||
createTime: task.createTime,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
disableUpdateCheck: true,
|
||||
suppressEvents: true
|
||||
})
|
||||
} catch {
|
||||
// ignore preload failures
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/**
|
||||
* insightService.ts
|
||||
*
|
||||
* AI 见解后台服务:
|
||||
@@ -21,6 +21,7 @@ import { URL } from 'url'
|
||||
import { app, Notification } from 'electron'
|
||||
import { ConfigService } from './config'
|
||||
import { chatService, ChatSession, Message } from './chatService'
|
||||
import { weiboService } from './social/weiboService'
|
||||
|
||||
// ─── 常量 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -35,7 +36,9 @@ const SILENCE_SCAN_INITIAL_DELAY_MS = 3 * 60 * 1000
|
||||
|
||||
/** 单次 API 请求超时(毫秒) */
|
||||
const API_TIMEOUT_MS = 45_000
|
||||
const API_MAX_TOKENS = 200
|
||||
const API_MAX_TOKENS_DEFAULT = 200
|
||||
const API_MAX_TOKENS_MIN = 1
|
||||
const API_MAX_TOKENS_MAX = 65_535
|
||||
const API_TEMPERATURE = 0.7
|
||||
|
||||
/** 沉默天数阈值默认值 */
|
||||
@@ -46,6 +49,11 @@ const INSIGHT_CONFIG_KEYS = new Set([
|
||||
'aiModelApiBaseUrl',
|
||||
'aiModelApiKey',
|
||||
'aiModelApiModel',
|
||||
'aiModelApiMaxTokens',
|
||||
'aiInsightAllowSocialContext',
|
||||
'aiInsightSocialContextCount',
|
||||
'aiInsightWeiboCookie',
|
||||
'aiInsightWeiboBindings',
|
||||
'dbPath',
|
||||
'decryptKey',
|
||||
'myWxid'
|
||||
@@ -62,6 +70,7 @@ interface SharedAiModelConfig {
|
||||
apiBaseUrl: string
|
||||
apiKey: string
|
||||
model: string
|
||||
maxTokens: number
|
||||
}
|
||||
|
||||
// ─── 日志 ─────────────────────────────────────────────────────────────────────
|
||||
@@ -166,6 +175,27 @@ function formatTimestamp(ts: number): string {
|
||||
return new Date(ts).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function formatPromptCurrentTime(date: Date = new Date()): string {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
return `当前系统时间:${year}年${month}月${day}日 ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
function appendPromptCurrentTime(prompt: string): string {
|
||||
const base = String(prompt || '').trimEnd()
|
||||
if (!base) return formatPromptCurrentTime()
|
||||
return `${base}\n\n${formatPromptCurrentTime()}`
|
||||
}
|
||||
|
||||
function normalizeApiMaxTokens(value: unknown): number {
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric)) return API_MAX_TOKENS_DEFAULT
|
||||
return Math.min(API_MAX_TOKENS_MAX, Math.max(API_MAX_TOKENS_MIN, Math.floor(numeric)))
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 OpenAI 兼容 API(非流式),返回模型第一条消息内容。
|
||||
* 使用 Node 原生 https/http 模块,无需任何第三方 SDK。
|
||||
@@ -175,7 +205,8 @@ function callApi(
|
||||
apiKey: string,
|
||||
model: string,
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
timeoutMs: number = API_TIMEOUT_MS
|
||||
timeoutMs: number = API_TIMEOUT_MS,
|
||||
maxTokens: number = API_MAX_TOKENS_DEFAULT
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions')
|
||||
@@ -190,7 +221,7 @@ function callApi(
|
||||
const body = JSON.stringify({
|
||||
model,
|
||||
messages,
|
||||
max_tokens: API_MAX_TOKENS,
|
||||
max_tokens: normalizeApiMaxTokens(maxTokens),
|
||||
temperature: API_TEMPERATURE,
|
||||
stream: false
|
||||
})
|
||||
@@ -318,6 +349,10 @@ class InsightService {
|
||||
if (!INSIGHT_CONFIG_KEYS.has(normalizedKey)) return
|
||||
|
||||
// 数据库相关配置变更后,丢弃缓存并强制下次重连
|
||||
if (normalizedKey === 'aiInsightAllowSocialContext' || normalizedKey === 'aiInsightSocialContextCount' || normalizedKey === 'aiInsightWeiboCookie' || normalizedKey === 'aiInsightWeiboBindings') {
|
||||
weiboService.clearCache()
|
||||
}
|
||||
|
||||
if (normalizedKey === 'dbPath' || normalizedKey === 'decryptKey' || normalizedKey === 'myWxid') {
|
||||
this.clearRuntimeCache()
|
||||
}
|
||||
@@ -350,6 +385,7 @@ class InsightService {
|
||||
this.lastSeenTimestamp.clear()
|
||||
this.todayTriggers.clear()
|
||||
this.todayDate = getStartOfDay()
|
||||
weiboService.clearCache()
|
||||
}
|
||||
|
||||
private clearTimers(): void {
|
||||
@@ -392,7 +428,7 @@ class InsightService {
|
||||
* 供设置页"测试连接"按钮调用。
|
||||
*/
|
||||
async testConnection(): Promise<{ success: boolean; message: string }> {
|
||||
const { apiBaseUrl, apiKey, model } = this.getSharedAiModelConfig()
|
||||
const { apiBaseUrl, apiKey, model, maxTokens } = this.getSharedAiModelConfig()
|
||||
|
||||
if (!apiBaseUrl || !apiKey) {
|
||||
return { success: false, message: '请先填写 API 地址和 API Key' }
|
||||
@@ -400,13 +436,14 @@ class InsightService {
|
||||
|
||||
try {
|
||||
const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions')
|
||||
const requestMessages = [{ role: 'user', content: '请回复"连接成功"四个字。' }]
|
||||
const requestMessages = [{ role: 'user', content: appendPromptCurrentTime('请回复"连接成功"四个字。') }]
|
||||
insightDebugSection(
|
||||
'INFO',
|
||||
'AI 测试连接请求',
|
||||
[
|
||||
`Endpoint: ${endpoint}`,
|
||||
`Model: ${model}`,
|
||||
`Max Tokens: ${maxTokens}`,
|
||||
'',
|
||||
'用户提示词:',
|
||||
requestMessages[0].content
|
||||
@@ -418,7 +455,8 @@ class InsightService {
|
||||
apiKey,
|
||||
model,
|
||||
requestMessages,
|
||||
15_000
|
||||
15_000,
|
||||
maxTokens
|
||||
)
|
||||
insightDebugSection('INFO', 'AI 测试连接输出原文', result)
|
||||
return { success: true, message: `连接成功,模型回复:${result.slice(0, 50)}` }
|
||||
@@ -505,7 +543,7 @@ class InsightService {
|
||||
return { success: false, message: '请先在设置中开启「AI 足迹总结」' }
|
||||
}
|
||||
|
||||
const { apiBaseUrl, apiKey, model } = this.getSharedAiModelConfig()
|
||||
const { apiBaseUrl, apiKey, model, maxTokens } = this.getSharedAiModelConfig()
|
||||
if (!apiBaseUrl || !apiKey) {
|
||||
return { success: false, message: '请先填写通用 AI 模型配置(API 地址和 Key)' }
|
||||
}
|
||||
@@ -545,7 +583,7 @@ class InsightService {
|
||||
const customPrompt = String(this.config.get('aiFootprintSystemPrompt') || '').trim()
|
||||
const systemPrompt = customPrompt || defaultSystemPrompt
|
||||
|
||||
const userPrompt = `统计范围:${rangeLabel}
|
||||
const userPromptBase = `统计范围:${rangeLabel}
|
||||
有聊天的人数:${Number(summary.private_inbound_people) || 0}
|
||||
我有回复的人数:${Number(summary.private_outbound_people) || 0}
|
||||
回复率:${(((Number(summary.private_reply_rate) || 0) * 100)).toFixed(1)}%
|
||||
@@ -559,6 +597,7 @@ ${topPrivateText}
|
||||
${topMentionText}
|
||||
|
||||
请给出足迹复盘(2-3句,含建议):`
|
||||
const userPrompt = appendPromptCurrentTime(userPromptBase)
|
||||
|
||||
try {
|
||||
const result = await callApi(
|
||||
@@ -569,7 +608,8 @@ ${topMentionText}
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt }
|
||||
],
|
||||
25_000
|
||||
25_000,
|
||||
maxTokens
|
||||
)
|
||||
const insight = result.trim().slice(0, 400)
|
||||
if (!insight) return { success: false, message: '模型返回为空' }
|
||||
@@ -601,8 +641,9 @@ ${topMentionText}
|
||||
|| this.config.get('aiInsightApiModel')
|
||||
|| 'gpt-4o-mini'
|
||||
).trim() || 'gpt-4o-mini'
|
||||
const maxTokens = normalizeApiMaxTokens(this.config.get('aiModelApiMaxTokens'))
|
||||
|
||||
return { apiBaseUrl, apiKey, model }
|
||||
return { apiBaseUrl, apiKey, model, maxTokens }
|
||||
}
|
||||
|
||||
private looksLikeWxid(text: string): boolean {
|
||||
@@ -786,6 +827,50 @@ ${topMentionText}
|
||||
return total
|
||||
}
|
||||
|
||||
private formatWeiboTimestamp(raw: string): string {
|
||||
const parsed = Date.parse(String(raw || ''))
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return String(raw || '').trim()
|
||||
}
|
||||
return new Date(parsed).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
private async getSocialContextSection(sessionId: string): Promise<string> {
|
||||
const allowSocialContext = this.config.get('aiInsightAllowSocialContext') === true
|
||||
if (!allowSocialContext) return ''
|
||||
|
||||
const rawCookie = String(this.config.get('aiInsightWeiboCookie') || '').trim()
|
||||
const hasCookie = rawCookie.length > 0
|
||||
|
||||
const bindings =
|
||||
(this.config.get('aiInsightWeiboBindings') as Record<string, { uid?: string; screenName?: string }> | undefined) || {}
|
||||
const binding = bindings[sessionId]
|
||||
const uid = String(binding?.uid || '').trim()
|
||||
if (!uid) return ''
|
||||
|
||||
const socialCountRaw = Number(this.config.get('aiInsightSocialContextCount') || 3)
|
||||
const socialCount = Math.max(1, Math.min(5, Math.floor(socialCountRaw) || 3))
|
||||
|
||||
try {
|
||||
const posts = await weiboService.fetchRecentPosts(uid, rawCookie, socialCount)
|
||||
if (posts.length === 0) return ''
|
||||
|
||||
const lines = posts.map((post) => {
|
||||
const time = this.formatWeiboTimestamp(post.createdAt)
|
||||
const text = post.text.length > 180 ? `${post.text.slice(0, 180)}...` : post.text
|
||||
return `[微博 ${time}] ${text}`
|
||||
})
|
||||
insightLog('INFO', `已加载 ${lines.length} 条微博公开内容 (uid=${uid})`)
|
||||
const riskHint = hasCookie
|
||||
? ''
|
||||
: '\n提示:未配置微博 Cookie,使用移动端公开接口抓取,可能因平台风控导致获取失败或内容较少。'
|
||||
return `近期公开社交平台内容(来源:微博,最近 ${lines.length} 条):\n${lines.join('\n')}${riskHint}`
|
||||
} catch (error) {
|
||||
insightLog('WARN', `拉取微博公开内容失败 (uid=${uid}): ${(error as Error).message}`)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// ── 沉默联系人扫描 ──────────────────────────────────────────────────────────
|
||||
|
||||
private scheduleSilenceScan(): void {
|
||||
@@ -996,7 +1081,7 @@ ${topMentionText}
|
||||
if (!sessionId) return
|
||||
if (!this.isEnabled()) return
|
||||
|
||||
const { apiBaseUrl, apiKey, model } = this.getSharedAiModelConfig()
|
||||
const { apiBaseUrl, apiKey, model, maxTokens } = this.getSharedAiModelConfig()
|
||||
const allowContext = this.config.get('aiInsightAllowContext') as boolean
|
||||
const contextCount = (this.config.get('aiInsightContextCount') as number) || 40
|
||||
const resolvedDisplayName = await this.resolveInsightSessionDisplayName(sessionId, displayName)
|
||||
@@ -1028,6 +1113,8 @@ ${topMentionText}
|
||||
}
|
||||
}
|
||||
|
||||
const socialContextSection = await this.getSocialContextSection(sessionId)
|
||||
|
||||
// ── 默认 system prompt(稳定内容,有利于 provider 端 prompt cache 命中)────
|
||||
const DEFAULT_SYSTEM_PROMPT = `你是用户的私人关系观察助手,名叫"见解"。你的任务是主动提供有价值的观察和建议。
|
||||
|
||||
@@ -1055,13 +1142,15 @@ ${topMentionText}
|
||||
|
||||
const globalStatsDesc = `今天全部联系人合计已触发 ${totalTodayTriggers} 条见解。`
|
||||
|
||||
const userPrompt = [
|
||||
const userPromptBase = [
|
||||
`触发原因:${triggerDesc}`,
|
||||
`时间统计:${todayStatsDesc}`,
|
||||
`全局统计:${globalStatsDesc}`,
|
||||
contextSection,
|
||||
socialContextSection,
|
||||
'请给出你的见解(≤80字):'
|
||||
].filter(Boolean).join('\n\n')
|
||||
const userPrompt = appendPromptCurrentTime(userPromptBase)
|
||||
|
||||
const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions')
|
||||
const requestMessages = [
|
||||
@@ -1076,6 +1165,7 @@ ${topMentionText}
|
||||
[
|
||||
`接口地址:${endpoint}`,
|
||||
`模型:${model}`,
|
||||
`Max Tokens:${maxTokens}`,
|
||||
`触发原因:${triggerReason}`,
|
||||
`上下文开关:${allowContext ? '开启' : '关闭'}`,
|
||||
`上下文条数:${contextCount}`,
|
||||
@@ -1093,7 +1183,9 @@ ${topMentionText}
|
||||
apiBaseUrl,
|
||||
apiKey,
|
||||
model,
|
||||
requestMessages
|
||||
requestMessages,
|
||||
API_TIMEOUT_MS,
|
||||
maxTokens
|
||||
)
|
||||
|
||||
insightLog('INFO', `API 返回原文: ${result.slice(0, 150)}`)
|
||||
@@ -1190,3 +1282,5 @@ ${topMentionText}
|
||||
}
|
||||
|
||||
export const insightService = new InsightService()
|
||||
|
||||
|
||||
|
||||
@@ -478,8 +478,6 @@ export class KeyServiceMac {
|
||||
'return "WF_ERR::" & errNum & "::" & errMsg & "::" & (pr as text)',
|
||||
'end try'
|
||||
]
|
||||
onStatus?.('已准备就绪,现在登录微信或退出登录后重新登录微信', 0)
|
||||
|
||||
let stdout = ''
|
||||
try {
|
||||
const result = await execFileAsync('/usr/bin/osascript', scriptLines.flatMap(line => ['-e', line]), {
|
||||
|
||||
@@ -2,6 +2,10 @@ import { ConfigService } from './config'
|
||||
import { chatService, type ChatSession, type Message } from './chatService'
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { httpService } from './httpService'
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
import { createHash } from 'crypto'
|
||||
import { pathToFileURL } from 'url'
|
||||
|
||||
interface SessionBaseline {
|
||||
lastTimestamp: number
|
||||
@@ -33,6 +37,8 @@ class MessagePushService {
|
||||
private readonly sessionBaseline = new Map<string, SessionBaseline>()
|
||||
private readonly recentMessageKeys = new Map<string, number>()
|
||||
private readonly groupNicknameCache = new Map<string, { nicknames: Record<string, string>; updatedAt: number }>()
|
||||
private readonly pushAvatarCacheDir: string
|
||||
private readonly pushAvatarDataCache = new Map<string, string>()
|
||||
private readonly debounceMs = 350
|
||||
private readonly recentMessageTtlMs = 10 * 60 * 1000
|
||||
private readonly groupNicknameCacheTtlMs = 5 * 60 * 1000
|
||||
@@ -45,6 +51,7 @@ class MessagePushService {
|
||||
|
||||
constructor() {
|
||||
this.configService = ConfigService.getInstance()
|
||||
this.pushAvatarCacheDir = path.join(this.configService.getCacheBasePath(), 'push-avatar-files')
|
||||
}
|
||||
|
||||
start(): void {
|
||||
@@ -53,6 +60,13 @@ class MessagePushService {
|
||||
void this.refreshConfiguration('startup')
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.started = false
|
||||
this.processing = false
|
||||
this.rerunRequested = false
|
||||
this.resetRuntimeState()
|
||||
}
|
||||
|
||||
handleDbMonitorChange(type: string, json: string): void {
|
||||
if (!this.started) return
|
||||
if (!this.isPushEnabled()) return
|
||||
@@ -303,12 +317,13 @@ class MessagePushService {
|
||||
const groupInfo = await chatService.getContactAvatar(sessionId)
|
||||
const groupName = session.displayName || groupInfo?.displayName || sessionId
|
||||
const sourceName = await this.resolveGroupSourceName(sessionId, message, session)
|
||||
const avatarUrl = await this.normalizePushAvatarUrl(session.avatarUrl || groupInfo?.avatarUrl)
|
||||
return {
|
||||
event: 'message.new',
|
||||
sessionId,
|
||||
sessionType,
|
||||
messageKey,
|
||||
avatarUrl: session.avatarUrl || groupInfo?.avatarUrl,
|
||||
avatarUrl,
|
||||
groupName,
|
||||
sourceName,
|
||||
content
|
||||
@@ -316,17 +331,63 @@ class MessagePushService {
|
||||
}
|
||||
|
||||
const contactInfo = await chatService.getContactAvatar(sessionId)
|
||||
const avatarUrl = await this.normalizePushAvatarUrl(session.avatarUrl || contactInfo?.avatarUrl)
|
||||
return {
|
||||
event: 'message.new',
|
||||
sessionId,
|
||||
sessionType,
|
||||
messageKey,
|
||||
avatarUrl: session.avatarUrl || contactInfo?.avatarUrl,
|
||||
avatarUrl,
|
||||
sourceName: session.displayName || contactInfo?.displayName || sessionId,
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
private async normalizePushAvatarUrl(avatarUrl?: string): Promise<string | undefined> {
|
||||
const normalized = String(avatarUrl || '').trim()
|
||||
if (!normalized) return undefined
|
||||
if (!normalized.startsWith('data:image/')) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
const cached = this.pushAvatarDataCache.get(normalized)
|
||||
if (cached) return cached
|
||||
|
||||
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/i.exec(normalized)
|
||||
if (!match) return undefined
|
||||
|
||||
try {
|
||||
const mimeType = match[1].toLowerCase()
|
||||
const base64Data = match[2]
|
||||
const imageBuffer = Buffer.from(base64Data, 'base64')
|
||||
if (!imageBuffer.length) return undefined
|
||||
|
||||
const ext = this.getImageExtFromMime(mimeType)
|
||||
const hash = createHash('sha1').update(normalized).digest('hex')
|
||||
const filePath = path.join(this.pushAvatarCacheDir, `avatar_${hash}.${ext}`)
|
||||
|
||||
await fs.mkdir(this.pushAvatarCacheDir, { recursive: true })
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
} catch {
|
||||
await fs.writeFile(filePath, imageBuffer)
|
||||
}
|
||||
|
||||
const fileUrl = pathToFileURL(filePath).toString()
|
||||
this.pushAvatarDataCache.set(normalized, fileUrl)
|
||||
return fileUrl
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
private getImageExtFromMime(mimeType: string): string {
|
||||
if (mimeType === 'image/png') return 'png'
|
||||
if (mimeType === 'image/gif') return 'gif'
|
||||
if (mimeType === 'image/webp') return 'webp'
|
||||
return 'jpg'
|
||||
}
|
||||
|
||||
private getSessionType(sessionId: string, session: ChatSession): MessagePushPayload['sessionType'] {
|
||||
if (sessionId.endsWith('@chatroom')) {
|
||||
return 'group'
|
||||
|
||||
110
electron/services/nativeImageDecrypt.ts
Normal file
110
electron/services/nativeImageDecrypt.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
type NativeDecryptResult = {
|
||||
data: Buffer
|
||||
ext: string
|
||||
isWxgf?: boolean
|
||||
is_wxgf?: boolean
|
||||
}
|
||||
|
||||
type NativeAddon = {
|
||||
decryptDatNative: (inputPath: string, xorKey: number, aesKey?: string) => NativeDecryptResult
|
||||
}
|
||||
|
||||
let cachedAddon: NativeAddon | null | undefined
|
||||
|
||||
function shouldEnableNative(): boolean {
|
||||
return process.env.WEFLOW_IMAGE_NATIVE !== '0'
|
||||
}
|
||||
|
||||
function expandAsarCandidates(filePath: string): string[] {
|
||||
if (!filePath.includes('app.asar') || filePath.includes('app.asar.unpacked')) {
|
||||
return [filePath]
|
||||
}
|
||||
return [filePath.replace('app.asar', 'app.asar.unpacked'), filePath]
|
||||
}
|
||||
|
||||
function getPlatformDir(): string {
|
||||
if (process.platform === 'win32') return 'win32'
|
||||
if (process.platform === 'darwin') return 'macos'
|
||||
if (process.platform === 'linux') return 'linux'
|
||||
return process.platform
|
||||
}
|
||||
|
||||
function getArchDir(): string {
|
||||
if (process.arch === 'x64') return 'x64'
|
||||
if (process.arch === 'arm64') return 'arm64'
|
||||
return process.arch
|
||||
}
|
||||
|
||||
function getAddonCandidates(): string[] {
|
||||
const platformDir = getPlatformDir()
|
||||
const archDir = getArchDir()
|
||||
const cwd = process.cwd()
|
||||
const fileNames = [
|
||||
`weflow-image-native-${platformDir}-${archDir}.node`
|
||||
]
|
||||
const roots = [
|
||||
join(cwd, 'resources', 'wedecrypt', platformDir, archDir),
|
||||
...(process.resourcesPath
|
||||
? [
|
||||
join(process.resourcesPath, 'resources', 'wedecrypt', platformDir, archDir),
|
||||
join(process.resourcesPath, 'wedecrypt', platformDir, archDir)
|
||||
]
|
||||
: [])
|
||||
]
|
||||
const candidates = roots.flatMap((root) => fileNames.map((name) => join(root, name)))
|
||||
return Array.from(new Set(candidates.flatMap(expandAsarCandidates)))
|
||||
}
|
||||
|
||||
function loadAddon(): NativeAddon | null {
|
||||
if (!shouldEnableNative()) return null
|
||||
if (cachedAddon !== undefined) return cachedAddon
|
||||
|
||||
for (const candidate of getAddonCandidates()) {
|
||||
if (!existsSync(candidate)) continue
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const addon = require(candidate) as NativeAddon
|
||||
if (addon && typeof addon.decryptDatNative === 'function') {
|
||||
cachedAddon = addon
|
||||
return addon
|
||||
}
|
||||
} catch {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
|
||||
cachedAddon = null
|
||||
return null
|
||||
}
|
||||
|
||||
export function nativeAddonLocation(): string | null {
|
||||
for (const candidate of getAddonCandidates()) {
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function decryptDatViaNative(
|
||||
inputPath: string,
|
||||
xorKey: number,
|
||||
aesKey?: string
|
||||
): { data: Buffer; ext: string; isWxgf: boolean } | null {
|
||||
const addon = loadAddon()
|
||||
if (!addon) return null
|
||||
|
||||
try {
|
||||
const result = addon.decryptDatNative(inputPath, xorKey, aesKey)
|
||||
const isWxgf = Boolean(result?.isWxgf ?? result?.is_wxgf)
|
||||
if (!result || !Buffer.isBuffer(result.data)) return null
|
||||
const rawExt = typeof result.ext === 'string' && result.ext.trim()
|
||||
? result.ext.trim().toLowerCase()
|
||||
: ''
|
||||
const ext = rawExt ? (rawExt.startsWith('.') ? rawExt : `.${rawExt}`) : ''
|
||||
return { data: result.data, ext, isWxgf }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { wcdbService } from './wcdbService'
|
||||
import { ConfigService } from './config'
|
||||
import { ContactCacheService } from './contactCacheService'
|
||||
import { app } from 'electron'
|
||||
import { existsSync, mkdirSync } from 'fs'
|
||||
import { existsSync, mkdirSync, unlinkSync } from 'fs'
|
||||
import { readFile, writeFile, mkdir } from 'fs/promises'
|
||||
import { basename, join } from 'path'
|
||||
import crypto from 'crypto'
|
||||
@@ -174,8 +174,17 @@ const detectImageMime = (buf: Buffer, fallback: string = 'image/jpeg') => {
|
||||
// BMP
|
||||
if (buf[0] === 0x42 && buf[1] === 0x4d) return 'image/bmp'
|
||||
|
||||
// MP4: 00 00 00 18 / 20 / ... + 'ftyp'
|
||||
if (buf.length > 8 && buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) return 'video/mp4'
|
||||
// ISO BMFF 家族:优先识别 AVIF/HEIF,避免误判为 MP4
|
||||
if (buf.length > 12 && buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) {
|
||||
const ftypWindow = buf.subarray(8, Math.min(buf.length, 64)).toString('ascii').toLowerCase()
|
||||
if (ftypWindow.includes('avif') || ftypWindow.includes('avis')) return 'image/avif'
|
||||
if (
|
||||
ftypWindow.includes('heic') || ftypWindow.includes('heix') ||
|
||||
ftypWindow.includes('hevc') || ftypWindow.includes('hevx') ||
|
||||
ftypWindow.includes('mif1') || ftypWindow.includes('msf1')
|
||||
) return 'image/heic'
|
||||
return 'video/mp4'
|
||||
}
|
||||
|
||||
// Fallback logic for video
|
||||
if (fallback.includes('video') || fallback.includes('mp4')) return 'video/mp4'
|
||||
@@ -1231,7 +1240,19 @@ class SnsService {
|
||||
const cacheKey = `${url}|${key ?? ''}`
|
||||
|
||||
if (this.imageCache.has(cacheKey)) {
|
||||
return { success: true, dataUrl: this.imageCache.get(cacheKey) }
|
||||
const cachedDataUrl = this.imageCache.get(cacheKey) || ''
|
||||
const base64Part = cachedDataUrl.split(',')[1] || ''
|
||||
if (base64Part) {
|
||||
try {
|
||||
const cachedBuf = Buffer.from(base64Part, 'base64')
|
||||
if (detectImageMime(cachedBuf, '').startsWith('image/')) {
|
||||
return { success: true, dataUrl: cachedDataUrl }
|
||||
}
|
||||
} catch {
|
||||
// ignore and fall through to refetch
|
||||
}
|
||||
}
|
||||
this.imageCache.delete(cacheKey)
|
||||
}
|
||||
|
||||
const result = await this.fetchAndDecryptImage(url, key)
|
||||
@@ -1244,6 +1265,9 @@ class SnsService {
|
||||
}
|
||||
|
||||
if (result.data && result.contentType) {
|
||||
if (!detectImageMime(result.data, '').startsWith('image/')) {
|
||||
return { success: false, error: '无效图片数据(可能密钥不匹配或缓存损坏)' }
|
||||
}
|
||||
const dataUrl = `data:${result.contentType};base64,${result.data.toString('base64')}`
|
||||
this.imageCache.set(cacheKey, dataUrl)
|
||||
return { success: true, dataUrl }
|
||||
@@ -1853,8 +1877,13 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
||||
}
|
||||
|
||||
const data = await readFile(cachePath)
|
||||
const contentType = detectImageMime(data)
|
||||
return { success: true, data, contentType, cachePath }
|
||||
if (!detectImageMime(data, '').startsWith('image/')) {
|
||||
// 旧版本可能把未解密内容写入缓存;发现无效图片头时删除并重新拉取。
|
||||
try { unlinkSync(cachePath) } catch { }
|
||||
} else {
|
||||
const contentType = detectImageMime(data)
|
||||
return { success: true, data, contentType, cachePath }
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[SnsService] 读取缓存失败: ${cachePath}`, e)
|
||||
}
|
||||
@@ -2006,6 +2035,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
||||
const xEnc = String(res.headers['x-enc'] || '').trim()
|
||||
|
||||
let decoded = raw
|
||||
const rawMagicMime = detectImageMime(raw, '')
|
||||
|
||||
// 图片逻辑
|
||||
const shouldDecrypt = (xEnc === '1' || !!key) && key !== undefined && key !== null && String(key).trim().length > 0
|
||||
@@ -2023,13 +2053,24 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
||||
decrypted[i] = raw[i] ^ keystream[i]
|
||||
}
|
||||
|
||||
decoded = decrypted
|
||||
const decryptedMagicMime = detectImageMime(decrypted, '')
|
||||
if (decryptedMagicMime.startsWith('image/')) {
|
||||
decoded = decrypted
|
||||
} else if (!rawMagicMime.startsWith('image/')) {
|
||||
decoded = decrypted
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[SnsService] TS Decrypt Error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const decodedMagicMime = detectImageMime(decoded, '')
|
||||
if (!decodedMagicMime.startsWith('image/')) {
|
||||
resolve({ success: false, error: '图片解密失败:无法识别图片格式' })
|
||||
return
|
||||
}
|
||||
|
||||
// 写入磁盘缓存
|
||||
try {
|
||||
await writeFile(cachePath, decoded)
|
||||
@@ -2063,6 +2104,15 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
||||
if (buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) return true
|
||||
if (buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46
|
||||
&& buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) return true
|
||||
if (buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) {
|
||||
const ftypWindow = buf.subarray(8, Math.min(buf.length, 64)).toString('ascii').toLowerCase()
|
||||
if (ftypWindow.includes('avif') || ftypWindow.includes('avis')) return true
|
||||
if (
|
||||
ftypWindow.includes('heic') || ftypWindow.includes('heix') ||
|
||||
ftypWindow.includes('hevc') || ftypWindow.includes('hevx') ||
|
||||
ftypWindow.includes('mif1') || ftypWindow.includes('msf1')
|
||||
) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
367
electron/services/social/weiboService.ts
Normal file
367
electron/services/social/weiboService.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import https from 'https'
|
||||
import { createHash } from 'crypto'
|
||||
import { URL } from 'url'
|
||||
|
||||
const WEIBO_TIMEOUT_MS = 10_000
|
||||
const WEIBO_MAX_POSTS = 5
|
||||
const WEIBO_CACHE_TTL_MS = 30 * 60 * 1000
|
||||
const WEIBO_USER_AGENT =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36'
|
||||
const WEIBO_MOBILE_USER_AGENT =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'
|
||||
|
||||
interface BrowserCookieEntry {
|
||||
domain?: string
|
||||
name?: string
|
||||
value?: string
|
||||
}
|
||||
|
||||
interface WeiboUserInfo {
|
||||
id?: number | string
|
||||
screen_name?: string
|
||||
}
|
||||
|
||||
interface WeiboWaterFallItem {
|
||||
id?: number | string
|
||||
idstr?: string
|
||||
mblogid?: string
|
||||
created_at?: string
|
||||
text_raw?: string
|
||||
isLongText?: boolean
|
||||
user?: WeiboUserInfo
|
||||
retweeted_status?: WeiboWaterFallItem
|
||||
}
|
||||
|
||||
interface WeiboWaterFallResponse {
|
||||
ok?: number
|
||||
data?: {
|
||||
list?: WeiboWaterFallItem[]
|
||||
next_cursor?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface WeiboStatusShowResponse {
|
||||
id?: number | string
|
||||
idstr?: string
|
||||
mblogid?: string
|
||||
created_at?: string
|
||||
text_raw?: string
|
||||
user?: WeiboUserInfo
|
||||
retweeted_status?: WeiboWaterFallItem
|
||||
}
|
||||
|
||||
interface MWeiboCard {
|
||||
mblog?: WeiboWaterFallItem
|
||||
card_group?: MWeiboCard[]
|
||||
}
|
||||
|
||||
interface MWeiboContainerResponse {
|
||||
ok?: number
|
||||
data?: {
|
||||
cards?: MWeiboCard[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface WeiboRecentPost {
|
||||
id: string
|
||||
createdAt: string
|
||||
url: string
|
||||
text: string
|
||||
screenName?: string
|
||||
}
|
||||
|
||||
interface CachedRecentPosts {
|
||||
expiresAt: number
|
||||
posts: WeiboRecentPost[]
|
||||
}
|
||||
|
||||
function requestJson<T>(url: string, options: { cookie?: string; referer?: string; userAgent?: string }): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let urlObj: URL
|
||||
try {
|
||||
urlObj = new URL(url)
|
||||
} catch {
|
||||
reject(new Error(`无效的微博请求地址:${url}`))
|
||||
return
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
Referer: options.referer || 'https://weibo.com',
|
||||
'User-Agent': options.userAgent || WEIBO_USER_AGENT,
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
if (options.cookie) {
|
||||
headers.Cookie = options.cookie
|
||||
}
|
||||
|
||||
const req = https.request(
|
||||
{
|
||||
hostname: urlObj.hostname,
|
||||
port: urlObj.port || 443,
|
||||
path: urlObj.pathname + urlObj.search,
|
||||
method: 'GET',
|
||||
headers
|
||||
},
|
||||
(res) => {
|
||||
let raw = ''
|
||||
res.setEncoding('utf8')
|
||||
res.on('data', (chunk) => {
|
||||
raw += chunk
|
||||
})
|
||||
res.on('end', () => {
|
||||
const statusCode = res.statusCode || 0
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
reject(new Error(`微博接口返回异常状态码 ${statusCode}`))
|
||||
return
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(raw) as T)
|
||||
} catch {
|
||||
reject(new Error('微博接口返回了非 JSON 响应'))
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
req.setTimeout(WEIBO_TIMEOUT_MS, () => {
|
||||
req.destroy()
|
||||
reject(new Error('微博请求超时'))
|
||||
})
|
||||
|
||||
req.on('error', reject)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeCookieArray(entries: BrowserCookieEntry[]): string {
|
||||
const picked = new Map<string, string>()
|
||||
|
||||
for (const entry of entries) {
|
||||
const name = String(entry?.name || '').trim()
|
||||
const value = String(entry?.value || '').trim()
|
||||
const domain = String(entry?.domain || '').trim().toLowerCase()
|
||||
|
||||
if (!name || !value) continue
|
||||
if (domain && !domain.includes('weibo.com') && !domain.includes('weibo.cn')) continue
|
||||
|
||||
picked.set(name, value)
|
||||
}
|
||||
|
||||
return Array.from(picked.entries())
|
||||
.map(([name, value]) => `${name}=${value}`)
|
||||
.join('; ')
|
||||
}
|
||||
|
||||
export function normalizeWeiboCookieInput(rawInput: string): string {
|
||||
const trimmed = String(rawInput || '').trim()
|
||||
if (!trimmed) return ''
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown
|
||||
if (Array.isArray(parsed)) {
|
||||
const normalized = normalizeCookieArray(parsed as BrowserCookieEntry[])
|
||||
if (normalized) return normalized
|
||||
throw new Error('Cookie JSON 中未找到可用的微博 Cookie 项')
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof SyntaxError)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed.replace(/^Cookie:\s*/i, '').trim()
|
||||
}
|
||||
|
||||
function normalizeWeiboUid(input: string): string {
|
||||
const trimmed = String(input || '').trim()
|
||||
const directMatch = trimmed.match(/^\d{5,}$/)
|
||||
if (directMatch) return directMatch[0]
|
||||
|
||||
const linkMatch = trimmed.match(/(?:weibo\.com|m\.weibo\.cn)\/u\/(\d{5,})/i)
|
||||
if (linkMatch) return linkMatch[1]
|
||||
|
||||
throw new Error('请输入有效的微博 UID(纯数字)')
|
||||
}
|
||||
|
||||
function sanitizeWeiboText(text: string): string {
|
||||
return String(text || '')
|
||||
.replace(/\u200b|\u200c|\u200d|\ufeff/g, '')
|
||||
.replace(/https?:\/\/t\.cn\/[A-Za-z0-9]+/g, ' ')
|
||||
.replace(/ +/g, ' ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function mergeRetweetText(item: Pick<WeiboWaterFallItem, 'text_raw' | 'retweeted_status'>): string {
|
||||
const baseText = sanitizeWeiboText(item.text_raw || '')
|
||||
const retweetText = sanitizeWeiboText(item.retweeted_status?.text_raw || '')
|
||||
if (!retweetText) return baseText
|
||||
if (!baseText || baseText === '转发微博') return `转发:${retweetText}`
|
||||
return `${baseText}\n\n转发内容:${retweetText}`
|
||||
}
|
||||
|
||||
function buildCacheKey(uid: string, count: number, cookie: string): string {
|
||||
const cookieHash = createHash('sha1').update(cookie).digest('hex')
|
||||
return `${uid}:${count}:${cookieHash}`
|
||||
}
|
||||
|
||||
class WeiboService {
|
||||
private recentPostsCache = new Map<string, CachedRecentPosts>()
|
||||
|
||||
clearCache(): void {
|
||||
this.recentPostsCache.clear()
|
||||
}
|
||||
|
||||
async validateUid(
|
||||
uidInput: string,
|
||||
cookieInput: string
|
||||
): Promise<{ success: boolean; uid?: string; screenName?: string; error?: string }> {
|
||||
try {
|
||||
const uid = normalizeWeiboUid(uidInput)
|
||||
const cookie = normalizeWeiboCookieInput(cookieInput)
|
||||
if (!cookie) {
|
||||
return { success: true, uid }
|
||||
}
|
||||
|
||||
const timeline = await this.fetchTimeline(uid, cookie)
|
||||
const firstItem = timeline.data?.list?.[0]
|
||||
if (!firstItem) {
|
||||
return { success: false, error: '该微博账号暂无可读取的近期公开内容,或当前 Cookie 已失效' }
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
uid,
|
||||
screenName: firstItem.user?.screen_name
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: (error as Error).message || '微博 UID 校验失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fetchRecentPosts(
|
||||
uidInput: string,
|
||||
cookieInput: string,
|
||||
requestedCount: number
|
||||
): Promise<WeiboRecentPost[]> {
|
||||
const uid = normalizeWeiboUid(uidInput)
|
||||
const cookie = normalizeWeiboCookieInput(cookieInput)
|
||||
const hasCookie = Boolean(cookie)
|
||||
|
||||
const count = Math.max(1, Math.min(WEIBO_MAX_POSTS, Math.floor(Number(requestedCount) || 0)))
|
||||
const cacheKey = buildCacheKey(uid, count, hasCookie ? cookie : '__no_cookie_mobile__')
|
||||
const cached = this.recentPostsCache.get(cacheKey)
|
||||
const now = Date.now()
|
||||
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.posts
|
||||
}
|
||||
|
||||
const rawItems = hasCookie
|
||||
? (await this.fetchTimeline(uid, cookie)).data?.list || []
|
||||
: await this.fetchMobileTimeline(uid)
|
||||
const posts: WeiboRecentPost[] = []
|
||||
|
||||
for (const item of rawItems) {
|
||||
if (posts.length >= count) break
|
||||
|
||||
const id = String(item.idstr || item.id || '').trim()
|
||||
if (!id) continue
|
||||
|
||||
let text = mergeRetweetText(item)
|
||||
if (item.isLongText && hasCookie) {
|
||||
try {
|
||||
const detail = await this.fetchDetail(id, cookie)
|
||||
text = mergeRetweetText(detail)
|
||||
} catch {
|
||||
// 长文补抓失败时回退到列表摘要
|
||||
}
|
||||
}
|
||||
|
||||
text = sanitizeWeiboText(text)
|
||||
if (!text) continue
|
||||
|
||||
posts.push({
|
||||
id,
|
||||
createdAt: String(item.created_at || ''),
|
||||
url: `https://m.weibo.cn/detail/${id}`,
|
||||
text,
|
||||
screenName: item.user?.screen_name
|
||||
})
|
||||
}
|
||||
|
||||
this.recentPostsCache.set(cacheKey, {
|
||||
expiresAt: now + WEIBO_CACHE_TTL_MS,
|
||||
posts
|
||||
})
|
||||
|
||||
return posts
|
||||
}
|
||||
|
||||
private fetchTimeline(uid: string, cookie: string): Promise<WeiboWaterFallResponse> {
|
||||
return requestJson<WeiboWaterFallResponse>(
|
||||
`https://weibo.com/ajax/profile/getWaterFallContent?uid=${encodeURIComponent(uid)}`,
|
||||
{
|
||||
cookie,
|
||||
referer: `https://weibo.com/u/${encodeURIComponent(uid)}`
|
||||
}
|
||||
).then((response) => {
|
||||
if (response.ok !== 1 || !Array.isArray(response.data?.list)) {
|
||||
throw new Error('微博时间线获取失败,请检查 Cookie 是否仍然有效')
|
||||
}
|
||||
return response
|
||||
})
|
||||
}
|
||||
|
||||
private fetchMobileTimeline(uid: string): Promise<WeiboWaterFallItem[]> {
|
||||
const containerid = `107603${uid}`
|
||||
return requestJson<MWeiboContainerResponse>(
|
||||
`https://m.weibo.cn/api/container/getIndex?type=uid&value=${encodeURIComponent(uid)}&containerid=${encodeURIComponent(containerid)}`,
|
||||
{
|
||||
referer: `https://m.weibo.cn/u/${encodeURIComponent(uid)}`,
|
||||
userAgent: WEIBO_MOBILE_USER_AGENT
|
||||
}
|
||||
).then((response) => {
|
||||
if (response.ok !== 1 || !Array.isArray(response.data?.cards)) {
|
||||
throw new Error('微博时间线获取失败,请稍后重试')
|
||||
}
|
||||
|
||||
const rows: WeiboWaterFallItem[] = []
|
||||
for (const card of response.data.cards) {
|
||||
if (card?.mblog) rows.push(card.mblog)
|
||||
if (Array.isArray(card?.card_group)) {
|
||||
for (const subCard of card.card_group) {
|
||||
if (subCard?.mblog) rows.push(subCard.mblog)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
throw new Error('该微博账号暂无可读取的近期公开内容')
|
||||
}
|
||||
|
||||
return rows
|
||||
})
|
||||
}
|
||||
|
||||
private fetchDetail(id: string, cookie: string): Promise<WeiboStatusShowResponse> {
|
||||
return requestJson<WeiboStatusShowResponse>(
|
||||
`https://weibo.com/ajax/statuses/show?id=${encodeURIComponent(id)}&isGetLongText=true`,
|
||||
{
|
||||
cookie,
|
||||
referer: `https://weibo.com/detail/${encodeURIComponent(id)}`
|
||||
}
|
||||
).then((response) => {
|
||||
if (!response || (!response.id && !response.idstr)) {
|
||||
throw new Error('微博详情获取失败')
|
||||
}
|
||||
return response
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const weiboService = new WeiboService()
|
||||
@@ -1,8 +1,6 @@
|
||||
import { join } from 'path'
|
||||
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync, unlinkSync } from 'fs'
|
||||
import { spawn } from 'child_process'
|
||||
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs'
|
||||
import { pathToFileURL } from 'url'
|
||||
import crypto from 'crypto'
|
||||
import { app } from 'electron'
|
||||
import { ConfigService } from './config'
|
||||
import { wcdbService } from './wcdbService'
|
||||
@@ -27,48 +25,15 @@ interface VideoIndexEntry {
|
||||
|
||||
type PosterFormat = 'dataUrl' | 'fileUrl'
|
||||
|
||||
function getStaticFfmpegPath(): string | null {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const ffmpegStatic = require('ffmpeg-static')
|
||||
if (typeof ffmpegStatic === 'string') {
|
||||
let fixedPath = ffmpegStatic
|
||||
if (fixedPath.includes('app.asar') && !fixedPath.includes('app.asar.unpacked')) {
|
||||
fixedPath = fixedPath.replace('app.asar', 'app.asar.unpacked')
|
||||
}
|
||||
if (existsSync(fixedPath)) return fixedPath
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const ffmpegName = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
|
||||
const devPath = join(process.cwd(), 'node_modules', 'ffmpeg-static', ffmpegName)
|
||||
if (existsSync(devPath)) return devPath
|
||||
|
||||
if (app.isPackaged) {
|
||||
const packedPath = join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', ffmpegName)
|
||||
if (existsSync(packedPath)) return packedPath
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
class VideoService {
|
||||
private configService: ConfigService
|
||||
private hardlinkResolveCache = new Map<string, TimedCacheEntry<string | null>>()
|
||||
private videoInfoCache = new Map<string, TimedCacheEntry<VideoInfo>>()
|
||||
private videoDirIndexCache = new Map<string, TimedCacheEntry<Map<string, VideoIndexEntry>>>()
|
||||
private pendingVideoInfo = new Map<string, Promise<VideoInfo>>()
|
||||
private pendingPosterExtract = new Map<string, Promise<string | null>>()
|
||||
private extractedPosterCache = new Map<string, TimedCacheEntry<string | null>>()
|
||||
private posterExtractRunning = 0
|
||||
private posterExtractQueue: Array<() => void> = []
|
||||
private readonly hardlinkCacheTtlMs = 10 * 60 * 1000
|
||||
private readonly videoInfoCacheTtlMs = 2 * 60 * 1000
|
||||
private readonly videoIndexCacheTtlMs = 90 * 1000
|
||||
private readonly extractedPosterCacheTtlMs = 15 * 60 * 1000
|
||||
private readonly maxPosterExtractConcurrency = 1
|
||||
private readonly maxCacheEntries = 2000
|
||||
private readonly maxIndexEntries = 6
|
||||
|
||||
@@ -287,11 +252,9 @@ class VideoService {
|
||||
}
|
||||
|
||||
async preloadVideoHardlinkMd5s(md5List: string[]): Promise<void> {
|
||||
const dbPath = this.getDbPath()
|
||||
const wxid = this.getMyWxid()
|
||||
const cleanedWxid = this.cleanWxid(wxid)
|
||||
if (!dbPath || !wxid) return
|
||||
await this.resolveVideoHardlinks(md5List, dbPath, wxid, cleanedWxid)
|
||||
// 视频链路已改为直接使用 packed_info_data 提取出的文件名索引本地目录。
|
||||
// 该预热接口保留仅为兼容旧调用方,不再查询 hardlink.db。
|
||||
void md5List
|
||||
}
|
||||
|
||||
private fileToPosterUrl(filePath: string | undefined, mimeType: string, posterFormat: PosterFormat): string | undefined {
|
||||
@@ -429,6 +392,23 @@ class VideoService {
|
||||
return null
|
||||
}
|
||||
|
||||
private normalizeVideoLookupKey(value: string): string {
|
||||
let text = String(value || '').trim().toLowerCase()
|
||||
if (!text) return ''
|
||||
text = text.replace(/^.*[\\/]/, '')
|
||||
text = text.replace(/\.(?:mp4|mov|m4v|avi|mkv|flv|jpg|jpeg|png|gif|dat)$/i, '')
|
||||
text = text.replace(/_thumb$/, '')
|
||||
const direct = /^([a-f0-9]{16,64})(?:_raw)?$/i.exec(text)
|
||||
if (direct) {
|
||||
const suffix = /_raw$/i.test(text) ? '_raw' : ''
|
||||
return `${direct[1].toLowerCase()}${suffix}`
|
||||
}
|
||||
const preferred32 = /([a-f0-9]{32})(?![a-f0-9])/i.exec(text)
|
||||
if (preferred32?.[1]) return preferred32[1].toLowerCase()
|
||||
const fallback = /([a-f0-9]{16,64})(?![a-f0-9])/i.exec(text)
|
||||
return String(fallback?.[1] || '').toLowerCase()
|
||||
}
|
||||
|
||||
private fallbackScanVideo(
|
||||
videoBaseDir: string,
|
||||
realVideoMd5: string,
|
||||
@@ -473,154 +453,10 @@ class VideoService {
|
||||
return null
|
||||
}
|
||||
|
||||
private getFfmpegPath(): string {
|
||||
const staticPath = getStaticFfmpegPath()
|
||||
if (staticPath) return staticPath
|
||||
return 'ffmpeg'
|
||||
}
|
||||
|
||||
private async withPosterExtractSlot<T>(run: () => Promise<T>): Promise<T> {
|
||||
if (this.posterExtractRunning >= this.maxPosterExtractConcurrency) {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.posterExtractQueue.push(resolve)
|
||||
})
|
||||
}
|
||||
this.posterExtractRunning += 1
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
this.posterExtractRunning = Math.max(0, this.posterExtractRunning - 1)
|
||||
const next = this.posterExtractQueue.shift()
|
||||
if (next) next()
|
||||
}
|
||||
}
|
||||
|
||||
private async extractFirstFramePoster(videoPath: string, posterFormat: PosterFormat): Promise<string | null> {
|
||||
const normalizedPath = String(videoPath || '').trim()
|
||||
if (!normalizedPath || !existsSync(normalizedPath)) return null
|
||||
|
||||
const cacheKey = `${normalizedPath}|format=${posterFormat}`
|
||||
const cached = this.readTimedCache(this.extractedPosterCache, cacheKey)
|
||||
if (cached !== undefined) return cached
|
||||
|
||||
const pending = this.pendingPosterExtract.get(cacheKey)
|
||||
if (pending) return pending
|
||||
|
||||
const task = this.withPosterExtractSlot(() => new Promise<string | null>((resolve) => {
|
||||
const tmpDir = join(app.getPath('temp'), 'weflow_video_frames')
|
||||
try {
|
||||
if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true })
|
||||
} catch {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
|
||||
const stableHash = crypto.createHash('sha1').update(normalizedPath).digest('hex').slice(0, 24)
|
||||
const outputPath = join(tmpDir, `frame_${stableHash}.jpg`)
|
||||
if (posterFormat === 'fileUrl' && existsSync(outputPath)) {
|
||||
resolve(pathToFileURL(outputPath).toString())
|
||||
return
|
||||
}
|
||||
|
||||
const ffmpegPath = this.getFfmpegPath()
|
||||
const args = [
|
||||
'-hide_banner', '-loglevel', 'error', '-y',
|
||||
'-ss', '0',
|
||||
'-i', normalizedPath,
|
||||
'-frames:v', '1',
|
||||
'-q:v', '3',
|
||||
outputPath
|
||||
]
|
||||
|
||||
const errChunks: Buffer[] = []
|
||||
let done = false
|
||||
const finish = (value: string | null) => {
|
||||
if (done) return
|
||||
done = true
|
||||
if (posterFormat === 'dataUrl') {
|
||||
try {
|
||||
if (existsSync(outputPath)) unlinkSync(outputPath)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
resolve(value)
|
||||
}
|
||||
|
||||
const proc = spawn(ffmpegPath, args, {
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
windowsHide: true
|
||||
})
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
try { proc.kill('SIGKILL') } catch { /* ignore */ }
|
||||
finish(null)
|
||||
}, 12000)
|
||||
|
||||
proc.stderr.on('data', (chunk: Buffer) => errChunks.push(chunk))
|
||||
|
||||
proc.on('error', () => {
|
||||
clearTimeout(timer)
|
||||
finish(null)
|
||||
})
|
||||
|
||||
proc.on('close', (code: number) => {
|
||||
clearTimeout(timer)
|
||||
if (code !== 0 || !existsSync(outputPath)) {
|
||||
if (errChunks.length > 0) {
|
||||
this.log('extractFirstFrameDataUrl failed', {
|
||||
videoPath: normalizedPath,
|
||||
error: Buffer.concat(errChunks).toString().slice(0, 240)
|
||||
})
|
||||
}
|
||||
finish(null)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const jpgBuf = readFileSync(outputPath)
|
||||
if (!jpgBuf.length) {
|
||||
finish(null)
|
||||
return
|
||||
}
|
||||
if (posterFormat === 'fileUrl') {
|
||||
finish(pathToFileURL(outputPath).toString())
|
||||
return
|
||||
}
|
||||
finish(`data:image/jpeg;base64,${jpgBuf.toString('base64')}`)
|
||||
} catch {
|
||||
finish(null)
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
this.pendingPosterExtract.set(cacheKey, task)
|
||||
try {
|
||||
const result = await task
|
||||
this.writeTimedCache(
|
||||
this.extractedPosterCache,
|
||||
cacheKey,
|
||||
result,
|
||||
this.extractedPosterCacheTtlMs,
|
||||
this.maxCacheEntries
|
||||
)
|
||||
return result
|
||||
} finally {
|
||||
this.pendingPosterExtract.delete(cacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
private async ensurePoster(info: VideoInfo, includePoster: boolean, posterFormat: PosterFormat): Promise<VideoInfo> {
|
||||
void posterFormat
|
||||
if (!includePoster) return info
|
||||
if (!info.exists || !info.videoUrl) return info
|
||||
if (info.coverUrl || info.thumbUrl) return info
|
||||
|
||||
const extracted = await this.extractFirstFramePoster(info.videoUrl, posterFormat)
|
||||
if (!extracted) return info
|
||||
return {
|
||||
...info,
|
||||
coverUrl: extracted,
|
||||
thumbUrl: extracted
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -652,7 +488,7 @@ class VideoService {
|
||||
if (pending) return pending
|
||||
|
||||
const task = (async (): Promise<VideoInfo> => {
|
||||
const realVideoMd5 = await this.queryVideoFileName(normalizedMd5) || normalizedMd5
|
||||
const realVideoMd5 = this.normalizeVideoLookupKey(normalizedMd5) || normalizedMd5
|
||||
const videoBaseDir = this.resolveVideoBaseDir(dbPath, wxid)
|
||||
|
||||
if (!existsSync(videoBaseDir)) {
|
||||
@@ -678,7 +514,7 @@ class VideoService {
|
||||
|
||||
const miss = { exists: false }
|
||||
this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||
this.log('getVideoInfo: 未找到视频', { inputMd5: normalizedMd5, resolvedMd5: realVideoMd5 })
|
||||
this.log('getVideoInfo: 未找到视频', { lookupKey: normalizedMd5, normalizedKey: realVideoMd5 })
|
||||
return miss
|
||||
})()
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { join, dirname, basename } from 'path'
|
||||
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import * as fzstd from 'fzstd'
|
||||
import { expandHomePath } from '../utils/pathUtils'
|
||||
|
||||
//数据服务初始化错误信息,用于帮助用户诊断问题
|
||||
let lastDllInitError: string | null = null
|
||||
@@ -481,7 +482,7 @@ export class WcdbCore {
|
||||
|
||||
private resolveDbStoragePath(basePath: string, wxid: string): string | null {
|
||||
if (!basePath) return null
|
||||
const normalized = basePath.replace(/[\\\\/]+$/, '')
|
||||
const normalized = expandHomePath(basePath).replace(/[\\\\/]+$/, '')
|
||||
if (normalized.toLowerCase().endsWith('db_storage') && existsSync(normalized)) {
|
||||
return normalized
|
||||
}
|
||||
@@ -1600,6 +1601,9 @@ export class WcdbCore {
|
||||
*/
|
||||
close(): void {
|
||||
if (this.handle !== null || this.initialized) {
|
||||
// 先停止监控与云控回调,避免 shutdown 后仍有 native 回调访问已释放资源。
|
||||
try { this.stopMonitor() } catch {}
|
||||
try { this.cloudStop() } catch {}
|
||||
try {
|
||||
// 不调用 closeAccount,直接 shutdown
|
||||
this.wcdbShutdown()
|
||||
@@ -2007,6 +2011,14 @@ export class WcdbCore {
|
||||
}
|
||||
return ''
|
||||
}
|
||||
const pickRaw = (row: Record<string, any>, keys: string[]): unknown => {
|
||||
for (const key of keys) {
|
||||
const value = row[key]
|
||||
if (value === null || value === undefined) continue
|
||||
return value
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const extractXmlValue = (xml: string, tag: string): string => {
|
||||
if (!xml) return ''
|
||||
const regex = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'i')
|
||||
@@ -2092,25 +2104,37 @@ export class WcdbCore {
|
||||
const md5Like = /([0-9a-fA-F]{16,64})/.exec(fileBase)
|
||||
return String(md5Like?.[1] || fileBase || '').trim().toLowerCase()
|
||||
}
|
||||
const decodePackedToPrintable = (raw: string): string => {
|
||||
const text = String(raw || '').trim()
|
||||
if (!text) return ''
|
||||
let buf: Buffer | null = null
|
||||
if (/^[a-fA-F0-9]+$/.test(text) && text.length % 2 === 0) {
|
||||
try {
|
||||
buf = Buffer.from(text, 'hex')
|
||||
} catch {
|
||||
buf = null
|
||||
const decodePackedInfoBuffer = (raw: unknown): Buffer | null => {
|
||||
if (!raw) return null
|
||||
if (Buffer.isBuffer(raw)) return raw
|
||||
if (raw instanceof Uint8Array) return Buffer.from(raw)
|
||||
if (Array.isArray(raw)) return Buffer.from(raw as any[])
|
||||
if (typeof raw === 'string') {
|
||||
const text = raw.trim()
|
||||
if (!text) return null
|
||||
const compactHex = text.replace(/\s+/g, '')
|
||||
if (/^[a-fA-F0-9]+$/.test(compactHex) && compactHex.length % 2 === 0) {
|
||||
try {
|
||||
return Buffer.from(compactHex, 'hex')
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!buf) {
|
||||
try {
|
||||
const base64 = Buffer.from(text, 'base64')
|
||||
if (base64.length > 0) buf = base64
|
||||
if (base64.length > 0) return base64
|
||||
} catch {
|
||||
buf = null
|
||||
// ignore
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (typeof raw === 'object' && raw !== null && Array.isArray((raw as any).data)) {
|
||||
return Buffer.from((raw as any).data)
|
||||
}
|
||||
return null
|
||||
}
|
||||
const decodePackedToPrintable = (raw: unknown): string => {
|
||||
const buf = decodePackedInfoBuffer(raw)
|
||||
if (!buf || buf.length === 0) return ''
|
||||
const printable: number[] = []
|
||||
for (const byte of buf) {
|
||||
@@ -2125,6 +2149,46 @@ export class WcdbCore {
|
||||
const match = /([a-fA-F0-9]{32})/.exec(input)
|
||||
return String(match?.[1] || '').toLowerCase()
|
||||
}
|
||||
const normalizeVideoFileToken = (value: unknown): string => {
|
||||
let text = String(value || '').trim().toLowerCase()
|
||||
if (!text) return ''
|
||||
text = text.replace(/^.*[\\/]/, '')
|
||||
text = text.replace(/\.(?:mp4|mov|m4v|avi|mkv|flv|jpg|jpeg|png|gif|dat)$/i, '')
|
||||
text = text.replace(/_thumb$/, '')
|
||||
const direct = /^([a-f0-9]{16,64})(?:_raw)?$/i.exec(text)
|
||||
if (direct) {
|
||||
const suffix = /_raw$/i.test(text) ? '_raw' : ''
|
||||
return `${direct[1].toLowerCase()}${suffix}`
|
||||
}
|
||||
const preferred32 = /([a-f0-9]{32})(?![a-f0-9])/i.exec(text)
|
||||
if (preferred32?.[1]) return preferred32[1].toLowerCase()
|
||||
const fallback = /([a-f0-9]{16,64})(?![a-f0-9])/i.exec(text)
|
||||
return String(fallback?.[1] || '').toLowerCase()
|
||||
}
|
||||
const extractVideoFileNameFromPackedRaw = (raw: unknown): string => {
|
||||
const buf = decodePackedInfoBuffer(raw)
|
||||
if (!buf || buf.length === 0) return ''
|
||||
const candidates: string[] = []
|
||||
let current = ''
|
||||
for (const byte of buf) {
|
||||
const isHex =
|
||||
(byte >= 0x30 && byte <= 0x39) ||
|
||||
(byte >= 0x41 && byte <= 0x46) ||
|
||||
(byte >= 0x61 && byte <= 0x66)
|
||||
if (isHex) {
|
||||
current += String.fromCharCode(byte)
|
||||
continue
|
||||
}
|
||||
if (current.length >= 16) candidates.push(current)
|
||||
current = ''
|
||||
}
|
||||
if (current.length >= 16) candidates.push(current)
|
||||
if (candidates.length === 0) return ''
|
||||
const exact32 = candidates.find((item) => item.length === 32)
|
||||
if (exact32) return exact32.toLowerCase()
|
||||
const fallback = candidates.find((item) => item.length >= 16 && item.length <= 64)
|
||||
return String(fallback || '').toLowerCase()
|
||||
}
|
||||
const extractImageDatName = (row: Record<string, any>, content: string): string => {
|
||||
const direct = pickString(row, [
|
||||
'image_path',
|
||||
@@ -2143,7 +2207,7 @@ export class WcdbCore {
|
||||
const normalizedXml = normalizeDatBase(xmlCandidate)
|
||||
if (normalizedXml) return normalizedXml
|
||||
|
||||
const packedRaw = pickString(row, [
|
||||
const packedRaw = pickRaw(row, [
|
||||
'packed_info_data',
|
||||
'packedInfoData',
|
||||
'packed_info_blob',
|
||||
@@ -2168,7 +2232,7 @@ export class WcdbCore {
|
||||
return ''
|
||||
}
|
||||
const extractPackedPayload = (row: Record<string, any>): string => {
|
||||
const packedRaw = pickString(row, [
|
||||
const packedRaw = pickRaw(row, [
|
||||
'packed_info_data',
|
||||
'packedInfoData',
|
||||
'packed_info_blob',
|
||||
@@ -2323,6 +2387,20 @@ export class WcdbCore {
|
||||
const packedPayload = extractPackedPayload(row)
|
||||
const imageMd5ByColumn = pickString(row, ['image_md5', 'imageMd5'])
|
||||
const videoMd5ByColumn = pickString(row, ['video_md5', 'videoMd5', 'raw_md5', 'rawMd5'])
|
||||
const packedRaw = pickRaw(row, [
|
||||
'packed_info_data',
|
||||
'packedInfoData',
|
||||
'packed_info_blob',
|
||||
'packedInfoBlob',
|
||||
'packed_info',
|
||||
'packedInfo',
|
||||
'BytesExtra',
|
||||
'bytes_extra',
|
||||
'WCDB_CT_packed_info',
|
||||
'reserved0',
|
||||
'Reserved0',
|
||||
'WCDB_CT_Reserved0'
|
||||
])
|
||||
|
||||
let content = ''
|
||||
let imageMd5: string | undefined
|
||||
@@ -2338,10 +2416,17 @@ export class WcdbCore {
|
||||
if (!imageDatName) imageDatName = extractImageDatName(row, content) || undefined
|
||||
}
|
||||
} else if (localType === 43) {
|
||||
videoMd5 = videoMd5ByColumn || extractHexMd5(packedPayload) || undefined
|
||||
videoMd5 =
|
||||
extractVideoFileNameFromPackedRaw(packedRaw) ||
|
||||
normalizeVideoFileToken(videoMd5ByColumn) ||
|
||||
extractHexMd5(packedPayload) ||
|
||||
undefined
|
||||
if (!videoMd5) {
|
||||
content = decodeContentIfNeeded()
|
||||
videoMd5 = extractVideoMd5(content) || extractHexMd5(packedPayload) || undefined
|
||||
videoMd5 =
|
||||
normalizeVideoFileToken(extractVideoMd5(content)) ||
|
||||
extractHexMd5(packedPayload) ||
|
||||
undefined
|
||||
} else if (useRawMessageContent) {
|
||||
// 占位态标题只依赖简单 XML,已带 md5 时不做额外解压
|
||||
content = rawMessageContent
|
||||
|
||||
20
electron/utils/pathUtils.ts
Normal file
20
electron/utils/pathUtils.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { homedir } from 'os'
|
||||
|
||||
/**
|
||||
* Expand "~" prefix to current user's home directory.
|
||||
* Examples:
|
||||
* - "~" => "/Users/alex"
|
||||
* - "~/Library/..." => "/Users/alex/Library/..."
|
||||
*/
|
||||
export function expandHomePath(inputPath: string): string {
|
||||
const raw = String(inputPath || '').trim()
|
||||
if (!raw) return raw
|
||||
|
||||
if (raw === '~') return homedir()
|
||||
if (/^~[\\/]/.test(raw)) {
|
||||
return `${homedir()}${raw.slice(1)}`
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user