app_status_notification 0.9.0.beta1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +10 -0
- data/.rubocop.yml +69 -0
- data/Gemfile +18 -0
- data/Gemfile.lock +112 -0
- data/LICENSE +21 -0
- data/README.md +22 -0
- data/app_status_notification.gemspec +46 -0
- data/config/locales/en.yml +10 -0
- data/config/locales/zh.yml +54 -0
- data/config/notification.yml +30 -0
- data/exe/app_status_notification +5 -0
- data/lib/app_status_notification.rb +24 -0
- data/lib/app_status_notification/command.rb +64 -0
- data/lib/app_status_notification/config.rb +234 -0
- data/lib/app_status_notification/connect_api.rb +92 -0
- data/lib/app_status_notification/connect_api/auth.rb +44 -0
- data/lib/app_status_notification/connect_api/clients/app.rb +46 -0
- data/lib/app_status_notification/connect_api/clients/app_store_version.rb +28 -0
- data/lib/app_status_notification/connect_api/clients/build.rb +29 -0
- data/lib/app_status_notification/connect_api/model.rb +152 -0
- data/lib/app_status_notification/connect_api/models/app.rb +27 -0
- data/lib/app_status_notification/connect_api/models/app_store_version.rb +84 -0
- data/lib/app_status_notification/connect_api/models/app_store_version_submission.rb +15 -0
- data/lib/app_status_notification/connect_api/models/build.rb +30 -0
- data/lib/app_status_notification/connect_api/models/pre_release_version.rb +16 -0
- data/lib/app_status_notification/connect_api/response.rb +102 -0
- data/lib/app_status_notification/error.rb +38 -0
- data/lib/app_status_notification/helper.rb +16 -0
- data/lib/app_status_notification/notification.rb +57 -0
- data/lib/app_status_notification/notifications/adapter.rb +25 -0
- data/lib/app_status_notification/notifications/dingtalk.rb +59 -0
- data/lib/app_status_notification/notifications/slack.rb +62 -0
- data/lib/app_status_notification/notifications/wecom.rb +48 -0
- data/lib/app_status_notification/runner.rb +409 -0
- data/lib/app_status_notification/store.rb +68 -0
- data/lib/app_status_notification/version.rb +5 -0
- data/lib/app_status_notification/watchman.rb +42 -0
- metadata +283 -0
@@ -0,0 +1,64 @@
|
|
1
|
+
require 'app_status_notification'
|
2
|
+
require 'app_status_notification/version'
|
3
|
+
|
4
|
+
require 'gli'
|
5
|
+
|
6
|
+
class AppStatusNotification::Command
|
7
|
+
extend GLI::App
|
8
|
+
|
9
|
+
program_desc 'Manage iOS app status notification'
|
10
|
+
|
11
|
+
version AppStatusNotification::VERSION
|
12
|
+
|
13
|
+
subcommand_option_handling :normal
|
14
|
+
arguments :strict
|
15
|
+
|
16
|
+
desc 'Enable development mode (use .local.yml config)'
|
17
|
+
switch [:d, :development]
|
18
|
+
|
19
|
+
desc 'Set log level'
|
20
|
+
default_value 'info'
|
21
|
+
arg_name 'value'
|
22
|
+
flag [:'log-level']
|
23
|
+
|
24
|
+
desc 'Set config path'
|
25
|
+
arg_name 'config'
|
26
|
+
flag [:c, :config]
|
27
|
+
|
28
|
+
desc 'Set locale path'
|
29
|
+
arg_name 'locale'
|
30
|
+
flag [:locale]
|
31
|
+
|
32
|
+
desc 'Start watch service'
|
33
|
+
arg_name 'Describe arguments to ddd here'
|
34
|
+
command :watch do |c|
|
35
|
+
c.action do |global_options, options, args|
|
36
|
+
AppStatusNotification.watch(global_options[:config])
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
pre do |global,command,options,args|
|
41
|
+
AppStatusNotification.development(global[:development])
|
42
|
+
# Pre logic here
|
43
|
+
# Return true to proceed; false to abort and not call the
|
44
|
+
# chosen command
|
45
|
+
# Use skips_pre before a command to skip this block
|
46
|
+
# on that command only
|
47
|
+
true
|
48
|
+
end
|
49
|
+
|
50
|
+
post do |global,command,options,args|
|
51
|
+
# Post logic here
|
52
|
+
# Use skips_post before a command to skip this
|
53
|
+
# block on that command only
|
54
|
+
end
|
55
|
+
|
56
|
+
on_error do |exception|
|
57
|
+
puts exception.backtrace unless exception.is_a?(Interrupt)
|
58
|
+
# Error logic here
|
59
|
+
# return false to skip default error handling
|
60
|
+
true
|
61
|
+
end
|
62
|
+
|
63
|
+
default_command :watch
|
64
|
+
end
|
@@ -0,0 +1,234 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'app_status_notification/version'
|
4
|
+
require 'anyway_config'
|
5
|
+
require 'raven/base'
|
6
|
+
require 'logger'
|
7
|
+
require 'i18n'
|
8
|
+
|
9
|
+
module AppStatusNotification
|
10
|
+
class Config < Anyway::Config
|
11
|
+
config_name :notification
|
12
|
+
|
13
|
+
attr_config accounts: [],
|
14
|
+
notifications: {},
|
15
|
+
refresh_interval: 60,
|
16
|
+
locale: 'zh',
|
17
|
+
dry: false,
|
18
|
+
enable_crash_report: true,
|
19
|
+
crash_report: 'https://aa7c78acbb324fcf93169fce2b7e5758@o333914.ingest.sentry.io/5575774'
|
20
|
+
|
21
|
+
def load_locale(path, file = '*.yml')
|
22
|
+
path = File.join(path) unless path.ends_with?(file)
|
23
|
+
I18n.load_path << Dir[path]
|
24
|
+
end
|
25
|
+
|
26
|
+
def debug?
|
27
|
+
(ENV['ASN_ENV'] || ENV['RACK_ENV'] || ENV['RAILS_ENV']) != 'production'
|
28
|
+
end
|
29
|
+
|
30
|
+
def dry?
|
31
|
+
!!dry
|
32
|
+
end
|
33
|
+
|
34
|
+
def env
|
35
|
+
debug? ? 'development' : 'production'
|
36
|
+
end
|
37
|
+
|
38
|
+
def logger
|
39
|
+
return @logger if @logger
|
40
|
+
|
41
|
+
@logger = Logger.new(STDOUT)
|
42
|
+
@logger.level = debug? ? Logger::DEBUG : Logger::INFO
|
43
|
+
@logger
|
44
|
+
end
|
45
|
+
|
46
|
+
def accounts
|
47
|
+
@account ||= Account.parse(super)
|
48
|
+
end
|
49
|
+
|
50
|
+
def store_path
|
51
|
+
File.join(File.expand_path('../../', __dir__), 'stores')
|
52
|
+
end
|
53
|
+
|
54
|
+
def config_path
|
55
|
+
File.join(File.expand_path('../../', __dir__), 'config')
|
56
|
+
end
|
57
|
+
|
58
|
+
on_load :configure_locale
|
59
|
+
on_load :ensure_accounts
|
60
|
+
on_load :ensure_notifications
|
61
|
+
on_load :configure_crash_report
|
62
|
+
|
63
|
+
private
|
64
|
+
|
65
|
+
def configure_locale
|
66
|
+
# built-in
|
67
|
+
I18n.load_path << Dir[File.join(config_path, 'locales', '*.yml')]
|
68
|
+
|
69
|
+
# default locale
|
70
|
+
I18n.locale = locale.to_sym
|
71
|
+
end
|
72
|
+
|
73
|
+
def configure_crash_report
|
74
|
+
return unless enable_crash_report
|
75
|
+
|
76
|
+
Raven.configure do |raven|
|
77
|
+
raven.dsn = crash_report
|
78
|
+
raven.current_environment = env
|
79
|
+
raven.logger = logger
|
80
|
+
raven.release = AppStatusNotification::VERSION
|
81
|
+
raven.excluded_exceptions += [
|
82
|
+
'Faraday::SSLError',
|
83
|
+
'Spaceship::UnauthorizedAccessError',
|
84
|
+
'Interrupt',
|
85
|
+
'SystemExit',
|
86
|
+
'SignalException',
|
87
|
+
'Faraday::ConnectionFailed'
|
88
|
+
]
|
89
|
+
|
90
|
+
raven.before_send = lambda { |event, hint|
|
91
|
+
event.extra = {
|
92
|
+
config: self.to_filtered_h
|
93
|
+
}
|
94
|
+
|
95
|
+
event
|
96
|
+
}
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
def ensure_accounts
|
101
|
+
%w[key_id issuer_id apps].each do |key|
|
102
|
+
accounts.each do |account|
|
103
|
+
unless account.send(key.to_sym)
|
104
|
+
raise ConfigError, "Missing account properties: #{key}"
|
105
|
+
end
|
106
|
+
|
107
|
+
unless account.key_path && account.key_exists?
|
108
|
+
raise ConfigError, "Can not find key file, place one into config directory, Eg: config/AuthKey_xxx.p8"
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
def ensure_notifications
|
115
|
+
if notifications.nil? || notifications.empty?
|
116
|
+
raise ConfigError, "Missing notifications"
|
117
|
+
elsif notifications.is_a?(Hash)
|
118
|
+
notifications.each do |key, url|
|
119
|
+
raise ConfigError, "Missing url properties: #{key}" unless url
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
def to_filtered_h
|
125
|
+
@to_filtered_h ||= to_h.each_with_object({}) do |(k, v), obj|
|
126
|
+
case k
|
127
|
+
when :accounts
|
128
|
+
obj[k] = filter_accounts
|
129
|
+
when :notifications
|
130
|
+
obj[k] = filter_notifications(v)
|
131
|
+
when :crash_report
|
132
|
+
obj[k] = filtered_token(v)
|
133
|
+
else
|
134
|
+
obj[k] = v
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
def filter_accounts
|
140
|
+
accounts.each_with_object([]) do |account, obj|
|
141
|
+
item = {
|
142
|
+
issuer_id: filtered_token(account.issuer_id),
|
143
|
+
key_id: filtered_token(account.key_id),
|
144
|
+
key_path: filtered_token(account.key_path),
|
145
|
+
apps: []
|
146
|
+
}
|
147
|
+
|
148
|
+
account.apps.each do |app|
|
149
|
+
item[:apps] << {
|
150
|
+
id: filtered_token(app.id),
|
151
|
+
notifications: app.notifications
|
152
|
+
}
|
153
|
+
end
|
154
|
+
|
155
|
+
obj << item
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
def filter_notifications(notifications)
|
160
|
+
notifications.each_with_object({}) do |(k, v), obj|
|
161
|
+
new_v = v.dup
|
162
|
+
new_v['webhook_url'] = filtered_token(new_v['webhook_url'])
|
163
|
+
obj[k] = new_v
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
def filtered_token(chars)
|
168
|
+
chars = chars.to_s
|
169
|
+
return '*' * chars.size if chars.size < 4
|
170
|
+
|
171
|
+
average = chars.size / 4
|
172
|
+
prefix = chars[0..average - 1]
|
173
|
+
hidden = '*' * (average * 2)
|
174
|
+
suffix = chars[(prefix.size + average * 2)..-1]
|
175
|
+
"#{prefix}#{hidden}#{suffix}"
|
176
|
+
end
|
177
|
+
|
178
|
+
class Account
|
179
|
+
def self.parse(accounts)
|
180
|
+
[].tap do |obj|
|
181
|
+
accounts.each do |account|
|
182
|
+
obj << Account.new(account)
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
attr_reader :issuer_id, :key_id, :key_path
|
188
|
+
attr_reader :apps
|
189
|
+
|
190
|
+
def initialize(raw)
|
191
|
+
@issuer_id = raw['issuer_id']
|
192
|
+
@key_id = raw['key_id']
|
193
|
+
@key_path = raw['key_path']
|
194
|
+
@apps = App.parse(raw['apps'])
|
195
|
+
end
|
196
|
+
|
197
|
+
def key_exists?
|
198
|
+
File.file?(key_path) || File.readable?(key_path)
|
199
|
+
end
|
200
|
+
|
201
|
+
def private_key
|
202
|
+
File.read(key_path)
|
203
|
+
end
|
204
|
+
|
205
|
+
class App
|
206
|
+
def self.parse(apps)
|
207
|
+
raise MissingAppsConfigError, 'Unable handle all apps of account, add app id(s) under accounts with name `apps`.' unless apps
|
208
|
+
|
209
|
+
[].tap do |obj|
|
210
|
+
apps.each do |app|
|
211
|
+
obj << App.new(app)
|
212
|
+
end
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
attr_reader :id
|
217
|
+
|
218
|
+
def initialize(raw)
|
219
|
+
case raw
|
220
|
+
when String, Integer
|
221
|
+
@id = raw.to_s
|
222
|
+
when Hash
|
223
|
+
@id = raw['id'].to_s
|
224
|
+
@notifications = raw['notifications']
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
def notifications
|
229
|
+
@notifications ||= []
|
230
|
+
end
|
231
|
+
end
|
232
|
+
end
|
233
|
+
end
|
234
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'faraday'
|
4
|
+
require 'faraday_middleware'
|
5
|
+
|
6
|
+
require 'app_status_notification/connect_api/auth'
|
7
|
+
require 'app_status_notification/connect_api/response'
|
8
|
+
require 'app_status_notification/connect_api/model'
|
9
|
+
|
10
|
+
module AppStatusNotification
|
11
|
+
class ConnectAPI
|
12
|
+
Dir[File.expand_path('connect_api/clients/*.rb', __dir__)].each { |f| require f }
|
13
|
+
|
14
|
+
ENDPOINT = 'https://api.appstoreconnect.apple.com/v1'
|
15
|
+
|
16
|
+
include Client::App
|
17
|
+
include Client::AppStoreVersion
|
18
|
+
include Client::Build
|
19
|
+
|
20
|
+
def self.from_context(context, **kargs)
|
21
|
+
new(**kargs.merge(
|
22
|
+
issuer_id: context.issuer_id,
|
23
|
+
key_id: context.key_id,
|
24
|
+
private_key: context.private_key,
|
25
|
+
))
|
26
|
+
end
|
27
|
+
|
28
|
+
attr_reader :connection
|
29
|
+
|
30
|
+
def initialize(**kargs)
|
31
|
+
configure_connection(**kargs)
|
32
|
+
end
|
33
|
+
|
34
|
+
%w[get post patch delete].each do |method|
|
35
|
+
define_method method do |path, options = {}|
|
36
|
+
params = options.dup
|
37
|
+
connection.authorization :Bearer, @auth.token
|
38
|
+
|
39
|
+
if %w[post patch].include?(method)
|
40
|
+
body = params[:body].to_json
|
41
|
+
headers = params[:headers] || {}
|
42
|
+
headers[:content_type] ||= 'application/json'
|
43
|
+
validates connection.send(method, path, body, headers)
|
44
|
+
else
|
45
|
+
validates connection.send(method, path, params)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def validates(response)
|
53
|
+
case response.status
|
54
|
+
when 200, 201, 204
|
55
|
+
# 200: get requests
|
56
|
+
# 201: post requests
|
57
|
+
# 204: patch, delete requests
|
58
|
+
handle_response response
|
59
|
+
when 429
|
60
|
+
# 429 Rate Limit Exceeded
|
61
|
+
raise RateLimitExceededError.parse(response)
|
62
|
+
else
|
63
|
+
raise ConnectAPIError.parse(response)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def handle_response(response)
|
68
|
+
response = Response.new(response, connection)
|
69
|
+
|
70
|
+
if (remaining = response.rate[:remaining]) && remaining.to_i.zero?
|
71
|
+
raise RateLimitExceededError, "Request limit reached #{response.rate[:limit]} in the previous 60 minutes with url: #{response.request_url}"
|
72
|
+
end
|
73
|
+
|
74
|
+
response
|
75
|
+
end
|
76
|
+
|
77
|
+
def configure_connection(**kargs)
|
78
|
+
@auth = Auth.new(**kargs)
|
79
|
+
endpoint = kargs[:endpoint] || ENDPOINT
|
80
|
+
|
81
|
+
connection_opts= {}
|
82
|
+
connection_opts[:proxy] = ENV['ASN_PROXY'] if ENV['ASN_PROXY']
|
83
|
+
@connection = Faraday.new(endpoint, connection_opts) do |builder|
|
84
|
+
builder.request :url_encoded
|
85
|
+
builder.headers[:content_type] = 'application/json'
|
86
|
+
|
87
|
+
builder.response :json, content_type: /\bjson$/
|
88
|
+
builder.response :logger if kargs[:debug] || ENV['ASN_DEBUG']
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'jwt'
|
4
|
+
|
5
|
+
module AppStatusNotification
|
6
|
+
class ConnectAPI
|
7
|
+
class Auth
|
8
|
+
AUDIENCE = 'appstoreconnect-v1'
|
9
|
+
ALGORITHM = 'ES256'
|
10
|
+
EXPIRE_DURATION = 20 * 60
|
11
|
+
|
12
|
+
attr_reader :issuer_id, :key_id, :private_key
|
13
|
+
|
14
|
+
def initialize(**kargs)
|
15
|
+
@issuer_id = kargs[:issuer_id]
|
16
|
+
@key_id = kargs[:key_id]
|
17
|
+
@private_key = handle_private_key(kargs[:private_key])
|
18
|
+
end
|
19
|
+
|
20
|
+
def token
|
21
|
+
JWT.encode(payload, private_key, ALGORITHM, header_fields)
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def payload
|
27
|
+
{
|
28
|
+
aud: AUDIENCE,
|
29
|
+
iss: issuer_id,
|
30
|
+
exp: Time.now.to_i + EXPIRE_DURATION
|
31
|
+
}
|
32
|
+
end
|
33
|
+
|
34
|
+
def header_fields
|
35
|
+
{ kid: key_id }
|
36
|
+
end
|
37
|
+
|
38
|
+
def handle_private_key(key)
|
39
|
+
key = File.open(key) if File.file?(key)
|
40
|
+
OpenSSL::PKey.read(key)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module AppStatusNotification
|
4
|
+
class ConnectAPI
|
5
|
+
module Client
|
6
|
+
module App
|
7
|
+
def apps(query = {})
|
8
|
+
get("apps", query = {})
|
9
|
+
end
|
10
|
+
|
11
|
+
def app(id, query = {})
|
12
|
+
get("apps/#{id}", query).to_model
|
13
|
+
end
|
14
|
+
|
15
|
+
def app_versions(id, query = {})
|
16
|
+
get("apps/#{id}/appStoreVersions", query)
|
17
|
+
end
|
18
|
+
|
19
|
+
def app_edit_version(id, includes: ConnectAPI::Model::AppStoreVersion::ESSENTIAL_INCLUDES)
|
20
|
+
filters = {
|
21
|
+
appStoreState: [
|
22
|
+
ConnectAPI::Model::AppStoreVersion::AppStoreState::PREPARE_FOR_SUBMISSION,
|
23
|
+
ConnectAPI::Model::AppStoreVersion::AppStoreState::DEVELOPER_REJECTED,
|
24
|
+
ConnectAPI::Model::AppStoreVersion::AppStoreState::REJECTED,
|
25
|
+
ConnectAPI::Model::AppStoreVersion::AppStoreState::METADATA_REJECTED,
|
26
|
+
ConnectAPI::Model::AppStoreVersion::AppStoreState::WAITING_FOR_REVIEW,
|
27
|
+
ConnectAPI::Model::AppStoreVersion::AppStoreState::INVALID_BINARY,
|
28
|
+
ConnectAPI::Model::AppStoreVersion::AppStoreState::IN_REVIEW,
|
29
|
+
ConnectAPI::Model::AppStoreVersion::AppStoreState::PENDING_DEVELOPER_RELEASE
|
30
|
+
].join(',')
|
31
|
+
}
|
32
|
+
|
33
|
+
app_versions(id, include: includes, filter: filters).to_model
|
34
|
+
end
|
35
|
+
|
36
|
+
def app_live_version(id, includes: ConnectAPI::Model::AppStoreVersion::ESSENTIAL_INCLUDES)
|
37
|
+
filters = {
|
38
|
+
appStoreState: ConnectAPI::Model::AppStoreVersion::AppStoreState::READY_FOR_SALE
|
39
|
+
}
|
40
|
+
|
41
|
+
app_versions(id, include: includes, filter: filters).to_model
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|