Files
AgentsMeeting/gateway/scripts/wechat_agent.py
T
hmo 1b2b935832 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
2026-06-12 21:51:36 +08:00

983 lines
40 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"
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")