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)