ForcomeBot 2.2.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,822 @@
1
+ """定时任务调度器 - 重构版本
2
+
3
+ 使用新模块:
4
+ - ConfigManager 获取配置
5
+ - TextProcessor 处理消息文本
6
+ - StateStore 持久化任务执行记录
7
+ - MessageQueue 统一消息发送
8
+
9
+ 新增功能:
10
+ - 任务执行记录(持久化到StateStore)
11
+ - 任务执行失败日志
12
+ - 支持手动触发任务执行
13
+ - 通过消息队列发送,避免并发过高
14
+ - 排班提醒功能
15
+ - 节假日跳过功能
16
+ """
17
+ import logging
18
+ import re
19
+ import asyncio
20
+ import random
21
+ from typing import Dict, Any, List, Optional, TYPE_CHECKING
22
+ from datetime import datetime, date, timedelta
23
+
24
+ from apscheduler.schedulers.asyncio import AsyncIOScheduler
25
+ from apscheduler.triggers.cron import CronTrigger
26
+
27
+ from ..utils.text_processor import TextProcessor
28
+
29
+ # 尝试导入中国节假日库
30
+ try:
31
+ import chinese_calendar
32
+ HAS_CHINESE_CALENDAR = True
33
+ except ImportError:
34
+ HAS_CHINESE_CALENDAR = False
35
+
36
+ if TYPE_CHECKING:
37
+ from ..clients.qianxun import QianXunClient
38
+ from ..core.config_manager import ConfigManager
39
+ from ..core.state_store import StateStore
40
+ from ..core.log_collector import LogCollector
41
+ from ..core.message_queue import MessageQueue
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+
46
+ class TaskScheduler:
47
+ """定时任务调度器 - 重构版本"""
48
+
49
+ def __init__(
50
+ self,
51
+ qianxun_client: "QianXunClient",
52
+ config_manager: "ConfigManager",
53
+ state_store: "StateStore",
54
+ log_collector: "LogCollector",
55
+ message_queue: Optional["MessageQueue"] = None
56
+ ):
57
+ """初始化定时任务调度器
58
+
59
+ Args:
60
+ qianxun_client: 千寻客户端
61
+ config_manager: 配置管理器
62
+ state_store: 状态存储器
63
+ log_collector: 日志收集器
64
+ message_queue: 消息队列(可选,如果提供则通过队列发送)
65
+ """
66
+ self.qianxun = qianxun_client
67
+ self.config_manager = config_manager
68
+ self.state_store = state_store
69
+ self.log_collector = log_collector
70
+ self.message_queue = message_queue
71
+ self.text_processor = TextProcessor()
72
+
73
+ self.scheduler = AsyncIOScheduler()
74
+ self.robot_wxid: Optional[str] = None
75
+
76
+ # 任务执行历史(内存中保留最近100条)
77
+ self._task_history: List[Dict[str, Any]] = []
78
+ self._max_history = 100
79
+
80
+ def set_message_queue(self, message_queue: "MessageQueue"):
81
+ """设置消息队列"""
82
+ self.message_queue = message_queue
83
+ logger.info("定时任务调度器已设置消息队列")
84
+
85
+ @property
86
+ def _config(self) -> Dict[str, Any]:
87
+ """获取当前配置"""
88
+ return self.config_manager.config
89
+
90
+ @property
91
+ def _rate_limit(self) -> Dict[str, Any]:
92
+ """获取限流配置"""
93
+ return self.config_manager.get_rate_limit_config()
94
+
95
+ async def _random_delay(self):
96
+ """随机延迟,模拟人工操作(仅在不使用消息队列时调用)
97
+
98
+ 使用配置中的 batch_min_interval 和 batch_max_interval
99
+ """
100
+ min_interval = self._rate_limit.get('batch_min_interval', 2)
101
+ max_interval = self._rate_limit.get('batch_max_interval', 5)
102
+ delay = random.uniform(min_interval, max_interval)
103
+ await asyncio.sleep(delay)
104
+
105
+ async def _send_text(self, target: str, message: str, task_name: str = ""):
106
+ """发送文本消息(通过消息队列或直接发送)
107
+
108
+ Args:
109
+ target: 目标wxid
110
+ message: 消息内容
111
+ task_name: 任务名称(用于日志)
112
+ """
113
+ if self.message_queue:
114
+ # 通过消息队列发送
115
+ from ..core.message_queue import MessagePriority
116
+ await self.message_queue.enqueue_text(
117
+ self.qianxun,
118
+ self.robot_wxid,
119
+ target,
120
+ message,
121
+ priority=MessagePriority.NORMAL # 定时任务使用普通优先级
122
+ )
123
+ logger.info(f"[{task_name}] 消息已加入队列 -> {target}")
124
+ else:
125
+ # 直接发送(带延迟)
126
+ await self._random_delay()
127
+ await self.qianxun.send_text(self.robot_wxid, target, message)
128
+ logger.info(f"[{task_name}] 消息已发送 -> {target}")
129
+
130
+ def set_robot_wxid(self, wxid: str):
131
+ """设置机器人wxid"""
132
+ self.robot_wxid = wxid
133
+ logger.info(f"调度器设置机器人wxid: {wxid}")
134
+
135
+ def start(self):
136
+ """启动调度器"""
137
+ # 从配置获取机器人wxid
138
+ robot_wxid = self.config_manager.robot_wxid
139
+ if robot_wxid:
140
+ self.robot_wxid = robot_wxid
141
+ logger.info(f"从配置加载机器人wxid: {robot_wxid}")
142
+
143
+ self._setup_nickname_check_tasks()
144
+ self._setup_scheduled_reminders()
145
+ self._setup_duty_schedules()
146
+
147
+ if self.scheduler.get_jobs():
148
+ self.scheduler.start()
149
+ logger.info(f"定时任务调度器已启动,共 {len(self.scheduler.get_jobs())} 个任务")
150
+ for job in self.scheduler.get_jobs():
151
+ try:
152
+ next_run = job.next_run_time if hasattr(job, 'next_run_time') else None
153
+ logger.info(f" - {job.id}: 下次执行 {next_run}")
154
+ except Exception:
155
+ logger.info(f" - {job.id}")
156
+ else:
157
+ logger.info("没有启用的定时任务")
158
+
159
+ def stop(self):
160
+ """停止调度器"""
161
+ self.scheduler.shutdown()
162
+ logger.info("定时任务调度器已停止")
163
+
164
+ def _parse_cron(self, cron_expr: str) -> dict:
165
+ """解析Cron表达式"""
166
+ parts = cron_expr.split()
167
+
168
+ if len(parts) == 6:
169
+ return {
170
+ 'second': parts[0],
171
+ 'minute': parts[1],
172
+ 'hour': parts[2],
173
+ 'day': parts[3],
174
+ 'month': parts[4],
175
+ 'day_of_week': parts[5].replace('?', '*')
176
+ }
177
+ elif len(parts) == 5:
178
+ return {
179
+ 'minute': parts[0],
180
+ 'hour': parts[1],
181
+ 'day': parts[2],
182
+ 'month': parts[3],
183
+ 'day_of_week': parts[4].replace('?', '*')
184
+ }
185
+ else:
186
+ raise ValueError(f"无效的Cron表达式: {cron_expr}")
187
+
188
+ def _setup_nickname_check_tasks(self):
189
+ """设置昵称检测任务"""
190
+ nickname_checks = self.config_manager.get_nickname_check_tasks()
191
+
192
+ for task in nickname_checks:
193
+ if not task.get('enabled', False):
194
+ continue
195
+
196
+ task_id = task.get('task_id', 'unnamed')
197
+ cron_expr = task.get('cron', '')
198
+
199
+ if not cron_expr:
200
+ continue
201
+
202
+ try:
203
+ cron_params = self._parse_cron(cron_expr)
204
+ trigger = CronTrigger(**cron_params)
205
+
206
+ self.scheduler.add_job(
207
+ self._run_nickname_check,
208
+ trigger,
209
+ args=[task],
210
+ id=f"nickname_check_{task_id}",
211
+ replace_existing=True
212
+ )
213
+ logger.info(f"已添加昵称检测任务: {task_id}, cron={cron_expr}")
214
+ except Exception as e:
215
+ logger.error(f"添加昵称检测任务失败 {task_id}: {e}")
216
+
217
+ def _setup_scheduled_reminders(self):
218
+ """设置定时提醒任务"""
219
+ reminders = self.config_manager.get_scheduled_reminders()
220
+
221
+ for idx, task in enumerate(reminders):
222
+ if not task.get('enabled', False):
223
+ continue
224
+
225
+ task_name = task.get('task_name', f'reminder_{idx}')
226
+ cron_expr = task.get('cron', '')
227
+
228
+ if not cron_expr:
229
+ continue
230
+
231
+ try:
232
+ cron_params = self._parse_cron(cron_expr)
233
+ trigger = CronTrigger(**cron_params)
234
+
235
+ self.scheduler.add_job(
236
+ self._run_scheduled_reminder,
237
+ trigger,
238
+ args=[task],
239
+ id=f"reminder_{task_name}_{idx}",
240
+ replace_existing=True
241
+ )
242
+ logger.info(f"已添加定时提醒任务: {task_name}, cron={cron_expr}")
243
+ except Exception as e:
244
+ logger.error(f"添加定时提醒任务失败 {task_name}: {e}")
245
+
246
+ def _setup_duty_schedules(self):
247
+ """设置排班提醒任务
248
+
249
+ 提醒触发规则:
250
+ - 按日轮换:每天触发,检查当天或提前N天的值班人员
251
+ - 按周轮换:每天触发,检查当天或提前N天是否有值班(由 _run_duty_reminder 判断)
252
+ - 按月轮换:每天触发,检查当天或提前N天是否有值班(由 _run_duty_reminder 判断)
253
+
254
+ 注意:所有类型都设置为每天触发,由 _run_duty_reminder 判断是否需要发送提醒
255
+ 这样可以正确支持"提前提醒"功能
256
+ """
257
+ schedules = self.config_manager.get_duty_schedules()
258
+
259
+ for idx, schedule in enumerate(schedules):
260
+ if not schedule.get('enabled', False):
261
+ continue
262
+
263
+ schedule_id = schedule.get('schedule_id', f'duty_{idx}')
264
+ reminder = schedule.get('reminder', {})
265
+
266
+ if not reminder.get('enabled', False):
267
+ continue
268
+
269
+ remind_time = reminder.get('time', '09:00')
270
+
271
+ try:
272
+ hour, minute = remind_time.split(':')
273
+
274
+ # 所有类型都每天触发,由 _run_duty_reminder 判断是否需要发送
275
+ cron_params = {'hour': int(hour), 'minute': int(minute)}
276
+
277
+ trigger = CronTrigger(**cron_params)
278
+
279
+ self.scheduler.add_job(
280
+ self._run_duty_reminder,
281
+ trigger,
282
+ args=[schedule],
283
+ id=f"duty_{schedule_id}_{idx}",
284
+ replace_existing=True
285
+ )
286
+ schedule_type = schedule.get('schedule_type', 'daily')
287
+ logger.info(f"已添加排班提醒任务: {schedule_id}, time={remind_time}, type={schedule_type}")
288
+ except Exception as e:
289
+ logger.error(f"添加排班提醒任务失败 {schedule_id}: {e}")
290
+
291
+ def _record_task_execution(
292
+ self,
293
+ task_id: str,
294
+ task_type: str,
295
+ status: str,
296
+ details: Optional[str] = None
297
+ ):
298
+ """记录任务执行历史
299
+
300
+ Args:
301
+ task_id: 任务ID
302
+ task_type: 任务类型(nickname_check, scheduled_reminder)
303
+ status: 执行状态(success, failed)
304
+ details: 详细信息
305
+ """
306
+ record = {
307
+ "task_id": task_id,
308
+ "task_type": task_type,
309
+ "executed_at": datetime.now().isoformat(),
310
+ "status": status,
311
+ "details": details
312
+ }
313
+
314
+ self._task_history.append(record)
315
+
316
+ # 限制历史记录数量
317
+ if len(self._task_history) > self._max_history:
318
+ self._task_history = self._task_history[-self._max_history:]
319
+
320
+ logger.info(f"任务执行记录: {task_id} - {status}")
321
+
322
+ def get_task_history(self, limit: int = 50) -> List[Dict[str, Any]]:
323
+ """获取任务执行历史
324
+
325
+ Args:
326
+ limit: 返回条数限制
327
+
328
+ Returns:
329
+ 任务执行历史列表(最新的在前)
330
+ """
331
+ return list(reversed(self._task_history))[:limit]
332
+
333
+ def get_tasks(self) -> List[Dict[str, Any]]:
334
+ """获取所有定时任务信息
335
+
336
+ Returns:
337
+ 任务列表
338
+ """
339
+ tasks = []
340
+
341
+ # 昵称检测任务
342
+ for task in self.config_manager.get_nickname_check_tasks():
343
+ task_id = task.get('task_id', 'unnamed')
344
+ job = self.scheduler.get_job(f"nickname_check_{task_id}")
345
+ tasks.append({
346
+ "id": f"nickname_check_{task_id}",
347
+ "name": task_id,
348
+ "type": "nickname_check",
349
+ "cron": task.get('cron', ''),
350
+ "enabled": task.get('enabled', False),
351
+ "next_run": job.next_run_time.isoformat() if job and job.next_run_time else None
352
+ })
353
+
354
+ # 定时提醒任务
355
+ for idx, task in enumerate(self.config_manager.get_scheduled_reminders()):
356
+ task_name = task.get('task_name', f'reminder_{idx}')
357
+ job = self.scheduler.get_job(f"reminder_{task_name}_{idx}")
358
+ tasks.append({
359
+ "id": f"reminder_{task_name}_{idx}",
360
+ "name": task_name,
361
+ "type": "scheduled_reminder",
362
+ "cron": task.get('cron', ''),
363
+ "enabled": task.get('enabled', False),
364
+ "next_run": job.next_run_time.isoformat() if job and job.next_run_time else None
365
+ })
366
+
367
+ # 排班提醒任务
368
+ for idx, schedule in enumerate(self.config_manager.get_duty_schedules()):
369
+ schedule_id = schedule.get('schedule_id', f'duty_{idx}')
370
+ job = self.scheduler.get_job(f"duty_{schedule_id}_{idx}")
371
+ reminder = schedule.get('reminder', {})
372
+ tasks.append({
373
+ "id": f"duty_{schedule_id}_{idx}",
374
+ "name": schedule_id,
375
+ "type": "duty_schedule",
376
+ "schedule_type": schedule.get('schedule_type', 'daily'),
377
+ "remind_time": reminder.get('time', ''),
378
+ "enabled": schedule.get('enabled', False),
379
+ "next_run": job.next_run_time.isoformat() if job and job.next_run_time else None
380
+ })
381
+
382
+ return tasks
383
+
384
+ async def run_task_manually(self, task_id: str) -> Dict[str, Any]:
385
+ """手动触发任务执行
386
+
387
+ Args:
388
+ task_id: 任务ID(如 nickname_check_xxx 或 reminder_xxx_0 或 duty_xxx_0)
389
+
390
+ Returns:
391
+ 执行结果
392
+ """
393
+ logger.info(f"手动触发任务: {task_id}")
394
+
395
+ try:
396
+ if task_id.startswith("nickname_check_"):
397
+ # 查找对应的昵称检测任务
398
+ check_id = task_id.replace("nickname_check_", "")
399
+ for task in self.config_manager.get_nickname_check_tasks():
400
+ if task.get('task_id') == check_id:
401
+ await self._run_nickname_check(task)
402
+ return {"status": "success", "message": f"任务 {task_id} 执行完成"}
403
+ return {"status": "error", "message": f"未找到任务: {task_id}"}
404
+
405
+ elif task_id.startswith("reminder_"):
406
+ # 查找对应的定时提醒任务
407
+ for idx, task in enumerate(self.config_manager.get_scheduled_reminders()):
408
+ task_name = task.get('task_name', f'reminder_{idx}')
409
+ if task_id == f"reminder_{task_name}_{idx}":
410
+ await self._run_scheduled_reminder(task)
411
+ return {"status": "success", "message": f"任务 {task_id} 执行完成"}
412
+ return {"status": "error", "message": f"未找到任务: {task_id}"}
413
+
414
+ elif task_id.startswith("duty_"):
415
+ # 查找对应的排班提醒任务
416
+ for idx, schedule in enumerate(self.config_manager.get_duty_schedules()):
417
+ schedule_id = schedule.get('schedule_id', f'duty_{idx}')
418
+ if task_id == f"duty_{schedule_id}_{idx}":
419
+ await self._run_duty_reminder(schedule)
420
+ return {"status": "success", "message": f"任务 {task_id} 执行完成"}
421
+ return {"status": "error", "message": f"未找到任务: {task_id}"}
422
+
423
+ else:
424
+ return {"status": "error", "message": f"未知任务类型: {task_id}"}
425
+
426
+ except Exception as e:
427
+ logger.error(f"手动执行任务失败 {task_id}: {e}", exc_info=True)
428
+ return {"status": "error", "message": str(e)}
429
+
430
+ async def _run_nickname_check(self, task: dict):
431
+ """执行昵称检测任务"""
432
+ if not self.robot_wxid:
433
+ logger.warning("机器人wxid未设置,跳过昵称检测")
434
+ return
435
+
436
+ task_id = task.get('task_id', 'unnamed')
437
+ target_groups = task.get('target_groups', [])
438
+ exclude_users = task.get('exclude_users', [])
439
+ regex_pattern = task.get('regex', '')
440
+ message_tpl = task.get('message_tpl', '⚠️ @{user} 您的昵称不规范,请及时修改。')
441
+
442
+ if not target_groups or not regex_pattern:
443
+ return
444
+
445
+ logger.info(f"开始执行昵称检测任务: {task_id}")
446
+
447
+ try:
448
+ pattern = re.compile(regex_pattern)
449
+ except re.error as e:
450
+ logger.error(f"正则表达式无效 {task_id}: {e}")
451
+ self._record_task_execution(task_id, "nickname_check", "failed", f"正则表达式无效: {e}")
452
+ return
453
+
454
+ try:
455
+ for group_id in target_groups:
456
+ await self._check_group_nicknames(
457
+ group_id, pattern, exclude_users, message_tpl, task_id
458
+ )
459
+
460
+ self._record_task_execution(task_id, "nickname_check", "success")
461
+
462
+ except Exception as e:
463
+ logger.error(f"昵称检测任务执行失败 {task_id}: {e}", exc_info=True)
464
+ self._record_task_execution(task_id, "nickname_check", "failed", str(e))
465
+
466
+ async def _check_group_nicknames(
467
+ self,
468
+ group_id: str,
469
+ pattern: re.Pattern,
470
+ exclude_users: List[str],
471
+ message_tpl: str,
472
+ task_id: str
473
+ ):
474
+ """检测单个群的昵称"""
475
+ logger.info(f"检测群 {group_id} 的昵称")
476
+
477
+ members = await self.qianxun.get_group_member_list(
478
+ self.robot_wxid, group_id, get_nick=True, refresh=True
479
+ )
480
+
481
+ if not members:
482
+ logger.warning(f"获取群 {group_id} 成员列表失败或为空")
483
+ return
484
+
485
+ non_compliant = []
486
+ for member in members:
487
+ wxid = member.get('wxid', '')
488
+ nick = member.get('groupNick', '')
489
+
490
+ if wxid in exclude_users:
491
+ continue
492
+
493
+ if nick and not pattern.match(nick):
494
+ non_compliant.append({'wxid': wxid, 'nick': nick})
495
+
496
+ if not non_compliant:
497
+ logger.info(f"群 {group_id} 所有成员昵称均合规")
498
+ return
499
+
500
+ logger.info(f"群 {group_id} 有 {len(non_compliant)} 人昵称不合规")
501
+
502
+ at_codes = [f"[@,wxid={m['wxid']},nick=,isAuto=true]" for m in non_compliant]
503
+ at_str = " ".join(at_codes)
504
+ message = message_tpl.replace('{user}', at_str)
505
+
506
+ # 使用 TextProcessor 把 \n 字符串转换为真正的换行符
507
+ message = self.text_processor.config_to_text(message)
508
+
509
+ logger.debug(f"发送消息内容: {repr(message)}")
510
+
511
+ # 通过统一方法发送
512
+ await self._send_text(group_id, message, f"昵称检测_{task_id}")
513
+ nicks = ", ".join([m['nick'] for m in non_compliant])
514
+ logger.info(f"已发送昵称提醒: {nicks}")
515
+
516
+ async def _run_scheduled_reminder(self, task: dict):
517
+ """执行定时提醒任务"""
518
+ if not self.robot_wxid:
519
+ logger.warning("机器人wxid未设置,跳过定时提醒")
520
+ return
521
+
522
+ task_name = task.get('task_name', 'unnamed')
523
+ target_groups = task.get('target_groups', [])
524
+ mention_users = task.get('mention_users', [])
525
+ content = task.get('content', '')
526
+
527
+ if not target_groups or not content:
528
+ return
529
+
530
+ logger.info(f"开始执行定时提醒任务: {task_name}")
531
+
532
+ try:
533
+ at_prefix = ""
534
+ if mention_users:
535
+ if "all" in mention_users:
536
+ at_prefix = "[@,wxid=all,nick=,isAuto=true] "
537
+ else:
538
+ at_codes = [f"[@,wxid={uid},nick=,isAuto=true]" for uid in mention_users]
539
+ at_prefix = " ".join(at_codes) + " "
540
+
541
+ message = at_prefix + content
542
+
543
+ # 使用 TextProcessor 把 \n 转换为真正的换行符
544
+ message = self.text_processor.config_to_text(message)
545
+
546
+ for group_id in target_groups:
547
+ await self._send_text(group_id, message, f"定时提醒_{task_name}")
548
+
549
+ self._record_task_execution(task_name, "scheduled_reminder", "success")
550
+
551
+ except Exception as e:
552
+ logger.error(f"定时提醒任务执行失败 {task_name}: {e}", exc_info=True)
553
+ self._record_task_execution(task_name, "scheduled_reminder", "failed", str(e))
554
+
555
+ def reload_tasks(self):
556
+ """重新加载任务(配置变更后调用)"""
557
+ logger.info("重新加载定时任务...")
558
+
559
+ # 移除所有现有任务
560
+ for job in self.scheduler.get_jobs():
561
+ job.remove()
562
+
563
+ # 重新设置任务
564
+ self._setup_nickname_check_tasks()
565
+ self._setup_scheduled_reminders()
566
+ self._setup_duty_schedules()
567
+
568
+ logger.info(f"定时任务已重新加载,共 {len(self.scheduler.get_jobs())} 个任务")
569
+
570
+ def _is_holiday(self, target_date: date) -> bool:
571
+ """判断是否为法定节假日
572
+
573
+ Args:
574
+ target_date: 目标日期
575
+
576
+ Returns:
577
+ 是否为节假日
578
+ """
579
+ if not HAS_CHINESE_CALENDAR:
580
+ return False
581
+
582
+ try:
583
+ return chinese_calendar.is_holiday(target_date)
584
+ except Exception as e:
585
+ logger.warning(f"判断节假日失败: {e}")
586
+ return False
587
+
588
+ def _is_workday(self, target_date: date) -> bool:
589
+ """判断是否为工作日(包括调休补班)
590
+
591
+ Args:
592
+ target_date: 目标日期
593
+
594
+ Returns:
595
+ 是否为工作日
596
+ """
597
+ if not HAS_CHINESE_CALENDAR:
598
+ # 没有库时,简单判断周末
599
+ return target_date.weekday() < 5
600
+
601
+ try:
602
+ return chinese_calendar.is_workday(target_date)
603
+ except Exception as e:
604
+ logger.warning(f"判断工作日失败: {e}")
605
+ return target_date.weekday() < 5
606
+
607
+ def _should_skip_date(
608
+ self,
609
+ schedule: Dict[str, Any],
610
+ target_date: date
611
+ ) -> bool:
612
+ """判断是否应该跳过该日期
613
+
614
+ Args:
615
+ schedule: 排班配置
616
+ target_date: 目标日期
617
+
618
+ Returns:
619
+ 是否跳过
620
+ """
621
+ target_date_str = target_date.isoformat()
622
+
623
+ # 1. 检查手动增加的日期(优先级最高,不跳过)
624
+ included_dates = schedule.get('included_dates', [])
625
+ if target_date_str in included_dates:
626
+ return False
627
+
628
+ # 2. 检查手动排除的日期
629
+ excluded_dates = schedule.get('excluded_dates', [])
630
+ if target_date_str in excluded_dates:
631
+ return True
632
+
633
+ # 3. 检查是否跳过节假日
634
+ skip_holidays = schedule.get('skip_holidays', False)
635
+ if skip_holidays and self._is_holiday(target_date):
636
+ return True
637
+
638
+ return False
639
+
640
+ def _get_duty_users_for_date(
641
+ self,
642
+ schedule: Dict[str, Any],
643
+ target_date: date
644
+ ) -> List[Dict[str, str]]:
645
+ """获取指定日期的值班人员
646
+
647
+ Args:
648
+ schedule: 排班配置
649
+ target_date: 目标日期
650
+
651
+ Returns:
652
+ 值班人员列表 [{"wxid": "xxx", "name": "张三"}, ...]
653
+ """
654
+ # 检查是否应该跳过该日期
655
+ if self._should_skip_date(schedule, target_date):
656
+ return []
657
+
658
+ # 1. 优先检查手动指定
659
+ manual_assignments = schedule.get('manual_assignments', [])
660
+ target_date_str = target_date.isoformat()
661
+
662
+ for assignment in manual_assignments:
663
+ if assignment.get('date') == target_date_str:
664
+ return assignment.get('users', [])
665
+
666
+ # 2. 使用自动轮换计算
667
+ auto = schedule.get('auto_rotation', {})
668
+ if not auto.get('enabled', False):
669
+ return []
670
+
671
+ members = auto.get('members', [])
672
+ if not members:
673
+ return []
674
+
675
+ group_size = auto.get('group_size', 1)
676
+ start_date_str = auto.get('start_date', '')
677
+
678
+ if not start_date_str:
679
+ return []
680
+
681
+ try:
682
+ start_date = date.fromisoformat(start_date_str)
683
+ except ValueError:
684
+ logger.error(f"无效的开始日期: {start_date_str}")
685
+ return []
686
+
687
+ # 如果目标日期在开始日期之前,返回空
688
+ if target_date < start_date:
689
+ return []
690
+
691
+ schedule_type = schedule.get('schedule_type', 'daily')
692
+ total_groups = len(members) // group_size
693
+ if total_groups == 0:
694
+ total_groups = 1
695
+
696
+ # 计算有效的轮换周期数(跳过节假日和排除日期)
697
+ skip_holidays = schedule.get('skip_holidays', False)
698
+ excluded_dates = set(schedule.get('excluded_dates', []))
699
+ included_dates = set(schedule.get('included_dates', []))
700
+
701
+ if schedule_type == 'daily':
702
+ # 按日轮换:计算从开始日期到目标日期之间的有效天数
703
+ periods_passed = 0
704
+ current = start_date
705
+ while current < target_date:
706
+ current_str = current.isoformat()
707
+ # 判断该日期是否有效
708
+ if current_str in included_dates:
709
+ # 手动增加的日期,算有效
710
+ periods_passed += 1
711
+ elif current_str in excluded_dates:
712
+ # 手动排除的日期,跳过
713
+ pass
714
+ elif skip_holidays and self._is_holiday(current):
715
+ # 节假日,跳过
716
+ pass
717
+ else:
718
+ # 正常日期,算有效
719
+ periods_passed += 1
720
+ current += timedelta(days=1)
721
+ elif schedule_type == 'weekly':
722
+ periods_passed = (target_date - start_date).days // 7
723
+ elif schedule_type == 'monthly':
724
+ periods_passed = (target_date.year - start_date.year) * 12 + (target_date.month - start_date.month)
725
+ else:
726
+ periods_passed = (target_date - start_date).days
727
+
728
+ group_index = periods_passed % total_groups
729
+ start_idx = group_index * group_size
730
+ end_idx = min(start_idx + group_size, len(members))
731
+
732
+ return members[start_idx:end_idx]
733
+
734
+ async def _run_duty_reminder(self, schedule: Dict[str, Any]):
735
+ """执行排班提醒任务
736
+
737
+ 提醒逻辑:
738
+ 1. 根据 timing_type 计算目标值班日期
739
+ - same_day: 今天
740
+ - advance: 今天 + advance_days
741
+ 2. 获取目标日期的值班人员
742
+ 3. 如果有值班人员,发送提醒
743
+ 4. 如果启用重复提醒,按间隔重复发送
744
+ """
745
+ if not self.robot_wxid:
746
+ logger.warning("机器人wxid未设置,跳过排班提醒")
747
+ return
748
+
749
+ schedule_id = schedule.get('schedule_id', 'unnamed')
750
+ target_group = schedule.get('target_group', '')
751
+ reminder = schedule.get('reminder', {})
752
+
753
+ if not target_group:
754
+ logger.warning(f"排班任务 {schedule_id} 没有设置目标群")
755
+ return
756
+
757
+ logger.info(f"开始执行排班提醒任务: {schedule_id}")
758
+
759
+ try:
760
+ # 根据提醒时机计算目标日期
761
+ timing_type = reminder.get('timing_type', 'same_day')
762
+ advance_days = reminder.get('advance_days', 1)
763
+
764
+ today = date.today()
765
+ if timing_type == 'advance':
766
+ # 提前提醒:获取N天后的值班人员
767
+ target_date = today + timedelta(days=advance_days)
768
+ else:
769
+ # 当天提醒:获取今天的值班人员
770
+ target_date = today
771
+
772
+ # 检查结束日期
773
+ auto = schedule.get('auto_rotation', {})
774
+ end_date_str = auto.get('end_date', '')
775
+ if end_date_str:
776
+ try:
777
+ end_date = date.fromisoformat(end_date_str)
778
+ if target_date > end_date:
779
+ logger.info(f"排班任务 {schedule_id} 目标日期 {target_date} 已超过结束日期 {end_date}")
780
+ return
781
+ except ValueError:
782
+ pass
783
+
784
+ duty_users = self._get_duty_users_for_date(schedule, target_date)
785
+
786
+ if not duty_users:
787
+ logger.info(f"排班任务 {schedule_id} 目标日期 {target_date} 没有值班人员(可能是节假日或未排班)")
788
+ return
789
+
790
+ # 构建@用户列表
791
+ at_codes = [f"[@,wxid={u['wxid']},nick=,isAuto=true]" for u in duty_users]
792
+ at_str = " ".join(at_codes)
793
+ user_names = "、".join([u.get('name', u['wxid']) for u in duty_users])
794
+
795
+ # 格式化日期
796
+ target_date_str = target_date.strftime('%m月%d日')
797
+
798
+ # 构建消息
799
+ message_tpl = reminder.get('message', '📢 值班提醒\n\n{users} 今天是你们的值班日!')
800
+ message = message_tpl.replace('{users}', at_str).replace('{names}', user_names).replace('{date}', target_date_str)
801
+
802
+ # 使用 TextProcessor 处理换行符
803
+ message = self.text_processor.config_to_text(message)
804
+
805
+ # 发送提醒
806
+ repeat_enabled = reminder.get('repeat_enabled', False)
807
+ repeat_count = reminder.get('repeat_count', 1) if repeat_enabled else 1
808
+ repeat_interval = reminder.get('repeat_interval', 30) # 分钟
809
+
810
+ for i in range(repeat_count):
811
+ if i > 0:
812
+ # 等待间隔时间
813
+ await asyncio.sleep(repeat_interval * 60)
814
+
815
+ await self._send_text(target_group, message, f"排班提醒_{schedule_id}")
816
+ logger.info(f"[排班提醒_{schedule_id}] 第{i+1}次提醒已发送,值班日期: {target_date},值班人员: {user_names}")
817
+
818
+ self._record_task_execution(schedule_id, "duty_schedule", "success", f"值班日期: {target_date}, 值班人员: {user_names}")
819
+
820
+ except Exception as e:
821
+ logger.error(f"排班提醒任务执行失败 {schedule_id}: {e}", exc_info=True)
822
+ self._record_task_execution(schedule_id, "duty_schedule", "failed", str(e))