25
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2018-12-03

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

Olkar

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

サーバー構成

サーバーは ASP.NET Core + SignalR + C# で作っていて、OS は Debian Stretch です。
構成図
クライアントとサーバーとの間で SignalR によるリアルタイム通信が実装されています。

SignalR での実装

何も考えずに ASP.NET Core Web アプリケーションとしてプロジェクト作りました
image.png
SignalR での実装に参考にしたのは Microsoft の公式ドキュメントですが、チュートリアルでは Web ページでも動作するための準備も含まれていて、今回はページとしてリアルタイムで何か見ることはしないので、unpkg での SignalR クライアント ライブラリの追加は省略します。

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

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 でできます。

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

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

SignalR を有効にするため、Startup.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 の実装

めっちゃめんどくさかった
実装には この記事がいろいろ参考になりました

Slack に対してお伝えするためのルーティングは、「属性ルーティング」と呼ばれる仕組みで実装しています。

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

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");
    }
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
今後はこのアドレスに対していろいろとメッセージが降ってくるようになります。

さきほどの SlackRequest メソッドにたくさんのメッセージがくる中、typeevent_callback になっているものに対して処理を掛けます。
BOT に対してのメンションが取りたいので、Request URL を入れたページの下の方にある「Subscribe to Bot Events」で、メンションを受け取るための app_mention を有効にしておきます。DM でも何か欲しい場合は message.im も有効にしておくとよいでしょう。
image.png

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

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" は排除します。

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 すれば OK です。「OAuth & Permissions」で拾ってきた BOT 用 OAuth Token をパラメーターに入れておくことで BOT の投稿として処理されます。
ユーザーへの返事は <@USER0123> のように、ユーザー名ではなく専用のユーザー ID に置き換えますが、その辺は処理するときに受け取った情報でどうにかなるのであまり気にしなくていいでしょう。

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 イベントでそれを拾うことができます。

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 のドキュメントを見ながら Hub を実装すれば OK です。サーバーでは状態の更新のために SetStatus を準備したので、

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

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

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

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

こうなる

image.png

今後の展望

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

お粗末様でした

25
10
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
25
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?