schedule-service.js 5.87 KB
/**
 * 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 };