Files
ai-customer-service/guest-cs-stack/_aug12_image_flask/chatwoot_proxy.py
T
2026-08-15 14:51:51 +08:00

725 lines
22 KiB
Python
Raw 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>
a[href*="chatwoot.com"],
a[href*="Chatwoot"],
.branding--container,
.branding {
display: none !important;
}
html, body, #app, .conversation-wrap, .messages-wrap {
background: #f5f6f8 !important;
}
header {
background: #e31c23 !important;
color: #fff !important;
border: 0 !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: #fff !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: #fff !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: #fff !important;
color: #222 !important;
border-radius: 14px !important;
border: 0 !important;
box-shadow: 0 1px 2px rgba(0,0,0,.06) !important;
}
.user-message .chat-bubble,
.user-message-wrap .chat-bubble {
background: #ffe5e5 !important;
color: #222 !important;
border-radius: 14px !important;
border: 0 !important;
}
.chat-message--input,
.chat-footer,
footer {
background: #fff !important;
border-top: 1px solid #eee !important;
}
textarea, input[type="text"] {
background: #f2f3f5 !important;
color: #222 !important;
border-radius: 999px !important;
border: 0 !important;
}
[data-yd-panel] {
margin: 10px 0 4px;
padding: 0;
font-size: 13px;
color: #333;
width: 100%;
max-width: 320px;
}
[data-yd-panel] .yd-hint {
margin: 0 0 10px;
color: #666;
}
[data-yd-panel] .yd-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
}
[data-yd-panel] .yd-grid button {
display: flex;
align-items: center;
gap: 6px;
min-height: 42px;
padding: 8px 10px;
border: 1px solid #e6e6e6;
border-radius: 10px;
background: #fff;
color: #333;
text-align: left;
cursor: pointer;
font-size: 12px;
}
[data-yd-panel] .yd-grid button span {
display: inline-grid;
place-items: center;
width: 22px;
height: 22px;
border-radius: 6px;
color: #fff;
font-size: 11px;
font-weight: 700;
}
[data-yd-panel] .yd-faq-title {
margin: 14px 0 6px;
font-size: 14px;
font-weight: 800;
}
[data-yd-panel] .yd-faq button {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
padding: 12px 2px;
border: 0;
border-bottom: 1px solid #eee;
background: transparent;
color: #333;
text-align: left;
cursor: pointer;
}
[data-yd-send] {
display: inline-grid !important;
place-items: center;
width: 36px;
height: 36px;
margin-left: 8px;
border: 0;
border-radius: 999px;
color: #fff;
background: #e31c23;
font-size: 16px;
cursor: pointer;
}
[data-yd-powered] {
display: block;
padding: 4px 0 8px;
color: #b0b0b0;
font-size: 11px;
text-align: center;
}
</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() {
var nodes = document.querySelectorAll("a, p, span, div, small");
for (var i = 0; i < nodes.length; i += 1) {
var el = nodes[i];
var text = norm(el.textContent);
if (
text.indexOf("Chatwoot") === -1 &&
text.indexOf("chatwoot") === -1 &&
text.indexOf("由Chatwoot支援") === -1
) {
continue;
}
if (text.length > 40) {
continue;
}
el.style.setProperty("display", "none", "important");
}
}
function hideStartButtons() {
var nodes = document.querySelectorAll("button, a, .button, [role='button']");
for (var i = 0; i < nodes.length; i += 1) {
var el = nodes[i];
if (
norm(el.textContent).indexOf("開始對話") === -1 &&
norm(el.textContent).indexOf("开始对话") === -1
) {
continue;
}
el.style.setProperty("display", "none", "important");
el.style.setProperty("visibility", "hidden", "important");
}
}
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) {}
}
function exitAndNewChat() {
try {
if (
window.parent &&
typeof window.parent.exitGuestChatAndStartNew === "function"
) {
window.parent.exitGuestChatAndStartNew();
return;
}
if (
window.parent &&
typeof window.parent.openGuestChatNewConversation === "function"
) {
window.parent.openGuestChatNewConversation();
return;
}
} catch (err) {}
try {
window.parent.postMessage(
"chatwoot-widget:" +
JSON.stringify({ event: "guestExitNewChat" }),
"*"
);
} catch (err2) {}
}
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", "#e31c23", "important");
header.style.setProperty("color", "#fff", "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:#fff;flex:1;";
box.innerHTML =
'<strong style="font-size:16px;color:#fff;">在线客服</strong>' +
'<span style="font-size:11px;opacity:.92;color:#fff;">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:#fff;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();
exitAndNewChat();
});
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 = "49彩票AI智能客服";
}
}
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", "#ffe5e5", "important");
userBubbles[i].style.setProperty("color", "#222", "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", "#fff", "important");
agentBubbles[j].style.setProperty("color", "#222", "important");
}
ensureComposer();
ensureQuickPanel();
}
function paintPromo() {
hideStartButtons();
hideFileChrome();
hideChatwootBrand();
tuneHeader();
paintTheme();
if (document.querySelector("img[data-yd-promo]")) {
return;
}
var greet = findGreetingNode();
if (!greet || !greet.parentNode) {
return;
}
var img = document.createElement("img");
img.setAttribute("data-yd-promo", "1");
img.src = PROMO_SRC;
img.alt = "";
img.style.cssText =
"display:block;width:100%;max-width:280px;border-radius:12px;margin:0 0 8px;";
greet.parentNode.insertBefore(img, greet);
}
setInterval(paintPromo, 250);
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);
if (lastMsgKey && key !== lastMsgKey) {
window.location.reload();
return;
}
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)