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
}
}
}
}