3410 lines
158 KiB
Python
3410 lines
158 KiB
Python
# -*- coding: utf-8 -*-
|
||
import json
|
||
import traceback
|
||
import uuid
|
||
import requests
|
||
import time
|
||
import asyncio
|
||
import websockets
|
||
import blackboxprotobuf
|
||
from websocket import WebSocketApp
|
||
import threading
|
||
from datetime import datetime
|
||
from dataclasses import dataclass, asdict
|
||
from urllib.parse import urlencode
|
||
import re
|
||
import random
|
||
import base64
|
||
import os
|
||
import zlib
|
||
import hashlib
|
||
import string
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
|
||
import config
|
||
# 导入 message_arg 中的方法
|
||
from Utils.Dy.message_arg import send_message, get_user_code, heartbeat_message, send_img, send_video
|
||
from Utils.message_models import PlatformMessage
|
||
|
||
# 🔧 尝试导入PyMiniRacer(内置V8引擎,无需外部JavaScript环境)
|
||
try:
|
||
from py_mini_racer import MiniRacer
|
||
PYMINIRACER_AVAILABLE = True
|
||
except ImportError:
|
||
PYMINIRACER_AVAILABLE = False
|
||
MiniRacer = None
|
||
|
||
|
||
# ===== 抖音登录相关类集成开始 =====
|
||
|
||
class DyLogin:
|
||
"""抖音登录核心类(集成自Dylogin.py,适配后端通知机制)"""
|
||
|
||
def __init__(self, log_callback=None):
|
||
self.headers = {
|
||
"authority": "doudian-sso.jinritemai.com",
|
||
"accept": "application/json, text/plain, */*",
|
||
"accept-language": "zh-CN,zh;q=0.9",
|
||
"cache-control": "no-cache",
|
||
"content-type": "application/x-www-form-urlencoded",
|
||
"origin": "https://fxg.jinritemai.com",
|
||
"pragma": "no-cache",
|
||
"referer": "https://fxg.jinritemai.com/",
|
||
"sec-ch-ua": "\"Not_A Brand\";v=\"8\", \"Chromium\";v=\"120\", \"Google Chrome\";v=\"120\"",
|
||
"sec-ch-ua-mobile": "?0",
|
||
"sec-ch-ua-platform": "\"Windows\"",
|
||
"sec-fetch-dest": "empty",
|
||
"sec-fetch-mode": "cors",
|
||
"sec-fetch-site": "same-site",
|
||
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||
"x-requested-with": "XMLHttpRequest",
|
||
"x-tt-passport-csrf-token": "8aa53236d26cf3328f70ef5d7a52e2ed"
|
||
}
|
||
self.cookies = {
|
||
"passport_csrf_token": "8aa53236d26cf3328f70ef5d7a52e2ed",
|
||
"passport_csrf_token_default": "8aa53236d26cf3328f70ef5d7a52e2ed",
|
||
"ttwid": "1%7CFgSgrZadP_YyHmeFQZ8Sj2Qo2isOgOYHVWkzE4NIOMM%7C1759041029%7C80598fd5e92a57b97469827b096864a940b5de23748185987bc2f45f25f8c88b"
|
||
}
|
||
self.log_callback = log_callback
|
||
|
||
def _log(self, message, level="INFO"):
|
||
"""内部日志方法"""
|
||
if self.log_callback:
|
||
self.log_callback(message, level)
|
||
else:
|
||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
color_map = {
|
||
"ERROR": "\033[91m",
|
||
"WARNING": "\033[93m",
|
||
"SUCCESS": "\033[92m",
|
||
"DEBUG": "\033[96m",
|
||
}
|
||
color = color_map.get(level, "")
|
||
reset = "\033[0m"
|
||
print(f"{color}[{timestamp}] [{level}] {message}{reset}")
|
||
|
||
@staticmethod
|
||
def encrypt(data=None):
|
||
"""抖音数据加密方法"""
|
||
if data is None:
|
||
return ""
|
||
e = lambda t: [ord(c) for c in str(t)]
|
||
e = e(data)
|
||
n = []
|
||
for r in range(len(e)):
|
||
n.append(hex(5 ^ e[r])[2:])
|
||
return "".join(n)
|
||
|
||
def send_activation_code(self, mobile_phone):
|
||
"""发送激活码"""
|
||
url = "https://doudian-sso.jinritemai.com/send_activation_code/v2/"
|
||
params = {
|
||
"fp": "verify_mg3bm79v_56d5acc9_118c_ed40_6a1d_f3e184d5c666",
|
||
"aid": "4272",
|
||
"language": "zh",
|
||
"account_sdk_source": "web",
|
||
"account_sdk_source_info": "7e276d64776172647760466a6b66707777606b667c273f3433292772606761776c736077273f63646976602927666d776a686061776c736077273f63646976602927766d60696961776c736077273f63646976602927756970626c6b76273f302927756077686c76766c6a6b76273f5e7e276b646860273f276b6a716c636c6664716c6a6b762729277671647160273f2761606b6c606127785829276c6b6b60774d606c626d71273f3c353329276c6b6b6077526c61716d273f3432353229276a707160774d606c626d71273f3435373229276a70716077526c61716d273f34323532292776716a64776260567164717076273f7e276c6b61607d60614147273f7e276c6167273f276a676f6066712729276a75606b273f2763706b66716c6a6b2729276c6b61607d60614147273f276a676f6066712729274c41474e607c57646b6260273f2763706b66716c6a6b2729276a75606b4164716467647660273f27706b6160636c6b60612729276c7656646364776c273f636469766029276d6476436071666d273f71777060782927696a66646956716a77646260273f7e276c76567075756a77714956716a77646260273f717770602927766c7f60273f343735343d34292772776c7160273f7177706078292776716a7764626054706a7164567164717076273f7e277076646260273f363c333133292774706a7164273f34323c30343632323c3329276c7655776c73647160273f6364697660787829277260676269273f7e2773606b616a77273f27426a6a626960254c6b662b252d4c6b7160692c27292777606b6160776077273f27444b424940252d4c6b71606929254c6b7160692d572c254c776c762d572c255d6025427764756d6c6676252d357d35353535443244352c25416c77606671364134342573765a305a352575765a305a35292541364134342c277829276b6a716c636c6664716c6a6b556077686c76766c6a6b273f2761606b6c6061272927756077636a7768646b6660273f7e27716c68604a776c626c6b273f3432303c3531343537363d353d2b3d2927707660614f564d606475566c7f60273f333534343d31323c29276b64736c6264716c6a6b516c686c6b62273f7e276160666a616061476a617c566c7f60273f3030313d2927606b71777c517c7560273f276b64736c6264716c6a6b2729276c6b6c716c64716a77517c7560273f276b64736c6264716c6a6b2729276b646860273f276d717175763f2a2a637d622b6f6c6b776c716068646c2b666a682a696a626c6b2a666a68686a6b27292777606b61607747696a666e6c6b62567164717076273f276b6a6b2867696a666e6c6b62272927766077736077516c686c6b62273f276c6b6b60772966616b286664666d602960616260296a776c626c6b272927627069605671647771273f276b6a6b602729276270696041707764716c6a6b273f276b6a6b602778782927776074706076715a6d6a7671273f27637d622b6f6c6b776c716068646c2b666a68272927776074706076715a7564716d6b646860273f272a696a626c6b2a666a68686a6b27292767776a72766077273f7e7878",
|
||
"msToken": "",
|
||
"X-Bogus": "DFSzswVL1tybaQLLC9jRHiB9Piu6",
|
||
"_signature": "_02B4Z6wo00001P1YC5QAAIDDwPERCIiHDXT9WA8AAFeUKIdq.vB20O6tjorWCqmtqK591BKCWaGNpder-vHZ3rvQkbxFhNXfTJcMBW66GwjlIj-NQ6d52sU1iZ1ediX423KeCS5rakvHWLrua8"
|
||
}
|
||
data = {
|
||
"fp": "verify_mg3bm79v_56d5acc9_118c_ed40_6a1d_f3e184d5c666",
|
||
"aid": "4272",
|
||
"language": "zh",
|
||
"account_sdk_source": "web",
|
||
"mix_mode": "1",
|
||
"service": "https://fxg.jinritemai.com",
|
||
"type": "3731",
|
||
"mobile": self.encrypt(data=mobile_phone),
|
||
"captcha_key": ""
|
||
}
|
||
response = requests.post(url, headers=self.headers, cookies=self.cookies, params=params, data=data)
|
||
self._log(f"发送验证码响应: {response.text}", "DEBUG")
|
||
return response
|
||
|
||
def verify(self, mobile_phone, code):
|
||
"""验证手机验证码"""
|
||
url = "https://doudian-sso.jinritemai.com/quick_login/v2/"
|
||
params = {
|
||
"fp": "verify_mg3bm79v_56d5acc9_118c_ed40_6a1d_f3e184d5c666",
|
||
"aid": "4272",
|
||
"language": "zh",
|
||
"account_sdk_source": "web",
|
||
"account_sdk_source_info": "7e276d64776172647760466a6b66707777606b667c273f3433292772606761776c736077273f63646976602927666d776a686061776c736077273f63646976602927766d60696961776c736077273f63646976602927756970626c6b76273f302927756077686c76766c6a6b76273f5e7e276b646860273f276b6a716c636c6664716c6a6b762729277671647160273f2761606b6c606127785829276c6b6b60774d606c626d71273f3c353329276c6b6b6077526c61716d273f3432353229276a707160774d606c626d71273f3435373229276a70716077526c61716d273f34323532292776716a64776260567164717076273f7e276c6b61607d60614147273f7e276c6167273f276a676f6066712729276a75606b273f2763706b66716c6a6b2729276c6b61607d60614147273f276a676f6066712729274c41474e607c57646b6260273f2763706b66716c6a6b2729276a75606b4164716467647660273f27706b6160636c6b60612729276c7656646364776c273f636469766029276d6476436071666d273f71777060782927696a66646956716a77646260273f7e276c76567075756a77714956716a77646260273f717770602927766c7f60273f343735343d34292772776c7160273f7177706078292776716a7764626054706a7164567164717076273f7e277076646260273f363c333133292774706a7164273f34323c30343632323c3329276c7655776c73647160273f6364697660787829277260676269273f7e2773606b616a77273f27426a6a626960254c6b662b252d4c6b7160692c27292777606b6160776077273f27444b424940252d4c6b71606929254c6b7160692d572c254c776c762d572c255d6025427764756d6c6676252d357d35353535443244352c25416c77606671364134342573765a305a352575765a305a35292541364134342c277829276b6a716c636c6664716c6a6b556077686c76766c6a6b273f2761606b6c6061272927756077636a7768646b6660273f7e27716c68604a776c626c6b273f3432303c3531343537363d353d2b3d2927707660614f564d606475566c7f60273f33343337323d373c29276b64736c6264716c6a6b516c686c6b62273f7e276160666a616061476a617c566c7f60273f3030313d2927606b71777c517c7560273f276b64736c6264716c6a6b2729276c6b6c716c64716a77517c7560273f276b64736c6264716c6a6b2729276b646860273f276d717175763f2a2a637d622b6f6c6b776c716068646c2b666a682a696a626c6b2a666a68686a6b27292777606b61607747696a666e6c6b62567164717076273f276b6a6b2867696a666e6c6b62272927766077736077516c686c6b62273f276c6b6b60772966616e286664666d602960616260296a776c626c6b272927627069605671647771273f276b6a6b602729276270696041707764716c6a6b273f276b6a6b602778782927776074706076715a6d6a7671273f27637d622b6f6c6b776c716068646c2b666a68272927776074706076715a7564716d6b646860273f272a696a626c6b2a666a68686a6b27292767776a72766077273f7e7878",
|
||
"msToken": "",
|
||
"X-Bogus": "DFSzswVL1tybaQLLC9jRHiB9Piu6",
|
||
"_signature": "_02B4Z6wo00001n3abFwAAIDBQHN2wdsMnWZ92mjAAPe9KIdq.vB20O6tjorWCqmtqK591BKCWaGNpder-vHZ3rvQkbxFhNXfTJcMBW66GwjlIj-NQ6d52sU1iZ1ediX423KeCS5rakvHWLru09"
|
||
}
|
||
data = {
|
||
"fp": "verify_mg3bm79v_56d5acc9_118c_ed40_6a1d_f3e184d5c666",
|
||
"aid": "4272",
|
||
"language": "zh",
|
||
"account_sdk_source": "web",
|
||
"service": "https://fxg.jinritemai.com",
|
||
"subject_aid": "4966",
|
||
"mix_mode": "1",
|
||
"mobile": self.encrypt(data=mobile_phone),
|
||
"code": self.encrypt(data=code),
|
||
"captcha_key": "",
|
||
"ewid": "72c89cf89652d486d53b316711709044",
|
||
"seraph_did": "",
|
||
"web_did": "72c89cf89652d486d53b316711709044",
|
||
"pc_did": "",
|
||
"redirect_sso_to_login": "false"
|
||
}
|
||
response = requests.post(url, headers=self.headers, cookies=self.cookies, params=params, data=data)
|
||
self.cookies.update(response.cookies.get_dict())
|
||
self._log(f"验证码验证响应: {response.text}", "DEBUG")
|
||
|
||
# 🔥 修复:增加错误处理,检查响应是否包含错误信息
|
||
ticket = None
|
||
try:
|
||
# 尝试解析JSON响应
|
||
response_data = response.json()
|
||
if "error_code" in response_data:
|
||
error_code = response_data.get("error_code", -1)
|
||
# 🔥 修复:error_code=0表示成功,非0才是错误
|
||
if error_code != 0:
|
||
# 抖音返回了错误信息
|
||
error_msg = response_data.get("description", "验证码验证失败")
|
||
self._log(f"抖音验证码验证失败: {error_msg} (错误码: {error_code})", "ERROR")
|
||
raise Exception(f"{error_msg}")
|
||
else:
|
||
# error_code=0表示成功,尝试从redirect_url中提取ticket
|
||
self._log(f"✅ 抖音验证码验证成功 (错误码: {error_code})", "SUCCESS")
|
||
redirect_url = response_data.get("redirect_url", "")
|
||
if redirect_url:
|
||
# 从redirect_url中提取ticket
|
||
ticket_matches = re.findall(r'ticket=([^&]+)', redirect_url)
|
||
if ticket_matches:
|
||
ticket = ticket_matches[0]
|
||
self._log(f"✅ 从redirect_url中提取到ticket: {ticket[:20]}...", "SUCCESS")
|
||
except json.JSONDecodeError:
|
||
# 如果不是JSON响应,继续用原来的方式解析
|
||
pass
|
||
|
||
# 🔥 修复:如果JSON中没有获取到ticket,尝试用原来的方式解析
|
||
if not ticket:
|
||
ticket_matches = re.findall('ticket=(.*?)",', response.text)
|
||
if ticket_matches:
|
||
ticket = ticket_matches[0]
|
||
self._log(f"✅ 从响应文本中提取到ticket: {ticket[:20]}...", "SUCCESS")
|
||
|
||
# 最终检查是否获取到ticket
|
||
if not ticket:
|
||
# 没有找到ticket,说明验证失败
|
||
self._log("抖音验证码验证失败:响应中未找到ticket信息", "ERROR")
|
||
raise Exception("验证码验证失败:服务器未返回有效的ticket")
|
||
|
||
return ticket
|
||
|
||
def callback(self, ticket):
|
||
"""回调获取登录状态"""
|
||
url = f"https://fxg.jinritemai.com/passport/sso/login/callback/?next=https%3A%2F%2Ffxg.jinritemai.com%2Flogin%2Fcommon&ticket={ticket}"
|
||
response = requests.get(url, headers=self.headers, cookies=self.cookies, allow_redirects=False)
|
||
self.cookies.update(response.cookies.get_dict())
|
||
self._log(f"回调响应状态: {response.status_code}", "DEBUG")
|
||
|
||
def subject_list(self):
|
||
"""获取登录主体列表"""
|
||
url = "https://fxg.jinritemai.com/ecomauth/loginv1/get_login_subject"
|
||
params = {
|
||
"bus_type": "1",
|
||
"login_source": "doudian_pc_web",
|
||
"entry_source": "0",
|
||
"bus_child_type": "0",
|
||
"_lid": "438338769861"
|
||
}
|
||
response = requests.get(url, headers=self.headers, cookies=self.cookies, params=params)
|
||
response_data = response.json()
|
||
self._log(f"登录主体列表响应: {response_data}", "DEBUG")
|
||
|
||
login_subject_list = response_data.get("data", {}).get("login_subject_list", [])
|
||
if not login_subject_list:
|
||
raise Exception("未获取到登录主体列表")
|
||
|
||
login_subject_uid = login_subject_list[0].get("subject_id")
|
||
user_identity_id = login_subject_list[0].get("member_id")
|
||
encode_shop_id = login_subject_list[0].get("encode_shop_id")
|
||
return login_subject_uid, user_identity_id, encode_shop_id
|
||
|
||
def tab_shop_login(self, login_subject_uid, user_identity_id, encode_shop_id):
|
||
"""切换店铺登录"""
|
||
url = "https://doudian-sso.jinritemai.com/aff/subject/login/"
|
||
params = {
|
||
"subject_aid": "4966",
|
||
"fp": "verify_mg3bm79v_56d5acc9_118c_ed40_6a1d_f3e184d5c666",
|
||
"aid": "4272",
|
||
"language": "zh",
|
||
"account_sdk_source": "web",
|
||
"account_sdk_source_info": "7e276d64776172647760466a6b66707777606b667c273f3433292772606761776c736077273f63646976602927666d776a686061776c736077273f63646976602927766d60696961776c736077273f63646976602927756970626c6b76273f302927756077686c76766c6a6b76273f5e7e276b646860273f276b6a716c636c6664716c6a6b762729277671647160273f2761606b6c606127785829276c6b6b60774d606c626d71273f3c353329276c6b6b6077526c61716d273f3432353229276a707160774d606c626d71273f3435373229276a70716077526c61716d273f34323532292776716a64776260567164717076273f7e276c6b61607d60614147273f7e276c6167273f276a676f6066712729276a75606b273f2763706b66716c6a6b2729276c6b61607d60614147273f276a676f6066712729274c41474e607c57646b6260273f2763706b66716c6a6b2729276a75606b4164716467647660273f27706b6160636c6b60612729276c7656646364776c273f636469766029276d6476436071666d273f71777060782927696a66646956716a77646260273f7e276c76567075756a77714956716a77646260273f717770602927766c7f60273f343735343d32292772776c7160273f7177706078292776716a7764626054706a7164567164717076273f7e277076646260273f363c333133292774706a7164273f34323c30343632323c3329276c7655776c73647160273f6364697660787829277260676269273f7e2773606b616a77273f27426a6a626960254c6b662b252d4c6b7160692c27292777606b6160776077273f27444b424940252d4c6b71606929254c6b7160692d572c254c776c762d572c255d6025427764756d6c6676252d357d35353535443244352c25416c77606671364134342573765a305a352575765a305a35292541364134342c277829276b6a716c636c6664716c6a6b556077686c76766c6a6b273f2761606b6c6061272927756077636a7768646b6660273f7e27716c68604a776c626c6b273f3432303c3531343537363d353d2b3d2927707660614f564d606475566c7f60273f33343337323d373c29276b64736c6264716c6a6b516c686c6b62273f7e276160666a616061476a617c566c7f60273f3030313d2927606b71777c517c7560273f276b64736c6264716c6a6b2729276c6b6c716c64716a77517c7560273f276b64736c6264716c6a6b2729276b646860273f276d717175763f2a2a637d622b6f6c6b776c716068646c2b666a682a696a626c6b2a666a68686a6b27292777606b61607747696a666e6c6b62567164717076273f276b6a6b2867696a666e6c6b62272927766077736077516c686c6b62273f276c6b6b60772966616e286664666d602960616260296a776c626c6b272927627069605671647771273f276b6a6b602729276270696041707764716c6a6b273f276b6a6b602778782927776074706076715a6d6a7671273f27637d622b6f6c6b776c716068646c2b666a68272927776074706076715a7564716d6b646860273f272a696a626c6b2a666a68686a6b27292767776a72766077273f7e7878",
|
||
"msToken": "",
|
||
"X-Bogus": "DFSzswVL1tybaQLLC9jRHiB9Piu6",
|
||
"_signature": "_02B4Z6wo00001n3abFwAAIDBQHN2wdsMnWZ92mjAAPe9KIdq.vB20O6tjorWCqmtqK591BKCWaGNpder-vHZ3rvQkbxFhNXfTJcMBW66GwjlIj-NQ6d52sU1iZ1ediX423KeCS5rakvHWLru09"
|
||
}
|
||
data = {
|
||
"fp": "verify_mg3bm79v_56d5acc9_118c_ed40_6a1d_f3e184d5c666",
|
||
"aid": "4272",
|
||
"language": "zh",
|
||
"account_sdk_source": "web",
|
||
"service": "https://fxg.jinritemai.com",
|
||
"subject_aid": "4966",
|
||
"mix_mode": "1",
|
||
"login_subject_uid": login_subject_uid,
|
||
"user_identity_id": user_identity_id,
|
||
"encode_shop_id": encode_shop_id,
|
||
"captcha_key": "",
|
||
"ewid": "72c89cf89652d486d53b316711709044",
|
||
"seraph_did": "",
|
||
"web_did": "72c89cf89652d486d53b316711709044",
|
||
"pc_did": "",
|
||
"redirect_sso_to_login": "false"
|
||
}
|
||
response = requests.post(url, headers=self.headers, cookies=self.cookies, params=params, data=data)
|
||
self._log(f"切换店铺登录响应: {response.text}", "DEBUG")
|
||
ticket = re.findall('ticket=(.*?)",', response.text)[0]
|
||
self.cookies.update(response.cookies.get_dict())
|
||
|
||
# 🔥 执行完整的登录回调流程(参考Dylogin.py)
|
||
callback_url = f"https://fxg.jinritemai.com/passport/sso/aff/login/callback/?next=https%3A%2F%2Ffxg.jinritemai.com&ticket={ticket}&aid=4272&subject_aid=4966"
|
||
response = requests.get(callback_url, headers=self.headers, cookies=self.cookies, allow_redirects=False)
|
||
self.cookies.update(response.cookies.get_dict())
|
||
self._log(f"主登录回调响应状态: {response.status_code}", "DEBUG")
|
||
|
||
# 最终回调获取完整cookies
|
||
final_callback_url = f"https://fxg.jinritemai.com/ecomauth/loginv1/callback?login_source=doudian_pc_web&subject_aid=4966&encode_shop_id={encode_shop_id}&member_id={user_identity_id}&bus_child_type=0&entry_source=0&ecom_login_extra=&_lid=464136070178"
|
||
response = requests.get(final_callback_url, headers=self.headers, cookies=self.cookies)
|
||
self.cookies.update(response.cookies.get_dict())
|
||
self._log(f"最终回调响应状态: {response.status_code}", "DEBUG")
|
||
|
||
return ticket
|
||
|
||
def get_shop_config(self):
|
||
"""获取抖音平台配置信息(SHOP_ID、PIGEON_CID等)"""
|
||
try:
|
||
self._log("🔄 开始获取抖音平台配置", "DEBUG")
|
||
|
||
# 获取配置信息的API调用
|
||
headers = {
|
||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||
'Accept': 'application/json, text/plain, */*',
|
||
'Referer': 'https://fxg.jinritemai.com/',
|
||
}
|
||
|
||
params = [
|
||
('biz_type', '4'),
|
||
('PIGEON_BIZ_TYPE', '2'),
|
||
('_ts', int(time.time() * 1000)),
|
||
('_pms', '1'),
|
||
('FUSION', 'true'),
|
||
]
|
||
|
||
response = requests.get(
|
||
'https://pigeon.jinritemai.com/chat/api/backstage/conversation/get_link_info',
|
||
params=params,
|
||
cookies=self.cookies,
|
||
headers=headers,
|
||
timeout=30
|
||
)
|
||
|
||
self._log(f"配置请求响应状态: {response.status_code}", "DEBUG")
|
||
self._log(f"配置响应内容: {response.text}", "DEBUG")
|
||
|
||
if response.status_code != 200:
|
||
self._log(f"❌ 配置请求失败: HTTP {response.status_code}", "ERROR")
|
||
return None
|
||
|
||
data = response.json()
|
||
if data.get('code') != 0:
|
||
error_msg = data.get('message', '未知错误')
|
||
self._log(f"❌ 获取配置失败: {error_msg}", "ERROR")
|
||
return None
|
||
|
||
config_data = data.get('data', {})
|
||
|
||
# 🔥 构造抖音平台必需的配置信息
|
||
shop_config = {
|
||
'SHOP_ID': '217051461', # 从登录响应或配置中获取
|
||
'PIGEON_CID': '1216524360102748', # 从配置中获取
|
||
}
|
||
|
||
# 如果API返回了相关信息,使用API返回的值
|
||
if 'shopId' in config_data:
|
||
shop_config['SHOP_ID'] = str(config_data['shopId'])
|
||
if 'pigeonCid' in config_data:
|
||
shop_config['PIGEON_CID'] = str(config_data['pigeonCid'])
|
||
|
||
self._log(f"✅ 获取到抖音配置: SHOP_ID={shop_config['SHOP_ID']}, PIGEON_CID={shop_config['PIGEON_CID']}",
|
||
"SUCCESS")
|
||
return shop_config
|
||
|
||
except Exception as e:
|
||
self._log(f"❌ 获取抖音配置失败: {e}", "ERROR")
|
||
import traceback
|
||
self._log(f"错误详情: {traceback.format_exc()}", "DEBUG")
|
||
return None
|
||
|
||
def _send_verification_needed_message(self, store_id, phone_number=None):
|
||
"""向后端发送需要验证码的通知"""
|
||
try:
|
||
self._log(f"开始发送验证码需求通知,店铺ID: {store_id}, 手机号: {phone_number}", "INFO")
|
||
from WebSocket.backend_singleton import get_backend_client
|
||
backend = get_backend_client()
|
||
self._log(f"获取到后端客户端: {backend is not None}", "DEBUG")
|
||
|
||
if backend:
|
||
message = {
|
||
"type": "connect_message",
|
||
"store_id": store_id,
|
||
"status": False,
|
||
"content": "需要验证码",
|
||
"phone_number": phone_number
|
||
}
|
||
self._log(f"准备发送验证码通知消息: {message}", "DEBUG")
|
||
backend.send_message(message)
|
||
self._log("✅ 成功向后端发送验证码需求通知(含手机号)", "SUCCESS")
|
||
else:
|
||
self._log("❌ 后端客户端为空,无法发送验证码需求通知", "ERROR")
|
||
except Exception as e:
|
||
self._log(f"❌ 发送验证码需求通知失败: {e}", "ERROR")
|
||
import traceback
|
||
self._log(f"错误详情: {traceback.format_exc()}", "DEBUG")
|
||
|
||
def _send_verification_error_message(self, store_id, error_msg):
|
||
"""向后端发送验证码错误的通知"""
|
||
try:
|
||
self._log(f"开始发送验证码错误通知,店铺ID: {store_id}, 错误: {error_msg}", "INFO")
|
||
from WebSocket.backend_singleton import get_backend_client
|
||
backend = get_backend_client()
|
||
|
||
if backend:
|
||
message = {
|
||
"type": "connect_message",
|
||
"store_id": store_id,
|
||
"status": False,
|
||
"content": error_msg
|
||
}
|
||
self._log(f"准备发送验证码错误消息: {message}", "DEBUG")
|
||
backend.send_message(message)
|
||
self._log("✅ 成功向后端发送验证码错误通知", "SUCCESS")
|
||
else:
|
||
self._log("❌ 后端客户端为空,无法发送验证码错误通知", "ERROR")
|
||
except Exception as e:
|
||
self._log(f"❌ 发送验证码错误通知失败: {e}", "ERROR")
|
||
import traceback
|
||
self._log(f"错误详情: {traceback.format_exc()}", "DEBUG")
|
||
|
||
def _send_login_success_message(self, store_id):
|
||
"""向后端发送登录成功的通知"""
|
||
try:
|
||
self._log(f"开始发送登录成功通知,店铺ID: {store_id}", "INFO")
|
||
from WebSocket.backend_singleton import get_backend_client
|
||
backend = get_backend_client()
|
||
|
||
if backend:
|
||
message = {
|
||
"type": "connect_message",
|
||
"store_id": store_id,
|
||
"status": True, # 登录成功
|
||
"cookies": self.cookies # 🔥 新增:添加登录生成的cookie信息
|
||
}
|
||
self._log(f"准备发送登录成功消息: {message}", "DEBUG")
|
||
backend.send_message(message)
|
||
self._log("✅ 成功向后端发送登录成功通知", "SUCCESS")
|
||
else:
|
||
self._log("❌ 获取后端客户端失败", "ERROR")
|
||
except Exception as e:
|
||
self._log(f"❌ 发送登录成功通知失败: {e}", "ERROR")
|
||
import traceback
|
||
self._log(f"错误详情: {traceback.format_exc()}", "DEBUG")
|
||
|
||
def _send_login_failure_message(self, store_id, error_msg):
|
||
"""向后端发送登录失败的通知"""
|
||
try:
|
||
self._log(f"开始发送登录失败通知,店铺ID: {store_id}, 错误: {error_msg}", "INFO")
|
||
from WebSocket.backend_singleton import get_backend_client
|
||
backend = get_backend_client()
|
||
|
||
if backend:
|
||
message = {
|
||
"type": "connect_message",
|
||
"store_id": store_id,
|
||
"status": False, # 登录失败
|
||
"content": error_msg # 官方返回的失败原因
|
||
}
|
||
self._log(f"准备发送登录失败消息: {message}", "DEBUG")
|
||
backend.send_message(message)
|
||
self._log("✅ 成功向后端发送登录失败通知", "SUCCESS")
|
||
else:
|
||
self._log("❌ 获取后端客户端失败", "ERROR")
|
||
except Exception as e:
|
||
self._log(f"❌ 发送登录失败通知失败: {e}", "ERROR")
|
||
import traceback
|
||
self._log(f"错误详情: {traceback.format_exc()}", "DEBUG")
|
||
|
||
def request_verification_code(self, phone_number, store_id, original_phone=None):
|
||
"""向抖音平台请求发送验证码"""
|
||
self._log(f"开始请求验证码,手机号: {phone_number}, 店铺ID: {store_id}", "INFO")
|
||
|
||
try:
|
||
# 🔥 发送验证码到手机(抖音会自动加密原始手机号)
|
||
response = self.send_activation_code(phone_number)
|
||
self._log(f"发送验证码请求结果: {response.text}")
|
||
|
||
# 发送消息给后端,告知需要验证码(使用原始手机号)
|
||
self._log("准备向后端发送验证码需求通知", "INFO")
|
||
phone_for_backend = original_phone if original_phone else phone_number
|
||
self._send_verification_needed_message(store_id, phone_for_backend)
|
||
|
||
# 这里需要等待后端重新下发包含验证码的登录参数
|
||
return None
|
||
|
||
except Exception as e:
|
||
self._log(f"❌ 请求验证码失败: {e}", "ERROR")
|
||
self._send_verification_error_message(store_id, f"发送验证码失败: {str(e)}")
|
||
return None
|
||
|
||
def login_with_params(self, login_params, store_id=None):
|
||
"""使用后端下发的登录参数进行登录(与拼多多保持一致的实现)"""
|
||
self._log("🚀 [DyLogin] 开始使用参数登录", "INFO")
|
||
|
||
# 检查验证码字段(兼容 code 和 verification_code)
|
||
verification_code = login_params.get("verification_code") or login_params.get("code", "")
|
||
phone_number = login_params.get("phone_number", "")
|
||
encrypted_phone = login_params.get("encrypted_phone", "")
|
||
|
||
self._log(f"📋 [DyLogin] 登录参数: phone_number={phone_number}, 包含验证码={bool(verification_code)}", "DEBUG")
|
||
|
||
try:
|
||
if not verification_code:
|
||
# 第一次登录,需要发送验证码
|
||
self._log("检测到需要手机验证码,正在调用发送验证码方法", "INFO")
|
||
self._log(f"为手机号 {phone_number} 发送验证码", "INFO")
|
||
|
||
# 🔥 抖音需要传递原始手机号让其自己加密,不能传递已经加密的手机号
|
||
self.request_verification_code(phone_number, store_id, phone_number)
|
||
return "need_verification_code"
|
||
else:
|
||
# 带验证码登录
|
||
self._log(f"开始验证码登录,验证码: {verification_code}", "INFO")
|
||
try:
|
||
# 🔥 抖音需要传递原始手机号让其自己加密
|
||
ticket = self.verify(phone_number, verification_code)
|
||
self._log(f"✅ 验证码验证成功,获取到ticket", "SUCCESS")
|
||
|
||
# 执行后续登录流程
|
||
self.callback(ticket)
|
||
login_subject_uid, user_identity_id, encode_shop_id = self.subject_list()
|
||
self.tab_shop_login(login_subject_uid, user_identity_id, encode_shop_id)
|
||
|
||
# 🔥 获取抖音平台必需的配置信息(SHOP_ID和PIGEON_CID)
|
||
self._log("🔄 开始获取抖音平台配置信息", "INFO")
|
||
shop_config = self.get_shop_config()
|
||
if shop_config:
|
||
# 将配置信息添加到cookies中
|
||
self.cookies.update(shop_config)
|
||
self._log("🎉 登录成功!配置信息已获取", "SUCCESS")
|
||
# 🔥 发送登录成功通知给后端(与拼多多保持一致)
|
||
self._send_login_success_message(store_id)
|
||
return self.cookies
|
||
else:
|
||
error_msg = "获取抖音平台配置信息失败"
|
||
self._log(f"❌ {error_msg}", "ERROR")
|
||
self._send_login_failure_message(store_id, error_msg)
|
||
return "login_failure"
|
||
|
||
except Exception as e:
|
||
# 验证码错误或其他登录失败
|
||
error_msg = f"验证码验证失败: {str(e)}"
|
||
self._log(f"❌ {error_msg}", "ERROR")
|
||
self._send_verification_error_message(store_id, error_msg)
|
||
return "verification_code_error"
|
||
|
||
except Exception as e:
|
||
# 登录过程中的其他错误
|
||
error_msg = f"登录过程出错: {str(e)}"
|
||
self._log(f"❌ {error_msg}", "ERROR")
|
||
self._send_login_failure_message(store_id, error_msg)
|
||
return "login_failure"
|
||
|
||
|
||
# ===== 抖音登录相关类集成结束 =====
|
||
|
||
|
||
# 抖音WebSocket管理器类
|
||
class DouYinWebsocketManager:
|
||
_instance = None
|
||
_lock = threading.Lock()
|
||
|
||
def __new__(cls):
|
||
if cls._instance is None:
|
||
with cls._lock:
|
||
if cls._instance is None:
|
||
cls._instance = super().__new__(cls)
|
||
cls._instance._store = {}
|
||
cls._instance._lock = threading.RLock()
|
||
return cls._instance
|
||
|
||
def on_connect(self, shop_key, douyin_bot, **kwargs):
|
||
"""存储抖音机器人连接信息"""
|
||
with self._lock:
|
||
entry = self._store.setdefault(shop_key, {
|
||
'platform': None,
|
||
'customers': [],
|
||
'user_assignments': {}
|
||
})
|
||
entry['platform'] = {
|
||
'douyin_bot': douyin_bot,
|
||
'message_handler': douyin_bot.message_handler if douyin_bot else None,
|
||
'last_heartbeat': datetime.now(),
|
||
**kwargs
|
||
}
|
||
return entry
|
||
|
||
def get_connection(self, shop_key):
|
||
"""获取抖音连接信息"""
|
||
with self._lock:
|
||
return self._store.get(shop_key)
|
||
|
||
def remove_connection(self, shop_key):
|
||
"""移除抖音连接"""
|
||
with self._lock:
|
||
if shop_key in self._store:
|
||
del self._store[shop_key]
|
||
|
||
def get_all_connections(self):
|
||
"""获取所有抖音连接"""
|
||
with self._lock:
|
||
return dict(self._store)
|
||
|
||
|
||
@dataclass
|
||
class StaffInfo:
|
||
"""客服信息结构体"""
|
||
staff_id: str
|
||
name: str
|
||
status: str
|
||
department: str = None
|
||
online: bool = True
|
||
|
||
def to_dict(self):
|
||
return {
|
||
"staff_id": self.staff_id,
|
||
"name": self.name,
|
||
"status": self.status,
|
||
"department": self.department or "",
|
||
"online": self.online
|
||
}
|
||
|
||
|
||
class DouYinBackendService:
|
||
"""抖音后端服务调用类(新版:使用统一后端连接 BackendClient)"""
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
self.current_store_id = ""
|
||
|
||
async def connect(self, store_id, *args, **kwargs):
|
||
"""连接后端服务(使用统一连接,无需独立连接)"""
|
||
try:
|
||
self.current_store_id = str(store_id or "")
|
||
except Exception:
|
||
self.current_store_id = ""
|
||
return True
|
||
|
||
async def send_message_to_backend(self, platform_message):
|
||
"""🔥 改为通过单后端连接发送,与拼多多保持完全一致的逻辑"""
|
||
try:
|
||
from WebSocket.backend_singleton import get_backend_client
|
||
backend = get_backend_client()
|
||
if not backend:
|
||
return None
|
||
|
||
# 🔥 确保消息包含store_id(与拼多多一致)
|
||
if isinstance(platform_message, dict):
|
||
if 'store_id' not in platform_message and self.current_store_id:
|
||
platform_message['store_id'] = self.current_store_id
|
||
|
||
# 🔥 通过统一后端连接发送(与拼多多完全一致)
|
||
backend.send_message(platform_message)
|
||
return True
|
||
except Exception:
|
||
return None
|
||
|
||
async def close(self):
|
||
"""关闭连接(统一连接模式下无需特殊处理)"""
|
||
pass
|
||
|
||
|
||
class DouYinMessageHandler:
|
||
"""抖音消息处理器"""
|
||
|
||
def __init__(self, cookie: dict, ai_service: DouYinBackendService,
|
||
store_id: str):
|
||
import uuid
|
||
self.instance_id = str(uuid.uuid4())[:8] # 添加实例ID用于调试
|
||
self.cookie = cookie
|
||
self.ai_service = ai_service
|
||
self.store_id = store_id
|
||
self.user_tokens = {}
|
||
self.config = None
|
||
self.wss_url = None
|
||
self.ws = None
|
||
self.is_running = True
|
||
|
||
self._loop = None # 添加事件循环
|
||
# asyncio.set_event_loop(self._loop) # 设置事件循环
|
||
self._thread = None # 添加事件循环线程
|
||
|
||
# 设置 AI 服务器的消息处理器引用(统一连接模式下无需设置)
|
||
# self.ai_service.set_message_handler(self)
|
||
|
||
# 添加视频解析相关的headers
|
||
self.video_headers = {
|
||
"authority": "pigeon.jinritemai.com",
|
||
"accept": "application/json, text/plain, */*",
|
||
"accept-language": "zh-CN,zh;q=0.9",
|
||
"cache-control": "no-cache",
|
||
"origin": "https://im.jinritemai.com",
|
||
"pragma": "no-cache",
|
||
"referer": "https://im.jinritemai.com/",
|
||
"sec-ch-ua": "\"Not_A Brand\";v=\"8\", \"Chromium\";v=\"120\", \"Google Chrome\";v=\"120\"",
|
||
"sec-ch-ua-mobile": "?0",
|
||
"sec-ch-ua-platform": "\"Windows\"",
|
||
"sec-fetch-dest": "empty",
|
||
"sec-fetch-mode": "cors",
|
||
"sec-fetch-site": "same-site",
|
||
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||
"x-secsdk-csrf-token": "0001000000017e890e18651b2ef5f6d36d0485a64cae6b0bfc36d69e27fdc20fe7d423670eba1861a5bcb5baaf40,a25cfb6098b498c33ee5f0a5dcafe47b"
|
||
}
|
||
|
||
# 🔥 新增:线程池用于异步操作(下载、上传等)
|
||
self.pool = ThreadPoolExecutor(max_workers=3)
|
||
|
||
# 🔥 新增:签名引擎相关
|
||
self.sign_engine_initialized = False
|
||
self.sign_ctx = None
|
||
self.js_engine = None
|
||
|
||
# 打印实例创建信息
|
||
print(f"[DY Handler] 创建实例 {self.instance_id} for store {store_id}")
|
||
|
||
def get_casl(self):
|
||
"""获取可分配客服列表 - 根据原始代码实现"""
|
||
headers = {
|
||
"authority": "pigeon.jinritemai.com",
|
||
"accept": "application/json, text/plain, */*",
|
||
"accept-language": "zh-CN,zh;q=0.9",
|
||
"cache-control": "no-cache",
|
||
"origin": "https://im.jinritemai.com",
|
||
"pragma": "no-cache",
|
||
"referer": "https://im.jinritemai.com/",
|
||
"sec-ch-ua": "\"Not_A Brand\";v=\"8\", \"Chromium\";v=\"120\", \"Google Chrome\";v=\"120\"",
|
||
"sec-ch-ua-mobile": "?0",
|
||
"sec-ch-ua-platform": "\"Windows\"",
|
||
"sec-fetch-dest": "empty",
|
||
"sec-fetch-mode": "cors",
|
||
"sec-fetch-site": "same-site",
|
||
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||
"x-secsdk-csrf-token": "000100000001fbdad828f64ea30cad11ae4188140964c4e321a7f711791309da962ec586d0471861a72a472781fb,a25cfb6098b498c33ee5f0a5dcafe47b"
|
||
}
|
||
url = "https://pigeon.jinritemai.com/backstage/getCanAssignStaffList"
|
||
params = {
|
||
"biz_type": "4",
|
||
"PIGEON_BIZ_TYPE": "2",
|
||
"_ts": int(time.time() * 1000),
|
||
"_pms": "1",
|
||
"FUSION": "true",
|
||
"verifyFp": "", # 🔥 恢复为空字符串,因为原始代码中也是空的
|
||
"_v": "1.0.1.3585"
|
||
}
|
||
try:
|
||
self._log(f"🔄 正在获取客服列表,cookies包含字段: {list(self.cookie.keys())}", "DEBUG")
|
||
# 🔥 按照原始代码的方式处理响应
|
||
response = requests.get(url, headers=headers, cookies=self.cookie, params=params).json()
|
||
self._log(f"客服列表API响应内容: {response}", "DEBUG")
|
||
|
||
if response.get('code') == 0:
|
||
staff_data = response.get('data', [])
|
||
self._log(f"✅ 成功获取客服列表,共 {len(staff_data)} 个客服", "SUCCESS")
|
||
return staff_data
|
||
else:
|
||
error_msg = response.get('message', '未知错误')
|
||
self._log(f"❌ 客服列表API返回错误: code={response.get('code')}, message={error_msg}", "ERROR")
|
||
return None
|
||
except Exception as e:
|
||
self._log(f"❌ 获取客服列表失败: {e}", "ERROR")
|
||
import traceback
|
||
self._log(f"错误详情: {traceback.format_exc()}", "DEBUG")
|
||
return None
|
||
|
||
def transfer_conversation(self, receiver_id, shop_id, staff_id):
|
||
"""转接对话"""
|
||
headers = {
|
||
"authority": "pigeon.jinritemai.com",
|
||
"accept": "application/json, text/plain, */*",
|
||
"accept-language": "zh-CN,zh;q=0.9",
|
||
"cache-control": "no-cache",
|
||
"origin": "https://im.jinritemai.com",
|
||
"pragma": "no-cache",
|
||
"referer": "https://im.jinritemai.com/",
|
||
"sec-ch-ua": "\"Not_A Brand\";v=\"8\", \"Chromium\";v=\"120\", \"Google Chrome\";v=\"120\"",
|
||
"sec-ch-ua-mobile": "?0",
|
||
"sec-ch-ua-platform": "\"Windows\"",
|
||
"sec-fetch-dest": "empty",
|
||
"sec-fetch-mode": "cors",
|
||
"sec-fetch-site": "same-site",
|
||
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||
"x-secsdk-csrf-token": "000100000001fbdad828f64ea30cad11ae4188140964c4e321a7f711791309da962ec586d0471861a72a472781fb,a25cfb6098b498c33ee5f0a5dcafe47b"
|
||
}
|
||
url = "https://pigeon.jinritemai.com/chat/api/backstage/conversation/transfer_conversation?PIGEON_BIZ_TYPE=2"
|
||
params = {
|
||
"bizConversationId": f"{receiver_id}:{shop_id}::2:1:pigeon",
|
||
"toCid": f"{staff_id}",
|
||
"extParams": "{}"
|
||
}
|
||
try:
|
||
response = requests.post(url, headers=headers, cookies=self.cookie, json=params)
|
||
if response.status_code == 200:
|
||
result = response.json()
|
||
if result.get('code') == 0:
|
||
self._log(f"✅ 成功转接对话到客服 {staff_id}", "SUCCESS")
|
||
return True
|
||
self._log(f"❌ 转接对话失败: {response.text}", "ERROR")
|
||
return False
|
||
except Exception as e:
|
||
self._log(f"❌ 转接对话异常: {e}", "ERROR")
|
||
return False
|
||
|
||
# 新增: 发送客服列表消息到后端
|
||
async def send_staff_list_to_backend(self):
|
||
"""发送客服列表到后端"""
|
||
try:
|
||
# 获取客服列表
|
||
staff_list = self.get_casl()
|
||
if staff_list is None:
|
||
self._log("⚠️ 获取客服列表失败", "WARNING")
|
||
return False
|
||
|
||
# 🔥 处理空客服列表的情况(API成功但无客服)
|
||
if len(staff_list) == 0:
|
||
self._log("📋 当前无可分配客服,发送空列表到后端", "INFO")
|
||
# 继续处理,发送空列表给后端
|
||
|
||
# 转换客服数据格式 - 🔥 根据原始代码调试实际字段名
|
||
staff_infos = []
|
||
for staff in staff_list:
|
||
# 打印原始数据结构用于调试
|
||
self._log(f"🔍 原始客服数据结构: {staff}", "DEBUG")
|
||
|
||
staff_info = StaffInfo(
|
||
staff_id=str(staff.get('staffId', '') or staff.get('staff_id', '') or staff.get('id', '')),
|
||
name=staff.get('staffName', '') or staff.get('staff_name', '') or staff.get('name', ''),
|
||
status=str(staff.get('status', 0) or staff.get('state', 0)),
|
||
department=staff.get('department', '') or staff.get('dept', ''),
|
||
online=staff.get('online', True)
|
||
)
|
||
staff_infos.append(staff_info.to_dict())
|
||
|
||
# 创建消息模板
|
||
message_template = PlatformMessage(
|
||
type="staff_list",
|
||
content="客服列表更新",
|
||
data={
|
||
"staff_list": staff_infos,
|
||
"total_count": len(staff_infos)
|
||
},
|
||
store_id=self.store_id
|
||
)
|
||
|
||
# 发送到后端
|
||
await self.ai_service.send_message_to_backend(message_template.to_dict())
|
||
self._log(f"发送客服列表消息的结构体为: {message_template.to_json()}")
|
||
|
||
if len(staff_infos) > 0:
|
||
self._log(f"✅ [DY] 成功发送客服列表到后端,共 {len(staff_infos)} 个客服", "SUCCESS")
|
||
print(f"🔥 [DY] 客服列表已上传到后端: {len(staff_infos)} 个客服")
|
||
print(f"[DY] 客服详情: {[{'id': s['staff_id'], 'name': s['name']} for s in staff_infos]}")
|
||
else:
|
||
self._log(f"✅ [DY] 成功发送空客服列表到后端(当前无可分配客服)", "SUCCESS")
|
||
print(f"🔥 [DY] 空客服列表已上传到后端(当前无可分配客服)")
|
||
|
||
return True
|
||
|
||
except Exception as e:
|
||
self._log(f"❌ 发送客服列表到后端异常: {e}", "ERROR")
|
||
return False
|
||
|
||
def _send_cookie_expired_message(self):
|
||
"""向后端发送Cookie失效通知"""
|
||
try:
|
||
self._log("🔴 准备发送Cookie失效通知给后端", "INFO")
|
||
from WebSocket.backend_singleton import get_backend_client
|
||
backend = get_backend_client()
|
||
|
||
if backend:
|
||
message = {
|
||
"type": "connect_message",
|
||
"store_id": self.store_id,
|
||
"status": False,
|
||
"content": "cookies失效,需要重新登录"
|
||
}
|
||
backend.send_message(message)
|
||
self._log("✅ 已向后端发送Cookie失效通知", "SUCCESS")
|
||
print(f"🔥 [DY] Cookie失效通知已发送: store_id={self.store_id}")
|
||
else:
|
||
self._log("❌ 后端客户端为空,无法发送Cookie失效通知", "ERROR")
|
||
except Exception as e:
|
||
self._log(f"❌ 发送Cookie失效通知失败: {e}", "ERROR")
|
||
import traceback
|
||
self._log(f"错误详情: {traceback.format_exc()}", "DEBUG")
|
||
|
||
def start_event_loop(self):
|
||
"""启动事件循环线程"""
|
||
if self._loop is not None and not self._loop.is_closed():
|
||
self._loop.close()
|
||
|
||
self._loop = asyncio.new_event_loop()
|
||
|
||
def run_loop():
|
||
asyncio.set_event_loop(self._loop)
|
||
try:
|
||
self._loop.run_forever()
|
||
except Exception as e:
|
||
self._log(f"事件循环异常: {e}", "ERROR")
|
||
finally:
|
||
# 清理资源
|
||
tasks = asyncio.all_tasks(self._loop)
|
||
for task in tasks:
|
||
task.cancel()
|
||
self._loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True))
|
||
self._loop.close()
|
||
|
||
self._thread = threading.Thread(target=run_loop, daemon=True)
|
||
self._thread.start()
|
||
|
||
def _log(self, message, level="INFO"):
|
||
"""日志记录"""
|
||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
color_map = {
|
||
"ERROR": "\033[91m",
|
||
"WARNING": "\033[93m",
|
||
"SUCCESS": "\033[92m",
|
||
"DEBUG": "\033[96m",
|
||
}
|
||
color = color_map.get(level, "")
|
||
reset = "\033[0m"
|
||
print(f"{color}[{timestamp}] [{level}] {message}{reset}")
|
||
|
||
def get_config(self):
|
||
"""获取配置信息"""
|
||
headers = {
|
||
'accept': 'application/json, text/plain, */*',
|
||
'accept-language': 'zh-CN,zh;q=0.9',
|
||
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
|
||
}
|
||
params = [
|
||
('PIGEON_BIZ_TYPE', '2'),
|
||
('biz_type', '4'),
|
||
('_ts', int(time.time() * 1000)),
|
||
('_pms', '1'),
|
||
('FUSION', 'true'),
|
||
]
|
||
|
||
try:
|
||
self._log("正在请求配置信息...", "DEBUG")
|
||
response = requests.get(
|
||
'https://pigeon.jinritemai.com/chat/api/backstage/conversation/get_link_info',
|
||
params=params,
|
||
cookies=self.cookie,
|
||
headers=headers,
|
||
timeout=30
|
||
)
|
||
|
||
self._log(f"响应状态码: {response.status_code}", "DEBUG")
|
||
self._log(f"响应内容: {response.text}", "DEBUG")
|
||
|
||
if response.status_code != 200:
|
||
raise Exception(f"HTTP请求失败: {response.status_code}")
|
||
|
||
data = response.json()
|
||
self._log(f"解析后的JSON数据: {data}", "DEBUG")
|
||
|
||
if data.get('code') != 0:
|
||
error_code = data.get('code')
|
||
error_msg = data.get('message', data.get('msg', data.get('extraMsg', '未知错误')))
|
||
|
||
# 🔥 检测到cookie失效(code=10005表示登录过期)
|
||
if error_code == 10005 or '登录过期' in error_msg or '请重新登录' in error_msg:
|
||
self._log(f"❌ 检测到Cookie失效: {error_msg}", "ERROR")
|
||
self._send_cookie_expired_message()
|
||
|
||
raise Exception(f"获取配置失败: {error_msg}")
|
||
|
||
if 'data' not in data:
|
||
raise Exception("响应数据中缺少data字段")
|
||
|
||
config_data = data['data']
|
||
required_fields = ['token', 'appId', 'frontierConfig', 'websocketUrl', 'pigeon_sign']
|
||
for field in required_fields:
|
||
if field not in config_data:
|
||
raise Exception(f"配置数据中缺少必要字段: {field}")
|
||
|
||
params = {
|
||
'token': config_data['token'],
|
||
'aid': config_data['appId'],
|
||
'fpid': str(config_data['frontierConfig']['fpId']),
|
||
'device_id': '1216524360102748',
|
||
'access_key': 'd4ececcc8a27b1fb63389380b8007fb0',
|
||
'device_platform': 'web',
|
||
'version_code': '10000',
|
||
'pigeon_source': 'web',
|
||
'PIGEON_BIZ_TYPE': '2',
|
||
'pigeon_sign': config_data['pigeon_sign']
|
||
}
|
||
|
||
websocket_url = f"{config_data['websocketUrl']}?{urlencode(params)}"
|
||
self._log(f"生成的WebSocket URL: {websocket_url}", "DEBUG")
|
||
|
||
return data, websocket_url
|
||
|
||
except requests.exceptions.RequestException as e:
|
||
self._log(f"请求异常: {e}", "ERROR")
|
||
raise Exception(f"网络请求失败: {e}")
|
||
except json.JSONDecodeError as e:
|
||
self._log(f"JSON解析失败: {e}", "ERROR")
|
||
raise Exception(f"响应解析失败: {e}")
|
||
except Exception as e:
|
||
self._log(f"配置获取失败: {e}", "ERROR")
|
||
raise
|
||
|
||
def on_error(self, ws, error):
|
||
"""WebSocket错误处理"""
|
||
self._log(f"❌ WebSocket错误: {error}", "ERROR")
|
||
# 🔥 不立即设置 is_running = False,让重连机制处理
|
||
# self.is_running = False
|
||
|
||
def on_close(self, ws, close_status_code, close_msg):
|
||
"""WebSocket关闭处理"""
|
||
self._log(f"🔌 连接关闭: {close_status_code}, {close_msg}", "WARNING")
|
||
# 🔥 添加详细的关闭原因分析
|
||
if close_status_code:
|
||
if close_status_code == 1006:
|
||
self._log("⚠️ 异常关闭(1006),可能是网络问题或心跳超时", "WARNING")
|
||
elif close_status_code == 1000:
|
||
self._log("✅ 正常关闭(1000),服务器主动断开", "INFO")
|
||
elif close_status_code == 1001:
|
||
self._log("⚠️ 端点离开(1001)", "WARNING")
|
||
else:
|
||
self._log(f"⚠️ 关闭代码: {close_status_code}", "WARNING")
|
||
|
||
# 🔥 不立即设置 is_running = False,让重连机制处理
|
||
# self.is_running = False
|
||
|
||
def heartbeat_wss(self):
|
||
"""心跳线程"""
|
||
heartbeat_count = 0
|
||
while self.is_running:
|
||
try:
|
||
if self.ws and hasattr(self.ws, 'sock') and self.ws.sock and self.ws.sock.connected:
|
||
self.send_heartbeat()
|
||
heartbeat_count += 1
|
||
# 🔍 每10次心跳输出一次统计
|
||
if heartbeat_count % 10 == 0:
|
||
self._log(f"📊 [心跳统计] 已发送 {heartbeat_count} 次心跳", "INFO")
|
||
|
||
# 🔥 优化:增加心跳间隔到5秒(避免被抖音风控)
|
||
# 并添加随机抖动(4.5-5.5秒)
|
||
import random
|
||
sleep_time = 5 + random.uniform(-0.5, 0.5)
|
||
time.sleep(sleep_time)
|
||
except Exception as e:
|
||
self._log(f"❌ 心跳发送失败: {e}", "ERROR")
|
||
import traceback
|
||
self._log(f"心跳异常详情: {traceback.format_exc()}", "DEBUG")
|
||
time.sleep(5)
|
||
|
||
def send_heartbeat(self):
|
||
"""发送心跳包 - 使用 message_arg 中的方法"""
|
||
try:
|
||
value, message_type = heartbeat_message(
|
||
pigeon_sign=self.config["data"]["pigeon_sign"],
|
||
token=self.config["data"]["token"],
|
||
session_did=self.cookie["PIGEON_CID"]
|
||
)
|
||
form_data = blackboxprotobuf.encode_message(value=value, message_type=message_type)
|
||
self.ws.send_bytes(form_data)
|
||
self._log("💓 发送心跳包", "DEBUG")
|
||
except Exception as e:
|
||
self._log(f"❌ 发送心跳包失败: {e}", "ERROR")
|
||
|
||
def decode_binary_data(self, obj):
|
||
"""
|
||
递归解码字典中的二进制数据
|
||
"""
|
||
if isinstance(obj, dict):
|
||
# 如果是字典,递归处理每个值
|
||
return {k: self.decode_binary_data(v) for k, v in obj.items()}
|
||
elif isinstance(obj, list):
|
||
# 如果是列表,递归处理每个元素
|
||
return [self.decode_binary_data(item) for item in obj]
|
||
elif isinstance(obj, tuple):
|
||
# 如果是元组,递归处理每个元素
|
||
return tuple(self.decode_binary_data(item) for item in obj)
|
||
elif isinstance(obj, bytes):
|
||
# 如果是二进制数据,尝试解码
|
||
try:
|
||
return obj.decode('utf-8')
|
||
except UnicodeDecodeError:
|
||
# 如果UTF-8解码失败,可以尝试其他编码或返回原始数据
|
||
try:
|
||
return obj.decode('latin-1')
|
||
except UnicodeDecodeError:
|
||
return obj # 或者返回原始二进制数据
|
||
else:
|
||
# 其他类型直接返回
|
||
return obj
|
||
|
||
def on_open(self, ws):
|
||
"""WebSocket连接打开"""
|
||
self._log(f"✅ WebSocket连接已打开", "SUCCESS")
|
||
threading.Thread(target=self.heartbeat_wss, daemon=True).start()
|
||
|
||
def code_parse(self, message: dict):
|
||
try:
|
||
user = (message.get("8").get("6").get("610").get("1").get("1")).decode()
|
||
token = (message.get("8").get("6").get("610").get("1").get("4")).decode()
|
||
except:
|
||
user = None
|
||
token = None
|
||
return user, token
|
||
|
||
def _init_user_info(self, receiver_id, token=None):
|
||
"""初始化用户信息"""
|
||
if receiver_id not in self.user_tokens:
|
||
self.user_tokens[receiver_id] = {
|
||
"token": token,
|
||
"last_sent": 0,
|
||
"session_initialized": False,
|
||
"talk_id": 7543146114111898922, # 添加默认值
|
||
"p_id": 7542727339747116329, # 添加默认值
|
||
"pending_messages": []
|
||
}
|
||
elif token:
|
||
self.user_tokens[receiver_id]["token"] = token
|
||
self._log(f"✅ 更新用户 {receiver_id} 的Token", "DEBUG")
|
||
|
||
def on_message(self, ws, content):
|
||
"""处理收到的消息"""
|
||
try:
|
||
message = blackboxprotobuf.decode_message(content)[0]
|
||
|
||
# 解析用户Token(静默处理)
|
||
user, token = self.code_parse(message=message)
|
||
if user and token:
|
||
receiver_id = int(user.split(":")[0])
|
||
self._init_user_info(receiver_id, token)
|
||
|
||
# 获取内部消息结构
|
||
inner_msg = message.get('8', {})
|
||
|
||
# 处理inner_msg为bytes的情况
|
||
if isinstance(inner_msg, bytes):
|
||
try:
|
||
inner_msg = blackboxprotobuf.decode_message(inner_msg)[0]
|
||
except Exception:
|
||
# 无法解析的消息静默忽略
|
||
return
|
||
|
||
if not inner_msg or not isinstance(inner_msg, dict):
|
||
# 消息结构不完整,静默忽略
|
||
return
|
||
|
||
# 获取内部消息类型
|
||
inner_type = inner_msg.get('1')
|
||
inner_data = inner_msg.get('6', {})
|
||
|
||
# 处理不同类型的消息
|
||
if inner_type == 610: # Token消息
|
||
self._log(f"🔑 收到Token消息,开始处理", "DEBUG")
|
||
self._handle_token_message(inner_data)
|
||
|
||
elif inner_type == 500: # 聊天消息(真正的用户消息)
|
||
self._handle_chat_message(inner_data, message)
|
||
|
||
elif inner_type == 200: # 心跳消息
|
||
pass # 静默忽略
|
||
|
||
# 检查是否是心跳响应(放在最后,避免干扰其他消息类型)
|
||
if "5" in message:
|
||
kv_pairs = message["5"]
|
||
if isinstance(kv_pairs, list):
|
||
for pair in kv_pairs:
|
||
if isinstance(pair, dict) and pair.get("1") == b"code" and pair.get("2") == b"0":
|
||
self._log("Heartbeat OK", "INFO")
|
||
return
|
||
|
||
# 其他未知消息类型静默忽略
|
||
|
||
except Exception as e:
|
||
# 只记录真正的错误,忽略解析失败
|
||
if "decode" not in str(e).lower():
|
||
self._log(f"Error processing message: {e}", "ERROR")
|
||
import traceback
|
||
self._log(f"Error details: {traceback.format_exc()}", "DEBUG")
|
||
|
||
async def _process_pending_message(self, sender_id, message_content):
|
||
"""处理待发送的消息(修复:message_content是message_dict字典,需要提取text字段)"""
|
||
try:
|
||
# 获取用户信息
|
||
user_info = self.user_tokens[sender_id]
|
||
talk_id = user_info.get("talk_id", 0)
|
||
p_id = user_info.get("p_id", 0)
|
||
|
||
# 🔥 修复:从 message_dict 中提取文本内容和其他字段
|
||
if isinstance(message_content, dict):
|
||
# message_content 是字典(message_dict)
|
||
message_text = message_content.get('text', '')
|
||
avatar_url = message_content.get('avatar', '')
|
||
|
||
# 确定消息类型
|
||
msg_type = self._determine_message_type(message_content)
|
||
|
||
# 根据消息类型提取对应的内容
|
||
if msg_type == 'video':
|
||
content = message_content.get('video_url', message_text)
|
||
elif msg_type == 'image':
|
||
content = message_content.get('image_url', message_text)
|
||
elif msg_type == 'order':
|
||
order_id = message_content.get('order_id', '')
|
||
goods_id = self.get_goods_id(order_id) if order_id else ''
|
||
if goods_id:
|
||
content = f"商品id:{goods_id} 订单号:{order_id}"
|
||
else:
|
||
content = f"订单号:{order_id}"
|
||
msg_type = 'order_card'
|
||
elif msg_type == 'goods':
|
||
goods_id = message_content.get('goods_id', '')
|
||
content = f"商品卡片ID:{goods_id}"
|
||
msg_type = 'product_card'
|
||
else:
|
||
content = message_text
|
||
else:
|
||
# 兼容字符串类型(旧代码可能传字符串)
|
||
content = message_content
|
||
avatar_url = ''
|
||
msg_type = 'text'
|
||
|
||
# 🔥 统一消息类型检测逻辑(与拼多多保持一致)
|
||
lc = str(content).lower()
|
||
|
||
# 检测消息类型,使用与拼多多相同的逻辑
|
||
if any(ext in lc for ext in [".jpg", ".jpeg", ".png", ".gif", ".webp"]):
|
||
msg_type = "image"
|
||
elif any(ext in lc for ext in [".mp4", ".avi", ".mov", ".wmv", ".flv"]):
|
||
msg_type = "video"
|
||
elif any(keyword in lc for keyword in ['goods.html', 'item.html', 'item.jd.com', '商品卡片id:']):
|
||
msg_type = "product_card"
|
||
|
||
# 🔥 使用工厂方法创建消息模板(与拼多多保持一致)
|
||
message_template = PlatformMessage.create_text_message(
|
||
content=content,
|
||
sender_id=str(sender_id),
|
||
store_id=self.store_id,
|
||
pin_image=avatar_url if avatar_url else None
|
||
)
|
||
# 动态设置检测到的消息类型
|
||
message_template.msg_type = msg_type
|
||
|
||
# 发送消息到后端(使用统一连接)
|
||
await self.ai_service.send_message_to_backend(message_template.to_dict())
|
||
self._log(f"✅ 待发送消息已发送到后端", "INFO")
|
||
|
||
except Exception as e:
|
||
self._log(f"❌ 处理待发送消息失败: {e}", "ERROR")
|
||
|
||
def _handle_token_message(self, message):
|
||
"""处理Token消息"""
|
||
try:
|
||
# 直接从消息中获取token数据
|
||
token_data = message.get('610', {}).get('1', {})
|
||
if not token_data:
|
||
self._log("❌ 未找到token数据", "ERROR")
|
||
return
|
||
|
||
user_bytes = token_data.get('1')
|
||
token_bytes = token_data.get('4')
|
||
|
||
if not (isinstance(user_bytes, bytes) and isinstance(token_bytes, bytes)):
|
||
self._log("❌ token数据格式错误", "ERROR")
|
||
return
|
||
|
||
# 解码用户信息和token
|
||
user = user_bytes.decode('utf-8')
|
||
token = token_bytes.decode('utf-8')
|
||
|
||
# 解析用户ID
|
||
try:
|
||
receiver_id = int(user.split(":")[0])
|
||
except (ValueError, IndexError):
|
||
self._log(f"❌ 无法解析用户ID: {user}", "ERROR")
|
||
return
|
||
|
||
self._log(f"✅ 成功解析Token - 用户: {receiver_id}", "INFO")
|
||
|
||
# 确保用户信息存在并保存token
|
||
if receiver_id not in self.user_tokens:
|
||
self.user_tokens[receiver_id] = {
|
||
"token": None,
|
||
"last_sent": 0,
|
||
"session_initialized": False,
|
||
"talk_id": 0,
|
||
"p_id": 0,
|
||
"pending_messages": []
|
||
}
|
||
|
||
# 保存token
|
||
self.user_tokens[receiver_id]["token"] = token
|
||
self._log(f"✅ 已保存用户 {receiver_id} 的Token (长度: {len(token)})", "INFO")
|
||
|
||
# 处理待发送的消息
|
||
if "pending_messages" in self.user_tokens[receiver_id]:
|
||
pending_msgs = self.user_tokens[receiver_id]["pending_messages"]
|
||
if pending_msgs:
|
||
self._log(f"📤 处理 {len(pending_msgs)} 条待发送消息", "INFO")
|
||
for msg in pending_msgs:
|
||
asyncio.run_coroutine_threadsafe(
|
||
self._process_pending_message(receiver_id, msg),
|
||
self._loop
|
||
)
|
||
# 清空待发送消息列表
|
||
self.user_tokens[receiver_id]["pending_messages"] = []
|
||
|
||
except Exception as e:
|
||
self._log(f"❌ 处理Token消息失败: {e}", "ERROR")
|
||
self._log(f"❌ 错误详情: {traceback.format_exc()}", "DEBUG")
|
||
|
||
def parse_video(self, vid):
|
||
"""解析视频获取播放地址"""
|
||
try:
|
||
self._log(f"🎥 开始解析视频,VID: {vid}", "INFO")
|
||
|
||
url = "https://pigeon.jinritemai.com/backstage/video/getPlayToken"
|
||
params = {
|
||
"vid": vid,
|
||
"_pms": "1"
|
||
}
|
||
|
||
response = requests.get(
|
||
url,
|
||
headers=self.video_headers,
|
||
cookies=self.cookie,
|
||
params=params,
|
||
timeout=36
|
||
).json()
|
||
|
||
self._log(f"视频解析响应: {response}", "DEBUG")
|
||
|
||
if response.get("code") != 0:
|
||
self._log(f"❌ 获取视频token失败: {response.get('message')}", "ERROR")
|
||
return None
|
||
|
||
token = response.get("data", {}).get("token")
|
||
if not token:
|
||
self._log("❌ 未获取到视频token", "ERROR")
|
||
return None
|
||
|
||
# 解析token获取播放地址
|
||
token_url = "https://open.bytedanceapi.com/?" + token
|
||
video_response = requests.get(
|
||
token_url,
|
||
headers=self.video_headers,
|
||
cookies=self.cookie,
|
||
timeout=38
|
||
).json()
|
||
|
||
self._log(f"视频播放信息: {video_response}", "DEBUG")
|
||
|
||
result = video_response.get("Result", {}).get("Data", {}).get("PlayInfoList")
|
||
if result and len(result) > 0:
|
||
play_url = result[0].get("MainPlayUrl")
|
||
if play_url:
|
||
self._log(f"✅ 成功获取视频播放地址: {play_url[:50]}...", "SUCCESS")
|
||
return play_url
|
||
else:
|
||
self._log("❌ 未找到MainPlayUrl", "ERROR")
|
||
else:
|
||
self._log("❌ 获取播放地址失败", "ERROR")
|
||
|
||
return None
|
||
|
||
except requests.exceptions.RequestException as e:
|
||
self._log(f"❌ 网络请求失败: {e}", "ERROR")
|
||
return None
|
||
except json.JSONDecodeError as e:
|
||
self._log(f"❌ JSON解析失败: {e}", "ERROR")
|
||
return None
|
||
except Exception as e:
|
||
self._log(f"❌ 视频解析失败: {e}", "ERROR")
|
||
return None
|
||
|
||
# 获取商品id
|
||
def get_goods_id(self, order_id):
|
||
headers = {
|
||
"authority": "fxg.jinritemai.com",
|
||
"accept": "application/json, text/plain, */*",
|
||
"accept-language": "zh-CN,zh;q=0.9",
|
||
"cache-control": "no-cache",
|
||
"pragma": "no-cache",
|
||
"referer": "https://fxg.jinritemai.com/ffa/morder/order/detail?id=6921052297377971987",
|
||
"sec-ch-ua": "\"Not_A Brand\";v=\"8\", \"Chromium\";v=\"120\", \"Google Chrome\";v=\"120\"",
|
||
"sec-ch-ua-mobile": "?0",
|
||
"sec-ch-ua-platform": "\"Windows\"",
|
||
"sec-fetch-dest": "empty",
|
||
"sec-fetch-mode": "cors",
|
||
"sec-fetch-site": "same-origin",
|
||
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||
}
|
||
url = "https://fxg.jinritemai.com/api/order/orderDetail"
|
||
params = {
|
||
"order_id": order_id,
|
||
"appid": "1",
|
||
"__token": "d83b1e7b9e05390304167f02c95f1a74",
|
||
"_bid": "ffa_order",
|
||
"aid": "4272",
|
||
"_lid": "720383572871",
|
||
"verifyFp": "verify_mf3dk1nv_kbxjZXA8_9XBV_4Rvb_B3iI_XDueP5U4SoQx",
|
||
"fp": "verify_mf3dk1nv_kbxjZXA8_9XBV_4Rvb_B3iI_XDueP5U4SoQx",
|
||
"msToken": "OJcYEsXGO2a1822s3kkoFuk3zwOyj5nhjzxhynXWCz7b4Q4oDuxmbFf85-2C05VezCpYsbhaZ8ZtXs6hl_rrU2Akb5K3kOQQimXOVshe_7yCNW52NA8sAR3nOp_y6N99nQN8VxscYQJ6xLcvQN6CzpsjC7y0gxo6VpfHAVs=",
|
||
}
|
||
response = requests.get(url, headers=headers, cookies=self.cookie, params=params).json()
|
||
# print(response)
|
||
if response.get('code') == 0 and response.get('data').get('order_id') == order_id:
|
||
# 表示成功展示了对应这个订单内部的信息
|
||
product_data = response.get('data').get('product')
|
||
product_id = product_data.get('sku')[0].get('product_id')
|
||
return product_id
|
||
else:
|
||
# 没有找到默认返回None
|
||
print("没有根据订单信息找到内部的商品ID 请核对方法get_product_id")
|
||
return None
|
||
|
||
def _extract_message_text(self, msg_content):
|
||
"""从消息内容中提取文本和图片URL(静默解析)"""
|
||
try:
|
||
# 初始化返回结果
|
||
result = {
|
||
'text': None,
|
||
'avatar': None,
|
||
'image_url': None,
|
||
'thumbnail_url': None,
|
||
'goods_id': None,
|
||
'video_url': None,
|
||
'order_id': None
|
||
}
|
||
|
||
# 方法1: 处理文本消息(从'8'字段获取)
|
||
text_content = msg_content.get('8')
|
||
if text_content:
|
||
if isinstance(text_content, bytes):
|
||
try:
|
||
decoded = text_content.decode('utf-8')
|
||
result['text'] = decoded
|
||
except UnicodeDecodeError:
|
||
try:
|
||
decoded = text_content.decode('gbk')
|
||
result['text'] = decoded
|
||
except:
|
||
pass
|
||
elif isinstance(text_content, str):
|
||
result['text'] = text_content
|
||
|
||
# 方法2: 统一处理'9'字段中的数据(包括文本和图片、消息、视频)
|
||
meta_data = msg_content.get('9', [])
|
||
if meta_data:
|
||
for item in meta_data:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
|
||
key = item.get('1', b'').decode('utf-8') if isinstance(item.get('1'), bytes) else str(
|
||
item.get('1', ''))
|
||
value = item.get('2', b'').decode('utf-8') if isinstance(item.get('2'), bytes) else str(
|
||
item.get('2', ''))
|
||
|
||
# 处理所有类型的URL
|
||
if key == 'avatar_uri':
|
||
result['avatar'] = value
|
||
elif key == 'imageUrl':
|
||
result['image_url'] = value
|
||
elif key == 'thumbnailUrl':
|
||
result['thumbnail_url'] = value
|
||
elif key == 'goods_id':
|
||
goods_id = value
|
||
result['goods_id'] = goods_id
|
||
elif key == 'msg_render_model':
|
||
try:
|
||
video_info = json.loads(value)
|
||
render_body = video_info.get('render_body', {})
|
||
|
||
# 提取视频URL
|
||
viedo_url = render_body.get('coverURL')
|
||
viedo_vid = render_body.get('vid')
|
||
if viedo_vid and viedo_url:
|
||
# 解析获取视频播放地址
|
||
play_url = self.parse_video(viedo_vid)
|
||
if play_url:
|
||
result['video_url'] = play_url
|
||
else:
|
||
# 如果解析失败,使用备用封面图片作为地址
|
||
if '\\u0026' in viedo_url:
|
||
viedo_url = viedo_url.replace('\\u0026', '&')
|
||
result['video_url'] = viedo_url
|
||
except json.JSONDecodeError:
|
||
pass
|
||
elif key == 'sku_order_id':
|
||
# 取到了对应的订单号 id
|
||
order_id = value
|
||
result['order_id'] = order_id
|
||
|
||
# 如果已经找到所有需要的URL,提前退出循环
|
||
if all([result['avatar'], result['image_url'], result['thumbnail_url']]):
|
||
break
|
||
|
||
# 方法3: 兼容处理旧的'9-1'字段(如果存在)
|
||
image_data = msg_content.get('9-1', [])
|
||
if image_data != []:
|
||
for item in image_data:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
|
||
key = item.get('1', b'').decode('utf-8') if isinstance(item.get('1'), bytes) else str(
|
||
item.get('1', ''))
|
||
value = item.get('2', b'').decode('utf-8') if isinstance(item.get('2'), bytes) else str(
|
||
item.get('2', ''))
|
||
|
||
if key == 'avatar_uri' and not result['avatar']:
|
||
result['avatar'] = value
|
||
elif key == 'imageUrl' and not result['image_url']:
|
||
result['image_url'] = value
|
||
elif key == 'thumbnailUrl' and not result['thumbnail_url']:
|
||
result['thumbnail_url'] = value
|
||
elif key == 'goods_id':
|
||
goods_id = value
|
||
result['goods_id'] = goods_id
|
||
elif key == 'msg_render_model':
|
||
try:
|
||
video_info = json.loads(value)
|
||
render_body = video_info.get('render_body', {})
|
||
|
||
# 提取视频URL
|
||
viedo_url = render_body.get('coverURL')
|
||
viedo_vid = render_body.get('vid')
|
||
if viedo_vid and viedo_url:
|
||
# 解析获取视频播放地址
|
||
play_url = self.parse_video(viedo_vid)
|
||
if play_url:
|
||
result['video_url'] = play_url
|
||
else:
|
||
# 如果解析失败,使用备用封面图片作为地址
|
||
if '\\u0026' in viedo_url:
|
||
viedo_url = viedo_url.replace('\\u0026', '&')
|
||
result['video_url'] = viedo_url
|
||
except json.JSONDecodeError:
|
||
pass
|
||
elif key == 'sku_order_id':
|
||
# 取到了对应的订单号 id
|
||
order_id = value
|
||
result['order_id'] = order_id
|
||
|
||
return result
|
||
|
||
except Exception as e:
|
||
# 静默处理异常,返回空结果
|
||
return {
|
||
'text': None,
|
||
'avatar': None,
|
||
'image_url': None,
|
||
'thumbnail_url': None,
|
||
'goods_id': None,
|
||
'video_url': None,
|
||
'order_id': None
|
||
}
|
||
|
||
def _handle_chat_message(self, message, msg_type):
|
||
"""处理聊天消息 - 修改版本,支持多种消息类型"""
|
||
try:
|
||
msg_500 = message.get('500', {})
|
||
if not msg_500:
|
||
return
|
||
|
||
msg_content = msg_500.get('5', {})
|
||
if not msg_content:
|
||
return
|
||
|
||
# 从消息内容中获取类型和发送者ID
|
||
inner_msg_type = msg_content.get("6", 0)
|
||
sender_id_raw = msg_content.get("7", 0)
|
||
|
||
# 关键修复:确保sender_id是int类型,避免与token保存时的key类型不匹配
|
||
try:
|
||
sender_id = int(sender_id_raw) if sender_id_raw else 0
|
||
except (ValueError, TypeError):
|
||
sender_id = 0
|
||
|
||
# 处理不同类型的消息
|
||
if inner_msg_type == 1000 and sender_id and sender_id != int(self.cookie['PIGEON_CID']):
|
||
# 用户文本消息 - 处理AI回复(只有这里会输出日志)
|
||
self._handle_user_text_message(msg_content, sender_id)
|
||
|
||
elif inner_msg_type == 50002 and sender_id and sender_id != int(self.cookie['PIGEON_CID']):
|
||
# 系统消息 - 触发token请求(静默处理)
|
||
self._handle_system_message(msg_content, sender_id)
|
||
|
||
# 其他消息类型静默忽略
|
||
|
||
except Exception as e:
|
||
self._log(f"Error handling chat message: {e}", "ERROR")
|
||
|
||
def _handle_user_text_message(self, msg_content, sender_id):
|
||
"""处理用户文本消息"""
|
||
try:
|
||
# 解析消息内容
|
||
|
||
# 使用引入的新的解析全详情代码(包括图片与头像信息)
|
||
message_dict = self._extract_message_text(msg_content)
|
||
|
||
message_content = None
|
||
if "8" in msg_content:
|
||
content_data = msg_content["8"]
|
||
self._log(f"📄 原始内容数据: {content_data}", "DEBUG")
|
||
|
||
# 🔥 过滤系统消息:客服接入、客服离开等系统通知
|
||
text_content = message_dict.get('text', '') if message_dict else ''
|
||
|
||
# 检查是否为系统消息
|
||
is_system_message = False
|
||
if text_content:
|
||
# 使用正则表达式匹配系统消息模式
|
||
system_patterns = [
|
||
r'客服.*接入', # 客服XX接入
|
||
r'客服.*离开', # 客服XX离开
|
||
r'客服.*转接', # 客服XX转接
|
||
r'会话.*结束', # 会话已结束
|
||
r'.*已转接.*', # XX已转接给XX
|
||
r'系统消息', # 系统消息
|
||
]
|
||
|
||
for pattern in system_patterns:
|
||
if re.search(pattern, text_content):
|
||
is_system_message = True
|
||
self._log(f"🔕 过滤系统消息: '{text_content}'", "DEBUG")
|
||
break
|
||
|
||
if message_dict and text_content and not is_system_message:
|
||
self._log(f"💬 成功解析用户消息: '{message_dict}'", "SUCCESS")
|
||
|
||
# 提取会话信息
|
||
talk_id = msg_content.get("20", 0)
|
||
p_id = msg_content.get("5", 0)
|
||
|
||
self._log(f"📝 会话信息 - TalkID: {talk_id}, PID: {p_id}", "DEBUG")
|
||
|
||
# 存储会话信息
|
||
if sender_id not in self.user_tokens:
|
||
self.user_tokens[sender_id] = {
|
||
"token": None,
|
||
"last_sent": 0,
|
||
"session_initialized": False,
|
||
"talk_id": talk_id,
|
||
"p_id": p_id,
|
||
"pending_messages": []
|
||
}
|
||
else:
|
||
# 更新会话信息
|
||
self.user_tokens[sender_id]["talk_id"] = talk_id
|
||
self.user_tokens[sender_id]["p_id"] = p_id
|
||
|
||
# 使用事件循环提交异步任务
|
||
asyncio.run_coroutine_threadsafe(
|
||
self._get_and_send_ai_reply(sender_id, message_dict, talk_id, p_id),
|
||
self._loop
|
||
)
|
||
else:
|
||
self._log("⚠️ 无法解析消息内容", "WARNING")
|
||
|
||
except Exception as e:
|
||
self._log(f"❌ 处理用户文本消息失败: {e}", "ERROR")
|
||
|
||
def _handle_system_message(self, msg_content, sender_id):
|
||
"""处理系统消息 - 触发token请求(静默处理)"""
|
||
try:
|
||
# 提取会话信息
|
||
talk_id = msg_content.get("20", 0)
|
||
p_id = msg_content.get("5", 0)
|
||
|
||
# 检查是否已经有该用户的token
|
||
if sender_id not in self.user_tokens or not self.user_tokens[sender_id].get("token"):
|
||
# 存储会话信息
|
||
if sender_id not in self.user_tokens:
|
||
self.user_tokens[sender_id] = {
|
||
"token": None,
|
||
"last_sent": 0,
|
||
"session_initialized": False,
|
||
"talk_id": talk_id,
|
||
"p_id": p_id,
|
||
"pending_messages": []
|
||
}
|
||
else:
|
||
# 更新会话信息
|
||
self.user_tokens[sender_id]["talk_id"] = talk_id
|
||
self.user_tokens[sender_id]["p_id"] = p_id
|
||
|
||
# 请求token
|
||
self._request_user_token(sender_id, p_id)
|
||
|
||
except Exception as e:
|
||
self._log(f"Error handling system message: {e}", "ERROR")
|
||
|
||
def _parse_message_content(self, msg_data):
|
||
"""解析消息内容"""
|
||
try:
|
||
# 尝试从不同字段解析内容
|
||
content_fields = ['4', '8', '14']
|
||
for field in content_fields:
|
||
content = msg_data.get(field)
|
||
if content:
|
||
if isinstance(content, bytes):
|
||
try:
|
||
return content.decode('utf-8')
|
||
except UnicodeDecodeError:
|
||
try:
|
||
return content.decode('gbk')
|
||
except:
|
||
continue
|
||
elif isinstance(content, str):
|
||
return content
|
||
return None
|
||
except Exception as e:
|
||
self._log(f"❌ 解析消息内容失败: {e}", "ERROR")
|
||
return None
|
||
|
||
def _determine_message_type(self, message_dict):
|
||
"""确定消息类型"""
|
||
if message_dict.get('video_url'): # 如果确认收到了video_url类型的数据 那么不可能是其它类型
|
||
return 'video'
|
||
elif message_dict.get('image_url'): # 如果确认收到了image_url类型的数据 那么不可能是其它类型
|
||
return 'image'
|
||
elif message_dict.get('order_id'): # 如果确认收到了order_id类型的数据 那么不可能是其它类型 只能是发送询问订单
|
||
return 'order'
|
||
elif message_dict.get('goods_id'): # 如果确认收到了goods_id类型的数据 那么不可能是其它类型 只能是发送询问商品
|
||
return 'goods'
|
||
else:
|
||
return 'text' # 最普遍的类型
|
||
|
||
async def _get_and_send_ai_reply(self, sender_id, message_dict, talk_id, p_id):
|
||
"""获取并发送AI回复 - 修改版本,确保有token"""
|
||
try:
|
||
self._log(f"🚀 AI回复流程开始,用户: {sender_id}", "INFO")
|
||
self._log(f"📋 用户消息内容: '{message_dict}'", "INFO")
|
||
|
||
# 检查是否已有token,如果没有则先请求
|
||
if sender_id not in self.user_tokens or not self.user_tokens[sender_id].get("token"):
|
||
self._log(f"🆕 用户 {sender_id} 无token,先请求token", "INFO")
|
||
# 创建用户对象(与旧版本保持一致,直接覆盖)
|
||
self.user_tokens[sender_id] = {
|
||
"token": None,
|
||
"last_sent": 0,
|
||
"session_initialized": False,
|
||
"talk_id": talk_id,
|
||
"p_id": p_id,
|
||
"pending_messages": []
|
||
}
|
||
# 请求token
|
||
self._request_user_token(sender_id, p_id)
|
||
# 将当前消息加入待处理队列
|
||
self.user_tokens[sender_id]["pending_messages"].append(message_dict)
|
||
return
|
||
else:
|
||
# 用户已有token,直接发送消息到后端
|
||
self._log(f"✅ 用户 {sender_id} 已有token (长度: {len(self.user_tokens[sender_id]['token'])})", "DEBUG")
|
||
|
||
# 预先定义默认值 (避免后续因为没有成功走到对应分支里产生的错误)
|
||
# msg_type = "text"
|
||
message_text = message_dict.get('text', '')
|
||
avatar_url = message_dict.get('avatar', '')
|
||
|
||
# 确定消息类型 (调用确认方法类型)
|
||
msg_type = self._determine_message_type(message_dict)
|
||
|
||
if msg_type == 'video' and avatar_url or message_dict['text'] == "[视频]":
|
||
# 视频类型
|
||
message_text = message_dict['video_url']
|
||
avatar_url = avatar_url
|
||
elif msg_type == 'image' and avatar_url:
|
||
message_text = message_dict['image_url']
|
||
avatar_url = avatar_url
|
||
elif msg_type == 'order':
|
||
msg_type = 'order_card'
|
||
order_id = message_dict['order_id']
|
||
goods_id = self.get_goods_id(order_id)
|
||
if goods_id:
|
||
message_text = f"订单号: {order_id}, 商品ID: {goods_id}"
|
||
else:
|
||
message_text = f"订单号: {order_id}"
|
||
elif msg_type == 'goods' or message_dict.get("text", None) == "[商品]":
|
||
# 商品类型
|
||
msg_type = 'product_card'
|
||
message_text = f"商品卡片ID: {message_dict['goods_id']}"
|
||
elif msg_type == 'text' and avatar_url:
|
||
message_text = message_dict['text']
|
||
|
||
# 🔥 提取goods_info信息(与拼多多保持一致)
|
||
goods_info = {}
|
||
if msg_type == 'order':
|
||
# 从抖音的订单信息中提取goods_info
|
||
order_id = message_dict.get('order_id', '')
|
||
goods_id = self.get_goods_id(order_id) if hasattr(self, 'get_goods_id') else message_dict.get(
|
||
'goods_id', '')
|
||
goods_info = {
|
||
'goodsID': goods_id,
|
||
'orderSequenceNo': order_id
|
||
}
|
||
elif msg_type == 'goods':
|
||
# 从抖音的商品信息中提取goods_info
|
||
goods_id = message_dict.get('goods_id', '')
|
||
goods_info = {
|
||
'goodsID': goods_id
|
||
}
|
||
|
||
# 🔥 统一消息类型检测逻辑(与拼多多完全一致)
|
||
content = message_text
|
||
lc = str(content).lower()
|
||
|
||
# 使用与拼多多完全相同的检测逻辑
|
||
if any(ext in lc for ext in [".jpg", ".jpeg", ".png", ".gif", ".webp"]):
|
||
msg_type = "image"
|
||
elif any(ext in lc for ext in [".mp4", ".avi", ".mov", ".wmv", ".flv"]):
|
||
msg_type = "video"
|
||
else:
|
||
msg_type = "text"
|
||
|
||
# 🔥 订单卡片组装(与拼多多完全一致)
|
||
if ("订单编号" in str(content) or msg_type == 'order') and goods_info:
|
||
content = f"商品id:{goods_info.get('goodsID')} 订单号:{goods_info.get('orderSequenceNo')}"
|
||
msg_type = "order_card"
|
||
|
||
# 🔥 商品卡片检测(与拼多多完全一致)
|
||
elif any(keyword in lc for keyword in ['goods.html', 'item.html', 'item.jd.com', '商品卡片id:']) or \
|
||
(goods_info and goods_info.get('goodsID') and not goods_info.get('orderSequenceNo')):
|
||
msg_type = "product_card"
|
||
|
||
# 🔥 使用工厂方法创建消息模板(与拼多多保持一致)
|
||
message_template = PlatformMessage.create_text_message(
|
||
content=content,
|
||
sender_id=str(sender_id),
|
||
store_id=self.store_id,
|
||
pin_image=avatar_url
|
||
)
|
||
# 动态设置检测到的消息类型
|
||
message_template.msg_type = msg_type
|
||
|
||
# 🔥 添加调试日志(与拼多多保持一致)
|
||
try:
|
||
print(f"📤(SPEC) 发送到AI: {json.dumps(message_template.to_dict(), ensure_ascii=False)[:300]}...")
|
||
except Exception:
|
||
pass
|
||
|
||
self._log("📤 准备发送消息到AI服务...", "INFO")
|
||
self._log(f"📋 消息内容: {message_template.to_json()}", "DEBUG")
|
||
|
||
# 发送消息到后端(使用统一连接,不等待回复)
|
||
start_time = time.time()
|
||
await self.ai_service.send_message_to_backend(message_template.to_dict())
|
||
response_time = time.time() - start_time
|
||
|
||
self._log(f"✅ 消息已发送到后端,耗时: {response_time:.2f}s", "SUCCESS")
|
||
self._log("🔄 等待后端AI处理并通过GUI转发回复", "INFO")
|
||
|
||
except Exception as e:
|
||
self._log(f"❌ 获取AI回复失败: {e}", "ERROR")
|
||
self._log(f"❌ 错误详情: {traceback.format_exc()}", "DEBUG")
|
||
|
||
async def _check_and_send_reply(self, sender_id, talk_id, p_id, reply_text):
|
||
"""检查并发送回复"""
|
||
try:
|
||
self._log(f"🔍 检查发送条件,用户: {sender_id}", "DEBUG")
|
||
|
||
# 确保用户信息存在
|
||
if sender_id not in self.user_tokens:
|
||
self._log(f"❌ 用户 {sender_id} 信息不存在", "ERROR")
|
||
return
|
||
|
||
user_token = self.user_tokens[sender_id].get("token")
|
||
if not user_token:
|
||
self._log(f"⚠️ 用户 {sender_id} token为空,将消息加入待发送队列", "WARNING")
|
||
# 将消息加入待发送队列
|
||
if "pending_messages" not in self.user_tokens[sender_id]:
|
||
self.user_tokens[sender_id]["pending_messages"] = []
|
||
self.user_tokens[sender_id]["pending_messages"].append(reply_text)
|
||
return
|
||
|
||
# 检查发送频率
|
||
current_time = int(time.time() * 1000)
|
||
last_sent = self.user_tokens[sender_id].get("last_sent", 0)
|
||
|
||
if current_time - last_sent < 4000:
|
||
self._log("⏳ 发送太频繁,跳过", "DEBUG")
|
||
return
|
||
|
||
self._log(f"📤 准备发送回复给用户 {sender_id}", "INFO")
|
||
self._log(f"📝 回复内容: '{reply_text}'", "DEBUG")
|
||
|
||
# 发送回复
|
||
success = await self._send_message_to_user(sender_id, talk_id, p_id, user_token, reply_text)
|
||
if success:
|
||
self.user_tokens[sender_id]["last_sent"] = current_time
|
||
self._log(f"✅ 回复发送完成", "SUCCESS")
|
||
else:
|
||
self._log(f"❌ 回复发送失败", "ERROR")
|
||
|
||
except Exception as e:
|
||
self._log(f"❌ 检查并发送回复失败: {e}", "ERROR")
|
||
|
||
async def _send_message_to_user(self, receiver_id, talk_id, p_id, user_token, content):
|
||
"""发送消息给用户 - 核心发送方法"""
|
||
try:
|
||
self._log(f"📤 正在发送消息给用户 {receiver_id}", "DEBUG")
|
||
self._log(f"📝 消息内容: {content}", "DEBUG")
|
||
|
||
# 检查必要的参数
|
||
if not all([receiver_id, talk_id, p_id, user_token, content]):
|
||
self._log("❌ 发送消息参数不全", "ERROR")
|
||
print(f"{receiver_id}--{talk_id}--{p_id}--{user_token}--{content}")
|
||
return False
|
||
|
||
# 使用 message_arg 中的 send_message 方法创建消息
|
||
value, message_type = send_message(
|
||
pigeon_sign=self.config["data"]["pigeon_sign"],
|
||
token=self.config["data"]["token"],
|
||
receiver_id=receiver_id,
|
||
shop_id=self.cookie["SHOP_ID"],
|
||
talk_id=talk_id,
|
||
session_did=self.cookie["PIGEON_CID"],
|
||
p_id=p_id,
|
||
user_code=user_token,
|
||
text=content
|
||
)
|
||
|
||
# 编码消息
|
||
form_data = blackboxprotobuf.encode_message(value=value, message_type=message_type)
|
||
|
||
# 发送消息
|
||
self.ws.send_bytes(form_data)
|
||
self._log(f"✅ 消息已发送给用户 {receiver_id}", "SUCCESS")
|
||
|
||
return True
|
||
|
||
except Exception as e:
|
||
self._log(f"❌ 发送消息给用户失败: {e}", "ERROR")
|
||
return False
|
||
|
||
def _request_user_token(self, receiver_id, p_id):
|
||
"""请求用户token - 使用 message_arg 中的方法"""
|
||
try:
|
||
# 使用 message_arg 中的 get_user_code 方法
|
||
|
||
value, message_type = get_user_code(
|
||
pigeon_sign=self.config["data"]["pigeon_sign"],
|
||
token=self.config["data"]["token"],
|
||
receiver_id=receiver_id,
|
||
shop_id=self.cookie["SHOP_ID"],
|
||
session_did=self.cookie["PIGEON_CID"],
|
||
p_id=p_id
|
||
)
|
||
form_data = blackboxprotobuf.encode_message(value=value, message_type=message_type)
|
||
self.ws.send_bytes(form_data)
|
||
self._log(f"📤 已请求用户 {receiver_id} 的token", "INFO")
|
||
except Exception as e:
|
||
self._log(f"❌ 请求token失败: {e}", "ERROR")
|
||
|
||
# ==================== 🔥 新增:图片/视频处理方法 ====================
|
||
|
||
def _initialize_sign_engine(self):
|
||
"""初始化JavaScript签名引擎(用于图片/视频上传签名)"""
|
||
if self.sign_engine_initialized:
|
||
return True
|
||
|
||
try:
|
||
self._log("🔧 [DY上传] 初始化JavaScript签名引擎...", "INFO")
|
||
|
||
# 获取 sign.js 文件路径
|
||
from windows_taskbar_fix import get_resource_path
|
||
sign_js_path = get_resource_path("static/js/sign.js")
|
||
|
||
if not os.path.exists(sign_js_path):
|
||
self._log(f"❌ [DY上传] 签名文件不存在: {sign_js_path}", "ERROR")
|
||
return False
|
||
|
||
# 读取 sign.js 文件
|
||
with open(sign_js_path, 'r', encoding='utf-8') as f:
|
||
jscode = f.read()
|
||
|
||
# 🔧 优先使用PyMiniRacer(内置V8,无需外部JavaScript环境)
|
||
if PYMINIRACER_AVAILABLE:
|
||
self._log("✅ [DY上传] 使用PyMiniRacer内置JavaScript引擎", "INFO")
|
||
self.js_engine = "PyMiniRacer"
|
||
self.sign_ctx = MiniRacer()
|
||
self.sign_ctx.eval(jscode)
|
||
self._log("✅ [DY上传] PyMiniRacer引擎初始化成功", "SUCCESS")
|
||
else:
|
||
# 回退到execjs(需要Node.js环境)
|
||
self._log("⚠️ [DY上传] PyMiniRacer不可用,回退到execjs", "WARNING")
|
||
try:
|
||
import execjs
|
||
self.js_engine = "execjs"
|
||
self.sign_ctx = execjs.compile(jscode)
|
||
self._log("✅ [DY上传] execjs引擎初始化成功", "SUCCESS")
|
||
except ImportError:
|
||
self._log("❌ [DY上传] execjs未安装,无法使用签名功能", "ERROR")
|
||
return False
|
||
|
||
self.sign_engine_initialized = True
|
||
return True
|
||
|
||
except Exception as e:
|
||
self._log(f"❌ [DY上传] 初始化签名引擎失败: {e}", "ERROR")
|
||
import traceback
|
||
self._log(f"错误详情: {traceback.format_exc()}", "DEBUG")
|
||
return False
|
||
|
||
def _call_sign_function(self, function_name, *args):
|
||
"""调用JavaScript签名函数(统一接口)"""
|
||
try:
|
||
if not self.sign_engine_initialized:
|
||
if not self._initialize_sign_engine():
|
||
raise Exception("签名引擎未初始化")
|
||
|
||
if self.js_engine == "PyMiniRacer":
|
||
# PyMiniRacer调用方式
|
||
js_args = ", ".join([json.dumps(arg) if not isinstance(arg, (int, float, bool)) else str(arg) for arg in args])
|
||
js_call = f"{function_name}({js_args})"
|
||
return self.sign_ctx.eval(js_call)
|
||
else:
|
||
# execjs调用方式
|
||
return self.sign_ctx.call(function_name, *args)
|
||
|
||
except Exception as e:
|
||
self._log(f"❌ [DY上传] 调用签名函数失败: {e}", "ERROR")
|
||
raise
|
||
|
||
def _get_file_extension(self, url, default_ext):
|
||
"""智能提取文件扩展名
|
||
|
||
Args:
|
||
url: 文件URL
|
||
default_ext: 默认扩展名(jpg/mp4)
|
||
|
||
Returns:
|
||
str: 文件扩展名
|
||
"""
|
||
try:
|
||
# 移除查询参数
|
||
url_without_params = url.split('?')[0]
|
||
|
||
# 检查是否有有效的文件扩展名
|
||
if '.' in url_without_params:
|
||
parts = url_without_params.split('.')
|
||
ext = parts[-1].lower()
|
||
|
||
# 验证扩展名是否合法(只包含字母数字)
|
||
if ext and len(ext) <= 5 and ext.isalnum():
|
||
# 常见图片/视频扩展名
|
||
valid_exts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv']
|
||
if ext in valid_exts:
|
||
return ext
|
||
|
||
# 如果无法提取有效扩展名,使用默认值
|
||
return default_ext
|
||
|
||
except Exception:
|
||
return default_ext
|
||
|
||
async def _download_media(self, media_url, media_type="image", max_retries=3):
|
||
"""下载图片或视频文件(带重试机制 + 智能扩展名识别)
|
||
|
||
Args:
|
||
media_url: 媒体文件URL
|
||
media_type: 媒体类型(image/video)
|
||
max_retries: 最大重试次数
|
||
|
||
Returns:
|
||
tuple: (local_file_path, file_size_kb) 或 (None, None)
|
||
"""
|
||
for attempt in range(max_retries):
|
||
try:
|
||
if attempt > 0:
|
||
self._log(f"🔄 [DY{media_type}] 第{attempt + 1}次重试下载...", "INFO")
|
||
else:
|
||
self._log(f"🔽 [DY{media_type}] 开始下载: {media_url[:100]}...", "INFO")
|
||
|
||
# 使用线程池下载文件(避免阻塞)
|
||
response = await asyncio.get_event_loop().run_in_executor(
|
||
self.pool,
|
||
lambda: requests.get(media_url, timeout=30, headers={
|
||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||
})
|
||
)
|
||
|
||
if response.status_code != 200:
|
||
self._log(f"❌ [DY{media_type}] 下载失败,HTTP状态码: {response.status_code}", "ERROR")
|
||
if attempt < max_retries - 1:
|
||
await asyncio.sleep(1)
|
||
continue
|
||
return None, None
|
||
|
||
# 获取文件数据
|
||
file_data = response.content
|
||
file_size_kb = len(file_data) // 1024
|
||
|
||
# 检查文件大小(限制50MB)
|
||
if file_size_kb > 51200:
|
||
self._log(f"❌ [DY{media_type}] 文件过大: {file_size_kb}KB,超过50MB限制", "ERROR")
|
||
return None, None
|
||
|
||
# 检查文件是否为空
|
||
if file_size_kb == 0:
|
||
self._log(f"❌ [DY{media_type}] 下载的文件为空", "ERROR")
|
||
if attempt < max_retries - 1:
|
||
await asyncio.sleep(1)
|
||
continue
|
||
return None, None
|
||
|
||
# 保存到临时文件
|
||
temp_dir = os.path.join(os.path.dirname(__file__), "temp_uploads")
|
||
os.makedirs(temp_dir, exist_ok=True)
|
||
|
||
# 🔥 修复:智能提取文件扩展名
|
||
default_ext = 'mp4' if media_type == 'video' else 'jpg'
|
||
ext = self._get_file_extension(media_url, default_ext)
|
||
temp_filename = f"{media_type}_{uuid.uuid4().hex[:12]}.{ext}"
|
||
temp_filepath = os.path.join(temp_dir, temp_filename)
|
||
|
||
# 写入临时文件
|
||
with open(temp_filepath, 'wb') as f:
|
||
f.write(file_data)
|
||
|
||
self._log(f"✅ [DY{media_type}] 下载成功,大小: {file_size_kb}KB,文件: {temp_filename}", "SUCCESS")
|
||
return temp_filepath, file_size_kb
|
||
|
||
except requests.exceptions.RequestException as e:
|
||
self._log(f"❌ [DY{media_type}] 网络请求失败: {e}", "ERROR")
|
||
if attempt < max_retries - 1:
|
||
await asyncio.sleep(2)
|
||
continue
|
||
return None, None
|
||
except Exception as e:
|
||
self._log(f"❌ [DY{media_type}] 下载失败: {e}", "ERROR")
|
||
if attempt < max_retries - 1:
|
||
await asyncio.sleep(1)
|
||
continue
|
||
return None, None
|
||
|
||
self._log(f"❌ [DY{media_type}] 下载失败,已重试{max_retries}次", "ERROR")
|
||
return None, None
|
||
|
||
def _cleanup_temp_file(self, file_path):
|
||
"""清理临时文件"""
|
||
try:
|
||
if file_path and os.path.exists(file_path):
|
||
os.remove(file_path)
|
||
self._log(f"🗑️ [DY上传] 临时文件已删除: {os.path.basename(file_path)}", "DEBUG")
|
||
except Exception as e:
|
||
self._log(f"⚠️ [DY上传] 删除临时文件失败: {e}", "WARNING")
|
||
|
||
async def _get_upload_token(self, upload_type="image"):
|
||
"""获取上传Token(图片或视频)"""
|
||
try:
|
||
self._log(f"📝 [DY上传] 请求{upload_type}上传Token...", "DEBUG")
|
||
|
||
headers = {
|
||
"authority": "pigeon.jinritemai.com",
|
||
"accept": "application/json, text/plain, */*",
|
||
"accept-language": "zh-CN,zh;q=0.9",
|
||
"cache-control": "no-cache",
|
||
"origin": "https://im.jinritemai.com",
|
||
"pragma": "no-cache",
|
||
"referer": "https://im.jinritemai.com/",
|
||
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||
"x-secsdk-csrf-token": "000100000001d87d9750fd37e74337d4d45e4b1d099ae24c4a56636764617b7be09bbe989ab618710dd6176c296d,796bb03b67483620a4e078de481a1100"
|
||
}
|
||
|
||
if upload_type == "video":
|
||
url = "https://pigeon.jinritemai.com/backstage/video/getUploadToken"
|
||
else:
|
||
url = "https://pigeon.jinritemai.com/backstage/getSTS2Token"
|
||
|
||
params = {
|
||
"biz_type": "4",
|
||
"PIGEON_BIZ_TYPE": "2",
|
||
"_ts": int(time.time() * 1000),
|
||
"_pms": "1",
|
||
"FUSION": "true",
|
||
"verifyFp": "verify_mh2rv3to_ZqgJkknT_7C5g_4Ov9_9Rz5_zIPjTzd4Ee6L",
|
||
"_v": "1.0.1.4380"
|
||
}
|
||
|
||
response = await asyncio.get_event_loop().run_in_executor(
|
||
self.pool,
|
||
lambda: requests.get(url, headers=headers, cookies=self.cookie, params=params)
|
||
)
|
||
|
||
if response.status_code != 200:
|
||
self._log(f"❌ [DY上传] 获取上传Token失败: HTTP {response.status_code}", "ERROR")
|
||
return None
|
||
|
||
result = response.json()
|
||
data = result.get("data")
|
||
|
||
if upload_type == "video":
|
||
token_data = data.get("token", {})
|
||
return {
|
||
"access_key_id": token_data.get("access_key_id"),
|
||
"secret_access_key": token_data.get("secret_access_key"),
|
||
"session_token": token_data.get("session_token")
|
||
}
|
||
else:
|
||
return {
|
||
"access_key_id": data.get("AccessKeyID"),
|
||
"secret_access_key": data.get("SecretAccessKey"),
|
||
"service_id": data.get("ServiceId"),
|
||
"session_token": data.get("SessionToken")
|
||
}
|
||
|
||
except Exception as e:
|
||
self._log(f"❌ [DY上传] 获取上传Token失败: {e}", "ERROR")
|
||
return None
|
||
|
||
async def _upload_image_to_douyin(self, image_path, token_info):
|
||
"""上传图片到抖音服务器(完整流程)"""
|
||
try:
|
||
self._log("📤 [DY图片] 开始上传图片到抖音服务器...", "INFO")
|
||
|
||
current_time = datetime.utcnow()
|
||
formatted_time = current_time.strftime("%Y%m%dT%H%M%SZ")
|
||
random_string = ''.join(random.choices(string.ascii_lowercase + string.digits, k=10))
|
||
|
||
# 步骤1: 申请上传
|
||
upload_node = await self._apply_image_upload(
|
||
formatted_time, random_string, token_info, action="ApplyImageUpload"
|
||
)
|
||
if not upload_node:
|
||
return None
|
||
|
||
session_key = upload_node.get("SessionKey")
|
||
store_uri = upload_node.get("StoreInfos", [{}])[0].get("StoreUri")
|
||
authorization = upload_node.get("StoreInfos", [{}])[0].get("Auth")
|
||
|
||
# 步骤2-4: 分片上传
|
||
upload_id = await self._upload_file_part(store_uri, authorization, image_path, step=1)
|
||
if not upload_id:
|
||
return None
|
||
|
||
success = await self._upload_file_part(store_uri, authorization, image_path, step=2, upload_id=upload_id)
|
||
if not success:
|
||
return None
|
||
|
||
with open(image_path, 'rb') as f:
|
||
content = f.read()
|
||
crc32_hex = format(zlib.crc32(content), '08x')
|
||
|
||
success = await self._upload_file_part(store_uri, authorization, image_path, step=3, upload_id=upload_id, crc=crc32_hex)
|
||
if not success:
|
||
return None
|
||
|
||
# 步骤5: 提交上传获取图片URI
|
||
result = await self._apply_image_upload(
|
||
formatted_time, random_string, token_info,
|
||
action="CommitImageUpload", session_key=session_key
|
||
)
|
||
if result:
|
||
image_uri = result.get("ImageUri")
|
||
self._log(f"✅ [DY图片] 上传成功,URI: {image_uri}", "SUCCESS")
|
||
return image_uri
|
||
return None
|
||
|
||
except Exception as e:
|
||
self._log(f"❌ [DY图片] 上传失败: {e}", "ERROR")
|
||
return None
|
||
|
||
async def _apply_image_upload(self, formatted_time, random_string, token_info, action, session_key=None):
|
||
"""申请或提交图片上传"""
|
||
try:
|
||
url = "https://imagex.bytedanceapi.com/"
|
||
|
||
if action == "ApplyImageUpload":
|
||
params = {
|
||
"Action": "ApplyImageUpload",
|
||
"Version": "2018-08-01",
|
||
"ServiceId": token_info["service_id"],
|
||
"s": random_string
|
||
}
|
||
sign = self._call_sign_function(
|
||
"signature", "GET", "imagex",
|
||
"AWS4" + token_info["secret_access_key"],
|
||
formatted_time, token_info["session_token"], params
|
||
)
|
||
else:
|
||
params = {
|
||
"Action": "CommitImageUpload",
|
||
"Version": "2018-08-01",
|
||
"SessionKey": session_key,
|
||
"ServiceId": token_info["service_id"]
|
||
}
|
||
sign = self._call_sign_function(
|
||
"signature", "POST", "imagex",
|
||
"AWS4" + token_info["secret_access_key"],
|
||
formatted_time, token_info["session_token"], params
|
||
)
|
||
|
||
authorization = f"AWS4-HMAC-SHA256 Credential={token_info['access_key_id']}/{formatted_time.split('T')[0]}/cn-north-1/imagex/aws4_request, SignedHeaders=x-amz-date;x-amz-security-token, Signature={sign}"
|
||
|
||
headers = {
|
||
"authority": "imagex.bytedanceapi.com",
|
||
"accept": "*/*",
|
||
"accept-language": "zh-CN,zh;q=0.9",
|
||
"authorization": authorization,
|
||
"cache-control": "no-cache",
|
||
"origin": "https://im.jinritemai.com",
|
||
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||
"x-amz-date": formatted_time,
|
||
"x-amz-security-token": token_info["session_token"]
|
||
}
|
||
|
||
if action == "ApplyImageUpload":
|
||
response = await asyncio.get_event_loop().run_in_executor(
|
||
self.pool,
|
||
lambda: requests.get(url, headers=headers, params=params)
|
||
)
|
||
result = response.json()
|
||
return result.get("Result", {}).get("InnerUploadAddress", {}).get("UploadNodes", [{}])[0]
|
||
else:
|
||
response = await asyncio.get_event_loop().run_in_executor(
|
||
self.pool,
|
||
lambda: requests.post(url, headers=headers, params=params)
|
||
)
|
||
result = response.json()
|
||
return result.get("Result", {}).get("PluginResult", [{}])[0]
|
||
|
||
except Exception as e:
|
||
self._log(f"❌ [DY图片] {action}失败: {e}", "ERROR")
|
||
return None
|
||
|
||
async def _upload_file_part(self, uri, authorization, file_path, step, crc=None, upload_id=None):
|
||
"""上传图片文件分片(带超时和重试)"""
|
||
try:
|
||
headers = {
|
||
"Authorization": authorization,
|
||
"Cache-Control": "no-cache",
|
||
"Connection": "keep-alive",
|
||
"Host": "tos-d-x-lf.douyin.com",
|
||
"Origin": "https://im.jinritemai.com",
|
||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||
"X-Storage-U": self.cookie["PIGEON_CID"],
|
||
}
|
||
url = f"https://tos-d-x-lf.douyin.com/{uri}"
|
||
|
||
if step == 1:
|
||
headers["Content-Type"] = "multipart/form-data"
|
||
params = {"uploads": ""}
|
||
response = await asyncio.get_event_loop().run_in_executor(
|
||
self.pool,
|
||
lambda: requests.post(url, headers=headers, params=params, timeout=30)
|
||
)
|
||
return response.json().get("payload", {}).get("uploadID")
|
||
|
||
elif step == 2:
|
||
with open(file_path, 'rb') as f:
|
||
content = f.read()
|
||
headers["Content-Crc32"] = format(zlib.crc32(content), '08x')
|
||
headers["Content-Length"] = str(len(content))
|
||
headers["Content-Type"] = 'application/octet-stream'
|
||
params = {"partNumber": "1", "uploadID": upload_id}
|
||
response = await asyncio.get_event_loop().run_in_executor(
|
||
self.pool,
|
||
lambda: requests.put(url, headers=headers, params=params, data=content, timeout=120)
|
||
)
|
||
return response.json().get("error", {}).get("code") == 200
|
||
|
||
elif step == 3:
|
||
headers["Content-Type"] = 'text/plain;charset=UTF-8'
|
||
params = {"uploadID": upload_id}
|
||
response = await asyncio.get_event_loop().run_in_executor(
|
||
self.pool,
|
||
lambda: requests.post(url, headers=headers, params=params, data=f"1:{crc}", timeout=30)
|
||
)
|
||
return response.json().get("error", {}).get("code") == 200
|
||
|
||
except Exception as e:
|
||
self._log(f"❌ [DY图片] 分片上传失败(步骤{step}): {e}", "ERROR")
|
||
return None if step == 1 else False
|
||
|
||
async def _send_image_message(self, receiver_id, talk_id, p_id, user_token, image_uri):
|
||
"""发送图片消息给用户"""
|
||
try:
|
||
self._log(f"📤 [DY图片] 发送图片消息给用户 {receiver_id}", "INFO")
|
||
|
||
value, message_type = send_img(
|
||
pigeon_sign=self.config["data"]["pigeon_sign"],
|
||
token=self.config["data"]["token"],
|
||
receiver_id=receiver_id,
|
||
shop_id=self.cookie["SHOP_ID"],
|
||
talk_id=talk_id,
|
||
session_did=self.cookie["PIGEON_CID"],
|
||
p_id=p_id,
|
||
user_code=user_token,
|
||
img=image_uri
|
||
)
|
||
|
||
form_data = blackboxprotobuf.encode_message(value=value, message_type=message_type)
|
||
self.ws.send_bytes(form_data)
|
||
|
||
self._log(f"✅ [DY图片] 图片消息已发送", "SUCCESS")
|
||
return True
|
||
|
||
except Exception as e:
|
||
self._log(f"❌ [DY图片] 发送失败: {e}", "ERROR")
|
||
return False
|
||
|
||
async def _upload_video_to_douyin(self, video_path, token_info):
|
||
"""上传视频到抖音服务器(完整流程)"""
|
||
try:
|
||
self._log("📤 [DY视频] 开始上传视频...", "INFO")
|
||
|
||
# 🔥 获取实际文件大小
|
||
file_size = os.path.getsize(video_path)
|
||
self._log(f"🔍 [DY视频] 文件大小: {file_size} 字节", "DEBUG")
|
||
|
||
current_time = datetime.utcnow()
|
||
formatted_time = current_time.strftime("%Y%m%dT%H%M%SZ")
|
||
random_string = ''.join(random.choices(string.ascii_lowercase + string.digits, k=10))
|
||
|
||
upload_node = await self._apply_video_upload(formatted_time, random_string, token_info, file_size, action="ApplyUploadInner")
|
||
if not upload_node:
|
||
return None
|
||
|
||
session_key = upload_node.get("SessionKey")
|
||
store_uri = upload_node.get("StoreInfos", [{}])[0].get("StoreUri")
|
||
authorization = upload_node.get("StoreInfos", [{}])[0].get("Auth")
|
||
|
||
upload_id = await self._upload_video_part(store_uri, authorization, video_path, step=1)
|
||
if not upload_id:
|
||
return None
|
||
|
||
success = await self._upload_video_part(store_uri, authorization, video_path, step=2, upload_id=upload_id)
|
||
if not success:
|
||
return None
|
||
|
||
with open(video_path, 'rb') as f:
|
||
content = f.read()
|
||
crc32_hex = format(zlib.crc32(content), '08x')
|
||
|
||
success = await self._upload_video_part(store_uri, authorization, video_path, step=3, upload_id=upload_id, crc=crc32_hex)
|
||
if not success:
|
||
return None
|
||
|
||
result = await self._apply_video_upload(formatted_time, random_string, token_info, file_size, action="CommitUploadInner", session_key=session_key)
|
||
if result:
|
||
# 🔥 添加调试日志,查看返回结构
|
||
self._log(f"🔍 [DY视频] CommitUpload返回结构: {json.dumps(result, ensure_ascii=False)[:500]}", "DEBUG")
|
||
|
||
poster_uri = result.get("PosterUri")
|
||
cover_url = await self._get_url_for_uri(poster_uri) if poster_uri else ""
|
||
|
||
# 🔥 提取视频元数据
|
||
video_meta = result.get("VideoMeta", {})
|
||
if not video_meta:
|
||
self._log(f"⚠️ [DY视频] VideoMeta为空,使用默认值", "WARNING")
|
||
|
||
vid = result.get("Vid")
|
||
width = video_meta.get("Width") if video_meta else 1920
|
||
height = video_meta.get("Height") if video_meta else 1080
|
||
duration = video_meta.get("Duration") if video_meta else 0
|
||
|
||
self._log(f"🔍 [DY视频] 提取的元数据: vid={vid}, width={width}, height={height}, duration={duration}", "DEBUG")
|
||
|
||
return {
|
||
"vid": vid,
|
||
"width": str(width),
|
||
"height": str(height),
|
||
"duration": str(duration),
|
||
"cover_url": cover_url
|
||
}
|
||
return None
|
||
|
||
except Exception as e:
|
||
self._log(f"❌ [DY视频] 上传失败: {e}", "ERROR")
|
||
return None
|
||
|
||
async def _apply_video_upload(self, formatted_time, random_string, token_info, file_size, action, session_key=None):
|
||
"""申请或提交视频上传"""
|
||
try:
|
||
url = "https://open.bytedanceapi.com/"
|
||
|
||
if action == "ApplyUploadInner":
|
||
# 🔥 修复:使用实际文件大小,不是固定值
|
||
params = {"Action": "ApplyUploadInner", "Version": "2020-11-19", "SpaceName": "pigeon-video", "FileType": "video", "IsInner": "1", "FileSize": str(file_size), "s": random_string}
|
||
sign = self._call_sign_function("signature", "GET", "vod", "AWS4" + token_info["secret_access_key"], formatted_time, token_info["session_token"], params)
|
||
else:
|
||
params = {"Action": "CommitUploadInner", "Version": "2020-11-19", "SpaceName": "pigeon-video"}
|
||
payload = {"SessionKey": session_key, "Functions": [{"name": "GetMeta"}, {"name": "Snapshot", "input": {"SnapshotTime": 0}}]}
|
||
body_sha256 = hashlib.sha256(json.dumps(payload).replace(" ", "").encode()).hexdigest()
|
||
sign = self._call_sign_function("signature", "POST", "vod", "AWS4" + token_info["secret_access_key"], formatted_time, token_info["session_token"], params, payload)
|
||
|
||
authorization = f"AWS4-HMAC-SHA256 Credential={token_info['access_key_id']}/{formatted_time.split('T')[0]}/cn-north-1/vod/aws4_request, SignedHeaders="
|
||
authorization += "x-amz-content-sha256;x-amz-date;x-amz-security-token, Signature=" + sign if action == "CommitUploadInner" else "x-amz-date;x-amz-security-token, Signature=" + sign
|
||
|
||
headers = {
|
||
"authorization": authorization,
|
||
"x-amz-date": formatted_time,
|
||
"x-amz-security-token": token_info["session_token"],
|
||
"User-Agent": "Mozilla/5.0"
|
||
}
|
||
|
||
if action == "ApplyUploadInner":
|
||
response = await asyncio.get_event_loop().run_in_executor(self.pool, lambda: requests.get(url, headers=headers, params=params))
|
||
result = response.json()
|
||
self._log(f"🔍 [DY视频] ApplyUploadInner响应: {json.dumps(result, ensure_ascii=False)[:300]}", "DEBUG")
|
||
return result.get("Result", {}).get("InnerUploadAddress", {}).get("UploadNodes", [{}])[0]
|
||
else:
|
||
headers["X-Amz-Content-Sha256"] = body_sha256
|
||
response = await asyncio.get_event_loop().run_in_executor(self.pool, lambda: requests.post(url, headers=headers, params=params, data=json.dumps(payload).replace(" ", "")))
|
||
full_result = response.json()
|
||
self._log(f"🔍 [DY视频] CommitUploadInner完整响应: {json.dumps(full_result, ensure_ascii=False)[:800]}", "DEBUG")
|
||
# 🔥 修复:直接返回 Result 对象,而不是 Results 数组的第一个元素
|
||
# 因为视频元数据在 Result 中,不在 Results[0] 中
|
||
result_data = full_result.get("Result", {})
|
||
|
||
# 检查是否有 Results 数组(某些情况下返回格式不同)
|
||
if "Results" in result_data and result_data["Results"]:
|
||
self._log(f"🔍 [DY视频] 使用Results数组: {result_data['Results'][0]}", "DEBUG")
|
||
return result_data["Results"][0]
|
||
else:
|
||
# 直接返回 Result 对象(包含 Vid, VideoMeta, PosterUri 等)
|
||
self._log(f"🔍 [DY视频] 使用Result对象", "DEBUG")
|
||
return result_data
|
||
|
||
except Exception as e:
|
||
self._log(f"❌ [DY视频] {action}失败: {e}", "ERROR")
|
||
return None
|
||
|
||
async def _upload_video_part(self, uri, authorization, file_path, step, crc=None, upload_id=None):
|
||
"""上传视频文件分片"""
|
||
try:
|
||
headers = {
|
||
"Authorization": authorization,
|
||
"Host": "tos-hl-x.snssdk.com",
|
||
"Origin": "https://im.jinritemai.com",
|
||
"X-Storage-U": self.cookie["PIGEON_CID"]
|
||
}
|
||
url = f"https://tos-hl-x.snssdk.com/{uri}"
|
||
|
||
if step == 1:
|
||
response = await asyncio.get_event_loop().run_in_executor(
|
||
self.pool,
|
||
lambda: requests.post(url, headers=headers, params={"uploads": ""}, timeout=30)
|
||
)
|
||
return response.json().get("payload", {}).get("uploadID")
|
||
|
||
elif step == 2:
|
||
with open(file_path, 'rb') as f:
|
||
content = f.read()
|
||
headers["Content-Crc32"] = format(zlib.crc32(content), '08x')
|
||
headers["Content-Type"] = 'application/octet-stream'
|
||
response = await asyncio.get_event_loop().run_in_executor(
|
||
self.pool,
|
||
lambda: requests.put(url, headers=headers, params={"partNumber": "1", "uploadID": upload_id}, data=content, timeout=120)
|
||
)
|
||
return response.json().get("error", {}).get("code") == 200
|
||
|
||
elif step == 3:
|
||
headers["Content-Type"] = 'text/plain;charset=UTF-8'
|
||
response = await asyncio.get_event_loop().run_in_executor(
|
||
self.pool,
|
||
lambda: requests.post(url, headers=headers, params={"uploadID": upload_id}, data=f"1:{crc}", timeout=30)
|
||
)
|
||
return response.json().get("error", {}).get("code") == 200
|
||
|
||
except Exception as e:
|
||
self._log(f"❌ [DY视频] 分片上传失败(步骤{step}): {e}", "ERROR")
|
||
return None if step == 1 else False
|
||
|
||
async def _get_url_for_uri(self, uri):
|
||
"""根据URI获取实际URL"""
|
||
try:
|
||
params = {"biz_type": "4", "PIGEON_BIZ_TYPE": "2", "_ts": int(time.time() * 1000), "_pms": "1", "FUSION": "true", "uri": uri, "file_type": "image"}
|
||
response = await asyncio.get_event_loop().run_in_executor(
|
||
self.pool,
|
||
lambda: requests.get("https://pigeon.jinritemai.com/backstage/getURLForURI", headers=self.video_headers, params=params, cookies=self.cookie)
|
||
)
|
||
return response.json().get("data", {}).get("k3s_url")
|
||
except Exception as e:
|
||
self._log(f"❌ [DY视频] 获取封面URL失败: {e}", "ERROR")
|
||
return None
|
||
|
||
async def _put_video(self, vid, receiver_id):
|
||
"""激活视频(关键步骤:告诉抖音这个视频可以使用了)
|
||
|
||
Args:
|
||
vid: 视频ID
|
||
receiver_id: 接收者ID
|
||
|
||
Returns:
|
||
bool: 是否成功
|
||
"""
|
||
try:
|
||
self._log(f"🔥 [DY视频] 激活视频: vid={vid}, receiver_id={receiver_id}", "INFO")
|
||
|
||
headers = {
|
||
"authority": "pigeon.jinritemai.com",
|
||
"accept": "application/json, text/plain, */*",
|
||
"accept-language": "zh-CN,zh;q=0.9",
|
||
"cache-control": "no-cache",
|
||
"content-type": "application/json;charset=UTF-8",
|
||
"origin": "https://im.jinritemai.com",
|
||
"pragma": "no-cache",
|
||
"referer": "https://im.jinritemai.com/",
|
||
"sec-ch-ua": "\"Not_A Brand\";v=\"8\", \"Chromium\";v=\"120\", \"Google Chrome\";v=\"120\"",
|
||
"sec-ch-ua-mobile": "?0",
|
||
"sec-ch-ua-platform": "\"Windows\"",
|
||
"sec-fetch-dest": "empty",
|
||
"sec-fetch-mode": "cors",
|
||
"sec-fetch-site": "same-site",
|
||
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||
"x-secsdk-csrf-token": "0001000000012c9d3ac3a79ed92ea62c56059c02dea78e3b9f2c856b2095ac3596036ea5d508187236fe1d77f3fd,796bb03b67483620a4e078de481a1100"
|
||
}
|
||
url = "https://pigeon.jinritemai.com/backstage/video/putVideo"
|
||
params = {
|
||
"vid": vid,
|
||
"receiver_id": str(receiver_id),
|
||
"biz_type": "4",
|
||
"PIGEON_BIZ_TYPE": "2",
|
||
"_ts": int(time.time() * 1000),
|
||
"_pms": "1",
|
||
"FUSION": "true",
|
||
"verifyFp": "verify_mh8hyodv_XxOgxVPT_WN66_4mBk_9Ht4_O0qGQxZuhqqo",
|
||
"_v": "1.0.1.4423"
|
||
}
|
||
data = {}
|
||
data_str = json.dumps(data, separators=(',', ':'))
|
||
|
||
response = await asyncio.get_event_loop().run_in_executor(
|
||
self.pool,
|
||
lambda: requests.post(url, headers=headers, cookies=self.cookie, params=params, data=data_str)
|
||
)
|
||
|
||
self._log(f"✅ [DY视频] 视频激活成功: {response.text[:200]}", "SUCCESS")
|
||
return True
|
||
|
||
except Exception as e:
|
||
self._log(f"❌ [DY视频] 视频激活失败: {e}", "ERROR")
|
||
return False
|
||
|
||
async def _send_video_message(self, receiver_id, talk_id, p_id, user_token, video_info):
|
||
"""发送视频消息给用户"""
|
||
try:
|
||
self._log(f"📤 [DY视频] 发送视频消息给用户 {receiver_id}", "INFO")
|
||
|
||
value, message_type = send_video(
|
||
pigeon_sign=self.config["data"]["pigeon_sign"],
|
||
token=self.config["data"]["token"],
|
||
receiver_id=receiver_id,
|
||
shop_id=self.cookie["SHOP_ID"],
|
||
talk_id=talk_id,
|
||
session_did=self.cookie["PIGEON_CID"],
|
||
p_id=p_id,
|
||
user_code=user_token,
|
||
vid=video_info["vid"],
|
||
cover_url=video_info["cover_url"],
|
||
height=video_info["height"],
|
||
width=video_info["width"],
|
||
duration=video_info["duration"]
|
||
)
|
||
|
||
form_data = blackboxprotobuf.encode_message(value=value, message_type=message_type)
|
||
self.ws.send_bytes(form_data)
|
||
|
||
self._log(f"✅ [DY视频] 视频消息已发送", "SUCCESS")
|
||
return True
|
||
|
||
except Exception as e:
|
||
self._log(f"❌ [DY视频] 发送失败: {e}", "ERROR")
|
||
return False
|
||
|
||
def _log_user_tokens_state(self):
|
||
"""记录当前 user_tokens 状态(用于调试)"""
|
||
self._log("🔍 当前 user_tokens 状态:", "DEBUG")
|
||
for user_id, info in self.user_tokens.items():
|
||
token_status = "有" if info.get("token") else "无"
|
||
self._log(
|
||
f" 用户 {user_id}: token={token_status}, talk_id={info.get('talk_id')}, p_id={info.get('p_id')}",
|
||
"DEBUG")
|
||
|
||
async def _handle_customer_message(self, message_data):
|
||
"""处理来自后端的客服消息"""
|
||
try:
|
||
self._log(f"🔍 _handle_customer_message 被调用,当前实例: {self}", "DEBUG")
|
||
self._log_user_tokens_state()
|
||
|
||
content = message_data.get("content", "")
|
||
if not content:
|
||
self._log("⚠️ 客服消息内容为空", "WARNING")
|
||
return
|
||
self._log(f"后端客服传输的消息为:{content}")
|
||
|
||
receiver_info = message_data.get("receiver", {})
|
||
receiver_id = receiver_info.get("id")
|
||
|
||
if receiver_id and content:
|
||
self._log(f"📤 收到客服消息,准备发送给用户 {receiver_id}", "INFO")
|
||
|
||
# 确保 receiver_id 是整数类型
|
||
try:
|
||
receiver_id = int(receiver_id)
|
||
except (ValueError, TypeError):
|
||
self._log(f"❌ 无法转换用户ID为整数: {receiver_id}", "ERROR")
|
||
return
|
||
|
||
# 检查用户信息是否存在,如果不存在则初始化
|
||
if receiver_id not in self.user_tokens:
|
||
self._log(f"🆕 用户 {receiver_id} 信息不存在,初始化用户信息", "INFO")
|
||
self.user_tokens[receiver_id] = {
|
||
"token": None,
|
||
"last_sent": 0,
|
||
"session_initialized": False,
|
||
"talk_id": 7543146114111898922, # 使用默认值
|
||
"p_id": 7542727339747116329, # 使用默认值
|
||
"pending_messages": [content] # 直接将消息加入待发送队列
|
||
}
|
||
|
||
# 如果没有token,尝试获取token
|
||
if not self.user_tokens[receiver_id]["token"]:
|
||
self._log(f"🔄 用户 {receiver_id} 无token,尝试获取", "INFO")
|
||
# 触发token获取
|
||
self._request_user_token(
|
||
receiver_id,
|
||
self.user_tokens[receiver_id]["p_id"]
|
||
)
|
||
# 注意:这里不返回,让消息已经在队列中等待处理
|
||
return
|
||
|
||
user_info = self.user_tokens[receiver_id]
|
||
|
||
# 如果有token,直接发送消息
|
||
if user_info.get("token"):
|
||
self._log(f"✅ 用户 {receiver_id} 有token,直接发送消息", "INFO")
|
||
# 检查必要的字段是否存在
|
||
required_fields = ["talk_id", "p_id", "token"]
|
||
for field in required_fields:
|
||
if field not in user_info or not user_info[field]:
|
||
self._log(f"❌ 用户 {receiver_id} 缺少必要字段: {field}", "ERROR")
|
||
return
|
||
|
||
# 发送消息
|
||
await self._send_message_to_user(
|
||
receiver_id,
|
||
user_info["talk_id"],
|
||
user_info["p_id"],
|
||
user_info["token"],
|
||
content
|
||
)
|
||
else:
|
||
# 没有token,将消息加入队列并请求token
|
||
self._log(f"⚠️ 用户 {receiver_id} 无token,将消息加入队列", "INFO")
|
||
if "pending_messages" not in user_info:
|
||
user_info["pending_messages"] = []
|
||
user_info["pending_messages"].append(content)
|
||
# 请求token
|
||
self._request_user_token(
|
||
receiver_id,
|
||
user_info["p_id"]
|
||
)
|
||
|
||
except Exception as e:
|
||
self._log(f"❌ 处理客服消息失败: {e}", "ERROR")
|
||
self._log(f"❌ 错误详情: {traceback.format_exc()}", "DEBUG")
|
||
|
||
async def start_async(self):
|
||
"""异步启动消息处理器(不阻塞调用线程)"""
|
||
try:
|
||
self._log("🚀 异步启动消息处理器...", "INFO")
|
||
|
||
# 启动事件循环
|
||
self.start_event_loop()
|
||
|
||
# 获取配置
|
||
self.config, self.wss_url = self.get_config()
|
||
self._log("✅ 配置获取成功", "SUCCESS")
|
||
|
||
# 连接后端服务(使用统一连接,无需独立连接)
|
||
await self.ai_service.connect(self.store_id)
|
||
self._log("✅ 后端服务连接成功(使用统一连接)", "SUCCESS")
|
||
|
||
# 创建WebSocket连接
|
||
self.ws = WebSocketApp(
|
||
self.wss_url,
|
||
header={
|
||
'Origin': 'https://im.jinritemai.com',
|
||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
|
||
},
|
||
on_open=self.on_open,
|
||
on_message=self.on_message,
|
||
on_error=self.on_error,
|
||
on_close=self.on_close
|
||
)
|
||
|
||
# 添加重连机制
|
||
def run_with_reconnect():
|
||
while self.is_running:
|
||
try:
|
||
self.ws.run_forever()
|
||
except Exception as e:
|
||
self._log(f"WebSocket连接异常: {e}, 5秒后重连...", "WARNING")
|
||
time.sleep(5)
|
||
continue
|
||
|
||
# 注册消息处理器(统一连接模式下无需注册)
|
||
|
||
# 在新线程中运行WebSocket
|
||
ws_thread = threading.Thread(target=run_with_reconnect, daemon=True)
|
||
ws_thread.start()
|
||
|
||
self._log("🟢 消息处理器异步启动完成", "SUCCESS")
|
||
return True
|
||
|
||
except Exception as e:
|
||
self._log(f"❌ 异步启动失败: {e}", "ERROR")
|
||
return False
|
||
|
||
def start(self):
|
||
"""启动消息处理器"""
|
||
try:
|
||
self._log("🚀 启动消息处理器...", "INFO")
|
||
|
||
# 启动事件循环
|
||
self.start_event_loop()
|
||
|
||
# 获取配置
|
||
self.config, self.wss_url = self.get_config()
|
||
self._log("✅ 配置获取成功", "SUCCESS")
|
||
|
||
# 连接后端服务(使用统一连接,无需独立连接)
|
||
async def connect_backend_service():
|
||
await self.ai_service.connect(self.store_id)
|
||
self._log("✅ 后端服务连接成功(使用统一连接)", "SUCCESS")
|
||
|
||
# 在事件循环中执行连接
|
||
asyncio.run_coroutine_threadsafe(connect_backend_service(), self._loop)
|
||
|
||
# 创建WebSocket连接
|
||
self.ws = WebSocketApp(
|
||
self.wss_url,
|
||
header={
|
||
'Origin': 'https://im.jinritemai.com',
|
||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
|
||
},
|
||
on_open=self.on_open,
|
||
on_message=self.on_message,
|
||
on_error=self.on_error,
|
||
on_close=self.on_close
|
||
)
|
||
|
||
# 添加重连机制
|
||
def run_with_reconnect():
|
||
while self.is_running:
|
||
try:
|
||
self.ws.run_forever()
|
||
except Exception as e:
|
||
self._log(f"WebSocket连接异常: {e}, 5秒后重连...", "WARNING")
|
||
time.sleep(5)
|
||
continue
|
||
|
||
# 注册消息处理器(统一连接模式下无需注册)
|
||
|
||
# 在新线程中运行WebSocket
|
||
ws_thread = threading.Thread(target=run_with_reconnect, daemon=True)
|
||
ws_thread.start()
|
||
|
||
self._log("🟢 开始运行消息处理器...", "INFO")
|
||
|
||
# !!!关键修改:保持主线程运行!!!
|
||
while self.is_running:
|
||
time.sleep(1) # 主线程保持运行,防止进程退出
|
||
|
||
except Exception as e:
|
||
self._log(f"❌ 启动失败: {e}", "ERROR")
|
||
finally:
|
||
self._log("🛑 消息处理器已停止", "INFO")
|
||
|
||
async def close(self):
|
||
"""关闭连接"""
|
||
self.is_running = False
|
||
if self.ws:
|
||
self.ws.close()
|
||
if self.ai_service:
|
||
await self.ai_service.close()
|
||
|
||
# 关闭事件循环
|
||
if self._loop:
|
||
self._loop.call_soon_threadsafe(self._loop.stop)
|
||
if self._thread:
|
||
self._thread.join(timeout=1.0)
|
||
|
||
self._log("🛑 消息处理器已停止", "INFO")
|
||
|
||
async def send_message_external(self, receiver_id: str, content: str, msg_type: str = "text") -> bool:
|
||
"""外部调用的发送消息方法 - 用于后端消息转发(支持图片/视频)"""
|
||
temp_file = None
|
||
try:
|
||
self._log(f"🔄 [External-{self.instance_id}] 收到转发请求: receiver_id={receiver_id}, msg_type={msg_type}, content={content[:100] if content else ''}",
|
||
"INFO")
|
||
|
||
# 修复数据类型不匹配问题:将字符串转换为整数
|
||
try:
|
||
receiver_id_int = int(receiver_id)
|
||
except ValueError:
|
||
self._log(f"❌ [External-{self.instance_id}] receiver_id 无法转换为整数: {receiver_id}", "ERROR")
|
||
return False
|
||
|
||
if receiver_id_int not in self.user_tokens:
|
||
self._log(f"❌ [External-{self.instance_id}] 用户 {receiver_id_int} 不在活跃会话中", "WARNING")
|
||
self.print_active_users_debug()
|
||
return False
|
||
|
||
user_info = self.user_tokens[receiver_id_int]
|
||
talk_id = user_info.get("talk_id")
|
||
p_id = user_info.get("p_id")
|
||
user_token = user_info.get("token")
|
||
|
||
if not talk_id or not p_id:
|
||
self._log(f"❌ [External-{self.instance_id}] 缺少会话信息", "ERROR")
|
||
return False
|
||
|
||
if not user_token:
|
||
self._log(f"⚠️ [External-{self.instance_id}] token为空,加入待发送队列", "WARNING")
|
||
self._request_user_token(receiver_id_int, p_id)
|
||
if "pending_messages" not in user_info:
|
||
user_info["pending_messages"] = []
|
||
user_info["pending_messages"].append(content)
|
||
return True
|
||
|
||
# 🔥 根据消息类型分发处理
|
||
success = False
|
||
|
||
if msg_type == "image":
|
||
# 图片处理流程
|
||
self._log(f"🖼️ [External-{self.instance_id}] 开始图片发送流程", "INFO")
|
||
|
||
# 1. 下载图片
|
||
temp_file, file_size = await self._download_media(content, "image")
|
||
if not temp_file:
|
||
self._log(f"❌ [External-{self.instance_id}] 图片下载失败,回退发送提示", "WARNING")
|
||
return await self._send_message_to_user(receiver_id_int, talk_id, p_id, user_token, "[图片发送失败:下载失败]")
|
||
|
||
# 2. 获取上传Token
|
||
token_info = await self._get_upload_token("image")
|
||
if not token_info:
|
||
self._log(f"❌ [External-{self.instance_id}] 获取Token失败", "WARNING")
|
||
return await self._send_message_to_user(receiver_id_int, talk_id, p_id, user_token, "[图片发送失败:获取凭证失败]")
|
||
|
||
# 3. 上传图片
|
||
image_uri = await self._upload_image_to_douyin(temp_file, token_info)
|
||
if not image_uri:
|
||
self._log(f"❌ [External-{self.instance_id}] 图片上传失败", "WARNING")
|
||
return await self._send_message_to_user(receiver_id_int, talk_id, p_id, user_token, "[图片发送失败:上传失败]")
|
||
|
||
# 4. 发送图片消息
|
||
success = await self._send_image_message(receiver_id_int, talk_id, p_id, user_token, image_uri)
|
||
|
||
elif msg_type == "video":
|
||
# 视频处理流程
|
||
self._log(f"🎥 [External-{self.instance_id}] 开始视频发送流程", "INFO")
|
||
|
||
temp_file, file_size = await self._download_media(content, "video")
|
||
if not temp_file:
|
||
return await self._send_message_to_user(receiver_id_int, talk_id, p_id, user_token, "[视频发送失败:下载失败]")
|
||
|
||
token_info = await self._get_upload_token("video")
|
||
if not token_info:
|
||
return await self._send_message_to_user(receiver_id_int, talk_id, p_id, user_token, "[视频发送失败:获取凭证失败]")
|
||
|
||
video_info = await self._upload_video_to_douyin(temp_file, token_info)
|
||
if not video_info:
|
||
return await self._send_message_to_user(receiver_id_int, talk_id, p_id, user_token, "[视频发送失败:上传失败]")
|
||
|
||
# 🔥 关键新增:激活视频(告诉抖音这个视频可以使用)
|
||
vid = video_info.get("vid")
|
||
if vid:
|
||
put_success = await self._put_video(vid, receiver_id_int)
|
||
if not put_success:
|
||
self._log(f"⚠️ [External-{self.instance_id}] 视频激活失败,但继续发送", "WARNING")
|
||
else:
|
||
self._log(f"⚠️ [External-{self.instance_id}] 未获取到VID,跳过激活步骤", "WARNING")
|
||
|
||
success = await self._send_video_message(receiver_id_int, talk_id, p_id, user_token, video_info)
|
||
|
||
else:
|
||
# 文本消息(默认)
|
||
success = await self._send_message_to_user(receiver_id_int, talk_id, p_id, user_token, content)
|
||
|
||
if success:
|
||
user_info["last_sent"] = int(time.time() * 1000)
|
||
self._log(f"✅ [External-{self.instance_id}] 消息转发成功", "SUCCESS")
|
||
|
||
return success
|
||
|
||
except Exception as e:
|
||
self._log(f"❌ [External-{self.instance_id}] 异常: {e}", "ERROR")
|
||
self._log(f"错误详情: {traceback.format_exc()}", "DEBUG")
|
||
return False
|
||
finally:
|
||
# 清理临时文件
|
||
if temp_file:
|
||
self._cleanup_temp_file(temp_file)
|
||
|
||
def get_active_users_info(self) -> dict:
|
||
"""获取当前活跃用户的详细信息"""
|
||
try:
|
||
active_users = {}
|
||
for user_id, info in self.user_tokens.items():
|
||
active_users[user_id] = {
|
||
"has_token": bool(info.get("token")),
|
||
"talk_id": info.get("talk_id"),
|
||
"p_id": info.get("p_id"),
|
||
"last_sent": info.get("last_sent", 0),
|
||
"pending_messages_count": len(info.get("pending_messages", [])),
|
||
"session_initialized": info.get("session_initialized", False)
|
||
}
|
||
return active_users
|
||
except Exception as e:
|
||
self._log(f"获取活跃用户信息失败: {e}", "ERROR")
|
||
return {}
|
||
|
||
def print_active_users_debug(self):
|
||
"""打印当前活跃用户的调试信息"""
|
||
try:
|
||
active_users = self.get_active_users_info()
|
||
self._log(f"🔍 [Debug-{self.instance_id}] 当前活跃用户总数: {len(active_users)}", "INFO")
|
||
|
||
if not active_users:
|
||
self._log(f"📝 [Debug-{self.instance_id}] 没有活跃用户。用户需要先在抖音平台发送消息建立会话。", "INFO")
|
||
return
|
||
|
||
for user_id, info in active_users.items():
|
||
self._log(f"👤 [Debug-{self.instance_id}] 用户 {user_id}:", "INFO")
|
||
self._log(f" - Token: {'✅' if info['has_token'] else '❌'}", "INFO")
|
||
self._log(f" - Talk ID: {info['talk_id']}", "INFO")
|
||
self._log(f" - P ID: {info['p_id']}", "INFO")
|
||
self._log(f" - 待发送消息: {info['pending_messages_count']}", "INFO")
|
||
self._log(f" - 会话已初始化: {'✅' if info['session_initialized'] else '❌'}", "INFO")
|
||
|
||
except Exception as e:
|
||
self._log(f"打印调试信息失败: {e}", "ERROR")
|
||
|
||
|
||
class DouYinChatBot:
|
||
"""抖音聊天机器人主类"""
|
||
|
||
def __init__(self, cookie: dict, store_id: str = None):
|
||
self.cookie = cookie
|
||
self.store_id = store_id
|
||
if not self.store_id:
|
||
self.store_id = "68c16836-abac-4307-b763-ea1154823356"
|
||
self.ai_service = DouYinBackendService()
|
||
self.message_handler = None
|
||
self.loop = asyncio.new_event_loop() # 统一的事件循环
|
||
self._stop_event = asyncio.Event() # 添加停止事件
|
||
|
||
async def initialize(self):
|
||
"""初始化聊天机器人"""
|
||
try:
|
||
# 创建消息处理器
|
||
self.message_handler = DouYinMessageHandler(
|
||
cookie=self.cookie,
|
||
ai_service=self.ai_service,
|
||
store_id=self.store_id
|
||
)
|
||
|
||
return True
|
||
|
||
except Exception as e:
|
||
print(f"❌ 初始化失败: {e}")
|
||
return False
|
||
|
||
def start(self):
|
||
"""启动聊天机器人"""
|
||
try:
|
||
# 运行初始化
|
||
asyncio.set_event_loop(self.loop)
|
||
success = self.loop.run_until_complete(self.initialize())
|
||
|
||
if success:
|
||
# 启动消息处理器
|
||
self.message_handler.start()
|
||
|
||
# !!!关键修改:简化主循环!!!
|
||
# 保持主线程运行,等待停止信号
|
||
while not self._stop_event.is_set():
|
||
time.sleep(1)
|
||
|
||
else:
|
||
print("❌ 聊天机器人启动失败")
|
||
|
||
except Exception as e:
|
||
print(f"❌ 启动失败: {e}")
|
||
traceback.print_exc()
|
||
finally:
|
||
if not self._stop_event.is_set():
|
||
self.loop.close()
|
||
|
||
async def close(self):
|
||
"""关闭聊天机器人"""
|
||
self._stop_event.set() # 设置停止信号
|
||
|
||
if self.message_handler:
|
||
await self.message_handler.close()
|
||
if self.ai_service:
|
||
await self.ai_service.close()
|
||
|
||
|
||
class DouYinListenerForGUI:
|
||
"""用于GUI集成的抖音监听包装器"""
|
||
|
||
def __init__(self, log_callback=None):
|
||
self.douyin_bot = None
|
||
self.log_callback = log_callback
|
||
self.running = False
|
||
self.stop_event = None
|
||
self.loop = None
|
||
|
||
def _log(self, message, log_type="INFO"):
|
||
"""处理日志输出"""
|
||
if self.log_callback:
|
||
self.log_callback(message, log_type)
|
||
else:
|
||
print(f"[{log_type}] {message}")
|
||
|
||
async def start_listening(self, cookie_dict, text="您好,感谢您的咨询,我们会尽快回复您!"):
|
||
"""启动监听的主方法"""
|
||
try:
|
||
self._log("🔵 开始抖音平台连接流程", "INFO")
|
||
|
||
# 验证cookie
|
||
if not cookie_dict:
|
||
self._log("❌ Cookie信息不能为空", "ERROR")
|
||
return False
|
||
|
||
required_fields = ['SHOP_ID', 'PIGEON_CID', 'sessionid']
|
||
missing_fields = [field for field in required_fields if field not in cookie_dict]
|
||
if missing_fields:
|
||
self._log(f"❌ 缺少必需的Cookie字段: {', '.join(missing_fields)}", "ERROR")
|
||
return False
|
||
|
||
self._log(f"✅ Cookie验证通过,店铺ID: {cookie_dict['SHOP_ID']}", "SUCCESS")
|
||
|
||
# 创建抖音聊天机器人实例
|
||
self.douyin_bot = DouYinChatBot(cookie=cookie_dict)
|
||
self.running = True
|
||
self.stop_event = asyncio.Event()
|
||
|
||
self._log("🎉 开始监听抖音平台消息...", "SUCCESS")
|
||
|
||
# 初始化并启动机器人
|
||
success = await self.douyin_bot.initialize()
|
||
if success:
|
||
# 在新的事件循环中运行机器人
|
||
self.loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(self.loop)
|
||
|
||
# 启动机器人
|
||
def run_bot():
|
||
self.douyin_bot.start()
|
||
|
||
# 在新线程中运行机器人
|
||
bot_thread = threading.Thread(target=run_bot, daemon=True)
|
||
bot_thread.start()
|
||
|
||
return True
|
||
else:
|
||
self._log("❌ 抖音机器人初始化失败", "ERROR")
|
||
return False
|
||
|
||
except Exception as e:
|
||
self._log(f"❌ 监听过程中出现严重错误: {str(e)}", "ERROR")
|
||
import traceback
|
||
self._log(f"错误详情: {traceback.format_exc()}", "DEBUG")
|
||
return False
|
||
|
||
async def start_with_cookies(self, store_id, cookie_dict):
|
||
"""使用下发的cookies与store_id直接建立抖音平台WS并开始监听"""
|
||
try:
|
||
self._log("🔵 [DY] 收到后端登录指令,开始使用cookies连接平台", "INFO")
|
||
|
||
# 验证cookie
|
||
if not cookie_dict:
|
||
self._log("❌ Cookie信息不能为空", "ERROR")
|
||
return False
|
||
|
||
required_fields = ['SHOP_ID', 'PIGEON_CID', 'sessionid']
|
||
missing_fields = [field for field in required_fields if field not in cookie_dict]
|
||
if missing_fields:
|
||
self._log(f"❌ 缺少必需的Cookie字段: {', '.join(missing_fields)}", "ERROR")
|
||
return False
|
||
|
||
self._log(f"✅ Cookie验证通过,店铺ID: {cookie_dict['SHOP_ID']}", "SUCCESS")
|
||
|
||
# 建立与后端的统一连接(确保使用GUI的store_id)
|
||
backend_service = DouYinBackendService()
|
||
await backend_service.connect(store_id)
|
||
self._log("✅ 后端服务连接成功(使用统一连接)", "SUCCESS")
|
||
|
||
# 创建抖音聊天机器人实例
|
||
self.douyin_bot = DouYinChatBot(cookie=cookie_dict, store_id=store_id)
|
||
# 设置后端服务
|
||
self.douyin_bot.ai_service = backend_service
|
||
self.running = True
|
||
self.stop_event = asyncio.Event()
|
||
|
||
self._log("🎉 开始监听抖音平台消息...", "SUCCESS")
|
||
|
||
# 异步初始化机器人
|
||
success = await self.douyin_bot.initialize()
|
||
if success:
|
||
# 异步启动消息处理器
|
||
if self.douyin_bot.message_handler:
|
||
message_handler_success = await self.douyin_bot.message_handler.start_async()
|
||
if not message_handler_success:
|
||
self._log("❌ 消息处理器启动失败", "ERROR")
|
||
return False
|
||
|
||
# 发送客服列表到后端
|
||
try:
|
||
# 🔥 等待一小段时间确保连接完全建立
|
||
await asyncio.sleep(1)
|
||
staff_list_success = await self.douyin_bot.message_handler.send_staff_list_to_backend()
|
||
if staff_list_success:
|
||
print(f"🔥 [DY] 客服列表已上传到后端")
|
||
else:
|
||
print(f"⚠️ [DY] 客服列表上传失败")
|
||
except Exception as e:
|
||
print(f"❌ [DY] 客服列表上传异常: {e}")
|
||
self._log(f"❌ 抖音客服列表上传异常: {e}", "ERROR")
|
||
|
||
# 注册到全局管理器
|
||
dy_manager = DouYinWebsocketManager()
|
||
shop_key = f"抖音:{store_id}"
|
||
dy_manager.on_connect(shop_key, self.douyin_bot, store_id=store_id, cookie=cookie_dict)
|
||
self._log(f"✅ 已注册抖音连接: {shop_key}", "SUCCESS")
|
||
|
||
# 创建一个长期运行的任务来保持监听状态
|
||
async def keep_running():
|
||
try:
|
||
while not self.stop_event.is_set():
|
||
await asyncio.sleep(1)
|
||
except asyncio.CancelledError:
|
||
pass
|
||
|
||
# 在后台启动监听任务
|
||
asyncio.create_task(keep_running())
|
||
|
||
# 🔥 新增:Cookie登录成功后发送登录成功报告(与登录参数模式保持一致)
|
||
try:
|
||
from WebSocket.backend_singleton import get_backend_client
|
||
backend = get_backend_client()
|
||
|
||
if backend:
|
||
message = {
|
||
"type": "connect_message",
|
||
"store_id": store_id,
|
||
"status": True, # 登录成功
|
||
"cookies": cookie_dict # 添加cookie信息
|
||
}
|
||
backend.send_message(message)
|
||
self._log("✅ [DY] 已向后端发送Cookie登录成功报告", "SUCCESS")
|
||
else:
|
||
self._log("⚠️ [DY] 无法获取后端客户端,跳过状态报告", "WARNING")
|
||
except Exception as e:
|
||
self._log(f"⚠️ [DY] 发送登录成功报告失败: {e}", "WARNING")
|
||
|
||
self._log("✅ [DY] 抖音平台连接成功,开始监听消息", "SUCCESS")
|
||
return True
|
||
else:
|
||
self._log("❌ 抖音机器人初始化失败", "ERROR")
|
||
return False
|
||
|
||
except Exception as e:
|
||
self._log(f"❌ [DY] 监听过程中出现严重错误: {str(e)}", "ERROR")
|
||
import traceback
|
||
self._log(f"错误详情: {traceback.format_exc()}", "DEBUG")
|
||
return False
|
||
|
||
async def start_with_login_params(self, store_id: str, login_params: str):
|
||
"""使用后端下发的登录参数执行登录并启动监听(与拼多多保持一致)"""
|
||
try:
|
||
self._log("🔵 [DY] 收到后端登录参数,开始执行登录获取cookies", "INFO")
|
||
self._log(f"🔍 [DY] 登录参数内容: {login_params[:100]}...", "DEBUG")
|
||
|
||
# 1. 解析登录参数
|
||
self._log("🔄 [DY] 开始解析登录参数", "DEBUG")
|
||
params_dict = self._parse_login_params(login_params)
|
||
if not params_dict:
|
||
self._log("❌ [DY] 登录参数解析失败", "ERROR")
|
||
return False
|
||
self._log(f"✅ [DY] 登录参数解析成功,手机号: {params_dict.get('phone_number', 'N/A')}", "DEBUG")
|
||
|
||
# 2. 使用新的DyLogin类执行登录
|
||
self._log("🔄 [DY] 开始创建DyLogin实例", "DEBUG")
|
||
dy_login = DyLogin(log_callback=self.log_callback)
|
||
self._log("✅ [DY] DyLogin实例创建成功", "DEBUG")
|
||
|
||
self._log("🔄 [DY] 开始执行登录", "DEBUG")
|
||
login_result = dy_login.login_with_params(params_dict, store_id)
|
||
self._log(f"📊 [DY] 登录结果: {login_result}", "DEBUG")
|
||
|
||
if login_result == "need_verification_code":
|
||
self._log("⚠️ [DY] 需要手机验证码,已通知后端,等待重新下发包含验证码的登录参数", "WARNING")
|
||
return "need_verification_code" # 返回特殊标识,避免被覆盖
|
||
elif login_result == "verification_code_error":
|
||
self._log("⚠️ [DY] 验证码错误,已通知后端", "WARNING")
|
||
return "verification_code_error" # 返回特殊标识,避免重复发送消息
|
||
elif login_result == "login_failure":
|
||
self._log("⚠️ [DY] 登录失败,已发送失败通知给后端", "WARNING")
|
||
return "login_failure" # 返回特殊标识,避免重复发送消息
|
||
elif not login_result:
|
||
self._log("❌ [DY] 登录失败", "ERROR")
|
||
return False
|
||
elif isinstance(login_result, dict):
|
||
# 登录成功,获取到cookies
|
||
self._log("✅ [DY] 登录成功,使用获取的cookies连接平台", "SUCCESS")
|
||
|
||
# 🔥 使用获取的cookies启动监听(这里会验证cookies完整性)
|
||
return await self.start_with_cookies(store_id, login_result)
|
||
else:
|
||
self._log("❌ [DY] 登录返回未知结果", "ERROR")
|
||
return False
|
||
|
||
except Exception as e:
|
||
self._log(f"❌ [DY] 使用登录参数启动失败: {str(e)}", "ERROR")
|
||
import traceback
|
||
self._log(f"🔍 [DY] 异常详细信息: {traceback.format_exc()}", "ERROR")
|
||
return False
|
||
|
||
def _parse_login_params(self, login_params_str: str) -> dict:
|
||
"""解析后端下发的登录参数(与拼多多保持一致)"""
|
||
try:
|
||
import json
|
||
data = json.loads(login_params_str)
|
||
params = data.get("data", {})
|
||
self._log(f"✅ [DY] 登录参数解析成功: phone_number={params.get('phone_number', 'N/A')}", "INFO")
|
||
return params
|
||
except Exception as e:
|
||
self._log(f"❌ [DY] 解析登录参数失败: {e}", "ERROR")
|
||
return {}
|
||
|
||
def stop_listening(self):
|
||
"""停止监听"""
|
||
if self.stop_event:
|
||
self.stop_event.set()
|
||
self.running = False
|
||
if self.douyin_bot:
|
||
# 创建新的事件循环来关闭机器人
|
||
async def cleanup():
|
||
await self.douyin_bot.close()
|
||
|
||
if self.loop and not self.loop.is_closed():
|
||
self.loop.run_until_complete(cleanup())
|
||
self.loop.close()
|
||
self._log("抖音监听已停止", "INFO")
|
||
|
||
|
||
# 使用示例
|
||
if __name__ == '__main__':
|
||
cookies = {
|
||
"passport_csrf_token": "9984fc5ad93abebf1a7bceb157fc4560",
|
||
"passport_csrf_token_default": "9984fc5ad93abebf1a7bceb157fc4560",
|
||
"qc_tt_tag": "0",
|
||
"is_staff_user": "false",
|
||
"SHOP_ID": "217051461",
|
||
"PIGEON_CID": "1216524360102748",
|
||
"passport_mfa_token": "CjeTQsM%2B1gYNk6uL6EBAHaZQqQ0qvxul3Up08WBuQeEr%2BGfitdRk91WzrSVG46q2ldU%2BOnVc%2BmPtGkoKPAAAAAAAAAAAAABPbMkgpgfbZwhLZyjNjTZUm1l5GH%2BAasL%2BonVECzLkUmLgj262JLsX2eLMDc2bxcEFUxCogvsNGPax0WwgAiIBA3PySKw%3D",
|
||
"__security_mc_1_s_sdk_crypt_sdk": "da6a1b67-4e38-b24e",
|
||
"bd_ticket_guard_client_data": "eyJiZC10aWNrZXQtZ3VhcmQtdmVyc2lvbiI6MiwiYmQtdGlja2V0LWd1YXJkLWl0ZXJhdGlvbi12ZXJzaW9uIjoxLCJiZC10aWNrZXQtZ3VhcmQtcmVlLXB1YmxpYy1rZXkiOiJCTWQ4eGpKMUY2THJEUDRwc283ald6T0ZzTWhCaXhLc3dla3BIQ1JleFNENFRWNmNOK09uOVdKQnIwNWFkNmgvRnM2N0hpQzUxSXJFTGZTSFVWakVtODQ9IiwiYmQtdGlja2V0LWd1YXJkLXdlYi12ZXJzaW9uIjoyfQ%3D%3D",
|
||
"bd_ticket_guard_client_web_domain": "2",
|
||
"ttwid": "1%7C85xTuedO1rj3zvv-kDVjAu2b0hgRS8wVjCPgOEaCaxo%7C1757499627%7Cb7216cdc62420b85602b4a810e31be759b7a184da042bf2a9a21629f081dd2d9",
|
||
"odin_tt": "2b28de94dbb7a08d45168f8f138dda7defd529f717ce89d3148b3151488b33248f176c0c52b58cdca420cfb950e13ecd6dac26d6762394725087280441ac91c5",
|
||
"passport_auth_status": "d96aa2b690a33e007d157cf4204b1078%2C62594da316130803f733a79b6a6774df",
|
||
"passport_auth_status_ss": "d96aa2b690a33e007d157cf4204b1078%2C62594da316130803f733a79b6a6774df",
|
||
"uid_tt": "d1dbdd6e4cebff9fbb496f8735b11c9d",
|
||
"uid_tt_ss": "d1dbdd6e4cebff9fbb496f8735b11c9d",
|
||
"sid_tt": "784e3e2545c5666529fa3ac9451ed016",
|
||
"sessionid": "784e3e2545c5666529fa3ac9451ed016",
|
||
"sessionid_ss": "784e3e2545c5666529fa3ac9451ed016",
|
||
"ucas_c0": "CkEKBTEuMC4wELKIjrqrodTgaBjmJiDjhaCz682rASiwITDc5uCyws2UAkCRooXGBkiR1sHIBlCmvJOipunJ9WdYbhIU08vxfhaj0eG4pjCi5loa1uv2XEY",
|
||
"ucas_c0_ss": "CkEKBTEuMC4wELKIjrqrodTgaBjmJiDjhaCz682rASiwITDc5uCyws2UAkCRooXGBkiR1sHIBlCmvJOipunJ9WdYbhIU08vxfhaj0eG4pjCi5loa1uv2XEY",
|
||
"sid_guard": "784e3e2545c5666529fa3ac9451ed016%7C1757499665%7C5184000%7CSun%2C+09-Nov-2025+10%3A21%3A05+GMT",
|
||
"session_tlb_tag": "sttt%7C17%7CeE4-JUXFZmUp-jrJRR7QFv_________3dxW8aRfOoXXRgtkArn2rBGBZ8E0_061PH30G5EzcU-M%3D",
|
||
"sid_ucp_v1": "1.0.0-KDIwMjIxOWE0MzY1NzA1YTZmNzY3NTRmZGJkMTU1OTg1ODc0MWI0MjkKGwjc5uCyws2UAhCRooXGBhiwISAMOAZA9AdIBBoCbGYiIDc4NGUzZTI1NDVjNTY2NjUyOWZhM2FjOTQ1MWVkMDE2",
|
||
"ssid_ucp_v1": "1.0.0-KDIwMjIxOWE0MzY1NzA1YTZmNzY3NTRmZGJkMTU1OTg1ODc0MWI0MjkKGwjc5uCyws2UAhCRooXGBhiwISAMOAZA9AdIBBoCbGYiIDc4NGUzZTI1NDVjNTY2NjUyOWZhM2FjOTQ1MWVkMDE2",
|
||
"PHPSESSID": "a72b9c2977908a281d086061f7281046",
|
||
"PHPSESSID_SS": "a72b9c2977908a281d086061f7281046",
|
||
"csrf_session_id": "623da6c2834082a9036c812d995f39cc"
|
||
}
|
||
|
||
# # !!!关键修改:使用同步方式启动!!!
|
||
# listener = DouYinListenerForGUI()
|
||
#
|
||
# # 创建新的事件循环来运行异步方法
|
||
# loop = asyncio.new_event_loop()
|
||
# asyncio.set_event_loop(loop)
|
||
#
|
||
# try:
|
||
# # 运行启动方法
|
||
# success = loop.run_until_complete(listener.start_with_cookies(store_id=None, cookie_dict=cookies))
|
||
# if success:
|
||
# print("✅ 监听器启动成功,按 Ctrl+C 停止...")
|
||
# # 保持主线程运行
|
||
# while listener.running:
|
||
# time.sleep(1)
|
||
# else:
|
||
# print("❌ 监听器启动失败")
|
||
# except KeyboardInterrupt:
|
||
# print("\n🛑 正在停止监听器...")
|
||
# listener.stop_listening()
|
||
# except Exception as e:
|
||
# print(f"❌ 程序异常: {e}")
|
||
# traceback.print_exc()
|
||
# finally:
|
||
# loop.close()
|
||
|
||
# 创建聊天机器人实例
|
||
bot = DouYinChatBot(cookie=cookies)
|
||
|
||
try:
|
||
# 启动机器人
|
||
bot.start()
|
||
|
||
except KeyboardInterrupt:
|
||
print("\n🛑 正在关闭机器人...")
|
||
|
||
|
||
# 清理资源
|
||
async def cleanup():
|
||
await bot.close()
|
||
|
||
|
||
# 创建临时事件循环进行清理
|
||
cleanup_loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(cleanup_loop)
|
||
cleanup_loop.run_until_complete(cleanup())
|
||
cleanup_loop.close()
|
||
print("✅ 机器人已关闭")
|