command_tower 0.16.0 → 0.18.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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +8 -8
  3. data/app/controllers/command_tower/application_controller.rb +1 -0
  4. data/app/controllers/command_tower/me/inbox_controller.rb +16 -0
  5. data/app/controllers/concerns/command_tower/execution/client_compatibility_boundary.rb +39 -0
  6. data/app/deserializers/command_tower/deserializers/messaging/inbox.rb +85 -0
  7. data/app/errors/command_tower/errors/client_update_required_error.rb +27 -0
  8. data/app/jobs/command_tower/messaging/communications/produce_recipient_job.rb +2 -0
  9. data/app/models/command_tower/messaging/communication.rb +31 -0
  10. data/app/serializers/command_tower/serializers/messaging/inbox.rb +45 -1
  11. data/app/services/command_tower/email_theme/resolver.rb +45 -0
  12. data/app/services/command_tower/messaging/accept/coordinator.rb +16 -1
  13. data/app/services/command_tower/messaging/accept/persister.rb +13 -0
  14. data/app/services/command_tower/messaging/contract/mappers/communication_mapper.rb +7 -0
  15. data/app/services/command_tower/messaging/contract/results/communication_result.rb +1 -0
  16. data/app/services/command_tower/messaging/inbox/conversation_result.rb +31 -0
  17. data/app/services/command_tower/messaging/inbox/entry_result.rb +36 -0
  18. data/app/services/command_tower/messaging/inbox/reader.rb +231 -18
  19. data/app/services/command_tower/messaging/inbox.rb +4 -0
  20. data/app/services/command_tower/messaging/notification_types/declaration.rb +14 -1
  21. data/app/services/command_tower/messaging/rendering/channel_renderer.rb +44 -13
  22. data/app/services/command_tower/messaging/rendering/inbox_document.rb +131 -0
  23. data/app/services/command_tower/messaging/rendering/inbox_document_renderer.rb +224 -0
  24. data/app/services/command_tower/messaging/rendering/inbox_presentation_resolver.rb +52 -0
  25. data/app/services/command_tower/messaging/rendering/inbox_presentation_snapshot.rb +64 -0
  26. data/app/services/command_tower/messaging/rendering/template_resolver.rb +128 -0
  27. data/app/services/command_tower/messaging.rb +5 -1
  28. data/app/services/command_tower/services/client_compatibility/evaluate.rb +219 -0
  29. data/app/services/command_tower/services/messaging/communications/produce.rb +4 -0
  30. data/app/services/command_tower/services/messaging/communications/produce_many.rb +6 -0
  31. data/app/services/command_tower/services/messaging/inbox.rb +55 -2
  32. data/app/views/command_tower/email_verification_mailer/verify_email.html.erb +18 -17
  33. data/app/views/command_tower/messaging/rendering/email.html.erb +6 -5
  34. data/app/views/command_tower/password_reset_mailer/reset_password.html.erb +24 -23
  35. data/app/workflows/command_tower/workflows/auth/plain_text/login_workflow.rb +3 -0
  36. data/app/workflows/command_tower/workflows/auth/session/show_workflow.rb +3 -0
  37. data/app/workflows/command_tower/workflows/client_compatibility/evaluate_workflow.rb +83 -0
  38. data/app/workflows/command_tower/workflows/client_compatibility/recommendation_meta.rb +25 -0
  39. data/app/workflows/command_tower/workflows/messaging/communications/produce_recipient_workflow.rb +5 -1
  40. data/app/workflows/command_tower/workflows/messaging/inbox.rb +15 -1
  41. data/config/routes.rb +1 -0
  42. data/db/migrate/20261010000001_add_inbox_presentation_snapshot_to_messaging_communications.rb +7 -0
  43. data/db/migrate/20261010000002_add_conversation_identity_to_messaging_communications.rb +10 -0
  44. data/docs/api_reference.md +13 -2
  45. data/docs/authorization.md +1 -1
  46. data/docs/bootstrap/00-ownership.md +77 -0
  47. data/docs/bootstrap/01-docker-make-compose.md +267 -0
  48. data/docs/bootstrap/02-create-the-rails-app.md +75 -0
  49. data/docs/bootstrap/03-pin-the-gem.md +53 -0
  50. data/docs/bootstrap/04-secrets-and-env.md +59 -0
  51. data/docs/bootstrap/05-install-migrate-doctor.md +74 -0
  52. data/docs/bootstrap/06-mount-and-health.md +53 -0
  53. data/docs/bootstrap/07-execution-bases.md +38 -0
  54. data/docs/bootstrap/08-initializer.md +90 -0
  55. data/docs/bootstrap/09-rbac.md +83 -0
  56. data/docs/bootstrap/10-roles-and-gates.md +62 -0
  57. data/docs/bootstrap/11-auth-client-path.md +57 -0
  58. data/docs/bootstrap/12-smoke-check.md +81 -0
  59. data/docs/bootstrap/13-optional.md +50 -0
  60. data/docs/bootstrap/14-sanity-checks.md +44 -0
  61. data/docs/bootstrap/README.md +90 -0
  62. data/docs/cookie_authentication_guide.md +11 -0
  63. data/docs/extending.md +5 -4
  64. data/docs/host_integration_guide.md +3 -246
  65. data/docs/initializing.md +37 -28
  66. data/docs/messaging_integration_guide.md +39 -1
  67. data/docs/principal_capabilities.md +1 -1
  68. data/docs/upgrades/0.10.0.md +5 -5
  69. data/docs/upgrades/0.11.0.md +5 -5
  70. data/docs/upgrades/0.17.0.md +33 -0
  71. data/docs/upgrades/0.18.0.md +35 -0
  72. data/docs/upgrades/README.md +3 -1
  73. data/lib/command_tower/authorization/default.yml +1 -0
  74. data/lib/command_tower/client_compatibility/version.rb +45 -0
  75. data/lib/command_tower/client_compatibility.rb +23 -0
  76. data/lib/command_tower/configuration/config.rb +6 -0
  77. data/lib/command_tower/configuration/email_theme/config.rb +69 -0
  78. data/lib/command_tower/configuration/registry/client_compatibility/binding_definition.rb +42 -0
  79. data/lib/command_tower/configuration/registry/client_compatibility/config.rb +353 -0
  80. data/lib/command_tower/configuration/registry/client_compatibility/contract_definition.rb +16 -0
  81. data/lib/command_tower/configuration/registry/client_compatibility/entity_requirement_definition.rb +67 -0
  82. data/lib/command_tower/configuration/registry/client_compatibility/minimum_overrides.rb +46 -0
  83. data/lib/command_tower/configuration/registry/client_compatibility/platform_definition.rb +66 -0
  84. data/lib/command_tower/configuration/registry/config.rb +14 -0
  85. data/lib/command_tower/configuration/registry/inbox_presentations/config.rb +105 -0
  86. data/lib/command_tower/configuration/registry/inbox_presentations/presentation_definition.rb +71 -0
  87. data/lib/command_tower/current.rb +1 -0
  88. data/lib/command_tower/engine.rb +4 -0
  89. data/lib/command_tower/inbox_presentations.rb +19 -0
  90. data/lib/command_tower/install/baseline.rb +2 -0
  91. data/lib/command_tower/version.rb +1 -1
  92. metadata +47 -2
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module CommandTower
6
+ module Messaging
7
+ module Rendering
8
+ # Internal Messaging::Rendering collaborator (peer of `ChannelRenderer`,
9
+ # not a host-facing service). Builds the `InboxDocument` shown at Inbox
10
+ # detail read time.
11
+ #
12
+ # Generic path: one `paragraph` block from `communication.body`, plus an
13
+ # optional `cta` block with a typed `destination` — internal
14
+ # (`metadata["deep_link_path"]`, origin-free, host-navigation-owned) if
15
+ # present and safe, else external (`metadata["deep_link"]`) if that is
16
+ # a safe href (D-INBOX-GENERIC; typed destination added Slice 2.6.4).
17
+ #
18
+ # Optional per-`notification_type_key` override: a pure-Ruby composer
19
+ # class (`declaration.inbox_document_composer`, resolved via
20
+ # `NotificationTypes.lookup` + `.constantize`), `.call(communication:) ->
21
+ # InboxDocument`. Any failure (unregistered type, absent composer,
22
+ # unresolvable class name, a raising composer, or a composer returning
23
+ # something other than an `InboxDocument`) fails open to the generic
24
+ # document — the Inbox read path must never 500 on a bad type composer
25
+ # (D-INBOX-TYPE). This replaces the Rich Messaging Campaign 2 ERB/JSON
26
+ # authoring path entirely — see
27
+ # artifacts/visual-improvements/rich-messaging/authority/RICH_MESSAGING_RUBY_INBOX_DEFINITION_DISCOVERY.md.
28
+ #
29
+ # After the document (type-specific or generic) is built, any
30
+ # `"presentation"` block is resolved against
31
+ # `CommandTower.config.registry.inbox_presentations` — at most one
32
+ # attempted/resolved presentation capability per document (Rich
33
+ # Messaging Slice 2.5). The budget is consumed the instant a
34
+ # `"presentation"`-typed block is seen, before lookup or composition,
35
+ # regardless of outcome, so a malformed/unregistered first node cannot
36
+ # fail open and then let a second node still execute composer domain
37
+ # I/O.
38
+ #
39
+ # A stored `inbox_presentation_snapshot` replaces that live resolution.
40
+ # Null means there is no retained presentation (legacy rows, or a
41
+ # document with no presentation node) and the live composer still runs.
42
+ # An omitted snapshot drops the presentation node and does not call
43
+ # the composer. A stored payload is rendered as-is.
44
+ #
45
+ # The composer's raw result is always projected down to the
46
+ # registration's declared `output_keys` before it reaches
47
+ # `content.blocks[].data` (corrective slice — presentation
48
+ # ServiceResult/context leak fix). Composer inputs and any other
49
+ # incidental context/result keys never reach the wire, regardless of
50
+ # whether the composer is `ApplicationService`-shaped or a plain Hash.
51
+ class InboxDocumentRenderer
52
+ UNSAFE_SCHEMES = %w[javascript data vbscript].freeze
53
+ DEFAULT_CTA_LABEL = "Open"
54
+
55
+ def self.render(communication:, recipient_id:)
56
+ new(communication:, recipient_id:).render
57
+ end
58
+
59
+ def self.compose_shell(communication)
60
+ new(communication:, recipient_id: nil).compose_shell
61
+ end
62
+
63
+ def initialize(communication:, recipient_id:)
64
+ @communication = communication
65
+ @recipient_id = recipient_id
66
+ @presentation_attempted = false
67
+ end
68
+
69
+ def render
70
+ document = compose_shell
71
+ snapshot = stored_presentation_snapshot
72
+ return resolve_presentation_blocks(document) if snapshot.nil?
73
+ return drop_presentation_blocks(document) if InboxPresentationSnapshot.omitted?(snapshot)
74
+
75
+ apply_stored_presentation(document, snapshot)
76
+ end
77
+
78
+ def compose_shell
79
+ build_from_type_composer || build_generic
80
+ end
81
+
82
+ private
83
+
84
+ attr_reader :communication, :recipient_id
85
+
86
+ def build_from_type_composer
87
+ declaration = CommandTower::Messaging::NotificationTypes.lookup(communication.notification_type_key)
88
+ composer_name = declaration.inbox_document_composer
89
+ return nil if composer_name.nil?
90
+
91
+ document = composer_name.constantize.call(communication:)
92
+ document.is_a?(InboxDocument) ? document : nil
93
+ rescue StandardError
94
+ # Fail-open: unregistered type, unresolvable class name, a raising
95
+ # composer, or a composer returning invalid data must never surface
96
+ # as a 500 on the Inbox read path (D-INBOX-TYPE).
97
+ nil
98
+ end
99
+
100
+ def build_generic
101
+ blocks = []
102
+ blocks << paragraph_block(communication.body) if communication.body.present?
103
+ cta = generic_cta_block
104
+ blocks << cta if cta
105
+
106
+ InboxDocument.build(blocks:)
107
+ end
108
+
109
+ # Re-walks the already-validated blocks array (produced either by a
110
+ # trusted Ruby composer via `InboxDocument::Builder` or by
111
+ # `build_generic`), resolving any `"presentation"` node in place.
112
+ # There is no untrusted JSON to parse/whitelist here — every block was
113
+ # already constructed via a whitelisted `Builder` method or this
114
+ # class's own `paragraph_block`/`cta_block` helpers.
115
+ def resolve_presentation_blocks(document)
116
+ resolved = document.blocks.map do |block|
117
+ block[:type] == "presentation" ? presentation_block(block) : block
118
+ end.compact
119
+
120
+ InboxDocument.build(blocks: resolved)
121
+ end
122
+
123
+ def presentation_block(block)
124
+ return nil if @presentation_attempted
125
+
126
+ @presentation_attempted = true
127
+ InboxPresentationResolver.resolve(communication:, recipient_id:, block:)
128
+ end
129
+
130
+ def stored_presentation_snapshot
131
+ raw = communication.inbox_presentation_snapshot
132
+ return nil if raw.blank?
133
+ return nil unless raw.respond_to?(:stringify_keys)
134
+
135
+ raw.stringify_keys
136
+ end
137
+
138
+ def drop_presentation_blocks(document)
139
+ InboxDocument.build(blocks: document.blocks.reject { |block| block[:type] == "presentation" })
140
+ end
141
+
142
+ def apply_stored_presentation(document, snapshot)
143
+ applied = false
144
+ blocks = document.blocks.filter_map do |block|
145
+ next block unless block[:type] == "presentation"
146
+ next nil if applied
147
+
148
+ applied = true
149
+ stored_presentation_block(snapshot)
150
+ end
151
+
152
+ InboxDocument.build(blocks:)
153
+ end
154
+
155
+ def stored_presentation_block(snapshot)
156
+ data = snapshot["data"].respond_to?(:to_h) ? snapshot["data"].to_h.deep_symbolize_keys : {}
157
+ {
158
+ type: "presentation",
159
+ key: snapshot["key"].to_s,
160
+ variant: snapshot["variant"],
161
+ data:,
162
+ }.freeze
163
+ end
164
+
165
+ def paragraph_block(text)
166
+ return nil unless text.is_a?(String)
167
+ return nil if text.empty?
168
+
169
+ { type: "paragraph", text: }.freeze
170
+ end
171
+
172
+ def generic_cta_block
173
+ cta_block(
174
+ label: metadata_value("cta_label"),
175
+ path: metadata_value("deep_link_path"),
176
+ href: metadata_value("deep_link")
177
+ )
178
+ end
179
+
180
+ # Internal (`path`) takes precedence over external (`href`) when
181
+ # both are present — a `cta` only ever means one navigation intent
182
+ # (Slice 2.6.4 typed destination; see artifacts/visual-improvements/
183
+ # rich-messaging/plans/2.6.4-ct-fe-inbox-navigation-capability.md).
184
+ def cta_block(label:, path: nil, href: nil)
185
+ destination = cta_destination(path:, href:)
186
+ return nil unless destination
187
+
188
+ resolved_label = label.is_a?(String) && !label.strip.empty? ? label : DEFAULT_CTA_LABEL
189
+ { type: "cta", label: resolved_label, destination: }.freeze
190
+ end
191
+
192
+ def cta_destination(path:, href:)
193
+ if safe_internal_path?(path)
194
+ { kind: "internal", path: }
195
+ elsif safe_href?(href)
196
+ { kind: "external", href: }
197
+ end
198
+ end
199
+
200
+ # A leading `//` is rejected even though it "starts with a slash" —
201
+ # it is a protocol-relative URL, not a safe same-app-only destination.
202
+ def safe_internal_path?(path)
203
+ path.is_a?(String) && path.start_with?("/") && !path.start_with?("//")
204
+ end
205
+
206
+ def safe_href?(href)
207
+ return false unless href.is_a?(String) && !href.strip.empty?
208
+
209
+ scheme = URI.parse(href).scheme&.downcase
210
+ scheme.present? && UNSAFE_SCHEMES.exclude?(scheme)
211
+ rescue URI::InvalidURIError
212
+ false
213
+ end
214
+
215
+ def metadata_value(key)
216
+ metadata = communication.metadata
217
+ return nil unless metadata.is_a?(Hash)
218
+
219
+ metadata[key] || metadata[key.to_sym]
220
+ end
221
+ end
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Messaging
5
+ module Rendering
6
+ # Resolves one presentation block through the inbox presentation registry.
7
+ # Returns the projected block, or nil when the capability is missing,
8
+ # the composer fails, or the composer raises. Does not persist and does
9
+ # not perform network I/O. Callers own the one-presentation budget.
10
+ class InboxPresentationResolver
11
+ def self.resolve(communication:, recipient_id:, block:)
12
+ new(communication:, recipient_id:, block:).resolve
13
+ end
14
+
15
+ def initialize(communication:, recipient_id:, block:)
16
+ @communication = communication
17
+ @recipient_id = recipient_id
18
+ @block = block
19
+ end
20
+
21
+ def resolve
22
+ definition = CommandTower.config.registry.inbox_presentations.fetch(block[:key])
23
+ raw_data = unwrap(definition.composer_class.constantize.call(communication:, recipient_id:))
24
+ return nil if raw_data.nil?
25
+
26
+ {
27
+ type: "presentation",
28
+ key: block[:key],
29
+ variant: block[:variant],
30
+ data: raw_data.to_h.slice(*definition.output_keys),
31
+ }.freeze
32
+ rescue StandardError
33
+ nil
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :communication, :recipient_id, :block
39
+
40
+ def unwrap(composer_result)
41
+ if composer_result.respond_to?(:success?)
42
+ return nil unless composer_result.success?
43
+
44
+ composer_result.data
45
+ else
46
+ composer_result
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Messaging
5
+ module Rendering
6
+ # Captures the first presentation node's projected data at Accept time.
7
+ # Returns nil when the document has no presentation node. Returns an
8
+ # omitted marker when a presentation node was seen and capture failed.
9
+ # Never persists and never performs network I/O.
10
+ class InboxPresentationSnapshot
11
+ VERSION = 1
12
+
13
+ def self.capture(communication:, recipient_id:)
14
+ new(communication:, recipient_id:).capture
15
+ end
16
+
17
+ def self.omitted?(snapshot)
18
+ snapshot.is_a?(Hash) && snapshot.stringify_keys["omitted"] == true
19
+ end
20
+
21
+ def initialize(communication:, recipient_id:)
22
+ @communication = communication
23
+ @recipient_id = recipient_id
24
+ end
25
+
26
+ def capture
27
+ block = first_presentation_block
28
+ return nil if block.nil?
29
+
30
+ capture_seen_block(block)
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :communication, :recipient_id
36
+
37
+ def capture_seen_block(block)
38
+ resolved = InboxPresentationResolver.resolve(communication:, recipient_id:, block:)
39
+ resolved.nil? ? omitted_marker : payload(resolved)
40
+ rescue StandardError
41
+ omitted_marker
42
+ end
43
+
44
+ def first_presentation_block
45
+ document = InboxDocumentRenderer.compose_shell(communication)
46
+ document.blocks.find { |block| block[:type] == "presentation" }
47
+ end
48
+
49
+ def payload(resolved)
50
+ {
51
+ "v" => VERSION,
52
+ "key" => resolved[:key].to_s,
53
+ "variant" => resolved[:variant],
54
+ "data" => resolved[:data].deep_stringify_keys,
55
+ }
56
+ end
57
+
58
+ def omitted_marker
59
+ { "v" => VERSION, "omitted" => true }
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_view"
4
+
5
+ module CommandTower
6
+ module Messaging
7
+ module Rendering
8
+ # Internal Execution-pipeline collaborator (not a Service/Workflow/Controller).
9
+ #
10
+ # Resolves one rendered-destination basename (e.g. "email", "sms") to an
11
+ # ActionView template, preferring a `notification_type_key`-specific view
12
+ # over the generic one, and preferring the host application's `app/views`
13
+ # over CommandTower's own engine views for either.
14
+ #
15
+ # Host convention (documentation, not code):
16
+ #
17
+ # app/views/command_tower/messaging/rendering/
18
+ # email.html.erb # optional host override of the generic chrome
19
+ # <notification_type_key>/email.html.erb # optional type-specific override
20
+ #
21
+ # Hosts customize by dropping ERB files at that path; they never call this
22
+ # class directly. `ChannelRenderer` remains the only public Ruby entry into
23
+ # rendering. `prepend_view_path`/`reset_view_paths!` exist solely as a
24
+ # messaging spec fixture seam (mirrors Rails' own `prepend_view_path`
25
+ # testing idiom) and are not part of any host-facing contract.
26
+ class TemplateResolver
27
+ TEMPLATE_PREFIX = "command_tower/messaging/rendering"
28
+ TYPE_KEY_PATTERN = /\A[a-z0-9_]+\z/
29
+
30
+ class << self
31
+ def render(basename:, format:, notification_type_key:, generic_locals:, type_locals:)
32
+ new.render(
33
+ basename:,
34
+ format:,
35
+ notification_type_key:,
36
+ generic_locals:,
37
+ type_locals:,
38
+ )
39
+ end
40
+
41
+ # Spec-only seam: prepend an additional view root ahead of the host
42
+ # and engine defaults so fixture views can prove override precedence.
43
+ def prepend_view_path(path)
44
+ extra_view_paths.unshift(path.to_s)
45
+ end
46
+
47
+ def reset_view_paths!
48
+ extra_view_paths.clear
49
+ end
50
+
51
+ # Type-only, no-generic-fallback resolution: returns `nil` when
52
+ # there is no matching type template (invalid/sanitized-out key, or
53
+ # the type directory/basename simply does not exist) instead of
54
+ # raising `Errno::ENOENT` like `.render` does. Callers that have no
55
+ # generic ERB file to fall back to (e.g. `InboxDocumentRenderer`,
56
+ # whose generic path is a pure-Ruby builder) use this instead of
57
+ # `.render`. A template that is *found* but raises mid-render still
58
+ # propagates (via `render_named`'s `ActionView::Template::Error`
59
+ # unwrap) so callers can distinguish "no type content" from "type
60
+ # content is broken" if they choose to.
61
+ def render_type_template(basename:, format:, notification_type_key:, locals:)
62
+ new.render_type_template(basename:, format:, notification_type_key:, locals:)
63
+ end
64
+
65
+ def extra_view_paths
66
+ @extra_view_paths ||= []
67
+ end
68
+ end
69
+
70
+ def render(basename:, format:, notification_type_key:, generic_locals:, type_locals:)
71
+ type_key = sanitized_type_key(notification_type_key)
72
+
73
+ if type_key
74
+ begin
75
+ return render_named("#{TEMPLATE_PREFIX}/#{type_key}/#{basename}", format, type_locals)
76
+ rescue ActionView::MissingTemplate
77
+ # No type-specific template for this basename; fall through to generic.
78
+ end
79
+ end
80
+
81
+ begin
82
+ render_named("#{TEMPLATE_PREFIX}/#{basename}", format, generic_locals)
83
+ rescue ActionView::MissingTemplate
84
+ raise Errno::ENOENT, "missing template #{basename}.#{format}.erb"
85
+ end
86
+ end
87
+
88
+ def render_type_template(basename:, format:, notification_type_key:, locals:)
89
+ type_key = sanitized_type_key(notification_type_key)
90
+ return nil unless type_key
91
+
92
+ render_named("#{TEMPLATE_PREFIX}/#{type_key}/#{basename}", format, locals)
93
+ rescue ActionView::MissingTemplate
94
+ nil
95
+ end
96
+
97
+ private
98
+
99
+ def sanitized_type_key(notification_type_key)
100
+ key = notification_type_key.to_s
101
+ key.match?(TYPE_KEY_PATTERN) ? key : nil
102
+ end
103
+
104
+ def render_named(template_name, format, locals)
105
+ view.render(template: template_name, formats: [format], locals:)
106
+ rescue ActionView::Template::Error => error
107
+ # ActionView wraps any error raised while executing a *found*
108
+ # template in `ActionView::Template::Error`. Legacy plain-ERB
109
+ # rendering never wrapped errors, so unwrap here to preserve the
110
+ # original `error_class` that `ChannelRenderer#render`'s outer
111
+ # rescue surfaces on `RenderError`.
112
+ raise(error.cause || error)
113
+ end
114
+
115
+ def view
116
+ @view ||= ActionView::Base.with_empty_template_cache.with_view_paths(view_paths)
117
+ end
118
+
119
+ def view_paths
120
+ self.class.extra_view_paths + [
121
+ Rails.root.join("app/views").to_s,
122
+ CommandTower::Engine.root.join("app/views").to_s,
123
+ ]
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -13,7 +13,9 @@ module CommandTower
13
13
  metadata: nil,
14
14
  preference_state: nil,
15
15
  platform_enabled_channels:,
16
- message_overrides: nil
16
+ message_overrides: nil,
17
+ conversation_key: nil,
18
+ conversation_title: nil
17
19
  )
18
20
  Accept::Coordinator.call(
19
21
  recipient_id:,
@@ -25,6 +27,8 @@ module CommandTower
25
27
  preference_state:,
26
28
  platform_enabled_channels:,
27
29
  message_overrides:,
30
+ conversation_key:,
31
+ conversation_title:,
28
32
  )
29
33
  end
30
34