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.
- checksums.yaml +4 -4
- data/app/deserializers/command_tower/deserializers/messaging/preferences/update_deserializer.rb +10 -1
- data/app/errors/command_tower/errors/account/push_test_destination_error.rb +21 -0
- data/app/models/command_tower/messaging/inbox_item.rb +6 -0
- data/app/serializers/command_tower/serializers/messaging/inbox.rb +1 -1
- data/app/serializers/command_tower/serializers/messaging/preferences/notification_serializer.rb +2 -0
- data/app/services/command_tower/messaging/execution/adapter_result.rb +11 -1
- data/app/services/command_tower/messaging/execution/adapters/expo/adapter.rb +146 -25
- data/app/services/command_tower/messaging/execution/adapters/expo/configuration.rb +5 -0
- data/app/services/command_tower/messaging/execution/adapters/expo/endpoint_ticket_history.rb +86 -0
- data/app/services/command_tower/messaging/execution/adapters/expo/interruption_level.rb +41 -0
- data/app/services/command_tower/messaging/execution/attempt_ledger.rb +3 -0
- data/app/services/command_tower/messaging/execution/expo_receipt_collection.rb +224 -0
- data/app/services/command_tower/messaging/execution/operation_logger.rb +55 -5
- data/app/services/command_tower/messaging/execution/recovery.rb +8 -0
- data/app/services/command_tower/messaging/inbox/reader.rb +15 -2
- data/app/services/command_tower/messaging/inbox/unread_count_result.rb +3 -2
- data/app/services/command_tower/messaging/notification_types/declaration.rb +19 -1
- data/app/services/command_tower/messaging/notification_types/declaration_validator.rb +62 -0
- data/app/services/command_tower/messaging/notification_types/permitted_choice.rb +55 -0
- data/app/services/command_tower/messaging/notification_types/registry.rb +25 -0
- data/app/services/command_tower/messaging/notification_types.rb +3 -0
- data/app/services/command_tower/messaging/preferences/catalog.rb +7 -0
- data/app/services/command_tower/messaging/preferences/catalog_notification_result.rb +26 -1
- data/app/services/command_tower/messaging/preferences/effective_choice.rb +92 -0
- data/app/services/command_tower/messaging/preferences/preference_state.rb +24 -4
- data/app/services/command_tower/messaging/preferences/store.rb +10 -0
- data/app/services/command_tower/messaging/preferences/update.rb +36 -4
- data/app/services/command_tower/messaging/rendering/channel_renderer.rb +63 -3
- data/app/services/command_tower/messaging/rendering/inbox_document.rb +27 -0
- data/app/services/command_tower/messaging/rendering/push_presentation.rb +90 -0
- data/app/services/command_tower/messaging/rendering/rendered_push_payload.rb +54 -6
- data/app/services/command_tower/messaging/sound_catalog.rb +85 -0
- data/app/services/command_tower/services/account/push/send_self_test.rb +7 -1
- data/app/services/command_tower/services/messaging/communications/produce.rb +27 -0
- data/app/services/command_tower/services/messaging/inbox.rb +5 -1
- data/app/workflows/command_tower/workflows/me/error_mapping.rb +1 -0
- data/app/workflows/command_tower/workflows/messaging/execution/deliver_workflow.rb +5 -1
- data/db/migrate/20261012000001_add_provider_metadata_to_messaging_delivery_attempts.rb +7 -0
- data/db/migrate/20261012000002_add_finished_at_index_to_messaging_delivery_attempts.rb +8 -0
- data/docs/upgrades/0.19.0.md +40 -0
- data/docs/upgrades/README.md +1 -0
- data/lib/command_tower/configuration/messaging/config.rb +10 -0
- data/lib/command_tower/configuration/messaging/expo.rb +61 -0
- data/lib/command_tower/install/baseline.rb +2 -0
- data/lib/command_tower/version.rb +1 -1
- metadata +13 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b932782bd089979dc908cc1865cdeefe8839fba2a4e5dbdb4956e00484345450
|
|
4
|
+
data.tar.gz: c88d647eaa666f9593416acb3771e407f22d2ef090c7035f45b65b4cc2ff9265
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b3cff84a5ecf68c9929e4c9eca099c2f551a97a886e8c2882b6d4e411d064cb9df24fb64e626b35e08d53b72bcca68950c5e7cd4c197c482415f040e76f89be4
|
|
7
|
+
data.tar.gz: aaa5bf71dadf4a4a8de13bcc25dd9c826a68c20c37cc014b86130b86d8ad7733d6a575608057a1f19c1d4e2435b79c5a2f679d7f8bb7d73c411886c9dfdfd712
|
data/app/deserializers/command_tower/deserializers/messaging/preferences/update_deserializer.rb
CHANGED
|
@@ -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
|
data/app/serializers/command_tower/serializers/messaging/preferences/notification_serializer.rb
CHANGED
|
@@ -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(:
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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.
|
|
219
|
-
|
|
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
|
-
|
|
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
|
)
|