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,364 @@
|
|
|
1
|
+
"""统一消息发送队列
|
|
2
|
+
|
|
3
|
+
功能:
|
|
4
|
+
- 所有消息发送请求统一排队
|
|
5
|
+
- 随机延迟发送,避免并发过高
|
|
6
|
+
- 支持优先级(高优先级插队)
|
|
7
|
+
- 失败重试机制
|
|
8
|
+
- 队列状态监控
|
|
9
|
+
"""
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
import random
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from enum import IntEnum
|
|
16
|
+
from typing import Any, Callable, Awaitable, Optional, Dict, List
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MessagePriority(IntEnum):
|
|
23
|
+
"""消息优先级(数字越小优先级越高)"""
|
|
24
|
+
HIGH = 1 # 普通回复消息(用户触发)
|
|
25
|
+
NORMAL = 5 # 定时任务消息
|
|
26
|
+
LOW = 10 # 群发消息
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(order=True)
|
|
30
|
+
class QueuedMessage:
|
|
31
|
+
"""队列中的消息"""
|
|
32
|
+
priority: int
|
|
33
|
+
timestamp: float = field(compare=False)
|
|
34
|
+
message_id: str = field(compare=False)
|
|
35
|
+
send_func: Callable[[], Awaitable[bool]] = field(compare=False)
|
|
36
|
+
message_type: str = field(compare=False, default="unknown")
|
|
37
|
+
target: str = field(compare=False, default="")
|
|
38
|
+
content_preview: str = field(compare=False, default="")
|
|
39
|
+
retry_count: int = field(compare=False, default=0)
|
|
40
|
+
max_retries: int = field(compare=False, default=2)
|
|
41
|
+
callback: Optional[Callable[[bool, str], Awaitable[None]]] = field(compare=False, default=None)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class MessageQueue:
|
|
45
|
+
"""统一消息发送队列"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
min_interval: float = 1.0,
|
|
50
|
+
max_interval: float = 3.0,
|
|
51
|
+
batch_min_interval: float = 2.0,
|
|
52
|
+
batch_max_interval: float = 5.0,
|
|
53
|
+
max_queue_size: int = 1000
|
|
54
|
+
):
|
|
55
|
+
"""初始化消息队列
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
min_interval: 高优先级消息最小间隔(秒)
|
|
59
|
+
max_interval: 高优先级消息最大间隔(秒)
|
|
60
|
+
batch_min_interval: 低优先级消息最小间隔(秒)
|
|
61
|
+
batch_max_interval: 低优先级消息最大间隔(秒)
|
|
62
|
+
max_queue_size: 队列最大容量
|
|
63
|
+
"""
|
|
64
|
+
self._queue: asyncio.PriorityQueue = asyncio.PriorityQueue(maxsize=max_queue_size)
|
|
65
|
+
self._min_interval = min_interval
|
|
66
|
+
self._max_interval = max_interval
|
|
67
|
+
self._batch_min_interval = batch_min_interval
|
|
68
|
+
self._batch_max_interval = batch_max_interval
|
|
69
|
+
self._max_queue_size = max_queue_size
|
|
70
|
+
|
|
71
|
+
self._running = False
|
|
72
|
+
self._worker_task: Optional[asyncio.Task] = None
|
|
73
|
+
self._last_send_time: float = 0
|
|
74
|
+
self._message_counter: int = 0
|
|
75
|
+
|
|
76
|
+
# 统计信息
|
|
77
|
+
self._stats = {
|
|
78
|
+
"total_sent": 0,
|
|
79
|
+
"total_failed": 0,
|
|
80
|
+
"total_retried": 0,
|
|
81
|
+
"started_at": None
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# 最近发送记录(用于监控)
|
|
85
|
+
self._recent_sends: List[Dict[str, Any]] = []
|
|
86
|
+
self._max_recent = 50
|
|
87
|
+
|
|
88
|
+
def update_config(
|
|
89
|
+
self,
|
|
90
|
+
min_interval: float = None,
|
|
91
|
+
max_interval: float = None,
|
|
92
|
+
batch_min_interval: float = None,
|
|
93
|
+
batch_max_interval: float = None
|
|
94
|
+
):
|
|
95
|
+
"""更新配置"""
|
|
96
|
+
if min_interval is not None:
|
|
97
|
+
self._min_interval = min_interval
|
|
98
|
+
if max_interval is not None:
|
|
99
|
+
self._max_interval = max_interval
|
|
100
|
+
if batch_min_interval is not None:
|
|
101
|
+
self._batch_min_interval = batch_min_interval
|
|
102
|
+
if batch_max_interval is not None:
|
|
103
|
+
self._batch_max_interval = batch_max_interval
|
|
104
|
+
logger.info(f"消息队列配置已更新: interval={self._min_interval}-{self._max_interval}s, "
|
|
105
|
+
f"batch={self._batch_min_interval}-{self._batch_max_interval}s")
|
|
106
|
+
|
|
107
|
+
async def start(self):
|
|
108
|
+
"""启动队列处理"""
|
|
109
|
+
if self._running:
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
self._running = True
|
|
113
|
+
self._stats["started_at"] = datetime.now().isoformat()
|
|
114
|
+
self._worker_task = asyncio.create_task(self._worker())
|
|
115
|
+
logger.info("消息队列已启动")
|
|
116
|
+
|
|
117
|
+
async def stop(self):
|
|
118
|
+
"""停止队列处理"""
|
|
119
|
+
self._running = False
|
|
120
|
+
if self._worker_task:
|
|
121
|
+
self._worker_task.cancel()
|
|
122
|
+
try:
|
|
123
|
+
await self._worker_task
|
|
124
|
+
except asyncio.CancelledError:
|
|
125
|
+
pass
|
|
126
|
+
logger.info(f"消息队列已停止,统计: 发送={self._stats['total_sent']}, "
|
|
127
|
+
f"失败={self._stats['total_failed']}, 重试={self._stats['total_retried']}")
|
|
128
|
+
|
|
129
|
+
def _generate_message_id(self) -> str:
|
|
130
|
+
"""生成消息ID"""
|
|
131
|
+
self._message_counter += 1
|
|
132
|
+
return f"msg_{int(time.time())}_{self._message_counter}"
|
|
133
|
+
|
|
134
|
+
async def enqueue(
|
|
135
|
+
self,
|
|
136
|
+
send_func: Callable[[], Awaitable[bool]],
|
|
137
|
+
priority: MessagePriority = MessagePriority.NORMAL,
|
|
138
|
+
message_type: str = "unknown",
|
|
139
|
+
target: str = "",
|
|
140
|
+
content_preview: str = "",
|
|
141
|
+
max_retries: int = 2,
|
|
142
|
+
callback: Optional[Callable[[bool, str], Awaitable[None]]] = None
|
|
143
|
+
) -> str:
|
|
144
|
+
"""添加消息到队列
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
send_func: 发送函数,返回是否成功
|
|
148
|
+
priority: 消息优先级
|
|
149
|
+
message_type: 消息类型(text, image, file 等)
|
|
150
|
+
target: 目标wxid
|
|
151
|
+
content_preview: 内容预览(用于日志)
|
|
152
|
+
max_retries: 最大重试次数
|
|
153
|
+
callback: 发送完成回调,参数为 (success, message_id)
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
消息ID
|
|
157
|
+
"""
|
|
158
|
+
message_id = self._generate_message_id()
|
|
159
|
+
|
|
160
|
+
msg = QueuedMessage(
|
|
161
|
+
priority=priority,
|
|
162
|
+
timestamp=time.time(),
|
|
163
|
+
message_id=message_id,
|
|
164
|
+
send_func=send_func,
|
|
165
|
+
message_type=message_type,
|
|
166
|
+
target=target,
|
|
167
|
+
content_preview=content_preview[:50] if content_preview else "",
|
|
168
|
+
max_retries=max_retries,
|
|
169
|
+
callback=callback
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
self._queue.put_nowait(msg)
|
|
174
|
+
logger.debug(f"消息入队: {message_id}, 优先级={priority}, 类型={message_type}, "
|
|
175
|
+
f"目标={target}, 队列长度={self._queue.qsize()}")
|
|
176
|
+
return message_id
|
|
177
|
+
except asyncio.QueueFull:
|
|
178
|
+
logger.error(f"消息队列已满,丢弃消息: {message_id}")
|
|
179
|
+
if callback:
|
|
180
|
+
await callback(False, message_id)
|
|
181
|
+
return message_id
|
|
182
|
+
|
|
183
|
+
async def enqueue_text(
|
|
184
|
+
self,
|
|
185
|
+
qianxun_client,
|
|
186
|
+
robot_wxid: str,
|
|
187
|
+
target: str,
|
|
188
|
+
message: str,
|
|
189
|
+
priority: MessagePriority = MessagePriority.NORMAL,
|
|
190
|
+
callback: Optional[Callable[[bool, str], Awaitable[None]]] = None
|
|
191
|
+
) -> str:
|
|
192
|
+
"""便捷方法:添加文本消息到队列"""
|
|
193
|
+
async def send():
|
|
194
|
+
return await qianxun_client.send_text(robot_wxid, target, message)
|
|
195
|
+
|
|
196
|
+
return await self.enqueue(
|
|
197
|
+
send_func=send,
|
|
198
|
+
priority=priority,
|
|
199
|
+
message_type="text",
|
|
200
|
+
target=target,
|
|
201
|
+
content_preview=message,
|
|
202
|
+
callback=callback
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
async def enqueue_image(
|
|
206
|
+
self,
|
|
207
|
+
qianxun_client,
|
|
208
|
+
robot_wxid: str,
|
|
209
|
+
target: str,
|
|
210
|
+
image_path: str,
|
|
211
|
+
file_name: str = "",
|
|
212
|
+
priority: MessagePriority = MessagePriority.NORMAL,
|
|
213
|
+
callback: Optional[Callable[[bool, str], Awaitable[None]]] = None
|
|
214
|
+
) -> str:
|
|
215
|
+
"""便捷方法:添加图片消息到队列"""
|
|
216
|
+
async def send():
|
|
217
|
+
return await qianxun_client.send_image(robot_wxid, target, image_path, file_name)
|
|
218
|
+
|
|
219
|
+
return await self.enqueue(
|
|
220
|
+
send_func=send,
|
|
221
|
+
priority=priority,
|
|
222
|
+
message_type="image",
|
|
223
|
+
target=target,
|
|
224
|
+
content_preview=image_path,
|
|
225
|
+
callback=callback
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
async def _worker(self):
|
|
229
|
+
"""队列处理工作线程"""
|
|
230
|
+
logger.info("消息队列工作线程已启动")
|
|
231
|
+
|
|
232
|
+
while self._running:
|
|
233
|
+
try:
|
|
234
|
+
# 等待消息,超时1秒检查运行状态
|
|
235
|
+
try:
|
|
236
|
+
msg: QueuedMessage = await asyncio.wait_for(
|
|
237
|
+
self._queue.get(), timeout=1.0
|
|
238
|
+
)
|
|
239
|
+
except asyncio.TimeoutError:
|
|
240
|
+
continue
|
|
241
|
+
|
|
242
|
+
# 计算延迟
|
|
243
|
+
delay = self._calculate_delay(msg.priority)
|
|
244
|
+
|
|
245
|
+
# 等待延迟时间
|
|
246
|
+
elapsed = time.time() - self._last_send_time
|
|
247
|
+
if elapsed < delay:
|
|
248
|
+
wait_time = delay - elapsed
|
|
249
|
+
logger.debug(f"等待 {wait_time:.2f}s 后发送: {msg.message_id}")
|
|
250
|
+
await asyncio.sleep(wait_time)
|
|
251
|
+
|
|
252
|
+
# 发送消息
|
|
253
|
+
success = await self._send_message(msg)
|
|
254
|
+
|
|
255
|
+
# 更新最后发送时间
|
|
256
|
+
self._last_send_time = time.time()
|
|
257
|
+
|
|
258
|
+
# 标记任务完成
|
|
259
|
+
self._queue.task_done()
|
|
260
|
+
|
|
261
|
+
except asyncio.CancelledError:
|
|
262
|
+
break
|
|
263
|
+
except Exception as e:
|
|
264
|
+
logger.error(f"消息队列工作线程异常: {e}", exc_info=True)
|
|
265
|
+
await asyncio.sleep(1)
|
|
266
|
+
|
|
267
|
+
logger.info("消息队列工作线程已停止")
|
|
268
|
+
|
|
269
|
+
def _calculate_delay(self, priority: int) -> float:
|
|
270
|
+
"""根据优先级计算延迟时间"""
|
|
271
|
+
if priority <= MessagePriority.HIGH:
|
|
272
|
+
# 高优先级:较短延迟
|
|
273
|
+
return random.uniform(self._min_interval, self._max_interval)
|
|
274
|
+
else:
|
|
275
|
+
# 普通/低优先级:较长延迟
|
|
276
|
+
return random.uniform(self._batch_min_interval, self._batch_max_interval)
|
|
277
|
+
|
|
278
|
+
async def _send_message(self, msg: QueuedMessage) -> bool:
|
|
279
|
+
"""发送单条消息"""
|
|
280
|
+
try:
|
|
281
|
+
success = await msg.send_func()
|
|
282
|
+
|
|
283
|
+
# 记录发送结果
|
|
284
|
+
record = {
|
|
285
|
+
"message_id": msg.message_id,
|
|
286
|
+
"type": msg.message_type,
|
|
287
|
+
"target": msg.target,
|
|
288
|
+
"priority": msg.priority,
|
|
289
|
+
"success": success,
|
|
290
|
+
"retry_count": msg.retry_count,
|
|
291
|
+
"sent_at": datetime.now().isoformat()
|
|
292
|
+
}
|
|
293
|
+
self._recent_sends.append(record)
|
|
294
|
+
if len(self._recent_sends) > self._max_recent:
|
|
295
|
+
self._recent_sends.pop(0)
|
|
296
|
+
|
|
297
|
+
if success:
|
|
298
|
+
self._stats["total_sent"] += 1
|
|
299
|
+
logger.info(f"消息发送成功: {msg.message_id} -> {msg.target}")
|
|
300
|
+
|
|
301
|
+
if msg.callback:
|
|
302
|
+
await msg.callback(True, msg.message_id)
|
|
303
|
+
return True
|
|
304
|
+
else:
|
|
305
|
+
# 发送失败,尝试重试
|
|
306
|
+
return await self._handle_failure(msg, "发送返回失败")
|
|
307
|
+
|
|
308
|
+
except Exception as e:
|
|
309
|
+
logger.error(f"消息发送异常: {msg.message_id}, {e}")
|
|
310
|
+
return await self._handle_failure(msg, str(e))
|
|
311
|
+
|
|
312
|
+
async def _handle_failure(self, msg: QueuedMessage, error: str) -> bool:
|
|
313
|
+
"""处理发送失败"""
|
|
314
|
+
if msg.retry_count < msg.max_retries:
|
|
315
|
+
# 重试
|
|
316
|
+
msg.retry_count += 1
|
|
317
|
+
self._stats["total_retried"] += 1
|
|
318
|
+
logger.warning(f"消息发送失败,重试 {msg.retry_count}/{msg.max_retries}: "
|
|
319
|
+
f"{msg.message_id}, 错误: {error}")
|
|
320
|
+
|
|
321
|
+
# 重新入队(降低优先级避免阻塞其他消息)
|
|
322
|
+
msg.priority = max(msg.priority, MessagePriority.LOW)
|
|
323
|
+
try:
|
|
324
|
+
self._queue.put_nowait(msg)
|
|
325
|
+
except asyncio.QueueFull:
|
|
326
|
+
logger.error(f"重试入队失败,队列已满: {msg.message_id}")
|
|
327
|
+
self._stats["total_failed"] += 1
|
|
328
|
+
if msg.callback:
|
|
329
|
+
await msg.callback(False, msg.message_id)
|
|
330
|
+
return False
|
|
331
|
+
else:
|
|
332
|
+
# 超过重试次数
|
|
333
|
+
self._stats["total_failed"] += 1
|
|
334
|
+
logger.error(f"消息发送最终失败: {msg.message_id} -> {msg.target}, 错误: {error}")
|
|
335
|
+
|
|
336
|
+
if msg.callback:
|
|
337
|
+
await msg.callback(False, msg.message_id)
|
|
338
|
+
return False
|
|
339
|
+
|
|
340
|
+
def get_status(self) -> Dict[str, Any]:
|
|
341
|
+
"""获取队列状态"""
|
|
342
|
+
return {
|
|
343
|
+
"running": self._running,
|
|
344
|
+
"queue_size": self._queue.qsize(),
|
|
345
|
+
"max_queue_size": self._max_queue_size,
|
|
346
|
+
"config": {
|
|
347
|
+
"min_interval": self._min_interval,
|
|
348
|
+
"max_interval": self._max_interval,
|
|
349
|
+
"batch_min_interval": self._batch_min_interval,
|
|
350
|
+
"batch_max_interval": self._batch_max_interval
|
|
351
|
+
},
|
|
352
|
+
"stats": self._stats.copy(),
|
|
353
|
+
"recent_sends": self._recent_sends[-10:] # 最近10条
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
@property
|
|
357
|
+
def queue_size(self) -> int:
|
|
358
|
+
"""当前队列长度"""
|
|
359
|
+
return self._queue.qsize()
|
|
360
|
+
|
|
361
|
+
@property
|
|
362
|
+
def is_running(self) -> bool:
|
|
363
|
+
"""是否运行中"""
|
|
364
|
+
return self._running
|
src/core/state_store.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""状态存储器 - 管理内存状态和持久化"""
|
|
2
|
+
import asyncio
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
from collections import OrderedDict
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional, Dict, Any
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StateStore:
|
|
14
|
+
"""状态存储器"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, data_dir: str = "data"):
|
|
17
|
+
self._data_dir = Path(data_dir)
|
|
18
|
+
self._data_dir.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
|
|
20
|
+
# 数据存储
|
|
21
|
+
self._processed_msg_ids: OrderedDict[str, float] = OrderedDict()
|
|
22
|
+
self._image_cache: OrderedDict[tuple, Dict[str, Any]] = OrderedDict()
|
|
23
|
+
self._group_id_mapping: OrderedDict[str, str] = OrderedDict()
|
|
24
|
+
self._message_user_mapping: OrderedDict[str, str] = OrderedDict()
|
|
25
|
+
|
|
26
|
+
# 清理任务
|
|
27
|
+
self._cleanup_task: Optional[asyncio.Task] = None
|
|
28
|
+
self._running = False
|
|
29
|
+
|
|
30
|
+
# 配置限制
|
|
31
|
+
self.max_msg_ids = 10000
|
|
32
|
+
self.max_image_cache = 1000
|
|
33
|
+
self.max_group_mapping = 10000
|
|
34
|
+
self.max_message_mapping = 10000
|
|
35
|
+
self.msg_id_ttl = 60
|
|
36
|
+
self.image_cache_ttl = 120
|
|
37
|
+
|
|
38
|
+
# 持久化
|
|
39
|
+
self._state_file = self._data_dir / "state.json"
|
|
40
|
+
self._persist_lock = asyncio.Lock()
|
|
41
|
+
self._dirty = False
|
|
42
|
+
|
|
43
|
+
def _enforce_limit(self, collection: OrderedDict, max_size: int):
|
|
44
|
+
"""通用的集合大小限制执行"""
|
|
45
|
+
while len(collection) > max_size:
|
|
46
|
+
collection.popitem(last=False)
|
|
47
|
+
|
|
48
|
+
def _set_mapping(self, collection: OrderedDict, key: str, value: Any, max_size: int):
|
|
49
|
+
"""通用的映射设置方法"""
|
|
50
|
+
collection[key] = value
|
|
51
|
+
self._dirty = True
|
|
52
|
+
collection.move_to_end(key)
|
|
53
|
+
self._enforce_limit(collection, max_size)
|
|
54
|
+
|
|
55
|
+
async def start(self):
|
|
56
|
+
"""启动状态存储器"""
|
|
57
|
+
logger.info("启动状态存储器...")
|
|
58
|
+
await self._load_persisted()
|
|
59
|
+
self._running = True
|
|
60
|
+
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
|
|
61
|
+
logger.info("状态存储器已启动")
|
|
62
|
+
|
|
63
|
+
async def stop(self):
|
|
64
|
+
"""停止状态存储器"""
|
|
65
|
+
logger.info("停止状态存储器...")
|
|
66
|
+
self._running = False
|
|
67
|
+
if self._cleanup_task:
|
|
68
|
+
self._cleanup_task.cancel()
|
|
69
|
+
try:
|
|
70
|
+
await self._cleanup_task
|
|
71
|
+
except asyncio.CancelledError:
|
|
72
|
+
pass
|
|
73
|
+
await self._persist()
|
|
74
|
+
logger.info("状态存储器已停止")
|
|
75
|
+
|
|
76
|
+
def is_duplicate_message(self, msg_id: str) -> bool:
|
|
77
|
+
"""检查消息是否重复"""
|
|
78
|
+
if msg_id in self._processed_msg_ids:
|
|
79
|
+
return True
|
|
80
|
+
self._processed_msg_ids[msg_id] = time.time()
|
|
81
|
+
self._dirty = True
|
|
82
|
+
self._enforce_limit(self._processed_msg_ids, self.max_msg_ids)
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
def cache_image(self, group_id: str, user_id: str, image_url: str):
|
|
86
|
+
"""缓存图片"""
|
|
87
|
+
cache_key = (group_id, user_id)
|
|
88
|
+
self._image_cache[cache_key] = {"image_url": image_url, "timestamp": time.time()}
|
|
89
|
+
self._dirty = True
|
|
90
|
+
self._image_cache.move_to_end(cache_key)
|
|
91
|
+
self._enforce_limit(self._image_cache, self.max_image_cache)
|
|
92
|
+
logger.debug(f"缓存图片: [{group_id}][{user_id}] -> {image_url}")
|
|
93
|
+
|
|
94
|
+
def get_cached_image(self, group_id: str, user_id: str) -> Optional[str]:
|
|
95
|
+
"""获取缓存的图片"""
|
|
96
|
+
cache_key = (group_id, user_id)
|
|
97
|
+
cached = self._image_cache.get(cache_key)
|
|
98
|
+
|
|
99
|
+
if cached:
|
|
100
|
+
age = time.time() - cached["timestamp"]
|
|
101
|
+
if age < self.image_cache_ttl:
|
|
102
|
+
del self._image_cache[cache_key]
|
|
103
|
+
self._dirty = True
|
|
104
|
+
logger.info(f"命中图片缓存: [{group_id}][{user_id}], 年龄: {age:.1f}秒")
|
|
105
|
+
return cached["image_url"]
|
|
106
|
+
else:
|
|
107
|
+
logger.debug(f"图片缓存已过期: {age:.1f}秒 > {self.image_cache_ttl}秒")
|
|
108
|
+
del self._image_cache[cache_key]
|
|
109
|
+
self._dirty = True
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
def set_group_mapping(self, numeric_id: str, original_id: str):
|
|
113
|
+
"""设置群ID映射"""
|
|
114
|
+
self._set_mapping(self._group_id_mapping, numeric_id, original_id, self.max_group_mapping)
|
|
115
|
+
|
|
116
|
+
def get_original_group_id(self, numeric_id: str) -> str:
|
|
117
|
+
"""获取原始群ID"""
|
|
118
|
+
return self._group_id_mapping.get(numeric_id, numeric_id)
|
|
119
|
+
|
|
120
|
+
def set_message_user(self, message_id: str, user_id: str):
|
|
121
|
+
"""设置消息-用户映射"""
|
|
122
|
+
self._set_mapping(self._message_user_mapping, message_id, user_id, self.max_message_mapping)
|
|
123
|
+
|
|
124
|
+
def get_user_by_message(self, message_id: str) -> Optional[str]:
|
|
125
|
+
"""根据消息ID获取用户ID"""
|
|
126
|
+
return self._message_user_mapping.get(message_id)
|
|
127
|
+
|
|
128
|
+
async def _cleanup_loop(self):
|
|
129
|
+
"""定期清理过期数据(每60秒)"""
|
|
130
|
+
while self._running:
|
|
131
|
+
try:
|
|
132
|
+
await asyncio.sleep(60)
|
|
133
|
+
if not self._running:
|
|
134
|
+
break
|
|
135
|
+
self._cleanup_expired()
|
|
136
|
+
if self._dirty:
|
|
137
|
+
await self._persist()
|
|
138
|
+
except asyncio.CancelledError:
|
|
139
|
+
break
|
|
140
|
+
except Exception as e:
|
|
141
|
+
logger.error(f"清理任务异常: {e}", exc_info=True)
|
|
142
|
+
|
|
143
|
+
def _cleanup_expired(self):
|
|
144
|
+
"""清理过期数据"""
|
|
145
|
+
now = time.time()
|
|
146
|
+
|
|
147
|
+
# 清理过期的消息ID
|
|
148
|
+
expired_msg_ids = [k for k, v in self._processed_msg_ids.items() if now - v > self.msg_id_ttl]
|
|
149
|
+
for k in expired_msg_ids:
|
|
150
|
+
del self._processed_msg_ids[k]
|
|
151
|
+
|
|
152
|
+
# 清理过期的图片缓存
|
|
153
|
+
expired_images = [k for k, v in self._image_cache.items() if now - v["timestamp"] > self.image_cache_ttl]
|
|
154
|
+
for k in expired_images:
|
|
155
|
+
del self._image_cache[k]
|
|
156
|
+
|
|
157
|
+
if expired_msg_ids or expired_images:
|
|
158
|
+
self._dirty = True
|
|
159
|
+
logger.debug(f"清理过期数据: 消息ID={len(expired_msg_ids)}, 图片缓存={len(expired_images)}")
|
|
160
|
+
|
|
161
|
+
async def _persist(self):
|
|
162
|
+
"""异步持久化数据"""
|
|
163
|
+
async with self._persist_lock:
|
|
164
|
+
try:
|
|
165
|
+
data = {
|
|
166
|
+
"processed_msg_ids": dict(self._processed_msg_ids),
|
|
167
|
+
"group_id_mapping": dict(self._group_id_mapping),
|
|
168
|
+
"message_user_mapping": dict(self._message_user_mapping),
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
def write_file():
|
|
172
|
+
with open(self._state_file, 'w', encoding='utf-8') as f:
|
|
173
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
174
|
+
|
|
175
|
+
await asyncio.get_event_loop().run_in_executor(None, write_file)
|
|
176
|
+
self._dirty = False
|
|
177
|
+
logger.debug(f"状态已持久化到 {self._state_file}")
|
|
178
|
+
except Exception as e:
|
|
179
|
+
logger.error(f"持久化状态失败: {e}", exc_info=True)
|
|
180
|
+
|
|
181
|
+
async def _load_persisted(self):
|
|
182
|
+
"""加载持久化数据"""
|
|
183
|
+
if not self._state_file.exists():
|
|
184
|
+
logger.info("没有找到持久化文件,使用空状态启动")
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
def read_file():
|
|
189
|
+
with open(self._state_file, 'r', encoding='utf-8') as f:
|
|
190
|
+
return json.load(f)
|
|
191
|
+
|
|
192
|
+
data = await asyncio.get_event_loop().run_in_executor(None, read_file)
|
|
193
|
+
now = time.time()
|
|
194
|
+
|
|
195
|
+
# 恢复消息ID记录(过滤过期的)
|
|
196
|
+
if "processed_msg_ids" in data:
|
|
197
|
+
self._processed_msg_ids = OrderedDict(
|
|
198
|
+
(k, v) for k, v in data["processed_msg_ids"].items()
|
|
199
|
+
if now - v <= self.msg_id_ttl
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# 恢复群ID映射
|
|
203
|
+
if "group_id_mapping" in data:
|
|
204
|
+
self._group_id_mapping = OrderedDict(data["group_id_mapping"])
|
|
205
|
+
|
|
206
|
+
# 恢复消息-用户映射
|
|
207
|
+
if "message_user_mapping" in data:
|
|
208
|
+
self._message_user_mapping = OrderedDict(data["message_user_mapping"])
|
|
209
|
+
|
|
210
|
+
logger.info(f"已从 {self._state_file} 恢复状态: "
|
|
211
|
+
f"消息ID={len(self._processed_msg_ids)}, "
|
|
212
|
+
f"群映射={len(self._group_id_mapping)}, "
|
|
213
|
+
f"消息映射={len(self._message_user_mapping)}")
|
|
214
|
+
|
|
215
|
+
except json.JSONDecodeError as e:
|
|
216
|
+
logger.warning(f"持久化文件格式错误,使用空状态启动: {e}")
|
|
217
|
+
except Exception as e:
|
|
218
|
+
logger.error(f"加载持久化数据失败: {e}", exc_info=True)
|
|
219
|
+
|
|
220
|
+
def get_stats(self) -> Dict[str, int]:
|
|
221
|
+
"""获取状态统计信息"""
|
|
222
|
+
return {
|
|
223
|
+
"processed_msg_ids": len(self._processed_msg_ids),
|
|
224
|
+
"image_cache": len(self._image_cache),
|
|
225
|
+
"group_id_mapping": len(self._group_id_mapping),
|
|
226
|
+
"message_user_mapping": len(self._message_user_mapping),
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
def clear_image_cache(self):
|
|
230
|
+
"""清空图片缓存"""
|
|
231
|
+
self._image_cache.clear()
|
|
232
|
+
self._dirty = True
|
|
233
|
+
logger.info("图片缓存已清空")
|
|
234
|
+
|
|
235
|
+
def clear_all(self):
|
|
236
|
+
"""清空所有缓存"""
|
|
237
|
+
self._processed_msg_ids.clear()
|
|
238
|
+
self._image_cache.clear()
|
|
239
|
+
self._group_id_mapping.clear()
|
|
240
|
+
self._message_user_mapping.clear()
|
|
241
|
+
self._dirty = True
|
|
242
|
+
logger.info("所有缓存已清空")
|
src/handlers/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Handlers layer
|
|
2
|
+
"""Business logic handlers for message processing and task scheduling."""
|
|
3
|
+
|
|
4
|
+
from .message_parser import MessageParser, ParsedMessage
|
|
5
|
+
from .message_handler import MessageHandler
|
|
6
|
+
from .scheduler import TaskScheduler
|
|
7
|
+
|
|
8
|
+
__all__ = ["MessageParser", "ParsedMessage", "MessageHandler", "TaskScheduler"]
|