
本教程详细指导如何利用Twilio构建一个匿名电话呼叫转接系统,并实现未接来电自动转语音留言功能。当客户拨打匿名号码,呼叫被转接至用户真实号码后,若用户未接听,系统将引导客户录制语音留言。文章将涵盖Twilio TwiML的Dial动词超时配置、Record动词的使用,以及如何处理录音回调、存储留言并进行语音转文本处理及邮件通知。
1. 引言
在构建基于Twilio的通信应用时,实现匿名呼叫转接是常见的需求。例如,当客户拨打一个由平台提供的匿名号码时,该呼叫会被转接到用户真实的手机号码。然而,如果用户因各种原因(如忙线、未接听、无法接通)未能接听电话,如何确保客户能够留下重要的信息?本教程将详细介绍如何利用Twilio的TwiML(Twilio Markup Language)功能,为未接来电自动启用语音留言系统,并将留言内容(包括语音文件和转录文本)通过邮件发送给用户。
2. 理解Twilio TwiML与呼叫流程
Twilio TwiML是一种xml方言,用于指示Twilio如何处理呼叫和短信。通过在您的服务器上响应Twilio Webhook请求时返回TwiML指令,您可以完全控制呼叫流程。
实现匿名呼叫转接和语音留言的理想流程如下:
- 客户拨打匿名号码: 客户拨打由Twilio分配给用户的匿名号码。
- Twilio发送Webhook请求: Twilio向您预先配置的Webhook URL发送http请求,告知有新的来电。
- 应用处理请求: 您的应用程序接收请求,识别匿名号码对应的用户及其真实号码。
- 呼叫转接: 应用程序生成包含Dial(拨号)动词的TwiML响应,尝试将呼叫转接到用户的真实号码。
- 超时或未接听处理: 如果用户在预设时间内未接听,或者电话忙线/无法接通,Dial动词将超时。
- 语音留言引导: 应用程序在Dial动词之后,通过Say(说话)和Record(录音)动词引导客户录制语音留言。
- 录音完成回调: 客户完成录音后,Twilio将录音文件存储起来,并向您配置的recordingStatusCallback URL发送另一个Webhook请求。
- 存储与通知: 您的应用程序接收录音回调,将录音信息(如URL)存储到数据库,并利用Twilio的语音转文本API(或已转录文本)通过邮件通知用户。
3. 实现呼叫转接与语音留言逻辑
我们将基于一个现有的node.js express应用示例进行修改,重点关注webhook/voice端点和新增的webhook/voicemail-callback端点。
3.1 配置呼叫转接与超时处理
Dial动词是Twilio TwiML中用于将当前呼叫连接到另一个号码的关键。其timeout属性允许您指定Twilio在放弃尝试连接被叫方之前等待的最大秒数。关键在于,如果Dial动词在未指定action属性的情况下超时或失败,Twilio会继续执行其TwiML响应中的下一个指令。这正是我们实现语音留言的入口点。
示例代码:修改 /webhook/voice 端点
const twilio = require("twilio"); const express = require("express"); const router = express.Router(); // 假设这些是您的数据库操作和邮件发送函数 // const { getNumberWithoutUser, updateQuota } = require("../db/dbOperations"); // const { sendMessageNotificationEmail } = require("../emailing/email"); // const { appendCall } = require("../db/callsCollectionUtils"); // 模拟数据库操作和邮件发送函数 async function getNumberWithoutUser(maskedNumber) { // 模拟从数据库获取号码信息 if (maskedNumber === "+1234567890") { // 假设这是匿名号码 return [{ _id: "user123", numbers: { subscriptions: [{ active: true, type: "premium" }], settings: { forwarding: { toPrimaryPhone: true, primaryPhoneNumber: "+1987654321" }, // 真实号码 emailForVoicemail: "user@example.com" // 接收语音留言的邮箱 } } }]; } return [null]; } async function updateQuota(userId, maskedNumber, type, subscriptionType) { console.log(`Updating quota for user ${userId} for masked number ${maskedNumber}, type: ${type}, subscription: ${subscriptionType}`); // 实际的配额更新逻辑 } async function appendCall(userId, to, from, callDetails) { console.log(`Appending call record for user ${userId}: From ${from} to ${to}`); // 实际的通话记录存储逻辑 } async function sendMessageNotificationEmail(toEmail, subject, body) { console.log(`Sending email to ${toEmail} with subject: "${subject}"`); // 实际的邮件发送逻辑 } router.post("/webhook/voice", async (req, res) => { const { To, From, CallStatus } = req.body; console.log(`Incoming call status: ${CallStatus}, From: ${From}, To: ${To}`); const [numbers] = await getNumberWithoutUser(To); if (!numbers) { console.warn(`User does not own this number: ${To}`); return res.status(400).send("User does not own this number"); } const isToPrimaryPhone = numbers?.numbers?.settings?.forwarding?.toPrimaryPhone; const primaryPhoneNumber = numbers?.numbers?.settings?.forwarding?.primaryPhoneNumber; if (isToPrimaryPhone && primaryPhoneNumber) { const twiml = new twilio.twiml.VoiceResponse(); if (CallStatus === "ringing") { // 播放欢迎语(可选),使用中文女声 twiml.say({ voice: 'woman', language: 'zh-CN' }, "您好,正在为您转接,请稍候。"); // 尝试拨打用户真实号码,设置7