208 lines
6.2 KiB
Python
208 lines
6.2 KiB
Python
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import re
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
from yuxi.channels.models import ChannelResponse
|
|||
|
|
|
|||
|
|
from . import cards
|
|||
|
|
|
|||
|
|
_MAX_TEXT_CHARS = 4000
|
|||
|
|
|
|||
|
|
|
|||
|
|
def format_outbound(response: ChannelResponse) -> dict[str, Any]:
|
|||
|
|
body: dict[str, Any] = {}
|
|||
|
|
content = sanitize_text(response.content or "")
|
|||
|
|
if not content:
|
|||
|
|
content = " "
|
|||
|
|
body["text"] = _truncate_text(content)
|
|||
|
|
|
|||
|
|
body = _apply_card(body, response)
|
|||
|
|
|
|||
|
|
if response.attachments:
|
|||
|
|
for att in response.attachments:
|
|||
|
|
if att.url and att.type in ("image", "IMAGE", "video", "VIDEO"):
|
|||
|
|
body.setdefault("cards_v2", [])
|
|||
|
|
body["cards_v2"].append({"card": {"sections": [{"widgets": [{"image": {"imageUrl": att.url}}]}]}})
|
|||
|
|
|
|||
|
|
if response.metadata and response.metadata.get("thread_key"):
|
|||
|
|
body["messageReplyOption"] = "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD"
|
|||
|
|
body["thread"] = {"threadKey": response.metadata["thread_key"]}
|
|||
|
|
|
|||
|
|
if response.metadata and response.metadata.get("silent"):
|
|||
|
|
body["disableNotification"] = True
|
|||
|
|
|
|||
|
|
return body
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _apply_card(body: dict[str, Any], response: ChannelResponse) -> dict[str, Any]:
|
|||
|
|
if response.metadata and response.metadata.get("card"):
|
|||
|
|
body["cards_v2"] = response.metadata["card"].get("cards_v2", [])
|
|||
|
|
return body
|
|||
|
|
|
|||
|
|
card_type = (response.metadata or {}).get("card_type", "")
|
|||
|
|
if not card_type:
|
|||
|
|
return body
|
|||
|
|
|
|||
|
|
card_params = (response.metadata or {}).get("card_params", {})
|
|||
|
|
card_body = _build_card(card_type, response.content, card_params)
|
|||
|
|
if card_body:
|
|||
|
|
body["cards_v2"] = card_body.get("cards_v2", [])
|
|||
|
|
|
|||
|
|
return body
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _build_card(card_type: str, content: str, params: dict[str, Any]) -> dict | None:
|
|||
|
|
action_id = params.get("action_id", "card_action")
|
|||
|
|
|
|||
|
|
if card_type == "approval":
|
|||
|
|
return cards.build_approval_card(
|
|||
|
|
title=params.get("title", content or "审批"),
|
|||
|
|
action_id=action_id,
|
|||
|
|
confirm_text=params.get("confirm_text", "确认"),
|
|||
|
|
reject_text=params.get("reject_text", "拒绝"),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if card_type == "poll":
|
|||
|
|
options = params.get("options", [])
|
|||
|
|
if not options:
|
|||
|
|
return None
|
|||
|
|
return cards.build_poll_card(
|
|||
|
|
question=params.get("title", content or "投票"),
|
|||
|
|
options=options,
|
|||
|
|
action_id=action_id,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if card_type == "info":
|
|||
|
|
fields = params.get("fields", {})
|
|||
|
|
if not fields:
|
|||
|
|
return None
|
|||
|
|
return cards.build_info_card(
|
|||
|
|
title=params.get("title", content or "信息"),
|
|||
|
|
fields=fields,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if card_type == "form":
|
|||
|
|
fields = params.get("fields", [])
|
|||
|
|
if not fields:
|
|||
|
|
return None
|
|||
|
|
return cards.build_form_card(
|
|||
|
|
title=params.get("title", content or "表单"),
|
|||
|
|
fields=fields,
|
|||
|
|
submit_action=params.get("submit_action", action_id),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if card_type == "selection":
|
|||
|
|
options = params.get("options", [])
|
|||
|
|
if not options:
|
|||
|
|
return None
|
|||
|
|
return cards.build_selection_card(
|
|||
|
|
title=params.get("title", content or "选择"),
|
|||
|
|
selection_name=params.get("selection_name", "selection"),
|
|||
|
|
label=params.get("label", "请选择"),
|
|||
|
|
options=options,
|
|||
|
|
selection_type=params.get("selection_type", "SINGLE_SELECT"),
|
|||
|
|
submit_action=params.get("submit_action"),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if card_type == "exec_approval":
|
|||
|
|
return cards.build_exec_approval_card(
|
|||
|
|
title=params.get("title", content or "执行审批"),
|
|||
|
|
action_id=action_id,
|
|||
|
|
detail_fields=params.get("detail_fields"),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def resolve_reply_to_mode(config: dict | None = None, default: str = "off") -> str:
|
|||
|
|
if not config:
|
|||
|
|
return default
|
|||
|
|
mode = str(config.get("reply_to_mode", config.get("replyToMode", default))).strip().lower()
|
|||
|
|
if mode in ("first", "all", "off"):
|
|||
|
|
return mode
|
|||
|
|
return default
|
|||
|
|
|
|||
|
|
|
|||
|
|
def sanitize_text(text: str) -> str:
|
|||
|
|
text = text.replace("\x00", "").rstrip()
|
|||
|
|
text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", text)
|
|||
|
|
return text
|
|||
|
|
|
|||
|
|
|
|||
|
|
def chunk_text_for_outbound(text: str, chunk_limit: int = _MAX_TEXT_CHARS) -> list[str]:
|
|||
|
|
if not text:
|
|||
|
|
return []
|
|||
|
|
if len(text) <= chunk_limit:
|
|||
|
|
return [text]
|
|||
|
|
|
|||
|
|
chunks: list[str] = []
|
|||
|
|
remaining = text
|
|||
|
|
|
|||
|
|
while remaining:
|
|||
|
|
if len(remaining) <= chunk_limit:
|
|||
|
|
chunks.append(remaining)
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
split_at = _find_split_point(remaining, chunk_limit)
|
|||
|
|
chunk = remaining[:split_at].rstrip()
|
|||
|
|
chunks.append(chunk)
|
|||
|
|
remaining = remaining[split_at:].lstrip()
|
|||
|
|
|
|||
|
|
return chunks
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _find_split_point(text: str, limit: int) -> int:
|
|||
|
|
window = text[:limit]
|
|||
|
|
|
|||
|
|
code_block_match = re.search(r"```[\s\S]*?```", window)
|
|||
|
|
if code_block_match and code_block_match.end() <= limit:
|
|||
|
|
after_end = code_block_match.end()
|
|||
|
|
if after_end < limit:
|
|||
|
|
return after_end
|
|||
|
|
|
|||
|
|
open_fence = re.search(r"```\w*\n", window)
|
|||
|
|
if open_fence:
|
|||
|
|
fence_start = open_fence.start()
|
|||
|
|
closing = text.find("```", fence_start + 3)
|
|||
|
|
if fence_start > 0 and (closing == -1 or closing >= limit):
|
|||
|
|
return fence_start
|
|||
|
|
|
|||
|
|
for pat in [r"\n\n", r"\n", r"\. ", r"。", r"\.\n"]:
|
|||
|
|
matches = list(re.finditer(pat, window))
|
|||
|
|
if matches:
|
|||
|
|
last = matches[-1]
|
|||
|
|
boundary = last.end()
|
|||
|
|
if boundary > limit * 0.6:
|
|||
|
|
return boundary
|
|||
|
|
|
|||
|
|
for pat_word in [r"\s", r"[,,;;::!!??]"]:
|
|||
|
|
m = re.search(pat_word + r"[^\s]*$", window)
|
|||
|
|
if m:
|
|||
|
|
return m.start()
|
|||
|
|
|
|||
|
|
return limit
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _truncate_text(text: str) -> str:
|
|||
|
|
if len(text) <= _MAX_TEXT_CHARS:
|
|||
|
|
return text
|
|||
|
|
|
|||
|
|
truncated = text[: _MAX_TEXT_CHARS - 3]
|
|||
|
|
|
|||
|
|
markdown_boundary = _find_markdown_break(truncated)
|
|||
|
|
if markdown_boundary > _MAX_TEXT_CHARS * 0.8:
|
|||
|
|
truncated = truncated[:markdown_boundary].rstrip()
|
|||
|
|
|
|||
|
|
return truncated + "..."
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _find_markdown_break(text: str) -> int:
|
|||
|
|
best = 0
|
|||
|
|
for pat in [r"\n\n", r"\n", r"\. ", r"。"]:
|
|||
|
|
matches = list(re.finditer(pat, text))
|
|||
|
|
if matches:
|
|||
|
|
best = matches[-1].end()
|
|||
|
|
break
|
|||
|
|
return best if best > 0 else len(text)
|