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
src/api/routes.py
ADDED
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
"""API路由模块 - RESTful API接口
|
|
2
|
+
|
|
3
|
+
提供以下API:
|
|
4
|
+
- 配置管理:GET/POST /api/config, POST /api/config/validate
|
|
5
|
+
- 状态监控:GET /api/status, /api/status/langbot, /api/status/memory, /api/status/queue
|
|
6
|
+
- 日志:GET /api/logs
|
|
7
|
+
- 任务:GET /api/tasks, POST /api/tasks/{id}/run, GET /api/tasks/history
|
|
8
|
+
- 缓存:POST /api/cache/clear
|
|
9
|
+
- 列表:GET /api/chatrooms, /api/friends, /api/group_members/{id}, /api/robot_info
|
|
10
|
+
- 群发:POST /api/broadcast/*
|
|
11
|
+
"""
|
|
12
|
+
import logging
|
|
13
|
+
from typing import Optional, Dict, Any, List, TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from fastapi import APIRouter, HTTPException
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from ..core.config_manager import ConfigManager
|
|
20
|
+
from ..core.state_store import StateStore
|
|
21
|
+
from ..core.log_collector import LogCollector
|
|
22
|
+
from ..core.message_queue import MessageQueue
|
|
23
|
+
from ..clients.qianxun import QianXunClient
|
|
24
|
+
from ..clients.langbot import LangBotClient
|
|
25
|
+
from ..handlers.scheduler import TaskScheduler
|
|
26
|
+
|
|
27
|
+
from ..core.message_queue import MessagePriority
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
# 创建路由器
|
|
32
|
+
router = APIRouter(prefix="/api", tags=["admin"])
|
|
33
|
+
|
|
34
|
+
# 全局引用(由main.py设置)
|
|
35
|
+
_config_manager: Optional["ConfigManager"] = None
|
|
36
|
+
_state_store: Optional["StateStore"] = None
|
|
37
|
+
_log_collector: Optional["LogCollector"] = None
|
|
38
|
+
_message_queue: Optional["MessageQueue"] = None
|
|
39
|
+
_qianxun_client: Optional["QianXunClient"] = None
|
|
40
|
+
_langbot_client: Optional["LangBotClient"] = None
|
|
41
|
+
_scheduler: Optional["TaskScheduler"] = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def set_dependencies(
|
|
45
|
+
config_manager: "ConfigManager",
|
|
46
|
+
state_store: "StateStore",
|
|
47
|
+
log_collector: "LogCollector",
|
|
48
|
+
qianxun_client: "QianXunClient",
|
|
49
|
+
langbot_client: "LangBotClient",
|
|
50
|
+
scheduler: "TaskScheduler",
|
|
51
|
+
message_queue: "MessageQueue" = None
|
|
52
|
+
):
|
|
53
|
+
"""设置API依赖的服务实例
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
config_manager: 配置管理器
|
|
57
|
+
state_store: 状态存储器
|
|
58
|
+
log_collector: 日志收集器
|
|
59
|
+
qianxun_client: 千寻客户端
|
|
60
|
+
langbot_client: LangBot客户端
|
|
61
|
+
scheduler: 任务调度器
|
|
62
|
+
message_queue: 消息队列
|
|
63
|
+
"""
|
|
64
|
+
global _config_manager, _state_store, _log_collector
|
|
65
|
+
global _qianxun_client, _langbot_client, _scheduler, _message_queue
|
|
66
|
+
|
|
67
|
+
_config_manager = config_manager
|
|
68
|
+
_state_store = state_store
|
|
69
|
+
_log_collector = log_collector
|
|
70
|
+
_qianxun_client = qianxun_client
|
|
71
|
+
_langbot_client = langbot_client
|
|
72
|
+
_scheduler = scheduler
|
|
73
|
+
_message_queue = message_queue
|
|
74
|
+
|
|
75
|
+
logger.info("API路由依赖已设置")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ============ 请求/响应模型 ============
|
|
79
|
+
|
|
80
|
+
class ConfigUpdate(BaseModel):
|
|
81
|
+
"""配置更新请求"""
|
|
82
|
+
config: Dict[str, Any]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class CacheClearRequest(BaseModel):
|
|
86
|
+
"""缓存清理请求"""
|
|
87
|
+
cache_type: str # all, image, msg_ids, group_mapping, message_mapping
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ============ 配置管理API ============
|
|
91
|
+
|
|
92
|
+
@router.get("/config")
|
|
93
|
+
async def get_config():
|
|
94
|
+
"""获取当前配置"""
|
|
95
|
+
if not _config_manager:
|
|
96
|
+
raise HTTPException(status_code=503, detail="服务未初始化")
|
|
97
|
+
|
|
98
|
+
return _config_manager.config
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@router.post("/config")
|
|
102
|
+
async def update_config(data: ConfigUpdate):
|
|
103
|
+
"""更新配置"""
|
|
104
|
+
if not _config_manager:
|
|
105
|
+
raise HTTPException(status_code=503, detail="服务未初始化")
|
|
106
|
+
|
|
107
|
+
success, message = await _config_manager.save(data.config)
|
|
108
|
+
|
|
109
|
+
if not success:
|
|
110
|
+
raise HTTPException(status_code=400, detail=message)
|
|
111
|
+
|
|
112
|
+
# 重新加载调度器任务
|
|
113
|
+
if _scheduler:
|
|
114
|
+
_scheduler.reload_tasks()
|
|
115
|
+
|
|
116
|
+
return {"status": "ok", "message": message}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@router.post("/config/validate")
|
|
120
|
+
async def validate_config(data: ConfigUpdate):
|
|
121
|
+
"""验证配置格式"""
|
|
122
|
+
if not _config_manager:
|
|
123
|
+
raise HTTPException(status_code=503, detail="服务未初始化")
|
|
124
|
+
|
|
125
|
+
valid, error = _config_manager.validate(data.config)
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
"valid": valid,
|
|
129
|
+
"error": error if not valid else None
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ============ 状态监控API ============
|
|
134
|
+
|
|
135
|
+
@router.get("/status")
|
|
136
|
+
async def get_status():
|
|
137
|
+
"""获取系统状态概览"""
|
|
138
|
+
robot_wxid = _config_manager.robot_wxid if _config_manager else None
|
|
139
|
+
|
|
140
|
+
# 获取调度任务信息
|
|
141
|
+
scheduled_jobs = []
|
|
142
|
+
if _scheduler and _scheduler.scheduler:
|
|
143
|
+
for job in _scheduler.scheduler.get_jobs():
|
|
144
|
+
next_run = None
|
|
145
|
+
try:
|
|
146
|
+
if hasattr(job, 'next_run_time') and job.next_run_time:
|
|
147
|
+
next_run = job.next_run_time.isoformat()
|
|
148
|
+
except:
|
|
149
|
+
pass
|
|
150
|
+
scheduled_jobs.append({
|
|
151
|
+
"id": job.id,
|
|
152
|
+
"next_run": next_run
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
# 获取内存使用情况
|
|
156
|
+
memory_usage = _state_store.get_stats() if _state_store else {}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
"robot_wxid": robot_wxid,
|
|
160
|
+
"langbot_connected": _langbot_client.is_connected if _langbot_client else False,
|
|
161
|
+
"langbot_reconnecting": _langbot_client.is_reconnecting if _langbot_client else False,
|
|
162
|
+
"scheduled_jobs": scheduled_jobs,
|
|
163
|
+
"memory_usage": memory_usage
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@router.get("/status/langbot")
|
|
168
|
+
async def get_langbot_status():
|
|
169
|
+
"""获取LangBot连接状态"""
|
|
170
|
+
if not _langbot_client:
|
|
171
|
+
return {
|
|
172
|
+
"connected": False,
|
|
173
|
+
"reconnecting": False,
|
|
174
|
+
"error": "LangBot客户端未初始化"
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
"connected": _langbot_client.is_connected,
|
|
179
|
+
"reconnecting": _langbot_client.is_reconnecting,
|
|
180
|
+
"reconnect_delay": _langbot_client.get_reconnect_delay(),
|
|
181
|
+
"host": _langbot_client.host,
|
|
182
|
+
"port": _langbot_client.port
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@router.get("/status/memory")
|
|
187
|
+
async def get_memory_status():
|
|
188
|
+
"""获取内存缓存状态"""
|
|
189
|
+
if not _state_store:
|
|
190
|
+
return {"error": "状态存储器未初始化"}
|
|
191
|
+
|
|
192
|
+
stats = _state_store.get_stats()
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
"stats": stats,
|
|
196
|
+
"limits": {
|
|
197
|
+
"max_msg_ids": _state_store.max_msg_ids,
|
|
198
|
+
"max_image_cache": _state_store.max_image_cache,
|
|
199
|
+
"max_group_mapping": _state_store.max_group_mapping,
|
|
200
|
+
"max_message_mapping": _state_store.max_message_mapping
|
|
201
|
+
},
|
|
202
|
+
"ttl": {
|
|
203
|
+
"msg_id_ttl": _state_store.msg_id_ttl,
|
|
204
|
+
"image_cache_ttl": _state_store.image_cache_ttl
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@router.get("/status/queue")
|
|
210
|
+
async def get_queue_status():
|
|
211
|
+
"""获取消息队列状态"""
|
|
212
|
+
if not _message_queue:
|
|
213
|
+
return {"error": "消息队列未初始化"}
|
|
214
|
+
|
|
215
|
+
return _message_queue.get_status()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ============ 日志API ============
|
|
219
|
+
|
|
220
|
+
@router.get("/logs")
|
|
221
|
+
async def get_logs(type: Optional[str] = None, limit: int = 100):
|
|
222
|
+
"""获取消息处理日志
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
type: 日志类型筛选(private, group, error, system)
|
|
226
|
+
limit: 返回条数限制
|
|
227
|
+
"""
|
|
228
|
+
if not _log_collector:
|
|
229
|
+
return {"logs": [], "error": "日志收集器未初始化"}
|
|
230
|
+
|
|
231
|
+
logs = _log_collector.get_logs(log_type=type, limit=limit)
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
"logs": logs,
|
|
235
|
+
"total": _log_collector.log_count,
|
|
236
|
+
"subscribers": _log_collector.subscriber_count
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ============ 任务API ============
|
|
241
|
+
|
|
242
|
+
@router.get("/tasks")
|
|
243
|
+
async def get_tasks():
|
|
244
|
+
"""获取所有定时任务"""
|
|
245
|
+
if not _scheduler:
|
|
246
|
+
return {"tasks": [], "error": "调度器未初始化"}
|
|
247
|
+
|
|
248
|
+
tasks = _scheduler.get_tasks()
|
|
249
|
+
|
|
250
|
+
return {"tasks": tasks}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@router.post("/tasks/{task_id}/run")
|
|
254
|
+
async def run_task(task_id: str):
|
|
255
|
+
"""手动触发任务执行"""
|
|
256
|
+
if not _scheduler:
|
|
257
|
+
raise HTTPException(status_code=503, detail="调度器未初始化")
|
|
258
|
+
|
|
259
|
+
result = await _scheduler.run_task_manually(task_id)
|
|
260
|
+
|
|
261
|
+
if result.get("status") == "error":
|
|
262
|
+
raise HTTPException(status_code=400, detail=result.get("message"))
|
|
263
|
+
|
|
264
|
+
return result
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@router.get("/tasks/history")
|
|
268
|
+
async def get_task_history(limit: int = 50):
|
|
269
|
+
"""获取任务执行历史"""
|
|
270
|
+
if not _scheduler:
|
|
271
|
+
return {"history": [], "error": "调度器未初始化"}
|
|
272
|
+
|
|
273
|
+
history = _scheduler.get_task_history(limit=limit)
|
|
274
|
+
|
|
275
|
+
return {"history": history}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ============ 缓存API ============
|
|
279
|
+
|
|
280
|
+
@router.post("/cache/clear")
|
|
281
|
+
async def clear_cache(data: CacheClearRequest):
|
|
282
|
+
"""清理缓存
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
cache_type: 缓存类型
|
|
286
|
+
- all: 清空所有缓存
|
|
287
|
+
- image: 清空图片缓存
|
|
288
|
+
"""
|
|
289
|
+
if not _state_store:
|
|
290
|
+
raise HTTPException(status_code=503, detail="状态存储器未初始化")
|
|
291
|
+
|
|
292
|
+
cache_type = data.cache_type
|
|
293
|
+
|
|
294
|
+
if cache_type == "all":
|
|
295
|
+
_state_store.clear_all()
|
|
296
|
+
return {"status": "ok", "message": "所有缓存已清空"}
|
|
297
|
+
elif cache_type == "image":
|
|
298
|
+
_state_store.clear_image_cache()
|
|
299
|
+
return {"status": "ok", "message": "图片缓存已清空"}
|
|
300
|
+
else:
|
|
301
|
+
raise HTTPException(status_code=400, detail=f"未知的缓存类型: {cache_type}")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ============ 列表API ============
|
|
305
|
+
|
|
306
|
+
@router.get("/chatrooms")
|
|
307
|
+
async def get_chatrooms():
|
|
308
|
+
"""获取群聊列表"""
|
|
309
|
+
if not _qianxun_client or not _config_manager:
|
|
310
|
+
return {"chatrooms": [], "error": "服务未初始化"}
|
|
311
|
+
|
|
312
|
+
robot_wxid = _config_manager.robot_wxid
|
|
313
|
+
if not robot_wxid:
|
|
314
|
+
return {"chatrooms": [], "error": "机器人wxid未配置"}
|
|
315
|
+
|
|
316
|
+
try:
|
|
317
|
+
chatrooms = await _qianxun_client.get_chatroom_list(robot_wxid)
|
|
318
|
+
return {"chatrooms": chatrooms}
|
|
319
|
+
except Exception as e:
|
|
320
|
+
logger.error(f"获取群聊列表失败: {e}")
|
|
321
|
+
return {"chatrooms": [], "error": str(e)}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@router.get("/friends")
|
|
325
|
+
async def get_friends():
|
|
326
|
+
"""获取好友列表"""
|
|
327
|
+
if not _qianxun_client or not _config_manager:
|
|
328
|
+
return {"friends": [], "error": "服务未初始化"}
|
|
329
|
+
|
|
330
|
+
robot_wxid = _config_manager.robot_wxid
|
|
331
|
+
if not robot_wxid:
|
|
332
|
+
return {"friends": [], "error": "机器人wxid未配置"}
|
|
333
|
+
|
|
334
|
+
try:
|
|
335
|
+
friends = await _qianxun_client.get_friend_list(robot_wxid)
|
|
336
|
+
return {"friends": friends}
|
|
337
|
+
except Exception as e:
|
|
338
|
+
logger.error(f"获取好友列表失败: {e}")
|
|
339
|
+
return {"friends": [], "error": str(e)}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@router.get("/group_members/{group_wxid}")
|
|
343
|
+
async def get_group_members(group_wxid: str):
|
|
344
|
+
"""获取群成员列表"""
|
|
345
|
+
if not _qianxun_client or not _config_manager:
|
|
346
|
+
return {"members": [], "error": "服务未初始化"}
|
|
347
|
+
|
|
348
|
+
robot_wxid = _config_manager.robot_wxid
|
|
349
|
+
if not robot_wxid:
|
|
350
|
+
return {"members": [], "error": "机器人wxid未配置"}
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
members = await _qianxun_client.get_group_member_list(
|
|
354
|
+
robot_wxid, group_wxid, get_nick=True
|
|
355
|
+
)
|
|
356
|
+
# 格式化返回数据
|
|
357
|
+
result = []
|
|
358
|
+
for m in members:
|
|
359
|
+
result.append({
|
|
360
|
+
"wxid": m.get("wxid", ""),
|
|
361
|
+
"nickname": m.get("groupNick", "") or m.get("nickname", "") or m.get("wxid", "")
|
|
362
|
+
})
|
|
363
|
+
return {"members": result}
|
|
364
|
+
except Exception as e:
|
|
365
|
+
logger.error(f"获取群成员列表失败: {e}")
|
|
366
|
+
return {"members": [], "error": str(e)}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@router.get("/robot_info")
|
|
370
|
+
async def get_robot_info():
|
|
371
|
+
"""获取机器人自身信息"""
|
|
372
|
+
if not _qianxun_client or not _config_manager:
|
|
373
|
+
return {"wxid": "", "nickname": "", "error": "服务未初始化"}
|
|
374
|
+
|
|
375
|
+
robot_wxid = _config_manager.robot_wxid
|
|
376
|
+
if not robot_wxid:
|
|
377
|
+
return {"wxid": "", "nickname": "", "error": "机器人wxid未配置"}
|
|
378
|
+
|
|
379
|
+
try:
|
|
380
|
+
info = await _qianxun_client.get_self_info(robot_wxid)
|
|
381
|
+
if info:
|
|
382
|
+
# 尝试多个可能的字段名
|
|
383
|
+
nickname = (
|
|
384
|
+
info.get("nick") or
|
|
385
|
+
info.get("nickname") or
|
|
386
|
+
info.get("nickName") or
|
|
387
|
+
info.get("name") or
|
|
388
|
+
robot_wxid
|
|
389
|
+
)
|
|
390
|
+
return {"wxid": robot_wxid, "nickname": nickname}
|
|
391
|
+
return {"wxid": robot_wxid, "nickname": robot_wxid}
|
|
392
|
+
except Exception as e:
|
|
393
|
+
logger.error(f"获取机器人信息失败: {e}")
|
|
394
|
+
return {"wxid": robot_wxid, "nickname": robot_wxid, "error": str(e)}
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# ============ 群发API ============
|
|
398
|
+
|
|
399
|
+
class BroadcastTextRequest(BaseModel):
|
|
400
|
+
"""群发文本请求"""
|
|
401
|
+
targets: List[str] # 目标wxid列表
|
|
402
|
+
message: str # 消息内容
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
class BroadcastImageRequest(BaseModel):
|
|
406
|
+
"""群发图片请求"""
|
|
407
|
+
targets: List[str] # 目标wxid列表
|
|
408
|
+
image_path: str # 图片路径(本地路径、网络直链或base64)
|
|
409
|
+
file_name: str = "" # 保存文件名(网络直链时必填)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
class BroadcastFileRequest(BaseModel):
|
|
413
|
+
"""群发文件请求"""
|
|
414
|
+
targets: List[str] # 目标wxid列表
|
|
415
|
+
file_path: str # 文件路径
|
|
416
|
+
file_name: str = "" # 保存文件名
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
class BroadcastShareUrlRequest(BaseModel):
|
|
420
|
+
"""群发分享链接请求"""
|
|
421
|
+
targets: List[str] # 目标wxid列表
|
|
422
|
+
title: str # 标题
|
|
423
|
+
content: str # 内容描述
|
|
424
|
+
jump_url: str # 跳转地址
|
|
425
|
+
thumb_path: str = "" # 缩略图路径
|
|
426
|
+
app: str = "" # 小尾巴
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
class BroadcastAppletRequest(BaseModel):
|
|
430
|
+
"""群发小程序请求"""
|
|
431
|
+
targets: List[str] # 目标wxid列表
|
|
432
|
+
title: str # 标题
|
|
433
|
+
content: str # 内容描述
|
|
434
|
+
jump_path: str # 跳转路径
|
|
435
|
+
gh: str # 小程序gh
|
|
436
|
+
thumb_path: str = "" # 缩略图路径
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
@router.post("/broadcast/text")
|
|
440
|
+
async def broadcast_text(data: BroadcastTextRequest):
|
|
441
|
+
"""群发文本消息(通过消息队列)"""
|
|
442
|
+
if not _qianxun_client or not _config_manager:
|
|
443
|
+
raise HTTPException(status_code=503, detail="服务未初始化")
|
|
444
|
+
|
|
445
|
+
if not _message_queue:
|
|
446
|
+
raise HTTPException(status_code=503, detail="消息队列未初始化")
|
|
447
|
+
|
|
448
|
+
robot_wxid = _config_manager.robot_wxid
|
|
449
|
+
if not robot_wxid:
|
|
450
|
+
raise HTTPException(status_code=400, detail="机器人wxid未配置")
|
|
451
|
+
|
|
452
|
+
if not data.targets:
|
|
453
|
+
raise HTTPException(status_code=400, detail="请选择发送目标")
|
|
454
|
+
|
|
455
|
+
if not data.message.strip():
|
|
456
|
+
raise HTTPException(status_code=400, detail="消息内容不能为空")
|
|
457
|
+
|
|
458
|
+
# 将所有消息加入队列
|
|
459
|
+
message_ids = []
|
|
460
|
+
for target in data.targets:
|
|
461
|
+
msg_id = await _message_queue.enqueue_text(
|
|
462
|
+
_qianxun_client,
|
|
463
|
+
robot_wxid,
|
|
464
|
+
target,
|
|
465
|
+
data.message,
|
|
466
|
+
priority=MessagePriority.LOW # 群发使用低优先级
|
|
467
|
+
)
|
|
468
|
+
message_ids.append({"target": target, "message_id": msg_id})
|
|
469
|
+
|
|
470
|
+
# 记录群发日志
|
|
471
|
+
if _log_collector:
|
|
472
|
+
await _log_collector.add_log("system", {
|
|
473
|
+
"message": f"群发文本已加入队列: {len(data.targets)} 条消息",
|
|
474
|
+
"details": {"type": "text", "total": len(data.targets)}
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
return {
|
|
478
|
+
"status": "queued",
|
|
479
|
+
"total": len(data.targets),
|
|
480
|
+
"queue_size": _message_queue.queue_size,
|
|
481
|
+
"message_ids": message_ids,
|
|
482
|
+
"message": f"已将 {len(data.targets)} 条消息加入发送队列"
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
@router.post("/broadcast/image")
|
|
487
|
+
async def broadcast_image(data: BroadcastImageRequest):
|
|
488
|
+
"""群发图片(通过消息队列)"""
|
|
489
|
+
if not _qianxun_client or not _config_manager:
|
|
490
|
+
raise HTTPException(status_code=503, detail="服务未初始化")
|
|
491
|
+
|
|
492
|
+
if not _message_queue:
|
|
493
|
+
raise HTTPException(status_code=503, detail="消息队列未初始化")
|
|
494
|
+
|
|
495
|
+
robot_wxid = _config_manager.robot_wxid
|
|
496
|
+
if not robot_wxid:
|
|
497
|
+
raise HTTPException(status_code=400, detail="机器人wxid未配置")
|
|
498
|
+
|
|
499
|
+
if not data.targets:
|
|
500
|
+
raise HTTPException(status_code=400, detail="请选择发送目标")
|
|
501
|
+
|
|
502
|
+
if not data.image_path.strip():
|
|
503
|
+
raise HTTPException(status_code=400, detail="图片路径不能为空")
|
|
504
|
+
|
|
505
|
+
# 将所有消息加入队列
|
|
506
|
+
message_ids = []
|
|
507
|
+
for target in data.targets:
|
|
508
|
+
msg_id = await _message_queue.enqueue_image(
|
|
509
|
+
_qianxun_client,
|
|
510
|
+
robot_wxid,
|
|
511
|
+
target,
|
|
512
|
+
data.image_path,
|
|
513
|
+
data.file_name,
|
|
514
|
+
priority=MessagePriority.LOW
|
|
515
|
+
)
|
|
516
|
+
message_ids.append({"target": target, "message_id": msg_id})
|
|
517
|
+
|
|
518
|
+
# 记录群发日志
|
|
519
|
+
if _log_collector:
|
|
520
|
+
await _log_collector.add_log("system", {
|
|
521
|
+
"message": f"群发图片已加入队列: {len(data.targets)} 条消息",
|
|
522
|
+
"details": {"type": "image", "total": len(data.targets), "path": data.image_path[:50]}
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
return {
|
|
526
|
+
"status": "queued",
|
|
527
|
+
"total": len(data.targets),
|
|
528
|
+
"queue_size": _message_queue.queue_size,
|
|
529
|
+
"message_ids": message_ids,
|
|
530
|
+
"message": f"已将 {len(data.targets)} 条消息加入发送队列"
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
@router.post("/broadcast/file")
|
|
535
|
+
async def broadcast_file(data: BroadcastFileRequest):
|
|
536
|
+
"""群发文件(通过消息队列)"""
|
|
537
|
+
if not _qianxun_client or not _config_manager:
|
|
538
|
+
raise HTTPException(status_code=503, detail="服务未初始化")
|
|
539
|
+
|
|
540
|
+
if not _message_queue:
|
|
541
|
+
raise HTTPException(status_code=503, detail="消息队列未初始化")
|
|
542
|
+
|
|
543
|
+
robot_wxid = _config_manager.robot_wxid
|
|
544
|
+
if not robot_wxid:
|
|
545
|
+
raise HTTPException(status_code=400, detail="机器人wxid未配置")
|
|
546
|
+
|
|
547
|
+
if not data.targets:
|
|
548
|
+
raise HTTPException(status_code=400, detail="请选择发送目标")
|
|
549
|
+
|
|
550
|
+
if not data.file_path.strip():
|
|
551
|
+
raise HTTPException(status_code=400, detail="文件路径不能为空")
|
|
552
|
+
|
|
553
|
+
# 提前捕获文件路径和文件名,避免闭包问题
|
|
554
|
+
file_path = data.file_path
|
|
555
|
+
file_name = data.file_name
|
|
556
|
+
|
|
557
|
+
# 将所有消息加入队列
|
|
558
|
+
message_ids = []
|
|
559
|
+
for target in data.targets:
|
|
560
|
+
# 使用默认参数捕获当前值,避免闭包延迟绑定问题
|
|
561
|
+
async def send_file(t=target, fp=file_path, fn=file_name):
|
|
562
|
+
return await _qianxun_client.send_file(robot_wxid, t, fp, fn)
|
|
563
|
+
|
|
564
|
+
msg_id = await _message_queue.enqueue(
|
|
565
|
+
send_func=send_file,
|
|
566
|
+
priority=MessagePriority.LOW,
|
|
567
|
+
message_type="file",
|
|
568
|
+
target=target,
|
|
569
|
+
content_preview=file_path
|
|
570
|
+
)
|
|
571
|
+
message_ids.append({"target": target, "message_id": msg_id})
|
|
572
|
+
|
|
573
|
+
# 记录群发日志
|
|
574
|
+
if _log_collector:
|
|
575
|
+
await _log_collector.add_log("system", {
|
|
576
|
+
"message": f"群发文件已加入队列: {len(data.targets)} 条消息",
|
|
577
|
+
"details": {"type": "file", "total": len(data.targets), "path": data.file_path[:50]}
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
return {
|
|
581
|
+
"status": "queued",
|
|
582
|
+
"total": len(data.targets),
|
|
583
|
+
"queue_size": _message_queue.queue_size,
|
|
584
|
+
"message_ids": message_ids,
|
|
585
|
+
"message": f"已将 {len(data.targets)} 条消息加入发送队列"
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
@router.post("/broadcast/share_url")
|
|
590
|
+
async def broadcast_share_url(data: BroadcastShareUrlRequest):
|
|
591
|
+
"""群发分享链接(通过消息队列)"""
|
|
592
|
+
if not _qianxun_client or not _config_manager:
|
|
593
|
+
raise HTTPException(status_code=503, detail="服务未初始化")
|
|
594
|
+
|
|
595
|
+
if not _message_queue:
|
|
596
|
+
raise HTTPException(status_code=503, detail="消息队列未初始化")
|
|
597
|
+
|
|
598
|
+
robot_wxid = _config_manager.robot_wxid
|
|
599
|
+
if not robot_wxid:
|
|
600
|
+
raise HTTPException(status_code=400, detail="机器人wxid未配置")
|
|
601
|
+
|
|
602
|
+
if not data.targets:
|
|
603
|
+
raise HTTPException(status_code=400, detail="请选择发送目标")
|
|
604
|
+
|
|
605
|
+
if not data.title.strip():
|
|
606
|
+
raise HTTPException(status_code=400, detail="标题不能为空")
|
|
607
|
+
|
|
608
|
+
if not data.jump_url.strip():
|
|
609
|
+
raise HTTPException(status_code=400, detail="跳转地址不能为空")
|
|
610
|
+
|
|
611
|
+
# 将所有消息加入队列
|
|
612
|
+
message_ids = []
|
|
613
|
+
for target in data.targets:
|
|
614
|
+
async def send_share(t=target):
|
|
615
|
+
return await _qianxun_client.send_share_url(
|
|
616
|
+
robot_wxid, t, data.title, data.content,
|
|
617
|
+
data.jump_url, data.thumb_path, data.app
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
msg_id = await _message_queue.enqueue(
|
|
621
|
+
send_func=send_share,
|
|
622
|
+
priority=MessagePriority.LOW,
|
|
623
|
+
message_type="share_url",
|
|
624
|
+
target=target,
|
|
625
|
+
content_preview=data.title
|
|
626
|
+
)
|
|
627
|
+
message_ids.append({"target": target, "message_id": msg_id})
|
|
628
|
+
|
|
629
|
+
# 记录群发日志
|
|
630
|
+
if _log_collector:
|
|
631
|
+
await _log_collector.add_log("system", {
|
|
632
|
+
"message": f"群发链接已加入队列: {len(data.targets)} 条消息",
|
|
633
|
+
"details": {"type": "share_url", "total": len(data.targets), "title": data.title}
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
return {
|
|
637
|
+
"status": "queued",
|
|
638
|
+
"total": len(data.targets),
|
|
639
|
+
"queue_size": _message_queue.queue_size,
|
|
640
|
+
"message_ids": message_ids,
|
|
641
|
+
"message": f"已将 {len(data.targets)} 条消息加入发送队列"
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
@router.post("/broadcast/applet")
|
|
646
|
+
async def broadcast_applet(data: BroadcastAppletRequest):
|
|
647
|
+
"""群发小程序(通过消息队列)"""
|
|
648
|
+
if not _qianxun_client or not _config_manager:
|
|
649
|
+
raise HTTPException(status_code=503, detail="服务未初始化")
|
|
650
|
+
|
|
651
|
+
if not _message_queue:
|
|
652
|
+
raise HTTPException(status_code=503, detail="消息队列未初始化")
|
|
653
|
+
|
|
654
|
+
robot_wxid = _config_manager.robot_wxid
|
|
655
|
+
if not robot_wxid:
|
|
656
|
+
raise HTTPException(status_code=400, detail="机器人wxid未配置")
|
|
657
|
+
|
|
658
|
+
if not data.targets:
|
|
659
|
+
raise HTTPException(status_code=400, detail="请选择发送目标")
|
|
660
|
+
|
|
661
|
+
if not data.title.strip():
|
|
662
|
+
raise HTTPException(status_code=400, detail="标题不能为空")
|
|
663
|
+
|
|
664
|
+
if not data.gh.strip():
|
|
665
|
+
raise HTTPException(status_code=400, detail="小程序gh不能为空")
|
|
666
|
+
|
|
667
|
+
# 将所有消息加入队列
|
|
668
|
+
message_ids = []
|
|
669
|
+
for target in data.targets:
|
|
670
|
+
async def send_applet(t=target):
|
|
671
|
+
return await _qianxun_client.send_applet(
|
|
672
|
+
robot_wxid, t, data.title, data.content,
|
|
673
|
+
data.jump_path, data.gh, data.thumb_path
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
msg_id = await _message_queue.enqueue(
|
|
677
|
+
send_func=send_applet,
|
|
678
|
+
priority=MessagePriority.LOW,
|
|
679
|
+
message_type="applet",
|
|
680
|
+
target=target,
|
|
681
|
+
content_preview=data.title
|
|
682
|
+
)
|
|
683
|
+
message_ids.append({"target": target, "message_id": msg_id})
|
|
684
|
+
|
|
685
|
+
# 记录群发日志
|
|
686
|
+
if _log_collector:
|
|
687
|
+
await _log_collector.add_log("system", {
|
|
688
|
+
"message": f"群发小程序已加入队列: {len(data.targets)} 条消息",
|
|
689
|
+
"details": {"type": "applet", "total": len(data.targets), "title": data.title, "gh": data.gh}
|
|
690
|
+
})
|
|
691
|
+
|
|
692
|
+
return {
|
|
693
|
+
"status": "queued",
|
|
694
|
+
"total": len(data.targets),
|
|
695
|
+
"queue_size": _message_queue.queue_size,
|
|
696
|
+
"message_ids": message_ids,
|
|
697
|
+
"message": f"已将 {len(data.targets)} 条消息加入发送队列"
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
# ============ 节假日API ============
|
|
702
|
+
|
|
703
|
+
# 尝试导入中国节假日库
|
|
704
|
+
try:
|
|
705
|
+
import chinese_calendar
|
|
706
|
+
HAS_CHINESE_CALENDAR = True
|
|
707
|
+
except ImportError:
|
|
708
|
+
HAS_CHINESE_CALENDAR = False
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
@router.get("/holidays/{year}")
|
|
712
|
+
async def get_holidays(year: int):
|
|
713
|
+
"""获取指定年份的中国法定节假日列表
|
|
714
|
+
|
|
715
|
+
Args:
|
|
716
|
+
year: 年份,如 2026
|
|
717
|
+
|
|
718
|
+
Returns:
|
|
719
|
+
{
|
|
720
|
+
"year": 2026,
|
|
721
|
+
"holidays": ["2026-01-01", "2026-01-28", ...], # 节假日列表
|
|
722
|
+
"workdays": ["2026-01-25", ...], # 调休补班日列表
|
|
723
|
+
"available": true # 是否有节假日数据
|
|
724
|
+
}
|
|
725
|
+
"""
|
|
726
|
+
if not HAS_CHINESE_CALENDAR:
|
|
727
|
+
return {
|
|
728
|
+
"year": year,
|
|
729
|
+
"holidays": [],
|
|
730
|
+
"workdays": [],
|
|
731
|
+
"available": False,
|
|
732
|
+
"error": "chinese-calendar 库未安装"
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
try:
|
|
736
|
+
from datetime import date, timedelta
|
|
737
|
+
|
|
738
|
+
holidays: List[str] = []
|
|
739
|
+
workdays: List[str] = []
|
|
740
|
+
|
|
741
|
+
# 遍历全年每一天
|
|
742
|
+
start_date = date(year, 1, 1)
|
|
743
|
+
end_date = date(year, 12, 31)
|
|
744
|
+
current = start_date
|
|
745
|
+
|
|
746
|
+
while current <= end_date:
|
|
747
|
+
try:
|
|
748
|
+
is_holiday = chinese_calendar.is_holiday(current)
|
|
749
|
+
is_workday = chinese_calendar.is_workday(current)
|
|
750
|
+
weekday = current.weekday()
|
|
751
|
+
|
|
752
|
+
if is_holiday:
|
|
753
|
+
holidays.append(current.isoformat())
|
|
754
|
+
elif is_workday and weekday >= 5:
|
|
755
|
+
# 周末但是工作日 = 调休补班
|
|
756
|
+
workdays.append(current.isoformat())
|
|
757
|
+
except Exception:
|
|
758
|
+
pass
|
|
759
|
+
current += timedelta(days=1)
|
|
760
|
+
|
|
761
|
+
return {
|
|
762
|
+
"year": year,
|
|
763
|
+
"holidays": holidays,
|
|
764
|
+
"workdays": workdays,
|
|
765
|
+
"available": True
|
|
766
|
+
}
|
|
767
|
+
except Exception as e:
|
|
768
|
+
logger.error(f"获取节假日数据失败: {e}")
|
|
769
|
+
return {
|
|
770
|
+
"year": year,
|
|
771
|
+
"holidays": [],
|
|
772
|
+
"workdays": [],
|
|
773
|
+
"available": False,
|
|
774
|
+
"error": str(e)
|
|
775
|
+
}
|