mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-08 07:21:19 +08:00
perf: 重构退群监控,持久化快照并降低 CPU 占用
- 持久化群成员 - 支持 TraceMemo 重启后恢复离线期间的退群检测 - 使用 Batch Membership 替代逐群成员查询 - 降低大量群聊监控时的 CPU 和数据库查询开销
This commit is contained in:
Binary file not shown.
@@ -86,6 +86,7 @@ export interface FormattedMessage {
|
|||||||
export interface GroupSnapshot {
|
export interface GroupSnapshot {
|
||||||
roomId: string
|
roomId: string
|
||||||
memberCount: number
|
memberCount: number
|
||||||
|
groupName?: string
|
||||||
members: {
|
members: {
|
||||||
wxid: string
|
wxid: string
|
||||||
nickname: 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<number, string> = {
|
const MSG_TYPE_DICT: Record<number, string> = {
|
||||||
1: '普通文本',
|
1: '普通文本',
|
||||||
3: '图片',
|
3: '图片',
|
||||||
@@ -236,6 +246,19 @@ export async function listContactsAsync(filter?: string): Promise<FormattedConta
|
|||||||
return listContacts(filter)
|
return listContacts(filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 读取已缓存/轻量 Session 群名,不执行成员、头像或联系人资料 hydration。 */
|
||||||
|
export async function getGroupNamesAsync(): Promise<Record<string, string>> {
|
||||||
|
if (!dbRef) return {}
|
||||||
|
const sessions = await dbRef.getWcdb4Client().getSessionsAsync({ hydrateDisplayNames: false })
|
||||||
|
const names: Record<string, string> = {}
|
||||||
|
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<Record<string, string>> {
|
export async function getContactAvatars(usernames: string[]): Promise<Record<string, string>> {
|
||||||
if (!dbRef) return {}
|
if (!dbRef) return {}
|
||||||
const normalized = Array.from(
|
const normalized = Array.from(
|
||||||
@@ -615,7 +638,40 @@ export async function getGroupSnapshotAsync(userMd5: string): Promise<GroupSnaps
|
|||||||
remark: member.remark || '',
|
remark: member.remark || '',
|
||||||
avatar: member.m_nsHeadImgUrl || ''
|
avatar: member.m_nsHeadImgUrl || ''
|
||||||
}))
|
}))
|
||||||
return { roomId, memberCount: members.length, members }
|
const session = wcdb4Client.getSessions().find((item) => item.username === roomId)
|
||||||
|
return {
|
||||||
|
roomId,
|
||||||
|
groupName: session?.nickname || undefined,
|
||||||
|
memberCount: members.length,
|
||||||
|
members
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 退群检测专用轻量读取,不执行成员名称或头像 hydration。 */
|
||||||
|
export async function getGroupMemberIdsAsync(
|
||||||
|
roomId: string
|
||||||
|
): Promise<GroupMembershipSnapshot | null> {
|
||||||
|
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<GroupMembershipBatchSnapshot[] | null> {
|
||||||
|
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 {
|
export function searchMessages(keyword: string): string | null {
|
||||||
|
|||||||
@@ -24,19 +24,37 @@ type StoredState = {
|
|||||||
monitoredRoomIds?: string[]
|
monitoredRoomIds?: string[]
|
||||||
notificationRoomIds?: string[]
|
notificationRoomIds?: string[]
|
||||||
notificationTemplate?: unknown
|
notificationTemplate?: unknown
|
||||||
|
snapshots?: Partial<StoredGroupSnapshot>[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type GroupSnapshotRecord = {
|
type GroupSnapshotRecord = {
|
||||||
contactId: string
|
contactId: string
|
||||||
roomId: string
|
roomId: string
|
||||||
groupName: string
|
groupName: string
|
||||||
|
capturedAt: number
|
||||||
members: GroupExitMonitorMember[]
|
members: GroupExitMonitorMember[]
|
||||||
/** 查询失败时保留旧快照。 */
|
/** 查询失败时保留旧快照。 */
|
||||||
membersValid?: boolean
|
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 DB_CHANGE_DEBOUNCE_MS = 350
|
||||||
const GROUP_READ_CONCURRENCY = 8
|
|
||||||
const DUPLICATE_WINDOW_MS = 2 * 60 * 1000
|
const DUPLICATE_WINDOW_MS = 2 * 60 * 1000
|
||||||
const MAX_EVENTS = 500
|
const MAX_EVENTS = 500
|
||||||
|
|
||||||
@@ -58,6 +76,10 @@ class GroupExitMonitorService {
|
|||||||
private checking = false
|
private checking = false
|
||||||
private checkQueued = false
|
private checkQueued = false
|
||||||
private initializing = 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 lastCheckedAt: number | undefined
|
||||||
private lastReadAt = 0
|
private lastReadAt = 0
|
||||||
private events: GroupExitMonitorEvent[] = []
|
private events: GroupExitMonitorEvent[] = []
|
||||||
@@ -66,8 +88,11 @@ class GroupExitMonitorService {
|
|||||||
private notificationRoomIds = new Set<string>()
|
private notificationRoomIds = new Set<string>()
|
||||||
private loaded = false
|
private loaded = false
|
||||||
private accountRoot = ''
|
private accountRoot = ''
|
||||||
|
private groupNamesByRoomId = new Map<string, string>()
|
||||||
|
private groupNamesRefreshPending = true
|
||||||
private eventSequence = 0
|
private eventSequence = 0
|
||||||
private scopeGeneration = 0
|
private scopeGeneration = 0
|
||||||
|
private legacyFallbackLogged = false
|
||||||
private notificationTemplate = normalizeGroupExitNotificationTemplate(undefined)
|
private notificationTemplate = normalizeGroupExitNotificationTemplate(undefined)
|
||||||
|
|
||||||
constructor(deps: GroupExitMonitorServiceDependencies = {}) {
|
constructor(deps: GroupExitMonitorServiceDependencies = {}) {
|
||||||
@@ -94,10 +119,15 @@ class GroupExitMonitorService {
|
|||||||
async start(nativeMonitorActive: boolean): Promise<void> {
|
async start(nativeMonitorActive: boolean): Promise<void> {
|
||||||
this.ensureLoaded()
|
this.ensureLoaded()
|
||||||
const currentRoot = chat.getCurrentAccountRoot()
|
const currentRoot = chat.getCurrentAccountRoot()
|
||||||
|
if (!currentRoot) {
|
||||||
|
this.active = true
|
||||||
|
this.nativeMonitorActive = nativeMonitorActive
|
||||||
|
this.broadcast()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
currentRoot &&
|
(this.accountRoot && !sameAccountRoot(currentRoot, this.accountRoot)) ||
|
||||||
this.accountRoot &&
|
(!this.accountRoot && this.snapshots.size > 0)
|
||||||
path.resolve(currentRoot) !== path.resolve(this.accountRoot)
|
|
||||||
) {
|
) {
|
||||||
this.actionGateway.clearMemberEvents?.()
|
this.actionGateway.clearMemberEvents?.()
|
||||||
this.events = []
|
this.events = []
|
||||||
@@ -105,32 +135,31 @@ class GroupExitMonitorService {
|
|||||||
this.monitorSelectionConfigured = true
|
this.monitorSelectionConfigured = true
|
||||||
this.monitoredRoomIds.clear()
|
this.monitoredRoomIds.clear()
|
||||||
this.notificationRoomIds.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.active = true
|
||||||
this.nativeMonitorActive = nativeMonitorActive
|
this.nativeMonitorActive = nativeMonitorActive
|
||||||
this.snapshots.clear()
|
|
||||||
this.checkQueued = false
|
this.checkQueued = false
|
||||||
this.initializing = true
|
this.initializing = true
|
||||||
const scopeGeneration = ++this.scopeGeneration
|
const scopeGeneration = ++this.scopeGeneration
|
||||||
const generation = this.eventSequence + 1
|
this.eventSequence += 1
|
||||||
this.eventSequence = generation
|
|
||||||
try {
|
try {
|
||||||
const groups = await this.readGroups()
|
const checked = await this.runMembershipCheck(scopeGeneration)
|
||||||
if (
|
if (!this.active || this.scopeGeneration !== scopeGeneration) return
|
||||||
!this.active ||
|
if (!checked) {
|
||||||
this.eventSequence !== generation ||
|
|
||||||
this.scopeGeneration !== scopeGeneration
|
|
||||||
)
|
|
||||||
return
|
|
||||||
if (groups) this.replaceSnapshots(groups)
|
|
||||||
this.save()
|
this.save()
|
||||||
this.broadcast()
|
this.broadcast()
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.initializing = false
|
this.initializing = false
|
||||||
if (this.checkQueued && this.active) {
|
if (this.checkQueued && this.active) {
|
||||||
this.checkQueued = false
|
this.checkQueued = false
|
||||||
void this.check()
|
void this.check()
|
||||||
|
} else {
|
||||||
|
this.startNextSnapshotHydration()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,8 +169,9 @@ class GroupExitMonitorService {
|
|||||||
this.nativeMonitorActive = false
|
this.nativeMonitorActive = false
|
||||||
this.eventSequence += 1
|
this.eventSequence += 1
|
||||||
this.scopeGeneration += 1
|
this.scopeGeneration += 1
|
||||||
this.snapshots.clear()
|
|
||||||
this.checkQueued = false
|
this.checkQueued = false
|
||||||
|
this.hydrationQueue.clear()
|
||||||
|
this.groupNamesRefreshPending = true
|
||||||
if (this.changeTimer) clearTimeout(this.changeTimer)
|
if (this.changeTimer) clearTimeout(this.changeTimer)
|
||||||
this.changeTimer = null
|
this.changeTimer = null
|
||||||
this.broadcast()
|
this.broadcast()
|
||||||
@@ -154,6 +184,7 @@ class GroupExitMonitorService {
|
|||||||
} catch {
|
} catch {
|
||||||
// 缓存清理失败时继续检查。
|
// 缓存清理失败时继续检查。
|
||||||
}
|
}
|
||||||
|
this.groupNamesRefreshPending = true
|
||||||
if (this.changeTimer) clearTimeout(this.changeTimer)
|
if (this.changeTimer) clearTimeout(this.changeTimer)
|
||||||
this.changeTimer = setTimeout(() => {
|
this.changeTimer = setTimeout(() => {
|
||||||
this.changeTimer = null
|
this.changeTimer = null
|
||||||
@@ -175,13 +206,19 @@ class GroupExitMonitorService {
|
|||||||
this.eventSequence += 1
|
this.eventSequence += 1
|
||||||
this.scopeGeneration += 1
|
this.scopeGeneration += 1
|
||||||
this.monitorSelectionConfigured = true
|
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)
|
const requestedNotifications = normalizeRoomIds(notificationRoomIds)
|
||||||
this.notificationRoomIds = new Set(
|
this.notificationRoomIds = new Set(
|
||||||
Array.from(requestedNotifications).filter((roomId) => this.monitoredRoomIds.has(roomId))
|
Array.from(requestedNotifications).filter((roomId) => this.monitoredRoomIds.has(roomId))
|
||||||
)
|
)
|
||||||
// 更换监控范围后重新建基线,避免旧快照误报。
|
|
||||||
this.snapshots.clear()
|
|
||||||
this.lastCheckedAt = undefined
|
this.lastCheckedAt = undefined
|
||||||
this.save()
|
this.save()
|
||||||
this.broadcast()
|
this.broadcast()
|
||||||
@@ -221,8 +258,16 @@ class GroupExitMonitorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async check(): Promise<void> {
|
private async check(): Promise<void> {
|
||||||
if (!this.active || !chat.isReady()) return
|
const currentRoot = chat.getCurrentAccountRoot()
|
||||||
if (this.initializing) {
|
if (
|
||||||
|
!this.active ||
|
||||||
|
!chat.isReady() ||
|
||||||
|
!currentRoot ||
|
||||||
|
!this.accountRoot ||
|
||||||
|
!sameAccountRoot(currentRoot, this.accountRoot)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if (this.initializing || this.hydrating) {
|
||||||
this.checkQueued = true
|
this.checkQueued = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -234,143 +279,240 @@ class GroupExitMonitorService {
|
|||||||
this.checking = true
|
this.checking = true
|
||||||
const scopeGeneration = this.scopeGeneration
|
const scopeGeneration = this.scopeGeneration
|
||||||
try {
|
try {
|
||||||
const groups = await this.readGroups()
|
await this.runMembershipCheck(scopeGeneration)
|
||||||
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<void>[] = []
|
|
||||||
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()
|
|
||||||
} finally {
|
} finally {
|
||||||
this.checking = false
|
this.checking = false
|
||||||
if (this.checkQueued && this.active) {
|
if (this.checkQueued && this.active) {
|
||||||
this.checkQueued = false
|
this.checkQueued = false
|
||||||
void this.check()
|
void this.check()
|
||||||
|
} else {
|
||||||
|
this.startNextSnapshotHydration()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async readGroups(): Promise<GroupSnapshotRecord[] | null> {
|
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.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 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 {
|
try {
|
||||||
const client = database.getWcdb4Client()
|
const client = database.getWcdb4Client()
|
||||||
const rawSessions = await client.getSessionsAsync({ hydrateDisplayNames: true })
|
const capturedAt = Date.now()
|
||||||
const sessions = Array.isArray(rawSessions) ? rawSessions : []
|
|
||||||
const groups = sessions.filter(
|
if (batchAvailable) {
|
||||||
(session) =>
|
const snapshots = await chat.getGroupMemberIdsBatchAsync(roomIds)
|
||||||
session.username?.endsWith('@chatroom') === true &&
|
if (!snapshots) return { mode, groupCount: roomIds.length, groups: null }
|
||||||
(!this.monitorSelectionConfigured || this.monitoredRoomIds.has(session.username))
|
return {
|
||||||
)
|
mode,
|
||||||
const records: Array<GroupSnapshotRecord | null> = new Array(groups.length).fill(null)
|
groupCount: roomIds.length,
|
||||||
let nextIndex = 0
|
groups: snapshots.map((snapshot) => {
|
||||||
const workerCount = Math.min(GROUP_READ_CONCURRENCY, groups.length)
|
const previous = this.snapshots.get(snapshot.roomId)
|
||||||
await Promise.all(
|
return {
|
||||||
Array.from({ length: workerCount }, async () => {
|
contactId: previous?.contactId || client.md5(snapshot.roomId),
|
||||||
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,
|
roomId: snapshot.roomId,
|
||||||
groupName: cleanGroupName(session.nickname, snapshot.roomId),
|
groupName: resolveGroupName(snapshot.roomId, this.groupNamesByRoomId, previous),
|
||||||
members: rawMembers
|
capturedAt,
|
||||||
.filter((member) => member?.wxid)
|
memberIds: normalizeMemberIds(snapshot.memberIds),
|
||||||
.map((member) => ({
|
membersValid: snapshot.status === 'ok'
|
||||||
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 {
|
if (!this.legacyFallbackLogged) {
|
||||||
this.snapshots = new Map(
|
this.legacyFallbackLogged = true
|
||||||
groups.filter((group) => group.membersValid !== false).map((group) => [group.roomId, group])
|
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 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<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(
|
private recordExit(
|
||||||
@@ -508,6 +650,7 @@ class GroupExitMonitorService {
|
|||||||
this.notificationTemplate = normalizeGroupExitNotificationTemplate(
|
this.notificationTemplate = normalizeGroupExitNotificationTemplate(
|
||||||
stored.notificationTemplate
|
stored.notificationTemplate
|
||||||
)
|
)
|
||||||
|
this.snapshots = normalizeSnapshots(stored.snapshots, this.monitoredRoomIds)
|
||||||
} catch {
|
} catch {
|
||||||
// 首次启动或文件损坏时从空记录开始。
|
// 首次启动或文件损坏时从空记录开始。
|
||||||
this.events = []
|
this.events = []
|
||||||
@@ -516,6 +659,7 @@ class GroupExitMonitorService {
|
|||||||
this.monitoredRoomIds.clear()
|
this.monitoredRoomIds.clear()
|
||||||
this.notificationRoomIds.clear()
|
this.notificationRoomIds.clear()
|
||||||
this.notificationTemplate = normalizeGroupExitNotificationTemplate(undefined)
|
this.notificationTemplate = normalizeGroupExitNotificationTemplate(undefined)
|
||||||
|
this.snapshots.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -532,12 +676,13 @@ class GroupExitMonitorService {
|
|||||||
monitorSelectionConfigured: this.monitorSelectionConfigured,
|
monitorSelectionConfigured: this.monitorSelectionConfigured,
|
||||||
monitoredRoomIds: Array.from(this.monitoredRoomIds),
|
monitoredRoomIds: Array.from(this.monitoredRoomIds),
|
||||||
notificationRoomIds: Array.from(this.notificationRoomIds),
|
notificationRoomIds: Array.from(this.notificationRoomIds),
|
||||||
notificationTemplate: this.notificationTemplate
|
notificationTemplate: this.notificationTemplate,
|
||||||
|
snapshots: Array.from(this.snapshots.values(), toStoredSnapshot)
|
||||||
},
|
},
|
||||||
{ spaces: 2 }
|
{ spaces: 2 }
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[GroupMonitor] 保存事件失败:', error)
|
console.warn('[GroupMonitor] 保存状态失败:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,6 +702,120 @@ function normalizeRoomIds(roomIds: string[]): Set<string> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
function cleanGroupName(value: string | undefined, roomId: string): string {
|
||||||
const name = String(value || '').trim()
|
const name = String(value || '').trim()
|
||||||
return name && name !== roomId && !name.startsWith('wxid_') ? name : roomId
|
return name && name !== roomId && !name.startsWith('wxid_') ? name : roomId
|
||||||
|
|||||||
@@ -59,6 +59,46 @@ export interface Wcdb4GroupMember {
|
|||||||
m_nsHeadImgUrl: string
|
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<string, Wcdb4GroupMemberIdsBatchItem>()
|
||||||
|
for (const rawItem of value) {
|
||||||
|
if (!rawItem || typeof rawItem !== 'object') return null
|
||||||
|
const item = rawItem as Record<string, unknown>
|
||||||
|
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:
|
private wcdbGetGroupMembers:
|
||||||
| ((handle: number, chatroomId: string, outJson: WcdbVoidOut) => number)
|
| ((handle: number, chatroomId: string, outJson: WcdbVoidOut) => number)
|
||||||
| null = null
|
| null = null
|
||||||
|
private wcdbGetGroupMembersBatch:
|
||||||
|
| ((handle: number, chatroomIdsJson: string, outJson: WcdbVoidOut) => number)
|
||||||
|
| null = null
|
||||||
private wcdbGetGroupNicknames:
|
private wcdbGetGroupNicknames:
|
||||||
| ((handle: number, chatroomId: string, outJson: WcdbVoidOut) => number)
|
| ((handle: number, chatroomId: string, outJson: WcdbVoidOut) => number)
|
||||||
| null = null
|
| null = null
|
||||||
@@ -1900,6 +1943,55 @@ export class Wcdb4Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退群检测热路径:只读取原始成员行并提取 wxid,不补群昵称、联系人资料或头像。
|
||||||
|
* null 表示读取失败,避免调用方把失败误判为成员列表为空。
|
||||||
|
*/
|
||||||
|
async getGroupMemberIdsAsync(chatroomId: string): Promise<string[] | null> {
|
||||||
|
if (!this.wcdbGetGroupMembers || !chatroomId) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
|
||||||
|
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<Wcdb4GroupMemberIdsBatchItem[] | null> {
|
||||||
|
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<unknown>(
|
||||||
|
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
|
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 {
|
try {
|
||||||
this.wcdbGetGroupNicknames = lib.func(
|
this.wcdbGetGroupNicknames = lib.func(
|
||||||
'int32 wcdb_get_group_nicknames(int64 handle, const char* chatroomId, _Out_ void** outJson)'
|
'int32 wcdb_get_group_nicknames(int64 handle, const char* chatroomId, _Out_ void** outJson)'
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ const mocks = vi.hoisted(() => ({
|
|||||||
getChatDb: vi.fn(),
|
getChatDb: vi.fn(),
|
||||||
getCurrentAccountRoot: vi.fn(() => 'fixture-account'),
|
getCurrentAccountRoot: vi.fn(() => 'fixture-account'),
|
||||||
isReady: vi.fn(() => true),
|
isReady: vi.fn(() => true),
|
||||||
|
getGroupNamesAsync: vi.fn(async () => ({})),
|
||||||
|
isGroupMemberIdsBatchAvailable: vi.fn(() => true),
|
||||||
|
getGroupMemberIdsBatchAsync: vi.fn(),
|
||||||
|
getGroupMemberIdsAsync: vi.fn(),
|
||||||
getGroupSnapshotAsync: vi.fn()
|
getGroupSnapshotAsync: vi.fn()
|
||||||
},
|
},
|
||||||
capability: {
|
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'
|
import { GROUP_EXIT_NOTIFICATION_TEMPLATE } from '../../src/shared/group-exit-monitor'
|
||||||
|
|
||||||
const member = { wxid: 'wxid_member', wechatNickname: '微信名', groupNickname: '群内名' }
|
const member = { wxid: 'wxid_member', wechatNickname: '微信名', groupNickname: '群内名' }
|
||||||
|
const otherMember = { wxid: 'wxid_other', wechatNickname: '另一个人' }
|
||||||
|
|
||||||
function installGroupDb(): void {
|
function installGroupDb(): void {
|
||||||
const client = {
|
const client = {
|
||||||
getSessionsAsync: vi
|
md5: vi.fn((roomId: string) => `${roomId}-md5`),
|
||||||
.fn()
|
invalidateGroupNicknameCache: vi.fn()
|
||||||
.mockResolvedValue([{ username: 'room@chatroom', nickname: '测试群' }]),
|
|
||||||
md5: vi.fn(() => 'room-md5')
|
|
||||||
}
|
}
|
||||||
mocks.chat.getChatDb.mockReturnValue({ getWcdb4Client: () => client })
|
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', () => {
|
describe('GroupExitMonitorService', () => {
|
||||||
const temporaryDirectories: string[] = []
|
const temporaryDirectories: string[] = []
|
||||||
|
|
||||||
@@ -55,7 +81,11 @@ describe('GroupExitMonitorService', () => {
|
|||||||
mocks.chat.getChatDb.mockReset()
|
mocks.chat.getChatDb.mockReset()
|
||||||
mocks.chat.getCurrentAccountRoot.mockReset().mockReturnValue('fixture-account')
|
mocks.chat.getCurrentAccountRoot.mockReset().mockReturnValue('fixture-account')
|
||||||
mocks.chat.isReady.mockReset().mockReturnValue(true)
|
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.capability.getPersonalWechatSendCapability.mockReset()
|
||||||
mocks.sender.send.mockReset()
|
mocks.sender.send.mockReset()
|
||||||
})
|
})
|
||||||
@@ -89,38 +119,418 @@ describe('GroupExitMonitorService', () => {
|
|||||||
expect(service.getState().notificationTemplate).toBe(GROUP_EXIT_NOTIFICATION_TEMPLATE)
|
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()
|
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({
|
mocks.chat.getGroupSnapshotAsync.mockResolvedValueOnce({
|
||||||
roomId: 'room@chatroom',
|
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()
|
const service = new GroupExitMonitorService()
|
||||||
service.setMonitoredRoomIds(['room@chatroom'])
|
service.setMonitoredRoomIds(['room@chatroom'])
|
||||||
await service.start(true)
|
await service.start(true)
|
||||||
|
|
||||||
mocks.chat.getGroupSnapshotAsync.mockResolvedValueOnce({
|
const statePath = join(mocks.userData, 'group-exit-monitor.json')
|
||||||
roomId: 'room@chatroom',
|
await vi.waitFor(() => {
|
||||||
members: null
|
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.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 () => {
|
it('returns from scope saving before the background baseline read completes', async () => {
|
||||||
installGroupDb()
|
installGroupDb()
|
||||||
mocks.chat.getGroupSnapshotAsync.mockResolvedValueOnce({
|
writeBaseline([{ roomId: 'room@chatroom', groupName: '测试群', members: [member] }])
|
||||||
roomId: 'room@chatroom',
|
mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValueOnce([
|
||||||
members: [member]
|
{ roomId: 'room@chatroom', status: 'ok', memberIds: [member.wxid] }
|
||||||
})
|
])
|
||||||
const service = new GroupExitMonitorService()
|
const service = new GroupExitMonitorService()
|
||||||
service.setMonitoredRoomIds(['room@chatroom'])
|
|
||||||
await service.start(true)
|
await service.start(true)
|
||||||
|
|
||||||
let release!: (snapshot: unknown) => void
|
let release!: (snapshot: unknown) => void
|
||||||
mocks.chat.getGroupSnapshotAsync.mockReturnValueOnce(
|
mocks.chat.getGroupMemberIdsBatchAsync.mockReturnValueOnce(
|
||||||
new Promise((resolve) => {
|
new Promise((resolve) => {
|
||||||
release = resolve
|
release = resolve
|
||||||
})
|
})
|
||||||
@@ -130,27 +540,148 @@ describe('GroupExitMonitorService', () => {
|
|||||||
|
|
||||||
expect(Date.now() - startedAt).toBeLessThan(250)
|
expect(Date.now() - startedAt).toBeLessThan(250)
|
||||||
expect(state.monitoredRoomIds).toEqual(['room@chatroom'])
|
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<void>((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 () => {
|
it('renders the saved template before sending a group notification', async () => {
|
||||||
installGroupDb()
|
installGroupDb()
|
||||||
|
writeBaseline(
|
||||||
|
[{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }],
|
||||||
|
['room@chatroom']
|
||||||
|
)
|
||||||
mocks.capability.getPersonalWechatSendCapability.mockResolvedValue({
|
mocks.capability.getPersonalWechatSendCapability.mockResolvedValue({
|
||||||
ready: true,
|
ready: true,
|
||||||
capabilities: { text: true }
|
capabilities: { text: true }
|
||||||
})
|
})
|
||||||
mocks.sender.send.mockResolvedValue({ success: true })
|
mocks.sender.send.mockResolvedValue({ success: true })
|
||||||
mocks.chat.getGroupSnapshotAsync
|
mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([
|
||||||
.mockResolvedValueOnce({
|
{ roomId: 'room@chatroom', status: 'ok', memberIds: [member.wxid] }
|
||||||
roomId: 'room@chatroom',
|
])
|
||||||
members: [member, { wxid: 'wxid_other', wechatNickname: '另一个人' }]
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({ roomId: 'room@chatroom', members: [member] })
|
|
||||||
const service = new GroupExitMonitorService()
|
const service = new GroupExitMonitorService()
|
||||||
service.setNotificationTemplate('退群: {user}/{groupRemark}/{wxid}')
|
service.setNotificationTemplate('退群: {user}/{groupRemark}/{wxid}')
|
||||||
service.setMonitoredRoomIds(['room@chatroom'], ['room@chatroom'])
|
|
||||||
await service.start(true)
|
await service.start(true)
|
||||||
await service.checkNow()
|
|
||||||
|
|
||||||
expect(mocks.sender.send).toHaveBeenCalledWith({
|
expect(mocks.sender.send).toHaveBeenCalledWith({
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@@ -162,16 +693,15 @@ describe('GroupExitMonitorService', () => {
|
|||||||
|
|
||||||
it('records an exit without creating a send action when notification is disabled', async () => {
|
it('records an exit without creating a send action when notification is disabled', async () => {
|
||||||
installGroupDb()
|
installGroupDb()
|
||||||
mocks.chat.getGroupSnapshotAsync
|
writeBaseline([
|
||||||
.mockResolvedValueOnce({
|
{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }
|
||||||
roomId: 'room@chatroom',
|
])
|
||||||
members: [member, { wxid: 'wxid_other', wechatNickname: '另一个人' }]
|
mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([
|
||||||
})
|
{ roomId: 'room@chatroom', status: 'ok', memberIds: [member.wxid] }
|
||||||
.mockResolvedValueOnce({ roomId: 'room@chatroom', members: [member] })
|
])
|
||||||
const service = new GroupExitMonitorService()
|
const service = new GroupExitMonitorService()
|
||||||
service.setMonitoredRoomIds(['room@chatroom'])
|
|
||||||
await service.start(true)
|
await service.start(true)
|
||||||
const state = await service.checkNow()
|
const state = service.getState()
|
||||||
|
|
||||||
expect(state.events[0]).toMatchObject({
|
expect(state.events[0]).toMatchObject({
|
||||||
memberWxid: 'wxid_other',
|
memberWxid: 'wxid_other',
|
||||||
@@ -183,6 +713,10 @@ describe('GroupExitMonitorService', () => {
|
|||||||
|
|
||||||
it('微信发送能力不可用时仍保留退群事件', async () => {
|
it('微信发送能力不可用时仍保留退群事件', async () => {
|
||||||
installGroupDb()
|
installGroupDb()
|
||||||
|
writeBaseline(
|
||||||
|
[{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }],
|
||||||
|
['room@chatroom']
|
||||||
|
)
|
||||||
mocks.capability.getPersonalWechatSendCapability.mockResolvedValue({
|
mocks.capability.getPersonalWechatSendCapability.mockResolvedValue({
|
||||||
ready: false,
|
ready: false,
|
||||||
supported: true,
|
supported: true,
|
||||||
@@ -191,16 +725,12 @@ describe('GroupExitMonitorService', () => {
|
|||||||
senderStatus: {},
|
senderStatus: {},
|
||||||
message: '当前微信发送能力不可用'
|
message: '当前微信发送能力不可用'
|
||||||
})
|
})
|
||||||
mocks.chat.getGroupSnapshotAsync
|
mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([
|
||||||
.mockResolvedValueOnce({
|
{ roomId: 'room@chatroom', status: 'ok', memberIds: [member.wxid] }
|
||||||
roomId: 'room@chatroom',
|
])
|
||||||
members: [member, { wxid: 'wxid_other', wechatNickname: '另一个人' }]
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({ roomId: 'room@chatroom', members: [member] })
|
|
||||||
const service = new GroupExitMonitorService()
|
const service = new GroupExitMonitorService()
|
||||||
service.setMonitoredRoomIds(['room@chatroom'], ['room@chatroom'])
|
|
||||||
await service.start(true)
|
await service.start(true)
|
||||||
const state = await service.checkNow()
|
const state = service.getState()
|
||||||
|
|
||||||
expect(state.events[0]).toMatchObject({
|
expect(state.events[0]).toMatchObject({
|
||||||
notificationStatus: 'failed',
|
notificationStatus: 'failed',
|
||||||
@@ -214,6 +744,10 @@ describe('GroupExitMonitorService', () => {
|
|||||||
|
|
||||||
it('does not send twice when the same snapshot diff is checked again', async () => {
|
it('does not send twice when the same snapshot diff is checked again', async () => {
|
||||||
installGroupDb()
|
installGroupDb()
|
||||||
|
writeBaseline(
|
||||||
|
[{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }],
|
||||||
|
['room@chatroom']
|
||||||
|
)
|
||||||
mocks.capability.getPersonalWechatSendCapability.mockResolvedValue({
|
mocks.capability.getPersonalWechatSendCapability.mockResolvedValue({
|
||||||
ready: true,
|
ready: true,
|
||||||
capabilities: { text: true, image: false, voice: false },
|
capabilities: { text: true, image: false, voice: false },
|
||||||
@@ -223,15 +757,10 @@ describe('GroupExitMonitorService', () => {
|
|||||||
message: 'ready'
|
message: 'ready'
|
||||||
})
|
})
|
||||||
mocks.sender.send.mockResolvedValue({ success: true })
|
mocks.sender.send.mockResolvedValue({ success: true })
|
||||||
mocks.chat.getGroupSnapshotAsync
|
mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([
|
||||||
.mockResolvedValueOnce({
|
{ roomId: 'room@chatroom', status: 'ok', memberIds: [member.wxid] }
|
||||||
roomId: 'room@chatroom',
|
])
|
||||||
members: [member, { wxid: 'wxid_other', wechatNickname: '另一个人' }]
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({ roomId: 'room@chatroom', members: [member] })
|
|
||||||
.mockResolvedValueOnce({ roomId: 'room@chatroom', members: [member] })
|
|
||||||
const service = new GroupExitMonitorService()
|
const service = new GroupExitMonitorService()
|
||||||
service.setMonitoredRoomIds(['room@chatroom'], ['room@chatroom'])
|
|
||||||
await service.start(true)
|
await service.start(true)
|
||||||
await service.checkNow()
|
await service.checkNow()
|
||||||
await service.checkNow()
|
await service.checkNow()
|
||||||
|
|||||||
@@ -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', () => {
|
it('falls back to the group nickname without leaking an ambiguous member nickname', () => {
|
||||||
const getGroupMembers = vi.fn(() => 0)
|
const getGroupMembers = vi.fn(() => 0)
|
||||||
const executeQuery = vi.fn(() => 0)
|
const executeQuery = vi.fn(() => 0)
|
||||||
@@ -121,12 +150,14 @@ describe('WCDB group member names', () => {
|
|||||||
wxid: ''
|
wxid: ''
|
||||||
}) as Wcdb4Client
|
}) as Wcdb4Client
|
||||||
|
|
||||||
const finalizeMessages = (client as unknown as {
|
const finalizeMessages = (
|
||||||
|
client as unknown as {
|
||||||
finalizeMessages: (
|
finalizeMessages: (
|
||||||
username: string,
|
username: string,
|
||||||
rows: Record<string, unknown>[]
|
rows: Record<string, unknown>[]
|
||||||
) => Array<{ senderNickname?: string }>
|
) => Array<{ senderNickname?: string }>
|
||||||
}).finalizeMessages
|
}
|
||||||
|
).finalizeMessages
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
finalizeMessages.call(client, 'fixture@chatroom', [
|
finalizeMessages.call(client, 'fixture@chatroom', [
|
||||||
@@ -142,3 +173,110 @@ describe('WCDB group member names', () => {
|
|||||||
expect(client.getGroupNicknames).toHaveBeenCalledWith('fixture@chatroom')
|
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<Promise<unknown>>(),
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user