Class: Insika::Channels::Relay

Inherits:
Object
  • Object
show all
Defined in:
lib/insika/channels/relay.rb

Overview

The channel for an adopter who ALREADY owns a messaging integration A WhatsApp BSP, a Zendesk, a legacy Rails app: they want the engine for the TURN, not for the platform. Two routes and an envelope —

consumer --POST /channels/relay/events--> engine   acked now, never the reply
consumer <--POST <deliver_url>---------- engine    the reply, when there is one

— and everything platform-shaped stays theirs: the 24-hour window, template approval, media, read receipts, and how markdown becomes WhatsApp formatting That is the promise, not the limitation: an integration someone has already tuned for years does not have to move for them to adopt the engine. A relay that starts growing template logic has stopped being a relay.

It is also the cheapest possible Shape B, which is why it is built first: both ends are ours, so there is no third-party signature scheme and no rendering to get wrong at the same time as the durability. What it DOES exercise — the outbox, the claim, bounded retry, inbound dedup — is what Slack and native WhatsApp inherit untouched.

R1/R2 hold: this object translates and authenticates, and does nothing else. No Executor, no store, no RubyLLM; it may refuse a request, never grant a capability.

Constant Summary collapse

DEFAULT_ID =
"relay"
DEFAULT_TIMEOUT =
10
POLICIES =

how the outbox flushes for THIS channel. :at_end (the default) is one POST with the whole answer, byte-identical to today; :progressive lets ChannelDelivery split the answer into balloons and POST them in order (the channel still only translates — it does not know what a balloon is).

%i[at_end progressive].freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(inbound_token:, deliver_url:, deliver_token: nil, http: nil, id: DEFAULT_ID, allow_http: false, allow_private: false, timeout: DEFAULT_TIMEOUT, shadow: false, delivery: :at_end) ⇒ Relay

inbound_token: shared secret the consumer sends us (Bearer). Blank -> the channel answers :disabled to every request, fail-closed by construction rather than open by omission. deliver_url: where the reply goes. Blank -> nothing is ever delivered (the channel still accepts inbound; the outbox records the reply and the delivery fails loudly instead of silently). deliver_token: Bearer we send THEM. Optional: a consumer on a private network may authenticate us another way. shadow: The turn runs, the reply is recorded and never sent. Fail-closed by construction: everything downstream duck-types shadow?, so a channel that does not answer it is a normal channel. delivery: How the outbox flushes (:at_end | :progressive).



83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/insika/channels/relay.rb', line 83

def initialize(inbound_token:, deliver_url:, deliver_token: nil, http: nil,
               id: DEFAULT_ID, allow_http: false, allow_private: false,
               timeout: DEFAULT_TIMEOUT, shadow: false, delivery: :at_end)
  @id = id.to_s
  @inbound_token = inbound_token.to_s
  @deliver_url = deliver_url.to_s
  @deliver_token = deliver_token.to_s
  @http = http || Insika::HttpClient.new
  @allow_http = allow_http
  @allow_private = allow_private
  @timeout = timeout
  @shadow = shadow
  @delivery = self.class.policy!(delivery)
end

Instance Attribute Details

#idObject (readonly)

Returns the value of attribute id.



44
45
46
# File 'lib/insika/channels/relay.rb', line 44

def id
  @id
end

Class Method Details

.from_env(env = ENV, http: nil, allow_http: false, allow_private: false) ⇒ Object

The bundled relay as an operator configures it: three env vars, of which the token is the SWITCH — no token, no channel, so there is no way to end up with this route mounted and open. -> Relay | nil.

INSIKA_RELAY_SHADOW (truthy) is the shadow switch: the turn runs end to end and the reply is recorded, never delivered.

INSIKA_RELAY_DELIVERY ("progressive" | "at_end") is how the outbox flushes. Unset = :at_end.

Shared by every composition root on purpose: the DSL front door has to reach the same feature as config.ru, or the docs are true of only one of them.



58
59
60
61
62
63
64
65
66
67
68
# File 'lib/insika/channels/relay.rb', line 58

def self.from_env(env = ENV, http: nil, allow_http: false, allow_private: false)
  token = Insika::EnvSchema.read("INSIKA_RELAY_TOKEN", env)
  return nil unless Insika::EnvSchema.present?(token)

  new(inbound_token: token,
      deliver_url: Insika::EnvSchema.read("INSIKA_RELAY_DELIVER_URL", env),
      deliver_token: Insika::EnvSchema.read("INSIKA_RELAY_DELIVER_TOKEN", env),
      shadow: Insika::EnvSchema.truthy?(Insika::EnvSchema.read("INSIKA_RELAY_SHADOW", env)),
      delivery: policy!(Insika::EnvSchema.read("INSIKA_RELAY_DELIVERY", env)),
      http: http, allow_http: allow_http, allow_private: allow_private)
end

.policy!(value) ⇒ Object

"progressive" | "at_end" | blank/unset (= :at_end). An unknown value is a config error at BOOT — the consumer would silently miss every progressive turn, so it is refused where the operator is.



106
107
108
109
110
111
112
113
114
# File 'lib/insika/channels/relay.rb', line 106

def self.policy!(value)
  return :at_end if Insika::Coercion.blank?(value)

  name = value.to_s.strip.downcase.to_sym
  return name if POLICIES.include?(name)

  raise Insika::ConfigError,
        "unknown relay delivery: #{value.inspect} (expected #{POLICIES.join(', ')})"
end

Instance Method Details

#authenticate(req) ⇒ Object

-> :ok | :unauthorized | :disabled. A SYMBOL and not a Rack triple (the the design sketched one): a status code is the transport's vocabulary, and keeping it out of here is what lets this class be tested without Rack and read without knowing HTTP.



120
121
122
123
124
125
126
127
# File 'lib/insika/channels/relay.rb', line 120

def authenticate(req)
  return :disabled if @inbound_token.empty?

  provided = req.get_header("HTTP_AUTHORIZATION").to_s[/\ABearer (.+)\z/, 1]
  return :unauthorized if provided.nil?

  secure_compare(@inbound_token, provided) ? :ok : :unauthorized
end

#deliver(payload, to:, delivery_id: nil) ⇒ Object

Hands ONE reply to the consumer's callback. -> the HTTP status (the dispatcher decides what 2xx means); raises DeliveryError when the request could not be made at all.

X-Insika-Delivery is the outbox id: a stable idempotency key, so a consumer that receives the same delivery twice (we retried after a timeout that actually landed) can drop the second one.



176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
# File 'lib/insika/channels/relay.rb', line 176

def deliver(payload, to:, delivery_id: nil)
  raise Insika::DeliveryError, "relay '#{@id}' is in shadow mode and must never deliver" if @shadow

  raise Insika::DeliveryError, "relay deliver_url is not configured" if @deliver_url.empty?

  if (reason = egress_violation)
    raise Insika::DeliveryError, "egress blocked for deliver_url: #{reason}"
  end

  response = @http.request(method: :post, url: @deliver_url, timeout: @timeout,
                           headers: headers(delivery_id),
                           body: JSON.generate(payload.merge("external_id" => to.to_s)))
  response[:status].to_i
rescue Insika::DeliveryError
  raise
rescue StandardError => e
  raise Insika::DeliveryError, "#{e.class}: #{e.message}"
end

#deliveryObject



100
# File 'lib/insika/channels/relay.rb', line 100

def delivery = @delivery

#external_id_from(session_id) ⇒ Object

The reverse: what the consumer called this conversation. Reads off the session id so a delivery needs no extra state.



164
165
166
167
# File 'lib/insika/channels/relay.rb', line 164

def external_id_from(session_id)
  s = session_id.to_s
  s.start_with?("#{@id}:") ? s.delete_prefix("#{@id}:") : nil
end

#parse(_req, body:) ⇒ Object

Inbound envelope -> the fields the mount turns into a :send_message. STRING keys in, because the consumer's vars are arbitrary data keys.

{ "agent": "support", "external_id": "5511999998888",
"event_id": "wamid.HBg…", "message": "queria saber do pedido",
"vars": { … } }

In SHADOW mode event_id is REQUIRED: it is the correlation key both halves of the pair are built from, and a mirror that cannot supply a stable id cannot be paired.



139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
# File 'lib/insika/channels/relay.rb', line 139

def parse(_req, body:)
  body = body.is_a?(Hash) ? body : {}
  agent = string(body["agent"])
  external_id = string(body["external_id"])
  message = string(body["message"])
  event_id = presence(body["event_id"])

  raise Insika::ValidationError, "agent is required" if agent.empty?
  raise Insika::ValidationError, "external_id is required" if external_id.empty?
  raise Insika::ValidationError, "message is required" if message.strip.empty?
  raise Insika::ValidationError, "event_id is required in shadow mode" if @shadow && event_id.nil?

  vars = body["vars"].is_a?(Hash) ? body["vars"] : {}
  { agent: agent, external_id: external_id, message: message,
    event_id: event_id, vars: vars, incumbent_reply: presence(body["incumbent_reply"]) }
end

#parse_shadow_reply(_req, body:) ⇒ Object

The reply, as recorded by the mirror. Follows the same strictness as parse; at is optional (nil = now).



197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
# File 'lib/insika/channels/relay.rb', line 197

def parse_shadow_reply(_req, body:)
  body = body.is_a?(Hash) ? body : {}
  external_id = string(body["external_id"])
  event_id = presence(body["event_id"])
  reply = string(body["reply"])

  raise Insika::ValidationError, "external_id is required" if external_id.empty?
  raise Insika::ValidationError, "event_id is required" if event_id.nil?
  raise Insika::ValidationError, "reply is required" if reply.strip.empty?

  at = presence(body["at"])
  raise Insika::ValidationError, "at must be an ISO8601 timestamp" if at && !parseable_time?(at)

  { external_id: external_id, event_id: event_id, reply: reply, at: at }
end

#progressive?Boolean

Returns:

  • (Boolean)


101
# File 'lib/insika/channels/relay.rb', line 101

def progressive? = @delivery == :progressive

#session_id_for(external_id) ⇒ Object

the engine namespaces the platform's conversation key, so a Slack channel id and a phone number can never collide, an operator can see where a conversation came from, and an id minted for one channel cannot be used to read another's session.



160
# File 'lib/insika/channels/relay.rb', line 160

def session_id_for(external_id) = "#{@id}:#{external_id}"

#shadow?Boolean

Returns:

  • (Boolean)


98
# File 'lib/insika/channels/relay.rb', line 98

def shadow? = @shadow