mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-08 07:21:19 +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 { installSafeConsole } from './safe-log'
|
||||||
import { agentHubService } from './services/agent-hub-service'
|
import { agentHubService } from './services/agent-hub-service'
|
||||||
import { groupExitMonitorService } from './services/group-exit-monitor-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 { personalWechatSendService } from './services/personal-wechat-send-service'
|
||||||
import { getPersonalWechatSendCapability } from './services/personal-wechat-capability-service'
|
import { getPersonalWechatSendCapability } from './services/personal-wechat-capability-service'
|
||||||
import { scheduledReportService } from './services/scheduled-report-service'
|
import { scheduledReportService } from './services/scheduled-report-service'
|
||||||
@@ -1079,11 +1080,14 @@ app.whenReady().then(async () => {
|
|||||||
return contacts
|
return contacts
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('db:getContactAvatars', async (_, usernames: string[]) => {
|
ipcMain.handle(
|
||||||
const avatars = await chat.getContactAvatars(usernames)
|
'db:getContactAvatars',
|
||||||
if (chat.isReady()) mergeBootstrapAvatars(chat.getCurrentAccountRoot(), avatars)
|
async (_, usernames: string[], options?: { refresh?: boolean }) => {
|
||||||
return avatars
|
const avatars = await chat.getContactAvatars(usernames, options)
|
||||||
})
|
if (chat.isReady()) mergeBootstrapAvatars(chat.getCurrentAccountRoot(), avatars)
|
||||||
|
return avatars
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'db:getMessages',
|
'db:getMessages',
|
||||||
@@ -1130,6 +1134,9 @@ app.whenReady().then(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('group-exit-monitor:getState', () => groupExitMonitorService.getState())
|
ipcMain.handle('group-exit-monitor:getState', () => groupExitMonitorService.getState())
|
||||||
|
ipcMain.handle('group-exit-monitor:setEnabled', (_, enabled: boolean) =>
|
||||||
|
groupExitMonitorService.setEnabled(enabled === true)
|
||||||
|
)
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'group-exit-monitor:setGroups',
|
'group-exit-monitor:setGroups',
|
||||||
(_, roomIds: string[], notificationRoomIds?: string[]) =>
|
(_, roomIds: string[], notificationRoomIds?: string[]) =>
|
||||||
@@ -1146,6 +1153,7 @@ app.whenReady().then(async () => {
|
|||||||
ipcMain.handle('group-exit-monitor:markRead', (_, readAt?: number) =>
|
ipcMain.handle('group-exit-monitor:markRead', (_, readAt?: number) =>
|
||||||
groupExitMonitorService.markRead(readAt)
|
groupExitMonitorService.markRead(readAt)
|
||||||
)
|
)
|
||||||
|
ipcMain.handle('wechat-action-log:list', () => wechatActionLogService.list())
|
||||||
|
|
||||||
ipcMain.handle('db:search', (_, keyword: string) => chat.searchMessages(keyword))
|
ipcMain.handle('db:search', (_, keyword: string) => chat.searchMessages(keyword))
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export interface FormattedContact {
|
|||||||
avatar?: string
|
avatar?: string
|
||||||
wechatNickname?: string
|
wechatNickname?: string
|
||||||
remark?: string
|
remark?: string
|
||||||
|
alias?: string
|
||||||
|
wechatId?: string
|
||||||
isFolded?: boolean
|
isFolded?: boolean
|
||||||
isMuted?: boolean
|
isMuted?: boolean
|
||||||
}
|
}
|
||||||
@@ -202,6 +204,8 @@ export function listContacts(filter?: string): FormattedContact[] {
|
|||||||
avatar: typeof user.avatar === 'string' ? user.avatar : undefined,
|
avatar: typeof user.avatar === 'string' ? user.avatar : undefined,
|
||||||
wechatNickname: user.wechatNickname,
|
wechatNickname: user.wechatNickname,
|
||||||
remark: user.remark,
|
remark: user.remark,
|
||||||
|
alias: user.alias,
|
||||||
|
wechatId: user.wechatId,
|
||||||
isFolded: user.isFolded,
|
isFolded: user.isFolded,
|
||||||
isMuted: user.isMuted
|
isMuted: user.isMuted
|
||||||
})
|
})
|
||||||
@@ -259,13 +263,18 @@ export async function getGroupNamesAsync(): Promise<Record<string, string>> {
|
|||||||
return names
|
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 {}
|
if (!dbRef) return {}
|
||||||
const normalized = Array.from(
|
const normalized = Array.from(
|
||||||
new Set((usernames || []).map((username) => String(username || '').trim()).filter(Boolean))
|
new Set((usernames || []).map((username) => String(username || '').trim()).filter(Boolean))
|
||||||
)
|
)
|
||||||
if (normalized.length === 0) return {}
|
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(
|
function listSourceMessages(
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from '../../shared/group-exit-monitor'
|
} from '../../shared/group-exit-monitor'
|
||||||
|
|
||||||
type StoredState = {
|
type StoredState = {
|
||||||
|
enabled?: boolean
|
||||||
accountRoot?: string
|
accountRoot?: string
|
||||||
events?: Partial<GroupExitMonitorEvent>[]
|
events?: Partial<GroupExitMonitorEvent>[]
|
||||||
lastReadAt?: number
|
lastReadAt?: number
|
||||||
@@ -70,6 +71,7 @@ type GroupExitActionGateway = Pick<WechatActionGateway, 'execute'> &
|
|||||||
class GroupExitMonitorService {
|
class GroupExitMonitorService {
|
||||||
private readonly actionGateway: GroupExitActionGateway
|
private readonly actionGateway: GroupExitActionGateway
|
||||||
private active = false
|
private active = false
|
||||||
|
private enabled = true
|
||||||
private nativeMonitorActive = false
|
private nativeMonitorActive = false
|
||||||
private snapshots = new Map<string, GroupSnapshotRecord>()
|
private snapshots = new Map<string, GroupSnapshotRecord>()
|
||||||
private changeTimer: NodeJS.Timeout | null = null
|
private changeTimer: NodeJS.Timeout | null = null
|
||||||
@@ -103,7 +105,8 @@ class GroupExitMonitorService {
|
|||||||
this.ensureLoaded()
|
this.ensureLoaded()
|
||||||
return {
|
return {
|
||||||
events: [...this.events],
|
events: [...this.events],
|
||||||
running: this.active && chat.isReady(),
|
enabled: this.enabled,
|
||||||
|
running: this.enabled && this.active && chat.isReady(),
|
||||||
nativeMonitorActive: this.nativeMonitorActive,
|
nativeMonitorActive: this.nativeMonitorActive,
|
||||||
monitoredGroupCount: this.snapshots.size,
|
monitoredGroupCount: this.snapshots.size,
|
||||||
monitorSelectionConfigured: this.monitorSelectionConfigured,
|
monitorSelectionConfigured: this.monitorSelectionConfigured,
|
||||||
@@ -143,6 +146,11 @@ class GroupExitMonitorService {
|
|||||||
this.active = true
|
this.active = true
|
||||||
this.nativeMonitorActive = nativeMonitorActive
|
this.nativeMonitorActive = nativeMonitorActive
|
||||||
this.checkQueued = false
|
this.checkQueued = false
|
||||||
|
if (!this.enabled) {
|
||||||
|
this.save()
|
||||||
|
this.broadcast()
|
||||||
|
return
|
||||||
|
}
|
||||||
this.initializing = true
|
this.initializing = true
|
||||||
const scopeGeneration = ++this.scopeGeneration
|
const scopeGeneration = ++this.scopeGeneration
|
||||||
this.eventSequence += 1
|
this.eventSequence += 1
|
||||||
@@ -178,7 +186,7 @@ class GroupExitMonitorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
notifyDatabaseChanged(rawPayload: string): void {
|
notifyDatabaseChanged(rawPayload: string): void {
|
||||||
if (!this.active || !isContactEvent(rawPayload)) return
|
if (!this.enabled || !this.active || !isContactEvent(rawPayload)) return
|
||||||
try {
|
try {
|
||||||
chat.getChatDb()?.getWcdb4Client().invalidateGroupNicknameCache()
|
chat.getChatDb()?.getWcdb4Client().invalidateGroupNicknameCache()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -193,6 +201,8 @@ class GroupExitMonitorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async checkNow(): Promise<GroupExitMonitorState> {
|
async checkNow(): Promise<GroupExitMonitorState> {
|
||||||
|
this.ensureLoaded()
|
||||||
|
if (!this.enabled) return this.getState()
|
||||||
if (!this.active && chat.isReady()) await this.start(false)
|
if (!this.active && chat.isReady()) await this.start(false)
|
||||||
await this.check()
|
await this.check()
|
||||||
return this.getState()
|
return this.getState()
|
||||||
@@ -223,7 +233,31 @@ class GroupExitMonitorService {
|
|||||||
this.save()
|
this.save()
|
||||||
this.broadcast()
|
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()
|
return this.getState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,6 +295,7 @@ class GroupExitMonitorService {
|
|||||||
const currentRoot = chat.getCurrentAccountRoot()
|
const currentRoot = chat.getCurrentAccountRoot()
|
||||||
if (
|
if (
|
||||||
!this.active ||
|
!this.active ||
|
||||||
|
!this.enabled ||
|
||||||
!chat.isReady() ||
|
!chat.isReady() ||
|
||||||
!currentRoot ||
|
!currentRoot ||
|
||||||
!this.accountRoot ||
|
!this.accountRoot ||
|
||||||
@@ -296,7 +331,7 @@ class GroupExitMonitorService {
|
|||||||
const result = await this.readMemberships()
|
const result = await this.readMemberships()
|
||||||
const membershipCostMs = Date.now() - startedAt
|
const membershipCostMs = Date.now() - startedAt
|
||||||
let changedGroups = 0
|
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)
|
changedGroups = await this.applyCurrentMemberships(result.groups, scopeGeneration)
|
||||||
}
|
}
|
||||||
console.log(
|
console.log(
|
||||||
@@ -367,7 +402,9 @@ class GroupExitMonitorService {
|
|||||||
const notifications: Array<{ group: GroupSnapshotRecord; event: GroupExitMonitorEvent }> = []
|
const notifications: Array<{ group: GroupSnapshotRecord; event: GroupExitMonitorEvent }> = []
|
||||||
let changedGroups = 0
|
let changedGroups = 0
|
||||||
for (const membership of groups) {
|
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
|
if (membership.membersValid === false) continue
|
||||||
const previous = this.snapshots.get(membership.roomId)
|
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.lastCheckedAt = Date.now()
|
||||||
// 先把事件和新基线作为同一检查点落盘,再执行可失败的通知动作。
|
// 先把事件和新基线作为同一检查点落盘,再执行可失败的通知动作。
|
||||||
this.save()
|
this.save()
|
||||||
@@ -419,7 +458,7 @@ class GroupExitMonitorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private startNextSnapshotHydration(): void {
|
private startNextSnapshotHydration(): void {
|
||||||
if (!this.active) {
|
if (!this.enabled || !this.active) {
|
||||||
this.finishHydrationBatch()
|
this.finishHydrationBatch()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -635,6 +674,7 @@ class GroupExitMonitorService {
|
|||||||
this.loaded = true
|
this.loaded = true
|
||||||
try {
|
try {
|
||||||
const stored = fs.readJsonSync(this.filePath()) as StoredState
|
const stored = fs.readJsonSync(this.filePath()) as StoredState
|
||||||
|
this.enabled = stored.enabled !== false
|
||||||
this.events = normalizeEvents(stored.events)
|
this.events = normalizeEvents(stored.events)
|
||||||
this.actionGateway.registerMemberEvents?.(this.events)
|
this.actionGateway.registerMemberEvents?.(this.events)
|
||||||
this.lastReadAt = Number(stored.lastReadAt) || 0
|
this.lastReadAt = Number(stored.lastReadAt) || 0
|
||||||
@@ -654,6 +694,7 @@ class GroupExitMonitorService {
|
|||||||
} catch {
|
} catch {
|
||||||
// 首次启动或文件损坏时从空记录开始。
|
// 首次启动或文件损坏时从空记录开始。
|
||||||
this.events = []
|
this.events = []
|
||||||
|
this.enabled = true
|
||||||
this.lastReadAt = 0
|
this.lastReadAt = 0
|
||||||
this.monitorSelectionConfigured = true
|
this.monitorSelectionConfigured = true
|
||||||
this.monitoredRoomIds.clear()
|
this.monitoredRoomIds.clear()
|
||||||
@@ -671,6 +712,7 @@ class GroupExitMonitorService {
|
|||||||
this.filePath(),
|
this.filePath(),
|
||||||
{
|
{
|
||||||
accountRoot: this.accountRoot,
|
accountRoot: this.accountRoot,
|
||||||
|
enabled: this.enabled,
|
||||||
events: this.events,
|
events: this.events,
|
||||||
lastReadAt: this.lastReadAt,
|
lastReadAt: this.lastReadAt,
|
||||||
monitorSelectionConfigured: this.monitorSelectionConfigured,
|
monitorSelectionConfigured: this.monitorSelectionConfigured,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { personalWechatSendService } from './personal-wechat-send-service'
|
|||||||
|
|
||||||
const MAX_AUDIT_RECORDS = 500
|
const MAX_AUDIT_RECORDS = 500
|
||||||
const MAX_CONTENT_PREVIEW_LENGTH = 240
|
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'])
|
const AUTOMATION_PURPOSE_ALLOWLIST = new Set(['scheduled_report', 'member_left_notification'])
|
||||||
|
|
||||||
export interface WechatActionGatewayDependencies {
|
export interface WechatActionGatewayDependencies {
|
||||||
@@ -34,6 +35,7 @@ export interface WechatActionGatewayDependencies {
|
|||||||
| undefined
|
| undefined
|
||||||
getUserDataPath?: () => string
|
getUserDataPath?: () => string
|
||||||
now?: () => Date
|
now?: () => Date
|
||||||
|
wait?: (milliseconds: number) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LoadedAuditState {
|
interface LoadedAuditState {
|
||||||
@@ -46,12 +48,16 @@ export interface WechatActionPolicyContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const defaultDependencies = (): Required<
|
const defaultDependencies = (): Required<
|
||||||
Pick<WechatActionGatewayDependencies, 'getCapability' | 'send' | 'getUserDataPath' | 'now'>
|
Pick<
|
||||||
|
WechatActionGatewayDependencies,
|
||||||
|
'getCapability' | 'send' | 'getUserDataPath' | 'now' | 'wait'
|
||||||
|
>
|
||||||
> => ({
|
> => ({
|
||||||
getCapability: () => personalWechatCapabilityService.getPersonalWechatSendCapability(),
|
getCapability: () => personalWechatCapabilityService.getPersonalWechatSendCapability(),
|
||||||
send: (request) => personalWechatSendService.send(request),
|
send: (request) => personalWechatSendService.send(request),
|
||||||
getUserDataPath: () => app.getPath('userData'),
|
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 {
|
export class WechatActionGateway {
|
||||||
private readonly deps: Required<
|
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 memberEvents = new Map<string, WechatActionMemberEventReference>()
|
||||||
private readonly inFlight = new Map<string, Promise<WechatActionResult>>()
|
private readonly inFlight = new Map<string, Promise<WechatActionResult>>()
|
||||||
private auditState: LoadedAuditState | null = null
|
private auditState: LoadedAuditState | null = null
|
||||||
|
private automationSendTail: Promise<void> = Promise.resolve()
|
||||||
|
private lastAutomationSendStartedAt: number | undefined
|
||||||
|
|
||||||
constructor(deps: WechatActionGatewayDependencies = {}) {
|
constructor(deps: WechatActionGatewayDependencies = {}) {
|
||||||
this.deps = { ...defaultDependencies(), ...deps }
|
this.deps = { ...defaultDependencies(), ...deps }
|
||||||
@@ -87,6 +101,10 @@ export class WechatActionGateway {
|
|||||||
this.memberEvents.clear()
|
this.memberEvents.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listAuditRecords(): WechatActionAuditRecord[] {
|
||||||
|
return this.loadAuditState().records.map((record) => ({ ...record }))
|
||||||
|
}
|
||||||
|
|
||||||
async execute(request: WechatActionRequest): Promise<WechatActionResult> {
|
async execute(request: WechatActionRequest): Promise<WechatActionResult> {
|
||||||
const normalized = normalizeActionRequest(request)
|
const normalized = normalizeActionRequest(request)
|
||||||
const idempotencyKey = normalized ? this.idempotencyKey(normalized) : undefined
|
const idempotencyKey = normalized ? this.idempotencyKey(normalized) : undefined
|
||||||
@@ -176,7 +194,7 @@ export class WechatActionGateway {
|
|||||||
|
|
||||||
let sendResult: PersonalWechatSendResult
|
let sendResult: PersonalWechatSendResult
|
||||||
try {
|
try {
|
||||||
sendResult = await this.deps.send(toPersonalWechatSendRequest(request))
|
sendResult = await this.send(request)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return this.finishFailed(
|
return this.finishFailed(
|
||||||
actionId,
|
actionId,
|
||||||
@@ -201,6 +219,26 @@ export class WechatActionGateway {
|
|||||||
return this.finishSent(actionId, request, idempotencyKey, startedAt, sendResult)
|
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(
|
private async resolveEventContext(
|
||||||
request: WechatActionRequest
|
request: WechatActionRequest
|
||||||
): Promise<WechatActionPolicyContext> {
|
): Promise<WechatActionPolicyContext> {
|
||||||
@@ -364,6 +402,7 @@ export class WechatActionGateway {
|
|||||||
...(request.executionId ? { executionId: request.executionId } : {}),
|
...(request.executionId ? { executionId: request.executionId } : {}),
|
||||||
recipientType: request.recipient.type,
|
recipientType: request.recipient.type,
|
||||||
recipientId: request.recipient.id,
|
recipientId: request.recipient.id,
|
||||||
|
...(request.recipient.name ? { recipientName: request.recipient.name } : {}),
|
||||||
contentType: request.content.type,
|
contentType: request.content.type,
|
||||||
...contentAudit(request.content),
|
...contentAudit(request.content),
|
||||||
createdAt: result.startedAt,
|
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
|
avatar?: string
|
||||||
wechatNickname?: string
|
wechatNickname?: string
|
||||||
remark?: string
|
remark?: string
|
||||||
|
alias?: string
|
||||||
|
wechatId?: string
|
||||||
isFolded?: boolean
|
isFolded?: boolean
|
||||||
isMuted?: boolean
|
isMuted?: boolean
|
||||||
raw: Record<string, unknown>
|
raw: Record<string, unknown>
|
||||||
@@ -1030,6 +1032,14 @@ export class Wcdb4Client {
|
|||||||
this.sessionDisplayNamesHydrated = false
|
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 {
|
invalidateGroupNicknameCache(): void {
|
||||||
this.groupNicknameCache.clear()
|
this.groupNicknameCache.clear()
|
||||||
@@ -2813,12 +2823,22 @@ export class Wcdb4Client {
|
|||||||
'contactRemark',
|
'contactRemark',
|
||||||
'contact_remark'
|
'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)
|
const status = this.sessionStatusCache.get(username)
|
||||||
return {
|
return {
|
||||||
username,
|
username,
|
||||||
nickname,
|
nickname,
|
||||||
wechatNickname,
|
wechatNickname,
|
||||||
remark,
|
remark,
|
||||||
|
alias,
|
||||||
|
wechatId: wechatId || alias,
|
||||||
isFolded: status?.isFolded,
|
isFolded: status?.isFolded,
|
||||||
isMuted: status?.isMuted,
|
isMuted: status?.isMuted,
|
||||||
raw: row
|
raw: row
|
||||||
|
|||||||
+12
-4
@@ -7,6 +7,8 @@ export interface UserContact {
|
|||||||
avatar?: string
|
avatar?: string
|
||||||
wechatNickname?: string
|
wechatNickname?: string
|
||||||
remark?: string
|
remark?: string
|
||||||
|
alias?: string
|
||||||
|
wechatId?: string
|
||||||
isFolded?: boolean
|
isFolded?: boolean
|
||||||
isMuted?: boolean
|
isMuted?: boolean
|
||||||
}
|
}
|
||||||
@@ -102,15 +104,21 @@ export class WechatDb {
|
|||||||
avatar: session.avatar,
|
avatar: session.avatar,
|
||||||
wechatNickname: session.wechatNickname,
|
wechatNickname: session.wechatNickname,
|
||||||
remark: session.remark,
|
remark: session.remark,
|
||||||
|
alias: session.alias,
|
||||||
|
wechatId: session.wechatId,
|
||||||
isFolded: session.isFolded,
|
isFolded: session.isFolded,
|
||||||
isMuted: session.isMuted
|
isMuted: session.isMuted
|
||||||
}))
|
}))
|
||||||
.filter((contact) => {
|
.filter((contact) => {
|
||||||
if (!keyword) return true
|
if (!keyword) return true
|
||||||
return (
|
return [
|
||||||
contact.m_nsUsrName.toLowerCase().includes(keyword) ||
|
contact.remark,
|
||||||
contact.nickname.toLowerCase().includes(keyword)
|
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'
|
} from '../shared/personal-wechat-voice-runtime'
|
||||||
import type { AppLogEntry } from '../shared/app-log'
|
import type { AppLogEntry } from '../shared/app-log'
|
||||||
import type { GroupExitMonitorState } from '../shared/group-exit-monitor'
|
import type { GroupExitMonitorState } from '../shared/group-exit-monitor'
|
||||||
|
import type { ActionLogEntry } from '../shared/action-log'
|
||||||
import type {
|
import type {
|
||||||
AppUpdateCheckResult,
|
AppUpdateCheckResult,
|
||||||
AppUpdateInstallResult,
|
AppUpdateInstallResult,
|
||||||
@@ -213,7 +214,10 @@ declare global {
|
|||||||
updatedAt: number
|
updatedAt: number
|
||||||
} | null>
|
} | null>
|
||||||
getContacts: (filter?: string) => Promise<Contact[]>
|
getContacts: (filter?: string) => Promise<Contact[]>
|
||||||
getContactAvatars: (usernames: string[]) => Promise<Record<string, string>>
|
getContactAvatars: (
|
||||||
|
usernames: string[],
|
||||||
|
options?: { refresh?: boolean }
|
||||||
|
) => Promise<Record<string, string>>
|
||||||
getCachedMessages: (
|
getCachedMessages: (
|
||||||
userMd5: string,
|
userMd5: string,
|
||||||
startTime?: number,
|
startTime?: number,
|
||||||
@@ -258,6 +262,7 @@ declare global {
|
|||||||
}[]
|
}[]
|
||||||
} | null>
|
} | null>
|
||||||
getGroupExitMonitorState: () => Promise<GroupExitMonitorState>
|
getGroupExitMonitorState: () => Promise<GroupExitMonitorState>
|
||||||
|
setGroupExitMonitorEnabled: (enabled: boolean) => Promise<GroupExitMonitorState>
|
||||||
setGroupExitMonitorGroups: (
|
setGroupExitMonitorGroups: (
|
||||||
roomIds: string[],
|
roomIds: string[],
|
||||||
notificationRoomIds?: string[]
|
notificationRoomIds?: string[]
|
||||||
@@ -266,6 +271,7 @@ declare global {
|
|||||||
checkGroupExitMonitorNow: () => Promise<GroupExitMonitorState>
|
checkGroupExitMonitorNow: () => Promise<GroupExitMonitorState>
|
||||||
clearGroupExitMonitorEvents: () => Promise<GroupExitMonitorState>
|
clearGroupExitMonitorEvents: () => Promise<GroupExitMonitorState>
|
||||||
markGroupExitMonitorRead: (readAt?: number) => Promise<GroupExitMonitorState>
|
markGroupExitMonitorRead: (readAt?: number) => Promise<GroupExitMonitorState>
|
||||||
|
listWechatActionLogs: () => Promise<ActionLogEntry[]>
|
||||||
onGroupExitMonitorState: (callback: (state: GroupExitMonitorState) => void) => () => void
|
onGroupExitMonitorState: (callback: (state: GroupExitMonitorState) => void) => () => void
|
||||||
search: (keyword: string) => Promise<string | null>
|
search: (keyword: string) => Promise<string | null>
|
||||||
searchKnowledge: (request: KnowledgeSearchIpcRequest) => Promise<KnowledgeSearchIpcResult>
|
searchKnowledge: (request: KnowledgeSearchIpcRequest) => Promise<KnowledgeSearchIpcResult>
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import type {
|
|||||||
import type { AppLogEntry } from '../shared/app-log'
|
import type { AppLogEntry } from '../shared/app-log'
|
||||||
import type { AppUpdateState } from '../shared/app-update'
|
import type { AppUpdateState } from '../shared/app-update'
|
||||||
import type { GroupExitMonitorState } from '../shared/group-exit-monitor'
|
import type { GroupExitMonitorState } from '../shared/group-exit-monitor'
|
||||||
|
import type { ActionLogEntry } from '../shared/action-log'
|
||||||
import type { CacheSummary } from '../shared/cache'
|
import type { CacheSummary } from '../shared/cache'
|
||||||
import type { ExportRequest, ExportJobProgress } from '../shared/export'
|
import type { ExportRequest, ExportJobProgress } from '../shared/export'
|
||||||
import type { ImageDecoderSelectionResult, ImageDecoderStatus } from '../shared/image-decryption'
|
import type { ImageDecoderSelectionResult, ImageDecoderStatus } from '../shared/image-decryption'
|
||||||
@@ -119,7 +120,8 @@ const api = {
|
|||||||
getBootstrapCache: () => ipcRenderer.invoke('db:getBootstrapCache'),
|
getBootstrapCache: () => ipcRenderer.invoke('db:getBootstrapCache'),
|
||||||
getStartupCache: () => ipcRenderer.invoke('db:getStartupCache'),
|
getStartupCache: () => ipcRenderer.invoke('db:getStartupCache'),
|
||||||
getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter),
|
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) =>
|
getCachedMessages: (userMd5: string, startTime?: number, endTime?: number) =>
|
||||||
ipcRenderer.invoke('db:getCachedMessages', userMd5, startTime, endTime),
|
ipcRenderer.invoke('db:getCachedMessages', userMd5, startTime, endTime),
|
||||||
getCachedMessagePage: (userMd5: string, startTime?: number, endTime?: number) =>
|
getCachedMessagePage: (userMd5: string, startTime?: number, endTime?: number) =>
|
||||||
@@ -133,6 +135,8 @@ const api = {
|
|||||||
getGroupSnapshot: (userMd5: string) => ipcRenderer.invoke('db:getGroupSnapshot', userMd5),
|
getGroupSnapshot: (userMd5: string) => ipcRenderer.invoke('db:getGroupSnapshot', userMd5),
|
||||||
getGroupExitMonitorState: (): Promise<GroupExitMonitorState> =>
|
getGroupExitMonitorState: (): Promise<GroupExitMonitorState> =>
|
||||||
ipcRenderer.invoke('group-exit-monitor:getState'),
|
ipcRenderer.invoke('group-exit-monitor:getState'),
|
||||||
|
setGroupExitMonitorEnabled: (enabled: boolean): Promise<GroupExitMonitorState> =>
|
||||||
|
ipcRenderer.invoke('group-exit-monitor:setEnabled', enabled),
|
||||||
setGroupExitMonitorGroups: (
|
setGroupExitMonitorGroups: (
|
||||||
roomIds: string[],
|
roomIds: string[],
|
||||||
notificationRoomIds?: string[]
|
notificationRoomIds?: string[]
|
||||||
@@ -148,6 +152,8 @@ const api = {
|
|||||||
ipcRenderer.invoke('group-exit-monitor:clearEvents'),
|
ipcRenderer.invoke('group-exit-monitor:clearEvents'),
|
||||||
markGroupExitMonitorRead: (readAt?: number): Promise<GroupExitMonitorState> =>
|
markGroupExitMonitorRead: (readAt?: number): Promise<GroupExitMonitorState> =>
|
||||||
ipcRenderer.invoke('group-exit-monitor:markRead', readAt),
|
ipcRenderer.invoke('group-exit-monitor:markRead', readAt),
|
||||||
|
listWechatActionLogs: (): Promise<ActionLogEntry[]> =>
|
||||||
|
ipcRenderer.invoke('wechat-action-log:list'),
|
||||||
onGroupExitMonitorState: (callback: (state: GroupExitMonitorState) => void) => {
|
onGroupExitMonitorState: (callback: (state: GroupExitMonitorState) => void) => {
|
||||||
const listener = (_event: Electron.IpcRendererEvent, state: GroupExitMonitorState): void =>
|
const listener = (_event: Electron.IpcRendererEvent, state: GroupExitMonitorState): void =>
|
||||||
callback(state)
|
callback(state)
|
||||||
|
|||||||
+10
-29
@@ -43,6 +43,9 @@ import { runtimePlatform, supportsPersonalWechatSend } from './utils/runtime-env
|
|||||||
import { useToast } from './components/ui'
|
import { useToast } from './components/ui'
|
||||||
import { AppUpdatePrompt } from './features/app-update/AppUpdatePrompt'
|
import { AppUpdatePrompt } from './features/app-update/AppUpdatePrompt'
|
||||||
import { GroupExitMonitorWorkspace } from './features/group-exit-monitor/GroupExitMonitorWorkspace'
|
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_MIN_WIDTH = 260
|
||||||
const SIDEBAR_MAX_WIDTH = 380
|
const SIDEBAR_MAX_WIDTH = 380
|
||||||
@@ -137,16 +140,6 @@ const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => {
|
|||||||
return true
|
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 = {
|
type GroupSnapshot = {
|
||||||
roomId: string
|
roomId: string
|
||||||
memberCount: number
|
memberCount: number
|
||||||
@@ -598,7 +591,7 @@ function App(): React.ReactElement {
|
|||||||
options?.onProgress?.('正在加载联系人...', 35)
|
options?.onProgress?.('正在加载联系人...', 35)
|
||||||
const list = await window.api.getContacts()
|
const list = await window.api.getContacts()
|
||||||
setContacts(list)
|
setContacts(list)
|
||||||
setFilteredContacts(filterContactList(list, options?.filterKeyword || ''))
|
setFilteredContacts(filterContacts(list, options?.filterKeyword || ''))
|
||||||
const runId = ++contactAvatarHydrationRunRef.current
|
const runId = ++contactAvatarHydrationRunRef.current
|
||||||
const hydrate = (): Promise<void> => hydrateContactAvatars(list, runId, options?.onProgress)
|
const hydrate = (): Promise<void> => hydrateContactAvatars(list, runId, options?.onProgress)
|
||||||
if (options?.waitForAvatars) {
|
if (options?.waitForAvatars) {
|
||||||
@@ -615,21 +608,7 @@ function App(): React.ReactElement {
|
|||||||
runId: number,
|
runId: number,
|
||||||
onProgress?: (message: string, percent?: number) => void
|
onProgress?: (message: string, percent?: number) => void
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const usernames = Array.from(
|
const usernames = selectContactAvatarRefreshUsernames(list)
|
||||||
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_')
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
onProgress?.(
|
onProgress?.(
|
||||||
usernames.length ? `正在加载头像 0/${usernames.length}...` : '头像缓存已就绪',
|
usernames.length ? `正在加载头像 0/${usernames.length}...` : '头像缓存已就绪',
|
||||||
usernames.length ? 55 : 90
|
usernames.length ? 55 : 90
|
||||||
@@ -641,7 +620,7 @@ function App(): React.ReactElement {
|
|||||||
const chunk = usernames.slice(index, index + chunkSize)
|
const chunk = usernames.slice(index, index + chunkSize)
|
||||||
if (chunk.length === 0) continue
|
if (chunk.length === 0) continue
|
||||||
try {
|
try {
|
||||||
const avatars = await window.api.getContactAvatars(chunk)
|
const avatars = await window.api.getContactAvatars(chunk, { refresh: true })
|
||||||
if (runId !== contactAvatarHydrationRunRef.current) return
|
if (runId !== contactAvatarHydrationRunRef.current) return
|
||||||
loadedCount += chunk.length
|
loadedCount += chunk.length
|
||||||
onProgress?.(
|
onProgress?.(
|
||||||
@@ -1052,7 +1031,7 @@ function App(): React.ReactElement {
|
|||||||
if (!contact) return
|
if (!contact) return
|
||||||
|
|
||||||
try {
|
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]
|
const avatar = avatars[contact.m_nsUsrName]
|
||||||
if (avatar) {
|
if (avatar) {
|
||||||
const updateContact = (item: Contact): Contact =>
|
const updateContact = (item: Contact): Contact =>
|
||||||
@@ -1457,7 +1436,7 @@ function App(): React.ReactElement {
|
|||||||
])
|
])
|
||||||
|
|
||||||
const handleSearchContacts = (keyword: string): void => {
|
const handleSearchContacts = (keyword: string): void => {
|
||||||
setFilteredContacts(filterContactList(contacts, keyword))
|
setFilteredContacts(filterContacts(contacts, keyword))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRefreshContacts = async (filterKeyword: string): Promise<void> => {
|
const handleRefreshContacts = async (filterKeyword: string): Promise<void> => {
|
||||||
@@ -1944,6 +1923,8 @@ function App(): React.ReactElement {
|
|||||||
onOpenSendSettings={openWechatSendSettings}
|
onOpenSendSettings={openWechatSendSettings}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
case 'logs':
|
||||||
|
return <LogsWorkspace />
|
||||||
case 'agent-hub':
|
case 'agent-hub':
|
||||||
return <AgentHubWorkspace />
|
return <AgentHubWorkspace />
|
||||||
case 'api':
|
case 'api':
|
||||||
|
|||||||
@@ -16,22 +16,29 @@ export function ConversationItem({
|
|||||||
const wxid = contact.m_nsUsrName
|
const wxid = contact.m_nsUsrName
|
||||||
const displayName = nickname || wxid || '未命名会话'
|
const displayName = nickname || wxid || '未命名会话'
|
||||||
const initial = (displayName || wxid || '?').charAt(0)
|
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 [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 avatar = repairedSource || contact.avatar
|
||||||
const avatarFailed = failedAvatar?.username === wxid && failedAvatar.source === avatar
|
const avatarFailed = failedAvatar?.username === wxid && failedAvatar.source === avatar
|
||||||
|
|
||||||
const handleAvatarError = (): void => {
|
const handleAvatarError = (): void => {
|
||||||
if (!avatar || avatarFailed) return
|
if (!avatar || avatarFailed) return
|
||||||
setFailedAvatar({ username: wxid, source: avatar })
|
setFailedAvatar({ username: wxid, source: avatar })
|
||||||
if (contact.type !== 'group' || avatar.startsWith('data:')) return
|
if (avatar.startsWith('data:')) return
|
||||||
void window.api
|
void window.api
|
||||||
.getContactAvatars([wxid])
|
.getContactAvatars([wxid], { refresh: true })
|
||||||
.then((avatars) => {
|
.then((avatars) => {
|
||||||
const fallback = avatars[wxid]
|
const fallback = avatars[wxid]
|
||||||
if (!fallback || fallback === avatar) return
|
if (!fallback || fallback === avatar) return
|
||||||
setRepairedAvatar({ username: wxid, source: fallback })
|
setRepairedAvatar({ username: wxid, source: fallback, replaces: avatar })
|
||||||
setFailedAvatar(undefined)
|
setFailedAvatar(undefined)
|
||||||
})
|
})
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ function NavIcon({ page }: NavIconProps): React.ReactElement {
|
|||||||
<path d="m16.5 9 3 3-3 3" />
|
<path d="m16.5 9 3 3-3 3" />
|
||||||
</svg>
|
</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':
|
case 'export':
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export type AppPage =
|
|||||||
| 'search'
|
| 'search'
|
||||||
| 'report'
|
| 'report'
|
||||||
| 'exit-monitor'
|
| 'exit-monitor'
|
||||||
|
| 'logs'
|
||||||
| 'agent-hub'
|
| 'agent-hub'
|
||||||
| 'export'
|
| 'export'
|
||||||
| 'api'
|
| 'api'
|
||||||
@@ -18,6 +19,7 @@ export const PRIMARY_NAV_ITEMS: NavigationItem[] = [
|
|||||||
{ id: 'search', label: '问问微信' },
|
{ id: 'search', label: '问问微信' },
|
||||||
{ id: 'report', label: '日报' },
|
{ id: 'report', label: '日报' },
|
||||||
{ id: 'exit-monitor', label: '退群监控' },
|
{ id: 'exit-monitor', label: '退群监控' },
|
||||||
|
// { id: 'logs', label: '日志' },
|
||||||
{ id: 'agent-hub', label: 'Agent' },
|
{ id: 'agent-hub', label: 'Agent' },
|
||||||
{ id: 'export', label: '导出' },
|
{ id: 'export', label: '导出' },
|
||||||
{ id: 'api', label: 'API' },
|
{ id: 'api', label: 'API' },
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
|
Switch,
|
||||||
Textarea,
|
Textarea,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -36,6 +37,7 @@ interface GroupExitMonitorWorkspaceProps {
|
|||||||
|
|
||||||
const EMPTY_STATE: GroupExitMonitorState = {
|
const EMPTY_STATE: GroupExitMonitorState = {
|
||||||
events: [],
|
events: [],
|
||||||
|
enabled: true,
|
||||||
running: false,
|
running: false,
|
||||||
nativeMonitorActive: false,
|
nativeMonitorActive: false,
|
||||||
monitoredGroupCount: 0,
|
monitoredGroupCount: 0,
|
||||||
@@ -443,6 +445,7 @@ export function GroupExitMonitorWorkspace({
|
|||||||
}: GroupExitMonitorWorkspaceProps): React.ReactElement {
|
}: GroupExitMonitorWorkspaceProps): React.ReactElement {
|
||||||
const [state, setState] = React.useState<GroupExitMonitorState>(EMPTY_STATE)
|
const [state, setState] = React.useState<GroupExitMonitorState>(EMPTY_STATE)
|
||||||
const [checking, setChecking] = React.useState(false)
|
const [checking, setChecking] = React.useState(false)
|
||||||
|
const [togglingEnabled, setTogglingEnabled] = React.useState(false)
|
||||||
const [saving, setSaving] = React.useState(false)
|
const [saving, setSaving] = React.useState(false)
|
||||||
const [error, setError] = React.useState('')
|
const [error, setError] = React.useState('')
|
||||||
const [sendCapability, setSendCapability] = React.useState<PersonalWechatSendCapability | null>(
|
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 toggleManageGroup = (roomId: string): void => {
|
||||||
const wasSelected = selectedManageRoomIds.has(roomId)
|
const wasSelected = selectedManageRoomIds.has(roomId)
|
||||||
setSelectedManageRoomIds((current) => {
|
setSelectedManageRoomIds((current) => {
|
||||||
@@ -702,6 +719,8 @@ export function GroupExitMonitorWorkspace({
|
|||||||
|
|
||||||
const statusLabel = !dbReady
|
const statusLabel = !dbReady
|
||||||
? '数据库未连接'
|
? '数据库未连接'
|
||||||
|
: !state.enabled
|
||||||
|
? '监控已暂停'
|
||||||
: state.running && state.nativeMonitorActive
|
: state.running && state.nativeMonitorActive
|
||||||
? '实时监听已启用'
|
? '实时监听已启用'
|
||||||
: state.running
|
: state.running
|
||||||
@@ -717,6 +736,15 @@ export function GroupExitMonitorWorkspace({
|
|||||||
<p>实时关注微信群成员变化,成员数量减少时自动留下退群动态。</p>
|
<p>实时关注微信群成员变化,成员数量减少时自动留下退群动态。</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="exit-monitor-header-actions">
|
<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 ${state.running ? 'running' : ''}`}>
|
||||||
<span className="exit-monitor-status-dot" aria-hidden="true" />
|
<span className="exit-monitor-status-dot" aria-hidden="true" />
|
||||||
{statusLabel}
|
{statusLabel}
|
||||||
@@ -729,7 +757,7 @@ export function GroupExitMonitorWorkspace({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => void checkNow()}
|
onClick={() => void checkNow()}
|
||||||
disabled={!dbReady || checking}
|
disabled={!dbReady || !state.enabled || checking}
|
||||||
>
|
>
|
||||||
{checking ? '检查中...' : '立即检查'}
|
{checking ? '检查中...' : '立即检查'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -869,7 +897,7 @@ export function GroupExitMonitorWorkspace({
|
|||||||
: '连接微信数据库后,退群事件会显示在这里。'
|
: '连接微信数据库后,退群事件会显示在这里。'
|
||||||
}
|
}
|
||||||
action={
|
action={
|
||||||
!state.events.length && dbReady ? (
|
!state.events.length && dbReady && state.enabled ? (
|
||||||
<Button variant="outline" size="sm" onClick={() => void checkNow()}>
|
<Button variant="outline" size="sm" onClick={() => void checkNow()}>
|
||||||
立即检查
|
立即检查
|
||||||
</Button>
|
</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
@@ -357,7 +357,6 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding-top: var(--wxex-shell-content-top);
|
|
||||||
background: var(--wxex-bg-main);
|
background: var(--wxex-bg-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
export interface GroupExitMonitorState {
|
||||||
events: GroupExitMonitorEvent[]
|
events: GroupExitMonitorEvent[]
|
||||||
|
enabled: boolean
|
||||||
running: boolean
|
running: boolean
|
||||||
nativeMonitorActive: boolean
|
nativeMonitorActive: boolean
|
||||||
monitoredGroupCount: number
|
monitoredGroupCount: number
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export interface Contact {
|
|||||||
avatar?: string
|
avatar?: string
|
||||||
wechatNickname?: string
|
wechatNickname?: string
|
||||||
remark?: string
|
remark?: string
|
||||||
|
alias?: string
|
||||||
|
wechatId?: string
|
||||||
isFolded?: boolean
|
isFolded?: boolean
|
||||||
isMuted?: boolean
|
isMuted?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ export interface WechatActionAuditRecord {
|
|||||||
executionId?: string
|
executionId?: string
|
||||||
recipientType: WechatActionRecipient['type']
|
recipientType: WechatActionRecipient['type']
|
||||||
recipientId: string
|
recipientId: string
|
||||||
|
recipientName?: string
|
||||||
contentType: WechatActionContent['type']
|
contentType: WechatActionContent['type']
|
||||||
contentPreview?: string
|
contentPreview?: string
|
||||||
contentHash?: 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
|
detectedAt: 1_756_600_100_000
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
enabled: true,
|
||||||
running: true,
|
running: true,
|
||||||
nativeMonitorActive: true,
|
nativeMonitorActive: true,
|
||||||
monitoredGroupCount: 3,
|
monitoredGroupCount: 3,
|
||||||
@@ -93,6 +94,28 @@ describe('GroupExitMonitorWorkspace', () => {
|
|||||||
expect(screen.getByText('实时监听已启用')).toBeVisible()
|
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 () => {
|
it('filters to groups that have exit events and clears the history', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
renderWorkspace(<GroupExitMonitorWorkspace dbReady />)
|
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()
|
await api.getGroupExitMonitorState()
|
||||||
expect(invoke).toHaveBeenLastCalledWith('group-exit-monitor:getState')
|
expect(invoke).toHaveBeenLastCalledWith('group-exit-monitor:getState')
|
||||||
|
await api.setGroupExitMonitorEnabled(false)
|
||||||
|
expect(invoke).toHaveBeenLastCalledWith('group-exit-monitor:setEnabled', false)
|
||||||
await api.setGroupExitMonitorGroups(['room@chatroom'])
|
await api.setGroupExitMonitorGroups(['room@chatroom'])
|
||||||
expect(invoke).toHaveBeenLastCalledWith('group-exit-monitor:setGroups', ['room@chatroom'])
|
expect(invoke).toHaveBeenLastCalledWith('group-exit-monitor:setGroups', ['room@chatroom'])
|
||||||
await api.setGroupExitMonitorGroups(['room@chatroom'], ['room@chatroom'])
|
await api.setGroupExitMonitorGroups(['room@chatroom'], ['room@chatroom'])
|
||||||
@@ -85,6 +87,8 @@ describe('preload IPC contract', () => {
|
|||||||
expect(invoke).toHaveBeenLastCalledWith('group-exit-monitor:clearEvents')
|
expect(invoke).toHaveBeenLastCalledWith('group-exit-monitor:clearEvents')
|
||||||
await api.markGroupExitMonitorRead(123)
|
await api.markGroupExitMonitorRead(123)
|
||||||
expect(invoke).toHaveBeenLastCalledWith('group-exit-monitor:markRead', 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', {
|
await api.getImage('fixture-md5', 'fixture.dat', 'fixture-session', {
|
||||||
force: true,
|
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)
|
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 () => {
|
it('uses the Session nickname when establishing a new group baseline', async () => {
|
||||||
installGroupDb()
|
installGroupDb()
|
||||||
const callOrder: string[] = []
|
const callOrder: string[] = []
|
||||||
|
|||||||
@@ -6,6 +6,21 @@ function setPrivate(target: object, key: string, value: unknown): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('Wcdb4Client shutdown', () => {
|
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', () => {
|
it('clears folded contact status snapshots when sessions are invalidated', () => {
|
||||||
const client = Object.create(Wcdb4Client.prototype) as Wcdb4Client
|
const client = Object.create(Wcdb4Client.prototype) as Wcdb4Client
|
||||||
const statusCache = new Map([['group@chatroom', { isFolded: true, isMuted: false }]])
|
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
|
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 { PersonalWechatSendCapability } from '../../src/shared/personal-wechat'
|
||||||
import type { WechatActionResult } from '../../src/shared/wechat-action'
|
import type { WechatActionResult } from '../../src/shared/wechat-action'
|
||||||
|
|
||||||
@@ -231,4 +234,145 @@ describe('WechatActionGateway', () => {
|
|||||||
reason: 'connector timeout'
|
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