773 lines
32 KiB
Python
773 lines
32 KiB
Python
#!/usr/bin/env python3
|
||
"""notiMessage 本地调试转发服务 — 在 PC 浏览器查看手机抓取的消息。"""
|
||
|
||
import json
|
||
import re
|
||
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"])
|
||
# 发放时间:issued= / createTime= / issuedAt=
|
||
for key in ("issued", "createTime", "issuedAt", "create"):
|
||
if meta.get(key):
|
||
meta["issued"] = meta[key]
|
||
break
|
||
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 "?"
|
||
try:
|
||
amt = float(c.get("amountValue") if c.get("amountValue") is not None
|
||
else _normalize_money(c.get("amount")) or 0.0)
|
||
except (TypeError, ValueError):
|
||
amt = 0.0
|
||
if nick not in buckets:
|
||
buckets[nick] = {"nickname": nick, "amount": amt, "count": 1,
|
||
"claimTime": c.get("claimTime") or ""}
|
||
order.append(nick)
|
||
continue
|
||
b = buckets[nick]
|
||
b["count"] += 1
|
||
if amt >= b["amount"]:
|
||
b["amount"] = amt
|
||
if c.get("claimTime"):
|
||
b["claimTime"] = c.get("claimTime") or b["claimTime"]
|
||
elif 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)
|
||
issued = meta.get("issued") or ""
|
||
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 "",
|
||
"issuedAt": issued,
|
||
"fetchedAt": msg.get("receivedAt") or "",
|
||
"latestAt": issued or (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)
|
||
# 优先保留「有发放时间 + 领取更全」的快照;时间戳用发放时间排序
|
||
def _score(it):
|
||
return (
|
||
1 if it.get("issuedAt") else 0,
|
||
len(it.get("claims") or []),
|
||
it.get("fetchedAt") or "",
|
||
)
|
||
if existing is None or _score(item) >= _score(existing):
|
||
if existing is not None:
|
||
item["snapshots"] = int(existing.get("snapshots") or 1) + 1
|
||
item["rawMessages"] = existing.get("rawMessages", []) + item["rawMessages"]
|
||
if not item.get("issuedAt") and existing.get("issuedAt"):
|
||
item["issuedAt"] = existing["issuedAt"]
|
||
item["latestAt"] = item["issuedAt"] or item.get("fetchedAt") or ""
|
||
packets[key] = item
|
||
else:
|
||
existing["snapshots"] = int(existing.get("snapshots") or 1) + 1
|
||
existing["rawMessages"].append(item["rawMessages"][0])
|
||
if not existing.get("issuedAt") and issued:
|
||
existing["issuedAt"] = issued
|
||
existing["latestAt"] = issued
|
||
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: _mmp_time_sort_key(x.get("issuedAt") or x.get("latestAt") or ""), reverse=True)
|
||
return result
|
||
|
||
|
||
def _mmp_time_sort_key(text):
|
||
"""把 13/07/2026 11:54:44 / ISO 等统一成可比较字符串。"""
|
||
s = (text or "").strip()
|
||
m = re.match(r"^(\d{2})/(\d{2})/(\d{4})(?:\s+(\d{2}:\d{2}(?::\d{2})?))?", s)
|
||
if m:
|
||
return "{0}-{1}-{2} {3}".format(m.group(3), m.group(2), m.group(1), m.group(4) or "")
|
||
return s
|
||
|
||
|
||
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 后停留任意页面即可自动拉历史并拉详情;也可打开 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.issuedAt ? ('发放 ' + p.issuedAt) : '发放时间未知')}</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.issuedAt) parts.push('发放 ' + p.issuedAt);
|
||
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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
}
|
||
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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
}
|
||
loadMessages();
|
||
setInterval(loadMessages, 2000);
|
||
</script>
|
||
</body>
|
||
</html>"""
|
||
return html.encode("utf-8")
|
||
|
||
|
||
class Handler(BaseHTTPRequestHandler):
|
||
def log_message(self, fmt, *args):
|
||
if self.path.startswith("/api/messages") and self.command == "GET":
|
||
return
|
||
BaseHTTPRequestHandler.log_message(self, fmt, *args)
|
||
|
||
def do_OPTIONS(self):
|
||
self.send_response(204)
|
||
self.send_header("Access-Control-Allow-Origin", "*")
|
||
self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
|
||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||
self.end_headers()
|
||
|
||
def do_GET(self):
|
||
path = urlparse(self.path).path
|
||
if path == "/":
|
||
body = _html_page()
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self.end_headers()
|
||
self.wfile.write(body)
|
||
return
|
||
if path == "/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()
|