From 9f4121d4f7b0cbbba99ce179384d92cbe380df86 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 23 Oct 2026 18:53:05 -0400 Subject: [PATCH 1/2] fix(discord): declare fields on Discord message components Co-authored-by: Cursor --- .../platform/sources/discord/components.py | 56 +++++++++++++------ .../sources/discord/discord_platform_event.py | 5 +- tests/unit/test_discord_components.py | 52 +++++++++++++++++ 3 files changed, 93 insertions(+), 20 deletions(-) create mode 100644 tests/unit/test_discord_components.py diff --git a/astrbot/core/platform/sources/discord/components.py b/astrbot/core/platform/sources/discord/components.py index 433509f5e1a..871eb6cddee 100644 --- a/astrbot/core/platform/sources/discord/components.py +++ b/astrbot/core/platform/sources/discord/components.py @@ -8,6 +8,14 @@ class DiscordEmbed(BaseMessageComponent): """Discord Embed消息组件""" type: str = "discord_embed" + title: str | None = None + description: str | None = None + color: int | None = None + url: str | None = None + thumbnail: str | None = None + image: str | None = None + footer: str | None = None + fields: list[dict] = [] def __init__( self, @@ -20,14 +28,16 @@ def __init__( footer: str | None = None, fields: list[dict] | None = None, ) -> None: - self.title = title - self.description = description - self.color = color - self.url = url - self.thumbnail = thumbnail - self.image = image - self.footer = footer - self.fields = fields or [] + super().__init__( + title=title, + description=description, + color=color, + url=url, + thumbnail=thumbnail, + image=image, + footer=footer, + fields=fields or [], + ) def to_discord_embed(self) -> discord.Embed: """转换为Discord Embed对象""" @@ -62,6 +72,12 @@ class DiscordButton(BaseMessageComponent): """Discord按钮组件""" type: str = "discord_button" + label: str + custom_id: str | None = None + style: str = "primary" + emoji: str | None = None + url: str | None = None + disabled: bool = False def __init__( self, @@ -72,36 +88,40 @@ def __init__( url: str | None = None, disabled: bool = False, ) -> None: - self.label = label - self.custom_id = custom_id - self.style = style - self.emoji = emoji - self.url = url - self.disabled = disabled + super().__init__( + label=label, + custom_id=custom_id, + style=style, + emoji=emoji, + url=url, + disabled=disabled, + ) class DiscordReference(BaseMessageComponent): """Discord引用组件""" type: str = "discord_reference" + message_id: str + channel_id: str def __init__(self, message_id: str, channel_id: str) -> None: - self.message_id = message_id - self.channel_id = channel_id + super().__init__(message_id=message_id, channel_id=channel_id) class DiscordView(BaseMessageComponent): """Discord视图组件,包含按钮和选择菜单""" type: str = "discord_view" + components: list = [] + timeout: float | None = None def __init__( self, components: list[BaseMessageComponent] | None = None, timeout: float | None = None, ) -> None: - self.components = components or [] - self.timeout = timeout + super().__init__(components=components or [], timeout=timeout) def to_discord_view(self) -> discord.ui.View: """转换为Discord View对象""" diff --git a/astrbot/core/platform/sources/discord/discord_platform_event.py b/astrbot/core/platform/sources/discord/discord_platform_event.py index c64bdc2e01f..b9012c9e51b 100644 --- a/astrbot/core/platform/sources/discord/discord_platform_event.py +++ b/astrbot/core/platform/sources/discord/discord_platform_event.py @@ -2,7 +2,7 @@ from collections.abc import AsyncGenerator from io import BytesIO from pathlib import Path -from typing import cast +from typing import Any, cast from urllib.parse import unquote, urlparse import discord @@ -40,9 +40,10 @@ # 自定义Discord视图组件(兼容旧版本) class DiscordViewComponent(BaseMessageComponent): type: str = "discord_view" + view: Any def __init__(self, view: discord.ui.View) -> None: - self.view = view + super().__init__(view=view) class DiscordPlatformEvent(AstrMessageEvent): diff --git a/tests/unit/test_discord_components.py b/tests/unit/test_discord_components.py new file mode 100644 index 00000000000..f37158521d0 --- /dev/null +++ b/tests/unit/test_discord_components.py @@ -0,0 +1,52 @@ +import discord +import pytest + +from astrbot.core.message.message_event_result import MessageChain +from astrbot.core.platform.sources.discord.components import ( + DiscordButton, + DiscordEmbed, + DiscordReference, + DiscordView, +) +from astrbot.core.platform.sources.discord.discord_platform_event import ( + DiscordViewComponent, +) + + +def test_discord_embed_can_be_constructed_and_converted(): + embed = DiscordEmbed( + title="title", + description="desc", + fields=[{"name": "a", "value": "b", "inline": True}], + ) + chain = MessageChain([embed]) + + result = chain.chain[0].to_discord_embed() + + assert result.title == "title" + assert result.description == "desc" + assert result.fields[0].name == "a" + assert result.fields[0].inline is True + + +def test_discord_reference_keeps_ids(): + ref = DiscordReference(message_id="1", channel_id="2") + assert (ref.message_id, ref.channel_id) == ("1", "2") + + +@pytest.mark.asyncio +async def test_discord_view_builds_buttons(): + view = DiscordView( + components=[ + DiscordButton(label="ok", custom_id="ok-id"), + DiscordButton(label="docs", url="https://example.com"), + ], + timeout=30, + ).to_discord_view() + + assert view.timeout == 30 + assert [item.label for item in view.children] == ["ok", "docs"] + assert view.children[1].style == discord.ButtonStyle.link + + raw_view = discord.ui.View() + assert DiscordViewComponent(raw_view).view is raw_view From 908f0f526aca75e167a2098407244619145182e5 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 26 Oct 2026 13:23:15 -0400 Subject: [PATCH 2/2] fix(discord): validate every supplied view component --- .../platform/sources/discord/components.py | 2 +- tests/unit/test_discord_components.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/astrbot/core/platform/sources/discord/components.py b/astrbot/core/platform/sources/discord/components.py index 871eb6cddee..039900b4b67 100644 --- a/astrbot/core/platform/sources/discord/components.py +++ b/astrbot/core/platform/sources/discord/components.py @@ -113,7 +113,7 @@ class DiscordView(BaseMessageComponent): """Discord视图组件,包含按钮和选择菜单""" type: str = "discord_view" - components: list = [] + components: list[BaseMessageComponent] = [] timeout: float | None = None def __init__( diff --git a/tests/unit/test_discord_components.py b/tests/unit/test_discord_components.py index f37158521d0..a96ae308a54 100644 --- a/tests/unit/test_discord_components.py +++ b/tests/unit/test_discord_components.py @@ -1,5 +1,6 @@ import discord import pytest +from pydantic.v1 import ValidationError from astrbot.core.message.message_event_result import MessageChain from astrbot.core.platform.sources.discord.components import ( @@ -50,3 +51,20 @@ async def test_discord_view_builds_buttons(): raw_view = discord.ui.View() assert DiscordViewComponent(raw_view).view is raw_view + + +@pytest.mark.parametrize("item", [1, "not-a-component", None]) +def test_discord_view_rejects_non_component_items(item): + """Validate each view item before constructing a Discord view. + + Args: + item: A value outside the message-component contract. + """ + with pytest.raises(ValidationError): + DiscordView(components=[item]) + + +def test_discord_view_serialization_preserves_button_fields(): + """Typed view items must retain their concrete component fields.""" + view = DiscordView(components=[DiscordButton(label="ok", custom_id="ok-id")]) + assert view.dict()["components"][0]["label"] == "ok"