From fd6ea76f540b382f4b4b6148a3ad48cf6ef3ae8b Mon Sep 17 00:00:00 2001 From: mohe Date: Fri, 31 Jul 2026 03:22:35 +0800 Subject: [PATCH 1/4] feat: WeChat status + QR on Dashboard, proactive login notifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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 --- config/agents.yaml | 2 +- docs/learned.md | 2 + gateway/linux/wechat_webhook.py | 515 ++++++---- gateway/scripts/agents_health_check.py | 46 +- gateway/scripts/dashboard.py | 229 ++++- gateway/scripts/service_registry.py | 4 +- gateway/scripts/templates/dashboard.html | 1111 +++++++++++---------- gateway/scripts/wechat_qr_notifier.py | 259 +++++ gateway/scripts/wechat_qr_notifier.py.bak | 166 +++ 9 files changed, 1589 insertions(+), 745 deletions(-) create mode 100644 gateway/scripts/wechat_qr_notifier.py create mode 100644 gateway/scripts/wechat_qr_notifier.py.bak 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..9b01c4c 100644 --- a/gateway/linux/wechat_webhook.py +++ b/gateway/linux/wechat_webhook.py @@ -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("&", "&") + 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/dashboard.py b/gateway/scripts/dashboard.py index fdb3dae..7cbf3dc 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/templates/dashboard.html b/gateway/scripts/templates/dashboard.html index 4e8a102..28024c8 100644 --- a/gateway/scripts/templates/dashboard.html +++ b/gateway/scripts/templates/dashboard.html @@ -1,545 +1,568 @@ - -AgentsMeeting Dashboard - -

AgentsMeeting

Dashboard
-
-
- \ No newline at end of file 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() From 24be3b2aa563869298d059f0cc95c3eb472b21eb Mon Sep 17 00:00:00 2001 From: mohe Date: Fri, 31 Jul 2026 03:43:19 +0800 Subject: [PATCH 2/4] feat: route WeChat messages through SessionRouter for per-user context Previously wechat_webhook.py sent each WeChat message as a stateless API call to Hermes (no X-Hermes-Session-Id), so Mohe had zero memory of previous messages. Now uses SessionRouter with SessionBridge (same as XMPP path), which: - Maintains per-user sessions via opencode.db - Injects recent conversation context automatically - Uses the same model/LLM pipeline as XMPP messages --- gateway/linux/wechat_webhook.py | 35 ++++++++++++++++----------------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/gateway/linux/wechat_webhook.py b/gateway/linux/wechat_webhook.py index 9b01c4c..a977719 100644 --- a/gateway/linux/wechat_webhook.py +++ b/gateway/linux/wechat_webhook.py @@ -4,6 +4,13 @@ import os, sys, json, logging, threading, queue, time from http.server import HTTPServer, BaseHTTPRequestHandler +# ── MoFin session routing (shared with XMPP) ── +_SCRIPTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts") +if _SCRIPTS_DIR not in sys.path: + sys.path.insert(0, _SCRIPTS_DIR) +from chat_bridge import SessionBridge +from session_router import SessionRouter + HERMES_API = "http://192.168.1.246:8646/v1/chat/completions" HERMES_KEY = "hermes123" PORT = 5804 @@ -23,6 +30,12 @@ logging.basicConfig( ) log = logging.getLogger("wc-webhook") +# ── Session router (per-user session context via opencode.db) ── +_router = SessionRouter( + bridge=SessionBridge(session_id="wechat-mohe"), + default_session="wechat-mohe", +) + # ── 消息队列(串行处理,防止并发打爆 Hermes)────────────── _msg_queue = queue.Queue() @@ -42,29 +55,15 @@ def _hermes_worker(): 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() - + """通过 SessionRouter 发送消息(自动带上下文 + 会话管理)。""" 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]}") + reply = _router.route("wechat", sender_id, text) + log.info(f"Router OK, reply: {reply[:60] if reply else '(empty)'}") if reply and sender: WebhookHandler._send_wechat_static(sender, reply) except Exception as e: - log.error(f"Hermes error: {e}") + log.error(f"Router error: {e}") # 启动 worker 线程 From 406446c3f14d97edf2bc7f8c8dbe0718fd682a27 Mon Sep 17 00:00:00 2001 From: mohe Date: Fri, 31 Jul 2026 10:11:53 +0800 Subject: [PATCH 3/4] fix: WeChat session context via X-Hermes-Session-Id (revert SessionRouter) SessionRouter approach failed on Linux because chat_bridge.py has hardcoded Windows paths for provider config and opencode.db. Instead, use the same pattern as xmpp_agent_core.py: direct Hermes API call with X-Hermes-Session-Id=wechat-{sender_id} for per-user sessions. This gives each WeChat user persistent context in Hermes without depending on cross-platform path issues in chat_bridge.py. --- gateway/linux/wechat_webhook.py | 42 +++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/gateway/linux/wechat_webhook.py b/gateway/linux/wechat_webhook.py index a977719..3e33e13 100644 --- a/gateway/linux/wechat_webhook.py +++ b/gateway/linux/wechat_webhook.py @@ -1,16 +1,9 @@ #!/usr/bin/env python3 -"""WeChat webhook receiver v2 - receives messages from docker-wechatbot-webhook.""" +"""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 -# ── MoFin session routing (shared with XMPP) ── -_SCRIPTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts") -if _SCRIPTS_DIR not in sys.path: - sys.path.insert(0, _SCRIPTS_DIR) -from chat_bridge import SessionBridge -from session_router import SessionRouter - HERMES_API = "http://192.168.1.246:8646/v1/chat/completions" HERMES_KEY = "hermes123" PORT = 5804 @@ -30,12 +23,6 @@ logging.basicConfig( ) log = logging.getLogger("wc-webhook") -# ── Session router (per-user session context via opencode.db) ── -_router = SessionRouter( - bridge=SessionBridge(session_id="wechat-mohe"), - default_session="wechat-mohe", -) - # ── 消息队列(串行处理,防止并发打爆 Hermes)────────────── _msg_queue = queue.Queue() @@ -55,15 +42,34 @@ def _hermes_worker(): def _do_forward(sender, sender_id, text): - """通过 SessionRouter 发送消息(自动带上下文 + 会话管理)。""" + """调用 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: - reply = _router.route("wechat", sender_id, text) - log.info(f"Router OK, reply: {reply[:60] if reply else '(empty)'}") + 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"Router error: {e}") + log.error(f"Hermes error [{sender[:8]}]: {e}") # 启动 worker 线程 From 7581bccda5b9a76740394fca88def8c30b34d098 Mon Sep 17 00:00:00 2001 From: mohe Date: Fri, 31 Jul 2026 10:37:07 +0800 Subject: [PATCH 4/4] fix: cross-platform config path in chat_bridge.py _load_providers and _SERVE_DB had hardcoded C:\\\\Users\\\\hmo paths. Now uses os.path.expanduser(" ~\) as primary path, with Windows fallback. Supports OPENCODE_CONFIG_PATH env override. --- gateway/scripts/chat_bridge.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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: