mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-04 03:01:42 +08:00
feat: 修复定时日报错误通知微信机器人功能
增加定时日报debug 修复定时日报历史头像显示并区分定时日报标题
This commit is contained in:
@@ -5,6 +5,9 @@ VITE_DB_KEY=
|
|||||||
# Set to true/1/yes/on for local development. Default is disabled.
|
# Set to true/1/yes/on for local development. Default is disabled.
|
||||||
VITE_AUTO_LOGIN=false
|
VITE_AUTO_LOGIN=false
|
||||||
|
|
||||||
|
# Show the scheduled report debug notification test action. Default is disabled.
|
||||||
|
VITE_SCHEDULED_REPORT_DEBUG=false
|
||||||
|
|
||||||
# AI API Configuration (Optional, can be entered in UI)
|
# AI API Configuration (Optional, can be entered in UI)
|
||||||
# 注意:发布版本不再自动读取以下环境变量。
|
# 注意:发布版本不再自动读取以下环境变量。
|
||||||
# 如果你只是本地开发想用默认值,可以在自己机器的 .env.local 里填,
|
# 如果你只是本地开发想用默认值,可以在自己机器的 .env.local 里填,
|
||||||
|
|||||||
+3
-2
@@ -35,6 +35,7 @@
|
|||||||
"cp:env": "node scripts/ensure-env.cjs",
|
"cp:env": "node scripts/ensure-env.cjs",
|
||||||
"prepare:env": "node scripts/ensure-env.cjs",
|
"prepare:env": "node scripts/ensure-env.cjs",
|
||||||
"prepare:ffmpeg:win": "node scripts/prepare-electron-runtime.cjs --platform win32 --arch x64",
|
"prepare:ffmpeg:win": "node scripts/prepare-electron-runtime.cjs --platform win32 --arch x64",
|
||||||
|
"prepare:win-runtime": "node scripts/prepare-win-runtime.cjs && npm run prepare:ffmpeg:win",
|
||||||
"prepare:wechat-personal": "node scripts/prepare-wechat-chatter-runtime.cjs",
|
"prepare:wechat-personal": "node scripts/prepare-wechat-chatter-runtime.cjs",
|
||||||
"start": "electron-vite preview",
|
"start": "electron-vite preview",
|
||||||
"predev": "node -e \"require('electron')\"",
|
"predev": "node -e \"require('electron')\"",
|
||||||
@@ -58,11 +59,11 @@
|
|||||||
"build": "npm run typecheck && npm run build:native-services && electron-vite build",
|
"build": "npm run typecheck && npm run build:native-services && electron-vite build",
|
||||||
"postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs",
|
"postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs",
|
||||||
"build:unpack": "npm run build && electron-builder --config electron-builder.yml --dir",
|
"build:unpack": "npm run build && electron-builder --config electron-builder.yml --dir",
|
||||||
"build:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:ffmpeg:win && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64",
|
"build:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:win-runtime && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64",
|
||||||
"build:mac:arm64": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch arm64 && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64",
|
"build:mac:arm64": "npm run typecheck && node scripts/build-wechat-connector.cjs --platform darwin --arch arm64 && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64",
|
||||||
"release": "npm run release:mac && npm run release:win",
|
"release": "npm run release:mac && npm run release:win",
|
||||||
"release:mac": "npm run typecheck && npm run build:wechat-connector:mac && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64 --publish always",
|
"release:mac": "npm run typecheck && npm run build:wechat-connector:mac && electron-vite build && electron-builder --config electron-builder.yml --mac --arm64 --publish always",
|
||||||
"release:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:ffmpeg:win && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64 --publish always",
|
"release:win": "npm run typecheck && npm run build:wechat-connector:win && npm run prepare:win-runtime && electron-vite build && electron-builder --config electron-builder.win.yml --win --x64 --publish always",
|
||||||
"release:beta": "cross-env RELEASE_TYPE=prerelease npm run release",
|
"release:beta": "cross-env RELEASE_TYPE=prerelease npm run release",
|
||||||
"release:stable": "cross-env RELEASE_TYPE=release npm run release",
|
"release:stable": "cross-env RELEASE_TYPE=release npm run release",
|
||||||
"build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux"
|
"build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux"
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */
|
||||||
|
const { execFileSync } = require('node:child_process')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
|
||||||
|
const projectRoot = path.resolve(__dirname, '..')
|
||||||
|
const defaultRuntimeRoot = path.join(projectRoot, 'node_modules', 'sherpa-onnx-win-x64')
|
||||||
|
const requiredFiles = ['package.json', 'sherpa-onnx.node']
|
||||||
|
|
||||||
|
function hasWindowsSherpaRuntime(runtimeRoot = defaultRuntimeRoot) {
|
||||||
|
return requiredFiles.every((fileName) => fs.existsSync(path.join(runtimeRoot, fileName)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureWindowsSherpaRuntime() {
|
||||||
|
if (hasWindowsSherpaRuntime()) {
|
||||||
|
console.log('[prepare-win-runtime] sherpa-onnx-win-x64 is ready')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[prepare-win-runtime] installing cross-platform optional dependencies')
|
||||||
|
execFileSync(
|
||||||
|
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
|
||||||
|
['install', '--force', '--ignore-scripts'],
|
||||||
|
{
|
||||||
|
cwd: projectRoot,
|
||||||
|
stdio: 'inherit'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!hasWindowsSherpaRuntime()) {
|
||||||
|
throw new Error(`Missing Windows sherpa runtime: ${defaultRuntimeRoot}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) ensureWindowsSherpaRuntime()
|
||||||
|
|
||||||
|
module.exports = { hasWindowsSherpaRuntime, ensureWindowsSherpaRuntime }
|
||||||
Vendored
+1
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_DEEPSEEK_API_KEY: string
|
readonly VITE_DEEPSEEK_API_KEY: string
|
||||||
|
readonly VITE_SCHEDULED_REPORT_DEBUG: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
+22
-1
@@ -460,6 +460,27 @@ function createScheduledReportRoute(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const retryPrefix = `${SCHEDULED_REPORTS_ROUTE}/executions/`
|
||||||
|
if (pathname.startsWith(retryPrefix)) {
|
||||||
|
const segments = pathname.slice(retryPrefix.length).split('/').filter(Boolean)
|
||||||
|
if (segments.length !== 2 || segments[1] !== 'retry-send') return undefined
|
||||||
|
let executionId: string
|
||||||
|
try {
|
||||||
|
executionId = decodeURIComponent(segments[0])
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return async ({ req, res }) => {
|
||||||
|
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
||||||
|
try {
|
||||||
|
const execution = await api.retrySend(executionId)
|
||||||
|
sendJson(res, 200, { success: execution.status !== 'failed', execution })
|
||||||
|
} catch (error) {
|
||||||
|
sendScheduledError(res, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const prefix = `${SCHEDULED_REPORTS_ROUTE}/`
|
const prefix = `${SCHEDULED_REPORTS_ROUTE}/`
|
||||||
if (!pathname.startsWith(prefix)) return undefined
|
if (!pathname.startsWith(prefix)) return undefined
|
||||||
const segments = pathname.slice(prefix.length).split('/').filter(Boolean)
|
const segments = pathname.slice(prefix.length).split('/').filter(Boolean)
|
||||||
@@ -500,7 +521,7 @@ function createScheduledReportRoute(
|
|||||||
}
|
}
|
||||||
if (action === 'run' && req.method === 'POST') {
|
if (action === 'run' && req.method === 'POST') {
|
||||||
const execution = await api.run(taskId)
|
const execution = await api.run(taskId)
|
||||||
sendJson(res, 200, { success: execution.status === 'success', execution })
|
sendJson(res, 200, { success: execution.status !== 'failed', execution })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (action === 'executions' && req.method === 'GET') {
|
if (action === 'executions' && req.method === 'GET') {
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ import type {
|
|||||||
ScheduledReportCreateInput,
|
ScheduledReportCreateInput,
|
||||||
ScheduledReportUpdateInput
|
ScheduledReportUpdateInput
|
||||||
} from '../shared/scheduled-report'
|
} from '../shared/scheduled-report'
|
||||||
|
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 {
|
||||||
ListTextToSpeechVoicesRequest,
|
ListTextToSpeechVoicesRequest,
|
||||||
@@ -1315,6 +1316,12 @@ app.whenReady().then(async () => {
|
|||||||
ipcMain.handle('scheduled-report:listExecutions', (_, taskId?: string) =>
|
ipcMain.handle('scheduled-report:listExecutions', (_, taskId?: string) =>
|
||||||
scheduledReportService.listExecutions(taskId)
|
scheduledReportService.listExecutions(taskId)
|
||||||
)
|
)
|
||||||
|
ipcMain.handle('scheduled-report:getNotificationSettings', () =>
|
||||||
|
scheduledReportService.getNotificationSettings()
|
||||||
|
)
|
||||||
|
ipcMain.handle('scheduled-report:setNotificationEnabled', (_, enabled: boolean) =>
|
||||||
|
scheduledReportService.setNotificationEnabled(Boolean(enabled))
|
||||||
|
)
|
||||||
ipcMain.handle('scheduled-report:create', (_, request: ScheduledReportCreateInput) =>
|
ipcMain.handle('scheduled-report:create', (_, request: ScheduledReportCreateInput) =>
|
||||||
scheduledReportService.createTask(request)
|
scheduledReportService.createTask(request)
|
||||||
)
|
)
|
||||||
@@ -1332,6 +1339,18 @@ app.whenReady().then(async () => {
|
|||||||
ipcMain.handle('scheduled-report:runNow', (_, taskId: string) =>
|
ipcMain.handle('scheduled-report:runNow', (_, taskId: string) =>
|
||||||
scheduledReportService.runScheduledReportNow(taskId)
|
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)) {
|
||||||
|
return Promise.resolve({
|
||||||
|
success: false,
|
||||||
|
error: '调试测试按钮未开启,请在 .env 中设置 VITE_SCHEDULED_REPORT_DEBUG=true。'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return scheduledReportService.testScheduledReportErrorNotification(taskId)
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('report:reveal', async (_, filePath: string) => {
|
ipcMain.handle('report:reveal', async (_, filePath: string) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ export async function saveGeneratedReport(
|
|||||||
contactId: request.contactId,
|
contactId: request.contactId,
|
||||||
contactName: request.contactName,
|
contactName: request.contactName,
|
||||||
contactAvatar: request.contactAvatar,
|
contactAvatar: request.contactAvatar,
|
||||||
|
source: request.source,
|
||||||
dateRange: request.dateRange,
|
dateRange: request.dateRange,
|
||||||
messageCount: request.messageCount,
|
messageCount: request.messageCount,
|
||||||
generatedAt: timestamp.toISOString(),
|
generatedAt: timestamp.toISOString(),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
ScheduledReportMemberNameMode,
|
ScheduledReportMemberNameMode,
|
||||||
ScheduledReportMessageType
|
ScheduledReportMessageType
|
||||||
} from '../../shared/scheduled-report'
|
} from '../../shared/scheduled-report'
|
||||||
|
import type { ScheduledReportExecutionStage } from '../../shared/scheduled-report'
|
||||||
import type { SelectableReportTemplateId } from '../../shared/report-templates'
|
import type { SelectableReportTemplateId } from '../../shared/report-templates'
|
||||||
import { resolveMemberName } from '../../shared/member-names'
|
import { resolveMemberName } from '../../shared/member-names'
|
||||||
import {
|
import {
|
||||||
@@ -47,6 +48,10 @@ export interface AgentGroupReportResult {
|
|||||||
}
|
}
|
||||||
duration?: number
|
duration?: number
|
||||||
error?: string
|
error?: string
|
||||||
|
errorCode?: string
|
||||||
|
errorStage?: ScheduledReportExecutionStage
|
||||||
|
errorStatus?: number
|
||||||
|
errorType?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateAgentGroupReport(
|
export async function generateAgentGroupReport(
|
||||||
@@ -57,11 +62,11 @@ export async function generateAgentGroupReport(
|
|||||||
.trim()
|
.trim()
|
||||||
.replace(/群聊?$/, '')
|
.replace(/群聊?$/, '')
|
||||||
.trim()
|
.trim()
|
||||||
if (!query) return { success: false, error: '缺少群聊名称' }
|
if (!query) return reportFailure('缺少群聊名称', 'data', 'CHAT_NOT_FOUND')
|
||||||
const contact = resolveMd5(query)
|
const contact = resolveMd5(query)
|
||||||
if (!contact) return { success: false, error: `没有找到群聊“${query}”` }
|
if (!contact) return reportFailure(`没有找到群聊“${query}”`, 'data', 'CHAT_NOT_FOUND')
|
||||||
if (contact.type !== 'group' && !contact.m_nsUsrName.endsWith('@chatroom')) {
|
if (contact.type !== 'group' && !contact.m_nsUsrName.endsWith('@chatroom')) {
|
||||||
return { success: false, error: `“${query}”不是群聊` }
|
return reportFailure(`“${query}”不是群聊`, 'data', 'CHAT_NOT_FOUND')
|
||||||
}
|
}
|
||||||
|
|
||||||
const range = request.range === 'yesterday' || request.range === '7days' ? request.range : 'today'
|
const range = request.range === 'yesterday' || request.range === '7days' ? request.range : 'today'
|
||||||
@@ -73,7 +78,7 @@ export async function generateAgentGroupReport(
|
|||||||
})()
|
})()
|
||||||
: getSummaryDateRange(range)
|
: getSummaryDateRange(range)
|
||||||
let messages = listMessages(contact.md5, startTime, endTime) as Message[]
|
let messages = listMessages(contact.md5, startTime, endTime) as Message[]
|
||||||
if (!messages.length) return { success: false, error: '所选时间范围没有可总结的消息' }
|
if (!messages.length) return reportFailure('所选时间范围没有可总结的消息', 'data', 'NO_MESSAGES')
|
||||||
|
|
||||||
const messageTypeMap: Record<ScheduledReportMessageType, string[]> = {
|
const messageTypeMap: Record<ScheduledReportMessageType, string[]> = {
|
||||||
text: ['普通文本'],
|
text: ['普通文本'],
|
||||||
@@ -91,7 +96,7 @@ export async function generateAgentGroupReport(
|
|||||||
).flatMap((type) => messageTypeMap[type] || [])
|
).flatMap((type) => messageTypeMap[type] || [])
|
||||||
)
|
)
|
||||||
messages = messages.filter((message) => selectedTypes.has(message.type))
|
messages = messages.filter((message) => selectedTypes.has(message.type))
|
||||||
if (!messages.length) return { success: false, error: '所选时间范围没有可总结的消息' }
|
if (!messages.length) return reportFailure('所选时间范围没有可总结的消息', 'data', 'NO_MESSAGES')
|
||||||
|
|
||||||
const snapshot = getGroupSnapshot(contact.md5)
|
const snapshot = getGroupSnapshot(contact.md5)
|
||||||
if (snapshot) {
|
if (snapshot) {
|
||||||
@@ -122,7 +127,13 @@ export async function generateAgentGroupReport(
|
|||||||
],
|
],
|
||||||
{ timeoutMs: Math.max(30, Math.min(1800, request.timeoutSeconds || 300)) * 1000 }
|
{ timeoutMs: Math.max(30, Math.min(1800, request.timeoutSeconds || 300)) * 1000 }
|
||||||
)
|
)
|
||||||
if (!ai.success || !ai.data) return { success: false, error: ai.error || 'AI 总结失败' }
|
if (!ai.success || !ai.data) {
|
||||||
|
return {
|
||||||
|
...reportFailure(ai.error || 'AI 总结失败', 'ai', ai.errorCode),
|
||||||
|
...(ai.errorStatus !== undefined ? { errorStatus: ai.errorStatus } : {}),
|
||||||
|
...(ai.errorType ? { errorType: ai.errorType } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
let tokenUsage = ai.usage
|
let tokenUsage = ai.usage
|
||||||
const parseReport = (raw: string): ReturnType<typeof parseGroupDailyReport> =>
|
const parseReport = (raw: string): ReturnType<typeof parseGroupDailyReport> =>
|
||||||
parseGroupDailyReport(
|
parseGroupDailyReport(
|
||||||
@@ -147,18 +158,24 @@ export async function generateAgentGroupReport(
|
|||||||
if (!repaired.success || !repaired.data) {
|
if (!repaired.success || !repaired.data) {
|
||||||
const cause = parseError instanceof Error ? parseError.message : String(parseError)
|
const cause = parseError instanceof Error ? parseError.message : String(parseError)
|
||||||
return {
|
return {
|
||||||
success: false,
|
...reportFailure(
|
||||||
error: `${repaired.error || 'AI 修复日报 JSON 失败'}(原始错误:${cause})`
|
`${repaired.error || 'AI 修复日报 JSON 失败'}(原始错误:${cause})`,
|
||||||
|
'ai',
|
||||||
|
repaired.errorCode
|
||||||
|
),
|
||||||
|
...(repaired.errorStatus !== undefined ? { errorStatus: repaired.errorStatus } : {}),
|
||||||
|
...(repaired.errorType ? { errorType: repaired.errorType } : {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tokenUsage = mergeTokenUsage(tokenUsage, repaired.usage)
|
tokenUsage = mergeTokenUsage(tokenUsage, repaired.usage)
|
||||||
try {
|
try {
|
||||||
report = parseReport(repaired.data)
|
report = parseReport(repaired.data)
|
||||||
} catch (repairError) {
|
} catch (repairError) {
|
||||||
return {
|
return reportFailure(
|
||||||
success: false,
|
repairError instanceof Error ? repairError.message : String(repairError),
|
||||||
error: repairError instanceof Error ? repairError.message : String(repairError)
|
'report',
|
||||||
}
|
'REPORT_GENERATION_FAILED'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const exported = await exportGroupReport({
|
const exported = await exportGroupReport({
|
||||||
@@ -167,7 +184,7 @@ export async function generateAgentGroupReport(
|
|||||||
templateId: request.templateId
|
templateId: request.templateId
|
||||||
})
|
})
|
||||||
if (!exported.success || !exported.pngPath) {
|
if (!exported.success || !exported.pngPath) {
|
||||||
return { success: false, error: exported.error || '总结图片生成失败' }
|
return reportFailure(exported.error || '总结图片生成失败', 'report', 'REPORT_GENERATION_FAILED')
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -196,3 +213,16 @@ function mergeTokenUsage(
|
|||||||
estimated: Boolean(first.estimated || second.estimated)
|
estimated: Boolean(first.estimated || second.estimated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reportFailure(
|
||||||
|
error: string,
|
||||||
|
errorStage: ScheduledReportExecutionStage,
|
||||||
|
errorCode?: string
|
||||||
|
): AgentGroupReportResult {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error,
|
||||||
|
errorStage,
|
||||||
|
...(errorCode ? { errorCode } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { app, BrowserWindow } from 'electron'
|
import { app, BrowserWindow } from 'electron'
|
||||||
import { ChildProcess, execFile, spawn } from 'child_process'
|
import { ChildProcess, execFile, spawn } from 'child_process'
|
||||||
import { randomBytes, timingSafeEqual } from 'crypto'
|
import { randomBytes, timingSafeEqual } from 'crypto'
|
||||||
import { appendFileSync, existsSync, mkdirSync, writeFileSync } from 'fs'
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'http'
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'http'
|
||||||
import { dirname, join } from 'path'
|
import { dirname, join } from 'path'
|
||||||
import { promisify } from 'util'
|
import { promisify } from 'util'
|
||||||
@@ -40,6 +40,19 @@ interface InboundMessage {
|
|||||||
items?: Array<{ type?: number; text?: string }>
|
items?: Array<{ type?: number; text?: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AgentHubNotificationRecipient {
|
||||||
|
accountId?: string
|
||||||
|
userId: string
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentHubNotificationResult {
|
||||||
|
success: boolean
|
||||||
|
status: 'sent' | 'recipient_unavailable' | 'connector_offline' | 'token_expired' | 'send_failed'
|
||||||
|
recipient?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
interface GroupReportIntent {
|
interface GroupReportIntent {
|
||||||
group: string
|
group: string
|
||||||
range: 'today' | 'yesterday' | '7days'
|
range: 'today' | 'yesterday' | '7days'
|
||||||
@@ -94,7 +107,7 @@ export function resolveWechatConnectorBinaryPath(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
class AgentHubService {
|
export class AgentHubService {
|
||||||
private hubServer: Server | null = null
|
private hubServer: Server | null = null
|
||||||
private connectorChild: ChildProcess | null = null
|
private connectorChild: ChildProcess | null = null
|
||||||
private loginChild: ChildProcess | null = null
|
private loginChild: ChildProcess | null = null
|
||||||
@@ -103,6 +116,8 @@ class AgentHubService {
|
|||||||
private logs: AgentHubLogEntry[] = []
|
private logs: AgentHubLogEntry[] = []
|
||||||
private nextLogId = 1
|
private nextLogId = 1
|
||||||
private readonly processedMessages = new Map<string, number>()
|
private readonly processedMessages = new Map<string, number>()
|
||||||
|
private notificationRecipient: AgentHubNotificationRecipient | null = null
|
||||||
|
private notificationRecipientLoaded = false
|
||||||
private readonly inboundToken =
|
private readonly inboundToken =
|
||||||
process.env['AGENT_HUB_INBOUND_TOKEN'] || randomBytes(32).toString('hex')
|
process.env['AGENT_HUB_INBOUND_TOKEN'] || randomBytes(32).toString('hex')
|
||||||
private status: AgentHubStatus = {
|
private status: AgentHubStatus = {
|
||||||
@@ -115,6 +130,7 @@ class AgentHubService {
|
|||||||
async start(settings: AppSettings): Promise<boolean> {
|
async start(settings: AppSettings): Promise<boolean> {
|
||||||
void settings
|
void settings
|
||||||
this.stopping = false
|
this.stopping = false
|
||||||
|
this.loadNotificationRecipient()
|
||||||
const hubStarted = await this.startHub()
|
const hubStarted = await this.startHub()
|
||||||
await this.initializeConnector()
|
await this.initializeConnector()
|
||||||
return hubStarted
|
return hubStarted
|
||||||
@@ -124,6 +140,53 @@ class AgentHubService {
|
|||||||
return { ...this.status }
|
return { ...this.status }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The last user who sent an inbound message to this Agent Hub bot. */
|
||||||
|
getNotificationRecipient(): string | undefined {
|
||||||
|
this.loadNotificationRecipient()
|
||||||
|
return this.notificationRecipient?.userId
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendNotification(input: {
|
||||||
|
to?: string
|
||||||
|
text: string
|
||||||
|
}): Promise<AgentHubNotificationResult> {
|
||||||
|
const to = String(input.to || this.getNotificationRecipient() || '').trim()
|
||||||
|
const text = String(input.text || '').trim()
|
||||||
|
if (!to || !text) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
status: 'recipient_unavailable',
|
||||||
|
error: 'Agent Hub 尚未记录可靠的通知接收者'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const accountId = this.notificationRecipient?.accountId || this.status.accountId
|
||||||
|
try {
|
||||||
|
const response = await this.postConnectorMessage({
|
||||||
|
accountId,
|
||||||
|
to,
|
||||||
|
text,
|
||||||
|
timeoutMs: 30_000
|
||||||
|
})
|
||||||
|
if (response.ok) return { success: true, status: 'sent', recipient: to }
|
||||||
|
const expired = /token|session|expired|unauthorized/i.test(response.body)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
status: expired ? 'token_expired' : 'send_failed',
|
||||||
|
recipient: to,
|
||||||
|
error: expired
|
||||||
|
? 'Agent Hub 微信连接器登录凭证已失效'
|
||||||
|
: `Agent Hub 通知发送失败:${response.body || response.status}`
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
status: 'connector_offline',
|
||||||
|
recipient: to,
|
||||||
|
error: `Agent Hub 微信连接器不可用:${this.errorMessage(error)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getLogs(): AgentHubLogEntry[] {
|
getLogs(): AgentHubLogEntry[] {
|
||||||
return [...this.logs]
|
return [...this.logs]
|
||||||
}
|
}
|
||||||
@@ -154,29 +217,24 @@ class AgentHubService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`http://${CONNECTOR_ADDR}/api/send`, {
|
const response = await this.postConnectorMessage({
|
||||||
method: 'POST',
|
accountId: this.status.accountId,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
to,
|
||||||
body: JSON.stringify({
|
text: text || undefined,
|
||||||
account_id: this.status.accountId,
|
mediaUrl: mediaUrl || undefined,
|
||||||
to,
|
timeoutMs: 30_000
|
||||||
text: text || undefined,
|
|
||||||
media_url: mediaUrl || undefined
|
|
||||||
}),
|
|
||||||
signal: AbortSignal.timeout(30_000)
|
|
||||||
})
|
})
|
||||||
const body = await response.text()
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
this.addLog('system', 'info', 'API 页面发送测试成功')
|
this.addLog('system', 'info', 'API 页面发送测试成功')
|
||||||
return { success: true, status: 'sent', message: '发送成功' }
|
return { success: true, status: 'sent', message: '发送成功' }
|
||||||
}
|
}
|
||||||
const expired = /token|session|expired|unauthorized/i.test(body)
|
const expired = /token|session|expired|unauthorized/i.test(response.body)
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
status: expired ? 'token_expired' : 'send_failed',
|
status: expired ? 'token_expired' : 'send_failed',
|
||||||
message: expired
|
message: expired
|
||||||
? '微信登录凭证已失效,请重新扫码登录'
|
? '微信登录凭证已失效,请重新扫码登录'
|
||||||
: `发送失败:${body || response.status}`
|
: `发送失败:${response.body || response.status}`
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
@@ -326,6 +384,7 @@ class AgentHubService {
|
|||||||
}
|
}
|
||||||
const from = String(inbound.from_user_id || '').trim()
|
const from = String(inbound.from_user_id || '').trim()
|
||||||
if (!from) return this.sendHubJson(response, 400, { error: 'from_user_id is required' })
|
if (!from) return this.sendHubJson(response, 400, { error: 'from_user_id is required' })
|
||||||
|
this.rememberNotificationRecipient(inbound.account_id, from)
|
||||||
|
|
||||||
const messageId = String(inbound.message_id || '')
|
const messageId = String(inbound.message_id || '')
|
||||||
this.cleanProcessedMessages()
|
this.cleanProcessedMessages()
|
||||||
@@ -791,18 +850,35 @@ class AgentHubService {
|
|||||||
text?: string,
|
text?: string,
|
||||||
mediaUrl?: string
|
mediaUrl?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const response = await this.postConnectorMessage({
|
||||||
|
accountId: inbound.account_id || this.status.accountId,
|
||||||
|
to: String(inbound.from_user_id || '').trim(),
|
||||||
|
text,
|
||||||
|
mediaUrl,
|
||||||
|
timeoutMs: mediaUrl ? 60_000 : 30_000
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error(response.body || `HTTP ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async postConnectorMessage(input: {
|
||||||
|
accountId?: string
|
||||||
|
to: string
|
||||||
|
text?: string
|
||||||
|
mediaUrl?: string
|
||||||
|
timeoutMs: number
|
||||||
|
}): Promise<{ ok: boolean; status: number; body: string }> {
|
||||||
const response = await fetch(`http://${CONNECTOR_ADDR}/api/send`, {
|
const response = await fetch(`http://${CONNECTOR_ADDR}/api/send`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
account_id: inbound.account_id,
|
account_id: input.accountId,
|
||||||
to: inbound.from_user_id,
|
to: input.to,
|
||||||
text,
|
text: input.text,
|
||||||
media_url: mediaUrl
|
media_url: input.mediaUrl
|
||||||
}),
|
}),
|
||||||
signal: AbortSignal.timeout(mediaUrl ? 60_000 : 30_000)
|
signal: AbortSignal.timeout(input.timeoutMs)
|
||||||
})
|
})
|
||||||
if (!response.ok) throw new Error((await response.text()) || `HTTP ${response.status}`)
|
return { ok: response.ok, status: response.status, body: await response.text() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private matchRecentChatIntent(text: string): number | null {
|
private matchRecentChatIntent(text: string): number | null {
|
||||||
@@ -965,6 +1041,51 @@ class AgentHubService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private loadNotificationRecipient(): void {
|
||||||
|
if (this.notificationRecipientLoaded) return
|
||||||
|
this.notificationRecipientLoaded = true
|
||||||
|
try {
|
||||||
|
const stored = JSON.parse(readFileSync(this.notificationRecipientPath(), 'utf8')) as {
|
||||||
|
accountId?: unknown
|
||||||
|
userId?: unknown
|
||||||
|
updatedAt?: unknown
|
||||||
|
}
|
||||||
|
const userId = String(stored.userId || '').trim()
|
||||||
|
if (userId) {
|
||||||
|
this.notificationRecipient = {
|
||||||
|
userId,
|
||||||
|
accountId: String(stored.accountId || '').trim() || undefined,
|
||||||
|
updatedAt: Number(stored.updatedAt) || Date.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.notificationRecipient = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private rememberNotificationRecipient(accountId: string | undefined, userId: string): void {
|
||||||
|
const normalizedUserId = String(userId || '').trim()
|
||||||
|
if (!normalizedUserId) return
|
||||||
|
const recipient: AgentHubNotificationRecipient = {
|
||||||
|
userId: normalizedUserId,
|
||||||
|
accountId: String(accountId || this.status.accountId || '').trim() || undefined,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}
|
||||||
|
this.notificationRecipient = recipient
|
||||||
|
this.notificationRecipientLoaded = true
|
||||||
|
try {
|
||||||
|
const filePath = this.notificationRecipientPath()
|
||||||
|
mkdirSync(dirname(filePath), { recursive: true })
|
||||||
|
writeFileSync(filePath, JSON.stringify(recipient, null, 2), 'utf8')
|
||||||
|
} catch (error) {
|
||||||
|
this.addLog('agent-hub', 'warn', `通知接收者保存失败:${this.errorMessage(error)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private notificationRecipientPath(): string {
|
||||||
|
return join(app.getPath('userData'), 'agent-hub', 'notification-recipient.json')
|
||||||
|
}
|
||||||
|
|
||||||
private async loadAccounts(): Promise<{ accountId: string; wechatUserId: string }[]> {
|
private async loadAccounts(): Promise<{ accountId: string; wechatUserId: string }[]> {
|
||||||
const executable = resolveWechatConnectorBinaryPath()
|
const executable = resolveWechatConnectorBinaryPath()
|
||||||
if (!existsSync(executable)) throw new Error(`微信连接器不存在:${executable}`)
|
if (!existsSync(executable)) throw new Error(`微信连接器不存在:${executable}`)
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ type AIRequestResult = {
|
|||||||
usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
|
usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
|
||||||
}
|
}
|
||||||
interface OpenAIResponsePayload {
|
interface OpenAIResponsePayload {
|
||||||
error?: { message?: string }
|
error?: { message?: string; code?: string | number; type?: string }
|
||||||
choices?: Array<{
|
choices?: Array<{
|
||||||
message?: {
|
message?: {
|
||||||
content?: string | Array<{ type?: string; text?: string }> | null
|
content?: string | Array<{ type?: string; text?: string }> | null
|
||||||
@@ -42,12 +42,12 @@ interface OpenAIResponsePayload {
|
|||||||
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number }
|
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number }
|
||||||
}
|
}
|
||||||
interface OpenAIStreamPayload {
|
interface OpenAIStreamPayload {
|
||||||
error?: { message?: string }
|
error?: { message?: string; code?: string | number; type?: string }
|
||||||
choices?: Array<{ delta?: { content?: string }; finish_reason?: string }>
|
choices?: Array<{ delta?: { content?: string }; finish_reason?: string }>
|
||||||
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number }
|
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number }
|
||||||
}
|
}
|
||||||
interface OpenAIResponsesPayload {
|
interface OpenAIResponsesPayload {
|
||||||
error?: { message?: string }
|
error?: { message?: string; code?: string | number; type?: string }
|
||||||
incomplete_details?: { reason?: string }
|
incomplete_details?: { reason?: string }
|
||||||
status?: string
|
status?: string
|
||||||
output_text?: string
|
output_text?: string
|
||||||
@@ -61,16 +61,35 @@ interface OpenAIResponsesStreamPayload {
|
|||||||
type?: string
|
type?: string
|
||||||
delta?: string
|
delta?: string
|
||||||
message?: string
|
message?: string
|
||||||
error?: { message?: string }
|
error?: { message?: string; code?: string | number; type?: string }
|
||||||
response?: OpenAIResponsesPayload
|
response?: OpenAIResponsesPayload
|
||||||
}
|
}
|
||||||
interface AnthropicResponsePayload {
|
interface AnthropicResponsePayload {
|
||||||
error?: { message?: string }
|
error?: { message?: string; type?: string }
|
||||||
content?: Array<{ type?: string; text?: string }>
|
content?: Array<{ type?: string; text?: string }>
|
||||||
stop_reason?: string
|
stop_reason?: string
|
||||||
usage?: { input_tokens?: number; output_tokens?: number }
|
usage?: { input_tokens?: number; output_tokens?: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class AIProviderRequestError extends Error {
|
||||||
|
readonly status?: number
|
||||||
|
readonly code?: string
|
||||||
|
readonly type?: string
|
||||||
|
readonly responseBody?: unknown
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
details: { status?: number; code?: unknown; type?: unknown; responseBody?: unknown } = {}
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'AIProviderRequestError'
|
||||||
|
this.status = details.status
|
||||||
|
this.code = typeof details.code === 'string' ? details.code : undefined
|
||||||
|
this.type = typeof details.type === 'string' ? details.type : undefined
|
||||||
|
this.responseBody = details.responseBody
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class AIProviderService {
|
export class AIProviderService {
|
||||||
constructor(private readonly keyStore = new AIProviderKeyStore()) {}
|
constructor(private readonly keyStore = new AIProviderKeyStore()) {}
|
||||||
|
|
||||||
@@ -272,12 +291,15 @@ export class AIProviderService {
|
|||||||
finishReason?: string
|
finishReason?: string
|
||||||
usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
|
usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
|
||||||
error?: string
|
error?: string
|
||||||
|
errorCode?: string
|
||||||
|
errorStatus?: number
|
||||||
|
errorType?: string
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
return { success: true, ...(await this.request(messages, options, false, signal, onDelta)) }
|
return { success: true, ...(await this.request(messages, options, false, signal, onDelta)) }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (signal?.aborted) throw error
|
if (signal?.aborted) throw error
|
||||||
return { success: false, error: safeAIError(error) }
|
return { success: false, error: safeAIError(error), ...aiProviderErrorDetails(error) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -752,7 +774,15 @@ async function requestOpenAICompatible(
|
|||||||
async (response) => {
|
async (response) => {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const payload = await parseJsonResponse<OpenAIResponsePayload>(response)
|
const payload = await parseJsonResponse<OpenAIResponsePayload>(response)
|
||||||
throw new Error(payload.error?.message || `AI 请求失败 (${response.status})`)
|
throw new AIProviderRequestError(
|
||||||
|
payload.error?.message || `AI 请求失败 (${response.status})`,
|
||||||
|
{
|
||||||
|
status: response.status,
|
||||||
|
code: payload.error?.code,
|
||||||
|
type: payload.error?.type,
|
||||||
|
responseBody: payload.error
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
provider.advanced.stream &&
|
provider.advanced.stream &&
|
||||||
@@ -810,7 +840,15 @@ async function requestOpenAIResponses(
|
|||||||
async (response) => {
|
async (response) => {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const payload = await parseJsonResponse<OpenAIResponsesPayload>(response)
|
const payload = await parseJsonResponse<OpenAIResponsesPayload>(response)
|
||||||
throw new Error(payload.error?.message || `AI 请求失败 (${response.status})`)
|
throw new AIProviderRequestError(
|
||||||
|
payload.error?.message || `AI 请求失败 (${response.status})`,
|
||||||
|
{
|
||||||
|
status: response.status,
|
||||||
|
code: payload.error?.code,
|
||||||
|
type: payload.error?.type,
|
||||||
|
responseBody: payload.error
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (provider.advanced.stream) return parseOpenAIResponsesStream(response, onDelta)
|
if (provider.advanced.stream) return parseOpenAIResponsesStream(response, onDelta)
|
||||||
const payload = await parseJsonResponse<OpenAIResponsesPayload>(response)
|
const payload = await parseJsonResponse<OpenAIResponsesPayload>(response)
|
||||||
@@ -867,7 +905,14 @@ async function requestAnthropic(
|
|||||||
async (response) => {
|
async (response) => {
|
||||||
const payload = await parseJsonResponse<AnthropicResponsePayload>(response)
|
const payload = await parseJsonResponse<AnthropicResponsePayload>(response)
|
||||||
if (!response.ok)
|
if (!response.ok)
|
||||||
throw new Error(payload.error?.message || `Anthropic 请求失败 (${response.status})`)
|
throw new AIProviderRequestError(
|
||||||
|
payload.error?.message || `Anthropic 请求失败 (${response.status})`,
|
||||||
|
{
|
||||||
|
status: response.status,
|
||||||
|
type: payload.error?.type,
|
||||||
|
responseBody: payload.error
|
||||||
|
}
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
data: Array.isArray(payload.content)
|
data: Array.isArray(payload.content)
|
||||||
? payload.content
|
? payload.content
|
||||||
@@ -1156,6 +1201,27 @@ function safeAIError(error: unknown): string {
|
|||||||
return message.replace(/sk-[a-z0-9_-]+/gi, '***').slice(0, 300)
|
return message.replace(/sk-[a-z0-9_-]+/gi, '***').slice(0, 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function aiProviderErrorDetails(error: unknown): {
|
||||||
|
errorCode?: string
|
||||||
|
errorStatus?: number
|
||||||
|
errorType?: string
|
||||||
|
} {
|
||||||
|
if (error instanceof AIProviderRequestError) {
|
||||||
|
return {
|
||||||
|
...(error.code ? { errorCode: error.code } : {}),
|
||||||
|
...(error.status !== undefined ? { errorStatus: error.status } : {}),
|
||||||
|
...(error.type ? { errorType: error.type } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const record = error && typeof error === 'object' ? (error as Record<string, unknown>) : undefined
|
||||||
|
const status = Number(record?.status)
|
||||||
|
return {
|
||||||
|
...(typeof record?.code === 'string' ? { errorCode: record.code } : {}),
|
||||||
|
...(Number.isFinite(status) && status > 0 ? { errorStatus: status } : {}),
|
||||||
|
...(typeof record?.type === 'string' ? { errorType: record.type } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function parseVisionImage(dataUrl: string): { mimeType: string; base64: string; bytes: number } {
|
function parseVisionImage(dataUrl: string): { mimeType: string; base64: string; bytes: number } {
|
||||||
const match = /^data:(image\/(?:png|jpeg|webp));base64,([a-z0-9+/=]+)$/i.exec(dataUrl)
|
const match = /^data:(image\/(?:png|jpeg|webp));base64,([a-z0-9+/=]+)$/i.exec(dataUrl)
|
||||||
if (!match) throw new Error('图片格式不受支持,请选择 PNG、JPG、JPEG 或 WebP')
|
if (!match) throw new Error('图片格式不受支持,请选择 PNG、JPG、JPEG 或 WebP')
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ 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
|
||||||
@@ -102,7 +103,6 @@ export class ScheduledReportApiService {
|
|||||||
request: ScheduledReportApiCreateRequest
|
request: ScheduledReportApiCreateRequest
|
||||||
): Promise<ScheduledReportApiTask> {
|
): Promise<ScheduledReportApiTask> {
|
||||||
this.assertDatabaseReady()
|
this.assertDatabaseReady()
|
||||||
await this.assertCapabilityReady()
|
|
||||||
if (!isRecord(request))
|
if (!isRecord(request))
|
||||||
throw new ScheduledReportApiError(400, 'invalid_request', '请求体格式无效')
|
throw new ScheduledReportApiError(400, 'invalid_request', '请求体格式无效')
|
||||||
|
|
||||||
@@ -250,6 +250,18 @@ 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', '当前运行时不支持重新发送日报')
|
||||||
|
}
|
||||||
|
const result = await retry.call(this.deps.service, executionId)
|
||||||
|
if (!result.data) {
|
||||||
|
throw new ScheduledReportApiError(404, 'not_found', result.error || '未找到定时日报执行记录')
|
||||||
|
}
|
||||||
|
return result.data
|
||||||
|
}
|
||||||
|
|
||||||
async executions(taskId: string): Promise<ScheduledReportApiExecution[]> {
|
async executions(taskId: string): Promise<ScheduledReportApiExecution[]> {
|
||||||
this.assertDatabaseReady()
|
this.assertDatabaseReady()
|
||||||
const task = await this.findTask(taskId)
|
const task = await this.findTask(taskId)
|
||||||
@@ -277,27 +289,6 @@ export class ScheduledReportApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async assertCapabilityReady(): Promise<void> {
|
|
||||||
let capability: PersonalWechatSendCapability
|
|
||||||
try {
|
|
||||||
capability = this.applyPlatformCapability(await this.deps.getCapability())
|
|
||||||
} catch (error) {
|
|
||||||
throw new ScheduledReportApiError(
|
|
||||||
503,
|
|
||||||
'wechat_capability_unavailable',
|
|
||||||
'微信发送能力检测失败,请在 TraceMemo 设置中检查个人微信。',
|
|
||||||
error instanceof Error ? error.message : String(error)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (capability.ready && capability.capabilities.image) return
|
|
||||||
throw new ScheduledReportApiError(
|
|
||||||
409,
|
|
||||||
'wechat_not_ready',
|
|
||||||
capability.message || 'TraceMemo 当前还没有完成微信消息发送能力配置',
|
|
||||||
{ capability: publicCapability(capability) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private applyPlatformCapability(
|
private applyPlatformCapability(
|
||||||
capability: PersonalWechatSendCapability
|
capability: PersonalWechatSendCapability
|
||||||
): PersonalWechatSendCapability {
|
): PersonalWechatSendCapability {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Vendored
+12
@@ -65,6 +65,8 @@ import type { PersonalWechatSendCapability } from '../shared/personal-wechat'
|
|||||||
import type {
|
import type {
|
||||||
ScheduledReportCreateInput,
|
ScheduledReportCreateInput,
|
||||||
ScheduledReportExecution,
|
ScheduledReportExecution,
|
||||||
|
ScheduledReportNotificationSettings,
|
||||||
|
ScheduledReportNotificationSettingsResult,
|
||||||
ScheduledReportResult,
|
ScheduledReportResult,
|
||||||
ScheduledReportTask,
|
ScheduledReportTask,
|
||||||
ScheduledReportUpdateInput
|
ScheduledReportUpdateInput
|
||||||
@@ -636,6 +638,10 @@ declare global {
|
|||||||
) => Promise<PersonalWechatSendResult>
|
) => Promise<PersonalWechatSendResult>
|
||||||
listScheduledReports: () => Promise<ScheduledReportTask[]>
|
listScheduledReports: () => Promise<ScheduledReportTask[]>
|
||||||
listScheduledReportExecutions: (taskId?: string) => Promise<ScheduledReportExecution[]>
|
listScheduledReportExecutions: (taskId?: string) => Promise<ScheduledReportExecution[]>
|
||||||
|
getScheduledReportNotificationSettings: () => Promise<ScheduledReportNotificationSettings>
|
||||||
|
setScheduledReportNotificationEnabled: (
|
||||||
|
enabled: boolean
|
||||||
|
) => Promise<ScheduledReportNotificationSettingsResult>
|
||||||
createScheduledReport: (
|
createScheduledReport: (
|
||||||
request: ScheduledReportCreateInput
|
request: ScheduledReportCreateInput
|
||||||
) => Promise<ScheduledReportResult<ScheduledReportTask>>
|
) => Promise<ScheduledReportResult<ScheduledReportTask>>
|
||||||
@@ -653,6 +659,12 @@ declare global {
|
|||||||
runScheduledReportNow: (
|
runScheduledReportNow: (
|
||||||
taskId: string
|
taskId: string
|
||||||
) => Promise<ScheduledReportResult<ScheduledReportExecution>>
|
) => Promise<ScheduledReportResult<ScheduledReportExecution>>
|
||||||
|
retryScheduledReportSend: (
|
||||||
|
executionId: string
|
||||||
|
) => Promise<ScheduledReportResult<ScheduledReportExecution>>
|
||||||
|
testScheduledReportErrorNotification: (
|
||||||
|
taskId: string
|
||||||
|
) => Promise<ScheduledReportResult<ScheduledReportExecution>>
|
||||||
getPersonalWechatVoiceDiagnostic: () => Promise<PersonalWechatVoiceDiagnostic | null>
|
getPersonalWechatVoiceDiagnostic: () => Promise<PersonalWechatVoiceDiagnostic | null>
|
||||||
getAgentHubStatus: () => Promise<AgentHubStatus>
|
getAgentHubStatus: () => Promise<AgentHubStatus>
|
||||||
getAgentHubLogs: () => Promise<AgentHubLogEntry[]>
|
getAgentHubLogs: () => Promise<AgentHubLogEntry[]>
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ import type { PersonalWechatSendCapability } from '../shared/personal-wechat'
|
|||||||
import type {
|
import type {
|
||||||
ScheduledReportCreateInput,
|
ScheduledReportCreateInput,
|
||||||
ScheduledReportExecution,
|
ScheduledReportExecution,
|
||||||
|
ScheduledReportNotificationSettings,
|
||||||
|
ScheduledReportNotificationSettingsResult,
|
||||||
ScheduledReportResult,
|
ScheduledReportResult,
|
||||||
ScheduledReportTask,
|
ScheduledReportTask,
|
||||||
ScheduledReportUpdateInput
|
ScheduledReportUpdateInput
|
||||||
@@ -441,6 +443,12 @@ const api = {
|
|||||||
ipcRenderer.invoke('scheduled-report:list'),
|
ipcRenderer.invoke('scheduled-report:list'),
|
||||||
listScheduledReportExecutions: (taskId?: string): Promise<ScheduledReportExecution[]> =>
|
listScheduledReportExecutions: (taskId?: string): Promise<ScheduledReportExecution[]> =>
|
||||||
ipcRenderer.invoke('scheduled-report:listExecutions', taskId),
|
ipcRenderer.invoke('scheduled-report:listExecutions', taskId),
|
||||||
|
getScheduledReportNotificationSettings: (): Promise<ScheduledReportNotificationSettings> =>
|
||||||
|
ipcRenderer.invoke('scheduled-report:getNotificationSettings'),
|
||||||
|
setScheduledReportNotificationEnabled: (
|
||||||
|
enabled: boolean
|
||||||
|
): Promise<ScheduledReportNotificationSettingsResult> =>
|
||||||
|
ipcRenderer.invoke('scheduled-report:setNotificationEnabled', enabled),
|
||||||
createScheduledReport: (
|
createScheduledReport: (
|
||||||
request: ScheduledReportCreateInput
|
request: ScheduledReportCreateInput
|
||||||
): Promise<ScheduledReportResult<ScheduledReportTask>> =>
|
): Promise<ScheduledReportResult<ScheduledReportTask>> =>
|
||||||
@@ -461,6 +469,14 @@ const api = {
|
|||||||
taskId: string
|
taskId: string
|
||||||
): Promise<ScheduledReportResult<ScheduledReportExecution>> =>
|
): Promise<ScheduledReportResult<ScheduledReportExecution>> =>
|
||||||
ipcRenderer.invoke('scheduled-report:runNow', taskId),
|
ipcRenderer.invoke('scheduled-report:runNow', taskId),
|
||||||
|
retryScheduledReportSend: (
|
||||||
|
executionId: string
|
||||||
|
): Promise<ScheduledReportResult<ScheduledReportExecution>> =>
|
||||||
|
ipcRenderer.invoke('scheduled-report:retrySend', executionId),
|
||||||
|
testScheduledReportErrorNotification: (
|
||||||
|
taskId: string
|
||||||
|
): Promise<ScheduledReportResult<ScheduledReportExecution>> =>
|
||||||
|
ipcRenderer.invoke('scheduled-report:testErrorNotification', taskId),
|
||||||
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'),
|
||||||
|
|||||||
@@ -1512,6 +1512,10 @@ function App(): React.ReactElement {
|
|||||||
setActivePage('settings')
|
setActivePage('settings')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openAgentHub = (): void => {
|
||||||
|
setActivePage('agent-hub')
|
||||||
|
}
|
||||||
|
|
||||||
const dismissFirstUseWelcome = (): void => {
|
const dismissFirstUseWelcome = (): void => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(FIRST_USE_WELCOME_SEEN_KEY, '1')
|
localStorage.setItem(FIRST_USE_WELCOME_SEEN_KEY, '1')
|
||||||
@@ -1604,6 +1608,7 @@ function App(): React.ReactElement {
|
|||||||
contactId: reportSourceContact.md5,
|
contactId: reportSourceContact.md5,
|
||||||
contactName: contactDisplayName(reportSourceContact),
|
contactName: contactDisplayName(reportSourceContact),
|
||||||
contactAvatar: reportSourceContact.avatar || undefined,
|
contactAvatar: reportSourceContact.avatar || undefined,
|
||||||
|
source: 'manual',
|
||||||
dateRange:
|
dateRange:
|
||||||
summaryDateRange === 'yesterday'
|
summaryDateRange === 'yesterday'
|
||||||
? '昨日'
|
? '昨日'
|
||||||
@@ -1816,6 +1821,7 @@ function App(): React.ReactElement {
|
|||||||
contacts={contacts}
|
contacts={contacts}
|
||||||
platformSupported={supportsPersonalWechatSend}
|
platformSupported={supportsPersonalWechatSend}
|
||||||
onOpenWechatSettings={openWechatSendSettings}
|
onOpenWechatSettings={openWechatSendSettings}
|
||||||
|
onOpenAgentHub={openAgentHub}
|
||||||
onOpenModelSettings={openModelSettings}
|
onOpenModelSettings={openModelSettings}
|
||||||
onNotice={(message, variant) =>
|
onNotice={(message, variant) =>
|
||||||
toast({ description: message, variant, duration: 3200 })
|
toast({ description: message, variant, duration: 3200 })
|
||||||
@@ -1825,6 +1831,7 @@ function App(): React.ReactElement {
|
|||||||
<div className="report-center-page">
|
<div className="report-center-page">
|
||||||
<ReportHistorySidebar
|
<ReportHistorySidebar
|
||||||
reports={generatedReports}
|
reports={generatedReports}
|
||||||
|
contacts={contacts}
|
||||||
selectedReportId={selectedReportId}
|
selectedReportId={selectedReportId}
|
||||||
selfInfo={selfInfo}
|
selfInfo={selfInfo}
|
||||||
dbReady={isDatabaseConnected}
|
dbReady={isDatabaseConnected}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Input
|
Input
|
||||||
} from '../ui'
|
} from '../ui'
|
||||||
import type { GeneratedReportRecord } from './types'
|
import type { GeneratedReportRecord } from './types'
|
||||||
|
import type { Contact } from '../../../../shared/types'
|
||||||
|
|
||||||
interface SelfInfo {
|
interface SelfInfo {
|
||||||
wxid: string
|
wxid: string
|
||||||
@@ -22,6 +23,7 @@ interface SelfInfo {
|
|||||||
|
|
||||||
interface ReportHistorySidebarProps {
|
interface ReportHistorySidebarProps {
|
||||||
reports: GeneratedReportRecord[]
|
reports: GeneratedReportRecord[]
|
||||||
|
contacts?: Contact[]
|
||||||
selectedReportId: string | null
|
selectedReportId: string | null
|
||||||
selfInfo: SelfInfo | null
|
selfInfo: SelfInfo | null
|
||||||
dbReady: boolean
|
dbReady: boolean
|
||||||
@@ -88,8 +90,12 @@ const buildGroups = (reports: GeneratedReportRecord[]): ReportGroup[] => {
|
|||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reportTitle = (report: GeneratedReportRecord): string =>
|
||||||
|
report.source === 'scheduled' ? `${report.contactName}的定时日报` : report.contactName
|
||||||
|
|
||||||
export function ReportHistorySidebar({
|
export function ReportHistorySidebar({
|
||||||
reports,
|
reports,
|
||||||
|
contacts = [],
|
||||||
selectedReportId,
|
selectedReportId,
|
||||||
selfInfo,
|
selfInfo,
|
||||||
dbReady,
|
dbReady,
|
||||||
@@ -105,6 +111,15 @@ export function ReportHistorySidebar({
|
|||||||
const [deletePending, setDeletePending] = useState(false)
|
const [deletePending, setDeletePending] = useState(false)
|
||||||
const deleteTriggerRef = useRef<HTMLButtonElement | null>(null)
|
const deleteTriggerRef = useRef<HTMLButtonElement | null>(null)
|
||||||
const restoreDeleteFocusRef = useRef(false)
|
const restoreDeleteFocusRef = useRef(false)
|
||||||
|
const contactAvatars = useMemo(() => {
|
||||||
|
const result = new Map<string, string>()
|
||||||
|
contacts.forEach((contact) => {
|
||||||
|
if (!contact.avatar) return
|
||||||
|
result.set(contact.md5, contact.avatar)
|
||||||
|
result.set(contact.m_nsUsrName, contact.avatar)
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}, [contacts])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pendingDelete || !restoreDeleteFocusRef.current) return
|
if (pendingDelete || !restoreDeleteFocusRef.current) return
|
||||||
@@ -116,9 +131,10 @@ export function ReportHistorySidebar({
|
|||||||
const lower = keyword.trim().toLowerCase()
|
const lower = keyword.trim().toLowerCase()
|
||||||
const filteredReports = reports
|
const filteredReports = reports
|
||||||
.filter((report) => {
|
.filter((report) => {
|
||||||
const haystack = `${report.contactName} ${report.dateRange} ${formatGeneratedAt(
|
const haystack =
|
||||||
report.generatedAt
|
`${reportTitle(report)} ${report.contactName} ${report.dateRange} ${formatGeneratedAt(
|
||||||
)}`.toLowerCase()
|
report.generatedAt
|
||||||
|
)}`.toLowerCase()
|
||||||
return lower ? haystack.includes(lower) : true
|
return lower ? haystack.includes(lower) : true
|
||||||
})
|
})
|
||||||
.sort((left, right) => dateKey(right.generatedAt) - dateKey(left.generatedAt))
|
.sort((left, right) => dateKey(right.generatedAt) - dateKey(left.generatedAt))
|
||||||
@@ -187,10 +203,10 @@ export function ReportHistorySidebar({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="report-history-avatar">
|
<span className="report-history-avatar">
|
||||||
{report.contactAvatar ? (
|
{report.contactAvatar || contactAvatars.get(report.contactId) ? (
|
||||||
<img
|
<img
|
||||||
src={report.contactAvatar}
|
src={report.contactAvatar || contactAvatars.get(report.contactId)}
|
||||||
alt={report.contactName}
|
alt={reportTitle(report)}
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -198,7 +214,7 @@ export function ReportHistorySidebar({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="report-history-text">
|
<span className="report-history-text">
|
||||||
<b>{report.contactName}</b>
|
<b>{reportTitle(report)}</b>
|
||||||
<small>{formatGeneratedAt(report.generatedAt)}</small>
|
<small>{formatGeneratedAt(report.generatedAt)}</small>
|
||||||
<small>{report.messageCount} 条消息</small>
|
<small>{report.messageCount} 条消息</small>
|
||||||
<em>PNG{report.pngStatus === 'ready' ? '已保存' : '缺失'}</em>
|
<em>PNG{report.pngStatus === 'ready' ? '已保存' : '缺失'}</em>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
ScheduledReportExecution,
|
ScheduledReportExecution,
|
||||||
ScheduledReportMessageType,
|
ScheduledReportMessageType,
|
||||||
ScheduledReportMemberNameMode,
|
ScheduledReportMemberNameMode,
|
||||||
|
ScheduledReportNotificationCapabilityReason,
|
||||||
ScheduledReportRange,
|
ScheduledReportRange,
|
||||||
ScheduledReportTask
|
ScheduledReportTask
|
||||||
} from '../../../../shared/scheduled-report'
|
} from '../../../../shared/scheduled-report'
|
||||||
@@ -46,6 +47,7 @@ import {
|
|||||||
type SummaryDateRange
|
type SummaryDateRange
|
||||||
} from '../../utils/group-report'
|
} from '../../utils/group-report'
|
||||||
import { REPORT_TEMPLATES, DEFAULT_REPORT_TEMPLATE } from '../../../../shared/report-templates'
|
import { REPORT_TEMPLATES, DEFAULT_REPORT_TEMPLATE } from '../../../../shared/report-templates'
|
||||||
|
import { isTruthyDebugFlag } from '../../../../shared/debug-flags'
|
||||||
|
|
||||||
type ReportDialogMode = 'create' | 'edit'
|
type ReportDialogMode = 'create' | 'edit'
|
||||||
|
|
||||||
@@ -53,6 +55,7 @@ interface ScheduledReportsWorkspaceProps {
|
|||||||
contacts: Contact[]
|
contacts: Contact[]
|
||||||
platformSupported?: boolean
|
platformSupported?: boolean
|
||||||
onOpenWechatSettings: () => void
|
onOpenWechatSettings: () => void
|
||||||
|
onOpenAgentHub: () => void
|
||||||
onOpenModelSettings?: () => void
|
onOpenModelSettings?: () => void
|
||||||
onNotice: (message: string, variant?: 'default' | 'success' | 'warning' | 'destructive') => void
|
onNotice: (message: string, variant?: 'default' | 'success' | 'warning' | 'destructive') => void
|
||||||
}
|
}
|
||||||
@@ -125,24 +128,37 @@ const capabilityTone = (capability: PersonalWechatSendCapability | null): string
|
|||||||
|
|
||||||
const readableError = (error?: string): string => {
|
const readableError = (error?: string): string => {
|
||||||
const value = String(error || '')
|
const value = String(error || '')
|
||||||
if (value.includes('wechat_not_ready') || value.includes('仅支持 macOS')) {
|
|
||||||
if (value.includes('unsupported') || value.includes('仅支持'))
|
|
||||||
return '微信消息发送目前仅支持 macOS 和 Windows'
|
|
||||||
if (value.includes('needs_binding') || value.includes('unconfigured')) return '请先绑定个人微信'
|
|
||||||
if (value.includes('needs_verification')) return '请先完成微信消息能力检测'
|
|
||||||
return '微信发送能力不可用,请检查微信连接。'
|
|
||||||
}
|
|
||||||
if (value.includes('wechat_send_failed')) return '微信发送失败,请检查微信连接。'
|
|
||||||
if (value.includes('report_generation_failed')) return '日报生成失败,请稍后重试。'
|
|
||||||
return value || '操作失败,请稍后重试。'
|
return value || '操作失败,请稍后重试。'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const notificationFailureCopy = (
|
||||||
|
reason?: ScheduledReportNotificationCapabilityReason,
|
||||||
|
error?: string
|
||||||
|
): string => {
|
||||||
|
if (reason === 'agent_hub_offline') return '需要先连接 Agent Hub 微信机器人,才能接收异常通知。'
|
||||||
|
if (reason === 'connector_offline') return 'Agent Hub 微信连接器当前未在线,请先恢复连接。'
|
||||||
|
if (reason === 'recipient_not_bound') {
|
||||||
|
return 'Agent Hub 已连接,但还不知道异常通知应该发送给谁。请先在微信中给 TraceMemo 机器人发送一条消息,完成通知接收者绑定。'
|
||||||
|
}
|
||||||
|
if (reason === 'settings_persist_failed') return '异常通知开关保存失败,请稍后重试。'
|
||||||
|
return error ? `异常通知测试发送失败:${error}` : '异常通知测试发送失败,请检查 Agent Hub 连接。'
|
||||||
|
}
|
||||||
|
|
||||||
const executionLabel = (execution: ScheduledReportExecution): string => {
|
const executionLabel = (execution: ScheduledReportExecution): string => {
|
||||||
if (execution.status === 'running') return '生成中 · 待发送'
|
if (execution.status === 'running') return '生成中 · 待发送'
|
||||||
if (execution.status === 'success') return '已生成并发送'
|
if (execution.status === 'success') return '已生成并发送'
|
||||||
if (execution.error?.includes('report_generation_failed')) return '日报生成失败'
|
if (execution.status === 'waiting_to_send') return '日报已生成,但未发送'
|
||||||
if (execution.error?.includes('wechat_not_ready')) return '微信发送能力不可用'
|
if (execution.status === 'partial_success') return '日报已生成,微信发送失败'
|
||||||
return '微信发送失败'
|
if (execution.status === 'waiting_for_recovery') return '等待恢复'
|
||||||
|
if (execution.status === 'skipped') return execution.userTitle || '暂无可生成的日报'
|
||||||
|
return execution.userTitle || '日报生成失败'
|
||||||
|
}
|
||||||
|
|
||||||
|
const executionDescription = (execution: ScheduledReportExecution): string => {
|
||||||
|
if (execution.status === 'success') {
|
||||||
|
return execution.sendTarget ? `已发送到:${execution.sendTarget}` : '日报已生成并发送'
|
||||||
|
}
|
||||||
|
return execution.userMessage || execution.message || '本次定时日报执行失败。'
|
||||||
}
|
}
|
||||||
|
|
||||||
function GroupAvatar({ contact }: { contact: Contact }): React.ReactElement {
|
function GroupAvatar({ contact }: { contact: Contact }): React.ReactElement {
|
||||||
@@ -550,11 +566,19 @@ export function ScheduledReportsWorkspace({
|
|||||||
contacts,
|
contacts,
|
||||||
platformSupported = true,
|
platformSupported = true,
|
||||||
onOpenWechatSettings,
|
onOpenWechatSettings,
|
||||||
|
onOpenAgentHub,
|
||||||
onOpenModelSettings,
|
onOpenModelSettings,
|
||||||
onNotice
|
onNotice
|
||||||
}: ScheduledReportsWorkspaceProps): React.ReactElement {
|
}: ScheduledReportsWorkspaceProps): React.ReactElement {
|
||||||
const [capability, setCapability] = React.useState<PersonalWechatSendCapability | null>(null)
|
const [capability, setCapability] = React.useState<PersonalWechatSendCapability | null>(null)
|
||||||
const [capabilityError, setCapabilityError] = React.useState(false)
|
const [capabilityError, setCapabilityError] = React.useState(false)
|
||||||
|
const [notificationEnabled, setNotificationEnabled] = React.useState(false)
|
||||||
|
const [notificationBusy, setNotificationBusy] = React.useState(false)
|
||||||
|
const [notificationSettingsError, setNotificationSettingsError] = React.useState(false)
|
||||||
|
const [notificationFailure, setNotificationFailure] = React.useState<{
|
||||||
|
reason?: ScheduledReportNotificationCapabilityReason
|
||||||
|
error?: string
|
||||||
|
} | null>(null)
|
||||||
const [tasks, setTasks] = React.useState<ScheduledReportTask[]>([])
|
const [tasks, setTasks] = React.useState<ScheduledReportTask[]>([])
|
||||||
const [executions, setExecutions] = React.useState<ScheduledReportExecution[]>([])
|
const [executions, setExecutions] = React.useState<ScheduledReportExecution[]>([])
|
||||||
const [loading, setLoading] = React.useState(true)
|
const [loading, setLoading] = React.useState(true)
|
||||||
@@ -580,14 +604,23 @@ export function ScheduledReportsWorkspace({
|
|||||||
})
|
})
|
||||||
setTasks([])
|
setTasks([])
|
||||||
setExecutions([])
|
setExecutions([])
|
||||||
|
try {
|
||||||
|
const settings = await window.api.getScheduledReportNotificationSettings()
|
||||||
|
setNotificationEnabled(settings.enabled)
|
||||||
|
setNotificationSettingsError(false)
|
||||||
|
} catch {
|
||||||
|
setNotificationSettingsError(true)
|
||||||
|
}
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const [taskResult, executionResult, capabilityResult] = await Promise.allSettled([
|
const [taskResult, executionResult, capabilityResult, notificationSettingsResult] =
|
||||||
window.api.listScheduledReports(),
|
await Promise.allSettled([
|
||||||
window.api.listScheduledReportExecutions(),
|
window.api.listScheduledReports(),
|
||||||
window.api.getPersonalWechatSendCapability()
|
window.api.listScheduledReportExecutions(),
|
||||||
])
|
window.api.getPersonalWechatSendCapability(),
|
||||||
|
window.api.getScheduledReportNotificationSettings()
|
||||||
|
])
|
||||||
if (taskResult.status === 'fulfilled') setTasks(taskResult.value)
|
if (taskResult.status === 'fulfilled') setTasks(taskResult.value)
|
||||||
else setListError(true)
|
else setListError(true)
|
||||||
if (executionResult.status === 'fulfilled') setExecutions(executionResult.value)
|
if (executionResult.status === 'fulfilled') setExecutions(executionResult.value)
|
||||||
@@ -597,6 +630,12 @@ export function ScheduledReportsWorkspace({
|
|||||||
} else {
|
} else {
|
||||||
setCapabilityError(true)
|
setCapabilityError(true)
|
||||||
}
|
}
|
||||||
|
if (notificationSettingsResult.status === 'fulfilled') {
|
||||||
|
setNotificationEnabled(notificationSettingsResult.value.enabled)
|
||||||
|
setNotificationSettingsError(false)
|
||||||
|
} else {
|
||||||
|
setNotificationSettingsError(true)
|
||||||
|
}
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}, [platformSupported])
|
}, [platformSupported])
|
||||||
|
|
||||||
@@ -624,10 +663,39 @@ export function ScheduledReportsWorkspace({
|
|||||||
setCapabilityError(false)
|
setCapabilityError(false)
|
||||||
})
|
})
|
||||||
.catch(() => setCapabilityError(true))
|
.catch(() => setCapabilityError(true))
|
||||||
|
void window.api
|
||||||
|
.getScheduledReportNotificationSettings()
|
||||||
|
.then((settings) => {
|
||||||
|
setNotificationEnabled(settings.enabled)
|
||||||
|
setNotificationSettingsError(false)
|
||||||
|
})
|
||||||
|
.catch(() => setNotificationSettingsError(true))
|
||||||
}, 15_000)
|
}, 15_000)
|
||||||
return () => window.clearInterval(timer)
|
return () => window.clearInterval(timer)
|
||||||
}, [dialogOpen, refreshTasks])
|
}, [dialogOpen, refreshTasks])
|
||||||
|
|
||||||
|
const setNotification = async (enabled: boolean): Promise<void> => {
|
||||||
|
setNotificationBusy(true)
|
||||||
|
try {
|
||||||
|
const result = await window.api.setScheduledReportNotificationEnabled(enabled)
|
||||||
|
setNotificationEnabled(result.data.enabled)
|
||||||
|
if (result.success) {
|
||||||
|
setNotificationFailure(null)
|
||||||
|
onNotice(enabled ? '微信异常通知已开启' : '微信异常通知已关闭', 'success')
|
||||||
|
} else {
|
||||||
|
setNotificationFailure({ reason: result.reason, error: result.error })
|
||||||
|
onNotice(notificationFailureCopy(result.reason, result.error), 'warning')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setNotificationEnabled(false)
|
||||||
|
const message = readableError(error instanceof Error ? error.message : String(error))
|
||||||
|
setNotificationFailure({ reason: 'send_failed', error: message })
|
||||||
|
onNotice(message, 'warning')
|
||||||
|
} finally {
|
||||||
|
setNotificationBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const submit = async (input: ScheduledReportCreateInput, taskId?: string): Promise<void> => {
|
const submit = async (input: ScheduledReportCreateInput, taskId?: string): Promise<void> => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
@@ -666,9 +734,21 @@ export function ScheduledReportsWorkspace({
|
|||||||
try {
|
try {
|
||||||
const result = await window.api.runScheduledReportNow(task.id)
|
const result = await window.api.runScheduledReportNow(task.id)
|
||||||
await refreshTasks()
|
await refreshTasks()
|
||||||
if (!result.success)
|
const execution = result.data
|
||||||
onNotice(readableError(result.error || result.data?.error), 'destructive')
|
if (execution?.status === 'waiting_to_send') {
|
||||||
else onNotice('日报已生成并发送', 'success')
|
onNotice('日报已生成,但未发送', 'warning')
|
||||||
|
} else if (execution?.status === 'partial_success') {
|
||||||
|
onNotice('日报已生成,微信发送失败', 'warning')
|
||||||
|
} else if (execution?.status === 'success') {
|
||||||
|
onNotice('日报已生成并发送', 'success')
|
||||||
|
} else if (execution?.status === 'skipped') {
|
||||||
|
onNotice('本次没有可生成的日报', 'default')
|
||||||
|
} else if (!result.success) {
|
||||||
|
onNotice(
|
||||||
|
execution?.userMessage || readableError(result.error || execution?.error),
|
||||||
|
'destructive'
|
||||||
|
)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotice(readableError(error instanceof Error ? error.message : String(error)), 'destructive')
|
onNotice(readableError(error instanceof Error ? error.message : String(error)), 'destructive')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -676,6 +756,50 @@ export function ScheduledReportsWorkspace({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const retrySend = async (execution: ScheduledReportExecution): Promise<void> => {
|
||||||
|
const task = tasks.find((item) => item.id === execution.taskId)
|
||||||
|
const busyId = task?.id || execution.id
|
||||||
|
setBusyTaskId(busyId)
|
||||||
|
try {
|
||||||
|
const result = await window.api.retryScheduledReportSend(execution.id)
|
||||||
|
await refreshTasks()
|
||||||
|
if (result.data?.status === 'success') onNotice('日报已重新发送', 'success')
|
||||||
|
else if (result.data?.status === 'waiting_to_send')
|
||||||
|
onNotice('微信发送能力仍未恢复', 'warning')
|
||||||
|
else onNotice(result.data?.userMessage || readableError(result.error), 'warning')
|
||||||
|
} catch (error) {
|
||||||
|
onNotice(readableError(error instanceof Error ? error.message : String(error)), 'destructive')
|
||||||
|
} finally {
|
||||||
|
setBusyTaskId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const testErrorNotification = async (task: ScheduledReportTask): Promise<void> => {
|
||||||
|
setBusyTaskId(task.id)
|
||||||
|
try {
|
||||||
|
const result = await window.api.testScheduledReportErrorNotification(task.id)
|
||||||
|
await refreshTasks()
|
||||||
|
if (result.data?.notificationStatus === 'sent') {
|
||||||
|
onNotice('测试错误信息已发送到 Agent Hub 微信通知接收者', 'success')
|
||||||
|
} else {
|
||||||
|
onNotice(readableError(result.error), 'warning')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
onNotice(readableError(error instanceof Error ? error.message : String(error)), 'destructive')
|
||||||
|
} finally {
|
||||||
|
setBusyTaskId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openReport = async (execution: ScheduledReportExecution): Promise<void> => {
|
||||||
|
if (!execution.pngPath) {
|
||||||
|
onNotice('该执行记录没有可查看的日报文件', 'destructive')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const result = await window.api.revealGroupReport(execution.pngPath)
|
||||||
|
if (!result.success) onNotice(result.error || '日报文件打开失败', 'destructive')
|
||||||
|
}
|
||||||
|
|
||||||
const confirmDelete = async (): Promise<void> => {
|
const confirmDelete = async (): Promise<void> => {
|
||||||
if (!deletingTask) return
|
if (!deletingTask) return
|
||||||
setBusyTaskId(deletingTask.id)
|
setBusyTaskId(deletingTask.id)
|
||||||
@@ -711,8 +835,7 @@ export function ScheduledReportsWorkspace({
|
|||||||
.slice(0, 8),
|
.slice(0, 8),
|
||||||
[executions]
|
[executions]
|
||||||
)
|
)
|
||||||
const canCreate = capability?.status === 'ready'
|
const showDebugNotificationButton = isTruthyDebugFlag(import.meta.env.VITE_SCHEDULED_REPORT_DEBUG)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="report-scheduled-page">
|
<div className="report-scheduled-page">
|
||||||
<div className="report-scheduled-header">
|
<div className="report-scheduled-header">
|
||||||
@@ -720,10 +843,50 @@ export function ScheduledReportsWorkspace({
|
|||||||
<h1>定时日报</h1>
|
<h1>定时日报</h1>
|
||||||
<p>每天自动生成群聊日报,并发送到指定微信群。</p>
|
<p>每天自动生成群聊日报,并发送到指定微信群。</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={openCreate} disabled={!canCreate}>
|
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||||
<span aria-hidden>+</span> 新建定时日报
|
<div className="flex items-center gap-2">
|
||||||
</Button>
|
<span className="text-sm font-medium">微信异常通知</span>
|
||||||
|
<Switch
|
||||||
|
aria-label="微信异常通知"
|
||||||
|
checked={notificationEnabled}
|
||||||
|
disabled={!platformSupported || notificationBusy || notificationSettingsError}
|
||||||
|
onCheckedChange={(value) => void setNotification(value)}
|
||||||
|
/>
|
||||||
|
{notificationEnabled && <span className="text-sm text-success">✓</span>}
|
||||||
|
</div>
|
||||||
|
<Button onClick={openCreate}>
|
||||||
|
<span aria-hidden>+</span> 新建定时日报
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{notificationEnabled && (
|
||||||
|
<p className="-mt-3 text-xs text-muted-foreground">
|
||||||
|
定时日报出现异常时,通过 Agent Hub 微信机器人通知你。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{notificationFailure && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-warning/30 bg-warning/10 px-4 py-3 text-sm"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{notificationFailureCopy(notificationFailure.reason, notificationFailure.error)}
|
||||||
|
</span>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<Button variant="link" size="sm" onClick={onOpenAgentHub}>
|
||||||
|
前往 Agent Hub
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={notificationBusy}
|
||||||
|
onClick={() => void setNotification(true)}
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className={`report-wechat-capability ${capabilityTone(capability)}`}>
|
<div className={`report-wechat-capability ${capabilityTone(capability)}`}>
|
||||||
<span className="font-medium">微信发送能力</span>
|
<span className="font-medium">微信发送能力</span>
|
||||||
<span
|
<span
|
||||||
@@ -760,7 +923,7 @@ export function ScheduledReportsWorkspace({
|
|||||||
) : tasks.length ? (
|
) : tasks.length ? (
|
||||||
<div className="overflow-x-auto rounded-xl border border-border-subtle bg-surface">
|
<div className="overflow-x-auto rounded-xl border border-border-subtle bg-surface">
|
||||||
<div className="min-w-[760px]">
|
<div className="min-w-[760px]">
|
||||||
<div className="grid grid-cols-[1.5fr_.8fr_1fr_1fr_.8fr_1fr_190px] gap-3 border-b border-border-subtle px-5 py-3 text-xs font-medium text-muted-foreground">
|
<div className="grid grid-cols-[1.5fr_.8fr_1fr_1fr_.8fr_1fr_300px] gap-3 border-b border-border-subtle px-5 py-3 text-xs font-medium text-muted-foreground">
|
||||||
<span>任务名称</span>
|
<span>任务名称</span>
|
||||||
<span>执行时间</span>
|
<span>执行时间</span>
|
||||||
<span>日报范围</span>
|
<span>日报范围</span>
|
||||||
@@ -776,7 +939,7 @@ export function ScheduledReportsWorkspace({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={task.id}
|
key={task.id}
|
||||||
className="grid grid-cols-[1.5fr_.8fr_1fr_1fr_.8fr_1fr_190px] items-center gap-3 border-b border-border-subtle px-5 py-4 last:border-b-0"
|
className="grid grid-cols-[1.5fr_.8fr_1fr_1fr_.8fr_1fr_300px] items-center gap-3 border-b border-border-subtle px-5 py-4 last:border-b-0"
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
{group && <GroupAvatar contact={group} />}
|
{group && <GroupAvatar contact={group} />}
|
||||||
@@ -813,6 +976,16 @@ export function ScheduledReportsWorkspace({
|
|||||||
>
|
>
|
||||||
{running ? '生成中…' : '立即执行'}
|
{running ? '生成中…' : '立即执行'}
|
||||||
</Button>
|
</Button>
|
||||||
|
{showDebugNotificationButton && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void testErrorNotification(task)}
|
||||||
|
disabled={running}
|
||||||
|
>
|
||||||
|
{running ? '测试中…' : '测试错误信息发送'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -850,7 +1023,7 @@ export function ScheduledReportsWorkspace({
|
|||||||
</div>
|
</div>
|
||||||
<h2>还没有定时日报</h2>
|
<h2>还没有定时日报</h2>
|
||||||
<p>创建一个任务,让 TraceMemo 每天自动生成群聊日报并发送到微信。</p>
|
<p>创建一个任务,让 TraceMemo 每天自动生成群聊日报并发送到微信。</p>
|
||||||
<Button onClick={openCreate} disabled={!canCreate}>
|
<Button onClick={openCreate}>
|
||||||
<span aria-hidden>+</span> 新建定时日报
|
<span aria-hidden>+</span> 新建定时日报
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -867,20 +1040,85 @@ export function ScheduledReportsWorkspace({
|
|||||||
{sortedExecutions.map((execution) => {
|
{sortedExecutions.map((execution) => {
|
||||||
const task = tasks.find((item) => item.id === execution.taskId)
|
const task = tasks.find((item) => item.id === execution.taskId)
|
||||||
const success = execution.status === 'success'
|
const success = execution.status === 'success'
|
||||||
|
const skipped = execution.status === 'skipped'
|
||||||
|
const sendPending =
|
||||||
|
execution.status === 'waiting_to_send' || execution.status === 'partial_success'
|
||||||
|
const busy = busyTaskId === (task?.id || execution.id)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={execution.id}
|
key={execution.id}
|
||||||
className="flex items-center gap-3 rounded-lg border border-border-subtle bg-surface px-4 py-3"
|
className="flex items-start gap-3 rounded-lg border border-border-subtle bg-surface px-4 py-3"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`text-lg ${success ? 'text-success' : execution.status === 'running' ? 'text-warning' : 'text-destructive'}`}
|
className={`pt-0.5 text-lg ${success ? 'text-success' : skipped ? 'text-muted-foreground' : execution.status === 'running' || sendPending ? 'text-warning' : 'text-destructive'}`}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
{success ? '✓' : execution.status === 'running' ? '…' : '×'}
|
{success
|
||||||
|
? '✓'
|
||||||
|
: skipped
|
||||||
|
? '–'
|
||||||
|
: execution.status === 'running'
|
||||||
|
? '…'
|
||||||
|
: sendPending
|
||||||
|
? '!'
|
||||||
|
: '×'}
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-medium">{task?.name || '定时日报'}</p>
|
<p className="truncate text-sm font-medium">{task?.name || '定时日报'}</p>
|
||||||
<p className="text-xs text-muted-foreground">{executionLabel(execution)}</p>
|
<p className="text-xs text-muted-foreground">{executionLabel(execution)}</p>
|
||||||
|
<p className="mt-1 text-sm text-foreground">
|
||||||
|
{executionDescription(execution)}
|
||||||
|
</p>
|
||||||
|
{execution.suggestedAction && execution.status !== 'success' && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
建议:{execution.suggestedAction}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
{sendPending && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void retrySend(execution)}
|
||||||
|
>
|
||||||
|
{busy ? '发送中…' : '重新发送'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{execution.status === 'failed' && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => task && void runNow(task)}
|
||||||
|
>
|
||||||
|
{busy ? '执行中…' : '重新执行'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{execution.pngPath && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => void openReport(execution)}
|
||||||
|
>
|
||||||
|
查看日报
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{execution.status !== 'success' &&
|
||||||
|
execution.errorCode?.startsWith('WECHAT_') && (
|
||||||
|
<Button size="sm" variant="ghost" onClick={onOpenWechatSettings}>
|
||||||
|
去检测
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{execution.technicalMessage && (
|
||||||
|
<details className="text-xs text-muted-foreground">
|
||||||
|
<summary className="cursor-pointer">查看详情</summary>
|
||||||
|
<p className="mt-1 max-w-xl whitespace-pre-wrap break-words">
|
||||||
|
{execution.technicalMessage}
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<time className="text-xs text-muted-foreground">
|
<time className="text-xs text-muted-foreground">
|
||||||
{formatDateTime(execution.finishedAt || execution.startedAt)}
|
{formatDateTime(execution.finishedAt || execution.startedAt)}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
const truthyValues = new Set(['1', 'true', 'yes', 'on'])
|
||||||
|
|
||||||
|
export const isTruthyDebugFlag = (value: unknown): boolean =>
|
||||||
|
truthyValues.has(
|
||||||
|
String(value ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
)
|
||||||
@@ -6,12 +6,14 @@ import type {
|
|||||||
import type { SelectableReportTemplateId } from './report-templates'
|
import type { SelectableReportTemplateId } from './report-templates'
|
||||||
|
|
||||||
export type ReportAssetStatus = 'ready' | 'missing'
|
export type ReportAssetStatus = 'ready' | 'missing'
|
||||||
|
export type GeneratedReportSource = 'manual' | 'scheduled'
|
||||||
|
|
||||||
export interface GeneratedReportRecord {
|
export interface GeneratedReportRecord {
|
||||||
id: string
|
id: string
|
||||||
contactId: string
|
contactId: string
|
||||||
contactName: string
|
contactName: string
|
||||||
contactAvatar?: string
|
contactAvatar?: string
|
||||||
|
source?: GeneratedReportSource
|
||||||
dateRange: string
|
dateRange: string
|
||||||
messageCount: number
|
messageCount: number
|
||||||
generatedAt: string
|
generatedAt: string
|
||||||
@@ -59,6 +61,7 @@ export interface SaveGeneratedReportRequest {
|
|||||||
contactId: string
|
contactId: string
|
||||||
contactName: string
|
contactName: string
|
||||||
contactAvatar?: string
|
contactAvatar?: string
|
||||||
|
source?: GeneratedReportSource
|
||||||
dateRange: string
|
dateRange: string
|
||||||
messageCount: number
|
messageCount: number
|
||||||
generatedAt: string
|
generatedAt: string
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ScheduledReportRange, ScheduledReportExecutionStatus } from './scheduled-report'
|
import type { ScheduledReportRange, ScheduledReportExecution } from './scheduled-report'
|
||||||
|
|
||||||
export type ScheduledReportApiSchedule = {
|
export type ScheduledReportApiSchedule = {
|
||||||
type: 'daily'
|
type: 'daily'
|
||||||
@@ -55,13 +55,4 @@ export interface ScheduledReportApiUpdateRequest {
|
|||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScheduledReportApiExecution {
|
export type ScheduledReportApiExecution = ScheduledReportExecution
|
||||||
id: string
|
|
||||||
taskId: string
|
|
||||||
startedAt: string
|
|
||||||
finishedAt?: string
|
|
||||||
status: ScheduledReportExecutionStatus
|
|
||||||
error?: string
|
|
||||||
message?: string
|
|
||||||
scheduledSlot?: string
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import type { ScheduledReportExecutionStage } from './scheduled-report'
|
||||||
|
|
||||||
|
export const SCHEDULED_REPORT_ERROR_CODES = [
|
||||||
|
'AI_CONTEXT_LIMIT',
|
||||||
|
'AI_QUOTA_EXCEEDED',
|
||||||
|
'AI_AUTH_INVALID',
|
||||||
|
'AI_PROVIDER_UNAVAILABLE',
|
||||||
|
'AI_TIMEOUT',
|
||||||
|
'DATABASE_UNAVAILABLE',
|
||||||
|
'CHAT_NOT_FOUND',
|
||||||
|
'NO_MESSAGES',
|
||||||
|
'REPORT_GENERATION_FAILED',
|
||||||
|
'REPORT_HISTORY_SAVE_FAILED',
|
||||||
|
'WECHAT_SEND_UNAVAILABLE',
|
||||||
|
'WECHAT_SEND_FAILED',
|
||||||
|
'WECHAT_AUTH_EXPIRED',
|
||||||
|
'UNKNOWN'
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type ScheduledReportErrorCode = (typeof SCHEDULED_REPORT_ERROR_CODES)[number]
|
||||||
|
export type ScheduledReportErrorSeverity = 'info' | 'warning' | 'error'
|
||||||
|
|
||||||
|
export interface ScheduledReportErrorDetails {
|
||||||
|
code: ScheduledReportErrorCode
|
||||||
|
stage: ScheduledReportExecutionStage
|
||||||
|
severity: ScheduledReportErrorSeverity
|
||||||
|
technicalMessage: string
|
||||||
|
userTitle: string
|
||||||
|
userMessage: string
|
||||||
|
suggestedAction: string
|
||||||
|
retryable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduledReportErrorInput {
|
||||||
|
error?: unknown
|
||||||
|
code?: unknown
|
||||||
|
status?: unknown
|
||||||
|
type?: unknown
|
||||||
|
stage?: ScheduledReportExecutionStage
|
||||||
|
}
|
||||||
|
|
||||||
|
const knownCodes = new Set<string>(SCHEDULED_REPORT_ERROR_CODES)
|
||||||
|
|
||||||
|
const asRecord = (value: unknown): Record<string, unknown> | undefined =>
|
||||||
|
value && typeof value === 'object' ? (value as Record<string, unknown>) : undefined
|
||||||
|
|
||||||
|
const safeString = (value: unknown): string => {
|
||||||
|
if (value instanceof Error) return value.message
|
||||||
|
if (typeof value === 'string') return value
|
||||||
|
if (value === undefined || value === null) return ''
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value)
|
||||||
|
} catch {
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const redact = (value: string): string =>
|
||||||
|
value
|
||||||
|
.replace(/sk-[a-z0-9_-]+/gi, '***')
|
||||||
|
.replace(/Bearer\s+[A-Za-z0-9._~-]+/gi, 'Bearer [已隐藏]')
|
||||||
|
.slice(0, 1_000)
|
||||||
|
|
||||||
|
const legacyCode = (value: string): ScheduledReportErrorCode | undefined => {
|
||||||
|
const match = value.match(/^([a-z_]+):/i)?.[1]
|
||||||
|
switch (match) {
|
||||||
|
case 'wechat_not_ready':
|
||||||
|
return 'WECHAT_SEND_UNAVAILABLE'
|
||||||
|
case 'wechat_send_failed':
|
||||||
|
return 'WECHAT_SEND_FAILED'
|
||||||
|
case 'report_generation_failed':
|
||||||
|
return 'REPORT_GENERATION_FAILED'
|
||||||
|
case 'report_history_save_failed':
|
||||||
|
return 'REPORT_HISTORY_SAVE_FAILED'
|
||||||
|
default:
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stageForCode = (code: ScheduledReportErrorCode): ScheduledReportExecutionStage => {
|
||||||
|
if (code.startsWith('AI_')) return 'ai'
|
||||||
|
if (code === 'DATABASE_UNAVAILABLE' || code === 'CHAT_NOT_FOUND' || code === 'NO_MESSAGES') {
|
||||||
|
return 'data'
|
||||||
|
}
|
||||||
|
if (code === 'REPORT_HISTORY_SAVE_FAILED') return 'persist'
|
||||||
|
if (code.startsWith('WECHAT_')) return 'send'
|
||||||
|
return 'report'
|
||||||
|
}
|
||||||
|
|
||||||
|
const messagesFor = (
|
||||||
|
code: ScheduledReportErrorCode
|
||||||
|
): Omit<ScheduledReportErrorDetails, 'code' | 'stage' | 'technicalMessage'> => {
|
||||||
|
switch (code) {
|
||||||
|
case 'AI_CONTEXT_LIMIT':
|
||||||
|
return {
|
||||||
|
severity: 'error',
|
||||||
|
userTitle: '日报生成失败',
|
||||||
|
userMessage: '今天的聊天内容较多,超过当前 AI 模型一次可以处理的内容长度。',
|
||||||
|
suggestedAction: '可以缩短日报范围,或更换支持更长上下文的模型。',
|
||||||
|
retryable: true
|
||||||
|
}
|
||||||
|
case 'AI_QUOTA_EXCEEDED':
|
||||||
|
return {
|
||||||
|
severity: 'error',
|
||||||
|
userTitle: '日报生成失败',
|
||||||
|
userMessage: '当前 AI 服务额度不足,因此暂时无法生成日报。',
|
||||||
|
suggestedAction: '请检查 AI Provider 的账户额度,或切换其他可用模型。',
|
||||||
|
retryable: true
|
||||||
|
}
|
||||||
|
case 'AI_AUTH_INVALID':
|
||||||
|
return {
|
||||||
|
severity: 'error',
|
||||||
|
userTitle: 'AI 服务不可用',
|
||||||
|
userMessage: '当前 AI 服务的认证信息无效。',
|
||||||
|
suggestedAction: '请检查 API Key 或重新配置模型。',
|
||||||
|
retryable: true
|
||||||
|
}
|
||||||
|
case 'AI_PROVIDER_UNAVAILABLE':
|
||||||
|
return {
|
||||||
|
severity: 'error',
|
||||||
|
userTitle: 'AI 服务暂时不可用',
|
||||||
|
userMessage: 'AI 服务暂时无法响应,本次日报没有生成。',
|
||||||
|
suggestedAction: '稍后重新执行。',
|
||||||
|
retryable: true
|
||||||
|
}
|
||||||
|
case 'AI_TIMEOUT':
|
||||||
|
return {
|
||||||
|
severity: 'error',
|
||||||
|
userTitle: '日报生成超时',
|
||||||
|
userMessage: 'AI 服务在限定时间内没有完成响应,本次日报没有生成。',
|
||||||
|
suggestedAction: '稍后重新执行,或适当增加日报生成超时时间。',
|
||||||
|
retryable: true
|
||||||
|
}
|
||||||
|
case 'DATABASE_UNAVAILABLE':
|
||||||
|
return {
|
||||||
|
severity: 'error',
|
||||||
|
userTitle: '日报生成失败',
|
||||||
|
userMessage: 'TraceMemo 当前无法读取微信数据库,本次日报没有生成。',
|
||||||
|
suggestedAction: '请连接微信数据后重新执行。',
|
||||||
|
retryable: true
|
||||||
|
}
|
||||||
|
case 'CHAT_NOT_FOUND':
|
||||||
|
return {
|
||||||
|
severity: 'error',
|
||||||
|
userTitle: '日报生成失败',
|
||||||
|
userMessage: '没有找到任务配置的目标群聊。',
|
||||||
|
suggestedAction: '请检查群聊配置后重新执行。',
|
||||||
|
retryable: false
|
||||||
|
}
|
||||||
|
case 'NO_MESSAGES':
|
||||||
|
return {
|
||||||
|
severity: 'warning',
|
||||||
|
userTitle: '没有可生成的日报',
|
||||||
|
userMessage: '所选时间范围内没有可总结的聊天消息。',
|
||||||
|
suggestedAction: '可以扩大日报范围,或稍后重新执行。',
|
||||||
|
retryable: true
|
||||||
|
}
|
||||||
|
case 'REPORT_HISTORY_SAVE_FAILED':
|
||||||
|
return {
|
||||||
|
severity: 'error',
|
||||||
|
userTitle: '日报保存失败',
|
||||||
|
userMessage: '日报已经生成,但保存到日报历史时失败。',
|
||||||
|
suggestedAction: '请检查磁盘空间和文件权限后重新执行。',
|
||||||
|
retryable: true
|
||||||
|
}
|
||||||
|
case 'WECHAT_SEND_UNAVAILABLE':
|
||||||
|
return {
|
||||||
|
severity: 'warning',
|
||||||
|
userTitle: '日报已生成,但未发送',
|
||||||
|
userMessage: '日报已经生成并保存,但当前个人微信发送能力不可用。',
|
||||||
|
suggestedAction: '恢复微信消息能力后,可以直接重新发送这份日报。',
|
||||||
|
retryable: true
|
||||||
|
}
|
||||||
|
case 'WECHAT_AUTH_EXPIRED':
|
||||||
|
return {
|
||||||
|
severity: 'warning',
|
||||||
|
userTitle: '日报已生成,微信登录已失效',
|
||||||
|
userMessage: '日报已经生成并保存,但个人微信登录凭证已失效。',
|
||||||
|
suggestedAction: '重新登录微信后,可以直接重新发送这份日报。',
|
||||||
|
retryable: true
|
||||||
|
}
|
||||||
|
case 'WECHAT_SEND_FAILED':
|
||||||
|
return {
|
||||||
|
severity: 'warning',
|
||||||
|
userTitle: '日报已生成,发送失败',
|
||||||
|
userMessage: '日报已经生成并保存,但发送到微信时失败。',
|
||||||
|
suggestedAction: '检查微信连接后重新发送。',
|
||||||
|
retryable: true
|
||||||
|
}
|
||||||
|
case 'REPORT_GENERATION_FAILED':
|
||||||
|
return {
|
||||||
|
severity: 'error',
|
||||||
|
userTitle: '日报生成失败',
|
||||||
|
userMessage: '日报生成过程中发生异常,本次日报没有生成。',
|
||||||
|
suggestedAction: '稍后重新执行;如果持续失败,请检查模型配置。',
|
||||||
|
retryable: true
|
||||||
|
}
|
||||||
|
case 'UNKNOWN':
|
||||||
|
return {
|
||||||
|
severity: 'error',
|
||||||
|
userTitle: '定时日报执行失败',
|
||||||
|
userMessage: '本次定时日报执行失败。',
|
||||||
|
suggestedAction: '查看详情并稍后重新执行。',
|
||||||
|
retryable: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inferCode = (
|
||||||
|
message: string,
|
||||||
|
stage: ScheduledReportExecutionStage,
|
||||||
|
input: ScheduledReportErrorInput
|
||||||
|
): ScheduledReportErrorCode => {
|
||||||
|
const explicit = safeString(input.code).trim().toUpperCase()
|
||||||
|
if (knownCodes.has(explicit)) return explicit as ScheduledReportErrorCode
|
||||||
|
const legacy = legacyCode(message)
|
||||||
|
if (legacy) return legacy
|
||||||
|
const status = Number(input.status)
|
||||||
|
const type = safeString(input.type).toLowerCase()
|
||||||
|
const lower = message.toLowerCase()
|
||||||
|
|
||||||
|
if (
|
||||||
|
/context.{0,30}(length|limit|window)|maximum.{0,20}(context|token)|too many tokens|prompt.{0,20}long|input.{0,20}token|上下文|超出.*长度|token.*超限/.test(
|
||||||
|
lower
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 'AI_CONTEXT_LIMIT'
|
||||||
|
if (
|
||||||
|
/quota|insufficient[_ -]?quota|billing|credit|额度|余额|付费|rate.?limit|too many requests/.test(
|
||||||
|
lower
|
||||||
|
) ||
|
||||||
|
status === 402
|
||||||
|
)
|
||||||
|
return 'AI_QUOTA_EXCEEDED'
|
||||||
|
if (
|
||||||
|
status === 401 ||
|
||||||
|
status === 403 ||
|
||||||
|
/invalid api key|incorrect api key|authentication|unauthorized|api key.*invalid|认证信息|密钥无效/.test(
|
||||||
|
lower
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return stage === 'send' ? 'WECHAT_AUTH_EXPIRED' : 'AI_AUTH_INVALID'
|
||||||
|
if (/timeout|timed out|etimedout|超时/.test(lower)) {
|
||||||
|
return stage === 'send' ? 'WECHAT_SEND_FAILED' : 'AI_TIMEOUT'
|
||||||
|
}
|
||||||
|
if (/database|db unavailable|数据库|数据库未|wcdb/.test(lower)) return 'DATABASE_UNAVAILABLE'
|
||||||
|
if (/no messages|没有.*消息|没有可总结|消息为空/.test(lower)) return 'NO_MESSAGES'
|
||||||
|
if (/chat.*not found|group.*not found|没有找到群|未找到群|不是群聊/.test(lower))
|
||||||
|
return 'CHAT_NOT_FOUND'
|
||||||
|
if (stage === 'persist' || /history|日报历史|保存.*日报/.test(lower))
|
||||||
|
return 'REPORT_HISTORY_SAVE_FAILED'
|
||||||
|
if (stage === 'send' || /wechat|微信|connector|发送/.test(lower)) return 'WECHAT_SEND_FAILED'
|
||||||
|
if (
|
||||||
|
stage === 'ai' &&
|
||||||
|
(status >= 500 || /fetch failed|econnrefused|enotfound|network|unavailable|service/.test(lower))
|
||||||
|
)
|
||||||
|
return 'AI_PROVIDER_UNAVAILABLE'
|
||||||
|
if (stage === 'ai') return 'REPORT_GENERATION_FAILED'
|
||||||
|
if (type.includes('provider') || status >= 500) return 'AI_PROVIDER_UNAVAILABLE'
|
||||||
|
return 'UNKNOWN'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeScheduledReportError(
|
||||||
|
input: unknown,
|
||||||
|
fallbackStage: ScheduledReportExecutionStage = 'report'
|
||||||
|
): ScheduledReportErrorDetails {
|
||||||
|
const record = asRecord(input)
|
||||||
|
const source = (record && 'error' in record ? record.error : input) as unknown
|
||||||
|
const sourceRecord = asRecord(source)
|
||||||
|
const message = safeString(
|
||||||
|
sourceRecord?.message || sourceRecord?.error || sourceRecord?.detail || source
|
||||||
|
)
|
||||||
|
const details: ScheduledReportErrorInput = {
|
||||||
|
error: source,
|
||||||
|
code: record?.code || sourceRecord?.code,
|
||||||
|
status: record?.status || sourceRecord?.status,
|
||||||
|
type: record?.type || sourceRecord?.type,
|
||||||
|
stage: (record?.stage || fallbackStage) as ScheduledReportExecutionStage
|
||||||
|
}
|
||||||
|
const stage = details.stage || fallbackStage
|
||||||
|
const code = inferCode(message, stage, details)
|
||||||
|
const copy = messagesFor(code)
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
stage: code === 'UNKNOWN' ? stage : stageForCode(code),
|
||||||
|
technicalMessage: redact(message || '未知错误'),
|
||||||
|
...copy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function legacyScheduledReportError(
|
||||||
|
code: ScheduledReportErrorCode,
|
||||||
|
message: string
|
||||||
|
): string {
|
||||||
|
const prefix: Record<ScheduledReportErrorCode, string> = {
|
||||||
|
AI_CONTEXT_LIMIT: 'report_generation_failed',
|
||||||
|
AI_QUOTA_EXCEEDED: 'report_generation_failed',
|
||||||
|
AI_AUTH_INVALID: 'report_generation_failed',
|
||||||
|
AI_PROVIDER_UNAVAILABLE: 'report_generation_failed',
|
||||||
|
AI_TIMEOUT: 'report_generation_failed',
|
||||||
|
DATABASE_UNAVAILABLE: 'report_generation_failed',
|
||||||
|
CHAT_NOT_FOUND: 'report_generation_failed',
|
||||||
|
NO_MESSAGES: 'report_generation_failed',
|
||||||
|
REPORT_GENERATION_FAILED: 'report_generation_failed',
|
||||||
|
REPORT_HISTORY_SAVE_FAILED: 'report_history_save_failed',
|
||||||
|
WECHAT_SEND_UNAVAILABLE: 'wechat_not_ready',
|
||||||
|
WECHAT_SEND_FAILED: 'wechat_send_failed',
|
||||||
|
WECHAT_AUTH_EXPIRED: 'wechat_send_failed',
|
||||||
|
UNKNOWN: 'scheduled_report_failed'
|
||||||
|
}
|
||||||
|
return `${prefix[code]}:${message}`
|
||||||
|
}
|
||||||
@@ -10,7 +10,79 @@ export type ScheduledReportMessageType =
|
|||||||
| 'share'
|
| 'share'
|
||||||
| 'system'
|
| 'system'
|
||||||
export type ScheduledReportMemberNameMode = 'groupNickname' | 'wechatNickname' | 'remark'
|
export type ScheduledReportMemberNameMode = 'groupNickname' | 'wechatNickname' | 'remark'
|
||||||
export type ScheduledReportExecutionStatus = 'running' | 'success' | 'failed'
|
export type ScheduledReportExecutionStatus =
|
||||||
|
| 'running'
|
||||||
|
| 'success'
|
||||||
|
| 'waiting_to_send'
|
||||||
|
| 'partial_success'
|
||||||
|
| 'failed'
|
||||||
|
| 'waiting_for_recovery'
|
||||||
|
| 'skipped'
|
||||||
|
|
||||||
|
export type ScheduledReportExecutionStage =
|
||||||
|
| 'precheck'
|
||||||
|
| 'data'
|
||||||
|
| 'ai'
|
||||||
|
| 'report'
|
||||||
|
| 'persist'
|
||||||
|
| 'send'
|
||||||
|
| 'notify'
|
||||||
|
|
||||||
|
export type ScheduledReportSendStatus = 'pending' | 'success' | 'failed' | 'unavailable'
|
||||||
|
export type ScheduledReportNotificationStatus =
|
||||||
|
| 'pending'
|
||||||
|
| 'sent'
|
||||||
|
| 'failed'
|
||||||
|
| 'not_needed'
|
||||||
|
| 'suppressed'
|
||||||
|
|
||||||
|
export type ScheduledReportNotificationType = 'failure' | 'partial_success' | 'recovery'
|
||||||
|
export type ScheduledReportNotificationSeverity = 'info' | 'warning' | 'error'
|
||||||
|
export type ScheduledReportNotificationDeliveryStatus = 'pending' | 'sent' | 'failed' | 'suppressed'
|
||||||
|
|
||||||
|
export type ScheduledReportNotificationCapabilityReason =
|
||||||
|
| 'agent_hub_offline'
|
||||||
|
| 'connector_offline'
|
||||||
|
| 'recipient_not_bound'
|
||||||
|
| 'send_failed'
|
||||||
|
| 'settings_persist_failed'
|
||||||
|
|
||||||
|
export interface ScheduledReportNotificationSettings {
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduledReportNotificationSettingsResult {
|
||||||
|
success: boolean
|
||||||
|
data: ScheduledReportNotificationSettings
|
||||||
|
reason?: ScheduledReportNotificationCapabilityReason
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduledReportNotificationCapability {
|
||||||
|
ready: boolean
|
||||||
|
recipient?: string
|
||||||
|
reason?: ScheduledReportNotificationCapabilityReason
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduledReportNotification {
|
||||||
|
id: string
|
||||||
|
executionId: string
|
||||||
|
taskId: string
|
||||||
|
type: ScheduledReportNotificationType
|
||||||
|
severity: ScheduledReportNotificationSeverity
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
dedupeKey: string
|
||||||
|
channel: 'agent_hub'
|
||||||
|
recipient?: string
|
||||||
|
status: ScheduledReportNotificationDeliveryStatus
|
||||||
|
createdAt: string
|
||||||
|
sentAt?: string
|
||||||
|
attempts: number
|
||||||
|
lastError?: string
|
||||||
|
suppressedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ScheduledReportTask {
|
export interface ScheduledReportTask {
|
||||||
id: string
|
id: string
|
||||||
@@ -37,12 +109,30 @@ export interface ScheduledReportTask {
|
|||||||
export interface ScheduledReportExecution {
|
export interface ScheduledReportExecution {
|
||||||
id: string
|
id: string
|
||||||
taskId: string
|
taskId: string
|
||||||
|
/** Missing on old executions; normalized reads treat them as scheduled runs. */
|
||||||
|
triggerType?: 'scheduled' | 'manual'
|
||||||
startedAt: string
|
startedAt: string
|
||||||
finishedAt?: string
|
finishedAt?: string
|
||||||
status: ScheduledReportExecutionStatus
|
status: ScheduledReportExecutionStatus
|
||||||
error?: string
|
error?: string
|
||||||
message?: string
|
message?: string
|
||||||
scheduledSlot?: string
|
scheduledSlot?: string
|
||||||
|
currentStage?: ScheduledReportExecutionStage
|
||||||
|
failedStage?: ScheduledReportExecutionStage
|
||||||
|
errorCode?: string
|
||||||
|
technicalMessage?: string
|
||||||
|
userTitle?: string
|
||||||
|
userMessage?: string
|
||||||
|
suggestedAction?: string
|
||||||
|
retryable?: boolean
|
||||||
|
retryCount?: number
|
||||||
|
reportId?: string
|
||||||
|
htmlPath?: string
|
||||||
|
pngPath?: string
|
||||||
|
sendTarget?: string
|
||||||
|
sendStatus?: ScheduledReportSendStatus
|
||||||
|
sendError?: string
|
||||||
|
notificationStatus?: ScheduledReportNotificationStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScheduledReportCreateInput {
|
export interface ScheduledReportCreateInput {
|
||||||
|
|||||||
@@ -782,6 +782,41 @@ describe('daily report controls', () => {
|
|||||||
expect(deleteTrigger).toHaveFocus()
|
expect(deleteTrigger).toHaveFocus()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('labels scheduled reports and uses the hydrated contact avatar as a fallback', () => {
|
||||||
|
const report: GeneratedReportRecord = {
|
||||||
|
id: 'scheduled-report-history',
|
||||||
|
contactId: groupContact.md5,
|
||||||
|
contactName: groupContact.m_nsNickName,
|
||||||
|
source: 'scheduled',
|
||||||
|
dateRange: '昨日',
|
||||||
|
messageCount: 10,
|
||||||
|
generatedAt: '2026-08-17T10:00:00.000Z',
|
||||||
|
reportDate: '2026-08-16',
|
||||||
|
htmlStatus: 'ready',
|
||||||
|
pngStatus: 'ready'
|
||||||
|
}
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ReportHistorySidebar
|
||||||
|
reports={[report]}
|
||||||
|
contacts={[{ ...groupContact, avatar: 'data:image/png;base64,avatar' }]}
|
||||||
|
selectedReportId={report.id}
|
||||||
|
selfInfo={null}
|
||||||
|
dbReady
|
||||||
|
onSelectReport={vi.fn()}
|
||||||
|
onCreateReport={vi.fn()}
|
||||||
|
onDeleteReport={vi.fn(async () => ({ success: true }))}
|
||||||
|
onOpenSettings={vi.fn()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText('测试群的定时日报')).toBeVisible()
|
||||||
|
expect(screen.getByRole('img', { name: '测试群的定时日报' })).toHaveAttribute(
|
||||||
|
'src',
|
||||||
|
'data:image/png;base64,avatar'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('prevents duplicate report deletion, keeps failures open, and closes after success', async () => {
|
it('prevents duplicate report deletion, keeps failures open, and closes after success', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const report: GeneratedReportRecord = {
|
const report: GeneratedReportRecord = {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { ScheduledReportsWorkspace } from '../../src/renderer/src/components/reports/ScheduledReportsWorkspace'
|
import { ScheduledReportsWorkspace } from '../../src/renderer/src/components/reports/ScheduledReportsWorkspace'
|
||||||
import type { Contact } from '../../src/shared/types'
|
import type { Contact } from '../../src/shared/types'
|
||||||
import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat'
|
import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat'
|
||||||
@@ -38,6 +38,7 @@ const task = {
|
|||||||
describe('ScheduledReportsWorkspace', () => {
|
describe('ScheduledReportsWorkspace', () => {
|
||||||
const onNotice = vi.fn()
|
const onNotice = vi.fn()
|
||||||
const onOpenWechatSettings = vi.fn()
|
const onOpenWechatSettings = vi.fn()
|
||||||
|
const onOpenAgentHub = vi.fn()
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
@@ -45,23 +46,37 @@ describe('ScheduledReportsWorkspace', () => {
|
|||||||
configurable: true,
|
configurable: true,
|
||||||
value: {
|
value: {
|
||||||
getPersonalWechatSendCapability: vi.fn(async () => capability('ready')),
|
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]),
|
listScheduledReports: vi.fn(async () => [task]),
|
||||||
listScheduledReportExecutions: vi.fn(async () => []),
|
listScheduledReportExecutions: vi.fn(async () => []),
|
||||||
createScheduledReport: vi.fn(async () => ({ success: true, data: task })),
|
createScheduledReport: vi.fn(async () => ({ success: true, data: task })),
|
||||||
updateScheduledReport: 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 } })),
|
deleteScheduledReport: vi.fn(async () => ({ success: true, data: { deletedId: task.id } })),
|
||||||
setScheduledReportEnabled: vi.fn(async () => ({ success: true, data: task })),
|
setScheduledReportEnabled: vi.fn(async () => ({ success: true, data: task })),
|
||||||
runScheduledReportNow: vi.fn(async () => ({ success: true, data: { status: 'success' } }))
|
runScheduledReportNow: 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 () => {
|
it('loads capability and renders a scheduled report task', async () => {
|
||||||
render(
|
render(
|
||||||
<ScheduledReportsWorkspace
|
<ScheduledReportsWorkspace
|
||||||
contacts={[group]}
|
contacts={[group]}
|
||||||
onNotice={onNotice}
|
onNotice={onNotice}
|
||||||
onOpenWechatSettings={onOpenWechatSettings}
|
onOpenWechatSettings={onOpenWechatSettings}
|
||||||
|
onOpenAgentHub={onOpenAgentHub}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
expect(screen.getByText('正在检查微信发送能力…')).toBeInTheDocument()
|
expect(screen.getByText('正在检查微信发送能力…')).toBeInTheDocument()
|
||||||
@@ -70,7 +85,7 @@ describe('ScheduledReportsWorkspace', () => {
|
|||||||
expect(screen.getByText('每天 09:00')).toBeVisible()
|
expect(screen.getByText('每天 09:00')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('disables creation and offers settings when WeChat is unsupported', async () => {
|
it('keeps creation available and offers settings when WeChat is unsupported', async () => {
|
||||||
vi.mocked(window.api.getPersonalWechatSendCapability).mockResolvedValue(
|
vi.mocked(window.api.getPersonalWechatSendCapability).mockResolvedValue(
|
||||||
capability('unsupported')
|
capability('unsupported')
|
||||||
)
|
)
|
||||||
@@ -79,12 +94,13 @@ describe('ScheduledReportsWorkspace', () => {
|
|||||||
contacts={[group]}
|
contacts={[group]}
|
||||||
onNotice={onNotice}
|
onNotice={onNotice}
|
||||||
onOpenWechatSettings={onOpenWechatSettings}
|
onOpenWechatSettings={onOpenWechatSettings}
|
||||||
|
onOpenAgentHub={onOpenAgentHub}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByText('微信消息发送目前仅支持 macOS 和 Windows')).toBeVisible()
|
expect(screen.getByText('微信消息发送目前仅支持 macOS 和 Windows')).toBeVisible()
|
||||||
)
|
)
|
||||||
expect(screen.getByRole('button', { name: /新建定时日报/ })).toBeDisabled()
|
expect(screen.getByRole('button', { name: /新建定时日报/ })).toBeEnabled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('opens the shared dialog and creates a task', async () => {
|
it('opens the shared dialog and creates a task', async () => {
|
||||||
@@ -94,6 +110,7 @@ describe('ScheduledReportsWorkspace', () => {
|
|||||||
contacts={[group]}
|
contacts={[group]}
|
||||||
onNotice={onNotice}
|
onNotice={onNotice}
|
||||||
onOpenWechatSettings={onOpenWechatSettings}
|
onOpenWechatSettings={onOpenWechatSettings}
|
||||||
|
onOpenAgentHub={onOpenAgentHub}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
await waitFor(() => expect(screen.getByText('还没有定时日报')).toBeVisible())
|
await waitFor(() => expect(screen.getByText('还没有定时日报')).toBeVisible())
|
||||||
@@ -110,4 +127,71 @@ describe('ScheduledReportsWorkspace', () => {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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'
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { expect, test } from '@playwright/test'
|
import { expect, test } from '@playwright/test'
|
||||||
import { launchTestApp } from './support/electron'
|
import { launchTestApp } from './support/electron'
|
||||||
|
|
||||||
|
const debugNotificationEnabled = ['1', 'true', 'yes', 'on'].includes(
|
||||||
|
String(process.env.VITE_SCHEDULED_REPORT_DEBUG || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
)
|
||||||
|
|
||||||
test('SCHEDULED-REPORT-UI-01 opens the scheduled report dialog without viewport overflow', async () => {
|
test('SCHEDULED-REPORT-UI-01 opens the scheduled report dialog without viewport overflow', async () => {
|
||||||
test.skip(
|
test.skip(
|
||||||
process.platform !== 'darwin' && process.platform !== 'win32',
|
process.platform !== 'darwin' && process.platform !== 'win32',
|
||||||
@@ -65,3 +71,61 @@ test('SCHEDULED-REPORT-UI-01 opens the scheduled report dialog without viewport
|
|||||||
await fixture.close()
|
await fixture.close()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('SCHEDULED-REPORT-UI-02 keeps notification switch off when Agent Hub is offline', async () => {
|
||||||
|
test.skip(
|
||||||
|
process.platform !== 'darwin' && process.platform !== 'win32',
|
||||||
|
'The scheduled report send capability requires macOS or Windows'
|
||||||
|
)
|
||||||
|
const fixture = await launchTestApp({ now: Date.parse('2026-08-27T08:30:00+08:00') })
|
||||||
|
try {
|
||||||
|
await fixture.page.getByRole('button', { name: '日报' }).click()
|
||||||
|
await fixture.page.getByRole('tab', { name: '定时日报' }).click()
|
||||||
|
const notificationSwitch = fixture.page.getByRole('switch', { name: '微信异常通知' })
|
||||||
|
await expect(notificationSwitch).toHaveAttribute('aria-checked', 'false')
|
||||||
|
await notificationSwitch.click()
|
||||||
|
await expect(
|
||||||
|
fixture.page
|
||||||
|
.getByRole('alert')
|
||||||
|
.getByText('需要先连接 Agent Hub 微信机器人,才能接收异常通知。')
|
||||||
|
).toBeVisible()
|
||||||
|
await expect(notificationSwitch).toHaveAttribute('aria-checked', 'false')
|
||||||
|
await fixture.page.getByRole('button', { name: '前往 Agent Hub' }).click()
|
||||||
|
await expect(
|
||||||
|
fixture.page.getByRole('heading', { name: 'Agent Hub', exact: true })
|
||||||
|
).toBeVisible()
|
||||||
|
} finally {
|
||||||
|
await fixture.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('SCHEDULED-REPORT-UI-03 sends a simulated error notification in debug mode', async () => {
|
||||||
|
test.skip(
|
||||||
|
(process.platform !== 'darwin' && process.platform !== 'win32') || !debugNotificationEnabled,
|
||||||
|
'The scheduled report debug action requires a supported platform and VITE_SCHEDULED_REPORT_DEBUG=true'
|
||||||
|
)
|
||||||
|
const fixture = await launchTestApp({ now: Date.parse('2026-08-27T08:30:00+08:00') })
|
||||||
|
try {
|
||||||
|
await fixture.page.getByRole('button', { name: '日报' }).click()
|
||||||
|
await fixture.page.getByRole('tab', { name: '定时日报' }).click()
|
||||||
|
await fixture.page
|
||||||
|
.getByRole('button', { name: /新建定时日报/ })
|
||||||
|
.first()
|
||||||
|
.click()
|
||||||
|
|
||||||
|
const dialog = fixture.page.getByRole('dialog', { name: '新建定时日报' })
|
||||||
|
await dialog.getByPlaceholder('例如:技术交流 · 每日日报').fill('调试定时日报')
|
||||||
|
await dialog.getByRole('button', { name: '创建定时日报' }).click()
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
|
const debugButton = fixture.page.getByRole('button', { name: '测试错误信息发送' })
|
||||||
|
await expect(debugButton).toBeVisible()
|
||||||
|
await debugButton.click()
|
||||||
|
await expect(
|
||||||
|
fixture.page.getByText('测试错误信息已发送到 Agent Hub 微信通知接收者')
|
||||||
|
).toBeVisible()
|
||||||
|
await expect(fixture.page.getByText('定时日报错误通知测试')).toBeVisible()
|
||||||
|
} finally {
|
||||||
|
await fixture.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -330,6 +330,7 @@ handle('group-exit-monitor:markRead', (readAt) => {
|
|||||||
const scheduledReportTasks = []
|
const scheduledReportTasks = []
|
||||||
const scheduledReportExecutions = []
|
const scheduledReportExecutions = []
|
||||||
const generatedReports = []
|
const generatedReports = []
|
||||||
|
let scheduledReportNotificationEnabled = false
|
||||||
const scheduledReportNextRun = (scheduleTime) => {
|
const scheduledReportNextRun = (scheduleTime) => {
|
||||||
const [hours, minutes] = String(scheduleTime || '09:00')
|
const [hours, minutes] = String(scheduleTime || '09:00')
|
||||||
.split(':')
|
.split(':')
|
||||||
@@ -437,10 +438,9 @@ handle('wechat-personal:getSendCapability', () => ({
|
|||||||
canSendVoice: personalWechatSupported,
|
canSendVoice: personalWechatSupported,
|
||||||
message: '个人微信已绑定'
|
message: '个人微信已绑定'
|
||||||
},
|
},
|
||||||
message:
|
message: personalWechatSupported
|
||||||
personalWechatSupported
|
? '个人微信已准备好发送日报'
|
||||||
? '个人微信已准备好发送日报'
|
: '微信消息发送目前仅支持 macOS 和 Windows'
|
||||||
: '微信消息发送目前仅支持 macOS 和 Windows'
|
|
||||||
}))
|
}))
|
||||||
handle('scheduled-report:list', () => [...scheduledReportTasks])
|
handle('scheduled-report:list', () => [...scheduledReportTasks])
|
||||||
handle('scheduled-report:listExecutions', (taskId) =>
|
handle('scheduled-report:listExecutions', (taskId) =>
|
||||||
@@ -448,6 +448,21 @@ handle('scheduled-report:listExecutions', (taskId) =>
|
|||||||
? scheduledReportExecutions.filter((execution) => execution.taskId === taskId)
|
? scheduledReportExecutions.filter((execution) => execution.taskId === taskId)
|
||||||
: [...scheduledReportExecutions]
|
: [...scheduledReportExecutions]
|
||||||
)
|
)
|
||||||
|
handle('scheduled-report:getNotificationSettings', () => ({
|
||||||
|
enabled: scheduledReportNotificationEnabled
|
||||||
|
}))
|
||||||
|
handle('scheduled-report:setNotificationEnabled', (enabled) => {
|
||||||
|
if (enabled) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
data: { enabled: false },
|
||||||
|
reason: 'agent_hub_offline',
|
||||||
|
error: '需要先连接 Agent Hub 微信机器人,才能接收异常通知。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scheduledReportNotificationEnabled = false
|
||||||
|
return { success: true, data: { enabled: false } }
|
||||||
|
})
|
||||||
handle('scheduled-report:create', (request) => {
|
handle('scheduled-report:create', (request) => {
|
||||||
const now = new Date(fixtureNowMs).toISOString()
|
const now = new Date(fixtureNowMs).toISOString()
|
||||||
const task = {
|
const task = {
|
||||||
@@ -495,18 +510,69 @@ handle('scheduled-report:setEnabled', (taskId, enabled) => {
|
|||||||
handle('scheduled-report:runNow', (taskId) => {
|
handle('scheduled-report:runNow', (taskId) => {
|
||||||
const task = scheduledReportTasks.find((item) => item.id === taskId)
|
const task = scheduledReportTasks.find((item) => item.id === taskId)
|
||||||
if (!task) return { success: false, error: '任务不存在' }
|
if (!task) return { success: false, error: '任务不存在' }
|
||||||
|
const pngPath = path.join(
|
||||||
|
userData,
|
||||||
|
`fixture-scheduled-report-${scheduledReportExecutions.length + 1}.png`
|
||||||
|
)
|
||||||
|
fs.writeFileSync(pngPath, Buffer.from(imageData.split(',')[1], 'base64'))
|
||||||
const execution = {
|
const execution = {
|
||||||
id: `fixture-execution-${scheduledReportExecutions.length + 1}`,
|
id: `fixture-execution-${scheduledReportExecutions.length + 1}`,
|
||||||
taskId,
|
taskId,
|
||||||
|
triggerType: 'manual',
|
||||||
startedAt: new Date(fixtureNowMs).toISOString(),
|
startedAt: new Date(fixtureNowMs).toISOString(),
|
||||||
finishedAt: new Date(fixtureNowMs).toISOString(),
|
finishedAt: new Date(fixtureNowMs).toISOString(),
|
||||||
status: 'success',
|
status: 'success',
|
||||||
|
currentStage: 'send',
|
||||||
|
scheduledSlot: new Date(fixtureNowMs).toISOString().slice(0, 10),
|
||||||
|
retryCount: 0,
|
||||||
|
reportId: `fixture-report-record-${scheduledReportExecutions.length + 1}`,
|
||||||
|
pngPath,
|
||||||
|
sendTarget: task.target,
|
||||||
|
sendStatus: 'success',
|
||||||
|
notificationStatus: 'not_needed',
|
||||||
message: '日报生成成功,微信发送成功'
|
message: '日报生成成功,微信发送成功'
|
||||||
}
|
}
|
||||||
scheduledReportExecutions.push(execution)
|
scheduledReportExecutions.push(execution)
|
||||||
task.lastRunAt = execution.finishedAt
|
task.lastRunAt = execution.finishedAt
|
||||||
return { success: true, data: execution }
|
return { success: true, data: execution }
|
||||||
})
|
})
|
||||||
|
handle('scheduled-report:retrySend', (executionId) => {
|
||||||
|
const execution = scheduledReportExecutions.find((item) => item.id === executionId)
|
||||||
|
if (!execution) return { success: false, error: '执行记录不存在' }
|
||||||
|
execution.status = 'success'
|
||||||
|
execution.currentStage = 'send'
|
||||||
|
execution.finishedAt = new Date(fixtureNowMs).toISOString()
|
||||||
|
execution.retryCount = Number(execution.retryCount || 0) + 1
|
||||||
|
execution.sendStatus = 'success'
|
||||||
|
execution.message = '日报生成成功,微信发送成功'
|
||||||
|
return { success: true, data: execution }
|
||||||
|
})
|
||||||
|
handle('scheduled-report:testErrorNotification', (taskId) => {
|
||||||
|
const task = scheduledReportTasks.find((item) => item.id === taskId)
|
||||||
|
if (!task) return { success: false, error: '任务不存在' }
|
||||||
|
const now = new Date(fixtureNowMs).toISOString()
|
||||||
|
const execution = {
|
||||||
|
id: `fixture-debug-execution-${scheduledReportExecutions.length + 1}`,
|
||||||
|
taskId,
|
||||||
|
triggerType: 'scheduled',
|
||||||
|
startedAt: now,
|
||||||
|
finishedAt: now,
|
||||||
|
status: 'failed',
|
||||||
|
currentStage: 'notify',
|
||||||
|
failedStage: 'report',
|
||||||
|
errorCode: 'DEBUG_TEST_NOTIFICATION',
|
||||||
|
technicalMessage: '这是调试用的模拟错误信息;本次没有调用 AI。',
|
||||||
|
userTitle: '定时日报错误通知测试',
|
||||||
|
userMessage: '这是一条调试用的模拟错误通知,用于验证 Agent Hub 推送链路。',
|
||||||
|
suggestedAction: '确认微信中是否收到这条测试通知。',
|
||||||
|
retryable: false,
|
||||||
|
retryCount: 0,
|
||||||
|
sendStatus: 'unavailable',
|
||||||
|
notificationStatus: 'sent'
|
||||||
|
}
|
||||||
|
scheduledReportExecutions.unshift(execution)
|
||||||
|
return { success: true, data: execution }
|
||||||
|
})
|
||||||
handle('wechat-share:getConfig', () => ({
|
handle('wechat-share:getConfig', () => ({
|
||||||
success: true,
|
success: true,
|
||||||
configured: true,
|
configured: true,
|
||||||
|
|||||||
@@ -141,6 +141,13 @@ describe('preload IPC contract', () => {
|
|||||||
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:getVoiceDiagnostic')
|
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:getVoiceDiagnostic')
|
||||||
await api.selectPersonalWechatImage()
|
await api.selectPersonalWechatImage()
|
||||||
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:selectImage')
|
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:selectImage')
|
||||||
|
|
||||||
|
await api.getScheduledReportNotificationSettings()
|
||||||
|
expect(invoke).toHaveBeenLastCalledWith('scheduled-report:getNotificationSettings')
|
||||||
|
await api.setScheduledReportNotificationEnabled(true)
|
||||||
|
expect(invoke).toHaveBeenLastCalledWith('scheduled-report:setNotificationEnabled', true)
|
||||||
|
await api.testScheduledReportErrorNotification('task-1')
|
||||||
|
expect(invoke).toHaveBeenLastCalledWith('scheduled-report:testErrorNotification', 'task-1')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('preserves key API return values without exposing ipcRenderer', async () => {
|
it('preserves key API return values without exposing ipcRenderer', async () => {
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ describe('scheduled report Local HTTP API', () => {
|
|||||||
expect(service.createTask).toHaveBeenCalledOnce()
|
expect(service.createTask).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects unsupported capability, ambiguous groups, and duplicate tasks', async () => {
|
it('allows unavailable sending capability while still rejecting ambiguous groups', async () => {
|
||||||
fixture.capability = {
|
fixture.capability = {
|
||||||
...fixture.capability,
|
...fixture.capability,
|
||||||
ready: false,
|
ready: false,
|
||||||
@@ -232,13 +232,16 @@ describe('scheduled report Local HTTP API', () => {
|
|||||||
message: '请先绑定个人微信'
|
message: '请先绑定个人微信'
|
||||||
}
|
}
|
||||||
const first = await startFixture()
|
const first = await startFixture()
|
||||||
const blocked = await fetch(`${baseUrl(first.handle)}/api/v1/scheduled-reports`, {
|
const createdWhileUnavailable = await fetch(
|
||||||
method: 'POST',
|
`${baseUrl(first.handle)}/api/v1/scheduled-reports`,
|
||||||
headers: { ...auth, 'Content-Type': 'application/json' },
|
{
|
||||||
body: JSON.stringify({ group: '技术交流群', schedule: { type: 'daily', time: '09:00' } })
|
method: 'POST',
|
||||||
})
|
headers: { ...auth, 'Content-Type': 'application/json' },
|
||||||
expect(blocked.status).toBe(409)
|
body: JSON.stringify({ group: '技术交流群', schedule: { type: 'daily', time: '09:00' } })
|
||||||
expect(await blocked.json()).toMatchObject({ error: 'wechat_not_ready' })
|
}
|
||||||
|
)
|
||||||
|
expect(createdWhileUnavailable.status).toBe(201)
|
||||||
|
expect(await createdWhileUnavailable.json()).toMatchObject({ created: true })
|
||||||
|
|
||||||
fixture.capability = { ...fixture.capability, ready: true, status: 'ready' }
|
fixture.capability = { ...fixture.capability, ready: true, status: 'ready' }
|
||||||
fixture.contacts = fixture.contacts.map((contact) => ({ ...contact, m_nsNickName: '重复群' }))
|
fixture.contacts = fixture.contacts.map((contact) => ({ ...contact, m_nsNickName: '重复群' }))
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ const nodeRequire = createRequire(import.meta.url)
|
|||||||
const asar = nodeRequire('@electron/asar') as {
|
const asar = nodeRequire('@electron/asar') as {
|
||||||
createPackage: (source: string, destination: string) => Promise<void>
|
createPackage: (source: string, destination: string) => Promise<void>
|
||||||
}
|
}
|
||||||
|
const { hasWindowsSherpaRuntime } = nodeRequire('../../scripts/prepare-win-runtime.cjs') as {
|
||||||
|
hasWindowsSherpaRuntime: (runtimeRoot: string) => boolean
|
||||||
|
}
|
||||||
const {
|
const {
|
||||||
validateAsarRuntimeDependencies,
|
validateAsarRuntimeDependencies,
|
||||||
validateFfmpegRuntime,
|
validateFfmpegRuntime,
|
||||||
@@ -68,6 +71,30 @@ describe('production runtime packaging', () => {
|
|||||||
expect(config).not.toContain('macos/')
|
expect(config).not.toContain('macos/')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('installs cross-platform optional dependencies before Windows packaging', () => {
|
||||||
|
const packageJson = JSON.parse(
|
||||||
|
readFileSync(resolve(__dirname, '../../package.json'), 'utf8')
|
||||||
|
) as { scripts: Record<string, string> }
|
||||||
|
|
||||||
|
expect(packageJson.scripts['prepare:win-runtime']).toBe(
|
||||||
|
'node scripts/prepare-win-runtime.cjs && npm run prepare:ffmpeg:win'
|
||||||
|
)
|
||||||
|
expect(packageJson.scripts['build:win']).toContain('npm run prepare:win-runtime')
|
||||||
|
expect(packageJson.scripts['release:win']).toContain('npm run prepare:win-runtime')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects an incomplete Windows sherpa runtime before invoking pnpm', () => {
|
||||||
|
const runtimeRoot = join(root, 'win-sherpa-runtime')
|
||||||
|
mkdirSync(runtimeRoot, { recursive: true })
|
||||||
|
expect(hasWindowsSherpaRuntime(runtimeRoot)).toBe(false)
|
||||||
|
|
||||||
|
writeFileSync(join(runtimeRoot, 'package.json'), '{}')
|
||||||
|
expect(hasWindowsSherpaRuntime(runtimeRoot)).toBe(false)
|
||||||
|
|
||||||
|
writeFileSync(join(runtimeRoot, 'sherpa-onnx.node'), 'fixture')
|
||||||
|
expect(hasWindowsSherpaRuntime(runtimeRoot)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
it('rejects an app archive with missing runtime dependencies', async () => {
|
it('rejects an app archive with missing runtime dependencies', async () => {
|
||||||
const resources = join(root, 'asar-resources')
|
const resources = join(root, 'asar-resources')
|
||||||
const source = join(root, 'asar-source')
|
const source = join(root, 'asar-source')
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ describe('ScheduledReportApiService', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it.each(['unsupported', 'unconfigured', 'needs_binding', 'needs_verification', 'error'] as const)(
|
it.each(['unsupported', 'unconfigured', 'needs_binding', 'needs_verification', 'error'] as const)(
|
||||||
'rejects creation before touching the task service for capability state %s',
|
'allows creation and leaves a send warning for capability state %s',
|
||||||
async (status) => {
|
async (status) => {
|
||||||
const { api, service } = makeApi({ capability: capability(status) })
|
const { api, service } = makeApi({ capability: capability(status) })
|
||||||
await expect(
|
await expect(
|
||||||
@@ -131,8 +131,8 @@ describe('ScheduledReportApiService', () => {
|
|||||||
group: '技术交流群',
|
group: '技术交流群',
|
||||||
schedule: { type: 'daily', time: '09:00' }
|
schedule: { type: 'daily', time: '09:00' }
|
||||||
})
|
})
|
||||||
).rejects.toMatchObject({ code: 'wechat_not_ready', status: 409 })
|
).resolves.toMatchObject({ id: 'task-1' })
|
||||||
expect(service.createTask).not.toHaveBeenCalled()
|
expect(service.createTask).toHaveBeenCalledOnce()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -153,7 +153,7 @@ describe('ScheduledReportApiService', () => {
|
|||||||
expect(service.createTask).toHaveBeenCalledOnce()
|
expect(service.createTask).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps unsupported platforms blocked even if a provider reports ready', async () => {
|
it('allows task creation on an unsupported platform for later recovery', async () => {
|
||||||
const { service } = makeApi()
|
const { service } = makeApi()
|
||||||
const api = new ScheduledReportApiService({
|
const api = new ScheduledReportApiService({
|
||||||
service,
|
service,
|
||||||
@@ -164,8 +164,8 @@ describe('ScheduledReportApiService', () => {
|
|||||||
})
|
})
|
||||||
await expect(
|
await expect(
|
||||||
api.create({ group: '技术交流群', schedule: { type: 'daily', time: '09:00' } })
|
api.create({ group: '技术交流群', schedule: { type: 'daily', time: '09:00' } })
|
||||||
).rejects.toMatchObject({ code: 'wechat_not_ready', status: 409 })
|
).resolves.toMatchObject({ id: 'task-1' })
|
||||||
expect(service.createTask).not.toHaveBeenCalled()
|
expect(service.createTask).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns candidates for ambiguous group names and rejects duplicate tasks', async () => {
|
it('returns candidates for ambiguous group names and rejects duplicate tasks', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { normalizeScheduledReportError } from '../../src/shared/scheduled-report-error'
|
||||||
|
|
||||||
|
describe('scheduled report error normalizer', () => {
|
||||||
|
it('maps provider context, quota, auth, timeout and availability errors', () => {
|
||||||
|
expect(
|
||||||
|
normalizeScheduledReportError({
|
||||||
|
error: 'maximum context length exceeded',
|
||||||
|
stage: 'ai'
|
||||||
|
}).code
|
||||||
|
).toBe('AI_CONTEXT_LIMIT')
|
||||||
|
expect(
|
||||||
|
normalizeScheduledReportError({
|
||||||
|
error: 'insufficient_quota',
|
||||||
|
code: 'insufficient_quota',
|
||||||
|
status: 429,
|
||||||
|
stage: 'ai'
|
||||||
|
}).code
|
||||||
|
).toBe('AI_QUOTA_EXCEEDED')
|
||||||
|
expect(
|
||||||
|
normalizeScheduledReportError({
|
||||||
|
error: 'Invalid API key',
|
||||||
|
status: 401,
|
||||||
|
stage: 'ai'
|
||||||
|
}).code
|
||||||
|
).toBe('AI_AUTH_INVALID')
|
||||||
|
expect(normalizeScheduledReportError({ error: 'AI 请求超时', stage: 'ai' }).code).toBe(
|
||||||
|
'AI_TIMEOUT'
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
normalizeScheduledReportError({ error: 'fetch failed: ECONNREFUSED', stage: 'ai' }).code
|
||||||
|
).toBe('AI_PROVIDER_UNAVAILABLE')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps legacy errors and keeps unknown errors unknown', () => {
|
||||||
|
expect(normalizeScheduledReportError('wechat_not_ready:needs_verification').code).toBe(
|
||||||
|
'WECHAT_SEND_UNAVAILABLE'
|
||||||
|
)
|
||||||
|
expect(normalizeScheduledReportError({ error: 'connector timeout', stage: 'send' }).code).toBe(
|
||||||
|
'WECHAT_SEND_FAILED'
|
||||||
|
)
|
||||||
|
expect(normalizeScheduledReportError({ error: 'unexpected', stage: 'report' })).toMatchObject({
|
||||||
|
code: 'UNKNOWN',
|
||||||
|
stage: 'report',
|
||||||
|
retryable: true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not expose provider credentials in technical details', () => {
|
||||||
|
expect(
|
||||||
|
normalizeScheduledReportError({
|
||||||
|
error: 'Bearer secret-token sk-test_secret',
|
||||||
|
stage: 'ai'
|
||||||
|
}).technicalMessage
|
||||||
|
).toBe('Bearer [已隐藏] ***')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { mkdtemp, readFile } from 'fs/promises'
|
import { mkdtemp, readFile, writeFile } from 'fs/promises'
|
||||||
import { tmpdir } from 'os'
|
import { tmpdir } from 'os'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
@@ -8,7 +8,14 @@ import {
|
|||||||
ScheduledReportService,
|
ScheduledReportService,
|
||||||
validateScheduleTime
|
validateScheduleTime
|
||||||
} from '../../src/main/services/scheduled-report-service'
|
} from '../../src/main/services/scheduled-report-service'
|
||||||
|
import type { AgentHubStatus } from '../../src/shared/agent-hub'
|
||||||
import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat'
|
import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat'
|
||||||
|
import type { GeneratedReportRecord } from '../../src/shared/report-history'
|
||||||
|
import type {
|
||||||
|
ScheduledReportExecution,
|
||||||
|
ScheduledReportNotification
|
||||||
|
} from '../../src/shared/scheduled-report'
|
||||||
|
import type { ScheduledReportDependencies } from '../../src/main/services/scheduled-report-service'
|
||||||
|
|
||||||
const capability: PersonalWechatSendCapability = {
|
const capability: PersonalWechatSendCapability = {
|
||||||
supported: true,
|
supported: true,
|
||||||
@@ -19,6 +26,74 @@ const capability: PersonalWechatSendCapability = {
|
|||||||
message: 'ready'
|
message: 'ready'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const unavailableCapability: PersonalWechatSendCapability = {
|
||||||
|
...capability,
|
||||||
|
ready: false,
|
||||||
|
status: 'needs_verification',
|
||||||
|
capabilities: { text: false, image: false, voice: false },
|
||||||
|
message: '请先完成微信消息能力检测'
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportRecord = (pngPath: string): GeneratedReportRecord => ({
|
||||||
|
id: 'report-1',
|
||||||
|
contactId: 'group-md5',
|
||||||
|
contactName: '研发群',
|
||||||
|
dateRange: '昨日',
|
||||||
|
messageCount: 12,
|
||||||
|
generatedAt: '2026-08-27T01:00:00.000Z',
|
||||||
|
reportDate: '2026-08-26',
|
||||||
|
pngPath,
|
||||||
|
htmlPath: `${pngPath}.html`,
|
||||||
|
htmlStatus: 'ready' as const,
|
||||||
|
pngStatus: 'ready' as const
|
||||||
|
})
|
||||||
|
|
||||||
|
function makeDependencies(overrides: Record<string, unknown> = {}): ScheduledReportDependencies {
|
||||||
|
return {
|
||||||
|
getCapability: async () => capability,
|
||||||
|
generateReport: async () => ({
|
||||||
|
success: true,
|
||||||
|
groupName: '研发群',
|
||||||
|
pngPath: '/tmp/generated.png',
|
||||||
|
messageCount: 12
|
||||||
|
}),
|
||||||
|
saveGeneratedReport: async () => ({
|
||||||
|
success: true,
|
||||||
|
record: reportRecord('/tmp/saved.png')
|
||||||
|
}),
|
||||||
|
send: async () => ({ success: true, status: capability.senderStatus }),
|
||||||
|
sendNotification: async () => ({ success: true, status: 'sent' as const }),
|
||||||
|
getNotificationRecipient: () => 'owner-wxid',
|
||||||
|
isDatabaseReady: () => true,
|
||||||
|
...overrides
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enableNotifications(storageDir: string): Promise<void> {
|
||||||
|
await writeFile(join(storageDir, 'settings.json'), JSON.stringify({ enabled: true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runScheduled(
|
||||||
|
service: ScheduledReportService,
|
||||||
|
taskId: string
|
||||||
|
): Promise<ScheduledReportExecution> {
|
||||||
|
const task = (await service.listTasks()).find((item) => item.id === taskId)
|
||||||
|
if (!task) throw new Error('scheduled task not found')
|
||||||
|
await service.tick(new Date(Date.parse(task.nextRunAt) + 1_000))
|
||||||
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||||
|
const execution = (await service.listExecutions(taskId))[0]
|
||||||
|
if (execution && execution.status !== 'running') return execution
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||||
|
}
|
||||||
|
throw new Error('scheduled execution did not finish')
|
||||||
|
}
|
||||||
|
|
||||||
|
const onlineAgentHubStatus = (): AgentHubStatus => ({
|
||||||
|
hub: 'online' as const,
|
||||||
|
connector: 'online' as const,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
describe('scheduled report scheduling', () => {
|
describe('scheduled report scheduling', () => {
|
||||||
it('validates daily HH:mm and computes the next local occurrence', () => {
|
it('validates daily HH:mm and computes the next local occurrence', () => {
|
||||||
expect(validateScheduleTime('09:05')).toBe(true)
|
expect(validateScheduleTime('09:05')).toBe(true)
|
||||||
@@ -28,6 +103,99 @@ describe('scheduled report scheduling', () => {
|
|||||||
expect(calculateNextRunAt('11:05', from)).toBe('2026-08-27T03:05:00.000Z')
|
expect(calculateNextRunAt('11:05', from)).toBe('2026-08-27T03:05:00.000Z')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('defaults notification settings to off and persists a successful enablement', async () => {
|
||||||
|
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-settings-'))
|
||||||
|
const sendNotification = vi.fn(async () => ({ success: true, status: 'sent' as const }))
|
||||||
|
const service = new ScheduledReportService({
|
||||||
|
storageDir,
|
||||||
|
getAgentHubStatus: onlineAgentHubStatus,
|
||||||
|
getNotificationRecipient: () => 'owner-wxid',
|
||||||
|
sendNotification
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(service.getNotificationSettings()).resolves.toEqual({ enabled: false })
|
||||||
|
const enabled = await service.setNotificationEnabled(true)
|
||||||
|
|
||||||
|
expect(enabled).toEqual({ success: true, data: { enabled: true } })
|
||||||
|
expect(sendNotification).toHaveBeenCalledWith({
|
||||||
|
to: 'owner-wxid',
|
||||||
|
text: `✅ TraceMemo 定时日报通知已开启
|
||||||
|
|
||||||
|
以后定时日报生成或发送出现异常时,
|
||||||
|
我会通过这里通知你。`
|
||||||
|
})
|
||||||
|
const restored = new ScheduledReportService({ storageDir })
|
||||||
|
await expect(restored.getNotificationSettings()).resolves.toEqual({ enabled: true })
|
||||||
|
await expect(service.setNotificationEnabled(false)).resolves.toEqual({
|
||||||
|
success: true,
|
||||||
|
data: { enabled: false }
|
||||||
|
})
|
||||||
|
await expect(restored.getNotificationSettings()).resolves.toEqual({ enabled: true })
|
||||||
|
const afterClose = new ScheduledReportService({ storageDir })
|
||||||
|
await expect(afterClose.getNotificationSettings()).resolves.toEqual({ enabled: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
name: 'rejects an offline Agent Hub',
|
||||||
|
status: { hub: 'offline' as const, connector: 'online' as const, updatedAt: Date.now() },
|
||||||
|
recipient: 'owner-wxid',
|
||||||
|
reason: 'agent_hub_offline' as const
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects an offline connector',
|
||||||
|
status: { hub: 'online' as const, connector: 'disconnected' as const, updatedAt: Date.now() },
|
||||||
|
recipient: 'owner-wxid',
|
||||||
|
reason: 'connector_offline' as const
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejects a missing notification recipient',
|
||||||
|
status: onlineAgentHubStatus(),
|
||||||
|
recipient: undefined,
|
||||||
|
reason: 'recipient_not_bound' as const
|
||||||
|
}
|
||||||
|
])('$name before saving enabled=true', async ({ status, recipient, reason }) => {
|
||||||
|
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-capability-'))
|
||||||
|
const sendNotification = vi.fn()
|
||||||
|
const service = new ScheduledReportService({
|
||||||
|
storageDir,
|
||||||
|
getAgentHubStatus: () => status,
|
||||||
|
getNotificationRecipient: () => recipient,
|
||||||
|
sendNotification
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await service.setNotificationEnabled(true)
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ success: false, data: { enabled: false }, reason })
|
||||||
|
expect(sendNotification).not.toHaveBeenCalled()
|
||||||
|
await expect(service.getNotificationSettings()).resolves.toEqual({ enabled: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not enable notifications when the real test send fails', async () => {
|
||||||
|
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-send-test-'))
|
||||||
|
const sendNotification = vi.fn(async () => ({
|
||||||
|
success: false,
|
||||||
|
status: 'send_failed' as const,
|
||||||
|
error: '连接器返回 500'
|
||||||
|
}))
|
||||||
|
const service = new ScheduledReportService({
|
||||||
|
storageDir,
|
||||||
|
getAgentHubStatus: onlineAgentHubStatus,
|
||||||
|
getNotificationRecipient: () => 'owner-wxid',
|
||||||
|
sendNotification
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await service.setNotificationEnabled(true)
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
data: { enabled: false },
|
||||||
|
reason: 'send_failed',
|
||||||
|
error: '连接器返回 500'
|
||||||
|
})
|
||||||
|
await expect(service.getNotificationSettings()).resolves.toEqual({ enabled: false })
|
||||||
|
})
|
||||||
|
|
||||||
it('persists lifecycle, executes generation and image sending, and restores tasks', async () => {
|
it('persists lifecycle, executes generation and image sending, and restores tasks', async () => {
|
||||||
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-'))
|
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-'))
|
||||||
const now = new Date('2026-08-27T01:00:00.000Z')
|
const now = new Date('2026-08-27T01:00:00.000Z')
|
||||||
@@ -111,4 +279,484 @@ describe('scheduled report scheduling', () => {
|
|||||||
expect(result.data?.error).toContain('report_history_save_failed:磁盘不可写')
|
expect(result.data?.error).toContain('report_history_save_failed:磁盘不可写')
|
||||||
expect(send).not.toHaveBeenCalled()
|
expect(send).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('records manual failures without creating user notifications', async () => {
|
||||||
|
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-manual-error-'))
|
||||||
|
const sendNotification = vi.fn()
|
||||||
|
const service = new ScheduledReportService({
|
||||||
|
storageDir,
|
||||||
|
...makeDependencies({
|
||||||
|
sendNotification,
|
||||||
|
generateReport: async () => ({
|
||||||
|
success: false,
|
||||||
|
error: 'maximum context length exceeded',
|
||||||
|
errorStage: 'ai' as const
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const created = await service.createTask({
|
||||||
|
name: '手动失败日报',
|
||||||
|
group: '研发群',
|
||||||
|
target: '研发群@chatroom',
|
||||||
|
scheduleTime: '09:00'
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await service.runScheduledReportNow(created.data!.id)
|
||||||
|
|
||||||
|
expect(result.data).toMatchObject({ status: 'failed', triggerType: 'manual' })
|
||||||
|
expect(result.data?.notificationStatus).toBe('not_needed')
|
||||||
|
expect(sendNotification).not.toHaveBeenCalled()
|
||||||
|
expect(await service.listNotifications()).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends a debug scheduled failure notification without generating or sending a report', async () => {
|
||||||
|
const storageDir = await mkdtemp(
|
||||||
|
join(tmpdir(), 'tracememo-scheduled-report-debug-notification-')
|
||||||
|
)
|
||||||
|
await enableNotifications(storageDir)
|
||||||
|
const generateReport = vi.fn()
|
||||||
|
const send = vi.fn()
|
||||||
|
const sendNotification = vi.fn(async () => ({ success: true, status: 'sent' as const }))
|
||||||
|
const service = new ScheduledReportService({
|
||||||
|
storageDir,
|
||||||
|
...makeDependencies({ generateReport, send, sendNotification })
|
||||||
|
})
|
||||||
|
const created = await service.createTask({
|
||||||
|
name: '调试日报',
|
||||||
|
group: '研发群',
|
||||||
|
target: '研发群@chatroom',
|
||||||
|
scheduleTime: '09:00'
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await service.testScheduledReportErrorNotification(created.data!.id)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data).toMatchObject({
|
||||||
|
taskId: created.data!.id,
|
||||||
|
triggerType: 'scheduled',
|
||||||
|
status: 'failed',
|
||||||
|
errorCode: 'DEBUG_TEST_NOTIFICATION',
|
||||||
|
notificationStatus: 'sent'
|
||||||
|
})
|
||||||
|
expect(result.data?.technicalMessage).toContain('没有调用 AI')
|
||||||
|
expect(generateReport).not.toHaveBeenCalled()
|
||||||
|
expect(send).not.toHaveBeenCalled()
|
||||||
|
expect(sendNotification).toHaveBeenCalledWith({
|
||||||
|
to: 'owner-wxid',
|
||||||
|
text: expect.stringContaining('定时日报错误通知测试')
|
||||||
|
})
|
||||||
|
expect(await service.listNotifications()).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('turns NO_MESSAGES into a neutral skipped execution without notification', async () => {
|
||||||
|
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-no-messages-'))
|
||||||
|
await enableNotifications(storageDir)
|
||||||
|
const sendNotification = vi.fn(async () => ({ success: true, status: 'sent' as const }))
|
||||||
|
const service = new ScheduledReportService({
|
||||||
|
storageDir,
|
||||||
|
...makeDependencies({
|
||||||
|
sendNotification,
|
||||||
|
getAgentHubStatus: onlineAgentHubStatus,
|
||||||
|
generateReport: async () => ({
|
||||||
|
success: false,
|
||||||
|
error: '所选时间范围没有可总结的消息',
|
||||||
|
errorCode: 'NO_MESSAGES',
|
||||||
|
errorStage: 'data' as const
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const created = await service.createTask({
|
||||||
|
name: '空消息日报',
|
||||||
|
group: '研发群',
|
||||||
|
target: '研发群@chatroom',
|
||||||
|
scheduleTime: '09:00'
|
||||||
|
})
|
||||||
|
|
||||||
|
const execution = await runScheduled(service, created.data!.id)
|
||||||
|
|
||||||
|
expect(execution).toMatchObject({
|
||||||
|
status: 'skipped',
|
||||||
|
errorCode: 'NO_MESSAGES',
|
||||||
|
userTitle: '暂无可生成的日报',
|
||||||
|
userMessage: '所选时间范围内没有可总结的聊天消息。',
|
||||||
|
notificationStatus: 'not_needed'
|
||||||
|
})
|
||||||
|
expect(sendNotification).not.toHaveBeenCalled()
|
||||||
|
expect(await service.listNotifications()).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('suppresses pending notifications when disabled and does not flush them after reopening', async () => {
|
||||||
|
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-suppressed-'))
|
||||||
|
await enableNotifications(storageDir)
|
||||||
|
let hubStatus = onlineAgentHubStatus()
|
||||||
|
const sendNotification = vi.fn(async () => ({
|
||||||
|
success: false,
|
||||||
|
status: 'connector_offline' as const,
|
||||||
|
error: '连接器暂时不可用'
|
||||||
|
}))
|
||||||
|
const service = new ScheduledReportService({
|
||||||
|
storageDir,
|
||||||
|
...makeDependencies({
|
||||||
|
getAgentHubStatus: () => hubStatus,
|
||||||
|
sendNotification,
|
||||||
|
getCapability: async () => unavailableCapability
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const created = await service.createTask({
|
||||||
|
name: '待关闭通知日报',
|
||||||
|
group: '研发群',
|
||||||
|
target: '研发群@chatroom',
|
||||||
|
scheduleTime: '09:00'
|
||||||
|
})
|
||||||
|
expect(await service.getNotificationSettings()).toEqual({ enabled: true })
|
||||||
|
const execution = await runScheduled(service, created.data!.id)
|
||||||
|
expect(execution.notificationStatus).toBe('pending')
|
||||||
|
expect(await service.listNotifications()).toEqual([
|
||||||
|
expect.objectContaining({ status: 'pending' })
|
||||||
|
])
|
||||||
|
|
||||||
|
await expect(service.setNotificationEnabled(false)).resolves.toMatchObject({
|
||||||
|
success: true,
|
||||||
|
data: { enabled: false }
|
||||||
|
})
|
||||||
|
expect(await service.listNotifications()).toEqual([
|
||||||
|
expect.objectContaining({ status: 'suppressed' })
|
||||||
|
])
|
||||||
|
|
||||||
|
hubStatus = onlineAgentHubStatus()
|
||||||
|
sendNotification.mockResolvedValue({ success: true, status: 'sent' })
|
||||||
|
await expect(service.setNotificationEnabled(true)).resolves.toMatchObject({
|
||||||
|
success: true,
|
||||||
|
data: { enabled: true }
|
||||||
|
})
|
||||||
|
expect(sendNotification).toHaveBeenCalledTimes(2)
|
||||||
|
expect((await service.listNotifications())[0].status).toBe('suppressed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('suppresses stale pending notifications when already disabled', async () => {
|
||||||
|
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-stale-pending-'))
|
||||||
|
const notification: ScheduledReportNotification = {
|
||||||
|
id: 'notification-1',
|
||||||
|
executionId: 'execution-1',
|
||||||
|
taskId: 'task-1',
|
||||||
|
type: 'failure',
|
||||||
|
severity: 'error',
|
||||||
|
title: '日报发送失败',
|
||||||
|
message: '连接器暂时不可用',
|
||||||
|
dedupeKey: 'execution-1:failure',
|
||||||
|
channel: 'agent_hub',
|
||||||
|
recipient: 'owner-wxid',
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: '2026-09-02T02:00:00.000Z',
|
||||||
|
attempts: 0
|
||||||
|
}
|
||||||
|
await writeFile(join(storageDir, 'notifications.json'), JSON.stringify([notification]))
|
||||||
|
const service = new ScheduledReportService({
|
||||||
|
storageDir,
|
||||||
|
now: () => new Date('2026-09-02T03:00:00.000Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(service.setNotificationEnabled(false)).resolves.toEqual({
|
||||||
|
success: true,
|
||||||
|
data: { enabled: false }
|
||||||
|
})
|
||||||
|
expect(await service.listNotifications()).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
status: 'suppressed',
|
||||||
|
suppressedAt: '2026-09-02T03:00:00.000Z',
|
||||||
|
lastError: '定时日报微信异常通知已关闭。'
|
||||||
|
})
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows creating and executing a report when personal WeChat is unavailable', async () => {
|
||||||
|
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-waiting-'))
|
||||||
|
await enableNotifications(storageDir)
|
||||||
|
const generateReport = vi.fn(async () => ({
|
||||||
|
success: true,
|
||||||
|
pngPath: '/tmp/generated.png',
|
||||||
|
groupName: '研发群'
|
||||||
|
}))
|
||||||
|
const saveHistory = vi.fn(async () => ({
|
||||||
|
success: true as const,
|
||||||
|
record: reportRecord('/tmp/saved.png')
|
||||||
|
}))
|
||||||
|
const send = vi.fn()
|
||||||
|
const service = new ScheduledReportService({
|
||||||
|
storageDir,
|
||||||
|
...makeDependencies({
|
||||||
|
getCapability: async () => unavailableCapability,
|
||||||
|
generateReport,
|
||||||
|
saveGeneratedReport: saveHistory,
|
||||||
|
send
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const created = await service.createTask({
|
||||||
|
name: '未发送日报',
|
||||||
|
group: '研发群',
|
||||||
|
target: '研发群@chatroom',
|
||||||
|
scheduleTime: '09:00'
|
||||||
|
})
|
||||||
|
expect(created.success).toBe(true)
|
||||||
|
|
||||||
|
const execution = await runScheduled(service, created.data!.id)
|
||||||
|
|
||||||
|
expect(execution).toMatchObject({
|
||||||
|
status: 'waiting_to_send',
|
||||||
|
errorCode: 'WECHAT_SEND_UNAVAILABLE',
|
||||||
|
pngPath: '/tmp/saved.png',
|
||||||
|
reportId: 'report-1',
|
||||||
|
sendStatus: 'unavailable',
|
||||||
|
notificationStatus: 'sent'
|
||||||
|
})
|
||||||
|
expect(generateReport).toHaveBeenCalledOnce()
|
||||||
|
expect(saveHistory).toHaveBeenCalledOnce()
|
||||||
|
expect(send).not.toHaveBeenCalled()
|
||||||
|
expect(await service.listNotifications()).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps notifications pending when no reliable recipient is available', async () => {
|
||||||
|
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-no-recipient-'))
|
||||||
|
await enableNotifications(storageDir)
|
||||||
|
const sendNotification = vi.fn()
|
||||||
|
const service = new ScheduledReportService({
|
||||||
|
storageDir,
|
||||||
|
...makeDependencies({
|
||||||
|
getCapability: async () => unavailableCapability,
|
||||||
|
getNotificationRecipient: () => undefined,
|
||||||
|
sendNotification
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const created = await service.createTask({
|
||||||
|
name: '待通知日报',
|
||||||
|
group: '研发群',
|
||||||
|
target: '研发群@chatroom',
|
||||||
|
scheduleTime: '09:00'
|
||||||
|
})
|
||||||
|
|
||||||
|
const execution = await runScheduled(service, created.data!.id)
|
||||||
|
const [notification] = await service.listNotifications()
|
||||||
|
|
||||||
|
expect(execution.notificationStatus).toBe('pending')
|
||||||
|
expect(notification).toMatchObject({ status: 'pending', attempts: 0 })
|
||||||
|
expect(notification.recipient).toBeUndefined()
|
||||||
|
expect(sendNotification).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps notifications pending when Agent Hub delivery fails', async () => {
|
||||||
|
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-hub-offline-'))
|
||||||
|
await enableNotifications(storageDir)
|
||||||
|
const sendNotification = vi.fn(async () => ({
|
||||||
|
success: false,
|
||||||
|
status: 'connector_offline' as const,
|
||||||
|
error: 'Agent Hub 连接器不可用'
|
||||||
|
}))
|
||||||
|
const service = new ScheduledReportService({
|
||||||
|
storageDir,
|
||||||
|
...makeDependencies({
|
||||||
|
getCapability: async () => unavailableCapability,
|
||||||
|
sendNotification
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const created = await service.createTask({
|
||||||
|
name: '连接器异常日报',
|
||||||
|
group: '研发群',
|
||||||
|
target: '研发群@chatroom',
|
||||||
|
scheduleTime: '09:00'
|
||||||
|
})
|
||||||
|
|
||||||
|
const execution = await runScheduled(service, created.data!.id)
|
||||||
|
const [notification] = await service.listNotifications()
|
||||||
|
|
||||||
|
expect(execution.notificationStatus).toBe('pending')
|
||||||
|
expect(notification).toMatchObject({
|
||||||
|
status: 'pending',
|
||||||
|
attempts: 1,
|
||||||
|
lastError: 'Agent Hub 连接器不可用'
|
||||||
|
})
|
||||||
|
expect(sendNotification).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the generated artifact when the image send attempt fails', async () => {
|
||||||
|
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-partial-'))
|
||||||
|
const service = new ScheduledReportService({
|
||||||
|
storageDir,
|
||||||
|
...makeDependencies({
|
||||||
|
send: vi.fn(async () => ({
|
||||||
|
success: false,
|
||||||
|
status: capability.senderStatus,
|
||||||
|
error: '连接器响应超时'
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const created = await service.createTask({
|
||||||
|
name: '发送失败日报',
|
||||||
|
group: '研发群',
|
||||||
|
target: '研发群@chatroom',
|
||||||
|
scheduleTime: '09:00'
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await service.runScheduledReportNow(created.data!.id)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data).toMatchObject({
|
||||||
|
status: 'partial_success',
|
||||||
|
errorCode: 'WECHAT_SEND_FAILED',
|
||||||
|
pngPath: '/tmp/saved.png',
|
||||||
|
sendStatus: 'failed',
|
||||||
|
sendTarget: '研发群@chatroom'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retries an existing PNG without generating the report again', async () => {
|
||||||
|
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-retry-'))
|
||||||
|
await enableNotifications(storageDir)
|
||||||
|
const generateReport = vi.fn(async () => ({
|
||||||
|
success: true,
|
||||||
|
pngPath: '/tmp/generated.png',
|
||||||
|
groupName: '研发群'
|
||||||
|
}))
|
||||||
|
let sendCount = 0
|
||||||
|
const send = vi.fn(async (request: { filePath: string }) => {
|
||||||
|
sendCount += 1
|
||||||
|
if (sendCount === 1) {
|
||||||
|
return { success: false, status: capability.senderStatus, error: '连接器暂时不可用' }
|
||||||
|
}
|
||||||
|
expect(request.filePath).toBe('/tmp/saved.png')
|
||||||
|
return { success: true, status: capability.senderStatus }
|
||||||
|
})
|
||||||
|
const service = new ScheduledReportService({
|
||||||
|
storageDir,
|
||||||
|
...makeDependencies({ generateReport, send })
|
||||||
|
})
|
||||||
|
const created = await service.createTask({
|
||||||
|
name: '可恢复日报',
|
||||||
|
group: '研发群',
|
||||||
|
target: '研发群@chatroom',
|
||||||
|
scheduleTime: '09:00'
|
||||||
|
})
|
||||||
|
const first = { data: await runScheduled(service, created.data!.id) }
|
||||||
|
const recovered = await service.retryScheduledReportSend(first.data!.id)
|
||||||
|
|
||||||
|
expect(first.data?.status).toBe('partial_success')
|
||||||
|
expect(recovered).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
data: { status: 'success', sendStatus: 'success', retryCount: 1 }
|
||||||
|
})
|
||||||
|
expect(recovered.data).not.toHaveProperty('error')
|
||||||
|
expect(recovered.data).not.toHaveProperty('errorCode')
|
||||||
|
expect(recovered.data).not.toHaveProperty('failedStage')
|
||||||
|
expect(recovered.data).not.toHaveProperty('technicalMessage')
|
||||||
|
expect(recovered.data).not.toHaveProperty('sendError')
|
||||||
|
expect(generateReport).toHaveBeenCalledOnce()
|
||||||
|
expect(send).toHaveBeenCalledTimes(2)
|
||||||
|
expect((await service.listNotifications()).map((item) => item.type)).toContain('recovery')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deduplicates repeated failure notifications for one execution', async () => {
|
||||||
|
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-dedupe-'))
|
||||||
|
await enableNotifications(storageDir)
|
||||||
|
const send = vi.fn(async () => ({
|
||||||
|
success: false,
|
||||||
|
status: capability.senderStatus,
|
||||||
|
error: '连接器暂时不可用'
|
||||||
|
}))
|
||||||
|
const sendNotification = vi.fn(async () => ({ success: true, status: 'sent' as const }))
|
||||||
|
const service = new ScheduledReportService({
|
||||||
|
storageDir,
|
||||||
|
...makeDependencies({ send, sendNotification })
|
||||||
|
})
|
||||||
|
const created = await service.createTask({
|
||||||
|
name: '重复失败日报',
|
||||||
|
group: '研发群',
|
||||||
|
target: '研发群@chatroom',
|
||||||
|
scheduleTime: '09:00'
|
||||||
|
})
|
||||||
|
|
||||||
|
const first = { data: await runScheduled(service, created.data!.id) }
|
||||||
|
const retried = await service.retryScheduledReportSend(first.data!.id)
|
||||||
|
|
||||||
|
expect(retried.data?.status).toBe('partial_success')
|
||||||
|
expect(await service.listNotifications()).toHaveLength(1)
|
||||||
|
expect(sendNotification).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes AI and unknown generation failures without a fake WeChat error', async () => {
|
||||||
|
const contextService = new ScheduledReportService({
|
||||||
|
storageDir: await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-context-')),
|
||||||
|
...makeDependencies({
|
||||||
|
generateReport: async () => ({
|
||||||
|
success: false,
|
||||||
|
error: 'maximum context length exceeded',
|
||||||
|
errorStage: 'ai' as const
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const contextTask = await contextService.createTask({
|
||||||
|
name: '上下文日报',
|
||||||
|
group: '研发群',
|
||||||
|
target: '研发群@chatroom',
|
||||||
|
scheduleTime: '09:00'
|
||||||
|
})
|
||||||
|
const contextResult = await contextService.runScheduledReportNow(contextTask.data!.id)
|
||||||
|
expect(contextResult.data).toMatchObject({ errorCode: 'AI_CONTEXT_LIMIT', failedStage: 'ai' })
|
||||||
|
expect(contextResult.data?.error).not.toContain('wechat_not_ready')
|
||||||
|
|
||||||
|
const unknownService = new ScheduledReportService({
|
||||||
|
storageDir: await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-unknown-')),
|
||||||
|
...makeDependencies({
|
||||||
|
generateReport: async () => {
|
||||||
|
throw new Error('unexpected generation failure')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const unknownTask = await unknownService.createTask({
|
||||||
|
name: '未知错误日报',
|
||||||
|
group: '研发群',
|
||||||
|
target: '研发群@chatroom',
|
||||||
|
scheduleTime: '09:00'
|
||||||
|
})
|
||||||
|
const unknownResult = await unknownService.runScheduledReportNow(unknownTask.data!.id)
|
||||||
|
expect(unknownResult.data).toMatchObject({ errorCode: 'UNKNOWN', failedStage: 'report' })
|
||||||
|
expect(unknownResult.data?.error).not.toContain('wechat_not_ready')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps old execution records readable and claims one scheduled slot once', async () => {
|
||||||
|
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-compat-'))
|
||||||
|
const oldExecution: ScheduledReportExecution = {
|
||||||
|
id: 'old-execution',
|
||||||
|
taskId: 'old-task',
|
||||||
|
startedAt: '2026-08-27T01:00:00.000Z',
|
||||||
|
finishedAt: '2026-08-27T01:01:00.000Z',
|
||||||
|
status: 'failed',
|
||||||
|
error: 'report_generation_failed:旧错误',
|
||||||
|
message: '旧错误'
|
||||||
|
}
|
||||||
|
await writeFile(join(storageDir, 'executions.json'), JSON.stringify([oldExecution]))
|
||||||
|
const generated = vi.fn(async () => ({ success: true, pngPath: '/tmp/generated.png' }))
|
||||||
|
const service = new ScheduledReportService({
|
||||||
|
storageDir,
|
||||||
|
now: () => new Date('2026-08-27T01:00:00.000Z'),
|
||||||
|
...makeDependencies({ generateReport: generated })
|
||||||
|
})
|
||||||
|
expect(await service.listExecutions()).toEqual([
|
||||||
|
expect.objectContaining({ triggerType: 'scheduled', retryCount: 0 })
|
||||||
|
])
|
||||||
|
const created = await service.createTask({
|
||||||
|
name: '幂等日报',
|
||||||
|
group: '研发群',
|
||||||
|
target: '研发群@chatroom',
|
||||||
|
scheduleTime: '09:00'
|
||||||
|
})
|
||||||
|
const dueAt = new Date(created.data!.nextRunAt)
|
||||||
|
await service.tick(new Date(dueAt.getTime() + 1_000))
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
await service.tick(new Date(dueAt.getTime() + 2_000))
|
||||||
|
|
||||||
|
expect(generated).toHaveBeenCalledOnce()
|
||||||
|
expect(
|
||||||
|
(await service.listExecutions()).filter((item) => item.taskId === created.data!.id)
|
||||||
|
).toHaveLength(1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user