From 0536be7137b8e14da45d51dcee31417ad8279cd2 Mon Sep 17 00:00:00 2001 From: Ben Weedon Date: Sat, 28 Oct 2017 20:00:10 -0700 Subject: [PATCH] Support reporting raids with SMS If a subscribed user sends an SMS starting with "report", the text after "report" will be interpreted as an address and sent out as an alert to all subscribers. This operates through the same code path as the alert form on the site. Right now, this only supports plain text addresses. We will be able to integrate it with geocoding in the future to acquire coordinates, as well as correct typos. --- app/controllers/AlertController.scala | 13 +--- app/controllers/TwilioController.scala | 18 ++++- app/models/Alert.scala | 19 ++++- app/models/SubscriberTransitions.scala | 24 ++++++- conf/messages | 1 + test/models/SubscriberTransitionsSpec.scala | 79 +++++++++++++++++---- 6 files changed, 126 insertions(+), 28 deletions(-) diff --git a/app/controllers/AlertController.scala b/app/controllers/AlertController.scala index 7b68652..6bab741 100644 --- a/app/controllers/AlertController.scala +++ b/app/controllers/AlertController.scala @@ -2,12 +2,10 @@ package controllers import javax.inject.{Inject, Singleton} -import com.twilio.rest.api.v2010.account.Message -import com.twilio.`type`.PhoneNumber import models.SubscriberRepository import play.api.data.Form import play.api.data.Forms._ -import play.api.i18n.{Lang, MessagesApi} +import play.api.i18n.MessagesApi import play.api.mvc.{AbstractController, ControllerComponents} import scala.concurrent.{ExecutionContext, Future} @@ -33,14 +31,7 @@ class AlertController @Inject()(cc: ControllerComponents, repo: SubscriberReposi }, alert => { repo.listActive().map { subscribers => - val messages = subscribers.map { subscriber => - implicit val lang: Lang = Lang.get("en").get - Message - .creator(new PhoneNumber(subscriber.phone), // to - new PhoneNumber(sys.env("TWILIO_PHONE")), // from - messagesApi("action_alert", alert.address)) - .create() - } + val messages = alert.sendAlert(subscribers, messagesApi) Redirect(routes.HomeController.index()) .flashing("success" -> ("Done! Messages sent with IDs " + messages.map(_.getSid()).mkString(","))) } diff --git a/app/controllers/TwilioController.scala b/app/controllers/TwilioController.scala index 83c3128..fa7eac3 100644 --- a/app/controllers/TwilioController.scala +++ b/app/controllers/TwilioController.scala @@ -3,6 +3,8 @@ package controllers import javax.inject.{Inject, Singleton} import com.twilio.twiml.{Body, Message, MessagingResponse} +import models.{Alert} +import models.{SubscriberAction, AlertAction} import models.{SubscriberRepository, SubscriberTransitions} import play.api.data.Form import play.api.data.Forms._ @@ -28,7 +30,11 @@ class TwilioController @Inject()(cc: ControllerComponents, repo: SubscriberRepos repo.getOrCreate(twilioData.from).map { subscriber => implicit val lang: Lang = Lang.get("en").get val subscriberTransition = new SubscriberTransitions(subscriber) - val (responseMessage, updatedSubscriber) = subscriberTransition.receiveInput(twilioData.body) + val action = subscriberTransition.action(twilioData.body) + if (action.isDefined) { + performAction(action.get) + } + val (responseMessage, updatedSubscriber) = subscriberTransition.transition(twilioData.body) if (updatedSubscriber.isDefined) { repo.update(updatedSubscriber.get) } @@ -41,4 +47,14 @@ class TwilioController @Inject()(cc: ControllerComponents, repo: SubscriberRepos Ok(response.toXml()).as("application/xml") } } + + def performAction(action: SubscriberAction) = { + action match { + case AlertAction(addr) => + val alert = Alert(addr) + repo.listActive().map { subscribers => + alert.sendAlert(subscribers, messagesApi) + } + } + } } diff --git a/app/models/Alert.scala b/app/models/Alert.scala index 1c37c17..74d7a7c 100644 --- a/app/models/Alert.scala +++ b/app/models/Alert.scala @@ -1,3 +1,20 @@ package models -case class Alert(address: String) +import com.twilio.rest.api.v2010.account.Message +import com.twilio.`type`.PhoneNumber +import play.api.i18n.{Lang, MessagesApi} + +import scala.concurrent.ExecutionContext + +case class Alert(address: String) { + def sendAlert(subscribers: Seq[Subscriber], messagesApi: MessagesApi) = { + subscribers.map { subscriber => + implicit val lang: Lang = Lang.get("en").get + Message + .creator(new PhoneNumber(subscriber.phone), // to + new PhoneNumber(sys.env("TWILIO_PHONE")), // from + messagesApi("action_alert", address)) + .create() + } + } +} diff --git a/app/models/SubscriberTransitions.scala b/app/models/SubscriberTransitions.scala index 5da6d26..da94d12 100644 --- a/app/models/SubscriberTransitions.scala +++ b/app/models/SubscriberTransitions.scala @@ -2,8 +2,28 @@ package models import models.SubscriberTransitions.{Complete, SelectingLanguage, SubscriptionState, Unsubscribed} +sealed trait SubscriberAction +case class AlertAction(addr: String) extends SubscriberAction + class SubscriberTransitions(subscriber : Subscriber) { - def receiveInput(input : String):(String, Option[Subscriber]) = { + def action(input: String): Option[SubscriberAction] = { + val trimmedInput = input.trim + val currentstate = SubscriberTransitions.withName(subscriber.state); + currentstate match { + case Unsubscribed => + None + case SelectingLanguage => + None + case Complete => + if (trimmedInput.length > 6 && trimmedInput.substring(0, 6).equalsIgnoreCase("report")) { + return Some(AlertAction(trimmedInput.substring(6).trim)) + } else { + return None + } + } + } + + def transition(input : String):(String, Option[Subscriber]) = { val trimmedInput = input.trim val currentstate = SubscriberTransitions.withName(subscriber.state); currentstate match { @@ -28,6 +48,8 @@ class SubscriberTransitions(subscriber : Subscriber) { } else if (trimmedInput.equalsIgnoreCase("leave")) { val newSubscriber = subscriber.copy(state = Unsubscribed.name) return ("unsubscribed_msg", Some(newSubscriber)) + } else if (trimmedInput.length > 6 && trimmedInput.substring(0, 6).equalsIgnoreCase("report")) { + return ("report_msg", None) } else { return ("error_msg", None) } diff --git a/conf/messages b/conf/messages index a379ddc..84537d3 100644 --- a/conf/messages +++ b/conf/messages @@ -12,3 +12,4 @@ subscribe_help_msg=We were unable to understand your message. Text "join" to sub unsubscribed_msg=You have been unsubscribed from receiving text message alerts. You may re-join by texting "join" to this number. unsupported_lang_msg=We were unable to understand your language choice. welcome_msg=Thank you for joining the WA Immigrant Solidarity Network's Text Message alert system to provide alerts when there is immigration (ICE) activity in our state. +report_msg=Thank you for reporting ICE activity. An alert will be sent out to all subscribers. diff --git a/test/models/SubscriberTransitionsSpec.scala b/test/models/SubscriberTransitionsSpec.scala index 170b1bb..1ad5dab 100644 --- a/test/models/SubscriberTransitionsSpec.scala +++ b/test/models/SubscriberTransitionsSpec.scala @@ -5,46 +5,47 @@ import org.scalatest.{FunSpec, Matchers} class SubscriberTransitionsSpec extends FunSpec with Matchers { describe("The subscriber transitions") { describe ("when a user is unsubscribed") { - val subscriber = Subscriber(1, "555-555-5555", None, SubscriberTransitions.Unsubscribed.name); + val subscriber = Subscriber(1, "555-555-5555", None, SubscriberTransitions.Unsubscribed.name) val subscriberTransitions = new SubscriberTransitions(subscriber) it ("should return the basic help message if an unknown message is passed") { - val (message, newSubscriber) = subscriberTransitions.receiveInput("Hi") + val (message, newSubscriber) = subscriberTransitions.transition("Hi") message shouldEqual "subscribe_help_msg" newSubscriber shouldBe None } it ("should transition the subscriber to selecting language if join is sent") { - val (message, newSubscriber) = subscriberTransitions.receiveInput("join") + val (message, newSubscriber) = subscriberTransitions.transition("join") message shouldEqual "language_selection_msg" newSubscriber.get.state shouldEqual SubscriberTransitions.SelectingLanguage.name } it ("should transition the subscriber to selecting language if join with surrounding whitespace is sent") { - val (message, newSubscriber) = subscriberTransitions.receiveInput(" \t\tjoin \n \n\t\n ") + val (message, newSubscriber) = subscriberTransitions.transition(" \t\tjoin \n \n\t\n ") message shouldEqual "language_selection_msg" newSubscriber.get.state shouldEqual SubscriberTransitions.SelectingLanguage.name } } + describe("when a user is choosing a language") { - val subscriber = Subscriber(1, "555-555-5555", None, SubscriberTransitions.SelectingLanguage.name); + val subscriber = Subscriber(1, "555-555-5555", None, SubscriberTransitions.SelectingLanguage.name) val subscriberTransitions = new SubscriberTransitions(subscriber) it("should return a help message if an unknown language is selected") { - val (message, newSubscriber) = subscriberTransitions.receiveInput("Hi") + val (message, newSubscriber) = subscriberTransitions.transition("Hi") message shouldEqual "unsupported_lang_msg" newSubscriber shouldBe None } it("should transition the subscriber to completed if a language is selected") { - val (message, newSubscriber) = subscriberTransitions.receiveInput("1") + val (message, newSubscriber) = subscriberTransitions.transition("1") message shouldEqual "confirmation_msg" newSubscriber.get.state shouldEqual SubscriberTransitions.Complete.name newSubscriber.get.language shouldEqual Some("eng") } it("should transition the subscriber to completed if a language with surrounding whitespace is selected") { - val (message, newSubscriber) = subscriberTransitions.receiveInput(" \n\n1 \t\n ") + val (message, newSubscriber) = subscriberTransitions.transition(" \n\n1 \t\n ") message shouldEqual "confirmation_msg" newSubscriber.get.state shouldEqual SubscriberTransitions.Complete.name newSubscriber.get.language shouldEqual Some("eng") @@ -52,38 +53,88 @@ class SubscriberTransitionsSpec extends FunSpec with Matchers { } describe("when a user has completed the subscription process") { - val subscriber = Subscriber(1, "555-555-5555", None, SubscriberTransitions.Complete.name); + val subscriber = Subscriber(1, "555-555-5555", None, SubscriberTransitions.Complete.name) val subscriberTransitions = new SubscriberTransitions(subscriber) it ("should return information on how to unsubscribe or change languages if an unknown message is sent while subscribed") { - val (message, newSubscriber) = subscriberTransitions.receiveInput("Hi") + val (message, newSubscriber) = subscriberTransitions.transition("Hi") message shouldEqual "error_msg" newSubscriber shouldBe None } it ("should return to the select language state if the appropriate message is sent") { - val (message, newSubscriber) = subscriberTransitions.receiveInput("change language") + val (message, newSubscriber) = subscriberTransitions.transition("change language") message shouldEqual "language_selection_msg" newSubscriber.get.state shouldEqual SubscriberTransitions.SelectingLanguage.name } it ("should return to the select language state if the appropriate message with surrounding whitespace is sent") { - val (message, newSubscriber) = subscriberTransitions.receiveInput(" \t change language \n") + val (message, newSubscriber) = subscriberTransitions.transition(" \t change language \n") message shouldEqual "language_selection_msg" newSubscriber.get.state shouldEqual SubscriberTransitions.SelectingLanguage.name } it ("should unsubscribe the user if the appropriate message is sent") { - val (message, newSubscriber) = subscriberTransitions.receiveInput("leave") + val (message, newSubscriber) = subscriberTransitions.transition("leave") message shouldEqual "unsubscribed_msg" newSubscriber.get.state shouldEqual SubscriberTransitions.Unsubscribed.name } it ("should unsubscribe the user if the appropriate message with surrounding whitespace is sent") { - val (message, newSubscriber) = subscriberTransitions.receiveInput(" \n leave \t") + val (message, newSubscriber) = subscriberTransitions.transition(" \n leave \t") message shouldEqual "unsubscribed_msg" newSubscriber.get.state shouldEqual SubscriberTransitions.Unsubscribed.name } + + it ("should stay in the complete state if a report is sent") { + val (message, newSubscriber) = subscriberTransitions.transition("report 1111 My Address Lane") + message shouldEqual "report_msg" + newSubscriber shouldBe None + } + + it ("should stay in the complete state if a report with surrounding whitespace is sent") { + val (message, newSubscriber) = subscriberTransitions.transition(" \t\n report 1111 My Address Lane\n\t\n ") + message shouldEqual "report_msg" + newSubscriber shouldBe None + } + } + } + + describe("The subscriber actions") { + describe ("when a user is unsubscribed") { + val subscriber = Subscriber(1, "555-555-5555", None, SubscriberTransitions.Unsubscribed.name) + val subscriberTransitions = new SubscriberTransitions(subscriber) + + it("should return None") { + val action = subscriberTransitions.action("Hi") + action shouldBe None + } + } + + describe("when a user is choosing a language") { + val subscriber = Subscriber(1, "555-555-5555", None, SubscriberTransitions.SelectingLanguage.name) + val subscriberTransitions = new SubscriberTransitions(subscriber) + + it("should return None") { + val action = subscriberTransitions.action("Hi") + action shouldBe None + } + } + + describe("when a user has completed the subscription process") { + val subscriber = Subscriber(1, "555-555-5555", None, SubscriberTransitions.Complete.name) + val subscriberTransitions = new SubscriberTransitions(subscriber) + + it ("should return None if a report is not sent") { + val action = subscriberTransitions.action("Hi") + action shouldBe None + } + + it ("should return the alert action if a report is sent") { + val action = subscriberTransitions.action("report 1111 My Address Lane") + action shouldBe 'isDefined + action.get shouldEqual AlertAction("1111 My Address Lane") + } } } }