ForcePilot/backend/package/yuxi/channels/adapters/googlechat/formatter.py
Kris ca07c593a8 feat(googlechat): 实现 Google Chat 适配器完整功能集
新增 Google Chat 对接的全套工具模块,包括:
- 会话线程管理、消息解析与格式化
- Pub/Sub 消息解码、提及和命令识别
- 权限审批、目录管理和审计日志
- 消息缓存、媒体上传下载和 SSFR 防护
- 策略配置、卡片构建和流式回复支持
2026-05-12 00:44:18 +08:00

208 lines
6.2 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", [])
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)