Skip to content

Commit dc3c4fe

Browse files
committed
qq combined forward: mine whitelist
- qq -> discord: ???
1 parent 5d07cda commit dc3c4fe

8 files changed

Lines changed: 91 additions & 4 deletions

File tree

docs/drivers/discord.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,4 @@ If the file is absent or an emoji is not found, the token falls back to the Unic
118118

119119
- Bot messages are automatically ignored (webhook echoes are not re-bridged).
120120
- Files are downloaded and re-uploaded via multipart form. If a file exceeds `max_file_size`, its URL is appended to the message text.
121+
- For NapCat/QQ sources, an explicit `@self_id` mention is also converted into a Discord mention of the target bot account when bot identity is available.

docs/drivers/napcat.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,12 @@ Incoming messages are parsed from OneBot 11 segment arrays:
8484

8585
For merged-forward images, clicking the image now opens the bridge-rendered resource (`/asset/...`) in `url` mode, instead of jumping to the original QQ CDN URL. In `base64` mode, the page opens the image via a temporary blob URL without adding a duplicate base64 `href` payload.
8686

87+
For security hardening, merged-forward image embedding now only allows a safe MIME allowlist (JPEG/PNG/GIF/WebP/BMP/AVIF). Unsafe types (for example `text/html` or `image/svg+xml`) are blocked from inline rendering and shown as a placeholder link.
88+
8789
When merged-forward sender UID reliability cannot be confidently verified (including single-sender batches), NextBridge marks that sender as `UID 不可信` in the rendered header.
8890

91+
Even when UID is marked unreliable, the rendered header still displays the QQ number (with the `UID 不可信` tag) for manual verification.
92+
8993
::: info Merged-forward access control
9094
Merged-forward links are plain paths and each page has its own TTL. When the timer runs out, the page stays on screen and switches to an expired state. If persistent storage is enabled, the page can still be opened again after a restart.
9195
:::

docs/zh/drivers/discord.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,4 @@ Discord 驱动器通过 Discord 网关(Bot Token)接收消息,并支持通
118118

119119
- Bot 发送的消息不会被再次桥接(Webhook 回显不会触发事件)。
120120
- 文件会被下载后通过 multipart 表单重新上传。若文件超过 `max_file_size`,其 URL 将以文字形式附加到消息中。
121+
- 对于 NapCat/QQ 来源,若源消息显式 `@self_id`,在可获取目标 Bot 身份时也会转换为 Discord 端对目标 Bot 账号的 mention。

docs/zh/drivers/napcat.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,11 @@ NextBridge 目前只桥接**群消息**,不转发私聊消息。
8383

8484
合并转发中的图片点击行为已调整:`url` 模式下点击图片会打开桥接服务提供的缓存资源地址(`/asset/...`),不再跳转到 QQ 原始 CDN 链接;`base64` 模式下会通过临时 blob URL 打开图片,避免额外复制一份 base64 到 `href`
8585

86+
出于安全考虑,合并转发图片仅允许安全 MIME 白名单(如 JPEG/PNG/GIF/WebP/BMP/AVIF)内嵌渲染;不安全类型(如 `text/html``image/svg+xml`)会被阻止内嵌,仅保留外链占位提示。
87+
8688
当合并转发中的发送者 UID 无法被可靠校验(包括仅有单一发送者 UID 的场景)时,NextBridge 会在渲染头部为该发送者添加 `UID 不可信` 标记。
8789

90+
即使 UID 被标记为不可信,渲染头部仍会展示 QQ 号(并附带 `UID 不可信` 标记),用于人工核对。
8891
::: info 合并转发页面访问控制
8992
合并转发链接为普通路径,并且页面有独立有效期。页面到期后不会立刻跳走,而是会在原页动态切换成“已销毁”。如果启用了持久化存储,页面内容还可以在重启后继续访问。
9093
:::

drivers/discord.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,24 @@ async def send(
395395
text = f"{prefix}\n{text}" if text else prefix
396396

397397
# Handle mentions: replace @Name with <@id>
398-
mentions = kwargs.get("mentions", [])
398+
mentions = list(kwargs.get("mentions", []))
399+
400+
# Fallback conversion for source "@self_id" mentions.
401+
# Bridge passes source mention display names, and we map them to the
402+
# current Discord bot account mention when available.
403+
source_self_mention_names = kwargs.get("source_self_mention_names", [])
404+
if source_self_mention_names and self._client and self._client.user:
405+
bot_id = str(self._client.user.id)
406+
existing_names = {
407+
str(m.get("name", "")).strip() for m in mentions if isinstance(m, dict)
408+
}
409+
for raw_name in source_self_mention_names:
410+
name = str(raw_name).strip()
411+
if not name or name in existing_names:
412+
continue
413+
mentions.append({"id": bot_id, "name": name})
414+
existing_names.add(name)
415+
399416
for m in mentions:
400417
text = text.replace(f"@{m['name']}", f"<@{m['id']}>")
401418

drivers/napcat.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,7 @@ async def _on_group_message(self, event: dict):
537537
message_id=str(event.get("message_id", "")),
538538
reply_parent=reply_id,
539539
mentions=mentions,
540+
source_self_id=self_id,
540541
source_mentioned_self=source_mentioned_self,
541542
time=datetime.datetime.fromtimestamp(time).isoformat() if time else None,
542543
source_proxy=self._media_proxy,
@@ -764,6 +765,19 @@ def _render_forward_face_segment_html(self, seg_data: dict) -> str:
764765
f"alt='{html.escape(alt_text)}' title='cqface:{html.escape(face_id)}'/>"
765766
)
766767

768+
@staticmethod
769+
def _is_safe_forward_image_mime(mime: str) -> bool:
770+
normalized = (mime or "").split(";", 1)[0].strip().lower()
771+
# Block script-capable or non-image payloads from being embedded/served.
772+
return normalized in {
773+
"image/jpeg",
774+
"image/png",
775+
"image/gif",
776+
"image/webp",
777+
"image/bmp",
778+
"image/avif",
779+
}
780+
767781
@staticmethod
768782
def _segment_url(seg_data: dict) -> str:
769783
url = (
@@ -887,9 +901,21 @@ async def _render_forward_image_asset_html(
887901
)
888902

889903
data, mime = result
904+
normalized_mime = (mime or "").split(";", 1)[0].strip().lower()
905+
if not self._is_safe_forward_image_mime(normalized_mime):
906+
safe_url = html.escape(url)
907+
return (
908+
f"<a href='{safe_url}' target='_blank' rel='noopener noreferrer'>"
909+
"<div class='fwd-image-placeholder'>"
910+
"图片 MIME 类型不安全,已阻止内嵌预览"
911+
"</div>"
912+
f"</a>"
913+
)
890914

891915
if self.config.forward_render_image_method == "base64":
892-
data_url = f"data:{mime};base64,{base64.b64encode(data).decode('ascii')}"
916+
data_url = (
917+
f"data:{normalized_mime};base64,{base64.b64encode(data).decode('ascii')}"
918+
)
893919
safe_data_url = html.escape(data_url)
894920
return (
895921
f"<img class='fwd-image fwd-image-open' src='{safe_data_url}' "
@@ -908,7 +934,7 @@ async def _render_forward_image_asset_html(
908934
asset_id=asset_id,
909935
page_id=page_id,
910936
instance_id=self.instance_id,
911-
mime=mime,
937+
mime=normalized_mime,
912938
data=data,
913939
created_at=int(_utc_now().timestamp()),
914940
expires_at=expires_at,
@@ -1398,7 +1424,7 @@ async def _render_forward_nodes_html(
13981424
richheader = self._apply_forward_msg_format_header(
13991425
msg_format=msg_format,
14001426
nickname=nickname,
1401-
user_id=user_id if user_id_reliable else "",
1427+
user_id=user_id,
14021428
msg_text=msg_text,
14031429
)
14041430

@@ -1415,6 +1441,12 @@ async def _render_forward_nodes_html(
14151441
str(richheader.get("content", "")).strip() if richheader else ""
14161442
)
14171443
if user_id and not user_id_reliable and richheader:
1444+
if user_id not in header_content_raw:
1445+
header_content_raw = (
1446+
f"QQ: {user_id} · {header_content_raw}"
1447+
if header_content_raw
1448+
else f"QQ: {user_id}"
1449+
)
14181450
header_content_raw = (
14191451
f"{header_content_raw} · UID 不可信"
14201452
if header_content_raw

services/bridge.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,14 +506,28 @@ async def _dispatch(
506506

507507
# Resolve target mentions
508508
target_mentions = []
509+
source_self_mention_names: list[str] = []
510+
source_self_id = str(getattr(msg, "source_self_id", "") or "")
509511
for m in msg.mentions:
510512
target_uid = msg_db().get_bound_user_id(
511513
msg.instance_id, m["id"], target_id
512514
)
513515
if target_uid:
514516
target_mentions.append({"id": target_uid, "name": m["name"]})
517+
continue
518+
519+
# Fallback: source @self_id (bot) mention can be converted by
520+
# target drivers that know their own bot account id.
521+
m_id = str(m.get("id", "") or "")
522+
m_name = str(m.get("name", "") or "").strip()
523+
if source_self_id and m_id == source_self_id and m_name:
524+
source_self_mention_names.append(m_name)
515525
if target_mentions:
516526
extra_out["mentions"] = target_mentions
527+
if source_self_mention_names:
528+
extra_out["source_self_mention_names"] = list(
529+
dict.fromkeys(source_self_mention_names)
530+
)
517531

518532
try:
519533
new_msg_id = await sender(
@@ -599,14 +613,28 @@ async def _dispatch_connect(
599613

600614
# Resolve target mentions
601615
target_mentions = []
616+
source_self_mention_names: list[str] = []
617+
source_self_id = str(getattr(msg, "source_self_id", "") or "")
602618
for m in msg.mentions:
603619
target_uid = msg_db().get_bound_user_id(
604620
msg.instance_id, m["id"], target_id
605621
)
606622
if target_uid:
607623
target_mentions.append({"id": target_uid, "name": m["name"]})
624+
continue
625+
626+
# Fallback: source @self_id (bot) mention can be converted by
627+
# target drivers that know their own bot account id.
628+
m_id = str(m.get("id", "") or "")
629+
m_name = str(m.get("name", "") or "").strip()
630+
if source_self_id and m_id == source_self_id and m_name:
631+
source_self_mention_names.append(m_name)
608632
if target_mentions:
609633
extra_out["mentions"] = target_mentions
634+
if source_self_mention_names:
635+
extra_out["source_self_mention_names"] = list(
636+
dict.fromkeys(source_self_mention_names)
637+
)
610638

611639
try:
612640
new_msg_id = await sender(

services/message.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class NormalizedMessage:
2929
mentions: list[dict] = field(
3030
default_factory=list
3131
) # list of {"id": str, "name": str}
32+
source_self_id: str = "" # source platform bot account id (if available)
3233
source_mentioned_self: bool | None = (
3334
None # whether source message explicitly @mentioned the source bot account
3435
)

0 commit comments

Comments
 (0)