#!/usr/bin/env python # -*- coding: utf-8 -*- import ctypes import ctypes.wintypes import json import os import shutil import time from io import BytesIO import pyautogui import pyperclip import requests from app.infrastructure.service.logging.log_service import log_event, new_trace_id from app.infrastructure.service.wechat.ocr import OCRService from app.infrastructure.service.wechat.screenshot import ScreenshotService from app.infrastructure.service.wechat.session_service import WechatSessionService from app.infrastructure.service.wechat.config import ( LOOP_INTERVAL, LOOP_ERROR_DELAY, CLICK_AFTER_DELAY, TITLE_AFTER_DELAY, CONTACT_SWITCH_DELAY, BOT_LOG_FILE, BOT_SESSION_LIST_LOG_FILE, BOT_SESSION_DETAIL_LOG_FILE, WECHAT_WINDOW_TARGET_WIDTH, WECHAT_WINDOW_TARGET_HEIGHT, WECHAT_WINDOW_TARGET_LEFT, WECHAT_WINDOW_TARGET_TOP, OCR_SAVE_IMAGES, OCR_SAVE_DIR, BLOCKED_ROW_CACHE_FILE, CONTACT_ROW_HEIGHT, CONTACT_ROW_WIDTH, CONTACT_LIST_LEFT_OFFSET, CONTACT_LIST_TOP_OFFSET, CONTACT_LIST_BOTTOM_OFFSET, SESSION_NAME_LEFT_OFFSET, SESSION_NAME_TOP_OFFSET, SESSION_NAME_WIDTH, SESSION_NAME_HEIGHT, CHAT_CAPTURE_LEFT_OFFSET, CHAT_CAPTURE_TOP_OFFSET, CHAT_CAPTURE_WIDTH, CHAT_CAPTURE_HEIGHT, TITLE_OCR_AREA_LEFT_OFFSET, TITLE_OCR_AREA_TOP_OFFSET, TITLE_OCR_AREA_WIDTH, TITLE_OCR_AREA_HEIGHT, ) class WechatMultiChatBot: def _sleep_interruptible(self, seconds): end_ts = time.time() + max(0.0, float(seconds or 0)) while self.running and time.time() < end_ts: time.sleep(min(0.1, end_ts - time.time())) def submit_message(self, session_name, content, confidence="", bubble_side=""): trace_id = new_trace_id("bot") backend_url = (os.getenv("BACKEND_URL") or "").strip() if not backend_url or not content: log_event("WARNING", "bot", "bot.submit", trace_id, "submit", "failed", "消息上报参数不完整", reason="invalid_input") return {"success": False, "should_reply": False, "reply_text": ""} try: payload = { "wx_user_id": session_name or "", "wx_nickname": session_name or "", "content": content, "is_friend_request": 0, "ocr_confidence": confidence, "ocr_bubble_side": bubble_side, } resp = requests.post(backend_url, json=payload, timeout=5) if resp.status_code != 200: log_event("WARNING", "bot", "bot.submit", trace_id, "submit", "failed", "消息上报失败", reason="http_error", extra={"status_code": resp.status_code}) return {"success": False, "should_reply": False, "reply_text": ""} data = resp.json() if resp.text else {} if not isinstance(data, dict): log_event("WARNING", "bot", "bot.submit", trace_id, "submit", "failed", "消息上报返回格式异常", reason="invalid_response") return {"success": False, "should_reply": False, "reply_text": ""} result = { "success": bool(data.get("success")), "should_reply": bool(data.get("should_reply")), "reply_text": (data.get("reply_text") or "").strip(), } log_event("INFO", "bot", "bot.submit", trace_id, "submit", "ok", "消息上报成功", extra={"should_reply": result["should_reply"]}) return result except Exception as e: log_event("ERROR", "bot", "bot.submit", trace_id, "submit", "failed", "消息上报异常", reason="request_error", extra={"error": str(e)}) return {"success": False, "should_reply": False, "reply_text": ""} def __init__(self): self.wechat_hwnd = 0 self.ocr = OCRService() self.screenshot = ScreenshotService() self.session_service = WechatSessionService( screenshot_service=self.screenshot, ocr_service=self.ocr, save_debug_image=self.save_debug_image, ) self.running = False self.blocked_row_cache = self.load_blocked_row_cache() self.init_debug_dirs() self.find_wechat_window() def load_blocked_row_cache(self): try: if not os.path.exists(BLOCKED_ROW_CACHE_FILE): return {} with open(BLOCKED_ROW_CACHE_FILE, "r", encoding="utf-8") as f: data = json.load(f) if isinstance(data, dict): filtered = {k: v for k, v in data.items() if isinstance(k, str) and k.startswith("title:")} dropped = len(data) - len(filtered) return filtered except Exception as e: pass return {} def save_blocked_row_cache(self): try: os.makedirs(os.path.dirname(BLOCKED_ROW_CACHE_FILE), exist_ok=True) with open(BLOCKED_ROW_CACHE_FILE, "w", encoding="utf-8") as f: json.dump(self.blocked_row_cache, f, ensure_ascii=False, indent=2) except Exception as e: pass def init_debug_dirs(self): if not OCR_SAVE_IMAGES: return try: if os.path.exists(OCR_SAVE_DIR): shutil.rmtree(OCR_SAVE_DIR) os.makedirs(os.path.join(OCR_SAVE_DIR, "sessions", "all"), exist_ok=True) os.makedirs(os.path.join(OCR_SAVE_DIR, "sessions", "unread"), exist_ok=True) os.makedirs(os.path.join(OCR_SAVE_DIR, "sessions", "clicked"), exist_ok=True) os.makedirs(os.path.join(OCR_SAVE_DIR, "sessions", "name_ocr"), exist_ok=True) except Exception as e: pass def find_wechat_window(self): user32 = ctypes.windll.user32 found_hwnd = [] EnumWindowsProc = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p) def _callback(hwnd, lparam): if not user32.IsWindowVisible(hwnd): return True length = user32.GetWindowTextLengthW(hwnd) if length <= 0: return True buf = ctypes.create_unicode_buffer(length + 1) user32.GetWindowTextW(hwnd, buf, length + 1) title = (buf.value or "").strip() if "微信" in title: found_hwnd.append(int(hwnd)) return False return True user32.EnumWindows(EnumWindowsProc(_callback), 0) if found_hwnd: self.wechat_hwnd = found_hwnd[0] self.normalize_wechat_window() return raise Exception("未找到微信窗口") def normalize_wechat_window(self): try: user32 = ctypes.windll.user32 hwnd = int(self.wechat_hwnd or 0) if not hwnd: return user32.ShowWindow(hwnd, 9) user32.SetForegroundWindow(hwnd) time.sleep(0.25) user32.SetWindowPos( hwnd, 0, int(WECHAT_WINDOW_TARGET_LEFT), int(WECHAT_WINDOW_TARGET_TOP), int(WECHAT_WINDOW_TARGET_WIDTH), int(WECHAT_WINDOW_TARGET_HEIGHT), 0x0004 | 0x0010, ) except Exception as e: pass def get_window_rect(self): try: user32 = ctypes.windll.user32 hwnd = int(self.wechat_hwnd or 0) if not hwnd: return None client_rect = ctypes.wintypes.RECT() ok = user32.GetClientRect(hwnd, ctypes.byref(client_rect)) if not ok: return None top_left = ctypes.wintypes.POINT(0, 0) bottom_right = ctypes.wintypes.POINT(int(client_rect.right), int(client_rect.bottom)) if not user32.ClientToScreen(hwnd, ctypes.byref(top_left)): return None if not user32.ClientToScreen(hwnd, ctypes.byref(bottom_right)): return None return { 'left': int(top_left.x), 'top': int(top_left.y), 'right': int(bottom_right.x), 'bottom': int(bottom_right.y), 'width': int(bottom_right.x - top_left.x), 'height': int(bottom_right.y - top_left.y), } except Exception as e: return None def save_debug_image(self, image_obj, filename): if not OCR_SAVE_IMAGES: return try: file_path = os.path.join(OCR_SAVE_DIR, filename) os.makedirs(os.path.dirname(file_path), exist_ok=True) image_obj.save(file_path) except Exception as e: pass def click_unread_session(self, session): self.session_service.reset_session_title_cache() click_x = session.get('click_x', session.get('center_x')) click_y = session.get('click_y', session.get('center_y')) if click_x is None or click_y is None: return pyautogui.click(int(click_x), int(click_y)) self._sleep_interruptible(CLICK_AFTER_DELAY) self._sleep_interruptible(TITLE_AFTER_DELAY) def send_reply_to_wechat(self, reply_text, expected_session_name=""): text = (reply_text or "").strip() if not text: return False window_rect = self.get_window_rect() if not window_rect: return False try: if expected_session_name: current_title = self.session_service.get_current_session_title(window_rect) if not self.session_service.is_same_session(expected_session_name, current_title): return False hwnd = int(self.wechat_hwnd or 0) if hwnd: ctypes.windll.user32.ShowWindow(hwnd, 9) ctypes.windll.user32.SetForegroundWindow(hwnd) self._sleep_interruptible(0.2) input_x = int(window_rect["left"] + window_rect["width"] * 0.62) input_y = int(window_rect["bottom"] - 88) pyautogui.click(input_x, input_y) self._sleep_interruptible(0.12) pyautogui.hotkey("ctrl", "a") self._sleep_interruptible(0.06) pyautogui.press("delete") self._sleep_interruptible(0.06) pyperclip.copy(text) self._sleep_interruptible(0.06) pyautogui.hotkey("ctrl", "v") self._sleep_interruptible(0.08) pyautogui.press("enter") return True except Exception as e: return False def save_current_chat_snapshot(self, window_rect, round_count, row_idx, expected_session_name="", expected_list_title=""): try: analyze_result = self.session_service.analyze_clicked_session(window_rect, round_count, row_idx) if not analyze_result.file_name: return False current_session_name = "" need_verify_current = bool(expected_session_name) if need_verify_current: current_session_name = self.session_service.get_current_session_title(window_rect) if expected_session_name and current_session_name and not self.session_service.is_same_session(expected_session_name, current_session_name): return False session_name = (expected_session_name or expected_list_title or current_session_name or '').strip() if analyze_result.ok: submit_result = self.submit_message(session_name, analyze_result.latest_text, analyze_result.confidence, analyze_result.bubble_side) if submit_result.get("should_reply"): reply_text = (submit_result.get("reply_text") or "").strip() if reply_text: sent_ok = self.send_reply_to_wechat(reply_text, expected_session_name=session_name) if not sent_ok: log_event("WARNING", "bot", "bot.submit", new_trace_id("bot"), "reply", "failed", "自动回复发送失败", reason="send_reply_failed", extra={"session_name": session_name}) return True log_event("WARNING", "bot", "bot.chat_analyze", new_trace_id("bot"), "analyze", "failed", "聊天截图未提取到可入库文本", reason="latest_text_empty", extra={"session_name": session_name, "file_name": analyze_result.file_name, "confidence": analyze_result.confidence, "bubble_side": analyze_result.bubble_side}) return False except Exception as e: log_event("ERROR", "bot", "bot.chat_analyze", new_trace_id("bot"), "snapshot", "failed", "保存聊天快照失败", reason="snapshot_error", extra={"error": str(e)}) return False def run_forever(self): trace_id = new_trace_id("bot") self.running = True log_event("INFO", "bot", "bot.loop", trace_id, "start", "ok", "微信机器人主循环启动") round_count = 0 while self.running: try: round_count += 1 window_rect = self.get_window_rect() if not window_rect: self._sleep_interruptible(LOOP_INTERVAL) continue scan_result = self.session_service.get_all_sessions_with_unread(window_rect, round_count) sessions, unread_sessions = scan_result.sessions, scan_result.unread_sessions if unread_sessions: pass if not unread_sessions: self._sleep_interruptible(LOOP_INTERVAL) continue session = unread_sessions[0] if not self.running: self._sleep_interruptible(LOOP_INTERVAL) continue if self.session_service.should_skip_session_by_ocr( session=session, blocked_row_cache=self.blocked_row_cache, save_blocked_row_cache=self.save_blocked_row_cache, ): self._sleep_interruptible(LOOP_INTERVAL) continue expected_list_title = (session.get('list_ocr_title') or '').strip() expected_row_idx = session.get('row_idx') precheck_scan = self.session_service.get_all_sessions_with_unread(window_rect, round_count) precheck_unread = precheck_scan.unread_sessions if not precheck_unread: self._sleep_interruptible(LOOP_INTERVAL) continue precheck_first = precheck_unread[0] self.session_service.should_skip_session_by_ocr( session=precheck_first, blocked_row_cache=self.blocked_row_cache, save_blocked_row_cache=self.save_blocked_row_cache, ) current_list_title = (precheck_first.get('list_ocr_title') or '').strip() current_row_idx = precheck_first.get('row_idx') same_list_session = False if expected_list_title and current_list_title: same_list_session = self.session_service.is_same_session(expected_list_title, current_list_title) elif expected_row_idx is not None and current_row_idx is not None: same_list_session = int(expected_row_idx) == int(current_row_idx) if not same_list_session: self._sleep_interruptible(LOOP_INTERVAL) continue session = precheck_first self.click_unread_session(session) if not self.running: self._sleep_interruptible(LOOP_INTERVAL) continue if self.session_service.should_skip_current_session( window_rect=window_rect, session=session, blocked_row_cache=self.blocked_row_cache, save_blocked_row_cache=self.save_blocked_row_cache, ): self._sleep_interruptible(LOOP_INTERVAL) continue snapshot_ok = self.save_current_chat_snapshot( window_rect, round_count, session['row_idx'], expected_session_name=expected_list_title, expected_list_title=expected_list_title, ) if not snapshot_ok: pass else: self._sleep_interruptible(CONTACT_SWITCH_DELAY) self._sleep_interruptible(LOOP_INTERVAL) except Exception as e: log_event("ERROR", "bot", "bot.loop", trace_id, "loop", "failed", "微信机器人主循环异常", reason="loop_error", extra={"error": str(e), "round": round_count}) self._sleep_interruptible(LOOP_ERROR_DELAY) def stop(self): self.running = False log_event("INFO", "bot", "bot.loop", new_trace_id("bot"), "stop", "ok", "微信机器人主循环停止") if __name__ == "__main__": try: bot = WechatMultiChatBot() bot.run_forever() except KeyboardInterrupt: bot.stop() except Exception as e: print(f"错误: {e}")