diff --git a/config/agents.yaml b/config/agents.yaml
index 83634d9..6c9996a 100644
--- a/config/agents.yaml
+++ b/config/agents.yaml
@@ -30,7 +30,7 @@ agents:
provider: ocg-new
services:
- type: hermes_gateway
- port: 8642
+ port: 8646
- type: xmpp_bot
- id: "agent-004"
diff --git a/docs/learned.md b/docs/learned.md
index 2cce70a..ddee6bb 100644
--- a/docs/learned.md
+++ b/docs/learned.md
@@ -8,3 +8,5 @@
---
- [2026-07-17] 问题: 配置完 key6 后没同步更新 usage_monitor spec,也不知道用户看到的 Dashboard 后端是 246 本地采集而非 Windows bridge | 根因: 没有先看 § spec 了解实际架构,开发完成后也没更新 spec | 正确做法: ①改代码前先看对应模块的 § spec 了解架构 ②改完后同步更新 spec 的 api/constraints/dependencies 与实际实现一致
+- [2026-07-31] 问题: 之前多篇微信文章入库只做了 "从DB读内容+分析报告",漏掉了 Obsidian 全文保存和 wiki 关键字提取两个步骤,导致知微/笑笑搜不到全文和概念 | 根因: 对 user-article-ingestion skill 的标准流程执行不完整 — "入库" 的心理模型只停留在"读了、分析了、报告了",忽略了持久化环节(存 Obsidian + 提取 wiki)。id=484 的截断问题只是催化剂,根本原因是长期习惯性漏步 | 正确做法: 每篇微信文章处理完必须自查 checklist — (1) 全文存 Obsidian raw/articles 了吗?(2) wiki 关键字提取了没有?(3) link index 更新了吗?(4) agentmemory 记录了吗?四项全满足才算"已入库"。
+
diff --git a/gateway/linux/wechat_webhook.py b/gateway/linux/wechat_webhook.py
index 020f5b4..3e33e13 100644
--- a/gateway/linux/wechat_webhook.py
+++ b/gateway/linux/wechat_webhook.py
@@ -1,191 +1,329 @@
-#!/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 v3 — per-user session context via Hermes session ID."""
+
+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 并回复微信(带 X-Hermes-Session-Id 实现 per-user 上下文)。"""
+ session_id = f"wechat-{sender_id}"
+ 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}",
+ "X-Hermes-Session-Id": session_id,
+ })
+ 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 [{sender[:8]}], reply: {reply[:60]}")
+
+ if reply and sender:
+ WebhookHandler._send_wechat_static(sender, reply)
+ except Exception as e:
+ log.error(f"Hermes error [{sender[:8]}]: {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("&", "&")
+ 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()
diff --git a/gateway/scripts/agents_health_check.py b/gateway/scripts/agents_health_check.py
index 83c5564..c41c63c 100644
--- a/gateway/scripts/agents_health_check.py
+++ b/gateway/scripts/agents_health_check.py
@@ -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)
diff --git a/gateway/scripts/chat_bridge.py b/gateway/scripts/chat_bridge.py
index 58c3d1a..b1f4e21 100644
--- a/gateway/scripts/chat_bridge.py
+++ b/gateway/scripts/chat_bridge.py
@@ -340,10 +340,13 @@ def _search_all_sessions(query: str, max_sessions: int = 5) -> str:
return f"(搜索出错: {e})"
-# ── Serve session DB path ──
+# ── Serve session DB path (cross-platform) ──
_SERVE_DB = os.path.join(
- os.environ.get("USERPROFILE", "C:\\Users\\hmo"),
- ".local", "share", "opencode", "opencode.db")
+ os.path.expanduser("~"), ".local", "share", "opencode", "opencode.db")
+if sys.platform == "win32" and not os.path.exists(_SERVE_DB):
+ _SERVE_DB = os.path.join(
+ os.environ.get("USERPROFILE", "C:\\Users\\hmo"),
+ ".local", "share", "opencode", "opencode.db")
class SessionBridge:
diff --git a/gateway/scripts/dashboard.py b/gateway/scripts/dashboard.py
index 597e0c2..fb02e75 100644
--- a/gateway/scripts/dashboard.py
+++ b/gateway/scripts/dashboard.py
@@ -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():
diff --git a/gateway/scripts/service_registry.py b/gateway/scripts/service_registry.py
index e78231d..d2c0347 100644
--- a/gateway/scripts/service_registry.py
+++ b/gateway/scripts/service_registry.py
@@ -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,不检查本地端口
diff --git a/gateway/scripts/wechat_qr_notifier.py b/gateway/scripts/wechat_qr_notifier.py
new file mode 100644
index 0000000..2805b32
--- /dev/null
+++ b/gateway/scripts/wechat_qr_notifier.py
@@ -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("&", "&").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()
diff --git a/gateway/scripts/wechat_qr_notifier.py.bak b/gateway/scripts/wechat_qr_notifier.py.bak
new file mode 100644
index 0000000..393d315
--- /dev/null
+++ b/gateway/scripts/wechat_qr_notifier.py.bak
@@ -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("&", "&").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():
+ # 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()