Twitchのチャンネルが配信を開始したらDiscordへ通知する

# この記事のゴール
- 任意のTwitchチャンネルが配信を開始したことをDiscordのテキストチャンネルへ通知するアプリをつくる :mega: 
- 初期構築・維持含め、タダで実現する :moneybag: 

![goal.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/556115/17398ddb-5ca9-e74f-e751-404febba4357.png)

# 採用したアーキテクチャ
- ハードウェア、サーバ
    - Google Cloud Platform Cloud Functions

- 言語
    - Java (OpenJDK 11)

## なぜ？
- 配信が開始されたときのみ動作すればいいアプリなので、ミニマルなサービスでいいから
- インターネットと通信を行なうアプリをタダで構築したいから（AWS LambdaはElastic IPのぶんコストが発生する）

言語は好みです。Javaを選択してGCPで構築する場合は、ランタイムの制約があるためJava11を使用することになります。

# alternative
- vs. IFTTT

フォローしているチャンネルが配信を開始したら通知してくれるサービスが存在するので、WebhookサービスをThenにして連携すれば同じことができます。無料で使えますが、配信開始から通知までめちゃくちゃ遅延します。（１時間くらい。意味ないじゃん）

# つくる

ソースコードについて、記事内では説明用に修正したものを記載します。実際に稼働するコードはGithubに公開しています。

https://github.com/syake-salmon/twitch-stream-notifier

## STEP-0. 前提となる事前準備

- Twitch Developerコンソールにて、アプリケーションを登録し、**クライアントID**と**秘密鍵**を発行しておく
- GCPダッシュボードにて、プロジェクトを作成しておく
- DiscordのWebhook URLを発行しておく

アプリケーションは **最低１つ、できれば２つ** 実装します。

![無題のプレゼンテーション (3).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/556115/2761cd93-ab8a-d047-79e7-f66d27b991f2.png)

## STEP-1. サブスクライバ

### アプリ化しなくてもいい
サブスクライバは、配信開始をTwitchから教えてもらうために、事前にEventSubサブスクリプションを要求しておくアプリケーションです。EventSubサブスクリプションは一度要求が通れば明示的に破棄するまで生き続けるので、サブスクリプションを要求したいチャンネルが少なく、増減も稀なのであればAPIを手動で呼び出せば実装する必要はありません。

- API呼出クライアントアプリ

https://github.com/advanced-rest-client/arc-electron

- EventSub仕様

https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#streamonline

### （実装する場合）ポイント

#### Twitchトークンの確認、再発行

ほとんどのTwitch APIにおいてOAuthトークンを要求されますが、トークンは発行後一定期間が過ぎると無効化されてしまいます。サブスクライバをアプリ化するメリットとして、Cloud Schedulerによって自動実行できる点を活かして、サブスクライブ要求処理の中でトークンの妥当性を問い合わせ、無効化されている場合は再発行します。

トークンはFirestoreに保存しておき、実行のたびにFirestoreから取得するようにします。

```java:Firestoreインスタンスの初期化
import com.google.auth.oauth2.GoogleCredentials;
import com.google.cloud.firestore.Firestore;
import com.google.firebase.FirebaseApp;
import com.google.firebase.FirebaseOptions;
import com.google.firebase.cloud.FirestoreClient;

private Firestore initializeFirestore(String projectId) throws IOException {
    Firestore db;

    List<FirebaseApp> apps = FirebaseApp.getApps();

    // 多重初期化するとエラーになるため、未初期化のときのみ初期化する
    if (apps.size() == 0) {
        GoogleCredentials credentials = GoogleCredentials.getApplicationDefault();
        FirebaseOptions options = FirebaseOptions.builder().setCredentials(credentials).setProjectId(projectId)
                .build();

        db = FirestoreClient.getFirestore(FirebaseApp.initializeApp(options));
    } else {
        db = FirestoreClient.getFirestore(apps.get(0));
    }

    return db;
}
```

```java:Firestoreからトークンを取得
import com.google.cloud.firestore.DocumentReference;

DocumentReference docRef = db.collection("コレクションID").document("ドキュメントID");
String token = (String) docRef.get().get().get("フィールド名");
```

```java:トークンの妥当性確認
private boolean isValidToken(String token) throws IOException {
    if (token == null || token.isEmpty()) {
        return false;
    }

    // リクエストヘッダ
    Map<String, String> headers = new HashMap<>() {
        {
            put("Authorization", "Bearer " + token);
        }
    };
    
    // ※ソース全文はGitHub
    // GET https://id.twitch.tv/oauth2/validate

    // IF リターンコードが2xx？
        // TRUE -> トークンは有効 
        // FALSE -> トークンは無効 
}
```

```java:トークンを再発行
/**
 * @param clientId TwitchアプリのクライアントID
 * @param secret Twitchアプリの秘密鍵
 */
private String generateNewToken(String clientId, String secret) throws Exception {
    // リクエストパラメータ
    Map<String, Object> params = new HashMap<>() {
        {
            put("client-id", clientId);
            put("client_secret", secret);
            put("grant_type", "client_credentials");
            put("scope", "user:read:follows");
        }
    };

    // ※ソース全文はGitHub
    // POST https://id.twitch.tv/oauth2/token
    // 返却されたJSONをパースしてトークンを取り出す
}

```

```java:再発行したトークンをFirestoreに保存
import com.google.cloud.firestore.DocumentReference;

DocumentReference docRef = db.collection("コレクションID").document("ドキュメントID");
docRef.update("フィールド名", token).get();
```

#### サブスクリプション秘密鍵の生成
サブスクリプション要求時に、秘密鍵が必要になります。（クライアント秘密鍵とは別なので注意）[^1]
この秘密鍵は、Twitchからの通知時にシグネチャとしてリクエストヘッダに載ってくるので、「本当にTwitchからの通知か」をバックエンドで判断する材料になります。
~~固定値でもいいし、適当に設定しておいてバックエンド側でシグネチャを無視しても大丈夫~~

秘密鍵を動的に生成する場合は、サブスクライブ対象のチャンネルと秘密鍵の紐づきを保持する必要があります。

| サブスク対象 | 秘密鍵 |
|:----------- |:------|
| チャンネルA  | AAA   |
| チャンネルB  | AAA   |
| チャンネルC  | BBB   |

マイクロサービスでDBが巨大化するのは本末転倒なので、今回はサブスクライバが起動するたびに、一度現在のサブスクリプションをすべて破棄して要求しなおすことで全サブスク対象の秘密鍵を統一します。

```java:現サブスクリプションIDの取得
private List<String> getSubscriptionIds(String token, String clientId) throws IOException {
    // リクエストヘッダ
    Map<String, String> headers = new HashMap<>() {
        {
            put("client-id", clientId);
            put("Authorization", "Bearer " + token);
        }
    };

    List<String> ids = new ArrayList<>();
    // ※ソース全文はGitHub
    // GET https://api.twitch.tv/helix/eventsub/subscriptions
    // 返却されたJSONをパースしてサブスクリプションIDを取り出しリストに詰める

    return ids;
}
```

```java:現サブスクリプションの破棄
private void revokeSubscriptions(String token, String clientId, List<String> ids) throws IOException {
    // リクエストヘッダ
    Map<String, String> headers = new HashMap<>() {
        {
            put("client-id", clientId);
            put("Authorization", "Bearer " + token);
        }
    };

    for (String id : ids) {
        // リクエストパラメータ
        Map<String, Object> params = new HashMap<>() {
            {
                put("id", id);
            }
        };

         // ※ソース全文はGitHub
         // DELETE https://api.twitch.tv/helix/eventsub/subscriptions
    }
}
```

サブスクリプション要求時にTwitchからバックエンドへコールバックリクエストが飛びます。（指定されたURLが正しく応答するかどうか確認するため）
コールバックリクエストにもシグネチャが乗るので、バックエンドが応答できるようDBに保存して共有します。

![無題のプレゼンテーション (6).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/556115/bbee79ed-8e7b-0b4f-5991-8c174d76d93f.png)



```java:サブスクリプション秘密鍵の生成
import org.apache.commons.lang3.RandomStringUtils;

// 秘密鍵をランダム生成
String preSubscriptionSecret = RandomStringUtils.randomAlphanumeric(100);

// 生成した秘密鍵をDBに保存する（コールバック用）
DocumentReference docRef = db.collection("コレクションID").document("ドキュメントID");
docRef.update("フィールド名", preSubscriptionSecret).get();
```

#### サブスクリプション要求結果の確認
サブスクリプション要求結果はリターンコードで判別できます。[^2]

## STEP-2. バックエンド
サブスクリプション対象のチャンネルが配信を開始したとき、Twitchからの通知を受けとるアプリケーションです。HTTPのリクエストを受けて処理結果に応じてレスポンスを返す必要があります。

### ポイント

#### リクエストタイプの判別
上述のとおり、Twitchがバックエンドにリクエストを送信するのは配信開始の通知時のみでなく、サブスクリプション要求を受けたときにもコールバックを送信します。

バックエンドは受け付けたリクエストが通知なのかコールバックなのかを判別する必要があります。判別には`Twitch-Eventsub-Message-Type`ヘッダを使用します。

#### シグネチャの確認
Twitchはサブスクリプション要求時に受けとった秘密鍵に基づいて生成したシグネチャを送信します。（`Twitch-Eventsub-Message-Signature`ヘッダ）
バックエンドはサブスクライバから共有された秘密鍵でシグネチャの期待値を生成し、実際のリクエストヘッダと突き合わせることで、信頼できる通信であるかを判断します。

```java:シグネチャの確認
/**
 * @param algo アルゴリズム（"HMacSHA256"を指定する）
 * @param request 受信したリクエスト
 * @param secret DBから取得した秘密鍵
 */
private boolean isValidSignature(String algo, HttpRequest request, String secret)
        throws IOException, NoSuchAlgorithmException, InvalidKeyException {
    // 期待値を生成
    String hmacMsg = request.getFirstHeader("Twitch-Eventsub-Message-Id").get()
            + request.getFirstHeader("Twitch-Eventsub-Message-Timestamp").get()
            + request.getReader().lines().collect(Collectors.joining());

    SecretKeySpec keySpec = new SecretKeySpec(secret.getBytes(), algo);
    Mac mac = Mac.getInstance(algo);
    mac.init(keySpec);
    mac.update(hmacMsg.getBytes());

    byte[] signBytes = mac.doFinal();
    String expected = "sha256=" + DatatypeConverter.printHexBinary(signBytes);

    // 実際値と比較、同一であればOK
    return request.getFirstHeader("Twitch-Eventsub-Message-Signature").get().compareToIgnoreCase(expected) == 0;
}
```

#### Discord通知用の情報を集める
配信開始通知のペイロードの内容は下記のとおりです。

- ストリームID
- 配信者ユーザID
- 配信者ログイン名
- 配信者表示名
- タイプ（`live`とか`watch_party`とか）
- 配信開始時刻

ぜんぜん足りねえ！「だれがいつ配信を始めたか」がわかるだけの最低限の内容で、これをこのままDiscordへ通知してもあまりうれしくありません。足りないものはAPIを叩いて集めます。

[Get Channel Information](https://dev.twitch.tv/docs/api/reference#get-channel-information) APIを使用して下記情報を取得します。

- 配信タイトル
- 配信ゲーム名

```java:チャンネル情報の取得
/**
 * @param token
 * @param clientId
 * @param userId 対象チャンネルの配信者ユーザID（配信開始通知イベントから取得できる）
 */
private Map<String, Object> getChannelInfo(String token, String clientId, String userId) throws IOException {
    // リクエストヘッダ
    Map<String, String> headers = new HashMap<>() {
        {
            put("Client-Id", clientId);
            put("Authorization", "Bearer " + token);
        }
    };

    // リクエストパラメータ
    Map<String, Object> params = new HashMap<>() {
        {
            put("broadcaster_id", userId);
        }
    };

    // ※ソース全文はGitHub
    // GET https://api.twitch.tv/helix/channels
    // レスポンスJSONをマップオブジェクトに変換して返却
}
```
#### Discordへ通知する
集めた情報を元に、メッセージをJSON形式で組み立ててDiscordのWebhook URLに投げます。
配信開始通知の`started_at`を使用する場合はUTCから変換するのを忘れずに。

Discord通知のイメージはこんなかんじ。チャンネルタイトルは、チャンネルURLのリンクになっています。

![キャプチャ.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/556115/6b768109-a205-e533-850d-0283aadd6914.png)

#### レスポンスを返す
Twitchは通知に対して一定時間レスポンスがないとリトライを送信します。Discordに通知したあとレスポンスを返さないとリトライされてしまい何度もDiscordに通知が行ってしまうので、忘れずにレスポンスを返すようにします。Twitchはリターンコード`2xx`を受け取れば正常に疎通したと判断してくれます。

## STEP-3. リリース

### パッケージング

いよいよ完成したアプリケーションをGCPへデプロイします。Cloud Functionsはコンソールへリソースをアップロードすることでもデプロイできるようですが、Javaの場合はCLIでデプロイします。

ソースをデプロイしてGCPでビルドすることもできますが、今回はパッケージングしたJARをデプロイします。
JARでデプロイする場合、`maven-shade-plugin`プラグインでUber JARにしてしまうのがラクです。

Mavenを使用している場合、`pom.xml`の`<plugins>`内に下記のように記述します。

```xml:pom.xml（サブスクライバ、バックエンド共に）
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-shade-plugin</artifactId>
    <executions>
        <execution>
            <phase>package</phase>
            <goals>
                <goal>shade</goal>
            </goals>
            <configuration>
                <outputFile>${project.build.directory}/deployment/${build.finalName}.jar</outputFile>
                <transformers>
                    <transformer
                        implementation="org.apache.maven.plugins.shade.resource.DontIncludeResourceTransformer">
                        <resource>.SF</resource>
                        <resource>.DSA</resource>
                        <resource>.RSA</resource>
                    </transformer>
                    <transformer
                        implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer" />
                </transformers>
            </configuration>
        </execution>
    </executions>
</plugin>
```

`mvn clean package`でパッケージングすると、`target/deployment`ディレクトリ配下にUber JARが出力されます。

### 環境変数ファイルの準備

Cloud Functionsは```System.getenv("キー名");```で事前に定義した環境変数を呼び出すことができます。環境変数は`env.yml`に定義してデプロイ時にて展開します。
Twitchのクライアント秘密鍵やWebhook URLなどの機密情報はなるべく`env.yml`に外だしにしましょう。

```yaml:env.yml（バックエンドの例）
GCP_PROJECT_ID: <GCPプロジェクトID. Firestoreの初期化に使う.>
TWITCH_CLIENT_ID: <TwitchアプリのクライアントID>
DISCORD_WEBHOOK_URL: <DiscordのWebhook URL>
```

### デプロイ

[Cloud SDK](https://cloud.google.com/sdk/docs/install)を使用して、GCPへデプロイします。

まずはバックエンドアプリケーションからデプロイします。
バックエンドはTwitchから呼ばれるため、トリガーには`--trigger-http`を指定し、認証なしでの実行を許可するために`--allow-unauthenticated`を付与します。
`--min-instances`と`--max-instances`の値は任意ですが、コールドスタートと多重実行を防ぐために両方`1`にしました。

```bash:バックエンドのデプロイコマンド
gcloud beta functions deploy <任意のFunction名> \
  --entry-point=<FunctionクラスのFQDN> \
  --trigger-http \
  --runtime=java11 \
  --min-instances=1 \
  --max-instances=1 \
  --env-vars-file=<env.ymlのパス> \
  --source=target/deployment \
  --allow-unauthenticated
```

デプロイが完了するとGCPのコンソール上で、バックエンドのトリガーURLが確認できます。

![a.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/556115/11bea1c8-6ed1-5d2b-15cc-77832fb8c15d.jpeg)

これをサブスクライバに反映して、続いてサブスクライバもデプロイします。
サブスクライバはCloud Schedulerによって起動されるため、トリガーには`--trigger-topic`を指定します。先にデプロイしたバックエンドアプリを上書きしないよう、Function名に注意してください。

```bash:サブスクライバのデプロイコマンド
gcloud beta functions deploy <任意のFunction名>`
  --entry-point=<FunctionクラスのFQDN> `
  --trigger-topic=<Pub/Subのトピック名.事前に作成しておく> `
  --runtime=java11 `
  --env-vars-file=env.yml `
  --source=target/deployment
```

バックエンドとサブスクライバのデプロイが完了したときのイメージ画像です。（ひとつ余計なアプリがありますが気にしないでください）
バックエンドのトリガーはHTTP、サブスクライバのトリガーがトピックになっていることがポイントです。

![無題のプレゼンテーション (7).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/556115/f873e2c6-c6d0-ff0d-98b4-c8e0fb2f12f7.png)

Cloud Schedulerを設定して、サブスクライバのトピックと紐づければ完成です。

## （おまけ）便利ツール
実装する際、下記のツールにとても助けられました。再掲のものも含みます。

- Advanced REST Client（https://github.com/advanced-rest-client/arc-electron）

Twitch APIの戻り値を確認したいときや、実装したバックエンドをテストするときに便利です。

- Discohook（https://discohook.org/）

Discord通知ペイロードをGUIでつくれる。超便利。 :godmode: 

[^1]: 要するにTwitchと交わす合言葉。バックエンド「山？」　Twitch「川」　バックエンド「通ってヨシ」するためのもの https://dev.twitch.tv/docs/eventsub/eventsub-reference#transport
[^2]: https://dev.twitch.tv/docs/api/reference#create-eventsub-subscription