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

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

#idObject (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.message}"
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"])
  message = 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 message.strip.empty?

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

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



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

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