Gmail + Google Apps Script + Slack APIを連携して、アプリストアからの通知メールを監視する

# 動機
あるとき、アプリの申請後、リジェクトされていることに気づかずに、
忙しいことも相まって、しばらく通知メールに気づかないということがありました

最近の弊社では、各プロジェクトの状況報告はSlackに集約されています
アプリがリジェクトされた場合は、早急にストアの設定の見直しや、
アプリの修正を行いたいので、定期的にSlackに通知を行うことで、
開発者が迅速に対応できるようなフローを構築しました

# 目標
Slack APIをGoogle Apps Script(以下GAS)から使用し、Gmailの内容から
アプリのストアへの申請ステータスがどうなっているのかを監視する

# 手順
## 1. SlackのAPIが使用できるようにセットアップする
以下の記事を書いたので、こちらをご覧ください

https://qiita.com/kobayashi_ryo/items/a194e620b49edad27364

## 2. GASを作成する
まず、取得したいGmailのアカウントにログインします

その後、Googleドライブに移動し、
「+ 新規」 -> 「その他」 -> 「Google Apps Script」
を選択し、スクリプトを作成します
![スクリーンショット 2023-03-24 11.47.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123957/3a2a1222-c4f2-bf17-a027-95e84719a010.png)

作成すると以下のようにエディター画面になります
![スクリーンショット 2023-03-24 11.49.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123957/faee0b41-2532-5eb7-3c9b-a063aaa400c5.png)

ひとまず、以下のようにソースを整えておきましょう
```javascript
// SlackトークンやチャンネルIDなどの定数一覧
const slackOAuthToken = "..." // 前回の記事で取得したOAuthトークン

// エントリーポイント
function main() {
}
```

そうしたらプロジェクトを保存しましょう

![スクリーンショット 2023-03-24 12.32.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123957/9fa8ca1d-1b57-8785-6987-d5550e56c707.png)

今後は都度、自己判断で「プロジェクトを保存」を行なってください

## 3. Gmailの内容を取得する
GASでは、ログインしているGoogleのアカウントの認証トークンは特に必要なく、
GmailやスプレッドシートのAPIが組み込みで提供されています
まずは、Gmailからメール内容を取得しましょう

```javascript
// main関数内
// メールのスレッドを取得する
const threads = GmailApp.search(
    `in: Inbox is:Unread`, // メールの状態(インボックスにある、未読)
    0, // 何番目のスレッドから返してくるか
    25 // 返されるスレッドの最大数
).reverse() // 古い順から処理したいので配列を反転させる

// 各スレッドに対して処理を実行していく
threads.forEach((thread) => {
    // スレッド内の各メッセージに対して処理を実行していく
    thread.getMessages().forEach((message) => {
        // メッセージに対する処理
        console.log(`From: ${message.getFrom()}, Subject: ${message.getSubject()}`)
    })
})
```

実行対象の関数をmainに設定し、「実行」(または「デバッグ」)をクリックします
![スクリーンショット 2023-03-24 12.54.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123957/aa9043d5-a330-2f0a-fea8-859fbdb73c2a.png)

初回実行時は以下のように権限確認が求められるので、確認します
![スクリーンショット 2023-03-24 13.04.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123957/e349392b-f9bb-ce6f-539c-707824f166c5.png)

連携したいGmailのアカウントを選択します
![スクリーンショット 2023-03-24 13.07.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123957/b60652b9-8aab-1bf8-db7c-d7ac99345f31.png)

警告画面が出るので、「詳細」 -> 「[スクリプト名]（安全ではないページ）に移動」をクリックします
![スクリーンショット 2023-03-24 13.04.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123957/d751de79-bce5-1608-2c67-cbe53ede3c49.png)
![スクリーンショット 2023-03-24 13.08.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123957/c3e194f2-79d4-72db-04fa-e55026f0fa72.png)

認証画面が出るので「許可」をクリックします
![スクリーンショット 2023-03-24 13.12.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123957/38f5cda2-7057-eab2-6f0c-08435a88b536.png)

再度GASを実行すると、実行ログに以下のようなメッセージが表示されると思います
![スクリーンショット 2023-03-24 13.15.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123957/91817cad-f129-cd88-f0be-7ba4fcd81340.png)

これでGASとGmailを連携できるようになりました

## 4. メール内容から投稿するメッセージを作成する
それでは次に、メールの内容から、Slackに投稿するメッセージの作成を行います
各プラットフォームでの申請ステータスの判定は以下のようになります

- Android (Google Play Console)
    - 送信元の文字列に`noreply-play-developer-console`を含む
        - ステータス
            - 公開完了
    - 送信元の文字列に`no-reply-googleplay-developer`を含む
        - ステータス
            - 否承認
- iOS (App Store Connect)
    - 送信元の文字列に`App Store Connect`を含む
        - ステータス
            - 申請の承認
            - 申請の却下
            - 公開完了
            - 手動リリース待ち
            - 申請の取り下げ
- Amazon (Amazon開発者ポータル)
    - 送信元の文字列に`no-reply-appdev`を含む
        - ステータス
            - 公開完了
            - 申請却下

上記の判定からメッセージを作成します。Androidの例を見てみましょう

```javascript
// main関数 start
...
// Google Developer Consoleからのメール
if(is_google_play_developer_console(message.getFrom())) {

  console.log(`Google Play Store: ${message.getFrom()} Subject: ${message.getSubject()}`)
  // 内容をパースして、メッセージ内容を作成
  json = process_google_play_store(message)

}
...
// main関数 end

// メールアドレスの送信元がGoogle Play Consoleかどうか
function is_google_play_developer_console(fromAddess) {
  return fromAddess.includes("noreply-play-developer-console")
}

// Google Play Storeからのメールの内容をパースする
function process_google_play_store(message) {
  const preTitle = "Google Play Store"
  if (message.getSubject() == "Your update is live") {
    // 本文からアプリの名前を抽出
    const appName = get_matched_string(message.getBody(), 'Your update to (.+?), created')
    return create_success_message({
      pretext: preTitle,
      title: `${appName}がストアにリリースされました。お疲れ様です。`,
      storeIconUrl: playStoreIconUrl,
    })
  }
  return null
}

```

作成するメッセージの内容は、最終的に以下の関数で作成されます

```javascript
// Slack APIにリクエストするメッセージを作成する
function create_message({
  channelId,
  color,
  pretext,
  title,
  text,
  storeIconUrl,
} = {}) {
  return JSON.stringify({
    "channel": channelId || defaultChannel, // 投稿先チャンネルID
    "attachments": [
      {
        "mrkdwn_in": ["text"],
        "color": color || "", // good:緑, danger:赤 など
        "pretext": pretext || "", // ストア名
        "title": title || "", // ステータス ex) {アプリ名}の申請が却下されました。
        "text": text || "<!channel>", // 任意(無ければ@channelになる)
        "thumb_url": storeIconUrl || "", // ストアのアイコンのURL
        "ts": new Date().getTime(), // 現在時刻
      }
    ]
  })
}
```

## 5. Slackにメッセージを投稿する
まず、Slackを開いて、投稿したいチャンネルを選択します
![スクリーンショット 2023-03-24 11.20.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123957/c36fe7b7-5e06-d3f5-c2bb-df5dc5e5282c.png)

チャンネルページの左上のチャンネル情報を開きます
![スクリーンショット 2023-03-24 11.20.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123957/550f5712-904a-8f1a-4648-5d82aaaaa960.png)

「チャンネル情報」タブの下に、チャンネルIDがあるので、それをコピーしてください
![スクリーンショット 2023-03-24 11.21.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123957/008bbfb0-d900-b18a-94a6-4e2341da9a4b.png)

先頭の定数一覧に設定しておきましょう
```javascript
const slackToken = "..."
const defaultChannel = "..." // <- ここ
```

前項で作成したメッセージ(JSON文字列)をpayloadに設定して、APIを呼び出します
```javascript
function post_message_to_slack(json) {
  const method = "chat.postMessage"
  callWebApi(slackToken, method, json)
}

function callWebApi(token, apiMethod, payload) {
  const response = UrlFetchApp.fetch(
    `https://www.slack.com/api/${apiMethod}`,
    {
      method: "post",
      contentType: "application/json; charset=utf-8",
      headers: { "Authorization": `Bearer ${token}` },
      payload: payload,
    }
  );
  // デバッグログを出しておくと、確認するときに便利です
  console.log(`Web API (${apiMethod}) response: ${response}`)
  return response;
}
```

## 6. 定期実行の設定をする
左のナビゲーション内の「トリガー」をクリックしてください
![スクリーンショット 2023-03-24 17.16.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123957/90c40cd6-da77-dd2c-f03f-c442a7487fb8.png)

画面右下の「+ トリガーを追加」をクリックしてください
![スクリーンショット 2023-03-24 17.17.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123957/ec097300-da4b-bd2c-5ecc-141f12cdd66e.png)

各パラメータを設定します。状況に合わせて実行する間隔を設定してください
設定が終わったら「保存」をクリックします
![スクリーンショット 2023-03-24 17.20.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123957/1729b0ae-7405-fe3d-6a28-2239721665e1.png)

実行されているかどうかは左のナビゲーションの「実行数」から確認できます

## 7. 問題なく送れているか確認する
APIが成功していると、Slackでは以下のように表示されます
![スクリーンショット 2023-03-24 17.29.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123957/54e93b0c-831a-e07a-9809-032af2505a74.png)

これで全ての手順は完了しました

# 課題点
現状、以下の課題があります。今後徐々に解決していく予定です
- アプリ名だけでなく、バージョン情報も取得したい
- Google Play Storeの通知から取れる情報が少ない
    - これに関しては、アプリのステータスを取得できるAPIがあるので、他のプラットフォームとは分けて作る
- Slack APIでattachmentsを使う実装はlegacyになっている
    - 新しい形式に対応したい
- 現在バージョン管理を行なっていない
    - Gitで管理して、PRがmainにマージされたらデプロイ、のようなワークフローを組めると良い

# おわりに
ここまでお疲れ様でした
個人的には、当初予想したよりも、複雑な実装は少なく済んだ印象です
これを読んだ方も、仕事やコミュニティ運営でSlackを使うようなことがあれば
参考にしていただけたらと思います

# おまけ
現在運用中のコードの全体像は以下になります
<details>
  <summary>コード全体</summary>
  <div>

```javascript:main.gs
function main() {
  const threads = GmailApp.search('in: Inbox is:Unread', 0, 25).reverse()

  threads.forEach((thread) => {
    thread.getMessages().forEach((message) => {
      
      var json = null
      if (is_app_store_connect(message.getFrom())) {

        console.log(`App Store Connect: ${message.getFrom()} Subject: ${message.getSubject()}`)
        json = process_app_store_connect(message)

      } else if(is_google_play_developer_console(message.getFrom())) {

        console.log(`Google Play Store: ${message.getFrom()} Subject: ${message.getSubject()}`)
        json = process_google_play_store(message)

      } else if(is_google_play_developer(message.getFrom())) {

        console.log(`Google Play Developer: ${message.getFrom()} Subject: ${message.getSubject()}`)
        json = process_google_play_developer(message)

      } else if (is_amazon_app_store(message.getFrom())) {

        console.log(`Amazon App Store: ${message.getFrom()} Subject: ${message.getSubject()}`)
        json = process_amazon_app_store(message)

      }

      if (json != null) {
        post_message_to_slack(json)
        message.markRead()
      }

    })
  })
}
```

```javascript:slack.gs
const slackToken = "..."
const defaultChannel = "..."

function post_message_to_slack(json) {
  const method = "chat.postMessage"
  callWebApi(slackToken, method, json)
}

function callWebApi(token, apiMethod, payload) {
  const response = UrlFetchApp.fetch(
    `https://www.slack.com/api/${apiMethod}`,
    {
      method: "post",
      contentType: "application/json; charset=utf-8",
      headers: { "Authorization": `Bearer ${token}` },
      payload: payload,
    }
  );
  console.log(`Web API (${apiMethod}) response: ${response}`)
  return response;
}

function create_neutral_message({channelId, pretext, title, text, storeIconUrl} = {}) {
  return create_message({
    channelId: channelId,
    pretext: pretext,
    title: title,
    text: text,
    storeIconUrl: storeIconUrl,
  })
}

function create_success_message({channelId, pretext, title, text, storeIconUrl} = {}) {
  return create_message({
    channelId: channelId,
    color: "good",
    pretext: pretext,
    title: title,
    text: text,
    storeIconUrl: storeIconUrl,
  })
}

function create_failure_message({channelId, pretext, title, text, storeIconUrl} = {}) {
  return create_message({
    channelId: channelId,
    color: "danger",
    pretext: pretext,
    title: title,
    text: text,
    storeIconUrl: storeIconUrl,
  })
}

function create_message({
  channelId,
  color,
  pretext,
  title,
  text,
  storeIconUrl,
} = {}) {
  return JSON.stringify({
    "channel": channelId || defaultChannel,
    "attachments": [
      {
        "mrkdwn_in": ["text"],
        "color": color || "",
        "pretext": pretext || "",
        "title": title || "",
        "text": text || "<!channel>",
        "thumb_url": storeIconUrl || "",
        "ts": new Date().getTime(),
      }
    ]
  })
}
```

```javascript:google_play_store.gs
const playStoreIconUrl = "..."

function is_google_play_developer_console(fromAddess) {
  return fromAddess.includes("noreply-play-developer-console")
}

function process_google_play_store(message) {
  const preTitle = "Google Play Store"
  if (message.getSubject() == "Your update is live") {
    const appName = get_matched_string(message.getBody(), 'Your update to (.+?), created')
    return create_success_message({
      pretext: preTitle,
      title: `${appName}がストアにリリースされました。お疲れ様です。`,
      storeIconUrl: playStoreIconUrl,
    })
  }
  return null
}

function is_google_play_developer(fromAddess) {
  return fromAddess.includes("no-reply-googleplay-developer")
}

function process_google_play_developer(message) {
  const preTitle = "Google Play Store"
  if (message.getSubject().includes("ご対応のお願い")) {
    const appName = get_matched_string(message.getBody(), 'お客様のアプリ (.+?)（パッケージ名')
    return create_failure_message({
      pretext: preTitle,
      title: `${appName}の承認が拒否されました`,
      storeIconUrl: playStoreIconUrl,
    })
  }
  return null
}
```

```javascript:app_store_connect.gs
const appleIconUrl = "..."

function is_app_store_connect(fromAddess, body) {
  return fromAddess.includes("App Store Connect")
}

function process_app_store_connect(message) {
  const preTitle = "App Store Connect"
  const subject = message.getSubject()
  const appName = get_matched_string(message.getPlainBody(), /App Name: (.+?)$/m)
  if (appName == "") {
    console.log(`${preTitle} appName null!`)
    return null
  }
  if (subject == "Your submission was accepted.") {
    // 申請通った
    return create_success_message({
      pretext: preTitle,
      title: `${appName}の申請が承認されました。`,
      storeIconUrl: appleIconUrl,
    })
  } else if(subject == "We noticed an issue with your submission.") {
    // 申請却下
    return create_failure_message({
      pretext: preTitle,
      title: `${appName}の申請が却下されました。`,
      storeIconUrl: appleIconUrl,
    })
  } else if (subject.includes("Ready for Sale")) {
    // ストアにリリースされた
    return create_success_message({
      pretext: preTitle,
      title: `${appName}がストアにリリースされました。お疲れ様です。`,
      storeIconUrl: appleIconUrl,
    })
  } else if (subject.includes("Pending Developer Release")) {
    // 手動リリース待ち
    return create_success_message({
      pretext: preTitle,
      title: `${appName}がリリースされるのを待っています。`,
      storeIconUrl: appleIconUrl,
    })
  } else if (subject.includes("Developer Rejected")) {
    // 申請取り下げ
    return create_neutral_message({
      pretext: preTitle,
      title: `${appName}の申請を取り下げました。`,
      storeIconUrl: appleIconUrl,
    })
  }
  return null
}
```

```javascript:amazon_app_store.gs
const amazonIconUrl = "..."

function is_amazon_app_store(fromAddess, body) {
  return fromAddess.includes("no-reply-appdev")
}

function process_amazon_app_store(message) {
  const preTitle = "Amazon App Store"
  if (message.getSubject().includes("is Live")) {
    const appName = get_matched_string(message.getSubject(), /– (.+?) is Live/)
    return create_success_message({
      pretext: preTitle,
      title: `${appName}がストアにリリースされました。お疲れ様です。`,
      storeIconUrl: amazonIconUrl,
    })
  } else if (message.getSubject().includes("Required Developer Attention")) {
    const appName = get_matched_string(message.getPlainBody(), /submission of (.+?) /)
    return create_failure_message({
      pretext: preTitle,
      title: `${appName}の申請が却下されました。`,
      storeIconUrl: amazonIconUrl,
    })
  }
  return null
}
```

```javascript:util.gs
function get_matched_string(str, m) {
  const result = str.match(m)
  if (result == null || result.length < 2) {
    return ""
  } else {
    return result[1]
  }
}
```

  </div>
</details>