feat: 优化档案搜索与头像

新增退群监控开关
发送限速
This commit is contained in:
Wxw-Gu
2026-09-03 18:12:29 +08:00
parent 3462d49516
commit 719ffa0a24
33 changed files with 1025 additions and 64 deletions
Binary file not shown.
+13 -5
View File
@@ -112,6 +112,7 @@ import {
import { installSafeConsole } from './safe-log'
import { agentHubService } from './services/agent-hub-service'
import { groupExitMonitorService } from './services/group-exit-monitor-service'
import { wechatActionLogService } from './services/wechat-action-log-service'
import { personalWechatSendService } from './services/personal-wechat-send-service'
import { getPersonalWechatSendCapability } from './services/personal-wechat-capability-service'
import { scheduledReportService } from './services/scheduled-report-service'
@@ -1079,11 +1080,14 @@ app.whenReady().then(async () => {
return contacts
})
ipcMain.handle('db:getContactAvatars', async (_, usernames: string[]) => {
const avatars = await chat.getContactAvatars(usernames)
if (chat.isReady()) mergeBootstrapAvatars(chat.getCurrentAccountRoot(), avatars)
return avatars
})
ipcMain.handle(
'db:getContactAvatars',
async (_, usernames: string[], options?: { refresh?: boolean }) => {
const avatars = await chat.getContactAvatars(usernames, options)
if (chat.isReady()) mergeBootstrapAvatars(chat.getCurrentAccountRoot(), avatars)
return avatars
}
)
ipcMain.handle(
'db:getMessages',
@@ -1130,6 +1134,9 @@ app.whenReady().then(async () => {
})
ipcMain.handle('group-exit-monitor:getState', () => groupExitMonitorService.getState())
ipcMain.handle('group-exit-monitor:setEnabled', (_, enabled: boolean) =>
groupExitMonitorService.setEnabled(enabled === true)
)
ipcMain.handle(
'group-exit-monitor:setGroups',
(_, roomIds: string[], notificationRoomIds?: string[]) =>
@@ -1146,6 +1153,7 @@ app.whenReady().then(async () => {
ipcMain.handle('group-exit-monitor:markRead', (_, readAt?: number) =>
groupExitMonitorService.markRead(readAt)
)
ipcMain.handle('wechat-action-log:list', () => wechatActionLogService.list())
ipcMain.handle('db:search', (_, keyword: string) => chat.searchMessages(keyword))
ipcMain.handle(
+11 -2
View File
@@ -44,6 +44,8 @@ export interface FormattedContact {
avatar?: string
wechatNickname?: string
remark?: string
alias?: string
wechatId?: string
isFolded?: boolean
isMuted?: boolean
}
@@ -202,6 +204,8 @@ export function listContacts(filter?: string): FormattedContact[] {
avatar: typeof user.avatar === 'string' ? user.avatar : undefined,
wechatNickname: user.wechatNickname,
remark: user.remark,
alias: user.alias,
wechatId: user.wechatId,
isFolded: user.isFolded,
isMuted: user.isMuted
})
@@ -259,13 +263,18 @@ export async function getGroupNamesAsync(): Promise<Record<string, string>> {
return names
}
export async function getContactAvatars(usernames: string[]): Promise<Record<string, string>> {
export async function getContactAvatars(
usernames: string[],
options?: { refresh?: boolean }
): Promise<Record<string, string>> {
if (!dbRef) return {}
const normalized = Array.from(
new Set((usernames || []).map((username) => String(username || '').trim()).filter(Boolean))
)
if (normalized.length === 0) return {}
return dbRef.getWcdb4Client().getAvatarUrlsAsync(normalized)
const client = dbRef.getWcdb4Client()
if (options?.refresh) client.invalidateAvatarCache(normalized)
return client.getAvatarUrlsAsync(normalized)
}
function listSourceMessages(
@@ -17,6 +17,7 @@ import {
} from '../../shared/group-exit-monitor'
type StoredState = {
enabled?: boolean
accountRoot?: string
events?: Partial<GroupExitMonitorEvent>[]
lastReadAt?: number
@@ -70,6 +71,7 @@ type GroupExitActionGateway = Pick<WechatActionGateway, 'execute'> &
class GroupExitMonitorService {
private readonly actionGateway: GroupExitActionGateway
private active = false
private enabled = true
private nativeMonitorActive = false
private snapshots = new Map<string, GroupSnapshotRecord>()
private changeTimer: NodeJS.Timeout | null = null
@@ -103,7 +105,8 @@ class GroupExitMonitorService {
this.ensureLoaded()
return {
events: [...this.events],
running: this.active && chat.isReady(),
enabled: this.enabled,
running: this.enabled && this.active && chat.isReady(),
nativeMonitorActive: this.nativeMonitorActive,
monitoredGroupCount: this.snapshots.size,
monitorSelectionConfigured: this.monitorSelectionConfigured,
@@ -143,6 +146,11 @@ class GroupExitMonitorService {
this.active = true
this.nativeMonitorActive = nativeMonitorActive
this.checkQueued = false
if (!this.enabled) {
this.save()
this.broadcast()
return
}
this.initializing = true
const scopeGeneration = ++this.scopeGeneration
this.eventSequence += 1
@@ -178,7 +186,7 @@ class GroupExitMonitorService {
}
notifyDatabaseChanged(rawPayload: string): void {
if (!this.active || !isContactEvent(rawPayload)) return
if (!this.enabled || !this.active || !isContactEvent(rawPayload)) return
try {
chat.getChatDb()?.getWcdb4Client().invalidateGroupNicknameCache()
} catch {
@@ -193,6 +201,8 @@ class GroupExitMonitorService {
}
async checkNow(): Promise<GroupExitMonitorState> {
this.ensureLoaded()
if (!this.enabled) return this.getState()
if (!this.active && chat.isReady()) await this.start(false)
await this.check()
return this.getState()
@@ -223,7 +233,31 @@ class GroupExitMonitorService {
this.save()
this.broadcast()
// 建立基线放到后台,保存配置可以立即返回。
if (this.active) void this.check()
if (this.enabled && this.active) void this.check()
return this.getState()
}
async setEnabled(enabled: boolean): Promise<GroupExitMonitorState> {
this.ensureLoaded()
if (this.enabled === enabled) return this.getState()
this.enabled = enabled
this.eventSequence += 1
this.scopeGeneration += 1
this.checkQueued = false
this.hydrationQueue.clear()
if (this.changeTimer) clearTimeout(this.changeTimer)
this.changeTimer = null
if (enabled) {
// 用户主动暂停期间的成员变化不补报;重新开启后从当前状态建立新基线。
this.snapshots.clear()
this.lastCheckedAt = undefined
this.groupNamesRefreshPending = true
}
this.save()
this.broadcast()
if (enabled && this.active && chat.isReady()) await this.check()
return this.getState()
}
@@ -261,6 +295,7 @@ class GroupExitMonitorService {
const currentRoot = chat.getCurrentAccountRoot()
if (
!this.active ||
!this.enabled ||
!chat.isReady() ||
!currentRoot ||
!this.accountRoot ||
@@ -296,7 +331,7 @@ class GroupExitMonitorService {
const result = await this.readMemberships()
const membershipCostMs = Date.now() - startedAt
let changedGroups = 0
if (result.groups && this.active && scopeGeneration === this.scopeGeneration) {
if (result.groups && this.enabled && this.active && scopeGeneration === this.scopeGeneration) {
changedGroups = await this.applyCurrentMemberships(result.groups, scopeGeneration)
}
console.log(
@@ -367,7 +402,9 @@ class GroupExitMonitorService {
const notifications: Array<{ group: GroupSnapshotRecord; event: GroupExitMonitorEvent }> = []
let changedGroups = 0
for (const membership of groups) {
if (!this.active || scopeGeneration !== this.scopeGeneration) return changedGroups
if (!this.enabled || !this.active || scopeGeneration !== this.scopeGeneration) {
return changedGroups
}
if (membership.membersValid === false) continue
const previous = this.snapshots.get(membership.roomId)
// 空数组可能是查询失败,先保留旧基线,避免误报和覆盖最后有效快照。
@@ -407,7 +444,9 @@ class GroupExitMonitorService {
}
}
if (!this.active || scopeGeneration !== this.scopeGeneration) return changedGroups
if (!this.enabled || !this.active || scopeGeneration !== this.scopeGeneration) {
return changedGroups
}
this.lastCheckedAt = Date.now()
// 先把事件和新基线作为同一检查点落盘,再执行可失败的通知动作。
this.save()
@@ -419,7 +458,7 @@ class GroupExitMonitorService {
}
private startNextSnapshotHydration(): void {
if (!this.active) {
if (!this.enabled || !this.active) {
this.finishHydrationBatch()
return
}
@@ -635,6 +674,7 @@ class GroupExitMonitorService {
this.loaded = true
try {
const stored = fs.readJsonSync(this.filePath()) as StoredState
this.enabled = stored.enabled !== false
this.events = normalizeEvents(stored.events)
this.actionGateway.registerMemberEvents?.(this.events)
this.lastReadAt = Number(stored.lastReadAt) || 0
@@ -654,6 +694,7 @@ class GroupExitMonitorService {
} catch {
// 首次启动或文件损坏时从空记录开始。
this.events = []
this.enabled = true
this.lastReadAt = 0
this.monitorSelectionConfigured = true
this.monitoredRoomIds.clear()
@@ -671,6 +712,7 @@ class GroupExitMonitorService {
this.filePath(),
{
accountRoot: this.accountRoot,
enabled: this.enabled,
events: this.events,
lastReadAt: this.lastReadAt,
monitorSelectionConfigured: this.monitorSelectionConfigured,
+44 -5
View File
@@ -21,6 +21,7 @@ import { personalWechatSendService } from './personal-wechat-send-service'
const MAX_AUDIT_RECORDS = 500
const MAX_CONTENT_PREVIEW_LENGTH = 240
export const AUTOMATION_SEND_INTERVAL_MS = 3_000
const AUTOMATION_PURPOSE_ALLOWLIST = new Set(['scheduled_report', 'member_left_notification'])
export interface WechatActionGatewayDependencies {
@@ -34,6 +35,7 @@ export interface WechatActionGatewayDependencies {
| undefined
getUserDataPath?: () => string
now?: () => Date
wait?: (milliseconds: number) => Promise<void>
}
interface LoadedAuditState {
@@ -46,12 +48,16 @@ export interface WechatActionPolicyContext {
}
const defaultDependencies = (): Required<
Pick<WechatActionGatewayDependencies, 'getCapability' | 'send' | 'getUserDataPath' | 'now'>
Pick<
WechatActionGatewayDependencies,
'getCapability' | 'send' | 'getUserDataPath' | 'now' | 'wait'
>
> => ({
getCapability: () => personalWechatCapabilityService.getPersonalWechatSendCapability(),
send: (request) => personalWechatSendService.send(request),
getUserDataPath: () => app.getPath('userData'),
now: () => new Date()
now: () => new Date(),
wait: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
})
/**
@@ -60,12 +66,20 @@ const defaultDependencies = (): Required<
*/
export class WechatActionGateway {
private readonly deps: Required<
Pick<WechatActionGatewayDependencies, 'getCapability' | 'send' | 'getUserDataPath' | 'now'>
Pick<
WechatActionGatewayDependencies,
'getCapability' | 'send' | 'getUserDataPath' | 'now' | 'wait'
>
> &
Omit<WechatActionGatewayDependencies, 'getCapability' | 'send' | 'getUserDataPath' | 'now'>
Omit<
WechatActionGatewayDependencies,
'getCapability' | 'send' | 'getUserDataPath' | 'now' | 'wait'
>
private readonly memberEvents = new Map<string, WechatActionMemberEventReference>()
private readonly inFlight = new Map<string, Promise<WechatActionResult>>()
private auditState: LoadedAuditState | null = null
private automationSendTail: Promise<void> = Promise.resolve()
private lastAutomationSendStartedAt: number | undefined
constructor(deps: WechatActionGatewayDependencies = {}) {
this.deps = { ...defaultDependencies(), ...deps }
@@ -87,6 +101,10 @@ export class WechatActionGateway {
this.memberEvents.clear()
}
listAuditRecords(): WechatActionAuditRecord[] {
return this.loadAuditState().records.map((record) => ({ ...record }))
}
async execute(request: WechatActionRequest): Promise<WechatActionResult> {
const normalized = normalizeActionRequest(request)
const idempotencyKey = normalized ? this.idempotencyKey(normalized) : undefined
@@ -176,7 +194,7 @@ export class WechatActionGateway {
let sendResult: PersonalWechatSendResult
try {
sendResult = await this.deps.send(toPersonalWechatSendRequest(request))
sendResult = await this.send(request)
} catch (error) {
return this.finishFailed(
actionId,
@@ -201,6 +219,26 @@ export class WechatActionGateway {
return this.finishSent(actionId, request, idempotencyKey, startedAt, sendResult)
}
private send(request: WechatActionRequest): Promise<PersonalWechatSendResult> {
const sendRequest = toPersonalWechatSendRequest(request)
if (request.triggerType !== 'automation') return this.deps.send(sendRequest)
const queued = this.automationSendTail.then(async () => {
const now = this.deps.now().getTime()
const remaining = this.lastAutomationSendStartedAt !== undefined
? Math.max(0, AUTOMATION_SEND_INTERVAL_MS - (now - this.lastAutomationSendStartedAt))
: 0
if (remaining > 0) await this.deps.wait(remaining)
this.lastAutomationSendStartedAt = this.deps.now().getTime()
return this.deps.send(sendRequest)
})
this.automationSendTail = queued.then(
() => undefined,
() => undefined
)
return queued
}
private async resolveEventContext(
request: WechatActionRequest
): Promise<WechatActionPolicyContext> {
@@ -364,6 +402,7 @@ export class WechatActionGateway {
...(request.executionId ? { executionId: request.executionId } : {}),
recipientType: request.recipient.type,
recipientId: request.recipient.id,
...(request.recipient.name ? { recipientName: request.recipient.name } : {}),
contentType: request.content.type,
...contentAudit(request.content),
createdAt: result.startedAt,
@@ -0,0 +1,55 @@
import type { ActionLogEntry } from '../../shared/action-log'
import type { Contact } from '../../shared/types'
import type { WechatActionAuditRecord } from '../../shared/wechat-action'
import * as chat from './chat-service'
import { wechatActionGateway } from './wechat-action-gateway'
export interface WechatActionLogServiceDependencies {
listAuditRecords?: () => WechatActionAuditRecord[]
listContacts?: () => Contact[]
}
export class WechatActionLogService {
private readonly listAuditRecords: () => WechatActionAuditRecord[]
private readonly listContacts: () => Contact[]
constructor(deps: WechatActionLogServiceDependencies = {}) {
this.listAuditRecords = deps.listAuditRecords || (() => wechatActionGateway.listAuditRecords())
this.listContacts = deps.listContacts || (() => chat.listContacts())
}
list(): ActionLogEntry[] {
let contacts: Contact[] = []
try {
contacts = this.listContacts()
} catch {
// 数据库未连接时仍可查看历史审计。
}
const names = new Map(
contacts.map((contact) => [
contact.m_nsUsrName,
contact.m_nsNickName?.trim() || contact.remark?.trim() || contact.m_nsUsrName
])
)
return this.listAuditRecords()
.map((record) => ({
id: record.actionId,
category: 'wechat_send' as const,
source: record.origin,
purpose: record.purpose,
timestamp: record.finishedAt || record.startedAt || record.createdAt,
recipientType: record.recipientType,
recipientId: record.recipientId,
recipientName: record.recipientName || names.get(record.recipientId),
contentType: record.contentType,
contentPreview: record.contentPreview,
status: record.sendStatus,
errorCode: record.errorCode,
reason: record.decisionReason
}))
.sort((left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp))
}
}
export const wechatActionLogService = new WechatActionLogService()
+20
View File
@@ -13,6 +13,8 @@ export interface Wcdb4Session {
avatar?: string
wechatNickname?: string
remark?: string
alias?: string
wechatId?: string
isFolded?: boolean
isMuted?: boolean
raw: Record<string, unknown>
@@ -1030,6 +1032,14 @@ export class Wcdb4Client {
this.sessionDisplayNamesHydrated = false
}
invalidateAvatarCache(usernames?: string[]): void {
if (!usernames) {
this.avatarCache.clear()
return
}
for (const username of this.uniq(usernames)) this.avatarCache.delete(username)
}
/** 清理群备注缓存,不影响会话缓存。 */
invalidateGroupNicknameCache(): void {
this.groupNicknameCache.clear()
@@ -2813,12 +2823,22 @@ export class Wcdb4Client {
'contactRemark',
'contact_remark'
])
const alias = this.pickString(row, ['alias', 'aliasName', 'alias_name', 'm_nsAlias'])
const wechatId = this.pickString(row, [
'wechatId',
'wechat_id',
'wxAccount',
'wx_account',
'account'
])
const status = this.sessionStatusCache.get(username)
return {
username,
nickname,
wechatNickname,
remark,
alias,
wechatId: wechatId || alias,
isFolded: status?.isFolded,
isMuted: status?.isMuted,
raw: row
+12 -4
View File
@@ -7,6 +7,8 @@ export interface UserContact {
avatar?: string
wechatNickname?: string
remark?: string
alias?: string
wechatId?: string
isFolded?: boolean
isMuted?: boolean
}
@@ -102,15 +104,21 @@ export class WechatDb {
avatar: session.avatar,
wechatNickname: session.wechatNickname,
remark: session.remark,
alias: session.alias,
wechatId: session.wechatId,
isFolded: session.isFolded,
isMuted: session.isMuted
}))
.filter((contact) => {
if (!keyword) return true
return (
contact.m_nsUsrName.toLowerCase().includes(keyword) ||
contact.nickname.toLowerCase().includes(keyword)
)
return [
contact.remark,
contact.wechatNickname,
contact.nickname,
contact.alias,
contact.wechatId,
contact.m_nsUsrName
].some((value) => value?.toLocaleLowerCase().includes(keyword))
})
}
+7 -1
View File
@@ -82,6 +82,7 @@ import type {
} from '../shared/personal-wechat-voice-runtime'
import type { AppLogEntry } from '../shared/app-log'
import type { GroupExitMonitorState } from '../shared/group-exit-monitor'
import type { ActionLogEntry } from '../shared/action-log'
import type {
AppUpdateCheckResult,
AppUpdateInstallResult,
@@ -213,7 +214,10 @@ declare global {
updatedAt: number
} | null>
getContacts: (filter?: string) => Promise<Contact[]>
getContactAvatars: (usernames: string[]) => Promise<Record<string, string>>
getContactAvatars: (
usernames: string[],
options?: { refresh?: boolean }
) => Promise<Record<string, string>>
getCachedMessages: (
userMd5: string,
startTime?: number,
@@ -258,6 +262,7 @@ declare global {
}[]
} | null>
getGroupExitMonitorState: () => Promise<GroupExitMonitorState>
setGroupExitMonitorEnabled: (enabled: boolean) => Promise<GroupExitMonitorState>
setGroupExitMonitorGroups: (
roomIds: string[],
notificationRoomIds?: string[]
@@ -266,6 +271,7 @@ declare global {
checkGroupExitMonitorNow: () => Promise<GroupExitMonitorState>
clearGroupExitMonitorEvents: () => Promise<GroupExitMonitorState>
markGroupExitMonitorRead: (readAt?: number) => Promise<GroupExitMonitorState>
listWechatActionLogs: () => Promise<ActionLogEntry[]>
onGroupExitMonitorState: (callback: (state: GroupExitMonitorState) => void) => () => void
search: (keyword: string) => Promise<string | null>
searchKnowledge: (request: KnowledgeSearchIpcRequest) => Promise<KnowledgeSearchIpcResult>
+7 -1
View File
@@ -55,6 +55,7 @@ import type {
import type { AppLogEntry } from '../shared/app-log'
import type { AppUpdateState } from '../shared/app-update'
import type { GroupExitMonitorState } from '../shared/group-exit-monitor'
import type { ActionLogEntry } from '../shared/action-log'
import type { CacheSummary } from '../shared/cache'
import type { ExportRequest, ExportJobProgress } from '../shared/export'
import type { ImageDecoderSelectionResult, ImageDecoderStatus } from '../shared/image-decryption'
@@ -119,7 +120,8 @@ const api = {
getBootstrapCache: () => ipcRenderer.invoke('db:getBootstrapCache'),
getStartupCache: () => ipcRenderer.invoke('db:getStartupCache'),
getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter),
getContactAvatars: (usernames: string[]) => ipcRenderer.invoke('db:getContactAvatars', usernames),
getContactAvatars: (usernames: string[], options?: { refresh?: boolean }) =>
ipcRenderer.invoke('db:getContactAvatars', usernames, options),
getCachedMessages: (userMd5: string, startTime?: number, endTime?: number) =>
ipcRenderer.invoke('db:getCachedMessages', userMd5, startTime, endTime),
getCachedMessagePage: (userMd5: string, startTime?: number, endTime?: number) =>
@@ -133,6 +135,8 @@ const api = {
getGroupSnapshot: (userMd5: string) => ipcRenderer.invoke('db:getGroupSnapshot', userMd5),
getGroupExitMonitorState: (): Promise<GroupExitMonitorState> =>
ipcRenderer.invoke('group-exit-monitor:getState'),
setGroupExitMonitorEnabled: (enabled: boolean): Promise<GroupExitMonitorState> =>
ipcRenderer.invoke('group-exit-monitor:setEnabled', enabled),
setGroupExitMonitorGroups: (
roomIds: string[],
notificationRoomIds?: string[]
@@ -148,6 +152,8 @@ const api = {
ipcRenderer.invoke('group-exit-monitor:clearEvents'),
markGroupExitMonitorRead: (readAt?: number): Promise<GroupExitMonitorState> =>
ipcRenderer.invoke('group-exit-monitor:markRead', readAt),
listWechatActionLogs: (): Promise<ActionLogEntry[]> =>
ipcRenderer.invoke('wechat-action-log:list'),
onGroupExitMonitorState: (callback: (state: GroupExitMonitorState) => void) => {
const listener = (_event: Electron.IpcRendererEvent, state: GroupExitMonitorState): void =>
callback(state)
+10 -29
View File
@@ -43,6 +43,9 @@ import { runtimePlatform, supportsPersonalWechatSend } from './utils/runtime-env
import { useToast } from './components/ui'
import { AppUpdatePrompt } from './features/app-update/AppUpdatePrompt'
import { GroupExitMonitorWorkspace } from './features/group-exit-monitor/GroupExitMonitorWorkspace'
import { LogsWorkspace } from './features/logs/LogsWorkspace'
import { selectContactAvatarRefreshUsernames } from './utils/contact-avatar'
import { filterContacts } from './utils/contact-search'
const SIDEBAR_MIN_WIDTH = 260
const SIDEBAR_MAX_WIDTH = 380
@@ -137,16 +140,6 @@ const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => {
return true
}
const filterContactList = (list: Contact[], keyword: string): Contact[] => {
const lower = keyword.trim().toLowerCase()
if (!lower) return list
return list.filter((contact) =>
[contact.m_nsNickName, contact.m_nsUsrName, contact.remark, contact.wechatNickname].some(
(value) => value?.toLowerCase().includes(lower)
)
)
}
type GroupSnapshot = {
roomId: string
memberCount: number
@@ -598,7 +591,7 @@ function App(): React.ReactElement {
options?.onProgress?.('正在加载联系人...', 35)
const list = await window.api.getContacts()
setContacts(list)
setFilteredContacts(filterContactList(list, options?.filterKeyword || ''))
setFilteredContacts(filterContacts(list, options?.filterKeyword || ''))
const runId = ++contactAvatarHydrationRunRef.current
const hydrate = (): Promise<void> => hydrateContactAvatars(list, runId, options?.onProgress)
if (options?.waitForAvatars) {
@@ -615,21 +608,7 @@ function App(): React.ReactElement {
runId: number,
onProgress?: (message: string, percent?: number) => void
): Promise<void> => {
const usernames = Array.from(
new Set(
list
.map((contact) => contact.m_nsUsrName)
.filter((username, index) => {
const contact = list[index]
return (
username &&
!contact.avatar &&
!username.startsWith('Group_') &&
!username.startsWith('Unknown_')
)
})
)
)
const usernames = selectContactAvatarRefreshUsernames(list)
onProgress?.(
usernames.length ? `正在加载头像 0/${usernames.length}...` : '头像缓存已就绪',
usernames.length ? 55 : 90
@@ -641,7 +620,7 @@ function App(): React.ReactElement {
const chunk = usernames.slice(index, index + chunkSize)
if (chunk.length === 0) continue
try {
const avatars = await window.api.getContactAvatars(chunk)
const avatars = await window.api.getContactAvatars(chunk, { refresh: true })
if (runId !== contactAvatarHydrationRunRef.current) return
loadedCount += chunk.length
onProgress?.(
@@ -1052,7 +1031,7 @@ function App(): React.ReactElement {
if (!contact) return
try {
const avatars = await window.api.getContactAvatars([contact.m_nsUsrName])
const avatars = await window.api.getContactAvatars([contact.m_nsUsrName], { refresh: true })
const avatar = avatars[contact.m_nsUsrName]
if (avatar) {
const updateContact = (item: Contact): Contact =>
@@ -1457,7 +1436,7 @@ function App(): React.ReactElement {
])
const handleSearchContacts = (keyword: string): void => {
setFilteredContacts(filterContactList(contacts, keyword))
setFilteredContacts(filterContacts(contacts, keyword))
}
const handleRefreshContacts = async (filterKeyword: string): Promise<void> => {
@@ -1944,6 +1923,8 @@ function App(): React.ReactElement {
onOpenSendSettings={openWechatSendSettings}
/>
)
case 'logs':
return <LogsWorkspace />
case 'agent-hub':
return <AgentHubWorkspace />
case 'api':
@@ -16,22 +16,29 @@ export function ConversationItem({
const wxid = contact.m_nsUsrName
const displayName = nickname || wxid || '未命名会话'
const initial = (displayName || wxid || '?').charAt(0)
const [repairedAvatar, setRepairedAvatar] = useState<{ username: string; source: string }>()
const [repairedAvatar, setRepairedAvatar] = useState<{
username: string
source: string
replaces: string
}>()
const [failedAvatar, setFailedAvatar] = useState<{ username: string; source: string }>()
const repairedSource = repairedAvatar?.username === wxid ? repairedAvatar.source : undefined
const repairedSource =
repairedAvatar?.username === wxid && repairedAvatar.replaces === contact.avatar
? repairedAvatar.source
: undefined
const avatar = repairedSource || contact.avatar
const avatarFailed = failedAvatar?.username === wxid && failedAvatar.source === avatar
const handleAvatarError = (): void => {
if (!avatar || avatarFailed) return
setFailedAvatar({ username: wxid, source: avatar })
if (contact.type !== 'group' || avatar.startsWith('data:')) return
if (avatar.startsWith('data:')) return
void window.api
.getContactAvatars([wxid])
.getContactAvatars([wxid], { refresh: true })
.then((avatars) => {
const fallback = avatars[wxid]
if (!fallback || fallback === avatar) return
setRepairedAvatar({ username: wxid, source: fallback })
setRepairedAvatar({ username: wxid, source: fallback, replaces: avatar })
setFailedAvatar(undefined)
})
.catch(() => undefined)
@@ -46,6 +46,13 @@ function NavIcon({ page }: NavIconProps): React.ReactElement {
<path d="m16.5 9 3 3-3 3" />
</svg>
)
case 'logs':
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M6 5.5h12v13H6z" />
<path d="M9 9h6M9 12h6M9 15h4" />
</svg>
)
case 'export':
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
@@ -3,6 +3,7 @@ export type AppPage =
| 'search'
| 'report'
| 'exit-monitor'
| 'logs'
| 'agent-hub'
| 'export'
| 'api'
@@ -18,6 +19,7 @@ export const PRIMARY_NAV_ITEMS: NavigationItem[] = [
{ id: 'search', label: '问问微信' },
{ id: 'report', label: '日报' },
{ id: 'exit-monitor', label: '退群监控' },
// { id: 'logs', label: '日志' },
{ id: 'agent-hub', label: 'Agent' },
{ id: 'export', label: '导出' },
{ id: 'api', label: 'API' },
@@ -12,6 +12,7 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
Switch,
Textarea,
Tooltip,
TooltipContent,
@@ -36,6 +37,7 @@ interface GroupExitMonitorWorkspaceProps {
const EMPTY_STATE: GroupExitMonitorState = {
events: [],
enabled: true,
running: false,
nativeMonitorActive: false,
monitoredGroupCount: 0,
@@ -443,6 +445,7 @@ export function GroupExitMonitorWorkspace({
}: GroupExitMonitorWorkspaceProps): React.ReactElement {
const [state, setState] = React.useState<GroupExitMonitorState>(EMPTY_STATE)
const [checking, setChecking] = React.useState(false)
const [togglingEnabled, setTogglingEnabled] = React.useState(false)
const [saving, setSaving] = React.useState(false)
const [error, setError] = React.useState('')
const [sendCapability, setSendCapability] = React.useState<PersonalWechatSendCapability | null>(
@@ -633,6 +636,20 @@ export function GroupExitMonitorWorkspace({
}
}
const setMonitorEnabled = async (enabled: boolean): Promise<void> => {
const api = window.api
if (togglingEnabled || typeof api.setGroupExitMonitorEnabled !== 'function') return
setTogglingEnabled(true)
setError('')
try {
setState(await api.setGroupExitMonitorEnabled(enabled))
} catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason))
} finally {
setTogglingEnabled(false)
}
}
const toggleManageGroup = (roomId: string): void => {
const wasSelected = selectedManageRoomIds.has(roomId)
setSelectedManageRoomIds((current) => {
@@ -702,6 +719,8 @@ export function GroupExitMonitorWorkspace({
const statusLabel = !dbReady
? '数据库未连接'
: !state.enabled
? '监控已暂停'
: state.running && state.nativeMonitorActive
? '实时监听已启用'
: state.running
@@ -717,6 +736,15 @@ export function GroupExitMonitorWorkspace({
<p>实时关注微信群成员变化,成员数量减少时自动留下退群动态。</p>
</div>
<div className="exit-monitor-header-actions">
<label className="flex items-center gap-2 text-sm font-medium">
<span>退群监控</span>
<Switch
checked={state.enabled}
disabled={togglingEnabled}
onCheckedChange={(checked) => void setMonitorEnabled(checked)}
aria-label="退群监控总开关"
/>
</label>
<span className={`exit-monitor-status ${state.running ? 'running' : ''}`}>
<span className="exit-monitor-status-dot" aria-hidden="true" />
{statusLabel}
@@ -729,7 +757,7 @@ export function GroupExitMonitorWorkspace({
variant="outline"
size="sm"
onClick={() => void checkNow()}
disabled={!dbReady || checking}
disabled={!dbReady || !state.enabled || checking}
>
{checking ? '检查中...' : '立即检查'}
</Button>
@@ -869,7 +897,7 @@ export function GroupExitMonitorWorkspace({
: '连接微信数据库后,退群事件会显示在这里。'
}
action={
!state.events.length && dbReady ? (
!state.events.length && dbReady && state.enabled ? (
<Button variant="outline" size="sm" onClick={() => void checkNow()}>
立即检查
</Button>
@@ -0,0 +1,181 @@
import React from 'react'
import type { ActionLogEntry } from '../../../../shared/action-log'
import type { WechatActionPurpose, WechatActionStatus } from '../../../../shared/wechat-action'
import {
EmptyState,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Spinner
} from '../../components/ui'
type StatusFilter = 'all' | WechatActionStatus
type PurposeFilter = 'all' | 'member_left_notification' | 'scheduled_report' | 'other'
const purposeLabel = (purpose: WechatActionPurpose): string => {
if (purpose === 'member_left_notification') return '退群通知'
if (purpose === 'scheduled_report') return '定时日报'
return purpose || '其它动作'
}
const contentTypeLabel = (type: ActionLogEntry['contentType']): string => {
if (type === 'image') return '图片'
if (type === 'voice') return '语音'
return '文字'
}
const statusDetails = (
entry: ActionLogEntry
): { label: string; className: string } => {
if (entry.status === 'sent') return { label: '已发送', className: 'text-success' }
if (entry.status === 'blocked') return { label: '被阻止', className: 'text-warning' }
if (
entry.errorCode === 'SEND_CAPABILITY_UNAVAILABLE' ||
entry.errorCode === 'SEND_NOT_READY'
) {
return { label: '发送能力不可用', className: 'text-warning' }
}
return { label: '失败', className: 'text-destructive' }
}
const matchesPurpose = (entry: ActionLogEntry, filter: PurposeFilter): boolean => {
if (filter === 'all') return true
if (filter === 'other') {
return entry.purpose !== 'member_left_notification' && entry.purpose !== 'scheduled_report'
}
return entry.purpose === filter
}
export function LogsWorkspace(): React.ReactElement {
const [entries, setEntries] = React.useState<ActionLogEntry[]>([])
const [loading, setLoading] = React.useState(true)
const [error, setError] = React.useState('')
const [keyword, setKeyword] = React.useState('')
const [status, setStatus] = React.useState<StatusFilter>('all')
const [purpose, setPurpose] = React.useState<PurposeFilter>('all')
React.useEffect(() => {
let disposed = false
void window.api
.listWechatActionLogs()
.then((items) => {
if (!disposed) setEntries(items)
})
.catch((reason) => {
if (!disposed) setError(reason instanceof Error ? reason.message : String(reason))
})
.finally(() => {
if (!disposed) setLoading(false)
})
return () => {
disposed = true
}
}, [])
const visibleEntries = React.useMemo(() => {
const query = keyword.trim().toLocaleLowerCase()
return entries.filter((entry) => {
if (status !== 'all' && entry.status !== status) return false
if (!matchesPurpose(entry, purpose)) return false
if (!query) return true
return [entry.recipientName, entry.recipientId, entry.contentPreview]
.filter(Boolean)
.some((value) => value!.toLocaleLowerCase().includes(query))
})
}, [entries, keyword, purpose, status])
return (
<div className="h-full min-w-0 overflow-y-auto bg-canvas">
<div className="mx-auto flex w-full max-w-5xl flex-col gap-5 px-6 py-6">
<header>
<h1 className="text-xl font-semibold text-foreground">日志</h1>
</header>
<div className="grid gap-3 sm:grid-cols-[minmax(220px,1fr)_180px_180px]">
<Input
type="search"
aria-label="搜索日志"
placeholder="搜索对象、ID 或内容"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
<Select value={purpose} onValueChange={(value) => setPurpose(value as PurposeFilter)}>
<SelectTrigger aria-label="筛选日志类型">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部类型</SelectItem>
<SelectItem value="member_left_notification">退群通知</SelectItem>
<SelectItem value="scheduled_report">定时日报</SelectItem>
<SelectItem value="other">其它动作</SelectItem>
</SelectContent>
</Select>
<Select value={status} onValueChange={(value) => setStatus(value as StatusFilter)}>
<SelectTrigger aria-label="筛选日志状态">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="sent">已发送</SelectItem>
<SelectItem value="failed">失败</SelectItem>
<SelectItem value="blocked">被阻止</SelectItem>
</SelectContent>
</Select>
</div>
{loading ? (
<div className="flex min-h-40 items-center justify-center">
<Spinner label="正在加载日志" />
</div>
) : error ? (
<EmptyState title="日志读取失败" description={error} />
) : visibleEntries.length ? (
<section className="grid gap-3" aria-label="操作日志列表">
{visibleEntries.map((entry) => {
const result = statusDetails(entry)
const recipient = entry.recipientName || entry.recipientId
return (
<article
key={entry.id}
className="grid gap-3 rounded-md border border-border-subtle bg-surface px-4 py-3 shadow-surface"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<strong className="text-sm text-foreground">{purposeLabel(entry.purpose)}</strong>
<p className="mt-1 break-words text-sm text-foreground">发送给 {recipient}</p>
</div>
<div className="text-right text-xs">
<strong className={result.className}>{result.label}</strong>
<time className="mt-1 block text-muted-foreground" dateTime={entry.timestamp}>
{new Date(entry.timestamp).toLocaleString('zh-CN', { hour12: false })}
</time>
</div>
</div>
<div className="grid gap-1 text-xs text-muted-foreground sm:grid-cols-[140px_1fr]">
<span>
{entry.recipientType === 'group' ? '群聊' : '联系人'} · {entry.recipientId}
</span>
<span className="break-words">
{contentTypeLabel(entry.contentType)} · {entry.contentPreview || '无内容预览'}
</span>
</div>
{entry.reason ? (
<p className="break-words text-xs text-muted-foreground">{entry.reason}</p>
) : null}
</article>
)
})}
</section>
) : (
<EmptyState
title={entries.length ? '没有匹配的日志' : '暂无日志'}
description={entries.length ? '请调整搜索或筛选条件。' : undefined}
/>
)}
</div>
</div>
)
}
+1 -2
View File
@@ -357,7 +357,6 @@ body {
display: flex;
align-items: stretch;
overflow: hidden;
padding-top: var(--wxex-shell-content-top);
background: var(--wxex-bg-main);
}
@@ -369,4 +368,4 @@ body {
min-width: 0;
align-items: stretch;
overflow: hidden;
}
}
+16
View File
@@ -0,0 +1,16 @@
import type { Contact } from '../../../shared/types'
export function selectContactAvatarRefreshUsernames(contacts: Contact[]): string[] {
return Array.from(
new Set(
contacts
.map((contact) => contact.m_nsUsrName)
.filter(
(username) =>
Boolean(username) &&
!username.startsWith('Group_') &&
!username.startsWith('Unknown_')
)
)
)
}
+17
View File
@@ -0,0 +1,17 @@
import type { Contact } from '../../../shared/types'
export function filterContacts(list: Contact[], keyword: string): Contact[] {
const query = keyword.trim().toLocaleLowerCase()
if (!query) return list
return list.filter((contact) =>
[
contact.remark,
contact.wechatNickname,
contact.m_nsNickName,
contact.alias,
contact.wechatId,
contact.m_nsUsrName
].some((value) => value?.trim().toLocaleLowerCase().includes(query))
)
}
+24
View File
@@ -0,0 +1,24 @@
import type {
WechatActionContent,
WechatActionErrorCode,
WechatActionOrigin,
WechatActionPurpose,
WechatActionRecipient,
WechatActionStatus
} from './wechat-action'
export interface ActionLogEntry {
id: string
category: 'wechat_send'
source: WechatActionOrigin
purpose: WechatActionPurpose
timestamp: string
recipientType: WechatActionRecipient['type']
recipientId: string
recipientName?: string
contentType: WechatActionContent['type']
contentPreview?: string
status: WechatActionStatus
errorCode?: WechatActionErrorCode
reason?: string
}
+1
View File
@@ -174,6 +174,7 @@ export function buildMemberLeftNotification(
export interface GroupExitMonitorState {
events: GroupExitMonitorEvent[]
enabled: boolean
running: boolean
nativeMonitorActive: boolean
monitoredGroupCount: number
+2
View File
@@ -9,6 +9,8 @@ export interface Contact {
avatar?: string
wechatNickname?: string
remark?: string
alias?: string
wechatId?: string
isFolded?: boolean
isMuted?: boolean
}
+1
View File
@@ -87,6 +87,7 @@ export interface WechatActionAuditRecord {
executionId?: string
recipientType: WechatActionRecipient['type']
recipientId: string
recipientName?: string
contentType: WechatActionContent['type']
contentPreview?: string
contentHash?: string
@@ -0,0 +1,49 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { ConversationItem } from '../../src/renderer/src/components/conversation/ConversationItem'
import type { Contact } from '../../src/shared/types'
const contact: Contact = {
m_nsUsrName: 'wxid_fixture',
m_nsNickName: '联系人',
md5: 'fixture',
type: 'user',
avatar: 'https://old.example/avatar.jpg'
}
describe('ConversationItem avatar recovery', () => {
it('refreshes only the failed contact and lets a newer prop replace the repaired source', async () => {
const getContactAvatars = vi
.fn()
.mockResolvedValue({ wxid_fixture: 'data:image/png;base64,repaired' })
Object.defineProperty(window, 'api', {
configurable: true,
value: { getContactAvatars } as unknown as typeof window.api
})
const { rerender } = render(
<ConversationItem contact={contact} active={false} onSelect={vi.fn()} />
)
fireEvent.error(screen.getByRole('img', { name: '联系人' }))
await waitFor(() =>
expect(screen.getByRole('img', { name: '联系人' })).toHaveAttribute(
'src',
'data:image/png;base64,repaired'
)
)
expect(getContactAvatars).toHaveBeenCalledWith(['wxid_fixture'], { refresh: true })
rerender(
<ConversationItem
contact={{ ...contact, avatar: 'https://new.example/avatar.jpg' }}
active={false}
onSelect={vi.fn()}
/>
)
expect(screen.getByRole('img', { name: '联系人' })).toHaveAttribute(
'src',
'https://new.example/avatar.jpg'
)
})
})
@@ -41,6 +41,7 @@ const state: GroupExitMonitorState = {
detectedAt: 1_756_600_100_000
}
],
enabled: true,
running: true,
nativeMonitorActive: true,
monitoredGroupCount: 3,
@@ -93,6 +94,28 @@ describe('GroupExitMonitorWorkspace', () => {
expect(screen.getByText('实时监听已启用')).toBeVisible()
})
it('persists the master switch and shows the paused state', async () => {
const user = userEvent.setup()
const pausedState = { ...state, enabled: false, running: false }
const setEnabled = vi.fn().mockResolvedValue(pausedState)
window.api = {
getGroupExitMonitorState: vi.fn().mockResolvedValue(state),
onGroupExitMonitorState: vi.fn(() => () => undefined),
setGroupExitMonitorEnabled: setEnabled,
checkGroupExitMonitorNow: vi.fn().mockResolvedValue(state),
clearGroupExitMonitorEvents: vi.fn().mockResolvedValue(state)
} as typeof window.api
renderWorkspace(<GroupExitMonitorWorkspace dbReady />)
const masterSwitch = await screen.findByRole('switch', { name: '退群监控总开关' })
expect(masterSwitch).toBeChecked()
await user.click(masterSwitch)
await waitFor(() => expect(setEnabled).toHaveBeenCalledWith(false))
expect(screen.getByText('监控已暂停')).toBeVisible()
expect(screen.getByRole('button', { name: '立即检查' })).toBeDisabled()
})
it('filters to groups that have exit events and clears the history', async () => {
const user = userEvent.setup()
renderWorkspace(<GroupExitMonitorWorkspace dbReady />)
+68
View File
@@ -0,0 +1,68 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { LogsWorkspace } from '../../src/renderer/src/features/logs/LogsWorkspace'
import type { ActionLogEntry } from '../../src/shared/action-log'
const entries: ActionLogEntry[] = [
{
id: 'member-log',
category: 'wechat_send',
source: 'member_monitor',
purpose: 'member_left_notification',
timestamp: '2026-09-03T08:30:21.000Z',
recipientType: 'group',
recipientId: '123456@chatroom',
recipientName: '测试 q1',
contentType: 'text',
contentPreview: 'Shinven 已退出群聊',
status: 'sent'
},
{
id: 'report-log',
category: 'wechat_send',
source: 'scheduled_report',
purpose: 'scheduled_report',
timestamp: '2026-09-03T07:00:00.000Z',
recipientType: 'group',
recipientId: 'report@chatroom',
recipientName: '日报群',
contentType: 'image',
contentPreview: 'daily-report.png',
status: 'failed',
errorCode: 'SEND_CAPABILITY_UNAVAILABLE',
reason: '个人微信发送能力不可用'
}
]
describe('LogsWorkspace', () => {
it('shows audit entries and filters by search, status, and purpose', async () => {
const user = userEvent.setup()
Object.defineProperty(window, 'api', {
configurable: true,
value: {
listWechatActionLogs: vi.fn().mockResolvedValue(entries)
} as unknown as typeof window.api
})
render(<LogsWorkspace />)
await waitFor(() => expect(screen.getByText(/Shinven 已退出群聊/)).toBeVisible())
expect(screen.getByText(/daily-report.png/)).toBeVisible()
expect(screen.getByText('发送能力不可用')).toBeVisible()
const search = screen.getByRole('searchbox', { name: '搜索日志' })
await user.type(search, '123456')
expect(screen.getByText(/Shinven 已退出群聊/)).toBeVisible()
expect(screen.queryByText(/daily-report.png/)).not.toBeInTheDocument()
await user.clear(search)
await user.click(screen.getByRole('combobox', { name: '筛选日志状态' }))
await user.click(await screen.findByRole('option', { name: '失败' }))
expect(screen.queryByText(/Shinven 已退出群聊/)).not.toBeInTheDocument()
expect(screen.getByText(/daily-report.png/)).toBeVisible()
await user.click(screen.getByRole('combobox', { name: '筛选日志类型' }))
await user.click(await screen.findByRole('option', { name: '退群通知' }))
expect(screen.getByText('没有匹配的日志')).toBeVisible()
})
})
@@ -69,6 +69,8 @@ describe('preload IPC contract', () => {
await api.getGroupExitMonitorState()
expect(invoke).toHaveBeenLastCalledWith('group-exit-monitor:getState')
await api.setGroupExitMonitorEnabled(false)
expect(invoke).toHaveBeenLastCalledWith('group-exit-monitor:setEnabled', false)
await api.setGroupExitMonitorGroups(['room@chatroom'])
expect(invoke).toHaveBeenLastCalledWith('group-exit-monitor:setGroups', ['room@chatroom'])
await api.setGroupExitMonitorGroups(['room@chatroom'], ['room@chatroom'])
@@ -85,6 +87,8 @@ describe('preload IPC contract', () => {
expect(invoke).toHaveBeenLastCalledWith('group-exit-monitor:clearEvents')
await api.markGroupExitMonitorRead(123)
expect(invoke).toHaveBeenLastCalledWith('group-exit-monitor:markRead', 123)
await api.listWechatActionLogs()
expect(invoke).toHaveBeenLastCalledWith('wechat-action-log:list')
await api.getImage('fixture-md5', 'fixture.dat', 'fixture-session', {
force: true,
+34
View File
@@ -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'])
})
})
+29
View File
@@ -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[] = []
+15
View File
@@ -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 }]])
+145 -1
View File
@@ -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' })
})
})