mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-04 19:43:43 +08:00
feat: 定时日报并入自动化,统一规则管理与发送链路
发送目标与退群通知统一为 来源群 / 自己 / 文件传输助手 / 指定好友 日报页补「定时日报已并入自动化」指引条
This commit is contained in:
+65
-31
@@ -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) => {
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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)}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
|
||||
Vendored
+17
-29
@@ -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
@@ -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
@@ -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) &&
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user