修改默认回复逻辑

This commit is contained in:
zhinianboke 2025-12-30 23:21:33 +08:00
parent d8c866c702
commit db27513301
6 changed files with 276 additions and 32 deletions

View File

@ -1734,7 +1734,7 @@ class XianyuLive:
# user_id=f"{self.cookie_id}_{int(time.time() * 1000)}", # 使用唯一ID避免冲突
user_id=f"{self.cookie_id}", # 使用唯一ID避免冲突
enable_learning=True, # 启用学习功能
headless=True # 使用无头模式
headless=False # 使用无头模式
)
# 在线程池中执行滑块验证
@ -3088,8 +3088,14 @@ class XianyuLive:
except Exception as e:
logger.error(f"调试消息结构时发生错误: {self._safe_str(e)}")
async def get_default_reply(self, send_user_name: str, send_user_id: str, send_message: str, chat_id: str, item_id: str = None) -> str:
"""获取默认回复内容,支持指定商品回复、变量替换和只回复一次功能"""
async def get_default_reply(self, send_user_name: str, send_user_id: str, send_message: str, chat_id: str, item_id: str = None) -> dict:
"""获取默认回复内容,支持指定商品回复、变量替换、只回复一次功能和图片发送
Returns:
dict: 包含 'text' (文字回复) 'image_url' (图片URL可选) 的字典
None (无回复)
"EMPTY_REPLY" (空回复标记)
"""
try:
from db_manager import db_manager
@ -3109,11 +3115,11 @@ class XianyuLive:
item_id=item_id
)
logger.info(f"{self.cookie_id}】指定商品回复内容: {formatted_reply}")
return formatted_reply
return {'text': formatted_reply, 'image_url': None}
except Exception as format_error:
logger.error(f"指定商品回复变量替换失败: {self._safe_str(format_error)}")
# 如果变量替换失败,返回原始内容
return reply_content
return {'text': reply_content, 'image_url': None}
else:
logger.warning(f"{self.cookie_id}】商品ID {item_id} 没有配置指定回复,使用默认回复")
@ -3132,8 +3138,11 @@ class XianyuLive:
return None
reply_content = default_reply_settings.get('reply_content', '')
if not reply_content or (reply_content and reply_content.strip() == ''):
logger.info(f"账号 {self.cookie_id} 默认回复内容为空,不进行回复")
reply_image_url = default_reply_settings.get('reply_image_url', '')
# 如果文字和图片都为空,返回空回复标记
if (not reply_content or reply_content.strip() == '') and (not reply_image_url or reply_image_url.strip() == ''):
logger.info(f"账号 {self.cookie_id} 默认回复内容和图片都为空,不进行回复")
return "EMPTY_REPLY" # 返回特殊标记表示不回复
# 进行变量替换
@ -3145,7 +3154,7 @@ class XianyuLive:
send_user_name=send_user_name,
send_user_id=send_user_id,
send_message=send_message
)
) if reply_content else ''
if item_replay:
formatted_reply = item_replay.get('reply_content', '')
@ -3155,12 +3164,12 @@ class XianyuLive:
db_manager.add_default_reply_record(self.cookie_id, chat_id)
logger.info(f"{self.cookie_id}】记录默认回复: chat_id={chat_id}")
logger.info(f"{self.cookie_id}】使用默认回复: {formatted_reply}")
return formatted_reply
logger.info(f"{self.cookie_id}】使用默认回复: 文字={formatted_reply}, 图片={reply_image_url}")
return {'text': formatted_reply, 'image_url': reply_image_url if reply_image_url and reply_image_url.strip() else None}
except Exception as format_error:
logger.error(f"默认回复变量替换失败: {self._safe_str(format_error)}")
# 如果变量替换失败,返回原始内容
return reply_content
return {'text': reply_content, 'image_url': reply_image_url if reply_image_url and reply_image_url.strip() else None}
except Exception as e:
logger.error(f"获取默认回复失败: {self._safe_str(e)}")
@ -3329,6 +3338,45 @@ class XianyuLive:
return False
async def _get_image_size_from_url(self, image_url: str) -> tuple:
"""从URL获取图片尺寸
Args:
image_url: 图片URL
Returns:
(width, height) 元组失败返回 (None, None)
"""
import aiohttp
from io import BytesIO
try:
logger.info(f"{self.cookie_id}】开始从URL获取图片尺寸: {image_url[:80]}...")
# 不接受AVIF格式PIL默认不支持让CDN返回WEBP/JPEG等格式
headers = {
'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',
'Accept': 'image/jpeg,image/png,image/gif,image/webp,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Referer': 'https://www.goofish.com/',
}
async with aiohttp.ClientSession() as session:
async with session.get(image_url, headers=headers, timeout=aiohttp.ClientTimeout(total=10)) as response:
if response.status == 200:
image_data = await response.read()
from PIL import Image
with Image.open(BytesIO(image_data)) as img:
width, height = img.size
logger.info(f"{self.cookie_id}】解析图片尺寸成功: {width}x{height}")
return (width, height)
else:
logger.warning(f"{self.cookie_id}】下载图片失败HTTP状态码: {response.status}")
except Exception as e:
logger.warning(f"{self.cookie_id}】从URL获取图片尺寸失败: {e}")
return (None, None)
async def _update_keyword_image_url(self, keyword: str, new_image_url: str):
"""更新关键词的图片URL"""
try:
@ -3353,6 +3401,18 @@ class XianyuLive:
except Exception as e:
logger.error(f"更新卡券图片URL失败: {e}")
async def _update_default_reply_image_url(self, new_image_url: str):
"""更新默认回复的图片URL为CDN URL"""
try:
from db_manager import db_manager
success = db_manager.update_default_reply_image_url(self.cookie_id, new_image_url)
if success:
logger.info(f"{self.cookie_id}】默认回复图片URL已更新: {new_image_url}")
else:
logger.warning(f"{self.cookie_id}】默认回复图片URL更新失败")
except Exception as e:
logger.error(f"{self.cookie_id}】更新默认回复图片URL失败: {e}")
async def get_ai_reply(self, send_user_name: str, send_user_id: str, send_message: str, item_id: str, chat_id: str):
"""获取AI回复"""
try:
@ -7095,12 +7155,87 @@ class XianyuLive:
reply_source = 'AI' # 标记为AI回复
else:
# 3. 最后使用默认回复
reply = await self.get_default_reply(send_user_name, send_user_id, send_message, chat_id, item_id)
if reply == "EMPTY_REPLY":
default_reply_result = await self.get_default_reply(send_user_name, send_user_id, send_message, chat_id, item_id)
if default_reply_result == "EMPTY_REPLY":
# 默认回复内容为空,不进行任何回复
logger.info(f"[{msg_time}] 【{self.cookie_id}】默认回复内容为空,跳过自动回复")
return
reply_source = '默认' # 标记为默认回复
# 处理默认回复(可能包含图片和文字)
if default_reply_result and isinstance(default_reply_result, dict):
reply_source = '默认' # 标记为默认回复
default_image_url = default_reply_result.get('image_url')
default_text = default_reply_result.get('text')
# 如果存在图片,先发送图片
if default_image_url:
try:
# 处理图片URL上传到CDN如果需要
final_image_url = default_image_url
image_width, image_height = 800, 600 # 默认尺寸
if self._is_cdn_url(default_image_url):
# 已经是CDN链接获取真实尺寸
logger.info(f"{self.cookie_id}】默认回复使用CDN图片: {default_image_url}")
width, height = await self._get_image_size_from_url(default_image_url)
if width and height:
image_width, image_height = width, height
elif default_image_url.startswith('/static/uploads/') or default_image_url.startswith('static/uploads/'):
# 本地图片需要上传到闲鱼CDN
local_image_path = default_image_url.replace('/static/uploads/', 'static/uploads/')
if os.path.exists(local_image_path):
logger.info(f"{self.cookie_id}】准备上传默认回复本地图片到闲鱼CDN: {local_image_path}")
from utils.image_uploader import ImageUploader
uploader = ImageUploader(self.cookies_str)
async with uploader:
cdn_url = await uploader.upload_image(local_image_path)
if cdn_url:
logger.info(f"{self.cookie_id}】默认回复图片上传成功CDN URL: {cdn_url}")
final_image_url = cdn_url
# 更新数据库中的图片URL为CDN URL
await self._update_default_reply_image_url(cdn_url)
# 获取实际图片尺寸
from utils.image_utils import image_manager
try:
actual_width, actual_height = image_manager.get_image_size(local_image_path)
if actual_width and actual_height:
image_width, image_height = actual_width, actual_height
except Exception as e:
logger.warning(f"{self.cookie_id}】获取图片尺寸失败,使用默认尺寸: {e}")
else:
logger.error(f"{self.cookie_id}】默认回复图片上传失败: {local_image_path}")
final_image_url = None
else:
logger.error(f"{self.cookie_id}】默认回复本地图片文件不存在: {local_image_path}")
final_image_url = None
else:
# 其他类型的URL获取真实尺寸
width, height = await self._get_image_size_from_url(default_image_url)
if width and height:
image_width, image_height = width, height
# 发送图片
if final_image_url:
await self.send_image_msg(websocket, chat_id, send_user_id, final_image_url, image_width, image_height)
msg_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
logger.info(f"[{msg_time}] 【{reply_source}图片发出】用户: {send_user_name} (ID: {send_user_id}), 商品({item_id}): 图片 {final_image_url}")
except Exception as e:
logger.error(f"{self.cookie_id}】默认回复图片发送失败: {self._safe_str(e)}")
# 然后发送文字(如果有)
if default_text and default_text.strip():
reply = default_text
else:
# 只有图片没有文字,已经发送完毕
if default_image_url:
return
reply = None
else:
reply = None
# 注意这里只有商品ID没有标题和详情根据新的规则不保存到数据库
# 商品信息会在其他有完整信息的地方保存(如发货规则匹配时)

View File

@ -322,6 +322,7 @@ class DBManager:
cookie_id TEXT PRIMARY KEY,
enabled BOOLEAN DEFAULT FALSE,
reply_content TEXT,
reply_image_url TEXT,
reply_once BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
@ -338,6 +339,15 @@ class DBManager:
if "duplicate column name" not in str(e).lower():
logger.warning(f"添加 reply_once 字段失败: {e}")
# 添加 reply_image_url 字段(如果不存在)
try:
cursor.execute('ALTER TABLE default_replies ADD COLUMN reply_image_url TEXT')
self.conn.commit()
logger.info("已添加 reply_image_url 字段到 default_replies 表")
except sqlite3.OperationalError as e:
if "duplicate column name" not in str(e).lower():
logger.warning(f"添加 reply_image_url 字段失败: {e}")
# 创建指定商品回复表
cursor.execute('''
CREATE TABLE IF NOT EXISTS item_replay (
@ -1905,17 +1915,17 @@ class DBManager:
return {}
# -------------------- 默认回复操作 --------------------
def save_default_reply(self, cookie_id: str, enabled: bool, reply_content: str = None, reply_once: bool = False):
def save_default_reply(self, cookie_id: str, enabled: bool, reply_content: str = None, reply_once: bool = False, reply_image_url: str = None):
"""保存默认回复设置"""
with self.lock:
try:
cursor = self.conn.cursor()
cursor.execute('''
INSERT OR REPLACE INTO default_replies (cookie_id, enabled, reply_content, reply_once, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
''', (cookie_id, enabled, reply_content, reply_once))
INSERT OR REPLACE INTO default_replies (cookie_id, enabled, reply_content, reply_image_url, reply_once, updated_at)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
''', (cookie_id, enabled, reply_content, reply_image_url, reply_once))
self.conn.commit()
logger.debug(f"保存默认回复设置: {cookie_id} -> {'启用' if enabled else '禁用'}, 只回复一次: {'' if reply_once else ''}")
logger.debug(f"保存默认回复设置: {cookie_id} -> {'启用' if enabled else '禁用'}, 只回复一次: {'' if reply_once else ''}, 图片: {reply_image_url}")
except Exception as e:
logger.error(f"保存默认回复设置失败: {e}")
raise
@ -1926,15 +1936,16 @@ class DBManager:
try:
cursor = self.conn.cursor()
cursor.execute('''
SELECT enabled, reply_content, reply_once FROM default_replies WHERE cookie_id = ?
SELECT enabled, reply_content, reply_once, reply_image_url FROM default_replies WHERE cookie_id = ?
''', (cookie_id,))
result = cursor.fetchone()
if result:
enabled, reply_content, reply_once = result
enabled, reply_content, reply_once, reply_image_url = result
return {
'enabled': bool(enabled),
'reply_content': reply_content or '',
'reply_once': bool(reply_once) if reply_once is not None else False
'reply_once': bool(reply_once) if reply_once is not None else False,
'reply_image_url': reply_image_url or ''
}
return None
except Exception as e:
@ -1946,15 +1957,16 @@ class DBManager:
with self.lock:
try:
cursor = self.conn.cursor()
cursor.execute('SELECT cookie_id, enabled, reply_content, reply_once FROM default_replies')
cursor.execute('SELECT cookie_id, enabled, reply_content, reply_once, reply_image_url FROM default_replies')
result = {}
for row in cursor.fetchall():
cookie_id, enabled, reply_content, reply_once = row
cookie_id, enabled, reply_content, reply_once, reply_image_url = row
result[cookie_id] = {
'enabled': bool(enabled),
'reply_content': reply_content or '',
'reply_once': bool(reply_once) if reply_once is not None else False
'reply_once': bool(reply_once) if reply_once is not None else False,
'reply_image_url': reply_image_url or ''
}
return result
@ -2015,6 +2027,22 @@ class DBManager:
self.conn.rollback()
return False
def update_default_reply_image_url(self, cookie_id: str, new_image_url: str) -> bool:
"""更新默认回复的图片URL用于将本地图片URL更新为CDN URL"""
with self.lock:
try:
cursor = self.conn.cursor()
cursor.execute('''
UPDATE default_replies SET reply_image_url = ? WHERE cookie_id = ?
''', (new_image_url, cookie_id))
self.conn.commit()
logger.debug(f"更新默认回复图片URL: {cookie_id} -> {new_image_url}")
return True
except Exception as e:
logger.error(f"更新默认回复图片URL失败: {e}")
self.conn.rollback()
return False
# -------------------- 通知渠道操作 --------------------
def create_notification_channel(self, name: str, channel_type: str, config: str, user_id: int = None) -> int:
"""创建通知渠道"""

View File

@ -98,16 +98,17 @@ export const batchDeleteKeywords = (cookieId: string, keywordIds: string[]): Pro
}
// 获取默认回复
export const getDefaultReply = (cookieId: string): Promise<{ enabled?: boolean; reply_content?: string; reply_once?: boolean }> => {
export const getDefaultReply = (cookieId: string): Promise<{ enabled?: boolean; reply_content?: string; reply_once?: boolean; reply_image_url?: string }> => {
return get(`/default-reply/${cookieId}`)
}
// 更新默认回复
export const updateDefaultReply = (cookieId: string, replyContent: string, enabled: boolean = true, replyOnce: boolean = false): Promise<ApiResponse> => {
export const updateDefaultReply = (cookieId: string, replyContent: string, enabled: boolean = true, replyOnce: boolean = false, replyImageUrl: string = ''): Promise<ApiResponse> => {
return put(`/default-reply/${cookieId}`, {
enabled,
reply_content: replyContent,
reply_once: replyOnce
reply_once: replyOnce,
reply_image_url: replyImageUrl
})
}

View File

@ -40,7 +40,7 @@ const mainNavItems: NavItem[] = [
{ icon: Package, label: '商品管理', path: '/items' },
{ icon: ShoppingCart, label: '订单管理', path: '/orders' },
{ icon: MessageSquare, label: '自动回复', path: '/keywords' },
// { icon: MessageCircle, label: '指定商品回复', path: '/item-replies' },
{ icon: MessageCircle, label: '指定商品回复', path: '/item-replies' },
{ icon: CreditCard, label: '卡券管理', path: '/cards' },
{ icon: Truck, label: '自动发货', path: '/delivery' },
{ icon: Bell, label: '通知渠道', path: '/notification-channels' },

View File

@ -30,7 +30,9 @@ export function Accounts() {
// 默认回复管理状态
const [defaultReplyAccount, setDefaultReplyAccount] = useState<AccountWithKeywordCount | null>(null)
const [defaultReplyContent, setDefaultReplyContent] = useState('')
const [defaultReplyImageUrl, setDefaultReplyImageUrl] = useState('')
const [defaultReplySaving, setDefaultReplySaving] = useState(false)
const [uploadingDefaultReplyImage, setUploadingDefaultReplyImage] = useState(false)
// 扫码登录状态
const [qrCodeUrl, setQrCodeUrl] = useState('')
@ -420,12 +422,14 @@ export function Accounts() {
const openDefaultReplyModal = async (account: AccountWithKeywordCount) => {
setDefaultReplyAccount(account)
setDefaultReplyContent('')
setDefaultReplyImageUrl('')
setActiveModal('default-reply')
// 加载当前默认回复
try {
const result = await getDefaultReply(account.id)
setDefaultReplyContent(result.reply_content || '')
setDefaultReplyImageUrl(result.reply_image_url || '')
} catch {
// ignore
}
@ -436,7 +440,7 @@ export function Accounts() {
try {
setDefaultReplySaving(true)
await updateDefaultReply(defaultReplyAccount.id, defaultReplyContent, true)
await updateDefaultReply(defaultReplyAccount.id, defaultReplyContent, true, false, defaultReplyImageUrl)
addToast({ type: 'success', message: '默认回复已保存' })
closeModal()
} catch {
@ -446,6 +450,39 @@ export function Accounts() {
}
}
// 上传默认回复图片
const handleUploadDefaultReplyImage = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
try {
setUploadingDefaultReplyImage(true)
const formData = new FormData()
formData.append('image', file)
const response = await fetch('/upload-image', {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
},
body: formData
})
const result = await response.json()
if (result.image_url) {
setDefaultReplyImageUrl(result.image_url)
addToast({ type: 'success', message: '图片上传成功' })
} else {
addToast({ type: 'error', message: result.detail || '图片上传失败' })
}
} catch {
addToast({ type: 'error', message: '图片上传失败' })
} finally {
setUploadingDefaultReplyImage(false)
e.target.value = ''
}
}
// ==================== AI回复开关 ====================
const handleToggleAI = async (account: AccountWithKeywordCount) => {
const newEnabled = !account.aiEnabled
@ -1098,6 +1135,48 @@ export function Accounts() {
使
</p>
</div>
<div className="input-group">
<label className="input-label"></label>
<div className="flex items-center gap-2">
<input
type="text"
value={defaultReplyImageUrl}
onChange={(e) => setDefaultReplyImageUrl(e.target.value)}
className="input-ios flex-1"
placeholder="图片URL或上传图片"
/>
<label className="btn-ios-secondary cursor-pointer">
{uploadingDefaultReplyImage ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
'上传'
)}
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleUploadDefaultReplyImage}
disabled={uploadingDefaultReplyImage}
/>
</label>
</div>
{defaultReplyImageUrl && (
<div className="mt-2 relative inline-block">
<img
src={defaultReplyImageUrl}
alt="回复图片预览"
className="max-w-32 max-h-32 rounded border border-slate-200 dark:border-slate-700"
/>
<button
type="button"
onClick={() => setDefaultReplyImageUrl('')}
className="absolute -top-2 -right-2 w-5 h-5 bg-red-500 text-white rounded-full flex items-center justify-center hover:bg-red-600"
>
<X className="w-3 h-3" />
</button>
</div>
)}
</div>
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<p className="text-xs text-blue-600 dark:text-blue-400">
<strong></strong><br />

View File

@ -1327,6 +1327,7 @@ class CookieStatusIn(BaseModel):
class DefaultReplyIn(BaseModel):
enabled: bool
reply_content: Optional[str] = None
reply_image_url: Optional[str] = None
reply_once: bool = False
@ -2656,8 +2657,8 @@ def update_default_reply(cid: str, reply_data: DefaultReplyIn, current_user: Dic
if cid not in user_cookies:
raise HTTPException(status_code=403, detail="无权限操作该Cookie")
db_manager.save_default_reply(cid, reply_data.enabled, reply_data.reply_content, reply_data.reply_once)
return {'msg': 'default reply updated', 'enabled': reply_data.enabled, 'reply_once': reply_data.reply_once}
db_manager.save_default_reply(cid, reply_data.enabled, reply_data.reply_content, reply_data.reply_once, reply_data.reply_image_url)
return {'msg': 'default reply updated', 'enabled': reply_data.enabled, 'reply_once': reply_data.reply_once, 'reply_image_url': reply_data.reply_image_url}
except HTTPException:
raise
except Exception as e: