iOSアプリの審査状況変更を、Slackに自動通知する(GAS + Slack Webhook)

iOSアプリの審査状況(ステータス)は、iOSアプリ開発に関わる方なら誰もが気になることですよね。
[AppStoreConnect](https://apps.apple.com/jp/app/app-store-connect/id1234793120)はリアルタイムで審査状況に関する通知を受け取ることができるため、こちらのアプリを使っている方は多いと思います。

今回は、Slackのチャンネルにも審査状況を伝えたい方のために、GASとSlack Webhookを使用して自動的に通知を行う方法を記載します。

# 仕組み
アプリの審査状況は、AppStoreConnectに登録してあるメールアドレスに対して、メールで通知させることができます。
また、Appleからのメールは定型文となっており、審査状況に応じてメールの件名が決まっています。
例.

- アプリ申請済み、審査待ち -> `[アプリ名], is now "Waiting For Review"`
- アプリ審査中 -> `[アプリ名], is now "In Review"`
- 配信準備完了 -> `[アプリ名], is now "Ready for Sale"`

今回は受信したメールを定期実行したGASで読み込んで、タイトルに応じて審査状況をSlackに通知する。という方法を選択しました。

# 実装
## 下準備
- Appleから、審査状況変更に関するメールを受け取る設定にする
- 審査状況の通知を送りたいSlackのチャンネルに、カスタムインテグレーションからIncoming Webhookを追加する

## スクリプト
こちらが、実際に作成したGASです。
このスクリプトをタイマーなどから定期実行させて、運用しています。

```javascript:appstoreconnect_notification.gs
// 下準備で追加した、Incoming WebhookのURL
var slackUrl = "https://hooks.slack.com/WEBHOOK_URL"
// Appleからのメールを受け取るアドレス
var selfAddress = "your-email@mail.com";
// 通知を送りたいアプリの名前
var appName = "YOUR APP NAME"

var defaultPayload = {
  "username": "AppStore Connect",
  "text": "アプリのステータスに変更がありました",
  "icon_emoji": ":apple:"
};

function clone(obj) { 
  if (null == obj || "object" != typeof obj) return obj; 
  var copy = obj.constructor(); 
  for (var attr in obj) { 
    if (obj.hasOwnProperty(attr)) copy[attr] = obj[attr]; 
  } 
  return copy; 
}

function getAttachment(subject) {
  var statuses = [
    {
      "token": "Prepare for Upload",
      "text": ":man_in_lotus_position: App Storeへのアップロード準備中です",
      "color": "#00BBAA"
    },
    {
      "token": "Processing for App Store",
      "text": ":globe_with_meridians: App Storeへのアップロードを実行中です...",
      "color": "#00BBAA"
    },
    {
      "token": "Waiting For Review",
      "text": ":hourglass_flowing_sand: アップロードが完了しました。レビュー待ちです",
      "color": "#00BBAA"
    },
    {
      "token": "Developer Rejected",
      "text": ":man-gesturing-no: アプリの申請を取り下げました",
      "color": "#FFBB44"
    },
    {
      "token": "In Review",
      "text": ":mag: レビューに入りました",
      "color": "#FFBB44"
    },
    {
      "token": "New message from App Review",
      "text": ":warning: レビューアプリに対して、Appleより連絡がありました",
      "color": "#FFBB44"
    },
    {
      "token": "Pending Developer Release",
      "text": ":100: レビューが通りました",
      "color": "#00BBAA"
    },
    {
      "token": "Ready for Sale",
      "text": ":tada: 公開処理が完了しました！ 反映までしばらくお待ちください",
      "color": "#00BBAA"
    },
  ];
  
  for (var status in statuses) {
    if (~subject.indexOf(statuses[status].token)) {
      return statuses[status];
    }
  }

  return null;
}

function checkAppName(message) {
  var title = message.getSubject();
  var regexp = new RegExp(appName);

  return (title.match(regexp) != null) ? true : false;
}

function makePayload(message) {
  var payload = clone(defaultPayload);
  
  var attachment = getAttachment(message.getSubject());
  if (!attachment) {
    return null
  }
  
  payload["attachments"] = [attachment];
  return payload;
}

function postSlack(payload) {
  var payloadStr = JSON.stringify(payload);
  var escapedStr = payloadStr.replace(/":"/g, "\"\:\"");
  Logger.log(escapedStr);
  
  var options = {
    'method': 'post',
    'contentType': 'Content-type: application/json; charset=utf-8',
    'payload': escapedStr
  }
  
  var response = UrlFetchApp.fetch(slackUrl, options) || false;

  var ret = true;
  if (!response || response.getResponseCode() != 200) {
    ret = false;
  }
  return {status: ret, code: response.getResponseCode(), response: response};
}

function postError(message) {
  GmailApp.sendEmail(selfAddress, "Slackへのポストに失敗しました", "スクリプトを確認してください。失敗メールの件名：" + message.getSubject());
}

function main() {
  var threads = GmailApp.search('from:no_reply@email.apple.com');
  var messages = GmailApp.getMessagesForThreads(threads);
  
  for (var i in messages) {
    for (var j in messages[i]) {
      var message = messages[i][j];
      if (!message || !checkAppName(message) || !message.isUnread()) {
        continue;
      }
      
      var payload = makePayload(message);
      if (!payload) {
        continue;
      }
      
      res = postSlack(payload);
      if (!res.status) {
        postError(message);
        continue;
      }
      
      message.markRead();
    } 
  }
}
```

## ポイント
処理の流れとしては、

1. `main()`内で、Appleから届いたメールのうち、審査状況の変更を通知したいアプリ名がタイトルに含まれる未読メールを検出。
2. `getAttachment()`内で、1で検出したメッセージのタイトルから、アプリの審査状況を判別し、`makePayload()`でSlackに通知するメッセージを作成。
3. 2で作成したメッセージを、`postSlack()`でWebhookを使ってSlackに通知。

となっています。

一度Slackに通知した内容はメールを既読にすることで、スクリプトが次回実行された際に同じ通知が送られることを防いでいます。
このため、Slackに通知する前にメールを既読にしてしまうと、正しい審査状況が通知されなくなります。:disappointed:


# 実行結果
こんな感じです:v_tone1::v_tone1::v_tone1:
![IMG_3238 2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/198029/4de61f33-9b5f-6ac6-8249-9abc552a0d92.jpeg)
![IMG_3239.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/198029/906524b9-e3c2-5acc-50d8-8f31c3368656.jpeg)
![IMG_3240.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/198029/cd6b49ae-5633-4108-192a-1465d67a8e64.jpeg)

# 最後に
今までは手動で「アプリリリースされました」といったメッセージを送っていたところが自動化されたことは、結構役に立ったんじゃないかなと思っています。
~~サービスに関わる全員が[AppStoreConnect](https://apps.apple.com/jp/app/app-store-connect/id1234793120)を見れば、このスクリプトは必要ないのでは…?~~