private def replyToUser()

in tweetypie/server/src/main/scala/com/twitter/tweetypie/handler/ReplyBuilder.scala [322:632]


  private def replyToUser(user: User, inReplyToStatusId: Option[TweetId] = None): Reply =
    Reply(
      inReplyToUserId = user.id,
      inReplyToScreenName = Some(user.screenName),
      inReplyToStatusId = inReplyToStatusId
    )

  /**
   * A builder that generates reply from `inReplyToTweetId` or tweet text
   *
   * There are two kinds of "reply":
   * 1. reply to tweet, which is generated from `inReplyToTweetId`.
   *
   * A valid reply-to-tweet satisfies the following conditions:
   * 1). the tweet that is in-reply-to exists (and is visible to the user creating the tweet)
   * 2). the author of the in-reply-to tweet is mentioned anywhere in the tweet, or
   *     this is a tweet that is in reply to the author's own tweet
   *
   * 2. reply to user, is generated when the tweet text starts with @user_name.  This is only
   * attempted if PostTweetRequest.enableTweetToNarrowcasting is true (default).
   */
  def apply(
    userIdentityRepo: UserIdentityRepository.Type,
    tweetRepo: TweetRepository.Optional,
    replyCardUsersFinder: CardUsersFinder.Type,
    selfThreadBuilder: SelfThreadBuilder,
    relationshipRepo: RelationshipRepository.Type,
    unmentionedEntitiesRepo: UnmentionedEntitiesRepository.Type,
    enableRemoveUnmentionedImplicits: Gate[Unit],
    stats: StatsReceiver,
    maxMentions: Int
  ): Type = {
    val exceptionCounters = ExceptionCounter(stats)
    val modeScope = stats.scope("mode")
    val compatModeCounter = modeScope.counter("compat")
    val simpleModeCounter = modeScope.counter("simple")

    def getUser(key: UserKey): Future[Option[User]] =
      Stitch.run(
        userIdentityRepo(key)
          .map(ident => User(ident.id, ident.screenName))
          .liftNotFoundToOption
      )

    def getUsers(userIds: Seq[UserId]): Future[Seq[ReplyBuilder.User]] =
      Stitch.run(
        Stitch
          .traverse(userIds)(id => userIdentityRepo(UserKey(id)).liftNotFoundToOption)
          .map(_.flatten)
          .map { identities => identities.map { ident => User(ident.id, ident.screenName) } }
      )

    val tweetQueryIncludes =
      TweetQuery.Include(
        tweetFields = Set(
          Tweet.CoreDataField.id,
          Tweet.CardReferenceField.id,
          Tweet.CommunitiesField.id,
          Tweet.MediaTagsField.id,
          Tweet.MentionsField.id,
          Tweet.UrlsField.id,
          Tweet.EditControlField.id
        ) ++ selfThreadBuilder.requiredReplySourceFields.map(_.id)
      )

    def tweetQueryOptions(forUserId: UserId) =
      TweetQuery.Options(
        tweetQueryIncludes,
        forUserId = Some(forUserId),
        enforceVisibilityFiltering = true
      )

    def getTweet(tweetId: TweetId, forUserId: UserId): Future[Option[Tweet]] =
      Stitch.run(tweetRepo(tweetId, tweetQueryOptions(forUserId)))

    def checkBlockRelationship(authorId: UserId, result: Result): Future[Unit] = {
      val inReplyToBlocksTweeter =
        RelationshipKey.blocks(
          sourceId = result.reply.inReplyToUserId,
          destinationId = authorId
        )

      Stitch.run(relationshipRepo(inReplyToBlocksTweeter)).flatMap {
        case true => Future.exception(InReplyToTweetNotFound)
        case false => Future.Unit
      }
    }

    def checkIPIPolicy(request: Request, reply: Reply): Future[Unit] = {
      if (request.spamResult == Spam.DisabledByIpiPolicy) {
        Future.exception(Spam.DisabledByIpiFailure(reply.inReplyToScreenName))
      } else {
        Future.Unit
      }
    }

    def getUnmentionedUsers(replySource: ReplySource): Future[Seq[UserId]] = {
      if (enableRemoveUnmentionedImplicits()) {
        val srcDirectedAt = replySource.srcTweet.directedAtUserMetadata.flatMap(_.userId)
        val srcTweetMentions = replySource.srcTweet.mentions.getOrElse(Nil).flatMap(_.userId)
        val idsToCheck = srcTweetMentions ++ srcDirectedAt

        val conversationId = replySource.srcTweet.coreData.flatMap(_.conversationId)
        conversationId match {
          case Some(cid) if idsToCheck.nonEmpty =>
            stats.counter("unmentioned_implicits_check").incr()
            Stitch
              .run(unmentionedEntitiesRepo(cid, idsToCheck)).liftToTry.map {
                case Return(Some(unmentionedUserIds)) =>
                  unmentionedUserIds
                case _ => Seq[UserId]()
              }
          case _ => Future.Nil

        }
      } else {
        Future.Nil
      }
    }

    /**
     * Constructs a `ReplySource` for the given `tweetId`, which captures the source tweet to be
     * replied to, its author, and if `tweetId` is for a retweet of the source tweet, then also
     * that retweet and its author.  If the source tweet (or a retweet of it), or a corresponding
     * author, can't be found or isn't visible to the replier, then `InReplyToTweetNotFound` is
     * thrown.
     */
    def getReplySource(tweetId: TweetId, forUserId: UserId): Future[ReplySource] =
      for {
        tweet <- getTweet(tweetId, forUserId).flatMap {
          case None => Future.exception(InReplyToTweetNotFound)
          case Some(t) => Future.value(t)
        }

        user <- getUser(UserKey(getUserId(tweet))).flatMap {
          case None => Future.exception(InReplyToTweetNotFound)
          case Some(u) => Future.value(u)
        }

        res <- getShare(tweet) match {
          case None => Future.value(ReplySource(tweet, user))
          case Some(share) =>
            // if the user is replying to a retweet, find the retweet source tweet,
            // then update with the retweet and author.
            getReplySource(share.sourceStatusId, forUserId)
              .map(_.copy(retweet = Some(tweet), rtUser = Some(user)))
        }
      } yield res

    /**
     * Computes a `Result` for the reply-to-tweet case.  If `inReplyToTweetId` is for a retweet,
     * the reply will be computed against the source tweet.  If `prependImplicitMentions` is true
     * and source tweet can't be found or isn't visible to replier, then this method will return
     * a `InReplyToTweetNotFound` failure.  If `prependImplicitMentions` is false, then the reply
     * text must either mention the source tweet user, or it must be a reply to self; if both of
     * those conditions fail, then `None` is returned.
     */
    def makeReplyToTweet(
      inReplyToTweetId: TweetId,
      text: String,
      author: User,
      prependImplicitMentions: Boolean,
      enableTweetToNarrowcasting: Boolean,
      excludeUserIds: Seq[UserId],
      batchMode: Option[BatchComposeMode]
    ): Future[Option[Result]] = {
      val explicitMentions: Seq[Extractor.Entity] =
        extractor.extractMentionedScreennamesWithIndices(text).asScala.toSeq
      val mentionedScreenNames =
        explicitMentions.map(_.getValue.toLowerCase).toSet

      /**
       * If `prependImplicitMentions` is true, or the reply author is the same as the in-reply-to
       * author, then the reply text doesn't have to mention the in-reply-to author.  Otherwise,
       * check that the text contains a mention of the reply author.
       */
      def isValidReplyTo(inReplyToUser: User): Boolean =
        prependImplicitMentions ||
          (inReplyToUser.id == author.id) ||
          mentionedScreenNames.contains(inReplyToUser.screenName.toLowerCase)

      getReplySource(inReplyToTweetId, author.id)
        .flatMap { replySrc =>
          val baseResult = BaseResult(
            reply = replyToUser(replySrc.srcUser, Some(replySrc.srcTweet.id)),
            conversationId = getConversationId(replySrc.srcTweet),
            selfThreadMetadata = selfThreadBuilder.build(author.id, replySrc.srcTweet),
            community = replySrc.srcTweet.communities,
            // Reply tweets retain the same exclusive
            // tweet controls as the tweet being replied to.
            exclusiveTweetControl = replySrc.srcTweet.exclusiveTweetControl,
            trustedFriendsControl = replySrc.srcTweet.trustedFriendsControl,
            editControl = replySrc.srcTweet.editControl
          )

          if (isValidReplyTo(replySrc.srcUser)) {
            if (prependImplicitMentions) {

              // Simplified Replies mode - append server-side generated prefix to passed in text
              simpleModeCounter.incr()
              // remove the in-reply-to tweet author from the excluded users, in-reply-to tweet author will always be a directedAtUser
              val filteredExcludedIds =
                excludeUserIds.filterNot(uid => uid == TweetLenses.userId(replySrc.srcTweet))
              for {
                unmentionedUserIds <- getUnmentionedUsers(replySrc)
                excludedUsers <- getUsers(filteredExcludedIds ++ unmentionedUserIds)
                (prefix, directedAtUser) = replySrc.implicitMentionPrefixAndDAU(
                  maxImplicits = math.max(0, maxMentions - explicitMentions.size),
                  excludedUsers = excludedUsers,
                  author = author,
                  enableTweetToNarrowcasting = enableTweetToNarrowcasting,
                  batchMode = batchMode
                )
              } yield {
                // prefix or text (or both) can be empty strings.  Add " " separator and adjust
                // prefix length only when both prefix and text are non-empty.
                val textChunks = Seq(prefix, text).map(_.trim).filter(_.nonEmpty)
                val tweetText = textChunks.mkString(" ")
                val visibleStart =
                  if (textChunks.size == 2) {
                    Offset.CodePoint.length(prefix + " ")
                  } else {
                    Offset.CodePoint.length(prefix)
                  }

                Some(
                  baseResult.toResult(
                    tweetText = tweetText,
                    directedAtMetadata = DirectedAtUserMetadata(directedAtUser.map(_.id)),
                    visibleStart = visibleStart
                  )
                )
              }
            } else {
              // Backwards-compatibility mode - walk from beginning of text until find visibleStart
              compatModeCounter.incr()
              for {
                cardUserIds <- replySrc.allCardUsers(author, replyCardUsersFinder)
                cardUsers <- getUsers(cardUserIds.toSeq)
                optUserIdentity <- extractReplyToUser(text)
                directedAtUserId = optUserIdentity.map(_.id).filter(_ => enableTweetToNarrowcasting)
              } yield {
                Some(
                  baseResult.toResult(
                    tweetText = text,
                    directedAtMetadata = DirectedAtUserMetadata(directedAtUserId),
                    visibleStart = replySrc.hideablePrefix(text, cardUsers, explicitMentions),
                  )
                )
              }
            }
          } else {
            Future.None
          }
        }
        .handle {
          // if `getReplySource` throws this exception, but we aren't computing implicit
          // mentions, then we fall back to the reply-to-user case instead of reply-to-tweet
          case InReplyToTweetNotFound if !prependImplicitMentions => None
        }
    }

    def makeReplyToUser(text: String): Future[Option[Result]] =
      extractReplyToUser(text).map(_.map { user =>
        Result(replyToUser(user), text, DirectedAtUserMetadata(Some(user.id)))
      })

    def extractReplyToUser(text: String): Future[Option[User]] =
      Option(extractor.extractReplyScreenname(text)) match {
        case None => Future.None
        case Some(screenName) => getUser(UserKey(screenName))
      }

    FutureArrow[Request, Option[Result]] { request =>
      exceptionCounters {
        (request.inReplyToTweetId.filter(_ > 0) match {
          case None =>
            Future.None

          case Some(tweetId) =>
            makeReplyToTweet(
              tweetId,
              request.tweetText,
              User(request.authorId, request.authorScreenName),
              request.prependImplicitMentions,
              request.enableTweetToNarrowcasting,
              request.excludeUserIds,
              request.batchMode
            )
        }).flatMap {
          case Some(r) =>
            // Ensure that the author of this reply is not blocked by
            // the user who they are replying to.
            checkBlockRelationship(request.authorId, r)
              .before(checkIPIPolicy(request, r.reply))
              .before(Future.value(Some(r)))

          case None if request.enableTweetToNarrowcasting =>
            // We don't check the block relationship when the tweet is
            // not part of a conversation (which is to say, we allow
            // directed-at tweets from a blocked user.) These tweets
            // will not cause notifications for the blocking user,
            // despite the presence of the reply struct.
            makeReplyToUser(request.tweetText)

          case None =>
            Future.None
        }
      }
    }
  }