v2.2.1 更新说明: - 新增 xposed-module(Telegram/微信/SQLite Hook),双 APK + LSPosed 作用域 - HookMessageReceiver 后台直接 DebugForwarder + goAsync,修复 notiMessage 退后台丢消息 - MessageLogStore 日志持久化;AppConfig 调试/上传开关;PC 调试台 debug-server - 健康检查去掉联网限制;通知/Hook 通道增加诊断日志 - 安装脚本 install-full/configure-lsposed/start-debug-server;文档 CHANGELOG + HOOK_GUIDE
312 lines
12 KiB
Python
312 lines
12 KiB
Python
#!/usr/bin/env python3
|
||
"""notiMessage 本地调试转发服务 — 在 PC 浏览器查看手机抓取的消息。"""
|
||
|
||
import json
|
||
import threading
|
||
from collections import deque
|
||
from datetime import datetime
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
from urllib.parse import urlparse
|
||
|
||
HOST = "0.0.0.0"
|
||
PORT = 8765
|
||
MAX_MESSAGES = 500
|
||
|
||
_messages = deque(maxlen=MAX_MESSAGES)
|
||
_lock = threading.Lock()
|
||
|
||
|
||
def _now_iso():
|
||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
|
||
|
||
def _resolve_group(payload):
|
||
group = (payload.get("group") or payload.get("title") or "").strip()
|
||
if group and "TLRPC$" not in group and "org.telegram.tgnet." not in group:
|
||
return group
|
||
app = payload.get("appName") or payload.get("packageName") or ""
|
||
return app + " / 未分类" if app else "未分类"
|
||
|
||
|
||
def _add_message(payload):
|
||
item = dict(payload)
|
||
item["group"] = _resolve_group(payload)
|
||
item["receivedAt"] = _now_iso()
|
||
with _lock:
|
||
_messages.appendleft(item)
|
||
item["id"] = len(_messages)
|
||
print("[{0}] [{1}] [{2}] {3} | {4}".format(
|
||
_now_iso(),
|
||
item["group"],
|
||
payload.get("source", "?"),
|
||
payload.get("appName", payload.get("packageName", "")),
|
||
(payload.get("content", "") or "")[:80],
|
||
))
|
||
return item
|
||
|
||
|
||
def _json_response(handler, status, data):
|
||
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
||
handler.send_response(status)
|
||
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
||
handler.send_header("Access-Control-Allow-Origin", "*")
|
||
handler.send_header("Content-Length", str(len(body)))
|
||
handler.end_headers()
|
||
handler.wfile.write(body)
|
||
|
||
|
||
def _group_messages(messages):
|
||
groups = {}
|
||
for msg in messages:
|
||
key = msg.get("group") or _resolve_group(msg)
|
||
if key not in groups:
|
||
groups[key] = {
|
||
"key": key,
|
||
"appName": msg.get("appName") or msg.get("packageName") or "",
|
||
"count": 0,
|
||
"latestAt": msg.get("receivedAt", ""),
|
||
"messages": [],
|
||
}
|
||
g = groups[key]
|
||
g["count"] += 1
|
||
g["messages"].append(msg)
|
||
if (msg.get("receivedAt") or "") > (g.get("latestAt") or ""):
|
||
g["latestAt"] = msg.get("receivedAt", "")
|
||
result = sorted(groups.values(), key=lambda x: x.get("latestAt", ""), reverse=True)
|
||
for g in result:
|
||
g["messages"].sort(key=lambda m: m.get("receivedAt", ""), reverse=True)
|
||
return result
|
||
|
||
|
||
def _html_page():
|
||
html = """<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>notiMessage 调试台</title>
|
||
<style>
|
||
* { box-sizing: border-box; }
|
||
body { font-family: ui-monospace, Consolas, monospace; margin: 0; background: #0f1115; color: #e6edf3; height: 100vh; display: flex; flex-direction: column; }
|
||
header { padding: 14px 20px; background: #161b22; border-bottom: 1px solid #30363d; display: flex; gap: 14px; align-items: center; flex-wrap: wrap; flex-shrink: 0; }
|
||
h1 { margin: 0; font-size: 18px; }
|
||
.stat { color: #8b949e; font-size: 13px; }
|
||
button { background: #238636; color: #fff; border: 0; padding: 8px 14px; border-radius: 6px; cursor: pointer; font-family: inherit; font-size: 13px; }
|
||
button.secondary { background: #21262d; border: 1px solid #30363d; }
|
||
.layout { display: flex; flex: 1; min-height: 0; }
|
||
.sidebar { width: 280px; border-right: 1px solid #30363d; background: #161b22; overflow-y: auto; flex-shrink: 0; }
|
||
.sidebar h2 { margin: 0; padding: 14px 16px 8px; font-size: 13px; color: #8b949e; font-weight: 600; }
|
||
.group-item { padding: 12px 16px; border-bottom: 1px solid #21262d; cursor: pointer; }
|
||
.group-item:hover { background: #1c2128; }
|
||
.group-item.active { background: #1f2937; border-left: 3px solid #58a6ff; padding-left: 13px; }
|
||
.group-name { color: #e6edf3; font-size: 13px; word-break: break-word; }
|
||
.group-meta { color: #8b949e; font-size: 11px; margin-top: 4px; }
|
||
.content-panel { flex: 1; overflow-y: auto; padding: 16px 20px; }
|
||
.panel-title { font-size: 16px; color: #ffa657; margin: 0 0 12px; word-break: break-word; }
|
||
.msg { padding: 12px 14px; margin-bottom: 10px; background: #161b22; border: 1px solid #21262d; border-radius: 8px; }
|
||
.msg-head { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 6px; font-size: 11px; color: #8b949e; }
|
||
.msg-source { color: #58a6ff; }
|
||
.msg-sender { color: #d2a8ff; }
|
||
.msg-body { color: #e6edf3; font-size: 13px; line-height: 1.5; word-break: break-word; white-space: pre-wrap; }
|
||
.empty { padding: 40px; text-align: center; color: #8b949e; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>notiMessage 调试台</h1>
|
||
<span class="stat" id="count">0 条</span>
|
||
<span class="stat" id="groupCount">0 群</span>
|
||
<span class="stat" id="updated">-</span>
|
||
<button onclick="loadMessages()">刷新</button>
|
||
<button class="secondary" onclick="clearMessages()">清空</button>
|
||
</header>
|
||
<div class="layout">
|
||
<aside class="sidebar">
|
||
<h2>群 / 会话</h2>
|
||
<div id="groups"></div>
|
||
</aside>
|
||
<main class="content-panel">
|
||
<h2 class="panel-title" id="panelTitle">请选择左侧群聊</h2>
|
||
<div id="messages"></div>
|
||
<div class="empty" id="empty" style="display:none">暂无消息,请在手机上开启监听并收一条 Telegram 消息</div>
|
||
</main>
|
||
</div>
|
||
<script>
|
||
let groups = [];
|
||
let activeGroup = null;
|
||
|
||
function groupKey(g) { return g.key; }
|
||
|
||
async function loadMessages() {
|
||
const res = await fetch('/api/groups');
|
||
groups = await res.json();
|
||
document.getElementById('count').textContent =
|
||
groups.reduce((n, g) => n + g.count, 0) + ' 条';
|
||
document.getElementById('groupCount').textContent = groups.length + ' 群';
|
||
document.getElementById('updated').textContent = '更新: ' + new Date().toLocaleTimeString();
|
||
const empty = document.getElementById('empty');
|
||
const groupsEl = document.getElementById('groups');
|
||
groupsEl.innerHTML = '';
|
||
|
||
if (!groups.length) {
|
||
empty.style.display = 'block';
|
||
document.getElementById('messages').innerHTML = '';
|
||
document.getElementById('panelTitle').textContent = '请选择左侧群聊';
|
||
activeGroup = null;
|
||
return;
|
||
}
|
||
empty.style.display = 'none';
|
||
|
||
if (!activeGroup || !groups.find(g => groupKey(g) === activeGroup)) {
|
||
activeGroup = groupKey(groups[0]);
|
||
}
|
||
|
||
for (const g of groups) {
|
||
const div = document.createElement('div');
|
||
div.className = 'group-item' + (groupKey(g) === activeGroup ? ' active' : '');
|
||
div.onclick = () => { activeGroup = groupKey(g); loadMessages(); };
|
||
div.innerHTML = `
|
||
<div class="group-name">${esc(g.key)}</div>
|
||
<div class="group-meta">${esc(g.appName || '')} · ${g.count} 条 · ${esc(g.latestAt || '')}</div>`;
|
||
groupsEl.appendChild(div);
|
||
}
|
||
renderActiveGroup();
|
||
}
|
||
|
||
function renderActiveGroup() {
|
||
const g = groups.find(x => groupKey(x) === activeGroup);
|
||
const panel = document.getElementById('messages');
|
||
if (!g) {
|
||
panel.innerHTML = '';
|
||
return;
|
||
}
|
||
document.getElementById('panelTitle').textContent = g.key + '(' + g.count + ' 条)';
|
||
panel.innerHTML = '';
|
||
for (const m of g.messages) {
|
||
const div = document.createElement('div');
|
||
div.className = 'msg';
|
||
const sender = (m.title && m.title !== g.key) ? m.title : '';
|
||
div.innerHTML = `
|
||
<div class="msg-head">
|
||
<span>${esc(m.receivedAt || '')}</span>
|
||
<span class="msg-source">${esc(m.source || '')}</span>
|
||
${sender ? `<span class="msg-sender">${esc(sender)}</span>` : ''}
|
||
</div>
|
||
<div class="msg-body">${esc(m.content || '')}</div>`;
|
||
panel.appendChild(div);
|
||
}
|
||
}
|
||
|
||
async function clearMessages() {
|
||
await fetch('/api/messages', { method: 'DELETE' });
|
||
activeGroup = null;
|
||
loadMessages();
|
||
}
|
||
|
||
function esc(s) {
|
||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
}
|
||
loadMessages();
|
||
setInterval(loadMessages, 2000);
|
||
</script>
|
||
</body>
|
||
</html>"""
|
||
return html.encode("utf-8")
|
||
|
||
|
||
class Handler(BaseHTTPRequestHandler):
|
||
def log_message(self, fmt, *args):
|
||
if self.path.startswith("/api/messages") and self.command == "GET":
|
||
return
|
||
BaseHTTPRequestHandler.log_message(self, fmt, *args)
|
||
|
||
def do_OPTIONS(self):
|
||
self.send_response(204)
|
||
self.send_header("Access-Control-Allow-Origin", "*")
|
||
self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
|
||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||
self.end_headers()
|
||
|
||
def do_GET(self):
|
||
path = urlparse(self.path).path
|
||
if path == "/":
|
||
body = _html_page()
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self.end_headers()
|
||
self.wfile.write(body)
|
||
return
|
||
if path == "/api/messages":
|
||
with _lock:
|
||
data = list(_messages)
|
||
_json_response(self, 200, data)
|
||
return
|
||
if path == "/api/groups":
|
||
with _lock:
|
||
data = _group_messages(list(_messages))
|
||
_json_response(self, 200, data)
|
||
return
|
||
if path == "/health":
|
||
_json_response(self, 200, {"ok": True})
|
||
return
|
||
_json_response(self, 404, {"error": "not found"})
|
||
|
||
def do_POST(self):
|
||
path = urlparse(self.path).path
|
||
if path not in ("/api/messages", "/api/debug/push", "/api/bills/app-upload"):
|
||
_json_response(self, 404, {"error": "not found"})
|
||
return
|
||
length = int(self.headers.get("Content-Length", "0"))
|
||
raw = self.rfile.read(length) if length else b"{}"
|
||
try:
|
||
payload = json.loads(raw.decode("utf-8") or "{}")
|
||
except ValueError:
|
||
_json_response(self, 400, {"error": "invalid json"})
|
||
return
|
||
|
||
if path == "/api/bills/app-upload":
|
||
data = payload.get("data") or {}
|
||
normalized = {
|
||
"source": "app-upload",
|
||
"packageName": payload.get("packageName", ""),
|
||
"appName": payload.get("appName", ""),
|
||
"title": data.get("title", ""),
|
||
"content": data.get("context", data.get("content", "")),
|
||
"timestamp": data.get("timestamp"),
|
||
"raw": payload,
|
||
}
|
||
else:
|
||
normalized = payload
|
||
|
||
if not normalized.get("group"):
|
||
normalized["group"] = _resolve_group(normalized)
|
||
|
||
item = _add_message(normalized)
|
||
_json_response(self, 200, {"ok": True, "id": item.get("id")})
|
||
|
||
def do_DELETE(self):
|
||
path = urlparse(self.path).path
|
||
if path != "/api/messages":
|
||
_json_response(self, 404, {"error": "not found"})
|
||
return
|
||
with _lock:
|
||
_messages.clear()
|
||
_json_response(self, 200, {"ok": True})
|
||
|
||
|
||
def main():
|
||
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
||
print("notiMessage debug server: http://127.0.0.1:{0}".format(PORT))
|
||
print("浏览器打开上述地址即可查看消息")
|
||
print("手机经 USB 调试时先执行: adb reverse tcp:8765 tcp:8765")
|
||
print("Wi-Fi 调试时将 AppConfig.DEBUG_SERVER_URL 改为 http://<PC局域网IP>:8765")
|
||
try:
|
||
server.serve_forever()
|
||
except KeyboardInterrupt:
|
||
print("\nstopped")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|