調整さんで絶対に回答させるline botを作ってみた【3】

にあえん

November 9, 2023

意外とRustってとっつきやすいなと考えを改めました。ナナオです。

今回はメッセージから期日設定などを行えるように実装を修正していきます。

ヤバいさんのコマンドを呼び出す

では具体的にどうやって期日設定を行うかといえば、「グループチャット内」で「矢倍くん宛」にメッセージを送ったときに行うようにしましょう。

また、期日設定以外にも調整さんのURLを設定できる必要があります。あとヘルプコマンドもあると便利ですね。

ということで、コマンドを3つ用意します。

コマンドといえばコマンドパターンですね。

こんな感じのtraitを実装します。

trait BotCommand {
    fn execute(&self, value: Value);
}

これを使って、3つのコマンドを実装します。

struct BotSetDeadlineCommand {
    pool: PgPool,
    line_group: LineGroup,
};
impl BotSetDeadlineCommand {
    /// コマンドのコンストラクタ
    /// DBの更新に必要な値を渡します
    fn new(
        pool: PgPool,
        group_id: &str, 
        deadline_date: &str,
    ) -> Self {
        let line_group = LineGroup {
            id: group_id.to_string(),
            deadline_date: NaiveDate::parse_from_str(deadline_date, "%Y-%m-%d")
                .expect("日付型への変換に失敗しました")
        };

        Self {
            pool,
            line_group
        }
    }
}
impl BotCommand for BotSetDeadlineCommand {
    fn execute(&self, value: Value) {}
}

// それ以外のコマンドも一応用意しておく
struct BotSetUrlCommand;
impl BotCommand for BotSetUrlCommand {
    fn execute(&self, value: Value) {}
}

struct BotHelpCommand;
impl BotCommand for BotHelpCommand {
    fn execute(&self, value: Value) {}
}

まずは期日設定を行えるようにしましょう。

webhookエンドポイントにコマンド実行関数を追加します。

async fn webhook(
    headers: HeaderMap,
    body: String,
) -> (StatusCode, &'static str) {
    if !verify_signature(headers, body.clone()) {
        return (StatusCode::UNAUTHORIZED, "無効なリクエストです。サーバー側と署名が一致しませんでした。")
    }

    let v: Value = serde_json::from_str(body.as_str())
        .expect("JSONのパースに失敗しました");

    // これを追加
    execute_bot_command(v);

    (StatusCode::OK, "OK")
}

execute_bot_commandから先ほど作成したCommandを実行するようにしていきます。

実装はこんな感じです。

/// Commandを実行する
fn execute_bot_command(
    pool: PgPool,
    value: Value,
) {
    let events = value.get("events")
        .expect("リクエストにeventsが存在しません。")
        .as_array()
        .expect("eventsを配列に変換できませんでした");

    if events.len() == 0 {
        // webhookの検証リクエスト
        return
    }

    let group_id = events[0].pointer("/source/groupId")
        .expect("グループIDが存在しません")
        .as_str()
        .expect("グループIDを文字列に変換できませんでした");

    let message = events[0].pointer("/message/text")
        .expect("テキストの取得に失敗しました")
        .as_str()
        .expect("メッセージを文字列に変換できませんでした");

    let re = Regex::new(format!(r"{}(?<contents>.*)", ACCOUNT_NAME).as_str())
        .expect("Regexの初期化に失敗しました");

    let contents = re.captures(message.clone())
        .expect("メッセージにアカウント名が含まれていません");

    let contents = contents["contents"].trim();

    // 期日設定を行う
    let re = Regex::new(r".*(?<deadline>[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}).*").unwrap();
    if let Some(deadline) = re.captures(contents) {
        let deadline = deadline["deadline"].trim();
        let command = BotSetDeadlineCommand::new(
            pool,
            group_id,
            deadline,
        );

        command.execute(value);
        return
    }
}

これで、ヤバいさんを呼び出したうえでメッセージ内に日付が含まれている場合にBotSetDeadlineCommandを呼び出せるようになりました!

BotSetDeadlineCommandの実装

では期日設定の実装を行っていきましょう。

処理としてはline_groupテーブルの作成か更新をするだけでいいです。

line_groupの更新処理だけ実装していなかったので追加しました。

async fn update_line_group(pool: &PgPool, data: LineGroup) -> Result<(), sqlx::Error> {
    sqlx::query(r"UPDATE line_group SET deadline_date = $2 WHERE id = $1;")
        .bind(data.id)
        .bind(data.deadline_date)
        .execute(pool)
        .await?;

    Ok(())
}

あとはline_groupが存在するか確認するため、line_groupの取得関数がOptionを返却するようにしておきます。

async fn select_line_group(pool: &PgPool, id: String) -> Result<Option<LineGroup>, sqlx::Error> {
    let line_group = sqlx::query_as(r"SELECT * FROM line_group WHERE id = $1;")
        .bind(id)
        .fetch_optional(pool) // fetch_one -> fetch_optionalに変更
        .await?;

    Ok(line_group)
}

あとはこのline_groupの作成関数などをexecute_bot_commandから呼び出せばいいんですが、BotCommandに定義した関数を非同期関数として定義してあげる必要があります。

ということで依存関係にasync-traitを追加しておきましょう。

async-trait = "0.1.74"

これを使用して先ほどのコマンドクラスにマクロを適用します。

use async_trait::async_trait;

// ...中略...

#[async_trait]
trait BotCommand {
    async fn execute(&self, value: Value);
}

あと、insert_line_groupupdate_line_groupの引数であるLineGroupは参照でも構わないので、修正しておきます。

async fn update_line_group(pool: &PgPool, data: &LineGroup) -> Result<(), sqlx::Error> {
    sqlx::query(r"UPDATE line_group SET deadline_date = $2 WHERE id = $1;")
        .bind(&data.id) // bindに渡す値を参照渡しにする
        .bind(&data.deadline_date)
        .execute(pool)
        .await?;

    Ok(())
}

async fn insert_line_group(pool: &PgPool, data: &LineGroup) -> Result<(), sqlx::Error> {
    sqlx::query(r"INSERT INTO line_group (id, deadline_date) VALUES ($1, $2);")
        .bind(&data.id) // bindに渡す値を参照渡しにする
        .bind(&data.deadline_date)
        .execute(pool)
        .await?;

    Ok(())
}

ではBotSetDeadlineCommandexecute関数を実装していきましょう。

#[async_trait]
impl BotCommand for BotSetDeadlineCommand {
    async fn execute(&self, _: Value) {
        let line_group = select_line_group(&self.pool, self.line_group.id.clone())
            .await
            .expect("line_groupの取得時に問題が発生しました");
        if line_group.is_some() {
            update_line_group(&self.pool, &self.line_group).await.expect("line_groupの更新に失敗しました");
        } else {
            insert_line_group(&self.pool, &self.line_group).await.expect("line_groupの作成に失敗しました");
        }
    }
}

早速動くか確認してみましょう。

まずはcargo shuttle runでサーバーを起動&ngrokで公開してwebhookを通したあと、DBにデータが登録されていないことを確認してからlineでメッセージを送信します。

うまくいけばこれでレコードが作成されているはずですが、、うまくいっていませんでした。

コードを見返したら、execute関数を実行している部分でawaitしていませんでした。

修正します。

/// Commandを実行する
async fn execute_bot_command( // async関数にする
    pool: PgPool,
    value: Value
) {
    // ...中略...

    // 期日設定を行う
    let re = Regex::new(r".*(?<deadline>[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}).*").unwrap();
    if let Some(deadline) = re.captures(contents) {
        let deadline = deadline["deadline"].trim();
        let command = BotSetDeadlineCommand::new(
            pool,
            group_id,
            deadline,
        );

        command.execute(value).await; // awaitを追加
        return
    }
}

// ...中略...

async fn webhook(
    State(state): State<AxumState>,
    headers: HeaderMap,
    body: String,
) -> (StatusCode, &'static str) {
    if !verify_signature(headers, body.clone()) {
        return (StatusCode::UNAUTHORIZED, "無効なリクエストです。サーバー側と署名が一致しませんでした。")
    }

    let v: Value = serde_json::from_str(body.as_str())
        .expect("JSONのパースに失敗しました");

    execute_bot_command(state.pool, v).await; // ここにもawaitを追加

    (StatusCode::OK, "OK")
}

これで動くはず!

サーバーを再起動して再度チャレンジします。

postgres=# select * from line_group;
                id                 | deadline_date
-----------------------------------+---------------
 C480b2f8b56ecaf62c2033867e2ff78b2 | 2023-11-11

作成されました~~!

更新のほうもテストしておきましょう。

postgres=# select * from line_group;
                id                 | deadline_date
-----------------------------------+---------------
 C480b2f8b56ecaf62c2033867e2ff78b2 | 2023-11-12

問題なく更新されています!

まとめ

これで期日設定をすることができました。

ほかにも実装予定のコマンドはありますが、とりあえず次回は設定された期日まで15分おきにメッセージを送れるようにしてみたいと思います。

それではまた。