mirror of
https://github.com/twitter/the-algorithm.git
synced 2025-06-10 22:58:17 -05:00
Twitter Recommendation Algorithm
Please note we have force-pushed a new initial commit in order to remove some publicly-available Twitter user information. Note that this process may be required in the future.
This commit is contained in:
17
timelineranker/common/BUILD
Normal file
17
timelineranker/common/BUILD
Normal file
@ -0,0 +1,17 @@
|
||||
target(
|
||||
name = "adapter",
|
||||
dependencies = ["timelineranker/common/src/main/scala/com/twitter/timelineranker/adapter"],
|
||||
)
|
||||
|
||||
target(
|
||||
name = "model",
|
||||
dependencies = ["timelineranker/common/src/main/scala/com/twitter/timelineranker/model"],
|
||||
)
|
||||
|
||||
target(
|
||||
tags = ["bazel-compatible"],
|
||||
dependencies = [
|
||||
":adapter",
|
||||
":model",
|
||||
],
|
||||
)
|
6
timelineranker/common/src/main/scala/BUILD
Normal file
6
timelineranker/common/src/main/scala/BUILD
Normal file
@ -0,0 +1,6 @@
|
||||
target(
|
||||
tags = ["bazel-compatible"],
|
||||
dependencies = [
|
||||
"timelineranker/common/src/main/scala/com/twitter/timelineranker/model",
|
||||
],
|
||||
)
|
@ -0,0 +1,14 @@
|
||||
scala_library(
|
||||
sources = ["*.scala"],
|
||||
compiler_option_sets = ["fatal_warnings"],
|
||||
platform = "java8",
|
||||
tags = ["bazel-compatible"],
|
||||
dependencies = [
|
||||
"configapi/configapi-core",
|
||||
"src/thrift/com/twitter/timelineservice/server/internal:thrift-scala",
|
||||
"src/thrift/com/twitter/tweetypie:tweet-scala",
|
||||
"timelineranker/common:model",
|
||||
"timelines/src/main/scala/com/twitter/timelines/clientconfig",
|
||||
"timelines/src/main/scala/com/twitter/timelines/model/tweet",
|
||||
],
|
||||
)
|
@ -0,0 +1,139 @@
|
||||
package com.twitter.timelineranker.adapter
|
||||
|
||||
import com.twitter.timelineranker.model._
|
||||
import com.twitter.timelines.model.tweet.HydratedTweet
|
||||
import com.twitter.timelines.model.TweetId
|
||||
import com.twitter.timelineservice.model.TimelineId
|
||||
import com.twitter.timelineservice.model.core
|
||||
import com.twitter.timelineservice.{model => tls}
|
||||
import com.twitter.timelineservice.{thriftscala => tlsthrift}
|
||||
import com.twitter.timelineservice.model.core._
|
||||
import com.twitter.util.Return
|
||||
import com.twitter.util.Throw
|
||||
import com.twitter.util.Try
|
||||
|
||||
/**
|
||||
* Enables TLR model objects to be converted to/from TLS model/thrift objects.
|
||||
*/
|
||||
object TimelineServiceAdapter {
|
||||
def toTlrQuery(
|
||||
id: Long,
|
||||
tlsRange: tls.TimelineRange,
|
||||
getTweetsFromArchiveIndex: Boolean = true
|
||||
): ReverseChronTimelineQuery = {
|
||||
val timelineId = TimelineId(id, TimelineKind.home)
|
||||
val maxCount = tlsRange.maxCount
|
||||
val tweetIdRange = tlsRange.cursor.map { cursor =>
|
||||
TweetIdRange(
|
||||
fromId = cursor.tweetIdBounds.bottom,
|
||||
toId = cursor.tweetIdBounds.top
|
||||
)
|
||||
}
|
||||
val options = ReverseChronTimelineQueryOptions(
|
||||
getTweetsFromArchiveIndex = getTweetsFromArchiveIndex
|
||||
)
|
||||
ReverseChronTimelineQuery(timelineId, Some(maxCount), tweetIdRange, Some(options))
|
||||
}
|
||||
|
||||
def toTlsQuery(query: ReverseChronTimelineQuery): tls.TimelineQuery = {
|
||||
val tlsRange = toTlsRange(query.range, query.maxCount)
|
||||
tls.TimelineQuery(
|
||||
id = query.id.id,
|
||||
kind = query.id.kind,
|
||||
range = tlsRange
|
||||
)
|
||||
}
|
||||
|
||||
def toTlsRange(range: Option[TimelineRange], maxCount: Option[Int]): tls.TimelineRange = {
|
||||
val cursor = range.map {
|
||||
case tweetIdRange: TweetIdRange =>
|
||||
RequestCursor(
|
||||
top = tweetIdRange.toId.map(CursorState.fromTweetId),
|
||||
bottom = tweetIdRange.fromId.map(core.CursorState.fromTweetId)
|
||||
)
|
||||
case _ =>
|
||||
throw new IllegalArgumentException(s"Only TweetIdRange is supported. Found: $range")
|
||||
}
|
||||
maxCount
|
||||
.map { count => tls.TimelineRange(cursor, count) }
|
||||
.getOrElse(tls.TimelineRange(cursor))
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts TLS timeline to a Try of TLR timeline.
|
||||
*
|
||||
* TLS timeline not only contains timeline entries/attributes but also the retrieval state;
|
||||
* whereas TLR timeline only has entries/attributes. Therefore, the TLS timeline is
|
||||
* mapped to a Try[Timeline] where the Try part captures retrieval state and
|
||||
* Timeline captures entries/attributes.
|
||||
*/
|
||||
def toTlrTimelineTry(tlsTimeline: tls.Timeline[tls.TimelineEntry]): Try[Timeline] = {
|
||||
require(
|
||||
tlsTimeline.kind == TimelineKind.home,
|
||||
s"Only home timelines are supported. Found: ${tlsTimeline.kind}"
|
||||
)
|
||||
|
||||
tlsTimeline.state match {
|
||||
case Some(TimelineHit) | None =>
|
||||
val tweetEnvelopes = tlsTimeline.entries.map {
|
||||
case tweet: tls.Tweet =>
|
||||
TimelineEntryEnvelope(Tweet(tweet.tweetId))
|
||||
case entry =>
|
||||
throw new Exception(s"Only tweet timelines are supported. Found: $entry")
|
||||
}
|
||||
Return(Timeline(TimelineId(tlsTimeline.id, tlsTimeline.kind), tweetEnvelopes))
|
||||
case Some(TimelineNotFound) | Some(TimelineUnavailable) =>
|
||||
Throw(new tls.core.TimelineUnavailableException(tlsTimeline.id, Some(tlsTimeline.kind)))
|
||||
}
|
||||
}
|
||||
|
||||
def toTlsTimeline(timeline: Timeline): tls.Timeline[tls.Tweet] = {
|
||||
val entries = timeline.entries.map { entry =>
|
||||
entry.entry match {
|
||||
case tweet: Tweet => tls.Tweet(tweet.id)
|
||||
case entry: HydratedTweetEntry => tls.Tweet.fromThrift(entry.tweet)
|
||||
case _ =>
|
||||
throw new IllegalArgumentException(
|
||||
s"Only tweet timelines are supported. Found: ${entry.entry}"
|
||||
)
|
||||
}
|
||||
}
|
||||
tls.Timeline(
|
||||
id = timeline.id.id,
|
||||
kind = timeline.id.kind,
|
||||
entries = entries
|
||||
)
|
||||
}
|
||||
|
||||
def toTweetIds(timeline: tlsthrift.Timeline): Seq[TweetId] = {
|
||||
timeline.entries.map {
|
||||
case tlsthrift.TimelineEntry.Tweet(tweet) =>
|
||||
tweet.statusId
|
||||
case entry =>
|
||||
throw new IllegalArgumentException(s"Only tweet timelines are supported. Found: ${entry}")
|
||||
}
|
||||
}
|
||||
|
||||
def toTweetIds(timeline: Timeline): Seq[TweetId] = {
|
||||
timeline.entries.map { entry =>
|
||||
entry.entry match {
|
||||
case tweet: Tweet => tweet.id
|
||||
case entry: HydratedTweetEntry => entry.tweet.id
|
||||
case _ =>
|
||||
throw new IllegalArgumentException(
|
||||
s"Only tweet timelines are supported. Found: ${entry.entry}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def toHydratedTweets(timeline: Timeline): Seq[HydratedTweet] = {
|
||||
timeline.entries.map { entry =>
|
||||
entry.entry match {
|
||||
case hydratedTweet: HydratedTweet => hydratedTweet
|
||||
case _ =>
|
||||
throw new IllegalArgumentException(s"Expected hydrated tweet. Found: ${entry.entry}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
scala_library(
|
||||
sources = ["*.scala"],
|
||||
compiler_option_sets = ["fatal_warnings"],
|
||||
platform = "java8",
|
||||
tags = ["bazel-compatible"],
|
||||
dependencies = [
|
||||
"src/java/com/twitter/common/text/language:locale-util",
|
||||
"src/thrift/com/twitter/search:earlybird-scala",
|
||||
"src/thrift/com/twitter/search/common:features-scala",
|
||||
"src/thrift/com/twitter/timelineranker/server/model:thrift-scala",
|
||||
"timelines:config-api-base",
|
||||
"timelines/src/main/scala/com/twitter/timelines/common/model",
|
||||
"timelines/src/main/scala/com/twitter/timelines/earlybird/common/options",
|
||||
"timelines/src/main/scala/com/twitter/timelines/earlybird/common/utils",
|
||||
"timelines/src/main/scala/com/twitter/timelines/model/candidate",
|
||||
"timelines/src/main/scala/com/twitter/timelines/model/tweet",
|
||||
"timelines/src/main/scala/com/twitter/timelines/util",
|
||||
"timelineservice/common/src/main/scala/com/twitter/timelineservice/model",
|
||||
],
|
||||
exports = [
|
||||
"timelines:config-api-base",
|
||||
],
|
||||
)
|
@ -0,0 +1,35 @@
|
||||
package com.twitter.timelineranker.model
|
||||
|
||||
import com.twitter.search.common.features.thriftscala.ThriftTweetFeatures
|
||||
import com.twitter.timelineranker.{thriftscala => thrift}
|
||||
import com.twitter.timelines.model.tweet.HydratedTweet
|
||||
import com.twitter.tweetypie.thriftscala
|
||||
|
||||
object CandidateTweet {
|
||||
val DefaultFeatures: ThriftTweetFeatures = ThriftTweetFeatures()
|
||||
|
||||
def fromThrift(candidate: thrift.CandidateTweet): CandidateTweet = {
|
||||
val tweet: thriftscala.Tweet = candidate.tweet.getOrElse(
|
||||
throw new IllegalArgumentException(s"CandidateTweet.tweet must have a value")
|
||||
)
|
||||
val features = candidate.features.getOrElse(
|
||||
throw new IllegalArgumentException(s"CandidateTweet.features must have a value")
|
||||
)
|
||||
|
||||
CandidateTweet(HydratedTweet(tweet), features)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A candidate Tweet and associated information.
|
||||
* Model object for CandidateTweet thrift struct.
|
||||
*/
|
||||
case class CandidateTweet(hydratedTweet: HydratedTweet, features: ThriftTweetFeatures) {
|
||||
|
||||
def toThrift: thrift.CandidateTweet = {
|
||||
thrift.CandidateTweet(
|
||||
tweet = Some(hydratedTweet.tweet),
|
||||
features = Some(features)
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
package com.twitter.timelineranker.model
|
||||
|
||||
import com.twitter.timelineranker.{thriftscala => thrift}
|
||||
import com.twitter.util.Future
|
||||
|
||||
object CandidateTweetsResult {
|
||||
val Empty: CandidateTweetsResult = CandidateTweetsResult(Nil, Nil)
|
||||
val EmptyFuture: Future[CandidateTweetsResult] = Future.value(Empty)
|
||||
val EmptyCandidateTweet: Seq[CandidateTweet] = Seq.empty[CandidateTweet]
|
||||
|
||||
def fromThrift(response: thrift.GetCandidateTweetsResponse): CandidateTweetsResult = {
|
||||
val candidates = response.candidates
|
||||
.map(_.map(CandidateTweet.fromThrift))
|
||||
.getOrElse(EmptyCandidateTweet)
|
||||
val sourceTweets = response.sourceTweets
|
||||
.map(_.map(CandidateTweet.fromThrift))
|
||||
.getOrElse(EmptyCandidateTweet)
|
||||
if (sourceTweets.nonEmpty) {
|
||||
require(candidates.nonEmpty, "sourceTweets cannot have a value if candidates list is empty.")
|
||||
}
|
||||
CandidateTweetsResult(candidates, sourceTweets)
|
||||
}
|
||||
}
|
||||
|
||||
case class CandidateTweetsResult(
|
||||
candidates: Seq[CandidateTweet],
|
||||
sourceTweets: Seq[CandidateTweet]) {
|
||||
|
||||
def toThrift: thrift.GetCandidateTweetsResponse = {
|
||||
val thriftCandidates = candidates.map(_.toThrift)
|
||||
val thriftSourceTweets = sourceTweets.map(_.toThrift)
|
||||
thrift.GetCandidateTweetsResponse(
|
||||
candidates = Some(thriftCandidates),
|
||||
sourceTweets = Some(thriftSourceTweets)
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
package com.twitter.timelineranker.model
|
||||
|
||||
import com.twitter.timelineranker.{thriftscala => thrift}
|
||||
import com.twitter.timelines.model.tweet.HydratedTweet
|
||||
import com.twitter.tweetypie.{thriftscala => tweetypie}
|
||||
|
||||
/**
|
||||
* Enables HydratedTweet entries to be included in a Timeline.
|
||||
*/
|
||||
class HydratedTweetEntry(tweet: tweetypie.Tweet) extends HydratedTweet(tweet) with TimelineEntry {
|
||||
|
||||
def this(hydratedTweet: HydratedTweet) = this(hydratedTweet.tweet)
|
||||
|
||||
override def toTimelineEntryThrift: thrift.TimelineEntry = {
|
||||
thrift.TimelineEntry.TweetypieTweet(tweet)
|
||||
}
|
||||
|
||||
override def throwIfInvalid(): Unit = {
|
||||
// No validation performed.
|
||||
}
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
package com.twitter.timelineranker.model
|
||||
|
||||
import com.twitter.common.text.language.LocaleUtil
|
||||
import com.twitter.timelineranker.{thriftscala => thrift}
|
||||
|
||||
object Language {
|
||||
|
||||
def fromThrift(lang: thrift.Language): Language = {
|
||||
require(lang.language.isDefined, "language can't be None")
|
||||
require(lang.scope.isDefined, "scope can't be None")
|
||||
Language(lang.language.get, LanguageScope.fromThrift(lang.scope.get))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a language and the scope that it relates to.
|
||||
*/
|
||||
case class Language(language: String, scope: LanguageScope.Value) {
|
||||
|
||||
throwIfInvalid()
|
||||
|
||||
def toThrift: thrift.Language = {
|
||||
val scopeOption = Some(LanguageScope.toThrift(scope))
|
||||
thrift.Language(Some(language), scopeOption)
|
||||
}
|
||||
|
||||
def throwIfInvalid(): Unit = {
|
||||
val result = LocaleUtil.getLocaleOf(language)
|
||||
require(result != LocaleUtil.UNKNOWN, s"Language ${language} is unsupported")
|
||||
}
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
package com.twitter.timelineranker.model
|
||||
|
||||
import com.twitter.timelineranker.{thriftscala => thrift}
|
||||
|
||||
/**
|
||||
* Represents what this language is associated with.
|
||||
* For example, "user" is one of the scopes and "event"
|
||||
* could be another scope.
|
||||
*/
|
||||
object LanguageScope extends Enumeration {
|
||||
|
||||
// User scope means that the language is the user's language.
|
||||
val User: Value = Value(thrift.LanguageScope.User.value)
|
||||
|
||||
// Event scope means that the language is the event's language.
|
||||
val Event: Value = Value(thrift.LanguageScope.Event.value)
|
||||
|
||||
// list of all LanguageScope values
|
||||
val All: ValueSet = LanguageScope.ValueSet(User, Event)
|
||||
|
||||
def apply(scope: thrift.LanguageScope): LanguageScope.Value = {
|
||||
scope match {
|
||||
case thrift.LanguageScope.User =>
|
||||
User
|
||||
case thrift.LanguageScope.Event =>
|
||||
Event
|
||||
case _ =>
|
||||
throw new IllegalArgumentException(s"Unsupported language scope: $scope")
|
||||
}
|
||||
}
|
||||
|
||||
def fromThrift(scope: thrift.LanguageScope): LanguageScope.Value = {
|
||||
apply(scope)
|
||||
}
|
||||
|
||||
def toThrift(scope: LanguageScope.Value): thrift.LanguageScope = {
|
||||
scope match {
|
||||
case LanguageScope.User =>
|
||||
thrift.LanguageScope.User
|
||||
case LanguageScope.Event =>
|
||||
thrift.LanguageScope.Event
|
||||
case _ =>
|
||||
throw new IllegalArgumentException(s"Unsupported language scope: $scope")
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,184 @@
|
||||
package com.twitter.timelineranker.model
|
||||
|
||||
import com.twitter.search.earlybird.thriftscala.ThriftSearchResult
|
||||
import com.twitter.timelines.model.tweet.HydratedTweet
|
||||
import com.twitter.timelines.model.TweetId
|
||||
import com.twitter.timelines.model.UserId
|
||||
import com.twitter.timelines.util.SnowflakeSortIndexHelper
|
||||
import com.twitter.tweetypie.{thriftscala => tweetypie}
|
||||
|
||||
object PartiallyHydratedTweet {
|
||||
private val InvalidValue = "Invalid value"
|
||||
|
||||
/**
|
||||
* Creates an instance of PartiallyHydratedTweet based on the given search result.
|
||||
*/
|
||||
def fromSearchResult(result: ThriftSearchResult): PartiallyHydratedTweet = {
|
||||
val tweetId = result.id
|
||||
val metadata = result.metadata.getOrElse(
|
||||
throw new IllegalArgumentException(
|
||||
s"cannot initialize PartiallyHydratedTweet $tweetId without ThriftSearchResult metadata."
|
||||
)
|
||||
)
|
||||
|
||||
val extraMetadataOpt = metadata.extraMetadata
|
||||
|
||||
val userId = metadata.fromUserId
|
||||
|
||||
// The value of referencedTweetAuthorId and sharedStatusId is only considered valid if it is greater than 0.
|
||||
val referencedTweetAuthorId =
|
||||
if (metadata.referencedTweetAuthorId > 0) Some(metadata.referencedTweetAuthorId) else None
|
||||
val sharedStatusId = if (metadata.sharedStatusId > 0) Some(metadata.sharedStatusId) else None
|
||||
|
||||
val isRetweet = metadata.isRetweet.getOrElse(false)
|
||||
val retweetSourceTweetId = if (isRetweet) sharedStatusId else None
|
||||
val retweetSourceUserId = if (isRetweet) referencedTweetAuthorId else None
|
||||
|
||||
// The fields sharedStatusId and referencedTweetAuthorId have overloaded meaning when
|
||||
// this tweet is not a retweet (for retweet, there is only 1 meaning).
|
||||
// When not a retweet,
|
||||
// if referencedTweetAuthorId and sharedStatusId are both set, it is considered a reply
|
||||
// if referencedTweetAuthorId is set and sharedStatusId is not set, it is a directed at tweet.
|
||||
// References: SEARCH-8561 and SEARCH-13142
|
||||
val inReplyToTweetId = if (!isRetweet) sharedStatusId else None
|
||||
val inReplyToUserId = if (!isRetweet) referencedTweetAuthorId else None
|
||||
val isReply = metadata.isReply.contains(true)
|
||||
|
||||
val quotedTweetId = extraMetadataOpt.flatMap(_.quotedTweetId)
|
||||
val quotedUserId = extraMetadataOpt.flatMap(_.quotedUserId)
|
||||
|
||||
val isNullcast = metadata.isNullcast.contains(true)
|
||||
|
||||
val conversationId = extraMetadataOpt.flatMap(_.conversationId)
|
||||
|
||||
// Root author id for the user who posts an exclusive tweet
|
||||
val exclusiveConversationAuthorId = extraMetadataOpt.flatMap(_.exclusiveConversationAuthorId)
|
||||
|
||||
// Card URI associated with an attached card to this tweet, if it contains one
|
||||
val cardUri = extraMetadataOpt.flatMap(_.cardUri)
|
||||
|
||||
val tweet = makeTweetyPieTweet(
|
||||
tweetId,
|
||||
userId,
|
||||
inReplyToTweetId,
|
||||
inReplyToUserId,
|
||||
retweetSourceTweetId,
|
||||
retweetSourceUserId,
|
||||
quotedTweetId,
|
||||
quotedUserId,
|
||||
isNullcast,
|
||||
isReply,
|
||||
conversationId,
|
||||
exclusiveConversationAuthorId,
|
||||
cardUri
|
||||
)
|
||||
new PartiallyHydratedTweet(tweet)
|
||||
}
|
||||
|
||||
def makeTweetyPieTweet(
|
||||
tweetId: TweetId,
|
||||
userId: UserId,
|
||||
inReplyToTweetId: Option[TweetId],
|
||||
inReplyToUserId: Option[TweetId],
|
||||
retweetSourceTweetId: Option[TweetId],
|
||||
retweetSourceUserId: Option[UserId],
|
||||
quotedTweetId: Option[TweetId],
|
||||
quotedUserId: Option[UserId],
|
||||
isNullcast: Boolean,
|
||||
isReply: Boolean,
|
||||
conversationId: Option[Long],
|
||||
exclusiveConversationAuthorId: Option[Long] = None,
|
||||
cardUri: Option[String] = None
|
||||
): tweetypie.Tweet = {
|
||||
val isDirectedAt = inReplyToUserId.isDefined
|
||||
val isRetweet = retweetSourceTweetId.isDefined && retweetSourceUserId.isDefined
|
||||
|
||||
val reply = if (isReply) {
|
||||
Some(
|
||||
tweetypie.Reply(
|
||||
inReplyToStatusId = inReplyToTweetId,
|
||||
inReplyToUserId = inReplyToUserId.getOrElse(0L) // Required
|
||||
)
|
||||
)
|
||||
} else None
|
||||
|
||||
val directedAt = if (isDirectedAt) {
|
||||
Some(
|
||||
tweetypie.DirectedAtUser(
|
||||
userId = inReplyToUserId.get,
|
||||
screenName = "" // not available from search
|
||||
)
|
||||
)
|
||||
} else None
|
||||
|
||||
val share = if (isRetweet) {
|
||||
Some(
|
||||
tweetypie.Share(
|
||||
sourceStatusId = retweetSourceTweetId.get,
|
||||
sourceUserId = retweetSourceUserId.get,
|
||||
parentStatusId =
|
||||
retweetSourceTweetId.get // Not always correct (eg, retweet of a retweet).
|
||||
)
|
||||
)
|
||||
} else None
|
||||
|
||||
val quotedTweet =
|
||||
for {
|
||||
tweetId <- quotedTweetId
|
||||
userId <- quotedUserId
|
||||
} yield tweetypie.QuotedTweet(tweetId = tweetId, userId = userId)
|
||||
|
||||
val coreData = tweetypie.TweetCoreData(
|
||||
userId = userId,
|
||||
text = InvalidValue,
|
||||
createdVia = InvalidValue,
|
||||
createdAtSecs = SnowflakeSortIndexHelper.idToTimestamp(tweetId).inSeconds,
|
||||
directedAtUser = directedAt,
|
||||
reply = reply,
|
||||
share = share,
|
||||
nullcast = isNullcast,
|
||||
conversationId = conversationId
|
||||
)
|
||||
|
||||
// Hydrate exclusiveTweetControl which determines whether the user is able to view an exclusive / SuperFollow tweet.
|
||||
val exclusiveTweetControl = exclusiveConversationAuthorId.map { authorId =>
|
||||
tweetypie.ExclusiveTweetControl(conversationAuthorId = authorId)
|
||||
}
|
||||
|
||||
val cardReference = cardUri.map { cardUriFromEB =>
|
||||
tweetypie.CardReference(cardUri = cardUriFromEB)
|
||||
}
|
||||
|
||||
tweetypie.Tweet(
|
||||
id = tweetId,
|
||||
quotedTweet = quotedTweet,
|
||||
coreData = Some(coreData),
|
||||
exclusiveTweetControl = exclusiveTweetControl,
|
||||
cardReference = cardReference
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an instance of HydratedTweet that is hydrated using search result
|
||||
* (instead of being hydrated using TweetyPie service).
|
||||
*
|
||||
* Not all fields are available using search therefore such fields if accessed
|
||||
* throw UnsupportedOperationException to ensure that they are not inadvertently
|
||||
* accessed and relied upon.
|
||||
*/
|
||||
class PartiallyHydratedTweet(tweet: tweetypie.Tweet) extends HydratedTweet(tweet) {
|
||||
override def parentTweetId: Option[TweetId] = throw notSupported("parentTweetId")
|
||||
override def mentionedUserIds: Seq[UserId] = throw notSupported("mentionedUserIds")
|
||||
override def takedownCountryCodes: Set[String] = throw notSupported("takedownCountryCodes")
|
||||
override def hasMedia: Boolean = throw notSupported("hasMedia")
|
||||
override def isNarrowcast: Boolean = throw notSupported("isNarrowcast")
|
||||
override def hasTakedown: Boolean = throw notSupported("hasTakedown")
|
||||
override def isNsfw: Boolean = throw notSupported("isNsfw")
|
||||
override def isNsfwUser: Boolean = throw notSupported("isNsfwUser")
|
||||
override def isNsfwAdmin: Boolean = throw notSupported("isNsfwAdmin")
|
||||
|
||||
private def notSupported(name: String): UnsupportedOperationException = {
|
||||
new UnsupportedOperationException(s"Not supported: $name")
|
||||
}
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
package com.twitter.timelineranker.model
|
||||
|
||||
import com.twitter.timelineranker.{thriftscala => thrift}
|
||||
import com.twitter.timelines.model.TweetId
|
||||
|
||||
object PriorSeenEntries {
|
||||
def fromThrift(entries: thrift.PriorSeenEntries): PriorSeenEntries = {
|
||||
PriorSeenEntries(seenEntries = entries.seenEntries)
|
||||
}
|
||||
}
|
||||
|
||||
case class PriorSeenEntries(seenEntries: Seq[TweetId]) {
|
||||
|
||||
throwIfInvalid()
|
||||
|
||||
def toThrift: thrift.PriorSeenEntries = {
|
||||
thrift.PriorSeenEntries(seenEntries = seenEntries)
|
||||
}
|
||||
|
||||
def throwIfInvalid(): Unit = {
|
||||
// No validation performed.
|
||||
}
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
package com.twitter.timelineranker.model
|
||||
|
||||
import com.twitter.timelineranker.{thriftscala => thrift}
|
||||
import com.twitter.timelineservice.model.TimelineId
|
||||
|
||||
case class RankedTimelineQuery(
|
||||
override val id: TimelineId,
|
||||
override val maxCount: Option[Int] = None,
|
||||
override val range: Option[TimelineRange] = None,
|
||||
override val options: Option[RankedTimelineQueryOptions] = None)
|
||||
extends TimelineQuery(thrift.TimelineQueryType.Ranked, id, maxCount, range, options) {
|
||||
|
||||
throwIfInvalid()
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
package com.twitter.timelineranker.model
|
||||
|
||||
import com.twitter.timelineranker.{thriftscala => thrift}
|
||||
|
||||
object RankedTimelineQueryOptions {
|
||||
def fromThrift(options: thrift.RankedTimelineQueryOptions): RankedTimelineQueryOptions = {
|
||||
RankedTimelineQueryOptions(
|
||||
seenEntries = options.seenEntries.map(PriorSeenEntries.fromThrift)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
case class RankedTimelineQueryOptions(seenEntries: Option[PriorSeenEntries])
|
||||
extends TimelineQueryOptions {
|
||||
|
||||
throwIfInvalid()
|
||||
|
||||
def toThrift: thrift.RankedTimelineQueryOptions = {
|
||||
thrift.RankedTimelineQueryOptions(seenEntries = seenEntries.map(_.toThrift))
|
||||
}
|
||||
|
||||
def toTimelineQueryOptionsThrift: thrift.TimelineQueryOptions = {
|
||||
thrift.TimelineQueryOptions.RankedTimelineQueryOptions(toThrift)
|
||||
}
|
||||
|
||||
def throwIfInvalid(): Unit = {
|
||||
seenEntries.foreach(_.throwIfInvalid)
|
||||
}
|
||||
}
|
@ -0,0 +1,278 @@
|
||||
package com.twitter.timelineranker.model
|
||||
|
||||
import com.twitter.servo.util.Gate
|
||||
import com.twitter.timelines.model.candidate.CandidateTweetSourceId
|
||||
import com.twitter.timelineranker.{thriftscala => thrift}
|
||||
import com.twitter.timelines.common.model._
|
||||
import com.twitter.timelines.earlybird.common.options.EarlybirdOptions
|
||||
import com.twitter.timelines.earlybird.common.utils.SearchOperator
|
||||
import com.twitter.timelines.configapi.{
|
||||
DependencyProvider => ConfigApiDependencyProvider,
|
||||
FutureDependencyProvider => ConfigApiFutureDependencyProvider,
|
||||
_
|
||||
}
|
||||
import com.twitter.timelines.model.TweetId
|
||||
import com.twitter.timelines.model.UserId
|
||||
import com.twitter.timelineservice.DeviceContext
|
||||
|
||||
object RecapQuery {
|
||||
|
||||
val EngagedTweetsSupportedTweetKindOption: TweetKindOption.ValueSet = TweetKindOption(
|
||||
includeReplies = false,
|
||||
includeRetweets = false,
|
||||
includeExtendedReplies = false,
|
||||
includeOriginalTweetsAndQuotes = true
|
||||
)
|
||||
|
||||
val DefaultSearchOperator: SearchOperator.Value = SearchOperator.Exclude
|
||||
def fromThrift(query: thrift.RecapQuery): RecapQuery = {
|
||||
|
||||
RecapQuery(
|
||||
userId = query.userId,
|
||||
maxCount = query.maxCount,
|
||||
range = query.range.map(TimelineRange.fromThrift),
|
||||
options = query.options
|
||||
.map(options => TweetKindOption.fromThrift(options.to[Set]))
|
||||
.getOrElse(TweetKindOption.None),
|
||||
searchOperator = query.searchOperator
|
||||
.map(SearchOperator.fromThrift)
|
||||
.getOrElse(DefaultSearchOperator),
|
||||
earlybirdOptions = query.earlybirdOptions.map(EarlybirdOptions.fromThrift),
|
||||
deviceContext = query.deviceContext.map(DeviceContext.fromThrift),
|
||||
authorIds = query.authorIds,
|
||||
excludedTweetIds = query.excludedTweetIds,
|
||||
searchClientSubId = query.searchClientSubId,
|
||||
candidateTweetSourceId =
|
||||
query.candidateTweetSourceId.flatMap(CandidateTweetSourceId.fromThrift),
|
||||
hydratesContentFeatures = query.hydratesContentFeatures
|
||||
)
|
||||
}
|
||||
|
||||
def fromThrift(query: thrift.RecapHydrationQuery): RecapQuery = {
|
||||
require(query.tweetIds.nonEmpty, "tweetIds must be non-empty")
|
||||
|
||||
RecapQuery(
|
||||
userId = query.userId,
|
||||
tweetIds = Some(query.tweetIds),
|
||||
searchOperator = DefaultSearchOperator,
|
||||
earlybirdOptions = query.earlybirdOptions.map(EarlybirdOptions.fromThrift),
|
||||
deviceContext = query.deviceContext.map(DeviceContext.fromThrift),
|
||||
candidateTweetSourceId =
|
||||
query.candidateTweetSourceId.flatMap(CandidateTweetSourceId.fromThrift),
|
||||
hydratesContentFeatures = query.hydratesContentFeatures
|
||||
)
|
||||
}
|
||||
|
||||
def fromThrift(query: thrift.EngagedTweetsQuery): RecapQuery = {
|
||||
val options = query.tweetKindOptions
|
||||
.map(tweetKindOptions => TweetKindOption.fromThrift(tweetKindOptions.to[Set]))
|
||||
.getOrElse(TweetKindOption.None)
|
||||
|
||||
if (!(options.isEmpty ||
|
||||
(options == EngagedTweetsSupportedTweetKindOption))) {
|
||||
throw new IllegalArgumentException(s"Unsupported TweetKindOption value: $options")
|
||||
}
|
||||
|
||||
RecapQuery(
|
||||
userId = query.userId,
|
||||
maxCount = query.maxCount,
|
||||
range = query.range.map(TimelineRange.fromThrift),
|
||||
options = options,
|
||||
searchOperator = DefaultSearchOperator,
|
||||
earlybirdOptions = query.earlybirdOptions.map(EarlybirdOptions.fromThrift),
|
||||
deviceContext = query.deviceContext.map(DeviceContext.fromThrift),
|
||||
authorIds = query.userIds,
|
||||
excludedTweetIds = query.excludedTweetIds,
|
||||
)
|
||||
}
|
||||
|
||||
def fromThrift(query: thrift.EntityTweetsQuery): RecapQuery = {
|
||||
require(
|
||||
query.semanticCoreIds.isDefined,
|
||||
"entities(semanticCoreIds) can't be None"
|
||||
)
|
||||
val options = query.tweetKindOptions
|
||||
.map(tweetKindOptions => TweetKindOption.fromThrift(tweetKindOptions.to[Set]))
|
||||
.getOrElse(TweetKindOption.None)
|
||||
|
||||
RecapQuery(
|
||||
userId = query.userId,
|
||||
maxCount = query.maxCount,
|
||||
range = query.range.map(TimelineRange.fromThrift),
|
||||
options = options,
|
||||
searchOperator = DefaultSearchOperator,
|
||||
earlybirdOptions = query.earlybirdOptions.map(EarlybirdOptions.fromThrift),
|
||||
deviceContext = query.deviceContext.map(DeviceContext.fromThrift),
|
||||
excludedTweetIds = query.excludedTweetIds,
|
||||
semanticCoreIds = query.semanticCoreIds.map(_.map(SemanticCoreAnnotation.fromThrift).toSet),
|
||||
hashtags = query.hashtags.map(_.toSet),
|
||||
languages = query.languages.map(_.map(Language.fromThrift).toSet),
|
||||
candidateTweetSourceId =
|
||||
query.candidateTweetSourceId.flatMap(CandidateTweetSourceId.fromThrift),
|
||||
includeNullcastTweets = query.includeNullcastTweets,
|
||||
includeTweetsFromArchiveIndex = query.includeTweetsFromArchiveIndex,
|
||||
authorIds = query.authorIds,
|
||||
hydratesContentFeatures = query.hydratesContentFeatures
|
||||
)
|
||||
}
|
||||
|
||||
def fromThrift(query: thrift.UtegLikedByTweetsQuery): RecapQuery = {
|
||||
val options = query.tweetKindOptions
|
||||
.map(tweetKindOptions => TweetKindOption.fromThrift(tweetKindOptions.to[Set]))
|
||||
.getOrElse(TweetKindOption.None)
|
||||
|
||||
RecapQuery(
|
||||
userId = query.userId,
|
||||
maxCount = query.maxCount,
|
||||
range = query.range.map(TimelineRange.fromThrift),
|
||||
options = options,
|
||||
earlybirdOptions = query.earlybirdOptions.map(EarlybirdOptions.fromThrift),
|
||||
deviceContext = query.deviceContext.map(DeviceContext.fromThrift),
|
||||
excludedTweetIds = query.excludedTweetIds,
|
||||
utegLikedByTweetsOptions = for {
|
||||
utegCount <- query.utegCount
|
||||
weightedFollowings <- query.weightedFollowings.map(_.toMap)
|
||||
} yield {
|
||||
UtegLikedByTweetsOptions(
|
||||
utegCount = utegCount,
|
||||
isInNetwork = query.isInNetwork,
|
||||
weightedFollowings = weightedFollowings
|
||||
)
|
||||
},
|
||||
candidateTweetSourceId =
|
||||
query.candidateTweetSourceId.flatMap(CandidateTweetSourceId.fromThrift),
|
||||
hydratesContentFeatures = query.hydratesContentFeatures
|
||||
)
|
||||
}
|
||||
|
||||
val paramGate: (Param[Boolean] => Gate[RecapQuery]) = HasParams.paramGate
|
||||
|
||||
type DependencyProvider[+T] = ConfigApiDependencyProvider[RecapQuery, T]
|
||||
object DependencyProvider extends DependencyProviderFunctions[RecapQuery]
|
||||
|
||||
type FutureDependencyProvider[+T] = ConfigApiFutureDependencyProvider[RecapQuery, T]
|
||||
object FutureDependencyProvider extends FutureDependencyProviderFunctions[RecapQuery]
|
||||
}
|
||||
|
||||
/**
|
||||
* Model object corresponding to RecapQuery thrift struct.
|
||||
*/
|
||||
case class RecapQuery(
|
||||
userId: UserId,
|
||||
maxCount: Option[Int] = None,
|
||||
range: Option[TimelineRange] = None,
|
||||
options: TweetKindOption.ValueSet = TweetKindOption.None,
|
||||
searchOperator: SearchOperator.Value = RecapQuery.DefaultSearchOperator,
|
||||
earlybirdOptions: Option[EarlybirdOptions] = None,
|
||||
deviceContext: Option[DeviceContext] = None,
|
||||
authorIds: Option[Seq[UserId]] = None,
|
||||
tweetIds: Option[Seq[TweetId]] = None,
|
||||
semanticCoreIds: Option[Set[SemanticCoreAnnotation]] = None,
|
||||
hashtags: Option[Set[String]] = None,
|
||||
languages: Option[Set[Language]] = None,
|
||||
excludedTweetIds: Option[Seq[TweetId]] = None,
|
||||
// options used only for yml tweets
|
||||
utegLikedByTweetsOptions: Option[UtegLikedByTweetsOptions] = None,
|
||||
searchClientSubId: Option[String] = None,
|
||||
override val params: Params = Params.Empty,
|
||||
candidateTweetSourceId: Option[CandidateTweetSourceId.Value] = None,
|
||||
includeNullcastTweets: Option[Boolean] = None,
|
||||
includeTweetsFromArchiveIndex: Option[Boolean] = None,
|
||||
hydratesContentFeatures: Option[Boolean] = None)
|
||||
extends HasParams {
|
||||
|
||||
override def toString: String = {
|
||||
s"RecapQuery(userId: $userId, maxCount: $maxCount, range: $range, options: $options, searchOperator: $searchOperator, " +
|
||||
s"earlybirdOptions: $earlybirdOptions, deviceContext: $deviceContext, authorIds: $authorIds, " +
|
||||
s"tweetIds: $tweetIds, semanticCoreIds: $semanticCoreIds, hashtags: $hashtags, languages: $languages, excludedTweetIds: $excludedTweetIds, " +
|
||||
s"utegLikedByTweetsOptions: $utegLikedByTweetsOptions, searchClientSubId: $searchClientSubId, " +
|
||||
s"params: $params, candidateTweetSourceId: $candidateTweetSourceId, includeNullcastTweets: $includeNullcastTweets, " +
|
||||
s"includeTweetsFromArchiveIndex: $includeTweetsFromArchiveIndex), hydratesContentFeatures: $hydratesContentFeatures"
|
||||
}
|
||||
|
||||
def throwIfInvalid(): Unit = {
|
||||
def noDuplicates[T <: Traversable[_]](elements: T) = {
|
||||
elements.toSet.size == elements.size
|
||||
}
|
||||
|
||||
maxCount.foreach { max => require(max > 0, "maxCount must be a positive integer") }
|
||||
range.foreach(_.throwIfInvalid())
|
||||
earlybirdOptions.foreach(_.throwIfInvalid())
|
||||
tweetIds.foreach { ids => require(ids.nonEmpty, "tweetIds must be nonEmpty if present") }
|
||||
semanticCoreIds.foreach(_.foreach(_.throwIfInvalid()))
|
||||
languages.foreach(_.foreach(_.throwIfInvalid()))
|
||||
languages.foreach { langs =>
|
||||
require(langs.nonEmpty, "languages must be nonEmpty if present")
|
||||
require(noDuplicates(langs.map(_.language)), "languages must be unique")
|
||||
}
|
||||
}
|
||||
|
||||
throwIfInvalid()
|
||||
|
||||
def toThriftRecapQuery: thrift.RecapQuery = {
|
||||
val thriftOptions = Some(TweetKindOption.toThrift(options))
|
||||
thrift.RecapQuery(
|
||||
userId,
|
||||
maxCount,
|
||||
range.map(_.toTimelineRangeThrift),
|
||||
deprecatedMinCount = None,
|
||||
thriftOptions,
|
||||
earlybirdOptions.map(_.toThrift),
|
||||
deviceContext.map(_.toThrift),
|
||||
authorIds,
|
||||
excludedTweetIds,
|
||||
Some(SearchOperator.toThrift(searchOperator)),
|
||||
searchClientSubId,
|
||||
candidateTweetSourceId.flatMap(CandidateTweetSourceId.toThrift)
|
||||
)
|
||||
}
|
||||
|
||||
def toThriftRecapHydrationQuery: thrift.RecapHydrationQuery = {
|
||||
require(tweetIds.isDefined && tweetIds.get.nonEmpty, "tweetIds must be present")
|
||||
thrift.RecapHydrationQuery(
|
||||
userId,
|
||||
tweetIds.get,
|
||||
earlybirdOptions.map(_.toThrift),
|
||||
deviceContext.map(_.toThrift),
|
||||
candidateTweetSourceId.flatMap(CandidateTweetSourceId.toThrift)
|
||||
)
|
||||
}
|
||||
|
||||
def toThriftEntityTweetsQuery: thrift.EntityTweetsQuery = {
|
||||
val thriftTweetKindOptions = Some(TweetKindOption.toThrift(options))
|
||||
thrift.EntityTweetsQuery(
|
||||
userId = userId,
|
||||
maxCount = maxCount,
|
||||
range = range.map(_.toTimelineRangeThrift),
|
||||
tweetKindOptions = thriftTweetKindOptions,
|
||||
earlybirdOptions = earlybirdOptions.map(_.toThrift),
|
||||
deviceContext = deviceContext.map(_.toThrift),
|
||||
excludedTweetIds = excludedTweetIds,
|
||||
semanticCoreIds = semanticCoreIds.map(_.map(_.toThrift)),
|
||||
hashtags = hashtags,
|
||||
languages = languages.map(_.map(_.toThrift)),
|
||||
candidateTweetSourceId.flatMap(CandidateTweetSourceId.toThrift),
|
||||
includeNullcastTweets = includeNullcastTweets,
|
||||
includeTweetsFromArchiveIndex = includeTweetsFromArchiveIndex,
|
||||
authorIds = authorIds
|
||||
)
|
||||
}
|
||||
|
||||
def toThriftUtegLikedByTweetsQuery: thrift.UtegLikedByTweetsQuery = {
|
||||
|
||||
val thriftTweetKindOptions = Some(TweetKindOption.toThrift(options))
|
||||
thrift.UtegLikedByTweetsQuery(
|
||||
userId = userId,
|
||||
maxCount = maxCount,
|
||||
utegCount = utegLikedByTweetsOptions.map(_.utegCount),
|
||||
range = range.map(_.toTimelineRangeThrift),
|
||||
tweetKindOptions = thriftTweetKindOptions,
|
||||
earlybirdOptions = earlybirdOptions.map(_.toThrift),
|
||||
deviceContext = deviceContext.map(_.toThrift),
|
||||
excludedTweetIds = excludedTweetIds,
|
||||
isInNetwork = utegLikedByTweetsOptions.map(_.isInNetwork).get,
|
||||
weightedFollowings = utegLikedByTweetsOptions.map(_.weightedFollowings),
|
||||
candidateTweetSourceId = candidateTweetSourceId.flatMap(CandidateTweetSourceId.toThrift)
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
package com.twitter.timelineranker.model
|
||||
|
||||
import com.twitter.timelineranker.{thriftscala => thrift}
|
||||
import com.twitter.timelineservice.model.TimelineId
|
||||
|
||||
object ReverseChronTimelineQuery {
|
||||
def fromTimelineQuery(query: TimelineQuery): ReverseChronTimelineQuery = {
|
||||
query match {
|
||||
case q: ReverseChronTimelineQuery => q
|
||||
case _ => throw new IllegalArgumentException(s"Unsupported query type: $query")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case class ReverseChronTimelineQuery(
|
||||
override val id: TimelineId,
|
||||
override val maxCount: Option[Int] = None,
|
||||
override val range: Option[TimelineRange] = None,
|
||||
override val options: Option[ReverseChronTimelineQueryOptions] = None)
|
||||
extends TimelineQuery(thrift.TimelineQueryType.ReverseChron, id, maxCount, range, options) {
|
||||
|
||||
throwIfInvalid()
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
package com.twitter.timelineranker.model
|
||||
|
||||
import com.twitter.timelineranker.{thriftscala => thrift}
|
||||
|
||||
object ReverseChronTimelineQueryOptions {
|
||||
val Default: ReverseChronTimelineQueryOptions = ReverseChronTimelineQueryOptions()
|
||||
|
||||
def fromThrift(
|
||||
options: thrift.ReverseChronTimelineQueryOptions
|
||||
): ReverseChronTimelineQueryOptions = {
|
||||
ReverseChronTimelineQueryOptions(
|
||||
getTweetsFromArchiveIndex = options.getTweetsFromArchiveIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
case class ReverseChronTimelineQueryOptions(getTweetsFromArchiveIndex: Boolean = true)
|
||||
extends TimelineQueryOptions {
|
||||
|
||||
throwIfInvalid()
|
||||
|
||||
def toThrift: thrift.ReverseChronTimelineQueryOptions = {
|
||||
thrift.ReverseChronTimelineQueryOptions(getTweetsFromArchiveIndex = getTweetsFromArchiveIndex)
|
||||
}
|
||||
|
||||
def toTimelineQueryOptionsThrift: thrift.TimelineQueryOptions = {
|
||||
thrift.TimelineQueryOptions.ReverseChronTimelineQueryOptions(toThrift)
|
||||
}
|
||||
|
||||
def throwIfInvalid(): Unit = {}
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
package com.twitter.timelineranker.model
|
||||
|
||||
import com.twitter.timelineranker.{thriftscala => thrift}
|
||||
import com.twitter.util.Time
|
||||
|
||||
object TimeRange {
|
||||
val default: TimeRange = TimeRange(None, None)
|
||||
|
||||
def fromThrift(range: thrift.TimeRange): TimeRange = {
|
||||
TimeRange(
|
||||
from = range.fromMs.map(Time.fromMilliseconds),
|
||||
to = range.toMs.map(Time.fromMilliseconds)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
case class TimeRange(from: Option[Time], to: Option[Time]) extends TimelineRange {
|
||||
|
||||
throwIfInvalid()
|
||||
|
||||
def throwIfInvalid(): Unit = {
|
||||
(from, to) match {
|
||||
case (Some(fromTime), Some(toTime)) =>
|
||||
require(fromTime <= toTime, "from-time must be less than or equal to-time.")
|
||||
case _ => // valid, do nothing.
|
||||
}
|
||||
}
|
||||
|
||||
def toThrift: thrift.TimeRange = {
|
||||
thrift.TimeRange(
|
||||
fromMs = from.map(_.inMilliseconds),
|
||||
toMs = to.map(_.inMilliseconds)
|
||||
)
|
||||
}
|
||||
|
||||
def toTimelineRangeThrift: thrift.TimelineRange = {
|
||||
thrift.TimelineRange.TimeRange(toThrift)
|
||||
}
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
package com.twitter.timelineranker.model
|
||||
|
||||
import com.twitter.timelineranker.{thriftscala => thrift}
|
||||
import com.twitter.timelines.model.UserId
|
||||
import com.twitter.timelineservice.model.TimelineId
|
||||
import com.twitter.timelineservice.model.core.TimelineKind
|
||||
|
||||
object Timeline {
|
||||
def empty(id: TimelineId): Timeline = {
|
||||
Timeline(id, Nil)
|
||||
}
|
||||
|
||||
def fromThrift(timeline: thrift.Timeline): Timeline = {
|
||||
Timeline(
|
||||
id = TimelineId.fromThrift(timeline.id),
|
||||
entries = timeline.entries.map(TimelineEntryEnvelope.fromThrift)
|
||||
)
|
||||
}
|
||||
|
||||
def throwIfIdInvalid(id: TimelineId): Unit = {
|
||||
// Note: if we support timelines other than TimelineKind.home, we need to update
|
||||
// the implementation of userId method here and in TimelineQuery class.
|
||||
require(id.kind == TimelineKind.home, s"Expected TimelineKind.home, found: ${id.kind}")
|
||||
}
|
||||
}
|
||||
|
||||
case class Timeline(id: TimelineId, entries: Seq[TimelineEntryEnvelope]) {
|
||||
|
||||
throwIfInvalid()
|
||||
|
||||
def userId: UserId = {
|
||||
id.id
|
||||
}
|
||||
|
||||
def throwIfInvalid(): Unit = {
|
||||
Timeline.throwIfIdInvalid(id)
|
||||
entries.foreach(_.throwIfInvalid())
|
||||
}
|
||||
|
||||
def toThrift: thrift.Timeline = {
|
||||
thrift.Timeline(
|
||||
id = id.toThrift,
|
||||
entries = entries.map(_.toThrift)
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
package com.twitter.timelineranker.model
|
||||
|
||||
import com.twitter.timelineranker.{thriftscala => thrift}
|
||||
|
||||
object TimelineEntry {
|
||||
def fromThrift(entry: thrift.TimelineEntry): TimelineEntry = {
|
||||
entry match {
|
||||
case thrift.TimelineEntry.Tweet(e) => Tweet.fromThrift(e)
|
||||
case thrift.TimelineEntry.TweetypieTweet(e) => new HydratedTweetEntry(e)
|
||||
case _ => throw new IllegalArgumentException(s"Unsupported type: $entry")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trait TimelineEntry {
|
||||
def toTimelineEntryThrift: thrift.TimelineEntry
|
||||
def throwIfInvalid(): Unit
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
package com.twitter.timelineranker.model
|
||||
|
||||
import com.twitter.timelineranker.{thriftscala => thrift}
|
||||
|
||||
object TimelineEntryEnvelope {
|
||||
def fromThrift(entryEnvelope: thrift.TimelineEntryEnvelope): TimelineEntryEnvelope = {
|
||||
TimelineEntryEnvelope(
|
||||
entry = TimelineEntry.fromThrift(entryEnvelope.entry)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
case class TimelineEntryEnvelope(entry: TimelineEntry) {
|
||||
|
||||
throwIfInvalid()
|
||||
|
||||
def toThrift: thrift.TimelineEntryEnvelope = {
|
||||
thrift.TimelineEntryEnvelope(entry.toTimelineEntryThrift)
|
||||
}
|
||||
|
||||
def throwIfInvalid(): Unit = {
|
||||
entry.throwIfInvalid()
|
||||
}
|
||||
}
|
@ -0,0 +1,82 @@
|
||||
package com.twitter.timelineranker.model
|
||||
|
||||
import com.twitter.timelineranker.{thriftscala => thrift}
|
||||
import com.twitter.timelines.model.UserId
|
||||
import com.twitter.timelineservice.model.TimelineId
|
||||
|
||||
object TimelineQuery {
|
||||
def fromThrift(query: thrift.TimelineQuery): TimelineQuery = {
|
||||
val queryType = query.queryType
|
||||
val id = TimelineId.fromThrift(query.timelineId)
|
||||
val maxCount = query.maxCount
|
||||
val range = query.range.map(TimelineRange.fromThrift)
|
||||
val options = query.options.map(TimelineQueryOptions.fromThrift)
|
||||
|
||||
queryType match {
|
||||
case thrift.TimelineQueryType.Ranked =>
|
||||
val rankedOptions = getRankedOptions(options)
|
||||
RankedTimelineQuery(id, maxCount, range, rankedOptions)
|
||||
|
||||
case thrift.TimelineQueryType.ReverseChron =>
|
||||
val reverseChronOptions = getReverseChronOptions(options)
|
||||
ReverseChronTimelineQuery(id, maxCount, range, reverseChronOptions)
|
||||
|
||||
case _ =>
|
||||
throw new IllegalArgumentException(s"Unsupported query type: $queryType")
|
||||
}
|
||||
}
|
||||
|
||||
def getRankedOptions(
|
||||
options: Option[TimelineQueryOptions]
|
||||
): Option[RankedTimelineQueryOptions] = {
|
||||
options.map {
|
||||
case o: RankedTimelineQueryOptions => o
|
||||
case _ =>
|
||||
throw new IllegalArgumentException(
|
||||
"Only RankedTimelineQueryOptions are supported when queryType is TimelineQueryType.Ranked"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
def getReverseChronOptions(
|
||||
options: Option[TimelineQueryOptions]
|
||||
): Option[ReverseChronTimelineQueryOptions] = {
|
||||
options.map {
|
||||
case o: ReverseChronTimelineQueryOptions => o
|
||||
case _ =>
|
||||
throw new IllegalArgumentException(
|
||||
"Only ReverseChronTimelineQueryOptions are supported when queryType is TimelineQueryType.ReverseChron"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class TimelineQuery(
|
||||
private val queryType: thrift.TimelineQueryType,
|
||||
val id: TimelineId,
|
||||
val maxCount: Option[Int],
|
||||
val range: Option[TimelineRange],
|
||||
val options: Option[TimelineQueryOptions]) {
|
||||
|
||||
throwIfInvalid()
|
||||
|
||||
def userId: UserId = {
|
||||
id.id
|
||||
}
|
||||
|
||||
def throwIfInvalid(): Unit = {
|
||||
Timeline.throwIfIdInvalid(id)
|
||||
range.foreach(_.throwIfInvalid())
|
||||
options.foreach(_.throwIfInvalid())
|
||||
}
|
||||
|
||||
def toThrift: thrift.TimelineQuery = {
|
||||
thrift.TimelineQuery(
|
||||
queryType = queryType,
|
||||
timelineId = id.toThrift,
|
||||
maxCount = maxCount,
|
||||
range = range.map(_.toTimelineRangeThrift),
|
||||
options = options.map(_.toTimelineQueryOptionsThrift)
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
package com.twitter.timelineranker.model
|
||||
|
||||
import com.twitter.timelineranker.{thriftscala => thrift}
|
||||
|
||||
object TimelineQueryOptions {
|
||||
def fromThrift(options: thrift.TimelineQueryOptions): TimelineQueryOptions = {
|
||||
options match {
|
||||
case thrift.TimelineQueryOptions.RankedTimelineQueryOptions(r) =>
|
||||
RankedTimelineQueryOptions.fromThrift(r)
|
||||
case thrift.TimelineQueryOptions.ReverseChronTimelineQueryOptions(r) =>
|
||||
ReverseChronTimelineQueryOptions.fromThrift(r)
|
||||
case _ => throw new IllegalArgumentException(s"Unsupported type: $options")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trait TimelineQueryOptions {
|
||||
def toTimelineQueryOptionsThrift: thrift.TimelineQueryOptions
|
||||
def throwIfInvalid(): Unit
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
package com.twitter.timelineranker.model
|
||||
|
||||
import com.twitter.timelineranker.{thriftscala => thrift}
|
||||
|
||||
object TimelineRange {
|
||||
def fromThrift(range: thrift.TimelineRange): TimelineRange = {
|
||||
range match {
|
||||
case thrift.TimelineRange.TimeRange(r) => TimeRange.fromThrift(r)
|
||||
case thrift.TimelineRange.TweetIdRange(r) => TweetIdRange.fromThrift(r)
|
||||
case _ => throw new IllegalArgumentException(s"Unsupported type: $range")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trait TimelineRange {
|
||||
def toTimelineRangeThrift: thrift.TimelineRange
|
||||
def throwIfInvalid(): Unit
|
||||
}
|
@ -0,0 +1,62 @@
|
||||
package com.twitter.timelineranker.model
|
||||
|
||||
import com.twitter.search.earlybird.thriftscala._
|
||||
import com.twitter.timelineranker.{thriftscala => thrift}
|
||||
import com.twitter.timelines.model.TweetId
|
||||
import com.twitter.timelines.model.UserId
|
||||
|
||||
object Tweet {
|
||||
def fromThrift(tweet: thrift.Tweet): Tweet = {
|
||||
Tweet(id = tweet.id)
|
||||
}
|
||||
}
|
||||
|
||||
case class Tweet(
|
||||
id: TweetId,
|
||||
userId: Option[UserId] = None,
|
||||
sourceTweetId: Option[TweetId] = None,
|
||||
sourceUserId: Option[UserId] = None)
|
||||
extends TimelineEntry {
|
||||
|
||||
throwIfInvalid()
|
||||
|
||||
def throwIfInvalid(): Unit = {}
|
||||
|
||||
def toThrift: thrift.Tweet = {
|
||||
thrift.Tweet(
|
||||
id = id,
|
||||
userId = userId,
|
||||
sourceTweetId = sourceTweetId,
|
||||
sourceUserId = sourceUserId)
|
||||
}
|
||||
|
||||
def toTimelineEntryThrift: thrift.TimelineEntry = {
|
||||
thrift.TimelineEntry.Tweet(toThrift)
|
||||
}
|
||||
|
||||
def toThriftSearchResult: ThriftSearchResult = {
|
||||
val metadata = ThriftSearchResultMetadata(
|
||||
resultType = ThriftSearchResultType.Recency,
|
||||
fromUserId = userId match {
|
||||
case Some(id) => id
|
||||
case None => 0L
|
||||
},
|
||||
isRetweet =
|
||||
if (sourceUserId.isDefined || sourceUserId.isDefined) Some(true)
|
||||
else
|
||||
None,
|
||||
sharedStatusId = sourceTweetId match {
|
||||
case Some(id) => id
|
||||
case None => 0L
|
||||
},
|
||||
referencedTweetAuthorId = sourceUserId match {
|
||||
case Some(id) => id
|
||||
case None => 0L
|
||||
}
|
||||
)
|
||||
ThriftSearchResult(
|
||||
id = id,
|
||||
metadata = Some(metadata)
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,53 @@
|
||||
package com.twitter.timelineranker.model
|
||||
|
||||
import com.twitter.timelineranker.{thriftscala => thrift}
|
||||
import com.twitter.timelines.model.TweetId
|
||||
|
||||
object TweetIdRange {
|
||||
val default: TweetIdRange = TweetIdRange(None, None)
|
||||
val empty: TweetIdRange = TweetIdRange(Some(0L), Some(0L))
|
||||
|
||||
def fromThrift(range: thrift.TweetIdRange): TweetIdRange = {
|
||||
TweetIdRange(fromId = range.fromId, toId = range.toId)
|
||||
}
|
||||
|
||||
def fromTimelineRange(range: TimelineRange): TweetIdRange = {
|
||||
range match {
|
||||
case r: TweetIdRange => r
|
||||
case _ =>
|
||||
throw new IllegalArgumentException(s"Only Tweet ID range is supported. Found: $range")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A range of Tweet IDs with exclusive bounds.
|
||||
*/
|
||||
case class TweetIdRange(fromId: Option[TweetId] = None, toId: Option[TweetId] = None)
|
||||
extends TimelineRange {
|
||||
|
||||
throwIfInvalid()
|
||||
|
||||
def throwIfInvalid(): Unit = {
|
||||
(fromId, toId) match {
|
||||
case (Some(fromTweetId), Some(toTweetId)) =>
|
||||
require(fromTweetId <= toTweetId, "fromId must be less than or equal to toId.")
|
||||
case _ => // valid, do nothing.
|
||||
}
|
||||
}
|
||||
|
||||
def toThrift: thrift.TweetIdRange = {
|
||||
thrift.TweetIdRange(fromId = fromId, toId = toId)
|
||||
}
|
||||
|
||||
def toTimelineRangeThrift: thrift.TimelineRange = {
|
||||
thrift.TimelineRange.TweetIdRange(toThrift)
|
||||
}
|
||||
|
||||
def isEmpty: Boolean = {
|
||||
(fromId, toId) match {
|
||||
case (Some(fromId), Some(toId)) if fromId == toId => true
|
||||
case _ => false
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
package com.twitter.timelineranker.model
|
||||
|
||||
import com.twitter.timelines.model.UserId
|
||||
|
||||
case class UtegLikedByTweetsOptions(
|
||||
utegCount: Int,
|
||||
isInNetwork: Boolean,
|
||||
weightedFollowings: Map[UserId, Double])
|
Reference in New Issue
Block a user