LoginSignup
7
8

More than 3 years have passed since last update.

[Go][Twilio] Go + gin + Twilio で留守電システムを構築

Last updated at Posted at 2020-12-02

本記事は Go3 Advent Calendar 2020 3日目の記事になります。

Advent Calendarに初めて寄稿(参加?)したのですが、いつも通りのギリギリ体質のせいでコードの準備と炎上案件が重なって睡眠時間がえらいことになりました。
はい、私の話は良いので、早速本題です。

はじめに

5年ほど前に、PHPを用いてTwilioで留守電システムを構築する話を書きました。
私の自作アプリの中で、PHPを本運用稼動させているシステム最後の生き残りとなっていました。
正直、このアプリのためだけにPHP-FPM環境を運用するのも面倒になってきていたので、更改しようかな、と。

そこで白羽の矢が立ったのがGo言語です。
私は昔からPyhtonが好きなのですが、Pyhtonと似たような気軽さで書けること、型推論付き静的型付け言語、それなりのスピードが出るところが気に入っています。

Webフレームワークは以前から使っている gin を使用します。
net/http を生で使ったほうが良いのでしょうが、Web周りをガリガリコーディングするのは面倒なので、軽量Webフレームワークを使用します。

このアプリは電話番号の保管等は行わないお手軽アプリですので、DBは使用しません。

筆者環境

  • OS: Arch Linux
  • Go: 1.15.5 linux/amd64
  • Editor: Eclipse Theia(Google Cloud Shell Editor Preview) / vim
  • パッケージ管理: Go Modules
  • サービス管理: systemd 246(246.6-1-arch)
  • ビルドツール: Gnu Make

最近、Google Cloud Shell Editorにハマっています。
しばらくは無料のようですし、クラウドIDEの実力を皆さんも体感したほうが良いと思います(押し売り)。

おしながき

1. これは何?

TwilioのIncoming Callを利用して自動応答後、架電側の音声メッセージを録音し、Twilioで記録します。
その後、録音された音声メッセージが記録されているTwilioのURLをメッセージとして受け取るシステムです。

準備するもの

  • 架電用電話
  • 受電用電話(転送電話の設定ができること)
  • Linuxサーバ or Heroku等のPaaS
  • Twilioアカウント
  • Twilio電話番号(日本の050のものでOK)

※Twilioアカウントの準備、設定等の解説は省略します。

そもそもTwilioとは?

Twilioの勉強でページを開いてくださった方は、Twilioをご存知かと思いますが、Go言語の勉強でページを開いてくださった方のために簡単に解説をば。

Twilioは電話やSMS・ビデオ・チャット・FAX・SNSなど世の中にある様々なコミュニケーションチャネルをWeb・モバイルアプリケーションとつなぐ「クラウドコミュニケーションAPI」です。

公式サイトの紹介より

従来、電話やSMSには専用設備が必要で、専門のベンダーがシステムを開発しなければなりませんでした。
その専用設備を集約し、APIとして制御可能なシステムを作り上げたものがTwilioです。
こうすることによって、APIを制御する手段(Webアプリやモバイルアプリ)があれば、電話やSMSができてしまうという仕組みになっています。

いわば、インターネットと公衆回線網等の仲立ちをする存在です。

作った背景

SIMフリー回線の音声通信や050電話番号サービスにおいては、留守電サービスは別途オプションサービスとしてお金がかかってしまいます。
その代わり転送電話サービスは無料であることが大半です。
ただし、転送先に対して音声通話料が普通に必要です。
(ただ、かけ放題オプション等を付けると、このデメリットもほぼなくなります。)

そこで、転送電話サービスを利用して留守電サービスを自作しようと相成りました。
この着想に至ったのはかれこれ6年も前の話ですが、改めて記載しておきます。

システムのフローについて

  1. 架電側が受電側に電話をかける
  2. 受電側が電話に出られなかった場合、Twilioの番号に転送される
  3. Twilioから「A CALL COMES IN」の「Webhook」へ設定したURLへRequestが行われる
  4. 電話があったことを受電側へ通知を行う
  5. アプリ上でTwiMLを生成し、TwilioへResponseを返す
  6. Twilioから架電側へ自動応答がなされる(TwiMLの内容に応じて処理が変わる)
    ここで架電側が電話を切らなければ、留守電シーケンスへ突入
  7. 音声メッセージを録音する
  8. 音声メッセージがTwilioに転送され保管される
  9. このとき、Twilioから音声メセージが保管されたことについて、Requestがある
  10. 留守電があったことを受電側に通知

alt
実線:http通信 / 破線:音声通信

2. どうなってんの?

それほど難しいことはしていません。

ルータ定義部分

server.go_SetupRouter
func SetupRouter() *gin.Engine {
    router := gin.Default()

    // ルーティングの定義
    router.POST("/answerphone", responseAnswerPhone)

     return router
}

ルータを定義しています。
POSTで受け、単機能しか無いので、ルーティングの定義は一つだけです。

処理部分

func responseAnswerPhone(c *gin.Context) {
    resp := twiml.NewResponse()
    lang := "ja-jp"

    // POSTで"From"を取得
    from := c.PostForm("From")
    if from == "" {
        // なければBad Requestとみなす
        c.AbortWithStatus(400)
    }

    var msg string

    // 特殊番号(匿名)からの電話を拒否
    if from == "+266696687" {
        msg = "非通知着信はお断りしております。"
        msg += "大変申し訳ありませんが発信番号を通知しておかけ直し下さい。"

        resp.Action(twiml.Say{ Text: msg, Language: lang })

    } else {
        msg = "ただ今電話に出ることができません。"
        msg += "ビープおんのあとにお名前とご用件をお願いいたします。"

        // 機械に喋らせたあとに録音時間を設ける
        resp.Action(
            twiml.Say{ Text: msg, Language: lang },
            twiml.Record{ Timeout: 120 },
        )
    }

    datetime := time.Now().Format("2006/01/02 15:04:05")
    // メッセージを送信する機能の関数を入れ込む
    sendmsg := sendLineMessage
    // sendmsg := sendSlackMessage
    // sendmsg := sendMail

    // 見やすいようにE.164形式から変換
    // (ただし、ハイフンは付きません)
    p := PhoneNumber(from)
    dfrom, err := p.To0ABJ()
    if err != nil {
        fmt.Println(err)
    }

    // 留守録が終わったあとにcallbackが来るので、それも拾えるように
    if c.PostForm("RecordingSid") != "" {
        vurl := c.PostForm("RecordingUrl") + ".mp3"
        subject := "【Twilio】留守電"
        body := fmt.Sprintf(
            "%s に %s からの着信において新規留守電が登録されました。\n\n%s",
            datetime,
            dfrom,
            vurl,
        )

        // メッセージを送信(本当は非同期にしたかった・・・修正するかも)
        if err := sendmsg(subject, body); err != nil {
            fmt.Println(err)
        }

    } else {
        subject := "【Twilio】着信あり"
        body := fmt.Sprintf(
            "%s に %s から着信がありました。",
            datetime,
            dfrom,
        )

        if err := sendmsg(subject, body); err != nil {
            fmt.Println(err)
        }
    }

    // c.XML() ←ginの機能でレスを返すとおかしくなるので、
    // gin.Context内のResponseWiterを使用してTwiMLを表示
    resp.Send(c.Writer)

TwilioにおいてはPOSTを選択すると Content-Type: application/x-www-form-urlencodedでやってくるようです。
架電してきた電話番号については CallerFrom のパラメータで取得できます。1

私はわかりやすさ重視で From で取得しています。
From のパラメータがなければ、400: BadRequest としておかえりいただきます。

その後、TwiMLの生成を行います。
特殊番号(匿名)2 については非通知発信ですので無視します。
因みに、TwiMLの生成には bitbucket.org/ckvist/twilio を利用させてもらいました。

// メッセージを送信する機能の関数を入れ込む
sendmsg := sendLineMessage
// sendmsg := sendSlackMessage
// sendmsg := sendMail

この部分については後述します。

電話番号については、TwilioからはE.164形式で渡ってくるので、0AB-J形式に変換します。
変換系の関数については外出ししています。
(ソースの phonenumber-tool.go を参照)

変換後、メッセージを送信し、TwiMLを応答します。

// c.XML() ←ginの機能でレスを返すとおかしくなるので、
// gin.Context内のResponseWiterを使用してTwiMLを表示
resp.Send(c.Writer)

この部分についてはうまくTwiML(XML)を表示することができずに困っていました。
元々以下のように書いていました。

c.XML(http.StatusOK, resp.String())

しかし、このように書いてしまうと以下のような謎のXMLになってしまいました。

<String>
    <?xml version="1.0" encoding="UTF-8"?>
    <Response> ... </Response>
</String>

<String></String>root よりも上位に来てしまいTwilioからエラーが返ってきていました。
ならば Send関数 を使うしか無いなと思いました。
io.Writer を引数に取るということは http.ResponseWriter を入れればうまくいくということまでは予測がつきました。
では、gin ではどうすればよいのか。

gin のContext周りのDocumentを読み直してみると、Context構造体の中にWriter ResponseWriter なるものが存在しているではありませんか。
こいつを引数に取ればうまくいくのでは無いかと思いやってみると、見事に成功です。

やはりGo言語はGoDocが充実していることも推しポイントだと思います。

メッセージ送信部分

func sendLineMessage(subject, body string) error {
    token := "ABCDEFGHIJKLMNOPQabcdefghijklmnopq0123456789"
    line := notify.NewLineClientWithTag(token, subject)

    return line.SendMessage(body)
}

func sendSlackMessage(subject, body string) error {
    url := "https://hooks.slack.com/services/TXXXXXXXX/BXXXXXXXXXX/ABCDEFGHIJKLMN0123456789"
    s := notify.NewSlackMessageClient(url)

    str := subject + "\n\n" + body
    return s.SendSimpleMessage(str)
}

func sendMail(subject, body string) error {
    s := notify.NewSmtpServer(
        "stmp.gmail.com",
        587,
        "username@gmail.com",
        "password",
    )

    return s.EasySendMail(
        "username@gmail.com",
        "hoge@example.com",
        subject,
        body,
    )
}

上から順に「LINE Notify」「Slack Incoming Webhook」「Mail」で、メッセージを送信する関数になっています。
これらのうち、どれかの手段でメッセージを送れるようになっています。

サーバ起動部

main.go
// import "nginx/unit"

const LISTEN_PORT string = ":8080"

func main() {
    // unit.ListenAndServe(LISTEN_PORT, SetupRouter())
    SetupRouter().Run(LISTEN_PORT)
}

最後にサーバの起動部分です。
ルータ定義を受けて起動しているだけです。

NGINX Unitにも対応可能なようにしてあります。
待受ポートは8080番になっています。

ソースの全て

5年前と違って、しっかりとgitでソース管理をすることを覚えました。
成長していますね。

twilio-answerphone-example

言い訳ですが、短時間でPHPからマイグレーションしたので、移行していない部分もあります。
メッセージ送信部分を非同期にすべきかどうか迷って時間切れになったのでとりあえずそのままです。

ライセンスはThe Unlicenseとしています。
サンプルプログラムにライセンスがあるのも変な話だと私は思っていますので。

3. 宣伝!

メッセージ送信機能として github.com/t-okkn/go-enjaxel というパッケージを使用しています。
これは私が作成したパッケージです。

作り始めた当初から、C++のBoostをイメージしてこれさえあれば開発が捗る!!という機能を詰め込んでいっています。
ただ、Go言語の思想とは真逆であるため、自分自身でも最近疑問を感じ始めています。
もしかしたら、単一機能に分けるかもしれません。

現状、数機能ほどが含まれています。

おわりに

割といろいろなアプリを自作していますが、Qiitaで公開している内容は意外と少ないんですよね。
ノウハウはそれなりに持っているつもりですが、やはり先駆者には適いませんね。
Qiita消費者からQiita生産者になれるように、もっと頑張りたいと思います。
 
 


  1. 投げられるパラメータ等についてはこちらに詳細が記載されています。 

  2. 特殊番号の一覧はこちらに記載されています。 

7
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
8