Bluesky feed server - NSFW Likes

feat: Add command line parsing

Closes #3, #5, and #8

Changed files
+135 -98
.fleet
darkfeed
src
main
kotlin
resources
gradle
+3
.fleet/run.json
··· 7 7 "tasks": [ 8 8 ":darkfeed:run" 9 9 ], 10 + "args": [ 11 + "--args=\"--debug\"" 12 + ], 10 13 "environmentFile": "local.env", 11 14 "initScripts": { 12 15 "flmapper": "ext.mapPath = { path -> path }"
+1
darkfeed/build.gradle.kts
··· 20 20 implementation(libs.ktor.server.content.negotiation) 21 21 implementation(libs.ktor.serialization.kotlinx.json) 22 22 implementation(libs.java.jwt) 23 + implementation(libs.clikt) 23 24 } 24 25 25 26 java {
+110 -91
darkfeed/src/main/kotlin/Main.kt
··· 1 1 package rs.averyrive.darkfeed 2 2 3 - import AuthAccount 4 - import AuthManager 5 - import io.ktor.http.* 6 - import kotlinx.coroutines.launch 7 - import kotlinx.coroutines.runBlocking 3 + import ch.qos.logback.classic.Level 4 + import ch.qos.logback.classic.LoggerContext 5 + import com.github.ajalt.clikt.command.SuspendingCliktCommand 6 + import com.github.ajalt.clikt.command.main 7 + import com.github.ajalt.clikt.parameters.options.default 8 + import com.github.ajalt.clikt.parameters.options.flag 9 + import com.github.ajalt.clikt.parameters.options.option 10 + import com.github.ajalt.clikt.parameters.options.required 11 + import com.github.ajalt.clikt.parameters.types.int 12 + import org.slf4j.LoggerFactory 13 + import rs.averyrive.darkfeed.api.AuthAccount 14 + import rs.averyrive.darkfeed.api.AuthManager 8 15 import rs.averyrive.darkfeed.api.BskyApi 9 16 import rs.averyrive.darkfeed.api.lexicon.app.bsky.feed.Generator 10 17 import rs.averyrive.darkfeed.server.FeedServer 11 - import kotlin.system.exitProcess 18 + import java.time.Instant 19 + import java.time.format.DateTimeFormatter 20 + 21 + val PACKAGE_NAME: String = object {}.javaClass.packageName 22 + 23 + suspend fun main(args: Array<String>) = DarkFeedApp().main(args) 24 + 25 + class DarkFeedApp : SuspendingCliktCommand(name = "darkfeed") { 26 + private val ownerPdsHost: String 27 + by option(help = "Feed owner's PDS", envvar = "OWNER_PDS_HOST") 28 + .required() 29 + 30 + private val ownerDid: String 31 + by option(help = "Feed owner's DID", envvar = "OWNER_DID") 32 + .required() 33 + 34 + private val ownerPassword: String 35 + by option(help = "Feed owner's password", envvar = "OWNER_PASSWORD") 36 + .required() 37 + 38 + private val hostname: String 39 + by option(help = "Hostname the feed is available at", envvar = "HOSTNAME") 40 + .required() 41 + 42 + private val listenAddress: String 43 + by option(help = "Address to listen on", envvar = "LISTEN_ADDRESS") 44 + .default("127.0.0.1") 12 45 13 - data class AppContext( 14 - /** PDS of the feed owner's account. */ 15 - val ownerPds: String, 16 - /** DID of the feed owner's account. */ 17 - val ownerDid: String, 18 - /** Password for the feed owner's account. */ 19 - val ownerPassword: String, 20 - /** Hostname of the feed generator server. */ 21 - val hostname: String, 22 - /** Record key for the feed generator record. */ 23 - val recordKey: String = "darkfeed-dev", 24 - /** Display name for the feed. */ 25 - val feedDisplayName: String = "DarkFeed (Dev)", 26 - /** Description for the feed. */ 27 - val description: String = "hi :3", 28 - ) 46 + private val listenPort: Int 47 + by option(help = "Port to listen on", envvar = "LISTEN_PORT") 48 + .int() 49 + .default(8080) 50 + 51 + private val feedRecordKey: String 52 + by option(help = "Record key to use for the feed", envvar = "FEED_RECORD_KEY") 53 + .default("darkfeed-dev") 29 54 30 - /** 31 - * Print a message and exit the application. 32 - * 33 - * @param message Message to print. 34 - * @param code Status code to exit with. 35 - */ 36 - fun printMessageAndExit(message: String, code: Int = 1): Nothing { 37 - println(message) 38 - exitProcess(code) 39 - } 55 + private val feedDisplayName: String 56 + by option(help = "Display name to use for the feed", envvar = "FEED_DISPLAY_NAME") 57 + .default("NSFW Likes (Dev)") 40 58 41 - /** 42 - * Verify the current feed generator record, creating or updating it if necessary. 43 - * 44 - * @param api Bluesky API instance. Requires login. 45 - * @param ctx Application context. 46 - */ 47 - suspend fun verifyAndUpdateFeedGeneratorRecord(api: BskyApi, ctx: AppContext) { 48 - // Get the current record stored in the repo. 49 - var feedGeneratorRecord = api.getFeedGeneratorRecord(ctx.ownerDid, ctx.recordKey) 59 + private val feedDescription: String 60 + by option(help = "Description to use for the feed", envvar = "FEED_DESCRIPTION") 61 + .default("Development version of the NSFW Likes feed. Likely will not work.") 50 62 51 - // TODO: Check all fields of the record against the context. 52 - // If the current record exists and has the correct DID, nothing needs to be done. 53 - if (feedGeneratorRecord?.did?.contains(ctx.hostname) == true) return 63 + private val debug: Boolean 64 + by option(help = "Log debug information") 65 + .flag(default = false) 54 66 55 - // Update the current record if one exists, or create a new one if it doesn't. 56 - feedGeneratorRecord = feedGeneratorRecord 57 - ?.copy(did = "did:web:${ctx.hostname}") 58 - ?: Generator( 59 - did = "did:web:${ctx.hostname}", 60 - displayName = ctx.feedDisplayName, 61 - description = ctx.description, 62 - // TODO: Use the real time here. 63 - createdAt = "2024-11-04T15:58:05.074Z" 64 - ) 67 + private val log = LoggerFactory.getLogger(this.javaClass) 65 68 66 - // Store the new/updated record in the repo. 67 - api.putFeedGeneratorRecord(ctx.ownerDid, ctx.recordKey, feedGeneratorRecord) 68 - } 69 + override suspend fun run() { 70 + // Set the log level. 71 + if (debug) { 72 + val loggerContext = LoggerFactory.getILoggerFactory() as LoggerContext 73 + val logger = loggerContext.getLogger(PACKAGE_NAME) 74 + logger.level = Level.DEBUG 69 75 70 - fun main() = runBlocking { 71 - // Create app context from environment variables. 72 - val ctx = AppContext( 73 - ownerPds = System.getenv("FEED_ACCOUNT_PDS") 74 - ?: "bsky.social", 75 - ownerDid = System.getenv("FEED_ACCOUNT_DID") 76 - ?: printMessageAndExit("error: variable FEED_ACCOUNT_DID not set"), 77 - ownerPassword = System.getenv("FEED_ACCOUNT_PASSWORD") 78 - ?: printMessageAndExit("error: variable FEED_ACCOUNT_PASSWORD not set"), 79 - hostname = System.getenv("HOSTNAME") 80 - ?: printMessageAndExit("error: variable HOSTNAME not set"), 81 - ) 76 + logger.debug("Debug logs enabled") 77 + } 82 78 83 - // Create Bluesky API instance. 84 - val bskyApi = BskyApi( 85 - authManager = AuthManager( 86 - authAccount = AuthAccount( 87 - username = ctx.ownerDid, 88 - password = ctx.ownerPassword, 89 - pdsHost = ctx.ownerPds, 90 - ), 79 + // Create the client used for Bluesky API calls. 80 + val bskyApi = BskyApi( 81 + authManager = AuthManager( 82 + authAccount = AuthAccount( 83 + username = ownerDid, 84 + password = ownerPassword, 85 + pdsHost = ownerPdsHost, 86 + ) 87 + ) 91 88 ) 92 - ) 93 89 94 - // Verify and update the feed generator record. 95 - launch { 90 + // Update the feed generator record if necessary. 96 91 try { 97 - verifyAndUpdateFeedGeneratorRecord(bskyApi, ctx) 98 - println("main: feed generator record verified") 92 + verifyAndUpdateFeedGeneratorRecord(bskyApi) 99 93 } catch (error: Exception) { 100 - println("main: failed to verify and update feed generator record: ${error.message}") 94 + log.warn("Failed to update feed generator record: {}", error.toString()) 101 95 } 102 - }.join() 103 96 104 - println("main: starting feed generator server") 97 + // Serve the feed generator. 98 + FeedServer( 99 + hostname = hostname, 100 + host = listenAddress, 101 + port = listenPort, 102 + bskyApi = bskyApi, 103 + ).serve() 104 + } 105 105 106 - // Start the feed server. 107 - FeedServer( 108 - hostname = ctx.hostname, 109 - bskyApi = bskyApi, 110 - port = 1234, 111 - ).serve() 106 + private suspend fun verifyAndUpdateFeedGeneratorRecord(api: BskyApi) { 107 + // Create a feed generator record from the input. 108 + val feedGeneratorRecord = Generator( 109 + did = "did:web:$hostname", 110 + displayName = feedDisplayName, 111 + description = feedDescription, 112 + createdAt = DateTimeFormatter.ISO_INSTANT.format(Instant.now()) 113 + ) 114 + 115 + // Get the current feed generator record. 116 + val currentRecord = api.getFeedGeneratorRecord(ownerDid, feedRecordKey) 117 + 118 + log.debug("Current feed generator record in PDS: {}", currentRecord) 119 + 120 + // Compare the records. 121 + if (feedGeneratorRecord.equalsNoCreatedAt(currentRecord)) { 122 + log.debug("Feed generator record does not need to be updated") 123 + return 124 + } 125 + 126 + log.info("Updating feed generator record '{}' for '{}' on '{}'", feedRecordKey, ownerDid, ownerPdsHost) 127 + 128 + // Put the new feed generator record. 129 + api.putFeedGeneratorRecord(ownerDid, feedRecordKey, feedGeneratorRecord) 130 + } 112 131 }
+2
darkfeed/src/main/kotlin/api/AuthManager.kt
··· 1 + package rs.averyrive.darkfeed.api 2 + 1 3 import io.ktor.client.* 2 4 import io.ktor.client.call.* 3 5 import io.ktor.client.engine.cio.*
-1
darkfeed/src/main/kotlin/api/AuthPlugin.kt
··· 1 1 package rs.averyrive.darkfeed.api 2 2 3 - import AuthManager 4 3 import io.ktor.client.call.* 5 4 import io.ktor.client.plugins.api.* 6 5 import io.ktor.client.statement.*
-1
darkfeed/src/main/kotlin/api/BskyApi.kt
··· 1 1 package rs.averyrive.darkfeed.api 2 2 3 - import AuthManager 4 3 import io.ktor.client.* 5 4 import io.ktor.client.call.* 6 5 import io.ktor.client.engine.cio.*
+7 -1
darkfeed/src/main/kotlin/api/lexicon/app/bsky/feed/Generator.kt
··· 8 8 val displayName: String, 9 9 val description: String?, 10 10 val createdAt: String, 11 - ) 11 + ) { 12 + fun equalsNoCreatedAt(other: Generator?): Boolean { 13 + return this.did == other?.did && 14 + this.displayName == other.displayName && 15 + this.description == other.description 16 + } 17 + }
+8 -2
darkfeed/src/main/kotlin/server/FeedServer.kt
··· 13 13 import kotlinx.coroutines.runBlocking 14 14 import kotlinx.serialization.Serializable 15 15 import kotlinx.serialization.json.Json 16 + import org.slf4j.LoggerFactory 16 17 import rs.averyrive.darkfeed.api.BskyApi 17 18 import rs.averyrive.darkfeed.api.lexicon.app.bsky.feed.FeedSkeleton 18 19 import rs.averyrive.darkfeed.api.lexicon.app.bsky.feed.defs.PostView ··· 22 23 23 24 class FeedServer( 24 25 private val hostname: String, 26 + private val host: String, 27 + private val port: Int, 25 28 private val bskyApi: BskyApi, 26 - private val port: Int = 8080, 27 29 ) { 30 + private val log = LoggerFactory.getLogger(this.javaClass) 31 + 28 32 @Serializable 29 33 data class DidJson( 30 34 val id: String, ··· 40 44 41 45 // you better work, bitch 42 46 fun serve() { 43 - embeddedServer(Netty, port = port) { 47 + log.info("Serving feed generator on {}:{}", host, port) 48 + 49 + embeddedServer(Netty, port = port, host = host) { 44 50 install(ContentNegotiation) { 45 51 json(Json { 46 52 explicitNulls = false
+2 -2
darkfeed/src/main/resources/logback.xml
··· 4 4 <pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> 5 5 </encoder> 6 6 </appender> 7 - <root level="trace"> 7 + <root level="INFO"> 8 8 <appender-ref ref="STDOUT"/> 9 9 </root> 10 - <logger name="io.netty" level="INFO"/> 10 + <logger name="io.netty" level="WARN"/> 11 11 <logger name="io.ktor.client" level="WARN"/> 12 12 <logger name="io.ktor.server" level="WARN"/> 13 13 </configuration>
+2
gradle/libs.versions.toml
··· 3 3 logback = "1.5.12" 4 4 ktor = "3.0.1" 5 5 auth0 = "4.4.0" 6 + clikt = "5.0.1" 6 7 7 8 [libraries] 8 9 logback-classic = { group = "ch.qos.logback", name = "logback-classic", version.ref = "logback" } ··· 16 17 ktor-server-content-negotiation = { group = "io.ktor", name = "ktor-server-content-negotiation", version.ref = "ktor" } 17 18 ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } 18 19 java-jwt = { group = "com.auth0", name = "java-jwt", version.ref = "auth0" } 20 + clikt = { group = "com.github.ajalt.clikt", name = "clikt", version.ref = "clikt" } 19 21 20 22 [plugins] 21 23 kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }