【Unity】TcpClientクラスを利用してTwitch.tvのコメント取得処理を実装する

先日、Unityプロジェクト上で[Twitch.tv](https://www.twitch.tv/)のチャットコメントやフォロー通知などの取得を可能とするライブラリ『UniTwitchClient』を公開しました。

https://github.com/mojopon/UniTwitchClient

今回はこのライブラリでも採用している方法、TcpClientクラスを利用してTwitch.tvのコメントを取得する処理を自前で実装してみようという記事です。
簡潔にまとめてご紹介いたします。

## コード
今回のコードは以下のものとなります。
適当なシーンに、このスクリプトを配置してください。
accessTokenにご自身で取得したTwitchのアクセストークンを入力。
userNameにはTwitchユーザーネームを入力。
channelNameにコメントを取得したいTwitchのチャンネル名( 例：https://www.twitch.tv/anomaloris ならanomaloris )を入力してください。
シーン再生でTwitch.tvのコメントIRCに接続、取得したコメントをConsoleで出力します。

※Twitchのアクセストークン取得方法については、[こちらの記事](https://qiita.com/mojomojopon/items/54184c88a32501561cd4)でまとめています。
```
using System;
using System.IO;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;

public class TwitchChatClient : MonoBehaviour
{
    // ここにアクセストークンを入力
    private readonly string accessToken = "";
    // Twitchユーザーネームを入力
    private readonly string userName = "";
    // コメント取得したいTwitchチャンネルネームを接続
    private readonly string channelName = "";

    private readonly string TWITCH_IRC_URL = "irc.chat.twitch.tv";
    private readonly int TWITCH_IRC_PORT = 6667;

    private TcpClient _tcpClient = new TcpClient();
    private StreamReader _reader;
    private StreamWriter _writer;

    private CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource();

    private void Start()
    {
        _ = ConnectAsync();
    }

    private async Task ConnectAsync()
    {
        try
        {
            await _tcpClient.ConnectAsync(TWITCH_IRC_URL, TWITCH_IRC_PORT);
        }
        catch (Exception ex)
        {
            Debug.LogError(ex);
            return;
        }

        if (_tcpClient.Connected)
        {
            _reader = new StreamReader(_tcpClient.GetStream());
            _writer = new StreamWriter(_tcpClient.GetStream());
        }

        var oauthToken = "oauth:" + accessToken;
        await _writer.WriteLineAsync($"PASS {oauthToken}");
        await _writer.WriteLineAsync($"NICK {userName}");

        await _writer.WriteLineAsync($"JOIN #{channelName}");

        await _writer.WriteLineAsync("CAP REQ :twitch.tv/tags");
        await _writer.WriteLineAsync("CAP REQ :twitch.tv/commands");
        await _writer.WriteLineAsync("CAP REQ :twitch.tv/membership");

        await _writer.FlushAsync();

        await Task.Run(ListenAsync, _cancellationTokenSource.Token);
    }

    private async Task ListenAsync() 
    {
        while (true) 
        {
            try
            {
                var message = await _reader.ReadLineAsync();
                if (string.IsNullOrEmpty(message)) 
                {
                    Debug.Log("the tcp client is disconnected.");
                    Shutdown();
                    return;
                }

                Debug.Log(message);
            }
            catch(Exception ex) 
            {
                Debug.LogError(ex);
                return;
            }
        }
    }

    private void OnDestroy()
    {
        Shutdown();
    }

    public void Shutdown() 
    {
        _cancellationTokenSource.Cancel();
        _reader?.Dispose();
        _writer?.Dispose();
        _tcpClient.Close();
        _tcpClient.Dispose();
    }
}
```
以下、順を追ってコードを解説していきます。

## IRCサーバーに接続

```
    try
    {
        await _tcpClient.ConnectAsync(TWITCH_IRC_URL, TWITCH_IRC_PORT);
    }
    catch (Exception ex)
    {
        Debug.LogError(ex);
        return;
    }
    
    if (_tcpClient.Connected)
    {
        _reader = new StreamReader(_tcpClient.GetStream());
        _writer = new StreamWriter(_tcpClient.GetStream());
    }
```
ConnectAsync()の開始数行は、TwitchのIRCサーバーへの接続を行っています。
接続が成功した場合は、後に利用するStreamReaderクラスとStreamWriterクラスを取得してプライベートフィールドに保持します。

## 認証とチャンネル接続
```
    var oauthToken = "oauth:" + accessToken;
    // アクセストークンを送信
    await _writer.WriteLineAsync($"PASS {oauthToken}");
    // ユーザーネームを送信
    await _writer.WriteLineAsync($"NICK {userName}");
    // 接続チャンネルを指定
    await _writer.WriteLineAsync($"JOIN #{channelName}");

    await _writer.WriteLineAsync("CAP REQ :twitch.tv/tags");
    await _writer.WriteLineAsync("CAP REQ :twitch.tv/commands");
    await _writer.WriteLineAsync("CAP REQ :twitch.tv/membership");

    await _writer.FlushAsync();
```
_writerを使って認証に必要となるアクセストークンとユーザーネームを送信します。
接続チャンネル指定の行で、コメントを取得するチャンネルを指定しています。
相手側にアクセストークンや接続チャンネルなど情報を伝えたい際にはStreamWriterを使って入力するといった感じです。

入力内容がCAP REQで始まる三行で、Capabilityのリクエストを行っています。
ひとまず必要なおまじないとでも考えてください。
詳細が知りたい方は[TwitchのDocument](https://dev.twitch.tv/docs/irc/capabilities/)をご覧ください。

最後のFlushAsync()で入力内容の送信を行います。

## メッセージ取得ループを開始する
```
    await Task.Run(ListenAsync, _cancellationTokenSource.Token);
```
ListenAsyncメソッドを立ち上げます。
このメソッドの中でループが開始され、IRCサーバーから受信したメッセージを処理し続けるというループになっています。
キャンセル時や終了時にきちんとループが終了されるようにCancellationTokenを渡しています。

## メッセージ取得処理
```
private async Task ListenAsync() 
{
    while (true) 
    {
        try
        {
            var message = await _reader.ReadLineAsync();
            if (string.IsNullOrEmpty(message)) 
            {
                Debug.Log("the tcp client is disconnected.");
                Shutdown();
                return;
            }

            Debug.Log(message);
        }
        catch(Exception ex) 
        {
            Debug.LogError(ex);
            return;
        }
    }
}
```
接続したIRCサーバーからのレスポンスを捌く処理です。
```
var message = await _reader.ReadLineAsync();
Debug.Log(message);
```
この部分で受け取ったメッセージ（コメントやシステムメッセージ）をConsole出力しています。

```
if (string.IsNullOrEmpty(message)) 
{
    Debug.Log("the tcp client is disconnected.");
    Shutdown();
    return;
}
```
接続が切断された状態になるとStreamReaderはNullを返します。
Nullがかえってきた場合は切断されたと考えて、Dispose等の後始末（Shutdown）を行います。

## 後始末
```
private void OnDestroy()
{
    Shutdown();
}

public void Shutdown() 
{
    _cancellationTokenSource.Cancel();
    _reader?.Dispose();
    _writer?.Dispose();
    _tcpClient.Close();
    _tcpClient.Dispose();
}
```
シーンの終了時や何らかの理由で切断された際に、TcpClientやその関連クラスのDisposeをして後始末します。
CancellationTokenSourceのキャンセルでListenAsyncのループも終了させています。

## 実際に動かす
以上のコードを書いたスクリプトを用意し、アクセストークンなどの必要な情報記入後にシーンに配置したゲームオブジェクトに貼り付けてシーン再生する事でIRCサーバーに接続され、ひとまずコメントが取得できます。
この方法で取得したコメントデータにはメタデータが多数含まれており、細かく扱うにはパースが必要なのですが、パース処理はやや長くなるので今回は省きます。
詳しく知りたい方はこちらの[ドキュメント](https://dev.twitch.tv/docs/irc/example-parser/)をご覧になって実装していただくか、UniTwitchClientの[IrcMessageParser.cs](https://github.com/mojopon/UniTwitchClient/blob/master/Assets/UniTwitchClient/Scripts/Chat/Irc/IrcMessageParser.cs)を参考にしていただければと思います。

ところで、Twitch IRCサーバーは定期的に受信者にPingメッセージを送信しており、Pongメッセージを返さないと受信者を切断するという挙動をします。
先ほどのコードでも接続してコメントを取得するまでは問題なく動作するのですが、実は時間が経つと切断されてしまうという問題があります。
なので前述のコードにPingPong対応をする処理を書き足してみましょう。

## PingPong処理
ListenAsyncメソッドを修正し、以下のようにしてください。
```
private async Task ListenAsync() 
{
    while (true) 
    {
        try
        {
            var message = await _reader.ReadLineAsync();
            if (string.IsNullOrEmpty(message)) 
            {
                Debug.Log("the tcp client is disconnected.");
                Shutdown();
                return;
            }

            //ここから修正
            if (message.Contains("PING :tmi.twitch.tv"))
            {
                await _writer.WriteLineAsync("PONG :tmi.twitch.tv");
                await _writer.FlushAsync();
            }
            //ここまで

            Debug.Log(message);
        }
        catch(Exception ex) 
        {
            Debug.LogError(ex);
            return;
        }
    }
}
```
「PING :tmi.twitch.tv」という文字列を含むメッセージを受信した場合に、「PONG :tmi.twitch.tv」というメッセージを送信します。これで、PingPong対応は完了です。
時間経過しても切断されなくなりました。

最終的な全コードは以下の通りです。
```
using System;
using System.IO;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;

public class TwitchChatClient : MonoBehaviour
{
    // ここにアクセストークンを入力
    private readonly string accessToken = "";
    // Twitchユーザーネームを入力
    private readonly string userName = "";
    // コメント取得したいTwitchチャンネルネームを接続
    private readonly string channelName = "";

    private readonly string TWITCH_IRC_URL = "irc.chat.twitch.tv";
    private readonly int TWITCH_IRC_PORT = 6667;

    private TcpClient _tcpClient = new TcpClient();
    private StreamReader _reader;
    private StreamWriter _writer;

    private CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource();

    private void Start()
    {
        _ = ConnectAsync();
    }

    private async Task ConnectAsync()
    {
        try
        {
            await _tcpClient.ConnectAsync(TWITCH_IRC_URL, TWITCH_IRC_PORT);
        }
        catch (Exception ex)
        {
            Debug.LogError(ex);
            return;
        }

        if (_tcpClient.Connected)
        {
            _reader = new StreamReader(_tcpClient.GetStream());
            _writer = new StreamWriter(_tcpClient.GetStream());
        }

        var oauthToken = "oauth:" + accessToken;
        await _writer.WriteLineAsync($"PASS {oauthToken}");
        await _writer.WriteLineAsync($"NICK {userName}");

        await _writer.WriteLineAsync($"JOIN #{channelName}");

        await _writer.WriteLineAsync("CAP REQ :twitch.tv/tags");
        await _writer.WriteLineAsync("CAP REQ :twitch.tv/commands");
        await _writer.WriteLineAsync("CAP REQ :twitch.tv/membership");

        await _writer.FlushAsync();

        await Task.Run(ListenAsync, _cancellationTokenSource.Token);
    }

    private async Task ListenAsync() 
    {
        while (true) 
        {
            try
            {
                var message = await _reader.ReadLineAsync();
                if (string.IsNullOrEmpty(message)) 
                {
                    Debug.Log("the tcp client is disconnected.");
                    Shutdown();
                    return;
                }

                if (message.Contains("PING :tmi.twitch.tv"))
                {
                    await _writer.WriteLineAsync("PONG :tmi.twitch.tv");
                    await _writer.FlushAsync();
                }

                Debug.Log(message);
            }
            catch(Exception ex) 
            {
                Debug.LogError(ex);
                return;
            }
        }
    }

    private void OnDestroy()
    {
        Shutdown();
    }

    public void Shutdown() 
    {
        _cancellationTokenSource.Cancel();
        _reader?.Dispose();
        _writer?.Dispose();
        _tcpClient.Close();
        _tcpClient.Dispose();
    }
}
```