ForcePilot/backend/package/yuxi/channel/outbound/downgrade.py
Kris bab30f2715
Some checks failed
Deploy VitePress site to Pages / build (push) Has been cancelled
Ruff Format Check / Ruff Format & Lint (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
feat:0715
2026-07-15 12:30:58 +08:00

161 lines
6.5 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.

"""渠道出站消息能力降级。"""
import asyncio
import copy
import json
import re
from yuxi.channel.plugins.protocol import DeliveryCapabilities, OutboundMessage
from yuxi.utils.logging_config import logger
class Downgrader:
"""根据渠道投递能力对 OutboundMessage 进行降级。"""
_SYNC_REGEX_THRESHOLD = 10000
@classmethod
async def downgrade(
cls,
message: OutboundMessage,
capabilities: DeliveryCapabilities,
) -> OutboundMessage:
"""将消息内容/媒体降级到渠道支持的形态。
当消息包含渠道不支持的能力时:
- markdown -> text
- interactive -> markdown/text尽量保留 action 语义)
- media -> 丢弃并附带文字说明;若完全无法表达则抛出永久错误
"""
result = copy.copy(message)
if result.content_type == "markdown" and not capabilities.supports_markdown:
result.content = await cls._markdown_to_text_async(result.content)
result.content_type = "text"
if result.content_type == "interactive" and not capabilities.supports_interactive:
if capabilities.supports_markdown:
result.content = cls._interactive_to_markdown(result.content)
result.content_type = "markdown"
else:
result.content = await cls._markdown_to_text_async(cls._interactive_to_markdown(result.content))
result.content_type = "text"
if not capabilities.supports_media:
dropped = len(result.media)
if dropped:
logger.warning(
"Channel does not support media, dropping %d attachment(s) for message",
dropped,
)
note = f"[该消息包含 {dropped} 个媒体附件,当前渠道不支持媒体,已省略]"
result.content = f"{result.content}\n\n{note}" if result.content else note
result.media = []
return result
@classmethod
async def _markdown_to_text_async(cls, text: str) -> str:
if len(text) > cls._SYNC_REGEX_THRESHOLD:
return await asyncio.to_thread(cls._markdown_to_text, text)
return cls._markdown_to_text(text)
@staticmethod
def _markdown_to_text(text: str) -> str:
"""简单移除常见 Markdown 标记,保留可读纯文本。"""
# 代码块:移除围栏标记但保留内部文本
text = re.sub(r"```\w*\n?", "", text)
text = re.sub(r"```", "", text)
text = re.sub(r"`([^`]*)`", r"\1", text)
# 图片/链接
text = re.sub(r"!\[([^\]]*)\]\([^)]*\)", r"\1", text)
text = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", text)
# 标题、加粗、斜体、删除线、引用
text = re.sub(r"^#{1,6}\s*", "", text, flags=re.MULTILINE)
text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
text = re.sub(r"__([^_]+)__", r"\1", text)
text = re.sub(r"\*([^*]+)\*", r"\1", text)
text = re.sub(r"_([^_]+)_", r"\1", text)
text = re.sub(r"~~([^~]+)~~", r"\1", text)
text = re.sub(r"^>\s*", "", text, flags=re.MULTILINE)
# 列表标记
text = re.sub(r"^\s*[-*+]\s+", "", text, flags=re.MULTILINE)
text = re.sub(r"^\s*\d+\.\s+", "", text, flags=re.MULTILINE)
return text.strip()
@classmethod
def _interactive_to_markdown(cls, content) -> str:
"""将交互内容表示为 Markdown尽量保留标题、选项与动作语义。"""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = [cls._interactive_to_markdown(item) for item in content]
return "\n\n".join(part for part in parts if part)
if isinstance(content, dict):
title = content.get("title") or content.get("text") or ""
description = content.get("description") or ""
lines: list[str] = []
if title:
lines.append(f"**{title}**")
if description:
lines.append(description)
# 选项/菜单
options = content.get("options") or []
if options:
lines.append("请选择:")
for idx, option in enumerate(options, start=1):
label, value = cls._extract_label_value(option)
lines.append(f"{idx}. {label}" + (f"{value}" if value and value != label else ""))
# 按钮
buttons = content.get("buttons") or []
if buttons:
lines.append("可执行操作:")
for idx, button in enumerate(buttons, start=1):
label, value, action_type = cls._extract_button_info(button)
lines.append(
f"{idx}. {label}"
+ (f" [{action_type}]" if action_type else "")
+ (f" -> {value}" if value else "")
)
# 通用 actions
actions = content.get("actions") or []
if actions:
lines.append("可执行操作:")
for idx, action in enumerate(actions, start=1):
label, value, action_type = cls._extract_button_info(action)
lines.append(
f"{idx}. {label}"
+ (f" [{action_type}]" if action_type else "")
+ (f" -> {value}" if value else "")
)
if lines:
return "\n".join(lines)
try:
return json.dumps(content, ensure_ascii=False)
except Exception:
return str(content)
@classmethod
def _extract_label_value(cls, option) -> tuple[str, str | None]:
if isinstance(option, dict):
label = option.get("label") or option.get("text") or option.get("title") or ""
value = option.get("value")
return str(label or value or ""), value
return str(option), None
@classmethod
def _extract_button_info(cls, button) -> tuple[str, str | None, str | None]:
if isinstance(button, dict):
label = button.get("label") or button.get("text") or button.get("title") or ""
value = button.get("value") or button.get("url") or button.get("action") or ""
action_type = button.get("type") or button.get("action_type") or ""
return str(label or value or ""), value or None, action_type or None
return str(button), None, None