Class: Silas::Channel
- Inherits:
-
Object
- Object
- Silas::Channel
- Defined in:
- lib/silas/channel.rb
Overview
A channel binds an external surface (email, Slack, HTTP) to the durable loop. Identity is the filename (app/agent/channels/slack.rb -> Agent::Channels::Slack).
Inbound is pure trigger reuse: dispatch maps an external thread to a Session via silas_sessions.channel + continuation_token, then calls the UNCHANGED public API (new thread -> the routed agent's .start; reply -> continue). Outbound (deliver the agent's answer / an approval request) is a subclass responsibility, invoked off the loop by ChannelDeliveryJob — so the loop's determinism and the ledger's exactly-once are never touched.
Direct Known Subclasses
Constant Summary collapse
- TOKEN_PURPOSE =
"silas/channel".freeze
- ROOT_AGENT =
silas_sessions.agent_name for the root app/agent — the column's default, and the name the loop reads as "no named scope".
"agent".freeze
Class Method Summary collapse
-
.approval_url(invocation, action, host: nil) ⇒ Object
A full one-click approve/decline URL for ANY transport — the signed token is the credential, so the link works in a WhatsApp message, a Discord embed, or an SMS exactly as it does in email.
- .channel_name ⇒ Object
-
.dispatch(thread_key:, input:, metadata: {}, agent: nil) ⇒ Object
The single inbound entry point for every transport.
- .find_session(thread_key, agent_name) ⇒ Object
-
.for_session(session) ⇒ Object
Resolve the channel instance that owns a session (for outbound delivery).
-
.legacy_namespaced(thread_key) ⇒ Object
Tokens minted before routing existed have no agent segment.
-
.namespaced(thread_key, agent_name = nil) ⇒ Object
Stable external-thread key, namespaced by channel AND agent: two channels can't collide, and two staff members sharing one transport can't either.
-
.resolve_agent(name) ⇒ Object
nil means the root agent.
-
.route_for(transport, *keys) ⇒ Object
The agent name a thread on
transportbelongs to, or nil for the root agent. -
.routes ⇒ Object
config.channel_routes, normalised to { transport => { key => agent_name } }.
-
.token_for(invocation, action) ⇒ Object
Signed token for email approve/decline links (no session state leaks into the URL).
-
.validate_routes!(staff) ⇒ Object
Checked at boot by Registry.install! against the app/agents/ roster.
- .verifier ⇒ Object
- .verify_token(token) ⇒ Object
Instance Method Summary collapse
-
#deliver_answer(session:, text:) ⇒ Object
--- outbound interface (subclasses implement) ---.
- #deliver_approval(session:, invocation:) ⇒ Object
Class Method Details
.approval_url(invocation, action, host: nil) ⇒ Object
A full one-click approve/decline URL for ANY transport — the signed token is the credential, so the link works in a WhatsApp message, a Discord embed, or an SMS exactly as it does in email.
Built from the engine's own route set plus the discovered mount point, because a channel runs in a delivery job with no routing scope. The host is required and never guessed: a hostless approval link is a dead link, so this raises with the fix rather than shipping one.
142 143 144 145 146 147 148 149 150 151 152 153 154 |
# File 'lib/silas/channel.rb', line 142 def self.approval_url(invocation, action, host: nil) = .merge(host: host || [:host]) if [:host].blank? raise Error, "Silas::Channel.approval_url needs a host. Set " \ "config.action_mailer.default_url_options = { host: \"example.com\" } " \ "(or Rails.application.routes.default_url_options), or pass host:." end Silas::Engine.routes.url_helpers.channels_approval_url( token: token_for(invocation, action), script_name: Silas::Inbox.mount_path, ** ) end |
.channel_name ⇒ Object
17 |
# File 'lib/silas/channel.rb', line 17 def self.channel_name = name.demodulize.underscore |
.dispatch(thread_key:, input:, metadata: {}, agent: nil) ⇒ Object
The single inbound entry point for every transport. agent is the NAME of
the staff member this thread belongs to (nil or "agent" = the root agent);
callers read it off configuration with .route_for.
39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
# File 'lib/silas/channel.rb', line 39 def self.dispatch(thread_key:, input:, metadata: {}, agent: nil) name = resolve_agent(agent) if (session = find_session(thread_key, name)) session.continue(input: input) session else owner = name ? Silas.agent(name) : Silas.agent owner.start(input: input, metadata: , channel: channel_name, continuation_token: namespaced(thread_key, name)) end rescue ActiveRecord::RecordNotUnique # Concurrent first-inbound race: the other request created the session; # treat this message as a continue. Nothing to continue means the conflict # was something else, so let it out. session = find_session(thread_key, name) or raise session.continue(input: input) session end |
.find_session(thread_key, agent_name) ⇒ Object
31 32 33 34 |
# File 'lib/silas/channel.rb', line 31 def self.find_session(thread_key, agent_name) Silas::Session.find_by(continuation_token: namespaced(thread_key, agent_name)) || Silas::Session.find_by(continuation_token: legacy_namespaced(thread_key)) end |
.for_session(session) ⇒ Object
Resolve the channel instance that owns a session (for outbound delivery).
112 113 114 115 116 117 |
# File 'lib/silas/channel.rb', line 112 def self.for_session(session) return nil if session.channel.blank? klass = Silas.config.channel_resolver&.call(session.channel) klass&.new end |
.legacy_namespaced(thread_key) ⇒ Object
Tokens minted before routing existed have no agent segment. Every one of them belongs to the root agent — dispatch could start nothing else — so a miss on the new form falls back to this one and a live thread upgrades without losing its session.
29 |
# File 'lib/silas/channel.rb', line 29 def self.legacy_namespaced(thread_key) = "#{channel_name}:#{thread_key}" |
.namespaced(thread_key, agent_name = nil) ⇒ Object
Stable external-thread key, namespaced by channel AND agent: two channels can't collide, and two staff members sharing one transport can't either.
21 22 23 |
# File 'lib/silas/channel.rb', line 21 def self.namespaced(thread_key, agent_name = nil) "#{channel_name}:#{agent_name.presence || ROOT_AGENT}:#{thread_key}" end |
.resolve_agent(name) ⇒ Object
nil means the root agent. An unknown name resolves to nil instead of raising: dispatch runs inside a webhook handler, and a 500 there is a message Slack retries into its own retry guard and then loses. Boot already refused a bad route, so this only fires when routes were assigned after boot — the thread lands on the root agent, exactly where it landed before routing existed, and the log says so.
101 102 103 104 105 106 107 108 109 |
# File 'lib/silas/channel.rb', line 101 def self.resolve_agent(name) name = name.to_s return nil if name.empty? || name == ROOT_AGENT return name if Silas.named_agent?(name) Rails.logger&.error("[Silas] channel route names unknown agent #{name.inspect}; " \ "starting the root agent instead. Fix config.channel_routes.") nil end |
.route_for(transport, *keys) ⇒ Object
The agent name a thread on transport belongs to, or nil for the root
agent. Candidate keys are tried in order and the first match wins, so a
caller holding several (an email's recipients) passes them all.
72 73 74 75 76 |
# File 'lib/silas/channel.rb', line 72 def self.route_for(transport, *keys) table = routes[transport.to_s] or return nil keys.flatten.filter_map { |key| table[key.to_s.downcase] }.first end |
.routes ⇒ Object
config.channel_routes, normalised to { transport => { key => agent_name } }. Keys are matched downcased because email recipients are case-insensitive; Slack channel ids are unaffected by folding both sides the same way.
63 64 65 66 67 |
# File 'lib/silas/channel.rb', line 63 def self.routes (Silas.config.channel_routes || {}).to_h do |transport, table| [ transport.to_s, table.to_h { |key, agent| [ key.to_s.downcase, agent.to_s ] } ] end end |
.token_for(invocation, action) ⇒ Object
Signed token for email approve/decline links (no session state leaks into the URL).
120 121 122 |
# File 'lib/silas/channel.rb', line 120 def self.token_for(invocation, action) verifier.generate({ "id" => invocation.id, "action" => action.to_s }, expires_in: Silas.config.approval_ttl) end |
.validate_routes!(staff) ⇒ Object
Checked at boot by Registry.install! against the app/agents/ roster. A route naming an agent that doesn't exist is a deploy failure, not a runtime one: Silas.agent raises on an unknown name, and discovering that at dispatch time would strand every future message on the thread.
82 83 84 85 86 87 88 89 90 91 92 93 |
# File 'lib/silas/channel.rb', line 82 def self.validate_routes!(staff) staff = staff.map(&:to_s) routes.each do |transport, table| table.each do |key, agent| next if agent == ROOT_AGENT || staff.include?(agent) raise Error, "config.channel_routes[#{transport.inspect}][#{key.inspect}] routes to " \ "agent #{agent.inspect}, which does not exist" \ "#{staff.any? ? " (known: #{staff.sort.join(', ')})" : " — no app/agents/ directories found"}" end end end |
.verifier ⇒ Object
130 131 132 |
# File 'lib/silas/channel.rb', line 130 def self.verifier Rails.application.(TOKEN_PURPOSE) end |
.verify_token(token) ⇒ Object
124 125 126 127 128 |
# File 'lib/silas/channel.rb', line 124 def self.verify_token(token) verifier.verify(token) rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveSupport::MessageEncryptor::InvalidMessage nil end |
Instance Method Details
#deliver_answer(session:, text:) ⇒ Object
--- outbound interface (subclasses implement) ---
163 164 165 |
# File 'lib/silas/channel.rb', line 163 def deliver_answer(session:, text:) raise NotImplementedError, "#{self.class}#deliver_answer" end |
#deliver_approval(session:, invocation:) ⇒ Object
167 168 169 |
# File 'lib/silas/channel.rb', line 167 def deliver_approval(session:, invocation:) raise NotImplementedError, "#{self.class}#deliver_approval" end |