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
56 changes: 38 additions & 18 deletions astrbot/core/platform/sources/discord/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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对象"""
Expand Down Expand Up @@ -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,
Expand All @@ -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对象"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
70 changes: 70 additions & 0 deletions tests/unit/test_discord_components.py
Original file line number Diff line number Diff line change
@@ -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"