Files
2026-08-15 14:51:51 +08:00

651 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from flask import Response, request
from config import CONFIG
HOP_BY_HOP = {
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
"host",
"content-length",
}
INJECT_SCRIPT = """
<script>
(function () {
var CABLE_ORIGIN = "__CABLE_WS_ORIGIN__";
var NativeWS = window.WebSocket;
if (!NativeWS || window.__ydCablePatched) {
return;
}
window.__ydCablePatched = true;
function WrappedWS(url, protocols) {
var next = String(url || "");
if (next.indexOf("/cable") !== -1 && next.indexOf(CABLE_ORIGIN) !== 0) {
try {
var parsed = new URL(next, window.location.href);
next = CABLE_ORIGIN + "/cable" + (parsed.search || "") + (parsed.hash || "");
} catch (err) {
next = CABLE_ORIGIN + "/cable";
}
}
if (protocols === undefined) {
return new NativeWS(next);
}
return new NativeWS(next, protocols);
}
WrappedWS.prototype = NativeWS.prototype;
WrappedWS.CONNECTING = NativeWS.CONNECTING;
WrappedWS.OPEN = NativeWS.OPEN;
WrappedWS.CLOSING = NativeWS.CLOSING;
WrappedWS.CLOSED = NativeWS.CLOSED;
window.WebSocket = WrappedWS;
})();
</script>
<style>
/* 旧版深色外观(保留稳定功能,去掉 8/12 红头白底皮肤) */
a[href*="chatwoot.com"],
a[href*="Chatwoot"],
.branding--container,
.branding {
display: none !important;
}
html, body, #app, .conversation-wrap, .messages-wrap {
background: #090d1a !important;
color: #f5f7ff !important;
}
header {
background: #11182b !important;
color: #f5f7ff !important;
border: 0 !important;
border-bottom: 1px solid rgba(255,255,255,.09) !important;
box-shadow: none !important;
min-height: 64px !important;
display: flex !important;
align-items: center !important;
justify-content: space-between !important;
gap: 8px !important;
padding: 10px 12px !important;
}
header [data-yd-right] {
display: inline-flex !important;
align-items: center !important;
gap: 8px !important;
margin-left: auto !important;
}
header, header * {
color: #f5f7ff !important;
}
header button.close-button,
header button[data-yd-close],
header button[data-yd-exit] {
display: inline-flex !important;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
font-size: 26px;
line-height: 1;
color: #f5f7ff !important;
background: transparent !important;
border: 0;
cursor: pointer;
}
header button[data-yd-exit] svg {
width: 20px;
height: 20px;
display: block;
}
.agent-message .chat-bubble,
.agent-message-wrap .chat-bubble,
.prose-bubble {
background: #19233c !important;
color: #f5f7ff !important;
border-radius: 14px !important;
border: 1px solid rgba(255,255,255,.08) !important;
box-shadow: none !important;
}
.user-message .chat-bubble,
.user-message-wrap .chat-bubble {
background: rgba(123,97,255,.28) !important;
color: #f5f7ff !important;
border-radius: 14px !important;
border: 0 !important;
}
.chat-message--input,
.chat-footer,
footer {
background: #11182b !important;
border-top: 1px solid rgba(255,255,255,.09) !important;
}
textarea, input[type="text"] {
background: #0c1222 !important;
color: #f5f7ff !important;
border-radius: 12px !important;
border: 1px solid rgba(255,255,255,.09) !important;
}
[data-yd-panel],
[data-yd-promo],
img[data-yd-promo],
[data-yd-powered] {
display: none !important;
}
[data-yd-send] {
display: inline-grid !important;
place-items: center;
width: 36px;
height: 36px;
margin-left: 8px;
border: 0;
border-radius: 999px;
color: #17120a;
background: #ffcc4d;
font-size: 16px;
cursor: pointer;
}
</style>
<script>
(function () {
var PROMO_SRC = "/static/guest_promo.png?v=49";
var GREET_MARK = "欢迎光临【49彩票集团】";
var QUICK = [
{ label: "充值问题", text: "我想咨询充值问题", color: "#3b82f6", icon: "充" },
{ label: "提款问题", text: "我想咨询提款问题", color: "#22c55e", icon: "提" },
{ label: "优惠活动", text: "我想咨询优惠活动", color: "#f59e0b", icon: "惠" },
{ label: "账户问题", text: "我想咨询账户问题", color: "#8b5cf6", icon: "户" },
{ label: "游戏问题", text: "我想咨询游戏问题", color: "#a855f7", icon: "游" },
{ label: "代理合作", text: "我想咨询代理合作", color: "#f97316", icon: "代" },
{ label: "常见问题", text: "常见问题", color: "#ef4444", icon: "?" },
{ label: "转人工客服", text: "转人工客服", color: "#e31c23", icon: "人" }
];
var FAQ = [
"提款未到账怎么办?",
"如何修改登录密码?",
"如何绑定银行卡?"
];
function norm(text) {
return String(text || "").replace(/\\s+/g, "");
}
function hideChatwootBrand() {
// 只藏 branding,不要每次掃整頁 DOM(會卡死)
var nodes = document.querySelectorAll(
"a[href*='chatwoot'], .branding--container, .branding, .branding--text"
);
for (var i = 0; i < nodes.length; i += 1) {
nodes[i].style.setProperty("display", "none", "important");
}
}
function hideStartButtons() {
// 不再隱藏「开始会话/開始對話」:退出後要回到可點「开始会话」的首頁
}
function isMessagesView() {
var hash = String(window.location.hash || "");
return hash.indexOf("messages") !== -1;
}
function goToStartSessionScreen() {
try {
var url = new URL(window.location.href);
url.searchParams.delete("cw_conversation");
url.hash = "/";
window.location.replace(url.toString());
return;
} catch (err) {}
try {
if (
window.parent &&
typeof window.parent.exitGuestChatToStartSession === "function"
) {
window.parent.exitGuestChatToStartSession();
return;
}
} catch (err2) {}
try {
window.parent.postMessage(
"chatwoot-widget:" +
JSON.stringify({ event: "guestExitToStartSession" }),
"*"
);
} catch (err3) {}
}
function exitAndNewChat() {
// 舊名保留:實際改為回到「开始会话」首頁,不再強制開新對話進聊天室
goToStartSessionScreen();
}
function hideFileChrome() {
var nodes = document.querySelectorAll("a, button, div, span, p");
for (var i = 0; i < nodes.length; i += 1) {
var el = nodes[i];
var text = el.textContent || "";
if (
text.indexOf("guest_promo.png") === -1 &&
!(text.indexOf("下載") !== -1 && text.indexOf(".png") !== -1)
) {
continue;
}
var box = el.closest(".file") || el.parentElement;
if (box) {
box.style.setProperty("display", "none", "important");
}
}
}
function findGreetingNode() {
var nodes = document.querySelectorAll("p, div, span");
for (var i = 0; i < nodes.length; i += 1) {
var el = nodes[i];
if ((el.textContent || "").indexOf(GREET_MARK) === -1) {
continue;
}
if (el.querySelector && el.querySelector("p, div")) {
continue;
}
return el;
}
return null;
}
function closeWidget() {
try {
window.parent.postMessage(
"chatwoot-widget:" + JSON.stringify({ event: "closeWindow" }),
"*"
);
} catch (err) {}
}
var EXIT_ICON =
'<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">' +
'<path d="M10 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>' +
'<path d="M14 12H21" stroke="#fff" stroke-width="1.8" stroke-linecap="round"/>' +
'<path d="M18 8l4 4-4 4" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>' +
"</svg>";
function fillAndSend(text) {
var ta = document.querySelector("textarea");
if (!ta) {
return;
}
var setter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
"value"
);
if (setter && setter.set) {
setter.set.call(ta, text);
} else {
ta.value = text;
}
ta.dispatchEvent(new Event("input", { bubbles: true }));
ta.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", code: "Enter", bubbles: true })
);
}
function tuneHeader() {
var header = document.querySelector("header");
if (!header) {
return;
}
header.style.setProperty("background", "#11182b", "important");
header.style.setProperty("color", "#f5f7ff", "important");
header.style.setProperty("display", "flex", "important");
header.style.setProperty("align-items", "center", "important");
header.style.setProperty("justify-content", "space-between", "important");
var oldTitles = header.querySelectorAll("h1, h2, p, span, strong, div");
for (var ot = 0; ot < oldTitles.length; ot += 1) {
var node = oldTitles[ot];
if (
node.closest("[data-yd-title]") ||
node.closest("[data-yd-online]") ||
node.closest("[data-yd-right]") ||
node.getAttribute("data-yd-title") ||
node.getAttribute("data-yd-online") ||
node.getAttribute("data-yd-right") ||
node.tagName === "BUTTON" ||
(node.querySelector &&
node.querySelector(
"button, img, svg, [data-yd-title], [data-yd-online], [data-yd-right]"
))
) {
continue;
}
var t = norm(node.textContent);
if (!t || t === "×" || t === "在线" || t.indexOf("7x24") !== -1 || t === "在线客服") {
continue;
}
if (t.length <= 30) {
node.style.setProperty("display", "none", "important");
}
}
if (!header.querySelector("[data-yd-title]")) {
var box = document.createElement("div");
box.setAttribute("data-yd-title", "1");
box.style.cssText =
"display:flex;flex-direction:column;gap:2px;line-height:1.2;color:#f5f7ff;flex:1;";
box.innerHTML =
'<strong style="font-size:16px;color:#f5f7ff;">在线客服</strong>' +
'<span style="font-size:11px;opacity:.92;color:#9aa7c5;">7x24小时为您服务</span>';
header.insertBefore(box, header.firstChild);
}
var right = header.querySelector("[data-yd-right]");
if (!right) {
right = document.createElement("div");
right.setAttribute("data-yd-right", "1");
right.style.cssText =
"display:inline-flex;align-items:center;gap:8px;margin-left:auto;";
header.appendChild(right);
}
var online = header.querySelector("[data-yd-online]");
if (!online) {
online = document.createElement("span");
online.setAttribute("data-yd-online", "1");
online.style.cssText =
"display:inline-flex;align-items:center;gap:5px;font-size:12px;color:#f5f7ff;white-space:nowrap;";
online.innerHTML =
'<i style="width:8px;height:8px;border-radius:999px;background:#22c55e;display:inline-block;"></i>在线';
}
if (online.parentNode !== right) {
right.appendChild(online);
}
// 隱藏原生關閉鈕,改用「退出並開新聊天」圖示
var buttons = header.querySelectorAll("button");
for (var i = 0; i < buttons.length; i += 1) {
var btn = buttons[i];
if (btn.getAttribute("data-yd-exit")) {
continue;
}
btn.style.setProperty("display", "none", "important");
}
var exitBtn = header.querySelector("button[data-yd-exit]");
if (!exitBtn) {
exitBtn = document.createElement("button");
exitBtn.type = "button";
exitBtn.setAttribute("data-yd-exit", "1");
exitBtn.setAttribute("aria-label", "退出到开始会话");
exitBtn.title = "退出到开始会话";
exitBtn.innerHTML = EXIT_ICON;
exitBtn.addEventListener("click", function (event) {
event.preventDefault();
event.stopPropagation();
goToStartSessionScreen();
});
right.appendChild(exitBtn);
}
exitBtn.style.setProperty("display", "inline-flex", "important");
if (exitBtn.parentNode !== right) {
right.appendChild(exitBtn);
} else if (online.nextSibling !== exitBtn) {
right.appendChild(exitBtn);
}
}
function ensureQuickPanel() {
var greet = findGreetingNode();
if (!greet || !greet.parentNode) {
return;
}
var panel = document.querySelector("[data-yd-panel]");
if (!panel) {
panel = document.createElement("div");
panel.setAttribute("data-yd-panel", "1");
var hint = document.createElement("p");
hint.className = "yd-hint";
hint.textContent = "您可以点击下方快捷入口获取帮助";
panel.appendChild(hint);
var grid = document.createElement("div");
grid.className = "yd-grid";
QUICK.forEach(function (item) {
var b = document.createElement("button");
b.type = "button";
b.innerHTML =
'<span style="background:' +
item.color +
'">' +
item.icon +
"</span>" +
item.label;
b.addEventListener("click", function () {
fillAndSend(item.text);
});
grid.appendChild(b);
});
panel.appendChild(grid);
var faqTitle = document.createElement("div");
faqTitle.className = "yd-faq-title";
faqTitle.textContent = "猜你想问";
panel.appendChild(faqTitle);
var faq = document.createElement("div");
faq.className = "yd-faq";
FAQ.forEach(function (q) {
var b = document.createElement("button");
b.type = "button";
b.innerHTML = "<span>" + q + "</span><span>›</span>";
b.addEventListener("click", function () {
fillAndSend(q);
});
faq.appendChild(b);
});
panel.appendChild(faq);
}
// 固定插在歡迎語正下方(同氣泡內、緊接文字後)
if (greet.nextSibling !== panel) {
greet.parentNode.insertBefore(panel, greet.nextSibling);
}
}
function ensureComposer() {
var ta = document.querySelector("textarea");
if (!ta || !ta.parentNode) {
return;
}
if (!ta.placeholder || ta.placeholder.indexOf("訊息") !== -1 || ta.placeholder.indexOf("请输入信息") !== -1) {
ta.placeholder = "请输入您的问题...";
}
if (!document.querySelector("[data-yd-send]")) {
var send = document.createElement("button");
send.type = "button";
send.setAttribute("data-yd-send", "1");
send.setAttribute("aria-label", "发送");
send.textContent = "➤";
send.addEventListener("click", function () {
fillAndSend(ta.value || "");
});
ta.parentNode.appendChild(send);
}
var footer = document.querySelector(".chat-message--input, .chat-footer, footer") || ta.parentNode.parentNode;
var powered = document.querySelector("[data-yd-powered]");
if (footer && !powered) {
powered = document.createElement("div");
powered.setAttribute("data-yd-powered", "1");
footer.appendChild(powered);
}
if (powered) {
powered.textContent = "";
powered.style.setProperty("display", "none", "important");
}
}
function paintTheme() {
var userBubbles = document.querySelectorAll(
".user-message .chat-bubble, .user-message-wrap .chat-bubble"
);
for (var i = 0; i < userBubbles.length; i += 1) {
userBubbles[i].style.setProperty("background", "rgba(123,97,255,.28)", "important");
userBubbles[i].style.setProperty("color", "#f5f7ff", "important");
}
var agentBubbles = document.querySelectorAll(
".agent-message .chat-bubble, .agent-message-wrap .chat-bubble"
);
for (var j = 0; j < agentBubbles.length; j += 1) {
agentBubbles[j].style.setProperty("background", "#19233c", "important");
agentBubbles[j].style.setProperty("color", "#f5f7ff", "important");
}
ensureComposer();
// 旧版:不插入快捷面板 / 猜你想问
}
function paintPromo() {
// 首頁(开始会话)不套聊天室皮膚,避免蓋住「开始会话」
if (!isMessagesView()) {
hideChatwootBrand();
return;
}
hideStartButtons();
hideFileChrome();
hideChatwootBrand();
tuneHeader();
paintTheme();
var promo = document.querySelector("img[data-yd-promo], [data-yd-panel]");
if (promo) {
promo.remove();
}
}
setInterval(paintPromo, 1500);
document.addEventListener("DOMContentLoaded", paintPromo);
// ActionCable 若仍失敗,輪詢訊息補上坐席回覆
var lastMsgKey = "";
function pollAgentMessages() {
try {
var params = new URLSearchParams(window.location.search);
var token = params.get("website_token");
var cw = params.get("cw_conversation");
if (!token || !cw) {
return;
}
fetch(
"/api/v1/widget/messages?website_token=" +
encodeURIComponent(token) +
"&cw_conversation=" +
encodeURIComponent(cw),
{ credentials: "same-origin" }
)
.then(function (response) {
return response.json();
})
.then(function (data) {
var list = [];
if (data && Array.isArray(data.payload)) {
list = data.payload;
} else if (data && data.payload && Array.isArray(data.payload.messages)) {
list = data.payload.messages;
} else if (Array.isArray(data)) {
list = data;
} else if (data && Array.isArray(data.messages)) {
list = data.messages;
}
if (!list.length) {
return;
}
var maxId = 0;
for (var i = 0; i < list.length; i += 1) {
var id = Number((list[i] && list[i].id) || 0);
if (id > maxId) {
maxId = id;
}
}
var key = String(maxId) + ":" + String(list.length);
// 禁止整頁 reload:訊息一變就刷新會造成聊天室卡死/閃爍
lastMsgKey = key;
})
.catch(function () {});
} catch (err) {}
}
setInterval(pollAgentMessages, 2500);
setTimeout(pollAgentMessages, 1200);
})();
</script>
"""
def _cable_ws_origin() -> str:
from urllib.parse import urlparse
parsed = urlparse(CONFIG.chatwoot_guest_proxy_target or "http://127.0.0.1:3000")
scheme = "wss" if parsed.scheme == "https" else "ws"
host = parsed.netloc or "127.0.0.1:3000"
return f"{scheme}://{host}"
def _inject_script() -> str:
return INJECT_SCRIPT.replace("__CABLE_WS_ORIGIN__", _cable_ws_origin())
def _forward_headers() -> dict[str, str]:
headers: dict[str, str] = {}
for key, value in request.headers.items():
if key.lower() in HOP_BY_HOP:
continue
headers[key] = value
return headers
def proxy_chatwoot(subpath: str) -> Response:
target = CONFIG.chatwoot_guest_proxy_target.rstrip("/")
query = urlencode(request.args, doseq=True)
url = f"{target}/{subpath.lstrip('/')}"
if query:
url = f"{url}?{query}"
body = request.get_data() if request.method not in {"GET", "HEAD"} else None
forwarded = Request(
url,
data=body,
headers=_forward_headers(),
method=request.method,
)
try:
with urlopen(forwarded, timeout=30) as upstream:
payload = upstream.read()
status = upstream.status
content_type = upstream.headers.get("Content-Type", "")
response_headers = [
(key, value)
for key, value in upstream.headers.items()
if key.lower() not in HOP_BY_HOP
]
except HTTPError as exc:
payload = exc.read()
status = exc.code
content_type = exc.headers.get("Content-Type", "") if exc.headers else ""
response_headers = []
except URLError as exc:
return Response(
f"无法连线本机 Chatwoot:{exc.reason}",
status=502,
mimetype="text/plain; charset=utf-8",
)
if "text/html" in content_type.lower() and payload:
html = payload.decode("utf-8", "replace")
inject = _inject_script()
if "</head>" in html:
html = html.replace("</head>", inject + "</head>", 1)
elif "</body>" in html:
html = html.replace("</body>", inject + "</body>", 1)
else:
html += inject
payload = html.encode("utf-8")
return Response(payload, status=status, headers=response_headers)