動機
あるとき、アプリの申請後、リジェクトされていることに気づかずに、
忙しいことも相まって、しばらく通知メールに気づかないということがありました
最近の弊社では、各プロジェクトの状況報告はSlackに集約されています
アプリがリジェクトされた場合は、早急にストアの設定の見直しや、
アプリの修正を行いたいので、定期的にSlackに通知を行うことで、
開発者が迅速に対応できるようなフローを構築しました
目標
Slack APIをGoogle Apps Script(以下GAS)から使用し、Gmailの内容から
アプリのストアへの申請ステータスがどうなっているのかを監視する
手順
1. SlackのAPIが使用できるようにセットアップする
以下の記事を書いたので、こちらをご覧ください
2. GASを作成する
まず、取得したいGmailのアカウントにログインします
その後、Googleドライブに移動し、
「+ 新規」 -> 「その他」 -> 「Google Apps Script」
を選択し、スクリプトを作成します
ひとまず、以下のようにソースを整えておきましょう
// SlackトークンやチャンネルIDなどの定数一覧
const slackOAuthToken = "..." // 前回の記事で取得したOAuthトークン
// エントリーポイント
function main() {
}
そうしたらプロジェクトを保存しましょう
今後は都度、自己判断で「プロジェクトを保存」を行なってください
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に設定し、「実行」(または「デバッグ」)をクリックします
初回実行時は以下のように権限確認が求められるので、確認します
警告画面が出るので、「詳細」 -> 「[スクリプト名](安全ではないページ)に移動」をクリックします
再度GASを実行すると、実行ログに以下のようなメッセージが表示されると思います
これで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にメッセージを投稿する
「チャンネル情報」タブの下に、チャンネルIDがあるので、それをコピーしてください
先頭の定数一覧に設定しておきましょう
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. 定期実行の設定をする
各パラメータを設定します。状況に合わせて実行する間隔を設定してください
設定が終わったら「保存」をクリックします
実行されているかどうかは左のナビゲーションの「実行数」から確認できます
7. 問題なく送れているか確認する
APIが成功していると、Slackでは以下のように表示されます
これで全ての手順は完了しました
課題点
現状、以下の課題があります。今後徐々に解決していく予定です
- アプリ名だけでなく、バージョン情報も取得したい
- Google Play Storeの通知から取れる情報が少ない
- これに関しては、アプリのステータスを取得できるAPIがあるので、他のプラットフォームとは分けて作る
- Slack APIでattachmentsを使う実装はlegacyになっている
- 新しい形式に対応したい
- 現在バージョン管理を行なっていない
- Gitで管理して、PRがmainにマージされたらデプロイ、のようなワークフローを組めると良い
おわりに
ここまでお疲れ様でした
個人的には、当初予想したよりも、複雑な実装は少なく済んだ印象です
これを読んだ方も、仕事やコミュニティ運営でSlackを使うようなことがあれば
参考にしていただけたらと思います
おまけ
現在運用中のコードの全体像は以下になります
コード全体
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()
}
})
})
}
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(),
}
]
})
}
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
}
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
}
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
}
function get_matched_string(str, m) {
const result = str.match(m)
if (result == null || result.length < 2) {
return ""
} else {
return result[1]
}
}