--[[ 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 }