mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-04 03:01:42 +08:00
feat: 优化档案搜索与头像
新增退群监控开关 发送限速
This commit is contained in:
Binary file not shown.
+13
-5
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Vendored
+7
-1
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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_')
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -174,6 +174,7 @@ export function buildMemberLeftNotification(
|
||||
|
||||
export interface GroupExitMonitorState {
|
||||
events: GroupExitMonitorEvent[]
|
||||
enabled: boolean
|
||||
running: boolean
|
||||
nativeMonitorActive: boolean
|
||||
monitoredGroupCount: number
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface Contact {
|
||||
avatar?: string
|
||||
wechatNickname?: string
|
||||
remark?: string
|
||||
alias?: string
|
||||
wechatId?: string
|
||||
isFolded?: boolean
|
||||
isMuted?: boolean
|
||||
}
|
||||
|
||||
@@ -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 />)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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