babbc46801
- Merge bot_base.py, gateway/scripts/xmpp_bot.py, bots/*, xmpp_bot_rest.py into single xmpp_agent_core.py with --agent flag (xxm|mohe|zhiwei|xiaoguo) - Add xxm_bot.py wrapper (encoding=utf-8 for Windows exec) - Fix slixmpp connect() API: use host=/port= keyword args (was tuple) - Clean up orphans: bots/, scripts/, hermes_state.py, xmpp_bot.py, xmpp_bot_rest.py - Add docs/CLEANUP_PLAN.md documenting the migration - Update README.md project structure - Also: fix WeChat agent path resolution (relative paths)
393 lines
13 KiB
Python
393 lines
13 KiB
Python
"""
|
|
WeChat History REST API Server
|
|
Starts on port 19001. Queries WeChat chat history via wxhelper DLL (http://127.0.0.1:19088).
|
|
|
|
Usage:
|
|
python history_api.py # start on 0.0.0.0:19001
|
|
python history_api.py --port 19001 # explicit port
|
|
|
|
Endpoints:
|
|
GET / → API info
|
|
GET /health → health check
|
|
GET /api/contacts → list WeChat contacts (wxid + nickname)
|
|
GET /api/history?wxid=wxid_xxx&count=20 → query chat history
|
|
POST /api/history → same via JSON body {"wxid":"...","count":20}
|
|
|
|
Requires: wxhelper DLL injected and WeChat running (wechat_agent.py handles this).
|
|
"""
|
|
|
|
import os
|
|
import json
|
|
import time
|
|
import urllib.request
|
|
import urllib.error
|
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
from urllib.parse import urlparse, parse_qs
|
|
from datetime import datetime
|
|
|
|
os.environ["no_proxy"] = "*"
|
|
os.environ["NO_PROXY"] = "*"
|
|
|
|
# ── Configuration ──
|
|
WX_API = "http://127.0.0.1:19088"
|
|
DEFAULT_PORT = 19001
|
|
BOT_WXID = "wxid_7onnerpx2s2l22"
|
|
HOST = "0.0.0.0"
|
|
|
|
# ── Cached state ──
|
|
nickname_cache = {}
|
|
db_handle_cache = None
|
|
|
|
# ── wxhelper API helpers ──
|
|
|
|
def wxpost(path, data=None, timeout=10):
|
|
"""Call wxhelper HTTP API."""
|
|
try:
|
|
body = json.dumps(data or {}).encode()
|
|
req = urllib.request.Request(
|
|
WX_API + path,
|
|
data=body,
|
|
headers={"Content-Type": "application/json"}
|
|
)
|
|
r = urllib.request.urlopen(req, 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:
|
|
return {"code": -1, "error": str(e)[:200]}
|
|
|
|
|
|
def get_db_handle():
|
|
"""Get handle for MSG*.db 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 []
|
|
for db in dbs:
|
|
dbname = db.get("databaseName", "")
|
|
if dbname.startswith("MSG") and "Media" not in dbname:
|
|
db_handle_cache = db.get("handle")
|
|
return db_handle_cache
|
|
return None
|
|
|
|
|
|
def get_nickname(wxid):
|
|
"""Get contact nickname from wxid, with caching."""
|
|
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 query_history(wxid, limit=10):
|
|
"""Query historical text messages from MSG table for a contact.
|
|
Returns list of dicts: [{CreateTime, IsSender, Type, content}, ...]"""
|
|
h = get_db_handle()
|
|
if not h:
|
|
return None
|
|
limit_val = min(int(limit), 200)
|
|
sql = (
|
|
f"SELECT CreateTime, IsSender, Type, SubType, StrContent, DisplayContent, CompressContent, BytesExtra "
|
|
f"FROM MSG WHERE StrTalker='{wxid}' AND Type IN ('1','49') "
|
|
f"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
|
|
# wxhelper returns [{value: [cols]}, {value: [row1]}, ...]
|
|
rows = [item.get("value", item) if isinstance(item, dict) else item for item in data]
|
|
rows = rows[1:] # skip header
|
|
rows.reverse()
|
|
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()
|
|
# Type 49 (article link): extract URL from CompressContent or BytesExtra
|
|
if not content and str(row[2]) == "49":
|
|
try:
|
|
import re
|
|
# Try BytesExtra first (row[7])
|
|
for idx in [7, 6]:
|
|
if idx < len(row) and row[idx]:
|
|
text = str(row[idx])
|
|
urls = re.findall(r'https?://[^\s\x00-\x1f<>\"\']{10,}', text)
|
|
if urls:
|
|
content = urls[0]
|
|
break
|
|
except:
|
|
pass
|
|
if not content:
|
|
if str(row[2]) == "49":
|
|
content = "[文章链接]"
|
|
else:
|
|
continue
|
|
results.append({
|
|
"CreateTime": row[0],
|
|
"IsSender": row[1],
|
|
"Type": row[2],
|
|
"content": content
|
|
})
|
|
return results
|
|
|
|
|
|
def format_history_json(wxid, rows):
|
|
"""Format raw MSG rows into JSON-serializable dict for API response."""
|
|
sender_name = get_nickname(wxid)
|
|
bot_name = get_nickname(BOT_WXID)
|
|
if not rows:
|
|
return {
|
|
"ok": True, "wxid": wxid, "sender_name": sender_name,
|
|
"count": 0, "messages": []
|
|
}
|
|
messages = []
|
|
for row in rows:
|
|
ts = int(row.get("CreateTime", 0))
|
|
time_str = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") 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,
|
|
"type_name": {1: "text", 49: "link"}.get(msg_type, f"type_{msg_type}"),
|
|
"content": content[:500]
|
|
})
|
|
return {
|
|
"ok": True,
|
|
"wxid": wxid,
|
|
"sender_name": sender_name,
|
|
"count": len(messages),
|
|
"messages": messages
|
|
}
|
|
|
|
|
|
def get_contacts():
|
|
"""Get all contacts from WeChat."""
|
|
r = wxpost("/api/getContactList", timeout=10)
|
|
contacts = r.get("data") or []
|
|
# Update cache
|
|
for c in contacts:
|
|
wxid = c.get("wxid", "")
|
|
nick = c.get("nickname") or c.get("customAccount") or wxid
|
|
nickname_cache[wxid] = nick
|
|
return [
|
|
{
|
|
"wxid": c.get("wxid", ""),
|
|
"nickname": c.get("nickname", ""),
|
|
"remark": c.get("remark", ""),
|
|
"customAccount": c.get("customAccount", ""),
|
|
}
|
|
for c in contacts
|
|
]
|
|
|
|
|
|
def get_recent_chats(limit=20):
|
|
"""Get list of contacts with recent messages."""
|
|
h = get_db_handle()
|
|
if not h:
|
|
return []
|
|
sql = (
|
|
f"SELECT DISTINCT StrTalker FROM MSG WHERE Type IN ('1','49') "
|
|
f"LIMIT {min(limit, 50)}"
|
|
)
|
|
r = wxpost("/api/execSql", {"dbHandle": h, "sql": sql}, timeout=15)
|
|
data = r.get("data") or []
|
|
if not data or len(data) < 2:
|
|
return []
|
|
rows = [item.get("value", item) if isinstance(item, dict) else item for item in data]
|
|
results = []
|
|
for row in rows[1:]:
|
|
wxid = (row[0] or "").strip()
|
|
if not wxid or wxid in ("fmessage", "weixin", "wechat", "filehelper", "medianote", "floatbottle", "qmessage"):
|
|
continue
|
|
results.append({
|
|
"wxid": wxid,
|
|
"nickname": get_nickname(wxid),
|
|
"last_message_time": None,
|
|
"last_message_ts": 0,
|
|
"message_count": 0,
|
|
})
|
|
return results
|
|
|
|
|
|
# ── HTTP Request Handler ──
|
|
|
|
class HistoryAPIHandler(BaseHTTPRequestHandler):
|
|
|
|
def _send_json(self, data, status=200):
|
|
"""Send JSON response with proper headers."""
|
|
body = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8")
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.send_header("Access-Control-Allow-Origin", "*")
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _send_error_json(self, msg, status=400):
|
|
self._send_json({"ok": False, "error": msg}, status=status)
|
|
|
|
def _read_json_body(self):
|
|
"""Read and parse JSON request body."""
|
|
length = int(self.headers.get("Content-Length", 0))
|
|
if not length:
|
|
return {}
|
|
try:
|
|
body = self.rfile.read(length)
|
|
return json.loads(body)
|
|
except json.JSONDecodeError:
|
|
return {}
|
|
|
|
def do_GET(self):
|
|
parsed = urlparse(self.path)
|
|
path = parsed.path.rstrip("/") or "/"
|
|
params = parse_qs(parsed.query)
|
|
|
|
# ── Root / Health ──
|
|
if path in ("/", ""):
|
|
self._send_json({
|
|
"service": "WeChat History API",
|
|
"version": "1.0",
|
|
"port": DEFAULT_PORT,
|
|
"wxhelper": WX_API,
|
|
"endpoints": {
|
|
"GET /api/contacts": "List all WeChat contacts",
|
|
"GET /api/history": "Query chat history (params: wxid, count)",
|
|
"POST /api/history": "Same via JSON body",
|
|
"GET /api/recent": "Recent chats list",
|
|
"GET /health": "Health check",
|
|
}
|
|
})
|
|
return
|
|
|
|
if path == "/health":
|
|
self._send_json({
|
|
"status": "ok",
|
|
"timestamp": datetime.now().isoformat(),
|
|
"wxhelper": bool(get_db_handle())
|
|
})
|
|
return
|
|
|
|
# ── Contacts ──
|
|
if path == "/api/contacts":
|
|
contacts = get_contacts()
|
|
self._send_json({"ok": True, "count": len(contacts), "contacts": contacts})
|
|
return
|
|
|
|
# ── Recent Chats ──
|
|
if path == "/api/recent":
|
|
limit = int(params.get("limit", ["20"])[0])
|
|
chats = get_recent_chats(limit)
|
|
self._send_json({"ok": True, "count": len(chats), "chats": chats})
|
|
return
|
|
|
|
# ── History ──
|
|
if path == "/api/history":
|
|
wxid = params.get("wxid", [""])[0]
|
|
count = params.get("count", ["10"])[0]
|
|
if not wxid:
|
|
self._send_error_json("Missing required parameter: wxid")
|
|
return
|
|
try:
|
|
rows = query_history(wxid, count)
|
|
result = format_history_json(wxid, rows)
|
|
self._send_json(result)
|
|
except Exception as e:
|
|
self._send_error_json(str(e)[:200], status=500)
|
|
return
|
|
|
|
# ── 404 ──
|
|
self._send_error_json(f"Not found: {path}", status=404)
|
|
|
|
def do_POST(self):
|
|
parsed = urlparse(self.path)
|
|
path = parsed.path.rstrip("/") or "/"
|
|
body = self._read_json_body()
|
|
|
|
# ── History (POST) ──
|
|
if path in ("/api/history", "/history"):
|
|
wxid = (body.get("wxid", "") or "").strip()
|
|
count = body.get("count", 10) or body.get("limit", 10)
|
|
if not wxid:
|
|
self._send_error_json("Missing required field: wxid")
|
|
return
|
|
try:
|
|
rows = query_history(wxid, count)
|
|
result = format_history_json(wxid, rows)
|
|
self._send_json(result)
|
|
except Exception as e:
|
|
self._send_error_json(str(e)[:200], status=500)
|
|
return
|
|
|
|
# ── 404 ──
|
|
self._send_error_json(f"Not found: {path}", status=404)
|
|
|
|
def do_OPTIONS(self):
|
|
"""Handle CORS preflight."""
|
|
self.send_response(200)
|
|
self.send_header("Access-Control-Allow-Origin", "*")
|
|
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|
self.end_headers()
|
|
|
|
def log_message(self, *args):
|
|
# Suppress default access logs to stdout
|
|
pass
|
|
|
|
|
|
# ── Main ──
|
|
|
|
def main():
|
|
import sys
|
|
port = DEFAULT_PORT
|
|
if "--port" in sys.argv:
|
|
idx = sys.argv.index("--port")
|
|
if idx + 1 < len(sys.argv):
|
|
port = int(sys.argv[idx + 1])
|
|
|
|
# Check wxhelper connectivity
|
|
print(f"[History API] Checking wxhelper at {WX_API}...")
|
|
try:
|
|
r = wxpost("/api/checkLogin", timeout=5)
|
|
if r.get("code") == 1:
|
|
print("[History API] wxhelper ONLINE")
|
|
db_handle = get_db_handle()
|
|
print(f"[History API] DB handle: {db_handle or 'NOT FOUND'}")
|
|
else:
|
|
print(f"[History API] WARNING: wxhelper not logged in: {r}")
|
|
except Exception as e:
|
|
print(f"[History API] WARNING: Cannot reach wxhelper: {e}")
|
|
|
|
# Start server
|
|
server = HTTPServer((HOST, port), HistoryAPIHandler)
|
|
print(f"[History API] Listening on http://{HOST}:{port}")
|
|
print(f"[History API] Endpoints:")
|
|
print(f" GET http://localhost:{port}/")
|
|
print(f" GET http://localhost:{port}/health")
|
|
print(f" GET http://localhost:{port}/api/contacts")
|
|
print(f" GET http://localhost:{port}/api/recent")
|
|
print(f" GET http://localhost:{port}/api/history?wxid=wxid_xxx&count=20")
|
|
print(f" POST http://localhost:{port}/api/history")
|
|
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
print("\n[History API] Shutting down...")
|
|
server.shutdown()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|