10
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

NTTコムウェアAdvent Calendar 2024

Day 12

設定ファイルの代わりにJavaScriptを使う

Last updated at Posted at 2024-12-12

この記事は NTTコムウェア Advent Calendar 2024 12日目の記事です。

NTTコムウェア株式会社の近江です。

プログラムを作成していて、設定ファイルによる柔軟な動作を実現しようと頑張っているうちに、いつの間にかアプリ独自のプログラミング言語を実装しちゃってることってよくありますよね?

実質自作プログラミング言語の言語仕様を考えるのも他者に伝えるのも大変です。それであれば、すでに世間に出回っている言語の実行エンジンを組み込んでしまえば良いのでは?という考えに至るのではないでしょうか。

そのような用途においては、かつては(今も?)Luaを使うことが多かったようですが、昨今はさまざまなJavaScript実行エンジンが出回っているということもありますので、この記事では設定ファイルの代わりにJavaScriptが書かれたファイルを読み込んで実行することにより動作を柔軟に変更する、という実験をしてみた記録を書いてみます。

昨年書いた記事ではRustにチャレンジしたので、今年はGoでやってみます。

この記事に記載されている情報は、筆者が個人で調査・検証した内容をもとに作成されています。内容の正確性や完全性、ソースコードの動作などについては一切の保証を行いません。

お題の設定

単純なサンプルプログラムで動作確認するだけであれば、そのライブラリのドキュメントを写経するのと大差ありませんので、動いて楽しい実践的なもので試したいところです。

私は若い頃VoIPエンジニアだったので、IVR(Interactive Voice Response)を題材にしてみます。
IVRとは、コールセンターなどに電話をかけた際、オペレーターに繋がる前に機械が応答し、「ただいま混み合っています」と案内したり、プッシュボタンで希望するメニューを選択させたりする、あの自動応答システムのことです。

もしIVRのソフトウェアを作るとしたら、「どの音声を再生するか」「発信者がプッシュボタンを押した時に何が起こるか」といった動作を、導入先ごとにプログラムを直接書き換えるのではなく、設定ファイルのようなもので簡単にカスタマイズできる方が望ましいでしょう。
また、IVRの動作は複雑な条件分岐を伴うことが多いため、単純な値の羅列ではなく、設定ファイルそのものをプログラムのように記述できる形式が適していると考えられ、今回のテーマ「設定ファイルの代わりにJavaScriptを使う」は、IVRにぴったりの題材ではないかと考えました。

以降、Goで書くIVRを本体、本体に読み込まれるJavaScriptのコードをシナリオと呼称します。

ゴールのイメージ

以降の説明を理解しやすいように、作ろうとしているものを絵で示します。

image.png

今回実装する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を使うことにしました。DiagoSIPだけでなく音声再生や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.jsJavaScriptで書かれたシナリオを読み込んでいます。

そのあと、着信時の処理のところに書いてある以下のコードで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などの実行エンジンを組み込むことも検討されてはいかがでしょうか。

※本記事内に記載されている会社名、製品名、サービス名は、各社の商標または登録商標です。

10
5
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
10
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?