374 lines
17 KiB
Python
374 lines
17 KiB
Python
|
|
#!/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}")
|