🕶️

discord.pyを拡張する、discord-ext-uiを使おう!

2021/12/31に公開

(この記事はQiitaにも投稿しています)

こんにちは、discord botの開発などをやっているsizumita(Discord: すみどら#8931)と申します。
私が開発をしているPycordの拡張であるdiscord-ext-uiの紹介記事です。

discord-ext-uiについて

discord-ext-uiは、SwiftUIのようにdiscordのメッセージを扱えるようなライブラリです。さらにversion 3からSwiftのCombineを模した機能を提供しています。

制作動機

discordにはメッセージにボタンを設置するためのシステムがありますが、discord.pyのボタンの送信の仕組みが私は好きではありませんでした。その頃swiftを触っていたこともあり、SwiftUIのようにViewを記述でき、メッセージの更新も意識することなく実行できればいいなと考えていました。
そこでdiscord-ext-uiを制作し、Viewという単位で綺麗に書けるようにしました。

discord-ext-uiの使い方

Viewを作る

discord-ext-uiの基本単位はViewです。このViewはdiscord.uiのViewとは全く異なります。

from discord.ext.ui import View

view = View()

基本的にこのViewを継承したクラスを作り、そのクラスを使ってプログラムを書いていきます。

from discord.ext.ui import View, Message, Button

class MyView(View):
    async def body() -> Message:
        return Message("Hello, World!").item([Button("hello!")])

このように書くだけでHello, World!と書かれたメッセージにhello!と書かれたボタンが設置されます。
ボタンが押された時の挙動はButtonクラスのon_click関数に関数を渡すことで変更できます。

...
        return Message("Hello, World!").item([
            Button("hello!").on_click(lambda _: print("hello button clicked"))
	])

渡す関数の第一引数は押されたボタンのdiscord.Interactionです。on_clickには普通の関数も子ルーチン関数もどちらも渡すことができます。

Viewを送信する

Viewを送信するためには、ViewTrackerを使います。ViewTracker.track関数にProviderを渡すことで任意の方法でViewを表示することができます。

from discord.ext.ui import ViewTracker, MessageProvider

@client.event
async def on_message(message: discord.Message):
    view = MyView()
    tracker = ViewTracker(view)
    await tracker.track(MessageProvider(message.channel))

MessageProviderはその名の通り通常のメッセージの形で送信するためのProviderです。
Interactionの返信として送信するためのInteractionProviderも存在しています。自分で作りたい場合はBaseProviderを継承してください。

変数が変更された時にViewを更新する

ボタンを押されたときにメッセージを変更したいですよね。そんな時に毎回discord.Message.editを実行するのは辛いと思います。そこで、discord-ext-uiではView内で変数が変更された時に自動的に更新できるようにしました。

from discord.ext.ui import View, Message, Button, state

class MyView(View):
    status: int = state("status")
    def __init__(self):
        super().__init__()
	self.status = 0
    
    def update_status(self, _):
        self.status = 1

    async def body() -> Message:
        if self.status == 0:
	    return Message("Hello, World!").item([
		    Button("nice to meet you!")
		    .on_click(self.update_status)
		])
	return Message("You too!")

status: int = state("status")のように、クラス変数をstate関数を使って定義すると、これがpropertyになります。これによってその変数が変更されたことを検知してViewを更新することができます。
引数はその変数が保存される実体の名前です。これはクラス変数のような見た目をしていますが、実際にはクラス変数ではなくインスタンス変数の挙動をします(@propertyとして定義した関数と同じような挙動をします)。

discord-ext-uiでMVVMをする

discord-ext-uiではSwiftUIのObservableObjectと同じような挙動をするObservableObjectと、@Publishedと同じような挙動をするpublishedがあります。

さらに、SwiftのCombineと同じような挙動をするdiscord.ext.ui.combineモジュールがあります。これらを使って、MVVM的な開発ができます。

ここでは こちらの記事と同じような、Qiitaの記事を一覧表示するプログラムを作ってみましょう。

完成品はGithubにあります。

Modelをつくる

まず、記事のデータの構造体を定義しましょう。ここではjsonを使うためTypedDictを使ってみます。

from typing import TypedDict

class Article(TypedDict):
    id: str
    title: str
    url: str

Protocolをつくる

Qiitaの記事を取得するfetch関数を持っているプロトコルを作成します。

from discord.ext.ui.combine import AsyncPublisher

class ArticleProtocol:
    async def fetch(self) -> AsyncPublisher:
        raise NotImplementedError

AnyPublisherをつくることができていないので、AsyncPublisher(PublisherのAsyncio版)を返しています。

リクエストするクラスを作る

先ほど作ったArticleProtocolに準拠しています。

class ArticleRequest(ArticleProtocol):
    scheme = "https"
    host = "qiita.com"
    base_path = "/api/v2"

    def fetch(self) -> AsyncPublisher:
        return URLRequestPublisher(self.api_components("/items")).json()

    def api_components(self, path: str) -> str:
        return f"{self.scheme}://{self.host}{self.base_path}{path}"

fetch関数ではqiitaのAPIから記事一覧を取ってきて、json関数でdictに変換するところまでが実装されています。

ViewModelをつくる

ここからが本題です。状態を持っているViewModelを作成します。

class ViewModel(ObservableObject):
    articles: list[Article] = published("articles")
    is_loading = published("is_loading")

    def __init__(self):
        super().__init__()
        self.articles = []
        self.is_loading = True
        self._article_request = ArticleRequest()

    async def fetch_articles(self):
        await self._article_request.fetch()\
            .sink(lambda x: self.articles.extend(x))
        self.is_loading = False

articles変数で記事一覧を持ち、is_loading変数で状態を管理しています。fetch_articles関数を実行することで記事一覧をQiita APIから取得しarticles変数に格納しています。

Viewをつくる

記事一覧を表示するViewを作成します。
view_model関数としてViewModelを保持し、bodyでis_loading変数によって出力を切り替えています。

class SampleView(View):
    def __init__(self):
        super().__init__()
        self.view_model = ViewModel()

    async def body(self) -> Message:
        if self.view_model.is_loading:
            return Message("Now loading...")
        if not self.view_model.articles:
            return Message("No results")
        return Message(
            embeds=[
                discord.Embed(
                    title="Qiita articles",
                    description="\n".join(
                        [f'[{x["title"]}]({x["url"]})' for x in self.view_model.articles]
                    )
                )
            ]
        )

    async def on_appear(self) -> None:
        await self.view_model.fetch_articles()

Viewのon_appear関数はViewが最初に表示された時に実行されます(SwiftUIで言う.onAppear)。そこで記事取得のリクエストをしています。

このようにとても綺麗にQiitaの記事取得ができました。ローディング画面を作るのも簡単です。

他のサンプルはこちらにあります: https://github.com/sizumita/discord-ext-ui/tree/master/samples

便利なView

PaginationView

class Page(PageView):
    def __init__(self, content: str):
        super(Page, self).__init__()
        self.content = content

    async def body(self, _paginator: PaginationView) -> Message | View:
        return Message(self.content)

    async def on_appear(self, paginator: PaginationView) -> None:
        print(f"appeared page: {paginator.page}")


@client.event
async def on_message(message: discord.Message):
    if message.content != "!test":
        return

    view = PaginationView([
        Page("The first page -- Morning --"),
        Page("The second page -- Noon --"),
        Page("The third page -- Afternoon --"),
        Page("The forth page -- Evening --"),
        Page("The last page -- Good night! --"),
    ])
    tracker = ViewTracker(view, timeout=None)
    await tracker.track(MessageProvider(message.channel))

このように書くことでペジネーションを簡単に実装できます。

使い方の説明

Alert

alert = Alert("編集を終了しますか?", "", [
    ActionButton("いいえ", discord.ButtonStyle.blurple, value=False),
    ActionButton("はい", discord.ButtonStyle.danger, value=True)
], ephemeral=True)
value: bool = await alert.wait_for_click(interaction)

このように書くだけで、ボタン付きアラートを表示できます。設定したvalueが返り値として帰ってきます。

使い方

最後に

SwiftUI likeとCombine likeな機能により、とても綺麗にdiscordで表示することができました。今後も開発を続けていきたいですが、Combineへの理解が足りなかったり、あまり綺麗に実装ができないことがあるかもしれません。また、テストなども不十分です。ぜひみなさんにもコントリビュートしていただきたいです。

Star, Fork, PR, issue等待ってます!

リポジトリ: https://github.com/sizumita/discord-ext-ui

Discussion