早いものでもう11月ですね。ナナオです。

年末に近づいて忘年会シーズンとなってきました。

ただ忘年会の幹事をするとなると全員の日程調整がめんどくさいじゃないですか。

なかなか日程が合わないということもありますが、日程に関する回答をしてくれない人がいると何も決まりませんよね。

ということで今回は、調整さんで絶対に回答させるline botを作っていこうと思います。

仕様

絶対に回答させるのに効果的な方法ってなんだと思いますか?

丁寧にお願いする?相手を信じて待つ?

いいえ違います、相手が回答するまで鬼電するのです。

結局これが一番効くんですよね。

ということで、line botの趣旨としては「鬼電」です。

たださすがに電話はできないので、メッセージをちょうどうざいくらいの間隔で送るようにします。

期日を過ぎてからの経過時間でどんどんメッセージ送信量が増えていくみたいな感じにします。

  • 期日まで
    • 未回答者に1日に一回催促のDMが送られる
  • 期日経過~1日
    • 未回答者に1時間に一回催促のDMが送られる
  • 期日経過1日~2日
    • 未回答者に30分に一回催促のDMが送られる
  • 期日経過3日~
    • 未回答者に15分に一回催促のDMが送られる

設計

簡単にフローにしてみます。

  1. LINEグループにbotを入れる
  2. 期日を設定する
  3. 期日を確認(全員回答するまで呼び出し続ける)
    1. 回答していない人を確認
    2. 回答していない人がいる場合、期日に応じた処理(DM送信)を行う

なので、プログラムとしては「期日を設定する」ものと「期日を確認する」ものの二つが必要になります。

呼び出される場面がそれぞれ違います。

  • 期日を設定する
    • line botに対して期日を設定するメッセージが送られたとき
  • 期日を確認する
    • 定期的に呼び出す
      • 15分に一回
      • 回答していない人がいる場合、期日からの経過日数によって処理を分ける

期日設定に関してはMessaging APIのWebhookイベントを使用します。

今回はRustアプリを無料でデプロイ可能なクラウドプラットフォームShuttleを利用します!

Shuttle - Build Backends Fast

Shuttleはよくわからなかったので、事前にちょっと触りつつ予習しておきました。

よければこちらの記事もぜひ。

調整さんから回答していない人の取得実装は、参考になるブログを見つけました。

調整さんリマインダLINE BOTを作ってみた - やらなイカ?

どうやら、調整さんのスケジュールIDが分かれば日にち候補をCSV形式でダウンロードができるようです。

(APIキーとか使わなくてもいいのはセキュリティ的にちょっと気になるけど)

これを使って「参加者の誰が回答していないか」を取得し、未回答者がいる場合は適切な制裁を加えていきます。

実装のセットアップ

まずはLine Messaging APIのチャネルを作成しましょう。

LINE Developers

これがline botのベースになります。

ぼくの場合すでに以前プロバイダの作成をしていたので、チャネルの作成だけします。

とりあえず準備ができました。

まずはCargoパッケージを作成します。

やばいLine BOTなので、Mr.ヤバいというプロジェクト名にしますか。

cargo new mr-yabai

作成したら、メインのアプリケーション実装(Messaging APIのwebhook関連の処理)はyabai-app、スケジューラの実装はyabai-schedulerとなるようにワークスペースを実装します。

cd mr-yabai
cargo new yabai-app
cargo new --lib yabai-scheduler

ルートパッケージのCargo.tomlは以下のように設定します。

[workspace.package]
version = "0.1.0"
edition = "2021"

[workspace]
resolver = "2"
members = [
    "yabai-app",
    "yabai-scheduler",
]

メインアプリケーションの実装

ではメインの実装から行います。

cd yabai-app

とりあえず必要なライブラリをインストールします。

[dependencies]
axum = "0.6.20"
shuttle-axum = "0.31.0"
shuttle-runtime = "0.31.0"

最低限の実装だけしてみて、動作確認します。

use axum::{routing::get, Router};

async fn hello_world() -> &'static str {
    "Hello world!"
}

#[shuttle_runtime::main]
async fn main() -> shuttle_axum::ShuttleAxum {
    let router = Router::new().route("/", get(hello_world));
    Ok(router.into())
}

この状態で一度動かしてみましょう。

cargo shuttle run

はい、これでとりあえずHello worldは表示されるようになりました。

ではまず、Messaging APIのwebhookが送ってくるJSONを受け取れるようにしましょう。

POST /webhookエンドポイントでJSONを受け取れるように、先ほどのコードを改修しましょう。

use axum::{routing::post, Router};

async fn webhook(
    axum::extract::Json(data): axum::extract::Json<serde_json::Value>
) {
    println!("request json: {:?}", data);
}

#[shuttle_runtime::main]
async fn main() -> shuttle_axum::ShuttleAxum {
    let router = Router::new().route("/webhook", post(webhook));
    Ok(router.into())
}

この状態で起動し、任意のHTTPクライアントでリクエストしてみます。

私はPostManを使用しました。

ログを見るとJSONの内容が出力されていることが確認できました。

2023-11-05T23:00:01.127+09:00 [Runtime] Starting on 127.0.0.1:8000
2023-11-05T23:00:36.547+09:00 [Runtime] request json: Object {"a": Number(0), "b": Number(1)}

ではこのAPIをとりあえずngrokで公開してみましょう。

ngrokはローカルで起動したHTTPサーバーを簡単に外部URLとして公開することができるサービスです。(以前紹介した記事はこちら

ngrok http 8000

作成された外部URLを、LineのMessaging APIの設定ページにあるWebHook URLに設定します。

POST /webhookエンドポイントにリクエストするようにしてほしいので、以下のように設定します。

[ngrokで作成された外部URL]/webhook

設定したら検証をクリックします。

「成功」と表示されれば疎通できています!

さて、ではテスト用にLINEでグループを作っておきましょう。

自分一人だけのグループをとりあえず作成します。

作成したグループにLineBotを追加します。

勝手に退出しちゃってる

追加した瞬間に退出してしまいました。

調べてみたらどうやら設定を有効にしておく必要があるようです。

LINEのチャットbotをグループに参加させて特定キーワードのみ反応させる #GAS - Qiita

設定を有効にしておきます。

再度入室させてみます。

入室させられました!

あと設定し忘れてたのですが、webhookの利用も可能なようにしておきます。

これで先ほど作成したグループにメッセージを送信すると、ngrokを通してローカルのサーバーにリクエストが送られてくるはずです。

試してみます。

デフォルトのメッセージが返却されています。

ローカルHTTPサーバーの様子はというと…

2023-11-05T23:26:32.273+09:00 [Runtime] request json: Object {"destination": String("U16defd7b9f60ec750a966287e830640b"), "events": Array [Object {"deliveryContext": Object {"isRedelivery": Bool(false)}, "mode": String("active"), "replyToken": String("b2aaf2602a0840cbb3eb27b2772e950d"), "source": Object {"groupId": String("C480b2f8b56ecaf62c2033867e2ff78b2"), "type": String("group")}, "timestamp": Number(1699194392387), "type": String("join"), "webhookEventId": String("01HEFY1KWEEQ7CMHD328H21407")}]}
2023-11-05T23:32:12.437+09:00 [Runtime] request json: Object {"destination": String("U16defd7b9f60ec750a966287e830640b"), "events": Array [Object {"deliveryContext": Object {"isRedelivery": Bool(false)}, "message": Object {"id": String("480438108740125014"), "quoteToken": String("LKEsuhnMrbpIym8wvh5pIfi3eWQEam1fnQts1BEv0QNRcQuXFcgAlv2gpVuG1LirCZxVhqynWak5-nwv5Sr-LLUd1lIUYKpdXW9OjpARW2d-uHPL2SmMdjIF4isquzNDRR0qBrUZEqU0MX9XtT1VOg"), "text": String("テストメッセージ"), "type": String("text")}, "mode": String("active"), "replyToken": String("bc670c3d47aa4f9aa81978a1ececa35e"), "source": Object {"groupId": String("C480b2f8b56ecaf62c2033867e2ff78b2"), "type": String("group"), "userId": String("U8dad2c9888362dda310ca880e101ce26")}, "timestamp": Number(1699194732124), "type": String("message"), "webhookEventId": String("01HEFYC02ME8NS2RNCZCR6QMCZ")}]}

グループ追加時とメッセージ送信時の二回webhookにリクエストが来ました!

まとめ

とりあえず今日はこんなもんにしておきます。

ngrok、初めてwebhookのテストとして使ってみましたが、やはりめちゃくちゃ便利ですね。

次回はwebhookをハンドリングして期日設定を行えるようにしていこうと思います。

ではまた。