A small helpdesk system: an office Pixel running a patched GrapheneOS Dialer answers technician calls, records both call legs as separate channels, and a Ruby backend transcribes them through Whisper and files an AI summary against the caller. Squashed to a single commit for sharing. No credentials are included; secrets live outside the repo in /etc/helpdesk/env on the server or a gitignored .claude/env.local locally. See .claude/env.local.example for the shape. Start at README.md, then docs/architecture.md.
112 lines
5.3 KiB
Ruby
112 lines
5.3 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# Webhook simulator — stands in for the phone, driving the device API over real HTTP with correct
|
|
# device auth: the happy path plus the awkward cases (out-of-order webhooks, an orphan upload, a
|
|
# rejected signature). Point it at a server with HELPDESK_URL. The quickest way to watch a whole call
|
|
# go through without a phone. Run the server first, then: ruby components/backend/sim/simulator.rb
|
|
require "net/http"
|
|
require "json"
|
|
require "openssl"
|
|
require "securerandom"
|
|
require "time"
|
|
|
|
BASE = ENV.fetch("HELPDESK_URL", "http://127.0.0.1:4000")
|
|
TOKEN = ENV.fetch("HELPDESK_DEVICE_TOKEN", "dev-device-token")
|
|
SECRET = ENV.fetch("HELPDESK_DEVICE_SECRET", "dev-device-secret")
|
|
|
|
$pass = 0; $fail = 0
|
|
def ok(d); $pass += 1; puts " ok #{d}"; end
|
|
def bad(d, g = nil); $fail += 1; puts " FAIL #{d}#{g.nil? ? '' : " (#{g.inspect})"}"; end
|
|
def eq(a, b, d); a == b ? ok(d) : bad(d, a); end
|
|
def truthy(x, d); x ? ok(d) : bad(d, x); end
|
|
|
|
def signed_headers(body)
|
|
ts = Time.now.to_i; nonce = SecureRandom.hex(8)
|
|
{ "Authorization" => "Bearer #{TOKEN}", "Content-Type" => "application/json",
|
|
"X-Request-Ts" => ts.to_s, "X-Request-Nonce" => nonce,
|
|
"X-Signature" => OpenSSL::HMAC.hexdigest("SHA256", SECRET, "#{ts}.#{nonce}.#{body}") }
|
|
end
|
|
|
|
def post(path, obj, headers: nil)
|
|
body = JSON.generate(obj)
|
|
http_req(Net::HTTP::Post, path, body, headers || signed_headers(body))
|
|
end
|
|
|
|
def put_rec(uuid, call_id, bytes = "AUDIO")
|
|
http_req(Net::HTTP::Put, "/api/v1/recordings/#{uuid}", bytes,
|
|
signed_headers(bytes).merge("X-Call-Id" => call_id, "Content-Type" => "application/octet-stream"))
|
|
end
|
|
|
|
def get(path)
|
|
uri = URI("#{BASE}#{path}")
|
|
res = Net::HTTP.get_response(uri)
|
|
[res.code.to_i, safe_json(res.body)]
|
|
end
|
|
|
|
# a device-authed GET (signed, no body). The device channels (heartbeat, command long-poll) are GETs
|
|
# with no Content-Length; this is what exercises the read() content-length path they hit.
|
|
def signed_get(path)
|
|
uri = URI("#{BASE}#{path}")
|
|
req = Net::HTTP::Get.new(uri); signed_headers("").each { |k, v| req[k] = v }
|
|
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |h| h.request(req) }
|
|
[res.code.to_i, safe_json(res.body)]
|
|
end
|
|
|
|
def http_req(klass, path, body, headers)
|
|
uri = URI("#{BASE}#{path}")
|
|
req = klass.new(uri); headers.each { |k, v| req[k] = v }; req.body = body
|
|
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |h| h.request(req) }
|
|
[res.code.to_i, safe_json(res.body)]
|
|
end
|
|
|
|
def safe_json(s) = (JSON.parse(s) rescue s)
|
|
def cid = SecureRandom.uuid
|
|
def t(o = 0) = (Time.now.utc + o).iso8601
|
|
|
|
# wait for server
|
|
20.times { break if (get("/healthz")[0] == 200 rescue false); sleep 0.1 }
|
|
|
|
puts "auth: unsigned request is rejected"
|
|
code, = post("/api/v1/calls/incoming", { call_id: cid, ts: t }, headers: { "Content-Type" => "application/json" })
|
|
eq(code, 401, "401 without bearer/HMAC")
|
|
|
|
puts "happy path over HTTP"
|
|
c = cid
|
|
eq(post("/api/v1/calls/incoming", { call_id: c, ts: t(0), number: "601 234 567", presentation: "allowed", dialed_did: "+420800111222", device_id: "sim" })[0], 200, "incoming 200")
|
|
sc, sb = get("/api/v1/screen-pop?operator=tmobile")
|
|
eq(sc, 200, "screen-pop 200")
|
|
truthy(sb["messages"]&.any? { |m| m.dig("person", "name") == "Jan Novák" }, "screen-pop delivered Jan Novák to tmobile operator")
|
|
eq(post("/api/v1/calls/answered", { call_id: c, ts: t(5) })[0], 200, "answered 200")
|
|
eq(post("/api/v1/calls/ended", { call_id: c, ts: t(125), duration_s: 120, disconnect_cause: "remote", recording_uuid: "recA" })[0], 200, "ended 200")
|
|
eq(put_rec("recA", c)[0], 200, "recording upload 200")
|
|
_, ev = get("/api/v1/events/#{c}")
|
|
eq(ev["state"], "recording_uploaded", "event reached recording_uploaded")
|
|
eq(ev["operator"], "tmobile", "operator resolved from DID") # event_view emits :operator; the old ["operator_context"] key was never returned by the API (pre-existing test bug, backend-agnostic)
|
|
eq(ev["duration_s"], 120, "duration recorded")
|
|
|
|
puts "out-of-order: ended before answered does not regress"
|
|
c = cid
|
|
post("/api/v1/calls/incoming", { call_id: c, ts: t(0), number: "+420601234567", presentation: "allowed", dialed_did: "+420800111222" })
|
|
post("/api/v1/calls/ended", { call_id: c, ts: t(125), duration_s: 120, disconnect_cause: "remote" })
|
|
post("/api/v1/calls/answered", { call_id: c, ts: t(5) })
|
|
_, ev = get("/api/v1/events/#{c}")
|
|
eq(ev["state"], "ended", "late /answered did not regress state")
|
|
truthy(ev["answered_at"], "answered_at still filled")
|
|
|
|
puts "orphan upload -> quarantine"
|
|
r = put_rec("orphanRec", "no-such-call")
|
|
truthy(r[1]["quarantined"], "orphan upload quarantined")
|
|
|
|
puts "missed call + reconcile (server-side clock, so just verify the endpoint path)"
|
|
c = cid
|
|
post("/api/v1/calls/incoming", { call_id: c, ts: t(0), number: "+420999888777", presentation: "allowed", dialed_did: "+420800111222" })
|
|
eq(post("/api/v1/reconcile", {})[0], 200, "reconcile endpoint 200")
|
|
|
|
puts "device GET channels: signed GET with no Content-Length header returns 200 (regression: read() used to 500 on req.content_length)"
|
|
eq(signed_get("/api/v1/device/heartbeat?device_id=sim")[0], 200, "device heartbeat 200")
|
|
gc, gb = signed_get("/api/v1/device/commands?device_id=sim&wait=0")
|
|
eq(gc, 200, "device commands 200")
|
|
truthy(gb.is_a?(Hash) && gb.key?("commands"), "device commands returns a commands list")
|
|
|
|
puts "\n#{$pass} passed, #{$fail} failed"
|
|
exit($fail.zero? ? 0 : 1)
|