在实现了使用VOIP客户端拨打8000号码后,播报语音验证码的功能后,我发现了一个最大的缺点,就是这需要用户主动去操作。这对于想使用API集成无法实现。

在思考之后,我决定使用一个可以调用API就呼叫VOIP客户端,当用户接通后,播报语音验证码的功能。当实现这个功能后,它的好处是显而易见的。比如,可以集成到嵌入式,集成到第三方网站。

那么该如何实现它呢?

一、 系统原理

传统的拨号方案(Dialplan)是静态的,而 ARI 允许我们动态控制。整个“API 触发呼叫并播报”的流程如下:

  • 触发阶段:第三方系统通过 API 向 Go 服务发送呼叫请求(包含目标 ID 和验证码)。
  • 呼叫发起(Originate):Go 服务调用 Asterisk ARI 的 /channels 接口。此时 Asterisk 会尝试向 PJSIP 终端(或通过中继向手机)发起呼叫。
  • 接通监听(Stasis Start):一旦用户接起电话,该通道会被移交给一个名为 Stasis 的应用。此时 Go 服务会收到一个“通道已接通”的 WebSocket 事件。
  • 语音合成与播放:Go 服务识别到接通后,调用播报指令(可以播放预录音文件,或对接 TTS 引擎生成的语音流)。
  • 挂断处理:播报完毕后,服务发送挂断指令,释放资源。

二、系统架构

[第三方API] --> [Go 后端服务] --(REST API)--> [Asterisk ARI]
                      |                             |
                 (WebSocket)                    (PJSIP/IMS)
                      |                             |
                 [接通状态回调]     <---         [用户终端接听]

三、 核心代码实现 (Golang)

假设你使用了 GitHub 上的 go-ari 库。

1. 初始化 ARI 客户端

import (
    "github.com/v5"
    "github.com/v5/client/native"
)

// 连接到 Asterisk ARI
cl, err := native.Connect(&native.Options{
    Application:  "voice-verify", // 必须与 asterisk.conf 配置一致
    Username:     "admin",
    Password:     "password",
    URL:          "http://localhost:8088/ari",
})

2. 实现呼叫并播报逻辑

这是核心逻辑:接收参数 -> 发起呼叫 -> 监听接通 -> 播放语音。

a. 接收参数

// 呼叫
	router.POST("/call", func(c *gin.Context) {
		var req arimanager.CallRequest
		if err := c.ShouldBindJSON(&req); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}

		callID, err := ariManager.ExecuteCall(req)
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}

		c.JSON(http.StatusAccepted, gin.H{
			"callId": callID,
			"status": "queued",
		})

	})

当收到参数时,就进行内部呼叫,并返回第三方,目前呼叫已经进入队列。

b. 发起呼叫

func (m *ARIManager) makeARICall(target string, callType CallType, payload map[string]string) (string, error) {
	// 构建变量映射
	variables := map[string]string{
		"CALL_ID":   target, // 自定义变量
		"CALL_TYPE": string(callType),
	}

	// 添加 payload 到变量
	for key, value := range payload {
		variables[fmt.Sprintf("CALL_%s", strings.ToUpper(key))] = value
	}

	// 发起 ARI 呼叫
	channel, err := m.client.Channel().Originate(nil, ari.OriginateRequest{
		Endpoint:  fmt.Sprintf("PJSIP/%s", target),
		App:       m.client.ApplicationName(),
		Extension: "s",
		Context:   "from-ari",
		Timeout:   30, // 超时时间(秒)
		CallerID:  fmt.Sprintf("\"Call System\" <%s>", m.client.ApplicationName()),
		Variables: variables,
	})

	if err != nil {
		return "", fmt.Errorf("ARI originate failed: %v", err)
	}

	return channel.ID(), nil
}

这里执行真正的呼叫,它会查找Context上下文,并进入到对应拨号计划中,将上面的信息塞入通道。

c. 监听接通

func (m *ARIManager) handleVerification(ctx context.Context, h *ari.ChannelHandle) {
	if err := h.Answer(); err != nil {
		shared.Log.Error("failed to answer verification call", "error", err)
		return
	}
	// 从变量获取验证码
	code, _ := h.GetVariable("CALL_CODE")

	// 播放验证码
	if err := m.playVerificationCode(ctx, h, code); err != nil {
		shared.Log.Error("failed to play verification code", "error", err)
		return
	}

	shared.Log.Info("verification playback completed", "code", code)
}

当在应用中收到事件后,马上进行状态,等待用户接听,用户接听之后,从通道中获取验证码,然后开始调用播放方法。

d. 播放语音

func (m *ARIManager) playVerificationCode(ctx context.Context, h *ari.ChannelHandle, code string) error {
	if err := play.Play(ctx, h, play.URI("sound:beep")).Err(); err != nil {
		return err
	}
	// 播放欢迎提示
	if err := play.Play(ctx, h, play.URI("sound:vcode/code")).Err(); err != nil {
		return err
	}

	// 逐位播放数字
	for _, digit := range code {
		digitSound := fmt.Sprintf("sound:vcode/%c", digit)
		if err := play.Play(ctx, h, play.URI(digitSound)).Err(); err != nil {
			return err
		}
		// 数字间短暂暂停
		// time.Sleep(100 * time.Millisecond)
	}

	// 播放结束提示
	if err := play.Play(ctx, h, play.URI("sound:vcode/thanks")).Err(); err != nil {
		return err
	}

	return nil
}

这是真正播放验证码的地方,它调用系统的play函数将音频流传输给用户。

四、 关键配置 (Asterisk 端)

为了让 Go 代码接管通话,你需要配置 ari.conf 开启接口,并在拨号方案中定义应用。

1. http.conf

[general]
enabled=yes
bindaddr=127.0.0.1
bindport=8088

2. ari.conf

[general]
enabled = yes
pretty = yes

[admin]
type = user
read_only = no
password = password

3. extensions.conf

虽然是 API 触发,但涉及到内部呼转,还需要配置拨号计划,需要从通道中获取值:

[from-ari]
exten => _X.,1,NoOp(External API Call)
 same => n,Stasis(voice-verify) ; 进入 Go 监听的应用名
 same => n,Hangup()

当没有对应的拨号计划时,就转到这个应用。

五、 这种方式的优势

  • 无缝集成:通过标准 HTTP RESTful API,嵌入式设备(ESP8266/ESP32)只需发送一个简单的 POST 请求给 Go 后端,或者接收到MQTT某的指令,Go调用POST请求,就能让指定终端响起电话。
  • 高到达率:语音验证码比短信更难被拦截软件屏蔽,且自带“强提醒”属性(响铃)。
  • 动态可扩展:
  1. 本地测试:呼叫 PJSIP/8000 等 VoIP 软电话。
  2. 生产环境:对接运营商的 SIP 中继 (SIP Trunk),呼叫地址改为 PJSIP/手机号@运营商网关,即可实现全国手机拨打。
  • 成本控制:使用开源的 Asterisk,只需支付运营商的分钟数费用,无需支付昂贵的第三方聚合服务费。

六、 总结

通过 Go + ARI + Asterisk 的组合,成功将传统的 VoIP 通信转换为了 通信能力平台 (CPaaS)。这种方案不仅解决了用户主动操作的痛点,更为后续的自动化预警(如嵌入式传感器触发报警电话)打下了坚实的技术基础。