app/updates/BreakingNewsUpdate.scala (285 lines of code) (raw):

package updates import java.net.URI import java.security.InvalidParameterException import java.util.UUID import com.gu.mobile.notifications.client.models.Importance.Importance import com.gu.mobile.notifications.client.models.Topic._ import com.gu.mobile.notifications.client.models.TopicTypes.Breaking import com.gu.mobile.notifications.client.models._ import conf.ApplicationConfiguration import logging.Logging import org.apache.commons.text.StringEscapeUtils import play.api.libs.json._ import play.api.libs.ws.WSClient import play.api.mvc.Result import play.api.mvc.Results.{InternalServerError, Ok} import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.Future import scala.util.control.NonFatal import scala.util.{Failure, Success, Try} class InvalidNotificationContentType(msg: String) extends Throwable(msg) {} object BreakingNewsUpdate { val SportGlobalTopicName = "global-sport" val SportBreakingNewsTopics = List( BreakingNewsSportUk, BreakingNewsSportUs, BreakingNewsSportAu, BreakingNewsSportInternational, BreakingNewsSportEurope ) val UsElectionsGlobalTopicName = "global-us-election" val UsElectionsBreakingNewsTopics = List( BreakingNewsUsElectionUk, BreakingNewsUsElectionUs, BreakingNewsUsElectionAu, BreakingNewsUsElectionInternational, BreakingNewsUsElectionEurope ) def createPayload( trail: ClientHydratedTrail, email: String ): BreakingNewsPayload = { val title = trail.topic match { case Some("uk-general-election") => Some("UK general election") case Some(topic) if (SportBreakingNewsTopics.map(_.name) :+ SportGlobalTopicName) .contains(topic) => Some("Sport breaking news") case Some(topic) if (UsElectionsBreakingNewsTopics.map( _.name ) :+ UsElectionsGlobalTopicName).contains(topic) => Some("US election") case _ => None } BreakingNewsPayload( title = title, message = Some(StringEscapeUtils.unescapeHtml4(trail.headline)), thumbnailUrl = trail.thumb.map { new URI(_) }, sender = email, link = createLinkDetails(trail), imageUrl = trail.imageHide match { case Some(true) => None case _ => trail.image.map { new URI(_) } }, importance = parseImportance(trail.group), topic = parseTopic(trail.topic), debug = false, dryRun = None ) } private def parseImportance(name: Option[String]): Importance = { name match { case Some("major") => Importance.Major case Some("minor") => Importance.Minor case Some("") => Importance.Minor case Some(importance) => throw new InvalidParameterException(s"Invalid importance $importance") case None => Importance.Minor } } private def parseTopic(topic: Option[String]): List[Topic] = { topic match { case Some("global") => List( BreakingNewsUk, BreakingNewsUs, BreakingNewsAu, BreakingNewsInternational, BreakingNewsEurope ) case Some("au") => List(BreakingNewsAu) case Some("international") => List(BreakingNewsInternational) case Some("uk") => List(BreakingNewsUk) case Some("us") => List(BreakingNewsUs) case Some("europe") => List(BreakingNewsEurope) case Some("uk-sport") => List(BreakingNewsSportUk) case Some("us-sport") => List(BreakingNewsSportUs) case Some("au-sport") => List(BreakingNewsSportAu) case Some("europe-sport") => List(BreakingNewsSportEurope) case Some("international-sport") => List(BreakingNewsSportInternational) case Some(SportGlobalTopicName) => SportBreakingNewsTopics case Some("uk-general-election") => List(BreakingNewsElection) case Some(UsElectionsGlobalTopicName) => UsElectionsBreakingNewsTopics case Some("uk-us-election") => List(BreakingNewsUsElectionUk) case Some("us-us-election") => List(BreakingNewsUsElectionUs) case Some("au-us-election") => List(BreakingNewsUsElectionAu) case Some("europe-us-election") => List(BreakingNewsUsElectionEurope) case Some("international-us-election") => List(BreakingNewsUsElectionInternational) case Some("") => throw new InvalidParameterException(s"Invalid empty string topic") case Some(notYetImplementedTopic) => List(Topic(Breaking, notYetImplementedTopic)) case None => throw new InvalidParameterException(s"Invalid empty topic") } } private def createLinkDetails(trail: ClientHydratedTrail) = { if (trail.isArticle) { GuardianLinkDetails( contentApiId = trail.path.getOrElse( throw new InvalidParameterException( s"Missing content API id for ${trail.headline}" ) ), title = trail.headline, git = GITContent, thumbnail = trail.thumb, shortUrl = trail.shortUrl, blockId = trail.blockId ) } else { throw new InvalidNotificationContentType( s"Can't send snap notifications for trail: ${trail.headline}" ) } } } class BreakingNewsUpdate( val config: ApplicationConfiguration, val ws: WSClient, val structuredLogger: StructuredLogger ) extends Logging { lazy val client = { logger.info( s"Configuring breaking news client to send notifications to ${config.notification.host}" ) new BreakingNewsClientImpl( ws = ws, host = config.notification.host, apiKey = config.notification.key ) } def putBreakingNewsUpdate( collectionId: String, collection: ClientHydratedCollection, email: String ): Future[Result] = { structuredLogger.putLog( LogUpdate(HandlingBreakingNewsCollection(collectionId), email) ) val futurePossibleErrors = Future.traverse(collection.trails)(trail => sendAlert(trail, email, collectionId).map(trail -> _) ) futurePossibleErrors.map { listOfPossibleErrors => { val errors = listOfPossibleErrors.collect { case (trail, Some(error)) => trail -> error } if (errors.isEmpty) { Ok } else { errors.foreach { case (trail, error) => logger.error( s"Error sending breaking news. Returning to client: $error. Trail: $trail" ) } InternalServerError(Json.toJson(errors.map { case (_, error) => error })) } } } } private def sendAlert( trail: ClientHydratedTrail, email: String, collectionId: String ): Future[Option[String]] = { def handleSuccessfulFuture(result: Either[ApiClientError, UUID]) = result match { case Left(error) => structuredLogger.putLog( LogUpdate(HandlingBreakingNewsCollection(collectionId), email), "error", Some(new Exception(error.description)) ) Some(error.description) case Right(_) => None } def withExceptionHandling( block: => Future[Option[String]] ): Future[Option[String]] = { Try(block) match { case Success(futureMaybeError) => futureMaybeError case Failure(t: Throwable) => val message = s"Exception in breaking news client send for trail ${trail.headline} because ${t.getMessage}" logger.error(message, t) Future.successful(Some(message)) } } if (trail.alert.getOrElse(false)) { withExceptionHandling({ structuredLogger.putLog( LogUpdate( HandlingBreakingNewsTrail(collectionId, trail: ClientHydratedTrail), email ) ) val payload = BreakingNewsUpdate.createPayload(trail, email) client .send(payload) .map(handleSuccessfulFuture) .recover { case NonFatal(e) => Some(e.getMessage) } }) } else { logger.error( s"Failed to send a breaking news alert for trail ${trail} because alert was missing" ) Future.successful( Some( "There may have been a problem in sending a breaking news alert. Please contact central production for information" ) ) } } } case class ApiClientError(description: String) trait BreakingNewsClient { def send( breakingNewsPayload: BreakingNewsPayload ): Future[Either[ApiClientError, UUID]] } class BreakingNewsClientImpl(ws: WSClient, host: String, apiKey: String) extends BreakingNewsClient with Logging { private val url = s"$host/push/topic" override def send( breakingNewsPayload: BreakingNewsPayload ): Future[Either[ApiClientError, UUID]] = { val body: String = Json.stringify(NotificationPayload.jf.writes(breakingNewsPayload)) ws.url(url) .withHttpHeaders( "Content-Type" -> "application/json; charset=UTF-8", "Authorization" -> s"Bearer $apiKey" ) .post(body) .map { response => if (response.status >= 200 && response.status < 300) { logger.info("Breaking news notification sent correctly") response.body[JsValue] \ "id" match { case JsDefined(JsString(id)) => Right(UUID.fromString(id)) case _ => Left( ApiClientError( s"Notification sent successfully but unable to parse response. Status: ${response.status}, Body: ${response.body}" ) ) } } else { logger.error( s"Unable to send breaking news notification, Status ${response.status}: ${response.statusText}, Body: ${response.body}" ) Left( ApiClientError( "Unable to send breaking news notification, status ${response.status}: ${response.statusText}" ) ) } } } }