Class: Silas::Channel

Inherits:
Object
  • Object
show all
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.

Constant Summary collapse

TOKEN_PURPOSE =
"silas/channel".freeze

Class Method Summary collapse

Instance Method Summary collapse

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)
  options = default_url_options.merge(host: host || default_url_options[:host])
  if options[: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, **options
  )
end

.channel_nameObject



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

.verifierObject



56
57
58
# File 'lib/silas/channel.rb', line 56

def self.verifier
  Rails.application.message_verifier(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) ---

Raises:

  • (NotImplementedError)


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

Raises:

  • (NotImplementedError)


93
94
95
# File 'lib/silas/channel.rb', line 93

def deliver_approval(session:, invocation:)
  raise NotImplementedError, "#{self.class}#deliver_approval"
end