League of Legendのレートを調べるためのDiscord BotをRustで作ってみた

## 自己紹介
- 牛島涼介
- ユニークビジョン株式会社所属
- 趣味：ゲーム（League of Legend、ボドゲ、TRPG）

---
## あらすじ
最近、[RustでDiscordBotを簡単に動かす記事](https://qiita.com/Cowsisland/items/c0d1c931fd3b92a3a6a1)を書きました。せっかくなので、DiscordBot使ってこんな感じのものを作ったよという意味で紹介します。
今回は、私が趣味でやっているLeague of LegendというゲームのAPIを使ってLeagueBotなるものを作りました。
LeagueofLegend(以下LoL)はRIOT社が提供しているゲームで、5vs5で戦う相手の陣地を制圧することを目的とする通称MOBAと呼ばれる種類のゲームです。プレイヤーはチャンピオンと呼ばれるキャラクターを1体選択し、ゲームに臨みます。
したがって、ゲームを掌握するためには、自信、味方、敵の「プレイヤーの実力」と「キャラクターの習熟度」が重要になります。

というわけで、今回はその二つを取得するBOTをRustで作成しました。

---
## 仕様とAPI
今回は、DiscordBotと連携するということで、以下のコマンドを元にデータを取得するようにします

- Discordのチャット欄に以下二つのコマンドを入力することでそれぞれデータを取得
    - mastery:{サモナー名(プレイヤー名)}　→　キャラクター習熟度TOP5
    - rate:{サモナー名(プレイヤー名)}　→　プレイヤーのレート
- Rust言語を使用し、Discordとの連携は[cerenity](https://github.com/serenity-rs/serenity)を使用

次に使用するAPIですが、RIOT社は、アカウントさえあれば、Tokenを取得できて自由に扱えるAPIを提供してくれています。（[RiotDeveloper](https://developer.riotgames.com/)）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1770714/5b064421-c289-015b-af0b-ea5535d1affe.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1770714/8884eb09-1673-011a-98b4-759d0ac06f8b.png)
こんな感じでログインしているとDEVELOPMENTKEYが発行できます。
このKEYは1日で切れてしまうので、都度発行していきましょう。
個人用のDEVELOPMENTKEYを取得する場合、成果物の提出を行う必要があります。
私はこのDiscordBotを概要付きで提出し、OKが出て、個人用のDEVELOPMENTKEYを発行していただきました。

さて、API一覧に移りましょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1770714/dff3dafd-37b1-db0f-ee83-e4d98bca790c.png)

開発ページのAPIから一覧を見ることができます。
このパスに対してリクエストを投げることで、必要なパラメータを取得することができます。
こちらから必要なAPIを選定します。

今回使用するのは以下になります。

- Summoner V4
    - プレイヤーの情報を取得
    - path: /lol/summoner/v4/summoners/by-name/{summonerName}
- LEAGUE V4
    - プレイヤーの現在のランクを取得
    - path: /lol/league/v4/entries/by-summoner/{encryptedSummonerId}
- CHAMPION-MASTERY-V4
    - プレイヤーのチャンピオンの習熟度TOP5を取得
    - path: /lol/champion-mastery/v4/champion-masteries/by-summoner/{encryptedSummonerId}

---
## APIを叩く部分の実装
実装ですが、このAPIはレスポンスがJSONになっています。RustはJSONをデシリアライズする時に事前に構造体を用意する必要があります。

```rust:SummonerV4
use serde::Deserialize;
#[derive(Deserialize)]
struct SummonerV4 {
    id: String,
    accountId: String,
    puuid: String,
    name: String,
    profileIconId: i32,
    revisionDate: i64,
    summonerLevel: i64
}
```

```rust:Leaguev4
#[derive(Deserialize)]
struct LeagueV4 {
    leagueId: String,
    queueType: String,
    tier: String,
    rank: String,
    summonerId: String,
    summonerName: String,
    leaguePoints: i32,
    wins: i32,
    losses: i32,
    veteran: bool,
    inactive: bool,
    freshBlood: bool,
    hotStreak: bool
}
```

```rust:ChampionMasteryV4
#[derive(Deserialize)]
struct ChampionMasteryV4 {
    championId: i64,
    championLevel: i32,
    championPoints: i32,
    lastPlayTime: i64,
    championPointsSinceLastLevel: i64,
    championPointsUntilNextLevel: i64,
    chestGranted: bool,
    tokensEarned: i32,
    summonerId: String
}
```
---
ここに、それぞれAPIを叩いてデータを取得する処理を実装します。今回は、構造体の初期化と[reqwest](https://docs.rs/reqwest/latest/reqwest/)を用いてAPIを叩いた結果の取得を行います。
処理は構造体にimplを用いて実装を行います。
必要なクエリパラメータとして、"api_key"がありますが、こちらは環境変数に保存しています。

```rust:SummonerV4
#[derive(Deserialize, Debug)]
struct SummonerV4 {
    id: String,
    accountId: String,
    puuid: String,
    name: String,
    profileIconId: i32,
    revisionDate: i64,
    summonerLevel: i64
}

impl Default for SummonerV4 {
    fn default() -> Self {
        Self {
            id: String::default(),
            accountId: String::default(),
            puuid: String::default(),
            name: String::default(),
            profileIconId: i32::default(),
            revisionDate: i64::default(),
            summonerLevel: i64::default(),
        }
    }
}

impl SummonerV4 {
    fn fetch_summoner_v4(region: &str, name: &str, api_key: &str) -> Result<Self, Error> {
        let url = format! (
            "https://{}1.api.riotgames.com/lol/summoner/v4/summoners/by-name/{}?api_key={}",
            region,
            name,
            api_key
        );

        let resp: Self = reqwest::blocking::get(&url)?.json()?;

        Ok(resp)
    }
}
```

```rust:LeagueV4
#[derive(Deserialize, Debug)]
struct LeagueV4 {
    leagueId: String,
    queueType: String,
    tier: String,
    rank: String,
    summonerId: String,
    summonerName: String,
    leaguePoints: i32,
    wins: i32,
    losses: i32,
    veteran: bool,
    inactive: bool,
    freshBlood: bool,
    hotStreak: bool
}

impl Default for LeagueV4 {
    fn default() -> Self {
        Self {
            leagueId: String::default(),
            queueType: String::default(),
            tier: String::default(),
            rank: String::default(),
            summonerId: String::default(),
            summonerName: String::default(),
            leaguePoints: i32::default(),
            wins: i32::default(),
            losses: i32::default(),
            veteran: bool::default(),
            inactive: bool::default(),
            freshBlood: bool::default(),
            hotStreak: bool::default()
        }
    }
}

impl LeagueV4 {
    fn fetch_league_v4(region: &str, id: &str, api_key: &str) -> Result<Vec<Self>, Error> {
        let url = format!("https://{}1.api.riotgames.com/lol/league/v4/entries/by-summoner/{}?api_key={}",
            region,
            id,
            api_key
        );
    
        let resp: Vec<Self> = reqwest::blocking::get(&url)?.json()?;
        Ok(resp)
    }
}
```

```rust:ChampionMasteryV4
#[derive(Deserialize, Debug)]
struct ChampionMasteryV4 {
    championId: i64,
    championLevel: i32,
    championPoints: i32,
    lastPlayTime: i64,
    championPointsSinceLastLevel: i64,
    championPointsUntilNextLevel: i64,
    chestGranted: bool,
    tokensEarned: i32,
    summonerId: String
}

impl Default for ChampionMasteryV4 {
    fn default() -> Self {
        Self{
            championId: i64::default(),
            championLevel: i32::default(),
            championPoints: i32::default(),
            lastPlayTime: i64::default(),
            championPointsSinceLastLevel: i64::default(),
            championPointsUntilNextLevel: i64::default(),
            chestGranted: bool::default(),
            tokensEarned: i32::default(),
            summonerId: String::default(),
        }
    }
}

impl ChampionMasteryV4 {
    fn fetch_champion_mastery_v4(region: &str, summoner_id: &str, api_key: &str) -> Result<Vec<Self>, Error> {
        let url = format! (
            "https://{}1.api.riotgames.com/lol/champion-mastery/v4/champion-masteries/by-summoner/{}?api_key={}",
            region,
            summoner_id,
            api_key
        );

        let resp: Vec<Self> = reqwest::blocking::get(&url)?.json()?;

        Ok(resp)
    }
}
```

これらの構造体、関数を使って、出力したいデータを作成します。
それぞれ、以下のようにしました。

```rust:習熟度の取得
fn resp_mastery(sn: &str) -> Result<Vec<String>, Error> {

    // to use env
    dotenv().ok();

    // Check Riot API
    let api_key = &env::var("RIOT_API_KEY").expect("Please setting RIOT_API_KEY");
    let champ_id_map = get_champ_id_map();
    let name = sn;
    let region = "jp";

    // Get data
    let summoner_v4_resp = SummonerV4::fetch_summoner_v4(region, name, api_key)?;
    let champion_mastery_v4_resp = ChampionMasteryV4::fetch_champion_mastery_v4(region, &summoner_v4_resp.id, api_key)?;

    let loop_num: usize;
    if champion_mastery_v4_resp.len() < 5 {
        loop_num = champion_mastery_v4_resp.len();
    } else {
        loop_num = 5;
    }

    // Generate output
    let mut data_vec = Vec::new();
    for i in 0..loop_num {
        let data_str = format!("{: <4} {: <12} {: <8} ({})",
            (i+1).to_string() +")",
            champ_id_map.get(&champion_mastery_v4_resp[i].championId).unwrap(),
            champion_mastery_v4_resp[i].championPoints,
            champion_mastery_v4_resp[i].championLevel
        );
        data_vec.push(data_str);
    }

    Ok(data_vec)
}
```

```rust:プレイヤーのランクの取得
fn resp_league(sn: &str) -> Result<Vec<String>, Error> {

    // to use env
    dotenv().ok();

    // Check Riot API
    let api_key = &env::var("RIOT_API_KEY").expect("Please setting RIOT_API_KEY");
    let name = sn;
    let region = "jp";

    // Get data
    let summoner_v4_resp = SummonerV4::fetch_summoner_v4(region, name, api_key)?;
    let league_v4_resp_vec = LeagueV4::fetch_league_v4(region, &summoner_v4_resp.id, api_key)?;

    // Generate header
    let mut data_vec = Vec::new();
    data_vec.push(
        format!("{}    {}  {}    {}    {}",
            "Queue", "Tier", "Division", "LP", "Win Rate"
        )    
    );

    // Generate output
    for league_v4_resp in league_v4_resp_vec {
        let win_rate: i32 = league_v4_resp.wins*100 / (league_v4_resp.wins+league_v4_resp.losses);
        let data_str = format!("{}    {}  {}    {}LP    {}%",
            league_v4_resp.queueType,
            league_v4_resp.tier,
            league_v4_resp.rank,
            league_v4_resp.leaguePoints,
            win_rate
        );
        data_vec.push(data_str);
    }

    Ok(data_vec)
}
```

---
## ディスコード部分の実装
最後に、以前記事にした部分で、出力した結果をメッセージとしてチャットに表示します。
なので、ボットとしてのトリガーとなる文字列を指定します。
":"を区切りにして後の文字列を検索します。

```rust:チャットボットの設定部分
    fn message(&self, ctx: Context, msg: Message) {
        if msg.content.starts_with("mastery:") {
            let sn: Vec<&str> = msg.content.split(":").collect();
            if sn.len() >= 2 {
                match resp_mastery(sn[1]) {
                    Ok(data_vec) => {
                        let data = data_vec.join("\n");
                        if let Err(why) = msg.channel_id.say(&ctx.http, data) {
                            println!("Error sending message: {:?}", why);
                        }
                    },
                    Err(err) => {
                        let err_message = "サモナーが見つかりませんでした".to_string();
                        if let Err(why) = msg.channel_id.say(&ctx.http, err_message) {
                            println!("Error sending message: {:?}", why);
                        }
                        println!("{}", err);
                    }
                }
            }
        } else if  msg.content.starts_with("rate:") {
            let sn: Vec<&str> = msg.content.split(":").collect();
            if sn.len() >= 2 {
                match resp_sumonner_rate(sn[1]) {
                    Ok(data_vec) => {
                        let data = data_vec.join("\n");
                        if let Err(why) = msg.channel_id.say(&ctx.http, data) {
                            println!("Error sending message: {:?}", why);
                        }
                    },
                    Err(err) => {
                        let err_message = "サモナーが見つかりませんでした".to_string();
                        if let Err(why) = msg.channel_id.say(&ctx.http, err_message) {
                            println!("Error sending message: {:?}", why);
                        }
                        println!("{}", err);
                    }
                }
            }
        }
    }
```

正直ここはMATCHを使ってきれいに書けると思いました。
またリファクタリングは別途行います。

--- 
## 実行結果
```rust
ussy@DESKTOP-91CH228-wsl:~/parse_summoner_on_discord/parse_summoner$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.07s
     Running `target/debug/summoner_parse`
League BOT is connected!
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1770714/235050bd-3d65-5f5f-96a4-caebdb670ba4.png)

---
## まとめ
こんな感じでRIOTが出しているAPIを使ってRustでDiscordBotが作れました。
エラー処理などをきちんとおこなっていないので、ここは今後の課題にしたいと思います。

これを応用すれば、例えば外部サービスのAPIを同様に使用し、データをチャットに流したり、定期的に情報を流したりというようなものが作れます。仕事や遊びでディスコードを触ってみる際に、試してみて下さい。
Rustの開発のきっかけになればと思って書きました。また、書き方が良くない点もあると思うので、ご指摘もお待ちしております。


Leahue of Legendは、今、世界大人気のゲームです。世界大会の優勝賞金は5億円を超え、世界中が熱狂しているといっても過言ではありません。そんな中、なんと今年、日本リーグの代表がベスト16に上る快挙を見せました!!
これを機に、開発と一緒にLeahue of Legendを始めましょう!!!

---
## GITHUB
古いですが、コードを載せています。
現在リファクタリング中です。
https://github.com/Cowsisland/parse_summoner_on_discord