Files
aiData/WeiXin/ChatMonitorAll_ReplyTxt.py
HuangHai 00375a80b2 'commit'
2026-01-31 17:29:57 +08:00

394 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# coding=utf-8
import os
import sys
import time
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, debug_mode=False):
self.device = None
self.debug_mode = debug_mode
# 同步设置 WxUtil 的调试模式
WxUtil.set_debug_mode(debug_mode)
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"
"**仅当且仅当**家长明确询问孩子今天的作文情况(例如:“孩子今天写得怎么样?”、“今天作文有进步吗?”)时,才触发此逻辑。触发时,请随机生成具体、专业的反馈。反馈应包含:\n"
"1. 孩子今天在写作中遇到的具体小问题(如修辞手法运用不当、开头略显生硬等);\n"
"2. 值得表表扬的亮点(如观察细致、某个句子写得很传神等);\n"
"3. 对字迹的评价(如字迹工整、卷面整洁等);\n"
"4. 针对性的课外阅读建议(推荐具体类型的书目)。\n"
"此类特定回复字数可适当增加,控制在 150 字以内。\n"
"【关于通用咨询的回复逻辑】:\n"
"如果家长是咨询学校信息、地址、课程时间、年级设置,或者是刚加好友打招呼,请务必保持礼貌、知性、亲切,直接回答相关问题。严禁在这些情况下提及孩子的作文表现或虚构作文反馈。\n"
"【严格约束】:\n"
"1. 绝对禁止输出任何括号内的内容(如动作、神态描述等)!例如不要输出‘(微笑)’。\n"
"2. 绝对禁止发散!绝对禁止幻觉!\n"
"3. 知道什么就说什么,不要乱讲话,不要自己编造内容!\n"
"4. 仅针对家长明确表达的内容进行回复。\n"
"5. 严禁使用列表格式。严禁使用‘首先、其次’等逻辑词。\n"
"6. 普通咨询回复字数严格控制在 50 字以内,专业作文评价回复控制在 150 字以内!\n"
"7. 说话要有人情味,不要回答得太生硬或太简短。例如当家长问‘学校叫什么名字’时,不要只说‘少惠林’,而应该说‘我们的学校名字叫少惠林。’或者‘咱们这儿叫少惠林。’,这样才显得亲切、有礼貌。\n"
"8. 对方问什么就答什么。回答要完整、体面,但不要过度发散地址和电话(除非被问到)。\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"
"1. **意图识别**:判断家长是否在询问“孩子今天的作文写得怎么样”。\n"
"2. **条件回复**\n"
" - 如果是询问作文,请严格按【关于作文评价的回复逻辑】生成 150 字以内的详细专业反馈。\n"
" - 如果是咨询学校地址、课程、年级、打招呼等通用问题,请按【关于通用咨询的回复逻辑】简洁回答,严禁提到任何关于孩子作文的内容,字数控制在 50 字以内。\n"
"3. **格式要求**:严禁发散,直接输出回复正文,不包含任何括号内的动作描述。"
)
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)
# [User Requested] 检查是否在聊天界面
if not WxUtil.check_is_chat_interface(self.screenshot_path):
logger.info("当前不在聊天界面,跳过本次循环,等待中...")
await asyncio.sleep(self.check_interval)
continue
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. 分析最新图片:识别发送者、消息类型及内容
logger.info("正在分析聊天界面...")
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
# 更新当前对话日志
self.dialogue_log = dialogue_log
self.input_pos = input_pos
# [Fix] 核心逻辑增强:只有当屏幕上最后一条聊天消息是"对方"时,才触发回复逻辑
# 这样可以彻底避免在自己回复后,因为 OCR 延迟或哈希误判导致的连续回复
chat_msgs = [m for m in dialogue_log if m.get('type') in ['text', 'voice']]
if not chat_msgs:
await asyncio.sleep(self.check_interval)
continue
absolute_last_msg = chat_msgs[-1]
if absolute_last_msg.get('sender') == "":
# logger.info("最后一条消息是我发送的,无需回复")
await asyncio.sleep(self.check_interval)
continue
# 提取最新消息并检查是否需要回复
# 过滤出对方发送的消息 (用于提取内容)
other_msgs = [m for m in dialogue_log if m.get('sender') != "" and m.get('type') in ['text', 'voice']]
if not other_msgs:
logger.info("当前没有对方发送的消息")
await asyncio.sleep(self.check_interval)
continue
last_msg = other_msgs[-1]
# 如果最后一条对方消息不是屏幕上的绝对最后一条,说明中间隔了我自己的消息,也跳过
if last_msg != absolute_last_msg:
logger.info("最后一条对方消息之后有我的消息,跳过")
await asyncio.sleep(self.check_interval)
continue
current_msg_hash = self.get_stable_message_hash(last_msg)
# 检查该消息是否已经处理过 (通过内容哈希)
is_processed = current_msg_hash in self.processed_hashes
if is_processed:
if current_msg_hash != self.last_processed_msg_hash:
self.last_processed_msg_hash = current_msg_hash
await asyncio.sleep(self.check_interval)
continue
if not is_processed and current_msg_hash != self.last_processed_msg_hash:
logger.info(f"💡 发现新消息 [{last_msg.get('type')}]: {last_msg.get('content')}")
# 记录发现新消息的现场截图
msg_shot_path = os.path.join(WxUtil.OUTPUT_DIR, f"NewMsg_{int(time.time())}.jpg")
self.device.screenshot(msg_shot_path)
logger.info(f"已保存新消息现场截图: {msg_shot_path}")
# 获取上下文文本
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 if m != last_msg])
last_content = last_msg.get('content') or ""
# 兜底逻辑:语音消息若无文字内容,尝试强制触发重试
if last_msg.get('type') == 'voice' and not last_content.strip():
logger.info("检测到未成功转换的语音消息,尝试强制重试 OCR 转换...")
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:
retry_other_msgs = [m for m in dialogue_log_retry if m.get('sender') != ""]
if retry_other_msgs:
last_msg = retry_other_msgs[-1]
last_content = last_msg.get('content') or ""
current_msg_hash = self.get_stable_message_hash(last_msg)
if current_msg_hash in self.processed_hashes:
self.last_processed_msg_hash = current_msg_hash
continue
if last_msg.get('type') == 'voice' and not last_content.strip():
logger.warning("语音消息内容为空,暂不生成回复")
await asyncio.sleep(self.check_interval)
continue
# E. 生成回复
reply = await self.get_reply(last_content, context_text)
if reply:
logger.info(f"LLM 建议回复: {reply}")
if self.input_pos:
# 确定输入框位置
target_pos = self.input_pos[0] if isinstance(self.input_pos, (list, tuple)) and len(self.input_pos) == 2 else self.input_pos
# 执行输入和发送动作,并保存过程截图
success = perform_input_action(
self.device,
target_pos,
reply,
auto_send=True,
debug_prefix=f"Reply_{int(time.time())}"
)
if success:
logger.info(">>> 回复发送成功 <<<")
self._record_processed_hash(last_msg, current_msg_hash)
self.last_processed_msg_hash = current_msg_hash
else:
logger.error("回复动作执行失败")
else:
logger.error("无法定位输入框坐标,放弃本次回复")
else:
logger.info("LLM 认为无需回复")
self._record_processed_hash(last_msg, current_msg_hash)
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():
"""
运行自动巡课机器人
"""
# 默认关闭调试模式以提高响应速度,如需调试可设为 True
bot = ChatMonitorBot(debug_mode=False)
await bot.run()
if __name__ == "__main__":
# 应用 Win32 补丁
Win32Patch.patch()
asyncio.run(run_main())