feat: WeChat status + QR on Dashboard, proactive login notifier

- /api/wechat/status: real login detection via webhook log + docker logs
- /api/platform: wechat_bridge now returns login_ok, message, qr_url
- Platform tab: inline QR code with 5min expiry + refresh button
- wechat_qr_notifier.py: 40h session-age WeChat warning + XMPP logout alert
- agents_health_check.py: login_check via docker logs
- wechat_webhook.py: timeout 180→600s
This commit is contained in:
2026-07-31 03:22:35 +08:00
parent 3e2a74d4d9
commit fd6ea76f54
9 changed files with 1589 additions and 745 deletions
+324 -191
View File
@@ -1,191 +1,324 @@
#!/usr/bin/env python3
"""WeChat webhook receiver v2 - receives messages from docker-wechatbot-webhook."""
import os, sys, json, logging, threading
from http.server import HTTPServer, BaseHTTPRequestHandler
HERMES_API = "http://192.168.1.246:8642/v1/chat/completions"
HERMES_KEY = "hermes123"
PORT = 5804
WECHAT_BOT_TOKEN = os.environ.get("WECHAT_BOT_TOKEN", "mowechat_fixed_token_001")
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler("/home/hmo/projects/AgentsMeeting/gateway/linux/logs/webhook.log"),
logging.StreamHandler()
]
)
log = logging.getLogger("wc-webhook")
class WebhookHandler(BaseHTTPRequestHandler):
def do_POST(self):
content_type = self.headers.get('Content-Type', '')
content_length = int(self.headers.get('Content-Length', 0))
# Read the body
body = self.rfile.read(content_length)
# Parse multipart/form-data
msg_type = 'unknown'
content = ''
source_raw = '{}'
is_system = '0'
if 'multipart/form-data' in content_type:
# Manual multipart parsing
boundary = content_type.split('boundary=')[1].strip()
if boundary.startswith('"') and boundary.endswith('"'):
boundary = boundary[1:-1]
parts = body.split(b'--' + boundary.encode())
for part in parts:
if b'Content-Disposition' not in part:
continue
# Parse headers
header_end = part.find(b'\r\n\r\n')
if header_end < 0:
continue
part_headers = part[:header_end].decode('utf-8', errors='replace')
part_body = part[header_end + 4:]
# Get field name
name_start = part_headers.find('name="')
if name_start < 0:
continue
name_start += 6
name_end = part_headers.find('"', name_start)
field_name = part_headers[name_start:name_end]
# Trim trailing \r\n--
if part_body.endswith(b'\r\n'):
part_body = part_body[:-2]
if part_body.endswith(b'--'):
part_body = part_body[:-2]
if part_body.endswith(b'\r\n'):
part_body = part_body[:-2]
if field_name == 'type':
msg_type = part_body.decode('utf-8', errors='replace')
elif field_name == 'content':
content = part_body.decode('utf-8', errors='replace')
elif field_name == 'source':
source_raw = part_body.decode('utf-8', errors='replace')
elif field_name == 'isSystemEvent':
is_system = part_body.decode('utf-8', errors='replace')
else:
# Try as regular form or JSON
try:
data = json.loads(body)
msg_type = data.get('type', 'unknown')
content = data.get('content', '')
source_raw = data.get('source', '{}')
except:
pass
# Parse source
try:
source = json.loads(source_raw) if isinstance(source_raw, str) else source_raw
except:
source = {}
# Extract sender info
sender_name = "unknown"
sender_id = "unknown"
if isinstance(source, dict):
from_data = source.get('from', {})
if isinstance(from_data, dict):
payload = from_data.get('payload', {})
sender_name = payload.get('name', 'unknown')
sender_id = payload.get('id', 'unknown')
# Skip system events
if is_system == '1':
log.info(f"System event: {msg_type}")
self._respond(200, {"status": "ok"})
return
log.info(f"From: {sender_name} ({sender_id}), Type: {msg_type}")
if msg_type == 'text':
log.info(f"Text: {content[:300]}")
self._forward_to_hermes(sender_name, sender_id, content)
elif msg_type == 'urlLink':
log.info(f"URL: {content[:200]}")
self._forward_to_hermes(sender_name, sender_id, f"[分享链接] {content}")
elif msg_type == 'file':
log.info(f"File received, length={len(body)}")
else:
log.info(f"Other: {msg_type}")
self._respond(200, {"status": "ok"})
def _forward_to_hermes(self, sender, sender_id, text):
"""Forward message to Hermes, get response, send back to WeChat."""
payload = json.dumps({
"model": "nova-4",
"messages": [
{"role": "user", "content": f"[微信消息] 来自 {sender}({sender_id}): {text}"}
]
}).encode()
def do_forward():
try:
import urllib.request as ureq
handler = ureq.ProxyHandler({})
opener = ureq.build_opener(handler)
req = ureq.Request(HERMES_API, data=payload,
headers={"Content-Type": "application/json", "Authorization": f"Bearer {HERMES_KEY}"})
resp = opener.open(req, timeout=180)
resp_data = json.loads(resp.read())
reply = resp_data.get('choices', [{}])[0].get('message', {}).get('content', '')
log.info(f"Hermes OK, reply: {reply[:60]}")
if reply and sender:
self._send_wechat(sender, reply)
except Exception as e:
log.error(f"Hermes error: {e}")
threading.Thread(target=do_forward, daemon=True).start()
def _send_wechat(self, to_name, text):
"""Send message back to WeChat user via bot API."""
import urllib.request as ureq
token = WECHAT_BOT_TOKEN
api = f"http://localhost:3001/webhook/msg/v2?token={token}"
data = json.dumps({"to": to_name, "data": {"content": text}}).encode()
try:
handler = ureq.ProxyHandler({})
opener = ureq.build_opener(handler)
req = ureq.Request(api, data=data,
headers={"Content-Type": "application/json"})
resp = opener.open(req, timeout=10)
log.info(f"WeChat send OK: {resp.status}")
except Exception as e:
log.error(f"WeChat send error: {e}")
def _respond(self, code, data):
self.send_response(code)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps(data).encode())
def log_message(self, format, *args):
pass
def main():
server = HTTPServer(('0.0.0.0', PORT), WebhookHandler)
log.info(f"Webhook receiver on :{PORT}")
try:
server.serve_forever()
except KeyboardInterrupt:
server.shutdown()
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""WeChat webhook receiver v2 - receives messages from docker-wechatbot-webhook."""
import os, sys, json, logging, threading, queue, time
from http.server import HTTPServer, BaseHTTPRequestHandler
HERMES_API = "http://192.168.1.246:8646/v1/chat/completions"
HERMES_KEY = "hermes123"
PORT = 5804
ARTICLE_PROCESSOR_API = "http://192.168.1.16:5810/process"
KB_API = "http://192.168.1.246:9090/api/articles"
WECHAT_BOT_TOKEN = os.environ.get("WECHAT_BOT_TOKEN", "mowechat_fixed_token_001")
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler("/home/hmo/AgentsMeeting/gateway/linux/logs/webhook.log"),
logging.StreamHandler()
]
)
log = logging.getLogger("wc-webhook")
# ── 消息队列(串行处理,防止并发打爆 Hermes)──────────────
_msg_queue = queue.Queue()
def _hermes_worker():
"""单线程 worker,从队列取消息逐条发给 Hermes。"""
while True:
sender, sender_id, text = _msg_queue.get()
try:
_do_forward(sender, sender_id, text)
except Exception as e:
log.error(f"Worker error: {e}")
finally:
_msg_queue.task_done()
# 每条消息之间稍作间隔,避免 LLM API 限流
time.sleep(2)
def _do_forward(sender, sender_id, text):
"""实际调用 Hermes API 并回复微信。"""
payload = json.dumps({
"model": "nova-4",
"messages": [
{"role": "user", "content": f"[微信消息] 来自 {sender}({sender_id}): {text}"}
]
}).encode()
try:
import urllib.request as ureq
handler = ureq.ProxyHandler({})
opener = ureq.build_opener(handler)
req = ureq.Request(HERMES_API, data=payload,
headers={"Content-Type": "application/json", "Authorization": f"Bearer {HERMES_KEY}"})
resp = opener.open(req, timeout=600)
resp_data = json.loads(resp.read())
reply = resp_data.get('choices', [{}])[0].get('message', {}).get('content', '')
log.info(f"Hermes OK, reply: {reply[:60]}")
if reply and sender:
WebhookHandler._send_wechat_static(sender, reply)
except Exception as e:
log.error(f"Hermes error: {e}")
# 启动 worker 线程
_worker_thread = threading.Thread(target=_hermes_worker, daemon=True)
_worker_thread.start()
# ── 文章抓取 + 入库 ──────────────────────────────────────
def _fetch_article(url):
"""调用 article_processor 抓取文章全文。"""
import urllib.request as ureq
payload = json.dumps({"url": url}).encode()
req = ureq.Request(ARTICLE_PROCESSOR_API, data=payload,
headers={"Content-Type": "application/json"})
with ureq.urlopen(req, timeout=300) as resp:
return json.loads(resp.read().decode())
def _register_article(data):
"""注册文章到知识库 DB。"""
import urllib.request as ureq
payload = json.dumps(data, ensure_ascii=False).encode("utf-8")
req = ureq.Request(KB_API, data=payload,
headers={"Content-Type": "application/json"}, method="POST")
try:
with ureq.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode())
except Exception as e:
log.error(f"KB register error: {e}")
return None
def _is_login_expired(text, result):
"""检测抓取结果是否为登录失效/验证码页面。
只有内容极短(<200字)且包含明确验证码关键词时才判定为风控。"""
if not text:
return True
# 内容足够长 = 正常文章,不可能是验证码页
if len(text) >= 200:
return False
# 内容太短但没有验证码关键词 = 可能只是短文章,不误判
captcha_keywords = ["环境异常", "安全验证", "滑动验证", "扫码验证", "操作频繁", "请完成安全验证"]
if any(k in text for k in captcha_keywords):
return True
return False
def _process_article(sender, sender_id, content):
"""处理转发文章:抓取 → 入库 → 交 Hermes 回复。"""
import re as _re
import urllib.request as ureq
# 提取 URL
url_match = _re.search(r'"url":"(https://mp\.weixin\.qq\.com/[^"]+)"', content)
if not url_match:
log.warning(f"No URL found in urlLink content")
_do_forward(sender, sender_id, f"[分享链接] {content}")
return
article_url = url_match.group(1).replace("&amp;", "&")
log.info(f"Fetching article: {article_url[:80]}...")
# 抓取
try:
result = _fetch_article(article_url)
title = result.get("title", "unknown")
text = result.get("content", "")
word_count = result.get("word_count", 0)
log.info(f"Fetched: {title[:40]} ({word_count} chars)")
# 检测风控/登录失效
if _is_login_expired(text, result):
log.warning(f"Login expired or captcha detected for {article_url[:60]}")
hermes_msg = (
f"[微信消息] 来自 {sender}({sender_id}): [分享链接] {content}\n\n"
f"[系统] 抓取这篇文章时检测到微信读者登录态已失效(页面返回验证码/环境异常)。"
f"这不是文章本身的问题,而是 article_processor 的 Chrome 微信读者 session 过期了。"
f"需要老莫在 Windows 上重新运行 wechat_fetcher.py --login 扫码登录后,抓取服务才能恢复。"
f"请告诉老莫这个情况,让他知道需要重新扫码。"
)
_do_forward(sender, sender_id, hermes_msg)
return
except Exception as e:
log.error(f"Fetch failed: {e}")
_do_forward(sender, sender_id, f"[分享链接] {content}")
return
# 入库
kb_data = {
"url": article_url,
"title": title,
"source": "user_sent",
"platform": "wechat",
"lifecycle": "draft",
"full_text": text[:5000] if text else "",
}
kb_result = _register_article(kb_data)
kb_id = kb_result.get("id") if kb_result else None
log.info(f"KB registered: id={kb_id}")
# 交 Hermes 回复(带入库结果)
kb_status = f"已入库(id={kb_id})" if kb_id else "入库失败"
hermes_msg = f"[微信消息] 来自 {sender}({sender_id}): [分享链接] {content}\n\n[系统] 文章已自动抓取并{kb_status},全文 {word_count} 字。"
_do_forward(sender, sender_id, hermes_msg)
class WebhookHandler(BaseHTTPRequestHandler):
def do_POST(self):
content_type = self.headers.get('Content-Type', '')
content_length = int(self.headers.get('Content-Length', 0))
# Read the body
body = self.rfile.read(content_length)
# Parse multipart/form-data
msg_type = 'unknown'
content = ''
source_raw = '{}'
is_system = '0'
if 'multipart/form-data' in content_type:
# Manual multipart parsing
boundary = content_type.split('boundary=')[1].strip()
if boundary.startswith('"') and boundary.endswith('"'):
boundary = boundary[1:-1]
parts = body.split(b'--' + boundary.encode())
for part in parts:
if b'Content-Disposition' not in part:
continue
# Parse headers
header_end = part.find(b'\r\n\r\n')
if header_end < 0:
continue
part_headers = part[:header_end].decode('utf-8', errors='replace')
part_body = part[header_end + 4:]
# Get field name
name_start = part_headers.find('name="')
if name_start < 0:
continue
name_start += 6
name_end = part_headers.find('"', name_start)
field_name = part_headers[name_start:name_end]
# Trim trailing \r\n--
if part_body.endswith(b'\r\n'):
part_body = part_body[:-2]
if part_body.endswith(b'--'):
part_body = part_body[:-2]
if part_body.endswith(b'\r\n'):
part_body = part_body[:-2]
if field_name == 'type':
msg_type = part_body.decode('utf-8', errors='replace')
elif field_name == 'content':
content = part_body.decode('utf-8', errors='replace')
elif field_name == 'source':
source_raw = part_body.decode('utf-8', errors='replace')
elif field_name == 'isSystemEvent':
is_system = part_body.decode('utf-8', errors='replace')
else:
# Try as regular form or JSON
try:
data = json.loads(body)
msg_type = data.get('type', 'unknown')
content = data.get('content', '')
source_raw = data.get('source', '{}')
except:
pass
# Parse source
try:
source = json.loads(source_raw) if isinstance(source_raw, str) else source_raw
except:
source = {}
# Extract sender info
sender_name = "unknown"
sender_id = "unknown"
if isinstance(source, dict):
from_data = source.get('from', {})
if isinstance(from_data, dict):
payload = from_data.get('payload', {})
sender_name = payload.get('name', 'unknown')
sender_id = payload.get('id', 'unknown')
# Skip system events
if is_system == '1':
log.info(f"System event: {msg_type}")
self._respond(200, {"status": "ok"})
return
log.info(f"From: {sender_name} ({sender_id}), Type: {msg_type}")
if msg_type == 'text':
log.info(f"Text: {content}")
self._forward_to_hermes(sender_name, sender_id, content)
elif msg_type == 'urlLink':
log.info(f"URL: {content}")
# 转发文章:抓取 → 入库 → 交 Hermes 回复
_process_article(sender_name, sender_id, content)
elif msg_type == 'file':
log.info(f"File received, length={len(body)}")
else:
log.info(f"Other: {msg_type}")
self._respond(200, {"status": "ok"})
def _forward_to_hermes(self, sender, sender_id, text):
"""把消息放入队列,由 worker 串行处理。"""
_msg_queue.put((sender, sender_id, text))
log.info(f"Queued for Hermes (queue size: {_msg_queue.qsize()})")
@staticmethod
def _send_wechat_static(to_name, text):
"""Send message back to WeChat user via bot API (static, for worker)."""
import urllib.request as ureq
token = WECHAT_BOT_TOKEN
api = f"http://localhost:3001/webhook/msg/v2?token={token}"
data = json.dumps({"to": to_name, "data": {"type": "text", "content": text}}).encode()
try:
handler = ureq.ProxyHandler({})
opener = ureq.build_opener(handler)
req = ureq.Request(api, data=data,
headers={"Content-Type": "application/json"})
resp = opener.open(req, timeout=10)
log.info(f"WeChat send OK: {resp.status}")
except Exception as e:
log.error(f"WeChat send error: {e}")
def _send_wechat(self, to_name, text):
"""Instance method wrapper."""
self._send_wechat_static(to_name, text)
def _respond(self, code, data):
self.send_response(code)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps(data).encode())
def log_message(self, format, *args):
pass
def main():
server = HTTPServer(('0.0.0.0', PORT), WebhookHandler)
log.info(f"Webhook receiver on :{PORT}")
try:
server.serve_forever()
except KeyboardInterrupt:
server.shutdown()
if __name__ == "__main__":
main()
+42 -4
View File
@@ -41,8 +41,8 @@ SERVICES = [
{
"name": "hermes_gateway_mohe",
"host": "127.0.0.1",
"port": 8642,
"health_url": "http://127.0.0.1:8642/v1/health",
"port": 8646,
"health_url": "http://127.0.0.1:8646/v1/health",
"fix_cmd": ["sudo", "systemctl", "restart", "hermes-gateway@mohe"],
"remote": False,
"critical": True,
@@ -64,6 +64,17 @@ SERVICES = [
"fix_cmd": ["docker", "restart", "wxBotWebhook"],
"remote": False,
"critical": True,
"docker_name": "wxBotWebhook",
"login_check": True, # 额外检查微信登录态
},
{
"name": "wechat_webhook",
"host": "127.0.0.1",
"port": 5804,
"health_url": None, # TCP only
"fix_cmd": ["sudo", "systemctl", "restart", "wechat-webhook"],
"remote": False,
"critical": True,
},
# === 远程服务(跨网 HTTP 检查)===
{
@@ -130,6 +141,20 @@ def http_check(url, timeout=5):
return (False, str(e)[:60])
def docker_log_check(container_name, pattern, minutes=10):
"""检查 Docker 日志中最近 N 分钟是否出现指定模式。
返回 True = 模式出现(如二维码提示 = 未登录),False = 未出现。"""
try:
result = subprocess.run(
["docker", "logs", container_name, "--since", f"{minutes}m"],
capture_output=True, text=True, timeout=10
)
logs = result.stdout + result.stderr
return pattern in logs
except Exception:
return False
def write_todo(name, issue):
"""写 TODO 供 executor 消费。"""
entry = {
@@ -173,8 +198,19 @@ def main():
entry["http_ok"] = False
entry["http_detail"] = "port_closed"
# 3. 判定:Port+HTTP OK = 健康
primary_ok = port_ok and entry.get("http_ok", False)
# 3. Docker 登录态检查(如果配置了 login_check
login_ok = True
if port_ok and svc.get("login_check") and svc.get("docker_name"):
qr_showing = docker_log_check(svc["docker_name"], "扫码", minutes=10)
if qr_showing:
login_ok = False
entry["login_ok"] = False
entry["login_detail"] = "not_logged_in"
else:
entry["login_ok"] = True
# 4. 判定:Port+HTTP OK + 登录态 = 健康
primary_ok = port_ok and entry.get("http_ok", False) and login_ok
entry["status"] = "ok" if primary_ok else "fail"
if primary_ok:
@@ -187,6 +223,8 @@ def main():
reasons.append("port_closed")
if port_ok and not entry.get("http_ok"):
reasons.append(f"http_{entry.get('http_detail','?')}")
if port_ok and not login_ok:
reasons.append("not_logged_in")
issue = f"异常: {' + '.join(reasons)}"
write_todo(name, issue)
+226 -3
View File
@@ -1,4 +1,4 @@
# -*- coding: utf-8 -*-
# -*- coding: utf-8 -*-
"""
dashboard.py - AgentsMeeting management dashboard backend
=========================================================
@@ -110,7 +110,7 @@ def _default_agents():
"id": "agent-002", "name": "Automation Manager", "display_name": "mohe",
"jid": "mohe@yoin.fun", "platform": "linux", "host": "192.168.1.246",
"bot_type": "hermes", "provider": "ocg-new",
"services": [{"type": "hermes_gateway", "port": 8642}, {"type": "xmpp_bot"}],
"services": [{"type": "hermes_gateway", "port": 8646}, {"type": "xmpp_bot"}],
},
{
"id": "agent-004", "name": "Position Analyst", "display_name": "zhiwei",
@@ -564,6 +564,115 @@ PLATFORM_SERVICES = [
"host": "192.168.1.16", "health_url": "http://192.168.1.16:5810/health"},
]
def _docker_login_check():
"""Check docker logs for recent WeChat login. Returns datetime or None."""
try:
dr = subprocess.run(
["docker", "logs", "wxBotWebhook", "--since", "120m"],
capture_output=True, text=True, timeout=5
)
for line in (dr.stdout + dr.stderr).split("\n"):
ansi_re = re.compile(r'\x1b\[[0-9;]*m')
clean = ansi_re.sub('', line)
m = re.match(r"^\[?(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})", clean)
if m and "logged in" in line:
try:
return datetime.strptime(m.group(1)[:19], "%Y-%m-%dT%H:%M:%S")
except ValueError:
pass
except Exception:
pass
return None
def _augment_wechat_status(entry):
"""Add real login status and QR code to wechat_bridge platform entry."""
import socket as _sock
now = datetime.now()
entry["login_ok"] = False
entry["message"] = ""
entry["qr_url"] = None
entry["qr_timestamp"] = None
entry["session_age_hours"] = 0
# 1. Fetch QR code (always available)
try:
req = urllib.request.Request("http://localhost:3001/login?token=mowechat_fixed_token_001")
html = urllib.request.urlopen(req, timeout=5).read().decode("utf-8", errors="replace")
m = re.search(r'qrcode\.makeCode\("([^"]+)"\)', html)
if m:
entry["qr_url"] = m.group(1)
entry["qr_timestamp"] = now.isoformat()
except Exception:
pass
# 2. Check webhook log for real login status
webhook_log = str(_GATEWAY_DIR / "linux" / "logs" / "webhook.log")
try:
r = subprocess.run(["tail", "-200", webhook_log],
capture_output=True, text=True, timeout=5)
lines = r.stdout.strip().split("\n") if r.stdout else []
last_ok, last_err, last_login = None, None, 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 "system_event_login" in line and ts and not last_login:
last_login = ts
if last_ok and last_err and last_login:
break
ok_dt = err_dt = None
if last_ok:
ok_dt = datetime.strptime(last_ok, "%Y-%m-%d %H:%M:%S")
entry["session_age_hours"] = round((now - ok_dt).total_seconds() / 3600, 1)
if last_err:
err_dt = datetime.strptime(last_err, "%Y-%m-%d %H:%M:%S")
# Check docker logs for recent login (catches fresh logins before webhook sees them)
# Check webhook log for login event
login_dt = None
if last_login:
try:
login_dt = datetime.strptime(last_login, "%Y-%m-%d %H:%M:%S")
except ValueError:
pass
dlogin_dt = _docker_login_check()
if err_dt and login_dt and login_dt > err_dt:
entry["login_ok"] = True
entry["session_age_hours"] = round((now - login_dt).total_seconds() / 3600, 1)
h = entry["session_age_hours"]
entry["message"] = "\u5df2\u767b\u5f55" if h < 0.1 else f"\u5df2\u767b\u5f55 {h:.0f}h"
entry["status"] = "running"
elif err_dt and dlogin_dt and dlogin_dt > err_dt:
entry["login_ok"] = True
entry["session_age_hours"] = round((now - dlogin_dt).total_seconds() / 3600, 1)
h = entry["session_age_hours"]
entry["message"] = "\u5df2\u767b\u5f55" if h < 0.1 else f"\u5df2\u767b\u5f55 {h:.0f}h"
entry["status"] = "running"
elif err_dt and ok_dt and err_dt > ok_dt:
entry["login_ok"] = False
h = round((now - err_dt).total_seconds() / 3600, 1)
entry["message"] = "已掉线" if h < 1 else f"已掉线 {h:.0f}h"
entry["status"] = "logged_out"
elif err_dt and not ok_dt:
entry["login_ok"] = False
h = round((now - err_dt).total_seconds() / 3600, 1)
entry["message"] = "已掉线" if h < 1 else f"已掉线 {h:.0f}h"
entry["status"] = "logged_out"
elif ok_dt:
entry["login_ok"] = True
h = entry["session_age_hours"]
entry["message"] = f"已登录 {h:.0f}h"
else:
entry["message"] = "状态未知"
except Exception:
entry["message"] = "无法检测"
@app.route("/api/platform")
def api_platform():
"""Return platform services status by querying health endpoints."""
@@ -605,6 +714,13 @@ def api_platform():
if health_data:
entry["health_data"] = health_data
result.append(entry)
# Augment wechat_bridge with real login status + QR code
for entry in result:
if entry["id"] == "wechat_bridge":
_augment_wechat_status(entry)
break
return jsonify(result)
@@ -775,7 +891,7 @@ def api_services():
services.append({"name": "ejabberd", "port": 5222, "health": {"ok": len(online) > 0},
"watchdog": True, "pid_lock": False, "type": "infra", "depends_on": []})
# remote gateways (R01 monitoring)
g1 = {"name": "gateway_mohe", "port": 8642, "health": _health_status("http://192.168.1.246:8642/v1/health"),
g1 = {"name": "gateway_mohe", "port": 8646, "health": _health_status("http://192.168.1.246:8646/v1/health"),
"watchdog": False, "pid_lock": False, "type": "gateway", "depends_on": ["ejabberd"]}
services.append(g1)
g2 = {"name": "gateway_zhiwei", "port": 8643, "health": _health_status("http://192.168.1.246:8643/v1/health"),
@@ -1598,6 +1714,113 @@ def api_tests():
return jsonify({"ok": False, "error": str(e)})
# ── WeChat Bridge Status ──────────────────────────────────────
@app.route("/api/wechat/status")
def api_wechat_status():
"""Returns real WeChat bridge login status + QR code."""
import socket as _sock
result = {"online": False, "message": "", "port_ok": False,
"qr_url": None, "qr_timestamp": None,
"session_age_hours": 0, "last_ok_at": None, "last_err_at": None}
now = datetime.now()
# 1. TCP check port 3001
try:
s = _sock.socket(); s.settimeout(2)
s.connect(("127.0.0.1", 3001)); s.close()
result["port_ok"] = True
except Exception:
result["message"] = "Docker container port 3001 unreachable"
return jsonify(result)
# 2. Fetch QR code from login page
try:
req = urllib.request.Request("http://localhost:3001/login?token=mowechat_fixed_token_001")
html = urllib.request.urlopen(req, timeout=5).read().decode("utf-8", errors="replace")
m = re.search(r'qrcode\.makeCode\("([^"]+)"\)', html)
if m:
result["qr_url"] = m.group(1)
result["qr_timestamp"] = now.isoformat()
except Exception as e:
result["message"] = f"QR page fetch failed: {e}"
# 3. Check login status from webhook log (last WeChat send)
webhook_log = str(_GATEWAY_DIR / "linux" / "logs" / "webhook.log")
try:
r = subprocess.run(["tail", "-200", webhook_log],
capture_output=True, text=True, timeout=5)
lines = r.stdout.strip().split("\n") if r.stdout else []
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 result["last_ok_at"]:
result["last_ok_at"] = ts
elif "WeChat send error" in line and ts and not result["last_err_at"]:
result["last_err_at"] = ts
if "system_event_login" in line and ts and not result.get("last_login_at"):
result["last_login_at"] = ts
if result["last_ok_at"] and result["last_err_at"] and result.get("last_login_at"):
break
except Exception:
pass
# 4. Determine online status
if result["last_ok_at"]:
try:
last_ok_dt = datetime.strptime(result["last_ok_at"], "%Y-%m-%d %H:%M:%S")
result["session_age_hours"] = round((now - last_ok_dt).total_seconds() / 3600, 1)
except ValueError:
pass
ok_dt = err_dt = None
try:
if result["last_ok_at"]:
ok_dt = datetime.strptime(result["last_ok_at"], "%Y-%m-%d %H:%M:%S")
if result["last_err_at"]:
err_dt = datetime.strptime(result["last_err_at"], "%Y-%m-%d %H:%M:%S")
except ValueError:
pass
# Check docker logs for recent login
dlogin_dt = _docker_login_check()
# Check webhook login event
login_dt = None
if result.get("last_login_at"):
try:
login_dt = datetime.strptime(result["last_login_at"], "%Y-%m-%d %H:%M:%S")
except ValueError:
pass
if err_dt and login_dt and login_dt > err_dt:
result["online"] = True
result["session_age_hours"] = round((now - login_dt).total_seconds() / 3600, 1)
h = result["session_age_hours"]
result["message"] = "\u5df2\u767b\u5f55" if h < 0.1 else f"\u5df2\u767b\u5f55 {h:.0f}h"
elif err_dt and dlogin_dt and dlogin_dt > err_dt:
result["online"] = True
result["session_age_hours"] = round((now - dlogin_dt).total_seconds() / 3600, 1)
h = result["session_age_hours"]
result["message"] = "\u5df2\u767b\u5f55" if h < 0.1 else f"\u5df2\u767b\u5f55 {h:.0f}h"
elif err_dt and ok_dt and err_dt > ok_dt:
result["online"] = False
h = round((now - err_dt).total_seconds() / 3600, 1)
result["message"] = "已掉线" if h < 1 else f"已掉线 {h:.0f}h 前"
elif err_dt and not ok_dt:
result["online"] = False
h = round((now - err_dt).total_seconds() / 3600, 1)
result["message"] = "已掉线" if h < 1 else f"已掉线 {h:.0f}h 前"
elif ok_dt and not err_dt:
result["online"] = True
h = result["session_age_hours"]
result["message"] = f"已登录 {h:.0f}h"
elif ok_dt and err_dt and ok_dt > err_dt:
result["online"] = True
h = result["session_age_hours"]
result["message"] = f"已登录 {h:.0f}h"
else:
result["message"] = "状态未知"
return jsonify(result)
@app.route("/api/platform")
@app.route("/api/health")
def api_health():
+2 -2
View File
@@ -59,8 +59,8 @@ SERVICES = [
"args": [],
"workdir": None,
"pid_file": None,
"port": 8642,
"health_url": "http://192.168.1.246:8642/v1/health",
"port": 8646,
"health_url": "http://192.168.1.246:8646/v1/health",
"accept_401": True, # may need auth
"depends_on": ["ejabberd"],
"remote": True, # 服务运行在 Linux 246,不检查本地端口
File diff suppressed because it is too large Load Diff
+259
View File
@@ -0,0 +1,259 @@
#!/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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
stanza = f"<message type='chat'><body>{body}</body></message>"
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()
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""
wechat_qr_notifier.py — 检测 wechat_bridge 未登录时,通过 XMPP 推送登录二维码
============================================================================
由 health check 或 crontab 调用。检查 Docker 日志中是否有二维码提示,
如果有则提取登录链接,通过 ejabberdctl 发送 XMPP 消息通知用户。
"""
import json, os, re, subprocess, sys, urllib.request, urllib.parse
from datetime import datetime
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"
XMPP_FROM = "mohe@yoin.fun"
XMPP_TO = "hmo@yoin.fun"
NOTIFY_INTERVAL_MIN = 240 # 同一轮掉线最多每 4 小时提醒一次
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 is_logged_out():
"""检查 Docker 日志最近 10 分钟是否有二维码提示。"""
try:
result = subprocess.run(
["docker", "logs", DOCKER_CONTAINER, "--since", "10m"],
capture_output=True, text=True, timeout=10
)
logs = result.stdout + result.stderr
# 只匹配二维码提示,不匹配 "already login" 等正常状态
return "扫码以下二维码以登录" in logs or "Or Access the URL to login" in logs
except Exception as e:
log(f"ERROR checking docker logs: {e}")
return False
def fetch_qr_url():
"""从登录页面提取微信登录二维码链接。"""
try:
with urllib.request.urlopen(LOGIN_URL, timeout=10) as r:
html = r.read().decode("utf-8", errors="replace")
# 提取 qrcode.makeCode("...") 中的 URL
m = re.search(r'qrcode\.makeCode\("([^"]+)"\)', html)
if m:
return m.group(1)
# 备选:直接找 login.weixin.qq.com 链接
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 should_notify():
"""检查是否应该发送通知(避免频繁打扰)。"""
os.makedirs(TEMP_DIR, exist_ok=True)
try:
with open(STATE_FILE, "r") as f:
state = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
state = {}
last_notify = state.get("last_notify", "")
last_qr = state.get("last_qr", "")
now = datetime.now()
# 如果 QR 链接变了,立即通知(新的一轮掉线)
# 如果 QR 链接没变但超过 30 分钟,再次提醒
if last_notify:
try:
last_dt = datetime.fromisoformat(last_notify)
elapsed = (now - last_dt).total_seconds() / 60
if elapsed < NOTIFY_INTERVAL_MIN:
return False, last_qr
except ValueError:
pass
return True, last_qr
def save_state(qr_url):
"""保存通知状态。"""
state = {
"last_notify": datetime.now().isoformat(),
"last_qr": qr_url,
}
with open(STATE_FILE, "w") as f:
json.dump(state, f)
def send_xmpp(qr_url):
"""通过 ejabberdctl 发送 XMPP 消息,附带 QR 码图片链接。"""
# 生成 QR 码图片链接(公网可访问,500x500 + margin"""
qr_img_url = QR_IMG_API + urllib.parse.quote(qr_url, safe="")
body = (
f"🔔 莫荷微信掉线了,需要重新扫码登录\n\n"
f"👉 打开这个链接,用微信扫页面上的二维码:\n"
f"{qr_img_url}\n\n"
f"如果相册扫码不生效,试试复制这个链接到微信里打开:\n"
f"{qr_url}\n\n"
f"⏰ 二维码约 5 分钟过期,请及时扫码"
)
# XML-escape body
body = body.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
stanza = f"<message type='chat'><body>{body}</body></message>"
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():
# 1. 检查是否掉线
if not is_logged_out():
log("wechat_bridge is logged in, nothing to do")
return
log("wechat_bridge appears to be logged out")
# 2. 提取二维码链接
qr_url = fetch_qr_url()
if not qr_url:
log("ERROR: could not extract QR code URL")
return
log(f"QR URL: {qr_url}")
# 3. 检查是否需要通知
should, last_qr = should_notify()
if not should:
log(f"Already notified recently (last QR: {last_qr[:50]}...), skipping")
return
# 4. 发送 XMPP 通知
if send_xmpp(qr_url):
save_state(qr_url)
log("Done")
else:
log("Failed to send notification")
sys.exit(1)
if __name__ == "__main__":
main()