dely Tech Blog

クラシル・TRILLを運営するdely株式会社の開発ブログです

Command Line Application BasedなSlack Botを作ってハッピーになろう

はじめに

こんにちは。クラシルのAndroidアプリチームのテックリードのうめもりです。

今回は、クラシルのAndroidチームで新しく作っているSlack Botをご紹介します。Command Line Application BasedなSlack Botになっている、というところが大きな特徴で、今までクラシルのSlack Botが抱えていた課題を解決するためにそのような構成のSlack Botを新しく作ることになりました。

本稿では、新しいSlack Botを作るに至った経緯や、Slack Botのどのような課題を解決するためにCommand Line Application Basedにしたのか、Command Line Application BasedなSlack Botを作るコツ、どういったところがハッピーなのかについてご紹介します。

経緯

クラシルでは、rebeccaというSlack Botを5年くらい運用してきました。

チームメンバーが増えていくにあたって、誰でも手軽にデバッグ版アプリをビルド出来るようにしたり、リリース作業をミスなく正しい手順で行えるようにしたり、デバッグ会をスムーズに進められるようにしたり、チームの要望をかなえるために多機能に進化してきました。

作った最初はとてもシンプルなコードでしたが、増築に次ぐ増築で今ではかなりコードベースも大きく、コード自体としても読みにくいものになっているというそもそもの課題はありますが、コード品質以前にSlack Botの特性から来るコードの複雑性があり、機能改善にもなかなか手を付けにくいような状態になってきていました。

分岐地獄

rebeccaはユーザーのメッセージを正規表現でパターンマッチングすることによって機能を提供するようにしていたのですが、

機能が増えるにしたがって分岐のif文がどんどん増えていき、結果的に分岐部分のコードはかなりカオスな状態になっていました。(そこに機能自体のコードも書いてあったから魔窟に…。)

自然言語による機能の呼び出しは呼び出す側もどういうメッセージを送ればいいか混乱しがちで、ちょっとした表記の揺れでうまく機能が呼び出せていない、というケースがちょこちょこ発生していました。

(本来はサンドボックスの 用意 と言わないと必要な機能が使えなかった図)

Interactive Components対応

さらに、rebeccaはSlackのInteractive Componentsの機能に対応しており、これもまたコードの複雑化に拍車をかけていました。実装側はメッセージ呼び出しの分岐と、Interactive Componentsの分岐の両方に対応する必要があったのです。

英語化の波

最近クラシルでは英語でのコミュニケーションがメインになるメンバーが参加してきており、そもそも日本語でしか使えないrebeccaはメッセージの内容やパターンマッチング用のコードを翻訳する必要があり、コードに大きく手を入れる必要が出てきていました。

そういった経緯もあり、今回Slack Botを作り直し、それらの課題に対応できる形に直すことになったのです。

新しいSlack Botの名前はチームと相談してkdroidにしました!シンプルで分かりやすい!

Slack BotはほぼCommand Line Application

Slack Botを作り直すにあたって、今後同じ轍を踏まないように大きな方針を決める必要がありました。その上で、5年間のbotの運用を経て、一つ気が付いたことがありました。

それは、Slack BotはほぼCommand Line Application ということです。

標準入力と標準出力

Command Line Applicationの基本的なインターフェースは、標準入力と標準出力です。標準入力経由でユーザーからの要求を受け取り、結果は標準出力で返すのが基本になります。Slack Botもほぼ同様の構造で、botを呼び出すためのSlackのメッセージが標準入力、botからのレスポンスが標準出力と考えることが出来ます。

エラーがあった時は標準出力

Command Line Applicationでエラーが発生した際は、標準出力にエラーを出力し、ユーザーにエラーを伝えます。Slack Botも、標準出力(Slackのメッセージ)経由でユーザーにエラーを伝えるのが好ましいです。

コマンドによる呼び出し

Command Line Applicationは、基本的にコマンドによって機能を呼び出すという構造になっています。コマンドによって細かな違いはあれど、基本的には次のような構造です。

$ (コマンド名) (機能名) (--オプション 値)* (パス)*

基本的なコマンド構造が決まっているので、ヘルプをちょっと見ればCommand Line Applicationの使い方はなんとなくわかるようになっています。Slack Botも標準入力(Slackのメッセージ)経由でコマンドを受け取りますが、コマンドの構造は特に決まっていないところが大きな違いです。(rebeccaは自然言語によるパターンマッチングで実現しています。)

呼んでみないと使い方がわからない

Command Line Applicationにしろ、Slack Botにしろ、呼んでみないと使い方がわからないという特徴があります。GUIのように機能を予めユーザーに明示するということがその特性上できないので、Command Line Applicationには基本的にヘルプ機能が付いており、どのように呼び出せばいいかわかるようになっています。同じようにSlack Botでもヘルプ機能を提供することが出来ます。Slack BotはInteractive Componentsを使ってGUIを実現する、というやり方があるという点はCommand Line Applicationとは違うポイントです。

ヘルプ機能にしろ、Interactive ComponentsによるGUIの実現にしろ、それぞれつらいポイントがあります。前者は標準化された仕組みが無いので、実装者がヘルプのフォーマットを構築するところからすべて作る必要があります。後者は一度に出来る選択肢を全て表示してしまうと表示が冗長になってしまう、という問題があったり、ステップバイステップで表示するにせよ、実装側が複雑になりがちという問題があります。

Command Line Application Basedに作り直してます

以上のような共通点があるということもあり、Slack BotをCommand Line Applicationという偉大な先人の肩に乗る形で作ることには大きなメリットがありそうだと判断し、現在Slack Botを作り直しています。元々クラシルのSlack BotはGo言語で作られていたのですが、Go言語にはCommand Line Applicationの作成を支援するライブラリが多い、ということもその理由の一つになりました。

今回はライブラリに Cobra を採用することにしました。Cobraにした理由は、

  • Go言語でCommand Line Applicationを作る際にかなりポピュラーな選択肢であること
  • 標準入力や標準出力を別の出力先に置き換えることが出来ること(Slack Botとして作るので必須でした)
  • ヘルプ機能を簡単に提供できること

が決め手になりました。

Command Line Application BasedなSlack Botを作るコツ

Command Line Application BasedなSlack Botを作るにあたって、いくつかコツを発見したのでご紹介します。

標準入力と標準出力をSlackとつなぎこむ

コマンドライン作成支援ライブラリのメリットを最大限生かすには、標準入力と標準出力をSlackにつなぎこむ必要があります。

標準入力

標準入力についてはSlackのメッセージから、コマンドを抽出して入力値として使うのが一番手っ取り早いです。今回はこのような実装になりました。(サンプル用にメソッドの内容を展開したりしてます)

pattern := regexp.MustCompile(fmt.Sprintf(`(?s)<@%s>\s*(.+?)\s*$`, botID))
matches := pattern.FindAllStringSubmatch(bodyText, -1)
var commandLine string
for _, groups := range matches {
    commandLine = groups[1]
    break
}

rootCmd := &cobra.Command{
    Use:   "kdroid",
    Short: "A bot for kurashiru-android ci",
    Long:  "kdroid is A bot for kurashiru-android ci.",
}
...

args, err := shellwords.Parse(commandLine)
if err != nil {
    return err
}
command := rootCmd.Root()
command.SetArgs(args)
...
command.ExecuteContext(ctx)

実際にSlackのメッセージをコマンドを抽出する過程は、2つの過程に分けられます。

  • 正規表現でメッセージの不要な部分を除去してコマンドが含まれている全文を抽出
  • シェルと同じように入力文字列をスペースで分割された配列に変換

入力文字列をスペースで分割する際には、実際にはエスケープ等を考慮したほうがよいでしょう。(文字列に含まれるスペース等で分割しないため)今回は go-shellwords を使わせていただきました。

標準出力

標準出力についてはちょっと工夫が必要です。cobraはio.Writer経由での出力を行うため、出力をリダイレクトしてSlackにメッセージとして投稿する必要があります。

この要件に対応するために、次のような関数を作りました。

func ProcessEachParagraph(reader io.Reader, doOnParagraph func(string)) *sync.WaitGroup {
    wg := sync.WaitGroup{}
    wg.Add(1)
    go func() {
        _ = readEachParagraph(reader, doOnParagraph)
        wg.Done()
    }()
    return &wg
}

func readEachParagraph(reader io.Reader, doOnParagraph func(string)) error {
    bufReader := bufio.NewReader(reader)
    var lines []string
    for {
        bytes, err := bufReader.ReadBytes('\n')
        if err != nil {
            if len(bytes) > 0 {
                lines = append(lines, strings.TrimRight(string(bytes), "\n"))
            }
            doOnParagraph(strings.Join(lines, "\n"))
            if err == io.EOF || err == io.ErrClosedPipe {
                break
            }
            return err
        }
        line := string(bytes)
        if line == "\n" {
            doOnParagraph(strings.Join(lines, "\n"))
            lines = []string{}
        } else {
            lines = append(lines, strings.TrimRight(line, "\n"))
        }
    }
    return nil
}

この関数は、io.Readerから一行ずつ読み出し、空の行かEOFがあったらそれまでの出力をまとめてコールバックする仕組みになっています。sync.WaitGroupを返すことで、呼び出し先で読み出しの終了を待つことが出来ます。

reader, writer := io.Pipe()
...
aWg := asyncreader.ProcessEachParagraph(reader, func(text string) {
    if strings.TrimSpace(text) != "" {
        _, _, err := slackClient.PostMessage(ctx, slackChannelID, slack.WithMessage(text), slack.WithThreadTs(threadTs))
        if err != nil {
            return err
        }
    }
})
...
rootCmd := &cobra.Command{
    Use:   "kdroid",
    Short: "A bot for kurashiru-android ci",
    Long:  "kdroid is A bot for kurashiru-android ci.",
}
...

command := rootCmd.Root()
...
command.SetOut(writer)
command.SetErr(write)
...
command.ExecuteContext(ctx)

io.Writerとio.Readerを繋ぐのにPipeを使います。こうすることによって、cobraのヘルプやエラー出力を含め、標準出力をslackにリダイレクトすることが出来るようになりました。

デフォルトの出力先は元メッセージのスレッドに

これは好みの問題もありますが、デフォルトのSlack Botのメッセージの出力先は元メッセージのスレッドにしています。 特にリプライなど付けなくてもタスク完了通知やエラー完了通知をコマンド送信者に送れますし、一連の操作をスレッドにすることで操作の文脈を分かりやすくすることもできます。

Interactive ComponentsからのActionも、コマンドとして解釈する

新Slack Botでは、Interactive ComponentsからのActionもコマンドとして解釈する仕組みを入れています。

var callback slackLib.InteractionCallback
if err := json.Unmarshal([]byte(payload), &callback); err != nil {
    return err
}
switch callback.Type {
case slackLib.InteractionTypeBlockActions:
    if len(callback.ActionCallback.BlockActions) < 1 {
        return xerrors.Errorf("found no action. just ignore.")
    }
    commandText := callback.ActionCallback.BlockActions[0].Value
    ...
    if err != nil {
        return err
    }
    command := rootCmd.Root()
    command.SetArgs(args)
}

Slack Blocksの生成側では、単純にvalueに実行するコマンドを設定するだけです。これにより、コマンドを追加するだけでSlack Botに機能追加できる仕組みを実現しています。

// 実際のコードでは簡略化するためにwrapperを介していますが、基本的にはSlack Block Kitのオブジェクトを組み立てているだけです。
messageSender.SendToThread(
    slack.WithBlocks(
        slack.ActionBlock(
            "block1",
            slack.BasicButton("action1", "Regular", "confirm_pull_requests --dry-run"),
            slack.BasicButton("action2", "Hot fix", "start_hotfix --dry-run"),
        ),
    ),
)

異常時のリトライも簡単に

Interactive ComponentsからのActionとメッセージによる呼び出しのやり方を同一のフォーマットのコマンド呼び出しに統一することで、異常発生時のリトライも簡単になります。例えば、一連のフローのActionの処理後にコマンドによる手動呼び出しのやり方を返しておけば、任意の場所からの再開も容易になります。

Command Line Application BasedなSlack Botここがハッピー

ヘルプを作るのが簡単

func New(
    awsClient aws.Client,
    githubClient github.Client,
) *cobra.Command {
    var buildTypeName string
    var debugApiURL string
    var debugApiHosts string

    cmd := &cobra.Command{
        Use:   "build [branch | ref]",
        Short: "Build android app",
        RunE: func(cmd *cobra.Command, args []string) error {
            if len(args) == 0 {
                _ = cmd.Help()
                return nil
            }
            // do command
            ...
            return nil
        },
    }

    cmd.Flags().StringVarP(&buildTypeName, "type", "t", "sandbox", "build type [sandbox | googleplay]")
    cmd.Flags().StringVarP(&debugApiURL, "apiurl", "u", "", "override api url for debug")
    cmd.Flags().StringVarP(&debugApiHosts, "apihosts", "o", "", "override api hosts for debug")

    return cmd
}

このコードはアプリをビルドするためのAWS CodeBuildを呼び出すためのコードの一部ですが、cobraを使うとこれだけで次のようなヘルプテキストが生成されます。

Build android app

Usage:
  kdroid build [branch | ref] [flags]

Flags:
  -o, --apihosts string   override api hosts for debug
  -u, --apiurl string     override api url for debug
  -h, --help              help for build
  -t, --type string       build type [sandbox | googleplay] (default "sandbox")

実装に沿う形でのヘルプを保守し続けるのは面倒なことですが、実際のコードにいくつかヘルプ用の実装を追加するだけで、このようなヘルプが生成されるのは非常に便利で、これだけでも Command Line Application Based にする価値があります。

機能拡張したい時はコマンドを増やすだけ

現在は原則としてcobraのユーザーガイド に沿う形でコマンドを作っています。

ユーザーガイドのパッケージ構成の例

├── cmd
│   ├── root.go
│   └── sub1
│       ├── sub1.go
│       └── sub2
│           ├── leafA.go
│           ├── leafB.go
│           └── sub2.go
└── main.go

このような作り方をしておくメリットは、機能を増やすときにパッケージやインターフェースに明確なルールがあるため、コードが散らかりにくくなることです。

とかく開発用のツールは無計画に機能を増やしがちでコードが散らかりがちですが、このような整理の仕方をしておけば機能を追加するときも削除するときも、非常にやりやすいです。Interactive Componentsを使う場合も、同様にコマンドを呼び出すように作るだけなので、コードが散らかりにくいというメリットが享受できます。

Interactive Componentsも活用しやすい!

通常Interactive Componentsを使うと、実際にBlock Kitを使ってボタン等をSlackに表示しないとデバッグやトラブルシューティングがやりにくくなってしまいがちです。ですが、Command Line Application Basedで作ったことにより同様の機能がいつでも手動で呼び出せるため、Interactive Componentsの活用もしやすくなっています。

終わりに

クラシルのAndroidチームではこのような技術も活用しながら、生産性高くアプリを開発できるような努力をしています。 今回の記事を読んで興味を持った方いらっしゃいましたら是非一度お話ししましょう!以下のリンクから、いつでもカジュアル面談の申し込みをお待ちしてます!

dely.jp