From b59ce33582e8d3046c929e5fd730e1d0d7dc27b5 Mon Sep 17 00:00:00 2001 From: WhiteFeather127 Date: Sat, 13 Jun 2026 16:11:23 +0800 Subject: [PATCH] feat: hide long original text on QQ, add /source command and View Original button --- src/adapters/base.py | 2 ++ src/adapters/discord/adapter.py | 11 +++++- src/bridge/message_store.py | 18 +++++++++- src/bridge/orchestrator.py | 64 ++++++++++++++++++++++++++++----- src/ui/__init__.py | 3 ++ src/ui/paginator.py | 22 ++++++++++++ 6 files changed, 110 insertions(+), 10 deletions(-) create mode 100644 src/ui/__init__.py create mode 100644 src/ui/paginator.py diff --git a/src/adapters/base.py b/src/adapters/base.py index 01e286b..838c529 100644 --- a/src/adapters/base.py +++ b/src/adapters/base.py @@ -35,6 +35,8 @@ async def send_message( channel_id: str, segments: list, reply_to: str | None = None, + pages: list[str] | None = None, + original_text: str | None = None, ) -> str | None: ... @abstractmethod diff --git a/src/adapters/discord/adapter.py b/src/adapters/discord/adapter.py index b9e65c9..8a59527 100644 --- a/src/adapters/discord/adapter.py +++ b/src/adapters/discord/adapter.py @@ -27,6 +27,7 @@ text_segment, ) from adapters.base import MessageEvent, PlatformAdapter +from ui.paginator import ToggleOriginalView logger = logging.getLogger(__name__) @@ -199,6 +200,8 @@ async def send_message( channel_id: str, segments: list[Any], reply_to: str | None = None, + pages: list[str] | None = None, + original_text: str | None = None, ) -> str | None: try: channel = self._client.get_channel(int(channel_id)) @@ -232,12 +235,18 @@ async def send_message( text_content = text_content.strip() kwargs: dict[str, Any] = {} - if text_content: + view: discord.ui.View | None = None + if original_text: + kwargs["content"] = text_content + view = ToggleOriginalView(original_text, text_content) + elif text_content: kwargs["content"] = text_content if files: kwargs["files"] = files if reply_to is not None: kwargs["reference"] = channel.get_partial_message(int(reply_to)) + if view is not None: + kwargs["view"] = view if not kwargs: return None diff --git a/src/bridge/message_store.py b/src/bridge/message_store.py index 01db563..ea3d27c 100644 --- a/src/bridge/message_store.py +++ b/src/bridge/message_store.py @@ -10,6 +10,7 @@ def __init__(self, data_dir: str = "./data") -> None: self._base_dir = Path(data_dir) / "message_store" self._qq_to_discord: dict[str, str] = {} self._discord_to_qq: dict[str, str] = {} + self._original_texts: dict[str, str] = {} self._load_all() @staticmethod @@ -29,6 +30,8 @@ def _load_all(self) -> None: raw = data.get("qq_to_discord", {}) self._qq_to_discord.update(raw) self._discord_to_qq.update({v: k for k, v in raw.items()}) + raw_orig = data.get("original_texts", {}) + self._original_texts.update(raw_orig) except (json.JSONDecodeError, OSError): continue @@ -37,7 +40,10 @@ def _save(self) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text( json.dumps( - {"qq_to_discord": self._qq_to_discord}, + { + "qq_to_discord": self._qq_to_discord, + "original_texts": self._original_texts, + }, ensure_ascii=False, indent=2, ), @@ -55,3 +61,13 @@ def get_counterpart(self, platform: str, msg_id: str) -> str | None: if platform == "discord": return self._discord_to_qq.get(msg_id) return None + + def store_original_text(self, discord_msg_id: str, text: str) -> None: + self._original_texts[discord_msg_id] = text + self._save() + + def get_original_text(self, discord_msg_id: str) -> str | None: + return self._original_texts.get(discord_msg_id) + + def get_discord_id_by_qq(self, qq_msg_id: str) -> str | None: + return self._qq_to_discord.get(qq_msg_id) diff --git a/src/bridge/orchestrator.py b/src/bridge/orchestrator.py index 886bb5d..3ac94e6 100644 --- a/src/bridge/orchestrator.py +++ b/src/bridge/orchestrator.py @@ -30,6 +30,7 @@ MENTION_TEXT_RE = re.compile(r"@([^\s]+)") BIND_COMMAND_RE = re.compile(r"^/bind\s+") +SOURCE_COMMAND_RE = re.compile(r"^/source\s*$", re.IGNORECASE) if TYPE_CHECKING: from adapters.base import MessageEvent, PlatformAdapter @@ -106,6 +107,8 @@ def _normalize_text(text: str) -> str: return ''.join(normalized.split()).casefold() +DISCORD_MAX_LENGTH = 2000 + _MD5_HEX_RE = re.compile(r'^([0-9a-fA-F]{32})') @@ -206,6 +209,28 @@ async def handle_qq_message(self, event: MessageEvent) -> None: has_bot_reply = True reply_to = resolved + # 检测 /source 命令:回复已转发的消息时查看原文 + if has_bot_reply and reply_to is not None: + text = "".join( + seg.data.get("text", "") + for seg in segments if seg.type == SEGMENT_TEXT + ).strip() + if SOURCE_COMMAND_RE.match(text): + original = self._message_store.get_original_text(reply_to) + if original: + await self.qq_adapter.send_message( + self._qq_group_id, + [text_segment(original)], + reply_to=reply_msg_id, + ) + else: + await self.qq_adapter.send_message( + self._qq_group_id, + [text_segment("没有找到原文")], + reply_to=reply_msg_id, + ) + return + if not has_bot_mention and not has_bot_reply: if self._debug: print(f"[DEBUG] 跳过 QQ 消息 | {author_name}: 未@bot 且未回复 bot 消息", flush=True) @@ -332,17 +357,24 @@ async def handle_qq_message(self, event: MessageEvent) -> None: translated = None if translated is not None: - text = f"`{author_name}`: {translated}" - if original_text: - text += "\n-# └─ " + original_text.replace("\n", "\n-# ") - # 过滤掉文本段(已被翻译替代),保留非文本段(图片、贴纸等) + prefix = f"`{author_name}`: " non_text_segments = [seg for seg in converted if seg.type != SEGMENT_TEXT] - segments_to_send = [text_segment(text)] + non_text_segments + if original_text and len(original_text) > 200: + orig_text = prefix + original_text + segments_to_send = [text_segment(prefix + translated)] + non_text_segments + else: + orig_text = None + text = prefix + translated + if original_text: + text += "\n-# └─ " + original_text.replace("\n", "\n-# ") + segments_to_send = [text_segment(text)] + non_text_segments else: # 翻译失败或与原文相同,走原文转发路径 + orig_text = None prefix = text_segment(f"`{author_name}`: ") segments_to_send = [prefix] + converted else: + orig_text = None prefix = text_segment(f"`{author_name}`: ") segments_to_send = [prefix] + converted @@ -350,6 +382,7 @@ async def handle_qq_message(self, event: MessageEvent) -> None: self._discord_channel_id, segments_to_send, reply_to=reply_to, + original_text=orig_text, ) if msg_id is None: if self._debug: @@ -437,8 +470,18 @@ async def handle_discord_message(self, event: MessageEvent) -> None: translated = None if translated is not None: - new_prefix = text_segment(f"{author_name}:{translated}\n└─ ") - segments_to_send = [new_prefix] + converted + if len(original_text) > 200: + self._message_store.store_original_text( + event.message_id, original_text, + ) + non_text_segments = [ + seg for seg in converted if seg.type != SEGMENT_TEXT + ] + hint = text_segment("\n\n[原文过长,回复 /source 查看原文]") + segments_to_send = [text_segment(f"{author_name}:{translated}"), hint] + non_text_segments + else: + new_prefix = text_segment(f"{author_name}:{translated}\n└─ ") + segments_to_send = [new_prefix] + converted else: if self._debug: print(f"[DEBUG] 翻译失败,仅发送原文", flush=True) @@ -472,7 +515,12 @@ async def _on_translation_complete(self, pending: PendingTranslation) -> None: # └─ Chinese original text text = f"`{pending.author_name}`: {pending.translated_text}" if pending.original_text: - text += "\n-# └─ " + pending.original_text.replace("\n", "\n-# ") + original = pending.original_text.replace("\n", "\n-# ") + # 编辑时无法分页,截断以适配 Discord 限制 + max_original = DISCORD_MAX_LENGTH - len(text) - 20 + if len(original) > max_original: + original = original[:max_original] + "\n-# …(截断)" + text += "\n-# └─ " + original # 仅编辑文本,图片已在原始消息中以附件形式发送 new_segments = [text_segment(text)] diff --git a/src/ui/__init__.py b/src/ui/__init__.py new file mode 100644 index 0000000..50ff50e --- /dev/null +++ b/src/ui/__init__.py @@ -0,0 +1,3 @@ +from ui.paginator import ToggleOriginalView + +__all__ = ["ToggleOriginalView"] diff --git a/src/ui/paginator.py b/src/ui/paginator.py new file mode 100644 index 0000000..b072967 --- /dev/null +++ b/src/ui/paginator.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import discord + + +class ToggleOriginalView(discord.ui.View): + def __init__(self, original: str, translated: str, ephemeral: bool = False) -> None: + super().__init__(timeout=300) + self.original = original + self.translated = translated + self.ephemeral = ephemeral + if ephemeral: + self.toggle_btn.label = "Close" + + @discord.ui.button(label="View Original", style=discord.ButtonStyle.secondary) + async def toggle_btn(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: + if self.ephemeral: + await interaction.response.defer() + await interaction.delete_original_response() + else: + ephem_view = ToggleOriginalView(self.original, self.translated, ephemeral=True) + await interaction.response.send_message(content=self.original, ephemeral=True, view=ephem_view)