この記事は NTTコムウェア Advent Calendar 2024 12日目の記事です。
NTTコムウェア株式会社の近江です。
プログラムを作成していて、設定ファイルによる柔軟な動作を実現しようと頑張っているうちに、いつの間にかアプリ独自のプログラミング言語を実装しちゃってることってよくありますよね?
実質自作プログラミング言語の言語仕様を考えるのも他者に伝えるのも大変です。それであれば、すでに世間に出回っている言語の実行エンジンを組み込んでしまえば良いのでは?という考えに至るのではないでしょうか。
そのような用途においては、かつては(今も?)Luaを使うことが多かったようですが、昨今はさまざまなJavaScript実行エンジンが出回っているということもありますので、この記事では設定ファイルの代わりにJavaScriptが書かれたファイルを読み込んで実行することにより動作を柔軟に変更する、という実験をしてみた記録を書いてみます。
昨年書いた記事ではRustにチャレンジしたので、今年はGoでやってみます。
この記事に記載されている情報は、筆者が個人で調査・検証した内容をもとに作成されています。内容の正確性や完全性、ソースコードの動作などについては一切の保証を行いません。
お題の設定
単純なサンプルプログラムで動作確認するだけであれば、そのライブラリのドキュメントを写経するのと大差ありませんので、動いて楽しい実践的なもので試したいところです。
私は若い頃VoIPエンジニアだったので、IVR(Interactive Voice Response)を題材にしてみます。
IVRとは、コールセンターなどに電話をかけた際、オペレーターに繋がる前に機械が応答し、「ただいま混み合っています」と案内したり、プッシュボタンで希望するメニューを選択させたりする、あの自動応答システムのことです。
もしIVRのソフトウェアを作るとしたら、「どの音声を再生するか」「発信者がプッシュボタンを押した時に何が起こるか」といった動作を、導入先ごとにプログラムを直接書き換えるのではなく、設定ファイルのようなもので簡単にカスタマイズできる方が望ましいでしょう。
また、IVRの動作は複雑な条件分岐を伴うことが多いため、単純な値の羅列ではなく、設定ファイルそのものをプログラムのように記述できる形式が適していると考えられ、今回のテーマ「設定ファイルの代わりにJavaScriptを使う」は、IVRにぴったりの題材ではないかと考えました。
以降、Goで書くIVRを本体、本体に読み込まれるJavaScriptのコードをシナリオと呼称します。
ゴールのイメージ
以降の説明を理解しやすいように、作ろうとしているものを絵で示します。
今回実装するIVRの仕様をざっくり考える
今回の目的である「スクリプトで本体の動作を制御する方法」を把握するため、最低限以下のようなことができれば良いだろうと考えました。
- セッション制御にはSIPを利用するものとする
- シナリオには
OnEvent
関数を実装するものとする - 本体は通話中にイベントが発生するたびに、この
OnEvent
を実行し、イベント発生時の処理をシナリオ側で決定する -
OnEvent
の引数として次の2つの引数を受け取る- 状態を示す数値 ... たとえば、「状態が1のとき、音声再生終了後に特定の処理を実行し、状態を2にする」ような使い方を想定。後述するAPIの利用によりシナリオの指示に基づいて変更できる
- 発生したイベントの種類。文字列で表現される。以下の種類がある
- "CONNECTED":通話が確立した
- "STOPPED":音声再生が終了した
- "DTMF_?":(ユーザのプッシュボタン押下により送出された)DTMFを受信した(? は押されたボタンに応じた 0〜9、#、* のいずれか)
- シナリオ内では以下の関数(API)を利用できるものとする。これらの関数はすべてノンブロッキングで実行され、処理が直ちに戻るものとする
- play:指定された音声を再生する。引数で再生する音声ファイル名を受け付ける。音声再生中に呼び出された場合は、再生中の音声を直ちに停止してから、指定された音声ファイルを再生する
- stop:音声再生を停止する
- setState:状態を更新する。引数で状態として設定する数値を指定する
- disconnect:通話を終了する
IVRを実装する
JavaScript実行エンジンとして gojaを採用し、SIPライブラリについてはDiagoを使うことにしました。DiagoはSIPだけでなく音声再生やDTMF検出などの機能も具備しているのでIVRを実装するにはうってつけです。いずれも2024年12月1日時点の最新版を使用します。
Diagoのドキュメントを読みつつ、サンプルコードの中にある、音声再生の例や、DTMF受信の例をかなり参考にしつつ、JavaScriptと連携する方法はgojaのドキュメントを参考にばーっと書いてみました。(※エラー処理などはかなり端折っています)
ここをクリックすると今回作ったIVRのコード全体を見ることができます
package main
import (
"context"
"fmt"
"os"
"os/signal"
"strconv"
"time"
"github.com/emiago/diago"
"github.com/emiago/diago/media"
"github.com/emiago/sipgo"
"github.com/emiago/sipgo/sip"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/dop251/goja"
)
// 起動パラメータの解析
func parseArgs(ip, port string) (string, int) {
tmp, err := strconv.ParseUint(port, 10, 16)
if err != nil {
log.Printf("%s", err)
os.Exit(1)
}
return ip, int(tmp)
}
// ロガー初期化
func initLogger() {
lev, err := zerolog.ParseLevel(os.Getenv("LOG_LEVEL"))
if err != nil || lev == zerolog.NoLevel {
lev = zerolog.InfoLevel
}
zerolog.TimeFieldFormat = zerolog.TimeFormatUnixMicro
log.Logger = zerolog.New(zerolog.ConsoleWriter{
Out: os.Stdout,
TimeFormat: time.StampMicro,
}).With().Timestamp().Logger().Level(lev)
sip.SIPDebug = os.Getenv("SIP_DEBUG") == "true"
media.RTCPDebug = os.Getenv("RTCP_DEBUG") == "true"
}
func main() {
// 起動パラメータの確認
if len(os.Args) != 6 {
log.Print("invalid parameter")
return
}
ProxyHost, ProxyPort := parseArgs(os.Args[1], os.Args[2])
MyHost, MyPort := parseArgs(os.Args[3], os.Args[4])
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
// ログの初期化
initLogger()
// 通話中に実行されまくるシナリオをロードしておく
// 着信の都度ロードするようにすればプログラムを動かしている最中に
// シナリオをかきかえることもできるだろう
b, err := os.ReadFile(os.Args[5])
if err != nil {
log.Fatal().Err(err).Msg("failed to load " + os.Args[5])
}
scenario := string(b)
fmt.Println(scenario)
regOpts := diago.RegisterOptions{
Username: "111",
}
recipient := sip.Uri{}
if err := sip.ParseUri(fmt.Sprintf("sip:%s:%d", ProxyHost, ProxyPort), &recipient); err != nil {
log.Fatal().Err(err).Msg("failed to parse uri")
}
useragent := regOpts.Username
ua, err := sipgo.NewUA(
sipgo.WithUserAgent(useragent),
sipgo.WithUserAgentHostname(MyHost),
)
if err != nil {
log.Fatal().Err(err).Msg("sipgo.NewUA() failed")
}
defer ua.Close()
tu := diago.NewDiago(ua, diago.WithTransport(
diago.Transport{
Transport: "udp",
BindHost: MyHost,
BindPort: MyPort,
}))
// 着信時の処理
tu.ServeBackground(ctx, func(inDialog *diago.DialogServerSession) {
// 100 Tryingを送信
inDialog.Progress()
// イベント受信用
ch := make(chan string, 10)
// 音声再生時のコントローラ
var pb *diago.AudioPlaybackControl
// シナリオの状態
state := 0
// JavaScriptのVM生成
vm := goja.New()
// 文字列出力用APIの登録
vm.Set("print", func(s string) {
log.Info().Msg(s)
})
// 音声再生用APIの登録
vm.Set("play", func(filename string) {
if pb != nil {
pb.Stop()
pb = nil
}
pb, err = Playback(inDialog, filename, ch)
if err != nil {
log.Error().Err(err).Msg("Failed to play")
pb = nil
}
})
// 音声再生停止用APIの登録
vm.Set("stop", func() {
if pb != nil {
pb.Stop()
pb = nil
}
})
// 状態値設定用APIの登録
vm.Set("setState", func(newState int) {
state = newState
})
// 切断用API
vm.Set("disconnect", func() {
inDialog.Bye(ctx)
})
// 一旦シナリオを実行しておく
_, err = vm.RunString(scenario)
if err != nil {
log.Fatal().Err(err).Msg("failed to run scenario")
}
// 読み込んだシナリオからOnEventという関数を取り出して呼べるようにする
onEvent, ok := goja.AssertFunction(vm.Get("OnEvent"))
if !ok {
log.Fatal().Err(err).Msg("failed to get OnEvent function")
}
// 180 Ringingを送信
inDialog.Ringing()
// 200 OKを送信
if err := inDialog.Answer(); err != nil {
inDialog.Hangup(ctx)
return
}
// DTMF検出開始
go ReadDTMF(inDialog, ch)
// 通話確立したので、シナリオのOnEventを実行
onEvent(goja.Undefined(), vm.ToValue(state), vm.ToValue("CONNECTED"))
// イベント受信ループ
// イベントが発生するたびにOnEventを呼び出して、次に何をしたいかを判断させる
for {
select {
case evt, ok := <-ch:
if ok {
onEvent(goja.Undefined(), vm.ToValue(state), vm.ToValue(evt))
} else {
log.Info().Msg("ERROR")
inDialog.Bye(ctx)
}
case <-inDialog.Context().Done():
return
default:
time.Sleep(10 * time.Millisecond)
}
}
})
tu.Register(ctx, recipient, regOpts)
}
// 音声再生
func Playback(inDialog *diago.DialogServerSession, filename string, ch chan<- string) (*diago.AudioPlaybackControl, error) {
//
// 再生できるファイルのフォーマットに制限あり。
// 例えばこんな感じで変換しておく(これでもダメな場合もある)
// ffmpeg -i ./input.wav -c:a pcm_s16le -ar 8000 -ac 1 ./output.wav
//
pb, err := inDialog.PlaybackControlCreate()
if err != nil {
log.Info().Err(err).Msg("failed to create playback control")
return nil, err
}
go func() {
defer func() {
ch <- string("STOPPED")
log.Info().Msg("media stopped")
}()
if _, err := pb.PlayFile(filename); err != nil {
log.Info().Err(err).Msg("failed to play file")
}
}()
return &pb, nil
}
// DTMF受信
func ReadDTMF(inDialog *diago.DialogServerSession, ch chan<- string) error {
reader := inDialog.AudioReaderDTMF()
err := reader.Listen(func(dtmf rune) error {
ch <- "DTMF_" + string(dtmf)
return nil
}, 10*time.Second)
return err
}
単純なプログラムではありますが、JavaScriptのコードを取り扱っている部分だけ簡単に説明しておきます。
プログラムが動き始めて少し進んだところで、以下のようなコードがあります。
b, err := os.ReadFile("./test.js")
if err != nil {
log.Fatal().Err(err).Msg("failed to load test.js")
}
scenario := string(b)
これで外部ファイルであるtest.js
にJavaScriptで書かれたシナリオを読み込んでいます。
そのあと、着信時の処理のところに書いてある以下のコードでJavaScriptのVMを生成し、
vm := goja.New()
これに続けて、JavaScript側から呼び出すことができる関数を登録していきます。
まずはデバッグのために作ってみた文字列を出力するだけの関数
vm.Set("print", func(s string) {
log.Info().Msg(s)
})
次に音声再生用のplay
関数。音声再生中だったら停止してから、再生を指示しています。
vm.Set("play", func(filename string) {
if pb != nil {
pb.Stop()
pb = nil
}
pb, err = Playback(inDialog, filename, ch)
if err != nil {
log.Error().Err(err).Msg("Failed to play")
pb = nil
}
})
さらに音声停止用のstop
関数
vm.Set("stop", func() {
if pb != nil {
pb.Stop()
pb = nil
}
})
状態を表す数値を設定するsetState
関数
vm.Set("setState", func(newState int) {
state = newState
})
最後に切断用のdisconnect
関数
vm.Set("disconnect", func() {
inDialog.Bye(ctx)
})
関数の設定が終わったところで、JavaScriptのコードであるシナリオをvmで走らせます
_, err = vm.RunString(scenario)
if err != nil {
log.Fatal().Err(err).Msg("failed to run scenario")
}
次にJavaScript側に書いてあるはずである、イベント発生時に実行するOnEvent
関数を取り出します。
onEvent, ok := goja.AssertFunction(vm.Get("OnEvent"))
if !ok {
log.Fatal().Err(err).Msg("failed to get OnEvent function")
}
そして通話が確立したらDTMF検出を開始してからOnEvent
関数を実行。これで通話が確立した時の処理をOnEvent
関数に委ねることとなります。接続したことをシナリオに伝えたいので、イベントとして"CONNECTED"という文字列を渡します。
onEvent(goja.Undefined(), vm.ToValue(state), vm.ToValue("CONNECTED"))
このあと、DTMF受信や音声再生終了などのイベントが発生したら、その都度OnEvent
関数を呼び出します
for {
select {
case evt, ok := <-ch:
if ok {
onEvent(goja.Undefined(), vm.ToValue(state), vm.ToValue(evt))
} else {
log.Info().Msg("ERROR")
inDialog.Bye(ctx)
}
case <-inDialog.Context().Done():
return
default:
time.Sleep(10 * time.Millisecond)
}
}
以上です!
遊んでみる
適当なSIPソフトフォンと適当なSIP Proxyと適当なシナリオと適当な音声ファイルを用意しておきます。
例えばこんなシナリオでどうでしょうか。
function OnEvent(state, evt) {
print("JavaScript呼ばれた")
if (evt === "CONNECTED") {
play("./1.wav")
setState(1)
} else if (state === 1) {
if (evt === "DTMF_1") {
play("./2.wav")
} else if (evt === "DTMF_2") {
play("./3.wav")
} else if (evt === "DTMF_3") {
disconnect()
}
}
}
繋がったら./1.wavというファイルを再生して、プッシュボタンで1が押されたら./2.wavを再生、2が押されたら./3.wavを再生、3が押されたら切断する、というシナリオです。
当然音声ファイルも必要なので、なんらかの形で準備した音声ファイルを準備し、IVR本体が扱える形式に変換しておきます。ffmpegを使う場合は、以下のようなコマンドで変換しておきます。ファイル名のところは適宜修正してください。(この方法で変換しても再生できないファイルもありました。今のところ原因不明です)
ffmpeg -i ./input.wav -c:a pcm_s16le -ar 8000 -ac 1 ./output.wav
私が適当に拾ってきたSIPソフトフォンがクセ強めで、標準的ではないSIPメッセージを送信しているように見えたので、Proxy 兼 Registrarも自作してうまいこと動くようにしました。通信相手が標準的でないように思われたところを誤魔化すための処理も混ざっているので、この動作確認専用だとお考えください。
今回使ったプロキシのコード
package main
import (
"crypto/sha1"
"encoding/hex"
"fmt"
"log"
"net"
"os"
"regexp"
"strconv"
"strings"
)
type AddrSpec struct {
Raw string
Userinfo string
Host string
Port uint16
}
func NewAddrSpec() *AddrSpec {
return new(AddrSpec)
}
func (p *AddrSpec) Parse(buf string) error {
p.Raw = buf
ary := regexp.MustCompile(`^sip:(([^@]+)@)?([^?;]+)`).FindAllStringSubmatch(buf, 1)
if len(ary) != 1 {
return fmt.Errorf("invalid addrspec: %s", buf)
}
if len(ary[0]) != 4 {
return fmt.Errorf("invalid addrspec: %s", buf)
}
p.Userinfo = ary[0][2]
hostport := ary[0][3]
ary = regexp.MustCompile(`^([^:]+)(:(\d+))?`).FindAllStringSubmatch(hostport, 1)
p.Host = ary[0][1]
port := ary[0][3]
if port == "" {
p.Port = 5060 // デフォルト
} else {
i, err := strconv.Atoi(port)
if err != nil {
return fmt.Errorf("invalid port format: %s", port)
}
p.Port = uint16(i)
}
return nil
}
type NameAddr struct {
Raw string
AddrSpec AddrSpec
}
func NewNameAddr() *NameAddr {
return new(NameAddr)
}
func (p *NameAddr) Parse(buf string) error {
p.Raw = buf
displayName := regexp.MustCompile(`^(("[^"]*")\s*)?`).FindString(buf)
if displayName != "" {
buf = buf[len(displayName):]
}
ary := regexp.MustCompile(`^([^<]*)\s*<([^>]+)>`).FindAllStringSubmatch(buf, 1)
if len(ary) == 1 {
if len(ary[0]) == 3 {
buf = ary[0][2]
return p.AddrSpec.Parse(buf)
}
}
tmp := regexp.MustCompile(`[^;]+`).FindString(buf)
return p.AddrSpec.Parse(tmp)
}
type Header struct {
Name string
Vals []string
}
func NewHeader() *Header {
p := new(Header)
p.Vals = []string{}
return p
}
func (p *Header) String() string {
return p.Name + ": " + strings.Join(p.Vals, ", ") + "\r\n"
}
func (p *Header) Parse(buf string) error {
ary := regexp.MustCompile(`\s*:\s*`).Split(buf, 2)
p.Name = ary[0]
if len(ary[1]) > 0 {
p.Vals = regexp.MustCompile(`\s*,\s*`).Split(ary[1], -1)
}
return nil
}
type Message struct {
Method string
ReqURI string
StCode string
Reason string
Hdrs []Header
Body string
}
func NewMessage() *Message {
p := new(Message)
return p
}
func (p *Message) String() string {
var buf string
if p.Method != "" {
buf = fmt.Sprintf("%s %s SIP/2.0\r\n", p.Method, p.ReqURI)
} else {
buf = fmt.Sprintf("SIP/2.0 %s %s\r\n", p.StCode, p.Reason)
}
for _, h := range p.Hdrs {
buf += h.Name + ": " + strings.Join(h.Vals, ", ") + "\r\n"
}
buf += "\r\n" + p.Body
return buf
}
func (p *Message) Parse(buf string, src *net.UDPAddr) error {
buf = strings.TrimLeft(buf, "\r\n")
ary := regexp.MustCompile(`(\r\n\r\n)|(\r\r)|(\n\n)`).Split(buf, 2)
if len(ary) != 2 {
return fmt.Errorf("invalid message")
}
p.Body = ary[1]
buf = regexp.MustCompile(`\r\n?`).ReplaceAllString(ary[0], "\n")
buf = regexp.MustCompile(`\n[ \t]+`).ReplaceAllString(buf, " ")
ary = strings.Split(buf, "\n")
sl := regexp.MustCompile(`(([A-Z]+) ([^ ]+) )?SIP\/2\.0( (\d+) ([^\n]+))?`).FindStringSubmatch(ary[0])
p.Method, p.ReqURI, p.StCode, p.Reason = sl[2], sl[3], sl[5], sl[6]
for _, buf := range ary[1:] {
h := NewHeader()
err := h.Parse(buf)
if err != nil {
return err
}
if len(h.Vals) > 0 { // なぜか対向が空っぽのヘッダがあるデータを投げてきた
p.Hdrs = append(p.Hdrs, *h)
}
}
return nil
}
// 指定された名前のヘッダを探してその位置を返す
func (p *Message) Search(hname1, hname2 string) int {
for i, h := range p.Hdrs {
n := strings.ToLower(h.Name)
if (n == hname1) || (n == hname2) {
return i
}
}
return -1
}
// Viaヘッダを更新、自アドレスを挿入する
func (p *Message) UpdateVia(myvia string, srcaddr *net.UDPAddr) error {
pos := p.Search("via", "v")
if pos == -1 {
return fmt.Errorf("no Via header found")
}
p.Hdrs[pos].Vals[0] += fmt.Sprintf(";received=%s", srcaddr.IP)
branch := p.generateBranch(p.Hdrs[pos].Vals[0])
p.Hdrs[pos].Vals = append([]string{myvia + branch}, p.Hdrs[pos].Vals...)
return nil
}
// 推奨されている方法ではないけれど、前段が真っ当に実装されていればこれで十分なはず
func (p *Message) generateBranch(input string) string {
hash := sha1.New()
hash.Write([]byte(input))
hashBytes := hash.Sum(nil)
return hex.EncodeToString(hashBytes)
}
// リクエストから必須ヘッダをコピーしながらレスポンスを生成する
func (p *Message) GenResp(StCode string, Reason string) *Message {
hns := []string{"call-id", "i", "from", "f", "to", "t", "via", "v", "cseq", "record-route"}
resp := NewMessage()
resp.StCode, resp.Reason = StCode, Reason
for _, hdr := range p.Hdrs {
flag := false
hname := strings.ToLower(hdr.Name)
for _, hn := range hns {
if hname == hn {
flag = true
break
}
}
if flag {
resp.Hdrs = append(resp.Hdrs, hdr)
}
}
resp.Hdrs = append(resp.Hdrs, Header{"Content-Length", []string{"0"}})
return resp
}
type Proxy struct {
IP string
Port uint16
Sock *net.UDPConn
Via string
RecordRoute string
LocationService map[string]string
}
func NewProxy() *Proxy {
p := new(Proxy)
p.LocationService = map[string]string{}
return p
}
func (p *Proxy) Init(ip string, port string) error {
tmp, err := strconv.ParseUint(port, 10, 16)
if err != nil {
return err
}
p.IP, p.Port = ip, uint16(tmp)
s := fmt.Sprintf("%s:%s", ip, port)
p.Via = fmt.Sprintf("SIP/2.0/UDP %s;branch=z9hG4bK", s)
p.RecordRoute = fmt.Sprintf("<sip:%s;lr>", s)
udpAddr, err := net.ResolveUDPAddr("udp", s)
if err != nil {
log.Println(err)
return err
}
p.Sock, err = net.ListenUDP("udp", udpAddr)
if err != nil {
log.Println(err)
return err
}
return nil
}
func (p *Proxy) isMe(addr *NameAddr) bool {
return (addr.AddrSpec.Host == p.IP) && (addr.AddrSpec.Port == p.Port)
}
func (p *Proxy) Run() {
buf := make([]byte, 0xffff)
for {
n, srcaddr, err := p.Sock.ReadFromUDP(buf)
if err != nil {
return
}
s := string(buf[:n])
log.Println("--------------------------")
log.Println(s, srcaddr)
msg := NewMessage()
err = msg.Parse(s, srcaddr)
if err == nil {
if msg.Method != "" {
p.procRequest(msg, srcaddr)
} else {
p.procResponse(msg)
}
}
}
}
// 受信したリクエストを処理
func (p *Proxy) procRequest(msg *Message, srcaddr *net.UDPAddr) error {
msg.UpdateVia(p.Via, srcaddr)
requri := NewNameAddr() // なぜか対向したソフトフォンがAddrSpecじゃなくてNameAddrを投げてきた
err := requri.Parse(msg.ReqURI)
if err != nil {
log.Println("invalid request uri: ", msg.ReqURI)
return err
}
if p.isMe(requri) {
if msg.Method == "REGISTER" {
return p.procRegister(msg)
}
// ユーザを探して、いる場合はSIPURIを更新する
if sipuri, ok := p.LocationService[requri.AddrSpec.Userinfo]; ok {
msg.ReqURI = sipuri
} else {
if msg.Method != "ACK" {
resp := msg.GenResp("404", "Not Found")
err = p.procResponse(resp)
}
return err
}
}
err = p.updateMaxForwards(msg) // Max-Forwards更新
if err == nil {
err = p.insertRecordRoute(msg) // Record-Route挿入
if err == nil {
err = p.updateRoute(msg) // Routeヘッダ更新
if err == nil {
// 中継先の決定
pos := msg.Search("route", "")
target := NewNameAddr()
if pos != -1 {
err = target.Parse(msg.Hdrs[pos].Vals[0])
if err != nil {
return err
}
} else {
target = requri
}
// 中継
err = p.send(msg, target.AddrSpec.Host, target.AddrSpec.Port)
}
}
}
return err
}
// Max-Forwadsがなければ70を設定、あれば減算、減算してゼロになったら420エラーを返す
func (p *Proxy) updateMaxForwards(msg *Message) error {
pos := msg.Search("max-forwards", "")
if pos == -1 {
msg.Hdrs = append(msg.Hdrs, Header{"Max-Forwards", []string{"69"}})
} else {
if msg.Hdrs[pos].Vals[0] == "0" {
if msg.Method != "ACK" {
resp := msg.GenResp("420", "Too Many Hops")
p.procResponse(resp)
}
return fmt.Errorf("too many hops")
}
mf, err := strconv.ParseUint(msg.Hdrs[pos].Vals[0], 10, 16)
if err != nil {
if msg.Method != "ACK" {
resp := msg.GenResp("400", "Bad Request")
p.procResponse(resp)
}
return fmt.Errorf("invalid max-forwards")
}
msg.Hdrs[pos].Vals = []string{fmt.Sprintf("%d", mf-1)}
}
return nil
}
// Record-Routeヘッダを末尾に挿入する
func (p *Proxy) insertRecordRoute(msg *Message) error {
pos := -1
for i, hdr := range msg.Hdrs {
name := strings.ToLower(hdr.Name)
if name == "record-route" {
pos = i
}
}
if pos == -1 {
msg.Hdrs = append(msg.Hdrs, Header{"Record-Route", []string{p.RecordRoute}})
} else {
addr := NewNameAddr()
err := addr.Parse(msg.Hdrs[pos].Vals[0])
if err != nil {
log.Println("invalid record-route header")
return err
}
if !p.isMe(addr) {
msg.Hdrs[pos].Vals = append(msg.Hdrs[pos].Vals, p.RecordRoute)
}
}
return nil
}
// Routeヘッダの先頭が自分宛だったらそれを削除する
func (p *Proxy) updateRoute(msg *Message) error {
pos := msg.Search("route", "")
if pos == -1 {
return nil
}
route := NewNameAddr()
if err := route.Parse(msg.Hdrs[pos].Vals[0]); err != nil {
resp := msg.GenResp("400", "Bad Request")
p.procResponse(resp)
return err
}
if p.isMe(route) {
msg.Hdrs[pos].Vals = msg.Hdrs[pos].Vals[1:]
if len(msg.Hdrs[pos].Vals) == 0 {
msg.Hdrs = append(msg.Hdrs[:pos], msg.Hdrs[pos+1:]...)
}
}
return nil
}
// 受信したREGISTERを処理
func (p *Proxy) procRegister(msg *Message) error {
topos := msg.Search("from", "f")
addr := NewNameAddr()
err := addr.Parse(msg.Hdrs[topos].Vals[0])
if err != nil {
return err // エラーレスポンスを返した方がいいけど、解析できないようなものを投げてくる方が悪いので無視
}
var resp *Message
mpos := msg.Search("contact", "m")
if msg.Hdrs[mpos].Vals[0] != "*" {
contact := NewNameAddr()
err = contact.Parse(msg.Hdrs[mpos].Vals[0])
if err != nil {
return err // エラーレスポンスを返した方がいいけど、解析できないようなものを投げてくる方が悪いので無視
}
p.LocationService[addr.AddrSpec.Userinfo] = contact.AddrSpec.Raw
resp = msg.GenResp("200", "OK")
resp.Hdrs = append(resp.Hdrs, Header{"Contact", []string{contact.Raw}})
} else {
resp = msg.GenResp("200", "OK")
}
return p.procResponse(resp)
}
// 受信したレスポンスや生成したレスポンスを処理
func (p *Proxy) procResponse(msg *Message) error {
err := p.removeTopVia(msg) // 先頭のViaを消す。本当は自分かどうかチェックをしないといけないけど。。
if err != nil {
return err
}
// 宛先を決める
pos := msg.Search("via", "v")
if pos == -1 {
err := fmt.Errorf("target via not found")
log.Println(err)
return err
}
host, port, err := p.parseVia(msg.Hdrs[pos].Vals[0])
if err != nil {
return err
}
return p.send(msg, host, port)
}
func (p *Proxy) removeTopVia(msg *Message) error {
pos := msg.Search("via", "v")
if pos == -1 {
err := fmt.Errorf("via header not found")
log.Println(err)
return err
}
if len(msg.Hdrs[pos].Vals) == 1 {
msg.Hdrs = append(msg.Hdrs[:pos], msg.Hdrs[pos+1:]...)
} else {
msg.Hdrs[pos].Vals = msg.Hdrs[pos].Vals[1:]
}
return nil
}
// Viaの解析
func (p *Proxy) parseVia(buf string) (string, uint16, error) {
ary := regexp.MustCompile(`SIP\s*/\s*2\.0\s*/\s*UDP\s+([^:;\s]+)(:(\d+))?`).FindStringSubmatch(buf)
if len(ary) != 4 {
return "", 0, fmt.Errorf("invalid via header: %s", buf)
}
host := ary[1]
port := uint16(5060)
if ary[3] != "" {
tmp, err := strconv.ParseUint(ary[3], 10, 16)
if err != nil {
return "", 0, fmt.Errorf("invalid via header: %s", buf)
}
port = uint16(tmp)
}
buf = buf[len(ary[0]):]
ary = regexp.MustCompile(`;\s*received\s*=\s*([^;]+)`).FindStringSubmatch(buf)
if len(ary) == 2 {
host = ary[1]
}
return host, port, nil
}
// メッセージを文字列化して送信する
func (p *Proxy) send(msg *Message, host string, port uint16) error {
buf := msg.String()
s := fmt.Sprintf("%s:%d", host, port)
udpAddr, err := net.ResolveUDPAddr("udp", s)
if err != nil {
log.Println(err)
return err
}
log.Println("<<<<<<<<< " + s + " <<<<<<<<<<<")
log.Println(buf)
log.Println("- - - - - - - - - - - - - - - - - - - -")
_, err = p.Sock.WriteToUDP([]byte(buf), udpAddr)
return err
}
func main() {
if len(os.Args) < 3 {
log.Fatal("invalid parameter")
}
px := NewProxy()
err := px.Init(os.Args[1], os.Args[2])
if err != nil {
log.Fatal(err)
}
log.Println("ok")
px.Run()
}
IVRもプロキシも起動パラメータでIPアドレスやポート番号を指定できるようにしておいたので、以下のような感じで起動しておいて、そのプロキシにREGISTERしたソフトフォンから111に発信すると、準備しておいたシナリオが動いてくれます!
proxy プロキシのIP プロキシのポート
ivr プロキシのIP プロキシのポート IVRのIP IVRのポート シナリオファイル
まとめ
ライブラリが便利すぎて特に苦労することもなかっため、実質ライブラリ使ってみた的な記事になってしまいました。エラー処理をサボりまくっていたり、本格的なIVRであれば必要そうな機能が全然なかったりといった簡易的なものではあるものの、JavaScriptで書いたシナリオによりその動作をコントロールできるIVRを作ることができました。
今回はGoを使いましたが、他の言語でもJavaScriptの実行エンジンを入手できれば同じようなことができると思います。皆様の自作プログラムで条件分岐などを伴うような複雑な設定ファイルになってしまっていたら、JavaScriptなどの実行エンジンを組み込むことも検討されてはいかがでしょうか。
※本記事内に記載されている会社名、製品名、サービス名は、各社の商標または登録商標です。