feat: add wechat webui login server and page
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -33,3 +33,8 @@ __pycache__/
|
|||||||
# OS files
|
# OS files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# WeChat webui runtime/session artifacts
|
||||||
|
/wechat-webui/.session.json
|
||||||
|
/wechat-webui/webui.log
|
||||||
|
/wechat-webui/__pycache__/
|
||||||
|
|||||||
878
wechat-webui/index.html
Normal file
878
wechat-webui/index.html
Normal file
@@ -0,0 +1,878 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>WeChatPadPro 登录控制台</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f3f6ff;
|
||||||
|
--card: #ffffff;
|
||||||
|
--line: #d9e1f6;
|
||||||
|
--text: #1f2a44;
|
||||||
|
--muted: #5f6b87;
|
||||||
|
--primary: #2356d8;
|
||||||
|
--primary-deep: #1b43ad;
|
||||||
|
--danger: #c9343f;
|
||||||
|
--ok: #14b86e;
|
||||||
|
--mono: "SFMono-Regular", Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 500px at 80% -20%, #d9e7ff 0%, transparent 70%),
|
||||||
|
radial-gradient(800px 300px at 10% -10%, #eef4ff 0%, transparent 72%),
|
||||||
|
var(--bg);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
background: linear-gradient(120deg, #2448a5 0%, #2f66e3 100%);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 10px 24px rgba(35, 86, 216, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 5px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
background: rgba(255, 255, 255, 0.16);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.24);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ffd25f;
|
||||||
|
box-shadow: 0 0 0 0 rgba(255, 210, 95, 0.6);
|
||||||
|
animation: pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.online {
|
||||||
|
background: var(--ok);
|
||||||
|
box-shadow: 0 0 0 0 rgba(20, 184, 110, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
box-shadow: 0 0 0 9px rgba(255, 255, 255, 0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
box-shadow: 0 7px 20px rgba(30, 68, 163, 0.06);
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel h2 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #cbd7f7;
|
||||||
|
background: #fbfdff;
|
||||||
|
padding: 10px 11px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
select:focus,
|
||||||
|
textarea:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(35, 86, 216, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 9px;
|
||||||
|
margin-top: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.06s ease, opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.58;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-main {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(120deg, var(--primary), var(--primary-deep));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(120deg, #d73f4a, #b61e2a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-wrap {
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px dashed #cad4f0;
|
||||||
|
min-height: 330px;
|
||||||
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: #fcfdff;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-img {
|
||||||
|
width: min(330px, 100%);
|
||||||
|
border: 1px solid #dde5f8;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card {
|
||||||
|
border-radius: 11px;
|
||||||
|
border: 1px solid #dbe4fa;
|
||||||
|
background: #fbfdff;
|
||||||
|
padding: 10px;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-meta {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: #44506a;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.42;
|
||||||
|
color: #0f172a;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 360px;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
background: #f8fbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell">
|
||||||
|
<section class="top">
|
||||||
|
<div>
|
||||||
|
<h1 class="title">WeChatPadPro 登录控制台</h1>
|
||||||
|
<p class="subtitle">只保留扫码登录,状态与消息自动轮询</p>
|
||||||
|
</div>
|
||||||
|
<div class="status-pill">
|
||||||
|
<span id="onlineDot" class="dot"></span>
|
||||||
|
<span id="onlineText">未连接</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<section class="panel">
|
||||||
|
<h2>1. 登录配置</h2>
|
||||||
|
<div class="field">
|
||||||
|
<label for="qrEndpoint">二维码类型</label>
|
||||||
|
<select id="qrEndpoint">
|
||||||
|
<option value="GetLoginQrCodeNewX">NewX(默认)</option>
|
||||||
|
<option value="GetLoginQrCodePadX">PadX</option>
|
||||||
|
<option value="GetLoginQrCodeWin">Win</option>
|
||||||
|
<option value="GetLoginQrCodeMac">Mac</option>
|
||||||
|
<option value="GetLoginQrCodeAndroidPad">AndroidPad</option>
|
||||||
|
<option value="GetLoginQrCodeCar">Car</option>
|
||||||
|
<option value="GetLoginQrCodeA16">A16</option>
|
||||||
|
<option value="GetLoginQrCodeNew">New</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="startLoginBtn" class="btn-main">生成授权二维码</button>
|
||||||
|
<button id="clearLoginBtn" class="btn-danger">清除登录状态</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>2. 扫码窗口</h2>
|
||||||
|
<div class="qr-wrap">
|
||||||
|
<img id="qrImg" class="qr-img" alt="二维码" />
|
||||||
|
<div id="qrHint" class="empty">点击“生成授权二维码”后在这里显示二维码</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>状态日志</h2>
|
||||||
|
<textarea id="statusView" readonly></textarea>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const state = {
|
||||||
|
baseUrl: "http://127.0.0.1:18238",
|
||||||
|
authKey: "",
|
||||||
|
qrData: null,
|
||||||
|
isOnline: false,
|
||||||
|
statusKnown: false,
|
||||||
|
statusTimer: null,
|
||||||
|
messageTimer: null,
|
||||||
|
lastStatusSig: "",
|
||||||
|
lastStatusAlertSig: "",
|
||||||
|
lastStatusReqError: "",
|
||||||
|
seenMessageKeys: new Set(),
|
||||||
|
persistTimer: null
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_INTERVAL_MS = 2000;
|
||||||
|
const MESSAGE_INTERVAL_MS = 2000;
|
||||||
|
const MESSAGE_COUNT = 30;
|
||||||
|
const QR_HINT_DEFAULT = "点击“生成授权二维码”后在这里显示二维码";
|
||||||
|
const QR_HINT_ONLINE = "当前账号已登录,二维码已隐藏。掉线或清除登录状态后可重新生成。";
|
||||||
|
const QR_HINT_CHECKING = "检测到已保存会话,正在校验登录状态,请稍候。";
|
||||||
|
const QR_ENDPOINT_PRESET = [
|
||||||
|
{ value: "GetLoginQrCodeNewX", label: "NewX(默认)" },
|
||||||
|
{ value: "GetLoginQrCodePadX", label: "PadX" },
|
||||||
|
{ value: "GetLoginQrCodeWin", label: "Win" },
|
||||||
|
{ value: "GetLoginQrCodeMac", label: "Mac" },
|
||||||
|
{ value: "GetLoginQrCodeAndroidPad", label: "AndroidPad" },
|
||||||
|
{ value: "GetLoginQrCodeCar", label: "Car" },
|
||||||
|
{ value: "GetLoginQrCodeA16", label: "A16" },
|
||||||
|
{ value: "GetLoginQrCodeNew", label: "New" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const el = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
function log(line) {
|
||||||
|
const area = el("statusView");
|
||||||
|
const stamp = new Date().toLocaleTimeString();
|
||||||
|
area.value = `[${stamp}] ${line}\n` + area.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, method = "GET", payload = null) {
|
||||||
|
const options = { method, headers: {} };
|
||||||
|
if (payload) {
|
||||||
|
options.headers["Content-Type"] = "application/json";
|
||||||
|
options.body = JSON.stringify(payload);
|
||||||
|
}
|
||||||
|
const resp = await fetch(path, options);
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await resp.json();
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`响应不是 JSON: ${resp.status}`);
|
||||||
|
}
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(data.error || `请求失败: ${resp.status}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOnline(isOnline) {
|
||||||
|
state.isOnline = Boolean(isOnline);
|
||||||
|
const dot = el("onlineDot");
|
||||||
|
const text = el("onlineText");
|
||||||
|
if (state.isOnline) {
|
||||||
|
dot.classList.add("online");
|
||||||
|
text.textContent = "已在线";
|
||||||
|
} else {
|
||||||
|
dot.classList.remove("online");
|
||||||
|
text.textContent = "未在线";
|
||||||
|
}
|
||||||
|
refreshLoginUiByState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideQr(hintText = QR_HINT_DEFAULT) {
|
||||||
|
const img = el("qrImg");
|
||||||
|
const hint = el("qrHint");
|
||||||
|
img.src = "";
|
||||||
|
img.style.display = "none";
|
||||||
|
hint.textContent = hintText;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshLoginUiByState() {
|
||||||
|
const btn = el("startLoginBtn");
|
||||||
|
if (!btn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.authKey && !state.statusKnown) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = "状态检测中...";
|
||||||
|
hideQr(QR_HINT_CHECKING);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.isOnline) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = "当前已登录";
|
||||||
|
hideQr(QR_HINT_ONLINE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = "生成授权二维码";
|
||||||
|
if (state.qrData?.qrCodeBase64) {
|
||||||
|
renderQr(state.qrData);
|
||||||
|
} else {
|
||||||
|
hideQr(QR_HINT_DEFAULT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderQr(qrData) {
|
||||||
|
const img = el("qrImg");
|
||||||
|
const hint = el("qrHint");
|
||||||
|
const base64 = qrData?.qrCodeBase64 || "";
|
||||||
|
if (!base64) {
|
||||||
|
hideQr(QR_HINT_DEFAULT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
img.src = base64.startsWith("data:") ? base64 : `data:image/jpeg;base64,${base64}`;
|
||||||
|
img.style.display = "block";
|
||||||
|
hint.textContent = "请用微信扫码并在手机确认登录";
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearMessageList() {
|
||||||
|
const list = el("messageList");
|
||||||
|
if (list) {
|
||||||
|
list.innerHTML = `<div class="empty">暂无消息</div>`;
|
||||||
|
}
|
||||||
|
state.seenMessageKeys.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendMessages(items) {
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const list = el("messageList");
|
||||||
|
if (!list) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const firstEmpty = list.querySelector(".empty");
|
||||||
|
if (firstEmpty) {
|
||||||
|
firstEmpty.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
const dedupeKey =
|
||||||
|
item.id && item.id !== "-"
|
||||||
|
? `id:${item.id}|type:${item.type ?? "-"}`
|
||||||
|
: [
|
||||||
|
item.id ?? "-",
|
||||||
|
item.type ?? "-",
|
||||||
|
item.time ?? "-",
|
||||||
|
item.from ?? "-",
|
||||||
|
item.to ?? "-",
|
||||||
|
String(item.content ?? "").slice(0, 120)
|
||||||
|
].join("|");
|
||||||
|
|
||||||
|
if (state.seenMessageKeys.has(dedupeKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.seenMessageKeys.add(dedupeKey);
|
||||||
|
if (state.seenMessageKeys.size > 3500) {
|
||||||
|
const first = state.seenMessageKeys.values().next().value;
|
||||||
|
state.seenMessageKeys.delete(first);
|
||||||
|
}
|
||||||
|
|
||||||
|
const card = document.createElement("article");
|
||||||
|
card.className = "message-card";
|
||||||
|
|
||||||
|
const meta = document.createElement("div");
|
||||||
|
meta.className = "message-meta";
|
||||||
|
meta.innerHTML =
|
||||||
|
`<span>id: ${item.id ?? "-"}</span>` +
|
||||||
|
`<span>type: ${item.type ?? "-"}</span>` +
|
||||||
|
`<span>from: ${item.from ?? "-"}</span>` +
|
||||||
|
`<span>to: ${item.to ?? "-"}</span>`;
|
||||||
|
|
||||||
|
const content = document.createElement("div");
|
||||||
|
content.className = "message-content";
|
||||||
|
content.textContent = item.content ?? "-";
|
||||||
|
|
||||||
|
card.appendChild(meta);
|
||||||
|
card.appendChild(content);
|
||||||
|
list.prepend(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function queuePersistSession() {
|
||||||
|
if (state.persistTimer) {
|
||||||
|
clearTimeout(state.persistTimer);
|
||||||
|
}
|
||||||
|
state.persistTimer = setTimeout(() => {
|
||||||
|
persistSession(true);
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistSession(silent = true) {
|
||||||
|
try {
|
||||||
|
await api("/api/session", "POST", {
|
||||||
|
baseUrl: state.baseUrl,
|
||||||
|
authKey: state.authKey,
|
||||||
|
qrData: state.qrData || {},
|
||||||
|
qrEndpoint: el("qrEndpoint").value
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (!silent) {
|
||||||
|
log(`保存会话失败: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureStatusPolling() {
|
||||||
|
if (state.statusTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.statusTimer = setInterval(() => {
|
||||||
|
checkStatus(false);
|
||||||
|
}, STATUS_INTERVAL_MS);
|
||||||
|
log(`已启动自动状态轮询(${STATUS_INTERVAL_MS / 1000}秒)。`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureMessagePolling() {
|
||||||
|
if (state.messageTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.messageTimer = setInterval(() => {
|
||||||
|
pollMessages(false);
|
||||||
|
}, MESSAGE_INTERVAL_MS);
|
||||||
|
log(`已启动自动消息轮询(${MESSAGE_INTERVAL_MS / 1000}秒)。`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopMessagePolling(silent = false) {
|
||||||
|
if (!state.messageTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearInterval(state.messageTimer);
|
||||||
|
state.messageTimer = null;
|
||||||
|
if (!silent) {
|
||||||
|
log("已停止自动消息轮询。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStatusSig(data) {
|
||||||
|
const checkCode = data?.check?.Code;
|
||||||
|
const checkState = data?.check?.Data?.state ?? "-";
|
||||||
|
const onlineCode = data?.online?.Code;
|
||||||
|
const onlineText = data?.online?.Text || "-";
|
||||||
|
return `${checkCode}|${checkState}|${onlineCode}|${onlineText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logStatusIfChanged(data) {
|
||||||
|
const sig = buildStatusSig(data);
|
||||||
|
if (sig === state.lastStatusSig) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.lastStatusSig = sig;
|
||||||
|
const checkCode = data?.check?.Code;
|
||||||
|
const checkText = data?.check?.Text || "-";
|
||||||
|
const checkState = data?.check?.Data?.state;
|
||||||
|
const onlineCode = data?.online?.Code;
|
||||||
|
const onlineText = data?.online?.Text || "-";
|
||||||
|
log(
|
||||||
|
`状态: CheckLoginStatus=${checkCode}, state=${checkState ?? "-"}(${checkText}) | ` +
|
||||||
|
`GetLoginStatus=${onlineCode}(${onlineText})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportStatusAlerts(data) {
|
||||||
|
if (state.isOnline) {
|
||||||
|
state.lastStatusAlertSig = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alerts = [];
|
||||||
|
const checkCode = Number(data?.check?.Code);
|
||||||
|
const checkState = data?.check?.Data?.state;
|
||||||
|
const checkText = String(data?.check?.Text || "").trim();
|
||||||
|
const onlineCode = Number(data?.online?.Code);
|
||||||
|
const onlineText = String(data?.online?.Text || "").trim();
|
||||||
|
|
||||||
|
if (onlineCode !== 200 && onlineText) {
|
||||||
|
alerts.push(`账号未在线: ${onlineCode}(${onlineText})`);
|
||||||
|
}
|
||||||
|
if (onlineText.includes("版本过低")) {
|
||||||
|
alerts.push("检测到版本过低,请切换二维码类型(Win/Mac/AndroidPad)或升级后端组件。");
|
||||||
|
}
|
||||||
|
if (onlineText.includes("重新登录") || onlineText.includes("MMLoginStateNoLogin")) {
|
||||||
|
alerts.push("账号状态需要重新登录,请点击“生成授权二维码”重新扫码。");
|
||||||
|
}
|
||||||
|
if (checkCode === -3) {
|
||||||
|
alerts.push("当前流程需要验证码,若持续出现请改用支持验证码的登录流程。");
|
||||||
|
}
|
||||||
|
if (checkCode === 300 || checkCode === -2) {
|
||||||
|
alerts.push("授权或二维码状态异常(可能已失效),请重新生成授权二维码。");
|
||||||
|
}
|
||||||
|
if (checkCode < 0 && checkCode !== -2 && checkCode !== -3) {
|
||||||
|
alerts.push(`扫码状态异常: ${checkCode}(${checkText || "-"})`);
|
||||||
|
}
|
||||||
|
if (checkState === 4 && onlineCode !== 200) {
|
||||||
|
alerts.push("登录卡在新设备验证阶段,请在手机确认或换二维码类型重试。");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sig = alerts.join("||");
|
||||||
|
if (!sig) {
|
||||||
|
state.lastStatusAlertSig = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sig === state.lastStatusAlertSig) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.lastStatusAlertSig = sig;
|
||||||
|
alerts.forEach((line) => log(`异常: ${line}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildQrEndpointOptions(serverEndpoints) {
|
||||||
|
const select = el("qrEndpoint");
|
||||||
|
const current = select.value;
|
||||||
|
const allowed =
|
||||||
|
Array.isArray(serverEndpoints) && serverEndpoints.length > 0
|
||||||
|
? new Set(serverEndpoints)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const options = [];
|
||||||
|
QR_ENDPOINT_PRESET.forEach((item) => {
|
||||||
|
if (!allowed || allowed.has(item.value)) {
|
||||||
|
options.push(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (allowed) {
|
||||||
|
Array.from(allowed)
|
||||||
|
.sort()
|
||||||
|
.forEach((name) => {
|
||||||
|
if (!options.some((x) => x.value === name)) {
|
||||||
|
options.push({ value: name, label: name });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.length === 0) {
|
||||||
|
options.push(...QR_ENDPOINT_PRESET);
|
||||||
|
}
|
||||||
|
|
||||||
|
select.innerHTML = "";
|
||||||
|
options.forEach((item) => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = item.value;
|
||||||
|
opt.textContent = item.label;
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (current && options.some((x) => x.value === current)) {
|
||||||
|
select.value = current;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (options.some((x) => x.value === "GetLoginQrCodeNewX")) {
|
||||||
|
select.value = "GetLoginQrCodeNewX";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
select.value = options[0].value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
try {
|
||||||
|
const cfg = await api("/api/config");
|
||||||
|
if (cfg.baseUrl) {
|
||||||
|
state.baseUrl = cfg.baseUrl;
|
||||||
|
}
|
||||||
|
rebuildQrEndpointOptions(cfg.qrEndpoints);
|
||||||
|
|
||||||
|
if (cfg.hasDefaultAdminKey) {
|
||||||
|
log("已检测到后端默认 ADMIN_KEY,可直接生成授权二维码。");
|
||||||
|
} else {
|
||||||
|
log("后端没有默认 ADMIN_KEY,生成授权二维码会失败。请先在服务端 deploy/.env 配置。");
|
||||||
|
}
|
||||||
|
if (cfg.hasSavedSession) {
|
||||||
|
log("检测到已保存会话,正在恢复。");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log(`读取配置失败: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreSession() {
|
||||||
|
try {
|
||||||
|
const data = await api("/api/session");
|
||||||
|
const session = data.session || {};
|
||||||
|
if (session.baseUrl) {
|
||||||
|
state.baseUrl = session.baseUrl;
|
||||||
|
}
|
||||||
|
if (session.authKey) {
|
||||||
|
state.authKey = session.authKey;
|
||||||
|
state.statusKnown = false;
|
||||||
|
log("已恢复上次会话(authKey/baseUrl)。");
|
||||||
|
}
|
||||||
|
if (session.qrEndpoint) {
|
||||||
|
el("qrEndpoint").value = session.qrEndpoint;
|
||||||
|
}
|
||||||
|
if (session.qrData && typeof session.qrData === "object") {
|
||||||
|
state.qrData = session.qrData;
|
||||||
|
}
|
||||||
|
refreshLoginUiByState();
|
||||||
|
} catch (err) {
|
||||||
|
log(`恢复会话失败: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startLogin() {
|
||||||
|
if (state.isOnline) {
|
||||||
|
log("当前账号已在线,已禁止重复生成授权二维码。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
baseUrl: state.baseUrl,
|
||||||
|
qrEndpoint: el("qrEndpoint").value
|
||||||
|
};
|
||||||
|
const data = await api("/api/login/start", "POST", payload);
|
||||||
|
state.authKey = data.authKey || "";
|
||||||
|
state.qrData = data.qrData || null;
|
||||||
|
state.statusKnown = true;
|
||||||
|
if (data.qrEndpoint) {
|
||||||
|
el("qrEndpoint").value = data.qrEndpoint;
|
||||||
|
}
|
||||||
|
renderQr(state.qrData || {});
|
||||||
|
clearMessageList();
|
||||||
|
await persistSession(true);
|
||||||
|
log(`已生成 authKey: ${state.authKey}`);
|
||||||
|
ensureStatusPolling();
|
||||||
|
await checkStatus(true);
|
||||||
|
} catch (err) {
|
||||||
|
log(`生成授权二维码失败: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkStatus(logError = true) {
|
||||||
|
if (!state.authKey) {
|
||||||
|
state.statusKnown = true;
|
||||||
|
state.lastStatusReqError = "";
|
||||||
|
setOnline(false);
|
||||||
|
stopMessagePolling(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url =
|
||||||
|
`/api/login/status?authKey=${encodeURIComponent(state.authKey)}` +
|
||||||
|
`&baseUrl=${encodeURIComponent(state.baseUrl)}`;
|
||||||
|
const data = await api(url);
|
||||||
|
state.statusKnown = true;
|
||||||
|
state.lastStatusReqError = "";
|
||||||
|
setOnline(Boolean(data.isOnline));
|
||||||
|
logStatusIfChanged(data);
|
||||||
|
reportStatusAlerts(data);
|
||||||
|
queuePersistSession();
|
||||||
|
|
||||||
|
if (state.isOnline) {
|
||||||
|
if (state.qrData) {
|
||||||
|
state.qrData = null;
|
||||||
|
queuePersistSession();
|
||||||
|
}
|
||||||
|
ensureMessagePolling();
|
||||||
|
pollMessages(false);
|
||||||
|
} else {
|
||||||
|
stopMessagePolling(true);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = `查询状态失败: ${err.message}`;
|
||||||
|
if (logError || state.lastStatusReqError !== message) {
|
||||||
|
log(message);
|
||||||
|
}
|
||||||
|
state.lastStatusReqError = message;
|
||||||
|
state.statusKnown = false;
|
||||||
|
setOnline(false);
|
||||||
|
stopMessagePolling(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollMessages(logError = true) {
|
||||||
|
if (!state.authKey || !state.isOnline) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
baseUrl: state.baseUrl,
|
||||||
|
authKey: state.authKey,
|
||||||
|
count: MESSAGE_COUNT
|
||||||
|
};
|
||||||
|
const data = await api("/api/message/poll", "POST", payload);
|
||||||
|
if (data.received > 0) {
|
||||||
|
appendMessages(data.items);
|
||||||
|
log(`收到 ${data.received} 条消息。`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (logError) {
|
||||||
|
log(`自动拉取消息失败: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearLoginState() {
|
||||||
|
try {
|
||||||
|
const resp = await api("/api/login/clear", "POST", {
|
||||||
|
baseUrl: state.baseUrl,
|
||||||
|
authKey: state.authKey
|
||||||
|
});
|
||||||
|
const logout = resp.logout || {};
|
||||||
|
|
||||||
|
state.authKey = "";
|
||||||
|
state.qrData = null;
|
||||||
|
state.statusKnown = true;
|
||||||
|
state.lastStatusSig = "";
|
||||||
|
state.lastStatusAlertSig = "";
|
||||||
|
state.lastStatusReqError = "";
|
||||||
|
setOnline(false);
|
||||||
|
stopMessagePolling(true);
|
||||||
|
clearMessageList();
|
||||||
|
|
||||||
|
if (logout.attempted) {
|
||||||
|
if (logout.ok) {
|
||||||
|
log(`远端登出成功: ${logout.method} ${logout.path}`);
|
||||||
|
} else if (logout.error) {
|
||||||
|
log(`远端登出结果: ${logout.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log("已清除登录状态与持久化会话。");
|
||||||
|
} catch (err) {
|
||||||
|
log(`清除登录状态失败: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
el("startLoginBtn").addEventListener("click", startLogin);
|
||||||
|
el("clearLoginBtn").addEventListener("click", clearLoginState);
|
||||||
|
el("qrEndpoint").addEventListener("change", queuePersistSession);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
await loadConfig();
|
||||||
|
await restoreSession();
|
||||||
|
refreshLoginUiByState();
|
||||||
|
ensureStatusPolling();
|
||||||
|
if (state.authKey) {
|
||||||
|
await checkStatus(true);
|
||||||
|
} else {
|
||||||
|
state.statusKnown = true;
|
||||||
|
refreshLoginUiByState();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
743
wechat-webui/server.py
Normal file
743
wechat-webui/server.py
Normal file
@@ -0,0 +1,743 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""WeChatPadPro login/message web UI server."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from collections import deque
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterable
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parent
|
||||||
|
INDEX_FILE = ROOT_DIR / "index.html"
|
||||||
|
SESSION_FILE = ROOT_DIR / ".session.json"
|
||||||
|
DEFAULT_BASE_URL = os.environ.get("WCPP_API_BASE", "http://127.0.0.1:18238")
|
||||||
|
HOST = os.environ.get("WEBUI_HOST", "0.0.0.0")
|
||||||
|
PORT = int(os.environ.get("WEBUI_PORT", "18239"))
|
||||||
|
SESSION_LOCK = threading.Lock()
|
||||||
|
MESSAGE_CACHE_LOCK = threading.Lock()
|
||||||
|
SUPPORTED_QR_ENDPOINTS = {
|
||||||
|
"GetLoginQrCodeNewX",
|
||||||
|
"GetLoginQrCodePadX",
|
||||||
|
"GetLoginQrCodeWin",
|
||||||
|
"GetLoginQrCodeMac",
|
||||||
|
"GetLoginQrCodeAndroidPad",
|
||||||
|
"GetLoginQrCodeCar",
|
||||||
|
"GetLoginQrCodeA16",
|
||||||
|
"GetLoginQrCodeNew",
|
||||||
|
}
|
||||||
|
MESSAGE_CACHE_MAX_KEYS = 6000
|
||||||
|
MESSAGE_CACHE_RECENT_KEYS: dict[str, deque[str]] = {}
|
||||||
|
MESSAGE_CACHE_RECENT_SET: dict[str, set[str]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def load_admin_key() -> str:
|
||||||
|
env_key = os.environ.get("WCPP_ADMIN_KEY", "").strip()
|
||||||
|
if env_key:
|
||||||
|
return env_key
|
||||||
|
|
||||||
|
candidates = [
|
||||||
|
ROOT_DIR.parent / "deploy" / ".env",
|
||||||
|
ROOT_DIR.parent / ".env",
|
||||||
|
]
|
||||||
|
for env_file in candidates:
|
||||||
|
if not env_file.exists():
|
||||||
|
continue
|
||||||
|
text = env_file.read_text(encoding="utf-8", errors="ignore")
|
||||||
|
for raw_line in text.splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
if key.strip() == "ADMIN_KEY" and value.strip():
|
||||||
|
return value.strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_ADMIN_KEY = load_admin_key()
|
||||||
|
|
||||||
|
DEFAULT_SESSION_STATE: dict[str, Any] = {
|
||||||
|
"baseUrl": DEFAULT_BASE_URL,
|
||||||
|
"authKey": "",
|
||||||
|
"ticket": "",
|
||||||
|
"qrData": {},
|
||||||
|
"qrEndpoint": "GetLoginQrCodeNewX",
|
||||||
|
"updatedAt": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_session(data: Any) -> dict[str, Any]:
|
||||||
|
state = dict(DEFAULT_SESSION_STATE)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
base_url = data.get("baseUrl")
|
||||||
|
auth_key = data.get("authKey")
|
||||||
|
ticket = data.get("ticket")
|
||||||
|
qr_data = data.get("qrData")
|
||||||
|
qr_endpoint = data.get("qrEndpoint")
|
||||||
|
updated_at = data.get("updatedAt")
|
||||||
|
|
||||||
|
if isinstance(base_url, str) and base_url.strip():
|
||||||
|
state["baseUrl"] = base_url.strip()
|
||||||
|
if isinstance(auth_key, str):
|
||||||
|
state["authKey"] = auth_key.strip()
|
||||||
|
if isinstance(ticket, str):
|
||||||
|
state["ticket"] = ticket.strip()
|
||||||
|
if isinstance(qr_data, dict):
|
||||||
|
state["qrData"] = qr_data
|
||||||
|
if isinstance(qr_endpoint, str) and qr_endpoint.strip() in SUPPORTED_QR_ENDPOINTS:
|
||||||
|
state["qrEndpoint"] = qr_endpoint.strip()
|
||||||
|
if isinstance(updated_at, (int, float)):
|
||||||
|
state["updatedAt"] = int(updated_at)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def _write_session_file(state: dict[str, Any]) -> None:
|
||||||
|
tmp = SESSION_FILE.with_suffix(".json.tmp")
|
||||||
|
tmp.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
|
||||||
|
tmp.replace(SESSION_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
def load_session_state() -> dict[str, Any]:
|
||||||
|
if not SESSION_FILE.exists():
|
||||||
|
return dict(DEFAULT_SESSION_STATE)
|
||||||
|
try:
|
||||||
|
raw = json.loads(SESSION_FILE.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return dict(DEFAULT_SESSION_STATE)
|
||||||
|
return _normalize_session(raw)
|
||||||
|
|
||||||
|
|
||||||
|
SESSION_STATE: dict[str, Any] = load_session_state()
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_state() -> dict[str, Any]:
|
||||||
|
with SESSION_LOCK:
|
||||||
|
return dict(SESSION_STATE)
|
||||||
|
|
||||||
|
|
||||||
|
def update_session_state(patch: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
with SESSION_LOCK:
|
||||||
|
merged = dict(SESSION_STATE)
|
||||||
|
for key in ("baseUrl", "authKey", "ticket", "qrData", "qrEndpoint"):
|
||||||
|
if key in patch:
|
||||||
|
merged[key] = patch[key]
|
||||||
|
merged = _normalize_session(merged)
|
||||||
|
merged["updatedAt"] = int(time.time())
|
||||||
|
SESSION_STATE.clear()
|
||||||
|
SESSION_STATE.update(merged)
|
||||||
|
_write_session_file(SESSION_STATE)
|
||||||
|
return dict(SESSION_STATE)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_session_state() -> dict[str, Any]:
|
||||||
|
with SESSION_LOCK:
|
||||||
|
SESSION_STATE.clear()
|
||||||
|
SESSION_STATE.update(dict(DEFAULT_SESSION_STATE))
|
||||||
|
SESSION_STATE["updatedAt"] = int(time.time())
|
||||||
|
_write_session_file(SESSION_STATE)
|
||||||
|
return dict(SESSION_STATE)
|
||||||
|
|
||||||
|
|
||||||
|
def json_response(handler: BaseHTTPRequestHandler, status: int, payload: dict[str, Any]) -> None:
|
||||||
|
raw = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||||
|
handler.send_response(status)
|
||||||
|
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
handler.send_header("Content-Length", str(len(raw)))
|
||||||
|
handler.send_header("Cache-Control", "no-store")
|
||||||
|
handler.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
handler.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
|
handler.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
handler.end_headers()
|
||||||
|
handler.wfile.write(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def text_response(handler: BaseHTTPRequestHandler, status: int, content_type: str, payload: bytes) -> None:
|
||||||
|
handler.send_response(status)
|
||||||
|
handler.send_header("Content-Type", content_type)
|
||||||
|
handler.send_header("Content-Length", str(len(payload)))
|
||||||
|
handler.send_header("Cache-Control", "no-store")
|
||||||
|
handler.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
handler.end_headers()
|
||||||
|
handler.wfile.write(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_json_body(handler: BaseHTTPRequestHandler) -> dict[str, Any]:
|
||||||
|
length = int(handler.headers.get("Content-Length", "0") or "0")
|
||||||
|
if length == 0:
|
||||||
|
return {}
|
||||||
|
raw = handler.rfile.read(length)
|
||||||
|
try:
|
||||||
|
data = json.loads(raw.decode("utf-8"))
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ValueError(f"请求体 JSON 无效: {exc}") from exc
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("请求体必须是 JSON 对象")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def api_request(
|
||||||
|
base_url: str,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
key: str | None = None,
|
||||||
|
body: dict[str, Any] | None = None,
|
||||||
|
timeout: int = 25,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
base = base_url.rstrip("/")
|
||||||
|
url = f"{base}{path}"
|
||||||
|
if key:
|
||||||
|
joiner = "&" if "?" in url else "?"
|
||||||
|
url = f"{url}{joiner}key={urllib.parse.quote(key)}"
|
||||||
|
|
||||||
|
payload = None
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
if body is not None:
|
||||||
|
payload = json.dumps(body).encode("utf-8")
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
|
||||||
|
req = urllib.request.Request(url, data=payload, method=method.upper(), headers=headers)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = exc.read().decode("utf-8", errors="replace")
|
||||||
|
raise RuntimeError(f"HTTP {exc.code}: {detail}") from exc
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise RuntimeError(f"请求失败: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def flatten_wrapped_value(value: Any) -> Any:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
for name in ("str", "string", "String", "value", "Value"):
|
||||||
|
if name in value and value[name] not in (None, ""):
|
||||||
|
return value[name]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def find_first_value(data: Any, keys: Iterable[str]) -> Any:
|
||||||
|
targets = {k.lower() for k in keys}
|
||||||
|
queue: list[Any] = [data]
|
||||||
|
while queue:
|
||||||
|
current = queue.pop(0)
|
||||||
|
if isinstance(current, dict):
|
||||||
|
for k, v in current.items():
|
||||||
|
if k.lower() in targets:
|
||||||
|
found = flatten_wrapped_value(v)
|
||||||
|
if found not in (None, "", [], {}):
|
||||||
|
return found
|
||||||
|
queue.append(v)
|
||||||
|
elif isinstance(current, list):
|
||||||
|
queue.extend(current)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def to_display_value(value: Any) -> Any:
|
||||||
|
value = flatten_wrapped_value(value)
|
||||||
|
if isinstance(value, (dict, list)):
|
||||||
|
try:
|
||||||
|
return json.dumps(value, ensure_ascii=False)
|
||||||
|
except Exception:
|
||||||
|
return str(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def pick_primary_message(item: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
add_msgs = item.get("AddMsgs")
|
||||||
|
if isinstance(add_msgs, list):
|
||||||
|
for msg in add_msgs:
|
||||||
|
if isinstance(msg, dict):
|
||||||
|
return msg
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_message(item: Any) -> dict[str, Any]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
return {"content": str(item)}
|
||||||
|
|
||||||
|
primary = pick_primary_message(item)
|
||||||
|
|
||||||
|
outer_type = find_first_value(item, ["Type"])
|
||||||
|
inner_type = find_first_value(primary, ["msg_type", "MsgType", "ContentType", "Type"])
|
||||||
|
|
||||||
|
msg_id = find_first_value(primary, ["msg_id", "new_msg_id", "MsgId", "NewMsgId", "ClientMsgId", "id"])
|
||||||
|
from_user = find_first_value(
|
||||||
|
primary,
|
||||||
|
["from_user_name", "FromUserName", "FromWxid", "Sender", "Talker", "wxid", "userName"],
|
||||||
|
)
|
||||||
|
to_user = find_first_value(primary, ["to_user_name", "ToUserName", "ToWxid", "Receiver"])
|
||||||
|
create_time = find_first_value(primary, ["create_time", "CreateTime", "Timestamp", "Time", "MsgTime"])
|
||||||
|
content = find_first_value(primary, ["text_content", "TextContent", "content", "Content", "Message", "Text"])
|
||||||
|
|
||||||
|
if content in (None, "", {}, []):
|
||||||
|
# 部分系统消息正文在 msg_source 里
|
||||||
|
content = find_first_value(primary, ["msg_source", "MsgSource"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": to_display_value(msg_id),
|
||||||
|
"type": to_display_value(inner_type if inner_type not in (None, "") else outer_type),
|
||||||
|
"from": to_display_value(from_user),
|
||||||
|
"to": to_display_value(to_user),
|
||||||
|
"time": to_display_value(create_time),
|
||||||
|
"content": to_display_value(content),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_message_dedupe_key(summary: dict[str, Any], raw_item: Any) -> str:
|
||||||
|
msg_id = summary.get("id")
|
||||||
|
msg_type = summary.get("type")
|
||||||
|
if msg_id not in (None, "", "-"):
|
||||||
|
return f"id:{msg_id}|type:{msg_type}"
|
||||||
|
if isinstance(raw_item, dict):
|
||||||
|
fallback = find_first_value(raw_item, ["new_msg_id", "msg_id", "NewMsgId", "MsgId"])
|
||||||
|
if fallback not in (None, "", "-"):
|
||||||
|
return f"id:{fallback}|type:{msg_type}"
|
||||||
|
return "|".join(
|
||||||
|
[
|
||||||
|
f"type:{msg_type}",
|
||||||
|
f"from:{summary.get('from')}",
|
||||||
|
f"to:{summary.get('to')}",
|
||||||
|
f"time:{summary.get('time')}",
|
||||||
|
f"content:{str(summary.get('content') or '')[:120]}",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def seen_message_before(auth_key: str, dedupe_key: str) -> bool:
|
||||||
|
with MESSAGE_CACHE_LOCK:
|
||||||
|
msg_set = MESSAGE_CACHE_RECENT_SET.setdefault(auth_key, set())
|
||||||
|
if dedupe_key in msg_set:
|
||||||
|
return True
|
||||||
|
|
||||||
|
msg_queue = MESSAGE_CACHE_RECENT_KEYS.setdefault(auth_key, deque())
|
||||||
|
msg_queue.append(dedupe_key)
|
||||||
|
msg_set.add(dedupe_key)
|
||||||
|
|
||||||
|
while len(msg_queue) > MESSAGE_CACHE_MAX_KEYS:
|
||||||
|
old = msg_queue.popleft()
|
||||||
|
msg_set.discard(old)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def clear_message_cache(auth_key: str | None = None) -> None:
|
||||||
|
with MESSAGE_CACHE_LOCK:
|
||||||
|
if auth_key:
|
||||||
|
MESSAGE_CACHE_RECENT_KEYS.pop(auth_key, None)
|
||||||
|
MESSAGE_CACHE_RECENT_SET.pop(auth_key, None)
|
||||||
|
return
|
||||||
|
MESSAGE_CACHE_RECENT_KEYS.clear()
|
||||||
|
MESSAGE_CACHE_RECENT_SET.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def create_auth_key(base_url: str, admin_key: str, days: int, remark: str) -> str:
|
||||||
|
resp = api_request(
|
||||||
|
base_url=base_url,
|
||||||
|
method="POST",
|
||||||
|
path="/admin/GenAuthKey1",
|
||||||
|
key=admin_key,
|
||||||
|
body={"Count": 1, "Days": days, "Remark": remark},
|
||||||
|
)
|
||||||
|
if resp.get("Code") != 200:
|
||||||
|
raise RuntimeError(f"生成授权码失败: {json.dumps(resp, ensure_ascii=False)}")
|
||||||
|
data = resp.get("Data")
|
||||||
|
if isinstance(data, dict):
|
||||||
|
keys = data.get("authKeys") or data.get("AuthKeys")
|
||||||
|
if isinstance(keys, list) and keys:
|
||||||
|
return str(keys[0])
|
||||||
|
raise RuntimeError("授权码响应结构异常")
|
||||||
|
|
||||||
|
|
||||||
|
def get_login_qr(base_url: str, auth_key: str, qr_endpoint: str = "GetLoginQrCodeNewX") -> dict[str, Any]:
|
||||||
|
endpoint = qr_endpoint.strip() if qr_endpoint else "GetLoginQrCodeNewX"
|
||||||
|
if endpoint not in SUPPORTED_QR_ENDPOINTS:
|
||||||
|
raise RuntimeError(f"不支持的二维码接口: {endpoint}")
|
||||||
|
resp = api_request(
|
||||||
|
base_url=base_url,
|
||||||
|
method="POST",
|
||||||
|
path=f"/login/{endpoint}",
|
||||||
|
key=auth_key,
|
||||||
|
body={"Check": False, "Proxy": ""},
|
||||||
|
)
|
||||||
|
if resp.get("Code") != 200:
|
||||||
|
raise RuntimeError(f"获取二维码失败: {json.dumps(resp, ensure_ascii=False)}")
|
||||||
|
data = resp.get("Data")
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise RuntimeError("二维码响应结构异常")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def get_status_bundle(base_url: str, auth_key: str) -> dict[str, Any]:
|
||||||
|
check = api_request(base_url=base_url, method="GET", path="/login/CheckLoginStatus", key=auth_key)
|
||||||
|
online = api_request(base_url=base_url, method="GET", path="/login/GetLoginStatus", key=auth_key)
|
||||||
|
return {
|
||||||
|
"check": check,
|
||||||
|
"online": online,
|
||||||
|
"isOnline": online.get("Code") == 200,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def submit_verify(base_url: str, auth_key: str, code: str, ticket: str, data62: str, uuid: str) -> dict[str, Any]:
|
||||||
|
payload: dict[str, Any] = {"Code": code}
|
||||||
|
if ticket:
|
||||||
|
payload["Ticket"] = ticket
|
||||||
|
if data62:
|
||||||
|
payload["Data62"] = data62
|
||||||
|
if uuid:
|
||||||
|
payload["Uuid"] = uuid
|
||||||
|
return api_request(
|
||||||
|
base_url=base_url,
|
||||||
|
method="POST",
|
||||||
|
path="/login/YPayVerificationcode",
|
||||||
|
key=auth_key,
|
||||||
|
body=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def poll_messages(base_url: str, auth_key: str, count: int) -> dict[str, Any]:
|
||||||
|
raw = api_request(
|
||||||
|
base_url=base_url,
|
||||||
|
method="POST",
|
||||||
|
path="/message/HttpSyncMsg",
|
||||||
|
key=auth_key,
|
||||||
|
body={"Count": count},
|
||||||
|
)
|
||||||
|
data = raw.get("Data")
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
batch_seen: set[str] = set()
|
||||||
|
if isinstance(data, list):
|
||||||
|
for item in data:
|
||||||
|
summary = summarize_message(item)
|
||||||
|
dedupe_key = build_message_dedupe_key(summary, item)
|
||||||
|
if dedupe_key in batch_seen:
|
||||||
|
continue
|
||||||
|
batch_seen.add(dedupe_key)
|
||||||
|
if seen_message_before(auth_key, dedupe_key):
|
||||||
|
continue
|
||||||
|
summary["raw"] = item
|
||||||
|
items.append(summary)
|
||||||
|
return {
|
||||||
|
"raw": raw,
|
||||||
|
"items": items,
|
||||||
|
"received": len(items),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ext_device_confirm_get(base_url: str, auth_key: str, url: str) -> dict[str, Any]:
|
||||||
|
payload: dict[str, Any] = {}
|
||||||
|
if url:
|
||||||
|
payload["Url"] = url
|
||||||
|
return api_request(
|
||||||
|
base_url=base_url,
|
||||||
|
method="POST",
|
||||||
|
path="/login/ExtDeviceLoginConfirmGet",
|
||||||
|
key=auth_key,
|
||||||
|
body=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ext_device_confirm_ok(base_url: str, auth_key: str, url: str) -> dict[str, Any]:
|
||||||
|
payload: dict[str, Any] = {}
|
||||||
|
if url:
|
||||||
|
payload["Url"] = url
|
||||||
|
return api_request(
|
||||||
|
base_url=base_url,
|
||||||
|
method="POST",
|
||||||
|
path="/login/ExtDeviceLoginConfirmOk",
|
||||||
|
key=auth_key,
|
||||||
|
body=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def try_logout(base_url: str, auth_key: str) -> dict[str, Any]:
|
||||||
|
attempts: list[dict[str, Any]] = []
|
||||||
|
candidates: list[tuple[str, str, dict[str, Any] | None]] = [
|
||||||
|
("POST", "/login/LogOut", {}),
|
||||||
|
("GET", "/login/LogOut", None),
|
||||||
|
("POST", "/login/Logout", {}),
|
||||||
|
("GET", "/login/Logout", None),
|
||||||
|
]
|
||||||
|
for method, path, body in candidates:
|
||||||
|
try:
|
||||||
|
resp = api_request(base_url=base_url, method=method, path=path, key=auth_key, body=body)
|
||||||
|
attempts.append({"method": method, "path": path, "ok": True, "raw": resp})
|
||||||
|
code = resp.get("Code") if isinstance(resp, dict) else None
|
||||||
|
if code in (None, 200, 300, -2, -3):
|
||||||
|
return {
|
||||||
|
"attempted": True,
|
||||||
|
"ok": True,
|
||||||
|
"method": method,
|
||||||
|
"path": path,
|
||||||
|
"raw": resp,
|
||||||
|
"attempts": attempts,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
attempts.append({"method": method, "path": path, "ok": False, "error": str(exc)})
|
||||||
|
|
||||||
|
last_error = next((item["error"] for item in reversed(attempts) if not item.get("ok")), "logout not supported")
|
||||||
|
return {"attempted": True, "ok": False, "error": last_error, "attempts": attempts}
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "WCPPWebUI/1.0"
|
||||||
|
|
||||||
|
def log_message(self, fmt: str, *args: Any) -> None:
|
||||||
|
print(f"[webui] {self.address_string()} - {fmt % args}")
|
||||||
|
|
||||||
|
def do_OPTIONS(self) -> None:
|
||||||
|
self.send_response(204)
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
|
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
parsed = urllib.parse.urlparse(self.path)
|
||||||
|
if parsed.path in {"/", "/index.html"}:
|
||||||
|
if not INDEX_FILE.exists():
|
||||||
|
json_response(self, 500, {"error": "index.html 不存在"})
|
||||||
|
return
|
||||||
|
payload = INDEX_FILE.read_bytes()
|
||||||
|
text_response(self, 200, "text/html; charset=utf-8", payload)
|
||||||
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/health":
|
||||||
|
json_response(self, 200, {"ok": True})
|
||||||
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/api/config":
|
||||||
|
json_response(
|
||||||
|
self,
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"baseUrl": DEFAULT_BASE_URL,
|
||||||
|
"hasDefaultAdminKey": bool(DEFAULT_ADMIN_KEY),
|
||||||
|
"hasSavedSession": bool(get_session_state().get("authKey")),
|
||||||
|
"qrEndpoints": sorted(SUPPORTED_QR_ENDPOINTS),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/api/session":
|
||||||
|
json_response(self, 200, {"session": get_session_state()})
|
||||||
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/api/login/status":
|
||||||
|
query = urllib.parse.parse_qs(parsed.query)
|
||||||
|
auth_key = (query.get("authKey") or [""])[0].strip()
|
||||||
|
base_url = (query.get("baseUrl") or [DEFAULT_BASE_URL])[0].strip() or DEFAULT_BASE_URL
|
||||||
|
if not auth_key:
|
||||||
|
json_response(self, 400, {"error": "authKey 不能为空"})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
result = get_status_bundle(base_url=base_url, auth_key=auth_key)
|
||||||
|
update_session_state({"baseUrl": base_url, "authKey": auth_key})
|
||||||
|
except Exception as exc: # pragma: no cover
|
||||||
|
json_response(self, 500, {"error": str(exc)})
|
||||||
|
return
|
||||||
|
json_response(self, 200, result)
|
||||||
|
return
|
||||||
|
|
||||||
|
json_response(self, 404, {"error": "Not Found"})
|
||||||
|
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
parsed = urllib.parse.urlparse(self.path)
|
||||||
|
try:
|
||||||
|
data = parse_json_body(self)
|
||||||
|
except ValueError as exc:
|
||||||
|
json_response(self, 400, {"error": str(exc)})
|
||||||
|
return
|
||||||
|
|
||||||
|
base_url = str(data.get("baseUrl") or DEFAULT_BASE_URL).strip() or DEFAULT_BASE_URL
|
||||||
|
|
||||||
|
if parsed.path == "/api/login/start":
|
||||||
|
admin_key = str(data.get("adminKey") or DEFAULT_ADMIN_KEY).strip()
|
||||||
|
if not admin_key:
|
||||||
|
json_response(self, 400, {"error": "adminKey 不能为空(也未找到默认 ADMIN_KEY)"})
|
||||||
|
return
|
||||||
|
days = int(data.get("days") or 30)
|
||||||
|
remark = str(data.get("remark") or "webui-login").strip()
|
||||||
|
qr_endpoint = str(data.get("qrEndpoint") or "GetLoginQrCodeNewX").strip()
|
||||||
|
try:
|
||||||
|
auth_key = create_auth_key(base_url=base_url, admin_key=admin_key, days=days, remark=remark)
|
||||||
|
qr_data = get_login_qr(base_url=base_url, auth_key=auth_key, qr_endpoint=qr_endpoint)
|
||||||
|
clear_message_cache(auth_key)
|
||||||
|
update_session_state(
|
||||||
|
{
|
||||||
|
"baseUrl": base_url,
|
||||||
|
"authKey": auth_key,
|
||||||
|
"qrData": qr_data,
|
||||||
|
"ticket": "",
|
||||||
|
"qrEndpoint": qr_endpoint,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc: # pragma: no cover
|
||||||
|
json_response(self, 500, {"error": str(exc)})
|
||||||
|
return
|
||||||
|
json_response(
|
||||||
|
self,
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"authKey": auth_key,
|
||||||
|
"qrEndpoint": qr_endpoint,
|
||||||
|
"qrData": qr_data,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/api/login/wakeup":
|
||||||
|
auth_key = str(data.get("authKey") or "").strip()
|
||||||
|
if not auth_key:
|
||||||
|
json_response(self, 400, {"error": "authKey 不能为空"})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
resp = api_request(
|
||||||
|
base_url=base_url,
|
||||||
|
method="POST",
|
||||||
|
path="/login/WakeUpLogin",
|
||||||
|
key=auth_key,
|
||||||
|
body={"Check": False, "Proxy": ""},
|
||||||
|
)
|
||||||
|
update_session_state({"baseUrl": base_url, "authKey": auth_key})
|
||||||
|
except Exception as exc: # pragma: no cover
|
||||||
|
json_response(self, 500, {"error": str(exc)})
|
||||||
|
return
|
||||||
|
json_response(self, 200, {"raw": resp})
|
||||||
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/api/login/verify":
|
||||||
|
auth_key = str(data.get("authKey") or "").strip()
|
||||||
|
code = str(data.get("code") or "").strip()
|
||||||
|
ticket = str(data.get("ticket") or "").strip()
|
||||||
|
data62 = str(data.get("data62") or "").strip()
|
||||||
|
uuid = str(data.get("uuid") or "").strip()
|
||||||
|
if not auth_key:
|
||||||
|
json_response(self, 400, {"error": "authKey 不能为空"})
|
||||||
|
return
|
||||||
|
if not code:
|
||||||
|
json_response(self, 400, {"error": "code 不能为空"})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
resp = submit_verify(
|
||||||
|
base_url=base_url,
|
||||||
|
auth_key=auth_key,
|
||||||
|
code=code,
|
||||||
|
ticket=ticket,
|
||||||
|
data62=data62,
|
||||||
|
uuid=uuid,
|
||||||
|
)
|
||||||
|
update_session_state({"baseUrl": base_url, "authKey": auth_key, "ticket": ticket})
|
||||||
|
except Exception as exc: # pragma: no cover
|
||||||
|
json_response(self, 500, {"error": str(exc)})
|
||||||
|
return
|
||||||
|
json_response(self, 200, {"raw": resp})
|
||||||
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/api/login/ext-confirm/get":
|
||||||
|
auth_key = str(data.get("authKey") or "").strip()
|
||||||
|
url = str(data.get("url") or "").strip()
|
||||||
|
if not auth_key:
|
||||||
|
json_response(self, 400, {"error": "authKey 不能为空"})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
resp = ext_device_confirm_get(base_url=base_url, auth_key=auth_key, url=url)
|
||||||
|
update_session_state({"baseUrl": base_url, "authKey": auth_key})
|
||||||
|
except Exception as exc: # pragma: no cover
|
||||||
|
json_response(self, 500, {"error": str(exc)})
|
||||||
|
return
|
||||||
|
json_response(self, 200, {"raw": resp})
|
||||||
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/api/login/ext-confirm/ok":
|
||||||
|
auth_key = str(data.get("authKey") or "").strip()
|
||||||
|
url = str(data.get("url") or "").strip()
|
||||||
|
if not auth_key:
|
||||||
|
json_response(self, 400, {"error": "authKey 不能为空"})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
resp = ext_device_confirm_ok(base_url=base_url, auth_key=auth_key, url=url)
|
||||||
|
update_session_state({"baseUrl": base_url, "authKey": auth_key})
|
||||||
|
except Exception as exc: # pragma: no cover
|
||||||
|
json_response(self, 500, {"error": str(exc)})
|
||||||
|
return
|
||||||
|
json_response(self, 200, {"raw": resp})
|
||||||
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/api/login/clear":
|
||||||
|
session = get_session_state()
|
||||||
|
auth_key = str(data.get("authKey") or session.get("authKey") or "").strip()
|
||||||
|
current_base_url = str(data.get("baseUrl") or session.get("baseUrl") or DEFAULT_BASE_URL).strip() or DEFAULT_BASE_URL
|
||||||
|
logout_result: dict[str, Any] = {"attempted": False, "ok": False}
|
||||||
|
|
||||||
|
if auth_key:
|
||||||
|
logout_result = try_logout(base_url=current_base_url, auth_key=auth_key)
|
||||||
|
|
||||||
|
try:
|
||||||
|
next_state = clear_session_state()
|
||||||
|
clear_message_cache(auth_key or None)
|
||||||
|
except Exception as exc: # pragma: no cover
|
||||||
|
json_response(self, 500, {"error": str(exc)})
|
||||||
|
return
|
||||||
|
|
||||||
|
json_response(self, 200, {"session": next_state, "logout": logout_result})
|
||||||
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/api/session":
|
||||||
|
try:
|
||||||
|
next_state = update_session_state(data)
|
||||||
|
except Exception as exc: # pragma: no cover
|
||||||
|
json_response(self, 500, {"error": str(exc)})
|
||||||
|
return
|
||||||
|
json_response(self, 200, {"session": next_state})
|
||||||
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/api/session/clear":
|
||||||
|
old_auth_key = str(get_session_state().get("authKey") or "").strip()
|
||||||
|
try:
|
||||||
|
next_state = clear_session_state()
|
||||||
|
clear_message_cache(old_auth_key or None)
|
||||||
|
except Exception as exc: # pragma: no cover
|
||||||
|
json_response(self, 500, {"error": str(exc)})
|
||||||
|
return
|
||||||
|
json_response(self, 200, {"session": next_state})
|
||||||
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/api/message/poll":
|
||||||
|
auth_key = str(data.get("authKey") or "").strip()
|
||||||
|
count = int(data.get("count") or 30)
|
||||||
|
if not auth_key:
|
||||||
|
json_response(self, 400, {"error": "authKey 不能为空"})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
result = poll_messages(base_url=base_url, auth_key=auth_key, count=count)
|
||||||
|
update_session_state({"baseUrl": base_url, "authKey": auth_key})
|
||||||
|
except Exception as exc: # pragma: no cover
|
||||||
|
json_response(self, 500, {"error": str(exc)})
|
||||||
|
return
|
||||||
|
json_response(self, 200, result)
|
||||||
|
return
|
||||||
|
|
||||||
|
json_response(self, 404, {"error": "Not Found"})
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
||||||
|
print(f"[webui] listening on http://{HOST}:{PORT}")
|
||||||
|
print(f"[webui] default api base: {DEFAULT_BASE_URL}")
|
||||||
|
print(f"[webui] default admin key loaded: {bool(DEFAULT_ADMIN_KEY)}")
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user