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

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

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.



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



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

.routesObject

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

.verifierObject



130
131
132
# File 'lib/silas/channel.rb', line 130

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

Raises:

  • (NotImplementedError)


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

Raises:

  • (NotImplementedError)


167
168
169
# File 'lib/silas/channel.rb', line 167

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