V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
GreatHumorist
V2EX  ›  分享发现

小罗和小姐姐的故事:到底是谁拨通的电话?

  •  
  •   GreatHumorist · 2020-06-25 12:30:01 +08:00 · 2184 次点击
    这是一个创建于 1401 天前的主题,其中的信息可能已经有所发展或是发生改变。

      故事的开始是这样的,小罗每天写 Bug 写得天昏地暗,海枯石烂,但是接二连三的神秘电话不仅打断了他的思路,还让他开始怀疑人生。

      电话铃响,有社交恐惧症的小罗内心一哆嗦,真想砸了手机,心想为什么不能通过文字聊天来沟通呢。可是没办法,电话还是得接,因为万一是线上紧急 Bug 呢。于是小罗滑动手机接通了电话,他“喂”了一下,没听到任何答复,反而是“嘟......嘟......嘟......”的自己拨打电话的等待音,接着对面接起了电话,是一个小姐姐!是一个声音甜美的小姐姐!她开始了向小罗的各种关心,小罗掏了掏自己的口袋,礼貌的回绝了电话那头的关心。

      挂了电话,小罗心想,这不是别人打给我的吗,怎么刚刚像是我打出去的电话呢?看了看眼前还没写完的 Bug,小罗也没多想,继续埋头啪啪啪的敲打键盘。过了一会儿,电话铃声又响起来了,小罗再次不情愿的接起了电话,同样的“嘟......嘟......嘟......”等待声,又一个小姐姐接起了电话。接下来的几天,小罗总是接到这样的电话,不由得让他开始怀疑,明明是小姐姐打过来的电话,怎么像是自己打出去的呢?

      为了证明不是自己写 Bug 写得精神错乱了或者手机出问题了,小罗做了两个实验,第一个实验是换了一个备用老人机,只有传统的拨打电话发送短信的功能,可是问题依旧,还是能接到各种像是自己拨打出去寻找关心的电话。于是小罗做了第二个实验,用另一部手机摄像,看到底是不是自己精神错乱的情况下主动打出去的电话。翻看了几个监控片段后,小罗发现确实不是自己主动打出去的。

      那到底是怎么回事儿呢?为什么会有自己拨打出去的感觉呢?难道是运营商从中间搞鬼?可是不应该啊,运营商应该不会做这样的事儿。小罗于是对着手机重新录像,同时用两部手机互拨,在仔细的分析了几种情况下的通话过程后,他发现一个很重要的点——那就是如果是自己拨打出去的话,”嘟“声等待音时,手机显示的是”正在呼叫中...“,而如果是那些小姐姐的电话的”嘟“声等待音,则电话显示的是具体的通话时间,也就是说,这时的”嘟“声是双方通话已经建立了,这个”嘟“声是对面播放给小罗听的,让小罗误以为是自己拨打出去的电话。

      当然,这也仅是当前的猜想,还不知道实际能否进行这样的操作,不过这激起了小罗的好奇心,作为一个工程师,他很喜欢去探索这些现象背后的技术和原理。于是小罗搜了搜通信相关的文档,大致了解了通话的过程,也意外知道了原来电话还可以通过一种叫做 VOIP ( Voice over Internet Protocol,即基于 IP 的语音传输)的技术来拨打,这当中有一款很出名的基于软交换技术的软件叫做 FreeSwitch 。

      小罗看了看 FreeSwitch 的介绍,发现只需要简单的配置就能实现很多很厉害的通话功能。于是他先按着官方的教程,在自己的电脑上安装了 FreeSwitch 的服务端,然后这个软件可以通过一种叫做 SIP ( Session Initiation Protocol,即会话初始协议)通信协议来进行通信,只需要在电脑或者手机上下载对应的 SIP 客户端就可以了。

      小罗在电脑和手机上分别装上 SIP 客户端后,按照软件默认分配的号码,配置了相关参数登录进去,然后在手机上输入电脑的号码,果然听到了电脑响起了呼叫声,接通后手机和电脑就可以正常通话了。小罗仔细一想,这玩意儿不是微信语音就可以实现吗,感觉没啥用啊,别人是给我的真实的手机号码打过来的电话啊。

      小罗继续翻看文档,原来刚刚这个只是内部通话,想要和外部通话需要接入 SIP 中继或者 VOIP 网关、GOIP 网关等进行落地,这样才能拨通真实的号码。小罗上淘宝搜了搜 GOIP 的网关,这是一种能插 SIM 卡的将 GSM 通话转换成 VOIP 协议的设备,发现费用都好贵,于是他转而求其次,去找 SIP 中继(服务商提供线路,只需注册到对应服务商即可进行落地),通过不断搜索终于找到一家国外的 SIP 中继服务商,注册上账号并获取到了对应的中继线路,然后在自己的 FreeSwitch 服务器上配置好对应的中继网关。

      重新加载配置后,小罗在电脑上的 SIP 客户端输入了自己的手机号拨通,电话那头一个女士在说着英文,小罗想自己的电话也没响啊,难道是打错了?或者是因为国外的中继需要加国别码吗?小罗仔细听了下声音,原来说的是号码无法到达,难道中继不行吗?他上服务器看了看日志,原来是因为拨号计划的路由并没有命中,在 FreeSwitch 中,需要设置拨号计划,这是一个类似于路由表的设置,他会决定你的号码最终需要进行哪些操作。这些操作包括了播放声音,桥接电话,IVR 菜单等。

      小罗配置了一个新的拨号计划,规则是以 9 位前缀的后面跟了 11 位数字的号码会使用配置的 SIP 中继网关来桥接,重新加载配置后,再次在电脑端拨打,手机这时正常铃响,接通后可以正常的通话。小罗这可高兴坏了,这样就可以随时随地通过这个客户端给任何地方的人打电话了,因为按照这个 SIP 中继服务商的说法是最终电话都是通过本地号码拨打出去的,也就是会省很大一笔的国际长途费用。

      但是想要复现那些让自己糊涂的电话应该怎么办呢?貌似现在只能通过手动拨打的形式啊。小罗接着看文档,发现 FreeSwitch 还可以通过命令行的形式来控制拨打电话,只需要执行 originate number_a &bridge(number_b) 即可,也就是把 number_a 设置为自己的手机号,number_b 是 FreeSwitch 内部的号码,执行这个命令,就会先拨通自己的手机号,然后再拨通电脑端的内部号码,实现通话。如果还想实现那种”嘟“声等待音的话,则稍微复杂点,需要先拨通 a,给他播放声音,然后拨通 b,最后桥接 a 和 b,如下:

    // 拨通 a,并给他一直播放等待音
    originate number_a &endless_playback(wait.wav)
    // 拨通 b
    originate number_b &park
    // 查看当前的通道
    show channels
    // 把 a 的通道和 b 的通道桥接,双方就可以通话了
    uuid_bridge uuid_a uuid_b
    

      就这样,通过几行简单的命令就实现了让小罗糊涂的通话,小罗也明白了其中的原理。不过,小罗又想到,小姐姐不可能一直这样手动的输入命令吧?那样得多麻烦,而且如果需要关心的对象比较多,工作量反而不是更大吗?有没有其他的方式可以简单的自动的进行呢?

      答案是当然有啦,FreeSwitch 为我们提供了各式各样的接入控制的接口,比如我们在前面输入的命令其实就是通过他的 ESL ( Event Socket Library )接口来实现的,我们也可以通过这个接口来监听事件,执行命令。ESL 提供了 Java, Python, PHP, Go 等库可以调用。本着 PHP 是世界上最好的语言的原则,小罗选择了 Go 的库(主要是因为 PHP 的库很久不更新,而且有很多问题)。

      基本的库和技术原理知道了,小罗得明白这个业务场景是怎么样的,这样才能开发出对应的程序来。小罗假定这些关心他的都是可爱的小姐姐们,这些小姐姐们每天要关心很多小罗们,小姐姐们总不能登录到服务器上去一个一个执行命令输入小罗们的手机号吧,所以应该是有一个程序,它能输入一堆的小罗们的手机号,然后尝试去拨通,拨通后给小罗们播放等待音,然后选择一个有空的小姐姐,把有空的小姐姐和拨通的小罗桥接上,这样小罗就能开心的接收小姐姐的关心了。

      以下是简单的实现代码,请注意这里只接入了一个小姐姐,同时没做错误处理等,所以如果出现丢失消息可能会阻塞后面的流程,或者桥接错误,只供大概了解运行过程,请勿用于生产环境。

    package main
    
    import (
       "errors"
       "fmt"
       "github.com/0x19/goesl"
       "strings"
    )
    
    var littleSister = "1000"
    var littleLuos = []string{"number_1", "number2", "number_3",}
    var littleLuoIndex = 0
    var client *goesl.Client
    var stage = 0
    var uuidLittleLuo = ""
    var uuidLittleSister = ""
    
    func main() {
       var err error
       // 初始化客户端
       client, err = goesl.NewClient("127.0.0.1", 8021, "123456", 10)
       if err != nil {
          goesl.Error("Error while creating new client: %s", err)
          return
       }
       goesl.Debug("New client: %q", client)
       // 开启协程接收消息
       go client.Handle()
       // 通知服务器接收所有事件
       client.Send("events json ALL")
       for {
          msg, err := client.ReadMessage()
          if err != nil {
             if !strings.Contains(err.Error(), "EOF") && err.Error() != "unexpected end of JSON input" {
                goesl.Error("Error while reading Freeswitch message: %s", err)
             }
             break
          }
          eventName := msg.GetHeader("Event-Name")
          // 分阶段执行
          switch stage {
          case 0:
             // 给小罗创建一个通道标识
             createUUID()
          case 1:
             // 如果是异步任务消息,且命令是是创建 uuid 的话
             if eventName == "BACKGROUND_JOB" && msg.GetHeader("Job-Command") == "create_uuid" {
                // 打给小罗,并播放等待音
                err = callLittleLuo(msg)
                if err != nil {
                   goesl.Error(err.Error())
                   break
                }
             }
          case 2:
             // 如果是通道状态信息,并且通道状态为执行,即拨打小罗
             if eventName == "CHANNEL_STATE" && msg.GetHeader("Channel-State") == "CS_EXECUTE" {
                // 给小姐姐创建一个通道标识
                createUUID()
             }
          case 3:
             // 如果是异步任务消息,且命令是是创建 uuid 的话
             if eventName == "BACKGROUND_JOB" && msg.GetHeader("Job-Command") == "create_uuid" {
                // 打给小姐姐
                err = callLittleSister(msg)
                if err != nil {
                   goesl.Error(err.Error())
                   break
                }
             }
          case 4:
             // 如果是通道状态信息,并且通道状态为执行,即拨打小姐姐
             if eventName == "CHANNEL_STATE" && msg.GetHeader("Channel-State") == "CS_EXECUTE" {
                // 桥接小罗和小姐姐的通话
                bridgeLuoAndSister()
             }
          case 5:
             // 如果是通道状态信息,并且通道状态为销毁
             if eventName == "CHANNEL_STATE" && msg.GetHeader("Channel-State") == "CS_DESTROY" {
                // 阶段重置,继续关心下一个小罗
                stage = 0
             }
          }
       }
    }
    
    func createUUID() {
       goesl.Debug(fmt.Sprintf("++++++ Stage %d ++++++++++", stage))
       // 生成 uuid,便于桥接
       client.BgApi("create_uuid")
       stage++
    }
    
    func getOneLittleLuo() (string, error) {
       if littleLuoIndex >= len(littleLuos) {
          return "", errors.New("没有更多的小罗可以关怀了")
       }
       var littleLuo = littleLuos[littleLuoIndex]
       littleLuoIndex++
       return littleLuo, nil
    }
    
    func callLittleLuo(msg *goesl.Message) error {
       goesl.Debug("++++++ Stage 1 ++++++++++")
       // 从返回的消息中获取到生成的 uuid
       uuidLittleLuo = strings.TrimSpace(string(msg.Body))
       // 找一个没有关心过的小罗
       littleLuo, err := getOneLittleLuo()
       if err != nil {
          return err
       }
       // 生成对应的网关路由
       profile := fmt.Sprintf("sofia/gateway/XXSipGate/9%s", littleLuo)
       // 生成拨打命令,拨通后会播放等待音
       command := fmt.Sprintf("originate {ignore_early_media=true,originate_timeout=60,hangup_after_bridge=false,origination_uuid=%s}%s &endless_playback(wait.wav)", uuidLittleLuo, profile)
       // 异步执行命令
       err = client.BgApi(command)
       if err != nil {
          retErr := errors.New(fmt.Sprintf("关心小罗%s 的时候发生了错误:%v", littleLuo, err))
          return retErr
       }
       stage++
       return nil
    }
    
    func callLittleSister(msg *goesl.Message) error {
       goesl.Debug("++++++ Stage 3 ++++++++++")
       uuidLittleSister = strings.TrimSpace(string(msg.Body))
       err := client.BgApi(fmt.Sprintf("originate {ignore_early_media=true,call_timeout=60,hangup_after_bridge=false,origination_uuid=%s,origination_caller_id_number=%s,origination_caller_id_name=%s}user/%s &park()", uuidLittleSister, littleSister, littleSister, littleSister))
       if err != nil {
          retErr := errors.New(fmt.Sprintf("拨打小姐姐的时候发生了错误:%v", err))
          return retErr
       }
       stage++
       return nil
    }
    
    func bridgeLuoAndSister() {
       goesl.Debug("++++++ Stage 4 ++++++++++")
       _ = client.BgApi(fmt.Sprintf("uuid_bridge %s %s", uuidLittleLuo, uuidLittleSister))
       stage++
    }
    

      通过以上代码,小姐姐就能边嗑瓜子边等小罗自动求关心,再也不用疯狂的输入手机号码了,小罗也能自动被无数小姐姐们关心了。

      当然,基于这种技术,小罗想到了,要是能接入语音识别和语音合成技术,对接上业务,可能能为更多的小罗提供更个性化的关心业务,小罗们也许并不会知道,电话另一端口口声声关心他的,或许只是一行行冷冰冰的代码支起的滚烫烫的心~

      本文故事纯属虚构,仅为通俗易懂讲解通过 FreeSwitch 实现自动拨号这一技术,如果你想了解更多关于 FreeSwitch 或者其他方面的技术,追更小罗和小姐姐的故事,请评论点赞让小罗知道~

    6 条回复    2023-02-27 15:05:54 +08:00
    maoxs2
        1
    maoxs2  
       2020-06-25 15:47:35 +08:00 via Android
    我的小爱天天自动接起来尬聊半天的就是这玩意打的电话?
    GreatHumorist
        2
    GreatHumorist  
    OP
       2020-06-25 15:48:31 +08:00
    @maoxs2 大部分都是通过 FreeSwitch 实现的
    mcone
        3
    mcone  
       2020-06-25 15:55:03 +08:00
    @maoxs2 小爱接起来还能尬聊半天的,对方十有八九也不是真人小姐姐,也是类似于小爱的脚本……
    GreatHumorist
        4
    GreatHumorist  
    OP
       2020-06-25 17:02:06 +08:00
    @mcone 小爱这种还不知道怎么实现的,手机端通话过程中貌似不给语音权限的
    GreatHumorist
        5
    GreatHumorist  
    OP
       2020-06-26 10:27:48 +08:00
    是文章太长了吗[手动狗头],怎么大家都只收藏没有互动呢
    alexfarm
        6
    alexfarm  
       2023-02-27 15:05:54 +08:00
    写得真不错
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   2472 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 26ms · UTC 01:10 · PVG 09:10 · LAX 18:10 · JFK 21:10
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.