mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-04 19:43:43 +08:00
feat: 优化档案搜索与头像
新增退群监控开关 发送限速
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Contact } from '../../src/shared/types'
|
||||
import { selectContactAvatarRefreshUsernames } from '../../src/renderer/src/utils/contact-avatar'
|
||||
|
||||
const contact = (username: string, avatar?: string): Contact => ({
|
||||
m_nsUsrName: username,
|
||||
m_nsNickName: username,
|
||||
md5: username,
|
||||
type: username.endsWith('@chatroom') ? 'group' : 'user',
|
||||
avatar
|
||||
})
|
||||
|
||||
describe('selectContactAvatarRefreshUsernames', () => {
|
||||
it('refreshes valid contacts even when they already have a cached avatar', () => {
|
||||
expect(
|
||||
selectContactAvatarRefreshUsernames([
|
||||
contact('wxid_with_stale_avatar', 'https://old.example/avatar.jpg'),
|
||||
contact('wxid_without_avatar'),
|
||||
contact('room@chatroom', 'https://old.example/group.jpg')
|
||||
])
|
||||
).toEqual(['wxid_with_stale_avatar', 'wxid_without_avatar', 'room@chatroom'])
|
||||
})
|
||||
|
||||
it('deduplicates usernames and skips synthetic contacts', () => {
|
||||
expect(
|
||||
selectContactAvatarRefreshUsernames([
|
||||
contact('wxid_same'),
|
||||
contact('wxid_same'),
|
||||
contact('Group_fixture'),
|
||||
contact('Unknown_fixture')
|
||||
])
|
||||
).toEqual(['wxid_same'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Contact } from '../../src/shared/types'
|
||||
import { filterContacts } from '../../src/renderer/src/utils/contact-search'
|
||||
|
||||
const contact: Contact = {
|
||||
m_nsUsrName: 'wxid_XxYyZz',
|
||||
m_nsNickName: '小号',
|
||||
md5: 'fixture',
|
||||
type: 'user',
|
||||
remark: '工作手机',
|
||||
wechatNickname: 'Shinven',
|
||||
alias: 'abc123',
|
||||
wechatId: 'wechat_abc123'
|
||||
}
|
||||
|
||||
describe('filterContacts', () => {
|
||||
it.each(['Shinven', '小号', '工作', 'abc123', 'WECHAT_ABC', 'WXID_xxy']) (
|
||||
'matches all contact identity fields with %s',
|
||||
(query) => {
|
||||
expect(filterContacts([contact], ` ${query} `)).toEqual([contact])
|
||||
}
|
||||
)
|
||||
|
||||
it('returns the original list for a blank query and excludes unrelated contacts', () => {
|
||||
const list = [contact]
|
||||
expect(filterContacts(list, ' ')).toBe(list)
|
||||
expect(filterContacts(list, 'not-found')).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -119,6 +119,54 @@ describe('GroupExitMonitorService', () => {
|
||||
expect(service.getState().notificationTemplate).toBe(GROUP_EXIT_NOTIFICATION_TEMPLATE)
|
||||
})
|
||||
|
||||
it('persists OFF without checking contact changes or deleting configuration', async () => {
|
||||
vi.useFakeTimers()
|
||||
installGroupDb()
|
||||
writeBaseline(
|
||||
[{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }],
|
||||
['room@chatroom']
|
||||
)
|
||||
const service = new GroupExitMonitorService()
|
||||
await service.setEnabled(false)
|
||||
await service.start(true)
|
||||
service.notifyDatabaseChanged('{"table":"contact","action":"update"}')
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
expect(mocks.chat.getGroupMemberIdsBatchAsync).not.toHaveBeenCalled()
|
||||
expect(service.getState()).toMatchObject({ enabled: false, running: false })
|
||||
const stored = readJsonSync(join(mocks.userData, 'group-exit-monitor.json'))
|
||||
expect(stored).toMatchObject({
|
||||
enabled: false,
|
||||
monitoredRoomIds: ['room@chatroom'],
|
||||
notificationRoomIds: ['room@chatroom']
|
||||
})
|
||||
expect(stored.snapshots[0].members).toEqual([member, otherMember])
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('rebuilds the current baseline when re-enabled without reporting paused exits', async () => {
|
||||
installGroupDb()
|
||||
writeBaseline(
|
||||
[{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }],
|
||||
['room@chatroom']
|
||||
)
|
||||
const service = new GroupExitMonitorService()
|
||||
await service.setEnabled(false)
|
||||
await service.start(true)
|
||||
mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([
|
||||
{ roomId: 'room@chatroom', status: 'ok', memberIds: [member.wxid] }
|
||||
])
|
||||
|
||||
const nextState = await service.setEnabled(true)
|
||||
|
||||
expect(nextState).toMatchObject({ enabled: true, running: true, events: [] })
|
||||
expect(mocks.chat.getGroupMemberIdsBatchAsync).toHaveBeenCalledOnce()
|
||||
expect(mocks.sender.send).not.toHaveBeenCalled()
|
||||
const stored = readJsonSync(join(mocks.userData, 'group-exit-monitor.json'))
|
||||
expect(stored.enabled).toBe(true)
|
||||
expect(stored.snapshots[0].members).toEqual([{ wxid: member.wxid }])
|
||||
})
|
||||
|
||||
it('uses the Session nickname when establishing a new group baseline', async () => {
|
||||
installGroupDb()
|
||||
const callOrder: string[] = []
|
||||
|
||||
@@ -6,6 +6,21 @@ function setPrivate(target: object, key: string, value: unknown): void {
|
||||
}
|
||||
|
||||
describe('Wcdb4Client shutdown', () => {
|
||||
it('invalidates only requested avatar cache entries', () => {
|
||||
const client = Object.create(Wcdb4Client.prototype) as Wcdb4Client
|
||||
const avatarCache = new Map([
|
||||
['wxid_changed', 'https://old.example/avatar.jpg'],
|
||||
['wxid_stable', 'data:image/png;base64,stable']
|
||||
])
|
||||
setPrivate(client, 'avatarCache', avatarCache)
|
||||
|
||||
client.invalidateAvatarCache(['wxid_changed'])
|
||||
|
||||
expect(avatarCache).toEqual(
|
||||
new Map([['wxid_stable', 'data:image/png;base64,stable']])
|
||||
)
|
||||
})
|
||||
|
||||
it('clears folded contact status snapshots when sessions are invalidated', () => {
|
||||
const client = Object.create(Wcdb4Client.prototype) as Wcdb4Client
|
||||
const statusCache = new Map([['group@chatroom', { isFolded: true, isMuted: false }]])
|
||||
|
||||
@@ -16,7 +16,10 @@ vi.mock('../../src/main/services/personal-wechat-send-service', () => ({
|
||||
personalWechatSendService: mocks.sender
|
||||
}))
|
||||
|
||||
import { WechatActionGateway } from '../../src/main/services/wechat-action-gateway'
|
||||
import {
|
||||
AUTOMATION_SEND_INTERVAL_MS,
|
||||
WechatActionGateway
|
||||
} from '../../src/main/services/wechat-action-gateway'
|
||||
import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat'
|
||||
import type { WechatActionResult } from '../../src/shared/wechat-action'
|
||||
|
||||
@@ -231,4 +234,145 @@ describe('WechatActionGateway', () => {
|
||||
reason: 'connector timeout'
|
||||
})
|
||||
})
|
||||
|
||||
it('sends accepted automation actions in FIFO order at least three seconds apart', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-09-03T08:00:00.000Z'))
|
||||
const gateway = createGateway()
|
||||
const sentAt: Array<{ to: string; at: number }> = []
|
||||
mocks.sender.send.mockImplementation(async (request: { to: string }) => {
|
||||
sentAt.push({ to: request.to, at: Date.now() })
|
||||
return { success: true, status: {} }
|
||||
})
|
||||
const action = (id: string) =>
|
||||
gateway.execute({
|
||||
idempotencyKey: `scheduled:${id}`,
|
||||
origin: 'scheduled_report',
|
||||
purpose: 'scheduled_report',
|
||||
triggerType: 'automation',
|
||||
executionId: id,
|
||||
recipient: { type: 'group', id: `${id}@chatroom` },
|
||||
content: { type: 'image', path: `/tmp/${id}.png` }
|
||||
})
|
||||
|
||||
const pending = [action('A'), action('B'), action('C')]
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(sentAt).toEqual([{ to: 'A@chatroom', at: Date.now() }])
|
||||
|
||||
await vi.advanceTimersByTimeAsync(AUTOMATION_SEND_INTERVAL_MS - 1)
|
||||
expect(sentAt).toHaveLength(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(sentAt.map((item) => item.to)).toEqual(['A@chatroom', 'B@chatroom'])
|
||||
await vi.advanceTimersByTimeAsync(AUTOMATION_SEND_INTERVAL_MS)
|
||||
await Promise.all(pending)
|
||||
|
||||
expect(sentAt.map((item) => item.to)).toEqual([
|
||||
'A@chatroom',
|
||||
'B@chatroom',
|
||||
'C@chatroom'
|
||||
])
|
||||
expect(sentAt[1].at - sentAt[0].at).toBe(AUTOMATION_SEND_INTERVAL_MS)
|
||||
expect(sentAt[2].at - sentAt[1].at).toBe(AUTOMATION_SEND_INTERVAL_MS)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('keeps the safety interval after a failed automatic send', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-09-03T09:00:00.000Z'))
|
||||
const gateway = createGateway()
|
||||
const sentAt: number[] = []
|
||||
mocks.sender.send.mockImplementation(async () => {
|
||||
sentAt.push(Date.now())
|
||||
if (sentAt.length === 1) throw new Error('first failed')
|
||||
return { success: true, status: {} }
|
||||
})
|
||||
const first = memberAction(gateway)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
gateway.registerMemberEvent({ id: 'event-2', roomId: 'room@chatroom' })
|
||||
const second = gateway.execute({
|
||||
origin: 'member_monitor',
|
||||
purpose: 'member_left_notification',
|
||||
triggerType: 'automation',
|
||||
sourceId: 'event-2',
|
||||
recipient: { type: 'group', id: 'room@chatroom' },
|
||||
content: { type: 'text', text: '李四已退出群聊' }
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(AUTOMATION_SEND_INTERVAL_MS)
|
||||
const results = await Promise.all([first, second])
|
||||
|
||||
expect(results.map((result) => result.status)).toEqual(['failed', 'sent'])
|
||||
expect(sentAt[1] - sentAt[0]).toBe(AUTOMATION_SEND_INTERVAL_MS)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('lets a user action send immediately while an automatic action is waiting', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-09-03T09:30:00.000Z'))
|
||||
const gateway = createGateway()
|
||||
const sent: Array<{ to: string; at: number }> = []
|
||||
mocks.sender.send.mockImplementation(async (request: { to: string }) => {
|
||||
sent.push({ to: request.to, at: Date.now() })
|
||||
return { success: true, status: {} }
|
||||
})
|
||||
const automaticAction = (id: string) =>
|
||||
gateway.execute({
|
||||
idempotencyKey: `scheduled:${id}`,
|
||||
origin: 'scheduled_report',
|
||||
purpose: 'scheduled_report',
|
||||
triggerType: 'automation',
|
||||
executionId: id,
|
||||
recipient: { type: 'group', id: `${id}@chatroom` },
|
||||
content: { type: 'image', path: `/tmp/${id}.png` }
|
||||
})
|
||||
|
||||
const first = automaticAction('first')
|
||||
const waiting = automaticAction('waiting')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
const userResult = await gateway.execute({
|
||||
origin: 'user_tts',
|
||||
purpose: 'tts_voice',
|
||||
triggerType: 'user',
|
||||
recipient: { type: 'contact', id: 'wxid_user' },
|
||||
content: { type: 'text', text: '用户主动发送' }
|
||||
})
|
||||
|
||||
expect(userResult.status).toBe('sent')
|
||||
expect(sent).toEqual([
|
||||
{ to: 'first@chatroom', at: Date.now() },
|
||||
{ to: 'wxid_user', at: Date.now() }
|
||||
])
|
||||
|
||||
await vi.advanceTimersByTimeAsync(AUTOMATION_SEND_INTERVAL_MS)
|
||||
await Promise.all([first, waiting])
|
||||
expect(sent[2]).toEqual({ to: 'waiting@chatroom', at: Date.now() })
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('does not put capability failures into the automatic send queue', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-09-03T10:00:00.000Z'))
|
||||
const gateway = createGateway()
|
||||
mocks.capability.getPersonalWechatSendCapability
|
||||
.mockResolvedValueOnce({ ...readyCapability, ready: false })
|
||||
.mockResolvedValueOnce(readyCapability)
|
||||
|
||||
await expect(memberAction(gateway)).resolves.toMatchObject({
|
||||
status: 'failed',
|
||||
errorCode: 'SEND_CAPABILITY_UNAVAILABLE'
|
||||
})
|
||||
const startedAt = Date.now()
|
||||
await gateway.execute({
|
||||
origin: 'scheduled_report',
|
||||
purpose: 'scheduled_report',
|
||||
triggerType: 'automation',
|
||||
executionId: 'execution-ready',
|
||||
recipient: { type: 'group', id: 'room@chatroom' },
|
||||
content: { type: 'image', path: '/tmp/report.png' }
|
||||
})
|
||||
|
||||
expect(mocks.sender.send).toHaveBeenCalledOnce()
|
||||
expect(Date.now()).toBe(startedAt)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../src/main/services/chat-service', () => ({ listContacts: vi.fn(() => []) }))
|
||||
vi.mock('../../src/main/services/wechat-action-gateway', () => ({
|
||||
wechatActionGateway: { listAuditRecords: vi.fn(() => []) }
|
||||
}))
|
||||
|
||||
import { WechatActionLogService } from '../../src/main/services/wechat-action-log-service'
|
||||
import type { WechatActionAuditRecord } from '../../src/shared/wechat-action'
|
||||
|
||||
const records: WechatActionAuditRecord[] = [
|
||||
{
|
||||
actionId: 'older',
|
||||
origin: 'member_monitor',
|
||||
purpose: 'member_left_notification',
|
||||
triggerType: 'automation',
|
||||
recipientType: 'group',
|
||||
recipientId: 'room@chatroom',
|
||||
contentType: 'text',
|
||||
contentPreview: 'Shinven 已退出群聊',
|
||||
createdAt: '2026-09-03T07:00:00.000Z',
|
||||
startedAt: '2026-09-03T07:00:00.000Z',
|
||||
finishedAt: '2026-09-03T07:00:01.000Z',
|
||||
decision: 'allow',
|
||||
sendStatus: 'sent'
|
||||
},
|
||||
{
|
||||
actionId: 'newer',
|
||||
origin: 'scheduled_report',
|
||||
purpose: 'scheduled_report',
|
||||
triggerType: 'automation',
|
||||
recipientType: 'group',
|
||||
recipientId: 'other@chatroom',
|
||||
recipientName: '审计内名称',
|
||||
contentType: 'image',
|
||||
contentPreview: 'report.png',
|
||||
createdAt: '2026-09-03T08:00:00.000Z',
|
||||
startedAt: '2026-09-03T08:00:00.000Z',
|
||||
finishedAt: '2026-09-03T08:00:02.000Z',
|
||||
decision: 'allow',
|
||||
decisionReason: '发送失败',
|
||||
sendStatus: 'failed',
|
||||
errorCode: 'SEND_FAILED'
|
||||
}
|
||||
]
|
||||
|
||||
describe('WechatActionLogService', () => {
|
||||
it('reuses audit records, resolves current contact names, and returns newest first', () => {
|
||||
const service = new WechatActionLogService({
|
||||
listAuditRecords: () => records,
|
||||
listContacts: () => [
|
||||
{
|
||||
m_nsUsrName: 'room@chatroom',
|
||||
m_nsNickName: '测试群',
|
||||
md5: 'room',
|
||||
type: 'group'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
expect(service.list()).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'newer',
|
||||
category: 'wechat_send',
|
||||
source: 'scheduled_report',
|
||||
recipientName: '审计内名称',
|
||||
status: 'failed'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'older',
|
||||
recipientName: '测试群',
|
||||
contentPreview: 'Shinven 已退出群聊',
|
||||
status: 'sent'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('still lists audit history when contacts are unavailable', () => {
|
||||
const service = new WechatActionLogService({
|
||||
listAuditRecords: () => records.slice(0, 1),
|
||||
listContacts: () => {
|
||||
throw new Error('database unavailable')
|
||||
}
|
||||
})
|
||||
|
||||
expect(service.list()[0]).toMatchObject({ id: 'older', recipientId: 'room@chatroom' })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user