app_status_notification 0.9.0.beta1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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