command_tower 0.18.0 → 0.19.0

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/app/deserializers/command_tower/deserializers/messaging/preferences/update_deserializer.rb +10 -1
  3. data/app/errors/command_tower/errors/account/push_test_destination_error.rb +21 -0
  4. data/app/models/command_tower/messaging/inbox_item.rb +6 -0
  5. data/app/serializers/command_tower/serializers/messaging/inbox.rb +1 -1
  6. data/app/serializers/command_tower/serializers/messaging/preferences/notification_serializer.rb +2 -0
  7. data/app/services/command_tower/messaging/execution/adapter_result.rb +11 -1
  8. data/app/services/command_tower/messaging/execution/adapters/expo/adapter.rb +146 -25
  9. data/app/services/command_tower/messaging/execution/adapters/expo/configuration.rb +5 -0
  10. data/app/services/command_tower/messaging/execution/adapters/expo/endpoint_ticket_history.rb +86 -0
  11. data/app/services/command_tower/messaging/execution/adapters/expo/interruption_level.rb +41 -0
  12. data/app/services/command_tower/messaging/execution/attempt_ledger.rb +3 -0
  13. data/app/services/command_tower/messaging/execution/expo_receipt_collection.rb +224 -0
  14. data/app/services/command_tower/messaging/execution/operation_logger.rb +55 -5
  15. data/app/services/command_tower/messaging/execution/recovery.rb +8 -0
  16. data/app/services/command_tower/messaging/inbox/reader.rb +15 -2
  17. data/app/services/command_tower/messaging/inbox/unread_count_result.rb +3 -2
  18. data/app/services/command_tower/messaging/notification_types/declaration.rb +19 -1
  19. data/app/services/command_tower/messaging/notification_types/declaration_validator.rb +62 -0
  20. data/app/services/command_tower/messaging/notification_types/permitted_choice.rb +55 -0
  21. data/app/services/command_tower/messaging/notification_types/registry.rb +25 -0
  22. data/app/services/command_tower/messaging/notification_types.rb +3 -0
  23. data/app/services/command_tower/messaging/preferences/catalog.rb +7 -0
  24. data/app/services/command_tower/messaging/preferences/catalog_notification_result.rb +26 -1
  25. data/app/services/command_tower/messaging/preferences/effective_choice.rb +92 -0
  26. data/app/services/command_tower/messaging/preferences/preference_state.rb +24 -4
  27. data/app/services/command_tower/messaging/preferences/store.rb +10 -0
  28. data/app/services/command_tower/messaging/preferences/update.rb +36 -4
  29. data/app/services/command_tower/messaging/rendering/channel_renderer.rb +63 -3
  30. data/app/services/command_tower/messaging/rendering/inbox_document.rb +27 -0
  31. data/app/services/command_tower/messaging/rendering/push_presentation.rb +90 -0
  32. data/app/services/command_tower/messaging/rendering/rendered_push_payload.rb +54 -6
  33. data/app/services/command_tower/messaging/sound_catalog.rb +85 -0
  34. data/app/services/command_tower/services/account/push/send_self_test.rb +7 -1
  35. data/app/services/command_tower/services/messaging/communications/produce.rb +27 -0
  36. data/app/services/command_tower/services/messaging/inbox.rb +5 -1
  37. data/app/workflows/command_tower/workflows/me/error_mapping.rb +1 -0
  38. data/app/workflows/command_tower/workflows/messaging/execution/deliver_workflow.rb +5 -1
  39. data/db/migrate/20261012000001_add_provider_metadata_to_messaging_delivery_attempts.rb +7 -0
  40. data/db/migrate/20261012000002_add_finished_at_index_to_messaging_delivery_attempts.rb +8 -0
  41. data/docs/upgrades/0.19.0.md +40 -0
  42. data/docs/upgrades/README.md +1 -0
  43. data/lib/command_tower/configuration/messaging/config.rb +10 -0
  44. data/lib/command_tower/configuration/messaging/expo.rb +61 -0
  45. data/lib/command_tower/install/baseline.rb +2 -0
  46. data/lib/command_tower/version.rb +1 -1
  47. metadata +13 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8b75ad05bfb533351f79dcf7c242e201a225d86cedd0a3f97358a01e91cbb830
4
- data.tar.gz: e5e89fe1645e781155862a124f37c625ad0804593959d118d5e8f2c523d629a2
3
+ metadata.gz: b932782bd089979dc908cc1865cdeefe8839fba2a4e5dbdb4956e00484345450
4
+ data.tar.gz: c88d647eaa666f9593416acb3771e407f22d2ef090c7035f45b65b4cc2ff9265
5
5
  SHA512:
6
- metadata.gz: fdbc47eafe86db87598541f3c4cbf86a4b23876e321eb137bef5f2a5d9e9b04f2adfb2d38c822b1980bfd150384443717eab03980442975aa93318fb909e2473
7
- data.tar.gz: d374ca61c7fef265974aa71ed8c9bee9c98401efb6328cc240b5685997c8fbf9c1e00928fd2bf2bdd0879e7a013acb0a46200a14133307df57b66da0a44ec391
6
+ metadata.gz: b3cff84a5ecf68c9929e4c9eca099c2f551a97a886e8c2882b6d4e411d064cb9df24fb64e626b35e08d53b72bcca68950c5e7cd4c197c482415f040e76f89be4
7
+ data.tar.gz: aaa5bf71dadf4a4a8de13bcc25dd9c826a68c20c37cc014b86130b86d8ad7733d6a575608057a1f19c1d4e2435b79c5a2f679d7f8bb7d73c411886c9dfdfd712
@@ -8,7 +8,7 @@ module CommandTower
8
8
  Input = Data.define(:notification_type_key, :preference_state)
9
9
 
10
10
  ROUTING_KEYS = %w[controller action format notification_type_key preference].freeze
11
- ALLOWED_PREFERENCE_KEYS = %w[inboxEnabled channels].freeze
11
+ ALLOWED_PREFERENCE_KEYS = %w[inboxEnabled channels urgency sound].freeze
12
12
 
13
13
  def call(params)
14
14
  raw = normalize_hash(params)
@@ -88,6 +88,15 @@ module CommandTower
88
88
  state["inbox"] = inbox
89
89
  end
90
90
 
91
+ %w[urgency sound].each do |field|
92
+ next unless preferences.key?(field)
93
+
94
+ value = preferences[field]
95
+ return failure(errors: { message: "invalid_#{field}" }) unless value.is_a?(String) && !value.strip.empty?
96
+
97
+ state[field] = value.strip
98
+ end
99
+
91
100
  state
92
101
  end
93
102
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Errors
5
+ module Account
6
+ class PushTestDestinationError < CommandTower::Errors::ApplicationError
7
+ def code
8
+ "push_test_destination_unresolved"
9
+ end
10
+
11
+ def message
12
+ "Push test destination is not configured"
13
+ end
14
+
15
+ def log_level
16
+ :warn
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -35,6 +35,12 @@ module CommandTower
35
35
  deleted_at.present?
36
36
  end
37
37
 
38
+ # NPB-5. Archived rows still open the inbox. Deleted rows do not.
39
+ # ChannelRenderer and the Expo adapter both use this predicate.
40
+ def live_for_push?
41
+ !deleted?
42
+ end
43
+
38
44
  def unread?
39
45
  !viewed? && !archived? && !deleted?
40
46
  end
@@ -73,7 +73,7 @@ module CommandTower
73
73
 
74
74
  class UnreadCountSerializer
75
75
  def self.serialize(payload)
76
- { count: payload.fetch(:count) }
76
+ { count: payload.fetch(:count), badgeCount: payload.fetch(:badge_count) }
77
77
  end
78
78
  end
79
79
 
@@ -16,6 +16,8 @@ module CommandTower
16
16
  inboxAvailable: notification.inbox_available,
17
17
  allowedChannels: notification.allowed_channels,
18
18
  availableChannels: notification.available_channels,
19
+ urgency: notification.urgency,
20
+ sound: notification.sound,
19
21
  preferences: {
20
22
  inboxEnabled: notification.inbox_enabled,
21
23
  channels: notification.channels,
@@ -8,6 +8,7 @@ module CommandTower
8
8
  :normalized_provider_status,
9
9
  :provider_message_id,
10
10
  :error_code,
11
+ :provider_metadata,
11
12
  ) do
12
13
  OUTCOMES = %i[success retryable_failure terminal_failure].freeze
13
14
 
@@ -15,7 +16,8 @@ module CommandTower
15
16
  outcome:,
16
17
  normalized_provider_status: nil,
17
18
  provider_message_id: nil,
18
- error_code: nil
19
+ error_code: nil,
20
+ provider_metadata: nil
19
21
  )
20
22
  raise InvalidAdapterContractError, "arbitrary hashes are not accepted as outcome" if outcome.is_a?(Hash)
21
23
  raise InvalidAdapterContractError, "raw exception objects are not accepted" if exception_like?(outcome) ||
@@ -28,12 +30,14 @@ module CommandTower
28
30
  validate_optional_string!(normalized_provider_status, "normalized_provider_status")
29
31
  validate_optional_string!(provider_message_id, "provider_message_id")
30
32
  validate_optional_string!(error_code, "error_code")
33
+ validate_optional_hash!(provider_metadata, "provider_metadata")
31
34
 
32
35
  new(
33
36
  outcome: normalized_outcome,
34
37
  normalized_provider_status:,
35
38
  provider_message_id:,
36
39
  error_code:,
40
+ provider_metadata:,
37
41
  ).freeze
38
42
  end
39
43
 
@@ -53,6 +57,12 @@ module CommandTower
53
57
  end
54
58
  private_class_method :validate_optional_string!
55
59
 
60
+ def self.validate_optional_hash!(value, field_name)
61
+ return if value.nil?
62
+ raise InvalidAdapterContractError, "#{field_name} must be a Hash" unless value.is_a?(Hash)
63
+ end
64
+ private_class_method :validate_optional_hash!
65
+
56
66
  def self.exception_like?(value)
57
67
  value.is_a?(Exception)
58
68
  end
@@ -17,6 +17,7 @@ module CommandTower
17
17
  :receipt,
18
18
  :outcome,
19
19
  :error_code,
20
+ :submitted,
20
21
  keyword_init: true,
21
22
  )
22
23
 
@@ -29,6 +30,7 @@ module CommandTower
29
30
  def call(request:)
30
31
  raise ArgumentError, "request must be an AdapterRequest" unless request.is_a?(AdapterRequest)
31
32
 
33
+ attempts = nil
32
34
  rendered = request.rendered
33
35
  unless rendered.is_a?(Rendering::RenderedPushPayload)
34
36
  return AdapterResult.build(outcome: :terminal_failure, error_code: "render_failed")
@@ -43,25 +45,43 @@ module CommandTower
43
45
  return AdapterResult.build(outcome: :terminal_failure, error_code: "recipient_missing")
44
46
  end
45
47
 
46
- delivery = Messaging::ChannelDelivery.includes(:communication).find_by(id: request.channel_delivery_id)
48
+ delivery = Messaging::ChannelDelivery.includes(communication: :inbox_item).find_by(id: request.channel_delivery_id)
47
49
  owner_user_id = delivery&.communication&.user_id
48
50
  if owner_user_id.nil?
49
51
  return AdapterResult.build(outcome: :terminal_failure, error_code: "recipient_missing")
50
52
  end
51
53
 
52
- attempts = build_endpoint_attempts(owner_user_id:, endpoint_ids:)
54
+ history = EndpointTicketHistory.call(
55
+ channel_delivery_id: delivery.id,
56
+ excluding_attempt_id: request.attempt_id,
57
+ )
58
+ unsettled_ids = endpoint_ids - history.settled_endpoint_ids
59
+ return settled_only_result(history) if unsettled_ids.empty?
60
+
61
+ attempts = build_endpoint_attempts(owner_user_id:, endpoint_ids: unsettled_ids)
53
62
  return attempts if attempts.is_a?(AdapterResult)
54
63
 
55
64
  case @configuration.adapter_name
56
65
  when "fake", "log"
57
66
  AdapterResult.build(outcome: :success, normalized_provider_status: "accepted")
58
67
  when "http"
59
- deliver_http(attempts:, rendered:, owner_user_id:)
68
+ deliver_http(
69
+ attempts:,
70
+ rendered:,
71
+ owner_user_id:,
72
+ history:,
73
+ communication: delivery.communication,
74
+ )
60
75
  else
61
76
  AdapterResult.build(outcome: :terminal_failure, error_code: "adapter_unconfigured")
62
77
  end
63
78
  rescue StandardError
64
- AdapterResult.build(outcome: :retryable_failure, error_code: "expo_transient")
79
+ # Keep the endpoint facts already recorded; the outcome stays today's rescue-all result.
80
+ AdapterResult.build(
81
+ outcome: :retryable_failure,
82
+ error_code: "expo_transient",
83
+ provider_metadata: provider_metadata_for(attempts),
84
+ )
65
85
  end
66
86
 
67
87
  private
@@ -86,13 +106,14 @@ module CommandTower
86
106
  attempts
87
107
  end
88
108
 
89
- def deliver_http(attempts:, rendered:, owner_user_id:)
109
+ def deliver_http(attempts:, rendered:, owner_user_id:, history:, communication:)
90
110
  sendable = attempts.select { |attempt| attempt.token.present? && attempt.outcome.nil? }
91
- messages = sendable.map { |attempt| expo_message(token: attempt.token, rendered:) }
111
+ messages = sendable.map { |attempt| expo_message(token: attempt.token, rendered:, communication:) }
92
112
 
113
+ sendable.each { |attempt| attempt.submitted = true }
93
114
  send_result = @http_client.send_messages(messages)
94
115
  unless send_result[:ok]
95
- return map_http_failure(send_result[:status_code])
116
+ return map_http_failure(send_result[:status_code], attempts:, history:)
96
117
  end
97
118
 
98
119
  tickets = Array(send_result[:tickets])
@@ -124,19 +145,49 @@ module CommandTower
124
145
  mark_invalid_if_needed!(owner_user_id:, attempt:)
125
146
  end
126
147
 
127
- aggregate_result(attempts)
148
+ aggregate_result(attempts, history:)
149
+ end
150
+
151
+ # Every eligible endpoint already holds an accepted ticket or a recorded terminal
152
+ # ticket error from an earlier attempt, so there is nothing to send.
153
+ def settled_only_result(history)
154
+ if history.accepted?
155
+ return AdapterResult.build(
156
+ outcome: :success,
157
+ normalized_provider_status: "accepted",
158
+ provider_message_id: history.first_accepted_ticket_id,
159
+ )
160
+ end
161
+
162
+ AdapterResult.build(
163
+ outcome: :terminal_failure,
164
+ error_code: history.first_terminal_error_code || "expo_rejected",
165
+ )
128
166
  end
129
167
 
130
- def expo_message(token:, rendered:)
168
+ def expo_message(token:, rendered:, communication:)
131
169
  message = {
132
170
  "to" => token,
133
171
  "title" => rendered.title,
134
172
  "body" => rendered.body,
135
- "sound" => "default",
173
+ "sound" => rendered.sound || "default",
136
174
  }
137
- if rendered.deep_link.present?
138
- message["data"] = { "deepLink" => rendered.deep_link }
175
+ interruption_level = InterruptionLevel.call(
176
+ urgency: rendered.urgency,
177
+ supported: @configuration.supported_interruption_levels,
178
+ )
179
+ message["interruptionLevel"] = interruption_level if interruption_level
180
+ message["threadId"] = rendered.thread_id if rendered.thread_id
181
+ # nil omits the field. 0 is a real count and must be sent. In Ruby, 0 is truthy.
182
+ message["badge"] = rendered.badge unless rendered.badge.nil?
183
+ data = {}
184
+ data["deepLink"] = rendered.deep_link if rendered.deep_link.present?
185
+ item = communication&.inbox_item
186
+ if item&.live_for_push?
187
+ data["inboxItemId"] = item.id.to_s
188
+ data["inboxEntryId"] = communication.conversation_key.presence || item.id.to_s
139
189
  end
190
+ message["data"] = data if data.any?
140
191
  message
141
192
  end
142
193
 
@@ -195,31 +246,101 @@ module CommandTower
195
246
  nil
196
247
  end
197
248
 
198
- def map_http_failure(status_code)
199
- if status_code.to_i >= 500 || status_code.to_i == 429
200
- AdapterResult.build(outcome: :retryable_failure, error_code: "expo_transient")
201
- else
202
- AdapterResult.build(outcome: :terminal_failure, error_code: "expo_rejected")
203
- end
249
+ # 5xx and 429 stay retryable. Any other non-success response is terminal for this
250
+ # attempt even when an earlier attempt had accepted tickets; those tickets are carried
251
+ # into the metadata so the accepted facts stay visible on this row.
252
+ def map_http_failure(status_code, attempts:, history:)
253
+ retryable = status_code.to_i >= 500 || status_code.to_i == 429
254
+ outcome = retryable ? :retryable_failure : :terminal_failure
255
+ error_code = retryable ? "expo_transient" : "expo_rejected"
256
+ attempts.each { |attempt| attempt.error_code ||= error_code if attempt.submitted }
257
+ carried = retryable ? [] : carried_accepted_entries(history)
258
+
259
+ AdapterResult.build(outcome:, error_code:, provider_metadata: provider_metadata_for(attempts, carried:))
204
260
  end
205
261
 
206
- def aggregate_result(attempts)
262
+ # Delivery-wide aggregation: this attempt's endpoints plus tickets recorded earlier.
263
+ def aggregate_result(attempts, history:)
207
264
  successes = attempts.select { |attempt| attempt.outcome == :success }
208
- if successes.any?
209
- first_ticket_id = successes.filter_map { |attempt| attempt.ticket&.dig(:id) }.first
265
+ first_ticket_id = successes.filter_map { |attempt| attempt.ticket&.dig(:id) }.first
266
+ metadata = provider_metadata_for(attempts)
267
+
268
+ if attempts.any? { |attempt| attempt.outcome == :retryable_failure }
269
+ return AdapterResult.build(
270
+ outcome: :retryable_failure,
271
+ error_code: "expo_transient",
272
+ provider_message_id: first_ticket_id,
273
+ provider_metadata: metadata,
274
+ )
275
+ end
276
+
277
+ if successes.any? || history.accepted?
210
278
  return AdapterResult.build(
211
279
  outcome: :success,
212
280
  normalized_provider_status: "accepted",
213
- provider_message_id: first_ticket_id,
281
+ provider_message_id: first_ticket_id || history.first_accepted_ticket_id,
282
+ provider_metadata: metadata,
214
283
  )
215
284
  end
216
285
 
217
286
  if attempts.all? { |attempt| attempt.outcome == :terminal_failure }
218
- error_code = attempts.map(&:error_code).compact.first || "expo_rejected"
219
- return AdapterResult.build(outcome: :terminal_failure, error_code:)
287
+ error_code = attempts.filter_map(&:error_code).first ||
288
+ history.first_terminal_error_code ||
289
+ "expo_rejected"
290
+ return AdapterResult.build(outcome: :terminal_failure, error_code:, provider_metadata: metadata)
291
+ end
292
+
293
+ AdapterResult.build(
294
+ outcome: :retryable_failure,
295
+ error_code: "expo_transient",
296
+ provider_metadata: metadata,
297
+ )
298
+ end
299
+
300
+ def carried_accepted_entries(history)
301
+ history.accepted_ticket_ids.map do |endpoint_id, ticket_id|
302
+ entry = { "endpoint_id" => endpoint_id, "ticket_status" => "ok" }
303
+ entry["ticket_id"] = ticket_id if ticket_id.present?
304
+ entry
220
305
  end
306
+ end
307
+
308
+ # Per-device facts as recorded so far. Endpoints never submitted to Expo carry
309
+ # no ticket or receipt status. No tokens, bodies, or exceptions are included.
310
+ def provider_metadata_for(attempts, carried: [])
311
+ return nil unless attempts.is_a?(Array) && attempts.any?(&:submitted)
312
+
313
+ { "expo" => { "endpoints" => attempts.map { |attempt| endpoint_metadata(attempt) } + carried } }
314
+ end
221
315
 
222
- AdapterResult.build(outcome: :retryable_failure, error_code: "expo_transient")
316
+ def endpoint_metadata(attempt)
317
+ entry = { "endpoint_id" => attempt.endpoint_id }
318
+ if attempt.submitted
319
+ ticket_id = attempt.ticket&.dig(:id)
320
+ entry["ticket_id"] = ticket_id if ticket_id.present?
321
+ entry["ticket_status"] = ticket_status_for(attempt)
322
+ entry["receipt_status"] = receipt_status_for(attempt)
323
+ end
324
+ entry["error_code"] = attempt.error_code if attempt.error_code.present?
325
+ entry
326
+ end
327
+
328
+ def ticket_status_for(attempt)
329
+ case attempt.ticket&.dig(:status)
330
+ when "ok" then "ok"
331
+ when "error" then "error"
332
+ else "missing"
333
+ end
334
+ end
335
+
336
+ def receipt_status_for(attempt)
337
+ return "unchecked" unless ticket_status_for(attempt) == "ok"
338
+
339
+ case attempt.receipt&.dig(:status)
340
+ when "ok" then "ok"
341
+ when "error" then "error"
342
+ else "pending"
343
+ end
223
344
  end
224
345
  end
225
346
  end
@@ -34,6 +34,11 @@ module CommandTower
34
34
  def access_token
35
35
  CommandTower.config.messaging.expo.access_token.to_s.strip
36
36
  end
37
+
38
+ # Urgency levels the host's shipped binary can honor. Normal is always supported.
39
+ def supported_interruption_levels
40
+ (Array(CommandTower.config.messaging.expo.interruption_levels).map(&:to_s) | ["normal"]).freeze
41
+ end
37
42
  end
38
43
  end
39
44
  end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Messaging
5
+ module Execution
6
+ module Adapters
7
+ module Expo
8
+ # Read-only lookup over the provider_metadata that earlier attempts of one delivery
9
+ # recorded: which endpoints already hold an accepted Expo ticket and which hit a
10
+ # terminal ticket error. Used so a retry only sends to endpoints still unsettled.
11
+ class EndpointTicketHistory
12
+ History = Data.define(:accepted_ticket_ids, :terminal_error_codes) do
13
+ def settled_endpoint_ids
14
+ accepted_ticket_ids.keys | terminal_error_codes.keys
15
+ end
16
+
17
+ def accepted?
18
+ accepted_ticket_ids.any?
19
+ end
20
+
21
+ def first_accepted_ticket_id
22
+ accepted_ticket_ids.values.first
23
+ end
24
+
25
+ def first_terminal_error_code
26
+ terminal_error_codes.values.first
27
+ end
28
+ end
29
+
30
+ def self.call(channel_delivery_id:, excluding_attempt_id:)
31
+ new(channel_delivery_id:, excluding_attempt_id:).call
32
+ end
33
+
34
+ def initialize(channel_delivery_id:, excluding_attempt_id:)
35
+ @channel_delivery_id = channel_delivery_id
36
+ @excluding_attempt_id = excluding_attempt_id
37
+ end
38
+
39
+ def call
40
+ accepted = {}
41
+ rejected = {}
42
+
43
+ recorded_endpoints.each do |entry|
44
+ endpoint_id = entry["endpoint_id"]
45
+ next if endpoint_id.nil?
46
+
47
+ case entry["ticket_status"]
48
+ when "ok"
49
+ if entry["receipt_status"] == "error"
50
+ # Accepted ticket but the receipt reported a failure: terminal for that device.
51
+ rejected[endpoint_id] ||= entry["error_code"]
52
+ else
53
+ accepted[endpoint_id] ||= entry["ticket_id"]
54
+ end
55
+ when "error"
56
+ rejected[endpoint_id] ||= entry["error_code"]
57
+ end
58
+ end
59
+
60
+ History.new(
61
+ accepted_ticket_ids: accepted.freeze,
62
+ terminal_error_codes: rejected.except(*accepted.keys).freeze,
63
+ ).freeze
64
+ end
65
+
66
+ private
67
+
68
+ def recorded_endpoints
69
+ Messaging::DeliveryAttempt
70
+ .where(channel_delivery_id: @channel_delivery_id)
71
+ .where.not(id: @excluding_attempt_id)
72
+ .order(:started_at, :id)
73
+ .pluck(:provider_metadata)
74
+ .flat_map { |metadata| endpoints_in(metadata) }
75
+ end
76
+
77
+ def endpoints_in(metadata)
78
+ endpoints = metadata.is_a?(Hash) ? metadata.dig("expo", "endpoints") : nil
79
+ endpoints.is_a?(Array) ? endpoints.grep(Hash) : []
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Messaging
5
+ module Execution
6
+ module Adapters
7
+ module Expo
8
+ # Maps a member's neutral urgency to Expo's `interruptionLevel`, degrading to the
9
+ # highest level the host says its shipped binary can honor:
10
+ # critical -> time_sensitive -> normal. Normal is the platform default, so it
11
+ # sends no level. Pure: no I/O.
12
+ class InterruptionLevel
13
+ EXPO_LEVELS = {
14
+ "time_sensitive" => "time-sensitive",
15
+ "critical" => "critical",
16
+ }.freeze
17
+ DEGRADE_ORDER = %w[critical time_sensitive].freeze
18
+
19
+ def self.call(urgency:, supported:)
20
+ new(urgency:, supported:).call
21
+ end
22
+
23
+ def initialize(urgency:, supported:)
24
+ @urgency = urgency.to_s
25
+ @supported = Array(supported).map(&:to_s)
26
+ end
27
+
28
+ # The Expo level string, or nil when the message should carry none.
29
+ def call
30
+ start = DEGRADE_ORDER.index(@urgency)
31
+ return nil if start.nil?
32
+
33
+ level = DEGRADE_ORDER.drop(start).find { |candidate| @supported.include?(candidate) }
34
+ EXPO_LEVELS[level]
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -55,6 +55,7 @@ module CommandTower
55
55
  finished_at: now,
56
56
  normalized_provider_status: result.normalized_provider_status,
57
57
  provider_message_id: result.provider_message_id,
58
+ provider_metadata: result.provider_metadata,
58
59
  error_code: nil,
59
60
  error_class: nil,
60
61
  )
@@ -65,6 +66,7 @@ module CommandTower
65
66
  finished_at: now,
66
67
  normalized_provider_status: result.normalized_provider_status,
67
68
  provider_message_id: result.provider_message_id,
69
+ provider_metadata: result.provider_metadata,
68
70
  error_code: result.error_code,
69
71
  error_class: nil,
70
72
  )
@@ -75,6 +77,7 @@ module CommandTower
75
77
  finished_at: now,
76
78
  normalized_provider_status: result.normalized_provider_status,
77
79
  provider_message_id: result.provider_message_id,
80
+ provider_metadata: result.provider_metadata,
78
81
  error_code: result.error_code,
79
82
  error_class: nil,
80
83
  )