いま席にいるかをお知らせするシステムを作った

仕事をしている中でとても気になるのが「いま話をしたい人が席にいるかどうか」だと思います。弊社では Slack を使って仕事のやり取りをしていますが、問いかけになかなか反応しないときに、反応できない状態なのか、そもそも席にいないのかわからないままもやもやすることってよくあるんじゃないかなと思います。そこで、「いま席にいるか」を問いかけると返事してくれるシステムを作りました。サーバー プログラミングのお勉強もかねてます。

# Olkar
Olkar (おるか) っていいます。名前付けは適当です。
状態管理と Slack BOT を兼ね備えたサーバー システム、状態を通知するクライアント システムの 2 つをあわせたものです。

## サーバー構成
サーバーは ASP.NET Core + SignalR + C# で作っていて、OS は Debian Stretch です。
![構成図](https://qiita-image-store.s3.amazonaws.com/0/314249/ee0a2b71-8fd1-a38e-92ab-20d679100727.png)
クライアントとサーバーとの間で SignalR によるリアルタイム通信が実装されています。

### SignalR での実装
何も考えずに ASP.NET Core Web アプリケーションとしてプロジェクト作りました
![image.png](https://qiita-image-store.s3.amazonaws.com/0/314249/8794de01-c4ae-ae26-1d23-4d032fc79aa1.png)
SignalR での実装に参考にしたのは [Microsoft の公式ドキュメント](https://docs.microsoft.com/ja-jp/aspnet/core/signalr/introduction?view=aspnetcore-2.1)ですが、[チュートリアル](https://docs.microsoft.com/ja-jp/aspnet/core/tutorials/signalr?view=aspnetcore-2.1&tabs=visual-studio)では Web ページでも動作するための準備も含まれていて、今回はページとしてリアルタイムで何か見ることはしないので、unpkg での SignalR クライアント ライブラリの追加は省略します。

あとはしこしこと実装するだけです。
Hub には状態を変更するためのメソッドと、現在の状態を知るためのメソッドを用意しました。

```cs:OlkarHub.cs
public class OlkarHub : Hub
{
    public OlkarStatusModel Status { get; private set; }

    public OlkarHub(OlkarStatusModel model)
    {
        this.Status = model;
    }

    /// <summary>
    /// ステータスを更新します。
    /// </summary>
    /// <returns></returns>
    public async Task SetStatus(Status status)
    {
        this.Status.Status = status;

        await Clients.All.SendAsync("Status", status);
    }

    /// <summary>
    /// 現在のステータスを取得します。
    /// </summary>
    /// <returns></returns>
    public async Task GetStatus()
    {
        await Clients.Caller.SendAsync("Status", this.Status.Status);
    }
}
```

ここで `OlkarStatusModel` は状態を管理するクラスのことをさします。このままだと、接続しているインスタンスがいなくなると状態が破棄されてしまうので、DI として登録しておき、いつでも状態を呼び出せるようにしておきます。
登録は `Startup.cs` でできます。

```cs:Startup.cs
public void ConfigureServices(IServiceCollection services)
{
    (略)
    services.AddMvcCore();
    // Singleton として登録しておくことで、プロセスが生きている間は状態が保持される
    services.AddSingleton<OlkarStatusModel>();
    (略)
}
```

呼び出したいときは、コントローラーとなったクラス (今回は `OlkarHub`) のコンストラクター引数に、呼び出すクラスを書いておくとインスタンスを挿入してくれます。

SignalR を有効にするため、`Startup.cs` にいくつか追記します。

```cs:Startup.cs
public void ConfigureServices(IServiceCollection services)
{
    (略)
    services.AddSignalR(config =>
    {
        // 明示的にしておくことで、勝手にタイムアウトしていくのを防ぐ
        config.HandshakeTimeout = TimeSpan.FromSeconds(15);
    });
    (略)
}

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    (略)
    // SignalR のルート
    app.UseSignalR(routes => routes.MapHub<OlkarHub>("/olkar"));
    (略)
}
```

これで SignalR 側の実装はだいたい終わり。Nginx と連携したリバースプロキシの設定や、https 接続の有効化、実行の方法はだいたい Microsoft のドキュメントを見るとわかるので省略します。

### Slack API の実装
めっちゃめんどくさかった
実装には [この記事がいろいろ参考になりました](https://qiita.com/takose/items/bc2b63426b9921604599)。

Slack に対してお伝えするためのルーティングは、「[属性ルーティング](https://aspnet-core.clock-up.jp/contents/routing/attribute)」と呼ばれる仕組みで実装しています。

Slack が呼び出し先として認知させるためのベリファイを実装します。送られてきたデータのうち `Challange` を何らかの形で返すようにします。JSON のパースはおなじみ [Json.NET](https://www.newtonsoft.com/json) です。

```cs:SlackController.cs
[Route("slack")]
public class SlackController : Controller
{
    [Route("request")]
    public async Task<IActionResult> SlackRequest([FromBody] object request)
    {
        if (!(request is JObject jObject))
        {
            return Forbid();
        }

        var type = jObject["type"];

        switch (type.ToString())
        {
            case "url_verification":
                var verify = jObject.ToObject<SlackVerifyHandshakeModel>();
                return Verify(verify);
            default:
                break;
        }

        return Content(request.ToString());
    }

    private IActionResult Verify(SlackVerifyHandshakeModel message)
    {
        // Content で HTTP 200 を返しつつ応答メッセージを返せる
        return Content(message?.Challenge ?? "NULL");
    }
```

```cs:SlackVerifyHandshakeModel.cs
public class SlackVerifyHandshakeModel
{
    [JsonProperty("token")]
    public string Token { get; set; }
    [JsonProperty("challenge")]
    public string Challenge { get; set; }
    [JsonProperty("type")]
    public string Type { get; set; }
}
```

`request` にやってくるデータが時と場合によってバラバラなので、`JsonConvert.DeserializeObject<T>(string)` ではなく `JObject` として拾って処理しました。中身が確定したらその都度変換しています。

あとは参考にした記事通り、追加したい Workspace に対して App を新規追加し、Event Subscriptions の Request URL にアドレスを記述して問題ないかを確認します。「Verified」が出れば OK
![image.png](https://qiita-image-store.s3.amazonaws.com/0/314249/9d988692-6d99-8e35-d4fc-27e7fdfb0050.png)
今後はこのアドレスに対していろいろとメッセージが降ってくるようになります。

さきほどの `SlackRequest` メソッドにたくさんのメッセージがくる中、`type` が `event_callback` になっているものに対して処理を掛けます。
BOT に対してのメンションが取りたいので、Request URL を入れたページの下の方にある「Subscribe to Bot Events」で、メンションを受け取るための `app_mention` を有効にしておきます。DM でも何か欲しい場合は `message.im` も有効にしておくとよいでしょう。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/314249/dc7d8257-891e-cf67-c533-08b1ff894959.png)

`event_callback` としてやってきたデータには、`event` と呼ばれる階層ができています。そこの `type` を見て処理を書けば OK です。さきほど `app_mention` を有効にしたので、 `"type": "app_mention"` と書かれたデータがやってきます。`message.im` も有効であれば、`"channel_type": "im"` も飛んできます。

```cs:SlackController.cs
private async Task<IActionResult> EventCallback(JObject jsonObject)
{
    var eventJson = jsonObject["event"];
    var eventType = eventJson["type"];

    // 基本情報
    var user = eventJson["user"];
    var item = eventJson["item"];
    var channelType = eventJson["channel_type"]?.ToString() ?? string.Empty;

    switch (eventType.ToString())
    {
        case var d when d == "message" && channelType == "im":
        case "app_mention":
            Log($"<<- {eventJson}");
            await ParseMessageAsync(eventJson);
            break;
        default:
            break;
    }

    return Ok();
}
```

あとはパースして処理してやれば OK です。BOT の応答にも返事しないように、 `"subtype" : "bot_message"` は排除します。

```cs:SlackController.cs
private async Task ParseMessageAsync(JToken message)
{
    var subtype = message["subtype"];
    if (subtype?.ToString() == "bot_message")
    {
        return;
    }

    var messageId = message["client_msg_id"];

    var channel = message["channel"];
    var text = message["text"];
    var user = message["user"];

    // 前に発言したのと全く同じのがきていたら無視
    if (messageId != null)
    {
        Log("Client Message Id: " + messageId);

        if (messageId.ToString() == previousMessageId)
        {
            return;
        }

        previousMessageId = messageId.ToString();
    }

    await ExecuteCommandAsync(channel.ToString(), user.ToString(), text.ToString());
}
```

Slack へのメッセージの送信は [chat.postMessage に POST](https://api.slack.com/methods/chat.postMessage) すれば OK です。「OAuth & Permissions」で拾ってきた BOT 用 OAuth Token をパラメーターに入れておくことで BOT の投稿として処理されます。
ユーザーへの返事は `<@USER0123>` のように、ユーザー名ではなく専用のユーザー ID に置き換えますが、その辺は処理するときに受け取った情報でどうにかなるのであまり気にしなくていいでしょう。

```cs:SlackController.cs
private async Task SendMessageAsync(string channel, string message)
{
    var contentDict = new Dictionary<string, string>
    {
        { "token", OAuthToken },
        { "channel", channel },
        { "text", message }
    };
    var content = new FormUrlEncodedContent(contentDict);

    // フィールドに HttpClient http = new HttpClient(); がいます
    var result = await this.http.PostAsync("https://slack.com/api/chat.postMessage", content);
}
```

これであらかたおわったはず。

## クライアントの実装
記事を書いてたらだんだん疲れました
常駐させておきたかったので WPF で実装し、通知領域に生きてもらうようにしています。
クライアントには SignalR をどこからか入れる必要があるので、Nuget で `Microsoft.AspNetCore.SignalR.Client` を探して入れておきます。`Microsoft.AspNet.SignalR.Client` もありますが、こっちは今回使いません。

今回は「PCがロック状態になったかどうかで在席・離席を管理」したかったので、ロック状態を検知できるようにします。
[Microsoft.Win32.SystemEvents.SessionSwitch イベント](https://docs.microsoft.com/ja-jp/dotnet/api/microsoft.win32.systemevents.sessionswitch?view=netframework-4.7.2)でそれを拾うことができます。

```cs
private async void SystemEvents_SessionSwitch(object sender, SessionSwitchEventArgs e)
{
    if (e.Reason == SessionSwitchReason.SessionLock)
    {
        Console.WriteLine($"{DateTimeOffset.Now:yyyy/MM/dd HH:mm:ss} セッションがロックされました");
    }
    else if(e.Reason == SessionSwitchReason.SessionUnlock)
    {
        Console.WriteLine($"{DateTimeOffset.Now:yyyy/MM/dd HH:mm:ss} セッションが復帰しました");
    }
}
```

あとは [Microsoft のドキュメント](https://docs.microsoft.com/ja-jp/aspnet/core/signalr/dotnet-client?view=aspnetcore-2.1)を見ながら Hub を実装すれば OK です。サーバーでは状態の更新のために `SetStatus` を準備したので、

```cs
await this.Hub.InvokeAsync("SetStatus", status);
```

というように実装しておきます。
状態のコールバックは `Status` として用意しているので、

```cs
this.Hub.On<Status>("Status", s => this.Status = s);
```

などと書いておけば OK です。 `Status` は列挙型で、状態を表す値を入れています。

## こうなる
![image.png](https://qiita-image-store.s3.amazonaws.com/0/314249/54408d2e-7a36-95bc-6bb9-6edad5a452b2.png)

## 今後の展望
SignalR で実装しているので、ほかのデバイスとも連動して状態をリアルタイムで表示できるようにしたいです
今は PC がロックされたかどうかでしか見ていませんが、ほかの状態を検知して、完全自動化を目指したいです

お粗末様でした