最初版本

This commit is contained in:
sa19840531
2026-08-15 14:51:51 +08:00
parent 4e01df4dce
commit ea47ea4449
176 changed files with 21009 additions and 0 deletions
@@ -0,0 +1,627 @@
from datetime import datetime
from decimal import Decimal, InvalidOperation
from functools import wraps
import hashlib
import hmac
import logging
import secrets
from typing import Any, Callable
from flask import (
Flask,
abort,
flash,
jsonify,
redirect,
render_template,
request,
session,
url_for,
)
from pymysql import MySQLError
from werkzeug.exceptions import HTTPException
from chatwoot_guest import ChatwootGuestError, create_guest_conversation
from chatwoot_proxy import proxy_chatwoot
from config import CONFIG
from maxkb_chatwoot_bridge import handle_chatwoot_webhook
from repository import MappingError, PlatformRepository, decimal_string
import threading
app = Flask(__name__)
app.config.update(
JSON_AS_ASCII=False,
SECRET_KEY=CONFIG.secret_key,
SESSION_COOKIE_NAME="api_interface_session",
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
SESSION_COOKIE_SECURE=CONFIG.session_cookie_secure,
PERMANENT_SESSION_LIFETIME=28800,
MAX_CONTENT_LENGTH=1024 * 1024,
)
logging.basicConfig(
level=logging.DEBUG if CONFIG.debug else logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
repo = PlatformRepository()
def payload() -> dict[str, Any]:
data: dict[str, Any] = {}
body = request.get_json(silent=True)
if isinstance(body, dict):
data.update(body)
data.update(request.form.to_dict())
data.update(request.args.to_dict())
return data
def api_auth(view: Callable):
@wraps(view)
def wrapped(*args, **kwargs):
if CONFIG.api_key:
provided = request.headers.get("X-API-Key", "")
if not hmac.compare_digest(provided, CONFIG.api_key):
return jsonify(
{"error": "unauthorized", "message": "API Key 無效"}
), 401
return view(*args, **kwargs)
return wrapped
def login_required(view: Callable):
@wraps(view)
def wrapped(*args, **kwargs):
if not session.get("username"):
return redirect(url_for("login"))
return view(*args, **kwargs)
return wrapped
def csrf_token() -> str:
token = session.get("csrf_token")
if not token:
token = secrets.token_urlsafe(32)
session["csrf_token"] = token
return str(token)
def verify_csrf() -> None:
received = str(request.form.get("csrf_token", ""))
saved = str(session.get("csrf_token", ""))
if not received or not saved or not hmac.compare_digest(received, saved):
abort(403, description="CSRF 验证失败")
def required_text(
data: dict[str, Any],
name: str,
max_length: int = 191,
) -> str:
value = str(data.get(name, "")).strip()
if not value:
raise ValueError(f"{name} 為必填")
if len(value) > max_length:
raise ValueError(f"{name} 長度不可超過 {max_length}")
return value
def is_api_request() -> bool:
return request.path == "/health" or request.path.startswith("/platform/")
def update_member_session(account: dict[str, Any]) -> None:
"""將目前會員資料同步到 Flask Session。"""
session["username"] = account.get("username_clean") or account.get("username") or ""
session["username_clean"] = account.get("username_clean") or ""
session["raw_username"] = account.get("raw_username") or ""
session["uid"] = account.get("uid")
session["account_status"] = account.get("status") or "normal"
session["locked_reason"] = account.get("locked_reason") or ""
session["member_type"] = account.get("member_type") or ""
session["vip_level"] = account.get("vip_level") or ""
session["tier"] = account.get("tier") or ""
def build_chatwoot_user() -> dict[str, Any]:
"""建立傳給 Chatwoot 前端 SDK 的安全會員資料。
identifier 直接使用 username_clean,作為 Chatwoot 抓取會員名的關鍵標識。
顯示名稱與自訂屬性同樣使用 username_clean。
"""
username = str(
session.get("username_clean")
or session.get("username")
or ""
).strip()
uid = session.get("uid")
# Chatwoot 的 identifier 直接使用登入會員名。
# 例如:yongle001,而不是 member-311。
identifier = username if username else ""
identifier_hash = ""
if identifier and CONFIG.chatwoot_identity_secret:
identifier_hash = hmac.new(
CONFIG.chatwoot_identity_secret.encode("utf-8"),
identifier.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return {
"is_authenticated": bool(identifier),
"visitor_type": "member" if identifier else "guest",
"identifier": identifier,
"identifier_hash": identifier_hash,
"name": username,
"username": username,
"uid": uid,
"account_status": str(
session.get("account_status", "")
),
"locked_reason": str(
session.get("locked_reason", "")
),
"member_type": str(
session.get("member_type", "")
),
"vip_level": str(
session.get("vip_level", "")
),
"tier": str(
session.get("tier", "")
),
}
@app.context_processor
def inject_template_values():
use_guest_widget = request.path.startswith("/guest-customer-service")
guest_token = CONFIG.chatwoot_guest_website_token
if use_guest_widget and guest_token:
# 依访客实际访问的 host(IP 或 test.pubuli.com)加载 /cw,避免写死域名
base_url = f"{request.scheme}://{request.host}/cw"
else:
base_url = CONFIG.chatwoot_base_url
website_token = (
guest_token
if use_guest_widget and guest_token
else CONFIG.chatwoot_website_token
)
chatwoot_config = {
"enabled": bool(
CONFIG.chatwoot_enabled
and base_url
and website_token
),
"base_url": base_url,
"website_token": website_token,
"debug": CONFIG.chatwoot_debug,
"csrf_token": csrf_token(),
"guest_new_path": "/guest-chat/new",
}
return {
"app_name": CONFIG.app_name,
"csrf_token": csrf_token,
"chatwoot_config": chatwoot_config,
"chatwoot_user": build_chatwoot_user(),
# 只在登出後首次渲染未登入頁面時為 True。
# 使用 pop() 確保該標記只消費一次。
"chatwoot_guest_reset_required": bool(
session.pop(
"chatwoot_guest_reset_required",
False,
)
),
}
@app.get("/")
def root():
if session.get("username"):
return redirect(url_for("home"))
return redirect(url_for("login"))
@app.get("/guest-customer-service")
def guest_customer_service():
"""遊客客服頁:不登入、不查會員資料,只載入匿名 Chatwoot。"""
return render_template(
"guest_customer_service.html",
is_guest=True,
)
@app.route(
"/cw/",
defaults={"subpath": ""},
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
)
@app.route(
"/cw/<path:subpath>",
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
)
def chatwoot_guest_proxy(subpath: str):
"""把本機 Chatwoot widget 代理進來,才能注入略過「開始對話」的腳本。"""
return proxy_chatwoot(subpath)
@app.route(
"/vite/<path:subpath>",
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
)
def chatwoot_vite_proxy(subpath: str):
"""widget HTML 用絕對路徑 /vite/assets,需一併代理。"""
return proxy_chatwoot(f"vite/{subpath}")
@app.route(
"/api/v1/widget/",
defaults={"subpath": ""},
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
)
@app.route(
"/api/v1/widget/<path:subpath>",
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
)
def chatwoot_widget_api_proxy(subpath: str):
"""widget 會打同源 /api/v1/widget。"""
suffix = f"api/v1/widget/{subpath}" if subpath else "api/v1/widget"
return proxy_chatwoot(suffix)
@app.route(
"/rails/<path:subpath>",
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
)
def chatwoot_rails_proxy(subpath: str):
"""歡迎圖走 Active Storage,widget 會打同源 /rails/active_storage。"""
return proxy_chatwoot(f"rails/{subpath}")
@app.post("/guest-chat/new")
def guest_chat_new():
"""為遊客預先建立 Chatwoot 對話,前端 setUser 後可略過「開始對話」。"""
received = str(request.headers.get("X-CSRF-Token", ""))
saved = str(session.get("csrf_token", ""))
if not received or not saved or not hmac.compare_digest(received, saved):
return jsonify(
{"error": "csrf_failed", "message": "CSRF 验证失败"}
), 403
try:
created = create_guest_conversation()
except ChatwootGuestError as exc:
app.logger.exception("建立游客对话失败")
return jsonify(
{"error": "chatwoot_guest_failed", "message": str(exc)}
), 502
identifier = created["identifier"]
identifier_hash = ""
if identifier and CONFIG.chatwoot_identity_secret:
identifier_hash = hmac.new(
CONFIG.chatwoot_identity_secret.encode("utf-8"),
identifier.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return jsonify(
{
"identifier": identifier,
"identifier_hash": identifier_hash,
"name": created["name"],
"source_id": created.get("source_id") or "",
"widget_token": created.get("widget_token") or "",
}
)
@app.post("/webhook/bot1/chatwoot")
def chatwoot_maxkb_webhook():
"""本機 Chatwoot Agent Bot → PAI MaxKB 橋接(對齊正式站 :6666 路徑)。"""
body = request.get_json(silent=True)
if not isinstance(body, dict):
return jsonify({"ok": False, "error": "invalid_json"}), 400
# Chatwoot SafeFetch 讀超時約 20s;MaxKB 常更久,先快速 200 再背景回覆。
def _run() -> None:
try:
result = handle_chatwoot_webhook(body)
app.logger.info("Chatwoot→MaxKB 橋接完成: %s", result)
except Exception:
app.logger.exception("Chatwoot→MaxKB 橋接失敗")
threading.Thread(target=_run, daemon=True).start()
return jsonify({"ok": True, "accepted": True})
@app.route("/login", methods=["GET", "POST"])
def login():
if session.get("username"):
return redirect(url_for("home"))
username = ""
if request.method == "POST":
verify_csrf()
username = str(request.form.get("username", "")).strip()
if not username or len(username) > 191:
flash("请输入有效的用户名", "error")
else:
try:
account = repo.get_user_account(username)
except Exception as exc:
app.logger.exception("会员登录查询失败")
flash(f"登录验证失败:{exc}", "error")
else:
if not account["is_exist"]:
flash("该用户名不存在于 members 表", "error")
else:
session.clear()
update_member_session(account)
session.permanent = True
csrf_token()
return redirect(url_for("home"))
return render_template("login.html", username=username)
@app.get("/home")
@login_required
def home():
username = str(session["username"])
warning = ""
account = {
"username": username,
"username_clean": username,
"raw_username": str(session.get("raw_username", username)),
"status": str(session.get("account_status", "normal")),
"locked_reason": str(session.get("locked_reason", "")),
}
try:
refreshed = repo.get_user_account(username)
if refreshed["is_exist"]:
account = refreshed
update_member_session(refreshed)
except Exception as exc:
app.logger.exception("帳戶狀態刷新失敗")
warning = str(exc)
games = [
{"icon": "🎰", "name": "幸運拉霸", "category": "電子遊戲"},
{"icon": "🃏", "name": "經典百家樂", "category": "真人遊戲"},
{"icon": "⚽", "name": "體育競猜", "category": "體育遊戲"},
{"icon": "🎲", "name": "骰寶大廳", "category": "桌面遊戲"},
{"icon": "🐉", "name": "龍虎鬥", "category": "真人遊戲"},
{"icon": "🚀", "name": "飛行挑戰", "category": "休閒遊戲"},
]
return render_template(
"home.html",
username=username,
account=account,
warning=warning,
games=games,
)
@app.get("/customer-service")
@login_required
def customer_service():
username = str(session["username"])
withdrawals: list[dict[str, Any]] = []
deposits: list[dict[str, Any]] = []
account: dict[str, Any] = {}
error = ""
try:
account = repo.get_user_account(username)
if account.get("is_exist"):
update_member_session(account)
withdrawals = repo.get_withdraw_orders(username, unfinished_only=True)
deposits = repo.get_deposit_orders(username)[:10]
except Exception as exc:
app.logger.exception("客服頁資料查詢失敗")
error = str(exc)
return render_template(
"customer_service.html",
username=username,
account=account,
withdrawals=withdrawals,
deposits=deposits,
error=error,
)
@app.post("/logout")
@login_required
def logout():
verify_csrf()
# 清除會員 Session 後,保留一個一次性標記。
# 登出後首次載入未登入頁面時,前端會再次 reset Chatwoot,
# 避免上一位會員身份殘留在匿名訪客頁面。
session.clear()
session["chatwoot_guest_reset_required"] = True
return redirect(url_for("login"))
@app.get("/health")
@app.get("/platform/health")
def health():
return jsonify(
{
"service": CONFIG.app_name,
"status": "ok",
"read_only_mode": CONFIG.read_only_mode,
}
)
@app.get("/platform/get-user-account")
@api_auth
def get_user_account():
username = required_text(payload(), "username")
return jsonify(repo.get_user_account(username))
@app.get("/platform/withdraw-orders")
@api_auth
def withdraw_orders():
username = required_text(payload(), "username")
return jsonify(repo.get_withdraw_orders(username, unfinished_only=False))
@app.get("/platform/withdraw-orders-unfinish")
@api_auth
def withdraw_orders_unfinish():
username = required_text(payload(), "username")
return jsonify(repo.get_withdraw_orders(username, unfinished_only=True))
@app.get("/platform/deposit-orders")
@app.get("/platform/deposit-not-received")
@api_auth
def deposit_orders():
data = payload()
username = required_text(data, "username")
amount: Decimal | None = None
raw_amount = str(data.get("amount", "")).strip()
if raw_amount:
try:
amount = Decimal(raw_amount)
except InvalidOperation as exc:
raise ValueError("amount 格式不正確") from exc
created_at: datetime | None = None
raw_created_at = str(data.get("created_at", "")).strip()
if raw_created_at:
try:
created_at = datetime.fromisoformat(
raw_created_at.replace("Z", "+00:00")
)
if created_at.tzinfo is not None:
created_at = created_at.astimezone().replace(tzinfo=None)
except ValueError as exc:
raise ValueError("created_at 必須為 ISO-8601 日期時間") from exc
return jsonify(repo.get_deposit_orders(username, amount, created_at))
@app.route("/platform/apply-activity", methods=["POST", "PUT"])
@api_auth
def apply_activity():
data = payload()
username = required_text(data, "username")
activity_id = required_text(data, "activity_id", max_length=64)
return jsonify(repo.apply_activity(username, activity_id))
@app.route("/platform/fix-balance", methods=["POST", "PUT"])
@api_auth
def fix_balance():
username = required_text(payload(), "username")
account = repo.get_user_account(username)
if not account["is_exist"]:
return jsonify({"error": "not_found", "message": "找不到用戶"}), 404
balance = repo.get_balance(username)
repo.log_balance_fix(username, balance)
return jsonify(
{
"username": account["username_clean"],
"balance": decimal_string(balance),
"simulated": True,
"message": "唯讀測試模式:僅回傳 members.balance,未修改資料庫",
}
)
@app.errorhandler(403)
def handle_forbidden_error(exc: HTTPException):
message = getattr(exc, "description", "拒絕存取")
if is_api_request():
return jsonify({"error": "forbidden", "message": message}), 403
return render_template(
"error.html", title="請求驗證失敗", message=message
), 403
@app.errorhandler(ValueError)
def handle_value_error(exc: ValueError):
if is_api_request():
return jsonify({"error": "bad_request", "message": str(exc)}), 400
return render_template(
"error.html", title="請求錯誤", message=str(exc)
), 400
@app.errorhandler(MappingError)
def handle_mapping_error(exc: MappingError):
if is_api_request():
return jsonify({"error": "mapping_error", "message": str(exc)}), 500
return render_template(
"error.html", title="資料映射錯誤", message=str(exc)
), 500
@app.errorhandler(MySQLError)
def handle_mysql_error(exc: MySQLError):
app.logger.exception("Database error")
message = str(exc) if CONFIG.debug else "資料庫查詢失敗,請檢查連線與欄位設定"
if is_api_request():
return jsonify({"error": "database_error", "message": message}), 500
return render_template(
"error.html", title="資料庫錯誤", message=message
), 500
@app.errorhandler(HTTPException)
def handle_http_exception(exc: HTTPException):
status_code = int(exc.code or 500)
message = str(exc.description or exc.name)
if is_api_request():
return jsonify(
{
"error": exc.name.lower().replace(" ", "_"),
"message": message,
}
), status_code
return render_template(
"error.html", title=exc.name, message=message
), status_code
@app.errorhandler(Exception)
def handle_unexpected_error(exc: Exception):
app.logger.exception("Unexpected error")
if is_api_request():
return jsonify(
{"error": "internal_error", "message": "系統內部錯誤"}
), 500
return render_template(
"error.html", title="系統錯誤", message="系統內部錯誤"
), 500
if __name__ == "__main__":
app.logger.info(
"啟動 %s host=%s port=%s read_only=%s",
CONFIG.app_name,
CONFIG.host,
CONFIG.port,
CONFIG.read_only_mode,
)
app.run(host=CONFIG.host, port=CONFIG.port, debug=CONFIG.debug)
@@ -0,0 +1,256 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import time
import uuid
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from config import CONFIG
_AUTH_CACHE: dict[str, Any] = {"headers": None, "expires_at": 0.0}
DEFAULT_GREETING = (
"您好,欢迎光临【49彩票集团】客服服务中心,请问有什么可以帮到您的呢?"
)
class ChatwootGuestError(RuntimeError):
pass
def _request(
method: str,
path: str,
headers: dict[str, str],
payload: dict[str, Any] | None = None,
) -> tuple[int, dict[str, str], Any]:
body = None
req_headers = dict(headers)
if payload is not None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req_headers["Content-Type"] = "application/json"
req_headers["Accept"] = "application/json"
api_base = (
CONFIG.chatwoot_guest_api_base or CONFIG.chatwoot_base_url
).rstrip("/")
request = Request(
f"{api_base}{path}",
data=body,
headers=req_headers,
method=method,
)
try:
with urlopen(request, timeout=20) as response:
raw = response.read().decode("utf-8", "replace")
data = json.loads(raw) if raw else {}
return response.status, dict(response.headers.items()), data
except HTTPError as exc:
raw = exc.read().decode("utf-8", "replace")
try:
data = json.loads(raw) if raw else {}
except json.JSONDecodeError:
data = {"message": raw[:300]}
raise ChatwootGuestError(
f"Chatwoot {method} {path} 失敗:{exc.code} {data}"
) from exc
except URLError as exc:
raise ChatwootGuestError(f"無法連線 Chatwoot:{exc.reason}") from exc
def _login_headers(*, force: bool = False) -> dict[str, str]:
email = CONFIG.chatwoot_guest_api_email or CONFIG.chatwoot_api_email
password = CONFIG.chatwoot_guest_api_password or CONFIG.chatwoot_api_password
if not email or not password:
raise ChatwootGuestError("未設定 Chatwoot API 帳號密碼")
now = time.time()
cached = _AUTH_CACHE.get("headers")
if not force and cached and now < float(_AUTH_CACHE.get("expires_at") or 0):
return cached
status, headers, _data = _request(
"POST",
"/auth/sign_in",
{},
{
"email": email,
"password": password,
},
)
if status >= 400:
raise ChatwootGuestError("Chatwoot 登入失敗")
token = headers.get("access-token") or headers.get("Access-Token") or ""
client = headers.get("client") or headers.get("Client") or ""
uid = headers.get("uid") or headers.get("Uid") or ""
if not token or not client or not uid:
raise ChatwootGuestError("Chatwoot 登入未回傳 token")
auth_headers = {
"access-token": token,
"client": client,
"uid": uid,
"token-type": "Bearer",
}
_AUTH_CACHE["headers"] = auth_headers
_AUTH_CACHE["expires_at"] = now + 300
return auth_headers
def _dig(data: Any, *keys: str) -> Any:
current = data
for key in keys:
if not isinstance(current, dict):
return None
current = current.get(key)
return current
def _source_id(*payloads: Any) -> str:
for payload in payloads:
if not isinstance(payload, dict):
continue
body = payload.get("payload") or payload
candidates = [
_dig(body, "contact_inbox", "source_id"),
_dig(payload, "contact_inbox", "source_id"),
_dig(
payload,
"last_non_activity_message",
"conversation",
"contact_inbox",
"source_id",
),
]
messages = body.get("messages") if isinstance(body, dict) else None
if isinstance(messages, list) and messages:
candidates.append(
_dig(
messages[0],
"conversation",
"contact_inbox",
"source_id",
)
)
for value in candidates:
if value:
return str(value)
return ""
def _contact_id(payload: Any) -> int | None:
if not isinstance(payload, dict):
return None
body = payload.get("payload") or payload
if not isinstance(body, dict):
return None
contact = body.get("contact") or body
if not isinstance(contact, dict):
return None
contact_id = contact.get("id")
return int(contact_id) if contact_id is not None else None
def create_guest_conversation() -> dict[str, Any]:
"""建立新的遊客聯絡人與對話,讓 widget 直接進入聊天室。"""
identifier = f"guest-{uuid.uuid4().hex[:16]}"
headers = _login_headers()
account_id = CONFIG.chatwoot_account_id
try:
return _create_with_headers(identifier, headers, account_id)
except ChatwootGuestError as exc:
if "401" not in str(exc) and "403" not in str(exc):
raise
headers = _login_headers(force=True)
return _create_with_headers(identifier, headers, account_id)
def _create_with_headers(
identifier: str,
headers: dict[str, str],
account_id: int,
) -> dict[str, Any]:
_, _, contact_body = _request(
"POST",
f"/api/v1/accounts/{account_id}/contacts",
headers,
{
"inbox_id": CONFIG.chatwoot_guest_inbox_id or CONFIG.chatwoot_inbox_id,
"name": "遊客",
"identifier": identifier,
"custom_attributes": {"visitor_type": "guest"},
},
)
contact_id = _contact_id(contact_body)
if not contact_id:
raise ChatwootGuestError("Chatwoot 未回傳 contact id")
source_id = _source_id(contact_body)
if not source_id:
raise ChatwootGuestError("Chatwoot 未回傳 source_id")
# 必須帶同一個 source_id。只傳 contact_id + inbox_id 時,
# ContactInboxBuilder 會再產生一組 WebWidget UUID,
# 歡迎語會掛在新 session,widget JWT 對不到就變成空白對話。
# 歡迎圖不走 Chatwoot 附件,否則 widget 會顯示檔名與「下載」。
_, _, conversation = _request(
"POST",
f"/api/v1/accounts/{account_id}/conversations",
headers,
{
"inbox_id": CONFIG.chatwoot_guest_inbox_id or CONFIG.chatwoot_inbox_id,
"contact_id": contact_id,
"source_id": source_id,
"status": "open",
"message": {
"content": CONFIG.chatwoot_greeting or DEFAULT_GREETING,
"message_type": "outgoing",
},
},
)
conversation_id = conversation.get("id") if isinstance(conversation, dict) else None
return {
"identifier": identifier,
"contact_id": contact_id,
"conversation_id": conversation_id,
"source_id": source_id,
"widget_token": make_widget_token(source_id),
"name": "遊客",
}
def make_widget_token(source_id: str) -> str:
"""產生 Chatwoot widget 的 cw_conversation JWT。"""
secret = CONFIG.chatwoot_widget_jwt_secret
if not secret:
raise ChatwootGuestError("未設定 CHATWOOT_WIDGET_JWT_SECRET")
now = int(time.time())
header = _b64url(json.dumps({"alg": "HS256"}, separators=(",", ":")).encode())
payload = _b64url(
json.dumps(
{
"source_id": source_id,
"inbox_id": CONFIG.chatwoot_guest_inbox_id or CONFIG.chatwoot_inbox_id,
"iat": now,
"exp": now + 60 * 60 * 24 * 180,
},
separators=(",", ":"),
).encode()
)
signing_input = f"{header}.{payload}".encode()
signature = hmac.new(secret.encode("utf-8"), signing_input, hashlib.sha256).digest()
return f"{header}.{payload}.{_b64url(signature)}"
def _b64url(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,113 @@
import os
from dataclasses import dataclass
from pathlib import Path
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent
load_dotenv(BASE_DIR / ".env", override=True)
def env_bool(name: str, default: bool = False) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
@dataclass(frozen=True)
class Config:
app_name: str = os.getenv("APP_NAME", "API接口系统")
host: str = os.getenv("APP_HOST", os.getenv("API_HOST", "0.0.0.0"))
port: int = int(os.getenv("APP_PORT", os.getenv("API_PORT", "7788")))
debug: bool = env_bool("APP_DEBUG", env_bool("API_DEBUG", False))
secret_key: str = os.getenv("SECRET_KEY", "replace-this-secret-key")
api_key: str = os.getenv("API_KEY", "change-me")
session_cookie_secure: bool = env_bool("SESSION_COOKIE_SECURE", False)
db_host: str = os.getenv("DB_HOST", "127.0.0.1")
db_port: int = int(os.getenv("DB_PORT", "3306"))
db_user: str = os.getenv("DB_USER", "api_reader")
db_password: str = os.getenv("DB_PASSWORD", "")
db_name: str = os.getenv("DB_NAME", "member_system")
db_charset: str = os.getenv("DB_CHARSET", "utf8mb4")
db_connect_timeout: int = int(os.getenv("DB_CONNECT_TIMEOUT", "5"))
members_table: str = os.getenv("MEMBERS_TABLE", "members")
withdraw_table: str = os.getenv("WITHDRAW_TABLE", "withdraw_records")
withdraw_request_table: str = os.getenv(
"WITHDRAW_REQUEST_TABLE", "withdraw_request_records"
)
deposit_table: str = os.getenv("DEPOSIT_TABLE", "deposit_records")
read_only_mode: bool = env_bool("READ_ONLY_MODE", True)
auto_init_sim_tables: bool = env_bool("AUTO_INIT_SIM_TABLES", False)
default_activity_id: str = os.getenv("DEFAULT_ACTIVITY_ID", "ad-x1234")
deposit_search_hours: int = int(os.getenv("DEPOSIT_SEARCH_HOURS", "24"))
max_orders: int = int(os.getenv("MAX_ORDERS", "50"))
# Chatwoot 網站客服設定
chatwoot_enabled: bool = env_bool("CHATWOOT_ENABLED", True)
chatwoot_base_url: str = os.getenv(
"CHATWOOT_BASE_URL",
"http://pai.pubuli.com",
).rstrip("/")
chatwoot_website_token: str = os.getenv(
"CHATWOOT_WEBSITE_TOKEN",
"Vivum4g1WWxH1AmxoTivAcX5",
)
# 必須使用 Chatwoot Website Inbox 的 Identity Validation Token。
# 此值只保留在後端,絕不可輸出到 HTML 或 JavaScript。
chatwoot_identity_secret: str = os.getenv(
"CHATWOOT_IDENTITY_SECRET",
"",
)
chatwoot_debug: bool = env_bool("CHATWOOT_DEBUG", False)
chatwoot_account_id: int = int(os.getenv("CHATWOOT_ACCOUNT_ID", "1"))
chatwoot_inbox_id: int = int(os.getenv("CHATWOOT_INBOX_ID", "1"))
chatwoot_api_email: str = os.getenv("CHATWOOT_API_EMAIL", "")
chatwoot_api_password: str = os.getenv("CHATWOOT_API_PASSWORD", "")
chatwoot_greeting: str = os.getenv(
"CHATWOOT_GREETING",
"您好,欢迎光临【49彩票集团】客服服务中心,请问有什么可以帮到您的呢?",
)
chatwoot_greeting_image: str = os.getenv(
"CHATWOOT_GREETING_IMAGE",
"static/guest_promo.png",
)
chatwoot_guest_base_url: str = os.getenv(
"CHATWOOT_GUEST_BASE_URL",
"",
).rstrip("/")
chatwoot_guest_website_token: str = os.getenv(
"CHATWOOT_GUEST_WEBSITE_TOKEN",
"",
)
chatwoot_guest_proxy_target: str = os.getenv(
"CHATWOOT_GUEST_PROXY_TARGET",
"http://127.0.0.1:3000",
).rstrip("/")
chatwoot_guest_api_base: str = os.getenv(
"CHATWOOT_GUEST_API_BASE",
"",
).rstrip("/")
chatwoot_guest_api_email: str = os.getenv("CHATWOOT_GUEST_API_EMAIL", "")
chatwoot_guest_api_password: str = os.getenv("CHATWOOT_GUEST_API_PASSWORD", "")
chatwoot_guest_inbox_id: int = int(os.getenv("CHATWOOT_GUEST_INBOX_ID", "1"))
chatwoot_widget_jwt_secret: str = os.getenv("CHATWOOT_WIDGET_JWT_SECRET", "")
chatwoot_agent_bot_id: int = int(os.getenv("CHATWOOT_AGENT_BOT_ID", "0") or "0")
chatwoot_agent_bot_token: str = os.getenv("CHATWOOT_AGENT_BOT_TOKEN", "")
# 本機 Chatwoot → PAI MaxKB 橋接
maxkb_bridge_enabled: bool = env_bool("MAXKB_BRIDGE_ENABLED", True)
maxkb_base_url: str = os.getenv(
"MAXKB_BASE_URL",
"http://pai.pubuli.com:8080",
).rstrip("/")
maxkb_access_token: str = os.getenv("MAXKB_ACCESS_TOKEN", "")
maxkb_timeout_sec: float = float(os.getenv("MAXKB_TIMEOUT_SEC", "120"))
CONFIG = Config()
@@ -0,0 +1,470 @@
"""Chatwoot Agent Bot webhook → MaxKB(PAI)→ 回寫對話。"""
from __future__ import annotations
import json
import logging
import re
import threading
import time
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from config import CONFIG
logger = logging.getLogger(__name__)
_SESSION_LOCK = threading.Lock()
_SESSIONS: dict[str, dict[str, Any]] = {}
_SESSION_FILE = Path(__file__).resolve().parent / ".maxkb_cw_sessions.json"
_QUICK_QUESTION_RE = re.compile(
r"<quick_question>\s*(.*?)\s*</quick_question>",
re.IGNORECASE | re.DOTALL,
)
def _load_sessions() -> None:
if not _SESSION_FILE.exists():
return
try:
data = json.loads(_SESSION_FILE.read_text(encoding="utf-8"))
if isinstance(data, dict):
_SESSIONS.update(data)
except Exception:
logger.exception("讀取 MaxKB session 快取失敗")
def _save_sessions() -> None:
try:
_SESSION_FILE.write_text(
json.dumps(_SESSIONS, ensure_ascii=False, indent=2),
encoding="utf-8",
)
except Exception:
logger.exception("寫入 MaxKB session 快取失敗")
_load_sessions()
def _http_json(
method: str,
url: str,
*,
headers: dict[str, str] | None = None,
body: Any = None,
timeout: float = 120,
) -> tuple[int, Any]:
data = None
if body is not None:
data = body if isinstance(body, (bytes, bytearray)) else json.dumps(
body, ensure_ascii=False
).encode("utf-8")
req_headers = {"Content-Type": "application/json", "Accept": "application/json"}
if headers:
req_headers.update(headers)
req = Request(url, data=data, headers=req_headers, method=method)
try:
with urlopen(req, timeout=timeout) as resp:
raw = resp.read()
try:
return resp.status, json.loads(raw.decode("utf-8") or "null")
except Exception:
return resp.status, raw.decode("utf-8", errors="replace")
except HTTPError as exc:
raw = exc.read()
try:
return exc.code, json.loads(raw.decode("utf-8") or "null")
except Exception:
return exc.code, raw.decode("utf-8", errors="replace")
except URLError as exc:
raise RuntimeError(f"HTTP 連線失敗: {url}: {exc}") from exc
def _maxkb_auth() -> str:
status, body = _http_json(
"POST",
f"{CONFIG.maxkb_base_url}/chat/api/auth/anonymous",
body={"access_token": CONFIG.maxkb_access_token},
timeout=30,
)
if status != 200 or not isinstance(body, dict) or body.get("code") != 200:
raise RuntimeError(f"MaxKB anonymous auth 失敗: {status} {body}")
token = body.get("data")
if not token:
raise RuntimeError("MaxKB anonymous auth 未回傳 token")
return str(token)
def _maxkb_open(jwt: str) -> str:
status, body = _http_json(
"GET",
f"{CONFIG.maxkb_base_url}/chat/api/open",
headers={"Authorization": f"Bearer {jwt}"},
timeout=30,
)
if status != 200 or not isinstance(body, dict) or body.get("code") != 200:
raise RuntimeError(f"MaxKB open 失敗: {status} {body}")
chat_id = body.get("data")
if not chat_id:
raise RuntimeError("MaxKB open 未回傳 chat_id")
return str(chat_id)
def _maxkb_ask(
jwt: str,
chat_id: str,
message: str,
form_data: dict[str, Any],
) -> str:
status, body = _http_json(
"POST",
f"{CONFIG.maxkb_base_url}/chat/api/chat_message/{chat_id}",
headers={"Authorization": f"Bearer {jwt}"},
body={
"message": message,
"stream": False,
"re_chat": False,
"form_data": form_data,
},
timeout=CONFIG.maxkb_timeout_sec,
)
if status != 200 or not isinstance(body, dict) or body.get("code") != 200:
raise RuntimeError(f"MaxKB chat 失敗: {status} {body}")
data = body.get("data") or {}
content = ""
if isinstance(data, dict):
content = str(data.get("content") or "").strip()
if not content:
answers = data.get("answer_list") or []
if answers and isinstance(answers[0], dict):
content = str(answers[0].get("content") or "").strip()
if not content:
raise RuntimeError(f"MaxKB 回覆為空: {body}")
return content
def _get_or_create_session(conversation_key: str) -> dict[str, Any]:
with _SESSION_LOCK:
sess = _SESSIONS.get(conversation_key)
if sess and sess.get("jwt") and sess.get("chat_id"):
return dict(sess)
jwt = _maxkb_auth()
chat_id = _maxkb_open(jwt)
sess = {
"jwt": jwt,
"chat_id": chat_id,
"updated_at": int(time.time()),
}
with _SESSION_LOCK:
_SESSIONS[conversation_key] = sess
_save_sessions()
return dict(sess)
def _refresh_session(conversation_key: str) -> dict[str, Any]:
with _SESSION_LOCK:
_SESSIONS.pop(conversation_key, None)
_save_sessions()
return _get_or_create_session(conversation_key)
def _split_quick_questions(reply: str) -> tuple[str, list[str]]:
"""把 MaxKB 的 <quick_question> 標籤拆成純文字 + 按鈕選項。"""
raw = str(reply or "")
items: list[str] = []
for match in _QUICK_QUESTION_RE.findall(raw):
label = " ".join(str(match).split()).strip()
if label and label not in items:
items.append(label)
clean = _QUICK_QUESTION_RE.sub("", raw)
clean = re.sub(r"[ \t]+\n", "\n", clean)
clean = re.sub(r"\n{3,}", "\n\n", clean).strip()
return clean, items
def _chatwoot_reply(
account_id: int,
conversation_id: int,
content: str,
*,
content_type: str = "text",
content_attributes: dict[str, Any] | None = None,
) -> None:
base = (
CONFIG.chatwoot_guest_api_base
or CONFIG.chatwoot_guest_proxy_target
or "http://127.0.0.1:3000"
).rstrip("/")
token = CONFIG.chatwoot_agent_bot_token
if not token:
raise RuntimeError("未設定 CHATWOOT_AGENT_BOT_TOKEN")
url = (
f"{base}/api/v1/accounts/{account_id}/conversations/"
f"{conversation_id}/messages"
)
payload: dict[str, Any] = {
"content": content,
"message_type": "outgoing",
"private": False,
"content_type": content_type,
}
if content_attributes:
payload["content_attributes"] = content_attributes
status, body = _http_json(
"POST",
url,
headers={"api_access_token": token},
body=payload,
timeout=30,
)
if status >= 300:
raise RuntimeError(f"Chatwoot 回覆失敗: {status} {body}")
def _chatwoot_reply_from_maxkb(
account_id: int,
conversation_id: int,
reply: str,
) -> None:
"""回寫 Chatwoot:去掉 quick_question 原文,改成可點選按鈕。"""
clean, items = _split_quick_questions(reply)
if clean:
_chatwoot_reply(account_id, conversation_id, clean)
elif not items:
# 空回覆不送
return
if items:
_chatwoot_reply(
account_id,
conversation_id,
"",
content_type="input_select",
content_attributes={
"items": [{"title": label, "value": label} for label in items]
},
)
def _has_attachments(message: dict[str, Any]) -> bool:
atts = message.get("attachments") or message.get("files") or []
return isinstance(atts, list) and len(atts) > 0
def _is_incoming_message(message: dict[str, Any]) -> bool:
message_type = message.get("message_type")
incoming_types = {0, "0", "incoming", "Incoming"}
if message_type not in incoming_types:
return False
if message.get("private"):
return False
if message.get("content_type") in {"input_email", "form", "input_select"}:
return False
has_text = bool(str(message.get("content") or "").strip())
return has_text or _has_attachments(message)
def _pick_latest_incoming(messages: Any) -> dict[str, Any] | None:
if not isinstance(messages, list):
return None
for msg in reversed(messages):
if isinstance(msg, dict) and _is_incoming_message(msg):
return msg
return None
def _extract_incoming(payload: dict[str, Any]) -> dict[str, Any] | None:
event = str(payload.get("event") or "")
message: dict[str, Any] | None = None
if event == "message_created":
candidate = payload.get("message") or payload
message = candidate if isinstance(candidate, dict) else None
elif event == "conversation_updated":
# Agent Bot webhook 常送 conversation_updated,訊息在 messages 陣列
conversation = payload.get("conversation") if isinstance(payload.get("conversation"), dict) else {}
messages = payload.get("messages") or conversation.get("messages") or []
message = _pick_latest_incoming(messages)
elif not event:
candidate = payload.get("message") or payload
message = candidate if isinstance(candidate, dict) else None
else:
return None
if not message or not _is_incoming_message(message):
return None
content = str(message.get("content") or "").strip()
conversation = message.get("conversation") or payload.get("conversation") or {}
if not isinstance(conversation, dict):
conversation = {}
conversation_id = conversation.get("id") or message.get("conversation_id") or payload.get("id")
account = payload.get("account") if isinstance(payload.get("account"), dict) else {}
account_id = (
message.get("account_id")
or conversation.get("account_id")
or account.get("id")
or CONFIG.chatwoot_account_id
)
if not conversation_id:
return None
sender = message.get("sender") or {}
username = ""
if isinstance(sender, dict):
username = str(
sender.get("name")
or sender.get("identifier")
or (sender.get("additional_attributes") or {}).get("username")
or ""
)
return {
"account_id": int(account_id),
"conversation_id": int(conversation_id),
"content": content,
"username": username,
"message_id": message.get("id"),
}
def _hard_reply_for_complaint(message: str) -> str | None:
"""客诉/提款强制话术,避免意图误判。"""
text = "".join(str(message or "").split())
if "一次不给赢" in text:
return "亲,每次游戏都会有输有赢,关键是保持好心态,接受挑战,最终会有丰厚的回报!"
# YD-CUSTOMER-018
if "回归" in text and "杀" in text:
return (
"亲,游戏输输赢赢很正常的,拼的是智力、耐心、心态和运气,"
"如果您觉得手气不好,可以稍作休息,转移一下注意力,稍后再进行投注,"
"或许下一个大奖就是您的,祝您鸿运当头,盈利多多。"
)
# YD-CUSTOMER-017
low = text.lower()
if (
"pg电子" in low
or "PG电子" in text
or "电子真是假" in text
or "假的我永远" in text
or ("电子" in text and "假" in text)
):
return (
"亲,您好!电子游戏的大奖奖池是根据机率来的哦,每一轮的结果都是随机的,"
"所以不能保证每次都能中奖。希望您能理解,这也是游戏的一部分乐趣所在!"
"我们可以一起再试试,说不定下一次就能碰上好运呢"
)
# YD-WITHDRAW-016(标准为转专人,禁止追问订单号)
if "取消提款" in text or "取消提现" in text or "申请取消提款" in text:
return (
"您好,取消提款需由专员协助核实处理,请稍候为您转接真人客服。"
"<quick_question>转专人服务</quick_question>"
)
return None
def handle_chatwoot_webhook(payload: dict[str, Any]) -> dict[str, Any]:
if not CONFIG.maxkb_bridge_enabled:
return {"ok": True, "skipped": "bridge_disabled"}
incoming = _extract_incoming(payload)
if not incoming:
return {"ok": True, "skipped": "not_incoming"}
account_id = incoming["account_id"]
conversation_id = incoming["conversation_id"]
key = f"{account_id}:{conversation_id}"
message_id = incoming.get("message_id")
content = str(incoming.get("content") or "").strip()
now = int(time.time())
event = str(payload.get("event") or "")
with _SESSION_LOCK:
cur = dict(_SESSIONS.get(key) or {})
if message_id is not None and cur.get("last_message_id") == message_id:
return {"ok": True, "skipped": "duplicate", "event": event}
if (
content
and cur.get("last_content") == content
and now - int(cur.get("last_content_at") or 0) < 45
):
return {"ok": True, "skipped": "duplicate_content", "event": event}
if message_id is not None:
cur["last_message_id"] = message_id
if content:
cur["last_content"] = content
cur["last_content_at"] = now
cur["updated_at"] = now
_SESSIONS[key] = cur
_save_sessions()
# 純圖片/附件沒有文字時,MaxKB 收不到內容,先前會直接略過,畫面像卡住。
if not content:
ack = (
"已收到您的图片。目前客服无法辨识图片内容,"
"请用文字说明您要咨询的问题,例如:充值未到账、无法登录、提款问题。"
)
_chatwoot_reply_from_maxkb(account_id, conversation_id, ack)
return {
"ok": True,
"conversation_id": conversation_id,
"image_ack": True,
"event": event,
}
form_data = {
"account_id": str(account_id),
"conversation_id": str(conversation_id),
"bot_id": str(CONFIG.chatwoot_agent_bot_id or ""),
"username": incoming["username"] or "游客",
}
hard = _hard_reply_for_complaint(incoming["content"])
if hard:
_chatwoot_reply_from_maxkb(account_id, conversation_id, hard)
with _SESSION_LOCK:
cur = dict(_SESSIONS.get(key) or {})
cur["updated_at"] = int(time.time())
cur["hard_reply"] = True
_SESSIONS[key] = cur
_save_sessions()
return {
"ok": True,
"conversation_id": conversation_id,
"hard_reply": True,
"reply_len": len(hard),
"event": event,
}
sess = _get_or_create_session(key)
try:
reply = _maxkb_ask(
sess["jwt"], sess["chat_id"], incoming["content"], form_data
)
except Exception:
logger.exception("MaxKB 對話失敗,嘗試重建 session")
sess = _refresh_session(key)
reply = _maxkb_ask(
sess["jwt"], sess["chat_id"], incoming["content"], form_data
)
_chatwoot_reply_from_maxkb(account_id, conversation_id, reply)
with _SESSION_LOCK:
cur = dict(_SESSIONS.get(key) or {})
cur["updated_at"] = int(time.time())
if message_id is not None:
cur["last_message_id"] = message_id
_SESSIONS[key] = cur
_save_sessions()
return {
"ok": True,
"conversation_id": conversation_id,
"chat_id": sess["chat_id"],
"reply_len": len(reply),
"event": event,
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,453 @@
{% if chatwoot_config.enabled %}
<script>
(function () {
"use strict";
/*
* 會員身份由 Flask Session 注入,identifier 直接使用 username_clean。
* 不再接受 ?identifier=... 等網址參數,避免使用者冒充其他會員。
*/
const CHATWOOT_CONFIG = {{ chatwoot_config | tojson }};
const CHATWOOT_USER = {{ chatwoot_user | tojson }};
window.chatwootSettings = {
hideMessageBubble: false,
showUnreadMessagesDialog: false,
position: "right",
locale: "zh_TW",
useBrowserLanguage: false,
type: "standard",
darkMode: "auto",
launcherTitle: ""
};
function debugLog() {
if (!CHATWOOT_CONFIG.debug) {
return;
}
console.log.apply(console, arguments);
}
function configureChatwootIdentity() {
if (!window.$chatwoot) {
return;
}
if (!CHATWOOT_USER.is_authenticated) {
if (typeof window.$chatwoot.setCustomAttributes === "function") {
window.$chatwoot.setCustomAttributes({
visitor_type: "guest"
});
}
debugLog("Chatwoot anonymous guest ready");
return;
}
if (
typeof window.$chatwoot.setUser !== "function" ||
!CHATWOOT_USER.identifier
) {
return;
}
const userPayload = {
name: CHATWOOT_USER.name || CHATWOOT_USER.username
};
if (CHATWOOT_USER.identifier_hash) {
userPayload.identifier_hash = CHATWOOT_USER.identifier_hash;
}
window.$chatwoot.setUser(
CHATWOOT_USER.identifier,
userPayload
);
if (typeof window.$chatwoot.setCustomAttributes === "function") {
window.$chatwoot.setCustomAttributes({
visitor_type: "member",
username: CHATWOOT_USER.username || "",
member_uid: CHATWOOT_USER.uid == null
? ""
: String(CHATWOOT_USER.uid),
account_status: CHATWOOT_USER.account_status || "",
locked_reason: CHATWOOT_USER.locked_reason || "",
member_type: CHATWOOT_USER.member_type || "",
vip_level: CHATWOOT_USER.vip_level || "",
tier: CHATWOOT_USER.tier || ""
});
}
}
/*
* Flask 登出前清除 Chatwoot Session,避免下一個登入會員
* 沿用上一位會員的 Chatwoot Cookie 與對話。
*/
window.resetChatwootBeforeLogout = function (form) {
if (!form) {
return true;
}
/*
* 防止使用者重複點擊登出,造成多次提交。
*/
if (form.dataset.submitting === "true") {
return false;
}
form.dataset.submitting = "true";
const submitLogout = function () {
form.submit();
};
if (
window.$chatwoot &&
typeof window.$chatwoot.reset === "function"
) {
try {
window.$chatwoot.reset();
} catch (error) {
console.error(
"Chatwoot reset before logout failed:",
error
);
}
/*
* Chatwoot reset 沒有 Promise 回傳,因此提供短暫緩衝時間。
* 即使前端清理未完成,後端的一次性 guest reset
* 仍會在登出後首次載入匿名頁面時再次執行。
*/
window.setTimeout(
submitLogout,
300
);
return false;
}
submitLogout();
return false;
};
window.openGuestChatNewConversation = function () {
createGuestConversationAndShow({ openAfter: true });
};
window.exitGuestChatAndStartNew = function () {
// 先關掉視窗,再換成新對話並重新打開
closeGuestChat();
createGuestConversationAndShow({ openAfter: true, delayMs: 350 });
};
function createGuestConversationAndShow(options) {
var opts = options || {};
var openAfter = opts.openAfter !== false;
var delayMs = typeof opts.delayMs === "number" ? opts.delayMs : 700;
if (!CHATWOOT_CONFIG.guest_new_path) {
return;
}
fetch(CHATWOOT_CONFIG.guest_new_path, {
method: "POST",
headers: {
Accept: "application/json",
"X-CSRF-Token": CHATWOOT_CONFIG.csrf_token || ""
}
})
.then(function (response) {
return response.json().then(function (data) {
if (!response.ok) {
throw new Error((data && data.message) || "建立游客对话失败");
}
return data;
});
})
.then(function (data) {
if (!data || !data.widget_token) {
throw new Error("Chatwoot 未回傳 widget_token");
}
document.querySelectorAll("iframe[src*='widget']").forEach(
function (iframe) {
const url = new URL(iframe.src, window.location.origin);
url.searchParams.set("cw_conversation", data.widget_token);
url.hash = "/messages";
iframe.src = url.toString();
}
);
if (!openAfter) {
window.__guestWidgetOpen = false;
return;
}
window.__guestWidgetOpen = true;
window.setTimeout(function () {
if (
window.$chatwoot &&
typeof window.$chatwoot.toggle === "function"
) {
window.$chatwoot.toggle("open");
}
}, delayMs);
})
.catch(function (error) {
console.error("Chatwoot guest open failed:", error);
window.alert("客服暂时无法开启,请稍后再试。");
});
};
function isGuestChatOpen() {
var holder = document.querySelector(".woot-widget-holder");
if (holder && !holder.classList.contains("woot--hide")) {
var style = window.getComputedStyle(holder);
var rect = holder.getBoundingClientRect();
if (
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity || "1") > 0 &&
rect.width > 0 &&
rect.height > 0
) {
return true;
}
}
return !!window.__guestWidgetOpen;
}
function openGuestChatResume() {
window.__guestWidgetOpen = true;
if (window.$chatwoot && typeof window.$chatwoot.toggle === "function") {
try {
window.$chatwoot.toggle("open");
return;
} catch (error) {}
}
var holder = document.querySelector(".woot-widget-holder");
if (holder) {
holder.classList.remove("woot--hide");
}
}
function closeGuestChat() {
window.__guestWidgetOpen = false;
if (window.$chatwoot && typeof window.$chatwoot.toggle === "function") {
try {
window.$chatwoot.toggle("close");
return;
} catch (error) {}
}
var holder = document.querySelector(".woot-widget-holder");
if (holder) {
holder.classList.add("woot--hide");
}
}
function bindGuestBubbleToggle() {
if (!CHATWOOT_CONFIG.guest_new_path || window.__guestBubbleBound) {
return;
}
window.__guestBubbleBound = true;
window.addEventListener("chatwoot:opened", function () {
window.__guestWidgetOpen = true;
});
window.addEventListener("chatwoot:closed", function () {
window.__guestWidgetOpen = false;
});
document.addEventListener(
"click",
function (event) {
var target = event.target;
if (!target || typeof target.closest !== "function") {
return;
}
var bubble = target.closest(
".woot-widget-bubble, .woot--bubble-holder, button.woot-widget-bubble"
);
if (!bubble) {
return;
}
// 右下角氣泡:只收起/重開同一個對話,绝不開新單。
event.preventDefault();
event.stopPropagation();
if (isGuestChatOpen()) {
closeGuestChat();
} else {
openGuestChatResume();
}
},
true
);
}
window.addEventListener("message", function (event) {
var raw = event && event.data;
if (typeof raw !== "string" || raw.indexOf("chatwoot-widget:") !== 0) {
return;
}
try {
var payload = JSON.parse(raw.replace("chatwoot-widget:", ""));
if (payload && payload.event === "guestExitNewChat") {
if (typeof window.exitGuestChatAndStartNew === "function") {
window.exitGuestChatAndStartNew();
} else if (typeof window.openGuestChatNewConversation === "function") {
window.openGuestChatNewConversation();
}
}
} catch (error) {}
});
window.addEventListener(
"chatwoot:ready",
function () {
debugLog("chatwoot:ready", window.$chatwoot);
const guestResetRequired =
{{ chatwoot_guest_reset_required | tojson }};
/*
* 只有「剛完成會員登出」且目前未登入時,才清除舊會員身份。
* 一般遊客頁面不會每次都 reset,因此不會破壞既有匿名對話。
*/
if (
!CHATWOOT_USER.is_authenticated &&
guestResetRequired &&
window.$chatwoot &&
typeof window.$chatwoot.reset === "function"
) {
try {
window.$chatwoot.reset();
} catch (error) {
console.error(
"Chatwoot guest reset failed:",
error
);
}
/*
* reset 後稍候再設定 visitor_type=guest。
* 不執行 setUser(),因此不會向 Chatwoot 傳遞用戶名。
*/
window.setTimeout(
configureChatwootIdentity,
300
);
bindGuestBubbleToggle();
return;
}
configureChatwootIdentity();
bindGuestBubbleToggle();
}
);
window.addEventListener(
"chatwoot:error",
function (event) {
console.error(
"chatwoot:error",
event && event.detail ? event.detail : event
);
}
);
if (CHATWOOT_CONFIG.debug) {
window.addEventListener(
"chatwoot:on-message",
function (event) {
debugLog("chatwoot:on-message", event.detail);
}
);
window.addEventListener(
"chatwoot:postback",
function (event) {
debugLog("chatwoot:postback", event.detail);
}
);
window.addEventListener(
"chatwoot:opened",
function () {
debugLog("chatwoot:opened");
}
);
window.addEventListener(
"chatwoot:closed",
function () {
debugLog("chatwoot:closed");
}
);
window.addEventListener(
"chatwoot:on-start-conversation",
function (event) {
debugLog(
"chatwoot:on-start-conversation",
event.detail
);
}
);
}
const script = document.createElement("script");
const firstScript = document.getElementsByTagName("script")[0];
script.src = (
CHATWOOT_CONFIG.base_url +
"/packs/js/sdk.js"
);
script.async = true;
script.onload = function () {
if (!window.chatwootSDK) {
console.error("Chatwoot SDK object not found");
return;
}
window.chatwootSDK.run({
websiteToken: CHATWOOT_CONFIG.website_token,
baseUrl: CHATWOOT_CONFIG.base_url
});
// 父頁強制觸發 iframe 內快捷入口/宣傳圖掛載(同源 /cw)
function boostGuestUi() {
try {
var iframes = document.querySelectorAll("iframe");
for (var i = 0; i < iframes.length; i += 1) {
var iframe = iframes[i];
var src = iframe.getAttribute("src") || "";
if (src.indexOf("widget") === -1 && src.indexOf("/cw") === -1) {
continue;
}
var win = iframe.contentWindow;
if (win && typeof win.__ydEnsureGuestUI === "function") {
win.__ydEnsureGuestUI();
}
}
} catch (err) {}
}
window.setInterval(boostGuestUi, 1000);
window.setTimeout(boostGuestUi, 1500);
window.setTimeout(boostGuestUi, 3500);
};
script.onerror = function () {
console.error(
"Chatwoot SDK load failed:",
script.src
);
};
firstScript.parentNode.insertBefore(
script,
firstScript
);
})();
</script>
{% endif %}
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ app_name }}{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body class="{% block body_class %}{% endblock %}">
{% block body %}{% endblock %}
{% block integrations %}{% endblock %}
</body>
</html>
@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}游客客服|{{ app_name }}{% endblock %}
{% block body_class %}guest-page{% endblock %}
{% block body %}
<header class="topbar">
<div>
<div class="site-title">游客客服中心</div>
<div class="site-subtitle">Guest Customer Service</div>
</div>
<a class="back-link" href="{{ url_for('login') }}">返回登录页</a>
</header>
<main class="page narrow">
<section class="support-panel guest-support-panel">
<div class="support-avatar">🎧</div>
<span class="guest-badge">游客模式</span>
<h1>欢迎使用在线客服</h1>
<p>您目前以游客身份进入,可咨询注册、登录、活动与平台操作问题。</p>
<div class="alert alert-warning">
游客模式不会识别会员身份,也不能查询充值、提款、余额或其他会员资料。
</div>
<button
type="button"
class="primary-button guest-open-chat"
onclick="if (window.openGuestChatNewConversation) { window.openGuestChatNewConversation(); }"
>
开启在线客服
</button>
</section>
</main>
{% endblock %}
{% block integrations %}
{% include "_chatwoot.html" %}
{% endblock %}
@@ -0,0 +1,74 @@
{% extends "base.html" %}
{% block title %}遊戲首頁|{{ app_name }}{% endblock %}
{% block body %}
<header class="topbar">
<div>
<div class="site-title">YD GAME DEMO</div>
<div class="site-subtitle">Python 唯讀 API 測試站點</div>
</div>
<div class="member-area">
<span>用戶:<strong>{{ account.raw_username or username }}</strong></span>
<span class="status-badge {{ account.status }}">
{{ "已凍結" if account.status == "locked" else "正常" }}
</span>
<form
method="post"
action="{{ url_for('logout') }}"
onsubmit="return window.resetChatwootBeforeLogout ? window.resetChatwootBeforeLogout(this) : true;"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="logout-button" type="submit">登出</button>
</form>
</div>
</header>
<main class="page">
{% if warning %}
<div class="alert alert-error">帳戶資料讀取失敗:{{ warning }}</div>
{% endif %}
{% if account.status == "locked" %}
<div class="alert alert-warning">
<strong>帳戶已凍結</strong>
{% if account.locked_reason %}|凍結原因:{{ account.locked_reason }}{% endif %}
</div>
{% endif %}
<section class="hero">
<div>
<span class="eyebrow">PYTHON TEST ENVIRONMENT</span>
<h1>歡迎進入遊戲站點模擬首頁</h1>
<p>登入會員:{{ account.username_clean or username }}。本頁只用於驗證會員資料、充值、提款與客服 API。</p>
</div>
<a class="service-entry" href="{{ url_for('customer_service') }}">
<span class="service-icon">🎧</span>
<span><strong>在線客服</strong><small>查詢充值與提款資料</small></span>
</a>
</section>
<section class="account-grid">
<div class="metric-card"><span>UID</span><strong>{{ account.uid or "NA" }}</strong></div>
<div class="metric-card"><span>VIP 等級</span><strong>{{ account.vip_level or "NA" }}</strong></div>
<div class="metric-card"><span>會員餘額</span><strong>{{ account.balance or "0.0000" }}</strong></div>
<div class="metric-card"><span>凍結餘額</span><strong>{{ account.frozen_balance or "0.0000" }}</strong></div>
</section>
<section>
<div class="section-heading"><h2>熱門遊戲</h2><span>畫面模擬,不執行真實投注</span></div>
<div class="game-grid">
{% for game in games %}
<article class="game-card">
<div class="game-icon">{{ game.icon }}</div>
<div><h3>{{ game.name }}</h3><p>{{ game.category }}</p></div>
<button type="button" disabled>模擬遊戲</button>
</article>
{% endfor %}
</div>
</section>
</main>
<a class="floating-service" href="{{ url_for('customer_service') }}">🎧 客服</a>
{% endblock %}
{% block integrations %}
{% include "_chatwoot.html" %}
{% endblock %}
@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}登录|{{ app_name }}{% endblock %}
{% block body_class %}login-body{% endblock %}
{% block body %}
<main class="login-card">
<div class="brand-mark">YD</div>
<h1>游戏站点模拟</h1>
<p class="muted">仅使用 members 表内存在的用户名登录,不需要密码。</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endwith %}
<form method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label for="username">用户名</label>
<input id="username" name="username" type="text" maxlength="191"
value="{{ username }}" placeholder="输入 username 或 username_clean"
required autofocus>
<button type="submit" class="primary-button">登录测试站点</button>
</form>
<div class="login-divider"><span>或</span></div>
<a
class="guest-service-button"
href="{{ url_for('guest_customer_service') }}"
>
🎧 游客进入客服
</a>
<p class="guest-service-note">
游客只能进行一般咨询,不能查询会员充值、提款或账户资料。
</p>
<p class="security-note">【测试限定】此登录方式不具备正式身份验证能力。</p>
</main>
{% endblock %}
@@ -0,0 +1,4 @@
from app import app
application = app
@@ -0,0 +1,30 @@
CHATWOOT_INBOX_ID=1
MAXKB_TIMEOUT_SEC=120
CHATWOOT_ENABLED=true
CHATWOOT_GUEST_BASE_URL=http://20.191.152.26:7777/cw
MAXKB_ACCESS_TOKEN=***
APP_NAME=API接口系统
CHATWOOT_API_PASSWORD=***
CHATWOOT_WEBSITE_TOKEN=***
MAXKB_BASE_URL=http://pai.pubuli.com:8080
APP_DEBUG=false
CHATWOOT_BASE_URL=http://pai.pubuli.com:3000
CHATWOOT_AGENT_BOT_TOKEN=***
MAXKB_BRIDGE_ENABLED=true
CHATWOOT_GUEST_API_PASSWORD=***
CHATWOOT_GREETING=您好,欢迎光临【49彩票集团】客服服务中心,请问有什么可以帮到您的呢?
CHATWOOT_GUEST_INBOX_ID=1
CHATWOOT_ACCOUNT_ID=1
CHATWOOT_GUEST_API_EMAIL=admin@localhost.local
CHATWOOT_GUEST_API_BASE=http://127.0.0.1:3000
APP_HOST=0.0.0.0
CHATWOOT_GUEST_WEBSITE_TOKEN=***
CHATWOOT_AGENT_BOT_ID=1
FRONTEND_URL=http://20.191.152.26:7777
CHATWOOT_API_EMAIL=yyds@nobody.com
CHATWOOT_GUEST_PROXY_TARGET=http://127.0.0.1:3000
CHATWOOT_IDENTITY_SECRET=***
APP_PORT=7788
CHATWOOT_WIDGET_JWT_SECRET=***
CHATWOOT_DEBUG=false
BUNDLE_APP_CONFIG=/usr/local/bundle
@@ -0,0 +1,35 @@
#!/bin/bash
set -euo pipefail
# test.pubuli.com:7777 空库初始化
sudo docker exec guest-chat bundle exec rails runner "
email = 'admin@localhost.local'
password = 'LocalCw#168'
greeting = '您好,欢迎光临【49彩票集团】客服服务中心,请问有什么可以帮到您的呢?'
site_url = 'http://test.pubuli.com:7777'
account = Account.first || Account.create!(name: '测试客服', locale: 'zh_TW')
user = User.find_by(email: email)
unless user
user = User.new(name: '测试管理员', email: email, password: password, password_confirmation: password)
user.save!
AccountUser.create!(account: account, user: user, role: :administrator)
end
inbox = account.inboxes.first
unless inbox
channel = Channel::WebWidget.create!(
account: account,
website_url: site_url,
widget_color: '#E53935',
welcome_title: '',
welcome_tagline: greeting,
pre_chat_form_enabled: false
)
inbox = Inbox.create!(account: account, name: '49彩票集团客服', channel: channel, greeting_enabled: true, greeting_message: greeting)
end
bot = AgentBot.find_by(name: 'AI 客服') || AgentBot.create!(name: 'AI 客服', outgoing_url: 'http://127.0.0.1:7788/webhook/bot1/chatwoot', account: account)
AgentBotInbox.find_or_create_by!(inbox: inbox, agent_bot: bot, account: account)
puts \"OK account=#{account.id} inbox=#{inbox.id} token=#{inbox.channel.website_token} bot_url=#{bot.outgoing_url}\"
"
@@ -0,0 +1,33 @@
#!/bin/bash
set -euo pipefail
sudo docker exec guest-chat bundle exec rails runner "
email = 'admin@localhost.local'
password = 'LocalCw#168'
greeting = '您好,欢迎光临【49彩票集团】客服服务中心,请问有什么可以帮到您的呢?'
account = Account.first || Account.create!(name: '本地客服', locale: 'zh_TW')
user = User.find_by(email: email)
unless user
user = User.new(name: '本地管理员', email: email, password: password, password_confirmation: password)
user.save!
AccountUser.create!(account: account, user: user, role: :administrator)
end
inbox = account.inboxes.first
unless inbox
channel = Channel::WebWidget.create!(
account: account,
website_url: 'http://www.pubuli.com:7777',
widget_color: '#E53935',
welcome_title: '',
welcome_tagline: greeting,
pre_chat_form_enabled: false
)
inbox = Inbox.create!(account: account, name: '49彩票集团客服', channel: channel, greeting_enabled: true, greeting_message: greeting)
end
bot = AgentBot.find_by(name: 'AI 客服') || AgentBot.create!(name: 'AI 客服', outgoing_url: 'http://127.0.0.1:7788/webhook/bot1/chatwoot', account: account)
AgentBotInbox.find_or_create_by!(inbox: inbox, agent_bot: bot, account: account)
puts \"OK account=#{account.id} inbox=#{inbox.id} token=#{inbox.channel.website_token} bot_url=#{bot.outgoing_url}\"
"
@@ -0,0 +1,28 @@
SECRET_KEY_BASE=61db0140c2e890c28f6737913fb22734365590c7b9501590cd43b108532e13dfe368b1792e6a6bee1ad90dda9dbb42fca39c9d4ef76749c24488a63246ee01ca
FRONTEND_URL=http://127.0.0.1:3000
FORCE_SSL=false
DEFAULT_LOCALE=zh_TW
ENABLE_ACCOUNT_SIGNUP=true
RAILS_ENV=production
NODE_ENV=production
RAILS_LOG_TO_STDOUT=true
RAILS_SERVE_STATIC_FILES=true
INSTALLATION_ENV=docker
BUNDLE_WITHOUT=development:test
EXECJS_RUNTIME=Disabled
REDIS_URL=redis://redis:6379
REDIS_PASSWORD=ziqIkS_An0QDXH9FQ70V7KTz
POSTGRES_HOST=postgres
POSTGRES_USERNAME=postgres
POSTGRES_PASSWORD=xi_E9IhtgSYjC9PoQXZmUnMX
POSTGRES_DATABASE=chatwoot_production
ACTIVE_STORAGE_SERVICE=local
MAILER_SENDER_EMAIL=Chatwoot <noreply@localhost>
SMTP_ADDRESS=
ENABLE_PUSH_RELAY_SERVER=false
# 允许 Agent Bot webhook 打 Docker 内网 guest-cs
SAFE_FETCH_ALLOW_PRIVATE_NETWORK=true
@@ -0,0 +1,12 @@
# 远端部署(对外 7777 → 容器 7788)
# www.pubuli.com / 20.191.152.26
services:
guest-chat:
ports: !override
- "7777:7788"
env_file:
- ./guest-cs.remote.env
environment:
CHATWOOT_GUEST_BASE_URL: http://www.pubuli.com:7777/cw
FRONTEND_URL: http://www.pubuli.com:7777
@@ -0,0 +1,12 @@
# test.pubuli.com 部署(对外 7777 → 容器 7788)
# docker compose -f docker-compose.chat.yaml -f docker-compose.chat.test7777.yaml up -d --force-recreate
services:
guest-chat:
ports: !override
- "7777:7788"
env_file:
- ./guest-cs.test.env
environment:
CHATWOOT_GUEST_BASE_URL: http://20.191.152.26:7777/cw
FRONTEND_URL: http://20.191.152.26:7777
@@ -0,0 +1,78 @@
# 只部署「在线客服聊天框」(production 预编译前端)
# 浏览器入口:http://127.0.0.1:7788/guest-customer-service
#
# docker pull ladioussuapp/guest-chat:latest
# docker compose -f docker-compose.chat.yaml -f docker-compose.reuse-chatwoot-data.yaml up -d
name: guest-chat
x-cw-env: &cw-env
NODE_ENV: production
RAILS_ENV: production
INSTALLATION_ENV: docker
RAILS_SERVE_STATIC_FILES: "true"
services:
guest-chat:
image: ladioussuapp/guest-chat:latest
build:
context: .
dockerfile: Dockerfile.chatwidget
args:
RAILS_ENV: production
NODE_ENV: production
container_name: guest-chat
restart: always
env_file:
- ./chatwoot.env
- ./guest-cs.env
environment:
<<: *cw-env
CHATWOOT_GUEST_PROXY_TARGET: http://127.0.0.1:3000
CHATWOOT_GUEST_API_BASE: http://127.0.0.1:3000
CHATWOOT_GUEST_BASE_URL: http://127.0.0.1:7788/cw
APP_DEBUG: "false"
ports:
# 只对外开聊天框;Chatwoot 后台 3000 不映射
- "127.0.0.1:7788:7788"
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- storage_data:/app/storage
depends_on:
- postgres
- redis
networks:
- guest-chat-net
postgres:
image: pgvector/pgvector:pg16
restart: always
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: chatwoot_production
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-xi_E9IhtgSYjC9PoQXZmUnMX}
networks:
- guest-chat-net
redis:
image: redis:alpine
restart: always
command: ["sh", "-c", "redis-server --requirepass \"$$REDIS_PASSWORD\""]
env_file:
- ./chatwoot.env
volumes:
- redis_data:/data
networks:
- guest-chat-net
volumes:
storage_data:
postgres_data:
redis_data:
networks:
guest-chat-net:
driver: bridge
@@ -0,0 +1,56 @@
APP_NAME=API接口系统
APP_HOST=0.0.0.0
APP_PORT=7788
APP_DEBUG=false
CHATWOOT_ENABLED=true
CHATWOOT_BASE_URL=http://pai.pubuli.com:3000
CHATWOOT_WEBSITE_TOKEN=FZBhe2W4VXSXNT9Ek4etmP3L
CHATWOOT_IDENTITY_SECRET=9Fq7fmzHmButzJdq2mkbaHx5
CHATWOOT_DEBUG=false
CHATWOOT_ACCOUNT_ID=1
CHATWOOT_INBOX_ID=1
CHATWOOT_API_EMAIL=yyds@nobody.com
CHATWOOT_API_PASSWORD=YYDSyyds#168
CHATWOOT_GREETING=您好,欢迎光临【49彩票集团】客服服务中心,请问有什么可以帮到您的呢?
# 浏览器访问仍用 127.0.0.1:7788/cw;容器内代理/API 走 docker 网
CHATWOOT_GUEST_BASE_URL=http://127.0.0.1:7788/cw
CHATWOOT_GUEST_WEBSITE_TOKEN=utiK8hZAiaqmqdp2baN6Pq14
CHATWOOT_GUEST_PROXY_TARGET=http://127.0.0.1:3000
CHATWOOT_GUEST_API_BASE=http://127.0.0.1:3000
CHATWOOT_GUEST_API_EMAIL=admin@localhost.local
CHATWOOT_GUEST_API_PASSWORD=LocalCw#168
CHATWOOT_GUEST_INBOX_ID=1
CHATWOOT_WIDGET_JWT_SECRET=61db0140c2e890c28f6737913fb22734365590c7b9501590cd43b108532e13dfe368b1792e6a6bee1ad90dda9dbb42fca39c9d4ef76749c24488a63246ee01ca
CHATWOOT_AGENT_BOT_ID=1
CHATWOOT_AGENT_BOT_TOKEN=E4rBf9TbMi1C5ShpDtLztGZh
MAXKB_BRIDGE_ENABLED=true
MAXKB_BASE_URL=http://pai.pubuli.com:8080
MAXKB_ACCESS_TOKEN=4a44cfae71e8aee7
MAXKB_TIMEOUT_SEC=120
SECRET_KEY=replace-with-a-long-random-secret
API_KEY=
SESSION_COOKIE_SECURE=false
# 会员库:本机隧道时指向宿主机;无隧道则登录会员功能不可用,游客页仍可用
DB_HOST=host.docker.internal
DB_PORT=3306
DB_USER=root
DB_PASSWORD=z88KmX2VjuiYHep
DB_NAME=member_system
DB_CHARSET=utf8mb4
DB_CONNECT_TIMEOUT=5
MEMBERS_TABLE=members
WITHDRAW_TABLE=withdraw_records
WITHDRAW_REQUEST_TABLE=withdraw_request_records
DEPOSIT_TABLE=deposit_records
READ_ONLY_MODE=true
AUTO_INIT_SIM_TABLES=false
DEFAULT_ACTIVITY_ID=ad-x1234
DEPOSIT_SEARCH_HOURS=24
MAX_ORDERS=50
@@ -0,0 +1,3 @@
# 远端 20.191.152.26 / www.pubuli.com:7777 专用(bootstrap 后 token)
CHATWOOT_GUEST_WEBSITE_TOKEN=j1UAjqBgZMWQ8WfczgJSymQx
CHATWOOT_GUEST_BASE_URL=http://www.pubuli.com:7777/cw
@@ -0,0 +1,4 @@
# test.pubuli.com:7777 专用(bootstrap 后 token)
CHATWOOT_GUEST_WEBSITE_TOKEN=j1UAjqBgZMWQ8WfczgJSymQx
CHATWOOT_GUEST_BASE_URL=http://20.191.152.26:7777/cw
CHATWOOT_AGENT_BOT_TOKEN=SrQpnTgYzXq4i17nVQhemxjT