161 lines
6.5 KiB
Python
161 lines
6.5 KiB
Python
"""渠道出站消息能力降级。"""
|
||
|
||
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
|