diff --git a/resources/wcdb/win32/x64/wcdb_api.dll b/resources/wcdb/win32/x64/wcdb_api.dll index 07b4313..530f6ed 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/services/chat-service.ts b/src/main/services/chat-service.ts index 413f7b9..48adb79 100644 --- a/src/main/services/chat-service.ts +++ b/src/main/services/chat-service.ts @@ -86,6 +86,7 @@ export interface FormattedMessage { export interface GroupSnapshot { roomId: string memberCount: number + groupName?: string members: { wxid: string nickname: string @@ -96,6 +97,15 @@ export interface GroupSnapshot { }[] } +export interface GroupMembershipSnapshot { + roomId: string + memberIds: string[] +} + +export interface GroupMembershipBatchSnapshot extends GroupMembershipSnapshot { + status: 'ok' | 'not_found' +} + const MSG_TYPE_DICT: Record = { 1: '普通文本', 3: '图片', @@ -236,6 +246,19 @@ export async function listContactsAsync(filter?: string): Promise> { + if (!dbRef) return {} + const sessions = await dbRef.getWcdb4Client().getSessionsAsync({ hydrateDisplayNames: false }) + const names: Record = {} + for (const session of sessions) { + if (!session.username.endsWith('@chatroom')) continue + const name = String(session.nickname || '').trim() + if (name) names[session.username] = name + } + return names +} + export async function getContactAvatars(usernames: string[]): Promise> { if (!dbRef) return {} const normalized = Array.from( @@ -615,7 +638,40 @@ export async function getGroupSnapshotAsync(userMd5: string): Promise item.username === roomId) + return { + roomId, + groupName: session?.nickname || undefined, + memberCount: members.length, + members + } +} + +/** 退群检测专用轻量读取,不执行成员名称或头像 hydration。 */ +export async function getGroupMemberIdsAsync( + roomId: string +): Promise { + if (!dbRef || !roomId.endsWith('@chatroom')) return null + const memberIds = await dbRef.getWcdb4Client().getGroupMemberIdsAsync(roomId) + return memberIds ? { roomId, memberIds } : null +} + +export function isGroupMemberIdsBatchAvailable(): boolean { + return Boolean(dbRef?.getWcdb4Client().isGroupMemberIdsBatchAvailable()) +} + +export async function getGroupMemberIdsBatchAsync( + roomIds: string[] +): Promise { + if (!dbRef) return null + const results = await dbRef.getWcdb4Client().getGroupMemberIdsBatchAsync(roomIds) + return results + ? results.map((result) => ({ + roomId: result.roomId, + status: result.status, + memberIds: result.memberWxids + })) + : null } export function searchMessages(keyword: string): string | null { diff --git a/src/main/services/group-exit-monitor-service.ts b/src/main/services/group-exit-monitor-service.ts index bfe44b4..06f4898 100644 --- a/src/main/services/group-exit-monitor-service.ts +++ b/src/main/services/group-exit-monitor-service.ts @@ -24,19 +24,37 @@ type StoredState = { monitoredRoomIds?: string[] notificationRoomIds?: string[] notificationTemplate?: unknown + snapshots?: Partial[] } type GroupSnapshotRecord = { contactId: string roomId: string groupName: string + capturedAt: number members: GroupExitMonitorMember[] /** 查询失败时保留旧快照。 */ membersValid?: boolean } +type GroupMembershipRecord = Omit & { + 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 GROUP_READ_CONCURRENCY = 8 const DUPLICATE_WINDOW_MS = 2 * 60 * 1000 const MAX_EVENTS = 500 @@ -58,6 +76,10 @@ class GroupExitMonitorService { private checking = false private checkQueued = false private initializing = false + private hydrating = false + private hydrationQueue = new Set() + private hydrationBatchStartedAt: number | null = null + private hydrationBatchGroups = 0 private lastCheckedAt: number | undefined private lastReadAt = 0 private events: GroupExitMonitorEvent[] = [] @@ -66,8 +88,11 @@ class GroupExitMonitorService { private notificationRoomIds = new Set() private loaded = false private accountRoot = '' + private groupNamesByRoomId = new Map() + private groupNamesRefreshPending = true private eventSequence = 0 private scopeGeneration = 0 + private legacyFallbackLogged = false private notificationTemplate = normalizeGroupExitNotificationTemplate(undefined) constructor(deps: GroupExitMonitorServiceDependencies = {}) { @@ -94,10 +119,15 @@ class GroupExitMonitorService { async start(nativeMonitorActive: boolean): Promise { this.ensureLoaded() const currentRoot = chat.getCurrentAccountRoot() + if (!currentRoot) { + this.active = true + this.nativeMonitorActive = nativeMonitorActive + this.broadcast() + return + } if ( - currentRoot && - this.accountRoot && - path.resolve(currentRoot) !== path.resolve(this.accountRoot) + (this.accountRoot && !sameAccountRoot(currentRoot, this.accountRoot)) || + (!this.accountRoot && this.snapshots.size > 0) ) { this.actionGateway.clearMemberEvents?.() this.events = [] @@ -105,32 +135,31 @@ class GroupExitMonitorService { this.monitorSelectionConfigured = true this.monitoredRoomIds.clear() this.notificationRoomIds.clear() + this.snapshots.clear() + this.groupNamesByRoomId.clear() } - if (currentRoot) this.accountRoot = currentRoot + this.accountRoot = currentRoot + this.groupNamesRefreshPending = true 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 + this.eventSequence += 1 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() + 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() } } } @@ -140,8 +169,9 @@ class GroupExitMonitorService { this.nativeMonitorActive = false this.eventSequence += 1 this.scopeGeneration += 1 - this.snapshots.clear() this.checkQueued = false + this.hydrationQueue.clear() + this.groupNamesRefreshPending = true if (this.changeTimer) clearTimeout(this.changeTimer) this.changeTimer = null this.broadcast() @@ -154,6 +184,7 @@ class GroupExitMonitorService { } catch { // 缓存清理失败时继续检查。 } + this.groupNamesRefreshPending = true if (this.changeTimer) clearTimeout(this.changeTimer) this.changeTimer = setTimeout(() => { this.changeTimer = null @@ -175,13 +206,19 @@ class GroupExitMonitorService { this.eventSequence += 1 this.scopeGeneration += 1 this.monitorSelectionConfigured = true - this.monitoredRoomIds = normalizeRoomIds(roomIds) + const nextMonitoredRoomIds = normalizeRoomIds(roomIds) + 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 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() @@ -221,8 +258,16 @@ class GroupExitMonitorService { } private async check(): Promise { - if (!this.active || !chat.isReady()) return - if (this.initializing) { + const currentRoot = chat.getCurrentAccountRoot() + if ( + !this.active || + !chat.isReady() || + !currentRoot || + !this.accountRoot || + !sameAccountRoot(currentRoot, this.accountRoot) + ) + return + if (this.initializing || this.hydrating) { this.checkQueued = true return } @@ -234,143 +279,240 @@ class GroupExitMonitorService { 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) - } - - const notificationTasks: Promise[] = [] - 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)) { - notificationTasks.push(this.notifyGroup(next, event)) - } - } - } - if (notificationTasks.length) await Promise.all(notificationTasks) - if (!this.active || scopeGeneration !== this.scopeGeneration) return - this.lastCheckedAt = Date.now() - this.save() - this.broadcast() + await this.runMembershipCheck(scopeGeneration) } finally { this.checking = false if (this.checkQueued && this.active) { this.checkQueued = false void this.check() + } else { + this.startNextSnapshotHydration() } } } - private async readGroups(): Promise { + private async runMembershipCheck(scopeGeneration: number): Promise { + const startedAt = Date.now() + const result = await this.readMemberships() + const membershipCostMs = Date.now() - startedAt + let changedGroups = 0 + if (result.groups && 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 { + await this.refreshGroupNamesIfNeeded() const database = chat.getChatDb() - if (!database) return null + 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 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 - } + 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 records.filter((record): record is GroupSnapshotRecord => Boolean(record)) + } + return { mode, groupCount: roomIds.length, groups } } catch (error) { - console.warn('[GroupMonitor] 读取群成员快照失败:', error) - return null + console.warn('[GroupMonitor] 读取群成员状态失败:', error) + return { mode, groupCount: roomIds.length, groups: null } } } - private replaceSnapshots(groups: GroupSnapshotRecord[]): void { - this.snapshots = new Map( - groups.filter((group) => group.membersValid !== false).map((group) => [group.roomId, group]) + private async applyCurrentMemberships( + groups: GroupMembershipRecord[], + scopeGeneration: number + ): Promise { + const notifications: Array<{ group: GroupSnapshotRecord; event: GroupExitMonitorEvent }> = [] + let changedGroups = 0 + for (const membership of groups) { + if (!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 && this.notificationRoomIds.has(next.roomId)) { + notifications.push({ group: next, 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.active || scopeGeneration !== this.scopeGeneration) return changedGroups + this.lastCheckedAt = Date.now() + // 先把事件和新基线作为同一检查点落盘,再执行可失败的通知动作。 + this.save() + this.broadcast() + if (notifications.length) { + await Promise.all(notifications.map(({ group, event }) => this.notifyGroup(group, event))) + } + return changedGroups + } + + private startNextSnapshotHydration(): void { + if (!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 { + 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( @@ -508,6 +650,7 @@ class GroupExitMonitorService { this.notificationTemplate = normalizeGroupExitNotificationTemplate( stored.notificationTemplate ) + this.snapshots = normalizeSnapshots(stored.snapshots, this.monitoredRoomIds) } catch { // 首次启动或文件损坏时从空记录开始。 this.events = [] @@ -516,6 +659,7 @@ class GroupExitMonitorService { this.monitoredRoomIds.clear() this.notificationRoomIds.clear() this.notificationTemplate = normalizeGroupExitNotificationTemplate(undefined) + this.snapshots.clear() } } @@ -532,12 +676,13 @@ class GroupExitMonitorService { monitorSelectionConfigured: this.monitorSelectionConfigured, monitoredRoomIds: Array.from(this.monitoredRoomIds), notificationRoomIds: Array.from(this.notificationRoomIds), - notificationTemplate: this.notificationTemplate + notificationTemplate: this.notificationTemplate, + snapshots: Array.from(this.snapshots.values(), toStoredSnapshot) }, { spaces: 2 } ) } catch (error) { - console.warn('[GroupMonitor] 保存事件失败:', error) + console.warn('[GroupMonitor] 保存状态失败:', error) } } @@ -557,6 +702,120 @@ function normalizeRoomIds(roomIds: string[]): Set { ) } +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[] | undefined, + monitoredRoomIds: Set +): Map { + const snapshots = new Map() + 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() + 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( + key: K, + value: unknown +): Partial> { + const normalized = String(value || '').trim() + return normalized ? ({ [key]: normalized } as Pick) : {} +} + +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, + previous?: Pick, + 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 diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts index de7f7fa..2632fd4 100644 --- a/src/main/wcdb4-client.ts +++ b/src/main/wcdb4-client.ts @@ -59,6 +59,46 @@ export interface Wcdb4GroupMember { m_nsHeadImgUrl: string } +export type Wcdb4GroupMemberBatchStatus = 'ok' | 'not_found' + +export interface Wcdb4GroupMemberIdsBatchItem { + roomId: string + status: Wcdb4GroupMemberBatchStatus + memberWxids: string[] +} + +function normalizeGroupMemberIdsBatch( + value: unknown, + requestedRoomIds: string[] +): Wcdb4GroupMemberIdsBatchItem[] | null { + if (!Array.isArray(value) || value.length !== requestedRoomIds.length) return null + const requested = new Set(requestedRoomIds) + const results = new Map() + for (const rawItem of value) { + if (!rawItem || typeof rawItem !== 'object') return null + const item = rawItem as Record + const roomId = typeof item.roomId === 'string' ? item.roomId.trim() : '' + const status = item.status + if ( + !roomId || + !requested.has(roomId) || + results.has(roomId) || + (status !== 'ok' && status !== 'not_found') || + !Array.isArray(item.memberWxids) || + item.memberWxids.some((memberWxid) => typeof memberWxid !== 'string' || !memberWxid.trim()) + ) { + return null + } + results.set(roomId, { + roomId, + status, + memberWxids: Array.from(new Set(item.memberWxids.map((memberWxid) => memberWxid.trim()))) + }) + } + if (results.size !== requestedRoomIds.length) return null + return requestedRoomIds.map((roomId) => results.get(roomId)!) +} + /** * 联系人表中独立保存的成员名称字段。 * @@ -395,6 +435,9 @@ export class Wcdb4Client { private wcdbGetGroupMembers: | ((handle: number, chatroomId: string, outJson: WcdbVoidOut) => number) | null = null + private wcdbGetGroupMembersBatch: + | ((handle: number, chatroomIdsJson: string, outJson: WcdbVoidOut) => number) + | null = null private wcdbGetGroupNicknames: | ((handle: number, chatroomId: string, outJson: WcdbVoidOut) => number) | null = null @@ -1900,6 +1943,55 @@ export class Wcdb4Client { } } + /** + * 退群检测热路径:只读取原始成员行并提取 wxid,不补群昵称、联系人资料或头像。 + * null 表示读取失败,避免调用方把失败误判为成员列表为空。 + */ + async getGroupMemberIdsAsync(chatroomId: string): Promise { + if (!this.wcdbGetGroupMembers || !chatroomId) return null + + try { + const rows = await this.callJsonAsync[]>( + this.wcdbGetGroupMembers as unknown as KoffiAsyncFunction, + chatroomId + ) + const memberRows = Array.isArray(rows) ? rows : [] + return Array.from(new Set(this.groupMemberUsernames(memberRows))) + } catch (error) { + console.warn(`[WCDB4] async group member ids failed chatroom=${chatroomId}:`, error) + return null + } + } + + isGroupMemberIdsBatchAvailable(): boolean { + return Boolean(this.wcdbGetGroupMembersBatch) + } + + async getGroupMemberIdsBatchAsync( + roomIds: string[] + ): Promise { + const requestedRoomIds = Array.from( + new Set( + (Array.isArray(roomIds) ? roomIds : []) + .map((roomId) => String(roomId || '').trim()) + .filter(Boolean) + ) + ) + if (requestedRoomIds.length === 0) return [] + if (!this.wcdbGetGroupMembersBatch) return null + + try { + const raw = await this.callJsonAsync( + this.wcdbGetGroupMembersBatch as unknown as KoffiAsyncFunction, + JSON.stringify(requestedRoomIds) + ) + return normalizeGroupMemberIdsBatch(raw, requestedRoomIds) + } catch (error) { + console.warn('[WCDB4] async group member ids batch failed:', error) + return null + } + } + /** * 将群成员接口与联系人表组装为语义明确的成员快照。 * @@ -2390,6 +2482,14 @@ export class Wcdb4Client { this.wcdbGetGroupMembers = null } + try { + this.wcdbGetGroupMembersBatch = lib.func( + 'int32 wcdb_get_group_members_batch(int64 handle, const char* chatroomIdsJson, _Out_ void** outJson)' + ) as (handle: number, chatroomIdsJson: string, outJson: WcdbVoidOut) => number + } catch { + this.wcdbGetGroupMembersBatch = null + } + try { this.wcdbGetGroupNicknames = lib.func( 'int32 wcdb_get_group_nicknames(int64 handle, const char* chatroomId, _Out_ void** outJson)' diff --git a/tests/unit/group-exit-monitor-service.test.ts b/tests/unit/group-exit-monitor-service.test.ts index c42e2a5..3c07571 100644 --- a/tests/unit/group-exit-monitor-service.test.ts +++ b/tests/unit/group-exit-monitor-service.test.ts @@ -9,6 +9,10 @@ const mocks = vi.hoisted(() => ({ getChatDb: vi.fn(), getCurrentAccountRoot: vi.fn(() => 'fixture-account'), isReady: vi.fn(() => true), + getGroupNamesAsync: vi.fn(async () => ({})), + isGroupMemberIdsBatchAvailable: vi.fn(() => true), + getGroupMemberIdsBatchAsync: vi.fn(), + getGroupMemberIdsAsync: vi.fn(), getGroupSnapshotAsync: vi.fn() }, capability: { @@ -35,17 +39,39 @@ import { GroupExitMonitorService } from '../../src/main/services/group-exit-moni import { GROUP_EXIT_NOTIFICATION_TEMPLATE } from '../../src/shared/group-exit-monitor' const member = { wxid: 'wxid_member', wechatNickname: '微信名', groupNickname: '群内名' } +const otherMember = { wxid: 'wxid_other', wechatNickname: '另一个人' } function installGroupDb(): void { const client = { - getSessionsAsync: vi - .fn() - .mockResolvedValue([{ username: 'room@chatroom', nickname: '测试群' }]), - md5: vi.fn(() => 'room-md5') + md5: vi.fn((roomId: string) => `${roomId}-md5`), + invalidateGroupNicknameCache: vi.fn() } mocks.chat.getChatDb.mockReturnValue({ getWcdb4Client: () => client }) } +function writeBaseline( + snapshots: Array<{ + roomId: string + groupName: string + members: Array<{ wxid: string; [key: string]: string }> + }>, + notificationRoomIds: string[] = [] +): void { + writeJsonSync(join(mocks.userData, 'group-exit-monitor.json'), { + accountRoot: 'fixture-account', + events: [], + monitoredRoomIds: snapshots.map((snapshot) => snapshot.roomId), + notificationRoomIds, + snapshots: snapshots.map((snapshot) => ({ + contactId: `${snapshot.roomId}-md5`, + roomId: snapshot.roomId, + groupName: snapshot.groupName, + capturedAt: Date.now() - 1_000, + members: snapshot.members + })) + }) +} + describe('GroupExitMonitorService', () => { const temporaryDirectories: string[] = [] @@ -55,7 +81,11 @@ describe('GroupExitMonitorService', () => { mocks.chat.getChatDb.mockReset() mocks.chat.getCurrentAccountRoot.mockReset().mockReturnValue('fixture-account') mocks.chat.isReady.mockReset().mockReturnValue(true) - mocks.chat.getGroupSnapshotAsync.mockReset() + mocks.chat.getGroupNamesAsync.mockReset().mockResolvedValue({}) + mocks.chat.isGroupMemberIdsBatchAvailable.mockReset().mockReturnValue(true) + mocks.chat.getGroupMemberIdsBatchAsync.mockReset() + mocks.chat.getGroupMemberIdsAsync.mockReset() + mocks.chat.getGroupSnapshotAsync.mockReset().mockResolvedValue(null) mocks.capability.getPersonalWechatSendCapability.mockReset() mocks.sender.send.mockReset() }) @@ -89,38 +119,418 @@ describe('GroupExitMonitorService', () => { expect(service.getState().notificationTemplate).toBe(GROUP_EXIT_NOTIFICATION_TEMPLATE) }) - it('keeps the previous baseline when a native snapshot contains null members', async () => { + it('uses the Session nickname when establishing a new group baseline', async () => { installGroupDb() + const callOrder: string[] = [] + mocks.chat.getGroupNamesAsync.mockImplementation(async () => { + callOrder.push('names') + return { 'A@chatroom': '测试群A' } + }) + mocks.chat.getGroupMemberIdsBatchAsync.mockImplementation(async () => { + callOrder.push('batch') + return [{ roomId: 'A@chatroom', status: 'ok', memberIds: [member.wxid] }] + }) + mocks.chat.getGroupSnapshotAsync.mockImplementation(async () => { + callOrder.push('hydration') + return null + }) + const service = new GroupExitMonitorService() + await service.setMonitoredRoomIds(['A@chatroom']) + await service.start(true) + + const stored = readJsonSync(join(mocks.userData, 'group-exit-monitor.json')) + expect(stored.snapshots[0]).toMatchObject({ roomId: 'A@chatroom', groupName: '测试群A' }) + expect(callOrder.indexOf('batch')).toBeGreaterThan(callOrder.indexOf('names')) + expect(callOrder.indexOf('hydration')).toBeGreaterThan(callOrder.indexOf('batch')) + }) + + it('preserves existing member metadata during a Batch membership refresh', async () => { + installGroupDb() + const richMember = { + wxid: 'wxid_rich', + nickname: 'Shinven', + groupNickname: '群内 Shinven', + wechatNickname: 'Shinven', + remark: '小号', + avatar: 'data:image/png;base64,known' + } + writeBaseline([{ roomId: 'A@chatroom', groupName: '测试群A', members: [richMember] }]) + mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([ + { roomId: 'A@chatroom', status: 'ok', memberIds: [richMember.wxid] } + ]) + const service = new GroupExitMonitorService() + + await service.start(true) + + expect( + readJsonSync(join(mocks.userData, 'group-exit-monitor.json')).snapshots[0].members + ).toEqual([richMember]) + }) + + it('keeps hydrated member metadata across the next Batch refresh', async () => { + installGroupDb() + writeBaseline([ + { roomId: 'A@chatroom', groupName: '测试群A', members: [{ wxid: 'wxid_rich' }] } + ]) + mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([ + { roomId: 'A@chatroom', status: 'ok', memberIds: ['wxid_rich'] } + ]) + mocks.chat.getGroupSnapshotAsync.mockResolvedValue({ + roomId: 'A@chatroom', + groupName: '测试群A', + members: [ + { + wxid: 'wxid_rich', + nickname: 'Shinven', + groupNickname: '群内 Shinven', + wechatNickname: 'Shinven', + remark: '小号', + avatar: '' + } + ] + }) + const service = new GroupExitMonitorService() + await service.start(true) + await vi.waitFor(() => { + expect( + readJsonSync(join(mocks.userData, 'group-exit-monitor.json')).snapshots[0].members[0] + ).toMatchObject({ wxid: 'wxid_rich', nickname: 'Shinven', remark: '小号' }) + }) + + await service.checkNow() + + expect( + readJsonSync(join(mocks.userData, 'group-exit-monitor.json')).snapshots[0].members[0] + ).toMatchObject({ wxid: 'wxid_rich', nickname: 'Shinven', remark: '小号' }) + }) + + it('preserves old member metadata while allowing a new member to start wxid-only', async () => { + installGroupDb() + const existing = { wxid: 'wxid_existing', nickname: 'Shinven', remark: '小号' } + writeBaseline([{ roomId: 'A@chatroom', groupName: '测试群A', members: [existing] }]) + mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([ + { roomId: 'A@chatroom', status: 'ok', memberIds: [existing.wxid, 'wxid_new'] } + ]) + mocks.chat.getGroupSnapshotAsync.mockResolvedValue({ + roomId: 'A@chatroom', + groupName: '测试群A', + members: [{ wxid: existing.wxid }, { wxid: 'wxid_new', nickname: '新成员' }] + }) + const service = new GroupExitMonitorService() + + await service.start(true) + + await vi.waitFor(() => { + expect( + readJsonSync(join(mocks.userData, 'group-exit-monitor.json')).snapshots[0].members + ).toEqual([existing, { wxid: 'wxid_new', nickname: '新成员' }]) + }) + }) + + it('repairs a persisted room id fallback when Session metadata becomes available', async () => { + installGroupDb() + writeBaseline([{ roomId: 'A@chatroom', groupName: 'A@chatroom', members: [member] }]) + mocks.chat.getGroupNamesAsync.mockResolvedValue({ 'A@chatroom': '测试群A' }) + mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([ + { roomId: 'A@chatroom', status: 'ok', memberIds: [member.wxid] } + ]) + const service = new GroupExitMonitorService() + await service.start(true) + + expect( + readJsonSync(join(mocks.userData, 'group-exit-monitor.json')).snapshots[0] + ).toMatchObject({ roomId: 'A@chatroom', groupName: '测试群A' }) + }) + + it('keeps the previous resolved group name when Session metadata is temporarily unavailable', async () => { + installGroupDb() + writeBaseline([{ roomId: 'A@chatroom', groupName: '测试群A', members: [member] }]) + mocks.chat.getGroupNamesAsync.mockResolvedValue({}) + mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([ + { roomId: 'A@chatroom', status: 'ok', memberIds: [member.wxid] } + ]) + const service = new GroupExitMonitorService() + await service.start(true) + + expect( + readJsonSync(join(mocks.userData, 'group-exit-monitor.json')).snapshots[0] + ).toMatchObject({ roomId: 'A@chatroom', groupName: '测试群A' }) + }) + + it('uses one resolved group name for both 3-to-1 events and Gateway notification recipients', async () => { + installGroupDb() + const departed = { wxid: 'wxid_departed', groupNickname: '离群一' } + const secondDeparted = { wxid: 'wxid_second_departed', groupNickname: '离群二' } + writeBaseline( + [ + { + roomId: 'A@chatroom', + groupName: 'A@chatroom', + members: [member, departed, secondDeparted] + } + ], + ['A@chatroom'] + ) + mocks.chat.getGroupNamesAsync.mockResolvedValue({ 'A@chatroom': '测试群A' }) + mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([ + { roomId: 'A@chatroom', status: 'ok', memberIds: [member.wxid] } + ]) + const requests: Array<{ recipient?: { name?: string } }> = [] + const actionGateway = { + execute: vi.fn(async (request: { recipient?: { name?: string } }) => { + requests.push(request) + return { status: 'sent', actionId: 'action-1', decision: 'allow' } + }), + registerMemberEvent: vi.fn() + } + const service = new GroupExitMonitorService({ actionGateway }) + + await service.start(true) + + expect(service.getState().events).toHaveLength(2) + expect(service.getState().events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + memberWxid: departed.wxid, + groupName: '测试群A', + message: '离群一退出了测试群A', + previousCount: 3, + currentCount: 1 + }), + expect.objectContaining({ + memberWxid: secondDeparted.wxid, + groupName: '测试群A', + message: '离群二退出了测试群A', + previousCount: 3, + currentCount: 1 + }) + ]) + ) + expect(requests).toHaveLength(2) + expect(requests.every((request) => request.recipient?.name === '测试群A')).toBe(true) + expect(mocks.chat.getGroupSnapshotAsync).not.toHaveBeenCalled() + }) + + it('persists a compact last good snapshot and recovers offline exits after restart', async () => { + installGroupDb() + const departed = { + wxid: 'wxid_departed', + nickname: '旧昵称', + wechatNickname: '离群成员', + groupNickname: '群内昵称', + remark: '通讯录备注', + avatar: 'data:image/png;base64,not-persisted' + } + const secondDeparted = { + wxid: 'wxid_second_departed', + nickname: '另一个旧昵称', + wechatNickname: '第二位离群成员', + groupNickname: '第二位群内昵称', + remark: '第二位通讯录备注' + } mocks.chat.getGroupSnapshotAsync.mockResolvedValueOnce({ roomId: 'room@chatroom', - members: [member] + members: [member, departed, secondDeparted] }) + mocks.chat.getGroupMemberIdsBatchAsync + .mockResolvedValueOnce([ + { + roomId: 'room@chatroom', + status: 'ok', + memberIds: [member.wxid, departed.wxid, secondDeparted.wxid] + } + ]) + .mockResolvedValue([{ roomId: 'room@chatroom', status: 'ok', memberIds: [member.wxid] }]) const service = new GroupExitMonitorService() service.setMonitoredRoomIds(['room@chatroom']) await service.start(true) - mocks.chat.getGroupSnapshotAsync.mockResolvedValueOnce({ - roomId: 'room@chatroom', - members: null + const statePath = join(mocks.userData, 'group-exit-monitor.json') + await vi.waitFor(() => { + expect(readJsonSync(statePath).snapshots[0].members[1]).toMatchObject({ + wxid: departed.wxid, + wechatNickname: departed.wechatNickname + }) }) - const state = await service.checkNow() + const baseline = readJsonSync(statePath) + expect(baseline.events).toEqual([]) + expect(baseline.snapshots).toEqual([ + expect.objectContaining({ + roomId: 'room@chatroom', + groupName: 'room@chatroom', + capturedAt: expect.any(Number), + members: [ + member, + expect.objectContaining({ wxid: 'wxid_departed' }), + expect.objectContaining({ wxid: 'wxid_second_departed' }) + ] + }) + ]) + expect(baseline.snapshots[0].members[1]).toHaveProperty('avatar', departed.avatar) + + service.stop() + const restarted = new GroupExitMonitorService() + await restarted.start(true) + + expect(restarted.getState().events).toHaveLength(2) + expect(restarted.getState().events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + memberWxid: 'wxid_departed', + memberName: departed.groupNickname, + previousCount: 3, + currentCount: 1 + }), + expect.objectContaining({ + memberWxid: 'wxid_second_departed', + memberName: secondDeparted.groupNickname, + previousCount: 3, + currentCount: 1 + }) + ]) + ) + expect(mocks.chat.getGroupMemberIdsBatchAsync).toHaveBeenCalledTimes(2) + expect(mocks.chat.getGroupMemberIdsAsync).not.toHaveBeenCalled() + expect(readJsonSync(statePath).snapshots[0].members).toEqual([member]) + + await restarted.start(true) + expect(restarted.getState().events).toHaveLength(2) + + restarted.clearEvents() + expect(readJsonSync(statePath).snapshots[0].members).toEqual([member]) + }) + + it('keeps every previous baseline when a batch result is invalid or incomplete', async () => { + installGroupDb() + writeBaseline([ + { roomId: 'room@chatroom', groupName: '测试群', members: [member] }, + { roomId: 'other@chatroom', groupName: '其他群', members: [otherMember] } + ]) + mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue(null) + const service = new GroupExitMonitorService() + await service.start(true) + const state = service.getState() expect(state.events).toEqual([]) - expect(state.monitoredGroupCount).toBe(1) + expect(state.monitoredGroupCount).toBe(2) + expect(readJsonSync(join(mocks.userData, 'group-exit-monitor.json')).snapshots).toEqual([ + expect.objectContaining({ roomId: 'room@chatroom', members: [member] }), + expect.objectContaining({ roomId: 'other@chatroom', members: [otherMember] }) + ]) + }) + + it('treats not_found as unavailable and preserves the last good snapshot', async () => { + installGroupDb() + writeBaseline([ + { roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] } + ]) + mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([ + { roomId: 'room@chatroom', status: 'not_found', memberIds: [] } + ]) + const service = new GroupExitMonitorService() + + await service.start(true) + + expect(service.getState().events).toEqual([]) + expect( + readJsonSync(join(mocks.userData, 'group-exit-monitor.json')).snapshots[0].members + ).toEqual([member, otherMember]) + }) + + it('uses the legacy member reader only when the batch symbol is unavailable', async () => { + installGroupDb() + writeBaseline([ + { roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }, + { + roomId: 'other@chatroom', + groupName: '其他群', + members: [{ wxid: 'wxid_stays', nickname: '保留成员' }] + } + ]) + mocks.chat.isGroupMemberIdsBatchAvailable.mockReturnValue(false) + mocks.chat.getGroupMemberIdsAsync.mockImplementation(async (roomId: string) => ({ + roomId, + memberIds: roomId === 'room@chatroom' ? [member.wxid] : ['wxid_stays'] + })) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const service = new GroupExitMonitorService() + + await service.start(true) + await service.checkNow() + + expect(service.getState().events).toHaveLength(1) + expect(mocks.chat.getGroupMemberIdsBatchAsync).not.toHaveBeenCalled() + expect(mocks.chat.getGroupMemberIdsAsync).toHaveBeenCalledTimes(4) + expect(warn).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledWith( + '[GroupMonitor] batch membership unavailable; using legacy fallback' + ) + warn.mockRestore() + }) + + it('keeps snapshots when only notification settings change', async () => { + writeBaseline([{ roomId: 'room@chatroom', groupName: '测试群', members: [member] }]) + const service = new GroupExitMonitorService() + service.getState() + + await service.setMonitoredRoomIds(['room@chatroom'], ['room@chatroom']) + + const stored = readJsonSync(join(mocks.userData, 'group-exit-monitor.json')) + expect(stored.notificationRoomIds).toEqual(['room@chatroom']) + expect(stored.snapshots).toHaveLength(1) + expect(stored.snapshots[0].members).toEqual([member]) + }) + + it('retains existing baselines and only establishes a baseline for a newly monitored group', async () => { + installGroupDb() + writeBaseline([{ roomId: 'room@chatroom', groupName: '测试群', members: [member] }]) + const service = new GroupExitMonitorService() + await service.setMonitoredRoomIds(['room@chatroom', 'new-room@chatroom']) + mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([ + { roomId: 'room@chatroom', status: 'ok', memberIds: [member.wxid] }, + { roomId: 'new-room@chatroom', status: 'ok', memberIds: ['wxid_new_group_member'] } + ]) + await service.start(true) + + expect(service.getState().events).toEqual([]) + const stored = readJsonSync(join(mocks.userData, 'group-exit-monitor.json')) + expect(stored.snapshots.map((snapshot: { roomId: string }) => snapshot.roomId).sort()).toEqual([ + 'new-room@chatroom', + 'room@chatroom' + ]) + }) + + it('does not diff persisted snapshots across account roots', async () => { + installGroupDb() + writeBaseline([ + { + roomId: 'room@chatroom', + groupName: '测试群', + members: [member, { wxid: 'wxid_account_a_member' }] + } + ]) + + mocks.chat.getCurrentAccountRoot.mockReturnValue('different-account') + const switched = new GroupExitMonitorService() + await switched.start(true) + + expect(switched.getState().events).toEqual([]) + const stored = readJsonSync(join(mocks.userData, 'group-exit-monitor.json')) + expect(stored.accountRoot).toBe('different-account') + expect(stored.snapshots).toEqual([]) }) it('returns from scope saving before the background baseline read completes', async () => { installGroupDb() - mocks.chat.getGroupSnapshotAsync.mockResolvedValueOnce({ - roomId: 'room@chatroom', - members: [member] - }) + writeBaseline([{ roomId: 'room@chatroom', groupName: '测试群', members: [member] }]) + mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValueOnce([ + { roomId: 'room@chatroom', status: 'ok', memberIds: [member.wxid] } + ]) const service = new GroupExitMonitorService() - service.setMonitoredRoomIds(['room@chatroom']) await service.start(true) let release!: (snapshot: unknown) => void - mocks.chat.getGroupSnapshotAsync.mockReturnValueOnce( + mocks.chat.getGroupMemberIdsBatchAsync.mockReturnValueOnce( new Promise((resolve) => { release = resolve }) @@ -130,27 +540,148 @@ describe('GroupExitMonitorService', () => { expect(Date.now() - startedAt).toBeLessThan(250) expect(state.monitoredRoomIds).toEqual(['room@chatroom']) - release({ roomId: 'room@chatroom', members: [member] }) + release([{ roomId: 'room@chatroom', status: 'ok', memberIds: [member.wxid] }]) + }) + + it('detects a removed member across multiple groups without full snapshot hydration', async () => { + installGroupDb() + const departed = { + wxid: 'wxid_departed', + wechatNickname: 'Previous Snapshot 姓名', + groupNickname: 'Previous Snapshot 群昵称', + remark: 'Previous Snapshot 备注' + } + writeBaseline([ + { roomId: 'room@chatroom', groupName: '目标群', members: [member, departed] }, + { + roomId: 'other-1@chatroom', + groupName: '其他群 1', + members: [{ wxid: 'wxid_other_1', nickname: '成员 1' }] + }, + { + roomId: 'other-2@chatroom', + groupName: '其他群 2', + members: [{ wxid: 'wxid_other_2', nickname: '成员 2' }] + } + ]) + mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([ + { roomId: 'room@chatroom', status: 'ok', memberIds: [member.wxid] }, + { roomId: 'other-1@chatroom', status: 'ok', memberIds: ['wxid_other_1'] }, + { roomId: 'other-2@chatroom', status: 'ok', memberIds: ['wxid_other_2'] } + ]) + + const service = new GroupExitMonitorService() + await service.start(true) + + expect(service.getState().events[0]).toMatchObject({ + memberWxid: departed.wxid, + memberName: departed.groupNickname, + wechatName: departed.wechatNickname, + groupRemark: departed.groupNickname, + contactRemark: departed.remark + }) + expect(mocks.chat.getGroupMemberIdsBatchAsync).toHaveBeenCalledOnce() + expect(mocks.chat.getGroupMemberIdsBatchAsync).toHaveBeenCalledWith([ + 'room@chatroom', + 'other-1@chatroom', + 'other-2@chatroom' + ]) + expect(mocks.chat.getGroupMemberIdsAsync).not.toHaveBeenCalled() + expect(mocks.chat.getGroupSnapshotAsync).not.toHaveBeenCalled() + }) + + it('logs one summary for a membership check and one for a hydration batch', async () => { + installGroupDb() + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined) + mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([ + { roomId: 'room@chatroom', status: 'ok', memberIds: [member.wxid] } + ]) + mocks.chat.getGroupSnapshotAsync.mockResolvedValue({ + roomId: 'room@chatroom', + members: [member] + }) + const service = new GroupExitMonitorService() + service.setMonitoredRoomIds(['room@chatroom']) + + await service.start(true) + await vi.waitFor(() => { + expect(log).toHaveBeenCalledWith( + expect.stringMatching(/^\[GroupMonitor\] hydration groups=1 costMs=\d+$/) + ) + }) + + expect(log).toHaveBeenCalledWith( + expect.stringMatching( + /^\[GroupMonitor\] check mode=batch groups=1 membershipCostMs=\d+ changedGroups=1 totalCostMs=\d+$/ + ) + ) + log.mockRestore() + }) + + it('pauses queued hydration as soon as a contact change is pending', async () => { + vi.useFakeTimers() + installGroupDb() + let membershipCalls = 0 + let finishPriorityCheck!: () => void + const priorityCheckFinished = new Promise((resolve) => { + finishPriorityCheck = resolve + }) + mocks.chat.getGroupMemberIdsBatchAsync.mockImplementation(async (roomIds: string[]) => { + membershipCalls += 1 + if (membershipCalls === 2) finishPriorityCheck() + return roomIds.map((roomId) => ({ + roomId, + status: 'ok', + memberIds: [roomId === 'room@chatroom' ? 'wxid_room' : 'wxid_other'] + })) + }) + let finishFirstHydration!: (snapshot: unknown) => void + mocks.chat.getGroupSnapshotAsync + .mockReturnValueOnce( + new Promise((resolve) => { + finishFirstHydration = resolve + }) + ) + .mockReturnValue(new Promise(() => undefined)) + const service = new GroupExitMonitorService() + service.setMonitoredRoomIds(['room@chatroom', 'other@chatroom']) + await service.start(true) + expect(mocks.chat.getGroupSnapshotAsync).toHaveBeenCalledOnce() + + service.notifyDatabaseChanged('{"table":"contact","action":"update"}') + finishFirstHydration({ + roomId: 'room@chatroom', + members: [{ wxid: 'wxid_room', nickname: '成员 1' }] + }) + await Promise.resolve() + await Promise.resolve() + + expect(mocks.chat.getGroupSnapshotAsync).toHaveBeenCalledOnce() + expect(mocks.chat.getGroupMemberIdsBatchAsync).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(350) + await priorityCheckFinished + expect(mocks.chat.getGroupMemberIdsBatchAsync).toHaveBeenCalledTimes(2) + service.stop() + vi.useRealTimers() }) it('renders the saved template before sending a group notification', async () => { installGroupDb() + writeBaseline( + [{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }], + ['room@chatroom'] + ) mocks.capability.getPersonalWechatSendCapability.mockResolvedValue({ ready: true, capabilities: { text: true } }) mocks.sender.send.mockResolvedValue({ success: true }) - mocks.chat.getGroupSnapshotAsync - .mockResolvedValueOnce({ - roomId: 'room@chatroom', - members: [member, { wxid: 'wxid_other', wechatNickname: '另一个人' }] - }) - .mockResolvedValueOnce({ roomId: 'room@chatroom', members: [member] }) + mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([ + { roomId: 'room@chatroom', status: 'ok', memberIds: [member.wxid] } + ]) const service = new GroupExitMonitorService() service.setNotificationTemplate('退群: {user}/{groupRemark}/{wxid}') - service.setMonitoredRoomIds(['room@chatroom'], ['room@chatroom']) await service.start(true) - await service.checkNow() expect(mocks.sender.send).toHaveBeenCalledWith({ type: 'text', @@ -162,16 +693,15 @@ describe('GroupExitMonitorService', () => { it('records an exit without creating a send action when notification is disabled', async () => { installGroupDb() - mocks.chat.getGroupSnapshotAsync - .mockResolvedValueOnce({ - roomId: 'room@chatroom', - members: [member, { wxid: 'wxid_other', wechatNickname: '另一个人' }] - }) - .mockResolvedValueOnce({ roomId: 'room@chatroom', members: [member] }) + writeBaseline([ + { roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] } + ]) + mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([ + { roomId: 'room@chatroom', status: 'ok', memberIds: [member.wxid] } + ]) const service = new GroupExitMonitorService() - service.setMonitoredRoomIds(['room@chatroom']) await service.start(true) - const state = await service.checkNow() + const state = service.getState() expect(state.events[0]).toMatchObject({ memberWxid: 'wxid_other', @@ -183,6 +713,10 @@ describe('GroupExitMonitorService', () => { it('微信发送能力不可用时仍保留退群事件', async () => { installGroupDb() + writeBaseline( + [{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }], + ['room@chatroom'] + ) mocks.capability.getPersonalWechatSendCapability.mockResolvedValue({ ready: false, supported: true, @@ -191,16 +725,12 @@ describe('GroupExitMonitorService', () => { senderStatus: {}, message: '当前微信发送能力不可用' }) - mocks.chat.getGroupSnapshotAsync - .mockResolvedValueOnce({ - roomId: 'room@chatroom', - members: [member, { wxid: 'wxid_other', wechatNickname: '另一个人' }] - }) - .mockResolvedValueOnce({ roomId: 'room@chatroom', members: [member] }) + mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([ + { roomId: 'room@chatroom', status: 'ok', memberIds: [member.wxid] } + ]) const service = new GroupExitMonitorService() - service.setMonitoredRoomIds(['room@chatroom'], ['room@chatroom']) await service.start(true) - const state = await service.checkNow() + const state = service.getState() expect(state.events[0]).toMatchObject({ notificationStatus: 'failed', @@ -214,6 +744,10 @@ describe('GroupExitMonitorService', () => { it('does not send twice when the same snapshot diff is checked again', async () => { installGroupDb() + writeBaseline( + [{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }], + ['room@chatroom'] + ) mocks.capability.getPersonalWechatSendCapability.mockResolvedValue({ ready: true, capabilities: { text: true, image: false, voice: false }, @@ -223,15 +757,10 @@ describe('GroupExitMonitorService', () => { message: 'ready' }) mocks.sender.send.mockResolvedValue({ success: true }) - mocks.chat.getGroupSnapshotAsync - .mockResolvedValueOnce({ - roomId: 'room@chatroom', - members: [member, { wxid: 'wxid_other', wechatNickname: '另一个人' }] - }) - .mockResolvedValueOnce({ roomId: 'room@chatroom', members: [member] }) - .mockResolvedValueOnce({ roomId: 'room@chatroom', members: [member] }) + mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([ + { roomId: 'room@chatroom', status: 'ok', memberIds: [member.wxid] } + ]) const service = new GroupExitMonitorService() - service.setMonitoredRoomIds(['room@chatroom'], ['room@chatroom']) await service.start(true) await service.checkNow() await service.checkNow() diff --git a/tests/unit/wcdb-group-members.test.ts b/tests/unit/wcdb-group-members.test.ts index 6f721c3..e28c34f 100644 --- a/tests/unit/wcdb-group-members.test.ts +++ b/tests/unit/wcdb-group-members.test.ts @@ -87,6 +87,35 @@ describe('WCDB group member names', () => { ) }) + it('reads only member ids without hydrating names or avatars', async () => { + const getGroupMembers = vi.fn() + const callJsonAsync = vi.fn(async () => [ + ...groupMemberRows, + { username: 'wxid-other' }, + { username: 'wxid-member' }, + { username: '' } + ]) + const getGroupNicknamesAsync = vi.fn() + const readContactMemberNamesAsync = vi.fn() + const hydrateAvatarUrlsAsync = vi.fn() + const client = Object.assign(Object.create(Wcdb4Client.prototype), { + wcdbGetGroupMembers: getGroupMembers, + callJsonAsync, + getGroupNicknamesAsync, + readContactMemberNamesAsync, + hydrateAvatarUrlsAsync + }) as Wcdb4Client + + await expect(client.getGroupMemberIdsAsync('fixture@chatroom')).resolves.toEqual([ + 'wxid-member', + 'wxid-other' + ]) + expect(callJsonAsync).toHaveBeenCalledOnce() + expect(getGroupNicknamesAsync).not.toHaveBeenCalled() + expect(readContactMemberNamesAsync).not.toHaveBeenCalled() + expect(hydrateAvatarUrlsAsync).not.toHaveBeenCalled() + }) + it('falls back to the group nickname without leaking an ambiguous member nickname', () => { const getGroupMembers = vi.fn(() => 0) const executeQuery = vi.fn(() => 0) @@ -121,12 +150,14 @@ describe('WCDB group member names', () => { wxid: '' }) as Wcdb4Client - const finalizeMessages = (client as unknown as { - finalizeMessages: ( - username: string, - rows: Record[] - ) => Array<{ senderNickname?: string }> - }).finalizeMessages + const finalizeMessages = ( + client as unknown as { + finalizeMessages: ( + username: string, + rows: Record[] + ) => Array<{ senderNickname?: string }> + } + ).finalizeMessages expect( finalizeMessages.call(client, 'fixture@chatroom', [ @@ -142,3 +173,110 @@ describe('WCDB group member names', () => { expect(client.getGroupNicknames).toHaveBeenCalledWith('fixture@chatroom') }) }) + +describe('WCDB group member batch', () => { + it('reads 200 rooms with one batch call and no legacy member call', async () => { + const roomIds = Array.from({ length: 200 }, (_, index) => `room-${index}@chatroom`) + const batchResult = roomIds.map((roomId, index) => ({ + roomId, + status: 'ok', + memberWxids: [`wxid_${index}`] + })) + const batchAsync = vi.fn( + ( + _handle: number, + _roomIdsJson: string, + outJson: [unknown], + callback: (error: unknown, result: number) => void + ) => { + outJson[0] = 'batch-json-pointer' + callback(null, 0) + } + ) + const legacyAsync = vi.fn() + const batchBinding = Object.assign(vi.fn(), { async: batchAsync }) + const legacyBinding = Object.assign(vi.fn(), { async: legacyAsync }) + const client = Object.assign(Object.create(Wcdb4Client.prototype), { + wcdbGetGroupMembersBatch: batchBinding, + wcdbGetGroupMembers: legacyBinding, + handle: 42, + closing: false, + nativeCallsInFlight: new Set>(), + koffi: { decode: vi.fn(() => JSON.stringify(batchResult)) }, + wcdbFreeString: vi.fn() + }) as Wcdb4Client + + await expect(client.getGroupMemberIdsBatchAsync(roomIds)).resolves.toEqual(batchResult) + expect(batchAsync).toHaveBeenCalledOnce() + expect(batchAsync).toHaveBeenCalledWith( + 42, + JSON.stringify(roomIds), + expect.any(Array), + expect.any(Function) + ) + expect(legacyAsync).not.toHaveBeenCalled() + }) + + it('trims and deduplicates room ids while preserving request order', async () => { + const batchBinding = vi.fn() + const requestedRoomIds = ['b@chatroom', 'a@chatroom'] + const callJsonAsync = vi.fn(async () => + requestedRoomIds.map((roomId) => ({ roomId, status: 'ok', memberWxids: [] })) + ) + const client = Object.assign(Object.create(Wcdb4Client.prototype), { + wcdbGetGroupMembersBatch: batchBinding, + callJsonAsync + }) as Wcdb4Client + + await expect( + client.getGroupMemberIdsBatchAsync([ + ' b@chatroom ', + 'a@chatroom', + 'b@chatroom', + '', + ' a@chatroom ' + ]) + ).resolves.toEqual([ + { roomId: 'b@chatroom', status: 'ok', memberWxids: [] }, + { roomId: 'a@chatroom', status: 'ok', memberWxids: [] } + ]) + expect(callJsonAsync).toHaveBeenCalledWith(batchBinding, JSON.stringify(requestedRoomIds)) + }) + + it.each([ + ['non-array result', { roomId: 'a@chatroom' }], + ['missing room result', [{ roomId: 'a@chatroom', status: 'ok', memberWxids: ['wxid_a'] }]], + [ + 'duplicate room result', + [ + { roomId: 'a@chatroom', status: 'ok', memberWxids: ['wxid_a'] }, + { roomId: 'a@chatroom', status: 'ok', memberWxids: ['wxid_b'] } + ] + ], + [ + 'unknown status', + [ + { roomId: 'a@chatroom', status: 'stale', memberWxids: ['wxid_a'] }, + { roomId: 'b@chatroom', status: 'ok', memberWxids: ['wxid_b'] } + ] + ], + [ + 'malformed member list', + [ + { roomId: 'a@chatroom', status: 'ok', memberWxids: ['wxid_a'] }, + { roomId: 'b@chatroom', status: 'ok', memberWxids: [null] } + ] + ] + ])('rejects an invalid batch: %s', async (_name, rawResult) => { + const batchBinding = vi.fn() + const callJsonAsync = vi.fn(async () => rawResult) + const client = Object.assign(Object.create(Wcdb4Client.prototype), { + wcdbGetGroupMembersBatch: batchBinding, + callJsonAsync + }) as Wcdb4Client + + await expect( + client.getGroupMemberIdsBatchAsync(['a@chatroom', 'b@chatroom']) + ).resolves.toBeNull() + }) +})