Class: Insika::Channels::Relay
- Inherits:
-
Object
- Object
- Insika::Channels::Relay
- 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;:progressivelets 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
-
#id ⇒ Object
readonly
Returns the value of attribute id.
Class Method Summary collapse
-
.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.
-
.policy!(value) ⇒ Object
"progressive" | "at_end" | blank/unset (= :at_end).
Instance Method Summary collapse
-
#authenticate(req) ⇒ Object
-> :ok | :unauthorized | :disabled.
-
#deliver(payload, to:, delivery_id: nil) ⇒ Object
Hands ONE reply to the consumer's callback.
- #delivery ⇒ Object
-
#external_id_from(session_id) ⇒ Object
The reverse: what the consumer called this conversation.
-
#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
constructor
inbound_token: shared secret the consumer sends us (Bearer).
-
#parse(_req, body:) ⇒ Object
Inbound envelope -> the fields the mount turns into a
:send_message. -
#parse_shadow_reply(_req, body:) ⇒ Object
The reply, as recorded by the mirror.
- #progressive? ⇒ Boolean
-
#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.
- #shadow? ⇒ Boolean
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
#id ⇒ Object (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.}" end |
#delivery ⇒ Object
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"]) = 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 .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: , 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
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
98 |
# File 'lib/insika/channels/relay.rb', line 98 def shadow? = @shadow |