どうも、ナナオです。

今回はCSVをパースして、未回答の人が誰かを取得する処理を実装していきます。

CSVパース処理の仕様

とりあえず、調整さん側の動きについて確認するためにイベントを作ってみます。

調整さん - 簡単スケジュール調整、出欠管理ツール

作ったイベントはこんな感じです。

作成したイベントに関連するURLは以下のようになっています。

イベントを作成してすぐにCSVを取得すると、以下のようなフォーマットになっています。

テストイベント

日程
11/15(水) 19:00〜
11/16(木) 19:00〜
11/17(金) 19:00〜
コメント

これに回答すると以下のようになります。

テストイベント

日程,nanao
11/15(水) 19:00〜,◯
11/16(木) 19:00〜,△
11/17(金) 19:00〜,×
コメント,test

回答者が増えると以下のようになります。

テストイベント

日程,nanao,test_1,test_2
11/15(水) 19:00〜,◯,×,△
11/16(木) 19:00〜,△,△,△
11/17(金) 19:00〜,×,◯,△
コメント,test,test_1,test_2

名前からLineのアカウント名を推測するという方法もあるんですが、ここはシンプルに回答者の数とグループ内の人数が合わない場合に通知するという感じにしましょう。

(ここまでやってて思ったんですが、調整さんだとちょっとカスタマイズ性にかけてしまいますね。。今後の改修ポイントかも)

パース処理を実装

CSVをパースするためにまずはcsvモジュールを依存関係に追加します。

csv = "1.3.0"
bytes = "1.5.0" # reqwestからのレスポンスボディをバイトで取得するため

前回、line_clientモジュールという名前で実装しましたが、調整さんへのリクエストも処理させたいのでhttp_clientという名前に変更して調整さんへのリクエスト処理を実装します。

// src/http_client.rs

// ...中略...

// 調整さんのCSV取得エンドポイントのレスポンスを表す構造体
pub struct GetChouseiCsv {
    title: String,
    csv_text: String,
}

// LineClient -> HttpClientに変更
pub struct HttpClient {
    client: Client,
}
impl HttpClient {

    // ...中略...

    // 調整CSVを取得する
    pub async fn get_chousei_csv(&self, event_id: String) -> Result<GetChouseiCsv, reqwest::Error> {
        let url = format!("https://chouseisan.com/schedule/List/createCsv?h={}&charset=utf-8&row=choice", event_id);

        let res_body = self.client.get(url).send().await?.text().await?;

        let lines: Vec<&str> = res_body.lines().collect();

        let title = lines[0];
        let csv_text = lines[2..].join("\n");

        Ok(GetChouseiCsv{ title: title.to_string(), csv_text })
    }
}

実装できました。

ただcsv_textをもう少し詳細にパースしてあげたいですね。

pub struct GetChouseiCsv {
    title: String,
    csv_text: String,
    member_info_map: HashMap<String, MemberInfo>, // 追加
}

// メンバーの出欠状態やコメントを含む構造体を定義
#[derive(Debug)]
pub struct MemberInfo {
    availability_for_candidate_dates: HashMap<String, String>,
    comment: Option<String>,
}

impl HttpClient {

    // ...中略...

    // 調整CSVを取得する
    pub async fn get_chousei_csv(&self, event_id: String) -> Result<GetChouseiCsv, reqwest::Error> {
        let url = format!("https://chouseisan.com/schedule/List/createCsv?h={}&charset=utf-8&row=choice", event_id);

        let res_body = self.client.get(url).send().await?.text().await?;

        let lines: Vec<&str> = res_body.lines().collect();

        let title = lines[0];
        let csv_text = lines[2..].join("\n");

        let csv_bytes = csv_text.as_bytes();
        let mut reader = csv::Reader::from_reader(csv_bytes);

        let headers = reader.headers().unwrap();
        let member_name = headers.iter().map(|x| x.to_string()).collect::<Vec<String>>()[1..].to_vec();

        let mut reader = csv::Reader::from_reader(csv_bytes);

        let mut member_info_map: HashMap<String, MemberInfo> = HashMap::new();
        for record in reader.records() {
            let record = record.unwrap();

            if &record[0] == "コメント" {
                for (i, m) in member_name.iter().enumerate() {
                    let comment = record[i + 1].to_string();
                    if member_info_map.contains_key(m) && !comment.is_empty() {
                        let member_info = member_info_map.get_mut(m)
                            .expect("メンバー情報を格納したマップからの情報取得に失敗しました");
                        member_info.comment = Some(comment);
                    }
                }
            }
            else {
                let day = record[0].to_string();
                for (i, m) in member_name.iter().enumerate() {
                    let status = record[i + 1].to_string();
                    if member_info_map.contains_key(m) {
                        let member_info = member_info_map.get_mut(m)
                            .expect("メンバー情報を格納したマップからの情報取得に失敗しました");
                        let dates = &mut member_info.availability_for_candidate_dates;
                        dates.insert(day.clone(), status);
                    } else {
                        let availability_for_candidate_dates = HashMap::from([
                            (day.clone(), status)
                        ]);
                        member_info_map.insert(m.clone(), MemberInfo { availability_for_candidate_dates, comment: None });
                    }
                }
            }
        }

        Ok(GetChouseiCsv{ title: title.to_string(), csv_text, member_info_map })
    }
}

はい、これで結構ちゃんとパースが実装できました。

グループ内の人数取得を実装

パース処理によって出欠に答えたメンバー数を取得できるようになったので、あとはグループ内に何人いるかを取得できるようにします。

Messaging APIリファレンス | LINE Developers

このエンドポイントではボットを除いたグループ内の人数が何人いるかを教えてくれます。

返却されるオブジェクトもかなり単純なので、関数では数値のみ返却するようにしてあげればよいでしょう。

impl HttpClient {

    // ...中略...
    
    pub async fn count_group_members(&self, id: String) -> Result<i64, reqwest::Error> {
        let res: Value = self.client.get(format!("https://api.line.me/v2/bot/group/{}/members/count", id))
            .bearer_auth("{アクセストークン}")
            .send()
            .await?
            .json()
            .await?;

        let count = res.as_object()
            .expect("グループ人数取得のレスポンスJSONをオブジェクトに変換できませんでした")
            .get("count")
            .expect("グループ人数取得のレスポンスJSONにcountプロパティが定義されていません")
            .as_i64()
            .expect("グループ人数取得のレスポンスJSONのcountプロパティが正しく数値に変換できませんでした");

        Ok(count)
    }
}

実装できました。

まとめ

念願のパース処理が完成しました。

あとは調整さんのイベントIDをline_groupテーブルに設定できるようにしてあげて、通知ができるようにしてあげればいったん完成です。

調整さんを使わずに実装したほうが自由度高そうなので、これが完成したらもうちょっと洗練された方法を考えます。

ではまた。