Files
AgentsMeeting/gateway/scripts/wechat_agent.py
T
hmo c6795bcb46 fix: decode HTML entities in wechat article URL from XML
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>
2026-06-25 01:23:21 +08:00

1062 lines
45 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 (&amp; → &) — WeChat XML uses &amp; 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")