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,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
@@ -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("所有缓存已清空")
@@ -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"]