mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-05 12:34:35 +08:00
feat: 新增定时日报与微信发送能力底座
This commit is contained in:
@@ -112,8 +112,14 @@ import {
|
||||
import { installSafeConsole } from './safe-log'
|
||||
import { agentHubService } from './services/agent-hub-service'
|
||||
import { personalWechatSendService } from './services/personal-wechat-send-service'
|
||||
import { getPersonalWechatSendCapability } from './services/personal-wechat-capability-service'
|
||||
import { scheduledReportService } from './services/scheduled-report-service'
|
||||
import { PersonalWechatRuntimeManager } from './services/personal-wechat-runtime-manager'
|
||||
import type { PersonalWechatSendRequest } from '../shared/personal-wechat'
|
||||
import type {
|
||||
ScheduledReportCreateInput,
|
||||
ScheduledReportUpdateInput
|
||||
} from '../shared/scheduled-report'
|
||||
import { TextToSpeechSettingsService } from './services/text-to-speech-settings-service'
|
||||
import type {
|
||||
ListTextToSpeechVoicesRequest,
|
||||
@@ -1280,6 +1286,29 @@ app.whenReady().then(async () => {
|
||||
return deleteGeneratedReport(reportId)
|
||||
})
|
||||
|
||||
ipcMain.handle('wechat-personal:getSendCapability', () => getPersonalWechatSendCapability())
|
||||
ipcMain.handle('scheduled-report:list', () => scheduledReportService.listTasks())
|
||||
ipcMain.handle('scheduled-report:listExecutions', (_, taskId?: string) =>
|
||||
scheduledReportService.listExecutions(taskId)
|
||||
)
|
||||
ipcMain.handle('scheduled-report:create', (_, request: ScheduledReportCreateInput) =>
|
||||
scheduledReportService.createTask(request)
|
||||
)
|
||||
ipcMain.handle(
|
||||
'scheduled-report:update',
|
||||
(_, taskId: string, request: ScheduledReportUpdateInput) =>
|
||||
scheduledReportService.updateTask(taskId, request)
|
||||
)
|
||||
ipcMain.handle('scheduled-report:delete', (_, taskId: string) =>
|
||||
scheduledReportService.deleteTask(taskId)
|
||||
)
|
||||
ipcMain.handle('scheduled-report:setEnabled', (_, taskId: string, enabled: boolean) =>
|
||||
scheduledReportService.setTaskEnabled(taskId, Boolean(enabled))
|
||||
)
|
||||
ipcMain.handle('scheduled-report:runNow', (_, taskId: string) =>
|
||||
scheduledReportService.runScheduledReportNow(taskId)
|
||||
)
|
||||
|
||||
ipcMain.handle('report:reveal', async (_, filePath: string) => {
|
||||
try {
|
||||
shell.showItemInFolder(filePath)
|
||||
@@ -1859,6 +1888,7 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
|
||||
await agentHubService.start(settings)
|
||||
await scheduledReportService.start()
|
||||
|
||||
setupTray()
|
||||
if (TRAY_MODE) app.dock?.hide()
|
||||
@@ -1895,6 +1925,7 @@ app.on('before-quit', (event) => {
|
||||
|
||||
void (async () => {
|
||||
agentHubService.stop()
|
||||
scheduledReportService.stop()
|
||||
flushBootstrapCacheWritesSync()
|
||||
const [, nativeCallsDrained] = await Promise.all([
|
||||
apiServer.stop().catch(() => undefined),
|
||||
|
||||
@@ -16,7 +16,7 @@ const aiProvider = new AIProviderService()
|
||||
|
||||
export interface AgentGroupReportRequest {
|
||||
group: string
|
||||
range?: SummaryDateRange
|
||||
range?: SummaryDateRange | 'recent24h'
|
||||
}
|
||||
|
||||
export interface AgentGroupReportResult {
|
||||
@@ -42,7 +42,10 @@ export async function generateAgentGroupReport(
|
||||
}
|
||||
|
||||
const range = request.range === 'yesterday' || request.range === '7days' ? request.range : 'today'
|
||||
const { startTime, endTime } = getSummaryDateRange(range)
|
||||
const { startTime, endTime } =
|
||||
request.range === 'recent24h'
|
||||
? { startTime: Math.floor(Date.now() / 1000) - 86400, endTime: Math.floor(Date.now() / 1000) }
|
||||
: getSummaryDateRange(range)
|
||||
let messages = listMessages(contact.md5, startTime, endTime) as Message[]
|
||||
if (!messages.length) return { success: false, error: '所选时间范围没有可总结的消息' }
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import type {
|
||||
PersonalWechatSendCapability,
|
||||
PersonalWechatSendCapabilityState,
|
||||
PersonalWechatSenderStatus
|
||||
} from '../../shared/personal-wechat'
|
||||
import {
|
||||
personalWechatSendService,
|
||||
type PersonalWechatSendService
|
||||
} from './personal-wechat-send-service'
|
||||
|
||||
/**
|
||||
* Converts the detailed sender diagnostics into a small contract that other
|
||||
* features can consume without knowing about OneBot, Hook or platform details.
|
||||
*/
|
||||
export class PersonalWechatCapabilityService {
|
||||
constructor(private readonly sender: Pick<PersonalWechatSendService, 'getStatus'>) {}
|
||||
|
||||
async getPersonalWechatSendCapability(): Promise<PersonalWechatSendCapability> {
|
||||
const senderStatus = await this.sender.getStatus()
|
||||
return this.fromSenderStatus(senderStatus)
|
||||
}
|
||||
|
||||
fromSenderStatus(senderStatus: PersonalWechatSenderStatus): PersonalWechatSendCapability {
|
||||
const capabilities = {
|
||||
text: Boolean(senderStatus.canSendText),
|
||||
image: Boolean(senderStatus.canSendImage),
|
||||
voice: Boolean(senderStatus.canSendVoice)
|
||||
}
|
||||
const status = this.mapState(senderStatus, capabilities.image)
|
||||
const ready = status === 'ready'
|
||||
return {
|
||||
supported: status !== 'unsupported',
|
||||
ready,
|
||||
status,
|
||||
capabilities,
|
||||
senderStatus,
|
||||
message:
|
||||
status === 'unsupported'
|
||||
? '微信消息发送目前仅支持 macOS'
|
||||
: status === 'needs_binding' || status === 'unconfigured'
|
||||
? '请先绑定个人微信'
|
||||
: status === 'needs_verification'
|
||||
? '请先完成微信消息能力检测'
|
||||
: status === 'ready'
|
||||
? '个人微信已准备好发送日报'
|
||||
: senderStatus.message || senderStatus.error || '个人微信发送能力异常',
|
||||
...(senderStatus.error ? { error: senderStatus.error } : {})
|
||||
}
|
||||
}
|
||||
|
||||
private mapState(
|
||||
senderStatus: PersonalWechatSenderStatus,
|
||||
canSendImage: boolean
|
||||
): PersonalWechatSendCapabilityState {
|
||||
if (senderStatus.platform !== 'darwin' || senderStatus.state === 'unsupported_platform') {
|
||||
return 'unsupported'
|
||||
}
|
||||
if (senderStatus.state === 'error') return 'error'
|
||||
const hasBinding = Boolean(senderStatus.boundWechatPid)
|
||||
if (!hasBinding) {
|
||||
return senderStatus.runtimeReady ? 'needs_binding' : 'unconfigured'
|
||||
}
|
||||
if (canSendImage) return 'ready'
|
||||
if (
|
||||
senderStatus.state === 'hook_not_ready' ||
|
||||
senderStatus.state === 'online' ||
|
||||
senderStatus.state === 'starting' ||
|
||||
senderStatus.state === 'stopped'
|
||||
) {
|
||||
return 'needs_verification'
|
||||
}
|
||||
return 'error'
|
||||
}
|
||||
}
|
||||
|
||||
export const personalWechatCapabilityService = new PersonalWechatCapabilityService(
|
||||
personalWechatSendService
|
||||
)
|
||||
|
||||
export const getPersonalWechatSendCapability = (): Promise<PersonalWechatSendCapability> =>
|
||||
personalWechatCapabilityService.getPersonalWechatSendCapability()
|
||||
@@ -0,0 +1,404 @@
|
||||
import { app } from 'electron'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
import type {
|
||||
PersonalWechatSendCapability,
|
||||
PersonalWechatSendRequest
|
||||
} from '../../shared/personal-wechat'
|
||||
import type {
|
||||
ScheduledReportCreateInput,
|
||||
ScheduledReportExecution,
|
||||
ScheduledReportRange,
|
||||
ScheduledReportResult,
|
||||
ScheduledReportTask,
|
||||
ScheduledReportUpdateInput
|
||||
} from '../../shared/scheduled-report'
|
||||
import { generateAgentGroupReport } from './agent-group-report-service'
|
||||
import { personalWechatSendService } from './personal-wechat-send-service'
|
||||
import { personalWechatCapabilityService } from './personal-wechat-capability-service'
|
||||
import { isReady as isChatReady, resolveMd5 } from './chat-service'
|
||||
|
||||
const STORAGE_DIR = 'scheduled-reports'
|
||||
const TASKS_FILE = 'tasks.json'
|
||||
const EXECUTIONS_FILE = 'executions.json'
|
||||
const TICK_MS = 15_000
|
||||
|
||||
export interface ScheduledReportDependencies {
|
||||
getCapability: () => Promise<PersonalWechatSendCapability>
|
||||
generateReport: typeof generateAgentGroupReport
|
||||
send: (request: PersonalWechatSendRequest) => ReturnType<typeof personalWechatSendService.send>
|
||||
storageDir: string
|
||||
isDatabaseReady: () => boolean
|
||||
now?: () => Date
|
||||
}
|
||||
|
||||
const defaultDependencies = (): ScheduledReportDependencies => ({
|
||||
getCapability: () => personalWechatCapabilityService.getPersonalWechatSendCapability(),
|
||||
generateReport: generateAgentGroupReport,
|
||||
send: (request) => personalWechatSendService.send(request),
|
||||
storageDir: path.join(app.getPath('userData'), STORAGE_DIR),
|
||||
isDatabaseReady: () => isChatReady()
|
||||
})
|
||||
|
||||
const rangeValues = new Set<ScheduledReportRange>(['yesterday', 'recent24h'])
|
||||
|
||||
export function validateScheduleTime(value: string): boolean {
|
||||
return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(String(value || '').trim())
|
||||
}
|
||||
|
||||
export function calculateNextRunAt(scheduleTime: string, from = new Date()): string {
|
||||
if (!validateScheduleTime(scheduleTime)) throw new Error('执行时间必须是 HH:mm')
|
||||
const [hour, minute] = scheduleTime.split(':').map(Number)
|
||||
const next = new Date(from)
|
||||
next.setHours(hour, minute, 0, 0)
|
||||
if (next.getTime() <= from.getTime()) next.setDate(next.getDate() + 1)
|
||||
return next.toISOString()
|
||||
}
|
||||
|
||||
const asArray = <T>(value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : [])
|
||||
|
||||
export class ScheduledReportService {
|
||||
private readonly deps: ScheduledReportDependencies
|
||||
private tasks: ScheduledReportTask[] | null = null
|
||||
private executions: ScheduledReportExecution[] | null = null
|
||||
private timer: NodeJS.Timeout | null = null
|
||||
private readonly running = new Map<string, Promise<ScheduledReportExecution>>()
|
||||
|
||||
constructor(deps?: Partial<ScheduledReportDependencies>) {
|
||||
this.deps = { ...defaultDependencies(), ...deps }
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
await this.load()
|
||||
if (this.timer) return
|
||||
this.timer = setInterval(() => {
|
||||
void this.tick().catch((error) => console.warn('[ScheduledReport] tick failed:', error))
|
||||
}, TICK_MS)
|
||||
void this.tick().catch((error) => console.warn('[ScheduledReport] initial tick failed:', error))
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.timer) clearInterval(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
|
||||
async listTasks(): Promise<ScheduledReportTask[]> {
|
||||
await this.load()
|
||||
return this.tasks!.map((task) => ({ ...task }))
|
||||
}
|
||||
|
||||
async listExecutions(taskId?: string): Promise<ScheduledReportExecution[]> {
|
||||
await this.load()
|
||||
const items = taskId
|
||||
? this.executions!.filter((item) => item.taskId === taskId)
|
||||
: this.executions!
|
||||
return items.map((item) => ({ ...item }))
|
||||
}
|
||||
|
||||
async createTask(
|
||||
input: ScheduledReportCreateInput
|
||||
): Promise<ScheduledReportResult<ScheduledReportTask>> {
|
||||
const capability = await this.deps.getCapability()
|
||||
if (!capability.ready) return { success: false, error: this.creationError(capability) }
|
||||
const normalized = this.normalizeInput(input)
|
||||
if (!normalized.success) return { success: false, error: normalized.error }
|
||||
const values = normalized.data!
|
||||
await this.load()
|
||||
const now = this.deps.now?.() || new Date()
|
||||
const task: ScheduledReportTask = {
|
||||
id: `scheduled_report_${randomUUID()}`,
|
||||
name: values.name,
|
||||
group: values.group,
|
||||
scheduleTime: values.scheduleTime,
|
||||
reportRange: values.reportRange,
|
||||
target: values.target,
|
||||
enabled: values.enabled,
|
||||
createdAt: now.toISOString(),
|
||||
updatedAt: now.toISOString(),
|
||||
nextRunAt: calculateNextRunAt(values.scheduleTime, now)
|
||||
}
|
||||
this.tasks!.push(task)
|
||||
await this.saveTasks()
|
||||
return { success: true, data: { ...task } }
|
||||
}
|
||||
|
||||
async updateTask(
|
||||
taskId: string,
|
||||
input: ScheduledReportUpdateInput
|
||||
): Promise<ScheduledReportResult<ScheduledReportTask>> {
|
||||
await this.load()
|
||||
const index = this.tasks!.findIndex((task) => task.id === taskId)
|
||||
if (index < 0) return { success: false, error: '未找到定时日报任务' }
|
||||
const current = this.tasks![index]
|
||||
const normalized = this.normalizeInput({ ...current, ...input })
|
||||
if (!normalized.success) return { success: false, error: normalized.error }
|
||||
const values = normalized.data!
|
||||
const now = this.deps.now?.() || new Date()
|
||||
const scheduleChanged = values.scheduleTime !== current.scheduleTime
|
||||
const updated: ScheduledReportTask = {
|
||||
...current,
|
||||
...values,
|
||||
updatedAt: now.toISOString(),
|
||||
...(scheduleChanged ? { nextRunAt: calculateNextRunAt(values.scheduleTime, now) } : {})
|
||||
}
|
||||
this.tasks![index] = updated
|
||||
await this.saveTasks()
|
||||
return { success: true, data: { ...updated } }
|
||||
}
|
||||
|
||||
async deleteTask(taskId: string): Promise<ScheduledReportResult<{ deletedId: string }>> {
|
||||
await this.load()
|
||||
const before = this.tasks!.length
|
||||
this.tasks = this.tasks!.filter((task) => task.id !== taskId)
|
||||
if (this.tasks.length === before) return { success: false, error: '未找到定时日报任务' }
|
||||
await this.saveTasks()
|
||||
return { success: true, data: { deletedId: taskId } }
|
||||
}
|
||||
|
||||
async setTaskEnabled(
|
||||
taskId: string,
|
||||
enabled: boolean
|
||||
): Promise<ScheduledReportResult<ScheduledReportTask>> {
|
||||
if (!enabled) return this.updateTask(taskId, { enabled: false })
|
||||
await this.load()
|
||||
const task = this.tasks!.find((item) => item.id === taskId)
|
||||
if (!task) return { success: false, error: '未找到定时日报任务' }
|
||||
const now = this.deps.now?.() || new Date()
|
||||
const updated = {
|
||||
...task,
|
||||
enabled: true,
|
||||
nextRunAt: calculateNextRunAt(task.scheduleTime, now),
|
||||
updatedAt: now.toISOString()
|
||||
}
|
||||
this.tasks![this.tasks!.findIndex((item) => item.id === taskId)] = updated
|
||||
await this.saveTasks()
|
||||
return { success: true, data: { ...updated } }
|
||||
}
|
||||
|
||||
async runScheduledReportNow(
|
||||
taskId: string
|
||||
): Promise<ScheduledReportResult<ScheduledReportExecution>> {
|
||||
await this.load()
|
||||
const task = this.tasks!.find((item) => item.id === taskId)
|
||||
if (!task) return { success: false, error: '未找到定时日报任务' }
|
||||
const execution = await this.runTask(task)
|
||||
return {
|
||||
success: execution.status === 'success',
|
||||
data: execution,
|
||||
...(execution.error ? { error: execution.error } : {})
|
||||
}
|
||||
}
|
||||
|
||||
async tick(at = this.deps.now?.() || new Date()): Promise<void> {
|
||||
await this.load()
|
||||
if (!this.deps.isDatabaseReady()) return
|
||||
const nowMs = at.getTime()
|
||||
for (const task of [...this.tasks!]) {
|
||||
if (!task.enabled || Date.parse(task.nextRunAt) > nowMs) continue
|
||||
const slot = task.nextRunAt
|
||||
if (task.lastScheduledSlot === slot) continue
|
||||
const slotMs = Date.parse(slot)
|
||||
const recentExecution = this.executions!.filter((item) => item.taskId === task.id).sort(
|
||||
(left, right) => Date.parse(right.startedAt) - Date.parse(left.startedAt)
|
||||
)[0]
|
||||
const executionOverlapsSlot = Boolean(
|
||||
recentExecution &&
|
||||
Date.parse(recentExecution.startedAt) >= slotMs - 60_000 &&
|
||||
Date.parse(recentExecution.startedAt) <= nowMs
|
||||
)
|
||||
const index = this.tasks!.findIndex((item) => item.id === task.id)
|
||||
if (index < 0 || !this.tasks![index].enabled) continue
|
||||
const claimed: ScheduledReportTask = {
|
||||
...this.tasks![index],
|
||||
lastScheduledSlot: slot,
|
||||
nextRunAt: calculateNextRunAt(task.scheduleTime, new Date(nowMs + 60_000)),
|
||||
updatedAt: at.toISOString()
|
||||
}
|
||||
this.tasks![index] = claimed
|
||||
await this.saveTasks()
|
||||
if (executionOverlapsSlot) continue
|
||||
void this.runTask(claimed, slot).catch((error) =>
|
||||
console.warn('[ScheduledReport] execution failed:', error)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async runTask(
|
||||
task: ScheduledReportTask,
|
||||
scheduledSlot?: string
|
||||
): Promise<ScheduledReportExecution> {
|
||||
const existing = this.running.get(task.id)
|
||||
if (existing) return existing
|
||||
const promise = this.executeTask(task, scheduledSlot).finally(() =>
|
||||
this.running.delete(task.id)
|
||||
)
|
||||
this.running.set(task.id, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
private async executeTask(
|
||||
task: ScheduledReportTask,
|
||||
scheduledSlot?: string
|
||||
): Promise<ScheduledReportExecution> {
|
||||
await this.load()
|
||||
const startedAt = (this.deps.now?.() || new Date()).toISOString()
|
||||
const execution: ScheduledReportExecution = {
|
||||
id: `scheduled_report_execution_${randomUUID()}`,
|
||||
taskId: task.id,
|
||||
startedAt,
|
||||
status: 'running',
|
||||
...(scheduledSlot ? { scheduledSlot } : {})
|
||||
}
|
||||
this.executions!.push(execution)
|
||||
await this.saveExecutions()
|
||||
const finish = async (
|
||||
status: ScheduledReportExecution['status'],
|
||||
error?: string,
|
||||
message?: string
|
||||
): Promise<ScheduledReportExecution> => {
|
||||
const completed: ScheduledReportExecution = {
|
||||
...execution,
|
||||
status,
|
||||
finishedAt: (this.deps.now?.() || new Date()).toISOString(),
|
||||
...(error ? { error } : {}),
|
||||
...(message ? { message } : {})
|
||||
}
|
||||
const index = this.executions!.findIndex((item) => item.id === execution.id)
|
||||
if (index >= 0) this.executions![index] = completed
|
||||
await this.saveExecutions()
|
||||
const taskIndex = this.tasks!.findIndex((item) => item.id === task.id)
|
||||
if (taskIndex >= 0) {
|
||||
this.tasks![taskIndex] = {
|
||||
...this.tasks![taskIndex],
|
||||
...(status !== 'running' ? { lastRunAt: completed.finishedAt } : {}),
|
||||
updatedAt: completed.finishedAt!
|
||||
}
|
||||
await this.saveTasks()
|
||||
}
|
||||
return completed
|
||||
}
|
||||
|
||||
try {
|
||||
const capability = await this.deps.getCapability()
|
||||
if (!capability.ready) {
|
||||
return finish('failed', `wechat_not_ready:${capability.status}`, capability.message)
|
||||
}
|
||||
let generated: Awaited<ReturnType<typeof this.deps.generateReport>>
|
||||
try {
|
||||
generated = await this.deps.generateReport({ group: task.group, range: task.reportRange })
|
||||
} catch (error) {
|
||||
return finish(
|
||||
'failed',
|
||||
`report_generation_failed:${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
if (!generated.success || !generated.pngPath) {
|
||||
return finish('failed', `report_generation_failed:${generated.error || '日报生成失败'}`)
|
||||
}
|
||||
const target = this.resolveTarget(task)
|
||||
if (!target) return finish('failed', 'wechat_send_failed:未找到指定微信群')
|
||||
let sent: Awaited<ReturnType<typeof this.deps.send>>
|
||||
try {
|
||||
sent = await this.deps.send({
|
||||
type: 'image',
|
||||
to: target,
|
||||
isGroup: true,
|
||||
filePath: generated.pngPath
|
||||
})
|
||||
} catch (error) {
|
||||
return finish(
|
||||
'failed',
|
||||
`wechat_send_failed:${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
if (!sent.success)
|
||||
return finish('failed', `wechat_send_failed:${sent.error || '微信发送失败'}`)
|
||||
return finish('success', undefined, '日报生成成功,微信发送成功')
|
||||
} catch (error) {
|
||||
return finish(
|
||||
'failed',
|
||||
`wechat_not_ready:${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private resolveTarget(task: ScheduledReportTask): string | undefined {
|
||||
const explicit = String(task.target || '').trim()
|
||||
if (explicit.endsWith('@chatroom')) return explicit
|
||||
const contact = resolveMd5(task.group || explicit)
|
||||
return contact?.m_nsUsrName?.endsWith('@chatroom') ? contact.m_nsUsrName : undefined
|
||||
}
|
||||
|
||||
private creationError(capability: PersonalWechatSendCapability): string {
|
||||
if (capability.status === 'unsupported') return '微信消息发送目前仅支持 macOS'
|
||||
if (capability.status === 'needs_binding' || capability.status === 'unconfigured')
|
||||
return '请先绑定个人微信'
|
||||
if (capability.status === 'needs_verification') return '请先完成微信消息能力检测'
|
||||
return capability.error || capability.message || '个人微信发送能力异常'
|
||||
}
|
||||
|
||||
private normalizeInput(input: ScheduledReportCreateInput): ScheduledReportResult<{
|
||||
name: string
|
||||
group: string
|
||||
scheduleTime: string
|
||||
reportRange: ScheduledReportRange
|
||||
target: string
|
||||
enabled: boolean
|
||||
}> {
|
||||
const name = String(input.name || '').trim()
|
||||
const group = String(input.group || '').trim()
|
||||
const scheduleTime = String(input.scheduleTime || '').trim()
|
||||
const reportRange = input.reportRange || 'yesterday'
|
||||
const target = String(input.target || group).trim()
|
||||
if (!name) return { success: false, error: '日报名称不能为空' }
|
||||
if (!group) return { success: false, error: '微信群不能为空' }
|
||||
if (!validateScheduleTime(scheduleTime))
|
||||
return { success: false, error: '执行时间必须是 HH:mm' }
|
||||
if (!rangeValues.has(reportRange)) return { success: false, error: '日报范围不受支持' }
|
||||
return {
|
||||
success: true,
|
||||
data: { name, group, scheduleTime, reportRange, target, enabled: input.enabled !== false }
|
||||
}
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
if (this.tasks && this.executions) return
|
||||
await fs.mkdir(this.deps.storageDir, { recursive: true })
|
||||
const [tasks, executions] = await Promise.all([
|
||||
this.readJson<ScheduledReportTask[]>(TASKS_FILE),
|
||||
this.readJson<ScheduledReportExecution[]>(EXECUTIONS_FILE)
|
||||
])
|
||||
this.tasks = asArray<ScheduledReportTask>(tasks)
|
||||
this.executions = asArray<ScheduledReportExecution>(executions)
|
||||
}
|
||||
|
||||
private async readJson<T>(file: string): Promise<T | undefined> {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(path.join(this.deps.storageDir, file), 'utf8')) as T
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.warn(`[ScheduledReport] failed to read ${file}:`, error)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
private async saveTasks(): Promise<void> {
|
||||
await fs.writeFile(
|
||||
path.join(this.deps.storageDir, TASKS_FILE),
|
||||
JSON.stringify(this.tasks, null, 2),
|
||||
'utf8'
|
||||
)
|
||||
}
|
||||
|
||||
private async saveExecutions(): Promise<void> {
|
||||
await fs.writeFile(
|
||||
path.join(this.deps.storageDir, EXECUTIONS_FILE),
|
||||
JSON.stringify(this.executions, null, 2),
|
||||
'utf8'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const scheduledReportService = new ScheduledReportService()
|
||||
Vendored
+28
@@ -61,6 +61,14 @@ import type {
|
||||
PersonalWechatSenderStatus,
|
||||
PersonalWechatVoiceDiagnostic
|
||||
} from '../shared/personal-wechat'
|
||||
import type { PersonalWechatSendCapability } from '../shared/personal-wechat'
|
||||
import type {
|
||||
ScheduledReportCreateInput,
|
||||
ScheduledReportExecution,
|
||||
ScheduledReportResult,
|
||||
ScheduledReportTask,
|
||||
ScheduledReportUpdateInput
|
||||
} from '../shared/scheduled-report'
|
||||
import type {
|
||||
PersonalWechatRuntimeDownloadResult,
|
||||
PersonalWechatRuntimeProgressEvent,
|
||||
@@ -582,6 +590,7 @@ declare global {
|
||||
limit?: number
|
||||
) => Promise<{ success: boolean; insights: ImageInsight[] }>
|
||||
getPersonalWechatSenderStatus: () => Promise<PersonalWechatSenderStatus>
|
||||
getPersonalWechatSendCapability: () => Promise<PersonalWechatSendCapability>
|
||||
getPersonalWechatKeepOneBotProcess: () => Promise<boolean>
|
||||
setPersonalWechatKeepOneBotProcess: (keep: boolean) => Promise<boolean>
|
||||
getPersonalWechatRuntimeStatus: () => Promise<PersonalWechatRuntimeStatus>
|
||||
@@ -598,6 +607,25 @@ declare global {
|
||||
sendPersonalWechatMessage: (
|
||||
request: PersonalWechatSendRequest
|
||||
) => Promise<PersonalWechatSendResult>
|
||||
listScheduledReports: () => Promise<ScheduledReportTask[]>
|
||||
listScheduledReportExecutions: (taskId?: string) => Promise<ScheduledReportExecution[]>
|
||||
createScheduledReport: (
|
||||
request: ScheduledReportCreateInput
|
||||
) => Promise<ScheduledReportResult<ScheduledReportTask>>
|
||||
updateScheduledReport: (
|
||||
taskId: string,
|
||||
request: ScheduledReportUpdateInput
|
||||
) => Promise<ScheduledReportResult<ScheduledReportTask>>
|
||||
deleteScheduledReport: (
|
||||
taskId: string
|
||||
) => Promise<ScheduledReportResult<{ deletedId: string }>>
|
||||
setScheduledReportEnabled: (
|
||||
taskId: string,
|
||||
enabled: boolean
|
||||
) => Promise<ScheduledReportResult<ScheduledReportTask>>
|
||||
runScheduledReportNow: (
|
||||
taskId: string
|
||||
) => Promise<ScheduledReportResult<ScheduledReportExecution>>
|
||||
getPersonalWechatVoiceDiagnostic: () => Promise<PersonalWechatVoiceDiagnostic | null>
|
||||
getAgentHubStatus: () => Promise<AgentHubStatus>
|
||||
getAgentHubLogs: () => Promise<AgentHubLogEntry[]>
|
||||
|
||||
@@ -33,6 +33,14 @@ import type {
|
||||
PersonalWechatSenderStatus,
|
||||
PersonalWechatVoiceDiagnostic
|
||||
} from '../shared/personal-wechat'
|
||||
import type { PersonalWechatSendCapability } from '../shared/personal-wechat'
|
||||
import type {
|
||||
ScheduledReportCreateInput,
|
||||
ScheduledReportExecution,
|
||||
ScheduledReportResult,
|
||||
ScheduledReportTask,
|
||||
ScheduledReportUpdateInput
|
||||
} from '../shared/scheduled-report'
|
||||
import type {
|
||||
PersonalWechatRuntimeDownloadResult,
|
||||
PersonalWechatRuntimeProgressEvent,
|
||||
@@ -353,6 +361,8 @@ const api = {
|
||||
ipcRenderer.invoke('image:listInsights', sessionId, limit),
|
||||
getPersonalWechatSenderStatus: (): Promise<PersonalWechatSenderStatus> =>
|
||||
ipcRenderer.invoke('wechat-personal:getStatus'),
|
||||
getPersonalWechatSendCapability: (): Promise<PersonalWechatSendCapability> =>
|
||||
ipcRenderer.invoke('wechat-personal:getSendCapability'),
|
||||
getPersonalWechatKeepOneBotProcess: (): Promise<boolean> =>
|
||||
ipcRenderer.invoke('wechat-personal:getKeepProcess'),
|
||||
setPersonalWechatKeepOneBotProcess: (keep: boolean): Promise<boolean> =>
|
||||
@@ -386,6 +396,30 @@ const api = {
|
||||
sendPersonalWechatMessage: (
|
||||
request: PersonalWechatSendRequest
|
||||
): Promise<PersonalWechatSendResult> => ipcRenderer.invoke('wechat-personal:send', request),
|
||||
listScheduledReports: (): Promise<ScheduledReportTask[]> =>
|
||||
ipcRenderer.invoke('scheduled-report:list'),
|
||||
listScheduledReportExecutions: (taskId?: string): Promise<ScheduledReportExecution[]> =>
|
||||
ipcRenderer.invoke('scheduled-report:listExecutions', taskId),
|
||||
createScheduledReport: (
|
||||
request: ScheduledReportCreateInput
|
||||
): Promise<ScheduledReportResult<ScheduledReportTask>> =>
|
||||
ipcRenderer.invoke('scheduled-report:create', request),
|
||||
updateScheduledReport: (
|
||||
taskId: string,
|
||||
request: ScheduledReportUpdateInput
|
||||
): Promise<ScheduledReportResult<ScheduledReportTask>> =>
|
||||
ipcRenderer.invoke('scheduled-report:update', taskId, request),
|
||||
deleteScheduledReport: (taskId: string): Promise<ScheduledReportResult<{ deletedId: string }>> =>
|
||||
ipcRenderer.invoke('scheduled-report:delete', taskId),
|
||||
setScheduledReportEnabled: (
|
||||
taskId: string,
|
||||
enabled: boolean
|
||||
): Promise<ScheduledReportResult<ScheduledReportTask>> =>
|
||||
ipcRenderer.invoke('scheduled-report:setEnabled', taskId, enabled),
|
||||
runScheduledReportNow: (
|
||||
taskId: string
|
||||
): Promise<ScheduledReportResult<ScheduledReportExecution>> =>
|
||||
ipcRenderer.invoke('scheduled-report:runNow', taskId),
|
||||
getPersonalWechatVoiceDiagnostic: (): Promise<PersonalWechatVoiceDiagnostic | null> =>
|
||||
ipcRenderer.invoke('wechat-personal:getVoiceDiagnostic'),
|
||||
getAgentHubStatus: () => ipcRenderer.invoke('agent-hub:getStatus'),
|
||||
|
||||
@@ -44,6 +44,29 @@ export interface PersonalWechatSenderStatus {
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** Stable, feature-facing send capability derived from the low-level sender status. */
|
||||
export type PersonalWechatSendCapabilityState =
|
||||
| 'unsupported'
|
||||
| 'unconfigured'
|
||||
| 'needs_binding'
|
||||
| 'needs_verification'
|
||||
| 'ready'
|
||||
| 'error'
|
||||
|
||||
export interface PersonalWechatSendCapability {
|
||||
supported: boolean
|
||||
ready: boolean
|
||||
status: PersonalWechatSendCapabilityState
|
||||
capabilities: {
|
||||
text: boolean
|
||||
image: boolean
|
||||
voice: boolean
|
||||
}
|
||||
senderStatus: PersonalWechatSenderStatus
|
||||
message: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface PersonalWechatSendBaseRequest {
|
||||
to: string
|
||||
isGroup: boolean
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
export type ScheduledReportRange = 'yesterday' | 'recent24h'
|
||||
export type ScheduledReportExecutionStatus = 'running' | 'success' | 'failed'
|
||||
|
||||
export interface ScheduledReportTask {
|
||||
id: string
|
||||
name: string
|
||||
/** Human-readable group name or WeChat room id. */
|
||||
group: string
|
||||
scheduleTime: string
|
||||
reportRange: ScheduledReportRange
|
||||
/** Current implementation targets one specified WeChat group. */
|
||||
target: string
|
||||
enabled: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
lastRunAt?: string
|
||||
nextRunAt: string
|
||||
/** Internal idempotency marker for a daily scheduled slot. */
|
||||
lastScheduledSlot?: string
|
||||
}
|
||||
|
||||
export interface ScheduledReportExecution {
|
||||
id: string
|
||||
taskId: string
|
||||
startedAt: string
|
||||
finishedAt?: string
|
||||
status: ScheduledReportExecutionStatus
|
||||
error?: string
|
||||
message?: string
|
||||
scheduledSlot?: string
|
||||
}
|
||||
|
||||
export interface ScheduledReportCreateInput {
|
||||
name: string
|
||||
group: string
|
||||
scheduleTime: string
|
||||
reportRange?: ScheduledReportRange
|
||||
target?: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export type ScheduledReportUpdateInput = Partial<
|
||||
Pick<
|
||||
ScheduledReportCreateInput,
|
||||
'name' | 'group' | 'scheduleTime' | 'reportRange' | 'target' | 'enabled'
|
||||
>
|
||||
>
|
||||
|
||||
export interface ScheduledReportResult<T> {
|
||||
success: boolean
|
||||
data?: T
|
||||
error?: string
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
vi.mock('electron', () => ({ app: { getPath: () => '/tmp/tracememo-test-user-data' } }))
|
||||
import { PersonalWechatCapabilityService } from '../../src/main/services/personal-wechat-capability-service'
|
||||
import type { PersonalWechatSenderStatus } from '../../src/shared/personal-wechat'
|
||||
|
||||
const status = (
|
||||
overrides: Partial<PersonalWechatSenderStatus> = {}
|
||||
): PersonalWechatSenderStatus => ({
|
||||
state: 'hook_not_ready',
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
sipDisabled: true,
|
||||
wechatRunning: true,
|
||||
endpoint: '127.0.0.1:58080',
|
||||
endpointReady: true,
|
||||
runtimeReady: true,
|
||||
attachReady: true,
|
||||
baseAddressReady: true,
|
||||
textHookInstalled: true,
|
||||
textHookReady: false,
|
||||
imageHookInstalled: true,
|
||||
imageHookReady: false,
|
||||
messageListenerReady: true,
|
||||
canSend: false,
|
||||
canSendText: false,
|
||||
canSendImage: false,
|
||||
canSendVoice: false,
|
||||
message: 'pending',
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('PersonalWechatCapabilityService', () => {
|
||||
it.each([
|
||||
['unsupported', status({ platform: 'win32' })],
|
||||
['unconfigured', status({ runtimeReady: false, boundWechatPid: undefined })],
|
||||
['needs_binding', status({ runtimeReady: true, boundWechatPid: undefined })],
|
||||
['needs_verification', status({ boundWechatPid: 123 })],
|
||||
['ready', status({ state: 'online', boundWechatPid: 123, canSendImage: true, canSend: true })],
|
||||
['error', status({ state: 'error', boundWechatPid: 123, error: 'hook failed' })]
|
||||
])('maps %s', (expected, senderStatus) => {
|
||||
const service = new PersonalWechatCapabilityService({ getStatus: async () => senderStatus })
|
||||
return expect(service.getPersonalWechatSendCapability()).resolves.toMatchObject({
|
||||
status: expected,
|
||||
ready: expected === 'ready',
|
||||
supported: expected !== 'unsupported'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getAppPath: () => '/fixture/app',
|
||||
getPath: () => '/tmp/tracememo-test-user-data',
|
||||
isPackaged: false
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { mkdtemp, readFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
vi.mock('electron', () => ({ app: { getPath: () => '/tmp/tracememo-test-user-data' } }))
|
||||
import {
|
||||
calculateNextRunAt,
|
||||
ScheduledReportService,
|
||||
validateScheduleTime
|
||||
} from '../../src/main/services/scheduled-report-service'
|
||||
import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat'
|
||||
|
||||
const capability: PersonalWechatSendCapability = {
|
||||
supported: true,
|
||||
ready: true,
|
||||
status: 'ready',
|
||||
capabilities: { text: true, image: true, voice: true },
|
||||
senderStatus: {} as never,
|
||||
message: 'ready'
|
||||
}
|
||||
|
||||
describe('scheduled report scheduling', () => {
|
||||
it('validates daily HH:mm and computes the next local occurrence', () => {
|
||||
expect(validateScheduleTime('09:05')).toBe(true)
|
||||
expect(validateScheduleTime('24:00')).toBe(false)
|
||||
const from = new Date('2026-08-27T10:00:00+08:00')
|
||||
expect(calculateNextRunAt('09:05', from)).toBe('2026-08-28T01:05:00.000Z')
|
||||
expect(calculateNextRunAt('11:05', from)).toBe('2026-08-27T03:05:00.000Z')
|
||||
})
|
||||
|
||||
it('persists lifecycle, executes generation and image sending, and restores tasks', async () => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-'))
|
||||
const now = new Date('2026-08-27T01:00:00.000Z')
|
||||
const generatedPath = join(storageDir, 'report.png')
|
||||
let generated = 0
|
||||
let sent = 0
|
||||
const service = new ScheduledReportService({
|
||||
storageDir,
|
||||
now: () => now,
|
||||
getCapability: async () => capability,
|
||||
generateReport: async () => {
|
||||
generated += 1
|
||||
return { success: true, pngPath: generatedPath }
|
||||
},
|
||||
send: async () => {
|
||||
sent += 1
|
||||
return { success: true, status: capability.senderStatus }
|
||||
},
|
||||
isDatabaseReady: () => true
|
||||
})
|
||||
const created = await service.createTask({
|
||||
name: '每日群报',
|
||||
group: '研发群',
|
||||
target: '研发群@chatroom',
|
||||
scheduleTime: '09:00'
|
||||
})
|
||||
expect(created.success).toBe(true)
|
||||
expect(created.data?.reportRange).toBe('yesterday')
|
||||
const taskId = created.data!.id
|
||||
const run = await service.runScheduledReportNow(taskId)
|
||||
expect(run.data?.status).toBe('success')
|
||||
expect(generated).toBe(1)
|
||||
expect(sent).toBe(1)
|
||||
expect(await service.listExecutions(taskId)).toHaveLength(1)
|
||||
const restored = new ScheduledReportService({
|
||||
storageDir,
|
||||
getCapability: async () => capability
|
||||
})
|
||||
expect((await restored.listTasks())[0].id).toBe(taskId)
|
||||
await service.setTaskEnabled(taskId, false)
|
||||
expect((await service.listTasks())[0].enabled).toBe(false)
|
||||
await service.deleteTask(taskId)
|
||||
expect(await service.listTasks()).toHaveLength(0)
|
||||
expect(
|
||||
JSON.parse(await readFile(join(storageDir, 'executions.json'), 'utf8'))[0].message
|
||||
).toContain('微信发送成功')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user