題外話:昨天是2020年元宵節,正值"新型肺炎"第二階段防治關鍵時期,返滬后按規定自覺在家隔離14天,不出去給社會添亂,真心希望這次疫情快點過去。
廢話不多說,繼續學習,上篇借助工具大致體驗了voip client的使用,這篇學習如何用代碼來實現類似的功能。esl全稱Event Socket Library, 通過它可以與freeswitch進行交互,esl client支持多種語言,本文將以esl java client為例,演示一些基本用法:
一、兩種模式:inbound、outbound
freeswitch(以下簡單fs)啟動后,內置了一個tcp server,默認會監聽8021端口,通過esl,java 應用可以監聽該端口,獲取fs的各種事件通知,這種模式稱為inbound模式。
如上圖,inbound模式下:java應用引用esl java client的jar包后(注:esl java client底層是依賴netty實現的),連接到fs(fs內置了mod_event_socket模塊,會在本地默認監聽2081端口),連接成功后,如果有來電,fs會觸發各種事件,透過已經連上的通道,通知java應用,java應用可以針對特定事件做些處理(有必要的話,還可以發送命令給fs),當然連接成功后,java應用也可以直接向fs發送命令,比如對外呼叫某個號碼。
如果反過來,java應用起1個端口,自己充當tcp server,fs連接java應用,就稱為outbound模式,如下圖:
java應用利用esl java client在本機監聽某個端口,相當於啟動了一個tcp server(底層仍然是基於nettty實現),當fs收到來電時,會連接java應用的tcp server(注:需要修改fs的配置,否則fs不知道tcp server的ip\port這些連接信息),然后java應用可以根據自身業務做些處理,發送命令給fs(比如:給客人放段音樂或轉接到特定目標),通話結束后(比如:主叫方掛斷,或被叫方拒接),fs會斷開連接,直到下次再有來電。
tips:inbound/outbound 是站在fs的角度來看的,外部應用連進來,就是inbound;fs連出去,就是outbound。 二種模式基本上都可以完成大多數業務功能,如何選取看各自特點,比如:如果要監控所有來電情況或實現客人自助語音服務,inbound相對更方便(可以很輕松獲取所有事件)。對於來電后的人工客服分配,outbound則更簡單(比如:客人來電撥打某個對外暴露公用客服號碼比如400電話時,fs把客人來電通過tcp connect最終給到java app,java應用按一定分配規則 ,比如哪個客服最空閑,把來電bridge到該客服分機即可)
二、inbound 代碼示例
2.1 pom依賴
<dependency> <groupId>org.freeswitch.esl.client</groupId> <artifactId>org.freeswitch.esl.client</artifactId> <version>0.9.2</version> </dependency>
2.2 演示代碼
下面的代碼,演示了連接到fs后,利用client直接發起外呼。
package com.cnblogs.yjmyzz.freeswitch.esl; import org.freeswitch.esl.client.IEslEventListener; import org.freeswitch.esl.client.inbound.Client; import org.freeswitch.esl.client.inbound.InboundConnectionFailure; import org.freeswitch.esl.client.transport.event.EslEvent; /** * @author 菩提樹下的楊過 */ public class InboundApp { public static void main(String[] args) throws InterruptedException { Client client = new Client(); try { //連接freeswitch client.connect("localhost", 8021, "ClueCon", 10); client.addEventListener(new IEslEventListener() { @Override public void eventReceived(EslEvent event) { String eventName = event.getEventName(); //這里僅演示了CHANNEL_開頭的幾個常用事件 if (eventName.startsWith("CHANNEL_")) { String calleeNumber = event.getEventHeaders().get("Caller-Callee-ID-Number"); String callerNumber = event.getEventHeaders().get("Caller-Caller-ID-Number"); switch (eventName) { case "CHANNEL_CREATE": System.out.println("發起呼叫, 主叫:" + callerNumber + " , 被叫:" + calleeNumber); break; case "CHANNEL_BRIDGE": System.out.println("用戶轉接, 主叫:" + callerNumber + " , 被叫:" + calleeNumber); break; case "CHANNEL_ANSWER": System.out.println("用戶應答, 主叫:" + callerNumber + " , 被叫:" + calleeNumber); break; case "CHANNEL_HANGUP": String response = event.getEventHeaders().get("variable_current_application_response"); String hangupCause = event.getEventHeaders().get("Hangup-Cause"); System.out.println("用戶掛斷, 主叫:" + callerNumber + " , 被叫:" + calleeNumber + " , response:" + response + " ,hangup cause:" + hangupCause); break; default: break; } } } @Override public void backgroundJobResultReceived(EslEvent event) { String jobUuid = event.getEventHeaders().get("Job-UUID"); System.out.println("異步回調:" + jobUuid); } }); client.setEventSubscriptions("plain", "all"); //這里必須檢查,防止網絡抖動時,連接斷開 if (client.canSend()) { System.out.println("連接成功,准備發起呼叫..."); //(異步)向1000用戶發起呼叫,用戶接通后,播放音樂/tmp/demo1.wav String callResult = client.sendAsyncApiCommand("originate", "user/1000 &playback(/tmp/demo.wav)"); System.out.println("api uuid:" + callResult); } } catch (InboundConnectionFailure inboundConnectionFailure) { System.out.println("連接失敗!"); inboundConnectionFailure.printStackTrace(); } } }
參考輸出結果類似如下:
連接成功,准備發起呼叫... api uuid:54ae7272-62c1-4d1f-87a1-aab2080538dc 發起呼叫, 主叫:0000000000 , 被叫:1000 用戶應答, 主叫:0000000000 , 被叫:1000 異步回調:54ae7272-62c1-4d1f-87a1-aab2080538dc 用戶掛斷, 主叫:1000 , 被叫:0000000000 , response:null ,hangup cause:NORMAL_CLEARING
代碼稍微解釋一下:
a) 18行,連接fs的用戶名、密碼、端口,可以在freeswitch安裝目錄下的conf/autoload_configs/event_socket.conf.xml 找到

1 <configuration name="event_socket.conf" description="Socket Client"> 2 <settings> 3 <param name="nat-map" value="false"/> 4 <param name="listen-ip" value="0.0.0.0"/> 5 <param name="listen-port" value="8021"/> 6 <param name="password" value="ClueCon"/> 7 <!--<param name="apply-inbound-acl" value="loopback.auto"/>--> 8 <!--<param name="stop-on-bind-error" value="true"/>--> 9 </settings> 10 </configuration>
強烈建議,把第4行listen-ip改成0.0.0.0(或具體的本機ip地址),默認的::是ipv6格式,很多情況會導致esl client連接失敗,改成0.0.0.0相當於強制使用ipv4
b) 考慮到網絡可能發生抖動,在發送命令前,建議參考60行的做法,先判斷canSend()
c) 61行,client.sendAsyncApiCommand 這里以異步方式,發送了一個命令給fs(即:呼叫1000用戶,接通后再放段聲音)。異步方式下,命令是否發成功當時並不知道,但是這個方法會返回一個uuid的字符串,fs收到后,會在backgroundJobResultReceived回調中,把這個uuid再還回來,參見上面貼出的輸出結果。(基於這個機制,可以做些重試處理,比如:先把uuid存下來,如果約定的時間內,uuid異步回調還沒回來,可以視為發送失敗,再發一次)
重要提示:esl java client 0.9.2這個版本,inbound模式下,長時間使用有內存泄露問題,網上有很多這個介紹及修復辦法,建議生產環境使用前,先修改esl client的源碼。
三、outbound示例
3.1 修改dialplan配置
出於演示目的,這里修改/usr/local/freeswitch/conf/dialplan/default.xml,在文件開頭部分添加一段:
<extension name="socket_400_example"> <condition field="destination_number" expression="^400\d+$"> <action application="socket" data="localhost:8086 async full"/> </condition> </extension>
即:當來電的被叫號碼為400開頭時,fs將利用socket,連接到localhost:8086
3.2 編寫業務邏輯
a) SampleOutboundHandler
package com.cnblogs.yjmyzz.freeswitch.esl.outbound; import org.freeswitch.esl.client.outbound.AbstractOutboundClientHandler; import org.freeswitch.esl.client.transport.SendMsg; import org.freeswitch.esl.client.transport.event.EslEvent; import org.freeswitch.esl.client.transport.message.EslHeaders; import org.freeswitch.esl.client.transport.message.EslMessage; import org.jboss.netty.channel.Channel; import org.jboss.netty.channel.ChannelHandlerContext; import java.util.ArrayList; import java.util.List; /** * @author 菩提樹下的楊過 */ public class SampleOutboundHandler extends AbstractOutboundClientHandler { @Override protected void handleConnectResponse(ChannelHandlerContext ctx, EslEvent event) { System.out.println("Received connect response :" + event); if (event.getEventName().equalsIgnoreCase("CHANNEL_DATA")) { // this is the response to the initial connect System.out.println("======================= incoming channel data ============================="); System.out.println("Event-Date-Local: " + event.getEventDateLocal()); System.out.println("Unique-ID: " + event.getEventHeaders().get("Unique-ID")); System.out.println("Channel-ANI: " + event.getEventHeaders().get("Channel-ANI")); System.out.println("Answer-State: " + event.getEventHeaders().get("Answer-State")); System.out.println("Caller-Destination-Number: " + event.getEventHeaders().get("Caller-Destination-Number")); System.out.println("======================= = = = = = = = = = = = ============================="); // now bridge the call bridgeCall(ctx.getChannel(), event); } else { throw new IllegalStateException("Unexpected event after connect: [" + event.getEventName() + ']'); } } private void bridgeCall(Channel channel, EslEvent event) { List<String> extNums = new ArrayList<>(2); extNums.add("1000"); extNums.add("1010"); //隨機找1個目標(注:這里只是演示目的,真正分配時,應該考慮到客服的忙閑情況,通常應該分給最空閑的客服) String destNumber = extNums.get((int)Math.abs(System.currentTimeMillis() % 2)); SendMsg bridgeMsg = new SendMsg(); bridgeMsg.addCallCommand("execute"); bridgeMsg.addExecuteAppName("bridge"); bridgeMsg.addExecuteAppArg("user/" + destNumber); //同步發送bridge命令接通 EslMessage response = sendSyncMultiLineCommand(channel, bridgeMsg.getMsgLines()); if (response.getHeaderValue(EslHeaders.Name.REPLY_TEXT).startsWith("+OK")) { String originCall = event.getEventHeaders().get("Caller-Destination-Number"); System.out.println(originCall + " bridge to " + destNumber + " successful"); } else { System.out.println("Call bridge failed: " + response.getHeaderValue(EslHeaders.Name.REPLY_TEXT)); } } @Override protected void handleEslEvent(ChannelHandlerContext ctx, EslEvent event) { System.out.println("received event:" + event); } @Override protected void handleDisconnectionNotice() { super.handleDisconnectionNotice(); System.out.println("Received disconnection notice"); } }
重點看下bridgeCall這個方法,假設有2個客服號碼1000、1010可用,隨機挑1個,然后將來電接通到這個號碼。
b) AbstractOutboundPipelineFactory
package com.cnblogs.yjmyzz.freeswitch.esl.outbound; import org.freeswitch.esl.client.outbound.AbstractOutboundClientHandler; import org.freeswitch.esl.client.outbound.AbstractOutboundPipelineFactory; /** * @author 菩提樹下的楊過 */ public class SamplePipelineFactory extends AbstractOutboundPipelineFactory { @Override protected AbstractOutboundClientHandler makeHandler() { return new SampleOutboundHandler(); } }
還需要一個工廠類,包裝一下。
c)OutboundApp 程序入口
package com.cnblogs.yjmyzz.freeswitch.esl.outbound; import org.freeswitch.esl.client.outbound.SocketClient; /** * @author 菩提樹下的楊過 */ public class OutboundApp { public static void main(String[] args) throws InterruptedException { new Thread(() -> { SocketClient socketClient = new SocketClient(8086, new SamplePipelineFactory()); socketClient.start(); }).start(); while (true) { Thread.sleep(500); } } }
輸出結果:
Received connect response :EslEvent: name=[CHANNEL_DATA] headers=5, eventHeaders=169, eventBody=0 lines. ======================= incoming channel data ============================= Event-Date-Local: 2020-02-09 12:02:35 Unique-ID: bd659733-d460-4f0f-8c73-4cd4f1e39f68 Channel-ANI: 1002 Answer-State: ringing Caller-Destination-Number: 4008123123 ======================= = = = = = = = = = = = ============================= 4008123123 bridge to 1010 successful Received disconnection notice
文中示例代碼git地址: https://github.com/yjmyzz/freeswitch-esl-java-client-sample
參考文章:
https://freeswitch.org/confluence/display/FREESWITCH/mod_event_socket
https://freeswitch.org/confluence/display/FREESWITCH/Java+ESL+Client