Rustに入門する〜5日目 Slack APIでステータスを変更する〜


# 初めに

4日目の続きです。

`Slack API`で自分のステータスを変更するものを作ります。

`Slack API`については多少触ったことありますが、
以前は`Legacy tokens`を利用していたので、
これは流石にやめて通常の(?)トークンを利用します。

ちょうど2020/05/05が期限みたいです。
記事書きながらコード書いているときは明日までやんけ！ってここ書いていたんですが、
完成する頃には5/6になって書き直しました。

> This tool will not allow creation of new tokens on May 5th, 2020. Learn more about why you shouldn't use it anymore.
> https://api.slack.com/legacy/custom-integrations/legacy-tokens

# 作るもの

今回は以下の２つの機能を作ります

- 指定したユーザのステータスを変更する
- 指定したユーザのメールアドレスを取得する

１つ目が今回の目的の本体です。
２つ目については最終的な目的であるカレンダーから休みを判定した上でステータスを変更するのに必要なものです。
これは今回以下の２つの前提条件が存在するため使えます。

- 対象のワークスペースに登録されているユーザのメールアドレスはG Suite(もしくはGmail)のメール
- Google Calendarの個人のカレンダーIDはメールアドレスと同じ
    - 少なくとも自分が確認する限りで調べてないので確実かは保証しません

ユーザのリストを取得する機能も実現し全体への処理を行うのも良さそうですが、
今回は必要ないのでユーザIDをコード上で指定した上で単一ユーザへの処理を行います。

# Slack Appを作成してtokenを取得する

ここから「Create an app」をすればAppを作ってKeyとかを取得できます。

https://api.slack.com/apps

1. App名と対象のワークスペースを選択
2. 「Permissions」で必要な権限を選択
3. 「Scopes->User Token Scopes」で必要な権限(後述)を追加
4. 元の画面に戻って「Install your app to your workspace」で自分のワークスペースに追加

アプリを公開するわけではないので`Manage distribution`は不要です。

今回はプロフィールの取得と変更をするための権限が必要で、
そのためにつようなのはusers.profile:write`と`users.profile:read`なのでこれらを付与します。

https://api.slack.com/methods/users.profile.set
https://api.slack.com/methods/users.profile.get

ちなみに4ののちに再度権限を追加する場合、4の手順をもう一回行いインストールし直す必要がありました。
権限増えてるので再確認のために当然のフローですね。

また`AWS Lambda`はIPが可変であるため一旦今回IP制限はかけません(リスクではありますが)。
`VPC Lambda`にして`NAT`を利用すれば固定はできますが今回その予定はありません。


# コードを書く


共通用とプロフィール用の２つのモジュールで構成しています。
共通用は認証ファイルはjson読むだけですが。

今回もとりあえず実装で脳死`unwrap()`しています。

```rust:slack_general.rs
extern crate serde;
use serde::{Deserialize, Serialize};

use std::fs::File;
use std::io::BufReader;

#[derive(Debug, Serialize, Deserialize)]
pub struct SlackAccessToken {
    pub token: Option<String>,
    pub bot_token: Option<String>
}

pub async fn get_access_token<'a>(secret_path: &'a str) -> SlackAccessToken {
    let file = File::open(secret_path).unwrap();
    serde_json::from_reader(BufReader::new(file)).unwrap()
}
```

取得と変更の本体は以下です。
設定を持つ`SlackProfile`については取得したものを一部書き換えてそのまま変更の方に渡す想定で作っています。
jsonの値をNoneにすれば必要なパラメータだけ設定してということができれば汎用性は広がりましたが後の検討事項です。

検証メモ)
ちなみに以下の設定をした場合`status_emoji`は変更されなかったです。
SlackのAPIの仕様をしっかり読んでないので試しにやってみた程度です。

- `status_emoji`を空文字列にして渡す
- `status_emoji`を`Option<T>`にして`None`を渡す。

```rust:slack_profile
extern crate serde;
use serde::{Deserialize, Serialize};
extern crate reqwest;

use crate::slack_general;
use reqwest::header;
use slack_general::SlackAccessToken;

#[derive(Debug, Serialize, Deserialize)]
pub struct SlackProfile {
    pub email: String,
    pub status_text: String,
    pub status_emoji: String,
    pub status_expiration: i32
}

#[derive(Debug, Serialize, Deserialize)]
struct SlackResponse {
    ok: bool,
    error: Option<String>,
    profile: Option<SlackProfile>

}

const GET_URI: &str = "https://slack.com/api/users.profile.get";
const SET_URI: &str = "https://slack.com/api/users.profile.set";


pub async fn get_profile<'a>(token: &'a SlackAccessToken, user_id: &'a str) -> SlackProfile {

    let mut headers = header::HeaderMap::new();
    headers.insert(header::CONTENT_TYPE, header::HeaderValue::from_str("application/x-www-form-urlencoded").unwrap());
    
    let response = reqwest::ClientBuilder::new()
        .default_headers(headers)
        .build().unwrap()
        .get(GET_URI)
        .query(&[
            ("token", token.token.as_ref().unwrap()),
            ("user", &user_id.to_string())
        ])
        .send().await.unwrap().text().await.unwrap();

    get_profile_in_response(&response)
}


pub async fn set_profile<'a>(tokens: &'a SlackAccessToken, profile: SlackProfile,user_id: &'a str) -> SlackProfile {

    let mut headers = header::HeaderMap::new();
    headers.insert(header::CONTENT_TYPE, header::HeaderValue::from_str("application/json; charset=utf-8").unwrap());
    headers.insert(header::HeaderName::from_static("x-slack-user"), header::HeaderValue::from_str(user_id).unwrap());
    headers.insert(header::AUTHORIZATION, header::HeaderValue::from_str(&format!("Bearer {}",tokens.token.as_ref().unwrap())).unwrap());

    let response = reqwest::ClientBuilder::new()
        .default_headers(headers)
        .build().unwrap()
        .post(SET_URI)
        .json(&serde_json::json!({"profile": profile}))
        .send().await.unwrap().text().await.unwrap();


    //println!("{}",response);
    get_profile_in_response(&response)
}

fn get_profile_in_response<'a>(response: &'a str) -> SlackProfile {
    let response_struct: SlackResponse = serde_json::from_str(&response).unwrap();
    if response_struct.ok {
        response_struct.profile.unwrap()
    }else{
        panic!(response_struct.error.unwrap())
    }
}
```

検証用の呼び出し元コードは以下です(一部抜粋)。
SlackのユーザID(xxxxx)はプロフィール内から確認できます。

```rust:main.rs
        let slack_token = slack_general::get_access_token("./slack_secret.json").await;
        let mut profile = slack_profile::get_profile(&slack_token,"xxxxx").await;
        profile.status_emoji = ":thinking_face:".to_string();
        profile.status_text = "thinking".to_string();
        slack_profile::set_profile(&slack_token,profile,"xxxxx").await;
```

無事にステータスが変更できました。
ちなみに`SET_URI`にgetのURL設定しているのに気づかないで半日くらいステータス変わらないでハマってました。
どちらも同じ形のレスポンス返ってくるので気づかなかったです。

ちなみに`json(&hogehoge)`に渡すパラメータは`Serialize`が実装されていれば良いみたいです。
また、これを使うためには`Cargo.toml`の`reqwest`に対して`feature = "json"`が必要でした。

## 地味にハマったところ

### `HTTP/2`のHTTPヘッダーは小文字しか受け付けなくなってた。

`users.profile.set`でJSONを送信する場合かつワークスペースへの割り当てAPIの場合、
`user`の値をPOSTパラメータではなく、`X-Slack-User: user`のようにHTTPヘッダーとして設定する必要があります。

> To send that JSON to users.profile.set, build an HTTP request like this, setting your content type, authorization credentials, and, for workspace tokens only, an X-Slack-User header indicating the user you're acting on behalf of
> https://api.slack.com/methods/users.profile.set

なのでこの通り`header::HeaderName::from_static("X-Slack-User")`として設定すると`panic`が発生します。
以下の記載の誤ったヘッダーとは何だろうということですが、大文字小文字のチェックをしているっぽいです。

> This function panics when the static string is a invalid header.
> https://docs.rs/reqwest/0.10.4/reqwest/header/struct.HeaderName.html

直接RFCを観に行ってないですが、以下の記事曰く`HTTP/2`から小文字のみになったとのことなので、
おそらくそのためのチェックではないかと思います。

> HTTP/1.x⇒HTTP/2　仕様変更で困ったこと (利用暗号の制約・httpヘッダーの小文字化)
> https://qiita.com/developer-kikikaikai/items/4a336420750d7b7d0483#%E3%81%99%E3%81%B9%E3%81%A6%E3%81%AE%E3%83%98%E3%83%83%E3%83%80%E3%83%BC-%E3%83%95%E3%82%A3%E3%83%BC%E3%83%AB%E3%83%89%E3%81%8C%E5%B0%8F%E6%96%87%E5%AD%97%E3%81%AE%E3%81%BF%E3%81%AB%E4%BB%95%E6%A7%98%E5%A4%89%E6%9B%B4

ちなみに`header::HeaderName::from_bytes()`はそんなチェックはなかったので、
どうしても大文字が使いたければこっちを使えばいいのかもしれないです(中のコードは読んでないですが)


### json->Structへの変換はわざわざ構造体を定義しなくてよかった

色々ハマってる時に見つけたのですが、
`serde_json::from_str()`で`Deserialize`する際は受け入れるための構造体が必要と思ってたのですが、
`serde_json::json!()`の場合は文字列下手で書けば構造体を定義しなくてもいい感じに何とかしてくれるみたいです。
今回一か所利用しています。

ちなみに最初`post(SET_URI).json(&profile)`にしててちょっとハマってました。
(`profile`の階層が足りない)


# 終わりに

変なところでハマっていたのもあるのですが、
どちらかといえば別趣味で少し離れていたので投稿に間が開きました。

GW内に完成と思っていたのですがもう少しかかる予定です。
(リファクタリングは別としても)完成までは記事として載せたいと思っています。
一応次回で一旦完了予定です。

次回はここまでの機能の連結+slackのステータスの設定部分のコード等Rustのコード変更部分もありますが、
AWS Lambdaのトリガーの設定など周辺部分もそれなりに増えるかと思います。