+3
.fleet/run.json
+3
.fleet/run.json
+1
darkfeed/build.gradle.kts
+1
darkfeed/build.gradle.kts
+110
-91
darkfeed/src/main/kotlin/Main.kt
+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
+2
darkfeed/src/main/kotlin/api/AuthManager.kt
-1
darkfeed/src/main/kotlin/api/AuthPlugin.kt
-1
darkfeed/src/main/kotlin/api/AuthPlugin.kt
-1
darkfeed/src/main/kotlin/api/BskyApi.kt
-1
darkfeed/src/main/kotlin/api/BskyApi.kt
+7
-1
darkfeed/src/main/kotlin/api/lexicon/app/bsky/feed/Generator.kt
+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
+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
+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
+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" }