Bluesky feed server - NSFW Likes

wip: Filter out non-labeled posts

Currently only works on my last 100 likes

Changed files
+74 -28
app
src
main
kotlin
lexicon
app
bsky
feed
com
atproto
label
defs
resources
gradle
+1
app/build.gradle.kts
··· 14 14 implementation(libs.ktor.client.cio) 15 15 implementation(libs.ktor.client.content.negotiation) 16 16 implementation(libs.ktor.client.logging) 17 + implementation(libs.ktor.client.auth) 17 18 implementation(libs.ktor.server.core) 18 19 implementation(libs.ktor.server.netty) 19 20 implementation(libs.ktor.server.content.negotiation)
+1 -1
app/src/main/kotlin/Application.kt
··· 39 39 pdsUrl = buildUrl { 40 40 protocol = URLProtocol.HTTPS 41 41 host = ownerPds 42 - } 42 + }, 43 43 ) 44 44 45 45 // Make sure the feed generator record exists and points to the current
+46 -15
app/src/main/kotlin/BskyApi.kt
··· 8 8 import io.ktor.client.call.* 9 9 import io.ktor.client.engine.cio.* 10 10 import io.ktor.client.plugins.* 11 + import io.ktor.client.plugins.auth.* 12 + import io.ktor.client.plugins.auth.providers.* 11 13 import io.ktor.client.plugins.contentnegotiation.* 12 14 import io.ktor.client.plugins.logging.* 13 15 import io.ktor.client.request.* ··· 18 20 import kotlinx.serialization.json.Json 19 21 20 22 class BskyApi( 21 - private var pdsUrl: Url = Url("https://bsky.social"), 23 + private val pdsUrl: Url = Url("https://bsky.social"), 24 + 25 + private val bearerTokens: MutableList<BearerTokens> = mutableListOf(), 22 26 23 27 private val httpClient: HttpClient = HttpClient(CIO) { 24 28 install(ContentNegotiation) { ··· 30 34 31 35 install(Logging) 32 36 37 + install(Auth) { 38 + bearer { 39 + loadTokens { 40 + bearerTokens.lastOrNull() 41 + } 42 + 43 + refreshTokens { 44 + val currentRefreshToken = bearerTokens.lastOrNull()?.refreshToken ?: return@refreshTokens null 45 + 46 + @Serializable 47 + data class Response(val accessJwt: String, val refreshJwt: String) 48 + 49 + val refreshSessionResponse = client.post("com.atproto.server.refreshSession") { 50 + header("Authorization", "Bearer $currentRefreshToken") 51 + markAsRefreshTokenRequest() 52 + } 53 + 54 + when (refreshSessionResponse.status) { 55 + HttpStatusCode.OK -> { 56 + val refreshSessionTokens = refreshSessionResponse.body<Response>() 57 + val newBearerTokens = 58 + BearerTokens(refreshSessionTokens.accessJwt, refreshSessionTokens.refreshJwt) 59 + 60 + bearerTokens.addLast(newBearerTokens) 61 + 62 + return@refreshTokens newBearerTokens 63 + } 64 + 65 + HttpStatusCode.BadRequest, 66 + HttpStatusCode.Unauthorized -> return@refreshTokens null 67 + 68 + else -> return@refreshTokens null 69 + } 70 + } 71 + } 72 + } 73 + 33 74 defaultRequest { 34 75 url { 35 76 protocol = pdsUrl.protocol ··· 39 80 } 40 81 }, 41 82 ) { 42 - 43 - 44 - data class AuthTokens( 45 - val accessJwt: String, 46 - val refreshJwt: String, 47 - val did: String, 48 - ) 49 - 50 - private var authTokens: AuthTokens? = null 51 - 52 83 @Serializable 53 84 data class ErrorResponse( 54 85 val error: String, ··· 70 101 when (response.status) { 71 102 HttpStatusCode.OK -> { 72 103 val tokens: Response = response.body() 73 - this.authTokens = AuthTokens(tokens.accessJwt, tokens.refreshJwt, tokens.did) 104 + bearerTokens.addLast(BearerTokens(tokens.accessJwt, tokens.refreshJwt)) 74 105 } 75 106 76 107 HttpStatusCode.BadRequest, ··· 111 142 @Serializable 112 143 data class Request( 113 144 val repo: String, 145 + val collection: String, 114 146 val rkey: String, 115 147 val record: Generator, 116 - val collection: String = "app.bsky.feed.generator", 117 148 ) 118 149 119 150 val response = httpClient.post("com.atproto.repo.putRecord") { 120 151 contentType(ContentType.Application.Json) 121 - setBody(Request(repo, rkey, record)) 152 + setBody(Request(repo, "app.bsky.feed.generator", rkey, record)) 122 153 } 123 154 124 155 when (response.status) { ··· 175 206 else -> throw RuntimeException("Unexpected response received: ${response.bodyAsText()}") 176 207 } 177 208 } 178 - } 209 + }
+18 -8
app/src/main/kotlin/DarkFeedApi.kt
··· 1 1 package gay.averyrivers 2 2 3 3 import gay.averyrivers.lexicon.app.bsky.feed.FeedSkeleton 4 + import gay.averyrivers.lexicon.app.bsky.feed.defs.PostView 4 5 import gay.averyrivers.lexicon.app.bsky.feed.defs.SkeletonFeedPost 5 6 import io.ktor.serialization.kotlinx.json.* 6 7 import io.ktor.server.application.* ··· 68 69 ) 69 70 } 70 71 71 - // SOOOOO 72 - // I NEED TO CHECK THE SELF LABEL (at://did:plc:vwivwqztbf6pmkgss3nv2scy/app.bsky.feed.post/3la5sxh4ica2r) 73 - // AND THE MODERATION.BSKY.APP LABEL (at://did:plc:ujrpupcjf22a4riwjbupdv42/app.bsky.feed.post/3la5qntezsu2v) 74 - 75 72 private suspend fun handleGetFeedSkeleton(call: RoutingCall) { 76 73 // TODO: Get requestor's DID from Authorization header. 77 74 call.respond(buildFeedSkeleton("did:plc:zhxv5pxpmojhnvaqy4mwailv")) 78 75 } 79 76 80 77 private suspend fun buildFeedSkeleton(requestor: String): FeedSkeleton { 78 + val actorLikes = bskyApi.getLikesByActor(requestor) 79 + .first 80 + .map { likeRef -> likeRef.value.subject.uri } 81 + 82 + val labeledPosts = actorLikes 83 + .chunked(25) 84 + .map { chunkedActorLikes -> 85 + bskyApi.getPostLabels(chunkedActorLikes) 86 + .filter { post -> 87 + post.labels?.any { label -> listOf("porn", "sexual").contains(label.value) } ?: false 88 + } 89 + } 90 + .flatten() 91 + 92 + 81 93 return FeedSkeleton( 82 - feed = bskyApi.getLikesByActor(requestor) 83 - .first 84 - .map { likeRef -> SkeletonFeedPost(post = likeRef.value.subject.uri) } 94 + feed = labeledPosts.map { post -> SkeletonFeedPost(post = post.uri) } 85 95 ) 86 96 } 87 - } 97 + }
+2 -2
app/src/main/kotlin/lexicon/app/bsky/feed/defs/PostView.kt
··· 7 7 data class PostView( 8 8 val uri: String, 9 9 val cid: String, 10 - val labels: List<Label>, 11 - ) 10 + val labels: List<Label>?, 11 + )
+3 -1
app/src/main/kotlin/lexicon/com/atproto/label/defs/Label.kt
··· 1 1 package gay.averyrivers.lexicon.com.atproto.label.defs 2 2 3 + import kotlinx.serialization.SerialName 3 4 import kotlinx.serialization.Serializable 4 5 5 6 @Serializable ··· 8 9 val src: String, 9 10 val uri: String, 10 11 val cid: String?, 12 + @SerialName("val") 11 13 val value: String, 12 14 val neg: Boolean?, 13 15 val cts: String, 14 16 val exp: String?, 15 - ) 17 + )
+2 -1
app/src/main/resources/logback.xml
··· 8 8 <appender-ref ref="STDOUT"/> 9 9 </root> 10 10 <logger name="io.netty" level="INFO"/> 11 - <logger name="io.ktor" level="TRACE"/> 11 + <logger name="io.ktor.client" level="INFO"/> 12 + <logger name="io.ktor.server" level="INFO"/> 12 13 </configuration>
+1
gradle/libs.versions.toml
··· 9 9 ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio", version.ref = "ktor" } 10 10 ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } 11 11 ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" } 12 + ktor-client-auth = { group = "io.ktor", name = "ktor-client-auth", version.ref = "ktor" } 12 13 ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" } 13 14 ktor-server-netty = { group = "io.ktor", name = "ktor-server-netty", version.ref = "ktor" } 14 15 ktor-server-content-negotiation = { group = "io.ktor", name = "ktor-server-content-negotiation", version.ref = "ktor" }