feat: 增加退群监控功能

This commit is contained in:
Wxw-Gu
2026-08-31 21:06:53 +08:00
parent 6cbf9af662
commit e59332b514
22 changed files with 3321 additions and 30 deletions
@@ -0,0 +1,150 @@
import { mkdtempSync, readJsonSync, rmSync } from 'fs-extra'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
userData: '',
chat: {
getChatDb: vi.fn(),
getCurrentAccountRoot: vi.fn(() => 'fixture-account'),
isReady: vi.fn(() => true),
getGroupSnapshotAsync: vi.fn()
},
capability: {
getPersonalWechatSendCapability: vi.fn()
},
sender: {
send: vi.fn()
}
}))
vi.mock('electron', () => ({
app: { getPath: () => mocks.userData },
BrowserWindow: { getAllWindows: () => [] }
}))
vi.mock('../../src/main/services/chat-service', () => mocks.chat)
vi.mock('../../src/main/services/personal-wechat-capability-service', () => ({
personalWechatCapabilityService: mocks.capability
}))
vi.mock('../../src/main/services/personal-wechat-send-service', () => ({
personalWechatSendService: mocks.sender
}))
import { GroupExitMonitorService } from '../../src/main/services/group-exit-monitor-service'
const member = { wxid: 'wxid_member', wechatNickname: '微信名', groupNickname: '群内名' }
function installGroupDb(): void {
const client = {
getSessionsAsync: vi
.fn()
.mockResolvedValue([{ username: 'room@chatroom', nickname: '测试群' }]),
md5: vi.fn(() => 'room-md5')
}
mocks.chat.getChatDb.mockReturnValue({ getWcdb4Client: () => client })
}
describe('GroupExitMonitorService', () => {
const temporaryDirectories: string[] = []
beforeEach(() => {
mocks.userData = mkdtempSync(join(tmpdir(), 'tracememo-group-monitor-'))
temporaryDirectories.push(mocks.userData)
mocks.chat.getChatDb.mockReset()
mocks.chat.getCurrentAccountRoot.mockReset().mockReturnValue('fixture-account')
mocks.chat.isReady.mockReset().mockReturnValue(true)
mocks.chat.getGroupSnapshotAsync.mockReset()
mocks.capability.getPersonalWechatSendCapability.mockReset()
mocks.sender.send.mockReset()
})
afterEach(() => {
while (temporaryDirectories.length) {
rmSync(temporaryDirectories.pop()!, { recursive: true, force: true })
}
})
it('persists a validated custom template in the monitor state file', () => {
const service = new GroupExitMonitorService()
const template = '[退群监测]\n用户: {user}'
const state = service.setNotificationTemplate(template)
expect(state.notificationTemplate).toBe(template)
expect(readJsonSync(join(mocks.userData, 'group-exit-monitor.json')).notificationTemplate).toBe(
template
)
expect(() => service.setNotificationTemplate('用户: {unknown}')).toThrow('不支持的占位符')
})
it('keeps the previous baseline when a native snapshot contains null members', async () => {
installGroupDb()
mocks.chat.getGroupSnapshotAsync.mockResolvedValueOnce({
roomId: 'room@chatroom',
members: [member]
})
const service = new GroupExitMonitorService()
service.setMonitoredRoomIds(['room@chatroom'])
await service.start(true)
mocks.chat.getGroupSnapshotAsync.mockResolvedValueOnce({
roomId: 'room@chatroom',
members: null
})
const state = await service.checkNow()
expect(state.events).toEqual([])
expect(state.monitoredGroupCount).toBe(1)
})
it('returns from scope saving before the background baseline read completes', async () => {
installGroupDb()
mocks.chat.getGroupSnapshotAsync.mockResolvedValueOnce({
roomId: 'room@chatroom',
members: [member]
})
const service = new GroupExitMonitorService()
service.setMonitoredRoomIds(['room@chatroom'])
await service.start(true)
let release!: (snapshot: unknown) => void
mocks.chat.getGroupSnapshotAsync.mockReturnValueOnce(
new Promise((resolve) => {
release = resolve
})
)
const startedAt = Date.now()
const state = await service.setMonitoredRoomIds(['room@chatroom'])
expect(Date.now() - startedAt).toBeLessThan(250)
expect(state.monitoredRoomIds).toEqual(['room@chatroom'])
release({ roomId: 'room@chatroom', members: [member] })
})
it('renders the saved template before sending a group notification', async () => {
installGroupDb()
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] })
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',
to: 'room@chatroom',
isGroup: true,
text: '退群: 另一个人/未设置/wxid_other'
})
})
})
+104
View File
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest'
import {
findRemovedGroupMembers,
groupExitMemberName,
normalizeGroupExitNotificationTemplate,
renderGroupExitMonitorNotification,
validateGroupExitNotificationTemplate
} from '../../src/shared/group-exit-monitor'
describe('group exit monitor member diff', () => {
it('compares wxid sets and keeps the removed member metadata', () => {
const removed = findRemovedGroupMembers(
[
{ wxid: 'wxid_alice', groupNickname: '小艾' },
{ wxid: 'wxid_bob', nickname: '小博' },
{ wxid: 'wxid_bob', nickname: '重复行' }
],
[{ wxid: 'wxid_alice', groupNickname: '小艾' }]
)
expect(removed).toEqual([{ wxid: 'wxid_bob', nickname: '小博' }])
})
it('uses group nickname before contact fallbacks', () => {
expect(
groupExitMemberName({
wxid: 'wxid_fixture',
nickname: '微信昵称',
wechatNickname: '微信昵称字段',
remark: '备注名',
groupNickname: '群昵称'
})
).toBe('群昵称')
expect(groupExitMemberName({ wxid: 'wxid_fixture' })).toBe('wxid_fixture')
})
it('uses the WeChat nickname when a group nickname is unavailable', () => {
expect(
groupExitMemberName({
wxid: 'wxid_fixture',
wechatNickname: '微信名',
remark: '通讯录备注'
})
).toBe('微信名')
})
it('renders the notification template with a fallback for a missing group remark', () => {
const message = renderGroupExitMonitorNotification({
id: 'event-1',
contactId: 'group-md5',
roomId: '测试群@chatroom',
groupName: '测试群',
memberWxid: 'wxid_fixture',
memberName: '群昵称',
wechatName: '微信名',
groupRemark: '',
previousCount: 240,
currentCount: 239,
delta: -1,
message: '群昵称退出了测试群',
detectedAt: new Date(2026, 7, 31, 0, 19, 7).getTime()
})
expect(message).toContain('[退群监测]')
expect(message).toContain('用户: 微信名')
expect(message).toContain('群备注: 未设置')
expect(message).toContain('微信号: wxid_fixture')
expect(message).toContain('退群时间: 2026-08-31 00:19:07')
})
it('validates custom notification templates and rejects unknown placeholders', () => {
expect(validateGroupExitNotificationTemplate(' 用户: {user} ')).toEqual({
valid: true,
template: '用户: {user}'
})
expect(validateGroupExitNotificationTemplate('用户: {unknown}')).toEqual({
valid: false,
error: '不支持的占位符: unknown'
})
expect(normalizeGroupExitNotificationTemplate('')).toContain('[退群监测]')
})
it('renders a saved custom template with all supported fields', () => {
const message = renderGroupExitMonitorNotification(
{
id: 'event-2',
contactId: 'group-md5',
roomId: '测试群@chatroom',
groupName: '测试群',
memberWxid: 'wxid_fixture',
memberName: '群昵称',
wechatName: '微信名',
groupRemark: '群内昵称',
previousCount: 2,
currentCount: 1,
delta: -1,
message: '群昵称退出了测试群',
detectedAt: new Date(2026, 7, 31, 0, 19, 7).getTime()
},
'{user}|{groupRemark}|{wxid}|{time}'
)
expect(message).toBe('微信名|群内昵称|wxid_fixture|2026-08-31 00:19:07')
})
})