c6795bcb46
WeChat XML uses & to encode & in forwarded article URLs. Without html.unescape(), chksm and other query params were passed encoded to WeChat servers, causing signature mismatch and captcha block. Ultraworked with Sisyphus Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1062 lines
45 KiB
Python
1062 lines
45 KiB
Python
"""
|
||
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"
|
||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
PROJECT_ROOT = os.path.dirname(SCRIPT_DIR)
|
||
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 = os.path.join(SCRIPT_DIR, "..", "tools", "Injector_x64.exe")
|
||
WXHELPER_DLL = os.path.join(SCRIPT_DIR, "..", "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)
|
||
# DEBUG: capture Type 49 full XML for URL analysis
|
||
if msg_type == 49:
|
||
try:
|
||
with open(LOG_DIR + "/t49_xml.txt", "a", encoding="utf-8") as _f:
|
||
_f.write(f"\n=== {time.time()} type=49 from={fu} ===\n{ct[:10000]}\n")
|
||
except: pass
|
||
if "@chatroom" in fu:
|
||
log(f"GROUP RAW DUMP: keys={list(d.keys())} ct_len={len(ct)} ct[:100]={ct[:100]}")
|
||
# DEBUG: capture full raw data for quote analysis
|
||
try:
|
||
with open(LOG_DIR + "/group_raw.jsonl", "a", encoding="utf-8") as _f:
|
||
_f.write(json.dumps({k: str(v)[:2000] for k, v in d.items()}, ensure_ascii=False) + "\n")
|
||
except: pass
|
||
# DEBUG: capture all raw msgs for field analysis
|
||
try:
|
||
with open(LOG_DIR + "/all_raw.jsonl", "a", encoding="utf-8") as _f:
|
||
_f.write(json.dumps({k: str(v)[:500] for k, v in d.items()}, ensure_ascii=False) + "\n")
|
||
except: pass
|
||
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
|
||
# Type 49 (forwarded article) - extract URL and process via article_processor
|
||
if msg_type == 49 and ct.strip().startswith("<?xml"):
|
||
try:
|
||
import re
|
||
# Try <url> first, then <shareUrlOriginal>, then <shareUrlOpen>
|
||
urls = re.findall(r'<url>(https?://mp\.weixin\.qq\.com[^<]+)</url>', ct)
|
||
if not urls:
|
||
urls = re.findall(r'<shareUrlOriginal>(https?://mp\.weixin\.qq\.com[^<]+)</shareUrlOriginal>', ct)
|
||
if not urls:
|
||
urls = re.findall(r'<shareUrlOpen>(https?://mp\.weixin\.qq\.com[^<]+)</shareUrlOpen>', ct)
|
||
url = urls[0] if urls else None
|
||
# Decode HTML entities (& → &) — WeChat XML uses & in URLs
|
||
if url:
|
||
import html as _html
|
||
url = _html.unescape(url)
|
||
# Extract title from XML
|
||
titles = re.findall(r'<title>(.*?)</title>', ct)
|
||
title = _html.unescape(titles[0]) if titles else ""
|
||
# Extract description
|
||
descs = re.findall(r'<des>(.*?)</des>', ct)
|
||
desc = _html.unescape(descs[0]) if descs else ""
|
||
|
||
if url:
|
||
log(f"ARTICLE URL: {url}")
|
||
# Call article_processor on localhost
|
||
import urllib.request as ur
|
||
req = ur.Request("http://127.0.0.1:5810/process",
|
||
data=json.dumps({"url": url}).encode("utf-8"),
|
||
headers={"Content-Type": "application/json"})
|
||
with ur.urlopen(req, timeout=180) as resp:
|
||
result = json.loads(resp.read().decode("utf-8"))
|
||
if result.get("status") == "ok":
|
||
content = result.get("content", "")[:3000]
|
||
title = result.get("title", "")
|
||
images = result.get("images_ocr", 0)
|
||
enriched = f"[老莫转发了一篇文章{(chr(10)+'标题: '+title) if title else ''},{images}张图片已OCR]\n\n{content}"
|
||
log(f"ARTICLE processed: {len(content)} chars")
|
||
reply = call_hermes(fu, enriched)
|
||
if reply and reply.strip():
|
||
log(f"-> {fu}: {reply[:50]}")
|
||
send_wx(fu, reply.strip())
|
||
return
|
||
else:
|
||
log(f"ARTICLE process failed: {result.get('error','')[:100]}")
|
||
# Fallback: send title + description
|
||
fallback = f"[老莫转发了一篇文章]{(chr(10)+'标题: '+title) if title else ''}{(chr(10)+'摘要: '+desc[:200]) if desc else ''}\n(全文抓取失败: {result.get('error','')[:60]})"
|
||
reply = call_hermes(fu, fallback)
|
||
if reply and reply.strip():
|
||
send_wx(fu, reply.strip())
|
||
return
|
||
else:
|
||
# No URL found, send title + description
|
||
if title:
|
||
log(f"ARTICLE: no URL, sending title+desc")
|
||
fallback = f"[老莫转发了一篇文章]{(chr(10)+'标题: '+title) if title else ''}{(chr(10)+'摘要: '+desc[:200]) if desc else ''}"
|
||
reply = call_hermes(fu, fallback)
|
||
if reply and reply.strip():
|
||
send_wx(fu, reply.strip())
|
||
return
|
||
except Exception as e:
|
||
log(f"ARTICLE handler error: {e}")
|
||
# Fall through to text handler
|
||
# 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")
|
||
|