記事一覧ページへ移動

【Discord Bot】serenity-rsで指定した時間にメッセージを送信する

2024-07-21
2024-04-24

最近こんな物を作りました。

Remind4FはRustで開発されたDiscord Bot1です。Serenityを使っています。ツイートにもある通り、event.toml でイベント情報を管理し、env.toml で指定した時間になったら、指定したチャンネルへイベントの日付と残り日数を教えてくれます。

GitHubで公開しています。よければstarしていってください。

さて、「決まった時間にメッセージを送信する」という機能を持つBotは以前にも開発したことがあります。それはTimeTree-NoticeBot-rustです。

TimeTreeのイベントを取得し、朝8時にお知らせしてくれます。名前の通りこちらもRust製ですが、Serenityを使わず、HTTPリクエストを直接組み立ててメッセージを送信しています。

なぜSerenityを使わなかったかというと、「機能が少ないからライブラリを使うまでもない規模だった」というのもありますが、「朝8時にメッセージを送信する」という機能をどうやって実装すればよいか分からなかったからです。

今回作成したRemind4Fでは、Serenityを用いて決まった時間にメッセージを送信する機能を実装しました。ベストプラクティスかどうかはわかりませんが、とりあえず紹介します。

検証環境

クレートについては後で出します。

  • rustc / cargo 1.79.0-nightly (6f06fe908 2024-04-16)
  • Arch Linux linux-zen 6.8.7

やり方

これも reminds4f/src/main.rs を見れば大体わかります。

どうやって実装するかというと、つまり別スレッドで現在時刻を確認すれば良いわけです。Tokioを使ってスレッドを生やし、無限ループ内でchronoを使って現在時刻を確認します。

まずは使用するクレートをCargo.tomlに列挙しておきましょう。いい感じのバージョンを指定してください。

chrono = "0.4"
tokio = { version = "1.10", features = ["full"] }
serenity = "0.12"

次に、EventHandlerのready関数内でtokio::spawnを使います。tokioの詳しい使い方は省略します2。こんな感じに書いてください。

struct Handler;
#[async_trait]
impl EventHandler for Handler {
  // Botの準備が出来たら実行
  async fn ready(&self, ctx: Context, _ready: Ready) {
    // スレッドを生やす
    tokio::spawn(async move {
      // 処理を書いていく
    });
  }
}

そうしたら、無限ループの中で時間を確認します。AM 10:30 (JST) にメッセージを送りたいときは以下のようになります(tokio::spawn 以外を省略しています)。

tokio::spawn(async move {
  let hour = 10;
  let minute = 30;
  // JST (+9h)
  let jst = FixedOffset::east_opt(9 * 3600).unwrap();
  loop {
    // 現在の日時をJST基準で取得する
    let now = Utc::now().with_timezone(&jst);
    // 指定した時間になったら
    if now.time().hour() == hour && now.time().minute() == minute {
      // メッセージを送信する処理を書く
    }
    // 1分待つ
    tokio::time::sleep(tokio::time::Duration::from_millis(1000 * 60)).await;
  }
});

この例だと秒レベルの精度はいらないので、1分ごとにループを回しています。もっと精度が必要なら最後の from_millis に渡す値を適切に変更して、if文を変更したりすれば良いと思います。

メッセージを送信する

メッセージ送信はSerenityがExampleとして用意してくれてそうなのでそっちを見たほうが良いかもしれません。とりあえずここでは、指定したチャンネルにメッセージを送る方法を紹介します。

Discordの設定から「詳細設定」を開き、開発者モードをオンにしてから、任意のチャンネルをクリックすると、「チャンネルIDをコピー」という項目が出てきます。これをクリックすると、数字だけで構成されたチャンネルIDがコピーされます。

ということで、このチャンネルIDを使って以下のようなコードを書けば、メッセージを送信できます。

let channel_id: u64 = /* チャンネルIDをここに入れる */;
ChannelId::new(channel_id).send_message(
  &ctx.http,
  CreateMessage::new().content("ここにメッセージを入力")
).await.unwrap();

実際には、チャンネルIDは環境変数として設定したり、外部ファイルから読み込んだりしたほうが良いと思います。

embedを使いたい場合は examples/e09_create_message_builder が参考になると思います。

概形

ということで、ここまでの作業をまとめると、こんな感じになります(channel_id を宣言する位置をちょっと変えています)。

struct Handler;
#[async_trait]
impl EventHandler for Handler {
  // Botの準備が出来たら実行
  async fn ready(&self, ctx: Context, _ready: Ready) {
    let channel_id: u64 = /* チャンネルIDをここに入れる */;
    // スレッドを生やす
    tokio::spawn(async move {
      let hour = 10;
      let minute = 30;
      // JST (+9h)
      let jst = FixedOffset::east_opt(9 * 3600).unwrap();
      loop {
        // 現在の日時をJST基準で取得する
        let now = Utc::now().with_timezone(&jst);
        // 指定した時間になったら
        if now.time().hour() == hour && now.time().minute() == minute {
          ChannelId::new(channel_id).send_message(
            &ctx.http,
            CreateMessage::new().content("ここにメッセージを入力")
          ).await.unwrap();
        }
        // 1分待つ
        tokio::time::sleep(tokio::time::Duration::from_millis(1000 * 60)).await;
      }
    });
  }
}

あとはmain関数で、この構造体をClient::builderに渡してclientをstartすれば終了です!

#[tokio::main]
async fn main() {
  let bot_token: String = /* ここにトークンを入れる */;
  Client::builder(&bot_token, GatewayIntents::empty())
    .event_handler(Handler)
    .await.unwrap();
  client.start().await.unwrap();
}

おわりに

実は、初期のRemind4Fではこの機能をSerenityを使わずに実装していました。既にTimeTree-NoticeBot-rustで実装した経験があったので楽そうだなと思ったからです。実際楽でした。ただし今回は、これに加えてスラッシュコマンドを使ってみたいと思っていて、そしてライブラリなしでスラッシュコマンドを実装するのは異常に面倒だということが分かったので、諦めました。

そもそもSerenityには examples/e13_parallel_loops というサンプルがあるので、Serenityでも定刻メッセージ送信は実装できそうだなとは思っていました。

まあサンプルどおり cache_ready() の中でtokio::spawnしようとしても動かなかったんですけどね。そもそも cache_ready() が呼ばれませんでした。英語弱者すぎてexamplesに書いてあるコメント3の意味がわかんないよ~。ドキュメントによると、cache_readyはBotが起動した時に(かなり素早く)呼ばれるらしいんですけどね。

とにかく、Serenityで時間に応じてメッセージを送信する方法はどこでも紹介されていなかったはずなので、この記事が役に立てば良いなあと思っています。

ちなみに今回書いたコード実行してないです。動かなかったら教えて下さい。

Footnotes

  1. Appと呼ぶほうが正しいらしい(サーバーのユーザーリストにもAppって書いてるし、Developer PortalでもApplicationと呼ばれているため)

  2. 自分でもあまり理解できていないからです

  3. L33-34, We use the cache_ready event just in case some cache operation is required in whatever use case you have for this.


Comments

Powered by Giscus