Files
WechatExplorer/src/main/services/group-exit-monitor-service.ts
T

1044 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { app, BrowserWindow } from 'electron'
import fs from 'fs-extra'
import path from 'path'
import * as chat from './chat-service'
import {
findRemovedGroupMembers,
groupExitMemberName,
type GroupExitMonitorEvent,
type GroupExitMonitorMember,
type GroupExitMonitorState
} from '../../shared/group-exit-monitor'
import {
toGroupMemberExitedEvent,
type GroupMemberExitedEvent
} from '../../shared/group-exit-event'
type StoredState = {
enabled?: boolean
accountRoot?: string
events?: Partial<GroupExitMonitorEvent>[]
lastReadAt?: number
monitorSelectionConfigured?: boolean
monitoredRoomIds?: string[]
snapshots?: Partial<StoredGroupSnapshot>[]
/*
* 历史遗留字段(`notificationRoomIds` / `notificationTemplate`)**刻意不再声明**。
*
* 它们已经迁到「自动化 → 退群通知」规则里,本服务运行期不再读、不再写。
* 旧状态文件原样保留在磁盘上(迁移时另有一份 backup),只是没有任何读取方 ——
* 这是"不双读"的落地方式。迁移逻辑在 `automation-rule-store.ts`。
*/
}
type GroupSnapshotRecord = {
contactId: string
roomId: string
groupName: string
capturedAt: number
members: GroupExitMonitorMember[]
/** 查询失败时保留旧快照。 */
membersValid?: boolean
}
type GroupMembershipRecord = Omit<GroupSnapshotRecord, 'members'> & {
memberIds: string[]
}
type MembershipMode = 'batch' | 'legacy'
type MembershipReadResult = {
mode: MembershipMode
groupCount: number
groups: GroupMembershipRecord[] | null
}
type StoredGroupSnapshot = Pick<
GroupSnapshotRecord,
'contactId' | 'roomId' | 'groupName' | 'capturedAt' | 'members'
>
const DB_CHANGE_DEBOUNCE_MS = 350
const DUPLICATE_WINDOW_MS = 2 * 60 * 1000
/**
* **只约束「一次状态回传带多少条」**,不是存储上限。
*
* 事件现在写在 append-only 的 JSONL 里(`eventsPath()`),**永久保留、不做截断**。
* 之所以必须把「存储」和「回传」分开:状态文件是整体重写的(`save()`),
* 事件留在里面时每来一条新事件都要重写整个文件 —— 越写越慢。
* 而 `getState()` 每次都把结果推过 IPC,也不能无上限。
*/
const MAX_EVENTS = 500
/**
* 退群事件处理器。
*
* 由主进程注入成 `automationService.handleGroupExit` —— 本服务**不再自己发送**。
* 契约:**必须自己吞掉异常**(实现方负责),本服务也会再兜一层。
*/
export type GroupExitEventHandler = (event: GroupMemberExitedEvent) => void | Promise<void>
export interface GroupExitMonitorServiceDependencies {
onGroupExit?: GroupExitEventHandler
}
class GroupExitMonitorService {
private onGroupExit: GroupExitEventHandler | undefined
private active = false
private enabled = true
private nativeMonitorActive = false
private snapshots = new Map<string, GroupSnapshotRecord>()
private changeTimer: NodeJS.Timeout | null = null
private checking = false
private checkQueued = false
private initializing = false
private hydrating = false
private hydrationQueue = new Set<string>()
private hydrationBatchStartedAt: number | null = null
private hydrationBatchGroups = 0
private lastCheckedAt: number | undefined
private lastReadAt = 0
private events: GroupExitMonitorEvent[] = []
private monitorSelectionConfigured = true
private monitoredRoomIds = new Set<string>()
private loaded = false
private accountRoot = ''
private groupNamesByRoomId = new Map<string, string>()
private groupNamesRefreshPending = true
private eventSequence = 0
private scopeGeneration = 0
private legacyFallbackLogged = false
constructor(deps: GroupExitMonitorServiceDependencies = {}) {
this.onGroupExit = deps.onGroupExit
}
/**
* 注入退群事件处理器(主进程在 AutomationService 初始化后调用)。
*
* 用 setter 而不是构造参数:AutomationService 依赖 `Wcdb4Client`,
* 要等数据库解锁后才存在,而本服务是模块级单例。
*/
setGroupExitHandler(handler: GroupExitEventHandler | undefined): void {
this.onGroupExit = handler
}
getState(): GroupExitMonitorState {
this.ensureLoaded()
return {
// this.events 恒为「新在前」,所以这里取到的就是**最新**的 MAX_EVENTS 条。
events: this.events.slice(0, MAX_EVENTS),
/** 永久保留的事件总数(`events` 只是最新的一批)。 */
totalEventCount: this.events.length,
enabled: this.enabled,
running: this.enabled && this.active && chat.isReady(),
nativeMonitorActive: this.nativeMonitorActive,
monitoredGroupCount: this.snapshots.size,
monitorSelectionConfigured: this.monitorSelectionConfigured,
monitoredRoomIds: Array.from(this.monitoredRoomIds),
lastCheckedAt: this.lastCheckedAt,
lastReadAt: this.lastReadAt,
unreadCount: this.events.filter((event) => event.detectedAt > this.lastReadAt).length
}
}
async start(nativeMonitorActive: boolean): Promise<void> {
this.ensureLoaded()
const currentRoot = chat.getCurrentAccountRoot()
if (!currentRoot) {
this.active = true
this.nativeMonitorActive = nativeMonitorActive
this.broadcast()
return
}
if (
(this.accountRoot && !sameAccountRoot(currentRoot, this.accountRoot)) ||
(!this.accountRoot && this.snapshots.size > 0)
) {
this.events = []
this.rewriteEventsToDisk([])
this.lastReadAt = 0
this.monitorSelectionConfigured = true
this.monitoredRoomIds.clear()
this.snapshots.clear()
this.groupNamesByRoomId.clear()
}
this.accountRoot = currentRoot
this.groupNamesRefreshPending = true
this.active = true
this.nativeMonitorActive = nativeMonitorActive
this.checkQueued = false
if (!this.enabled) {
this.save()
this.broadcast()
return
}
this.initializing = true
const scopeGeneration = ++this.scopeGeneration
this.eventSequence += 1
try {
const checked = await this.runMembershipCheck(scopeGeneration)
if (!this.active || this.scopeGeneration !== scopeGeneration) return
if (!checked) {
this.save()
this.broadcast()
}
} finally {
this.initializing = false
if (this.checkQueued && this.active) {
this.checkQueued = false
void this.check()
} else {
this.startNextSnapshotHydration()
}
}
}
stop(): void {
this.active = false
this.nativeMonitorActive = false
this.eventSequence += 1
this.scopeGeneration += 1
this.checkQueued = false
this.hydrationQueue.clear()
this.groupNamesRefreshPending = true
if (this.changeTimer) clearTimeout(this.changeTimer)
this.changeTimer = null
this.broadcast()
}
notifyDatabaseChanged(rawPayload: string): void {
if (!this.enabled || !this.active || !isContactEvent(rawPayload)) return
try {
chat.getChatDb()?.getWcdb4Client().invalidateGroupNicknameCache()
} catch {
// 缓存清理失败时继续检查。
}
this.groupNamesRefreshPending = true
if (this.changeTimer) clearTimeout(this.changeTimer)
this.changeTimer = setTimeout(() => {
this.changeTimer = null
void this.check()
}, DB_CHANGE_DEBOUNCE_MS)
}
async checkNow(): Promise<GroupExitMonitorState> {
this.ensureLoaded()
if (!this.enabled) return this.getState()
if (!this.active && chat.isReady()) await this.start(false)
await this.check()
return this.getState()
}
/**
* 保存**监控范围**。
*
* 参数只剩监控目标 —— 旧版第二个参数(通知群聊)已经迁到自动化规则里,
* 本服务不再持有任何通知配置。
*/
async setMonitoredRoomIds(roomIds: string[]): Promise<GroupExitMonitorState> {
return this.configure({ monitoredRoomIds: roomIds })
}
/**
* Atomically update the monitor scope and enabled state.
*
* Agent API PATCH validates the whole request before calling this method. Keeping the
* two mutations in one service operation also means persistence and the renderer broadcast
* observe one final configuration rather than an intermediate half-patched state.
*/
async configure(configuration: {
monitoredRoomIds?: string[]
enabled?: boolean
}): Promise<GroupExitMonitorState> {
this.ensureLoaded()
const hasScope = configuration.monitoredRoomIds !== undefined
const hasEnabled = configuration.enabled !== undefined
if (!hasScope && !hasEnabled) return this.getState()
if (hasEnabled && !hasScope && this.enabled === configuration.enabled) return this.getState()
if (hasScope) {
this.eventSequence += 1
this.scopeGeneration += 1
this.monitorSelectionConfigured = true
const nextMonitoredRoomIds = normalizeRoomIds(configuration.monitoredRoomIds || [])
for (const roomId of this.snapshots.keys()) {
if (!nextMonitoredRoomIds.has(roomId)) this.snapshots.delete(roomId)
}
for (const roomId of this.hydrationQueue) {
if (!nextMonitoredRoomIds.has(roomId)) this.hydrationQueue.delete(roomId)
}
this.monitoredRoomIds = nextMonitoredRoomIds
this.groupNamesRefreshPending = true
this.lastCheckedAt = undefined
}
if (hasEnabled && this.enabled !== configuration.enabled) {
this.enabled = configuration.enabled === true
this.eventSequence += 1
this.scopeGeneration += 1
this.checkQueued = false
this.hydrationQueue.clear()
if (this.changeTimer) clearTimeout(this.changeTimer)
this.changeTimer = null
if (this.enabled) {
// 用户主动暂停期间的成员变化不补报;重新开启后从当前状态建立新基线。
this.snapshots.clear()
this.lastCheckedAt = undefined
this.groupNamesRefreshPending = true
}
}
this.save()
this.broadcast()
// 仅修改范围时保持原有的后台基线行为;涉及 enabled 的 PATCH 等待一次检查,
// 让调用方拿到的是最终运行状态。
if (this.enabled && this.active && chat.isReady()) {
if (hasEnabled) await this.check()
else void this.check()
}
return this.getState()
}
async setEnabled(enabled: boolean): Promise<GroupExitMonitorState> {
return this.configure({ enabled })
}
/**
* 按群 / 时间范围查退群事件(档案合并展示用)。
*
* 与 `getState()` 的分工:后者只带回最近 `MAX_EVENTS` 条、且是**给管理页**看的概览;
* 档案要的是「某个群在这段时间里的全部事件」,所以单独开一个查询入口,
* 直接打在内存里的完整历史上(事件是永久保留的)。
*
* 返回**按时间升序**(旧 → 新),与档案消息流的顺序一致。
*/
listEvents(
query: { roomId?: string; sinceMs?: number; untilMs?: number; limit?: number } = {}
): GroupExitMonitorEvent[] {
this.ensureLoaded()
const roomId = String(query.roomId || '').trim()
const since = Number(query.sinceMs)
const until = Number(query.untilMs)
const limit = Number(query.limit)
// this.events 是倒序(新在前)。
let result = [...this.events].reverse()
if (roomId) result = result.filter((event) => event.roomId === roomId)
if (Number.isFinite(since)) result = result.filter((event) => event.detectedAt >= since)
if (Number.isFinite(until)) result = result.filter((event) => event.detectedAt <= until)
// 超量时保留**最近**的一批(尾部即最新)。
if (Number.isFinite(limit) && limit > 0 && result.length > limit) {
result = result.slice(-limit)
}
return result
}
clearEvents(): GroupExitMonitorState {
this.ensureLoaded()
this.events = []
// 磁盘上的 append-only 历史也要清掉,否则下次启动又读回来了。
this.rewriteEventsToDisk([])
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<void> {
const currentRoot = chat.getCurrentAccountRoot()
if (
!this.active ||
!this.enabled ||
!chat.isReady() ||
!currentRoot ||
!this.accountRoot ||
!sameAccountRoot(currentRoot, this.accountRoot)
)
return
if (this.initializing || this.hydrating) {
this.checkQueued = true
return
}
if (this.checking) {
this.checkQueued = true
return
}
this.checking = true
const scopeGeneration = this.scopeGeneration
try {
await this.runMembershipCheck(scopeGeneration)
} finally {
this.checking = false
if (this.checkQueued && this.active) {
this.checkQueued = false
void this.check()
} else {
this.startNextSnapshotHydration()
}
}
}
private async runMembershipCheck(scopeGeneration: number): Promise<boolean> {
const startedAt = Date.now()
const result = await this.readMemberships()
const membershipCostMs = Date.now() - startedAt
let changedGroups = 0
if (result.groups && this.enabled && this.active && scopeGeneration === this.scopeGeneration) {
changedGroups = await this.applyCurrentMemberships(result.groups, scopeGeneration)
}
console.log(
`[GroupMonitor] check mode=${result.mode} groups=${result.groupCount} membershipCostMs=${membershipCostMs} changedGroups=${changedGroups} totalCostMs=${Date.now() - startedAt}`
)
return result.groups !== null
}
private async readMemberships(): Promise<MembershipReadResult> {
await this.refreshGroupNamesIfNeeded()
const database = chat.getChatDb()
const roomIds = Array.from(this.monitoredRoomIds)
const batchAvailable = chat.isGroupMemberIdsBatchAvailable()
const mode: MembershipMode = batchAvailable ? 'batch' : 'legacy'
if (!database) return { mode, groupCount: roomIds.length, groups: null }
try {
const client = database.getWcdb4Client()
const capturedAt = Date.now()
if (batchAvailable) {
const snapshots = await chat.getGroupMemberIdsBatchAsync(roomIds)
if (!snapshots) return { mode, groupCount: roomIds.length, groups: null }
return {
mode,
groupCount: roomIds.length,
groups: snapshots.map((snapshot) => {
const previous = this.snapshots.get(snapshot.roomId)
return {
contactId: previous?.contactId || client.md5(snapshot.roomId),
roomId: snapshot.roomId,
groupName: resolveGroupName(snapshot.roomId, this.groupNamesByRoomId, previous),
capturedAt,
memberIds: normalizeMemberIds(snapshot.memberIds),
membersValid: snapshot.status === 'ok'
}
})
}
}
if (!this.legacyFallbackLogged) {
this.legacyFallbackLogged = true
console.warn('[GroupMonitor] batch membership unavailable; using legacy fallback')
}
const groups: GroupMembershipRecord[] = []
for (const roomId of roomIds) {
const previous = this.snapshots.get(roomId)
const snapshot = await chat.getGroupMemberIdsAsync(roomId)
groups.push({
contactId: previous?.contactId || client.md5(roomId),
roomId,
groupName: resolveGroupName(roomId, this.groupNamesByRoomId, previous),
capturedAt,
memberIds: snapshot?.roomId === roomId ? normalizeMemberIds(snapshot.memberIds) : [],
membersValid: snapshot?.roomId === roomId
})
}
return { mode, groupCount: roomIds.length, groups }
} catch (error) {
console.warn('[GroupMonitor] 读取群成员状态失败:', error)
return { mode, groupCount: roomIds.length, groups: null }
}
}
private async applyCurrentMemberships(
groups: GroupMembershipRecord[],
scopeGeneration: number
): Promise<number> {
const exits: GroupExitMonitorEvent[] = []
let changedGroups = 0
for (const membership of groups) {
if (!this.enabled || !this.active || scopeGeneration !== this.scopeGeneration) {
return changedGroups
}
if (membership.membersValid === false) continue
const previous = this.snapshots.get(membership.roomId)
// 空数组可能是查询失败,先保留旧基线,避免误报和覆盖最后有效快照。
if (previous && previous.members.length > 0 && membership.memberIds.length === 0) continue
const previousMembers = new Map(previous?.members.map((member) => [member.wxid, member]))
const next: GroupSnapshotRecord = {
contactId: membership.contactId,
roomId: membership.roomId,
groupName:
membership.groupName === membership.roomId && previous
? previous.groupName
: membership.groupName,
capturedAt: membership.capturedAt,
members: membership.memberIds.map(
(wxid) => previousMembers.get(wxid) || ({ wxid } satisfies GroupExitMonitorMember)
),
membersValid: true
}
const membershipChanged = !previous || !sameMemberIds(previous.members, membership.memberIds)
if (membershipChanged) changedGroups += 1
if (previous && next.members.length < previous.members.length) {
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) exits.push(event)
}
}
// Last Good Snapshot 必须在 Diff 完成后才能替换。
this.snapshots.set(next.roomId, next)
if (next.members.some((member) => !hasMemberMetadata(member))) {
this.hydrationQueue.add(next.roomId)
}
}
if (!this.enabled || !this.active || scopeGeneration !== this.scopeGeneration) {
return changedGroups
}
this.lastCheckedAt = Date.now()
// 先把事件和新基线作为同一检查点落盘,再交给自动化。
// 「退群事实已记录」与「通知发送成功」是两件独立的事。
this.save()
this.broadcast()
for (const event of exits) this.emitGroupExit(event)
return changedGroups
}
private startNextSnapshotHydration(): void {
if (!this.enabled || !this.active) {
this.finishHydrationBatch()
return
}
if (this.initializing || this.checking || this.hydrating) return
if (this.checkQueued || this.changeTimer) {
this.finishHydrationBatch()
return
}
const roomId = this.hydrationQueue.values().next().value as string | undefined
if (!roomId) {
this.finishHydrationBatch()
return
}
this.hydrationQueue.delete(roomId)
const baseline = this.snapshots.get(roomId)
if (!baseline || !this.monitoredRoomIds.has(roomId)) {
this.startNextSnapshotHydration()
return
}
this.hydrating = true
if (this.hydrationBatchStartedAt === null) this.hydrationBatchStartedAt = Date.now()
this.hydrationBatchGroups += 1
const scopeGeneration = this.scopeGeneration
void chat
.getGroupSnapshotAsync(baseline.contactId)
.then((snapshot) => {
const current = this.snapshots.get(roomId)
if (
!snapshot ||
snapshot.roomId !== roomId ||
!this.active ||
scopeGeneration !== this.scopeGeneration ||
!current ||
!sameMemberIds(
current.members,
snapshot.members.map((member) => member.wxid)
)
)
return
const hydrated = new Map(
normalizeSnapshotMembers(snapshot.members).map((member) => [member.wxid, member])
)
this.snapshots.set(roomId, {
...current,
groupName: resolveGroupName(roomId, this.groupNamesByRoomId, current, snapshot.groupName),
members: current.members.map((member) => {
const refreshed = hydrated.get(member.wxid)
return refreshed ? { ...member, ...refreshed } : member
})
})
this.hydrationQueue.delete(roomId)
this.save()
this.broadcast()
})
.catch(() => undefined)
.finally(() => {
this.hydrating = false
if (this.checkQueued && this.active) {
this.finishHydrationBatch()
this.checkQueued = false
void this.check()
} else if (this.changeTimer) {
this.finishHydrationBatch()
} else {
this.startNextSnapshotHydration()
}
})
}
private finishHydrationBatch(): void {
if (this.hydrationBatchStartedAt === null || this.hydrationBatchGroups === 0) return
console.log(
`[GroupMonitor] hydration groups=${this.hydrationBatchGroups} costMs=${Date.now() - this.hydrationBatchStartedAt}`
)
this.hydrationBatchStartedAt = null
this.hydrationBatchGroups = 0
}
private async refreshGroupNamesIfNeeded(): Promise<void> {
if (!this.groupNamesRefreshPending) return
this.groupNamesRefreshPending = false
try {
const names = await chat.getGroupNamesAsync()
for (const [roomId, name] of Object.entries(names || {})) {
const resolved = normalizeKnownGroupName(roomId, name)
if (resolved) this.groupNamesByRoomId.set(roomId, resolved)
}
} catch {
// Session 群名是展示信息;读取失败时继续使用快照中的已知群名。
}
}
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
}
// 内存按时间倒序(新事件在前);磁盘**只追加这一条**,不重写历史。
// 这里不再有 `.slice(0, MAX_EVENTS)` —— 事件是永久保留的。
this.events = [event, ...this.events]
this.appendEventsToDisk([event])
console.log(
`[GroupMonitor] detected member exit roomId=${group.roomId} member=${member.wxid} ${previousCount}->${currentCount}`
)
return event
}
/**
* 把退群事件交给自动化 —— **不等待**。
*
* 三条约束:
* 1. **绝不 await**:快照扫描与成员 diff 不能被微信发送耗时拖住;
* 2. **异常必须被捕获**:`void promise` 漏掉 `.catch` 会变成 unhandled rejection;
* 3. **不影响退群事实**:事件与快照在同一检查点已经先落盘,通知失败不回滚记录。
*/
private emitGroupExit(event: GroupExitMonitorEvent): void {
const handler = this.onGroupExit
if (!handler) return
try {
void Promise.resolve(
handler(toGroupMemberExitedEvent(event))
).catch((error) => {
console.warn(
`[GroupMonitor] 退群通知处理失败 eventId=${event.id}: ${
error instanceof Error ? error.message : String(error)
}`
)
})
} catch (error) {
// handler 同步抛出的情况(`handleGroupExit` 本身不抛,这里是防御性兜底)。
console.warn(
`[GroupMonitor] 退群通知处理异常 eventId=${event.id}: ${
error instanceof Error ? error.message : String(error)
}`
)
}
}
private filePath(): string {
return path.join(app.getPath('userData'), 'group-exit-monitor.json')
}
/**
* 退群事件的 append-only 存储(每行一条 JSON)。
*
* 与状态文件分开,因为两者的写入模式完全不同:
* - **状态**(开关 / 监控范围 / 快照 / 模板)小、且总是整体重写;
* - **事件**只增不改,且要求**永久保留**。
* 混在一个文件里时,每来一条事件都要把整部历史重新序列化写一遍 —— 越写越慢。
*/
private eventsPath(): string {
return path.join(app.getPath('userData'), 'group-exit-monitor-events.jsonl')
}
/** 读全量历史事件。单行损坏只跳过该行,不让整部历史读不出来。 */
private readEventsFromDisk(): GroupExitMonitorEvent[] {
let raw = ''
try {
raw = fs.readFileSync(this.eventsPath(), 'utf8')
} catch {
return []
}
const events: GroupExitMonitorEvent[] = []
for (const line of raw.split('\n')) {
const trimmed = line.trim()
if (!trimmed) continue
try {
events.push(JSON.parse(trimmed) as GroupExitMonitorEvent)
} catch {
// 跳过坏行
}
}
return events
}
/** 只追加新增的那几行,不重写历史。 */
private appendEventsToDisk(events: GroupExitMonitorEvent[]): void {
if (!events.length) return
try {
fs.ensureDirSync(path.dirname(this.eventsPath()))
const payload = events.map((event) => `${JSON.stringify(event)}\n`).join('')
fs.appendFileSync(this.eventsPath(), payload, 'utf8')
} catch (error) {
console.warn('[GroupMonitor] 追加退群事件失败:', error)
}
}
/** 整体重写事件文件(清空、切换账号、老数据迁移时使用)。 */
private rewriteEventsToDisk(events: GroupExitMonitorEvent[]): void {
try {
fs.ensureDirSync(path.dirname(this.eventsPath()))
const payload = events.map((event) => `${JSON.stringify(event)}\n`).join('')
fs.writeFileSync(this.eventsPath(), payload, 'utf8')
} catch (error) {
console.warn('[GroupMonitor] 重写退群事件失败:', error)
}
}
private ensureLoaded(): void {
if (this.loaded) return
this.loaded = true
try {
const stored = fs.readJsonSync(this.filePath()) as StoredState
this.enabled = stored.enabled !== false
// 事件从 append-only 文件读;状态文件不再承载它们。
const fromDisk = this.readEventsFromDisk()
const legacy = normalizeEvents(stored.events)
if (legacy.length && !fromDisk.length) {
// 老版本把事件塞在状态文件里 —— 一次性迁移过去,避免这批历史丢失。
// 落盘按时间**升序**(旧 → 新),与之后 append 的方向一致,避免在
// append-only 文件开头留下一段方向相反的旧历史(历史行序错乱的来源)。
this.rewriteEventsToDisk(
[...legacy].sort((left, right) => left.detectedAt - right.detectedAt)
)
this.events = sortEventsNewestFirst(legacy)
} else {
// 磁盘行序不保证时间有序(迁移段与追加段方向相反),读回后必须显式重建
// 「新在前」这个内存不变量,否则列表顶部会恒为最旧的一批。
this.events = sortEventsNewestFirst(normalizeEvents(fromDisk))
}
this.lastReadAt = Number(stored.lastReadAt) || 0
this.accountRoot = String(stored.accountRoot || '')
// 没有显式范围时按空范围处理,保留已有选择。
this.monitorSelectionConfigured = true
this.monitoredRoomIds = normalizeRoomIds(stored.monitoredRoomIds || [])
this.snapshots = normalizeSnapshots(stored.snapshots, this.monitoredRoomIds)
} catch {
// 首次启动或状态文件损坏时从空记录开始 —— 但事件在独立文件里,
// 不该被状态文件的问题连累,仍然读回来。
this.events = sortEventsNewestFirst(normalizeEvents(this.readEventsFromDisk()))
this.enabled = true
this.lastReadAt = 0
this.monitorSelectionConfigured = true
this.monitoredRoomIds.clear()
this.snapshots.clear()
}
}
private save(): void {
this.ensureLoaded()
try {
fs.ensureDirSync(path.dirname(this.filePath()))
fs.writeJsonSync(
this.filePath(),
{
accountRoot: this.accountRoot,
enabled: this.enabled,
// 事件**不在这里**:它们走 append-only 的 JSONL(见 `eventsPath()`)。
// 放进状态文件会让每新增一条事件都把整部历史重写一遍。
lastReadAt: this.lastReadAt,
monitorSelectionConfigured: this.monitorSelectionConfigured,
monitoredRoomIds: Array.from(this.monitoredRoomIds),
snapshots: Array.from(this.snapshots.values(), toStoredSnapshot)
},
{ 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<string> {
return new Set(
(Array.isArray(roomIds) ? roomIds : [])
.map((roomId) => String(roomId || '').trim())
.filter((roomId) => roomId.endsWith('@chatroom'))
)
}
function normalizeMemberIds(values: unknown[]): string[] {
return Array.from(
new Set(values.map((value) => String(value || '').trim()).filter((value) => Boolean(value)))
)
}
function sameMemberIds(previous: GroupExitMonitorMember[], nextIds: string[]): boolean {
if (previous.length !== nextIds.length) return false
const previousIds = new Set(previous.map((member) => member.wxid))
return nextIds.every((wxid) => previousIds.has(wxid))
}
function hasMemberMetadata(member: GroupExitMonitorMember): boolean {
return Boolean(
member.nickname?.trim() ||
member.groupNickname?.trim() ||
member.wechatNickname?.trim() ||
member.remark?.trim()
)
}
function sameAccountRoot(left: string, right: string): boolean {
const normalize = (value: string): string => {
const resolved = path.resolve(value)
return process.platform === 'win32' ? resolved.toLowerCase() : resolved
}
return normalize(left) === normalize(right)
}
function normalizeSnapshots(
values: Partial<StoredGroupSnapshot>[] | undefined,
monitoredRoomIds: Set<string>
): Map<string, GroupSnapshotRecord> {
const snapshots = new Map<string, GroupSnapshotRecord>()
if (!Array.isArray(values)) return snapshots
for (const value of values) {
const roomId = String(value?.roomId || '').trim()
const capturedAt = Number(value?.capturedAt)
if (
!roomId.endsWith('@chatroom') ||
!monitoredRoomIds.has(roomId) ||
!Number.isFinite(capturedAt) ||
capturedAt <= 0 ||
!Array.isArray(value?.members)
) {
continue
}
snapshots.set(roomId, {
contactId: String(value.contactId || ''),
roomId,
groupName: cleanGroupName(value.groupName, roomId),
capturedAt,
members: normalizeSnapshotMembers(value.members)
})
}
return snapshots
}
function normalizeSnapshotMembers(values: GroupExitMonitorMember[]): GroupExitMonitorMember[] {
const members: GroupExitMonitorMember[] = []
const seen = new Set<string>()
for (const value of values) {
const wxid = String(value?.wxid || '').trim()
if (!wxid || seen.has(wxid)) continue
seen.add(wxid)
members.push({
wxid,
...optionalMemberField('nickname', value.nickname),
...optionalMemberField('groupNickname', value.groupNickname),
...optionalMemberField('wechatNickname', value.wechatNickname),
...optionalMemberField('remark', value.remark),
...optionalMemberField('avatar', value.avatar)
})
}
return members
}
function optionalMemberField<K extends keyof GroupExitMonitorMember>(
key: K,
value: unknown
): Partial<Pick<GroupExitMonitorMember, K>> {
const normalized = String(value || '').trim()
return normalized ? ({ [key]: normalized } as Pick<GroupExitMonitorMember, K>) : {}
}
function normalizeKnownGroupName(roomId: string, value: unknown): string {
const name = String(value || '').trim()
return name && name !== roomId && !name.endsWith('@chatroom') ? name : ''
}
function resolveGroupName(
roomId: string,
namesByRoomId: Map<string, string>,
previous?: Pick<GroupSnapshotRecord, 'groupName'>,
hydratedName?: unknown
): string {
return (
normalizeKnownGroupName(roomId, namesByRoomId.get(roomId)) ||
normalizeKnownGroupName(roomId, previous?.groupName) ||
normalizeKnownGroupName(roomId, hydratedName) ||
roomId
)
}
function toStoredSnapshot(snapshot: GroupSnapshotRecord): StoredGroupSnapshot {
return {
contactId: snapshot.contactId,
roomId: snapshot.roomId,
groupName: snapshot.groupName,
capturedAt: snapshot.capturedAt,
members: normalizeSnapshotMembers(snapshot.members)
}
}
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<GroupExitMonitorEvent>[] | 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
})
// 不再按 MAX_EVENTS 截断:事件是永久保留的,截在这里等于每次启动都丢掉历史。
// 历史上的 `notificationStatus` / `notification` 字段被**丢弃**:
// 通知状态已归 Automation 执行日志,退群监控不再持有它。
}
return normalized
}
/**
* 事件在内存里恒定保持「**新在前**」。
*
* 这个不变量有三个依赖方:`recordExit` 的 `[event, ...this.events]` 写入方向、
* `listEvents()` 的 `.reverse()`(它假定内存是倒序,反转后得到升序)、
* 以及 `getState()` 的 `slice(0, MAX_EVENTS)`(要求取到的是**最新**的一批)。
*
* 必须显式重建它:磁盘是 append-only,行序由「迁移写入的历史 + 之后追加的新事件」
* 决定,两段方向相反,整体不保证时间有序。直接信任文件行序会让列表顶部恒为最旧的
* 一批,并让 `slice(0, MAX_EVENTS)` 恰好把最新的事件截掉。
*/
function sortEventsNewestFirst(events: GroupExitMonitorEvent[]): GroupExitMonitorEvent[] {
return [...events].sort((left, right) => right.detectedAt - left.detectedAt)
}
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 }