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.
- 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
|