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