# coding=utf-8 import os import sys import logging import asyncio import hashlib import json import numpy as np import cv2 # 添加项目根目录到 sys.path project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if project_root not in sys.path: sys.path.append(project_root) from WeiXin import WxUtil from WeiXin.WxUtil import perform_input_action from Util.LlmUtil import get_llm_response from Util import Win32Patch # 配置日志 log_dir = WxUtil.LOG_DIR if not os.path.exists(log_dir): os.makedirs(log_dir) log_file_path = os.path.join(log_dir, "T2_ChatMonitor.log") # 设置 logger logger = logging.getLogger("T2_ChatMonitor") logger.setLevel(logging.INFO) if logger.hasHandlers(): logger.handlers.clear() file_handler = logging.FileHandler(log_file_path, encoding='utf-8', mode='w') file_handler.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')) logger.addHandler(file_handler) stream_handler = logging.StreamHandler() stream_handler.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')) logger.addHandler(stream_handler) logger.propagate = False logger.info(f"🚀 日志文件路径: {os.path.abspath(log_file_path)}") # 同时将 WxUtil 的日志也输出到同一个文件 wx_logger = logging.getLogger("WxUtil") wx_logger.propagate = False # 防止日志向上传递导致重复 (因为 WxUtil 中调用了 basicConfig) if not any(isinstance(h, logging.FileHandler) and os.path.abspath(h.baseFilename) == os.path.abspath(log_file_path) for h in wx_logger.handlers): wx_logger.addHandler(file_handler) wx_logger.addHandler(stream_handler) # 确保 WxUtil 也输出到控制台 class ChatMonitorBot: """ 大张老师自动巡课系统 (CV版) """ def __init__(self): self.device = None self.screenshot_path = os.path.join(WxUtil.OUTPUT_DIR, "T2_ChatMonitor_live_shot.jpg") self.debug_view_path = os.path.join(WxUtil.OUTPUT_DIR, "T2_ChatMonitor_debug_view.jpg") self.dialogue_log = [] self.input_pos = None self.last_screen_hash = None self.last_processed_msg_hash = None # [User Requested] 移除持久化存储,只在内存中记录,重启即忘 self.processed_hashes = set() # 新增:记录已处理消息的元数据 (sender, time_display, type) 用于防止空内容重试循环 self.processed_meta = set() self.check_interval = 3 # 检查频率 (秒) self.persona = ( "你是一名1999年毕业、拥有27年一线教学经验的小学高级女教师,名叫‘大张老师’。你目前在‘长春市少惠林作文素养培养中心’工作。" "你不仅是一位作文教学专家,更是一位心思细腻、能与家长共情的教育智者。" "你的回复风格应该是:温柔、知性、亲切,就像一位邻家大姐姐在聊天。" "【严格约束】:\n" "1. 绝对禁止发散!绝对禁止幻觉!\n" "2. 知道什么就说什么,不要乱讲话,不要自己编造内容!\n" "3. 仅针对家长明确表达的内容进行回复。\n" "4. 严禁使用列表格式。严禁使用‘首先、其次’等逻辑词。\n" "5. 回复必须简练,字数严格控制在 50 字以内!\n" "6. 对方问什么就答什么。例如问‘学校叫什么’,就只回答‘少惠林’,不要回复地址和电话!\n" "如果涉及到校区信息,必须且只能使用以下真实数据:\n" "- 单位/学校名称:长春市少惠林作文素养培养中心(简称:少惠林)\n" "- 地址:南环城路与临河街交汇,TOUCH12街3楼325号\n" "- 联系人:小张老师(电话:18686619970)\n" "- 每学期开学招收小学三年级至六年级,初中七年级的学生入学,其它年段不招生。\n" ) def _record_processed_hash(self, msg, msg_hash): """记录已处理的消息哈希和元数据 (仅内存)""" self.processed_hashes.add(msg_hash) # 记录元数据 (Sender, Time, Type) if msg: meta = (msg.get("sender", ""), msg.get("time_display", ""), msg.get("type", "")) self.processed_meta.add(meta) # 仅保留最近 100 条记录,防止无限增长 if len(self.processed_hashes) > 100: # 简单丢弃旧的 temp = list(self.processed_hashes)[-100:] self.processed_hashes = set(temp) if len(self.processed_meta) > 100: temp_meta = list(self.processed_meta)[-100:] self.processed_meta = set(temp_meta) async def get_reply(self, last_message_text, context_text=""): prompt = ( f"【教师人设】:{self.persona}\n\n" f"【上下文对话内容】:\n{context_text}\n\n" f"【最后一条待回复消息】:\n{last_message_text}\n\n" "【任务要求】:\n" "请作为大张老师回复家长。**必须且只能针对最后一条消息进行回复!**\n" "参考上下文对话内容,确保回复逻辑连贯。\n" "严禁发散,严禁编造家长没说过的情况。如果不清楚家长的意图,就温柔询问。\n" "字数严格控制在 50 字以内。直接输出回复正文。" ) full_response = "" async for chunk in get_llm_response(prompt, stream=False): full_response += chunk return full_response.strip().strip('"').strip('“').strip('”') def step_1_prepare_env(self): """步骤1: 环境准备""" logger.info("--- [Step 1] 环境准备 ---") WxUtil.setup_script_environment() return True def step_2_connect_device(self): """步骤2: 连接设备""" logger.info("--- [Step 2] 连接设备 ---") self.device = WxUtil.connect_device() if not self.device: logger.error("❌ 设备连接失败,请检查手机是否连接且开启了调试模式") return False return True def get_image_hash(self, file_path): """计算图片的 MD5 哈希值 (忽略顶部 100 像素的状态栏)""" if not os.path.exists(file_path): return None try: # 使用 OpenCV 读取图片 img = cv2.imread(file_path) if img is None: # 如果读取失败,回退到文件哈希 with open(file_path, "rb") as f: return hashlib.md5(f.read()).hexdigest() # 裁剪掉顶部 150 像素 (状态栏/时间) h, w = img.shape[:2] if h > 150: cropped_img = img[150:h, 0:w] else: cropped_img = img # 计算裁剪后数据的哈希 return hashlib.md5(cropped_img.tobytes()).hexdigest() except Exception as e: logger.error(f"计算哈希出错: {e}, 回退到文件哈希") with open(file_path, "rb") as f: return hashlib.md5(f.read()).hexdigest() def get_stable_message_hash(self, msg): """ 计算消息的稳定哈希值(忽略坐标等易变字段) 仅包含: sender, content, time_display, type """ if not msg: return "" stable_data = { "sender": msg.get("sender", ""), "content": msg.get("content") or "", # 确保 None 转为空字符串 "time_display": msg.get("time_display", ""), "type": msg.get("type", "") } # 序列化并计算哈希 msg_str = json.dumps(stable_data, sort_keys=True, ensure_ascii=False) return hashlib.md5(msg_str.encode('utf-8')).hexdigest() async def run(self): """ 主运行循环 """ logger.info("🚀 正在启动 T2_ChatMonitor (Auto-Reply)...") # 定义 JSON 序列化辅助函数 def numpy_serializer(obj): if isinstance(obj, np.integer): return int(obj) if isinstance(obj, np.floating): return float(obj) if isinstance(obj, np.ndarray): return obj.tolist() raise TypeError(f"Type {type(obj)} not serializable") # 1. 环境准备 if not self.step_1_prepare_env(): return if not self.step_2_connect_device(): return # [User Requested] 移除首屏概念,直接进入监控循环 # 以前说过什么都不管了,只关注最后一条 logger.info("🚀 启动完成,直接进入实时监控阶段...") # 3. 进入循环阶段 while True: try: # A. 截图并计算哈希 self.device.screenshot(self.screenshot_path) current_screen_hash = self.get_image_hash(self.screenshot_path) # B. 如果屏幕无变化,则跳过识别 if current_screen_hash == self.last_screen_hash: await asyncio.sleep(self.check_interval) continue self.last_screen_hash = current_screen_hash logger.info("📸 屏幕发生变化,正在分析...") # C. 分析最新图片 dialogue_log, input_pos = await WxUtil.analyze_chat_image( self.screenshot_path, self.debug_view_path, device=self.device, process_strategy="UNREAD", # 监控阶段:只处理带红点的新语音 restore_processed_voice=False # 不还原状态,防止红点未消导致重复转换 ) if not dialogue_log: logger.info("😴 未识别到有效消息") await asyncio.sleep(self.check_interval) continue logger.info(f"📊 当前识别到 {len(dialogue_log)} 条消息,最后一条: {dialogue_log[-1]}") # 更新当前对话日志(可用于上下文参考) self.dialogue_log = dialogue_log self.input_pos = input_pos # D. 只关注最后一条消息 last_msg = dialogue_log[-1] # 计算稳定哈希(忽略坐标变化) current_msg_hash = self.get_stable_message_hash(last_msg) # E. 判断是否需要回复 (对方发送且非重复消息) sender = last_msg.get('sender', '') # Check if hash is already processed (in-memory only) is_processed = current_msg_hash in self.processed_hashes # Log only if it changed from last *in-memory* check to avoid spam if is_processed and current_msg_hash != self.last_processed_msg_hash: # logger.info(f"🚫 [监控] 消息哈希已存在于历史记录中,跳过回复 (Hash: {current_msg_hash})") self.last_processed_msg_hash = current_msg_hash if not is_processed and current_msg_hash != self.last_processed_msg_hash: if sender != "我": event_shot = WxUtil.get_next_debug_path("event_new_msg") self.device.screenshot(event_shot) logger.info(f"💡 [监控] 发现新消息: {last_msg},保存现场截图: {event_shot}") # 获取上下文文本 (格式化为 Sender: Content) context_text = "\n".join([f"{m.get('time_display', '') + ' ' if m.get('time_display') else ''}{m.get('sender')}: {m.get('content')}" for m in dialogue_log[:-1]]) last_content = last_msg.get('content') or "" # 兜底逻辑:如果最后一条是语音且内容为空(可能因无红点未被 UNREAD 策略处理),尝试强制转换 if last_msg.get('type') == 'voice' and not last_content.strip(): logger.info("⚠️ [监控] 最后一条语音消息未获取到内容(可能已读无红点),尝试强制转换...") # 强制使用 LAST 策略重试,并且不还原状态(保持文字展开),防止下次循环因无内容再次触发重试 dialogue_log_retry, _ = await WxUtil.analyze_chat_image( self.screenshot_path, self.debug_view_path, device=self.device, process_strategy="LAST", restore_processed_voice=False ) if dialogue_log_retry: # 更新引用 self.dialogue_log = dialogue_log_retry dialogue_log = dialogue_log_retry last_msg = dialogue_log[-1] last_content = last_msg.get('content') or "" logger.info(f"🔄 [重试] 强制转换后内容: {last_content}") # 重新构建哈希 current_msg_hash = self.get_stable_message_hash(last_msg) # 再次检查是否已处理 (因为内容变了,哈希变了) if current_msg_hash in self.processed_hashes: logger.info(f"🚫 [重试] 转换后发现该消息已处理,跳过。") self.last_processed_msg_hash = current_msg_hash # 跳过本次循环的剩余部分 continue # 生成回复 reply = await self.get_reply(last_content, context_text) if reply: logger.info(f"🤖 [监控] LLM 建议回复: {reply}") if self.input_pos: logger.info(f"⚡ [监控] 执行自动回复...") # input_pos 是 ((x,y), box) 格式,取第一个元素坐标点 target_pos = self.input_pos[0] if isinstance(self.input_pos, (list, tuple)) and len(self.input_pos) == 2 and isinstance(self.input_pos[0], (list, tuple)) else self.input_pos # 简单兼容处理:如果 input_pos[0] 是 tuple/list 且 input_pos[1] 是 None/box,则取 input_pos[0] if isinstance(self.input_pos, (list, tuple)) and len(self.input_pos) == 2 and isinstance(self.input_pos[0], (list, tuple)): target_pos = self.input_pos[0] # [User Request] 语音转文字未关闭 -> 顺序不对 # 在回复发送前,尝试关闭语音转文字(如果最后一条是语音) # 这样截图看起来更干净,且符合用户期望的顺序 if last_msg.get('type') == 'voice': logger.info("🧹 [监控] 尝试关闭语音转文字显示...") try: # 点击语音消息中心,关闭文字 cx, cy = last_msg['center'] WxUtil.safe_device_click(self.device, cx, cy) # 稍微等待界面刷新 await asyncio.sleep(1) except Exception as e: logger.warning(f"关闭语音转文字失败: {e}") perform_input_action(self.device, target_pos, reply) # 发送后截图留存 reply_sent_shot = WxUtil.get_next_debug_path("event_reply_sent") self.device.screenshot(reply_sent_shot) logger.info(f"✅ [监控] 回复已发送,保存发送后截图: {reply_sent_shot}") self._record_processed_hash(last_msg, current_msg_hash) self.last_processed_msg_hash = current_msg_hash else: logger.error("❌ 未找到输入框位置,无法发送回复") else: logger.info("⚪ [监控] LLM 认为无需回复") self._record_processed_hash(last_msg, current_msg_hash) self.last_processed_msg_hash = current_msg_hash else: # 是我发的消息,更新哈希,不再处理 self.last_processed_msg_hash = current_msg_hash await asyncio.sleep(self.check_interval) except Exception as e: logger.error(f"Error in monitoring loop: {e}", exc_info=True) await asyncio.sleep(self.check_interval) async def run_main(): """ 运行自动巡课机器人 """ bot = ChatMonitorBot() await bot.run() if __name__ == "__main__": # 应用 Win32 补丁 Win32Patch.patch() asyncio.run(run_main())