XCC API

快速上手

接下来,我们看几个实际的例子,以便能快速理解和使用 XCC API 控制呼叫。

本章的示例大部分以Node.js为例,因为它基于 Javascript 语言,易懂,也容易讲解。


接听后播放文件

在 XSwitch 中创建如下路由:

  • 被叫号码:1234
  • 目的地类型选择:系统
  • 目的地:xcc

当呼叫1234时,路由到xcc,它会固定往cn.xswitch.ctrl上发Event.Channel消息。如果想让消息发到特定的 Subject 上,目的地中可以填xcc cn.xswitch.ctrl.你自己的Ctroller的UUID

呼叫流程看代码中的注释(简单起见没有加太多错误处理代码)。本示例使用了 NATS Node.js V2 SDK,使用了xcc.js简单封装。具体代码可以参见本文档尾部相关链接。

"use strict";

const XCC = require("./xcc");
let nats_url = process.env.NATS_URL;
let service = process.env.XSWITCH_SUBJECT || "cn.xswitch.node.test";
let xctrl_subject = process.env.XCTRL_SUBJECT || "cn.xswitch.ctrl";

const options = {
  nats_url: nats_url,
  log_level: 7,
  service: service,
};

const xcc = new XCC(options); // 新建一个对象

xcc.on("connected", () => {
  console.log("connected"); // 连接后打印日志
});

xcc.on("START", (call) => {
  ivr(call); // 来话处理
});

(async () => {
  await xcc.init(); // 连接到NATS
  xcc.listen(xctrl_subject, "play"); // 等待呼入
})();

async function ivr(call) {
  //呼叫处理
  console.log(
    `>> call from ${call.cid_number} to ${call.dest_number} uuid=${
      call.uuid
    } Total Calls: ${xcc.ncalls()}`
  );
  call.on("DESTROY", (params) => {
    // xcc.log('>> DESTROY', params);
    xcc.log(">> DESTROY", params.uuid, `Total Calls: ${xcc.ncalls()}`);
  });
  await call.answer(); // 应答
  // await call.play('/tmp/test.wav'); // 本地wav文件
  await call.play("https://xswitch.cn/download/wav/xiaoyingtao.wav"); // http文件
  await call.play("silence_stream://1000"); // 1秒静音
  await call.play("silence_stream://2000"); // 2秒
  await call.play("silence_stream://3000"); // 3秒
  await call.hangup("NORMAL_CLEARING"); // 挂机
  console.log(`Done uuid=${call.uuid} Total Calls: ${xcc.ncalls()}`);
}

如果要播放 TTS,则需要设置 TTS 引擎,代码如下:

await call.answer();
call.set_tts_params("ali", "default"); // 设置TTS引擎和语法
await call.speak("您好");

V1 版的 SDK 看起来就比较长了,但也比较直观,简要注释如下,供参考:

const NATS = require("nats"); // 引入NATS
let nats_url = process.env.NATS_URL; // 从环境变量中获取NATS服务器地址
// 连接NATS服务器,默认为`nats://localhost://4222`
const nc = NATS.connect(nats_url);
const tts_engine = "ali"; // 设置TTS引擎,相关模块必须存在
const uuidv4 = require("uuid/v4"); // UUID库
const ctrl_uuid = uuidv4(); // 随机生成一个UUID,作为本控制器的UUID

console.log("IVR started, waiting ...");

function default_rpc() {
  var rpc = {
    // 构造JSON-RPC对象
    jsonrpc: "2.0",
    method: "XNode.Answer",
    id: "fake-uuid-answer",
    params: {
      ctrl_uuid: ctrl_uuid,
    },
  };

  return rpc;
}

// 应答函数,service为XNode侧的Subject,uuid为当前Channel的UUID
function xnode_answer(service, uuid, callback) {
  // 设置应答请求的参数
  var rpc = default_rpc();
  rpc.method = "XNode.Answer";
  rpc.params.uuid = uuid;

  // 转为JSON字符串
  const msg = JSON.stringify(rpc);
  console.log("sending " + msg);
  // 发送请求,超时时间为1000毫秒,然后会执行callback函数
  nc.request(service, msg, { max: 1, timeout: 1000 }, callback);
}

// 播放函数,uuid为当前Channel的UUID,file为语音文件名
function xnode_play(service, uuid, file, callback) {
  var rpc = default_rpc();
  rpc.id = "fake-play";
  rpc.method = "XNode.Play";
  rpc.params.uuid = uuid;
  rpc.params.media = {
    data: file,
  };

  const msg = JSON.stringify(rpc);
  console.log("sending " + msg);
  // 发送请求,简单起见,超时时间硬编码为20秒,可自行调整
  nc.request(service, msg, { max: 1, timeout: 20000 }, callback);
}

// 订阅本Ctrl的Subject,当收到消息时打印相关消息
nc.subscribe(
  "cn.xswitch.ctrl." + ctrl_uuid,
  function (msg, reply, subject, sid) {
    console.log("Received a message: " + subject + " " + msg);
  }
);

// 订阅消息,等待呼入,电话呼入后的第一个消息会发到这里
nc.subscribe(
  "cn.xswitch.ctrl",
  { queue: "controller" },
  function (msg, reply, subject, sid) {
    console.log("Received a message: " + subject + " " + msg);
    // 收到的是一个字符串,转换成Javascript对象
    m = JSON.parse(msg);

    // 简单的合法性检查
    if (m.method) {
      if (m.method == "Event.Channel") {
        if (m.params.state == "START") {
          // 第一个消息的state = START
          // 注意,这个消息中没有`id`,也就是说这只是一个事件通知
          console.log("new call from " + m.params.cid_number); // 打印主叫号码
          const channel_uuid = m.params.uuid; // 这是当前Channel的UUID
          // node_uuid是XNode侧的UUID,拼成一个Subject,XNode侧应该已经订阅了该Subject
          const service = "cn.xswitch.node." + m.params.node_uuid;

          // 应答,它将会发送一个应答指令,这是一个JSON-RPC请求消息
          xnode_answer(service, channel_uuid, (msg) => {
            // 应答请求的回调函数
            if (
              msg instanceof NATS.NatsError &&
              msg.code === NATS.REQ_TIMEOUT
            ) {
              console.log("request timed out");
            } else {
              console.log("Got a response: " + msg);
              m = JSON.parse(msg);
              if (m.result.code == 200) {
                // 200代表请求成功
                var file = "/tmp/welcome.wav"; // 播放文件
                xnode_play(service, channel_uuid, file, (msg) => {
                  if (
                    msg instanceof NATS.NatsError &&
                    msg.code === NATS.REQ_TIMEOUT
                  ) {
                    console.log("request timed out");
                  } else {
                    console.log("Got a response: " + msg);
                  }
                });
              }
            }
          });
        }
      }
    }
  }
);

// 永久等待,防止退出
function wait() {
  // console.log('tick ... ');
  setTimeout(wait, 3000);
}

wait();

Node.js 实际上只有一个线程,所有请求的结果都使用回调函数来实现。Async/Await 的版本可以以同步的方式写异步代码,代码看起来更简单,但其实只是个语法糖,学起来也是有门槛的,需要懂 Promise。

外呼

外呼代码片断如下。

let auto_play = false; // 是否自动播放,见后面
// 设置呼叫字符串,该示例号码会自动接听
const dial_string = "sofia/public/10000200@rts.xswitch.cn:20003;transport=tcp";

function default_rpc() {
  var rpc = {
    jsonrpc: "2.0",
    method: "XNode.Answer",
    id: "fake-uuid-answer",
    params: {
      // 简单起见使用硬编码,实际使用时应该保证与其它Controller不重复,最好是个真正的UUID
      ctrl_uuid: "test-nodejs-controller",
    },
  };

  return rpc;
}

// 播放
function xnode_play(service, uuid, file, callback) {
  var rpc = default_rpc();
  rpc.method = "XNode.Play";
  rpc.id = uuid;
  rpc.params.uuid = uuid;
  rpc.params.media = {
    data: file,
  };

  const msg = JSON.stringify(rpc);
  console.log("sending " + msg);
  nc.request(service, msg, { max: 1, timeout: 5000 }, callback);
}

// 挂机
function xnode_hangup(service, uuid, callback) {
  var rpc = default_rpc();
  rpc.id = "fake-uuid-hangup";
  rpc.method = "XNode.Hangup";
  rpc.params.uuid = uuid;

  const msg = JSON.stringify(rpc);
  console.log("sending " + msg);
  nc.request(service, msg, { max: 1, timeout: 1000 }, callback);
}

console.log("caller started, waiting ...");

// controller handler,与该Controller相关的消息都会发到这里
nc.subscribe(
  "cn.xswitch.ctrl.test-nodejs-controller",
  function (msg, reply, subject, sid) {
    console.log("Received a message: " + subject + " " + msg);
    m = JSON.parse(msg);

    if (m && m.params) {
      if (m.params.state == "DESTROY") {
        running = false;
      } else if (m.params.state == "READY") {
        // 呼叫成功后,收到的第一个消息
        if (auto_play) {
          // 如果使用auto_play,这里我们什么也不需要做
          // auto_play mode, just waiting for hangup
          console.log("call is auto playing, waitinng to hangup");
          return;
        }

        // 根据node_uuid构造XNode侧的Subject
        var service = "cn.xswitch.node." + m.params.node_uuid;
        const channel_uuid = m.params.uuid;
        var file = "silence_stream://3000";
        xnode_play(service, channel_uuid, file, (msg) => {
          // 播放文件
          if (msg instanceof NATS.NatsError && msg.code === NATS.REQ_TIMEOUT) {
            console.log("request timed out");
          } else {
            console.log("Got a response: " + msg);
          }
        });
      }
    }
  }
);

// 每一路电话都有一个UUID,这里我们事先构造一个
const uuid = uuidv4();

console.log("calling " + uuid + " ...");

var rpc = default_rpc();
rpc.method = "XNode.Dial"; // 外呼
rpc.id = "call1"; // 请求id,实际使用时应该使用不重复的id以方便跟踪
rpc.params.destination = {
  global_params: {
    ignore_early_media: "true", // 忽略早期媒体(如回铃音、彩铃等)
  },
  call_params: [
    {
      // 这是个数组,数组中的每一个对象代码一路,可以并行呼叫,这里我们只呼一路
      uuid: uuid, // Channel UUID
      dial_string: dial_string, // 呼叫字符串
      params: {
        // 呼叫参数
        leg_timeout: "20", // 无应答超时
        // absolute_codec_string: "G729",  // 使用何种编解码
      },
    },
  ],
};

if (auto_play) {
  // 在auto_play的情况下,呼通后会自动执行这些app,而无须本侧再控制,比较简单
  rpc.params.apps = [
    // 播放一次铃声
    { app: "playback", data: "tone_stream://%(100,1000,800);loops=1" },
    // 播放三次铃声
    { app: "loop_playback", data: "+3 tone_stream://%(100,1000,800);loops=1" },
    // 挂机
    { app: "hangup", data: "NORMAL_CLEARING" },
  ];
}

// XNode侧订阅的Subject
service = "cn.xswitch.node.test";

// 发送呼叫请求,呼叫进展消息(振铃、应答等)将可在cn.xswitch.ctrl.test-nodejs-controller这个主题上接收
nc.request(service, JSON.stringify(rpc), { max: 1, timeout: 30000 }, (msg) => {
  if (msg instanceof NATS.NatsError && msg.code === NATS.REQ_TIMEOUT) {
    console.log("request timed out");
  } else {
    console.log("Got a response: " + msg);
    m = JSON.parse(msg);
    if (m.result && m.result.code == 200) {
      // good
      console.log("so far so good");
    }
  }
});

FreeSWITCH 及 ESL 开发者指南

如果你已经熟悉 FreeSWITCH 和 ESL,也可以直接使用 XCC API 提供的NativeAPINativeApp进行开发。当然,如果你不熟悉,也可以直接忽略本节的内容。

XCC API 可以完全替代 ESL,也可以支持 ESL 中的 inbound 和 outbound 模式。本质上,ESL 主要有三种概念和操作:

  • API:底层使用api命令实现,用于控制 FreeSWITCH,向 FreeSWITCH 发送指令
  • App:底层使用sendmsg实现,用于执行 FreeSWITCH 中的 Application
  • Event:事件订阅,以便实时知道 FreeSWITCH 中发生了什么

Event

事件订阅可以直接使用 NATS 提供的subscribe功能实现,可以订阅 FreeSWITCH 中原生的事件,如:cn.xswitch.ctrl.event.CHANNEL_ANSWER,或直接订阅所有原生事件:cn.xswitch.ctrl.event.>。事件的 Subject 前缀可以在配置文件中通过publish-events-subject-prefix指定,所需的事件类型可以在bindings部分配置,这些都需要在 XSwitch 侧的xcc.conf配置文件中指定,详见配置文件

原生事件以 JSON-RPC 格式发出,可以在各种语言中很简单的解析其中的内容。

inbound 模式

在 FreeSWITCH 中,inbound 和 outbound 都是相对 FreeSWITCH 而言的。前者相当于 Ctrl 侧连接到 FreeSWITCH 上主动控制它。在 XCC 中,实际上 XNode 和 XCtrl 双方都是连接到 NATS 上,因而可以互相收发消息。

Ctrl 侧可以使用使用NativeAPI发送 API 命令,查询系统状态或发起通话,如:

{
	"jsonrpc": "2.0",
	"id": "0",
	"method": "XNode.NativeAPI",
	"params": {
		"ctrl_uuid": "ctrl_uuid",
		"cmd": "status"
	}
}

{
	"jsonrpc": "2.0",
	"id": "0",
	"method": "XNode.NativeAPI",
	"params": {
		"ctrl_uuid": "ctrl_uuid",
		"cmd": "originate",
		"args": "user/1000 &echo"
	}
}

当 FreeSWITCH 侧有来话时,可以通过 Dialplan 查询请求 Ctrl 侧动态生成 Dialplan,或者,可以简单地直接执行park Application,Ctrl 侧就可以收到CHANNEL_PARK事件,进而可以使用NativeApp进行应答,放音等处理,如:

{
	"jsonrpc": "2.0",
	"id": "0",
	"method": "XNode.NativeApp",
	"params": {
		"ctrl_uuid": "ctrl_uuid",
		"uuid": "channel_uuid",
		"cmd": "answer"
	}
}

{
	"jsonrpc": "2.0",
	"id": "0",
	"method": "XNode.NativeApp",
	"params": {
		"ctrl_uuid": "ctrl_uuid",
		"uuid": "channel_uuid",
		"cmd": "playback",
		"args": "/tmp/welcome.wav"
	}
}

outbound 模式

实际上,上一节最后一个示例也相当于 outbound 模式,只是由于 XNode 和 XCtrl 之间的连接是“天然”的,因而 XNode 无需像 ESL 中那样再初始化一个连接。一旦连接建立,后续的控制流程都是一样的。

当然,XCC API 也专门设计了一个xcc Application,类似于 outbound 模式与 Ctrl 侧连立一个“虚”连接,这在 XNode 与 XCtrl 多对多的情况下非常有用,这不属于原生的 FreeSWITCH 功能,但更强大,下面我们来做一个对比:

在 ESL 中,来话的 Channel 通过socket Application 可以初始化一个 outbound 连接到你自己实现的 ESL Server,你的 ESL Server 检测到连接到来时在底层回复一个“connect\n\n”来确认接收这个请求,然后 FreeSWITCH 才开始发送 Channel Data 事件以启动后续的交互。

在而 XCC 中,xcc也是一个 Application,它向 NATS 广播一个Event.Channel事件,所有订阅并收到这个事件的 Ctrl 都可以竞争接管这路呼叫,接管可以通过AcceptAnswer实现。

所以,XCC API 提供的NativeAPINativeApp完全可以代替原来 ESL 的功能,其它的 XCC API 功能更强大,且有更多的保护,推荐尽量使用非 Native 的 XCC API。

track 模式

有时候,在使用 ESL 或原生 Dialplan 的场景下,也希望接收 XCC 的Event.Channel消息,这时候可以使用xcc_track Application 实现。使用方法:

xcc_track <cn.xswitch.ctrl.$ctrl_uuid>
answer
playback ...

XCC 消息会发到对应的ctrl_uuid上,当且仅当在这种情况下,Event.Channel消息的state = TRACK

注意,该模式主要用于获取事件模式。在这种状态下,XCC API 可能不能正常工作,因此,不建议使用 XCC API 控制 Channel。

已知可以工作的 XCC API 如下:

  • DetectSpeech:仅支持异步模式,即id为空,使用publish而非request请求的模式,结果会发送到对应的ctrl_uuid上。
  • SetVar:设置通道变量。
  • GetVar:获取通道变量。
  • Hangup:挂机。

如果取消控制,可以使用xcc_untrack Application。

参见示例:https://git.xswitch.cn/xswitch/xcc-examples/src/branch/master/python#关于-asr-event-py

设计架构及交互流程