# -*- coding: utf-8 -*- import asyncio import base64 import io import os import threading import time import tomllib import traceback import uuid from collections import deque from loguru import logger import qrcode import wechat_ipad from base.plugin_common.message_plugin_interface import MessagePluginInterface from base.plugin_common.plugin_interface import PluginStatus from base.plugin_common.plugin_manager import PluginManager from base.plugin_common.plugin_registry import PluginRegistry from configuration import Config from db.connection import DBConnectionManager from db.contacts_db import ContactsDBOperator from db.group_plugin_config_db import GroupPluginConfigDBOperator from db.llm_catalog_db import LLMCatalogDBOperator from db.plugin_schedule_db import PluginScheduleDBOperator from db.system_job_db import SystemJobDBOperator from utils.system_jobs import SystemJobLoader from utils.email_util import EmailSender from utils.group_plugin_config_service import GroupPluginConfigService from utils.plugin_schedule_manager import PluginScheduleManager from utils.revoke.message_auto_revoke import MessageAutoRevoke from utils.robot_cmd.robot_command import GroupBotManager, Feature, PermissionStatus from utils.wechat.contact_manager import ContactManager from utils.wechat.member_monitor import ChatroomMemberMonitor from utils.wechat.message_to_db import MessageStorage from utils.ai.llm_registry import LLMRegistry from utils.trace_context import set_current_trace_id, reset_current_trace_id from wechat_ipad import WechatAPIClient, WechatGateway from wechat_ipad.models.message import WxMessage, MessageType # 定义全局信号量,限制最大并发 10 sem = asyncio.Semaphore(20) class Robot: """个性化自己的机器人 """ def __init__(self, config: Config) -> None: super().__init__() self.config = config self.LOG = logger self.LOG.info(f"=" * 50) # wechat_ipad 相关属性 # 这里先显式给出一个空值: # 1. Dashboard 可能在 wechat 线程真正跑起来前就读取 `robot.ipad_bot`; # 2. 若只写类型标注不赋默认值,启动竞态下会直接抛 `AttributeError`; # 3. 先置为 None 后,其他模块就可以安全地做“是否已就绪”的判定。 self.ipad_bot: WechatAPIClient | None = None self.ipad_config = None self.ipad_running = False self.ipad_thread = None self.ipad_loop = None # 启动结果同步事件: # 1. `init_wechat_ipad()` 在主线程调用,但真正的 provider 初始化在子线程里执行; # 2. 这里用 Event 把“子线程是否至少成功创建了 provider”回传给主线程; # 3. 这样主线程就不会再把“线程已启动”误判成“wechat 已成功就绪”。 self.ipad_startup_event = threading.Event() self.ipad_startup_error = None # Dashboard 登录引导态: # 1. 首次部署或登录失效时,后台首页需要知道当前二维码、剩余有效期和最近一次刷新时间; # 2. 这类状态属于“运行时临时信息”,不应该写回配置文件,也不值得额外拉一层服务; # 3. 因此直接挂在 Robot 上,用锁保护跨线程读写,保持实现足够轻。 self._ipad_login_qr_lock = threading.Lock() self.ipad_login_qr_state = self._build_empty_ipad_login_qr_state() self.wxid = None self.nickname = None self.alias = None self.phone = None self.message_auto_revoke: MessageAutoRevoke = None self.LOG.debug(f"DB+REDIS 连接池开始初始化") # 使用单例模式获取实例 self.db_manager = DBConnectionManager.get_instance( mysql_config=self.config.mariadb, redis_config=self.config.redis ) self.LOG.debug(f"数据库连接管理器初始化完成") # 为了兼容现有代码,保留原有的连接池 self.db_pool = self.db_manager.mysql_pool self.redis_pool = self.db_manager.redis_pool self.contacts_db = ContactsDBOperator(self.db_manager) self.group_plugin_config_db = GroupPluginConfigDBOperator(self.db_manager) self.llm_catalog_db = LLMCatalogDBOperator(self.db_manager) self.plugin_schedule_db = PluginScheduleDBOperator(self.db_manager) self.system_job_db = SystemJobDBOperator(self.db_manager) self.group_plugin_config_db.init_tables() # 新版 LLM 目录模型(Provider 模板 / Dify 应用 / Scene)初始化。 self.llm_catalog_db.init_tables() self.llm_catalog_db.bootstrap_from_legacy_llm(self.config.llm) self.group_plugin_config_service = GroupPluginConfigService( db_operator=self.group_plugin_config_db, redis_client=self.db_manager.get_redis_connection(), ) # 初始化联系人管理器 self.contact_manager = ContactManager.get_instance() self.allContacts = {} # 将在登录后填充 # 提前初始化消息存储: # 1. DashboardServer 会在主线程里较早启动,并直接读取 robot.message_storage; # 2. 旧逻辑要等 iPad 登录成功后才赋值,导致后台在启动竞态下拿不到这个属性; # 3. 这里先给一个可用的默认实例,后续登录成功后再注入真实 bot 覆盖即可。 self.message_storage = MessageStorage() self.groups = {} # 存储按group_id分组的消息列表,每个group_id最多保留10条消息 # 初始化插件系统 self.LOG.debug("开始初始化插件系统...") plugin_bootstrap_started_at = time.perf_counter() self.plugin_registry = PluginRegistry() self.plugin_modules = {} # 存储已加载的插件模块 self.plugins = {} # 存储已加载的插件实例 # 设置插件系统上下文 self.system_context = { "config": config, "plugin_registry": self.plugin_registry, "db_manager": self.db_manager, "db_pool": self.db_pool, "redis_pool": self.redis_pool, "group_plugin_config_service": self.group_plugin_config_service, } self.plugin_manager = PluginManager(plugin_dir=getattr(self.config, "plugin_dir", "plugins")) self.plugin_manager.set_system_context(self.system_context) plugin_load_started_at = time.perf_counter() self.plugins = self.plugin_manager.load_all_plugins() plugin_load_cost_ms = int((time.perf_counter() - plugin_load_started_at) * 1000) self.LOG.info(f"插件加载完成: count={len(self.plugins)} cost={plugin_load_cost_ms}ms") # 插件热加载默认关闭: # 1. 它会持续轮询插件目录并调用 discover_plugins,线上运行会产生额外扫盘开销; # 2. 对当前以“稳定运行”为主的场景,这类自动热更新收益远低于成本; # 3. 若后续确实需要在线调试插件,可通过配置重新打开,并把轮询间隔调大。 hot_reload_cfg = dict(getattr(self.config, "plugin_hot_reload", {}) or {}) if bool(hot_reload_cfg.get("enabled")): interval_seconds = max(float(hot_reload_cfg.get("interval_seconds", 600) or 600), 60.0) self.plugin_manager.start_hot_reload_watcher(interval_seconds=interval_seconds) self.LOG.info(f"插件热加载监听已启用,轮询间隔 {interval_seconds}s") else: self.LOG.info("插件热加载监听已禁用,启动阶段不再自动扫盘检查插件变更") system_job_started_at = time.perf_counter() self.system_job_loader = SystemJobLoader(self, self.system_job_db) # 启动阶段只做任务注册,不同步补跑历史漏执行任务: # 1. 旧逻辑会在这里直接补跑系统任务,重任务会把主进程启动拖慢几十秒; # 2. 用户看到的日志像“卡在插件系统初始化”,实际常常是补偿任务在阻塞; # 3. 这里先保证主流程快速完成,后续如需人工补跑,可在后台单独触发。 self.system_job_loader.init_and_load(run_startup_compensation=False) system_job_cost_ms = int((time.perf_counter() - system_job_started_at) * 1000) self.LOG.info(f"系统任务装载完成: cost={system_job_cost_ms}ms") plugin_schedule_started_at = time.perf_counter() self.plugin_schedule_manager = PluginScheduleManager(self.plugin_manager, self.plugin_schedule_db) # 插件调度同样关闭启动期同步补偿,避免某些定时任务在启动时直接执行。 self.plugin_schedule_manager.init_and_load(run_startup_compensation=False) plugin_schedule_cost_ms = int((time.perf_counter() - plugin_schedule_started_at) * 1000) self.LOG.info(f"插件调度装载完成: cost={plugin_schedule_cost_ms}ms") # 将历史业务型系统任务迁移到插件调度配置,避免升级后出现“任务丢失”。 migration_started_at = time.perf_counter() migration_result = self.plugin_schedule_manager.migrate_from_system_jobs(self.system_job_db) if migration_result.get("migrated", 0) > 0: self.LOG.info(f"系统任务迁移到插件任务完成: {migration_result}") self.plugin_schedule_manager.reload_from_db() migration_cost_ms = int((time.perf_counter() - migration_started_at) * 1000) self.LOG.info(f"插件任务迁移检查完成: cost={migration_cost_ms}ms result={migration_result}") # 迁移完成后,清理已下沉到插件层的系统任务,避免后台重复维护两套配置。 cleanup_started_at = time.perf_counter() self._cleanup_migrated_system_jobs() cleanup_cost_ms = int((time.perf_counter() - cleanup_started_at) * 1000) # 加载插件 plugin_bootstrap_cost_ms = int((time.perf_counter() - plugin_bootstrap_started_at) * 1000) self.LOG.info( "插件系统初始化完成: " f"total={plugin_bootstrap_cost_ms}ms, " f"plugin_load={plugin_load_cost_ms}ms, " f"system_jobs={system_job_cost_ms}ms, " f"plugin_schedules={plugin_schedule_cost_ms}ms, " f"migration={migration_cost_ms}ms, " f"cleanup={cleanup_cost_ms}ms" ) GroupBotManager.load_local_cache() # 权限模块加载 self.gbm = GroupBotManager() self.email_sender = EmailSender( smtp_server=self.config.email.get("smtp_server", "smtp.163.com"), smtp_port=self.config.email.get("smtp_port", 465), sender_email=self.config.email.get("sender_email", "bovine_liu@163.com"), sender_password=self.config.email.get("sender_password", "LTS9BhmX9XhS36QS") ) # 通过类属性设置 admin_list,而不是实例属性 GroupBotManager.admin_list = self.config.wx_config.get("admin", []) self.recent_msg_ids = deque(maxlen=20) def apply_runtime_config(self, reload_catalog: bool = False) -> None: """把最新全局配置应用到当前运行中的关键对象。 说明: 1. `self.config.reload()` 只会刷新 Config 实例中的字段,不会自动更新启动时已构造的依赖对象; 2. 这里集中处理“保存配置后希望立刻生效”的轻量刷新动作,避免为大多数改动走整进程重启; 3. 该方法刻意不去重建 DB 连接、微信登录态、插件实例,尽量把影响范围控制在可热刷的配置项。 """ # 邮件发送器在初始化时会拷贝 SMTP 参数,因此这里需要按最新配置重建一份实例。 self.email_sender = EmailSender( smtp_server=self.config.email.get("smtp_server", "smtp.163.com"), smtp_port=self.config.email.get("smtp_port", 465), sender_email=self.config.email.get("sender_email", "bovine_liu@163.com"), sender_password=self.config.email.get("sender_password", "LTS9BhmX9XhS36QS") ) # 管理员列表走 GroupBotManager 的类级缓存;只 reload Config 不会自动回写到这里。 GroupBotManager.admin_list = self.config.wx_config.get("admin", []) # system_context 中保存的是 config 对象引用,reload 后插件读取到的是最新字段。 # 但 LLMRegistry 自己还有一层短 TTL 缓存,因此保存全局 LLM 配置后需要显式清掉。 if reload_catalog: self.llm_catalog_db.bootstrap_from_legacy_llm(self.config.llm) LLMRegistry.invalidate_cache() self.LOG.info( "运行时配置已应用: " f"admin_count={len(GroupBotManager.admin_list)}, " f"email_sender={'ready' if self.email_sender else 'missing'}, " f"llm_cache_reloaded={reload_catalog}" ) def _cleanup_migrated_system_jobs(self): """清理已经迁移到插件层的历史系统任务键。""" migrated_keys = [ "news_baidu_report_auto", "epic_free_games", "message_ranking_push", "sehuatang_pdf_push", "xiuren_download", "shenshi_r15_download", "update_image_cache", # 联系人头像缓存任务在 2026-05-06 调整过命名: # 1. 旧键 `sync_contact_avatar_cache` 只存在于历史数据库配置; # 2. 新键统一使用 `contact_avatar_cache_sync`,避免命名风格前后不一致; # 3. 这里在启动期顺手清理旧键,避免后台任务页长期出现“数据库有记录、运行态无处理器”的幽灵任务。 "sync_contact_avatar_cache", ] removed = 0 for job_key in migrated_keys: try: row = self.system_job_db.get_job(job_key) if not row: continue if self.system_job_db.delete_job(job_key): removed += 1 except Exception as e: self.LOG.warning(f"清理迁移系统任务失败: job_key={job_key}, error={e}") if removed > 0: self.LOG.info(f"已清理 {removed} 个历史系统任务配置(迁移至插件任务)") def init_wechat_ipad(self): """初始化wechat_ipad客户端""" try: self.ipad_startup_event.clear() self.ipad_startup_error = None # wechat_ipad 静态配置统一走 Config: # 1. 用户现在只需要维护 `.env` / `config.yaml`,不必再手工维护独立 TOML; # 2. 登录态仍保留本地缓存文件,但只作为运行期状态,不再作为主配置源; # 3. 这里先做一次“静态配置 + 本地状态缓存 + 历史 config.toml”的合并,保证升级不中断。 self.ipad_config = self._build_wechat_ipad_runtime_config() self.LOG.debug("正在初始化wechat_ipad客户端...") # 检查必要的配置 server_url = str(self.ipad_config.get("server_url", "") or "").strip() if server_url == "": self.LOG.error("server_url不能为空,wechat_ipad初始化失败") return False server_ip = str(self.ipad_config.get("server_ip", "") or "").strip() server_port = int(self.ipad_config.get("server_port", 8059) or 8059) # 当前阶段先通过 Gateway 承接 provider 选择: # 1. 默认仍走 legacy_855,保持现有现网协议行为; # 2. 这里提前把入口收敛到 Gateway,后续接 864 时可不再修改主链路; # 3. `server_type` 缺失时自动回退 legacy_855,兼容现有 config.toml。 # 创建事件循环 self.ipad_loop = asyncio.new_event_loop() # 在新线程中启动wechat_ipad客户端 self.ipad_thread = threading.Thread( target=self._run_wechat_ipad_client, args=(server_ip, server_port), daemon=True ) self.ipad_thread.start() # 等待子线程至少完成 provider 创建或明确报错: # 1. 这里不强求“已经登录成功”,否则首次扫码场景会被误判为启动失败; # 2. 但至少要确认 Gateway / provider 能正常实例化,避免主线程盲目打印成功日志; # 3. 若超时仍未收到回传,按失败处理,让运维更早感知异常启动。 startup_ready = self.ipad_startup_event.wait(timeout=15) if not startup_ready: self.LOG.error("wechat_ipad客户端初始化超时:未在预期时间内完成 provider 启动") return False if self.ipad_startup_error: self.LOG.error(f"wechat_ipad客户端初始化失败: {self.ipad_startup_error}") return False self.LOG.debug("wechat_ipad客户端初始化完成") return True except Exception as e: self.LOG.error(f"初始化wechat_ipad客户端失败: {e}") return False def _run_wechat_ipad_client(self, server_ip, server_port): """在新线程中运行wechat_ipad客户端""" asyncio.set_event_loop(self.ipad_loop) self.ipad_loop.run_until_complete(self._wechat_ipad_core(server_ip, server_port)) async def _wechat_ipad_core(self, server_ip, server_port): """wechat_ipad 核心运行逻辑。""" try: self.LOG.debug("启动wechat_ipad bot") # 调用登录接口: # 1. 这里不再直接实例化具体客户端实现,而是统一通过 Gateway 选择 provider; # 2. 第一阶段仍默认绑定 legacy_855,后续接入 864 时这里只需要读新配置即可; # 3. 通过 Gateway 的属性透传能力,先尽量保持现有 `self.ipad_bot.xxx` 写法不变。 server_type = str(self.ipad_config.get("server_type", "legacy_855") or "legacy_855").strip() self.ipad_bot = WechatGateway(server_ip, server_port, server_type=server_type) self.message_auto_revoke = MessageAutoRevoke(self.ipad_bot) # 一旦 provider 已成功创建,就尽快通知主线程: # 1. 这说明 `server_type`、Gateway 映射和 provider 构造链至少是可用的; # 2. Dashboard 此时再读取 `robot.ipad_bot` 也不会踩到空对象; # 3. 后续若登录失败,会由运行时日志和告警继续暴露,而不是伪装成“启动成功”。 self.ipad_startup_event.set() # 855 provider 现在自行承接运行时模型: # 1. provider 内部负责登录、历史消息拉取、心跳、长心跳、掉线恢复与实时轮询; # 2. Robot 只注册业务回调,继续处理联系人初始化、消息归档、插件调度等项目内逻辑; # 3. 这样未来切到 864 时,主链路只需要替换 provider,而不是继续改这里的大循环。 await self.ipad_bot.run_runtime( ipad_config=self.ipad_config, state_path=str( self.ipad_config.get("state_file", "") or self._default_wechat_state_path(self.ipad_config) ), logger=self.LOG, on_login_ready=self._on_ipad_login_ready, on_history_message=self._archive_startup_history_message, on_message=self._handle_runtime_message, on_idle_payload=self._handle_runtime_idle_payload, on_logout=self._handle_ipad_logout, on_runtime_state_change=self._handle_runtime_state_change, on_login_qr_update=self._handle_ipad_login_qr_update, on_login_qr_cleared=self._handle_ipad_login_qr_cleared, ) except Exception as e: self.ipad_startup_error = e self.ipad_startup_event.set() self.LOG.exception(f"wechat_ipad客户端运行出错: {e}") self.ipad_running = False def _build_wechat_ipad_runtime_config(self) -> dict: """构建 wechat_ipad 的运行时配置快照。 合并顺序说明: 1. 先取 `config.yaml + .env` 里的静态连接配置,作为新的唯一人工维护入口; 2. 再补本地状态缓存中的 wxid / device 信息,避免每次启动都重新扫码; 3. 最后兼容历史 `wechat_ipad/config.toml`,让老环境升级后可以平滑迁移。 """ base_config = dict(getattr(self.config, "wechat_ipad", {}) or {}) normalized_provider_key = self._normalize_wechat_provider_key(base_config.get("server_type", "legacy_855")) state_path = str(base_config.get("state_file", "") or self._default_wechat_state_path(base_config)) legacy_config_path = str( base_config.get("legacy_config_path", "wechat_ipad/config.toml") or "wechat_ipad/config.toml" ) state_config = self._load_toml_config_if_exists(state_path) legacy_config = {} # 只有 855 家族继续兼容历史 `wechat_ipad/config.toml`: # 1. 用户当前明确担心 864 调试时误用本地 855 登录信息; # 2. 864 的静态鉴权已经切到 `server_key`,不需要再借助旧 TOML 补状态; # 3. 因此从这一层开始做硬隔离,避免 provider 切换时把 855 缓存带进 864 运行链路。 if normalized_provider_key == "legacy_855" and os.path.abspath(state_path) != os.path.abspath(legacy_config_path): legacy_config = self._load_toml_config_if_exists(legacy_config_path) merged_config = dict(base_config) # 静态字段优先级:`.env/config.yaml` > 历史文件。 # 这样每个人只要改 `.env` 就能切换自己的 server,不需要再同步别处。 for field_name in ("server_url", "server_ip", "server_port", "server_type", "server_key"): if not str(merged_config.get(field_name, "") or "").strip(): legacy_value = legacy_config.get(field_name) if legacy_value not in (None, ""): merged_config[field_name] = legacy_value # 动态字段优先级:显式环境变量 > 新状态文件 > 历史 config.toml。 # 这样既支持用户手工覆盖,也保留现有登录缓存迁移能力。 for field_name in ("wxid", "device_name", "device_id", "login_time"): current_value = merged_config.get(field_name) if str(current_value or "").strip(): continue state_value = state_config.get(field_name) if state_value not in (None, ""): merged_config[field_name] = state_value continue legacy_value = legacy_config.get(field_name) if legacy_value not in (None, ""): merged_config[field_name] = legacy_value # 对 864 这类新 provider,进一步清理可能残留的 855 动态字段: # 1. `wxid/device_name/device_id` 对 864 不是主鉴权参数; # 2. 若用户历史上跑过 855,本地 `.env` 或旧缓存里残留这些字段,最容易让人误以为“复用了旧号”; # 3. 这里直接在构建运行时快照时清空,保证 864 启动只依赖 `server_key + 远端登录态`。 if normalized_provider_key != "legacy_855": for field_name in ("wxid", "device_name", "device_id"): merged_config[field_name] = "" merged_config["state_file"] = state_path merged_config["legacy_config_path"] = legacy_config_path return merged_config @staticmethod def _default_wechat_state_path(ipad_config: dict) -> str: """根据当前 provider 类型返回默认的本地状态文件路径。 设计原因: 1. 用户希望登录态跟随 provider 放置,避免散落在 `temp/` 目录中; 2. 后续新增 864 等 provider 时,可以天然形成“每个 provider 自己维护自己的状态”; 3. 这里统一在主程序收口默认路径,避免把路径规则写散到文档、脚本和 provider 内部。 """ server_type = Robot._normalize_wechat_provider_key(ipad_config.get("server_type", "legacy_855")) return os.path.join("wechat_ipad", "providers", server_type, "runtime_state.toml") @staticmethod def _normalize_wechat_provider_key(server_type) -> str: """把对外可配置的 server_type 归一化成 provider 目录键。 说明: 1. 运行入口允许用户写 `legacy_855` / `855` / `859`,这是配置层的易用性; 2. 但这几种写法本质上都指向同一个 provider,不应该因为别名不同就分裂出多份登录态文件; 3. 因此这里统一把 855 家族收敛到 `legacy_855`,保证线上切换别名时缓存路径稳定。 """ normalized_server_type = str(server_type or "legacy_855").strip().lower() if normalized_server_type in {"855", "859", "legacy_855"}: return "legacy_855" if normalized_server_type in {"864", "server_864"}: return "server_864" return normalized_server_type or "legacy_855" def _load_toml_config_if_exists(self, file_path: str) -> dict: """安全读取一个 TOML 文件,缺失或格式异常时回退为空配置。""" normalized_path = str(file_path or "").strip() if not normalized_path or not os.path.exists(normalized_path): return {} try: with open(normalized_path, "rb") as f: return tomllib.load(f) except Exception as e: self.LOG.warning(f"读取 TOML 配置失败,将按空配置继续: path={normalized_path}, error={e}") return {} @staticmethod def _build_empty_ipad_login_qr_state() -> dict: """构造 Dashboard 可直接消费的默认二维码登录态。""" return { "logged_in": False, "active": False, "status": "idle", "provider_name": "", "provider_stage": "bootstrap", "connection_ready": False, "login_required": False, "status_text": "尚未进入扫码登录流程", "current": {}, "history": [], "updated_at": 0, } @staticmethod def _build_qr_image_data(scan_url: str) -> str: """把扫码内容生成 base64 图片,供 Dashboard 直接展示。""" normalized_scan_url = str(scan_url or "").strip() if not normalized_scan_url: return "" try: # 这里直接在后端生成二维码图片: # 1. 避免首页再额外引入前端二维码依赖,减少静态资源改动; # 2. 即使 provider 没有返回可直接访问的图片 URL,只要有扫码内容也能展示; # 3. 返回 data URI 后,前端只需要普通 `` 即可渲染。 qr = qrcode.QRCode( version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, border=4, ) qr.add_data(normalized_scan_url) qr.make(fit=True) image = qr.make_image(fill_color="black", back_color="white") buffer = io.BytesIO() image.save(buffer, format="PNG") encoded = base64.b64encode(buffer.getvalue()).decode("utf-8") return f"data:image/png;base64,{encoded}" except Exception: return "" def get_ipad_login_qr_state(self) -> dict: """返回当前 Dashboard 可读取的二维码登录态快照。""" with self._ipad_login_qr_lock: login_state_flag = bool(self.ipad_login_qr_state.get("logged_in", False)) qr_status = str(self.ipad_login_qr_state.get("status", "idle") or "idle") provider_name = str( self.ipad_login_qr_state.get("provider_name", self.ipad_config.get("server_type", "") if self.ipad_config else "") or "" ).strip() provider_stage = str(self.ipad_login_qr_state.get("provider_stage", "bootstrap") or "bootstrap").strip() connection_ready = bool(self.ipad_login_qr_state.get("connection_ready", False)) login_required = bool(self.ipad_login_qr_state.get("login_required", False)) # 首页弹窗是否关闭,必须优先以“当前二维码运行态”本身为准: # 1. 之前这里把 `self.wxid` 也算进了 logged_in,导致旧身份缓存会把新一轮扫码流程误判成“已经登录”; # 2. 864 收口失败后虽然 provider 已经回到扫码引导态,但 Robot 上残留的旧 wxid 仍会让前端直接收起弹窗; # 3. 因此这里收紧为只认当前二维码状态机明确给出的 logged_in / confirmed 信号,不再混入历史缓存身份。 logged_in = login_state_flag or (qr_status in {"confirmed", "logged_in"} and not login_required) state = { "logged_in": logged_in, "active": bool(self.ipad_login_qr_state.get("active", False)), "status": qr_status, "provider_name": provider_name, "provider_stage": provider_stage, "connection_ready": connection_ready, "login_required": login_required, "status_text": str( self.ipad_login_qr_state.get("status_text", "尚未进入扫码登录流程") or "尚未进入扫码登录流程" ), "updated_at": float(self.ipad_login_qr_state.get("updated_at", 0) or 0), "current": dict(self.ipad_login_qr_state.get("current", {}) or {}), "history": [dict(item or {}) for item in (self.ipad_login_qr_state.get("history", []) or [])], "runtime_running": bool(self.ipad_running), "wxid": str(self.wxid or ""), "nickname": str(self.nickname or ""), } now_ts = time.time() current = state.get("current", {}) or {} expires_at = float(current.get("expires_at", 0) or 0) if expires_at > 0: current["remaining_seconds"] = max(0, int(expires_at - now_ts)) else: current["remaining_seconds"] = int(current.get("remaining_seconds", 0) or 0) state["current"] = current state["server_now"] = now_ts return state async def _handle_ipad_login_qr_update(self, payload: dict) -> None: """同步 provider 扫码登录态到 Robot,供 Dashboard 轮询读取。""" now_ts = time.time() uuid_value = str((payload or {}).get("uuid", "") or "").strip() scan_url = str((payload or {}).get("scan_url", "") or "").strip() raw_url = str((payload or {}).get("url", "") or "").strip() status = str((payload or {}).get("status", "waiting") or "waiting").strip() or "waiting" status_text = str((payload or {}).get("status_text", "等待扫码登录") or "等待扫码登录").strip() login_source = str((payload or {}).get("login_source", "fresh_qr") or "fresh_qr").strip() provider_name = str( (payload or {}).get("provider_name", self.ipad_config.get("server_type", "") if self.ipad_config else "") or "" ).strip() provider_stage = str((payload or {}).get("provider_stage", "waiting_scan") or "waiting_scan").strip() connection_ready = bool((payload or {}).get("connection_ready", False)) login_required = bool((payload or {}).get("login_required", True)) verification_url = str((payload or {}).get("verification_url", "") or "").strip() raw_state = int((payload or {}).get("raw_state", 0) or 0) nick_name = str((payload or {}).get("nick_name", "") or "").strip() head_img_url = str((payload or {}).get("head_img_url", "") or "").strip() expires_in = (payload or {}).get("expires_in") expires_in = None if expires_in in (None, "") else max(0, int(expires_in)) # 一旦重新进入扫码链路,就主动清掉 Robot 侧残留的账号身份: # 1. 864 登录收口失败时,provider 已明确告诉前端“当前没有可用账号”,此时继续保留旧 wxid 会误导首页状态; # 2. Dashboard 的当前账号、健康卡片、二维码弹窗都共享这份运行态缓存,必须确保未登录时不再展示历史账号; # 3. 这里只在非成功态下清空,不影响真正登录完成后的账号信息展示。 if status not in {"confirmed", "logged_in"}: self._clear_ipad_identity_cache() current_record = { "uuid": uuid_value, "scan_url": scan_url, "raw_url": raw_url, "verification_url": verification_url, "raw_state": raw_state, "nick_name": nick_name, "head_img_url": head_img_url, "image_data": self._build_qr_image_data(scan_url), "status": status, "status_text": status_text, "login_source": login_source, "provider_name": provider_name, "provider_stage": provider_stage, "connection_ready": connection_ready, "login_required": login_required, "updated_at": now_ts, "updated_at_text": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(now_ts)), } if expires_in is not None: current_record["remaining_seconds"] = expires_in current_record["expires_at"] = now_ts + expires_in with self._ipad_login_qr_lock: self.ipad_login_qr_state = { "logged_in": False, "active": status != "confirmed", "status": status, "provider_name": provider_name, "provider_stage": provider_stage, "connection_ready": connection_ready, "login_required": login_required, "status_text": status_text, "current": current_record, "history": [], "updated_at": now_ts, } def _clear_ipad_identity_cache(self) -> None: """清理 Robot 侧缓存的 wechat 账号身份,避免未登录时误显示旧号。""" self.wxid = "" self.nickname = "" self.alias = "" self.phone = "" self.signature = "" # `ipad_bot` 可能还未初始化完成,因此这里做一次存在性保护: # 1. 登录引导状态会在 provider 创建早期就被推给 Dashboard; # 2. 这时 bot 对象不一定已经完整可用,不能因为清理缓存反而引入新的 AttributeError; # 3. 所以只在对象存在时尽量同步清空,做成纯兜底动作。 if self.ipad_bot is not None: self.ipad_bot.wxid = "" self.ipad_bot.nickname = "" self.ipad_bot.alias = "" self.ipad_bot.phone = "" self.ipad_bot.signature = "" async def _handle_ipad_login_qr_cleared(self, payload: dict | None = None) -> None: """在登录完成或识别到已有登录态后关闭首页二维码引导。""" now_ts = time.time() status = str((payload or {}).get("status", "idle") or "idle").strip() or "idle" status_text = str((payload or {}).get("status_text", "登录流程已结束") or "登录流程已结束").strip() cleared_uuid = str((payload or {}).get("uuid", "") or "").strip() provider_name = str( (payload or {}).get("provider_name", self.ipad_config.get("server_type", "") if self.ipad_config else "") or "" ).strip() provider_stage = str((payload or {}).get("provider_stage", "logged_in") or "logged_in").strip() connection_ready = bool((payload or {}).get("connection_ready", True)) login_required = bool((payload or {}).get("login_required", False)) with self._ipad_login_qr_lock: self.ipad_login_qr_state = { "logged_in": status in {"confirmed", "logged_in"} or bool(self.wxid), "active": False, "status": status, "provider_name": provider_name, "provider_stage": provider_stage, "connection_ready": connection_ready, "login_required": login_required, "status_text": status_text, "current": {}, "history": [], "updated_at": now_ts, } async def _on_ipad_login_ready(self, login_identity: dict) -> None: """处理 provider 登录成功后的项目侧初始化动作。 这里保留在 Robot 的原因很明确: 1. 联系人缓存、插件注入、消息归档器、成员监控器都属于项目业务层能力; 2. provider 不应该知道本项目有哪些数据库表、后台缓存或插件系统; 3. 因此登录“流程”放到 provider,登录后的“业务初始化”继续留在 Robot。 """ # 这里再做一次项目侧兜底校验: # 1. provider 已经会尽量保证只有“拿到可用身份”才会调进来; # 2. 但 Robot 这一层承接的是联系人同步、插件注入、消息归档等重业务动作,不能接受空账号继续执行; # 3. 因此只要 `wxid/nickname` 都为空,就立刻阻断后台初始化,强制回到扫码登录流程。 if not str(login_identity.get("wxid", "") or "").strip() and not str(login_identity.get("nickname", "") or "").strip(): raise RuntimeError("当前未拿到可用登录账号身份,已阻止进入后台初始化流程") self.wxid = login_identity.get("wxid", "") self.nickname = login_identity.get("nickname", "") self.alias = login_identity.get("alias", "") self.phone = login_identity.get("phone", "") self.signature = login_identity.get("signature", "") # 这里同时把 Robot 侧的身份信息镜像回 bot,保证旧代码仍可从 `self.ipad_bot.xxx` 读取。 self.ipad_bot.wxid = self.wxid self.ipad_bot.nickname = self.nickname self.ipad_bot.alias = self.alias self.ipad_bot.phone = self.phone self.ipad_bot.signature = self.signature await self._handle_ipad_login_qr_cleared( { "status": "confirmed", "status_text": "微信已登录,二维码弹窗已关闭", } ) self.LOG.info( f"wechat_ipad登录账号信息: wxid: {self.wxid} 昵称: {self.nickname} 微信号: {self.alias} 手机号: {self.phone}" ) self.plugin_manager.inject_bot(self.ipad_bot) self.allContacts = self.get_all_contacts() friends = await self.ipad_bot.get_contract_list() self.head_images = self.get_all_head_images() self.all_chatroom_members = self.contacts_db.get_chatroom_member_list_name_all() self.contact_manager.set_contacts(self.allContacts, friends, self.head_images, self.all_chatroom_members) self.message_storage = MessageStorage(self.ipad_bot) self.member_monitor = ChatroomMemberMonitor(self.ipad_bot) ext_profile = await self.ipad_bot.get_profile_info_ext() self.ipad_bot.profile_ext = ext_profile self.head_image = ext_profile.get("SmallHeadImgUrl") async def _handle_runtime_message(self, raw_message: dict) -> None: """处理 provider 交付的单条实时原始消息。""" try: wxmsg: WxMessage = WxMessage.from_json(raw_message) self._attach_trace_id(wxmsg) msg_id = wxmsg.msg_id if msg_id in self.recent_msg_ids: self.LOG.info(self._trace_message(wxmsg, f"出现重复ID消息: {msg_id}")) return self.recent_msg_ids.append(msg_id) self.LOG.debug( self._trace_message( wxmsg, f"收到消息 type={getattr(wxmsg.msg_type, 'name', wxmsg.msg_type)} " f"sender={wxmsg.sender} room={wxmsg.roomid or '-'}" ) ) except Exception as e: self.LOG.error(f"WxMessage.from_json 解析失败,消息内容: {raw_message},错误: {e}") return # 这里继续沿用“单条消息单独起任务 + 信号量限流”的项目策略: # 1. 保持与现网处理吞吐一致; # 2. 避免 provider 轮询被某条耗时消息阻塞; # 3. 也不把并发控制职责再塞回 provider,边界更清楚。 asyncio.create_task(self._process_with_semaphore(wxmsg)) async def _handle_runtime_idle_payload(self, data_temp: dict) -> None: """处理 855 空轮询之外的补充同步负载,例如群成员变更通知。""" if isinstance(data_temp, dict) and data_temp.get("Ret") == 0 and "KeyBuf" in data_temp: return self.LOG.debug(f"MESSAGE:{data_temp}") changed_groups = self.member_monitor.parse_mod_contacts_msg(data_temp) if changed_groups: self.LOG.info(f"监测到群成员变动消息,涉及群: {changed_groups}") for group_id in changed_groups: if self.gbm.get_group_permission( group_id, Feature.GROUP_MEMBER_CHANGE, ) == PermissionStatus.ENABLED: asyncio.create_task(self.member_monitor.check_and_handle_changes(group_id)) async def _handle_ipad_logout(self, reason: str) -> None: """处理 provider 识别到的掉线事件,仅负责业务侧告警。""" self.LOG.error(f"用户可能退出: {reason}") self.email_sender.send_wechat_alert( self.config.email.get("alert_recipient"), f"用户可能退出: {reason}", self.wxid, self.nickname, ) async def _handle_runtime_state_change(self, running: bool) -> None: """镜像 provider 运行态到 Robot,供后台与运维逻辑读取。""" self.ipad_running = running async def _archive_startup_history_message(self, raw_message: dict) -> None: """启动阶段只归档历史消息,不触发实时业务处理。 目标: 1. 保留历史消息记录,方便后台查询、总结和审计; 2. 不触发插件、副作用指令、自动回复、积分统计等实时逻辑; 3. 与实时拉流阶段共享最近消息去重队列,避免边界消息被重复处理。 """ try: wxmsg: WxMessage = WxMessage.from_json(raw_message) except Exception as e: self.LOG.error(f"启动阶段历史消息解析失败,消息内容: {raw_message},错误: {e}") return try: self._attach_trace_id(wxmsg) msg_id = wxmsg.msg_id if msg_id in self.recent_msg_ids: self.LOG.debug(self._trace_message(wxmsg, f"历史消息重复,跳过归档: {msg_id}")) return # 先放入近期去重队列: # 1. 启动阶段拉到的最后几条消息,可能和实时阶段收到的第一批消息重叠; # 2. 这里提前记下 msg_id,可以避免后续被当成“新消息”再次触发业务逻辑; # 3. 该队列长度虽然有限,但足够覆盖启动切换期的边界重复问题。 self.recent_msg_ids.append(msg_id) if not self.message_storage: self.LOG.warning(self._trace_message(wxmsg, "历史消息归档跳过:message_storage 尚未初始化")) return # 历史消息只落库,不做实时业务: # 1. 不调用 process_plugin_message,避免历史消息触发插件副作用; # 2. 不调用 process_message,避免历史发言被重复计入实时统计; # 3. 不走 _process_ipad_message,避免自动加群、成员变更、媒体业务等被整段回放。 self.message_storage.archive_message(wxmsg) self.LOG.debug( self._trace_message( wxmsg, f"历史消息已归档 type={getattr(wxmsg.msg_type, 'name', wxmsg.msg_type)} " f"sender={wxmsg.sender} room={wxmsg.roomid or '-'}" ) ) except Exception as e: self.LOG.error(self._trace_message(wxmsg, f"历史消息归档失败 msg_id={wxmsg.msg_id}, 错误: {e}")) async def _process_with_semaphore(self, wxmsg): async with sem: # 进入单条消息处理前,把 trace_id 放入当前异步上下文: # 1. 后续插件中的 AI 调用、消息发送、子协程都可以自动继承这个 trace_id; # 2. 这样不需要给大量现有方法额外加 trace_id 参数,侵入性更小; # 3. finally 中会回滚 token,避免把当前消息的 trace_id 泄漏到下一条消息。 trace_token = set_current_trace_id(self._get_trace_id(wxmsg)) try: await self._process_ipad_message(wxmsg) except Exception as e: self.LOG.error(self._trace_message(wxmsg, f"处理消息失败 msg_id={wxmsg.msg_id}, 错误: {e}")) finally: reset_current_trace_id(trace_token) async def _process_ipad_message(self, message: WxMessage): """处理wechat_ipad消息""" try: # self.LOG.debug(f"message: {message}") # 消息已经是WxMessage对象,直接使用其属性和方法 # 判断是否为群消息 is_group = message.from_group() group_id = message.roomid # 检测群聊是否已加入机器人管理 if is_group and group_id not in GroupBotManager.local_cache["group_list"]: self.LOG.info(f"检测到新群聊: {group_id},自动添加到机器人管理列表并启用机器人功能") # 添加群组到列表 GroupBotManager.local_cache["group_list"].add(group_id) # 保存到Redis redis_conn = self.db_manager.get_redis_connection() redis_conn.sadd("group:list", group_id) # 设置ROBOT功能为启用状态 GroupBotManager.set_group_permission(group_id, Feature.ROBOT, PermissionStatus.ENABLED) # 获取群成员信息并更新数据库 try: chatroom_info = await self.ipad_bot.get_chatroom_info(group_id) self.LOG.debug(f"获取到群信息: {chatroom_info}") self.allContacts[group_id] = chatroom_info.get('NickName').get("string", "未知群名") if chatroom_info: # 保存群信息到数据库 self.contacts_db.save_chatroom_info(chatroom_info) members = await self.ipad_bot.get_chatroom_member_list(group_id) # 保存群成员信息 if members: # 兼容逻辑已放到save_chatroom_member_simple内部 self.contacts_db.save_chatroom_member_simple(group_id, members) self.LOG.info(f"member_list: {members}") # 更新联系人缓存 for member in members: wxid = member.get("UserName", "") nick_name = member.get("NickName", "") displayName = member.get("DisplayName", "") small_head_img_url = member.get("SmallHeadImgUrl", "") # 如果displayName不为空,使用displayName if displayName: nick_name = displayName if wxid: self.allContacts[wxid] = nick_name self.head_images[wxid] = small_head_img_url friends = await self.ipad_bot.get_contract_list() self.all_chatroom_members = self.contacts_db.get_chatroom_member_list_name_all() self.contact_manager.set_contacts(self.allContacts, friends, self.head_images, self.all_chatroom_members) self.LOG.info(f"已更新群 {group_id} 的成员信息") except Exception as e: self.LOG.error(f"获取群成员信息失败: {e}") # 尝试使用插件处理消息 await self.process_plugin_message(message) if is_group: self.LOG.debug(f"入库和记录群消息: {message}") # 调用统计逻辑进行聊天数据统计: try: if message.sender != self.wxid: self.message_storage.process_message(message) except Exception as e: self.LOG.error(self._trace_message(message, f"process_message error: {e}")) # # 聊天记录入库动作: try: self.message_storage.archive_message(message) # 单独处理图片消息 后续写定时任务自动完成下载。延时处理。 if message.msg_type == MessageType.IMAGE: # 图片消息类型 self.message_storage.process_image(message) except Exception as e: self.LOG.error(self._trace_message(message, f"archive_message error: {e}")) except Exception as e: self.LOG.error(self._trace_message(message, f"处理wechat_ipad消息出错: {e}")) def stop_wechat_ipad(self): """停止wechat_ipad客户端""" self.ipad_running = False if hasattr(self, "ipad_bot") and self.ipad_bot and hasattr(self.ipad_bot, "stop_runtime"): self.ipad_bot.stop_runtime() if self.ipad_loop: try: # 事件循环运行在独立线程里,主线程这里需要走线程安全停止: # 1. 直接 `loop.stop()` 在某些时机会留下竞态,导致旧线程迟迟不退出; # 2. Dashboard 现在需要支持“不重启主进程,直接切换 864 登录模式”; # 3. 因此这里改成 `call_soon_threadsafe`,让旧 provider 能更稳地收尾退出。 self.ipad_loop.call_soon_threadsafe(self.ipad_loop.stop) except Exception: self.ipad_loop.stop() if self.ipad_thread and self.ipad_thread.is_alive(): self.ipad_thread.join(timeout=5) self.ipad_thread = None self.ipad_loop = None self.ipad_bot = None self.LOG.info("wechat_ipad客户端已停止") def switch_server_864_login_entry(self, *, login_qr_api: str, login_way: str | None = None, do_logout: bool = True) -> dict: """切换 864 登录入口并重启登录流程。 设计说明: 1. 用户希望在后台直接切换“鸿蒙专用二维码”和“标准 New 二维码”,而不是改 `.env` 后重启整套服务; 2. 当前 864 runtime 在独立线程里长驻运行,最稳妥的切换方式是“更新配置 -> 退出旧登录态 -> 重启 provider 登录线程”; 3. 这样实现虽然比纯运行时热切换更直接,但代码层次更浅,也更便于后续继续接其他 server 变体。 """ if not isinstance(self.ipad_config, dict): raise RuntimeError("wechat_ipad 运行时配置尚未初始化,暂时无法切换 864 登录入口") current_server_type = self._normalize_wechat_provider_key(self.ipad_config.get("server_type", "legacy_855")) if current_server_type != "server_864": raise RuntimeError("当前仅支持在 server_864 模式下切换登录二维码入口") normalized_login_qr_api = str(login_qr_api or "").strip().lower() if normalized_login_qr_api not in {"harmony_api", "new", "new_x"}: raise ValueError(f"不支持的 864 登录入口模式: {login_qr_api}") normalized_login_way = str(login_way or self.ipad_config.get("login_way", "mac") or "mac").strip().lower() if normalized_login_qr_api == "harmony_api": normalized_login_way = "harmony" if do_logout and self.ipad_bot and self.ipad_loop: try: logout_future = asyncio.run_coroutine_threadsafe(self.ipad_bot.log_out(), self.ipad_loop) logout_future.result(timeout=20) except Exception as e: # 切换登录模式时,退出旧会话失败不应阻断后续重启: # 1. 某些 864 版本在会话已失效时会直接返回错误; # 2. 用户真正关心的是“后台能否尽快切到新的二维码入口”; # 3. 因此这里记录日志后继续向下执行重启流程。 self.LOG.warning(f"切换 864 登录入口前执行退出请求失败,继续重启登录流程: {e}") # `do_logout=False` 时只重启 ABOT 本地 provider,不会主动登出远端在线账号: # 1. 这样切换二维码模式不会误把当前服务端现有会话踢下线; # 2. 若远端本来就已在线,新的 provider 线程会优先尝试接管现有登录态; # 3. 真正的远端退出只保留给手动点击“退出864登录”的显式动作。 self.ipad_config["login_qr_api"] = normalized_login_qr_api self.ipad_config["login_way"] = normalized_login_way if isinstance(getattr(self.config, "wechat_ipad", None), dict): self.config.wechat_ipad["login_qr_api"] = normalized_login_qr_api self.config.wechat_ipad["login_way"] = normalized_login_way self._clear_ipad_identity_cache() switch_label_map = { "harmony_api": "864 鸿蒙专用二维码", "new": "864 标准 New 二维码", "new_x": f"864 NewX 二维码({normalized_login_way})", } with self._ipad_login_qr_lock: self.ipad_login_qr_state = { "logged_in": False, "active": True, "status": "waiting", "provider_name": "server_864", "provider_stage": "login_required", "connection_ready": False, "login_required": True, "status_text": f"正在切换到{switch_label_map.get(normalized_login_qr_api, normalized_login_qr_api)},请稍候刷新二维码", "current": { "login_qr_api": normalized_login_qr_api, "login_way": normalized_login_way, "updated_at": time.time(), "updated_at_text": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), }, "history": [], "updated_at": time.time(), } self.stop_wechat_ipad() if not self.init_wechat_ipad(): raise RuntimeError("重启 wechat_ipad 登录线程失败,请检查 864 服务端与配置") return { "server_type": "server_864", "login_qr_api": normalized_login_qr_api, "login_way": normalized_login_way, "message": switch_label_map.get(normalized_login_qr_api, normalized_login_qr_api), } def logout_server_864_and_restart_login(self) -> dict: """退出当前 864 登录态,并按现有二维码入口重新进入登录引导。""" return self.switch_server_864_login_entry( login_qr_api=str(self.ipad_config.get("login_qr_api", "new_x") or "new_x"), login_way=str(self.ipad_config.get("login_way", "mac") or "mac"), do_logout=True, ) def keep_running_and_block_process(self) -> None: """ 保持机器人运行,不让进程退出 """ while True: time.sleep(1) async def process_plugin_message(self, msg) -> bool: """使用插件处理消息""" # 获取所有消息处理插件 # 关闭00:30-05:00的系统交互,降低被风控风险 current_hour = time.localtime().tm_hour current_minute = time.localtime().tm_min is_sleep_time = (current_hour == 0 and current_minute >= 30) or (1 <= current_hour < 5) if is_sleep_time: # 只处理特定消息,如管理员消息或紧急消息 self.LOG.info(self._trace_message(msg, f"夜间休眠时间(00:30-05:00),忽略消息: {msg}")) return False message_plugins = self.plugin_registry.get_plugins_by_type(MessagePluginInterface) message_plugins = self._sort_message_plugins(message_plugins) if not message_plugins: return False # 依次尝试处理消息 for plugin in message_plugins: if plugin.status != PluginStatus.RUNNING: continue # 这里在进入插件前统一准备统计上下文: # 1. 事件系统删除后,插件调用统计需要直接在主链路埋点; # 2. 提前抽出 room_id / sender / command,后续无论成功还是异常都能复用; # 3. 这样可以保证观测逻辑收口在一处,避免每个插件自己重复埋点。 room_id = msg.roomid if msg.from_group() else "" sender = msg.sender command_name = self._extract_plugin_command(msg) started_at = time.perf_counter() try: # 转换消息为插件可处理的格式 plugin_msg = { "type": msg.msg_type, "content": msg.content.clean_content, "sender": sender, "roomid": room_id, "is_at": msg.is_at(self.wxid), "timestamp": time.time(), "trace_id": self._get_trace_id(msg), "all_contacts": self.allContacts, "full_wx_msg": msg, "gbm": self.gbm, "bot": self.ipad_bot, "revoke": self.message_auto_revoke } # 检查插件是否可以处理该消息 if plugin.can_process(plugin_msg): processed, _ = await plugin.process_message(plugin_msg) self._record_plugin_call_result( plugin=plugin, msg=msg, command_name=command_name, # 这里把“无异常执行完成”视为统计意义上的成功: # 1. 很多插件返回 False 只是表示“本次不拦截”或“异步排队后继续放行”; # 2. 若直接把 processed=False 记成失败,会把成功率统计严重拉低; # 3. 真正的失败已经会走异常分支,因此统计层这里按“未抛错即成功”更合理。 process_result=True, process_time_ms=self._elapsed_ms(started_at), ) if processed: self.LOG.info( self._trace_message( msg, f"插件命中 plugin={plugin.name} command={command_name} " f"cost_ms={self._elapsed_ms(started_at)}" ) ) return True except Exception as e: self._record_plugin_call_error( plugin=plugin, msg=msg, command_name=command_name, error=e, ) self.LOG.error(self._trace_message(msg, f"插件 {plugin.name} 处理消息失败: {e}")) return False def _attach_trace_id(self, msg: WxMessage) -> str: """为消息对象附加稳定 trace_id,便于后续全链路关联。""" trace_id = self._get_trace_id(msg) if trace_id: return trace_id msg_id = str(getattr(msg, "msg_id", "") or "0") create_time = str(getattr(msg, "create_time", "") or "0") sender_tail = str(getattr(msg, "sender", "") or "")[-6:] or "unknown" random_tail = uuid.uuid4().hex[:6] trace_id = f"wx-{msg_id}-{create_time}-{sender_tail}-{random_tail}" setattr(msg, "trace_id", trace_id) return trace_id @staticmethod def _get_trace_id(msg: WxMessage) -> str: """读取消息对象上的 trace_id;若不存在则返回空字符串。""" return str(getattr(msg, "trace_id", "") or "").strip() def _trace_message(self, msg: WxMessage, message: str) -> str: """为日志消息统一追加 trace_id 前缀。""" trace_id = self._get_trace_id(msg) if not trace_id: return message return f"[trace_id={trace_id}] {message}" @staticmethod def _elapsed_ms(started_at: float) -> float: """把 monotonic 起始时间转换为毫秒耗时。""" return round((time.perf_counter() - started_at) * 1000, 2) @staticmethod def _extract_plugin_command(msg: WxMessage) -> str: """尽力从消息内容中提取一个可读的“触发命令”。""" # 这里不追求把所有命令解析得非常精确,只要能满足后台统计可读性即可: # 1. 文本消息优先取第一段词,避免把整句长文本都记成 command; # 2. 非文本消息统一落到消息类型名,便于区分“文本触发”和“链接触发”等场景; # 3. 空内容时返回通用占位,避免统计表出现 NULL / 空字符串。 raw_content = str(getattr(getattr(msg, "content", None), "clean_content", "") or "").strip() if raw_content: first_token = raw_content.split()[0].strip() return first_token[:50] if first_token else "[文本消息]" msg_type = getattr(getattr(msg, "msg_type", None), "name", "") return f"[{msg_type or 'UNKNOWN'}]" def _get_stats_collector_plugin(self): """获取运行中的统计收集插件实例。""" # 统计插件已经从“事件订阅”切到“主链路直接回调”, # 因此每次埋点前都需要安全地确认插件实例是否存在且处于运行态。 plugin = self.plugin_manager.plugins.get("指令记录") if not plugin: return None if getattr(plugin, "status", None) != PluginStatus.RUNNING: return None return plugin def _record_plugin_call_result( self, *, plugin, msg: WxMessage, command_name: str, process_result: bool, process_time_ms: float, ) -> None: """将插件执行结果直接写入统计插件。""" stats_plugin = self._get_stats_collector_plugin() if not stats_plugin or not hasattr(stats_plugin, "record_plugin_call"): return try: stats_plugin.record_plugin_call( plugin_name=plugin.name, command=command_name, user_id=msg.sender, group_id=msg.roomid if msg.from_group() else None, is_group=msg.from_group(), process_result=process_result, process_time_ms=process_time_ms, trace_id=self._get_trace_id(msg), ) except Exception as stats_error: self.LOG.error(self._trace_message(msg, f"记录插件调用统计失败: plugin={plugin.name}, error={stats_error}")) def _record_plugin_call_error( self, *, plugin, msg: WxMessage, command_name: str, error: Exception, ) -> None: """将插件执行异常直接写入统计插件。""" stats_plugin = self._get_stats_collector_plugin() if not stats_plugin or not hasattr(stats_plugin, "record_plugin_error"): return try: stats_plugin.record_plugin_error( plugin_name=plugin.name, command=command_name, user_id=msg.sender, group_id=msg.roomid if msg.from_group() else None, is_group=msg.from_group(), error_message=str(error), trace_id=self._get_trace_id(msg), # 这里保留完整堆栈,便于后台直接查看异常上下文,而不必只看摘要日志。 stack_trace=traceback.format_exc(), ) except Exception as stats_error: self.LOG.error(self._trace_message(msg, f"记录插件异常统计失败: plugin={plugin.name}, error={stats_error}")) @staticmethod def _sort_message_plugins(message_plugins): """将兜底型插件放到最后执行,避免影响其他插件命中。""" if not message_plugins: return [] def is_fallback_plugin(plugin): feature_key = str(getattr(plugin, "feature_key", "") or "").strip().upper() module_name = str(getattr(plugin.__class__, "__module__", "") or "").lower() plugin_name = str(getattr(plugin, "name", "") or "").strip().lower() return ( feature_key == "AI_AUTO_RESPONSE" or "plugins.ai_auto_response" in module_name or plugin_name in {"小牛群聊bot", "ai_auto_response"} ) normal_plugins = [plugin for plugin in message_plugins if not is_fallback_plugin(plugin)] fallback_plugins = [plugin for plugin in message_plugins if is_fallback_plugin(plugin)] return normal_plugins + fallback_plugins def get_all_contacts(self) -> dict: """获取所有联系人信息并返回字典格式 {wxid: nickname}""" try: # 从数据库获取联系人信息 contacts = self.contacts_db.get_all_contacts() return contacts except Exception as e: self.LOG.error(f"获取联系人信息失败: {e}") return {} def get_all_head_images(self) -> dict: """获取所有的联系人头像信息""" try: # 从数据库获取所有联系人的头像信息 head_images = self.contacts_db.get_all_contacts_avatar() return head_images except Exception as e: self.LOG.error(f"获取所有联系人头像信息失败: {e}") return {} async def refresh_contacts_db(self): """刷新联系人信息""" self.LOG.info("开始刷新联系人信息") contacts = await self.ipad_bot.get_contract_list() self.LOG.debug(f"获取到的联系人:{contacts}") batch_size = 20 discovered_groups = set() for i in range(0, len(contacts), batch_size): batch_contacts = contacts[i:i + batch_size] contact_info = await self.ipad_bot.get_contract_detail(batch_contacts) self.LOG.debug(f"获取到的联系人详细信息数量:{len(contact_info)}") friend_contacts = [] official_contacts = [] for contact in contact_info: user_name = contact.get("UserName") if isinstance(user_name, dict): user_name = user_name.get("string", "") user_name = user_name or "" if not user_name: continue if user_name.endswith("@chatroom"): discovered_groups.add(user_name) # 群资料这里不能只在“首次发现”时写入: # 1. 群头像、小群名、公告等字段都可能在微信侧发生变化; # 2. 如果只插入不更新,后续头像缓存拿到的仍然会是旧 URL; # 3. 因此每次刷新通讯录都做一次 upsert,确保群资料是最新的。 self.contacts_db.save_chatroom_info(contact) continue if user_name.startswith("gh_"): official_contacts.append(contact) else: friend_contacts.append(contact) # 联系人详情这里必须允许覆盖更新: # 1. get_contract_detail 已经重新向远端拿到了最新昵称、签名、头像 URL; # 2. 如果 still only_insert=True,库里旧联系人将永远保留历史头像地址; # 3. 改成 upsert 后,后续头像缓存同步才能真正拿到最新 URL 并下载新头像。 if friend_contacts: self.contacts_db.save_contacts(friend_contacts, "friends", only_insert=False) if official_contacts: self.contacts_db.save_contacts(official_contacts, "ghs", only_insert=False) groups = self.contacts_db.get_chatroom_list() for group in groups: group_id = group["chatroom_id"] discovered_groups.add(group_id) chatroom_info = await self.ipad_bot.get_chatroom_info(group_id) self.LOG.debug(f"获取到的群成员信息:{chatroom_info}") if chatroom_info.get("UserName", ""): members = await self.ipad_bot.get_chatroom_member_list(group_id) if members: active_member_wxids = [] for member in members: wxid = member.get("UserName", "") if isinstance(wxid, dict): wxid = wxid.get("string", "") if wxid: active_member_wxids.append(wxid) self.contacts_db.mark_chatroom_members_active(group_id, active_member_wxids) self.contacts_db.mark_chatroom_members_left(group_id, active_member_wxids) # 群成员头像 URL 同样需要覆盖更新: # 1. 群成员换头像后,成员表里的 small_head_img_url 会变; # 2. 若只做 INSERT IGNORE,则历史记录不会被刷新; # 3. 这里改成 upsert,保证后台通讯录与头像缓存都能感知到最新头像地址。 self.contacts_db.save_chatroom_member_simple(group_id, members, only_insert=False) self.LOG.info(f"已增量同步群 {group_id} 的成员信息") else: self.contacts_db.mark_chatroom_members_left(group_id, []) self.LOG.warning(f"群 {group_id} 当前未获取到成员列表,已将历史成员标记为已退群") else: self.LOG.warning(f"获取群 {group_id} 信息失败,保留群资料并将成员标记为已退群。") self.contacts_db.mark_chatroom_members_left(group_id, []) for group_id in discovered_groups: if not self.contacts_db.get_chatroom_info(group_id): chatroom_info = await self.ipad_bot.get_chatroom_info(group_id) if chatroom_info.get("UserName", ""): self.contacts_db.save_chatroom_info(chatroom_info) members = await self.ipad_bot.get_chatroom_member_list(group_id) if members: self.contacts_db.save_chatroom_member_simple(group_id, members, only_insert=False) friends = await self.ipad_bot.get_contract_list() self.allContacts = self.get_all_contacts() self.head_images = self.get_all_head_images() self.all_chatroom_members = self.contacts_db.get_chatroom_member_list_name_all() self.contact_manager.set_contacts(self.allContacts, friends, self.head_images, self.all_chatroom_members) self.LOG.info("联系人信息刷新完成") # ============================================== 系统级任务(刚需)========================================================== async def message_count_to_db(self): try: self.message_storage.write_to_db() except Exception as e: self.LOG.error(f"write_to_db error:{e}")