v2: project cleanup, Desktop paths fixed, README updated, serve daemon added, mohe-xxm protocol documented
This commit is contained in:
@@ -1,75 +0,0 @@
|
||||
"""
|
||||
[DEPRECATED] 早期架构组件,已由 wechat_agent.py 直调 Hermes API 替代。
|
||||
不再使用,仅作为架构参考保留。
|
||||
"""
|
||||
import pymem, pymem.process, requests, time, json, threading
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
|
||||
DLL = r"C:\Users\hmo\AppData\Local\Programs\Python\Python310\Lib\site-packages\wxhook\tools\wxhook.dll"
|
||||
WX = "http://127.0.0.1:19088"
|
||||
HERMES = "http://192.168.0.103:5800/callback"
|
||||
LOG = r"C:\Users\hmo\Desktop\bridge.log"
|
||||
BOT = "wxid_7onnerpx2s2l22"
|
||||
PORT = 5801
|
||||
|
||||
def log(m):
|
||||
with open(LOG, "a", encoding="utf-8") as f:
|
||||
f.write(f"{time.strftime('%H:%M:%S')} {m}\n")
|
||||
|
||||
# Inject DLL
|
||||
try:
|
||||
requests.post(f"{WX}/api/checkLogin", json={}, timeout=3)
|
||||
log("DLL OK")
|
||||
except:
|
||||
pm = pymem.Pymem("WeChat.exe")
|
||||
pymem.process.inject_dll(pm.process_handle, DLL.encode())
|
||||
time.sleep(2)
|
||||
log("DLL injected")
|
||||
|
||||
log(f"login: {requests.post(f'{WX}/api/checkLogin', json={}, timeout=5).json()}")
|
||||
|
||||
# Periodic webhook refresh (every 30 seconds)
|
||||
def webhook_keepalive():
|
||||
while True:
|
||||
try:
|
||||
requests.post(f"{WX}/api/hookSyncMsg", json={
|
||||
"port": PORT, "ip": "0.0.0.0", "enableHttp": 1,
|
||||
"url": f"http://127.0.0.1:{PORT}", "timeout": 300
|
||||
}, timeout=5)
|
||||
except:
|
||||
pass
|
||||
time.sleep(30)
|
||||
|
||||
threading.Thread(target=webhook_keepalive, daemon=True).start()
|
||||
log("keepalive started (30s)")
|
||||
|
||||
# Test message
|
||||
requests.post(f"{WX}/api/sendTextMsg",
|
||||
json={"wxid": "filehelper", "msg": "[Bridge] online"}, timeout=5)
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
|
||||
try:
|
||||
d = json.loads(body)
|
||||
fu = d.get("fromUser", "")
|
||||
ct = d.get("content", "")
|
||||
if fu and ct and fu != BOT:
|
||||
log(f"MSG {fu}: {ct[:60]}")
|
||||
threading.Thread(target=lambda: requests.post(HERMES, json=[{"id": int(time.time()*1000), "type": 1, "content": ct, "sender": fu, "roomid": "", "ts": time.time()}], timeout=30), daemon=True).start()
|
||||
self.send_response(200); self.end_headers(); return
|
||||
to = d.get("to", "") or d.get("wxid", "")
|
||||
msg = d.get("message", "") or d.get("content", "")
|
||||
if to and msg:
|
||||
r = requests.post(f"{WX}/api/sendTextMsg", json={"wxid": to, "msg": msg}, timeout=5)
|
||||
log(f"SEND {to}: {r.get('msg','')}")
|
||||
self.send_response(200); self.end_headers(); return
|
||||
except Exception as e:
|
||||
log(f"ERR: {str(e)[:80]}")
|
||||
self.send_response(200); self.end_headers()
|
||||
def do_GET(self):
|
||||
self.send_response(200); self.end_headers(); self.wfile.write(b'{"ok":true}')
|
||||
def log_message(self, *a): pass
|
||||
|
||||
log(f"ready on :{PORT}")
|
||||
HTTPServer(("0.0.0.0", PORT), H).serve_forever()
|
||||
@@ -0,0 +1,118 @@
|
||||
# Mohe & XiaoXiaoMo Chat Viewer
|
||||
# Lists [mohe]/[xxm] messages from an OpenCode session.
|
||||
#
|
||||
# Usage:
|
||||
# .\moho_chat.ps1 <session_id> [minutes]
|
||||
#
|
||||
# Examples:
|
||||
# .\moho_chat.ps1 ses_1d95d15c4ffehQaZ6hrbIbak5k
|
||||
# .\moho_chat.ps1 ses_1d95d15c4ffehQaZ6hrbIbak5k 30
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$SessionId,
|
||||
[int]$Minutes = 0
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
Write-Host "Exporting session..." -ForegroundColor DarkGray
|
||||
|
||||
$tmpFile = [System.IO.Path]::GetTempFileName() + ".json"
|
||||
$env:CI = 'true'
|
||||
opencode.cmd export $SessionId 2>$null | Set-Content -Path $tmpFile -NoNewline -Encoding UTF8
|
||||
|
||||
if (-not (Test-Path $tmpFile) -or (Get-Item $tmpFile).Length -eq 0) {
|
||||
Write-Error "Export failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$size = (Get-Item $tmpFile).Length
|
||||
Write-Host "Read ($($size / 1MB -as [int]) MB)" -ForegroundColor DarkGray
|
||||
|
||||
$raw = Get-Content $tmpFile -Raw -Encoding UTF8
|
||||
Remove-Item $tmpFile -Force
|
||||
|
||||
# Check if it's actually UTF-16LE in disguise (opencode export uses UTF-16LE)
|
||||
if ($raw.Length -eq 0 -or $raw[0] -ne '{') {
|
||||
# Re-read as UTF-16
|
||||
$bytes = [System.IO.File]::ReadAllBytes($tmpFile)
|
||||
Remove-Item $tmpFile -Force
|
||||
$raw = [System.Text.Encoding]::Unicode.GetString($bytes)
|
||||
}
|
||||
|
||||
$brace = $raw.IndexOf('{')
|
||||
if ($brace -gt 0) { $raw = $raw.Substring($brace) }
|
||||
|
||||
# Use regex to find "text": "[mohe]..." patterns directly (no JSON parsing)
|
||||
$pattern = '"text":\s*"\[(mohe|xxm)\][^"]*"'
|
||||
|
||||
$cutoff = if ($Minutes -gt 0) { (Get-Date).ToUniversalTime().AddMinutes(-$Minutes) } else { $null }
|
||||
|
||||
$results = @()
|
||||
$matchPos = 0
|
||||
|
||||
while ($true) {
|
||||
$m = [regex]::Match($raw, $pattern, $matchPos)
|
||||
if (-not $m.Success) { break }
|
||||
$matchPos = $m.Index + 1
|
||||
|
||||
$tag = $m.Groups[1].Value
|
||||
$content = $m.Value
|
||||
|
||||
# Extract the full text content (everything between "text": " and the closing ")
|
||||
$start = $m.Index + $m.Value.IndexOf('"', $m.Value.IndexOf(':')+1) + 1
|
||||
$fullText = ''
|
||||
$escape = $false
|
||||
for ($i = $start; $i -lt $raw.Length; $i++) {
|
||||
$c = $raw[$i]
|
||||
if ($escape) { $fullText += $c; $escape = $false }
|
||||
elseif ($c -eq '\') { $fullText += $c; $escape = $true }
|
||||
elseif ($c -eq '"') { break }
|
||||
else { $fullText += $c }
|
||||
}
|
||||
|
||||
$fullText = $fullText.Trim()
|
||||
if (-not ($fullText.StartsWith('[mohe]') -or $fullText.StartsWith('[xxm]'))) { continue }
|
||||
|
||||
$sender = if ($fullText.StartsWith('[mohe]')) { '[mohe] Mohe' } else { '[xxm] XiaoXiaoMo' }
|
||||
$display = $fullText -replace '^\[\w+\]\s*', ''
|
||||
|
||||
# Get timestamp by looking backward
|
||||
$before = $raw.Substring([Math]::Max(0, $m.Index - 3000), [Math]::Min(3000, $m.Index))
|
||||
$tsMatch = [regex]::Match($before, '"timestamp":\s*"([^"]+)"')
|
||||
$tsStr = if ($tsMatch.Success) { $tsMatch.Groups[1].Value } else { '' }
|
||||
|
||||
if (-not $tsStr) {
|
||||
$crMatch = [regex]::Match($before, '"created":\s*(\d+)')
|
||||
if ($crMatch.Success) {
|
||||
$tsStr = ([DateTimeOffset]::FromUnixTimeMilliseconds([long]$crMatch.Groups[1].Value).UtcDateTime).ToString('o')
|
||||
}
|
||||
}
|
||||
|
||||
$ts = $null
|
||||
if ($tsStr) {
|
||||
try { $ts = [DateTime]::Parse($tsStr, $null, [System.Globalization.DateTimeStyles]::AssumeUniversal) } catch {}
|
||||
}
|
||||
|
||||
if ($cutoff -and $ts -and $ts -lt $cutoff) { continue }
|
||||
|
||||
$timeLocal = if ($ts) { $ts.ToLocalTime().ToString('HH:mm:ss') } else { '??' }
|
||||
|
||||
$results += [PSCustomObject]@{ Time=$timeLocal; Sort=if($ts){$ts.Ticks}else{0}; Sender=$sender; Message=$display }
|
||||
}
|
||||
|
||||
$results = $results | Sort-Object Sort
|
||||
|
||||
if ($results.Count -eq 0) {
|
||||
Write-Host "No [mohe]/[xxm] messages found" -ForegroundColor Yellow
|
||||
if ($Minutes -gt 0) { Write-Host "(last $Minutes minutes)" }
|
||||
exit
|
||||
}
|
||||
|
||||
Write-Host "`n$($results.Count) message(s)" -ForegroundColor Cyan
|
||||
if ($Minutes -gt 0) { Write-Host "(last $Minutes min)" }
|
||||
Write-Host ("=" * 60)
|
||||
|
||||
foreach ($r in $results) {
|
||||
Write-Host ("[{0}] {1}: {2}" -f $r.Time, $r.Sender, $r.Message)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Mohe & XiaoXiaoMo Chat Viewer
|
||||
Fixes one corrupted byte in session export, then parses cleanly.
|
||||
|
||||
Usage:
|
||||
python moho_chat.py <session_id> [minutes]
|
||||
|
||||
Examples:
|
||||
python moho_chat.py ses_1d95d15c4ffehQaZ6hrbIbak5k
|
||||
python moho_chat.py ses_1d95d15c4ffehQaZ6hrbIbak5k 30
|
||||
"""
|
||||
|
||||
import subprocess, sys, os, tempfile, re, json
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
def export(session_id):
|
||||
"""Export session via cmd.exe (preserves UTF-16LE), return path."""
|
||||
tmp = tempfile.NamedTemporaryFile(suffix='.json', delete=False)
|
||||
tmp.close()
|
||||
r = subprocess.run(
|
||||
f'opencode.cmd export {session_id} > "{tmp.name}" 2>nul',
|
||||
shell=True, timeout=180
|
||||
)
|
||||
if r.returncode != 0 or os.path.getsize(tmp.name) == 0:
|
||||
raise ValueError('Export failed')
|
||||
return tmp.name
|
||||
|
||||
def load(filepath):
|
||||
"""Read UTF-16LE, fix corruption, parse JSON, return messages."""
|
||||
with open(filepath, 'rb') as f:
|
||||
raw = f.read()
|
||||
|
||||
# Decode UTF-16LE
|
||||
if raw[:2] == b'\xff\xfe':
|
||||
text = raw.decode('utf-16-le', errors='replace')
|
||||
else:
|
||||
text = raw.decode('utf-8', errors='replace')
|
||||
|
||||
# Strip to first {
|
||||
brace = text.find('{')
|
||||
if brace > 0:
|
||||
text = text[brace:]
|
||||
|
||||
# FIX THE CORRUPTION:
|
||||
# The first message's text field is missing its closing quote.
|
||||
# Pattern: corrupt_char, comma, newline, then a field name
|
||||
# Insert missing closing quote before the comma
|
||||
|
||||
# Specific fix: after corrupted chars that precede a structural comma
|
||||
fixed = re.sub(
|
||||
r'(\uFFFD)\s*,\s*\n\s*"(id|role|parts|info|timestamp|type|text)"',
|
||||
r'?",\n "\2"',
|
||||
text
|
||||
)
|
||||
|
||||
# Also try without \uFFFD (in case it was decoded differently)
|
||||
fixed = re.sub(
|
||||
r'(\?)\s*,\s*\n\s*"(id|role|parts|info|timestamp|type|text)"',
|
||||
r'?",\n "\2"',
|
||||
fixed
|
||||
)
|
||||
|
||||
return json.loads(fixed).get('messages', [])
|
||||
|
||||
def msg_text(msg):
|
||||
parts = msg.get('parts', [])
|
||||
text = ''
|
||||
for p in parts:
|
||||
if isinstance(p, dict) and p.get('type') == 'text':
|
||||
text += p.get('text', '')
|
||||
return text.strip()
|
||||
|
||||
def msg_ts(msg):
|
||||
info = msg.get('info', {})
|
||||
ts = info.get('timestamp', '') or ''
|
||||
if not ts:
|
||||
t = info.get('time', {})
|
||||
if isinstance(t, dict) and t.get('created'):
|
||||
ts = datetime.fromtimestamp(t['created']/1000, tz=timezone.utc).isoformat()
|
||||
return ts
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
session_id = sys.argv[1]
|
||||
minutes = int(sys.argv[2]) if len(sys.argv) > 2 else None
|
||||
|
||||
print('Exporting...', file=sys.stderr)
|
||||
tmp = export(session_id)
|
||||
|
||||
try:
|
||||
messages = load(tmp)
|
||||
finally:
|
||||
os.unlink(tmp)
|
||||
|
||||
print(f'{len(messages)} messages', file=sys.stderr)
|
||||
|
||||
# Search for 测试新协议 (Moho's test message keyword)
|
||||
found_moho = 0
|
||||
for m in messages:
|
||||
t = msg_text(m)
|
||||
if '测试新协议' in t or '写入Session' in t or '23 × 17' in t:
|
||||
found_moho += 1
|
||||
if found_moho <= 5:
|
||||
print(f' [Moho #{found_moho}] {t[:200]!r}', file=sys.stderr)
|
||||
print(f' Moho explicit messages: {found_moho}', file=sys.stderr)
|
||||
|
||||
cutoff = None
|
||||
if minutes:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=minutes)
|
||||
|
||||
results = []
|
||||
|
||||
for m in messages:
|
||||
text = msg_text(m)
|
||||
if not (text.startswith('[mohe]') or text.startswith('[xxm]')):
|
||||
continue
|
||||
|
||||
sender = '[mohe] Mohe' if text.startswith('[mohe]') else '[xxm] XiaoXiaoMo'
|
||||
display = re.sub(r'^\[\w+\]\s*', '', text, count=1).strip()
|
||||
ts_str = msg_ts(m)
|
||||
|
||||
ts_dt = None
|
||||
if ts_str:
|
||||
try: ts_dt = datetime.fromisoformat(ts_str.replace('Z', '+00:00'))
|
||||
except: pass
|
||||
if cutoff and ts_dt and ts_dt < cutoff:
|
||||
continue
|
||||
|
||||
results.append((ts_dt or datetime.min, ts_str, sender, display))
|
||||
|
||||
results.sort(key=lambda x: x[0])
|
||||
|
||||
if not results:
|
||||
print('No [mohe]/[xxm] messages found' + (f' in last {minutes} min' if minutes else ''))
|
||||
return
|
||||
|
||||
print(f'\n{len(results)} message(s)' + (f' (last {minutes} min)' if minutes else ''))
|
||||
print('=' * 60)
|
||||
for ts_dt, ts_str, sender, display in results:
|
||||
t = '??'
|
||||
if ts_str:
|
||||
try: t = datetime.fromisoformat(ts_str.replace('Z', '+00:00')).strftime('%H:%M:%S')
|
||||
except: t = str(ts_str)[:19]
|
||||
print(f'[{t}] {sender}: {display}')
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,34 +0,0 @@
|
||||
@echo off
|
||||
title WeChat Hermes Bridge
|
||||
cd /d "%~dp0.."
|
||||
|
||||
set PYTHON=C:\Users\hmo\AppData\Local\Programs\Python\Python310\python.exe
|
||||
set AGENT=scripts\wechat_agent.py
|
||||
|
||||
echo ========================================
|
||||
echo WeChat Hermes Bridge
|
||||
echo ========================================
|
||||
echo.
|
||||
echo [1/3] 启动微信 + 注入 DLL...
|
||||
echo.
|
||||
set PYTHONHOME=
|
||||
set WXHOOK_LOG_LEVEL=ERROR
|
||||
|
||||
%PYTHON% %AGENT%
|
||||
|
||||
echo.
|
||||
echo ========================================
|
||||
echo 微信已启动!
|
||||
echo.
|
||||
echo 第 2 步:运行修复过低工具 ^> 扫码登录
|
||||
echo 路径:D:\Program Files (x86)\低版本修复工具\
|
||||
echo 低版本修复工具\修复过低6.0\低版通用杀器.sp.exe
|
||||
echo.
|
||||
echo 第 3 步:手机扫码 → 自动开始转发
|
||||
echo.
|
||||
echo 按 Ctrl+C 停止桥接
|
||||
echo ========================================
|
||||
|
||||
if %errorlevel% neq 0 (
|
||||
pause
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
@echo off
|
||||
title WeChat History API
|
||||
cd /d "%~dp0.."
|
||||
|
||||
set PYTHON=python
|
||||
|
||||
echo ========================================
|
||||
echo WeChat History REST API Server
|
||||
echo Port: 19001
|
||||
echo ========================================
|
||||
echo.
|
||||
echo Starting history API server...
|
||||
echo Endpoints:
|
||||
echo GET http://localhost:19001/
|
||||
echo GET http://localhost:19001/health
|
||||
echo GET http://localhost:19001/api/contacts
|
||||
echo GET http://localhost:19001/api/recent
|
||||
echo GET http://localhost:19001/api/history?wxid=wxid_xxx^&count=20
|
||||
echo POST http://localhost:19001/api/history
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
set PYTHONHOME=
|
||||
%PYTHON% api\history_api.py --port 19001
|
||||
|
||||
if %errorlevel% neq 0 (
|
||||
pause
|
||||
)
|
||||
+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