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

@@ -12,11 +12,18 @@ public final class AppConfig {
public static final boolean ENABLE_DEBUG_FORWARD = true; public static final boolean ENABLE_DEBUG_FORWARD = true;
/** /**
* 调试服务地址。 * 调试服务地址(可同时配置 USB 本机 + 局域网)
* USB + adb reverse: http://127.0.0.1:8765 * USB + adb reverse: http://127.0.0.1:8765
* Wi-Fi: http://<电脑局域网IP>:8765 * Wi-Fi: http://<电脑局域网IP>:8765
* 会向列表中每个地址各推送一次;不可达的静默失败。
*/ */
public static final String DEBUG_SERVER_URL = "http://127.0.0.1:8765"; public static final String[] DEBUG_SERVER_URLS = {
"http://127.0.0.1:8765",
"http://10.151.104.25:8765",
};
/** 兼容旧引用:取第一个调试地址 */
public static final String DEBUG_SERVER_URL = DEBUG_SERVER_URLS[0];
/** 是否定期唤醒监听列表中的 App保持进程 / Hook 加载) */ /** 是否定期唤醒监听列表中的 App保持进程 / Hook 加载) */
public static final boolean ENABLE_MONITORED_APP_KEEP_ALIVE = true; public static final boolean ENABLE_MONITORED_APP_KEEP_ALIVE = true;

View File

@@ -10,6 +10,7 @@ import com.miraclegarden.smsmessage.MessageInfo;
import org.json.JSONObject; import org.json.JSONObject;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import okhttp3.Call; import okhttp3.Call;
import okhttp3.Callback; import okhttp3.Callback;
@@ -21,6 +22,7 @@ import okhttp3.Response;
/** /**
* 将抓取到的消息转发到 PC 本地调试服务,便于浏览器查看。 * 将抓取到的消息转发到 PC 本地调试服务,便于浏览器查看。
* 支持同时向本机USB/adb reverse和局域网地址推送。
*/ */
public final class DebugForwarder { public final class DebugForwarder {
@@ -51,6 +53,14 @@ public final class DebugForwarder {
return; return;
} }
String[] urls = AppConfig.DEBUG_SERVER_URLS;
if (urls == null || urls.length == 0) {
if (onComplete != null) {
onComplete.run();
}
return;
}
try { try {
JSONObject json = new JSONObject(); JSONObject json = new JSONObject();
json.put("source", source != null ? source : "unknown"); json.put("source", source != null ? source : "unknown");
@@ -60,39 +70,49 @@ public final class DebugForwarder {
json.put("group", resolveGroup(title, messageInfo)); json.put("group", resolveGroup(title, messageInfo));
json.put("content", content != null ? content : ""); json.put("content", content != null ? content : "");
json.put("timestamp", timestamp); json.put("timestamp", timestamp);
final String body = json.toString();
final AtomicInteger pending = new AtomicInteger(urls.length);
Request request = new Request.Builder() for (String baseUrl : urls) {
.url(AppConfig.DEBUG_SERVER_URL + "/api/debug/push") if (TextUtils.isEmpty(baseUrl)) {
.header("Content-Type", "application/json") finishOne(pending, onComplete);
.post(RequestBody.create(json.toString(), JSON)) continue;
.build();
Log.d(TAG, "forwarding [" + source + "] " + messageInfo.getPackageName()
+ " -> " + AppConfig.DEBUG_SERVER_URL);
CLIENT.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, java.io.IOException e) {
Log.w(TAG, "forward failed [" + source + "]: " + e.getMessage());
if (onComplete != null) {
onComplete.run();
}
} }
final String target = baseUrl.endsWith("/")
? baseUrl.substring(0, baseUrl.length() - 1)
: baseUrl;
Request request = new Request.Builder()
.url(target + "/api/debug/push")
.header("Content-Type", "application/json")
.post(RequestBody.create(body, JSON))
.build();
@Override Log.d(TAG, "forwarding [" + source + "] " + messageInfo.getPackageName()
public void onResponse(Call call, Response response) { + " -> " + target);
int code = response.code();
response.close(); CLIENT.newCall(request).enqueue(new Callback() {
if (code >= 200 && code < 300) { @Override
Log.d(TAG, "forward ok [" + source + "] " + title); public void onFailure(Call call, java.io.IOException e) {
} else { Log.w(TAG, "forward failed [" + source + "] -> " + target
Log.w(TAG, "forward http " + code + " [" + source + "] " + title); + ": " + e.getMessage());
finishOne(pending, onComplete);
} }
if (onComplete != null) {
onComplete.run(); @Override
public void onResponse(Call call, Response response) {
int code = response.code();
response.close();
if (code >= 200 && code < 300) {
Log.d(TAG, "forward ok [" + source + "] -> " + target
+ " " + title);
} else {
Log.w(TAG, "forward http " + code + " [" + source + "] -> "
+ target + " " + title);
}
finishOne(pending, onComplete);
} }
} });
}); }
} catch (Exception e) { } catch (Exception e) {
Log.w(TAG, "forward build failed: " + e.getMessage()); Log.w(TAG, "forward build failed: " + e.getMessage());
if (onComplete != null) { if (onComplete != null) {
@@ -101,6 +121,12 @@ public final class DebugForwarder {
} }
} }
private static void finishOne(AtomicInteger pending, Runnable onComplete) {
if (pending.decrementAndGet() == 0 && onComplete != null) {
onComplete.run();
}
}
private static String resolveGroup(String title, MessageInfo messageInfo) { private static String resolveGroup(String title, MessageInfo messageInfo) {
if (!TextUtils.isEmpty(title)) { if (!TextUtils.isEmpty(title)) {
return title.trim(); return title.trim();

View File

@@ -3,6 +3,7 @@
import json import json
import threading import threading
import time
from collections import deque from collections import deque
from datetime import datetime from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -14,6 +15,7 @@ MAX_MESSAGES = 500
_messages = deque(maxlen=MAX_MESSAGES) _messages = deque(maxlen=MAX_MESSAGES)
_lock = threading.Lock() _lock = threading.Lock()
_last_dedup = {"key": None, "ts": 0.0}
def _now_iso(): def _now_iso():
@@ -32,7 +34,21 @@ def _add_message(payload):
item = dict(payload) item = dict(payload)
item["group"] = _resolve_group(payload) item["group"] = _resolve_group(payload)
item["receivedAt"] = _now_iso() 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: 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) _messages.appendleft(item)
item["id"] = len(_messages) item["id"] = len(_messages)
print("[{0}] [{1}] [{2}] {3} | {4}".format( print("[{0}] [{1}] [{2}] {3} | {4}".format(
@@ -78,6 +94,394 @@ def _group_messages(messages):
return result 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(): def _html_page():
html = """<!DOCTYPE html> html = """<!DOCTYPE html>
<html lang="zh-CN"> <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; } 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; } h1 { margin: 0; font-size: 18px; }
.stat { color: #8b949e; font-size: 13px; } .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 { 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; } button.secondary { background: #21262d; border: 1px solid #30363d; }
.layout { display: flex; flex: 1; min-height: 0; } .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="count">0 条</span>
<span class="stat" id="groupCount">0 群</span> <span class="stat" id="groupCount">0 群</span>
<span class="stat" id="updated">-</span> <span class="stat" id="updated">-</span>
<a class="nav" href="/mmp">红包领取台 →</a>
<button onclick="loadMessages()">刷新</button> <button onclick="loadMessages()">刷新</button>
<button class="secondary" onclick="clearMessages()">清空</button> <button class="secondary" onclick="clearMessages()">清空</button>
</header> </header>
@@ -237,6 +644,14 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(body) self.wfile.write(body)
return 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": if path == "/api/messages":
with _lock: with _lock:
data = list(_messages) data = list(_messages)
@@ -247,6 +662,9 @@ class Handler(BaseHTTPRequestHandler):
data = _group_messages(list(_messages)) data = _group_messages(list(_messages))
_json_response(self, 200, data) _json_response(self, 200, data)
return return
if path == "/api/mmp":
_json_response(self, 200, _mmp_packets())
return
if path == "/health": if path == "/health":
_json_response(self, 200, {"ok": True}) _json_response(self, 200, {"ok": True})
return return
@@ -298,9 +716,11 @@ class Handler(BaseHTTPRequestHandler):
def main(): def main():
server = ThreadingHTTPServer((HOST, PORT), Handler) server = ThreadingHTTPServer((HOST, PORT), Handler)
print("notiMessage debug server: http://127.0.0.1:{0}".format(PORT)) print("notiMessage debug server: http://127.0.0.1:{0}".format(PORT))
print("浏览器打开上述地址即可查看消息") print("通用调试台: http://127.0.0.1:{0}/".format(PORT))
print("手机经 USB 调试时先执行: adb reverse tcp:8765 tcp:8765") print("红包领取台: http://127.0.0.1:{0}/mmp".format(PORT))
print("Wi-Fi 调试时将 AppConfig.DEBUG_SERVER_URL 改为 http://<PC局域网IP>:8765") print("手机经 USB: adb reverse tcp:8765 tcp:8765走 127.0.0.1")
print("手机经 Wi-Fi: AppConfig 已含局域网地址,与电脑同一网段即可")
print("App 会对 DEBUG_SERVER_URLS 全部推送;不可达的会失败,可达的生效")
try: try:
server.serve_forever() server.serve_forever()
except KeyboardInterrupt: except KeyboardInterrupt:

View File

@@ -49,6 +49,7 @@ public final class TngMoneyPacketHook {
hookOkHttpUrl(lpparam); hookOkHttpUrl(lpparam);
hookResponseBody(lpparam); hookResponseBody(lpparam);
hookGsonFromJson(lpparam); hookGsonFromJson(lpparam);
hookQuakeRpc(lpparam);
XposedBridge.log(TAG + " installed for " + lpparam.packageName); XposedBridge.log(TAG + " installed for " + lpparam.packageName);
} }
@@ -167,11 +168,110 @@ public final class TngMoneyPacketHook {
if (TextUtils.isEmpty(className)) { if (TextUtils.isEmpty(className)) {
return false; return false;
} }
return className.contains("MmpDetailResult") String n = className;
|| className.contains("MmpClaimQueryResult") return n.contains("MmpDetail")
|| className.contains("MmpClaimResult") || n.contains("MmpClaim")
|| className.contains("MmpReceiver") || n.contains("MmpReceiver")
|| className.contains("MmpDetailLeaderboard"); || n.contains("MmpLeaderboard")
|| n.contains("MoneyPacketDetail")
|| n.contains("MoneyPacketClaim")
|| n.contains("MoneyPacketLeader")
|| n.contains("MoneyPacketDoClaim")
|| n.contains("ClaimResultDto")
|| n.contains("ClaimDto")
|| n.contains("ReceiverList")
|| n.contains("packetReceiver");
}
private static void hookQuakeRpc(XC_LoadPackage.LoadPackageParam lpparam) {
String[] rpcClasses = {
"my.com.tngdigital.common.aliservice.quake.TngdRpcInvocationHandlerHost",
"com.alipay.imobile.network.quake.rpc.RpcInvocationHandler",
};
XC_MethodHook hook = new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
if (param.hasThrowable() || param.getResult() == null) {
return;
}
String op = extractMoneyPacketRpcOp(param.args);
if (op == null) {
return;
}
Object result = param.getResult();
String className = result.getClass().getName();
XposedBridge.log(TAG + " rpc hit op=" + op + " type=" + className);
try {
String json = buildJsonFromObject(result);
if (TextUtils.isEmpty(json)) {
json = tryFastjson(result);
}
if (TextUtils.isEmpty(json)) {
XposedBridge.log(TAG + " rpc serialize empty op=" + op);
return;
}
forwardParsedJson(json, op, "rpc");
MmpSnapshot probe = parseSnapshot(json);
if (probe == null || probe.claims.isEmpty()) {
String snippet = json.length() > 500 ? json.substring(0, 500) + "..." : json;
XposedBridge.log(TAG + " rpc no-claims op=" + op + " body=" + snippet);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " rpc capture failed: " + t.getMessage());
}
}
};
for (String className : rpcClasses) {
try {
Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
int hooked = 0;
for (Method method : clazz.getDeclaredMethods()) {
if (!"invoke".equals(method.getName())) {
continue;
}
XposedBridge.hookMethod(method, hook);
hooked++;
}
if (hooked > 0) {
XposedBridge.log(TAG + " hooked RPC " + className + " invoke=" + hooked);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " skip RPC " + className + ": " + t.getMessage());
}
}
}
private static String extractMoneyPacketRpcOp(Object[] args) {
if (args == null) {
return null;
}
for (Object arg : args) {
if (arg instanceof Method) {
String name = ((Method) arg).getName().toLowerCase(Locale.US);
if (name.contains("moneypacket") || name.contains("mmp")) {
return ((Method) arg).getName();
}
} else if (arg instanceof String) {
String text = (String) arg;
String lower = text.toLowerCase(Locale.US);
if (lower.contains("moneypacket") || lower.contains(".mmp")
|| lower.contains("tngdwallet.money")) {
return text;
}
}
}
return null;
}
private static String tryFastjson(Object obj) {
try {
Class<?> json = Class.forName("com.alibaba.fastjson.JSON", false,
obj.getClass().getClassLoader());
Object out = XposedHelpers.callStaticMethod(json, "toJSONString", obj);
return out != null ? String.valueOf(out) : null;
} catch (Throwable ignored) {
return null;
}
} }
private static void forwardParsedJson(String json, String sourceHint, String channel) { private static void forwardParsedJson(String json, String sourceHint, String channel) {
@@ -185,9 +285,11 @@ public final class TngMoneyPacketHook {
} }
Context context = getContext(); Context context = getContext();
if (context == null) { if (context == null) {
XposedBridge.log(TAG + " skip forward: no context, claims="
+ snapshot.claims.size());
return; return;
} }
String title = TextUtils.isEmpty(snapshot.title) ? "TNG Money Packet" : snapshot.title; String title = TextUtils.isEmpty(snapshot.title) ? "TNG 红包" : snapshot.title;
String content = snapshot.formatForForward(channel, sourceHint); String content = snapshot.formatForForward(channel, sourceHint);
HookForwarder.forward(context, PACKAGE, title, content, HookBridge.SOURCE_XPOSED_TNG_MMP); HookForwarder.forward(context, PACKAGE, title, content, HookBridge.SOURCE_XPOSED_TNG_MMP);
XposedBridge.log(TAG + " captured packet=" + snapshot.packetId XposedBridge.log(TAG + " captured packet=" + snapshot.packetId
@@ -205,7 +307,11 @@ public final class TngMoneyPacketHook {
private static MmpSnapshot parseSnapshot(JSONObject root, MmpSnapshot base) { private static MmpSnapshot parseSnapshot(JSONObject root, MmpSnapshot base) {
MmpSnapshot snapshot = base != null ? base : new MmpSnapshot(); MmpSnapshot snapshot = base != null ? base : new MmpSnapshot();
fillMeta(root, snapshot); boolean rootPass = base == null;
// 领取行不再回填 meta其它嵌套节点可补 packetId/sender/total
if (rootPass || parseClaimLine(root) == null) {
fillMeta(root, snapshot);
}
JSONArray receiverList = findReceiverList(root); JSONArray receiverList = findReceiverList(root);
if (receiverList != null) { if (receiverList != null) {
@@ -233,7 +339,7 @@ public final class TngMoneyPacketHook {
Object elem = arr.opt(i); Object elem = arr.opt(i);
if (elem instanceof JSONObject) { if (elem instanceof JSONObject) {
ClaimLine line = parseClaimLine((JSONObject) elem); ClaimLine line = parseClaimLine((JSONObject) elem);
if (line != null && looksLikeClaimRow((JSONObject) elem)) { if (line != null) {
snapshot.claims.add(line); snapshot.claims.add(line);
} }
parseSnapshot((JSONObject) elem, snapshot); parseSnapshot((JSONObject) elem, snapshot);
@@ -241,6 +347,9 @@ public final class TngMoneyPacketHook {
} }
} }
} }
if (rootPass && TextUtils.isEmpty(snapshot.packetId) && !snapshot.claims.isEmpty()) {
snapshot.packetId = "fp" + Integer.toHexString(snapshot.dedupKey().hashCode());
}
return snapshot; return snapshot;
} }
@@ -249,7 +358,7 @@ public final class TngMoneyPacketHook {
while (keys.hasNext()) { while (keys.hasNext()) {
String key = keys.next(); String key = keys.next();
Object val = obj.opt(key); Object val = obj.opt(key);
if ("receiverList".equalsIgnoreCase(key) && val instanceof JSONArray) { if (val instanceof JSONArray && isReceiverListKey(key)) {
return (JSONArray) val; return (JSONArray) val;
} }
if (val instanceof JSONObject) { if (val instanceof JSONObject) {
@@ -262,12 +371,32 @@ public final class TngMoneyPacketHook {
return null; return null;
} }
private static boolean isReceiverListKey(String key) {
if (TextUtils.isEmpty(key)) {
return false;
}
String k = key.toLowerCase(Locale.US);
return "receiverlist".equals(k)
|| "leaderboardlist".equals(k)
|| "leaderboard".equals(k)
|| "rankinglist".equals(k)
|| "claimlist".equals(k)
|| "claimers".equals(k)
|| "packetreceiverlist".equals(k)
|| "receivers".equals(k)
|| k.contains("receiverlist")
|| k.contains("leaderboard");
}
private static void fillMeta(JSONObject obj, MmpSnapshot snapshot) { private static void fillMeta(JSONObject obj, MmpSnapshot snapshot) {
putIfPresent(obj, snapshot, "packetId", "mmpId", "moneyPacketId", "id"); // 勿用裸 amount/id会把领取金额或嵌套 id 误当成红包总额/编号
putIfPresent(obj, snapshot, "packetId", "mmpId", "moneyPacketId", "packetCode",
"fundOrderId", "orderNo", "bizNo", "bizOrderId", "activityId");
putIfPresent(obj, snapshot, "groupId", "chatId", "conversationId"); putIfPresent(obj, snapshot, "groupId", "chatId", "conversationId");
putIfPresent(obj, snapshot, "groupName", "chatName", "conversationName"); putIfPresent(obj, snapshot, "groupName", "chatName", "conversationName");
putIfPresent(obj, snapshot, "senderName", "senderNickName", "operatorName"); putIfPresent(obj, snapshot, "senderName", "senderNickName", "operatorName", "creatorName");
putIfPresent(obj, snapshot, "totalAmount", "packetAmount", "amount"); putIfPresent(obj, snapshot, "totalAmount", "packetAmount", "packetTotalAmount",
"totalPacketAmount", "originalAmount");
if (TextUtils.isEmpty(snapshot.title)) { if (TextUtils.isEmpty(snapshot.title)) {
String merchant = firstNonEmpty( String merchant = firstNonEmpty(
obj.optString("merchantName", null), obj.optString("merchantName", null),
@@ -283,70 +412,142 @@ public final class TngMoneyPacketHook {
if (!obj.has(key)) { if (!obj.has(key)) {
continue; continue;
} }
String val = obj.optString(key, null); String val = readFieldText(obj, key);
if (TextUtils.isEmpty(val) || "null".equalsIgnoreCase(val)) { if (TextUtils.isEmpty(val) || "null".equalsIgnoreCase(val)) {
continue; continue;
} }
if (key.toLowerCase(Locale.US).contains("packet") || key.equals("mmpId") || key.equals("id")) { String keyLower = key.toLowerCase(Locale.US);
if (TextUtils.isEmpty(snapshot.packetId)) { if (keyLower.contains("packet") || keyLower.contains("mmp")
|| keyLower.contains("order") || keyLower.contains("biz")
|| keyLower.contains("activity") || keyLower.equals("packetcode")) {
if (TextUtils.isEmpty(snapshot.packetId) && !looksLikeMoneyJson(val)) {
snapshot.packetId = val; snapshot.packetId = val;
} }
} else if (key.toLowerCase(Locale.US).contains("group")) { } else if (keyLower.contains("group") || keyLower.contains("chat")
if (TextUtils.isEmpty(snapshot.groupId)) { || keyLower.contains("conversation")) {
if (keyLower.contains("name")) {
if (TextUtils.isEmpty(snapshot.title)) {
snapshot.title = val;
}
} else if (TextUtils.isEmpty(snapshot.groupId)) {
snapshot.groupId = val; snapshot.groupId = val;
} }
} else if (key.toLowerCase(Locale.US).contains("name") && key.toLowerCase(Locale.US).contains("group")) { } else if (keyLower.contains("sender") || keyLower.contains("operator")
snapshot.title = val; || keyLower.contains("creator")) {
} else if (key.toLowerCase(Locale.US).contains("sender") if (TextUtils.isEmpty(snapshot.senderName) && !looksLikeMoneyJson(val)) {
|| key.toLowerCase(Locale.US).contains("operator")) {
if (TextUtils.isEmpty(snapshot.senderName)) {
snapshot.senderName = val; snapshot.senderName = val;
} }
} else if (key.toLowerCase(Locale.US).contains("total") || key.equals("amount")) { } else if (keyLower.contains("total") || keyLower.contains("packetamount")
if (TextUtils.isEmpty(snapshot.totalAmount)) { || keyLower.contains("original")) {
snapshot.totalAmount = val; String money = normalizeMoneyText(val);
if (TextUtils.isEmpty(snapshot.totalAmount) && !TextUtils.isEmpty(money)) {
snapshot.totalAmount = money;
} }
} }
} }
} }
private static boolean looksLikeClaimRow(JSONObject obj) { private static boolean looksLikeClaimRow(JSONObject obj) {
String name = firstNonEmpty( return parseClaimLine(obj) != null;
obj.optString("nickName", null),
obj.optString("displayName", null),
obj.optString("userName", null),
obj.optString("receiverName", null));
String amount = firstNonEmpty(
obj.optString("claimedAmount", null),
obj.optString("receiveAmount", null),
obj.optString("amount", null));
return !TextUtils.isEmpty(name) && !TextUtils.isEmpty(amount);
} }
private static ClaimLine parseClaimLine(JSONObject obj) { private static ClaimLine parseClaimLine(JSONObject obj) {
String name = firstNonEmpty( String name = firstNonEmpty(
obj.optString("nickName", null), readFieldText(obj, "nickName"),
obj.optString("displayName", null), readFieldText(obj, "nickname"),
obj.optString("userName", null), readFieldText(obj, "displayName"),
obj.optString("receiverName", null), readFieldText(obj, "userName"),
obj.optString("name", null)); readFieldText(obj, "receiverName"),
readFieldText(obj, "receiverAccountName"),
readFieldText(obj, "accountName"),
readFieldText(obj, "participantName"),
readFieldText(obj, "name"));
String amount = firstNonEmpty( String amount = firstNonEmpty(
obj.optString("claimedAmount", null), normalizeMoneyText(readFieldText(obj, "claimedAmount")),
obj.optString("receiveAmount", null), normalizeMoneyText(readFieldText(obj, "receiveAmount")),
obj.optString("amount", null)); normalizeMoneyText(readFieldText(obj, "receiverClaimedAmount")),
normalizeMoneyText(readFieldText(obj, "claimAmount")),
normalizeMoneyText(readFieldText(obj, "amount")));
if (TextUtils.isEmpty(name) || TextUtils.isEmpty(amount)) { if (TextUtils.isEmpty(name) || TextUtils.isEmpty(amount)) {
return null; return null;
} }
if (name.length() > 64 || looksLikeMoneyJson(name)) {
return null;
}
ClaimLine line = new ClaimLine(); ClaimLine line = new ClaimLine();
line.nickname = name; line.nickname = name.trim();
line.amount = amount; line.amount = amount;
line.claimTime = firstNonEmpty( line.claimTime = firstNonEmpty(
obj.optString("claimTime", null), readFieldText(obj, "claimTime"),
obj.optString("claimedTime", null), readFieldText(obj, "claimedTime"),
obj.optString("receiveTime", null)); readFieldText(obj, "receiveTime"),
readFieldText(obj, "gmtClaim"),
readFieldText(obj, "gmtCreate"),
readFieldText(obj, "time"));
return line; return line;
} }
/** 读取字段:兼容 Money 对象 / 嵌套 JSON / 普通字符串。 */
private static String readFieldText(JSONObject obj, String key) {
if (obj == null || TextUtils.isEmpty(key) || !obj.has(key)) {
return null;
}
Object raw = obj.opt(key);
if (raw == null || raw == JSONObject.NULL) {
return null;
}
if (raw instanceof JSONObject) {
return normalizeMoneyText(((JSONObject) raw).toString());
}
String text = String.valueOf(raw).trim();
if (TextUtils.isEmpty(text) || "null".equalsIgnoreCase(text)) {
return null;
}
if (looksLikeMoneyJson(text)) {
return normalizeMoneyText(text);
}
return text;
}
private static boolean looksLikeMoneyJson(String text) {
if (TextUtils.isEmpty(text)) {
return false;
}
String t = text.trim();
return t.startsWith("{") && t.contains("\"amount\"");
}
private static String normalizeMoneyText(String text) {
if (TextUtils.isEmpty(text) || "null".equalsIgnoreCase(text)) {
return null;
}
String t = text.trim().replace("RM", "").trim();
if (looksLikeMoneyJson(t)) {
try {
JSONObject money = new JSONObject(t);
String amount = money.optString("amount", null);
if (!TextUtils.isEmpty(amount) && !"null".equalsIgnoreCase(amount)) {
return amount.trim();
}
String cent = money.optString("cent", null);
if (!TextUtils.isEmpty(cent) && !"null".equalsIgnoreCase(cent)) {
try {
return String.format(Locale.US, "%.2f", Integer.parseInt(cent) / 100.0);
} catch (Throwable ignored) {
}
}
} catch (Throwable ignored) {
}
return null;
}
// 纯数字或 1.00
try {
return String.format(Locale.US, "%.2f", Double.parseDouble(t.replace(",", "")));
} catch (Throwable ignored) {
return t;
}
}
private static String buildJsonFromObject(Object root) { private static String buildJsonFromObject(Object root) {
JSONObject json = objectToJson(root, new HashSet<Integer>(), 0); JSONObject json = objectToJson(root, new HashSet<Integer>(), 0);
return json != null ? json.toString() : null; return json != null ? json.toString() : null;
@@ -541,23 +742,24 @@ public final class TngMoneyPacketHook {
String formatForForward(String channel, String sourceHint) { String formatForForward(String channel, String sourceHint) {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
sb.append("[MMP统计] "); sb.append("[MMP统计]");
if (!TextUtils.isEmpty(packetId)) { if (!TextUtils.isEmpty(packetId)) {
sb.append("packet=").append(packetId).append(' '); sb.append(" packet=").append(sanitizeMeta(packetId));
} }
if (!TextUtils.isEmpty(groupId)) { if (!TextUtils.isEmpty(groupId)) {
sb.append("group=").append(groupId).append(' '); sb.append(" | group=").append(sanitizeMeta(groupId));
} }
if (!TextUtils.isEmpty(senderName)) { if (!TextUtils.isEmpty(senderName)) {
sb.append("sender=").append(senderName).append(' '); sb.append(" | sender=").append(sanitizeMeta(senderName));
} }
if (!TextUtils.isEmpty(totalAmount)) { if (!TextUtils.isEmpty(totalAmount)) {
sb.append("total=").append(totalAmount).append(' '); sb.append(" | total=").append(sanitizeMeta(totalAmount));
} }
sb.append("via=").append(channel); sb.append(" | via=").append(sanitizeMeta(channel));
if (!TextUtils.isEmpty(sourceHint)) { if (!TextUtils.isEmpty(sourceHint)) {
sb.append(" src=").append(sourceHint.length() > 120 String src = sourceHint.length() > 120
? sourceHint.substring(0, 120) + "..." : sourceHint); ? sourceHint.substring(0, 120) + "..." : sourceHint;
sb.append(" | src=").append(sanitizeMeta(src));
} }
sb.append("\n"); sb.append("\n");
for (ClaimLine line : claims) { for (ClaimLine line : claims) {
@@ -569,6 +771,14 @@ public final class TngMoneyPacketHook {
} }
return sb.toString().trim(); return sb.toString().trim();
} }
private static String sanitizeMeta(String value) {
if (value == null) {
return "";
}
// 避免空格拆坏 meta竖线是分隔符
return value.replace('|', '/').trim();
}
} }
private static final class ClaimLine { private static final class ClaimLine {