import asyncio import websockets import json import re import logging from collections import deque from datetime import datetime from pathlib import Path from typing import Optional # ========== 转接分组映射 ========== def _get_transfer_group(acc_id: str) -> str: """根据店铺 acc_id 获取转接分组 ID。不同店铺对应不同客服分组。""" from config.config import CONFIG_DIR config_path = CONFIG_DIR / "transfer_groups.json" default_group = "20252916034" try: if config_path.exists(): with open(config_path, "r", encoding="utf-8") as f: cfg = json.load(f) return cfg.get(acc_id, cfg.get("default", default_group)) except Exception: pass return default_group # ========== 日志配置(轮转:按大小 10MB,保留 7 份)========== def setup_logger(): from logging.handlers import RotatingFileHandler from config.config import LOG_DIR, LOG_MAX_BYTES, LOG_BACKUP_COUNT logger = logging.getLogger("cs_agent") logger.setLevel(logging.INFO) fmt = logging.Formatter("[%(asctime)s] %(message)s", datefmt="%H:%M:%S") ch = logging.StreamHandler() ch.setFormatter(fmt) logger.addHandler(ch) LOG_DIR.mkdir(exist_ok=True) today = datetime.now().strftime("%Y-%m-%d") fh = RotatingFileHandler( LOG_DIR / f"chat_{today}.log", maxBytes=LOG_MAX_BYTES, backupCount=LOG_BACKUP_COUNT, encoding="utf-8", ) fh.setFormatter(fmt) logger.addHandler(fh) return logger import os logger = setup_logger() from db.chat_log_db import log_message as _chat_log # 导入 Agent 模块 try: from core.pydantic_ai_agent import CustomerServiceAgent, CustomerMessage, _get_shop_type from db.customer_db import db from core.workflow import workflow AGENT_AVAILABLE = True except Exception as e: AGENT_AVAILABLE = False workflow = None _get_shop_type = lambda acc_id, goods_name: "find_image" import traceback print(f"警告: Agent 模块导入失败: {e}") traceback.print_exc() print("将使用基础回复功能") class QingjianAPIClient: """轻简API WebSocket客户端""" def __init__(self, uri=None, enable_agent: bool = True): from config.config import QINGJIAN_WS_URI from config.config import IMAGE_MODULE_ENABLED from config.config import MESSAGE_DEBOUNCE_SECONDS self.uri = uri or QINGJIAN_WS_URI self.websocket = None self.running = True self.reply_id = "tb001" # 回复时使用的from_id self.last_msg = None # 保存最后一条消息 self.enable_agent = enable_agent and AGENT_AVAILABLE self.agent = None self._replied_msg_ids: deque = deque(maxlen=200) # 已回复消息ID,FIFO去重 # 消息防抖:同一客户连续发消息时,等待 N 秒后合并处理 self._DEBOUNCE_SECONDS = MESSAGE_DEBOUNCE_SECONDS if isinstance(MESSAGE_DEBOUNCE_SECONDS, int) else 8 self._debounce_tasks: dict = {} # customer_key -> asyncio.Task self._pending_msgs: dict = {} # customer_key -> list[data] self._image_enabled = IMAGE_MODULE_ENABLED # 同客户消息串行:保证「发图→这个高清」等顺序,避免误判 self._customer_locks: dict = {} # customer_key -> asyncio.Lock # agent_reply 并发上限,防止 API 打满 self._agent_semaphore = asyncio.Semaphore(8) self._pending_images: dict = {} self._pending_image_tasks: dict = {} # 初始化 Agent if self.enable_agent: try: self.agent = CustomerServiceAgent() print(f"[{self.get_time()}] Agent 初始化成功") except Exception as e: print(f"[{self.get_time()}] Agent 初始化失败: {e}") self.enable_agent = False # 注册 workflow 消息发送回调(供图片AI完成后推送消息用) if workflow: workflow.register_send_callback(self._workflow_send) workflow.register_agent_notify_callback(self._workflow_agent_notify) async def connect(self): """连接WebSocket服务器""" while self.running: try: print(f"[{self.get_time()}] 正在连接轻简API {self.uri}...") async with websockets.connect(self.uri) as websocket: self.websocket = websocket from utils.health_check import set_qingjian_connected set_qingjian_connected(True) print(f"[{self.get_time()}] 连接成功!") if self.enable_agent: print(f"[{self.get_time()}] AI Agent 已启用,将自动处理消息") print(f"[{self.get_time()}] 等待接收消息...") # 持续接收消息 await self.receive_messages() except ConnectionRefusedError: from utils.health_check import set_qingjian_connected set_qingjian_connected(False) print(f"[{self.get_time()}] 连接被拒绝,请检查轻简软件是否已启动") except websockets.exceptions.InvalidURI: from utils.health_check import set_qingjian_connected set_qingjian_connected(False) print(f"[{self.get_time()}] URI格式错误") except Exception as e: from utils.health_check import set_qingjian_connected set_qingjian_connected(False) print(f"[{self.get_time()}] 连接错误: {e}") # 等待5秒后重连 if self.running: print(f"[{self.get_time()}] 5秒后尝试重连...") await asyncio.sleep(5) def _customer_key(self, data: dict) -> str: """同一店铺+客户 = 同一会话""" return f"{data.get('acc_id','')}:{data.get('from_id','')}" def _get_customer_lock(self, key: str) -> asyncio.Lock: if key not in self._customer_locks: self._customer_locks[key] = asyncio.Lock() return self._customer_locks[key] async def _agent_reply_serialized(self, data: dict): """同客户串行 + 全局并发限制,再执行 agent_reply""" key = self._customer_key(data) async with self._get_customer_lock(key): async with self._agent_semaphore: await self.agent_reply(data) def _fire_and_forget(self, coro): """后台执行协程,不阻塞接收循环;异常会记录到日志""" task = asyncio.create_task(coro) def _done(t): if t.cancelled(): return exc = t.exception() if exc: logger.exception(f"后台任务异常: {exc}") task.add_done_callback(_done) async def receive_messages(self): """持续接收消息""" try: async for message in self.websocket: await self.handle_message(message) except websockets.exceptions.ConnectionClosed: from utils.health_check import set_qingjian_connected set_qingjian_connected(False) print(f"[{self.get_time()}] 连接已关闭") except Exception as e: from utils.health_check import set_qingjian_connected set_qingjian_connected(False) print(f"[{self.get_time()}] 接收消息错误: {e}") async def handle_message(self, message): """处理接收到的消息""" timestamp = self.get_time() try: data = json.loads(message) # 保存最后一条消息用于回复 self.last_msg = data # 打印格式化的消息 print(f"\n{'='*50}") print(f"[{timestamp}] 收到新消息:") print(f"{'='*50}") print(f" 消息ID: {data.get('msg_id', 'N/A')}") print(f" 账号ID: {self.to_chinese(data.get('acc_id', 'N/A'))}") print(f" 发送者ID: {self.to_chinese(data.get('from_id', 'N/A'))}") print(f" 发送者名称: {self.to_chinese(data.get('from_name', 'N/A'))}") print(f" 会话ID: {self.to_chinese(data.get('cy_id', 'N/A'))}") print(f" 平台类型: {data.get('acc_type', 'N/A')}") print(f" 消息类型: {self.get_msg_type_name(data.get('msg_type', 0))}") print(f" 消息内容: {self.to_chinese(data.get('msg', 'N/A'))}") # 显示商品信息(如果有) if data.get('goods_name'): print(f" 商品名称: {self.to_chinese(data.get('goods_name', ''))}") if data.get('goods_order'): print(f" 订单信息: {self.to_chinese(data.get('goods_order', ''))}") print(f"{'='*50}\n") # 消息去重:同一条消息不重复处理 msg_id = data.get('msg_id', '') if msg_id and msg_id in self._replied_msg_ids: logger.info(f"重复消息,跳过: {msg_id}") return if msg_id: self._replied_msg_ids.append(msg_id) # deque 自动淘汰最旧的 # 空消息/无效消息过滤(N/A 或关键字段全为空) from_id = data.get('from_id', '') acc_id = data.get('acc_id', '') msg_body = data.get('msg', '') if not from_id or from_id == 'N/A' or not acc_id or acc_id == 'N/A': print(f"[{self.get_time()}] 空消息跳过(from_id={from_id!r} acc_id={acc_id!r})") return # Gemini 店铺:不回复,直接跳过 goods_name = self.to_chinese(data.get('goods_name', '') or '') if _get_shop_type(acc_id, goods_name) == "gemini_api": print(f"[{self.get_time()}] Gemini 店铺消息,跳过") try: _chat_log( data.get('from_id', ''), self.to_chinese(data.get('msg', '')), "in", customer_name=self.to_chinese(data.get('from_name', '') or data.get('cy_name', '')), acc_id=data.get('acc_id', ''), platform=data.get('acc_type', ''), msg_type=data.get('msg_type', 0), ) except Exception: pass try: from utils.wechat_chat_log import push_chat_to_wechat asyncio.create_task(push_chat_to_wechat( customer_name=self.to_chinese(data.get('from_name', '') or data.get('cy_name', '')), customer_id=data.get('from_id', ''), acc_id=data.get('acc_id', ''), customer_msg=self.to_chinese(data.get('msg', '')), reply_msg="", goods_name=goods_name, )) except Exception: pass return # 使用 Agent 自动回复(仅处理文本消息) if self.enable_agent: msg_type = data.get('msg_type', 0) if msg_type == 0: if self._is_transfer_msg(data): # 会话转交 → 主动打招呼 print(f"[{self.get_time()}] 收到转交消息,发送问候") greeting = "在呢,发图来我看看" await self.send_reply(data, greeting) try: from utils.wechat_chat_log import push_chat_to_wechat asyncio.create_task(push_chat_to_wechat( customer_name=self.to_chinese(data.get('from_name', '') or data.get('cy_name', '')), customer_id=data.get('from_id', ''), acc_id=data.get('acc_id', ''), customer_msg=self.to_chinese(data.get('msg', '')), reply_msg=greeting, goods_name=self.to_chinese(data.get('goods_name', '') or ''), )) except Exception: pass elif self._is_shop_card(data): # 进店卡片:有历史对话就不回复,没有才打招呼(Gemini 已在上面统一跳过) cid = data.get('from_id', '') if self._has_chat_history(cid): print(f"[{self.get_time()}] 进店卡片(已有记录),跳过") else: print(f"[{self.get_time()}] 进店卡片(新客户),发送问候") greeting = "在呢,发图来我看看" await self.send_reply(data, greeting) try: from utils.wechat_chat_log import push_chat_to_wechat asyncio.create_task(push_chat_to_wechat( customer_name=self.to_chinese(data.get('from_name', '') or data.get('cy_name', '')), customer_id=data.get('from_id', ''), acc_id=data.get('acc_id', ''), customer_msg=self.to_chinese(data.get('msg', '')), reply_msg=greeting, goods_name=goods_name, )) except Exception: pass elif self._should_ignore(data): print(f"[{self.get_time()}] 系统通知,跳过回复") else: await self._debounce_agent_reply(data) elif msg_type == 1: # 图片消息直接处理,不走防抖(图片不会连续多发) await self.handle_image_message(data) except json.JSONDecodeError: print(f"[{timestamp}] 收到非JSON消息: {message}") async def _debounce_agent_reply(self, data: dict): """ 消息防抖:同一客户在 _DEBOUNCE_SECONDS 内的连续消息合并后再处理。 订单通知、图片URL、付款相关消息不走防抖,立即处理。 """ msg_body = data.get('msg', '') # 以下情况跳过防抖,立即处理(后台执行,不阻塞接收循环) immediate_keywords = ["买家已付款", "已付款", "[系统订单信息]"] if any(kw in msg_body for kw in immediate_keywords) or self._msg_has_image_url(msg_body): self._fire_and_forget(self._agent_reply_serialized(data)) return key = f"{data.get('acc_id','')}:{data.get('from_id','')}" # 积攒消息 if key not in self._pending_msgs: self._pending_msgs[key] = [] self._pending_msgs[key].append(msg_body) # 取消上一个等待任务(如果有) old_task = self._debounce_tasks.get(key) if old_task and not old_task.done(): old_task.cancel() # 创建新的延迟处理任务 async def _delayed(capture_key, capture_data): await asyncio.sleep(self._DEBOUNCE_SECONDS) msgs = self._pending_msgs.pop(capture_key, []) if not msgs: return if len(msgs) == 1: merged_msg = msgs[0] else: merged_msg = "、".join(m for m in msgs if m.strip()) print(f"[{self.get_time()}] 防抖合并 {len(msgs)} 条消息: {merged_msg[:60]}") merged_data = dict(capture_data) merged_data['msg'] = merged_msg await self._agent_reply_serialized(merged_data) task = asyncio.create_task(_delayed(key, data)) self._debounce_tasks[key] = task def _msg_has_image_url(self, msg: str) -> bool: """判断文本消息里是否包含图片URL(客户粘贴了图片链接,可能带前缀文字如 有吗#*#https://...)""" if not msg: return False lower = msg.lower() image_exts = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp") image_hosts = ("alicdn.com", "imgextra", "taobao.com", "jd.com", "pinduoduo.com") if "http://" in lower or "https://" in lower: if any(ext in lower for ext in image_exts) or any(h in lower for h in image_hosts): return True return False def _msg_refers_images(self, msg: str) -> bool: """判断文本是否指代之前的图片(图一/图二/这张/那张/上面那张等)""" if not msg: return False refs = ("图一", "图二", "第一张", "第二张", "这张", "那张", "上面那张", "下面那张", "刚才那张", "上一张", "下一张") return any(r in msg for r in refs) def _extract_image_urls(self, msg: str) -> list: if not msg: return [] parts = [p.strip() for p in msg.split("#*#") if p.strip()] urls = [] for p in parts: if p.startswith("http://") or p.startswith("https://"): urls.append(p) if not urls and ("http://" in msg or "https://" in msg): tokens = re.findall(r'(https?://\S+)', msg) for t in tokens: if any(ext in t.lower() for ext in [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]): urls.append(t) return urls[:8] def _collect_recent_image_urls(self, customer_id: str, acc_id: str, max_count: int = 6) -> list: """从最近对话中回溯收集图片URL(优先买家消息),用于慢发或引用图片的场景""" urls, seen = [], set() try: from db.chat_log_db import get_recent_conversation recent = get_recent_conversation(customer_id=customer_id, acc_id=acc_id, limit=20) # 从最近到更早遍历,收集买家(in)消息中的图片链接 for m in reversed(recent): if m.get("direction") != "in": continue ms = m.get("message") or "" us = self._extract_image_urls(ms) for u in us: if u not in seen: seen.add(u) urls.append(u) if len(urls) >= max_count: return urls except Exception: pass return urls def _msg_is_requirement(self, msg: str) -> bool: if not msg: return False kws = ("要", "抓到", "放到", "合成", "替换", "抠", "修", "高清", "尺寸", "横", "竖", "颜色", "去背景", "排版", "一样", "类似", "同款") return any(k in msg for k in kws) def _add_pending_images(self, key: str, urls: list, limit: int = 12): if not urls: return cur = self._pending_images.get(key) or [] for u in urls: if u not in cur: cur.append(u) if len(cur) >= limit: break self._pending_images[key] = cur async def _flush_pending_images(self, key: str, data: dict): urls = self._pending_images.get(key) or [] if not urls: return self._pending_images[key] = [] if len(urls) == 1: await self._analyze_single_and_reply(data, urls[0]) else: await self._analyze_multi_and_reply(data, urls) def _msg_is_price_inquiry(self, msg: str) -> bool: """判断是否是价格询问""" if not msg: return False patterns = ("多少钱", "多少一张", "一张多少钱", "画图多少", "报价", "给个价", "几块", "多少钱") return any(p in msg for p in patterns) def _detect_order_status(self, msg: str) -> str: if not msg: return "" s = msg if "买家已付款" in s or "已付款" in s: return "paid" if "[系统订单信息]" in s: if "等待买家付款" in s or "未付款" in s: return "waiting" return "order" return "" async def _analyze_single_and_reply(self, data: dict, url: str): try: from image.image_analyzer import image_analyzer r = await image_analyzer.analyze(url) if isinstance(r, dict) and r.get("success", False): from config.config import MIN_PRICE_FLOOR p = r.get("price_suggest", 20) floor_dyn = r.get("price_min", MIN_PRICE_FLOOR) floor = max(MIN_PRICE_FLOOR, int(floor_dyn) if isinstance(floor_dyn, (int, float)) else MIN_PRICE_FLOOR) p = max(floor, round(p / 5) * 5) try: from db.customer_db import db as _db _db.update_last_min_price(data.get('from_id',''), floor) except Exception: pass reply = f"这张按{p}元,满意再拍" else: reply = "这张我看了,先按20元给你做" await self.send_reply(data, reply) try: _chat_log( data.get('from_id', ''), reply, "out", customer_name=self.to_chinese(data.get('from_name', '') or data.get('cy_name', '')), acc_id=data.get('acc_id', ''), platform=data.get('acc_type', '') ) except Exception: pass except Exception: pass async def agent_reply(self, data: dict): """使用 Agent 处理消息并回复""" try: msg_text = self.to_chinese(data.get('msg', '')) _cid = data.get('from_id', '') _name = self.to_chinese(data.get('from_name', '') or data.get('cy_name', '')) _plat = data.get('acc_type', '') # 记录客户来消息 if _cid and msg_text: try: _chat_log(_cid, msg_text, "in", customer_name=_name, acc_id=data.get('acc_id', ''), platform=_plat, msg_type=data.get('msg_type', 0)) except Exception: pass # 消息含图片URL:累积到待处理列表,先询问要求 if self._msg_has_image_url(msg_text): urls = self._extract_image_urls(msg_text) key = self._customer_key(data) self._add_pending_images(key, urls) await self.send_reply(data, "图片收到了,说下要求(尺寸/要做什么)") old = self._pending_image_tasks.get(key) if old and not old.done(): old.cancel() async def _delay_flush(capture_key, capture_data): await asyncio.sleep(self._DEBOUNCE_SECONDS + 4) await self._flush_pending_images(capture_key, capture_data) task = asyncio.create_task(_delay_flush(key, data)) self._pending_image_tasks[key] = task elif self._msg_refers_images(msg_text): urls = self._collect_recent_image_urls(_cid, data.get('acc_id', ''), max_count=6) if urls: key = self._customer_key(data) self._add_pending_images(key, urls) await self.send_reply(data, "稍等,我找找刚才那几张") await self._flush_pending_images(key, data) else: status = self._detect_order_status(msg_text) if status == "paid": ack = "收到付款,我马上安排处理,有需要第一时间联系您" await self.send_reply(data, ack) elif status in ("waiting", "order"): ack = "订单我看到了哈,方便的话请完成付款,我好安排处理" await self.send_reply(data, ack) else: urls = self._extract_image_urls(msg_text) if len(urls) == 1: key = self._customer_key(data) self._add_pending_images(key, urls) await self.send_reply(data, "图片收到了,说下要求(尺寸/要做什么)") else: if self._msg_requests_external_contact(msg_text): reply = "这里沟通就可以哦,其他联系方式不方便" await self.send_reply(data, reply) try: from utils.wechat_chat_log import push_chat_to_wechat asyncio.create_task(push_chat_to_wechat( customer_name=_name, customer_id=_cid, acc_id=data.get('acc_id', ''), customer_msg=msg_text, reply_msg=reply, goods_name=self.to_chinese(data.get('goods_name', '') or ''), )) except Exception: pass return if self._msg_is_requirement(msg_text) or self._msg_is_price_inquiry(msg_text): key = self._customer_key(data) if self._pending_images.get(key): await self.send_reply(data, "稍等,我把刚才那几张一起看下") await self._flush_pending_images(key, data) old = self._pending_image_tasks.get(key) if old and not old.done(): old.cancel() return if self._msg_is_price_inquiry(msg_text): recent_urls = self._collect_recent_image_urls(_cid, data.get('acc_id', ''), max_count=6) if recent_urls: await self.send_reply(data, "稍等,我刚才那几张一起看下") if len(recent_urls) == 1: asyncio.create_task(self._analyze_single_and_reply(data, recent_urls[0])) else: asyncio.create_task(self._analyze_multi_and_reply(data, recent_urls)) return status = self._detect_order_status(msg_text) if status == "paid": ack = "收到付款,我马上安排处理,有需要第一时间联系您" await self.send_reply(data, ack) elif status in ("waiting", "order"): ack = "订单我看到了哈,方便的话请完成付款,我好安排处理" await self.send_reply(data, ack) # 构建 CustomerMessage customer_msg = CustomerMessage( msg_id=data.get('msg_id', ''), acc_id=data.get('acc_id', ''), msg=self.to_chinese(data.get('msg', '')), from_id=data.get('from_id', ''), from_name=self.to_chinese(data.get('from_name', '')), cy_id=data.get('cy_id', ''), acc_type=data.get('acc_type', ''), msg_type=data.get('msg_type', 0), cy_name=self.to_chinese(data.get('cy_name', '')), goods_name=self.to_chinese(data.get('goods_name', '')) if data.get('goods_name') else None, goods_order=self.to_chinese(data.get('goods_order', '')) if data.get('goods_order') else None ) # 先检查是否是 workflow 等待确认中的回复(如邮箱、确认/不满意) if workflow: workflow_reply = await workflow.handle_customer_reply( customer_id=data.get('from_id', ''), message=self.to_chinese(data.get('msg', '')), acc_id=data.get('acc_id', ''), acc_type=data.get('acc_type', 'AliWorkbench') ) if workflow_reply: logger.info(f"Workflow 回复: {workflow_reply}") await self.send_reply(data, workflow_reply) # 推送到企微:客户消息+回复成对 try: from utils.wechat_chat_log import push_chat_to_wechat asyncio.create_task(push_chat_to_wechat( customer_name=_name, customer_id=_cid, acc_id=data.get('acc_id', ''), customer_msg=msg_text, reply_msg=workflow_reply, goods_name=self.to_chinese(data.get('goods_name', '') or ''), )) except Exception: pass return logger.info("Agent 正在处理消息...") # 调用 Agent response = await self.agent.process_message(customer_msg) # 检查是否需要转接人工 if response.need_transfer: logger.info("Agent 决定转接人工") await self.transfer_to_human(data, response.transfer_msg) # 推送到企微:客户消息+转接回复成对 try: from utils.wechat_chat_log import push_chat_to_wechat asyncio.create_task(push_chat_to_wechat( customer_name=_name, customer_id=_cid, acc_id=data.get('acc_id', ''), customer_msg=msg_text, reply_msg=response.transfer_msg or "转接", goods_name=self.to_chinese(data.get('goods_name', '') or ''), )) except Exception: pass # 联系方式提取已由 Agent 的 update_contact_info 工具负责 # 此处仅做兜底:更新最后联系时间 customer_id = data.get('from_id', '') if customer_id: try: profile = db.get_customer(customer_id) profile.last_contact = datetime.now().isoformat() db.save_customer(profile) except Exception: pass # 保存对话摘要(异步,不阻塞回复) if response.should_reply and response.reply and customer_id: asyncio.create_task(self._save_conversation_summary( customer_id=customer_id, buyer_msg=self.to_chinese(data.get('msg', '')), agent_reply=response.reply, )) # 正常回复 if response.should_reply and response.reply: # 过滤 AI 误输出的"无需回复"类废话,避免发给客户 nonsense_patterns = [ "无需", "流程已完成", "不需要回复", "无需额外", "已完成", "无需回复", "不需要额外", "已经完成", "无需再", "操作已完成", "任务完成", "流程完成", "记录完成", "报价已", ] matched = [p for p in nonsense_patterns if p in response.reply] if matched: logger.warning(f"Agent 回复含无效内容,已拦截: {response.reply} ← 命中pattern: {matched}") else: # 模拟真人打字延迟,避免瞬间回复太机械 await asyncio.sleep(0.8) logger.info(f"Agent 回复: {response.reply}") await self.send_reply(data, response.reply) # 记录客服回复 if _cid: try: _chat_log(_cid, response.reply, "out", customer_name=_name, acc_id=data.get('acc_id', ''), platform=_plat) except Exception: pass # 推送到企微:客户消息+AI回复成对 try: from utils.wechat_chat_log import push_chat_to_wechat asyncio.create_task(push_chat_to_wechat( customer_name=_name, customer_id=_cid, acc_id=data.get('acc_id', ''), customer_msg=msg_text, reply_msg=response.reply, goods_name=self.to_chinese(data.get('goods_name', '') or ''), )) except Exception: pass elif not response.need_transfer: logger.info("Agent 决定不回复此消息") except Exception as e: logger.error(f"Agent 处理失败: {e}") async def _analyze_multi_and_reply(self, data: dict, urls: list): try: from image.image_analyzer import image_analyzer def _detect_composite_request() -> bool: try: from db.chat_log_db import get_recent_conversation recent = get_recent_conversation( customer_id=data.get('from_id', ''), acc_id=data.get('acc_id', ''), limit=8 ) kw = ("抓到", "放到", "合成", "融合", "嵌到", "换到", "替换", "P到", "抠出来放到") for m in recent: msg = (m.get("message") or "") if any(k in msg for k in kw): return True except Exception: pass return False tasks = [image_analyzer.analyze(u) for u in urls] results = await asyncio.gather(*tasks, return_exceptions=True) pairs = [] for u, r in zip(urls, results): if isinstance(r, dict) and r.get("success", False): from config.config import MIN_PRICE_FLOOR floor_dyn = r.get("price_min", MIN_PRICE_FLOOR) floor = max(MIN_PRICE_FLOOR, int(floor_dyn) if isinstance(floor_dyn, (int, float)) else MIN_PRICE_FLOOR) ps = max(floor, round(r.get("price_suggest", 20) / 5) * 5) pairs.append((u, ps, r.get("category", ""), r.get("megapixels", 0.0))) try: if pairs: floors = [] for u, r in zip(urls, results): if isinstance(r, dict) and r.get("success", False): floor_dyn = r.get("price_min", MIN_PRICE_FLOOR) floor = max(MIN_PRICE_FLOOR, int(floor_dyn) if isinstance(floor_dyn, (int, float)) else MIN_PRICE_FLOOR) floors.append(floor) if floors: from db.customer_db import db as _db _db.update_last_min_price(data.get('from_id',''), min(floors)) except Exception: pass if not pairs: await self.send_reply(data, "这组图我看了,先按20元一张给你做") return composite = _detect_composite_request() composite_fee = 5 if composite else 0 avg_raw = sum(p for _, p, _, _ in pairs) / len(pairs) from config.config import MIN_PRICE_FLOOR avg_price = max(MIN_PRICE_FLOOR, round((avg_raw + composite_fee) / 5) * 5) top_price = max(MIN_PRICE_FLOOR, max(pairs, key=lambda x: x[1])[1] + composite_fee) count = len(pairs) if composite: reply = f"这组{count}张我看了,按{avg_price}元一张;合成那张{top_price}元,满意再拍" else: reply = f"这组{count}张我看了,按{avg_price}元一张;复杂那张{top_price}元,满意再拍" await self.send_reply(data, reply) try: _chat_log( data.get('from_id', ''), reply, "out", customer_name=self.to_chinese(data.get('from_name', '') or data.get('cy_name', '')), acc_id=data.get('acc_id', ''), platform=data.get('acc_type', '') ) except Exception: pass except Exception as e: logger.error(f"多图分析失败: {e}") try: await self.send_reply(data, "这组图我看了,先按20元一张给你做") except Exception: pass def _msg_requests_external_contact(self, msg: str) -> bool: if not msg: return False lower = msg.lower() kws = ("加qq", "qq号", "vx", "微信", "加v", "联系方式", "私聊", "加一下", "加个", "手机号", "电话", "加群", "q q", "v 信") return any(k in lower for k in kws) def _is_transfer_msg(self, data: dict) -> bool: """判断是否是会话转交消息(需要主动打招呼)""" msg = self.to_chinese(data.get('msg', '')) return '转交给' in msg or '转接给' in msg def _is_shop_card(self, data: dict) -> bool: """判断是否是进店卡片消息""" msg = self.to_chinese(data.get('msg', '')) return msg.startswith('[进店卡片]') or '我想咨询你们店的这个商品' in msg def _has_chat_history(self, customer_id: str) -> bool: """判断该客户是否已有聊天记录(内存历史或数据库均可)""" if not customer_id: return False # 先查内存对话历史(最快) if customer_id in self.agent.message_histories and self.agent.message_histories[customer_id]: return True # 再查数据库(重启后仍有记录) try: from db.chat_log_db import get_conversation msgs = get_conversation(customer_id, limit=1) return len(msgs) > 0 except Exception: return False def _should_ignore(self, data: dict) -> bool: """判断是否应该忽略该消息(不回复)""" msg = self.to_chinese(data.get('msg', '')) # 会话转交由 _is_transfer_msg 单独处理,这里不再忽略 ignore_patterns = [ '已转接', '接入会话', '结束会话', '会话已', '[系统消息]', '[系统通知]', ] for pattern in ignore_patterns: if pattern in msg: return True # 发送者是自己(店铺账号),避免回复自己发的消息 acc_id = data.get('acc_id', '') from_id = data.get('from_id', '') if acc_id and from_id and acc_id == from_id: return True return False def get_msg_type_name(self, msg_type): """获取消息类型名称""" types = { 0: "文本", 1: "图片", 2: "视频", 3: "文件" } return types.get(msg_type, f"未知({msg_type})") def _extract_and_save_customer_info(self, message: str, customer_id: str): """从消息中提取客户信息并保存""" if not message or not customer_id: return # 提取邮箱 email_pattern = r'[\w\.-]+@[\w\.-]+\.\w+' email_match = re.search(email_pattern, message) if email_match: db.update_email(customer_id, email_match.group()) # 提取手机号 phone_pattern = r'1[3-9]\d{9}' phone_match = re.search(phone_pattern, message) if phone_match: db.update_phone(customer_id, phone_match.group()) # 提取微信号 wechat_pattern = r'[Vv微信]+号[::]?\s*([\w-]+)' wechat_match = re.search(wechat_pattern, message) if wechat_match: db.update_wechat(customer_id, wechat_match.group(1)) # 提取预算关键词 budget_keywords = ['预算', '不超过', '最多', '便宜点', '便宜'] for keyword in budget_keywords: if keyword in message: db.add_personality_tag(customer_id, "关注价格") break # 提取性格关键词 personality_keywords = { '爽快': '爽快', '干脆': '爽快', '纠结': '纠结', '墨迹': '纠结', '砍价': '砍价', '贵': '砍价' } for keyword, tag in personality_keywords.items(): if keyword in message: db.add_personality_tag(customer_id, tag) # 更新最后联系时间 profile = db.get_customer(customer_id) profile.last_contact = datetime.now().isoformat() db.save_customer(profile) def to_chinese(self, text): """处理文本,安全地转换 unicode 转义""" if not isinstance(text, str): return text if '\\u' not in text: return text try: return json.loads(f'"{text}"') except Exception: return text async def handle_image_message(self, data: dict): """ 处理图片消息。 先回复"我找找",然后把图片URL作为消息内容交给 Agent(后台执行)。 Agent 会自主调用 analyze_image() 工具分析复杂度,再报价。 整个过程由 Agent 自主协调,无需外部干预。 不阻塞接收循环,可同时接收其他客户消息。 """ # 立刻回复,让客户感觉真人在操作 await self.send_reply(data, "我找找") # 把图片URL当作消息内容,交给 Agent 后台处理(图片分析约 12 秒,不阻塞新消息接收) image_data = dict(data) image_data['msg'] = f"[客户发来图片] {data.get('msg', '')}" image_data['msg_type'] = 0 # 转为文本消息,让 agent_reply 处理 self._fire_and_forget(self._agent_reply_serialized(image_data)) async def transfer_to_human(self, data: dict, transfer_msg: str = ""): """ 转接人工客服。 1. 优先从 designer_roster 轮询派单(在线设计师) 2. 无人在线或未配置时,回退到 config/transfer_groups.json 设计师在线状态:仅在转人工时按需查询,不轮询。 """ if not self.websocket: print(f"[{self.get_time()}] 错误: 未连接到服务器") return acc_id = data.get("acc_id", "") group_id = None # 1. 转人工时按需查询设计师在线状态(调用另一台 AI 的查询服务),再派单 try: from utils.designer_roster import poll_and_update_roster from db.designer_roster_db import get_transfer_group_for_shop await poll_and_update_roster() group_id = get_transfer_group_for_shop(acc_id) except Exception as e: logger.debug(f"设计师派单未启用或异常: {e}") # 2. 无人在线时企微提醒 if not group_id: try: from config.config import WECHAT_WEBHOOK if WECHAT_WEBHOOK: import httpx async with httpx.AsyncClient(timeout=5) as client: resp = await client.post(WECHAT_WEBHOOK, json={ "msgtype": "text", "text": {"content": "谁在线啊"} }) if resp.status_code != 200: logger.warning(f"企微提醒发送失败: {resp.status_code} {resp.text}") else: logger.debug("未配置 WECHAT_WEBHOOK,跳过企微提醒") except Exception as e: logger.warning(f"企微提醒发送异常: {e}") # 3. 回退到静态配置 if not group_id: group_id = _get_transfer_group(acc_id) # 先发一条提示语给客户 await self.send_reply(data, "亲,正在为您转接人工客服,请稍等~") cmd = f"话术|[转移会话],分组{group_id},无原因" await self.send_reply(data, cmd) print(f"[{self.get_time()}] 已发送转接请求 (店铺:{acc_id or '未知'} -> 分组:{group_id})") async def _save_conversation_summary(self, customer_id: str, buyer_msg: str, agent_reply: str): """用 AI 生成一句话对话摘要并持久化""" try: from db.customer_db import db from openai import AsyncOpenAI client = AsyncOpenAI( api_key=self.agent.api_key if self.agent else None, base_url=self.agent.base_url if self.agent else None, ) resp = await client.chat.completions.create( model=self.agent.model_name if self.agent else "gpt-4o-mini", messages=[ {"role": "system", "content": "用一句话(15字以内)总结这段对话的核心内容,只输出摘要文字。"}, {"role": "user", "content": f"买家:{buyer_msg}\n客服:{agent_reply}"}, ], max_tokens=30, temperature=0.3, ) summary = resp.choices[0].message.content.strip() db.save_conversation_summary(customer_id, summary) except Exception: pass # 摘要失败不影响主流程 async def _workflow_agent_notify( self, customer_id: str, acc_id: str, acc_type: str, system_hint: str, ): """图片处理完成后,让客服 AI 生成自然话术发给客户""" if not self.enable_agent or not self.agent: return try: from core.pydantic_ai_agent import CustomerMessage notify_msg = CustomerMessage( msg_id="workflow_notify", acc_id=acc_id, msg=system_hint, from_id=customer_id, from_name="", cy_id=customer_id, acc_type=acc_type, msg_type=0, cy_name="", ) response = await self.agent.process_message(notify_msg) if response.should_reply and response.reply: nonsense_patterns = [ "无需", "流程已完成", "不需要回复", "无需额外", "已完成", "无需回复", "不需要额外", "已经完成", "无需再", "操作已完成", "任务完成", "流程完成", "记录完成", "报价已", ] if not any(p in response.reply for p in nonsense_patterns): # 构造一个虚拟原始消息用于 send_reply fake_data = { "acc_id": acc_id, "from_id": customer_id, "from_name": "", "cy_id": customer_id, "acc_type": acc_type, } await asyncio.sleep(0.5) await self.send_reply(fake_data, response.reply) try: _chat_log(customer_id, response.reply, "out", acc_id=acc_id, platform=acc_type) except Exception: pass logger.info(f"[Workflow] AI 通知已发送: {response.reply}") except Exception as e: logger.error(f"[Workflow] AI 通知生成失败: {e}") async def _workflow_send( self, customer_id: str, acc_id: str, acc_type: str, content: str, msg_type: int = 0 ): """workflow 回调:图片AI完成后用此方法推送消息给客户""" msg = { "msg_id": "", "acc_id": acc_id, "msg": content, "from_id": customer_id, "from_name": customer_id, "cy_id": customer_id, "acc_type": acc_type, "msg_type": msg_type, "cy_name": customer_id } await self.send_message(msg) async def send_reply(self, original_msg, reply_content): """ 发送回复消息 Args: original_msg: 收到的原始消息字典 reply_content: 回复内容(文本或本地文件路径/http地址) """ if not self.websocket: print(f"[{self.get_time()}] 错误: 未连接到服务器") return shop_id = original_msg.get("acc_id", "") # 根据轻简API文档: # from_id = 客户ID(收消息方) # cy_id = 非群聊时与 from_id 相同 customer_id = original_msg.get("from_id", "") customer_name = original_msg.get("from_name", "") reply = { "msg_id": "", "acc_id": shop_id, "msg": reply_content, "from_id": customer_id, "from_name": customer_name, "cy_id": customer_id, "acc_type": original_msg.get("acc_type", ""), "msg_type": 0, "cy_name": customer_name } await self.send_message(reply) async def send_text(self, cy_id, acc_type, content): """ 主动发送文本消息 Args: cy_id: 会话ID(对方ID) acc_type: 平台类型 content: 消息内容 """ message = { "msg_id": "", "acc_id": "", "msg": content, "from_id": self.reply_id, "from_name": self.reply_id, "cy_id": cy_id, "acc_type": acc_type, "msg_type": 0, "cy_name": "" } await self.send_message(message) async def send_image(self, cy_id, acc_type, image_path): """ 主动发送图片消息 Args: cy_id: 会话ID(对方ID) acc_type: 平台类型 image_path: 图片本地路径或http地址 """ message = { "msg_id": "", "acc_id": "", "msg": image_path, "from_id": self.reply_id, "from_name": self.reply_id, "cy_id": cy_id, "acc_type": acc_type, "msg_type": 1, "cy_name": "" } await self.send_message(message) async def send_message(self, message): """发送消息到服务器""" if self.websocket and self.websocket.state == websockets.protocol.State.OPEN: try: msg_json = json.dumps(message, ensure_ascii=False) await self.websocket.send(msg_json) pretty = json.dumps(message, ensure_ascii=False, indent=2) print(f"[{self.get_time()}] 发送成功:\n{pretty}") except Exception as e: print(f"[{self.get_time()}] 发送失败: {e}") else: print(f"[{self.get_time()}] 错误: 连接未打开") async def auto_reply(self, data): """自动回复示例(已弃用,使用 agent_reply 替代)""" pass async def command_handler(self): """命令行交互""" print("\n命令帮助:") print(" reply <内容> - 回复最后一条消息") print(" text <平台> <内容> - 发送文本消息") print(" img <平台> <路径> - 发送图片") print(" setid - 设置回复ID") print(" agent on/off - 开启/关闭 Agent") print(" exit/quit - 退出\n") while self.running: try: loop = asyncio.get_running_loop() user_input = await loop.run_in_executor(None, input, "") parts = user_input.strip().split(maxsplit=1) if not parts: continue cmd = parts[0].lower() if cmd in ["exit", "quit", "q"]: print(f"[{self.get_time()}] 正在关闭...") self.running = False if self.websocket: await self.websocket.close() break elif cmd == "setid" and len(parts) > 1: self.reply_id = parts[1] print(f"[{self.get_time()}] 回复ID已设置为: {self.reply_id}") elif cmd == "agent" and len(parts) > 1: if parts[1].lower() == "on": self.enable_agent = True print(f"[{self.get_time()}] Agent 已开启") elif parts[1].lower() == "off": self.enable_agent = False print(f"[{self.get_time()}] Agent 已关闭") elif cmd == "reply" and len(parts) > 1: if self.last_msg: await self.send_reply(self.last_msg, parts[1]) else: print(f"[{self.get_time()}] 错误: 还没有收到任何消息") elif cmd == "text" and len(parts) > 1: # text cy_id acc_type content args = parts[1].split(maxsplit=2) if len(args) >= 3: await self.send_text(args[0], args[1], args[2]) else: print(f"[{self.get_time()}] 格式: text <内容>") elif cmd == "img" and len(parts) > 1: # img cy_id acc_type image_path args = parts[1].split(maxsplit=2) if len(args) >= 3: await self.send_image(args[0], args[1], args[2]) else: print(f"[{self.get_time()}] 格式: img <图片路径>") else: print(f"[{self.get_time()}] 未知命令: {cmd}") except Exception as e: print(f"[{self.get_time()}] 命令错误: {e}") def get_time(self): """获取当前时间字符串""" return datetime.now().strftime("%H:%M:%S") async def run(self): """运行客户端""" tasks = [self.connect(), self.command_handler()] # 启动邮件接收后台任务 try: from mail.email_receiver import email_receiver if email_receiver.username: print(f"[{self.get_time()}] 邮件接收已启动,监控: {email_receiver.username}") tasks.append(email_receiver.start()) else: print(f"[{self.get_time()}] 未配置邮件账号,跳过邮件接收") except Exception as e: print(f"[{self.get_time()}] 邮件接收模块加载失败: {e}") # 启动每日汇总定时任务 try: from utils.daily_summary import scheduler as daily_scheduler tasks.append(daily_scheduler()) print(f"[{self.get_time()}] 每日日报定时任务已启动") except Exception as e: print(f"[{self.get_time()}] 日报模块加载失败: {e}") # 设计师在线状态:转人工时按需查询,不再轮询 # 启动健康检查(轻简/企微断线告警) try: from utils.health_check import health_check_loop def _qingjian_ok(): return self.websocket is not None and not getattr(self.websocket, "closed", True) tasks.append(health_check_loop(_qingjian_ok)) print(f"[{self.get_time()}] 健康检查已启动") except Exception as e: print(f"[{self.get_time()}] 健康检查模块加载失败: {e}") # 每天早上8点发送启动消息到企微群 try: from utils.wechat_chat_log import morning_startup_scheduler tasks.append(morning_startup_scheduler()) print(f"[{self.get_time()}] 早8点企微启动消息已启动") except Exception as e: print(f"[{self.get_time()}] 企微启动消息模块加载失败: {e}") await asyncio.gather(*tasks) if __name__ == "__main__": import sys # 检查是否有 --no-agent 参数 enable_agent = "--no-agent" not in sys.argv client = QingjianAPIClient(enable_agent=enable_agent) try: asyncio.run(client.run()) except KeyboardInterrupt: print("\n已停止")