mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-05 04:07:40 +08:00
feat: 增加退群监控功能
This commit is contained in:
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user