Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion src/adapters/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
text_segment,
)
from adapters.base import MessageEvent, PlatformAdapter
from ui.paginator import ToggleOriginalView

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion src/bridge/message_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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,
),
Expand All @@ -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)
64 changes: 56 additions & 8 deletions src/bridge/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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})')


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -332,24 +357,32 @@ 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

msg_id = await self.discord_adapter.send_message(
self._discord_channel_id,
segments_to_send,
reply_to=reply_to,
original_text=orig_text,
)
if msg_id is None:
if self._debug:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)]

Expand Down
3 changes: 3 additions & 0 deletions src/ui/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from ui.paginator import ToggleOriginalView

__all__ = ["ToggleOriginalView"]
22 changes: 22 additions & 0 deletions src/ui/paginator.py
Original file line number Diff line number Diff line change
@@ -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)