#!/usr/bin/env python3 """ wechat_qr_notifier.py — 微信登录态监控 & 智能通知 ================================================ 功能: 1. 检测 session 年龄: 如果已登录超过 40 小时,通过微信发送预警 2. 检测掉线: 如果微信已登出,通过 XMPP 发送二维码通知 3. 智能去重: 同一轮预警/掉线不重复通知 部署: crontab 每 30 分钟执行 */30 * * * * cd /home/hmo/AgentsMeeting/gateway/scripts && /usr/bin/python3 wechat_qr_notifier.py >> ../logs/qr_notifier.log 2>&1 """ import json, os, re, subprocess, sys, urllib.request, urllib.parse from datetime import datetime, timedelta GATEWAY_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) TEMP_DIR = os.path.join(GATEWAY_DIR, "temp") STATE_FILE = os.path.join(TEMP_DIR, "wechat_qr_state.json") LOGIN_URL = "http://localhost:3001/login?token=mowechat_fixed_token_001" DOCKER_CONTAINER = "wxBotWebhook" WECHAT_BOT_API = "http://localhost:3001/webhook/msg/v2" WECHAT_BOT_TOKEN = "mowechat_fixed_token_001" XMPP_FROM = "mohe@yoin.fun" XMPP_TO = "hmo@yoin.fun" WEBHOOK_LOG = os.path.join(GATEWAY_DIR, "linux", "logs", "webhook.log") DASHBOARD_URL = "http://192.168.1.246:5803" NOTIFY_INTERVAL_MIN = 240 # 掉线通知: 同一轮最多每 4 小时提醒 AGING_WARN_HOURS = 40 # 累计登录 40 小时后发送微信预警 AGING_WARN_INTERVAL_H = 8 # 预警间隔: 每 8 小时提醒一次 QR_IMG_API = "https://api.qrserver.com/v1/create-qr-code/?size=500x500&margin=10&data=" def log(msg): ts = datetime.now().strftime("%H:%M:%S") print(f"[{ts}] {msg}") def load_state(): os.makedirs(TEMP_DIR, exist_ok=True) try: with open(STATE_FILE, "r") as f: return json.load(f) except (FileNotFoundError, json.JSONDecodeError): return {} def save_state(state): with open(STATE_FILE, "w") as f: json.dump(state, f, ensure_ascii=False) def is_logged_out(): """检查最近有没掉线事件。""" try: result = subprocess.run( ["docker", "logs", DOCKER_CONTAINER, "--since", "30m"], capture_output=True, text=True, timeout=10 ) logs = result.stdout + result.stderr if "system_event_logout" in logs: return True if "扫码以下二维码以登录" in logs[-5000:]: return True except Exception as e: log(f"ERROR checking docker logs: {e}") return False def check_session_age(): """从 webhook log 分析 session 年龄。 返回: (is_online, session_age_hours, last_ok_time_str) """ try: r = subprocess.run(["tail", "-500", WEBHOOK_LOG], capture_output=True, text=True, timeout=5) lines = r.stdout.strip().split("\n") if r.stdout else [] last_ok = None last_err = None for line in reversed(lines): ts_match = re.match(r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})", line) ts = ts_match.group(1) if ts_match else None if "WeChat send OK" in line and ts and not last_ok: last_ok = ts elif "WeChat send error" in line and ts and not last_err: last_err = ts if last_ok and last_err: break # Determine current state is_online = False session_age = 0 if last_ok: ok_dt = datetime.strptime(last_ok, "%Y-%m-%d %H:%M:%S") session_age = round((datetime.now() - ok_dt).total_seconds() / 3600, 1) if last_err and last_ok: err_dt = datetime.strptime(last_err, "%Y-%m-%d %H:%M:%S") ok_dt = datetime.strptime(last_ok, "%Y-%m-%d %H:%M:%S") is_online = ok_dt > err_dt elif last_ok and not last_err: is_online = True # No errors seen, assume online elif last_err and not last_ok: is_online = False return is_online, session_age, last_ok except Exception as e: log(f"ERROR reading webhook log: {e}") return False, 0, None def fetch_qr_url(): """从登录页面提取微信登录二维码链接。""" try: with urllib.request.urlopen(LOGIN_URL, timeout=10) as r: html = r.read().decode("utf-8", errors="replace") m = re.search(r'qrcode\.makeCode\("([^"]+)"\)', html) if m: return m.group(1) m = re.search(r'(https://login\.weixin\.qq\.com/l/[^\s"\'<>]+)', html) if m: return m.group(1) except Exception as e: log(f"ERROR fetching QR page: {e}") return None def send_wechat(to_name, text): """通过 docker wechatbot-webhook API 发送微信消息。""" try: url = f"{WECHAT_BOT_API}?token={WECHAT_BOT_TOKEN}" payload = json.dumps({"to": to_name, "content": text}).encode("utf-8") req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=10) as resp: if resp.status == 200: log(f"WeChat message sent to {to_name}") return True else: log(f"WeChat send failed: HTTP {resp.status}") return False except Exception as e: log(f"WeChat send error: {e}") return False def send_xmpp(qr_url): """通过 ejabberdctl 发送 XMPP 消息(用于掉线时,微信不可用)。""" qr_img_url = QR_IMG_API + urllib.parse.quote(qr_url, safe="") body = ( f"\U0001f514 莫荷微信掉线了,需要重新扫码登录\n\n" f"\U0001f449 打开 Dashboard 健康检查页面扫码:\n" f"{DASHBOARD_URL}\n\n" f"\U0001f4f1 或用手机扫这个二维码:\n" f"{qr_img_url}\n\n" f"\u23f0 二维码约 5 分钟过期,请及时扫码" ) body = body.replace("&", "&").replace("<", "<").replace(">", ">") stanza = f"{body}" try: result = subprocess.run( ["docker", "exec", "ejabberd", "ejabberdctl", "send_stanza", XMPP_FROM, XMPP_TO, stanza], capture_output=True, text=True, timeout=15 ) if result.returncode == 0: log(f"XMPP notification sent to {XMPP_TO}") return True else: log(f"XMPP send failed: {result.stderr}") return False except Exception as e: log(f"XMPP send error: {e}") return False def main(): now = datetime.now() state = load_state() # ── 场景 1: 已登录但 session 年龄过大 → 微信预警 ── is_online, session_age, last_ok = check_session_age() log(f"Session: online={is_online}, age={session_age}h, last_ok={last_ok}") if is_online and session_age >= AGING_WARN_HOURS: last_aging_warn = state.get("last_aging_warn", "") should_warn = True if last_aging_warn: try: last_warn_dt = datetime.fromisoformat(last_aging_warn) if (now - last_warn_dt).total_seconds() / 3600 < AGING_WARN_INTERVAL_H: should_warn = False except ValueError: pass if should_warn: user_name = "hmo" # WeChat contact name msg = ( f"\u26a0\ufe0f 莫荷微信 session 已运行 {session_age:.0f} 小时\n\n" f"\U0001f4e2 预计未来几小时可能掉线\n" f"\U0001f449 请到 Dashboard 健康页面提前准备扫码\n" f"{DASHBOARD_URL}" ) if send_wechat(user_name, msg): state["last_aging_warn"] = now.isoformat() state["aging_warn_hours"] = session_age save_state(state) log(f"Aging warning sent ({session_age:.0f}h)") return # ── 场景 2: 已掉线 → XMPP 通知(微信不可用) ── if not is_online and (is_logged_out() or session_age > 1): log("WeChat bridge appears to be logged out") qr_url = fetch_qr_url() if not qr_url: log("ERROR: could not extract QR code URL") return last_notify = state.get("last_notify", "") last_qr = state.get("last_qr", "") should_notify = True if last_notify: try: last_dt = datetime.fromisoformat(last_notify) if (now - last_dt).total_seconds() / 60 < NOTIFY_INTERVAL_MIN: should_notify = False except ValueError: pass if should_notify: if send_xmpp(qr_url): state["last_notify"] = now.isoformat() state["last_qr"] = qr_url save_state(state) log("Logout XMPP notification sent") else: log(f"Already notified recently, skipping") return # ── 场景 3: 在线且无需预警 ── if is_online and session_age < AGING_WARN_HOURS: log(f"wechat bridge OK, session age {session_age:.0f}h") elif is_online: log(f"session aging ({session_age:.0f}h) but already warned recently") else: log("not online but no recent logout detected - possibly idle") if __name__ == "__main__": main()