command_tower 0.13.1 → 0.14.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/command_tower/me/push_controller.rb +66 -0
  3. data/app/deserializers/command_tower/deserializers/me/push/token_deserializer.rb +42 -0
  4. data/app/errors/command_tower/errors/account/push_capability_unavailable_error.rb +21 -0
  5. data/app/errors/command_tower/errors/account/push_endpoint_not_found_error.rb +21 -0
  6. data/app/serializers/command_tower/serializers/me/push_serializer.rb +47 -0
  7. data/app/services/command_tower/messaging/channel_detectors.rb +6 -0
  8. data/app/services/command_tower/messaging/endpoints/validators/push.rb +6 -0
  9. data/app/services/command_tower/messaging/execution/adapter_request.rb +21 -3
  10. data/app/services/command_tower/messaging/execution/adapters/expo/adapter.rb +229 -0
  11. data/app/services/command_tower/messaging/execution/adapters/expo/configuration.rb +42 -0
  12. data/app/services/command_tower/messaging/execution/adapters/expo/http_client.rb +137 -0
  13. data/app/services/command_tower/messaging/execution/resolve_recipient_address.rb +7 -0
  14. data/app/services/command_tower/messaging/execution/select_adapter.rb +4 -0
  15. data/app/services/command_tower/messaging/rendering/channel_renderer.rb +21 -3
  16. data/app/services/command_tower/messaging/rendering/rendered_push_payload.rb +50 -0
  17. data/app/services/command_tower/services/account/push/create.rb +25 -0
  18. data/app/services/command_tower/services/account/push/ct_support.rb +38 -0
  19. data/app/services/command_tower/services/account/push/destroy.rb +23 -0
  20. data/app/services/command_tower/services/account/push/list.rb +23 -0
  21. data/app/services/command_tower/services/account/push/replace.rb +26 -0
  22. data/app/services/command_tower/services/me/push_product_gate.rb +18 -0
  23. data/app/views/command_tower/messaging/rendering/push.text.erb +1 -0
  24. data/app/workflows/command_tower/workflows/me/error_mapping.rb +3 -1
  25. data/app/workflows/command_tower/workflows/me/push/create_workflow.rb +37 -0
  26. data/app/workflows/command_tower/workflows/me/push/destroy_workflow.rb +37 -0
  27. data/app/workflows/command_tower/workflows/me/push/index_workflow.rb +34 -0
  28. data/app/workflows/command_tower/workflows/me/push/replace_workflow.rb +38 -0
  29. data/app/workflows/command_tower/workflows/me/push/workflow_support.rb +35 -0
  30. data/app/workflows/command_tower/workflows/messaging/execution/deliver_workflow.rb +1 -0
  31. data/config/routes.rb +9 -0
  32. data/docs/api_reference.md +21 -0
  33. data/docs/controllers.md +1 -0
  34. data/docs/messaging_integration_guide.md +8 -3
  35. data/docs/upgrades/0.14.0.md +31 -0
  36. data/docs/upgrades/README.md +1 -0
  37. data/lib/command_tower/authorization/default.yml +7 -0
  38. data/lib/command_tower/configuration/messaging/config.rb +6 -0
  39. data/lib/command_tower/configuration/messaging/expo.rb +38 -0
  40. data/lib/command_tower/version.rb +1 -1
  41. metadata +25 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5243f531ae8a96ca6373b331d30665a0417078f9b799ca0491c78a26231b639e
4
- data.tar.gz: 6d6ed873a0a3012fd8b09b952be2b4a32652980fea5ba6871c8089ffdba71ade
3
+ metadata.gz: bce961e0a6770bd17a23396e3fdb5066655dbe937f91c85b00c4605802c48f7d
4
+ data.tar.gz: e5247dda098ad94825317ec31cfff34c1fa060aac6fcf432ca5a082f93c83e02
5
5
  SHA512:
6
- metadata.gz: 94aa74a9fe97771c5b690db1fa00298dd551ba9a0a62430097a3ffac583e4be5c66198768e8262f670863533fa90d18ddc5ab89e34a64fd5967d00bbf550f93b
7
- data.tar.gz: 57431bc097a11ea361fe5e56d7ef797a6c07afbd076219745a0687fb33d456e809bf693785eb7d794e0a0cf9443f414d78522dc742e8d8bd5219a508155ef41d
6
+ metadata.gz: dd5117447c0bf551b4b55e3a402fbda78a915c8bd0e31a2fbc10a785ef747cc9c6b7c5c8e3aa5306978872fd89910233fb67a69563a3dc1c5105fe06ce67834b
7
+ data.tar.gz: bb8d35c52ca0e32b44134015a81462350357fa5e678e3e3bce0daa11a3f76c2e14ff7d2e0731f8ddea812185f579df464886e01464670e68a375490d21528b03
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Me
5
+ class PushController < CommandTower::ApplicationController
6
+ include CommandTower::Auth::AuthenticationBoundary
7
+ include CommandTower::Auth::AuthorizationBoundary
8
+
9
+ before_action :authenticate_request!
10
+ before_action :authorize_request!
11
+
12
+ def index
13
+ result = CommandTower::Workflows::Me::Push::IndexWorkflow.call(
14
+ current_user: current_user,
15
+ auth_context: current_auth_context,
16
+ )
17
+ render_application_result(result)
18
+ end
19
+
20
+ def create
21
+ deserialized = CommandTower::Deserializers::Me::Push::TokenDeserializer.call(params)
22
+ return render_deserializer_errors unless deserialized.success?
23
+
24
+ result = CommandTower::Workflows::Me::Push::CreateWorkflow.call(
25
+ current_user: current_user,
26
+ address: deserialized.input.address,
27
+ auth_context: current_auth_context,
28
+ )
29
+ render_application_result(result)
30
+ end
31
+
32
+ def update
33
+ deserialized = CommandTower::Deserializers::Me::Push::TokenDeserializer.call(params)
34
+ return render_deserializer_errors unless deserialized.success?
35
+
36
+ result = CommandTower::Workflows::Me::Push::ReplaceWorkflow.call(
37
+ current_user: current_user,
38
+ endpoint_id: params[:id],
39
+ address: deserialized.input.address,
40
+ auth_context: current_auth_context,
41
+ )
42
+ render_application_result(result)
43
+ end
44
+
45
+ def destroy
46
+ result = CommandTower::Workflows::Me::Push::DestroyWorkflow.call(
47
+ current_user: current_user,
48
+ endpoint_id: params[:id],
49
+ auth_context: current_auth_context,
50
+ )
51
+ render_application_result(result)
52
+ end
53
+
54
+ private
55
+
56
+ def render_deserializer_errors
57
+ render_application_result(
58
+ CommandTower::Workflows::WorkflowResult.failure(
59
+ errors: [CommandTower::Errors::ValidationError.new(details: { base: "Missing or invalid push token" })],
60
+ http_status: :unprocessable_entity,
61
+ ),
62
+ )
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Deserializers
5
+ module Me
6
+ module Push
7
+ class TokenDeserializer < CommandTower::Deserializers::ApplicationDeserializer
8
+ Input = Data.define(:address)
9
+
10
+ EXPO_TOKEN = CommandTower::Messaging::Endpoints::Validators::Push::EXPO_TOKEN
11
+
12
+ def call(params)
13
+ address = extract(params, :token, :Token, :address, :Address)
14
+
15
+ if address.blank?
16
+ return failure(errors: { message: "missing_required_fields" })
17
+ end
18
+
19
+ unless address.match?(EXPO_TOKEN)
20
+ return failure(errors: { message: "invalid_push_token" })
21
+ end
22
+
23
+ success(Input.new(address:))
24
+ end
25
+
26
+ private
27
+
28
+ def extract(params, *keys)
29
+ keys.each do |key|
30
+ raw = params[key] || params[key.to_s]
31
+ next if raw.nil?
32
+
33
+ value = raw.to_s.strip
34
+ return value unless value.empty?
35
+ end
36
+ ""
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Errors
5
+ module Account
6
+ class PushCapabilityUnavailableError < CommandTower::Errors::ApplicationError
7
+ def code
8
+ "push_capability_unavailable"
9
+ end
10
+
11
+ def message
12
+ "Push notifications are currently unavailable"
13
+ end
14
+
15
+ def log_level
16
+ :warn
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Errors
5
+ module Account
6
+ class PushEndpointNotFoundError < CommandTower::Errors::ApplicationError
7
+ def code
8
+ "push_endpoint_not_found"
9
+ end
10
+
11
+ def message
12
+ "Push endpoint was not found"
13
+ end
14
+
15
+ def log_level
16
+ :info
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Serializers
5
+ module Me
6
+ class PushSerializer
7
+ def self.serialize(safe_view)
8
+ new(safe_view).serialize
9
+ end
10
+
11
+ def self.serialize_collection(safe_views)
12
+ {
13
+ endpoints: Array(safe_views).map { |view| serialize(view) },
14
+ }
15
+ end
16
+
17
+ def initialize(safe_view)
18
+ @safe_view = safe_view
19
+ end
20
+
21
+ def serialize
22
+ {
23
+ id: @safe_view.id,
24
+ channelKey: @safe_view.channel_key,
25
+ lifecycleState: @safe_view.lifecycle_state,
26
+ verificationState: @safe_view.verification_state,
27
+ maskedDisplayValue: @safe_view.masked_display_value,
28
+ verifiedAt: @safe_view.verified_at&.iso8601,
29
+ createdAt: @safe_view.created_at&.iso8601,
30
+ updatedAt: @safe_view.updated_at&.iso8601,
31
+ actions: actions_for(@safe_view),
32
+ }
33
+ end
34
+
35
+ private
36
+
37
+ def actions_for(view)
38
+ active = view.lifecycle_state.to_s == "active"
39
+ {
40
+ canReplace: active,
41
+ canRemove: active,
42
+ }
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -26,6 +26,10 @@ module CommandTower
26
26
  Execution::Adapters::Pushover::Configuration.pushover_configured?
27
27
  end
28
28
 
29
+ def expo_configured?
30
+ Execution::Adapters::Expo::Configuration.expo_configured?
31
+ end
32
+
29
33
  # Used by RecipientReadiness#platform_configured_for? (fail-closed).
30
34
  def configured?(channel_key)
31
35
  case channel_key.to_s
@@ -37,6 +41,8 @@ module CommandTower
37
41
  sms_configured?
38
42
  when "pushover"
39
43
  pushover_configured?
44
+ when "push"
45
+ expo_configured?
40
46
  else
41
47
  false
42
48
  end
@@ -7,10 +7,16 @@ module CommandTower
7
7
  module Push
8
8
  module_function
9
9
 
10
+ # Expo push token forms used by expo-notifications / Expo Push API.
11
+ EXPO_TOKEN = /\A(ExponentPushToken|ExpoPushToken)\[[^\]]+\]\z/
12
+
10
13
  def validate!(address)
11
14
  token = address.to_s.strip
12
15
  raise ValidationError, "push token must be present" if token.empty?
13
16
  raise ValidationError, "push token is too short" if token.length < 8
17
+ unless token.match?(EXPO_TOKEN)
18
+ raise ValidationError, "push token must be an Expo push token (ExponentPushToken[...] or ExpoPushToken[...])"
19
+ end
14
20
 
15
21
  Result.new(
16
22
  normalized_address: token,
@@ -9,8 +9,16 @@ module CommandTower
9
9
  :channel_key,
10
10
  :attempt_id,
11
11
  :rendered,
12
+ :eligible_endpoint_ids,
12
13
  ) do
13
- def self.build(channel_delivery_id:, communication_id:, channel_key:, attempt_id:, rendered:)
14
+ def self.build(
15
+ channel_delivery_id:,
16
+ communication_id:,
17
+ channel_key:,
18
+ attempt_id:,
19
+ rendered:,
20
+ eligible_endpoint_ids: []
21
+ )
14
22
  raise InvalidAdapterContractError, "arbitrary hashes are not accepted" if [channel_delivery_id, communication_id, channel_key, attempt_id].any? { |v| v.is_a?(Hash) }
15
23
  raise InvalidAdapterContractError, "channel_delivery_id is required" if blank?(channel_delivery_id)
16
24
  raise InvalidAdapterContractError, "communication_id is required" if blank?(communication_id)
@@ -20,11 +28,20 @@ module CommandTower
20
28
  raise InvalidAdapterContractError, "rendered is required" if rendered.nil?
21
29
  unless rendered.is_a?(Rendering::RenderedPayload) ||
22
30
  rendered.is_a?(Rendering::RenderedSmsPayload) ||
23
- rendered.is_a?(Rendering::RenderedPushoverPayload)
31
+ rendered.is_a?(Rendering::RenderedPushoverPayload) ||
32
+ rendered.is_a?(Rendering::RenderedPushPayload)
24
33
  raise InvalidAdapterContractError,
25
- "rendered must be a RenderedPayload, RenderedSmsPayload, or RenderedPushoverPayload"
34
+ "rendered must be a RenderedPayload, RenderedSmsPayload, RenderedPushoverPayload, or RenderedPushPayload"
26
35
  end
27
36
  raise InvalidAdapterContractError, "rendered must be frozen" unless rendered.frozen?
37
+ raise InvalidAdapterContractError, "eligible_endpoint_ids must be an Array" unless eligible_endpoint_ids.is_a?(Array)
38
+
39
+ ids = eligible_endpoint_ids.map do |id|
40
+ integer_id = Integer(id, exception: false)
41
+ raise InvalidAdapterContractError, "eligible_endpoint_ids must be Integers" if integer_id.nil?
42
+
43
+ integer_id
44
+ end
28
45
 
29
46
  new(
30
47
  channel_delivery_id:,
@@ -32,6 +49,7 @@ module CommandTower
32
49
  channel_key:,
33
50
  attempt_id:,
34
51
  rendered:,
52
+ eligible_endpoint_ids: ids.freeze,
35
53
  ).freeze
36
54
  end
37
55
 
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Messaging
5
+ module Execution
6
+ module Adapters
7
+ module Expo
8
+ # Execution adapter: fans out to eligible push endpoints, sends via Expo
9
+ # Push API, fetches receipts once for ok tickets, and marks invalid devices.
10
+ class Adapter
11
+ DEVICE_INVALID_ERRORS = %w[DeviceNotRegistered].freeze
12
+
13
+ EndpointAttempt = Struct.new(
14
+ :endpoint_id,
15
+ :token,
16
+ :ticket,
17
+ :receipt,
18
+ :outcome,
19
+ :error_code,
20
+ keyword_init: true,
21
+ )
22
+
23
+ def initialize(configuration: nil, http_client: nil, secret_reader: nil)
24
+ @configuration = configuration || Configuration.new
25
+ @http_client = http_client || HttpClient.new(configuration: @configuration)
26
+ @secret_reader = secret_reader || Endpoints::SecretReader
27
+ end
28
+
29
+ def call(request:)
30
+ raise ArgumentError, "request must be an AdapterRequest" unless request.is_a?(AdapterRequest)
31
+
32
+ rendered = request.rendered
33
+ unless rendered.is_a?(Rendering::RenderedPushPayload)
34
+ return AdapterResult.build(outcome: :terminal_failure, error_code: "render_failed")
35
+ end
36
+
37
+ unless @configuration.expo_configured?
38
+ return AdapterResult.build(outcome: :terminal_failure, error_code: "adapter_unconfigured")
39
+ end
40
+
41
+ endpoint_ids = Array(request.eligible_endpoint_ids)
42
+ if endpoint_ids.empty?
43
+ return AdapterResult.build(outcome: :terminal_failure, error_code: "recipient_missing")
44
+ end
45
+
46
+ delivery = Messaging::ChannelDelivery.includes(:communication).find_by(id: request.channel_delivery_id)
47
+ owner_user_id = delivery&.communication&.user_id
48
+ if owner_user_id.nil?
49
+ return AdapterResult.build(outcome: :terminal_failure, error_code: "recipient_missing")
50
+ end
51
+
52
+ attempts = build_endpoint_attempts(owner_user_id:, endpoint_ids:)
53
+ return attempts if attempts.is_a?(AdapterResult)
54
+
55
+ case @configuration.adapter_name
56
+ when "fake", "log"
57
+ AdapterResult.build(outcome: :success, normalized_provider_status: "accepted")
58
+ when "http"
59
+ deliver_http(attempts:, rendered:, owner_user_id:)
60
+ else
61
+ AdapterResult.build(outcome: :terminal_failure, error_code: "adapter_unconfigured")
62
+ end
63
+ rescue StandardError
64
+ AdapterResult.build(outcome: :retryable_failure, error_code: "expo_transient")
65
+ end
66
+
67
+ private
68
+
69
+ def build_endpoint_attempts(owner_user_id:, endpoint_ids:)
70
+ attempts = endpoint_ids.map do |endpoint_id|
71
+ token = @secret_reader.read!(owner_user_id:, endpoint_id:)
72
+ EndpointAttempt.new(endpoint_id:, token:, outcome: nil)
73
+ rescue Endpoints::NotFoundError, Endpoints::ValidationError
74
+ EndpointAttempt.new(
75
+ endpoint_id:,
76
+ token: nil,
77
+ outcome: :terminal_failure,
78
+ error_code: "recipient_missing",
79
+ )
80
+ end
81
+
82
+ if attempts.all? { |attempt| attempt.outcome == :terminal_failure }
83
+ return AdapterResult.build(outcome: :terminal_failure, error_code: "recipient_missing")
84
+ end
85
+
86
+ attempts
87
+ end
88
+
89
+ def deliver_http(attempts:, rendered:, owner_user_id:)
90
+ sendable = attempts.select { |attempt| attempt.token.present? && attempt.outcome.nil? }
91
+ messages = sendable.map { |attempt| expo_message(token: attempt.token, rendered:) }
92
+
93
+ send_result = @http_client.send_messages(messages)
94
+ unless send_result[:ok]
95
+ return map_http_failure(send_result[:status_code])
96
+ end
97
+
98
+ tickets = Array(send_result[:tickets])
99
+ sendable.each_with_index do |attempt, index|
100
+ ticket = tickets[index]
101
+ attempt.ticket = ticket
102
+ apply_ticket_outcome!(attempt)
103
+ end
104
+
105
+ ok_ticket_ids = sendable.filter_map { |attempt| attempt.ticket&.dig(:id) if attempt.ticket&.dig(:status) == "ok" }
106
+ if ok_ticket_ids.any?
107
+ receipts_result = @http_client.get_receipts(ok_ticket_ids)
108
+ if receipts_result[:ok]
109
+ receipts = receipts_result[:receipts] || {}
110
+ sendable.each do |attempt|
111
+ ticket_id = attempt.ticket&.dig(:id)
112
+ next if ticket_id.nil?
113
+ next unless attempt.ticket&.dig(:status) == "ok"
114
+
115
+ receipt = receipts[ticket_id]
116
+ attempt.receipt = receipt
117
+ apply_receipt_outcome!(attempt)
118
+ end
119
+ end
120
+ # Missing or failed receipt fetch: ticket ok without receipt stays success (plan).
121
+ end
122
+
123
+ sendable.each do |attempt|
124
+ mark_invalid_if_needed!(owner_user_id:, attempt:)
125
+ end
126
+
127
+ aggregate_result(attempts)
128
+ end
129
+
130
+ def expo_message(token:, rendered:)
131
+ message = {
132
+ "to" => token,
133
+ "title" => rendered.title,
134
+ "body" => rendered.body,
135
+ "sound" => "default",
136
+ }
137
+ if rendered.deep_link.present?
138
+ message["data"] = { "deepLink" => rendered.deep_link }
139
+ end
140
+ message
141
+ end
142
+
143
+ def apply_ticket_outcome!(attempt)
144
+ ticket = attempt.ticket
145
+ if ticket.nil?
146
+ attempt.outcome = :retryable_failure
147
+ attempt.error_code = "expo_transient"
148
+ return
149
+ end
150
+
151
+ case ticket[:status]
152
+ when "ok"
153
+ attempt.outcome = :success
154
+ when "error"
155
+ if device_invalid_error?(ticket[:error])
156
+ attempt.outcome = :terminal_failure
157
+ attempt.error_code = "expo_device_invalid"
158
+ else
159
+ attempt.outcome = :terminal_failure
160
+ attempt.error_code = "expo_rejected"
161
+ end
162
+ else
163
+ attempt.outcome = :retryable_failure
164
+ attempt.error_code = "expo_transient"
165
+ end
166
+ end
167
+
168
+ def apply_receipt_outcome!(attempt)
169
+ receipt = attempt.receipt
170
+ return if receipt.nil? # pending / missing → keep ticket success
171
+
172
+ case receipt[:status]
173
+ when "ok"
174
+ attempt.outcome = :success
175
+ when "error"
176
+ if device_invalid_error?(receipt[:error])
177
+ attempt.outcome = :terminal_failure
178
+ attempt.error_code = "expo_device_invalid"
179
+ else
180
+ attempt.outcome = :terminal_failure
181
+ attempt.error_code = "expo_rejected"
182
+ end
183
+ end
184
+ end
185
+
186
+ def device_invalid_error?(error)
187
+ DEVICE_INVALID_ERRORS.include?(error.to_s)
188
+ end
189
+
190
+ def mark_invalid_if_needed!(owner_user_id:, attempt:)
191
+ return unless attempt.error_code == "expo_device_invalid"
192
+
193
+ Endpoints.mark_invalid(owner_user_id:, endpoint_id: attempt.endpoint_id)
194
+ rescue StandardError
195
+ nil
196
+ end
197
+
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
204
+ end
205
+
206
+ def aggregate_result(attempts)
207
+ successes = attempts.select { |attempt| attempt.outcome == :success }
208
+ if successes.any?
209
+ first_ticket_id = successes.filter_map { |attempt| attempt.ticket&.dig(:id) }.first
210
+ return AdapterResult.build(
211
+ outcome: :success,
212
+ normalized_provider_status: "accepted",
213
+ provider_message_id: first_ticket_id,
214
+ )
215
+ end
216
+
217
+ 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:)
220
+ end
221
+
222
+ AdapterResult.build(outcome: :retryable_failure, error_code: "expo_transient")
223
+ end
224
+ end
225
+ end
226
+ end
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Messaging
5
+ module Execution
6
+ module Adapters
7
+ module Expo
8
+ # Platform readiness detector for Expo Push delivery.
9
+ # Ready when transport adapter is fake, log, or http — disabled never counts.
10
+ # http does not require access_token (Expo default); Bearer is optional.
11
+ class Configuration
12
+ CONFIGURED_ADAPTERS = %w[fake log http].freeze
13
+
14
+ def self.expo_configured?
15
+ new.expo_configured?
16
+ end
17
+
18
+ def expo_configured?
19
+ CONFIGURED_ADAPTERS.include?(adapter_name)
20
+ end
21
+
22
+ def adapter_name
23
+ CommandTower.config.messaging.expo.adapter.to_s
24
+ end
25
+
26
+ def api_base_url
27
+ CommandTower.config.messaging.expo.api_base_url.to_s.strip
28
+ end
29
+
30
+ def timeout_seconds
31
+ CommandTower.config.messaging.expo.timeout_seconds
32
+ end
33
+
34
+ def access_token
35
+ CommandTower.config.messaging.expo.access_token.to_s.strip
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end