merge: resolve dashboard.html conflict (keep proxy section)
This commit is contained in:
+1
-1
@@ -30,7 +30,7 @@ agents:
|
||||
provider: ocg-new
|
||||
services:
|
||||
- type: hermes_gateway
|
||||
port: 8642
|
||||
port: 8646
|
||||
- type: xmpp_bot
|
||||
|
||||
- id: "agent-004"
|
||||
|
||||
@@ -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 记录了吗?四项全满足才算"已入库"。
|
||||
|
||||
|
||||
+174
-36
@@ -1,25 +1,180 @@
|
||||
#!/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
|
||||
import os, sys, json, logging, threading, queue, time
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
|
||||
HERMES_API = "http://192.168.1.246:8642/v1/chat/completions"
|
||||
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/projects/AgentsMeeting/gateway/linux/logs/webhook.log"),
|
||||
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):
|
||||
@@ -112,11 +267,12 @@ class WebhookHandler(BaseHTTPRequestHandler):
|
||||
log.info(f"From: {sender_name} ({sender_id}), Type: {msg_type}")
|
||||
|
||||
if msg_type == 'text':
|
||||
log.info(f"Text: {content[:300]}")
|
||||
log.info(f"Text: {content}")
|
||||
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}")
|
||||
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:
|
||||
@@ -125,39 +281,17 @@ class WebhookHandler(BaseHTTPRequestHandler):
|
||||
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()
|
||||
"""把消息放入队列,由 worker 串行处理。"""
|
||||
_msg_queue.put((sender, sender_id, text))
|
||||
log.info(f"Queued for Hermes (queue size: {_msg_queue.qsize()})")
|
||||
|
||||
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."""
|
||||
@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": {"content": text}}).encode()
|
||||
data = json.dumps({"to": to_name, "data": {"type": "text", "content": text}}).encode()
|
||||
try:
|
||||
handler = ureq.ProxyHandler({})
|
||||
opener = ureq.build_opener(handler)
|
||||
@@ -168,6 +302,10 @@ class WebhookHandler(BaseHTTPRequestHandler):
|
||||
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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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,不检查本地端口
|
||||
|
||||
@@ -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"<message type='chat'><body>{body}</body></message>"
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "exec", "ejabberd", "ejabberdctl", "send_stanza",
|
||||
XMPP_FROM, XMPP_TO, stanza],
|
||||
capture_output=True, text=True, timeout=15
|
||||
)
|
||||
if result.returncode == 0:
|
||||
log(f"XMPP notification sent to {XMPP_TO}")
|
||||
return True
|
||||
else:
|
||||
log(f"XMPP send failed: {result.stderr}")
|
||||
return False
|
||||
except Exception as e:
|
||||
log(f"XMPP send error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
now = datetime.now()
|
||||
state = load_state()
|
||||
|
||||
# ── 场景 1: 已登录但 session 年龄过大 → 微信预警 ──
|
||||
is_online, session_age, last_ok = check_session_age()
|
||||
log(f"Session: online={is_online}, age={session_age}h, last_ok={last_ok}")
|
||||
|
||||
if is_online and session_age >= AGING_WARN_HOURS:
|
||||
last_aging_warn = state.get("last_aging_warn", "")
|
||||
should_warn = True
|
||||
if last_aging_warn:
|
||||
try:
|
||||
last_warn_dt = datetime.fromisoformat(last_aging_warn)
|
||||
if (now - last_warn_dt).total_seconds() / 3600 < AGING_WARN_INTERVAL_H:
|
||||
should_warn = False
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if should_warn:
|
||||
user_name = "hmo" # WeChat contact name
|
||||
msg = (
|
||||
f"\u26a0\ufe0f 莫荷微信 session 已运行 {session_age:.0f} 小时\n\n"
|
||||
f"\U0001f4e2 预计未来几小时可能掉线\n"
|
||||
f"\U0001f449 请到 Dashboard 健康页面提前准备扫码\n"
|
||||
f"{DASHBOARD_URL}"
|
||||
)
|
||||
if send_wechat(user_name, msg):
|
||||
state["last_aging_warn"] = now.isoformat()
|
||||
state["aging_warn_hours"] = session_age
|
||||
save_state(state)
|
||||
log(f"Aging warning sent ({session_age:.0f}h)")
|
||||
return
|
||||
|
||||
# ── 场景 2: 已掉线 → XMPP 通知(微信不可用) ──
|
||||
if not is_online and (is_logged_out() or session_age > 1):
|
||||
log("WeChat bridge appears to be logged out")
|
||||
|
||||
qr_url = fetch_qr_url()
|
||||
if not qr_url:
|
||||
log("ERROR: could not extract QR code URL")
|
||||
return
|
||||
|
||||
last_notify = state.get("last_notify", "")
|
||||
last_qr = state.get("last_qr", "")
|
||||
|
||||
should_notify = True
|
||||
if last_notify:
|
||||
try:
|
||||
last_dt = datetime.fromisoformat(last_notify)
|
||||
if (now - last_dt).total_seconds() / 60 < NOTIFY_INTERVAL_MIN:
|
||||
should_notify = False
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if should_notify:
|
||||
if send_xmpp(qr_url):
|
||||
state["last_notify"] = now.isoformat()
|
||||
state["last_qr"] = qr_url
|
||||
save_state(state)
|
||||
log("Logout XMPP notification sent")
|
||||
else:
|
||||
log(f"Already notified recently, skipping")
|
||||
return
|
||||
|
||||
# ── 场景 3: 在线且无需预警 ──
|
||||
if is_online and session_age < AGING_WARN_HOURS:
|
||||
log(f"wechat bridge OK, session age {session_age:.0f}h")
|
||||
elif is_online:
|
||||
log(f"session aging ({session_age:.0f}h) but already warned recently")
|
||||
else:
|
||||
log("not online but no recent logout detected - possibly idle")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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"<message type='chat'><body>{body}</body></message>"
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "exec", "ejabberd", "ejabberdctl", "send_stanza",
|
||||
XMPP_FROM, XMPP_TO, stanza],
|
||||
capture_output=True, text=True, timeout=15
|
||||
)
|
||||
if result.returncode == 0:
|
||||
log(f"XMPP notification sent to {XMPP_TO}")
|
||||
return True
|
||||
else:
|
||||
log(f"XMPP send failed: {result.stderr}")
|
||||
return False
|
||||
except Exception as e:
|
||||
log(f"XMPP send error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
# 1. 检查是否掉线
|
||||
if not is_logged_out():
|
||||
log("wechat_bridge is logged in, nothing to do")
|
||||
return
|
||||
|
||||
log("wechat_bridge appears to be logged out")
|
||||
|
||||
# 2. 提取二维码链接
|
||||
qr_url = fetch_qr_url()
|
||||
if not qr_url:
|
||||
log("ERROR: could not extract QR code URL")
|
||||
return
|
||||
|
||||
log(f"QR URL: {qr_url}")
|
||||
|
||||
# 3. 检查是否需要通知
|
||||
should, last_qr = should_notify()
|
||||
if not should:
|
||||
log(f"Already notified recently (last QR: {last_qr[:50]}...), skipping")
|
||||
return
|
||||
|
||||
# 4. 发送 XMPP 通知
|
||||
if send_xmpp(qr_url):
|
||||
save_state(qr_url)
|
||||
log("Done")
|
||||
else:
|
||||
log("Failed to send notification")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user