Initial: multi-agent XMPP communication system with dashboard
- Platform-based architecture (Windows/Linux/Mac) - Agent instance registry (agents.yaml) - Management dashboard with cross-platform monitoring - xmpp_bot with HTTP bridge + health endpoints - wechat_agent with WeChat-Hermes bridging - Platform services: ProcessGuardian, HealthProbe, APIRouter, ChannelBridge - Deployment: systemd (Linux) + PowerShell (Windows) - Monitoring: SSH+ejabberdctl for cross-platform presence
This commit is contained in:
@@ -0,0 +1,982 @@
|
||||
"""
|
||||
WeChat Agent v2 - wxhelper DLL + Hermes API (:8642)
|
||||
"""
|
||||
import os, json, time, threading, requests, re, socketserver, subprocess, urllib.request, urllib.error, queue, locale
|
||||
import warnings
|
||||
warnings.filterwarnings("ignore", message=".*urllib3.*")
|
||||
os.environ["no_proxy"] = "*"
|
||||
os.environ["NO_PROXY"] = "*"
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
from chat_bridge import SessionBridge
|
||||
from session_router import SessionRouter
|
||||
from proc_guard import guard as _proc_guard
|
||||
|
||||
# ── PID lock — prevent duplicate instances ──
|
||||
_lock = _proc_guard("wechat_agent")
|
||||
if not _lock.ok:
|
||||
print(_lock.message, flush=True)
|
||||
sys.exit(1)
|
||||
|
||||
BOT_WXID = "wxid_5bhmquvkbude22"
|
||||
BLOCK_WXIDS = {"fmessage", "weixin", "wechat"} # ϵͳ?˺?/???Ŷӣ----ظ?
|
||||
WX_API = "http://127.0.0.1:19088"
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
LOG_DIR = os.path.join(PROJECT_ROOT, "logs")
|
||||
TEMP_DIR = os.path.join(PROJECT_ROOT, "temp")
|
||||
LOG_FILE = os.path.join(LOG_DIR, "wechat_agent.log")
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
os.makedirs(TEMP_DIR, exist_ok=True)
|
||||
TCP_PORT = 19099
|
||||
last_msg_time = time.time()
|
||||
nickname_cache = {}
|
||||
db_handle_cache = None # MicroMsg.db handle for history queries
|
||||
|
||||
# ── 莫荷↔小小莫 对话记忆 (本地持久化, 可检索) ──
|
||||
MEMORY_DIR = os.path.join(PROJECT_ROOT, "mohe_memory")
|
||||
MEMORY_FILE = os.path.join(MEMORY_DIR, "conversations.jsonl")
|
||||
os.makedirs(MEMORY_DIR, exist_ok=True)
|
||||
|
||||
# 当前serve session ID (莫荷消息进这个session, LLM自动有上下文)
|
||||
ATTACH_SESSION = "ses_1d95d15c4ffehQaZ6hrbIbak5k"
|
||||
SESSION_CTX_FILE = os.path.join(MEMORY_DIR, "session_context.txt")
|
||||
_ctx_last_refresh = 0
|
||||
|
||||
_memory_counter = 0
|
||||
|
||||
def _next_memory_id():
|
||||
global _memory_counter
|
||||
_memory_counter += 1
|
||||
return _memory_counter
|
||||
|
||||
def append_mohe_memory(direction, content):
|
||||
"""Append one exchange to the append-only log."""
|
||||
entry = {"id": _next_memory_id(), "ts": int(time.time()),
|
||||
"direction": direction, "content": content}
|
||||
try:
|
||||
with open(MEMORY_FILE, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||
except Exception as e:
|
||||
log(f"memory append ERR: {e}")
|
||||
|
||||
def read_mohe_context(n=30):
|
||||
"""Read last n exchanges, return as formatted context string."""
|
||||
try:
|
||||
if not os.path.exists(MEMORY_FILE):
|
||||
return ""
|
||||
with open(MEMORY_FILE, "r", encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
recent = lines[-n:] if len(lines) > n else lines
|
||||
parts = []
|
||||
for line in recent:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
who = "莫荷" if entry.get("direction") == "mohe_to_xxm" else "小小莫"
|
||||
parts.append(f"{who}: {entry.get('content', '')[:200]}")
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return "\n".join(parts)
|
||||
except Exception as e:
|
||||
log(f"memory read ERR: {e}")
|
||||
return ""
|
||||
|
||||
def search_mohe_memory(keyword, max_results=10):
|
||||
"""Search conversation memory by keyword. Returns list of matching entries."""
|
||||
results = []
|
||||
try:
|
||||
if not os.path.exists(MEMORY_FILE):
|
||||
return results
|
||||
with open(MEMORY_FILE, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or keyword not in line:
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
results.append(entry)
|
||||
if len(results) >= max_results:
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except Exception as e:
|
||||
log(f"memory search ERR: {e}")
|
||||
return results
|
||||
|
||||
# Session bridge + router — shared with vc_webhook / xmpp
|
||||
attach_bridge = SessionBridge(session_id=ATTACH_SESSION, serve_url="http://127.0.0.1:4096")
|
||||
_router = SessionRouter(
|
||||
bridge=attach_bridge,
|
||||
default_session=ATTACH_SESSION,
|
||||
)
|
||||
|
||||
attach_queue = queue.Queue()
|
||||
_attach_worker_started = False
|
||||
|
||||
def _attach_worker():
|
||||
"""Single worker: processes attach_queue one at a time."""
|
||||
while True:
|
||||
try:
|
||||
msg_text = attach_queue.get()
|
||||
if msg_text is None:
|
||||
break
|
||||
qsize = attach_queue.qsize()
|
||||
if qsize > 0:
|
||||
log(f"attach_queue: {qsize} pending after this")
|
||||
do_attach(msg_text)
|
||||
except Exception as e:
|
||||
log(f"_attach_worker ERR: {e}")
|
||||
|
||||
def queue_attach(msg_text):
|
||||
"""Enqueue a message for serialized async processing (one at a time)."""
|
||||
global _attach_worker_started
|
||||
if not _attach_worker_started:
|
||||
_attach_worker_started = True
|
||||
threading.Thread(target=_attach_worker, daemon=True).start()
|
||||
attach_queue.put(msg_text)
|
||||
log(f"queue_attach: queued ({attach_queue.qsize()} pending)")
|
||||
|
||||
def clear_attach_queue():
|
||||
"""Clear all pending messages in the attach queue (stop mechanism)."""
|
||||
n = 0
|
||||
while not attach_queue.empty():
|
||||
try:
|
||||
attach_queue.get_nowait()
|
||||
n += 1
|
||||
except queue.Empty:
|
||||
break
|
||||
log(f"clear_attach_queue: cleared {n} pending messages")
|
||||
return n
|
||||
|
||||
HERMES_API = "http://192.168.1.246:8642/v1/chat/completions"
|
||||
HERMES_KEY = "hermes123"
|
||||
SENSENOVA_KEY = "sk-aRNj3UwKSLPsDfh15QNTPwbHxahblfaO"
|
||||
SENSENOVA_URL = "https://token.sensenova.cn/v1"
|
||||
|
||||
INJECTOR = r"D:\F\NewI\opencode\daily-workspace\projects\wechat-hermes-gateway\tools\Injector_x64.exe"
|
||||
WXHELPER_DLL = r"D:\F\NewI\opencode\daily-workspace\projects\wechat-hermes-gateway\tools\wxhelper_official_39581.dll"
|
||||
|
||||
def log(m):
|
||||
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
||||
f.write(f"{time.strftime('%H:%M:%S')} {m}\n")
|
||||
|
||||
def wxpost(path, data=None, timeout=10):
|
||||
try:
|
||||
body = json.dumps(data or {}, ensure_ascii=False).encode("utf-8")
|
||||
r = urllib.request.urlopen(urllib.request.Request(WX_API + path, data=body, headers={"Content-Type": "application/json; charset=utf-8"}), timeout=timeout)
|
||||
return json.loads(r.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
return json.loads(e.read().decode()) if e.code else {"code": -1}
|
||||
except Exception as e:
|
||||
log(f"WX ERR: {e}")
|
||||
return {"code": -1}
|
||||
|
||||
# ---- History Query (via MSG table in MSG*.db databases) ----
|
||||
def get_db_handle():
|
||||
"""Get handle for database containing MSG table. Cached after first call."""
|
||||
global db_handle_cache
|
||||
if db_handle_cache:
|
||||
return db_handle_cache
|
||||
r = wxpost("/api/getDBInfo", timeout=10)
|
||||
dbs = r.get("data") or []
|
||||
# WeChat 3.9.5.81+: messages stored in MSG0.db, MSG1.db, etc.
|
||||
# Also check ChatMsg.db (has ChatMsg table with different schema).
|
||||
# Prefer MSG*.db over MicroMsg.db (MicroMsg.db has "Msg" in name but no MSG table in new versions).
|
||||
candidate = None
|
||||
for db in dbs:
|
||||
dbname = db.get("databaseName", "")
|
||||
# Prefer MSG0.db/MSG1.db over MicroMsg.db
|
||||
if dbname.upper().startswith("MSG") and dbname.upper().endswith(".DB"):
|
||||
candidate = db.get("handle")
|
||||
log(f"History DB: {dbname} handle={candidate}")
|
||||
break
|
||||
# Fallback: check if any table is named MSG
|
||||
for t in (db.get("tables") or []):
|
||||
if t.get("tableName") == "MSG":
|
||||
candidate = db.get("handle")
|
||||
log(f"History DB: {dbname} handle={candidate}")
|
||||
break
|
||||
if candidate:
|
||||
break
|
||||
if candidate:
|
||||
db_handle_cache = candidate
|
||||
return candidate
|
||||
log("History DB handle: NOT FOUND")
|
||||
return None
|
||||
|
||||
# Message type labels
|
||||
MSG_TYPES = {1: "----", 3: "ͼƬ", 34: "----", 43: "??Ƶ", 47: "----", 49: "----", 10000: "ϵͳ", 10002: "???"}
|
||||
|
||||
def query_history(wxid, limit=10):
|
||||
"""Query historical text messages with a contact from MSG table."""
|
||||
h = get_db_handle()
|
||||
if not h:
|
||||
return None
|
||||
# Text (type=1) and appmsg/link (type=49), use DisplayContent as fallback for StrContent
|
||||
limit_val = min(int(limit), 50)
|
||||
sql = f"SELECT CreateTime, IsSender, Type, SubType, StrContent, DisplayContent FROM MSG WHERE StrTalker='{wxid}' AND Type IN (1,49) ORDER BY CreateTime DESC LIMIT {limit_val}"
|
||||
r = wxpost("/api/execSql", {"dbHandle": h, "sql": sql}, timeout=15)
|
||||
data = r.get("data") or []
|
||||
if not data or len(data) < 2:
|
||||
return None
|
||||
# Skip header row, reverse to chronological order
|
||||
rows = data[1:]
|
||||
rows.reverse()
|
||||
# Normalize content: prefer StrContent, fallback to DisplayContent
|
||||
results = []
|
||||
for row in rows:
|
||||
content = (row[4] or "").strip() if len(row) > 4 else ""
|
||||
if not content and len(row) > 5:
|
||||
content = (row[5] or "").strip()
|
||||
if not content:
|
||||
continue
|
||||
results.append({"CreateTime": row[0], "IsSender": row[1], "Type": row[2], "content": content})
|
||||
return results
|
||||
|
||||
def format_history(wxid, rows):
|
||||
"""Format MSG rows into readable chat history text."""
|
||||
sender_name = get_nickname(wxid)
|
||||
bot_name = get_nickname(BOT_WXID)
|
||||
lines = [f"?? ----? {sender_name} ----???¼ ({len(rows)}??):"]
|
||||
for row in rows:
|
||||
ts = int(row.get("CreateTime", 0))
|
||||
time_str = time.strftime("%m/%d %H:%M", time.localtime(ts)) if ts else "?"
|
||||
is_sender = int(row.get("IsSender", 0))
|
||||
msg_type = int(row.get("Type", 1))
|
||||
content = row.get("content", "")
|
||||
# Determine who sent it
|
||||
who = bot_name if is_sender else sender_name
|
||||
# Format content
|
||||
if msg_type == 49:
|
||||
content = f"[----] {content[:60]}"
|
||||
else:
|
||||
content = content[:200]
|
||||
lines.append(f"[{time_str}] {who}: {content}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def handle_history(wxid, count):
|
||||
"""Query and format history for a given wxid. Returns text to send."""
|
||||
try:
|
||||
rows = query_history(wxid, count)
|
||||
if rows:
|
||||
return format_history(wxid, rows)
|
||||
return f"----?? {get_nickname(wxid)} ----???¼"
|
||||
except Exception as e:
|
||||
log(f"History ERR: {e}")
|
||||
return "??ѯ??ʷ??¼ʧ??"
|
||||
|
||||
def handle_history_json(wxid, count):
|
||||
"""Query history and return JSON-serializable dict for HTTP API."""
|
||||
try:
|
||||
rows = query_history(wxid, count)
|
||||
sender_name = get_nickname(wxid)
|
||||
if not rows:
|
||||
return {"ok": True, "wxid": wxid, "sender_name": sender_name, "count": 0, "messages": []}
|
||||
bot_name = get_nickname(BOT_WXID)
|
||||
messages = []
|
||||
for row in rows:
|
||||
ts = int(row.get("CreateTime", 0))
|
||||
time_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts)) if ts else ""
|
||||
is_sender = int(row.get("IsSender", 0))
|
||||
msg_type = int(row.get("Type", 1))
|
||||
content = row.get("content", "")
|
||||
messages.append({
|
||||
"time": time_str,
|
||||
"timestamp": ts,
|
||||
"sender": bot_name if is_sender else sender_name,
|
||||
"is_self": bool(is_sender),
|
||||
"type": msg_type,
|
||||
"content": content[:500]
|
||||
})
|
||||
return {
|
||||
"ok": True,
|
||||
"wxid": wxid,
|
||||
"sender_name": sender_name,
|
||||
"count": len(messages),
|
||||
"requested_count": min(int(count or 10), 50),
|
||||
"messages": messages
|
||||
}
|
||||
except Exception as e:
|
||||
log(f"History JSON ERR: {e}")
|
||||
return {"ok": False, "error": str(e)[:200]}
|
||||
|
||||
def send_wx(wxid, msg):
|
||||
# Strip weixin:// URLs that WeChat interprets as commands
|
||||
import re as _re2
|
||||
msg = _re2.sub(r'weixin://[^\s]+', '[----?ѹ???]', msg)
|
||||
r = wxpost("/api/sendTextMsg", {"wxid": wxid, "msg": msg})
|
||||
log(f"SEND {wxid}: {r.get('msg','')}")
|
||||
|
||||
def get_nickname(wxid):
|
||||
if wxid in nickname_cache:
|
||||
return nickname_cache[wxid]
|
||||
r = wxpost("/api/getContactList", timeout=10)
|
||||
for c in (r.get("data") or []):
|
||||
if c.get("wxid") == wxid:
|
||||
nick = c.get("nickname") or c.get("customAccount") or wxid
|
||||
nickname_cache[wxid] = nick
|
||||
return nick
|
||||
nickname_cache[wxid] = wxid
|
||||
return wxid
|
||||
|
||||
def call_hermes(wxid, content):
|
||||
nickname = get_nickname(wxid)
|
||||
headers = {"Authorization": f"Bearer {HERMES_KEY}", "X-Hermes-Session-Id": "sisyphus", "Content-Type": "application/json"}
|
||||
# 群聊 vs 私聊自动适配:群聊有接龙游戏时直接参与,不分析规则
|
||||
is_group = "@chatroom" in wxid
|
||||
if is_group:
|
||||
sys_prompt = "你是莫荷,女生。群聊中回复要短。有人玩成语接龙时,看到「接X字」直接以X开头接一个成语继续,不要分析规则、不要解释、不要评价。保持接龙节奏不打断。"
|
||||
else:
|
||||
sys_prompt = "你是莫荷,女生。回复简短自然,像朋友聊天。"
|
||||
body = {"model": "hermes-agent", "messages": [{"role": "system", "content": sys_prompt}, {"role": "user", "content": content}]}
|
||||
log(f"CALL_HERMES content[:120]={content[:120]}")
|
||||
log(f"CALL_HERMES body user_msg={json.dumps(body, ensure_ascii=False)[:200]}")
|
||||
try:
|
||||
r = requests.post(HERMES_API, json=body, headers=headers, proxies={"http": None, "https": None})
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
choice = data["choices"][0]
|
||||
# Observer pattern: Gateway returns finish_reason="silent" for group messages that don't need reply
|
||||
if choice.get("finish_reason") == "silent":
|
||||
log("Hermes: __SILENT__ (group, skip)")
|
||||
return None
|
||||
return choice["message"]["content"]
|
||||
except Exception as e:
|
||||
log(f"API ERR: {e}")
|
||||
return None
|
||||
|
||||
def inject_to_hermes_session(text):
|
||||
"""Inject chat history / context directly into Hermes's sisyphus session for memory repair."""
|
||||
headers = {"Authorization": f"Bearer {HERMES_KEY}", "X-Hermes-Session-Id": "sisyphus", "Content-Type": "application/json"}
|
||||
sys_prompt = "?? MEMORY INJECTION: Below is past chat history. Absorb this into your context for memory repair. Do NOT reply to this ?? just acknowledge with 'Memory synced.'"
|
||||
body = {"model": "hermes-agent", "messages": [
|
||||
{"role": "system", "content": sys_prompt},
|
||||
{"role": "user", "content": text}
|
||||
]}
|
||||
try:
|
||||
r = requests.post(HERMES_API, json=body, headers=headers, proxies={"http": None, "https": None})
|
||||
if r.status_code == 200:
|
||||
reply = r.json()["choices"][0]["message"]["content"]
|
||||
log(f"Inject history OK: {reply[:50]}")
|
||||
return True
|
||||
log(f"Inject history HTTP {r.status_code}")
|
||||
except Exception as e:
|
||||
log(f"Inject history ERR: {e}")
|
||||
return False
|
||||
|
||||
# ---- Inject wxhelper DLL ----
|
||||
def inject_wxhelper(force=False):
|
||||
if not force:
|
||||
try:
|
||||
r = wxpost("/api/checkLogin", timeout=5)
|
||||
if r.get("code") == 1:
|
||||
log("wxhelper already injected")
|
||||
return True
|
||||
except:
|
||||
pass
|
||||
# Also check if port 19088 is just listening (wxhelper HTTP server alive)
|
||||
try:
|
||||
import socket as _sock
|
||||
s = _sock.create_connection(("127.0.0.1", 19088), timeout=2)
|
||||
s.close()
|
||||
r = wxpost("/api/checkLogin", timeout=5)
|
||||
if r.get("code") == 1:
|
||||
log("wxhelper HTTP server alive, login OK")
|
||||
return True
|
||||
except:
|
||||
pass
|
||||
# Wait a moment in case server is still starting
|
||||
time.sleep(3)
|
||||
try:
|
||||
r = wxpost("/api/checkLogin", timeout=5)
|
||||
if r.get("code") == 1:
|
||||
log("wxhelper responding after wait")
|
||||
return True
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
# Injector_x64.exe: -n process_name -i dll_path
|
||||
result = subprocess.run([INJECTOR, "-n", "WeChat.exe", "-i", WXHELPER_DLL], capture_output=True, text=True, timeout=30)
|
||||
output = (result.stdout + result.stderr).strip()
|
||||
log(f"Inject: {output[:100]}")
|
||||
# Check if injection succeeded by looking for "success" in output
|
||||
if "success" not in output.lower():
|
||||
log(f"Inject MAY HAVE FAILED (no 'success' in output), retrying...")
|
||||
time.sleep(2)
|
||||
result2 = subprocess.run([INJECTOR, "-n", "WeChat.exe", "-i", WXHELPER_DLL], capture_output=True, text=True, timeout=30)
|
||||
log(f"Inject retry: {(result2.stdout+result2.stderr).strip()[:100]}")
|
||||
time.sleep(3)
|
||||
r = wxpost("/api/checkLogin", timeout=5)
|
||||
if r.get("code") == 1:
|
||||
log("wxhelper injected OK")
|
||||
return True
|
||||
log(f"Inject check: {r}")
|
||||
return False
|
||||
except Exception as e:
|
||||
log(f"Inject FAIL: {e}")
|
||||
return False
|
||||
|
||||
# ---- TCP Message Receiver ----
|
||||
class MsgHandler(socketserver.BaseRequestHandler):
|
||||
def handle(self):
|
||||
try:
|
||||
data = b""
|
||||
while True:
|
||||
c = self.request.recv(4096)
|
||||
data += c
|
||||
if not c or c[-1] == 10:
|
||||
break
|
||||
if data.strip():
|
||||
threading.Thread(target=process_msg, args=(data,), daemon=True).start()
|
||||
self.request.sendall(b"200 OK\n")
|
||||
except:
|
||||
pass
|
||||
finally:
|
||||
self.request.close()
|
||||
|
||||
# ---- Image OCR ----
|
||||
WX_FILES_BASE = os.path.join(os.path.expanduser("~"), "Documents", "WeChat Files")
|
||||
BOT_WX_DIR = os.path.join(WX_FILES_BASE, BOT_WXID, "wxhelper")
|
||||
|
||||
def ocr_image(base64_data):
|
||||
"""OCR from in-memory base64 image data. Returns text or None."""
|
||||
try:
|
||||
headers = {"Authorization": "Bearer b0359bed-09f2-49e2-a53c-32ba057412e3", "Content-Type": "application/json"}
|
||||
payload = {
|
||||
"model": "doubao-seed-code",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "请识别这张图片中的所有中文和英文字符,保持原文输出,包括数字、表格、百分比的完整结构。严格逐行逐列输出所有数据,不要省略、不要总结。"},
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{base64_data}"}}
|
||||
]
|
||||
}]
|
||||
}
|
||||
r = requests.post(
|
||||
"https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions",
|
||||
json=payload, headers=headers, timeout=60,
|
||||
proxies={"http": None, "https": None}
|
||||
)
|
||||
if r.status_code == 200:
|
||||
text = r.json()["choices"][0]["message"]["content"].strip()
|
||||
log(f"OCR OK ({len(text)} chars)")
|
||||
return text
|
||||
log(f"OCR HTTP {r.status_code}: {r.text[:200]}")
|
||||
except Exception as e:
|
||||
log(f"OCR ERR: {e}")
|
||||
return None
|
||||
|
||||
def ocr_image_file(image_path):
|
||||
"""OCR an image file on disk. Returns text or None."""
|
||||
try:
|
||||
with open(image_path, "rb") as f:
|
||||
b64 = base64.b64encode(f.read()).decode()
|
||||
return ocr_image(b64)
|
||||
except Exception as e:
|
||||
log(f"ocr_image_file ERR: {e}")
|
||||
return None
|
||||
|
||||
# ---- Full Image Download & Decode (wxhelper 3.9.5.81+) ----
|
||||
def download_full_image(msg_id):
|
||||
"""Download full image from CDN via downloadAttach. Returns encrypted .dat path or None.
|
||||
|
||||
Retries both the API call (wxhelper may return -2 transiently)
|
||||
and file existence (async CDN download takes time).
|
||||
"""
|
||||
try:
|
||||
dat_path = os.path.join(BOT_WX_DIR, "image", f"{msg_id}.dat")
|
||||
|
||||
# Phase 1: Retry API call (wxhelper may return -2 if msg not ready)
|
||||
for api_attempt in range(10):
|
||||
r = wxpost("/api/downloadAttach", {"msgId": int(msg_id)}, timeout=30)
|
||||
code = r.get("code", -1)
|
||||
if code >= 0:
|
||||
break
|
||||
log(f"downloadAttach attempt {api_attempt+1}: code={code} {r.get('msg','')}")
|
||||
time.sleep(1)
|
||||
else:
|
||||
log(f"downloadAttach FAILED after 10 attempts, last code={code}")
|
||||
return None
|
||||
|
||||
# Phase 2: Wait for async CDN download
|
||||
log(f"downloadAttach queued, waiting for file...")
|
||||
for wait_attempt in range(20):
|
||||
if os.path.exists(dat_path):
|
||||
log(f"Download OK: {dat_path} ({os.path.getsize(dat_path)} bytes)")
|
||||
return dat_path
|
||||
time.sleep(1)
|
||||
log(f"downloadAttach: .dat not found after 20s for msgId={msg_id}")
|
||||
except Exception as e:
|
||||
log(f"downloadAttach ERR: {e}")
|
||||
return None
|
||||
|
||||
def decode_image_file(dat_path):
|
||||
"""Decrypt encrypted .dat to viewable image. Returns decoded path or None.
|
||||
|
||||
Some .dat files are already valid PNG/JPEG images (not encrypted).
|
||||
Falls back to checking if .dat itself is a valid image.
|
||||
"""
|
||||
try:
|
||||
before_files = set(os.listdir(TEMP_DIR))
|
||||
r = wxpost("/api/decodeImage", {"filePath": dat_path, "storeDir": TEMP_DIR}, timeout=30)
|
||||
if r.get("code", -1) > 0:
|
||||
base = os.path.splitext(os.path.basename(dat_path))[0]
|
||||
for ext in ['.jpg', '.jpeg', '.png', '.bmp']:
|
||||
cand = os.path.join(TEMP_DIR, base + ext)
|
||||
if os.path.exists(cand):
|
||||
log(f"Decoded: {cand}")
|
||||
return cand
|
||||
for f in os.listdir(TEMP_DIR):
|
||||
if f in before_files: continue
|
||||
if f.lower().endswith(('.jpg', '.jpeg', '.png')):
|
||||
cand = os.path.join(TEMP_DIR, f)
|
||||
log(f"Decoded (new): {cand}")
|
||||
return cand
|
||||
log("decodeImage OK but no new image file found")
|
||||
# Fallback: .dat file may already be a valid image (not encrypted)
|
||||
with open(dat_path, "rb") as f:
|
||||
header = f.read(4)
|
||||
ext = None
|
||||
if header[:2] == b'\xff\xd8': # JPEG
|
||||
ext = '.jpg'
|
||||
elif header[:4] == b'\x89PNG': # PNG
|
||||
ext = '.png'
|
||||
elif header[:4] == b'GIF8': # GIF
|
||||
ext = '.gif'
|
||||
elif header[:2] == b'BM': # BMP
|
||||
ext = '.bmp'
|
||||
if ext:
|
||||
out_path = os.path.join(TEMP_DIR, os.path.splitext(os.path.basename(dat_path))[0] + ext)
|
||||
import shutil
|
||||
shutil.copy(dat_path, out_path)
|
||||
log(f".dat is already {ext}, copied to {out_path}")
|
||||
return out_path
|
||||
log(f"decodeImage FAIL: code={r.get('code')} {r.get('msg','')}")
|
||||
except Exception as e:
|
||||
log(f"decodeImage ERR: {e}")
|
||||
return None
|
||||
|
||||
def process_msg(raw_data):
|
||||
global last_msg_time, last_raw_msg_time
|
||||
last_msg_time = time.time()
|
||||
last_raw_msg_time = time.time()
|
||||
try:
|
||||
d = json.loads(raw_data)
|
||||
log(f"RAW: fromUser={d.get('fromUser','')} type={d.get('type','')} self={d.get('isSelf',d.get('self',0))}")
|
||||
fu = d.get("fromUser", "") or d.get("fromuser", "") or d.get("sender", "")
|
||||
ct = d.get("content", "") or d.get("msg", "") or d.get("text", "")
|
||||
msg_type = d.get("type", 1)
|
||||
is_self = d.get("isSelf", 0) or d.get("self", 0)
|
||||
if "@chatroom" in fu:
|
||||
log(f"GROUP RAW DUMP: keys={list(d.keys())} ct_len={len(ct)} ct[:100]={ct[:100]}")
|
||||
if not fu or not ct or fu == BOT_WXID or fu in BLOCK_WXIDS or fu.startswith("gh_") or is_self:
|
||||
log(f"SKIP: fu={fu} self={is_self}")
|
||||
return
|
||||
# Route by message type
|
||||
if msg_type == 34: # Voice
|
||||
log(f"<- {fu}: [voice]")
|
||||
reply = call_hermes(fu, "[voice message]")
|
||||
if reply and reply.strip():
|
||||
send_wx(fu, reply.strip())
|
||||
return
|
||||
if msg_type == 3: # Image
|
||||
msg_id = d.get("msgId", 0) or d.get("svrid", 0)
|
||||
log(f"IMAGE: msgId={msg_id} b64_len={len(d.get('base64Img',''))}")
|
||||
ocr_text = None
|
||||
# Full-image OCR via wxhelper 3.9.5.81 APIs
|
||||
if msg_id:
|
||||
dat_path = download_full_image(msg_id)
|
||||
if dat_path:
|
||||
decoded = decode_image_file(dat_path)
|
||||
if decoded:
|
||||
log(f"Full image OCR on {decoded}")
|
||||
ocr_text = ocr_image_file(decoded)
|
||||
if ocr_text:
|
||||
log(f"OCR result ({len(ocr_text)} chars): {ocr_text[:200]}")
|
||||
reply = call_hermes(fu, f"[老莫发送了一张图片,OCR识别结果如下]\n{ocr_text}")
|
||||
elif msg_id:
|
||||
log("Full-image OCR failed, skipping thumbnail (useless at 84x210)")
|
||||
reply = call_hermes(fu, "[老莫发送了一张图片,但全尺寸图片下载或OCR识别失败,无法读取内容]")
|
||||
else:
|
||||
log("No msgId available, cannot download full image")
|
||||
reply = call_hermes(fu, "[老莫发送了一张图片,但无法获取图片ID,无法识别]")
|
||||
if reply and reply.strip():
|
||||
log(f"-> {fu}: {reply[:50]}")
|
||||
process_tags(reply, fu)
|
||||
else:
|
||||
log(f"-> {fu}: skip (blank image response)")
|
||||
return
|
||||
# Text - prepend sender wxid+name so Hermes knows who's talking
|
||||
sender_name = get_nickname(fu)
|
||||
chat_type = "Group" if "@chatroom" in fu else "Private"
|
||||
msg_with_sender = f"[{chat_type}][{fu}|{sender_name}] {ct}"
|
||||
log(f"<- {fu} ({sender_name}): {ct[:50]}")
|
||||
log(f"TO HERMES: [{chat_type}] {ct[:80]}")
|
||||
log(f"TO HERMES FULL: {msg_with_sender[:150]}")
|
||||
reply = call_hermes(fu, msg_with_sender)
|
||||
if reply and reply.strip():
|
||||
log(f"-> {fu}: {reply[:50]}")
|
||||
process_tags(reply, fu)
|
||||
else:
|
||||
log(f"-> {fu}: no reply (blank/empty)")
|
||||
except Exception as e:
|
||||
log(f"MSG ERR: {e}")
|
||||
import traceback
|
||||
log(f"TRACE: {traceback.format_exc()[:200]}")
|
||||
|
||||
def process_tags(reply, fu):
|
||||
if not reply:
|
||||
return
|
||||
clean = reply
|
||||
# [FILE]
|
||||
for tag, pattern, repl in [
|
||||
("FILE", r'\[FILE\](.*?)\[/FILE\]', lambda m: download_and_send_file(m, fu)),
|
||||
("IMG", r'\[IMG\](.*?)\[/IMG\]', lambda m: handle_img(m, fu)),
|
||||
("EMOJI", r'\[EMOJI\](.*?)\[/EMOJI\]', lambda m: download_emoji(m, fu)),
|
||||
]:
|
||||
match = re.search(pattern, clean)
|
||||
if match:
|
||||
clean = re.sub(r'\s*' + pattern.replace('(.*?)', '.*?') + r'\s*', '', clean).strip()
|
||||
try:
|
||||
match = re.search(pattern, reply) # re-match against original
|
||||
if match:
|
||||
threading.Thread(target=repl, args=(match,), daemon=True).start()
|
||||
except Exception as e:
|
||||
log(f"[{tag}] Thread start ERR: {e}")
|
||||
# [CONTACT:wxid]
|
||||
cm = re.search(r'\[CONTACT:(\w+)\]', clean)
|
||||
if cm:
|
||||
clean = re.sub(r'\s*\[CONTACT:\w+\]\s*', '', clean).strip()
|
||||
r = wxpost("/api/getContactProfile", {"wxid": cm.group(1)})
|
||||
cd = r.get("data", {})
|
||||
send_wx(fu, f"?dz?: {cd.get('nickname','?')} ??ע: {cd.get('remark','')}")
|
||||
# [ROOM_MEMBERS:roomid]
|
||||
rm = re.search(r'\[ROOM_MEMBERS:(\S+)\]', clean)
|
||||
if rm:
|
||||
clean = re.sub(r'\s*\[ROOM_MEMBERS:\S+\]\s*', '', clean).strip()
|
||||
r = wxpost("/api/getMemberFromChatRoom", {"chatRoomId": rm.group(1)})
|
||||
members = (r.get("data") or {}).get("members", "")
|
||||
mlist = [m for m in members.split("\u0007") if m]
|
||||
send_wx(fu, f"Ⱥ??Ա ({len(mlist)}): {','.join(mlist[:20])}")
|
||||
# [HISTORY:wxid:count] - query chat history from MSG table
|
||||
hm = re.search(r'\[HISTORY:(\S+?):(\d+)\]', clean)
|
||||
if hm:
|
||||
clean = re.sub(r'\s*\[HISTORY:\S+?:\d+\]\s*', '', clean).strip()
|
||||
target_wxid, count = hm.group(1), int(hm.group(2))
|
||||
threading.Thread(target=lambda: send_wx(fu, handle_history(target_wxid, count)), daemon=True).start()
|
||||
# [PAT:roomid:wxid]
|
||||
pm = re.search(r'\[PAT:(\S+):(\S+)\]', clean)
|
||||
if pm:
|
||||
clean = re.sub(r'\s*\[PAT:\S+:\S+\]\s*', '', clean).strip()
|
||||
wxpost("/api/sendPatMsg", {"receiver": pm.group(1), "wxid": pm.group(2)})
|
||||
if clean.strip():
|
||||
send_wx(fu, clean.strip())
|
||||
|
||||
def download_and_send_file(m, fu):
|
||||
url = m.group(1).strip()
|
||||
log(f"[FILE] Downloading: {url}")
|
||||
try:
|
||||
ir = requests.get(url, timeout=60, proxies={"http": None, "https": None})
|
||||
log(f"[FILE] HTTP {ir.status_code}, size={len(ir.content)}")
|
||||
if ir.status_code == 200:
|
||||
# Preserve original file extension so wxhelper can detect file type
|
||||
ext = os.path.splitext(urlparse(url).path)[-1] or ".dat"
|
||||
if ext.lower() not in ('.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
||||
'.txt', '.zip', '.rar', '.jpg', '.png', '.gif', '.mp3', '.mp4'):
|
||||
ext = ".dat"
|
||||
tmp = os.path.join(TEMP_DIR, f"send_file_{int(time.time())}{ext}")
|
||||
with open(tmp, "wb") as f:
|
||||
f.write(ir.content)
|
||||
log(f"[FILE] Saved to {tmp}, sending via wxhelper...")
|
||||
r = wxpost("/api/sendFileMsg", {"wxid": fu, "filePath": tmp})
|
||||
log(f"[FILE] wxpost result: {r.get('code','?')} {r.get('msg','?')}")
|
||||
# Keep file alive briefly for async wxhelper read
|
||||
time.sleep(1)
|
||||
try:
|
||||
os.remove(tmp)
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
log(f"[FILE] Download FAILED: HTTP {ir.status_code}")
|
||||
except Exception as e:
|
||||
log(f"[FILE] ERR: {e}")
|
||||
|
||||
def handle_img(m, fu):
|
||||
cmd = m.group(1).strip()
|
||||
if cmd.startswith("generate:") or cmd.startswith("draw:"):
|
||||
parts = cmd.split(":", 1)[1].strip()
|
||||
ratio = "1:1"
|
||||
if "|" in parts:
|
||||
ratio = parts.split("|")[1].strip()
|
||||
prompt = parts.split("|")[0].strip()
|
||||
else:
|
||||
prompt = parts
|
||||
size_map = {"1:1":"2048x2048","16:9":"2752x1536","9:16":"1536x2752","3:2":"2496x1664","2:3":"1664x2496","3:4":"1760x2368","4:3":"2368x1760"}
|
||||
size = size_map.get(ratio, "2048x2048")
|
||||
log(f"GEN SenseNova: {prompt[:30]} [{ratio}]")
|
||||
r = requests.post(SENSENOVA_URL + "/images/generations",
|
||||
json={"model": "sensenova-u1-fast", "prompt": prompt, "size": size, "response_format": "url"},
|
||||
headers={"Authorization": f"Bearer {SENSENOVA_KEY}", "Content-Type": "application/json"}, timeout=180)
|
||||
if r.status_code == 200:
|
||||
img_url = r.json()["data"][0]["url"]
|
||||
ir = requests.get(img_url, timeout=60)
|
||||
if ir.status_code == 200:
|
||||
tmp = os.path.join(TEMP_DIR, f"gen_img_{int(time.time())}.png")
|
||||
with open(tmp, "wb") as f: f.write(ir.content)
|
||||
wxpost("/api/sendImagesMsg", {"wxid": fu, "imagePath": tmp})
|
||||
os.remove(tmp)
|
||||
else:
|
||||
ir = requests.get(cmd, timeout=30, proxies={"http": None, "https": None})
|
||||
if ir.status_code == 200:
|
||||
ext = ".jpg"
|
||||
if "png" in ir.headers.get("content-type", ""): ext = ".png"
|
||||
tmp = os.path.join(TEMP_DIR, f"send_img_{int(time.time())}{ext}")
|
||||
with open(tmp, "wb") as f: f.write(ir.content)
|
||||
wxpost("/api/sendImagesMsg", {"wxid": fu, "imagePath": tmp})
|
||||
os.remove(tmp)
|
||||
|
||||
def download_emoji(m, fu):
|
||||
url = m.group(1).strip()
|
||||
ir = requests.get(url, timeout=30, proxies={"http": None, "https": None})
|
||||
if ir.status_code == 200:
|
||||
tmp = os.path.join(TEMP_DIR, f"emoji_{int(time.time())}.png")
|
||||
with open(tmp, "wb") as f: f.write(ir.content)
|
||||
wxpost("/api/sendCustomEmotion", {"wxid": fu, "filePath": tmp})
|
||||
os.remove(tmp)
|
||||
|
||||
# ---- Watchdog ----
|
||||
def force_unhook():
|
||||
"""Switch wxhelper to HTTP mode to clear an existing TCP hook."""
|
||||
try:
|
||||
wxpost("/api/hookSyncMsg", {"ip": "0.0.0.0", "port": 0, "enableHttp": 1}, timeout=5)
|
||||
time.sleep(1)
|
||||
return True
|
||||
except Exception as e:
|
||||
log(f"unhook ERR: {e}")
|
||||
return False
|
||||
|
||||
def force_rehook():
|
||||
"""Forcefully reset the wxhelper sync hook.
|
||||
|
||||
Strategy: switch to HTTP mode (breaks existing TCP hook),
|
||||
then switch back to TCP (forces fresh TCP push connection).
|
||||
This fixes the case where hookSyncMsg returns code:2 but
|
||||
the actual TCP push has silently died.
|
||||
"""
|
||||
log("FORCE REHOOK: resetting sync hook (HTTP to TCP flip)...")
|
||||
try:
|
||||
# Step 1: Switch to HTTP mode (clears TCP hook)
|
||||
force_unhook()
|
||||
# Step 2: Switch back to TCP mode (re-establishes TCP push)
|
||||
r = wxpost("/api/hookSyncMsg", {"ip": "127.0.0.1", "port": TCP_PORT, "enableHttp": 0}, timeout=5)
|
||||
log(f"FORCE REHOOK: hookSyncMsg returned {r}")
|
||||
time.sleep(2)
|
||||
# Verify
|
||||
r2 = wxpost("/api/checkLogin", timeout=5)
|
||||
if r2.get("code") == 1:
|
||||
log("FORCE REHOOK: OK")
|
||||
return True
|
||||
log(f"FORCE REHOOK: checkLogin after rehook: {r2}")
|
||||
except Exception as e:
|
||||
log(f"FORCE REHOOK ERR: {e}")
|
||||
return False
|
||||
|
||||
def watchdog():
|
||||
global last_msg_time, last_raw_msg_time
|
||||
_force_rehook_attempted = False
|
||||
while True:
|
||||
now = time.time()
|
||||
idle = now - last_msg_time
|
||||
raw_idle = now - last_raw_msg_time
|
||||
if idle > 120:
|
||||
try:
|
||||
# Detect: messages dried up for 5+ minutes
|
||||
if raw_idle > 300:
|
||||
log(f"WATCHDOG: no RAW msg for {int(raw_idle)}s (idle={int(idle)}s) -- force rehook")
|
||||
ok = force_rehook()
|
||||
if ok:
|
||||
_force_rehook_attempted = False
|
||||
elif _force_rehook_attempted:
|
||||
log("WATCHDOG: force_rehook failed twice, attempting DLL re-inject...")
|
||||
inject_wxhelper(force=True)
|
||||
_force_rehook_attempted = False
|
||||
else:
|
||||
log("WATCHDOG: force_rehook failed, retrying next cycle...")
|
||||
_force_rehook_attempted = True
|
||||
else:
|
||||
# Normal: wxhelper alive, just refresh hook
|
||||
r = wxpost("/api/checkLogin", timeout=5)
|
||||
if r.get("code") == 1:
|
||||
wxpost("/api/hookSyncMsg", {"ip": "127.0.0.1", "port": TCP_PORT, "enableHttp": 0})
|
||||
log(f"WATCHDOG: refreshed ({int(idle)}s, raw_idle={int(raw_idle)}s)")
|
||||
else:
|
||||
log(f"WATCHDOG: checkLogin failed ({r}), re-injecting...")
|
||||
inject_wxhelper(force=True)
|
||||
except Exception as e:
|
||||
log(f"WATCHDOG EXC: {e}")
|
||||
last_msg_time = now
|
||||
time.sleep(30)
|
||||
|
||||
# ---- Start ----
|
||||
print("[Agent] starting...", flush=True)
|
||||
log("=== Agent v2 (wxhelper) ===")
|
||||
|
||||
# Inject wxhelper
|
||||
inject_wxhelper()
|
||||
|
||||
# Check login
|
||||
r = wxpost("/api/checkLogin")
|
||||
if r.get("code") == 1:
|
||||
log(f"Logged in: OK")
|
||||
else:
|
||||
log(f"Login check: {r}")
|
||||
log("Will retry via watchdog")
|
||||
|
||||
# Start watchdog
|
||||
threading.Thread(target=watchdog, daemon=True).start()
|
||||
|
||||
# Start TCP server for message receiving
|
||||
tcp_server = socketserver.ThreadingTCPServer(("127.0.0.1", TCP_PORT), MsgHandler)
|
||||
threading.Thread(target=tcp_server.serve_forever, daemon=True).start()
|
||||
log(f"TCP server on :{TCP_PORT}")
|
||||
|
||||
# Hook sync messages (tell DLL to send events to our TCP server)
|
||||
r = wxpost("/api/hookSyncMsg", {"port": TCP_PORT, "ip": "127.0.0.1", "enableHttp": 0})
|
||||
log(f"hookSyncMsg: {r}")
|
||||
|
||||
# ── 5801 hermes-msg handler ──
|
||||
def do_attach(msg_text):
|
||||
"""Inject → LLM → capture reply → Hermes forward (all in one flow)."""
|
||||
# Pre-process [FILE] tags
|
||||
clean_msg = msg_text
|
||||
fm = re.search(r'\[FILE\](.*?)\[/FILE\]', msg_text, re.IGNORECASE)
|
||||
if fm:
|
||||
url = fm.group(1).strip()
|
||||
log(f"[FILE] Detected in message: {url[:80]}")
|
||||
try:
|
||||
download_and_send_file(fm, "wxid_c0a6izmwd78y22")
|
||||
clean_msg = re.sub(r'\s*\[FILE\].*?\[/FILE\]\s*', ' ', msg_text).strip()
|
||||
except Exception as e:
|
||||
log(f"[FILE] process ERR: {e}")
|
||||
|
||||
reply = _router.route("wechat", "mohe", clean_msg[:2000])
|
||||
if not reply:
|
||||
log("do_attach: no text reply in time")
|
||||
return
|
||||
|
||||
# Save to memory
|
||||
append_mohe_memory("mohe_to_xxm", msg_text[:500])
|
||||
append_mohe_memory("xxm_to_mohe", reply[:500])
|
||||
|
||||
# Hermes forward
|
||||
log(f"do_attach: -> {reply[:80]}")
|
||||
try:
|
||||
requests.post(HERMES_API,
|
||||
json={"model": "hermes-agent",
|
||||
"messages": [{"role": "user", "content": f"[xxm] {reply[:500]}"}]},
|
||||
headers={"Authorization": f"Bearer {HERMES_KEY}",
|
||||
"X-Hermes-Session-Id": "sisyphus"},
|
||||
timeout=60, proxies={"http": None, "https": None})
|
||||
except Exception as e:
|
||||
log(f"do_attach: Hermes forward fail ({e})")
|
||||
log("do_attach done")
|
||||
|
||||
class RH(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
global last_msg_time
|
||||
last_msg_time = time.time()
|
||||
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
|
||||
try:
|
||||
d = json.loads(body)
|
||||
if self.path in ("/history", "/api/chatHistory"):
|
||||
wxid = (d.get("wxid", "") or "").strip()
|
||||
count = d.get("count", 10) or d.get("limit", 10)
|
||||
if not wxid:
|
||||
self._send_json({"ok": False, "error": "Missing wxid"})
|
||||
return
|
||||
self._send_json(handle_history_json(wxid, count))
|
||||
return
|
||||
if self.path == "/stop":
|
||||
n = clear_attach_queue()
|
||||
log(f"STOP via HTTP: cleared {n} pending")
|
||||
self._send_json({"ok": True, "cleared": n, "status": "stopped"})
|
||||
return
|
||||
if self.path == "/hermes-msg":
|
||||
msg = d.get("message", "") or d.get("content", "") or str(d)[:200]
|
||||
log("<<< HERMES: " + msg[:200] if len(msg) > 200 else msg)
|
||||
with open(os.path.join(TEMP_DIR, "hermes_inbox.txt"), "a", encoding="utf-8") as f:
|
||||
f.write(f"{time.strftime('%H:%M:%S')} {msg}\n")
|
||||
queue_attach(msg)
|
||||
# Also handle HISTORY_DATA tag in hermes messages
|
||||
hm = re.search(r'\[HISTORY_DATA:(\S+?):(\d+)\]', msg)
|
||||
if hm:
|
||||
target_wxid, count = hm.group(1), int(hm.group(2))
|
||||
history_text = handle_history(target_wxid, count)
|
||||
if history_text:
|
||||
threading.Thread(target=lambda: inject_to_hermes_session(history_text), daemon=True).start()
|
||||
log(f"HISTORY_DATA: injected for {target_wxid} ({count} msgs)")
|
||||
else:
|
||||
log(f"HISTORY_DATA: no messages for {target_wxid}")
|
||||
self.send_response(200); self.end_headers(); return
|
||||
to = d.get("to", "") or d.get("wxid", "")
|
||||
msg = d.get("message", "") or d.get("content", "") or str(d)[:200]
|
||||
if to and msg:
|
||||
# Has to field → direct WeChat forward (no LLM)
|
||||
log(f"REPLY {to}: {msg[:50]}")
|
||||
send_wx(to, msg)
|
||||
elif msg:
|
||||
# No to field → LLM processing (queue_attach handles reply + notification)
|
||||
queue_attach(msg)
|
||||
except Exception as e:
|
||||
log(f"RH ERR: {e}")
|
||||
self.send_response(200); self.end_headers()
|
||||
def do_GET(self):
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path == "/health":
|
||||
# Dashboard monitoring endpoint
|
||||
import urllib.request as _ur
|
||||
hermes_ok = False
|
||||
try:
|
||||
req = _ur.Request("http://192.168.1.246:8642/v1/models", headers={"Authorization": "Bearer hermes123"})
|
||||
_ur.urlopen(req, timeout=3)
|
||||
hermes_ok = True
|
||||
except Exception:
|
||||
pass
|
||||
self._send_json({
|
||||
"ok": True, "hermes_connected": hermes_ok,
|
||||
})
|
||||
return
|
||||
if parsed.path in ("/history", "/api/chatHistory"):
|
||||
params = parse_qs(parsed.query)
|
||||
wxid = params.get("wxid", [""])[0]
|
||||
count = params.get("count", ["10"])[0]
|
||||
result = handle_history_json(wxid, count)
|
||||
log(f"HTTP GET {parsed.path} wxid={wxid} count={count}")
|
||||
self._send_json(result)
|
||||
return
|
||||
self.send_response(200); self.end_headers(); self.wfile.write(b'{"ok":true}')
|
||||
def _send_json(self, data):
|
||||
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
def log_message(self, *a): pass
|
||||
|
||||
threading.Thread(target=lambda: HTTPServer(("0.0.0.0", 5801), RH).serve_forever(), daemon=True).start()
|
||||
log("HTTP :5801")
|
||||
|
||||
# Notify user
|
||||
send_wx("filehelper", "[Agent v2] wxhelper online")
|
||||
log("Ready")
|
||||
print(f"[Agent v2] wxhelper :19088 | Hermes :8642")
|
||||
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
log("Bye")
|
||||
|
||||
Reference in New Issue
Block a user