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.
- forcomebot-2.2.4.dist-info/METADATA +342 -0
- forcomebot-2.2.4.dist-info/RECORD +36 -0
- forcomebot-2.2.4.dist-info/WHEEL +4 -0
- forcomebot-2.2.4.dist-info/entry_points.txt +4 -0
- src/__init__.py +68 -0
- src/__main__.py +487 -0
- src/api/__init__.py +21 -0
- src/api/routes.py +775 -0
- src/api/websocket.py +280 -0
- src/auth/__init__.py +33 -0
- src/auth/database.py +87 -0
- src/auth/dingtalk.py +373 -0
- src/auth/jwt_handler.py +129 -0
- src/auth/middleware.py +260 -0
- src/auth/models.py +107 -0
- src/auth/routes.py +385 -0
- src/clients/__init__.py +7 -0
- src/clients/langbot.py +710 -0
- src/clients/qianxun.py +388 -0
- src/core/__init__.py +19 -0
- src/core/config_manager.py +411 -0
- src/core/log_collector.py +167 -0
- src/core/message_queue.py +364 -0
- src/core/state_store.py +242 -0
- src/handlers/__init__.py +8 -0
- src/handlers/message_handler.py +833 -0
- src/handlers/message_parser.py +325 -0
- src/handlers/scheduler.py +822 -0
- src/models.py +77 -0
- src/static/assets/index-B4i68B5_.js +50 -0
- src/static/assets/index-BPXisDkw.css +2 -0
- src/static/index.html +14 -0
- src/static/vite.svg +1 -0
- src/utils/__init__.py +13 -0
- src/utils/text_processor.py +166 -0
- src/utils/xml_parser.py +215 -0
|
@@ -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))
|