最初版本
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user