v2: project cleanup, Desktop paths fixed, README updated, serve daemon added, mohe-xxm protocol documented
This commit is contained in:
+189
-8
@@ -5,22 +5,29 @@ import os, json, time, threading, requests, re, socketserver, subprocess, urllib
|
||||
os.environ["no_proxy"] = "*"
|
||||
os.environ["NO_PROXY"] = "*"
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
BOT_WXID = "wxid_7onnerpx2s2l22"
|
||||
BLOCK_WXIDS = {"fmessage", "weixin", "wechat"} # 系统账号/微信团队,不回复
|
||||
WX_API = "http://127.0.0.1:19088"
|
||||
LOG_FILE = r"C:\Users\hmo\Desktop\wechat_agent.log"
|
||||
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
|
||||
|
||||
HERMES_API = "http://192.168.0.103:8642/v1/chat/completions"
|
||||
HERMES_KEY = "hermes123"
|
||||
SENSENOVA_KEY = "sk-aRNj3UwKSLPsDfh15QNTPwbHxahblfaO"
|
||||
SENSENOVA_URL = "https://token.sensenova.cn/v1"
|
||||
|
||||
INJECTOR = r"C:\Users\hmo\Desktop\wxhelper_v11\wxhelper-3.9.5.81-v11\tool\injector\ConsoleApplication.exe"
|
||||
WXHELPER_DLL = r"C:\Users\hmo\Desktop\wxhelper_391019.dll"
|
||||
INJECTOR = r"D:\F\NewI\opencode\daily-workspace\projects\wechat-hermes-gateway\tools\ConsoleApplication.exe"
|
||||
WXHELPER_DLL = r"D:\F\NewI\opencode\daily-workspace\projects\wechat-hermes-gateway\tools\wxhelper_391019.dll"
|
||||
|
||||
def log(m):
|
||||
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
||||
@@ -37,6 +44,121 @@ def wxpost(path, data=None, timeout=10):
|
||||
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.
|
||||
# Older versions: messages in MicroMsg.db's MSG table
|
||||
for db in dbs:
|
||||
dbname = db.get("databaseName", "")
|
||||
if "MSG" in dbname or "Msg" in dbname:
|
||||
db_handle_cache = db.get("handle")
|
||||
log(f"History DB: {dbname} handle={db_handle_cache}")
|
||||
return db_handle_cache
|
||||
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
|
||||
@@ -69,6 +191,25 @@ def call_hermes(wxid, content):
|
||||
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, timeout=180, 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():
|
||||
try:
|
||||
@@ -179,6 +320,12 @@ def process_tags(reply, fu):
|
||||
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:
|
||||
@@ -191,7 +338,7 @@ def download_and_send_file(m, fu):
|
||||
url = m.group(1).strip()
|
||||
ir = requests.get(url, timeout=60, proxies={"http": None, "https": None})
|
||||
if ir.status_code == 200:
|
||||
tmp = os.path.join(r"C:\Users\hmo\Desktop", f"send_file_{int(time.time())}.dat")
|
||||
tmp = os.path.join(TEMP_DIR, f"send_file_{int(time.time())}.dat")
|
||||
with open(tmp, "wb") as f: f.write(ir.content)
|
||||
wxpost("/api/sendFileMsg", {"wxid": fu, "filePath": tmp})
|
||||
os.remove(tmp)
|
||||
@@ -216,7 +363,7 @@ def handle_img(m, fu):
|
||||
img_url = r.json()["data"][0]["url"]
|
||||
ir = requests.get(img_url, timeout=60)
|
||||
if ir.status_code == 200:
|
||||
tmp = os.path.join(r"C:\Users\hmo\Desktop", f"gen_img_{int(time.time())}.png")
|
||||
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)
|
||||
@@ -225,7 +372,7 @@ def handle_img(m, fu):
|
||||
if ir.status_code == 200:
|
||||
ext = ".jpg"
|
||||
if "png" in ir.headers.get("content-type", ""): ext = ".png"
|
||||
tmp = os.path.join(r"C:\Users\hmo\Desktop", f"send_img_{int(time.time())}{ext}")
|
||||
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)
|
||||
@@ -234,7 +381,7 @@ 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(r"C:\Users\hmo\Desktop", f"emoji_{int(time.time())}.png")
|
||||
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)
|
||||
@@ -293,11 +440,29 @@ class RH(BaseHTTPRequestHandler):
|
||||
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 == "/hermes-msg":
|
||||
msg = d.get("message", "") or d.get("content", "") or str(d)[:200]
|
||||
log("<<< HERMES: " + msg[:100])
|
||||
with open(r"C:\Users\hmo\Desktop\hermes_inbox.txt", "a", encoding="utf-8") as f:
|
||||
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")
|
||||
# HISTORY_DATA tag: query history and inject back to Hermes session
|
||||
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", "")
|
||||
@@ -308,7 +473,23 @@ class RH(BaseHTTPRequestHandler):
|
||||
log(f"RH ERR: {e}")
|
||||
self.send_response(200); self.end_headers()
|
||||
def do_GET(self):
|
||||
parsed = urlparse(self.path)
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user