diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96ef862 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +target/ +.idea/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..28174f8 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,4 @@ +jdk: + - oraclejdk8 +language: scala +script: "sbt publish" diff --git a/README.md b/README.md new file mode 100644 index 0000000..53e55b5 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +## simpleivr + +A Scala algebra for writing telephony applications, including an implementation using [asterisk-java](https://github.com/asterisk-java/asterisk-java). diff --git a/asterisk/src/main/scala/simpleivr/asterisk/AgiIvrApi.scala b/asterisk/src/main/scala/simpleivr/asterisk/AgiIvrApi.scala new file mode 100644 index 0000000..17c195c --- /dev/null +++ b/asterisk/src/main/scala/simpleivr/asterisk/AgiIvrApi.scala @@ -0,0 +1,72 @@ +package simpleivr.asterisk + +import java.io.File + +import cats.effect.IO +import org.asteriskjava.fastagi.{AgiChannel, AgiHangupException} +import simpleivr.IvrApi + + +/** + * Implements the IvrApi trait for Asterisk AGI + */ +class AgiIvrApi(channel: AgiChannel, val ami: Ami) extends IvrApi { + final val HangupReturnCode = -1.toChar + + def hangupAndQuit(): Nothing = { + hangup() + throw new AgiHangupException + } + + def dial(to: String, ringTimeout: Int, flags: String): Int = channel.exec("Dial", s"$to,$ringTimeout|$flags") + + def amd() = channel.exec("AMD") + + def getVar(name: String) = Option(channel.getFullVariable("${" + name + "}")) + + def callerId: IO[String] = IO { + channel.getFullVariable("$" + "{CALLERID(num)}") + } + + def waitForDigit(timeout: Int): IO[Option[Char]] = IO { + channel.waitForDigit(timeout) match { + case HangupReturnCode => hangupAndQuit() + case c: Char if c > 0 => Some(c) + case _ => None + } + } + + def waitForSilence(ms: Int, repeat: Int = 1, timeoutSec: Option[Int] = None): Unit = { + channel.exec("WaitForSilence", s"$ms,$repeat" + timeoutSec.map("," + _).getOrElse("")) + () + } + + def monitor(file: File): Unit = { + channel.exec("MixMonitor", file.getAbsolutePath) + () + } + + def hangup(): Unit = channel.hangup() + + def recordFile(pathAndName: String, + format: String, + interruptChars: String, + timeLimitMillis: Int, + offset: Int, + beep: Boolean, + maxSilenceSecs: Int): Char = + channel.recordFile(pathAndName, format, interruptChars, timeLimitMillis, offset, beep, maxSilenceSecs) + + def setAutoHangup(seconds: Int): IO[Unit] = IO { + channel.setAutoHangup(seconds) + } + + def streamFile(pathAndName: String, interruptChars: String): Char = + channel.streamFile(pathAndName, interruptChars) match { + case HangupReturnCode => hangupAndQuit() + case c => c + } + + override def originate(dest: String, script: String, args: Seq[String]): Unit = + ami.originate(dest, script, args) +} diff --git a/asterisk/src/main/scala/simpleivr/asterisk/Ami.scala b/asterisk/src/main/scala/simpleivr/asterisk/Ami.scala new file mode 100644 index 0000000..e7a091e --- /dev/null +++ b/asterisk/src/main/scala/simpleivr/asterisk/Ami.scala @@ -0,0 +1,59 @@ +package simpleivr.asterisk + +import java.beans.PropertyChangeEvent +import java.time.Instant + +import org.asteriskjava.live._ + + +class Ami(settings: AmiSettings) + extends DefaultAsteriskServer(settings.asteriskHost, settings.amiUsername, settings.amiPassword) { + + def originate(dest: String, script: String, args: Seq[String]): Unit = try { + val scriptAndArgs = script +: args + var done = false + val startTime = System.currentTimeMillis() + println(s"Executing call for $scriptAndArgs at ${Instant.now}") + var chan: Option[AsteriskChannel] = None + import scala.collection.JavaConverters._ + originateToApplicationAsync( + s"SIP/${settings.peer}/1$dest", + "Agi", + s"agi://${settings.agiHost}/${scriptAndArgs.mkString(",")}", + 60000, + new CallerId(settings.callerIdName, settings.callerIdNum), + Map("DEST_NUM" -> dest).asJava, + new OriginateCallback { + override def onDialing(channel: AsteriskChannel): Unit = { + println("Dialing " + dest) + } + override def onSuccess(channel: AsteriskChannel): Unit = { + println("Success! " + scriptAndArgs) + chan = Some(channel) + channel.addPropertyChangeListener( + "state", + (_: PropertyChangeEvent) => if (channel.getState == ChannelState.HUNGUP) done = true + ) + } + override def onNoAnswer(channel: AsteriskChannel): Unit = { + println("No answer " + scriptAndArgs) + done = true + } + override def onBusy(channel: AsteriskChannel): Unit = { + println("Busy " + scriptAndArgs) + done = true + } + override def onFailure(cause: LiveException): Unit = { + println("Failure for " + scriptAndArgs + ": " + cause) + done = true + } + } + ) + + while (System.currentTimeMillis() - startTime < 120000 && !done) Thread.sleep(10000) + + println("Finished executing call for " + scriptAndArgs + " at " + System.currentTimeMillis() + ", done = " + done + ", channel = " + chan) + } catch { + case e: Exception => e.printStackTrace() + } +} diff --git a/asterisk/src/main/scala/simpleivr/asterisk/AmiSettings.scala b/asterisk/src/main/scala/simpleivr/asterisk/AmiSettings.scala new file mode 100644 index 0000000..f2c29d7 --- /dev/null +++ b/asterisk/src/main/scala/simpleivr/asterisk/AmiSettings.scala @@ -0,0 +1,11 @@ +package simpleivr.asterisk + +trait AmiSettings { + def peer: String + def asteriskHost: String + def agiHost: String + def callerIdNum: String + def callerIdName: String + def amiUsername: String + def amiPassword: String +} diff --git a/asterisk/src/main/scala/simpleivr/asterisk/DefaultAgiScript.scala b/asterisk/src/main/scala/simpleivr/asterisk/DefaultAgiScript.scala new file mode 100644 index 0000000..82f665f --- /dev/null +++ b/asterisk/src/main/scala/simpleivr/asterisk/DefaultAgiScript.scala @@ -0,0 +1,23 @@ +package simpleivr.asterisk + +import org.asteriskjava.fastagi._ +import simpleivr.{IvrApi, IvrStep, IvrStepRunner, Sayables} + + +abstract class DefaultAgiScript(sayables: Sayables, api: IvrApi) extends AgiScript { + def run(request: AgiRequest): IvrStep[Unit] + + def ivrStepRunner(request: AgiRequest) = new IvrStepRunner(api, sayables) + + override def service(request: AgiRequest, channel: AgiChannel): Unit = { + channel.answer() + try { + val runner = ivrStepRunner(request) + runner.runIvrStep(run(request)).unsafeRunSync() + channel.hangup() + } catch { + case e: AgiHangupException => + println("Caught hangup") + } + } +} diff --git a/bintray.sbt b/bintray.sbt new file mode 100644 index 0000000..8960190 --- /dev/null +++ b/bintray.sbt @@ -0,0 +1,11 @@ +publishMavenStyle in ThisBuild := true +publishTo in ThisBuild := Some("bintray" at "https://api.bintray.com/maven/naftoligug/maven/simpleivr") + +sys.env.get("BINTRAYKEY").toSeq.map { key => + credentials in ThisBuild += Credentials( + "Bintray API Realm", + "api.bintray.com", + "naftoligug", + key + ) +} diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..08ed474 --- /dev/null +++ b/build.sbt @@ -0,0 +1,30 @@ +ThisBuild / organization := "io.github.nafg.simpleivr" +ThisBuild / version := "0.1.0" + +lazy val core = project + .settings( + name := "simpleivr-core", + libraryDependencies ++= Seq( + "com.lihaoyi" %% "sourcecode" % "0.1.4", + "org.typelevel" %% "cats-free" % "1.0.1", + "org.typelevel" %% "cats-effect" % "0.8" + ) + ) + +lazy val testing = project + .dependsOn(core) + .settings( + name := "simpleivr-testing", + libraryDependencies += "org.scalatest" %% "scalatest" % "3.0.5" + ) + +lazy val asterisk = project + .dependsOn(core) + .settings( + name := "simpleivr-asterisk", + libraryDependencies += "org.asteriskjava" % "asterisk-java" % "2.0.2" + ) + +publishArtifact := false +publish := () +publishLocal := () diff --git a/core/src/main/scala/simpleivr/AudioPath.scala b/core/src/main/scala/simpleivr/AudioPath.scala new file mode 100644 index 0000000..4f5d2bb --- /dev/null +++ b/core/src/main/scala/simpleivr/AudioPath.scala @@ -0,0 +1,26 @@ +package simpleivr + +import java.io.File + + +case class AudioPath(directory: File, name: String) { + lazy val supportedAudioFiles = + Seq("wav", "sln", "ulaw") + .map(ext => ext -> new File(directory, name + "." + ext)) + .toMap + + lazy val wavFile = supportedAudioFiles("wav") + lazy val slnFile = supportedAudioFiles("sln") + + def pathAndName: String = directory.getAbsolutePath + File.separator + name + + def existingFiles() = supportedAudioFiles.filter(_._2.exists()) + + def exists() = existingFiles().nonEmpty +} + +object AudioPath { + private val removeExtensionRegex = """\.[^./\\]*$""".r + def fromFile(file: File) = + AudioPath(file.getParentFile, removeExtensionRegex.replaceAllIn(file.getName, "")) +} diff --git a/core/src/main/scala/simpleivr/Ivr.scala b/core/src/main/scala/simpleivr/Ivr.scala new file mode 100644 index 0000000..7707072 --- /dev/null +++ b/core/src/main/scala/simpleivr/Ivr.scala @@ -0,0 +1,66 @@ +package simpleivr + +import cats.implicits._ + + +class Ivr(sayables: Sayables) { + + import sayables._ + + + def record(desc: Sayable, path: AudioPath, timeLimitInSeconds: Int): IvrStep[Unit] = + (IvrStep.say(`Please say` & desc & `after the tone, and press pound when finished.`) *> + IvrStep.recordFile(path.pathAndName, "wav", "#", timeLimitInSeconds * 1000, 0, beep = true, 3)) + .void + + def confirmRecording(desc: Sayable, file: Sayable): IvrStep[Option[Boolean]] = + askYesNo(desc & `is` & file & `Is that correct?`) + + def sayAndGetDigit(msgs: Sayable, wait: Int = 5000): IvrStep[Option[Char]] = + IvrStep.say(msgs, "0123456789#*").flatMap { + case Some(c) => IvrStep(Some(c)) + case None => IvrStep.waitForDigit(wait) + } + + /** + * None means * was pressed, signifying that inputting was canceled + */ + def sayAndHandleDigits[T](min: Int, max: Int, msgs: Sayable) + (handle: PartialFunction[String, T] = PartialFunction(identity[String])): IvrStep[Option[T]] = { + def validate(acc: String): Either[Sayable, T] = + if ((min == max) && (acc.length != min)) + Left(`You must enter ` & numberWords(min) & (if (min == 1) `digit` else `digits`)) + else if (acc.length < min) + Left(`You must enter at least` & numberWords(min) & (if (min == 1) `digit` else `digits`)) + else if (acc.length > max) + Left(`You cannot enter more than` & numberWords(max) & (if (max == 1) `digit` else `digits`)) + else + handle.andThen(Right(_)).applyOrElse(acc, (_: String) => Left(`That entry is not valid`)) + + def calcRes(acc: String = ""): Option[Char] => IvrStep[Either[Sayable, Option[T]]] = { + case Some(c) if acc.length + 1 < max && c.isDigit => + IvrStep.waitForDigit(5000).flatMap(calcRes(acc + c)) + case Some('*') => + IvrStep(Right(None)) + case x => + val s = acc + x.filter(_ != '#').mkString + IvrStep(validate(s).right.map(Option(_))) + } + + sayAndGetDigit(msgs) + .flatMap(calcRes()) + .flatMap { + case Right(x) => IvrStep(x) + case Left(msg) => IvrStep.say(msg) *> sayAndHandleDigits(min, max, msgs)(handle) + } + } + + def askYesNo(msgs: Sayable): IvrStep[Option[Boolean]] = + sayAndGetDigit(msgs & `Press 1 for yes, or 2 for no.`) flatMap { + case Some('1') => IvrStep(Some(true)) + case Some('2') => IvrStep(Some(false)) + case Some('*') => IvrStep(None) + case None => IvrStep.say(`Please make a selection`) *> askYesNo(msgs) + case _ => IvrStep.say(`That is not one of the choices.`) *> askYesNo(msgs) + } +} diff --git a/core/src/main/scala/simpleivr/IvrApi.scala b/core/src/main/scala/simpleivr/IvrApi.scala new file mode 100644 index 0000000..63e778f --- /dev/null +++ b/core/src/main/scala/simpleivr/IvrApi.scala @@ -0,0 +1,21 @@ +package simpleivr + +import java.io.File + +import cats.effect.IO + + +trait IvrApi { + def streamFile(pathAndName: String, interruptChars: String): Char + def recordFile(pathAndName: String, format: String, interruptChars: String, timeLimitMillis: Int, offset: Int, beep: Boolean, maxSilenceSecs: Int): Char + def waitForDigit(timeout: Int): IO[Option[Char]] + def dial(to: String, ringTimeout: Int, flags: String): Int + def amd(): Int + def getVar(name: String): Option[String] + def callerId: IO[String] + def waitForSilence(ms: Int, repeat: Int = 1, timeoutSec: Option[Int] = None): Unit + def monitor(file: File): Unit + def hangup(): Unit + def setAutoHangup(seconds: Int): IO[Unit] + def originate(dest: String, script: String, args: Seq[String]) +} diff --git a/core/src/main/scala/simpleivr/IvrChoices.scala b/core/src/main/scala/simpleivr/IvrChoices.scala new file mode 100644 index 0000000..7a1022a --- /dev/null +++ b/core/src/main/scala/simpleivr/IvrChoices.scala @@ -0,0 +1,98 @@ +package simpleivr + +import cats.free.Free +import cats.implicits._ + + +class IvrChoices(sayables: Sayables) extends Ivr(sayables) { + + import sayables._ + + + case class Choice[+A](key: Option[Char], label: Sayable, action: A) { + def map[B](f: A => B): Choice[B] = Choice(key, label, f(action)) + } + object Choice { + def apply[A](label: Sayable, action: A): Choice[A] = new Choice(None, label, action) + def apply[A](key: Char, label: Sayable, action: A): Choice[A] = new Choice(Some(key), label, action) + } + + def paginated[T](maximum: Int, choices: List[Choice[T]], fixedChoices: List[Choice[T]]): List[Choice[IvrStep[T]]] = { + def doPage(page: List[Choice[T]], rest: List[Choice[T]], back: List[Choice[T]]): List[Choice[IvrStep[T]]] = { + def askNextPage: IvrStep[T] = Free.defer { + val (p, r) = rest.splitAt(7) + val b = page ::: back + askChoice(ChoiceMenu(SayNothing, doPage(p, r, b))).flatten + } + + def askPrevPage: IvrStep[T] = Free.defer { + if (back.length >= maximum) { + val num = if (back.length <= 8) back.length else 7 + val (page2, back2) = back.splitAt(num) + val rest2 = page ::: rest + askChoice(ChoiceMenu(SayNothing, doPage(page2, rest2, back2))).flatten + } else { + val (page2, rest2) = choices.splitAt(maximum - 1) + val back2 = Nil + askChoice(ChoiceMenu(SayNothing, doPage(page2, rest2, back2))).flatten + } + } + + page.map(_ map IvrStep.apply) ++ + List(Choice('8', `For more choices`, askNextPage)).filter(_ => rest.nonEmpty) ++ + List(Choice('9', `For the previous choices`, askPrevPage)).filter(_ => back.nonEmpty) ++ + fixedChoices.map(_ map IvrStep.apply) + } + + doPage(choices.take(maximum - 1), choices.drop(maximum - 1), Nil) + } + + def assignNums[A](choices: List[Choice[A]]): List[Choice[A]] = { + val unused = "1234567890".toList.filterNot(n => choices exists (_.key contains n)) + println("assignNums: unused: " + unused.mkString) + for (c <- choices) println(s" ${c.key} / ${c.label}") + assignNums[A](choices, unused) + } + def assignNums[A](choices: List[Choice[A]], nums: List[Char]): List[Choice[A]] = choices match { + case Nil => Nil + case (c @ Choice(Some(n), _, _)) :: rest => c :: assignNums(rest, nums.filter(_ != n)) + case c :: rest => + if (nums.nonEmpty) + c.copy(key = Some(nums.head)) :: assignNums(rest, nums.tail) + else { + println("ERROR: No num available for choices: " + choices) + c :: rest + } + } + + case class ChoiceMenu[A](title: Sayable, choices: Seq[Choice[A]]) + object ChoiceMenu { + def apply[A](title: Sayable, dummy: Null = null)(choices: Choice[A]*): ChoiceMenu[A] = + new ChoiceMenu(title, choices) + } + + def askChoice[A](choiceMenu: ChoiceMenu[A]): IvrStep[A] = { + val menu = assignNums(choiceMenu.choices.toList) + val word: Char => Sayable = { + case '*' => `star` + case '#' => `pound` + case c => digitWords(c.toString) + } + val menuMsgs = menu.collect { + case Choice(Some(key), label, _) => Pause(750) & `Press` & word(key) & label + } + if (menuMsgs.length < menu.length) + Console.err.println(s"ERROR: Not all menu choices have keys in ${choiceMenu.title}: $menu") + + def loop: IvrStep[A] = sayAndGetDigit(choiceMenu.title & menuMsgs) flatMap { + case None => IvrStep.say(`Please make a selection` & Pause(750)) *> loop + case Some(c) => + menu.find(_.key.contains(c)) match { + case Some(choice) => IvrStep(choice.action) + case None => IvrStep.say(`That is not one of the choices.` & Pause(750)) *> loop + } + } + + loop + } +} diff --git a/core/src/main/scala/simpleivr/IvrCommand.scala b/core/src/main/scala/simpleivr/IvrCommand.scala new file mode 100644 index 0000000..4ab7b78 --- /dev/null +++ b/core/src/main/scala/simpleivr/IvrCommand.scala @@ -0,0 +1,106 @@ +package simpleivr + +import java.io.File + +import scala.language.higherKinds + +import cats.Functor +import cats.free.Free + + +sealed trait IvrCommand[A] extends Product { + def fold[F[_]](folder: IvrCommand.Folder[F]): F[A] + + object functor extends IvrCommandF[A] { + override type Intermediate = A + override def ivrCommand = IvrCommand.this + override def apply = identity + } + + def ivrStep: IvrStep[A] = Free.liftF(functor) +} + +object IvrCommand { + class Folder[F[_]] { + def default[T]: IvrCommand[T] => F[T] = (cmd: IvrCommand[T]) => throw new MatchError(cmd) + def streamFile(pathAndName: String, interruptChars: String): F[Char] = default(StreamFile(pathAndName, interruptChars)) + def recordFile(pathAndName: String, format: String, interruptChars: String, timeLimitMillis: Int, offset: Int, beep: Boolean, maxSilenceSecs: Int): F[Char] = default(RecordFile(pathAndName, format, interruptChars, timeLimitMillis, offset, beep, maxSilenceSecs)) + def waitForDigit(timeout: Int): F[Option[Char]] = default(WaitForDigit(timeout)) + def dial(to: String, ringTimeout: Int, flags: String): F[Int] = default(Dial(to, ringTimeout, flags)) + def amd: F[Int] = default(Amd) + def getVar(name: String): F[Option[String]] = default(GetVar(name)) + def callerId: F[String] = default(CallerId) + def waitForSilence(ms: Int, repeat: Int = 1, timeoutSec: Option[Int] = None): F[Unit] = default(WaitForSilence(ms, repeat, timeoutSec)) + def monitor(file: File): F[Unit] = default(Monitor(file)) + def hangup: F[Unit] = default(Hangup) + def setAutoHangup(seconds: Int): F[Unit] = default(SetAutoHangup(seconds)) + def say(sayable: Sayable, interruptDigits: String = ""): F[Option[Char]] = default(Say(sayable, interruptDigits)) + def originate(dest: String, script: String, args: Seq[String]): F[Unit] = default(Originate(dest, script, args)) + } + + case class StreamFile(pathAndName: String, interruptChars: String) extends IvrCommand[Char] { + override def fold[F[_]](folder: Folder[F]) = folder.streamFile(pathAndName, interruptChars) + } + case class RecordFile(pathAndName: String, + format: String, + interruptChars: String, + timeLimitMillis: Int, + offset: Int, + beep: Boolean, + maxSilenceSecs: Int) extends IvrCommand[Char] { + override def fold[F[_]](folder: Folder[F]) = + folder.recordFile(pathAndName, format, interruptChars, timeLimitMillis, offset, beep, maxSilenceSecs) + } + case class WaitForDigit(timeout: Int) extends IvrCommand[Option[Char]] { + override def fold[F[_]](folder: Folder[F]) = folder.waitForDigit(timeout) + } + case class Dial(to: String, ringTimeout: Int, flags: String) extends IvrCommand[Int] { + override def fold[F[_]](folder: Folder[F]) = folder.dial(to, ringTimeout, flags) + } + case object Amd extends IvrCommand[Int] { + override def fold[F[_]](folder: Folder[F]) = folder.amd + } + case class GetVar(name: String) extends IvrCommand[Option[String]] { + override def fold[F[_]](folder: Folder[F]) = folder.getVar(name) + } + case object CallerId extends IvrCommand[String] { + override def fold[F[_]](folder: Folder[F]) = folder.callerId + } + case class WaitForSilence(ms: Int, repeat: Int = 1, timeoutSec: Option[Int] = None) extends IvrCommand[Unit] { + override def fold[F[_]](folder: Folder[F]) = folder.waitForSilence(ms, repeat, timeoutSec) + } + case class Monitor(file: File) extends IvrCommand[Unit] { + override def fold[F[_]](folder: Folder[F]) = folder.monitor(file) + } + case object Hangup extends IvrCommand[Unit] { + override def fold[F[_]](folder: Folder[F]) = folder.hangup + } + case class SetAutoHangup(seconds: Int) extends IvrCommand[Unit] { + override def fold[F[_]](folder: Folder[F]) = folder.setAutoHangup(seconds) + } + case class Say(sayable: Sayable, interruptDigits: String = "") extends IvrCommand[Option[Char]] { + override def fold[F[_]](folder: Folder[F]) = folder.say(sayable, interruptDigits) + } + case class Originate(dest: String, script: String, args: Seq[String]) extends IvrCommand[Unit] { + override def fold[F[_]](folder: Folder[F]) = folder.originate(dest, script, args) + } +} + +trait IvrCommandF[A] { + type Intermediate + def ivrCommand: IvrCommand[Intermediate] + def apply: Intermediate => A + def map[B](g: A => B) = new IvrCommandF[B] { + type Intermediate = IvrCommandF.this.Intermediate + def ivrCommand = IvrCommandF.this.ivrCommand + def apply = (i: Intermediate) => g(IvrCommandF.this.apply(i)) + } + + def fold[F[_]](folder: IvrCommand.Folder[F])(implicit functor: Functor[F]): F[A] = + functor.map[Intermediate, A](ivrCommand.fold[F](folder))(apply) +} +object IvrCommandF { + implicit object functor extends Functor[IvrCommandF] { + override def map[A, B](fa: IvrCommandF[A])(f: A => B) = fa map f + } +} diff --git a/core/src/main/scala/simpleivr/IvrCommandInterpreter.scala b/core/src/main/scala/simpleivr/IvrCommandInterpreter.scala new file mode 100644 index 0000000..4b474f3 --- /dev/null +++ b/core/src/main/scala/simpleivr/IvrCommandInterpreter.scala @@ -0,0 +1,96 @@ +package simpleivr + +import java.io.File + +import scala.io.Source + +import cats.effect.IO + + +class IvrCommandInterpreter(ivrApi: IvrApi, sayables: Sayables) extends IvrCommand.Folder[IO] { + private def ensureSpeakFile(speak: Sayables#Speak) = IO { + if (!speak.path.exists()) { + println("Falling back to text2wave because audio file does not exist: " + speak.path.supportedAudioFiles) + val file = speak.path.wavFile + val text2wave = Runtime.getRuntime.exec("/usr/bin/text2wave -scale 1.5 -F 8000 -o " + file.getAbsolutePath) + val os = text2wave.getOutputStream + os.write(speak.msg.getBytes()) + os.flush() + os.close() + text2wave.waitFor() + Source.fromInputStream(text2wave.getInputStream).getLines() foreach println + Source.fromInputStream(text2wave.getErrorStream).getLines() foreach println + file.setWritable(true, false) + } + } + + /** + * `None` if no DTMF was received, otherwise `Some(d)` where `d` is the + * digit that was pressed. + */ + final def runSayable(sayable: Sayable, interrupt: String): IO[Option[Char]] = sayable match { + case SayNothing => + IO.pure(None) + + case Pause(ms) => + if (interrupt.nonEmpty) + waitForDigit(ms) + else + IO(Thread.sleep(ms)).map(_ => None) + + case play: Play => + streamFile(play.path.pathAndName, interrupt).map { + case 0 => None + case c => Some(c) + } + + case speak: Sayables#Speak => + ensureSpeakFile(speak) flatMap { _ => + println("Speaking: " + speak.msg) + runSayable(Play(speak.path), interrupt) + } + + case SayableSeq(messages) => + def loop(sayables: List[Sayable]): IO[Option[Char]] = sayables match { + case Nil => IO.pure(None) + case msg :: msgs => + runSayable(msg, interrupt).flatMap { + case Some(c) => IO.pure(Some(c)) + case None => loop(msgs) + } + } + + loop(messages) + } + override def streamFile(pathAndName: String, interruptChars: String) = IO { + ivrApi.streamFile(pathAndName, interruptChars) + } + override def recordFile(pathAndName: String, format: String, interruptChars: String, timeLimitMillis: Int, offset: Int, beep: Boolean, maxSilenceSecs: Int) = IO { + ivrApi.recordFile(pathAndName, format, interruptChars, timeLimitMillis, offset, beep, maxSilenceSecs) + } + override def waitForDigit(timeout: Int) = ivrApi.waitForDigit(timeout) + override def dial(to: String, ringTimeout: Int, flags: String) = IO { + ivrApi.dial(to, ringTimeout, flags) + } + override def amd = IO { + ivrApi.amd() + } + override def getVar(name: String) = IO { + ivrApi.getVar(name) + } + override def callerId = ivrApi.callerId + override def waitForSilence(ms: Int, repeat: Int, timeoutSec: Option[Int]) = IO { + ivrApi.waitForSilence(ms, repeat, timeoutSec) + } + override def monitor(file: File) = IO { + ivrApi.monitor(file) + } + override def hangup = IO { + ivrApi.hangup() + } + override def setAutoHangup(seconds: Int) = ivrApi.setAutoHangup(seconds) + override def say(sayable: Sayable, interruptDigits: String) = runSayable(sayable, interruptDigits) + override def originate(dest: String, script: String, args: Seq[String]) = IO { + ivrApi.originate(dest, script, args) + } +} diff --git a/core/src/main/scala/simpleivr/IvrStepRunner.scala b/core/src/main/scala/simpleivr/IvrStepRunner.scala new file mode 100644 index 0000000..6e29de3 --- /dev/null +++ b/core/src/main/scala/simpleivr/IvrStepRunner.scala @@ -0,0 +1,9 @@ +package simpleivr + +import cats.effect.IO + + +class IvrStepRunner(api: IvrApi, sayables: Sayables) { + def runIvrCommand[A](cmd: IvrCommandF[A]): IO[A] = cmd.fold[IO](new IvrCommandInterpreter(api, sayables)) + final def runIvrStep[A](step: IvrStep[A]): IO[A] = step.runM(runIvrCommand) +} diff --git a/core/src/main/scala/simpleivr/Sayable.scala b/core/src/main/scala/simpleivr/Sayable.scala new file mode 100644 index 0000000..f1348ce --- /dev/null +++ b/core/src/main/scala/simpleivr/Sayable.scala @@ -0,0 +1,54 @@ +package simpleivr + +import java.io.File + +import scala.language.implicitConversions + +import sourcecode.Name + + +sealed trait Sayable { + final def &(that: Sayable) = (this, that) match { + case (SayNothing, _) => that + case (_, SayNothing) => this + case (SayableSeq(msgs1), SayableSeq(msgs2)) => SayableSeq(msgs1 ++ msgs2) + case (SayableSeq(msgs1), msg2) => SayableSeq(msgs1 :+ msg2) + case (msg1, SayableSeq(msgs2)) => SayableSeq(msg1 +: msgs2) + case (msg1, msg2) => SayableSeq(List(msg1, msg2)) + } +} +object Sayable { + implicit def fromSeqSayable(s: Seq[Sayable]): Sayable = SayableSeq(s.toList) +} + +object SayNothing extends Sayable + +case class Pause(ms: Int) extends Sayable + +case class SayableSeq(messages: List[Sayable]) extends Sayable + +object Play { + def ifExists(path: AudioPath): Option[Play] = Some(path).filter(_.exists()).map(new Play(_)) +} +case class Play(path: AudioPath) extends Sayable + +trait Speaks { + def ttsDir: File + + protected def Speak(msg: String) = new Speak(msg) + object Speak { + def unapply(s: Speak) = Some(s.msg) + } + class Speak private[Speaks](val msg: String) extends Sayable { + lazy val path = new AudioPath(ttsDir, msg.replaceAll("\\W", "-").trim.toLowerCase) + override def toString = s"Speak($msg)" + } + + protected def speak(implicit name: Name) = Speak(name.value) + + def speaks: List[Speak] = + getClass.getMethods.toList + .filter(classOf[Speak] isAssignableFrom _.getReturnType) + .filter(_.getParameterCount == 0) + .map(_.invoke(this).asInstanceOf[Speak]) +} diff --git a/core/src/main/scala/simpleivr/Sayables.scala b/core/src/main/scala/simpleivr/Sayables.scala new file mode 100644 index 0000000..5ee9212 --- /dev/null +++ b/core/src/main/scala/simpleivr/Sayables.scala @@ -0,0 +1,118 @@ +package simpleivr + +import java.io.File +import java.time.LocalTime + + +abstract class Sayables(val ttsDir: File) extends Speaks { + lazy val + `An error occurred.`, + `Please say`, + `after the tone, and press pound when finished.`, + `is`, + `Is that correct?`, + `You must enter `, + `You must enter at least`, + `Please make a selection`, + `That is not one of the choices.`, + `Press 1 for yes, or 2 for no.`, + `Press`, + `star`, + `pound`, + `For more choices`, + `For the previous choices`, + `for any of the following:`, + `To return to the previous menu`, + `digit`, + `digits`, + `You cannot enter more than`, + `That entry is not valid`, + `and`, + `or`, + `now`, + `Please enter`, + `A`, + `M`, + `O`, + `P`, + `clock` + = speak + + lazy val + `zero`, `one`, `two`, `three`, `four`, `five`, + `six`, `seven`, `eight`, `nine`, `ten`, + `eleven`, `twelve`, `thirteen`, `fourteen`, `fifteen`, + `sixteen`, `seventeen`, `eighteen`, `nineteen`, `twenty`, + `thirty`, `forty`, `fifty`, `sixty`, `seventy`, `eighty`, `ninety`, + `hundred`, `thousand`, `million`, `negative` + = speak + + + def numberWords(number: Int): Sayable = { + /* + * Based on an article by Richard Carr, at http://www.blackwasp.co.uk/NumberToWords.aspx + */ + val smallNumbers = collection.IndexedSeq( + `one`, `two`, `three`, `four`, `five`, `six`, `seven`, `eight`, `nine`, `ten`, + `eleven`, `twelve`, `thirteen`, `fourteen`, `fifteen`, `sixteen`, `seventeen`, `eighteen`, `nineteen` + ) + + // Tens number names from twenty upwards + val _tens = collection.IndexedSeq( + `twenty`, `thirty`, `forty`, `fifty`, `sixty`, `seventy`, `eighty`, `ninety` + ) + + // Scale number names for use during recombination + val scaleNumbers = List(SayNothing, `thousand`, `million`) + + def ifs(cond: Boolean)(s: => Sayable) = if (cond) s else SayNothing + + if (number == 0) `zero` else { + @annotation.tailrec def split(num: Int)(agg: List[Int]): List[Int] = + if (num == 0) agg + else split(num / 1000)((num % 1000) :: agg) + + val digitGroups = split(math.abs(number))(Nil).reverse + + val groupText = digitGroups map { num => + val hundreds = num / 100 + val tensUnits = num % 100 + val tens = tensUnits / 10 + val units = tensUnits % 10 + + val text = ifs(hundreds != 0)(smallNumbers(hundreds - 1) & `hundred` & ifs(tensUnits != 0)(`and`)) & + ifs(tens >= 2)(_tens(tens - 2) & ifs(units != 0)(smallNumbers(units - 1))) & + ifs(tens < 2 && tensUnits != 0)(smallNumbers(tensUnits - 1)) + + (num, text) + } + + def render(groups: List[(Int, Sayable)], scales: List[Sayable], first: Boolean): Sayable = groups match { + case Nil => + SayNothing + case (num, text) :: rest => + val next = render(rest, scales.tail, first = false) + val cur = ifs(num > 0)(text & scales.head) + val sep = ifs(SayNothing != next && SayNothing != cur)(Pause(250) & ifs(first && num > 0 && num < 100)(`and`)) + next & sep & cur + } + + ifs(number < 0)(`negative`) & render(groupText, scaleNumbers, first = true) + } + } + + def digitWords(s: String) = s.toSeq.map(c => numberWords(c - '0')) + + def charWord(c: Char): Sayable = c match { + case '#' => `pound` + case '*' => `star` + case chr if chr >= '0' && chr <= '9' => digitWords(chr.toString) + // TODO other case? log error? use better type than Char? + } + + def timeWords(time: LocalTime) = + numberWords((time.getHour + 11) % 12 + 1) & + (if (time.getMinute < 10) `O` else SayNothing) & + (if (time.getMinute == 0) `clock` else numberWords(time.getMinute)) & + (if (time.getHour < 12) `A` else `P`) & `M` +} diff --git a/core/src/main/scala/simpleivr/package.scala b/core/src/main/scala/simpleivr/package.scala new file mode 100644 index 0000000..d11181f --- /dev/null +++ b/core/src/main/scala/simpleivr/package.scala @@ -0,0 +1,75 @@ +import java.io.File + +import cats.Functor +import cats.free.Free + + +package object simpleivr { + type IvrStep[A] = Free[IvrCommandF, A] + + object IvrStep extends IvrCommand.Folder[IvrStep] { + def apply[A](result: A): IvrStep[A] = Free.pure(result) + def unit = apply(()) + override def default[T] = _.ivrStep + } + + private trait Cont[A, R] { + type L[I] = (I => IvrStep[A]) => R + } + type FoldCmd[A, R] = IvrCommand.Folder[Cont[A, R]#L] + + implicit class IvrStepExtensionMethods[A](private val self: IvrStep[A]) { + def get: Option[A] = self.fold(Some(_), _ => None) + + final def next: Either[IvrStep[A], A] = { + val fwd = self.step + fwd.fold(Right(_), x => Left(fwd)) + } + + private def contFunctor[R]: Functor[Cont[A, R]#L] = new Functor[Cont[A, R]#L] { + override def map[T, U](ft: Cont[A, R]#L[T])(f: T => U) = { k => + ft(t => k(f(t))) + } + } + + def runNext[R](folder: FoldCmd[A, R]): Either[R, A] = + self.resume.left.map(_.fold[Cont[A, R]#L](folder)(contFunctor).apply(identity)) + + private def throwMatchErr: IvrCommand[_] => Any => Nothing = cmd => _ => throw new MatchError(cmd) + def foldNext[R](streamFileF: IvrCommand.StreamFile => (Char => IvrStep[A]) => R = throwMatchErr, + recordFileF: IvrCommand.RecordFile => (Char => IvrStep[A]) => R = throwMatchErr, + waitForDigitF: IvrCommand.WaitForDigit => (Option[Char] => IvrStep[A]) => R = throwMatchErr, + dialF: IvrCommand.Dial => (Int => IvrStep[A]) => R = throwMatchErr, + amdF: (Int => IvrStep[A]) => R = throwMatchErr(IvrCommand.Amd), + getVarF: IvrCommand.GetVar => (Option[String] => IvrStep[A]) => R = throwMatchErr, + callerIdF: (String => IvrStep[A]) => R = throwMatchErr(IvrCommand.CallerId), + waitForSilenceF: IvrCommand.WaitForSilence => (() => IvrStep[A]) => R = throwMatchErr, + monitorF: IvrCommand.Monitor => (() => IvrStep[A]) => R = throwMatchErr, + hangupF: (() => IvrStep[A]) => R = throwMatchErr(IvrCommand.Hangup), + setAutoHangupF: IvrCommand.SetAutoHangup => (() => IvrStep[A]) => R = throwMatchErr, + sayF: IvrCommand.Say => (Option[Char] => IvrStep[A]) => R = throwMatchErr, + originateF: IvrCommand.Originate => (() => IvrStep[A]) => R = throwMatchErr, + ): Either[R, A] = runNext(new FoldCmd[A, R] { + override def streamFile(pathAndName: String, interruptChars: String) = + streamFileF(IvrCommand.StreamFile(pathAndName, interruptChars)) + override def recordFile(pathAndName: String, format: String, interruptChars: String, timeLimitMillis: Int, offset: Int, beep: Boolean, maxSilenceSecs: Int) = + recordFileF(IvrCommand.RecordFile(pathAndName, format, interruptChars, timeLimitMillis, offset, beep, maxSilenceSecs)) + override def waitForDigit(timeout: Int) = waitForDigitF(IvrCommand.WaitForDigit(timeout)) + override def dial(to: String, ringTimeout: Int, flags: String) = dialF(IvrCommand.Dial(to, ringTimeout, flags)) + override def amd = amdF + override def getVar(name: String) = getVarF(IvrCommand.GetVar(name)) + override def callerId = callerIdF + override def waitForSilence(ms: Int, repeat: Int, timeoutSec: Option[Int]) = f => waitForSilenceF(IvrCommand.WaitForSilence(ms, repeat, timeoutSec))(() => f(())) + override def monitor(file: File) = f => monitorF(IvrCommand.Monitor(file))(() => f(())) + override def hangup = f => hangupF(() => f(())) + override def setAutoHangup(seconds: Int) = f => setAutoHangupF(IvrCommand.SetAutoHangup(seconds))(() => f(())) + override def say(sayable: Sayable, interruptDigits: String) = sayF(IvrCommand.Say(sayable, interruptDigits)) + override def originate(dest: String, script: String, args: Seq[String]) = f => originateF(IvrCommand.Originate(dest, script, args))(() => f(())) + }) + } + + implicit class IvrStepOptionExtensionMethods[A](private val self: IvrStep[Option[A]]) extends AnyVal { + final def flatMapOpt[B](none: => B = ())(some: A => IvrStep[B]) = + self.flatMap(_.fold[IvrStep[B]](IvrStep(none))(some)) + } +} diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..8b697bb --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.1.0 diff --git a/testing/src/main/scala/simpleivr/testing/StepTestingHelpers.scala b/testing/src/main/scala/simpleivr/testing/StepTestingHelpers.scala new file mode 100644 index 0000000..46b56f7 --- /dev/null +++ b/testing/src/main/scala/simpleivr/testing/StepTestingHelpers.scala @@ -0,0 +1,50 @@ +package simpleivr.testing + +import org.scalatest.exceptions.TestFailedException +import simpleivr.{FoldCmd, IvrStep, Sayable} + + +object StepTestingHelpers { + implicit class mustMatchOps[A](a: A) { + /** + * @example + * {{{ + * Person("jack", 50) mustMatch { + * case Person(_, age) => age shouldBe 50 + * } + * }}} + */ + def mustMatch[R](pf: PartialFunction[A, R]): R = + pf.applyOrElse[A, R](a, _ => throw new TestFailedException(s"Unexpected value: $a", 6)) + } + implicit class nextStepEitherOps[A, B](e: Either[A, B]) { + def next[R](f: A => R): R = e mustMatch { + case Left(x) => f(x) + } + } + + type InpCont[A] = Option[Char] => IvrStep[A] + + implicit class pressCharOp[R](f: InpCont[R]) { + def press(presses: Seq[Char]) = presses match { + case Seq() => Left(f(None)) + case Seq(oneChar) => Left(f(Some(oneChar))) + case Seq(first, rest @ _*) => f(Some(first)) press rest + } + def press(c: Char) = if (c == ',') f(None) else f(Some(c)) + def nopress = f(None) + } + + implicit class pressSeqOp[A](step: IvrStep[A]) { + private def folder(p: Option[Char], rest: List[Char]) = new FoldCmd[A, Either[IvrStep[A], A]] { + override def default[T] = _ => _ => Left(step) + override def say(sayable: Sayable, interruptDigits: String) = _(p).press(rest) + override def waitForDigit(timeout: Int) = _(p).press(rest) + } + def press(presses: Seq[Char]): Either[IvrStep[A], A] = presses.toList match { + case Nil => step.next + case ',' :: rest => step.runNext(folder(None, rest)).joinLeft + case c :: rest => step.runNext(folder(Some(c), rest)).joinLeft + } + } +} diff --git a/testing/src/main/scala/simpleivr/testing/TestIvr.scala b/testing/src/main/scala/simpleivr/testing/TestIvr.scala new file mode 100644 index 0000000..1186a4f --- /dev/null +++ b/testing/src/main/scala/simpleivr/testing/TestIvr.scala @@ -0,0 +1,45 @@ +package simpleivr.testing + +import simpleivr.{IvrChoices, Pause, Sayable, SayableSeq, Sayables} + + +class TestIvr(sayables: Sayables) extends IvrChoices(sayables) { + object Sayables { + private def flatten(msgs: Seq[Sayable]): Seq[Sayable] = msgs.flatMap { + case s: SayableSeq => flatten(s.messages) + case s => List(s) + } + + def unapplySeq(sayable: Sayable): Option[Seq[Any]] = Some(sayable match { + case s: SayableSeq => flatten(s.messages) flatMap (x => unapplySeq(x).toList.flatten) + case sayables.Speak(msg) => List(msg) + case s => List(s) + }) + } + + def getChoices(in: Seq[Any]): Seq[(Char, Seq[String])] = { + def readStrings(xs: List[Any], acc: List[String]): (List[Any], List[String]) = xs match { + case (s: String) :: rest => readStrings(rest, acc :+ s) + case _ => (xs, acc) + } + + object DigitWord { + def unapply(s: String) = s match { + case "one" => Some('1') + case "two" => Some('2') + case _ => None + } + } + def extract(xs: List[Any], cur: Seq[(Char, Seq[String])]): Seq[(Char, Seq[String])] = xs match { + case Pause(_) :: "Press" :: ys => extract(ys, cur) + case DigitWord(d) :: ys => + readStrings(ys, Nil) match { + case (Nil, strings) => cur :+ ((d, strings)) + case (rest, strings) => extract(rest, cur :+ ((d, strings))) + } + case _ => cur + } + + extract(in.toList, Nil) + } +} diff --git a/testing/src/main/scala/simpleivr/testing/TestIvrApi.scala b/testing/src/main/scala/simpleivr/testing/TestIvrApi.scala new file mode 100644 index 0000000..a404840 --- /dev/null +++ b/testing/src/main/scala/simpleivr/testing/TestIvrApi.scala @@ -0,0 +1,22 @@ +package simpleivr.testing + +import java.io.File + +import cats.effect.IO +import simpleivr.IvrApi + + +object TestIvrApi extends IvrApi { + def streamFile(pathAndName: String, interruptChars: String): Char = 0 + def waitForDigit(timeout: Int): IO[Option[Char]] = IO.pure(None) + def recordFile(pathAndName: String, format: String, interruptChars: String, timeLimitMillis: Int, offset: Int, beep: Boolean, maxSilenceSecs: Int): Char = 0 + def dial(to: String, ringTimeout: Int, flags: String): Int = 0 + def amd(): Int = 0 + def getVar(name: String): Option[String] = None + def callerId: IO[String] = IO.pure("") + def waitForSilence(ms: Int, repeat: Int = 1, timeoutSec: Option[Int] = None): Unit = {} + def monitor(file: File): Unit = {} + def hangup(): Unit = {} + def setAutoHangup(seconds: Int): IO[Unit] = IO.pure(()) + def originate(dest: String, script: String, args: Seq[String]): Unit = () +}