Avoid A Void

Slash Nephyの備忘録

GLaDOS-bot プラグインガイド

この記事は Kaigen Discord Advent Calendar 17日目の記事です。

2018年も残り2週間を切りましたね!今日は GLaDOS-botプラグインガイドについて書きたいと思います。
このガイドは HackMD でもご覧いただけます。

実際に動作しているサンプルは GLaDOS-bot-plugins を参照してください。

import jp.nephy.glados.core.plugins.Plugin
import net.dv8tion.jda.core.events.ReadyEvent
import java.util.*
import java.util.concurrent.TimeUnit

object ExamplePlugin: Plugin() {
    override suspend fun onReady(event: ReadyEvent) {
        logger.info { "Discord に接続しました。" }
    }
    
    @Command(description = "Ping Pong")
    suspend fun ping(event: Command.Event) {
        event.embedResult {
            "🏓 Pong!"
        }.await()
    }
    
    @Loop(10, TimeUnit.SECONDS)
    fun everyTenSeconds() {
        println(":)")
    }
    
    @Schedule(multipleHours = [1])
    fun everyHours() {
        val calendar = Calendar.getInstance()
        logger.info { "Current hour is ${calendar.get(Calendar.HOUR)}." }
    }
    
    @Web.Page("/v1/test", "api.example.com")
    suspend fun test(event: Web.AccessEvent) {
        event.call.respondText {
            "Request OK!"
        }
    }
}

以下の例では特記なき場合 import は省略して記述します。

クラス宣言

Plugin クラス

すべてのプラグインPlugin() を継承する必要があります。継承していないクラスはスキップされます。

// OK
object ValidPlugin: Plugin()

// NG
class InvalidPlugin: Map<String, String> {
    // ...
}

プラグインjp.nephy.glados.core.plugins.EventModel, CoroutineScope, Closeable を継承しています。

EventModel

GLaDOS で扱えるイベントハンドラが Empty Body でデフォルト実装されたインターフェイスです。すべてのイベントハンドラは中断関数(suspend fun)です。 利用可能なイベントハンドラEventModel.ktにすべて宣言されています。

override suspend fun onTweetstormStatus(event: StatusEvent) {
    println(status.fullText())
}

CoroutineScope

Kotlin 1.3 で正式版に移行した Coroutine の coroutineContext が実装されています。 Plugin クラス内では launch {}, async {} などの Job ビルダーのスコープが既に定義されているので GlobalScope 等を使用する必要はありません。 Job は GLaDOS の管理する ThreadDispatcher で処理されます。

override suspend fun onReady(event: ReadyEvent) {
    launch {
        delay(10000)
        logger.info { "10 secs!" }
    }
}

Closeable

close() が Empty Body でデフォルト実装されています。必要に応じて override することで Unload 時の挙動を実装できます。(Plugin Unload は未実装です)

object CloseablePlugin: Plugin() {
    private val httpClient = HttpClient()

    override fun close() {
        httpClient.close()
    }
}

visibility

プラグインpublic の可視性で宣言する必要があります。internal, protected, private のクラスはロードされません。

// OK
object PublicPlugin: Plugin()

// NG
private object PrivatePlugin: Plugin()

object v.s. class

プラグインobject で宣言することを推奨します。なぜならプラグインは ロード時にのみインスタンス化され static のような振る舞いをするからです。 object で宣言することで別の プラグイン から容易に参照できるようになります。

object PluginA: Plugin() {
    const val specialString = "AAAAA"
}

object PluginB: Plugin() {
    fun func() {
        val a = PluginA.specialString
        // ...
    }
}

constructor

object 宣言が推奨されることから分かるように プラグインは プライマリ コンストラクタに引数を持たせてはいけません。 GLaDOS はそのようなクラスをロードしません。

// Good
object Plugin1: Plugin()

// OK
class Plugin2: Plugin()

// OK
class Plugin3: Plugin() {
    constructor(param: Double): this()
}

// NG
class Plugin4(param: String): Plugin()

// NG
class Plugin5(param: Int): Plugin() {
    constructor(): this(1)
}

関数宣言

visibility

クラス宣言と同様に GLaDOS のプラグインでロードする関数は 可視性を public で宣言する必要があります。 private 等で宣言した場合, GLaDOS はそのような関数をロードしません。

@Command
suspend fun publicFunction(event: Command.Event) {
    // OK
}

@Command
private suspend fun privateFunction(event: Command.Event) {
    // NG
}

override

GLaDOS のプラグインは引数で対応するイベントハンドラを検索します。そのため override は必須ではありませんが, 楽であるため override することを推奨します。

override suspend fun onGuildMessageReceived(event: GuildMessageReceiveEvent) {
    logger.info { "Overriding: ${event.message.contentDisplay}" }
}

suspend fun guildMessageReceived(event: GuildMessageReceiveEvent) {
    logger.info { "Not overriding: ${event.message.contentDisplay}" }
}

同じ引数を持つ関数を同一 Plugin 内に複数宣言することも可能です。この場合は もう一方を override せずに宣言する必要があります。

suspend

GLaDOS のプラグインの関数は すべて suspend に対応しています。非中断関数でも宣言することはできますが, Kotlin Coroutines による柔軟なコードを書くことができるため suspend 宣言を推奨します。

@Command
suspend fun hello(event: Command.Event) {
}

@Command
fun hello2(event: Command.Event) {
}

inline

GLaDOS のプラグイン関数は 必要に応じて inline 関数化することは可能ですが, 推奨されません。inline は引数のラムダのオーバーヘッドを減らすために使用すべきです。

@Schedule(multipleHours = [1])
inline fun everyHours() {
}

アノテーション

@Event

このアノテーションは必須ではありませんが, 付与することで実行の優先度を指定できます。

必須? 付与対象
No 関数 (GLaDOS のイベントハンドラ)
引数 デフォルト値 説明
priority Plugin.Priority Priority.Normal 実行の優先度
@Event(priority = Priority.Highest)
override suspend fun onGuildMemberAdded(event: GuildMemberAddEvent) {
    // Executes with top-priority
}

@Command

Command.Event を引数にとる関数に付与することで, コマンドとして登録できます。

必須? 付与対象
Yes 関数 (Command.Event のみを引数に持つ)
引数 デフォルト値 説明
priority Plugin.Priority Priority.Normal 実行の優先度を指定
command String 関数名 コマンド名; <コマンド名>でコマンドを実行できるようになります
aliases Array [] コマンド名のエイリアス
priority Plugin.Priority Priority.Normal 実行の優先度
permission Plugin.Command.PermissionPolicy PermissionPolicy.Anyone コマンドを実行できるユーザ
channelType Plugin.Command.TargetChannelType TargetChannelType.Any コマンドを実行できるチャンネルタイプ
case Plugin.Command.CasePolicy CasePolicy.Ignore コマンド名でのCaseの区別
condition Plugin.Command.ConditionPolicy ConditionPolicy.Anytime コマンドを実行できる状況
description String "" コマンドの説明 (helpで使用されます)
args Array [] コマンド引数の説明
checkArgsCount Boolean true 引数の個数が args で指定した個数と等しいかをチェック
prefix String "!" コマンドのプレフィックス
category String "" コマンドのカテゴリ (help で使用されます)
@Command(description = "Responds with hello world.")
suspend fun hello(event: Command.Event) {
    event.reply {
        text {
            append("Hello, World!")
        }
    }.await()
}

@Loop

引数なしの関数に付与することで, 一定間隔ごとに実行する関数を宣言できます。

必須? 付与対象
Yes 関数 (引数なし)
引数 デフォルト値 説明
interval Long 必須 実行の間隔
unit TimeUnit 必須 実行の間隔の単位
priority Plugin.Priority Priority.Normal 実行の優先度
@Loop(10, TimeUnit.SECONDS)
fun everyTenSecs() {
    // Executes every 10 secs
}

@Schedule

引数なしの関数に付与することで, 定期実行する関数を宣言できます。

必須? 付与対象
Yes 関数 (引数なし)
引数 デフォルト値 説明
hours Array 実行する時 (hour)
minutes Array 実行する分 (minute)
multipleHours Array 実行する時 (hour) の公倍数
multipleMinutes Array 実行する分 (minute) の公倍数
priority Plugin.Priority Priority.Normal 実行の優先度
@Schedule(multipleMinutes = [3])
fun everyThreeMinutes() {
    // Executes every 3 minutes (0, 3, 6, 9, ..., 57)
}

@Tweetstorm

@Tweetstorm は Tweetstorm のイベントを受け取る関数を宣言するアノテーションです。関数にのみ付与できます。

@Web.Page

@Web.Page は サーブする Web ページを宣言するアノテーションです。関数にのみ付与できます。

@Web.ErrorPage

@Web.ErrorPage は Web サーバのエラーページを宣言するアノテーションです。関数にのみ付与できます。

@Web.Session

@Web.Session は Web サーバで使用するセッションクラスを宣言するアノテーションです。関数にのみ付与できます。

@Experimental

@Experimental は実験的なプラグインを定義するアノテーションです。クラスにのみ付与できます。 このアノテーションを含むプラグインのコマンドを実行すると ユーザに試験的機能であることについて同意を求めます。

@Testable

--debug オプションを付けて起動した場合にも実行可能なプラグインを定義するアノテーションです。クラスにのみ付与できます。

@TestOnly

--debug オプションを付けて起動した場合にのみ実行可能なプラグインを定義するアノテーションです。クラスにのみ付与できます。

Ktor Client で Cookie を MongoDB に保管する

この記事は Kaigen Discord Advent Calendar 10日目の記事です。

  埋まってなかったので 突然湧いたネタを書きます。

ある GLaDOS-bot プラグインでは ログインが必要なページのスクレイピングのために Ktor の HttpClient#submitForm メソッドでログインセッションを発行しています。  

しかし, このセッションは GLaDOS-bot を再起動するたびに消えてしまいます (Cookie がオンメモリで管理されているため)。そういうわけで, 起動毎に再ログインが必要という状態になっていました。別にこのままでも問題はないのですが, 某xiv では ログイン毎に「新しいログインがありました」メールが来るようになってしまいました。(IPアドレスUAは普段使いの環境と同じなのに一体何で環境を判定してるんだろう)  

そこで今回は MongoDB に Cookie を保存する MongoCookiesSrorage を作ってみることにしました。  

Ktor は Kotlin 1.3 頃から正式リリースされた, 柔軟さ・非同期・Testable が売りのクロスプラットフォーム対応 HTTP Client / Server です。  

github.com

今回はこの Client を使います。Ktor は "Feature" を使って機能拡張をしやすいという特徴があります。  

Cookie を管理する機能も HttpCookies という "Feature" です。次のようにして組み込みます。  

val client = HttpClient {
    install(HttpCookies) {
        storage = AcceptAllCookiesStorage()
    }
}

storageCookie を格納するインスタンスです。今回はこの, Cookie を格納するクラスを実装します。  

  MongoCookiesStorage を実装するにあたり AcceptAllCookiesStorage を参考にしました。CookiesStorage インターフェースはここに宣言されています。

ktor/AcceptAllCookiesStorage.kt at master · ktorio/ktor · GitHub

今回作成した MongoCookiesStorage.kt はこちらです。

GLaDOS-bot-plugins/MongoCookiesStorage.kt at master · NephyProject/GLaDOS-bot-plugins · GitHub

クラスを作る際に気になったのが, AcceptAllCookiesStorage で使われている Cookie.matches(Url)Cookie.fillDefaults(Url) が internal 宣言だったので実装をコピーしました。