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:
+82
-18
@@ -113,6 +113,10 @@ import { agentHubService } from './services/agent-hub-service'
|
||||
import { WechatConnectorService } from './services/wechat-ilink'
|
||||
import { wechatSendGateway } from './services/wechat-send-gateway'
|
||||
import { groupExitMonitorService } from './services/group-exit-monitor-service'
|
||||
import {
|
||||
filterSendableFriendContacts,
|
||||
leaveNotificationContactDisplayName
|
||||
} from './services/leave-notification-target'
|
||||
import { MessageListenerService } from './services/message-listener-service'
|
||||
import { automationRuleStore } from './services/automation-rule-store'
|
||||
import { automationExecutionLogService } from './services/automation-execution-log-service'
|
||||
@@ -1043,6 +1047,32 @@ app.whenReady().then(async () => {
|
||||
*/
|
||||
let automationListening = false
|
||||
initAutomationService(wcdb4Client, { isListening: () => automationListening })
|
||||
/**
|
||||
* 退群通知的唯一通路:**退群监控只负责产生事件,自动化负责发送**。
|
||||
*
|
||||
* `handleGroupExit` 自己吞掉异常,这里再兜一层 `.catch` 是防御性的 ——
|
||||
* 未捕获的 rejection 会污染监控循环,而退群事实早就已经记录成功了。
|
||||
*/
|
||||
groupExitMonitorService.setGroupExitHandler((event) => {
|
||||
try {
|
||||
return getAutomationService()
|
||||
.handleGroupExit(event)
|
||||
.catch((error) => {
|
||||
console.warn(
|
||||
`[Automation] handleGroupExit failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
)
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[Automation] 退群通知未初始化,已跳过本次事件: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
messageListener.onMessage((message) => {
|
||||
// 日志只允许出现「类型 / 群或私聊 / 是否自己发 / @ 数量」这类不可逆标识,
|
||||
// 不含 wxid、昵称、群名、正文与 source。
|
||||
@@ -1623,16 +1653,14 @@ app.whenReady().then(async () => {
|
||||
ipcMain.handle('group-exit-monitor:setEnabled', (_, enabled: boolean) =>
|
||||
groupExitMonitorService.setEnabled(enabled === true)
|
||||
)
|
||||
ipcMain.handle(
|
||||
'group-exit-monitor:setGroups',
|
||||
(_, roomIds: string[], notificationRoomIds?: string[]) =>
|
||||
groupExitMonitorService.setMonitoredRoomIds(
|
||||
Array.isArray(roomIds) ? roomIds : [],
|
||||
Array.isArray(notificationRoomIds) ? notificationRoomIds : []
|
||||
)
|
||||
)
|
||||
ipcMain.handle('group-exit-monitor:setTemplate', (_, template: unknown) =>
|
||||
groupExitMonitorService.setNotificationTemplate(template)
|
||||
/**
|
||||
* 保存**监控范围**。
|
||||
*
|
||||
* 只有一个参数:旧版第二个参数(通知群聊)已经迁到「自动化 → 退群通知」,
|
||||
* 退群监控不再持有任何通知配置。
|
||||
*/
|
||||
ipcMain.handle('group-exit-monitor:setGroups', (_, roomIds: string[]) =>
|
||||
groupExitMonitorService.setMonitoredRoomIds(Array.isArray(roomIds) ? roomIds : [])
|
||||
)
|
||||
ipcMain.handle('group-exit-monitor:checkNow', () => groupExitMonitorService.checkNow())
|
||||
/**
|
||||
@@ -1655,9 +1683,6 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
})
|
||||
ipcMain.handle('group-exit-monitor:clearEvents', () => groupExitMonitorService.clearEvents())
|
||||
ipcMain.handle('group-exit-monitor:resendEvent', (_, eventId: string) =>
|
||||
groupExitMonitorService.resendEvent(eventId)
|
||||
)
|
||||
ipcMain.handle('group-exit-monitor:markRead', (_, readAt?: number) =>
|
||||
groupExitMonitorService.markRead(readAt)
|
||||
)
|
||||
@@ -1698,6 +1723,35 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
ipcMain.handle('automation:clearExecutions', () => automationExecutionLogService.clear())
|
||||
ipcMain.handle('automation:listGroups', () => tryAutomationService()?.listGroups() ?? [])
|
||||
/**
|
||||
* 保存「退群通知」规则(singleton upsert)。
|
||||
*
|
||||
* 单独一个通道而不是复用 `updateRule`:这条规则是系统规则,
|
||||
* 保存语义是"存在即更新、不存在即创建",且 id 固定 —— 不允许渲染层自己拼 id。
|
||||
*/
|
||||
ipcMain.handle('automation:saveLeaveNotificationRule', (_, draft: unknown) =>
|
||||
automationRuleStore.saveLeaveNotificationRule(draft)
|
||||
)
|
||||
/**
|
||||
* 「指定好友」的可选项。
|
||||
*
|
||||
* 过滤(群聊 / 公众号 / 文件传输助手 / 自己)在 main 侧完成 ——
|
||||
* 只有这里知道当前登录账号是谁,渲染层再筛一遍必然会漂移。
|
||||
*/
|
||||
ipcMain.handle('automation:listSendableContacts', () => {
|
||||
try {
|
||||
const selfWxid = String(chat.getSelfAccountInfo()?.wxid || '')
|
||||
return filterSendableFriendContacts(chat.listContacts(), selfWxid).map((contact) => ({
|
||||
id: String(contact.m_nsUsrName || ''),
|
||||
name: leaveNotificationContactDisplayName(contact)
|
||||
}))
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[Automation] 读取可选联系人失败: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
return []
|
||||
}
|
||||
})
|
||||
ipcMain.handle('automation:getStatus', async () => {
|
||||
const service = tryAutomationService()
|
||||
if (service) return service.getStatus()
|
||||
@@ -2715,11 +2769,21 @@ app.on('before-quit', (event) => {
|
||||
chat.closeChatDbForQuit().catch(() => false),
|
||||
voiceRecognition?.dispose().catch(() => undefined),
|
||||
knowledgeSearchService?.dispose().catch(() => undefined),
|
||||
process.platform === 'darwin'
|
||||
? macWechatRuntimeManager.shutdown().catch((error) => {
|
||||
console.warn('[Shutdown] macOS native WeChat runtime cleanup failed:', error)
|
||||
})
|
||||
: Promise.resolve()
|
||||
/*
|
||||
* Deliberately do NOT stop the macOS native runtime here.
|
||||
*
|
||||
* The host is designed to outlive TraceMemo ("保留发送能力进程"): keeping
|
||||
* it alive preserves the bound frida session, so relaunching the app —
|
||||
* including every dev-mode restart — re-adopts it and works immediately
|
||||
* instead of forcing the user to bind WeChat again.
|
||||
*
|
||||
* Stopping it on quit threw that away and, worse, gave teardown a chance
|
||||
* to hang mid-unload while the app was already exiting. The host has its
|
||||
* own lifecycle instead: it exits itself once WeChat is gone ("stale" ->
|
||||
* self-shutdown), and it can be restarted explicitly from the settings
|
||||
* card ("重新加载组件").
|
||||
*/
|
||||
Promise.resolve()
|
||||
])
|
||||
if (!nativeCallsDrained) {
|
||||
console.warn('[Shutdown] WCDB async calls did not fully drain before quit')
|
||||
|
||||
@@ -8,8 +8,13 @@ import {
|
||||
type AutomationAction,
|
||||
type AutomationRule,
|
||||
type AutomationStep,
|
||||
type AutomationStepKey
|
||||
type AutomationStepKey,
|
||||
type LeaveNotificationConfig
|
||||
} from '../../shared/automation'
|
||||
import {
|
||||
renderLeaveNotificationText,
|
||||
type GroupMemberExitedEvent
|
||||
} from '../../shared/group-exit-event'
|
||||
import type { WechatActionContent, WechatActionRequest, WechatActionResult } from '../../shared/wechat-action'
|
||||
import {
|
||||
generateAgentGroupReport,
|
||||
@@ -17,6 +22,7 @@ import {
|
||||
type AgentGroupReportResult
|
||||
} from './agent-group-report-service'
|
||||
import { wechatActionGateway } from './wechat-action-gateway'
|
||||
import type { LeaveNotificationTargetResolution } from './leave-notification-target'
|
||||
|
||||
/**
|
||||
* AutomationActionRunner —— 把一次命中跑成**步骤序列**。
|
||||
@@ -32,6 +38,14 @@ import { wechatActionGateway } from './wechat-action-gateway'
|
||||
/** 一步执行完毕后,后续步骤的处置方式。 */
|
||||
const STEP_ORDER: AutomationStepKey[] = ['received', 'matched', 'reply', 'report', 'send']
|
||||
|
||||
/** 退群通知的步骤顺序(与消息型规则**不共用**)。 */
|
||||
const LEAVE_NOTIFICATION_STEP_ORDER: AutomationStepKey[] = [
|
||||
'exit_received',
|
||||
'exit_matched',
|
||||
'exit_target',
|
||||
'exit_send'
|
||||
]
|
||||
|
||||
/**
|
||||
* 前置步骤失败时,后续步骤的 `skipReason`。
|
||||
*
|
||||
@@ -65,6 +79,30 @@ export interface AutomationRunResult {
|
||||
pngPath?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 退群通知的执行输入。
|
||||
*
|
||||
* 目标解析与发送能力预检都由 `AutomationService` 在调用前完成,
|
||||
* 这里只负责"把它跑成步骤序列" —— 于是**所有步骤构造只在一处**,
|
||||
* 不会出现"服务拼一半、runner 拼一半"的裂口。
|
||||
*/
|
||||
export interface LeaveNotificationRunInput {
|
||||
executionId: string
|
||||
event: GroupMemberExitedEvent
|
||||
config: LeaveNotificationConfig
|
||||
resolution: LeaveNotificationTargetResolution
|
||||
/** 事件来源的显示名(群名 / 「群聊」)。**不含 wxid**。 */
|
||||
sourceDisplayName: string
|
||||
/** 发送能力缺失时的一句话说明;有值时 `exit_send` 直接判失败。 */
|
||||
sendBlockedReason?: string
|
||||
}
|
||||
|
||||
export interface LeaveNotificationRunResult {
|
||||
steps: AutomationStep[]
|
||||
status: 'success' | 'failed'
|
||||
errorSummary?: string
|
||||
}
|
||||
|
||||
export interface AutomationActionRunnerDependencies {
|
||||
generateReport?: (request: AgentGroupReportRequest) => Promise<AgentGroupReportResult>
|
||||
executeAction?: (request: WechatActionRequest) => Promise<WechatActionResult>
|
||||
@@ -235,6 +273,93 @@ export class AutomationActionRunner {
|
||||
return { steps, status: 'success', pngPath }
|
||||
}
|
||||
|
||||
/**
|
||||
* 退群通知:把「检测到成员退出」这件事跑成一次发送。
|
||||
*
|
||||
* 与 `run()` 的差别:没有动作链、没有回复等待、没有 cooldown
|
||||
* (退群是低频事件,且**不能被时间窗合并** —— 两个成员先后退出就是两条通知)。
|
||||
*/
|
||||
async runLeaveNotification(input: LeaveNotificationRunInput): Promise<LeaveNotificationRunResult> {
|
||||
const steps = LEAVE_NOTIFICATION_STEP_ORDER.map((key) => createStep(key))
|
||||
const stepAt = (key: AutomationStepKey): AutomationStep =>
|
||||
steps.find((step) => step.key === key) as AutomationStep
|
||||
|
||||
markSuccess(stepAt('exit_received'), this.now())
|
||||
markSuccess(stepAt('exit_matched'), this.now())
|
||||
|
||||
// ---- 目标解析失败:不许 fallback 到任何地方,直接判失败 ----
|
||||
if (!input.resolution.ok) {
|
||||
markFailed(stepAt('exit_target'), this.now(), input.resolution.error)
|
||||
markSkipped(stepAt('exit_send'), '没有可用的通知目标,未发送。')
|
||||
return { steps, status: 'failed', errorSummary: input.resolution.error }
|
||||
}
|
||||
const targetStep = stepAt('exit_target')
|
||||
targetStep.status = 'success'
|
||||
targetStep.startedAt = this.now()
|
||||
targetStep.finishedAt = targetStep.startedAt
|
||||
targetStep.durationMs = 0
|
||||
// 用户可读的目标名(例如「文件传输助手」「张三」「我」)—— 不带任何 id。
|
||||
targetStep.detail = input.resolution.target.displayName
|
||||
|
||||
const sendStep = stepAt('exit_send')
|
||||
|
||||
// ---- 发送能力缺失:如实判失败(退群事实本身仍然是成功的) ----
|
||||
if (input.sendBlockedReason) {
|
||||
markFailed(sendStep, this.now(), input.sendBlockedReason)
|
||||
return { steps, status: 'failed', errorSummary: input.sendBlockedReason }
|
||||
}
|
||||
|
||||
const text = renderLeaveNotificationText(input.event, input.config.template).trim()
|
||||
if (!text) {
|
||||
const error = '通知内容为空,未发送。'
|
||||
markFailed(sendStep, this.now(), error)
|
||||
return { steps, status: 'failed', errorSummary: error }
|
||||
}
|
||||
|
||||
sendStep.status = 'running'
|
||||
sendStep.startedAt = this.now()
|
||||
const sent = await this.sendLeaveNotification(input, text)
|
||||
if (!sent.ok) {
|
||||
markFailed(sendStep, this.now(), sent.error || '发送退群通知失败')
|
||||
return { steps, status: 'failed', errorSummary: sendStep.error }
|
||||
}
|
||||
markSuccess(sendStep, this.now())
|
||||
return { steps, status: 'success' }
|
||||
}
|
||||
|
||||
/**
|
||||
* 退群通知的统一发送出口。
|
||||
*
|
||||
* **必须走 `WechatActionGateway`**:幂等 + 3 秒发送间隔 + 审计落盘都在那里,
|
||||
* 直接调个人微信发送会绕过全部三样。Automation 层也不允许知道
|
||||
* OneBot / WCHook / native host / Windows hook 的存在。
|
||||
*
|
||||
* 幂等键由 **eventId** 派生(不是 executionId):审计是落盘的,
|
||||
* 于是"重启后重复投递同一退群事件"会被持久层直接短路。
|
||||
*/
|
||||
private async sendLeaveNotification(
|
||||
input: LeaveNotificationRunInput,
|
||||
text: string
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
if (!input.resolution.ok) return { ok: false, error: input.resolution.error }
|
||||
try {
|
||||
const result = await this.executeAction({
|
||||
idempotencyKey: `${AUTOMATION_SEND_PURPOSE.leaveNotification}:${input.event.eventId}`,
|
||||
origin: AUTOMATION_SEND_ORIGIN,
|
||||
purpose: AUTOMATION_SEND_PURPOSE.leaveNotification,
|
||||
triggerType: 'automation',
|
||||
executionId: input.executionId,
|
||||
sourceId: input.event.eventId,
|
||||
recipient: input.resolution.target.recipient,
|
||||
content: { type: 'text', text }
|
||||
})
|
||||
if (result.status === 'sent') return { ok: true }
|
||||
return { ok: false, error: actionErrorMessage(result.errorCode, result.reason) }
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一发送出口。
|
||||
*
|
||||
|
||||
@@ -3,10 +3,22 @@ import path from 'node:path'
|
||||
import { app } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import {
|
||||
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
|
||||
createDefaultDailyReportRule,
|
||||
createDefaultLeaveNotificationRule,
|
||||
normalizeRuleDraft,
|
||||
type AutomationRule
|
||||
type AutomationRule,
|
||||
type AutomationRuleType
|
||||
} from '../../shared/automation'
|
||||
import {
|
||||
planLeaveNotificationMigration,
|
||||
type LeaveNotificationMigrationPlan,
|
||||
type LegacyLeaveNotificationState
|
||||
} from './leave-notification-migration'
|
||||
import {
|
||||
GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH,
|
||||
insertGroupNamePlaceholder
|
||||
} from '../../shared/group-exit-monitor'
|
||||
|
||||
/**
|
||||
* AutomationRuleStore —— 自动化规则的持久化与增删改查。
|
||||
@@ -17,15 +29,31 @@ import {
|
||||
* **内置规则的播种标记(`builtinSeeded`)单独存**:
|
||||
* 如果靠"文件里有没有那条内置规则"来判断是否播种,用户一旦删掉它,
|
||||
* 下次启动就会被重新塞回来 —— 用户会认为删除功能坏了。
|
||||
*
|
||||
* 退群通知的迁移标记(`leaveNotificationMigrated`)同理:迁移**只允许跑一次**,
|
||||
* 否则用户删掉退群通知规则后重启又被塞回来。
|
||||
*/
|
||||
|
||||
const STORAGE_DIR = 'automation'
|
||||
const RULES_FILE = 'rules.json'
|
||||
const MIGRATION_BACKUP_FILE = 'leave-notification-migration-backup.json'
|
||||
/** 旧退群监控的状态文件(迁移时读一次,之后运行期不再读)。 */
|
||||
const LEGACY_MONITOR_FILE = 'group-exit-monitor.json'
|
||||
const CURRENT_VERSION = 3
|
||||
|
||||
interface StoredRules {
|
||||
version: number
|
||||
/** 内置规则是否已经播种过。**即使随后被删除也保持 true**。 */
|
||||
/** 内置「@我生成日报」是否已经播种过。**即使随后被删除也保持 true**。 */
|
||||
builtinSeeded: boolean
|
||||
/** 旧退群通知配置是否已经迁移过。**即使随后被删除也保持 true**。 */
|
||||
leaveNotificationMigrated: boolean
|
||||
/**
|
||||
* 是否已经替用户往模板里补过 `{groupName}`。
|
||||
*
|
||||
* **必须有这个标记**:没有它就得靠"模板里有没有 `{groupName}`"来判断,
|
||||
* 于是用户主动删掉那一行后、下次启动又会被补回来 —— 删不掉的东西最烦人。
|
||||
*/
|
||||
notificationTemplateUpgraded: boolean
|
||||
rules: AutomationRule[]
|
||||
}
|
||||
|
||||
@@ -35,22 +63,95 @@ export interface AutomationRuleStoreDependencies {
|
||||
}
|
||||
|
||||
function emptyState(): StoredRules {
|
||||
return { version: 1, builtinSeeded: false, rules: [] }
|
||||
return {
|
||||
version: CURRENT_VERSION,
|
||||
builtinSeeded: false,
|
||||
leaveNotificationMigrated: false,
|
||||
notificationTemplateUpgraded: false,
|
||||
rules: []
|
||||
}
|
||||
}
|
||||
|
||||
/** 读盘容错:任何字段可疑都降级成安全值,绝不因为一个坏文件让功能整体不可用。 */
|
||||
function normalizeStored(value: unknown): StoredRules {
|
||||
function normalizeRuleType(value: unknown): AutomationRuleType {
|
||||
return value === 'leave_notification' ? 'leave_notification' : 'daily_report'
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条规则的读盘归一化。
|
||||
*
|
||||
* 走 `normalizeRuleDraft` —— 它覆盖了 `AutomationRule` 除 id / 时间戳之外的**全部**字段,
|
||||
* 所以这是无损的,同时自动补上历史 rules.json 缺失的 `ruleType` / `leaveNotification`。
|
||||
*/
|
||||
function normalizeStoredRule(value: unknown): AutomationRule | null {
|
||||
if (!value || typeof value !== 'object') return null
|
||||
const raw = value as Partial<AutomationRule>
|
||||
const id = String(raw.id || '').trim()
|
||||
if (!id) return null
|
||||
const ruleType = normalizeRuleType(raw.ruleType)
|
||||
const normalized = normalizeRuleDraft(
|
||||
{
|
||||
...raw,
|
||||
ruleType,
|
||||
// 历史 daily_report 规则没有 leaveNotification;反过来也要避免脏字段落盘。
|
||||
...(ruleType === 'leave_notification' ? { leaveNotification: raw.leaveNotification } : {})
|
||||
},
|
||||
String(raw.name || '').trim() || '未命名自动化'
|
||||
)
|
||||
const createdAt = Number(raw.createdAt) || 0
|
||||
const updatedAt = Number(raw.updatedAt) || createdAt
|
||||
return {
|
||||
...normalized,
|
||||
id,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
...(normalized.ruleType === 'leave_notification' && normalized.leaveNotification
|
||||
? { leaveNotification: normalized.leaveNotification }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次性替用户把 `{groupName}` 补进退群通知模板。
|
||||
*
|
||||
* **为什么替他填**:模板里原先根本没有群名变量(`{groupRemark}` 是成员在本群的昵称)。
|
||||
* 目标一旦不是「当前群聊」,通知就等于「张三退群了」—— 收件人不知道是哪个群。
|
||||
* 模板是用户已保存的数据,让他自己去发现"少一个变量"是不合理的。
|
||||
*
|
||||
* **为什么只跑一次**:靠"模板里有没有 `{groupName}`"判断会导致用户删掉后被反复补回来。
|
||||
* 所以由 `notificationTemplateUpgraded` 标记保证一次性;用户之后删掉不会回来。
|
||||
*/
|
||||
function upgradeLeaveNotificationTemplate(rules: AutomationRule[]): {
|
||||
rules: AutomationRule[]
|
||||
count: number
|
||||
} {
|
||||
let count = 0
|
||||
const next = rules.map((rule) => {
|
||||
if (rule.ruleType !== 'leave_notification' || !rule.leaveNotification) return rule
|
||||
const current = rule.leaveNotification.template
|
||||
const upgraded = insertGroupNamePlaceholder(current)
|
||||
// 补完不能超长:超了下次读盘会被校验拦下、整条模板被重置成默认,反而更糟。
|
||||
if (upgraded === current || upgraded.length > GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH) {
|
||||
return rule
|
||||
}
|
||||
count += 1
|
||||
return { ...rule, leaveNotification: { ...rule.leaveNotification, template: upgraded } }
|
||||
})
|
||||
return { rules: next, count }
|
||||
}
|
||||
|
||||
/** 读盘容错:任何字段可疑都降级成安全值,绝不因为一个坏文件让功能整体不可用。 */function normalizeStored(value: unknown): StoredRules {
|
||||
if (!value || typeof value !== 'object') return emptyState()
|
||||
const input = value as Partial<StoredRules>
|
||||
const rules = Array.isArray(input.rules)
|
||||
? input.rules.filter(
|
||||
(rule): rule is AutomationRule =>
|
||||
Boolean(rule && typeof rule === 'object' && String(rule.id || '').trim())
|
||||
)
|
||||
? input.rules
|
||||
.map((rule) => normalizeStoredRule(rule))
|
||||
.filter((rule): rule is AutomationRule => rule !== null)
|
||||
: []
|
||||
return {
|
||||
version: Number(input.version) || 1,
|
||||
builtinSeeded: input.builtinSeeded === true,
|
||||
leaveNotificationMigrated: input.leaveNotificationMigrated === true,
|
||||
notificationTemplateUpgraded: input.notificationTemplateUpgraded === true,
|
||||
rules
|
||||
}
|
||||
}
|
||||
@@ -60,6 +161,8 @@ export class AutomationRuleStore {
|
||||
private readonly now: () => number
|
||||
private state: StoredRules = emptyState()
|
||||
private loaded = false
|
||||
/** 迁移结果,供启动日志/报告读取(不含任何 id)。 */
|
||||
private lastMigrationPlan: LeaveNotificationMigrationPlan | null = null
|
||||
|
||||
constructor(dependencies: AutomationRuleStoreDependencies = {}) {
|
||||
this.userDataPath = dependencies.userDataPath ?? (() => app.getPath('userData'))
|
||||
@@ -78,6 +181,12 @@ export class AutomationRuleStore {
|
||||
return found
|
||||
}
|
||||
|
||||
/** 上一次迁移的判定结果(未迁移时为 null)。 */
|
||||
getLastLeaveNotificationMigration(): LeaveNotificationMigrationPlan | null {
|
||||
this.ensureLoaded()
|
||||
return this.lastMigrationPlan ? { ...this.lastMigrationPlan } : null
|
||||
}
|
||||
|
||||
createRule(draft: unknown): AutomationRule {
|
||||
this.ensureLoaded()
|
||||
const timestamp = this.now()
|
||||
@@ -105,14 +214,51 @@ export class AutomationRuleStore {
|
||||
...current,
|
||||
...normalized,
|
||||
id: current.id,
|
||||
// ruleType 不允许被草稿改掉:它是规则的**身份**,不是可编辑字段。
|
||||
ruleType: current.ruleType,
|
||||
...(current.ruleType === 'leave_notification' && normalized.leaveNotification
|
||||
? { leaveNotification: normalized.leaveNotification }
|
||||
: {}),
|
||||
createdAt: current.createdAt,
|
||||
updatedAt: this.now()
|
||||
}
|
||||
// 切类型时不能留下另一种类型才认识的字段。
|
||||
if (rule.ruleType === 'daily_report') delete rule.leaveNotification
|
||||
this.state.rules = this.state.rules.map((item, at) => (at === index ? rule : item))
|
||||
this.persist()
|
||||
return structuredClone(rule)
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存「退群通知」规则(**singleton upsert**)。
|
||||
*
|
||||
* 不存在则创建(固定 id),存在则更新 —— 所以点多少次保存都只有一条规则。
|
||||
*/
|
||||
saveLeaveNotificationRule(draft: unknown): AutomationRule {
|
||||
this.ensureLoaded()
|
||||
const existing = this.state.rules.find(
|
||||
(rule) => rule.id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID
|
||||
)
|
||||
if (existing) {
|
||||
const updated = this.updateRule(existing.id, { ...(draft as object), ruleType: 'leave_notification' })
|
||||
if (updated) return updated
|
||||
}
|
||||
const timestamp = this.now()
|
||||
const normalized = normalizeRuleDraft({ ...(draft as object), ruleType: 'leave_notification' })
|
||||
const rule: AutomationRule = {
|
||||
...createDefaultLeaveNotificationRule(timestamp),
|
||||
...normalized,
|
||||
id: BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
|
||||
ruleType: 'leave_notification',
|
||||
name: normalized.name || '退群通知',
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp
|
||||
}
|
||||
this.state.rules = [...this.state.rules, rule]
|
||||
this.persist()
|
||||
return structuredClone(rule)
|
||||
}
|
||||
|
||||
deleteRule(id: string): boolean {
|
||||
this.ensureLoaded()
|
||||
const key = String(id || '').trim()
|
||||
@@ -142,10 +288,123 @@ export class AutomationRuleStore {
|
||||
stored.builtinSeeded = true
|
||||
stored.rules = [...stored.rules, createDefaultDailyReportRule(this.now())]
|
||||
}
|
||||
if (!stored.leaveNotificationMigrated) {
|
||||
stored.leaveNotificationMigrated = true
|
||||
stored.rules = this.migrateLeaveNotification(stored.rules)
|
||||
}
|
||||
if (!stored.notificationTemplateUpgraded) {
|
||||
stored.notificationTemplateUpgraded = true
|
||||
const upgraded = upgradeLeaveNotificationTemplate(stored.rules)
|
||||
stored.rules = upgraded.rules
|
||||
// 只记条数,**不记模板正文**。
|
||||
if (upgraded.count > 0) {
|
||||
console.log(`[Automation] 退群通知模板已补上群名变量(${upgraded.count} 条)`)
|
||||
}
|
||||
}
|
||||
stored.version = CURRENT_VERSION
|
||||
this.state = stored
|
||||
this.persist()
|
||||
}
|
||||
|
||||
/** 旧退群通知配置 → 内置退群通知规则。**只跑一次**,且先备份旧配置。 */
|
||||
private migrateLeaveNotification(rules: AutomationRule[]): AutomationRule[] {
|
||||
if (rules.some((rule) => rule.id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID)) return rules
|
||||
const timestamp = this.now()
|
||||
const legacy = this.readLegacyLeaveNotificationState()
|
||||
if (!legacy) {
|
||||
// 全新安装:没有历史可迁,直接建默认规则(默认目标:文件传输助手)。
|
||||
this.logMigration('fresh_install', null)
|
||||
return [...rules, createDefaultLeaveNotificationRule(timestamp)]
|
||||
}
|
||||
const plan = planLeaveNotificationMigration(legacy)
|
||||
this.lastMigrationPlan = plan
|
||||
this.writeMigrationBackup(legacy, plan)
|
||||
this.logMigration(plan.outcome, plan)
|
||||
return [
|
||||
...rules,
|
||||
createDefaultLeaveNotificationRule(timestamp, {
|
||||
template: plan.template,
|
||||
target: plan.target,
|
||||
enabled: plan.enabled,
|
||||
targetNeedsReview: plan.targetNeedsReview
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* 读旧退群监控状态文件里的通知相关字段。
|
||||
*
|
||||
* 只读、只在这里读一次;运行期其余代码**不再读** legacy(避免双读)。
|
||||
* 读失败视为"没有历史配置",不抛。
|
||||
*/
|
||||
private readLegacyLeaveNotificationState(): LegacyLeaveNotificationState | null {
|
||||
try {
|
||||
const raw = fs.readJsonSync(
|
||||
path.join(this.userDataPath(), LEGACY_MONITOR_FILE)
|
||||
) as Partial<LegacyLeaveNotificationState>
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
return {
|
||||
monitoredRoomIds: Array.isArray(raw.monitoredRoomIds) ? raw.monitoredRoomIds : [],
|
||||
notificationRoomIds: Array.isArray(raw.notificationRoomIds) ? raw.notificationRoomIds : [],
|
||||
...(raw.notificationTemplate !== undefined
|
||||
? { notificationTemplate: raw.notificationTemplate }
|
||||
: {})
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 迁移前把旧配置原样落一份 backup。
|
||||
*
|
||||
* §「不要不可逆直接覆盖」:旧状态文件本身也**不删**,只是不再被运行时代码读取。
|
||||
*/
|
||||
private writeMigrationBackup(
|
||||
legacy: LegacyLeaveNotificationState,
|
||||
plan: LeaveNotificationMigrationPlan
|
||||
): void {
|
||||
try {
|
||||
const filePath = path.join(this.userDataPath(), STORAGE_DIR, MIGRATION_BACKUP_FILE)
|
||||
fs.ensureDirSync(path.dirname(filePath))
|
||||
fs.writeJsonSync(
|
||||
filePath,
|
||||
{
|
||||
migratedAt: this.now(),
|
||||
outcome: plan.outcome,
|
||||
// 备份里保留原始 roomId:这是**用户数据**,不是日志,不进任何用户可见界面。
|
||||
legacy: {
|
||||
monitoredRoomIds: legacy.monitoredRoomIds,
|
||||
notificationRoomIds: legacy.notificationRoomIds,
|
||||
notificationTemplate: legacy.notificationTemplate
|
||||
},
|
||||
applied: {
|
||||
enabled: plan.enabled,
|
||||
targetType: plan.target.type,
|
||||
targetNeedsReview: plan.targetNeedsReview
|
||||
}
|
||||
},
|
||||
{ spaces: 2 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[Automation] 写入退群通知迁移备份失败: ${errorText(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 迁移日志:只允许出现判定结果与布尔量,**禁止** roomId / 模板正文。 */
|
||||
private logMigration(outcome: string, plan: LeaveNotificationMigrationPlan | null): void {
|
||||
if (!plan) {
|
||||
console.log(`[Automation] 退群通知迁移 outcome=${outcome}`)
|
||||
return
|
||||
}
|
||||
console.log(
|
||||
`[Automation] 退群通知迁移 outcome=${outcome} enabled=${plan.enabled}` +
|
||||
` targetType=${plan.target.type} needsReview=${plan.targetNeedsReview}`
|
||||
)
|
||||
}
|
||||
|
||||
private rulesFilePath(): string {
|
||||
return path.join(this.userDataPath(), STORAGE_DIR, RULES_FILE)
|
||||
}
|
||||
@@ -163,4 +422,8 @@ export class AutomationRuleStore {
|
||||
}
|
||||
}
|
||||
|
||||
function errorText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
export const automationRuleStore = new AutomationRuleStore()
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import {
|
||||
matchAutomationRule,
|
||||
type AutomationMatchInput
|
||||
} from '../../shared/automation-matcher'
|
||||
import type {
|
||||
AutomationExecution,
|
||||
AutomationRule,
|
||||
AutomationStatusSummary
|
||||
import {
|
||||
AUTOMATION_SEND_PURPOSE,
|
||||
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
|
||||
type AutomationExecution,
|
||||
type AutomationRule,
|
||||
type AutomationStatusSummary
|
||||
} from '../../shared/automation'
|
||||
import type { GroupMemberExitedEvent } from '../../shared/group-exit-event'
|
||||
import type { PersonalWechatSendCapability } from '../../shared/personal-wechat'
|
||||
import type { Wcdb4Client } from '../wcdb4-client'
|
||||
import type { NormalizedIncomingMessage } from './message-listener-service'
|
||||
import { listContacts } from './chat-service'
|
||||
import { getSelfAccountInfo, listContacts } from './chat-service'
|
||||
import { getPersonalWechatSendCapability } from './personal-wechat-capability-service'
|
||||
import {
|
||||
resolveLeaveNotificationTarget,
|
||||
type LeaveNotificationTargetResolution
|
||||
} from './leave-notification-target'
|
||||
import {
|
||||
automationRuleStore,
|
||||
type AutomationRuleStore
|
||||
@@ -80,6 +87,37 @@ const CLAIM_MAX_ENTRIES = 5_000
|
||||
/** 自己的 username 候选集缓存时长 —— 这个值几乎不变,没必要每条消息都查。 */
|
||||
const SELF_USERNAME_TTL_MS = 30_000
|
||||
|
||||
/**
|
||||
* 退群事件幂等表的容量上限。
|
||||
*
|
||||
* 退群是低频事件,这里不需要 MessageListener 那种 5000 级容量;
|
||||
* 但同样要有界,避免长跑期间无界增长。
|
||||
*/
|
||||
const EXIT_CLAIM_MAX_ENTRIES = 1_000
|
||||
|
||||
/**
|
||||
* 由 `ruleId + eventId` 推出**确定性** executionId。
|
||||
*
|
||||
* 为什么要确定而不是 `randomUUID()`:幂等必须跨进程重启成立。
|
||||
* 同一 eventId 重新投递时算出同一个 executionId,`ExecutionLog.record()` 按 id 覆盖,
|
||||
* 于是"同一事件只产生一条 execution"在重启后依然成立。
|
||||
*/
|
||||
export function leaveNotificationExecutionId(ruleId: string, eventId: string): string {
|
||||
return createHash('sha1')
|
||||
.update(`leave_notification\u0001${ruleId}\u0001${eventId}`)
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* 退群通知的发送幂等键。
|
||||
*
|
||||
* **必须由 eventId 派生**(而不是 executionId 派生):gateway 的审计是落盘的,
|
||||
* 用 eventId 才能让"重启后重复投递同一事件"被持久层直接短路掉。
|
||||
*/
|
||||
export function leaveNotificationIdempotencyKey(eventId: string): string {
|
||||
return `${AUTOMATION_SEND_PURPOSE.leaveNotification}:${eventId}`
|
||||
}
|
||||
|
||||
export interface AutomationServiceDependencies {
|
||||
ruleStore?: AutomationRuleStore
|
||||
executionLog?: AutomationExecutionLogService
|
||||
@@ -106,6 +144,13 @@ export class AutomationService {
|
||||
|
||||
/** `ruleId:sessionId:localId` → 登记时间。 */
|
||||
private readonly claims = new Map<string, ClaimEntry>()
|
||||
/**
|
||||
* 退群事件幂等表:`ruleId:eventId` → 登记时间。
|
||||
*
|
||||
* 与消息型 `claims` 分开:退群事件没有 `localId` 这个概念,
|
||||
* 硬塞进同一张表会让两边的 key 语义混在一起。
|
||||
*/
|
||||
private readonly exitClaims = new Map<string, ClaimEntry>()
|
||||
/**
|
||||
* Automation rule ↔ conversation gate。
|
||||
*
|
||||
@@ -247,8 +292,149 @@ export class AutomationService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 「在哪些聊天生效」的可选项。`id` 必须是 `xxx@chatroom`,与 message.sessionId 对齐。 */
|
||||
listGroups(): Array<{ id: string; name: string }> {
|
||||
/**
|
||||
* 退群事件入口。**绝不抛** —— 它挂在 `GroupExitMonitorService` 的回调上,
|
||||
* 抛出去会污染监控循环(退群事实已经记录成功,不能被通知失败连累)。
|
||||
*
|
||||
* 与 `handleMessage` 的分工:
|
||||
* - 消息型规则:MessageListener → handleMessage → 动作链;
|
||||
* - 退群通知:GroupExitMonitor → **本入口** → 单次文本发送。
|
||||
*
|
||||
* 三层闸门(缺一不可):
|
||||
* 1. `enabled === false` → 不执行,**且不创建 execution**(§「没有执行:不创建」);
|
||||
* 2. `targetNeedsReview` → 迁移过来但目标无法无损映射,同样不执行、不创建记录;
|
||||
* 3. `ruleId + eventId` 幂等 → 同一事件即使重复投递也只发送一次。
|
||||
*/
|
||||
async handleGroupExit(event: GroupMemberExitedEvent): Promise<void> {
|
||||
const eventId = String(event?.eventId || '').trim()
|
||||
let rule: AutomationRule | undefined
|
||||
try {
|
||||
rule = this.ruleStore.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
|
||||
} catch (error) {
|
||||
this.warn(`读取退群通知规则失败: ${errorText(error)}`)
|
||||
return
|
||||
}
|
||||
if (!rule || rule.ruleType !== 'leave_notification' || !rule.leaveNotification) return
|
||||
if (!eventId) {
|
||||
this.warn('退群事件缺少事件 id,已跳过本次通知')
|
||||
return
|
||||
}
|
||||
// ① 关闭:不执行、不创建失败记录。
|
||||
if (!rule.enabled) return
|
||||
// ② 迁移遗留的"目标待重选":同样不算一次执行,由 UI 提示用户处理。
|
||||
if (rule.leaveNotification.targetNeedsReview) {
|
||||
this.warn(`退群通知目标待重新选择,已跳过本次通知 ruleId=${rule.id}`)
|
||||
return
|
||||
}
|
||||
// ③ 幂等(内存快路径)。跨重启那一层由确定性 executionId + gateway 审计兜住。
|
||||
if (!this.claimGroupExit(rule.id, eventId)) return
|
||||
|
||||
const executionId = leaveNotificationExecutionId(rule.id, eventId)
|
||||
const startedAt = this.now()
|
||||
const sourceDisplayName = String(event.groupName || '').trim() || '群聊'
|
||||
|
||||
// 目标解析与发送能力预检都放在 runner 之外,runner 只负责"跑成步骤"。
|
||||
const resolution = this.resolveLeaveTarget(rule, event)
|
||||
const sendBlockedReason = await this.leaveNotificationCapabilityError()
|
||||
|
||||
let result: Awaited<ReturnType<AutomationActionRunner['runLeaveNotification']>>
|
||||
try {
|
||||
result = await this.runner.runLeaveNotification({
|
||||
executionId,
|
||||
event,
|
||||
config: rule.leaveNotification,
|
||||
resolution,
|
||||
sourceDisplayName,
|
||||
...(sendBlockedReason ? { sendBlockedReason } : {})
|
||||
})
|
||||
} catch (error) {
|
||||
result = {
|
||||
steps: [],
|
||||
status: 'failed',
|
||||
errorSummary: `执行过程异常:${errorText(error)}`
|
||||
}
|
||||
}
|
||||
|
||||
this.executionLog.record(
|
||||
this.buildExecution({
|
||||
executionId,
|
||||
rule,
|
||||
startedAt,
|
||||
sourceDisplayName,
|
||||
status: result.status,
|
||||
durationMs: this.now() - startedAt,
|
||||
steps: result.steps,
|
||||
...(result.errorSummary ? { errorSummary: result.errorSummary } : {})
|
||||
})
|
||||
)
|
||||
|
||||
// 隐私红线:日志只允许 ruleId / executionId / 状态 / 耗时 / 步骤状态与目标类型。
|
||||
// 群名、昵称、wxid、模板正文一律不进日志。
|
||||
const trail = result.steps.map((step) => `${step.key}=${step.status}`).join(' ')
|
||||
this.info(
|
||||
`executed ruleId=${rule.id} executionId=${executionId} status=${result.status}` +
|
||||
` durationMs=${this.now() - startedAt}${trail ? ` ${trail}` : ''}`
|
||||
)
|
||||
}
|
||||
|
||||
/** 解析退群通知目标(联系人清单 / 自身身份都取自既有能力,不另建一套)。 */
|
||||
private resolveLeaveTarget(
|
||||
rule: AutomationRule,
|
||||
event: GroupMemberExitedEvent
|
||||
): LeaveNotificationTargetResolution {
|
||||
let contacts: ReturnType<typeof listContacts> = []
|
||||
try {
|
||||
contacts = listContacts()
|
||||
} catch (error) {
|
||||
this.warn(`读取联系人失败: ${errorText(error)}`)
|
||||
}
|
||||
let selfWxid = ''
|
||||
try {
|
||||
selfWxid = String(getSelfAccountInfo()?.wxid || '')
|
||||
} catch (error) {
|
||||
this.warn(`读取当前账号失败: ${errorText(error)}`)
|
||||
}
|
||||
return resolveLeaveNotificationTarget({
|
||||
config: rule.leaveNotification,
|
||||
event,
|
||||
contacts,
|
||||
selfWxid
|
||||
})
|
||||
}
|
||||
|
||||
/** 退群通知只需要文字能力。缺失时给出用户能看懂的一句说明。 */
|
||||
private async leaveNotificationCapabilityError(): Promise<string | undefined> {
|
||||
let capability: PersonalWechatSendCapability | null = null
|
||||
try {
|
||||
capability = await this.getCapability()
|
||||
} catch {
|
||||
return '暂时无法获知微信发送能力,本次退群通知未发送。'
|
||||
}
|
||||
if (!capability?.supported) {
|
||||
return capability?.message || '当前系统不支持微信消息发送'
|
||||
}
|
||||
if (!capability.capabilities?.text) {
|
||||
return '当前环境无法发送文字,本次退群通知未发送。'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** 退群事件幂等登记(原子 check + 写入,中间无 await)。 */
|
||||
private claimGroupExit(ruleId: string, eventId: string): boolean {
|
||||
const key = `${ruleId}:${eventId}`
|
||||
const now = this.now()
|
||||
if (this.exitClaims.has(key)) return false
|
||||
this.exitClaims.set(key, { at: now })
|
||||
if (this.exitClaims.size > EXIT_CLAIM_MAX_ENTRIES) {
|
||||
for (const [entryKey, entry] of this.exitClaims) {
|
||||
if (this.exitClaims.size <= EXIT_CLAIM_MAX_ENTRIES) break
|
||||
if (now - entry.at > CLAIM_TTL_MS) this.exitClaims.delete(entryKey)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** 「在哪些聊天生效」的可选项。`id` 必须是 `xxx@chatroom`,与 message.sessionId 对齐。 */ listGroups(): Array<{ id: string; name: string }> {
|
||||
try {
|
||||
return listContacts()
|
||||
.filter((contact) => contact.type === 'group' || contact.m_nsUsrName?.endsWith('@chatroom'))
|
||||
|
||||
@@ -2,19 +2,17 @@ import { app, BrowserWindow } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import * as chat from './chat-service'
|
||||
import { wechatActionGateway, type WechatActionGateway } from './wechat-action-gateway'
|
||||
import {
|
||||
buildMemberLeftNotification,
|
||||
findRemovedGroupMembers,
|
||||
groupExitMemberName,
|
||||
normalizeGroupExitNotificationTemplate,
|
||||
validateGroupExitNotificationTemplate,
|
||||
type GroupExitMonitorEvent,
|
||||
type GroupExitMonitorMember,
|
||||
type GroupExitNotificationStatus,
|
||||
type GroupExitNotificationState,
|
||||
type GroupExitMonitorState
|
||||
} from '../../shared/group-exit-monitor'
|
||||
import {
|
||||
toGroupMemberExitedEvent,
|
||||
type GroupMemberExitedEvent
|
||||
} from '../../shared/group-exit-event'
|
||||
|
||||
type StoredState = {
|
||||
enabled?: boolean
|
||||
@@ -23,9 +21,14 @@ type StoredState = {
|
||||
lastReadAt?: number
|
||||
monitorSelectionConfigured?: boolean
|
||||
monitoredRoomIds?: string[]
|
||||
notificationRoomIds?: string[]
|
||||
notificationTemplate?: unknown
|
||||
snapshots?: Partial<StoredGroupSnapshot>[]
|
||||
/*
|
||||
* 历史遗留字段(`notificationRoomIds` / `notificationTemplate`)**刻意不再声明**。
|
||||
*
|
||||
* 它们已经迁到「自动化 → 退群通知」规则里,本服务运行期不再读、不再写。
|
||||
* 旧状态文件原样保留在磁盘上(迁移时另有一份 backup),只是没有任何读取方 ——
|
||||
* 这是"不双读"的落地方式。迁移逻辑在 `automation-rule-store.ts`。
|
||||
*/
|
||||
}
|
||||
|
||||
type GroupSnapshotRecord = {
|
||||
@@ -67,17 +70,20 @@ const DUPLICATE_WINDOW_MS = 2 * 60 * 1000
|
||||
*/
|
||||
const MAX_EVENTS = 500
|
||||
|
||||
/**
|
||||
* 退群事件处理器。
|
||||
*
|
||||
* 由主进程注入成 `automationService.handleGroupExit` —— 本服务**不再自己发送**。
|
||||
* 契约:**必须自己吞掉异常**(实现方负责),本服务也会再兜一层。
|
||||
*/
|
||||
export type GroupExitEventHandler = (event: GroupMemberExitedEvent) => void | Promise<void>
|
||||
|
||||
export interface GroupExitMonitorServiceDependencies {
|
||||
actionGateway?: GroupExitActionGateway
|
||||
onGroupExit?: GroupExitEventHandler
|
||||
}
|
||||
|
||||
type GroupExitActionGateway = Pick<WechatActionGateway, 'execute'> &
|
||||
Partial<
|
||||
Pick<WechatActionGateway, 'registerMemberEvent' | 'registerMemberEvents' | 'clearMemberEvents'>
|
||||
>
|
||||
|
||||
class GroupExitMonitorService {
|
||||
private readonly actionGateway: GroupExitActionGateway
|
||||
private onGroupExit: GroupExitEventHandler | undefined
|
||||
private active = false
|
||||
private enabled = true
|
||||
private nativeMonitorActive = false
|
||||
@@ -95,7 +101,6 @@ class GroupExitMonitorService {
|
||||
private events: GroupExitMonitorEvent[] = []
|
||||
private monitorSelectionConfigured = true
|
||||
private monitoredRoomIds = new Set<string>()
|
||||
private notificationRoomIds = new Set<string>()
|
||||
private loaded = false
|
||||
private accountRoot = ''
|
||||
private groupNamesByRoomId = new Map<string, string>()
|
||||
@@ -103,10 +108,19 @@ class GroupExitMonitorService {
|
||||
private eventSequence = 0
|
||||
private scopeGeneration = 0
|
||||
private legacyFallbackLogged = false
|
||||
private notificationTemplate = normalizeGroupExitNotificationTemplate(undefined)
|
||||
|
||||
constructor(deps: GroupExitMonitorServiceDependencies = {}) {
|
||||
this.actionGateway = deps.actionGateway || wechatActionGateway
|
||||
this.onGroupExit = deps.onGroupExit
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入退群事件处理器(主进程在 AutomationService 初始化后调用)。
|
||||
*
|
||||
* 用 setter 而不是构造参数:AutomationService 依赖 `Wcdb4Client`,
|
||||
* 要等数据库解锁后才存在,而本服务是模块级单例。
|
||||
*/
|
||||
setGroupExitHandler(handler: GroupExitEventHandler | undefined): void {
|
||||
this.onGroupExit = handler
|
||||
}
|
||||
|
||||
getState(): GroupExitMonitorState {
|
||||
@@ -122,8 +136,6 @@ class GroupExitMonitorService {
|
||||
monitoredGroupCount: this.snapshots.size,
|
||||
monitorSelectionConfigured: this.monitorSelectionConfigured,
|
||||
monitoredRoomIds: Array.from(this.monitoredRoomIds),
|
||||
notificationRoomIds: Array.from(this.notificationRoomIds),
|
||||
notificationTemplate: this.notificationTemplate,
|
||||
lastCheckedAt: this.lastCheckedAt,
|
||||
lastReadAt: this.lastReadAt,
|
||||
unreadCount: this.events.filter((event) => event.detectedAt > this.lastReadAt).length
|
||||
@@ -143,13 +155,11 @@ class GroupExitMonitorService {
|
||||
(this.accountRoot && !sameAccountRoot(currentRoot, this.accountRoot)) ||
|
||||
(!this.accountRoot && this.snapshots.size > 0)
|
||||
) {
|
||||
this.actionGateway.clearMemberEvents?.()
|
||||
this.events = []
|
||||
this.rewriteEventsToDisk([])
|
||||
this.lastReadAt = 0
|
||||
this.monitorSelectionConfigured = true
|
||||
this.monitoredRoomIds.clear()
|
||||
this.notificationRoomIds.clear()
|
||||
this.snapshots.clear()
|
||||
this.groupNamesByRoomId.clear()
|
||||
}
|
||||
@@ -220,10 +230,13 @@ class GroupExitMonitorService {
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
async setMonitoredRoomIds(
|
||||
roomIds: string[],
|
||||
notificationRoomIds: string[] = []
|
||||
): Promise<GroupExitMonitorState> {
|
||||
/**
|
||||
* 保存**监控范围**。
|
||||
*
|
||||
* 参数只剩监控目标 —— 旧版第二个参数(通知群聊)已经迁到自动化规则里,
|
||||
* 本服务不再持有任何通知配置。
|
||||
*/
|
||||
async setMonitoredRoomIds(roomIds: string[]): Promise<GroupExitMonitorState> {
|
||||
this.ensureLoaded()
|
||||
this.eventSequence += 1
|
||||
this.scopeGeneration += 1
|
||||
@@ -237,10 +250,6 @@ class GroupExitMonitorService {
|
||||
}
|
||||
this.monitoredRoomIds = nextMonitoredRoomIds
|
||||
this.groupNamesRefreshPending = true
|
||||
const requestedNotifications = normalizeRoomIds(notificationRoomIds)
|
||||
this.notificationRoomIds = new Set(
|
||||
Array.from(requestedNotifications).filter((roomId) => this.monitoredRoomIds.has(roomId))
|
||||
)
|
||||
this.lastCheckedAt = undefined
|
||||
this.save()
|
||||
this.broadcast()
|
||||
@@ -273,18 +282,6 @@ class GroupExitMonitorService {
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
setNotificationTemplate(value: unknown): GroupExitMonitorState {
|
||||
this.ensureLoaded()
|
||||
const result = validateGroupExitNotificationTemplate(value)
|
||||
if (!result.valid || !result.template) {
|
||||
throw new Error(result.error || '退群监测模板无效')
|
||||
}
|
||||
this.notificationTemplate = result.template
|
||||
this.save()
|
||||
this.broadcast()
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
/**
|
||||
* 按群 / 时间范围查退群事件(档案合并展示用)。
|
||||
*
|
||||
@@ -320,7 +317,6 @@ class GroupExitMonitorService {
|
||||
this.events = []
|
||||
// 磁盘上的 append-only 历史也要清掉,否则下次启动又读回来了。
|
||||
this.rewriteEventsToDisk([])
|
||||
this.actionGateway.clearMemberEvents?.()
|
||||
this.lastReadAt = Date.now()
|
||||
this.save()
|
||||
this.broadcast()
|
||||
@@ -443,7 +439,7 @@ class GroupExitMonitorService {
|
||||
groups: GroupMembershipRecord[],
|
||||
scopeGeneration: number
|
||||
): Promise<number> {
|
||||
const notifications: Array<{ group: GroupSnapshotRecord; event: GroupExitMonitorEvent }> = []
|
||||
const exits: GroupExitMonitorEvent[] = []
|
||||
let changedGroups = 0
|
||||
for (const membership of groups) {
|
||||
if (!this.enabled || !this.active || scopeGeneration !== this.scopeGeneration) {
|
||||
@@ -474,10 +470,9 @@ class GroupExitMonitorService {
|
||||
if (previous && next.members.length < previous.members.length) {
|
||||
const removed = findRemovedGroupMembers(previous.members, next.members)
|
||||
for (const member of removed) {
|
||||
// 一人一条事件、一条通知 —— 保持既有产品语义,不聚合。
|
||||
const event = this.recordExit(next, member, previous.members.length, next.members.length)
|
||||
if (event && this.notificationRoomIds.has(next.roomId)) {
|
||||
notifications.push({ group: next, event })
|
||||
}
|
||||
if (event) exits.push(event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,12 +487,11 @@ class GroupExitMonitorService {
|
||||
return changedGroups
|
||||
}
|
||||
this.lastCheckedAt = Date.now()
|
||||
// 先把事件和新基线作为同一检查点落盘,再执行可失败的通知动作。
|
||||
// 先把事件和新基线作为同一检查点落盘,再交给自动化。
|
||||
// 「退群事实已记录」与「通知发送成功」是两件独立的事。
|
||||
this.save()
|
||||
this.broadcast()
|
||||
if (notifications.length) {
|
||||
await Promise.all(notifications.map(({ group, event }) => this.notifyGroup(group, event)))
|
||||
}
|
||||
for (const event of exits) this.emitGroupExit(event)
|
||||
return changedGroups
|
||||
}
|
||||
|
||||
@@ -633,10 +627,8 @@ class GroupExitMonitorService {
|
||||
currentCount,
|
||||
delta: currentCount - previousCount,
|
||||
message,
|
||||
detectedAt,
|
||||
notificationStatus: 'not_requested'
|
||||
detectedAt
|
||||
}
|
||||
this.actionGateway.registerMemberEvent?.(event)
|
||||
// 内存按时间倒序(新事件在前);磁盘**只追加这一条**,不重写历史。
|
||||
// 这里不再有 `.slice(0, MAX_EVENTS)` —— 事件是永久保留的。
|
||||
this.events = [event, ...this.events]
|
||||
@@ -647,83 +639,37 @@ class GroupExitMonitorService {
|
||||
return event
|
||||
}
|
||||
|
||||
private async notifyGroup(
|
||||
group: GroupSnapshotRecord,
|
||||
event: GroupExitMonitorEvent,
|
||||
idempotencyKey = `member_left_notification:${event.id}`
|
||||
): Promise<void> {
|
||||
event.notificationStatus = 'pending'
|
||||
event.notification = { status: 'pending' }
|
||||
this.save()
|
||||
this.broadcast()
|
||||
/**
|
||||
* 把退群事件交给自动化 —— **不等待**。
|
||||
*
|
||||
* 三条约束:
|
||||
* 1. **绝不 await**:快照扫描与成员 diff 不能被微信发送耗时拖住;
|
||||
* 2. **异常必须被捕获**:`void promise` 漏掉 `.catch` 会变成 unhandled rejection;
|
||||
* 3. **不影响退群事实**:事件与快照在同一检查点已经先落盘,通知失败不回滚记录。
|
||||
*/
|
||||
private emitGroupExit(event: GroupExitMonitorEvent): void {
|
||||
const handler = this.onGroupExit
|
||||
if (!handler) return
|
||||
try {
|
||||
const result = await this.actionGateway.execute({
|
||||
idempotencyKey,
|
||||
origin: 'member_monitor',
|
||||
purpose: 'member_left_notification',
|
||||
triggerType: 'automation',
|
||||
sourceId: event.id,
|
||||
recipient: {
|
||||
type: 'group',
|
||||
id: group.roomId,
|
||||
name: group.groupName
|
||||
},
|
||||
content: {
|
||||
type: 'text',
|
||||
text: buildMemberLeftNotification(event, this.notificationTemplate)
|
||||
},
|
||||
metadata: {
|
||||
memberId: event.memberWxid,
|
||||
memberName: event.memberName,
|
||||
detectedAt: event.detectedAt,
|
||||
eventType: 'member_left',
|
||||
eventRoomId: event.roomId
|
||||
}
|
||||
})
|
||||
const notification: GroupExitNotificationState = {
|
||||
status: result.status,
|
||||
actionId: result.actionId,
|
||||
decision: result.decision,
|
||||
...(result.errorCode ? { errorCode: result.errorCode } : {}),
|
||||
...(result.reason ? { reason: result.reason } : {}),
|
||||
startedAt: result.startedAt,
|
||||
finishedAt: result.finishedAt
|
||||
}
|
||||
event.notificationStatus = result.status
|
||||
event.notification = notification
|
||||
if (result.status !== 'sent') {
|
||||
void Promise.resolve(
|
||||
handler(toGroupMemberExitedEvent(event))
|
||||
).catch((error) => {
|
||||
console.warn(
|
||||
`[GroupMonitor] 群聊通知未发送 roomId=${group.roomId} status=${result.status} code=${result.errorCode || ''}`
|
||||
`[GroupMonitor] 退群通知处理失败 eventId=${event.id}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
event.notificationStatus = 'failed'
|
||||
event.notification = {
|
||||
status: 'failed',
|
||||
errorCode: 'UNKNOWN',
|
||||
reason: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
// handler 同步抛出的情况(`handleGroupExit` 本身不抛,这里是防御性兜底)。
|
||||
console.warn(
|
||||
`[GroupMonitor] 群聊通知异常 roomId=${group.roomId}:`,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
`[GroupMonitor] 退群通知处理异常 eventId=${event.id}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
)
|
||||
} finally {
|
||||
this.save()
|
||||
this.broadcast()
|
||||
}
|
||||
}
|
||||
|
||||
async resendEvent(eventId: string): Promise<GroupExitMonitorState> {
|
||||
const event = this.events.find((item) => item.id === eventId)
|
||||
if (!event) throw new Error('退群动态不存在')
|
||||
await this.notifyGroup(
|
||||
{ roomId: event.roomId, groupName: event.groupName } as GroupSnapshotRecord,
|
||||
event,
|
||||
`member_left_notification:${event.id}:retry:${Date.now()}`
|
||||
)
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
private filePath(): string {
|
||||
return path.join(app.getPath('userData'), 'group-exit-monitor.json')
|
||||
}
|
||||
@@ -806,20 +752,11 @@ class GroupExitMonitorService {
|
||||
// 「新在前」这个内存不变量,否则列表顶部会恒为最旧的一批。
|
||||
this.events = sortEventsNewestFirst(normalizeEvents(fromDisk))
|
||||
}
|
||||
this.actionGateway.registerMemberEvents?.(this.events)
|
||||
this.lastReadAt = Number(stored.lastReadAt) || 0
|
||||
this.accountRoot = String(stored.accountRoot || '')
|
||||
// 没有显式范围时按空范围处理,保留已有选择。
|
||||
this.monitorSelectionConfigured = true
|
||||
this.monitoredRoomIds = normalizeRoomIds(stored.monitoredRoomIds || [])
|
||||
this.notificationRoomIds = new Set(
|
||||
Array.from(normalizeRoomIds(stored.notificationRoomIds || [])).filter((roomId) =>
|
||||
this.monitoredRoomIds.has(roomId)
|
||||
)
|
||||
)
|
||||
this.notificationTemplate = normalizeGroupExitNotificationTemplate(
|
||||
stored.notificationTemplate
|
||||
)
|
||||
this.snapshots = normalizeSnapshots(stored.snapshots, this.monitoredRoomIds)
|
||||
} catch {
|
||||
// 首次启动或状态文件损坏时从空记录开始 —— 但事件在独立文件里,
|
||||
@@ -829,8 +766,6 @@ class GroupExitMonitorService {
|
||||
this.lastReadAt = 0
|
||||
this.monitorSelectionConfigured = true
|
||||
this.monitoredRoomIds.clear()
|
||||
this.notificationRoomIds.clear()
|
||||
this.notificationTemplate = normalizeGroupExitNotificationTemplate(undefined)
|
||||
this.snapshots.clear()
|
||||
}
|
||||
}
|
||||
@@ -849,8 +784,6 @@ class GroupExitMonitorService {
|
||||
lastReadAt: this.lastReadAt,
|
||||
monitorSelectionConfigured: this.monitorSelectionConfigured,
|
||||
monitoredRoomIds: Array.from(this.monitoredRoomIds),
|
||||
notificationRoomIds: Array.from(this.notificationRoomIds),
|
||||
notificationTemplate: this.notificationTemplate,
|
||||
snapshots: Array.from(this.snapshots.values(), toStoredSnapshot)
|
||||
},
|
||||
{ spaces: 2 }
|
||||
@@ -1047,15 +980,11 @@ function normalizeEvents(
|
||||
? Number(value.delta)
|
||||
: currentCount - previousCount,
|
||||
message: String(value.message || `${memberName}退出了${groupName}`),
|
||||
detectedAt,
|
||||
...(value.notificationStatus
|
||||
? { notificationStatus: normalizeNotificationStatus(value.notificationStatus) }
|
||||
: {}),
|
||||
...(value.notification && typeof value.notification === 'object'
|
||||
? { notification: normalizeNotification(value.notification) }
|
||||
: {})
|
||||
detectedAt
|
||||
})
|
||||
// 不再按 MAX_EVENTS 截断:事件是永久保留的,截在这里等于每次启动都丢掉历史。
|
||||
// 历史上的 `notificationStatus` / `notification` 字段被**丢弃**:
|
||||
// 通知状态已归 Automation 执行日志,退群监控不再持有它。
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
@@ -1075,28 +1004,6 @@ function sortEventsNewestFirst(events: GroupExitMonitorEvent[]): GroupExitMonito
|
||||
return [...events].sort((left, right) => right.detectedAt - left.detectedAt)
|
||||
}
|
||||
|
||||
function normalizeNotificationStatus(value: unknown): GroupExitNotificationStatus {
|
||||
const status = String(value || '').trim()
|
||||
return status === 'pending' || status === 'sent' || status === 'blocked' || status === 'failed'
|
||||
? status
|
||||
: 'not_requested'
|
||||
}
|
||||
|
||||
function normalizeNotification(value: object): GroupExitNotificationState {
|
||||
const input = value as Partial<GroupExitNotificationState>
|
||||
return {
|
||||
status: normalizeNotificationStatus(input.status),
|
||||
...(input.actionId ? { actionId: String(input.actionId) } : {}),
|
||||
...(input.decision === 'allow' || input.decision === 'block'
|
||||
? { decision: input.decision }
|
||||
: {}),
|
||||
...(input.errorCode ? { errorCode: String(input.errorCode) } : {}),
|
||||
...(input.reason ? { reason: String(input.reason) } : {}),
|
||||
...(input.startedAt ? { startedAt: String(input.startedAt) } : {}),
|
||||
...(input.finishedAt ? { finishedAt: String(input.finishedAt) } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function isContactEvent(rawPayload: string): boolean {
|
||||
const payload = String(rawPayload || '').trim()
|
||||
if (!payload) return false
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
LEAVE_NOTIFICATION_DEFAULT_TARGET_TYPE,
|
||||
normalizeLeaveNotificationTemplate,
|
||||
type LeaveNotificationTarget
|
||||
} from '../../shared/automation'
|
||||
|
||||
/**
|
||||
* 旧「退群监控 → 通知群聊 / 模板」配置 → 新「自动化 → 退群通知」规则的**纯映射器**。
|
||||
*
|
||||
* 单独成文件是为了能直接单测:这是整轮迁移里**唯一会改变用户既有行为**的地方,
|
||||
* 不允许只靠"跑一遍看看"来验证。
|
||||
*
|
||||
* ## 旧语义(实测自 `group-exit-monitor-service.ts`)
|
||||
*
|
||||
* `notificationRoomIds` **不是**"统一通知接收目标列表",而是
|
||||
* **"哪些被监控群要在自己群里通报成员退出"**(per-group 多值):
|
||||
* - `applyCurrentMemberships()` 只在 `notificationRoomIds.has(roomId)` 时通知;
|
||||
* - `notifyGroup()` 的收件人**恒等于事件所在群**。
|
||||
*
|
||||
* ## 新语义
|
||||
*
|
||||
* 单值目标:`source_chat` / `self` / `file_transfer` / `contact`。
|
||||
*
|
||||
* ⇒ 两者**只在一种情况下等价**:旧配置把**每一个**被监控群都勾上了通知。
|
||||
* 严格子集无法表达,按保守策略处理,绝不"随便取第一个群"。
|
||||
*/
|
||||
|
||||
export interface LegacyLeaveNotificationState {
|
||||
monitoredRoomIds: string[]
|
||||
notificationRoomIds: string[]
|
||||
notificationTemplate?: unknown
|
||||
}
|
||||
|
||||
export type LeaveNotificationMigrationOutcome =
|
||||
/** 旧配置从不通知任何人 ⇒ 迁移成"关闭",行为完全一致。 */
|
||||
| 'lossless_off'
|
||||
/** 旧配置在**每个**被监控群里通报 ⇒ 等价于 `source_chat`,行为完全一致。 */
|
||||
| 'lossless_source_chat'
|
||||
/** 旧配置只在**部分**群里通报 ⇒ 单值目标无法表达,需用户重选。 */
|
||||
| 'needs_review'
|
||||
|
||||
export interface LeaveNotificationMigrationPlan {
|
||||
outcome: LeaveNotificationMigrationOutcome
|
||||
enabled: boolean
|
||||
target: LeaveNotificationTarget
|
||||
targetNeedsReview: boolean
|
||||
template: string
|
||||
}
|
||||
|
||||
function uniqueRoomIds(values: unknown): string[] {
|
||||
if (!Array.isArray(values)) return []
|
||||
const result: string[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const value of values) {
|
||||
const roomId = String(value || '').trim()
|
||||
if (!roomId || seen.has(roomId)) continue
|
||||
seen.add(roomId)
|
||||
result.push(roomId)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function planLeaveNotificationMigration(
|
||||
legacy: LegacyLeaveNotificationState
|
||||
): LeaveNotificationMigrationPlan {
|
||||
const monitored = uniqueRoomIds(legacy.monitoredRoomIds)
|
||||
const monitoredSet = new Set(monitored)
|
||||
// 再夹一次:防止脏状态文件把范围外的群带进来。
|
||||
const notifications = uniqueRoomIds(legacy.notificationRoomIds).filter((roomId) =>
|
||||
monitoredSet.has(roomId)
|
||||
)
|
||||
// 模板永远是无损迁移的那一项:它就是用户写的那段文字。
|
||||
const template = normalizeLeaveNotificationTemplate(legacy.notificationTemplate)
|
||||
const defaultTarget: LeaveNotificationTarget = { type: LEAVE_NOTIFICATION_DEFAULT_TARGET_TYPE }
|
||||
|
||||
if (!notifications.length) {
|
||||
// 旧语义是"从不通知" ⇒ 关掉,避免迁移后突然开始发消息。
|
||||
return {
|
||||
outcome: 'lossless_off',
|
||||
enabled: false,
|
||||
target: defaultTarget,
|
||||
targetNeedsReview: false,
|
||||
template
|
||||
}
|
||||
}
|
||||
|
||||
if (monitored.length > 0 && notifications.length === monitored.length) {
|
||||
// 每个被监控群都在自己群里通报 ⇔ 单值的 `source_chat`。
|
||||
return {
|
||||
outcome: 'lossless_source_chat',
|
||||
enabled: true,
|
||||
target: { type: 'source_chat' },
|
||||
targetNeedsReview: false,
|
||||
template
|
||||
}
|
||||
}
|
||||
|
||||
// 部分勾选:**不能**无损映射。不取第一个群、不改写成当前群聊、保持关闭、要求重选。
|
||||
return {
|
||||
outcome: 'needs_review',
|
||||
enabled: false,
|
||||
target: defaultTarget,
|
||||
targetNeedsReview: true,
|
||||
template
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
LEAVE_NOTIFICATION_TARGET_OPTIONS,
|
||||
leaveNotificationTargetLabel,
|
||||
type LeaveNotificationConfig
|
||||
} from '../../shared/automation'
|
||||
import type { GroupMemberExitedEvent } from '../../shared/group-exit-event'
|
||||
import { WECHAT_FILE_HELPER_USERNAME, isFileHelperUsername } from '../../shared/wechat-identities'
|
||||
|
||||
/**
|
||||
* 退群通知的**目标解析**。
|
||||
*
|
||||
* 纯函数(联系人清单与自身身份由调用方注入),因为这里最容易出错、
|
||||
* 也最需要被单测穷举:发错人是不可撤销的。
|
||||
*
|
||||
* 四条规则:
|
||||
* 1. `source_chat` 取**事件所在群**(`event.conversationId`),
|
||||
* 不是"当前正在看的聊天",也不是"最后一个活跃会话"。
|
||||
* 2. 目标解析失败 → **报错**,绝不 fallback 到别的目标(尤其不许偷偷发文件助手)。
|
||||
* 3. 只允许出现在选项表里的目标类型。
|
||||
* 4. 显示名不带 wxid:日志与界面只出现用户能看懂的称呼。
|
||||
*/
|
||||
|
||||
/** 解析所需的最小联系人结构(`FormattedContact` 结构上可赋值给它)。 */
|
||||
export interface LeaveNotificationCandidateContact {
|
||||
m_nsUsrName: string
|
||||
m_nsNickName?: string
|
||||
type: 'user' | 'group'
|
||||
isOfficialAccount?: boolean
|
||||
wechatNickname?: string
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface ResolvedLeaveNotificationTarget {
|
||||
recipient: { type: 'group' | 'contact'; id: string; name: string }
|
||||
/** 用户可读的目标名,直接进执行日志的 `detail`。 */
|
||||
displayName: string
|
||||
}
|
||||
|
||||
export type LeaveNotificationTargetResolution =
|
||||
| { ok: true; target: ResolvedLeaveNotificationTarget }
|
||||
| { ok: false; error: string }
|
||||
|
||||
/** 联系人显示名:备注 → 微信昵称 → 会话昵称 → 兜底称呼。**永不回落到 wxid**。 */
|
||||
export function leaveNotificationContactDisplayName(
|
||||
contact: LeaveNotificationCandidateContact
|
||||
): string {
|
||||
return (
|
||||
contact.remark?.trim() ||
|
||||
contact.wechatNickname?.trim() ||
|
||||
contact.m_nsNickName?.trim() ||
|
||||
'指定好友'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 这个联系人能不能作为「指定好友」的发送目标。
|
||||
*
|
||||
* 排除:群聊、公众号(`gh_`)、文件传输助手、自己。
|
||||
* UI 的联系人选择器与运行时的目标解析**共用这一条判定**,
|
||||
* 否则会出现"能选但发不出去"。
|
||||
*/
|
||||
export function isSendableFriendContact(
|
||||
contact: LeaveNotificationCandidateContact,
|
||||
selfWxid?: string
|
||||
): boolean {
|
||||
if (contact.type !== 'user') return false
|
||||
if (contact.isOfficialAccount) return false
|
||||
const username = String(contact.m_nsUsrName || '').trim()
|
||||
if (!username) return false
|
||||
if (isFileHelperUsername(username)) return false
|
||||
const self = String(selfWxid || '').trim()
|
||||
if (self && username === self) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** 供 UI 用的「可选好友」过滤(与运行时同一套判定)。 */
|
||||
export function filterSendableFriendContacts<T extends LeaveNotificationCandidateContact>(
|
||||
contacts: T[],
|
||||
selfWxid?: string
|
||||
): T[] {
|
||||
return contacts.filter((contact) => isSendableFriendContact(contact, selfWxid))
|
||||
}
|
||||
|
||||
export function resolveLeaveNotificationTarget(input: {
|
||||
config: LeaveNotificationConfig | undefined
|
||||
event: GroupMemberExitedEvent
|
||||
contacts: LeaveNotificationCandidateContact[]
|
||||
/** 当前登录账号的 wxid;拿不到时传空串。 */
|
||||
selfWxid?: string
|
||||
}): LeaveNotificationTargetResolution {
|
||||
const config = input.config
|
||||
if (!config) {
|
||||
return { ok: false, error: '退群通知尚未配置发送目标' }
|
||||
}
|
||||
const known = LEAVE_NOTIFICATION_TARGET_OPTIONS.some(
|
||||
(option) => option.type === config.target?.type
|
||||
)
|
||||
if (!known) {
|
||||
return { ok: false, error: '退群通知的发送目标无效,请重新选择' }
|
||||
}
|
||||
|
||||
switch (config.target.type) {
|
||||
case 'source_chat': {
|
||||
const conversationId = String(input.event.conversationId || '').trim()
|
||||
if (!conversationId) {
|
||||
return { ok: false, error: '无法确定发生退群的群聊,本次通知未发送' }
|
||||
}
|
||||
const groupName = String(input.event.groupName || '').trim()
|
||||
return {
|
||||
ok: true,
|
||||
target: {
|
||||
recipient: {
|
||||
type: 'group',
|
||||
id: conversationId,
|
||||
name: groupName || '当前群聊'
|
||||
},
|
||||
displayName: groupName || '发生退群事件的群聊'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case 'self': {
|
||||
const selfWxid = String(input.selfWxid || '').trim()
|
||||
if (!selfWxid) {
|
||||
// 明确报错,不 fallback。§「不要偷偷 fallback 文件传输助手」。
|
||||
return { ok: false, error: '无法确定当前登录的微信账号,本次通知未发送' }
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
target: {
|
||||
recipient: { type: 'contact', id: selfWxid, name: '我' },
|
||||
displayName: '我'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case 'file_transfer':
|
||||
return {
|
||||
ok: true,
|
||||
target: {
|
||||
recipient: {
|
||||
type: 'contact',
|
||||
id: WECHAT_FILE_HELPER_USERNAME,
|
||||
name: leaveNotificationTargetLabel('file_transfer')
|
||||
},
|
||||
displayName: leaveNotificationTargetLabel('file_transfer')
|
||||
}
|
||||
}
|
||||
|
||||
case 'contact': {
|
||||
const contactId = String(config.target.contactId || '').trim()
|
||||
if (!contactId) {
|
||||
return { ok: false, error: '还没有选择通知联系人,请重新选择' }
|
||||
}
|
||||
const contact = input.contacts.find(
|
||||
(item) => String(item.m_nsUsrName || '').trim() === contactId
|
||||
)
|
||||
if (!contact || !isSendableFriendContact(contact, input.selfWxid)) {
|
||||
// §「如果之前选择的联系人被删除 / 不可发送 / 找不到,规则不要偷偷发给其它地方」。
|
||||
return { ok: false, error: '通知联系人已不存在或当前无法发送,请重新选择' }
|
||||
}
|
||||
const displayName = leaveNotificationContactDisplayName(contact)
|
||||
return {
|
||||
ok: true,
|
||||
target: {
|
||||
recipient: { type: 'contact', id: contactId, name: displayName },
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import type {
|
||||
WechatActionAuditRecord,
|
||||
WechatActionContent,
|
||||
WechatActionErrorCode,
|
||||
WechatActionMemberEventReference,
|
||||
WechatActionRequest,
|
||||
WechatActionResult
|
||||
} from '../../shared/wechat-action'
|
||||
@@ -25,12 +24,14 @@ 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',
|
||||
// Automation v1(@我生成日报)。必须与 `src/shared/automation.ts` 的
|
||||
// Automation(@我生成日报)。必须与 `src/shared/automation.ts` 的
|
||||
// `AUTOMATION_SEND_PURPOSE` 保持一致,否则会被下面 evaluateWechatActionPolicy
|
||||
// 以 ACTION_NOT_ALLOWED 拦下 —— 那是有意的闸门,不是 bug。
|
||||
'automation_reply',
|
||||
'automation_report',
|
||||
// 退群通知(由 Automation 发出)。目标可以是群 / 自己 / 文件传输助手 / 指定好友,
|
||||
// 所以**不绑定**任何 "只能发回原群" 的作用域锁。
|
||||
'automation_leave_notification',
|
||||
'manual_report_image',
|
||||
'manual_report_postfix'
|
||||
])
|
||||
@@ -49,12 +50,6 @@ export interface ReportImageSequenceResult {
|
||||
export interface WechatActionGatewayDependencies {
|
||||
getCapability?: () => Promise<PersonalWechatSendCapability>
|
||||
send?: (request: PersonalWechatSendRequest) => Promise<PersonalWechatSendResult>
|
||||
getMemberEvent?: (
|
||||
sourceId: string
|
||||
) =>
|
||||
| WechatActionMemberEventReference
|
||||
| Promise<WechatActionMemberEventReference | undefined>
|
||||
| undefined
|
||||
getUserDataPath?: () => string
|
||||
now?: () => Date
|
||||
wait?: (milliseconds: number) => Promise<void>
|
||||
@@ -65,10 +60,6 @@ interface LoadedAuditState {
|
||||
records: WechatActionAuditRecord[]
|
||||
}
|
||||
|
||||
export interface WechatActionPolicyContext {
|
||||
memberEvent?: WechatActionMemberEventReference
|
||||
}
|
||||
|
||||
const defaultDependencies = (): Required<
|
||||
Pick<
|
||||
WechatActionGatewayDependencies,
|
||||
@@ -98,7 +89,6 @@ export class WechatActionGateway {
|
||||
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()
|
||||
@@ -108,22 +98,6 @@ export class WechatActionGateway {
|
||||
this.deps = { ...defaultDependencies(), ...deps }
|
||||
}
|
||||
|
||||
/** 记录退群事件,发送通知前可确认事件所属群聊。 */
|
||||
registerMemberEvent(event: WechatActionMemberEventReference): void {
|
||||
const id = String(event?.id || '').trim()
|
||||
const roomId = String(event?.roomId || '').trim()
|
||||
if (!id || !roomId) return
|
||||
this.memberEvents.set(id, { id, roomId })
|
||||
}
|
||||
|
||||
registerMemberEvents(events: WechatActionMemberEventReference[]): void {
|
||||
for (const event of events) this.registerMemberEvent(event)
|
||||
}
|
||||
|
||||
clearMemberEvents(): void {
|
||||
this.memberEvents.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户确认后的日报发送序列。两步都进入 automation 发送队列,从而复用既有 3 秒间隔;
|
||||
* 后置词 action 只在图片明确 sent 后创建,图片失败/blocked 时严格短路。
|
||||
@@ -196,8 +170,7 @@ export class WechatActionGateway {
|
||||
)
|
||||
}
|
||||
|
||||
const eventContext = await this.resolveEventContext(request)
|
||||
const policy = evaluateWechatActionPolicy(request, eventContext)
|
||||
const policy = evaluateWechatActionPolicy(request)
|
||||
if (policy.decision !== 'allow') {
|
||||
return this.finishBlocked(
|
||||
actionId,
|
||||
@@ -291,28 +264,6 @@ export class WechatActionGateway {
|
||||
return queued
|
||||
}
|
||||
|
||||
private async resolveEventContext(
|
||||
request: WechatActionRequest
|
||||
): Promise<WechatActionPolicyContext> {
|
||||
if (request.purpose !== 'member_left_notification') return {}
|
||||
const sourceId = String(request.sourceId || '').trim()
|
||||
if (!sourceId) return {}
|
||||
let memberEvent: WechatActionMemberEventReference | undefined = this.memberEvents.get(sourceId)
|
||||
if (!memberEvent && this.deps.getMemberEvent) {
|
||||
let resolved: WechatActionMemberEventReference | undefined
|
||||
try {
|
||||
resolved = await this.deps.getMemberEvent(sourceId)
|
||||
} catch {
|
||||
resolved = undefined
|
||||
}
|
||||
if (resolved && String(resolved.id || '').trim() === sourceId) {
|
||||
memberEvent = { id: String(resolved.id), roomId: String(resolved.roomId) }
|
||||
this.registerMemberEvent(memberEvent)
|
||||
}
|
||||
}
|
||||
return memberEvent ? { memberEvent } : {}
|
||||
}
|
||||
|
||||
private async findExisting(idempotencyKey: string): Promise<WechatActionResult | undefined> {
|
||||
const state = this.loadAuditState()
|
||||
const existing = state.records.find((record) => record.idempotencyKey === idempotencyKey)
|
||||
@@ -331,9 +282,6 @@ export class WechatActionGateway {
|
||||
private idempotencyKey(request: WechatActionRequest): string | undefined {
|
||||
const explicit = String(request.idempotencyKey || '').trim()
|
||||
if (explicit) return explicit
|
||||
if (request.purpose === 'member_left_notification' && request.sourceId) {
|
||||
return `member_left_notification:${request.sourceId}`
|
||||
}
|
||||
if (request.origin === 'scheduled_report' && request.executionId) {
|
||||
return `scheduled_report:${request.executionId}`
|
||||
}
|
||||
@@ -480,10 +428,7 @@ export class WechatActionGateway {
|
||||
}
|
||||
}
|
||||
|
||||
export function evaluateWechatActionPolicy(
|
||||
request: WechatActionRequest,
|
||||
context: WechatActionPolicyContext = {}
|
||||
): PolicyDecision {
|
||||
export function evaluateWechatActionPolicy(request: WechatActionRequest): PolicyDecision {
|
||||
if (!request || typeof request !== 'object') {
|
||||
return {
|
||||
decision: 'block',
|
||||
@@ -514,29 +459,6 @@ export function evaluateWechatActionPolicy(
|
||||
reason: `自动化动作不允许执行 purpose=${request.purpose}`
|
||||
}
|
||||
}
|
||||
if (request.purpose === 'member_left_notification') {
|
||||
if (
|
||||
!request.sourceId ||
|
||||
!context.memberEvent ||
|
||||
context.memberEvent.id !== request.sourceId ||
|
||||
!context.memberEvent.roomId
|
||||
) {
|
||||
return {
|
||||
decision: 'block',
|
||||
source: 'deterministic',
|
||||
reasonCode: 'INVALID_REQUEST',
|
||||
reason: '退群通知必须关联已记录的退群事件'
|
||||
}
|
||||
}
|
||||
if (request.recipient.type !== 'group' || request.recipient.id !== context.memberEvent.roomId) {
|
||||
return {
|
||||
decision: 'block',
|
||||
source: 'deterministic',
|
||||
reasonCode: 'RECIPIENT_SCOPE_VIOLATION',
|
||||
reason: '退群通知只能发送回原事件所在群聊'
|
||||
}
|
||||
}
|
||||
}
|
||||
return { decision: 'allow', source: 'deterministic' }
|
||||
}
|
||||
|
||||
|
||||
Vendored
+6
-6
@@ -304,14 +304,10 @@ declare global {
|
||||
limit?: number
|
||||
}) => Promise<GroupExitMonitorEvent[]>
|
||||
setGroupExitMonitorEnabled: (enabled: boolean) => Promise<GroupExitMonitorState>
|
||||
setGroupExitMonitorGroups: (
|
||||
roomIds: string[],
|
||||
notificationRoomIds?: string[]
|
||||
) => Promise<GroupExitMonitorState>
|
||||
setGroupExitMonitorNotificationTemplate: (template: string) => Promise<GroupExitMonitorState>
|
||||
/** 保存**监控范围**。通知配置已迁到「自动化 → 退群通知」。 */
|
||||
setGroupExitMonitorGroups: (roomIds: string[]) => Promise<GroupExitMonitorState>
|
||||
checkGroupExitMonitorNow: () => Promise<GroupExitMonitorState>
|
||||
clearGroupExitMonitorEvents: () => Promise<GroupExitMonitorState>
|
||||
resendGroupExitMonitorEvent: (eventId: string) => Promise<GroupExitMonitorState>
|
||||
markGroupExitMonitorRead: (readAt?: number) => Promise<GroupExitMonitorState>
|
||||
listWechatActionLogs: () => Promise<ActionLogEntry[]>
|
||||
getAutomationStatus: () => Promise<AutomationStatusSummary>
|
||||
@@ -326,6 +322,10 @@ declare global {
|
||||
listAutomationExecutions: (query?: { limit?: number }) => Promise<AutomationExecution[]>
|
||||
clearAutomationExecutions: () => Promise<boolean>
|
||||
listAutomationGroups: () => Promise<Array<{ id: string; name: string }>>
|
||||
/** 保存「退群通知」规则(singleton upsert)。 */
|
||||
saveLeaveNotificationRule: (draft: AutomationRuleDraft) => Promise<AutomationRule>
|
||||
/** 「指定好友」的可选项;已在 main 侧过滤掉群聊 / 公众号 / 文件传输助手 / 自己。 */
|
||||
listSendableContacts: () => Promise<Array<{ id: string; name: string }>>
|
||||
onGroupExitMonitorState: (callback: (state: GroupExitMonitorState) => void) => () => void
|
||||
search: (keyword: string) => Promise<string | null>
|
||||
searchKnowledge: (request: KnowledgeSearchIpcRequest) => Promise<KnowledgeSearchIpcResult>
|
||||
|
||||
+13
-11
@@ -182,21 +182,13 @@ const api = {
|
||||
ipcRenderer.invoke('group-exit-monitor:listEvents', query),
|
||||
setGroupExitMonitorEnabled: (enabled: boolean): Promise<GroupExitMonitorState> =>
|
||||
ipcRenderer.invoke('group-exit-monitor:setEnabled', enabled),
|
||||
setGroupExitMonitorGroups: (
|
||||
roomIds: string[],
|
||||
notificationRoomIds?: string[]
|
||||
): Promise<GroupExitMonitorState> =>
|
||||
notificationRoomIds === undefined
|
||||
? ipcRenderer.invoke('group-exit-monitor:setGroups', roomIds)
|
||||
: ipcRenderer.invoke('group-exit-monitor:setGroups', roomIds, notificationRoomIds),
|
||||
setGroupExitMonitorNotificationTemplate: (template: string): Promise<GroupExitMonitorState> =>
|
||||
ipcRenderer.invoke('group-exit-monitor:setTemplate', template),
|
||||
/** 保存**监控范围**。通知配置已迁到自动化规则,这里不再有第二个参数。 */
|
||||
setGroupExitMonitorGroups: (roomIds: string[]): Promise<GroupExitMonitorState> =>
|
||||
ipcRenderer.invoke('group-exit-monitor:setGroups', roomIds),
|
||||
checkGroupExitMonitorNow: (): Promise<GroupExitMonitorState> =>
|
||||
ipcRenderer.invoke('group-exit-monitor:checkNow'),
|
||||
clearGroupExitMonitorEvents: (): Promise<GroupExitMonitorState> =>
|
||||
ipcRenderer.invoke('group-exit-monitor:clearEvents'),
|
||||
resendGroupExitMonitorEvent: (eventId: string): Promise<GroupExitMonitorState> =>
|
||||
ipcRenderer.invoke('group-exit-monitor:resendEvent', eventId),
|
||||
markGroupExitMonitorRead: (readAt?: number): Promise<GroupExitMonitorState> =>
|
||||
ipcRenderer.invoke('group-exit-monitor:markRead', readAt),
|
||||
listWechatActionLogs: (): Promise<ActionLogEntry[]> =>
|
||||
@@ -220,6 +212,16 @@ const api = {
|
||||
/** 「在哪些聊天生效」的可选项。`id` 为 `xxx@chatroom`,与规则内 conversationIds 同口径。 */
|
||||
listAutomationGroups: (): Promise<Array<{ id: string; name: string }>> =>
|
||||
ipcRenderer.invoke('automation:listGroups'),
|
||||
/**
|
||||
* 保存「退群通知」规则(singleton upsert,id 由 main 侧固定,渲染层不拼 id)。
|
||||
*/
|
||||
saveLeaveNotificationRule: (draft: AutomationRuleDraft): Promise<AutomationRule> =>
|
||||
ipcRenderer.invoke('automation:saveLeaveNotificationRule', draft),
|
||||
/**
|
||||
* 「指定好友」的可选项。已在 main 侧过滤掉群聊 / 公众号 / 文件传输助手 / 自己。
|
||||
*/
|
||||
listSendableContacts: (): Promise<Array<{ id: string; name: string }>> =>
|
||||
ipcRenderer.invoke('automation:listSendableContacts'),
|
||||
onGroupExitMonitorState: (callback: (state: GroupExitMonitorState) => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, state: GroupExitMonitorState): void =>
|
||||
callback(state)
|
||||
|
||||
@@ -45,8 +45,8 @@ import { switchGeneratedReportTemplate } from './utils/report-template-switch'
|
||||
import { runtimePlatform, supportsPersonalWechatSend } from './utils/runtime-environment'
|
||||
import { useToast } from './components/ui'
|
||||
import { AppUpdatePrompt } from './features/app-update/AppUpdatePrompt'
|
||||
import { GroupExitMonitorWorkspace } from './features/group-exit-monitor/GroupExitMonitorWorkspace'
|
||||
import { AutomationWorkspace } from './features/automation/AutomationWorkspace'
|
||||
import { GroupExitMonitorWorkspace, type GroupExitMonitorOpenViewRequest } from './features/group-exit-monitor/GroupExitMonitorWorkspace'
|
||||
import { AutomationWorkspace, type AutomationOpenRuleRequest } from './features/automation/AutomationWorkspace'
|
||||
import { selectContactAvatarRefreshUsernames } from './utils/contact-avatar'
|
||||
import {
|
||||
buildContactSearchIndex,
|
||||
@@ -258,6 +258,17 @@ function App(): React.ReactElement {
|
||||
const [databaseEnvironment, setDatabaseEnvironment] = useState<DatabaseKeyEnvironment>()
|
||||
const connectionOperationRef = React.useRef(0)
|
||||
const [activePage, setActivePage] = useState<AppPage>('archive')
|
||||
/**
|
||||
* 「退群监控 ⇄ 自动化」之间的跨页深链。
|
||||
*
|
||||
* 项目没有 react-router,一级菜单就是 `activePage` 这一份 state,
|
||||
* 所以深链同样是 state —— 用一个单调递增的 requestId 表达"又点了一次",
|
||||
* 子页面据此重新落位(同一个对象引用不会重复触发 effect)。
|
||||
*/
|
||||
const [automationOpenRuleRequest, setAutomationOpenRuleRequest] =
|
||||
React.useState<AutomationOpenRuleRequest | null>(null)
|
||||
const [exitMonitorOpenViewRequest, setExitMonitorOpenViewRequest] =
|
||||
React.useState<GroupExitMonitorOpenViewRequest | null>(null)
|
||||
const [archiveJumpTime, setArchiveJumpTime] = useState<number | null>(null)
|
||||
/**
|
||||
* 精确跳转目标(规范化后的消息 id)。
|
||||
@@ -1655,6 +1666,23 @@ function App(): React.ReactElement {
|
||||
setActivePage('agent-hub')
|
||||
}
|
||||
|
||||
/**
|
||||
* 「退群监控 → 退群通知自动化」。
|
||||
*
|
||||
* 直接落到「自动化 → 规则 → 退群通知」,不是只跳到自动化首页。
|
||||
* 这是纯导航:不改退群监控的任何配置,也不触发任何发送。
|
||||
*/
|
||||
const openLeaveNotificationAutomation = (): void => {
|
||||
setAutomationOpenRuleRequest({ ruleType: 'leave_notification', requestId: Date.now() })
|
||||
setActivePage('automation')
|
||||
}
|
||||
|
||||
/** 「自动化 → 退群通知 → 管理监控群聊 / 查看群聊」:回到退群监控的管理群聊页。 */
|
||||
const openExitMonitorGroups = (): void => {
|
||||
setExitMonitorOpenViewRequest({ view: 'manage', requestId: Date.now() })
|
||||
setActivePage('exit-monitor')
|
||||
}
|
||||
|
||||
const dismissFirstUseWelcome = (): void => {
|
||||
try {
|
||||
localStorage.setItem(FIRST_USE_WELCOME_SEEN_KEY, '1')
|
||||
@@ -2097,6 +2125,8 @@ function App(): React.ReactElement {
|
||||
dbReady={isDatabaseConnected}
|
||||
contacts={contacts}
|
||||
onOpenSendSettings={openWechatSendSettings}
|
||||
openViewRequest={exitMonitorOpenViewRequest}
|
||||
onOpenLeaveNotificationAutomation={openLeaveNotificationAutomation}
|
||||
/>
|
||||
)
|
||||
case 'automation':
|
||||
@@ -2104,6 +2134,8 @@ function App(): React.ReactElement {
|
||||
<AutomationWorkspace
|
||||
dbReady={isDatabaseConnected}
|
||||
onOpenSendSettings={openWechatSendSettings}
|
||||
onOpenExitMonitorGroups={openExitMonitorGroups}
|
||||
openRuleRequest={automationOpenRuleRequest}
|
||||
/>
|
||||
)
|
||||
case 'agent-hub':
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as React from 'react'
|
||||
import { SegmentedControl, SegmentedControlItem } from '../../components/ui'
|
||||
import { AUTOMATION_RULE_TYPE_LABELS, type AutomationRuleType } from '../../../../shared/automation'
|
||||
|
||||
/**
|
||||
* AutomationRuleTypeTabs —— 编辑器上方的**规则类型切换**。
|
||||
*
|
||||
* 它切的是"现在在编辑哪一类规则",**不是**"把当前这条规则改成另一类"。
|
||||
* 文案必须说清这一点:叫「规则类型」时,用户会以为它是当前规则的类型选择器,
|
||||
* 于是"切一下再保存"就意外改到了另一条规则上。
|
||||
*
|
||||
* 两项都是**真规则**:切换只改前端选中态,不碰任何已保存的配置。
|
||||
*/
|
||||
|
||||
const RULE_TYPES: AutomationRuleType[] = ['daily_report', 'leave_notification']
|
||||
|
||||
export function AutomationRuleTypeTabs({
|
||||
value,
|
||||
onValueChange
|
||||
}: {
|
||||
value: AutomationRuleType
|
||||
onValueChange: (next: AutomationRuleType) => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="automation-rule-type-tabs">
|
||||
<span className="automation-rule-type-label">切换规则类型</span>
|
||||
<SegmentedControl
|
||||
value={value}
|
||||
onValueChange={(next) => onValueChange(next as AutomationRuleType)}
|
||||
aria-label="切换规则类型"
|
||||
>
|
||||
{RULE_TYPES.map((type) => (
|
||||
<SegmentedControlItem key={type} value={type}>
|
||||
{AUTOMATION_RULE_TYPE_LABELS[type]}
|
||||
</SegmentedControlItem>
|
||||
))}
|
||||
</SegmentedControl>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +1,30 @@
|
||||
import * as React from 'react'
|
||||
import type {
|
||||
AutomationExecution,
|
||||
AutomationRule,
|
||||
AutomationRuleDraft,
|
||||
AutomationStatusSummary
|
||||
import {
|
||||
BUILTIN_DAILY_REPORT_RULE_ID,
|
||||
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
|
||||
type AutomationExecution,
|
||||
type AutomationRule,
|
||||
type AutomationRuleDraft,
|
||||
type AutomationRuleType,
|
||||
type AutomationStatusSummary
|
||||
} from '../../../../shared/automation'
|
||||
import {
|
||||
Button,
|
||||
SegmentedControl,
|
||||
SegmentedControlItem,
|
||||
Spinner,
|
||||
useToast
|
||||
} from '../../components/ui'
|
||||
import { RuleListPanel } from './RuleListPanel'
|
||||
import { RuleEditorPanel } from './RuleEditorPanel'
|
||||
import { ExecutionLogPanel } from './ExecutionLogPanel'
|
||||
import { ExecutionDetailDrawer } from './ExecutionDetailDrawer'
|
||||
import { AutomationRuleTypeTabs } from './AutomationRuleTypeTabs'
|
||||
import {
|
||||
LeaveNotificationEditor,
|
||||
type LeaveNotificationSaveInput
|
||||
} from './LeaveNotificationEditor'
|
||||
import type { LeaveNotificationContactOption } from './LeaveNotificationTargetPicker'
|
||||
import { UNAVAILABLE_STATUS, automationApi, isAutomationApiAvailable, type AutomationGroupOption } from './model/api'
|
||||
|
||||
/**
|
||||
@@ -23,24 +33,41 @@ import { UNAVAILABLE_STATUS, automationApi, isAutomationApiAvailable, type Autom
|
||||
* 两个 tab:规则 / 执行日志。顶部三块状态(监听 / 发送能力 / 今日执行)
|
||||
* **全部来自 main 的真实能力**,渲染层不做任何平台猜测 ——
|
||||
* 粗平台判断在这里是错的:装了 macOS 但没绑定个人微信,照样发不出去。
|
||||
*
|
||||
* 「规则」内部再分一层**规则类型**:@我生成日报 / 退群通知。
|
||||
* 两种类型现在都是**真实规则**,保存都会落盘。
|
||||
*/
|
||||
|
||||
type AutomationTab = 'rules' | 'logs'
|
||||
|
||||
/** 由「退群监控」深链过来时的请求:直接打开指定规则类型。 */
|
||||
export interface AutomationOpenRuleRequest {
|
||||
ruleType: AutomationRuleType
|
||||
/** 每次点击都要能重新触发,所以带一个自增/时间戳。 */
|
||||
requestId: number
|
||||
}
|
||||
|
||||
export interface AutomationWorkspaceProps {
|
||||
dbReady: boolean
|
||||
/** 发送能力不可用时,引导用户去设置页。 */
|
||||
onOpenSendSettings?: () => void
|
||||
/** 「管理监控群聊 →」:跳到退群监控的管理群聊页(纯导航)。 */
|
||||
onOpenExitMonitorGroups?: () => void
|
||||
/** 深链请求(来自退群监控页的「退群通知自动化」入口)。 */
|
||||
openRuleRequest?: AutomationOpenRuleRequest | null
|
||||
}
|
||||
|
||||
interface EditorState {
|
||||
mode: 'create' | 'edit'
|
||||
rule: AutomationRule | null
|
||||
ruleType: AutomationRuleType
|
||||
}
|
||||
|
||||
export function AutomationWorkspace({
|
||||
dbReady,
|
||||
onOpenSendSettings
|
||||
onOpenSendSettings,
|
||||
onOpenExitMonitorGroups,
|
||||
openRuleRequest
|
||||
}: AutomationWorkspaceProps): React.ReactElement {
|
||||
const { toast } = useToast()
|
||||
const apiAvailable = React.useMemo(() => isAutomationApiAvailable(), [])
|
||||
@@ -50,6 +77,8 @@ export function AutomationWorkspace({
|
||||
const [rules, setRules] = React.useState<AutomationRule[]>([])
|
||||
const [groups, setGroups] = React.useState<AutomationGroupOption[]>([])
|
||||
const [executions, setExecutions] = React.useState<AutomationExecution[]>([])
|
||||
const [sendableContacts, setSendableContacts] = React.useState<LeaveNotificationContactOption[]>([])
|
||||
const [monitoredGroupCount, setMonitoredGroupCount] = React.useState(0)
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
const [clearing, setClearing] = React.useState(false)
|
||||
const [saving, setSaving] = React.useState(false)
|
||||
@@ -59,16 +88,22 @@ export function AutomationWorkspace({
|
||||
const [pendingDelete, setPendingDelete] = React.useState<AutomationRule | null>(null)
|
||||
|
||||
const reload = React.useCallback(async (): Promise<void> => {
|
||||
const [nextStatus, nextRules, nextGroups, nextExecutions] = await Promise.all([
|
||||
automationApi.getStatus(),
|
||||
automationApi.listRules(),
|
||||
automationApi.listGroups(),
|
||||
automationApi.listExecutions(100)
|
||||
])
|
||||
const [nextStatus, nextRules, nextGroups, nextExecutions, nextContacts, nextMonitored] =
|
||||
await Promise.all([
|
||||
automationApi.getStatus(),
|
||||
automationApi.listRules(),
|
||||
automationApi.listGroups(),
|
||||
automationApi.listExecutions(100),
|
||||
// 「指定好友」候选与已监控群聊数量:都来自既有能力,不在自动化里另存一份。
|
||||
automationApi.listSendableContacts(),
|
||||
automationApi.getMonitoredGroupCount()
|
||||
])
|
||||
setStatus(nextStatus)
|
||||
setRules(nextRules)
|
||||
setGroups(nextGroups)
|
||||
setExecutions(nextExecutions)
|
||||
setSendableContacts(nextContacts)
|
||||
setMonitoredGroupCount(nextMonitored)
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -89,6 +124,65 @@ export function AutomationWorkspace({
|
||||
void reload()
|
||||
}, [tab, reload])
|
||||
|
||||
/**
|
||||
* 「退群监控 → 退群通知自动化」的深链。
|
||||
*
|
||||
* 落到「规则 → 退群通知」这一层,而不是只跳到自动化首页 ——
|
||||
* 否则用户还得自己再找一次。请求对象本身在父层是稳定的,
|
||||
* 所以依赖整个对象;requestId 变化就代表又点了一次。
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (!openRuleRequest) return
|
||||
setTab('rules')
|
||||
setEditor({ mode: 'edit', rule: null, ruleType: openRuleRequest.ruleType })
|
||||
}, [openRuleRequest])
|
||||
|
||||
/**
|
||||
* 当前编辑目标(日报)—— **UI 显示与保存共用这一个**。
|
||||
*
|
||||
* 这里保留"回落到内置日报"的兜底,但它同时驱动下面的「正在编辑:X」,
|
||||
* 所以即使回落发生,用户也能看到真实目标,不会再出现"以为在改 A、实际改了 B"。
|
||||
*/
|
||||
const dailyReportTarget =
|
||||
editor?.ruleType === 'daily_report'
|
||||
? (editor.rule ?? rules.find((item) => item.id === BUILTIN_DAILY_REPORT_RULE_ID) ?? null)
|
||||
: null
|
||||
|
||||
/** 退群通知是 singleton 规则,永远从规则表里取(不存在则由 main 侧播种)。 */
|
||||
const leaveNotificationRule =
|
||||
rules.find((item) => item.id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID) ?? null
|
||||
|
||||
/** 「正在编辑」显示的名字(与保存目标同源)。 */
|
||||
const editorTargetName = React.useMemo((): string => {
|
||||
if (!editor) return ''
|
||||
if (editor.ruleType === 'leave_notification') return leaveNotificationRule?.name ?? '退群通知'
|
||||
return dailyReportTarget?.name ?? '新建自动化'
|
||||
}, [editor, leaveNotificationRule, dailyReportTarget])
|
||||
|
||||
/** 「指定好友」目标的显示名(首页卡片用;拿不到时留空,不伪造)。 */
|
||||
const leaveContactName = React.useMemo((): string => {
|
||||
const target = leaveNotificationRule?.leaveNotification?.target
|
||||
if (!target || target.type !== 'contact') return ''
|
||||
return sendableContacts.find((contact) => contact.id === target.contactId)?.name ?? ''
|
||||
}, [leaveNotificationRule, sendableContacts])
|
||||
|
||||
/**
|
||||
* 记住"上一次在日报这一类里编辑的那条"。
|
||||
*
|
||||
* 切类型只是换一类规则来编辑,切回来时应当回到刚才那条 ——
|
||||
* 否则用户从"某条日报"切走再切回,会被悄悄带到内置日报上。
|
||||
*/
|
||||
const lastDailyTargetRef = React.useRef<AutomationRule | null>(null)
|
||||
|
||||
/** 切到「@我生成日报」时的编辑目标:上次那条 → 内置日报 → 新建。 */
|
||||
const resolveDailyTarget = React.useCallback((): AutomationRule | null => {
|
||||
return (
|
||||
lastDailyTargetRef.current ??
|
||||
rules.find((item) => item.id === BUILTIN_DAILY_REPORT_RULE_ID) ??
|
||||
null
|
||||
)
|
||||
}, [rules])
|
||||
|
||||
const handleToggle = async (rule: AutomationRule, enabled: boolean): Promise<void> => {
|
||||
setBusyRuleId(rule.id)
|
||||
const updated = await automationApi.setRuleEnabled(rule.id, enabled)
|
||||
@@ -103,11 +197,32 @@ export function AutomationWorkspace({
|
||||
|
||||
const handleSave = async (draft: AutomationRuleDraft): Promise<void> => {
|
||||
if (!editor) return
|
||||
|
||||
const target = editor.ruleType === 'daily_report' ? dailyReportTarget : editor.rule
|
||||
|
||||
/*
|
||||
* 编辑态但目标不存在 ⇒ **报错,绝不许静默新建**。
|
||||
*
|
||||
* 这里以前写的是 `editor.mode === 'create' || !target ? createRule : updateRule` ——
|
||||
* 把"没有目标"当成了"新建",于是用户以为在改 A、实际多出一条新规则。
|
||||
* 新建只能由 `mode === 'create'` 决定;`edit` 且无目标是不可恢复的状态,必须说出来。
|
||||
*/
|
||||
if (editor.mode === 'edit' && !target) {
|
||||
toast({
|
||||
description: '要编辑的规则已不存在,请返回列表重新打开',
|
||||
variant: 'destructive',
|
||||
duration: 3600
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
const saved =
|
||||
editor.mode === 'create'
|
||||
? await automationApi.createRule(draft)
|
||||
: await automationApi.updateRule(editor.rule?.id ?? '', draft)
|
||||
let saved: AutomationRule | null = null
|
||||
if (editor.mode === 'create') {
|
||||
saved = await automationApi.createRule(draft)
|
||||
} else if (target) {
|
||||
saved = await automationApi.updateRule(target.id, draft)
|
||||
}
|
||||
setSaving(false)
|
||||
if (!saved) {
|
||||
toast({ description: '保存失败,请稍后重试', variant: 'destructive', duration: 3200 })
|
||||
@@ -127,6 +242,45 @@ export function AutomationWorkspace({
|
||||
void automationApi.getStatus().then(setStatus)
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存「退群通知」规则。
|
||||
*
|
||||
* 走独立的 singleton upsert 通道:这条规则 id 固定,保存永远不会多出一条。
|
||||
* 保存成功后清掉迁移遗留的 `targetNeedsReview`(用户已经做过选择)。
|
||||
*/
|
||||
const handleLeaveNotificationSave = async (
|
||||
input: LeaveNotificationSaveInput
|
||||
): Promise<void> => {
|
||||
// 静默 return 会让"点保存没反应"变成用户眼里的坏掉,必须说出来。
|
||||
if (!leaveNotificationRule) {
|
||||
toast({
|
||||
description: '退群通知规则已不存在,请返回列表重新打开',
|
||||
variant: 'destructive',
|
||||
duration: 3600
|
||||
})
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
const saved = await automationApi.saveLeaveNotificationRule({
|
||||
...leaveNotificationRule,
|
||||
enabled: input.enabled,
|
||||
leaveNotification: input.config
|
||||
})
|
||||
setSaving(false)
|
||||
if (!saved) {
|
||||
toast({ description: '保存失败,请稍后重试', variant: 'destructive', duration: 3200 })
|
||||
return
|
||||
}
|
||||
setRules((current) =>
|
||||
current.some((item) => item.id === saved.id)
|
||||
? current.map((item) => (item.id === saved.id ? saved : item))
|
||||
: [...current, saved]
|
||||
)
|
||||
setEditor(null)
|
||||
toast({ description: `已保存「${saved.name}」`, duration: 2800 })
|
||||
void automationApi.getStatus().then(setStatus)
|
||||
}
|
||||
|
||||
const confirmDelete = async (): Promise<void> => {
|
||||
if (!pendingDelete) return
|
||||
const target = pendingDelete
|
||||
@@ -166,7 +320,14 @@ export function AutomationWorkspace({
|
||||
<p>当群里出现符合条件的消息时,TraceMemo 会自动执行你配置的动作。</p>
|
||||
</div>
|
||||
{tab === 'rules' && !showEditor ? (
|
||||
<Button onClick={() => setEditor({ mode: 'create', rule: null })}>新建自动化</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
lastDailyTargetRef.current = null
|
||||
setEditor({ mode: 'create', rule: null, ruleType: 'daily_report' })
|
||||
}}
|
||||
>
|
||||
新建自动化
|
||||
</Button>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
@@ -223,24 +384,154 @@ export function AutomationWorkspace({
|
||||
) : null}
|
||||
|
||||
{showEditor && editor ? (
|
||||
<RuleEditorPanel
|
||||
mode={editor.mode}
|
||||
rule={editor.rule}
|
||||
groups={groups}
|
||||
saving={saving}
|
||||
onCancel={() => setEditor(null)}
|
||||
onSave={(draft) => void handleSave(draft)}
|
||||
/>
|
||||
<div className="automation-rule-type-shell">
|
||||
{/*
|
||||
切类型 = **换一条规则来编辑**,不是"把这条规则改成另一类"。
|
||||
|
||||
以前这里只改 `ruleType`,把上个类型的 `mode`/`rule` 一起带过来,
|
||||
于是"从退群通知切到日报"会留下 `mode='edit' + rule=null` 的中间态,
|
||||
保存时再隐式回落到内置日报 —— 用户以为在改退群通知,实际改掉了日报。
|
||||
现在切换时**显式重算编辑目标**,并把当前目标名显示出来。
|
||||
*/}
|
||||
<AutomationRuleTypeTabs
|
||||
value={editor.ruleType}
|
||||
onValueChange={(next) => {
|
||||
if (next === editor.ruleType) return
|
||||
if (next === 'leave_notification') {
|
||||
// 退群通知是 singleton:编辑目标就是它自己,不依赖规则列表。
|
||||
setEditor({ mode: 'edit', rule: null, ruleType: 'leave_notification' })
|
||||
return
|
||||
}
|
||||
// 日报可以有多条:目标必须是**具体某一条**或"新建",
|
||||
// 绝不允许留下 mode='edit' 却没有目标的状态。
|
||||
const target = resolveDailyTarget()
|
||||
lastDailyTargetRef.current = target
|
||||
setEditor(
|
||||
target
|
||||
? { mode: 'edit', rule: target, ruleType: 'daily_report' }
|
||||
: { mode: 'create', rule: null, ruleType: 'daily_report' }
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/*
|
||||
编辑目标必须**可见**。
|
||||
保存的目标与这里显示的名字来自**同一个变量** —— 不允许 UI 显示一个、保存改另一个。
|
||||
*/}
|
||||
<p className="automation-editing-target" data-testid="automation-editing-target">
|
||||
正在编辑:<strong>{editorTargetName}</strong>
|
||||
</p>
|
||||
|
||||
{editor.ruleType === 'daily_report' ? (
|
||||
/*
|
||||
* 编辑态却没有目标(规则被删/列表刷新后消失)—— 不能让用户对着一个
|
||||
* 标题写着「编辑自动化」的空表单填半天,最后保存时报错。
|
||||
*/
|
||||
editor.mode === 'edit' && !dailyReportTarget ? (
|
||||
<div className="automation-notice warning automation-notice-stack">
|
||||
<p>要编辑的规则已不存在(可能已被删除)。</p>
|
||||
<Button variant="outline" size="sm" onClick={() => setEditor(null)}>
|
||||
返回规则列表
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<RuleEditorPanel
|
||||
mode={editor.mode}
|
||||
rule={dailyReportTarget}
|
||||
groups={groups}
|
||||
saving={saving}
|
||||
onCancel={() => setEditor(null)}
|
||||
onSave={(draft) => void handleSave(draft)}
|
||||
/>
|
||||
)
|
||||
) : leaveNotificationRule ? (
|
||||
<LeaveNotificationEditor
|
||||
rule={leaveNotificationRule}
|
||||
contacts={sendableContacts}
|
||||
monitoredCount={monitoredGroupCount}
|
||||
saving={saving}
|
||||
{...(capabilityReady
|
||||
? {}
|
||||
: {
|
||||
// §「规则仍然可以保存」:只提示,不阻断编辑。
|
||||
sendCapabilityWarning:
|
||||
'当前发送能力未就绪:退群事件仍会被记录,但通知发送会失败。'
|
||||
})}
|
||||
onOpenMonitoredGroups={onOpenExitMonitorGroups}
|
||||
onCancel={() => setEditor(null)}
|
||||
onSave={(input) => void handleLeaveNotificationSave(input)}
|
||||
/>
|
||||
) : loading ? (
|
||||
/*
|
||||
* **正在读,不是"没有"。**
|
||||
*
|
||||
* 从「退群监控 → 退群通知自动化」深链进来时,编辑器是同步打开的,
|
||||
* 而规则列表还在 IPC 回来的路上 —— 这一帧必然还没有规则。
|
||||
* 旧写法在这里直接渲染「尚未就绪 / 版本不匹配」,把最正常的一种
|
||||
* 加载状态说成了故障,必须区分开。
|
||||
*/
|
||||
<div className="automation-loading">
|
||||
<Spinner />
|
||||
<span>正在读取退群通知规则…</span>
|
||||
</div>
|
||||
) : (
|
||||
/*
|
||||
* 读完了**确实没有**这条规则。
|
||||
*
|
||||
* `builtin-leave-notification` 由主进程 `AutomationRuleStore` 首次加载时自动播种,
|
||||
* 所以走到这里通常意味着:跑着的主进程还是迁移前的旧构建
|
||||
* (渲染层已经升级,`out/main` 没有)。如实说明并给一个重试入口。
|
||||
*/
|
||||
<div className="automation-notice warning automation-notice-stack">
|
||||
<p>没有找到内置的退群通知规则。</p>
|
||||
<small>
|
||||
这条规则应由主进程在首次启动时自动创建。如果你刚更新过版本,
|
||||
请重启 TraceMemo —— 已经跑起来的主进程不会自动换成新代码。
|
||||
</small>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setLoading(true)
|
||||
void reload().finally(() => setLoading(false))
|
||||
}}
|
||||
>
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : tab === 'rules' ? (
|
||||
<RuleListPanel
|
||||
rules={rules}
|
||||
groups={groups}
|
||||
loading={loading}
|
||||
busyRuleId={busyRuleId}
|
||||
leaveNotification={
|
||||
leaveNotificationRule
|
||||
? {
|
||||
rule: leaveNotificationRule,
|
||||
monitoredCount: monitoredGroupCount,
|
||||
contactName: leaveContactName
|
||||
}
|
||||
: null
|
||||
}
|
||||
onToggle={(rule, enabled) => void handleToggle(rule, enabled)}
|
||||
onEdit={(rule) => setEditor({ mode: 'edit', rule })}
|
||||
onEdit={(rule) => {
|
||||
// 记住这条,切类型回来时能恢复 —— 不记住就会被悄悄换成内置日报。
|
||||
lastDailyTargetRef.current = rule
|
||||
setEditor({ mode: 'edit', rule, ruleType: rule.ruleType ?? 'daily_report' })
|
||||
}}
|
||||
onDelete={(rule) => setPendingDelete(rule)}
|
||||
onCreate={() => setEditor({ mode: 'create', rule: null })}
|
||||
onCreate={() => {
|
||||
// 新建是"还没有对象",所以不记住任何规则。
|
||||
lastDailyTargetRef.current = null
|
||||
setEditor({ mode: 'create', rule: null, ruleType: 'daily_report' })
|
||||
}}
|
||||
onEditLeaveNotification={() =>
|
||||
setEditor({ mode: 'edit', rule: null, ruleType: 'leave_notification' })
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ExecutionLogPanel
|
||||
|
||||
@@ -127,6 +127,9 @@ export function ExecutionDetailDrawer({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{step.detail ? (
|
||||
<p className="automation-step-detail">{step.detail}</p>
|
||||
) : null}
|
||||
{step.error ? <p className="automation-step-error">{step.error}</p> : null}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import * as React from 'react'
|
||||
import { Button, RadioGroup, RadioGroupItem, Switch } from '../../components/ui'
|
||||
import {
|
||||
LEAVE_NOTIFICATION_TARGET_OPTIONS,
|
||||
createDefaultLeaveNotificationRule,
|
||||
type AutomationRule,
|
||||
type LeaveNotificationConfig,
|
||||
type LeaveNotificationTargetType
|
||||
} from '../../../../shared/automation'
|
||||
import { insertGroupNamePlaceholder } from '../../../../shared/group-exit-monitor'
|
||||
import { LeaveNotificationPreview } from './LeaveNotificationPreview'
|
||||
import {
|
||||
LeaveNotificationTargetPicker,
|
||||
type LeaveNotificationContactOption
|
||||
} from './LeaveNotificationTargetPicker'
|
||||
import { LeaveNotificationTemplateEditor } from './LeaveNotificationTemplateEditor'
|
||||
|
||||
/**
|
||||
* LeaveNotificationEditor —— 「自动化 → 规则 → 退群通知」。
|
||||
*
|
||||
* 这是**真实配置页**:保存会落盘成一条 singleton 规则(`builtin-leave-notification`),
|
||||
* 由 `AutomationService.handleGroupExit` 在退群事件到来时执行。
|
||||
*
|
||||
* 职责边界在 UI 上一眼可见:
|
||||
* 退群监控 = 监测哪些群有人退出(**范围完全由它管**);自动化 = 检测到之后发到哪。
|
||||
* 所以这里**没有**「触发范围」二次筛选,规则级启停也**只有**顶部一处。
|
||||
*/
|
||||
|
||||
export interface LeaveNotificationSaveInput {
|
||||
enabled: boolean
|
||||
config: LeaveNotificationConfig
|
||||
}
|
||||
|
||||
export interface LeaveNotificationEditorProps {
|
||||
/** 已持久化的退群通知规则(singleton,永远存在)。 */
|
||||
rule: AutomationRule
|
||||
/** 「指定好友」的可选项(main 侧已过滤,只含可发送的个人联系人)。 */
|
||||
contacts: LeaveNotificationContactOption[]
|
||||
/** 真实已监控群聊数量(来自退群监控,自动化不维护副本)。 */
|
||||
monitoredCount: number
|
||||
saving: boolean
|
||||
/** 发送能力不完整时的一句话提示;为 undefined 表示能力正常。 */
|
||||
sendCapabilityWarning?: string
|
||||
/** 「管理监控群聊 →」—— 跳退群监控的管理群聊页,纯导航。 */
|
||||
onOpenMonitoredGroups?: () => void
|
||||
onCancel: () => void
|
||||
onSave: (input: LeaveNotificationSaveInput) => void
|
||||
}
|
||||
|
||||
function initialConfig(rule: AutomationRule): LeaveNotificationConfig {
|
||||
return (
|
||||
rule.leaveNotification ??
|
||||
createDefaultLeaveNotificationRule(Date.now()).leaveNotification ?? {
|
||||
target: { type: 'file_transfer' },
|
||||
template: ''
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function LeaveNotificationEditor({
|
||||
rule,
|
||||
contacts,
|
||||
monitoredCount,
|
||||
saving,
|
||||
sendCapabilityWarning,
|
||||
onOpenMonitoredGroups,
|
||||
onCancel,
|
||||
onSave
|
||||
}: LeaveNotificationEditorProps): React.ReactElement {
|
||||
const [enabled, setEnabled] = React.useState(rule.enabled)
|
||||
const [config, setConfig] = React.useState<LeaveNotificationConfig>(() => initialConfig(rule))
|
||||
|
||||
// 切换编辑对象 / 保存后回填时重建草稿,避免把上一份改动带过来。
|
||||
React.useEffect(() => {
|
||||
setEnabled(rule.enabled)
|
||||
setConfig(initialConfig(rule))
|
||||
}, [rule])
|
||||
|
||||
const targetType = config.target.type
|
||||
const selectedContactId = String(config.target.contactId || '')
|
||||
|
||||
const selectedContactName = React.useMemo(
|
||||
() => contacts.find((contact) => contact.id === selectedContactId)?.name ?? '',
|
||||
[contacts, selectedContactId]
|
||||
)
|
||||
|
||||
/** 选了「指定好友」但那个联系人已经不在了(被删 / 不可发送 / 找不到)。 */
|
||||
const contactMissing =
|
||||
targetType === 'contact' && Boolean(selectedContactId) && !selectedContactName
|
||||
|
||||
/**
|
||||
* 通知不是发给「当前群聊」、但内容里又没有群名 → 收件人认不出是哪个群。
|
||||
*
|
||||
* 只在非「当前群聊」时要求:发给群内时,群名是冗余的。
|
||||
*/
|
||||
const missingGroupName =
|
||||
targetType !== 'source_chat' && !config.template.includes('{groupName}')
|
||||
|
||||
const selectTarget = (next: LeaveNotificationTargetType): void => {
|
||||
setConfig((current) => ({
|
||||
...current,
|
||||
// 换目标类型时清掉上一个类型才有的字段,避免存下互相矛盾的配置。
|
||||
// 同时**清除迁移遗留的「待重选」标记** —— 用户已经做了选择。
|
||||
target: next === 'contact' ? { type: next, contactId: current.target.contactId } : { type: next }
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSave = (): void => {
|
||||
onSave({ enabled, config: { ...config, targetNeedsReview: undefined } })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="automation-editor">
|
||||
<header className="automation-editor-header">
|
||||
<div>
|
||||
<h2>退群通知</h2>
|
||||
<p>当已监控群聊检测到成员退出时,自动发送通知。</p>
|
||||
<div className="automation-leave-status-row">
|
||||
<span className="automation-field-label">启用这条自动化</span>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
aria-label="启用这条自动化"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="automation-editor-actions">
|
||||
<Button variant="ghost" onClick={onCancel} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{config.targetNeedsReview ? (
|
||||
<p className="automation-notice warning">
|
||||
旧版「通知群聊」是逐群配置的,无法无损转换成新的单一通知目标。已保留你的通知模板,
|
||||
但<strong>在你重新选择通知目标之前不会发送任何通知</strong>。
|
||||
</p>
|
||||
) : null}
|
||||
{contactMissing ? (
|
||||
<p className="automation-notice warning">
|
||||
之前选择的联系人已不存在或当前无法发送,请重新选择通知目标。
|
||||
</p>
|
||||
) : null}
|
||||
{sendCapabilityWarning ? (
|
||||
<p className="automation-notice warning">{sendCapabilityWarning}</p>
|
||||
) : null}
|
||||
|
||||
<div className="automation-editor-body">
|
||||
<div className="automation-editor-form">
|
||||
<section className="automation-section">
|
||||
<div className="automation-section-heading">
|
||||
<h3>1 · 什么时候触发</h3>
|
||||
</div>
|
||||
|
||||
<div className="automation-inline-row">
|
||||
<div>
|
||||
<span className="automation-field-label">触发事件</span>
|
||||
<span className="automation-static-value">检测到群成员退出</span>
|
||||
<small>这是「退群监控」提供的事件,由本规则响应。</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="automation-inline-row">
|
||||
<div>
|
||||
<span className="automation-field-label">监控来源</span>
|
||||
<span className="automation-static-value">退群监控</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
只读摘要 + 唯一入口。
|
||||
「哪些群被监控」完全由退群监控决定,本规则不做二次筛选 ——
|
||||
所以这里不给任何勾选控件,只把当前范围如实说出来。
|
||||
*/}
|
||||
<div className="automation-leave-groups-card">
|
||||
<div>
|
||||
<span className="automation-field-label">已监控群聊</span>
|
||||
<strong>{monitoredCount} 个群聊</strong>
|
||||
<small>监控范围由「退群监控」管理</small>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onOpenMonitoredGroups}
|
||||
disabled={!onOpenMonitoredGroups}
|
||||
>
|
||||
管理监控群聊 →
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="automation-section">
|
||||
<div className="automation-section-heading">
|
||||
<h3>2 · 通知发送到哪里</h3>
|
||||
</div>
|
||||
|
||||
<RadioGroup
|
||||
value={targetType}
|
||||
onValueChange={(value) => selectTarget(value as LeaveNotificationTargetType)}
|
||||
aria-label="通知发送到哪里"
|
||||
className="automation-leave-targets"
|
||||
>
|
||||
{LEAVE_NOTIFICATION_TARGET_OPTIONS.map((option) => (
|
||||
<div key={option.type} className="automation-leave-radio option">
|
||||
<RadioGroupItem value={option.type} id={`leave-target-${option.type}`} />
|
||||
<div className="automation-leave-radio-body">
|
||||
<label htmlFor={`leave-target-${option.type}`}>{option.label}</label>
|
||||
<small>{option.description}</small>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
|
||||
{targetType === 'contact' ? (
|
||||
<div className="automation-field">
|
||||
<span className="automation-field-label">选择好友</span>
|
||||
<LeaveNotificationTargetPicker
|
||||
contacts={contacts}
|
||||
selectedId={selectedContactId}
|
||||
onSelect={(id) =>
|
||||
setConfig((current) => ({
|
||||
...current,
|
||||
target: { type: 'contact', contactId: id },
|
||||
targetNeedsReview: undefined
|
||||
}))
|
||||
}
|
||||
ariaLabel="选择好友"
|
||||
searchPlaceholder="搜索好友"
|
||||
emptyText="没有可发送的联系人"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="automation-section">
|
||||
<div className="automation-section-heading">
|
||||
<h3>3 · 通知内容</h3>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
护栏:通知发给「别人」时,不带群名的通知等于「张三退群了」。
|
||||
主进程已替用户补过一次(一次性迁移),这里兜的是"用户后来主动删掉了"的情况 ——
|
||||
只提示 + 一键补上,不强行改用户文本。
|
||||
*/}
|
||||
{missingGroupName ? (
|
||||
<div className="automation-notice warning automation-notice-stack">
|
||||
<p>这份内容里没有群聊名,收件人看不出是哪个群退的人。</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setConfig((current) => ({
|
||||
...current,
|
||||
template: insertGroupNamePlaceholder(current.template)
|
||||
}))
|
||||
}
|
||||
>
|
||||
补上群聊名
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<LeaveNotificationTemplateEditor
|
||||
template={config.template}
|
||||
onChange={(next) =>
|
||||
setConfig((current) => ({ ...current, template: next }))
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<LeaveNotificationPreview
|
||||
config={config}
|
||||
contactName={selectedContactName}
|
||||
monitoredCount={monitoredCount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import * as React from 'react'
|
||||
import {
|
||||
describeMonitoredScope,
|
||||
leaveNotificationTargetLabel,
|
||||
type LeaveNotificationConfig
|
||||
} from '../../../../shared/automation'
|
||||
import {
|
||||
createGroupExitPreviewSample,
|
||||
renderLeaveNotificationText
|
||||
} from '../../../../shared/group-exit-event'
|
||||
|
||||
/**
|
||||
* LeaveNotificationPreview —— 右侧「效果预览」。
|
||||
*
|
||||
* 与左侧表单逐项联动:改模板 → 气泡变;改发送目标 → 顶部收件会话与底部「通知目标」一起变。
|
||||
*
|
||||
* **绝不发送**:即使规则已经真实接通,这里也只做渲染 ——
|
||||
* 它拿的是 `GROUP_EXIT_PREVIEW_SAMPLE` 样本事件,不碰 `WechatActionGateway`。
|
||||
* 模板渲染复用 shared 的唯一实现,所以预览与实发不会出现两套结果。
|
||||
*/
|
||||
|
||||
export interface LeaveNotificationPreviewProps {
|
||||
config: LeaveNotificationConfig
|
||||
/** 「指定好友」已选联系人的显示名;未选 / 不是该目标时传空串。 */
|
||||
contactName: string
|
||||
/** 真实已监控群聊数量(来自退群监控)。 */
|
||||
monitoredCount: number
|
||||
}
|
||||
|
||||
export function LeaveNotificationPreview({
|
||||
config,
|
||||
contactName,
|
||||
monitoredCount
|
||||
}: LeaveNotificationPreviewProps): React.ReactElement {
|
||||
// 样本事件只服务预览;时间取"此刻",不伪造历史日期。
|
||||
const sample = React.useMemo(() => createGroupExitPreviewSample(Date.now()), [])
|
||||
|
||||
const chatTitle = React.useMemo((): string => {
|
||||
switch (config.target.type) {
|
||||
case 'source_chat':
|
||||
return sample.groupName || '当前群聊'
|
||||
case 'self':
|
||||
return '我'
|
||||
case 'file_transfer':
|
||||
return leaveNotificationTargetLabel('file_transfer')
|
||||
case 'contact':
|
||||
return contactName.trim() || '未选择好友'
|
||||
}
|
||||
}, [config.target.type, contactName, sample.groupName])
|
||||
|
||||
const targetSummary = React.useMemo((): string => {
|
||||
switch (config.target.type) {
|
||||
case 'source_chat':
|
||||
return '发生退群事件的群聊'
|
||||
case 'self':
|
||||
return '我'
|
||||
case 'file_transfer':
|
||||
return leaveNotificationTargetLabel('file_transfer')
|
||||
case 'contact':
|
||||
return contactName.trim() || '未选择好友'
|
||||
}
|
||||
}, [config.target.type, contactName])
|
||||
|
||||
const bubbleText = renderLeaveNotificationText(sample, config.template).trim() || '(模板为空)'
|
||||
|
||||
return (
|
||||
<section className="automation-preview" aria-label="效果模拟预览">
|
||||
<div className="automation-preview-heading">
|
||||
<h3>效果预览</h3>
|
||||
<span className="automation-preview-badge">仅预览,不会真实发送</span>
|
||||
</div>
|
||||
|
||||
<div className="automation-preview-phone">
|
||||
<div className="automation-preview-chat-title" data-testid="leave-preview-recipient">
|
||||
<span>{chatTitle}</span>
|
||||
</div>
|
||||
<div className="automation-preview-chat">
|
||||
{/* 退群监控产生的系统事件,不来自任何消息文本 */}
|
||||
<p className="automation-preview-system">检测到成员退出</p>
|
||||
|
||||
<div className="automation-chat-row outgoing">
|
||||
<div className="automation-chat-body">
|
||||
<span className="automation-chat-name">我</span>
|
||||
<span className="automation-chat-bubble pre">{bubbleText}</span>
|
||||
</div>
|
||||
<span className="automation-chat-avatar self" aria-hidden="true">
|
||||
我
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="automation-preview-facts">
|
||||
<div>
|
||||
<dt>触发事件</dt>
|
||||
<dd>成员退出</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>监控来源</dt>
|
||||
<dd>{describeMonitoredScope(monitoredCount)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>通知目标</dt>
|
||||
<dd>{targetSummary}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import * as React from 'react'
|
||||
import { Input, RadioGroup, RadioGroupItem } from '../../components/ui'
|
||||
|
||||
/**
|
||||
* LeaveNotificationTargetPicker —— 「指定好友」展开后的联系人选择器。
|
||||
*
|
||||
* **单选**:通知只能发给一个联系人,所以这里是 radio 而不是 checkbox ——
|
||||
* 多选联系人不在范围内,也不该顺手加。
|
||||
*
|
||||
* 联系人清单由 main 侧提供(`listSendableContacts`),**已经过滤掉**
|
||||
* 群聊 / 公众号 / 文件传输助手 / 自己。这里不再二次筛选,避免两处规则漂移。
|
||||
*/
|
||||
|
||||
export interface LeaveNotificationContactOption {
|
||||
/** 稳定 id(wxid)。保存与发送都用它,**不用显示名**。 */
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface LeaveNotificationTargetPickerProps {
|
||||
contacts: LeaveNotificationContactOption[]
|
||||
selectedId: string
|
||||
onSelect: (id: string) => void
|
||||
searchPlaceholder: string
|
||||
emptyText: string
|
||||
ariaLabel: string
|
||||
}
|
||||
|
||||
export function LeaveNotificationTargetPicker({
|
||||
contacts,
|
||||
selectedId,
|
||||
onSelect,
|
||||
searchPlaceholder,
|
||||
emptyText,
|
||||
ariaLabel
|
||||
}: LeaveNotificationTargetPickerProps): React.ReactElement {
|
||||
const [filter, setFilter] = React.useState('')
|
||||
|
||||
const visibleContacts = React.useMemo(() => {
|
||||
const needle = filter.trim().toLowerCase()
|
||||
if (!needle) return contacts
|
||||
return contacts.filter(
|
||||
(contact) =>
|
||||
contact.name.toLowerCase().includes(needle) || contact.id.toLowerCase().includes(needle)
|
||||
)
|
||||
}, [contacts, filter])
|
||||
|
||||
return (
|
||||
<RadioGroup
|
||||
value={selectedId}
|
||||
onValueChange={onSelect}
|
||||
aria-label={ariaLabel}
|
||||
className="automation-leave-contact-picker"
|
||||
>
|
||||
<Input
|
||||
value={filter}
|
||||
onChange={(event) => setFilter(event.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
aria-label={searchPlaceholder}
|
||||
/>
|
||||
<div className="automation-leave-contact-list">
|
||||
{visibleContacts.length === 0 ? (
|
||||
<p className="automation-group-empty">{emptyText}</p>
|
||||
) : (
|
||||
visibleContacts.map((contact) => (
|
||||
<div key={contact.id} className="automation-leave-radio option">
|
||||
<RadioGroupItem value={contact.id} id={`leave-contact-${contact.id}`} />
|
||||
<label htmlFor={`leave-contact-${contact.id}`}>{contact.name}</label>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import * as React from 'react'
|
||||
import { Textarea } from '../../components/ui'
|
||||
import {
|
||||
GROUP_EXIT_NOTIFICATION_PLACEHOLDER_LABELS,
|
||||
GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH,
|
||||
GROUP_EXIT_NOTIFICATION_TEMPLATE_PLACEHOLDERS
|
||||
} from '../../../../shared/group-exit-monitor'
|
||||
|
||||
/** 在光标处插入一个变量;没有光标位置就追加到末尾。纯 UI 辅助。 */
|
||||
function insertTemplateVariable(
|
||||
template: string,
|
||||
variable: string,
|
||||
selectionStart?: number | null,
|
||||
selectionEnd?: number | null
|
||||
): string {
|
||||
const token = `{${variable}}`
|
||||
const start = typeof selectionStart === 'number' ? selectionStart : template.length
|
||||
const end = typeof selectionEnd === 'number' ? selectionEnd : start
|
||||
return `${template.slice(0, start)}${token}${template.slice(end)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* LeaveNotificationTemplateEditor —— 「3 · 通知内容」。
|
||||
*
|
||||
* 占位符清单直接复用 shared 常量 —— UI 能插的变量与真实解释逻辑必须是同一份,
|
||||
* 否则会出现"插得进去、发出去不变"的变量。
|
||||
*/
|
||||
|
||||
export interface LeaveNotificationTemplateEditorProps {
|
||||
template: string
|
||||
onChange: (next: string) => void
|
||||
}
|
||||
|
||||
export function LeaveNotificationTemplateEditor({
|
||||
template,
|
||||
onChange
|
||||
}: LeaveNotificationTemplateEditorProps): React.ReactElement {
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement | null>(null)
|
||||
|
||||
const insertVariable = (variable: string): void => {
|
||||
const element = textareaRef.current
|
||||
onChange(
|
||||
insertTemplateVariable(
|
||||
template,
|
||||
variable,
|
||||
element?.selectionStart ?? null,
|
||||
element?.selectionEnd ?? null
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="automation-leave-template">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
id="automation-leave-template"
|
||||
aria-label="退群通知模板内容"
|
||||
value={template}
|
||||
rows={11}
|
||||
maxLength={GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="automation-leave-template-editor"
|
||||
/>
|
||||
|
||||
<div className="automation-leave-template-meta">
|
||||
<span>支持</span>
|
||||
<span className="automation-leave-template-chips">
|
||||
{GROUP_EXIT_NOTIFICATION_TEMPLATE_PLACEHOLDERS.map((placeholder) => (
|
||||
<button
|
||||
key={placeholder}
|
||||
type="button"
|
||||
className="automation-leave-template-chip"
|
||||
// 悬停就能看到含义 —— 光看 {groupRemark} 这种符号谁也猜不出它是什么。
|
||||
title={`{${placeholder}}:${GROUP_EXIT_NOTIFICATION_PLACEHOLDER_LABELS[placeholder]}`}
|
||||
onClick={() => insertVariable(placeholder)}
|
||||
aria-label={`插入变量 ${placeholder}`}
|
||||
>
|
||||
{`{${placeholder}}`}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
<span className="automation-leave-template-count">
|
||||
{template.length}/{GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
先把最容易混的两项说清楚,再说兜底。
|
||||
实测有人把 {groupRemark} 当成群名(它其实是成员在本群的昵称),
|
||||
于是以为通知里已经有群名了 —— 所以这句必须放在最前面、直说区别。
|
||||
*/}
|
||||
<p className="automation-leave-template-note">
|
||||
{`{groupName}`} 是<strong>群聊名</strong>;{`{groupRemark}`} 是退群成员
|
||||
<strong>在本群的昵称</strong>(不是群名)。取不到值时:{`{groupName}`}、{`{user}`}、
|
||||
{`{wxid}`} 显示「未读取到」,{`{groupRemark}`} 显示「未设置」。
|
||||
悬停变量可看含义;通知只发送到上面选定的目标。
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import * as React from 'react'
|
||||
import {
|
||||
AUTOMATION_RULE_TEMPLATES,
|
||||
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
|
||||
describeLeaveNotificationTarget,
|
||||
describeMonitoredScope,
|
||||
describeRuleActions,
|
||||
describeRuleTrigger,
|
||||
type AutomationRule
|
||||
@@ -8,24 +11,36 @@ import {
|
||||
import { Button, Spinner, Switch } from '../../components/ui'
|
||||
|
||||
/**
|
||||
* RuleListPanel —— 规则列表(对应 Stitch 设计稿 2 的「规则」tab)。
|
||||
* RuleListPanel —— 规则列表(「规则」tab)。
|
||||
*
|
||||
* 两个区块:
|
||||
* 1. **已配置的规则** —— 真实可运行,带启停开关;
|
||||
* 1. **已配置的自动化** —— 全部是**真规则**,带启停开关;
|
||||
* 2. **模板入口** —— 尚未接通的能力必须**如实标注**为「即将支持」。
|
||||
* 这里最容易犯的错是把设计稿里的卡片全渲染成"已启用"的样子,
|
||||
* 用户点进去发现是空的 —— 那比拼不出这个功能更糟。
|
||||
*
|
||||
* 「退群通知」现在是真规则:状态按真实 rule 展示(运行中 / 已关闭 / 配置异常),
|
||||
* 不再有「待配置」「UI 预览」这类占位标识。
|
||||
*/
|
||||
|
||||
export interface LeaveNotificationCardModel {
|
||||
rule: AutomationRule
|
||||
/** 真实已监控群聊数量(来自退群监控)。 */
|
||||
monitoredCount: number
|
||||
/** 「指定好友」目标的显示名;未选 / 不是该目标时为空串。 */
|
||||
contactName: string
|
||||
}
|
||||
|
||||
export interface RuleListPanelProps {
|
||||
rules: AutomationRule[]
|
||||
groups: Array<{ id: string; name: string }>
|
||||
loading: boolean
|
||||
busyRuleId: string | null
|
||||
leaveNotification: LeaveNotificationCardModel | null
|
||||
onToggle: (rule: AutomationRule, enabled: boolean) => void
|
||||
onEdit: (rule: AutomationRule) => void
|
||||
onDelete: (rule: AutomationRule) => void
|
||||
onCreate: () => void
|
||||
/** 打开「退群通知」规则类型。 */
|
||||
onEditLeaveNotification: () => void
|
||||
}
|
||||
|
||||
/** 把规则里的 conversationIds 翻成群名;找不到时显示占位而不是裸 id。 */
|
||||
@@ -40,22 +55,41 @@ function scopeLabel(rule: AutomationRule, groups: Array<{ id: string; name: stri
|
||||
return `${names.slice(0, 2).join('、')} 等 ${names.length} 个群`
|
||||
}
|
||||
|
||||
/**
|
||||
* 退群通知的真实状态。
|
||||
*
|
||||
* 「配置异常」只对应一种真实情况:迁移时旧「通知群聊」无法无损映射,
|
||||
* 规则被标成待重选目标 —— 这时它不会发送,需要用户处理。
|
||||
*/
|
||||
function leaveStatus(rule: AutomationRule): { label: string; tone: 'on' | 'off' | 'pending' } {
|
||||
if (rule.leaveNotification?.targetNeedsReview) return { label: '配置异常', tone: 'pending' }
|
||||
return rule.enabled ? { label: '运行中', tone: 'on' } : { label: '已关闭', tone: 'off' }
|
||||
}
|
||||
|
||||
export function RuleListPanel({
|
||||
rules,
|
||||
groups,
|
||||
loading,
|
||||
busyRuleId,
|
||||
leaveNotification,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onCreate
|
||||
onCreate,
|
||||
onEditLeaveNotification
|
||||
}: RuleListPanelProps): React.ReactElement {
|
||||
// 退群通知有自己的卡片,别在「已配置的自动化」里重复渲染一遍。
|
||||
const configuredRules = rules.filter((rule) => rule.id !== BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
|
||||
const leaveRule = leaveNotification?.rule ?? null
|
||||
const leaveState = leaveRule ? leaveStatus(leaveRule) : null
|
||||
const configuredCount = configuredRules.length + (leaveRule ? 1 : 0)
|
||||
|
||||
return (
|
||||
<div className="automation-rule-list">
|
||||
<section className="automation-rule-group">
|
||||
<div className="automation-section-heading">
|
||||
<h2>已配置的自动化</h2>
|
||||
<span className="automation-section-note">{rules.length} 条</span>
|
||||
<span className="automation-section-note">{configuredCount} 条</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
@@ -63,68 +97,123 @@ export function RuleListPanel({
|
||||
<Spinner />
|
||||
<span>正在读取规则…</span>
|
||||
</div>
|
||||
) : rules.length === 0 ? (
|
||||
<div className="automation-empty-card">
|
||||
<p>还没有任何自动化。</p>
|
||||
<Button onClick={onCreate}>新建自动化</Button>
|
||||
</div>
|
||||
) : (
|
||||
rules.map((rule) => (
|
||||
<article key={rule.id} className={`automation-rule-card ${rule.enabled ? '' : 'disabled'}`}>
|
||||
<div className="automation-rule-card-main">
|
||||
<div className="automation-rule-card-title">
|
||||
<h3>{rule.name}</h3>
|
||||
<span className={`automation-rule-state ${rule.enabled ? 'on' : 'off'}`}>
|
||||
{rule.enabled ? '运行中' : '已停用'}
|
||||
</span>
|
||||
<>
|
||||
{!configuredRules.length && !leaveRule ? (
|
||||
<div className="automation-empty-card">
|
||||
<p>还没有任何自动化。</p>
|
||||
<Button onClick={onCreate}>新建自动化</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{configuredRules.map((rule) => (
|
||||
<article
|
||||
key={rule.id}
|
||||
className={`automation-rule-card ${rule.enabled ? '' : 'disabled'}`}
|
||||
>
|
||||
<div className="automation-rule-card-main">
|
||||
<div className="automation-rule-card-title">
|
||||
<h3>{rule.name}</h3>
|
||||
<span className={`automation-rule-state ${rule.enabled ? 'on' : 'off'}`}>
|
||||
{rule.enabled ? '运行中' : '已停用'}
|
||||
</span>
|
||||
</div>
|
||||
<dl className="automation-rule-meta">
|
||||
<div>
|
||||
<dt>触发</dt>
|
||||
<dd>{describeRuleTrigger(rule)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>范围</dt>
|
||||
<dd>{scopeLabel(rule, groups)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>动作</dt>
|
||||
<dd>{describeRuleActions(rule)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>间隔</dt>
|
||||
<dd>{rule.cooldownSeconds > 0 ? `${rule.cooldownSeconds} 秒` : '不限制'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<dl className="automation-rule-meta">
|
||||
<div>
|
||||
<dt>触发</dt>
|
||||
<dd>{describeRuleTrigger(rule)}</dd>
|
||||
<div className="automation-rule-card-side">
|
||||
<Switch
|
||||
checked={rule.enabled}
|
||||
disabled={busyRuleId === rule.id}
|
||||
onCheckedChange={(checked) => onToggle(rule, checked)}
|
||||
aria-label={`${rule.name} 启停`}
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={() => onEdit(rule)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={() => onDelete(rule)}
|
||||
disabled={busyRuleId === rule.id}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
|
||||
{leaveRule && leaveState ? (
|
||||
<article
|
||||
className={`automation-rule-card ${leaveRule.enabled && leaveState.tone === 'on' ? '' : 'disabled'}`}
|
||||
>
|
||||
<div className="automation-rule-card-main">
|
||||
<div className="automation-rule-card-title">
|
||||
<h3>{leaveRule.name}</h3>
|
||||
<span className={`automation-rule-state ${leaveState.tone}`}>
|
||||
{leaveState.label}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<dt>范围</dt>
|
||||
<dd>{scopeLabel(rule, groups)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>动作</dt>
|
||||
<dd>{describeRuleActions(rule)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>间隔</dt>
|
||||
<dd>{rule.cooldownSeconds > 0 ? `${rule.cooldownSeconds} 秒` : '不限制'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div className="automation-rule-card-side">
|
||||
<Switch
|
||||
checked={rule.enabled}
|
||||
disabled={busyRuleId === rule.id}
|
||||
onCheckedChange={(checked) => onToggle(rule, checked)}
|
||||
aria-label={`${rule.name} 启停`}
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={() => onEdit(rule)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={() => onDelete(rule)}
|
||||
disabled={busyRuleId === rule.id}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
<dl className="automation-rule-meta">
|
||||
<div>
|
||||
<dt>触发</dt>
|
||||
<dd>{describeRuleTrigger(leaveRule)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>范围</dt>
|
||||
<dd>{describeMonitoredScope(leaveNotification?.monitoredCount ?? 0)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>动作</dt>
|
||||
<dd>{describeRuleActions(leaveRule)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>目标</dt>
|
||||
<dd>
|
||||
{describeLeaveNotificationTarget(
|
||||
leaveRule.leaveNotification,
|
||||
leaveNotification?.contactName
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div className="automation-rule-card-side">
|
||||
<Switch
|
||||
checked={leaveRule.enabled}
|
||||
disabled={busyRuleId === leaveRule.id}
|
||||
onCheckedChange={(checked) => onToggle(leaveRule, checked)}
|
||||
aria-label={`${leaveRule.name} 启停`}
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={onEditLeaveNotification}>
|
||||
编辑
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="automation-rule-group">
|
||||
<div className="automation-section-heading">
|
||||
<h2>更多模板</h2>
|
||||
<span className="automation-section-note">后续版本开放</span>
|
||||
</div>
|
||||
<div className="automation-template-grid">
|
||||
{AUTOMATION_RULE_TEMPLATES.map((template) => (
|
||||
@@ -134,9 +223,7 @@ export function RuleListPanel({
|
||||
<span className="automation-template-flag">即将支持</span>
|
||||
</div>
|
||||
<p>{template.description}</p>
|
||||
{template.unavailableReason ? (
|
||||
<small>{template.unavailableReason}</small>
|
||||
) : null}
|
||||
{template.unavailableReason ? <small>{template.unavailableReason}</small> : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
AutomationRuleDraft,
|
||||
AutomationStatusSummary
|
||||
} from '../../../../../shared/automation'
|
||||
import type { GroupExitMonitorState } from '../../../../../shared/group-exit-monitor'
|
||||
|
||||
/**
|
||||
* `window.api` 的薄封装。
|
||||
@@ -91,5 +92,31 @@ export const automationApi = {
|
||||
|
||||
clearExecutions: (): Promise<boolean> => invoke('clearAutomationExecutions', false),
|
||||
|
||||
listGroups: (): Promise<AutomationGroupOption[]> => invoke('listAutomationGroups', [])
|
||||
listGroups: (): Promise<AutomationGroupOption[]> => invoke('listAutomationGroups', []),
|
||||
|
||||
/** 保存「退群通知」规则(singleton upsert,id 由 main 侧固定)。 */
|
||||
saveLeaveNotificationRule: (draft: AutomationRuleDraft): Promise<AutomationRule | null> =>
|
||||
invoke<AutomationRule | null>('saveLeaveNotificationRule', null, draft),
|
||||
|
||||
/**
|
||||
* 「指定好友」的可选项。
|
||||
*
|
||||
* 过滤(群聊 / 公众号 / 文件传输助手 / 自己)在 main 侧完成 ——
|
||||
* 那里才知道当前登录账号是谁。渲染层不再筛一遍,避免两套规则漂移。
|
||||
*/
|
||||
listSendableContacts: (): Promise<AutomationGroupOption[]> =>
|
||||
invoke('listSendableContacts', []),
|
||||
|
||||
/**
|
||||
* 真实已监控群聊数量。
|
||||
*
|
||||
* 数据源是**退群监控**,自动化不维护副本(§「Automation 不做二次 group filter」)。
|
||||
* 取数与退群监控页保持同一表达式,否则两个页面会对同一个数字给出不同答案。
|
||||
*/
|
||||
async getMonitoredGroupCount(): Promise<number> {
|
||||
const state = await invoke<GroupExitMonitorState | null>('getGroupExitMonitorState', null)
|
||||
if (!state) return 0
|
||||
if (state.monitorSelectionConfigured) return (state.monitoredRoomIds || []).length
|
||||
return Number(state.monitoredGroupCount) || 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,16 +4,12 @@ import {
|
||||
Checkbox,
|
||||
EmptyState,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Switch,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger
|
||||
@@ -21,20 +17,30 @@ import {
|
||||
import type { Contact } from '../../../../shared/types'
|
||||
import {
|
||||
GROUP_EXIT_NOTIFICATION_TEMPLATE,
|
||||
GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH,
|
||||
renderGroupExitMonitorNotification,
|
||||
validateGroupExitNotificationTemplate,
|
||||
type GroupExitMonitorEvent,
|
||||
type GroupExitMonitorState
|
||||
} from '../../../../shared/group-exit-monitor'
|
||||
import { BUILTIN_LEAVE_NOTIFICATION_RULE_ID } from '../../../../shared/automation'
|
||||
import type { PersonalWechatSendCapability } from '../../../../shared/personal-wechat'
|
||||
import { LeaveMonitorAutomationEntry } from './LeaveMonitorAutomationEntry'
|
||||
|
||||
type GroupSelectionFilter = 'all' | 'selected' | 'unselected'
|
||||
|
||||
/** 从「自动化 → 退群通知」跳回来时直接打开哪一页。 */
|
||||
export interface GroupExitMonitorOpenViewRequest {
|
||||
view: 'events' | 'manage'
|
||||
requestId: number
|
||||
}
|
||||
|
||||
interface GroupExitMonitorWorkspaceProps {
|
||||
dbReady: boolean
|
||||
contacts?: Contact[]
|
||||
onOpenSendSettings?: () => void
|
||||
/** 深链请求:直接落到「退群监控 → 管理群聊」。 */
|
||||
openViewRequest?: GroupExitMonitorOpenViewRequest | null
|
||||
/** 「退群通知自动化 →」:跳到自动化里对应的规则类型(纯导航)。 */
|
||||
onOpenLeaveNotificationAutomation?: () => void
|
||||
}
|
||||
|
||||
const EMPTY_STATE: GroupExitMonitorState = {
|
||||
@@ -45,7 +51,6 @@ const EMPTY_STATE: GroupExitMonitorState = {
|
||||
monitoredGroupCount: 0,
|
||||
monitorSelectionConfigured: true,
|
||||
monitoredRoomIds: [],
|
||||
notificationRoomIds: [],
|
||||
lastReadAt: 0,
|
||||
unreadCount: 0
|
||||
}
|
||||
@@ -85,56 +90,6 @@ const sendCapabilityLabel = (capability: PersonalWechatSendCapability | null): s
|
||||
const sendCapabilityTone = (capability: PersonalWechatSendCapability | null): string =>
|
||||
capability?.ready && capability.capabilities.text ? 'ready' : 'unready'
|
||||
|
||||
function EventNotificationStatus({
|
||||
event,
|
||||
onOpenSendSettings,
|
||||
onResend,
|
||||
resending
|
||||
}: {
|
||||
event: GroupExitMonitorState['events'][number]
|
||||
onOpenSendSettings?: () => void
|
||||
onResend?: () => void
|
||||
resending?: boolean
|
||||
}): React.ReactElement {
|
||||
const status = event.notificationStatus || event.notification?.status || 'not_requested'
|
||||
const details =
|
||||
status === 'sent'
|
||||
? { label: '已通知当前群聊', tone: 'sent', description: '' }
|
||||
: status === 'pending'
|
||||
? { label: '正在通知当前群聊', tone: 'pending', description: '' }
|
||||
: status === 'blocked'
|
||||
? { label: '通知已拦截', tone: 'blocked', description: '该操作未通过发送策略检查。' }
|
||||
: status === 'failed'
|
||||
? {
|
||||
label: '通知未发送',
|
||||
tone: 'failed',
|
||||
description:
|
||||
event.notification?.errorCode === 'SEND_CAPABILITY_UNAVAILABLE' ||
|
||||
event.notification?.errorCode === 'SEND_NOT_READY'
|
||||
? '当前微信发送能力不可用,退群事件已正常记录。'
|
||||
: '退群事件已正常记录,但通知发送失败。'
|
||||
}
|
||||
: { label: '仅记录', tone: 'recorded', description: '' }
|
||||
return (
|
||||
<div className={`exit-monitor-event-notification ${details.tone}`}>
|
||||
<span>{details.label}</span>
|
||||
{details.description ? <small>{details.description}</small> : null}
|
||||
{(event.notification?.errorCode === 'SEND_CAPABILITY_UNAVAILABLE' ||
|
||||
event.notification?.errorCode === 'SEND_NOT_READY') &&
|
||||
onOpenSendSettings ? (
|
||||
<Button variant="link" size="sm" onClick={onOpenSendSettings}>
|
||||
去设置
|
||||
</Button>
|
||||
) : null}
|
||||
{status !== 'sent' && status !== 'pending' && onResend ? (
|
||||
<Button variant="link" size="sm" onClick={onResend} disabled={resending}>
|
||||
{resending ? '发送中...' : '重新发送'}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SendCapabilityStatus({
|
||||
capability,
|
||||
className = ''
|
||||
@@ -160,88 +115,12 @@ function SendCapabilityStatus({
|
||||
)
|
||||
}
|
||||
|
||||
function GroupExitNotificationTemplate({
|
||||
template,
|
||||
saving,
|
||||
error,
|
||||
onSave
|
||||
}: {
|
||||
template: string
|
||||
saving: boolean
|
||||
error: string
|
||||
onSave: (template: string) => Promise<boolean>
|
||||
}): React.ReactElement {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [draft, setDraft] = React.useState(template)
|
||||
const [validationError, setValidationError] = React.useState('')
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) setDraft(template)
|
||||
}, [open, template])
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean): void => {
|
||||
setOpen(nextOpen)
|
||||
setValidationError('')
|
||||
if (nextOpen) setDraft(template)
|
||||
}
|
||||
|
||||
const saveDraft = async (): Promise<void> => {
|
||||
const result = validateGroupExitNotificationTemplate(draft)
|
||||
if (!result.valid || !result.template) {
|
||||
setValidationError(result.error || '模板无效')
|
||||
return
|
||||
}
|
||||
setValidationError('')
|
||||
const saved = await onSave(result.template)
|
||||
if (saved) setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" aria-label="查看退群监测模板">
|
||||
模板
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="exit-monitor-template-popover">
|
||||
<div className="exit-monitor-template-heading">
|
||||
<div>
|
||||
<span className="exit-monitor-eyebrow">消息格式</span>
|
||||
<h2>退群监测模板</h2>
|
||||
</div>
|
||||
<span className="exit-monitor-template-status">发送时使用</span>
|
||||
</div>
|
||||
<Textarea
|
||||
id="exit-monitor-notification-template"
|
||||
aria-label="退群监测模板内容"
|
||||
value={draft}
|
||||
rows={10}
|
||||
maxLength={GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
className="exit-monitor-template-editor"
|
||||
/>
|
||||
<div className="exit-monitor-template-meta">
|
||||
<span>
|
||||
支持 {`{user}`}、{`{groupRemark}`}、{`{wxid}`}、{`{previousCount}`}、{`{currentCount}`}
|
||||
、{`{time}`}
|
||||
</span>
|
||||
<span>
|
||||
{draft.length}/{GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH}
|
||||
</span>
|
||||
</div>
|
||||
<p>退群时会替换占位符;群备注指退群前的群内昵称,没有群备注时显示“未设置”。</p>
|
||||
{validationError || error ? (
|
||||
<p className="exit-monitor-template-error">{validationError || error}</p>
|
||||
) : null}
|
||||
<div className="exit-monitor-template-actions">
|
||||
<Button size="sm" onClick={() => void saveDraft()} disabled={saving}>
|
||||
{saving ? '保存中...' : '保存模板'}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
/*
|
||||
* 「退群监测模板」编辑入口**已从这里移除**。
|
||||
*
|
||||
* 模板的唯一编辑处现在是「自动化 → 规则 → 退群通知 → 3 · 通知内容」。
|
||||
* 退群监控只产生退群事实,不再持有通知模板 —— 两处可编辑必然漂移。
|
||||
*/
|
||||
|
||||
function GroupAvatar({ contact }: { contact: Contact }): React.ReactElement {
|
||||
const name = contactDisplayName(contact)
|
||||
@@ -256,7 +135,6 @@ function GroupAvatar({ contact }: { contact: Contact }): React.ReactElement {
|
||||
interface ManageGroupsProps {
|
||||
groups: Contact[]
|
||||
selectedRoomIds: Set<string>
|
||||
notificationRoomIds: Set<string>
|
||||
sendCapability: PersonalWechatSendCapability | null
|
||||
keyword: string
|
||||
listFilter: GroupSelectionFilter
|
||||
@@ -265,20 +143,14 @@ interface ManageGroupsProps {
|
||||
onKeywordChange: (keyword: string) => void
|
||||
onListFilterChange: (filter: GroupSelectionFilter) => void
|
||||
onToggle: (roomId: string) => void
|
||||
onToggleNotification: (roomId: string) => void
|
||||
onToggleAll: () => void
|
||||
onSave: () => void
|
||||
onCancel: () => void
|
||||
notificationTemplate: string
|
||||
templateSaving: boolean
|
||||
templateError: string
|
||||
onSaveTemplate: (template: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
function ManageGroups({
|
||||
groups,
|
||||
selectedRoomIds,
|
||||
notificationRoomIds,
|
||||
sendCapability,
|
||||
keyword,
|
||||
listFilter,
|
||||
@@ -287,14 +159,9 @@ function ManageGroups({
|
||||
onKeywordChange,
|
||||
onListFilterChange,
|
||||
onToggle,
|
||||
onToggleNotification,
|
||||
onToggleAll,
|
||||
onSave,
|
||||
onCancel,
|
||||
notificationTemplate,
|
||||
templateSaving,
|
||||
templateError,
|
||||
onSaveTemplate
|
||||
onCancel
|
||||
}: ManageGroupsProps): React.ReactElement {
|
||||
const lowerKeyword = keyword.trim().toLowerCase()
|
||||
const filteredGroups = React.useMemo(() => {
|
||||
@@ -326,12 +193,6 @@ function ManageGroups({
|
||||
<p>选择需要持续关注成员变化的群聊,保存后会重新建立成员快照。</p>
|
||||
</div>
|
||||
<div className="exit-monitor-manage-header-status">
|
||||
<GroupExitNotificationTemplate
|
||||
template={notificationTemplate}
|
||||
saving={templateSaving}
|
||||
error={templateError}
|
||||
onSave={onSaveTemplate}
|
||||
/>
|
||||
<SendCapabilityStatus capability={sendCapability} />
|
||||
<div className="exit-monitor-manage-count" aria-live="polite">
|
||||
已选择 <strong>{selectedRoomIds.size}</strong> 个群聊
|
||||
@@ -394,7 +255,6 @@ function ManageGroups({
|
||||
const roomId = contactRoomId(contact)
|
||||
const name = contactDisplayName(contact)
|
||||
const checked = selectedRoomIds.has(roomId)
|
||||
const notificationChecked = notificationRoomIds.has(roomId)
|
||||
return (
|
||||
<div key={roomId} className={`exit-monitor-group-row ${checked ? 'selected' : ''}`}>
|
||||
<Checkbox
|
||||
@@ -412,15 +272,6 @@ function ManageGroups({
|
||||
<span className="exit-monitor-group-row-status">
|
||||
{checked ? '监控中' : '未选择'}
|
||||
</span>
|
||||
<label className="exit-monitor-group-notification">
|
||||
<Checkbox
|
||||
checked={notificationChecked}
|
||||
disabled={!checked}
|
||||
onCheckedChange={() => onToggleNotification(roomId)}
|
||||
aria-label={`是否通知${name}`}
|
||||
/>
|
||||
<span>通知群聊</span>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -435,7 +286,9 @@ function ManageGroups({
|
||||
</section>
|
||||
|
||||
<footer className="exit-monitor-manage-footer">
|
||||
<span>保存后将从新的成员快照开始记录退群动态;通知仅发送到已勾选群聊。</span>
|
||||
<span>
|
||||
保存后将从新的成员快照开始记录退群动态;退群通知发到哪里,请在「自动化 → 退群通知」里配置。
|
||||
</span>
|
||||
<div>
|
||||
<Button variant="ghost" onClick={onCancel} disabled={saving}>
|
||||
取消
|
||||
@@ -452,7 +305,9 @@ function ManageGroups({
|
||||
export function GroupExitMonitorWorkspace({
|
||||
dbReady,
|
||||
contacts = [],
|
||||
onOpenSendSettings
|
||||
onOpenSendSettings,
|
||||
openViewRequest,
|
||||
onOpenLeaveNotificationAutomation
|
||||
}: GroupExitMonitorWorkspaceProps): React.ReactElement {
|
||||
const [state, setState] = React.useState<GroupExitMonitorState>(EMPTY_STATE)
|
||||
const [checking, setChecking] = React.useState(false)
|
||||
@@ -462,21 +317,23 @@ export function GroupExitMonitorWorkspace({
|
||||
const [sendCapability, setSendCapability] = React.useState<PersonalWechatSendCapability | null>(
|
||||
null
|
||||
)
|
||||
const [templateSaving, setTemplateSaving] = React.useState(false)
|
||||
const [templateError, setTemplateError] = React.useState('')
|
||||
const [selectedRoomId, setSelectedRoomId] = React.useState('all')
|
||||
const [view, setView] = React.useState<'events' | 'manage'>('events')
|
||||
const [resendingEventId, setResendingEventId] = React.useState<string | null>(null)
|
||||
const [copiedEventId, setCopiedEventId] = React.useState('')
|
||||
/**
|
||||
* 「复制退群信息」用的模板。
|
||||
*
|
||||
* 已迁到自动化规则;这里只是把它读回来给剪贴板用(见下面的 effect)。
|
||||
*/
|
||||
const [copyNoticeTemplate, setCopyNoticeTemplate] = React.useState(
|
||||
GROUP_EXIT_NOTIFICATION_TEMPLATE
|
||||
)
|
||||
const copyResetTimer = React.useRef<number | null>(null)
|
||||
const [manageKeyword, setManageKeyword] = React.useState('')
|
||||
const [manageFilter, setManageFilter] = React.useState<GroupSelectionFilter>('all')
|
||||
const [selectedManageRoomIds, setSelectedManageRoomIds] = React.useState<Set<string>>(
|
||||
() => new Set()
|
||||
)
|
||||
const [selectedManageNotificationRoomIds, setSelectedManageNotificationRoomIds] = React.useState<
|
||||
Set<string>
|
||||
>(() => new Set())
|
||||
|
||||
const groups = React.useMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
@@ -526,6 +383,36 @@ export function GroupExitMonitorWorkspace({
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 「自动化 → 退群通知 → 管理监控群聊」深链过来时,直接落到管理群聊页。
|
||||
React.useEffect(() => {
|
||||
if (!openViewRequest) return
|
||||
setView(openViewRequest.view)
|
||||
}, [openViewRequest])
|
||||
|
||||
/**
|
||||
* 「复制退群信息」用的模板。
|
||||
*
|
||||
* 模板**唯一来源**是「自动化 → 退群通知」规则 —— 退群监控自己不再持有模板。
|
||||
* 读不到就回落默认模板(复制功能本身不依赖自动化是否启用)。
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
const api = typeof window !== 'undefined' ? window.api : undefined
|
||||
if (!api || typeof api.listAutomationRules !== 'function') return
|
||||
let disposed = false
|
||||
void api
|
||||
.listAutomationRules()
|
||||
.then((rules) => {
|
||||
if (disposed) return
|
||||
const rule = rules.find((item) => item.id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
|
||||
const template = rule?.leaveNotification?.template?.trim()
|
||||
setCopyNoticeTemplate(template || GROUP_EXIT_NOTIFICATION_TEMPLATE)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
return () => {
|
||||
disposed = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
if (copyResetTimer.current !== null) window.clearTimeout(copyResetTimer.current)
|
||||
@@ -572,21 +459,15 @@ export function GroupExitMonitorWorkspace({
|
||||
}, [state.events])
|
||||
const setupEmpty =
|
||||
state.monitorSelectionConfigured === true && !(state.monitoredRoomIds || []).length
|
||||
const notificationTemplate = state.notificationTemplate || GROUP_EXIT_NOTIFICATION_TEMPLATE
|
||||
|
||||
const openManage = (): void => {
|
||||
const availableRoomIds = new Set(groups.map(contactRoomId))
|
||||
const defaultIds = state.monitorSelectionConfigured ? state.monitoredRoomIds || [] : []
|
||||
const selectedIds = new Set(defaultIds.filter((roomId) => availableRoomIds.has(roomId)))
|
||||
const notificationIds = new Set(
|
||||
(state.notificationRoomIds || []).filter((roomId) => selectedIds.has(roomId))
|
||||
)
|
||||
setSelectedManageRoomIds(selectedIds)
|
||||
setSelectedManageNotificationRoomIds(notificationIds)
|
||||
setManageKeyword('')
|
||||
setManageFilter('all')
|
||||
setError('')
|
||||
setTemplateError('')
|
||||
setView('manage')
|
||||
}
|
||||
|
||||
@@ -615,26 +496,15 @@ export function GroupExitMonitorWorkspace({
|
||||
}
|
||||
}
|
||||
|
||||
const resendEvent = async (eventId: string): Promise<void> => {
|
||||
if (resendingEventId || typeof window.api.resendGroupExitMonitorEvent !== 'function') return
|
||||
setResendingEventId(eventId)
|
||||
setError('')
|
||||
try {
|
||||
setState(await window.api.resendGroupExitMonitorEvent(eventId))
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : String(reason))
|
||||
} finally {
|
||||
setResendingEventId(null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动复制退群信息:按“管理群聊”里保存的模板拼接当前事件,只写系统剪贴板,
|
||||
* 手动复制退群信息:按「自动化 → 退群通知」里的模板拼接当前事件,只写系统剪贴板,
|
||||
* 不触发任何微信发送,用户自行决定粘贴到哪里。
|
||||
*
|
||||
* 发送与重发**都不在这里** —— 通知由自动化执行,重发走执行日志。
|
||||
*/
|
||||
const copyEventNotification = async (event: GroupExitMonitorEvent): Promise<void> => {
|
||||
const api = typeof window !== 'undefined' ? window.api : undefined
|
||||
const text = renderGroupExitMonitorNotification(event, notificationTemplate)
|
||||
const text = renderGroupExitMonitorNotification(event, copyNoticeTemplate)
|
||||
setError('')
|
||||
try {
|
||||
const result = api && typeof api.copyText === 'function' ? await api.copyText(text) : null
|
||||
@@ -656,13 +526,11 @@ export function GroupExitMonitorWorkspace({
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
// 只保存**监控范围**:通知配置属于自动化,不从这一页写。
|
||||
const selectedIds = groups
|
||||
.map(contactRoomId)
|
||||
.filter((roomId) => selectedManageRoomIds.has(roomId))
|
||||
const notificationIds = selectedIds.filter((roomId) =>
|
||||
selectedManageNotificationRoomIds.has(roomId)
|
||||
)
|
||||
const nextState = await api.setGroupExitMonitorGroups(selectedIds, notificationIds)
|
||||
const nextState = await api.setGroupExitMonitorGroups(selectedIds)
|
||||
setState(nextState)
|
||||
setView('events')
|
||||
} catch (reason) {
|
||||
@@ -672,26 +540,6 @@ export function GroupExitMonitorWorkspace({
|
||||
}
|
||||
}
|
||||
|
||||
const saveNotificationTemplate = async (template: string): Promise<boolean> => {
|
||||
const api = window.api
|
||||
if (templateSaving || typeof api.setGroupExitMonitorNotificationTemplate !== 'function') {
|
||||
setTemplateError('当前版本不支持保存退群监测模板')
|
||||
return false
|
||||
}
|
||||
setTemplateSaving(true)
|
||||
setTemplateError('')
|
||||
try {
|
||||
const nextState = await api.setGroupExitMonitorNotificationTemplate(template)
|
||||
setState(nextState)
|
||||
return true
|
||||
} catch (reason) {
|
||||
setTemplateError(reason instanceof Error ? reason.message : String(reason))
|
||||
return false
|
||||
} finally {
|
||||
setTemplateSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const setMonitorEnabled = async (enabled: boolean): Promise<void> => {
|
||||
const api = window.api
|
||||
if (togglingEnabled || typeof api.setGroupExitMonitorEnabled !== 'function') return
|
||||
@@ -707,20 +555,12 @@ export function GroupExitMonitorWorkspace({
|
||||
}
|
||||
|
||||
const toggleManageGroup = (roomId: string): void => {
|
||||
const wasSelected = selectedManageRoomIds.has(roomId)
|
||||
setSelectedManageRoomIds((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(roomId)) next.delete(roomId)
|
||||
else next.add(roomId)
|
||||
return next
|
||||
})
|
||||
if (wasSelected) {
|
||||
setSelectedManageNotificationRoomIds((notifications) => {
|
||||
const nextNotifications = new Set(notifications)
|
||||
nextNotifications.delete(roomId)
|
||||
return nextNotifications
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const toggleAllManageGroups = (): void => {
|
||||
@@ -734,17 +574,6 @@ export function GroupExitMonitorWorkspace({
|
||||
})
|
||||
return next
|
||||
})
|
||||
if (!shouldSelect) setSelectedManageNotificationRoomIds(new Set())
|
||||
}
|
||||
|
||||
const toggleManageNotification = (roomId: string): void => {
|
||||
if (!selectedManageRoomIds.has(roomId)) return
|
||||
setSelectedManageNotificationRoomIds((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(roomId)) next.delete(roomId)
|
||||
else next.add(roomId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
if (view === 'manage') {
|
||||
@@ -752,7 +581,6 @@ export function GroupExitMonitorWorkspace({
|
||||
<ManageGroups
|
||||
groups={groups}
|
||||
selectedRoomIds={selectedManageRoomIds}
|
||||
notificationRoomIds={selectedManageNotificationRoomIds}
|
||||
sendCapability={sendCapability}
|
||||
keyword={manageKeyword}
|
||||
listFilter={manageFilter}
|
||||
@@ -761,14 +589,9 @@ export function GroupExitMonitorWorkspace({
|
||||
onKeywordChange={setManageKeyword}
|
||||
onListFilterChange={setManageFilter}
|
||||
onToggle={toggleManageGroup}
|
||||
onToggleNotification={toggleManageNotification}
|
||||
onToggleAll={toggleAllManageGroups}
|
||||
onSave={() => void saveGroups()}
|
||||
onCancel={() => setView('events')}
|
||||
notificationTemplate={notificationTemplate}
|
||||
templateSaving={templateSaving}
|
||||
templateError={templateError}
|
||||
onSaveTemplate={saveNotificationTemplate}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -806,6 +629,19 @@ export function GroupExitMonitorWorkspace({
|
||||
{statusLabel}
|
||||
</span>
|
||||
<SendCapabilityStatus capability={sendCapability} />
|
||||
{/*
|
||||
发送能力不完整时给一个可操作的去处。
|
||||
§「规则仍然可以保存」:这里只提示,不阻断任何编辑。
|
||||
*/}
|
||||
{sendCapability &&
|
||||
!(sendCapability.ready && sendCapability.capabilities?.text) &&
|
||||
onOpenSendSettings ? (
|
||||
<Button variant="link" size="sm" onClick={onOpenSendSettings}>
|
||||
去设置发送能力
|
||||
</Button>
|
||||
) : null}
|
||||
{/* 检测到退群之后做什么,归自动化管;这里只是个入口。 */}
|
||||
<LeaveMonitorAutomationEntry onOpen={onOpenLeaveNotificationAutomation} />
|
||||
<Button variant="outline" size="sm" onClick={openManage}>
|
||||
管理群聊
|
||||
</Button>
|
||||
@@ -921,12 +757,10 @@ export function GroupExitMonitorWorkspace({
|
||||
群人数 {event.previousCount} 人 → {event.currentCount} 人
|
||||
</p>
|
||||
) : null}
|
||||
<EventNotificationStatus
|
||||
event={event}
|
||||
onOpenSendSettings={onOpenSendSettings}
|
||||
onResend={() => void resendEvent(event.id)}
|
||||
resending={resendingEventId === event.id}
|
||||
/>
|
||||
{/*
|
||||
这里**不再显示通知状态**:发送结果归「自动化 → 执行日志」。
|
||||
退群监控只对"谁、哪个群、什么时候、人数怎么变"负责。
|
||||
*/}
|
||||
<dl className="exit-monitor-event-details">
|
||||
<div>
|
||||
<dt>微信名</dt>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react'
|
||||
import { Button } from '../../components/ui'
|
||||
|
||||
/**
|
||||
* LeaveMonitorAutomationEntry —— 退群监控页上的「退群通知自动化」入口。
|
||||
*
|
||||
* 只做导航:跳到「自动化 → 规则 → 退群通知」。
|
||||
* 它不变更退群监控的任何配置,也不触发发送。
|
||||
*
|
||||
* 之所以把职责拆开说清楚:退群监控负责「谁退出了」,
|
||||
* 自动化负责「检测到之后做什么」—— 入口名字必须体现这个分界。
|
||||
*/
|
||||
export function LeaveMonitorAutomationEntry({
|
||||
onOpen
|
||||
}: {
|
||||
onOpen?: () => void
|
||||
}): React.ReactElement | null {
|
||||
if (!onOpen) return null
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onOpen}
|
||||
aria-label="配置退群通知自动化"
|
||||
className="exit-monitor-automation-entry"
|
||||
>
|
||||
退群通知自动化 →
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -13,7 +13,21 @@ import {
|
||||
} from '../../components/ui'
|
||||
|
||||
type StatusFilter = 'all' | WechatActionStatus
|
||||
type PurposeFilter = 'all' | 'member_left_notification' | 'scheduled_report' | 'other'
|
||||
type PurposeFilter = 'all' | 'leave_notification' | 'scheduled_report' | 'other'
|
||||
|
||||
/**
|
||||
* 「退群通知」的发送用途。
|
||||
*
|
||||
* - `automation_leave_notification`:迁移后由自动化发出的退群通知(**当前唯一生产者**);
|
||||
* - `member_left_notification`:迁移前退群监控自己发的,只在**历史审计记录**里存在。
|
||||
*
|
||||
* 两个都归到「退群通知」这一个筛选项下 —— 否则用户翻旧日志时会发现
|
||||
* 明明写着「退群通知」却筛不出来。
|
||||
*/
|
||||
const LEAVE_NOTIFICATION_PURPOSES: readonly string[] = [
|
||||
'automation_leave_notification',
|
||||
'member_left_notification'
|
||||
]
|
||||
|
||||
const sourceLabel = (source: ActionLogEntry['source']): string => {
|
||||
if (source === 'member_monitor') return '退群监控'
|
||||
@@ -27,7 +41,7 @@ const triggerLabel = (triggerType: ActionLogEntry['triggerType']): string =>
|
||||
triggerType === 'user' ? '用户触发' : '自动化'
|
||||
|
||||
const purposeLabel = (purpose: WechatActionPurpose): string => {
|
||||
if (purpose === 'member_left_notification') return '退群通知'
|
||||
if (LEAVE_NOTIFICATION_PURPOSES.includes(purpose)) return '退群通知'
|
||||
if (purpose === 'scheduled_report') return '定时日报'
|
||||
if (purpose === 'tts_voice') return '文字转语音'
|
||||
return purpose || '其它动作'
|
||||
@@ -51,7 +65,12 @@ const statusDetails = (entry: ActionLogEntry): { label: string; className: strin
|
||||
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 (
|
||||
!LEAVE_NOTIFICATION_PURPOSES.includes(entry.purpose) && entry.purpose !== 'scheduled_report'
|
||||
)
|
||||
}
|
||||
if (filter === 'leave_notification') {
|
||||
return LEAVE_NOTIFICATION_PURPOSES.includes(entry.purpose)
|
||||
}
|
||||
return entry.purpose === filter
|
||||
}
|
||||
@@ -133,7 +152,7 @@ export function LogsWorkspace(): React.ReactElement {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部类型</SelectItem>
|
||||
<SelectItem value="member_left_notification">退群通知</SelectItem>
|
||||
<SelectItem value="leave_notification">退群通知</SelectItem>
|
||||
<SelectItem value="scheduled_report">定时日报</SelectItem>
|
||||
<SelectItem value="other">其它动作</SelectItem>
|
||||
</SelectContent>
|
||||
|
||||
@@ -227,6 +227,13 @@
|
||||
background: var(--wxex-bg-app);
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
// 视觉稿卡片:虚线边框表示"还没有真实配置"。
|
||||
// 刻意不降透明度 —— 它需要被当成正常的一张卡来评估信息架构。
|
||||
&.preview {
|
||||
border-style: dashed;
|
||||
background: var(--wxex-bg-app);
|
||||
}
|
||||
}
|
||||
|
||||
.automation-rule-card-main {
|
||||
@@ -267,6 +274,14 @@
|
||||
background: color-mix(in srgb, var(--wxex-text-muted) 18%, transparent);
|
||||
color: var(--wxex-text-secondary);
|
||||
}
|
||||
|
||||
// 「退群通知」尚未迁移真实功能,状态如实写「待配置」——
|
||||
// 不给它 success 色,避免和真正在跑的规则混淆。
|
||||
&.pending {
|
||||
border: 1px dashed color-mix(in srgb, var(--wxex-text-muted) 45%, transparent);
|
||||
background: transparent;
|
||||
color: var(--wxex-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.automation-rule-meta {
|
||||
@@ -1179,3 +1194,292 @@
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 规则类型(@我生成日报 / 退群通知)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// 只是「规则」内部的一层导航。视觉继续用现有 token,
|
||||
// 这里不引入第二套配色,也不做卡片化的 Dashboard 重构。
|
||||
|
||||
.automation-rule-type-shell {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.automation-rule-type-tabs {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.automation-rule-type-label {
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.automation-rule-type-note {
|
||||
margin: 0;
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 退群通知编辑器
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
.automation-leave-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
// 只读的「已监控群聊」摘要:范围归退群监控管,这里不重复维护一份选择状态。
|
||||
.automation-leave-groups-card {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-sm);
|
||||
background: var(--wxex-bg-app);
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
}
|
||||
}
|
||||
|
||||
// 「通知发送到哪里」四选一。
|
||||
.automation-leave-targets {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
// 单选项一行:radio + (可选)标题/说明两行。
|
||||
// 联系人单选也复用同一行样式,所以只有一处定义。
|
||||
.automation-leave-radio {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.option {
|
||||
padding: 7px 10px;
|
||||
border-radius: var(--wxex-radius-sm);
|
||||
|
||||
&:hover {
|
||||
background: var(--wxex-bg-app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.automation-leave-radio-body {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
small {
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
.automation-leave-contact-picker {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.automation-leave-contact-list {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
max-height: 200px;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-sm);
|
||||
background: var(--wxex-bg-app);
|
||||
}
|
||||
|
||||
.automation-leave-template {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.automation-leave-template-editor {
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 19px;
|
||||
}
|
||||
|
||||
.automation-leave-template-meta {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.automation-leave-template-chips {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.automation-leave-template-chip {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: 999px;
|
||||
background: var(--wxex-bg-app);
|
||||
color: var(--wxex-text-secondary);
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--wxex-brand) 40%, var(--wxex-border));
|
||||
background: var(--wxex-brand-soft);
|
||||
color: var(--wxex-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.automation-leave-template-count {
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.automation-leave-template-note {
|
||||
margin: 0;
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 退群通知效果预览
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
.automation-preview-chat-title {
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--wxex-border);
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
// 退群监控产生的系统事件,不是聊天消息 —— 所以居中、弱化,不做成气泡。
|
||||
.automation-preview-system {
|
||||
margin: 0;
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// 通知模板是多行文本,必须保留换行,否则 JSON 式内容会糊成一行。
|
||||
.automation-chat-bubble.pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
// 退群通知的「已解析目标」(文件传输助手 / 我 / 张三…)——
|
||||
// 只显示用户能理解的称呼,不含任何 id。
|
||||
.automation-step-detail {
|
||||
margin: 4px 0 0;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/*
|
||||
* 需要「正文 + 说明 + 一个动作按钮」的提示块。
|
||||
*
|
||||
* 用 grid 而不是靠 inline-block 自然换行:按钮必须**独占一行**
|
||||
* (历史要求:不靠 fixed width / transform 之类手段凑布局)。
|
||||
*/
|
||||
.automation-notice-stack {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
justify-items: start;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 13px;
|
||||
line-height: 19px;
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* 「正在编辑:<规则名>」。
|
||||
*
|
||||
* 必须显眼且与保存目标同源:用户报过"从退群通知切到日报后保存,却改到了日报",
|
||||
* 根因就是编辑目标不可见 —— 界面只显示表单,不显示它在改哪条规则。
|
||||
*/
|
||||
.automation-editing-target {
|
||||
margin: 0 0 6px;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
|
||||
strong {
|
||||
color: var(--wxex-text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,69 +157,6 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.exit-monitor-template-popover {
|
||||
width: min(390px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.exit-monitor-template-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.exit-monitor-template-heading h2 {
|
||||
margin: 2px 0 0;
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.exit-monitor-template-status {
|
||||
flex: 0 0 auto;
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.exit-monitor-template-editor {
|
||||
margin: 14px 0 0;
|
||||
min-height: 190px;
|
||||
resize: vertical;
|
||||
background: color-mix(in srgb, var(--wxex-bg-main) 70%, var(--wxex-bg-elevated));
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.exit-monitor-template-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 6px;
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.exit-monitor-template-popover p {
|
||||
margin: 10px 0 0;
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.exit-monitor-template-error {
|
||||
color: var(--wxex-danger) !important;
|
||||
}
|
||||
|
||||
.exit-monitor-template-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.exit-monitor-manage-count strong {
|
||||
margin: 0 4px;
|
||||
color: var(--wxex-brand);
|
||||
@@ -363,40 +300,6 @@
|
||||
color: var(--wxex-danger);
|
||||
}
|
||||
|
||||
.exit-monitor-event-notification {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
margin-top: 7px;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.exit-monitor-event-notification span {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.exit-monitor-event-notification small {
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.exit-monitor-event-notification.sent {
|
||||
color: var(--wxex-success);
|
||||
}
|
||||
|
||||
.exit-monitor-event-notification.blocked,
|
||||
.exit-monitor-event-notification.failed {
|
||||
color: var(--wxex-danger);
|
||||
}
|
||||
|
||||
.exit-monitor-event-notification.pending {
|
||||
color: var(--wxex-brand);
|
||||
}
|
||||
|
||||
.exit-monitor-event-details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -629,25 +532,6 @@
|
||||
color: var(--wxex-brand);
|
||||
}
|
||||
|
||||
.exit-monitor-group-notification {
|
||||
display: inline-flex;
|
||||
min-width: 82px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 7px;
|
||||
color: var(--wxex-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.exit-monitor-group-notification:has([data-disabled]) {
|
||||
color: var(--wxex-text-muted);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.exit-monitor-manage-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -749,12 +633,6 @@
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.exit-monitor-group-notification {
|
||||
grid-column: 4;
|
||||
grid-row: 2;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.exit-monitor-manage-footer {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface AutomationMatchInput {
|
||||
}
|
||||
|
||||
export type AutomationMatchFailure =
|
||||
| 'rule_type'
|
||||
| 'disabled'
|
||||
| 'self'
|
||||
| 'scope'
|
||||
@@ -52,6 +53,15 @@ export function matchAutomationRule(
|
||||
/** 自己的 username 候选集(`getMyUsernameCandidates()`)。 */
|
||||
selfUsernames: readonly string[]
|
||||
): AutomationMatchResult {
|
||||
// **规则类型闸门放在最前面。**
|
||||
//
|
||||
// 「退群通知」的空关键词 + 不要求 @我,会让它命中**每一条群消息** ——
|
||||
// 那是最坏的一类 bug:用户什么都没配,规则却开始乱跑。
|
||||
// 类型分派必须在这里一处解决,预览与真实执行才会一致。
|
||||
if (rule.ruleType === 'leave_notification') {
|
||||
return { matched: false, reason: 'rule_type' }
|
||||
}
|
||||
|
||||
if (!rule.enabled) return { matched: false, reason: 'disabled' }
|
||||
|
||||
// 防死循环第一道闸:自己发的消息默认不触发。
|
||||
|
||||
+278
-18
@@ -5,6 +5,8 @@
|
||||
* 第一版不做:regex、IF/ELSE 嵌套、可视化编排、Webhook、通用 AI 机器人。
|
||||
*/
|
||||
|
||||
import { normalizeGroupExitNotificationTemplate } from './group-exit-monitor'
|
||||
|
||||
/** 触发事件类型。第一版只支持「收到消息」。 */
|
||||
export type AutomationTriggerType = 'message'
|
||||
|
||||
@@ -19,6 +21,49 @@ export type KeywordMatchMode = 'contains' | 'exact' | 'prefix'
|
||||
/** 消息来源过滤。 */
|
||||
export type AutomationMessageScope = 'group' | 'direct' | 'all'
|
||||
|
||||
/**
|
||||
* 规则类型。
|
||||
*
|
||||
* 决定**这条规则由什么驱动、有哪些字段有意义**:
|
||||
* - `daily_report`:消息驱动,条件 + 动作链(@我生成日报);
|
||||
* - `leave_notification`:退群事件驱动,通知目标 + 模板(退群通知)。
|
||||
*
|
||||
* ⚠️ 这是一条**硬分派**:`matchAutomationRule` 会直接拒绝非 `daily_report` 的规则,
|
||||
* 否则一条 keyword 为空的退群通知会命中**每一条群消息**。
|
||||
*/
|
||||
export type AutomationRuleType = 'daily_report' | 'leave_notification'
|
||||
|
||||
/** 规则类型的界面标签(Tab 与首页卡片共用一处文案)。 */
|
||||
export const AUTOMATION_RULE_TYPE_LABELS: Record<AutomationRuleType, string> = {
|
||||
daily_report: '@我生成日报',
|
||||
leave_notification: '退群通知'
|
||||
}
|
||||
|
||||
/** 退群通知发到哪里。刻意只有这四种。 */
|
||||
export type LeaveNotificationTargetType = 'source_chat' | 'self' | 'file_transfer' | 'contact'
|
||||
|
||||
export interface LeaveNotificationTarget {
|
||||
type: LeaveNotificationTargetType
|
||||
/**
|
||||
* `contact` 时必填。
|
||||
*
|
||||
* **稳定 id(wxid)**,不是显示昵称 —— 昵称会改,改完就发错人。
|
||||
*/
|
||||
contactId?: string
|
||||
}
|
||||
|
||||
export interface LeaveNotificationConfig {
|
||||
target: LeaveNotificationTarget
|
||||
template: string
|
||||
/**
|
||||
* 迁移时旧配置**无法无损映射**的标记(旧「通知群聊」是 per-group 多值,
|
||||
* 新目标是单值)。为 true 时:规则不执行发送,UI 提示用户重选目标。
|
||||
*
|
||||
* 只有迁移会把它置为 true,用户手动保存一次即清除。
|
||||
*/
|
||||
targetNeedsReview?: boolean
|
||||
}
|
||||
|
||||
export interface AutomationConditions {
|
||||
/** 必须**真正** @我(走 source → atuserlist,不是正文里的 @昵称)。 */
|
||||
requireMentionMe: boolean
|
||||
@@ -44,6 +89,8 @@ export interface AutomationRule {
|
||||
id: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
/** 规则类型。缺省视为 `daily_report`(历史 rules.json 没有这个字段)。 */
|
||||
ruleType: AutomationRuleType
|
||||
trigger: AutomationTriggerType
|
||||
scope: AutomationMessageScope
|
||||
conditions: AutomationConditions
|
||||
@@ -61,6 +108,8 @@ export interface AutomationRule {
|
||||
* `normalizeReplyDelaySeconds()` 处理 ⇒ 缺省即默认 2 秒,不是「不等待」。
|
||||
*/
|
||||
replyDelaySeconds: number
|
||||
/** `ruleType === 'leave_notification'` 时必填。 */
|
||||
leaveNotification?: LeaveNotificationConfig
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
@@ -68,8 +117,26 @@ export interface AutomationRule {
|
||||
/** 执行步骤状态。`skipped` 用于「上一步失败所以这一步不做」。 */
|
||||
export type AutomationStepStatus = 'pending' | 'running' | 'success' | 'failed' | 'skipped'
|
||||
|
||||
/** 执行步骤键。只有**真正进入执行**的规则才会有步骤。 */
|
||||
export type AutomationStepKey = 'received' | 'matched' | 'reply' | 'report' | 'send'
|
||||
/**
|
||||
* 执行步骤键。
|
||||
*
|
||||
* 分两族,**不共用**:
|
||||
* - 消息型(`daily_report`):received / matched / reply / report / send
|
||||
* - 退群型(`leave_notification`):exit_received / exit_matched / exit_target / exit_send
|
||||
*
|
||||
* 不共用的理由:给退群通知渲染一个「回复确认 已跳过」的步骤是纯噪声,
|
||||
* 用户会以为规则配错了。
|
||||
*/
|
||||
export type AutomationStepKey =
|
||||
| 'received'
|
||||
| 'matched'
|
||||
| 'reply'
|
||||
| 'report'
|
||||
| 'send'
|
||||
| 'exit_received'
|
||||
| 'exit_matched'
|
||||
| 'exit_target'
|
||||
| 'exit_send'
|
||||
|
||||
export interface AutomationStep {
|
||||
key: AutomationStepKey
|
||||
@@ -79,6 +146,13 @@ export interface AutomationStep {
|
||||
startedAt?: number
|
||||
finishedAt?: number
|
||||
durationMs?: number
|
||||
/**
|
||||
* 用户可读的**细节**(例如已解析的通知目标显示名「文件传输助手」)。
|
||||
*
|
||||
* ⚠️ 展示层契约:只允许放用户能理解的名称。
|
||||
* **禁止** wxid / chatroom id / localId / serverId / 文件路径 / 原始 payload。
|
||||
*/
|
||||
detail?: string
|
||||
/** 用户可读的错误信息。**禁止**含 wxid / 文件路径 / 原始 payload。 */
|
||||
error?: string
|
||||
/**
|
||||
@@ -143,7 +217,15 @@ export const AUTOMATION_SEND_PURPOSE = {
|
||||
/** 触发确认文字回复。 */
|
||||
reply: 'automation_reply',
|
||||
/** 日报图片。 */
|
||||
report: 'automation_report'
|
||||
report: 'automation_report',
|
||||
/**
|
||||
* 退群通知文字。
|
||||
*
|
||||
* 刻意**不复用**历史 purpose `member_left_notification`:那一个被 WechatActionGateway
|
||||
* 的作用域锁绑死在「只能发回事件所在群」,而现在的目标可以是自己 / 文件传输助手 /
|
||||
* 指定好友。用新 purpose 才能真正解除那个锁,同时让旧 purpose 变成无生产者的历史值。
|
||||
*/
|
||||
leaveNotification: 'automation_leave_notification'
|
||||
} as const
|
||||
|
||||
/** 审计日志里的来源标识(`WechatActionOrigin`)。 */
|
||||
@@ -194,6 +276,7 @@ export function automationIdempotencyKey(
|
||||
export interface AutomationRuleDraft {
|
||||
name: string
|
||||
enabled: boolean
|
||||
ruleType: AutomationRuleType
|
||||
trigger: AutomationTriggerType
|
||||
scope: AutomationMessageScope
|
||||
conditions: AutomationConditions
|
||||
@@ -201,6 +284,8 @@ export interface AutomationRuleDraft {
|
||||
cooldownSeconds: number
|
||||
/** 命中后等多久再回复确认(秒)。0 = 立刻回复。 */
|
||||
replyDelaySeconds: number
|
||||
/** `ruleType === 'leave_notification'` 时的通知配置。 */
|
||||
leaveNotification?: LeaveNotificationConfig
|
||||
}
|
||||
|
||||
/** 顶部状态条数据(全部来自 main 侧真实能力,UI 不做平台判断)。 */
|
||||
@@ -244,33 +329,41 @@ export const AUTOMATION_RULE_TEMPLATES: AutomationRuleTemplate[] = [
|
||||
name: '定时发送日报',
|
||||
description: '按固定时间自动生成并发送群日报',
|
||||
available: false,
|
||||
unavailableReason: '沿用现有「定时日报」能力,本轮不迁移'
|
||||
},
|
||||
{
|
||||
id: 'member-exit-notice',
|
||||
name: '退群通知',
|
||||
description: '群成员退出时自动发送通知',
|
||||
available: false,
|
||||
unavailableReason: '现有「退群监控」已具备该能力,本轮不迁移'
|
||||
unavailableReason: ''
|
||||
},
|
||||
{
|
||||
id: 'keyword-reply',
|
||||
name: '关键词自动回复',
|
||||
description: '消息命中关键词时自动回复指定内容',
|
||||
available: false,
|
||||
unavailableReason: '本轮验收对象为「@我生成日报」,关键词模板下一轮开放'
|
||||
unavailableReason: ''
|
||||
}
|
||||
]
|
||||
|
||||
/** 内置规则 id。固定值,便于升级时不重复创建。 */
|
||||
export const BUILTIN_DAILY_REPORT_RULE_ID = 'builtin-mention-me-daily-report'
|
||||
|
||||
/**
|
||||
* 内置「退群通知」规则 id。
|
||||
*
|
||||
* 这是一个 **singleton / system rule**:保存永远按这个 id upsert,
|
||||
* 不允许"每打开一次编辑页多一条规则"。
|
||||
*/
|
||||
export const BUILTIN_LEAVE_NOTIFICATION_RULE_ID = 'builtin-leave-notification'
|
||||
|
||||
/** 退群通知的默认展示名。 */
|
||||
export const LEAVE_NOTIFICATION_RULE_NAME = '退群通知'
|
||||
|
||||
export const AUTOMATION_STEP_LABELS: Record<AutomationStepKey, string> = {
|
||||
received: '收到消息',
|
||||
matched: '规则匹配',
|
||||
reply: '回复确认',
|
||||
report: '生成日报',
|
||||
send: '发送日报图片'
|
||||
send: '发送日报图片',
|
||||
exit_received: '检测到成员退出',
|
||||
exit_matched: '规则匹配',
|
||||
exit_target: '确定通知目标',
|
||||
exit_send: '发送退群通知'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -316,8 +409,80 @@ export function matchKeyword(
|
||||
return haystack.includes(needle)
|
||||
}
|
||||
|
||||
/**
|
||||
* 退群通知的四种发送目标(展示层单一来源)。
|
||||
*
|
||||
* UI 单选项与首页卡片摘要都从这里取文案,避免同一句话在两个文件里各写一份、慢慢漂移。
|
||||
* **顺序即界面顺序**:`当前群聊` 排第一,因为它也是默认值。
|
||||
*/
|
||||
export const LEAVE_NOTIFICATION_TARGET_OPTIONS: ReadonlyArray<{
|
||||
type: LeaveNotificationTargetType
|
||||
label: string
|
||||
description: string
|
||||
}> = [
|
||||
{
|
||||
type: 'source_chat',
|
||||
label: '当前群聊',
|
||||
description: '将通知发送到发生成员退出的群聊。'
|
||||
},
|
||||
{
|
||||
type: 'file_transfer',
|
||||
label: '文件传输助手',
|
||||
description: '将通知发送到文件传输助手。'
|
||||
},
|
||||
{
|
||||
type: 'self',
|
||||
label: '发给自己',
|
||||
description: '将通知发送到当前登录微信账号自己的会话。'
|
||||
},
|
||||
{
|
||||
type: 'contact',
|
||||
label: '指定好友',
|
||||
description: '将通知发送给一个指定联系人。'
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* 新安装 / 无历史配置时的默认目标。
|
||||
*
|
||||
* 「当前群聊」= 旧「退群监控」通知的原始行为(通知发回事件所在群),
|
||||
* 所以它既是默认值、也排在选项第一位。
|
||||
*/
|
||||
export const LEAVE_NOTIFICATION_DEFAULT_TARGET_TYPE: LeaveNotificationTargetType = 'source_chat'
|
||||
|
||||
/** 目标类型 → 界面短标签。 */
|
||||
export function leaveNotificationTargetLabel(type: LeaveNotificationTargetType): string {
|
||||
return LEAVE_NOTIFICATION_TARGET_OPTIONS.find((option) => option.type === type)?.label ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 「范围」摘要:退群通知覆盖多少个已监控群聊。
|
||||
*
|
||||
* 数字**必须**来自退群监控(`GroupExitMonitorState`),自动化不维护副本。
|
||||
*/
|
||||
export function describeMonitoredScope(monitoredCount: number): string {
|
||||
return `${Math.max(0, Math.floor(Number(monitoredCount) || 0))} 个已监控群聊`
|
||||
}
|
||||
|
||||
/**
|
||||
* 目标的一句话描述(首页卡片摘要用)。
|
||||
*
|
||||
* `contact` 需要外部把联系人显示名传进来 —— shared 不读通讯录。
|
||||
* 名字拿不到时如实说「未选择好友」,不伪造一个收件人。
|
||||
*/
|
||||
export function describeLeaveNotificationTarget(
|
||||
config: LeaveNotificationConfig | undefined,
|
||||
contactDisplayName?: string
|
||||
): string {
|
||||
const target = config?.target
|
||||
if (!target) return '未配置'
|
||||
if (target.type === 'contact') return contactDisplayName?.trim() || '未选择好友'
|
||||
return leaveNotificationTargetLabel(target.type)
|
||||
}
|
||||
|
||||
/** 触发条件的一句话摘要(UI 展示用,不含任何内部 id)。 */
|
||||
export function describeRuleTrigger(rule: AutomationRule): string {
|
||||
if (rule.ruleType === 'leave_notification') return '检测到群成员退出'
|
||||
const parts: string[] = []
|
||||
if (rule.conditions.requireMentionMe) parts.push('@我')
|
||||
if (rule.conditions.keyword.trim()) {
|
||||
@@ -328,6 +493,7 @@ export function describeRuleTrigger(rule: AutomationRule): string {
|
||||
|
||||
/** 动作链的一句话摘要。 */
|
||||
export function describeRuleActions(rule: AutomationRule): string {
|
||||
if (rule.ruleType === 'leave_notification') return '发送退群通知'
|
||||
const labels: Record<AutomationActionType, string> = {
|
||||
replyText: '回复确认',
|
||||
generateReport: '生成日报',
|
||||
@@ -354,6 +520,7 @@ export function createDefaultDailyReportRule(now: number): AutomationRule {
|
||||
id: BUILTIN_DAILY_REPORT_RULE_ID,
|
||||
name: '@我生成日报',
|
||||
enabled: true,
|
||||
ruleType: 'daily_report',
|
||||
trigger: 'message',
|
||||
scope: 'group',
|
||||
conditions: {
|
||||
@@ -375,10 +542,66 @@ export function createDefaultDailyReportRule(now: number): AutomationRule {
|
||||
}
|
||||
}
|
||||
|
||||
/** 构造内置规则「退群通知」(singleton,固定 id)。 */
|
||||
export function createDefaultLeaveNotificationRule(
|
||||
now: number,
|
||||
options: {
|
||||
template?: string
|
||||
target?: LeaveNotificationTarget
|
||||
enabled?: boolean
|
||||
targetNeedsReview?: boolean
|
||||
} = {}
|
||||
): AutomationRule {
|
||||
return {
|
||||
id: BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
|
||||
name: LEAVE_NOTIFICATION_RULE_NAME,
|
||||
// 规则级启停是**唯一**开关;退群通知没有第二个"发送"开关。
|
||||
enabled: options.enabled !== false,
|
||||
ruleType: 'leave_notification',
|
||||
// 由退群事件驱动,不参与消息匹配。这两个字段保留只是为了 schema 完整。
|
||||
trigger: 'message',
|
||||
scope: 'group',
|
||||
conditions: {
|
||||
requireMentionMe: false,
|
||||
keyword: '',
|
||||
keywordMatchMode: 'contains',
|
||||
conversationIds: [],
|
||||
ignoreSelf: true
|
||||
},
|
||||
// 退群通知没有动作链,动作由 leaveNotification 表达。
|
||||
actions: [],
|
||||
// 不套用消息型规则的 cooldown:两次退群不能被 60 秒窗口合并。
|
||||
cooldownSeconds: 0,
|
||||
replyDelaySeconds: AUTOMATION_REPLY_DELAY_DEFAULT_SECONDS,
|
||||
leaveNotification: {
|
||||
target: options.target ?? { type: LEAVE_NOTIFICATION_DEFAULT_TARGET_TYPE },
|
||||
template: normalizeLeaveNotificationTemplate(options.template),
|
||||
...(options.targetNeedsReview ? { targetNeedsReview: true } : {})
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 模板归一化。
|
||||
*
|
||||
* 复用退群监控那份校验/归一化(含 legacy 模板升级),保证
|
||||
* 「模板规则只有一处实现」——迁移不产生第二套占位符解析。
|
||||
*/
|
||||
export function normalizeLeaveNotificationTemplate(value: unknown): string {
|
||||
return normalizeGroupExitNotificationTemplate(value)
|
||||
}
|
||||
|
||||
/** 归一化用户提交的草稿:把未知值收敛成合法值,避免脏数据落盘。 */
|
||||
export function normalizeRuleDraft(input: unknown, fallbackName = '未命名自动化'): AutomationRuleDraft {
|
||||
export function normalizeRuleDraft(
|
||||
input: unknown,
|
||||
fallbackName = '未命名自动化'
|
||||
): AutomationRuleDraft {
|
||||
const raw = (input ?? {}) as Partial<AutomationRuleDraft>
|
||||
const conditions = (raw.conditions ?? {}) as Partial<AutomationConditions>
|
||||
const ruleType: AutomationRuleType =
|
||||
raw.ruleType === 'leave_notification' ? 'leave_notification' : 'daily_report'
|
||||
const keywordMatchMode: KeywordMatchMode =
|
||||
conditions.keywordMatchMode === 'exact' || conditions.keywordMatchMode === 'prefix'
|
||||
? conditions.keywordMatchMode
|
||||
@@ -393,14 +616,18 @@ export function normalizeRuleDraft(input: unknown, fallbackName = '未命名自
|
||||
enabled: action.enabled !== false,
|
||||
...(typeof action.text === 'string' ? { text: action.text } : {})
|
||||
}))
|
||||
.filter((action) =>
|
||||
action.type === 'replyText' || action.type === 'generateReport' || action.type === 'sendReportImage'
|
||||
.filter(
|
||||
(action) =>
|
||||
action.type === 'replyText' ||
|
||||
action.type === 'generateReport' ||
|
||||
action.type === 'sendReportImage'
|
||||
)
|
||||
const cooldown = Number(raw.cooldownSeconds)
|
||||
return {
|
||||
const base = {
|
||||
name: String(raw.name ?? '').trim() || fallbackName,
|
||||
enabled: raw.enabled !== false,
|
||||
trigger: 'message',
|
||||
ruleType,
|
||||
trigger: 'message' as AutomationTriggerType,
|
||||
scope,
|
||||
conditions: {
|
||||
requireMentionMe: conditions.requireMentionMe !== false,
|
||||
@@ -415,4 +642,37 @@ export function normalizeRuleDraft(input: unknown, fallbackName = '未命名自
|
||||
cooldownSeconds: Number.isFinite(cooldown) ? Math.max(0, Math.floor(cooldown)) : 60,
|
||||
replyDelaySeconds: normalizeReplyDelaySeconds(raw.replyDelaySeconds)
|
||||
}
|
||||
if (ruleType !== 'leave_notification') return base
|
||||
return { ...base, leaveNotification: normalizeLeaveNotificationConfig(raw.leaveNotification) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 收敛退群通知配置。
|
||||
*
|
||||
* 目标是**必填**的:缺失一律回落到默认目标(文件传输助手),
|
||||
* 而不是留一个"没有目标"的规则 —— 那种规则在运行时只能失败。
|
||||
* `contact` 缺 contactId 时同样回落,避免存下一条永远发不出去的规则。
|
||||
*/
|
||||
export function normalizeLeaveNotificationConfig(input: unknown): LeaveNotificationConfig {
|
||||
const raw = (input ?? {}) as Partial<LeaveNotificationConfig>
|
||||
const targetRaw = (raw.target ?? {}) as Partial<LeaveNotificationTarget>
|
||||
const allowed: LeaveNotificationTargetType[] = ['source_chat', 'self', 'file_transfer', 'contact']
|
||||
const type: LeaveNotificationTargetType = allowed.includes(
|
||||
targetRaw.type as LeaveNotificationTargetType
|
||||
)
|
||||
? (targetRaw.type as LeaveNotificationTargetType)
|
||||
: LEAVE_NOTIFICATION_DEFAULT_TARGET_TYPE
|
||||
const contactId = String(targetRaw.contactId ?? '').trim()
|
||||
// `contact` 但没有 id → 回落默认目标;宁可发到文件助手,也不能存一条必然失败的规则。
|
||||
const effectiveType: LeaveNotificationTargetType =
|
||||
type === 'contact' && !contactId ? LEAVE_NOTIFICATION_DEFAULT_TARGET_TYPE : type
|
||||
return {
|
||||
target: {
|
||||
type: effectiveType,
|
||||
...(effectiveType === 'contact' ? { contactId } : {})
|
||||
},
|
||||
template: normalizeLeaveNotificationTemplate(raw.template),
|
||||
// 用户手动保存一次即视为已确认目标,清除迁移提示。
|
||||
...(raw.targetNeedsReview === true ? { targetNeedsReview: true } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
buildGroupExitNotificationValues,
|
||||
renderGroupExitNotificationTemplate,
|
||||
type GroupExitMonitorEvent
|
||||
} from './group-exit-monitor'
|
||||
|
||||
/**
|
||||
* GroupMemberExitedEvent —— 退群监控交给自动化消费的**稳定事件契约**。
|
||||
*
|
||||
* 为什么不让 Automation 直接吃 `GroupExitMonitorEvent`:
|
||||
* 后者是退群监控的**存储形态**(含 delta / message 展示串 / 历史通知状态),
|
||||
* 会随监控自身的演进而变。Automation 只需要「谁、在哪个群、什么时候、人数怎么变」,
|
||||
* 所以这里显式定义一份最小契约 + 一个纯映射函数。
|
||||
*
|
||||
* `eventId` 是**唯一键**,直接复用退群记录自己的 id(`GroupExitMonitorEvent.id`)——
|
||||
* 不允许用昵称 / 群名 / 显示文本做唯一键。
|
||||
*/
|
||||
|
||||
export interface GroupMemberExitedEvent {
|
||||
/** 稳定事件 id(= 退群记录的 id)。Automation 侧幂等以它为准。 */
|
||||
eventId: string
|
||||
/** 发生退群的群(`xxx@chatroom`)。`source_chat` 目标就是它。 */
|
||||
conversationId: string
|
||||
/** 群显示名(用户可读,用于日志/展示,**不是**唯一键)。 */
|
||||
groupName?: string
|
||||
/** 成员在本群的备注。 */
|
||||
groupRemark?: string
|
||||
/** 退群成员的稳定 id(wxid)。唯一键之一,但**不进用户可见日志**。 */
|
||||
memberId: string
|
||||
/** 成员显示名(用户可读)。 */
|
||||
memberName?: string
|
||||
/** 联系人表里的微信昵称。 */
|
||||
wechatNickname?: string
|
||||
/** 通讯录备注。 */
|
||||
contactRemark?: string
|
||||
previousCount: number
|
||||
currentCount: number
|
||||
/** 事件发生时间(epoch ms)。 */
|
||||
occurredAt: number
|
||||
}
|
||||
|
||||
/** 纯映射:退群记录 → 自动化事件。 */
|
||||
export function toGroupMemberExitedEvent(event: GroupExitMonitorEvent): GroupMemberExitedEvent {
|
||||
const groupName = String(event.groupName || '').trim()
|
||||
const groupRemark = String(event.groupRemark || '').trim()
|
||||
const memberName = String(event.memberName || '').trim()
|
||||
const wechatNickname = String(event.wechatName || '').trim()
|
||||
const contactRemark = String(event.contactRemark || '').trim()
|
||||
return {
|
||||
eventId: String(event.id || '').trim(),
|
||||
conversationId: String(event.roomId || '').trim(),
|
||||
...(groupName ? { groupName } : {}),
|
||||
...(groupRemark ? { groupRemark } : {}),
|
||||
memberId: String(event.memberWxid || '').trim(),
|
||||
...(memberName ? { memberName } : {}),
|
||||
...(wechatNickname ? { wechatNickname } : {}),
|
||||
...(contactRemark ? { contactRemark } : {}),
|
||||
previousCount: Number(event.previousCount) || 0,
|
||||
currentCount: Number(event.currentCount) || 0,
|
||||
occurredAt: Number(event.detectedAt) || 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染退群通知文本。
|
||||
*
|
||||
* **复用**退群监控那份唯一的插值实现(`renderGroupExitNotificationTemplate`)——
|
||||
* 迁移不允许产生第二套模板 regex,否则预览与实发迟早不一致。
|
||||
*/
|
||||
export function renderLeaveNotificationText(
|
||||
event: GroupMemberExitedEvent,
|
||||
template: string
|
||||
): string {
|
||||
return renderGroupExitNotificationTemplate(
|
||||
template,
|
||||
buildGroupExitNotificationValues({
|
||||
groupName: event.groupName,
|
||||
wechatNickname: event.wechatNickname,
|
||||
memberName: event.memberName,
|
||||
memberId: event.memberId,
|
||||
groupRemark: event.groupRemark,
|
||||
previousCount: event.previousCount,
|
||||
currentCount: event.currentCount,
|
||||
occurredAt: event.occurredAt
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* **只服务效果预览**的样本事件。
|
||||
*
|
||||
* 它永远不会进入发送路径:渲染层拿它把模板渲染出来给用户看。
|
||||
* 时间取"此刻",所以预览里显示的是当前时间,而不是一个伪造的历史日期。
|
||||
* 集中定义在这里,避免散落的假数据被误当成真实事件。
|
||||
*
|
||||
* ⚠️ **样本必须与真实数据同形**,否则预览会骗人:
|
||||
* `groupRemark` 是「退群成员**在本群的昵称**」(真实来源 `member.groupNickname`),
|
||||
* 不是群名;群名是 `groupName`。两者取不同的值,区别才看得出来。
|
||||
*/
|
||||
export function createGroupExitPreviewSample(now: number): GroupMemberExitedEvent {
|
||||
return {
|
||||
eventId: 'preview-sample',
|
||||
conversationId: 'preview@chatroom',
|
||||
groupName: 'TraceMemo 交流群',
|
||||
// 成员在本群的昵称:刻意与 groupName 取不同的值,一眼能看出两者不是一回事。
|
||||
groupRemark: '小张',
|
||||
// 明确是占位符,**不要**长得像真实账号 —— 预览里的 {wxid} 不是任何人的 wxid。
|
||||
memberId: 'wxid_xxxxx',
|
||||
memberName: '张三',
|
||||
wechatNickname: '张三',
|
||||
previousCount: 243,
|
||||
currentCount: 242,
|
||||
occurredAt: now
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { WechatActionResult } from './wechat-action'
|
||||
|
||||
export interface GroupExitMonitorMember {
|
||||
wxid: string
|
||||
nickname?: string
|
||||
@@ -9,23 +7,6 @@ export interface GroupExitMonitorMember {
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
export type GroupExitNotificationStatus =
|
||||
| 'not_requested'
|
||||
| 'pending'
|
||||
| 'sent'
|
||||
| 'blocked'
|
||||
| 'failed'
|
||||
|
||||
export interface GroupExitNotificationState {
|
||||
status: GroupExitNotificationStatus
|
||||
actionId?: string
|
||||
decision?: WechatActionResult['decision']
|
||||
errorCode?: string
|
||||
reason?: string
|
||||
startedAt?: string
|
||||
finishedAt?: string
|
||||
}
|
||||
|
||||
export interface GroupExitMonitorEvent {
|
||||
id: string
|
||||
contactId: string
|
||||
@@ -44,9 +25,6 @@ export interface GroupExitMonitorEvent {
|
||||
delta: number
|
||||
message: string
|
||||
detectedAt: number
|
||||
/** 可选通知的结果;退群检测与通知发送彼此独立。 */
|
||||
notificationStatus?: GroupExitNotificationStatus
|
||||
notification?: GroupExitNotificationState
|
||||
}
|
||||
|
||||
const LEGACY_GROUP_EXIT_NOTIFICATION_TEMPLATE = [
|
||||
@@ -65,6 +43,8 @@ const LEGACY_GROUP_EXIT_NOTIFICATION_TEMPLATE = [
|
||||
export const GROUP_EXIT_NOTIFICATION_TEMPLATE = [
|
||||
'[退群监测]',
|
||||
'',
|
||||
'群聊: {groupName}',
|
||||
'',
|
||||
'用户: {user}',
|
||||
'',
|
||||
'群备注: {groupRemark}',
|
||||
@@ -78,6 +58,9 @@ export const GROUP_EXIT_NOTIFICATION_TEMPLATE = [
|
||||
|
||||
export const GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH = 2_000
|
||||
export const GROUP_EXIT_NOTIFICATION_TEMPLATE_PLACEHOLDERS = [
|
||||
// 群名排在最前:通知一旦发给「文件传输助手 / 自己 / 指定好友」,
|
||||
// 收件人第一件要知道的事就是**哪个群**退的人。
|
||||
'groupName',
|
||||
'user',
|
||||
'groupRemark',
|
||||
'wxid',
|
||||
@@ -86,6 +69,26 @@ export const GROUP_EXIT_NOTIFICATION_TEMPLATE_PLACEHOLDERS = [
|
||||
'time'
|
||||
] as const
|
||||
|
||||
/**
|
||||
* 每个变量的**中文含义**(编辑器悬停提示用)。
|
||||
*
|
||||
* 为什么必须有:变量清单原先只有 `{groupRemark}` 这种符号,用户看不出含义 ——
|
||||
* 实测有人把 `{groupRemark}` 当成"群名"(它其实是**成员在本群的昵称**),
|
||||
* 于是以为通知里已经有群名了。这类误会只能靠把含义写出来消除。
|
||||
*/
|
||||
export const GROUP_EXIT_NOTIFICATION_PLACEHOLDER_LABELS: Record<
|
||||
(typeof GROUP_EXIT_NOTIFICATION_TEMPLATE_PLACEHOLDERS)[number],
|
||||
string
|
||||
> = {
|
||||
groupName: '群聊名(发生退群的群)',
|
||||
user: '退群成员的显示名',
|
||||
groupRemark: '退群成员在本群的昵称(不是群名)',
|
||||
wxid: '成员的微信号',
|
||||
previousCount: '退群前人数',
|
||||
currentCount: '退群后人数',
|
||||
time: '退群时间'
|
||||
}
|
||||
|
||||
export interface GroupExitNotificationTemplateValidation {
|
||||
valid: boolean
|
||||
template?: string
|
||||
@@ -134,26 +137,20 @@ export function formatGroupExitMonitorTime(timestamp: number): string {
|
||||
)}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||
}
|
||||
|
||||
/** 根据模板生成通知文本。 */
|
||||
export function renderGroupExitMonitorNotification(
|
||||
event: GroupExitMonitorEvent,
|
||||
template = GROUP_EXIT_NOTIFICATION_TEMPLATE
|
||||
/**
|
||||
* 模板插值的**唯一实现**。
|
||||
*
|
||||
* 退群监控的「复制退群信息」与自动化的「退群通知」都走这里 ——
|
||||
* 迁移不允许产生第二套 regex,否则预览与实际发送迟早不一致。
|
||||
*/
|
||||
export function renderGroupExitNotificationTemplate(
|
||||
template: string,
|
||||
values: GroupExitNotificationValues
|
||||
): string {
|
||||
const user =
|
||||
event.wechatName?.trim() || event.memberName?.trim() || event.memberWxid || '未读取到'
|
||||
const groupRemark = event.groupRemark?.trim() || '未设置'
|
||||
const wxid = event.memberWxid?.trim() || '未读取到'
|
||||
const values = {
|
||||
user,
|
||||
groupRemark,
|
||||
wxid,
|
||||
previousCount: String(event.previousCount),
|
||||
currentCount: String(event.currentCount),
|
||||
time: formatGroupExitMonitorTime(event.detectedAt)
|
||||
}
|
||||
return normalizeGroupExitNotificationTemplate(template).replace(
|
||||
/\{(user|groupRemark|wxid|previousCount|currentCount|time)\}/g,
|
||||
/\{(groupName|user|groupRemark|wxid|previousCount|currentCount|time)\}/g,
|
||||
(placeholder) => {
|
||||
if (placeholder === '{groupName}') return values.groupName
|
||||
if (placeholder === '{user}') return values.user
|
||||
if (placeholder === '{groupRemark}') return values.groupRemark
|
||||
if (placeholder === '{wxid}') return values.wxid
|
||||
@@ -164,12 +161,64 @@ export function renderGroupExitMonitorNotification(
|
||||
)
|
||||
}
|
||||
|
||||
/** 主进程中的辅助函数,供生成通知和预览时共用。 */
|
||||
export function buildMemberLeftNotification(
|
||||
export interface GroupExitNotificationValues {
|
||||
/** 发生退群的**群显示名**(含群备注)。 */
|
||||
groupName: string
|
||||
/** 退群成员的显示名(微信名 → 群昵称 → 微信号 依次回退)。 */
|
||||
user: string
|
||||
/** 退群成员**在本群的昵称**。⚠️ 不是群名 —— 群名是 `groupName`。 */
|
||||
groupRemark: string
|
||||
wxid: string
|
||||
previousCount: string
|
||||
currentCount: string
|
||||
time: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 变量取值的**唯一实现**,含全部 fallback 规则。
|
||||
*
|
||||
* ⚠️ 这些 fallback 是历史产品行为(`未读取到` / `未设置`),迁移**不得**改变输出:
|
||||
* 退群监控与自动化都从这里取值,所以不存在"两处 fallback 漂移"的可能。
|
||||
*/
|
||||
export function buildGroupExitNotificationValues(input: {
|
||||
groupName?: string
|
||||
wechatNickname?: string
|
||||
memberName?: string
|
||||
memberId?: string
|
||||
groupRemark?: string
|
||||
previousCount: number
|
||||
currentCount: number
|
||||
occurredAt: number
|
||||
}): GroupExitNotificationValues {
|
||||
return {
|
||||
groupName: input.groupName?.trim() || '未读取到',
|
||||
user: input.wechatNickname?.trim() || input.memberName?.trim() || input.memberId || '未读取到',
|
||||
groupRemark: input.groupRemark?.trim() || '未设置',
|
||||
wxid: input.memberId?.trim() || '未读取到',
|
||||
previousCount: String(input.previousCount),
|
||||
currentCount: String(input.currentCount),
|
||||
time: formatGroupExitMonitorTime(input.occurredAt)
|
||||
}
|
||||
}
|
||||
|
||||
/** 根据模板生成通知文本(退群监控侧入口,保持既有签名与输出)。 */
|
||||
export function renderGroupExitMonitorNotification(
|
||||
event: GroupExitMonitorEvent,
|
||||
template = GROUP_EXIT_NOTIFICATION_TEMPLATE
|
||||
): string {
|
||||
return renderGroupExitMonitorNotification(event, template)
|
||||
return renderGroupExitNotificationTemplate(
|
||||
template,
|
||||
buildGroupExitNotificationValues({
|
||||
groupName: event.groupName,
|
||||
wechatNickname: event.wechatName,
|
||||
memberName: event.memberName,
|
||||
memberId: event.memberWxid,
|
||||
groupRemark: event.groupRemark,
|
||||
previousCount: event.previousCount,
|
||||
currentCount: event.currentCount,
|
||||
occurredAt: event.detectedAt
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export interface GroupExitMonitorState {
|
||||
@@ -185,10 +234,6 @@ export interface GroupExitMonitorState {
|
||||
monitorSelectionConfigured?: boolean
|
||||
/** 管理页选中的群。 */
|
||||
monitoredRoomIds?: string[]
|
||||
/** 需要发送通知的群。 */
|
||||
notificationRoomIds?: string[]
|
||||
/** 自动通知模板。 */
|
||||
notificationTemplate?: string
|
||||
lastCheckedAt?: number
|
||||
lastReadAt: number
|
||||
unreadCount: number
|
||||
@@ -219,3 +264,31 @@ export function findRemovedGroupMembers(
|
||||
seen.add(member.wxid)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 `群聊: {groupName}` 补进一份**已有**模板。
|
||||
*
|
||||
* 放在 shared 而不是 main:主进程的模板升级迁移要用它,
|
||||
* 编辑器的「一键补上群聊名」也要用它 —— 只允许有一份实现。
|
||||
*
|
||||
* 三条约束:
|
||||
* 1. **幂等**:已经含 `{groupName}` 就原样返回;
|
||||
* 2. **不破坏用户内容**:只在标题行后插入一行,其余文本与顺序一字不动;
|
||||
* 3. 调用方负责"只补一次"—— 否则用户删掉这一行后重启它又回来,
|
||||
* 那种"删不掉"的行为比缺失信息更糟。
|
||||
*/
|
||||
export function insertGroupNamePlaceholder(template: string): string {
|
||||
const normalized = String(template ?? '').replace(/\r\n?/g, '\n')
|
||||
if (!normalized.trim()) return template
|
||||
if (normalized.includes('{groupName}')) return template
|
||||
|
||||
const lines = normalized.split('\n')
|
||||
const titleIndex = lines.findIndex((line) => line.trim())
|
||||
// 首行是标题(`[退群监测]` 这类)就插在它后面,否则插到最前面。
|
||||
const insertAt = titleIndex >= 0 && lines[titleIndex].trim().startsWith('[') ? titleIndex + 1 : 0
|
||||
// 前后各留一个空行(与标题、与下文都分开);紧随其后的多余空行由下面的压缩处理掉。
|
||||
return [...lines.slice(0, insertAt), '', '群聊: {groupName}', '', ...lines.slice(insertAt)]
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
export type WechatActionOrigin =
|
||||
/**
|
||||
* 退群监控历史发送用的来源。
|
||||
*
|
||||
* 迁移后**没有任何生产者**了(退群监控不再自己发送)。保留它只为两件事:
|
||||
* 让磁盘上的历史 audit 记录仍能正常显示,以及让类型仍是穷举的。
|
||||
*/
|
||||
| 'member_monitor'
|
||||
| 'scheduled_report'
|
||||
| 'user_tts'
|
||||
@@ -10,12 +16,19 @@ export type WechatActionOrigin =
|
||||
* 也不进 Send Log —— 排查「消息到底发没发出去」时会缺一份证据。
|
||||
*/
|
||||
| 'user_manual'
|
||||
/** Automation v1(@我生成日报)。与 `shared/automation.ts` 的 AUTOMATION_SEND_ORIGIN 一致。 */
|
||||
/** Automation(@我生成日报 / 退群通知)。与 `shared/automation.ts` 的 AUTOMATION_SEND_ORIGIN 一致。 */
|
||||
| 'automation'
|
||||
| 'unknown'
|
||||
| (string & {})
|
||||
|
||||
export type WechatActionPurpose =
|
||||
/**
|
||||
* 历史用途:退群监控自己发通知。
|
||||
*
|
||||
* 迁移后**没有任何生产者**,也已从 `AUTOMATION_PURPOSE_ALLOWLIST` 摘除 ——
|
||||
* 即"旧链路在代码上已经不可能再发出去"。保留类型成员只为让历史 audit 记录可读
|
||||
* (界面仍把它显示成「退群通知」)。
|
||||
*/
|
||||
| 'member_left_notification'
|
||||
| 'scheduled_report'
|
||||
| 'tts_voice'
|
||||
@@ -28,14 +41,16 @@ export type WechatActionPurpose =
|
||||
/** 日报图片成功后的后置词;只有图片 sent 后才会创建。 */
|
||||
| 'manual_report_postfix'
|
||||
/**
|
||||
* Automation v1 的两个用途。
|
||||
* Automation 的三个用途。
|
||||
*
|
||||
* ⚠️ 这两个值必须同步登记进 `wechat-action-gateway.ts` 的
|
||||
* ⚠️ 这三个值必须同步登记进 `wechat-action-gateway.ts` 的
|
||||
* `AUTOMATION_PURPOSE_ALLOWLIST`,否则会被策略层以 `ACTION_NOT_ALLOWED` 拦下。
|
||||
* 与 `shared/automation.ts` 的 `AUTOMATION_SEND_PURPOSE` 保持一致。
|
||||
*/
|
||||
| 'automation_reply'
|
||||
| 'automation_report'
|
||||
/** 退群通知(由 Automation 发出)。目标可以是群 / 自己 / 文件传输助手 / 指定好友。 */
|
||||
| 'automation_leave_notification'
|
||||
| (string & {})
|
||||
|
||||
export type WechatActionTriggerType = 'automation' | 'user'
|
||||
@@ -99,11 +114,6 @@ export interface PolicyDecision {
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface WechatActionMemberEventReference {
|
||||
id: string
|
||||
roomId: string
|
||||
}
|
||||
|
||||
export interface WechatActionAuditRecord {
|
||||
actionId: string
|
||||
idempotencyKey?: string
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 微信协议层的**固定身份**。
|
||||
*
|
||||
* 为什么要有这个文件:这些值不是「昵称」,是协议里写死的 username。
|
||||
* 用昵称去搜索(`nickname === '文件传输助手'`)在改过备注、多语言、
|
||||
* 或同名联系人存在时会直接选错发送对象 —— 而发错人是不可撤销的。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 文件传输助手。
|
||||
*
|
||||
* 微信客户端的固定 username,不随账号语言、昵称、备注变化。
|
||||
* **真机核对项**:需在真机实测一次。
|
||||
*/
|
||||
export const WECHAT_FILE_HELPER_USERNAME = 'filehelper'
|
||||
|
||||
export function isFileHelperUsername(value: string | undefined | null): boolean {
|
||||
return String(value || '').trim().toLowerCase() === WECHAT_FILE_HELPER_USERNAME
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import React from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ToastProvider } from '../../src/renderer/src/components/ui'
|
||||
import {
|
||||
AutomationWorkspace,
|
||||
type AutomationOpenRuleRequest
|
||||
} from '../../src/renderer/src/features/automation/AutomationWorkspace'
|
||||
import {
|
||||
BUILTIN_DAILY_REPORT_RULE_ID,
|
||||
createDefaultDailyReportRule,
|
||||
createDefaultLeaveNotificationRule,
|
||||
type AutomationRule
|
||||
} from '../../src/shared/automation'
|
||||
|
||||
/**
|
||||
* 规则类型切换的**目标归属**回归。
|
||||
*
|
||||
* 真实事故:从「退群通知」卡片进编辑器(编辑器内部记的是"没有具体目标"),
|
||||
* 切到「@我生成日报」Tab 后保存 —— 结果改掉了**内置日报**(用户看到的就是
|
||||
* "我编辑退群通知,保存却改到了另一条日报上")。
|
||||
*
|
||||
* 根因两条,都要锁死:
|
||||
* 1. 切换类型时继承了上一个类型的 `mode`,留下 `mode='edit'` 却没有目标的中间态;
|
||||
* 2. 编辑目标**不可见** —— 表单不显示它在改哪条规则。
|
||||
*/
|
||||
|
||||
const DAILY_NAME = '我的日报'
|
||||
|
||||
function installApi(): {
|
||||
rules: AutomationRule[]
|
||||
updateAutomationRule: ReturnType<typeof vi.fn>
|
||||
createAutomationRule: ReturnType<typeof vi.fn>
|
||||
saveLeaveNotificationRule: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const renamedDaily: AutomationRule = {
|
||||
...createDefaultDailyReportRule(Date.now()),
|
||||
name: DAILY_NAME
|
||||
}
|
||||
const leaveRule = createDefaultLeaveNotificationRule(Date.now())
|
||||
const rules = [renamedDaily, leaveRule]
|
||||
|
||||
const updateAutomationRule = vi.fn(async (id: string, draft: AutomationRule) => ({
|
||||
...renamedDaily,
|
||||
...draft,
|
||||
id
|
||||
}))
|
||||
const createAutomationRule = vi.fn(async (draft: AutomationRule) => ({
|
||||
...renamedDaily,
|
||||
...draft,
|
||||
id: 'created-rule'
|
||||
}))
|
||||
const saveLeaveNotificationRule = vi.fn(async (draft: AutomationRule) => draft)
|
||||
|
||||
window.api = {
|
||||
getAutomationStatus: vi.fn().mockResolvedValue({
|
||||
listening: true,
|
||||
listeningDegraded: false,
|
||||
todayExecutions: 0,
|
||||
todaySuccesses: 0,
|
||||
sendCapability: {
|
||||
supported: true,
|
||||
ready: true,
|
||||
canSendText: true,
|
||||
canSendImage: true,
|
||||
message: 'ready'
|
||||
}
|
||||
}),
|
||||
listAutomationRules: vi.fn().mockResolvedValue(rules),
|
||||
listAutomationGroups: vi.fn().mockResolvedValue([]),
|
||||
listAutomationExecutions: vi.fn().mockResolvedValue([]),
|
||||
listSendableContacts: vi.fn().mockResolvedValue([]),
|
||||
getGroupExitMonitorState: vi.fn().mockResolvedValue({
|
||||
events: [],
|
||||
enabled: true,
|
||||
running: true,
|
||||
nativeMonitorActive: true,
|
||||
monitoredGroupCount: 3,
|
||||
monitorSelectionConfigured: true,
|
||||
monitoredRoomIds: [],
|
||||
lastReadAt: 0,
|
||||
unreadCount: 0
|
||||
}),
|
||||
onGroupExitMonitorState: vi.fn(() => () => undefined),
|
||||
updateAutomationRule,
|
||||
createAutomationRule,
|
||||
saveLeaveNotificationRule
|
||||
} as unknown as typeof window.api
|
||||
|
||||
return { rules, updateAutomationRule, createAutomationRule, saveLeaveNotificationRule }
|
||||
}
|
||||
|
||||
const renderWorkspace = (): void => {
|
||||
render(
|
||||
<ToastProvider>
|
||||
<AutomationWorkspace dbReady onOpenExitMonitorGroups={() => {}} />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const editingTarget = (): string =>
|
||||
screen.getByTestId('automation-editing-target').textContent ?? ''
|
||||
|
||||
const leaveCard = async (): Promise<HTMLElement> =>
|
||||
(await screen.findByText('退群通知')).closest('article') as HTMLElement
|
||||
|
||||
describe('规则类型切换 · 编辑目标归属', () => {
|
||||
it('从退群通知进编辑器时,「正在编辑」显示退群通知', async () => {
|
||||
const user = userEvent.setup()
|
||||
installApi()
|
||||
renderWorkspace()
|
||||
|
||||
await user.click(within(await leaveCard()).getByRole('button', { name: '编辑' }))
|
||||
|
||||
expect(editingTarget()).toContain('退群通知')
|
||||
})
|
||||
|
||||
/*
|
||||
* 事故核心:切到日报后,界面必须**明说**现在改的是哪条。
|
||||
* 以前这里什么都不显示,用户以为还在改退群通知。
|
||||
*/
|
||||
it('切到日报后,「正在编辑」显示那条日报的名字(不再隐式指向未知目标)', async () => {
|
||||
const user = userEvent.setup()
|
||||
installApi()
|
||||
renderWorkspace()
|
||||
|
||||
await user.click(within(await leaveCard()).getByRole('button', { name: '编辑' }))
|
||||
await user.click(screen.getByRole('radio', { name: '@我生成日报' }))
|
||||
|
||||
expect(editingTarget()).toContain(DAILY_NAME)
|
||||
expect(editingTarget()).not.toContain('退群通知')
|
||||
})
|
||||
|
||||
it('切到日报后保存:只更新那一条日报,退群通知与新建都不受影响', async () => {
|
||||
const user = userEvent.setup()
|
||||
const api = installApi()
|
||||
renderWorkspace()
|
||||
|
||||
await user.click(within(await leaveCard()).getByRole('button', { name: '编辑' }))
|
||||
await user.click(screen.getByRole('radio', { name: '@我生成日报' }))
|
||||
await user.click(screen.getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => expect(api.updateAutomationRule).toHaveBeenCalledTimes(1))
|
||||
// 必须更新"屏幕上显示的那条"。
|
||||
expect(api.updateAutomationRule.mock.calls[0][0]).toBe(BUILTIN_DAILY_REPORT_RULE_ID)
|
||||
// 绝不能顺手新建一条重复规则,也不能动退群通知。
|
||||
expect(api.createAutomationRule).not.toHaveBeenCalled()
|
||||
expect(api.saveLeaveNotificationRule).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('从日报卡片进入并保存:更新日报,且不会误存到退群通知', async () => {
|
||||
const user = userEvent.setup()
|
||||
const api = installApi()
|
||||
renderWorkspace()
|
||||
|
||||
const dailyCard = (await screen.findByText(DAILY_NAME)).closest('article') as HTMLElement
|
||||
await user.click(within(dailyCard).getByRole('button', { name: '编辑' }))
|
||||
expect(editingTarget()).toContain(DAILY_NAME)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => expect(api.updateAutomationRule).toHaveBeenCalledTimes(1))
|
||||
expect(api.saveLeaveNotificationRule).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('从日报切到退群通知后保存:走退群通知通道,不改日报', async () => {
|
||||
const user = userEvent.setup()
|
||||
const api = installApi()
|
||||
renderWorkspace()
|
||||
|
||||
const dailyCard = (await screen.findByText(DAILY_NAME)).closest('article') as HTMLElement
|
||||
await user.click(within(dailyCard).getByRole('button', { name: '编辑' }))
|
||||
await user.click(screen.getByRole('radio', { name: '退群通知' }))
|
||||
|
||||
expect(editingTarget()).toContain('退群通知')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => expect(api.saveLeaveNotificationRule).toHaveBeenCalledTimes(1))
|
||||
expect(api.updateAutomationRule).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('内置日报不存在时切到日报:显示「新建自动化」,保存走新建而不是更新', async () => {
|
||||
const user = userEvent.setup()
|
||||
const api = installApi()
|
||||
// 只剩退群通知(等价于用户把内置日报删掉了)。
|
||||
;(window.api.listAutomationRules as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
createDefaultLeaveNotificationRule(Date.now())
|
||||
])
|
||||
renderWorkspace()
|
||||
|
||||
await user.click(within(await leaveCard()).getByRole('button', { name: '编辑' }))
|
||||
await user.click(screen.getByRole('radio', { name: '@我生成日报' }))
|
||||
|
||||
expect(editingTarget()).toContain('新建自动化')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '保存' }))
|
||||
await waitFor(() => expect(api.createAutomationRule).toHaveBeenCalledTimes(1))
|
||||
expect(api.updateAutomationRule).not.toHaveBeenCalled()
|
||||
expect(api.saveLeaveNotificationRule).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/*
|
||||
* 日报可以有多条。切走再切回时必须回到**刚才那条**,
|
||||
* 否则用户会在不知情的情况下被带到内置日报上(等于换个方式误改规则)。
|
||||
*/
|
||||
it('从别的日报进入,切走再切回仍指向那条日报,不会被换回内置日报', async () => {
|
||||
const user = userEvent.setup()
|
||||
const api = installApi()
|
||||
const OTHER_NAME = '我的另一条日报'
|
||||
;(window.api.listAutomationRules as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ ...createDefaultDailyReportRule(Date.now()), name: DAILY_NAME },
|
||||
{
|
||||
...createDefaultDailyReportRule(Date.now()),
|
||||
id: 'custom-daily',
|
||||
name: OTHER_NAME
|
||||
},
|
||||
createDefaultLeaveNotificationRule(Date.now())
|
||||
])
|
||||
renderWorkspace()
|
||||
|
||||
const otherCard = (await screen.findByText(OTHER_NAME)).closest('article') as HTMLElement
|
||||
await user.click(within(otherCard).getByRole('button', { name: '编辑' }))
|
||||
expect(editingTarget()).toContain(OTHER_NAME)
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: '退群通知' }))
|
||||
await user.click(screen.getByRole('radio', { name: '@我生成日报' }))
|
||||
|
||||
expect(editingTarget()).toContain(OTHER_NAME)
|
||||
expect(editingTarget()).not.toContain(DAILY_NAME)
|
||||
|
||||
// 保存也必须落在这一条上。
|
||||
await user.click(screen.getByRole('button', { name: '保存' }))
|
||||
await waitFor(() => expect(api.updateAutomationRule).toHaveBeenCalledTimes(1))
|
||||
expect(api.updateAutomationRule.mock.calls[0][0]).toBe('custom-daily')
|
||||
})
|
||||
|
||||
/*
|
||||
* 同类 bug 的第二处:`mode === 'edit' || !target ? createRule : updateRule` ——
|
||||
* "编辑态但没有目标"被静默当成"新建",用户以为在改 A,结果多出一条新规则。
|
||||
* 现在必须明确报错,且不能给 User 一个看起来能保存的空表单。
|
||||
*/
|
||||
it('编辑态但目标规则已不存在:不给新建入口,明确提示', async () => {
|
||||
const api = installApi()
|
||||
// 内置日报不存在(等价于用户把它删掉了)。
|
||||
;(window.api.listAutomationRules as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
createDefaultLeaveNotificationRule(Date.now())
|
||||
])
|
||||
|
||||
function DeepLinkHarness(): React.ReactElement {
|
||||
const [request] = React.useState<AutomationOpenRuleRequest>({
|
||||
ruleType: 'daily_report',
|
||||
requestId: 1
|
||||
})
|
||||
return <AutomationWorkspace dbReady openRuleRequest={request} />
|
||||
}
|
||||
|
||||
render(
|
||||
<ToastProvider>
|
||||
<DeepLinkHarness />
|
||||
</ToastProvider>
|
||||
)
|
||||
|
||||
expect(await screen.findByText(/要编辑的规则已不存在/)).toBeVisible()
|
||||
// 关键:没有可点的「保存」——否则用户点下去就会凭空新建一条规则。
|
||||
expect(screen.queryByRole('button', { name: '保存' })).toBeNull()
|
||||
expect(api.createAutomationRule).not.toHaveBeenCalled()
|
||||
expect(api.updateAutomationRule).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('切类型不会静默新建出重复规则', async () => {
|
||||
const user = userEvent.setup()
|
||||
const api = installApi()
|
||||
renderWorkspace()
|
||||
|
||||
await user.click(within(await leaveCard()).getByRole('button', { name: '编辑' }))
|
||||
for (const label of ['@我生成日报', '退群通知', '@我生成日报', '退群通知']) {
|
||||
await user.click(screen.getByRole('radio', { name: label }))
|
||||
}
|
||||
|
||||
expect(api.createAutomationRule).not.toHaveBeenCalled()
|
||||
expect(api.updateAutomationRule).not.toHaveBeenCalled()
|
||||
expect(api.saveLeaveNotificationRule).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Tab 文案说明它是「切换规则类型」,不是改当前规则的类型', async () => {
|
||||
const user = userEvent.setup()
|
||||
installApi()
|
||||
renderWorkspace()
|
||||
|
||||
await user.click(within(await leaveCard()).getByRole('button', { name: '编辑' }))
|
||||
|
||||
expect(screen.getByText('切换规则类型')).toBeVisible()
|
||||
expect(
|
||||
screen.getByRole('radiogroup', { name: '切换规则类型' }) as HTMLElement
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { ReactElement } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -48,7 +48,6 @@ const state: GroupExitMonitorState = {
|
||||
monitoredGroupCount: 3,
|
||||
monitorSelectionConfigured: true,
|
||||
monitoredRoomIds: ['研发群@chatroom', '设计群@chatroom'],
|
||||
notificationRoomIds: [],
|
||||
lastCheckedAt: 1_756_600_000_000,
|
||||
lastReadAt: 0,
|
||||
unreadCount: 1
|
||||
@@ -163,25 +162,23 @@ describe('GroupExitMonitorWorkspace', () => {
|
||||
await user.click(screen.getByRole('checkbox', { name: '监控研发群' }))
|
||||
await user.click(screen.getByRole('button', { name: '保存监控群聊' }))
|
||||
|
||||
await waitFor(() => expect(setGroups).toHaveBeenCalledWith(['研发群@chatroom'], []))
|
||||
await waitFor(() => expect(setGroups).toHaveBeenCalledWith(['研发群@chatroom']))
|
||||
expect(screen.getByRole('heading', { name: '退群监控' })).toBeVisible()
|
||||
expect(screen.getByText('已监控群聊')).toBeVisible()
|
||||
})
|
||||
|
||||
it('filters group rows by selection and persists notification targets separately', async () => {
|
||||
it('filters group rows by selection and saves the monitoring scope', async () => {
|
||||
const user = userEvent.setup()
|
||||
const configuredState: GroupExitMonitorState = {
|
||||
...state,
|
||||
events: [],
|
||||
monitorSelectionConfigured: true,
|
||||
monitoredRoomIds: ['研发群@chatroom'],
|
||||
notificationRoomIds: [],
|
||||
monitoredGroupCount: 1,
|
||||
unreadCount: 0
|
||||
}
|
||||
const savedState: GroupExitMonitorState = {
|
||||
...configuredState,
|
||||
notificationRoomIds: ['研发群@chatroom']
|
||||
}
|
||||
const setGroups = vi.fn().mockResolvedValue(savedState)
|
||||
window.api = {
|
||||
@@ -209,12 +206,9 @@ describe('GroupExitMonitorWorkspace', () => {
|
||||
|
||||
await user.click(screen.getByRole('combobox', { name: '筛选群聊状态' }))
|
||||
await user.click(await screen.findByRole('option', { name: '全部' }))
|
||||
await user.click(screen.getByRole('checkbox', { name: '是否通知研发群' }))
|
||||
await user.click(screen.getByRole('button', { name: '保存监控群聊' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(setGroups).toHaveBeenCalledWith(['研发群@chatroom'], ['研发群@chatroom'])
|
||||
)
|
||||
await waitFor(() => expect(setGroups).toHaveBeenCalledWith(['研发群@chatroom']))
|
||||
})
|
||||
|
||||
it('selects every group even when a search term is active', async () => {
|
||||
@@ -224,7 +218,6 @@ describe('GroupExitMonitorWorkspace', () => {
|
||||
events: [],
|
||||
monitorSelectionConfigured: true,
|
||||
monitoredRoomIds: [],
|
||||
notificationRoomIds: [],
|
||||
monitoredGroupCount: 0,
|
||||
unreadCount: 0
|
||||
}
|
||||
@@ -244,7 +237,7 @@ describe('GroupExitMonitorWorkspace', () => {
|
||||
await user.click(screen.getByRole('button', { name: '保存监控群聊' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(setGroups).toHaveBeenCalledWith(['研发群@chatroom', '设计群@chatroom'], [])
|
||||
expect(setGroups).toHaveBeenCalledWith(['研发群@chatroom', '设计群@chatroom'])
|
||||
)
|
||||
})
|
||||
|
||||
@@ -275,43 +268,17 @@ describe('GroupExitMonitorWorkspace', () => {
|
||||
await waitFor(() => expect(screen.getByRole('tooltip')).toHaveTextContent('请先绑定个人微信'))
|
||||
})
|
||||
|
||||
it('shows the notification template from the management page header', async () => {
|
||||
/*
|
||||
* 迁移后这里**不再有模板入口与「通知群聊」列**:
|
||||
* 模板的唯一编辑处是「自动化 → 规则 → 退群通知」。
|
||||
* 退群监控只负责监控范围与退群事实。
|
||||
*/
|
||||
it('管理群聊页不再提供通知模板与通知群聊配置', async () => {
|
||||
const user = userEvent.setup()
|
||||
window.api = {
|
||||
getGroupExitMonitorState: vi.fn().mockResolvedValue(state),
|
||||
onGroupExitMonitorState: vi.fn(() => () => undefined),
|
||||
getPersonalWechatSendCapability: vi.fn().mockResolvedValue({
|
||||
supported: true,
|
||||
ready: false,
|
||||
status: 'needs_binding',
|
||||
capabilities: { text: false, image: false, voice: false },
|
||||
senderStatus: {} as never,
|
||||
message: '请先绑定个人微信'
|
||||
}),
|
||||
setGroupExitMonitorGroups: vi.fn().mockResolvedValue(state),
|
||||
checkGroupExitMonitorNow: vi.fn().mockResolvedValue(state),
|
||||
clearGroupExitMonitorEvents: vi.fn().mockResolvedValue(state)
|
||||
} as typeof window.api
|
||||
|
||||
renderWorkspace(<GroupExitMonitorWorkspace dbReady contacts={groups} />)
|
||||
await user.click(await screen.findByRole('button', { name: '管理群聊' }))
|
||||
await user.click(screen.getByRole('button', { name: '查看退群监测模板' }))
|
||||
|
||||
expect(screen.getByRole('heading', { name: '退群监测模板' })).toBeVisible()
|
||||
const template = screen.getByLabelText('退群监测模板内容')
|
||||
expect((template as HTMLTextAreaElement).value).toContain('用户: {user}')
|
||||
expect((template as HTMLTextAreaElement).value).toContain('群备注: {groupRemark}')
|
||||
})
|
||||
|
||||
it('edits and persists the notification template', async () => {
|
||||
const user = userEvent.setup()
|
||||
const savedTemplate = '[退群监测]\n用户: {user}\n群: {groupRemark}'
|
||||
const setTemplate = vi.fn().mockResolvedValue({ ...state, notificationTemplate: savedTemplate })
|
||||
window.api = {
|
||||
getGroupExitMonitorState: vi.fn().mockResolvedValue(state),
|
||||
onGroupExitMonitorState: vi.fn(() => () => undefined),
|
||||
getPersonalWechatSendCapability: vi.fn().mockResolvedValue(null),
|
||||
setGroupExitMonitorNotificationTemplate: setTemplate,
|
||||
setGroupExitMonitorGroups: vi.fn().mockResolvedValue(state),
|
||||
checkGroupExitMonitorNow: vi.fn().mockResolvedValue(state),
|
||||
clearGroupExitMonitorEvents: vi.fn().mockResolvedValue(state)
|
||||
@@ -319,16 +286,16 @@ describe('GroupExitMonitorWorkspace', () => {
|
||||
|
||||
renderWorkspace(<GroupExitMonitorWorkspace dbReady contacts={groups} />)
|
||||
await user.click(await screen.findByRole('button', { name: '管理群聊' }))
|
||||
await user.click(screen.getByRole('button', { name: '查看退群监测模板' }))
|
||||
const template = screen.getByLabelText('退群监测模板内容')
|
||||
fireEvent.change(template, { target: { value: savedTemplate } })
|
||||
await user.click(screen.getByRole('button', { name: '保存模板' }))
|
||||
|
||||
await waitFor(() => expect(setTemplate).toHaveBeenCalledWith(savedTemplate))
|
||||
expect(screen.queryByRole('heading', { name: '退群监测模板' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '查看退群监测模板' })).toBeNull()
|
||||
expect(screen.queryByRole('heading', { name: '退群监测模板' })).toBeNull()
|
||||
expect(screen.queryByText('通知群聊')).toBeNull()
|
||||
expect(screen.queryByLabelText('退群监测模板内容')).toBeNull()
|
||||
// 监控选择本身仍然可用。
|
||||
expect(screen.getByRole('checkbox', { name: '监控研发群' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('copies the exit notice built from the template saved on the management page', async () => {
|
||||
it('复制退群信息用的模板来自自动化规则,而不是退群监控自己存的', async () => {
|
||||
const user = userEvent.setup()
|
||||
const template = [
|
||||
'[退群监测]',
|
||||
@@ -340,11 +307,19 @@ describe('GroupExitMonitorWorkspace', () => {
|
||||
].join('\n')
|
||||
const copyText = vi.fn().mockResolvedValue({ success: true })
|
||||
window.api = {
|
||||
getGroupExitMonitorState: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ...state, notificationTemplate: template }),
|
||||
getGroupExitMonitorState: vi.fn().mockResolvedValue(state),
|
||||
onGroupExitMonitorState: vi.fn(() => () => undefined),
|
||||
copyText,
|
||||
// 模板唯一来源:自动化里的退群通知规则。
|
||||
listAutomationRules: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'builtin-leave-notification',
|
||||
name: '退群通知',
|
||||
enabled: true,
|
||||
ruleType: 'leave_notification',
|
||||
leaveNotification: { target: { type: 'file_transfer' }, template }
|
||||
}
|
||||
]),
|
||||
checkGroupExitMonitorNow: vi.fn().mockResolvedValue(state),
|
||||
clearGroupExitMonitorEvents: vi.fn().mockResolvedValue(state)
|
||||
} as typeof window.api
|
||||
@@ -396,7 +371,6 @@ describe('GroupExitMonitorWorkspace', () => {
|
||||
events: [],
|
||||
monitorSelectionConfigured: false,
|
||||
monitoredRoomIds: [],
|
||||
notificationRoomIds: [],
|
||||
monitoredGroupCount: 0,
|
||||
unreadCount: 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,665 @@
|
||||
import React from 'react'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { ReactElement } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ToastProvider, TooltipProvider } from '../../src/renderer/src/components/ui'
|
||||
import {
|
||||
AutomationWorkspace,
|
||||
type AutomationOpenRuleRequest
|
||||
} from '../../src/renderer/src/features/automation/AutomationWorkspace'
|
||||
import { GroupExitMonitorWorkspace } from '../../src/renderer/src/features/group-exit-monitor/GroupExitMonitorWorkspace'
|
||||
import {
|
||||
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
|
||||
createDefaultDailyReportRule,
|
||||
createDefaultLeaveNotificationRule,
|
||||
type AutomationRule
|
||||
} from '../../src/shared/automation'
|
||||
import type { GroupExitMonitorState } from '../../src/shared/group-exit-monitor'
|
||||
|
||||
/**
|
||||
* 「自动化 → 规则 → 退群通知」现在是**真实配置页**。
|
||||
*
|
||||
* 这组测试锁的是「真实化」之后的契约:
|
||||
* - 已监控群聊数量来自退群监控(不是硬编码的 243);
|
||||
* - 好友清单来自 main 侧的可发送联系人接口(不是 mock 的张三李四);
|
||||
* - 保存真的会调用 singleton upsert;
|
||||
* - **效果预览永远不发送**;
|
||||
* - 退群监控里旧的通知群聊列与模板入口已经不存在。
|
||||
*/
|
||||
|
||||
const MONITOR_STATE: GroupExitMonitorState = {
|
||||
events: [],
|
||||
enabled: true,
|
||||
running: true,
|
||||
nativeMonitorActive: true,
|
||||
monitoredGroupCount: 7,
|
||||
monitorSelectionConfigured: true,
|
||||
monitoredRoomIds: ['a@chatroom', 'b@chatroom', 'c@chatroom'],
|
||||
lastReadAt: 0,
|
||||
unreadCount: 0
|
||||
}
|
||||
|
||||
const SENDABLE_CONTACTS = [
|
||||
{ id: 'wxid_friend_a', name: '好友甲' },
|
||||
{ id: 'wxid_friend_b', name: '好友乙' }
|
||||
]
|
||||
|
||||
interface ApiSpies {
|
||||
saveLeaveNotificationRule: ReturnType<typeof vi.fn>
|
||||
createAutomationRule: ReturnType<typeof vi.fn>
|
||||
updateAutomationRule: ReturnType<typeof vi.fn>
|
||||
deleteAutomationRule: ReturnType<typeof vi.fn>
|
||||
setAutomationRuleEnabled: ReturnType<typeof vi.fn>
|
||||
setGroupExitMonitorGroups: ReturnType<typeof vi.fn>
|
||||
listSendableContacts: ReturnType<typeof vi.fn>
|
||||
listAutomationRules: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function installApi(
|
||||
overrides: { leaveRule?: AutomationRule } = {}
|
||||
): { spies: ApiSpies; rules: AutomationRule[] } {
|
||||
const leaveRule = overrides.leaveRule ?? createDefaultLeaveNotificationRule(Date.now())
|
||||
const rules = [createDefaultDailyReportRule(Date.now()), leaveRule]
|
||||
const spies: ApiSpies = {
|
||||
saveLeaveNotificationRule: vi.fn(async (draft: AutomationRule) => ({
|
||||
...leaveRule,
|
||||
...draft,
|
||||
id: BUILTIN_LEAVE_NOTIFICATION_RULE_ID
|
||||
})),
|
||||
createAutomationRule: vi.fn().mockResolvedValue(null),
|
||||
updateAutomationRule: vi.fn().mockResolvedValue(null),
|
||||
deleteAutomationRule: vi.fn().mockResolvedValue(false),
|
||||
setAutomationRuleEnabled: vi.fn().mockResolvedValue(null),
|
||||
setGroupExitMonitorGroups: vi.fn().mockResolvedValue(MONITOR_STATE),
|
||||
listSendableContacts: vi.fn().mockResolvedValue(SENDABLE_CONTACTS),
|
||||
listAutomationRules: vi.fn().mockResolvedValue(rules)
|
||||
}
|
||||
|
||||
window.api = {
|
||||
getAutomationStatus: vi.fn().mockResolvedValue({
|
||||
listening: true,
|
||||
listeningDegraded: false,
|
||||
todayExecutions: 0,
|
||||
todaySuccesses: 0,
|
||||
sendCapability: {
|
||||
supported: true,
|
||||
ready: true,
|
||||
canSendText: true,
|
||||
canSendImage: true,
|
||||
message: '个人微信发送能力已就绪'
|
||||
}
|
||||
}),
|
||||
listAutomationRules: spies.listAutomationRules,
|
||||
listAutomationGroups: vi.fn().mockResolvedValue([]),
|
||||
listAutomationExecutions: vi.fn().mockResolvedValue([]),
|
||||
listSendableContacts: spies.listSendableContacts,
|
||||
getGroupExitMonitorState: vi.fn().mockResolvedValue(MONITOR_STATE),
|
||||
onGroupExitMonitorState: vi.fn(() => () => undefined),
|
||||
saveLeaveNotificationRule: spies.saveLeaveNotificationRule,
|
||||
createAutomationRule: spies.createAutomationRule,
|
||||
updateAutomationRule: spies.updateAutomationRule,
|
||||
deleteAutomationRule: spies.deleteAutomationRule,
|
||||
setAutomationRuleEnabled: spies.setAutomationRuleEnabled,
|
||||
setGroupExitMonitorGroups: spies.setGroupExitMonitorGroups
|
||||
} as unknown as typeof window.api
|
||||
|
||||
return { spies, rules }
|
||||
}
|
||||
|
||||
const renderWithProviders = (element: ReactElement): void => {
|
||||
render(<ToastProvider>{element}</ToastProvider>)
|
||||
}
|
||||
|
||||
const renderMonitorWithProviders = (element: ReactElement): void => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ToastProvider>{element}</ToastProvider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/** 找到首页那张「退群通知」卡片(现在有两张卡,都有「编辑」)。 */
|
||||
async function leaveNotificationCard(): Promise<HTMLElement> {
|
||||
return (await screen.findByText('退群通知')).closest('article') as HTMLElement
|
||||
}
|
||||
|
||||
/** 从自动化首页那张退群通知卡片的「编辑」进编辑器。 */
|
||||
async function openLeaveNotificationEditor(overrides: {
|
||||
leaveRule?: AutomationRule
|
||||
} = {}): Promise<{ spies: ApiSpies }> {
|
||||
const user = userEvent.setup()
|
||||
const { spies } = installApi(overrides)
|
||||
renderWithProviders(<AutomationWorkspace dbReady onOpenExitMonitorGroups={() => {}} />)
|
||||
|
||||
const card = await leaveNotificationCard()
|
||||
await user.click(within(card).getByRole('button', { name: '编辑' }))
|
||||
return { spies }
|
||||
}
|
||||
|
||||
const previewRecipient = (): string =>
|
||||
screen.getByTestId('leave-preview-recipient').textContent ?? ''
|
||||
|
||||
const previewFact = (label: string): string => {
|
||||
const rows = Array.from(document.querySelectorAll('.automation-preview-facts > div'))
|
||||
for (const row of rows) {
|
||||
if (row.querySelector('dt')?.textContent === label) {
|
||||
return row.querySelector('dd')?.textContent ?? ''
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const targetRadio = (label: string): HTMLElement => screen.getByRole('radio', { name: label })
|
||||
|
||||
describe('自动化 · 规则类型 Tab', () => {
|
||||
it('两种规则类型都是真规则,切换互不影响', async () => {
|
||||
const user = userEvent.setup()
|
||||
installApi()
|
||||
renderWithProviders(<AutomationWorkspace dbReady />)
|
||||
|
||||
await screen.findByText('退群通知')
|
||||
await user.click(screen.getAllByRole('button', { name: '新建自动化' })[0])
|
||||
expect(screen.getByLabelText('关键词')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: '退群通知' }))
|
||||
expect(screen.getByLabelText('退群通知模板内容')).toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('关键词')).toBeNull()
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: '@我生成日报' }))
|
||||
expect(screen.getByLabelText('关键词')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('首页卡片显示真实状态与真实目标,不再有「待配置 / UI 预览」', async () => {
|
||||
installApi()
|
||||
renderWithProviders(<AutomationWorkspace dbReady />)
|
||||
|
||||
await screen.findByText('退群通知')
|
||||
const card = screen.getByText('退群通知').closest('article') as HTMLElement
|
||||
|
||||
expect(card.textContent).toContain('运行中')
|
||||
expect(card.textContent).toContain('当前群聊')
|
||||
expect(card.textContent).toContain('3 个已监控群聊')
|
||||
expect(document.body.textContent).not.toContain('待配置')
|
||||
expect(document.body.textContent).not.toContain('UI 预览')
|
||||
})
|
||||
|
||||
it('迁移待重选的规则在首页显示为「配置异常」', async () => {
|
||||
installApi({
|
||||
leaveRule: createDefaultLeaveNotificationRule(Date.now(), {
|
||||
enabled: false,
|
||||
targetNeedsReview: true
|
||||
})
|
||||
})
|
||||
renderWithProviders(<AutomationWorkspace dbReady />)
|
||||
|
||||
const card = (await screen.findByText('退群通知')).closest('article') as HTMLElement
|
||||
expect(card.textContent).toContain('配置异常')
|
||||
})
|
||||
})
|
||||
|
||||
describe('退群通知 · 真实数据', () => {
|
||||
it('已监控群聊数量来自退群监控', async () => {
|
||||
await openLeaveNotificationEditor()
|
||||
|
||||
expect(await screen.findByText('3 个群聊')).toBeVisible()
|
||||
expect(screen.queryByText('243 个群聊')).toBeNull()
|
||||
})
|
||||
|
||||
it('好友清单来自 main 侧的可发送联系人接口', async () => {
|
||||
const user = userEvent.setup()
|
||||
await openLeaveNotificationEditor()
|
||||
|
||||
await user.click(targetRadio('指定好友'))
|
||||
|
||||
expect(screen.getByRole('radio', { name: '好友甲' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('radio', { name: '好友乙' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('radio', { name: '张三' })).toBeNull()
|
||||
})
|
||||
|
||||
it('Section 1 没有「触发范围」,只有唯一的「管理监控群聊」入口', async () => {
|
||||
await openLeaveNotificationEditor()
|
||||
|
||||
expect(screen.queryByText('触发范围')).toBeNull()
|
||||
expect(screen.queryByText('全部已监控群聊')).toBeNull()
|
||||
expect(screen.queryByText('指定已监控群聊')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: /管理监控群聊/ })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('规则级启停只有一个开关,没有第二个「发送退群通知」', async () => {
|
||||
await openLeaveNotificationEditor()
|
||||
|
||||
expect(screen.getAllByRole('switch')).toHaveLength(1)
|
||||
expect(screen.getByRole('switch', { name: '启用这条自动化' })).toBeVisible()
|
||||
expect(screen.queryByText('发送退群通知')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('退群通知 · 四种目标', () => {
|
||||
it('是四选一,「当前群聊」排第一且默认选中', async () => {
|
||||
await openLeaveNotificationEditor()
|
||||
|
||||
const order = Array.from(
|
||||
document.querySelectorAll('.automation-leave-targets .automation-leave-radio label')
|
||||
).map((label) => label.textContent?.trim())
|
||||
|
||||
expect(order).toEqual(['当前群聊', '文件传输助手', '发给自己', '指定好友'])
|
||||
expect(targetRadio('当前群聊')).toBeChecked()
|
||||
})
|
||||
|
||||
it('只有「指定好友」才展开好友选择器', async () => {
|
||||
const user = userEvent.setup()
|
||||
await openLeaveNotificationEditor()
|
||||
|
||||
for (const label of ['文件传输助手', '当前群聊', '发给自己']) {
|
||||
await user.click(targetRadio(label))
|
||||
expect(screen.queryByPlaceholderText('搜索好友')).toBeNull()
|
||||
}
|
||||
|
||||
await user.click(targetRadio('指定好友'))
|
||||
expect(screen.getByPlaceholderText('搜索好友')).toBeVisible()
|
||||
})
|
||||
|
||||
it('好友只能选一个', async () => {
|
||||
const user = userEvent.setup()
|
||||
await openLeaveNotificationEditor()
|
||||
await user.click(targetRadio('指定好友'))
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: '好友甲' }))
|
||||
expect(screen.getByRole('radio', { name: '好友甲' })).toBeChecked()
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: '好友乙' }))
|
||||
expect(screen.getByRole('radio', { name: '好友乙' })).toBeChecked()
|
||||
expect(screen.getByRole('radio', { name: '好友甲' })).not.toBeChecked()
|
||||
})
|
||||
})
|
||||
|
||||
describe('退群通知 · 保存与取消', () => {
|
||||
it('保存会写入 singleton 规则(含目标、模板、开关)', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { spies } = await openLeaveNotificationEditor()
|
||||
|
||||
// 刻意选一个**与默认不同**的目标:这样才验证了"保存的是用户选的"。
|
||||
await user.click(targetRadio('发给自己'))
|
||||
await user.click(screen.getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => expect(spies.saveLeaveNotificationRule).toHaveBeenCalledTimes(1))
|
||||
const draft = spies.saveLeaveNotificationRule.mock.calls[0][0] as AutomationRule
|
||||
expect(draft.id).toBe(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
|
||||
expect(draft.ruleType).toBe('leave_notification')
|
||||
expect(draft.enabled).toBe(true)
|
||||
expect(draft.leaveNotification?.target).toEqual({ type: 'self' })
|
||||
expect(draft.leaveNotification?.template).toContain('[退群监测]')
|
||||
})
|
||||
|
||||
it('取消不写任何东西', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { spies } = await openLeaveNotificationEditor()
|
||||
|
||||
await user.click(targetRadio('发给自己'))
|
||||
await user.click(screen.getByRole('button', { name: '取消' }))
|
||||
|
||||
expect(spies.saveLeaveNotificationRule).not.toHaveBeenCalled()
|
||||
// 回到首页:卡片上的目标仍是持久化的那个(默认「当前群聊」),不是刚点的「发给自己」。
|
||||
expect((await screen.findByText('退群通知')).closest('article')?.textContent).toContain(
|
||||
'当前群聊'
|
||||
)
|
||||
})
|
||||
|
||||
it('重新打开时恢复的是已持久化配置,不是上次未保存的改动', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { spies } = await openLeaveNotificationEditor()
|
||||
|
||||
await user.click(targetRadio('发给自己'))
|
||||
await user.click(screen.getByRole('button', { name: '取消' }))
|
||||
|
||||
await user.click(within(await leaveNotificationCard()).getByRole('button', { name: '编辑' }))
|
||||
expect(targetRadio('当前群聊')).toBeChecked()
|
||||
expect(spies.saveLeaveNotificationRule).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('启用开关也会被保存', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { spies } = await openLeaveNotificationEditor()
|
||||
|
||||
await user.click(screen.getByRole('switch', { name: '启用这条自动化' }))
|
||||
await user.click(screen.getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => expect(spies.saveLeaveNotificationRule).toHaveBeenCalledTimes(1))
|
||||
const draft = spies.saveLeaveNotificationRule.mock.calls[0][0] as AutomationRule
|
||||
expect(draft.enabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('退群通知 · 效果预览', () => {
|
||||
it('预览用的是模板渲染结果,不是写死的示例', async () => {
|
||||
await openLeaveNotificationEditor()
|
||||
|
||||
const bubble = document.querySelector('.automation-chat-bubble.pre')
|
||||
expect(bubble?.textContent).toContain('[退群监测]')
|
||||
// 占位符已按样本事件替换(同一套 shared 渲染规则)。
|
||||
expect(bubble?.textContent).toContain('用户: 张三')
|
||||
expect(bubble?.textContent).toContain('人数: 243 -> 242')
|
||||
})
|
||||
|
||||
it('改发送目标 → 顶部模拟收件人跟着变', async () => {
|
||||
const user = userEvent.setup()
|
||||
await openLeaveNotificationEditor()
|
||||
|
||||
// 默认 = 当前群聊 ⇒ 顶部收件会话就是发生退群的那个群。
|
||||
expect(previewRecipient()).toBe('TraceMemo 交流群')
|
||||
|
||||
await user.click(targetRadio('文件传输助手'))
|
||||
expect(previewRecipient()).toBe('文件传输助手')
|
||||
|
||||
await user.click(targetRadio('发给自己'))
|
||||
expect(previewRecipient()).toBe('我')
|
||||
})
|
||||
|
||||
it('指定好友时预览收件人跟着好友变,未选时如实说未选择', async () => {
|
||||
const user = userEvent.setup()
|
||||
await openLeaveNotificationEditor()
|
||||
|
||||
await user.click(targetRadio('指定好友'))
|
||||
expect(previewRecipient()).toBe('未选择好友')
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: '好友甲' }))
|
||||
expect(previewRecipient()).toBe('好友甲')
|
||||
})
|
||||
|
||||
it('底部摘要随目标变化,且不含「执行方式」', async () => {
|
||||
const user = userEvent.setup()
|
||||
await openLeaveNotificationEditor()
|
||||
|
||||
expect(previewFact('触发事件')).toBe('成员退出')
|
||||
expect(previewFact('监控来源')).toBe('3 个已监控群聊')
|
||||
expect(previewFact('通知目标')).toBe('发生退群事件的群聊')
|
||||
expect(document.querySelector('.automation-preview-facts')?.textContent).not.toContain('执行方式')
|
||||
|
||||
await user.click(targetRadio('文件传输助手'))
|
||||
expect(previewFact('通知目标')).toBe('文件传输助手')
|
||||
})
|
||||
|
||||
it('预览永远不触发发送与保存', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { spies } = await openLeaveNotificationEditor()
|
||||
|
||||
await user.click(targetRadio('当前群聊'))
|
||||
await user.click(targetRadio('指定好友'))
|
||||
|
||||
for (const spy of [
|
||||
spies.saveLeaveNotificationRule,
|
||||
spies.createAutomationRule,
|
||||
spies.updateAutomationRule,
|
||||
spies.setAutomationRuleEnabled
|
||||
]) {
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('编辑器里不出现底层实现词', async () => {
|
||||
await openLeaveNotificationEditor()
|
||||
|
||||
expect(document.body.textContent).not.toMatch(/AutomationRule|trigger matcher|ActionRunner|WCDB/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('退群通知 · 通知内容与变量', () => {
|
||||
it('变量清单以 {groupName} 打头,且可以插进模板', async () => {
|
||||
const user = userEvent.setup()
|
||||
await openLeaveNotificationEditor()
|
||||
|
||||
const chips = Array.from(document.querySelectorAll('.automation-leave-template-chip')).map(
|
||||
(chip) => chip.textContent
|
||||
)
|
||||
expect(chips).toEqual([
|
||||
'{groupName}',
|
||||
'{user}',
|
||||
'{groupRemark}',
|
||||
'{wxid}',
|
||||
'{previousCount}',
|
||||
'{currentCount}',
|
||||
'{time}'
|
||||
])
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '插入变量 groupName' }))
|
||||
const textarea = screen.getByLabelText('退群通知模板内容') as HTMLTextAreaElement
|
||||
expect(textarea.value).toContain('{groupName}')
|
||||
})
|
||||
|
||||
/*
|
||||
* 预览必须能同时看出「哪个群」和「谁退的」:
|
||||
* groupName 与 groupRemark 一旦取值相同,两者的区别就从界面上消失了。
|
||||
*/
|
||||
it('预览里群名与成员群昵称是两个不同的值', async () => {
|
||||
await openLeaveNotificationEditor()
|
||||
|
||||
const bubble = document.querySelector('.automation-chat-bubble.pre')
|
||||
expect(bubble?.textContent).toContain('群聊: TraceMemo 交流群')
|
||||
expect(bubble?.textContent).toContain('群备注: 小张')
|
||||
})
|
||||
|
||||
it('底部说明直说「群名 vs 成员群昵称」的区别,且不含旧语义残句', async () => {
|
||||
await openLeaveNotificationEditor()
|
||||
|
||||
const note = document.querySelector('.automation-leave-template-note')?.textContent ?? ''
|
||||
expect(note).toContain('未读取到')
|
||||
// 实测有人把 {groupRemark} 当成群名 —— 这句必须直说区别。
|
||||
expect(note).toContain('群聊名')
|
||||
expect(note).toContain('不是群名')
|
||||
// 旧文案两句都是错的:回退链被安到了 groupRemark 上;后半句是"只能发回原群"时代的残句。
|
||||
expect(note).not.toContain('通知仅发送到本规则配置的通知群聊')
|
||||
expect(note).not.toContain('群备注为空时可回退显示群昵称')
|
||||
})
|
||||
|
||||
/*
|
||||
* 变量清单原先只有 {groupRemark} 这种符号,用户看不出含义,
|
||||
* 于是有人把它当成群名。每个 chip 必须带中文说明。
|
||||
*/
|
||||
it('每个变量 chip 都带中文含义说明', async () => {
|
||||
await openLeaveNotificationEditor()
|
||||
|
||||
const chips = Array.from(document.querySelectorAll('.automation-leave-template-chip'))
|
||||
const titleOf = (token: string): string =>
|
||||
chips
|
||||
.find((chip) => chip.textContent?.trim() === token)
|
||||
?.getAttribute('title') ?? ''
|
||||
|
||||
expect(titleOf('{groupName}')).toBe('{groupName}:群聊名(发生退群的群)')
|
||||
expect(titleOf('{groupRemark}')).toContain('不是群名')
|
||||
expect(titleOf('{user}')).toContain('退群成员')
|
||||
expect(titleOf('{time}')).toContain('退群时间')
|
||||
})
|
||||
})
|
||||
|
||||
describe('退群通知 · 群聊名护栏', () => {
|
||||
/** 一份没有群名变量的模板(模拟用户主动删掉了那一行)。 */
|
||||
const ruleWithoutGroupName = (): AutomationRule => ({
|
||||
...createDefaultLeaveNotificationRule(Date.now()),
|
||||
leaveNotification: {
|
||||
target: { type: 'source_chat' },
|
||||
template: '[退群监测]\n\n用户: {user}'
|
||||
}
|
||||
})
|
||||
|
||||
it('目标是「当前群聊」时不提示 —— 通知就发在群里,群名是多余的', async () => {
|
||||
await openLeaveNotificationEditor({ leaveRule: ruleWithoutGroupName() })
|
||||
|
||||
expect(screen.queryByText(/收件人看不出是哪个群退的人/)).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: '补上群聊名' })).toBeNull()
|
||||
})
|
||||
|
||||
it('目标改成非当前群聊时才提示,并给一键补上', async () => {
|
||||
const user = userEvent.setup()
|
||||
await openLeaveNotificationEditor({ leaveRule: ruleWithoutGroupName() })
|
||||
|
||||
await user.click(targetRadio('文件传输助手'))
|
||||
expect(await screen.findByText(/收件人看不出是哪个群退的人/)).toBeVisible()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '补上群聊名' }))
|
||||
|
||||
const textarea = screen.getByLabelText('退群通知模板内容') as HTMLTextAreaElement
|
||||
expect(textarea.value).toContain('群聊: {groupName}')
|
||||
// 补完提示就消失。
|
||||
expect(screen.queryByText(/收件人看不出是哪个群退的人/)).toBeNull()
|
||||
})
|
||||
|
||||
it('模板本来就含群名时,任何目标都不提示', async () => {
|
||||
const user = userEvent.setup()
|
||||
await openLeaveNotificationEditor()
|
||||
|
||||
await user.click(targetRadio('发给自己'))
|
||||
expect(screen.queryByText(/收件人看不出是哪个群退的人/)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('退群监控 → 退群通知自动化', () => {
|
||||
it('退群监控页顶部有自动化入口', async () => {
|
||||
installApi()
|
||||
renderMonitorWithProviders(
|
||||
<GroupExitMonitorWorkspace dbReady onOpenLeaveNotificationAutomation={() => {}} />
|
||||
)
|
||||
|
||||
expect(await screen.findByRole('button', { name: '配置退群通知自动化' })).toBeVisible()
|
||||
})
|
||||
|
||||
it('点入口直接落到「自动化 → 规则 → 退群通知」', async () => {
|
||||
const user = userEvent.setup()
|
||||
installApi()
|
||||
|
||||
function Harness(): React.ReactElement {
|
||||
const [page, setPage] = React.useState<'exit-monitor' | 'automation'>('exit-monitor')
|
||||
const [request, setRequest] = React.useState<AutomationOpenRuleRequest | null>(null)
|
||||
return page === 'exit-monitor' ? (
|
||||
<GroupExitMonitorWorkspace
|
||||
dbReady
|
||||
onOpenLeaveNotificationAutomation={() => {
|
||||
setRequest({ ruleType: 'leave_notification', requestId: 1 })
|
||||
setPage('automation')
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<AutomationWorkspace dbReady openRuleRequest={request} />
|
||||
)
|
||||
}
|
||||
|
||||
renderMonitorWithProviders(<Harness />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '配置退群通知自动化' }))
|
||||
|
||||
expect(await screen.findByLabelText('退群通知模板内容')).toBeVisible()
|
||||
expect(screen.getByRole('radio', { name: '退群通知' })).toBeChecked()
|
||||
})
|
||||
|
||||
it('「管理监控群聊 →」把用户送回退群监控的管理群聊页', async () => {
|
||||
const user = userEvent.setup()
|
||||
installApi()
|
||||
const onOpenExitMonitorGroups = vi.fn()
|
||||
renderWithProviders(
|
||||
<AutomationWorkspace dbReady onOpenExitMonitorGroups={onOpenExitMonitorGroups} />
|
||||
)
|
||||
|
||||
await user.click(within(await leaveNotificationCard()).getByRole('button', { name: '编辑' }))
|
||||
await user.click(screen.getByRole('button', { name: /管理监控群聊/ }))
|
||||
|
||||
expect(onOpenExitMonitorGroups).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('退群通知 · 规则缺失时的兜底', () => {
|
||||
/** 深链的对象引用必须稳定,否则 effect 每次渲染都会重跑。 */
|
||||
function DeepLinkHarness(): React.ReactElement {
|
||||
const [request] = React.useState<AutomationOpenRuleRequest>({
|
||||
ruleType: 'leave_notification',
|
||||
requestId: 1
|
||||
})
|
||||
return <AutomationWorkspace dbReady openRuleRequest={request} />
|
||||
}
|
||||
|
||||
/*
|
||||
* 深链进来时编辑器是**同步**打开的,而规则列表还在 IPC 回来的路上。
|
||||
* 这一帧必然还没有规则 —— 它是最正常的加载状态,不能说成"尚未就绪 / 版本不匹配"。
|
||||
*/
|
||||
it('规则还在读取时显示加载态,不把正常加载描述成故障', async () => {
|
||||
const { spies } = installApi()
|
||||
let release: (rules: AutomationRule[]) => void = () => undefined
|
||||
spies.listAutomationRules.mockReturnValue(
|
||||
new Promise<AutomationRule[]>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
)
|
||||
|
||||
renderWithProviders(<DeepLinkHarness />)
|
||||
|
||||
expect(await screen.findByText('正在读取退群通知规则…')).toBeVisible()
|
||||
// 旧兜底文案(把正常加载说成故障)必须消失。这里用**完整旧句子**匹配 ——
|
||||
// 宽泛的 /尚未就绪/ 会命中顶部「发送能力」卡片,那是另一块 UI。
|
||||
expect(screen.queryByText(/退群通知规则尚未就绪/)).toBeNull()
|
||||
expect(screen.queryByText(/没有找到内置的退群通知规则/)).toBeNull()
|
||||
|
||||
release([
|
||||
createDefaultDailyReportRule(Date.now()),
|
||||
createDefaultLeaveNotificationRule(Date.now())
|
||||
])
|
||||
expect(await screen.findByLabelText('退群通知模板内容')).toBeVisible()
|
||||
})
|
||||
|
||||
it('读完了确实没有这条规则时,给出准确说明与重试入口', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { spies } = installApi()
|
||||
// 只有日报规则 —— 等价于「主进程还是迁移前的旧构建」。
|
||||
spies.listAutomationRules.mockResolvedValue([createDefaultDailyReportRule(Date.now())])
|
||||
|
||||
renderWithProviders(<DeepLinkHarness />)
|
||||
|
||||
expect(await screen.findByText('没有找到内置的退群通知规则。')).toBeVisible()
|
||||
expect(screen.getByText(/请重启 TraceMemo/)).toBeVisible()
|
||||
expect(screen.queryByText('正在读取退群通知规则…')).toBeNull()
|
||||
|
||||
const before = spies.listAutomationRules.mock.calls.length
|
||||
await user.click(screen.getByRole('button', { name: '重新加载' }))
|
||||
await waitFor(() =>
|
||||
expect(spies.listAutomationRules.mock.calls.length).toBeGreaterThan(before)
|
||||
)
|
||||
})
|
||||
|
||||
it('重试后规则回来了就直接进编辑器', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { spies } = installApi()
|
||||
spies.listAutomationRules
|
||||
.mockResolvedValueOnce([createDefaultDailyReportRule(Date.now())])
|
||||
.mockResolvedValue([
|
||||
createDefaultDailyReportRule(Date.now()),
|
||||
createDefaultLeaveNotificationRule(Date.now())
|
||||
])
|
||||
|
||||
renderWithProviders(<DeepLinkHarness />)
|
||||
await user.click(await screen.findByRole('button', { name: '重新加载' }))
|
||||
|
||||
expect(await screen.findByLabelText('退群通知模板内容')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
describe('退群监控 · 旧通知 UI 已移除', () => {
|
||||
it('管理群聊页没有「通知群聊」列,也没有「模板」按钮', async () => {
|
||||
const user = userEvent.setup()
|
||||
installApi()
|
||||
renderMonitorWithProviders(
|
||||
<GroupExitMonitorWorkspace
|
||||
dbReady
|
||||
contacts={[
|
||||
{ m_nsUsrName: 'group_a@chatroom', m_nsNickName: '群 A', md5: 'md5-a', type: 'group' }
|
||||
]}
|
||||
onOpenLeaveNotificationAutomation={() => {}}
|
||||
/>
|
||||
)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '管理群聊' }))
|
||||
|
||||
expect(screen.queryByText('通知群聊')).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: '查看退群监测模板' })).toBeNull()
|
||||
// 监控选择本身仍然可用。
|
||||
expect(screen.getByLabelText('监控群 A')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -289,9 +289,6 @@ let groupExitMonitorState = {
|
||||
monitoredGroupCount: 2,
|
||||
monitorSelectionConfigured: true,
|
||||
monitoredRoomIds: ['group_regular@chatroom', 'group_folded@chatroom'],
|
||||
notificationRoomIds: [],
|
||||
notificationTemplate:
|
||||
'[退群监测]\n\n用户: {user}\n\n群备注: {groupRemark}\n\n微信号: {wxid}\n\n人数: {previousCount} -> {currentCount}\n\n退群时间: {time}',
|
||||
lastCheckedAt: fixtureNowMs - 30 * 1000,
|
||||
lastReadAt: 0,
|
||||
unreadCount: 1
|
||||
@@ -304,33 +301,20 @@ const cloneGroupExitMonitorState = () => ({
|
||||
).length
|
||||
})
|
||||
handle('group-exit-monitor:getState', () => cloneGroupExitMonitorState())
|
||||
handle('group-exit-monitor:setGroups', (roomIds, notificationRoomIds) => {
|
||||
// 只保存**监控范围**:通知配置已迁到自动化规则。
|
||||
handle('group-exit-monitor:setGroups', (roomIds) => {
|
||||
const selected = Array.isArray(roomIds)
|
||||
? roomIds.filter((roomId) => typeof roomId === 'string' && roomId.endsWith('@chatroom'))
|
||||
: []
|
||||
const notifications = Array.isArray(notificationRoomIds)
|
||||
? notificationRoomIds.filter(
|
||||
(roomId) => typeof roomId === 'string' && selected.includes(roomId)
|
||||
)
|
||||
: []
|
||||
groupExitMonitorState = {
|
||||
...groupExitMonitorState,
|
||||
monitorSelectionConfigured: true,
|
||||
monitoredRoomIds: [...new Set(selected)],
|
||||
notificationRoomIds: [...new Set(notifications)],
|
||||
monitoredGroupCount: selected.length,
|
||||
lastCheckedAt: fixtureNowMs
|
||||
}
|
||||
return cloneGroupExitMonitorState()
|
||||
})
|
||||
handle('group-exit-monitor:setTemplate', (template) => {
|
||||
if (typeof template !== 'string' || !template.trim()) throw new Error('模板不能为空')
|
||||
groupExitMonitorState = {
|
||||
...groupExitMonitorState,
|
||||
notificationTemplate: template.trim()
|
||||
}
|
||||
return cloneGroupExitMonitorState()
|
||||
})
|
||||
handle('group-exit-monitor:checkNow', () => {
|
||||
groupExitMonitorState = {
|
||||
...groupExitMonitorState,
|
||||
@@ -358,6 +342,133 @@ handle('group-exit-monitor:markRead', (readAt) => {
|
||||
return cloneGroupExitMonitorState()
|
||||
})
|
||||
|
||||
// ---- Automation v1(@我生成日报)----
|
||||
//
|
||||
// 这组桩只服务 UI / 视觉评审:让自动化首页拿到一份确定性的规则与群列表,
|
||||
// 而不是整页挂着「自动化接口尚未就绪」。
|
||||
// 它**不**参与任何真实发送,也**不**代表自动化功能已迁移。
|
||||
const automationGroupOptions = () =>
|
||||
contacts
|
||||
.filter(
|
||||
(contact) =>
|
||||
contact.type === 'group' || String(contact.m_nsUsrName || '').endsWith('@chatroom')
|
||||
)
|
||||
.map((contact) => ({
|
||||
id: contact.m_nsUsrName,
|
||||
name: contact.m_nsNickName || contact.m_nsUsrName
|
||||
}))
|
||||
|
||||
const builtinDailyReportRule = () => ({
|
||||
id: 'builtin-mention-me-daily-report',
|
||||
name: '@我生成日报',
|
||||
enabled: true,
|
||||
ruleType: 'daily_report',
|
||||
trigger: 'message',
|
||||
scope: 'group',
|
||||
conditions: {
|
||||
requireMentionMe: true,
|
||||
keyword: '日报',
|
||||
keywordMatchMode: 'contains',
|
||||
conversationIds: [],
|
||||
ignoreSelf: true
|
||||
},
|
||||
actions: [
|
||||
{ type: 'replyText', enabled: true, text: '收到,正在生成今日日报' },
|
||||
{ type: 'generateReport', enabled: true },
|
||||
{ type: 'sendReportImage', enabled: true }
|
||||
],
|
||||
cooldownSeconds: 60,
|
||||
replyDelaySeconds: 2,
|
||||
createdAt: fixtureNowMs,
|
||||
updatedAt: fixtureNowMs
|
||||
})
|
||||
|
||||
/*
|
||||
* ⚠️ 这里的默认值必须与 `src/shared/automation.ts` 的
|
||||
* `createDefaultLeaveNotificationRule()` **逐字一致**。
|
||||
*
|
||||
* 它扮演"一个全新安装的 main 进程":一旦漂移,E2E 与截图验证的就不是真实默认行为。
|
||||
* 由 `tests/integration/automation-default-contract.test.ts` 锁住。
|
||||
*/
|
||||
let leaveNotificationRule = {
|
||||
id: 'builtin-leave-notification',
|
||||
name: '退群通知',
|
||||
enabled: true,
|
||||
ruleType: 'leave_notification',
|
||||
trigger: 'message',
|
||||
scope: 'group',
|
||||
conditions: {
|
||||
requireMentionMe: false,
|
||||
keyword: '',
|
||||
keywordMatchMode: 'contains',
|
||||
conversationIds: [],
|
||||
ignoreSelf: true
|
||||
},
|
||||
actions: [],
|
||||
cooldownSeconds: 0,
|
||||
replyDelaySeconds: 2,
|
||||
leaveNotification: {
|
||||
// 默认目标 = 当前群聊(旧退群监控通知的原始行为)。
|
||||
target: { type: 'source_chat' },
|
||||
template:
|
||||
'[退群监测]\n\n群聊: {groupName}\n\n用户: {user}\n\n群备注: {groupRemark}\n\n微信号: {wxid}\n\n人数: {previousCount} -> {currentCount}\n\n退群时间: {time}'
|
||||
},
|
||||
createdAt: fixtureNowMs,
|
||||
updatedAt: fixtureNowMs
|
||||
}
|
||||
|
||||
/** 「指定好友」候选:个人联系人,排除群 / 公众号 / 文件传输助手 / 自己。 */
|
||||
const sendableContactOptions = () =>
|
||||
contacts
|
||||
.filter(
|
||||
(contact) =>
|
||||
contact.type !== 'group' &&
|
||||
!String(contact.m_nsUsrName || '').endsWith('@chatroom') &&
|
||||
!String(contact.m_nsUsrName || '').startsWith('gh_') &&
|
||||
contact.m_nsUsrName !== 'filehelper'
|
||||
)
|
||||
.map((contact) => ({
|
||||
id: contact.m_nsUsrName,
|
||||
name: contact.m_nsNickName || contact.m_nsUsrName
|
||||
}))
|
||||
|
||||
handle('automation:getStatus', () => ({
|
||||
listening: connected,
|
||||
listeningDegraded: false,
|
||||
todayExecutions: connected ? 3 : 0,
|
||||
todaySuccesses: connected ? 3 : 0,
|
||||
sendCapability: {
|
||||
supported: personalWechatSupported,
|
||||
ready: connected,
|
||||
canSendText: connected,
|
||||
canSendImage: connected,
|
||||
message: connected ? '个人微信发送能力已就绪' : '尚未绑定个人微信发送能力'
|
||||
}
|
||||
}))
|
||||
handle('automation:listRules', () => [
|
||||
builtinDailyReportRule(),
|
||||
structuredClone(leaveNotificationRule)
|
||||
])
|
||||
handle('automation:listGroups', () => automationGroupOptions())
|
||||
handle('automation:listExecutions', () => [])
|
||||
handle('automation:clearExecutions', () => true)
|
||||
handle('automation:listSendableContacts', () => sendableContactOptions())
|
||||
handle('automation:saveLeaveNotificationRule', (draft) => {
|
||||
leaveNotificationRule = {
|
||||
...leaveNotificationRule,
|
||||
...(draft && typeof draft === 'object' ? draft : {}),
|
||||
id: 'builtin-leave-notification',
|
||||
ruleType: 'leave_notification',
|
||||
updatedAt: fixtureNowMs
|
||||
}
|
||||
if (leaveNotificationRule.leaveNotification) {
|
||||
const { targetNeedsReview, ...rest } = leaveNotificationRule.leaveNotification
|
||||
void targetNeedsReview
|
||||
leaveNotificationRule.leaveNotification = rest
|
||||
}
|
||||
return structuredClone(leaveNotificationRule)
|
||||
})
|
||||
|
||||
const scheduledReportTasks = []
|
||||
const scheduledReportExecutions = []
|
||||
const generatedReports = []
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { readFileSync } from 'fs-extra'
|
||||
import { join } from 'path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createDefaultLeaveNotificationRule } from '../../src/shared/automation'
|
||||
import { GROUP_EXIT_NOTIFICATION_TEMPLATE } from '../../src/shared/group-exit-monitor'
|
||||
|
||||
/**
|
||||
* E2E main 替身(`tests/e2e/support/electron-main.cjs`)的**默认值契约**。
|
||||
*
|
||||
* 替身扮演"一个全新安装的 main 进程"。它里面的默认值必须与真实默认值逐字一致,
|
||||
* 否则 E2E 与截图验证的就是一个**不存在的世界**。
|
||||
*
|
||||
* 这个契约是被真实事故逼出来的:迁移时把默认目标从"文件传输助手"改成"当前群聊"、
|
||||
* 并往默认模板里加了「群聊: {groupName}」一行,但替身没跟着改 ——
|
||||
* 于是截图里看到的是"当前群聊没被选中、模板也没有群聊行",
|
||||
* 而组件测试却全绿(组件测试用的是内存 mock,不是替身)。
|
||||
*/
|
||||
|
||||
const DOUBLE_PATH = join(process.cwd(), 'tests/e2e/support/electron-main.cjs')
|
||||
|
||||
/**
|
||||
* 取出替身里整条退群通知规则(该声明在文件里只出现一次)。
|
||||
*
|
||||
* 起点取 `let leaveNotificationRule = {` 而不是 `leaveNotification:` ——
|
||||
* `enabled` / `ruleType` / `cooldownSeconds` 都在嵌套的 leaveNotification 之前。
|
||||
*/
|
||||
function leaveNotificationBlock(): string {
|
||||
const source = readFileSync(DOUBLE_PATH, 'utf8')
|
||||
const start = source.indexOf('let leaveNotificationRule = {')
|
||||
expect(start, '替身里找不到 leaveNotificationRule').toBeGreaterThan(-1)
|
||||
return source.slice(start)
|
||||
}
|
||||
|
||||
describe('E2E main 替身的默认值契约', () => {
|
||||
const defaults = createDefaultLeaveNotificationRule(0)
|
||||
|
||||
it('退群通知的默认目标与真实默认值一致', () => {
|
||||
const block = leaveNotificationBlock()
|
||||
const match = block.match(/target:\s*\{\s*type:\s*'([a-z_]+)'/)
|
||||
|
||||
expect(match?.[1]).toBe(defaults.leaveNotification?.target.type)
|
||||
})
|
||||
|
||||
it('退群通知的默认模板与真实默认值逐字一致', () => {
|
||||
const block = leaveNotificationBlock()
|
||||
const match = block.match(/template:\s*'((?:[^'\\]|\\.)*)'/)
|
||||
|
||||
expect(match, '替身里找不到 template 字面量').not.toBeNull()
|
||||
// 替身里是单引号 + \n 转义的 JS 字面量,反转义后与 shared 常量比对。
|
||||
const template = (match as RegExpMatchArray)[1].replace(/\\n/g, '\n')
|
||||
|
||||
expect(template).toBe(defaults.leaveNotification?.template)
|
||||
expect(template).toBe(GROUP_EXIT_NOTIFICATION_TEMPLATE)
|
||||
})
|
||||
|
||||
it('替身里必须存在群名占位符(否则截图看不出群名变量)', () => {
|
||||
expect(leaveNotificationBlock()).toContain('群聊: {groupName}')
|
||||
})
|
||||
|
||||
it('起停/冷却语义与真实默认值一致', () => {
|
||||
const block = leaveNotificationBlock()
|
||||
|
||||
expect(block).toMatch(/enabled:\s*true/)
|
||||
expect(block).toMatch(/ruleType:\s*'leave_notification'/)
|
||||
// 退群通知不套用消息型规则的冷却,否则两次退群会被并成一次。
|
||||
expect(block).toMatch(/cooldownSeconds:\s*0/)
|
||||
})
|
||||
|
||||
it('替身里的日报规则类型与真实分派一致', () => {
|
||||
const source = readFileSync(DOUBLE_PATH, 'utf8')
|
||||
expect(source).toContain("ruleType: 'daily_report'")
|
||||
})
|
||||
})
|
||||
@@ -73,14 +73,6 @@ describe('preload IPC contract', () => {
|
||||
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'])
|
||||
expect(invoke).toHaveBeenLastCalledWith(
|
||||
'group-exit-monitor:setGroups',
|
||||
['room@chatroom'],
|
||||
['room@chatroom']
|
||||
)
|
||||
await api.setGroupExitMonitorNotificationTemplate('用户: {user}')
|
||||
expect(invoke).toHaveBeenLastCalledWith('group-exit-monitor:setTemplate', '用户: {user}')
|
||||
await api.checkGroupExitMonitorNow()
|
||||
expect(invoke).toHaveBeenLastCalledWith('group-exit-monitor:checkNow')
|
||||
await api.clearGroupExitMonitorEvents()
|
||||
@@ -90,6 +82,24 @@ describe('preload IPC contract', () => {
|
||||
await api.listWechatActionLogs()
|
||||
expect(invoke).toHaveBeenLastCalledWith('wechat-action-log:list')
|
||||
|
||||
// 退群通知:保存走 singleton upsert,联系人候选由 main 侧过滤。
|
||||
const leaveDraft = {
|
||||
name: '退群通知',
|
||||
enabled: true,
|
||||
ruleType: 'leave_notification',
|
||||
trigger: 'message',
|
||||
scope: 'group',
|
||||
conditions: {},
|
||||
actions: [],
|
||||
cooldownSeconds: 0,
|
||||
replyDelaySeconds: 2,
|
||||
leaveNotification: { target: { type: 'file_transfer' }, template: '[退群监测]' }
|
||||
} as never
|
||||
await api.saveLeaveNotificationRule(leaveDraft)
|
||||
expect(invoke).toHaveBeenLastCalledWith('automation:saveLeaveNotificationRule', leaveDraft)
|
||||
await api.listSendableContacts()
|
||||
expect(invoke).toHaveBeenLastCalledWith('automation:listSendableContacts')
|
||||
|
||||
await api.getImage('fixture-md5', 'fixture.dat', 'fixture-session', {
|
||||
force: true,
|
||||
priority: 0
|
||||
|
||||
@@ -14,7 +14,7 @@ const root = vi.hoisted(() => {
|
||||
|
||||
vi.mock('electron', () => ({ app: { getPath: () => root } }))
|
||||
|
||||
import { BUILTIN_DAILY_REPORT_RULE_ID } from '../../src/shared/automation'
|
||||
import { BUILTIN_DAILY_REPORT_RULE_ID, BUILTIN_LEAVE_NOTIFICATION_RULE_ID } from '../../src/shared/automation'
|
||||
import {
|
||||
AutomationRuleStore,
|
||||
type AutomationRuleStoreDependencies
|
||||
@@ -31,6 +31,15 @@ const deps = (dir: string, now?: () => number): AutomationRuleStoreDependencies
|
||||
...(now ? { now } : {})
|
||||
})
|
||||
|
||||
/** 写旧退群监控状态文件(退群通知迁移的唯一输入)。 */
|
||||
function writeLegacyMonitorState(input: {
|
||||
monitoredRoomIds?: string[]
|
||||
notificationRoomIds?: string[]
|
||||
notificationTemplate?: string
|
||||
}): void {
|
||||
fs.writeJsonSync(path.join(root, 'group-exit-monitor.json'), input)
|
||||
}
|
||||
|
||||
describe('AutomationRuleStore', () => {
|
||||
beforeEach(() => {
|
||||
fs.removeSync(path.join(root, 'automation'))
|
||||
@@ -38,21 +47,22 @@ describe('AutomationRuleStore', () => {
|
||||
|
||||
afterAll(() => fs.removeSync(root))
|
||||
|
||||
it('首次加载会播种内置规则「@我生成日报」并落盘', () => {
|
||||
it('首次加载会播种内置规则「@我生成日报」与「退群通知」并落盘', () => {
|
||||
const store = new AutomationRuleStore(deps(root))
|
||||
const rules = store.listRules()
|
||||
|
||||
expect(rules).toHaveLength(1)
|
||||
const builtin = rules[0]
|
||||
expect(builtin.id).toBe(BUILTIN_DAILY_REPORT_RULE_ID)
|
||||
expect(builtin.name).toBe('@我生成日报')
|
||||
expect(builtin.enabled).toBe(true)
|
||||
expect(builtin.scope).toBe('group')
|
||||
expect(builtin.conditions.requireMentionMe).toBe(true)
|
||||
expect(builtin.conditions.keyword).toBe('日报')
|
||||
expect(builtin.conditions.ignoreSelf).toBe(true)
|
||||
expect(builtin.cooldownSeconds).toBe(60)
|
||||
expect(builtin.actions.map((action) => action.type)).toEqual([
|
||||
expect(rules).toHaveLength(2)
|
||||
const builtin = rules.find((rule) => rule.id === BUILTIN_DAILY_REPORT_RULE_ID)
|
||||
expect(builtin).toBeDefined()
|
||||
expect(builtin?.name).toBe('@我生成日报')
|
||||
expect(builtin?.ruleType).toBe('daily_report')
|
||||
expect(builtin?.enabled).toBe(true)
|
||||
expect(builtin?.scope).toBe('group')
|
||||
expect(builtin?.conditions.requireMentionMe).toBe(true)
|
||||
expect(builtin?.conditions.keyword).toBe('日报')
|
||||
expect(builtin?.conditions.ignoreSelf).toBe(true)
|
||||
expect(builtin?.cooldownSeconds).toBe(60)
|
||||
expect(builtin?.actions.map((action) => action.type)).toEqual([
|
||||
'replyText',
|
||||
'generateReport',
|
||||
'sendReportImage'
|
||||
@@ -60,9 +70,25 @@ describe('AutomationRuleStore', () => {
|
||||
expect(fs.pathExistsSync(rulesFile())).toBe(true)
|
||||
})
|
||||
|
||||
it('全新安装的退群通知规则:默认目标是「当前群聊」', () => {
|
||||
const store = new AutomationRuleStore(deps(root))
|
||||
const leave = store.listRules().find((rule) => rule.id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
|
||||
|
||||
expect(leave).toBeDefined()
|
||||
expect(leave?.ruleType).toBe('leave_notification')
|
||||
expect(leave?.name).toBe('退群通知')
|
||||
// 「当前群聊」= 旧退群监控通知的原始行为,所以既是默认值也是第一项。
|
||||
expect(leave?.leaveNotification?.target).toEqual({ type: 'source_chat' })
|
||||
expect(leave?.leaveNotification?.targetNeedsReview).toBeUndefined()
|
||||
// 退群通知不套用消息型规则的冷却,避免把两次退群合并成一次。
|
||||
expect(leave?.cooldownSeconds).toBe(0)
|
||||
expect(leave?.actions).toEqual([])
|
||||
})
|
||||
|
||||
it('删掉内置规则后不会在下次启动时被重新塞回来', () => {
|
||||
const store = new AutomationRuleStore(deps(root))
|
||||
expect(store.deleteRule(BUILTIN_DAILY_REPORT_RULE_ID)).toBe(true)
|
||||
expect(store.deleteRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)).toBe(true)
|
||||
expect(store.listRules()).toHaveLength(0)
|
||||
|
||||
// 关键:新实例(等于重启)不应该重新播种。
|
||||
@@ -83,12 +109,13 @@ describe('AutomationRuleStore', () => {
|
||||
|
||||
expect(created.id).not.toBe(BUILTIN_DAILY_REPORT_RULE_ID)
|
||||
expect(created.name).toBe('关键词回复')
|
||||
expect(created.ruleType).toBe('daily_report')
|
||||
expect(created.scope).toBe('group')
|
||||
expect(created.conditions.keywordMatchMode).toBe('contains')
|
||||
expect(created.conditions.conversationIds).toEqual(['a'])
|
||||
expect(created.createdAt).toBe(now)
|
||||
expect(created.updatedAt).toBe(now)
|
||||
expect(store.listRules()).toHaveLength(2)
|
||||
expect(store.listRules()).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('updateRule 保留 id 与 createdAt,刷新 updatedAt', () => {
|
||||
@@ -137,13 +164,20 @@ describe('AutomationRuleStore', () => {
|
||||
fs.writeFileSync(rulesFile(), '{ 这不是 JSON', 'utf8')
|
||||
|
||||
const store = new AutomationRuleStore(deps(root))
|
||||
expect(store.listRules()).toHaveLength(1)
|
||||
expect(store.listRules()[0].id).toBe(BUILTIN_DAILY_REPORT_RULE_ID)
|
||||
expect(store.listRules()).toHaveLength(2)
|
||||
expect(store.listRules().map((rule) => rule.id)).toEqual(
|
||||
expect.arrayContaining([BUILTIN_DAILY_REPORT_RULE_ID, BUILTIN_LEAVE_NOTIFICATION_RULE_ID])
|
||||
)
|
||||
})
|
||||
|
||||
it('已播种但规则列表为空的存档不会被再次播种', () => {
|
||||
it('已播种但规则列表为空的存档不会被再次播种「@我生成日报」', () => {
|
||||
fs.ensureDirSync(path.dirname(rulesFile()))
|
||||
fs.writeJsonSync(rulesFile(), { version: 1, builtinSeeded: true, rules: [] })
|
||||
fs.writeJsonSync(rulesFile(), {
|
||||
version: 2,
|
||||
builtinSeeded: true,
|
||||
leaveNotificationMigrated: true,
|
||||
rules: []
|
||||
})
|
||||
|
||||
const store = new AutomationRuleStore(deps(root))
|
||||
expect(store.listRules()).toHaveLength(0)
|
||||
@@ -156,6 +190,291 @@ describe('AutomationRuleStore', () => {
|
||||
const reopened = new AutomationRuleStore(deps(root))
|
||||
expect(reopened.listRules().map((rule) => rule.name)).toContain('持久化检查')
|
||||
})
|
||||
|
||||
it('saveLeaveNotificationRule 是 singleton upsert:保存多次也只有一条', () => {
|
||||
const store = new AutomationRuleStore(deps(root))
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
store.saveLeaveNotificationRule({
|
||||
name: '退群通知',
|
||||
enabled: true,
|
||||
ruleType: 'leave_notification',
|
||||
scope: 'group',
|
||||
conditions: {},
|
||||
actions: [],
|
||||
cooldownSeconds: 0,
|
||||
replyDelaySeconds: 2,
|
||||
leaveNotification: {
|
||||
target: { type: 'contact', contactId: `wxid_friend_${index}` },
|
||||
template: '[退群监测]\n用户: {user}'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const leaveRules = store
|
||||
.listRules()
|
||||
.filter((rule) => rule.id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
|
||||
expect(leaveRules).toHaveLength(1)
|
||||
expect(leaveRules[0].leaveNotification?.target).toEqual({
|
||||
type: 'contact',
|
||||
contactId: 'wxid_friend_2'
|
||||
})
|
||||
})
|
||||
|
||||
it('保存退群通知会清掉迁移遗留的「目标待重选」标记', () => {
|
||||
writeLegacyMonitorState({
|
||||
monitoredRoomIds: ['a@chatroom', 'b@chatroom'],
|
||||
notificationRoomIds: ['a@chatroom']
|
||||
})
|
||||
const store = new AutomationRuleStore(deps(root))
|
||||
expect(
|
||||
store.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)?.leaveNotification?.targetNeedsReview
|
||||
).toBe(true)
|
||||
|
||||
store.saveLeaveNotificationRule({
|
||||
...store.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID),
|
||||
enabled: true,
|
||||
leaveNotification: { target: { type: 'source_chat' }, template: '[退群监测]' }
|
||||
})
|
||||
|
||||
const saved = store.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
|
||||
expect(saved?.leaveNotification?.targetNeedsReview).toBeUndefined()
|
||||
expect(saved?.leaveNotification?.target).toEqual({ type: 'source_chat' })
|
||||
})
|
||||
|
||||
/*
|
||||
* 加上群名变量之前的默认模板(有「人数」行、没有「群聊」行)。
|
||||
* 这正是老用户手上那份 —— 模板是他们的已保存数据。
|
||||
*/
|
||||
const PREVIOUS_DEFAULT_TEMPLATE = [
|
||||
'[退群监测]',
|
||||
'',
|
||||
'用户: {user}',
|
||||
'',
|
||||
'群备注: {groupRemark}',
|
||||
'',
|
||||
'微信号: {wxid}',
|
||||
'',
|
||||
'人数: {previousCount} -> {currentCount}',
|
||||
'',
|
||||
'退群时间: {time}'
|
||||
].join('\n')
|
||||
|
||||
/** 写一份"老用户"的规则文件(退群通知模板里没有群名变量)。 */
|
||||
function writeRulesWithTemplate(
|
||||
template: string,
|
||||
flags: { notificationTemplateUpgraded?: boolean } = {}
|
||||
): void {
|
||||
fs.ensureDirSync(path.dirname(rulesFile()))
|
||||
fs.writeJsonSync(rulesFile(), {
|
||||
version: 2,
|
||||
builtinSeeded: true,
|
||||
leaveNotificationMigrated: true,
|
||||
notificationTemplateUpgraded: flags.notificationTemplateUpgraded === true,
|
||||
rules: [
|
||||
{
|
||||
id: BUILTIN_DAILY_REPORT_RULE_ID,
|
||||
name: '@我生成日报',
|
||||
enabled: true,
|
||||
ruleType: 'daily_report',
|
||||
trigger: 'message',
|
||||
scope: 'group',
|
||||
conditions: { requireMentionMe: true, keyword: '日报', keywordMatchMode: 'contains' },
|
||||
actions: [{ type: 'generateReplyText', enabled: true }],
|
||||
cooldownSeconds: 60,
|
||||
replyDelaySeconds: 2,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
},
|
||||
{
|
||||
id: BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
|
||||
name: '退群通知',
|
||||
enabled: true,
|
||||
ruleType: 'leave_notification',
|
||||
trigger: 'message',
|
||||
scope: 'group',
|
||||
conditions: { requireMentionMe: false, keyword: '', keywordMatchMode: 'contains' },
|
||||
actions: [],
|
||||
cooldownSeconds: 0,
|
||||
replyDelaySeconds: 2,
|
||||
leaveNotification: { target: { type: 'source_chat' }, template },
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
describe('替用户把群名变量补进模板(一次性)', () => {
|
||||
it('老用户首次读盘时自动补上「群聊」这一行,其余内容保留', () => {
|
||||
writeRulesWithTemplate(PREVIOUS_DEFAULT_TEMPLATE)
|
||||
|
||||
const rule = new AutomationRuleStore(deps(root)).getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
|
||||
const template = rule?.leaveNotification?.template ?? ''
|
||||
|
||||
expect(template).toContain('群聊: {groupName}')
|
||||
// 原来每一行都还在。
|
||||
for (const line of PREVIOUS_DEFAULT_TEMPLATE.split('\n')) {
|
||||
if (line.trim()) expect(template).toContain(line)
|
||||
}
|
||||
})
|
||||
|
||||
it('只补一次:之后用户删掉这一行,重启不会被加回来', () => {
|
||||
writeRulesWithTemplate(PREVIOUS_DEFAULT_TEMPLATE)
|
||||
// 第一轮:自动补上,并落盘标记。
|
||||
const first = new AutomationRuleStore(deps(root))
|
||||
expect(first.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)?.leaveNotification?.template).toContain(
|
||||
'群聊: {groupName}'
|
||||
)
|
||||
|
||||
// 用户主动把这一行删掉并保存。
|
||||
const stripped = PREVIOUS_DEFAULT_TEMPLATE
|
||||
first.saveLeaveNotificationRule({
|
||||
...first.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID),
|
||||
leaveNotification: { target: { type: 'source_chat' }, template: stripped }
|
||||
})
|
||||
|
||||
// 重启:不能再替他补 —— "删不掉"比缺信息更讨厌。
|
||||
const reopened = new AutomationRuleStore(deps(root))
|
||||
expect(
|
||||
reopened.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)?.leaveNotification?.template
|
||||
).toBe(stripped)
|
||||
})
|
||||
|
||||
it('已经含 {groupName} 的模板原样不动', () => {
|
||||
const already = '[退群监测]\n\n群聊: {groupName}\n\n用户: {user}'
|
||||
writeRulesWithTemplate(already)
|
||||
|
||||
const rule = new AutomationRuleStore(deps(root)).getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
|
||||
expect(rule?.leaveNotification?.template).toBe(already)
|
||||
})
|
||||
|
||||
it('用户完全自定义的模板也不会被覆盖内容', () => {
|
||||
writeRulesWithTemplate('退群: {user}')
|
||||
|
||||
const rule = new AutomationRuleStore(deps(root)).getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
|
||||
const template = rule?.leaveNotification?.template ?? ''
|
||||
|
||||
expect(template).toContain('退群: {user}')
|
||||
expect(template).toContain('群聊: {groupName}')
|
||||
})
|
||||
|
||||
it('标记已是 true 时不再动模板', () => {
|
||||
writeRulesWithTemplate(PREVIOUS_DEFAULT_TEMPLATE, { notificationTemplateUpgraded: true })
|
||||
|
||||
const rule = new AutomationRuleStore(deps(root)).getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
|
||||
expect(rule?.leaveNotification?.template).toBe(PREVIOUS_DEFAULT_TEMPLATE)
|
||||
})
|
||||
|
||||
it('全新安装的模板本来就带群名,不需要补', () => {
|
||||
const rule = new AutomationRuleStore(deps(root)).getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
|
||||
expect(rule?.leaveNotification?.template).toContain('群聊: {groupName}')
|
||||
})
|
||||
})
|
||||
|
||||
describe('旧退群通知配置迁移', () => {
|
||||
it('旧配置从不通知任何人 → 迁移成关闭,且保留自定义模板', () => {
|
||||
writeLegacyMonitorState({
|
||||
monitoredRoomIds: ['a@chatroom', 'b@chatroom'],
|
||||
notificationRoomIds: [],
|
||||
notificationTemplate: '退群: {user}'
|
||||
})
|
||||
|
||||
const leave = new AutomationRuleStore(deps(root)).getRule(
|
||||
BUILTIN_LEAVE_NOTIFICATION_RULE_ID
|
||||
)
|
||||
|
||||
expect(leave?.enabled).toBe(false)
|
||||
// 关闭状态下 target 只是占位值(= 默认目标),不会发送任何东西。
|
||||
expect(leave?.leaveNotification?.target).toEqual({ type: 'source_chat' })
|
||||
// 用户写的模板内容保留;同一次启动的"补群名"升级会另加一行。
|
||||
expect(leave?.leaveNotification?.template).toContain('退群: {user}')
|
||||
expect(leave?.leaveNotification?.template).toContain('群聊: {groupName}')
|
||||
})
|
||||
|
||||
it('旧配置在每个被监控群都通知 → 无损映射为「当前群聊」且保持启用', () => {
|
||||
writeLegacyMonitorState({
|
||||
monitoredRoomIds: ['a@chatroom', 'b@chatroom'],
|
||||
notificationRoomIds: ['a@chatroom', 'b@chatroom']
|
||||
})
|
||||
|
||||
const leave = new AutomationRuleStore(deps(root)).getRule(
|
||||
BUILTIN_LEAVE_NOTIFICATION_RULE_ID
|
||||
)
|
||||
|
||||
expect(leave?.enabled).toBe(true)
|
||||
expect(leave?.leaveNotification?.target).toEqual({ type: 'source_chat' })
|
||||
expect(leave?.leaveNotification?.targetNeedsReview).toBeUndefined()
|
||||
})
|
||||
|
||||
/*
|
||||
* 关键的一条:旧「通知群聊」是**逐群**的多值配置,新目标是**单值**。
|
||||
* 只勾了一部分时无法无损映射 —— 这时候:
|
||||
* 不取第一个群、不改写成当前群聊、保持关闭、要求用户重选,并留一份备份。
|
||||
*/
|
||||
it('旧配置只勾了部分群 → 标记待重选、不发送、留下备份,绝不猜', () => {
|
||||
writeLegacyMonitorState({
|
||||
monitoredRoomIds: ['a@chatroom', 'b@chatroom', 'c@chatroom'],
|
||||
notificationRoomIds: ['b@chatroom'],
|
||||
notificationTemplate: '退群: {user}'
|
||||
})
|
||||
|
||||
const store = new AutomationRuleStore(deps(root))
|
||||
const leave = store.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
|
||||
|
||||
expect(leave?.enabled).toBe(false)
|
||||
expect(leave?.leaveNotification?.targetNeedsReview).toBe(true)
|
||||
// 不回落到「取第一个群」,也不改写成别的具体目标 —— 只留默认占位(当前群聊),
|
||||
// 且因为 enabled=false + targetNeedsReview=true,在用户重选之前不会发送。
|
||||
expect(leave?.leaveNotification?.target).toEqual({ type: 'source_chat' })
|
||||
// 用户写的模板内容保留;同一次启动的"补群名"升级会另加一行。
|
||||
expect(leave?.leaveNotification?.template).toContain('退群: {user}')
|
||||
expect(leave?.leaveNotification?.template).toContain('群聊: {groupName}')
|
||||
|
||||
const backup = fs.readJsonSync(
|
||||
path.join(root, 'automation', 'leave-notification-migration-backup.json')
|
||||
)
|
||||
expect(backup).toMatchObject({
|
||||
outcome: 'needs_review',
|
||||
legacy: { notificationRoomIds: ['b@chatroom'] }
|
||||
})
|
||||
expect(store.getLastLeaveNotificationMigration()?.outcome).toBe('needs_review')
|
||||
})
|
||||
|
||||
it('迁移只跑一次:重启两次也只有一条退群通知规则', () => {
|
||||
writeLegacyMonitorState({
|
||||
monitoredRoomIds: ['a@chatroom', 'b@chatroom'],
|
||||
notificationRoomIds: ['a@chatroom', 'b@chatroom']
|
||||
})
|
||||
|
||||
const first = new AutomationRuleStore(deps(root))
|
||||
const second = new AutomationRuleStore(deps(root))
|
||||
|
||||
for (const store of [first, second]) {
|
||||
expect(
|
||||
store.listRules().filter((rule) => rule.id === BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
|
||||
).toHaveLength(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('迁移完成标记会被持久化,删掉退群通知规则后重启不会再被塞回来', () => {
|
||||
const store = new AutomationRuleStore(deps(root))
|
||||
expect(store.deleteRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)).toBe(true)
|
||||
|
||||
const reopened = new AutomationRuleStore(deps(root))
|
||||
expect(reopened.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('旧状态文件在迁移后仍留在磁盘上(不做不可逆删除)', () => {
|
||||
writeLegacyMonitorState({
|
||||
monitoredRoomIds: ['a@chatroom'],
|
||||
notificationRoomIds: ['a@chatroom']
|
||||
})
|
||||
|
||||
void new AutomationRuleStore(deps(root))
|
||||
|
||||
expect(fs.pathExistsSync(path.join(root, 'group-exit-monitor.json'))).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('AutomationExecutionLogService', () => {
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
chat: {
|
||||
listContacts: vi.fn(),
|
||||
getSelfAccountInfo: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({ app: { getPath: () => '/tmp/tracememo-automation-group-exit' } }))
|
||||
vi.mock('../../src/main/services/chat-service', () => mocks.chat)
|
||||
|
||||
import {
|
||||
AutomationService,
|
||||
leaveNotificationExecutionId,
|
||||
leaveNotificationIdempotencyKey
|
||||
} from '../../src/main/services/automation-service'
|
||||
import { AutomationActionRunner } from '../../src/main/services/automation-action-runner'
|
||||
import type { AutomationExecutionLogService } from '../../src/main/services/automation-execution-log-service'
|
||||
import type { AutomationRuleStore } from '../../src/main/services/automation-rule-store'
|
||||
import {
|
||||
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
|
||||
createDefaultLeaveNotificationRule,
|
||||
type AutomationExecution,
|
||||
type AutomationRule,
|
||||
type LeaveNotificationConfig
|
||||
} from '../../src/shared/automation'
|
||||
import type { GroupMemberExitedEvent } from '../../src/shared/group-exit-event'
|
||||
import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat'
|
||||
import type { WechatActionRequest, WechatActionResult } from '../../src/shared/wechat-action'
|
||||
|
||||
/**
|
||||
* 退群事件 → 自动化执行。
|
||||
*
|
||||
* 这是迁移后的**唯一发送链路**,所以这里锁的是行为契约而不是实现细节:
|
||||
* 关闭不执行、四种目标各自解析正确、同一事件只发一次、失败不影响退群事实。
|
||||
*/
|
||||
|
||||
const readyCapability: PersonalWechatSendCapability = {
|
||||
supported: true,
|
||||
ready: true,
|
||||
status: 'ready',
|
||||
capabilities: { text: true, image: true, voice: true },
|
||||
senderStatus: {} as never,
|
||||
message: 'ready'
|
||||
}
|
||||
|
||||
const NOW = 1_700_000_000_000
|
||||
|
||||
function exitEvent(overrides: Partial<GroupMemberExitedEvent> = {}): GroupMemberExitedEvent {
|
||||
return {
|
||||
eventId: 'room@chatroom:wxid_member:1700000000000:1',
|
||||
conversationId: 'room@chatroom',
|
||||
groupName: '产品测试群',
|
||||
memberId: 'wxid_member',
|
||||
memberName: '张三',
|
||||
wechatNickname: '张三',
|
||||
previousCount: 243,
|
||||
currentCount: 242,
|
||||
occurredAt: NOW,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function sentAction(): WechatActionResult {
|
||||
return {
|
||||
actionId: 'action-1',
|
||||
status: 'sent',
|
||||
decision: 'allow',
|
||||
startedAt: new Date(NOW).toISOString(),
|
||||
finishedAt: new Date(NOW).toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
service: AutomationService
|
||||
requests: WechatActionRequest[]
|
||||
executions: AutomationExecution[]
|
||||
setRule: (rule: AutomationRule | undefined) => void
|
||||
setCapability: (capability: PersonalWechatSendCapability) => void
|
||||
}
|
||||
|
||||
function createHarness(
|
||||
options: {
|
||||
config?: Partial<LeaveNotificationConfig>
|
||||
rule?: Partial<AutomationRule>
|
||||
actionResult?: () => Promise<WechatActionResult> | WechatActionResult
|
||||
} = {}
|
||||
): Harness {
|
||||
const base = createDefaultLeaveNotificationRule(NOW)
|
||||
let rule: AutomationRule | undefined = {
|
||||
...base,
|
||||
...options.rule,
|
||||
leaveNotification: { ...base.leaveNotification!, ...options.config }
|
||||
}
|
||||
let capability: PersonalWechatSendCapability = readyCapability
|
||||
const requests: WechatActionRequest[] = []
|
||||
const executions: AutomationExecution[] = []
|
||||
|
||||
const ruleStore = {
|
||||
listRules: () => (rule ? [rule] : []),
|
||||
getRule: (id: string) => (rule && rule.id === id ? rule : undefined)
|
||||
} as unknown as AutomationRuleStore
|
||||
const executionLog = {
|
||||
record: (execution: AutomationExecution) => {
|
||||
const index = executions.findIndex((item) => item.executionId === execution.executionId)
|
||||
if (index >= 0) executions[index] = execution
|
||||
else executions.push(execution)
|
||||
},
|
||||
list: () => executions,
|
||||
clear: () => true,
|
||||
countSince: () => ({ total: executions.length, success: 0 })
|
||||
} as unknown as AutomationExecutionLogService
|
||||
const runner = new AutomationActionRunner({
|
||||
executeAction: async (request) => {
|
||||
requests.push(request)
|
||||
return options.actionResult ? options.actionResult() : sentAction()
|
||||
},
|
||||
now: () => NOW
|
||||
})
|
||||
|
||||
const service = new AutomationService({} as never, {
|
||||
ruleStore,
|
||||
executionLog,
|
||||
runner,
|
||||
getCapability: async () => capability,
|
||||
now: () => NOW
|
||||
})
|
||||
|
||||
return {
|
||||
service,
|
||||
requests,
|
||||
executions,
|
||||
setRule: (next) => {
|
||||
rule = next
|
||||
},
|
||||
setCapability: (next) => {
|
||||
capability = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.chat.listContacts.mockReset().mockReturnValue([
|
||||
{ m_nsUsrName: 'wxid_friend', m_nsNickName: '好友', remark: '好友备注', type: 'user' },
|
||||
{ m_nsUsrName: 'room@chatroom', m_nsNickName: '产品测试群', type: 'group' }
|
||||
])
|
||||
mocks.chat.getSelfAccountInfo.mockReset().mockReturnValue({ wxid: 'wxid_self' })
|
||||
})
|
||||
|
||||
describe('AutomationService.handleGroupExit', () => {
|
||||
it('退群事件 → 命中规则 → 解析目标 → Gateway 调用一次 → execution 成功', async () => {
|
||||
const harness = createHarness({ config: { target: { type: 'file_transfer' } } })
|
||||
|
||||
await harness.service.handleGroupExit(exitEvent())
|
||||
|
||||
expect(harness.requests).toHaveLength(1)
|
||||
expect(harness.requests[0]).toMatchObject({
|
||||
origin: 'automation',
|
||||
purpose: 'automation_leave_notification',
|
||||
triggerType: 'automation',
|
||||
recipient: { type: 'contact', id: 'filehelper', name: '文件传输助手' },
|
||||
content: { type: 'text' }
|
||||
})
|
||||
expect(harness.executions).toHaveLength(1)
|
||||
expect(harness.executions[0]).toMatchObject({
|
||||
ruleId: BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
|
||||
ruleName: '退群通知',
|
||||
sourceDisplayName: '产品测试群',
|
||||
status: 'success'
|
||||
})
|
||||
// 步骤序列:触发 → 匹配 → 目标 → 发送
|
||||
expect(harness.executions[0].steps.map((step) => step.key)).toEqual([
|
||||
'exit_received',
|
||||
'exit_matched',
|
||||
'exit_target',
|
||||
'exit_send'
|
||||
])
|
||||
// 用户可见的「目标」是显示名,不是 wxid。
|
||||
const targetStep = harness.executions[0].steps.find((step) => step.key === 'exit_target')
|
||||
expect(targetStep?.detail).toBe('文件传输助手')
|
||||
})
|
||||
|
||||
it('规则关闭时不执行、也不创建 execution', async () => {
|
||||
const harness = createHarness({ rule: { enabled: false } })
|
||||
|
||||
await harness.service.handleGroupExit(exitEvent())
|
||||
|
||||
expect(harness.requests).toHaveLength(0)
|
||||
expect(harness.executions).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('目标待重选(迁移遗留)时不执行、也不创建 execution', async () => {
|
||||
const harness = createHarness({
|
||||
config: { targetNeedsReview: true, target: { type: 'file_transfer' } }
|
||||
})
|
||||
|
||||
await harness.service.handleGroupExit(exitEvent())
|
||||
|
||||
expect(harness.requests).toHaveLength(0)
|
||||
expect(harness.executions).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('缺少 eventId 时直接跳过,绝不发送', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await harness.service.handleGroupExit(exitEvent({ eventId: '' }))
|
||||
|
||||
expect(harness.requests).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('当前群聊 → 发送目标是事件所在群', async () => {
|
||||
const harness = createHarness({ config: { target: { type: 'source_chat' } } })
|
||||
|
||||
await harness.service.handleGroupExit(
|
||||
exitEvent({ conversationId: 'A@chatroom', groupName: 'A 群' })
|
||||
)
|
||||
|
||||
expect(harness.requests[0].recipient).toEqual({
|
||||
type: 'group',
|
||||
id: 'A@chatroom',
|
||||
name: 'A 群'
|
||||
})
|
||||
})
|
||||
|
||||
it('发给自己 → 发送目标是当前账号 wxid', async () => {
|
||||
mocks.chat.getSelfAccountInfo.mockReturnValue({ wxid: 'wxid_realme' })
|
||||
const harness = createHarness({ config: { target: { type: 'self' } } })
|
||||
|
||||
await harness.service.handleGroupExit(exitEvent())
|
||||
|
||||
expect(harness.requests[0].recipient).toEqual({
|
||||
type: 'contact',
|
||||
id: 'wxid_realme',
|
||||
name: '我'
|
||||
})
|
||||
})
|
||||
|
||||
it('文件传输助手 → 稳定 identity,而不是昵称', async () => {
|
||||
const harness = createHarness({ config: { target: { type: 'file_transfer' } } })
|
||||
|
||||
await harness.service.handleGroupExit(exitEvent())
|
||||
|
||||
expect(harness.requests[0].recipient.id).toBe('filehelper')
|
||||
expect(harness.requests[0].recipient.id).not.toBe('文件传输助手')
|
||||
})
|
||||
|
||||
it('指定好友 → 发送目标是保存的稳定 id', async () => {
|
||||
const harness = createHarness({
|
||||
config: { target: { type: 'contact', contactId: 'wxid_friend' } }
|
||||
})
|
||||
|
||||
await harness.service.handleGroupExit(exitEvent())
|
||||
|
||||
expect(harness.requests[0].recipient).toEqual({
|
||||
type: 'contact',
|
||||
id: 'wxid_friend',
|
||||
name: '好友备注'
|
||||
})
|
||||
})
|
||||
|
||||
/*
|
||||
* §「旧联系人失效」:不偷偷发给别人,execution 记 failed,Gateway 一次都不调。
|
||||
*/
|
||||
it('指定好友已不存在 → execution failed 且不调用 Gateway', async () => {
|
||||
const harness = createHarness({
|
||||
config: { target: { type: 'contact', contactId: 'wxid_gone' } }
|
||||
})
|
||||
|
||||
await harness.service.handleGroupExit(exitEvent())
|
||||
|
||||
expect(harness.requests).toHaveLength(0)
|
||||
expect(harness.executions).toHaveLength(1)
|
||||
expect(harness.executions[0].status).toBe('failed')
|
||||
expect(harness.executions[0].errorSummary).toContain('已不存在或当前无法发送')
|
||||
const sendStep = harness.executions[0].steps.find((step) => step.key === 'exit_send')
|
||||
expect(sendStep?.status).toBe('skipped')
|
||||
})
|
||||
|
||||
it('发送能力缺失 → execution failed(退群事实不受影响)', async () => {
|
||||
const harness = createHarness()
|
||||
harness.setCapability({ ...readyCapability, capabilities: { text: false, image: false, voice: false } })
|
||||
|
||||
await harness.service.handleGroupExit(exitEvent())
|
||||
|
||||
expect(harness.requests).toHaveLength(0)
|
||||
expect(harness.executions[0].status).toBe('failed')
|
||||
expect(harness.executions[0].errorSummary).toContain('无法发送文字')
|
||||
})
|
||||
|
||||
it('Gateway 抛错 → execution failed,且 handleGroupExit 自己绝不抛', async () => {
|
||||
const harness = createHarness({
|
||||
actionResult: () => {
|
||||
throw new Error('connector timeout')
|
||||
}
|
||||
})
|
||||
|
||||
await expect(harness.service.handleGroupExit(exitEvent())).resolves.toBeUndefined()
|
||||
expect(harness.executions).toHaveLength(1)
|
||||
expect(harness.executions[0].status).toBe('failed')
|
||||
})
|
||||
|
||||
it('Gateway 返回失败状态 → execution failed', async () => {
|
||||
const harness = createHarness({
|
||||
actionResult: () => ({
|
||||
actionId: 'action-x',
|
||||
status: 'failed',
|
||||
decision: 'allow',
|
||||
errorCode: 'SEND_FAILED',
|
||||
reason: '微信发送失败',
|
||||
startedAt: new Date(NOW).toISOString(),
|
||||
finishedAt: new Date(NOW).toISOString()
|
||||
})
|
||||
})
|
||||
|
||||
await harness.service.handleGroupExit(exitEvent())
|
||||
|
||||
expect(harness.executions[0].status).toBe('failed')
|
||||
expect(harness.executions[0].errorSummary).toBe('微信发送失败')
|
||||
})
|
||||
|
||||
it('模板里所有变量都被替换(预览与实发共用同一套规则)', async () => {
|
||||
const harness = createHarness({
|
||||
config: {
|
||||
target: { type: 'file_transfer' },
|
||||
template: '{groupName}|{user}|{groupRemark}|{wxid}|{previousCount}->{currentCount}|{time}'
|
||||
}
|
||||
})
|
||||
|
||||
await harness.service.handleGroupExit(
|
||||
exitEvent({ groupName: '产品测试群', groupRemark: '群内备注', wechatNickname: '微信昵称' })
|
||||
)
|
||||
|
||||
const content = harness.requests[0].content
|
||||
expect(content.type).toBe('text')
|
||||
if (content.type === 'text') {
|
||||
// 群名({groupName})与成员群昵称({groupRemark})是两个值,不能混。
|
||||
expect(content.text).toBe(
|
||||
'产品测试群|微信昵称|群内备注|wxid_member|243->242|2023-11-15 06:13:20'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('同一个 eventId 投递两次 → 只发一次、只产生一条 execution', async () => {
|
||||
const harness = createHarness()
|
||||
const event = exitEvent()
|
||||
|
||||
await harness.service.handleGroupExit(event)
|
||||
await harness.service.handleGroupExit(event)
|
||||
|
||||
expect(harness.requests).toHaveLength(1)
|
||||
expect(harness.executions).toHaveLength(1)
|
||||
})
|
||||
|
||||
/*
|
||||
* §「不同事件」:同群两个成员先后退出就是两条通知。
|
||||
* 退群通知**不复用**消息型规则的 cooldown,否则第二次会被时间窗吞掉。
|
||||
*/
|
||||
it('不同 eventId 各自执行一次,不被合并', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
await harness.service.handleGroupExit(exitEvent({ eventId: 'event-a' }))
|
||||
await harness.service.handleGroupExit(exitEvent({ eventId: 'event-b' }))
|
||||
|
||||
expect(harness.requests).toHaveLength(2)
|
||||
expect(harness.executions).toHaveLength(2)
|
||||
expect(new Set(harness.executions.map((item) => item.executionId)).size).toBe(2)
|
||||
})
|
||||
|
||||
it('幂等键与 executionId 都由 eventId 派生(跨重启也稳定)', () => {
|
||||
const eventId = 'room@chatroom:wxid_member:1700000000000:1'
|
||||
|
||||
expect(leaveNotificationIdempotencyKey(eventId)).toBe(
|
||||
`automation_leave_notification:${eventId}`
|
||||
)
|
||||
// 同一个 (ruleId, eventId) 永远算出同一个 executionId —— 重启后重复投递会覆盖同一条记录。
|
||||
expect(leaveNotificationExecutionId('rule-1', eventId)).toBe(
|
||||
leaveNotificationExecutionId('rule-1', eventId)
|
||||
)
|
||||
expect(leaveNotificationExecutionId('rule-1', eventId)).not.toBe(
|
||||
leaveNotificationExecutionId('rule-1', 'another-event')
|
||||
)
|
||||
})
|
||||
|
||||
it('规则不存在或不是退群通知类型时静默跳过', async () => {
|
||||
const harness = createHarness()
|
||||
harness.setRule(undefined)
|
||||
|
||||
await harness.service.handleGroupExit(exitEvent())
|
||||
|
||||
expect(harness.requests).toHaveLength(0)
|
||||
expect(harness.executions).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -44,7 +44,7 @@ vi.mock('../../src/main/services/personal-wechat-send-service', () => ({
|
||||
}))
|
||||
|
||||
import { GroupExitMonitorService } from '../../src/main/services/group-exit-monitor-service'
|
||||
import { GROUP_EXIT_NOTIFICATION_TEMPLATE } from '../../src/shared/group-exit-monitor'
|
||||
import type { GroupMemberExitedEvent } from '../../src/shared/group-exit-event'
|
||||
|
||||
const member = { wxid: 'wxid_member', wechatNickname: '微信名', groupNickname: '群内名' }
|
||||
const otherMember = { wxid: 'wxid_other', wechatNickname: '另一个人' }
|
||||
@@ -93,14 +93,12 @@ function writeBaseline(
|
||||
roomId: string
|
||||
groupName: string
|
||||
members: Array<{ wxid: string; [key: string]: string }>
|
||||
}>,
|
||||
notificationRoomIds: string[] = []
|
||||
}>
|
||||
): void {
|
||||
writeJsonSync(join(mocks.userData, 'group-exit-monitor.json'), {
|
||||
accountRoot: 'fixture-account',
|
||||
events: [],
|
||||
monitoredRoomIds: snapshots.map((snapshot) => snapshot.roomId),
|
||||
notificationRoomIds,
|
||||
snapshots: snapshots.map((snapshot) => ({
|
||||
contactId: `${snapshot.roomId}-md5`,
|
||||
roomId: snapshot.roomId,
|
||||
@@ -135,36 +133,16 @@ describe('GroupExitMonitorService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('persists a validated custom template in the monitor state file', () => {
|
||||
const service = new GroupExitMonitorService()
|
||||
const template = '[退群监测]\n用户: {user}'
|
||||
|
||||
const state = service.setNotificationTemplate(template)
|
||||
expect(state.notificationTemplate).toBe(template)
|
||||
expect(readJsonSync(join(mocks.userData, 'group-exit-monitor.json')).notificationTemplate).toBe(
|
||||
template
|
||||
)
|
||||
expect(() => service.setNotificationTemplate('用户: {unknown}')).toThrow('不支持的占位符')
|
||||
})
|
||||
|
||||
it('migrates the previous default notification template', () => {
|
||||
writeJsonSync(join(mocks.userData, 'group-exit-monitor.json'), {
|
||||
notificationTemplate:
|
||||
'[退群监测]\n\n用户: {user}\n\n群备注: {groupRemark}\n\n微信号: {wxid}\n\n退群时间: {time}'
|
||||
})
|
||||
|
||||
const service = new GroupExitMonitorService()
|
||||
|
||||
expect(service.getState().notificationTemplate).toBe(GROUP_EXIT_NOTIFICATION_TEMPLATE)
|
||||
})
|
||||
/*
|
||||
* 模板相关的用例(保存 / 归一化 / 渲染)**已随迁移移出本文件**:
|
||||
* 模板现在属于自动化规则,由 `leave-notification-*` 那组测试覆盖。
|
||||
* 退群监控不再持有模板,所以这里没有任何模板可测。
|
||||
*/
|
||||
|
||||
it('persists OFF without checking contact changes or deleting configuration', async () => {
|
||||
vi.useFakeTimers()
|
||||
installGroupDb()
|
||||
writeBaseline(
|
||||
[{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }],
|
||||
['room@chatroom']
|
||||
)
|
||||
writeBaseline([{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }])
|
||||
const service = new GroupExitMonitorService()
|
||||
await service.setEnabled(false)
|
||||
await service.start(true)
|
||||
@@ -176,8 +154,7 @@ describe('GroupExitMonitorService', () => {
|
||||
const stored = readJsonSync(join(mocks.userData, 'group-exit-monitor.json'))
|
||||
expect(stored).toMatchObject({
|
||||
enabled: false,
|
||||
monitoredRoomIds: ['room@chatroom'],
|
||||
notificationRoomIds: ['room@chatroom']
|
||||
monitoredRoomIds: ['room@chatroom']
|
||||
})
|
||||
expect(stored.snapshots[0].members).toEqual([member, otherMember])
|
||||
vi.useRealTimers()
|
||||
@@ -186,8 +163,7 @@ describe('GroupExitMonitorService', () => {
|
||||
it('rebuilds the current baseline when re-enabled without reporting paused exits', async () => {
|
||||
installGroupDb()
|
||||
writeBaseline(
|
||||
[{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }],
|
||||
['room@chatroom']
|
||||
[{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }]
|
||||
)
|
||||
const service = new GroupExitMonitorService()
|
||||
await service.setEnabled(false)
|
||||
@@ -344,33 +320,26 @@ describe('GroupExitMonitorService', () => {
|
||||
).toMatchObject({ roomId: 'A@chatroom', groupName: '测试群A' })
|
||||
})
|
||||
|
||||
it('uses one resolved group name for both 3-to-1 events and Gateway notification recipients', async () => {
|
||||
it('uses one resolved group name for both 3-to-1 events and the emitted leave events', async () => {
|
||||
installGroupDb()
|
||||
const departed = { wxid: 'wxid_departed', groupNickname: '离群一' }
|
||||
const secondDeparted = { wxid: 'wxid_second_departed', groupNickname: '离群二' }
|
||||
writeBaseline(
|
||||
[
|
||||
{
|
||||
roomId: 'A@chatroom',
|
||||
groupName: 'A@chatroom',
|
||||
members: [member, departed, secondDeparted]
|
||||
}
|
||||
],
|
||||
['A@chatroom']
|
||||
)
|
||||
writeBaseline([
|
||||
{
|
||||
roomId: 'A@chatroom',
|
||||
groupName: 'A@chatroom',
|
||||
members: [member, departed, secondDeparted]
|
||||
}
|
||||
])
|
||||
mocks.chat.getGroupNamesAsync.mockResolvedValue({ 'A@chatroom': '测试群A' })
|
||||
mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([
|
||||
{ roomId: 'A@chatroom', status: 'ok', memberIds: [member.wxid] }
|
||||
])
|
||||
const requests: Array<{ recipient?: { name?: string } }> = []
|
||||
const actionGateway = {
|
||||
execute: vi.fn(async (request: { recipient?: { name?: string } }) => {
|
||||
requests.push(request)
|
||||
return { status: 'sent', actionId: 'action-1', decision: 'allow' }
|
||||
}),
|
||||
registerMemberEvent: vi.fn()
|
||||
}
|
||||
const service = new GroupExitMonitorService({ actionGateway })
|
||||
const emitted: GroupMemberExitedEvent[] = []
|
||||
const service = new GroupExitMonitorService()
|
||||
service.setGroupExitHandler((event) => {
|
||||
emitted.push(event)
|
||||
})
|
||||
|
||||
await service.start(true)
|
||||
|
||||
@@ -393,8 +362,10 @@ describe('GroupExitMonitorService', () => {
|
||||
})
|
||||
])
|
||||
)
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests.every((request) => request.recipient?.name === '测试群A')).toBe(true)
|
||||
// 事件里的群名与存储里的一致 —— 通知目标要显示的就是这个名字。
|
||||
expect(emitted).toHaveLength(2)
|
||||
expect(emitted.every((event) => event.groupName === '测试群A')).toBe(true)
|
||||
expect(emitted.every((event) => event.conversationId === 'A@chatroom')).toBe(true)
|
||||
expect(mocks.chat.getGroupSnapshotAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -559,15 +530,18 @@ describe('GroupExitMonitorService', () => {
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('keeps snapshots when only notification settings change', async () => {
|
||||
it('keeps snapshots when the monitor scope is re-saved', async () => {
|
||||
writeBaseline([{ roomId: 'room@chatroom', groupName: '测试群', members: [member] }])
|
||||
const service = new GroupExitMonitorService()
|
||||
service.getState()
|
||||
|
||||
await service.setMonitoredRoomIds(['room@chatroom'], ['room@chatroom'])
|
||||
await service.setMonitoredRoomIds(['room@chatroom'])
|
||||
|
||||
const stored = readJsonSync(join(mocks.userData, 'group-exit-monitor.json'))
|
||||
expect(stored.notificationRoomIds).toEqual(['room@chatroom'])
|
||||
expect(stored.monitoredRoomIds).toEqual(['room@chatroom'])
|
||||
// 通知配置已经不在这个文件里了 —— 迁到自动化规则后不允许双写。
|
||||
expect(stored.notificationRoomIds).toBeUndefined()
|
||||
expect(stored.notificationTemplate).toBeUndefined()
|
||||
expect(stored.snapshots).toHaveLength(1)
|
||||
expect(stored.snapshots[0].members).toEqual([member])
|
||||
})
|
||||
@@ -756,33 +730,50 @@ describe('GroupExitMonitorService', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('renders the saved template before sending a group notification', async () => {
|
||||
/**
|
||||
* 迁移后的分工:**退群监控只产生事件,发送完全归自动化**。
|
||||
*
|
||||
* 它不再读发送能力、不再调 `WechatActionGateway`、也不在事件上记发送状态。
|
||||
*/
|
||||
it('hands the exit event to automation and never touches the send path itself', async () => {
|
||||
installGroupDb()
|
||||
writeBaseline(
|
||||
[{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }],
|
||||
['room@chatroom']
|
||||
)
|
||||
mocks.capability.getPersonalWechatSendCapability.mockResolvedValue({
|
||||
ready: true,
|
||||
capabilities: { text: true }
|
||||
})
|
||||
mocks.sender.send.mockResolvedValue({ success: true })
|
||||
writeBaseline([
|
||||
{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }
|
||||
])
|
||||
mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([
|
||||
{ roomId: 'room@chatroom', status: 'ok', memberIds: [member.wxid] }
|
||||
])
|
||||
const emitted: GroupMemberExitedEvent[] = []
|
||||
const service = new GroupExitMonitorService()
|
||||
service.setNotificationTemplate('退群: {user}/{groupRemark}/{wxid}')
|
||||
await service.start(true)
|
||||
|
||||
expect(mocks.sender.send).toHaveBeenCalledWith({
|
||||
type: 'text',
|
||||
to: 'room@chatroom',
|
||||
isGroup: true,
|
||||
text: '退群: 另一个人/未设置/wxid_other'
|
||||
service.setGroupExitHandler((event) => {
|
||||
emitted.push(event)
|
||||
})
|
||||
await service.start(true)
|
||||
const state = service.getState()
|
||||
|
||||
expect(state.events[0]).toMatchObject({ memberWxid: 'wxid_other' })
|
||||
// 事件上不再有发送状态字段(发送结果归自动化执行日志)。
|
||||
expect(state.events[0]).not.toHaveProperty('notificationStatus')
|
||||
expect(state.events[0]).not.toHaveProperty('notification')
|
||||
expect(emitted).toHaveLength(1)
|
||||
expect(emitted[0]).toMatchObject({
|
||||
eventId: state.events[0].id,
|
||||
conversationId: 'room@chatroom',
|
||||
memberId: 'wxid_other',
|
||||
previousCount: 2,
|
||||
currentCount: 1
|
||||
})
|
||||
// 监控自己不发送、也不去问发送能力。
|
||||
expect(mocks.sender.send).not.toHaveBeenCalled()
|
||||
expect(mocks.capability.getPersonalWechatSendCapability).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records an exit without creating a send action when notification is disabled', async () => {
|
||||
/*
|
||||
* 处理器抛错**不能**污染监控循环:退群事实已经落盘,
|
||||
* 通知失败是自动化那边的事(§「退群事实已检测成功 ≠ 通知发送成功」)。
|
||||
* 未捕获的 rejection 也会让整个 diff 循环挂掉。
|
||||
*/
|
||||
it('keeps recording exits when the automation handler rejects', async () => {
|
||||
installGroupDb()
|
||||
writeBaseline([
|
||||
{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }
|
||||
@@ -791,72 +782,39 @@ describe('GroupExitMonitorService', () => {
|
||||
{ roomId: 'room@chatroom', status: 'ok', memberIds: [member.wxid] }
|
||||
])
|
||||
const service = new GroupExitMonitorService()
|
||||
await service.start(true)
|
||||
const state = service.getState()
|
||||
|
||||
expect(state.events[0]).toMatchObject({
|
||||
memberWxid: 'wxid_other',
|
||||
notificationStatus: 'not_requested'
|
||||
service.setGroupExitHandler(async () => {
|
||||
throw new Error('通知发送失败')
|
||||
})
|
||||
expect(mocks.sender.send).not.toHaveBeenCalled()
|
||||
expect(mocks.capability.getPersonalWechatSendCapability).not.toHaveBeenCalled()
|
||||
|
||||
await service.start(true)
|
||||
// 让 handler 的 rejection 落地。
|
||||
await Promise.resolve()
|
||||
|
||||
const state = service.getState()
|
||||
expect(state.events).toHaveLength(1)
|
||||
expect(state.events[0]).toMatchObject({ memberWxid: 'wxid_other' })
|
||||
expect(state.lastCheckedAt).toBeDefined()
|
||||
})
|
||||
|
||||
it('微信发送能力不可用时仍保留退群事件', async () => {
|
||||
it('does not re-emit the same snapshot diff when checked again', async () => {
|
||||
installGroupDb()
|
||||
writeBaseline(
|
||||
[{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }],
|
||||
['room@chatroom']
|
||||
)
|
||||
mocks.capability.getPersonalWechatSendCapability.mockResolvedValue({
|
||||
ready: false,
|
||||
supported: true,
|
||||
status: 'needs_binding',
|
||||
capabilities: { text: false, image: false, voice: false },
|
||||
senderStatus: {},
|
||||
message: '当前微信发送能力不可用'
|
||||
})
|
||||
writeBaseline([
|
||||
{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }
|
||||
])
|
||||
mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([
|
||||
{ roomId: 'room@chatroom', status: 'ok', memberIds: [member.wxid] }
|
||||
])
|
||||
const emitted: GroupMemberExitedEvent[] = []
|
||||
const service = new GroupExitMonitorService()
|
||||
await service.start(true)
|
||||
const state = service.getState()
|
||||
|
||||
expect(state.events[0]).toMatchObject({
|
||||
notificationStatus: 'failed',
|
||||
notification: {
|
||||
status: 'failed',
|
||||
errorCode: 'SEND_CAPABILITY_UNAVAILABLE'
|
||||
}
|
||||
service.setGroupExitHandler((event) => {
|
||||
emitted.push(event)
|
||||
})
|
||||
expect(mocks.sender.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not send twice when the same snapshot diff is checked again', async () => {
|
||||
installGroupDb()
|
||||
writeBaseline(
|
||||
[{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }],
|
||||
['room@chatroom']
|
||||
)
|
||||
mocks.capability.getPersonalWechatSendCapability.mockResolvedValue({
|
||||
ready: true,
|
||||
capabilities: { text: true, image: false, voice: false },
|
||||
supported: true,
|
||||
status: 'ready',
|
||||
senderStatus: {},
|
||||
message: 'ready'
|
||||
})
|
||||
mocks.sender.send.mockResolvedValue({ success: true })
|
||||
mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([
|
||||
{ roomId: 'room@chatroom', status: 'ok', memberIds: [member.wxid] }
|
||||
])
|
||||
const service = new GroupExitMonitorService()
|
||||
await service.start(true)
|
||||
await service.checkNow()
|
||||
await service.checkNow()
|
||||
|
||||
expect(mocks.sender.send).toHaveBeenCalledOnce()
|
||||
// 快照已更新 ⇒ 后续检查没有新的 diff,也就不会重复投递事件。
|
||||
expect(emitted).toHaveLength(1)
|
||||
expect(service.getState().events).toHaveLength(1)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
GROUP_EXIT_NOTIFICATION_TEMPLATE,
|
||||
findRemovedGroupMembers,
|
||||
groupExitMemberName,
|
||||
insertGroupNamePlaceholder,
|
||||
normalizeGroupExitNotificationTemplate,
|
||||
renderGroupExitMonitorNotification,
|
||||
validateGroupExitNotificationTemplate
|
||||
@@ -62,6 +64,8 @@ describe('group exit monitor member diff', () => {
|
||||
})
|
||||
|
||||
expect(message).toContain('[退群监测]')
|
||||
// 群名是**独立变量**,且默认模板第一行就是它 —— 通知发给别人时才认得出是哪个群。
|
||||
expect(message).toContain('群聊: 测试群')
|
||||
expect(message).toContain('用户: 微信名')
|
||||
expect(message).toContain('群备注: 未设置')
|
||||
expect(message).toContain('微信号: wxid_fixture')
|
||||
@@ -69,11 +73,69 @@ describe('group exit monitor member diff', () => {
|
||||
expect(message).toContain('退群时间: 2026-08-31 00:19:07')
|
||||
})
|
||||
|
||||
/*
|
||||
* 回归:`{groupName}`(群名)与 `{groupRemark}`(成员在本群的昵称)是两个不同的东西。
|
||||
*
|
||||
* 起因是预览样本曾把群名填进 `groupRemark`,导致「群备注」看起来就是群名,
|
||||
* 于是没人发现模板里**根本没有群名变量** —— 通知发给好友时收件人不知道是哪个群。
|
||||
* 这条用例把两者钉死成不同的值。
|
||||
*/
|
||||
it('keeps the group name and the member group nickname as two separate variables', () => {
|
||||
const message = renderGroupExitMonitorNotification(
|
||||
{
|
||||
id: 'event-3',
|
||||
contactId: 'group-md5',
|
||||
roomId: '测试群@chatroom',
|
||||
groupName: '产品测试群',
|
||||
memberWxid: 'wxid_fixture',
|
||||
memberName: '群昵称',
|
||||
wechatName: '微信名',
|
||||
groupRemark: '小张',
|
||||
previousCount: 3,
|
||||
currentCount: 2,
|
||||
delta: -1,
|
||||
message: '小张退出了产品测试群',
|
||||
detectedAt: new Date(2026, 7, 31, 0, 19, 7).getTime()
|
||||
},
|
||||
'{groupName} / {groupRemark}'
|
||||
)
|
||||
|
||||
expect(message).toBe('产品测试群 / 小张')
|
||||
expect(message).not.toContain('{')
|
||||
})
|
||||
|
||||
it('falls back to 未读取到 when the group name is unavailable', () => {
|
||||
const message = renderGroupExitMonitorNotification(
|
||||
{
|
||||
id: 'event-4',
|
||||
contactId: 'group-md5',
|
||||
roomId: '测试群@chatroom',
|
||||
groupName: '',
|
||||
memberWxid: 'wxid_fixture',
|
||||
memberName: '群昵称',
|
||||
groupRemark: '',
|
||||
previousCount: 2,
|
||||
currentCount: 1,
|
||||
delta: -1,
|
||||
message: '群昵称退出了测试群',
|
||||
detectedAt: new Date(2026, 7, 31, 0, 19, 7).getTime()
|
||||
},
|
||||
'群聊: {groupName}'
|
||||
)
|
||||
|
||||
expect(message).toBe('群聊: 未读取到')
|
||||
})
|
||||
|
||||
it('validates custom notification templates and rejects unknown placeholders', () => {
|
||||
expect(validateGroupExitNotificationTemplate(' 用户: {user} ')).toEqual({
|
||||
valid: true,
|
||||
template: '用户: {user}'
|
||||
})
|
||||
// 新增的群名变量必须被校验通过,否则用户存不下来。
|
||||
expect(validateGroupExitNotificationTemplate('群聊: {groupName}')).toEqual({
|
||||
valid: true,
|
||||
template: '群聊: {groupName}'
|
||||
})
|
||||
expect(validateGroupExitNotificationTemplate('用户: {unknown}')).toEqual({
|
||||
valid: false,
|
||||
error: '不支持的占位符: unknown'
|
||||
@@ -98,8 +160,77 @@ describe('group exit monitor member diff', () => {
|
||||
message: '群昵称退出了测试群',
|
||||
detectedAt: new Date(2026, 7, 31, 0, 19, 7).getTime()
|
||||
},
|
||||
'{user}|{groupRemark}|{wxid}|{previousCount}|{currentCount}|{time}'
|
||||
'{groupName}|{user}|{groupRemark}|{wxid}|{previousCount}|{currentCount}|{time}'
|
||||
)
|
||||
expect(message).toBe('微信名|群内昵称|wxid_fixture|2|1|2026-08-31 00:19:07')
|
||||
expect(message).toBe('测试群|微信名|群内昵称|wxid_fixture|2|1|2026-08-31 00:19:07')
|
||||
})
|
||||
})
|
||||
|
||||
describe('把群聊名补进已有模板', () => {
|
||||
/** 加上群名变量之前的默认模板(有「人数」行、没有「群聊」行)。 */
|
||||
const previousDefault = [
|
||||
'[退群监测]',
|
||||
'',
|
||||
'用户: {user}',
|
||||
'',
|
||||
'群备注: {groupRemark}',
|
||||
'',
|
||||
'微信号: {wxid}',
|
||||
'',
|
||||
'人数: {previousCount} -> {currentCount}',
|
||||
'',
|
||||
'退群时间: {time}'
|
||||
].join('\n')
|
||||
|
||||
it('插在标题行之后,其余内容一字不动', () => {
|
||||
const result = insertGroupNamePlaceholder(previousDefault)
|
||||
|
||||
expect(result).toBe(
|
||||
[
|
||||
'[退群监测]',
|
||||
'',
|
||||
'群聊: {groupName}',
|
||||
'',
|
||||
'用户: {user}',
|
||||
'',
|
||||
'群备注: {groupRemark}',
|
||||
'',
|
||||
'微信号: {wxid}',
|
||||
'',
|
||||
'人数: {previousCount} -> {currentCount}',
|
||||
'',
|
||||
'退群时间: {time}'
|
||||
].join('\n')
|
||||
)
|
||||
// 用户原有内容全部保留。
|
||||
for (const line of previousDefault.split('\n')) {
|
||||
if (line.trim()) expect(result).toContain(line)
|
||||
}
|
||||
})
|
||||
|
||||
it('补完就是当前的默认模板形状', () => {
|
||||
expect(insertGroupNamePlaceholder(previousDefault)).toBe(GROUP_EXIT_NOTIFICATION_TEMPLATE)
|
||||
})
|
||||
|
||||
it('已经含 {groupName} 时原样返回(幂等)', () => {
|
||||
const template = '[退群监测]\n\n群聊: {groupName}\n\n用户: {user}'
|
||||
expect(insertGroupNamePlaceholder(template)).toBe(template)
|
||||
// 连续补两次结果不变。
|
||||
expect(insertGroupNamePlaceholder(insertGroupNamePlaceholder(previousDefault))).toBe(
|
||||
insertGroupNamePlaceholder(previousDefault)
|
||||
)
|
||||
})
|
||||
|
||||
it('没有标题行时插到最前面', () => {
|
||||
expect(insertGroupNamePlaceholder('用户: {user}')).toBe('群聊: {groupName}\n\n用户: {user}')
|
||||
})
|
||||
|
||||
it('空模板原样返回,不硬造内容', () => {
|
||||
expect(insertGroupNamePlaceholder('')).toBe('')
|
||||
expect(insertGroupNamePlaceholder(' ')).toBe(' ')
|
||||
})
|
||||
|
||||
it('不会留下连续空行', () => {
|
||||
expect(insertGroupNamePlaceholder('[退群监测]\n\n\n\n用户: {user}')).not.toMatch(/\n{3,}/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
filterSendableFriendContacts,
|
||||
isSendableFriendContact,
|
||||
leaveNotificationContactDisplayName,
|
||||
resolveLeaveNotificationTarget,
|
||||
type LeaveNotificationCandidateContact
|
||||
} from '../../src/main/services/leave-notification-target'
|
||||
import type { GroupMemberExitedEvent } from '../../src/shared/group-exit-event'
|
||||
import { WECHAT_FILE_HELPER_USERNAME } from '../../src/shared/wechat-identities'
|
||||
import type { LeaveNotificationConfig } from '../../src/shared/automation'
|
||||
|
||||
/**
|
||||
* 退群通知的**目标解析**。
|
||||
*
|
||||
* 这是整轮迁移里最不能出错的一处:解析错就等于发错人,而发出去的消息撤不回来。
|
||||
* 所以这里把四种目标、失效、不可发送、自身身份缺失全部穷举。
|
||||
*/
|
||||
|
||||
const EVENT: GroupMemberExitedEvent = {
|
||||
eventId: 'room@chatroom:wxid_member:1700000000000:1',
|
||||
conversationId: 'room@chatroom',
|
||||
groupName: '产品测试群',
|
||||
groupRemark: '',
|
||||
memberId: 'wxid_member',
|
||||
memberName: '张三',
|
||||
previousCount: 243,
|
||||
currentCount: 242,
|
||||
occurredAt: 1_700_000_000_000
|
||||
}
|
||||
|
||||
const FRIEND: LeaveNotificationCandidateContact = {
|
||||
m_nsUsrName: 'wxid_friend',
|
||||
m_nsNickName: '好友昵称',
|
||||
remark: '好友备注',
|
||||
type: 'user'
|
||||
}
|
||||
|
||||
const contacts: LeaveNotificationCandidateContact[] = [
|
||||
FRIEND,
|
||||
{ m_nsUsrName: 'room@chatroom', m_nsNickName: '测试群', type: 'group' },
|
||||
{ m_nsUsrName: 'gh_official', m_nsNickName: '某公众号', type: 'user', isOfficialAccount: true },
|
||||
{ m_nsUsrName: WECHAT_FILE_HELPER_USERNAME, m_nsNickName: '文件传输助手', type: 'user' },
|
||||
{ m_nsUsrName: 'wxid_self', m_nsNickName: '我自己', type: 'user' }
|
||||
]
|
||||
|
||||
function resolve(
|
||||
config: LeaveNotificationConfig,
|
||||
overrides: { selfWxid?: string; event?: GroupMemberExitedEvent } = {}
|
||||
) {
|
||||
return resolveLeaveNotificationTarget({
|
||||
config,
|
||||
event: overrides.event ?? EVENT,
|
||||
contacts,
|
||||
selfWxid: overrides.selfWxid ?? 'wxid_self'
|
||||
})
|
||||
}
|
||||
|
||||
describe('leaveNotificationTarget · 四种目标', () => {
|
||||
it('当前群聊 = 事件所在群,不是"最后活跃会话"', () => {
|
||||
const result = resolve({ target: { type: 'source_chat' }, template: '' })
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
target: {
|
||||
recipient: { type: 'group', id: 'room@chatroom', name: '产品测试群' },
|
||||
displayName: '产品测试群'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('当前群聊在群名缺失时用中性称呼,不回落成 roomId', () => {
|
||||
const result = resolveLeaveNotificationTarget({
|
||||
config: { target: { type: 'source_chat' }, template: '' },
|
||||
event: { ...EVENT, groupName: undefined },
|
||||
contacts,
|
||||
selfWxid: 'wxid_self'
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.target.recipient.name).toBe('当前群聊')
|
||||
expect(result.target.displayName).toBe('发生退群事件的群聊')
|
||||
}
|
||||
})
|
||||
|
||||
it('当前群聊在缺少 conversationId 时直接失败', () => {
|
||||
const result = resolveLeaveNotificationTarget({
|
||||
config: { target: { type: 'source_chat' }, template: '' },
|
||||
event: { ...EVENT, conversationId: '' },
|
||||
contacts,
|
||||
selfWxid: 'wxid_self'
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('发给自己 = 当前登录账号的真实 wxid', () => {
|
||||
const result = resolve({ target: { type: 'self' }, template: '' }, { selfWxid: 'wxid_me' })
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
target: {
|
||||
recipient: { type: 'contact', id: 'wxid_me', name: '我' },
|
||||
displayName: '我'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/*
|
||||
* 拿不到自身身份时必须**报 blocker**,绝不偷偷 fallback 到文件传输助手 ——
|
||||
* 那会让用户以为"发给自己"生效了,实际消息去了别处。
|
||||
*/
|
||||
it('拿不到自身身份时失败,且不 fallback 文件传输助手', () => {
|
||||
const result = resolve({ target: { type: 'self' }, template: '' }, { selfWxid: '' })
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.error).toContain('当前登录的微信账号')
|
||||
expect(result.error).not.toContain('文件传输助手')
|
||||
}
|
||||
})
|
||||
|
||||
it('文件传输助手用协议级固定身份,不靠昵称搜索', () => {
|
||||
const result = resolve({ target: { type: 'file_transfer' }, template: '' })
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.target.recipient).toEqual({
|
||||
type: 'contact',
|
||||
id: WECHAT_FILE_HELPER_USERNAME,
|
||||
name: '文件传输助手'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('指定好友用稳定 id,显示名用备注', () => {
|
||||
const result = resolve({
|
||||
target: { type: 'contact', contactId: 'wxid_friend' },
|
||||
template: ''
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
target: {
|
||||
recipient: { type: 'contact', id: 'wxid_friend', name: '好友备注' },
|
||||
displayName: '好友备注'
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('leaveNotificationTarget · 失效与非法', () => {
|
||||
it('联系人不在了 → 失败,且不往别处发', () => {
|
||||
const result = resolve({ target: { type: 'contact', contactId: 'wxid_gone' }, template: '' })
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.error).toContain('已不存在或当前无法发送')
|
||||
})
|
||||
|
||||
it('还没选好友 → 失败并提示重新选择', () => {
|
||||
const result = resolve({ target: { type: 'contact' }, template: '' })
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.error).toContain('还没有选择通知联系人')
|
||||
})
|
||||
|
||||
it('把群 / 公众号 / 文件传输助手 / 自己填进「指定好友」也会被拒', () => {
|
||||
for (const contactId of [
|
||||
'room@chatroom',
|
||||
'gh_official',
|
||||
WECHAT_FILE_HELPER_USERNAME,
|
||||
'wxid_self'
|
||||
]) {
|
||||
const result = resolve({ target: { type: 'contact', contactId }, template: '' })
|
||||
expect(result.ok).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('未配置配置项 → 失败', () => {
|
||||
expect(resolveLeaveNotificationTarget({
|
||||
config: undefined,
|
||||
event: EVENT,
|
||||
contacts,
|
||||
selfWxid: 'wxid_self'
|
||||
}).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('未知目标类型 → 失败(不允许静默当成默认值)', () => {
|
||||
const result = resolve({
|
||||
target: { type: 'webhook' as never },
|
||||
template: ''
|
||||
})
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('leaveNotificationTarget · 可选好友过滤', () => {
|
||||
it('排除群 / 公众号 / 文件传输助手 / 自己', () => {
|
||||
const sendable = filterSendableFriendContacts(contacts, 'wxid_self').map(
|
||||
(contact) => contact.m_nsUsrName
|
||||
)
|
||||
|
||||
expect(sendable).toEqual(['wxid_friend'])
|
||||
})
|
||||
|
||||
it('UI 选择器与运行时判定共用同一个函数', () => {
|
||||
for (const contact of contacts) {
|
||||
expect(isSendableFriendContact(contact, 'wxid_self')).toBe(
|
||||
filterSendableFriendContacts([contact], 'wxid_self').length === 1
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('显示名依次回落到备注 → 微信昵称 → 会话昵称 → 中性称呼,永不出现 wxid', () => {
|
||||
expect(leaveNotificationContactDisplayName(FRIEND)).toBe('好友备注')
|
||||
expect(
|
||||
leaveNotificationContactDisplayName({ m_nsUsrName: 'wxid_x', type: 'user' })
|
||||
).toBe('指定好友')
|
||||
})
|
||||
})
|
||||
@@ -51,26 +51,34 @@ describe('WechatActionGateway', () => {
|
||||
return new WechatActionGateway({ getUserDataPath: () => userData })
|
||||
}
|
||||
|
||||
function memberAction(
|
||||
/**
|
||||
* 退群通知 —— 迁移后由 **Automation** 发出(purpose `automation_leave_notification`)。
|
||||
*
|
||||
* 幂等键由 eventId 派生,与 `AutomationService.leaveNotificationIdempotencyKey` 同口径;
|
||||
* 收件人**不再被锁死在事件所在群**(可以发给自己 / 文件传输助手 / 指定好友)。
|
||||
*/
|
||||
function leaveNotificationAction(
|
||||
gateway: WechatActionGateway,
|
||||
roomId = 'room@chatroom'
|
||||
recipient: WechatActionRequest['recipient'] = { type: 'group', id: 'room@chatroom' },
|
||||
eventId = 'event-1'
|
||||
): Promise<WechatActionResult> {
|
||||
gateway.registerMemberEvent({ id: 'event-1', roomId: 'room@chatroom' })
|
||||
return gateway.execute({
|
||||
origin: 'member_monitor',
|
||||
purpose: 'member_left_notification',
|
||||
idempotencyKey: `automation_leave_notification:${eventId}`,
|
||||
origin: 'automation',
|
||||
purpose: 'automation_leave_notification',
|
||||
triggerType: 'automation',
|
||||
sourceId: 'event-1',
|
||||
recipient: { type: 'group', id: roomId },
|
||||
executionId: `exec-${eventId}`,
|
||||
sourceId: eventId,
|
||||
recipient,
|
||||
content: { type: 'text', text: '张三已退出群聊' }
|
||||
})
|
||||
}
|
||||
|
||||
it('allows a valid member action, sends once, and writes an audit record', async () => {
|
||||
it('sends a leave notification once and writes an audit record', async () => {
|
||||
const userData = mkdtempSync(join(tmpdir(), 'tracememo-wechat-action-'))
|
||||
directories.push(userData)
|
||||
const gateway = new WechatActionGateway({ getUserDataPath: () => userData })
|
||||
const result = await memberAction(gateway)
|
||||
const result = await leaveNotificationAction(gateway)
|
||||
|
||||
expect(result).toMatchObject({ status: 'sent', decision: 'allow' })
|
||||
expect(mocks.sender.send).toHaveBeenCalledOnce()
|
||||
@@ -83,7 +91,7 @@ describe('WechatActionGateway', () => {
|
||||
const audit = readJsonSync(join(userData, 'actions', 'wechat-actions.json'))
|
||||
expect(audit).toEqual([
|
||||
expect.objectContaining({
|
||||
purpose: 'member_left_notification',
|
||||
purpose: 'automation_leave_notification',
|
||||
recipientId: 'room@chatroom',
|
||||
sendStatus: 'sent',
|
||||
decision: 'allow',
|
||||
@@ -92,23 +100,70 @@ describe('WechatActionGateway', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('deduplicates the same member event across repeated execution calls', async () => {
|
||||
it('deduplicates the same leave event across repeated execution calls', async () => {
|
||||
const gateway = createGateway()
|
||||
const first = await memberAction(gateway)
|
||||
const second = await memberAction(gateway)
|
||||
const first = await leaveNotificationAction(gateway)
|
||||
const second = await leaveNotificationAction(gateway)
|
||||
|
||||
expect(second.actionId).toBe(first.actionId)
|
||||
expect(mocks.sender.send).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('blocks a recipient outside the source group', async () => {
|
||||
/**
|
||||
* 迁移的核心:**不再有「只能发回原群」的作用域锁**。
|
||||
* 四种目标必须都能发出去,否则「自己 / 文件传输助手 / 指定好友」形同虚设。
|
||||
*/
|
||||
it('allows every leave-notification recipient, not just the source group', async () => {
|
||||
// 四条自动化发送串行且彼此间隔 3 秒 —— 用假定时器推过去,别真的等 9 秒。
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-09-24T10:00:00.000Z'))
|
||||
const gateway = createGateway()
|
||||
const result = await memberAction(gateway, 'other@chatroom')
|
||||
const targets: Array<{ to: string; isGroup: boolean }> = []
|
||||
mocks.sender.send.mockImplementation(async (request: { to: string; isGroup: boolean }) => {
|
||||
targets.push({ to: request.to, isGroup: request.isGroup })
|
||||
return { success: true, status: {} }
|
||||
})
|
||||
|
||||
const pending = [
|
||||
leaveNotificationAction(gateway, { type: 'group', id: 'room@chatroom' }, 'event-1'),
|
||||
leaveNotificationAction(gateway, { type: 'contact', id: 'wxid_self' }, 'event-2'),
|
||||
leaveNotificationAction(gateway, { type: 'contact', id: 'filehelper' }, 'event-3'),
|
||||
leaveNotificationAction(gateway, { type: 'contact', id: 'wxid_friend' }, 'event-4')
|
||||
]
|
||||
await vi.advanceTimersByTimeAsync(AUTOMATION_SEND_INTERVAL_MS * 4)
|
||||
const results = await Promise.all(pending)
|
||||
|
||||
for (const result of results) {
|
||||
expect(result).toMatchObject({ status: 'sent', decision: 'allow' })
|
||||
}
|
||||
expect(targets).toEqual([
|
||||
{ to: 'room@chatroom', isGroup: true },
|
||||
{ to: 'wxid_self', isGroup: false },
|
||||
{ to: 'filehelper', isGroup: false },
|
||||
{ to: 'wxid_friend', isGroup: false }
|
||||
])
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
/**
|
||||
* 回归闸门:旧的发送用途**已经在代码上不可能再发出去**。
|
||||
* 退群监控不再产生它,allowlist 也把它摘了 —— 谁要是把它加回来,这里会红。
|
||||
*/
|
||||
it('blocks the legacy member_left_notification purpose', async () => {
|
||||
const gateway = createGateway()
|
||||
const result = await gateway.execute({
|
||||
origin: 'member_monitor',
|
||||
purpose: 'member_left_notification',
|
||||
triggerType: 'automation',
|
||||
sourceId: 'event-1',
|
||||
recipient: { type: 'group', id: 'room@chatroom' },
|
||||
content: { type: 'text', text: '张三已退出群聊' }
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'blocked',
|
||||
decision: 'block',
|
||||
errorCode: 'RECIPIENT_SCOPE_VIOLATION'
|
||||
errorCode: 'ACTION_NOT_ALLOWED'
|
||||
})
|
||||
expect(mocks.sender.send).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -135,8 +190,8 @@ describe('WechatActionGateway', () => {
|
||||
it('returns INVALID_REQUEST for malformed input without throwing from audit handling', async () => {
|
||||
const gateway = createGateway()
|
||||
const result = await gateway.execute({
|
||||
origin: 'member_monitor',
|
||||
purpose: 'member_left_notification',
|
||||
origin: 'automation',
|
||||
purpose: 'automation_leave_notification',
|
||||
triggerType: 'automation',
|
||||
recipient: { type: 'group', id: 'room@chatroom' }
|
||||
} as never)
|
||||
@@ -151,8 +206,8 @@ describe('WechatActionGateway', () => {
|
||||
it('returns INVALID_RECIPIENT when the recipient id is missing', async () => {
|
||||
const gateway = createGateway()
|
||||
const result = await gateway.execute({
|
||||
origin: 'member_monitor',
|
||||
purpose: 'member_left_notification',
|
||||
origin: 'automation',
|
||||
purpose: 'automation_leave_notification',
|
||||
triggerType: 'automation',
|
||||
sourceId: 'event-1',
|
||||
recipient: { type: 'group', id: '' },
|
||||
@@ -167,30 +222,6 @@ describe('WechatActionGateway', () => {
|
||||
expect(mocks.sender.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not accept an event lookup for a different source id', async () => {
|
||||
const userData = mkdtempSync(join(tmpdir(), 'tracememo-wechat-action-'))
|
||||
directories.push(userData)
|
||||
const gateway = new WechatActionGateway({
|
||||
getUserDataPath: () => userData,
|
||||
getMemberEvent: () => ({ id: 'different-event', roomId: 'room@chatroom' })
|
||||
})
|
||||
const result = await gateway.execute({
|
||||
origin: 'member_monitor',
|
||||
purpose: 'member_left_notification',
|
||||
triggerType: 'automation',
|
||||
sourceId: 'event-1',
|
||||
recipient: { type: 'group', id: 'room@chatroom' },
|
||||
content: { type: 'text', text: '张三已退出群聊' }
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'blocked',
|
||||
decision: 'block',
|
||||
errorCode: 'INVALID_REQUEST'
|
||||
})
|
||||
expect(mocks.sender.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns a structured capability failure without sending', async () => {
|
||||
const gateway = createGateway()
|
||||
mocks.capability.getPersonalWechatSendCapability.mockResolvedValueOnce({
|
||||
@@ -199,7 +230,7 @@ describe('WechatActionGateway', () => {
|
||||
capabilities: { text: false, image: false, voice: false },
|
||||
message: '当前微信发送能力不可用'
|
||||
})
|
||||
const result = await memberAction(gateway)
|
||||
const result = await leaveNotificationAction(gateway)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'failed',
|
||||
@@ -226,7 +257,7 @@ describe('WechatActionGateway', () => {
|
||||
it('converts a transport throw into SEND_FAILED', async () => {
|
||||
const gateway = createGateway()
|
||||
mocks.sender.send.mockRejectedValueOnce(new Error('connector timeout'))
|
||||
const result = await memberAction(gateway)
|
||||
const result = await leaveNotificationAction(gateway)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'failed',
|
||||
@@ -287,17 +318,10 @@ describe('WechatActionGateway', () => {
|
||||
if (sentAt.length === 1) throw new Error('first failed')
|
||||
return { success: true, status: {} }
|
||||
})
|
||||
const first = memberAction(gateway)
|
||||
const first = leaveNotificationAction(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: '李四已退出群聊' }
|
||||
})
|
||||
// 第二条用不同的 eventId → 不同的幂等键,才会真的进队列。
|
||||
const second = leaveNotificationAction(gateway, { type: 'group', id: 'room@chatroom' }, 'event-2')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(AUTOMATION_SEND_INTERVAL_MS)
|
||||
const results = await Promise.all([first, second])
|
||||
@@ -407,7 +431,7 @@ describe('WechatActionGateway', () => {
|
||||
.mockResolvedValueOnce({ ...readyCapability, ready: false })
|
||||
.mockResolvedValueOnce(readyCapability)
|
||||
|
||||
await expect(memberAction(gateway)).resolves.toMatchObject({
|
||||
await expect(leaveNotificationAction(gateway)).resolves.toMatchObject({
|
||||
status: 'failed',
|
||||
errorCode: 'SEND_CAPABILITY_UNAVAILABLE'
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user