""" 意图:海运协同群入站(文字意图 / 附件)。 本文件职责:对上工单后交给 sea_group_ops;一类动作一条分支。 禁止:改询价七项;终态装没看见;把销售附件当报价。 """ from __future__ import annotations import logging from typing import Any, Optional from agent.channel.wecom.models import InboundMessage from agent.policy.sea_group_ops import ( apply_collab_update, apply_confirm_tms_quote, apply_group_deal, apply_product_quote, apply_tms_quote_choice, capture_group_lost_reason, dissolve_group, group_client_of, handle_group_file, hydrate_ticket, is_handoff_ticket, peek_pending_product_quote, send_group_text, ticket_no, ticket_view, ) from agent.policy.sea_text_flow import get_sea_text_flow logger = logging.getLogger(__name__) def handle_group_collab( message: InboundMessage, *, flow=None, ledger=None, injected_intent: str = "", injected_quote: Optional[dict[str, Any]] = None, ) -> str: """ 处理一条群聊入站。兼容旧测试名。 必须能对上已绑定工单,且发言人是被拉进来的销售或产品。 """ return handle_group_inbound( message, flow=flow, ledger=ledger, injected_intent=injected_intent, injected_quote=injected_quote, ) def handle_group_inbound( message: InboundMessage, *, flow=None, ledger=None, injected_intent: str = "", injected_quote: Optional[dict[str, Any]] = None, ) -> str: """ 群里一条文字或附件。 文字走 DeepSeek A(单测可注入);附件只看扩展名。 已成交/未成交/已关闭要回话。真正转人工后群里不再跟、不回。 """ if (message.chat_type or "") != "group": return "group_ignore" if not message.has_sender(): return "reject_no_sender" engine = flow or get_sea_text_flow() book = ledger or engine.ledger chat_id = (message.chat_id or "").strip() if not chat_id: return "group_ignore" ticket = _find_ticket(book, chat_id) if ticket is None: logger.info("group_inbound 对不上工单 chat=%s", chat_id) return "group_ignore" if flow is None: view0 = ticket_view(ticket) line = str( view0.get("business_line") or view0.get("businessLine") or getattr(ticket, "business_line", "") or "" ).upper() if line == "LAND": from agent.policy.land_text_flow import get_land_text_flow engine = get_land_text_flow() view0 = ticket_view(ticket) line = str( view0.get("business_line") or view0.get("businessLine") or getattr(ticket, "business_line", "") or "" ).upper() if line == "MULTI": from agent.policy.multi_group_ops import bot_sender_when_air_multi, handle_multi_inbound, sender_mode if not ( _sender_allowed(ticket, message.sender_id) or sender_mode(engine, message.sender_id) ): logger.info("group_inbound 多段忽略非成员 sender=%s", message.sender_id) return "group_ignore" # 会话存档听到的报价也走这里。含空运时群是机器人建的,必须改走桥才能发得出去。 with bot_sender_when_air_multi(ticket): return handle_multi_inbound( flow=engine, ticket=ticket, chat_id=chat_id, sender_id=message.sender_id, text=message.content or "", injected_quote=injected_quote, ) ticket = hydrate_ticket(engine, ticket) if not _sender_allowed(ticket, message.sender_id): logger.info("group_inbound 忽略非成员 sender=%s chat=%s", message.sender_id, chat_id) return "group_ignore" from agent.policy.system_exception import ( cabin_failure_pause, reply_paused_ticket, ticket_is_paused, ) # 锁舱失败不挡海运/陆运群里的后续报价。查价失败的暂停仍要停。 if ticket_is_paused(ticket) and not cabin_failure_pause(ticket): reply_paused_ticket( ticket=ticket, reply=lambda text: send_group_text(group_client_of(engine), chat_id, text), ) return "system_exception_paused" sess = _session_of_ticket(engine, ticket) if is_handoff_ticket(ticket, sess): logger.info( "group_inbound 转人工不再跟 wo=%s chat=%s", ticket_no(ticket), chat_id, ) return "handoff_ignore" view = ticket_view(ticket) if view.get("collab_ended") or (sess is not None and getattr(sess, "collab_ended", False)): from agent.policy import inquiry_copy as copy send_group_text( group_client_of(engine), chat_id, copy.group_dissolved(work_order_no=ticket_no(ticket)), ) return "group_already_ended" kind = (message.msg_type or "text").lower() if kind in {"file", "image", "video"}: media = dict(message.media or {}) filename = str( media.get("filename") or media.get("FileName") or (message.content or "") ).strip() return handle_group_file( flow=engine, ticket=ticket, sender_id=message.sender_id, filename=filename, media=media, sess=sess, ) from agent.routing.group_intent import ( INTENT_CONFIRM_TMS, INTENT_DEAL, INTENT_DISSOLVE, INTENT_DROP_TMS, INTENT_FIELDS, INTENT_KEEP_TMS, INTENT_LOST, INTENT_NEGOTIATE, INTENT_ADJUST, INTENT_QUOTE, classify_group_text, ) lost = capture_group_lost_reason( flow=engine, ticket=ticket, sender_id=message.sender_id, text=message.content or "", sess=sess, ) if lost: return lost intent = classify_group_text(message.content or "", injected=injected_intent) logger.info( "group_inbound 文字 chat=%s sender=%s intent=%s chars=%s", chat_id, message.sender_id, intent, len((message.content or "").strip()), ) if intent in {INTENT_DEAL, INTENT_LOST, INTENT_NEGOTIATE}: outcome = { INTENT_DEAL: "已成交", INTENT_LOST: "未成交", INTENT_NEGOTIATE: "协商中", }[intent] return apply_group_deal( flow=engine, ticket=ticket, sender_id=message.sender_id, outcome=outcome, sess=sess, text=message.content or "", ) if intent == INTENT_CONFIRM_TMS: return apply_confirm_tms_quote( flow=engine, ticket=ticket, sender_id=message.sender_id, sess=sess, ) if intent in {INTENT_KEEP_TMS, INTENT_DROP_TMS}: return apply_tms_quote_choice( flow=engine, ticket=ticket, sender_id=message.sender_id, keep_tms=intent == INTENT_KEEP_TMS, sess=sess, ) if intent == INTENT_FIELDS: return apply_collab_update( flow=engine, ticket=ticket, text=message.content or "", sender_id=message.sender_id, sess=sess, ) from agent.policy.sea_group_ops import is_sales from agent.routing.group_intent import resolve_price_intent # 产品写出「报关费改为300」是报价。不是这张单的销售时,不能回调价话术。 priced = resolve_price_intent( intent, message.content or "", sales=is_sales(ticket, message.sender_id), ) if priced != intent: logger.info("group_inbound 报价/调价改判 %s -> %s chat=%s", intent, priced, chat_id) intent = priced if intent == INTENT_ADJUST: from agent.policy.quote_adjust_ops import apply_group_adjust return apply_group_adjust( flow=engine, ticket=ticket, sender_id=message.sender_id, text=message.content or "", sess=sess, chat_id=chat_id, ) if intent == INTENT_QUOTE: return apply_product_quote( flow=engine, ticket=ticket, text=message.content or "", sender_id=message.sender_id, sess=sess, injected_quote=injected_quote, ) if intent == INTENT_DISSOLVE: return dissolve_group(flow=engine, ticket=ticket, sess=sess) parked = peek_pending_product_quote(ticket_no(ticket), sess) if parked: from agent.policy import inquiry_copy as copy send_group_text(group_client_of(engine), chat_id, copy.group_tms_choice_unclear()) return "wait_tms_choice" logger.info("group_inbound 其它意图 chat=%s intent=%s", chat_id, intent) return "group_ignore" def _find_ticket(book: object, chat_id: str): finder = getattr(book, "find_by_collab_chat", None) if callable(finder): hit = finder(chat_id=chat_id) if hit is not None: return hit try: from agent.channel.wshoto.rooms import ticket_no_for_room no = ticket_no_for_room(chat_id) except Exception: logger.exception("存档房间反查工单失败 chat=%s", chat_id) no = "" if not no: return None getter = getattr(book, "get_ticket", None) if callable(getter): hit = getter(work_order_no=no) if hit is not None: return hit getter = getattr(book, "get_for_agent", None) if callable(getter): return getter(work_order_no=no, sender_id="") return None def _session_of_ticket(engine: object, ticket: object): """用工单号找回销售侧书签,方便写 collab_facts / deal_version。""" no = ticket_no(ticket) getter = getattr(engine, "session_by_work_order", None) if callable(getter) and no: return getter(no) by_ticket = getattr(engine, "_by_ticket", None) if isinstance(by_ticket, dict) and no: return by_ticket.get(no) return None def _sender_allowed(ticket: object, sender_id: str) -> bool: uid = (sender_id or "").strip() if not uid: return False view = ticket_view(ticket) sales = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "") members = view.get("collab_member_ids") or view.get("memberIds") or [] if uid == sales: return True return uid in {str(x) for x in members}