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,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AppStatusNotification
4
+ class Error < StandardError; end
5
+
6
+ class ConfigError < Error; end
7
+ class MissingAppsConfigError < ConfigError; end
8
+ class UnknownNotificationError < ConfigError; end
9
+
10
+ class ConnectAPIError < Error
11
+ class << self
12
+ def parse(response)
13
+ errors = response.body['errors']
14
+ case response.status
15
+ when 401
16
+ InvalidUserCredentialsError.from_errors(errors)
17
+ when 409
18
+ InvalidEntityError.from_errors(errors)
19
+ else
20
+ ConnectAPIError.from_errors(errors)
21
+ end
22
+ end
23
+
24
+ def from_errors(errors)
25
+ message = ["Check errors(#{errors.size}) from response:"]
26
+ errors.each_with_index do |error, i|
27
+ message << "#{i + 1} - [#{error['status']}] #{error['title']}: #{error['detail']} in #{error['source']}"
28
+ end
29
+
30
+ new(message)
31
+ end
32
+ end
33
+ end
34
+
35
+ class RateLimitExceededError < ConnectAPIError; end
36
+ class InvalidEntityError < ConnectAPIError; end
37
+ class InvalidUserCredentialsError < ConnectAPIError; end
38
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'i18n'
4
+
5
+ module AppStatusNotification
6
+ module I18nHelper
7
+ def t(key = nil, **kargs)
8
+ key ||= kargs.delete(:key)
9
+ raise 'No found key of i18n' if key.to_s.empty?
10
+
11
+ return key unless I18n.exists?(key)
12
+
13
+ I18n.t(key.to_sym, **kargs)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AppStatusNotification
4
+ module Notification
5
+ class << self
6
+ def register(adapter, type, *shortcuts)
7
+ adapters[type.to_sym] = adapter
8
+
9
+ shortcuts.each do |name|
10
+ aliases[name.to_sym] = type.to_sym
11
+ end
12
+ end
13
+
14
+ def [](type)
15
+ adapters[normalize(type)] || raise(UnknownNotificationError, "Unknown notication: #{type}")
16
+ end
17
+
18
+ def send(message, options)
19
+ adapter = options.delete('type')
20
+ notification = Notification[adapter].new(options)
21
+ notification.send(message)
22
+ end
23
+
24
+ private
25
+
26
+ # :nodoc:
27
+ def normalize(type)
28
+ aliases.fetch(type, type.to_sym)
29
+ end
30
+
31
+ # :nodoc:
32
+ def adapters
33
+ @adapters ||= {}
34
+ end
35
+
36
+ # :nodoc:
37
+ def aliases
38
+ @aliases ||= {}
39
+ end
40
+ end
41
+
42
+ class Message
43
+ attr_accessor :app, :version, :build
44
+
45
+ def initialize(app, version, build)
46
+ @app = app
47
+ @version = version
48
+ @build = build
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ require 'app_status_notification/notifications/adapter'
55
+ require 'app_status_notification/notifications/wecom'
56
+ require 'app_status_notification/notifications/slack'
57
+ require 'app_status_notification/notifications/dingtalk'
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ module AppStatusNotification
8
+ module Notification
9
+ class Adapter
10
+ include AppStatusNotification::I18nHelper
11
+
12
+ def initialize(options = {}) # rubocop:disable Style/OptionHash
13
+ @options = options
14
+ end
15
+
16
+ # def send(message)
17
+ # fail Error, 'Adapter does not supports #send'
18
+ # end
19
+
20
+ # def on_error(exception)
21
+ # fail Error, "Adapter does not supports #send"
22
+ # end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Doc: https://ding-doc.dingtalk.com/doc?spm=a1zb9.8233112.0.0.340c3a88sgMlJJ#/serverapi2/qf2nxq/9e91d73c
4
+
5
+ module AppStatusNotification
6
+ module Notification
7
+ class Dingtalk < Adapter
8
+ def initialize(options)
9
+ @webhook_url = URI(options['webhook_url'])
10
+ @secret = options['secret']
11
+
12
+ super
13
+ end
14
+
15
+ def send(message)
16
+ message = t(**message)
17
+
18
+ data = {
19
+ msgtype: :markdown,
20
+ markdown: {
21
+ title: message,
22
+ text: message
23
+ }
24
+ }
25
+
26
+ response = Net::HTTP.post(build_url, data.to_json, 'Content-Type' => 'application/json')
27
+ ap response.code
28
+ ap response.body
29
+ # rescue => e
30
+ # @exception = e
31
+ # nil
32
+ end
33
+
34
+ def build_url
35
+ url = @webhook_url.dup
36
+ query = url.query
37
+ if secret_sign = generate_sign
38
+ query = CGI.parse(query).merge(secret_sign)
39
+ end
40
+ url.query = URI.encode_www_form(query)
41
+ url
42
+ end
43
+
44
+ def generate_sign
45
+ return unless @secret
46
+
47
+ timestamp = (Time.now.to_f * 1000).to_i
48
+ sign = Base64.encode64(OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'), @secret, "#{timestamp}\n#{@secret}")).strip
49
+
50
+ {
51
+ timestamp: timestamp,
52
+ sign: sign
53
+ }
54
+ end
55
+
56
+ Notification.register self, :dingtalk, :dingding
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Doc: https://api.slack.com/messaging/webhooks and https://app.slack.com/block-kit-builder/
4
+
5
+ module AppStatusNotification
6
+ module Notification
7
+ class Slack < Adapter
8
+
9
+ def initialize(options)
10
+ @webhook_url = URI(options['webhook_url'])
11
+ @token = options['token']
12
+ @channel = options['channel']
13
+
14
+ super
15
+ end
16
+
17
+ def send(message)
18
+ data = if message.is_a?(String)
19
+ {
20
+ type: :mrkdwn,
21
+ text: message
22
+ }
23
+ else
24
+ {
25
+ blocks: [
26
+ {
27
+ type: :section,
28
+ text: {
29
+ type: 'mrkdwn',
30
+ text: t(**message),
31
+ }
32
+ },
33
+ {
34
+ type: :section,
35
+ fields: [
36
+ {
37
+ type: :mrkdwn,
38
+ text: "*版本:*\n#{message[:version]}"
39
+ },
40
+ {
41
+ type: :mrkdwn,
42
+ text: "*状态:*\n#{message[:status]}"
43
+ }
44
+ ]
45
+ },
46
+ ]
47
+ }
48
+ end
49
+
50
+ response = Net::HTTP.post(@webhook_url, data.to_json, 'Content-Type' => 'application/json')
51
+
52
+ ap response.code
53
+ ap response.body
54
+ # rescue => e
55
+ # @exception = e
56
+ # nil
57
+ end
58
+
59
+ Notification.register self, :slack
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Doc: https://work.weixin.qq.com/help?doc_id=13376 or https://work.weixin.qq.com/api/doc/90000/90136/91770
4
+
5
+ module AppStatusNotification
6
+ module Notification
7
+ class WeCom < Adapter
8
+ attr_reader :exception
9
+
10
+ MARKDOWN_MAX_LIMITED_LENGTH = 4096
11
+
12
+ @exception = nil
13
+
14
+ def initialize(**options)
15
+ @webhook_url = URI(options['webhook_url'])
16
+ super
17
+ end
18
+
19
+ def send(message)
20
+ @exception = nil
21
+
22
+ message = t(message)
23
+ content = if message.bytesize >= MARKDOWN_MAX_LIMITED_LENGTH
24
+ "#{message[0..MARKDOWN_MAX_LIMITED_LENGTH-10]}\n\n..."
25
+ else
26
+ message
27
+ end
28
+
29
+ data = {
30
+ msgtype: :markdown,
31
+ markdown: {
32
+ content: content
33
+ }
34
+ }
35
+
36
+ response = Net::HTTP.post(@webhook_url, data.to_json, 'Content-Type' => 'application/json')
37
+
38
+ ap response.code
39
+ ap response.body
40
+ # rescue => e
41
+ # @exception = e
42
+ # nil
43
+ end
44
+
45
+ Notification.register self, :wecom, :wechat_work
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,409 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ module AppStatusNotification
6
+ class Runner
7
+ include AppStatusNotification::I18nHelper
8
+
9
+ extend Forwardable
10
+
11
+ attr_reader :context
12
+
13
+ def initialize(context)
14
+ @context = context
15
+ determine_notification!
16
+ end
17
+
18
+ def_delegators :@context, :config, :client
19
+ def_delegators :config, :logger
20
+
21
+ def start
22
+ find_app
23
+ start_work
24
+ rescue Interrupt
25
+ logger.info t('logger.interrupt')
26
+ exit
27
+ rescue => e
28
+ Raven.capture_exception(e) unless config.dry?
29
+
30
+ logger.error t('logger.raise_error', message: e.full_message)
31
+ wait_next_loop
32
+ retry
33
+ end
34
+
35
+ def start_work
36
+ runloop do
37
+ edit_version = get_edit_version
38
+ next if not_found_edit_version(edit_version)
39
+
40
+ # 预检查提交版本是否真的存在
41
+ review_version = edit_version.version_string
42
+ next unless review_version
43
+
44
+ review_status = edit_version.app_store_state
45
+ check_app_store_version_changes(review_version, review_status)
46
+ app_store_status_changes_notification(edit_version)
47
+
48
+ next unless edit_version.editable?
49
+
50
+ check_selected_build_changes(edit_version)
51
+ build_processing_changes_notification(edit_version)
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ # 检查之前选中但被网页上人工取消该版本就发出通知
58
+ def check_selected_build_changes(edit_version)
59
+ cached_selected_build = store.selected_build
60
+ selected_build = edit_version.build
61
+ return unless cached_selected_build && !selected_build
62
+
63
+ store.unselected_build = cached_selected_build
64
+ store.delete :selected_build
65
+ send_notifications(
66
+ key: 'messages.app_build_removed',
67
+ app: app.name,
68
+ version: edit_version.version_string,
69
+ build: cached_selected_build
70
+ )
71
+ end
72
+
73
+ # 检查构建版本状态后(成功、失败)通知
74
+ def build_processing_changes_notification(edit_version)
75
+ # 获取最新上传的构建版本
76
+
77
+ latest_build = get_app_latest_build
78
+ return if latest_build.nil?
79
+
80
+ # 检查 build 的 app 版本是否和当前审核版本一致
81
+ return if latest_build.pre_release_version.version != edit_version.version_string
82
+
83
+ # 检查选中版本是否是最新版本
84
+ return if same_selected_build?(edit_version.build, latest_build)
85
+
86
+ review_version = edit_version.version_string
87
+ unless cached_latest_build?(review_version, latest_build)
88
+ case latest_build.processing_state
89
+ when ConnectAPI::ProcessStatus::PROCESSING
90
+ build_received_notification(review_version, latest_build)
91
+ when ConnectAPI::ProcessStatus::VALID
92
+ build_processed_notification(review_version, latest_build)
93
+ else
94
+ build_failed_notification(review_version, latest_build)
95
+ end
96
+ end
97
+
98
+ selected_build_notification(edit_version, latest_build)
99
+ end
100
+
101
+ def same_selected_build?(selected_build, latest_build)
102
+ return false unless selected_build
103
+
104
+ is_same = selected_build.version == latest_build.version
105
+ if is_same && !store.selected_build
106
+ store.selected_build = selected_build.version
107
+ store.delete :unselected_build
108
+
109
+ send_notifications(
110
+ key: 'messages.select_appstoreversion_build_from_another_source',
111
+ app: app.name,
112
+ version: store.version,
113
+ build: selected_build.version
114
+ )
115
+ end
116
+
117
+ is_same
118
+ end
119
+
120
+ # 处理选中构建版本
121
+ def selected_build_notification(edit_version, latest_build)
122
+ cached_selected_build = store.selected_build
123
+ selected_build = edit_version.build
124
+
125
+ # 没有缓存和已经选中构建版本,尝试选中最新上传版本
126
+ if cached_selected_build.nil? && selected_build.nil?
127
+ return select_version_build(edit_version, latest_build)
128
+ end
129
+
130
+ # 发现选中版本写入缓存并发通知
131
+ if selected_build && !cached_selected_build
132
+ store.selected_build = selected_build.version
133
+ return send_notifications(
134
+ key: 'messages.app_build_processed',
135
+ app: app.name,
136
+ version: release_version,
137
+ build: edit_version.version
138
+ )
139
+ end
140
+
141
+ # 没有选中版本可能是网页上被删除选中
142
+ return unless selected_build
143
+
144
+ # 发现选择版本一样跳过
145
+ return if cached_selected_build == selected_build.version || selected_build.version == latest_build.version
146
+
147
+ # 选中构建版本和最新上传构建版本不一致通知
148
+ store.selected_build = selected_build.version
149
+ send_notifications(
150
+ key: 'messages.app_build_changed',
151
+ app: app.name,
152
+ version: release_version,
153
+ old_build: cached_selected_build,
154
+ new_build: selected_build.version
155
+ )
156
+ end
157
+
158
+ def select_version_build(edit_version, build)
159
+ # 如果曾经选中被移除不再重新选中
160
+ return if store.unselected_build == build.version
161
+
162
+ send_notifications(
163
+ key: 'messages.prepare_appstoreversion_build',
164
+ app: app.name,
165
+ version: edit_version.version_string,
166
+ build: build.version
167
+ )
168
+
169
+ r = client.select_version_build(edit_version.id, build_id: build.id)
170
+ if r.status == 204
171
+ store.selected_build = build.version
172
+ send_notifications(
173
+ key: 'messages.success_select_appstoreversion_build',
174
+ app: app.name,
175
+ version: edit_version.version_string,
176
+ build: build.version
177
+ )
178
+ else
179
+ send_notifications(
180
+ key: 'messages.failed_select_appstoreversion_build',
181
+ app: app.name,
182
+ version: edit_version.version_string,
183
+ build: build.version
184
+ )
185
+ end
186
+ end
187
+
188
+ def cached_latest_build?(version, build)
189
+ return true if store.version == version && store.latest_build == build.version
190
+
191
+ store.latest_build = build.version
192
+ false
193
+ end
194
+
195
+ # 没有找到新建版本的审核
196
+ def not_found_edit_version(edit_version)
197
+ return if edit_version
198
+
199
+ live_version = get_live_version
200
+ logger.debug t('logger.not_found_edit_version', version: live_version.version_string)
201
+
202
+ app_on_sale_with_uncatch_process_notification(live_version)
203
+ store.clear
204
+
205
+ true
206
+ end
207
+
208
+ def app_on_sale_with_uncatch_process_notification(live_version)
209
+ if (cache_version = store.version) &&
210
+ Gem::Version.new(live_version.version_string) >= Gem::Version.new(cache_version)
211
+
212
+ # TODO: 审核的版本已经发布发送通知
213
+ send_notifications(
214
+ key: 'app_was_on_sale',
215
+ app: app.name,
216
+ version: live_version.version_string
217
+ )
218
+ end
219
+ end
220
+
221
+ # 检查编辑版本号是否发生变化
222
+ def check_app_store_version_changes(version, status)
223
+ cached_version = store.version
224
+ store.version = version
225
+
226
+ status_text = t("app_store_status.#{status.downcase}")
227
+ if cached_version.to_s.empty?
228
+ store.status = status
229
+
230
+ send_notifications(
231
+ key: 'messages.app_version_created',
232
+ app: app.name,
233
+ version: version,
234
+ status: status_text
235
+ )
236
+ elsif cached_version != version
237
+ send_notifications(
238
+ key: 'messages.app_version_changed',
239
+ app: app.name,
240
+ current_version: cached_version,
241
+ new_version: version,
242
+ status: status_text
243
+ )
244
+ end
245
+ end
246
+
247
+ # 状态变更的通知
248
+ def app_store_status_changes_notification(edit_version)
249
+ status = edit_version.app_store_state
250
+ version = edit_version.version_string
251
+ return if status == store.status
252
+
253
+ logger.info "#{app.name} v#{version} changed status to `#{status}` created at #{edit_version.created_date}"
254
+
255
+ store.status = status
256
+ status_text = t("app_store_status.#{status.downcase}")
257
+ todo_text = t("todo.#{status.downcase}")
258
+ message = if I18n.exists?(:"todo.#{status.downcase}")
259
+ {
260
+ key: 'messages.app_store_status_changes_with_todo',
261
+ app: app.name,
262
+ version: version,
263
+ status: status_text,
264
+ todo: todo_text
265
+ }
266
+ else
267
+ {
268
+ key: 'messages.app_store_status_changes',
269
+ app: app.name,
270
+ version: version,
271
+ status: status_text
272
+ }
273
+ end
274
+
275
+ send_notifications(message)
276
+ end
277
+
278
+
279
+ #####################
280
+ # Notifcations
281
+ #####################
282
+
283
+ # 发出接收到构建版本通知
284
+ def build_received_notification(version, build)
285
+ send_notifications(
286
+ key: 'messages.app_build_received',
287
+ app: app.name,
288
+ version: version,
289
+ build: build.version
290
+ )
291
+ end
292
+
293
+ # 发出处理完毕构建版本通知
294
+ def build_processed_notification(version, build)
295
+ send_notifications(
296
+ key: 'messages.app_build_processed',
297
+ app: app.name,
298
+ version: version,
299
+ build: build.version
300
+ )
301
+ end
302
+
303
+ # 发出构建版本处理失败、无效通知
304
+ def build_failed_notification(version, build)
305
+ send_notifications(
306
+ key: 'messages.app_build_failed',
307
+ app: app.name,
308
+ version: version,
309
+ build: build.version,
310
+ status: build.processing_state
311
+ )
312
+ end
313
+
314
+ def send_notifications(message)
315
+ return unless message
316
+
317
+ allowed_notifications = context.app.notifications
318
+
319
+ config.notifications.each do |nname, nargs|
320
+ next unless allowed_notifications.size == 0 ||
321
+ allowed_notifications.include?(nname)
322
+
323
+ logger.debug t('logger.send_notification', name: nname, message: t(**message))
324
+ Notification.send(message, nargs) unless config.dry?
325
+ end
326
+ end
327
+
328
+ #####################
329
+ # App
330
+ #####################
331
+
332
+ # 获得当前 app 信息
333
+ def app
334
+ @app ||= client.app(context.app.id)
335
+ end
336
+
337
+ #####################
338
+ # Version
339
+ #####################
340
+
341
+ def get_edit_version
342
+ version = client.app_edit_version(app.id)
343
+ logger.debug "API Rate: #{version.rate}" if version
344
+ version
345
+ end
346
+
347
+ def get_live_version
348
+ client.app_live_version(app.id)
349
+ end
350
+
351
+ #####################
352
+ # Build
353
+ #####################
354
+
355
+ def get_app_latest_build
356
+ client.app_latest_build(app.id)
357
+ end
358
+
359
+ #####################
360
+ # Internal
361
+ #####################
362
+
363
+ def determine_notification!
364
+ global_notifications = config.notifications
365
+ app_notifications = context.app.notifications
366
+ enabled_notifications = app_notifications.empty? ? global_notifications.keys : app_notifications
367
+ logger.info "Enabled notifications (#{enabled_notifications.size}): #{enabled_notifications.join(', ')}"
368
+ end
369
+
370
+ def find_app
371
+ logger.info t('logger.found_app', name: app.name, id: app.id, bundle_id: app.bundle_id)
372
+ end
373
+
374
+ def store
375
+ @store ||= Store.new context.app.id, config.store_path
376
+ end
377
+
378
+ def runloop(&block)
379
+ loop do
380
+ logger.debug store.to_h
381
+ block.call
382
+ wait_next_loop
383
+ end
384
+ end
385
+
386
+ def wait_next_loop
387
+ logger.debug t('logger.wait_next_loop', interval: config.refresh_interval)
388
+ sleep config.refresh_interval
389
+ end
390
+
391
+ class Context
392
+ extend Forwardable
393
+
394
+ attr_reader :app, :config
395
+
396
+ def initialize(account, app, config)
397
+ @account = account
398
+ @app = app
399
+ @config = config
400
+ end
401
+
402
+ def client
403
+ @client ||= ConnectAPI.from_context(self)
404
+ end
405
+
406
+ def_delegators :@account, :issuer_id, :key_id, :private_key
407
+ end
408
+ end
409
+ end