diff --git a/resources/macos/libwcdb_api.dylib b/resources/macos/libwcdb_api.dylib index abdd2a7..1c855b2 100755 Binary files a/resources/macos/libwcdb_api.dylib and b/resources/macos/libwcdb_api.dylib differ diff --git a/resources/wcdb/win32/x64/wcdb_api.dll b/resources/wcdb/win32/x64/wcdb_api.dll index 88ddfad..07b4313 100644 Binary files a/resources/wcdb/win32/x64/wcdb_api.dll and b/resources/wcdb/win32/x64/wcdb_api.dll differ diff --git a/src/main/index.ts b/src/main/index.ts index 5f23b73..8360607 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -111,6 +111,7 @@ import { } from './services/bootstrap-cache' import { installSafeConsole } from './safe-log' import { agentHubService } from './services/agent-hub-service' +import { groupExitMonitorService } from './services/group-exit-monitor-service' import { personalWechatSendService } from './services/personal-wechat-send-service' import { getPersonalWechatSendCapability } from './services/personal-wechat-capability-service' import { scheduledReportService } from './services/scheduled-report-service' @@ -746,6 +747,7 @@ app.whenReady().then(async () => { chat.getCurrentAccountRoot() === selectedRoot ) { console.log('[WCDB4] db:init reuse current connection') + void groupExitMonitorService.start(true) return { success: true, monitoring: true } } const nextWechatDb = await WechatDb.create(key, selectedRoot) @@ -753,6 +755,7 @@ app.whenReady().then(async () => { await nextWechatDb.closeAsync() return { success: false, error: '应用正在退出,数据库连接已取消', monitoring: false } } + groupExitMonitorService.stop() const resolvedRoot = nextWechatDb.getWcdb4Client().getAccountRoot() if (resolvedRoot) { // 同步更新 imageKeyRoot,避免自动获取图片密钥时扫描到错误目录 @@ -774,11 +777,13 @@ app.whenReady().then(async () => { videoAssetService = new VideoAssetService(wcdb4Client) const monitoring = await wcdb4Client.startMonitor((type, json) => { wcdb4Client.invalidateSessionCache() + groupExitMonitorService.notifyDatabaseChanged(json) recallArchiveMonitor?.handleDatabaseChange(json) for (const window of BrowserWindow.getAllWindows()) { if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json }) } }) + void groupExitMonitorService.start(monitoring) const recentSession = sessions[0] if (recentSession?.username) { void wcdb4Client @@ -1123,6 +1128,24 @@ app.whenReady().then(async () => { return snapshot }) + ipcMain.handle('group-exit-monitor:getState', () => groupExitMonitorService.getState()) + ipcMain.handle( + 'group-exit-monitor:setGroups', + (_, roomIds: string[], notificationRoomIds?: string[]) => + groupExitMonitorService.setMonitoredRoomIds( + Array.isArray(roomIds) ? roomIds : [], + Array.isArray(notificationRoomIds) ? notificationRoomIds : [] + ) + ) + ipcMain.handle('group-exit-monitor:setTemplate', (_, template: unknown) => + groupExitMonitorService.setNotificationTemplate(template) + ) + ipcMain.handle('group-exit-monitor:checkNow', () => groupExitMonitorService.checkNow()) + ipcMain.handle('group-exit-monitor:clearEvents', () => groupExitMonitorService.clearEvents()) + ipcMain.handle('group-exit-monitor:markRead', (_, readAt?: number) => + groupExitMonitorService.markRead(readAt) + ) + ipcMain.handle('db:search', (_, keyword: string) => chat.searchMessages(keyword)) ipcMain.handle( 'knowledge:search', @@ -1734,12 +1757,21 @@ app.whenReady().then(async () => { }) ipcMain.handle('db:reopenWithRoot', async (_, accountRoot: string) => { + groupExitMonitorService.stop() const ok = chat.reopenWithRoot(accountRoot) if (!ok) return { success: false, error: '数据库未初始化或重新打开失败' } const client = chat.getChatDb()?.getWcdb4Client() if (client) { voiceService = new VoiceService(client) voiceRecognition?.connect(voiceService, client.getAccountRoot()) + const monitoring = await client.startMonitor((type, json) => { + client.invalidateSessionCache() + groupExitMonitorService.notifyDatabaseChanged(json) + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json }) + } + }) + void groupExitMonitorService.start(monitoring) } // 同步 imageKeyRoot,避免自动获取扫描到旧目录 const settings = loadSettings() @@ -1773,6 +1805,7 @@ app.whenReady().then(async () => { voiceBatchService?.cancel() voiceRecognition?.disconnect() voiceService = null + groupExitMonitorService.stop() if (options?.closeNative !== false && chat.isReady()) chat.setChatDb(null) return { success: true } }) @@ -2027,6 +2060,7 @@ app.on('before-quit', (event) => { void (async () => { agentHubService.stop() scheduledReportService.stop() + groupExitMonitorService.stop() flushBootstrapCacheWritesSync() const [, nativeCallsDrained] = await Promise.all([ apiServer.stop().catch(() => undefined), diff --git a/src/main/services/chat-service.ts b/src/main/services/chat-service.ts index e495f22..413f7b9 100644 --- a/src/main/services/chat-service.ts +++ b/src/main/services/chat-service.ts @@ -583,8 +583,8 @@ export function getGroupSnapshot(userMd5: string): GroupSnapshot | null { const roomId = wcdb4Client.getUsernameByMd5(userMd5) if (!roomId || !roomId.endsWith('@chatroom')) return null - const members = wcdb4Client - .getGroupMembers(roomId) + const rawMembers = wcdb4Client.getGroupMembers(roomId) + const members = (Array.isArray(rawMembers) ? rawMembers : []) .filter((member) => member?.m_nsUsrName) .map((member) => ({ wxid: member.m_nsUsrName, @@ -604,7 +604,8 @@ export async function getGroupSnapshotAsync(userMd5: string): Promise member?.m_nsUsrName) .map((member) => ({ wxid: member.m_nsUsrName, diff --git a/src/main/services/group-exit-monitor-service.ts b/src/main/services/group-exit-monitor-service.ts new file mode 100644 index 0000000..7d294ef --- /dev/null +++ b/src/main/services/group-exit-monitor-service.ts @@ -0,0 +1,579 @@ +import { app, BrowserWindow } from 'electron' +import fs from 'fs-extra' +import path from 'path' +import * as chat from './chat-service' +import { personalWechatCapabilityService } from './personal-wechat-capability-service' +import { personalWechatSendService } from './personal-wechat-send-service' +import { + findRemovedGroupMembers, + groupExitMemberName, + normalizeGroupExitNotificationTemplate, + renderGroupExitMonitorNotification, + validateGroupExitNotificationTemplate, + type GroupExitMonitorEvent, + type GroupExitMonitorMember, + type GroupExitMonitorState +} from '../../shared/group-exit-monitor' + +type StoredState = { + accountRoot?: string + events?: Partial[] + lastReadAt?: number + monitorSelectionConfigured?: boolean + monitoredRoomIds?: string[] + notificationRoomIds?: string[] + notificationTemplate?: unknown +} + +type GroupSnapshotRecord = { + contactId: string + roomId: string + groupName: string + members: GroupExitMonitorMember[] + /** 查询失败时保留旧快照。 */ + membersValid?: boolean +} + +const DB_CHANGE_DEBOUNCE_MS = 350 +const GROUP_READ_CONCURRENCY = 8 +const DUPLICATE_WINDOW_MS = 2 * 60 * 1000 +const MAX_EVENTS = 500 + +class GroupExitMonitorService { + private active = false + private nativeMonitorActive = false + private snapshots = new Map() + private changeTimer: NodeJS.Timeout | null = null + private checking = false + private checkQueued = false + private initializing = false + private lastCheckedAt: number | undefined + private lastReadAt = 0 + private events: GroupExitMonitorEvent[] = [] + private monitorSelectionConfigured = true + private monitoredRoomIds = new Set() + private notificationRoomIds = new Set() + private loaded = false + private accountRoot = '' + private eventSequence = 0 + private scopeGeneration = 0 + private notificationTemplate = normalizeGroupExitNotificationTemplate(undefined) + + getState(): GroupExitMonitorState { + this.ensureLoaded() + return { + events: [...this.events], + running: this.active && chat.isReady(), + nativeMonitorActive: this.nativeMonitorActive, + monitoredGroupCount: this.snapshots.size, + monitorSelectionConfigured: this.monitorSelectionConfigured, + monitoredRoomIds: Array.from(this.monitoredRoomIds), + notificationRoomIds: Array.from(this.notificationRoomIds), + notificationTemplate: this.notificationTemplate, + lastCheckedAt: this.lastCheckedAt, + lastReadAt: this.lastReadAt, + unreadCount: this.events.filter((event) => event.detectedAt > this.lastReadAt).length + } + } + + async start(nativeMonitorActive: boolean): Promise { + this.ensureLoaded() + const currentRoot = chat.getCurrentAccountRoot() + if ( + currentRoot && + this.accountRoot && + path.resolve(currentRoot) !== path.resolve(this.accountRoot) + ) { + this.events = [] + this.lastReadAt = 0 + this.monitorSelectionConfigured = true + this.monitoredRoomIds.clear() + this.notificationRoomIds.clear() + } + if (currentRoot) this.accountRoot = currentRoot + this.active = true + this.nativeMonitorActive = nativeMonitorActive + this.snapshots.clear() + this.checkQueued = false + this.initializing = true + const scopeGeneration = ++this.scopeGeneration + const generation = this.eventSequence + 1 + this.eventSequence = generation + try { + const groups = await this.readGroups() + if ( + !this.active || + this.eventSequence !== generation || + this.scopeGeneration !== scopeGeneration + ) + return + if (groups) this.replaceSnapshots(groups) + this.save() + this.broadcast() + } finally { + this.initializing = false + if (this.checkQueued && this.active) { + this.checkQueued = false + void this.check() + } + } + } + + stop(): void { + this.active = false + this.nativeMonitorActive = false + this.eventSequence += 1 + this.scopeGeneration += 1 + this.snapshots.clear() + this.checkQueued = false + if (this.changeTimer) clearTimeout(this.changeTimer) + this.changeTimer = null + this.broadcast() + } + + notifyDatabaseChanged(rawPayload: string): void { + if (!this.active || !isContactEvent(rawPayload)) return + try { + chat.getChatDb()?.getWcdb4Client().invalidateGroupNicknameCache() + } catch { + // 缓存清理失败时继续检查。 + } + if (this.changeTimer) clearTimeout(this.changeTimer) + this.changeTimer = setTimeout(() => { + this.changeTimer = null + void this.check() + }, DB_CHANGE_DEBOUNCE_MS) + } + + async checkNow(): Promise { + if (!this.active && chat.isReady()) await this.start(false) + await this.check() + return this.getState() + } + + async setMonitoredRoomIds( + roomIds: string[], + notificationRoomIds: string[] = [] + ): Promise { + this.ensureLoaded() + this.eventSequence += 1 + this.scopeGeneration += 1 + this.monitorSelectionConfigured = true + this.monitoredRoomIds = normalizeRoomIds(roomIds) + const requestedNotifications = normalizeRoomIds(notificationRoomIds) + this.notificationRoomIds = new Set( + Array.from(requestedNotifications).filter((roomId) => this.monitoredRoomIds.has(roomId)) + ) + // 更换监控范围后重新建基线,避免旧快照误报。 + this.snapshots.clear() + this.lastCheckedAt = undefined + this.save() + this.broadcast() + // 建立基线放到后台,保存配置可以立即返回。 + if (this.active) void this.check() + return this.getState() + } + + setNotificationTemplate(value: unknown): GroupExitMonitorState { + this.ensureLoaded() + const result = validateGroupExitNotificationTemplate(value) + if (!result.valid || !result.template) { + throw new Error(result.error || '退群监测模板无效') + } + this.notificationTemplate = result.template + this.save() + this.broadcast() + return this.getState() + } + + clearEvents(): GroupExitMonitorState { + this.ensureLoaded() + this.events = [] + this.lastReadAt = Date.now() + this.save() + this.broadcast() + return this.getState() + } + + markRead(readAt?: number): GroupExitMonitorState { + this.ensureLoaded() + this.lastReadAt = Math.max(this.lastReadAt, Number(readAt) || Date.now()) + this.save() + this.broadcast() + return this.getState() + } + + private async check(): Promise { + if (!this.active || !chat.isReady()) return + if (this.initializing) { + this.checkQueued = true + return + } + if (this.checking) { + this.checkQueued = true + return + } + + this.checking = true + const scopeGeneration = this.scopeGeneration + try { + const groups = await this.readGroups() + if (!groups || !this.active || scopeGeneration !== this.scopeGeneration) return + + const currentRoomIds = new Set(groups.map((group) => group.roomId)) + for (const roomId of this.snapshots.keys()) { + if (!currentRoomIds.has(roomId)) this.snapshots.delete(roomId) + } + + for (const next of groups) { + if (scopeGeneration !== this.scopeGeneration) return + if (next.membersValid === false) continue + const previous = this.snapshots.get(next.roomId) + if (previous?.membersValid === false) { + this.snapshots.set(next.roomId, next) + continue + } + // 空数组可能是查询失败,先保留旧基线,避免误报。 + if (previous && previous.members.length > 0 && next.members.length === 0) continue + + this.snapshots.set(next.roomId, next) + if (!previous) continue + + // 只有群人数下降才记录退群。 + if (next.members.length >= previous.members.length) continue + + const removed = findRemovedGroupMembers(previous.members, next.members) + for (const member of removed) { + const event = this.recordExit(next, member, previous.members.length, next.members.length) + if (event && this.notificationRoomIds.has(next.roomId)) { + void this.notifyGroup(next, event) + } + } + } + this.lastCheckedAt = Date.now() + this.save() + this.broadcast() + } finally { + this.checking = false + if (this.checkQueued && this.active) { + this.checkQueued = false + void this.check() + } + } + } + + private async readGroups(): Promise { + const database = chat.getChatDb() + if (!database) return null + try { + const client = database.getWcdb4Client() + const rawSessions = await client.getSessionsAsync({ hydrateDisplayNames: true }) + const sessions = Array.isArray(rawSessions) ? rawSessions : [] + const groups = sessions.filter( + (session) => + session.username?.endsWith('@chatroom') === true && + (!this.monitorSelectionConfigured || this.monitoredRoomIds.has(session.username)) + ) + const records: Array = new Array(groups.length).fill(null) + let nextIndex = 0 + const workerCount = Math.min(GROUP_READ_CONCURRENCY, groups.length) + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (true) { + const index = nextIndex++ + if (index >= groups.length) return + const session = groups[index] + const contactId = client.md5(session.username) + try { + const snapshot = await chat.getGroupSnapshotAsync(contactId) + const groupName = cleanGroupName(session.nickname, session.username) + if (!snapshot || snapshot.roomId !== session.username) { + records[index] = { + contactId, + roomId: session.username, + groupName, + members: [], + membersValid: false + } + continue + } + const rawMembers = (snapshot as { members?: unknown }).members + if (!Array.isArray(rawMembers)) { + console.warn( + `[GroupMonitor] 群成员快照不是数组 roomId=${session.username}; 保留上一份基线` + ) + records[index] = { + contactId, + roomId: session.username, + groupName, + members: [], + membersValid: false + } + continue + } + records[index] = { + contactId, + roomId: snapshot.roomId, + groupName: cleanGroupName(session.nickname, snapshot.roomId), + members: rawMembers + .filter((member) => member?.wxid) + .map((member) => ({ + wxid: member.wxid, + nickname: member.nickname, + groupNickname: member.groupNickname, + wechatNickname: member.wechatNickname, + remark: member.remark, + avatar: member.avatar + })), + membersValid: true + } + } catch (error) { + console.warn(`[GroupMonitor] 读取群成员失败 roomId=${session.username}:`, error) + records[index] = { + contactId, + roomId: session.username, + groupName: cleanGroupName(session.nickname, session.username), + members: [], + membersValid: false + } + } + } + }) + ) + return records.filter((record): record is GroupSnapshotRecord => Boolean(record)) + } catch (error) { + console.warn('[GroupMonitor] 读取群成员快照失败:', error) + return null + } + } + + private replaceSnapshots(groups: GroupSnapshotRecord[]): void { + this.snapshots = new Map( + groups.filter((group) => group.membersValid !== false).map((group) => [group.roomId, group]) + ) + } + + private recordExit( + group: GroupSnapshotRecord, + member: GroupExitMonitorMember, + previousCount: number, + currentCount: number + ): GroupExitMonitorEvent | null { + const memberName = groupExitMemberName(member) + const wechatName = String(member.wechatNickname || '').trim() + const groupRemark = String(member.groupNickname || '').trim() + const contactRemark = String(member.remark || '').trim() + const detectedAt = Date.now() + const message = `${memberName}退出了${group.groupName}` + const duplicate = this.events.some( + (event) => + event.roomId === group.roomId && + event.memberWxid === member.wxid && + event.previousCount === previousCount && + event.currentCount === currentCount && + Math.abs(event.detectedAt - detectedAt) < DUPLICATE_WINDOW_MS + ) + if (duplicate) return null + const event: GroupExitMonitorEvent = { + id: `${group.roomId}:${member.wxid}:${detectedAt}:${this.eventSequence++}`, + contactId: group.contactId, + roomId: group.roomId, + groupName: group.groupName, + memberWxid: member.wxid, + memberName, + wechatName, + groupRemark, + contactRemark, + previousCount, + currentCount, + delta: currentCount - previousCount, + message, + detectedAt + } + this.events = [event, ...this.events].slice(0, MAX_EVENTS) + console.log( + `[GroupMonitor] detected member exit roomId=${group.roomId} member=${member.wxid} ${previousCount}->${currentCount}` + ) + return event + } + + private async notifyGroup( + group: GroupSnapshotRecord, + event: GroupExitMonitorEvent + ): Promise { + try { + const capability = await personalWechatCapabilityService.getPersonalWechatSendCapability() + if (!capability.ready || !capability.capabilities.text) { + console.warn(`[GroupMonitor] 跳过群聊通知 roomId=${group.roomId}: ${capability.message}`) + return + } + // 具体传输方式由发送服务按平台处理。 + const result = await personalWechatSendService.send({ + type: 'text', + to: group.roomId, + isGroup: true, + text: renderGroupExitMonitorNotification(event, this.notificationTemplate) + }) + if (!result.success) { + console.warn( + `[GroupMonitor] 群聊通知发送失败 roomId=${group.roomId}: ${result.error || ''}` + ) + } + } catch (error) { + console.warn( + `[GroupMonitor] 群聊通知异常 roomId=${group.roomId}:`, + error instanceof Error ? error.message : String(error) + ) + } + } + + private filePath(): string { + return path.join(app.getPath('userData'), 'group-exit-monitor.json') + } + + private ensureLoaded(): void { + if (this.loaded) return + this.loaded = true + try { + const stored = fs.readJsonSync(this.filePath()) as StoredState + this.events = normalizeEvents(stored.events) + this.lastReadAt = Number(stored.lastReadAt) || 0 + this.accountRoot = String(stored.accountRoot || '') + // 没有显式范围时按空范围处理,保留已有选择。 + this.monitorSelectionConfigured = true + this.monitoredRoomIds = normalizeRoomIds(stored.monitoredRoomIds || []) + this.notificationRoomIds = new Set( + Array.from(normalizeRoomIds(stored.notificationRoomIds || [])).filter((roomId) => + this.monitoredRoomIds.has(roomId) + ) + ) + this.notificationTemplate = normalizeGroupExitNotificationTemplate( + stored.notificationTemplate + ) + } catch { + // 首次启动或文件损坏时从空记录开始。 + this.events = [] + this.lastReadAt = 0 + this.monitorSelectionConfigured = true + this.monitoredRoomIds.clear() + this.notificationRoomIds.clear() + this.notificationTemplate = normalizeGroupExitNotificationTemplate(undefined) + } + } + + private save(): void { + this.ensureLoaded() + try { + fs.ensureDirSync(path.dirname(this.filePath())) + fs.writeJsonSync( + this.filePath(), + { + accountRoot: this.accountRoot, + events: this.events, + lastReadAt: this.lastReadAt, + monitorSelectionConfigured: this.monitorSelectionConfigured, + monitoredRoomIds: Array.from(this.monitoredRoomIds), + notificationRoomIds: Array.from(this.notificationRoomIds), + notificationTemplate: this.notificationTemplate + }, + { spaces: 2 } + ) + } catch (error) { + console.warn('[GroupMonitor] 保存事件失败:', error) + } + } + + private broadcast(): void { + const state = this.getState() + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) window.webContents.send('group-exit-monitor:state', state) + } + } +} + +function normalizeRoomIds(roomIds: string[]): Set { + return new Set( + (Array.isArray(roomIds) ? roomIds : []) + .map((roomId) => String(roomId || '').trim()) + .filter((roomId) => roomId.endsWith('@chatroom')) + ) +} + +function cleanGroupName(value: string | undefined, roomId: string): string { + const name = String(value || '').trim() + return name && name !== roomId && !name.startsWith('wxid_') ? name : roomId +} + +function normalizeEvents( + values: Partial[] | undefined +): GroupExitMonitorEvent[] { + if (!Array.isArray(values)) return [] + const normalized: GroupExitMonitorEvent[] = [] + for (const value of values) { + const memberName = String(value.memberName || '').trim() + const groupName = String(value.groupName || '').trim() + const roomId = String(value.roomId || '').trim() + const previousCount = Number(value.previousCount) + const currentCount = Number(value.currentCount) + const detectedAt = Number(value.detectedAt) + // 旧记录没有人数变化,无法确认退群,直接忽略。 + if ( + !memberName || + !groupName || + !roomId || + !Number.isFinite(previousCount) || + !Number.isFinite(currentCount) || + previousCount <= currentCount || + previousCount <= 0 || + !Number.isFinite(detectedAt) || + detectedAt <= 0 + ) { + continue + } + const memberWxid = String(value.memberWxid || '').trim() + const duplicate = normalized.some( + (event) => + event.roomId === roomId && + event.memberWxid === memberWxid && + event.previousCount === previousCount && + event.currentCount === currentCount && + Math.abs(event.detectedAt - detectedAt) < DUPLICATE_WINDOW_MS + ) + if (duplicate) continue + normalized.push({ + id: String(value.id || `${roomId}:${memberWxid || memberName}:${detectedAt}`), + contactId: String(value.contactId || ''), + roomId, + groupName, + memberWxid, + memberName, + wechatName: String(value.wechatName || '').trim(), + groupRemark: String(value.groupRemark || '').trim(), + contactRemark: String(value.contactRemark || '').trim(), + previousCount, + currentCount, + delta: Number.isFinite(Number(value.delta)) + ? Number(value.delta) + : currentCount - previousCount, + message: String(value.message || `${memberName}退出了${groupName}`), + detectedAt + }) + if (normalized.length >= MAX_EVENTS) break + } + return normalized +} + +function isContactEvent(rawPayload: string): boolean { + const payload = String(rawPayload || '').trim() + if (!payload) return false + try { + const parsed = JSON.parse(payload) as { table?: unknown } + return String(parsed.table || '').toLowerCase() === 'contact' + } catch { + return /["']table["']\s*:\s*["']contact["']/i.test(payload) + } +} + +export const groupExitMonitorService = new GroupExitMonitorService() + +export { GroupExitMonitorService } diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts index 189b3e7..de7f7fa 100644 --- a/src/main/wcdb4-client.ts +++ b/src/main/wcdb4-client.ts @@ -987,6 +987,11 @@ export class Wcdb4Client { this.sessionDisplayNamesHydrated = false } + /** 清理群备注缓存,不影响会话缓存。 */ + invalidateGroupNicknameCache(): void { + this.groupNicknameCache.clear() + } + getChatTables(): { name: string; db_number: string }[] { if (this.cachedChatTables) return this.cachedChatTables const sessions = @@ -2740,14 +2745,15 @@ export class Wcdb4Client { ]) const content = this.decodeMessageContent(contentRaw, compressRaw) const contentSender = this.extractSenderFromMessageContent(content) - const sender = this.pickString(row, [ - 'sender_username', - 'senderUsername', - 'sender', - 'fromUsername', - 'from_username', - 'WCDB_CT_sender_username' - ]) || contentSender + const sender = + this.pickString(row, [ + 'sender_username', + 'senderUsername', + 'sender', + 'fromUsername', + 'from_username', + 'WCDB_CT_sender_username' + ]) || contentSender const createTime = this.pickNumber(row, [ 'create_time', 'createTime', @@ -2795,21 +2801,32 @@ export class Wcdb4Client { 'mesDes', 'WCDB_CT_is_send' ]) - const parsedIsSend = rawIsSend === undefined || rawIsSend === null - ? false - : this.pickBoolean(row, [ - 'computed_is_send', - 'computedIsSend', - 'is_send', - 'isSend', - 'mesDes', - 'WCDB_CT_is_send' - ]) - const isSelfSender = sender ? this.getMyUsernameCandidates().some((candidate) => { - const left = String(candidate || '').trim().toLowerCase() - const right = String(sender || '').trim().toLowerCase() - return Boolean(left && right && (left === right || left.startsWith(`${right}_`) || right.startsWith(`${left}_`))) - }) : false + const parsedIsSend = + rawIsSend === undefined || rawIsSend === null + ? false + : this.pickBoolean(row, [ + 'computed_is_send', + 'computedIsSend', + 'is_send', + 'isSend', + 'mesDes', + 'WCDB_CT_is_send' + ]) + const isSelfSender = sender + ? this.getMyUsernameCandidates().some((candidate) => { + const left = String(candidate || '') + .trim() + .toLowerCase() + const right = String(sender || '') + .trim() + .toLowerCase() + return Boolean( + left && + right && + (left === right || left.startsWith(`${right}_`) || right.startsWith(`${left}_`)) + ) + }) + : false const isSend = isSelfSender || parsedIsSend return { @@ -2828,11 +2845,14 @@ export class Wcdb4Client { /** 解决群里自己的消息被识别成 user */ private extractSenderFromMessageContent(content: string): string { - const firstLine = String(content || '').split(/\r?\n/, 1)[0].trim() + const firstLine = String(content || '') + .split(/\r?\n/, 1)[0] + .trim() if (!firstLine || firstLine.length > 128 || /[<>]/.test(firstLine)) return '' const candidate = firstLine.replace(/:$/, '').trim() if (!candidate || /\s/.test(candidate)) return '' - if (candidate.includes('@') || candidate.startsWith('wxid_') || candidate.startsWith('gh_')) return candidate + if (candidate.includes('@') || candidate.startsWith('wxid_') || candidate.startsWith('gh_')) + return candidate return this.getMyUsernameCandidates().includes(candidate) ? candidate : '' } diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 6c8b30f..1cd380b 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -79,6 +79,7 @@ import type { PersonalWechatVoiceEncodingEnvironmentResult } from '../shared/personal-wechat-voice-runtime' import type { AppLogEntry } from '../shared/app-log' +import type { GroupExitMonitorState } from '../shared/group-exit-monitor' import type { AppUpdateCheckResult, AppUpdateInstallResult, @@ -254,6 +255,16 @@ declare global { avatar: string }[] } | null> + getGroupExitMonitorState: () => Promise + setGroupExitMonitorGroups: ( + roomIds: string[], + notificationRoomIds?: string[] + ) => Promise + setGroupExitMonitorNotificationTemplate: (template: string) => Promise + checkGroupExitMonitorNow: () => Promise + clearGroupExitMonitorEvents: () => Promise + markGroupExitMonitorRead: (readAt?: number) => Promise + onGroupExitMonitorState: (callback: (state: GroupExitMonitorState) => void) => () => void search: (keyword: string) => Promise searchKnowledge: (request: KnowledgeSearchIpcRequest) => Promise runAiSearch: (request: AiSearchPipelineRequest) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 82d55c9..45ac003 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -52,6 +52,7 @@ import type { } from '../shared/personal-wechat-voice-runtime' import type { AppLogEntry } from '../shared/app-log' import type { AppUpdateState } from '../shared/app-update' +import type { GroupExitMonitorState } from '../shared/group-exit-monitor' import type { CacheSummary } from '../shared/cache' import type { ExportRequest, ExportJobProgress } from '../shared/export' import type { ImageDecoderSelectionResult, ImageDecoderStatus } from '../shared/image-decryption' @@ -128,6 +129,29 @@ const api = { options?: { limit?: number } ) => ipcRenderer.invoke('db:getMessages', userMd5, startTime, endTime, options), getGroupSnapshot: (userMd5: string) => ipcRenderer.invoke('db:getGroupSnapshot', userMd5), + getGroupExitMonitorState: (): Promise => + ipcRenderer.invoke('group-exit-monitor:getState'), + setGroupExitMonitorGroups: ( + roomIds: string[], + notificationRoomIds?: string[] + ): Promise => + notificationRoomIds === undefined + ? ipcRenderer.invoke('group-exit-monitor:setGroups', roomIds) + : ipcRenderer.invoke('group-exit-monitor:setGroups', roomIds, notificationRoomIds), + setGroupExitMonitorNotificationTemplate: (template: string): Promise => + ipcRenderer.invoke('group-exit-monitor:setTemplate', template), + checkGroupExitMonitorNow: (): Promise => + ipcRenderer.invoke('group-exit-monitor:checkNow'), + clearGroupExitMonitorEvents: (): Promise => + ipcRenderer.invoke('group-exit-monitor:clearEvents'), + markGroupExitMonitorRead: (readAt?: number): Promise => + ipcRenderer.invoke('group-exit-monitor:markRead', readAt), + onGroupExitMonitorState: (callback: (state: GroupExitMonitorState) => void) => { + const listener = (_event: Electron.IpcRendererEvent, state: GroupExitMonitorState): void => + callback(state) + ipcRenderer.on('group-exit-monitor:state', listener) + return () => ipcRenderer.removeListener('group-exit-monitor:state', listener) + }, search: (keyword: string) => ipcRenderer.invoke('db:search', keyword), searchKnowledge: (request: KnowledgeSearchIpcRequest): Promise => ipcRenderer.invoke('knowledge:search', request), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index db86f93..57517e0 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -41,6 +41,7 @@ import { switchGeneratedReportTemplate } from './utils/report-template-switch' import { runtimePlatform } from './utils/runtime-environment' import { useToast } from './components/ui' import { AppUpdatePrompt } from './features/app-update/AppUpdatePrompt' +import { GroupExitMonitorWorkspace } from './features/group-exit-monitor/GroupExitMonitorWorkspace' const SIDEBAR_MIN_WIDTH = 260 const SIDEBAR_MAX_WIDTH = 380 @@ -1908,6 +1909,8 @@ function App(): React.ReactElement { return renderArchiveWorkspace() case 'report': return renderReportWorkspace() + case 'exit-monitor': + return case 'agent-hub': return case 'api': diff --git a/src/renderer/src/components/layout/PrimaryNavigation.tsx b/src/renderer/src/components/layout/PrimaryNavigation.tsx index 5a78200..7aa9cac 100644 --- a/src/renderer/src/components/layout/PrimaryNavigation.tsx +++ b/src/renderer/src/components/layout/PrimaryNavigation.tsx @@ -1,5 +1,6 @@ import React from 'react' import { AppPage, PRIMARY_NAV_ITEMS } from './navigation' +import type { GroupExitMonitorState } from '../../../../shared/group-exit-monitor' interface NavIconProps { page: AppPage @@ -37,6 +38,14 @@ function NavIcon({ page }: NavIconProps): React.ReactElement { ) + case 'exit-monitor': + return ( + + ) case 'export': return ( + {item.id === 'exit-monitor' && unreadExitCount > 0 && ( + {Math.min(unreadExitCount, 99)} + )} {item.label} diff --git a/src/renderer/src/components/layout/navigation.ts b/src/renderer/src/components/layout/navigation.ts index 4f3a661..1b99788 100644 --- a/src/renderer/src/components/layout/navigation.ts +++ b/src/renderer/src/components/layout/navigation.ts @@ -1,4 +1,12 @@ -export type AppPage = 'archive' | 'search' | 'report' | 'agent-hub' | 'export' | 'api' | 'settings' +export type AppPage = + | 'archive' + | 'search' + | 'report' + | 'exit-monitor' + | 'agent-hub' + | 'export' + | 'api' + | 'settings' export interface NavigationItem { id: AppPage @@ -9,6 +17,7 @@ export const PRIMARY_NAV_ITEMS: NavigationItem[] = [ { id: 'archive', label: '档案' }, { id: 'search', label: '问问微信' }, { id: 'report', label: '日报' }, + { id: 'exit-monitor', label: '退群监控' }, { id: 'agent-hub', label: 'Agent' }, { id: 'export', label: '导出' }, { id: 'api', label: 'API' }, diff --git a/src/renderer/src/features/group-exit-monitor/GroupExitMonitorWorkspace.tsx b/src/renderer/src/features/group-exit-monitor/GroupExitMonitorWorkspace.tsx new file mode 100644 index 0000000..968eec4 --- /dev/null +++ b/src/renderer/src/features/group-exit-monitor/GroupExitMonitorWorkspace.tsx @@ -0,0 +1,838 @@ +import React from 'react' +import { + Button, + Checkbox, + EmptyState, + Input, + Popover, + PopoverContent, + PopoverTrigger, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Textarea, + Tooltip, + TooltipContent, + TooltipTrigger +} from '../../components/ui' +import type { Contact } from '../../../../shared/types' +import { + GROUP_EXIT_NOTIFICATION_TEMPLATE, + GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH, + validateGroupExitNotificationTemplate, + type GroupExitMonitorState +} from '../../../../shared/group-exit-monitor' +import type { PersonalWechatSendCapability } from '../../../../shared/personal-wechat' + +type GroupSelectionFilter = 'all' | 'selected' | 'unselected' + +interface GroupExitMonitorWorkspaceProps { + dbReady: boolean + contacts?: Contact[] +} + +const EMPTY_STATE: GroupExitMonitorState = { + events: [], + running: false, + nativeMonitorActive: false, + monitoredGroupCount: 0, + monitorSelectionConfigured: true, + monitoredRoomIds: [], + notificationRoomIds: [], + lastReadAt: 0, + unreadCount: 0 +} + +const formatDetectedAt = (timestamp: number): string => + new Date(timestamp).toLocaleString('zh-CN', { hour12: false }) + +const dayStart = (timestamp: number): number => { + const date = new Date(timestamp) + date.setHours(0, 0, 0, 0) + return date.getTime() +} + +const formatLastChecked = (timestamp?: number): string => { + if (!timestamp) return '尚未检查' + const elapsed = Math.max(0, Date.now() - timestamp) + if (elapsed < 60_000) return '刚刚' + if (elapsed < 60 * 60_000) return `${Math.max(1, Math.floor(elapsed / 60_000))} 分钟前` + return formatDetectedAt(timestamp) +} + +const contactDisplayName = (contact: Contact): string => + contact.m_nsNickName?.trim() || contact.remark?.trim() || contact.m_nsUsrName || '未命名群聊' + +const isGroupContact = (contact: Contact): boolean => + contact.type === 'group' || contact.m_nsUsrName?.trim().endsWith('@chatroom') === true + +const contactRoomId = (contact: Contact): string => contact.m_nsUsrName.trim() + +const sendCapabilityLabel = (capability: PersonalWechatSendCapability | null): string => { + if (!capability) return '发送能力检查中' + if (capability.ready && capability.capabilities.text) return '发送能力已就绪' + if (capability.status === 'unsupported') return '发送能力不可用' + return '发送能力未就绪' +} + +const sendCapabilityTone = (capability: PersonalWechatSendCapability | null): string => + capability?.ready && capability.capabilities.text ? 'ready' : 'unready' + +function SendCapabilityStatus({ + capability, + className = '' +}: { + capability: PersonalWechatSendCapability | null + className?: string +}): React.ReactElement { + const label = sendCapabilityLabel(capability) + return ( + + + + + + {capability?.message || '正在读取微信发送能力'} + + ) +} + +function GroupExitNotificationTemplate({ + template, + saving, + error, + onSave +}: { + template: string + saving: boolean + error: string + onSave: (template: string) => Promise +}): React.ReactElement { + const [open, setOpen] = React.useState(false) + const [draft, setDraft] = React.useState(template) + const [validationError, setValidationError] = React.useState('') + + React.useEffect(() => { + if (!open) setDraft(template) + }, [open, template]) + + const handleOpenChange = (nextOpen: boolean): void => { + setOpen(nextOpen) + setValidationError('') + if (nextOpen) setDraft(template) + } + + const saveDraft = async (): Promise => { + const result = validateGroupExitNotificationTemplate(draft) + if (!result.valid || !result.template) { + setValidationError(result.error || '模板无效') + return + } + setValidationError('') + const saved = await onSave(result.template) + if (saved) setOpen(false) + } + + return ( + + + + + +
+
+ 消息格式 +

退群监测模板

+
+ 发送时使用 +
+