mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-22 15:09:04 +00:00
Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev
This commit is contained in:
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -51,7 +51,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
|
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
|
||||||
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
|
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
|
||||||
npx electron-builder --mac dmg zip --arm64 --publish always
|
npx electron-builder --mac dmg zip --arm64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}'
|
||||||
|
|
||||||
- name: Inject minimumVersion into latest yml
|
- name: Inject minimumVersion into latest yml
|
||||||
env:
|
env:
|
||||||
@@ -114,7 +114,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
npx electron-builder --linux --publish always
|
npx electron-builder --linux --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}'
|
||||||
|
|
||||||
- name: Inject minimumVersion into latest yml
|
- name: Inject minimumVersion into latest yml
|
||||||
env:
|
env:
|
||||||
@@ -167,7 +167,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
npx electron-builder --win nsis --x64 --publish always '--config.artifactName=${productName}-${version}-x64-Setup.${ext}'
|
npx electron-builder --win nsis --x64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}' '--config.artifactName=${productName}-${version}-x64-Setup.${ext}'
|
||||||
|
|
||||||
- name: Inject minimumVersion into latest yml
|
- name: Inject minimumVersion into latest yml
|
||||||
env:
|
env:
|
||||||
@@ -220,7 +220,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
npx electron-builder --win nsis --arm64 --publish always '--config.publish.channel=latest-arm64' '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}'
|
npx electron-builder --win nsis --arm64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}' '--config.publish.channel=latest-arm64' '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}'
|
||||||
|
|
||||||
- name: Inject minimumVersion into latest yml
|
- name: Inject minimumVersion into latest yml
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ interface ConfigSchema {
|
|||||||
aiInsightSilenceDays: number
|
aiInsightSilenceDays: number
|
||||||
aiInsightAllowContext: boolean
|
aiInsightAllowContext: boolean
|
||||||
aiInsightAllowSocialContext: boolean
|
aiInsightAllowSocialContext: boolean
|
||||||
|
aiInsightFilterMode: 'whitelist' | 'blacklist'
|
||||||
|
aiInsightFilterList: string[]
|
||||||
aiInsightWhitelistEnabled: boolean
|
aiInsightWhitelistEnabled: boolean
|
||||||
aiInsightWhitelist: string[]
|
aiInsightWhitelist: string[]
|
||||||
/** 活跃分析冷却时间(分钟),0 表示无冷却 */
|
/** 活跃分析冷却时间(分钟),0 表示无冷却 */
|
||||||
@@ -202,6 +204,8 @@ export class ConfigService {
|
|||||||
aiInsightSilenceDays: 3,
|
aiInsightSilenceDays: 3,
|
||||||
aiInsightAllowContext: false,
|
aiInsightAllowContext: false,
|
||||||
aiInsightAllowSocialContext: false,
|
aiInsightAllowSocialContext: false,
|
||||||
|
aiInsightFilterMode: 'whitelist',
|
||||||
|
aiInsightFilterList: [],
|
||||||
aiInsightWhitelistEnabled: false,
|
aiInsightWhitelistEnabled: false,
|
||||||
aiInsightWhitelist: [],
|
aiInsightWhitelist: [],
|
||||||
aiInsightCooldownMinutes: 120,
|
aiInsightCooldownMinutes: 120,
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ const MESSAGE_TYPE_MAP: Record<number, number> = {
|
|||||||
34: 2, // 语音 -> VOICE
|
34: 2, // 语音 -> VOICE
|
||||||
43: 3, // 视频 -> VIDEO
|
43: 3, // 视频 -> VIDEO
|
||||||
49: 7, // 链接/文件 -> LINK (需要进一步判断)
|
49: 7, // 链接/文件 -> LINK (需要进一步判断)
|
||||||
|
34359738417: 7, // 文件消息变体 -> LINK
|
||||||
|
103079215153: 7, // 文件消息变体 -> LINK
|
||||||
|
25769803825: 7, // 文件消息变体 -> LINK
|
||||||
47: 5, // 表情包 -> EMOJI
|
47: 5, // 表情包 -> EMOJI
|
||||||
48: 8, // 位置 -> LOCATION
|
48: 8, // 位置 -> LOCATION
|
||||||
42: 27, // 名片 -> CONTACT
|
42: 27, // 名片 -> CONTACT
|
||||||
@@ -86,9 +89,13 @@ const MESSAGE_TYPE_MAP: Record<number, number> = {
|
|||||||
10000: 80, // 系统消息 -> SYSTEM
|
10000: 80, // 系统消息 -> SYSTEM
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 与 chatService 的资源消息识别保持一致,覆盖桌面微信里的多种文件消息 localType。
|
||||||
|
const FILE_APP_LOCAL_TYPES = [49, 34359738417, 103079215153, 25769803825] as const
|
||||||
|
const FILE_APP_LOCAL_TYPE_SET = new Set<number>(FILE_APP_LOCAL_TYPES)
|
||||||
|
|
||||||
export interface ExportOptions {
|
export interface ExportOptions {
|
||||||
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
||||||
contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji'
|
contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file'
|
||||||
dateRange?: { start: number; end: number } | null
|
dateRange?: { start: number; end: number } | null
|
||||||
senderUsername?: string
|
senderUsername?: string
|
||||||
fileNameSuffix?: string
|
fileNameSuffix?: string
|
||||||
@@ -137,11 +144,19 @@ interface ExportDisplayProfile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type MessageCollectMode = 'full' | 'text-fast' | 'media-fast'
|
type MessageCollectMode = 'full' | 'text-fast' | 'media-fast'
|
||||||
type MediaContentType = 'voice' | 'image' | 'video' | 'emoji'
|
type MediaContentType = 'voice' | 'image' | 'video' | 'emoji' | 'file'
|
||||||
interface FileExportCandidate {
|
interface FileExportCandidate {
|
||||||
sourcePath: string
|
sourcePath: string
|
||||||
matchedBy: 'md5' | 'name'
|
matchedBy: 'md5' | 'name'
|
||||||
yearMonth?: string
|
yearMonth?: string
|
||||||
|
preferredMonth?: boolean
|
||||||
|
mtimeMs: number
|
||||||
|
searchOrder: number
|
||||||
|
}
|
||||||
|
interface FileAttachmentSearchRoot {
|
||||||
|
accountDir: string
|
||||||
|
msgFileRoot?: string
|
||||||
|
fileStorageRoot?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExportProgress {
|
export interface ExportProgress {
|
||||||
@@ -501,6 +516,13 @@ class ExportService {
|
|||||||
.trim()
|
.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveFileAttachmentExtensionDir(msg: any, fileName: string): string {
|
||||||
|
const rawExt = String(msg?.fileExt || '').trim() || path.extname(String(fileName || ''))
|
||||||
|
const normalizedExt = rawExt.replace(/^\.+/, '').trim().toLowerCase()
|
||||||
|
const safeExt = this.sanitizeExportFileNamePart(normalizedExt).replace(/\s+/g, '_')
|
||||||
|
return safeExt || 'no-extension'
|
||||||
|
}
|
||||||
|
|
||||||
private normalizeFileNamingMode(value: unknown): 'classic' | 'date-range' {
|
private normalizeFileNamingMode(value: unknown): 'classic' | 'date-range' {
|
||||||
return String(value || '').trim().toLowerCase() === 'date-range' ? 'date-range' : 'classic'
|
return String(value || '').trim().toLowerCase() === 'date-range' ? 'date-range' : 'classic'
|
||||||
}
|
}
|
||||||
@@ -947,7 +969,7 @@ class ExportService {
|
|||||||
|
|
||||||
private getMediaContentType(options: ExportOptions): MediaContentType | null {
|
private getMediaContentType(options: ExportOptions): MediaContentType | null {
|
||||||
const value = options.contentType
|
const value = options.contentType
|
||||||
if (value === 'voice' || value === 'image' || value === 'video' || value === 'emoji') {
|
if (value === 'voice' || value === 'image' || value === 'video' || value === 'emoji' || value === 'file') {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@@ -963,15 +985,117 @@ class ExportService {
|
|||||||
if (mediaContentType === 'image') return new Set([3])
|
if (mediaContentType === 'image') return new Set([3])
|
||||||
if (mediaContentType === 'video') return new Set([43])
|
if (mediaContentType === 'video') return new Set([43])
|
||||||
if (mediaContentType === 'emoji') return new Set([47])
|
if (mediaContentType === 'emoji') return new Set([47])
|
||||||
|
if (mediaContentType === 'file') return new Set(FILE_APP_LOCAL_TYPES)
|
||||||
|
|
||||||
const selected = new Set<number>()
|
const selected = new Set<number>()
|
||||||
if (options.exportImages) selected.add(3)
|
if (options.exportImages) selected.add(3)
|
||||||
if (options.exportVoices) selected.add(34)
|
if (options.exportVoices) selected.add(34)
|
||||||
if (options.exportVideos) selected.add(43)
|
if (options.exportVideos) selected.add(43)
|
||||||
if (options.exportFiles) selected.add(49)
|
if (options.exportFiles) {
|
||||||
|
for (const fileType of FILE_APP_LOCAL_TYPES) {
|
||||||
|
selected.add(fileType)
|
||||||
|
}
|
||||||
|
}
|
||||||
return selected
|
return selected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isFileAppLocalType(localType: number): boolean {
|
||||||
|
return FILE_APP_LOCAL_TYPE_SET.has(localType)
|
||||||
|
}
|
||||||
|
|
||||||
|
private isFileOnlyMediaFilter(targetMediaTypes: Set<number> | null): boolean {
|
||||||
|
return Boolean(
|
||||||
|
targetMediaTypes &&
|
||||||
|
targetMediaTypes.size === FILE_APP_LOCAL_TYPES.length &&
|
||||||
|
FILE_APP_LOCAL_TYPES.every((fileType) => targetMediaTypes.has(fileType))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFileAppMessageHints(message: Record<string, any> | null | undefined): {
|
||||||
|
xmlType?: string
|
||||||
|
fileName?: string
|
||||||
|
fileSize?: number
|
||||||
|
fileExt?: string
|
||||||
|
fileMd5?: string
|
||||||
|
} {
|
||||||
|
const xmlType = String(message?.xmlType ?? message?.xml_type ?? '').trim() || undefined
|
||||||
|
const fileName = String(message?.fileName ?? message?.file_name ?? '').trim() || undefined
|
||||||
|
const fileExt = String(message?.fileExt ?? message?.file_ext ?? '').trim() || undefined
|
||||||
|
const fileSizeRaw = Number(message?.fileSize ?? message?.file_size ?? message?.total_len ?? message?.totalLen ?? message?.totallen ?? 0)
|
||||||
|
const fileSize = Number.isFinite(fileSizeRaw) && fileSizeRaw > 0 ? Math.floor(fileSizeRaw) : undefined
|
||||||
|
const fileMd5Raw = String(message?.fileMd5 ?? message?.file_md5 ?? '').trim()
|
||||||
|
const fileMd5 = /^[a-f0-9]{32}$/i.test(fileMd5Raw) ? fileMd5Raw.toLowerCase() : undefined
|
||||||
|
return { xmlType, fileName, fileSize, fileExt, fileMd5 }
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasFileAppMessageHints(message: Record<string, any> | null | undefined): boolean {
|
||||||
|
const hints = this.getFileAppMessageHints(message)
|
||||||
|
if (hints.xmlType) return hints.xmlType === '6'
|
||||||
|
return Boolean(hints.fileName || hints.fileExt || hints.fileMd5 || hints.fileSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
private isFileAppMessage(msg: {
|
||||||
|
localType?: unknown
|
||||||
|
xmlType?: unknown
|
||||||
|
xml_type?: unknown
|
||||||
|
content?: unknown
|
||||||
|
fileName?: unknown
|
||||||
|
file_name?: unknown
|
||||||
|
fileSize?: unknown
|
||||||
|
file_size?: unknown
|
||||||
|
fileExt?: unknown
|
||||||
|
file_ext?: unknown
|
||||||
|
fileMd5?: unknown
|
||||||
|
file_md5?: unknown
|
||||||
|
}): boolean {
|
||||||
|
const { xmlType, fileName, fileExt, fileMd5, fileSize } = this.getFileAppMessageHints(msg as Record<string, any>)
|
||||||
|
if (xmlType) return xmlType === '6'
|
||||||
|
if (fileName || fileExt || fileMd5 || fileSize) return true
|
||||||
|
|
||||||
|
const normalized = this.normalizeAppMessageContent(String(msg?.content || ''))
|
||||||
|
if (!normalized || (!normalized.includes('<appmsg') && !normalized.includes('<msg>'))) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return this.extractAppMessageType(normalized) === '6'
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractFileAppMessageMeta(content: string): {
|
||||||
|
xmlType?: string
|
||||||
|
fileName?: string
|
||||||
|
fileSize?: number
|
||||||
|
fileExt?: string
|
||||||
|
fileMd5?: string
|
||||||
|
} | null {
|
||||||
|
const normalized = this.normalizeAppMessageContent(content || '')
|
||||||
|
if (!normalized || (!normalized.includes('<appmsg') && !normalized.includes('<msg>'))) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const xmlType = this.extractAppMessageType(normalized)
|
||||||
|
if (!xmlType) return null
|
||||||
|
|
||||||
|
const rawFileName = this.extractXmlValue(normalized, 'filename') || this.extractXmlValue(normalized, 'title')
|
||||||
|
const rawFileExt = this.extractXmlValue(normalized, 'fileext')
|
||||||
|
const rawFileSize =
|
||||||
|
this.extractXmlValue(normalized, 'totallen') ||
|
||||||
|
this.extractXmlValue(normalized, 'datasize') ||
|
||||||
|
this.extractXmlValue(normalized, 'filesize')
|
||||||
|
const rawFileMd5 =
|
||||||
|
this.extractXmlValue(normalized, 'md5') ||
|
||||||
|
this.extractXmlAttribute(normalized, 'appattach', 'md5') ||
|
||||||
|
this.extractLooseHexMd5(normalized)
|
||||||
|
const fileSize = Number.parseInt(rawFileSize, 10)
|
||||||
|
const fileMd5 = String(rawFileMd5 || '').trim()
|
||||||
|
|
||||||
|
return {
|
||||||
|
xmlType,
|
||||||
|
fileName: this.decodeHtmlEntities(rawFileName).trim() || undefined,
|
||||||
|
fileSize: Number.isFinite(fileSize) && fileSize > 0 ? fileSize : undefined,
|
||||||
|
fileExt: this.decodeHtmlEntities(rawFileExt).trim() || undefined,
|
||||||
|
fileMd5: /^[a-f0-9]{32}$/i.test(fileMd5) ? fileMd5.toLowerCase() : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private resolveCollectMode(options: ExportOptions): MessageCollectMode {
|
private resolveCollectMode(options: ExportOptions): MessageCollectMode {
|
||||||
if (this.isMediaContentBatchExport(options)) {
|
if (this.isMediaContentBatchExport(options)) {
|
||||||
return 'media-fast'
|
return 'media-fast'
|
||||||
@@ -1020,12 +1144,17 @@ class ExportService {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldDecodeMessageContentInMediaMode(localType: number, targetMediaTypes: Set<number> | null): boolean {
|
private shouldDecodeMessageContentInMediaMode(
|
||||||
if (!targetMediaTypes || !targetMediaTypes.has(localType)) return false
|
localType: number,
|
||||||
|
targetMediaTypes: Set<number> | null,
|
||||||
|
options?: { allowFileProbe?: boolean }
|
||||||
|
): boolean {
|
||||||
|
const allowFileProbe = options?.allowFileProbe === true
|
||||||
|
if (!targetMediaTypes || (!targetMediaTypes.has(localType) && !allowFileProbe)) return false
|
||||||
// 语音导出仅需要 localId 读取音频数据,不依赖 XML 内容
|
// 语音导出仅需要 localId 读取音频数据,不依赖 XML 内容
|
||||||
if (localType === 34) return false
|
if (localType === 34) return false
|
||||||
// 图片/视频/表情可能需要从 XML 提取 md5/datName/cdnUrl
|
// 图片/视频/表情/文件可能需要从 XML 提取 md5/datName/附件信息
|
||||||
if (localType === 3 || localType === 43 || localType === 47) return true
|
if (localType === 3 || localType === 43 || localType === 47 || this.isFileAppLocalType(localType) || allowFileProbe) return true
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3628,7 +3757,7 @@ class ExportService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((localType === 49 || localType === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') {
|
if (options.exportFiles && this.isFileAppMessage(msg)) {
|
||||||
return this.exportFileAttachment(
|
return this.exportFileAttachment(
|
||||||
msg,
|
msg,
|
||||||
mediaRootDir,
|
mediaRootDir,
|
||||||
@@ -4183,33 +4312,104 @@ class ExportService {
|
|||||||
return this.normalizeVideoFileToken(this.extractVideoMd5(content || ''))
|
return this.normalizeVideoFileToken(this.extractVideoMd5(content || ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveFileAttachmentRoots(): string[] {
|
private isFileAttachmentAccountDir(dirPath: string): boolean {
|
||||||
|
if (!dirPath) return false
|
||||||
|
return fs.existsSync(path.join(dirPath, 'db_storage')) ||
|
||||||
|
fs.existsSync(path.join(dirPath, 'msg', 'file')) ||
|
||||||
|
fs.existsSync(path.join(dirPath, 'FileStorage', 'File')) ||
|
||||||
|
fs.existsSync(path.join(dirPath, 'FileStorage', 'Image')) ||
|
||||||
|
fs.existsSync(path.join(dirPath, 'FileStorage', 'Image2'))
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveAccountDirForFileExport(basePath: string, wxid: string): string | null {
|
||||||
|
const cleanedWxid = this.cleanAccountDirName(wxid)
|
||||||
|
if (!basePath || !cleanedWxid) return null
|
||||||
|
|
||||||
|
const normalized = path.resolve(basePath.replace(/[\\/]+$/, ''))
|
||||||
|
const parentDir = path.dirname(normalized)
|
||||||
|
const dbStorageParent = path.basename(normalized).toLowerCase() === 'db_storage'
|
||||||
|
? path.dirname(normalized)
|
||||||
|
: ''
|
||||||
|
const fileInsideDbStorageParent = path.basename(parentDir).toLowerCase() === 'db_storage'
|
||||||
|
? path.dirname(parentDir)
|
||||||
|
: ''
|
||||||
|
const candidateBases = Array.from(new Set([
|
||||||
|
normalized,
|
||||||
|
parentDir,
|
||||||
|
path.join(normalized, 'WeChat Files'),
|
||||||
|
path.join(parentDir, 'WeChat Files'),
|
||||||
|
dbStorageParent,
|
||||||
|
fileInsideDbStorageParent
|
||||||
|
].filter(Boolean)))
|
||||||
|
|
||||||
|
const lowerWxid = cleanedWxid.toLowerCase()
|
||||||
|
const tryResolveBase = (candidateBase: string): string | null => {
|
||||||
|
if (!candidateBase || !fs.existsSync(candidateBase)) return null
|
||||||
|
if (this.isFileAttachmentAccountDir(candidateBase)) return candidateBase
|
||||||
|
|
||||||
|
const direct = path.join(candidateBase, cleanedWxid)
|
||||||
|
if (this.isFileAttachmentAccountDir(direct)) return direct
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = fs.readdirSync(candidateBase, { withFileTypes: true })
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue
|
||||||
|
const lowerEntry = entry.name.toLowerCase()
|
||||||
|
if (lowerEntry === lowerWxid || lowerEntry.startsWith(`${lowerWxid}_`)) {
|
||||||
|
const entryPath = path.join(candidateBase, entry.name)
|
||||||
|
if (this.isFileAttachmentAccountDir(entryPath)) {
|
||||||
|
return entryPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidateBase of candidateBases) {
|
||||||
|
const resolved = tryResolveBase(candidateBase)
|
||||||
|
if (resolved) return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveFileAttachmentSearchRoots(): FileAttachmentSearchRoot[] {
|
||||||
const dbPath = String(this.configService.get('dbPath') || '').trim()
|
const dbPath = String(this.configService.get('dbPath') || '').trim()
|
||||||
const rawWxid = String(this.configService.get('myWxid') || '').trim()
|
const rawWxid = String(this.configService.get('myWxid') || '').trim()
|
||||||
const cleanedWxid = this.cleanAccountDirName(rawWxid)
|
const cleanedWxid = this.cleanAccountDirName(rawWxid)
|
||||||
if (!dbPath) return []
|
if (!dbPath) return []
|
||||||
|
|
||||||
const normalized = dbPath.replace(/[\\/]+$/, '')
|
const normalized = path.resolve(dbPath.replace(/[\\/]+$/, ''))
|
||||||
const roots = new Set<string>()
|
const accountDirs = new Set<string>()
|
||||||
const tryAddRoot = (candidate: string) => {
|
const maybeAddAccountDir = (candidate: string | null | undefined) => {
|
||||||
const fileRoot = path.join(candidate, 'msg', 'file')
|
if (!candidate) return
|
||||||
if (fs.existsSync(fileRoot)) {
|
const resolved = path.resolve(candidate)
|
||||||
roots.add(fileRoot)
|
if (this.isFileAttachmentAccountDir(resolved)) {
|
||||||
|
accountDirs.add(resolved)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tryAddRoot(normalized)
|
maybeAddAccountDir(normalized)
|
||||||
if (rawWxid) tryAddRoot(path.join(normalized, rawWxid))
|
maybeAddAccountDir(path.dirname(normalized))
|
||||||
if (cleanedWxid && cleanedWxid !== rawWxid) tryAddRoot(path.join(normalized, cleanedWxid))
|
|
||||||
|
|
||||||
const dbStoragePath =
|
const wxidCandidates = Array.from(new Set([cleanedWxid, rawWxid].filter(Boolean)))
|
||||||
this.resolveDbStoragePathForExport(normalized, cleanedWxid) ||
|
for (const wxid of wxidCandidates) {
|
||||||
this.resolveDbStoragePathForExport(normalized, rawWxid)
|
maybeAddAccountDir(this.resolveAccountDirForFileExport(normalized, wxid))
|
||||||
if (dbStoragePath) {
|
|
||||||
tryAddRoot(path.dirname(dbStoragePath))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(roots)
|
return Array.from(accountDirs).map((accountDir) => {
|
||||||
|
const msgFileRoot = path.join(accountDir, 'msg', 'file')
|
||||||
|
const fileStorageRoot = path.join(accountDir, 'FileStorage', 'File')
|
||||||
|
return {
|
||||||
|
accountDir,
|
||||||
|
msgFileRoot: fs.existsSync(msgFileRoot) ? msgFileRoot : undefined,
|
||||||
|
fileStorageRoot: fs.existsSync(fileStorageRoot) ? fileStorageRoot : undefined
|
||||||
|
}
|
||||||
|
}).filter((root) => Boolean(root.msgFileRoot || root.fileStorageRoot))
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildPreferredFileYearMonths(createTime?: unknown): string[] {
|
private buildPreferredFileYearMonths(createTime?: unknown): string[] {
|
||||||
@@ -4241,52 +4441,147 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveFileAttachmentCandidates(msg: any): Promise<FileExportCandidate[]> {
|
private collectFileStorageCandidatesByName(rootDir: string, fileName: string, maxDepth = 3): string[] {
|
||||||
const fileName = String(msg?.fileName || '').trim()
|
const normalizedName = String(fileName || '').trim().toLowerCase()
|
||||||
if (!fileName) return []
|
if (!rootDir || !normalizedName) return []
|
||||||
|
|
||||||
const roots = this.resolveFileAttachmentRoots()
|
const matches: string[] = []
|
||||||
if (roots.length === 0) return []
|
const stack: Array<{ dir: string; depth: number }> = [{ dir: rootDir, depth: 0 }]
|
||||||
|
|
||||||
const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase()
|
while (stack.length > 0) {
|
||||||
const preferredMonths = this.buildPreferredFileYearMonths(msg?.createTime)
|
const current = stack.pop()!
|
||||||
const candidates: FileExportCandidate[] = []
|
let entries: fs.Dirent[]
|
||||||
const seen = new Set<string>()
|
|
||||||
|
|
||||||
for (const root of roots) {
|
|
||||||
let monthDirs: string[] = []
|
|
||||||
try {
|
try {
|
||||||
monthDirs = fs.readdirSync(root)
|
entries = fs.readdirSync(current.dir, { withFileTypes: true })
|
||||||
.filter(entry => /^\d{4}-\d{2}$/.test(entry) && fs.existsSync(path.join(root, entry)))
|
|
||||||
.sort()
|
|
||||||
} catch {
|
} catch {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderedMonths = Array.from(new Set([
|
for (const entry of entries) {
|
||||||
...preferredMonths,
|
const entryPath = path.join(current.dir, entry.name)
|
||||||
...monthDirs.slice().reverse()
|
if (entry.isFile() && entry.name.toLowerCase() === normalizedName) {
|
||||||
]))
|
matches.push(entryPath)
|
||||||
|
|
||||||
for (const month of orderedMonths) {
|
|
||||||
const sourcePath = path.join(root, month, fileName)
|
|
||||||
if (!fs.existsSync(sourcePath)) continue
|
|
||||||
const resolvedPath = path.resolve(sourcePath)
|
|
||||||
if (seen.has(resolvedPath)) continue
|
|
||||||
seen.add(resolvedPath)
|
|
||||||
|
|
||||||
if (normalizedMd5) {
|
|
||||||
const ok = await this.verifyFileHash(resolvedPath, normalizedMd5)
|
|
||||||
if (ok) {
|
|
||||||
candidates.unshift({ sourcePath: resolvedPath, matchedBy: 'md5', yearMonth: month })
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if (entry.isDirectory() && current.depth < maxDepth) {
|
||||||
|
stack.push({ dir: entryPath, depth: current.depth + 1 })
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
candidates.push({ sourcePath: resolvedPath, matchedBy: 'name', yearMonth: month })
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFileAttachmentLogContext(msg: any): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
localId: msg?.localId,
|
||||||
|
createTime: msg?.createTime,
|
||||||
|
localType: msg?.localType,
|
||||||
|
xmlType: msg?.xmlType,
|
||||||
|
fileName: msg?.fileName,
|
||||||
|
fileMd5: msg?.fileMd5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private logFileAttachmentEvent(
|
||||||
|
level: 'warn' | 'error',
|
||||||
|
action: string,
|
||||||
|
msg: any,
|
||||||
|
extra: Record<string, unknown> = {}
|
||||||
|
): void {
|
||||||
|
const logger = level === 'error' ? console.error : console.warn
|
||||||
|
logger(`[Export][File] ${action}`, {
|
||||||
|
...this.getFileAttachmentLogContext(msg),
|
||||||
|
...extra
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordFileAttachmentMiss(msg: any, action: string, extra: Record<string, unknown> = {}): void {
|
||||||
|
this.logFileAttachmentEvent('warn', action, msg, extra)
|
||||||
|
this.noteMediaTelemetry({ cacheMissFiles: 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveFileAttachmentCandidates(msg: any): Promise<FileExportCandidate[]> {
|
||||||
|
const fileName = String(msg?.fileName || '').trim()
|
||||||
|
if (!fileName) return []
|
||||||
|
|
||||||
|
const roots = this.resolveFileAttachmentSearchRoots()
|
||||||
|
if (roots.length === 0) return []
|
||||||
|
|
||||||
|
const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase()
|
||||||
|
const preferredMonths = new Set(this.buildPreferredFileYearMonths(msg?.createTime))
|
||||||
|
const candidates: FileExportCandidate[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
let searchOrder = 0
|
||||||
|
|
||||||
|
const appendCandidate = async (sourcePath: string, yearMonth?: string) => {
|
||||||
|
if (!sourcePath || !fs.existsSync(sourcePath)) return
|
||||||
|
|
||||||
|
const resolvedPath = path.resolve(sourcePath)
|
||||||
|
if (seen.has(resolvedPath)) return
|
||||||
|
|
||||||
|
let stat: fs.Stats
|
||||||
|
try {
|
||||||
|
stat = await fs.promises.stat(resolvedPath)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!stat.isFile()) return
|
||||||
|
|
||||||
|
seen.add(resolvedPath)
|
||||||
|
const matchedBy = normalizedMd5 && await this.verifyFileHash(resolvedPath, normalizedMd5) ? 'md5' : 'name'
|
||||||
|
candidates.push({
|
||||||
|
sourcePath: resolvedPath,
|
||||||
|
matchedBy,
|
||||||
|
yearMonth,
|
||||||
|
preferredMonth: Boolean(yearMonth && preferredMonths.has(yearMonth)),
|
||||||
|
mtimeMs: Number.isFinite(stat.mtimeMs) ? stat.mtimeMs : 0,
|
||||||
|
searchOrder: searchOrder++
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const root of roots) {
|
||||||
|
if (root.msgFileRoot) {
|
||||||
|
for (const month of preferredMonths) {
|
||||||
|
await appendCandidate(path.join(root.msgFileRoot, month, fileName), month)
|
||||||
|
}
|
||||||
|
|
||||||
|
let monthDirs: string[] = []
|
||||||
|
try {
|
||||||
|
monthDirs = fs.readdirSync(root.msgFileRoot, { withFileTypes: true })
|
||||||
|
.filter(entry => entry.isDirectory() && /^\d{4}-\d{2}$/.test(entry.name) && !preferredMonths.has(entry.name))
|
||||||
|
.map(entry => entry.name)
|
||||||
|
.sort()
|
||||||
|
} catch {
|
||||||
|
monthDirs = []
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const month of monthDirs) {
|
||||||
|
await appendCandidate(path.join(root.msgFileRoot, month, fileName), month)
|
||||||
|
}
|
||||||
|
await appendCandidate(path.join(root.msgFileRoot, fileName))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root.fileStorageRoot) {
|
||||||
|
for (const candidatePath of this.collectFileStorageCandidatesByName(root.fileStorageRoot, fileName, 3)) {
|
||||||
|
await appendCandidate(candidatePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.sort((left, right) => {
|
||||||
|
if (left.matchedBy !== right.matchedBy) {
|
||||||
|
return left.matchedBy === 'md5' ? -1 : 1
|
||||||
|
}
|
||||||
|
if (left.preferredMonth !== right.preferredMonth) {
|
||||||
|
return left.preferredMonth ? -1 : 1
|
||||||
|
}
|
||||||
|
if (left.mtimeMs !== right.mtimeMs) {
|
||||||
|
return right.mtimeMs - left.mtimeMs
|
||||||
|
}
|
||||||
|
return left.searchOrder - right.searchOrder
|
||||||
|
})
|
||||||
|
|
||||||
return candidates
|
return candidates
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4301,14 +4596,20 @@ class ExportService {
|
|||||||
const fileNameRaw = String(msg?.fileName || '').trim()
|
const fileNameRaw = String(msg?.fileName || '').trim()
|
||||||
if (!fileNameRaw) return null
|
if (!fileNameRaw) return null
|
||||||
|
|
||||||
const filesDir = path.join(mediaRootDir, mediaRelativePrefix, 'files')
|
const fileExtDir = this.resolveFileAttachmentExtensionDir(msg, fileNameRaw)
|
||||||
if (!dirCache?.has(filesDir)) {
|
const fileDir = path.join(mediaRootDir, mediaRelativePrefix, 'file', fileExtDir)
|
||||||
await fs.promises.mkdir(filesDir, { recursive: true })
|
if (!dirCache?.has(fileDir)) {
|
||||||
dirCache?.add(filesDir)
|
await fs.promises.mkdir(fileDir, { recursive: true })
|
||||||
|
dirCache?.add(fileDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidates = await this.resolveFileAttachmentCandidates(msg)
|
const candidates = await this.resolveFileAttachmentCandidates(msg)
|
||||||
if (candidates.length === 0) return null
|
if (candidates.length === 0) {
|
||||||
|
this.recordFileAttachmentMiss(msg, '附件候选未命中', {
|
||||||
|
searchRoots: this.resolveFileAttachmentSearchRoots().map(root => root.accountDir)
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const maxBytes = Number.isFinite(maxFileSizeMb)
|
const maxBytes = Number.isFinite(maxFileSizeMb)
|
||||||
? Math.max(0, Math.floor(Number(maxFileSizeMb) * 1024 * 1024))
|
? Math.max(0, Math.floor(Number(maxFileSizeMb) * 1024 * 1024))
|
||||||
@@ -4316,28 +4617,54 @@ class ExportService {
|
|||||||
|
|
||||||
const selected = candidates[0]
|
const selected = candidates[0]
|
||||||
const stat = await fs.promises.stat(selected.sourcePath)
|
const stat = await fs.promises.stat(selected.sourcePath)
|
||||||
if (!stat.isFile()) return null
|
if (!stat.isFile()) {
|
||||||
if (maxBytes > 0 && stat.size > maxBytes) return null
|
this.recordFileAttachmentMiss(msg, '附件候选不是普通文件', {
|
||||||
|
sourcePath: selected.sourcePath
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (maxBytes > 0 && stat.size > maxBytes) {
|
||||||
|
this.recordFileAttachmentMiss(msg, '附件超过大小限制', {
|
||||||
|
sourcePath: selected.sourcePath,
|
||||||
|
size: stat.size,
|
||||||
|
maxBytes
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase()
|
const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase()
|
||||||
if (normalizedMd5 && selected.matchedBy !== 'md5') {
|
if (normalizedMd5 && selected.matchedBy !== 'md5') {
|
||||||
const verified = await this.verifyFileHash(selected.sourcePath, normalizedMd5)
|
this.recordFileAttachmentMiss(msg, '附件哈希校验失败', {
|
||||||
if (!verified) return null
|
sourcePath: selected.sourcePath,
|
||||||
|
expectedMd5: normalizedMd5
|
||||||
|
})
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const safeBaseName = path.basename(fileNameRaw).replace(/[\\/:*?"<>|]/g, '_') || 'file'
|
const safeBaseName = path.basename(fileNameRaw).replace(/[\\/:*?"<>|]/g, '_') || 'file'
|
||||||
const messageId = String(msg?.localId || Date.now())
|
const messageId = String(msg?.localId || Date.now())
|
||||||
const destFileName = `${messageId}_${safeBaseName}`
|
const destFileName = `${messageId}_${safeBaseName}`
|
||||||
const destPath = path.join(filesDir, destFileName)
|
const destPath = path.join(fileDir, destFileName)
|
||||||
const copied = await this.copyFileOptimized(selected.sourcePath, destPath)
|
const copied = await this.copyFileOptimized(selected.sourcePath, destPath)
|
||||||
if (!copied.success) return null
|
if (!copied.success) {
|
||||||
|
this.recordFileAttachmentMiss(msg, '附件复制失败', {
|
||||||
|
sourcePath: selected.sourcePath,
|
||||||
|
destPath,
|
||||||
|
code: copied.code
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
this.noteMediaTelemetry({ doneFiles: 1, bytesWritten: stat.size })
|
this.noteMediaTelemetry({ doneFiles: 1, bytesWritten: stat.size })
|
||||||
return {
|
return {
|
||||||
relativePath: path.posix.join(mediaRelativePrefix, 'files', destFileName),
|
relativePath: path.posix.join(mediaRelativePrefix, 'file', fileExtDir, destFileName),
|
||||||
kind: 'file'
|
kind: 'file'
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
this.logFileAttachmentEvent('error', '附件导出异常', msg, {
|
||||||
|
error: error instanceof Error ? error.message : String(error || 'unknown')
|
||||||
|
})
|
||||||
|
this.noteMediaTelemetry({ cacheMissFiles: 1 })
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4420,6 +4747,38 @@ class ExportService {
|
|||||||
return { exportMediaEnabled, mediaRootDir: outputDir, mediaRelativePrefix }
|
return { exportMediaEnabled, mediaRootDir: outputDir, mediaRelativePrefix }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private collectMediaMessagesForExport(messages: any[], options: ExportOptions): any[] {
|
||||||
|
if (!this.isMediaExportEnabled(options)) return []
|
||||||
|
|
||||||
|
return messages.filter((msg) => {
|
||||||
|
const localType = Number(msg?.localType || 0)
|
||||||
|
return (localType === 3 && options.exportImages) ||
|
||||||
|
(localType === 47 && options.exportEmojis) ||
|
||||||
|
(localType === 43 && options.exportVideos) ||
|
||||||
|
(localType === 34 && options.exportVoices) ||
|
||||||
|
(options.exportFiles === true && this.isFileAppMessage(msg))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMediaDoneFilesCount(): number {
|
||||||
|
return this.mediaExportTelemetry?.doneFiles ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildFileOnlyExportFailure(
|
||||||
|
options: ExportOptions,
|
||||||
|
mediaMessages: any[],
|
||||||
|
beforeDoneFiles: number
|
||||||
|
): { success: boolean; error?: string } | null {
|
||||||
|
if (options.contentType !== 'file') return null
|
||||||
|
if (!mediaMessages.some(msg => this.isFileAppMessage(msg))) return null
|
||||||
|
if (this.getMediaDoneFilesCount() > beforeDoneFiles) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '检测到文件消息,但未找到可导出的源文件,请检查数据库路径或文件存储目录配置'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 下载文件
|
* 下载文件
|
||||||
*/
|
*/
|
||||||
@@ -4485,6 +4844,7 @@ class ExportService {
|
|||||||
const mediaTypeFilter = collectMode === 'media-fast' && targetMediaTypes && targetMediaTypes.size > 0
|
const mediaTypeFilter = collectMode === 'media-fast' && targetMediaTypes && targetMediaTypes.size > 0
|
||||||
? targetMediaTypes
|
? targetMediaTypes
|
||||||
: null
|
: null
|
||||||
|
const fileOnlyMediaFilter = this.isFileOnlyMediaFilter(mediaTypeFilter)
|
||||||
|
|
||||||
// 修复时间范围:0 表示不限制,而不是时间戳 0
|
// 修复时间范围:0 表示不限制,而不是时间戳 0
|
||||||
const beginTime = dateRange?.start || 0
|
const beginTime = dateRange?.start || 0
|
||||||
@@ -4545,12 +4905,14 @@ class ExportService {
|
|||||||
const localType = this.getIntFromRow(row, [
|
const localType = this.getIntFromRow(row, [
|
||||||
'local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'
|
'local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'
|
||||||
], 1)
|
], 1)
|
||||||
if (mediaTypeFilter && !mediaTypeFilter.has(localType)) {
|
const rowFileHints = this.getFileAppMessageHints(row)
|
||||||
|
const allowFileProbe = fileOnlyMediaFilter && this.hasFileAppMessageHints(row)
|
||||||
|
if (mediaTypeFilter && !mediaTypeFilter.has(localType) && !allowFileProbe) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const shouldDecodeContent = collectMode === 'full'
|
const shouldDecodeContent = collectMode === 'full'
|
||||||
|| (collectMode === 'text-fast' && this.shouldDecodeMessageContentInFastMode(localType))
|
|| (collectMode === 'text-fast' && this.shouldDecodeMessageContentInFastMode(localType))
|
||||||
|| (collectMode === 'media-fast' && this.shouldDecodeMessageContentInMediaMode(localType, mediaTypeFilter))
|
|| (collectMode === 'media-fast' && this.shouldDecodeMessageContentInMediaMode(localType, mediaTypeFilter, { allowFileProbe }))
|
||||||
const content = shouldDecodeContent
|
const content = shouldDecodeContent
|
||||||
? this.decodeMessageContent(row.message_content, row.compress_content)
|
? this.decodeMessageContent(row.message_content, row.compress_content)
|
||||||
: ''
|
: ''
|
||||||
@@ -4619,6 +4981,11 @@ class ExportService {
|
|||||||
let locationLabel: string | undefined
|
let locationLabel: string | undefined
|
||||||
let chatRecordList: any[] | undefined
|
let chatRecordList: any[] | undefined
|
||||||
let emojiCaption: string | undefined
|
let emojiCaption: string | undefined
|
||||||
|
let xmlType: string | undefined
|
||||||
|
let fileName: string | undefined
|
||||||
|
let fileSize: number | undefined
|
||||||
|
let fileExt: string | undefined
|
||||||
|
let fileMd5: string | undefined
|
||||||
|
|
||||||
if (localType === 48 && content) {
|
if (localType === 48 && content) {
|
||||||
const locationMeta = this.extractLocationMeta(content, localType)
|
const locationMeta = this.extractLocationMeta(content, localType)
|
||||||
@@ -4649,6 +5016,22 @@ class ExportService {
|
|||||||
imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined
|
imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined
|
||||||
imageDatName = String(row.image_dat_name || row.imageDatName || '').trim() || undefined
|
imageDatName = String(row.image_dat_name || row.imageDatName || '').trim() || undefined
|
||||||
videoMd5 = this.extractVideoFileNameFromRow(row, content)
|
videoMd5 = this.extractVideoFileNameFromRow(row, content)
|
||||||
|
xmlType = rowFileHints.xmlType
|
||||||
|
fileName = rowFileHints.fileName
|
||||||
|
fileExt = rowFileHints.fileExt
|
||||||
|
fileSize = rowFileHints.fileSize
|
||||||
|
fileMd5 = rowFileHints.fileMd5
|
||||||
|
|
||||||
|
if (content && (this.isFileAppLocalType(localType) || allowFileProbe || this.hasFileAppMessageHints({ xmlType, fileName, fileSize, fileExt, fileMd5 }))) {
|
||||||
|
const fileMeta = this.extractFileAppMessageMeta(content)
|
||||||
|
if (fileMeta) {
|
||||||
|
xmlType = fileMeta.xmlType || xmlType
|
||||||
|
fileName = fileMeta.fileName || fileName
|
||||||
|
fileSize = fileMeta.fileSize || fileSize
|
||||||
|
fileExt = fileMeta.fileExt || fileExt
|
||||||
|
fileMd5 = fileMeta.fileMd5 || fileMd5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (localType === 3 && content) {
|
if (localType === 3 && content) {
|
||||||
// 图片消息
|
// 图片消息
|
||||||
@@ -4667,6 +5050,10 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fileOnlyMediaFilter && !this.isFileAppMessage({ localType, xmlType, content, fileName, fileExt, fileMd5, fileSize })) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
rows.push({
|
rows.push({
|
||||||
localId,
|
localId,
|
||||||
serverId,
|
serverId,
|
||||||
@@ -4682,6 +5069,11 @@ class ExportService {
|
|||||||
emojiMd5,
|
emojiMd5,
|
||||||
emojiCaption,
|
emojiCaption,
|
||||||
videoMd5,
|
videoMd5,
|
||||||
|
xmlType,
|
||||||
|
fileName,
|
||||||
|
fileSize,
|
||||||
|
fileExt,
|
||||||
|
fileMd5,
|
||||||
locationLat,
|
locationLat,
|
||||||
locationLng,
|
locationLng,
|
||||||
locationPoiname,
|
locationPoiname,
|
||||||
@@ -4746,7 +5138,12 @@ class ExportService {
|
|||||||
targetMediaTypes: Set<number>,
|
targetMediaTypes: Set<number>,
|
||||||
control?: ExportTaskControl
|
control?: ExportTaskControl
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const fileOnlyMediaFilter = this.isFileOnlyMediaFilter(targetMediaTypes)
|
||||||
const needsBackfill = rows.filter((msg) => {
|
const needsBackfill = rows.filter((msg) => {
|
||||||
|
const isFileCandidate = this.isFileAppLocalType(Number(msg.localType || 0)) || (fileOnlyMediaFilter && this.hasFileAppMessageHints(msg))
|
||||||
|
if (isFileCandidate) {
|
||||||
|
return !msg.xmlType || !msg.fileName || !msg.fileMd5 || !msg.fileSize || !msg.fileExt
|
||||||
|
}
|
||||||
if (!targetMediaTypes.has(msg.localType)) return false
|
if (!targetMediaTypes.has(msg.localType)) return false
|
||||||
if (msg.localType === 3) return !msg.imageMd5 && !msg.imageDatName
|
if (msg.localType === 3) return !msg.imageMd5 && !msg.imageDatName
|
||||||
if (msg.localType === 47) return !msg.emojiMd5
|
if (msg.localType === 47) return !msg.emojiMd5
|
||||||
@@ -4803,6 +5200,24 @@ class ExportService {
|
|||||||
if (msg.localType === 43) {
|
if (msg.localType === 43) {
|
||||||
const videoMd5 = this.extractVideoFileNameFromRow(row, content)
|
const videoMd5 = this.extractVideoFileNameFromRow(row, content)
|
||||||
if (videoMd5) msg.videoMd5 = videoMd5
|
if (videoMd5) msg.videoMd5 = videoMd5
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isFileAppLocalType(Number(msg.localType || 0)) || this.hasFileAppMessageHints(msg)) {
|
||||||
|
const rowFileHints = this.getFileAppMessageHints(row)
|
||||||
|
const fileMeta = this.extractFileAppMessageMeta(content)
|
||||||
|
const mergedFileMeta = {
|
||||||
|
xmlType: fileMeta?.xmlType || rowFileHints.xmlType,
|
||||||
|
fileName: fileMeta?.fileName || rowFileHints.fileName,
|
||||||
|
fileSize: fileMeta?.fileSize || rowFileHints.fileSize,
|
||||||
|
fileExt: fileMeta?.fileExt || rowFileHints.fileExt,
|
||||||
|
fileMd5: fileMeta?.fileMd5 || rowFileHints.fileMd5
|
||||||
|
}
|
||||||
|
if (mergedFileMeta.xmlType) msg.xmlType = mergedFileMeta.xmlType
|
||||||
|
if (mergedFileMeta.fileName) msg.fileName = mergedFileMeta.fileName
|
||||||
|
if (mergedFileMeta.fileSize) msg.fileSize = mergedFileMeta.fileSize
|
||||||
|
if (mergedFileMeta.fileExt) msg.fileExt = mergedFileMeta.fileExt
|
||||||
|
if (mergedFileMeta.fileMd5) msg.fileMd5 = mergedFileMeta.fileMd5
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 详情补取失败时保持降级导出(占位符),避免中断整批任务。
|
// 详情补取失败时保持降级导出(占位符),避免中断整批任务。
|
||||||
@@ -5329,19 +5744,11 @@ class ExportService {
|
|||||||
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
||||||
|
|
||||||
// ========== 阶段1:并行导出媒体文件 ==========
|
// ========== 阶段1:并行导出媒体文件 ==========
|
||||||
const mediaMessages = exportMediaEnabled
|
const mediaMessages = this.collectMediaMessagesForExport(allMessages, options)
|
||||||
? allMessages.filter(msg => {
|
|
||||||
const t = msg.localType
|
|
||||||
return (t === 3 && options.exportImages) || // 图片
|
|
||||||
(t === 47 && options.exportEmojis) || // 表情
|
|
||||||
(t === 43 && options.exportVideos) || // 视频
|
|
||||||
(t === 34 && options.exportVoices) || // 语音文件
|
|
||||||
((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6')
|
|
||||||
})
|
|
||||||
: []
|
|
||||||
|
|
||||||
const mediaCache = new Map<string, MediaExportItem | null>()
|
const mediaCache = new Map<string, MediaExportItem | null>()
|
||||||
const mediaDirCache = new Set<string>()
|
const mediaDirCache = new Set<string>()
|
||||||
|
const beforeMediaDoneFiles = this.getMediaDoneFilesCount()
|
||||||
|
|
||||||
if (mediaMessages.length > 0) {
|
if (mediaMessages.length > 0) {
|
||||||
await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
|
await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
|
||||||
@@ -5400,6 +5807,8 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
|
||||||
|
if (fileOnlyExportFailure) return fileOnlyExportFailure
|
||||||
|
|
||||||
// ========== 阶段2:并行语音转文字 ==========
|
// ========== 阶段2:并行语音转文字 ==========
|
||||||
const voiceTranscriptMap = new Map<string, string>()
|
const voiceTranscriptMap = new Map<string, string>()
|
||||||
@@ -5840,19 +6249,11 @@ class ExportService {
|
|||||||
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
||||||
|
|
||||||
// ========== 阶段1:并行导出媒体文件 ==========
|
// ========== 阶段1:并行导出媒体文件 ==========
|
||||||
const mediaMessages = exportMediaEnabled
|
const mediaMessages = this.collectMediaMessagesForExport(collected.rows, options)
|
||||||
? collected.rows.filter(msg => {
|
|
||||||
const t = msg.localType
|
|
||||||
return (t === 3 && options.exportImages) ||
|
|
||||||
(t === 47 && options.exportEmojis) ||
|
|
||||||
(t === 43 && options.exportVideos) ||
|
|
||||||
(t === 34 && options.exportVoices) ||
|
|
||||||
((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6')
|
|
||||||
})
|
|
||||||
: []
|
|
||||||
|
|
||||||
const mediaCache = new Map<string, MediaExportItem | null>()
|
const mediaCache = new Map<string, MediaExportItem | null>()
|
||||||
const mediaDirCache = new Set<string>()
|
const mediaDirCache = new Set<string>()
|
||||||
|
const beforeMediaDoneFiles = this.getMediaDoneFilesCount()
|
||||||
|
|
||||||
if (mediaMessages.length > 0) {
|
if (mediaMessages.length > 0) {
|
||||||
await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
|
await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
|
||||||
@@ -5910,6 +6311,8 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
|
||||||
|
if (fileOnlyExportFailure) return fileOnlyExportFailure
|
||||||
|
|
||||||
// ========== 阶段2:并行语音转文字 ==========
|
// ========== 阶段2:并行语音转文字 ==========
|
||||||
const voiceTranscriptMap = new Map<string, string>()
|
const voiceTranscriptMap = new Map<string, string>()
|
||||||
@@ -6711,19 +7114,11 @@ class ExportService {
|
|||||||
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
||||||
|
|
||||||
// ========== 并行预处理:媒体文件 ==========
|
// ========== 并行预处理:媒体文件 ==========
|
||||||
const mediaMessages = exportMediaEnabled
|
const mediaMessages = this.collectMediaMessagesForExport(sortedMessages, options)
|
||||||
? sortedMessages.filter(msg => {
|
|
||||||
const t = msg.localType
|
|
||||||
return (t === 3 && options.exportImages) ||
|
|
||||||
(t === 47 && options.exportEmojis) ||
|
|
||||||
(t === 43 && options.exportVideos) ||
|
|
||||||
(t === 34 && options.exportVoices) ||
|
|
||||||
((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6')
|
|
||||||
})
|
|
||||||
: []
|
|
||||||
|
|
||||||
const mediaCache = new Map<string, MediaExportItem | null>()
|
const mediaCache = new Map<string, MediaExportItem | null>()
|
||||||
const mediaDirCache = new Set<string>()
|
const mediaDirCache = new Set<string>()
|
||||||
|
const beforeMediaDoneFiles = this.getMediaDoneFilesCount()
|
||||||
|
|
||||||
if (mediaMessages.length > 0) {
|
if (mediaMessages.length > 0) {
|
||||||
await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
|
await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
|
||||||
@@ -6781,6 +7176,8 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
|
||||||
|
if (fileOnlyExportFailure) return fileOnlyExportFailure
|
||||||
|
|
||||||
// ========== 并行预处理:语音转文字 ==========
|
// ========== 并行预处理:语音转文字 ==========
|
||||||
const voiceTranscriptMap = new Map<string, string>()
|
const voiceTranscriptMap = new Map<string, string>()
|
||||||
@@ -7461,19 +7858,11 @@ class ExportService {
|
|||||||
const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime)
|
const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime)
|
||||||
|
|
||||||
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
||||||
const mediaMessages = exportMediaEnabled
|
const mediaMessages = this.collectMediaMessagesForExport(sortedMessages, options)
|
||||||
? sortedMessages.filter(msg => {
|
|
||||||
const t = msg.localType
|
|
||||||
return (t === 3 && options.exportImages) ||
|
|
||||||
(t === 47 && options.exportEmojis) ||
|
|
||||||
(t === 43 && options.exportVideos) ||
|
|
||||||
(t === 34 && options.exportVoices) ||
|
|
||||||
((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6')
|
|
||||||
})
|
|
||||||
: []
|
|
||||||
|
|
||||||
const mediaCache = new Map<string, MediaExportItem | null>()
|
const mediaCache = new Map<string, MediaExportItem | null>()
|
||||||
const mediaDirCache = new Set<string>()
|
const mediaDirCache = new Set<string>()
|
||||||
|
const beforeMediaDoneFiles = this.getMediaDoneFilesCount()
|
||||||
|
|
||||||
if (mediaMessages.length > 0) {
|
if (mediaMessages.length > 0) {
|
||||||
await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
|
await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
|
||||||
@@ -7531,6 +7920,8 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
|
||||||
|
if (fileOnlyExportFailure) return fileOnlyExportFailure
|
||||||
|
|
||||||
const voiceTranscriptMap = new Map<string, string>()
|
const voiceTranscriptMap = new Map<string, string>()
|
||||||
|
|
||||||
@@ -7840,19 +8231,11 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
||||||
const mediaMessages = exportMediaEnabled
|
const mediaMessages = this.collectMediaMessagesForExport(sortedMessages, options)
|
||||||
? sortedMessages.filter(msg => {
|
|
||||||
const t = msg.localType
|
|
||||||
return (t === 3 && options.exportImages) ||
|
|
||||||
(t === 47 && options.exportEmojis) ||
|
|
||||||
(t === 43 && options.exportVideos) ||
|
|
||||||
(t === 34 && options.exportVoices) ||
|
|
||||||
((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6')
|
|
||||||
})
|
|
||||||
: []
|
|
||||||
|
|
||||||
const mediaCache = new Map<string, MediaExportItem | null>()
|
const mediaCache = new Map<string, MediaExportItem | null>()
|
||||||
const mediaDirCache = new Set<string>()
|
const mediaDirCache = new Set<string>()
|
||||||
|
const beforeMediaDoneFiles = this.getMediaDoneFilesCount()
|
||||||
|
|
||||||
if (mediaMessages.length > 0) {
|
if (mediaMessages.length > 0) {
|
||||||
await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
|
await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
|
||||||
@@ -7910,6 +8293,8 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
|
||||||
|
if (fileOnlyExportFailure) return fileOnlyExportFailure
|
||||||
|
|
||||||
const voiceTranscriptMap = new Map<string, string>()
|
const voiceTranscriptMap = new Map<string, string>()
|
||||||
|
|
||||||
@@ -8263,18 +8648,11 @@ class ExportService {
|
|||||||
const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime)
|
const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime)
|
||||||
|
|
||||||
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
||||||
const mediaMessages = exportMediaEnabled
|
const mediaMessages = this.collectMediaMessagesForExport(sortedMessages, options)
|
||||||
? sortedMessages.filter(msg => {
|
|
||||||
const t = msg.localType
|
|
||||||
return (t === 3 && options.exportImages) ||
|
|
||||||
(t === 47 && options.exportEmojis) ||
|
|
||||||
(t === 34 && options.exportVoices) ||
|
|
||||||
(t === 43 && options.exportVideos)
|
|
||||||
})
|
|
||||||
: []
|
|
||||||
|
|
||||||
const mediaCache = new Map<string, MediaExportItem | null>()
|
const mediaCache = new Map<string, MediaExportItem | null>()
|
||||||
const mediaDirCache = new Set<string>()
|
const mediaDirCache = new Set<string>()
|
||||||
|
const beforeMediaDoneFiles = this.getMediaDoneFilesCount()
|
||||||
|
|
||||||
if (mediaMessages.length > 0) {
|
if (mediaMessages.length > 0) {
|
||||||
await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
|
await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
|
||||||
@@ -8333,6 +8711,8 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
|
||||||
|
if (fileOnlyExportFailure) return fileOnlyExportFailure
|
||||||
|
|
||||||
const useVoiceTranscript = options.exportVoiceAsText === true
|
const useVoiceTranscript = options.exportVoiceAsText === true
|
||||||
const voiceMessages = useVoiceTranscript
|
const voiceMessages = useVoiceTranscript
|
||||||
@@ -9051,7 +9431,7 @@ class ExportService {
|
|||||||
: options
|
: options
|
||||||
|
|
||||||
const exportMediaEnabled = effectiveOptions.exportMedia === true &&
|
const exportMediaEnabled = effectiveOptions.exportMedia === true &&
|
||||||
Boolean(effectiveOptions.exportImages || effectiveOptions.exportVoices || effectiveOptions.exportVideos || effectiveOptions.exportEmojis)
|
Boolean(effectiveOptions.exportImages || effectiveOptions.exportVoices || effectiveOptions.exportVideos || effectiveOptions.exportEmojis || effectiveOptions.exportFiles)
|
||||||
attachMediaTelemetry = exportMediaEnabled
|
attachMediaTelemetry = exportMediaEnabled
|
||||||
if (exportMediaEnabled) {
|
if (exportMediaEnabled) {
|
||||||
this.triggerMediaFileCacheCleanup()
|
this.triggerMediaFileCacheCleanup()
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ const INSIGHT_CONFIG_KEYS = new Set([
|
|||||||
'aiModelApiKey',
|
'aiModelApiKey',
|
||||||
'aiModelApiModel',
|
'aiModelApiModel',
|
||||||
'aiModelApiMaxTokens',
|
'aiModelApiMaxTokens',
|
||||||
|
'aiInsightFilterMode',
|
||||||
|
'aiInsightFilterList',
|
||||||
'aiInsightAllowSocialContext',
|
'aiInsightAllowSocialContext',
|
||||||
'aiInsightSocialContextCount',
|
'aiInsightSocialContextCount',
|
||||||
'aiInsightWeiboCookie',
|
'aiInsightWeiboCookie',
|
||||||
@@ -73,6 +75,8 @@ interface SharedAiModelConfig {
|
|||||||
maxTokens: number
|
maxTokens: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type InsightFilterMode = 'whitelist' | 'blacklist'
|
||||||
|
|
||||||
// ─── 日志 ─────────────────────────────────────────────────────────────────────
|
// ─── 日志 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type InsightLogLevel = 'INFO' | 'WARN' | 'ERROR'
|
type InsightLogLevel = 'INFO' | 'WARN' | 'ERROR'
|
||||||
@@ -196,6 +200,11 @@ function normalizeApiMaxTokens(value: unknown): number {
|
|||||||
return Math.min(API_MAX_TOKENS_MAX, Math.max(API_MAX_TOKENS_MIN, Math.floor(numeric)))
|
return Math.min(API_MAX_TOKENS_MAX, Math.max(API_MAX_TOKENS_MIN, Math.floor(numeric)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeSessionIdList(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean)))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 调用 OpenAI 兼容 API(非流式),返回模型第一条消息内容。
|
* 调用 OpenAI 兼容 API(非流式),返回模型第一条消息内容。
|
||||||
* 使用 Node 原生 https/http 模块,无需任何第三方 SDK。
|
* 使用 Node 原生 https/http 模块,无需任何第三方 SDK。
|
||||||
@@ -495,7 +504,7 @@ class InsightService {
|
|||||||
return id && !id.endsWith('@chatroom') && !id.toLowerCase().includes('placeholder') && this.isSessionAllowed(id)
|
return id && !id.endsWith('@chatroom') && !id.toLowerCase().includes('placeholder') && this.isSessionAllowed(id)
|
||||||
})
|
})
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return { success: false, message: '未找到任何私聊会话(若已启用白名单,请检查是否有勾选的私聊)' }
|
return { success: false, message: '未找到任何可触发的私聊会话(请检查黑白名单模式与选择列表)' }
|
||||||
}
|
}
|
||||||
const sessionId = session.username?.trim() || ''
|
const sessionId = session.username?.trim() || ''
|
||||||
const displayName = session.displayName || sessionId
|
const displayName = session.displayName || sessionId
|
||||||
@@ -747,14 +756,23 @@ ${topMentionText}
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断某个会话是否允许触发见解。
|
* 判断某个会话是否允许触发见解。
|
||||||
* 若白名单未启用,则所有私聊会话均允许;
|
* white/black 模式二选一:
|
||||||
* 若白名单已启用,则只有在白名单中的会话才允许。
|
* - whitelist:仅名单内允许
|
||||||
|
* - blacklist:名单内屏蔽,其他允许
|
||||||
*/
|
*/
|
||||||
|
private getInsightFilterConfig(): { mode: InsightFilterMode; list: string[] } {
|
||||||
|
const modeRaw = String(this.config.get('aiInsightFilterMode') || '').trim().toLowerCase()
|
||||||
|
const mode: InsightFilterMode = modeRaw === 'blacklist' ? 'blacklist' : 'whitelist'
|
||||||
|
const list = normalizeSessionIdList(this.config.get('aiInsightFilterList'))
|
||||||
|
return { mode, list }
|
||||||
|
}
|
||||||
|
|
||||||
private isSessionAllowed(sessionId: string): boolean {
|
private isSessionAllowed(sessionId: string): boolean {
|
||||||
const whitelistEnabled = this.config.get('aiInsightWhitelistEnabled') as boolean
|
const normalizedSessionId = String(sessionId || '').trim()
|
||||||
if (!whitelistEnabled) return true
|
if (!normalizedSessionId) return false
|
||||||
const whitelist = (this.config.get('aiInsightWhitelist') as string[]) || []
|
const { mode, list } = this.getInsightFilterConfig()
|
||||||
return whitelist.includes(sessionId)
|
if (mode === 'whitelist') return list.includes(normalizedSessionId)
|
||||||
|
return !list.includes(normalizedSessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -966,8 +984,8 @@ ${topMentionText}
|
|||||||
* 1. 会话有真正的新消息(lastTimestamp 比上次见到的更新)
|
* 1. 会话有真正的新消息(lastTimestamp 比上次见到的更新)
|
||||||
* 2. 该会话距上次活跃分析已超过冷却期
|
* 2. 该会话距上次活跃分析已超过冷却期
|
||||||
*
|
*
|
||||||
* 白名单启用时:直接使用白名单里的 sessionId,完全跳过 getSessions()。
|
* whitelist 模式:直接使用名单里的 sessionId,完全跳过 getSessions()。
|
||||||
* 白名单未启用时:从缓存拉取全量会话后过滤私聊。
|
* blacklist 模式:从缓存拉取会话后过滤名单。
|
||||||
*/
|
*/
|
||||||
private async analyzeRecentActivity(): Promise<void> {
|
private async analyzeRecentActivity(): Promise<void> {
|
||||||
if (!this.isEnabled()) return
|
if (!this.isEnabled()) return
|
||||||
@@ -978,12 +996,11 @@ ${topMentionText}
|
|||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const cooldownMinutes = (this.config.get('aiInsightCooldownMinutes') as number) ?? 120
|
const cooldownMinutes = (this.config.get('aiInsightCooldownMinutes') as number) ?? 120
|
||||||
const cooldownMs = cooldownMinutes * 60 * 1000
|
const cooldownMs = cooldownMinutes * 60 * 1000
|
||||||
const whitelistEnabled = this.config.get('aiInsightWhitelistEnabled') as boolean
|
const { mode: filterMode, list: filterList } = this.getInsightFilterConfig()
|
||||||
const whitelist = (this.config.get('aiInsightWhitelist') as string[]) || []
|
|
||||||
|
|
||||||
// 白名单启用且有勾选项时,直接用白名单 sessionId,无需查数据库全量会话列表。
|
// whitelist 模式且有勾选项时,直接用名单 sessionId,无需查数据库全量会话列表。
|
||||||
// 通过拉取该会话最新 1 条消息时间戳判断是否真正有新消息,开销极低。
|
// 通过拉取该会话最新 1 条消息时间戳判断是否真正有新消息,开销极低。
|
||||||
if (whitelistEnabled && whitelist.length > 0) {
|
if (filterMode === 'whitelist' && filterList.length > 0) {
|
||||||
// 确保数据库已连接(首次时连接,之后复用)
|
// 确保数据库已连接(首次时连接,之后复用)
|
||||||
if (!this.dbConnected) {
|
if (!this.dbConnected) {
|
||||||
const connectResult = await chatService.connect()
|
const connectResult = await chatService.connect()
|
||||||
@@ -991,8 +1008,8 @@ ${topMentionText}
|
|||||||
this.dbConnected = true
|
this.dbConnected = true
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const sessionId of whitelist) {
|
for (const sessionId of filterList) {
|
||||||
if (!sessionId || sessionId.endsWith('@chatroom')) continue
|
if (!sessionId || sessionId.toLowerCase().includes('placeholder')) continue
|
||||||
|
|
||||||
// 冷却期检查(先过滤,减少不必要的 DB 查询)
|
// 冷却期检查(先过滤,减少不必要的 DB 查询)
|
||||||
if (cooldownMs > 0) {
|
if (cooldownMs > 0) {
|
||||||
@@ -1029,16 +1046,22 @@ ${topMentionText}
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 白名单未启用:需要拉取全量会话列表,从中过滤私聊
|
if (filterMode === 'whitelist' && filterList.length === 0) {
|
||||||
|
insightLog('INFO', '白名单模式且名单为空,跳过活跃分析')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// blacklist 模式:拉取会话缓存后按过滤规则筛选
|
||||||
const sessions = await this.getSessionsCached()
|
const sessions = await this.getSessionsCached()
|
||||||
if (sessions.length === 0) return
|
if (sessions.length === 0) return
|
||||||
|
|
||||||
const privateSessions = sessions.filter((s) => {
|
const candidateSessions = sessions.filter((s) => {
|
||||||
const id = s.username?.trim() || ''
|
const id = s.username?.trim() || ''
|
||||||
return id && !id.endsWith('@chatroom') && !id.toLowerCase().includes('placeholder')
|
if (!id || id.toLowerCase().includes('placeholder')) return false
|
||||||
|
return this.isSessionAllowed(id)
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const session of privateSessions.slice(0, 10)) {
|
for (const session of candidateSessions.slice(0, 10)) {
|
||||||
const sessionId = session.username?.trim() || ''
|
const sessionId = session.username?.trim() || ''
|
||||||
if (!sessionId) continue
|
if (!sessionId) continue
|
||||||
|
|
||||||
|
|||||||
@@ -25,9 +25,7 @@ export class WcdbService {
|
|||||||
private logEnabled = false
|
private logEnabled = false
|
||||||
private monitorListener: ((type: string, json: string) => void) | null = null
|
private monitorListener: ((type: string, json: string) => void) | null = null
|
||||||
|
|
||||||
constructor() {
|
constructor() {}
|
||||||
this.initWorker()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化 Worker 线程
|
* 初始化 Worker 线程
|
||||||
|
|||||||
@@ -13,13 +13,13 @@
|
|||||||
},
|
},
|
||||||
"//": "二改不应改变此处的作者与应用信息",
|
"//": "二改不应改变此处的作者与应用信息",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "electron-builder install-app-deps",
|
"postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs",
|
||||||
"rebuild": "electron-rebuild",
|
"rebuild": "electron-rebuild",
|
||||||
"dev": "vite",
|
"dev": "node scripts/prepare-electron-runtime.cjs && vite",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"build": "tsc && vite build && electron-builder",
|
"build": "tsc && vite build && electron-builder",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"electron:dev": "vite --mode electron",
|
"electron:dev": "node scripts/prepare-electron-runtime.cjs && vite --mode electron",
|
||||||
"electron:build": "npm run build"
|
"electron:build": "npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
57
scripts/prepare-electron-runtime.cjs
Normal file
57
scripts/prepare-electron-runtime.cjs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const runtimeNames = [
|
||||||
|
'msvcp140.dll',
|
||||||
|
'msvcp140_1.dll',
|
||||||
|
'vcruntime140.dll',
|
||||||
|
'vcruntime140_1.dll',
|
||||||
|
];
|
||||||
|
|
||||||
|
function copyIfDifferent(sourcePath, targetPath) {
|
||||||
|
const source = fs.statSync(sourcePath);
|
||||||
|
const targetExists = fs.existsSync(targetPath);
|
||||||
|
|
||||||
|
if (targetExists) {
|
||||||
|
const target = fs.statSync(targetPath);
|
||||||
|
if (target.size === source.size && target.mtimeMs >= source.mtimeMs) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.copyFileSync(sourcePath, targetPath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectRoot = path.resolve(__dirname, '..');
|
||||||
|
const sourceDir = path.join(projectRoot, 'resources', 'runtime', 'win32');
|
||||||
|
const targetDir = path.join(projectRoot, 'node_modules', 'electron', 'dist');
|
||||||
|
|
||||||
|
if (!fs.existsSync(sourceDir) || !fs.existsSync(targetDir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let copiedCount = 0;
|
||||||
|
|
||||||
|
for (const name of runtimeNames) {
|
||||||
|
const sourcePath = path.join(sourceDir, name);
|
||||||
|
const targetPath = path.join(targetDir, name);
|
||||||
|
if (!fs.existsSync(sourcePath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (copyIfDifferent(sourcePath, targetPath)) {
|
||||||
|
copiedCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (copiedCount > 0) {
|
||||||
|
console.log(`[prepare-electron-runtime] synced ${copiedCount} runtime DLL(s) to ${targetDir}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -1898,6 +1898,9 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
|||||||
const mediaCacheMetricLabel = mediaCacheTotal > 0
|
const mediaCacheMetricLabel = mediaCacheTotal > 0
|
||||||
? `缓存命中 ${mediaCacheHitFiles}/${mediaCacheTotal}`
|
? `缓存命中 ${mediaCacheHitFiles}/${mediaCacheTotal}`
|
||||||
: ''
|
: ''
|
||||||
|
const mediaMissMetricLabel = mediaCacheMissFiles > 0
|
||||||
|
? `未导出 ${mediaCacheMissFiles} 个文件/媒体`
|
||||||
|
: ''
|
||||||
const mediaDedupMetricLabel = mediaDedupReuseFiles > 0
|
const mediaDedupMetricLabel = mediaDedupReuseFiles > 0
|
||||||
? `复用 ${mediaDedupReuseFiles}`
|
? `复用 ${mediaDedupReuseFiles}`
|
||||||
: ''
|
: ''
|
||||||
@@ -1958,6 +1961,7 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
|||||||
{phaseMetricLabel ? ` · ${phaseMetricLabel}` : ''}
|
{phaseMetricLabel ? ` · ${phaseMetricLabel}` : ''}
|
||||||
{mediaLiveMetricLabel ? ` · ${mediaLiveMetricLabel}` : ''}
|
{mediaLiveMetricLabel ? ` · ${mediaLiveMetricLabel}` : ''}
|
||||||
{mediaCacheMetricLabel ? ` · ${mediaCacheMetricLabel}` : ''}
|
{mediaCacheMetricLabel ? ` · ${mediaCacheMetricLabel}` : ''}
|
||||||
|
{mediaMissMetricLabel ? ` · ${mediaMissMetricLabel}` : ''}
|
||||||
{mediaDedupMetricLabel ? ` · ${mediaDedupMetricLabel}` : ''}
|
{mediaDedupMetricLabel ? ` · ${mediaDedupMetricLabel}` : ''}
|
||||||
{task.status === 'running' && currentSessionRatio !== null
|
{task.status === 'running' && currentSessionRatio !== null
|
||||||
? `(当前会话 ${Math.round(currentSessionRatio * 100)}%)`
|
? `(当前会话 ${Math.round(currentSessionRatio * 100)}%)`
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ interface WxidOption {
|
|||||||
type SessionFilterType = configService.MessagePushSessionType
|
type SessionFilterType = configService.MessagePushSessionType
|
||||||
type SessionFilterTypeValue = 'all' | SessionFilterType
|
type SessionFilterTypeValue = 'all' | SessionFilterType
|
||||||
type SessionFilterMode = 'all' | 'whitelist' | 'blacklist'
|
type SessionFilterMode = 'all' | 'whitelist' | 'blacklist'
|
||||||
|
type InsightSessionFilterTypeValue = 'all' | 'private' | 'group' | 'official'
|
||||||
|
|
||||||
interface SessionFilterOption {
|
interface SessionFilterOption {
|
||||||
username: string
|
username: string
|
||||||
@@ -91,6 +92,13 @@ const sessionFilterTypeOptions: Array<{ value: SessionFilterTypeValue; label: st
|
|||||||
{ value: 'other', label: '其他/非好友' }
|
{ value: 'other', label: '其他/非好友' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const insightFilterTypeOptions: Array<{ value: InsightSessionFilterTypeValue; label: string }> = [
|
||||||
|
{ value: 'all', label: '全部' },
|
||||||
|
{ value: 'private', label: '私聊' },
|
||||||
|
{ value: 'group', label: '群聊' },
|
||||||
|
{ value: 'official', label: '订阅号/服务号' }
|
||||||
|
]
|
||||||
|
|
||||||
interface SettingsPageProps {
|
interface SettingsPageProps {
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
}
|
}
|
||||||
@@ -194,6 +202,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false)
|
const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false)
|
||||||
const [positionDropdownOpen, setPositionDropdownOpen] = useState(false)
|
const [positionDropdownOpen, setPositionDropdownOpen] = useState(false)
|
||||||
const [closeBehaviorDropdownOpen, setCloseBehaviorDropdownOpen] = useState(false)
|
const [closeBehaviorDropdownOpen, setCloseBehaviorDropdownOpen] = useState(false)
|
||||||
|
const [insightFilterModeDropdownOpen, setInsightFilterModeDropdownOpen] = useState(false)
|
||||||
|
|
||||||
const [wordCloudExcludeWords, setWordCloudExcludeWords] = useState<string[]>([])
|
const [wordCloudExcludeWords, setWordCloudExcludeWords] = useState<string[]>([])
|
||||||
const [excludeWordsInput, setExcludeWordsInput] = useState('')
|
const [excludeWordsInput, setExcludeWordsInput] = useState('')
|
||||||
@@ -275,8 +284,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const [showInsightApiKey, setShowInsightApiKey] = useState(false)
|
const [showInsightApiKey, setShowInsightApiKey] = useState(false)
|
||||||
const [isTriggeringInsightTest, setIsTriggeringInsightTest] = useState(false)
|
const [isTriggeringInsightTest, setIsTriggeringInsightTest] = useState(false)
|
||||||
const [insightTriggerResult, setInsightTriggerResult] = useState<{ success: boolean; message: string } | null>(null)
|
const [insightTriggerResult, setInsightTriggerResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||||
const [aiInsightWhitelistEnabled, setAiInsightWhitelistEnabled] = useState(false)
|
const [aiInsightFilterMode, setAiInsightFilterMode] = useState<configService.AiInsightFilterMode>('whitelist')
|
||||||
const [aiInsightWhitelist, setAiInsightWhitelist] = useState<Set<string>>(new Set())
|
const [aiInsightFilterList, setAiInsightFilterList] = useState<Set<string>>(new Set())
|
||||||
|
const [insightFilterType, setInsightFilterType] = useState<InsightSessionFilterTypeValue>('all')
|
||||||
const [insightWhitelistSearch, setInsightWhitelistSearch] = useState('')
|
const [insightWhitelistSearch, setInsightWhitelistSearch] = useState('')
|
||||||
const [aiInsightCooldownMinutes, setAiInsightCooldownMinutes] = useState(120)
|
const [aiInsightCooldownMinutes, setAiInsightCooldownMinutes] = useState(120)
|
||||||
const [aiInsightScanIntervalHours, setAiInsightScanIntervalHours] = useState(4)
|
const [aiInsightScanIntervalHours, setAiInsightScanIntervalHours] = useState(4)
|
||||||
@@ -397,15 +407,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
setPositionDropdownOpen(false)
|
setPositionDropdownOpen(false)
|
||||||
setCloseBehaviorDropdownOpen(false)
|
setCloseBehaviorDropdownOpen(false)
|
||||||
setMessagePushFilterDropdownOpen(false)
|
setMessagePushFilterDropdownOpen(false)
|
||||||
|
setInsightFilterModeDropdownOpen(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen || messagePushFilterDropdownOpen) {
|
if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen || messagePushFilterDropdownOpen || insightFilterModeDropdownOpen) {
|
||||||
document.addEventListener('click', handleClickOutside)
|
document.addEventListener('click', handleClickOutside)
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('click', handleClickOutside)
|
document.removeEventListener('click', handleClickOutside)
|
||||||
}
|
}
|
||||||
}, [closeBehaviorDropdownOpen, filterModeDropdownOpen, messagePushFilterDropdownOpen, positionDropdownOpen])
|
}, [closeBehaviorDropdownOpen, filterModeDropdownOpen, insightFilterModeDropdownOpen, messagePushFilterDropdownOpen, positionDropdownOpen])
|
||||||
|
|
||||||
|
|
||||||
const loadConfig = async () => {
|
const loadConfig = async () => {
|
||||||
@@ -531,8 +542,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const savedAiModelApiMaxTokens = await configService.getAiModelApiMaxTokens()
|
const savedAiModelApiMaxTokens = await configService.getAiModelApiMaxTokens()
|
||||||
const savedAiInsightSilenceDays = await configService.getAiInsightSilenceDays()
|
const savedAiInsightSilenceDays = await configService.getAiInsightSilenceDays()
|
||||||
const savedAiInsightAllowContext = await configService.getAiInsightAllowContext()
|
const savedAiInsightAllowContext = await configService.getAiInsightAllowContext()
|
||||||
const savedAiInsightWhitelistEnabled = await configService.getAiInsightWhitelistEnabled()
|
const savedAiInsightFilterMode = await configService.getAiInsightFilterMode()
|
||||||
const savedAiInsightWhitelist = await configService.getAiInsightWhitelist()
|
const savedAiInsightFilterList = await configService.getAiInsightFilterList()
|
||||||
const savedAiInsightCooldownMinutes = await configService.getAiInsightCooldownMinutes()
|
const savedAiInsightCooldownMinutes = await configService.getAiInsightCooldownMinutes()
|
||||||
const savedAiInsightScanIntervalHours = await configService.getAiInsightScanIntervalHours()
|
const savedAiInsightScanIntervalHours = await configService.getAiInsightScanIntervalHours()
|
||||||
const savedAiInsightContextCount = await configService.getAiInsightContextCount()
|
const savedAiInsightContextCount = await configService.getAiInsightContextCount()
|
||||||
@@ -555,8 +566,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
setAiModelApiMaxTokens(savedAiModelApiMaxTokens)
|
setAiModelApiMaxTokens(savedAiModelApiMaxTokens)
|
||||||
setAiInsightSilenceDays(savedAiInsightSilenceDays)
|
setAiInsightSilenceDays(savedAiInsightSilenceDays)
|
||||||
setAiInsightAllowContext(savedAiInsightAllowContext)
|
setAiInsightAllowContext(savedAiInsightAllowContext)
|
||||||
setAiInsightWhitelistEnabled(savedAiInsightWhitelistEnabled)
|
setAiInsightFilterMode(savedAiInsightFilterMode)
|
||||||
setAiInsightWhitelist(new Set(savedAiInsightWhitelist))
|
setAiInsightFilterList(new Set(savedAiInsightFilterList))
|
||||||
setAiInsightCooldownMinutes(savedAiInsightCooldownMinutes)
|
setAiInsightCooldownMinutes(savedAiInsightCooldownMinutes)
|
||||||
setAiInsightScanIntervalHours(savedAiInsightScanIntervalHours)
|
setAiInsightScanIntervalHours(savedAiInsightScanIntervalHours)
|
||||||
setAiInsightContextCount(savedAiInsightContextCount)
|
setAiInsightContextCount(savedAiInsightContextCount)
|
||||||
@@ -3390,98 +3401,129 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
|
|
||||||
<div className="divider" />
|
<div className="divider" />
|
||||||
|
|
||||||
{/* 对话白名单 */}
|
{/* 对话过滤名单 */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const sortedSessions = [...chatSessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0))
|
const selectableSessions = sessionFilterOptions.filter((session) =>
|
||||||
|
session.type === 'private' || session.type === 'group' || session.type === 'official'
|
||||||
|
)
|
||||||
const keyword = insightWhitelistSearch.trim().toLowerCase()
|
const keyword = insightWhitelistSearch.trim().toLowerCase()
|
||||||
const filteredSessions = sortedSessions.filter((s) => {
|
const filteredSessions = selectableSessions.filter((session) => {
|
||||||
const id = s.username?.trim() || ''
|
if (insightFilterType !== 'all' && session.type !== insightFilterType) return false
|
||||||
if (!id || id.endsWith('@chatroom') || id.toLowerCase().includes('placeholder')) return false
|
const id = session.username?.trim() || ''
|
||||||
|
if (!id || id.toLowerCase().includes('placeholder')) return false
|
||||||
if (!keyword) return true
|
if (!keyword) return true
|
||||||
return (
|
return (
|
||||||
String(s.displayName || '').toLowerCase().includes(keyword) ||
|
String(session.displayName || '').toLowerCase().includes(keyword) ||
|
||||||
id.toLowerCase().includes(keyword)
|
id.toLowerCase().includes(keyword)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
const filteredIds = filteredSessions.map((s) => s.username)
|
const filteredIds = filteredSessions.map((session) => session.username)
|
||||||
const selectedCount = aiInsightWhitelist.size
|
const selectedCount = aiInsightFilterList.size
|
||||||
const selectedInFilteredCount = filteredIds.filter((id) => aiInsightWhitelist.has(id)).length
|
const selectedInFilteredCount = filteredIds.filter((id) => aiInsightFilterList.has(id)).length
|
||||||
const allFilteredSelected = filteredIds.length > 0 && selectedInFilteredCount === filteredIds.length
|
const allFilteredSelected = filteredIds.length > 0 && selectedInFilteredCount === filteredIds.length
|
||||||
|
|
||||||
const toggleSession = (id: string) => {
|
const saveFilterList = async (next: Set<string>) => {
|
||||||
setAiInsightWhitelist((prev) => {
|
await configService.setAiInsightFilterList(Array.from(next))
|
||||||
const next = new Set(prev)
|
|
||||||
if (next.has(id)) next.delete(id)
|
|
||||||
else next.add(id)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveWhitelist = async (next: Set<string>) => {
|
const saveFilterMode = async (mode: configService.AiInsightFilterMode) => {
|
||||||
await configService.setAiInsightWhitelist(Array.from(next))
|
setAiInsightFilterMode(mode)
|
||||||
|
setInsightFilterModeDropdownOpen(false)
|
||||||
|
await configService.setAiInsightFilterMode(mode)
|
||||||
|
showMessage(mode === 'whitelist' ? '已切换为白名单模式' : '已切换为黑名单模式', true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectAllFiltered = () => {
|
const selectAllFiltered = () => {
|
||||||
setAiInsightWhitelist((prev) => {
|
setAiInsightFilterList((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
for (const id of filteredIds) next.add(id)
|
for (const id of filteredIds) next.add(id)
|
||||||
void saveWhitelist(next)
|
void saveFilterList(next)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearSelection = () => {
|
const clearSelection = () => {
|
||||||
const next = new Set<string>()
|
const next = new Set<string>()
|
||||||
setAiInsightWhitelist(next)
|
setAiInsightFilterList(next)
|
||||||
void saveWhitelist(next)
|
void saveFilterList(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="anti-revoke-tab insight-social-tab">
|
<div className="anti-revoke-tab insight-social-tab">
|
||||||
<div className="anti-revoke-hero">
|
<div className="anti-revoke-hero">
|
||||||
<div className="anti-revoke-hero-main">
|
<div className="anti-revoke-hero-main">
|
||||||
<h3>对话白名单</h3>
|
<h3>对话黑白名单</h3>
|
||||||
<p>
|
<p>
|
||||||
开启后,AI 见解仅对勾选的私聊对话生效,未勾选的对话将被完全忽略。关闭时对所有私聊均生效。中间可填写微博 UID。
|
白名单模式下仅对已选会话触发见解;黑名单模式下会跳过已选会话。默认白名单且不选择任何会话。支持私聊、群聊、订阅号/服务号分类筛选后批量选择。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="anti-revoke-metrics">
|
<div className="anti-revoke-metrics">
|
||||||
<div className="anti-revoke-metric is-total">
|
<div className="anti-revoke-metric is-total">
|
||||||
<span className="label">私聊总数</span>
|
<span className="label">可选会话总数</span>
|
||||||
<span className="value">{filteredIds.length + (keyword ? 0 : 0)}</span>
|
<span className="value">{selectableSessions.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="anti-revoke-metric is-installed">
|
<div className="anti-revoke-metric is-installed">
|
||||||
<span className="label">已选中</span>
|
<span className="label">已加入名单</span>
|
||||||
<span className="value">{selectedCount}</span>
|
<span className="value">{selectedCount}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="log-toggle-line" style={{ marginBottom: 12 }}>
|
<div className="form-group" style={{ marginBottom: 12 }}>
|
||||||
|
<div className="log-toggle-line">
|
||||||
<span className="log-status" style={{ fontWeight: 600 }}>
|
<span className="log-status" style={{ fontWeight: 600 }}>
|
||||||
{aiInsightWhitelistEnabled ? '白名单已启用(仅对勾选对话生效)' : '白名单未启用(对所有私聊生效)'}
|
{aiInsightFilterMode === 'whitelist'
|
||||||
|
? '白名单模式(仅对名单内会话生效)'
|
||||||
|
: '黑名单模式(名单内会话将被忽略)'}
|
||||||
</span>
|
</span>
|
||||||
<label className="switch">
|
<div className="custom-select" style={{ minWidth: 210 }}>
|
||||||
<input
|
<div
|
||||||
type="checkbox"
|
className={`custom-select-trigger ${insightFilterModeDropdownOpen ? 'open' : ''}`}
|
||||||
checked={aiInsightWhitelistEnabled}
|
onClick={() => setInsightFilterModeDropdownOpen(!insightFilterModeDropdownOpen)}
|
||||||
onChange={async (e) => {
|
>
|
||||||
const val = e.target.checked
|
<span className="custom-select-value">
|
||||||
setAiInsightWhitelistEnabled(val)
|
{aiInsightFilterMode === 'whitelist' ? '白名单模式' : '黑名单模式'}
|
||||||
await configService.setAiInsightWhitelistEnabled(val)
|
</span>
|
||||||
}}
|
<ChevronDown size={14} className={`custom-select-arrow ${insightFilterModeDropdownOpen ? 'rotate' : ''}`} />
|
||||||
/>
|
</div>
|
||||||
<span className="switch-slider" />
|
<div className={`custom-select-dropdown ${insightFilterModeDropdownOpen ? 'open' : ''}`}>
|
||||||
</label>
|
{[
|
||||||
|
{ value: 'whitelist', label: '白名单模式' },
|
||||||
|
{ value: 'blacklist', label: '黑名单模式' }
|
||||||
|
].map(option => (
|
||||||
|
<div
|
||||||
|
key={option.value}
|
||||||
|
className={`custom-select-option ${aiInsightFilterMode === option.value ? 'selected' : ''}`}
|
||||||
|
onClick={() => { void saveFilterMode(option.value as configService.AiInsightFilterMode) }}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
{aiInsightFilterMode === option.value && <Check size={14} />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="anti-revoke-control-card">
|
<div className="anti-revoke-control-card">
|
||||||
|
<div className="push-filter-type-tabs" style={{ marginBottom: 10 }}>
|
||||||
|
{insightFilterTypeOptions.map(option => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`push-filter-type-tab ${insightFilterType === option.value ? 'active' : ''}`}
|
||||||
|
onClick={() => setInsightFilterType(option.value)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<div className="anti-revoke-toolbar">
|
<div className="anti-revoke-toolbar">
|
||||||
<div className="filter-search-box anti-revoke-search">
|
<div className="filter-search-box anti-revoke-search">
|
||||||
<Search size={14} />
|
<Search size={14} />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="搜索私聊对话..."
|
placeholder="搜索对话..."
|
||||||
value={insightWhitelistSearch}
|
value={insightWhitelistSearch}
|
||||||
onChange={(e) => setInsightWhitelistSearch(e.target.value)}
|
onChange={(e) => setInsightWhitelistSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -3517,7 +3559,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
<div className="anti-revoke-list">
|
<div className="anti-revoke-list">
|
||||||
{filteredSessions.length === 0 ? (
|
{filteredSessions.length === 0 ? (
|
||||||
<div className="anti-revoke-empty">
|
<div className="anti-revoke-empty">
|
||||||
{insightWhitelistSearch ? '没有匹配的对话' : '暂无私聊对话'}
|
{insightWhitelistSearch || insightFilterType !== 'all' ? '没有匹配的对话' : '暂无可选对话'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -3527,7 +3569,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
<span>状态</span>
|
<span>状态</span>
|
||||||
</div>
|
</div>
|
||||||
{filteredSessions.map((session) => {
|
{filteredSessions.map((session) => {
|
||||||
const isSelected = aiInsightWhitelist.has(session.username)
|
const isSelected = aiInsightFilterList.has(session.username)
|
||||||
const weiboBinding = aiInsightWeiboBindings[session.username]
|
const weiboBinding = aiInsightWeiboBindings[session.username]
|
||||||
const weiboDraftValue = getWeiboBindingDraftValue(session.username)
|
const weiboDraftValue = getWeiboBindingDraftValue(session.username)
|
||||||
const isBindingLoading = weiboBindingLoadingSessionId === session.username
|
const isBindingLoading = weiboBindingLoadingSessionId === session.username
|
||||||
@@ -3543,11 +3585,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onChange={async () => {
|
onChange={async () => {
|
||||||
setAiInsightWhitelist((prev) => {
|
setAiInsightFilterList((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
if (next.has(session.username)) next.delete(session.username)
|
if (next.has(session.username)) next.delete(session.username)
|
||||||
else next.add(session.username)
|
else next.add(session.username)
|
||||||
void configService.setAiInsightWhitelist(Array.from(next))
|
void configService.setAiInsightFilterList(Array.from(next))
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
@@ -3563,9 +3605,12 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
/>
|
/>
|
||||||
<div className="anti-revoke-row-text">
|
<div className="anti-revoke-row-text">
|
||||||
<span className="name">{session.displayName || session.username}</span>
|
<span className="name">{session.displayName || session.username}</span>
|
||||||
|
<span className="desc">{getSessionFilterTypeLabel(session.type)}</span>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<div className="insight-social-binding-cell">
|
<div className="insight-social-binding-cell">
|
||||||
|
{session.type === 'private' ? (
|
||||||
|
<>
|
||||||
<div className="insight-social-binding-input-wrap">
|
<div className="insight-social-binding-input-wrap">
|
||||||
<span className="binding-platform-chip">微博</span>
|
<span className="binding-platform-chip">微博</span>
|
||||||
<input
|
<input
|
||||||
@@ -3606,11 +3651,19 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
<span className="binding-feedback muted">仅支持手动填写数字 UID</span>
|
<span className="binding-feedback muted">仅支持手动填写数字 UID</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="insight-social-binding-feedback">
|
||||||
|
<span className="binding-feedback muted">仅私聊支持微博绑定</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="anti-revoke-row-status">
|
<div className="anti-revoke-row-status">
|
||||||
<span className={`status-badge ${isSelected ? 'installed' : 'not-installed'}`}>
|
<span className={`status-badge ${isSelected ? 'installed' : 'not-installed'}`}>
|
||||||
<i className="status-dot" aria-hidden="true" />
|
<i className="status-dot" aria-hidden="true" />
|
||||||
{isSelected ? '已加入' : '未加入'}
|
{isSelected
|
||||||
|
? (aiInsightFilterMode === 'whitelist' ? '已允许' : '已屏蔽')
|
||||||
|
: (aiInsightFilterMode === 'whitelist' ? '未允许' : '允许')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3631,7 +3684,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
<div className="api-docs">
|
<div className="api-docs">
|
||||||
<div className="api-item">
|
<div className="api-item">
|
||||||
<p className="api-desc" style={{ lineHeight: 1.7 }}>
|
<p className="api-desc" style={{ lineHeight: 1.7 }}>
|
||||||
<strong>触发方式一:活跃会话分析</strong> — 每当微信数据库变化(即你收到新消息)时,经过 500ms 防抖后,对最近活跃的私聊会话进行分析。<br />
|
<strong>触发方式一:活跃会话分析</strong> — 每当微信数据库变化(即你收到新消息)时,经过 500ms 防抖后,对符合黑白名单规则的活跃会话进行分析。<br />
|
||||||
<strong>触发方式二:沉默扫描</strong> — 每 4 小时独立扫描一次,对超过阈值天数无消息的联系人发出提醒。<br />
|
<strong>触发方式二:沉默扫描</strong> — 每 4 小时独立扫描一次,对超过阈值天数无消息的联系人发出提醒。<br />
|
||||||
<strong>时间观念</strong> — 每次调用时,AI 会收到今天已向该联系人和全局发出过多少次见解,由 AI 自行决定是否需要克制。<br />
|
<strong>时间观念</strong> — 每次调用时,AI 会收到今天已向该联系人和全局发出过多少次见解,由 AI 自行决定是否需要克制。<br />
|
||||||
<strong>隐私</strong> — 所有分析请求均直接从你的电脑发往你填写的 API 地址,不经过任何 WeFlow 服务器。
|
<strong>隐私</strong> — 所有分析请求均直接从你的电脑发往你填写的 API 地址,不经过任何 WeFlow 服务器。
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ export const CONFIG_KEYS = {
|
|||||||
AI_INSIGHT_SILENCE_DAYS: 'aiInsightSilenceDays',
|
AI_INSIGHT_SILENCE_DAYS: 'aiInsightSilenceDays',
|
||||||
AI_INSIGHT_ALLOW_CONTEXT: 'aiInsightAllowContext',
|
AI_INSIGHT_ALLOW_CONTEXT: 'aiInsightAllowContext',
|
||||||
AI_INSIGHT_ALLOW_SOCIAL_CONTEXT: 'aiInsightAllowSocialContext',
|
AI_INSIGHT_ALLOW_SOCIAL_CONTEXT: 'aiInsightAllowSocialContext',
|
||||||
|
AI_INSIGHT_FILTER_MODE: 'aiInsightFilterMode',
|
||||||
|
AI_INSIGHT_FILTER_LIST: 'aiInsightFilterList',
|
||||||
AI_INSIGHT_WHITELIST_ENABLED: 'aiInsightWhitelistEnabled',
|
AI_INSIGHT_WHITELIST_ENABLED: 'aiInsightWhitelistEnabled',
|
||||||
AI_INSIGHT_WHITELIST: 'aiInsightWhitelist',
|
AI_INSIGHT_WHITELIST: 'aiInsightWhitelist',
|
||||||
AI_INSIGHT_COOLDOWN_MINUTES: 'aiInsightCooldownMinutes',
|
AI_INSIGHT_COOLDOWN_MINUTES: 'aiInsightCooldownMinutes',
|
||||||
@@ -1917,22 +1919,49 @@ export async function setAiInsightAllowSocialContext(allow: boolean): Promise<vo
|
|||||||
await config.set(CONFIG_KEYS.AI_INSIGHT_ALLOW_SOCIAL_CONTEXT, allow)
|
await config.set(CONFIG_KEYS.AI_INSIGHT_ALLOW_SOCIAL_CONTEXT, allow)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AiInsightFilterMode = 'whitelist' | 'blacklist'
|
||||||
|
|
||||||
|
const normalizeAiInsightFilterList = (value: unknown): string[] => {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAiInsightFilterMode(): Promise<AiInsightFilterMode> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_FILTER_MODE)
|
||||||
|
if (value === 'blacklist') return 'blacklist'
|
||||||
|
if (value === 'whitelist') return 'whitelist'
|
||||||
|
return 'whitelist'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setAiInsightFilterMode(mode: AiInsightFilterMode): Promise<void> {
|
||||||
|
const normalizedMode: AiInsightFilterMode = mode === 'blacklist' ? 'blacklist' : 'whitelist'
|
||||||
|
await config.set(CONFIG_KEYS.AI_INSIGHT_FILTER_MODE, normalizedMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAiInsightFilterList(): Promise<string[]> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_FILTER_LIST)
|
||||||
|
return normalizeAiInsightFilterList(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setAiInsightFilterList(list: string[]): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.AI_INSIGHT_FILTER_LIST, normalizeAiInsightFilterList(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容旧字段命名:内部已映射到新的黑白名单模式
|
||||||
export async function getAiInsightWhitelistEnabled(): Promise<boolean> {
|
export async function getAiInsightWhitelistEnabled(): Promise<boolean> {
|
||||||
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_WHITELIST_ENABLED)
|
return (await getAiInsightFilterMode()) === 'whitelist'
|
||||||
return value === true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setAiInsightWhitelistEnabled(enabled: boolean): Promise<void> {
|
export async function setAiInsightWhitelistEnabled(enabled: boolean): Promise<void> {
|
||||||
await config.set(CONFIG_KEYS.AI_INSIGHT_WHITELIST_ENABLED, enabled)
|
await setAiInsightFilterMode(enabled ? 'whitelist' : 'blacklist')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAiInsightWhitelist(): Promise<string[]> {
|
export async function getAiInsightWhitelist(): Promise<string[]> {
|
||||||
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_WHITELIST)
|
return getAiInsightFilterList()
|
||||||
return Array.isArray(value) ? (value as string[]) : []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setAiInsightWhitelist(list: string[]): Promise<void> {
|
export async function setAiInsightWhitelist(list: string[]): Promise<void> {
|
||||||
await config.set(CONFIG_KEYS.AI_INSIGHT_WHITELIST, list)
|
await setAiInsightFilterList(list)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAiInsightCooldownMinutes(): Promise<number> {
|
export async function getAiInsightCooldownMinutes(): Promise<number> {
|
||||||
|
|||||||
Reference in New Issue
Block a user