283 lines
9.5 KiB
Lua
283 lines
9.5 KiB
Lua
local session = session
|
|
|
|
local function json_escape(value)
|
|
value = value or ""
|
|
value = value:gsub('\\', '\\\\')
|
|
value = value:gsub('"', '\\"')
|
|
return value
|
|
end
|
|
|
|
local function xml_escape(value)
|
|
value = value or ""
|
|
value = value:gsub('&', '&')
|
|
value = value:gsub('<', '<')
|
|
value = value:gsub('>', '>')
|
|
value = value:gsub('"', '"')
|
|
value = value:gsub("'", ''')
|
|
return value
|
|
end
|
|
|
|
local function shell_escape(value)
|
|
value = tostring(value or "")
|
|
return "'" .. value:gsub("'", "'\\''") .. "'"
|
|
end
|
|
|
|
local function json_string(value)
|
|
return '"' .. json_escape(value) .. '"'
|
|
end
|
|
|
|
local function json_object(fields)
|
|
return "{" .. table.concat(fields, ",") .. "}"
|
|
end
|
|
|
|
local function json_unescape(value)
|
|
value = tostring(value or "")
|
|
value = value:gsub('\\"', '"')
|
|
value = value:gsub('\\\\', '\\')
|
|
value = value:gsub('\\/', '/')
|
|
value = value:gsub('\\b', '\b')
|
|
value = value:gsub('\\f', '\f')
|
|
value = value:gsub('\\n', '\n')
|
|
value = value:gsub('\\r', '\r')
|
|
value = value:gsub('\\t', '\t')
|
|
return value
|
|
end
|
|
|
|
local function extract_json_number_field(body, field)
|
|
local pattern = '"' .. field .. '"%s*:%s*(%-?%d+)'
|
|
local value = string.match(body or "", pattern)
|
|
return value and tonumber(value) or nil
|
|
end
|
|
|
|
local function extract_json_string_field(body, field)
|
|
local source = body or ""
|
|
local _, value_start = string.find(source, '"' .. field .. '"%s*:%s*"')
|
|
if value_start then
|
|
local chars = {}
|
|
local escaped = false
|
|
for index = value_start + 1, #source do
|
|
local ch = source:sub(index, index)
|
|
if escaped then
|
|
chars[#chars + 1] = '\\' .. ch
|
|
escaped = false
|
|
elseif ch == '\\' then
|
|
escaped = true
|
|
elseif ch == '"' then
|
|
return json_unescape(table.concat(chars))
|
|
else
|
|
chars[#chars + 1] = ch
|
|
end
|
|
end
|
|
end
|
|
|
|
local null_pattern = '"' .. field .. '"%s*:%s*null'
|
|
if string.find(source, null_pattern) then
|
|
return nil
|
|
end
|
|
return nil
|
|
end
|
|
|
|
if not session or not session:ready() then
|
|
freeswitch.consoleLog("WARNING", "[AI-BOT-9205-REALTIME] no ready session for media bridge\n")
|
|
return
|
|
end
|
|
|
|
local uuid = session:get_uuid()
|
|
local caller = session:getVariable("caller_id_number") or ""
|
|
local arg1 = argv and argv[1] or ""
|
|
local arg2 = argv and argv[2] or ""
|
|
local arg3 = argv and argv[3] or ""
|
|
local arg4 = argv and argv[4] or ""
|
|
local arg5 = argv and argv[5] or ""
|
|
local arg6 = argv and argv[6] or ""
|
|
local arg7 = argv and argv[7] or ""
|
|
local arg8 = argv and argv[8] or ""
|
|
|
|
local function first_non_blank(...)
|
|
local values = {...}
|
|
for _, value in ipairs(values) do
|
|
if value ~= nil and value ~= "" then
|
|
return value
|
|
end
|
|
end
|
|
return ""
|
|
end
|
|
|
|
local function is_ws_url(value)
|
|
return string.match(value or "", "^wss?://") ~= nil
|
|
end
|
|
|
|
local ws_url = first_non_blank(
|
|
session:getVariable("voice_agent_realtime_ws_url"),
|
|
session:getVariable("qwen_realtime_media_ws_url"),
|
|
is_ws_url(arg1) and arg1 or "")
|
|
local bot_did = first_non_blank(
|
|
session:getVariable("bot_did"),
|
|
session:getVariable("voice_agent_bot_did"),
|
|
not is_ws_url(arg1) and arg1 or "",
|
|
arg2)
|
|
local org_uid = first_non_blank(session:getVariable("org_uid"), arg3)
|
|
local conversation_id = first_non_blank(session:getVariable("conversation_id"), arg4, uuid)
|
|
local bridge_enabled = first_non_blank(session:getVariable("voice_agent_bridge_enabled"), arg5, "true")
|
|
local fallback_destination = first_non_blank(session:getVariable("voice_agent_hotline_fallback_destination"), arg6)
|
|
local fallback_context = first_non_blank(session:getVariable("voice_agent_hotline_fallback_context"), arg7, "default")
|
|
local ai_bot_base_url = first_non_blank(
|
|
session:getVariable("voice_agent_ai_bot_base_url"),
|
|
session:getVariable("ai_bot_base_url"),
|
|
arg8)
|
|
local dynamic_hotline = fallback_destination ~= "" or session:getVariable("voice_agent") == "true"
|
|
|
|
if ws_url == "" then
|
|
ws_url = "ws://host.docker.internal:9003/visitor/api/v1/call/voice-agent/qwen-realtime/media"
|
|
end
|
|
|
|
if bot_did == "" then
|
|
bot_did = session:getVariable("destination_number") or "9205"
|
|
end
|
|
|
|
local function enabled(value)
|
|
value = string.lower(value or "")
|
|
return value == "true" or value == "1" or value == "yes"
|
|
end
|
|
|
|
local function normalize_base_url(value)
|
|
value = first_non_blank(value)
|
|
if value == "" then
|
|
return ""
|
|
end
|
|
return value:gsub("/$", "")
|
|
end
|
|
|
|
local function normalize_playback_url(value)
|
|
value = first_non_blank(value)
|
|
if value == "" then
|
|
return ""
|
|
end
|
|
value = value:gsub("//localhost:", "//host.docker.internal:")
|
|
value = value:gsub("//127%.0%.0%.1:", "//host.docker.internal:")
|
|
|
|
local lower = string.lower(value)
|
|
local lower_without_suffix = lower:gsub("[%?#].*$", "")
|
|
if string.match(lower, "^https?://") and string.sub(lower_without_suffix, -4) == ".mp3" then
|
|
local normalized = value:gsub("^https?://", "")
|
|
return "shout://" .. normalized
|
|
end
|
|
|
|
return value
|
|
end
|
|
|
|
local function http_post_json(base_url, path, payload)
|
|
local normalized_base_url = normalize_base_url(base_url)
|
|
if normalized_base_url == "" then
|
|
return nil
|
|
end
|
|
|
|
local body = json_object({
|
|
'"orgUid":' .. json_string(payload and payload.orgUid or ""),
|
|
'"did":' .. json_string(payload and payload.did or "")
|
|
})
|
|
local command = table.concat({
|
|
"curl -sS --connect-timeout 5 --max-time 15",
|
|
"-H 'Accept: application/json'",
|
|
"-H 'Content-Type: application/json'",
|
|
"-X POST",
|
|
"--data", shell_escape(body),
|
|
shell_escape(normalized_base_url .. path)
|
|
}, " ")
|
|
|
|
local handle = io.popen(command, "r")
|
|
if not handle then
|
|
freeswitch.consoleLog("WARNING", string.format("[AI-HOTLINE-REALTIME] http open failed path=%s\n", path))
|
|
return nil
|
|
end
|
|
|
|
local response = handle:read("*a") or ""
|
|
handle:close()
|
|
if response == "" then
|
|
freeswitch.consoleLog("WARNING", string.format("[AI-HOTLINE-REALTIME] http empty response path=%s\n", path))
|
|
return nil
|
|
end
|
|
|
|
local code = extract_json_number_field(response, "code") or 500
|
|
if code ~= 200 then
|
|
freeswitch.consoleLog("WARNING", string.format("[AI-HOTLINE-REALTIME] http business failed path=%s code=%s message=%s\n",
|
|
path,
|
|
tostring(code),
|
|
tostring(extract_json_string_field(response, "message"))))
|
|
return nil
|
|
end
|
|
|
|
return {
|
|
welcomeType = extract_json_string_field(response, "welcomeType"),
|
|
welcomeText = extract_json_string_field(response, "welcomeText"),
|
|
welcomeAudioUrl = extract_json_string_field(response, "welcomeAudioUrl")
|
|
}
|
|
end
|
|
|
|
local function play_dynamic_hotline_welcome()
|
|
local mrcp_profile = session:getVariable("mrcp_profile") or "java-mrcp"
|
|
local default_text = "您好,我是微语智能语音助手,请问有什么可以帮您的?"
|
|
local welcome = http_post_json(ai_bot_base_url, "/visitor/api/v1/call/voice-agent/welcome", {
|
|
orgUid = org_uid,
|
|
did = bot_did
|
|
})
|
|
|
|
local welcome_type = string.upper(first_non_blank(welcome and welcome.welcomeType, "TTS"))
|
|
local welcome_audio_url = normalize_playback_url(welcome and welcome.welcomeAudioUrl)
|
|
local welcome_text = first_non_blank(welcome and welcome.welcomeText, default_text)
|
|
|
|
freeswitch.consoleLog("INFO", string.format("[AI-HOTLINE-REALTIME] welcome resolved uuid=%s did=%s type=%s rawAudioUrl=%s playbackUrl=%s\n",
|
|
uuid,
|
|
bot_did,
|
|
welcome_type,
|
|
first_non_blank(welcome and welcome.welcomeAudioUrl),
|
|
welcome_audio_url))
|
|
|
|
if welcome_type == "AUDIO" and welcome_audio_url ~= "" then
|
|
session:execute("playback", welcome_audio_url)
|
|
freeswitch.consoleLog("INFO", string.format("[AI-HOTLINE-REALTIME] played audio welcome uuid=%s did=%s audioUrl=%s\n", uuid, bot_did, welcome_audio_url))
|
|
return
|
|
end
|
|
|
|
session:execute("speak", "unimrcp:" .. mrcp_profile .. "||<speak version='1.0' xml:lang='zh-CN'>" .. xml_escape(welcome_text) .. "</speak>")
|
|
freeswitch.consoleLog("INFO", string.format("[AI-HOTLINE-REALTIME] played tts welcome uuid=%s did=%s welcomeType=%s textLength=%s\n",
|
|
uuid,
|
|
bot_did,
|
|
welcome_type,
|
|
tostring(string.len(welcome_text))))
|
|
end
|
|
|
|
local function run_fallback_entry()
|
|
if fallback_destination == "" or ai_bot_base_url == "" then
|
|
freeswitch.consoleLog("WARNING", string.format("[AI-HOTLINE-REALTIME] bridge disabled but fallback is incomplete uuid=%s did=%s\n", uuid, bot_did))
|
|
return
|
|
end
|
|
local httapi = string.format("{url=%s/ai-bot?turn=1&mode=unlimited&bot_did=%s&org_uid=%s&voice_agent=true&conversation_id=%s,method=POST}",
|
|
ai_bot_base_url, bot_did, org_uid, conversation_id)
|
|
session:execute("playback", "tone_stream://%(300,1000,440);loops=1")
|
|
session:execute("httapi", httapi)
|
|
session:execute("sleep", "200")
|
|
session:execute("transfer", string.format("%s XML %s", fallback_destination, fallback_context))
|
|
end
|
|
|
|
if dynamic_hotline and not enabled(bridge_enabled) then
|
|
freeswitch.consoleLog("INFO", string.format("[AI-HOTLINE-REALTIME] bridge disabled, fallback to httapi loop uuid=%s did=%s orgUid=%s\n", uuid, bot_did, org_uid))
|
|
run_fallback_entry()
|
|
return
|
|
end
|
|
|
|
if dynamic_hotline then
|
|
play_dynamic_hotline_welcome()
|
|
end
|
|
|
|
local metadata = string.format('{"uuid":"%s","caller":"%s","botDid":"%s","orgUid":"%s","conversationId":"%s"}',
|
|
json_escape(uuid), json_escape(caller), json_escape(bot_did), json_escape(org_uid), json_escape(conversation_id))
|
|
local command = string.format("uuid_audio_stream %s start %s mono 16k %s", uuid, ws_url, metadata)
|
|
local api = freeswitch.API()
|
|
local result = api:executeString(command) or ""
|
|
|
|
freeswitch.consoleLog("INFO", string.format("[AI-HOTLINE-REALTIME] media bridge start uuid=%s did=%s orgUid=%s ws=%s result=%s\n", uuid, bot_did, org_uid, ws_url, result))
|
|
|
|
if dynamic_hotline then
|
|
session:execute("park", "")
|
|
end |