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

にあえん

November 17, 2023

会社で発表したら緊張しすぎて手汗がすごい、ナナオです。

今回でおそらく完成させたい、LineBotのヤバいさんを早速実装していきます。

line_groupテーブルに調整さんのIDを設定できるようにする

まずはマイグレーションを追加します。

sqlx-cliを使用しましょう。

sqlx migrate add add_column_chousei_id

作成されたマイグレーションファイルを実装します。

PostgreSQL: Documentation: 16: ALTER TABLE

調整さんのIDは小文字の英数字32桁からなる文字列なので、そのように定義します。

-- 調整さんのイベントIDを設定できる列を追加
ALTER TABLE "line_group" ADD COLUMN "chousei_id" varchar(32);

この定義だと1グループにつき一つのイベントまでしか設定できませんが、そんなに立て続けに同じグループでイベントの出欠を取ることはないと信じています。

一旦この状態でshuttleを起動して、マイグレーションが問題なく適用されるか確認します。

cargo shuttle run

DBを確認してみます。

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

既存データにchousei_idが追加されています!

続けて、LineGroupのモデルにchousei_idプロパティを追加してあげましょう。

#[derive(Debug, FromRow, Serialize, Deserialize)]
pub struct LineGroup {
    pub id: String,
    pub deadline_date: NaiveDate,
    pub chousei_id: Option<String>,
}

期日設定を行うCommandの実装にLineGroupの初期化処理があるので、ここにchousei_idの初期化も追加しておきます。

impl BotSetDeadlineCommand {
    async fn new(
        pool: Arc<PgPool>,
        group_id: &str, 
        deadline_date: &str,
    ) -> Self {
        let repository = LineGroupRepository::new(&pool);
        let id = group_id.to_string();
        let line_group_option = repository.select(id.clone())
            .await
            .expect("期日設定中のline_groupの取得に失敗しました");
        let deadline_date = NaiveDate::parse_from_str(deadline_date, "%Y-%m-%d")
            .expect("日付型への変換に失敗しました");

        let line_group = match line_group_option {
            Some(mut line_group) => {
                line_group.id = id;
                line_group.deadline_date = deadline_date;

                line_group
            },
            _ => LineGroup {
                id,
                deadline_date,
                chousei_id: None,
            }
        };

        Self {
            pool,
            line_group
        }
    }
}

期日設定によってchousei_idがリセットされないように、line_groupの初期化処理の前に既存のline_groupを取得するようにしました。

(それによってちょっと処理が複雑になってしまいましたが。。)

解決策はいくつかあるんですが、一旦放置で。。。

リポジトリの作成処理と更新処理も修正しておきます。

impl<'a> LineGroupRepository<'a> {
    
    // ...中略...

    pub async fn update(&self, data: &LineGroup) -> Result<(), sqlx::Error> {
        // SQLにchousei_idの参照を追加
        sqlx::query(r"UPDATE line_group SET deadline_date = $2, chousei_id = $3 WHERE id = $1;")
            .bind(&data.id)
            .bind(&data.deadline_date)
            .bind(&data.chousei_id) // chousei_idの参照を追加
            .execute(self.pool)
            .await?;

        Ok(())
    }

    pub async fn insert(&self, data: &LineGroup) -> Result<(), sqlx::Error> {
        // SQLにchousei_idの参照を追加
        sqlx::query(r"INSERT INTO line_group (id, deadline_date, chousei_id) VALUES ($1, $2, $3);")
            .bind(&data.id)
            .bind(&data.deadline_date)
            .bind(&data.chousei_id) // chousei_idの参照を追加
            .execute(self.pool)
            .await?;

        Ok(())
    }
}

ここまで実装して気づいたんですが(遅い)、PgPoolはそもそもスマートポインタを実装していました。

What happen when a pool is cloned? · launchbadge/sqlx · Discussion #917 · GitHub

となるとそもそもRepositoryもPgPoolの参照を持つべきではないので、Arc<PgPool>&PgPoolなどで定義していた箇所をPgPoolに統一しなおしました。

// Repositoryを修正
pub struct LineGroupRepository {
    pool: PgPool
}
// 期日設定のコマンド構造体も修正
pub struct BotSetDeadlineCommand {
    pool: PgPool,
    line_group: LineGroup,
}
// ほかにもいろいろ…

あとはモデル側のdeadline_dateもOptionにしておく必要があるので、修正します。

#[derive(Debug, FromRow, Serialize, Deserialize)]
pub struct LineGroup {
    pub id: String,
    pub deadline_date: Option<NaiveDate>,
    pub chousei_id: Option<String>,
}

マイグレーションも追加しましょう。

sqlx migrate add alter_column_deadline_date
-- deadline_dateのNOT NULL制約を外す
ALTER TABLE "line_group" ALTER COLUMN "deadline_date" DROP NOT NULL;

では、調整さんIDを設定するコマンドを実装しましょう。

pub struct BotSetUrlCommand {
    line_group_repository: LineGroupRepository,
    line_group: LineGroup,
}
impl BotSetUrlCommand {
    async fn new(
        pool: PgPool,
        group_id: &str, 
        chousei_id: &str,
    ) -> Self {
        let line_group_repository = LineGroupRepository::new(pool.clone());
        let id = group_id.to_string();
        let line_group_option = line_group_repository.select(id.clone())
            .await
            .expect("調整さんのURL設定中のline_groupの取得に失敗しました");

        let chousei_id = Some(chousei_id.to_string());

        let line_group = match line_group_option {
            Some(mut line_group) => {
                line_group.chousei_id = chousei_id;
                line_group
            },
            _ => LineGroup {
                id,
                deadline_date: None,
                chousei_id,
            }
        };

        Self {
            line_group_repository,
            line_group
        }
    }
}
#[async_trait]
impl BotCommand for BotSetUrlCommand {
    async fn execute(&self, value: Value) {
        let line_group = self.line_group_repository.select(self.line_group.id.clone())
            .await
            .expect("line_groupの取得時に問題が発生しました");
        if line_group.is_some() {
            self.line_group_repository.update(&self.line_group).await.expect("line_groupの更新に失敗しました");
        } else {
            self.line_group_repository.insert(&self.line_group).await.expect("line_groupの作成に失敗しました");
        }
    }
}

// ...中略...

/// Commandを実行する
pub async fn execute_bot_command(
    pool: PgPool,
    account_name: String,
    value: Value,
) {
    // ...中略...

    // 調整さんのURL設定
    let re = Regex::new(r".*https://chouseisan.com/s\?h=(?<chousei_id>[a-z0-9]{32}).*").unwrap();
    if let Some(m) = re.captures(contents) {
        let chousei_id = m["chousei_id"].trim();
        let command = BotSetUrlCommand::new(pool, group_id, chousei_id).await;

        command.execute(value).await;
        return        
    }
}

ではチャットを送ってみましょう。

このイベントIDが設定されるか確認してみます。

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

設定されました!

調整さんに回答している人数が少ない場合、LINEメッセージを送る実装を追加

最後に、今まで実装したものを組み合わせてバッチの処理を実装します。

impl MultipleService {
    pub async fn new(pool: PgPool) -> Result<MultipleService, shuttle_runtime::Error> {
        let state = AxumState { pool: pool.clone() };
        let router = Router::new()
            .route("/webhook", post(webhook))
            .with_state(state);

        let scheduler = JobScheduler::new().await.unwrap();

        // 15分に一回
        let job = Job::new_async("0 */15 * * * *", move |_uuid, _l| {
            let clone_pool = pool.clone();
            let http_client = HttpClient::new();

            Box::pin(async move {
                let line_group_repository = LineGroupRepository::new(clone_pool);
                let all_line_group = line_group_repository.get_all()
                    .await
                    .expect("全てのline_group取得に失敗しました");
                
                for line_group in all_line_group.iter() {
                    // ilne_group
                    if let Some(chousei_id) = line_group.chousei_id.clone() {
                        let group_count = http_client.count_group_members(line_group.id.clone())
                            .await
                            .expect(format!("line_groupの人数取得に失敗しました。id: {}", line_group.id).as_str())
                            .try_into()
                            .expect("グループカウントをusizeに変換できませんでした");
                        
                        let res = http_client.get_chousei_csv(chousei_id)
                            .await
                            .expect("調整さんのCSV取得に失敗しました");
                        // グループ内の人数より調整さんの回答者数が少ない場合はメッセージをプッシュします
                        if res.member_info_map.len() < group_count {
                            let message = "調整さんに回答してください!".to_string();
                            http_client.push_message(line_group.id.clone(), vec![message])
                                .await
                                .expect("lineのメッセージ送信に失敗しました");
                        }
                    }
                }
            })
        }).unwrap();

        scheduler.add(job).await.unwrap();

        Ok(Self {
            router,
            scheduler,
        })
    }
}

できました。

動作確認のため、調整さんで新しくイベントを作成します。

これをLINEグループに設定して、しばらくすると…

ちゃんと催促されました。

回答したら止まるのかも確認してみます。

回答したら止まりました。

まとめ

長くなりましたが、一旦動くようになりましたね。うれしい。

あとは期日設定よりも遅れている場合はそれに応じて応答時間を変えるような実装を行えば完成です。

それが終わった後も調整さんを自分で実装できればもっと幅広くいろいろできそうなので、まだまだ改修していきますよ!

ではまた。