--[[
nomoVM answering machine detection for FreeSWITCH.
Streams the answered call's audio to nomoVM with mod_audio_stream, waits for the verdict and sets:
AMDSTATUS = HUMAN | MACHINE | FALLBACK
AMDCAUSE = HUMAN | MACHINE | SCREENING | SILENCE | TONE | BUSY | UNKNOWN (HUMAN / MACHINE)
AUTH | SUSPENDED | QUOTA | NOSERVER (FALLBACK)
FALLBACK means nomoVM can't judge this call (bad or disabled key, plan limit, service unreachable, or
mod_audio_stream not loaded). It is decided before the call's audio is used, so your dialplan can use
another detector or treat the call as before. A timeout or error after streaming started gives
MACHINE / UNKNOWN: only a confirmed HUMAN goes to an agent.
Needs mod_lua (standard) and mod_audio_stream (https://github.com/amigniter/mod_audio_stream, loaded with
`load mod_audio_stream`). Copy this file to FreeSWITCH's scripts folder (usually /usr/share/freeswitch/scripts
or /etc/freeswitch/scripts) and call it right after the call is answered:
Arguments: WebSocket URL, API key, overall timeout in ms (default 4500; it must cover the service's
longest listening window, 3.2 s, plus network time).
]]
local VERSION = "1.0.0"
local HANDSHAKE_MS = 1000 -- no connection after this long: FALLBACK while the call is still fresh
local POLL_MS = 50
local function log(level, msg)
freeswitch.consoleLog(level, "nomovm_amd: " .. msg .. "\n")
end
-- "amdstatus": "HUMAN" -> HUMAN (the verdict is small, flat JSON: no parser needed)
local function json_field(body, key)
return body and body:match('"' .. key .. '"%s*:%s*"([^"]*)"') or nil
end
-- AMDSTATUS / AMDCAUSE from the service's verdict, with the HUMAN rule enforced
local function verdict_vars(body)
local status, cause = json_field(body, "amdstatus"), json_field(body, "amdcause")
if status == "FALLBACK" then
return "FALLBACK", cause or "NOSERVER"
end
if status ~= "HUMAN" and status ~= "MACHINE" then
return "MACHINE", "UNKNOWN"
end
if (status == "HUMAN") ~= (cause == "HUMAN") then
return "MACHINE", (cause and cause ~= "HUMAN") and cause or "UNKNOWN"
end
return status, cause
end
local function run(session, api, url, key, timeout_ms)
local uuid = session:get_uuid()
if not url or url == "" or not key or key == "" then
return "FALLBACK", "NOSERVER"
end
if key:find('[%s"\\]') then -- the key goes into a JSON header value
return "FALLBACK", "AUTH"
end
-- mod_audio_stream reads these when the stream starts
session:setVariable("STREAM_EXTRA_HEADERS", '{"Authorization":"Bearer ' .. key .. '"}')
session:setVariable("STREAM_NO_RECONNECT", "true") -- the service closes after its verdict: don't reconnect
session:setVariable("STREAM_SUPPRESS_LOG", "true")
session:setVariable("STREAM_BUFFER_SIZE", "100") -- 100 ms frames, like the Asterisk client
local events = freeswitch.EventConsumer("CUSTOM", "mod_audio_stream::json")
events:bind("CUSTOM", "mod_audio_stream::connect")
events:bind("CUSTOM", "mod_audio_stream::disconnect")
events:bind("CUSTOM", "mod_audio_stream::error")
local start = '{"type":"start","call_id":"' .. uuid .. '"}'
local reply = api:executeString("uuid_audio_stream " .. uuid .. " start " .. url .. " mono 8k " .. start) or ""
if not reply:find("^%+OK") then
log("WARNING", "uuid_audio_stream failed (is mod_audio_stream loaded?): " .. reply)
return "FALLBACK", "NOSERVER"
end
-- api:getTime() is FreeSWITCH's clock in ms; without it, count poll steps (slightly long on busy servers)
local function now() local ok, t = pcall(function() return tonumber(api:getTime()) end); return ok and t or nil end
local t0 = now()
local elapsed, connected = 0, false
local status, cause = "MACHINE", "UNKNOWN"
while elapsed < timeout_ms do
if not session:ready() then break end -- caller hung up
local e = events:pop(1, POLL_MS)
if e then
local id = e:getHeader("Unique-ID") or e:getHeader("Channel-Call-UUID")
if id == uuid then
local sub = e:getHeader("Event-Subclass") or ""
local body = e:getBody() or ""
if sub == "mod_audio_stream::connect" then
connected = true
elseif sub == "mod_audio_stream::json" then
status, cause = verdict_vars(body)
break
elseif sub == "mod_audio_stream::error" then
log("WARNING", "stream error: " .. body)
if not connected then status, cause = "FALLBACK", "NOSERVER" end -- nothing was streamed yet
break
elseif sub == "mod_audio_stream::disconnect" then
break -- closed without a verdict: MACHINE / UNKNOWN
end
end
end
local t = t0 and now()
if t then elapsed = t - t0 else elapsed = elapsed + POLL_MS end
if not connected and elapsed >= HANDSHAKE_MS then
log("WARNING", "no connection to " .. url .. " after " .. HANDSHAKE_MS .. " ms")
status, cause = "FALLBACK", "NOSERVER"
break
end
end
api:executeString("uuid_audio_stream " .. uuid .. " stop")
return status, cause
end
-- when FreeSWITCH runs the script (tests load it with `session` unset and call the returned table)
if session then
local api = freeswitch.API()
local status, cause = run(session, api, argv[1], argv[2], tonumber(argv[3] or "") or 4500)
session:setVariable("AMDSTATUS", status)
session:setVariable("AMDCAUSE", cause)
log("INFO", status .. " " .. cause)
end
return { run = run, verdict_vars = verdict_vars, VERSION = VERSION }