Files
WechatExplorer/src/main/services/scheduled-report-api-service.ts
T

452 lines
16 KiB
TypeScript

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
}
}
}