ForcePilot/backend/package/yuxi/channels/adapters/googlechat/formatter.py

208 lines
6.2 KiB
Python
Raw Normal View History

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)