Bluesky feed server - NSFW Likes

wip: Big refactor

Closes #2

Changed files
+160 -150
.fleet
app
src
main
kotlin
lexicon
app
bsky
darkfeed
src
main
kotlin
resources
+2 -2
.fleet/run.json
··· 1 1 { 2 2 "configurations": [ 3 3 { 4 - "name": "darkfeed [:app:run]", 4 + "name": "darkfeed [:darkfeed:run]", 5 5 "type": "gradle", 6 6 "workingDir": "$PROJECT_DIR$", 7 7 "tasks": [ 8 - ":app:run" 8 + ":darkfeed:run" 9 9 ], 10 10 "environmentFile": "local.env", 11 11 "initScripts": {
+3 -3
Containerfile
··· 1 1 FROM registry.access.redhat.com/ubi8/openjdk-21 AS build 2 2 WORKDIR /build 3 3 COPY --chown=jboss / . 4 - RUN ./gradlew --no-daemon :app:installDist 4 + RUN ./gradlew --no-daemon :darkfeed:installDist 5 5 6 6 FROM registry.access.redhat.com/ubi8/openjdk-21 7 - COPY --from=build /build/app/build/install/app /app 8 - ENTRYPOINT /app/bin/app 7 + COPY --from=build /build/darkfeed/build/install/darkfeed /app 8 + ENTRYPOINT /app/bin/darkfeed
+1 -2
app/build.gradle.kts darkfeed/build.gradle.kts
··· 29 29 } 30 30 31 31 application { 32 - mainClass = "gay.averyrivers.ApplicationKt" 33 - applicationDefaultJvmArgs = listOf("-Dio.ktor.development=true") 32 + mainClass = "MainKt" 34 33 }
-92
app/src/main/kotlin/Application.kt
··· 1 - package gay.averyrivers 2 - 3 - import gay.averyrivers.lexicon.app.bsky.feed.Generator 4 - import gay.averyrivers.lexicon.app.bsky.feed.Like 5 - import gay.averyrivers.lexicon.app.bsky.feed.Post 6 - import io.ktor.client.* 7 - import io.ktor.client.call.* 8 - import io.ktor.client.engine.cio.* 9 - import io.ktor.client.request.* 10 - import io.ktor.client.statement.* 11 - import io.ktor.http.* 12 - import io.ktor.serialization.kotlinx.json.* 13 - import io.ktor.server.application.* 14 - import io.ktor.server.engine.* 15 - import io.ktor.server.netty.* 16 - import io.ktor.server.plugins.contentnegotiation.* 17 - import io.ktor.server.response.* 18 - import io.ktor.server.routing.* 19 - import io.ktor.server.util.* 20 - import kotlinx.coroutines.runBlocking 21 - import kotlinx.serialization.Contextual 22 - import kotlinx.serialization.SerialName 23 - import kotlinx.serialization.Serializable 24 - import kotlinx.serialization.json.Json 25 - import java.util.logging.Logger 26 - 27 - const val FEED_DISPLAY_NAME = "DarkFeed" 28 - const val FEED_DESCRIPTION = "Hi!" 29 - const val FEED_RECORD_KEY = "darkfeed" 30 - 31 - fun main() { 32 - val ownerPds = System.getenv("DF_OWNER_PDS") 33 - val ownerDid = System.getenv("DF_OWNER_DID") 34 - val ownerAppPassword = System.getenv("DF_OWNER_PASSWORD") 35 - val hostname = System.getenv("DF_HOSTNAME") 36 - 37 - val api = BskyApi( 38 - pdsUrl = buildUrl { 39 - protocol = URLProtocol.HTTPS 40 - host = ownerPds 41 - }, 42 - ) 43 - 44 - // Make sure the feed generator record exists and points to the current 45 - // feed generator's hostname. 46 - runBlocking { 47 - api.login(ownerDid, ownerAppPassword) 48 - 49 - try { 50 - verifyAndUpdateFeedGeneratorRecord(api, ownerDid, FEED_RECORD_KEY, hostname) 51 - println("Successfully set feed generator record") 52 - } catch (error: Exception) { 53 - println("Failed to verify and update feed generator record: ${error.message}") 54 - } 55 - } 56 - 57 - // Serve the feed generator API. 58 - DarkFeedApi( 59 - hostname = hostname, 60 - bskyApi = api, 61 - port = 8080, 62 - ).serve() 63 - } 64 - 65 - /** 66 - * Verify the current feed generator record, creating or updating it if necessary. 67 - * 68 - * @param api Bluesky API instance. Requires login. 69 - * @param repo Owner of the record. 70 - * @param rkey Record key of the record to check. 71 - * @param labelerHostname Hostname of the feed generator. 72 - */ 73 - suspend fun verifyAndUpdateFeedGeneratorRecord(api: BskyApi, repo: String, rkey: String, labelerHostname: String) { 74 - // Get the current record. 75 - var feedGeneratorRecord = api.getFeedGeneratorRecord(repo, rkey) 76 - 77 - // If the current record exists and has the correct DID, nothing needs to be done. 78 - if (feedGeneratorRecord?.did?.contains(labelerHostname) == true) return 79 - 80 - // Update the current record if one exists, or create a new one if it doesn't. 81 - feedGeneratorRecord = feedGeneratorRecord 82 - ?.copy(did = "did:web:$labelerHostname") 83 - ?: Generator( 84 - did = "did:web:$labelerHostname", 85 - displayName = FEED_DISPLAY_NAME, 86 - description = FEED_DESCRIPTION, 87 - createdAt = "2024-11-04T15:58:05.074Z", 88 - ) 89 - 90 - // Store the new/updated record in the repo. 91 - api.putFeedGeneratorRecord(repo, rkey, feedGeneratorRecord) 92 - }
+4 -5
app/src/main/kotlin/BskyApi.kt darkfeed/src/main/kotlin/api/BskyApi.kt
··· 1 - package gay.averyrivers 1 + package api 2 2 3 - import gay.averyrivers.lexicon.app.bsky.feed.Generator 4 - import gay.averyrivers.lexicon.app.bsky.feed.LikeRef 5 - import gay.averyrivers.lexicon.app.bsky.feed.defs.PostView 6 - import gay.averyrivers.lexicon.com.atproto.label.defs.Label 3 + import api.lexicon.app.bsky.feed.Generator 4 + import api.lexicon.app.bsky.feed.LikeRef 5 + import api.lexicon.app.bsky.feed.defs.PostView 7 6 import io.ktor.client.* 8 7 import io.ktor.client.call.* 9 8 import io.ktor.client.engine.cio.*
+6 -5
app/src/main/kotlin/DarkFeedApi.kt darkfeed/src/main/kotlin/server/FeedServer.kt
··· 1 - package gay.averyrivers 1 + package server 2 2 3 + import api.BskyApi 4 + import api.lexicon.app.bsky.feed.FeedSkeleton 5 + import api.lexicon.app.bsky.feed.defs.PostView 6 + import api.lexicon.app.bsky.feed.defs.SkeletonFeedPost 3 7 import com.auth0.jwt.JWT 4 - import gay.averyrivers.lexicon.app.bsky.feed.FeedSkeleton 5 - import gay.averyrivers.lexicon.app.bsky.feed.defs.PostView 6 - import gay.averyrivers.lexicon.app.bsky.feed.defs.SkeletonFeedPost 7 8 import io.ktor.serialization.kotlinx.json.* 8 9 import io.ktor.server.application.* 9 10 import io.ktor.server.engine.* ··· 17 18 18 19 val DESIRED_LABELS: List<String> = listOf("porn", "sexual", "nudity", "sexual-figurative") 19 20 20 - class DarkFeedApi( 21 + class FeedServer( 21 22 private val hostname: String, 22 23 private val bskyApi: BskyApi, 23 24 private val port: Int = 8080,
-10
app/src/main/kotlin/lexicon/app/bsky/feed/FeedSkeleton.kt
··· 1 - package gay.averyrivers.lexicon.app.bsky.feed 2 - 3 - import gay.averyrivers.lexicon.app.bsky.feed.defs.SkeletonFeedPost 4 - import kotlinx.serialization.Serializable 5 - 6 - @Serializable 7 - data class FeedSkeleton( 8 - val cursor: String? = null, 9 - val feed: List<SkeletonFeedPost>, 10 - )
+1 -1
app/src/main/kotlin/lexicon/app/bsky/feed/Generator.kt darkfeed/src/main/kotlin/api/lexicon/app/bsky/feed/Generator.kt
··· 1 - package gay.averyrivers.lexicon.app.bsky.feed 1 + package api.lexicon.app.bsky.feed 2 2 3 3 import kotlinx.serialization.Serializable 4 4
+3 -3
app/src/main/kotlin/lexicon/app/bsky/feed/Like.kt darkfeed/src/main/kotlin/api/lexicon/app/bsky/feed/Like.kt
··· 1 - package gay.averyrivers.lexicon.app.bsky.feed 1 + package api.lexicon.app.bsky.feed 2 2 3 - import gay.averyrivers.lexicon.com.atproto.repo.StrongRef 3 + import api.lexicon.com.atproto.repo.StrongRef 4 4 import kotlinx.serialization.Serializable 5 5 6 6 @Serializable ··· 14 14 val uri: String, 15 15 val cid: String, 16 16 val value: Like, 17 - ) 17 + )
+2 -2
app/src/main/kotlin/lexicon/app/bsky/feed/Post.kt darkfeed/src/main/kotlin/api/lexicon/app/bsky/feed/Post.kt
··· 1 - package gay.averyrivers.lexicon.app.bsky.feed 1 + package api.lexicon.app.bsky.feed 2 2 3 3 import kotlinx.serialization.Serializable 4 4 ··· 6 6 data class Post( 7 7 val text: String, 8 8 val createdAt: String, 9 - ) 9 + )
-11
app/src/main/kotlin/lexicon/app/bsky/feed/defs/PostView.kt
··· 1 - package gay.averyrivers.lexicon.app.bsky.feed.defs 2 - 3 - import gay.averyrivers.lexicon.com.atproto.label.defs.Label 4 - import kotlinx.serialization.Serializable 5 - 6 - @Serializable 7 - data class PostView( 8 - val uri: String, 9 - val cid: String, 10 - val labels: List<Label>?, 11 - )
+2 -2
app/src/main/kotlin/lexicon/app/bsky/feed/defs/SkeletonFeedPost.kt darkfeed/src/main/kotlin/api/lexicon/app/bsky/feed/defs/SkeletonFeedPost.kt
··· 1 - package gay.averyrivers.lexicon.app.bsky.feed.defs 1 + package api.lexicon.app.bsky.feed.defs 2 2 3 3 import kotlinx.serialization.Serializable 4 4 ··· 6 6 data class SkeletonFeedPost( 7 7 val post: String, 8 8 val feedContext: String? = null, 9 - ) 9 + )
+1 -1
app/src/main/kotlin/lexicon/com/atproto/label/defs/Label.kt darkfeed/src/main/kotlin/api/lexicon/com/atproto/label/defs/Label.kt
··· 1 - package gay.averyrivers.lexicon.com.atproto.label.defs 1 + package api.lexicon.com.atproto.label.defs 2 2 3 3 import kotlinx.serialization.SerialName 4 4 import kotlinx.serialization.Serializable
+2 -2
app/src/main/kotlin/lexicon/com/atproto/repo/StrongRef.kt darkfeed/src/main/kotlin/api/lexicon/com/atproto/repo/StrongRef.kt
··· 1 - package gay.averyrivers.lexicon.com.atproto.repo 1 + package api.lexicon.com.atproto.repo 2 2 3 3 import kotlinx.serialization.Serializable 4 4 ··· 6 6 data class StrongRef( 7 7 val uri: String, 8 8 val cid: String, 9 - ) 9 + )
app/src/main/resources/logback.xml darkfeed/src/main/resources/logback.xml
+108
darkfeed/src/main/kotlin/Main.kt
··· 1 + import api.BskyApi 2 + import api.lexicon.app.bsky.feed.Generator 3 + import io.ktor.http.* 4 + import kotlinx.coroutines.launch 5 + import kotlinx.coroutines.runBlocking 6 + import server.FeedServer 7 + import kotlin.system.exitProcess 8 + 9 + /** 10 + * 11 + */ 12 + data class AppContext( 13 + /** PDS of the feed owner's account. */ 14 + val ownerPds: String, 15 + /** DID of the feed owner's account. */ 16 + val ownerDid: String, 17 + /** Password for the feed owner's account. */ 18 + val ownerPassword: String, 19 + /** Hostname of the feed generator server. */ 20 + val hostname: String, 21 + /** Record key for the feed generator record. */ 22 + val recordKey: String = "darkfeed", 23 + /** Display name for the feed. */ 24 + val feedDisplayName: String = "DarkFeed", 25 + /** Description for the feed. */ 26 + val description: String = "hi :3", 27 + ) 28 + 29 + /** 30 + * Print a message and exit the application. 31 + * 32 + * @param message Message to print. 33 + * @param code Status code to exit with. 34 + */ 35 + fun printMessageAndExit(message: String, code: Int = 1): Nothing { 36 + println(message) 37 + exitProcess(code) 38 + } 39 + 40 + /** 41 + * Verify the current feed generator record, creating or updating it if necessary. 42 + * 43 + * @param api Bluesky API instance. Requires login. 44 + * @param ctx Application context. 45 + */ 46 + suspend fun verifyAndUpdateFeedGeneratorRecord(api: BskyApi, ctx: AppContext) { 47 + // Get the current record stored in the repo. 48 + var feedGeneratorRecord = api.getFeedGeneratorRecord(ctx.ownerDid, ctx.recordKey) 49 + 50 + // TODO: Check all fields of the record against the context. 51 + // If the current record exists and has the correct DID, nothing needs to be done. 52 + if (feedGeneratorRecord?.did?.contains(ctx.hostname) == true) return 53 + 54 + // Update the current record if one exists, or create a new one if it doesn't. 55 + feedGeneratorRecord = feedGeneratorRecord 56 + ?.copy(did = "did:web:${ctx.hostname}") 57 + ?: Generator( 58 + did = "did:web:${ctx.hostname}", 59 + displayName = ctx.feedDisplayName, 60 + description = ctx.description, 61 + // TODO: Use the real time here. 62 + createdAt = "2024-11-04T15:58:05.074Z" 63 + ) 64 + 65 + // Store the new/updated record in the repo. 66 + api.putFeedGeneratorRecord(ctx.ownerDid, ctx.recordKey, feedGeneratorRecord) 67 + } 68 + 69 + fun main() = runBlocking { 70 + // Create app context from environment variables. 71 + val ctx = AppContext( 72 + ownerPds = System.getenv("FEED_ACCOUNT_PDS") 73 + ?: "bsky.social", 74 + ownerDid = System.getenv("FEED_ACCOUNT_DID") 75 + ?: printMessageAndExit("error: variable FEED_ACCOUNT_DID not set"), 76 + ownerPassword = System.getenv("FEED_ACCOUNT_PASSWORD") 77 + ?: printMessageAndExit("error: variable FEED_ACCOUNT_PASSWORD not set"), 78 + hostname = System.getenv("HOSTNAME") 79 + ?: printMessageAndExit("error: variable HOSTNAME not set"), 80 + ) 81 + 82 + // Create API instance. 83 + val bskyApi = BskyApi(buildUrl { 84 + protocol = URLProtocol.HTTPS 85 + host = ctx.ownerPds 86 + }) 87 + 88 + // Verify and update the feed generator record. 89 + launch { 90 + bskyApi.login(ctx.ownerDid, ctx.ownerPassword) 91 + 92 + try { 93 + verifyAndUpdateFeedGeneratorRecord(bskyApi, ctx) 94 + println("main: feed generator record verified") 95 + } catch (error: Exception) { 96 + println("main: failed to verify and update feed generator record: ${error.message}") 97 + } 98 + }.join() 99 + 100 + println("main: starting feed generator server") 101 + 102 + // Start the feed server. 103 + FeedServer( 104 + hostname = ctx.hostname, 105 + bskyApi = bskyApi, 106 + port = 8080, 107 + ).serve() 108 + }
+10
darkfeed/src/main/kotlin/api/lexicon/app/bsky/feed/FeedSkeleton.kt
··· 1 + package api.lexicon.app.bsky.feed 2 + 3 + import api.lexicon.app.bsky.feed.defs.SkeletonFeedPost 4 + import kotlinx.serialization.Serializable 5 + 6 + @Serializable 7 + data class FeedSkeleton( 8 + val cursor: String? = null, 9 + val feed: List<SkeletonFeedPost>, 10 + )
+11
darkfeed/src/main/kotlin/api/lexicon/app/bsky/feed/defs/PostView.kt
··· 1 + package api.lexicon.app.bsky.feed.defs 2 + 3 + import api.lexicon.com.atproto.label.defs.Label 4 + import kotlinx.serialization.Serializable 5 + 6 + @Serializable 7 + data class PostView( 8 + val uri: String, 9 + val cid: String, 10 + val labels: List<Label>?, 11 + )
+3 -8
fly.toml
··· 1 - # fly.toml app configuration file generated for darkfeed on 2024-11-05T20:07:05-05:00 2 - # 3 - # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 - # 5 - 6 1 app = 'darkfeed' 7 2 primary_region = 'iad' 8 3 ··· 23 18 memory = '512mb' 24 19 25 20 [env] 26 - DF_OWNER_PDS = 'bsky.social' 27 - DF_OWNER_DID = 'did:plc:zhxv5pxpmojhnvaqy4mwailv' 28 - DF_HOSTNAME = 'darkfeed.fly.dev' 21 + FEED_ACCOUNT_PDS = 'bsky.social' 22 + FEED_ACCOUNT_DID = 'did:plc:zhxv5pxpmojhnvaqy4mwailv' 23 + HOSTNAME = 'darkfeed.fly.dev'
+1 -1
settings.gradle.kts
··· 12 12 } 13 13 14 14 rootProject.name = "darkfeed" 15 - include("app") 15 + include("darkfeed")