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 (RFC-0011 §6). 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 (§6.3). 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
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.
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.
-
#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) ⇒ 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. -
#session_id_for(external_id) ⇒ Object
RFC-0011 §4.3 — 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.
Constructor Details
#initialize(inbound_token:, deliver_url:, deliver_token: nil, http: nil, id: DEFAULT_ID, allow_http: false, allow_private: false, timeout: DEFAULT_TIMEOUT) ⇒ 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.
62 63 64 65 66 67 68 69 70 71 72 73 |
# File 'lib/insika/channels/relay.rb', line 62 def initialize(inbound_token:, deliver_url:, deliver_token: nil, http: nil, id: DEFAULT_ID, allow_http: false, allow_private: false, timeout: DEFAULT_TIMEOUT) @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 end |
Instance Attribute Details
#id ⇒ Object (readonly)
Returns the value of attribute id.
36 37 38 |
# File 'lib/insika/channels/relay.rb', line 36 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.
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.
44 45 46 47 48 49 50 51 52 |
# File 'lib/insika/channels/relay.rb', line 44 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), http: http, allow_http: allow_http, allow_private: allow_private) end |
Instance Method Details
#authenticate(req) ⇒ Object
-> :ok | :unauthorized | :disabled. A SYMBOL and not a Rack triple (the RFC 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.
79 80 81 82 83 84 85 86 |
# File 'lib/insika/channels/relay.rb', line 79 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.
129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 |
# File 'lib/insika/channels/relay.rb', line 129 def deliver(payload, to:, delivery_id: nil) 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 |
#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.
117 118 119 120 |
# File 'lib/insika/channels/relay.rb', line 117 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": { … } }
94 95 96 97 98 99 100 101 102 103 104 105 106 107 |
# File 'lib/insika/channels/relay.rb', line 94 def parse(_req, body:) body = body.is_a?(Hash) ? body : {} agent = string(body["agent"]) external_id = string(body["external_id"]) = string(body["message"]) 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? vars = body["vars"].is_a?(Hash) ? body["vars"] : {} { agent: agent, external_id: external_id, message: , event_id: presence(body["event_id"]), vars: vars } end |
#session_id_for(external_id) ⇒ Object
RFC-0011 §4.3 — 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.
113 |
# File 'lib/insika/channels/relay.rb', line 113 def session_id_for(external_id) = "#{@id}:#{external_id}" |