LoginSignup
6
3

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-03-24

動機

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

最近の弊社では、各プロジェクトの状況報告は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]
  }
}
6
3
0

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
  3. You can use dark theme
What you can do with signing up
6
3