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

.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)


61
62
63
# File 'lib/silas/channel.rb', line 61

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

#deliver_approval(session:, invocation:) ⇒ Object

Raises:

  • (NotImplementedError)


65
66
67
# File 'lib/silas/channel.rb', line 65

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