Slack×Swift×Fastlane：iOSアプリ配信ボット

この記事は [Slack Advent Calendar 2016 - Qiita](http://qiita.com/advent-calendar/2016/slack) の6日目の記事です。

昨日は [S_Shimotori](http://qiita.com/S_Shimotori) さんの [HubotとTypeScriptで翻訳Slack bot](http://qiita.com/S_Shimotori/items/3da73b05bfdc3c582995)でした。

Hi, I'm John! 今日は[エウレカ](https://developers.eure.jp/)のiOS開発チームが使っている便利なボットを紹介したいです！Swiftで書かれて、Swiftをビルドするボット、 `porygon` と呼んでる。

このボットには、Slackでこういうメッセージ（コマンド）を送ると：

```
@porygon deploy jp-fabric-staging
```
<img width="343" alt="porygon_deploy.gif" src="https://qiita-image-store.s3.amazonaws.com/0/72816/608533ca-6c9f-de42-8f9a-b6f15ff3dfd1.gif">


XcodeのコンパイルからiTunesConnectやCrashlyticsまでデプロイしてくれるボットです！リアルタイムの報告もできる：
<img width="343" alt="porygon_done.png" src="https://qiita-image-store.s3.amazonaws.com/0/72816/bf3ecd16-621f-7092-a407-8627ee2dfaca.png">

ビルドはエラーがあった場合、ちゃんとログも送ってくれる：
<img width="665" alt="porygon_failed.png" src="https://qiita-image-store.s3.amazonaws.com/0/72816/dea6c556-e8f4-2ffe-83b0-7c2e6e80a0e5.png">

そしてporygonの特徴は、ローカルのMacでも実行できる！
porygonみたいなボットが作りたくなってきたかな？


## Technologies Used
*Porygon is powered by* といえば、

### 1. Xcode 8：iOS＋Swift 3アプリのビルド
<img width="128" alt="Xcode.png" src="https://qiita-image-store.s3.amazonaws.com/0/72816/b094019e-d6af-7df1-ace5-b933fd859941.png">


### 2. Vapor：サーバーサイドSwiftフレームワーク
<img width="250" alt="Vapor.png" src="https://qiita-image-store.s3.amazonaws.com/0/72816/9d28e85c-b942-35e5-fa9b-3d2be5bcc745.png">
[GitHub - vapor/vapor: A web framework and server for Swift that works on macOS and Ubuntu.](https://github.com/vapor/vapor)

### 3. Fabric（特にBeta）：ベータテスト用アプリの配信に便利
<img width="150" alt="Fabric.png" src="https://qiita-image-store.s3.amazonaws.com/0/72816/627343a0-ef0f-277e-f0dd-c8c572867485.png">
[Fabric - Twitter's Mobile Development Platform](https://get.fabric.io/)

### 4. Fastlane：デプロイの作業など、全てスクリプト化されてる
<img width="200" alt="Fastlane.png" src="https://qiita-image-store.s3.amazonaws.com/0/72816/64fc8498-5aa8-ac79-a2c2-c42440740adf.png">
[fastlane - iOS and Android Automation for Continuous Delivery](https://fastlane.tools/)

### 5. そして、もちろん、Slack
<img width="150" alt="Slack.png" src="https://qiita-image-store.s3.amazonaws.com/0/72816/bc19fd19-dc92-11a2-65c7-97aeb104197e.png">
[Slack: Be less busy](https://slack.com/)


## Architecture
実はVaporの会社はSlackbotのテンプレートフレームワークまで作ってくれた：
[GitHub - vapor/slack-bot](https://github.com/vapor/slack-bot)

このボットは基本的に[SlackのRTM API](https://api.slack.com/rtm)（Websocket）で動いています。ボットトークンの設定などはテンプレートプロジェクトを参考すればすぐ分かると思います。
（slack botの初期化方法は[こちらご参考まで！](http://blog.kaneshin.co/entry/2016/12/03/162653) ）

さて、これで全てのツールが揃いましたので、どう連携しているのか紹介します！
<img width="650" alt="Architecture.png" src="https://qiita-image-store.s3.amazonaws.com/0/72816/1ae0195b-aeea-5ddd-78f3-86a609bef519.png">

### ① SlackのメッセージをWebsocketで受信する
参考：https://api.slack.com/rtm
返ってきているJSONからは、`text`項目をパースする。まず、ボットのメンションがないと無視するべきなので、そのバリデーションを行う。例えば：

```
@porygon deploy appstore-staging
```
メッセージを送ると、 `@porygon` の部分のメンションは実際のJSONにはこういう形になる：

```json
{
    // ...
    "channel": "C12345678",
    "text": "<@U12345678> deploy appstore-staging",
    // ...
}
```

`C12345678`は返事を送るチャンネルのIDです。これを保持してレスポンスを送るときに使う。

`U12345678`はボットのIDです。そのIDが見つかるとコマンドをパースして、自由にハンドリングする。私たちの場合、 `deploy <Xcode scheme名>` が見つかったら **Fastlane** のシェルスクリプトをコールしている。

### ② FastlaneコマンドをSwiftコードから実行する
参考：https://fastlane.tools/
Swiftからシェルコマンドを実行するには、[`Process (NSProcess)`](https://developer.apple.com/reference/foundation/process)を使用する。
シェルのoutput streamは[`Pipe (NSPipe)`](https://developer.apple.com/reference/foundation/pipe)で取得できる。

先の例を続けると、`deploy appstore-staging`の文字列が見つかったら、

```swift
let task = Process()
// ...
task.launchPath = "/bin/bash"
task.arguments = ["-l", "-c", "fastlane deploy_appstore_staging"]

// ...

task.launch()
task.waitUntilExit()
```

を処理している。`fastlane` コマンドで `deploy_appstore_staging` という*lane*を実行している。Fastlaneの*lane*というのは、デプロイするときの全てのタスクを実施できるrubyスクリプトです。[**Fastfile**の作り方にはこちらをご参考ください。](http://qiita.com/gin0606/items/162d756dfda7b84e97d4)

Fastfileが出来たら、

- Xcodeプロジェクトのビルド
- Code Sign
- Xcode Archive
- iTunesConnectへのアップロード又はFabricへのアップロード

を一発で `fastlane <lane名>` コマンドでデプロイできる。

**重要**
**fastlaneの実行はバックグラウンドスレッドで行わないと、ボットが動くなかったりwebsocketがタイムアウトしたりしてしまう**ので、`Process`をコールするメソッドは必ずバックグラウンドスレッドで実行してください！[Grand Central DispatchのSerial Queue](https://developer.apple.com/reference/dispatch/dispatchqueue/2016098-async)はおすすめです。

### ③ & ④ FastlaneがXcodeプロジェクトをビルドして、出来上がったバイナリをiTunesConnectやFabricへアップロードする
これはfastlaneが全てやってくれる！

が、もちろんビルドの進捗・結果をslackbotに教えて欲しいですよね。そのために先ほどの `Process`の初期化には、[`Pipe (NSPipe)`](https://developer.apple.com/reference/foundation/pipe)のオブサーバーを実装して、シェルのoutput文字列を全てパースする：

```swift
let task = Process()
// ...
task.arguments = ["-l", "-c", "fastlane deploy_appstore_staging"]

// ...
let pipe = Pipe()
task.standardOutput = pipe
task.standardError = pipe
        
let readHandle = pipe.fileHandleForReading
readHandle.waitForDataInBackgroundAndNotify()

var logObserver: NSObjectProtocol?
logObserver = NotificationCenter.default.addObserver(
    forName: NSNotification.Name.NSFileHandleDataAvailable,
    object: readHandle,
    queue: nil,
    using: { (notification) -> Void in
        
        let data = readHandle.availableData
        if data.count > 0 {
            
            if let string = String(data: data, encoding: .utf8) {
                
                // stringはシェルの１行出力の文字列になります
                print("[log] \(string)", terminator: "")
                didReceiveLogString(string, channel: channel) // 後ほど説明する
            }
            readHandle.waitForDataInBackgroundAndNotify()
        }
        else {
            
            print("[log] EOF on stdout from process")
            NotificationCenter.default.removeObserver(logObserver)
        }
    }
)

task.launch()
task.waitUntilExit()
```

`didReceiveLogString()` は出力からの文字列をパースするメソッドですが、進捗をどうすれば判定する？と思っているでしょう。そのためには、**Fastfile**の設定に戻りましょう。私たちの場合は、このようなコードをFastfileに入れている：

```ruby
lane :deploy_appstore_staging do

    # ...
    bump_build
    
    puts "--- Hook: Xcodeでコンパイル中..."
    ipa_path = gym(scheme: scheme,
                   output_name: scheme,
                   export_team_id: team_id,
                   use_legacy_build_api: true,
                   include_bitcode: true,
                   include_symbols: true)
                   
    puts "--- Hook: Betaへアップロード中..."
    crashlytics(ipa_path: ipa_path)
    upload_symbols_to_crashlytics

    puts "--- Hook: appstore-stagingの配布が完了しました！"
end
```

そう、コンソールへわざと`puts`（プリント）している！その文字列には `--- Hook:` みたいなプレフィックスをつけて、そのあとの文字列をslackbotがパースするべき、という仕組みです。

先ほどの`didReceiveLogString()`メソッドに戻ると、

```swift
func didReceiveLogString(_ string: String, channel: String) {
    guard let range = string.range(of: "--- Hook:") else{
        return // 定義したプレフィックスがないと無視する
    }
    let response = string.substring(from: range.lowerBound)
    // responseをchannelへSlackのWeb APIで送る
}
```

`response`をSlackの[RTM APIのwebsocket](https://api.slack.com/rtm)ではなく、[Web API](https://api.slack.com/web)でPOSTする。理由は、websocketのAPIでは送信済みのメッセージを変更できないからです。それと違くてWeb APIでは、

- `Deploying...` を [chat.postMessage](https://api.slack.com/methods/chat.postMessage)で送って、タイムスタンプを保存する
- 同じタイムスタンプを[chat.update](https://api.slack.com/methods/chat.update)のパラメータで使って、`Xcodeでコンパイル中...` → `Betaへアップロード中...` → `appstore-stagingの配布が完了しました！` に上書きできる。

私たちの場合は、[Slackのカスタム絵文字](https://get.slack.help/hc/en-us/articles/206870317-Emoji-reactions)を使って、この感じで表示しています：
<img width="343" alt="porygon_done.png" src="https://qiita-image-store.s3.amazonaws.com/0/72816/bf3ecd16-621f-7092-a407-8627ee2dfaca.png">

### ⑤ & ⑥ ビルドとアップロードの成功・失敗を判定してボットで報告する
エラーが発生する場合は `--- Hook:` みたいなアウトプットはもちろん出せないです。失敗した時ログが知りたいので、それもファイルに保存してみましょう：

```swift
// ...
let pipe = Pipe()
// ...

// ログファイルを作る
FileManager.default.createFile(atPath: outputFilePath, contents: nil, attributes: nil)
let writeHandle = FileHandle(forWritingAtPath: outputFilePath)

var logObserver: NSObjectProtocol?
logObserver = NotificationCenter.default.addObserver(
    forName: NSNotification.Name.NSFileHandleDataAvailable,
    object: readHandle,
    queue: nil,
    using: { (notification) -> Void in
        
        let data = readHandle.availableData
        if data.count > 0 {
            
            writeHandle?.write(data) // アウトプットをログファイルに保存
            // ...
        }
        else {
            
            // ...
        }
    }
)
var exitObserver: NSObjectProtocol?
exitObserver = NotificationCenter.default.addObserver(
    forName: Process.didTerminateNotification,
    object: task,
    queue: nil,
    using: { (notification) -> Void in
        
        writeHandle?.closeFile()
        NotificationCenter.default.removeObserver(exitObserver)
    }
)
task.launch()
task.waitUntilExit()
```

まず、シェル出力をパースしているところには `writeHandle?.write(data)` でファイルに書き込む。それで `Process.didTerminateNotification` の通知をオブサーブして、 `writeHandle?.closeFile()` でファイルを閉じる。

`fastlane`のスクリプトが成功したか失敗したかは `task.waitUntilExit()` が終わった後判別できる：

```swift
// ...
task.launch()
task.waitUntilExit()

if task.terminationStatus == 0 {
    // ... 成功
}
else {
    // ... 失敗
    sendLogFile(outputFilePath, channel: channel)
}
```

成功した場合は[chat.update](https://api.slack.com/methods/chat.update)で報告する。
<img width="343" alt="porygon_done.png" src="https://qiita-image-store.s3.amazonaws.com/0/72816/bf3ecd16-621f-7092-a407-8627ee2dfaca.png">

失敗した場合は、ログファイルを[files.upload](https://api.slack.com/methods/files.upload) APIでアップロードする：
<img width="665" alt="porygon_failed.png" src="https://qiita-image-store.s3.amazonaws.com/0/72816/dea6c556-e8f4-2ffe-83b0-7c2e6e80a0e5.png">


## Bonus Features
Fastlaneで様々な便利な機能があるので、Slackbotに連携して是非実施してみてください！

- AppStoreの審査状況をSlackbotに教えてもらう
- gitブランチを選択できるようにする
- debugサーバーかproductionサーバーかパラメータ化する
- アプリのデプロイ先はFastlaneかiTunesConnectかパラメータ化する