feat: 定时日报并入自动化,统一规则管理与发送链路

发送目标与退群通知统一为 来源群 / 自己 / 文件传输助手 / 指定好友
日报页补「定时日报已并入自动化」指引条
This commit is contained in:
电摇小子
2026-09-28 17:11:50 +07:00
parent c835d5c570
commit 4d5c6459fe
49 changed files with 6436 additions and 4863 deletions
+65 -31
View File
@@ -135,10 +135,6 @@ import type {
PersonalWechatSendResult,
PersonalWechatSenderStatus
} from '../shared/personal-wechat'
import type {
ScheduledReportCreateInput,
ScheduledReportUpdateInput
} from '../shared/scheduled-report'
import { isTruthyDebugFlag } from '../shared/debug-flags'
import { TextToSpeechSettingsService } from './services/text-to-speech-settings-service'
import type {
@@ -1047,6 +1043,30 @@ app.whenReady().then(async () => {
*/
let automationListening = false
initAutomationService(wcdb4Client, { isListening: () => automationListening })
/**
* 旧「定时日报任务」迁移所需的会话解析器。
*
* **必须在数据库就绪之后注入**:旧 `task.group` 可能是会话 md5、群名或 roomId
* 三种形态,只有 `resolveMd5` 能收敛成稳定会话 id。
* 注入前若渲染层已经读过一次规则(`automation:listRules`),迁移会**整体推迟**
* 而不落盘 —— 注入这一步会立刻补跑(见 `AutomationRuleStore.setLegacyConversationResolver`)。
*
* 这样设计的原因:拿不到解析器时如果把旧数据判成「无法无损映射」,
* 用户的定时日报会被**无辜停用**,而事实只是"数据库还没打开"。
*/
automationRuleStore.setLegacyConversationResolver((raw) => {
const key = String(raw || '').trim()
if (!key) return undefined
// 已经是稳定会话 id 就直接用,不查库。
if (key.endsWith('@chatroom')) return key
try {
const username = String(chat.resolveMd5(key)?.m_nsUsrName || '').trim()
return username || undefined
} catch {
return undefined
}
})
/**
* 退群通知的唯一通路:**退群监控只负责产生事件,自动化负责发送**。
*
@@ -1993,44 +2013,58 @@ app.whenReady().then(async () => {
})
ipcMain.handle('wechat-personal:getSendCapability', () => getPersonalWechatSendCapability())
ipcMain.handle('scheduled-report:list', () => scheduledReportService.listTasks())
ipcMain.handle('scheduled-report:listExecutions', (_, taskId?: string) =>
scheduledReportService.listExecutions(taskId)
/**
* 定时日报(Automation 的 `scheduled_report` 规则类型)。
*
* 规则本身的 CRUD 走上面的 `automation:*`;这里只补三块**专属**能力:
* 1. 「立即执行」—— 走 `AutomationService.executeScheduledRule(ruleId,'manual')`,
* 与 scheduler 触发**同一条链路**(生成 → 落库 → 解析目标 → Gateway 发图);
* 2. 微信异常通知 —— 定时日报失败时的 Agent Hub 推送,随功能保留;
* 3. 旧执行记录**只读存档** —— 旧记录无法无损转换,原样保留给 UI 展示。
*/
ipcMain.handle('automation:runScheduledReportRule', async (_, ruleId: string) => {
const service = tryAutomationService()
if (!service) return { success: false, error: '数据库尚未就绪,暂时无法执行定时日报' }
const outcome = await service.executeScheduledRule(String(ruleId || ''), { trigger: 'manual' })
if (outcome.executed) {
return { success: outcome.status !== 'failed', data: outcome }
}
// 「没执行」的每一种原因都要能翻译成用户看得懂的一句话 —— 否则点了按钮没反应,无从排查。
const skipMessages: Record<string, string> = {
missing_rule: '规则 id 无效',
rule_not_found: '未找到这条定时日报规则',
disabled: '这条定时日报已停用,请先启用再执行',
target_needs_review: '这条规则的发送目标需要重新选择后才能执行',
in_flight: '这条规则正在执行中,请稍后再试',
store_error: '读取规则失败,请稍后再试'
}
return {
success: false,
error: skipMessages[outcome.reason || ''] || '定时日报未执行',
data: outcome
}
})
ipcMain.handle('automation:listScheduledReportLegacyExecutions', (_, ruleId?: string) =>
scheduledReportService.listLegacyExecutions(ruleId)
)
ipcMain.handle('scheduled-report:getNotificationSettings', () =>
ipcMain.handle('automation:getScheduledReportNotificationSettings', () =>
scheduledReportService.getNotificationSettings()
)
ipcMain.handle('scheduled-report:setNotificationEnabled', (_, enabled: boolean) =>
ipcMain.handle('automation:getScheduledReportNotificationCapability', () =>
scheduledReportService.checkNotificationCapability()
)
ipcMain.handle('automation:setScheduledReportNotificationEnabled', (_, enabled: boolean) =>
scheduledReportService.setNotificationEnabled(Boolean(enabled))
)
ipcMain.handle('scheduled-report:create', (_, request: ScheduledReportCreateInput) =>
scheduledReportService.createTask(request)
)
ipcMain.handle(
'scheduled-report:update',
(_, taskId: string, request: ScheduledReportUpdateInput) =>
scheduledReportService.updateTask(taskId, request)
)
ipcMain.handle('scheduled-report:delete', (_, taskId: string) =>
scheduledReportService.deleteTask(taskId)
)
ipcMain.handle('scheduled-report:setEnabled', (_, taskId: string, enabled: boolean) =>
scheduledReportService.setTaskEnabled(taskId, Boolean(enabled))
)
ipcMain.handle('scheduled-report:runNow', (_, taskId: string) =>
scheduledReportService.runScheduledReportNow(taskId)
)
ipcMain.handle('scheduled-report:retrySend', (_, executionId: string) =>
scheduledReportService.retryScheduledReportSend(executionId)
)
ipcMain.handle('scheduled-report:testErrorNotification', (_, taskId: string) => {
ipcMain.handle('automation:testScheduledReportErrorNotification', (_, ruleId: string) => {
if (!isTruthyDebugFlag(import.meta.env.VITE_SCHEDULED_REPORT_DEBUG)) {
return Promise.resolve({
success: false,
error: '调试测试按钮未开启,请在 .env 中设置 VITE_SCHEDULED_REPORT_DEBUG=true。'
})
}
return scheduledReportService.testScheduledReportErrorNotification(taskId)
return scheduledReportService.testScheduledReportErrorNotification(ruleId)
})
ipcMain.handle('report:reveal', async (_, filePath: string) => {
+317 -3
View File
@@ -5,24 +5,30 @@ import {
DEFAULT_REPLY_TEXT,
automationIdempotencyKey,
normalizeReplyDelaySeconds,
scheduledReportRangeLabel,
type AutomationAction,
type AutomationExecutionTrigger,
type AutomationRule,
type AutomationStep,
type AutomationStepKey,
type LeaveNotificationConfig
type LeaveNotificationConfig,
type ScheduledReportAutomationConfig
} from '../../shared/automation'
import {
renderLeaveNotificationText,
type GroupMemberExitedEvent
} from '../../shared/group-exit-event'
import type { WechatActionContent, WechatActionRequest, WechatActionResult } from '../../shared/wechat-action'
import type { SaveGeneratedReportRequest, SaveGeneratedReportResult } from '../../shared/report-history'
import {
generateAgentGroupReport,
type AgentGroupReportRequest,
type AgentGroupReportResult
} from './agent-group-report-service'
import { wechatActionGateway } from './wechat-action-gateway'
import type { LeaveNotificationTargetResolution } from './leave-notification-target'
import { saveGeneratedReport } from '../report-history-service'
import { getContactAvatars, resolveMd5 } from './chat-service'
import type { AutomationTargetResolution } from './automation-wechat-target'
/**
* AutomationActionRunner —— 把一次命中跑成**步骤序列**。
@@ -46,6 +52,20 @@ const LEAVE_NOTIFICATION_STEP_ORDER: AutomationStepKey[] = [
'exit_send'
]
/**
* 定时日报的步骤顺序(与另外两族**不共用**)。
*
* 「生成中 / 已生成」与「确定目标 / 已发送」**刻意拆成四步**:
* 用户必须能一眼看出"日报确实生成了,只是没发出去" —— 合成两步就表达不了。
*/
const SCHEDULED_REPORT_STEP_ORDER: AutomationStepKey[] = [
'schedule_triggered',
'report_generating',
'report_generated',
'send_resolved',
'report_sent'
]
/**
* 前置步骤失败时,后续步骤的 `skipReason`。
*
@@ -54,6 +74,15 @@ const LEAVE_NOTIFICATION_STEP_ORDER: AutomationStepKey[] = [
const SKIPPED_AFTER_REPLY_FAILURE = '前置步骤失败(回复确认未成功),本次不再继续。'
const SKIPPED_AFTER_REPORT_FAILURE = '前置步骤失败(日报未生成),没有图片可发送。'
/**
* 定时日报「生成 / 落库」失败时,后续步骤的 `skipReason`。
*
* 与 `SKIPPED_AFTER_REPORT_FAILURE` 的区别:那条说的是「@我日报没图可发」,
* 这条说的是「定时日报这一步就没跑起来」—— 文案分开,避免用户混淆两条链路。
*/
const SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE =
'前置步骤失败(定时日报未生成或未保存),本次未发送。'
/**
* 规则启用了「发送日报图片」,但手上没有图片文件。
*
@@ -90,7 +119,7 @@ export interface LeaveNotificationRunInput {
executionId: string
event: GroupMemberExitedEvent
config: LeaveNotificationConfig
resolution: LeaveNotificationTargetResolution
resolution: AutomationTargetResolution
/** 事件来源的显示名(群名 / 「群聊」)。**不含 wxid**。 */
sourceDisplayName: string
/** 发送能力缺失时的一句话说明;有值时 `exit_send` 直接判失败。 */
@@ -103,12 +132,69 @@ export interface LeaveNotificationRunResult {
errorSummary?: string
}
/**
* 定时日报的执行输入。
*
* 与退群通知同构:目标解析、来源显示名、发送能力预检都在 `AutomationService`
* 调用前算好,runner 只负责"跑成步骤序列"。
*/
export interface ScheduledReportRunInput {
executionId: string
rule: AutomationRule
config: ScheduledReportAutomationConfig
resolution: AutomationTargetResolution
/** 日报来源群的显示名(群名 / 「日报来源群」)。**不含 roomId**。 */
sourceDisplayName: string
/**
* 本次触发方式。
*
* 只影响**发送节流口径**:定时触发算 `automation`(纳入发送节流),
* 用户在页面上点「立即执行」算 `user`(与旧定时日报的手动执行一致)。
* 执行日志里的 `trigger` 由 `AutomationService` 单独记录,不从这里读。
*/
trigger: Extract<AutomationExecutionTrigger, 'schedule' | 'manual'>
/** 发送能力缺失时的一句话说明;有值时 `report_sent` 直接判失败。 */
sendBlockedReason?: string
}
export interface ScheduledReportRunResult {
steps: AutomationStep[]
status: 'success' | 'failed'
errorSummary?: string
/**
* 日报是否**已经生成并落库**。
*
* 这是「生成成功但发送失败」的判据:`reportGenerated === true && status === 'failed'`
* 就是"日报在,只是没发出去"。日报本身已进日报历史,不会被丢掉。
*/
reportGenerated: boolean
/** 生成出来的 PNG(仅在内存里传递,**不落执行日志**)。 */
pngPath?: string
/**
* 底层生成错误的**机器可读码**(例如 `NO_MESSAGES`)。
*
* 用途只有一个:让上层区分「真的失败了」和「这一天没有消息可生成」——
* 后者不该给用户推微信异常通知。
*/
errorCode?: string
}
export interface AutomationActionRunnerDependencies {
generateReport?: (request: AgentGroupReportRequest) => Promise<AgentGroupReportResult>
executeAction?: (request: WechatActionRequest) => Promise<WechatActionResult>
now?: () => number
/** 延迟实现。默认真 sleep;单测注入即时 resolve 的假实现,避免真的等 2 秒。 */
delay?: (ms: number) => Promise<void>
/** 日报落库(日报历史)。与旧定时日报**复用同一个**实现,不复制。 */
saveGeneratedReport?: (request: SaveGeneratedReportRequest) => Promise<SaveGeneratedReportResult>
/** 把日报来源群标识解析成联系人(拿头像 / 显示名)。 */
resolveReportContact?: (raw: string) => {
md5?: string
m_nsUsrName?: string
m_nsNickName?: string
avatar?: string
} | null
getContactAvatars?: (usernames: string[]) => Promise<Record<string, string>>
}
/** 策略层的错误码 → 用户可读短句。UI 直接展示这些文案,不做二次翻译。 */
@@ -161,6 +247,13 @@ export class AutomationActionRunner {
private readonly executeAction: (request: WechatActionRequest) => Promise<WechatActionResult>
private readonly now: () => number
private readonly delay: (ms: number) => Promise<void>
private readonly saveGeneratedReport: (
request: SaveGeneratedReportRequest
) => Promise<SaveGeneratedReportResult>
private readonly resolveReportContact: NonNullable<
AutomationActionRunnerDependencies['resolveReportContact']
>
private readonly getContactAvatars: (usernames: string[]) => Promise<Record<string, string>>
constructor(dependencies: AutomationActionRunnerDependencies = {}) {
this.generateReport = dependencies.generateReport ?? generateAgentGroupReport
@@ -169,6 +262,9 @@ export class AutomationActionRunner {
this.delay =
dependencies.delay ??
((ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)))
this.saveGeneratedReport = dependencies.saveGeneratedReport ?? saveGeneratedReport
this.resolveReportContact = dependencies.resolveReportContact ?? ((raw) => resolveMd5(raw))
this.getContactAvatars = dependencies.getContactAvatars ?? ((ids) => getContactAvatars(ids))
}
async run(input: AutomationRunInput): Promise<AutomationRunResult> {
@@ -273,6 +369,224 @@ export class AutomationActionRunner {
return { steps, status: 'success', pngPath }
}
/**
* 定时日报:把一次「到点触发 / 手动立即执行」跑成一次生成 + 一次发送。
*
* 与旧 `ScheduledReportService.executeTask` 的行为对齐(**不重写日报能力**):
* 1. 先生成(含落库进日报历史)—— 发送能力不足**也照常生成**(旧语义如此);
* 2. 再解析目标 —— 解析失败**不 fallback**,直接判失败;
* 3. 最后经 `WechatActionGateway` 发图片 —— 仍然是**发图片**,不退化成纯文本。
*
* 「生成成功但发送失败」是可表达的:`report_generated` 为 success、
* `report_sent` 为 failed,整体 `failed`,且 `reportGenerated === true`。
*/
async runScheduledReport(input: ScheduledReportRunInput): Promise<ScheduledReportRunResult> {
const steps = SCHEDULED_REPORT_STEP_ORDER.map((key) => createStep(key))
const stepAt = (key: AutomationStepKey): AutomationStep =>
steps.find((step) => step.key === key) as AutomationStep
markSuccess(stepAt('schedule_triggered'), this.now())
// ---- 生成日报(含落进日报历史)----
const generatingStep = stepAt('report_generating')
generatingStep.status = 'running'
generatingStep.startedAt = this.now()
let generated: AgentGroupReportResult
try {
generated = await this.generateReport({
group: input.config.report.sourceConversationId,
range: input.config.report.range,
messageTypes: input.config.report.messageTypes,
templateId: input.config.report.templateId,
memberNameMode: input.config.report.memberNameMode,
timeoutSeconds: input.config.report.timeoutSeconds
})
} catch (error) {
generated = {
success: false,
error: error instanceof Error ? error.message : String(error)
}
}
if (!generated.success || !generated.pngPath) {
// 生成失败就**不发**:绝不生成空图片、也绝不退而求其次发上一张旧图。
const reason = generated.error || '日报生成失败'
markFailed(generatingStep, this.now(), reason)
markSkipped(stepAt('report_generated'), SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE)
markSkipped(stepAt('send_resolved'), SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE)
markSkipped(stepAt('report_sent'), SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE)
return {
steps,
status: 'failed',
errorSummary: reason,
reportGenerated: false,
...(generated.errorCode ? { errorCode: generated.errorCode } : {})
}
}
let pngPath = generated.pngPath
try {
const reportContact = this.resolveReportContact(input.config.report.sourceConversationId)
let contactAvatar = reportContact?.avatar
if (!contactAvatar && reportContact?.m_nsUsrName) {
try {
const avatars = await this.getContactAvatars([reportContact.m_nsUsrName])
contactAvatar = avatars[reportContact.m_nsUsrName]
} catch (error) {
console.warn('[Automation] 日报群头像补全失败:', error)
}
}
const savedHistory = await this.saveGeneratedReport({
contactId: reportContact?.md5 || input.config.report.sourceConversationId,
contactName:
generated.groupName || reportContact?.m_nsNickName || input.sourceDisplayName || '群聊',
contactAvatar,
source: 'scheduled',
dateRange: generated.reportMetadata?.dateRange || scheduledReportRangeLabel(input.config.report.range),
reportDate: generated.reportMetadata?.reportDate,
messageCount: generated.messageCount ?? generated.reportMetadata?.messageCount ?? 0,
generatedAt: new Date(this.now()).toISOString(),
htmlPath: generated.htmlPath,
pngPath: generated.pngPath,
duration: generated.duration,
modelName: generated.modelName,
tokenUsage: generated.tokenUsage,
reportSnapshot: generated.reportSnapshot,
reportMetadata: generated.reportMetadata,
templateId: input.config.report.templateId
})
if (!savedHistory.success) {
throw new Error(savedHistory.error || '日报历史保存失败')
}
const recordPath = savedHistory.record?.pngPath || generated.pngPath
if (!recordPath) throw new Error('日报历史未返回可发送的 PNG 文件')
pngPath = recordPath
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
markFailed(generatingStep, this.now(), reason)
markSkipped(stepAt('report_generated'), SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE)
markSkipped(stepAt('send_resolved'), SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE)
markSkipped(stepAt('report_sent'), SKIPPED_AFTER_SCHEDULED_GENERATION_FAILURE)
return { steps, status: 'failed', errorSummary: reason, reportGenerated: false }
}
markSuccess(generatingStep, this.now())
const generatedStep = stepAt('report_generated')
markSuccess(generatedStep, this.now())
const messageCount = generated.messageCount ?? 0
if (messageCount > 0) generatedStep.detail = `共 ${messageCount} 条消息`
// ---- 目标解析失败:不许 fallback 到任何地方,直接判失败 ----
const targetStep = stepAt('send_resolved')
if (!input.resolution.ok) {
markFailed(targetStep, this.now(), input.resolution.error)
markSkipped(stepAt('report_sent'), '没有可用的发送目标,本次未发送。')
return {
steps,
status: 'failed',
errorSummary: input.resolution.error,
reportGenerated: true,
pngPath
}
}
targetStep.status = 'success'
targetStep.startedAt = this.now()
targetStep.finishedAt = targetStep.startedAt
targetStep.durationMs = 0
// 用户可读的目标名(例如「文件传输助手」「张三」「我」)—— 不带任何 id。
targetStep.detail = input.resolution.target.displayName
const sendStep = stepAt('report_sent')
// ---- 发送能力缺失:日报已生成,如实判失败(不回滚、不隐藏)----
if (input.sendBlockedReason) {
markFailed(sendStep, this.now(), input.sendBlockedReason)
return {
steps,
status: 'failed',
errorSummary: input.sendBlockedReason,
reportGenerated: true,
pngPath
}
}
sendStep.status = 'running'
sendStep.startedAt = this.now()
const sent = await this.sendScheduledReportImage(input, pngPath)
if (!sent.ok) {
markFailed(sendStep, this.now(), sent.error || '发送日报失败')
return {
steps,
status: 'failed',
errorSummary: sendStep.error,
reportGenerated: true,
pngPath
}
}
markSuccess(sendStep, this.now())
return { steps, status: 'success', reportGenerated: true, pngPath }
}
/**
* 定时日报的统一发送出口。
*
* **必须走 `WechatActionGateway`**:幂等 + 3 秒发送间隔 + 审计落盘都在那里。
* Automation 层不允许知道 OneBot / WCHook / native host / Windows hook 的存在。
*/
private async sendScheduledReportImage(
input: ScheduledReportRunInput,
pngPath: string
): Promise<{ ok: boolean; error?: string }> {
if (!input.resolution.ok) return { ok: false, error: input.resolution.error }
// 定时触发走 automation(纳入发送节流);用户手动执行走 user(不纳入节流)。
const triggerType = input.trigger === 'manual' ? 'user' : 'automation'
try {
const result = await this.executeAction({
idempotencyKey: `${AUTOMATION_SEND_PURPOSE.scheduledReport}:${input.executionId}`,
origin: AUTOMATION_SEND_ORIGIN,
purpose: AUTOMATION_SEND_PURPOSE.scheduledReport,
triggerType,
executionId: input.executionId,
recipient: input.resolution.target.recipient,
content: { type: 'image', path: pngPath }
})
if (result.status !== 'sent') {
return { ok: false, error: actionErrorMessage(result.errorCode, result.reason) }
}
/*
* 后置词:**只在图片明确 sent 之后**才发,图片失败时严格短路 ——
* 与 `WechatActionGateway.executeReportImageSequence`(手动发送)同一口径。
* 空字符串 = 用户只要图片,什么都不补发。
*
* 独立 purpose + 独立幂等位:共用一个 key 会让「图片发成功、后置词被
* 幂等短路」变成常态。
*/
const postfixText = String(input.config.postfixText || '').trim()
if (!postfixText) return { ok: true }
const postfix = await this.executeAction({
idempotencyKey: `${AUTOMATION_SEND_PURPOSE.scheduledReportPostfix}:${input.executionId}`,
origin: AUTOMATION_SEND_ORIGIN,
purpose: AUTOMATION_SEND_PURPOSE.scheduledReportPostfix,
triggerType,
executionId: input.executionId,
recipient: input.resolution.target.recipient,
content: { type: 'text', text: postfixText }
})
if (postfix.status === 'sent') return { ok: true }
// 绝不吞掉:图片确实发出去了,但这一次执行**没有完成**,必须如实报出来。
return {
ok: false,
error: `日报图片已发送,但后置词发送失败:${actionErrorMessage(
postfix.errorCode,
postfix.reason
)}`
}
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) }
}
}
/**
* 退群通知:把「检测到成员退出」这件事跑成一次发送。
*
@@ -1,7 +1,10 @@
import path from 'node:path'
import { app } from 'electron'
import fs from 'fs-extra'
import type { AutomationExecution } from '../../shared/automation'
import type {
AutomationExecution,
AutomationExecutionTrigger
} from '../../shared/automation'
/**
* AutomationExecutionLogService —— **用户层**执行日志。
@@ -24,6 +27,8 @@ export interface AutomationExecutionLogDependencies {
const EXECUTION_STATUSES: AutomationExecution['status'][] = ['running', 'success', 'failed']
const EXECUTION_TRIGGERS: AutomationExecutionTrigger[] = ['message', 'exit', 'schedule', 'manual']
function normalizeExecution(value: unknown): AutomationExecution | null {
if (!value || typeof value !== 'object') return null
const record = value as Partial<AutomationExecution>
@@ -34,6 +39,17 @@ function normalizeExecution(value: unknown): AutomationExecution | null {
ruleId: String(record.ruleId || ''),
ruleName: String(record.ruleName || ''),
triggerTime: Number(record.triggerTime) || 0,
/*
* `trigger` 必须在这里**显式透传**。
*
* 这个归一化是白名单式的:没列出来的字段会被静默丢掉。定时日报的
* 「本次是定时跑的还是用户点的立即执行」全靠这个字段区分,
* 漏掉它就会变成"写的时候有、读出来永远没有"—— 而且没有任何报错。
* 旧记录没有这个字段,保持 `undefined`(读盘不猜测)。
*/
...(EXECUTION_TRIGGERS.includes(record.trigger as AutomationExecutionTrigger)
? { trigger: record.trigger as AutomationExecutionTrigger }
: {}),
sourceDisplayName: String(record.sourceDisplayName || ''),
// 不认识的 status(含历史遗留值)一律降级成 `running`,绝不凭空造出成功/失败。
status: EXECUTION_STATUSES.includes(record.status as AutomationExecution['status'])
+243 -20
View File
@@ -6,15 +6,22 @@ import {
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
createDefaultDailyReportRule,
createDefaultLeaveNotificationRule,
createDefaultScheduledReportRule,
normalizeRuleDraft,
type AutomationRule,
type AutomationRuleType
type AutomationRuleType,
type ScheduledReportAutomationConfig
} from '../../shared/automation'
import {
planLeaveNotificationMigration,
type LeaveNotificationMigrationPlan,
type LegacyLeaveNotificationState
} from './leave-notification-migration'
import {
planScheduledReportMigrationBatch,
type LegacyScheduledReportTask,
type ScheduledReportMigrationSummary
} from './scheduled-report-migration'
import {
GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH,
insertGroupNamePlaceholder
@@ -26,12 +33,10 @@ import {
* 存储形态刻意做到最简:一个 JSON 文件(`{userData}/automation/rules.json`)。
* 规则总量是个位数到几十条,引入数据库只会增加迁移负担。
*
* **内置规则的播种标记(`builtinSeeded`)单独存**:
* **一次性标记全部单独存**(`builtinSeeded` / `leaveNotificationMigrated` /
* `notificationTemplateUpgraded` / `scheduledReportMigrated`):
* 如果靠"文件里有没有那条内置规则"来判断是否播种,用户一旦删掉它,
* 下次启动就会被重新塞回来 —— 用户会认为删除功能坏了。
*
* 退群通知的迁移标记(`leaveNotificationMigrated`)同理:迁移**只允许跑一次**,
* 否则用户删掉退群通知规则后重启又被塞回来。
* 下次启动就会被重新塞回来 —— 用户会认为删除功能坏了。迁移同理。
*/
const STORAGE_DIR = 'automation'
@@ -39,7 +44,11 @@ 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
/** 旧定时日报的**任务**文件(迁移时读一次,之后运行期不再读)。 */
const LEGACY_SCHEDULED_DIR = 'scheduled-reports'
const LEGACY_SCHEDULED_TASKS_FILE = 'tasks.json'
const SCHEDULED_MIGRATION_BACKUP_FILE = 'scheduled-report-migration-backup.json'
const CURRENT_VERSION = 4
interface StoredRules {
version: number
@@ -54,12 +63,27 @@ interface StoredRules {
* 于是用户主动删掉那一行后、下次启动又会被补回来 —— 删不掉的东西最烦人。
*/
notificationTemplateUpgraded: boolean
/**
* 旧「定时日报」任务是否已经迁移过。
*
* ⚠️ 与其它标记有一点不同:迁移需要**把旧群标识解析成稳定会话 id**,
* 而那需要数据库。数据库没就绪时这一位**保持 false**,等解析器注入后再补跑 ——
* 否则会把每一条规则都误判成"目标无法确认"。
*/
scheduledReportMigrated: boolean
rules: AutomationRule[]
}
export interface AutomationRuleStoreDependencies {
userDataPath?: () => string
now?: () => number
/**
* 迁移旧定时日报时,把 legacy 群标识(会话 md5 / 群名 / roomId)解析成
* **稳定会话 id**(`xxx@chatroom`)。解析不到返回 `undefined`。
*
* 不注入 ⇒ 迁移**推迟**(不写任何规则、不置标记)。
*/
resolveLegacyConversationId?: (raw: string) => string | undefined
}
function emptyState(): StoredRules {
@@ -68,19 +92,24 @@ function emptyState(): StoredRules {
builtinSeeded: false,
leaveNotificationMigrated: false,
notificationTemplateUpgraded: false,
scheduledReportMigrated: false,
rules: []
}
}
function normalizeRuleType(value: unknown): AutomationRuleType {
return value === 'leave_notification' ? 'leave_notification' : 'daily_report'
return value === 'leave_notification'
? 'leave_notification'
: value === 'scheduled_report'
? 'scheduled_report'
: 'daily_report'
}
/**
* 单条规则的读盘归一化。
*
* 走 `normalizeRuleDraft` —— 它覆盖了 `AutomationRule` 除 id / 时间戳之外的**全部**字段,
* 所以这是无损的,同时自动补上历史 rules.json 缺失的 `ruleType` / `leaveNotification`。
* 所以这是无损的,同时自动补上历史 rules.json 缺失的 `ruleType` / 各类 config。
*/
function normalizeStoredRule(value: unknown): AutomationRule | null {
if (!value || typeof value !== 'object') return null
@@ -92,8 +121,9 @@ function normalizeStoredRule(value: unknown): AutomationRule | null {
{
...raw,
ruleType,
// 历史 daily_report 规则没有 leaveNotification;反过来也要避免脏字段落盘。
...(ruleType === 'leave_notification' ? { leaveNotification: raw.leaveNotification } : {})
// 历史规则没有这些字段;反过来也要避免脏字段落盘。
...(ruleType === 'leave_notification' ? { leaveNotification: raw.leaveNotification } : {}),
...(ruleType === 'scheduled_report' ? { scheduledReport: raw.scheduledReport } : {})
},
String(raw.name || '').trim() || '未命名自动化'
)
@@ -106,6 +136,9 @@ function normalizeStoredRule(value: unknown): AutomationRule | null {
updatedAt,
...(normalized.ruleType === 'leave_notification' && normalized.leaveNotification
? { leaveNotification: normalized.leaveNotification }
: {}),
...(normalized.ruleType === 'scheduled_report' && normalized.scheduledReport
? { scheduledReport: normalized.scheduledReport }
: {})
}
}
@@ -115,7 +148,6 @@ function normalizeStoredRule(value: unknown): AutomationRule | null {
*
* **为什么替他填**:模板里原先根本没有群名变量(`{groupRemark}` 是成员在本群的昵称)。
* 目标一旦不是「当前群聊」,通知就等于「张三退群了」—— 收件人不知道是哪个群。
* 模板是用户已保存的数据,让他自己去发现"少一个变量"是不合理的。
*
* **为什么只跑一次**:靠"模板里有没有 `{groupName}`"判断会导致用户删掉后被反复补回来。
* 所以由 `notificationTemplateUpgraded` 标记保证一次性;用户之后删掉不会回来。
@@ -139,7 +171,24 @@ function upgradeLeaveNotificationTemplate(rules: AutomationRule[]): {
return { rules: next, count }
}
/** 读盘容错:任何字段可疑都降级成安全值,绝不因为一个坏文件让功能整体不可用。 */function normalizeStored(value: unknown): StoredRules {
/**
* 调度游标(`lastRunAt` / `lastScheduledSlot`)**由 scheduler 拥有**,草稿改不动它们。
*
* 保存规则时一律从 `current` 取,而不是从草稿取 —— 否则用户进一次编辑页保存,
* 就会把"已消费的槽位"抹掉,导致当天重复补跑一次日报。
*/
function pickScheduledReportRuntime(
current: ScheduledReportAutomationConfig | undefined
): Partial<ScheduledReportAutomationConfig> {
if (!current) return {}
return {
...(current.lastRunAt ? { lastRunAt: current.lastRunAt } : {}),
...(current.lastScheduledSlot ? { lastScheduledSlot: current.lastScheduledSlot } : {})
}
}
/** 读盘容错:任何字段可疑都降级成安全值,绝不因为一个坏文件让功能整体不可用。 */
function normalizeStored(value: unknown): StoredRules {
if (!value || typeof value !== 'object') return emptyState()
const input = value as Partial<StoredRules>
const rules = Array.isArray(input.rules)
@@ -152,6 +201,7 @@ function upgradeLeaveNotificationTemplate(rules: AutomationRule[]): {
builtinSeeded: input.builtinSeeded === true,
leaveNotificationMigrated: input.leaveNotificationMigrated === true,
notificationTemplateUpgraded: input.notificationTemplateUpgraded === true,
scheduledReportMigrated: input.scheduledReportMigrated === true,
rules
}
}
@@ -163,10 +213,31 @@ export class AutomationRuleStore {
private loaded = false
/** 迁移结果,供启动日志/报告读取(不含任何 id)。 */
private lastMigrationPlan: LeaveNotificationMigrationPlan | null = null
private lastScheduledMigrationSummary: ScheduledReportMigrationSummary | null = null
private legacyConversationResolver: ((raw: string) => string | undefined) | null
constructor(dependencies: AutomationRuleStoreDependencies = {}) {
this.userDataPath = dependencies.userDataPath ?? (() => app.getPath('userData'))
this.now = dependencies.now ?? (() => Date.now())
this.legacyConversationResolver = dependencies.resolveLegacyConversationId ?? null
}
/**
* 注入 / 替换旧定时日报迁移所需的会话解析器(数据库就绪后由 main 调用)。
*
* 若之前因为"解析器不可用"推迟了迁移,这里会**立刻补跑**并落盘。
*/
setLegacyConversationResolver(resolver: (raw: string) => string | undefined): void {
this.legacyConversationResolver = resolver
if (!this.loaded) return
if (this.state.scheduledReportMigrated) return
if (this.runScheduledReportMigration(this.state)) this.persist()
}
/** 旧定时日报迁移是否仍在等待解析器(仅用于启动日志与测试断言)。 */
hasPendingScheduledReportMigration(): boolean {
this.ensureLoaded()
return !this.state.scheduledReportMigrated && this.readLegacyScheduledTasks().length > 0
}
listRules(): AutomationRule[] {
@@ -187,6 +258,12 @@ export class AutomationRuleStore {
return this.lastMigrationPlan ? { ...this.lastMigrationPlan } : null
}
/** 上一次旧定时日报迁移的统计(未迁移时为 null)。 */
getLastScheduledReportMigration(): ScheduledReportMigrationSummary | null {
this.ensureLoaded()
return this.lastScheduledMigrationSummary ? { ...this.lastScheduledMigrationSummary } : null
}
createRule(draft: unknown): AutomationRule {
this.ensureLoaded()
const timestamp = this.now()
@@ -219,11 +296,26 @@ export class AutomationRuleStore {
...(current.ruleType === 'leave_notification' && normalized.leaveNotification
? { leaveNotification: normalized.leaveNotification }
: {}),
...(current.ruleType === 'scheduled_report' && normalized.scheduledReport
? {
scheduledReport: {
...normalized.scheduledReport,
...pickScheduledReportRuntime(current.scheduledReport)
}
}
: {}),
createdAt: current.createdAt,
updatedAt: this.now()
}
// 切类型时不能留下另一种类型才认识的字段。
if (rule.ruleType === 'daily_report') delete rule.leaveNotification
if (rule.ruleType === 'daily_report') {
delete rule.leaveNotification
delete rule.scheduledReport
} else if (rule.ruleType === 'leave_notification') {
delete rule.scheduledReport
} else {
delete rule.leaveNotification
}
this.state.rules = this.state.rules.map((item, at) => (at === index ? rule : item))
this.persist()
return structuredClone(rule)
@@ -275,6 +367,38 @@ export class AutomationRuleStore {
return this.updateRule(id, { ...rule, enabled: enabled === true })
}
/**
* 写**调度游标**(`lastRunAt` / `lastScheduledSlot`)。
*
* 刻意不走 `updateRule`:这不是用户配置编辑,走草稿归一化会顺带触碰别的字段,
* 也可能被"草稿里没这个字段"给抹掉。这里只允许改这两个键。
*/
setScheduledReportRuntime(
id: string,
patch: { lastRunAt?: string; lastScheduledSlot?: string }
): AutomationRule | undefined {
this.ensureLoaded()
const key = String(id || '').trim()
const index = this.state.rules.findIndex((rule) => rule.id === key)
if (index < 0) return undefined
const current = this.state.rules[index]
if (current.ruleType !== 'scheduled_report' || !current.scheduledReport) return undefined
const next: AutomationRule = {
...current,
scheduledReport: {
...current.scheduledReport,
...(patch.lastRunAt !== undefined ? { lastRunAt: patch.lastRunAt } : {}),
...(patch.lastScheduledSlot !== undefined
? { lastScheduledSlot: patch.lastScheduledSlot }
: {})
},
updatedAt: this.now()
}
this.state.rules = this.state.rules.map((item, at) => (at === index ? next : item))
this.persist()
return structuredClone(next)
}
private ensureLoaded(): void {
if (this.loaded) return
this.loaded = true
@@ -301,6 +425,7 @@ export class AutomationRuleStore {
console.log(`[Automation] 退群通知模板已补上群名变量(${upgraded.count} 条)`)
}
}
this.runScheduledReportMigration(stored)
stored.version = CURRENT_VERSION
this.state = stored
this.persist()
@@ -326,11 +451,110 @@ export class AutomationRuleStore {
template: plan.template,
target: plan.target,
enabled: plan.enabled,
targetNeedsReview: plan.targetNeedsReview
// 二次勾选(旧「通知群聊」)逐字保留,迁移不再是有损的。
notifyScope: plan.notifyScope,
notifyRoomIds: plan.notifyRoomIds
})
]
}
/**
* 旧「定时日报」任务 → `scheduled_report` 规则。**只跑一次**。
*
* 返回 `true` 表示本次真的做了迁移并需要落盘。
* **解析器不可用时返回 `false` 且不置标记** —— 迁移推迟到数据库就绪后补跑。
*/
private runScheduledReportMigration(stored: StoredRules): boolean {
if (stored.scheduledReportMigrated) return false
const tasks = this.readLegacyScheduledTasks()
if (!tasks.length) {
// 全新安装 / 从没用过旧定时日报:没有历史可迁,直接置位。
stored.scheduledReportMigrated = true
console.log('[Automation] 定时日报迁移 outcome=fresh_install')
return true
}
const resolver = this.legacyConversationResolver
if (!resolver) {
console.log(
`[Automation] 定时日报迁移已推迟:数据库尚未就绪,无法解析旧群标识(待迁 ${tasks.length} 条)`
)
return false
}
const { plans, summary } = planScheduledReportMigrationBatch(tasks, resolver, this.now())
this.lastScheduledMigrationSummary = summary
this.writeScheduledMigrationBackup(tasks, summary)
const existing = new Set(stored.rules.map((rule) => rule.id))
const migrated: AutomationRule[] = []
for (const plan of plans) {
// 幂等:同一 id 已存在就不再插入(重复执行迁移也不会多出规则)。
if (existing.has(plan.ruleId)) continue
existing.add(plan.ruleId)
migrated.push({
...createDefaultScheduledReportRule(plan.createdAt, {
id: plan.ruleId,
name: plan.name,
enabled: plan.enabled,
config: plan.config
}),
createdAt: plan.createdAt,
updatedAt: plan.updatedAt
})
}
stored.rules = [...stored.rules, ...migrated]
stored.scheduledReportMigrated = true
// 只记数量与判定分布,**禁止**出现群名 / roomId / 群主昵称。
console.log(
`[Automation] 定时日报迁移 outcome=done total=${summary.total} migrated=${migrated.length}` +
` lossless=${summary.lossless} needsReview=${summary.needsReview}` +
` duplicatesSkipped=${summary.duplicatesSkipped}`
)
return true
}
/**
* 读旧定时日报**任务**文件。
*
* 只读、只在这里读一次;运行期其余代码**不再读** legacy(避免双读)。
* 读失败视为"没有历史任务",不抛。
*/
private readLegacyScheduledTasks(): LegacyScheduledReportTask[] {
try {
const value = fs.readJsonSync(
path.join(this.userDataPath(), LEGACY_SCHEDULED_DIR, LEGACY_SCHEDULED_TASKS_FILE)
) as unknown
return Array.isArray(value) ? (value as LegacyScheduledReportTask[]) : []
} catch {
return []
}
}
/** 迁移前把旧任务**原样**落一份 backup(不做不可逆覆盖)。 */
private writeScheduledMigrationBackup(
tasks: LegacyScheduledReportTask[],
summary: ScheduledReportMigrationSummary
): void {
try {
const filePath = path.join(
this.userDataPath(),
STORAGE_DIR,
SCHEDULED_MIGRATION_BACKUP_FILE
)
fs.ensureDirSync(path.dirname(filePath))
fs.writeJsonSync(
filePath,
{
migratedAt: this.now(),
summary,
// 备份里保留原始字段:这是**用户数据**,不是日志,不进任何用户可见界面。
legacyTasks: tasks
},
{ spaces: 2 }
)
} catch (error) {
console.warn(`[Automation] 写入定时日报迁移备份失败: ${errorText(error)}`)
}
}
/**
* 读旧退群监控状态文件里的通知相关字段。
*
@@ -358,7 +582,7 @@ export class AutomationRuleStore {
/**
* 迁移前把旧配置原样落一份 backup。
*
* §「不要不可逆直接覆盖」:旧状态文件本身也**不删**,只是不再被运行时代码读取。
* 不做不可逆覆盖:旧状态文件本身也**不删**,只是不再被运行时代码读取。
*/
private writeMigrationBackup(
legacy: LegacyLeaveNotificationState,
@@ -381,15 +605,14 @@ export class AutomationRuleStore {
applied: {
enabled: plan.enabled,
targetType: plan.target.type,
targetNeedsReview: plan.targetNeedsReview
notifyScope: plan.notifyScope,
notifyRoomIds: plan.notifyRoomIds
}
},
{ spaces: 2 }
)
} catch (error) {
console.warn(
`[Automation] 写入退群通知迁移备份失败: ${errorText(error)}`
)
console.warn(`[Automation] 写入退群通知迁移备份失败: ${errorText(error)}`)
}
}
+243 -14
View File
@@ -6,9 +6,12 @@ import {
import {
AUTOMATION_SEND_PURPOSE,
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
leaveNotificationCoversRoom,
type AutomationExecution,
type AutomationExecutionTrigger,
type AutomationRule,
type AutomationStatusSummary
type AutomationStatusSummary,
type ScheduledRuleRunOutcome
} from '../../shared/automation'
import type { GroupMemberExitedEvent } from '../../shared/group-exit-event'
import type { PersonalWechatSendCapability } from '../../shared/personal-wechat'
@@ -20,6 +23,11 @@ import {
resolveLeaveNotificationTarget,
type LeaveNotificationTargetResolution
} from './leave-notification-target'
import {
resolveScheduledReportTarget,
scheduledReportSourceDisplayName
} from './scheduled-report-target'
import type { AutomationTargetContact } from './automation-wechat-target'
import {
automationRuleStore,
type AutomationRuleStore
@@ -32,7 +40,6 @@ import {
automationActionRunner,
type AutomationActionRunner
} from './automation-action-runner'
/**
* AutomationService —— Automation v1 的编排层。
*
@@ -118,6 +125,26 @@ export function leaveNotificationIdempotencyKey(eventId: string): string {
return `${AUTOMATION_SEND_PURPOSE.leaveNotification}:${eventId}`
}
/**
* 定时日报的**确定性** executionId。
*
* 由 `ruleId + 槽位` 推出(不是 `randomUUID`):同一个每日槽位即使因为
* 崩溃重启被重复触发,也算出同一个 executionId ——
* `ExecutionLog.record()` 按 id 覆盖,`WechatActionGateway` 的落盘幂等键
* (`automation_scheduled_report:<executionId>`)直接把第二次发送短路掉。
*
* **手动执行不算槽位**:每次都应该是独立的一次执行,否则用户连点两次
* 「立即执行」会因为幂等而被静默吞掉。
*/
export function scheduledReportExecutionId(ruleId: string, slot: string): string {
return createHash('sha1')
.update(`scheduled_report\u0001${ruleId}\u0001${slot}`)
.digest('hex')
}
/** 定时日报一次执行的结果(定义在 shared 层,便于 IPC / 渲染层共用)。 */
export type { ScheduledRuleRunOutcome }
export interface AutomationServiceDependencies {
ruleStore?: AutomationRuleStore
executionLog?: AutomationExecutionLogService
@@ -159,6 +186,16 @@ export class AutomationService {
*/
private readonly gates = new Map<string, ConversationRuleGate>()
/**
* 定时日报的 `ruleId inFlight` 锁。
*
* **刻意不用 `gates`(conversation gate)**:定时日报的触发与"某个会话刚被触发过"
* 没有关系,套 60 秒 cooldown 会莫名其妙吞掉当天的补跑。
* 这里只要一条最朴素的约束:**同一条规则同时最多一个 execution**;
* 不同规则之间互不影响(可以并行)。
*/
private readonly scheduledInFlight = new Set<string>()
/** 仅用于诊断统计(`[Automation] blockedMessages=N`),**不进用户执行日志**。 */
private blockedMessageCount = 0
@@ -300,10 +337,16 @@ export class AutomationService {
* - 消息型规则:MessageListener → handleMessage → 动作链;
* - 退群通知:GroupExitMonitor → **本入口** → 单次文本发送。
*
* 三层闸门(缺一不可):
* 1. `enabled === false` → 不执行,**且不创建 execution**(§「没有执行:不创建」);
* 2. `targetNeedsReview` → 迁移过来但目标无法无损映射,同样不执行、不创建记录;
* 3. `ruleId + eventId` 幂等 → 同一事件即使重复投递也只发送一次。
* 四层闸门(缺一不可):
* 1. `enabled === false` → 不执行,**且不创建 execution**;
* 2. `targetNeedsReview` → 存量迁移遗留的"目标待重选",同样不执行、不创建记录;
* 3. **通知范围**(`notifyScope` / `notifyRoomIds`)→ 事件所在群不在二次勾选范围内则跳过。
* 这是旧版「退群监控 → 通知群聊」的第二层,缺了它规则一启用就全量通知;
* 同样**不创建 execution** —— 被范围排除不是"一次失败的执行"。
* 4. `ruleId + eventId` 幂等 → 同一事件即使重复投递也只发送一次。
*
* 闸门顺序有意如此:范围判定放在幂等**之前**,否则一个被排除的群会白白占用
* 该事件的幂等位(用户随后真的补勾了那个群,反而发不出去)。
*/
async handleGroupExit(event: GroupMemberExitedEvent): Promise<void> {
const eventId = String(event?.eventId || '').trim()
@@ -326,7 +369,12 @@ export class AutomationService {
this.warn(`退群通知目标待重新选择,已跳过本次通知 ruleId=${rule.id}`)
return
}
// ③ 幂等(内存快路径)。跨重启那一层由确定性 executionId + gateway 审计兜住。
// ③ 通知范围:这个群没被勾选就不归本规则管(与 UI 共用 shared 同一份判定)。
if (!leaveNotificationCoversRoom(rule.leaveNotification, event.conversationId)) {
this.info(`退群通知范围外,已跳过本次通知 ruleId=${rule.id}`)
return
}
// ④ 幂等(内存快路径)。跨重启那一层由确定性 executionId + gateway 审计兜住。
if (!this.claimGroupExit(rule.id, eventId)) return
const executionId = leaveNotificationExecutionId(rule.id, eventId)
@@ -382,8 +430,26 @@ export class AutomationService {
rule: AutomationRule,
event: GroupMemberExitedEvent
): LeaveNotificationTargetResolution {
let contacts: ReturnType<typeof listContacts> = []
const { contacts, selfWxid } = this.resolveTargetContext()
return resolveLeaveNotificationTarget({
config: rule.leaveNotification,
event,
contacts,
selfWxid
})
}
/**
* 联系人清单 + 当前登录 wxid。
*
* 退群通知与定时日报**共用这一处**:两份都要"把目标解析成可发送对象",
* 解析所需的数据源(通讯录 / 当前账号)必须只有一个出口,
* 否则两边会慢慢长成两套判定。
*/
private resolveTargetContext(): { contacts: AutomationTargetContact[]; selfWxid: string } {
let contacts: AutomationTargetContact[] = []
try {
// `FormattedContact` 结构上就满足 `AutomationTargetContact`,不需要断言。
contacts = listContacts()
} catch (error) {
this.warn(`读取联系人失败: ${errorText(error)}`)
@@ -394,12 +460,172 @@ export class AutomationService {
} catch (error) {
this.warn(`读取当前账号失败: ${errorText(error)}`)
}
return resolveLeaveNotificationTarget({
config: rule.leaveNotification,
event,
contacts,
selfWxid
})
return { contacts, selfWxid }
}
/**
* 定时日报执行入口。**scheduler 与「立即执行」共用这一条**。
*
* ```text
* Scheduler ─┐
* ├─→ executeScheduledRule(ruleId, { trigger, scheduledSlot })
* 手动执行 ───┘ ↓ resolveScheduledReportTarget
* ↓ runner.runScheduledReport(生成 → 落库 → 解析目标 → Gateway 发图)
* ↓ ExecutionLog.record(trigger = schedule | manual)
* ```
*
* 三层闸门(与退群通知同口径,但**不套 conversation gate / cooldown**):
* 1. `enabled === false` → 不执行、不创建记录;
* 2. `targetNeedsReview` → 目标无法无损映射,不执行、不创建记录;
* 3. `ruleId inFlight` → 同一条规则同时最多一个 execution(不同规则可并行)。
*/
async executeScheduledRule(
ruleId: string,
options: { trigger: 'schedule' | 'manual'; scheduledSlot?: string }
): Promise<ScheduledRuleRunOutcome> {
const key = String(ruleId || '').trim()
if (!key) return { executed: false, reason: 'missing_rule' }
let rule: AutomationRule | undefined
try {
rule = this.ruleStore.getRule(key)
} catch (error) {
this.warn(`读取定时日报规则失败: ${errorText(error)}`)
return { executed: false, reason: 'store_error' }
}
if (!rule || rule.ruleType !== 'scheduled_report' || !rule.scheduledReport) {
return { executed: false, reason: 'rule_not_found' }
}
if (!rule.enabled) return { executed: false, reason: 'disabled' }
if (rule.scheduledReport.targetNeedsReview) {
this.warn(`定时日报目标待重新选择,已跳过 ruleId=${rule.id}`)
return { executed: false, reason: 'target_needs_review' }
}
// 并发保护:同一条规则同时最多一个执行。冲突时**直接返回**,不排队 ——
// 排队会让"18:00 那次没跑完"变成"18:00 和 18:01 各发一份"。
if (this.scheduledInFlight.has(rule.id)) {
return { executed: false, reason: 'in_flight' }
}
this.scheduledInFlight.add(rule.id)
const scheduledSlot = String(options.scheduledSlot || '').trim()
try {
const startedAt = this.now()
const executionId =
options.trigger === 'schedule' && scheduledSlot
? scheduledReportExecutionId(rule.id, scheduledSlot)
: randomUUID()
const { contacts, selfWxid } = this.resolveTargetContext()
const resolution = resolveScheduledReportTarget({
config: rule.scheduledReport,
contacts,
selfWxid
})
const sourceDisplayName = scheduledReportSourceDisplayName(rule.scheduledReport, contacts)
const sendBlockedReason = await this.scheduledReportCapabilityError()
let result: Awaited<ReturnType<AutomationActionRunner['runScheduledReport']>>
try {
result = await this.runner.runScheduledReport({
executionId,
rule,
config: rule.scheduledReport,
resolution,
sourceDisplayName,
trigger: options.trigger === 'manual' ? 'manual' : 'schedule',
...(sendBlockedReason ? { sendBlockedReason } : {})
})
} catch (error) {
result = {
steps: [],
status: 'failed',
errorSummary: `执行过程异常:${errorText(error)}`,
reportGenerated: false
}
}
this.executionLog.record(
this.buildExecution({
executionId,
rule,
startedAt,
sourceDisplayName,
trigger: options.trigger,
status: result.status,
durationMs: this.now() - startedAt,
steps: result.steps,
...(result.errorSummary ? { errorSummary: result.errorSummary } : {})
})
)
this.recordScheduledRun(rule.id, {
trigger: options.trigger,
...(scheduledSlot ? { scheduledSlot } : {}),
finishedAt: new Date(this.now()).toISOString()
})
// 隐私红线:日志只允许 ruleId / executionId / 触发方式 / 状态 / 耗时 / 步骤状态。
// 群名、昵称、wxid、日报正文、图片路径一律不进日志。
const trail = result.steps.map((step) => `${step.key}=${step.status}`).join(' ')
this.info(
`executed ruleId=${rule.id} executionId=${executionId} trigger=${options.trigger}` +
` status=${result.status} durationMs=${this.now() - startedAt}${trail ? ` ${trail}` : ''}`
)
return {
executed: true,
executionId,
status: result.status,
reportGenerated: result.reportGenerated,
...(result.errorCode ? { errorCode: result.errorCode } : {}),
...(result.errorSummary ? { errorSummary: result.errorSummary } : {})
}
} finally {
this.scheduledInFlight.delete(rule.id)
}
}
/**
* 写调度游标。
*
* - `lastRunAt`:**任何一次执行**(含手动)都会更新 —— UI 的"最近执行"要如实反映;
* - `lastScheduledSlot`:**只有定时触发**才消费槽位。
* 手动执行若也写它,会把今天已经安排好的那次定时执行一起吞掉。
*/
private recordScheduledRun(
ruleId: string,
input: { trigger: 'schedule' | 'manual'; scheduledSlot?: string; finishedAt: string }
): void {
try {
this.ruleStore.setScheduledReportRuntime(ruleId, {
lastRunAt: input.finishedAt,
...(input.trigger === 'schedule' && input.scheduledSlot
? { lastScheduledSlot: input.scheduledSlot }
: {})
})
} catch (error) {
// 游标写失败不影响本次执行 —— 但必须留痕,否则"同一天重复补跑"会变成无声故障。
this.warn(`写入定时日报调度游标失败 ruleId=${ruleId}: ${errorText(error)}`)
}
}
/** 定时日报只需要图片能力。缺失时给出用户能看懂的一句说明。 */
private async scheduledReportCapabilityError(): Promise<string | undefined> {
let capability: PersonalWechatSendCapability | null = null
try {
capability = await this.getCapability()
} catch {
return '暂时无法获知微信发送能力,本次定时日报未发送。'
}
if (!capability?.supported) {
return capability?.message || '当前系统不支持微信消息发送'
}
if (!capability.capabilities?.image) {
return '当前环境无法发送图片,本次定时日报未发送。'
}
return undefined
}
/** 退群通知只需要文字能力。缺失时给出用户能看懂的一句说明。 */
@@ -565,6 +791,8 @@ export class AutomationService {
rule: AutomationRule
startedAt: number
sourceDisplayName: string
/** 触发方式。旧调用点不传时为 `undefined` —— 读盘不猜测。 */
trigger?: AutomationExecutionTrigger
status: AutomationExecution['status']
durationMs: number
steps: AutomationExecution['steps']
@@ -575,6 +803,7 @@ export class AutomationService {
ruleId: input.rule.id,
ruleName: input.rule.name,
triggerTime: input.startedAt,
...(input.trigger ? { trigger: input.trigger } : {}),
sourceDisplayName: input.sourceDisplayName,
status: input.status,
durationMs: input.durationMs,
@@ -0,0 +1,266 @@
import {
LEAVE_NOTIFICATION_TARGET_OPTIONS,
SCHEDULED_REPORT_TARGET_OPTIONS,
leaveNotificationTargetLabel,
scheduledReportTargetLabel
} from '../../shared/automation'
import { WECHAT_FILE_HELPER_USERNAME, isFileHelperUsername } from '../../shared/wechat-identities'
/**
* **中性的微信发送目标解析**。
*
* 退群通知与定时日报的目标语义完全一致(来源会话 / 自己 / 文件传输助手 / 指定好友),
* 差的只是"来源会话"从哪来:
* - 退群通知 → 事件所在群(`event.conversationId`);
* - 定时日报 → 该规则配置的日报来源群(`config.report.sourceConversationId`)。
*
* ⇒ 所以**只允许有一份实现**(只保留一套 resolve)。
* 两个业务各自只有一层薄适配(把参数摊平后交给这里),不重复任何判定逻辑。
*
* 五条规则(两边共用):
* 1. 目标解析失败 → **报错**,绝不 fallback 到别的目标(尤其不许偷偷发文件助手 / 来源群);
* 2. 只允许出现在选项表里的目标类型;
* 3. 显示名不带 wxid / roomId:日志与界面只出现用户能看懂的称呼;
* 4. 联系人必须**可发送**(排除群聊 / 公众号 / 文件传输助手 / 自己);
* 5. 只有 `source_chat` 用得到来源会话,其余目标完全不看它。
*/
/** 解析所需的最小联系人结构(main 侧 `FormattedContact` 结构上可赋值给它)。 */
export interface AutomationTargetContact {
m_nsUsrName: string
m_nsNickName?: string
md5?: string
type: 'user' | 'group'
isOfficialAccount?: boolean
wechatNickname?: string
remark?: string
}
/** 目标类型(两个业务共用同一组字面量)。 */
export type AutomationTargetType = 'source_chat' | 'self' | 'file_transfer' | 'contact'
export interface ResolvedAutomationTarget {
recipient: { type: 'group' | 'contact'; id: string; name: string }
/** 用户可读的目标名,直接进执行日志的 `detail`。 */
displayName: string
}
export type AutomationTargetResolution =
| { ok: true; target: ResolvedAutomationTarget }
| { ok: false; error: string }
/** 联系人的展示名:备注 → 微信昵称 → 会话昵称 → 兜底称呼。**永不回落到 wxid**。 */
export function automationContactDisplayName(contact: AutomationTargetContact): string {
return (
contact.remark?.trim() ||
contact.wechatNickname?.trim() ||
contact.m_nsNickName?.trim() ||
'指定好友'
)
}
/**
* 这个联系人能不能作为「指定好友」的发送目标。
*
* 排除:群聊、公众号(`gh_`)、文件传输助手、自己。
* UI 的联系人选择器与运行时的目标解析**共用这一条判定**,
* 否则会出现"能选但发不出去"。
*/
export function isSendableFriendContact(
contact: AutomationTargetContact,
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 AutomationTargetContact>(
contacts: T[],
selfWxid?: string
): T[] {
return contacts.filter((contact) => isSendableFriendContact(contact, selfWxid))
}
/** 目标类型是否在(该业务的)选项表里。 */
function isKnownTargetType(
type: string,
options: ReadonlyArray<{ type: string }>
): boolean {
return options.some((option) => option.type === type)
}
/**
* 把群标识解析成**稳定会话 id**(`xxx@chatroom`)。
*
* 兼容三种历史形态:roomId 本身 / 会话 md5 / 群名。
* 解析不出来返回 `undefined` —— 调用方必须如实报错,**不许回落**。
*/
export function resolveGroupConversationId(
raw: string,
contacts: AutomationTargetContact[]
): string | undefined {
const value = String(raw || '').trim()
if (!value) return undefined
if (value.endsWith('@chatroom')) return value
const matched = contacts.find(
(contact) =>
(contact.type === 'group' || contact.m_nsUsrName.endsWith('@chatroom')) &&
(contact.md5 === value ||
contact.m_nsUsrName === value ||
contact.m_nsNickName?.trim() === value)
)
const conversationId = String(matched?.m_nsUsrName || '').trim()
return conversationId.endsWith('@chatroom') ? conversationId : undefined
}
/** 群标识 → 可读群名(解析不到时返回空串,由调用方决定兜底文案)。 */
export function resolveGroupDisplayName(
raw: string,
contacts: AutomationTargetContact[]
): string {
const value = String(raw || '').trim()
if (!value) return ''
const matched = contacts.find(
(contact) =>
contact.m_nsUsrName === value ||
contact.md5 === value ||
contact.m_nsNickName?.trim() === value
)
return String(matched?.m_nsNickName || '').trim()
}
export interface ResolveAutomationTargetInput {
targetType: AutomationTargetType
/**
* `source_chat` 时使用:来源会话(退群事件所在群 / 日报来源群)。
* 允许是 roomId / 会话 md5 / 群名 —— 一律经 `resolveGroupConversationId` 收敛。
*/
sourceConversationId?: string
/** 来源会话的可读名(拿不到时留空,由这里给兜底称呼)。 */
sourceDisplayName?: string
/** `contact` 时使用:稳定 id(wxid / username)。 */
contactId?: string
contacts: AutomationTargetContact[]
/** 当前登录账号的 wxid;拿不到时传空串。 */
selfWxid?: string
}
/**
* 目标解析的**文案与类型表**(按业务注入)。
*
* 为什么把文案参数化而不是各写一份 switch:两个业务的**判定逻辑必须一模一样**,
* 只有"主语"不同。文案集中放在这里,判定仍然只有一份。
*/
export interface AutomationTargetMessages {
/** 目标类型表的单一来源(与 UI 选项同源)。 */
options: ReadonlyArray<{ type: string }>
/** 类型非法(伪造值 / 未知值)。 */
invalidTarget: string
/** `source_chat` 但来源会话拿不到。 */
sourceMissing: string
/** `source_chat` 且来源会话没有可读名时,`recipient.name` 的兜底。 */
sourceRecipientName: string
/** `source_chat` 且来源会话没有可读名时,`displayName` 的兜底。 */
sourceDisplayName: string
/** `self` 但拿不到自身身份。 */
selfMissing: string
/** `contact` 但没选联系人。 */
contactNotChosen: string
/** `contact` 但联系人失效 / 不可发送。 */
contactUnavailable: string
}
/**
* 中性目标解析。**唯一的实现**。
*/
export function resolveAutomationTarget(
input: ResolveAutomationTargetInput,
messages: AutomationTargetMessages
): AutomationTargetResolution {
const type = input.targetType
if (!type || !isKnownTargetType(type, messages.options)) {
return { ok: false, error: messages.invalidTarget }
}
switch (type) {
case 'source_chat': {
const conversationId = resolveGroupConversationId(
String(input.sourceConversationId || ''),
input.contacts
)
if (!conversationId) return { ok: false, error: messages.sourceMissing }
const explicitName = String(input.sourceDisplayName || '').trim()
const resolvedName = resolveGroupDisplayName(conversationId, input.contacts)
const name = explicitName || resolvedName || messages.sourceRecipientName
const displayName = explicitName || resolvedName || messages.sourceDisplayName
return {
ok: true,
target: {
recipient: { type: 'group', id: conversationId, name },
displayName
}
}
}
case 'self': {
const selfWxid = String(input.selfWxid || '').trim()
// 明确报错,不 fallback。
if (!selfWxid) return { ok: false, error: messages.selfMissing }
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: '文件传输助手'
},
displayName: '文件传输助手'
}
}
case 'contact': {
const contactId = String(input.contactId || '').trim()
if (!contactId) return { ok: false, error: messages.contactNotChosen }
const contact = input.contacts.find(
(item) => String(item.m_nsUsrName || '').trim() === contactId
)
if (!contact || !isSendableFriendContact(contact, input.selfWxid)) {
// 之前选择的联系人被删除 / 不可发送 / 找不到时,规则不偷偷改发别处。
return { ok: false, error: messages.contactUnavailable }
}
const displayName = automationContactDisplayName(contact)
return {
ok: true,
target: {
recipient: { type: 'contact', id: contactId, name: displayName },
displayName
}
}
}
}
}
/** 退群通知的已知目标类型表(与文案同源)。 */
export const LEAVE_NOTIFICATION_TARGET_TYPES = LEAVE_NOTIFICATION_TARGET_OPTIONS
/** 定时日报的已知目标类型表(与文案同源)。 */
export const SCHEDULED_REPORT_TARGET_TYPES = SCHEDULED_REPORT_TARGET_OPTIONS
export { leaveNotificationTargetLabel, scheduledReportTargetLabel }
@@ -1,6 +1,7 @@
import {
LEAVE_NOTIFICATION_DEFAULT_TARGET_TYPE,
normalizeLeaveNotificationRoomIds,
normalizeLeaveNotificationTemplate,
type LeaveNotificationNotifyScope,
type LeaveNotificationTarget
} from '../../shared/automation'
@@ -12,6 +13,11 @@ import {
*
* ## 旧语义(实测自 `group-exit-monitor-service.ts`)
*
* 旧版是**两层**配置,缺一不可:
* 1. **管理群聊**(`monitoredRoomIds`)= 监测哪些群有人退出;
* 2. **通知群聊**(`notificationRoomIds`)= 上面这批群里,**哪些要真的发通知**
* —— 即对第 1 层的二次勾选。
*
* `notificationRoomIds` **不是**"统一通知接收目标列表",而是
* **"哪些被监控群要在自己群里通报成员退出"**(per-group 多值):
* - `applyCurrentMemberships()` 只在 `notificationRoomIds.has(roomId)` 时通知;
@@ -19,10 +25,13 @@ import {
*
* ## 新语义
*
* 单值目标:`source_chat` / `self` / `file_transfer` / `contact`。
* 拆成**两个正交维度**,恰好一一对应旧版那两层:
* - `target` ← 旧版第 2 层里"发回本群"这个事实 ⇒ 恒为 `source_chat`;
* - `notifyScope` / `notifyRoomIds` ← 旧版第 2 层是"哪些群",逐字保留。
*
* ⇒ 两者**只在一种情况下等价**:旧配置把**每一个**被监控群都勾上了通知。
* 严格子集无法表达,按保守策略处理,绝不"随便取第一个群"。
* ⇒ **迁移现在是无损的**。早期版本把目标压成单值、丢掉了第 2 层的"哪些群",
* 于是部分勾选只能判成 `needs_review` 并强制停用;恢复 `notifyScope` 之后
* 这个特例整体消失,用户不会再有"规则被无辜停用"的体验。
*/
export interface LegacyLeaveNotificationState {
@@ -34,30 +43,23 @@ export interface LegacyLeaveNotificationState {
export type LeaveNotificationMigrationOutcome =
/** 旧配置从不通知任何人 ⇒ 迁移成"关闭",行为完全一致。 */
| 'lossless_off'
/** 旧配置在**每个**被监控群里通报 ⇒ 等价于 `source_chat`,行为完全一致。 */
| 'lossless_source_chat'
/** 旧配置只在**部分**群里通报 ⇒ 单值目标无法表达,需用户重选。 */
| 'needs_review'
/** 旧配置在**每个**被监控群里通报 ⇒ `notifyScope: 'all'`,行为完全一致。 */
| 'lossless_all_groups'
/** 旧配置只在**部分**群里通报 ⇒ `notifyScope: 'selected'` + 原样保留那份勾选。 */
| 'lossless_selected_groups'
export interface LeaveNotificationMigrationPlan {
outcome: LeaveNotificationMigrationOutcome
enabled: boolean
target: LeaveNotificationTarget
notifyScope: LeaveNotificationNotifyScope
notifyRoomIds: string[]
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
return normalizeLeaveNotificationRoomIds(values)
}
export function planLeaveNotificationMigration(
@@ -71,36 +73,44 @@ export function planLeaveNotificationMigration(
)
// 模板永远是无损迁移的那一项:它就是用户写的那段文字。
const template = normalizeLeaveNotificationTemplate(legacy.notificationTemplate)
const defaultTarget: LeaveNotificationTarget = { type: LEAVE_NOTIFICATION_DEFAULT_TARGET_TYPE }
// 旧版收件人恒为事件所在群 ⇒ 无论勾了几个群,目标都是 source_chat。
const target: LeaveNotificationTarget = { type: 'source_chat' }
if (!notifications.length) {
// 旧语义是"从不通知" ⇒ 关掉,避免迁移后突然开始发消息。
// 同时把范围写成 `selected` + 空集:配置本身也精确表达"一个都不通知"。
return {
outcome: 'lossless_off',
enabled: false,
target: defaultTarget,
target,
notifyScope: 'selected',
notifyRoomIds: [],
targetNeedsReview: false,
template
}
}
if (monitored.length > 0 && notifications.length === monitored.length) {
// 每个被监控群都在自己群里通报 ⇔ 单值的 `source_chat`。
// 每个被监控群都在自己群里通报 ⇒ 等价于"全部已监控群聊"。
return {
outcome: 'lossless_source_chat',
outcome: 'lossless_all_groups',
enabled: true,
target: { type: 'source_chat' },
target,
notifyScope: 'all',
notifyRoomIds: [],
targetNeedsReview: false,
template
}
}
// 部分勾选:**不能**无损映射。不取第一个群、不改写成当前群聊、保持关闭、要求重选。
// 部分勾选:**无损**保留那份子集,不再降级成 needs_review。
return {
outcome: 'needs_review',
enabled: false,
target: defaultTarget,
targetNeedsReview: true,
outcome: 'lossless_selected_groups',
enabled: true,
target,
notifyScope: 'selected',
notifyRoomIds: notifications,
targetNeedsReview: false,
template
}
}
+51 -132
View File
@@ -4,81 +4,65 @@ import {
type LeaveNotificationConfig
} from '../../shared/automation'
import type { GroupMemberExitedEvent } from '../../shared/group-exit-event'
import { WECHAT_FILE_HELPER_USERNAME, isFileHelperUsername } from '../../shared/wechat-identities'
import {
automationContactDisplayName,
filterSendableFriendContacts as filterSendable,
isSendableFriendContact as isSendable,
resolveAutomationTarget,
type AutomationTargetContact,
type AutomationTargetResolution,
type ResolvedAutomationTarget
} from './automation-wechat-target'
/**
* 退群通知的**目标解析**。
* 退群通知的**目标解析**(薄适配层)。
*
* 纯函数(联系人清单与自身身份由调用方注入),因为这里最容易出错、
* 也最需要被单测穷举:发错人是不可撤销的。
* 真正的判定在 `automation-wechat-target.ts` —— 那里才是**唯一实现**,
* 定时日报与退群通知共用它。这里只有两件事:
* 1. 把 `LeaveNotificationConfig` + 退群事件摊平成中性输入;
* 2. 提供退群通知专属文案,保持既有调用方 / 测试不变。
*
* 四条规则:
* 1. `source_chat` 取**事件所在群**(`event.conversationId`),
* 不是"当前正在看的聊天",也不是"最后一个活跃会话"。
* 2. 目标解析失败 → **报错**,绝不 fallback 到别的目标(尤其不许偷偷发文件助手)。
* 3. 只允许出现在选项表里的目标类型。
* 4. 显示名不带 wxid:日志与界面只出现用户能看懂的称呼。
* ⚠️ 这里**不允许**再出现一份 switch / 判定逻辑 —— 两套并存迟早分叉。
*/
/** 解析所需的最小联系人结构(`FormattedContact` 结构上可赋值给它)。 */
export interface LeaveNotificationCandidateContact {
m_nsUsrName: string
m_nsNickName?: string
type: 'user' | 'group'
isOfficialAccount?: boolean
wechatNickname?: string
remark?: string
}
export type LeaveNotificationCandidateContact = AutomationTargetContact
export interface ResolvedLeaveNotificationTarget {
recipient: { type: 'group' | 'contact'; id: string; name: string }
/** 用户可读的目标名,直接进执行日志的 `detail`。 */
displayName: string
}
export type ResolvedLeaveNotificationTarget = ResolvedAutomationTarget
export type LeaveNotificationTargetResolution =
| { ok: true; target: ResolvedLeaveNotificationTarget }
| { ok: false; error: string }
export type LeaveNotificationTargetResolution = AutomationTargetResolution
/** 退群通知的目标文案(唯一一份)。 */
export const LEAVE_NOTIFICATION_TARGET_MESSAGES = {
options: LEAVE_NOTIFICATION_TARGET_OPTIONS,
invalidTarget: '退群通知的发送目标无效,请重新选择',
sourceMissing: '无法确定发生退群的群聊,本次通知未发送',
sourceRecipientName: '当前群聊',
sourceDisplayName: '发生退群事件的群聊',
selfMissing: '无法确定当前登录的微信账号,本次通知未发送',
contactNotChosen: '还没有选择通知联系人,请重新选择',
contactUnavailable: '通知联系人已不存在或当前无法发送,请重新选择'
} as const
/** 联系人显示名:备注 → 微信昵称 → 会话昵称 → 兜底称呼。**永不回落到 wxid**。 */
export function leaveNotificationContactDisplayName(
contact: LeaveNotificationCandidateContact
): string {
return (
contact.remark?.trim() ||
contact.wechatNickname?.trim() ||
contact.m_nsNickName?.trim() ||
'指定好友'
)
export function leaveNotificationContactDisplayName(contact: AutomationTargetContact): string {
return automationContactDisplayName(contact)
}
/**
* 这个联系人能不能作为「指定好友」的发送目标。
*
* 排除:群聊、公众号(`gh_`)、文件传输助手、自己。
* UI 的联系人选择器与运行时的目标解析**共用这一条判定**,
* 否则会出现"能选但发不出去"。
*/
/** 这个联系人能不能作为「指定好友」的发送目标(与定时日报共用同一条判定)。 */
export function isSendableFriendContact(
contact: LeaveNotificationCandidateContact,
contact: AutomationTargetContact,
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
return isSendable(contact, selfWxid)
}
/** 供 UI 用的「可选好友」过滤(与运行时同一套判定)。 */
export function filterSendableFriendContacts<T extends LeaveNotificationCandidateContact>(
export function filterSendableFriendContacts<T extends AutomationTargetContact>(
contacts: T[],
selfWxid?: string
): T[] {
return contacts.filter((contact) => isSendableFriendContact(contact, selfWxid))
return filterSendable(contacts, selfWxid)
}
export function resolveLeaveNotificationTarget(input: {
@@ -89,84 +73,19 @@ export function resolveLeaveNotificationTarget(input: {
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 (!config) return { ok: false, error: '退群通知尚未配置发送目标' }
return resolveAutomationTarget(
{
targetType: config.target?.type as never,
sourceConversationId: String(input.event.conversationId || '').trim(),
sourceDisplayName: String(input.event.groupName || '').trim(),
...(config.target?.contactId ? { contactId: config.target.contactId } : {}),
contacts: input.contacts,
...(input.selfWxid ? { selfWxid: input.selfWxid } : {})
},
LEAVE_NOTIFICATION_TARGET_MESSAGES
)
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
}
}
}
}
}
/** 目标类型 → 界面短标签(保持既有导出面)。 */
export { leaveNotificationTargetLabel }
@@ -27,8 +27,7 @@ export interface ScheduledReportApiDependencies {
| 'deleteTask'
| 'setTaskEnabled'
| 'runScheduledReportNow'
> &
Partial<Pick<ScheduledReportService, 'retryScheduledReportSend'>>
>
getCapability: () => Promise<PersonalWechatSendCapability>
listContacts: () => FormattedContact[]
isDatabaseReady: () => boolean
@@ -250,16 +249,20 @@ export class ScheduledReportApiService {
return result.data
}
async retrySend(executionId: string): Promise<ScheduledReportExecution> {
const retry = this.deps.service.retryScheduledReportSend
if (!retry) {
throw new ScheduledReportApiError(501, 'not_supported', '当前运行时不支持重新发送日报')
}
const result = await retry.call(this.deps.service, executionId)
if (!result.data) {
throw new ScheduledReportApiError(404, 'not_found', result.error || '未找到定时日报执行记录')
}
return result.data
/**
* 「重新发送」不再支持。
*
* 日报图片归日报历史所有,发送目标由规则配置决定,「把某次执行记录里那张 PNG
* 就地再发一遍」这个语义已经不存在 —— 与其在此伪造一个只对部分规则有效的重发
* 语义,不如**明确返回 501**。
* 用户的替代路径:在日报历史里手动转发,或对该规则点「立即执行」重跑一次。
*/
async retrySend(_executionId: string): Promise<ScheduledReportExecution> {
throw new ScheduledReportApiError(
501,
'not_supported',
'当前运行时不支持重新发送日报;请在 TraceMemo 的自动化页面使用「立即执行」,或从日报历史手动转发。'
)
}
async executions(taskId: string): Promise<ScheduledReportApiExecution[]> {
@@ -0,0 +1,207 @@
import { createHash } from 'node:crypto'
import {
normalizeScheduledReportConfig,
type ScheduledReportAutomationConfig
} from '../../shared/automation'
import type {
ScheduledReportMemberNameMode,
ScheduledReportMessageType,
ScheduledReportRange
} from '../../shared/scheduled-report'
import type { SelectableReportTemplateId } from '../../shared/report-templates'
/**
* 旧「定时日报任务」→ 新「AutomationRule(ruleType='scheduled_report')」的**纯映射器**。
*
* 单独成文件是为了能直接单测:这是整轮迁移里**唯一会改变用户既有行为**的地方,
* 不允许只靠"跑一遍看看"来验证。
*
* ## 旧 target 的真实语义(实测)
*
* `ScheduledReportService.resolveTarget()`:
* - `target` 以 `@chatroom` 结尾 ⇒ **直接发到那个群**(可能是另一个群);
* - 否则 ⇒ **静默回落到来源群**。
*
* ⇒ 历史行为**事实上支持「生成 A 群日报 → 发 B 群」**,也存在一处静默 fallback。
* 新产品明确不支持"指定另一个群聊",所以:
* **能证明同群 → 无损映射成 `source_chat`;不能证明 → 停用 + 要求用户重选**,
* 绝不自动改写、绝不偷偷丢掉目标。
*/
/** 旧任务里迁移真正用得到的字段(其余字段不参与,也不需要读)。 */
export interface LegacyScheduledReportTask {
id?: unknown
name?: unknown
group?: unknown
scheduleTime?: unknown
reportRange?: unknown
messageTypes?: unknown
templateId?: unknown
memberNameMode?: unknown
timeoutSeconds?: unknown
target?: unknown
enabled?: unknown
createdAt?: unknown
updatedAt?: unknown
lastRunAt?: unknown
lastScheduledSlot?: unknown
}
export type ScheduledReportMigrationOutcome =
/** 目标与来源群是同一群 ⇒ 等价于 `source_chat`,行为完全一致。 */
| 'lossless_source_chat'
/** 目标是另一个群 / 无法确认是否同群 ⇒ 单值四选一表达不了,需用户重选。 */
| 'needs_review'
export interface ScheduledReportMigrationPlan {
outcome: ScheduledReportMigrationOutcome
/** 迁移后的规则 id(确定性,可重复执行且不产生新 id)。 */
ruleId: string
name: string
enabled: boolean
config: ScheduledReportAutomationConfig
createdAt: number
updatedAt: number
}
/** 把任意输入收敛成字符串。 */
function asText(value: unknown): string {
return typeof value === 'string' ? value.trim() : String(value ?? '').trim()
}
/**
* 确定性 ruleId。
*
* 优先**原样复用旧 taskId**(旧 id 形如 `scheduled_report_<uuid>`,与自动化现有
* id 空间 —— `randomUUID()` / `builtin-*` —— 不可能冲突)。
* 旧 id 缺失时(文件被手改坏),退化成一个**由稳定字段派生**的 id,
* 保证"重复迁移不会生成新规则"。
*/
export function scheduledReportMigrationRuleId(task: LegacyScheduledReportTask): string {
const id = asText(task.id)
if (id) return id
const digest = createHash('sha1')
.update(
[asText(task.name), asText(task.group), asText(task.scheduleTime), asText(task.target)].join(
'\u0001'
)
)
.digest('hex')
return `scheduled-report:${digest.slice(0, 32)}`
}
/** 旧 `createdAt` / `updatedAt` 是 ISO 字符串;非法时回落到迁移时刻。 */
function parseIso(value: unknown, fallback: number): number {
const text = asText(value)
if (!text) return fallback
const parsed = Date.parse(text)
return Number.isFinite(parsed) ? parsed : fallback
}
/**
* 单条旧任务 → 迁移计划。
*
* `resolveConversationId` 由调用方注入(main 侧是 `chat-service.resolveMd5`,
* 单测里是假的联系人表)。**它拿不到就说明主进程还没准备好**,
* 调用方必须**推迟整批迁移**,而不是用"解析不到"去误判成 needs_review。
*/
export function planScheduledReportMigration(
task: LegacyScheduledReportTask,
resolveConversationId: (raw: string) => string | undefined,
now: number
): ScheduledReportMigrationPlan {
const sourceRaw = asText(task.group)
const targetRaw = asText(task.target)
const sourceResolved = resolveConversationId(sourceRaw)
const targetResolved = resolveConversationId(targetRaw)
/*
* 三种"能证明是同一个群"的情况:
* 1. target 为空 —— 旧 `normalizeInput` 本就把空 target 填成 group;
* 2. 两者文本完全相等 —— 无论能否解析,用户表达的就是同一个东西;
* 3. 两者都能解析且解析结果相同 —— 旧 UI 的常态(group=会话 md5,target=roomId)。
*/
const sameGroup =
!targetRaw || targetRaw === sourceRaw || Boolean(sourceResolved && targetResolved && sourceResolved === targetResolved)
const config = normalizeScheduledReportConfig({
schedule: { time: asText(task.scheduleTime) },
report: {
// 解析得到就用稳定会话 id;解析不到**保留原值** —— 运行期会如实失败,
// 绝不在这里"猜一个群"。
sourceConversationId: sourceResolved ?? sourceRaw,
range: asText(task.reportRange) as ScheduledReportRange,
messageTypes: task.messageTypes as ScheduledReportMessageType[],
templateId: asText(task.templateId) as SelectableReportTemplateId,
memberNameMode: asText(task.memberNameMode) as ScheduledReportMemberNameMode,
timeoutSeconds: Number(task.timeoutSeconds)
},
target: { type: 'source_chat' },
...(sameGroup ? {} : { targetNeedsReview: true }),
// 旧目标原文只作展示;`sameGroup` 时不保留,避免留下看起来像"还生效"的残值。
...(sameGroup || !targetRaw ? {} : { legacyTarget: targetRaw }),
// 来源群被改写过(md5/群名 → roomId)时留下原文,便于用户核对。
...(sourceRaw && sourceResolved && sourceRaw !== sourceResolved
? { legacySourceGroup: sourceRaw }
: {}),
...(asText(task.lastRunAt) ? { lastRunAt: asText(task.lastRunAt) } : {}),
...(asText(task.lastScheduledSlot) ? { lastScheduledSlot: asText(task.lastScheduledSlot) } : {})
})
const name = asText(task.name) || '定时日报'
return {
outcome: sameGroup ? 'lossless_source_chat' : 'needs_review',
ruleId: scheduledReportMigrationRuleId(task),
name,
// 不自动改写、也不自动启用:无法确认目标的规则一律停用。
enabled: sameGroup ? task.enabled !== false : false,
config,
createdAt: parseIso(task.createdAt, now),
updatedAt: parseIso(task.updatedAt, now)
}
}
export interface ScheduledReportMigrationSummary {
total: number
migrated: number
lossless: number
needsReview: number
duplicatesSkipped: number
}
/**
* 整批任务的迁移结果:**按 id 去重**,保证幂等(同一份旧文件跑两次结果一致)。
*/
export function planScheduledReportMigrationBatch(
tasks: LegacyScheduledReportTask[],
resolveConversationId: (raw: string) => string | undefined,
now: number
): { plans: ScheduledReportMigrationPlan[]; summary: ScheduledReportMigrationSummary } {
const plans: ScheduledReportMigrationPlan[] = []
const seen = new Set<string>()
let duplicatesSkipped = 0
let lossless = 0
let needsReview = 0
for (const task of tasks) {
if (!task || typeof task !== 'object') continue
const plan = planScheduledReportMigration(task, resolveConversationId, now)
if (seen.has(plan.ruleId)) {
duplicatesSkipped += 1
continue
}
seen.add(plan.ruleId)
plans.push(plan)
if (plan.outcome === 'lossless_source_chat') lossless += 1
else needsReview += 1
}
return {
plans,
summary: {
total: plans.length + duplicatesSkipped,
migrated: plans.length,
lossless,
needsReview,
duplicatesSkipped
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,81 @@
import {
SCHEDULED_REPORT_TARGET_OPTIONS,
scheduledReportTargetLabel,
type ScheduledReportAutomationConfig
} from '../../shared/automation'
import {
resolveAutomationTarget,
resolveGroupConversationId,
resolveGroupDisplayName,
type AutomationTargetContact,
type AutomationTargetResolution
} from './automation-wechat-target'
/**
* 定时日报的**目标解析**(薄适配层)。
*
* 判定逻辑在 `automation-wechat-target.ts`,与退群通知**共用同一份**。
* 差异只有一处:来源会话不是"事件所在群",而是**这条规则配置的日报来源群**。
*/
/** 定时日报的目标文案(唯一一份)。 */
export const SCHEDULED_REPORT_TARGET_MESSAGES = {
options: SCHEDULED_REPORT_TARGET_OPTIONS,
invalidTarget: '定时日报的发送目标无效,请重新选择',
sourceMissing: '无法确定日报来源群,本次日报未发送',
sourceRecipientName: '日报来源群',
sourceDisplayName: '日报来源群',
selfMissing: '无法确定当前登录的微信账号,本次日报未发送',
contactNotChosen: '还没有选择发送联系人,请重新选择',
contactUnavailable: '发送联系人已不存在或当前无法发送,请重新选择'
} as const
export function resolveScheduledReportTarget(input: {
config: ScheduledReportAutomationConfig | undefined
contacts: AutomationTargetContact[]
/** 当前登录账号的 wxid;拿不到时传空串。 */
selfWxid?: string
}): AutomationTargetResolution {
const config = input.config
if (!config) return { ok: false, error: '定时日报尚未配置发送目标' }
const sourceConversationId = String(config.report?.sourceConversationId || '').trim()
return resolveAutomationTarget(
{
targetType: config.target?.type as never,
sourceConversationId,
// 来源群的可读名从通讯录解析;解析不到时由中性层给兜底称呼。
sourceDisplayName: resolveGroupDisplayName(sourceConversationId, input.contacts),
...(config.target?.contactId ? { contactId: config.target.contactId } : {}),
contacts: input.contacts,
...(input.selfWxid ? { selfWxid: input.selfWxid } : {})
},
SCHEDULED_REPORT_TARGET_MESSAGES
)
}
/**
* 日报来源群的可读显示名(执行日志的 `sourceDisplayName` 用它)。
*
* **绝不回落到裸 id**:解析不到就说「日报来源群」。
*/
export function scheduledReportSourceDisplayName(
config: ScheduledReportAutomationConfig | undefined,
contacts: AutomationTargetContact[]
): string {
const raw = String(config?.report?.sourceConversationId || '').trim()
if (!raw) return '日报来源群'
return resolveGroupDisplayName(raw, contacts) || '日报来源群'
}
/** 日报来源群解析成稳定会话 id(执行前预检用;解析不到返回 undefined)。 */
export function scheduledReportSourceConversationId(
config: ScheduledReportAutomationConfig | undefined,
contacts: AutomationTargetContact[]
): string | undefined {
return resolveGroupConversationId(
String(config?.report?.sourceConversationId || ''),
contacts
)
}
export { scheduledReportTargetLabel }
@@ -32,6 +32,12 @@ const AUTOMATION_PURPOSE_ALLOWLIST = new Set([
// 退群通知(由 Automation 发出)。目标可以是群 / 自己 / 文件传输助手 / 指定好友,
// 所以**不绑定**任何 "只能发回原群" 的作用域锁。
'automation_leave_notification',
// 定时日报(由 Automation 的 `scheduled_report` 规则发出)。目标是四选一,
// 同样**不绑定**任何作用域锁。
'automation_scheduled_report',
// 定时日报的「后置词」(图片 sent 之后补发的那条文本)。与图片是两个独立
// purpose,各自有幂等位 —— 少这一条会被 evaluateWechatActionPolicy 拦下。
'automation_scheduled_report_postfix',
'manual_report_image',
'manual_report_postfix'
])
+17 -29
View File
@@ -84,13 +84,12 @@ import type {
import type { PersonalWechatSendCapability } from '../shared/personal-wechat'
import type { GroupMemberStatsQuery, GroupMemberStatsResult } from '../shared/group-stats'
import type {
ScheduledReportCreateInput,
ScheduledReportExecution,
ScheduledReportNotification,
ScheduledReportNotificationCapability,
ScheduledReportNotificationSettings,
ScheduledReportNotificationSettingsResult,
ScheduledReportResult,
ScheduledReportTask,
ScheduledReportUpdateInput
ScheduledReportResult
} from '../shared/scheduled-report'
import type {
PersonalWechatVoiceEncodingEnvironment,
@@ -102,7 +101,8 @@ import type {
AutomationExecution,
AutomationRule,
AutomationRuleDraft,
AutomationStatusSummary
AutomationStatusSummary,
ScheduledRuleRunOutcome
} from '../shared/automation'
import type { ActionLogEntry } from '../shared/action-log'
import type {
@@ -733,35 +733,23 @@ declare global {
sendPersonalWechatMessage: (
request: PersonalWechatSendRequest
) => Promise<PersonalWechatSendResult>
listScheduledReports: () => Promise<ScheduledReportTask[]>
listScheduledReportExecutions: (taskId?: string) => Promise<ScheduledReportExecution[]>
/** 定时日报:「立即执行」——与 scheduler 走同一条执行链路(manual trigger)。 */
runScheduledReportRule: (
ruleId: string
) => Promise<{ success: boolean; error?: string; data?: ScheduledRuleRunOutcome }>
/** 定时日报:旧执行记录**只读存档**(旧记录无法无损转换,原样展示)。 */
listScheduledReportLegacyExecutions: (
ruleId?: string
) => Promise<ScheduledReportExecution[]>
/** 定时日报:微信异常通知开关与能力检测。 */
getScheduledReportNotificationSettings: () => Promise<ScheduledReportNotificationSettings>
getScheduledReportNotificationCapability: () => Promise<ScheduledReportNotificationCapability>
setScheduledReportNotificationEnabled: (
enabled: boolean
) => Promise<ScheduledReportNotificationSettingsResult>
createScheduledReport: (
request: ScheduledReportCreateInput
) => Promise<ScheduledReportResult<ScheduledReportTask>>
updateScheduledReport: (
taskId: string,
request: ScheduledReportUpdateInput
) => Promise<ScheduledReportResult<ScheduledReportTask>>
deleteScheduledReport: (
taskId: string
) => Promise<ScheduledReportResult<{ deletedId: string }>>
setScheduledReportEnabled: (
taskId: string,
enabled: boolean
) => Promise<ScheduledReportResult<ScheduledReportTask>>
runScheduledReportNow: (
taskId: string
) => Promise<ScheduledReportResult<ScheduledReportExecution>>
retryScheduledReportSend: (
executionId: string
) => Promise<ScheduledReportResult<ScheduledReportExecution>>
testScheduledReportErrorNotification: (
taskId: string
) => Promise<ScheduledReportResult<ScheduledReportExecution>>
ruleId: string
) => Promise<ScheduledReportResult<ScheduledReportNotification>>
getPersonalWechatVoiceDiagnostic: () => Promise<PersonalWechatVoiceDiagnostic | null>
getAgentHubStatus: () => Promise<AgentHubStatus>
getAgentHubLogs: () => Promise<AgentHubLogEntry[]>
+24 -38
View File
@@ -52,13 +52,12 @@ import type {
} from '../shared/personal-wechat'
import type { PersonalWechatSendCapability } from '../shared/personal-wechat'
import type {
ScheduledReportCreateInput,
ScheduledReportExecution,
ScheduledReportNotification,
ScheduledReportNotificationCapability,
ScheduledReportNotificationSettings,
ScheduledReportNotificationSettingsResult,
ScheduledReportResult,
ScheduledReportTask,
ScheduledReportUpdateInput
ScheduledReportResult
} from '../shared/scheduled-report'
import type {
PersonalWechatVoiceEncodingEnvironment,
@@ -71,7 +70,8 @@ import type {
AutomationExecution,
AutomationRule,
AutomationRuleDraft,
AutomationStatusSummary
AutomationStatusSummary,
ScheduledRuleRunOutcome
} from '../shared/automation'
import type { GroupMemberStatsQuery, GroupMemberStatsResult } from '../shared/group-stats'
import type { ActionLogEntry } from '../shared/action-log'
@@ -572,44 +572,30 @@ const api = {
sendPersonalWechatMessage: (
request: PersonalWechatSendRequest
): Promise<PersonalWechatSendResult> => ipcRenderer.invoke('wechat-personal:send', request),
listScheduledReports: (): Promise<ScheduledReportTask[]> =>
ipcRenderer.invoke('scheduled-report:list'),
listScheduledReportExecutions: (taskId?: string): Promise<ScheduledReportExecution[]> =>
ipcRenderer.invoke('scheduled-report:listExecutions', taskId),
/**
* 定时日报(Automation 的 `scheduled_report` 规则类型)。
*
* 规则 CRUD 复用上面的 `automation:*` 通道;这里只暴露三块专属能力:
* 立即执行、微信异常通知、旧执行记录只读存档。
*/
runScheduledReportRule: (
ruleId: string
): Promise<{ success: boolean; error?: string; data?: ScheduledRuleRunOutcome }> =>
ipcRenderer.invoke('automation:runScheduledReportRule', ruleId),
listScheduledReportLegacyExecutions: (ruleId?: string): Promise<ScheduledReportExecution[]> =>
ipcRenderer.invoke('automation:listScheduledReportLegacyExecutions', ruleId),
getScheduledReportNotificationSettings: (): Promise<ScheduledReportNotificationSettings> =>
ipcRenderer.invoke('scheduled-report:getNotificationSettings'),
ipcRenderer.invoke('automation:getScheduledReportNotificationSettings'),
getScheduledReportNotificationCapability: (): Promise<ScheduledReportNotificationCapability> =>
ipcRenderer.invoke('automation:getScheduledReportNotificationCapability'),
setScheduledReportNotificationEnabled: (
enabled: boolean
): Promise<ScheduledReportNotificationSettingsResult> =>
ipcRenderer.invoke('scheduled-report:setNotificationEnabled', enabled),
createScheduledReport: (
request: ScheduledReportCreateInput
): Promise<ScheduledReportResult<ScheduledReportTask>> =>
ipcRenderer.invoke('scheduled-report:create', request),
updateScheduledReport: (
taskId: string,
request: ScheduledReportUpdateInput
): Promise<ScheduledReportResult<ScheduledReportTask>> =>
ipcRenderer.invoke('scheduled-report:update', taskId, request),
deleteScheduledReport: (taskId: string): Promise<ScheduledReportResult<{ deletedId: string }>> =>
ipcRenderer.invoke('scheduled-report:delete', taskId),
setScheduledReportEnabled: (
taskId: string,
enabled: boolean
): Promise<ScheduledReportResult<ScheduledReportTask>> =>
ipcRenderer.invoke('scheduled-report:setEnabled', taskId, enabled),
runScheduledReportNow: (
taskId: string
): Promise<ScheduledReportResult<ScheduledReportExecution>> =>
ipcRenderer.invoke('scheduled-report:runNow', taskId),
retryScheduledReportSend: (
executionId: string
): Promise<ScheduledReportResult<ScheduledReportExecution>> =>
ipcRenderer.invoke('scheduled-report:retrySend', executionId),
ipcRenderer.invoke('automation:setScheduledReportNotificationEnabled', enabled),
testScheduledReportErrorNotification: (
taskId: string
): Promise<ScheduledReportResult<ScheduledReportExecution>> =>
ipcRenderer.invoke('scheduled-report:testErrorNotification', taskId),
ruleId: string
): Promise<ScheduledReportResult<ScheduledReportNotification>> =>
ipcRenderer.invoke('automation:testScheduledReportErrorNotification', ruleId),
getPersonalWechatVoiceDiagnostic: (): Promise<PersonalWechatVoiceDiagnostic | null> =>
ipcRenderer.invoke('wechat-personal:getVoiceDiagnostic'),
getAgentHubStatus: () => ipcRenderer.invoke('agent-hub:getStatus'),
+22 -34
View File
@@ -18,7 +18,6 @@ import { ReportInfoPanel } from './components/reports/ReportInfoPanel'
import { ReportSourceSidebar } from './components/reports/ReportSourceSidebar'
import { ReportTaskStatusPanel } from './components/reports/ReportTaskStatusPanel'
import { ReportViewer } from './components/reports/ReportViewer'
import { ScheduledReportsWorkspace } from './components/reports/ScheduledReportsWorkspace'
import { ReportTemplateMarketWorkspace } from './components/reports/ReportTemplateMarketWorkspace'
import { contactDisplayName } from './components/reports/types'
import type { GeneratedReportRecord, ReportWorkspaceView } from './components/reports/types'
@@ -42,8 +41,8 @@ import { isRelevantMessageMonitorEvent, parseWcdbMonitorEvent } from './utils/me
import { enrichQuotedMessages } from './utils/quoted-messages'
import type { ReportTemplateSelectionId } from '../../shared/report-templates'
import { switchGeneratedReportTemplate } from './utils/report-template-switch'
import { runtimePlatform, supportsPersonalWechatSend } from './utils/runtime-environment'
import { useToast } from './components/ui'
import { runtimePlatform } from './utils/runtime-environment'
import { Button, useToast } from './components/ui'
import { AppUpdatePrompt } from './features/app-update/AppUpdatePrompt'
import { GroupExitMonitorWorkspace, type GroupExitMonitorOpenViewRequest } from './features/group-exit-monitor/GroupExitMonitorWorkspace'
import { AutomationWorkspace, type AutomationOpenRuleRequest } from './features/automation/AutomationWorkspace'
@@ -280,7 +279,7 @@ function App(): React.ReactElement {
const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('account-database')
const [reportSourceContact, setReportSourceContact] = useState<Contact | null>(null)
const [reportWorkspaceView, setReportWorkspaceView] = useState<ReportWorkspaceView>('result')
const [reportSection, setReportSection] = useState<'today' | 'scheduled' | 'market'>('today')
const [reportSection, setReportSection] = useState<'today' | 'market'>('today')
const [generatedReports, setGeneratedReports] = useState<GeneratedReportRecord[]>([])
const [selectedReportId, setSelectedReportId] = useState<string | null>(null)
const [latestGeneratedReportId, setLatestGeneratedReportId] = useState<string | null>(null)
@@ -1678,9 +1677,10 @@ function App(): React.ReactElement {
}
/**
* 「日报 → 定时日报 → 在自动化中配置」。
* 「定时日报」已正式迁入自动化,日报页只保留「今日日报 | 社区模板市场」两个 Tab。
*
* 纯导航:直接落到「自动化 → 规则 → 定时日报」的规则列表。
* 这里仍然留一个导航入口:用户是从日报页产生"要定时发日报"这个念头的,
* 不给路会显得功能被删了。它**不是**第三个 Tab,只是一句去处的说明。
*/
const openScheduledReportAutomation = (): void => {
setAutomationOpenRuleRequest({ ruleType: 'scheduled_report', requestId: Date.now() })
@@ -1977,22 +1977,6 @@ function App(): React.ReactElement {
>
今日日报
</button>
<button
type="button"
role="tab"
aria-selected={reportSection === 'scheduled'}
aria-disabled={!supportsPersonalWechatSend}
className={`${reportSection === 'scheduled' ? 'active' : ''} ${!supportsPersonalWechatSend ? 'unsupported' : ''}`}
onClick={() => {
if (!supportsPersonalWechatSend) {
toast({ description: '定时日报目前仅支持 macOS 和 Windows。', duration: 3200 })
return
}
setReportSection('scheduled')
}}
>
定时日报{!supportsPersonalWechatSend && <small>仅 macOS / Windows</small>}
</button>
<button
type="button"
role="tab"
@@ -2003,24 +1987,26 @@ function App(): React.ReactElement {
社区模板市场
</button>
</div>
{/*
「定时日报已并入自动化」的指引条。
这里以前是裸 `<p>` + 裸 `<button>`,并且挂了 `.report-workspace-hint` /
`.report-workspace-hint-link` 两个**样式表里根本不存在**的类 ——
于是就成了一条没样式的文字 + 一个长得不像系统里任何按钮的按钮。
现在补上真实样式,按钮统一走 UI 组件库。
*/}
<div className="report-workspace-hint">
<span>需要「定时日报」?它已经并入「自动化」,可以按时间自动生成并发送日报。</span>
<Button variant="link" size="sm" onClick={openScheduledReportAutomation}>
去自动化配置 →
</Button>
</div>
<div className="report-workspace-body">
{reportSection === 'market' ? (
<ReportTemplateMarketWorkspace
value={reportGeneration.templateId}
onChange={reportGeneration.setTemplateId}
/>
) : reportSection === 'scheduled' ? (
<ScheduledReportsWorkspace
contacts={contacts}
platformSupported={supportsPersonalWechatSend}
onOpenWechatSettings={openWechatSendSettings}
onOpenAgentHub={openAgentHub}
onOpenModelSettings={openModelSettings}
onNotice={(message, variant) =>
toast({ description: message, variant, duration: 3200 })
}
onOpenAutomation={openScheduledReportAutomation}
/>
) : reportWorkspaceView === 'result' ? (
<div className="report-center-page">
<ReportHistorySidebar
@@ -2137,6 +2123,7 @@ function App(): React.ReactElement {
contacts={contacts}
onOpenSendSettings={openWechatSendSettings}
openViewRequest={exitMonitorOpenViewRequest}
onOpenViewRequestHandled={() => setExitMonitorOpenViewRequest(null)}
onOpenLeaveNotificationAutomation={openLeaveNotificationAutomation}
/>
)
@@ -2148,6 +2135,7 @@ function App(): React.ReactElement {
onOpenExitMonitorGroups={openExitMonitorGroups}
openRuleRequest={automationOpenRuleRequest}
onOpenModelSettings={openModelSettings}
onOpenAgentHub={openAgentHub}
/>
)
case 'agent-hub':
File diff suppressed because it is too large Load Diff
@@ -5,11 +5,10 @@ import { AUTOMATION_RULE_TYPE_LABELS, type AutomationRuleType } from '../../../.
/**
* Tab 上能出现的规则类型。
*
* `scheduled_report` **只存在于这一层**:真实 `AutomationRule.ruleType`
* 仍然只有 `daily_report` / `leave_notification`
* —— 不往真实 schema 里塞一个还不存在的类型。
* `scheduled_report` 是真实的 `AutomationRuleType`;这个别名只为让调用点的
* 语义更清楚 —— 它等价于 `AutomationRuleType`。
*/
export type AutomationRuleTabType = AutomationRuleType | 'scheduled_report'
export type AutomationRuleTabType = AutomationRuleType
/**
* Tab 顺序 = 触发方式的演进:消息触发 → 时间触发 → 系统事件触发。
@@ -20,11 +19,7 @@ const RULE_TABS: AutomationRuleTabType[] = [
'leave_notification'
]
const TAB_LABELS: Record<AutomationRuleTabType, string> = {
daily_report: AUTOMATION_RULE_TYPE_LABELS.daily_report,
scheduled_report: '定时日报',
leave_notification: AUTOMATION_RULE_TYPE_LABELS.leave_notification
}
const TAB_LABELS: Record<AutomationRuleTabType, string> = AUTOMATION_RULE_TYPE_LABELS
/**
* AutomationRuleTypeTabs —— 编辑器上方的**规则类型切换**。
@@ -2,6 +2,8 @@ import * as React from 'react'
import {
BUILTIN_DAILY_REPORT_RULE_ID,
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
calculateNextRunAt,
normalizeScheduledReportConfig,
type AutomationExecution,
type AutomationRule,
type AutomationRuleDraft,
@@ -24,15 +26,15 @@ import {
} from './AutomationRuleTypeTabs'
import { ScheduledReportEditor } from './ScheduledReportEditor'
import { ScheduledReportRuleList } from './ScheduledReportRuleList'
import type { ScheduledReportTask } from '../../../../shared/scheduled-report'
import { ScheduledReportNotificationPanel } from './ScheduledReportNotificationPanel'
import {
createDraftFromRule,
createEmptyScheduledReportDraft,
createDraftFromTask,
draftToAutomationRuleDraft,
formatNextRunAt,
loadScheduledReportTasks,
resolveGroupDisplayName,
type ScheduledReportDraft
} from './model/scheduled-report-preview'
} from './model/scheduled-report-model'
import {
LeaveNotificationEditor,
type LeaveNotificationSaveInput
@@ -68,6 +70,8 @@ export interface AutomationWorkspaceProps {
onOpenExitMonitorGroups?: () => void
/** 「更改模型」:复用现有的模型设置入口(定时日报的模型配置)。 */
onOpenModelSettings?: () => void
/** 「微信异常通知」依赖 Agent Hub:未就绪时给一个可操作的去处。 */
onOpenAgentHub?: () => void
/** 深链请求(来自退群监控页的「退群通知自动化」入口)。 */
openRuleRequest?: AutomationOpenRuleRequest | null
}
@@ -84,6 +88,7 @@ export function AutomationWorkspace({
onOpenSendSettings,
onOpenExitMonitorGroups,
onOpenModelSettings,
onOpenAgentHub,
openRuleRequest
}: AutomationWorkspaceProps): React.ReactElement {
const { toast } = useToast()
@@ -96,6 +101,8 @@ export function AutomationWorkspace({
const [executions, setExecutions] = React.useState<AutomationExecution[]>([])
const [sendableContacts, setSendableContacts] = React.useState<LeaveNotificationContactOption[]>([])
const [monitoredGroupCount, setMonitoredGroupCount] = React.useState(0)
/** 已监控群聊清单(含显示名)—— 退群通知「通知群聊」二次勾选的候选项。 */
const [monitoredGroups, setMonitoredGroups] = React.useState<AutomationGroupOption[]>([])
const [loading, setLoading] = React.useState(true)
const [clearing, setClearing] = React.useState(false)
const [saving, setSaving] = React.useState(false)
@@ -105,22 +112,31 @@ export function AutomationWorkspace({
const [pendingDelete, setPendingDelete] = React.useState<AutomationRule | null>(null)
const reload = React.useCallback(async (): Promise<void> => {
const [nextStatus, nextRules, nextGroups, nextExecutions, nextContacts, nextMonitored] =
await Promise.all([
automationApi.getStatus(),
automationApi.listRules(),
automationApi.listGroups(),
automationApi.listExecutions(100),
// 「指定好友」候选与已监控群聊数量:都来自既有能力,不在自动化里另存一份。
automationApi.listSendableContacts(),
automationApi.getMonitoredGroupCount()
])
const [
nextStatus,
nextRules,
nextGroups,
nextExecutions,
nextContacts,
nextMonitored,
nextMonitoredGroups
] = await Promise.all([
automationApi.getStatus(),
automationApi.listRules(),
automationApi.listGroups(),
automationApi.listExecutions(100),
// 「指定好友」候选、已监控群聊数量与清单:都来自既有能力,不在自动化里另存一份。
automationApi.listSendableContacts(),
automationApi.getMonitoredGroupCount(),
automationApi.getMonitoredGroups()
])
setStatus(nextStatus)
setRules(nextRules)
setGroups(nextGroups)
setExecutions(nextExecutions)
setSendableContacts(nextContacts)
setMonitoredGroupCount(nextMonitored)
setMonitoredGroups(nextMonitoredGroups)
}, [])
React.useEffect(() => {
@@ -195,15 +211,19 @@ export function AutomationWorkspace({
}, [rules])
/*
* ---------- 定时日报(只读)----------
* ---------- 定时日报(真实规则,真写入)----------
*
* 数据是**只读**拿到的真实任务;所有写操作(保存 / 立即执行 / 启停 / 删除)
* 一律不落盘、不调 scheduler、不生成日报。详见 `model/scheduled-report-preview.ts`。
* `scheduled_report` 是真实的 `AutomationRuleType`,所以这一层
* **不做任何数据副本**:列表 = `rules.filter(ruleType==='scheduled_report')`,
* 保存 / 启停 / 删除 / 立即执行全部经 `automation:*` IPC 落到
* `AutomationRuleStore` 与 `AutomationService`。
*/
const [scheduledTasks, setScheduledTasks] = React.useState<ScheduledReportTask[]>([])
const [scheduledLoading, setScheduledLoading] = React.useState(true)
/** `null` = 看列表;非空 = 在编辑器里(`taskId` 为 `null` 表示新建)。 */
const [scheduledEditor, setScheduledEditor] = React.useState<{ taskId: string | null } | null>(
const scheduledRules = React.useMemo(
() => rules.filter((rule) => rule.ruleType === 'scheduled_report'),
[rules]
)
/** `null` = 看列表;非空 = 在编辑器里(`ruleId` 为 `null` 表示新建)。 */
const [scheduledEditor, setScheduledEditor] = React.useState<{ ruleId: string | null } | null>(
null
)
const [scheduledDraft, setScheduledDraft] = React.useState<ScheduledReportDraft | null>(null)
@@ -218,47 +238,35 @@ export function AutomationWorkspace({
return dailyReportTarget?.name ?? '新建自动化'
}, [editor, leaveNotificationRule, dailyReportTarget, scheduledDraft])
React.useEffect(() => {
let disposed = false
setScheduledLoading(true)
void loadScheduledReportTasks()
.then((tasks) => {
if (!disposed) setScheduledTasks(tasks)
})
.finally(() => {
if (!disposed) setScheduledLoading(false)
})
return () => {
disposed = true
}
}, [])
/** 首页汇总卡:数量 + 最近一次执行时间(加载中先不显示,避免闪一个"0 条")。 */
/**
* 首页汇总卡:数量 / 运行中 / 最近一次「下次执行」。
* 数据直接来自真实规则 —— 加载中不显示(避免闪一个假的"0 条")。
*/
const scheduledSummary = React.useMemo(() => {
if (scheduledLoading) return null
const running = scheduledTasks.filter((task) => task.enabled)
const nextRunAt = running
.map((task) => task.nextRunAt)
.filter((value) => Boolean(value))
.sort()[0]
if (loading) return null
const now = new Date()
const running = scheduledRules.filter((rule) => rule.enabled)
const nextRuns = running
.map((rule) => normalizeScheduledReportConfig(rule.scheduledReport))
.filter((config) => !config.targetNeedsReview)
.map((config) => calculateNextRunAt(config.schedule.time, now))
.sort()
return {
total: scheduledTasks.length,
total: scheduledRules.length,
running: running.length,
nextRunLabel: nextRunAt ? formatNextRunAt(nextRunAt) : '—'
nextRunLabel: nextRuns.length ? formatNextRunAt(nextRuns[0]) : '—'
}
}, [scheduledTasks, scheduledLoading])
}, [scheduledRules, loading])
/** 群标识 → 显示名(列表与预览共用,保证任何地方都不出现裸 id)。 */
/** 群标识 → 显示名(列表与编辑器共用,保证任何地方都不出现裸 id)。 */
const resolveScheduledGroupDisplay = React.useCallback(
(raw: string): string => resolveGroupDisplayName(raw, groups),
[groups]
)
const PREVIEW_NOTICE = 'UI 预览模式,配置暂未保存'
const openScheduledEditor = (task: ScheduledReportTask | null): void => {
setScheduledDraft(task ? createDraftFromTask(task) : createEmptyScheduledReportDraft())
setScheduledEditor({ taskId: task?.id ?? null })
const openScheduledEditor = (rule: AutomationRule | null): void => {
setScheduledDraft(rule ? createDraftFromRule(rule) : createEmptyScheduledReportDraft())
setScheduledEditor({ ruleId: rule?.id ?? null })
}
const closeScheduledEditor = (): void => {
@@ -266,28 +274,77 @@ export function AutomationWorkspace({
setScheduledDraft(null)
}
/** 保存:只提示,绝不 create/update。 */
const handleScheduledSave = (): void => {
toast({ description: PREVIEW_NOTICE, duration: 3200 })
closeScheduledEditor()
}
/** 立即执行:不碰 scheduler / 生成 / 发送。 */
const handleScheduledRunNow = (_task: ScheduledReportTask): void => {
toast({ description: 'UI 预览模式,不会执行真实日报任务', duration: 3200 })
}
/** 启停:只改本地列表,不写 store。 */
const handleScheduledToggle = (task: ScheduledReportTask, enabled: boolean): void => {
setScheduledTasks((current) =>
current.map((item) => (item.id === task.id ? { ...item, enabled } : item))
/** 保存:**真的**创建 / 更新一条 `scheduled_report` 规则。 */
const handleScheduledSave = async (draft: ScheduledReportDraft): Promise<void> => {
if (!scheduledEditor) return
const target = scheduledEditor.ruleId
? (scheduledRules.find((rule) => rule.id === scheduledEditor.ruleId) ?? null)
: null
// 编辑态但目标不存在 ⇒ 报错,绝不静默新建(与 @我日报同一条红线)。
if (scheduledEditor.ruleId && !target) {
toast({
description: '要编辑的定时日报已不存在,请返回列表重新打开',
variant: 'destructive',
duration: 3600
})
return
}
setSaving(true)
const payload = draftToAutomationRuleDraft(draft)
const saved =
scheduledEditor.ruleId && target
? await automationApi.updateRule(target.id, payload)
: await automationApi.createRule(payload)
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]
)
toast({ description: PREVIEW_NOTICE, duration: 3200 })
closeScheduledEditor()
toast({
description: scheduledEditor.ruleId ? `已保存「${saved.name}」` : `已创建「${saved.name}」`,
duration: 2800
})
void automationApi.getStatus().then(setStatus)
}
/** 删除:直接提示,避免用户以为任务真被删了。 */
const handleScheduledDelete = (_task: ScheduledReportTask): void => {
toast({ description: 'UI 预览模式,不会删除现有任务', duration: 3200 })
/** 立即执行:走与 scheduler 完全相同的执行链路(manual trigger)。 */
const handleScheduledRunNow = async (rule: AutomationRule): Promise<void> => {
setBusyRuleId(rule.id)
const result = await automationApi.runScheduledReportRule(rule.id)
setBusyRuleId(null)
toast({
description: result.success
? `「${rule.name}」已执行完成`
: result.error || '定时日报执行失败,请查看执行日志',
variant: result.success ? undefined : 'destructive',
duration: 3600
})
// 执行结果落在 Automation Execution Log,切到日志 tab 就能看到。
void reload()
}
/** 启停:真写 store。 */
const handleScheduledToggle = async (rule: AutomationRule, enabled: boolean): Promise<void> => {
setBusyRuleId(rule.id)
const updated = await automationApi.setRuleEnabled(rule.id, enabled)
setBusyRuleId(null)
if (!updated) {
toast({ description: '切换失败,规则状态未改变', variant: 'destructive', duration: 3200 })
return
}
setRules((current) => current.map((item) => (item.id === updated.id ? updated : item)))
void automationApi.getStatus().then(setStatus)
}
/** 删除:走统一的确认弹层(与其它规则类型一致)。 */
const handleScheduledDelete = (rule: AutomationRule): void => {
setPendingDelete(rule)
}
const handleToggle = async (rule: AutomationRule, enabled: boolean): Promise<void> => {
@@ -563,27 +620,45 @@ export function AutomationWorkspace({
) : editor.ruleType === 'scheduled_report' ? (
scheduledEditor && scheduledDraft ? (
<ScheduledReportEditor
// 换一条任务就重建编辑器,避免把上一条的草稿带过来。
key={scheduledEditor.taskId ?? 'new'}
// 换一条规则就重建编辑器,避免把上一条的草稿带过来。
key={scheduledEditor.ruleId ?? 'new'}
initialDraft={scheduledDraft}
groups={groups}
sendableContacts={sendableContacts}
saving={saving}
onBack={closeScheduledEditor}
onCancel={closeScheduledEditor}
onSave={handleScheduledSave}
onSave={(draft) => void handleScheduledSave(draft)}
{...(onOpenModelSettings ? { onOpenModelSettings } : {})}
{...(scheduledEditor.ruleId
? (() => {
const rule = scheduledRules.find(
(item) => item.id === scheduledEditor.ruleId
)
const legacy = rule?.scheduledReport?.legacyTarget
return legacy ? { legacyTarget: legacy } : {}
})()
: {})}
notificationSlot={
<ScheduledReportNotificationPanel
rule={scheduledRules.find((item) => item.id === scheduledEditor.ruleId) ?? null}
{...(onOpenAgentHub ? { onOpenAgentHub } : {})}
/>
}
/>
) : (
<ScheduledReportRuleList
tasks={scheduledTasks}
loading={scheduledLoading}
busyTaskId={null}
rules={scheduledRules}
loading={loading}
busyRuleId={busyRuleId}
resolveGroupDisplay={resolveScheduledGroupDisplay}
// 与另外两个类型的「取消」等价:退出本类型,回到规则面板。
// 少了这一个,用户在定时日报列表上就只能靠切 tab 绕出去。
onBack={() => setEditor(null)}
onCreate={() => openScheduledEditor(null)}
onEdit={openScheduledEditor}
onRunNow={handleScheduledRunNow}
onToggle={handleScheduledToggle}
onRunNow={(rule) => void handleScheduledRunNow(rule)}
onToggle={(rule, enabled) => void handleScheduledToggle(rule, enabled)}
onDelete={handleScheduledDelete}
/>
)
@@ -592,11 +667,12 @@ export function AutomationWorkspace({
rule={leaveNotificationRule}
contacts={sendableContacts}
monitoredCount={monitoredGroupCount}
monitoredGroups={monitoredGroups}
saving={saving}
{...(capabilityReady
? {}
: {
// §「规则仍然可以保存」:只提示,不阻断编辑。
// 只提示,不阻断编辑。
sendCapabilityWarning:
'当前发送能力未就绪:退群事件仍会被记录,但通知发送会失败。'
})}
@@ -1,14 +1,17 @@
import * as React from 'react'
import { Button, RadioGroup, RadioGroupItem, Switch } from '../../components/ui'
import { Button, Input, RadioGroup, RadioGroupItem, Switch } from '../../components/ui'
import {
LEAVE_NOTIFICATION_TARGET_OPTIONS,
createDefaultLeaveNotificationRule,
describeLeaveNotificationNotifyScope,
normalizeLeaveNotificationConfig,
type AutomationRule,
type LeaveNotificationConfig,
type LeaveNotificationNotifyScope,
type LeaveNotificationTargetType
} from '../../../../shared/automation'
import { insertGroupNamePlaceholder } from '../../../../shared/group-exit-monitor'
import { LeaveNotificationPreview } from './LeaveNotificationPreview'
import type { AutomationGroupOption } from './model/api'
import {
LeaveNotificationTargetPicker,
type LeaveNotificationContactOption
@@ -21,9 +24,12 @@ import { LeaveNotificationTemplateEditor } from './LeaveNotificationTemplateEdit
* 这是**真实配置页**:保存会落盘成一条 singleton 规则(`builtin-leave-notification`),
* 由 `AutomationService.handleGroupExit` 在退群事件到来时执行。
*
* 职责边界在 UI 上一眼可见:
* 退群监控 = 监测哪些群有人退出(**范围完全由它管**);自动化 = 检测到之后发到哪。
* 所以这里**没有**「触发范围」二次筛选,规则级启停也**只有**顶部一处。
* 职责边界在 UI 上一眼可见,是**两层**而不是一层:
* 退群监控 = 监测哪些群有人退出(范围由它管);
* 本规则 = ① 这批群里**哪些要通知**(二次勾选);② 通知**发到哪**。
*
* ① 就是旧版「退群监控 → 通知群聊」的那份逐群勾选。它曾在自动化迁移里被压成单值目标而丢失,
* 导致"规则一启用就全量通知";`notifyScope` 把它恢复回来,所以这里**有**勾选控件。
*/
export interface LeaveNotificationSaveInput {
@@ -38,6 +44,8 @@ export interface LeaveNotificationEditorProps {
contacts: LeaveNotificationContactOption[]
/** 真实已监控群聊数量(来自退群监控,自动化不维护副本)。 */
monitoredCount: number
/** 真实已监控群聊清单 —— 二次勾选的候选项,与 `monitoredCount` 同一次取数。 */
monitoredGroups: AutomationGroupOption[]
saving: boolean
/** 发送能力不完整时的一句话提示;为 undefined 表示能力正常。 */
sendCapabilityWarning?: string
@@ -47,20 +55,22 @@ export interface LeaveNotificationEditorProps {
onSave: (input: LeaveNotificationSaveInput) => void
}
/**
* 草稿初值。
*
* 一律过一遍 `normalizeLeaveNotificationConfig`:它本来就负责"补齐缺失字段 +
* 清掉互相矛盾的配置",所以契约新增字段(例如 `notifyScope`)时这里不会漏。
* 手拼字面量就会漏。
*/
function initialConfig(rule: AutomationRule): LeaveNotificationConfig {
return (
rule.leaveNotification ??
createDefaultLeaveNotificationRule(Date.now()).leaveNotification ?? {
target: { type: 'file_transfer' },
template: ''
}
)
return normalizeLeaveNotificationConfig(rule.leaveNotification)
}
export function LeaveNotificationEditor({
rule,
contacts,
monitoredCount,
monitoredGroups,
saving,
sendCapabilityWarning,
onOpenMonitoredGroups,
@@ -69,11 +79,13 @@ export function LeaveNotificationEditor({
}: LeaveNotificationEditorProps): React.ReactElement {
const [enabled, setEnabled] = React.useState(rule.enabled)
const [config, setConfig] = React.useState<LeaveNotificationConfig>(() => initialConfig(rule))
const [groupFilter, setGroupFilter] = React.useState('')
// 切换编辑对象 / 保存后回填时重建草稿,避免把上一份改动带过来。
React.useEffect(() => {
setEnabled(rule.enabled)
setConfig(initialConfig(rule))
setGroupFilter('')
}, [rule])
const targetType = config.target.type
@@ -96,6 +108,21 @@ export function LeaveNotificationEditor({
const missingGroupName =
targetType !== 'source_chat' && !config.template.includes('{groupName}')
/** 通知范围(二次勾选)。`config.notifyScope` 是唯一权威,这里只是取短名。 */
const notifyScope: LeaveNotificationNotifyScope =
config.notifyScope === 'selected' ? 'selected' : 'all'
const selectedNotifyIds = React.useMemo(
() => new Set(config.notifyRoomIds),
[config.notifyRoomIds]
)
const visibleMonitoredGroups = React.useMemo(() => {
const keyword = groupFilter.trim().toLowerCase()
if (!keyword) return monitoredGroups
return monitoredGroups.filter((group) => group.name.toLowerCase().includes(keyword))
}, [monitoredGroups, groupFilter])
const selectTarget = (next: LeaveNotificationTargetType): void => {
setConfig((current) => ({
...current,
@@ -105,8 +132,40 @@ export function LeaveNotificationEditor({
}))
}
const selectNotifyScope = (next: LeaveNotificationNotifyScope): void => {
setConfig((current) => ({ ...current, notifyScope: next }))
}
const toggleNotifyRoom = (roomId: string): void => {
setConfig((current) => {
const chosen = new Set(current.notifyRoomIds)
if (chosen.has(roomId)) chosen.delete(roomId)
else chosen.add(roomId)
// 按候选项顺序落盘,勾选顺序不影响存下来的结果。
const ordered = monitoredGroups
.map((group) => group.id)
.filter((id) => chosen.has(id))
// 候选项还没加载出来时保留原样,避免把用户的勾选静默清空。
return {
...current,
notifyRoomIds: monitoredGroups.length ? ordered : current.notifyRoomIds
}
})
}
const handleSave = (): void => {
onSave({ enabled, config: { ...config, targetNeedsReview: undefined } })
onSave({
enabled,
config: {
...config,
// 勾选集收敛到「当前已监控」这一集合内:否则监控范围缩小之后,
// 会留下一批界面上看不见、且永远不可能命中的幽灵勾选。
notifyRoomIds: monitoredGroups.length
? monitoredGroups.map((group) => group.id).filter((id) => selectedNotifyIds.has(id))
: config.notifyRoomIds,
targetNeedsReview: undefined
}
})
}
return (
@@ -172,9 +231,8 @@ export function LeaveNotificationEditor({
</div>
{/*
只读摘要 + 唯一入口。
「哪些群被监控」完全由退群监控决定,本规则不做二次筛选 ——
所以这里不给任何勾选控件,只把当前范围如实说出来。
第一层:只读的监控范围摘要 + 唯一入口。
「哪些群被监控」完全由退群监控决定,本规则不重复维护一份。
*/}
<div className="automation-leave-groups-card">
<div>
@@ -191,6 +249,100 @@ export function LeaveNotificationEditor({
管理监控群聊 →
</Button>
</div>
{/*
第二层:在上面这批已监控群聊里**二次勾选**哪些要通知。
这就是旧版「退群监控 → 通知群聊」的那份逐群勾选 ——
没有它,规则一启用就等于给全部已监控群聊发通知。
*/}
<div className="automation-inline-row">
<div className="automation-leave-scope">
<span className="automation-field-label">通知范围</span>
<RadioGroup
value={notifyScope}
onValueChange={(value) =>
selectNotifyScope(value as LeaveNotificationNotifyScope)
}
aria-label="通知范围"
className="automation-leave-scope-options"
>
<div className="automation-leave-radio option">
<RadioGroupItem value="all" id="leave-scope-all" />
<div className="automation-leave-radio-body">
<label htmlFor="leave-scope-all">全部已监控群聊</label>
<small>任一被监控的群有人退出都发通知。</small>
</div>
</div>
<div className="automation-leave-radio option">
<RadioGroupItem
value="selected"
id="leave-scope-selected"
disabled={!monitoredGroups.length}
/>
<div className="automation-leave-radio-body">
<label htmlFor="leave-scope-selected">仅选中的群聊</label>
<small>只对下面勾选的群发通知。</small>
</div>
</div>
</RadioGroup>
<small className="automation-leave-scope-summary">
当前覆盖:{describeLeaveNotificationNotifyScope(config, monitoredCount)}
</small>
</div>
</div>
{notifyScope === 'selected' ? (
monitoredGroups.length ? (
<div className="automation-field">
<div className="automation-section-heading">
<span className="automation-field-label">勾选要通知的群聊</span>
<span className="automation-section-note">
{selectedNotifyIds.size ? `已选 ${selectedNotifyIds.size} 个` : '尚未勾选'}
</span>
</div>
<Input
value={groupFilter}
onChange={(event) => setGroupFilter(event.target.value)}
placeholder="搜索群聊"
aria-label="搜索群聊"
/>
<div className="automation-group-list" role="group" aria-label="通知群聊">
{visibleMonitoredGroups.length === 0 ? (
<p className="automation-group-empty">没有匹配「{groupFilter}」的群聊</p>
) : (
visibleMonitoredGroups.map((group) => {
const checked = selectedNotifyIds.has(group.id)
return (
<label
key={group.id}
className={`automation-group-option ${checked ? 'selected' : ''}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleNotifyRoom(group.id)}
/>
<span>{group.name}</span>
</label>
)
})
)}
</div>
{selectedNotifyIds.size === 0 ? (
<p className="automation-section-hint">
<span aria-hidden="true">ⓘ</span>
一个群都没勾选,这条规则不会发送任何通知。
</p>
) : null}
</div>
) : (
<p className="automation-section-hint">
<span aria-hidden="true">ⓘ</span>
还没有设置监控范围。请先到「退群监控 → 管理群聊」选择要监控的群,
再回来勾选通知范围。
</p>
)
) : null}
</section>
<section className="automation-section">
@@ -1,5 +1,6 @@
import * as React from 'react'
import {
describeLeaveNotificationNotifyScope,
describeMonitoredScope,
leaveNotificationTargetLabel,
type LeaveNotificationConfig
@@ -99,6 +100,10 @@ export function LeaveNotificationPreview({
<dt>监控来源</dt>
<dd>{describeMonitoredScope(monitoredCount)}</dd>
</div>
<div>
<dt>通知范围</dt>
<dd>{describeLeaveNotificationNotifyScope(config, monitoredCount)}</dd>
</div>
<div>
<dt>通知目标</dt>
<dd>{targetSummary}</dd>
@@ -2,8 +2,8 @@ import * as React from 'react'
import {
AUTOMATION_RULE_TEMPLATES,
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
describeLeaveNotificationNotifyScope,
describeLeaveNotificationTarget,
describeMonitoredScope,
describeRuleActions,
describeRuleTrigger,
type AutomationRule
@@ -192,8 +192,13 @@ export function RuleListPanel({
<dd>{describeRuleTrigger(leaveRule)}</dd>
</div>
<div>
<dt>范围</dt>
<dd>{describeMonitoredScope(leaveNotification?.monitoredCount ?? 0)}</dd>
<dt>通知范围</dt>
<dd>
{describeLeaveNotificationNotifyScope(
leaveRule.leaveNotification,
leaveNotification?.monitoredCount ?? 0
)}
</dd>
</div>
<div>
<dt>动作</dt>
@@ -14,7 +14,9 @@ import {
} from '../../components/ui'
import { MessageTypeSelector } from '../../components/reports/MessageTypeSelector'
import {
DEFAULT_REPORT_IMAGE_POSTFIX_TEXT,
SCHEDULED_REPORT_MEMBER_NAME_OPTIONS,
SCHEDULED_REPORT_POSTFIX_MAX_LENGTH,
SCHEDULED_REPORT_RANGE_OPTIONS,
SCHEDULED_REPORT_TARGET_OPTIONS,
SCHEDULED_REPORT_TEMPLATE_SELECT_OPTIONS,
@@ -24,9 +26,8 @@ import {
resolveGroupDisplayName,
type ScheduledReportDraft,
type ScheduledReportTargetType
} from './model/scheduled-report-preview'
} from './model/scheduled-report-model'
import { LeaveNotificationTargetPicker } from './LeaveNotificationTargetPicker'
import { ScheduledReportPreview } from './ScheduledReportPreview'
/**
* ScheduledReportEditor —— 「自动化 → 规则 → 定时日报」的编辑器。
@@ -36,7 +37,7 @@ import { ScheduledReportPreview } from './ScheduledReportPreview'
* Section 2 承载完整的日报配置(纳入的消息类型 / 日报内容 / 模型配置 /
* 日报模板 / 成员名称 / 生成超时),字段全部来自真实模型 —— 不发明,也不删减。
*
* 草稿只存在 renderer local state,保存不落盘(由父层提示)。
* 草稿仍然是 renderer local state,但**保存是真的落盘**(父层调 `automation:*`)。
*/
export interface ScheduledReportEditorProps {
@@ -52,6 +53,15 @@ export interface ScheduledReportEditorProps {
onSave: (draft: ScheduledReportDraft) => void
/** 复用现有的模型配置入口。 */
onOpenModelSettings?: () => void
/**
* 迁移遗留的旧目标原文(只作展示)。
*
* 有值时说明这条规则是迁移过来的、当时的目标无法自动对应 ——
* 界面必须让用户**看见原来的目标是什么**再重选。
*/
legacyTarget?: string
/** 微信异常通知的入口(底部一块,与退群通知同一套开关组件)。 */
notificationSlot?: React.ReactNode
}
export function ScheduledReportEditor({
@@ -62,7 +72,9 @@ export function ScheduledReportEditor({
onBack,
onCancel,
onSave,
onOpenModelSettings
onOpenModelSettings,
legacyTarget,
notificationSlot
}: ScheduledReportEditorProps): React.ReactElement {
const [draft, setDraft] = React.useState<ScheduledReportDraft>(initialDraft)
const [groupFilter, setGroupFilter] = React.useState('')
@@ -101,23 +113,20 @@ export function ScheduledReportEditor({
}
/*
* 候选群 = 自动化现有的群列表 **并上** 当前任务已在用的来源群。
* 候选群 = 自动化现有的群列表 **并上** 当前规则已在用的来源群。
*
* 任务里存的群标识与群列表是两个数据源,不保证一致;不并的话,
* 编辑已有任务会出现"当前来源群在列表里找不到、于是没有任何一项被选中"。
* ⚠️ `value` 一律是**稳定会话 id**(`xxx@chatroom`),不是群名 ——
* 群名会改,把群名当 id 存下来的规则改一次名就发错群。
* 迁移过来的老规则里可能仍是群名 / md5,这时作为"额外候选项"补进列表,
* 界面上用可读名展示,**绝不把裸标识印给用户**。
*/
const groupOptions = React.useMemo(() => {
const known = new Set(groups.map((group) => group.name))
// 去重:不去重会生成两条同 id 的候选项,React 重复 key 会让渲染错乱。
const known = new Set(groups.map((group) => group.id))
const extras = Array.from(
new Set(
[initialDraft.group, initialDraft.target].filter(
(value) => Boolean(value) && !known.has(value)
)
)
).map((value) => ({ id: `current:${value}`, name: value }))
new Set([initialDraft.group].filter((value) => Boolean(value) && !known.has(value)))
).map((value) => ({ id: value, name: resolveGroupDisplayName(value, groups) }))
return [...groups, ...extras]
}, [groups, initialDraft.group, initialDraft.target])
}, [groups, initialDraft.group])
const visibleGroups = React.useMemo(() => {
const needle = groupFilter.trim().toLowerCase()
@@ -128,14 +137,24 @@ export function ScheduledReportEditor({
)
}, [groupOptions, groupFilter])
const selectedContactName =
sendableContacts.find((contact) => contact.id === draft.targetContactId)?.name ?? ''
/**
* 保存前置校验。
*
* 不允许存一条跑不起来的规则:没有名称 / 没有来源群 / 选了「指定好友」
* 却没选人,这三种在运行期必然失败,与其让用户过几天在日志里发现,不如现在说清。
*/
const saveBlocker = !draft.name.trim()
? '请填写任务名称'
: !draft.group
? '请选择日报来源群'
: draft.targetType === 'contact' && !draft.targetContactId
? '请选择要发送的指定好友'
: ''
const selectTargetType = (next: ScheduledReportTargetType): void => {
// 「发送到日报来源群」的目标跟随来源群;其余目标清空,避免看起来已经选好了。
// 切换目标类型时清空已选联系人,避免看起来"已经选好了"却发不出去。
patch({
targetType: next,
target: next === 'source_chat' ? draft.group : '',
targetContactId: next === 'contact' ? draft.targetContactId : ''
})
}
@@ -163,12 +182,29 @@ export function ScheduledReportEditor({
<Button variant="ghost" onClick={onCancel} disabled={saving}>
取消
</Button>
<Button onClick={() => onSave(draft)} disabled={saving}>
<Button
onClick={() => onSave(draft)}
disabled={saving || Boolean(saveBlocker)}
title={saveBlocker || undefined}
>
{saving ? '保存中…' : '保存'}
</Button>
</div>
</header>
{saveBlocker ? (
<p className="automation-notice warning" role="status" data-testid="scheduled-save-blocker">
{saveBlocker}
</p>
) : null}
{legacyTarget ? (
<p className="automation-notice warning" role="status" data-testid="scheduled-legacy-target">
这条规则是升级前创建的,当时的发送目标({legacyTarget})无法自动对应到新的选项。
请重新选择发送目标后再保存。
</p>
) : null}
<div className="automation-editor-body">
<div className="automation-editor-form">
<section className="automation-section">
@@ -244,12 +280,7 @@ export function ScheduledReportEditor({
<small>生成哪个群的日报。</small>
<RadioGroup
value={draft.group}
onValueChange={(value) =>
patch({
group: value,
target: draft.targetType === 'source_chat' ? value : draft.target
})
}
onValueChange={(value) => patch({ group: value })}
aria-label="日报来源"
className="automation-leave-contact-picker"
>
@@ -265,7 +296,7 @@ export function ScheduledReportEditor({
) : (
visibleGroups.map((group) => (
<div key={group.id} className="automation-leave-radio option">
<RadioGroupItem value={group.name} id={`scheduled-source-${group.id}`} />
<RadioGroupItem value={group.id} id={`scheduled-source-${group.id}`} />
<label htmlFor={`scheduled-source-${group.id}`}>{group.name}</label>
</div>
))
@@ -421,6 +452,20 @@ export function ScheduledReportEditor({
</div>
) : null}
{/* 后置词:从「手动发送日报图片」那个输入框原样搬运过来,只是改成按规则存。 */}
<div className="automation-field">
<span className="automation-field-label">发送后置词</span>
<Input
aria-label="发送后置词"
value={draft.postfixText}
maxLength={SCHEDULED_REPORT_POSTFIX_MAX_LENGTH}
placeholder={DEFAULT_REPORT_IMAGE_POSTFIX_TEXT}
disabled={saving}
onChange={(event) => patch({ postfixText: event.target.value })}
/>
<small>图片确认发送成功后,会发送一条文本消息;留空则只发图片。</small>
</div>
<p className="automation-editor-footnote">
微信数据库和聊天记录默认从本机读取。所选内容将发送至你配置的模型服务进行处理,
TraceMemo 本身不额外保存或转发内容。
@@ -428,11 +473,9 @@ export function ScheduledReportEditor({
</section>
</div>
<ScheduledReportPreview
draft={draft}
groupLabel={resolveGroupDisplayName(draft.group, groups)}
contactName={selectedContactName}
/>
<aside className="automation-editor-side">
{notificationSlot}
</aside>
</div>
</div>
)
@@ -0,0 +1,150 @@
import * as React from 'react'
import { Button, Switch, useToast } from '../../components/ui'
import type {
ScheduledReportNotificationCapability,
ScheduledReportNotificationSettings
} from '../../../../shared/scheduled-report'
import type { AutomationRule } from '../../../../shared/automation'
import { automationApi } from './model/api'
/**
* ScheduledReportNotificationPanel —— **微信异常通知**的入口。
*
* 这是「定时日报」功能的一项**全局能力**(不是某条规则的配置):
* 定时任务在生成 / 发送失败时,经 Agent Hub 给用户推一条微信。
*
* 迁移前它挂在旧「日报 → 定时日报」页面上;那个页面退役后,入口挪到这里 ——
* 能力本身一行没改,绝不因为删页面而静默丢掉。
*/
export interface ScheduledReportNotificationPanelProps {
/** 当前正在编辑的规则(用于「发送测试错误通知」;新建时可传 null)。 */
rule: AutomationRule | null
/** 异常通知依赖 Agent Hub:未就绪时给一个可操作的去处。 */
onOpenAgentHub?: () => void
}
export function ScheduledReportNotificationPanel({
rule,
onOpenAgentHub
}: ScheduledReportNotificationPanelProps): React.ReactElement {
const { toast } = useToast()
const [settings, setSettings] = React.useState<ScheduledReportNotificationSettings>({
enabled: false
})
const [capability, setCapability] = React.useState<ScheduledReportNotificationCapability>({
ready: false
})
const [loading, setLoading] = React.useState(true)
const [saving, setSaving] = React.useState(false)
const [testing, setTesting] = React.useState(false)
const load = React.useCallback(async (): Promise<void> => {
const [nextSettings, nextCapability] = await Promise.all([
automationApi.getScheduledReportNotificationSettings(),
automationApi.getScheduledReportNotificationCapability()
])
setSettings(nextSettings)
setCapability(nextCapability)
}, [])
React.useEffect(() => {
let disposed = false
setLoading(true)
void load().finally(() => {
if (!disposed) setLoading(false)
})
return () => {
disposed = true
}
}, [load])
const handleToggle = async (enabled: boolean): Promise<void> => {
setSaving(true)
const result = await automationApi.setScheduledReportNotificationEnabled(enabled)
setSaving(false)
if (!result.success) {
// 开启失败时 main 侧会带出真实原因(Agent Hub 未连接 / 未绑定接收者 / 测试发送失败)。
toast({
description: result.error || '异常通知设置失败',
variant: 'destructive',
duration: 4000
})
await load()
return
}
setSettings(result.data)
toast({
description: result.data.enabled ? '已开启微信异常通知' : '已关闭微信异常通知',
duration: 2800
})
}
const handleTest = async (): Promise<void> => {
if (!rule) {
toast({ description: '请先保存这条定时日报,再发送测试通知', variant: 'destructive', duration: 3200 })
return
}
setTesting(true)
const result = await automationApi.testScheduledReportErrorNotification(rule.id)
setTesting(false)
toast({
description: result.success ? '测试通知已发送,请查看微信' : result.error || '测试通知发送失败',
variant: result.success ? undefined : 'destructive',
duration: 4000
})
}
const capabilityHint = capability.ready
? capability.recipient
? `通知将发送给:${capability.recipient}`
: '通知接收者已就绪'
: capability.error || '异常通知能力尚未就绪。'
return (
<section className="automation-section automation-notification-panel">
<div className="automation-section-heading">
<h3>微信异常通知</h3>
</div>
<p className="automation-section-lead">
定时日报生成或发送失败时,通过 Agent Hub 给你发一条微信。关闭后不会再推送。
</p>
<div className="automation-inline-row">
<div>
<span className="automation-field-label">开启异常通知</span>
<small>{capabilityHint}</small>
{!capability.ready && onOpenAgentHub ? (
<Button variant="link" size="sm" onClick={onOpenAgentHub}>
去连接 Agent Hub
</Button>
) : null}
</div>
<Switch
checked={settings.enabled}
disabled={loading || saving || (!capability.ready && !settings.enabled)}
onCheckedChange={(checked) => void handleToggle(checked)}
aria-label="开启微信异常通知"
/>
</div>
{settings.enabled ? (
<div className="automation-inline-row">
<div>
<span className="automation-field-label">发送测试错误通知</span>
<small>验证 Agent Hub 推送链路是否通畅。</small>
</div>
<Button
variant="outline"
size="sm"
disabled={testing || !rule}
onClick={() => void handleTest()}
>
{testing ? '发送中…' : '发送测试'}
</Button>
</div>
) : null}
</section>
)
}
@@ -1,153 +0,0 @@
import * as React from 'react'
import { SUMMARY_TYPE_OPTIONS } from '../../utils/group-report'
import {
formatScheduledSlot,
scheduledReportRangeLabel,
scheduledReportTemplateLabel,
type ScheduledReportDraft,
type ScheduledReportTargetType
} from './model/scheduled-report-preview'
/**
* ScheduledReportPreview —— 右侧「效果预览」。
*
* 与左侧表单逐项联动,**绝不执行任何真实动作**:
* 不调 scheduler、不生成日报、不发送、不碰 store。
*
* 定时日报用「流程」表达比微信气泡准确:一次执行是
* 定时触发 → 生成日报 → 模型 → 模板 → 发送 的链条,而不是一段对话。
*/
export interface ScheduledReportPreviewProps {
draft: ScheduledReportDraft
/** 来源群的**显示名**(草稿里存的可能是内部标识,展示前已在调用方解析)。 */
groupLabel: string
/** 指定好友的显示名。 */
contactName: string
}
export function ScheduledReportPreview({
draft,
groupLabel,
contactName
}: ScheduledReportPreviewProps): React.ReactElement {
const groupText = groupLabel.trim() || '未选择群聊'
/** 发送目标的显示文案(四种类型各取真实语义,不回落成内部 id)。 */
const targetText = React.useMemo((): string => {
const labels: Record<ScheduledReportTargetType, string> = {
source_chat: groupText,
self: '我',
file_transfer: '文件传输助手',
contact: contactName.trim() || '未选择好友'
}
return labels[draft.targetType]
}, [draft.targetType, groupText, contactName])
/**
* 纳入的消息类型只做**摘要**:左侧才是详细配置,
* 右侧逐条列出来会把预览拉得很长。
*/
const messageTypesSummary = React.useMemo((): string => {
if (draft.messageTypes.length === 0) return '未选择'
if (draft.messageTypes.length === SUMMARY_TYPE_OPTIONS.length) return '全部类型'
return draft.messageTypes
.map((value) => SUMMARY_TYPE_OPTIONS.find((option) => option.value === value)?.label ?? value)
.join('、')
}, [draft.messageTypes])
return (
<section className="automation-preview" aria-label="效果模拟预览">
<div className="automation-preview-heading">
<h3>效果预览</h3>
<span className="automation-preview-badge">仅预览,不会真实执行</span>
</div>
<ol className="automation-preview-flow">
<li className="automation-preview-flow-step">
<span className="automation-preview-flow-time">{draft.scheduleTime}</span>
<span className="automation-preview-flow-title">定时任务触发</span>
<small>每天 {draft.scheduleTime}</small>
</li>
<li className="automation-preview-flow-arrow" aria-hidden="true">
↓
</li>
<li className="automation-preview-flow-step">
<span className="automation-preview-flow-title">生成日报</span>
<small>来源:{groupText}</small>
<small>范围:{scheduledReportRangeLabel(draft.reportRange)}</small>
<small>纳入消息:{messageTypesSummary}</small>
</li>
<li className="automation-preview-flow-arrow" aria-hidden="true">
↓
</li>
<li className="automation-preview-flow-step">
<span className="automation-preview-flow-title">模型</span>
<small>默认日报模型</small>
</li>
<li className="automation-preview-flow-arrow" aria-hidden="true">
↓
</li>
<li className="automation-preview-flow-step">
<span className="automation-preview-flow-title">模板</span>
<small>{scheduledReportTemplateLabel(draft.templateId)}</small>
</li>
<li className="automation-preview-flow-arrow" aria-hidden="true">
↓
</li>
<li className="automation-preview-flow-step">
<span className="automation-preview-flow-title">发送到</span>
<small>{targetText}</small>
</li>
</ol>
{/* 日报图片的形状示意(不是真实截图,也不触发生成)。 */}
<div className="automation-preview-report-card" aria-hidden="true">
<div className="automation-preview-report-head">
<span>{groupText}</span>
<span>{scheduledReportRangeLabel(draft.reportRange)}</span>
</div>
<div className="automation-preview-report-line wide" />
<div className="automation-preview-report-line" />
<div className="automation-preview-report-line" />
<div className="automation-preview-report-line short" />
</div>
<dl className="automation-preview-facts">
<div>
<dt>触发方式</dt>
<dd>每天 {draft.scheduleTime}</dd>
</div>
<div>
<dt>日报来源</dt>
<dd>{groupText}</dd>
</div>
<div>
<dt>日报范围</dt>
<dd>{scheduledReportRangeLabel(draft.reportRange)}</dd>
</div>
<div>
<dt>日报模板</dt>
<dd>{scheduledReportTemplateLabel(draft.templateId)}</dd>
</div>
<div>
<dt>发送目标</dt>
<dd>{targetText}</dd>
</div>
</dl>
<p className="automation-preview-footnote">
预计下次执行:{formatScheduledSlot(draft.scheduleTime)}
</p>
</section>
)
}
@@ -1,12 +1,17 @@
import * as React from 'react'
import { Button, Spinner, Switch } from '../../components/ui'
import type { ScheduledReportTask } from '../../../../shared/scheduled-report'
import {
calculateNextRunAt,
normalizeScheduledReportConfig,
type AutomationRule
} from '../../../../shared/automation'
import {
formatNextRunAt,
scheduledReportRangeLabel,
scheduledReportTargetDisplayName,
scheduledReportTemplateLabel
} from './model/scheduled-report-preview'
} from './model/scheduled-report-model'
/**
* ScheduledReportRuleList —— 「自动化 → 规则 → 定时日报」的**规则列表**。
@@ -14,48 +19,59 @@ import {
* 为什么这里是列表而不是直接进编辑器:定时日报是**多条**规则
* (与 singleton 的「退群通知」不同),所以必须先选一条,或新建一条。
*
* 所有写操作(立即执行 / 启停 / 删除)都不落盘,只提示。
* 数据是**只读**拿到的真实任务(见 `model/scheduled-report-preview.ts`)。
* 数据是 `AutomationRuleStore` 里的真实 `scheduled_report` 规则;
* 立即执行 / 启停 / 删除全部**真写入**(由父层调 `automation:*` IPC)。
*
* `onBack` 是这一层**必须**有的逃生口:进了「定时日报」这一类型后,只有
* 列表与编辑器两种形态,而列表本身没有「取消」——如果这里不给退出入口,
* 用户唯一的出路就是切到别的规则类型 tab,再从那边的「取消」绕回来。
*/
export interface ScheduledReportRuleListProps {
tasks: ScheduledReportTask[]
rules: AutomationRule[]
loading: boolean
/** 正在"操作中"的任务 id(仅用于视觉反馈,不产生真实副作用)。 */
busyTaskId: string | null
/**
* 群标识 → 显示名。
*
* 任务里存的可能是群名、room id 或 hash;列表**绝不能**把内部标识露给用户。
*/
/** 正在"操作中"的规则 id(视觉反馈,防止重复点击)。 */
busyRuleId: string | null
/** 群标识 → 显示名(**绝不**把内部标识露给用户)。 */
resolveGroupDisplay: (raw: string) => string
/** 退出本类型、回到规则面板(三个类型卡片那一层)。 */
onBack?: () => void
onCreate: () => void
onEdit: (task: ScheduledReportTask) => void
onRunNow: (task: ScheduledReportTask) => void
onToggle: (task: ScheduledReportTask, enabled: boolean) => void
onDelete: (task: ScheduledReportTask) => void
onEdit: (rule: AutomationRule) => void
onRunNow: (rule: AutomationRule) => void
onToggle: (rule: AutomationRule, enabled: boolean) => void
onDelete: (rule: AutomationRule) => void
}
export function ScheduledReportRuleList({
tasks,
rules,
loading,
busyTaskId,
busyRuleId,
resolveGroupDisplay,
onBack,
onCreate,
onEdit,
onRunNow,
onToggle,
onDelete
}: ScheduledReportRuleListProps): React.ReactElement {
const runningCount = tasks.filter((task) => task.enabled).length
const runningCount = rules.filter((rule) => rule.enabled).length
const now = new Date()
return (
<div className="automation-rule-list">
{/* 没有 onBack 就不渲染死按钮:按钮点不动比没有按钮更糟。 */}
{onBack ? (
<button type="button" className="automation-editor-back" onClick={onBack}>
← 返回规则列表
</button>
) : null}
<section className="automation-rule-group">
<div className="automation-section-heading">
<h2>定时日报</h2>
<span className="automation-section-note">
{tasks.length > 0 ? `${tasks.length} 条规则` : '尚未配置'}
{rules.length > 0 ? `${rules.length} 条规则` : '尚未配置'}
</span>
</div>
@@ -65,9 +81,9 @@ export function ScheduledReportRuleList({
<div className="automation-scheduled-toolbar">
<Button onClick={onCreate}>+ 新建定时日报</Button>
{tasks.length > 0 ? (
{rules.length > 0 ? (
<span className="automation-section-note">
运行中 {runningCount} / {tasks.length}
运行中 {runningCount} / {rules.length}
</span>
) : null}
</div>
@@ -77,83 +93,106 @@ export function ScheduledReportRuleList({
<Spinner />
<span>正在读取定时日报…</span>
</div>
) : tasks.length === 0 ? (
) : rules.length === 0 ? (
<div className="automation-empty-card">
<p>还没有定时日报</p>
<p>创建一条自动化,让 TraceMemo 在指定时间生成并发送日报。</p>
<Button onClick={onCreate}>+ 新建定时日报</Button>
</div>
) : (
tasks.map((task) => (
<article
key={task.id}
className={`automation-rule-card ${task.enabled ? '' : 'disabled'}`}
>
<div className="automation-rule-card-main">
<div className="automation-rule-card-title">
<h3>{task.name}</h3>
{/* 状态只出现两处:这个 badge + 右侧 toggle,不做第三重表达。 */}
<span className={`automation-rule-state ${task.enabled ? 'on' : 'off'}`}>
{task.enabled ? '运行中' : '已暂停'}
</span>
rules.map((rule) => {
const config = normalizeScheduledReportConfig(rule.scheduledReport)
const needsReview = config.targetNeedsReview === true
const busy = busyRuleId === rule.id
return (
<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>
{/* 状态只出现两处:这个 badge + 右侧 toggle,不做第三重表达。 */}
<span className={`automation-rule-state ${rule.enabled ? 'on' : 'off'}`}>
{rule.enabled ? '运行中' : '已暂停'}
</span>
</div>
{needsReview ? (
// 迁移过来但目标无法无损映射:如实提示,绝不冒充"配置正常"。
<p className="automation-rule-warning" role="status">
这条规则的发送目标需要重新选择(迁移自旧版本,原目标无法自动对应)。
{config.legacyTarget ? (
<small>原来的目标:{config.legacyTarget}</small>
) : null}
</p>
) : null}
<dl className="automation-rule-meta">
<div>
<dt>触发</dt>
<dd>每天 {config.schedule.time}</dd>
</div>
<div>
<dt>日报</dt>
<dd>{scheduledReportRangeLabel(config.report.range)}</dd>
</div>
<div>
<dt>来源</dt>
<dd>{resolveGroupDisplay(config.report.sourceConversationId) || '未设置'}</dd>
</div>
<div>
<dt>模板</dt>
<dd>{scheduledReportTemplateLabel(config.report.templateId)}</dd>
</div>
<div>
<dt>发送到</dt>
<dd>{scheduledReportTargetDisplayName(config, resolveGroupDisplay)}</dd>
</div>
<div>
<dt>下次执行</dt>
<dd>
{rule.enabled && !needsReview
? formatNextRunAt(calculateNextRunAt(config.schedule.time, now))
: '—'}
</dd>
</div>
</dl>
</div>
<dl className="automation-rule-meta">
<div>
<dt>触发</dt>
<dd>每天 {task.scheduleTime}</dd>
</div>
<div>
<dt>日报</dt>
<dd>{scheduledReportRangeLabel(task.reportRange)}</dd>
</div>
<div>
<dt>来源</dt>
<dd>{resolveGroupDisplay(task.group) || '未设置'}</dd>
</div>
<div>
<dt>模板</dt>
<dd>{scheduledReportTemplateLabel(task.templateId)}</dd>
</div>
<div>
<dt>发送到</dt>
{/* 旧任务的 target 恒等于来源群;这时用语义文案比重复群名清楚。 */}
<dd>
{!task.target || task.target === task.group
? '日报来源群'
: resolveGroupDisplay(task.target)}
</dd>
</div>
<div>
<dt>下次执行</dt>
<dd>{task.enabled ? formatNextRunAt(task.nextRunAt) : '—'}</dd>
</div>
</dl>
</div>
<div className="automation-rule-card-side">
<Switch
checked={task.enabled}
disabled={busyTaskId === task.id}
onCheckedChange={(checked) => onToggle(task, checked)}
aria-label={`${task.name} 启停`}
/>
<Button
variant="outline"
size="sm"
disabled={busyTaskId === task.id}
onClick={() => onRunNow(task)}
>
立即执行
</Button>
<Button variant="outline" size="sm" onClick={() => onEdit(task)}>
编辑
</Button>
<Button variant="link" size="sm" onClick={() => onDelete(task)}>
删除
</Button>
</div>
</article>
))
<div className="automation-rule-card-side">
<Switch
checked={rule.enabled}
disabled={busy}
onCheckedChange={(checked) => onToggle(rule, checked)}
aria-label={`${rule.name} 启停`}
/>
<Button
variant="outline"
size="sm"
disabled={busy || needsReview}
onClick={() => onRunNow(rule)}
>
立即执行
</Button>
<Button variant="outline" size="sm" disabled={busy} onClick={() => onEdit(rule)}>
编辑
</Button>
<Button
variant="link"
size="sm"
disabled={busy}
onClick={() => onDelete(rule)}
>
删除
</Button>
</div>
</article>
)
})
)}
{/* 消息驱动的规则不在这里维护;显式说明,避免用户以为它消失了。 */}
<p className="automation-section-footnote">
消息驱动的「@我生成日报」规则在上一个类型里维护;这里只列出时间驱动的定时日报。
</p>
</section>
</div>
)
@@ -2,8 +2,17 @@ import type {
AutomationExecution,
AutomationRule,
AutomationRuleDraft,
AutomationStatusSummary
AutomationStatusSummary,
ScheduledRuleRunOutcome
} from '../../../../../shared/automation'
import type {
ScheduledReportExecution,
ScheduledReportNotification,
ScheduledReportNotificationCapability,
ScheduledReportNotificationSettings,
ScheduledReportNotificationSettingsResult,
ScheduledReportResult
} from '../../../../../shared/scheduled-report'
import type { GroupExitMonitorState } from '../../../../../shared/group-exit-monitor'
/**
@@ -110,7 +119,7 @@ export const automationApi = {
/**
* 真实已监控群聊数量。
*
* 数据源是**退群监控**,自动化不维护副本(§「Automation 不做二次 group filter」)。
* 数据源是**退群监控**,自动化不维护副本(不做二次群过滤)。
* 取数与退群监控页保持同一表达式,否则两个页面会对同一个数字给出不同答案。
*/
async getMonitoredGroupCount(): Promise<number> {
@@ -118,5 +127,73 @@ export const automationApi = {
if (!state) return 0
if (state.monitorSelectionConfigured) return (state.monitoredRoomIds || []).length
return Number(state.monitoredGroupCount) || 0
}
},
/**
* 真实已监控群聊清单(含显示名),供退群通知做**二次勾选**。
*
* 与 `getMonitoredGroupCount` **同一处取数**:清单和数字必须来自同一个快照,
* 否则会出现"卡片说 5 个群、勾选列表只有 3 个"这种自相矛盾的界面。
*
* 名字从群列表里取(`listAutomationGroups`)。取不到的 roomId **保留原样**而不是丢掉 ——
* 那个群仍然被监控着,丢掉它等于悄悄缩小用户的范围。
*
* 未显式设置监控范围时返回空数组:此时没有"被监控的群"这个集合,
* 二次筛选无从谈起,由调用方退化成"全部"并如实说明。
*/
async getMonitoredGroups(): Promise<AutomationGroupOption[]> {
const [state, groups] = await Promise.all([
invoke<GroupExitMonitorState | null>('getGroupExitMonitorState', null),
invoke<AutomationGroupOption[]>('listAutomationGroups', [])
])
if (!state?.monitorSelectionConfigured) return []
const nameById = new Map(groups.map((group) => [group.id, group.name]))
return (state.monitoredRoomIds || [])
.map((roomId) => ({ id: roomId, name: nameById.get(roomId) || roomId }))
.sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'))
},
/** 定时日报「立即执行」:与 scheduler 走同一条执行链路(manual trigger)。 */
runScheduledReportRule: (
ruleId: string
): Promise<{ success: boolean; error?: string; data?: ScheduledRuleRunOutcome }> =>
invoke<{ success: boolean; error?: string; data?: ScheduledRuleRunOutcome }>(
'runScheduledReportRule',
{ success: false, error: '定时日报执行接口尚未就绪' },
ruleId
),
/**
* 旧执行记录**只读存档**。
*
* 旧记录是 7 态 + stage 模型,新执行日志是 3 态 + 通用步骤 —— 两者无法无损互转,
* 所以历史记录原样保留、只读展示,不迁进新日志。
*/
listScheduledReportLegacyExecutions: (ruleId?: string): Promise<ScheduledReportExecution[]> =>
invoke('listScheduledReportLegacyExecutions', [], ruleId),
/** 微信异常通知(随定时日报功能保留的全局能力)。 */
getScheduledReportNotificationSettings: (): Promise<ScheduledReportNotificationSettings> =>
invoke('getScheduledReportNotificationSettings', { enabled: false }),
getScheduledReportNotificationCapability: (): Promise<ScheduledReportNotificationCapability> =>
invoke('getScheduledReportNotificationCapability', { ready: false }),
setScheduledReportNotificationEnabled: (
enabled: boolean
): Promise<ScheduledReportNotificationSettingsResult> =>
invoke<ScheduledReportNotificationSettingsResult>(
'setScheduledReportNotificationEnabled',
{ success: false, data: { enabled: false }, error: '异常通知设置接口尚未就绪' },
enabled
),
testScheduledReportErrorNotification: (
ruleId: string
): Promise<ScheduledReportResult<ScheduledReportNotification>> =>
invoke<ScheduledReportResult<ScheduledReportNotification>>(
'testScheduledReportErrorNotification',
{ success: false, error: '调试接口尚未就绪' },
ruleId
)
}
@@ -0,0 +1,304 @@
import {
DEFAULT_REPORT_TEMPLATE,
REPORT_TEMPLATES,
getReportTemplate,
type SelectableReportTemplateId
} from '../../../../../shared/report-templates'
import type {
ScheduledReportMemberNameMode,
ScheduledReportMessageType,
ScheduledReportRange
} from '../../../../../shared/scheduled-report'
import {
DEFAULT_REPORT_IMAGE_POSTFIX_TEXT
} from '../../../../../shared/personal-wechat'
import {
AUTOMATION_REPLY_DELAY_DEFAULT_SECONDS,
SCHEDULED_REPORT_DEFAULT_MEMBER_NAME_MODE,
SCHEDULED_REPORT_DEFAULT_RANGE,
SCHEDULED_REPORT_DEFAULT_TEMPLATE_ID,
SCHEDULED_REPORT_DEFAULT_TIMEOUT_SECONDS,
SCHEDULED_REPORT_DEFAULT_TARGET_TYPE,
SCHEDULED_REPORT_MEMBER_NAME_OPTIONS,
SCHEDULED_REPORT_POSTFIX_MAX_LENGTH,
SCHEDULED_REPORT_RANGE_LABELS,
SCHEDULED_REPORT_TARGET_OPTIONS,
normalizeScheduledReportConfig,
scheduledReportTargetLabel,
type AutomationRule,
type AutomationRuleDraft,
type ScheduledReportAutomationConfig,
type ScheduledReportTargetType
} from '../../../../../shared/automation'
import { type SummaryMessageType } from '../../../utils/group-report'
/**
* 定时日报编辑器的**真实模型**(唯一一份)。
*
* 与迁移前的 `scheduled-report-preview.ts` 的根本区别:
* 那时这一层只读旧 `ScheduledReportTask`、保存不落盘;现在
* `scheduled_report` 已经是真实的 `AutomationRuleType`,
* 编辑器产出的草稿会经 `automation:*` IPC **真写入** `AutomationRuleStore`。
*
* 所以这里只剩两类东西:
* 1. 草稿 ↔ `ScheduledReportAutomationConfig` 的双向映射;
* 2. 纯展示格式化函数(时间 / 范围 / 模板名)。
*
* **不再有** `loadScheduledReportTasks()` 这类"只读预览数据层"。
*/
/**
* 编辑器草稿。
*
* 字段与 `ScheduledReportAutomationConfig` 一一对应(外加 `name` / `enabled`),
* 唯一多出来的是 `targetContactId`:真实模型里它是 `target.contactId`,
* 摊平只是为了表单好用。
*/
export interface ScheduledReportDraft {
name: string
enabled: boolean
/** 每天的执行时间 `HH:mm`。 */
scheduleTime: string
/** 日报来源群:生成**哪个群**的日报(稳定会话 id)。 */
group: string
reportRange: ScheduledReportRange
messageTypes: SummaryMessageType[]
memberNameMode: ScheduledReportMemberNameMode
timeoutSeconds: number
templateId: SelectableReportTemplateId
targetType: ScheduledReportTargetType
targetContactId: string
/**
* 日报图片发送成功后补发的那句话(「后置词」)。
*
* 与手动发送日报图片里的后置词同一件事,但这里**按规则存**。
* 空字符串 = 只发图片。
*/
postfixText: string
}
/**
* 新建规则时「纳入的消息类型」的默认值:**只勾选「文本」**。
*
* 以前这里是全选(对齐迁移前的旧「定时日报」页面)。改成只选文本是刻意的产品口径:
* 日报默认只吃文本,想要图片 / 语音等再手动勾 —— 与日报页(今日日报)的默认值保持一致。
*
* 存量规则**不受影响**:`createDraftFromRule` 读的是规则自己存的 `messageTypes`,
* 只有「新建」才会用到这个默认值。
*/
export const SCHEDULED_REPORT_DEFAULT_MESSAGE_TYPES: SummaryMessageType[] = ['text']
/** 日报范围选项(文案来自共享层,避免同一份配置出现两种叫法)。 */
export const SCHEDULED_REPORT_RANGE_OPTIONS: ReadonlyArray<{
value: ScheduledReportRange
label: string
}> = (['today', 'yesterday', '7days', 'recent24h'] as ScheduledReportRange[]).map((value) => ({
value,
label: SCHEDULED_REPORT_RANGE_LABELS[value]
}))
/** 成员名称选项(共享层唯一来源)。 */
export { SCHEDULED_REPORT_MEMBER_NAME_OPTIONS, SCHEDULED_REPORT_TARGET_OPTIONS }
/**
* 后置词输入框的约束与占位文案。
*
* 两个都从共享层转出,**刻意不让编辑器直接 import shared** ——
* 这样「手动发送」与「定时日报」两个入口的 200 字上限和占位句只有一份定义。
*/
export { SCHEDULED_REPORT_POSTFIX_MAX_LENGTH, DEFAULT_REPORT_IMAGE_POSTFIX_TEXT }
/** 发送目标四选一(共享层唯一来源)。 */
export type { ScheduledReportTargetType }
/** 模板选项(默认模板 + 社区模板)。 */
export const SCHEDULED_REPORT_TEMPLATE_SELECT_OPTIONS: ReadonlyArray<{
value: SelectableReportTemplateId
label: string
}> = [
{ value: DEFAULT_REPORT_TEMPLATE.id, label: DEFAULT_REPORT_TEMPLATE.name },
...REPORT_TEMPLATES.map((template) => ({
value: template.id,
label: `${template.label} · ${template.name}`
}))
]
/** 与旧页面同语义的范围文案。 */
export function scheduledReportRangeLabel(range: ScheduledReportRange): string {
return SCHEDULED_REPORT_RANGE_LABELS[range] ?? SCHEDULED_REPORT_RANGE_LABELS.today
}
/** 模板的展示名(列表 / 编辑器 / 预览必须是同一个称呼)。 */
export function scheduledReportTemplateLabel(templateId?: string): string {
return getReportTemplate(templateId).name
}
/** 发送目标的展示名(列表用;不再需要"target 是否等于 group"的猜测)。 */
export function scheduledReportTargetDisplayName(
config: ScheduledReportAutomationConfig | undefined,
resolveGroupDisplay: (raw: string) => string
): string {
const target = config?.target
if (!target) return ''
if (target.type === 'source_chat') {
const group = resolveGroupDisplay(config.report.sourceConversationId)
return group ? `${scheduledReportTargetLabel('source_chat')}(${group})` : scheduledReportTargetLabel('source_chat')
}
if (target.type === 'contact') return scheduledReportTargetLabel('contact')
return scheduledReportTargetLabel(target.type)
}
/** 新建草稿:目标默认「自己」,避免任何形式的"没选就发到群里"。 */
export function createEmptyScheduledReportDraft(): ScheduledReportDraft {
return {
name: '',
enabled: true,
scheduleTime: '18:30',
group: '',
reportRange: SCHEDULED_REPORT_DEFAULT_RANGE,
messageTypes: [...SCHEDULED_REPORT_DEFAULT_MESSAGE_TYPES],
memberNameMode: SCHEDULED_REPORT_DEFAULT_MEMBER_NAME_MODE,
timeoutSeconds: SCHEDULED_REPORT_DEFAULT_TIMEOUT_SECONDS,
templateId: SCHEDULED_REPORT_DEFAULT_TEMPLATE_ID,
targetType: SCHEDULED_REPORT_DEFAULT_TARGET_TYPE,
targetContactId: '',
// 刻意留空:新建规则默认**不**补发任何文本,用户明确要才填。
postfixText: ''
}
}
/** 真实规则 → 编辑器草稿。 */
export function createDraftFromRule(rule: AutomationRule): ScheduledReportDraft {
const config = normalizeScheduledReportConfig(rule.scheduledReport)
return {
name: rule.name,
enabled: rule.enabled,
scheduleTime: config.schedule.time,
group: config.report.sourceConversationId,
reportRange: config.report.range,
messageTypes: config.report.messageTypes.length
? (config.report.messageTypes as SummaryMessageType[])
: [...SCHEDULED_REPORT_DEFAULT_MESSAGE_TYPES],
memberNameMode: config.report.memberNameMode,
timeoutSeconds: config.report.timeoutSeconds,
templateId: config.report.templateId,
targetType: config.target.type,
targetContactId: config.target.contactId ?? '',
postfixText: config.postfixText ?? ''
}
}
/**
* 草稿 → `AutomationRuleDraft`(真正落盘的那一份)。
*
* 定时日报由**时间驱动**,不参与消息匹配;`trigger` / `scope` / `conditions` /
* `actions` / `cooldownSeconds` 这几个字段保留只是为了 schema 完整
* (`matchAutomationRule` 对 `scheduled_report` 是硬分派,永不读它们)。
*
* 显式清掉 `targetNeedsReview`:用户手动保存一次即代表"目标已确认"。
* 旧的 `legacyTarget` / `legacySourceGroup` 也在这里丢弃 ——
* 它们只服务于"让用户看清原来配的是什么",保存之后就不再需要。
*/
export function draftToAutomationRuleDraft(draft: ScheduledReportDraft): AutomationRuleDraft {
return {
name: draft.name.trim(),
enabled: draft.enabled,
ruleType: 'scheduled_report',
trigger: 'message',
scope: 'group',
conditions: {
requireMentionMe: false,
keyword: '',
keywordMatchMode: 'contains',
conversationIds: [],
ignoreSelf: true
},
actions: [],
// 不套用消息型规则的 cooldown:定时规则有自己的 ruleId inFlight 锁。
cooldownSeconds: 0,
replyDelaySeconds: AUTOMATION_REPLY_DELAY_DEFAULT_SECONDS,
scheduledReport: normalizeScheduledReportConfig({
schedule: { time: draft.scheduleTime },
report: {
sourceConversationId: draft.group,
range: draft.reportRange,
messageTypes: draft.messageTypes as ScheduledReportMessageType[],
templateId: draft.templateId,
memberNameMode: draft.memberNameMode,
timeoutSeconds: draft.timeoutSeconds
},
target: {
type: draft.targetType,
...(draft.targetType === 'contact' ? { contactId: draft.targetContactId } : {})
},
postfixText: draft.postfixText
})
}
}
/**
* 时间展示:`今日 18:30` / `明日 18:30`。
*
* 只服务编辑器里的"预计下次执行"(草稿还没落盘,没有真实 `nextRunAt` 可读)。
*/
export function formatScheduledSlot(scheduleTime: string, now = new Date()): string {
const [hour, minute] = parseScheduleTime(scheduleTime)
const slot = new Date(now)
slot.setSeconds(0, 0)
slot.setHours(hour, minute)
if (slot.getTime() <= now.getTime()) slot.setDate(slot.getDate() + 1)
const isToday = slot.toDateString() === now.toDateString()
return `${isToday ? '今日' : '明日'} ${pad2(hour)}:${pad2(minute)}`
}
/** 把真实的 `nextRunAt`(ISO)格式化成旧页面同款文案。 */
export function formatNextRunAt(value?: string): string {
if (!value) return '尚未执行'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '时间未知'
const now = new Date()
const dayLabel =
date.toDateString() === now.toDateString() ? '今日' : date.toLocaleDateString('zh-CN')
return `${dayLabel} ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`
}
export function parseScheduleTime(value: string): [number, number] {
const matched = /^(\d{1,2}):(\d{2})$/.exec(String(value ?? '').trim())
if (!matched) return [18, 30]
const hour = Math.min(23, Math.max(0, Number(matched[1])))
const minute = Math.min(59, Math.max(0, Number(matched[2])))
return [hour, minute]
}
export function composeScheduleTime(hour: number, minute: number): string {
return `${pad2(Math.min(23, Math.max(0, hour)))}:${pad2(Math.min(59, Math.max(0, minute)))}`
}
/**
* 把规则里存的群标识解析成**能给用户看**的名字。
*
* 迁移过来的老数据可能仍是会话 md5 / 群名 / roomId 三种形态之一;
* 直接渲染就会把内部标识暴露给用户。能对上群名就用群名;
* 明显是内部标识又对不上时显示「未知群聊」,**绝不显示裸 id**。
*/
export function resolveGroupDisplayName(
raw: string,
groups: Array<{ id: string; name: string }>
): string {
const value = String(raw ?? '').trim()
if (!value) return ''
const matched = groups.find((group) => group.id === value) ?? groups.find((g) => g.name === value)
if (matched) return matched.name
return looksLikeInternalIdentifier(value) ? '未知群聊' : value
}
/** 内部标识形态:room id / 16 进制 hash / 纯数字串。 */
function looksLikeInternalIdentifier(value: string): boolean {
return (
value.includes('@chatroom') || /^[0-9a-f]{16,}$/i.test(value) || /^\d{6,}$/.test(value)
)
}
function pad2(value: number): string {
return String(value).padStart(2, '0')
}
@@ -1,291 +0,0 @@
import {
DEFAULT_REPORT_TEMPLATE,
REPORT_TEMPLATES,
SELECTABLE_REPORT_TEMPLATES,
getReportTemplate,
type SelectableReportTemplateId
} from '../../../../../shared/report-templates'
import type {
ScheduledReportMemberNameMode,
ScheduledReportRange,
ScheduledReportTask
} from '../../../../../shared/scheduled-report'
import { SUMMARY_TYPE_OPTIONS, type SummaryMessageType } from '../../../utils/group-report'
/**
* 定时日报编辑态的模型与只读数据层。
*
* 对外唯一的接触面是 `window.api.listScheduledReports`(**只读**):
* 不写、不执行、不动 scheduler / store / schema。
*
* 所有字段都来自 `shared/scheduled-report.ts` 的真实模型,**不发明后端不存在的选项**:
* - `schedule.type` 只有 `'daily'` ⇒ 界面只给"每天 + 时间";
* - `target` 是单个群(`type: 'wechat_group'`)⇒ 界面只给群聊目标;
* - `templateId` 真实存在 ⇒ 界面可以给模板选择。
*/
/**
* 编辑器的草稿:字段与**旧「定时日报」真实配置**一一对应,
* 没有任何"想象出来的"配置,也没有丢掉任何一项已有能力。
*/
export interface ScheduledReportDraft {
name: string
/** 每天的执行时间 `HH:mm`,对应真实的 `schedule: { type: 'daily', time }`。 */
scheduleTime: string
reportRange: ScheduledReportRange
/** 日报来源群:生成**哪个群**的日报。 */
group: string
/** 纳入的消息类型(真实 `messageTypes`)。 */
messageTypes: SummaryMessageType[]
/** 成员名称显示方式(真实 `memberNameMode`)。 */
memberNameMode: ScheduledReportMemberNameMode
/** 日报生成超时(真实 `timeoutSeconds`,旧页面 30–1800 秒)。 */
timeoutSeconds: number
templateId: SelectableReportTemplateId
/**
* 发送目标的**类型**。
*
* 这是本层的概念:真实的 `ScheduledReportTask.target` 只是一个字符串
* (目前只表达"某个微信群")。四选一里的「自己 / 文件传输助手 / 指定好友」
* 在真实模型里还没有对应的类型化表达 —— 真实迁移时应当照退群通知的做法,
* 给 target 一个带类型的模型与解析器。
*/
targetType: ScheduledReportTargetType
/** `targetType === 'source_chat'` 时的目标群(= 来源群,旧页面就是这个语义)。 */
target: string
/** `targetType === 'contact'` 时的联系人。 */
targetContactId: string
enabled: boolean
}
/** 全部消息类型(旧页面默认全选)。 */
export const ALL_SCHEDULED_REPORT_MESSAGE_TYPES: SummaryMessageType[] = SUMMARY_TYPE_OPTIONS.map(
(option) => option.value
)
/** 成员名称选项(与旧页面一致)。 */
export const SCHEDULED_REPORT_MEMBER_NAME_OPTIONS: ReadonlyArray<{
value: ScheduledReportMemberNameMode
label: string
}> = [
{ value: 'groupNickname', label: '群昵称' },
{ value: 'wechatNickname', label: '微信昵称' },
{ value: 'remark', label: '通讯录备注' }
]
/**
* 发送目标四选一。
*
* 第一项与退群通知不同:定时日报没有"当前消息所在群"这个概念,
* 所以是「日报来源群」。刻意**不提供"指定其他群聊"** ——
* 生成 A 群日报却发到 B 群是很容易让人误解的配置。
*/
export type ScheduledReportTargetType = 'source_chat' | 'self' | 'file_transfer' | 'contact'
export const SCHEDULED_REPORT_TARGET_OPTIONS: ReadonlyArray<{
type: ScheduledReportTargetType
label: string
description: string
}> = [
{
type: 'file_transfer',
label: '文件传输助手',
description: '将生成的日报发送到文件传输助手。'
},
{
type: 'source_chat',
label: '发送到日报来源群',
description: '把生成的日报发送回用于生成日报的那个群。'
},
{
type: 'self',
label: '发给自己',
description: '将生成的日报发送到当前登录微信账号自己的会话。'
},
{
type: 'contact',
label: '指定好友',
description: '将生成的日报发送给一个指定联系人。'
}
]
/** 新建时的默认发送目标:文件传输助手(不会误打扰群聊)。 */
export const DEFAULT_SCHEDULED_REPORT_TARGET_TYPE: ScheduledReportTargetType = 'file_transfer'
/** 模板选项:与旧页面同一套定义(默认模板 + REPORT_TEMPLATES)。 */
export const SCHEDULED_REPORT_TEMPLATE_SELECT_OPTIONS: ReadonlyArray<{
value: SelectableReportTemplateId
label: string
}> = [
{ value: DEFAULT_REPORT_TEMPLATE.id, label: DEFAULT_REPORT_TEMPLATE.name },
...REPORT_TEMPLATES.map((template) => ({
value: template.id,
label: `${template.label} · ${template.name}`
}))
]
/** 日报范围选项。文案与旧「日报 → 定时日报」页面保持一致。 */
export const SCHEDULED_REPORT_RANGE_OPTIONS: ReadonlyArray<{
value: ScheduledReportRange
label: string
}> = [
{ value: 'today', label: '今日' },
{ value: 'yesterday', label: '昨日' },
{ value: '7days', label: '近 7 天' },
{ value: 'recent24h', label: '最近24小时' }
]
/** 可选日报模板(真实定义来自 `report-templates.ts`)。 */
export const SCHEDULED_REPORT_TEMPLATE_OPTIONS = SELECTABLE_REPORT_TEMPLATES.map((template) => ({
value: template.id,
label: template.label
}))
export const DEFAULT_SCHEDULED_REPORT_TEMPLATE_ID: SelectableReportTemplateId = 'v1'
/** 与旧页面同语义的范围文案(`近 7 天` 的空格等细节刻意保持一致)。 */
export function scheduledReportRangeLabel(range: ScheduledReportRange): string {
return (
SCHEDULED_REPORT_RANGE_OPTIONS.find((option) => option.value === range)?.label ??
SCHEDULED_REPORT_RANGE_OPTIONS[0].label
)
}
/**
* 时间展示:`今日 18:30` / `明日 18:30`。
*
* 这里只服务**本地草稿预览**(用户刚改完时间就要看到结果),
* 所以用"今日/明日"表达相对关系;真实任务的 `nextRunAt` 展示沿用旧页面语义。
*/
export function formatScheduledSlot(scheduleTime: string, now = new Date()): string {
const [hour, minute] = parseScheduleTime(scheduleTime)
const slot = new Date(now)
slot.setSeconds(0, 0)
slot.setHours(hour, minute)
if (slot.getTime() <= now.getTime()) slot.setDate(slot.getDate() + 1)
const isToday = slot.toDateString() === now.toDateString()
return `${isToday ? '今日' : '明日'} ${pad2(hour)}:${pad2(minute)}`
}
/** 把真实的 `nextRunAt` 格式化成旧页面同款文案。 */
export function formatNextRunAt(value?: string): string {
if (!value) return '尚未执行'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '时间未知'
const now = new Date()
const dayLabel =
date.toDateString() === now.toDateString() ? '今日' : date.toLocaleDateString('zh-CN')
return `${dayLabel} ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`
}
export function parseScheduleTime(value: string): [number, number] {
const matched = /^(\d{1,2}):(\d{2})$/.exec(String(value ?? '').trim())
if (!matched) return [18, 30]
const hour = Math.min(23, Math.max(0, Number(matched[1])))
const minute = Math.min(59, Math.max(0, Number(matched[2])))
return [hour, minute]
}
export function composeScheduleTime(hour: number, minute: number): string {
return `${pad2(Math.min(23, Math.max(0, hour)))}:${pad2(Math.min(59, Math.max(0, minute)))}`
}
export function createEmptyScheduledReportDraft(): ScheduledReportDraft {
return {
name: '',
scheduleTime: '18:30',
reportRange: 'today',
group: '',
// 与旧页面一致:默认纳入全部消息类型。
messageTypes: [...ALL_SCHEDULED_REPORT_MESSAGE_TYPES],
memberNameMode: 'groupNickname',
timeoutSeconds: 300,
templateId: DEFAULT_SCHEDULED_REPORT_TEMPLATE_ID,
// 默认发到文件传输助手:不会误打扰任何群聊。
targetType: DEFAULT_SCHEDULED_REPORT_TARGET_TYPE,
target: '',
targetContactId: '',
enabled: true
}
}
/** 真实任务 → 编辑器草稿(只用于**预览**,保存不落盘)。 */
export function createDraftFromTask(task: ScheduledReportTask): ScheduledReportDraft {
return {
name: task.name,
scheduleTime: task.scheduleTime,
reportRange: task.reportRange,
group: task.group,
messageTypes: task.messageTypes?.length
? (task.messageTypes as SummaryMessageType[])
: [...ALL_SCHEDULED_REPORT_MESSAGE_TYPES],
memberNameMode: task.memberNameMode ?? 'groupNickname',
timeoutSeconds: task.timeoutSeconds ?? 300,
templateId: task.templateId ?? DEFAULT_SCHEDULED_REPORT_TEMPLATE_ID,
// 旧页面里「发送到」恒等于来源群(只读展示),所以历史任务一律按"发回来源群"回显。
targetType: 'source_chat',
target: task.target,
targetContactId: '',
enabled: task.enabled
}
}
/**
* 把任务里存的群标识解析成**能给用户看**的名字。
*
* `ScheduledReportTask.group/target` 的真实类型是 "human-readable group name or room id",
* 实际数据里两种都可能出现(纯数字 room id 与 16 进制 hash)。
* 直接渲染就会把内部标识暴露给用户,所以这里做一次显示名解析:
* 能对上群名就用群名;明显是内部标识又对不上时显示「未知群聊」,**绝不显示裸 id**。
*/
export function resolveGroupDisplayName(
raw: string,
groups: Array<{ id: string; name: string }>
): string {
const value = String(raw ?? '').trim()
if (!value) return ''
const matched = groups.find((group) => group.id === value) ?? groups.find((g) => g.name === value)
if (matched) return matched.name
return looksLikeInternalIdentifier(value) ? '未知群聊' : value
}
/** 内部标识形态:room id / 16 进制 hash / 纯数字串。 */
function looksLikeInternalIdentifier(value: string): boolean {
return (
value.includes('@chatroom') || /^[0-9a-f]{16,}$/i.test(value) || /^\d{6,}$/.test(value)
)
}
/**
* 模板的**展示名**。
*
* 用 `name`("经典日报")而不是 `label`("默认模板"):
* 同一个模板在列表、预览、选择器里必须是同一个称呼,
* 否则同一份配置会出现两种叫法。
*/
export function scheduledReportTemplateLabel(templateId?: string): string {
return getReportTemplate(templateId).name
}
/**
* 只读加载现有定时日报任务。
*
* **唯一的读取入口**,也是本文件与真实系统唯一的接触面:
* - 不调用 create / update / delete / execute / toggle;
* - 接口不存在或失败时返回空数组,让界面走空态,而不是伪造数据。
*/
export async function loadScheduledReportTasks(): Promise<ScheduledReportTask[]> {
const target = (globalThis as { api?: Record<string, unknown> }).api
const loader = target?.listScheduledReports
if (typeof loader !== 'function') return []
try {
const tasks = await (loader as () => Promise<unknown>).call(target)
return Array.isArray(tasks) ? (tasks as ScheduledReportTask[]) : []
} catch {
return []
}
}
function pad2(value: number): string {
return String(value).padStart(2, '0')
}
@@ -39,6 +39,13 @@ interface GroupExitMonitorWorkspaceProps {
onOpenSendSettings?: () => void
/** 深链请求:直接落到「退群监控 → 管理群聊」。 */
openViewRequest?: GroupExitMonitorOpenViewRequest | null
/**
* 深链请求已被消费(父层据此把它清掉)。
*
* **不给这个回调,深链就会变成常驻状态**:本组件在切换一级菜单时会被卸载,
* 而请求还挂在父层,于是下次正常点「退群监控」也会被重放,直接落到「管理群聊」。
*/
onOpenViewRequestHandled?: () => void
/** 「退群通知自动化 →」:跳到自动化里对应的规则类型(纯导航)。 */
onOpenLeaveNotificationAutomation?: () => void
}
@@ -307,6 +314,7 @@ export function GroupExitMonitorWorkspace({
contacts = [],
onOpenSendSettings,
openViewRequest,
onOpenViewRequestHandled,
onOpenLeaveNotificationAutomation
}: GroupExitMonitorWorkspaceProps): React.ReactElement {
const [state, setState] = React.useState<GroupExitMonitorState>(EMPTY_STATE)
@@ -383,11 +391,19 @@ export function GroupExitMonitorWorkspace({
}
}, [])
// 「自动化 → 退群通知 → 管理监控群聊」深链过来时,直接落到管理群聊页。
/**
* 「自动化 → 退群通知 → 管理监控群聊」深链过来时,直接落到管理群聊页。
*
* 请求是**一次性的**:落位之后立刻回报父层把它清掉。以前只 `setView` 不回报,
* 请求就一直留在父层;而 `renderCurrentWorkspace()` 是 switch,切一级菜单会卸载
* 本组件 —— 于是下次正常点「退群监控」时,这条陈旧的 `{view:'manage'}` 被重放,
* 用户看到的就是"点退群监控却进了管理群聊"。
*/
React.useEffect(() => {
if (!openViewRequest) return
setView(openViewRequest.view)
}, [openViewRequest])
onOpenViewRequestHandled?.()
}, [openViewRequest, onOpenViewRequestHandled])
/**
* 「复制退群信息」用的模板。
@@ -631,7 +647,7 @@ export function GroupExitMonitorWorkspace({
<SendCapabilityStatus capability={sendCapability} />
{/*
发送能力不完整时给一个可操作的去处。
§「规则仍然可以保存」:这里只提示,不阻断任何编辑。
这里只提示,不阻断任何编辑。
*/}
{sendCapability &&
!(sendCapability.ready && sendCapability.capabilities?.text) &&
+22 -1
View File
@@ -1243,7 +1243,8 @@
margin-top: 10px;
}
// 只读的「已监控群聊」摘要:范围归退群监控管,这里不重复维护一份选择状态。
// 只读的「已监控群聊」摘要:监控范围归退群监控管,这里不重复维护一份选择状态。
// (本规则只管**二次勾选**:下面 .automation-leave-scope 那一层。)
.automation-leave-groups-card {
display: flex;
min-width: 0;
@@ -1277,6 +1278,26 @@
}
}
// 「通知范围」二次勾选:全部已监控群聊 / 仅选中的群聊。
// 复用 .automation-leave-radio 那一套单行样式(radio + 标题/说明两行),
// 所以这里只补"整块纵向排布 + 选项间距 + 覆盖摘要"。
.automation-leave-scope {
display: flex;
min-width: 0;
flex-direction: column;
gap: 6px;
}
.automation-leave-scope-options {
gap: 2px;
}
.automation-leave-scope-summary {
color: var(--wxex-text-muted);
font-size: 12px;
line-height: 17px;
}
// 「通知发送到哪里」四选一。
.automation-leave-targets {
gap: 2px;
+27
View File
@@ -72,6 +72,33 @@
flex: 1 1 auto;
}
/*
* 「定时日报已并入自动化」的指引条。
*
* ⚠️ 这两个类以前只出现在 JSX 里,样式表里**从来没有**对应规则 ——
* 所以它一直是一条没样式的裸文字 + 一个没样式的裸按钮。
* 现在补上:与 tab 条同宽的柔软信息条,按钮交给 UI 组件库。
*/
.report-workspace-hint {
display: flex;
flex: 0 0 auto;
min-width: 0;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px 12px;
margin: 0;
padding: 10px 24px;
border-bottom: 1px solid var(--wxex-border);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-secondary);
font: 400 12px/18px var(--wxex-font);
> span {
min-width: 0;
}
}
.report-workspace-body > .report-page,
.report-workspace-body > .report-center-page {
height: 100%;
+2 -2
View File
@@ -55,10 +55,10 @@ export function matchAutomationRule(
): AutomationMatchResult {
// **规则类型闸门放在最前面。**
//
// 「退群通知」的空关键词 + 不要求 @我,会让它命中**每一条群消息** ——
// 「退群通知」与「定时日报」的空关键词 + 不要求 @我,会让它们命中**每一条群消息** ——
// 那是最坏的一类 bug:用户什么都没配,规则却开始乱跑。
// 类型分派必须在这里一处解决,预览与真实执行才会一致。
if (rule.ruleType === 'leave_notification') {
if (rule.ruleType !== 'daily_report') {
return { matched: false, reason: 'rule_type' }
}
+595 -22
View File
@@ -6,6 +6,15 @@
*/
import { normalizeGroupExitNotificationTemplate } from './group-exit-monitor'
import {
isSelectableReportTemplateId,
type SelectableReportTemplateId
} from './report-templates'
import type {
ScheduledReportMemberNameMode,
ScheduledReportMessageType,
ScheduledReportRange
} from './scheduled-report'
/** 触发事件类型。第一版只支持「收到消息」。 */
export type AutomationTriggerType = 'message'
@@ -26,16 +35,18 @@ export type AutomationMessageScope = 'group' | 'direct' | 'all'
*
* 决定**这条规则由什么驱动、有哪些字段有意义**:
* - `daily_report`:消息驱动,条件 + 动作链(@我生成日报);
* - `scheduled_report`:时间驱动,日程 + 日报配置 + 发送目标(定时日报);
* - `leave_notification`:退群事件驱动,通知目标 + 模板(退群通知)。
*
* ⚠️ 这是一条**硬分派**:`matchAutomationRule` 会直接拒绝非 `daily_report` 的规则,
* 否则一条 keyword 为空的退群通知会命中**每一条群消息**。
* ⚠️ 这是一条**硬分派**:`matchAutomationRule` 只放行 `daily_report`,
* 否则一条 keyword 为空的定时/退群规则会命中**每一条群消息**。
*/
export type AutomationRuleType = 'daily_report' | 'leave_notification'
export type AutomationRuleType = 'daily_report' | 'scheduled_report' | 'leave_notification'
/** 规则类型的界面标签(Tab 与首页卡片共用一处文案)。 */
export const AUTOMATION_RULE_TYPE_LABELS: Record<AutomationRuleType, string> = {
daily_report: '@我生成日报',
scheduled_report: '定时日报',
leave_notification: '退群通知'
}
@@ -52,16 +63,350 @@ export interface LeaveNotificationTarget {
contactId?: string
}
/**
* 退群通知的**通知范围** —— 对「退群监控 → 管理群聊」选中集合的二次筛选。
*
* 旧版这里是 `notificationRoomIds`(per-group 多值):管理群聊选 N 个群,
* 通知群聊再从这 N 个里勾子集。迁移到自动化时被压成单值目标,二次筛选能力随之丢失,
* 于是"规则一启用就全量通知"。本字段把它恢复回来 —— 语义与旧版逐字对齐:
*
* - `all` = 全部已监控群聊。**旧规则缺字段时的归一化结果**,保持既有行为不变。
* - `selected` = 仅 `notifyRoomIds` 里的群。
*/
export type LeaveNotificationNotifyScope = 'all' | 'selected'
export interface LeaveNotificationConfig {
target: LeaveNotificationTarget
template: string
/**
* 迁移时旧配置**无法无损映射**的标记(旧「通知群聊」是 per-group 多值,
* 新目标是单值)。为 true 时:规则不执行发送,UI 提示用户重选目标。
* 通知范围:哪些**已监控**群聊值得发通知。
*
* 与 `target` **正交**,两者回答的是不同问题:
* - `target` → 通知**发到哪里**(当前群聊 / 自己 / 文件助手 / 指定好友)
* - `notifyScope` → **哪些群的退群**才值得发
*/
notifyScope: LeaveNotificationNotifyScope
/**
* `notifyScope === 'selected'` 时生效的已监控群聊子集(roomId,即 `xxx@chatroom`)。
*
* 选中的 id 在本字段里**记住**,所以「全部 ⇄ 仅选中」来回切换不会丢掉用户的勾选;
* 但它只在 `notifyScope === 'selected'` 时被读取 —— `notifyScope` 是唯一权威。
*
* 范围外的 id 不具备危害:未被监控的群不会产生退群事件,自然永不命中。
*/
notifyRoomIds: string[]
/**
* 迁移时旧配置**无法无损映射**的标记。
*
* ⚠️ 退群通知的迁移**已经不再产生**它(子集现在可无损表达,见
* `leave-notification-migration.ts`)。保留只为两件事:
* 1. 存量安装里**已经落盘**的 `targetNeedsReview: true` 仍要拦发送,等用户重选;
* 2. 定时日报迁移确实还需要它(`scheduled_report` 的跨群目标是新产品不支持的形态)。
*
* 为 true 时:规则不执行发送,UI 提示用户重选目标。用户手动保存一次即清除。
*/
targetNeedsReview?: boolean
}
/**
* 定时日报发到哪里。刻意只有这四种,**不提供「指定另一个群聊」** ——
* 生成 A 群日报却发到 B 群是最容易让人误解的配置。
*/
export type ScheduledReportTargetType = 'source_chat' | 'self' | 'file_transfer' | 'contact'
export interface ScheduledReportTarget {
type: ScheduledReportTargetType
/** `contact` 时必填:**稳定 id(wxid / username)**,不是昵称。 */
contactId?: string
}
/**
* 定时日报的规则配置。
*
* 字段一一对应历史「定时日报任务」的既有能力,
* 没有任何"按 UI 猜出来的"配置,也没有丢掉任何一项已有能力。
*/
export interface ScheduledReportAutomationConfig {
schedule: {
/** `HH:mm`,**每天一次**。当前只支持每日。 */
time: string
}
report: {
/**
* 日报来源群的**稳定会话 id**(`xxx@chatroom`)。
*
* ⚠️ 迁移过来的老数据可能仍是旧形态(会话 md5 / 群名)—— 那是历史事实,
* 不在这里"猜着改";运行期解析失败会如实报错(不静默换群)。
*/
sourceConversationId: string
range: ScheduledReportRange
messageTypes: ScheduledReportMessageType[]
templateId: SelectableReportTemplateId
memberNameMode: ScheduledReportMemberNameMode
timeoutSeconds: number
}
target: ScheduledReportTarget
/**
* 日报图片**发送成功之后**再补发的一句话(「后置词」)。
*
* 与「手动发送日报图片」里的后置词是同一件事,但这里是**按规则存**:
* 定时日报按群发,A 群和 B 群想跟的话未必一样;统一塞进全局设置反而更别扭。
*
* 空字符串 = 只发图片。存量规则迁移过来就是空(`normalizeScheduledReportConfig`
* 不再凭空补默认值)—— 「上线后突然多出一条文本消息」是绝不能接受的静默行为变化。
*/
postfixText?: string
/**
* 迁移时旧目标**无法无损映射**的标记(例如"生成 A 群日报 → 发 B 群",
* 或旧目标解析不到)。为 true 时:规则不执行、不创建记录,UI 提示重选目标。
*
* 只有迁移会把它置为 true,用户手动保存一次即清除。
*/
targetNeedsReview?: boolean
/**
* 迁移保留的**旧目标原文**。仅供用户在编辑器里看清"原来配的是哪个",
* **永远不会**被当作发送目标使用。
*/
legacyTarget?: string
/** 迁移保留的**旧来源群原文**(同理,只作展示)。 */
legacySourceGroup?: string
/**
* 最近一次执行的完成时间(ISO)。**调度游标,不是用户配置**:
* 由 scheduler 写,`updateRule` 会保留它,草稿改不动它。
*/
lastRunAt?: string
/**
* 已消费的每日槽位(ISO)。**幂等标记,不是用户配置**。
* 用于避免同一天重复补跑、以及重启后重复执行同一槽位。
*/
lastScheduledSlot?: string
}
/** 定时日报的四种发送目标(展示层单一来源)。 */
export const SCHEDULED_REPORT_TARGET_OPTIONS: ReadonlyArray<{
type: ScheduledReportTargetType
label: string
description: string
}> = [
{
type: 'source_chat',
label: '发送到日报来源群',
description: '把生成的日报发送回用于生成日报的那个群。'
},
{
type: 'file_transfer',
label: '文件传输助手',
description: '将生成的日报发送到文件传输助手。'
},
{
type: 'self',
label: '发给自己',
description: '将生成的日报发送到当前登录微信账号自己的会话。'
},
{
type: 'contact',
label: '指定好友',
description: '将生成的日报发送给一个指定联系人。'
}
]
/**
* 新建定时日报的默认目标:文件传输助手。
*
* 与退群通知不同(那边默认「当前群聊」):
* 定时日报新建时**绝不能默认发回群里** —— 用户还没确认过就自动往群里发日报,
* 是最容易造成打扰的一类默认值。
*/
export const SCHEDULED_REPORT_DEFAULT_TARGET_TYPE: ScheduledReportTargetType = 'file_transfer'
/** 目标类型 → 界面短标签。 */
export function scheduledReportTargetLabel(type: ScheduledReportTargetType): string {
return SCHEDULED_REPORT_TARGET_OPTIONS.find((option) => option.type === type)?.label ?? ''
}
/** 日报范围的界面文案(与旧「定时日报」页面逐字一致)。 */
export const SCHEDULED_REPORT_RANGE_LABELS: Record<ScheduledReportRange, string> = {
today: '今日',
yesterday: '昨日',
'7days': '近 7 天',
recent24h: '最近24小时'
}
export function scheduledReportRangeLabel(range: ScheduledReportRange): string {
return SCHEDULED_REPORT_RANGE_LABELS[range] ?? SCHEDULED_REPORT_RANGE_LABELS.today
}
/** 成员名称选项(与旧页面一致)。 */
export const SCHEDULED_REPORT_MEMBER_NAME_OPTIONS: ReadonlyArray<{
value: ScheduledReportMemberNameMode
label: string
}> = [
{ value: 'groupNickname', label: '群昵称' },
{ value: 'wechatNickname', label: '微信昵称' },
{ value: 'remark', label: '通讯录备注' }
]
export const SCHEDULED_REPORT_MEMBER_NAME_LABELS: Record<ScheduledReportMemberNameMode, string> = {
groupNickname: '群昵称',
wechatNickname: '微信昵称',
remark: '通讯录备注'
}
export const SCHEDULED_REPORT_DEFAULT_TIME = '18:30'
export const SCHEDULED_REPORT_DEFAULT_RANGE: ScheduledReportRange = 'today'
export const SCHEDULED_REPORT_DEFAULT_TEMPLATE_ID: SelectableReportTemplateId = 'v1'
export const SCHEDULED_REPORT_DEFAULT_MEMBER_NAME_MODE: ScheduledReportMemberNameMode =
'groupNickname'
export const SCHEDULED_REPORT_DEFAULT_TIMEOUT_SECONDS = 300
export const SCHEDULED_REPORT_MIN_TIMEOUT_SECONDS = 30
export const SCHEDULED_REPORT_MAX_TIMEOUT_SECONDS = 1800
/**
* 后置词长度上限。
*
* 与手动发送日报图片那个输入框(`ReportImagePostfixInput` 的 `maxLength`)
* **必须一致**:同一句话在两个入口能存下不同长度,是最没道理的一种不一致。
*/
export const SCHEDULED_REPORT_POSTFIX_MAX_LENGTH = 200
/** 全部消息类型(新建时的默认:与旧页面一致,全选)。 */
export const SCHEDULED_REPORT_ALL_MESSAGE_TYPES: ScheduledReportMessageType[] = [
'text',
'image',
'sticker',
'video',
'voice',
'share',
'system'
]
const SCHEDULED_REPORT_RANGES: ScheduledReportRange[] = ['today', 'yesterday', '7days', 'recent24h']
/** `HH:mm` 校验。 */
export function isValidScheduleTime(value: unknown): boolean {
return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(String(value ?? '').trim())
}
/** 任意输入 → 合法 `HH:mm`;非法回落到默认时间。 */
export function normalizeScheduleTime(value: unknown): string {
return isValidScheduleTime(value) ? String(value).trim() : SCHEDULED_REPORT_DEFAULT_TIME
}
/**
* 归一化「后置词」。
*
* 去首尾空白 + 截断到上限。**空就是空** —— 绝不回落到
* `DEFAULT_REPORT_IMAGE_POSTFIX_TEXT`,那会让每一条存量规则在上线后
* 突然多发出一句文本,属于静默的行为变化。
*/
export function normalizeScheduledReportPostfixText(input: unknown): string {
return String(input ?? '')
.trim()
.slice(0, SCHEDULED_REPORT_POSTFIX_MAX_LENGTH)
}
/**
* 下一次执行时刻(本地时间)。
*
* **source of truth 是 `schedule.time`**:这个函数是纯派生,
* `nextRunAt` 不再作为可写配置持久化。
*/
export function calculateNextRunAt(scheduleTime: string, from: Date = new Date()): string {
if (!isValidScheduleTime(scheduleTime)) throw new Error('执行时间必须是 HH:mm')
const [hour, minute] = String(scheduleTime).trim().split(':').map(Number)
const next = new Date(from)
next.setHours(hour, minute, 0, 0)
if (next.getTime() <= from.getTime()) next.setDate(next.getDate() + 1)
return next.toISOString()
}
/** 收敛定时日报配置:未知值一律回落到安全默认,绝不落一条跑不起来的规则。 */
export function normalizeScheduledReportConfig(
input: unknown
): ScheduledReportAutomationConfig {
const raw = (input ?? {}) as Partial<ScheduledReportAutomationConfig>
const scheduleRaw = (raw.schedule ?? {}) as Partial<ScheduledReportAutomationConfig['schedule']>
const reportRaw = (raw.report ?? {}) as Partial<ScheduledReportAutomationConfig['report']>
const targetRaw = (raw.target ?? {}) as Partial<ScheduledReportTarget>
const allowedTargets: ScheduledReportTargetType[] = [
'source_chat',
'self',
'file_transfer',
'contact'
]
const targetType: ScheduledReportTargetType = allowedTargets.includes(
targetRaw.type as ScheduledReportTargetType
)
? (targetRaw.type as ScheduledReportTargetType)
: SCHEDULED_REPORT_DEFAULT_TARGET_TYPE
const contactId = String(targetRaw.contactId ?? '').trim()
// `contact` 但没有 id → 回落默认目标(与退群通知同口径):宁可发文件助手,
// 也不能存一条必然失败的规则。
const effectiveTargetType: ScheduledReportTargetType =
targetType === 'contact' && !contactId ? SCHEDULED_REPORT_DEFAULT_TARGET_TYPE : targetType
const range: ScheduledReportRange = SCHEDULED_REPORT_RANGES.includes(
reportRaw.range as ScheduledReportRange
)
? (reportRaw.range as ScheduledReportRange)
: SCHEDULED_REPORT_DEFAULT_RANGE
const messageTypes = Array.isArray(reportRaw.messageTypes)
? (reportRaw.messageTypes
.map((type) => String(type))
.filter((type) =>
SCHEDULED_REPORT_ALL_MESSAGE_TYPES.includes(type as ScheduledReportMessageType)
) as ScheduledReportMessageType[])
: []
const effectiveMessageTypes: ScheduledReportMessageType[] = messageTypes.length
? messageTypes
: ['text']
const memberNameMode: ScheduledReportMemberNameMode =
reportRaw.memberNameMode === 'wechatNickname' || reportRaw.memberNameMode === 'remark'
? reportRaw.memberNameMode
: SCHEDULED_REPORT_DEFAULT_MEMBER_NAME_MODE
const rawTimeout = Number(reportRaw.timeoutSeconds)
const timeoutSeconds = Number.isFinite(rawTimeout)
? Math.max(
SCHEDULED_REPORT_MIN_TIMEOUT_SECONDS,
Math.min(SCHEDULED_REPORT_MAX_TIMEOUT_SECONDS, Math.round(rawTimeout))
)
: SCHEDULED_REPORT_DEFAULT_TIMEOUT_SECONDS
const legacyTarget = String(raw.legacyTarget ?? '').trim()
const legacySourceGroup = String(raw.legacySourceGroup ?? '').trim()
const lastRunAt = String(raw.lastRunAt ?? '').trim()
const lastScheduledSlot = String(raw.lastScheduledSlot ?? '').trim()
const postfixText = normalizeScheduledReportPostfixText(raw.postfixText)
return {
schedule: { time: normalizeScheduleTime(scheduleRaw.time) },
report: {
sourceConversationId: String(reportRaw.sourceConversationId ?? '').trim(),
range,
messageTypes: effectiveMessageTypes,
// 必须**校验**而不是只兜空值:旧 tasks.json 里可能存着本版本已经没有的模板 id,
// 原样透传会得到一条"配置看着在、运行期生成不出来"的规则。
templateId: isSelectableReportTemplateId(reportRaw.templateId)
? reportRaw.templateId
: SCHEDULED_REPORT_DEFAULT_TEMPLATE_ID,
memberNameMode,
timeoutSeconds
},
target: {
type: effectiveTargetType,
...(effectiveTargetType === 'contact' ? { contactId } : {})
},
...(postfixText ? { postfixText } : {}),
...(raw.targetNeedsReview === true ? { targetNeedsReview: true } : {}),
...(legacyTarget ? { legacyTarget } : {}),
...(legacySourceGroup ? { legacySourceGroup } : {}),
...(lastRunAt ? { lastRunAt } : {}),
...(lastScheduledSlot ? { lastScheduledSlot } : {})
}
}
export interface AutomationConditions {
@@ -110,6 +455,8 @@ export interface AutomationRule {
replyDelaySeconds: number
/** `ruleType === 'leave_notification'` 时必填。 */
leaveNotification?: LeaveNotificationConfig
/** `ruleType === 'scheduled_report'` 时必填。 */
scheduledReport?: ScheduledReportAutomationConfig
createdAt: number
updatedAt: number
}
@@ -120,12 +467,18 @@ export type AutomationStepStatus = 'pending' | 'running' | 'success' | 'failed'
/**
* 执行步骤键。
*
* 分两族,**不共用**:
* 分三族,**不共用**:
* - 消息型(`daily_report`):received / matched / reply / report / send
* - 定时型(`scheduled_report`):schedule_triggered / report_generating / report_generated /
* send_resolved / report_sent
* - 退群型(`leave_notification`):exit_received / exit_matched / exit_target / exit_send
*
* 不共用的理由:给退群通知渲染一个「回复确认 已跳过」的步骤是纯噪声,
* 不共用的理由:给定时日报渲染一个「回复确认 已跳过」的步骤是纯噪声,
* 用户会以为规则配错了。
*
* ⚠️ 定时型的步骤键**刻意拆开「生成中 / 已生成」与「确定目标 / 已发送」**:
* 「生成成功但发送失败」要求用户能一眼看出**日报确实生成了**,只是没发出去。
* 合成一步就没法表达这件事。
*/
export type AutomationStepKey =
| 'received'
@@ -133,6 +486,11 @@ export type AutomationStepKey =
| 'reply'
| 'report'
| 'send'
| 'schedule_triggered'
| 'report_generating'
| 'report_generated'
| 'send_resolved'
| 'report_sent'
| 'exit_received'
| 'exit_matched'
| 'exit_target'
@@ -188,11 +546,33 @@ export interface AutomationStep {
*/
export type AutomationExecutionStatus = 'running' | 'success' | 'failed'
/**
* 一次执行是**被什么触发**的。
*
* - `message`:群里出现符合条件的消息(@我生成日报);
* - `exit`:检测到群成员退出(退群通知);
* - `schedule`:到了设定时间(定时日报);
* - `manual`:用户在列表里点了「立即执行」(定时日报)。
*
* 区分 `schedule` 与 `manual` 是硬要求:否则看日志没人知道"为什么 17:03 跑了一次"。
* 旧记录没有这个字段(读盘时保持 `undefined`,不猜测、不编造)。
*/
export type AutomationExecutionTrigger = 'message' | 'exit' | 'schedule' | 'manual'
export const AUTOMATION_EXECUTION_TRIGGER_LABELS: Record<AutomationExecutionTrigger, string> = {
message: '收到消息',
exit: '检测到成员退出',
schedule: '定时任务',
manual: '手动立即执行'
}
export interface AutomationExecution {
executionId: string
ruleId: string
ruleName: string
triggerTime: number
/** 触发方式。旧记录缺省(`undefined`)—— 读盘不猜测。 */
trigger?: AutomationExecutionTrigger
/**
* 来源的**显示名**(群名 / 联系人昵称)。
*
@@ -206,6 +586,31 @@ export interface AutomationExecution {
errorSummary?: string
}
/**
* 定时日报一次执行的结果(scheduler 与「立即执行」共用)。
*
* 放在 shared 层是因为它要跨三层:`AutomationService` 产出 → IPC 回执 → 渲染层提示。
*/
export interface ScheduledRuleRunOutcome {
/** 是否真的跑了一次。false 时 `reason` 说明为什么没跑。 */
executed: boolean
/** 本次执行的 id(定时触发时为**确定性** id,同一槽位重启后不变)。 */
executionId?: string
status?: AutomationExecutionStatus
/** 日报是否已生成并落库(`false` 且 `status==='failed'` 表示"根本没生成出来")。 */
reportGenerated?: boolean
/** 底层生成错误的机器可读码(如 `NO_MESSAGES`)。 */
errorCode?: string
/** 失败时用户可读的一句话摘要。 */
errorSummary?: string
/**
* 未执行的原因(机器可读):
* `missing_rule` / `rule_not_found` / `disabled` / `target_needs_review` /
* `in_flight` / `store_error` / `database_not_ready`。
*/
reason?: string
}
/**
* 发送用途标识(对应 `WechatActionPurpose`)。
*
@@ -225,7 +630,21 @@ export const AUTOMATION_SEND_PURPOSE = {
* 的作用域锁绑死在「只能发回事件所在群」,而现在的目标可以是自己 / 文件传输助手 /
* 指定好友。用新 purpose 才能真正解除那个锁,同时让旧 purpose 变成无生产者的历史值。
*/
leaveNotification: 'automation_leave_notification'
leaveNotification: 'automation_leave_notification',
/**
* 定时日报的图片发送。
*
* 刻意**不复用**历史 purpose `scheduled_report`:那一个由已退役的旧定时日报链路发出,
* 保留它只为了让历史审计记录仍可读。新链路用新 purpose,便于在审计里区分两代实现。
*/
scheduledReport: 'automation_scheduled_report',
/**
* 定时日报的**后置词**发送(图片 sent 之后补发的那条文本)。
*
* 与图片用**不同的 purpose**:两条消息是两次独立发送,各自要有自己的幂等位 ——
* 共用一个 key 会让「图片先发成功、后置词那次被短路」变成常态。
*/
scheduledReportPostfix: 'automation_scheduled_report_postfix'
} as const
/** 审计日志里的来源标识(`WechatActionOrigin`)。 */
@@ -286,6 +705,8 @@ export interface AutomationRuleDraft {
replyDelaySeconds: number
/** `ruleType === 'leave_notification'` 时的通知配置。 */
leaveNotification?: LeaveNotificationConfig
/** `ruleType === 'scheduled_report'` 时的日报配置。 */
scheduledReport?: ScheduledReportAutomationConfig
}
/** 顶部状态条数据(全部来自 main 侧真实能力,UI 不做平台判断)。 */
@@ -310,27 +731,20 @@ export interface AutomationStatusSummary {
/**
* 规则列表里的**模板入口**。
*
* `available: false` 表示该能力本轮**尚未接通** —— UI 必须显示为「未启用 / 即将支持」,
* 不允许伪装成可运行。这是防止设计稿倒逼假数据的硬约束。
* `available: false` 表示该能力**尚未接通** —— UI 必须显示为「未启用 / 即将支持」,
* 不得伪装成可运行;否则设计稿会倒逼出假数据。
*/
export interface AutomationRuleTemplate {
id: string
name: string
description: string
/** 本轮是否已接通的真实能力。 */
/** 该能力是否已经真正接通。 */
available: boolean
/** 未接通时的说明文案。 */
unavailableReason?: string
}
export const AUTOMATION_RULE_TEMPLATES: AutomationRuleTemplate[] = [
{
id: 'scheduled-report',
name: '定时发送日报',
description: '按固定时间自动生成并发送群日报',
available: false,
unavailableReason: ''
},
{
id: 'keyword-reply',
name: '关键词自动回复',
@@ -360,6 +774,11 @@ export const AUTOMATION_STEP_LABELS: Record<AutomationStepKey, string> = {
reply: '回复确认',
report: '生成日报',
send: '发送日报图片',
schedule_triggered: '定时任务触发',
report_generating: '生成日报',
report_generated: '日报已生成',
send_resolved: '确定发送目标',
report_sent: '发送日报',
exit_received: '检测到成员退出',
exit_matched: '规则匹配',
exit_target: '确定通知目标',
@@ -450,13 +869,41 @@ export const LEAVE_NOTIFICATION_TARGET_OPTIONS: ReadonlyArray<{
*/
export const LEAVE_NOTIFICATION_DEFAULT_TARGET_TYPE: LeaveNotificationTargetType = 'source_chat'
/**
* 通知范围的默认值:全部已监控群聊。
*
* 选 `all` 而不是 `selected`:新装用户还没做过二次勾选,此时"不通知任何群"
* 会让规则看起来启用了却永远静默 —— 与旧版 `source_chat` 的既有行为也不一致。
*/
export const LEAVE_NOTIFICATION_DEFAULT_NOTIFY_SCOPE: LeaveNotificationNotifyScope = 'all'
/**
* 归一化「通知群聊」子集。
*
* 去重 + 去空白 + 丢非字符串。**不**在这里与监控范围求交集 ——
* shared 读不到退群监控的状态,硬做只会得到第二份"监控范围"的真相。
*/
export function normalizeLeaveNotificationRoomIds(input: unknown): string[] {
if (!Array.isArray(input)) return []
const result: string[] = []
const seen = new Set<string>()
for (const value of input) {
if (typeof value !== 'string') continue
const roomId = value.trim()
if (!roomId || seen.has(roomId)) continue
seen.add(roomId)
result.push(roomId)
}
return result
}
/** 目标类型 → 界面短标签。 */
export function leaveNotificationTargetLabel(type: LeaveNotificationTargetType): string {
return LEAVE_NOTIFICATION_TARGET_OPTIONS.find((option) => option.type === type)?.label ?? ''
}
/**
* 「范围」摘要:退群通知覆盖多少个已监控群聊。
* 「范围」摘要:退群监控覆盖多少个已监控群聊。
*
* 数字**必须**来自退群监控(`GroupExitMonitorState`),自动化不维护副本。
*/
@@ -464,6 +911,40 @@ export function describeMonitoredScope(monitoredCount: number): string {
return `${Math.max(0, Math.floor(Number(monitoredCount) || 0))} 个已监控群聊`
}
/**
* 「通知范围」摘要:二次筛选之后的实际覆盖面。
*
* 与 `describeMonitoredScope` 的区别就是这一层筛选:
* 前者说"监控了几个群",这里说"其中几个会发通知"。
*/
export function describeLeaveNotificationNotifyScope(
config: LeaveNotificationConfig | undefined,
monitoredCount: number
): string {
const monitored = Math.max(0, Math.floor(Number(monitoredCount) || 0))
if (config?.notifyScope !== 'selected') return `${monitored} 个已监控群聊(全部)`
const selected = normalizeLeaveNotificationRoomIds(config.notifyRoomIds).length
// 一个都没勾 = 明确"不通知任何群",如实说,不退回"全部"。
if (!selected) return '未选中任何群聊'
return `${selected} / ${monitored} 个已监控群聊`
}
/**
* 判定某个群是否在通知范围内。
*
* main(执行闸门)与 UI(预览/摘要)共用这一处,避免"界面说会发、实际不发"。
*/
export function leaveNotificationCoversRoom(
config: LeaveNotificationConfig | undefined,
roomId: string
): boolean {
if (!config) return false
if (config.notifyScope !== 'selected') return true
const target = String(roomId || '').trim()
if (!target) return false
return normalizeLeaveNotificationRoomIds(config.notifyRoomIds).includes(target)
}
/**
* 目标的一句话描述(首页卡片摘要用)。
*
@@ -480,9 +961,24 @@ export function describeLeaveNotificationTarget(
return leaveNotificationTargetLabel(target.type)
}
/** 目标的一句话描述(首页卡片摘要用)。 */
export function describeScheduledReportTarget(
config: ScheduledReportAutomationConfig | undefined,
contactDisplayName?: string
): string {
const target = config?.target
if (!target) return '未配置'
if (target.type === 'contact') return contactDisplayName?.trim() || '未选择好友'
return scheduledReportTargetLabel(target.type)
}
/** 触发条件的一句话摘要(UI 展示用,不含任何内部 id)。 */
export function describeRuleTrigger(rule: AutomationRule): string {
if (rule.ruleType === 'leave_notification') return '检测到群成员退出'
if (rule.ruleType === 'scheduled_report') {
const time = rule.scheduledReport?.schedule?.time
return time ? `每天 ${time}` : '按设定时间'
}
const parts: string[] = []
if (rule.conditions.requireMentionMe) parts.push('@我')
if (rule.conditions.keyword.trim()) {
@@ -494,6 +990,11 @@ export function describeRuleTrigger(rule: AutomationRule): string {
/** 动作链的一句话摘要。 */
export function describeRuleActions(rule: AutomationRule): string {
if (rule.ruleType === 'leave_notification') return '发送退群通知'
if (rule.ruleType === 'scheduled_report') {
return `生成并发送${scheduledReportRangeLabel(
rule.scheduledReport?.report?.range ?? SCHEDULED_REPORT_DEFAULT_RANGE
)}日报`
}
const labels: Record<AutomationActionType, string> = {
replyText: '回复确认',
generateReport: '生成日报',
@@ -550,6 +1051,8 @@ export function createDefaultLeaveNotificationRule(
target?: LeaveNotificationTarget
enabled?: boolean
targetNeedsReview?: boolean
notifyScope?: LeaveNotificationNotifyScope
notifyRoomIds?: string[]
} = {}
): AutomationRule {
return {
@@ -576,6 +1079,9 @@ export function createDefaultLeaveNotificationRule(
leaveNotification: {
target: options.target ?? { type: LEAVE_NOTIFICATION_DEFAULT_TARGET_TYPE },
template: normalizeLeaveNotificationTemplate(options.template),
// 新装 / 无历史配置 = 全部已监控群聊都通知(与旧版"每个群都勾上"等价)。
notifyScope: options.notifyScope ?? LEAVE_NOTIFICATION_DEFAULT_NOTIFY_SCOPE,
notifyRoomIds: normalizeLeaveNotificationRoomIds(options.notifyRoomIds),
...(options.targetNeedsReview ? { targetNeedsReview: true } : {})
},
createdAt: now,
@@ -593,6 +1099,59 @@ export function normalizeLeaveNotificationTemplate(value: unknown): string {
return normalizeGroupExitNotificationTemplate(value)
}
/** 定时日报规则的默认展示名。 */
export const SCHEDULED_REPORT_RULE_NAME = '定时日报'
/** 新建定时日报时的默认名字(与旧页面「<群名> · 每日日报」同风格)。 */
export function suggestedScheduledReportName(groupDisplayName?: string): string {
const name = String(groupDisplayName ?? '').trim()
return name ? `${name} · 每日日报` : SCHEDULED_REPORT_RULE_NAME
}
/**
* 构造一条「定时日报」规则。
*
* ⚠️ 这**不是** singleton —— 定时日报是 0..N 条,
* 所以这里没有固定 id,id 一律由 store 生成 / 由迁移复用旧 taskId。
*/
export function createDefaultScheduledReportRule(
now: number,
options: {
id?: string
name?: string
enabled?: boolean
config?: ScheduledReportAutomationConfig
} = {}
): AutomationRule {
const baseConfig = normalizeScheduledReportConfig(
options.config ?? { schedule: { time: SCHEDULED_REPORT_DEFAULT_TIME } }
)
return {
id: options.id ?? '',
name: options.name?.trim() || SCHEDULED_REPORT_RULE_NAME,
enabled: options.enabled !== false,
ruleType: 'scheduled_report',
// 由时间驱动,不参与消息匹配。这两个字段保留只是为了 schema 完整。
trigger: 'message',
scope: 'group',
conditions: {
requireMentionMe: false,
keyword: '',
keywordMatchMode: 'contains',
conversationIds: [],
ignoreSelf: true
},
// 定时日报没有动作链,动作由 scheduledReport 表达。
actions: [],
// 不套用消息型规则的 cooldown:定时规则有自己的 ruleId inFlight 锁。
cooldownSeconds: 0,
replyDelaySeconds: AUTOMATION_REPLY_DELAY_DEFAULT_SECONDS,
scheduledReport: baseConfig,
createdAt: now,
updatedAt: now
}
}
/** 归一化用户提交的草稿:把未知值收敛成合法值,避免脏数据落盘。 */
export function normalizeRuleDraft(
input: unknown,
@@ -601,7 +1160,11 @@ export function normalizeRuleDraft(
const raw = (input ?? {}) as Partial<AutomationRuleDraft>
const conditions = (raw.conditions ?? {}) as Partial<AutomationConditions>
const ruleType: AutomationRuleType =
raw.ruleType === 'leave_notification' ? 'leave_notification' : 'daily_report'
raw.ruleType === 'leave_notification'
? 'leave_notification'
: raw.ruleType === 'scheduled_report'
? 'scheduled_report'
: 'daily_report'
const keywordMatchMode: KeywordMatchMode =
conditions.keywordMatchMode === 'exact' || conditions.keywordMatchMode === 'prefix'
? conditions.keywordMatchMode
@@ -642,8 +1205,13 @@ export function normalizeRuleDraft(
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) }
if (ruleType === 'leave_notification') {
return { ...base, leaveNotification: normalizeLeaveNotificationConfig(raw.leaveNotification) }
}
if (ruleType === 'scheduled_report') {
return { ...base, scheduledReport: normalizeScheduledReportConfig(raw.scheduledReport) }
}
return base
}
/**
@@ -652,6 +1220,9 @@ export function normalizeRuleDraft(
* 目标是**必填**的:缺失一律回落到默认目标(文件传输助手),
* 而不是留一个"没有目标"的规则 —— 那种规则在运行时只能失败。
* `contact` 缺 contactId 时同样回落,避免存下一条永远发不出去的规则。
*
* `notifyScope` 缺失 → 回落 `all`(**不是** `selected`):已经落盘的规则
* 都是在"没有二次筛选"的版本下保存的,默认成 `selected` 会让它们静默停发。
*/
export function normalizeLeaveNotificationConfig(input: unknown): LeaveNotificationConfig {
const raw = (input ?? {}) as Partial<LeaveNotificationConfig>
@@ -672,6 +1243,8 @@ export function normalizeLeaveNotificationConfig(input: unknown): LeaveNotificat
...(effectiveType === 'contact' ? { contactId } : {})
},
template: normalizeLeaveNotificationTemplate(raw.template),
notifyScope: raw.notifyScope === 'selected' ? 'selected' : LEAVE_NOTIFICATION_DEFAULT_NOTIFY_SCOPE,
notifyRoomIds: normalizeLeaveNotificationRoomIds(raw.notifyRoomIds),
// 用户手动保存一次即视为已确认目标,清除迁移提示。
...(raw.targetNeedsReview === true ? { targetNeedsReview: true } : {})
}
@@ -1,8 +1,11 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { ReactElement } from 'react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { GroupExitMonitorWorkspace } from '../../src/renderer/src/features/group-exit-monitor/GroupExitMonitorWorkspace'
import {
GroupExitMonitorWorkspace,
type GroupExitMonitorOpenViewRequest
} from '../../src/renderer/src/features/group-exit-monitor/GroupExitMonitorWorkspace'
import type { GroupExitMonitorState } from '../../src/shared/group-exit-monitor'
import { formatGroupExitMonitorTime } from '../../src/shared/group-exit-monitor'
import type { Contact } from '../../src/shared/types'
@@ -69,7 +72,7 @@ const groups: Contact[] = [
]
describe('GroupExitMonitorWorkspace', () => {
const renderWorkspace = (element: ReactElement): void => {
const renderWorkspace = (element: React.ReactElement): void => {
render(<TooltipProvider>{element}</TooltipProvider>)
}
@@ -387,4 +390,63 @@ describe('GroupExitMonitorWorkspace', () => {
expect(screen.getByRole('checkbox', { name: '监控研发群' })).not.toBeChecked()
expect(screen.getByRole('checkbox', { name: '监控设计群' })).not.toBeChecked()
})
/*
* 深链请求必须是**一次性**的。
*
* 真实拓扑里请求挂在 App,而本组件随一级菜单切换被卸载/重挂。
* 修复前 effect 只 `setView` 不回报父层,请求永远不清 —— 于是重挂时
* 那条陈旧的 `{view:'manage'}` 被重放,用户看到的现象就是
* "点一级菜单退群监控,却跳到了管理群聊"。
*/
it('深链落到「管理群聊」,并立刻回报父层消费掉请求', async () => {
const onHandled = vi.fn()
renderWorkspace(
<GroupExitMonitorWorkspace
dbReady
openViewRequest={{ view: 'manage', requestId: 1 }}
onOpenViewRequestHandled={onHandled}
/>
)
expect(await screen.findByRole('heading', { name: '管理群聊' })).toBeVisible()
await waitFor(() => expect(onHandled).toHaveBeenCalledTimes(1))
})
it('请求被消费后重新挂载,回到「退群事件」而不是「管理群聊」', async () => {
function Harness(): React.ReactElement {
const [request, setRequest] = React.useState<GroupExitMonitorOpenViewRequest | null>({
view: 'manage',
requestId: 1
})
const [mounted, setMounted] = React.useState(true)
return (
<>
<button type="button" onClick={() => setMounted((prev) => !prev)}>
切换页面
</button>
{mounted ? (
<GroupExitMonitorWorkspace
dbReady
openViewRequest={request}
onOpenViewRequestHandled={() => setRequest(null)}
/>
) : null}
</>
)
}
const user = userEvent.setup()
renderWorkspace(<Harness />)
// 第一次:深链确实生效。
expect(await screen.findByRole('heading', { name: '管理群聊' })).toBeVisible()
// 离开再回来(等价于点别的菜单再点「退群监控」)。
await user.click(screen.getByRole('button', { name: '切换页面' }))
await user.click(screen.getByRole('button', { name: '切换页面' }))
expect(await screen.findByText('小艾退出了研发群')).toBeVisible()
expect(screen.queryByRole('heading', { name: '管理群聊' })).toBeNull()
})
})
+101 -6
View File
@@ -58,7 +58,11 @@ interface ApiSpies {
}
function installApi(
overrides: { leaveRule?: AutomationRule } = {}
overrides: {
leaveRule?: AutomationRule
/** `listAutomationGroups` 的返回:把 roomId 映射成可读群名。 */
automationGroups?: Array<{ id: string; name: string }>
} = {}
): { spies: ApiSpies; rules: AutomationRule[] } {
const leaveRule = overrides.leaveRule ?? createDefaultLeaveNotificationRule(Date.now())
const rules = [createDefaultDailyReportRule(Date.now()), leaveRule]
@@ -92,7 +96,7 @@ function installApi(
}
}),
listAutomationRules: spies.listAutomationRules,
listAutomationGroups: vi.fn().mockResolvedValue([]),
listAutomationGroups: vi.fn().mockResolvedValue(overrides.automationGroups ?? []),
listAutomationExecutions: vi.fn().mockResolvedValue([]),
listSendableContacts: spies.listSendableContacts,
getGroupExitMonitorState: vi.fn().mockResolvedValue(MONITOR_STATE),
@@ -128,6 +132,7 @@ async function leaveNotificationCard(): Promise<HTMLElement> {
/** 从自动化首页那张退群通知卡片的「编辑」进编辑器。 */
async function openLeaveNotificationEditor(overrides: {
leaveRule?: AutomationRule
automationGroups?: Array<{ id: string; name: string }>
} = {}): Promise<{ spies: ApiSpies }> {
const user = userEvent.setup()
const { spies } = installApi(overrides)
@@ -218,12 +223,17 @@ describe('退群通知 · 真实数据', () => {
expect(screen.queryByRole('radio', { name: '张三' })).toBeNull()
})
it('Section 1 没有「触发范围」,只有唯一的「管理监控群聊」入口', async () => {
/*
* 旧版「退群监控 → 通知群聊」的第二层。它曾在迁移里被删掉,
* 导致"规则一启用就全量通知" —— 所以这里锁的是它**必须存在**。
*/
it('Section 1 有「通知范围」二次勾选,且保留唯一的「管理监控群聊」入口', async () => {
await openLeaveNotificationEditor()
// 「触发事件」仍不可配(由退群监控驱动),但这不等于不能筛群。
expect(screen.queryByText('触发范围')).toBeNull()
expect(screen.queryByText('全部已监控群聊')).toBeNull()
expect(screen.queryByText('指定已监控群聊')).toBeNull()
expect(screen.getByRole('radio', { name: '全部已监控群聊' })).toBeChecked()
expect(screen.getByRole('radio', { name: '仅选中的群聊' })).toBeEnabled()
expect(screen.getByRole('button', { name: /管理监控群聊/ })).toBeEnabled()
})
@@ -236,6 +246,88 @@ describe('退群通知 · 真实数据', () => {
})
})
const MONITORED_GROUPS = [
{ id: 'a@chatroom', name: '甲群' },
{ id: 'b@chatroom', name: '乙群' },
{ id: 'c@chatroom', name: '丙群' }
]
/**
* 「通知范围」二次勾选 —— 旧版「退群监控 → 通知群聊」的第二层。
*
* 这组测试锁的是**回归本身**:缺了这一层,规则一启用就等于给全部已监控群聊发通知。
*/
describe('退群通知 · 通知范围(二次勾选)', () => {
it('默认是「全部已监控群聊」,预览里如实说明覆盖范围', async () => {
await openLeaveNotificationEditor({ automationGroups: MONITORED_GROUPS })
expect(screen.getByRole('radio', { name: '全部已监控群聊' })).toBeChecked()
expect(previewFact('通知范围')).toBe('3 个已监控群聊(全部)')
})
it('切到「仅选中的群聊」才列候选,且候选只含已监控的群', async () => {
const user = userEvent.setup()
await openLeaveNotificationEditor({ automationGroups: MONITORED_GROUPS })
// 还没切过去时不该出现候选项。
expect(screen.queryByRole('checkbox', { name: '甲群' })).toBeNull()
await user.click(screen.getByRole('radio', { name: '仅选中的群聊' }))
for (const name of ['甲群', '乙群', '丙群']) {
expect(screen.getByRole('checkbox', { name })).not.toBeChecked()
}
// 一个都没勾 = 明确不通知,而不是悄悄退回"全部"。
expect(screen.getByText(/一个群都没勾选/)).toBeVisible()
})
it('勾中的群会被保存成 notifyScope=selected + 那份子集', async () => {
const user = userEvent.setup()
const { spies } = await openLeaveNotificationEditor({ automationGroups: MONITORED_GROUPS })
await user.click(screen.getByRole('radio', { name: '仅选中的群聊' }))
await user.click(screen.getByRole('checkbox', { name: '甲群' }))
await user.click(screen.getByRole('checkbox', { 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.leaveNotification?.notifyScope).toBe('selected')
// 落盘顺序按候选项顺序(本文件里候选按群名拼音排),所以这里只比较集合:
// 勾选先后不该影响存下来的内容。
expect([...(draft.leaveNotification?.notifyRoomIds ?? [])].sort()).toEqual([
'a@chatroom',
'c@chatroom'
])
})
it('没动过范围时保存下来的是 all,不会顺手把用户缩到子集', async () => {
const user = userEvent.setup()
const { spies } = await openLeaveNotificationEditor({ automationGroups: MONITORED_GROUPS })
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.leaveNotification?.notifyScope).toBe('all')
})
it('旧版迁移过来的子集在界面上是勾好的,不是空选', async () => {
await openLeaveNotificationEditor({
automationGroups: MONITORED_GROUPS,
leaveRule: createDefaultLeaveNotificationRule(Date.now(), {
notifyScope: 'selected',
notifyRoomIds: ['b@chatroom']
})
})
expect(screen.getByRole('radio', { name: '仅选中的群聊' })).toBeChecked()
expect(screen.getByRole('checkbox', { name: '乙群' })).toBeChecked()
expect(screen.getByRole('checkbox', { name: '甲群' })).not.toBeChecked()
expect(previewFact('通知范围')).toBe('1 / 3 个已监控群聊')
})
})
describe('退群通知 · 四种目标', () => {
it('是四选一,「当前群聊」排第一且默认选中', async () => {
await openLeaveNotificationEditor()
@@ -479,7 +571,10 @@ describe('退群通知 · 群聊名护栏', () => {
...createDefaultLeaveNotificationRule(Date.now()),
leaveNotification: {
target: { type: 'source_chat' },
template: '[退群监测]\n\n用户: {user}'
template: '[退群监测]\n\n用户: {user}',
// 明确写全:本组用例只关心群名护栏,范围取默认的"全部已监控群聊"。
notifyScope: 'all',
notifyRoomIds: []
}
})
@@ -8,82 +8,95 @@ import {
AutomationWorkspace,
type AutomationOpenRuleRequest
} from '../../src/renderer/src/features/automation/AutomationWorkspace'
import { ScheduledReportsWorkspace } from '../../src/renderer/src/components/reports/ScheduledReportsWorkspace'
import {
createDefaultDailyReportRule,
createDefaultLeaveNotificationRule
createDefaultLeaveNotificationRule,
createDefaultScheduledReportRule,
normalizeScheduledReportConfig,
type AutomationRule
} from '../../src/shared/automation'
import type { ScheduledReportTask } from '../../src/shared/scheduled-report'
import { SUMMARY_TYPE_OPTIONS } from '../../src/renderer/src/utils/group-report'
/**
* 「自动化 → 规则 → 定时日报」。
*
* 这一组测试要守住的核心不是"界面长什么样",而是**边界**:
* 任何写操作都不许落到真实定时日报系统上
* (create / update / delete / setEnabled / runNow / retry / 异常通知开关)。
* 迁移前这一组测试守的是**边界**("任何写操作都不许落盘")。
* 迁移后 `scheduled_report` 已经是真实的自动化规则类型,边界随之反转:
* 现在要守的是**写真的发生、且写在正确的通道上**:
* - 保存 → `createAutomationRule` / `updateAutomationRule`,且 `ruleType === 'scheduled_report'`;
* - 立即执行 → `runScheduledReportRule`(与 scheduler 同一条链路);
* - 启停 → `setAutomationRuleEnabled`;
* - 删除 → `deleteAutomationRule`;
* - 且**没有任何** `scheduled-report:*` 旧通道残留。
*/
const TASKS: ScheduledReportTask[] = [
{
id: 'task-1',
const RULES_FIXTURE: AutomationRule[] = [
createDefaultScheduledReportRule(Date.now(), {
id: 'rule-1',
name: 'TraceMemo 每日晚报',
group: 'TraceMemo 交流群',
scheduleTime: '18:21',
reportRange: 'today',
target: 'TraceMemo 管理群',
enabled: true,
createdAt: '2026-09-01T00:00:00.000Z',
updatedAt: '2026-09-01T00:00:00.000Z',
nextRunAt: '2026-09-24T10:21:00.000Z'
},
{
id: 'task-2',
config: normalizeScheduledReportConfig({
schedule: { time: '18:21' },
report: {
sourceConversationId: 'g1@chatroom',
range: 'today',
messageTypes: ['text', 'image'],
templateId: 'v1',
memberNameMode: 'groupNickname',
timeoutSeconds: 300
},
target: { type: 'source_chat' }
})
}),
createDefaultScheduledReportRule(Date.now(), {
id: 'rule-2',
name: '技术交流群日报',
group: '技术交流群',
scheduleTime: '09:00',
reportRange: 'yesterday',
target: '技术交流群',
enabled: false,
createdAt: '2026-09-01T00:00:00.000Z',
updatedAt: '2026-09-01T00:00:00.000Z',
nextRunAt: '2026-09-25T01:00:00.000Z'
}
config: normalizeScheduledReportConfig({
schedule: { time: '09:00' },
report: {
sourceConversationId: 'g2@chatroom',
range: 'yesterday',
templateId: 'v1',
memberNameMode: 'groupNickname',
timeoutSeconds: 300
},
target: { type: 'file_transfer' }
})
})
]
interface Spies {
createScheduledReport: ReturnType<typeof vi.fn>
updateScheduledReport: ReturnType<typeof vi.fn>
deleteScheduledReport: ReturnType<typeof vi.fn>
setScheduledReportEnabled: ReturnType<typeof vi.fn>
runScheduledReportNow: ReturnType<typeof vi.fn>
retryScheduledReportSend: ReturnType<typeof vi.fn>
testScheduledReportErrorNotification: ReturnType<typeof vi.fn>
listScheduledReports: ReturnType<typeof vi.fn>
createRule: ReturnType<typeof vi.fn>
updateRule: ReturnType<typeof vi.fn>
deleteRule: ReturnType<typeof vi.fn>
setEnabled: ReturnType<typeof vi.fn>
runScheduled: ReturnType<typeof vi.fn>
setNotification: ReturnType<typeof vi.fn>
}
/** 所有"写操作"的集合:任何一条被调用都说明界面越界了。 */
function writeSpies(spies: Spies): Array<[string, ReturnType<typeof vi.fn>]> {
return [
['createScheduledReport', spies.createScheduledReport],
['updateScheduledReport', spies.updateScheduledReport],
['deleteScheduledReport', spies.deleteScheduledReport],
['setScheduledReportEnabled', spies.setScheduledReportEnabled],
['runScheduledReportNow', spies.runScheduledReportNow],
['retryScheduledReportSend', spies.retryScheduledReportSend],
['testScheduledReportErrorNotification', spies.testScheduledReportErrorNotification]
]
}
function installApi(tasks: ScheduledReportTask[] = TASKS): Spies {
function installApi(rules: AutomationRule[] = RULES_FIXTURE): Spies {
const spies: Spies = {
listScheduledReports: vi.fn().mockResolvedValue(tasks),
createScheduledReport: vi.fn(),
updateScheduledReport: vi.fn(),
deleteScheduledReport: vi.fn(),
setScheduledReportEnabled: vi.fn(),
runScheduledReportNow: vi.fn(),
retryScheduledReportSend: vi.fn(),
testScheduledReportErrorNotification: vi.fn()
createRule: vi.fn().mockImplementation((draft: unknown) =>
Promise.resolve({
...(draft as AutomationRule),
id: 'rule-new',
createdAt: Date.now(),
updatedAt: Date.now()
})
),
updateRule: vi.fn().mockImplementation((id: string, draft: unknown) =>
Promise.resolve({ ...(draft as AutomationRule), id, createdAt: 0, updatedAt: 0 })
),
deleteRule: vi.fn().mockResolvedValue(true),
setEnabled: vi.fn().mockImplementation((id: string, enabled: boolean) => {
const found = rules.find((rule) => rule.id === id)
return Promise.resolve(found ? { ...found, enabled } : null)
}),
runScheduled: vi.fn().mockResolvedValue({
success: true,
data: { executed: true, executionId: 'exec-1', status: 'success', reportGenerated: true }
}),
setNotification: vi.fn().mockResolvedValue({ success: true, data: { enabled: true } })
}
window.api = {
@@ -102,12 +115,17 @@ function installApi(tasks: ScheduledReportTask[] = TASKS): Spies {
}),
listAutomationRules: vi.fn().mockResolvedValue([
createDefaultDailyReportRule(Date.now()),
createDefaultLeaveNotificationRule(Date.now())
createDefaultLeaveNotificationRule(Date.now()),
...rules
]),
createAutomationRule: spies.createRule,
updateAutomationRule: spies.updateRule,
deleteAutomationRule: spies.deleteRule,
setAutomationRuleEnabled: spies.setEnabled,
listAutomationGroups: vi.fn().mockResolvedValue([
{ id: 'g1', name: 'TraceMemo 交流群' },
{ id: 'g2', name: '技术交流群' },
{ id: 'g3', name: 'TraceMemo 管理群' }
{ id: 'g1@chatroom', name: 'TraceMemo 交流群' },
{ id: 'g2@chatroom', name: '技术交流群' },
{ id: 'g3@chatroom', name: 'TraceMemo 管理群' }
]),
listAutomationExecutions: vi.fn().mockResolvedValue([]),
listSendableContacts: vi.fn().mockResolvedValue([]),
@@ -123,11 +141,15 @@ function installApi(tasks: ScheduledReportTask[] = TASKS): Spies {
unreadCount: 0
}),
onGroupExitMonitorState: vi.fn(() => () => undefined),
// 旧「日报 → 定时日报」页面 mount 时会读这几项;只读、与预览无关。
listScheduledReportExecutions: vi.fn().mockResolvedValue([]),
getPersonalWechatSendCapability: vi.fn().mockResolvedValue(null),
runScheduledReportRule: spies.runScheduled,
listScheduledReportLegacyExecutions: vi.fn().mockResolvedValue([]),
getScheduledReportNotificationSettings: vi.fn().mockResolvedValue({ enabled: false }),
...spies
getScheduledReportNotificationCapability: vi.fn().mockResolvedValue({
ready: false,
error: '需要先连接 Agent Hub 微信机器人,才能接收异常通知。'
}),
setScheduledReportNotificationEnabled: spies.setNotification,
testScheduledReportErrorNotification: vi.fn().mockResolvedValue({ success: true })
} as unknown as typeof window.api
return spies
@@ -174,11 +196,29 @@ describe('自动化 · 规则类型 Tab(定时日报)', () => {
expect(await screen.findByRole('heading', { name: '定时日报' })).toBeVisible()
expect(screen.getByText('按设定时间自动生成日报,并发送到指定微信会话。')).toBeVisible()
expect(screen.getByText('2 条规则')).toBeVisible()
// 列表态不该出现编辑器的保存按钮。
expect(screen.queryByRole('button', { name: '保存' })).toBeNull()
})
it('列表按真实字段渲染(触发 / 日报 / 来源 / 发送到 / 下次执行)', async () => {
it('列表层有「返回规则列表」入口,不切 tab 也能退出', async () => {
installApi()
renderWorkspace()
await openScheduledTab()
const user = userEvent.setup()
/*
* 这里以前是个死胡同:定时日报先落列表,而列表只有新建/编辑/立即执行/删除,
* 页面级「新建自动化」又因编辑态被隐藏,顶部 规则/执行日志 切换条同样被隐藏 ——
* 唯一的出路竟然是切到别的规则类型 tab,再从那边的「取消」绕回来。
*/
const back = await screen.findByRole('button', { name: /返回规则列表/ })
await user.click(back)
// 回到规则面板(三个类型卡片),本类型的 tab 条随之消失。
expect(await screen.findByText('退群通知')).toBeVisible()
expect(screen.queryByRole('radio', { name: '定时日报' })).toBeNull()
})
it('列表按真实规则字段渲染(触发 / 日报 / 来源 / 模板 / 发送到)', async () => {
installApi()
renderWorkspace()
await openScheduledTab()
@@ -194,41 +234,53 @@ describe('自动化 · 规则类型 Tab(定时日报)', () => {
['日报', '今日'],
['来源', 'TraceMemo 交流群'],
['模板', '经典日报'],
// target 与来源群不同 ⇒ 显示 target 的群名;相同时会显示「日报来源群」。
['发送到', 'TraceMemo 管理群'],
['发送到', '发送到日报来源群(TraceMemo 交流群)'],
['下次执行', expect.any(String)]
])
// 暂停的任务下一次执行显示破折号,而不是一个会误导的时间。
// 暂停的规则不再给出"下次执行"时间,避免误导。
const paused = (await screen.findByText('技术交流群日报')).closest('article') as HTMLElement
expect(paused.textContent).toContain('已暂停')
expect(within(paused).getByText('—')).toBeVisible()
// 这条的 target 与来源群相同 ⇒ 用「日报来源群」这种语义文案,而不是重复群名。
expect(paused.textContent).toContain('日报来源群')
expect(paused.textContent).toContain('文件传输助手')
})
})
describe('定时日报 · 编辑器与预览联动', () => {
it('点「+ 新建定时日报」进入编辑器(三段 + 预览)', async () => {
installApi()
describe('定时日报 · 真写入', () => {
it('保存新规则 → createAutomationRule,且 ruleType 与完整配置都对', async () => {
const spies = installApi()
renderWorkspace()
await openScheduledTab()
const user = userEvent.setup()
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
expect(screen.getByRole('heading', { name: '1 · 什么时候触发' })).toBeVisible()
expect(screen.getByRole('heading', { name: '2 · 生成什么日报' })).toBeVisible()
expect(screen.getByRole('heading', { name: '3 · 生成后发送到哪里' })).toBeVisible()
expect(screen.getByRole('heading', { name: '效果预览' })).toBeVisible()
// 只有一个开关:规则级启停。
expect(screen.getAllByRole('switch')).toHaveLength(1)
await user.type(screen.getByLabelText('定时日报任务名称'), '新的定时日报')
await user.click(
within(screen.getByRole('radiogroup', { name: '日报来源' })).getByRole('radio', {
name: '技术交流群'
})
)
await user.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(spies.createRule).toHaveBeenCalledTimes(1))
const draft = spies.createRule.mock.calls[0][0] as {
name: string
ruleType: string
scheduledReport: { report: { sourceConversationId: string }; target: { type: string } }
}
expect(draft.name).toBe('新的定时日报')
expect(draft.ruleType).toBe('scheduled_report')
// 来源群存的是**稳定会话 id**,不是群名。
expect(draft.scheduledReport.report.sourceConversationId).toBe('g2@chatroom')
// 新建默认发到文件传输助手(不会误打扰群聊)。
expect(draft.scheduledReport.target.type).toBe('file_transfer')
expect(spies.updateRule).not.toHaveBeenCalled()
})
it('点卡片「编辑」进入编辑器,并带出该任务的值', async () => {
installApi()
it('编辑既有规则 → updateAutomationRule(不是新建)', async () => {
const spies = installApi()
renderWorkspace()
await openScheduledTab()
const user = userEvent.setup()
const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement
await user.click(within(card).getByRole('button', { name: '编辑' }))
@@ -236,103 +288,31 @@ describe('定时日报 · 编辑器与预览联动', () => {
expect(screen.getByLabelText('定时日报任务名称')).toHaveValue('TraceMemo 每日晚报')
expect(screen.getByLabelText('执行时间小时')).toHaveValue('18')
expect(screen.getByLabelText('执行时间分钟')).toHaveValue('21')
expect(
within(screen.getByRole('radiogroup', { name: '日报来源' })).getByRole('radio', {
name: 'TraceMemo 交流群'
})
).toBeChecked()
await user.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(spies.updateRule).toHaveBeenCalledTimes(1))
expect(spies.updateRule.mock.calls[0][0]).toBe('rule-1')
expect(spies.createRule).not.toHaveBeenCalled()
})
/*
* 任务里存的群名与群列表是两个数据源,不保证一致
* (群被移出监控、或列表来源变了)。这时**必须回显当前值**,
* 否则编辑已有任务会出现"一串群里没有任何一项被选中"。
*/
it('来源群不在群列表里时,仍会作为候选项被选中', async () => {
installApi([{ ...TASKS[0], group: '已不在列表的群', target: '已不在列表的群' }])
renderWorkspace()
await openScheduledTab()
const user = userEvent.setup()
const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement
await user.click(within(card).getByRole('button', { name: '编辑' }))
expect(
within(screen.getByRole('radiogroup', { name: '日报来源' })).getByRole('radio', {
name: '已不在列表的群'
})
).toBeChecked()
})
it('改执行时间 → 预览与「预计下次执行」同时更新', async () => {
installApi()
renderWorkspace()
await openScheduledTab()
const user = userEvent.setup()
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
const hourInput = screen.getByLabelText('执行时间小时')
await user.clear(hourInput)
await user.type(hourInput, '07')
const minuteInput = screen.getByLabelText('执行时间分钟')
await user.clear(minuteInput)
await user.type(minuteInput, '05')
expect(screen.getByTestId('scheduled-next-run').textContent).toMatch(/0?7:05/)
const facts = document.querySelector('.automation-preview-facts')?.textContent ?? ''
expect(facts).toContain('每天 07:05')
})
it('改日报来源 → 预览的生成目标跟着变', async () => {
installApi()
renderWorkspace()
await openScheduledTab()
const user = userEvent.setup()
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
await user.click(
within(screen.getByRole('radiogroup', { name: '日报来源' })).getByRole('radio', {
name: '技术交流群'
})
)
const flow = document.querySelector('.automation-preview-flow')?.textContent ?? ''
expect(flow).toContain('技术交流群')
const facts = document.querySelector('.automation-preview-facts')?.textContent ?? ''
expect(facts).toContain('技术交流群')
})
it('发送目标是四选一,且**没有**「指定群聊」这种"发到另一个群"的选项', async () => {
installApi()
renderWorkspace()
await openScheduledTab()
const user = userEvent.setup()
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
const group = screen.getByRole('radiogroup', { name: '生成后发送到哪里' })
const labels = Array.from(group.querySelectorAll('label')).map((el) => el.textContent?.trim())
expect(labels).toEqual(['文件传输助手', '发送到日报来源群', '发给自己', '指定好友'])
expect(screen.queryByRole('radio', { name: '指定群聊' })).toBeNull()
// 新建默认 = 文件传输助手(不会误打扰群聊)。
expect(screen.getByRole('radio', { name: '文件传输助手' })).toBeChecked()
})
it('保存只提示,不调用任何真实写接口', async () => {
it('缺名称 / 缺来源群时**不允许保存**,并说明原因', async () => {
const spies = installApi()
renderWorkspace()
await openScheduledTab()
const user = userEvent.setup()
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
await user.click(screen.getByRole('button', { name: '保存' }))
expect(await screen.findByText('UI 预览模式,配置暂未保存')).toBeVisible()
for (const [name, spy] of writeSpies(spies)) {
expect(spy, `${name} 不该被调用`).not.toHaveBeenCalled()
}
expect(screen.getByTestId('scheduled-save-blocker')).toHaveTextContent('请填写任务名称')
expect(screen.getByRole('button', { name: '保存' })).toBeDisabled()
await user.type(screen.getByLabelText('定时日报任务名称'), '有名字了')
expect(screen.getByTestId('scheduled-save-blocker')).toHaveTextContent('请选择日报来源群')
expect(screen.getByRole('button', { name: '保存' })).toBeDisabled()
expect(spies.createRule).not.toHaveBeenCalled()
})
it('「立即执行」不触发真实执行', async () => {
it('「立即执行」走 runScheduledReportRule(与 scheduler 同一条链路)', async () => {
const spies = installApi()
renderWorkspace()
await openScheduledTab()
@@ -340,13 +320,10 @@ describe('定时日报 · 编辑器与预览联动', () => {
const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement
await user.click(within(card).getByRole('button', { name: '立即执行' }))
expect(await screen.findByText('UI 预览模式,不会执行真实日报任务')).toBeVisible()
for (const [name, spy] of writeSpies(spies)) {
expect(spy, `${name} 不该被调用`).not.toHaveBeenCalled()
}
await waitFor(() => expect(spies.runScheduled).toHaveBeenCalledWith('rule-1'))
})
it('toggle 只改本地状态并提示,不写回 store', async () => {
it('toggle 真写 setAutomationRuleEnabled', async () => {
const spies = installApi()
renderWorkspace()
await openScheduledTab()
@@ -354,16 +331,10 @@ describe('定时日报 · 编辑器与预览联动', () => {
const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement
await user.click(within(card).getByRole('switch'))
expect(await screen.findByText('UI 预览模式,配置暂未保存')).toBeVisible()
expect(within(card).getByText('已暂停')).toBeVisible()
expect(spies.setScheduledReportEnabled).not.toHaveBeenCalled()
for (const [name, spy] of writeSpies(spies)) {
expect(spy, `${name} 不该被调用`).not.toHaveBeenCalled()
}
await waitFor(() => expect(spies.setEnabled).toHaveBeenCalledWith('rule-1', false))
})
it('删除只是提示,不移除任务', async () => {
it('删除要经过确认弹层,确认后才真删', async () => {
const spies = installApi()
renderWorkspace()
await openScheduledTab()
@@ -371,24 +342,50 @@ describe('定时日报 · 编辑器与预览联动', () => {
const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement
await user.click(within(card).getByRole('button', { name: '删除' }))
expect(await screen.findByText('UI 预览模式,不会删除现有任务')).toBeVisible()
expect(screen.getByText('TraceMemo 每日晚报')).toBeVisible()
for (const [name, spy] of writeSpies(spies)) {
expect(spy, `${name} 不该被调用`).not.toHaveBeenCalled()
}
const dialog = await screen.findByRole('alertdialog', { name: '删除自动化' })
expect(dialog).toHaveTextContent('删除「TraceMemo 每日晚报」?')
expect(spies.deleteRule).not.toHaveBeenCalled()
await user.click(within(dialog).getByRole('button', { name: '删除' }))
await waitFor(() => expect(spies.deleteRule).toHaveBeenCalledWith('rule-1'))
})
it('任务列表为空时给空态与新建入口', async () => {
installApi([])
it('不再有任何旧的 scheduled-report:* 通道被调用', async () => {
installApi()
renderWorkspace()
await openScheduledTab()
expect(await screen.findByText('还没有定时日报')).toBeVisible()
expect(screen.getAllByRole('button', { name: '+ 新建定时日报' }).length).toBeGreaterThan(0)
const api = window.api as unknown as Record<string, unknown>
for (const legacy of [
'listScheduledReports',
'createScheduledReport',
'updateScheduledReport',
'deleteScheduledReport',
'setScheduledReportEnabled',
'runScheduledReportNow',
'retryScheduledReportSend'
]) {
expect(api[legacy], `旧通道 ${legacy} 仍然存在于 preload 契约里`).toBeUndefined()
}
})
})
describe('定时日报 · 完整日报配置(不丢旧能力)', () => {
it('新建时「纳入的消息类型」默认只勾选文本', async () => {
installApi()
renderWorkspace()
await openScheduledTab()
const user = userEvent.setup()
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
// 逐个断言,而不是只看"有没有被勾":默认值改了方向的话,全选也是"有被勾"。
for (const option of SUMMARY_TYPE_OPTIONS) {
const box = screen.getByRole('checkbox', { name: option.label })
if (option.value === 'text') expect(box).toBeChecked()
else expect(box).not.toBeChecked()
}
})
it('Section 2 承载旧「定时日报」的全部日报配置', async () => {
installApi()
renderWorkspace()
@@ -405,25 +402,7 @@ describe('定时日报 · 完整日报配置(不丢旧能力)', () => {
expect(screen.getByText('模型配置')).toBeVisible()
expect(screen.getByText('更改模型')).toBeVisible()
expect(screen.getByText('日报内容')).toBeVisible()
})
it('纳入的消息类型用旧页面的真实类型与说明(默认全选)', async () => {
installApi()
renderWorkspace()
await openScheduledTab()
const user = userEvent.setup()
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
expect(screen.getByText('纳入的消息类型')).toBeVisible()
expect(screen.getByText('至少选择一种')).toBeVisible()
// 七类都来自 SUMMARY_TYPE_OPTIONS,不是新写的一套。
for (const label of ['文本', '图片', '表情包', '视频', '语音', '分享/引用', '系统消息']) {
expect(screen.getByRole('checkbox', { name: label })).toBeVisible()
}
// 旧页面默认全选。
expect(screen.getByRole('checkbox', { name: '文本' })).toBeChecked()
expect(screen.getByRole('checkbox', { name: '系统消息' })).toBeChecked()
})
it('成员名称保留旧页面的三个选项', async () => {
@@ -440,31 +419,24 @@ describe('定时日报 · 完整日报配置(不丢旧能力)', () => {
})
})
describe('定时日报 · 返回与四选一发送目标', () => {
it('新建页有「← 返回定时日报」,点击回到规则列表', async () => {
describe('定时日报 · 四选一发送目标', () => {
it('只有四选一,且**没有**「指定群聊」这种"发到另一个群"的选项', async () => {
installApi()
renderWorkspace()
await openScheduledTab()
const user = userEvent.setup()
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
await user.click(screen.getByRole('button', { name: '← 返回定时日报' }))
// 回到「定时日报」列表,而不是自动化首页。
expect(await screen.findByText('2 条规则')).toBeVisible()
expect(screen.getByRole('radio', { name: '定时日报' })).toBeChecked()
})
it('编辑页也有返回入口,点击回到规则列表', async () => {
installApi()
renderWorkspace()
await openScheduledTab()
const user = userEvent.setup()
const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement
await user.click(within(card).getByRole('button', { name: '编辑' }))
expect(screen.getByRole('button', { name: '← 返回定时日报' })).toBeVisible()
await user.click(screen.getByRole('button', { name: '← 返回定时日报' }))
expect(await screen.findByText('2 条规则')).toBeVisible()
const group = screen.getByRole('radiogroup', { name: '生成后发送到哪里' })
const labels = Array.from(group.querySelectorAll('label')).map((el) => el.textContent?.trim())
expect(labels).toEqual([
'发送到日报来源群',
'文件传输助手',
'发给自己',
'指定好友'
])
expect(screen.queryByRole('radio', { name: '指定群聊' })).toBeNull()
expect(screen.getByRole('radio', { name: '文件传输助手' })).toBeChecked()
})
it('只有「指定好友」才展开好友选择器', async () => {
@@ -473,6 +445,14 @@ describe('定时日报 · 返回与四选一发送目标', () => {
await openScheduledTab()
const user = userEvent.setup()
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
// 先把名称 / 来源群补齐:阻断提示是有优先级的(名称 → 来源群 → 指定好友),
// 否则这里看到的是「请填写任务名称」,断言就打偏了。
await user.type(screen.getByLabelText('定时日报任务名称'), '发给指定好友的日报')
await user.click(
within(screen.getByRole('radiogroup', { name: '日报来源' })).getByRole('radio', {
name: '技术交流群'
})
)
for (const label of ['文件传输助手', '发送到日报来源群', '发给自己']) {
await user.click(screen.getByRole('radio', { name: label }))
@@ -481,80 +461,83 @@ describe('定时日报 · 返回与四选一发送目标', () => {
await user.click(screen.getByRole('radio', { name: '指定好友' }))
expect(screen.getByPlaceholderText('搜索好友')).toBeVisible()
})
it('Preview 随发送目标变化,且「发送到日报来源群」显示来源群名', async () => {
installApi()
renderWorkspace()
await openScheduledTab()
const user = userEvent.setup()
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
await user.click(
within(screen.getByRole('radiogroup', { name: '日报来源' })).getByRole('radio', {
name: '技术交流群'
})
// 选了「指定好友」却没选人 → 不允许保存(否则运行期必然失败)。
expect(screen.getByTestId('scheduled-save-blocker')).toHaveTextContent(
'请选择要发送的指定好友'
)
const targetOf = (): string => {
const rows = Array.from(document.querySelectorAll('.automation-preview-facts > div'))
const row = rows.find((item) => item.querySelector('dt')?.textContent === '发送目标')
return row?.querySelector('dd')?.textContent ?? ''
}
expect(targetOf()).toBe('文件传输助手')
await user.click(screen.getByRole('radio', { name: '发送到日报来源群' }))
expect(targetOf()).toBe('技术交流群')
await user.click(screen.getByRole('radio', { name: '发给自己' }))
expect(targetOf()).toBe('我')
})
it('Preview 随来源群与模板变化', async () => {
installApi()
renderWorkspace()
await openScheduledTab()
const user = userEvent.setup()
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
await user.click(
within(screen.getByRole('radiogroup', { name: '日报来源' })).getByRole('radio', {
name: '技术交流群'
})
)
const flow = document.querySelector('.automation-preview-flow')?.textContent ?? ''
expect(flow).toContain('技术交流群')
expect(flow).toContain('经典日报')
await user.click(screen.getByLabelText('日报模板'))
// 选项文案来自 REPORT_TEMPLATES 的 `label · name`。
await user.click(await screen.findByRole('option', { name: /Mobile 01/ }))
expect(document.querySelector('.automation-preview-flow')?.textContent).not.toContain('经典日报')
expect(screen.getByRole('button', { name: '保存' })).toBeDisabled()
})
})
describe('定时日报 · 不向用户暴露内部标识', () => {
describe('定时日报 · 迁移遗留与内部标识', () => {
it('targetNeedsReview 的规则在列表上明确提示"需要重选目标"', async () => {
const needsReview = createDefaultScheduledReportRule(Date.now(), {
id: 'rule-review',
name: '旧规则',
enabled: false,
config: {
...normalizeScheduledReportConfig({}),
target: { type: 'source_chat' },
targetNeedsReview: true,
legacyTarget: '某个旧群'
}
})
installApi([needsReview])
renderWorkspace()
await openScheduledTab()
const card = (await screen.findByText('旧规则')).closest('article') as HTMLElement
expect(card.textContent).toContain('发送目标需要重新选择')
expect(card.textContent).toContain('某个旧群')
// 目标待重选时不允许执行(执行必然失败)。
expect(within(card).getByRole('button', { name: '立即执行' })).toBeDisabled()
})
it('列表把 room id / hash 解析成显示名,绝不显示裸 id', async () => {
// 合成值:形态与真实 room id / hash 相同(纯数字 + @chatroom、32 位十六进制),
// 但取值本身可一眼辨认是假数据。与仓库其它测试保持一致。
const FAKE_ROOM_ID = '12345678@chatroom'
const FAKE_GROUP_HASH = '0123456789abcdef0123456789abcdef'
installApi([
{ ...TASKS[0], group: FAKE_ROOM_ID, target: FAKE_ROOM_ID },
{ ...TASKS[1], group: FAKE_GROUP_HASH }
createDefaultScheduledReportRule(Date.now(), {
id: 'rule-internal',
name: '内部标识规则',
config: normalizeScheduledReportConfig({
report: { sourceConversationId: FAKE_ROOM_ID, messageTypes: ['text'] }
})
}),
createDefaultScheduledReportRule(Date.now(), {
id: 'rule-internal-2',
name: '内部标识规则 2',
config: normalizeScheduledReportConfig({
report: { sourceConversationId: FAKE_GROUP_HASH, messageTypes: ['text'] }
})
})
])
renderWorkspace()
await openScheduledTab()
await screen.findByText('TraceMemo 每日晚报')
await screen.findByText('内部标识规则')
const body = document.body.textContent ?? ''
expect(body).not.toContain(FAKE_ROOM_ID)
expect(body).not.toContain(FAKE_GROUP_HASH.slice(0, 12))
// 对不上群名时给中性文案,而不是把 id 当名字。
expect(screen.getAllByText('未知群聊').length).toBeGreaterThan(0)
})
})
describe('定时日报 · 微信异常通知入口', () => {
it('编辑器里保留异常通知开关,并给出 Agent Hub 的去处', async () => {
installApi()
renderWorkspace()
await openScheduledTab()
const user = userEvent.setup()
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
expect(screen.getByRole('heading', { name: '微信异常通知' })).toBeVisible()
expect(
screen.getByText('需要先连接 Agent Hub 微信机器人,才能接收异常通知。')
).toBeVisible()
})
})
describe('定时日报 · 首页汇总卡', () => {
it('首页把它当"多条规则"汇总,而不是一条 singleton', async () => {
installApi()
@@ -563,7 +546,6 @@ describe('定时日报 · 首页汇总卡', () => {
const card = (await screen.findByText('定时日报')).closest('article') as HTMLElement
expect(card.textContent).toContain('2 条规则 · 1 条运行中')
expect(card.textContent).toContain('按设定时间')
// 汇总卡不该有单个启停开关。
expect(within(card).queryByRole('switch')).toBeNull()
await userEvent.setup().click(within(card).getByRole('button', { name: '管理定时日报 →' }))
@@ -571,7 +553,7 @@ describe('定时日报 · 首页汇总卡', () => {
})
})
describe('定时日报 · 深链与旧页面', () => {
describe('定时日报 · 深链', () => {
it('深链直接落到「自动化 → 规则 → 定时日报」', async () => {
installApi()
@@ -592,38 +574,15 @@ describe('定时日报 · 深链与旧页面', () => {
expect(await screen.findByText('2 条规则')).toBeVisible()
expect(screen.getByRole('radio', { name: '定时日报' })).toBeChecked()
})
it('旧「日报 → 定时日报」页面仍在,且入口能触发跳转', async () => {
installApi()
const onOpenAutomation = vi.fn()
render(
<ToastProvider>
<ScheduledReportsWorkspace
contacts={[]}
onOpenWechatSettings={() => {}}
onOpenAgentHub={() => {}}
onNotice={() => {}}
onOpenAutomation={onOpenAutomation}
/>
</ToastProvider>
)
expect(screen.getByRole('heading', { name: '定时日报' })).toBeVisible()
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: '在自动化中配置 →' }))
expect(onOpenAutomation).toHaveBeenCalledTimes(1)
})
})
/** 让 `waitFor` 在本文件被使用(空态/异步断言需要)。 */
describe('定时日报 · 异步加载', () => {
it('加载完成后不再显示 loading', async () => {
installApi()
describe('定时日报 · 空态', () => {
it('没有规则时给空态与新建入口', async () => {
installApi([])
renderWorkspace()
await openScheduledTab()
await waitFor(() => expect(screen.queryByText('正在读取定时日报…')).toBeNull())
expect(await screen.findByText('还没有定时日报')).toBeVisible()
expect(screen.getAllByRole('button', { name: '+ 新建定时日报' }).length).toBeGreaterThan(0)
})
})
@@ -1,259 +0,0 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ScheduledReportsWorkspace } from '../../src/renderer/src/components/reports/ScheduledReportsWorkspace'
import type { Contact } from '../../src/shared/types'
import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat'
const group: Contact = {
md5: 'group-md5',
m_nsUsrName: 'group@chatroom',
m_nsNickName: '技术交流',
type: 'group'
}
const capability = (
status: PersonalWechatSendCapability['status']
): PersonalWechatSendCapability => ({
supported: status !== 'unsupported',
ready: status === 'ready',
status,
capabilities: { text: status === 'ready', image: status === 'ready', voice: status === 'ready' },
senderStatus: {} as PersonalWechatSendCapability['senderStatus'],
message: ''
})
const task = {
id: 'task-1',
name: '技术交流 · 每日日报',
group: group.md5,
target: group.m_nsUsrName,
scheduleTime: '09:00',
reportRange: 'yesterday' as const,
enabled: true,
createdAt: '2026-08-26T00:00:00.000Z',
updatedAt: '2026-08-26T00:00:00.000Z',
nextRunAt: '2026-08-28T01:00:00.000Z'
}
describe('ScheduledReportsWorkspace', () => {
const onNotice = vi.fn()
const onOpenWechatSettings = vi.fn()
const onOpenAgentHub = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
Object.defineProperty(window, 'api', {
configurable: true,
value: {
getPersonalWechatSendCapability: vi.fn(async () => capability('ready')),
getScheduledReportNotificationSettings: vi.fn(async () => ({ enabled: false })),
setScheduledReportNotificationEnabled: vi.fn(async (enabled: boolean) => ({
success: true,
data: { enabled }
})),
listScheduledReports: vi.fn(async () => [task]),
listScheduledReportExecutions: vi.fn(async () => []),
createScheduledReport: vi.fn(async () => ({ success: true, data: task })),
updateScheduledReport: vi.fn(async () => ({ success: true, data: task })),
deleteScheduledReport: vi.fn(async () => ({ success: true, data: { deletedId: task.id } })),
setScheduledReportEnabled: vi.fn(async () => ({ success: true, data: task })),
runScheduledReportNow: vi.fn(async () => ({ success: true, data: { status: 'success' } })),
retryScheduledReportSend: vi.fn(async () => ({
success: true,
data: { status: 'success' }
})),
testScheduledReportErrorNotification: vi.fn(async () => ({
success: true,
data: { status: 'failed', notificationStatus: 'sent' }
}))
}
})
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('loads capability and renders a scheduled report task', async () => {
render(
<ScheduledReportsWorkspace
contacts={[group]}
onNotice={onNotice}
onOpenWechatSettings={onOpenWechatSettings}
onOpenAgentHub={onOpenAgentHub}
/>
)
expect(screen.getByText('正在检查微信发送能力…')).toBeInTheDocument()
await waitFor(() => expect(screen.getByText('✓ 微信发送能力已就绪')).toBeVisible())
expect(screen.getByText(task.name)).toBeVisible()
expect(screen.getByText('每天 09:00')).toBeVisible()
})
it('keeps creation available and offers settings when WeChat is unsupported', async () => {
vi.mocked(window.api.getPersonalWechatSendCapability).mockResolvedValue(
capability('unsupported')
)
render(
<ScheduledReportsWorkspace
contacts={[group]}
onNotice={onNotice}
onOpenWechatSettings={onOpenWechatSettings}
onOpenAgentHub={onOpenAgentHub}
/>
)
await waitFor(() =>
expect(screen.getByText('微信消息发送目前仅支持 macOS 和 Windows')).toBeVisible()
)
expect(screen.getByRole('button', { name: /新建定时日报/ })).toBeEnabled()
})
it('opens the shared dialog and creates a task', async () => {
vi.mocked(window.api.listScheduledReports).mockResolvedValue([])
render(
<ScheduledReportsWorkspace
contacts={[group]}
onNotice={onNotice}
onOpenWechatSettings={onOpenWechatSettings}
onOpenAgentHub={onOpenAgentHub}
/>
)
await waitFor(() => expect(screen.getByText('还没有定时日报')).toBeVisible())
fireEvent.click(screen.getAllByRole('button', { name: /新建定时日报/ })[0])
expect(screen.getByRole('dialog')).toBeVisible()
const dialog = screen.getByRole('dialog')
fireEvent.change(within(dialog).getByPlaceholderText('例如:技术交流 · 每日日报'), {
target: { value: '测试日报' }
})
fireEvent.click(within(dialog).getByRole('button', { name: '创建定时日报' }))
await waitFor(() =>
expect(window.api.createScheduledReport).toHaveBeenCalledWith(
expect.objectContaining({ name: '测试日报', group: group.md5, target: group.m_nsUsrName })
)
)
})
it('keeps scheduled report range labels readable in compact layouts', async () => {
vi.mocked(window.api.listScheduledReports).mockResolvedValue([])
render(
<ScheduledReportsWorkspace
contacts={[group]}
onNotice={onNotice}
onOpenWechatSettings={onOpenWechatSettings}
onOpenAgentHub={onOpenAgentHub}
/>
)
await waitFor(() => expect(screen.getByText('还没有定时日报')).toBeVisible())
fireEvent.click(screen.getAllByRole('button', { name: /新建定时日报/ })[0])
const range = screen.getByRole('radiogroup', { name: '总结范围' })
expect(range).toHaveClass('grid-cols-2', 'sm:grid-cols-5')
expect(screen.getByText('自定义', { exact: true })).toHaveClass('whitespace-nowrap')
expect(screen.getByText('即将支持', { exact: true })).toHaveClass('whitespace-nowrap')
})
it('keeps the notification switch off by default and loads the persisted setting', async () => {
render(
<ScheduledReportsWorkspace
contacts={[group]}
onNotice={onNotice}
onOpenWechatSettings={onOpenWechatSettings}
onOpenAgentHub={onOpenAgentHub}
/>
)
const notificationSwitch = screen.getByRole('switch', { name: '微信异常通知' })
await waitFor(() => expect(notificationSwitch).toHaveAttribute('aria-checked', 'false'))
expect(window.api.getScheduledReportNotificationSettings).toHaveBeenCalledOnce()
})
it('restores the switch off and offers Agent Hub when enabling fails', async () => {
vi.mocked(window.api.setScheduledReportNotificationEnabled).mockResolvedValue({
success: false,
data: { enabled: false },
reason: 'agent_hub_offline',
error: '需要先连接 Agent Hub 微信机器人,才能接收异常通知。'
})
render(
<ScheduledReportsWorkspace
contacts={[group]}
onNotice={onNotice}
onOpenWechatSettings={onOpenWechatSettings}
onOpenAgentHub={onOpenAgentHub}
/>
)
await waitFor(() => expect(screen.getByText('微信异常通知')).toBeVisible())
fireEvent.click(screen.getByRole('switch', { name: '微信异常通知' }))
await waitFor(() =>
expect(screen.getByText('需要先连接 Agent Hub 微信机器人,才能接收异常通知。')).toBeVisible()
)
expect(screen.getByRole('switch', { name: '微信异常通知' })).toHaveAttribute(
'aria-checked',
'false'
)
fireEvent.click(screen.getByRole('button', { name: '前往 Agent Hub' }))
expect(onOpenAgentHub).toHaveBeenCalledOnce()
})
it('shows the debug notification action only when enabled and sends the test request', async () => {
vi.stubEnv('VITE_SCHEDULED_REPORT_DEBUG', 'true')
render(
<ScheduledReportsWorkspace
contacts={[group]}
onNotice={onNotice}
onOpenWechatSettings={onOpenWechatSettings}
onOpenAgentHub={onOpenAgentHub}
/>
)
await waitFor(() => expect(screen.getByText(task.name)).toBeVisible())
fireEvent.click(screen.getByRole('button', { name: '测试错误信息发送' }))
await waitFor(() =>
expect(window.api.testScheduledReportErrorNotification).toHaveBeenCalledWith(task.id)
)
expect(onNotice).toHaveBeenCalledWith(
'测试错误信息已发送到 Agent Hub 微信通知接收者',
'success'
)
})
it('confirms a manual resend and does not retry when the dialog is cancelled', async () => {
const execution = {
id: 'execution-1',
taskId: task.id,
startedAt: '2026-08-27T01:00:00.000Z',
finishedAt: '2026-08-27T01:01:00.000Z',
status: 'waiting_to_send' as const,
sendTarget: group.m_nsUsrName,
userMessage: '日报已生成,但微信暂不可用'
}
vi.mocked(window.api.listScheduledReportExecutions).mockResolvedValue([execution])
render(
<ScheduledReportsWorkspace
contacts={[group]}
onNotice={onNotice}
onOpenWechatSettings={onOpenWechatSettings}
onOpenAgentHub={onOpenAgentHub}
/>
)
await waitFor(() => expect(screen.getByText('日报已生成,但微信暂不可用')).toBeVisible())
fireEvent.click(screen.getByRole('button', { name: '重新发送' }))
const dialog = await screen.findByRole('alertdialog')
expect(within(dialog).getByText('目标:' + group.m_nsUsrName)).toBeVisible()
expect(within(dialog).getByText('日报:技术交流 · 每日日报')).toBeVisible()
expect(within(dialog).getByText(/日期:/)).toBeVisible()
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }))
expect(window.api.retryScheduledReportSend).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '重新发送' }))
const secondDialog = await screen.findByRole('alertdialog')
fireEvent.click(within(secondDialog).getByRole('button', { name: '确认重新发送' }))
await waitFor(() =>
expect(window.api.retryScheduledReportSend).toHaveBeenCalledWith(execution.id)
)
expect(window.api.retryScheduledReportSend).toHaveBeenCalledTimes(1)
})
})
+53 -1
View File
@@ -22,7 +22,10 @@ vi.mock('electron', () => ({
BrowserWindow: { getAllWindows: () => [] }
}))
import { AUTOMATION_SEND_PURPOSE } from '../../src/shared/automation'
import {
AUTOMATION_SEND_PURPOSE,
BUILTIN_DAILY_REPORT_RULE_ID
} from '../../src/shared/automation'
import type {
PersonalWechatSendCapability,
PersonalWechatSendRequest
@@ -273,4 +276,53 @@ describe('automation end-to-end chain', () => {
await chain.service.handleMessage(message({ content: '@我 今日日报', mentionTargets: [] }))
expect(chain.sends).toHaveLength(0)
})
it('同一条 store 里存在定时日报规则时,@我消息只触发 @我日报规则(规则类型硬分派)', async () => {
const chain = buildChain()
// 定时日报是**时间驱动**,不参与消息匹配。硬分派一旦写错,它的
// 「空关键词 + 不要求 @我」会让它命中**每一条群消息** ——
// 这正是这条用例要守住的边界。
const scheduled = chain.store.createRule({
name: '每天 09:00 的定时日报',
enabled: true,
ruleType: 'scheduled_report',
trigger: 'message',
scope: 'group',
conditions: {
requireMentionMe: false,
keyword: '',
keywordMatchMode: 'contains',
conversationIds: [],
ignoreSelf: true
},
actions: [],
cooldownSeconds: 0,
replyDelaySeconds: 0,
scheduledReport: {
schedule: { time: '09:00' },
report: {
sourceConversationId: GROUP_ID,
range: 'yesterday',
messageTypes: ['text'],
templateId: 'v1',
memberNameMode: 'groupNickname',
timeoutSeconds: 300
},
target: { type: 'file_transfer' }
}
})
expect(scheduled.ruleType).toBe('scheduled_report')
await chain.service.handleMessage(message())
// 只有 @我日报那条留下执行记录;定时日报规则**一条都没有**。
const records = chain.log.list()
expect(records).toHaveLength(1)
expect(records[0].ruleId).toBe(BUILTIN_DAILY_REPORT_RULE_ID)
expect(records.some((record) => record.ruleId === scheduled.id)).toBe(false)
// 发送也只有 @我日报的那两次(确认文字 + 日报图片),没有第三条。
expect(chain.sends.map((send) => send.type)).toEqual(['text', 'image'])
})
})
+53 -4
View File
@@ -160,13 +160,62 @@ describe('preload IPC contract', () => {
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:send', reportImageRequest)
await api.getPersonalWechatVoiceDiagnostic()
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:getVoiceDiagnostic')
})
/**
* 定时日报退役后,专属能力全部改走 `automation:*`。
*
* 单独成一个用例(而不是塞进上面那条超长用例)有两个理由:
* 1. 上面的用例在 `voice:recognize` 上会因 vitest 4 对**尾随 undefined** 的严格比较而失败,
* 放在一起会让这几条断言永远跑不到;
* 2. 「旧通道必须彻底消失」本身就是一条独立契约,值得有自己的名字。
*/
it('routes scheduled-report capabilities through automation channels only', async () => {
const api = await loadApi()
invoke.mockResolvedValue({ success: true })
await api.runScheduledReportRule('rule-1')
expect(invoke).toHaveBeenLastCalledWith('automation:runScheduledReportRule', 'rule-1')
await api.listScheduledReportLegacyExecutions('rule-1')
expect(invoke).toHaveBeenLastCalledWith(
'automation:listScheduledReportLegacyExecutions',
'rule-1'
)
await api.getScheduledReportNotificationSettings()
expect(invoke).toHaveBeenLastCalledWith('scheduled-report:getNotificationSettings')
expect(invoke).toHaveBeenLastCalledWith('automation:getScheduledReportNotificationSettings')
await api.getScheduledReportNotificationCapability()
expect(invoke).toHaveBeenLastCalledWith('automation:getScheduledReportNotificationCapability')
await api.setScheduledReportNotificationEnabled(true)
expect(invoke).toHaveBeenLastCalledWith('scheduled-report:setNotificationEnabled', true)
await api.testScheduledReportErrorNotification('task-1')
expect(invoke).toHaveBeenLastCalledWith('scheduled-report:testErrorNotification', 'task-1')
expect(invoke).toHaveBeenLastCalledWith(
'automation:setScheduledReportNotificationEnabled',
true
)
await api.testScheduledReportErrorNotification('rule-1')
expect(invoke).toHaveBeenLastCalledWith(
'automation:testScheduledReportErrorNotification',
'rule-1'
)
// 旧的 `scheduled-report:*` 通道必须**彻底不存在**:
// 留一个"能用但没人维护"的入口,等于给双写 / 双读留后门。
const channels = invoke.mock.calls.map((call) => String(call[0]))
expect(channels.some((channel) => channel.startsWith('scheduled-report:'))).toBe(false)
const legacyMethods = [
'listScheduledReports',
'listScheduledReportExecutions',
'createScheduledReport',
'updateScheduledReport',
'deleteScheduledReport',
'setScheduledReportEnabled',
'runScheduledReportNow',
'retryScheduledReportSend'
]
for (const method of legacyMethods) {
expect(api).not.toHaveProperty(method)
}
})
it('preserves key API return values without exposing ipcRenderer', async () => {
+86 -51
View File
@@ -4,7 +4,9 @@ const fixture = vi.hoisted(() => ({
capability: {
supported: true,
ready: true,
status: 'ready' as const,
// 显式标注成联合类型:否则会被收窄成字面量 'ready',
// 后面把它改成 'needs_binding' 时就不合法了。
status: 'ready' as PersonalWechatSendCapabilityState,
capabilities: { text: true, image: true, voice: true },
senderStatus: {} as never,
message: '个人微信已准备好发送日报'
@@ -58,7 +60,14 @@ vi.mock('../../src/main/services/agent-hub-service', () => ({
}))
import { startHttpServer, type HttpServerHandle } from '../../src/main/http-server'
import type { ScheduledReportTask } from '../../src/shared/scheduled-report'
import type {
ScheduledReportCreateInput,
ScheduledReportExecution,
ScheduledReportResult,
ScheduledReportTask,
ScheduledReportUpdateInput
} from '../../src/shared/scheduled-report'
import type { PersonalWechatSendCapabilityState } from '../../src/shared/personal-wechat'
import type { ScheduledReportApiDependencies } from '../../src/main/services/scheduled-report-api-service'
const TOKEN = 'A'.repeat(43)
@@ -87,57 +96,76 @@ async function startFixture(
options: { tasks?: ScheduledReportTask[] } = {}
): Promise<{ handle: HttpServerHandle; service: ScheduledReportApiDependencies['service'] }> {
const tasks = options.tasks || []
const executions: Array<Record<string, unknown>> = []
const service = {
listTasks: vi.fn(async () => tasks),
listExecutions: vi.fn(async (taskId?: string) =>
taskId ? executions.filter((item) => item.taskId === taskId) : executions
const executions: ScheduledReportExecution[] = []
const service: ScheduledReportApiDependencies['service'] = {
listTasks: vi.fn(async (): Promise<ScheduledReportTask[]> => tasks),
listExecutions: vi.fn(
async (taskId?: string): Promise<ScheduledReportExecution[]> =>
taskId ? executions.filter((item) => item.taskId === taskId) : executions
),
createTask: vi.fn(async (input: Record<string, unknown>) => {
const created = makeTask({
id: `task-${tasks.length + 1}`,
name: String(input.name),
group: String(input.group),
target: String(input.target),
scheduleTime: String(input.scheduleTime),
reportRange: input.reportRange as ScheduledReportTask['reportRange'],
enabled: Boolean(input.enabled)
})
tasks.push(created)
return { success: true, data: created }
}),
updateTask: vi.fn(async (id: string, input: Record<string, unknown>) => {
const current = tasks.find((item) => item.id === id)
if (!current) return { success: false, error: '未找到定时日报任务' }
const updated = { ...current, ...input, updatedAt: '2026-08-27T02:00:00.000Z' }
tasks[tasks.indexOf(current)] = updated
return { success: true, data: updated }
}),
deleteTask: vi.fn(async (id: string) => {
const index = tasks.findIndex((item) => item.id === id)
if (index < 0) return { success: false, error: '未找到定时日报任务' }
tasks.splice(index, 1)
return { success: true, data: { deletedId: id } }
}),
setTaskEnabled: vi.fn(async (id: string, enabled: boolean) => {
const current = tasks.find((item) => item.id === id)
if (!current) return { success: false, error: '未找到定时日报任务' }
const updated = { ...current, enabled }
tasks[tasks.indexOf(current)] = updated
return { success: true, data: updated }
}),
runScheduledReportNow: vi.fn(async (id: string) => {
const execution = {
id: `execution-${executions.length + 1}`,
taskId: id,
startedAt: '2026-08-27T01:00:00.000Z',
finishedAt: '2026-08-27T01:01:00.000Z',
status: 'success' as const,
message: '日报生成成功,微信发送成功'
createTask: vi.fn(
async (
input: ScheduledReportCreateInput
): Promise<ScheduledReportResult<ScheduledReportTask>> => {
const created = makeTask({
id: `task-${tasks.length + 1}`,
name: String(input.name),
group: String(input.group),
target: String(input.target),
scheduleTime: String(input.scheduleTime),
reportRange: input.reportRange as ScheduledReportTask['reportRange'],
enabled: Boolean(input.enabled)
})
tasks.push(created)
return { success: true, data: created }
}
executions.push(execution)
return { success: true, data: execution }
})
),
updateTask: vi.fn(
async (
id: string,
input: ScheduledReportUpdateInput
): Promise<ScheduledReportResult<ScheduledReportTask>> => {
const current = tasks.find((item) => item.id === id)
if (!current) return { success: false, error: '未找到定时日报任务' }
const updated = { ...current, ...input, updatedAt: '2026-08-27T02:00:00.000Z' }
tasks[tasks.indexOf(current)] = updated
return { success: true, data: updated }
}
),
deleteTask: vi.fn(
async (id: string): Promise<ScheduledReportResult<{ deletedId: string }>> => {
const index = tasks.findIndex((item) => item.id === id)
if (index < 0) return { success: false, error: '未找到定时日报任务' }
tasks.splice(index, 1)
return { success: true, data: { deletedId: id } }
}
),
setTaskEnabled: vi.fn(
async (
id: string,
enabled: boolean
): Promise<ScheduledReportResult<ScheduledReportTask>> => {
const current = tasks.find((item) => item.id === id)
if (!current) return { success: false, error: '未找到定时日报任务' }
const updated = { ...current, enabled }
tasks[tasks.indexOf(current)] = updated
return { success: true, data: updated }
}
),
runScheduledReportNow: vi.fn(
async (id: string): Promise<ScheduledReportResult<ScheduledReportExecution>> => {
const execution: ScheduledReportExecution = {
id: `execution-${executions.length + 1}`,
taskId: id,
startedAt: '2026-08-27T01:00:00.000Z',
finishedAt: '2026-08-27T01:01:00.000Z',
status: 'success',
message: '日报生成成功,微信发送成功'
}
executions.push(execution)
return { success: true, data: execution }
}
)
}
const handle = await startHttpServer('127.0.0.1', 0, {
tokenProvider: () => TOKEN,
@@ -222,6 +250,13 @@ describe('scheduled report Local HTTP API', () => {
})
expect(await capability.json()).toMatchObject({ capability: { status: 'ready' } })
expect(service.createTask).toHaveBeenCalledOnce()
// HTTP 契约把群解析成**会话 md5**、目标解析成 roomId 后交给服务层
//(服务层再把这些标识收敛成稳定会话 id)。
expect(vi.mocked(service.createTask).mock.calls[0][0]).toMatchObject({
group: 'tech-md5',
target: 'tech@chatroom',
scheduleTime: '09:00'
})
})
it('allows unavailable sending capability while still rejecting ambiguous groups', async () => {
@@ -0,0 +1,659 @@
import fs from 'fs-extra'
import path from 'path'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
const root = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const fs = require('node:fs') as typeof import('node:fs')
// eslint-disable-next-line @typescript-eslint/no-require-imports
const os = require('node:os') as typeof import('node:os')
// eslint-disable-next-line @typescript-eslint/no-require-imports
const path = require('node:path') as typeof import('node:path')
return fs.mkdtempSync(path.join(os.tmpdir(), 'wxe-scheduled-report-automation-'))
})
const SELF = 'my_account_9527'
/** 联系人表必须在 `vi.hoisted` 里 —— `vi.mock` 的工厂是提前求值的。 */
const contacts = vi.hoisted(() => [
{
md5: 'tech-md5',
m_nsUsrName: 'tech@chatroom',
m_nsNickName: '技术交流群',
type: 'group' as const
},
{
m_nsUsrName: 'wxid_friend',
m_nsNickName: '好友昵称',
remark: '好友备注',
type: 'user' as const
}
])
vi.mock('electron', () => ({
app: { getPath: () => root },
safeStorage: {
isEncryptionAvailable: () => false,
encryptString: (value: string) => Buffer.from(value, 'utf8'),
decryptString: (value: Buffer) => value.toString('utf8')
},
BrowserWindow: { getAllWindows: () => [] }
}))
vi.mock('../../src/main/services/chat-service', () => ({
listContacts: () => contacts,
getSelfAccountInfo: () => ({ wxid: 'my_account_9527' }),
// 头像补全是可选增强:这里显式给一个空实现,避免 runner 落到未 mock 的导出上刷警告。
getContactAvatars: async () => ({})
}))
import type {
PersonalWechatSendCapability,
PersonalWechatSendRequest
} from '../../src/shared/personal-wechat'
import type { Wcdb4Client } from '../../src/main/wcdb4-client'
import type { GeneratedReportRecord } from '../../src/shared/report-history'
import type { AgentGroupReportResult } from '../../src/main/services/agent-group-report-service'
import type {
AutomationRule,
AutomationRuleDraft,
ScheduledReportTargetType
} from '../../src/shared/automation'
import { WechatActionGateway } from '../../src/main/services/wechat-action-gateway'
import { AutomationRuleStore } from '../../src/main/services/automation-rule-store'
import { AutomationExecutionLogService } from '../../src/main/services/automation-execution-log-service'
import { AutomationActionRunner } from '../../src/main/services/automation-action-runner'
import { AutomationService } from '../../src/main/services/automation-service'
import { ScheduledReportService } from '../../src/main/services/scheduled-report-service'
/**
* 定时日报的**端到端链路**。
*
* Scheduler(`ScheduledReportService.tick`)→ `AutomationService.executeScheduledRule`
* → `AutomationActionRunner.runScheduledReport` → `WechatActionGateway` → 被替换掉的传输层。
*
* 除了「微信怎么把图片发出去」,其余**全部是生产实现**。这个文件的存在理由:
* - `AUTOMATION_PURPOSE_ALLOWLIST` 一旦漏登记 `automation_scheduled_report`,
* 整条链会在策略层被**静默**拦下(ACTION_NOT_ALLOWED),而每个单元测试都还是绿的;
* - `scheduledInFlight` / 确定性 executionId 这两道并发闸,只有真的拼起来才看得出来;
* - 四个发送目标、槽位游标、执行日志的 trigger 区分,都是"拼起来"才成立的语义。
*/
function readyCapability(): PersonalWechatSendCapability {
return {
supported: true,
ready: true,
status: 'ready',
capabilities: { text: true, image: true, voice: false },
message: '个人微信已准备好发送日报',
senderStatus: {
state: 'online',
platform: 'darwin',
arch: 'arm64',
sipDisabled: false,
wechatRunning: true,
endpoint: '127.0.0.1:0',
endpointReady: true,
runtimeReady: true,
attachReady: true,
baseAddressReady: true,
textHookInstalled: true,
textHookReady: true,
imageHookInstalled: true,
imageHookReady: true,
messageListenerReady: true,
canSend: true,
canSendText: true,
canSendImage: true,
canSendVoice: false,
message: '个人微信已准备好发送日报'
}
}
}
const RULE_NAME = '技术交流群 · 每日晚报'
const SCHEDULE_TIME = '18:30'
/** 规则的创建时间固定在 2026-08-20,必须早于所有参与断言的槽位。 */
const CREATED_AT = Date.parse('2026-08-20T09:00:00+08:00')
/** 2026-08-27 18:31,当天 18:30 的槽位已经到点。 */
const AFTER_SLOT = new Date('2026-08-27T18:31:00+08:00')
/**
* `AutomationService` / `AutomationRuleStore` 的 `now` 是**毫秒数**,
* `ScheduledReportService` 的 `now` 是 **`Date`** —— 两者刻意分开命名,
* 避免把毫秒数丢给需要 `Date` 的那一个(那会在推通知时抛 `toISOString` 错误)。
*/
const AFTER_SLOT_MS = AFTER_SLOT.getTime()
const SLOT_ISO = new Date('2026-08-27T18:30:00+08:00').toISOString()
function scheduledDraft(
target: ScheduledReportTargetType,
options: { contactId?: string; enabled?: boolean; postfixText?: string } = {}
): AutomationRuleDraft {
return {
name: RULE_NAME,
enabled: options.enabled !== false,
ruleType: 'scheduled_report',
trigger: 'message',
scope: 'group',
conditions: {
requireMentionMe: false,
keyword: '',
keywordMatchMode: 'contains',
conversationIds: [],
ignoreSelf: true
},
actions: [],
cooldownSeconds: 0,
replyDelaySeconds: 2,
scheduledReport: {
schedule: { time: SCHEDULE_TIME },
report: {
sourceConversationId: 'tech@chatroom',
range: 'yesterday',
messageTypes: ['text'],
templateId: 'v1',
memberNameMode: 'groupNickname',
timeoutSeconds: 300
},
target:
target === 'contact'
? { type: 'contact', contactId: options.contactId ?? 'wxid_friend' }
: { type: target },
// 只有显式给了后置词才写进规则:空字符串意味着"只发图片",
// 这正是存量规则迁移过来的形态,不能在测试工厂里被悄悄补上默认值。
...(options.postfixText ? { postfixText: options.postfixText } : {})
}
}
}
interface Chain {
scheduler: ScheduledReportService
service: AutomationService
store: AutomationRuleStore
log: AutomationExecutionLogService
sends: PersonalWechatSendRequest[]
ruleId: string
}
function buildChain(
options: {
capability?: PersonalWechatSendCapability
target?: ScheduledReportTargetType
contactId?: string
enabled?: boolean
postfixText?: string
generate?: () => Promise<AgentGroupReportResult>
} = {}
): Chain {
const sends: PersonalWechatSendRequest[] = []
const savedReports: GeneratedReportRecord[] = []
const capability = options.capability ?? readyCapability()
const gateway = new WechatActionGateway({
getUserDataPath: () => root,
getCapability: async () => capability,
// 传输层是唯一被替换的部分:策略、幂等、审计、节流都是真的。
send: async (request) => {
sends.push(request)
return { success: true, status: readyCapability().senderStatus }
},
wait: async () => undefined
})
// `now` 固定:否则 `createdAt` 会变成墙上时钟,把「槽位必须晚于创建时间」那道闸踩掉。
const store = new AutomationRuleStore({ userDataPath: () => root, now: () => CREATED_AT })
const log = new AutomationExecutionLogService({ userDataPath: () => root })
const runner = new AutomationActionRunner({
executeAction: (request) => gateway.execute(request),
generateReport:
options.generate ??
(async () => ({
success: true,
groupName: '技术交流群',
pngPath: path.join(root, 'scheduled-report.png'),
messageCount: 42
})),
saveGeneratedReport: async (request) => {
const record: GeneratedReportRecord = {
id: 'report-1',
contactId: request.contactId,
contactName: request.contactName,
source: 'scheduled',
dateRange: request.dateRange,
messageCount: request.messageCount,
generatedAt: request.generatedAt,
reportDate: '2026-08-26',
pngPath: path.join(root, 'scheduled-report.png'),
htmlStatus: 'ready',
pngStatus: 'ready'
}
savedReports.push(record)
return { success: true, record }
},
resolveReportContact: () => ({
md5: 'tech-md5',
m_nsUsrName: 'tech@chatroom',
m_nsNickName: '技术交流群'
})
})
const client = {
getMyUsernameCandidates: () => [SELF],
getSessions: () => [{ username: 'tech@chatroom', nickname: '技术交流群' }]
} as unknown as Wcdb4Client
const service = new AutomationService(client, {
ruleStore: store,
executionLog: log,
runner,
getCapability: async () => capability,
now: () => AFTER_SLOT_MS
})
const ruleId = store.createRule(
scheduledDraft(options.target ?? 'source_chat', {
...(options.contactId ? { contactId: options.contactId } : {}),
...(options.enabled === false ? { enabled: false } : {}),
...(options.postfixText ? { postfixText: options.postfixText } : {})
})
).id
const scheduler = new ScheduledReportService({
storageDir: path.join(root, 'scheduled-reports-legacy'),
executeRule: (id, runOptions) => service.executeScheduledRule(id, runOptions),
listRules: () => store.listRules(),
listExecutions: (input) => log.list(input),
isDatabaseReady: () => true,
now: () => AFTER_SLOT
})
return { scheduler, service, store, log, sends, ruleId }
}
/** 每个用例都换一个干净的 userData 目录,避免审计 / 幂等记录互相污染。 */
const workdirs: string[] = []
function isolatedRoot(): void {
const dir = fs.mkdtempSync(path.join(root, 'case-'))
workdirs.push(dir)
}
describe('scheduled report end-to-end chain', () => {
beforeEach(() => {
fs.removeSync(path.join(root, 'automation'))
fs.removeSync(path.join(root, 'actions'))
fs.removeSync(path.join(root, 'scheduled-reports-legacy'))
isolatedRoot()
})
afterAll(() => {
for (const dir of workdirs) fs.removeSync(dir)
fs.removeSync(root)
})
it('到点 ⇒ 生成日报 → 落日报历史 → 穿过 Gateway 发出,并记录 schedule 触发', async () => {
const chain = buildChain()
await chain.scheduler.tick(AFTER_SLOT)
await chain.scheduler.settle()
// 真的发出去了:说明 purpose 通过了 `AUTOMATION_PURPOSE_ALLOWLIST`。
expect(chain.sends).toHaveLength(1)
expect(chain.sends[0]).toMatchObject({
type: 'image',
to: 'tech@chatroom',
isGroup: true,
filePath: path.join(root, 'scheduled-report.png')
})
const record = chain.log.list()[0]
expect(record.trigger).toBe('schedule')
expect(record.status).toBe('success')
expect(record.steps.map((step) => `${step.key}:${step.status}`)).toEqual([
'schedule_triggered:success',
'report_generating:success',
'report_generated:success',
'send_resolved:success',
'report_sent:success'
])
// `send_resolved` 的 detail 是**用户可读的目标名**,不是 roomId。
expect(record.steps.find((step) => step.key === 'send_resolved')?.detail).toBe('技术交流群')
})
it('配置了后置词 ⇒ 图片 sent 之后补发一条文本,两条各自独立', async () => {
const chain = buildChain({ postfixText: '今日日报' })
await chain.scheduler.tick(AFTER_SLOT)
await chain.scheduler.settle()
/*
* 两条:先图后文。
*
* 后置词走的是**另一个 purpose**(`automation_scheduled_report_postfix`)——
* 只要它在 `AUTOMATION_PURPOSE_ALLOWLIST` 里漏登记,第二次发送就会被策略层
* 以 ACTION_NOT_ALLOWED 静默拦下,这里会只剩 1 条。这正是本用例存在的理由。
*/
expect(chain.sends.map((send) => send.type)).toEqual(['image', 'text'])
expect(chain.sends[0]).toMatchObject({
type: 'image',
to: 'tech@chatroom',
isGroup: true,
filePath: path.join(root, 'scheduled-report.png')
})
expect(chain.sends[1]).toMatchObject({
type: 'text',
to: 'tech@chatroom',
isGroup: true,
text: '今日日报'
})
// 两条都成功,整次执行才是 success(后置词失败会拖垮整次判定,见下一条)。
expect(chain.log.list()[0].status).toBe('success')
})
it('未配置后置词 ⇒ 只发图片,绝不"顺手补一句"', async () => {
const chain = buildChain()
await chain.scheduler.tick(AFTER_SLOT)
await chain.scheduler.settle()
expect(chain.sends.map((send) => send.type)).toEqual(['image'])
expect(chain.log.list()[0].status).toBe('success')
})
it('图片能力缺失 ⇒ 后置词绝不发送(图片没发出去,就没有"之后")', async () => {
const capability = readyCapability()
const chain = buildChain({
postfixText: '今日日报',
capability: { ...capability, capabilities: { ...capability.capabilities, image: false } }
})
await chain.scheduler.tick(AFTER_SLOT)
await chain.scheduler.settle()
// 图片都没发出去,后置词更不可能发 —— 传输层零调用。
expect(chain.sends).toHaveLength(0)
expect(chain.log.list()[0].status).toBe('failed')
})
it('图片已发出但后置词失败 ⇒ 如实判失败,不吞掉也不回滚', async () => {
const capability = readyCapability()
// 文字能力缺失:图片照发,但后置词那一步会被 SEND_NOT_READY 拦下。
const chain = buildChain({
postfixText: '今日日报',
capability: { ...capability, capabilities: { ...capability.capabilities, text: false } }
})
await chain.scheduler.tick(AFTER_SLOT)
await chain.scheduler.settle()
// 图片确实发出去了 —— 微信发送不支持回滚,所以**必须留痕**,不能假装没发生。
expect(chain.sends.map((send) => send.type)).toEqual(['image'])
const record = chain.log.list()[0]
expect(record.status).toBe('failed')
// 失败点必须精确落在"后置词",而不是伪装成"日报没生成/没发送"。
expect(record.steps.find((step) => step.key === 'report_sent')?.error).toContain(
'后置词发送失败'
)
})
it('同一槽位重放 ⇒ 图片与后置词各自幂等,都不会重复发', async () => {
const chain = buildChain({ postfixText: '今日日报' })
await chain.service.executeScheduledRule(chain.ruleId, {
trigger: 'schedule',
scheduledSlot: SLOT_ISO
})
await chain.service.executeScheduledRule(chain.ruleId, {
trigger: 'schedule',
scheduledSlot: SLOT_ISO
})
// 两次重放合起来仍然只有一条图 + 一条文:两条各有独立幂等位,
// 少给后置词一个 key 会让它在第二次重放时"被图片的幂等位短路掉"。
expect(chain.sends.map((send) => send.type)).toEqual(['image', 'text'])
})
it('四个发送目标各自解析正确(同一个 Gateway,不绑作用域锁)', async () => {
const cases: Array<{ target: ScheduledReportTargetType; to: string; isGroup: boolean }> = [
{ target: 'source_chat', to: 'tech@chatroom', isGroup: true },
{ target: 'file_transfer', to: 'filehelper', isGroup: false },
{ target: 'self', to: SELF, isGroup: false },
{ target: 'contact', to: 'wxid_friend', isGroup: false }
]
for (const item of cases) {
const chain = buildChain({ target: item.target })
await chain.scheduler.tick(AFTER_SLOT)
await chain.scheduler.settle()
expect(
chain.sends.map((send) => ({ type: send.type, to: send.to, isGroup: send.isGroup }))
).toEqual([{ type: 'image', to: item.to, isGroup: item.isGroup }])
expect(chain.log.list()[0].status).toBe('success')
}
})
it('目标失效 ⇒ 生成成功但**绝不** fallback 到别处,且如实判失败', async () => {
const chain = buildChain({ target: 'contact', contactId: 'wxid_gone' })
await chain.scheduler.tick(AFTER_SLOT)
await chain.scheduler.settle()
expect(chain.sends).toHaveLength(0)
const record = chain.log.list()[0]
expect(record.status).toBe('failed')
// 失败点在"发到哪",不能伪装成"生成失败"。
expect(record.steps.find((step) => step.key === 'report_generating')?.status).toBe('success')
expect(record.steps.find((step) => step.key === 'send_resolved')?.status).toBe('failed')
expect(record.steps.find((step) => step.key === 'report_sent')?.status).toBe('skipped')
})
it('停用的规则完全不执行(连日报都不生成)', async () => {
const chain = buildChain({ enabled: false })
await chain.scheduler.tick(AFTER_SLOT)
await chain.scheduler.settle()
expect(chain.sends).toHaveLength(0)
expect(chain.log.list()).toHaveLength(0)
})
it('图片能力缺失 ⇒ 日报照生成,但不发送,并说明原因', async () => {
const capability = readyCapability()
const chain = buildChain({
capability: { ...capability, capabilities: { ...capability.capabilities, image: false } }
})
await chain.scheduler.tick(AFTER_SLOT)
await chain.scheduler.settle()
expect(chain.sends).toHaveLength(0)
const record = chain.log.list()[0]
expect(record.status).toBe('failed')
expect(record.steps.find((step) => step.key === 'report_generating')?.status).toBe('success')
expect(record.steps.find((step) => step.key === 'report_sent')?.error).toContain('无法发送图片')
})
it('手动执行与定时执行**共用同一条链路**,只有 trigger 不同', async () => {
const chain = buildChain()
const outcome = await chain.service.executeScheduledRule(chain.ruleId, { trigger: 'manual' })
expect(outcome.executed).toBe(true)
expect(chain.sends).toHaveLength(1)
expect(chain.log.list()[0].trigger).toBe('manual')
})
it('同一条规则并发触发 ⇒ 第二个被 inFlight 挡掉,只发一次', async () => {
const chain = buildChain()
const [first, second] = await Promise.all([
chain.service.executeScheduledRule(chain.ruleId, {
trigger: 'schedule',
scheduledSlot: SLOT_ISO
}),
chain.service.executeScheduledRule(chain.ruleId, {
trigger: 'schedule',
scheduledSlot: SLOT_ISO
})
])
const executed = [first, second].filter((item) => item.executed)
const blocked = [first, second].filter((item) => !item.executed)
expect(executed).toHaveLength(1)
expect(blocked).toHaveLength(1)
expect(blocked[0].reason).toBe('in_flight')
expect(chain.sends).toHaveLength(1)
})
it('同一槽位被重复触发 ⇒ 确定性 executionId 让 Gateway 去重,不会重复发', async () => {
const chain = buildChain()
const first = await chain.service.executeScheduledRule(chain.ruleId, {
trigger: 'schedule',
scheduledSlot: SLOT_ISO
})
const second = await chain.service.executeScheduledRule(chain.ruleId, {
trigger: 'schedule',
scheduledSlot: SLOT_ISO
})
// executionId 由 ruleId + slot 确定性派生,所以两次完全一致。
expect(first.executionId).toBeTruthy()
expect(second.executionId).toBe(first.executionId)
// 传输层只被调用一次 —— 幂等拦在 Gateway,不是靠上层自觉。
expect(chain.sends).toHaveLength(1)
})
it('槽位游标:定时触发消费槽位,手动执行不消费', async () => {
const chain = buildChain()
await chain.service.executeScheduledRule(chain.ruleId, { trigger: 'manual' })
const afterManual = chain.store.getRule(chain.ruleId)?.scheduledReport
expect(afterManual?.lastRunAt).toBeTruthy()
// 手动执行若写了槽位,就会把今天安排好的那次定时执行一起吞掉。
expect(afterManual?.lastScheduledSlot).toBeUndefined()
await chain.service.executeScheduledRule(chain.ruleId, {
trigger: 'schedule',
scheduledSlot: SLOT_ISO
})
expect(chain.store.getRule(chain.ruleId)?.scheduledReport?.lastScheduledSlot).toBe(SLOT_ISO)
})
it('调度器只读规则:槽位已消费后重启不再补跑', async () => {
const chain = buildChain()
await chain.scheduler.tick(AFTER_SLOT)
await chain.scheduler.settle()
expect(chain.sends).toHaveLength(1)
// 新建一个调度器实例(= 应用重启):规则与游标都从盘上读。
const restarted = new ScheduledReportService({
storageDir: path.join(root, 'scheduled-reports-legacy'),
executeRule: (id, runOptions) => chain.service.executeScheduledRule(id, runOptions),
listRules: () => chain.store.listRules(),
listExecutions: (input) => chain.log.list(input),
isDatabaseReady: () => true,
now: () => AFTER_SLOT
})
await restarted.tick(AFTER_SLOT)
await restarted.settle()
expect(chain.sends).toHaveLength(1)
})
it('执行日志与步骤 detail 不泄露 roomId / wxid / 文件路径', async () => {
const chain = buildChain({ target: 'contact' })
await chain.scheduler.tick(AFTER_SLOT)
await chain.scheduler.settle()
const record = chain.log.list()[0]
const serialized = JSON.stringify(record)
expect(serialized).not.toContain('@chatroom')
expect(serialized).not.toContain('wxid_friend')
expect(serialized).not.toContain(root)
// 但必须留下可读的目标名(备注优先)。
expect(record.steps.find((step) => step.key === 'send_resolved')?.detail).toBe('好友备注')
})
it('定时失败 ⇒ 推一条微信异常通知,且"今天执行"统计与那条日志是同一份', async () => {
const store = new AutomationRuleStore({ userDataPath: () => root, now: () => CREATED_AT })
const log = new AutomationExecutionLogService({ userDataPath: () => root })
const runner = new AutomationActionRunner({
// 生成直接失败 ⇒ 定时执行判 failed。
generateReport: async () => ({
success: false,
error: '模型调用超时',
errorCode: 'AI_TIMEOUT'
})
})
const service = new AutomationService(
{
getMyUsernameCandidates: () => [SELF],
getSessions: () => [{ username: 'tech@chatroom', nickname: '技术交流群' }]
} as unknown as Wcdb4Client,
{
ruleStore: store,
executionLog: log,
runner,
getCapability: async () => readyCapability(),
now: () => AFTER_SLOT_MS
}
)
const ruleId = store.createRule(scheduledDraft('source_chat')).id
const notifications: string[] = []
const scheduler = new ScheduledReportService({
storageDir: path.join(root, 'scheduled-reports-legacy'),
executeRule: (id, runOptions) => service.executeScheduledRule(id, runOptions),
listRules: () => store.listRules(),
listExecutions: (input) => log.list(input),
sendNotification: async (request) => {
notifications.push(request.text)
return { success: true, status: 'sent' }
},
getNotificationRecipient: () => SELF,
getAgentHubStatus: () => ({ hub: 'online', connector: 'online', updatedAt: 0 }),
isDatabaseReady: () => true,
now: () => AFTER_SLOT
})
// 开启异常通知会先试发一条,把它排掉,后面只看执行失败那一条。
await scheduler.setNotificationEnabled(true)
expect(notifications).toHaveLength(1)
notifications.length = 0
await scheduler.tick(AFTER_SLOT)
await scheduler.settle()
expect(log.list()[0].status).toBe('failed')
expect(log.list()[0].ruleId).toBe(ruleId)
expect(notifications).toHaveLength(1)
expect(notifications[0]).toContain(RULE_NAME)
expect(notifications[0]).toContain('模型调用超时')
})
it('规则不存在 / 目标待重选 ⇒ 明确拒绝,不执行也不发送', async () => {
const chain = buildChain()
const missing = await chain.service.executeScheduledRule('rule_does_not_exist', {
trigger: 'manual'
})
expect(missing.executed).toBe(false)
expect(missing.reason).toBe('rule_not_found')
// 把规则改成「目标待重选」:迁移遗留态,执行必然失败 ⇒ 直接拒绝。
const current = chain.store.getRule(chain.ruleId) as AutomationRule
chain.store.updateRule(chain.ruleId, {
...(current as unknown as AutomationRuleDraft),
scheduledReport: { ...current.scheduledReport!, targetNeedsReview: true }
})
const needsReview = await chain.service.executeScheduledRule(chain.ruleId, {
trigger: 'manual'
})
expect(needsReview.executed).toBe(false)
expect(needsReview.reason).toBe('target_needs_review')
expect(chain.sends).toHaveLength(0)
})
})
+2 -6
View File
@@ -1,6 +1,6 @@
{
"note": "测试文件类型检查基线:只允许下降,不允许上升。用 node scripts/typecheck-tests.cjs --update 下调。",
"total": 234,
"total": 209,
"files": {
"tests/component/ai-model-settings.test.tsx": 2,
"tests/component/ai-search-cache-consent.test.tsx": 3,
@@ -10,7 +10,7 @@
"tests/component/database-connection.test.tsx": 2,
"tests/component/export-voice-transcript.test.tsx": 1,
"tests/component/export-workspace.test.tsx": 8,
"tests/component/group-exit-monitor-workspace.test.tsx": 11,
"tests/component/group-exit-monitor-workspace.test.tsx": 10,
"tests/component/image-bubble.test.tsx": 1,
"tests/component/image-text-index-card.test.tsx": 5,
"tests/component/local-image-text-recognition.test.tsx": 1,
@@ -32,13 +32,11 @@
"tests/integration/image-text-index-synthetic-e2e.test.ts": 22,
"tests/integration/query-agent-image-ocr-text.test.ts": 16,
"tests/integration/query-agent-self-direction.test.ts": 7,
"tests/integration/scheduled-report-api.test.ts": 3,
"tests/unit/agent-hub-routing.test.ts": 1,
"tests/unit/ai-search-pipeline-service.test.ts": 1,
"tests/unit/ai-search-provider-consent.test.ts": 5,
"tests/unit/ai-search-workspace-pure-functions.test.ts": 13,
"tests/unit/ask-wechat-service.test.ts": 8,
"tests/unit/group-exit-monitor-service.test.ts": 1,
"tests/unit/group-report.test.ts": 1,
"tests/unit/http-media-service.test.ts": 2,
"tests/unit/knowledge-freshness.test.ts": 1,
@@ -47,8 +45,6 @@
"tests/unit/query-agent-image-ocr-coverage.test.ts": 1,
"tests/unit/query-agent-service.test.ts": 4,
"tests/unit/report-template-market.test.ts": 1,
"tests/unit/scheduled-report-api-service.test.ts": 2,
"tests/unit/scheduled-report-service.test.ts": 18,
"tests/unit/system-ocr-service.test.ts": 3,
"tests/unit/voice-pipeline.test.ts": 1
}
+216 -16
View File
@@ -220,16 +220,37 @@ describe('AutomationRuleStore', () => {
})
})
/*
* 存量安装:早期版本把"部分勾选"判成无法无损映射,于是落盘了
* `targetNeedsReview: true`。这个标记会拦发送(`handleGroupExit` 第②道闸门),
* 所以**用户重存一次必须能清掉它** —— 否则那条规则永远发不出去。
*
* 注意种的是"已经落盘的存量状态",不是靠迁移产出:迁移现在不再产生这个标记了。
*/
it('保存退群通知会清掉迁移遗留的「目标待重选」标记', () => {
writeLegacyMonitorState({
monitoredRoomIds: ['a@chatroom', 'b@chatroom'],
notificationRoomIds: ['a@chatroom']
})
const store = new AutomationRuleStore(deps(root))
store.saveLeaveNotificationRule({
name: '退群通知',
enabled: false,
ruleType: 'leave_notification',
scope: 'group',
conditions: {},
actions: [],
cooldownSeconds: 0,
replyDelaySeconds: 2,
leaveNotification: {
target: { type: 'source_chat' },
template: '[退群监测]',
notifyScope: 'all',
notifyRoomIds: [],
targetNeedsReview: true
}
})
expect(
store.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)?.leaveNotification?.targetNeedsReview
).toBe(true)
// 用户来重存一次:草稿里不带这个标记 ⇒ 归一化后它就该消失。
store.saveLeaveNotificationRule({
...store.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID),
enabled: true,
@@ -384,14 +405,17 @@ describe('AutomationRuleStore', () => {
)
expect(leave?.enabled).toBe(false)
// 关闭状态下 target 只是占位值(= 默认目标),不会发送任何东西。
// 关闭状态下 target 只是占位值(= 「发回本群」),不会发送任何东西。
expect(leave?.leaveNotification?.target).toEqual({ type: 'source_chat' })
// 范围本身也精确:`selected` + 空集 = 「一个群都不通知」。
expect(leave?.leaveNotification?.notifyScope).toBe('selected')
expect(leave?.leaveNotification?.notifyRoomIds).toEqual([])
// 用户写的模板内容保留;同一次启动的"补群名"升级会另加一行。
expect(leave?.leaveNotification?.template).toContain('退群: {user}')
expect(leave?.leaveNotification?.template).toContain('群聊: {groupName}')
})
it('旧配置在每个被监控群都通知 → 无损映射为「当前群聊」且保持启用', () => {
it('旧配置在每个被监控群都通知 → 无损映射为「当前群聊」+ 全部范围,且保持启用', () => {
writeLegacyMonitorState({
monitoredRoomIds: ['a@chatroom', 'b@chatroom'],
notificationRoomIds: ['a@chatroom', 'b@chatroom']
@@ -403,15 +427,16 @@ describe('AutomationRuleStore', () => {
expect(leave?.enabled).toBe(true)
expect(leave?.leaveNotification?.target).toEqual({ type: 'source_chat' })
expect(leave?.leaveNotification?.notifyScope).toBe('all')
expect(leave?.leaveNotification?.targetNeedsReview).toBeUndefined()
})
/*
* 关键的一条:旧「通知群聊」是**逐群**的多值配置,新目标是**单值**。
* 只勾了一部分时无法无损映射 —— 这时候:
* 不取第一个群、不改写成当前群聊、保持关闭、要求用户重选,并留一份备份。
* 关键的一条:旧「通知群聊」是**逐群**的多值配置,恢复 `notifyScope` 之后
* 它就是一个正常可表达的字段 —— 所以这里**不再降级成 needs_review**:
* 那份勾选被原样保留、规则保持启用、不会再多一次"被无辜停用"的体验。
*/
it('旧配置只勾了部分群 → 标记待重选、不发送、留下备份,绝不猜', () => {
it('旧配置只勾了部分群 → 原样保留那份子集,保持启用,不做有损改写', () => {
writeLegacyMonitorState({
monitoredRoomIds: ['a@chatroom', 'b@chatroom', 'c@chatroom'],
notificationRoomIds: ['b@chatroom'],
@@ -421,11 +446,13 @@ describe('AutomationRuleStore', () => {
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?.enabled).toBe(true)
expect(leave?.leaveNotification?.targetNeedsReview).toBeUndefined()
// 收件人恒为事件所在群(旧版 notifyGroup 的事实)⇒ target 仍是 source_chat。
expect(leave?.leaveNotification?.target).toEqual({ type: 'source_chat' })
// 二次勾选逐字保留:只通知 b,不多不少。
expect(leave?.leaveNotification?.notifyScope).toBe('selected')
expect(leave?.leaveNotification?.notifyRoomIds).toEqual(['b@chatroom'])
// 用户写的模板内容保留;同一次启动的"补群名"升级会另加一行。
expect(leave?.leaveNotification?.template).toContain('退群: {user}')
expect(leave?.leaveNotification?.template).toContain('群聊: {groupName}')
@@ -434,10 +461,10 @@ describe('AutomationRuleStore', () => {
path.join(root, 'automation', 'leave-notification-migration-backup.json')
)
expect(backup).toMatchObject({
outcome: 'needs_review',
outcome: 'lossless_selected_groups',
legacy: { notificationRoomIds: ['b@chatroom'] }
})
expect(store.getLastLeaveNotificationMigration()?.outcome).toBe('needs_review')
expect(store.getLastLeaveNotificationMigration()?.outcome).toBe('lossless_selected_groups')
})
it('迁移只跑一次:重启两次也只有一条退群通知规则', () => {
@@ -477,6 +504,179 @@ describe('AutomationRuleStore', () => {
})
})
/**
* 旧「定时日报任务」→ `scheduled_report` 规则的 store 级迁移。
*
* 纯映射器的逐字段断言在 `scheduled-report-migration.test.ts`;
* 这里只守 store 负责的三件事:**插几条 / 幂等 / 解析器不可用时推迟**。
*/
describe('AutomationRuleStore · 定时日报迁移', () => {
const legacyTasksFile = (): string => path.join(root, 'scheduled-reports', 'tasks.json')
const backupFile = (): string =>
path.join(root, 'automation', 'scheduled-report-migration-backup.json')
/** 与 main 侧注入的真实解析器同口径(`@chatroom` 原样透传)。 */
const resolver = (raw: string): string | undefined => {
const key = raw.trim()
if (!key) return undefined
if (key.endsWith('@chatroom')) return key
return { 'tech-md5': 'tech@chatroom', 'product-md5': 'product@chatroom' }[key]
}
const writeLegacyTasks = (value: unknown): void => {
fs.ensureDirSync(path.dirname(legacyTasksFile()))
fs.writeJsonSync(legacyTasksFile(), value)
}
const legacyTasks = (): unknown[] => [
{
id: 'scheduled_report_a',
name: 'A 群日报',
group: 'tech-md5',
target: 'tech@chatroom',
scheduleTime: '09:00',
reportRange: 'yesterday',
enabled: true,
createdAt: '2026-08-01T01:00:00.000Z',
updatedAt: '2026-08-01T01:00:00.000Z'
},
{
id: 'scheduled_report_b',
name: 'B 群日报',
group: 'product-md5',
target: 'product@chatroom',
scheduleTime: '10:00',
reportRange: 'today',
enabled: false,
createdAt: '2026-08-02T01:00:00.000Z',
updatedAt: '2026-08-02T01:00:00.000Z'
},
{
id: 'scheduled_report_c',
name: '发到别的群',
group: 'tech-md5',
target: 'product@chatroom',
scheduleTime: '11:00',
reportRange: 'today',
enabled: true,
createdAt: '2026-08-03T01:00:00.000Z',
updatedAt: '2026-08-03T01:00:00.000Z'
}
]
beforeEach(() => {
fs.removeSync(path.join(root, 'automation'))
fs.removeSync(path.join(root, 'scheduled-reports'))
})
it('3 条旧任务 ⇒ 3 条 scheduled_report 规则(连同内置规则一起),且判定分布如实', () => {
writeLegacyTasks(legacyTasks())
const store = new AutomationRuleStore(deps(root))
store.setLegacyConversationResolver(resolver)
const scheduled = store.listRules().filter((rule) => rule.ruleType === 'scheduled_report')
expect(scheduled.map((rule) => rule.id).sort()).toEqual([
'scheduled_report_a',
'scheduled_report_b',
'scheduled_report_c'
])
const summary = store.getLastScheduledReportMigration()
expect(summary).toEqual({
total: 3,
migrated: 3,
lossless: 2,
needsReview: 1,
duplicatesSkipped: 0
})
// 「发到别的群」那条:不自动改写目标,且必须停用。
const needsReview = scheduled.find((rule) => rule.id === 'scheduled_report_c')
expect(needsReview?.enabled).toBe(false)
expect(needsReview?.scheduledReport?.targetNeedsReview).toBe(true)
// 旧任务是启用的,但迁移后必须停用 —— 不能替用户决定发到哪儿。
expect(store.getRule('scheduled_report_a')?.enabled).toBe(true)
})
it('二次启动不会重复迁移(仍只有 3 条,不是 6 条)', () => {
writeLegacyTasks(legacyTasks())
const first = new AutomationRuleStore(deps(root))
first.setLegacyConversationResolver(resolver)
expect(
first.listRules().filter((rule) => rule.ruleType === 'scheduled_report')
).toHaveLength(3)
// 第二个实例 = 重启:从盘上读,旧 tasks.json 仍在原处。
const second = new AutomationRuleStore(deps(root))
second.setLegacyConversationResolver(resolver)
const ids = second
.listRules()
.filter((rule) => rule.ruleType === 'scheduled_report')
.map((rule) => rule.id)
expect(ids).toHaveLength(3)
expect(new Set(ids).size).toBe(3)
// 已迁移标记落盘 ⇒ 不再有待迁任务。
expect(second.hasPendingScheduledReportMigration()).toBe(false)
})
it('解析器不可用时**推迟**迁移:不置标记、不插规则、如实报告待迁', () => {
writeLegacyTasks(legacyTasks())
const store = new AutomationRuleStore(deps(root))
expect(store.listRules().filter((rule) => rule.ruleType === 'scheduled_report')).toHaveLength(0)
expect(store.hasPendingScheduledReportMigration()).toBe(true)
expect(store.getLastScheduledReportMigration()).toBeNull()
// 数据库就绪后注入解析器 ⇒ 立刻补跑。
store.setLegacyConversationResolver(resolver)
expect(store.listRules().filter((rule) => rule.ruleType === 'scheduled_report')).toHaveLength(3)
expect(store.hasPendingScheduledReportMigration()).toBe(false)
})
it('迁移前把旧任务原样落一份 backup(用户数据可回溯)', () => {
const tasks = legacyTasks()
writeLegacyTasks(tasks)
const store = new AutomationRuleStore(deps(root))
// 先注入解析器、后首次加载:这正是 main 的真实顺序(构造函数不读盘)。
// `setLegacyConversationResolver` 在未加载时只记住 resolver,
// 迁移推迟到第一次 `ensureLoaded()`,注入顺序不影响结果。
store.setLegacyConversationResolver(resolver)
store.listRules()
expect(fs.pathExistsSync(backupFile())).toBe(true)
const backup = fs.readJsonSync(backupFile()) as {
summary: { migrated: number }
legacyTasks: unknown[]
}
expect(backup.summary.migrated).toBe(3)
// 备份里保留**原始字段**(包括旧目标原文),否则出了问题无法回溯。
expect(backup.legacyTasks).toEqual(tasks)
// 旧 tasks.json 不被删除或改写 —— 迁移必须是可逆的。
expect(fs.readJsonSync(legacyTasksFile())).toEqual(tasks)
})
it('没有旧任务时走全新安装路径:不插任何定时日报规则,也不报待迁', () => {
const store = new AutomationRuleStore(deps(root))
store.setLegacyConversationResolver(resolver)
expect(store.listRules().filter((rule) => rule.ruleType === 'scheduled_report')).toHaveLength(0)
expect(store.hasPendingScheduledReportMigration()).toBe(false)
expect(store.getLastScheduledReportMigration()).toBeNull()
expect(fs.pathExistsSync(backupFile())).toBe(false)
})
it('旧文件损坏时按「没有旧任务」处理,绝不抛异常挡住启动', () => {
fs.ensureDirSync(path.dirname(legacyTasksFile()))
fs.writeFileSync(legacyTasksFile(), 'not json at all', 'utf8')
const store = new AutomationRuleStore(deps(root))
store.setLegacyConversationResolver(resolver)
expect(store.listRules().filter((rule) => rule.ruleType === 'scheduled_report')).toHaveLength(0)
})
})
describe('AutomationExecutionLogService', () => {
beforeEach(() => {
fs.removeSync(path.join(root, 'automation'))
@@ -201,6 +201,65 @@ describe('AutomationService.handleGroupExit', () => {
expect(harness.executions).toHaveLength(0)
})
/*
* 「通知范围」=历史「退群监控 → 通知群聊」的第二层。
* 缺了它,规则一启用就等于给全部已监控群聊发通知 —— 就是那个被报回来的问题。
*/
it('范围 = 仅选中的群聊:勾中的群照常发送', async () => {
const harness = createHarness({
config: { notifyScope: 'selected', notifyRoomIds: ['room@chatroom'] }
})
await harness.service.handleGroupExit(exitEvent())
expect(harness.requests).toHaveLength(1)
expect(harness.executions).toHaveLength(1)
})
it('范围 = 仅选中的群聊:没勾的群不发,且不创建 execution', async () => {
const harness = createHarness({
config: { notifyScope: 'selected', notifyRoomIds: ['other@chatroom'] }
})
await harness.service.handleGroupExit(exitEvent())
expect(harness.requests).toHaveLength(0)
// 被范围排除**不是**"一次失败的执行",所以不能留失败记录吓人。
expect(harness.executions).toHaveLength(0)
})
it('范围 = 全部已监控群聊:不筛群,照常发送', async () => {
const harness = createHarness({ config: { notifyScope: 'all', notifyRoomIds: [] } })
await harness.service.handleGroupExit(exitEvent())
expect(harness.requests).toHaveLength(1)
})
/*
* 范围判定必须在幂等**之前**:否则被排除的群会白白占用该事件的幂等位,
* 用户随后补勾了那个群,同一个事件反而再也发不出去。
*/
it('范围外不占用幂等位:补勾之后同一事件仍能发出', async () => {
const harness = createHarness({
config: { notifyScope: 'selected', notifyRoomIds: [] }
})
await harness.service.handleGroupExit(exitEvent())
expect(harness.requests).toHaveLength(0)
expect(harness.executions).toHaveLength(0)
harness.setRule(
createDefaultLeaveNotificationRule(NOW, {
notifyScope: 'selected',
notifyRoomIds: ['room@chatroom']
})
)
await harness.service.handleGroupExit(exitEvent())
expect(harness.requests).toHaveLength(1)
})
it('缺少 eventId 时直接跳过,绝不发送', async () => {
const harness = createHarness()
@@ -260,7 +319,7 @@ describe('AutomationService.handleGroupExit', () => {
})
/*
* §「旧联系人失效」:不偷偷发给别人,execution 记 failed,Gateway 一次都不调。
* 「旧联系人失效」:不偷偷发给别人,execution 记 failed,Gateway 一次都不调。
*/
it('指定好友已不存在 → execution failed 且不调用 Gateway', async () => {
const harness = createHarness({
@@ -353,7 +412,7 @@ describe('AutomationService.handleGroupExit', () => {
})
/*
* §「不同事件」:同群两个成员先后退出就是两条通知。
* 不同事件:同群两个成员先后退出就是两条通知。
* 退群通知**不复用**消息型规则的 cooldown,否则第二次会被时间窗吞掉。
*/
it('不同 eventId 各自执行一次,不被合并', async () => {
+54 -22
View File
@@ -45,6 +45,23 @@ const contacts: LeaveNotificationCandidateContact[] = [
{ m_nsUsrName: 'wxid_self', m_nsNickName: '我自己', type: 'user' }
]
/**
* 构造一份合法配置。
*
* 本文件关心的是**目标解析**,通知范围(`notifyScope`)与之正交,
* 所以在这里统一补默认值 —— 契约再新增字段时也只改这一处,
* 不必去逐个补十几处字面量(那才是漏字段的根源)。
*/
function config(overrides: Partial<LeaveNotificationConfig> = {}): LeaveNotificationConfig {
return {
target: { type: 'source_chat' },
template: '',
notifyScope: 'all',
notifyRoomIds: [],
...overrides
}
}
function resolve(
config: LeaveNotificationConfig,
overrides: { selfWxid?: string; event?: GroupMemberExitedEvent } = {}
@@ -59,7 +76,7 @@ function resolve(
describe('leaveNotificationTarget · 四种目标', () => {
it('当前群聊 = 事件所在群,不是"最后活跃会话"', () => {
const result = resolve({ target: { type: 'source_chat' }, template: '' })
const result = resolve(config())
expect(result).toEqual({
ok: true,
@@ -71,23 +88,42 @@ describe('leaveNotificationTarget · 四种目标', () => {
})
it('当前群聊在群名缺失时用中性称呼,不回落成 roomId', () => {
const result = resolveLeaveNotificationTarget({
config: { target: { type: 'source_chat' }, template: '' },
// 事件里没带群名,但本地联系人表里有这个群 ⇒ 用会话昵称,
// 比「当前群聊」更具体,且**仍然不是** roomId。
const withNickname = resolveLeaveNotificationTarget({
config: config(),
event: { ...EVENT, groupName: undefined },
contacts,
selfWxid: 'wxid_self'
})
expect(withNickname.ok).toBe(true)
if (withNickname.ok) {
expect(withNickname.target.recipient.name).toBe('测试群')
expect(withNickname.target.displayName).toBe('测试群')
}
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.target.recipient.name).toBe('当前群聊')
expect(result.target.displayName).toBe('发生退群事件的群聊')
// 群名与联系人表**都**拿不到 ⇒ 退到中性称呼。
// 唯一不能发生的事是把 roomId / wxid 当显示名吐出去。
const fallback = resolveLeaveNotificationTarget({
config: config(),
event: { ...EVENT, groupName: undefined },
contacts: [],
selfWxid: 'wxid_self'
})
expect(fallback.ok).toBe(true)
if (fallback.ok) {
expect(fallback.target.recipient.name).toBe('当前群聊')
expect(fallback.target.displayName).toBe('发生退群事件的群聊')
// `recipient.id` 本来就该是真实会话 id(发送要用它),
// 这条断言约束的是**显示名**:不许把 id 当称呼给人看。
expect(fallback.target.recipient.name).not.toContain('@chatroom')
expect(fallback.target.displayName).not.toContain('@chatroom')
}
})
it('当前群聊在缺少 conversationId 时直接失败', () => {
const result = resolveLeaveNotificationTarget({
config: { target: { type: 'source_chat' }, template: '' },
config: config(),
event: { ...EVENT, conversationId: '' },
contacts,
selfWxid: 'wxid_self'
@@ -97,7 +133,7 @@ describe('leaveNotificationTarget · 四种目标', () => {
})
it('发给自己 = 当前登录账号的真实 wxid', () => {
const result = resolve({ target: { type: 'self' }, template: '' }, { selfWxid: 'wxid_me' })
const result = resolve(config({ target: { type: 'self' } }), { selfWxid: 'wxid_me' })
expect(result).toEqual({
ok: true,
@@ -113,7 +149,7 @@ describe('leaveNotificationTarget · 四种目标', () => {
* 那会让用户以为"发给自己"生效了,实际消息去了别处。
*/
it('拿不到自身身份时失败,且不 fallback 文件传输助手', () => {
const result = resolve({ target: { type: 'self' }, template: '' }, { selfWxid: '' })
const result = resolve(config({ target: { type: 'self' } }), { selfWxid: '' })
expect(result.ok).toBe(false)
if (!result.ok) {
@@ -123,7 +159,7 @@ describe('leaveNotificationTarget · 四种目标', () => {
})
it('文件传输助手用协议级固定身份,不靠昵称搜索', () => {
const result = resolve({ target: { type: 'file_transfer' }, template: '' })
const result = resolve(config({ target: { type: 'file_transfer' } }))
expect(result.ok).toBe(true)
if (result.ok) {
@@ -136,10 +172,9 @@ describe('leaveNotificationTarget · 四种目标', () => {
})
it('指定好友用稳定 id,显示名用备注', () => {
const result = resolve({
target: { type: 'contact', contactId: 'wxid_friend' },
template: ''
})
const result = resolve(
config({ target: { type: 'contact', contactId: 'wxid_friend' } })
)
expect(result).toEqual({
ok: true,
@@ -153,14 +188,14 @@ describe('leaveNotificationTarget · 四种目标', () => {
describe('leaveNotificationTarget · 失效与非法', () => {
it('联系人不在了 → 失败,且不往别处发', () => {
const result = resolve({ target: { type: 'contact', contactId: 'wxid_gone' }, template: '' })
const result = resolve(config({ target: { type: 'contact', contactId: 'wxid_gone' } }))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.error).toContain('已不存在或当前无法发送')
})
it('还没选好友 → 失败并提示重新选择', () => {
const result = resolve({ target: { type: 'contact' }, template: '' })
const result = resolve(config({ target: { type: 'contact' } }))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.error).toContain('还没有选择通知联系人')
@@ -173,7 +208,7 @@ describe('leaveNotificationTarget · 失效与非法', () => {
WECHAT_FILE_HELPER_USERNAME,
'wxid_self'
]) {
const result = resolve({ target: { type: 'contact', contactId }, template: '' })
const result = resolve(config({ target: { type: 'contact', contactId } }))
expect(result.ok).toBe(false)
}
})
@@ -188,10 +223,7 @@ describe('leaveNotificationTarget · 失效与非法', () => {
})
it('未知目标类型 → 失败(不允许静默当成默认值)', () => {
const result = resolve({
target: { type: 'webhook' as never },
template: ''
})
const result = resolve(config({ target: { type: 'webhook' as never } }))
expect(result.ok).toBe(false)
})
})
+60 -30
View File
@@ -1,6 +1,12 @@
import { describe, expect, it, vi } from 'vitest'
import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat'
import type { ScheduledReportTask } from '../../src/shared/scheduled-report'
import type {
ScheduledReportCreateInput,
ScheduledReportExecution,
ScheduledReportResult,
ScheduledReportTask,
ScheduledReportUpdateInput
} from '../../src/shared/scheduled-report'
import {
ScheduledReportApiService,
type ScheduledReportApiDependencies
@@ -60,36 +66,60 @@ function makeApi(
): ApiTestResult {
const tasks = options.tasks || []
const service = {
listTasks: vi.fn(async () => tasks),
listExecutions: vi.fn(async () => []),
createTask: vi.fn(async (input: Record<string, unknown>) => {
const created = task({
id: `task-${tasks.length + 1}`,
name: String(input.name),
group: String(input.group),
target: String(input.target),
scheduleTime: String(input.scheduleTime),
reportRange: input.reportRange as ScheduledReportTask['reportRange'],
enabled: Boolean(input.enabled)
})
tasks.push(created)
return { success: true, data: created }
}),
updateTask: vi.fn(async () => ({ success: true, data: tasks[0] })),
deleteTask: vi.fn(async (id: string) => ({ success: true, data: { deletedId: id } })),
setTaskEnabled: vi.fn(async (id: string, enabled: boolean) => ({
success: true,
data: task({ id, enabled })
})),
runScheduledReportNow: vi.fn(async (id: string) => ({
success: true,
data: {
id: 'execution-1',
taskId: id,
startedAt: '2026-08-27T01:00:00.000Z',
status: 'success' as const
listTasks: vi.fn(async (): Promise<ScheduledReportTask[]> => tasks),
listExecutions: vi.fn(
async (_taskId?: string): Promise<ScheduledReportExecution[]> => []
),
createTask: vi.fn(
async (input: ScheduledReportCreateInput): Promise<ScheduledReportResult<ScheduledReportTask>> => {
const created = task({
id: `task-${tasks.length + 1}`,
name: String(input.name),
group: String(input.group),
target: String(input.target),
scheduleTime: String(input.scheduleTime),
reportRange: input.reportRange as ScheduledReportTask['reportRange'],
enabled: Boolean(input.enabled)
})
tasks.push(created)
return { success: true, data: created }
}
}))
),
updateTask: vi.fn(
async (
_taskId: string,
_input: ScheduledReportUpdateInput
): Promise<ScheduledReportResult<ScheduledReportTask>> => ({
success: true,
data: tasks[0]
})
),
deleteTask: vi.fn(
async (id: string): Promise<ScheduledReportResult<{ deletedId: string }>> => ({
success: true,
data: { deletedId: id }
})
),
setTaskEnabled: vi.fn(
async (
id: string,
enabled: boolean
): Promise<ScheduledReportResult<ScheduledReportTask>> => ({
success: true,
data: task({ id, enabled })
})
),
runScheduledReportNow: vi.fn(
async (id: string): Promise<ScheduledReportResult<ScheduledReportExecution>> => ({
success: true,
data: {
id: 'execution-1',
taskId: id,
startedAt: '2026-08-27T01:00:00.000Z',
status: 'success' as const
}
})
)
}
return {
api: new ScheduledReportApiService({
@@ -0,0 +1,296 @@
import { describe, expect, it } from 'vitest'
import {
planScheduledReportMigration,
planScheduledReportMigrationBatch,
scheduledReportMigrationRuleId,
type LegacyScheduledReportTask
} from '../../src/main/services/scheduled-report-migration'
import { SCHEDULED_REPORT_DEFAULT_MEMBER_NAME_MODE } from '../../src/shared/automation'
/**
* 旧「定时日报任务」→ `AutomationRule(ruleType='scheduled_report')` 的映射器。
*
* 这是本次迁移里**唯一会改变用户既有行为**的地方,所以每条断言都对应一条产品约束:
* - Rule ID 优先复用旧 taskId(不许拿显示名当稳定 ID);
* - 配置无损映射(时间 / 范围 / 消息类型 / 模板 / 成员名口径 / 超时);
* - 只有"能证明是同一个群"才无损映射成 `source_chat`;
* 历史行为事实上支持"生成 A 群日报 → 发 B 群",新产品不支持 ⇒ 必须 `targetNeedsReview`
* 且 **enabled = false**,绝不自动改写、绝不静默丢掉目标;
* - 解析不到群标识时**保留原值**,运行期如实失败,而不是在这里猜一个群;
* - 批次按 id 去重 ⇒ 幂等(同一份文件跑两次结果一致)。
*/
const NOW = Date.parse('2026-08-27T10:00:00+08:00')
/** 假联系人表:md5 / 群名 → roomId。 */
const CONTACTS: Record<string, string> = {
'tech-md5': 'tech@chatroom',
'product-md5': 'product@chatroom',
'技术交流群': 'tech@chatroom'
}
/**
* 与 main 侧 `setLegacyConversationResolver` 注入的真实解析器**同口径**:
* - 以 `@chatroom` 结尾 ⇒ 原样返回(旧 UI 的 `target` 存的就是 roomId,
* 而 `chat.resolveMd5` 对 roomId 是查不到的,所以这条 pass-through 是必需的);
* - 否则按 md5 / 群名查联系人表。
*/
const resolve = (raw: string): string | undefined => {
const key = raw.trim()
if (!key) return undefined
if (key.endsWith('@chatroom')) return key
return CONTACTS[key]
}
const task = (overrides: LegacyScheduledReportTask = {}): LegacyScheduledReportTask => ({
id: 'scheduled_report_old_1',
name: '技术交流群 · 每日日报',
group: 'tech-md5',
target: 'tech@chatroom',
scheduleTime: '09:15',
reportRange: 'yesterday',
messageTypes: ['text', 'image'],
templateId: 'v1',
memberNameMode: 'wechatNickname',
timeoutSeconds: 600,
enabled: true,
createdAt: '2026-08-01T01:00:00.000Z',
updatedAt: '2026-08-20T01:00:00.000Z',
...overrides
})
describe('定时日报迁移 · Rule ID', () => {
it('优先原样复用旧 taskId(不拿显示名当稳定 ID)', () => {
expect(scheduledReportMigrationRuleId(task())).toBe('scheduled_report_old_1')
// 改名字不影响 id —— 显示名不是身份。
expect(scheduledReportMigrationRuleId(task({ name: '换个名字' }))).toBe(
'scheduled_report_old_1'
)
})
it('旧 id 缺失时退化成由稳定字段派生的确定性 id(重复迁移不产生新规则)', () => {
const broken = task({ id: undefined })
const first = scheduledReportMigrationRuleId(broken)
const second = scheduledReportMigrationRuleId({ ...broken })
expect(first).toMatch(/^scheduled-report:[0-9a-f]{32}$/)
expect(second).toBe(first)
})
it('id 全是空白字符时同样走派生路径', () => {
expect(scheduledReportMigrationRuleId(task({ id: ' ' }))).toMatch(/^scheduled-report:/)
})
})
describe('定时日报迁移 · 逐字段无损映射', () => {
it('配置字段一个不丢(不是"看起来差不多")', () => {
const plan = planScheduledReportMigration(task(), resolve, NOW)
expect(plan.outcome).toBe('lossless_source_chat')
expect(plan.name).toBe('技术交流群 · 每日日报')
expect(plan.enabled).toBe(true)
expect(plan.config.schedule).toEqual({ time: '09:15' })
expect(plan.config.report).toEqual({
// 来源群存**稳定会话 id**:旧值 'tech-md5' 被解析成 roomId。
sourceConversationId: 'tech@chatroom',
range: 'yesterday',
messageTypes: ['text', 'image'],
templateId: 'v1',
memberNameMode: 'wechatNickname',
timeoutSeconds: 600
})
expect(plan.config.target).toEqual({ type: 'source_chat' })
expect(plan.config.targetNeedsReview).toBeUndefined()
expect(plan.config.legacyTarget).toBeUndefined()
// 来源群被改写过 ⇒ 留下原文方便用户核对。
expect(plan.config.legacySourceGroup).toBe('tech-md5')
})
it('createdAt / updatedAt 继承旧值(ISO → 毫秒),非法值回落迁移时刻', () => {
const plan = planScheduledReportMigration(task(), resolve, NOW)
expect(plan.createdAt).toBe(Date.parse('2026-08-01T01:00:00.000Z'))
expect(plan.updatedAt).toBe(Date.parse('2026-08-20T01:00:00.000Z'))
const broken = planScheduledReportMigration(
task({ createdAt: '不是时间', updatedAt: undefined }),
resolve,
NOW
)
expect(broken.createdAt).toBe(NOW)
expect(broken.updatedAt).toBe(NOW)
})
it('继承调度游标(lastRunAt / lastScheduledSlot),避免迁移后重复补跑', () => {
const slot = new Date('2026-08-26T09:15:00+08:00').toISOString()
const plan = planScheduledReportMigration(
task({ lastRunAt: '2026-08-26T01:15:00.000Z', lastScheduledSlot: slot }),
resolve,
NOW
)
expect(plan.config.lastRunAt).toBe('2026-08-26T01:15:00.000Z')
expect(plan.config.lastScheduledSlot).toBe(slot)
})
it('脏值一律收敛到合法默认值,绝不落一条跑不起来的规则', () => {
const plan = planScheduledReportMigration(
task({
scheduleTime: '99:99',
reportRange: '一个不存在的范围',
messageTypes: ['不存在的类型'],
templateId: '不存在的模板',
memberNameMode: '不存在的口径',
timeoutSeconds: 999999
}),
resolve,
NOW
)
expect(plan.config.schedule.time).toBe('18:30')
expect(plan.config.report.range).toBe('today')
expect(plan.config.report.messageTypes).toEqual(['text'])
expect(plan.config.report.templateId).toBe('v1')
expect(plan.config.report.memberNameMode).toBe(SCHEDULED_REPORT_DEFAULT_MEMBER_NAME_MODE)
expect(plan.config.report.timeoutSeconds).toBe(1800)
})
it('名字缺失时回落到「定时日报」,不会留一条空名字的规则', () => {
expect(planScheduledReportMigration(task({ name: ' ' }), resolve, NOW).name).toBe('定时日报')
})
})
describe('定时日报迁移 · 发送目标(唯一会改变行为的地方)', () => {
it('target 为空 ⇒ 旧系统本就回落到来源群,等价于 source_chat', () => {
const plan = planScheduledReportMigration(task({ target: '' }), resolve, NOW)
expect(plan.outcome).toBe('lossless_source_chat')
expect(plan.config.target).toEqual({ type: 'source_chat' })
expect(plan.config.targetNeedsReview).toBeUndefined()
})
it('target 与 group 文本相同 ⇒ 无损映射(不依赖能否解析)', () => {
const plan = planScheduledReportMigration(
task({ group: 'tech-md5', target: 'tech-md5' }),
() => undefined,
NOW
)
expect(plan.outcome).toBe('lossless_source_chat')
expect(plan.config.targetNeedsReview).toBeUndefined()
})
it('两者都解析到同一个 roomId ⇒ 无损映射(旧 UI 的常态:group=md5, target=roomId)', () => {
const plan = planScheduledReportMigration(
task({ group: 'tech-md5', target: 'tech@chatroom' }),
resolve,
NOW
)
expect(plan.outcome).toBe('lossless_source_chat')
// 这条无损判定**依赖**解析器对 `@chatroom` 的 pass-through:
// 少了它,旧 UI 里每一组 (md5, roomId) 都会退化成 needs_review,
// 等于把所有用户的定时日报全部停用。
expect(resolve('tech@chatroom')).toBe('tech@chatroom')
})
it('target 指向**另一个群** ⇒ needs_review + 强制停用 + 保留旧目标原文', () => {
const plan = planScheduledReportMigration(
task({ group: 'tech-md5', target: 'product@chatroom' }),
resolve,
NOW
)
expect(plan.outcome).toBe('needs_review')
// 不能自动改写目标,也不能在用户确认前就被定时发出去。
expect(plan.enabled).toBe(false)
expect(plan.config.targetNeedsReview).toBe(true)
expect(plan.config.legacyTarget).toBe('product@chatroom')
})
it('target 无法解析 ⇒ 一律 needs_review(拿不到证据就不自动改写)', () => {
const plan = planScheduledReportMigration(
task({ group: 'tech-md5', target: 'unknown-id' }),
resolve,
NOW
)
expect(plan.outcome).toBe('needs_review')
expect(plan.enabled).toBe(false)
expect(plan.config.legacyTarget).toBe('unknown-id')
})
it('needs_review 的规则即使旧任务是启用的也必须停用', () => {
const plan = planScheduledReportMigration(
task({ target: 'product@chatroom', enabled: true, group: 'tech-md5' }),
resolve,
NOW
)
expect(plan.enabled).toBe(false)
})
it('来源群解析不到 ⇒ 保留原值,不在迁移期猜一个群', () => {
const plan = planScheduledReportMigration(task({ group: '未知群', target: '' }), resolve, NOW)
// 原值留着 —— 运行期会如实失败,比"悄悄换一个群发"安全得多。
expect(plan.config.report.sourceConversationId).toBe('未知群')
expect(plan.config.legacySourceGroup).toBeUndefined()
})
})
describe('定时日报迁移 · 批次幂等', () => {
const tasks: LegacyScheduledReportTask[] = [
task({ id: 'scheduled_report_a', name: 'A', group: 'tech-md5', target: 'tech@chatroom' }),
task({ id: 'scheduled_report_b', name: 'B', group: 'product-md5', target: 'product@chatroom' }),
task({ id: 'scheduled_report_c', name: 'C', group: 'tech-md5', target: 'product@chatroom' })
]
it('3 条旧任务 ⇒ 3 条规则,并给出判定分布', () => {
const { plans, summary } = planScheduledReportMigrationBatch(tasks, resolve, NOW)
expect(plans.map((plan) => plan.ruleId)).toEqual([
'scheduled_report_a',
'scheduled_report_b',
'scheduled_report_c'
])
expect(summary).toEqual({
total: 3,
migrated: 3,
lossless: 2,
needsReview: 1,
duplicatesSkipped: 0
})
})
it('重复执行结果完全一致(幂等,不是"看起来没多")', () => {
const first = planScheduledReportMigrationBatch(tasks, resolve, NOW)
const second = planScheduledReportMigrationBatch(tasks, resolve, NOW)
expect(second).toEqual(first)
})
it('同一 id 出现两次只迁一条,并如实计入 duplicatesSkipped', () => {
const { plans, summary } = planScheduledReportMigrationBatch(
[tasks[0], task({ id: 'scheduled_report_a', name: '重复' })],
resolve,
NOW
)
expect(plans).toHaveLength(1)
expect(plans[0].name).toBe('A')
expect(summary.total).toBe(2)
expect(summary.migrated).toBe(1)
expect(summary.duplicatesSkipped).toBe(1)
})
it('文件里混进非对象元素时跳过而不是崩掉', () => {
const { plans } = planScheduledReportMigrationBatch(
[tasks[0], null as unknown as LegacyScheduledReportTask, 'x' as unknown as LegacyScheduledReportTask],
resolve,
NOW
)
expect(plans).toHaveLength(1)
})
it('空文件 ⇒ 0 条规则(全新安装路径)', () => {
const { plans, summary } = planScheduledReportMigrationBatch([], resolve, NOW)
expect(plans).toHaveLength(0)
expect(summary.migrated).toBe(0)
})
})
File diff suppressed because it is too large Load Diff