新增 Google Chat 对接的全套工具模块,包括: - 会话线程管理、消息解析与格式化 - Pub/Sub 消息解码、提及和命令识别 - 权限审批、目录管理和审计日志 - 消息缓存、媒体上传下载和 SSFR 防护 - 策略配置、卡片构建和流式回复支持
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)
|