where

FaceRigに自作3Dモデルを入れる事や、その他技術っぽいことについてメモするだけのブログです

Twitch配信開始の通知を自動でツイートする

はじめに

旧記事から半分ほど作り直した Twitch 配信通知システムの作り方メモです。


やりたいこと

準備

Twitch Developer 登録

Twitch Developer

控えておくもの

  • クライアントID
  • クライアントシークレット

Twitter Developer 登録

Twitter Developer

登録について
英語でアプリの目的とかを書いて申請する必要あり。
自分の場合は、英語で申請した後に日本語のメールが来て、英語と同じような内容を日本語でちゃんと書いて返信したら登録できた。
単純な通知アプリを作りたい、というのの他に、個人で使う用です、とか、勉強がてらです、とか書いたけど必要だったかは分からない。

プロジェクトを登録出来たら Settings の App permissions で Write を含むように変更する。

控えておくもの

Twitchから情報取得

アクセストークン取得
Authentication | Twitch Developers

App access tokens を取得したいので、
Client credentials flow を使う。

curl -X POST -H "Content-Type: application/json" "https://id.twitch.tv/oauth2/token?client_id=クライアントID&client_secret=クライアントシークレット&grant_type=client_credentials"

返ってきた access_token を控える。

ユーザID取得

curl "https://api.twitch.tv/helix/users?login=ユーザー名" -H "Content-Type: application/json" -H "Authorization: Bearer Twitchアクセストークン" -H "client-id: クライアントID"

控えておくもの

  • Twitchアクセストーク
  • ユーザーID

Firebase Functions 登録

Firebase

Functions を使うために料金プランをBlazeにする。
料金参考:Firebaseで無料枠の範囲内のはずが課金されたのならこれが理由かも? - Qiita

控えておくもの

  • コールバックURL

Firebase Functions プロジェクト作成

npm インストール
npm のはじめかた - Qiita

npm でインストールするもの

npm install -g firebase-tools
npm install twitter

フォルダ準備
適当な場所にデプロイ用のフォルダを作成。

控えておくもの

  • Firebaseプロジェクトパス

ログイン
Firebase にログイン。

firebase login

プロジェクト作成
用意したフォルダに移動。

cd Firebaseプロジェクトパス

プロジェクト作成開始。

firebase init

するといろいろ質問される。

Firebase Functions 環境変数設定

キー構成は自分で分かりやすければ何でも大丈夫だと思う。

firebase functions:config:set credential.twitter.api_key="値"

設定するもの Twitch関連

  • クライアントID
  • クライアントシークレット
  • ユーザーID
  • Twitchアクセストーク
  • HMAC用シークレット

署名の確認に使用する。
10~100の文字列をパスワードのように決めておく。
Twitch で取得したアクセスシークレットや Twitter で取得したクライアントシークレットとは異なるので注意。

設定するもの Twitter関連

Twitch API サブスクリプション登録

EventSub | Twitch Developers

  1. 登録の curl コマンドを流す
  2. webhook_callback_verification_pending のレスポンスが返ってくる
  3. コールバックURLの方にリクエストが送られてくる
  4. 署名の確認をする
  5. challenge の値を返す
  6. 登録成功

という流れ。
なのでまず、コールバックURLにくるリクエスト処理(上の4, 5)を作成する。

Firebase Functions 受け取り側作成

ソース全体

署名の確認
Verify a signature | Twitch Developers

    // 署名確認
    const hmac_message =
      headers['twitch-eventsub-message-id'] +
      headers['twitch-eventsub-message-timestamp'] +
      request.rawBody;
    const signature = crypto
      .createHmac('sha256', twitchConfig.hmac_secret)
      .update(hmac_message)
      .digest('hex');
    const expected_signature_header = 'sha256=' + signature;
    const message_signature = headers["twitch-eventsub-message-signature"];
    if (message_signature != expected_signature_header) {
      functions.logger.error("Error", "Verify Signature Failed");
      response.sendStatus(403);
    }

前に設定したHMAC用シークレットを元に HMAC-SHA256 でなんやかんやしたものと、送られてきた twitch-eventsub-message-signature を比較する。
合っていなかったら403が返るようにする。

リクエスト受け取り

    if (messageType == "webhook_callback_verification") {
      // Webhhok登録認証
      response.send(request.body.challenge);
    }

ヘッダーの twitch-eventsub-message-type で判断。
webhook_callback_verification なら challenge を返す。

デプロイ

firebase deploy

ESLint 修正

サブスクリプション登録

curl -X POST -d "{\"type\":\"サブスクリプションタイプ\", \"version\":\"1\", \"condition\":{\"broadcaster_user_id\":\"ユーザID\"}, \"transport\":{\"method\":\"webhook\", \"callback\":\"コールバックURL\", \"secret\":\"HMAC用シークレット\"}}" -sS -H "Content-Type: application/json" -H "Authorization: Bearer Twitchアクセストークン" -H "client-id: クライアントID" https://api.twitch.tv/helix/eventsub/subscriptions

サブスクリプションタイプ
EventSub Subscription Types | Twitch Developers
ここでは配信開始の通知を受け取りたいので stream.online を使用する。
配信終了も受け取りたかったので stream.offline も後で同じように登録する。

Firebase 側を作成出来たら、上の curl コマンドを流す。
webhook_callback_verification_pending のレスポンスが返ってくるのを確認して、リクエスト受け取りの方も上手くいったら登録成功。

サブスクリプション確認

curl "https://api.twitch.tv/helix/eventsub/subscriptions" -H "Content-Type: application/json" -H "Authorization: Bearer Twitchアクセストークン" -H "client-id: クライアントID"

サブスクリプション一覧が取得できる。
status が enabled になっていれば大丈夫。

検証に失敗して status が webhook_callback_verification_failed になっていたり、余計なサブスクリプションを削除したい場合は以下のコマンド。

curl -X DELETE https://api.twitch.tv/helix/eventsub/subscriptions?id=サブスクリプションID -H "Content-Type: application/json" -H "Authorization: Bearer Twitchアクセストークン" -H "client-id: クライアントID"

通知受け取り処理

ヘッダーの twitch-eventsub-message-type で判断。
通知は notification。

Twitter処理

      // Twitter準備
      const client = new Twitter({
        consumer_key: twitterConfig.api_key,
        consumer_secret: twitterConfig.api_secret,
        access_token_key: twitterConfig.access_token_key,
        access_token_secret: twitterConfig.access_token_secret,
      });

      var tweet = "";
      if (subscriptionType == "stream.online") {
        // 配信開始

        // 配信タイトル取得
        const title = await getStreamTitle();

        tweet = title +
          " https://www.twitch.tv/" +
          request.body.event.broadcaster_user_login;

      } else if (subscriptionType == "stream.offline") {
        // 配信終了
        tweet = "配信おわり";
      }
      if (tweet != "") {
        await client.post("statuses/update", {
          status: tweet
        })
      }

twitch-eventsub-subscription-type にサブスクリプションタイプが入っているので、これで配信開始か終了かを判断する。

配信タイトル取得

以前のサブスクリプションでは配信タイトルが通知データ内に入っていたのだが、更新されてからは入っていないので都度取得する必要がある。

// 配信タイトル取得
async function getStreamTitle() {
  functions.logger.log("getStreamTitle Start");
  var title = "";
  try {
    const accessToken = await checkAccessToken();
    if (accessToken == "") {
      title = "配信はじめ";
      return title;
    }
    const headers = {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + accessToken,
      "client-id": twitchConfig.client_id,
    }
    const url = "https://api.twitch.tv/helix/channels?broadcaster_id=" + twitchConfig.user_id;
    const options = {
      url: url,
      method: "GET",
      headers: headers,
      json: true,
    }
    const result = await requestAsync(options);
    if (result && result.data[0].title) {
      title = result.data[0].title;
    } else {
      title = "配信はじめ";
    }
  } catch (e) {
    functions.logger.error("Error", e);
  }
  return title;
}

アクセストークン確認、更新

上の配信タイトル取得にはアクセストークンが必要だが、アクセストークンには期限が設定されているので切れている可能性がある。
その為、まず疎通確認をする。

// Twitchアクセストークン疎通確認
async function checkAccessToken() {
  functions.logger.log("checkAccessToken Start");
  var accessToken = "";
  try {
    const headers = {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + twitchConfig.access_token,
    }
    const url = "https://id.twitch.tv/oauth2/validate";
    const options = {
      url: url,
      method: "GET",
      headers: headers,
      json: true,
    }
    const result = await requestAsync(options);
    if (result && result.status != 200) {
      accessToken = await getAccessToken();
    } else {
      accessToken = twitchConfig.access_token;
    }
  } catch (e) {
    functions.logger.error("Error", e);
  }
  return accessToken;
}

期限が切れていたらアクセストークンを取得しなおす。

// Twitchアクセストークン更新
async function getAccessToken() {
  functions.logger.log("getAccessToken Start");
  var accessToken = "";
  try {
    const headers = {
      "Content-Type": "application/json",
    }
    const url = "https://id.twitch.tv/oauth2/token" +
      "?client_id=" + twitchConfig.client_id +
      "&client_secret=" + twitchConfig.client_secret +
      "&grant_type=client_credentials";
    const options = {
      url: url,
      method: "POST",
      headers: headers,
      json: true,
    }
    const result = await requestAsync(options);
    if (result && result.access_token) {
      functions.logger.log("getAccessToken Success");
      accessToken = result.access_token;
    } else {
      functions.logger.log("getAccessToken Failed");
    }
  } catch (e) {
    functions.logger.error("Error", e);
  }
  return accessToken;
}

※本当はここで取得しなおしたアクセストークンで Firebase Functions 環境変数を変更したいのだが……コマンドライン以外の変更方法が無さそうだったので断念。

バッチ作成

上のアクセストークン確認、更新だと取得したトークンを保存できないため、一度切れたらずっと都度取得し直しになってしまう。
それもどうかと思ったので、週一くらいの頻度でアクセストークンの確認をして、切れていたらコマンドを叩いて Firebase Functions の環境変数を更新するバッチを作成する。
(ドキュメントでは60日で切れるらしいので頻度はもっと低くてもいいかもしれない)
と思ったが、いろいろした結果バッチでなく exe になった。
exe の中でバッチを叩く。

コンフィグに入れるもの

  • Twitchアクセストーク
  • クライアントID
  • クライアントシークレット

確認処理

/// <summary>
/// OAuth疎通チェック
/// </summary>
/// <returns></returns>
private static async Task<bool> CheckOAuth()
{
	Configuration config = ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.None);
	string twitch_access_token = config.AppSettings.Settings["twitch_access_token"].Value;

	string url = "https://id.twitch.tv/oauth2/validate";
	HttpClient client = new HttpClient();

	HttpRequestMessage request = new HttpRequestMessage(System.Net.Http.HttpMethod.Get, url);
	request.Headers.Add("ContentType", "application/json");
	request.Headers.Add("Authorization", "Bearer " + twitch_access_token);

	HttpResponseMessage response = await client.SendAsync(request);

	return response.IsSuccessStatusCode;
}

取得処理

/// <summary>
/// アクセストークン取得
/// </summary>
private static async Task<string> GetOAuth()
{
	Configuration config = ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.None);
	string twitch_client_id = config.AppSettings.Settings["twitch_client_id"].Value;
	string twitch_client_secret = config.AppSettings.Settings["twitch_client_secret"].Value;

	string url = "https://id.twitch.tv/oauth2/token"
		+ "?client_id=" + twitch_client_id
		+ "&client_secret=" + twitch_client_secret
		+ "&grant_type=client_credentials";
	HttpClient client = new HttpClient();
	HttpRequestMessage request = new HttpRequestMessage(System.Net.Http.HttpMethod.Post, url);

	HttpResponseMessage response = await client.SendAsync(request);

	var responseContent = await response.Content.ReadAsStringAsync();
	dynamic data = JsonConvert.DeserializeObject(responseContent);
	string new_access_token = data.access_token;

	config.AppSettings.Settings["twitch_access_token"].Value = new_access_token;
	config.Save();

	UpdateFireBaseConfig(new_access_token);

	return new_access_token;
}

更新処理

/// <summary>
/// FirebaseのConfigを更新
/// </summary>
/// <param name="new_access_token"></param>
private static void UpdateFireBaseConfig(string new_access_token)
{
	string deployPath = @"Firebaseプロジェクトパス";
	string batPath = @"バッチを置きたい場所のパス\バッチ名.bat";

	StringBuilder sb = new StringBuilder();
	sb.AppendLine("cd " + deployPath);
	sb.AppendLine("firebase functions:config:set credential.twitch.access_token=" + new_access_token);
	sb.AppendLine("firebase deploy");

	File.WriteAllText(batPath, sb.ToString());

	ProcessStartInfo processStartInfo = new ProcessStartInfo();
	processStartInfo.FileName = batPath;
	processStartInfo.CreateNoWindow = true;
	processStartInfo.UseShellExecute = false;

	Process.Start(processStartInfo);
}

バッチファイルを作成して、起動する。
その中で Firebase Functions の環境変数を更新し、デプロイし直す。

index.js 全体

const functions = require("firebase-functions");
const Twitter = require("twitter");
const crypto = require('crypto');
const request = require('request');

const twitchConfig = functions.config().credential.twitch;
const twitterConfig = functions.config().credential.twitter;

exports.tw2tw = functions.https.onRequest(async (request, response) => {
  try {
    functions.logger.log("tw2tw Start");

    const headers = request.headers;
    const messageType = headers["twitch-eventsub-message-type"];
    const subscriptionType = headers["twitch-eventsub-subscription-type"];

    // 署名確認
    const hmac_message =
      headers['twitch-eventsub-message-id'] +
      headers['twitch-eventsub-message-timestamp'] +
      request.rawBody;
    const signature = crypto
      .createHmac('sha256', twitchConfig.hmac_secret)
      .update(hmac_message)
      .digest('hex');
    const expected_signature_header = 'sha256=' + signature;
    const message_signature = headers["twitch-eventsub-message-signature"];
    if (message_signature != expected_signature_header) {
      functions.logger.error("Error", "Verify Signature Failed");
      response.sendStatus(403);
    }

    functions.logger.log("MessageType", messageType);
    functions.logger.log("subscriptionType", subscriptionType);

    if (messageType == "webhook_callback_verification") {
      // Webhhok登録認証
      response.send(request.body.challenge);

    } else if (messageType == "notification") {
      // 通知

      // Twitter準備
      const client = new Twitter({
        consumer_key: twitterConfig.api_key,
        consumer_secret: twitterConfig.api_secret,
        access_token_key: twitterConfig.access_token_key,
        access_token_secret: twitterConfig.access_token_secret,
      });

      var tweet = "";
      if (subscriptionType == "stream.online") {
        // 配信開始

        // 配信タイトル取得
        const title = await getStreamTitle();

        tweet = title +
          " https://www.twitch.tv/" +
          request.body.event.broadcaster_user_login;

      } else if (subscriptionType == "stream.offline") {
        // 配信終了
        tweet = "配信おわり";
      }
      if (tweet != "") {
        await client.post("statuses/update", {
          status: tweet
        })
      }

      functions.logger.log("Tweet Posted", tweet);
      response.send("Tweet Posted");

    } else {
      functions.logger.error("Error", "Unknown MessageType");
      response.sendStatus(500);
    }
  } catch (e) {
    functions.logger.error("Error", e);
    response.sendStatus(500);
  }
});

// 配信タイトル取得
async function getStreamTitle() {
  functions.logger.log("getStreamTitle Start");
  var title = "";
  try {
    const accessToken = await checkAccessToken();
    if (accessToken == "") {
      title = "配信はじめ";
      return title;
    }
    const headers = {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + accessToken,
      "client-id": twitchConfig.client_id,
    }
    const url = "https://api.twitch.tv/helix/channels?broadcaster_id=" + twitchConfig.user_id;
    const options = {
      url: url,
      method: "GET",
      headers: headers,
      json: true,
    }
    const result = await requestAsync(options);
    if (result && result.data[0].title) {
      title = result.data[0].title;
    } else {
      title = "配信はじめ";
    }
  } catch (e) {
    functions.logger.error("Error", e);
  }
  return title;
}

// Twitchアクセストークン疎通確認
async function checkAccessToken() {
  functions.logger.log("checkAccessToken Start");
  var accessToken = "";
  try {
    const headers = {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + twitchConfig.access_token,
    }
    const url = "https://id.twitch.tv/oauth2/validate";
    const options = {
      url: url,
      method: "GET",
      headers: headers,
      json: true,
    }
    const result = await requestAsync(options);
    if (result && result.status != 200) {
      accessToken = await getAccessToken();
    } else {
      accessToken = twitchConfig.access_token;
    }
  } catch (e) {
    functions.logger.error("Error", e);
  }
  return accessToken;
}

// Twitchアクセストークン更新
async function getAccessToken() {
  functions.logger.log("getAccessToken Start");
  var accessToken = "";
  try {
    const headers = {
      "Content-Type": "application/json",
    }
    const url = "https://id.twitch.tv/oauth2/token" +
      "?client_id=" + twitchConfig.client_id +
      "&client_secret=" + twitchConfig.client_secret +
      "&grant_type=client_credentials";
    const options = {
      url: url,
      method: "POST",
      headers: headers,
      json: true,
    }
    const result = await requestAsync(options);
    if (result && result.access_token) {
      functions.logger.log("getAccessToken Success");
      accessToken = result.access_token;
    } else {
      functions.logger.log("getAccessToken Failed");
    }
  } catch (e) {
    functions.logger.error("Error", e);
  }
  return accessToken;
}

// 非同期リクエスト
function requestAsync(options) {
  try {
    return new Promise(function(resolve, reject) {
      request(options, function(error, res, body) {
        if (res.statusCode != 200) {
          functions.logger.error("requestAsync Status", res.statusCode);
        }
        if (error) {
          functions.logger.error("requestAsync Error", error);
        }
        resolve(body);
      });
    });
  } catch (e) {
    functions.logger.error("Error", e);
    return null;
  }
}