多段激活后无需再带工单号;含空运群回复走机器人;复制补问清单时空货好时间不再吃成下一行编号。 Co-authored-by: Cursor <cursoragent@cursor.com>
321 lines
9.8 KiB
Python
321 lines
9.8 KiB
Python
"""
|
|
意图:海运协同群入站(文字意图 / 附件)。
|
|
|
|
本文件职责:对上工单后交给 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 reply_paused_ticket, ticket_is_paused
|
|
|
|
if ticket_is_paused(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,
|
|
)
|
|
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}
|