mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-09 00:16:32 +08:00
feat: 定时日报并入自动化,统一规则管理与发送链路
发送目标与退群通知统一为 来源群 / 自己 / 文件传输助手 / 指定好友 日报页补「定时日报已并入自动化」指引条
This commit is contained in:
+65
-31
@@ -135,10 +135,6 @@ import type {
|
|||||||
PersonalWechatSendResult,
|
PersonalWechatSendResult,
|
||||||
PersonalWechatSenderStatus
|
PersonalWechatSenderStatus
|
||||||
} from '../shared/personal-wechat'
|
} from '../shared/personal-wechat'
|
||||||
import type {
|
|
||||||
ScheduledReportCreateInput,
|
|
||||||
ScheduledReportUpdateInput
|
|
||||||
} from '../shared/scheduled-report'
|
|
||||||
import { isTruthyDebugFlag } from '../shared/debug-flags'
|
import { isTruthyDebugFlag } from '../shared/debug-flags'
|
||||||
import { TextToSpeechSettingsService } from './services/text-to-speech-settings-service'
|
import { TextToSpeechSettingsService } from './services/text-to-speech-settings-service'
|
||||||
import type {
|
import type {
|
||||||
@@ -1047,6 +1043,30 @@ app.whenReady().then(async () => {
|
|||||||
*/
|
*/
|
||||||
let automationListening = false
|
let automationListening = false
|
||||||
initAutomationService(wcdb4Client, { isListening: () => automationListening })
|
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('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()
|
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))
|
scheduledReportService.setNotificationEnabled(Boolean(enabled))
|
||||||
)
|
)
|
||||||
ipcMain.handle('scheduled-report:create', (_, request: ScheduledReportCreateInput) =>
|
ipcMain.handle('automation:testScheduledReportErrorNotification', (_, ruleId: string) => {
|
||||||
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) => {
|
|
||||||
if (!isTruthyDebugFlag(import.meta.env.VITE_SCHEDULED_REPORT_DEBUG)) {
|
if (!isTruthyDebugFlag(import.meta.env.VITE_SCHEDULED_REPORT_DEBUG)) {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
success: false,
|
success: false,
|
||||||
error: '调试测试按钮未开启,请在 .env 中设置 VITE_SCHEDULED_REPORT_DEBUG=true。'
|
error: '调试测试按钮未开启,请在 .env 中设置 VITE_SCHEDULED_REPORT_DEBUG=true。'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return scheduledReportService.testScheduledReportErrorNotification(taskId)
|
return scheduledReportService.testScheduledReportErrorNotification(ruleId)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('report:reveal', async (_, filePath: string) => {
|
ipcMain.handle('report:reveal', async (_, filePath: string) => {
|
||||||
|
|||||||
@@ -5,24 +5,30 @@ import {
|
|||||||
DEFAULT_REPLY_TEXT,
|
DEFAULT_REPLY_TEXT,
|
||||||
automationIdempotencyKey,
|
automationIdempotencyKey,
|
||||||
normalizeReplyDelaySeconds,
|
normalizeReplyDelaySeconds,
|
||||||
|
scheduledReportRangeLabel,
|
||||||
type AutomationAction,
|
type AutomationAction,
|
||||||
|
type AutomationExecutionTrigger,
|
||||||
type AutomationRule,
|
type AutomationRule,
|
||||||
type AutomationStep,
|
type AutomationStep,
|
||||||
type AutomationStepKey,
|
type AutomationStepKey,
|
||||||
type LeaveNotificationConfig
|
type LeaveNotificationConfig,
|
||||||
|
type ScheduledReportAutomationConfig
|
||||||
} from '../../shared/automation'
|
} from '../../shared/automation'
|
||||||
import {
|
import {
|
||||||
renderLeaveNotificationText,
|
renderLeaveNotificationText,
|
||||||
type GroupMemberExitedEvent
|
type GroupMemberExitedEvent
|
||||||
} from '../../shared/group-exit-event'
|
} from '../../shared/group-exit-event'
|
||||||
import type { WechatActionContent, WechatActionRequest, WechatActionResult } from '../../shared/wechat-action'
|
import type { WechatActionContent, WechatActionRequest, WechatActionResult } from '../../shared/wechat-action'
|
||||||
|
import type { SaveGeneratedReportRequest, SaveGeneratedReportResult } from '../../shared/report-history'
|
||||||
import {
|
import {
|
||||||
generateAgentGroupReport,
|
generateAgentGroupReport,
|
||||||
type AgentGroupReportRequest,
|
type AgentGroupReportRequest,
|
||||||
type AgentGroupReportResult
|
type AgentGroupReportResult
|
||||||
} from './agent-group-report-service'
|
} from './agent-group-report-service'
|
||||||
import { wechatActionGateway } from './wechat-action-gateway'
|
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 —— 把一次命中跑成**步骤序列**。
|
* AutomationActionRunner —— 把一次命中跑成**步骤序列**。
|
||||||
@@ -46,6 +52,20 @@ const LEAVE_NOTIFICATION_STEP_ORDER: AutomationStepKey[] = [
|
|||||||
'exit_send'
|
'exit_send'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时日报的步骤顺序(与另外两族**不共用**)。
|
||||||
|
*
|
||||||
|
* 「生成中 / 已生成」与「确定目标 / 已发送」**刻意拆成四步**:
|
||||||
|
* 用户必须能一眼看出"日报确实生成了,只是没发出去" —— 合成两步就表达不了。
|
||||||
|
*/
|
||||||
|
const SCHEDULED_REPORT_STEP_ORDER: AutomationStepKey[] = [
|
||||||
|
'schedule_triggered',
|
||||||
|
'report_generating',
|
||||||
|
'report_generated',
|
||||||
|
'send_resolved',
|
||||||
|
'report_sent'
|
||||||
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 前置步骤失败时,后续步骤的 `skipReason`。
|
* 前置步骤失败时,后续步骤的 `skipReason`。
|
||||||
*
|
*
|
||||||
@@ -54,6 +74,15 @@ const LEAVE_NOTIFICATION_STEP_ORDER: AutomationStepKey[] = [
|
|||||||
const SKIPPED_AFTER_REPLY_FAILURE = '前置步骤失败(回复确认未成功),本次不再继续。'
|
const SKIPPED_AFTER_REPLY_FAILURE = '前置步骤失败(回复确认未成功),本次不再继续。'
|
||||||
const SKIPPED_AFTER_REPORT_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
|
executionId: string
|
||||||
event: GroupMemberExitedEvent
|
event: GroupMemberExitedEvent
|
||||||
config: LeaveNotificationConfig
|
config: LeaveNotificationConfig
|
||||||
resolution: LeaveNotificationTargetResolution
|
resolution: AutomationTargetResolution
|
||||||
/** 事件来源的显示名(群名 / 「群聊」)。**不含 wxid**。 */
|
/** 事件来源的显示名(群名 / 「群聊」)。**不含 wxid**。 */
|
||||||
sourceDisplayName: string
|
sourceDisplayName: string
|
||||||
/** 发送能力缺失时的一句话说明;有值时 `exit_send` 直接判失败。 */
|
/** 发送能力缺失时的一句话说明;有值时 `exit_send` 直接判失败。 */
|
||||||
@@ -103,12 +132,69 @@ export interface LeaveNotificationRunResult {
|
|||||||
errorSummary?: string
|
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 {
|
export interface AutomationActionRunnerDependencies {
|
||||||
generateReport?: (request: AgentGroupReportRequest) => Promise<AgentGroupReportResult>
|
generateReport?: (request: AgentGroupReportRequest) => Promise<AgentGroupReportResult>
|
||||||
executeAction?: (request: WechatActionRequest) => Promise<WechatActionResult>
|
executeAction?: (request: WechatActionRequest) => Promise<WechatActionResult>
|
||||||
now?: () => number
|
now?: () => number
|
||||||
/** 延迟实现。默认真 sleep;单测注入即时 resolve 的假实现,避免真的等 2 秒。 */
|
/** 延迟实现。默认真 sleep;单测注入即时 resolve 的假实现,避免真的等 2 秒。 */
|
||||||
delay?: (ms: number) => Promise<void>
|
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 直接展示这些文案,不做二次翻译。 */
|
/** 策略层的错误码 → 用户可读短句。UI 直接展示这些文案,不做二次翻译。 */
|
||||||
@@ -161,6 +247,13 @@ export class AutomationActionRunner {
|
|||||||
private readonly executeAction: (request: WechatActionRequest) => Promise<WechatActionResult>
|
private readonly executeAction: (request: WechatActionRequest) => Promise<WechatActionResult>
|
||||||
private readonly now: () => number
|
private readonly now: () => number
|
||||||
private readonly delay: (ms: number) => Promise<void>
|
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 = {}) {
|
constructor(dependencies: AutomationActionRunnerDependencies = {}) {
|
||||||
this.generateReport = dependencies.generateReport ?? generateAgentGroupReport
|
this.generateReport = dependencies.generateReport ?? generateAgentGroupReport
|
||||||
@@ -169,6 +262,9 @@ export class AutomationActionRunner {
|
|||||||
this.delay =
|
this.delay =
|
||||||
dependencies.delay ??
|
dependencies.delay ??
|
||||||
((ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)))
|
((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> {
|
async run(input: AutomationRunInput): Promise<AutomationRunResult> {
|
||||||
@@ -273,6 +369,224 @@ export class AutomationActionRunner {
|
|||||||
return { steps, status: 'success', pngPath }
|
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 path from 'node:path'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import fs from 'fs-extra'
|
import fs from 'fs-extra'
|
||||||
import type { AutomationExecution } from '../../shared/automation'
|
import type {
|
||||||
|
AutomationExecution,
|
||||||
|
AutomationExecutionTrigger
|
||||||
|
} from '../../shared/automation'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AutomationExecutionLogService —— **用户层**执行日志。
|
* AutomationExecutionLogService —— **用户层**执行日志。
|
||||||
@@ -24,6 +27,8 @@ export interface AutomationExecutionLogDependencies {
|
|||||||
|
|
||||||
const EXECUTION_STATUSES: AutomationExecution['status'][] = ['running', 'success', 'failed']
|
const EXECUTION_STATUSES: AutomationExecution['status'][] = ['running', 'success', 'failed']
|
||||||
|
|
||||||
|
const EXECUTION_TRIGGERS: AutomationExecutionTrigger[] = ['message', 'exit', 'schedule', 'manual']
|
||||||
|
|
||||||
function normalizeExecution(value: unknown): AutomationExecution | null {
|
function normalizeExecution(value: unknown): AutomationExecution | null {
|
||||||
if (!value || typeof value !== 'object') return null
|
if (!value || typeof value !== 'object') return null
|
||||||
const record = value as Partial<AutomationExecution>
|
const record = value as Partial<AutomationExecution>
|
||||||
@@ -34,6 +39,17 @@ function normalizeExecution(value: unknown): AutomationExecution | null {
|
|||||||
ruleId: String(record.ruleId || ''),
|
ruleId: String(record.ruleId || ''),
|
||||||
ruleName: String(record.ruleName || ''),
|
ruleName: String(record.ruleName || ''),
|
||||||
triggerTime: Number(record.triggerTime) || 0,
|
triggerTime: Number(record.triggerTime) || 0,
|
||||||
|
/*
|
||||||
|
* `trigger` 必须在这里**显式透传**。
|
||||||
|
*
|
||||||
|
* 这个归一化是白名单式的:没列出来的字段会被静默丢掉。定时日报的
|
||||||
|
* 「本次是定时跑的还是用户点的立即执行」全靠这个字段区分,
|
||||||
|
* 漏掉它就会变成"写的时候有、读出来永远没有"—— 而且没有任何报错。
|
||||||
|
* 旧记录没有这个字段,保持 `undefined`(读盘不猜测)。
|
||||||
|
*/
|
||||||
|
...(EXECUTION_TRIGGERS.includes(record.trigger as AutomationExecutionTrigger)
|
||||||
|
? { trigger: record.trigger as AutomationExecutionTrigger }
|
||||||
|
: {}),
|
||||||
sourceDisplayName: String(record.sourceDisplayName || ''),
|
sourceDisplayName: String(record.sourceDisplayName || ''),
|
||||||
// 不认识的 status(含历史遗留值)一律降级成 `running`,绝不凭空造出成功/失败。
|
// 不认识的 status(含历史遗留值)一律降级成 `running`,绝不凭空造出成功/失败。
|
||||||
status: EXECUTION_STATUSES.includes(record.status as AutomationExecution['status'])
|
status: EXECUTION_STATUSES.includes(record.status as AutomationExecution['status'])
|
||||||
|
|||||||
@@ -6,15 +6,22 @@ import {
|
|||||||
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
|
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
|
||||||
createDefaultDailyReportRule,
|
createDefaultDailyReportRule,
|
||||||
createDefaultLeaveNotificationRule,
|
createDefaultLeaveNotificationRule,
|
||||||
|
createDefaultScheduledReportRule,
|
||||||
normalizeRuleDraft,
|
normalizeRuleDraft,
|
||||||
type AutomationRule,
|
type AutomationRule,
|
||||||
type AutomationRuleType
|
type AutomationRuleType,
|
||||||
|
type ScheduledReportAutomationConfig
|
||||||
} from '../../shared/automation'
|
} from '../../shared/automation'
|
||||||
import {
|
import {
|
||||||
planLeaveNotificationMigration,
|
planLeaveNotificationMigration,
|
||||||
type LeaveNotificationMigrationPlan,
|
type LeaveNotificationMigrationPlan,
|
||||||
type LegacyLeaveNotificationState
|
type LegacyLeaveNotificationState
|
||||||
} from './leave-notification-migration'
|
} from './leave-notification-migration'
|
||||||
|
import {
|
||||||
|
planScheduledReportMigrationBatch,
|
||||||
|
type LegacyScheduledReportTask,
|
||||||
|
type ScheduledReportMigrationSummary
|
||||||
|
} from './scheduled-report-migration'
|
||||||
import {
|
import {
|
||||||
GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH,
|
GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH,
|
||||||
insertGroupNamePlaceholder
|
insertGroupNamePlaceholder
|
||||||
@@ -26,12 +33,10 @@ import {
|
|||||||
* 存储形态刻意做到最简:一个 JSON 文件(`{userData}/automation/rules.json`)。
|
* 存储形态刻意做到最简:一个 JSON 文件(`{userData}/automation/rules.json`)。
|
||||||
* 规则总量是个位数到几十条,引入数据库只会增加迁移负担。
|
* 规则总量是个位数到几十条,引入数据库只会增加迁移负担。
|
||||||
*
|
*
|
||||||
* **内置规则的播种标记(`builtinSeeded`)单独存**:
|
* **一次性标记全部单独存**(`builtinSeeded` / `leaveNotificationMigrated` /
|
||||||
|
* `notificationTemplateUpgraded` / `scheduledReportMigrated`):
|
||||||
* 如果靠"文件里有没有那条内置规则"来判断是否播种,用户一旦删掉它,
|
* 如果靠"文件里有没有那条内置规则"来判断是否播种,用户一旦删掉它,
|
||||||
* 下次启动就会被重新塞回来 —— 用户会认为删除功能坏了。
|
* 下次启动就会被重新塞回来 —— 用户会认为删除功能坏了。迁移同理。
|
||||||
*
|
|
||||||
* 退群通知的迁移标记(`leaveNotificationMigrated`)同理:迁移**只允许跑一次**,
|
|
||||||
* 否则用户删掉退群通知规则后重启又被塞回来。
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const STORAGE_DIR = 'automation'
|
const STORAGE_DIR = 'automation'
|
||||||
@@ -39,7 +44,11 @@ const RULES_FILE = 'rules.json'
|
|||||||
const MIGRATION_BACKUP_FILE = 'leave-notification-migration-backup.json'
|
const MIGRATION_BACKUP_FILE = 'leave-notification-migration-backup.json'
|
||||||
/** 旧退群监控的状态文件(迁移时读一次,之后运行期不再读)。 */
|
/** 旧退群监控的状态文件(迁移时读一次,之后运行期不再读)。 */
|
||||||
const LEGACY_MONITOR_FILE = 'group-exit-monitor.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 {
|
interface StoredRules {
|
||||||
version: number
|
version: number
|
||||||
@@ -54,12 +63,27 @@ interface StoredRules {
|
|||||||
* 于是用户主动删掉那一行后、下次启动又会被补回来 —— 删不掉的东西最烦人。
|
* 于是用户主动删掉那一行后、下次启动又会被补回来 —— 删不掉的东西最烦人。
|
||||||
*/
|
*/
|
||||||
notificationTemplateUpgraded: boolean
|
notificationTemplateUpgraded: boolean
|
||||||
|
/**
|
||||||
|
* 旧「定时日报」任务是否已经迁移过。
|
||||||
|
*
|
||||||
|
* ⚠️ 与其它标记有一点不同:迁移需要**把旧群标识解析成稳定会话 id**,
|
||||||
|
* 而那需要数据库。数据库没就绪时这一位**保持 false**,等解析器注入后再补跑 ——
|
||||||
|
* 否则会把每一条规则都误判成"目标无法确认"。
|
||||||
|
*/
|
||||||
|
scheduledReportMigrated: boolean
|
||||||
rules: AutomationRule[]
|
rules: AutomationRule[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AutomationRuleStoreDependencies {
|
export interface AutomationRuleStoreDependencies {
|
||||||
userDataPath?: () => string
|
userDataPath?: () => string
|
||||||
now?: () => number
|
now?: () => number
|
||||||
|
/**
|
||||||
|
* 迁移旧定时日报时,把 legacy 群标识(会话 md5 / 群名 / roomId)解析成
|
||||||
|
* **稳定会话 id**(`xxx@chatroom`)。解析不到返回 `undefined`。
|
||||||
|
*
|
||||||
|
* 不注入 ⇒ 迁移**推迟**(不写任何规则、不置标记)。
|
||||||
|
*/
|
||||||
|
resolveLegacyConversationId?: (raw: string) => string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyState(): StoredRules {
|
function emptyState(): StoredRules {
|
||||||
@@ -68,19 +92,24 @@ function emptyState(): StoredRules {
|
|||||||
builtinSeeded: false,
|
builtinSeeded: false,
|
||||||
leaveNotificationMigrated: false,
|
leaveNotificationMigrated: false,
|
||||||
notificationTemplateUpgraded: false,
|
notificationTemplateUpgraded: false,
|
||||||
|
scheduledReportMigrated: false,
|
||||||
rules: []
|
rules: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRuleType(value: unknown): AutomationRuleType {
|
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 / 时间戳之外的**全部**字段,
|
* 走 `normalizeRuleDraft` —— 它覆盖了 `AutomationRule` 除 id / 时间戳之外的**全部**字段,
|
||||||
* 所以这是无损的,同时自动补上历史 rules.json 缺失的 `ruleType` / `leaveNotification`。
|
* 所以这是无损的,同时自动补上历史 rules.json 缺失的 `ruleType` / 各类 config。
|
||||||
*/
|
*/
|
||||||
function normalizeStoredRule(value: unknown): AutomationRule | null {
|
function normalizeStoredRule(value: unknown): AutomationRule | null {
|
||||||
if (!value || typeof value !== 'object') return null
|
if (!value || typeof value !== 'object') return null
|
||||||
@@ -92,8 +121,9 @@ function normalizeStoredRule(value: unknown): AutomationRule | null {
|
|||||||
{
|
{
|
||||||
...raw,
|
...raw,
|
||||||
ruleType,
|
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() || '未命名自动化'
|
String(raw.name || '').trim() || '未命名自动化'
|
||||||
)
|
)
|
||||||
@@ -106,6 +136,9 @@ function normalizeStoredRule(value: unknown): AutomationRule | null {
|
|||||||
updatedAt,
|
updatedAt,
|
||||||
...(normalized.ruleType === 'leave_notification' && normalized.leaveNotification
|
...(normalized.ruleType === 'leave_notification' && normalized.leaveNotification
|
||||||
? { leaveNotification: 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}` 是成员在本群的昵称)。
|
* **为什么替他填**:模板里原先根本没有群名变量(`{groupRemark}` 是成员在本群的昵称)。
|
||||||
* 目标一旦不是「当前群聊」,通知就等于「张三退群了」—— 收件人不知道是哪个群。
|
* 目标一旦不是「当前群聊」,通知就等于「张三退群了」—— 收件人不知道是哪个群。
|
||||||
* 模板是用户已保存的数据,让他自己去发现"少一个变量"是不合理的。
|
|
||||||
*
|
*
|
||||||
* **为什么只跑一次**:靠"模板里有没有 `{groupName}`"判断会导致用户删掉后被反复补回来。
|
* **为什么只跑一次**:靠"模板里有没有 `{groupName}`"判断会导致用户删掉后被反复补回来。
|
||||||
* 所以由 `notificationTemplateUpgraded` 标记保证一次性;用户之后删掉不会回来。
|
* 所以由 `notificationTemplateUpgraded` 标记保证一次性;用户之后删掉不会回来。
|
||||||
@@ -139,7 +171,24 @@ function upgradeLeaveNotificationTemplate(rules: AutomationRule[]): {
|
|||||||
return { rules: next, count }
|
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()
|
if (!value || typeof value !== 'object') return emptyState()
|
||||||
const input = value as Partial<StoredRules>
|
const input = value as Partial<StoredRules>
|
||||||
const rules = Array.isArray(input.rules)
|
const rules = Array.isArray(input.rules)
|
||||||
@@ -152,6 +201,7 @@ function upgradeLeaveNotificationTemplate(rules: AutomationRule[]): {
|
|||||||
builtinSeeded: input.builtinSeeded === true,
|
builtinSeeded: input.builtinSeeded === true,
|
||||||
leaveNotificationMigrated: input.leaveNotificationMigrated === true,
|
leaveNotificationMigrated: input.leaveNotificationMigrated === true,
|
||||||
notificationTemplateUpgraded: input.notificationTemplateUpgraded === true,
|
notificationTemplateUpgraded: input.notificationTemplateUpgraded === true,
|
||||||
|
scheduledReportMigrated: input.scheduledReportMigrated === true,
|
||||||
rules
|
rules
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,10 +213,31 @@ export class AutomationRuleStore {
|
|||||||
private loaded = false
|
private loaded = false
|
||||||
/** 迁移结果,供启动日志/报告读取(不含任何 id)。 */
|
/** 迁移结果,供启动日志/报告读取(不含任何 id)。 */
|
||||||
private lastMigrationPlan: LeaveNotificationMigrationPlan | null = null
|
private lastMigrationPlan: LeaveNotificationMigrationPlan | null = null
|
||||||
|
private lastScheduledMigrationSummary: ScheduledReportMigrationSummary | null = null
|
||||||
|
private legacyConversationResolver: ((raw: string) => string | undefined) | null
|
||||||
|
|
||||||
constructor(dependencies: AutomationRuleStoreDependencies = {}) {
|
constructor(dependencies: AutomationRuleStoreDependencies = {}) {
|
||||||
this.userDataPath = dependencies.userDataPath ?? (() => app.getPath('userData'))
|
this.userDataPath = dependencies.userDataPath ?? (() => app.getPath('userData'))
|
||||||
this.now = dependencies.now ?? (() => Date.now())
|
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[] {
|
listRules(): AutomationRule[] {
|
||||||
@@ -187,6 +258,12 @@ export class AutomationRuleStore {
|
|||||||
return this.lastMigrationPlan ? { ...this.lastMigrationPlan } : null
|
return this.lastMigrationPlan ? { ...this.lastMigrationPlan } : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 上一次旧定时日报迁移的统计(未迁移时为 null)。 */
|
||||||
|
getLastScheduledReportMigration(): ScheduledReportMigrationSummary | null {
|
||||||
|
this.ensureLoaded()
|
||||||
|
return this.lastScheduledMigrationSummary ? { ...this.lastScheduledMigrationSummary } : null
|
||||||
|
}
|
||||||
|
|
||||||
createRule(draft: unknown): AutomationRule {
|
createRule(draft: unknown): AutomationRule {
|
||||||
this.ensureLoaded()
|
this.ensureLoaded()
|
||||||
const timestamp = this.now()
|
const timestamp = this.now()
|
||||||
@@ -219,11 +296,26 @@ export class AutomationRuleStore {
|
|||||||
...(current.ruleType === 'leave_notification' && normalized.leaveNotification
|
...(current.ruleType === 'leave_notification' && normalized.leaveNotification
|
||||||
? { leaveNotification: normalized.leaveNotification }
|
? { leaveNotification: normalized.leaveNotification }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(current.ruleType === 'scheduled_report' && normalized.scheduledReport
|
||||||
|
? {
|
||||||
|
scheduledReport: {
|
||||||
|
...normalized.scheduledReport,
|
||||||
|
...pickScheduledReportRuntime(current.scheduledReport)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
createdAt: current.createdAt,
|
createdAt: current.createdAt,
|
||||||
updatedAt: this.now()
|
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.state.rules = this.state.rules.map((item, at) => (at === index ? rule : item))
|
||||||
this.persist()
|
this.persist()
|
||||||
return structuredClone(rule)
|
return structuredClone(rule)
|
||||||
@@ -275,6 +367,38 @@ export class AutomationRuleStore {
|
|||||||
return this.updateRule(id, { ...rule, enabled: enabled === true })
|
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 {
|
private ensureLoaded(): void {
|
||||||
if (this.loaded) return
|
if (this.loaded) return
|
||||||
this.loaded = true
|
this.loaded = true
|
||||||
@@ -301,6 +425,7 @@ export class AutomationRuleStore {
|
|||||||
console.log(`[Automation] 退群通知模板已补上群名变量(${upgraded.count} 条)`)
|
console.log(`[Automation] 退群通知模板已补上群名变量(${upgraded.count} 条)`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.runScheduledReportMigration(stored)
|
||||||
stored.version = CURRENT_VERSION
|
stored.version = CURRENT_VERSION
|
||||||
this.state = stored
|
this.state = stored
|
||||||
this.persist()
|
this.persist()
|
||||||
@@ -326,11 +451,110 @@ export class AutomationRuleStore {
|
|||||||
template: plan.template,
|
template: plan.template,
|
||||||
target: plan.target,
|
target: plan.target,
|
||||||
enabled: plan.enabled,
|
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。
|
* 迁移前把旧配置原样落一份 backup。
|
||||||
*
|
*
|
||||||
* §「不要不可逆直接覆盖」:旧状态文件本身也**不删**,只是不再被运行时代码读取。
|
* 不做不可逆覆盖:旧状态文件本身也**不删**,只是不再被运行时代码读取。
|
||||||
*/
|
*/
|
||||||
private writeMigrationBackup(
|
private writeMigrationBackup(
|
||||||
legacy: LegacyLeaveNotificationState,
|
legacy: LegacyLeaveNotificationState,
|
||||||
@@ -381,15 +605,14 @@ export class AutomationRuleStore {
|
|||||||
applied: {
|
applied: {
|
||||||
enabled: plan.enabled,
|
enabled: plan.enabled,
|
||||||
targetType: plan.target.type,
|
targetType: plan.target.type,
|
||||||
targetNeedsReview: plan.targetNeedsReview
|
notifyScope: plan.notifyScope,
|
||||||
|
notifyRoomIds: plan.notifyRoomIds
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ spaces: 2 }
|
{ spaces: 2 }
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(`[Automation] 写入退群通知迁移备份失败: ${errorText(error)}`)
|
||||||
`[Automation] 写入退群通知迁移备份失败: ${errorText(error)}`
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ import {
|
|||||||
import {
|
import {
|
||||||
AUTOMATION_SEND_PURPOSE,
|
AUTOMATION_SEND_PURPOSE,
|
||||||
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
|
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
|
||||||
|
leaveNotificationCoversRoom,
|
||||||
type AutomationExecution,
|
type AutomationExecution,
|
||||||
|
type AutomationExecutionTrigger,
|
||||||
type AutomationRule,
|
type AutomationRule,
|
||||||
type AutomationStatusSummary
|
type AutomationStatusSummary,
|
||||||
|
type ScheduledRuleRunOutcome
|
||||||
} from '../../shared/automation'
|
} from '../../shared/automation'
|
||||||
import type { GroupMemberExitedEvent } from '../../shared/group-exit-event'
|
import type { GroupMemberExitedEvent } from '../../shared/group-exit-event'
|
||||||
import type { PersonalWechatSendCapability } from '../../shared/personal-wechat'
|
import type { PersonalWechatSendCapability } from '../../shared/personal-wechat'
|
||||||
@@ -20,6 +23,11 @@ import {
|
|||||||
resolveLeaveNotificationTarget,
|
resolveLeaveNotificationTarget,
|
||||||
type LeaveNotificationTargetResolution
|
type LeaveNotificationTargetResolution
|
||||||
} from './leave-notification-target'
|
} from './leave-notification-target'
|
||||||
|
import {
|
||||||
|
resolveScheduledReportTarget,
|
||||||
|
scheduledReportSourceDisplayName
|
||||||
|
} from './scheduled-report-target'
|
||||||
|
import type { AutomationTargetContact } from './automation-wechat-target'
|
||||||
import {
|
import {
|
||||||
automationRuleStore,
|
automationRuleStore,
|
||||||
type AutomationRuleStore
|
type AutomationRuleStore
|
||||||
@@ -32,7 +40,6 @@ import {
|
|||||||
automationActionRunner,
|
automationActionRunner,
|
||||||
type AutomationActionRunner
|
type AutomationActionRunner
|
||||||
} from './automation-action-runner'
|
} from './automation-action-runner'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AutomationService —— Automation v1 的编排层。
|
* AutomationService —— Automation v1 的编排层。
|
||||||
*
|
*
|
||||||
@@ -118,6 +125,26 @@ export function leaveNotificationIdempotencyKey(eventId: string): string {
|
|||||||
return `${AUTOMATION_SEND_PURPOSE.leaveNotification}:${eventId}`
|
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 {
|
export interface AutomationServiceDependencies {
|
||||||
ruleStore?: AutomationRuleStore
|
ruleStore?: AutomationRuleStore
|
||||||
executionLog?: AutomationExecutionLogService
|
executionLog?: AutomationExecutionLogService
|
||||||
@@ -159,6 +186,16 @@ export class AutomationService {
|
|||||||
*/
|
*/
|
||||||
private readonly gates = new Map<string, ConversationRuleGate>()
|
private readonly gates = new Map<string, ConversationRuleGate>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时日报的 `ruleId inFlight` 锁。
|
||||||
|
*
|
||||||
|
* **刻意不用 `gates`(conversation gate)**:定时日报的触发与"某个会话刚被触发过"
|
||||||
|
* 没有关系,套 60 秒 cooldown 会莫名其妙吞掉当天的补跑。
|
||||||
|
* 这里只要一条最朴素的约束:**同一条规则同时最多一个 execution**;
|
||||||
|
* 不同规则之间互不影响(可以并行)。
|
||||||
|
*/
|
||||||
|
private readonly scheduledInFlight = new Set<string>()
|
||||||
|
|
||||||
/** 仅用于诊断统计(`[Automation] blockedMessages=N`),**不进用户执行日志**。 */
|
/** 仅用于诊断统计(`[Automation] blockedMessages=N`),**不进用户执行日志**。 */
|
||||||
private blockedMessageCount = 0
|
private blockedMessageCount = 0
|
||||||
|
|
||||||
@@ -300,10 +337,16 @@ export class AutomationService {
|
|||||||
* - 消息型规则:MessageListener → handleMessage → 动作链;
|
* - 消息型规则:MessageListener → handleMessage → 动作链;
|
||||||
* - 退群通知:GroupExitMonitor → **本入口** → 单次文本发送。
|
* - 退群通知:GroupExitMonitor → **本入口** → 单次文本发送。
|
||||||
*
|
*
|
||||||
* 三层闸门(缺一不可):
|
* 四层闸门(缺一不可):
|
||||||
* 1. `enabled === false` → 不执行,**且不创建 execution**(§「没有执行:不创建」);
|
* 1. `enabled === false` → 不执行,**且不创建 execution**;
|
||||||
* 2. `targetNeedsReview` → 迁移过来但目标无法无损映射,同样不执行、不创建记录;
|
* 2. `targetNeedsReview` → 存量迁移遗留的"目标待重选",同样不执行、不创建记录;
|
||||||
* 3. `ruleId + eventId` 幂等 → 同一事件即使重复投递也只发送一次。
|
* 3. **通知范围**(`notifyScope` / `notifyRoomIds`)→ 事件所在群不在二次勾选范围内则跳过。
|
||||||
|
* 这是旧版「退群监控 → 通知群聊」的第二层,缺了它规则一启用就全量通知;
|
||||||
|
* 同样**不创建 execution** —— 被范围排除不是"一次失败的执行"。
|
||||||
|
* 4. `ruleId + eventId` 幂等 → 同一事件即使重复投递也只发送一次。
|
||||||
|
*
|
||||||
|
* 闸门顺序有意如此:范围判定放在幂等**之前**,否则一个被排除的群会白白占用
|
||||||
|
* 该事件的幂等位(用户随后真的补勾了那个群,反而发不出去)。
|
||||||
*/
|
*/
|
||||||
async handleGroupExit(event: GroupMemberExitedEvent): Promise<void> {
|
async handleGroupExit(event: GroupMemberExitedEvent): Promise<void> {
|
||||||
const eventId = String(event?.eventId || '').trim()
|
const eventId = String(event?.eventId || '').trim()
|
||||||
@@ -326,7 +369,12 @@ export class AutomationService {
|
|||||||
this.warn(`退群通知目标待重新选择,已跳过本次通知 ruleId=${rule.id}`)
|
this.warn(`退群通知目标待重新选择,已跳过本次通知 ruleId=${rule.id}`)
|
||||||
return
|
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
|
if (!this.claimGroupExit(rule.id, eventId)) return
|
||||||
|
|
||||||
const executionId = leaveNotificationExecutionId(rule.id, eventId)
|
const executionId = leaveNotificationExecutionId(rule.id, eventId)
|
||||||
@@ -382,8 +430,26 @@ export class AutomationService {
|
|||||||
rule: AutomationRule,
|
rule: AutomationRule,
|
||||||
event: GroupMemberExitedEvent
|
event: GroupMemberExitedEvent
|
||||||
): LeaveNotificationTargetResolution {
|
): 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 {
|
try {
|
||||||
|
// `FormattedContact` 结构上就满足 `AutomationTargetContact`,不需要断言。
|
||||||
contacts = listContacts()
|
contacts = listContacts()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.warn(`读取联系人失败: ${errorText(error)}`)
|
this.warn(`读取联系人失败: ${errorText(error)}`)
|
||||||
@@ -394,12 +460,172 @@ export class AutomationService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.warn(`读取当前账号失败: ${errorText(error)}`)
|
this.warn(`读取当前账号失败: ${errorText(error)}`)
|
||||||
}
|
}
|
||||||
return resolveLeaveNotificationTarget({
|
return { contacts, selfWxid }
|
||||||
config: rule.leaveNotification,
|
}
|
||||||
event,
|
|
||||||
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
|
rule: AutomationRule
|
||||||
startedAt: number
|
startedAt: number
|
||||||
sourceDisplayName: string
|
sourceDisplayName: string
|
||||||
|
/** 触发方式。旧调用点不传时为 `undefined` —— 读盘不猜测。 */
|
||||||
|
trigger?: AutomationExecutionTrigger
|
||||||
status: AutomationExecution['status']
|
status: AutomationExecution['status']
|
||||||
durationMs: number
|
durationMs: number
|
||||||
steps: AutomationExecution['steps']
|
steps: AutomationExecution['steps']
|
||||||
@@ -575,6 +803,7 @@ export class AutomationService {
|
|||||||
ruleId: input.rule.id,
|
ruleId: input.rule.id,
|
||||||
ruleName: input.rule.name,
|
ruleName: input.rule.name,
|
||||||
triggerTime: input.startedAt,
|
triggerTime: input.startedAt,
|
||||||
|
...(input.trigger ? { trigger: input.trigger } : {}),
|
||||||
sourceDisplayName: input.sourceDisplayName,
|
sourceDisplayName: input.sourceDisplayName,
|
||||||
status: input.status,
|
status: input.status,
|
||||||
durationMs: input.durationMs,
|
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 {
|
import {
|
||||||
LEAVE_NOTIFICATION_DEFAULT_TARGET_TYPE,
|
normalizeLeaveNotificationRoomIds,
|
||||||
normalizeLeaveNotificationTemplate,
|
normalizeLeaveNotificationTemplate,
|
||||||
|
type LeaveNotificationNotifyScope,
|
||||||
type LeaveNotificationTarget
|
type LeaveNotificationTarget
|
||||||
} from '../../shared/automation'
|
} from '../../shared/automation'
|
||||||
|
|
||||||
@@ -12,6 +13,11 @@ import {
|
|||||||
*
|
*
|
||||||
* ## 旧语义(实测自 `group-exit-monitor-service.ts`)
|
* ## 旧语义(实测自 `group-exit-monitor-service.ts`)
|
||||||
*
|
*
|
||||||
|
* 旧版是**两层**配置,缺一不可:
|
||||||
|
* 1. **管理群聊**(`monitoredRoomIds`)= 监测哪些群有人退出;
|
||||||
|
* 2. **通知群聊**(`notificationRoomIds`)= 上面这批群里,**哪些要真的发通知**
|
||||||
|
* —— 即对第 1 层的二次勾选。
|
||||||
|
*
|
||||||
* `notificationRoomIds` **不是**"统一通知接收目标列表",而是
|
* `notificationRoomIds` **不是**"统一通知接收目标列表",而是
|
||||||
* **"哪些被监控群要在自己群里通报成员退出"**(per-group 多值):
|
* **"哪些被监控群要在自己群里通报成员退出"**(per-group 多值):
|
||||||
* - `applyCurrentMemberships()` 只在 `notificationRoomIds.has(roomId)` 时通知;
|
* - `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 {
|
export interface LegacyLeaveNotificationState {
|
||||||
@@ -34,30 +43,23 @@ export interface LegacyLeaveNotificationState {
|
|||||||
export type LeaveNotificationMigrationOutcome =
|
export type LeaveNotificationMigrationOutcome =
|
||||||
/** 旧配置从不通知任何人 ⇒ 迁移成"关闭",行为完全一致。 */
|
/** 旧配置从不通知任何人 ⇒ 迁移成"关闭",行为完全一致。 */
|
||||||
| 'lossless_off'
|
| 'lossless_off'
|
||||||
/** 旧配置在**每个**被监控群里通报 ⇒ 等价于 `source_chat`,行为完全一致。 */
|
/** 旧配置在**每个**被监控群里通报 ⇒ `notifyScope: 'all'`,行为完全一致。 */
|
||||||
| 'lossless_source_chat'
|
| 'lossless_all_groups'
|
||||||
/** 旧配置只在**部分**群里通报 ⇒ 单值目标无法表达,需用户重选。 */
|
/** 旧配置只在**部分**群里通报 ⇒ `notifyScope: 'selected'` + 原样保留那份勾选。 */
|
||||||
| 'needs_review'
|
| 'lossless_selected_groups'
|
||||||
|
|
||||||
export interface LeaveNotificationMigrationPlan {
|
export interface LeaveNotificationMigrationPlan {
|
||||||
outcome: LeaveNotificationMigrationOutcome
|
outcome: LeaveNotificationMigrationOutcome
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
target: LeaveNotificationTarget
|
target: LeaveNotificationTarget
|
||||||
|
notifyScope: LeaveNotificationNotifyScope
|
||||||
|
notifyRoomIds: string[]
|
||||||
targetNeedsReview: boolean
|
targetNeedsReview: boolean
|
||||||
template: string
|
template: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function uniqueRoomIds(values: unknown): string[] {
|
function uniqueRoomIds(values: unknown): string[] {
|
||||||
if (!Array.isArray(values)) return []
|
return normalizeLeaveNotificationRoomIds(values)
|
||||||
const result: string[] = []
|
|
||||||
const seen = new Set<string>()
|
|
||||||
for (const value of values) {
|
|
||||||
const roomId = String(value || '').trim()
|
|
||||||
if (!roomId || seen.has(roomId)) continue
|
|
||||||
seen.add(roomId)
|
|
||||||
result.push(roomId)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function planLeaveNotificationMigration(
|
export function planLeaveNotificationMigration(
|
||||||
@@ -71,36 +73,44 @@ export function planLeaveNotificationMigration(
|
|||||||
)
|
)
|
||||||
// 模板永远是无损迁移的那一项:它就是用户写的那段文字。
|
// 模板永远是无损迁移的那一项:它就是用户写的那段文字。
|
||||||
const template = normalizeLeaveNotificationTemplate(legacy.notificationTemplate)
|
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) {
|
if (!notifications.length) {
|
||||||
// 旧语义是"从不通知" ⇒ 关掉,避免迁移后突然开始发消息。
|
// 旧语义是"从不通知" ⇒ 关掉,避免迁移后突然开始发消息。
|
||||||
|
// 同时把范围写成 `selected` + 空集:配置本身也精确表达"一个都不通知"。
|
||||||
return {
|
return {
|
||||||
outcome: 'lossless_off',
|
outcome: 'lossless_off',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
target: defaultTarget,
|
target,
|
||||||
|
notifyScope: 'selected',
|
||||||
|
notifyRoomIds: [],
|
||||||
targetNeedsReview: false,
|
targetNeedsReview: false,
|
||||||
template
|
template
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (monitored.length > 0 && notifications.length === monitored.length) {
|
if (monitored.length > 0 && notifications.length === monitored.length) {
|
||||||
// 每个被监控群都在自己群里通报 ⇔ 单值的 `source_chat`。
|
// 每个被监控群都在自己群里通报 ⇒ 等价于"全部已监控群聊"。
|
||||||
return {
|
return {
|
||||||
outcome: 'lossless_source_chat',
|
outcome: 'lossless_all_groups',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
target: { type: 'source_chat' },
|
target,
|
||||||
|
notifyScope: 'all',
|
||||||
|
notifyRoomIds: [],
|
||||||
targetNeedsReview: false,
|
targetNeedsReview: false,
|
||||||
template
|
template
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 部分勾选:**不能**无损映射。不取第一个群、不改写成当前群聊、保持关闭、要求重选。
|
// 部分勾选:**无损**保留那份子集,不再降级成 needs_review。
|
||||||
return {
|
return {
|
||||||
outcome: 'needs_review',
|
outcome: 'lossless_selected_groups',
|
||||||
enabled: false,
|
enabled: true,
|
||||||
target: defaultTarget,
|
target,
|
||||||
targetNeedsReview: true,
|
notifyScope: 'selected',
|
||||||
|
notifyRoomIds: notifications,
|
||||||
|
targetNeedsReview: false,
|
||||||
template
|
template
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,81 +4,65 @@ import {
|
|||||||
type LeaveNotificationConfig
|
type LeaveNotificationConfig
|
||||||
} from '../../shared/automation'
|
} from '../../shared/automation'
|
||||||
import type { GroupMemberExitedEvent } from '../../shared/group-exit-event'
|
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. 提供退群通知专属文案,保持既有调用方 / 测试不变。
|
||||||
*
|
*
|
||||||
* 四条规则:
|
* ⚠️ 这里**不允许**再出现一份 switch / 判定逻辑 —— 两套并存迟早分叉。
|
||||||
* 1. `source_chat` 取**事件所在群**(`event.conversationId`),
|
|
||||||
* 不是"当前正在看的聊天",也不是"最后一个活跃会话"。
|
|
||||||
* 2. 目标解析失败 → **报错**,绝不 fallback 到别的目标(尤其不许偷偷发文件助手)。
|
|
||||||
* 3. 只允许出现在选项表里的目标类型。
|
|
||||||
* 4. 显示名不带 wxid:日志与界面只出现用户能看懂的称呼。
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** 解析所需的最小联系人结构(`FormattedContact` 结构上可赋值给它)。 */
|
/** 解析所需的最小联系人结构(`FormattedContact` 结构上可赋值给它)。 */
|
||||||
export interface LeaveNotificationCandidateContact {
|
export type LeaveNotificationCandidateContact = AutomationTargetContact
|
||||||
m_nsUsrName: string
|
|
||||||
m_nsNickName?: string
|
|
||||||
type: 'user' | 'group'
|
|
||||||
isOfficialAccount?: boolean
|
|
||||||
wechatNickname?: string
|
|
||||||
remark?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ResolvedLeaveNotificationTarget {
|
export type ResolvedLeaveNotificationTarget = ResolvedAutomationTarget
|
||||||
recipient: { type: 'group' | 'contact'; id: string; name: string }
|
|
||||||
/** 用户可读的目标名,直接进执行日志的 `detail`。 */
|
|
||||||
displayName: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LeaveNotificationTargetResolution =
|
export type LeaveNotificationTargetResolution = AutomationTargetResolution
|
||||||
| { ok: true; target: ResolvedLeaveNotificationTarget }
|
|
||||||
| { ok: false; error: string }
|
/** 退群通知的目标文案(唯一一份)。 */
|
||||||
|
export const LEAVE_NOTIFICATION_TARGET_MESSAGES = {
|
||||||
|
options: LEAVE_NOTIFICATION_TARGET_OPTIONS,
|
||||||
|
invalidTarget: '退群通知的发送目标无效,请重新选择',
|
||||||
|
sourceMissing: '无法确定发生退群的群聊,本次通知未发送',
|
||||||
|
sourceRecipientName: '当前群聊',
|
||||||
|
sourceDisplayName: '发生退群事件的群聊',
|
||||||
|
selfMissing: '无法确定当前登录的微信账号,本次通知未发送',
|
||||||
|
contactNotChosen: '还没有选择通知联系人,请重新选择',
|
||||||
|
contactUnavailable: '通知联系人已不存在或当前无法发送,请重新选择'
|
||||||
|
} as const
|
||||||
|
|
||||||
/** 联系人显示名:备注 → 微信昵称 → 会话昵称 → 兜底称呼。**永不回落到 wxid**。 */
|
/** 联系人显示名:备注 → 微信昵称 → 会话昵称 → 兜底称呼。**永不回落到 wxid**。 */
|
||||||
export function leaveNotificationContactDisplayName(
|
export function leaveNotificationContactDisplayName(contact: AutomationTargetContact): string {
|
||||||
contact: LeaveNotificationCandidateContact
|
return automationContactDisplayName(contact)
|
||||||
): string {
|
|
||||||
return (
|
|
||||||
contact.remark?.trim() ||
|
|
||||||
contact.wechatNickname?.trim() ||
|
|
||||||
contact.m_nsNickName?.trim() ||
|
|
||||||
'指定好友'
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 这个联系人能不能作为「指定好友」的发送目标(与定时日报共用同一条判定)。 */
|
||||||
* 这个联系人能不能作为「指定好友」的发送目标。
|
|
||||||
*
|
|
||||||
* 排除:群聊、公众号(`gh_`)、文件传输助手、自己。
|
|
||||||
* UI 的联系人选择器与运行时的目标解析**共用这一条判定**,
|
|
||||||
* 否则会出现"能选但发不出去"。
|
|
||||||
*/
|
|
||||||
export function isSendableFriendContact(
|
export function isSendableFriendContact(
|
||||||
contact: LeaveNotificationCandidateContact,
|
contact: AutomationTargetContact,
|
||||||
selfWxid?: string
|
selfWxid?: string
|
||||||
): boolean {
|
): boolean {
|
||||||
if (contact.type !== 'user') return false
|
return isSendable(contact, selfWxid)
|
||||||
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 用的「可选好友」过滤(与运行时同一套判定)。 */
|
/** 供 UI 用的「可选好友」过滤(与运行时同一套判定)。 */
|
||||||
export function filterSendableFriendContacts<T extends LeaveNotificationCandidateContact>(
|
export function filterSendableFriendContacts<T extends AutomationTargetContact>(
|
||||||
contacts: T[],
|
contacts: T[],
|
||||||
selfWxid?: string
|
selfWxid?: string
|
||||||
): T[] {
|
): T[] {
|
||||||
return contacts.filter((contact) => isSendableFriendContact(contact, selfWxid))
|
return filterSendable(contacts, selfWxid)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveLeaveNotificationTarget(input: {
|
export function resolveLeaveNotificationTarget(input: {
|
||||||
@@ -89,84 +73,19 @@ export function resolveLeaveNotificationTarget(input: {
|
|||||||
selfWxid?: string
|
selfWxid?: string
|
||||||
}): LeaveNotificationTargetResolution {
|
}): LeaveNotificationTargetResolution {
|
||||||
const config = input.config
|
const config = input.config
|
||||||
if (!config) {
|
if (!config) return { ok: false, error: '退群通知尚未配置发送目标' }
|
||||||
return { ok: false, error: '退群通知尚未配置发送目标' }
|
return resolveAutomationTarget(
|
||||||
}
|
{
|
||||||
const known = LEAVE_NOTIFICATION_TARGET_OPTIONS.some(
|
targetType: config.target?.type as never,
|
||||||
(option) => option.type === config.target?.type
|
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'
|
| 'deleteTask'
|
||||||
| 'setTaskEnabled'
|
| 'setTaskEnabled'
|
||||||
| 'runScheduledReportNow'
|
| 'runScheduledReportNow'
|
||||||
> &
|
>
|
||||||
Partial<Pick<ScheduledReportService, 'retryScheduledReportSend'>>
|
|
||||||
getCapability: () => Promise<PersonalWechatSendCapability>
|
getCapability: () => Promise<PersonalWechatSendCapability>
|
||||||
listContacts: () => FormattedContact[]
|
listContacts: () => FormattedContact[]
|
||||||
isDatabaseReady: () => boolean
|
isDatabaseReady: () => boolean
|
||||||
@@ -250,16 +249,20 @@ export class ScheduledReportApiService {
|
|||||||
return result.data
|
return result.data
|
||||||
}
|
}
|
||||||
|
|
||||||
async retrySend(executionId: string): Promise<ScheduledReportExecution> {
|
/**
|
||||||
const retry = this.deps.service.retryScheduledReportSend
|
* 「重新发送」不再支持。
|
||||||
if (!retry) {
|
*
|
||||||
throw new ScheduledReportApiError(501, 'not_supported', '当前运行时不支持重新发送日报')
|
* 日报图片归日报历史所有,发送目标由规则配置决定,「把某次执行记录里那张 PNG
|
||||||
}
|
* 就地再发一遍」这个语义已经不存在 —— 与其在此伪造一个只对部分规则有效的重发
|
||||||
const result = await retry.call(this.deps.service, executionId)
|
* 语义,不如**明确返回 501**。
|
||||||
if (!result.data) {
|
* 用户的替代路径:在日报历史里手动转发,或对该规则点「立即执行」重跑一次。
|
||||||
throw new ScheduledReportApiError(404, 'not_found', result.error || '未找到定时日报执行记录')
|
*/
|
||||||
}
|
async retrySend(_executionId: string): Promise<ScheduledReportExecution> {
|
||||||
return result.data
|
throw new ScheduledReportApiError(
|
||||||
|
501,
|
||||||
|
'not_supported',
|
||||||
|
'当前运行时不支持重新发送日报;请在 TraceMemo 的自动化页面使用「立即执行」,或从日报历史手动转发。'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async executions(taskId: string): Promise<ScheduledReportApiExecution[]> {
|
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 发出)。目标可以是群 / 自己 / 文件传输助手 / 指定好友,
|
||||||
// 所以**不绑定**任何 "只能发回原群" 的作用域锁。
|
// 所以**不绑定**任何 "只能发回原群" 的作用域锁。
|
||||||
'automation_leave_notification',
|
'automation_leave_notification',
|
||||||
|
// 定时日报(由 Automation 的 `scheduled_report` 规则发出)。目标是四选一,
|
||||||
|
// 同样**不绑定**任何作用域锁。
|
||||||
|
'automation_scheduled_report',
|
||||||
|
// 定时日报的「后置词」(图片 sent 之后补发的那条文本)。与图片是两个独立
|
||||||
|
// purpose,各自有幂等位 —— 少这一条会被 evaluateWechatActionPolicy 拦下。
|
||||||
|
'automation_scheduled_report_postfix',
|
||||||
'manual_report_image',
|
'manual_report_image',
|
||||||
'manual_report_postfix'
|
'manual_report_postfix'
|
||||||
])
|
])
|
||||||
|
|||||||
Vendored
+17
-29
@@ -84,13 +84,12 @@ import type {
|
|||||||
import type { PersonalWechatSendCapability } from '../shared/personal-wechat'
|
import type { PersonalWechatSendCapability } from '../shared/personal-wechat'
|
||||||
import type { GroupMemberStatsQuery, GroupMemberStatsResult } from '../shared/group-stats'
|
import type { GroupMemberStatsQuery, GroupMemberStatsResult } from '../shared/group-stats'
|
||||||
import type {
|
import type {
|
||||||
ScheduledReportCreateInput,
|
|
||||||
ScheduledReportExecution,
|
ScheduledReportExecution,
|
||||||
|
ScheduledReportNotification,
|
||||||
|
ScheduledReportNotificationCapability,
|
||||||
ScheduledReportNotificationSettings,
|
ScheduledReportNotificationSettings,
|
||||||
ScheduledReportNotificationSettingsResult,
|
ScheduledReportNotificationSettingsResult,
|
||||||
ScheduledReportResult,
|
ScheduledReportResult
|
||||||
ScheduledReportTask,
|
|
||||||
ScheduledReportUpdateInput
|
|
||||||
} from '../shared/scheduled-report'
|
} from '../shared/scheduled-report'
|
||||||
import type {
|
import type {
|
||||||
PersonalWechatVoiceEncodingEnvironment,
|
PersonalWechatVoiceEncodingEnvironment,
|
||||||
@@ -102,7 +101,8 @@ import type {
|
|||||||
AutomationExecution,
|
AutomationExecution,
|
||||||
AutomationRule,
|
AutomationRule,
|
||||||
AutomationRuleDraft,
|
AutomationRuleDraft,
|
||||||
AutomationStatusSummary
|
AutomationStatusSummary,
|
||||||
|
ScheduledRuleRunOutcome
|
||||||
} from '../shared/automation'
|
} from '../shared/automation'
|
||||||
import type { ActionLogEntry } from '../shared/action-log'
|
import type { ActionLogEntry } from '../shared/action-log'
|
||||||
import type {
|
import type {
|
||||||
@@ -733,35 +733,23 @@ declare global {
|
|||||||
sendPersonalWechatMessage: (
|
sendPersonalWechatMessage: (
|
||||||
request: PersonalWechatSendRequest
|
request: PersonalWechatSendRequest
|
||||||
) => Promise<PersonalWechatSendResult>
|
) => Promise<PersonalWechatSendResult>
|
||||||
listScheduledReports: () => Promise<ScheduledReportTask[]>
|
/** 定时日报:「立即执行」——与 scheduler 走同一条执行链路(manual trigger)。 */
|
||||||
listScheduledReportExecutions: (taskId?: string) => Promise<ScheduledReportExecution[]>
|
runScheduledReportRule: (
|
||||||
|
ruleId: string
|
||||||
|
) => Promise<{ success: boolean; error?: string; data?: ScheduledRuleRunOutcome }>
|
||||||
|
/** 定时日报:旧执行记录**只读存档**(旧记录无法无损转换,原样展示)。 */
|
||||||
|
listScheduledReportLegacyExecutions: (
|
||||||
|
ruleId?: string
|
||||||
|
) => Promise<ScheduledReportExecution[]>
|
||||||
|
/** 定时日报:微信异常通知开关与能力检测。 */
|
||||||
getScheduledReportNotificationSettings: () => Promise<ScheduledReportNotificationSettings>
|
getScheduledReportNotificationSettings: () => Promise<ScheduledReportNotificationSettings>
|
||||||
|
getScheduledReportNotificationCapability: () => Promise<ScheduledReportNotificationCapability>
|
||||||
setScheduledReportNotificationEnabled: (
|
setScheduledReportNotificationEnabled: (
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
) => Promise<ScheduledReportNotificationSettingsResult>
|
) => 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: (
|
testScheduledReportErrorNotification: (
|
||||||
taskId: string
|
ruleId: string
|
||||||
) => Promise<ScheduledReportResult<ScheduledReportExecution>>
|
) => Promise<ScheduledReportResult<ScheduledReportNotification>>
|
||||||
getPersonalWechatVoiceDiagnostic: () => Promise<PersonalWechatVoiceDiagnostic | null>
|
getPersonalWechatVoiceDiagnostic: () => Promise<PersonalWechatVoiceDiagnostic | null>
|
||||||
getAgentHubStatus: () => Promise<AgentHubStatus>
|
getAgentHubStatus: () => Promise<AgentHubStatus>
|
||||||
getAgentHubLogs: () => Promise<AgentHubLogEntry[]>
|
getAgentHubLogs: () => Promise<AgentHubLogEntry[]>
|
||||||
|
|||||||
+24
-38
@@ -52,13 +52,12 @@ import type {
|
|||||||
} from '../shared/personal-wechat'
|
} from '../shared/personal-wechat'
|
||||||
import type { PersonalWechatSendCapability } from '../shared/personal-wechat'
|
import type { PersonalWechatSendCapability } from '../shared/personal-wechat'
|
||||||
import type {
|
import type {
|
||||||
ScheduledReportCreateInput,
|
|
||||||
ScheduledReportExecution,
|
ScheduledReportExecution,
|
||||||
|
ScheduledReportNotification,
|
||||||
|
ScheduledReportNotificationCapability,
|
||||||
ScheduledReportNotificationSettings,
|
ScheduledReportNotificationSettings,
|
||||||
ScheduledReportNotificationSettingsResult,
|
ScheduledReportNotificationSettingsResult,
|
||||||
ScheduledReportResult,
|
ScheduledReportResult
|
||||||
ScheduledReportTask,
|
|
||||||
ScheduledReportUpdateInput
|
|
||||||
} from '../shared/scheduled-report'
|
} from '../shared/scheduled-report'
|
||||||
import type {
|
import type {
|
||||||
PersonalWechatVoiceEncodingEnvironment,
|
PersonalWechatVoiceEncodingEnvironment,
|
||||||
@@ -71,7 +70,8 @@ import type {
|
|||||||
AutomationExecution,
|
AutomationExecution,
|
||||||
AutomationRule,
|
AutomationRule,
|
||||||
AutomationRuleDraft,
|
AutomationRuleDraft,
|
||||||
AutomationStatusSummary
|
AutomationStatusSummary,
|
||||||
|
ScheduledRuleRunOutcome
|
||||||
} from '../shared/automation'
|
} from '../shared/automation'
|
||||||
import type { GroupMemberStatsQuery, GroupMemberStatsResult } from '../shared/group-stats'
|
import type { GroupMemberStatsQuery, GroupMemberStatsResult } from '../shared/group-stats'
|
||||||
import type { ActionLogEntry } from '../shared/action-log'
|
import type { ActionLogEntry } from '../shared/action-log'
|
||||||
@@ -572,44 +572,30 @@ const api = {
|
|||||||
sendPersonalWechatMessage: (
|
sendPersonalWechatMessage: (
|
||||||
request: PersonalWechatSendRequest
|
request: PersonalWechatSendRequest
|
||||||
): Promise<PersonalWechatSendResult> => ipcRenderer.invoke('wechat-personal:send', request),
|
): Promise<PersonalWechatSendResult> => ipcRenderer.invoke('wechat-personal:send', request),
|
||||||
listScheduledReports: (): Promise<ScheduledReportTask[]> =>
|
/**
|
||||||
ipcRenderer.invoke('scheduled-report:list'),
|
* 定时日报(Automation 的 `scheduled_report` 规则类型)。
|
||||||
listScheduledReportExecutions: (taskId?: string): Promise<ScheduledReportExecution[]> =>
|
*
|
||||||
ipcRenderer.invoke('scheduled-report:listExecutions', taskId),
|
* 规则 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> =>
|
getScheduledReportNotificationSettings: (): Promise<ScheduledReportNotificationSettings> =>
|
||||||
ipcRenderer.invoke('scheduled-report:getNotificationSettings'),
|
ipcRenderer.invoke('automation:getScheduledReportNotificationSettings'),
|
||||||
|
getScheduledReportNotificationCapability: (): Promise<ScheduledReportNotificationCapability> =>
|
||||||
|
ipcRenderer.invoke('automation:getScheduledReportNotificationCapability'),
|
||||||
setScheduledReportNotificationEnabled: (
|
setScheduledReportNotificationEnabled: (
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
): Promise<ScheduledReportNotificationSettingsResult> =>
|
): Promise<ScheduledReportNotificationSettingsResult> =>
|
||||||
ipcRenderer.invoke('scheduled-report:setNotificationEnabled', enabled),
|
ipcRenderer.invoke('automation:setScheduledReportNotificationEnabled', 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),
|
|
||||||
testScheduledReportErrorNotification: (
|
testScheduledReportErrorNotification: (
|
||||||
taskId: string
|
ruleId: string
|
||||||
): Promise<ScheduledReportResult<ScheduledReportExecution>> =>
|
): Promise<ScheduledReportResult<ScheduledReportNotification>> =>
|
||||||
ipcRenderer.invoke('scheduled-report:testErrorNotification', taskId),
|
ipcRenderer.invoke('automation:testScheduledReportErrorNotification', ruleId),
|
||||||
getPersonalWechatVoiceDiagnostic: (): Promise<PersonalWechatVoiceDiagnostic | null> =>
|
getPersonalWechatVoiceDiagnostic: (): Promise<PersonalWechatVoiceDiagnostic | null> =>
|
||||||
ipcRenderer.invoke('wechat-personal:getVoiceDiagnostic'),
|
ipcRenderer.invoke('wechat-personal:getVoiceDiagnostic'),
|
||||||
getAgentHubStatus: () => ipcRenderer.invoke('agent-hub:getStatus'),
|
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 { ReportSourceSidebar } from './components/reports/ReportSourceSidebar'
|
||||||
import { ReportTaskStatusPanel } from './components/reports/ReportTaskStatusPanel'
|
import { ReportTaskStatusPanel } from './components/reports/ReportTaskStatusPanel'
|
||||||
import { ReportViewer } from './components/reports/ReportViewer'
|
import { ReportViewer } from './components/reports/ReportViewer'
|
||||||
import { ScheduledReportsWorkspace } from './components/reports/ScheduledReportsWorkspace'
|
|
||||||
import { ReportTemplateMarketWorkspace } from './components/reports/ReportTemplateMarketWorkspace'
|
import { ReportTemplateMarketWorkspace } from './components/reports/ReportTemplateMarketWorkspace'
|
||||||
import { contactDisplayName } from './components/reports/types'
|
import { contactDisplayName } from './components/reports/types'
|
||||||
import type { GeneratedReportRecord, ReportWorkspaceView } 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 { enrichQuotedMessages } from './utils/quoted-messages'
|
||||||
import type { ReportTemplateSelectionId } from '../../shared/report-templates'
|
import type { ReportTemplateSelectionId } from '../../shared/report-templates'
|
||||||
import { switchGeneratedReportTemplate } from './utils/report-template-switch'
|
import { switchGeneratedReportTemplate } from './utils/report-template-switch'
|
||||||
import { runtimePlatform, supportsPersonalWechatSend } from './utils/runtime-environment'
|
import { runtimePlatform } from './utils/runtime-environment'
|
||||||
import { useToast } from './components/ui'
|
import { Button, useToast } from './components/ui'
|
||||||
import { AppUpdatePrompt } from './features/app-update/AppUpdatePrompt'
|
import { AppUpdatePrompt } from './features/app-update/AppUpdatePrompt'
|
||||||
import { GroupExitMonitorWorkspace, type GroupExitMonitorOpenViewRequest } from './features/group-exit-monitor/GroupExitMonitorWorkspace'
|
import { GroupExitMonitorWorkspace, type GroupExitMonitorOpenViewRequest } from './features/group-exit-monitor/GroupExitMonitorWorkspace'
|
||||||
import { AutomationWorkspace, type AutomationOpenRuleRequest } from './features/automation/AutomationWorkspace'
|
import { AutomationWorkspace, type AutomationOpenRuleRequest } from './features/automation/AutomationWorkspace'
|
||||||
@@ -280,7 +279,7 @@ function App(): React.ReactElement {
|
|||||||
const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('account-database')
|
const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('account-database')
|
||||||
const [reportSourceContact, setReportSourceContact] = useState<Contact | null>(null)
|
const [reportSourceContact, setReportSourceContact] = useState<Contact | null>(null)
|
||||||
const [reportWorkspaceView, setReportWorkspaceView] = useState<ReportWorkspaceView>('result')
|
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 [generatedReports, setGeneratedReports] = useState<GeneratedReportRecord[]>([])
|
||||||
const [selectedReportId, setSelectedReportId] = useState<string | null>(null)
|
const [selectedReportId, setSelectedReportId] = useState<string | null>(null)
|
||||||
const [latestGeneratedReportId, setLatestGeneratedReportId] = useState<string | null>(null)
|
const [latestGeneratedReportId, setLatestGeneratedReportId] = useState<string | null>(null)
|
||||||
@@ -1678,9 +1677,10 @@ function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 「日报 → 定时日报 → 在自动化中配置」。
|
* 「定时日报」已正式迁入自动化,日报页只保留「今日日报 | 社区模板市场」两个 Tab。
|
||||||
*
|
*
|
||||||
* 纯导航:直接落到「自动化 → 规则 → 定时日报」的规则列表。
|
* 这里仍然留一个导航入口:用户是从日报页产生"要定时发日报"这个念头的,
|
||||||
|
* 不给路会显得功能被删了。它**不是**第三个 Tab,只是一句去处的说明。
|
||||||
*/
|
*/
|
||||||
const openScheduledReportAutomation = (): void => {
|
const openScheduledReportAutomation = (): void => {
|
||||||
setAutomationOpenRuleRequest({ ruleType: 'scheduled_report', requestId: Date.now() })
|
setAutomationOpenRuleRequest({ ruleType: 'scheduled_report', requestId: Date.now() })
|
||||||
@@ -1977,22 +1977,6 @@ function App(): React.ReactElement {
|
|||||||
>
|
>
|
||||||
今日日报
|
今日日报
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
@@ -2003,24 +1987,26 @@ function App(): React.ReactElement {
|
|||||||
社区模板市场
|
社区模板市场
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div className="report-workspace-body">
|
||||||
{reportSection === 'market' ? (
|
{reportSection === 'market' ? (
|
||||||
<ReportTemplateMarketWorkspace
|
<ReportTemplateMarketWorkspace
|
||||||
value={reportGeneration.templateId}
|
value={reportGeneration.templateId}
|
||||||
onChange={reportGeneration.setTemplateId}
|
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' ? (
|
) : reportWorkspaceView === 'result' ? (
|
||||||
<div className="report-center-page">
|
<div className="report-center-page">
|
||||||
<ReportHistorySidebar
|
<ReportHistorySidebar
|
||||||
@@ -2137,6 +2123,7 @@ function App(): React.ReactElement {
|
|||||||
contacts={contacts}
|
contacts={contacts}
|
||||||
onOpenSendSettings={openWechatSendSettings}
|
onOpenSendSettings={openWechatSendSettings}
|
||||||
openViewRequest={exitMonitorOpenViewRequest}
|
openViewRequest={exitMonitorOpenViewRequest}
|
||||||
|
onOpenViewRequestHandled={() => setExitMonitorOpenViewRequest(null)}
|
||||||
onOpenLeaveNotificationAutomation={openLeaveNotificationAutomation}
|
onOpenLeaveNotificationAutomation={openLeaveNotificationAutomation}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -2148,6 +2135,7 @@ function App(): React.ReactElement {
|
|||||||
onOpenExitMonitorGroups={openExitMonitorGroups}
|
onOpenExitMonitorGroups={openExitMonitorGroups}
|
||||||
openRuleRequest={automationOpenRuleRequest}
|
openRuleRequest={automationOpenRuleRequest}
|
||||||
onOpenModelSettings={openModelSettings}
|
onOpenModelSettings={openModelSettings}
|
||||||
|
onOpenAgentHub={openAgentHub}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
case 'agent-hub':
|
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 上能出现的规则类型。
|
* Tab 上能出现的规则类型。
|
||||||
*
|
*
|
||||||
* `scheduled_report` **只存在于这一层**:真实 `AutomationRule.ruleType`
|
* `scheduled_report` 是真实的 `AutomationRuleType`;这个别名只为让调用点的
|
||||||
* 仍然只有 `daily_report` / `leave_notification`
|
* 语义更清楚 —— 它等价于 `AutomationRuleType`。
|
||||||
* —— 不往真实 schema 里塞一个还不存在的类型。
|
|
||||||
*/
|
*/
|
||||||
export type AutomationRuleTabType = AutomationRuleType | 'scheduled_report'
|
export type AutomationRuleTabType = AutomationRuleType
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tab 顺序 = 触发方式的演进:消息触发 → 时间触发 → 系统事件触发。
|
* Tab 顺序 = 触发方式的演进:消息触发 → 时间触发 → 系统事件触发。
|
||||||
@@ -20,11 +19,7 @@ const RULE_TABS: AutomationRuleTabType[] = [
|
|||||||
'leave_notification'
|
'leave_notification'
|
||||||
]
|
]
|
||||||
|
|
||||||
const TAB_LABELS: Record<AutomationRuleTabType, string> = {
|
const TAB_LABELS: Record<AutomationRuleTabType, string> = AUTOMATION_RULE_TYPE_LABELS
|
||||||
daily_report: AUTOMATION_RULE_TYPE_LABELS.daily_report,
|
|
||||||
scheduled_report: '定时日报',
|
|
||||||
leave_notification: AUTOMATION_RULE_TYPE_LABELS.leave_notification
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AutomationRuleTypeTabs —— 编辑器上方的**规则类型切换**。
|
* AutomationRuleTypeTabs —— 编辑器上方的**规则类型切换**。
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import * as React from 'react'
|
|||||||
import {
|
import {
|
||||||
BUILTIN_DAILY_REPORT_RULE_ID,
|
BUILTIN_DAILY_REPORT_RULE_ID,
|
||||||
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
|
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
|
||||||
|
calculateNextRunAt,
|
||||||
|
normalizeScheduledReportConfig,
|
||||||
type AutomationExecution,
|
type AutomationExecution,
|
||||||
type AutomationRule,
|
type AutomationRule,
|
||||||
type AutomationRuleDraft,
|
type AutomationRuleDraft,
|
||||||
@@ -24,15 +26,15 @@ import {
|
|||||||
} from './AutomationRuleTypeTabs'
|
} from './AutomationRuleTypeTabs'
|
||||||
import { ScheduledReportEditor } from './ScheduledReportEditor'
|
import { ScheduledReportEditor } from './ScheduledReportEditor'
|
||||||
import { ScheduledReportRuleList } from './ScheduledReportRuleList'
|
import { ScheduledReportRuleList } from './ScheduledReportRuleList'
|
||||||
import type { ScheduledReportTask } from '../../../../shared/scheduled-report'
|
import { ScheduledReportNotificationPanel } from './ScheduledReportNotificationPanel'
|
||||||
import {
|
import {
|
||||||
|
createDraftFromRule,
|
||||||
createEmptyScheduledReportDraft,
|
createEmptyScheduledReportDraft,
|
||||||
createDraftFromTask,
|
draftToAutomationRuleDraft,
|
||||||
formatNextRunAt,
|
formatNextRunAt,
|
||||||
loadScheduledReportTasks,
|
|
||||||
resolveGroupDisplayName,
|
resolveGroupDisplayName,
|
||||||
type ScheduledReportDraft
|
type ScheduledReportDraft
|
||||||
} from './model/scheduled-report-preview'
|
} from './model/scheduled-report-model'
|
||||||
import {
|
import {
|
||||||
LeaveNotificationEditor,
|
LeaveNotificationEditor,
|
||||||
type LeaveNotificationSaveInput
|
type LeaveNotificationSaveInput
|
||||||
@@ -68,6 +70,8 @@ export interface AutomationWorkspaceProps {
|
|||||||
onOpenExitMonitorGroups?: () => void
|
onOpenExitMonitorGroups?: () => void
|
||||||
/** 「更改模型」:复用现有的模型设置入口(定时日报的模型配置)。 */
|
/** 「更改模型」:复用现有的模型设置入口(定时日报的模型配置)。 */
|
||||||
onOpenModelSettings?: () => void
|
onOpenModelSettings?: () => void
|
||||||
|
/** 「微信异常通知」依赖 Agent Hub:未就绪时给一个可操作的去处。 */
|
||||||
|
onOpenAgentHub?: () => void
|
||||||
/** 深链请求(来自退群监控页的「退群通知自动化」入口)。 */
|
/** 深链请求(来自退群监控页的「退群通知自动化」入口)。 */
|
||||||
openRuleRequest?: AutomationOpenRuleRequest | null
|
openRuleRequest?: AutomationOpenRuleRequest | null
|
||||||
}
|
}
|
||||||
@@ -84,6 +88,7 @@ export function AutomationWorkspace({
|
|||||||
onOpenSendSettings,
|
onOpenSendSettings,
|
||||||
onOpenExitMonitorGroups,
|
onOpenExitMonitorGroups,
|
||||||
onOpenModelSettings,
|
onOpenModelSettings,
|
||||||
|
onOpenAgentHub,
|
||||||
openRuleRequest
|
openRuleRequest
|
||||||
}: AutomationWorkspaceProps): React.ReactElement {
|
}: AutomationWorkspaceProps): React.ReactElement {
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
@@ -96,6 +101,8 @@ export function AutomationWorkspace({
|
|||||||
const [executions, setExecutions] = React.useState<AutomationExecution[]>([])
|
const [executions, setExecutions] = React.useState<AutomationExecution[]>([])
|
||||||
const [sendableContacts, setSendableContacts] = React.useState<LeaveNotificationContactOption[]>([])
|
const [sendableContacts, setSendableContacts] = React.useState<LeaveNotificationContactOption[]>([])
|
||||||
const [monitoredGroupCount, setMonitoredGroupCount] = React.useState(0)
|
const [monitoredGroupCount, setMonitoredGroupCount] = React.useState(0)
|
||||||
|
/** 已监控群聊清单(含显示名)—— 退群通知「通知群聊」二次勾选的候选项。 */
|
||||||
|
const [monitoredGroups, setMonitoredGroups] = React.useState<AutomationGroupOption[]>([])
|
||||||
const [loading, setLoading] = React.useState(true)
|
const [loading, setLoading] = React.useState(true)
|
||||||
const [clearing, setClearing] = React.useState(false)
|
const [clearing, setClearing] = React.useState(false)
|
||||||
const [saving, setSaving] = 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 [pendingDelete, setPendingDelete] = React.useState<AutomationRule | null>(null)
|
||||||
|
|
||||||
const reload = React.useCallback(async (): Promise<void> => {
|
const reload = React.useCallback(async (): Promise<void> => {
|
||||||
const [nextStatus, nextRules, nextGroups, nextExecutions, nextContacts, nextMonitored] =
|
const [
|
||||||
await Promise.all([
|
nextStatus,
|
||||||
automationApi.getStatus(),
|
nextRules,
|
||||||
automationApi.listRules(),
|
nextGroups,
|
||||||
automationApi.listGroups(),
|
nextExecutions,
|
||||||
automationApi.listExecutions(100),
|
nextContacts,
|
||||||
// 「指定好友」候选与已监控群聊数量:都来自既有能力,不在自动化里另存一份。
|
nextMonitored,
|
||||||
automationApi.listSendableContacts(),
|
nextMonitoredGroups
|
||||||
automationApi.getMonitoredGroupCount()
|
] = await Promise.all([
|
||||||
])
|
automationApi.getStatus(),
|
||||||
|
automationApi.listRules(),
|
||||||
|
automationApi.listGroups(),
|
||||||
|
automationApi.listExecutions(100),
|
||||||
|
// 「指定好友」候选、已监控群聊数量与清单:都来自既有能力,不在自动化里另存一份。
|
||||||
|
automationApi.listSendableContacts(),
|
||||||
|
automationApi.getMonitoredGroupCount(),
|
||||||
|
automationApi.getMonitoredGroups()
|
||||||
|
])
|
||||||
setStatus(nextStatus)
|
setStatus(nextStatus)
|
||||||
setRules(nextRules)
|
setRules(nextRules)
|
||||||
setGroups(nextGroups)
|
setGroups(nextGroups)
|
||||||
setExecutions(nextExecutions)
|
setExecutions(nextExecutions)
|
||||||
setSendableContacts(nextContacts)
|
setSendableContacts(nextContacts)
|
||||||
setMonitoredGroupCount(nextMonitored)
|
setMonitoredGroupCount(nextMonitored)
|
||||||
|
setMonitoredGroups(nextMonitoredGroups)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -195,15 +211,19 @@ export function AutomationWorkspace({
|
|||||||
}, [rules])
|
}, [rules])
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ---------- 定时日报(只读)----------
|
* ---------- 定时日报(真实规则,真写入)----------
|
||||||
*
|
*
|
||||||
* 数据是**只读**拿到的真实任务;所有写操作(保存 / 立即执行 / 启停 / 删除)
|
* `scheduled_report` 是真实的 `AutomationRuleType`,所以这一层
|
||||||
* 一律不落盘、不调 scheduler、不生成日报。详见 `model/scheduled-report-preview.ts`。
|
* **不做任何数据副本**:列表 = `rules.filter(ruleType==='scheduled_report')`,
|
||||||
|
* 保存 / 启停 / 删除 / 立即执行全部经 `automation:*` IPC 落到
|
||||||
|
* `AutomationRuleStore` 与 `AutomationService`。
|
||||||
*/
|
*/
|
||||||
const [scheduledTasks, setScheduledTasks] = React.useState<ScheduledReportTask[]>([])
|
const scheduledRules = React.useMemo(
|
||||||
const [scheduledLoading, setScheduledLoading] = React.useState(true)
|
() => rules.filter((rule) => rule.ruleType === 'scheduled_report'),
|
||||||
/** `null` = 看列表;非空 = 在编辑器里(`taskId` 为 `null` 表示新建)。 */
|
[rules]
|
||||||
const [scheduledEditor, setScheduledEditor] = React.useState<{ taskId: string | null } | null>(
|
)
|
||||||
|
/** `null` = 看列表;非空 = 在编辑器里(`ruleId` 为 `null` 表示新建)。 */
|
||||||
|
const [scheduledEditor, setScheduledEditor] = React.useState<{ ruleId: string | null } | null>(
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
const [scheduledDraft, setScheduledDraft] = React.useState<ScheduledReportDraft | null>(null)
|
const [scheduledDraft, setScheduledDraft] = React.useState<ScheduledReportDraft | null>(null)
|
||||||
@@ -218,47 +238,35 @@ export function AutomationWorkspace({
|
|||||||
return dailyReportTarget?.name ?? '新建自动化'
|
return dailyReportTarget?.name ?? '新建自动化'
|
||||||
}, [editor, leaveNotificationRule, dailyReportTarget, scheduledDraft])
|
}, [editor, leaveNotificationRule, dailyReportTarget, scheduledDraft])
|
||||||
|
|
||||||
React.useEffect(() => {
|
/**
|
||||||
let disposed = false
|
* 首页汇总卡:数量 / 运行中 / 最近一次「下次执行」。
|
||||||
setScheduledLoading(true)
|
* 数据直接来自真实规则 —— 加载中不显示(避免闪一个假的"0 条")。
|
||||||
void loadScheduledReportTasks()
|
*/
|
||||||
.then((tasks) => {
|
|
||||||
if (!disposed) setScheduledTasks(tasks)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!disposed) setScheduledLoading(false)
|
|
||||||
})
|
|
||||||
return () => {
|
|
||||||
disposed = true
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/** 首页汇总卡:数量 + 最近一次执行时间(加载中先不显示,避免闪一个"0 条")。 */
|
|
||||||
const scheduledSummary = React.useMemo(() => {
|
const scheduledSummary = React.useMemo(() => {
|
||||||
if (scheduledLoading) return null
|
if (loading) return null
|
||||||
const running = scheduledTasks.filter((task) => task.enabled)
|
const now = new Date()
|
||||||
const nextRunAt = running
|
const running = scheduledRules.filter((rule) => rule.enabled)
|
||||||
.map((task) => task.nextRunAt)
|
const nextRuns = running
|
||||||
.filter((value) => Boolean(value))
|
.map((rule) => normalizeScheduledReportConfig(rule.scheduledReport))
|
||||||
.sort()[0]
|
.filter((config) => !config.targetNeedsReview)
|
||||||
|
.map((config) => calculateNextRunAt(config.schedule.time, now))
|
||||||
|
.sort()
|
||||||
return {
|
return {
|
||||||
total: scheduledTasks.length,
|
total: scheduledRules.length,
|
||||||
running: running.length,
|
running: running.length,
|
||||||
nextRunLabel: nextRunAt ? formatNextRunAt(nextRunAt) : '—'
|
nextRunLabel: nextRuns.length ? formatNextRunAt(nextRuns[0]) : '—'
|
||||||
}
|
}
|
||||||
}, [scheduledTasks, scheduledLoading])
|
}, [scheduledRules, loading])
|
||||||
|
|
||||||
/** 群标识 → 显示名(列表与预览共用,保证任何地方都不出现裸 id)。 */
|
/** 群标识 → 显示名(列表与编辑器共用,保证任何地方都不出现裸 id)。 */
|
||||||
const resolveScheduledGroupDisplay = React.useCallback(
|
const resolveScheduledGroupDisplay = React.useCallback(
|
||||||
(raw: string): string => resolveGroupDisplayName(raw, groups),
|
(raw: string): string => resolveGroupDisplayName(raw, groups),
|
||||||
[groups]
|
[groups]
|
||||||
)
|
)
|
||||||
|
|
||||||
const PREVIEW_NOTICE = 'UI 预览模式,配置暂未保存'
|
const openScheduledEditor = (rule: AutomationRule | null): void => {
|
||||||
|
setScheduledDraft(rule ? createDraftFromRule(rule) : createEmptyScheduledReportDraft())
|
||||||
const openScheduledEditor = (task: ScheduledReportTask | null): void => {
|
setScheduledEditor({ ruleId: rule?.id ?? null })
|
||||||
setScheduledDraft(task ? createDraftFromTask(task) : createEmptyScheduledReportDraft())
|
|
||||||
setScheduledEditor({ taskId: task?.id ?? null })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeScheduledEditor = (): void => {
|
const closeScheduledEditor = (): void => {
|
||||||
@@ -266,28 +274,77 @@ export function AutomationWorkspace({
|
|||||||
setScheduledDraft(null)
|
setScheduledDraft(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 保存:只提示,绝不 create/update。 */
|
/** 保存:**真的**创建 / 更新一条 `scheduled_report` 规则。 */
|
||||||
const handleScheduledSave = (): void => {
|
const handleScheduledSave = async (draft: ScheduledReportDraft): Promise<void> => {
|
||||||
toast({ description: PREVIEW_NOTICE, duration: 3200 })
|
if (!scheduledEditor) return
|
||||||
closeScheduledEditor()
|
const target = scheduledEditor.ruleId
|
||||||
}
|
? (scheduledRules.find((rule) => rule.id === scheduledEditor.ruleId) ?? null)
|
||||||
|
: null
|
||||||
/** 立即执行:不碰 scheduler / 生成 / 发送。 */
|
// 编辑态但目标不存在 ⇒ 报错,绝不静默新建(与 @我日报同一条红线)。
|
||||||
const handleScheduledRunNow = (_task: ScheduledReportTask): void => {
|
if (scheduledEditor.ruleId && !target) {
|
||||||
toast({ description: 'UI 预览模式,不会执行真实日报任务', duration: 3200 })
|
toast({
|
||||||
}
|
description: '要编辑的定时日报已不存在,请返回列表重新打开',
|
||||||
|
variant: 'destructive',
|
||||||
/** 启停:只改本地列表,不写 store。 */
|
duration: 3600
|
||||||
const handleScheduledToggle = (task: ScheduledReportTask, enabled: boolean): void => {
|
})
|
||||||
setScheduledTasks((current) =>
|
return
|
||||||
current.map((item) => (item.id === task.id ? { ...item, enabled } : item))
|
}
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除:直接提示,避免用户以为任务真被删了。 */
|
/** 立即执行:走与 scheduler 完全相同的执行链路(manual trigger)。 */
|
||||||
const handleScheduledDelete = (_task: ScheduledReportTask): void => {
|
const handleScheduledRunNow = async (rule: AutomationRule): Promise<void> => {
|
||||||
toast({ description: 'UI 预览模式,不会删除现有任务', duration: 3200 })
|
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> => {
|
const handleToggle = async (rule: AutomationRule, enabled: boolean): Promise<void> => {
|
||||||
@@ -563,27 +620,45 @@ export function AutomationWorkspace({
|
|||||||
) : editor.ruleType === 'scheduled_report' ? (
|
) : editor.ruleType === 'scheduled_report' ? (
|
||||||
scheduledEditor && scheduledDraft ? (
|
scheduledEditor && scheduledDraft ? (
|
||||||
<ScheduledReportEditor
|
<ScheduledReportEditor
|
||||||
// 换一条任务就重建编辑器,避免把上一条的草稿带过来。
|
// 换一条规则就重建编辑器,避免把上一条的草稿带过来。
|
||||||
key={scheduledEditor.taskId ?? 'new'}
|
key={scheduledEditor.ruleId ?? 'new'}
|
||||||
initialDraft={scheduledDraft}
|
initialDraft={scheduledDraft}
|
||||||
groups={groups}
|
groups={groups}
|
||||||
sendableContacts={sendableContacts}
|
sendableContacts={sendableContacts}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
onBack={closeScheduledEditor}
|
onBack={closeScheduledEditor}
|
||||||
onCancel={closeScheduledEditor}
|
onCancel={closeScheduledEditor}
|
||||||
onSave={handleScheduledSave}
|
onSave={(draft) => void handleScheduledSave(draft)}
|
||||||
{...(onOpenModelSettings ? { onOpenModelSettings } : {})}
|
{...(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
|
<ScheduledReportRuleList
|
||||||
tasks={scheduledTasks}
|
rules={scheduledRules}
|
||||||
loading={scheduledLoading}
|
loading={loading}
|
||||||
busyTaskId={null}
|
busyRuleId={busyRuleId}
|
||||||
resolveGroupDisplay={resolveScheduledGroupDisplay}
|
resolveGroupDisplay={resolveScheduledGroupDisplay}
|
||||||
|
// 与另外两个类型的「取消」等价:退出本类型,回到规则面板。
|
||||||
|
// 少了这一个,用户在定时日报列表上就只能靠切 tab 绕出去。
|
||||||
|
onBack={() => setEditor(null)}
|
||||||
onCreate={() => openScheduledEditor(null)}
|
onCreate={() => openScheduledEditor(null)}
|
||||||
onEdit={openScheduledEditor}
|
onEdit={openScheduledEditor}
|
||||||
onRunNow={handleScheduledRunNow}
|
onRunNow={(rule) => void handleScheduledRunNow(rule)}
|
||||||
onToggle={handleScheduledToggle}
|
onToggle={(rule, enabled) => void handleScheduledToggle(rule, enabled)}
|
||||||
onDelete={handleScheduledDelete}
|
onDelete={handleScheduledDelete}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -592,11 +667,12 @@ export function AutomationWorkspace({
|
|||||||
rule={leaveNotificationRule}
|
rule={leaveNotificationRule}
|
||||||
contacts={sendableContacts}
|
contacts={sendableContacts}
|
||||||
monitoredCount={monitoredGroupCount}
|
monitoredCount={monitoredGroupCount}
|
||||||
|
monitoredGroups={monitoredGroups}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
{...(capabilityReady
|
{...(capabilityReady
|
||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
// §「规则仍然可以保存」:只提示,不阻断编辑。
|
// 只提示,不阻断编辑。
|
||||||
sendCapabilityWarning:
|
sendCapabilityWarning:
|
||||||
'当前发送能力未就绪:退群事件仍会被记录,但通知发送会失败。'
|
'当前发送能力未就绪:退群事件仍会被记录,但通知发送会失败。'
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { Button, RadioGroup, RadioGroupItem, Switch } from '../../components/ui'
|
import { Button, Input, RadioGroup, RadioGroupItem, Switch } from '../../components/ui'
|
||||||
import {
|
import {
|
||||||
LEAVE_NOTIFICATION_TARGET_OPTIONS,
|
LEAVE_NOTIFICATION_TARGET_OPTIONS,
|
||||||
createDefaultLeaveNotificationRule,
|
describeLeaveNotificationNotifyScope,
|
||||||
|
normalizeLeaveNotificationConfig,
|
||||||
type AutomationRule,
|
type AutomationRule,
|
||||||
type LeaveNotificationConfig,
|
type LeaveNotificationConfig,
|
||||||
|
type LeaveNotificationNotifyScope,
|
||||||
type LeaveNotificationTargetType
|
type LeaveNotificationTargetType
|
||||||
} from '../../../../shared/automation'
|
} from '../../../../shared/automation'
|
||||||
import { insertGroupNamePlaceholder } from '../../../../shared/group-exit-monitor'
|
import { insertGroupNamePlaceholder } from '../../../../shared/group-exit-monitor'
|
||||||
import { LeaveNotificationPreview } from './LeaveNotificationPreview'
|
import { LeaveNotificationPreview } from './LeaveNotificationPreview'
|
||||||
|
import type { AutomationGroupOption } from './model/api'
|
||||||
import {
|
import {
|
||||||
LeaveNotificationTargetPicker,
|
LeaveNotificationTargetPicker,
|
||||||
type LeaveNotificationContactOption
|
type LeaveNotificationContactOption
|
||||||
@@ -21,9 +24,12 @@ import { LeaveNotificationTemplateEditor } from './LeaveNotificationTemplateEdit
|
|||||||
* 这是**真实配置页**:保存会落盘成一条 singleton 规则(`builtin-leave-notification`),
|
* 这是**真实配置页**:保存会落盘成一条 singleton 规则(`builtin-leave-notification`),
|
||||||
* 由 `AutomationService.handleGroupExit` 在退群事件到来时执行。
|
* 由 `AutomationService.handleGroupExit` 在退群事件到来时执行。
|
||||||
*
|
*
|
||||||
* 职责边界在 UI 上一眼可见:
|
* 职责边界在 UI 上一眼可见,是**两层**而不是一层:
|
||||||
* 退群监控 = 监测哪些群有人退出(**范围完全由它管**);自动化 = 检测到之后发到哪。
|
* 退群监控 = 监测哪些群有人退出(范围由它管);
|
||||||
* 所以这里**没有**「触发范围」二次筛选,规则级启停也**只有**顶部一处。
|
* 本规则 = ① 这批群里**哪些要通知**(二次勾选);② 通知**发到哪**。
|
||||||
|
*
|
||||||
|
* ① 就是旧版「退群监控 → 通知群聊」的那份逐群勾选。它曾在自动化迁移里被压成单值目标而丢失,
|
||||||
|
* 导致"规则一启用就全量通知";`notifyScope` 把它恢复回来,所以这里**有**勾选控件。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface LeaveNotificationSaveInput {
|
export interface LeaveNotificationSaveInput {
|
||||||
@@ -38,6 +44,8 @@ export interface LeaveNotificationEditorProps {
|
|||||||
contacts: LeaveNotificationContactOption[]
|
contacts: LeaveNotificationContactOption[]
|
||||||
/** 真实已监控群聊数量(来自退群监控,自动化不维护副本)。 */
|
/** 真实已监控群聊数量(来自退群监控,自动化不维护副本)。 */
|
||||||
monitoredCount: number
|
monitoredCount: number
|
||||||
|
/** 真实已监控群聊清单 —— 二次勾选的候选项,与 `monitoredCount` 同一次取数。 */
|
||||||
|
monitoredGroups: AutomationGroupOption[]
|
||||||
saving: boolean
|
saving: boolean
|
||||||
/** 发送能力不完整时的一句话提示;为 undefined 表示能力正常。 */
|
/** 发送能力不完整时的一句话提示;为 undefined 表示能力正常。 */
|
||||||
sendCapabilityWarning?: string
|
sendCapabilityWarning?: string
|
||||||
@@ -47,20 +55,22 @@ export interface LeaveNotificationEditorProps {
|
|||||||
onSave: (input: LeaveNotificationSaveInput) => void
|
onSave: (input: LeaveNotificationSaveInput) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 草稿初值。
|
||||||
|
*
|
||||||
|
* 一律过一遍 `normalizeLeaveNotificationConfig`:它本来就负责"补齐缺失字段 +
|
||||||
|
* 清掉互相矛盾的配置",所以契约新增字段(例如 `notifyScope`)时这里不会漏。
|
||||||
|
* 手拼字面量就会漏。
|
||||||
|
*/
|
||||||
function initialConfig(rule: AutomationRule): LeaveNotificationConfig {
|
function initialConfig(rule: AutomationRule): LeaveNotificationConfig {
|
||||||
return (
|
return normalizeLeaveNotificationConfig(rule.leaveNotification)
|
||||||
rule.leaveNotification ??
|
|
||||||
createDefaultLeaveNotificationRule(Date.now()).leaveNotification ?? {
|
|
||||||
target: { type: 'file_transfer' },
|
|
||||||
template: ''
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LeaveNotificationEditor({
|
export function LeaveNotificationEditor({
|
||||||
rule,
|
rule,
|
||||||
contacts,
|
contacts,
|
||||||
monitoredCount,
|
monitoredCount,
|
||||||
|
monitoredGroups,
|
||||||
saving,
|
saving,
|
||||||
sendCapabilityWarning,
|
sendCapabilityWarning,
|
||||||
onOpenMonitoredGroups,
|
onOpenMonitoredGroups,
|
||||||
@@ -69,11 +79,13 @@ export function LeaveNotificationEditor({
|
|||||||
}: LeaveNotificationEditorProps): React.ReactElement {
|
}: LeaveNotificationEditorProps): React.ReactElement {
|
||||||
const [enabled, setEnabled] = React.useState(rule.enabled)
|
const [enabled, setEnabled] = React.useState(rule.enabled)
|
||||||
const [config, setConfig] = React.useState<LeaveNotificationConfig>(() => initialConfig(rule))
|
const [config, setConfig] = React.useState<LeaveNotificationConfig>(() => initialConfig(rule))
|
||||||
|
const [groupFilter, setGroupFilter] = React.useState('')
|
||||||
|
|
||||||
// 切换编辑对象 / 保存后回填时重建草稿,避免把上一份改动带过来。
|
// 切换编辑对象 / 保存后回填时重建草稿,避免把上一份改动带过来。
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setEnabled(rule.enabled)
|
setEnabled(rule.enabled)
|
||||||
setConfig(initialConfig(rule))
|
setConfig(initialConfig(rule))
|
||||||
|
setGroupFilter('')
|
||||||
}, [rule])
|
}, [rule])
|
||||||
|
|
||||||
const targetType = config.target.type
|
const targetType = config.target.type
|
||||||
@@ -96,6 +108,21 @@ export function LeaveNotificationEditor({
|
|||||||
const missingGroupName =
|
const missingGroupName =
|
||||||
targetType !== 'source_chat' && !config.template.includes('{groupName}')
|
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 => {
|
const selectTarget = (next: LeaveNotificationTargetType): void => {
|
||||||
setConfig((current) => ({
|
setConfig((current) => ({
|
||||||
...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 => {
|
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 (
|
return (
|
||||||
@@ -172,9 +231,8 @@ export function LeaveNotificationEditor({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
只读摘要 + 唯一入口。
|
第一层:只读的监控范围摘要 + 唯一入口。
|
||||||
「哪些群被监控」完全由退群监控决定,本规则不做二次筛选 ——
|
「哪些群被监控」完全由退群监控决定,本规则不重复维护一份。
|
||||||
所以这里不给任何勾选控件,只把当前范围如实说出来。
|
|
||||||
*/}
|
*/}
|
||||||
<div className="automation-leave-groups-card">
|
<div className="automation-leave-groups-card">
|
||||||
<div>
|
<div>
|
||||||
@@ -191,6 +249,100 @@ export function LeaveNotificationEditor({
|
|||||||
管理监控群聊 →
|
管理监控群聊 →
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<section className="automation-section">
|
<section className="automation-section">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import {
|
import {
|
||||||
|
describeLeaveNotificationNotifyScope,
|
||||||
describeMonitoredScope,
|
describeMonitoredScope,
|
||||||
leaveNotificationTargetLabel,
|
leaveNotificationTargetLabel,
|
||||||
type LeaveNotificationConfig
|
type LeaveNotificationConfig
|
||||||
@@ -99,6 +100,10 @@ export function LeaveNotificationPreview({
|
|||||||
<dt>监控来源</dt>
|
<dt>监控来源</dt>
|
||||||
<dd>{describeMonitoredScope(monitoredCount)}</dd>
|
<dd>{describeMonitoredScope(monitoredCount)}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>通知范围</dt>
|
||||||
|
<dd>{describeLeaveNotificationNotifyScope(config, monitoredCount)}</dd>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>通知目标</dt>
|
<dt>通知目标</dt>
|
||||||
<dd>{targetSummary}</dd>
|
<dd>{targetSummary}</dd>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import * as React from 'react'
|
|||||||
import {
|
import {
|
||||||
AUTOMATION_RULE_TEMPLATES,
|
AUTOMATION_RULE_TEMPLATES,
|
||||||
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
|
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
|
||||||
|
describeLeaveNotificationNotifyScope,
|
||||||
describeLeaveNotificationTarget,
|
describeLeaveNotificationTarget,
|
||||||
describeMonitoredScope,
|
|
||||||
describeRuleActions,
|
describeRuleActions,
|
||||||
describeRuleTrigger,
|
describeRuleTrigger,
|
||||||
type AutomationRule
|
type AutomationRule
|
||||||
@@ -192,8 +192,13 @@ export function RuleListPanel({
|
|||||||
<dd>{describeRuleTrigger(leaveRule)}</dd>
|
<dd>{describeRuleTrigger(leaveRule)}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>范围</dt>
|
<dt>通知范围</dt>
|
||||||
<dd>{describeMonitoredScope(leaveNotification?.monitoredCount ?? 0)}</dd>
|
<dd>
|
||||||
|
{describeLeaveNotificationNotifyScope(
|
||||||
|
leaveRule.leaveNotification,
|
||||||
|
leaveNotification?.monitoredCount ?? 0
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>动作</dt>
|
<dt>动作</dt>
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import {
|
|||||||
} from '../../components/ui'
|
} from '../../components/ui'
|
||||||
import { MessageTypeSelector } from '../../components/reports/MessageTypeSelector'
|
import { MessageTypeSelector } from '../../components/reports/MessageTypeSelector'
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_REPORT_IMAGE_POSTFIX_TEXT,
|
||||||
SCHEDULED_REPORT_MEMBER_NAME_OPTIONS,
|
SCHEDULED_REPORT_MEMBER_NAME_OPTIONS,
|
||||||
|
SCHEDULED_REPORT_POSTFIX_MAX_LENGTH,
|
||||||
SCHEDULED_REPORT_RANGE_OPTIONS,
|
SCHEDULED_REPORT_RANGE_OPTIONS,
|
||||||
SCHEDULED_REPORT_TARGET_OPTIONS,
|
SCHEDULED_REPORT_TARGET_OPTIONS,
|
||||||
SCHEDULED_REPORT_TEMPLATE_SELECT_OPTIONS,
|
SCHEDULED_REPORT_TEMPLATE_SELECT_OPTIONS,
|
||||||
@@ -24,9 +26,8 @@ import {
|
|||||||
resolveGroupDisplayName,
|
resolveGroupDisplayName,
|
||||||
type ScheduledReportDraft,
|
type ScheduledReportDraft,
|
||||||
type ScheduledReportTargetType
|
type ScheduledReportTargetType
|
||||||
} from './model/scheduled-report-preview'
|
} from './model/scheduled-report-model'
|
||||||
import { LeaveNotificationTargetPicker } from './LeaveNotificationTargetPicker'
|
import { LeaveNotificationTargetPicker } from './LeaveNotificationTargetPicker'
|
||||||
import { ScheduledReportPreview } from './ScheduledReportPreview'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ScheduledReportEditor —— 「自动化 → 规则 → 定时日报」的编辑器。
|
* ScheduledReportEditor —— 「自动化 → 规则 → 定时日报」的编辑器。
|
||||||
@@ -36,7 +37,7 @@ import { ScheduledReportPreview } from './ScheduledReportPreview'
|
|||||||
* Section 2 承载完整的日报配置(纳入的消息类型 / 日报内容 / 模型配置 /
|
* Section 2 承载完整的日报配置(纳入的消息类型 / 日报内容 / 模型配置 /
|
||||||
* 日报模板 / 成员名称 / 生成超时),字段全部来自真实模型 —— 不发明,也不删减。
|
* 日报模板 / 成员名称 / 生成超时),字段全部来自真实模型 —— 不发明,也不删减。
|
||||||
*
|
*
|
||||||
* 草稿只存在 renderer local state,保存不落盘(由父层提示)。
|
* 草稿仍然是 renderer local state,但**保存是真的落盘**(父层调 `automation:*`)。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface ScheduledReportEditorProps {
|
export interface ScheduledReportEditorProps {
|
||||||
@@ -52,6 +53,15 @@ export interface ScheduledReportEditorProps {
|
|||||||
onSave: (draft: ScheduledReportDraft) => void
|
onSave: (draft: ScheduledReportDraft) => void
|
||||||
/** 复用现有的模型配置入口。 */
|
/** 复用现有的模型配置入口。 */
|
||||||
onOpenModelSettings?: () => void
|
onOpenModelSettings?: () => void
|
||||||
|
/**
|
||||||
|
* 迁移遗留的旧目标原文(只作展示)。
|
||||||
|
*
|
||||||
|
* 有值时说明这条规则是迁移过来的、当时的目标无法自动对应 ——
|
||||||
|
* 界面必须让用户**看见原来的目标是什么**再重选。
|
||||||
|
*/
|
||||||
|
legacyTarget?: string
|
||||||
|
/** 微信异常通知的入口(底部一块,与退群通知同一套开关组件)。 */
|
||||||
|
notificationSlot?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScheduledReportEditor({
|
export function ScheduledReportEditor({
|
||||||
@@ -62,7 +72,9 @@ export function ScheduledReportEditor({
|
|||||||
onBack,
|
onBack,
|
||||||
onCancel,
|
onCancel,
|
||||||
onSave,
|
onSave,
|
||||||
onOpenModelSettings
|
onOpenModelSettings,
|
||||||
|
legacyTarget,
|
||||||
|
notificationSlot
|
||||||
}: ScheduledReportEditorProps): React.ReactElement {
|
}: ScheduledReportEditorProps): React.ReactElement {
|
||||||
const [draft, setDraft] = React.useState<ScheduledReportDraft>(initialDraft)
|
const [draft, setDraft] = React.useState<ScheduledReportDraft>(initialDraft)
|
||||||
const [groupFilter, setGroupFilter] = React.useState('')
|
const [groupFilter, setGroupFilter] = React.useState('')
|
||||||
@@ -101,23 +113,20 @@ export function ScheduledReportEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 候选群 = 自动化现有的群列表 **并上** 当前任务已在用的来源群。
|
* 候选群 = 自动化现有的群列表 **并上** 当前规则已在用的来源群。
|
||||||
*
|
*
|
||||||
* 任务里存的群标识与群列表是两个数据源,不保证一致;不并的话,
|
* ⚠️ `value` 一律是**稳定会话 id**(`xxx@chatroom`),不是群名 ——
|
||||||
* 编辑已有任务会出现"当前来源群在列表里找不到、于是没有任何一项被选中"。
|
* 群名会改,把群名当 id 存下来的规则改一次名就发错群。
|
||||||
|
* 迁移过来的老规则里可能仍是群名 / md5,这时作为"额外候选项"补进列表,
|
||||||
|
* 界面上用可读名展示,**绝不把裸标识印给用户**。
|
||||||
*/
|
*/
|
||||||
const groupOptions = React.useMemo(() => {
|
const groupOptions = React.useMemo(() => {
|
||||||
const known = new Set(groups.map((group) => group.name))
|
const known = new Set(groups.map((group) => group.id))
|
||||||
// 去重:不去重会生成两条同 id 的候选项,React 重复 key 会让渲染错乱。
|
|
||||||
const extras = Array.from(
|
const extras = Array.from(
|
||||||
new Set(
|
new Set([initialDraft.group].filter((value) => Boolean(value) && !known.has(value)))
|
||||||
[initialDraft.group, initialDraft.target].filter(
|
).map((value) => ({ id: value, name: resolveGroupDisplayName(value, groups) }))
|
||||||
(value) => Boolean(value) && !known.has(value)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).map((value) => ({ id: `current:${value}`, name: value }))
|
|
||||||
return [...groups, ...extras]
|
return [...groups, ...extras]
|
||||||
}, [groups, initialDraft.group, initialDraft.target])
|
}, [groups, initialDraft.group])
|
||||||
|
|
||||||
const visibleGroups = React.useMemo(() => {
|
const visibleGroups = React.useMemo(() => {
|
||||||
const needle = groupFilter.trim().toLowerCase()
|
const needle = groupFilter.trim().toLowerCase()
|
||||||
@@ -128,14 +137,24 @@ export function ScheduledReportEditor({
|
|||||||
)
|
)
|
||||||
}, [groupOptions, groupFilter])
|
}, [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 => {
|
const selectTargetType = (next: ScheduledReportTargetType): void => {
|
||||||
// 「发送到日报来源群」的目标跟随来源群;其余目标清空,避免看起来已经选好了。
|
// 切换目标类型时清空已选联系人,避免看起来"已经选好了"却发不出去。
|
||||||
patch({
|
patch({
|
||||||
targetType: next,
|
targetType: next,
|
||||||
target: next === 'source_chat' ? draft.group : '',
|
|
||||||
targetContactId: next === 'contact' ? draft.targetContactId : ''
|
targetContactId: next === 'contact' ? draft.targetContactId : ''
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -163,12 +182,29 @@ export function ScheduledReportEditor({
|
|||||||
<Button variant="ghost" onClick={onCancel} disabled={saving}>
|
<Button variant="ghost" onClick={onCancel} disabled={saving}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => onSave(draft)} disabled={saving}>
|
<Button
|
||||||
|
onClick={() => onSave(draft)}
|
||||||
|
disabled={saving || Boolean(saveBlocker)}
|
||||||
|
title={saveBlocker || undefined}
|
||||||
|
>
|
||||||
{saving ? '保存中…' : '保存'}
|
{saving ? '保存中…' : '保存'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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-body">
|
||||||
<div className="automation-editor-form">
|
<div className="automation-editor-form">
|
||||||
<section className="automation-section">
|
<section className="automation-section">
|
||||||
@@ -244,12 +280,7 @@ export function ScheduledReportEditor({
|
|||||||
<small>生成哪个群的日报。</small>
|
<small>生成哪个群的日报。</small>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={draft.group}
|
value={draft.group}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) => patch({ group: value })}
|
||||||
patch({
|
|
||||||
group: value,
|
|
||||||
target: draft.targetType === 'source_chat' ? value : draft.target
|
|
||||||
})
|
|
||||||
}
|
|
||||||
aria-label="日报来源"
|
aria-label="日报来源"
|
||||||
className="automation-leave-contact-picker"
|
className="automation-leave-contact-picker"
|
||||||
>
|
>
|
||||||
@@ -265,7 +296,7 @@ export function ScheduledReportEditor({
|
|||||||
) : (
|
) : (
|
||||||
visibleGroups.map((group) => (
|
visibleGroups.map((group) => (
|
||||||
<div key={group.id} className="automation-leave-radio option">
|
<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>
|
<label htmlFor={`scheduled-source-${group.id}`}>{group.name}</label>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
@@ -421,6 +452,20 @@ export function ScheduledReportEditor({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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">
|
<p className="automation-editor-footnote">
|
||||||
微信数据库和聊天记录默认从本机读取。所选内容将发送至你配置的模型服务进行处理,
|
微信数据库和聊天记录默认从本机读取。所选内容将发送至你配置的模型服务进行处理,
|
||||||
TraceMemo 本身不额外保存或转发内容。
|
TraceMemo 本身不额外保存或转发内容。
|
||||||
@@ -428,11 +473,9 @@ export function ScheduledReportEditor({
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScheduledReportPreview
|
<aside className="automation-editor-side">
|
||||||
draft={draft}
|
{notificationSlot}
|
||||||
groupLabel={resolveGroupDisplayName(draft.group, groups)}
|
</aside>
|
||||||
contactName={selectedContactName}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</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 * as React from 'react'
|
||||||
|
|
||||||
import { Button, Spinner, Switch } from '../../components/ui'
|
import { Button, Spinner, Switch } from '../../components/ui'
|
||||||
import type { ScheduledReportTask } from '../../../../shared/scheduled-report'
|
import {
|
||||||
|
calculateNextRunAt,
|
||||||
|
normalizeScheduledReportConfig,
|
||||||
|
type AutomationRule
|
||||||
|
} from '../../../../shared/automation'
|
||||||
import {
|
import {
|
||||||
formatNextRunAt,
|
formatNextRunAt,
|
||||||
scheduledReportRangeLabel,
|
scheduledReportRangeLabel,
|
||||||
|
scheduledReportTargetDisplayName,
|
||||||
scheduledReportTemplateLabel
|
scheduledReportTemplateLabel
|
||||||
} from './model/scheduled-report-preview'
|
} from './model/scheduled-report-model'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ScheduledReportRuleList —— 「自动化 → 规则 → 定时日报」的**规则列表**。
|
* ScheduledReportRuleList —— 「自动化 → 规则 → 定时日报」的**规则列表**。
|
||||||
@@ -14,48 +19,59 @@ import {
|
|||||||
* 为什么这里是列表而不是直接进编辑器:定时日报是**多条**规则
|
* 为什么这里是列表而不是直接进编辑器:定时日报是**多条**规则
|
||||||
* (与 singleton 的「退群通知」不同),所以必须先选一条,或新建一条。
|
* (与 singleton 的「退群通知」不同),所以必须先选一条,或新建一条。
|
||||||
*
|
*
|
||||||
* 所有写操作(立即执行 / 启停 / 删除)都不落盘,只提示。
|
* 数据是 `AutomationRuleStore` 里的真实 `scheduled_report` 规则;
|
||||||
* 数据是**只读**拿到的真实任务(见 `model/scheduled-report-preview.ts`)。
|
* 立即执行 / 启停 / 删除全部**真写入**(由父层调 `automation:*` IPC)。
|
||||||
|
*
|
||||||
|
* `onBack` 是这一层**必须**有的逃生口:进了「定时日报」这一类型后,只有
|
||||||
|
* 列表与编辑器两种形态,而列表本身没有「取消」——如果这里不给退出入口,
|
||||||
|
* 用户唯一的出路就是切到别的规则类型 tab,再从那边的「取消」绕回来。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface ScheduledReportRuleListProps {
|
export interface ScheduledReportRuleListProps {
|
||||||
tasks: ScheduledReportTask[]
|
rules: AutomationRule[]
|
||||||
loading: boolean
|
loading: boolean
|
||||||
/** 正在"操作中"的任务 id(仅用于视觉反馈,不产生真实副作用)。 */
|
/** 正在"操作中"的规则 id(视觉反馈,防止重复点击)。 */
|
||||||
busyTaskId: string | null
|
busyRuleId: string | null
|
||||||
/**
|
/** 群标识 → 显示名(**绝不**把内部标识露给用户)。 */
|
||||||
* 群标识 → 显示名。
|
|
||||||
*
|
|
||||||
* 任务里存的可能是群名、room id 或 hash;列表**绝不能**把内部标识露给用户。
|
|
||||||
*/
|
|
||||||
resolveGroupDisplay: (raw: string) => string
|
resolveGroupDisplay: (raw: string) => string
|
||||||
|
/** 退出本类型、回到规则面板(三个类型卡片那一层)。 */
|
||||||
|
onBack?: () => void
|
||||||
onCreate: () => void
|
onCreate: () => void
|
||||||
onEdit: (task: ScheduledReportTask) => void
|
onEdit: (rule: AutomationRule) => void
|
||||||
onRunNow: (task: ScheduledReportTask) => void
|
onRunNow: (rule: AutomationRule) => void
|
||||||
onToggle: (task: ScheduledReportTask, enabled: boolean) => void
|
onToggle: (rule: AutomationRule, enabled: boolean) => void
|
||||||
onDelete: (task: ScheduledReportTask) => void
|
onDelete: (rule: AutomationRule) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScheduledReportRuleList({
|
export function ScheduledReportRuleList({
|
||||||
tasks,
|
rules,
|
||||||
loading,
|
loading,
|
||||||
busyTaskId,
|
busyRuleId,
|
||||||
resolveGroupDisplay,
|
resolveGroupDisplay,
|
||||||
|
onBack,
|
||||||
onCreate,
|
onCreate,
|
||||||
onEdit,
|
onEdit,
|
||||||
onRunNow,
|
onRunNow,
|
||||||
onToggle,
|
onToggle,
|
||||||
onDelete
|
onDelete
|
||||||
}: ScheduledReportRuleListProps): React.ReactElement {
|
}: ScheduledReportRuleListProps): React.ReactElement {
|
||||||
const runningCount = tasks.filter((task) => task.enabled).length
|
const runningCount = rules.filter((rule) => rule.enabled).length
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="automation-rule-list">
|
<div className="automation-rule-list">
|
||||||
|
{/* 没有 onBack 就不渲染死按钮:按钮点不动比没有按钮更糟。 */}
|
||||||
|
{onBack ? (
|
||||||
|
<button type="button" className="automation-editor-back" onClick={onBack}>
|
||||||
|
← 返回规则列表
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="automation-rule-group">
|
<section className="automation-rule-group">
|
||||||
<div className="automation-section-heading">
|
<div className="automation-section-heading">
|
||||||
<h2>定时日报</h2>
|
<h2>定时日报</h2>
|
||||||
<span className="automation-section-note">
|
<span className="automation-section-note">
|
||||||
{tasks.length > 0 ? `${tasks.length} 条规则` : '尚未配置'}
|
{rules.length > 0 ? `${rules.length} 条规则` : '尚未配置'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -65,9 +81,9 @@ export function ScheduledReportRuleList({
|
|||||||
|
|
||||||
<div className="automation-scheduled-toolbar">
|
<div className="automation-scheduled-toolbar">
|
||||||
<Button onClick={onCreate}>+ 新建定时日报</Button>
|
<Button onClick={onCreate}>+ 新建定时日报</Button>
|
||||||
{tasks.length > 0 ? (
|
{rules.length > 0 ? (
|
||||||
<span className="automation-section-note">
|
<span className="automation-section-note">
|
||||||
运行中 {runningCount} / {tasks.length}
|
运行中 {runningCount} / {rules.length}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -77,83 +93,106 @@ export function ScheduledReportRuleList({
|
|||||||
<Spinner />
|
<Spinner />
|
||||||
<span>正在读取定时日报…</span>
|
<span>正在读取定时日报…</span>
|
||||||
</div>
|
</div>
|
||||||
) : tasks.length === 0 ? (
|
) : rules.length === 0 ? (
|
||||||
<div className="automation-empty-card">
|
<div className="automation-empty-card">
|
||||||
<p>还没有定时日报</p>
|
<p>还没有定时日报</p>
|
||||||
<p>创建一条自动化,让 TraceMemo 在指定时间生成并发送日报。</p>
|
<p>创建一条自动化,让 TraceMemo 在指定时间生成并发送日报。</p>
|
||||||
<Button onClick={onCreate}>+ 新建定时日报</Button>
|
<Button onClick={onCreate}>+ 新建定时日报</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
tasks.map((task) => (
|
rules.map((rule) => {
|
||||||
<article
|
const config = normalizeScheduledReportConfig(rule.scheduledReport)
|
||||||
key={task.id}
|
const needsReview = config.targetNeedsReview === true
|
||||||
className={`automation-rule-card ${task.enabled ? '' : 'disabled'}`}
|
const busy = busyRuleId === rule.id
|
||||||
>
|
return (
|
||||||
<div className="automation-rule-card-main">
|
<article
|
||||||
<div className="automation-rule-card-title">
|
key={rule.id}
|
||||||
<h3>{task.name}</h3>
|
className={`automation-rule-card ${rule.enabled ? '' : 'disabled'}`}
|
||||||
{/* 状态只出现两处:这个 badge + 右侧 toggle,不做第三重表达。 */}
|
>
|
||||||
<span className={`automation-rule-state ${task.enabled ? 'on' : 'off'}`}>
|
<div className="automation-rule-card-main">
|
||||||
{task.enabled ? '运行中' : '已暂停'}
|
<div className="automation-rule-card-title">
|
||||||
</span>
|
<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>
|
</div>
|
||||||
<dl className="automation-rule-meta">
|
<div className="automation-rule-card-side">
|
||||||
<div>
|
<Switch
|
||||||
<dt>触发</dt>
|
checked={rule.enabled}
|
||||||
<dd>每天 {task.scheduleTime}</dd>
|
disabled={busy}
|
||||||
</div>
|
onCheckedChange={(checked) => onToggle(rule, checked)}
|
||||||
<div>
|
aria-label={`${rule.name} 启停`}
|
||||||
<dt>日报</dt>
|
/>
|
||||||
<dd>{scheduledReportRangeLabel(task.reportRange)}</dd>
|
<Button
|
||||||
</div>
|
variant="outline"
|
||||||
<div>
|
size="sm"
|
||||||
<dt>来源</dt>
|
disabled={busy || needsReview}
|
||||||
<dd>{resolveGroupDisplay(task.group) || '未设置'}</dd>
|
onClick={() => onRunNow(rule)}
|
||||||
</div>
|
>
|
||||||
<div>
|
立即执行
|
||||||
<dt>模板</dt>
|
</Button>
|
||||||
<dd>{scheduledReportTemplateLabel(task.templateId)}</dd>
|
<Button variant="outline" size="sm" disabled={busy} onClick={() => onEdit(rule)}>
|
||||||
</div>
|
编辑
|
||||||
<div>
|
</Button>
|
||||||
<dt>发送到</dt>
|
<Button
|
||||||
{/* 旧任务的 target 恒等于来源群;这时用语义文案比重复群名清楚。 */}
|
variant="link"
|
||||||
<dd>
|
size="sm"
|
||||||
{!task.target || task.target === task.group
|
disabled={busy}
|
||||||
? '日报来源群'
|
onClick={() => onDelete(rule)}
|
||||||
: resolveGroupDisplay(task.target)}
|
>
|
||||||
</dd>
|
删除
|
||||||
</div>
|
</Button>
|
||||||
<div>
|
</div>
|
||||||
<dt>下次执行</dt>
|
</article>
|
||||||
<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>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 消息驱动的规则不在这里维护;显式说明,避免用户以为它消失了。 */}
|
||||||
|
<p className="automation-section-footnote">
|
||||||
|
消息驱动的「@我生成日报」规则在上一个类型里维护;这里只列出时间驱动的定时日报。
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,8 +2,17 @@ import type {
|
|||||||
AutomationExecution,
|
AutomationExecution,
|
||||||
AutomationRule,
|
AutomationRule,
|
||||||
AutomationRuleDraft,
|
AutomationRuleDraft,
|
||||||
AutomationStatusSummary
|
AutomationStatusSummary,
|
||||||
|
ScheduledRuleRunOutcome
|
||||||
} from '../../../../../shared/automation'
|
} from '../../../../../shared/automation'
|
||||||
|
import type {
|
||||||
|
ScheduledReportExecution,
|
||||||
|
ScheduledReportNotification,
|
||||||
|
ScheduledReportNotificationCapability,
|
||||||
|
ScheduledReportNotificationSettings,
|
||||||
|
ScheduledReportNotificationSettingsResult,
|
||||||
|
ScheduledReportResult
|
||||||
|
} from '../../../../../shared/scheduled-report'
|
||||||
import type { GroupExitMonitorState } from '../../../../../shared/group-exit-monitor'
|
import type { GroupExitMonitorState } from '../../../../../shared/group-exit-monitor'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -110,7 +119,7 @@ export const automationApi = {
|
|||||||
/**
|
/**
|
||||||
* 真实已监控群聊数量。
|
* 真实已监控群聊数量。
|
||||||
*
|
*
|
||||||
* 数据源是**退群监控**,自动化不维护副本(§「Automation 不做二次 group filter」)。
|
* 数据源是**退群监控**,自动化不维护副本(不做二次群过滤)。
|
||||||
* 取数与退群监控页保持同一表达式,否则两个页面会对同一个数字给出不同答案。
|
* 取数与退群监控页保持同一表达式,否则两个页面会对同一个数字给出不同答案。
|
||||||
*/
|
*/
|
||||||
async getMonitoredGroupCount(): Promise<number> {
|
async getMonitoredGroupCount(): Promise<number> {
|
||||||
@@ -118,5 +127,73 @@ export const automationApi = {
|
|||||||
if (!state) return 0
|
if (!state) return 0
|
||||||
if (state.monitorSelectionConfigured) return (state.monitoredRoomIds || []).length
|
if (state.monitorSelectionConfigured) return (state.monitoredRoomIds || []).length
|
||||||
return Number(state.monitoredGroupCount) || 0
|
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
|
onOpenSendSettings?: () => void
|
||||||
/** 深链请求:直接落到「退群监控 → 管理群聊」。 */
|
/** 深链请求:直接落到「退群监控 → 管理群聊」。 */
|
||||||
openViewRequest?: GroupExitMonitorOpenViewRequest | null
|
openViewRequest?: GroupExitMonitorOpenViewRequest | null
|
||||||
|
/**
|
||||||
|
* 深链请求已被消费(父层据此把它清掉)。
|
||||||
|
*
|
||||||
|
* **不给这个回调,深链就会变成常驻状态**:本组件在切换一级菜单时会被卸载,
|
||||||
|
* 而请求还挂在父层,于是下次正常点「退群监控」也会被重放,直接落到「管理群聊」。
|
||||||
|
*/
|
||||||
|
onOpenViewRequestHandled?: () => void
|
||||||
/** 「退群通知自动化 →」:跳到自动化里对应的规则类型(纯导航)。 */
|
/** 「退群通知自动化 →」:跳到自动化里对应的规则类型(纯导航)。 */
|
||||||
onOpenLeaveNotificationAutomation?: () => void
|
onOpenLeaveNotificationAutomation?: () => void
|
||||||
}
|
}
|
||||||
@@ -307,6 +314,7 @@ export function GroupExitMonitorWorkspace({
|
|||||||
contacts = [],
|
contacts = [],
|
||||||
onOpenSendSettings,
|
onOpenSendSettings,
|
||||||
openViewRequest,
|
openViewRequest,
|
||||||
|
onOpenViewRequestHandled,
|
||||||
onOpenLeaveNotificationAutomation
|
onOpenLeaveNotificationAutomation
|
||||||
}: GroupExitMonitorWorkspaceProps): React.ReactElement {
|
}: GroupExitMonitorWorkspaceProps): React.ReactElement {
|
||||||
const [state, setState] = React.useState<GroupExitMonitorState>(EMPTY_STATE)
|
const [state, setState] = React.useState<GroupExitMonitorState>(EMPTY_STATE)
|
||||||
@@ -383,11 +391,19 @@ export function GroupExitMonitorWorkspace({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 「自动化 → 退群通知 → 管理监控群聊」深链过来时,直接落到管理群聊页。
|
/**
|
||||||
|
* 「自动化 → 退群通知 → 管理监控群聊」深链过来时,直接落到管理群聊页。
|
||||||
|
*
|
||||||
|
* 请求是**一次性的**:落位之后立刻回报父层把它清掉。以前只 `setView` 不回报,
|
||||||
|
* 请求就一直留在父层;而 `renderCurrentWorkspace()` 是 switch,切一级菜单会卸载
|
||||||
|
* 本组件 —— 于是下次正常点「退群监控」时,这条陈旧的 `{view:'manage'}` 被重放,
|
||||||
|
* 用户看到的就是"点退群监控却进了管理群聊"。
|
||||||
|
*/
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!openViewRequest) return
|
if (!openViewRequest) return
|
||||||
setView(openViewRequest.view)
|
setView(openViewRequest.view)
|
||||||
}, [openViewRequest])
|
onOpenViewRequestHandled?.()
|
||||||
|
}, [openViewRequest, onOpenViewRequestHandled])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 「复制退群信息」用的模板。
|
* 「复制退群信息」用的模板。
|
||||||
@@ -631,7 +647,7 @@ export function GroupExitMonitorWorkspace({
|
|||||||
<SendCapabilityStatus capability={sendCapability} />
|
<SendCapabilityStatus capability={sendCapability} />
|
||||||
{/*
|
{/*
|
||||||
发送能力不完整时给一个可操作的去处。
|
发送能力不完整时给一个可操作的去处。
|
||||||
§「规则仍然可以保存」:这里只提示,不阻断任何编辑。
|
这里只提示,不阻断任何编辑。
|
||||||
*/}
|
*/}
|
||||||
{sendCapability &&
|
{sendCapability &&
|
||||||
!(sendCapability.ready && sendCapability.capabilities?.text) &&
|
!(sendCapability.ready && sendCapability.capabilities?.text) &&
|
||||||
|
|||||||
@@ -1243,7 +1243,8 @@
|
|||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只读的「已监控群聊」摘要:范围归退群监控管,这里不重复维护一份选择状态。
|
// 只读的「已监控群聊」摘要:监控范围归退群监控管,这里不重复维护一份选择状态。
|
||||||
|
// (本规则只管**二次勾选**:下面 .automation-leave-scope 那一层。)
|
||||||
.automation-leave-groups-card {
|
.automation-leave-groups-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
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 {
|
.automation-leave-targets {
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
|||||||
@@ -72,6 +72,33 @@
|
|||||||
flex: 1 1 auto;
|
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-page,
|
||||||
.report-workspace-body > .report-center-page {
|
.report-workspace-body > .report-center-page {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -55,10 +55,10 @@ export function matchAutomationRule(
|
|||||||
): AutomationMatchResult {
|
): AutomationMatchResult {
|
||||||
// **规则类型闸门放在最前面。**
|
// **规则类型闸门放在最前面。**
|
||||||
//
|
//
|
||||||
// 「退群通知」的空关键词 + 不要求 @我,会让它命中**每一条群消息** ——
|
// 「退群通知」与「定时日报」的空关键词 + 不要求 @我,会让它们命中**每一条群消息** ——
|
||||||
// 那是最坏的一类 bug:用户什么都没配,规则却开始乱跑。
|
// 那是最坏的一类 bug:用户什么都没配,规则却开始乱跑。
|
||||||
// 类型分派必须在这里一处解决,预览与真实执行才会一致。
|
// 类型分派必须在这里一处解决,预览与真实执行才会一致。
|
||||||
if (rule.ruleType === 'leave_notification') {
|
if (rule.ruleType !== 'daily_report') {
|
||||||
return { matched: false, reason: 'rule_type' }
|
return { matched: false, reason: 'rule_type' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+595
-22
@@ -6,6 +6,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { normalizeGroupExitNotificationTemplate } from './group-exit-monitor'
|
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'
|
export type AutomationTriggerType = 'message'
|
||||||
@@ -26,16 +35,18 @@ export type AutomationMessageScope = 'group' | 'direct' | 'all'
|
|||||||
*
|
*
|
||||||
* 决定**这条规则由什么驱动、有哪些字段有意义**:
|
* 决定**这条规则由什么驱动、有哪些字段有意义**:
|
||||||
* - `daily_report`:消息驱动,条件 + 动作链(@我生成日报);
|
* - `daily_report`:消息驱动,条件 + 动作链(@我生成日报);
|
||||||
|
* - `scheduled_report`:时间驱动,日程 + 日报配置 + 发送目标(定时日报);
|
||||||
* - `leave_notification`:退群事件驱动,通知目标 + 模板(退群通知)。
|
* - `leave_notification`:退群事件驱动,通知目标 + 模板(退群通知)。
|
||||||
*
|
*
|
||||||
* ⚠️ 这是一条**硬分派**:`matchAutomationRule` 会直接拒绝非 `daily_report` 的规则,
|
* ⚠️ 这是一条**硬分派**:`matchAutomationRule` 只放行 `daily_report`,
|
||||||
* 否则一条 keyword 为空的退群通知会命中**每一条群消息**。
|
* 否则一条 keyword 为空的定时/退群规则会命中**每一条群消息**。
|
||||||
*/
|
*/
|
||||||
export type AutomationRuleType = 'daily_report' | 'leave_notification'
|
export type AutomationRuleType = 'daily_report' | 'scheduled_report' | 'leave_notification'
|
||||||
|
|
||||||
/** 规则类型的界面标签(Tab 与首页卡片共用一处文案)。 */
|
/** 规则类型的界面标签(Tab 与首页卡片共用一处文案)。 */
|
||||||
export const AUTOMATION_RULE_TYPE_LABELS: Record<AutomationRuleType, string> = {
|
export const AUTOMATION_RULE_TYPE_LABELS: Record<AutomationRuleType, string> = {
|
||||||
daily_report: '@我生成日报',
|
daily_report: '@我生成日报',
|
||||||
|
scheduled_report: '定时日报',
|
||||||
leave_notification: '退群通知'
|
leave_notification: '退群通知'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,16 +63,350 @@ export interface LeaveNotificationTarget {
|
|||||||
contactId?: string
|
contactId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退群通知的**通知范围** —— 对「退群监控 → 管理群聊」选中集合的二次筛选。
|
||||||
|
*
|
||||||
|
* 旧版这里是 `notificationRoomIds`(per-group 多值):管理群聊选 N 个群,
|
||||||
|
* 通知群聊再从这 N 个里勾子集。迁移到自动化时被压成单值目标,二次筛选能力随之丢失,
|
||||||
|
* 于是"规则一启用就全量通知"。本字段把它恢复回来 —— 语义与旧版逐字对齐:
|
||||||
|
*
|
||||||
|
* - `all` = 全部已监控群聊。**旧规则缺字段时的归一化结果**,保持既有行为不变。
|
||||||
|
* - `selected` = 仅 `notifyRoomIds` 里的群。
|
||||||
|
*/
|
||||||
|
export type LeaveNotificationNotifyScope = 'all' | 'selected'
|
||||||
|
|
||||||
export interface LeaveNotificationConfig {
|
export interface LeaveNotificationConfig {
|
||||||
target: LeaveNotificationTarget
|
target: LeaveNotificationTarget
|
||||||
template: string
|
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,用户手动保存一次即清除。
|
* 只有迁移会把它置为 true,用户手动保存一次即清除。
|
||||||
*/
|
*/
|
||||||
targetNeedsReview?: boolean
|
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 {
|
export interface AutomationConditions {
|
||||||
@@ -110,6 +455,8 @@ export interface AutomationRule {
|
|||||||
replyDelaySeconds: number
|
replyDelaySeconds: number
|
||||||
/** `ruleType === 'leave_notification'` 时必填。 */
|
/** `ruleType === 'leave_notification'` 时必填。 */
|
||||||
leaveNotification?: LeaveNotificationConfig
|
leaveNotification?: LeaveNotificationConfig
|
||||||
|
/** `ruleType === 'scheduled_report'` 时必填。 */
|
||||||
|
scheduledReport?: ScheduledReportAutomationConfig
|
||||||
createdAt: number
|
createdAt: number
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
}
|
}
|
||||||
@@ -120,12 +467,18 @@ export type AutomationStepStatus = 'pending' | 'running' | 'success' | 'failed'
|
|||||||
/**
|
/**
|
||||||
* 执行步骤键。
|
* 执行步骤键。
|
||||||
*
|
*
|
||||||
* 分两族,**不共用**:
|
* 分三族,**不共用**:
|
||||||
* - 消息型(`daily_report`):received / matched / reply / report / send
|
* - 消息型(`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
|
* - 退群型(`leave_notification`):exit_received / exit_matched / exit_target / exit_send
|
||||||
*
|
*
|
||||||
* 不共用的理由:给退群通知渲染一个「回复确认 已跳过」的步骤是纯噪声,
|
* 不共用的理由:给定时日报渲染一个「回复确认 已跳过」的步骤是纯噪声,
|
||||||
* 用户会以为规则配错了。
|
* 用户会以为规则配错了。
|
||||||
|
*
|
||||||
|
* ⚠️ 定时型的步骤键**刻意拆开「生成中 / 已生成」与「确定目标 / 已发送」**:
|
||||||
|
* 「生成成功但发送失败」要求用户能一眼看出**日报确实生成了**,只是没发出去。
|
||||||
|
* 合成一步就没法表达这件事。
|
||||||
*/
|
*/
|
||||||
export type AutomationStepKey =
|
export type AutomationStepKey =
|
||||||
| 'received'
|
| 'received'
|
||||||
@@ -133,6 +486,11 @@ export type AutomationStepKey =
|
|||||||
| 'reply'
|
| 'reply'
|
||||||
| 'report'
|
| 'report'
|
||||||
| 'send'
|
| 'send'
|
||||||
|
| 'schedule_triggered'
|
||||||
|
| 'report_generating'
|
||||||
|
| 'report_generated'
|
||||||
|
| 'send_resolved'
|
||||||
|
| 'report_sent'
|
||||||
| 'exit_received'
|
| 'exit_received'
|
||||||
| 'exit_matched'
|
| 'exit_matched'
|
||||||
| 'exit_target'
|
| 'exit_target'
|
||||||
@@ -188,11 +546,33 @@ export interface AutomationStep {
|
|||||||
*/
|
*/
|
||||||
export type AutomationExecutionStatus = 'running' | 'success' | 'failed'
|
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 {
|
export interface AutomationExecution {
|
||||||
executionId: string
|
executionId: string
|
||||||
ruleId: string
|
ruleId: string
|
||||||
ruleName: string
|
ruleName: string
|
||||||
triggerTime: number
|
triggerTime: number
|
||||||
|
/** 触发方式。旧记录缺省(`undefined`)—— 读盘不猜测。 */
|
||||||
|
trigger?: AutomationExecutionTrigger
|
||||||
/**
|
/**
|
||||||
* 来源的**显示名**(群名 / 联系人昵称)。
|
* 来源的**显示名**(群名 / 联系人昵称)。
|
||||||
*
|
*
|
||||||
@@ -206,6 +586,31 @@ export interface AutomationExecution {
|
|||||||
errorSummary?: string
|
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`)。
|
* 发送用途标识(对应 `WechatActionPurpose`)。
|
||||||
*
|
*
|
||||||
@@ -225,7 +630,21 @@ export const AUTOMATION_SEND_PURPOSE = {
|
|||||||
* 的作用域锁绑死在「只能发回事件所在群」,而现在的目标可以是自己 / 文件传输助手 /
|
* 的作用域锁绑死在「只能发回事件所在群」,而现在的目标可以是自己 / 文件传输助手 /
|
||||||
* 指定好友。用新 purpose 才能真正解除那个锁,同时让旧 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
|
} as const
|
||||||
|
|
||||||
/** 审计日志里的来源标识(`WechatActionOrigin`)。 */
|
/** 审计日志里的来源标识(`WechatActionOrigin`)。 */
|
||||||
@@ -286,6 +705,8 @@ export interface AutomationRuleDraft {
|
|||||||
replyDelaySeconds: number
|
replyDelaySeconds: number
|
||||||
/** `ruleType === 'leave_notification'` 时的通知配置。 */
|
/** `ruleType === 'leave_notification'` 时的通知配置。 */
|
||||||
leaveNotification?: LeaveNotificationConfig
|
leaveNotification?: LeaveNotificationConfig
|
||||||
|
/** `ruleType === 'scheduled_report'` 时的日报配置。 */
|
||||||
|
scheduledReport?: ScheduledReportAutomationConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 顶部状态条数据(全部来自 main 侧真实能力,UI 不做平台判断)。 */
|
/** 顶部状态条数据(全部来自 main 侧真实能力,UI 不做平台判断)。 */
|
||||||
@@ -310,27 +731,20 @@ export interface AutomationStatusSummary {
|
|||||||
/**
|
/**
|
||||||
* 规则列表里的**模板入口**。
|
* 规则列表里的**模板入口**。
|
||||||
*
|
*
|
||||||
* `available: false` 表示该能力本轮**尚未接通** —— UI 必须显示为「未启用 / 即将支持」,
|
* `available: false` 表示该能力**尚未接通** —— UI 必须显示为「未启用 / 即将支持」,
|
||||||
* 不允许伪装成可运行。这是防止设计稿倒逼假数据的硬约束。
|
* 不得伪装成可运行;否则设计稿会倒逼出假数据。
|
||||||
*/
|
*/
|
||||||
export interface AutomationRuleTemplate {
|
export interface AutomationRuleTemplate {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
/** 本轮是否已接通的真实能力。 */
|
/** 该能力是否已经真正接通。 */
|
||||||
available: boolean
|
available: boolean
|
||||||
/** 未接通时的说明文案。 */
|
/** 未接通时的说明文案。 */
|
||||||
unavailableReason?: string
|
unavailableReason?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AUTOMATION_RULE_TEMPLATES: AutomationRuleTemplate[] = [
|
export const AUTOMATION_RULE_TEMPLATES: AutomationRuleTemplate[] = [
|
||||||
{
|
|
||||||
id: 'scheduled-report',
|
|
||||||
name: '定时发送日报',
|
|
||||||
description: '按固定时间自动生成并发送群日报',
|
|
||||||
available: false,
|
|
||||||
unavailableReason: ''
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'keyword-reply',
|
id: 'keyword-reply',
|
||||||
name: '关键词自动回复',
|
name: '关键词自动回复',
|
||||||
@@ -360,6 +774,11 @@ export const AUTOMATION_STEP_LABELS: Record<AutomationStepKey, string> = {
|
|||||||
reply: '回复确认',
|
reply: '回复确认',
|
||||||
report: '生成日报',
|
report: '生成日报',
|
||||||
send: '发送日报图片',
|
send: '发送日报图片',
|
||||||
|
schedule_triggered: '定时任务触发',
|
||||||
|
report_generating: '生成日报',
|
||||||
|
report_generated: '日报已生成',
|
||||||
|
send_resolved: '确定发送目标',
|
||||||
|
report_sent: '发送日报',
|
||||||
exit_received: '检测到成员退出',
|
exit_received: '检测到成员退出',
|
||||||
exit_matched: '规则匹配',
|
exit_matched: '规则匹配',
|
||||||
exit_target: '确定通知目标',
|
exit_target: '确定通知目标',
|
||||||
@@ -450,13 +869,41 @@ export const LEAVE_NOTIFICATION_TARGET_OPTIONS: ReadonlyArray<{
|
|||||||
*/
|
*/
|
||||||
export const LEAVE_NOTIFICATION_DEFAULT_TARGET_TYPE: LeaveNotificationTargetType = 'source_chat'
|
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 {
|
export function leaveNotificationTargetLabel(type: LeaveNotificationTargetType): string {
|
||||||
return LEAVE_NOTIFICATION_TARGET_OPTIONS.find((option) => option.type === type)?.label ?? ''
|
return LEAVE_NOTIFICATION_TARGET_OPTIONS.find((option) => option.type === type)?.label ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 「范围」摘要:退群通知覆盖多少个已监控群聊。
|
* 「范围」摘要:退群监控覆盖多少个已监控群聊。
|
||||||
*
|
*
|
||||||
* 数字**必须**来自退群监控(`GroupExitMonitorState`),自动化不维护副本。
|
* 数字**必须**来自退群监控(`GroupExitMonitorState`),自动化不维护副本。
|
||||||
*/
|
*/
|
||||||
@@ -464,6 +911,40 @@ export function describeMonitoredScope(monitoredCount: number): string {
|
|||||||
return `${Math.max(0, Math.floor(Number(monitoredCount) || 0))} 个已监控群聊`
|
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)
|
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)。 */
|
/** 触发条件的一句话摘要(UI 展示用,不含任何内部 id)。 */
|
||||||
export function describeRuleTrigger(rule: AutomationRule): string {
|
export function describeRuleTrigger(rule: AutomationRule): string {
|
||||||
if (rule.ruleType === 'leave_notification') return '检测到群成员退出'
|
if (rule.ruleType === 'leave_notification') return '检测到群成员退出'
|
||||||
|
if (rule.ruleType === 'scheduled_report') {
|
||||||
|
const time = rule.scheduledReport?.schedule?.time
|
||||||
|
return time ? `每天 ${time}` : '按设定时间'
|
||||||
|
}
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
if (rule.conditions.requireMentionMe) parts.push('@我')
|
if (rule.conditions.requireMentionMe) parts.push('@我')
|
||||||
if (rule.conditions.keyword.trim()) {
|
if (rule.conditions.keyword.trim()) {
|
||||||
@@ -494,6 +990,11 @@ export function describeRuleTrigger(rule: AutomationRule): string {
|
|||||||
/** 动作链的一句话摘要。 */
|
/** 动作链的一句话摘要。 */
|
||||||
export function describeRuleActions(rule: AutomationRule): string {
|
export function describeRuleActions(rule: AutomationRule): string {
|
||||||
if (rule.ruleType === 'leave_notification') return '发送退群通知'
|
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> = {
|
const labels: Record<AutomationActionType, string> = {
|
||||||
replyText: '回复确认',
|
replyText: '回复确认',
|
||||||
generateReport: '生成日报',
|
generateReport: '生成日报',
|
||||||
@@ -550,6 +1051,8 @@ export function createDefaultLeaveNotificationRule(
|
|||||||
target?: LeaveNotificationTarget
|
target?: LeaveNotificationTarget
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
targetNeedsReview?: boolean
|
targetNeedsReview?: boolean
|
||||||
|
notifyScope?: LeaveNotificationNotifyScope
|
||||||
|
notifyRoomIds?: string[]
|
||||||
} = {}
|
} = {}
|
||||||
): AutomationRule {
|
): AutomationRule {
|
||||||
return {
|
return {
|
||||||
@@ -576,6 +1079,9 @@ export function createDefaultLeaveNotificationRule(
|
|||||||
leaveNotification: {
|
leaveNotification: {
|
||||||
target: options.target ?? { type: LEAVE_NOTIFICATION_DEFAULT_TARGET_TYPE },
|
target: options.target ?? { type: LEAVE_NOTIFICATION_DEFAULT_TARGET_TYPE },
|
||||||
template: normalizeLeaveNotificationTemplate(options.template),
|
template: normalizeLeaveNotificationTemplate(options.template),
|
||||||
|
// 新装 / 无历史配置 = 全部已监控群聊都通知(与旧版"每个群都勾上"等价)。
|
||||||
|
notifyScope: options.notifyScope ?? LEAVE_NOTIFICATION_DEFAULT_NOTIFY_SCOPE,
|
||||||
|
notifyRoomIds: normalizeLeaveNotificationRoomIds(options.notifyRoomIds),
|
||||||
...(options.targetNeedsReview ? { targetNeedsReview: true } : {})
|
...(options.targetNeedsReview ? { targetNeedsReview: true } : {})
|
||||||
},
|
},
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
@@ -593,6 +1099,59 @@ export function normalizeLeaveNotificationTemplate(value: unknown): string {
|
|||||||
return normalizeGroupExitNotificationTemplate(value)
|
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(
|
export function normalizeRuleDraft(
|
||||||
input: unknown,
|
input: unknown,
|
||||||
@@ -601,7 +1160,11 @@ export function normalizeRuleDraft(
|
|||||||
const raw = (input ?? {}) as Partial<AutomationRuleDraft>
|
const raw = (input ?? {}) as Partial<AutomationRuleDraft>
|
||||||
const conditions = (raw.conditions ?? {}) as Partial<AutomationConditions>
|
const conditions = (raw.conditions ?? {}) as Partial<AutomationConditions>
|
||||||
const ruleType: AutomationRuleType =
|
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 =
|
const keywordMatchMode: KeywordMatchMode =
|
||||||
conditions.keywordMatchMode === 'exact' || conditions.keywordMatchMode === 'prefix'
|
conditions.keywordMatchMode === 'exact' || conditions.keywordMatchMode === 'prefix'
|
||||||
? conditions.keywordMatchMode
|
? conditions.keywordMatchMode
|
||||||
@@ -642,8 +1205,13 @@ export function normalizeRuleDraft(
|
|||||||
cooldownSeconds: Number.isFinite(cooldown) ? Math.max(0, Math.floor(cooldown)) : 60,
|
cooldownSeconds: Number.isFinite(cooldown) ? Math.max(0, Math.floor(cooldown)) : 60,
|
||||||
replyDelaySeconds: normalizeReplyDelaySeconds(raw.replyDelaySeconds)
|
replyDelaySeconds: normalizeReplyDelaySeconds(raw.replyDelaySeconds)
|
||||||
}
|
}
|
||||||
if (ruleType !== 'leave_notification') return base
|
if (ruleType === 'leave_notification') {
|
||||||
return { ...base, leaveNotification: normalizeLeaveNotificationConfig(raw.leaveNotification) }
|
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 时同样回落,避免存下一条永远发不出去的规则。
|
* `contact` 缺 contactId 时同样回落,避免存下一条永远发不出去的规则。
|
||||||
|
*
|
||||||
|
* `notifyScope` 缺失 → 回落 `all`(**不是** `selected`):已经落盘的规则
|
||||||
|
* 都是在"没有二次筛选"的版本下保存的,默认成 `selected` 会让它们静默停发。
|
||||||
*/
|
*/
|
||||||
export function normalizeLeaveNotificationConfig(input: unknown): LeaveNotificationConfig {
|
export function normalizeLeaveNotificationConfig(input: unknown): LeaveNotificationConfig {
|
||||||
const raw = (input ?? {}) as Partial<LeaveNotificationConfig>
|
const raw = (input ?? {}) as Partial<LeaveNotificationConfig>
|
||||||
@@ -672,6 +1243,8 @@ export function normalizeLeaveNotificationConfig(input: unknown): LeaveNotificat
|
|||||||
...(effectiveType === 'contact' ? { contactId } : {})
|
...(effectiveType === 'contact' ? { contactId } : {})
|
||||||
},
|
},
|
||||||
template: normalizeLeaveNotificationTemplate(raw.template),
|
template: normalizeLeaveNotificationTemplate(raw.template),
|
||||||
|
notifyScope: raw.notifyScope === 'selected' ? 'selected' : LEAVE_NOTIFICATION_DEFAULT_NOTIFY_SCOPE,
|
||||||
|
notifyRoomIds: normalizeLeaveNotificationRoomIds(raw.notifyRoomIds),
|
||||||
// 用户手动保存一次即视为已确认目标,清除迁移提示。
|
// 用户手动保存一次即视为已确认目标,清除迁移提示。
|
||||||
...(raw.targetNeedsReview === true ? { targetNeedsReview: true } : {})
|
...(raw.targetNeedsReview === true ? { targetNeedsReview: true } : {})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { render, screen, waitFor } from '@testing-library/react'
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
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 { 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 type { GroupExitMonitorState } from '../../src/shared/group-exit-monitor'
|
||||||
import { formatGroupExitMonitorTime } from '../../src/shared/group-exit-monitor'
|
import { formatGroupExitMonitorTime } from '../../src/shared/group-exit-monitor'
|
||||||
import type { Contact } from '../../src/shared/types'
|
import type { Contact } from '../../src/shared/types'
|
||||||
@@ -69,7 +72,7 @@ const groups: Contact[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
describe('GroupExitMonitorWorkspace', () => {
|
describe('GroupExitMonitorWorkspace', () => {
|
||||||
const renderWorkspace = (element: ReactElement): void => {
|
const renderWorkspace = (element: React.ReactElement): void => {
|
||||||
render(<TooltipProvider>{element}</TooltipProvider>)
|
render(<TooltipProvider>{element}</TooltipProvider>)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,4 +390,63 @@ describe('GroupExitMonitorWorkspace', () => {
|
|||||||
expect(screen.getByRole('checkbox', { name: '监控研发群' })).not.toBeChecked()
|
expect(screen.getByRole('checkbox', { name: '监控研发群' })).not.toBeChecked()
|
||||||
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(
|
function installApi(
|
||||||
overrides: { leaveRule?: AutomationRule } = {}
|
overrides: {
|
||||||
|
leaveRule?: AutomationRule
|
||||||
|
/** `listAutomationGroups` 的返回:把 roomId 映射成可读群名。 */
|
||||||
|
automationGroups?: Array<{ id: string; name: string }>
|
||||||
|
} = {}
|
||||||
): { spies: ApiSpies; rules: AutomationRule[] } {
|
): { spies: ApiSpies; rules: AutomationRule[] } {
|
||||||
const leaveRule = overrides.leaveRule ?? createDefaultLeaveNotificationRule(Date.now())
|
const leaveRule = overrides.leaveRule ?? createDefaultLeaveNotificationRule(Date.now())
|
||||||
const rules = [createDefaultDailyReportRule(Date.now()), leaveRule]
|
const rules = [createDefaultDailyReportRule(Date.now()), leaveRule]
|
||||||
@@ -92,7 +96,7 @@ function installApi(
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
listAutomationRules: spies.listAutomationRules,
|
listAutomationRules: spies.listAutomationRules,
|
||||||
listAutomationGroups: vi.fn().mockResolvedValue([]),
|
listAutomationGroups: vi.fn().mockResolvedValue(overrides.automationGroups ?? []),
|
||||||
listAutomationExecutions: vi.fn().mockResolvedValue([]),
|
listAutomationExecutions: vi.fn().mockResolvedValue([]),
|
||||||
listSendableContacts: spies.listSendableContacts,
|
listSendableContacts: spies.listSendableContacts,
|
||||||
getGroupExitMonitorState: vi.fn().mockResolvedValue(MONITOR_STATE),
|
getGroupExitMonitorState: vi.fn().mockResolvedValue(MONITOR_STATE),
|
||||||
@@ -128,6 +132,7 @@ async function leaveNotificationCard(): Promise<HTMLElement> {
|
|||||||
/** 从自动化首页那张退群通知卡片的「编辑」进编辑器。 */
|
/** 从自动化首页那张退群通知卡片的「编辑」进编辑器。 */
|
||||||
async function openLeaveNotificationEditor(overrides: {
|
async function openLeaveNotificationEditor(overrides: {
|
||||||
leaveRule?: AutomationRule
|
leaveRule?: AutomationRule
|
||||||
|
automationGroups?: Array<{ id: string; name: string }>
|
||||||
} = {}): Promise<{ spies: ApiSpies }> {
|
} = {}): Promise<{ spies: ApiSpies }> {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const { spies } = installApi(overrides)
|
const { spies } = installApi(overrides)
|
||||||
@@ -218,12 +223,17 @@ describe('退群通知 · 真实数据', () => {
|
|||||||
expect(screen.queryByRole('radio', { name: '张三' })).toBeNull()
|
expect(screen.queryByRole('radio', { name: '张三' })).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Section 1 没有「触发范围」,只有唯一的「管理监控群聊」入口', async () => {
|
/*
|
||||||
|
* 旧版「退群监控 → 通知群聊」的第二层。它曾在迁移里被删掉,
|
||||||
|
* 导致"规则一启用就全量通知" —— 所以这里锁的是它**必须存在**。
|
||||||
|
*/
|
||||||
|
it('Section 1 有「通知范围」二次勾选,且保留唯一的「管理监控群聊」入口', async () => {
|
||||||
await openLeaveNotificationEditor()
|
await openLeaveNotificationEditor()
|
||||||
|
|
||||||
|
// 「触发事件」仍不可配(由退群监控驱动),但这不等于不能筛群。
|
||||||
expect(screen.queryByText('触发范围')).toBeNull()
|
expect(screen.queryByText('触发范围')).toBeNull()
|
||||||
expect(screen.queryByText('全部已监控群聊')).toBeNull()
|
expect(screen.getByRole('radio', { name: '全部已监控群聊' })).toBeChecked()
|
||||||
expect(screen.queryByText('指定已监控群聊')).toBeNull()
|
expect(screen.getByRole('radio', { name: '仅选中的群聊' })).toBeEnabled()
|
||||||
expect(screen.getByRole('button', { 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('退群通知 · 四种目标', () => {
|
describe('退群通知 · 四种目标', () => {
|
||||||
it('是四选一,「当前群聊」排第一且默认选中', async () => {
|
it('是四选一,「当前群聊」排第一且默认选中', async () => {
|
||||||
await openLeaveNotificationEditor()
|
await openLeaveNotificationEditor()
|
||||||
@@ -479,7 +571,10 @@ describe('退群通知 · 群聊名护栏', () => {
|
|||||||
...createDefaultLeaveNotificationRule(Date.now()),
|
...createDefaultLeaveNotificationRule(Date.now()),
|
||||||
leaveNotification: {
|
leaveNotification: {
|
||||||
target: { type: 'source_chat' },
|
target: { type: 'source_chat' },
|
||||||
template: '[退群监测]\n\n用户: {user}'
|
template: '[退群监测]\n\n用户: {user}',
|
||||||
|
// 明确写全:本组用例只关心群名护栏,范围取默认的"全部已监控群聊"。
|
||||||
|
notifyScope: 'all',
|
||||||
|
notifyRoomIds: []
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -8,82 +8,95 @@ import {
|
|||||||
AutomationWorkspace,
|
AutomationWorkspace,
|
||||||
type AutomationOpenRuleRequest
|
type AutomationOpenRuleRequest
|
||||||
} from '../../src/renderer/src/features/automation/AutomationWorkspace'
|
} from '../../src/renderer/src/features/automation/AutomationWorkspace'
|
||||||
import { ScheduledReportsWorkspace } from '../../src/renderer/src/components/reports/ScheduledReportsWorkspace'
|
|
||||||
import {
|
import {
|
||||||
createDefaultDailyReportRule,
|
createDefaultDailyReportRule,
|
||||||
createDefaultLeaveNotificationRule
|
createDefaultLeaveNotificationRule,
|
||||||
|
createDefaultScheduledReportRule,
|
||||||
|
normalizeScheduledReportConfig,
|
||||||
|
type AutomationRule
|
||||||
} from '../../src/shared/automation'
|
} from '../../src/shared/automation'
|
||||||
import type { ScheduledReportTask } from '../../src/shared/scheduled-report'
|
import { SUMMARY_TYPE_OPTIONS } from '../../src/renderer/src/utils/group-report'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 「自动化 → 规则 → 定时日报」。
|
* 「自动化 → 规则 → 定时日报」。
|
||||||
*
|
*
|
||||||
* 这一组测试要守住的核心不是"界面长什么样",而是**边界**:
|
* 迁移前这一组测试守的是**边界**("任何写操作都不许落盘")。
|
||||||
* 任何写操作都不许落到真实定时日报系统上
|
* 迁移后 `scheduled_report` 已经是真实的自动化规则类型,边界随之反转:
|
||||||
* (create / update / delete / setEnabled / runNow / retry / 异常通知开关)。
|
* 现在要守的是**写真的发生、且写在正确的通道上**:
|
||||||
|
* - 保存 → `createAutomationRule` / `updateAutomationRule`,且 `ruleType === 'scheduled_report'`;
|
||||||
|
* - 立即执行 → `runScheduledReportRule`(与 scheduler 同一条链路);
|
||||||
|
* - 启停 → `setAutomationRuleEnabled`;
|
||||||
|
* - 删除 → `deleteAutomationRule`;
|
||||||
|
* - 且**没有任何** `scheduled-report:*` 旧通道残留。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const TASKS: ScheduledReportTask[] = [
|
const RULES_FIXTURE: AutomationRule[] = [
|
||||||
{
|
createDefaultScheduledReportRule(Date.now(), {
|
||||||
id: 'task-1',
|
id: 'rule-1',
|
||||||
name: 'TraceMemo 每日晚报',
|
name: 'TraceMemo 每日晚报',
|
||||||
group: 'TraceMemo 交流群',
|
config: normalizeScheduledReportConfig({
|
||||||
scheduleTime: '18:21',
|
schedule: { time: '18:21' },
|
||||||
reportRange: 'today',
|
report: {
|
||||||
target: 'TraceMemo 管理群',
|
sourceConversationId: 'g1@chatroom',
|
||||||
enabled: true,
|
range: 'today',
|
||||||
createdAt: '2026-09-01T00:00:00.000Z',
|
messageTypes: ['text', 'image'],
|
||||||
updatedAt: '2026-09-01T00:00:00.000Z',
|
templateId: 'v1',
|
||||||
nextRunAt: '2026-09-24T10:21:00.000Z'
|
memberNameMode: 'groupNickname',
|
||||||
},
|
timeoutSeconds: 300
|
||||||
{
|
},
|
||||||
id: 'task-2',
|
target: { type: 'source_chat' }
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
createDefaultScheduledReportRule(Date.now(), {
|
||||||
|
id: 'rule-2',
|
||||||
name: '技术交流群日报',
|
name: '技术交流群日报',
|
||||||
group: '技术交流群',
|
|
||||||
scheduleTime: '09:00',
|
|
||||||
reportRange: 'yesterday',
|
|
||||||
target: '技术交流群',
|
|
||||||
enabled: false,
|
enabled: false,
|
||||||
createdAt: '2026-09-01T00:00:00.000Z',
|
config: normalizeScheduledReportConfig({
|
||||||
updatedAt: '2026-09-01T00:00:00.000Z',
|
schedule: { time: '09:00' },
|
||||||
nextRunAt: '2026-09-25T01:00:00.000Z'
|
report: {
|
||||||
}
|
sourceConversationId: 'g2@chatroom',
|
||||||
|
range: 'yesterday',
|
||||||
|
templateId: 'v1',
|
||||||
|
memberNameMode: 'groupNickname',
|
||||||
|
timeoutSeconds: 300
|
||||||
|
},
|
||||||
|
target: { type: 'file_transfer' }
|
||||||
|
})
|
||||||
|
})
|
||||||
]
|
]
|
||||||
|
|
||||||
interface Spies {
|
interface Spies {
|
||||||
createScheduledReport: ReturnType<typeof vi.fn>
|
createRule: ReturnType<typeof vi.fn>
|
||||||
updateScheduledReport: ReturnType<typeof vi.fn>
|
updateRule: ReturnType<typeof vi.fn>
|
||||||
deleteScheduledReport: ReturnType<typeof vi.fn>
|
deleteRule: ReturnType<typeof vi.fn>
|
||||||
setScheduledReportEnabled: ReturnType<typeof vi.fn>
|
setEnabled: ReturnType<typeof vi.fn>
|
||||||
runScheduledReportNow: ReturnType<typeof vi.fn>
|
runScheduled: ReturnType<typeof vi.fn>
|
||||||
retryScheduledReportSend: ReturnType<typeof vi.fn>
|
setNotification: ReturnType<typeof vi.fn>
|
||||||
testScheduledReportErrorNotification: ReturnType<typeof vi.fn>
|
|
||||||
listScheduledReports: ReturnType<typeof vi.fn>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 所有"写操作"的集合:任何一条被调用都说明界面越界了。 */
|
function installApi(rules: AutomationRule[] = RULES_FIXTURE): Spies {
|
||||||
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 {
|
|
||||||
const spies: Spies = {
|
const spies: Spies = {
|
||||||
listScheduledReports: vi.fn().mockResolvedValue(tasks),
|
createRule: vi.fn().mockImplementation((draft: unknown) =>
|
||||||
createScheduledReport: vi.fn(),
|
Promise.resolve({
|
||||||
updateScheduledReport: vi.fn(),
|
...(draft as AutomationRule),
|
||||||
deleteScheduledReport: vi.fn(),
|
id: 'rule-new',
|
||||||
setScheduledReportEnabled: vi.fn(),
|
createdAt: Date.now(),
|
||||||
runScheduledReportNow: vi.fn(),
|
updatedAt: Date.now()
|
||||||
retryScheduledReportSend: vi.fn(),
|
})
|
||||||
testScheduledReportErrorNotification: vi.fn()
|
),
|
||||||
|
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 = {
|
window.api = {
|
||||||
@@ -102,12 +115,17 @@ function installApi(tasks: ScheduledReportTask[] = TASKS): Spies {
|
|||||||
}),
|
}),
|
||||||
listAutomationRules: vi.fn().mockResolvedValue([
|
listAutomationRules: vi.fn().mockResolvedValue([
|
||||||
createDefaultDailyReportRule(Date.now()),
|
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([
|
listAutomationGroups: vi.fn().mockResolvedValue([
|
||||||
{ id: 'g1', name: 'TraceMemo 交流群' },
|
{ id: 'g1@chatroom', name: 'TraceMemo 交流群' },
|
||||||
{ id: 'g2', name: '技术交流群' },
|
{ id: 'g2@chatroom', name: '技术交流群' },
|
||||||
{ id: 'g3', name: 'TraceMemo 管理群' }
|
{ id: 'g3@chatroom', name: 'TraceMemo 管理群' }
|
||||||
]),
|
]),
|
||||||
listAutomationExecutions: vi.fn().mockResolvedValue([]),
|
listAutomationExecutions: vi.fn().mockResolvedValue([]),
|
||||||
listSendableContacts: vi.fn().mockResolvedValue([]),
|
listSendableContacts: vi.fn().mockResolvedValue([]),
|
||||||
@@ -123,11 +141,15 @@ function installApi(tasks: ScheduledReportTask[] = TASKS): Spies {
|
|||||||
unreadCount: 0
|
unreadCount: 0
|
||||||
}),
|
}),
|
||||||
onGroupExitMonitorState: vi.fn(() => () => undefined),
|
onGroupExitMonitorState: vi.fn(() => () => undefined),
|
||||||
// 旧「日报 → 定时日报」页面 mount 时会读这几项;只读、与预览无关。
|
runScheduledReportRule: spies.runScheduled,
|
||||||
listScheduledReportExecutions: vi.fn().mockResolvedValue([]),
|
listScheduledReportLegacyExecutions: vi.fn().mockResolvedValue([]),
|
||||||
getPersonalWechatSendCapability: vi.fn().mockResolvedValue(null),
|
|
||||||
getScheduledReportNotificationSettings: vi.fn().mockResolvedValue({ enabled: false }),
|
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
|
} as unknown as typeof window.api
|
||||||
|
|
||||||
return spies
|
return spies
|
||||||
@@ -174,11 +196,29 @@ describe('自动化 · 规则类型 Tab(定时日报)', () => {
|
|||||||
expect(await screen.findByRole('heading', { name: '定时日报' })).toBeVisible()
|
expect(await screen.findByRole('heading', { name: '定时日报' })).toBeVisible()
|
||||||
expect(screen.getByText('按设定时间自动生成日报,并发送到指定微信会话。')).toBeVisible()
|
expect(screen.getByText('按设定时间自动生成日报,并发送到指定微信会话。')).toBeVisible()
|
||||||
expect(screen.getByText('2 条规则')).toBeVisible()
|
expect(screen.getByText('2 条规则')).toBeVisible()
|
||||||
// 列表态不该出现编辑器的保存按钮。
|
|
||||||
expect(screen.queryByRole('button', { name: '保存' })).toBeNull()
|
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()
|
installApi()
|
||||||
renderWorkspace()
|
renderWorkspace()
|
||||||
await openScheduledTab()
|
await openScheduledTab()
|
||||||
@@ -194,41 +234,53 @@ describe('自动化 · 规则类型 Tab(定时日报)', () => {
|
|||||||
['日报', '今日'],
|
['日报', '今日'],
|
||||||
['来源', 'TraceMemo 交流群'],
|
['来源', 'TraceMemo 交流群'],
|
||||||
['模板', '经典日报'],
|
['模板', '经典日报'],
|
||||||
// target 与来源群不同 ⇒ 显示 target 的群名;相同时会显示「日报来源群」。
|
['发送到', '发送到日报来源群(TraceMemo 交流群)'],
|
||||||
['发送到', 'TraceMemo 管理群'],
|
|
||||||
['下次执行', expect.any(String)]
|
['下次执行', expect.any(String)]
|
||||||
])
|
])
|
||||||
// 暂停的任务下一次执行显示破折号,而不是一个会误导的时间。
|
|
||||||
|
// 暂停的规则不再给出"下次执行"时间,避免误导。
|
||||||
const paused = (await screen.findByText('技术交流群日报')).closest('article') as HTMLElement
|
const paused = (await screen.findByText('技术交流群日报')).closest('article') as HTMLElement
|
||||||
expect(paused.textContent).toContain('已暂停')
|
expect(paused.textContent).toContain('已暂停')
|
||||||
expect(within(paused).getByText('—')).toBeVisible()
|
expect(within(paused).getByText('—')).toBeVisible()
|
||||||
// 这条的 target 与来源群相同 ⇒ 用「日报来源群」这种语义文案,而不是重复群名。
|
expect(paused.textContent).toContain('文件传输助手')
|
||||||
expect(paused.textContent).toContain('日报来源群')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('定时日报 · 编辑器与预览联动', () => {
|
describe('定时日报 · 真写入', () => {
|
||||||
it('点「+ 新建定时日报」进入编辑器(三段 + 预览)', async () => {
|
it('保存新规则 → createAutomationRule,且 ruleType 与完整配置都对', async () => {
|
||||||
installApi()
|
const spies = installApi()
|
||||||
renderWorkspace()
|
renderWorkspace()
|
||||||
await openScheduledTab()
|
await openScheduledTab()
|
||||||
|
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
|
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
|
||||||
|
|
||||||
expect(screen.getByRole('heading', { name: '1 · 什么时候触发' })).toBeVisible()
|
await user.type(screen.getByLabelText('定时日报任务名称'), '新的定时日报')
|
||||||
expect(screen.getByRole('heading', { name: '2 · 生成什么日报' })).toBeVisible()
|
await user.click(
|
||||||
expect(screen.getByRole('heading', { name: '3 · 生成后发送到哪里' })).toBeVisible()
|
within(screen.getByRole('radiogroup', { name: '日报来源' })).getByRole('radio', {
|
||||||
expect(screen.getByRole('heading', { name: '效果预览' })).toBeVisible()
|
name: '技术交流群'
|
||||||
// 只有一个开关:规则级启停。
|
})
|
||||||
expect(screen.getAllByRole('switch')).toHaveLength(1)
|
)
|
||||||
|
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 () => {
|
it('编辑既有规则 → updateAutomationRule(不是新建)', async () => {
|
||||||
installApi()
|
const spies = installApi()
|
||||||
renderWorkspace()
|
renderWorkspace()
|
||||||
await openScheduledTab()
|
await openScheduledTab()
|
||||||
|
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement
|
const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement
|
||||||
await user.click(within(card).getByRole('button', { name: '编辑' }))
|
await user.click(within(card).getByRole('button', { name: '编辑' }))
|
||||||
@@ -236,103 +288,31 @@ describe('定时日报 · 编辑器与预览联动', () => {
|
|||||||
expect(screen.getByLabelText('定时日报任务名称')).toHaveValue('TraceMemo 每日晚报')
|
expect(screen.getByLabelText('定时日报任务名称')).toHaveValue('TraceMemo 每日晚报')
|
||||||
expect(screen.getByLabelText('执行时间小时')).toHaveValue('18')
|
expect(screen.getByLabelText('执行时间小时')).toHaveValue('18')
|
||||||
expect(screen.getByLabelText('执行时间分钟')).toHaveValue('21')
|
expect(screen.getByLabelText('执行时间分钟')).toHaveValue('21')
|
||||||
expect(
|
|
||||||
within(screen.getByRole('radiogroup', { name: '日报来源' })).getByRole('radio', {
|
await user.click(screen.getByRole('button', { name: '保存' }))
|
||||||
name: 'TraceMemo 交流群'
|
await waitFor(() => expect(spies.updateRule).toHaveBeenCalledTimes(1))
|
||||||
})
|
expect(spies.updateRule.mock.calls[0][0]).toBe('rule-1')
|
||||||
).toBeChecked()
|
expect(spies.createRule).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
/*
|
it('缺名称 / 缺来源群时**不允许保存**,并说明原因', async () => {
|
||||||
* 任务里存的群名与群列表是两个数据源,不保证一致
|
|
||||||
* (群被移出监控、或列表来源变了)。这时**必须回显当前值**,
|
|
||||||
* 否则编辑已有任务会出现"一串群里没有任何一项被选中"。
|
|
||||||
*/
|
|
||||||
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 () => {
|
|
||||||
const spies = installApi()
|
const spies = installApi()
|
||||||
renderWorkspace()
|
renderWorkspace()
|
||||||
await openScheduledTab()
|
await openScheduledTab()
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
|
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
|
||||||
await user.click(screen.getByRole('button', { name: '保存' }))
|
|
||||||
|
|
||||||
expect(await screen.findByText('UI 预览模式,配置暂未保存')).toBeVisible()
|
expect(screen.getByTestId('scheduled-save-blocker')).toHaveTextContent('请填写任务名称')
|
||||||
for (const [name, spy] of writeSpies(spies)) {
|
expect(screen.getByRole('button', { name: '保存' })).toBeDisabled()
|
||||||
expect(spy, `${name} 不该被调用`).not.toHaveBeenCalled()
|
|
||||||
}
|
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()
|
const spies = installApi()
|
||||||
renderWorkspace()
|
renderWorkspace()
|
||||||
await openScheduledTab()
|
await openScheduledTab()
|
||||||
@@ -340,13 +320,10 @@ describe('定时日报 · 编辑器与预览联动', () => {
|
|||||||
const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement
|
const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement
|
||||||
await user.click(within(card).getByRole('button', { name: '立即执行' }))
|
await user.click(within(card).getByRole('button', { name: '立即执行' }))
|
||||||
|
|
||||||
expect(await screen.findByText('UI 预览模式,不会执行真实日报任务')).toBeVisible()
|
await waitFor(() => expect(spies.runScheduled).toHaveBeenCalledWith('rule-1'))
|
||||||
for (const [name, spy] of writeSpies(spies)) {
|
|
||||||
expect(spy, `${name} 不该被调用`).not.toHaveBeenCalled()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('toggle 只改本地状态并提示,不写回 store', async () => {
|
it('toggle 真写 setAutomationRuleEnabled', async () => {
|
||||||
const spies = installApi()
|
const spies = installApi()
|
||||||
renderWorkspace()
|
renderWorkspace()
|
||||||
await openScheduledTab()
|
await openScheduledTab()
|
||||||
@@ -354,16 +331,10 @@ describe('定时日报 · 编辑器与预览联动', () => {
|
|||||||
const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement
|
const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement
|
||||||
|
|
||||||
await user.click(within(card).getByRole('switch'))
|
await user.click(within(card).getByRole('switch'))
|
||||||
|
await waitFor(() => expect(spies.setEnabled).toHaveBeenCalledWith('rule-1', false))
|
||||||
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()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('删除只是提示,不移除任务', async () => {
|
it('删除要经过确认弹层,确认后才真删', async () => {
|
||||||
const spies = installApi()
|
const spies = installApi()
|
||||||
renderWorkspace()
|
renderWorkspace()
|
||||||
await openScheduledTab()
|
await openScheduledTab()
|
||||||
@@ -371,24 +342,50 @@ describe('定时日报 · 编辑器与预览联动', () => {
|
|||||||
const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement
|
const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement
|
||||||
await user.click(within(card).getByRole('button', { name: '删除' }))
|
await user.click(within(card).getByRole('button', { name: '删除' }))
|
||||||
|
|
||||||
expect(await screen.findByText('UI 预览模式,不会删除现有任务')).toBeVisible()
|
const dialog = await screen.findByRole('alertdialog', { name: '删除自动化' })
|
||||||
expect(screen.getByText('TraceMemo 每日晚报')).toBeVisible()
|
expect(dialog).toHaveTextContent('删除「TraceMemo 每日晚报」?')
|
||||||
for (const [name, spy] of writeSpies(spies)) {
|
expect(spies.deleteRule).not.toHaveBeenCalled()
|
||||||
expect(spy, `${name} 不该被调用`).not.toHaveBeenCalled()
|
|
||||||
}
|
await user.click(within(dialog).getByRole('button', { name: '删除' }))
|
||||||
|
await waitFor(() => expect(spies.deleteRule).toHaveBeenCalledWith('rule-1'))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('任务列表为空时给空态与新建入口', async () => {
|
it('不再有任何旧的 scheduled-report:* 通道被调用', async () => {
|
||||||
installApi([])
|
installApi()
|
||||||
renderWorkspace()
|
renderWorkspace()
|
||||||
await openScheduledTab()
|
await openScheduledTab()
|
||||||
|
|
||||||
expect(await screen.findByText('还没有定时日报')).toBeVisible()
|
const api = window.api as unknown as Record<string, unknown>
|
||||||
expect(screen.getAllByRole('button', { name: '+ 新建定时日报' }).length).toBeGreaterThan(0)
|
for (const legacy of [
|
||||||
|
'listScheduledReports',
|
||||||
|
'createScheduledReport',
|
||||||
|
'updateScheduledReport',
|
||||||
|
'deleteScheduledReport',
|
||||||
|
'setScheduledReportEnabled',
|
||||||
|
'runScheduledReportNow',
|
||||||
|
'retryScheduledReportSend'
|
||||||
|
]) {
|
||||||
|
expect(api[legacy], `旧通道 ${legacy} 仍然存在于 preload 契约里`).toBeUndefined()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('定时日报 · 完整日报配置(不丢旧能力)', () => {
|
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 () => {
|
it('Section 2 承载旧「定时日报」的全部日报配置', async () => {
|
||||||
installApi()
|
installApi()
|
||||||
renderWorkspace()
|
renderWorkspace()
|
||||||
@@ -405,25 +402,7 @@ describe('定时日报 · 完整日报配置(不丢旧能力)', () => {
|
|||||||
expect(screen.getByText('模型配置')).toBeVisible()
|
expect(screen.getByText('模型配置')).toBeVisible()
|
||||||
expect(screen.getByText('更改模型')).toBeVisible()
|
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()
|
||||||
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 () => {
|
it('成员名称保留旧页面的三个选项', async () => {
|
||||||
@@ -440,31 +419,24 @@ describe('定时日报 · 完整日报配置(不丢旧能力)', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('定时日报 · 返回与四选一发送目标', () => {
|
describe('定时日报 · 四选一发送目标', () => {
|
||||||
it('新建页有「← 返回定时日报」,点击回到规则列表', async () => {
|
it('只有四选一,且**没有**「指定群聊」这种"发到另一个群"的选项', async () => {
|
||||||
installApi()
|
installApi()
|
||||||
renderWorkspace()
|
renderWorkspace()
|
||||||
await openScheduledTab()
|
await openScheduledTab()
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
|
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: '← 返回定时日报' }))
|
const group = screen.getByRole('radiogroup', { name: '生成后发送到哪里' })
|
||||||
// 回到「定时日报」列表,而不是自动化首页。
|
const labels = Array.from(group.querySelectorAll('label')).map((el) => el.textContent?.trim())
|
||||||
expect(await screen.findByText('2 条规则')).toBeVisible()
|
expect(labels).toEqual([
|
||||||
expect(screen.getByRole('radio', { name: '定时日报' })).toBeChecked()
|
'发送到日报来源群',
|
||||||
})
|
'文件传输助手',
|
||||||
|
'发给自己',
|
||||||
it('编辑页也有返回入口,点击回到规则列表', async () => {
|
'指定好友'
|
||||||
installApi()
|
])
|
||||||
renderWorkspace()
|
expect(screen.queryByRole('radio', { name: '指定群聊' })).toBeNull()
|
||||||
await openScheduledTab()
|
expect(screen.getByRole('radio', { name: '文件传输助手' })).toBeChecked()
|
||||||
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()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('只有「指定好友」才展开好友选择器', async () => {
|
it('只有「指定好友」才展开好友选择器', async () => {
|
||||||
@@ -473,6 +445,14 @@ describe('定时日报 · 返回与四选一发送目标', () => {
|
|||||||
await openScheduledTab()
|
await openScheduledTab()
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
|
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 ['文件传输助手', '发送到日报来源群', '发给自己']) {
|
for (const label of ['文件传输助手', '发送到日报来源群', '发给自己']) {
|
||||||
await user.click(screen.getByRole('radio', { name: label }))
|
await user.click(screen.getByRole('radio', { name: label }))
|
||||||
@@ -481,80 +461,83 @@ describe('定时日报 · 返回与四选一发送目标', () => {
|
|||||||
|
|
||||||
await user.click(screen.getByRole('radio', { name: '指定好友' }))
|
await user.click(screen.getByRole('radio', { name: '指定好友' }))
|
||||||
expect(screen.getByPlaceholderText('搜索好友')).toBeVisible()
|
expect(screen.getByPlaceholderText('搜索好友')).toBeVisible()
|
||||||
})
|
// 选了「指定好友」却没选人 → 不允许保存(否则运行期必然失败)。
|
||||||
|
expect(screen.getByTestId('scheduled-save-blocker')).toHaveTextContent(
|
||||||
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.getByRole('button', { name: '保存' })).toBeDisabled()
|
||||||
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('经典日报')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
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 () => {
|
it('列表把 room id / hash 解析成显示名,绝不显示裸 id', async () => {
|
||||||
// 合成值:形态与真实 room id / hash 相同(纯数字 + @chatroom、32 位十六进制),
|
|
||||||
// 但取值本身可一眼辨认是假数据。与仓库其它测试保持一致。
|
|
||||||
const FAKE_ROOM_ID = '12345678@chatroom'
|
const FAKE_ROOM_ID = '12345678@chatroom'
|
||||||
const FAKE_GROUP_HASH = '0123456789abcdef0123456789abcdef'
|
const FAKE_GROUP_HASH = '0123456789abcdef0123456789abcdef'
|
||||||
|
|
||||||
installApi([
|
installApi([
|
||||||
{ ...TASKS[0], group: FAKE_ROOM_ID, target: FAKE_ROOM_ID },
|
createDefaultScheduledReportRule(Date.now(), {
|
||||||
{ ...TASKS[1], group: FAKE_GROUP_HASH }
|
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()
|
renderWorkspace()
|
||||||
await openScheduledTab()
|
await openScheduledTab()
|
||||||
|
|
||||||
await screen.findByText('TraceMemo 每日晚报')
|
await screen.findByText('内部标识规则')
|
||||||
const body = document.body.textContent ?? ''
|
const body = document.body.textContent ?? ''
|
||||||
expect(body).not.toContain(FAKE_ROOM_ID)
|
expect(body).not.toContain(FAKE_ROOM_ID)
|
||||||
expect(body).not.toContain(FAKE_GROUP_HASH.slice(0, 12))
|
expect(body).not.toContain(FAKE_GROUP_HASH.slice(0, 12))
|
||||||
// 对不上群名时给中性文案,而不是把 id 当名字。
|
|
||||||
expect(screen.getAllByText('未知群聊').length).toBeGreaterThan(0)
|
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('定时日报 · 首页汇总卡', () => {
|
describe('定时日报 · 首页汇总卡', () => {
|
||||||
it('首页把它当"多条规则"汇总,而不是一条 singleton', async () => {
|
it('首页把它当"多条规则"汇总,而不是一条 singleton', async () => {
|
||||||
installApi()
|
installApi()
|
||||||
@@ -563,7 +546,6 @@ describe('定时日报 · 首页汇总卡', () => {
|
|||||||
const card = (await screen.findByText('定时日报')).closest('article') as HTMLElement
|
const card = (await screen.findByText('定时日报')).closest('article') as HTMLElement
|
||||||
expect(card.textContent).toContain('2 条规则 · 1 条运行中')
|
expect(card.textContent).toContain('2 条规则 · 1 条运行中')
|
||||||
expect(card.textContent).toContain('按设定时间')
|
expect(card.textContent).toContain('按设定时间')
|
||||||
// 汇总卡不该有单个启停开关。
|
|
||||||
expect(within(card).queryByRole('switch')).toBeNull()
|
expect(within(card).queryByRole('switch')).toBeNull()
|
||||||
|
|
||||||
await userEvent.setup().click(within(card).getByRole('button', { name: '管理定时日报 →' }))
|
await userEvent.setup().click(within(card).getByRole('button', { name: '管理定时日报 →' }))
|
||||||
@@ -571,7 +553,7 @@ describe('定时日报 · 首页汇总卡', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('定时日报 · 深链与旧页面', () => {
|
describe('定时日报 · 深链', () => {
|
||||||
it('深链直接落到「自动化 → 规则 → 定时日报」', async () => {
|
it('深链直接落到「自动化 → 规则 → 定时日报」', async () => {
|
||||||
installApi()
|
installApi()
|
||||||
|
|
||||||
@@ -592,38 +574,15 @@ describe('定时日报 · 深链与旧页面', () => {
|
|||||||
expect(await screen.findByText('2 条规则')).toBeVisible()
|
expect(await screen.findByText('2 条规则')).toBeVisible()
|
||||||
expect(screen.getByRole('radio', { name: '定时日报' })).toBeChecked()
|
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('定时日报 · 空态', () => {
|
||||||
describe('定时日报 · 异步加载', () => {
|
it('没有规则时给空态与新建入口', async () => {
|
||||||
it('加载完成后不再显示 loading', async () => {
|
installApi([])
|
||||||
installApi()
|
|
||||||
renderWorkspace()
|
renderWorkspace()
|
||||||
await openScheduledTab()
|
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: () => [] }
|
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 {
|
import type {
|
||||||
PersonalWechatSendCapability,
|
PersonalWechatSendCapability,
|
||||||
PersonalWechatSendRequest
|
PersonalWechatSendRequest
|
||||||
@@ -273,4 +276,53 @@ describe('automation end-to-end chain', () => {
|
|||||||
await chain.service.handleMessage(message({ content: '@我 今日日报', mentionTargets: [] }))
|
await chain.service.handleMessage(message({ content: '@我 今日日报', mentionTargets: [] }))
|
||||||
expect(chain.sends).toHaveLength(0)
|
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)
|
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:send', reportImageRequest)
|
||||||
await api.getPersonalWechatVoiceDiagnostic()
|
await api.getPersonalWechatVoiceDiagnostic()
|
||||||
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:getVoiceDiagnostic')
|
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()
|
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)
|
await api.setScheduledReportNotificationEnabled(true)
|
||||||
expect(invoke).toHaveBeenLastCalledWith('scheduled-report:setNotificationEnabled', true)
|
expect(invoke).toHaveBeenLastCalledWith(
|
||||||
await api.testScheduledReportErrorNotification('task-1')
|
'automation:setScheduledReportNotificationEnabled',
|
||||||
expect(invoke).toHaveBeenLastCalledWith('scheduled-report:testErrorNotification', 'task-1')
|
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 () => {
|
it('preserves key API return values without exposing ipcRenderer', async () => {
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ const fixture = vi.hoisted(() => ({
|
|||||||
capability: {
|
capability: {
|
||||||
supported: true,
|
supported: true,
|
||||||
ready: true,
|
ready: true,
|
||||||
status: 'ready' as const,
|
// 显式标注成联合类型:否则会被收窄成字面量 'ready',
|
||||||
|
// 后面把它改成 'needs_binding' 时就不合法了。
|
||||||
|
status: 'ready' as PersonalWechatSendCapabilityState,
|
||||||
capabilities: { text: true, image: true, voice: true },
|
capabilities: { text: true, image: true, voice: true },
|
||||||
senderStatus: {} as never,
|
senderStatus: {} as never,
|
||||||
message: '个人微信已准备好发送日报'
|
message: '个人微信已准备好发送日报'
|
||||||
@@ -58,7 +60,14 @@ vi.mock('../../src/main/services/agent-hub-service', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
import { startHttpServer, type HttpServerHandle } from '../../src/main/http-server'
|
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'
|
import type { ScheduledReportApiDependencies } from '../../src/main/services/scheduled-report-api-service'
|
||||||
|
|
||||||
const TOKEN = 'A'.repeat(43)
|
const TOKEN = 'A'.repeat(43)
|
||||||
@@ -87,57 +96,76 @@ async function startFixture(
|
|||||||
options: { tasks?: ScheduledReportTask[] } = {}
|
options: { tasks?: ScheduledReportTask[] } = {}
|
||||||
): Promise<{ handle: HttpServerHandle; service: ScheduledReportApiDependencies['service'] }> {
|
): Promise<{ handle: HttpServerHandle; service: ScheduledReportApiDependencies['service'] }> {
|
||||||
const tasks = options.tasks || []
|
const tasks = options.tasks || []
|
||||||
const executions: Array<Record<string, unknown>> = []
|
const executions: ScheduledReportExecution[] = []
|
||||||
const service = {
|
const service: ScheduledReportApiDependencies['service'] = {
|
||||||
listTasks: vi.fn(async () => tasks),
|
listTasks: vi.fn(async (): Promise<ScheduledReportTask[]> => tasks),
|
||||||
listExecutions: vi.fn(async (taskId?: string) =>
|
listExecutions: vi.fn(
|
||||||
taskId ? executions.filter((item) => item.taskId === taskId) : executions
|
async (taskId?: string): Promise<ScheduledReportExecution[]> =>
|
||||||
|
taskId ? executions.filter((item) => item.taskId === taskId) : executions
|
||||||
),
|
),
|
||||||
createTask: vi.fn(async (input: Record<string, unknown>) => {
|
createTask: vi.fn(
|
||||||
const created = makeTask({
|
async (
|
||||||
id: `task-${tasks.length + 1}`,
|
input: ScheduledReportCreateInput
|
||||||
name: String(input.name),
|
): Promise<ScheduledReportResult<ScheduledReportTask>> => {
|
||||||
group: String(input.group),
|
const created = makeTask({
|
||||||
target: String(input.target),
|
id: `task-${tasks.length + 1}`,
|
||||||
scheduleTime: String(input.scheduleTime),
|
name: String(input.name),
|
||||||
reportRange: input.reportRange as ScheduledReportTask['reportRange'],
|
group: String(input.group),
|
||||||
enabled: Boolean(input.enabled)
|
target: String(input.target),
|
||||||
})
|
scheduleTime: String(input.scheduleTime),
|
||||||
tasks.push(created)
|
reportRange: input.reportRange as ScheduledReportTask['reportRange'],
|
||||||
return { success: true, data: created }
|
enabled: Boolean(input.enabled)
|
||||||
}),
|
})
|
||||||
updateTask: vi.fn(async (id: string, input: Record<string, unknown>) => {
|
tasks.push(created)
|
||||||
const current = tasks.find((item) => item.id === id)
|
return { success: true, data: created }
|
||||||
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: '日报生成成功,微信发送成功'
|
|
||||||
}
|
}
|
||||||
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, {
|
const handle = await startHttpServer('127.0.0.1', 0, {
|
||||||
tokenProvider: () => TOKEN,
|
tokenProvider: () => TOKEN,
|
||||||
@@ -222,6 +250,13 @@ describe('scheduled report Local HTTP API', () => {
|
|||||||
})
|
})
|
||||||
expect(await capability.json()).toMatchObject({ capability: { status: 'ready' } })
|
expect(await capability.json()).toMatchObject({ capability: { status: 'ready' } })
|
||||||
expect(service.createTask).toHaveBeenCalledOnce()
|
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 () => {
|
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 下调。",
|
"note": "测试文件类型检查基线:只允许下降,不允许上升。用 node scripts/typecheck-tests.cjs --update 下调。",
|
||||||
"total": 234,
|
"total": 209,
|
||||||
"files": {
|
"files": {
|
||||||
"tests/component/ai-model-settings.test.tsx": 2,
|
"tests/component/ai-model-settings.test.tsx": 2,
|
||||||
"tests/component/ai-search-cache-consent.test.tsx": 3,
|
"tests/component/ai-search-cache-consent.test.tsx": 3,
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
"tests/component/database-connection.test.tsx": 2,
|
"tests/component/database-connection.test.tsx": 2,
|
||||||
"tests/component/export-voice-transcript.test.tsx": 1,
|
"tests/component/export-voice-transcript.test.tsx": 1,
|
||||||
"tests/component/export-workspace.test.tsx": 8,
|
"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-bubble.test.tsx": 1,
|
||||||
"tests/component/image-text-index-card.test.tsx": 5,
|
"tests/component/image-text-index-card.test.tsx": 5,
|
||||||
"tests/component/local-image-text-recognition.test.tsx": 1,
|
"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/image-text-index-synthetic-e2e.test.ts": 22,
|
||||||
"tests/integration/query-agent-image-ocr-text.test.ts": 16,
|
"tests/integration/query-agent-image-ocr-text.test.ts": 16,
|
||||||
"tests/integration/query-agent-self-direction.test.ts": 7,
|
"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/agent-hub-routing.test.ts": 1,
|
||||||
"tests/unit/ai-search-pipeline-service.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-provider-consent.test.ts": 5,
|
||||||
"tests/unit/ai-search-workspace-pure-functions.test.ts": 13,
|
"tests/unit/ai-search-workspace-pure-functions.test.ts": 13,
|
||||||
"tests/unit/ask-wechat-service.test.ts": 8,
|
"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/group-report.test.ts": 1,
|
||||||
"tests/unit/http-media-service.test.ts": 2,
|
"tests/unit/http-media-service.test.ts": 2,
|
||||||
"tests/unit/knowledge-freshness.test.ts": 1,
|
"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-image-ocr-coverage.test.ts": 1,
|
||||||
"tests/unit/query-agent-service.test.ts": 4,
|
"tests/unit/query-agent-service.test.ts": 4,
|
||||||
"tests/unit/report-template-market.test.ts": 1,
|
"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/system-ocr-service.test.ts": 3,
|
||||||
"tests/unit/voice-pipeline.test.ts": 1
|
"tests/unit/voice-pipeline.test.ts": 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,16 +220,37 @@ describe('AutomationRuleStore', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 存量安装:早期版本把"部分勾选"判成无法无损映射,于是落盘了
|
||||||
|
* `targetNeedsReview: true`。这个标记会拦发送(`handleGroupExit` 第②道闸门),
|
||||||
|
* 所以**用户重存一次必须能清掉它** —— 否则那条规则永远发不出去。
|
||||||
|
*
|
||||||
|
* 注意种的是"已经落盘的存量状态",不是靠迁移产出:迁移现在不再产生这个标记了。
|
||||||
|
*/
|
||||||
it('保存退群通知会清掉迁移遗留的「目标待重选」标记', () => {
|
it('保存退群通知会清掉迁移遗留的「目标待重选」标记', () => {
|
||||||
writeLegacyMonitorState({
|
|
||||||
monitoredRoomIds: ['a@chatroom', 'b@chatroom'],
|
|
||||||
notificationRoomIds: ['a@chatroom']
|
|
||||||
})
|
|
||||||
const store = new AutomationRuleStore(deps(root))
|
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(
|
expect(
|
||||||
store.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)?.leaveNotification?.targetNeedsReview
|
store.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)?.leaveNotification?.targetNeedsReview
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
|
|
||||||
|
// 用户来重存一次:草稿里不带这个标记 ⇒ 归一化后它就该消失。
|
||||||
store.saveLeaveNotificationRule({
|
store.saveLeaveNotificationRule({
|
||||||
...store.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID),
|
...store.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -384,14 +405,17 @@ describe('AutomationRuleStore', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(leave?.enabled).toBe(false)
|
expect(leave?.enabled).toBe(false)
|
||||||
// 关闭状态下 target 只是占位值(= 默认目标),不会发送任何东西。
|
// 关闭状态下 target 只是占位值(= 「发回本群」),不会发送任何东西。
|
||||||
expect(leave?.leaveNotification?.target).toEqual({ type: 'source_chat' })
|
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('退群: {user}')
|
||||||
expect(leave?.leaveNotification?.template).toContain('群聊: {groupName}')
|
expect(leave?.leaveNotification?.template).toContain('群聊: {groupName}')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('旧配置在每个被监控群都通知 → 无损映射为「当前群聊」且保持启用', () => {
|
it('旧配置在每个被监控群都通知 → 无损映射为「当前群聊」+ 全部范围,且保持启用', () => {
|
||||||
writeLegacyMonitorState({
|
writeLegacyMonitorState({
|
||||||
monitoredRoomIds: ['a@chatroom', 'b@chatroom'],
|
monitoredRoomIds: ['a@chatroom', 'b@chatroom'],
|
||||||
notificationRoomIds: ['a@chatroom', 'b@chatroom']
|
notificationRoomIds: ['a@chatroom', 'b@chatroom']
|
||||||
@@ -403,15 +427,16 @@ describe('AutomationRuleStore', () => {
|
|||||||
|
|
||||||
expect(leave?.enabled).toBe(true)
|
expect(leave?.enabled).toBe(true)
|
||||||
expect(leave?.leaveNotification?.target).toEqual({ type: 'source_chat' })
|
expect(leave?.leaveNotification?.target).toEqual({ type: 'source_chat' })
|
||||||
|
expect(leave?.leaveNotification?.notifyScope).toBe('all')
|
||||||
expect(leave?.leaveNotification?.targetNeedsReview).toBeUndefined()
|
expect(leave?.leaveNotification?.targetNeedsReview).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 关键的一条:旧「通知群聊」是**逐群**的多值配置,新目标是**单值**。
|
* 关键的一条:旧「通知群聊」是**逐群**的多值配置,恢复 `notifyScope` 之后
|
||||||
* 只勾了一部分时无法无损映射 —— 这时候:
|
* 它就是一个正常可表达的字段 —— 所以这里**不再降级成 needs_review**:
|
||||||
* 不取第一个群、不改写成当前群聊、保持关闭、要求用户重选,并留一份备份。
|
* 那份勾选被原样保留、规则保持启用、不会再多一次"被无辜停用"的体验。
|
||||||
*/
|
*/
|
||||||
it('旧配置只勾了部分群 → 标记待重选、不发送、留下备份,绝不猜', () => {
|
it('旧配置只勾了部分群 → 原样保留那份子集,保持启用,不做有损改写', () => {
|
||||||
writeLegacyMonitorState({
|
writeLegacyMonitorState({
|
||||||
monitoredRoomIds: ['a@chatroom', 'b@chatroom', 'c@chatroom'],
|
monitoredRoomIds: ['a@chatroom', 'b@chatroom', 'c@chatroom'],
|
||||||
notificationRoomIds: ['b@chatroom'],
|
notificationRoomIds: ['b@chatroom'],
|
||||||
@@ -421,11 +446,13 @@ describe('AutomationRuleStore', () => {
|
|||||||
const store = new AutomationRuleStore(deps(root))
|
const store = new AutomationRuleStore(deps(root))
|
||||||
const leave = store.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
|
const leave = store.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
|
||||||
|
|
||||||
expect(leave?.enabled).toBe(false)
|
expect(leave?.enabled).toBe(true)
|
||||||
expect(leave?.leaveNotification?.targetNeedsReview).toBe(true)
|
expect(leave?.leaveNotification?.targetNeedsReview).toBeUndefined()
|
||||||
// 不回落到「取第一个群」,也不改写成别的具体目标 —— 只留默认占位(当前群聊),
|
// 收件人恒为事件所在群(旧版 notifyGroup 的事实)⇒ target 仍是 source_chat。
|
||||||
// 且因为 enabled=false + targetNeedsReview=true,在用户重选之前不会发送。
|
|
||||||
expect(leave?.leaveNotification?.target).toEqual({ type: '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('退群: {user}')
|
||||||
expect(leave?.leaveNotification?.template).toContain('群聊: {groupName}')
|
expect(leave?.leaveNotification?.template).toContain('群聊: {groupName}')
|
||||||
@@ -434,10 +461,10 @@ describe('AutomationRuleStore', () => {
|
|||||||
path.join(root, 'automation', 'leave-notification-migration-backup.json')
|
path.join(root, 'automation', 'leave-notification-migration-backup.json')
|
||||||
)
|
)
|
||||||
expect(backup).toMatchObject({
|
expect(backup).toMatchObject({
|
||||||
outcome: 'needs_review',
|
outcome: 'lossless_selected_groups',
|
||||||
legacy: { notificationRoomIds: ['b@chatroom'] }
|
legacy: { notificationRoomIds: ['b@chatroom'] }
|
||||||
})
|
})
|
||||||
expect(store.getLastLeaveNotificationMigration()?.outcome).toBe('needs_review')
|
expect(store.getLastLeaveNotificationMigration()?.outcome).toBe('lossless_selected_groups')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('迁移只跑一次:重启两次也只有一条退群通知规则', () => {
|
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', () => {
|
describe('AutomationExecutionLogService', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fs.removeSync(path.join(root, 'automation'))
|
fs.removeSync(path.join(root, 'automation'))
|
||||||
|
|||||||
@@ -201,6 +201,65 @@ describe('AutomationService.handleGroupExit', () => {
|
|||||||
expect(harness.executions).toHaveLength(0)
|
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 () => {
|
it('缺少 eventId 时直接跳过,绝不发送', async () => {
|
||||||
const harness = createHarness()
|
const harness = createHarness()
|
||||||
|
|
||||||
@@ -260,7 +319,7 @@ describe('AutomationService.handleGroupExit', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* §「旧联系人失效」:不偷偷发给别人,execution 记 failed,Gateway 一次都不调。
|
* 「旧联系人失效」:不偷偷发给别人,execution 记 failed,Gateway 一次都不调。
|
||||||
*/
|
*/
|
||||||
it('指定好友已不存在 → execution failed 且不调用 Gateway', async () => {
|
it('指定好友已不存在 → execution failed 且不调用 Gateway', async () => {
|
||||||
const harness = createHarness({
|
const harness = createHarness({
|
||||||
@@ -353,7 +412,7 @@ describe('AutomationService.handleGroupExit', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* §「不同事件」:同群两个成员先后退出就是两条通知。
|
* 不同事件:同群两个成员先后退出就是两条通知。
|
||||||
* 退群通知**不复用**消息型规则的 cooldown,否则第二次会被时间窗吞掉。
|
* 退群通知**不复用**消息型规则的 cooldown,否则第二次会被时间窗吞掉。
|
||||||
*/
|
*/
|
||||||
it('不同 eventId 各自执行一次,不被合并', async () => {
|
it('不同 eventId 各自执行一次,不被合并', async () => {
|
||||||
|
|||||||
@@ -45,6 +45,23 @@ const contacts: LeaveNotificationCandidateContact[] = [
|
|||||||
{ m_nsUsrName: 'wxid_self', m_nsNickName: '我自己', type: 'user' }
|
{ 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(
|
function resolve(
|
||||||
config: LeaveNotificationConfig,
|
config: LeaveNotificationConfig,
|
||||||
overrides: { selfWxid?: string; event?: GroupMemberExitedEvent } = {}
|
overrides: { selfWxid?: string; event?: GroupMemberExitedEvent } = {}
|
||||||
@@ -59,7 +76,7 @@ function resolve(
|
|||||||
|
|
||||||
describe('leaveNotificationTarget · 四种目标', () => {
|
describe('leaveNotificationTarget · 四种目标', () => {
|
||||||
it('当前群聊 = 事件所在群,不是"最后活跃会话"', () => {
|
it('当前群聊 = 事件所在群,不是"最后活跃会话"', () => {
|
||||||
const result = resolve({ target: { type: 'source_chat' }, template: '' })
|
const result = resolve(config())
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -71,23 +88,42 @@ describe('leaveNotificationTarget · 四种目标', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('当前群聊在群名缺失时用中性称呼,不回落成 roomId', () => {
|
it('当前群聊在群名缺失时用中性称呼,不回落成 roomId', () => {
|
||||||
const result = resolveLeaveNotificationTarget({
|
// 事件里没带群名,但本地联系人表里有这个群 ⇒ 用会话昵称,
|
||||||
config: { target: { type: 'source_chat' }, template: '' },
|
// 比「当前群聊」更具体,且**仍然不是** roomId。
|
||||||
|
const withNickname = resolveLeaveNotificationTarget({
|
||||||
|
config: config(),
|
||||||
event: { ...EVENT, groupName: undefined },
|
event: { ...EVENT, groupName: undefined },
|
||||||
contacts,
|
contacts,
|
||||||
selfWxid: 'wxid_self'
|
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) {
|
// 唯一不能发生的事是把 roomId / wxid 当显示名吐出去。
|
||||||
expect(result.target.recipient.name).toBe('当前群聊')
|
const fallback = resolveLeaveNotificationTarget({
|
||||||
expect(result.target.displayName).toBe('发生退群事件的群聊')
|
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 时直接失败', () => {
|
it('当前群聊在缺少 conversationId 时直接失败', () => {
|
||||||
const result = resolveLeaveNotificationTarget({
|
const result = resolveLeaveNotificationTarget({
|
||||||
config: { target: { type: 'source_chat' }, template: '' },
|
config: config(),
|
||||||
event: { ...EVENT, conversationId: '' },
|
event: { ...EVENT, conversationId: '' },
|
||||||
contacts,
|
contacts,
|
||||||
selfWxid: 'wxid_self'
|
selfWxid: 'wxid_self'
|
||||||
@@ -97,7 +133,7 @@ describe('leaveNotificationTarget · 四种目标', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('发给自己 = 当前登录账号的真实 wxid', () => {
|
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({
|
expect(result).toEqual({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -113,7 +149,7 @@ describe('leaveNotificationTarget · 四种目标', () => {
|
|||||||
* 那会让用户以为"发给自己"生效了,实际消息去了别处。
|
* 那会让用户以为"发给自己"生效了,实际消息去了别处。
|
||||||
*/
|
*/
|
||||||
it('拿不到自身身份时失败,且不 fallback 文件传输助手', () => {
|
it('拿不到自身身份时失败,且不 fallback 文件传输助手', () => {
|
||||||
const result = resolve({ target: { type: 'self' }, template: '' }, { selfWxid: '' })
|
const result = resolve(config({ target: { type: 'self' } }), { selfWxid: '' })
|
||||||
|
|
||||||
expect(result.ok).toBe(false)
|
expect(result.ok).toBe(false)
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
@@ -123,7 +159,7 @@ describe('leaveNotificationTarget · 四种目标', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('文件传输助手用协议级固定身份,不靠昵称搜索', () => {
|
it('文件传输助手用协议级固定身份,不靠昵称搜索', () => {
|
||||||
const result = resolve({ target: { type: 'file_transfer' }, template: '' })
|
const result = resolve(config({ target: { type: 'file_transfer' } }))
|
||||||
|
|
||||||
expect(result.ok).toBe(true)
|
expect(result.ok).toBe(true)
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
@@ -136,10 +172,9 @@ describe('leaveNotificationTarget · 四种目标', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('指定好友用稳定 id,显示名用备注', () => {
|
it('指定好友用稳定 id,显示名用备注', () => {
|
||||||
const result = resolve({
|
const result = resolve(
|
||||||
target: { type: 'contact', contactId: 'wxid_friend' },
|
config({ target: { type: 'contact', contactId: 'wxid_friend' } })
|
||||||
template: ''
|
)
|
||||||
})
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -153,14 +188,14 @@ describe('leaveNotificationTarget · 四种目标', () => {
|
|||||||
|
|
||||||
describe('leaveNotificationTarget · 失效与非法', () => {
|
describe('leaveNotificationTarget · 失效与非法', () => {
|
||||||
it('联系人不在了 → 失败,且不往别处发', () => {
|
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)
|
expect(result.ok).toBe(false)
|
||||||
if (!result.ok) expect(result.error).toContain('已不存在或当前无法发送')
|
if (!result.ok) expect(result.error).toContain('已不存在或当前无法发送')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('还没选好友 → 失败并提示重新选择', () => {
|
it('还没选好友 → 失败并提示重新选择', () => {
|
||||||
const result = resolve({ target: { type: 'contact' }, template: '' })
|
const result = resolve(config({ target: { type: 'contact' } }))
|
||||||
|
|
||||||
expect(result.ok).toBe(false)
|
expect(result.ok).toBe(false)
|
||||||
if (!result.ok) expect(result.error).toContain('还没有选择通知联系人')
|
if (!result.ok) expect(result.error).toContain('还没有选择通知联系人')
|
||||||
@@ -173,7 +208,7 @@ describe('leaveNotificationTarget · 失效与非法', () => {
|
|||||||
WECHAT_FILE_HELPER_USERNAME,
|
WECHAT_FILE_HELPER_USERNAME,
|
||||||
'wxid_self'
|
'wxid_self'
|
||||||
]) {
|
]) {
|
||||||
const result = resolve({ target: { type: 'contact', contactId }, template: '' })
|
const result = resolve(config({ target: { type: 'contact', contactId } }))
|
||||||
expect(result.ok).toBe(false)
|
expect(result.ok).toBe(false)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -188,10 +223,7 @@ describe('leaveNotificationTarget · 失效与非法', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('未知目标类型 → 失败(不允许静默当成默认值)', () => {
|
it('未知目标类型 → 失败(不允许静默当成默认值)', () => {
|
||||||
const result = resolve({
|
const result = resolve(config({ target: { type: 'webhook' as never } }))
|
||||||
target: { type: 'webhook' as never },
|
|
||||||
template: ''
|
|
||||||
})
|
|
||||||
expect(result.ok).toBe(false)
|
expect(result.ok).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat'
|
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 {
|
import {
|
||||||
ScheduledReportApiService,
|
ScheduledReportApiService,
|
||||||
type ScheduledReportApiDependencies
|
type ScheduledReportApiDependencies
|
||||||
@@ -60,36 +66,60 @@ function makeApi(
|
|||||||
): ApiTestResult {
|
): ApiTestResult {
|
||||||
const tasks = options.tasks || []
|
const tasks = options.tasks || []
|
||||||
const service = {
|
const service = {
|
||||||
listTasks: vi.fn(async () => tasks),
|
listTasks: vi.fn(async (): Promise<ScheduledReportTask[]> => tasks),
|
||||||
listExecutions: vi.fn(async () => []),
|
listExecutions: vi.fn(
|
||||||
createTask: vi.fn(async (input: Record<string, unknown>) => {
|
async (_taskId?: string): Promise<ScheduledReportExecution[]> => []
|
||||||
const created = task({
|
),
|
||||||
id: `task-${tasks.length + 1}`,
|
createTask: vi.fn(
|
||||||
name: String(input.name),
|
async (input: ScheduledReportCreateInput): Promise<ScheduledReportResult<ScheduledReportTask>> => {
|
||||||
group: String(input.group),
|
const created = task({
|
||||||
target: String(input.target),
|
id: `task-${tasks.length + 1}`,
|
||||||
scheduleTime: String(input.scheduleTime),
|
name: String(input.name),
|
||||||
reportRange: input.reportRange as ScheduledReportTask['reportRange'],
|
group: String(input.group),
|
||||||
enabled: Boolean(input.enabled)
|
target: String(input.target),
|
||||||
})
|
scheduleTime: String(input.scheduleTime),
|
||||||
tasks.push(created)
|
reportRange: input.reportRange as ScheduledReportTask['reportRange'],
|
||||||
return { success: true, data: created }
|
enabled: Boolean(input.enabled)
|
||||||
}),
|
})
|
||||||
updateTask: vi.fn(async () => ({ success: true, data: tasks[0] })),
|
tasks.push(created)
|
||||||
deleteTask: vi.fn(async (id: string) => ({ success: true, data: { deletedId: id } })),
|
return { success: true, data: created }
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}))
|
),
|
||||||
|
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 {
|
return {
|
||||||
api: new ScheduledReportApiService({
|
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