LoginSignup
1

posted at

updated at

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

動機

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

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

目標

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

手順

1. SlackのAPIが使用できるようにセットアップする

以下の記事を書いたので、こちらをご覧ください

2. GASを作成する

まず、取得したいGmailのアカウントにログインします

その後、Googleドライブに移動し、
「+ 新規」 -> 「その他」 -> 「Google Apps Script」
を選択し、スクリプトを作成します
スクリーンショット 2023-03-24 11.47.27.png

作成すると以下のようにエディター画面になります
スクリーンショット 2023-03-24 11.49.57.png

ひとまず、以下のようにソースを整えておきましょう

// SlackトークンやチャンネルIDなどの定数一覧
const slackOAuthToken = "..." // 前回の記事で取得したOAuthトークン

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

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

スクリーンショット 2023-03-24 12.32.26.png

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

3. Gmailの内容を取得する

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

// 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

初回実行時は以下のように権限確認が求められるので、確認します
スクリーンショット 2023-03-24 13.04.10.png

連携したいGmailのアカウントを選択します
スクリーンショット 2023-03-24 13.07.00.png

警告画面が出るので、「詳細」 -> 「[スクリプト名](安全ではないページ)に移動」をクリックします
スクリーンショット 2023-03-24 13.04.58.png
スクリーンショット 2023-03-24 13.08.50.png

認証画面が出るので「許可」をクリックします
スクリーンショット 2023-03-24 13.12.57.png

再度GASを実行すると、実行ログに以下のようなメッセージが表示されると思います
スクリーンショット 2023-03-24 13.15.53.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の例を見てみましょう

// 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
}

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

// 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

チャンネルページの左上のチャンネル情報を開きます
スクリーンショット 2023-03-24 11.20.31.png

「チャンネル情報」タブの下に、チャンネルIDがあるので、それをコピーしてください
スクリーンショット 2023-03-24 11.21.04.png

先頭の定数一覧に設定しておきましょう

const slackToken = "..."
const defaultChannel = "..." // <- ここ

前項で作成したメッセージ(JSON文字列)をpayloadに設定して、APIを呼び出します

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

画面右下の「+ トリガーを追加」をクリックしてください
スクリーンショット 2023-03-24 17.17.40.png

各パラメータを設定します。状況に合わせて実行する間隔を設定してください
設定が終わったら「保存」をクリックします
スクリーンショット 2023-03-24 17.20.24.png

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

7. 問題なく送れているか確認する

APIが成功していると、Slackでは以下のように表示されます
スクリーンショット 2023-03-24 17.29.45.png

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

課題点

現状、以下の課題があります。今後徐々に解決していく予定です

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

おわりに

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

おまけ

現在運用中のコードの全体像は以下になります

コード全体
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()
      }

    })
  })
}
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(),
      }
    ]
  })
}
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
}
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
}
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
}
util.gs
function get_matched_string(str, m) {
  const result = str.match(m)
  if (result == null || result.length < 2) {
    return ""
  } else {
    return result[1]
  }
}

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
1