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