ForcePilot/backend/package/yuxi/channels/adapters/googlechat/formatter.py
Kris 87fc26cd15 refactor(googlechat): 整理并优化Google Chat适配器代码
- 调整依赖导入顺序与清理冗余空行
- 修复setup wizard步骤存储逻辑
- 新增消息编辑/转发标记、卡片控件支持
- 完善目标校验、媒体上传、配对存储功能
- 优化流式响应与错误处理逻辑
- 增强SSRF防护与配置灵活性
- 新增/扩展内置命令与卡片处理能力
- 调整媒体大小限制与类型参数
2026-05-13 16:09:31 +08:00

211 lines
6.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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", [])
img_widget = {"image": {"imageUrl": att.url}}
if att.filename:
img_widget["image"]["altText"] = att.filename
body["cards_v2"].append({"card": {"sections": [{"widgets": [img_widget]}]}})
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)