--[[
  numbers_online_cnam.lua — inbound CNAM + spam-score enrichment for FreeSWITCH

  Install:  conf/scripts/numbers_online_cnam.lua  (the mod_lua script dir)
  Call from the dialplan EARLY in the inbound route, before you ring an extension:
      <action application="lua" data="numbers_online_cnam.lua" inline="false"/>

  Why Lua instead of mod_cidlookup
  --------------------------------
  This path uses our JSON endpoint (/v1/lookup) with Bearer-header auth, giving you
  the full response (cnam, spam_score, line_type, carrier) — not just a name string.
  mod_curl / the dialplan curl app cannot set an Authorization header (FreeSWITCH
  issue #1377), so we shell out to the system `curl` binary via io.popen with
  -H "Authorization: Bearer ...". The API key is read from a FreeSWITCH global
  variable, never hardcoded here.

  What it sets on the channel (only on a clean result):
    effective_caller_id_name      cnam, optionally prefixed with your spam tag
    numbers_online_spam_score     1-99 integer, for downstream routing decisions
    numbers_online_cnam           the raw cnam string (no tag prefix)

  This data is a supplementary, low-confidence signal. We never assert a caller is
  spam or fraud. Whether risk wording reaches the caller name is YOUR choice, via
  numbers_online_spam_tag / numbers_online_spam_threshold below.

  Configure these in conf/vars.xml (pre-processed globals), e.g.:
    <X-PRE-PROCESS cmd="set" data="numbers_online_key=YOUR_API_KEY"/>
    <X-PRE-PROCESS cmd="set" data="numbers_online_spam_tag=Spam?"/>      (optional)
    <X-PRE-PROCESS cmd="set" data="numbers_online_spam_threshold=80"/>   (optional)
  Leave numbers_online_spam_tag unset to never prefix the caller name with risk text.

  FAIL-OPEN CONTRACT: every error path below returns WITHOUT touching the channel.
  A slow or unreachable API must never block or delay call setup.
]]

-- ---- tunables -------------------------------------------------------------
local API_HOST       = "https://numbers.online"
local CURL_MAX_TIME  = 2          -- seconds; hard cap so call setup never stalls
local DEFAULT_THRESH = 80         -- spam_score at/above which the tag is applied

-- ---- read config from FreeSWITCH globals ----------------------------------
local api_key   = freeswitch.getGlobalVariable("numbers_online_key")
local spam_tag  = freeswitch.getGlobalVariable("numbers_online_spam_tag")        -- may be nil
local thresh_s  = freeswitch.getGlobalVariable("numbers_online_spam_threshold")
local threshold = tonumber(thresh_s) or DEFAULT_THRESH

-- No session, or no key configured? Do nothing (fail open).
if not session or not api_key or api_key == "" or api_key == "YOUR_API_KEY" then
  return
end

-- The key is interpolated into a shell command below: restrict it to the
-- character set our keys actually use (nol_ + hex) so a misconfigured global
-- can never break out of the quoting.
api_key = api_key:gsub("[^%w_%-]", "")
if api_key == "" then return end

-- ---- get the inbound number -----------------------------------------------
local raw = session:getVariable("caller_id_number")
if not raw or raw == "" then return end

-- Normalize loose national/E.164 input to E.164. Adjust the +1 default if your
-- bare 10-digit numbers are not NANP.
local digits = raw:gsub("%D", "")
local e164
if raw:sub(1, 1) == "+" then
  e164 = "+" .. digits
elseif #digits == 10 then
  e164 = "+1" .. digits
elseif #digits == 11 and digits:sub(1, 1) == "1" then
  e164 = "+" .. digits
else
  e164 = "+" .. digits
end
if #digits < 7 then return end   -- too short to be a routable number; bail

-- ---- call the JSON lookup over HTTPS with Bearer auth ---------------------
-- Build args safely: the key goes in a header (not the URL), and we single-quote
-- everything for the shell. The key/number never reach stdout or any log here.
local url = API_HOST .. "/v1/lookup/" .. e164
local cmd = string.format(
  "curl -s -m %d -H 'Authorization: Bearer %s' '%s' 2>/dev/null",
  CURL_MAX_TIME, api_key, url
)

local pipe = io.popen(cmd)
if not pipe then return end                 -- could not spawn curl: fail open
local body = pipe:read("*a")
pipe:close()
if not body or body == "" then return end   -- timeout / empty: fail open

-- ---- extract fields with plain string patterns (no JSON lib dependency) ----
-- We only need two fields. cnam may be a JSON string or null; spam_score an int
-- or null. Tolerant patterns; any miss simply leaves the value nil (fail open).
--
-- The cnam pattern must survive names containing escaped quotes (JSON \" inside
-- the value): match up to the first quote NOT preceded by a backslash, then
-- unescape. A naive '(.-)"' would truncate 'ACME \"BEST\" CORP' at the first \".
local cnam = body:match('"cnam"%s*:%s*"(.-[^\\])"')   -- nil when "cnam":null or ""
if cnam then
  cnam = cnam:gsub('\\(.)', '%1')                     -- unescape \" \\ \/ etc.
  cnam = cnam:gsub('%c', '')                          -- strip any control chars
end
local spam = tonumber(body:match('"spam_score"%s*:%s*(%d+)'))

-- Guard against a fail-open API error blob ever becoming a caller name.
if not cnam and not spam then return end

-- ---- apply the result -----------------------------------------------------
if spam then
  session:setVariable("numbers_online_spam_score", tostring(spam))
end

if cnam and cnam ~= "" then
  session:setVariable("numbers_online_cnam", cnam)

  -- Risk wording is opt-in: prefix only when an operator tag is set AND the score
  -- meets the threshold. Otherwise the plain name is used.
  local display = cnam
  if spam_tag and spam_tag ~= "" and spam and spam >= threshold then
    display = spam_tag .. " " .. cnam
  end
  session:setVariable("effective_caller_id_name", display)
elseif spam_tag and spam_tag ~= "" and spam and spam >= threshold then
  -- No name, but the operator opted into a tag and the score crossed the line:
  -- surface the tag alone so the agent still sees the signal.
  session:setVariable("effective_caller_id_name", spam_tag)
end

-- Done. On every path above, an error/timeout left the channel untouched.
