Files
notiMessage/debug-server/server.py
mars 1e2021d8fd docs(tng): 补充 Money Packet 领取台说明,并同步自动拉详情 Hook
说明 activityId、手动缓存模板后再自动拉历史详情的流程,以及 /mmp 双通道与去重行为。
2026-08-04 15:39:44 +08:00

736 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""notiMessage 本地调试转发服务 — 在 PC 浏览器查看手机抓取的消息。"""
import json
import threading
import time
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()
_last_dedup = {"key": None, "ts": 0.0}
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()
# 本机 + 局域网双推时可能各成功一次3 秒内同内容去重
dedup_key = "|".join([
str(payload.get("source") or ""),
str(payload.get("packageName") or ""),
str(payload.get("title") or ""),
str(payload.get("content") or ""),
])
now = time.time()
with _lock:
if (_last_dedup["key"] == dedup_key
and now - float(_last_dedup["ts"]) < 3.0):
if _messages:
return _messages[0]
_last_dedup["key"] = dedup_key
_last_dedup["ts"] = now
_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 _is_mmp_message(msg):
source = (msg.get("source") or "").lower()
content = msg.get("content") or ""
title = msg.get("title") or ""
if "tng_mmp" in source or "xposed_tng_mmp" in source:
return True
if "[MMP统计]" in content or "Money Packet" in title:
return True
lower = content.lower()
return "receiverlist" in lower or ("claimedamount" in lower and "mmp" in lower)
def _normalize_money(value):
"""把 1.23 / RM1.23 / {"amount":"0.71","cent":"71"} 统一成 float 或 None。"""
if value is None:
return None
text = str(value).strip()
if not text or text.lower() == "null":
return None
text = text.replace("RM", "").replace("rm", "").strip()
if text.startswith("{") and "amount" in text:
try:
obj = json.loads(text)
if isinstance(obj, dict):
if obj.get("amount") not in (None, ""):
return float(str(obj["amount"]).replace(",", ""))
if obj.get("cent") not in (None, ""):
return int(str(obj["cent"])) / 100.0
except Exception:
return None
try:
return float(text.replace(",", ""))
except ValueError:
return None
def _parse_mmp_content(content):
"""解析 TngMoneyPacketHook 转发文本 → {meta, claims[]}。"""
text = (content or "").strip()
meta = {}
claims = []
if not text:
return meta, claims
lines = text.splitlines()
head = lines[0] if lines else ""
if head.startswith("[MMP统计]"):
head = head[len("[MMP统计]"):].strip()
# 新格式用 " | " 分隔;旧格式兼容空格拆 token
if " | " in head:
parts = [p.strip() for p in head.split(" | ") if p.strip()]
else:
parts = []
buf = ""
for part in head.split():
if buf:
buf += " " + part
if buf.count("{") <= buf.count("}"):
parts.append(buf)
buf = ""
continue
if "=" in part and part.split("=", 1)[1].startswith("{") and part.count("{") > part.count("}"):
buf = part
continue
parts.append(part)
if buf:
parts.append(buf)
for part in parts:
if "=" not in part:
continue
k, v = part.split("=", 1)
meta[k.strip()] = v.strip()
# 旧报文 sender=LIAO RUICHAO 被空格截断时,尝试从原文还原
if meta.get("sender") and "sender=" in head:
raw_sender = head.split("sender=", 1)[1]
for stop in (" | ", " total=", " via=", " group=", " packet=", " src="):
if stop in raw_sender:
raw_sender = raw_sender.split(stop, 1)[0]
break
raw_sender = raw_sender.strip()
if raw_sender and len(raw_sender) > len(meta.get("sender") or ""):
meta["sender"] = raw_sender
if "total" in meta:
total_num = _normalize_money(meta["total"])
meta["total"] = ("{0:.2f}".format(total_num) if total_num is not None else meta["total"])
for line in lines[1:]:
line = line.strip()
if not line or "->" not in line:
continue
left, right = line.split("->", 1)
nick = left.strip()
right = right.strip()
claim_time = ""
if right.endswith(")") and "(" in right and not right.startswith("{"):
amt, _, rest = right.partition("(")
amount_raw = amt.strip()
claim_time = rest.rstrip(")").strip()
else:
amount_raw = right
amount_num = _normalize_money(amount_raw)
if nick:
claims.append({
"nickname": nick,
"amount": ("{0:.2f}".format(amount_num) if amount_num is not None else "0.00"),
"amountValue": amount_num if amount_num is not None else 0.0,
"claimTime": claim_time,
})
return meta, claims
def _claims_fingerprint(claims):
rows = []
for c in claims:
rows.append("{0}={1}".format(c.get("nickname") or "?", c.get("amount") or "0"))
rows.sort()
return "|".join(rows)
def _aggregate_claims(claims):
"""同一快照内同昵称合并;跨快照不应再混加(外层只取最新快照)。"""
buckets = {}
order = []
for c in claims:
nick = c.get("nickname") or "?"
if nick not in buckets:
buckets[nick] = {"nickname": nick, "amount": 0.0, "count": 0, "claimTime": ""}
order.append(nick)
b = buckets[nick]
b["count"] += 1
try:
b["amount"] += float(c.get("amountValue") if c.get("amountValue") is not None
else _normalize_money(c.get("amount")) or 0.0)
except (TypeError, ValueError):
pass
if c.get("claimTime") and not b["claimTime"]:
b["claimTime"] = c.get("claimTime") or ""
result = []
for nick in order:
b = buckets[nick]
result.append({
"nickname": nick,
"amount": round(b["amount"], 4),
"amountText": "{0:.2f}".format(b["amount"]),
"claimCount": b["count"],
"claimTime": b.get("claimTime") or "",
})
result.sort(key=lambda x: x["amount"], reverse=True)
for i, row in enumerate(result, 1):
row["rank"] = i
return result
def _mmp_packets():
with _lock:
msgs = [m for m in _messages if _is_mmp_message(m)]
# 每个红包只保留「最新一次完整领取榜」快照,避免多个红包因缺 packetId 被揉在一起
packets = {}
for msg in msgs:
meta, claims = _parse_mmp_content(msg.get("content") or "")
if not claims:
continue
packet_id = meta.get("packet") or meta.get("packetId") or ""
if not packet_id or packet_id in ("unknown", "TNG 红包", "TNG Money Packet"):
# 用领取名单指纹区分不同红包
packet_id = "红包-" + _claims_fingerprint(claims)[:48]
key = str(packet_id)
item = {
"packetId": key,
"title": msg.get("title") or "TNG 红包",
"sender": meta.get("sender") or "",
"group": meta.get("group") or "",
"total": meta.get("total") or "",
"via": meta.get("via") or "",
"latestAt": msg.get("receivedAt") or "",
"snapshots": 1,
"claims": claims,
"rawMessages": [{
"id": msg.get("id"),
"receivedAt": msg.get("receivedAt"),
"content": msg.get("content"),
"source": msg.get("source"),
}],
}
existing = packets.get(key)
if existing is None or (item["latestAt"] or "") >= (existing.get("latestAt") or ""):
if existing is not None:
item["snapshots"] = int(existing.get("snapshots") or 1) + 1
# 保留历史原始报文,但排行只用最新 claims
item["rawMessages"] = existing.get("rawMessages", []) + item["rawMessages"]
packets[key] = item
else:
existing["snapshots"] = int(existing.get("snapshots") or 1) + 1
existing["rawMessages"].append(item["rawMessages"][0])
result = []
for p in packets.values():
ranked = _aggregate_claims(p.get("claims") or [])
p["leaderboard"] = ranked
p["claimantCount"] = len(ranked)
p["claimEventCount"] = len(p.get("claims") or [])
try:
p["sumClaimed"] = round(sum(x["amount"] for x in ranked), 4)
except Exception:
p["sumClaimed"] = 0
# 总额缺失或只抓到单笔金额时,用领取合计兜底
total_num = _normalize_money(p.get("total"))
if total_num is None or (p["sumClaimed"] > 0 and abs(total_num - p["sumClaimed"]) > 0.001
and total_num <= max((x["amount"] for x in ranked), default=0) + 1e-9):
p["total"] = "{0:.2f}".format(p["sumClaimed"])
elif total_num is not None:
p["total"] = "{0:.2f}".format(total_num)
del p["claims"]
result.append(p)
result.sort(key=lambda x: x.get("latestAt") or "", reverse=True)
return result
def _mmp_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>红包领取台</title>
<style>
* { box-sizing: border-box; }
body { font-family: "Segoe UI", ui-sans-serif, system-ui, sans-serif; margin: 0; background: #0b1220; color: #e8eef7; height: 100vh; display: flex; flex-direction: column; }
header { padding: 14px 20px; background: linear-gradient(90deg,#1a1030,#0f1b2d); border-bottom: 1px solid #2a3550; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; flex-shrink: 0; }
h1 { margin: 0; font-size: 18px; letter-spacing: 0.02em; }
.stat { color: #9db0cc; font-size: 13px; }
a.nav { color: #8ec5ff; text-decoration: none; font-size: 13px; }
a.nav:hover { text-decoration: underline; }
button { background: #c45c26; color: #fff; border: 0; padding: 8px 14px; border-radius: 8px; cursor: pointer; font-size: 13px; }
button.secondary { background: #1c2740; border: 1px solid #314062; }
.layout { display: flex; flex: 1; min-height: 0; }
.sidebar { width: 320px; border-right: 1px solid #2a3550; background: #111a2c; overflow-y: auto; flex-shrink: 0; }
.sidebar h2 { margin: 0; padding: 14px 16px 8px; font-size: 12px; color: #8aa0c0; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }
.pkt { padding: 14px 16px; border-bottom: 1px solid #1a2438; cursor: pointer; }
.pkt:hover { background: #162033; }
.pkt.active { background: #1a2744; border-left: 3px solid #ff7a45; padding-left: 13px; }
.pkt-id { font-size: 13px; color: #ffd2a8; word-break: break-all; font-family: ui-monospace, Consolas, monospace; }
.pkt-meta { color: #8aa0c0; font-size: 12px; margin-top: 6px; line-height: 1.45; }
.main { flex: 1; overflow-y: auto; padding: 18px 22px; }
.title { margin: 0 0 6px; font-size: 20px; color: #ffe0c2; }
.sub { color: #9db0cc; font-size: 13px; margin-bottom: 16px; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin-bottom: 18px; }
.card { background: #141f35; border: 1px solid #273552; border-radius: 10px; padding: 12px 14px; }
.card .k { color: #8aa0c0; font-size: 11px; }
.card .v { margin-top: 4px; font-size: 18px; color: #fff; font-variant-numeric: tabular-nums; }
table { width: 100%; border-collapse: collapse; background: #111a2c; border-radius: 10px; overflow: hidden; border: 1px solid #273552; }
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #1e2a42; font-size: 13px; }
th { background: #182338; color: #9db0cc; font-weight: 600; }
tr:last-child td { border-bottom: 0; }
.rank { width: 48px; color: #ff9b5a; font-weight: 700; font-variant-numeric: tabular-nums; }
.amt { font-variant-numeric: tabular-nums; color: #7dffb3; font-weight: 600; }
.empty { padding: 48px 20px; text-align: center; color: #8aa0c0; }
.hint { margin-top: 10px; font-size: 12px; color: #6f84a6; }
.raw { margin-top: 18px; }
.raw summary { cursor: pointer; color: #8ec5ff; font-size: 13px; }
pre { background: #0d1524; border: 1px solid #273552; border-radius: 8px; padding: 12px; overflow: auto; font-size: 12px; color: #c9d6ea; white-space: pre-wrap; word-break: break-word; }
</style>
</head>
<body>
<header>
<h1>红包领取台</h1>
<span class="stat" id="pktCount">0 个红包</span>
<span class="stat" id="updated">-</span>
<a class="nav" href="/">← 返回消息台</a>
<button onclick="loadData()">刷新</button>
<button class="secondary" onclick="clearAll()">清空全部</button>
</header>
<div class="layout">
<aside class="sidebar">
<h2>红包列表</h2>
<div id="list"></div>
</aside>
<main class="main">
<div id="empty" class="empty">
暂无红包数据<br/>
<div class="hint">打开 Money Packet 历史页可自动拉取;若没有数据,先手动点开任意一条详情一次(缓存登录模板),再回历史页</div>
</div>
<div id="panel" style="display:none">
<h2 class="title" id="title">-</h2>
<div class="sub" id="sub">-</div>
<div class="cards">
<div class="card"><div class="k">领取人数</div><div class="v" id="cPeople">0</div></div>
<div class="card"><div class="k">领取合计</div><div class="v" id="cSum">0</div></div>
<div class="card"><div class="k">抓取次数</div><div class="v" id="cSnap">0</div></div>
<div class="card"><div class="k">红包总额</div><div class="v" id="cTotal">-</div></div>
</div>
<table>
<thead><tr><th class="rank">排名</th><th>昵称</th><th>领取金额</th><th>领取时间</th></tr></thead>
<tbody id="board"></tbody>
</table>
<div class="raw"><details><summary>查看原始数据</summary><pre id="raw"></pre></details></div>
</div>
</main>
</div>
<script>
let packets = [];
let activeId = null;
async function loadData() {
const res = await fetch('/api/mmp');
packets = await res.json();
document.getElementById('pktCount').textContent = packets.length + ' 个红包';
document.getElementById('updated').textContent = '更新: ' + new Date().toLocaleTimeString();
const list = document.getElementById('list');
list.innerHTML = '';
const empty = document.getElementById('empty');
const panel = document.getElementById('panel');
if (!packets.length) {
empty.style.display = 'block';
panel.style.display = 'none';
activeId = null;
return;
}
empty.style.display = 'none';
panel.style.display = 'block';
if (!activeId || !packets.find(p => p.packetId === activeId)) {
activeId = packets[0].packetId;
}
for (const p of packets) {
const div = document.createElement('div');
div.className = 'pkt' + (p.packetId === activeId ? ' active' : '');
div.onclick = () => { activeId = p.packetId; loadData(); };
div.innerHTML = `
<div class="pkt-id">${esc(shortId(p.packetId))} · ${esc((p.sender || '红包') + ' RM' + (p.sumClaimed ?? p.total ?? 0))}</div>
<div class="pkt-meta">${p.claimantCount || 0} 人领取 · ${esc(p.latestAt || '')}</div>`;
list.appendChild(div);
}
renderActive();
}
function shortId(id) {
const s = String(id || '');
if (s.length >= 8 && s.indexOf('-') > 0) return s.slice(0, 8);
return s.slice(0, 16) || '-';
}
function renderActive() {
const p = packets.find(x => x.packetId === activeId);
if (!p) return;
document.getElementById('title').textContent =
(p.sender ? p.sender + ' 的红包' : (p.title || '红包详情'));
const parts = [];
if (p.packetId) parts.push('activityId ' + p.packetId);
if (p.sender) parts.push('发送人 ' + p.sender);
if (p.group) parts.push('群组 ' + p.group);
if (p.via) parts.push('来源 ' + (p.via === 'http' ? '网络' : p.via === 'gson' ? '解析' : p.via === 'rpc' ? 'RPC' : p.via));
document.getElementById('sub').textContent = parts.join(' · ') || '单个红包领取排行';
document.getElementById('cPeople').textContent = p.claimantCount || 0;
document.getElementById('cSum').textContent = Number(p.sumClaimed ?? 0).toFixed(2);
document.getElementById('cSnap').textContent = p.snapshots || 0;
document.getElementById('cTotal').textContent = p.total || '-';
const board = document.getElementById('board');
board.innerHTML = '';
const rows = p.leaderboard || [];
if (!rows.length) {
const tr = document.createElement('tr');
tr.innerHTML = '<td colspan="4" style="color:#8aa0c0;text-align:center">暂无领取记录</td>';
board.appendChild(tr);
}
for (const row of rows) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="rank">${row.rank}</td>
<td>${esc(row.nickname)}</td>
<td class="amt">${esc(row.amountText || Number(row.amount).toFixed(2))}</td>
<td>${esc(row.claimTime || '-')}</td>`;
board.appendChild(tr);
}
const raw = (p.rawMessages || []).map(m =>
'[' + (m.receivedAt || '') + '] ' + (m.source || '') + '\\n' + (m.content || '')
).join('\\n---\\n');
document.getElementById('raw').textContent = raw || '(无)';
}
async function clearAll() {
await fetch('/api/messages', { method: 'DELETE' });
activeId = null;
loadData();
}
function esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
loadData();
setInterval(loadData, 2000);
</script>
</body>
</html>"""
return html.encode("utf-8")
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; }
a.nav { color: #58a6ff; text-decoration: none; font-size: 13px; }
a.nav:hover { text-decoration: underline; }
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>
<a class="nav" href="/mmp">红包领取台 →</a>
<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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
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 == "/mmp":
body = _mmp_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 == "/api/mmp":
_json_response(self, 200, _mmp_packets())
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("通用调试台: http://127.0.0.1:{0}/".format(PORT))
print("红包领取台: http://127.0.0.1:{0}/mmp".format(PORT))
print("手机经 USB: adb reverse tcp:8765 tcp:8765走 127.0.0.1")
print("手机经 Wi-Fi: AppConfig 已含局域网地址,与电脑同一网段即可")
print("App 会对 DEBUG_SERVER_URLS 全部推送;不可达的会失败,可达的生效")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nstopped")
if __name__ == "__main__":
main()