mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-04 03:01:42 +08:00
feat: 扩展 Skill 支持定时日报操作
This commit is contained in:
@@ -28,7 +28,7 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
|
||||
## 端点速查
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| ---- | --------------------- | ------------------------------------------------- |
|
||||
| ------ | ----------------------------------- | ------------------------------------------------- |
|
||||
| GET | `/health` | 健康和数据库状态 |
|
||||
| GET | `/current_time` | 本机时间与时区 |
|
||||
| GET | `/contact` | 联系人/群聊列表;可传 `filter`、`type` |
|
||||
@@ -38,11 +38,60 @@ description: 通过 TraceMemo 本地 HTTP API 按需读取用户有权访问的
|
||||
| GET | `/media/{messageId}` | 获取图片消息的真实图片二进制资源 |
|
||||
| GET | `/group_snapshot` | 群成员快照;必填 `md5` |
|
||||
| GET | `/resolve` | 昵称、wxid、md5 解析;必填 `q` |
|
||||
| GET | `/wechat-personal/send-capability` | 个人微信图片发送能力状态 |
|
||||
| GET | `/scheduled-reports` | 查询全部定时日报任务 |
|
||||
| GET | `/scheduled-reports/:id` | 查询单个定时日报任务 |
|
||||
| POST | `/scheduled-reports` | 创建定时日报任务 |
|
||||
| PATCH | `/scheduled-reports/:id` | 修改定时日报任务 |
|
||||
| DELETE | `/scheduled-reports/:id` | 删除定时日报任务(执行前必须获得用户确认) |
|
||||
| POST | `/scheduled-reports/:id/enable` | 启用定时日报任务 |
|
||||
| POST | `/scheduled-reports/:id/disable` | 暂停定时日报任务 |
|
||||
| POST | `/scheduled-reports/:id/run` | 立即执行一次并返回 execution |
|
||||
| GET | `/scheduled-reports/:id/executions` | 查询执行记录 |
|
||||
| POST | `/report` | 将已有日报结构渲染为 HTML/PNG |
|
||||
| GET | `/agent/status` | Agent Hub、连接器和数据库状态 |
|
||||
| POST | `/agent/group-report` | 按群和 `today`/`yesterday`/`7days` 生成总结图片 |
|
||||
| POST | `/agent/send` | 已连接机器人发送测试 |
|
||||
|
||||
## 定时日报管理
|
||||
|
||||
定时日报由 TraceMemo 自己持久化和调度。Agent 只负责理解自然语言、解析群聊和时间,再调用上述 API;不要创建 cron、维护任务文件、计算下一次执行时间或自行发送微信。
|
||||
|
||||
### 创建任务
|
||||
|
||||
用户提出“每天早上 9 点给技术交流群发昨天的日报”时,按以下顺序执行:
|
||||
|
||||
1. 调用 `/health`,确认 TraceMemo 和数据库可用。
|
||||
2. 调用 `/wechat-personal/send-capability`,只有 `capability.status === "ready"` 且 `capability.capabilities.image === true` 才允许继续。
|
||||
3. 用户使用“今天”“昨天”等相对日期时调用 `/current_time`;日报任务的 `schedule.time` 使用 TraceMemo 本机时区的 `HH:mm`,不要转成 UTC。
|
||||
4. 调用 `/chatroom` 或 `/contact?type=group` 查找群聊。名称匹配多个结果时,必须把候选项展示给用户并要求选择;不能猜测。
|
||||
5. 使用唯一群聊的 `talker` 创建:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "技术交流群 · 每日日报",
|
||||
"group": { "talker": "xxx@chatroom", "name": "技术交流群" },
|
||||
"schedule": { "type": "daily", "time": "09:00" },
|
||||
"reportRange": "yesterday",
|
||||
"target": { "type": "wechat_group", "talker": "xxx@chatroom" },
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
如果 API 返回 `409` 且 `error === "duplicate"`,告诉用户相同任务已经存在,不要再次创建。能力状态为 `unsupported`、`unconfigured`、`needs_binding`、`needs_verification` 或 `error` 时,直接说明需要先在 TraceMemo 设置中完成个人微信绑定和消息能力检测。
|
||||
|
||||
### 查看、修改和执行
|
||||
|
||||
- “我现在有哪些定时日报”调用 `GET /scheduled-reports`,使用返回的 `tasks` 展示任务名称、群聊、每天的时间、范围、目标和启停状态。
|
||||
- 修改前先查询列表并确认唯一任务,再调用 `PATCH /scheduled-reports/:id`。只提交需要修改的字段,例如 `{"schedule":{"type":"daily","time":"10:00"}}`。
|
||||
- 暂停调用 `/scheduled-reports/:id/disable`,恢复调用 `/scheduled-reports/:id/enable`。
|
||||
- “现在执行一次”调用 `/scheduled-reports/:id/run`,不要改用 `/agent/group-report` 后自行发送微信;该接口和定时执行共用同一条链路。
|
||||
- 查询执行结果调用 `/scheduled-reports/:id/executions`,根据 `status`、`startedAt`、`finishedAt`、`message` 和 `error` 向用户解释结果。
|
||||
|
||||
### 删除确认
|
||||
|
||||
删除是不可逆操作。收到删除请求后,先用任务列表找到唯一任务,向用户展示任务名称、时间、日报范围和发送目标并明确询问确认;只有用户明确确认后,才调用 `DELETE /scheduled-reports/:id`。
|
||||
|
||||
## 时间与上下文规则
|
||||
|
||||
`/chatlog` 的 `time` 支持 `YYYY-MM-DD`、日期闭区间和分钟范围;也可以使用 Unix 秒级 `startTime`/`endTime`。时间按 TraceMemo 所在机器的本机时区解释。
|
||||
|
||||
+151
-1
@@ -11,6 +11,17 @@ import {
|
||||
import { exportGroupReport } from './group-report-service'
|
||||
import { GroupReportExportRequest } from '../shared/group-report'
|
||||
import { generateAgentGroupReport } from './services/agent-group-report-service'
|
||||
import { scheduledReportService } from './services/scheduled-report-service'
|
||||
import { personalWechatCapabilityService } from './services/personal-wechat-capability-service'
|
||||
import {
|
||||
ScheduledReportApiError,
|
||||
ScheduledReportApiService,
|
||||
type ScheduledReportApiDependencies
|
||||
} from './services/scheduled-report-api-service'
|
||||
import type {
|
||||
ScheduledReportApiCreateRequest,
|
||||
ScheduledReportApiUpdateRequest
|
||||
} from '../shared/scheduled-report-api'
|
||||
import { agentHubService } from './services/agent-hub-service'
|
||||
import { safeError, safeLog, safeWarn } from './safe-log'
|
||||
import { apiTokenStore } from './api-token-store'
|
||||
@@ -35,6 +46,11 @@ interface RouteContext {
|
||||
export interface HttpServerOptions {
|
||||
tokenProvider?: () => string | null
|
||||
mediaProvider?: (messageId: string) => Promise<HttpImageResult>
|
||||
scheduledReportService?: ScheduledReportApiDependencies['service']
|
||||
scheduledReportCapabilityProvider?: ScheduledReportApiDependencies['getCapability']
|
||||
scheduledReportContactsProvider?: ScheduledReportApiDependencies['listContacts']
|
||||
scheduledReportDatabaseReadyProvider?: ScheduledReportApiDependencies['isDatabaseReady']
|
||||
scheduledReportPlatform?: NodeJS.Platform
|
||||
}
|
||||
|
||||
type RouteHandler = (ctx: RouteContext) => void | Promise<void>
|
||||
@@ -67,7 +83,7 @@ function applyCorsHeaders(req: IncomingMessage, res: ServerResponse): boolean {
|
||||
if (!isAllowedCorsOrigin(origin)) return false
|
||||
res.setHeader('Access-Control-Allow-Origin', origin)
|
||||
res.setHeader('Vary', 'Origin')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS')
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
||||
return true
|
||||
}
|
||||
@@ -367,6 +383,138 @@ const routes: Record<string, RouteHandler> = {
|
||||
}
|
||||
}
|
||||
|
||||
const SCHEDULED_REPORTS_ROUTE = '/api/v1/scheduled-reports'
|
||||
const WECHAT_SEND_CAPABILITY_ROUTE = '/api/v1/wechat-personal/send-capability'
|
||||
|
||||
function parseJsonBody(body: unknown): unknown {
|
||||
if (typeof body !== 'string' || !body.trim()) {
|
||||
throw new ScheduledReportApiError(400, 'invalid_request', '请求体不能为空')
|
||||
}
|
||||
try {
|
||||
return JSON.parse(body)
|
||||
} catch {
|
||||
throw new ScheduledReportApiError(400, 'invalid_request', '请求体 JSON 解析失败')
|
||||
}
|
||||
}
|
||||
|
||||
function sendScheduledError(res: ServerResponse, error: unknown): void {
|
||||
if (error instanceof ScheduledReportApiError) {
|
||||
sendJson(res, error.status, {
|
||||
error: error.code,
|
||||
message: error.message,
|
||||
...(error.details !== undefined ? { details: error.details } : {})
|
||||
})
|
||||
return
|
||||
}
|
||||
safeError('[HttpServer] scheduled report request failed:', error)
|
||||
sendJson(res, 500, {
|
||||
error: 'internal_error',
|
||||
message: '定时日报 API 执行失败'
|
||||
})
|
||||
}
|
||||
|
||||
function createScheduledReportApi(options: HttpServerOptions): ScheduledReportApiService {
|
||||
return new ScheduledReportApiService({
|
||||
service: options.scheduledReportService || scheduledReportService,
|
||||
getCapability:
|
||||
options.scheduledReportCapabilityProvider ||
|
||||
(() => personalWechatCapabilityService.getPersonalWechatSendCapability()),
|
||||
listContacts: options.scheduledReportContactsProvider || listContacts,
|
||||
isDatabaseReady: options.scheduledReportDatabaseReadyProvider || isReady,
|
||||
platform: options.scheduledReportPlatform
|
||||
})
|
||||
}
|
||||
|
||||
function createScheduledReportRoute(
|
||||
pathname: string,
|
||||
api: ScheduledReportApiService
|
||||
): RouteHandler | undefined {
|
||||
if (pathname === WECHAT_SEND_CAPABILITY_ROUTE) {
|
||||
return async ({ req, res }) => {
|
||||
if (req.method !== 'GET') return sendError(res, 405, '需要 GET 请求')
|
||||
try {
|
||||
sendJson(res, 200, { capability: await api.getCapability() })
|
||||
} catch (error) {
|
||||
sendScheduledError(res, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === SCHEDULED_REPORTS_ROUTE) {
|
||||
return async ({ req, res, body }) => {
|
||||
try {
|
||||
if (req.method === 'GET') {
|
||||
const tasks = await api.list()
|
||||
sendJson(res, 200, { count: tasks.length, tasks })
|
||||
return
|
||||
}
|
||||
if (req.method !== 'POST') {
|
||||
sendError(res, 405, '需要 GET 或 POST 请求')
|
||||
return
|
||||
}
|
||||
const task = await api.create(parseJsonBody(body) as ScheduledReportApiCreateRequest)
|
||||
sendJson(res, 201, { created: true, task })
|
||||
} catch (error) {
|
||||
sendScheduledError(res, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const prefix = `${SCHEDULED_REPORTS_ROUTE}/`
|
||||
if (!pathname.startsWith(prefix)) return undefined
|
||||
const segments = pathname.slice(prefix.length).split('/').filter(Boolean)
|
||||
if (!segments.length || segments.length > 2) return undefined
|
||||
let taskId: string
|
||||
try {
|
||||
taskId = decodeURIComponent(segments[0])
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
const action = segments[1]
|
||||
|
||||
return async ({ req, res, body }) => {
|
||||
try {
|
||||
if (!action && req.method === 'GET') {
|
||||
sendJson(res, 200, { task: await api.get(taskId) })
|
||||
return
|
||||
}
|
||||
if (!action && req.method === 'PATCH') {
|
||||
const task = await api.update(
|
||||
taskId,
|
||||
parseJsonBody(body) as ScheduledReportApiUpdateRequest
|
||||
)
|
||||
sendJson(res, 200, { updated: true, task })
|
||||
return
|
||||
}
|
||||
if (!action && req.method === 'DELETE') {
|
||||
sendJson(res, 200, { deleted: true, ...(await api.delete(taskId)) })
|
||||
return
|
||||
}
|
||||
if (action === 'enable' && req.method === 'POST') {
|
||||
sendJson(res, 200, { updated: true, task: await api.setEnabled(taskId, true) })
|
||||
return
|
||||
}
|
||||
if (action === 'disable' && req.method === 'POST') {
|
||||
sendJson(res, 200, { updated: true, task: await api.setEnabled(taskId, false) })
|
||||
return
|
||||
}
|
||||
if (action === 'run' && req.method === 'POST') {
|
||||
const execution = await api.run(taskId)
|
||||
sendJson(res, 200, { success: execution.status === 'success', execution })
|
||||
return
|
||||
}
|
||||
if (action === 'executions' && req.method === 'GET') {
|
||||
const executions = await api.executions(taskId)
|
||||
sendJson(res, 200, { count: executions.length, executions })
|
||||
return
|
||||
}
|
||||
sendError(res, 405, '请求方法或定时日报操作不受支持')
|
||||
} catch (error) {
|
||||
sendScheduledError(res, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MEDIA_ROUTE_PREFIX = '/api/v1/media/'
|
||||
|
||||
function createMediaRoute(
|
||||
@@ -424,6 +572,7 @@ export function startHttpServer(
|
||||
): Promise<HttpServerHandle> {
|
||||
const tokenProvider = options.tokenProvider || (() => apiTokenStore.getTokenForAuthentication())
|
||||
const mediaProvider = options.mediaProvider || readImageMedia
|
||||
const scheduledReportApi = createScheduledReportApi(options)
|
||||
return new Promise((resolve, reject) => {
|
||||
const server: Server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
@@ -437,6 +586,7 @@ export function startHttpServer(
|
||||
}
|
||||
const handler =
|
||||
routes[url.pathname] ||
|
||||
createScheduledReportRoute(url.pathname, scheduledReportApi) ||
|
||||
(url.pathname.startsWith(MEDIA_ROUTE_PREFIX)
|
||||
? createMediaRoute(mediaProvider)
|
||||
: undefined)
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
import type { PersonalWechatSendCapability } from '../../shared/personal-wechat'
|
||||
import type {
|
||||
ScheduledReportApiCreateRequest,
|
||||
ScheduledReportApiExecution,
|
||||
ScheduledReportApiGroupRef,
|
||||
ScheduledReportApiTargetRef,
|
||||
ScheduledReportApiTask,
|
||||
ScheduledReportApiUpdateRequest
|
||||
} from '../../shared/scheduled-report-api'
|
||||
import type {
|
||||
ScheduledReportExecution,
|
||||
ScheduledReportRange,
|
||||
ScheduledReportTask,
|
||||
ScheduledReportUpdateInput
|
||||
} from '../../shared/scheduled-report'
|
||||
import type { FormattedContact } from './chat-service'
|
||||
import { resolveContact } from './contact-resolution-service'
|
||||
import type { ScheduledReportService } from './scheduled-report-service'
|
||||
|
||||
export interface ScheduledReportApiDependencies {
|
||||
service: Pick<
|
||||
ScheduledReportService,
|
||||
| 'listTasks'
|
||||
| 'listExecutions'
|
||||
| 'createTask'
|
||||
| 'updateTask'
|
||||
| 'deleteTask'
|
||||
| 'setTaskEnabled'
|
||||
| 'runScheduledReportNow'
|
||||
>
|
||||
getCapability: () => Promise<PersonalWechatSendCapability>
|
||||
listContacts: () => FormattedContact[]
|
||||
isDatabaseReady: () => boolean
|
||||
platform?: NodeJS.Platform
|
||||
}
|
||||
|
||||
export class ScheduledReportApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
readonly details?: unknown
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ScheduledReportApiError'
|
||||
}
|
||||
}
|
||||
|
||||
const REPORT_RANGES = new Set<ScheduledReportRange>(['today', 'yesterday', '7days', 'recent24h'])
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
|
||||
const asTrimmed = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
|
||||
|
||||
function publicCapability(capability: PersonalWechatSendCapability): Record<string, unknown> {
|
||||
return {
|
||||
supported: capability.supported,
|
||||
ready: capability.ready,
|
||||
status: capability.status,
|
||||
capabilities: capability.capabilities,
|
||||
message: capability.message,
|
||||
...(capability.error ? { error: capability.error } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export class ScheduledReportApiService {
|
||||
private mutationQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
constructor(private readonly deps: ScheduledReportApiDependencies) {}
|
||||
|
||||
async getCapability(): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
return publicCapability(this.applyPlatformCapability(await this.deps.getCapability()))
|
||||
} catch (error) {
|
||||
throw new ScheduledReportApiError(
|
||||
503,
|
||||
'wechat_capability_unavailable',
|
||||
'微信发送能力检测失败,请在 TraceMemo 设置中检查个人微信。',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async list(): Promise<ScheduledReportApiTask[]> {
|
||||
this.assertDatabaseReady()
|
||||
return (await this.deps.service.listTasks()).map((task) => this.toPublicTask(task))
|
||||
}
|
||||
|
||||
async get(taskId: string): Promise<ScheduledReportApiTask> {
|
||||
this.assertDatabaseReady()
|
||||
const task = await this.findTask(taskId)
|
||||
if (!task) throw new ScheduledReportApiError(404, 'not_found', '未找到定时日报任务')
|
||||
return this.toPublicTask(task)
|
||||
}
|
||||
|
||||
async create(request: ScheduledReportApiCreateRequest): Promise<ScheduledReportApiTask> {
|
||||
return this.withMutationLock(() => this.createUnlocked(request))
|
||||
}
|
||||
|
||||
private async createUnlocked(
|
||||
request: ScheduledReportApiCreateRequest
|
||||
): Promise<ScheduledReportApiTask> {
|
||||
this.assertDatabaseReady()
|
||||
await this.assertCapabilityReady()
|
||||
if (!isRecord(request))
|
||||
throw new ScheduledReportApiError(400, 'invalid_request', '请求体格式无效')
|
||||
|
||||
const groupRef = request.group
|
||||
if (!(typeof groupRef === 'string' || isRecord(groupRef))) {
|
||||
throw new ScheduledReportApiError(400, 'invalid_group', 'group 需要提供 name 或 talker')
|
||||
}
|
||||
const group = this.resolveGroup(groupRef as ScheduledReportApiGroupRef | string)
|
||||
const targetRef = request.target
|
||||
if (targetRef !== undefined && !isRecord(targetRef)) {
|
||||
throw new ScheduledReportApiError(400, 'invalid_target', 'target 格式无效')
|
||||
}
|
||||
const target = this.resolveTarget(targetRef as ScheduledReportApiTargetRef | undefined, group)
|
||||
const schedule = this.parseSchedule(request.schedule)
|
||||
const reportRange = this.parseRange(request.reportRange)
|
||||
const name = asTrimmed(request.name) || `${group.m_nsNickName} · 每日日报`
|
||||
const enabled = request.enabled !== false
|
||||
|
||||
const duplicate = await this.findDuplicate({
|
||||
group: group.md5,
|
||||
target: target.m_nsUsrName,
|
||||
scheduleTime: schedule.time,
|
||||
reportRange,
|
||||
enabled
|
||||
})
|
||||
if (duplicate) {
|
||||
throw new ScheduledReportApiError(409, 'duplicate', '相同的定时日报任务已经存在', {
|
||||
existingTaskId: duplicate.id,
|
||||
task: this.toPublicTask(duplicate)
|
||||
})
|
||||
}
|
||||
|
||||
const result = await this.deps.service.createTask({
|
||||
name,
|
||||
group: group.md5,
|
||||
target: target.m_nsUsrName,
|
||||
scheduleTime: schedule.time,
|
||||
reportRange,
|
||||
enabled
|
||||
})
|
||||
if (!result.success || !result.data) {
|
||||
throw new ScheduledReportApiError(400, 'invalid_request', result.error || '定时日报创建失败')
|
||||
}
|
||||
return this.toPublicTask(result.data)
|
||||
}
|
||||
|
||||
async update(
|
||||
taskId: string,
|
||||
request: ScheduledReportApiUpdateRequest
|
||||
): Promise<ScheduledReportApiTask> {
|
||||
return this.withMutationLock(() => this.updateUnlocked(taskId, request))
|
||||
}
|
||||
|
||||
private async updateUnlocked(
|
||||
taskId: string,
|
||||
request: ScheduledReportApiUpdateRequest
|
||||
): Promise<ScheduledReportApiTask> {
|
||||
this.assertDatabaseReady()
|
||||
const current = await this.findTask(taskId)
|
||||
if (!current) throw new ScheduledReportApiError(404, 'not_found', '未找到定时日报任务')
|
||||
if (!isRecord(request))
|
||||
throw new ScheduledReportApiError(400, 'invalid_request', '请求体格式无效')
|
||||
|
||||
const currentGroup = this.contactForTask(current)
|
||||
let group = currentGroup
|
||||
if (request.group !== undefined) {
|
||||
if (!(typeof request.group === 'string' || isRecord(request.group))) {
|
||||
throw new ScheduledReportApiError(400, 'invalid_group', 'group 需要提供 name 或 talker')
|
||||
}
|
||||
group = this.resolveGroup(request.group as ScheduledReportApiGroupRef | string)
|
||||
}
|
||||
if (request.target !== undefined && !isRecord(request.target)) {
|
||||
throw new ScheduledReportApiError(400, 'invalid_target', 'target 格式无效')
|
||||
}
|
||||
const target =
|
||||
request.target === undefined
|
||||
? group
|
||||
: this.resolveTarget(request.target as unknown as ScheduledReportApiTargetRef, group)
|
||||
const schedule =
|
||||
request.schedule === undefined
|
||||
? { type: 'daily' as const, time: current.scheduleTime }
|
||||
: this.parseSchedule(request.schedule)
|
||||
const reportRange =
|
||||
request.reportRange === undefined ? current.reportRange : this.parseRange(request.reportRange)
|
||||
const enabled = request.enabled === undefined ? current.enabled : Boolean(request.enabled)
|
||||
const name = request.name === undefined ? current.name : asTrimmed(request.name)
|
||||
if (!name) throw new ScheduledReportApiError(400, 'invalid_request', '日报名称不能为空')
|
||||
|
||||
const duplicate = await this.findDuplicate(
|
||||
{
|
||||
group: group.md5,
|
||||
target: target.m_nsUsrName,
|
||||
scheduleTime: schedule.time,
|
||||
reportRange,
|
||||
enabled
|
||||
},
|
||||
taskId
|
||||
)
|
||||
if (duplicate) {
|
||||
throw new ScheduledReportApiError(409, 'duplicate', '相同的定时日报任务已经存在', {
|
||||
existingTaskId: duplicate.id,
|
||||
task: this.toPublicTask(duplicate)
|
||||
})
|
||||
}
|
||||
|
||||
const patch: ScheduledReportUpdateInput = {
|
||||
name,
|
||||
group: group.md5,
|
||||
target: target.m_nsUsrName,
|
||||
scheduleTime: schedule.time,
|
||||
reportRange,
|
||||
enabled
|
||||
}
|
||||
const result = await this.deps.service.updateTask(taskId, patch)
|
||||
if (!result.success || !result.data) {
|
||||
throw new ScheduledReportApiError(400, 'invalid_request', result.error || '定时日报更新失败')
|
||||
}
|
||||
return this.toPublicTask(result.data)
|
||||
}
|
||||
|
||||
async delete(taskId: string): Promise<{ deletedId: string }> {
|
||||
this.assertDatabaseReady()
|
||||
const result = await this.deps.service.deleteTask(taskId)
|
||||
if (!result.success || !result.data) {
|
||||
throw new ScheduledReportApiError(404, 'not_found', result.error || '未找到定时日报任务')
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
async setEnabled(taskId: string, enabled: boolean): Promise<ScheduledReportApiTask> {
|
||||
this.assertDatabaseReady()
|
||||
const result = await this.deps.service.setTaskEnabled(taskId, enabled)
|
||||
if (!result.success || !result.data) {
|
||||
throw new ScheduledReportApiError(404, 'not_found', result.error || '未找到定时日报任务')
|
||||
}
|
||||
return this.toPublicTask(result.data)
|
||||
}
|
||||
|
||||
async run(taskId: string): Promise<ScheduledReportExecution> {
|
||||
this.assertDatabaseReady()
|
||||
const result = await this.deps.service.runScheduledReportNow(taskId)
|
||||
if (!result.data) {
|
||||
throw new ScheduledReportApiError(404, 'not_found', result.error || '未找到定时日报任务')
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
async executions(taskId: string): Promise<ScheduledReportApiExecution[]> {
|
||||
this.assertDatabaseReady()
|
||||
const task = await this.findTask(taskId)
|
||||
if (!task) throw new ScheduledReportApiError(404, 'not_found', '未找到定时日报任务')
|
||||
return (await this.deps.service.listExecutions(taskId)).map((execution) => ({ ...execution }))
|
||||
}
|
||||
|
||||
private assertDatabaseReady(): void {
|
||||
if (!this.deps.isDatabaseReady()) {
|
||||
throw new ScheduledReportApiError(503, 'database_not_ready', 'TraceMemo 数据库未初始化')
|
||||
}
|
||||
}
|
||||
|
||||
private async withMutationLock<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const previous = this.mutationQueue
|
||||
let release!: () => void
|
||||
this.mutationQueue = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
await previous
|
||||
try {
|
||||
return await operation()
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
capability: PersonalWechatSendCapability
|
||||
): PersonalWechatSendCapability {
|
||||
if ((this.deps.platform || process.platform) === 'darwin') return capability
|
||||
return {
|
||||
...capability,
|
||||
supported: false,
|
||||
ready: false,
|
||||
status: 'unsupported',
|
||||
capabilities: { text: false, image: false, voice: false },
|
||||
message: '微信消息发送目前仅支持 macOS'
|
||||
}
|
||||
}
|
||||
|
||||
private async findTask(taskId: string): Promise<ScheduledReportTask | undefined> {
|
||||
const normalized = asTrimmed(taskId)
|
||||
if (!normalized) return undefined
|
||||
return (await this.deps.service.listTasks()).find((task) => task.id === normalized)
|
||||
}
|
||||
|
||||
private resolveGroup(ref: ScheduledReportApiGroupRef | string): FormattedContact {
|
||||
const contacts = this.deps.listContacts().filter((contact) => contact.type === 'group')
|
||||
const query = typeof ref === 'string' ? ref : isRecord(ref) ? asTrimmed(ref.name) : ''
|
||||
const talker = typeof ref === 'string' ? '' : isRecord(ref) ? asTrimmed(ref.talker) : ''
|
||||
if (!query && !talker) {
|
||||
throw new ScheduledReportApiError(400, 'invalid_group', 'group 需要提供 name 或 talker')
|
||||
}
|
||||
if (talker) {
|
||||
const direct = contacts.find(
|
||||
(contact) => contact.m_nsUsrName === talker || contact.md5 === talker
|
||||
)
|
||||
if (direct) return direct
|
||||
throw new ScheduledReportApiError(404, 'group_not_found', '未找到 talker 对应的唯一群聊')
|
||||
}
|
||||
const resolution = resolveContact(query || talker, contacts, 'group')
|
||||
if (resolution.ambiguous) {
|
||||
throw new ScheduledReportApiError(
|
||||
409,
|
||||
'group_ambiguous',
|
||||
'群聊名称匹配到多个群,请先确认目标群聊',
|
||||
{
|
||||
candidates: resolution.candidates
|
||||
}
|
||||
)
|
||||
}
|
||||
if (!resolution.matched || !resolution.conversationId) {
|
||||
throw new ScheduledReportApiError(404, 'group_not_found', '未找到唯一的目标群聊')
|
||||
}
|
||||
const contact = contacts.find((item) => item.md5 === resolution.conversationId)
|
||||
if (!contact) throw new ScheduledReportApiError(404, 'group_not_found', '未找到唯一的目标群聊')
|
||||
return contact
|
||||
}
|
||||
|
||||
private resolveTarget(
|
||||
ref: ScheduledReportApiTargetRef | undefined,
|
||||
group: FormattedContact
|
||||
): FormattedContact {
|
||||
if (!ref) return group
|
||||
if (!isRecord(ref) || ref.type !== 'wechat_group') {
|
||||
throw new ScheduledReportApiError(400, 'invalid_target', 'target.type 必须是 wechat_group')
|
||||
}
|
||||
const talker = asTrimmed(ref.talker)
|
||||
const name = asTrimmed(ref.name)
|
||||
const target = this.resolveGroup({ talker, name })
|
||||
if (target.md5 !== group.md5) {
|
||||
throw new ScheduledReportApiError(400, 'target_mismatch', '发送目标必须是选择的微信群')
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
private parseSchedule(value: unknown): { type: 'daily'; time: string } {
|
||||
if (!isRecord(value) || value.type !== 'daily' || !asTrimmed(value.time)) {
|
||||
throw new ScheduledReportApiError(
|
||||
400,
|
||||
'invalid_schedule',
|
||||
'schedule 目前只支持 { type: "daily", time: "HH:mm" }'
|
||||
)
|
||||
}
|
||||
const time = asTrimmed(value.time)
|
||||
if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(time)) {
|
||||
throw new ScheduledReportApiError(400, 'invalid_schedule', 'schedule.time 必须是 HH:mm')
|
||||
}
|
||||
return { type: 'daily', time }
|
||||
}
|
||||
|
||||
private parseRange(value: unknown): ScheduledReportRange {
|
||||
const range = value === undefined ? 'yesterday' : value
|
||||
if (typeof range !== 'string' || !REPORT_RANGES.has(range as ScheduledReportRange)) {
|
||||
throw new ScheduledReportApiError(400, 'invalid_report_range', 'reportRange 不受支持')
|
||||
}
|
||||
return range as ScheduledReportRange
|
||||
}
|
||||
|
||||
private contactForTask(task: ScheduledReportTask): FormattedContact {
|
||||
const contacts = this.deps.listContacts().filter((contact) => contact.type === 'group')
|
||||
const contact = contacts.find(
|
||||
(item) =>
|
||||
item.md5 === task.group ||
|
||||
item.m_nsUsrName === task.target ||
|
||||
item.m_nsUsrName === task.group
|
||||
)
|
||||
if (contact) return contact
|
||||
return {
|
||||
md5: task.group,
|
||||
m_nsUsrName: task.target,
|
||||
m_nsNickName: task.group,
|
||||
type: 'group'
|
||||
}
|
||||
}
|
||||
|
||||
private async findDuplicate(
|
||||
candidate: {
|
||||
group: string
|
||||
target: string
|
||||
scheduleTime: string
|
||||
reportRange: ScheduledReportRange
|
||||
enabled: boolean
|
||||
},
|
||||
excludeTaskId?: string
|
||||
): Promise<ScheduledReportTask | undefined> {
|
||||
const tasks = await this.deps.service.listTasks()
|
||||
return tasks.find(
|
||||
(task) =>
|
||||
task.id !== excludeTaskId &&
|
||||
task.group === candidate.group &&
|
||||
task.target === candidate.target &&
|
||||
task.scheduleTime === candidate.scheduleTime &&
|
||||
task.reportRange === candidate.reportRange &&
|
||||
task.enabled === candidate.enabled
|
||||
)
|
||||
}
|
||||
|
||||
private toPublicTask(task: ScheduledReportTask): ScheduledReportApiTask {
|
||||
const group = this.contactForTask(task)
|
||||
const talker = task.target || group.m_nsUsrName
|
||||
const name = group.m_nsNickName || task.group
|
||||
return {
|
||||
id: task.id,
|
||||
name: task.name,
|
||||
group: { talker, name },
|
||||
schedule: { type: 'daily', time: task.scheduleTime },
|
||||
reportRange: task.reportRange,
|
||||
target: { type: 'wechat_group', talker, name },
|
||||
enabled: task.enabled,
|
||||
createdAt: task.createdAt,
|
||||
updatedAt: task.updatedAt,
|
||||
...(task.lastRunAt ? { lastRunAt: task.lastRunAt } : {}),
|
||||
nextRunAt: task.nextRunAt
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,7 @@
|
||||
|
||||
.report-scheduled-empty {
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
min-height: 260px;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
@@ -145,7 +146,24 @@
|
||||
}
|
||||
|
||||
.report-scheduled-executions {
|
||||
display: flex;
|
||||
max-height: clamp(160px, 30vh, 320px);
|
||||
min-height: 0;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
margin-top: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.report-scheduled-executions > .grid {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.report-scheduled-page > .overflow-x-auto {
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
@@ -158,6 +176,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 720px) {
|
||||
.report-scheduled-page {
|
||||
gap: 12px;
|
||||
padding-top: 16px;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.report-scheduled-executions {
|
||||
max-height: clamp(140px, 26vh, 220px);
|
||||
}
|
||||
}
|
||||
|
||||
.report-center-page {
|
||||
display: grid;
|
||||
grid-template-columns: 292px minmax(420px, 1fr) 304px;
|
||||
|
||||
@@ -11,6 +11,16 @@ export const LOCAL_API_ENDPOINTS = {
|
||||
},
|
||||
'group-snapshot': { method: 'GET', path: '/api/v1/group_snapshot', queryKeys: ['md5'] },
|
||||
resolve: { method: 'GET', path: '/api/v1/resolve', queryKeys: ['q'] },
|
||||
'wechat-send-capability': {
|
||||
method: 'GET',
|
||||
path: '/api/v1/wechat-personal/send-capability',
|
||||
queryKeys: []
|
||||
},
|
||||
'scheduled-reports': {
|
||||
method: 'GET',
|
||||
path: '/api/v1/scheduled-reports',
|
||||
queryKeys: []
|
||||
},
|
||||
report: { method: 'POST', path: '/api/v1/report', queryKeys: [] },
|
||||
'agent-status': { method: 'GET', path: '/api/v1/agent/status', queryKeys: [] },
|
||||
'agent-group-report': { method: 'POST', path: '/api/v1/agent/group-report', queryKeys: [] },
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { ScheduledReportRange, ScheduledReportExecutionStatus } from './scheduled-report'
|
||||
|
||||
export type ScheduledReportApiSchedule = {
|
||||
type: 'daily'
|
||||
time: string
|
||||
}
|
||||
|
||||
export interface ScheduledReportApiGroupRef {
|
||||
talker?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface ScheduledReportApiTargetRef {
|
||||
type: 'wechat_group'
|
||||
talker?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface ScheduledReportApiTask {
|
||||
id: string
|
||||
name: string
|
||||
group: {
|
||||
talker: string
|
||||
name: string
|
||||
}
|
||||
schedule: ScheduledReportApiSchedule
|
||||
reportRange: ScheduledReportRange
|
||||
target: {
|
||||
type: 'wechat_group'
|
||||
talker: string
|
||||
name: string
|
||||
}
|
||||
enabled: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
lastRunAt?: string
|
||||
nextRunAt: string
|
||||
}
|
||||
|
||||
export interface ScheduledReportApiCreateRequest {
|
||||
name?: string
|
||||
group: ScheduledReportApiGroupRef | string
|
||||
schedule: ScheduledReportApiSchedule
|
||||
reportRange?: ScheduledReportRange
|
||||
target?: ScheduledReportApiTargetRef
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export interface ScheduledReportApiUpdateRequest {
|
||||
name?: string
|
||||
group?: ScheduledReportApiGroupRef | string
|
||||
schedule?: ScheduledReportApiSchedule
|
||||
reportRange?: ScheduledReportRange
|
||||
target?: ScheduledReportApiTargetRef
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export interface ScheduledReportApiExecution {
|
||||
id: string
|
||||
taskId: string
|
||||
startedAt: string
|
||||
finishedAt?: string
|
||||
status: ScheduledReportExecutionStatus
|
||||
error?: string
|
||||
message?: string
|
||||
scheduledSlot?: string
|
||||
}
|
||||
@@ -264,6 +264,8 @@ describe('Local API authentication', () => {
|
||||
})
|
||||
expect(response.status).toBe(204)
|
||||
expect(response.headers.get('access-control-allow-origin')).toBe(origin)
|
||||
expect(response.headers.get('access-control-allow-methods')).toContain('PATCH')
|
||||
expect(response.headers.get('access-control-allow-methods')).toContain('DELETE')
|
||||
expect(response.headers.get('access-control-allow-headers')).toBe('Content-Type, Authorization')
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const fixture = vi.hoisted(() => ({
|
||||
capability: {
|
||||
supported: true,
|
||||
ready: true,
|
||||
status: 'ready' as const,
|
||||
capabilities: { text: true, image: true, voice: true },
|
||||
senderStatus: {} as never,
|
||||
message: '个人微信已准备好发送日报'
|
||||
},
|
||||
contacts: [
|
||||
{
|
||||
md5: 'tech-md5',
|
||||
m_nsUsrName: 'tech@chatroom',
|
||||
m_nsNickName: '技术交流群',
|
||||
type: 'group' as const
|
||||
},
|
||||
{
|
||||
md5: 'product-md5',
|
||||
m_nsUsrName: 'product@chatroom',
|
||||
m_nsNickName: '产品交流群',
|
||||
type: 'group' as const
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => '/tmp/tracememo-scheduled-report-api' },
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (value: string) => Buffer.from(value),
|
||||
decryptString: (value: Buffer) => value.toString()
|
||||
}
|
||||
}))
|
||||
vi.mock('../../src/main/services/chat-service', () => ({
|
||||
isReady: () => true,
|
||||
listContacts: () => fixture.contacts,
|
||||
listMessages: () => [],
|
||||
getGroupSnapshot: () => null,
|
||||
listRecentChat: () => [],
|
||||
resolveMd5: () => null
|
||||
}))
|
||||
vi.mock('../../src/main/services/personal-wechat-capability-service', () => ({
|
||||
personalWechatCapabilityService: {
|
||||
getPersonalWechatSendCapability: vi.fn(async () => fixture.capability)
|
||||
}
|
||||
}))
|
||||
vi.mock('../../src/main/services/scheduled-report-service', () => ({ scheduledReportService: {} }))
|
||||
vi.mock('../../src/main/services/agent-group-report-service', () => ({
|
||||
generateAgentGroupReport: vi.fn(async () => ({ success: true }))
|
||||
}))
|
||||
vi.mock('../../src/main/group-report-service', () => ({
|
||||
exportGroupReport: vi.fn(async () => ({ success: true }))
|
||||
}))
|
||||
vi.mock('../../src/main/services/agent-hub-service', () => ({
|
||||
agentHubService: { getStatus: () => ({}), testSend: vi.fn(async () => ({ success: true })) }
|
||||
}))
|
||||
|
||||
import { startHttpServer, type HttpServerHandle } from '../../src/main/http-server'
|
||||
import type { ScheduledReportTask } from '../../src/shared/scheduled-report'
|
||||
import type { ScheduledReportApiDependencies } from '../../src/main/services/scheduled-report-api-service'
|
||||
|
||||
const TOKEN = 'A'.repeat(43)
|
||||
const handles: HttpServerHandle[] = []
|
||||
const originalContacts = fixture.contacts
|
||||
|
||||
const makeTask = (overrides: Partial<ScheduledReportTask> = {}): ScheduledReportTask => ({
|
||||
id: 'task-1',
|
||||
name: '技术交流群 · 每日日报',
|
||||
group: 'tech-md5',
|
||||
target: 'tech@chatroom',
|
||||
scheduleTime: '09:00',
|
||||
reportRange: 'yesterday',
|
||||
enabled: true,
|
||||
createdAt: '2026-08-27T00:00:00.000Z',
|
||||
updatedAt: '2026-08-27T00:00:00.000Z',
|
||||
nextRunAt: '2026-08-28T01:00:00.000Z',
|
||||
...overrides
|
||||
})
|
||||
|
||||
function baseUrl(handle: HttpServerHandle): string {
|
||||
return `http://${handle.host}:${handle.port}`
|
||||
}
|
||||
|
||||
async function startFixture(
|
||||
options: { tasks?: ScheduledReportTask[] } = {}
|
||||
): Promise<{ handle: HttpServerHandle; service: ScheduledReportApiDependencies['service'] }> {
|
||||
const tasks = options.tasks || []
|
||||
const executions: Array<Record<string, unknown>> = []
|
||||
const service = {
|
||||
listTasks: vi.fn(async () => tasks),
|
||||
listExecutions: vi.fn(async (taskId?: string) =>
|
||||
taskId ? executions.filter((item) => item.taskId === taskId) : executions
|
||||
),
|
||||
createTask: vi.fn(async (input: Record<string, unknown>) => {
|
||||
const created = makeTask({
|
||||
id: `task-${tasks.length + 1}`,
|
||||
name: String(input.name),
|
||||
group: String(input.group),
|
||||
target: String(input.target),
|
||||
scheduleTime: String(input.scheduleTime),
|
||||
reportRange: input.reportRange as ScheduledReportTask['reportRange'],
|
||||
enabled: Boolean(input.enabled)
|
||||
})
|
||||
tasks.push(created)
|
||||
return { success: true, data: created }
|
||||
}),
|
||||
updateTask: vi.fn(async (id: string, input: Record<string, unknown>) => {
|
||||
const current = tasks.find((item) => item.id === id)
|
||||
if (!current) return { success: false, error: '未找到定时日报任务' }
|
||||
const updated = { ...current, ...input, updatedAt: '2026-08-27T02:00:00.000Z' }
|
||||
tasks[tasks.indexOf(current)] = updated
|
||||
return { success: true, data: updated }
|
||||
}),
|
||||
deleteTask: vi.fn(async (id: string) => {
|
||||
const index = tasks.findIndex((item) => item.id === id)
|
||||
if (index < 0) return { success: false, error: '未找到定时日报任务' }
|
||||
tasks.splice(index, 1)
|
||||
return { success: true, data: { deletedId: id } }
|
||||
}),
|
||||
setTaskEnabled: vi.fn(async (id: string, enabled: boolean) => {
|
||||
const current = tasks.find((item) => item.id === id)
|
||||
if (!current) return { success: false, error: '未找到定时日报任务' }
|
||||
const updated = { ...current, enabled }
|
||||
tasks[tasks.indexOf(current)] = updated
|
||||
return { success: true, data: updated }
|
||||
}),
|
||||
runScheduledReportNow: vi.fn(async (id: string) => {
|
||||
const execution = {
|
||||
id: `execution-${executions.length + 1}`,
|
||||
taskId: id,
|
||||
startedAt: '2026-08-27T01:00:00.000Z',
|
||||
finishedAt: '2026-08-27T01:01:00.000Z',
|
||||
status: 'success' as const,
|
||||
message: '日报生成成功,微信发送成功'
|
||||
}
|
||||
executions.push(execution)
|
||||
return { success: true, data: execution }
|
||||
})
|
||||
}
|
||||
const handle = await startHttpServer('127.0.0.1', 0, {
|
||||
tokenProvider: () => TOKEN,
|
||||
scheduledReportService: service,
|
||||
scheduledReportCapabilityProvider: async () => fixture.capability,
|
||||
scheduledReportContactsProvider: () => fixture.contacts,
|
||||
scheduledReportDatabaseReadyProvider: () => true,
|
||||
scheduledReportPlatform: 'darwin'
|
||||
})
|
||||
handles.push(handle)
|
||||
return { handle, service }
|
||||
}
|
||||
|
||||
const auth = { Authorization: `Bearer ${TOKEN}` }
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(handles.splice(0).map((handle) => handle.close()))
|
||||
fixture.contacts = originalContacts
|
||||
fixture.capability = {
|
||||
...fixture.capability,
|
||||
ready: true,
|
||||
status: 'ready',
|
||||
message: '个人微信已准备好发送日报'
|
||||
}
|
||||
})
|
||||
|
||||
describe('scheduled report Local HTTP API', () => {
|
||||
it('requires authentication and exposes capability plus CRUD lifecycle', async () => {
|
||||
const unauthenticated = await startFixture()
|
||||
expect(
|
||||
(await fetch(`${baseUrl(unauthenticated.handle)}/api/v1/scheduled-reports`)).status
|
||||
).toBe(401)
|
||||
|
||||
const { handle, service } = await startFixture()
|
||||
const create = await fetch(`${baseUrl(handle)}/api/v1/scheduled-reports`, {
|
||||
method: 'POST',
|
||||
headers: { ...auth, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
group: { name: '技术交流群' },
|
||||
schedule: { type: 'daily', time: '09:00' },
|
||||
reportRange: 'yesterday'
|
||||
})
|
||||
})
|
||||
expect(create.status).toBe(201)
|
||||
const created = await create.json()
|
||||
expect(created).toMatchObject({ created: true, task: { group: { talker: 'tech@chatroom' } } })
|
||||
const taskId = created.task.id
|
||||
|
||||
const list = await fetch(`${baseUrl(handle)}/api/v1/scheduled-reports`, { headers: auth })
|
||||
expect(await list.json()).toMatchObject({ count: 1, tasks: [{ id: taskId }] })
|
||||
const get = await fetch(`${baseUrl(handle)}/api/v1/scheduled-reports/${taskId}`, {
|
||||
headers: auth
|
||||
})
|
||||
expect((await get.json()).task.id).toBe(taskId)
|
||||
|
||||
const update = await fetch(`${baseUrl(handle)}/api/v1/scheduled-reports/${taskId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { ...auth, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ schedule: { type: 'daily', time: '10:00' } })
|
||||
})
|
||||
expect((await update.json()).task.schedule.time).toBe('10:00')
|
||||
await fetch(`${baseUrl(handle)}/api/v1/scheduled-reports/${taskId}/disable`, {
|
||||
method: 'POST',
|
||||
headers: auth
|
||||
})
|
||||
await fetch(`${baseUrl(handle)}/api/v1/scheduled-reports/${taskId}/enable`, {
|
||||
method: 'POST',
|
||||
headers: auth
|
||||
})
|
||||
const run = await fetch(`${baseUrl(handle)}/api/v1/scheduled-reports/${taskId}/run`, {
|
||||
method: 'POST',
|
||||
headers: auth
|
||||
})
|
||||
expect(await run.json()).toMatchObject({ success: true, execution: { status: 'success' } })
|
||||
const executions = await fetch(
|
||||
`${baseUrl(handle)}/api/v1/scheduled-reports/${taskId}/executions`,
|
||||
{ headers: auth }
|
||||
)
|
||||
expect((await executions.json()).executions).toHaveLength(1)
|
||||
const capability = await fetch(`${baseUrl(handle)}/api/v1/wechat-personal/send-capability`, {
|
||||
headers: auth
|
||||
})
|
||||
expect(await capability.json()).toMatchObject({ capability: { status: 'ready' } })
|
||||
expect(service.createTask).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects unsupported capability, ambiguous groups, and duplicate tasks', async () => {
|
||||
fixture.capability = {
|
||||
...fixture.capability,
|
||||
ready: false,
|
||||
status: 'needs_binding',
|
||||
message: '请先绑定个人微信'
|
||||
}
|
||||
const first = await startFixture()
|
||||
const blocked = await fetch(`${baseUrl(first.handle)}/api/v1/scheduled-reports`, {
|
||||
method: 'POST',
|
||||
headers: { ...auth, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ group: '技术交流群', schedule: { type: 'daily', time: '09:00' } })
|
||||
})
|
||||
expect(blocked.status).toBe(409)
|
||||
expect(await blocked.json()).toMatchObject({ error: 'wechat_not_ready' })
|
||||
|
||||
fixture.capability = { ...fixture.capability, ready: true, status: 'ready' }
|
||||
fixture.contacts = fixture.contacts.map((contact) => ({ ...contact, m_nsNickName: '重复群' }))
|
||||
const ambiguous = await startFixture()
|
||||
const ambiguousResponse = await fetch(`${baseUrl(ambiguous.handle)}/api/v1/scheduled-reports`, {
|
||||
method: 'POST',
|
||||
headers: { ...auth, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ group: '重复群', schedule: { type: 'daily', time: '09:00' } })
|
||||
})
|
||||
expect(ambiguousResponse.status).toBe(409)
|
||||
expect(await ambiguousResponse.json()).toMatchObject({ error: 'group_ambiguous' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,204 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat'
|
||||
import type { ScheduledReportTask } from '../../src/shared/scheduled-report'
|
||||
import {
|
||||
ScheduledReportApiService,
|
||||
type ScheduledReportApiDependencies
|
||||
} from '../../src/main/services/scheduled-report-api-service'
|
||||
|
||||
type ApiTestService = ScheduledReportApiDependencies['service'] & {
|
||||
createTask: ReturnType<typeof vi.fn>
|
||||
updateTask: ReturnType<typeof vi.fn>
|
||||
}
|
||||
type ApiTestResult = {
|
||||
api: ScheduledReportApiService
|
||||
service: ApiTestService
|
||||
}
|
||||
|
||||
const contacts = [
|
||||
{
|
||||
md5: 'tech-md5',
|
||||
m_nsUsrName: 'tech@chatroom',
|
||||
m_nsNickName: '技术交流群',
|
||||
type: 'group' as const
|
||||
},
|
||||
{
|
||||
md5: 'product-md5',
|
||||
m_nsUsrName: 'product@chatroom',
|
||||
m_nsNickName: '产品交流群',
|
||||
type: 'group' as const
|
||||
}
|
||||
]
|
||||
|
||||
const task = (overrides: Partial<ScheduledReportTask> = {}): ScheduledReportTask => ({
|
||||
id: 'task-1',
|
||||
name: '技术交流群 · 每日日报',
|
||||
group: 'tech-md5',
|
||||
target: 'tech@chatroom',
|
||||
scheduleTime: '09:00',
|
||||
reportRange: 'yesterday',
|
||||
enabled: true,
|
||||
createdAt: '2026-08-27T00:00:00.000Z',
|
||||
updatedAt: '2026-08-27T00:00:00.000Z',
|
||||
nextRunAt: '2026-08-28T01:00:00.000Z',
|
||||
...overrides
|
||||
})
|
||||
|
||||
const capability = (
|
||||
status: PersonalWechatSendCapability['status']
|
||||
): PersonalWechatSendCapability => ({
|
||||
supported: status !== 'unsupported',
|
||||
ready: status === 'ready',
|
||||
status,
|
||||
capabilities: { text: status === 'ready', image: status === 'ready', voice: status === 'ready' },
|
||||
senderStatus: {} as PersonalWechatSendCapability['senderStatus'],
|
||||
message: status === 'ready' ? 'ready' : '请先配置微信'
|
||||
})
|
||||
|
||||
function makeApi(
|
||||
options: { capability?: PersonalWechatSendCapability; tasks?: ScheduledReportTask[] } = {}
|
||||
): ApiTestResult {
|
||||
const tasks = options.tasks || []
|
||||
const service = {
|
||||
listTasks: vi.fn(async () => tasks),
|
||||
listExecutions: vi.fn(async () => []),
|
||||
createTask: vi.fn(async (input: Record<string, unknown>) => {
|
||||
const created = task({
|
||||
id: `task-${tasks.length + 1}`,
|
||||
name: String(input.name),
|
||||
group: String(input.group),
|
||||
target: String(input.target),
|
||||
scheduleTime: String(input.scheduleTime),
|
||||
reportRange: input.reportRange as ScheduledReportTask['reportRange'],
|
||||
enabled: Boolean(input.enabled)
|
||||
})
|
||||
tasks.push(created)
|
||||
return { success: true, data: created }
|
||||
}),
|
||||
updateTask: vi.fn(async () => ({ success: true, data: tasks[0] })),
|
||||
deleteTask: vi.fn(async (id: string) => ({ success: true, data: { deletedId: id } })),
|
||||
setTaskEnabled: vi.fn(async (id: string, enabled: boolean) => ({
|
||||
success: true,
|
||||
data: task({ id, enabled })
|
||||
})),
|
||||
runScheduledReportNow: vi.fn(async (id: string) => ({
|
||||
success: true,
|
||||
data: {
|
||||
id: 'execution-1',
|
||||
taskId: id,
|
||||
startedAt: '2026-08-27T01:00:00.000Z',
|
||||
status: 'success' as const
|
||||
}
|
||||
}))
|
||||
}
|
||||
return {
|
||||
api: new ScheduledReportApiService({
|
||||
service,
|
||||
getCapability: async () => options.capability || capability('ready'),
|
||||
listContacts: () => contacts,
|
||||
isDatabaseReady: () => true,
|
||||
platform: 'darwin'
|
||||
}),
|
||||
service
|
||||
}
|
||||
}
|
||||
|
||||
describe('ScheduledReportApiService', () => {
|
||||
it('resolves a unique group and maps the public create request to the existing service', async () => {
|
||||
const { api, service } = makeApi()
|
||||
const result = await api.create({
|
||||
group: { name: '技术交流群' },
|
||||
schedule: { type: 'daily', time: '09:00' },
|
||||
reportRange: 'yesterday'
|
||||
})
|
||||
expect(result).toMatchObject({
|
||||
name: '技术交流群 · 每日日报',
|
||||
group: { talker: 'tech@chatroom', name: '技术交流群' },
|
||||
schedule: { type: 'daily', time: '09:00' },
|
||||
target: { type: 'wechat_group', talker: 'tech@chatroom' }
|
||||
})
|
||||
expect(service.createTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ group: 'tech-md5', target: 'tech@chatroom' })
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['unsupported', 'unconfigured', 'needs_binding', 'needs_verification', 'error'] as const)(
|
||||
'rejects creation before touching the task service for capability state %s',
|
||||
async (status) => {
|
||||
const { api, service } = makeApi({ capability: capability(status) })
|
||||
await expect(
|
||||
api.create({
|
||||
group: '技术交流群',
|
||||
schedule: { type: 'daily', time: '09:00' }
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'wechat_not_ready', status: 409 })
|
||||
expect(service.createTask).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('enforces the macOS platform gate even if a provider reports ready', async () => {
|
||||
const { service } = makeApi()
|
||||
const api = new ScheduledReportApiService({
|
||||
service,
|
||||
getCapability: async () => capability('ready'),
|
||||
listContacts: () => contacts,
|
||||
isDatabaseReady: () => true,
|
||||
platform: 'win32'
|
||||
})
|
||||
await expect(
|
||||
api.create({ group: '技术交流群', schedule: { type: 'daily', time: '09:00' } })
|
||||
).rejects.toMatchObject({ code: 'wechat_not_ready', status: 409 })
|
||||
expect(service.createTask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns candidates for ambiguous group names and rejects duplicate tasks', async () => {
|
||||
const ambiguousContacts = contacts.map((contact) => ({ ...contact, m_nsNickName: '重复群' }))
|
||||
const { service: ambiguousService } = makeApi()
|
||||
const ambiguousApi = new ScheduledReportApiService({
|
||||
service: ambiguousService,
|
||||
getCapability: async () => capability('ready'),
|
||||
listContacts: () => ambiguousContacts,
|
||||
isDatabaseReady: () => true,
|
||||
platform: 'darwin'
|
||||
})
|
||||
await expect(
|
||||
ambiguousApi.create({ group: '重复群', schedule: { type: 'daily', time: '09:00' } })
|
||||
).rejects.toMatchObject({ code: 'group_ambiguous', status: 409 })
|
||||
|
||||
const duplicateApi = makeApi({ tasks: [task()] }).api
|
||||
await expect(
|
||||
duplicateApi.create({ group: '技术交流群', schedule: { type: 'daily', time: '09:00' } })
|
||||
).rejects.toMatchObject({ code: 'duplicate', status: 409 })
|
||||
})
|
||||
|
||||
it('updates a changed group target together when target is omitted', async () => {
|
||||
const existing = task()
|
||||
const { api, service } = makeApi({ tasks: [existing] })
|
||||
vi.mocked(service.updateTask).mockResolvedValue({
|
||||
success: true,
|
||||
data: task({ group: 'product-md5', target: 'product@chatroom', name: '产品日报' })
|
||||
})
|
||||
const result = await api.update('task-1', {
|
||||
group: { name: '产品交流群' },
|
||||
name: '产品日报'
|
||||
})
|
||||
expect(result.group).toMatchObject({ talker: 'product@chatroom', name: '产品交流群' })
|
||||
expect(service.updateTask).toHaveBeenCalledWith(
|
||||
'task-1',
|
||||
expect.objectContaining({ group: 'product-md5', target: 'product@chatroom' })
|
||||
)
|
||||
})
|
||||
|
||||
it('serializes concurrent creates so only one identical task is created', async () => {
|
||||
const { api, service } = makeApi()
|
||||
const request = {
|
||||
group: '技术交流群',
|
||||
schedule: { type: 'daily' as const, time: '09:00' },
|
||||
reportRange: 'yesterday' as const
|
||||
}
|
||||
const results = await Promise.allSettled([api.create(request), api.create(request)])
|
||||
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1)
|
||||
expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1)
|
||||
expect(service.createTask).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user