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 -> Silas.agent.start; reply -> session.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
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: {}) ⇒ Object
The single inbound entry point for every transport.
-
.for_session(session) ⇒ Object
Resolve the channel instance that owns a session (for outbound delivery).
-
.namespaced(thread_key) ⇒ Object
Stable external-thread key, namespaced by channel so two channels can't collide.
-
.token_for(invocation, action) ⇒ Object
Signed token for email approve/decline links (no session state leaks into the URL).
- .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.
68 69 70 71 72 73 74 75 76 77 78 79 80 |
# File 'lib/silas/channel.rb', line 68 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
14 |
# File 'lib/silas/channel.rb', line 14 def self.channel_name = name.demodulize.underscore |
.dispatch(thread_key:, input:, metadata: {}) ⇒ Object
The single inbound entry point for every transport.
20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
# File 'lib/silas/channel.rb', line 20 def self.dispatch(thread_key:, input:, metadata: {}) token = namespaced(thread_key) if (session = Silas::Session.find_by(continuation_token: token)) session.continue(input: input) session else Silas.agent.start(input: input, metadata: , channel: channel_name, continuation_token: token) end rescue ActiveRecord::RecordNotUnique # Concurrent first-inbound race: the other request created the session; # treat this message as a continue. session = Silas::Session.find_by!(continuation_token: namespaced(thread_key)) session.continue(input: input) session end |
.for_session(session) ⇒ Object
Resolve the channel instance that owns a session (for outbound delivery).
38 39 40 41 42 43 |
# File 'lib/silas/channel.rb', line 38 def self.for_session(session) return nil if session.channel.blank? klass = Silas.config.channel_resolver&.call(session.channel) klass&.new end |
.namespaced(thread_key) ⇒ Object
Stable external-thread key, namespaced by channel so two channels can't collide.
17 |
# File 'lib/silas/channel.rb', line 17 def self.namespaced(thread_key) = "#{channel_name}:#{thread_key}" |
.token_for(invocation, action) ⇒ Object
Signed token for email approve/decline links (no session state leaks into the URL).
46 47 48 |
# File 'lib/silas/channel.rb', line 46 def self.token_for(invocation, action) verifier.generate({ "id" => invocation.id, "action" => action.to_s }, expires_in: Silas.config.approval_ttl) end |
.verifier ⇒ Object
56 57 58 |
# File 'lib/silas/channel.rb', line 56 def self.verifier Rails.application.(TOKEN_PURPOSE) end |
.verify_token(token) ⇒ Object
50 51 52 53 54 |
# File 'lib/silas/channel.rb', line 50 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) ---
89 90 91 |
# File 'lib/silas/channel.rb', line 89 def deliver_answer(session:, text:) raise NotImplementedError, "#{self.class}#deliver_answer" end |
#deliver_approval(session:, invocation:) ⇒ Object
93 94 95 |
# File 'lib/silas/channel.rb', line 93 def deliver_approval(session:, invocation:) raise NotImplementedError, "#{self.class}#deliver_approval" end |