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,411 @@
1
+ """配置管理器 - 配置加载、验证、保存和热更新
2
+
3
+ 功能:
4
+ - 配置加载(使用TextProcessor处理换行符)
5
+ - 配置验证(必填字段、格式检查)
6
+ - 配置保存(使用TextProcessor处理换行符)
7
+ - 观察者模式(配置变更通知)
8
+ - 统一的robot_wxid获取
9
+ - 变更日志记录
10
+ - 敏感配置从环境变量读取(auth、dingtalk)
11
+ """
12
+ import asyncio
13
+ import logging
14
+ import os
15
+ import yaml
16
+ from pathlib import Path
17
+ from typing import Dict, Any, List, Callable, Awaitable, Tuple
18
+
19
+ from ..utils.text_processor import TextProcessor
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ # 默认忽略的 wxid(系统服务号,避免机器人与它们对话形成循环)
25
+ DEFAULT_IGNORE_WXIDS = [
26
+ 'qqsafe', # QQ安全中心
27
+ 'weixin', # 微信团队
28
+ 'filehelper', # 文件传输助手
29
+ 'floatbottle', # 漂流瓶
30
+ 'medianote', # 语音记事本
31
+ 'newsapp', # 腾讯新闻
32
+ ]
33
+
34
+ # 忽略的 wxid 前缀(公众号、服务号等)
35
+ DEFAULT_IGNORE_PREFIXES = [
36
+ 'gh_', # 公众号/服务号/游戏号
37
+ 'wxh_', # 微信小程序
38
+ ]
39
+
40
+ # 从环境变量读取的配置键(这些配置不会被保存到config.yaml)
41
+ ENV_CONFIG_KEYS = ['auth', 'dingtalk']
42
+
43
+
44
+ class ConfigManager:
45
+ """配置管理器"""
46
+
47
+ def __init__(self, config_path: str = "config.yaml"):
48
+ self._config: Dict[str, Any] = {}
49
+ self._config_path = Path(config_path)
50
+ self._observers: List[Callable[[Dict, Dict], Awaitable[None]]] = []
51
+ self._lock = asyncio.Lock()
52
+ self._text_processor = TextProcessor()
53
+ self._env_config: Dict[str, Any] = {} # 从环境变量读取的配置
54
+
55
+ @property
56
+ def robot_wxid(self) -> str:
57
+ """统一获取机器人wxid"""
58
+ return self._config.get('qianxun', {}).get('robot_wxid', '')
59
+
60
+ @property
61
+ def config(self) -> Dict[str, Any]:
62
+ """获取当前配置(只读)"""
63
+ return self._config.copy()
64
+
65
+ def _load_env_config(self) -> Dict[str, Any]:
66
+ """从环境变量加载敏感配置
67
+
68
+ 环境变量命名规则:
69
+ - AUTH_ENABLED: 是否启用认证 (true/false)
70
+ - AUTH_JWT_SECRET: JWT密钥
71
+ - AUTH_JWT_EXPIRE_HOURS: JWT过期时间(小时)
72
+ - DINGTALK_APP_KEY: 钉钉应用AppKey
73
+ - DINGTALK_APP_SECRET: 钉钉应用AppSecret
74
+ - DINGTALK_CORP_ID: 钉钉企业CorpId
75
+ - DINGTALK_AGENT_ID: 钉钉应用AgentId
76
+
77
+ Returns:
78
+ 从环境变量读取的配置字典
79
+ """
80
+ env_config: Dict[str, Any] = {}
81
+
82
+ # Auth配置
83
+ auth_enabled = os.environ.get('AUTH_ENABLED', '').lower()
84
+ if auth_enabled or os.environ.get('AUTH_JWT_SECRET'):
85
+ env_config['auth'] = {
86
+ 'enabled': auth_enabled == 'true',
87
+ 'jwt_secret': os.environ.get('AUTH_JWT_SECRET', 'default-secret-change-me'),
88
+ 'jwt_expire_hours': int(os.environ.get('AUTH_JWT_EXPIRE_HOURS', '24')),
89
+ }
90
+
91
+ # 钉钉配置
92
+ dingtalk_app_key = os.environ.get('DINGTALK_APP_KEY', '')
93
+ if dingtalk_app_key:
94
+ env_config['dingtalk'] = {
95
+ 'app_key': dingtalk_app_key,
96
+ 'app_secret': os.environ.get('DINGTALK_APP_SECRET', ''),
97
+ 'corp_id': os.environ.get('DINGTALK_CORP_ID', ''),
98
+ 'agent_id': os.environ.get('DINGTALK_AGENT_ID', ''),
99
+ }
100
+
101
+ return env_config
102
+
103
+ def load(self) -> Dict[str, Any]:
104
+ """加载配置文件
105
+
106
+ Returns:
107
+ 加载的配置字典
108
+ """
109
+ try:
110
+ with open(self._config_path, "r", encoding="utf-8") as f:
111
+ config = yaml.safe_load(f) or {}
112
+
113
+ # 转换配置中的换行符
114
+ config = self._convert_to_newlines(config)
115
+
116
+ # 从环境变量加载敏感配置
117
+ self._env_config = self._load_env_config()
118
+
119
+ # 环境变量配置优先级高于文件配置
120
+ for key in ENV_CONFIG_KEYS:
121
+ if key in self._env_config:
122
+ config[key] = self._env_config[key]
123
+ logger.info(f"配置 '{key}' 从环境变量加载")
124
+
125
+ self._config = config
126
+ logger.info(f"配置已加载: {self._config_path}")
127
+ return config
128
+
129
+ except FileNotFoundError:
130
+ logger.warning(f"配置文件不存在: {self._config_path}")
131
+ # 即使文件不存在,也尝试从环境变量加载
132
+ self._env_config = self._load_env_config()
133
+ self._config = self._env_config.copy()
134
+ return self._config
135
+ except yaml.YAMLError as e:
136
+ logger.error(f"配置文件格式错误: {e}")
137
+ raise ValueError(f"配置文件格式错误: {e}")
138
+
139
+ def _recursive_transform(self, obj: Any, transform_fn: Callable[[str], str],
140
+ condition: Callable[[str], bool] = lambda _: True) -> Any:
141
+ """递归转换配置中的字符串值
142
+
143
+ Args:
144
+ obj: 要转换的对象
145
+ transform_fn: 字符串转换函数
146
+ condition: 条件函数,只有满足条件的字符串才会被转换
147
+ """
148
+ if isinstance(obj, dict):
149
+ return {k: self._recursive_transform(v, transform_fn, condition) for k, v in obj.items()}
150
+ elif isinstance(obj, list):
151
+ return [self._recursive_transform(item, transform_fn, condition) for item in obj]
152
+ elif isinstance(obj, str) and condition(obj):
153
+ return transform_fn(obj)
154
+ return obj
155
+
156
+ def _convert_to_newlines(self, obj: Any) -> Any:
157
+ """递归转换配置中的\\n字符串为真正的换行符"""
158
+ return self._recursive_transform(obj, TextProcessor.config_to_text, lambda s: '\\n' in s)
159
+
160
+ def _convert_newlines_for_save(self, obj: Any) -> Any:
161
+ """递归转换换行符为配置文件格式"""
162
+ return self._recursive_transform(obj, TextProcessor.text_to_config)
163
+
164
+ def validate(self, config: Dict[str, Any]) -> Tuple[bool, str]:
165
+ """验证配置格式
166
+
167
+ Args:
168
+ config: 要验证的配置
169
+
170
+ Returns:
171
+ (是否有效, 错误信息)
172
+ """
173
+ errors = []
174
+
175
+ # 检查必填字段
176
+ qianxun = config.get('qianxun', {})
177
+ if not qianxun.get('api_url'):
178
+ errors.append("缺少千寻API地址 (qianxun.api_url)")
179
+
180
+ # 检查LangBot配置
181
+ langbot = config.get('langbot', {})
182
+ if langbot:
183
+ if langbot.get('ws_host') and not isinstance(langbot.get('ws_port'), int):
184
+ errors.append("LangBot端口必须是整数 (langbot.ws_port)")
185
+
186
+ # 检查限流配置
187
+ rate_limit = config.get('rate_limit', {})
188
+ if rate_limit:
189
+ min_interval = rate_limit.get('min_interval', 1)
190
+ max_interval = rate_limit.get('max_interval', 3)
191
+ if min_interval < 0 or max_interval < 0:
192
+ errors.append("限流间隔不能为负数")
193
+ if min_interval > max_interval:
194
+ errors.append("最小间隔不能大于最大间隔")
195
+
196
+ # 检查欢迎配置
197
+ welcome = config.get('welcome', [])
198
+ if isinstance(welcome, list):
199
+ for i, task in enumerate(welcome):
200
+ if task.get('enabled'):
201
+ # 支持单条 message 或多条 messages
202
+ has_message = bool(task.get('message') and task.get('message').strip())
203
+ # 过滤空字符串后检查 messages 数组
204
+ messages_list = task.get('messages', []) or []
205
+ valid_messages = [m for m in messages_list if m and m.strip()]
206
+ has_messages = len(valid_messages) > 0
207
+ if not has_message and not has_messages:
208
+ errors.append(f"欢迎配置 #{i+1} 启用但没有设置欢迎词")
209
+
210
+ # 检查昵称检测配置
211
+ nickname_check = config.get('nickname_check', [])
212
+ if isinstance(nickname_check, list):
213
+ for i, task in enumerate(nickname_check):
214
+ if task.get('enabled'):
215
+ if not task.get('target_groups'):
216
+ errors.append(f"昵称检测 #{i+1} 启用但没有设置目标群")
217
+ if not task.get('regex'):
218
+ errors.append(f"昵称检测 #{i+1} 启用但没有设置检测规则 (regex)")
219
+
220
+ # 检查定时提醒配置
221
+ reminders = config.get('scheduled_reminders', [])
222
+ if isinstance(reminders, list):
223
+ for i, task in enumerate(reminders):
224
+ if task.get('enabled'):
225
+ if not task.get('cron'):
226
+ errors.append(f"定时提醒 #{i+1} 启用但没有设置cron表达式")
227
+ if not task.get('content'):
228
+ errors.append(f"定时提醒 #{i+1} 启用但没有设置提醒内容 (content)")
229
+
230
+ # 检查排班配置
231
+ duty_schedules = config.get('duty_schedules', [])
232
+ if isinstance(duty_schedules, list):
233
+ for i, schedule in enumerate(duty_schedules):
234
+ if schedule.get('enabled'):
235
+ if not schedule.get('target_group'):
236
+ errors.append(f"排班任务 #{i+1} 启用但没有设置目标群")
237
+ if not schedule.get('schedule_type'):
238
+ errors.append(f"排班任务 #{i+1} 启用但没有设置排班类型")
239
+ # 检查自动轮换配置
240
+ auto = schedule.get('auto_rotation', {})
241
+ manual = schedule.get('manual_assignments', [])
242
+ if auto.get('enabled'):
243
+ if not auto.get('members'):
244
+ errors.append(f"排班任务 #{i+1} 启用自动轮换但没有设置参与人员")
245
+ elif not manual:
246
+ errors.append(f"排班任务 #{i+1} 没有设置自动轮换也没有手动排班")
247
+
248
+ if errors:
249
+ return False, "; ".join(errors)
250
+ return True, ""
251
+
252
+ async def save(self, config: Dict[str, Any]) -> Tuple[bool, str]:
253
+ """保存配置并通知观察者
254
+
255
+ 注意:auth 和 dingtalk 配置从环境变量读取,不会被保存到文件
256
+
257
+ Args:
258
+ config: 新配置
259
+
260
+ Returns:
261
+ (是否成功, 错误信息或成功消息)
262
+ """
263
+ async with self._lock:
264
+ # 验证配置
265
+ valid, error = self.validate(config)
266
+ if not valid:
267
+ return False, error
268
+
269
+ old_config = self._config.copy()
270
+
271
+ try:
272
+ # 自动合并默认忽略列表
273
+ if 'filter' not in config:
274
+ config['filter'] = {}
275
+ user_ignore = config['filter'].get('ignore_wxids', []) or []
276
+ merged_ignore = list(set(user_ignore + DEFAULT_IGNORE_WXIDS))
277
+ config['filter']['ignore_wxids'] = merged_ignore
278
+
279
+ # 准备保存到文件的配置(排除环境变量配置)
280
+ config_to_save = {k: v for k, v in config.items() if k not in ENV_CONFIG_KEYS}
281
+
282
+ # 转换换行符为配置文件格式
283
+ converted = self._convert_newlines_for_save(config_to_save)
284
+
285
+ # 保存到文件
286
+ with open(self._config_path, "w", encoding="utf-8") as f:
287
+ yaml.dump(converted, f, allow_unicode=True,
288
+ default_flow_style=False, sort_keys=False)
289
+
290
+ # 更新内存中的配置(保留环境变量配置)
291
+ for key in ENV_CONFIG_KEYS:
292
+ if key in self._env_config:
293
+ config[key] = self._env_config[key]
294
+
295
+ self._config = config
296
+
297
+ # 记录变更日志
298
+ self._log_changes(old_config, config)
299
+
300
+ # 通知观察者
301
+ await self._notify_observers(old_config, config)
302
+
303
+ logger.info("配置已保存并生效")
304
+ return True, "配置已保存并生效"
305
+
306
+ except Exception as e:
307
+ logger.error(f"保存配置失败: {e}")
308
+ return False, f"保存配置失败: {e}"
309
+
310
+ def _log_changes(self, old_config: Dict, new_config: Dict, prefix: str = ""):
311
+ """记录配置变更日志"""
312
+ all_keys = set(old_config.keys()) | set(new_config.keys())
313
+
314
+ for key in all_keys:
315
+ full_key = f"{prefix}.{key}" if prefix else key
316
+ old_val = old_config.get(key)
317
+ new_val = new_config.get(key)
318
+
319
+ if old_val != new_val:
320
+ if isinstance(old_val, dict) and isinstance(new_val, dict):
321
+ self._log_changes(old_val, new_val, full_key)
322
+ else:
323
+ # 对于敏感字段,不记录具体值
324
+ sensitive_keys = ['access_token', 'password', 'secret']
325
+ if any(s in key.lower() for s in sensitive_keys):
326
+ logger.info(f"配置变更: {full_key} = [已隐藏]")
327
+ else:
328
+ logger.info(f"配置变更: {full_key}: {old_val} -> {new_val}")
329
+
330
+ def register_observer(self, callback: Callable[[Dict, Dict], Awaitable[None]]):
331
+ """注册配置变更观察者
332
+
333
+ Args:
334
+ callback: 回调函数,接收 (old_config, new_config)
335
+ """
336
+ self._observers.append(callback)
337
+ logger.debug(f"注册配置观察者,当前共 {len(self._observers)} 个")
338
+
339
+ def unregister_observer(self, callback: Callable[[Dict, Dict], Awaitable[None]]):
340
+ """取消注册配置变更观察者"""
341
+ if callback in self._observers:
342
+ self._observers.remove(callback)
343
+
344
+ async def _notify_observers(self, old_config: Dict, new_config: Dict):
345
+ """通知所有观察者配置已变更"""
346
+ for observer in self._observers:
347
+ try:
348
+ await observer(old_config, new_config)
349
+ except Exception as e:
350
+ logger.error(f"通知配置观察者失败: {e}")
351
+
352
+ def get(self, key: str, default: Any = None) -> Any:
353
+ """获取配置项
354
+
355
+ 支持点号分隔的嵌套键,如 'qianxun.api_url'
356
+
357
+ Args:
358
+ key: 配置键
359
+ default: 默认值
360
+
361
+ Returns:
362
+ 配置值
363
+ """
364
+ keys = key.split('.')
365
+ value = self._config
366
+
367
+ for k in keys:
368
+ if isinstance(value, dict):
369
+ value = value.get(k)
370
+ else:
371
+ return default
372
+
373
+ if value is None:
374
+ return default
375
+
376
+ return value
377
+
378
+ def get_langbot_config(self) -> Dict[str, Any]:
379
+ """获取LangBot连接配置"""
380
+ return self._config.get('langbot', {})
381
+
382
+ def get_qianxun_config(self) -> Dict[str, Any]:
383
+ """获取千寻配置"""
384
+ return self._config.get('qianxun', {})
385
+
386
+ def get_filter_config(self) -> Dict[str, Any]:
387
+ """获取过滤配置"""
388
+ return self._config.get('filter', {})
389
+
390
+ def get_rate_limit_config(self) -> Dict[str, Any]:
391
+ """获取限流配置"""
392
+ return self._config.get('rate_limit', {})
393
+
394
+ def get_welcome_tasks(self) -> List[Dict[str, Any]]:
395
+ """获取入群欢迎任务列表"""
396
+ welcome = self._config.get('welcome', [])
397
+ if isinstance(welcome, list):
398
+ return welcome
399
+ return [welcome] if welcome else []
400
+
401
+ def get_nickname_check_tasks(self) -> List[Dict[str, Any]]:
402
+ """获取昵称检测任务列表"""
403
+ return self._config.get('nickname_check', []) or []
404
+
405
+ def get_scheduled_reminders(self) -> List[Dict[str, Any]]:
406
+ """获取定时提醒任务列表"""
407
+ return self._config.get('scheduled_reminders', []) or []
408
+
409
+ def get_duty_schedules(self) -> List[Dict[str, Any]]:
410
+ """获取排班任务列表"""
411
+ return self._config.get('duty_schedules', []) or []
@@ -0,0 +1,167 @@
1
+ """日志收集器 - 收集消息处理日志,支持WebSocket推送
2
+
3
+ 功能:
4
+ - 日志添加(deque + maxlen=100)
5
+ - 日志查询(支持类型筛选)
6
+ - 订阅/取消订阅(asyncio.Queue)
7
+ """
8
+ import asyncio
9
+ import logging
10
+ from collections import deque
11
+ from datetime import datetime
12
+ from typing import Optional, List, Dict, Any, Set
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class LogCollector:
18
+ """日志收集器"""
19
+
20
+ def __init__(self, max_logs: int = 100):
21
+ """初始化日志收集器
22
+
23
+ Args:
24
+ max_logs: 最大日志条数
25
+ """
26
+ self._logs: deque = deque(maxlen=max_logs)
27
+ self._subscribers: Set[asyncio.Queue] = set()
28
+ self._lock = asyncio.Lock()
29
+
30
+ async def add_log(self, log_type: str, content: Dict[str, Any]):
31
+ """添加日志并推送给订阅者
32
+
33
+ Args:
34
+ log_type: 日志类型(private, group, error, system)
35
+ content: 日志内容
36
+ """
37
+ log_entry = {
38
+ "id": f"{datetime.now().timestamp():.6f}",
39
+ "timestamp": datetime.now().isoformat(),
40
+ "type": log_type,
41
+ **content
42
+ }
43
+
44
+ async with self._lock:
45
+ self._logs.append(log_entry)
46
+
47
+ # 推送给所有订阅者
48
+ await self._broadcast(log_entry)
49
+
50
+ async def _broadcast(self, log_entry: Dict[str, Any]):
51
+ """广播日志给所有订阅者"""
52
+ dead_subscribers = set()
53
+
54
+ for queue in self._subscribers.copy():
55
+ try:
56
+ # 使用 put_nowait 避免阻塞
57
+ queue.put_nowait(log_entry)
58
+ except asyncio.QueueFull:
59
+ # 队列满了,跳过这条日志
60
+ logger.debug("订阅者队列已满,跳过日志")
61
+ except Exception as e:
62
+ logger.debug(f"推送日志失败: {e}")
63
+ dead_subscribers.add(queue)
64
+
65
+ # 清理失效的订阅者
66
+ for queue in dead_subscribers:
67
+ self._subscribers.discard(queue)
68
+
69
+ def get_logs(self, log_type: Optional[str] = None, limit: int = 100) -> List[Dict[str, Any]]:
70
+ """获取日志列表
71
+
72
+ Args:
73
+ log_type: 日志类型筛选(None表示全部)
74
+ limit: 返回条数限制
75
+
76
+ Returns:
77
+ 日志列表(最新的在前)
78
+ """
79
+ logs = list(self._logs)
80
+
81
+ # 按类型筛选
82
+ if log_type:
83
+ logs = [log for log in logs if log.get("type") == log_type]
84
+
85
+ # 返回最新的日志(倒序)
86
+ logs = list(reversed(logs))
87
+
88
+ # 限制条数
89
+ return logs[:limit]
90
+
91
+ def subscribe(self, max_queue_size: int = 100) -> asyncio.Queue:
92
+ """订阅日志更新
93
+
94
+ Args:
95
+ max_queue_size: 队列最大大小
96
+
97
+ Returns:
98
+ 用于接收日志的队列
99
+ """
100
+ queue = asyncio.Queue(maxsize=max_queue_size)
101
+ self._subscribers.add(queue)
102
+ logger.debug(f"新增日志订阅者,当前共 {len(self._subscribers)} 个")
103
+ return queue
104
+
105
+ def unsubscribe(self, queue: asyncio.Queue):
106
+ """取消订阅
107
+
108
+ Args:
109
+ queue: 要取消的订阅队列
110
+ """
111
+ self._subscribers.discard(queue)
112
+ logger.debug(f"移除日志订阅者,当前共 {len(self._subscribers)} 个")
113
+
114
+ def clear(self):
115
+ """清空所有日志"""
116
+ self._logs.clear()
117
+ logger.info("日志已清空")
118
+
119
+ @property
120
+ def subscriber_count(self) -> int:
121
+ """获取当前订阅者数量"""
122
+ return len(self._subscribers)
123
+
124
+ @property
125
+ def log_count(self) -> int:
126
+ """获取当前日志数量"""
127
+ return len(self._logs)
128
+
129
+
130
+ # 便捷函数,用于记录不同类型的日志
131
+ async def log_private_message(collector: LogCollector, from_wxid: str, content: str,
132
+ status: str = "received", error: Optional[str] = None):
133
+ """记录私聊消息日志"""
134
+ await collector.add_log("private", {
135
+ "from_wxid": from_wxid,
136
+ "content": content[:200], # 截断过长内容
137
+ "status": status,
138
+ "error": error
139
+ })
140
+
141
+
142
+ async def log_group_message(collector: LogCollector, group_wxid: str, sender_wxid: str,
143
+ content: str, status: str = "received", error: Optional[str] = None):
144
+ """记录群聊消息日志"""
145
+ await collector.add_log("group", {
146
+ "group_wxid": group_wxid,
147
+ "sender_wxid": sender_wxid,
148
+ "content": content[:200], # 截断过长内容
149
+ "status": status,
150
+ "error": error
151
+ })
152
+
153
+
154
+ async def log_error(collector: LogCollector, message: str, details: Optional[Dict] = None):
155
+ """记录错误日志"""
156
+ await collector.add_log("error", {
157
+ "message": message,
158
+ "details": details
159
+ })
160
+
161
+
162
+ async def log_system(collector: LogCollector, message: str, details: Optional[Dict] = None):
163
+ """记录系统日志"""
164
+ await collector.add_log("system", {
165
+ "message": message,
166
+ "details": details
167
+ })