SlackBot.Apiを公開しました


アドベントカレンダーの季節ですが、この記事は特にアドベントカレンダーと関係がありません。
すみません。

こんにちは。
現在、弊社では社内の雑務、特に社内特有の事情などを新人さんなどにチュートリアルをしたり、その他便利に使えるようなSlackBotを業務とは別に時間を見つけて開発をしています。
仕事の関係もあり最近C#に触れられていないので、その練習という意味あいも大きいです。

https://github.com/yKimisaki/SlackBot
が、せっかくなので整えて公開することにしました。
今日はこれについてお話ししようと思います。

事の発端

「替えの備品どこ」

弊社まだ規模が小さいスタジオなので全員が開発者であり、その片手間に掃除や備品交換、ゴミ捨てなどを行っています。
しかし備品の場所などを逐一聞くのは作業の邪魔になったり、夜など人に聞けない場合もあり、非常に不便でした。
そこで社内特有の事情についてはSlackBotにやらせようとなりました。

コンセプト

非常に単純明快で、
・スラッシュコマンドは1つ
・スラッシュコマンドに続けて日本語で聞ける
・かわいい
の3点です。

最初の「スラッシュコマンドは1つ」ですが、さまざまな業種、サポートが必要な新人さん、海外からの方もいたりする場合、複数のスラッシュコマンドをみんなに使いこなしてもらうのは至難の業です。
なのでコマンドを分けるのではなく1つのコマンド+引数の形を作り、「/botにきいて」という風に縛ります。
1つにすることで迷うことなくスラッシュコマンドを使えるようになってくれます。

二つ目に「日本語で聞ける」も同じで、横文字対策です。

三つ目の可愛いは、せっかく人に聞き辛いのに聞きづらそうなBOTを作らない、ということです。
そこで弊社の「自主制作支援制度」で作った「バーチャル社員雇用計画」のキャラ、


の二人をバーチャル社員としてBOTの中の人になってもらいましょう。
ブラストエッジなので、ブラスとエッジです。
イラストは弊社イラストレータの新改ユキト先生ときみか先生に描いていただきました!
今回のコマンドはエッジちゃんに担当してもらいます。

実際の運用事例

社内の細かい事情を出せないので汎用的なもので申し訳ないんですが、
/bot 運行情報
/bot 天気予報
とかこんな感じで聞くと、

のように返ってきます。
他にも「運勢」や「近くのお店」、通にはわかる「2d6」などいろいろ気軽に聞けるようにというのを重視しています。

将来的には大人の事情で複数に散らばってしまっているWikiやナレッジベースやチャットから横断的にワードを検索したりみたいなのができればいいなぁとか思ったり。

SlackBot.Apiについて

今回はC#の練習も兼ねているので、
・ASP.NET Core 3.1
・C# 8
・Nullable
でやっています。
社内ではこれをmac miniに立てて、serveoあたりでつなげて運用しています。
また隙間時間で作っているので各コマンドの実装は結構な力技なのとログまわりは実際適当なので、実際に使う時はいい感じにしてください。

コマンドの実装

このライブラリにおいてコマンドというのは、「電車の遅延情報を返す」や「時刻を返す」「運勢を返す」など、特定の機能のことを言います。
このライブラリにおける機能の追加はCommandBaseを継承したCommandを追加していくことで行うことができます。
サンプルでCommandsフォルダの下にいくつか入れています。
基本的には以下の考えにのっとって、日本語を解釈して結果を返すようにしています。
例えばDateCommandだと、

DateCommand.cs
internal class DateCommand : CommandBase
{
    // 全体に返すか、聞いてきた人だけに返すかの設定
    public override bool IsBroadcast => false;

    // HelpCommandで使用される、このコマンドの説明
    public override (string, string) DefaultCommandAndHelpMessage => ("date", "日にちと曜日を表示");

    public DateCommand()
    {
        // どういう日本語に、どれぐらいの優先度で引っ掛けたいか。
        // ほかのコマンドで同じ単語で引っかかった場合、優先度順に選ばれる。
        RegisterKeyword(CommandPriority.Low, "何日");
        RegisterKeyword(CommandPriority.Low, "日付");
        RegisterKeyword(CommandPriority.Low, "曜日");
    }

    // 実際に頑張って結果を返す部分。
    // DefaultCommandまたはRegisterKeywordで選ばれたときしか呼ばれない。
    public override ValueTask<string> CreateOutputAsync(string user, string channel, string filteredKeyword, string rawWords)
    {
        if (rawWords.Contains("一昨日") || rawWords.Contains("おととい") || rawWords.Contains("おとつい"))
        {
            return new ValueTask<string>($"明日は{(DateTime.Now - TimeSpan.FromDays(1)).ToString("gyyyy年MM月dd日(dddd)")}。");
        }
        else if (rawWords.Contains("昨日") || rawWords.Contains("前日") || rawWords.Contains("きのう"))
        {
            return new ValueTask<string>($"明日は{(DateTime.Now - TimeSpan.FromDays(1)).ToString("gyyyy年MM月dd日(dddd)")}。");
        }
        else if (rawWords.Contains("明日") || rawWords.Contains("翌日") || rawWords.Contains("あした") || rawWords.Contains("あす"))
        {
            return new ValueTask<string>($"明日は{(DateTime.Now + TimeSpan.FromDays(1)).ToString("gyyyy年MM月dd日(dddd)")}。");
        }
        else if (rawWords.Contains("明後日") || rawWords.Contains("あさって"))
        {
            return new ValueTask<string>($"明後日は{(DateTime.Now + TimeSpan.FromDays(2)).ToString("gyyyy年MM月dd日(dddd)")}。");
        }
        else if (rawWords.Contains("明々後日") || rawWords.Contains("明々後日") || rawWords.Contains("しあさって"))
        {
            return new ValueTask<string>($"明々後日は{(DateTime.Now + TimeSpan.FromDays(3)).ToString("gyyyy年MM月dd日(dddd)")}。");
        }
        else
        {
            return new ValueTask<string>($"今日は{DateTime.Now.ToString("gyyyy年MM月dd日(dddd)")}。");
        }
    }
}

のようになっています。
CommandBaseを継承して、RegisterKeywordして、CreateOutputAsyncを作る。
これだけ!
面倒なところはCommandSelectorにマルナゲドン!

その他の機能

CommandBaseにGetPriorityというvirtualメソッドがあります。
これは「特定のワードに対して優先度を返す」というものです。
これはRollDiceCommandを見てもらうとわかるのですが、

RollDiceCommand.cs
public override CommandPriority GetPriority(string rawWords)
{
    var match = new Regex(@"^(?!\d+$)(([1-9]\d*)?[Dd]?[1-9]\d*( ?[+-] ?)?)+(?<![+-] ?)$").Match(rawWords);

    if (match.Success)
    {
        return CommandPriority.High;
    }

    return CommandPriority.None;
}

と、正しいダイスコードの場合は優先度をHighで返すということをしています。
これで1d1~99d99にさらに1d1+5などありとあらゆるコマンドをRegisterKeywordしなくて済みます。
基本はRegisterKeywordのほうが楽ですが、パターンがあったりする場合はこちらのGetPriorityを使ってください。

おわりに

いかがでしたか?

こういうものは実際使う人と使わない人はいるかと思うのですが、弊社は比較的使われているんじゃないかなと思います。
またこういうフレームワークがあるとコマンドの追加依頼とかにも(そこそこ)気軽に対応できて、オフィス周辺の天気を調べるコマンドだとOpenWeatherMapを使ったりとか、新しいAPIにも触れられて個人的には良かったかなと思っています。そこそこ。
あとControllerの部分を変えればSlack以外のチャットにも応用できると思います。

以上、会社でこういうのを作ったというお話でした。