From 0974cf22622251507fac6049c1c95ae1f1ffde11 Mon Sep 17 00:00:00 2001 From: WhiteFeather127 Date: Fri, 12 Jun 2026 22:53:16 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E7=BB=91=E5=AE=9A=E7=94=A8?= =?UTF-8?q?=E6=88=B7@=E6=8F=90=E5=8F=8A=E9=9D=99=E9=BB=98=EF=BC=8C?= =?UTF-8?q?=E9=9D=9E=E7=BB=91=E5=AE=9A=E7=94=A8=E6=88=B7@=E6=8F=90?= =?UTF-8?q?=E5=8F=8A=E6=AD=A3=E5=B8=B8=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/bridge/orchestrator.py | 59 +++++++++++++++++--------------------- 1 file changed, 26 insertions(+), 33 deletions(-) diff --git a/src/bridge/orchestrator.py b/src/bridge/orchestrator.py index 886bb5d..81ee9f7 100644 --- a/src/bridge/orchestrator.py +++ b/src/bridge/orchestrator.py @@ -923,55 +923,48 @@ def _is_covered(start: int, end: int) -> bool: # ── 逐一解析并构建结果 ── last_end = 0 - for start, end, name, uid, dn, is_full in entries: + for start, end, mention_name, uid, dn, is_full in entries: if start > last_end: result.append(text_segment(text[last_end:start])) - name = match.group(1) - # 优先查绑定:在 source 平台中找 @name 对应的用户,看是否有绑定 - bound_target = self._resolve_text_mention_via_binding(name, source_platform, target_platform) + # 1. 优先查绑定:有绑定 → 静默提及 <@id>(不通知) + bound_target = self._resolve_text_mention_via_binding( + mention_name, source_platform, target_platform, + ) if bound_target is not None: - user_id, display_name = bound_target + bid, bdisplay = bound_target if self._debug: - print(f"[DEBUG] 文本 @{name} -> 绑定匹配 {target_platform} 用户 {display_name} ({user_id})", flush=True) + print(f"[DEBUG] 文本 @{mention_name} -> 绑定匹配 {target_platform} 用户 {bdisplay} ({bid})", flush=True) + if target_platform == "discord": + result.append(text_segment(f"<@{bid}>")) + else: + result.append(at_segment("qq", bid, bdisplay)) + last_end = end + continue + # 2. 无绑定 → 按名称匹配,用 <@!id>(通知提醒) if is_full: - # 全名匹配直接使用缓存中的用户信息 - final_id, final_display = uid, dn # type: ignore[misc] + matched_id, matched_display = uid, dn # type: ignore[misc] if self._debug: - print(f"[DEBUG] 文本 @{name} -> 全名匹配 {target_platform} 用户 {final_display} ({final_id})", flush=True) + print(f"[DEBUG] 文本 @{mention_name} -> 全名匹配 {target_platform} 用户 {matched_display} ({matched_id})", flush=True) else: - # 正则匹配,尝试名称匹配 - matched = self.matcher.match_user(name, target_platform) + matched = self.matcher.match_user(mention_name, target_platform) if matched is not None: - final_id, final_display = matched + matched_id, matched_display = matched if self._debug: - print(f"[DEBUG] 文本 @{name} -> 名称匹配 {target_platform} 用户 {final_display} ({final_id})", flush=True) + print(f"[DEBUG] 文本 @{mention_name} -> 名称匹配 {target_platform} 用户 {matched_display} ({matched_id})", flush=True) else: - final_id, final_display = None, None - if self._debug: - print(f"[DEBUG] 文本 @{name} -> 未在 {target_platform} 中找到匹配", flush=True) + matched_id = matched_display = None - if final_id is not None: + if matched_id is not None: if target_platform == "discord": - result.append(text_segment(f"<@{final_id}>")) + result.append(text_segment(f"<@!{matched_id}>")) else: - result.append(at_segment("qq", final_id, final_display)) + result.append(at_segment("qq", matched_id, matched_display)) else: - # 回退到名称匹配 - matched = self.matcher.match_user(name, target_platform) - if matched is not None: - user_id, display_name = matched - if self._debug: - print(f"[DEBUG] 文本 @{name} -> 名称匹配 {target_platform} 用户 {display_name} ({user_id})", flush=True) - if target_platform == "discord": - result.append(text_segment(f"<@{user_id}>")) - else: - result.append(at_segment("qq", user_id, display_name)) - else: - if self._debug: - print(f"[DEBUG] 文本 @{name} -> 未在 {target_platform} 中找到匹配", flush=True) - result.append(text_segment(f"@{name}")) + if self._debug: + print(f"[DEBUG] 文本 @{mention_name} -> 未在 {target_platform} 中找到匹配", flush=True) + result.append(text_segment(f"@{mention_name}")) last_end = end if last_end < len(text): From 1d70f8a249a984a813f75713aee61d4d336102df Mon Sep 17 00:00:00 2001 From: WhiteFeather127 Date: Fri, 12 Jun 2026 23:09:41 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20=E7=BB=93=E6=9E=84=E5=8C=96@?= =?UTF-8?q?=E6=AE=B5=E8=BD=AC=E6=8D=A2=E4=B9=9F=E8=A1=A5=E4=B8=8A=E9=9D=9E?= =?UTF-8?q?=E7=BB=91=E5=AE=9A=E7=94=A8=E6=88=B7=E7=9A=84=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E6=8F=90=E9=86=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/bridge/matcher.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/bridge/matcher.py b/src/bridge/matcher.py index 64fd166..1cac3f5 100644 --- a/src/bridge/matcher.py +++ b/src/bridge/matcher.py @@ -139,13 +139,16 @@ def register_converter_rules(self, converter: SegmentConverter) -> None: def qq_to_discord(segment: MessageSegment, context: Any) -> MessageSegment | None: user_id = segment.data.get("user_id", "") display = segment.data.get("display", "") - # 优先查绑定 + # 1. 优先查绑定 → 静默提及 <@id>(不通知) result = matcher.resolve_mention("qq", user_id, "discord") if result is not None: target_id, display_name = result return text_segment(f"<@{target_id}>") - # 无绑定 → 原有行为 + # 2. 无绑定 → 在 Discord 缓存中按显示名称查找,找到则用 <@!id>(通知提醒) qq_display_name = matcher._resolve_qq_display_name(user_id) or display or user_id + discord_id = matcher.exact_match(qq_display_name, "discord") + if discord_id: + return text_segment(f"<@!{discord_id}>") return text_segment(f"@{qq_display_name}") def discord_to_qq(segment: MessageSegment, context: Any) -> MessageSegment | None: From 3f6b662e12a7f05582cbec3101e157131eaa7c78 Mon Sep 17 00:00:00 2001 From: WhiteFeather127 Date: Fri, 12 Jun 2026 23:13:03 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=E5=B7=B2=E7=BB=91=E5=AE=9AQQ?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=9A=84=E6=B6=88=E6=81=AF=E4=BD=9C=E8=80=85?= =?UTF-8?q?=E6=94=B9=E4=B8=BA@discord=5Fid=E9=9D=99=E9=BB=98=E6=8F=90?= =?UTF-8?q?=E5=8F=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/bridge/matcher.py | 7 ++-- src/bridge/orchestrator.py | 74 +++++++++++++++++++++++--------------- 2 files changed, 47 insertions(+), 34 deletions(-) diff --git a/src/bridge/matcher.py b/src/bridge/matcher.py index 1cac3f5..64fd166 100644 --- a/src/bridge/matcher.py +++ b/src/bridge/matcher.py @@ -139,16 +139,13 @@ def register_converter_rules(self, converter: SegmentConverter) -> None: def qq_to_discord(segment: MessageSegment, context: Any) -> MessageSegment | None: user_id = segment.data.get("user_id", "") display = segment.data.get("display", "") - # 1. 优先查绑定 → 静默提及 <@id>(不通知) + # 优先查绑定 result = matcher.resolve_mention("qq", user_id, "discord") if result is not None: target_id, display_name = result return text_segment(f"<@{target_id}>") - # 2. 无绑定 → 在 Discord 缓存中按显示名称查找,找到则用 <@!id>(通知提醒) + # 无绑定 → 原有行为 qq_display_name = matcher._resolve_qq_display_name(user_id) or display or user_id - discord_id = matcher.exact_match(qq_display_name, "discord") - if discord_id: - return text_segment(f"<@!{discord_id}>") return text_segment(f"@{qq_display_name}") def discord_to_qq(segment: MessageSegment, context: Any) -> MessageSegment | None: diff --git a/src/bridge/orchestrator.py b/src/bridge/orchestrator.py index 81ee9f7..712d421 100644 --- a/src/bridge/orchestrator.py +++ b/src/bridge/orchestrator.py @@ -178,6 +178,13 @@ async def handle_qq_message(self, event: MessageEvent) -> None: "qq", event.author_id, event.author_name, ) + # 已绑定用户:作者前缀用 <@discord_id> 静默提及格式 + author_prefix: str | None = None + if self._bind_manager is not None: + bound_discord_id = self._bind_manager.get_counterpart("qq", event.author_id) + if bound_discord_id is not None: + author_prefix = f"<@{bound_discord_id}>" + if self._debug: preview = _debug_message_preview(event.segments) print(f"[DEBUG] 收到 QQ 消息 | {event.author_name} ({author_name}): {preview}", flush=True) @@ -332,7 +339,10 @@ async def handle_qq_message(self, event: MessageEvent) -> None: translated = None if translated is not None: - text = f"`{author_name}`: {translated}" + if author_prefix: + text = f"{author_prefix}: {translated}" + else: + text = f"`{author_name}`: {translated}" if original_text: text += "\n-# └─ " + original_text.replace("\n", "\n-# ") # 过滤掉文本段(已被翻译替代),保留非文本段(图片、贴纸等) @@ -340,10 +350,10 @@ async def handle_qq_message(self, event: MessageEvent) -> None: segments_to_send = [text_segment(text)] + non_text_segments else: # 翻译失败或与原文相同,走原文转发路径 - prefix = text_segment(f"`{author_name}`: ") + prefix = text_segment(author_prefix or f"`{author_name}`: ") segments_to_send = [prefix] + converted else: - prefix = text_segment(f"`{author_name}`: ") + prefix = text_segment(author_prefix or f"`{author_name}`: ") segments_to_send = [prefix] + converted msg_id = await self.discord_adapter.send_message( @@ -923,48 +933,54 @@ def _is_covered(start: int, end: int) -> bool: # ── 逐一解析并构建结果 ── last_end = 0 - for start, end, mention_name, uid, dn, is_full in entries: + for start, end, name, uid, dn, is_full in entries: if start > last_end: result.append(text_segment(text[last_end:start])) - # 1. 优先查绑定:有绑定 → 静默提及 <@id>(不通知) - bound_target = self._resolve_text_mention_via_binding( - mention_name, source_platform, target_platform, - ) + # 优先查绑定:在 source 平台中找 @name 对应的用户,看是否有绑定 + bound_target = self._resolve_text_mention_via_binding(name, source_platform, target_platform) if bound_target is not None: - bid, bdisplay = bound_target + user_id, display_name = bound_target if self._debug: - print(f"[DEBUG] 文本 @{mention_name} -> 绑定匹配 {target_platform} 用户 {bdisplay} ({bid})", flush=True) - if target_platform == "discord": - result.append(text_segment(f"<@{bid}>")) - else: - result.append(at_segment("qq", bid, bdisplay)) - last_end = end - continue + print(f"[DEBUG] 文本 @{name} -> 绑定匹配 {target_platform} 用户 {display_name} ({user_id})", flush=True) - # 2. 无绑定 → 按名称匹配,用 <@!id>(通知提醒) if is_full: - matched_id, matched_display = uid, dn # type: ignore[misc] + # 全名匹配直接使用缓存中的用户信息 + final_id, final_display = uid, dn # type: ignore[misc] if self._debug: - print(f"[DEBUG] 文本 @{mention_name} -> 全名匹配 {target_platform} 用户 {matched_display} ({matched_id})", flush=True) + print(f"[DEBUG] 文本 @{name} -> 全名匹配 {target_platform} 用户 {final_display} ({final_id})", flush=True) else: - matched = self.matcher.match_user(mention_name, target_platform) + # 正则匹配,尝试名称匹配 + matched = self.matcher.match_user(name, target_platform) if matched is not None: - matched_id, matched_display = matched + final_id, final_display = matched if self._debug: - print(f"[DEBUG] 文本 @{mention_name} -> 名称匹配 {target_platform} 用户 {matched_display} ({matched_id})", flush=True) + print(f"[DEBUG] 文本 @{name} -> 名称匹配 {target_platform} 用户 {final_display} ({final_id})", flush=True) else: - matched_id = matched_display = None + final_id, final_display = None, None + if self._debug: + print(f"[DEBUG] 文本 @{name} -> 未在 {target_platform} 中找到匹配", flush=True) - if matched_id is not None: + if final_id is not None: if target_platform == "discord": - result.append(text_segment(f"<@!{matched_id}>")) + result.append(text_segment(f"<@{final_id}>")) else: - result.append(at_segment("qq", matched_id, matched_display)) + result.append(at_segment("qq", final_id, final_display)) else: - if self._debug: - print(f"[DEBUG] 文本 @{mention_name} -> 未在 {target_platform} 中找到匹配", flush=True) - result.append(text_segment(f"@{mention_name}")) + # 回退到名称匹配 + matched = self.matcher.match_user(name, target_platform) + if matched is not None: + user_id, display_name = matched + if self._debug: + print(f"[DEBUG] 文本 @{name} -> 名称匹配 {target_platform} 用户 {display_name} ({user_id})", flush=True) + if target_platform == "discord": + result.append(text_segment(f"<@{user_id}>")) + else: + result.append(at_segment("qq", user_id, display_name)) + else: + if self._debug: + print(f"[DEBUG] 文本 @{name} -> 未在 {target_platform} 中找到匹配", flush=True) + result.append(text_segment(f"@{name}")) last_end = end if last_end < len(text): From 660c413d314f753c168789512be598e530cca81f Mon Sep 17 00:00:00 2001 From: WhiteFeather127 Date: Fri, 12 Jun 2026 23:17:37 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20=E5=B7=B2=E7=BB=91=E5=AE=9A=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=94=B9=E4=B8=BA@=E6=98=B5=E7=A7=B0=E7=BA=AF?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E6=A0=BC=E5=BC=8F=EF=BC=8C=E4=B8=8D=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/bridge/orchestrator.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/bridge/orchestrator.py b/src/bridge/orchestrator.py index 712d421..918f8d1 100644 --- a/src/bridge/orchestrator.py +++ b/src/bridge/orchestrator.py @@ -178,12 +178,13 @@ async def handle_qq_message(self, event: MessageEvent) -> None: "qq", event.author_id, event.author_name, ) - # 已绑定用户:作者前缀用 <@discord_id> 静默提及格式 + # 已绑定用户:作者前缀用 @Discord昵称 格式(纯文本,不触发通知) author_prefix: str | None = None if self._bind_manager is not None: bound_discord_id = self._bind_manager.get_counterpart("qq", event.author_id) if bound_discord_id is not None: - author_prefix = f"<@{bound_discord_id}>" + bound_display = self.matcher.get_display_name("discord", bound_discord_id) if self.matcher else None + author_prefix = f"@{bound_display or bound_discord_id}: " if self._debug: preview = _debug_message_preview(event.segments) @@ -340,7 +341,7 @@ async def handle_qq_message(self, event: MessageEvent) -> None: if translated is not None: if author_prefix: - text = f"{author_prefix}: {translated}" + text = f"{author_prefix}{translated}" else: text = f"`{author_name}`: {translated}" if original_text: From aa795cc5b992fb971307d66c635590f96a8cb4e9 Mon Sep 17 00:00:00 2001 From: WhiteFeather127 Date: Fri, 12 Jun 2026 23:31:17 +0800 Subject: [PATCH 5/5] feat: bound QQ author shown as @Discord mention without ping using allowed_mentions --- src/adapters/discord/adapter.py | 3 +++ src/bridge/orchestrator.py | 39 ++++++++++++++++++++++++++++++--- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/adapters/discord/adapter.py b/src/adapters/discord/adapter.py index b9e65c9..12a4e1a 100644 --- a/src/adapters/discord/adapter.py +++ b/src/adapters/discord/adapter.py @@ -199,6 +199,7 @@ async def send_message( channel_id: str, segments: list[Any], reply_to: str | None = None, + allowed_mentions: discord.AllowedMentions | None = None, ) -> str | None: try: channel = self._client.get_channel(int(channel_id)) @@ -238,6 +239,8 @@ async def send_message( kwargs["files"] = files if reply_to is not None: kwargs["reference"] = channel.get_partial_message(int(reply_to)) + if allowed_mentions is not None: + kwargs["allowed_mentions"] = allowed_mentions if not kwargs: return None diff --git a/src/bridge/orchestrator.py b/src/bridge/orchestrator.py index 918f8d1..357dcb4 100644 --- a/src/bridge/orchestrator.py +++ b/src/bridge/orchestrator.py @@ -8,6 +8,8 @@ import unicodedata from typing import TYPE_CHECKING, Any +import discord + from bridge.bind_manager import BindError, BindManager from bridge.message_store import MessageStore @@ -29,6 +31,7 @@ from bridge.verification import VerificationManager MENTION_TEXT_RE = re.compile(r"@([^\s]+)") +MENTION_DISCORD_USER_RE = re.compile(r"<@!?(\d+)>") BIND_COMMAND_RE = re.compile(r"^/bind\s+") if TYPE_CHECKING: @@ -178,13 +181,13 @@ async def handle_qq_message(self, event: MessageEvent) -> None: "qq", event.author_id, event.author_name, ) - # 已绑定用户:作者前缀用 @Discord昵称 格式(纯文本,不触发通知) + # 已绑定用户:作者前缀用 <@discord_id> 格式 + bound_discord_id: str | None = None author_prefix: str | None = None if self._bind_manager is not None: bound_discord_id = self._bind_manager.get_counterpart("qq", event.author_id) if bound_discord_id is not None: - bound_display = self.matcher.get_display_name("discord", bound_discord_id) if self.matcher else None - author_prefix = f"@{bound_display or bound_discord_id}: " + author_prefix = f"<@{bound_discord_id}>: " if self._debug: preview = _debug_message_preview(event.segments) @@ -361,6 +364,7 @@ async def handle_qq_message(self, event: MessageEvent) -> None: self._discord_channel_id, segments_to_send, reply_to=reply_to, + allowed_mentions=self._build_allowed_mentions(segments_to_send, bound_discord_id), ) if msg_id is None: if self._debug: @@ -838,6 +842,35 @@ def _resolve_author_display_name( if bound_name: return bound_name return original_name + + def _build_allowed_mentions( + self, segments: list[MessageSegment], exclude_user_id: str | None, + ) -> discord.AllowedMentions | None: + """从消息段中提取所有 Discord @提及的用户 ID,构建 allowed_mentions. + + 将 exclude_user_id 排除在通知白名单外(用于不通知绑定用户), + 其他被 @的用户正常通知。 + """ + mentioned_ids: set[int] = set() + for seg in segments: + if seg.type == SEGMENT_TEXT: + text = seg.data.get("text", "") + for m in MENTION_DISCORD_USER_RE.finditer(text): + mentioned_ids.add(int(m.group(1))) + elif seg.type == SEGMENT_AT: + uid = seg.data.get("user_id", "") + if uid.isdigit(): + mentioned_ids.add(int(uid)) + + if not mentioned_ids: + return None + + # 从白名单中移除作者(绑定用户不通知) + if exclude_user_id and exclude_user_id.isdigit(): + mentioned_ids.discard(int(exclude_user_id)) + + return discord.AllowedMentions(users=list(mentioned_ids)) + def _build_name_regex(self, target_platform: str) -> re.Pattern | None: """为平台构建一次性匹配所有 display_name 的正则,按名字长度降序。""" if self.matcher is None: