feat(tng): 红包领取台双通道推送,并修复金额/按包分组

Hook Quake RPC 抓 Money Packet;解析 Money 对象金额;调试台按单个红包展示排行;支持 USB 本机与共享网段双地址转发。
This commit is contained in:
mars
2026-08-04 15:09:28 +08:00
parent e80f7f908c
commit 88a2b319f8
4 changed files with 751 additions and 88 deletions

View File

@@ -3,6 +3,7 @@
import json
import threading
import time
from collections import deque
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -14,6 +15,7 @@ MAX_MESSAGES = 500
_messages = deque(maxlen=MAX_MESSAGES)
_lock = threading.Lock()
_last_dedup = {"key": None, "ts": 0.0}
def _now_iso():
@@ -32,7 +34,21 @@ 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(
@@ -78,6 +94,394 @@ def _group_messages(messages):
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">在手机打开 TNG「红包」详情或排行榜后领取列表会显示在这里</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((p.sender ? p.sender + ' · ' : '') + 'RM' + (p.sumClaimed ?? p.total ?? 0))}</div>
<div class="pkt-meta">${esc(p.title || '红包')} · ${p.claimantCount || 0} 人领取<br/>
${esc(p.latestAt || '')}
</div>`;
list.appendChild(div);
}
renderActive();
}
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 && !String(p.packetId).startsWith('红包-')) parts.push('编号 ' + 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">
@@ -91,6 +495,8 @@ def _html_page():
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; }
@@ -117,6 +523,7 @@ def _html_page():
<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>
@@ -237,6 +644,14 @@ class Handler(BaseHTTPRequestHandler):
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)
@@ -247,6 +662,9 @@ class Handler(BaseHTTPRequestHandler):
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
@@ -298,9 +716,11 @@ class Handler(BaseHTTPRequestHandler):
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")
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: