#!/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 = """ 红包领取台

红包领取台

0 个红包 - ← 返回消息台
暂无红包数据
在手机打开 TNG「红包」详情或排行榜后,领取列表会显示在这里
""" return html.encode("utf-8") def _html_page(): html = """ notiMessage 调试台

notiMessage 调试台

0 条 0 群 - 红包领取台 →

请选择左侧群聊

""" 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()