diff --git a/astrbot/core/platform/sources/discord/components.py b/astrbot/core/platform/sources/discord/components.py index 433509f5e1a..039900b4b67 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[BaseMessageComponent] = [] + 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..a96ae308a54 --- /dev/null +++ b/tests/unit/test_discord_components.py @@ -0,0 +1,70 @@ +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 ( + 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 + + +@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"