/** * ScheduleService — 公司级工作日历系统 * * 数据目录: data/{companyId}/schedule.json * * 核心概念: * Schedule = 公司的每日时间表 * TimeSlot = 某个Agent在某个时间点执行的固定任务 * * 与 HeartbeatService 集成: * 心跳触发时,ScheduleService 检查当前时间是否有需要执行的日程 * 匹配到的日程自动触发对应 Agent 的 Chat */ const fs = require('fs'); const path = require('path'); const DATA_DIR = path.resolve(__dirname, '../../data'); class ScheduleService { /** * 获取公司的日程配置 * @param {string} companyId * @returns {object} schedule config */ getSchedule(companyId) { const fp = path.join(DATA_DIR, companyId, 'schedule.json'); if (!fs.existsSync(fp)) return { timezone: 'Asia/Shanghai', slots: [] }; try { return JSON.parse(fs.readFileSync(fp, 'utf-8')); } catch { return { timezone: 'Asia/Shanghai', slots: [] }; } } /** * 保存公司日程配置 * @param {string} companyId * @param {object} schedule */ saveSchedule(companyId, schedule) { const fp = path.join(DATA_DIR, companyId, 'schedule.json'); fs.writeFileSync(fp, JSON.stringify(schedule, null, 2), 'utf-8'); } /** * 添加日程时间槽 * @param {string} companyId * @param {object} slot - { time, agent, task, days?, enabled? } * time: "09:00" (24小时制) * agent: "trend-hunter" (Agent ID) * task: "执行每日热点抓取" (任务描述) * days: ["mon","tue","wed","thu","fri","sat","sun"] (可选,默认每天) * enabled: true (可选) */ addSlot(companyId, slot) { const schedule = this.getSchedule(companyId); const newSlot = { id: `S-${Date.now().toString(36)}`, time: slot.time, agent: slot.agent, task: slot.task, days: slot.days || ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'], enabled: slot.enabled !== false, type: slot.type || 'task', // task | meeting | review createdAt: new Date().toISOString(), }; schedule.slots.push(newSlot); this.saveSchedule(companyId, schedule); console.log(`[Schedule] 📅 ${companyId} 新增日程: ${newSlot.time} ${newSlot.agent} - ${newSlot.task}`); return newSlot; } /** * 删除日程 */ removeSlot(companyId, slotId) { const schedule = this.getSchedule(companyId); schedule.slots = schedule.slots.filter(s => s.id !== slotId); this.saveSchedule(companyId, schedule); return true; } /** * 更新日程 */ updateSlot(companyId, slotId, updates) { const schedule = this.getSchedule(companyId); const slot = schedule.slots.find(s => s.id === slotId); if (!slot) return null; if (updates.time !== undefined) slot.time = updates.time; if (updates.agent !== undefined) slot.agent = updates.agent; if (updates.task !== undefined) slot.task = updates.task; if (updates.days !== undefined) slot.days = updates.days; if (updates.enabled !== undefined) slot.enabled = updates.enabled; this.saveSchedule(companyId, schedule); return slot; } /** * 获取当前时间应该触发的日程 * * @param {string} companyId * @param {number} toleranceMinutes - 容差(分钟),默认 15 分钟 * @returns {Array} 到期的 slots */ getDueSlots(companyId, toleranceMinutes = 15) { const schedule = this.getSchedule(companyId); if (!schedule.slots || schedule.slots.length === 0) return []; const now = new Date(); // 转换到公司时区 const tz = schedule.timezone || 'Asia/Shanghai'; const localTime = new Date(now.toLocaleString('en-US', { timeZone: tz })); const currentHour = localTime.getHours(); const currentMinute = localTime.getMinutes(); const dayNames = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; const currentDay = dayNames[localTime.getDay()]; // 记录已执行的日程(防重复触发) const executedPath = path.join(DATA_DIR, companyId, '.schedule-executed.json'); let executed = {}; try { executed = JSON.parse(fs.readFileSync(executedPath, 'utf-8')); } catch {} const today = localTime.toISOString().slice(0, 10); // 清理前一天的记录 for (const key of Object.keys(executed)) { if (!key.startsWith(today)) delete executed[key]; } const dueSlots = []; for (const slot of schedule.slots) { if (!slot.enabled) continue; if (!slot.days.includes(currentDay)) continue; // 解析 slot 时间 const [slotHour, slotMinute] = slot.time.split(':').map(Number); const diffMinutes = (currentHour * 60 + currentMinute) - (slotHour * 60 + slotMinute); // 在容差范围内且未执行过 if (diffMinutes >= 0 && diffMinutes < toleranceMinutes) { const execKey = `${today}:${slot.id}`; if (!executed[execKey]) { dueSlots.push(slot); executed[execKey] = new Date().toISOString(); } } } // 保存执行记录 if (dueSlots.length > 0) { fs.writeFileSync(executedPath, JSON.stringify(executed, null, 2), 'utf-8'); } return dueSlots; } /** * 生成人类可读的日程表文本 (用于注入 Agent prompt) */ formatScheduleForAgent(companyId, agentId) { const schedule = this.getSchedule(companyId); const mySlots = schedule.slots.filter(s => s.agent === agentId && s.enabled); if (mySlots.length === 0) return ''; const lines = ['## 📅 我的每日日程', '']; const sorted = [...mySlots].sort((a, b) => a.time.localeCompare(b.time)); for (const slot of sorted) { const daysStr = slot.days.length === 7 ? '每天' : slot.days.join('/'); const typeEmoji = slot.type === 'meeting' ? '📋' : slot.type === 'review' ? '🔍' : '📌'; lines.push(`- **${slot.time}** ${typeEmoji} ${slot.task} (${daysStr})`); } return lines.join('\n'); } } module.exports = { ScheduleService };