app_status_notification 0.9.0.beta1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +10 -0
  3. data/.rubocop.yml +69 -0
  4. data/Gemfile +18 -0
  5. data/Gemfile.lock +112 -0
  6. data/LICENSE +21 -0
  7. data/README.md +22 -0
  8. data/app_status_notification.gemspec +46 -0
  9. data/config/locales/en.yml +10 -0
  10. data/config/locales/zh.yml +54 -0
  11. data/config/notification.yml +30 -0
  12. data/exe/app_status_notification +5 -0
  13. data/lib/app_status_notification.rb +24 -0
  14. data/lib/app_status_notification/command.rb +64 -0
  15. data/lib/app_status_notification/config.rb +234 -0
  16. data/lib/app_status_notification/connect_api.rb +92 -0
  17. data/lib/app_status_notification/connect_api/auth.rb +44 -0
  18. data/lib/app_status_notification/connect_api/clients/app.rb +46 -0
  19. data/lib/app_status_notification/connect_api/clients/app_store_version.rb +28 -0
  20. data/lib/app_status_notification/connect_api/clients/build.rb +29 -0
  21. data/lib/app_status_notification/connect_api/model.rb +152 -0
  22. data/lib/app_status_notification/connect_api/models/app.rb +27 -0
  23. data/lib/app_status_notification/connect_api/models/app_store_version.rb +84 -0
  24. data/lib/app_status_notification/connect_api/models/app_store_version_submission.rb +15 -0
  25. data/lib/app_status_notification/connect_api/models/build.rb +30 -0
  26. data/lib/app_status_notification/connect_api/models/pre_release_version.rb +16 -0
  27. data/lib/app_status_notification/connect_api/response.rb +102 -0
  28. data/lib/app_status_notification/error.rb +38 -0
  29. data/lib/app_status_notification/helper.rb +16 -0
  30. data/lib/app_status_notification/notification.rb +57 -0
  31. data/lib/app_status_notification/notifications/adapter.rb +25 -0
  32. data/lib/app_status_notification/notifications/dingtalk.rb +59 -0
  33. data/lib/app_status_notification/notifications/slack.rb +62 -0
  34. data/lib/app_status_notification/notifications/wecom.rb +48 -0
  35. data/lib/app_status_notification/runner.rb +409 -0
  36. data/lib/app_status_notification/store.rb +68 -0
  37. data/lib/app_status_notification/version.rb +5 -0
  38. data/lib/app_status_notification/watchman.rb +42 -0
  39. 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