├── .github └── workflows │ └── docker-image.yml ├── .gitignore ├── LICENSE ├── README.md ├── build.gradle.kts ├── docker-compose.yml ├── settings.gradle.kts └── src ├── main ├── kotlin │ └── de │ │ └── rtrx │ │ └── a │ │ ├── Configuration.kt │ │ ├── CoreModule.kt │ │ ├── Helpers.kt │ │ ├── Main.kt │ │ ├── database │ │ ├── DDL.kt │ │ ├── Linkage.kt │ │ ├── NormDDL.kt │ │ └── NormLinkage.kt │ │ ├── flow │ │ ├── Flow.kt │ │ ├── FlowDispatcher.kt │ │ ├── FlowParts.kt │ │ ├── IsolationStrategy.kt │ │ ├── UtilityFlows.kt │ │ ├── events │ │ │ ├── EventMultiplexer.kt │ │ │ ├── EventTypes.kt │ │ │ └── comments │ │ │ │ └── FullComments.kt │ │ └── exampleflow.yml │ │ ├── jrawExtension │ │ ├── RotatingSearchList.kt │ │ ├── SuspendableStream.kt │ │ └── UpdatedCommentNode.kt │ │ ├── monitor │ │ ├── Check.kt │ │ └── DBCheck.kt │ │ └── unex │ │ ├── UnexFlow.kt │ │ ├── UnexFlowDispatcher.kt │ │ └── UnexFlowModule.kt └── resources │ ├── DDL.sql │ ├── config.yml │ └── logging.properties └── test └── kotlin └── de └── rtrx └── a └── flow ├── FlowTest.kt ├── TestUtil.kt ├── UnexFlowDispatcherTest.kt └── UnexFlowTest.kt /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: docker-image 2 | on: 3 | push: 4 | tags: 5 | - '**' 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Set up Java 13 | uses: actions/setup-java@v2 14 | with: 15 | java-version: '11' 16 | distribution: 'adopt' 17 | - name: Run Jib Task 18 | run: gradle jib 19 | env: 20 | GITHUB_USER: ${{ secrets.USER }} 21 | GITHUB_TOKEN: ${{ secrets.TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore Gradle project-specific cache directory 2 | .gradle 3 | 4 | # Ignore Gradle build output directory 5 | build 6 | /.idea/ 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # unexBot 2 | 3 | A small bot for Reddit that filters out (some) of the rubbish and collects data for analyzing submissions. 4 | It also includes a framework for expressing a concurrent actor with sequential coroutines. 5 | As moving the framework to a seperate repository is a WIP, you'll find below only a description on how to use the bot. 6 | 7 | ## What does this bot do? 8 | 9 | 1. When a post is submitted to a subreddit, the user is messaged asking them to answer back with a sum-up of their content. 10 | 2. Does the user answer quickly? 11 | 1. If the user answers within a small time frame, then the post stays up and the reply is posted in a stickied comment below the post. 12 | 2. If the user doesn't answer within this time frame, then the post is removed. The user has another window of time to reply and restore the post. Replying after this time window is passed will do ... nothing. 13 | 3. The bot creates a snapshot of the post, including the score of the post, the score of the stickied comment, if it was removed or deleted, user reports, top comments and so on and saves them in a database. Then the bot waits for a fixed amount of time and repeats everything for a set amount of times. 14 | 15 | ## How do I use it? 16 | 17 | There are both a docker image and a fat-jar (a jar that includes all dependencies) available. You can also build the fat-jar on your own using: 18 | 19 | ```bash 20 | gradle shadowJar 21 | ``` 22 | 23 | or if you want to create the docker image run while having the docker daemon running (and having access to it): 24 | 25 | ```bash 26 | gradle dockerBuildImage 27 | ``` 28 | 29 | You'll need gradle with a version > 5. 30 | 31 | ### Command Line Options 32 | 33 | There are currently 4 command line options available: 34 | 35 | - `useDB` (boolean): Whether you want to actually want to use a (postresql) database. The default is true, I wouldn't recommend turning it off, it exists mostly for debugging purposes. 36 | - `createDDL` (boolean): Whether you want the bot to run the commands to create the tables in the database. Default is true. This options just exists because the error messages can be a bit annoying. 37 | - `createDBFunctions` (boolean): Same like createDDL, but has even more annoying error messages. 38 | - `configPath` (string): If you don't want to configure everything via environment variables, this should be set to the location of your config file. If no file is found at the given path, the default one will be copied there. 39 | - `startDispatcher` (boolean): Whether the Bot should actually start working. The intended purpose is that you can create the DDL and SQL Functions without starting the bot. It's also the only way to make the program exit with 2. 40 | - `restart` (boolean): whether to restart all the flows that were started already. Otherwise all posts that were touched already by the bot in anyway won't be processed further. Defaults to true. 41 | 42 | This would tell the bot to not create the DDL or functions and use the config file called "unexbotConfig.yml" in the same directory: 43 | 44 | ```bash 45 | java -jar unexBot-1.1.0-all.jar createDDL=false createDBFunctions=false configPath="unexbotConfig.yml" 46 | ``` 47 | 48 | You can pass options for the docker image via the command option. 49 | 50 | ### Configuration File 51 | 52 | Most settings are set to a sane default, but some things (like database access or the Reddit API credentials) will have to be set by every user. 53 | The bot uses three sources for values. The values of the first one are overwritten by the second and third one, and the values of the second one by the third one: 54 | 55 | 1. The default configuration file included in the jar (and also found in this repository as `config.yml`). 56 | 2. Your config file, for which you have specified the path with the command line option `configPath`. 57 | 3. Environment variables. This is useful for both configuring the docker image and debugging. To set a variable use a dot to separate the node names (e.g. `reddit.credentials.username`) 58 | 59 | ### Logging 60 | 61 | The SLF4J java.util logging library is included by default, but feel free to swap it out. You will then have to configure the underlying logger according to your needs. 62 | 63 | ## Credits 64 | 65 | This program uses different libraries: 66 | 67 | - [JRAW](https://github.com/mattbdean/JRAW) is probably the most important one. I even stole an 1 and a half class from it (Rotating Search List and SuspendableStream), since the original implementation blocks the thread. Maybe someday I will find the time to create a pull request to JRAW to include this (or create my own fork of it and properly integrate it) 68 | - SLF4J for logging 69 | - [Gson](https://github.com/google/gson): For parsing some data since I'm too stupid to change the appropriate classes of JRAW. 70 | - [Kotlin Logging](https://github.com/MicroUtils/kotlin-logging): A wrapper around SLF4J (which itself is a wrapper around a logging backend). 71 | - [Konf](https://github.com/uchuhimo/konf): For the configuration system. 72 | - [The PostgreSQL JDBC Driver](https://jdbc.postgresql.org). 73 | - [Docker Gradle Plugin](https://github.com/bmuschko/gradle-docker-plugin): Pretty amazing stuff if you ask me! 74 | - [Docker Shadow Plugin](https://github.com/johnrengelman/shadow): To create Fat jars. 75 | - Guice for DI 76 | - Mockito for Mocking 77 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 2 | 3 | project.version = "2.3.2" 4 | plugins { 5 | id("org.jetbrains.kotlin.jvm") version "1.4.0" 6 | 7 | application 8 | id("com.github.johnrengelman.shadow") version("5.1.0") 9 | id("com.google.cloud.tools.jib") version "2.8.0" 10 | `maven-publish` 11 | 12 | } 13 | 14 | repositories { 15 | jcenter() 16 | } 17 | 18 | dependencies { 19 | implementation(platform("org.jetbrains.kotlin:kotlin-bom")) 20 | testImplementation("org.junit.jupiter:junit-jupiter-api:5.3.1") 21 | testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.3.1") 22 | testImplementation("org.junit.jupiter:junit-jupiter-params:5.3.1") 23 | 24 | implementation("dev.misfitlabs.kotlinguice4:kotlin-guice:1.4.1") 25 | implementation(kotlin("stdlib")) 26 | implementation("org.jetbrains.kotlin:kotlin-reflect:1.4.10") 27 | 28 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9") 29 | implementation( "net.dean.jraw:JRAW:1.1.0") 30 | implementation( "org.postgresql:postgresql:42.2.8") 31 | implementation( "com.google.code.gson:gson:2.8.5") 32 | implementation(group= "org.slf4j", name= "slf4j-api", version= "1.7.28") 33 | implementation(group= "org.slf4j", name= "slf4j-jdk14", version= "1.7.28") 34 | 35 | implementation(group="com.google.inject", name= "guice", version = "4.2.3") 36 | implementation(group="com.google.inject.extensions", name= "guice-assistedinject", version = "4.2.3") 37 | testImplementation("com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0") 38 | testImplementation( "org.mockito:mockito-inline:2.13.0") 39 | 40 | implementation("io.github.microutils:kotlin-logging:1.5.9") 41 | implementation("com.uchuhimo:konf-core:0.20.0") 42 | implementation("com.uchuhimo:konf-yaml:0.20.0") 43 | implementation("org.yaml:snakeyaml:1.25") 44 | } 45 | 46 | application { 47 | mainClassName = "de.rtrx.a.MainKt" 48 | } 49 | java { 50 | sourceCompatibility = JavaVersion.VERSION_1_8 51 | targetCompatibility = JavaVersion.VERSION_1_8 52 | } 53 | 54 | tasks.test { 55 | useJUnitPlatform() 56 | } 57 | 58 | tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile::class.java).all { 59 | kotlinOptions { 60 | jvmTarget = "1.8" 61 | } 62 | } 63 | 64 | jib { 65 | to { 66 | image = "docker.pkg.github.com/artraxon/unexbot/full-image:${project.version}" 67 | auth { 68 | username = System.getenv("GITHUB_USER") 69 | password = System.getenv("GITHUB_TOKEN") 70 | } 71 | } 72 | } 73 | 74 | val compileKotlin: KotlinCompile by tasks 75 | compileKotlin.kotlinOptions { 76 | jvmTarget = "1.8" 77 | } 78 | val compileTestKotlin: KotlinCompile by tasks 79 | compileTestKotlin.kotlinOptions { 80 | jvmTarget = "1.8" 81 | } -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | postgres: 5 | image: postgres 6 | restart: always 7 | ports: 8 | - 5432 9 | volumes: 10 | - ./docker/data/unexDB:/var/lib/postgresql 11 | environment: 12 | - POSTGRES_USER=unex 13 | - POSTGRES_PASSWORD=testPassword 14 | - POSTGRES_DB=unexdb 15 | 16 | unexBot: 17 | image: docker.pkg.github.com/artraxon/unexbot/full-image:2.3.2 18 | container_name: unexbot 19 | restart: always 20 | command: createDDL=false createDBFunctions=false configPath=config.ym restart=true 21 | volumes: 22 | - ./testEnvironment/config.yml:/app/resources/config.yml 23 | - ./testEnvironment/logging.properties:/app/resources/logging.properties 24 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "unexBot" 2 | -------------------------------------------------------------------------------- /src/main/kotlin/de/rtrx/a/Configuration.kt: -------------------------------------------------------------------------------- 1 | package de.rtrx.a 2 | 3 | import com.uchuhimo.konf.Config 4 | import com.uchuhimo.konf.ConfigSpec 5 | import com.uchuhimo.konf.notEmptyOr 6 | import com.uchuhimo.konf.source.yaml 7 | import java.io.File 8 | import java.nio.file.Files 9 | import java.nio.file.Paths 10 | import java.nio.file.StandardCopyOption 11 | import kotlin.system.exitProcess 12 | 13 | 14 | fun initConfig(path: String?, vararg specs: ConfigSpec): Config{ 15 | return Config { specs.forEach(::addSpec) } 16 | //Adding the config Sources 17 | .from.yaml.resource("config.yml") 18 | .run { 19 | if(path != null) { 20 | from.yaml.watchFile( 21 | run { 22 | val filePath = path.notEmptyOr("${System.getProperty("user.dir")}/config.yml") 23 | val file = File(filePath) 24 | if (file.createNewFile() || Files.size(file.toPath()) == 0L) { 25 | println("Cloning Config...") 26 | val inputStream = RedditSpec::class.java.getResourceAsStream("/config.yml") 27 | Files.copy(inputStream, Paths.get(filePath), StandardCopyOption.REPLACE_EXISTING) 28 | } 29 | filePath 30 | }) 31 | } else this 32 | } 33 | .from.env() 34 | } 35 | 36 | 37 | object RedditSpec: ConfigSpec("reddit") { 38 | val subreddit by required() 39 | 40 | object credentials: ConfigSpec("credentials"){ 41 | val username by required() 42 | val clientID by required() 43 | val clientSecret by required() 44 | val password by required() 45 | val operatorUsername by required() 46 | val appID by required() 47 | } 48 | object submissions : ConfigSpec("submissions"){ 49 | val maxTimeDistance by required() 50 | val limit by required() 51 | val waitIntervall by required() 52 | } 53 | 54 | object messages : ConfigSpec("messages") { 55 | object sent: ConfigSpec("sent") { 56 | val maxTimeDistance by required() 57 | val maxWaitForCompletion by required() 58 | val limit by required() 59 | val waitIntervall by required() 60 | val subject by required() 61 | val body by required() 62 | } 63 | 64 | object unread: ConfigSpec("unread"){ 65 | val waitIntervall by required() 66 | val limit by required() 67 | val answerMaxCharacters by required() 68 | } 69 | } 70 | 71 | 72 | object scoring: ConfigSpec("scoring"){ 73 | val timeUntilRemoval by required() 74 | val commentBody by required() 75 | } 76 | 77 | object checks: ConfigSpec("checks"){ 78 | object DB: ConfigSpec("db"){ 79 | val every by required() 80 | val forTimes by required() 81 | val comments_amount by required() 82 | val depth by required() 83 | val commentWaitIntervall by required() 84 | } 85 | } 86 | 87 | } 88 | 89 | object DBSpec: ConfigSpec("DB"){ 90 | val username by required() 91 | val password by required() 92 | val address by required() 93 | val db by required() 94 | } 95 | 96 | private val verifyBoolean = { it: String -> if(it == "true" || it == "false") true else false} 97 | private val convertBoolean = {it: String -> if(it == "true") true else false} 98 | private val availableOptions: Map Boolean, (String) -> Any>> = mapOf( 99 | "createDDL" to (verifyBoolean to convertBoolean), 100 | "createDBFunctions" to (verifyBoolean to convertBoolean), 101 | "configPath" to ({it: String -> true} to {str: String -> str}), 102 | "useDB" to (verifyBoolean to convertBoolean), 103 | "startDispatcher" to (verifyBoolean to convertBoolean), 104 | "restart" to (verifyBoolean to convertBoolean) 105 | ) 106 | 107 | fun parseOptions(args: Array): Map{ 108 | var (pairs, invalids) = args.map { it.split("=") }.partition { it.size == 2 } 109 | for(pair in pairs){ 110 | if(availableOptions.get(pair[0])?.first?.invoke(pair[1])?.not() ?: true){ 111 | pairs = pairs.minusElement(pair) 112 | invalids = pairs.plusElement(pair) 113 | } 114 | } 115 | 116 | if(invalids.size != 0){ 117 | for(invalidOption in invalids){ 118 | println("Invalid Option or Value: $invalidOption") 119 | } 120 | exitProcess(1) 121 | } 122 | 123 | return pairs.map { it[0] to availableOptions[it[0]]!!.second(it[1]) }.toMap() 124 | } 125 | 126 | 127 | -------------------------------------------------------------------------------- /src/main/kotlin/de/rtrx/a/CoreModule.kt: -------------------------------------------------------------------------------- 1 | package de.rtrx.a 2 | 3 | import com.google.inject.Provides 4 | import com.google.inject.TypeLiteral 5 | import com.google.inject.name.Names 6 | import com.uchuhimo.konf.Config 7 | import de.rtrx.a.database.DDL 8 | import de.rtrx.a.flow.* 9 | import de.rtrx.a.flow.events.* 10 | import de.rtrx.a.flow.events.comments.CommentsFetcherFactory 11 | import de.rtrx.a.flow.events.comments.FullComments 12 | import de.rtrx.a.flow.events.comments.RedditCommentsFetchedFactory 13 | import de.rtrx.a.monitor.DBCheckFactory 14 | import de.rtrx.a.monitor.IDBCheckBuilder 15 | import dev.misfitlabs.kotlinguice4.KotlinModule 16 | import dev.misfitlabs.kotlinguice4.typeLiteral 17 | import kotlinx.coroutines.CoroutineStart 18 | import kotlinx.coroutines.channels.ReceiveChannel 19 | import kotlinx.coroutines.runBlocking 20 | import mu.KotlinLogging 21 | import net.dean.jraw.RedditClient 22 | import net.dean.jraw.http.OkHttpNetworkAdapter 23 | import net.dean.jraw.http.UserAgent 24 | import net.dean.jraw.models.Message 25 | import net.dean.jraw.oauth.Credentials 26 | import net.dean.jraw.oauth.OAuthHelper 27 | import net.dean.jraw.references.SubmissionReference 28 | import java.lang.Exception 29 | import javax.inject.Provider 30 | 31 | class CoreModule(private val config: Config, private val useDB: Boolean) : KotlinModule() { 32 | private val logger = KotlinLogging.logger { } 33 | val redditClient: RedditClient 34 | 35 | val newPostEvent: NewPostReferenceEvent 36 | val newPostsOutput: ReceiveChannel 37 | 38 | val incomingMessageFactory: IncomingMessageFactory 39 | val sentMessageFactory: SentMessageFactory 40 | 41 | init { 42 | redditClient = initReddit() 43 | 44 | val (newPosts, outChannel) = 45 | runBlocking { RedditNewPostReferenceFactory(config, redditClient).create(config[RedditSpec.subreddit]) } 46 | 47 | this.newPostEvent = newPosts 48 | newPostsOutput = outChannel 49 | 50 | incomingMessageFactory = RedditIncomingMessageFactory(config, redditClient, CoroutineStart.LAZY) 51 | sentMessageFactory = RedditSentMessageFactory(config, redditClient, CoroutineStart.LAZY) 52 | } 53 | 54 | 55 | 56 | @Provides 57 | fun provideEventMultiplexer(): EventMultiplexerBuilder>{ 58 | return SimpleMultiplexer.SimpleMultiplexerBuilder().setIsolationStrategy(SingleFlowIsolation()) 59 | } 60 | 61 | @Provides 62 | fun provideSpecificEventMultiplexer(): EventMultiplexerBuilder, @JvmSuppressWildcards ReceiveChannel>{ 63 | return provideEventMultiplexer() 64 | } 65 | 66 | override fun configure() { 67 | bind(RedditClient::class.java).toInstance(redditClient) 68 | bind(Config::class.java).toInstance(config) 69 | 70 | if(useDB) bind(DDL::class.java) 71 | 72 | bindConfigValues() 73 | bind(IsolationStrategy::class.java).to(SingleFlowIsolation::class.java) 74 | 75 | bind(typeLiteral<@JvmSuppressWildcards EventMultiplexerBuilder, @JvmSuppressWildcards ReceiveChannel>>()) 76 | .toProvider(object : Provider<@kotlin.jvm.JvmSuppressWildcards EventMultiplexerBuilder, @kotlin.jvm.JvmSuppressWildcards ReceiveChannel>> { 77 | override fun get() = SimpleMultiplexer.SimpleMultiplexerBuilder() 78 | }) 79 | 80 | bind(IncomingMessageFactory::class.java).toInstance(incomingMessageFactory) 81 | bind(SentMessageFactory::class.java).toInstance(sentMessageFactory) 82 | bind(CommentsFetcherFactory::class.java).to(RedditCommentsFetchedFactory::class.java) 83 | bind(object : TypeLiteral<@kotlin.jvm.JvmSuppressWildcards ReceiveChannel>() {}) 84 | .toInstance(newPostsOutput) 85 | 86 | 87 | bind(MarkAsReadFlow::class.java) 88 | 89 | bind(object : TypeLiteral>() {}).toProvider(DefferedConversationProvider::class.java) 90 | bind(IDBCheckBuilder::class.java).toProvider(DBCheckFactory::class.java) 91 | bind(MessageComposer::class.java).to(RedditMessageComposer::class.java) 92 | bind(Replyer::class.java).to(RedditReplyer::class.java) 93 | bind(Unignorer::class.java).to(RedditUnignorer::class.java) 94 | 95 | } 96 | 97 | fun bindConfigValues() { 98 | bind(Long::class.java).annotatedWith(Names.named("delayToDeleteMillis")).toInstance(config[RedditSpec.scoring.timeUntilRemoval]) 99 | bind(Long::class.java) 100 | .annotatedWith(Names.named("delayToFinishMillis")) 101 | .toInstance(config[RedditSpec.messages.sent.maxTimeDistance]) 102 | } 103 | fun initReddit(): RedditClient { 104 | 105 | val oauthCreds = Credentials.script( 106 | config[RedditSpec.credentials.username], 107 | config[RedditSpec.credentials.password], 108 | config[RedditSpec.credentials.clientID], 109 | config[RedditSpec.credentials.clientSecret] 110 | ) 111 | 112 | val userAgent = UserAgent("linux", config[RedditSpec.credentials.appID], "2.2", config[RedditSpec.credentials.operatorUsername]) 113 | 114 | 115 | val reddit = try { 116 | logger.info { "Trying to authenticate..." } 117 | OAuthHelper.automatic(OkHttpNetworkAdapter(userAgent), oauthCreds) 118 | } catch (e: Throwable){ 119 | logger.error { "An exception was raised while trying to authenticate. Are your credentials correct?" } 120 | System.exit(1) 121 | throw Exception() 122 | } 123 | 124 | logger.info { "Successfully authenticated" } 125 | reddit.logHttp = false 126 | return reddit 127 | } 128 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/rtrx/a/Helpers.kt: -------------------------------------------------------------------------------- 1 | package de.rtrx.a 2 | 3 | import com.google.gson.JsonElement 4 | import com.google.gson.JsonObject 5 | import com.google.gson.JsonParser 6 | import com.uchuhimo.konf.Config 7 | import com.uchuhimo.konf.source.asTree 8 | import de.rtrx.a.database.Linkage 9 | import kotlinx.coroutines.CompletableDeferred 10 | import kotlinx.coroutines.Deferred 11 | import mu.KLogger 12 | import mu.KotlinLogging 13 | import net.dean.jraw.RedditClient 14 | import net.dean.jraw.models.Comment 15 | import java.io.PrintWriter 16 | import java.io.StringWriter 17 | import java.util.concurrent.ConcurrentHashMap 18 | 19 | class ResponseBodyEmptyException(fullname: String): Exception("Response body from fetching informations about $fullname is empty") 20 | fun RedditClient.getSubmissionJson(fullname: String): JsonObject { 21 | val response = request { 22 | it.url("https://oauth.reddit.com/api/info.json?id=$fullname") 23 | } 24 | val body = response.body 25 | response.raw.close() 26 | 27 | return if(body.isNotEmpty()) { 28 | JsonParser().parse(body).asJsonObject["data"].asJsonObject["children"].let { 29 | if (it.asJsonArray.size() == 0) JsonObject() 30 | else it.asJsonArray[0].asJsonObject["data"].asJsonObject 31 | } 32 | } else throw ResponseBodyEmptyException(fullname) 33 | } 34 | 35 | 36 | 37 | fun ConcurrentHashMap>.access(key: R): CompletableDeferred{ 38 | return getOrPut(key, { CompletableDeferred() }) 39 | } 40 | 41 | val KLogger.logLevel: String get() { 42 | return if (isErrorEnabled) "ERROR" 43 | else if (isWarnEnabled) "WARN" 44 | else if (isInfoEnabled) "INFO" 45 | else if (isDebugEnabled) "DEBUG" 46 | else "TRACE" 47 | } 48 | 49 | fun Throwable.getStackTraceString(): String{ 50 | val sw = StringWriter() 51 | this.printStackTrace(PrintWriter(sw)) 52 | return sw.toString() 53 | } 54 | 55 | fun String.toSupplier(): (Config) -> String { return { this } } 56 | 57 | fun Deferred.getCompletedOrNull(): R? { 58 | return if(isCompleted) { 59 | getCompleted() 60 | } else null 61 | } 62 | 63 | 64 | -------------------------------------------------------------------------------- /src/main/kotlin/de/rtrx/a/Main.kt: -------------------------------------------------------------------------------- 1 | package de.rtrx.a 2 | 3 | import com.google.inject.Guice 4 | import de.rtrx.a.database.DDL 5 | import de.rtrx.a.database.PostgresSQLinkage 6 | import de.rtrx.a.unex.UnexFlowModule 7 | import de.rtrx.a.unex.UnexFlowDispatcher 8 | import kotlinx.coroutines.* 9 | import mu.KotlinLogging 10 | import kotlin.concurrent.thread 11 | 12 | private val logger = KotlinLogging.logger { } 13 | @ExperimentalCoroutinesApi 14 | fun main(args: Array) { 15 | val options = parseOptions(args) 16 | val configPath = options.get("configPath") as String? ?: "" 17 | val useDB = options.get("useDB") as Boolean? ?: true 18 | val restart = options.get("restart") as Boolean? ?: true 19 | 20 | val injector = Guice.createInjector( 21 | CoreModule(initConfig(configPath, RedditSpec, DBSpec), useDB), 22 | UnexFlowModule(restart)) 23 | 24 | injector.getInstance(DDL::class.java).init( 25 | createDDL = (options.get("createDDL") as Boolean?) ?: true, 26 | createFunctions = (options.get("createDBFunctions") as Boolean?) ?: true 27 | ) 28 | if(!((options.get("startDispatcher") as Boolean?) ?:true)) { 29 | logger.info { "Exiting before starting dispatcher" } 30 | System.exit(2) 31 | } 32 | 33 | val dispatcher: UnexFlowDispatcher = injector.getInstance(UnexFlowDispatcher::class.java) 34 | 35 | Runtime.getRuntime().addShutdownHook(thread(false) { 36 | runBlocking { dispatcher.stop() } 37 | logger.info { "Stopping Bot" } 38 | }) 39 | 40 | runBlocking { 41 | dispatcher.join() 42 | } 43 | } 44 | 45 | class ApplicationStoppedException: CancellationException("Application was stopped") -------------------------------------------------------------------------------- /src/main/kotlin/de/rtrx/a/database/DDL.kt: -------------------------------------------------------------------------------- 1 | package de.rtrx.a.database 2 | 3 | import com.uchuhimo.konf.Config 4 | import de.rtrx.a.RedditSpec 5 | import de.rtrx.a.toSupplier 6 | import mu.KotlinLogging 7 | import org.intellij.lang.annotations.Language 8 | import java.sql.PreparedStatement 9 | import java.sql.SQLException 10 | import javax.inject.Inject 11 | import javax.inject.Named 12 | 13 | val ddlFilePath = "/DDL.sql" 14 | typealias DDLFunctions = List<(Config) -> String> 15 | class DDL @Inject constructor( 16 | private val config: Config, 17 | private val db: Linkage, 18 | @param:Named("functions") private val ddlFunctions: @JvmSuppressWildcards List, 19 | @param:Named("tables") private val DDLs: @JvmSuppressWildcards List 20 | ){ 21 | private val logger = KotlinLogging.logger { } 22 | fun init(createDDL: Boolean, createFunctions: Boolean){ 23 | if(createDDL){ 24 | logger.info("Creating the DDL") 25 | DDLs.forEach { 26 | try { 27 | db.connection.prepareStatement(it).execute() 28 | } catch (e: SQLException){ 29 | logger.error { "Something went wrong when creating table $it:\n${e.message}" } 30 | } 31 | } 32 | } 33 | if(createFunctions){ 34 | logger.info("Creating DB Functions") 35 | 36 | ddlFunctions.forEach { 37 | try { 38 | db.connection.prepareStatement(it).execute() 39 | }catch (e: SQLException){ 40 | logger.error { "Something went wrong when creating function $it:\n${e.message}" } 41 | } 42 | } 43 | } 44 | } 45 | companion object { 46 | object Functions { 47 | @Language("PostgreSQL") 48 | val commentIfNotExists = """ 49 | create function comment_if_not_exists(comment_id text, body text, created timestamp with time zone, author text) 50 | returns boolean 51 | language plpgsql 52 | as $$ 53 | DECLARE 54 | result boolean; 55 | BEGIN 56 | result = false; 57 | INSERT INTO comments VALUES(comment_id, body, created, author) 58 | ON CONFLICT DO NOTHING 59 | RETURNING TRUE INTO result; 60 | RETURN result; 61 | end; 62 | $$; 63 | """.trimIndent().toSupplier() 64 | 65 | @Language("PostgreSQL") 66 | val commentWithMessage = """ 67 | create function comment_with_message(submission_id text, message_id text, comment_id text, message_body text, comment_body text, author_id text, comment_time timestamp with time zone, message_time timestamp with time zone) 68 | returns void 69 | language plpgsql 70 | as $$ 71 | DECLARE 72 | BEGIN 73 | INSERT INTO comments VALUES (comment_id, comment_body, comment_time, reddit_username()); 74 | INSERT INTO relevant_messages VALUES (message_id, submission_id, message_body, author_id, message_time); 75 | INSERT INTO comments_caused VALUES (message_id, comment_id); 76 | END 77 | $$; 78 | """.trimIndent().toSupplier() 79 | 80 | @Language("PostgreSQL") 81 | val createCheck = """ 82 | create function create_check(submission_id text, tmptz timestamp with time zone, user_reports json, user_reports_dismissed json, is_deleted boolean, submission_score integer, removed_by text, flair text, current_sticky text, unexscore integer, top_posts_id text[], top_posts_score integer[]) 83 | returns void 84 | language plpgsql 85 | as $$ 86 | DECLARE 87 | BEGIN 88 | INSERT INTO "check" VALUES (${'$'}1, ${'$'}2, ${'$'}3, ${'$'}4, ${'$'}5, ${'$'}6, ${'$'}7, ${'$'}8, ${'$'}9); 89 | INSERT INTO unex_score VALUES (${'$'}1, ${'$'}2, ${'$'}10); 90 | FOR i in 1.. coalesce(array_upper(top_posts_id, 1), 0) 91 | LOOP 92 | IF top_posts_id[i] IS NULL THEN 93 | EXIT; 94 | end if; 95 | INSERT INTO top_posts VALUES (${'$'}1, ${'$'}2, top_posts_id[i], top_posts_score[i]); 96 | end loop; 97 | END 98 | $$; 99 | """.trimIndent().toSupplier() 100 | 101 | @Language("PostgreSQL") 102 | val addParentIfNotExists = """ 103 | create function add_parent_if_not_exists(c_id text, p_id text) returns boolean 104 | language plpgsql 105 | as 106 | $$ 107 | DECLARE 108 | result boolean; 109 | BEGIN 110 | result = false; 111 | INSERT INTO comments_hierarchy VALUES(c_id, p_id) 112 | ON CONFLICT DO NOTHING 113 | RETURNING TRUE INTO result; 114 | RETURN result; 115 | end 116 | $$; 117 | 118 | """.trimIndent().toSupplier() 119 | 120 | val redditUsername = { config: Config -> 121 | """ 122 | create function reddit_username() 123 | returns text 124 | language sql 125 | as $$ 126 | SELECT '${config[RedditSpec.credentials.username]}' 127 | $$; 128 | """.trimIndent() 129 | } 130 | } 131 | 132 | object Tables { 133 | 134 | @Language("PostgreSQL") 135 | val comments = """ 136 | create table if not exists comments 137 | ( 138 | id text not null 139 | constraint comments_pkey 140 | primary key, 141 | body text not null, 142 | created timestamp with time zone not null, 143 | author_id text not null 144 | ) 145 | ; 146 | 147 | create unique index comments_id_uindex 148 | on comments (id) 149 | ;""".trimIndent().toSupplier() 150 | 151 | @Language("PostgreSQL") 152 | val submissions = """create table if not exists submissions 153 | ( 154 | id text not null 155 | constraint submission_id 156 | primary key, 157 | title text not null, 158 | url text not null, 159 | author_id text not null, 160 | created timestamp with time zone not null 161 | ) 162 | ;""".trimIndent().toSupplier() 163 | 164 | 165 | @Language("PostgreSQL") 166 | val check = """create table if not exists "check" 167 | ( 168 | submission_id text not null 169 | constraint submission_constraint 170 | references submissions, 171 | timestamp timestamp with time zone not null, 172 | user_reports json, 173 | dismissed_user_reports json, 174 | is_deleted boolean not null, 175 | submission_score integer, 176 | removed_by text, 177 | flair text, 178 | current_sticky text 179 | constraint comment_stickied 180 | references comments, 181 | constraint depend_on_submission 182 | primary key (submission_id, timestamp) 183 | ) 184 | ; 185 | 186 | 187 | create unique index check_timestamp_uindex 188 | on "check" (timestamp) 189 | ;""".trimIndent().toSupplier() 190 | 191 | @Language("PostgreSQL") 192 | val relevantMessages = """create table if not exists relevant_messages 193 | ( 194 | id text not null 195 | constraint message_pkey 196 | primary key, 197 | submission_id text not null 198 | constraint submission_explained 199 | references submissions, 200 | body text not null, 201 | author_id text not null, 202 | timestamp timestamp with time zone 203 | ) 204 | ; 205 | 206 | create unique index message_id_uindex 207 | on relevant_messages (id) 208 | """.trimIndent().toSupplier() 209 | 210 | 211 | @Language("PostgreSQL") 212 | val comments_caused = """create table if not exists comments_caused 213 | ( 214 | message_id text not null 215 | constraint message_ref 216 | references relevant_messages, 217 | comment_id text not null 218 | constraint comment_ref 219 | references comments, 220 | constraint comment_caused_pk 221 | primary key (comment_id, message_id) 222 | ) 223 | ; 224 | 225 | create unique index comment_caused_message_id_uindex 226 | on comments_caused (message_id) 227 | ; 228 | 229 | ;""".trimIndent().toSupplier() 230 | 231 | @Language("PostgreSQL") 232 | val top_posts = """create table if not exists top_posts 233 | ( 234 | submission_id text not null, 235 | timestamp timestamp with time zone not null, 236 | comment_id text not null 237 | constraint comment_referenced 238 | references comments, 239 | score integer not null, 240 | constraint top_posts_pk 241 | primary key (submission_id, timestamp, comment_id), 242 | constraint during_check 243 | foreign key (submission_id, timestamp) references "check" 244 | ) 245 | ;""".trimIndent().toSupplier() 246 | 247 | @Language("PostgreSQL") 248 | val unexScore = """create table if not exists unex_score 249 | ( 250 | submission_id text not null, 251 | timestamp timestamp with time zone not null, 252 | score integer, 253 | constraint check_identifier 254 | primary key (submission_id, timestamp), 255 | constraint check_performed 256 | foreign key (submission_id, timestamp) references "check" 257 | ) 258 | ;""".trimIndent().toSupplier() 259 | 260 | @Language("PostgreSQL") 261 | val commentsHierarchy = """ 262 | create table comments_hierarchy 263 | ( 264 | child_id text not null 265 | constraint comments_hierarchy_pk 266 | primary key 267 | constraint comment_child 268 | references comments, 269 | parent_id text not null 270 | constraint parent_comment 271 | references comments 272 | ); 273 | """.trimIndent().toSupplier() 274 | 275 | 276 | } 277 | } 278 | 279 | } 280 | class SQLScript(val content: String, private val db: Linkage){ 281 | private val logger = KotlinLogging.logger { } 282 | lateinit var statements: List 283 | 284 | fun prepareStatements(){ 285 | statements = content.split(";").fold(emptyList()) {prev, str -> 286 | prev + db.connection.prepareStatement(str) 287 | } 288 | } 289 | 290 | fun executeStatements(){ 291 | statements.forEach { 292 | try { 293 | it.execute() 294 | }catch (ex: SQLException) { 295 | logger.error { "During DDL init: ${ex.message}" } 296 | } 297 | } 298 | } 299 | 300 | } 301 | 302 | -------------------------------------------------------------------------------- /src/main/kotlin/de/rtrx/a/database/Linkage.kt: -------------------------------------------------------------------------------- 1 | package de.rtrx.a.database 2 | 3 | import com.google.gson.JsonArray 4 | import com.google.gson.JsonElement 5 | import com.google.gson.JsonObject 6 | import com.google.inject.Inject 7 | import com.uchuhimo.konf.Config 8 | import de.rtrx.a.* 9 | import mu.KotlinLogging 10 | import net.dean.jraw.RedditClient 11 | import net.dean.jraw.models.Comment 12 | import net.dean.jraw.models.Message 13 | import net.dean.jraw.models.Submission 14 | import java.sql.Connection 15 | import java.sql.DriverManager 16 | import java.sql.SQLException 17 | import java.sql.Types 18 | import java.time.OffsetDateTime 19 | import java.time.ZoneId 20 | import java.time.ZoneOffset 21 | import java.util.* 22 | import kotlin.system.exitProcess 23 | 24 | 25 | interface Booleable{ 26 | val bool: Boolean 27 | } 28 | fun Boolean.toBooleable() = object : Booleable { 29 | override val bool: Boolean 30 | get() = this@toBooleable 31 | } 32 | data class CheckSelectResult(public val checkResult: Booleable, public val dbResult: Booleable, public val exception: Throwable?) 33 | 34 | fun interface ConversationLinkage { 35 | /** 36 | * @return The amount of rows changed in the DB 37 | */ 38 | fun saveCommentMessage(submission_id: String, message: Message, comment: Comment): Int 39 | } 40 | interface ObservationLinkage: Linkage { 41 | 42 | fun insertSubmission(submission: Submission): Int 43 | 44 | /** 45 | * @return whether the check was inserted successfully into the DB or not 46 | */ fun createCheck(submission_fullname: String, botComment: Comment?, stickied_comment: Comment?, top_comments: Array): Pair> { 47 | return createCheck(getSubmissionJson(submission_fullname), botComment, stickied_comment, top_comments) 48 | } 49 | 50 | 51 | 52 | fun createCheckSelectValues( 53 | submission_fullname: String, 54 | botComment: Comment?, 55 | stickied_comment: Comment?, 56 | top_comments: Array, 57 | predicate: (JsonObject) -> T 58 | ): CheckSelectResult { 59 | return try { 60 | val json = getSubmissionJson(submission_fullname) 61 | val predicateResult = predicate(json) 62 | CheckSelectResult(predicateResult, 63 | if(predicateResult.bool) this.createCheck(json, botComment, stickied_comment, top_comments).first.toBooleable() 64 | else false.toBooleable(), 65 | null) 66 | } catch (e: Throwable) { 67 | KotlinLogging.logger { }.error { e.message } 68 | CheckSelectResult(false.toBooleable(), false.toBooleable(), e) 69 | } 70 | } 71 | 72 | 73 | fun createCheck(jsonData: JsonObject, botComment: Comment?, stickied_comment: Comment?, top_comments: Array): Pair> 74 | 75 | fun add_parent(child: String, parent: String): Boolean 76 | } 77 | interface Linkage { 78 | val connection: Connection 79 | 80 | fun getSubmissionJson(submissionFullname: String): JsonObject 81 | } 82 | 83 | class DummyLinkage:Linkage, ConversationLinkage, ObservationLinkage { 84 | override fun insertSubmission(submission: Submission): Int = 1 85 | 86 | override fun createCheck( 87 | data: JsonObject, 88 | botComment: Comment?, 89 | stickied_comment: Comment?, 90 | top_comments: Array 91 | ): Pair> = true to emptyList() 92 | 93 | override fun getSubmissionJson(submissionFullname: String): JsonObject { 94 | return JsonObject() 95 | } 96 | 97 | override fun saveCommentMessage(submission_id: String, message: Message, comment: Comment) = 1 98 | override fun add_parent(child: String, parent: String): Boolean { 99 | return true 100 | } 101 | 102 | override val connection: Connection 103 | get() = throw DummyException() 104 | 105 | class DummyException: Throwable("Tried to access the sql connection on a dummy") 106 | } 107 | 108 | class PostgresSQLinkage @Inject constructor(private val redditClient: RedditClient, private val config: Config): Linkage, ConversationLinkage, ObservationLinkage { 109 | private val logger = KotlinLogging.logger { } 110 | override val connection: Connection = run { 111 | val properties = Properties() 112 | properties.put("user", config[DBSpec.username]) 113 | if (config[DBSpec.password].isNotEmpty()){ 114 | properties.put("password", config[DBSpec.password]) 115 | } 116 | try { 117 | Class.forName("org.postgresql.Driver").getDeclaredConstructor().newInstance() 118 | DriverManager.getConnection("jdbc:postgresql://${config[DBSpec.address]}/${config[DBSpec.db]}", properties) 119 | } catch (ex: Exception) { 120 | logger.error {"Something went wrong when trying to connect to the db" } 121 | logger.error { ex.getStackTraceString() } 122 | exitProcess(1) 123 | } 124 | 125 | } 126 | 127 | override fun insertSubmission(submission: Submission): Int{ 128 | val pst = connection.prepareStatement("INSERT INTO submissions VALUES (?, ?, ?, ?, ?)") 129 | pst.setString(1, submission.id) 130 | pst.setString(2, submission.title) 131 | pst.setString(3, submission.url) 132 | pst.setString(4, submission.author) 133 | pst.setObject(5, submission.created.toOffsetDateTime()) 134 | return try { 135 | pst.executeUpdate() 136 | } catch (ex: SQLException){ 137 | if(ex.message?.contains("""unique constraint "submission_id"""") ?: false)0 138 | else { 139 | logger.error { "SQL Exception: ${ex.message}" } 140 | 1 141 | } 142 | } 143 | } 144 | 145 | override fun createCheck(jsonData: JsonObject, botComment: Comment?, stickied_comment: Comment?, topComments: Array): Pair>{ 146 | val submission_fullname = jsonData.get("name")?.asString ?: return false to emptyList() 147 | 148 | val linkFlairText = jsonData.get("link_flair_text")?.asStringOrNull() 149 | val userReports = jsonData.get("user_reports")?.asJsonArray?.ifEmptyNull() 150 | val userReportsDismissed = jsonData.get("user_reports_dimissed")?.asJsonArray?.ifEmptyNull() 151 | val deleted = jsonData.get("author")?.asStringOrNull() == "[deleted]" 152 | val removedBy = jsonData.get("banned_by")?.asStringOrNull() 153 | val score = jsonData.get("score")?.asInt 154 | val unexScore = if(botComment != null)(redditClient.lookup(botComment.fullName)[0] as Comment).score else null 155 | 156 | val pst = connection.prepareStatement("SELECT * FROM create_check(?, ?, (to_json(?::json)), (to_json(?::json)), ?, ?, ?, ?, ?, ?, ?, ?)") 157 | pst.setString(1, submission_fullname.drop(3)) 158 | pst.setObject(2, OffsetDateTime.now(), Types.TIMESTAMP_WITH_TIMEZONE) 159 | pst.setString(3, userReports?.toString()) 160 | pst.setString(4, userReportsDismissed?.toString()) 161 | pst.setBoolean(5, deleted) 162 | score?.also { pst.setInt(6, score) } ?: pst.setNull(6, Types.INTEGER) 163 | pst.setString(7, removedBy) 164 | pst.setString(8, linkFlairText) 165 | pst.setString(9, stickied_comment?.id) 166 | unexScore?.also { pst.setInt(10, unexScore) } ?: pst.setNull(10, Types.INTEGER) 167 | pst.setObject(11, topComments.map { it.id }.toTypedArray()) 168 | pst.setArray(12, connection.createArrayOf("INTEGER", topComments.map { it.score }.toTypedArray())) 169 | 170 | val commentsPst = connection.prepareStatement("SELECT * FROM comment_if_not_exists(?, ?, ?, ?)") 171 | val createdComments = topComments.mapNotNull { comment -> 172 | commentsPst.setString(1, comment.id) 173 | commentsPst.setString(2, comment.body) 174 | commentsPst.setObject(3, comment.created.toOffsetDateTime()) 175 | commentsPst.setString(4, comment.author) 176 | try { 177 | commentsPst.execute() 178 | val resultSet = commentsPst.resultSet 179 | resultSet.next() 180 | if(resultSet.getBoolean(1)) comment else null 181 | } catch (ex: SQLException) { 182 | logger.error { ex.getStackTraceString() } 183 | null 184 | } 185 | } 186 | 187 | return try { 188 | pst.execute() to createdComments 189 | } catch (ex: SQLException){ 190 | logger.error { (ex.message) } 191 | false to createdComments 192 | } 193 | } 194 | 195 | override fun getSubmissionJson(submissionFullname: String) = redditClient.getSubmissionJson(submissionFullname) 196 | 197 | 198 | override fun saveCommentMessage(submission_id: String, message: Message, comment: Comment): Int{ 199 | 200 | val pst = connection.prepareStatement("SELECT * FROM comment_with_message(?, ?, ?, ?, ?, ?, ?, ?)") 201 | pst.setString(1, submission_id) 202 | pst.setString(2, message.id) 203 | pst.setString(3, comment.id) 204 | pst.setString(4, message.body) 205 | pst.setString(5, comment.body) 206 | pst.setString(6, message.author) 207 | pst.setObject(7, comment.created.toOffsetDateTime(), Types.TIMESTAMP_WITH_TIMEZONE) 208 | pst.setObject(8, message.created.toOffsetDateTime(), Types.TIMESTAMP_WITH_TIMEZONE) 209 | 210 | return try { 211 | pst.execute() 212 | 1 213 | } catch (ex: SQLException){ 214 | logger.error { (ex.message) } 215 | 0 216 | } 217 | 218 | 219 | } 220 | 221 | override fun add_parent(child: String, parent: String): Boolean { 222 | val pst = connection.prepareStatement("SELECT * FROM add_parent_if_not_exists(?, ?)") 223 | pst.setString(1, child) 224 | pst.setString(2, parent) 225 | return try { 226 | pst.execute() 227 | true 228 | } catch (ex: SQLException){ 229 | logger.error { ex.message } 230 | false 231 | } 232 | } 233 | 234 | } 235 | 236 | fun Date.toOffsetDateTime() = OffsetDateTime.ofInstant(this.toInstant(), ZoneId.ofOffset("", ZoneOffset.UTC)) 237 | 238 | 239 | fun JsonArray.ifEmptyNull(): JsonArray? { 240 | return if(size() > 0 ) this else null 241 | } 242 | 243 | fun JsonElement.asStringOrNull() = if(this.isJsonNull) null else this.asString -------------------------------------------------------------------------------- /src/main/kotlin/de/rtrx/a/database/NormDDL.kt: -------------------------------------------------------------------------------- 1 | package de.rtrx.a.tihi.database 2 | 3 | import de.rtrx.a.toSupplier 4 | import org.intellij.lang.annotations.Language 5 | 6 | object NormDDL { 7 | @Language("PostgreSQL") 8 | val commentsTable = """ 9 | create table comments 10 | ( 11 | id text not null 12 | constraint comments_pkey 13 | primary key, 14 | body text not null, 15 | created timestamp with time zone not null, 16 | author_id text not null, 17 | submission_id text 18 | constraint comment_submission 19 | references submissions 20 | ); 21 | 22 | create unique index comments_id_uindex 23 | on comments (id); 24 | 25 | """.trimIndent().toSupplier() 26 | 27 | @Language("PostgreSQL") 28 | val topPosts = """ 29 | create table top_posts 30 | ( 31 | timestamp timestamp with time zone not null, 32 | comment_id text not null 33 | constraint comment_referenced 34 | references comments, 35 | score integer not null, 36 | constraint top_posts_pk 37 | primary key (timestamp, comment_id) 38 | ); 39 | 40 | """.trimIndent().toSupplier() 41 | 42 | @Language("PostgreSQL") 43 | val createComment = """ 44 | create function comment_if_not_exists(submission_id text, comment_id text, body text, created timestamp with time zone, author text) returns boolean 45 | language plpgsql 46 | as 47 | $$ 48 | DECLARE 49 | result boolean; 50 | BEGIN 51 | result = false; 52 | INSERT INTO comments VALUES(comment_id, body, created, author, submission_id) 53 | ON CONFLICT DO NOTHING 54 | RETURNING TRUE INTO result; 55 | RETURN result; 56 | end; 57 | $$; 58 | 59 | """.trimIndent().toSupplier() 60 | 61 | @Language("PostgreSQL") 62 | val createCheck = """ 63 | create function create_check(submission_id text, tmptz timestamp with time zone, user_reports json, user_reports_dismissed json, is_deleted boolean, submission_score integer, removed_by text, flair text, current_sticky text, unexscore integer, top_posts_id text[], top_posts_score integer[]) returns void 64 | language plpgsql 65 | as 66 | $$ 67 | DECLARE 68 | BEGIN 69 | INSERT INTO "check" VALUES (${'$'}1, ${'$'}2, ${'$'}3, ${'$'}4, ${'$'}5, ${'$'}6, ${'$'}7, ${'$'}8, ${'$'}9); 70 | INSERT INTO unex_score VALUES (${'$'}1, ${'$'}2, ${'$'}10); 71 | IF array_upper(top_posts_id, 1) IS NOT NULL THEN 72 | FOR i in 1.. array_upper(top_posts_id, 1) 73 | LOOP 74 | IF top_posts_id[i] IS NULL THEN 75 | EXIT; 76 | end if; 77 | INSERT INTO top_posts VALUES (${'$'}2, top_posts_id[i], top_posts_score[i]); 78 | end loop; 79 | end if; 80 | END; 81 | $$; 82 | 83 | """.trimIndent().toSupplier() 84 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/rtrx/a/database/NormLinkage.kt: -------------------------------------------------------------------------------- 1 | package de.rtrx.a.tihi.database 2 | 3 | import com.google.gson.JsonObject 4 | import de.rtrx.a.database.* 5 | import de.rtrx.a.getStackTraceString 6 | import mu.KotlinLogging 7 | import net.dean.jraw.RedditClient 8 | import net.dean.jraw.models.Comment 9 | import net.dean.jraw.models.Message 10 | import net.dean.jraw.models.Submission 11 | import java.sql.Connection 12 | import java.sql.SQLException 13 | import java.sql.Types 14 | import java.time.OffsetDateTime 15 | import javax.inject.Inject 16 | 17 | class NormLinkage @Inject constructor(private val delegateLinkage: PostgresSQLinkage, private val redditClient: RedditClient) : ObservationLinkage by delegateLinkage{ 18 | private val logger = KotlinLogging.logger { } 19 | 20 | override fun createCheck(jsonData: JsonObject, botComment: Comment?, stickied_comment: Comment?, topComments: Array): Pair>{ 21 | val submission_fullname = jsonData.get("name")?.asString ?: return false to emptyList() 22 | 23 | val linkFlairText = jsonData.get("link_flair_text")?.asStringOrNull() 24 | val userReports = jsonData.get("user_reports")?.asJsonArray?.ifEmptyNull() 25 | val userReportsDismissed = jsonData.get("user_reports_dimissed")?.asJsonArray?.ifEmptyNull() 26 | val deleted = jsonData.get("author")?.asStringOrNull() == "[deleted]" 27 | val removedBy = jsonData.get("banned_by")?.asStringOrNull() 28 | val score = jsonData.get("score")?.asInt 29 | val unexScore = if(botComment != null)(redditClient.lookup(botComment.fullName)[0] as Comment).score else null 30 | 31 | val pst = delegateLinkage.connection.prepareStatement("SELECT * FROM create_check(?, ?, (to_json(?::json)), (to_json(?::json)), ?, ?, ?, ?, ?, ?, ?, ?)") 32 | pst.setString(1, submission_fullname.drop(3)) 33 | pst.setObject(2, OffsetDateTime.now(), Types.TIMESTAMP_WITH_TIMEZONE) 34 | pst.setString(3, userReports?.toString()) 35 | pst.setString(4, userReportsDismissed?.toString()) 36 | pst.setBoolean(5, deleted) 37 | score?.also { pst.setInt(6, score) } ?: pst.setNull(6, Types.INTEGER) 38 | pst.setString(7, removedBy) 39 | pst.setString(8, linkFlairText) 40 | pst.setString(9, stickied_comment?.id) 41 | unexScore?.also { pst.setInt(10, unexScore) } ?: pst.setNull(10, Types.INTEGER) 42 | pst.setObject(11, topComments.map { it.id }.toTypedArray()) 43 | pst.setArray(12, connection.createArrayOf("INTEGER", topComments.map { it.score }.toTypedArray())) 44 | 45 | val commentsPst = connection.prepareStatement("SELECT * FROM comment_if_not_exists(?, ?, ?, ?, ?)") 46 | val createdComments = topComments.mapNotNull { comment -> 47 | commentsPst.setString(1, submission_fullname.drop(3)) 48 | commentsPst.setString(2, comment.id) 49 | commentsPst.setString(3, comment.body) 50 | commentsPst.setObject(4, comment.created.toOffsetDateTime()) 51 | commentsPst.setString(5, comment.author) 52 | try { 53 | commentsPst.execute() 54 | val resultSet = commentsPst.resultSet 55 | resultSet.next() 56 | if(resultSet.getBoolean(1)) comment else null 57 | } catch (ex: SQLException) { 58 | logger.error { ex.getStackTraceString() } 59 | null 60 | } 61 | } 62 | 63 | return try { 64 | pst.execute() to createdComments 65 | } catch (ex: SQLException){ 66 | logger.error { (ex.message) } 67 | false to createdComments 68 | } 69 | } 70 | 71 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/rtrx/a/flow/Flow.kt: -------------------------------------------------------------------------------- 1 | package de.rtrx.a.flow 2 | 3 | import de.rtrx.a.flow.events.EventType 4 | import kotlinx.coroutines.* 5 | import javax.inject.Provider 6 | import kotlin.coroutines.CoroutineContext 7 | 8 | /** 9 | * @param S The type of the returned Result 10 | */ 11 | interface Flow : CoroutineScope{ 12 | suspend fun start() 13 | 14 | fun startInScope(startFn: suspend () -> Unit): Job { 15 | return launch { startFn() } 16 | } 17 | } 18 | 19 | interface RelaunchableFlow : Flow { 20 | suspend fun relaunch() 21 | } 22 | 23 | /** 24 | * @param T The type of the flow created 25 | * @param M The type of the initial value 26 | */ 27 | interface FlowBuilder { 28 | 29 | /** 30 | Give the flow one (or multiple) values from the init trigger 31 | **/ 32 | fun setInitValue(value: M?): FlowBuilder 33 | /** 34 | Sets the callback that is called after the flow has run through 35 | **/ 36 | fun setFinishCallback(callback: Callback, Unit>): FlowBuilder 37 | 38 | /** 39 | * Sets a method that can be used by the flow to subscribe to event types that it is interested in 40 | * @param R The type of the item that should be passed later on 41 | * **/ 42 | fun setSubscribeAccess(access: suspend (T, suspend (R) -> Unit, EventType) -> Unit): FlowBuilder 43 | 44 | /** 45 | * Sets a method that can be used by the flow to unsubscribe from event types 46 | */ 47 | fun setUnsubscribeAccess(access: suspend (T, EventType<*>) -> Unit): FlowBuilder 48 | 49 | /** 50 | * Sets the Coroutine Scope for the Flow 51 | */ 52 | fun setCoroutineScope(scope: CoroutineScope): FlowBuilder 53 | 54 | /** 55 | * Creates the Flow 56 | */ 57 | fun build(): T 58 | } 59 | 60 | abstract class FlowBuilderDSL : FlowBuilder 61 | where T: IFlowStub, 62 | T : Flow{ 63 | protected var _initValue: M? = null 64 | protected var _callback: Callback, Unit> = Callback {_ -> Unit} 65 | protected var _subscribeAccess: suspend (T, suspend (Any) -> Unit, EventType<*>) -> Unit = { _, _, _ -> Unit} 66 | protected var _unsubscribeAccess: suspend (T, EventType<*>) -> Unit = { _, _ -> Unit} 67 | protected var _scope: CoroutineScope = CoroutineScope(Dispatchers.Default) 68 | 69 | override fun setSubscribeAccess(access: suspend (T, suspend (R) -> Unit, EventType) -> Unit): FlowBuilder { 70 | _subscribeAccess = {flow, function, type: EventType<*> -> access(flow, function, type as EventType)} 71 | return this 72 | } 73 | 74 | override fun setUnsubscribeAccess(access: suspend (T, EventType<*>) -> Unit): FlowBuilder { 75 | _unsubscribeAccess = access 76 | return this 77 | } 78 | 79 | override fun setInitValue(value: M?): FlowBuilder { 80 | _initValue = value 81 | return this 82 | } 83 | 84 | override fun setFinishCallback(callback: Callback, Unit>): FlowBuilder { 85 | _callback = callback 86 | return this 87 | } 88 | 89 | override fun setCoroutineScope(scope: CoroutineScope): FlowBuilder { 90 | this._scope = scope 91 | return this 92 | } 93 | 94 | open operator fun invoke(dsl: FlowBuilder.() -> Unit): FlowBuilder{ 95 | (this).dsl() 96 | return this 97 | } 98 | 99 | } 100 | 101 | interface FlowFactory{ 102 | suspend fun create(dispatcher: FlowDispatcherInterface, initValue: M, callback: Callback, Unit>): T 103 | } 104 | 105 | interface RelaunchableFlowFactory: FlowFactory { 106 | suspend fun recreateFlows(dispatcher: FlowDispatcherInterface, callbackProvider: Provider, Unit>>, additionalData: D): Collection 107 | } 108 | 109 | 110 | /** 111 | * @param M The type of the initial value 112 | */ 113 | interface IFlowStub> { 114 | val initValue: M 115 | val coroutineContext: CoroutineContext 116 | 117 | fun setOuter(outer: C) 118 | 119 | @Deprecated("use withSubscription") 120 | suspend fun subscribe(function: suspend (R) -> Unit, type: EventType) 121 | @Deprecated("use withSubscription") 122 | suspend fun unsubscribe(type: EventType) 123 | 124 | 125 | suspend fun withSubscription(subscription: Subscription, block: suspend CoroutineScope.() -> T): T 126 | suspend fun withSubscriptions(subscriptions: Collection>, block: suspend CoroutineScope.() -> T): T 127 | } 128 | 129 | /** 130 | * Typesafe Representation of a subscription 131 | */ 132 | interface Subscription { 133 | val hook: suspend (R) -> Unit 134 | val type: EventType 135 | companion object { 136 | private class SubscriptionImplementation( 137 | override val hook: suspend (R) -> Unit, 138 | override val type: EventType 139 | ): Subscription 140 | 141 | fun create(hook: suspend (R) -> Unit, type: EventType): Subscription = SubscriptionImplementation(hook, type) 142 | } 143 | } 144 | 145 | 146 | 147 | 148 | /** 149 | * @param M the type of the initial Value being supplied 150 | */ 151 | class FlowStub > ( 152 | override val initValue: M, 153 | private val _subscribeAccess: suspend (C, suspend (Any) -> Unit, EventType<*>) -> Unit, 154 | private val _unsubscribeAccess: suspend (C, EventType<*>) -> Unit, 155 | private val scope: CoroutineScope) 156 | : IFlowStub { 157 | private var outer: C? = null 158 | 159 | override fun setOuter(outer: C) = if(this.outer == null) this.outer = outer else Unit 160 | 161 | override suspend fun subscribe(function: suspend (R) -> Unit, type: EventType) { 162 | _subscribeAccess(outer!!, { event: Any -> function(event as R) } , type) 163 | } 164 | 165 | override suspend fun unsubscribe(type: EventType) { 166 | _unsubscribeAccess(outer!!, type) 167 | } 168 | 169 | override val coroutineContext: CoroutineContext 170 | get() = scope.coroutineContext 171 | 172 | override suspend fun withSubscription(subscription: Subscription, block: suspend CoroutineScope.() -> T): T{ 173 | _subscribeAccess(outer!!, { event: Any -> subscription.hook(event as R) }, subscription.type) 174 | //Wrappers are needed (according to Intellij) to make sure the receiver is unambiguous 175 | val result = block(scope) 176 | _unsubscribeAccess(outer!!, subscription.type) 177 | return result 178 | } 179 | 180 | override suspend fun withSubscriptions(subscriptions: Collection>, block: suspend CoroutineScope.() -> T): T { 181 | subscriptions.forEach { subscription -> with(subscription.hook as suspend (Any) -> Unit) { 182 | _subscribeAccess(outer!!, {event: Any -> this(event) }, subscription.type) } 183 | } 184 | 185 | val result = block(scope) 186 | subscriptions.forEach { subscription -> _unsubscribeAccess(outer!!, subscription.type)} 187 | return result 188 | } 189 | } 190 | 191 | sealed class FlowResult (val finishedFlow: T){ 192 | 193 | abstract val isFailed: Boolean 194 | 195 | abstract class NotFailedEnd(finishedFlow: T) : FlowResult(finishedFlow) { 196 | override val isFailed: Boolean = false 197 | 198 | class RegularEnd(finishedFlow: T) : NotFailedEnd(finishedFlow) 199 | } 200 | 201 | abstract class FailedEnd(finishedFlow: T): FlowResult(finishedFlow) { 202 | override val isFailed = true 203 | abstract val errorMessage: String 204 | 205 | class LogicFailed(finishedFlow: T) : FailedEnd(finishedFlow) { 206 | override val errorMessage = "Logic failed" 207 | } 208 | 209 | class NetworkFailed(finishedFlow: T): FailedEnd(finishedFlow) { 210 | override val errorMessage = "Network Failed" 211 | } 212 | 213 | class Cancelled(unfinishedFlow: T): FailedEnd(unfinishedFlow){ 214 | override val errorMessage = "Flow was cancelled" 215 | } 216 | 217 | } 218 | 219 | } 220 | 221 | 222 | -------------------------------------------------------------------------------- /src/main/kotlin/de/rtrx/a/flow/FlowDispatcher.kt: -------------------------------------------------------------------------------- 1 | package de.rtrx.a.flow 2 | 3 | import de.rtrx.a.ApplicationStoppedException 4 | import de.rtrx.a.flow.events.EventMultiplexer 5 | import de.rtrx.a.flow.events.EventMultiplexerBuilder 6 | import de.rtrx.a.flow.events.EventType 7 | import de.rtrx.a.flow.events.EventTypeFactory 8 | import kotlinx.coroutines.* 9 | import kotlinx.coroutines.channels.BroadcastChannel 10 | import kotlinx.coroutines.channels.Channel 11 | import kotlinx.coroutines.channels.ReceiveChannel 12 | import mu.KotlinLogging 13 | import java.util.concurrent.ConcurrentHashMap 14 | import java.util.concurrent.Executors 15 | import javax.inject.Inject 16 | import javax.inject.Named 17 | import javax.inject.Provider 18 | import kotlin.reflect.KClass 19 | 20 | typealias EventFactories = Map, Pair, KClass<*>>> 21 | 22 | /** 23 | * @param S The type of the result that a flow returns 24 | * @param T The type of the flow created 25 | */ 26 | 27 | interface FlowDispatcherInterface { 28 | suspend fun subscribe(flow: T, callback: suspend (R) -> Unit, type: EventType) 29 | suspend fun unsubscribe(flow: T, type: EventType<*>) 30 | 31 | fun getCreatedFlows(): ReceiveChannel 32 | suspend fun stop() 33 | suspend fun join() 34 | suspend fun , R : Any, I : Any> createNewEvent( 35 | clazz: KClass, 36 | id: I, 37 | multiplexerBuilder: EventMultiplexerBuilder> 38 | ): E 39 | 40 | suspend fun , R: Any, I: Any> unregisterEvent( 41 | clazz: KClass, 42 | id: I 43 | ) 44 | } 45 | 46 | interface IFlowDispatcherStub>: FlowDispatcherInterface{ 47 | suspend fun , R: Any> registerMultiplexer(type: E, multiplexer: EventMultiplexer) 48 | fun start() 49 | val flowFactory: F 50 | val launcherScope: CoroutineScope 51 | suspend fun setupAndStartFlows(startFn: suspend T.() -> Unit, flowProvider: suspend (Provider, Unit>>) -> Collection): Collection 52 | } 53 | 54 | /** 55 | * Provides Basic Utilities for dispatching new flows, most Applications written using this library will probably 56 | * need this Class. Decorate it to add your own functionality. 57 | * 58 | * @param eventFactories Key is the Klass of the type, Value Consists of the corresponding Factory and the Type of the Key 59 | * @param starterEventChannel upon which events to create new Flows 60 | * @param flowFactory Factory for the Flows 61 | * @param flowLauncherScope the scope in which to process [starterEventChannel], create and send the flows 62 | * @param eventFactories Factories for all Events that should be available 63 | */ 64 | class FlowDispatcherStub> @Inject constructor ( 65 | private val starterEventChannel: ReceiveChannel, 66 | override val flowFactory: F, 67 | @param:Named("launcherScope") override val launcherScope: CoroutineScope, 68 | private val eventFactories: EventFactories, 69 | internal val startAction: suspend T.() -> Unit = { start() } 70 | ): IFlowDispatcherStub { 71 | private val flows: BroadcastChannel = BroadcastChannel(Channel.CONFLATED) 72 | private val finishedFlows: BroadcastChannel> = BroadcastChannel(Channel.CONFLATED) 73 | private val multiplexers = mutableMapOf, EventMultiplexer<*>>() 74 | private val job: Job 75 | private val logger = KotlinLogging.logger { } 76 | 77 | private val callbackProvider: Provider, Unit>> = Provider { Callback { flowResult -> launcherScope.launch { finishedFlows.send(flowResult) } } } 78 | 79 | private val runningEvents = ConcurrentHashMap, ConcurrentHashMap, ReceiveChannel<*>>>>() 80 | private val eventCreationContext = Executors.newSingleThreadExecutor().asCoroutineDispatcher() 81 | 82 | init { 83 | eventFactories.forEach {(clazz, _) -> 84 | runningEvents.put(clazz, ConcurrentHashMap()) 85 | } 86 | 87 | job = launcherScope.launch(start = CoroutineStart.LAZY) { 88 | while(isActive) { 89 | try { 90 | for (event in starterEventChannel) { 91 | flows.send(flowFactory.create( 92 | this@FlowDispatcherStub, 93 | event, 94 | callbackProvider.get()) 95 | .apply { startInScope { startAction() } }) 96 | } 97 | } catch (e: Throwable) { 98 | logger.warn { e.message } 99 | } 100 | } 101 | } 102 | } 103 | 104 | 105 | override suspend fun subscribe(flow: T, callback: suspend (R) -> Unit, type: EventType) { 106 | CoroutineScope(eventCreationContext).launch { multiplexers[type]?.addListener(flow) { callback(it as R)} }.join() 107 | } 108 | 109 | override suspend fun unsubscribe(flow: T, type: EventType<*>) { 110 | CoroutineScope(eventCreationContext).launch { multiplexers[type]?.removeListeners(flow) }.join() 111 | } 112 | 113 | override fun getCreatedFlows(): ReceiveChannel { 114 | return flows.openSubscription() 115 | } 116 | 117 | override suspend fun , R : Any> registerMultiplexer(type: E, multiplexer: EventMultiplexer) { 118 | CoroutineScope(eventCreationContext).launch { multiplexers.put(type, multiplexer) }.join() 119 | } 120 | 121 | override fun start() { 122 | job.start() 123 | } 124 | 125 | override suspend fun stop() { 126 | job.cancel(ApplicationStoppedException()) 127 | job.join() 128 | } 129 | 130 | override suspend fun join() { 131 | job.join() 132 | } 133 | 134 | /** 135 | * Allows the creation of new events during Runtime. 136 | * Keep in mind that if the event is not needed during the whole runtime, it should be unregistered using [unregisterEvent] 137 | * to avoid memory leaks. 138 | * 139 | * If an Event is created for an individual flow, an easy way to make sure that the event is unregistered is to put the 140 | * call in the callback (e.g. in the corresponding [FlowFactory] Implementation) 141 | */ 142 | override suspend fun , R : Any, I : Any> createNewEvent( 143 | clazz: KClass, 144 | id: I, 145 | multiplexerBuilder: EventMultiplexerBuilder> 146 | ): E { 147 | val event = CoroutineScope(eventCreationContext).async { 148 | 149 | var (factory, idType) = eventFactories.get(clazz) 150 | ?: throw IllegalArgumentException("No Factory Present for given Type") 151 | if(idType.isInstance(id).not()) throw IllegalArgumentException("Given Key does not match required Key for Factory") 152 | factory = factory as EventTypeFactory 153 | 154 | val (event, out) = runningEvents.get(clazz)!!.getOrPut( 155 | id, 156 | { 157 | val (event, out) = factory.create(id) 158 | val multiplexer = multiplexerBuilder.setOrigin(out).build() 159 | registerMultiplexer(event, multiplexer as EventMultiplexer) 160 | event to out 161 | } 162 | ) 163 | 164 | event 165 | } 166 | return event.await() as E 167 | } 168 | 169 | /** 170 | * Removes all references to the Event Identified by the arguments. 171 | * However it does not remove flows from the [EventMultiplexer] 172 | */ 173 | override suspend fun , R : Any, I : Any> unregisterEvent(clazz: KClass, id: I) { 174 | CoroutineScope(eventCreationContext).launch { 175 | runningEvents.get(clazz)?.remove(id)?.run { 176 | multiplexers.remove(first) 177 | } 178 | } 179 | } 180 | 181 | override suspend fun setupAndStartFlows(startFn: suspend T.() -> Unit, flowProvider: suspend (Provider, Unit>>) -> Collection): Collection { 182 | return launcherScope.async { 183 | val flows = flowProvider(callbackProvider) 184 | val jobs = flows.map { it.startInScope { it.startFn() } } 185 | flows.forEach { this@FlowDispatcherStub.flows.send(it)} 186 | return@async jobs 187 | }.await() 188 | } 189 | 190 | } 191 | 192 | class RelaunchableFlowDispatcherStub> @Inject constructor ( 193 | private val flowDispatcherStub: IFlowDispatcherStub, 194 | private val flowsToLaunch: Collection 195 | ): IFlowDispatcherStub by flowDispatcherStub{ 196 | private val relaunchedCompleted = CompletableDeferred() 197 | override fun start() { 198 | flowDispatcherStub.launcherScope.launch { 199 | flowsToLaunch.map { it.startInScope(it::relaunch) }.joinAll() 200 | flowDispatcherStub.start() 201 | } 202 | } 203 | } 204 | 205 | 206 | -------------------------------------------------------------------------------- /src/main/kotlin/de/rtrx/a/flow/FlowParts.kt: -------------------------------------------------------------------------------- 1 | package de.rtrx.a.flow 2 | 3 | import com.google.inject.Inject 4 | import com.google.inject.Provider 5 | import com.google.inject.assistedinject.Assisted 6 | import com.google.inject.assistedinject.AssistedInject 7 | import com.google.inject.name.Named 8 | import com.uchuhimo.konf.Config 9 | import de.rtrx.a.RedditSpec 10 | import de.rtrx.a.database.Booleable 11 | import de.rtrx.a.database.Linkage 12 | import de.rtrx.a.database.ObservationLinkage 13 | import kotlinx.coroutines.* 14 | import kotlinx.coroutines.selects.SelectClause1 15 | import kotlinx.coroutines.selects.select 16 | import mu.KotlinLogging 17 | import net.dean.jraw.RedditClient 18 | import net.dean.jraw.models.Comment 19 | import net.dean.jraw.models.Message 20 | import net.dean.jraw.models.Submission 21 | import net.dean.jraw.references.CommentReference 22 | import net.dean.jraw.references.PublicContributionReference 23 | 24 | typealias MessageCheck = (Message) -> Boolean 25 | interface DeletePrevention{ 26 | suspend fun check(publicRef: PublicContributionReference): DelayedDelete.DeleteResult 27 | } 28 | 29 | class Callback(private var action: (T) -> R) : (T) -> R{ 30 | private var wasCalled = false 31 | override operator fun invoke(value: T): R { 32 | if(wasCalled == true) throw CallbackAlreadyCalledException() 33 | else return action(value) 34 | } 35 | 36 | fun addAction(action: (T) -> R){ 37 | val currentAction = this.action 38 | this.action = { t -> 39 | currentAction(t) 40 | action(t) 41 | } 42 | } 43 | 44 | class CallbackAlreadyCalledException : Throwable("Callback was already called") 45 | class NoCallbackDefinedWarning: Throwable("No Callback function was set") 46 | } 47 | 48 | interface Conversation { 49 | /** 50 | * Setup the conversation 51 | * @param check A Predicate for identifiying the starting Message of the conversation 52 | */ 53 | suspend fun CoroutineScope.waitForCompletion(check: MessageCheck): Message 54 | 55 | /** 56 | * Takes in messages, that are checked and if matched are saved as the origin of the conversation 57 | */ 58 | suspend fun start(message: Message) 59 | 60 | /** 61 | * Takes in messages, that are checked if they are a reply to the message matched by [start] 62 | */ 63 | suspend fun reply(message: Message) 64 | 65 | 66 | } 67 | 68 | interface JumpstartConversation : Conversation { 69 | suspend fun jumpstart(id: R) 70 | } 71 | class DefferedConversationProvider @Inject constructor ( 72 | private val config: Config 73 | ): Provider> { override fun get() = DefferedConversation(config) } 74 | 75 | class DefferedConversation @Inject constructor( 76 | private val config: Config 77 | ) : JumpstartConversation{ 78 | private val defferedOwnMessage: CompletableDeferred = CompletableDeferred() 79 | val ownMessage get() = defferedOwnMessage.getCompleted() 80 | 81 | private val defferedReply: CompletableDeferred = CompletableDeferred() 82 | val reply get() = defferedReply.getCompleted() 83 | 84 | private val deferredScope: CompletableDeferred = CompletableDeferred() 85 | private lateinit var checkMessage: (Message) -> Boolean 86 | 87 | 88 | override suspend fun CoroutineScope.waitForCompletion(check: MessageCheck): Message { 89 | deferredScope.complete(this) 90 | checkMessage = check 91 | return defferedReply.await() 92 | } 93 | 94 | override suspend fun start(message: Message) { 95 | deferredScope.await().launch { 96 | if(checkMessage(message)){ 97 | defferedOwnMessage.complete(message.fullName) 98 | } 99 | } 100 | } 101 | 102 | override suspend fun reply(message: Message) { 103 | deferredScope.await().launch { 104 | val proceed = select { 105 | defferedOwnMessage.onAwait { true } 106 | defferedReply.onAwait { false } 107 | onTimeout(config[RedditSpec.messages.sent.maxWaitForCompletion]) { false } 108 | } 109 | if(proceed){ 110 | if(message.firstMessage == ownMessage){ 111 | defferedReply.complete(message) 112 | } 113 | } 114 | } 115 | } 116 | 117 | override suspend fun jumpstart(id: String) { 118 | deferredScope.await().launch { 119 | defferedOwnMessage.complete(id) 120 | } 121 | } 122 | 123 | } 124 | 125 | fun produceCheckString(submissionID: String): (String) -> Boolean { 126 | fun checkMessage(body: String): Boolean { 127 | val startIndex = body.indexOf("(") 128 | val endIndex = body.indexOf(")") 129 | 130 | //Check whether the parent message was sent by us and if a link exists. 131 | if (startIndex >= 0 && endIndex >= 0) { 132 | //Extract the Link from the parent message by cropping around the first parenthesis 133 | return try { 134 | val id = body 135 | .slice((startIndex + 1) until endIndex) 136 | //Extract the ID of the submission 137 | .split("comments/")[1].split("/")[0] 138 | id == submissionID 139 | } catch (t: Throwable) { 140 | false 141 | } 142 | 143 | } 144 | return false 145 | } 146 | return ::checkMessage 147 | } 148 | 149 | fun produceCheckMessage(submissionID: String): (Message) -> Boolean { 150 | return {message -> produceCheckString(submissionID).invoke(message.body)} 151 | } 152 | interface MessageComposer: (String, String) -> Unit 153 | class RedditMessageComposer @Inject constructor( 154 | private val redditClient: RedditClient, 155 | private val config: Config 156 | ): MessageComposer { 157 | override fun invoke(author: String, postURL: String) { 158 | fun decideS(time: Long, unit: Long) = if((time / unit)> 1) "s" else "" 159 | fun decideUnit(time: Long) = 160 | if(time < 60) time.toString() + " second" + decideS(time, 1) 161 | else if(time < 60*60) (time / 60).toString() + " minute" + decideS(time, 60) 162 | else (time / (60*60)).toString() + " hour" + decideS(time, 60*60) 163 | 164 | val timeToDrop = (config[RedditSpec.messages.sent.maxTimeDistance] / (1000)) 165 | val timeToDelete = (config[RedditSpec.scoring.timeUntilRemoval] / 1000) 166 | redditClient.me().inbox().compose( 167 | dest = author, 168 | subject = config[RedditSpec.messages.sent.subject], 169 | body = config[RedditSpec.messages.sent.body] 170 | .replace("%{Submission}", postURL) 171 | .replace("%{TimeUntilDrop}", decideUnit(timeToDrop)) 172 | .replace("%{TimeUntilRemoval}", decideUnit(timeToDelete)) 173 | .replace("%{HoursUntilDrop}", (config[RedditSpec.messages.sent.maxTimeDistance] / (1000 * 60 * 60)).toString()) 174 | .replace("%{subreddit}", config[RedditSpec.subreddit]) 175 | .replace("%{MinutesUntilRemoval}", (config[RedditSpec.scoring.timeUntilRemoval] / (1000 * 60)).toString()) 176 | ) 177 | } 178 | } 179 | 180 | interface Replyer : (Submission, String) -> Pair 181 | class RedditReplyer @Inject constructor( 182 | private val redditClient: RedditClient, 183 | private val config: Config): Replyer { 184 | override fun invoke(submission: Submission, reason: String): Pair { 185 | val comment = submission.toReference(redditClient) 186 | .reply(config[RedditSpec.scoring.commentBody].replace("%{Reason}", 187 | //Prevents People from breaking the spoiler tag 188 | reason.take(config[RedditSpec.messages.unread.answerMaxCharacters]).replace("\n\n"," \n").trim())) 189 | return comment to comment.toReference(redditClient) 190 | } 191 | } 192 | 193 | interface Unignorer : (PublicContributionReference) -> Unit 194 | 195 | class RedditUnignorer @Inject constructor( 196 | private val redditClient: RedditClient 197 | ) : Unignorer{ 198 | override fun invoke(publicContribution: PublicContributionReference) { 199 | val response = redditClient.request { 200 | it.url("https://oauth.reddit.com/api/unignore_reports").post( 201 | mapOf( "id" to publicContribution.fullName ) 202 | ) 203 | } 204 | if(response.successful.not()){ 205 | KotlinLogging.logger { }.warn { "couldn't unignore reports from post ${publicContribution.fullName}" } 206 | } 207 | 208 | } 209 | 210 | } 211 | 212 | interface DelayedDeleteFactory{ 213 | fun create(publicContribution: PublicContributionReference, scope: CoroutineScope, skip: Long): DelayedDelete 214 | } 215 | 216 | /** 217 | * Represents a safe Way for deleting (and reapproving) a [PublicContributionReference] depending on the outcome of a selectClause 218 | * (For Example The Finishing of a job or an deferred Value becoming available) 219 | */ 220 | interface DelayedDelete { 221 | /** 222 | * Starts The Implementation Specific counter for deleting the Post 223 | */ 224 | fun start() 225 | 226 | suspend fun safeSelectTo(clause1: SelectClause1): DeleteResult 227 | 228 | companion object { 229 | val approvedCheck: (ObservationLinkage) -> DeletePrevention = { linkage -> object : DeletePrevention 230 | { 231 | override suspend fun check(publicRef: PublicContributionReference): DeleteResult { 232 | return linkage.createCheckSelectValues( 233 | publicRef.fullName, 234 | null, 235 | null, 236 | emptyArray(), 237 | { if (it.get("approved")?.asBoolean ?: false) NotDeletedApproved() else DeleteResult.WasDeleted() } 238 | ).checkResult as DeleteResult 239 | } 240 | } 241 | } 242 | class NotDeletedApproved:DeleteResult.NotDeleted() 243 | } 244 | sealed class DeleteResult(bool: Boolean): Booleable{ 245 | override val bool: Boolean = bool 246 | open class WasDeleted: DeleteResult(false) 247 | open class NotDeleted: DeleteResult(true){ 248 | class NotDeletedReapproved: NotDeleted() 249 | } 250 | } 251 | } 252 | 253 | class RedditDelayedDelete @AssistedInject constructor( 254 | @Named("delayToDeleteMillis") delayToDeleteMillis: Long, 255 | @Named("delayToFinishMillis") delayToFinishMillis: Long, 256 | private val unignorer: Unignorer, 257 | private val preventsDeletion: @JvmSuppressWildcards DeletePrevention, 258 | @param:Assisted private val publicContribution: PublicContributionReference, 259 | @param:Assisted private val scope: CoroutineScope, 260 | @param:Assisted private val skip: Long 261 | ): DelayedDelete { 262 | val removeSubmission = remove() 263 | lateinit var deletionJob: Deferred 264 | 265 | private val delayToDeleteMillis: Long 266 | private val delayToFinishMillis: Long 267 | 268 | init { 269 | this.delayToDeleteMillis = (delayToDeleteMillis - skip).coerceAtLeast(0) 270 | this.delayToFinishMillis = (delayToFinishMillis - (delayToDeleteMillis - skip)).coerceAtLeast(0) 271 | } 272 | override fun start() { 273 | deletionJob = scope.async { 274 | try { 275 | delay(delayToDeleteMillis) 276 | removeSubmission.start() 277 | delay(delayToFinishMillis) 278 | removeSubmission.await() 279 | } catch (e: CancellationException) { DelayedDelete.DeleteResult.NotDeleted()} 280 | } 281 | } 282 | 283 | override suspend fun safeSelectTo(clause1: SelectClause1): DelayedDelete.DeleteResult { 284 | return select { 285 | clause1 { 286 | logger.trace("Received Reply for ${publicContribution.fullName}") 287 | deletionJob.cancel() 288 | val removalCheck: DelayedDelete.DeleteResult 289 | if (removeSubmission.isActive || removeSubmission.isCompleted) { 290 | removalCheck = removeSubmission 291 | .await() 292 | .takeUnless { it is DelayedDelete.DeleteResult.WasDeleted } 293 | ?: DelayedDelete.DeleteResult.NotDeleted.NotDeletedReapproved() 294 | 295 | publicContribution.approve() 296 | unignorer(publicContribution) 297 | logger.trace("Reapproved ${publicContribution.fullName}") 298 | } else { removalCheck = DelayedDelete.DeleteResult.NotDeleted() } 299 | removalCheck 300 | } 301 | deletionJob.onAwait { 302 | logger.trace("Didn't receive an answer for ${publicContribution.fullName}") 303 | it 304 | } 305 | } 306 | } 307 | 308 | private fun remove(): Deferred { 309 | return scope.async (start = CoroutineStart.LAZY) { 310 | val willRemove = preventsDeletion.check(publicContribution) 311 | if(!willRemove.bool) { 312 | publicContribution.remove() 313 | logger.trace { "Contribution ${publicContribution.fullName} removed" } 314 | } else logger.info { "Contribution ${publicContribution.fullName} not removed because it was manually approved" } 315 | willRemove 316 | } 317 | } 318 | companion object { 319 | private val logger = KotlinLogging.logger { } 320 | } 321 | } 322 | 323 | class SubmissionAlreadyPresent(finishedFlow: T) : FlowResult.NotFailedEnd(finishedFlow) 324 | class NoAnswerReceived(finishedFlow: T) : FlowResult.NotFailedEnd(finishedFlow) 325 | 326 | -------------------------------------------------------------------------------- /src/main/kotlin/de/rtrx/a/flow/IsolationStrategy.kt: -------------------------------------------------------------------------------- 1 | package de.rtrx.a.flow 2 | 3 | import kotlinx.coroutines.* 4 | import mu.KotlinLogging 5 | import java.util.concurrent.ConcurrentHashMap 6 | import java.util.concurrent.Executors 7 | 8 | private val logger = KotlinLogging.logger { } 9 | interface IsolationStrategy{ 10 | /** 11 | * Executes a list of functions with the context of the flow. 12 | * @param data Some Object that is passed to the functions as an argument 13 | */ 14 | fun executeEach(data: R, flow: Flow, fns: Collection Unit>) 15 | 16 | /** 17 | * Removes the current Context for the flow and does cleanup. 18 | * Calling [executeEach] with [flow] is possible and results in a new context beeing created. 19 | */ 20 | fun removeFlow(flow: Flow) 21 | 22 | /** 23 | * Calls removeFlow on every flow that has a context assigned to it 24 | */ 25 | fun removeAllFlows() 26 | 27 | /** 28 | * Stops all currently running jobs. 29 | * Note that [stop] does not imply [removeAllFlows] 30 | */ 31 | fun stop() 32 | } 33 | 34 | class SingleFlowIsolation: IsolationStrategy { 35 | val flows = ConcurrentHashMap() 36 | val executorRoutine = CoroutineScope(CoroutineName("IsolationStrategy")) 37 | override fun executeEach(data: R, flow: Flow, fns: Collection Unit>) { 38 | executorRoutine.launch(flows.getOrPut(flow) { Executors.newSingleThreadExecutor().asCoroutineDispatcher() }) { 39 | for (fn in fns) try { fn(data) } catch (e: Throwable) { logger.error { e.message }} 40 | } 41 | } 42 | 43 | override fun removeFlow(flow: Flow) { 44 | flows.remove(flow)?.close() 45 | } 46 | 47 | override fun removeAllFlows() { 48 | flows.keys.forEach(this::removeFlow) 49 | } 50 | 51 | 52 | override fun stop() { 53 | flows.forEach { it.value.cancel(SingleFlowIsolationStopped()) } 54 | } 55 | 56 | inner class SingleFlowIsolationStopped : CancellationException("Single Flow Isolation Strategy was stopped") 57 | 58 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/rtrx/a/flow/UtilityFlows.kt: -------------------------------------------------------------------------------- 1 | package de.rtrx.a.flow 2 | 3 | import com.uchuhimo.konf.Config 4 | import de.rtrx.a.RedditSpec 5 | import kotlinx.coroutines.* 6 | import net.dean.jraw.RedditClient 7 | import net.dean.jraw.models.Message 8 | import java.time.Instant 9 | import java.util.* 10 | import java.util.concurrent.ConcurrentSkipListSet 11 | import javax.inject.Inject 12 | import kotlin.coroutines.CoroutineContext 13 | 14 | class MarkAsReadFlow @Inject constructor(private val redditClient: RedditClient) : Flow { 15 | override suspend fun start() { } 16 | 17 | suspend fun markAsRead(message: Message){ 18 | redditClient.me().inbox().markRead(true, message.fullName) 19 | } 20 | 21 | override val coroutineContext: CoroutineContext = Dispatchers.Default 22 | } 23 | 24 | class ArchivingFlow @Inject constructor(private val config: Config) : Flow { 25 | lateinit var startDate: Instant 26 | private val mutableMessages = ConcurrentSkipListSet { t1, t2 -> t1.created.compareTo(t2.created) } 27 | public val messages get() = LinkedList(mutableMessages) 28 | private val allMessagesSaved = CompletableDeferred() 29 | public val finished: Deferred = allMessagesSaved 30 | 31 | override suspend fun start() { 32 | startDate = Instant.now() 33 | //Complete archive when no messages come 34 | launch { 35 | delay(config[RedditSpec.messages.sent.waitIntervall] * 5) 36 | if(mutableMessages.isEmpty()){ 37 | allMessagesSaved.complete(Unit) 38 | } 39 | } 40 | allMessagesSaved.await() 41 | } 42 | 43 | override val coroutineContext: CoroutineContext = Dispatchers.Default 44 | 45 | suspend fun saveMessage(message: Message){ 46 | //Don't save new messages 47 | if(startDate < message.created.toInstant()) return 48 | if(!allMessagesSaved.isActive){ 49 | throw IllegalStateException("Couldn't add message ${message.id}, Archive is already finished") 50 | } 51 | 52 | mutableMessages.add(message) 53 | 54 | //Complete archive when no messages come after this one anymore 55 | launch { 56 | delay(config[RedditSpec.messages.sent.waitIntervall] * 3) 57 | if(mutableMessages.last().created == message.created){ 58 | allMessagesSaved.complete(Unit) 59 | } 60 | } 61 | } 62 | 63 | 64 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/rtrx/a/flow/events/EventMultiplexer.kt: -------------------------------------------------------------------------------- 1 | package de.rtrx.a.flow.events 2 | 3 | import de.rtrx.a.flow.Flow 4 | import de.rtrx.a.flow.IsolationStrategy 5 | import de.rtrx.a.flow.SingleFlowIsolation 6 | import kotlinx.coroutines.* 7 | import kotlinx.coroutines.channels.ReceiveChannel 8 | import kotlinx.coroutines.sync.Mutex 9 | import kotlinx.coroutines.sync.withLock 10 | import mu.KotlinLogging 11 | import java.util.concurrent.ConcurrentHashMap 12 | import java.util.concurrent.ConcurrentLinkedQueue 13 | import java.util.concurrent.Executors 14 | import java.util.concurrent.ThreadPoolExecutor 15 | import javax.inject.Inject 16 | import kotlin.coroutines.CoroutineContext 17 | 18 | private val logger = KotlinLogging.logger { } 19 | interface EventMultiplexer { 20 | fun addListener(flow: Flow, fn: suspend (R) -> Unit) 21 | 22 | fun removeListeners(flow: Flow) 23 | } 24 | 25 | /** 26 | * @param R The Type that's going to be passed to the listeners 27 | * @param X The Produced Multiplexer 28 | * @param O The Origin of the data 29 | */ 30 | @JvmSuppressWildcards 31 | interface EventMultiplexerBuilder, in O> { 32 | fun build() : X 33 | fun setOrigin(origin: O): EventMultiplexerBuilder 34 | fun setIsolationStrategy(strategy: IsolationStrategy): EventMultiplexerBuilder 35 | operator fun invoke(dsl: EventMultiplexerBuilder.() -> Unit): EventMultiplexerBuilder { 36 | this.dsl() 37 | return this 38 | } 39 | } 40 | 41 | 42 | @JvmSuppressWildcards 43 | class SimpleMultiplexer @Inject constructor(private val origin: ReceiveChannel<@JvmSuppressWildcards R>, private val isolationStrategy: IsolationStrategy): EventMultiplexer { 44 | private val listeners: MutableMap Unit>> = ConcurrentHashMap() 45 | private val accessScope: CoroutineScope = CoroutineScope(Dispatchers.Default) 46 | private val mutex: Mutex = Mutex() 47 | 48 | init { 49 | CoroutineScope(Dispatchers.Default).launch { 50 | for (element in origin) { 51 | mutex.withLock { 52 | listeners.forEach { flow, list -> 53 | try { 54 | isolationStrategy.executeEach(element, flow, list) 55 | } catch (e: Throwable){ 56 | logger.error { "flow threw exception: " + e.message } 57 | } 58 | } 59 | } 60 | } 61 | } 62 | } 63 | 64 | override fun addListener(flow: Flow, fn: suspend (R) -> Unit) { 65 | //It would probably necessary to synchronize with a mutex since the Reddit API is very slow 66 | accessScope.launch { 67 | mutex.withLock { 68 | listeners.getOrPut(flow, { ConcurrentLinkedQueue() }).add(fn) 69 | } 70 | } 71 | } 72 | 73 | override fun removeListeners(flow: Flow) { 74 | accessScope.launch { 75 | mutex.withLock { 76 | listeners.remove(flow) 77 | isolationStrategy.removeFlow(flow) 78 | } 79 | } 80 | } 81 | 82 | class SimpleMultiplexerBuilder : @kotlin.jvm.JvmSuppressWildcards EventMultiplexerBuilder, @kotlin.jvm.JvmSuppressWildcards ReceiveChannel>{ 83 | @JvmSuppressWildcards 84 | private lateinit var _origin: ReceiveChannel 85 | @JvmSuppressWildcards 86 | private var _isolationStrategy: IsolationStrategy = SingleFlowIsolation() 87 | 88 | @JvmSuppressWildcards 89 | override fun setOrigin(origin: ReceiveChannel): SimpleMultiplexerBuilder{ 90 | this._origin = origin 91 | return this 92 | } 93 | 94 | override fun setIsolationStrategy(strategy: IsolationStrategy): SimpleMultiplexerBuilder { 95 | this._isolationStrategy = strategy 96 | return this 97 | } 98 | 99 | override fun build() = SimpleMultiplexer(_origin, _isolationStrategy) 100 | 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/main/kotlin/de/rtrx/a/flow/events/EventTypes.kt: -------------------------------------------------------------------------------- 1 | package de.rtrx.a.flow.events 2 | 3 | import com.uchuhimo.konf.Config 4 | import de.rtrx.a.RedditSpec 5 | import de.rtrx.a.jrawExtension.subscribe 6 | import kotlinx.coroutines.* 7 | import kotlinx.coroutines.channels.Channel 8 | import kotlinx.coroutines.channels.ReceiveChannel 9 | import kotlinx.coroutines.channels.produce 10 | import mu.KotlinLogging 11 | import net.dean.jraw.RedditClient 12 | import net.dean.jraw.models.Message 13 | import net.dean.jraw.models.Submission 14 | import net.dean.jraw.models.SubredditSort 15 | import net.dean.jraw.references.SubmissionReference 16 | import javax.inject.Inject 17 | import javax.inject.Named 18 | 19 | 20 | /** 21 | * Represents a real event on the Reddit API Side 22 | * @param R The Type of the data that is being produced 23 | **/ 24 | interface EventType 25 | 26 | /** 27 | * Utility Class that can be used if it is desired to implement the ReceiveChannel here and not in the Factory 28 | */ 29 | abstract class EventStream(_outReceiver: (() -> ReceiveChannel) -> Unit) : EventType { 30 | protected abstract val out: ReceiveChannel 31 | private fun _getOut() = out 32 | 33 | init { 34 | _outReceiver { _getOut() } 35 | } 36 | } 37 | 38 | /** 39 | * @param E The type of the Event 40 | * @param R Type of [E] 41 | * @param I Type of the ID that can be used to get an Event 42 | */ 43 | interface EventTypeFactory, R: Any, in I>{ 44 | fun create(id: I): Pair> 45 | } 46 | 47 | fun , R: Any, I: Any> UniversalEventTypeFactory(factoryFn: (I) -> Pair> ): EventTypeFactory { 48 | return object : EventTypeFactory { 49 | override fun create(id: I) = factoryFn(id) 50 | } 51 | } 52 | 53 | interface NewPostEventFactory: EventTypeFactory 54 | 55 | interface NewPostEvent : EventType 56 | 57 | class RedditNewPostEvent( private val out: ReceiveChannel ): NewPostEvent 58 | class RedditNewPostEventFactory @Inject constructor( 59 | private val config: Config, 60 | private val redditClient: RedditClient 61 | ): NewPostEventFactory{ 62 | override fun create(id: String): Pair> { 63 | val (_, out) = redditClient 64 | .subreddit(id) 65 | .posts() 66 | .sorting(SubredditSort.NEW) 67 | .limit(config[RedditSpec.submissions.limit]) 68 | .build() 69 | .subscribe(config[RedditSpec.submissions.waitIntervall], ageLimit = config[RedditSpec.submissions.maxTimeDistance]) 70 | val eventType = RedditNewPostEvent(out) 71 | return eventType to out 72 | } 73 | } 74 | 75 | interface NewPostReferenceEvent: EventType 76 | interface NewPostReferenceFactory: EventTypeFactory 77 | 78 | class RedditNewPostReferenceEvent( private val out: ReceiveChannel): NewPostReferenceEvent 79 | private val logger = KotlinLogging.logger { } 80 | class RedditNewPostReferenceFactory @Inject constructor( 81 | private val config: Config, 82 | private val redditClient: RedditClient 83 | ): NewPostReferenceFactory { 84 | override fun create(id: String): Pair> { 85 | logger.debug { "Creating a RedditNewPostReference Event for id $id" } 86 | val (_, out) = redditClient 87 | .subreddit(id) 88 | .posts() 89 | .sorting(SubredditSort.NEW) 90 | .limit(config[RedditSpec.submissions.limit]) 91 | .build() 92 | .subscribe(config[RedditSpec.submissions.waitIntervall], ageLimit = config[RedditSpec.submissions.maxTimeDistance]) 93 | val submissionReferences = CoroutineScope(Dispatchers.Default).produce(capacity = Channel.UNLIMITED) { 94 | for (submission in out) { 95 | KotlinLogging.logger { }.trace("Received new Submission ${submission.fullName} in RedditNewPostReferenceEvent with id $id") 96 | send(submission.toReference(redditClient)) 97 | } 98 | } 99 | val eventType = RedditNewPostReferenceEvent(submissionReferences) 100 | return eventType to submissionReferences 101 | } 102 | } 103 | 104 | interface MessageEvent: EventType{ 105 | /** 106 | * Starts the Event if not started yet. 107 | * @return whether this call made this event start. Returning false means that this event was already running 108 | */ 109 | fun start(): Boolean 110 | } 111 | interface SentMessageEvent: MessageEvent 112 | interface IncomingMessagesEvent: MessageEvent 113 | interface MessageEventFactory: EventTypeFactory 114 | 115 | /** 116 | * A Factory creating EventTypes representing the "unread" api Endpoint. For each [create] call a new instance will be created, but 117 | * interacting with it (like marking messages as read) might interfere with other instances. 118 | * Received messages will not be marked read automatically. 119 | * It is recommended that you do so in the Dispatcher, Flow or Multiplexer. 120 | */ 121 | interface IncomingMessageFactory: MessageEventFactory 122 | 123 | class InboxEventFactory( 124 | private val where: String, 125 | private val limit: Int, 126 | private val waitIntervall: Long, 127 | private val ageLimit: Long, 128 | private val redditClient: RedditClient, 129 | private val creator: (ReceiveChannel, Job) -> E, 130 | private val channelStart: CoroutineStart 131 | ): MessageEventFactory{ 132 | override fun create(id: String): Pair> { 133 | val (job, out) = redditClient 134 | .me() 135 | .inbox() 136 | .iterate(where) 137 | .limit(limit) 138 | .build() 139 | .subscribe(waitIntervall, ageLimit = ageLimit, channelStart = channelStart) 140 | 141 | val eventType = creator(out, job) 142 | return eventType to out 143 | } 144 | } 145 | 146 | interface SentMessageFactory: MessageEventFactory 147 | 148 | class RedditSentMessageFactory @Inject constructor( 149 | private val config: Config, 150 | private val redditClient: RedditClient, 151 | @param:Named("RedditSentMessage") private val channelStart: CoroutineStart, 152 | ): SentMessageFactory, MessageEventFactory by InboxEventFactory( 153 | "sent", 154 | config[RedditSpec.messages.sent.limit], 155 | config[RedditSpec.messages.sent.waitIntervall], 156 | config[RedditSpec.messages.sent.maxTimeDistance], 157 | redditClient, 158 | ::RedditSentMessageEvent, 159 | channelStart 160 | ) 161 | 162 | class RedditIncomingMessageFactory @Inject constructor( 163 | private val config: Config, 164 | private val redditClient: RedditClient, 165 | @param:Named("RedditIncomingMessage") private val channelStart: CoroutineStart, 166 | ): IncomingMessageFactory, MessageEventFactory by InboxEventFactory( 167 | "unread", 168 | config[RedditSpec.messages.unread.limit], 169 | config[RedditSpec.messages.unread.waitIntervall], 170 | config[RedditSpec.messages.sent.maxTimeDistance], 171 | redditClient, 172 | ::RedditIncomingMessageEvent, 173 | channelStart 174 | ) 175 | 176 | class RedditSentMessageEvent(private val out: ReceiveChannel, private val job: Job): SentMessageEvent{ 177 | override fun start() = job.start() 178 | 179 | } 180 | class RedditIncomingMessageEvent(private val out: ReceiveChannel, private val job: Job): IncomingMessagesEvent { 181 | override fun start() = job.start() 182 | } 183 | 184 | -------------------------------------------------------------------------------- /src/main/kotlin/de/rtrx/a/flow/events/comments/FullComments.kt: -------------------------------------------------------------------------------- 1 | package de.rtrx.a.flow.events.comments 2 | 3 | import com.uchuhimo.konf.Config 4 | import de.rtrx.a.RedditSpec 5 | import de.rtrx.a.flow.events.EventType 6 | import de.rtrx.a.flow.events.EventTypeFactory 7 | import de.rtrx.a.jrawExtension.UpdatedCommentNode 8 | import kotlinx.coroutines.* 9 | import kotlinx.coroutines.channels.Channel 10 | import kotlinx.coroutines.channels.ReceiveChannel 11 | import net.dean.jraw.RedditClient 12 | import net.dean.jraw.models.Comment 13 | import net.dean.jraw.models.CommentSort 14 | import net.dean.jraw.references.CommentsRequest 15 | import net.dean.jraw.references.SubmissionReference 16 | import net.dean.jraw.tree.AbstractCommentNode 17 | import net.dean.jraw.tree.ReplyCommentNode 18 | import java.util.* 19 | import java.util.concurrent.Executors 20 | import javax.inject.Inject 21 | 22 | data class FullComments(public val comments: List, public val commentsHierarchy: Map, public val sticky: Comment?) { 23 | public val commentsToSticky by lazy { sticky?.run { commentsHierarchy.count { it.value == id } } ?: 0 } 24 | } 25 | 26 | interface CommentsFetchedEvent: EventType 27 | interface ManuallyFetchedEvent: CommentsFetchedEvent { 28 | suspend fun fetchComments() 29 | } 30 | 31 | 32 | interface CommentsFetcherFactory: EventTypeFactory 33 | 34 | class RedditCommentsFetchedFactory @Inject constructor( 35 | private val redditClient: RedditClient, 36 | private val config: Config 37 | ) : CommentsFetcherFactory { 38 | 39 | override fun create(id: SubmissionReference): Pair> { 40 | val event = RedditCommentsFetchedEvent(id, redditClient, config) 41 | val out = event.channel 42 | return event to out 43 | } 44 | 45 | private class RedditCommentsFetchedEvent ( 46 | private val submission: SubmissionReference, 47 | private val redditClient: RedditClient, 48 | private val config: Config 49 | ): ManuallyFetchedEvent { 50 | private val context = Executors.newSingleThreadExecutor().asCoroutineDispatcher() 51 | .plus(CoroutineName("CommentFetcher:${submission.fullName}")) 52 | 53 | var channel = Channel() 54 | 55 | override suspend fun fetchComments() { 56 | CoroutineScope(context).launch { 57 | //Forms the Request for looking up the comments 58 | val firstPull = submission.comments(CommentsRequest( 59 | depth = config[RedditSpec.checks.DB.depth], 60 | sort = CommentSort.TOP 61 | )) 62 | 63 | var size = UpdatedCommentNode(firstPull).totalSize() 64 | //Pulls comments until the value specified in the configuration is reached or no more comments can be found 65 | var newComments: List? = null 66 | while (size < config[RedditSpec.checks.DB.comments_amount] && newComments?.isNotEmpty() ?: true) { 67 | delay(config[RedditSpec.checks.DB.commentWaitIntervall]) 68 | newComments = firstPull.replaceMore(redditClient) 69 | size += newComments.size 70 | } 71 | 72 | val commentsTree = UpdatedCommentNode(firstPull).walkTree().toCollection(LinkedList()) 73 | 74 | var sticky: Comment? = null 75 | val commentHierarchy = mutableMapOf() 76 | val comments = mutableListOf() 77 | 78 | while (commentsTree.isNotEmpty()){ 79 | val contribution = commentsTree.poll().let { if (it is UpdatedCommentNode<*, *>) it else UpdatedCommentNode(it as AbstractCommentNode<*>) } 80 | if(contribution.subject is Comment) { 81 | val comment = contribution.subject as Comment 82 | if (comment.isStickied) { 83 | sticky = comment 84 | //Comments to the stickied comment are not loaded by default, so we have to 85 | //load them explicitly and add them to the list 86 | contribution.loadFully(redditClient) 87 | commentsTree.addAll(contribution.replies.flatMap { UpdatedCommentNode(it as AbstractCommentNode<*>).walkTree().toList() }) 88 | } 89 | comments.add(comment) 90 | if (contribution.depth > 1){ 91 | commentHierarchy.put(comment, contribution.parent.subject as Comment) 92 | } 93 | } 94 | } 95 | 96 | val full = FullComments(comments, commentHierarchy.mapKeys { it.key.id }.mapValues { it.value.id }, sticky) 97 | channel.send(full) 98 | } 99 | } 100 | 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/main/kotlin/de/rtrx/a/flow/exampleflow.yml: -------------------------------------------------------------------------------- 1 | flow: 2 | init: 3 | !!newPost 4 | returns: 5 | post: newPost 6 | - step: 7 | !!messageWithReply 8 | messageBody: "Please answer, you submitted %{newPost.url}" 9 | messageSubject: "Important Message" 10 | returns: 11 | reply: explanation 12 | - step: 13 | !!postComment 14 | trigger: 15 | !!varFullfilled 16 | toFullfill: explanation 17 | commentBody: "This is the explanation %{explanation}" 18 | returns: 19 | comment: unexComment 20 | - step: &DB 21 | trigger: time 22 | wait: 0 23 | action: DB 24 | botComment: unexComment 25 | - step: &check 26 | trigger: time 27 | wait: 15 28 | unit: minutes 29 | action: check 30 | checks: 31 | - var: commentScore 32 | maxValue: -10 33 | with: 34 | comment: unexComment 35 | - var: scorePost 36 | minValue: 100 37 | do: 38 | - action: increaseVar 39 | var: deletionCounter 40 | - step: &delete 41 | trigger: time 42 | wait: 0 43 | action: check 44 | checks: 45 | - var: deletionCounter 46 | minValue: 3 47 | do: 48 | - action: removePost 49 | 50 | - step: *DB 51 | - 52 | - step: *check 53 | - steop: *delete 54 | - step: *DB 55 | - 56 | - step: *check 57 | - steop: *delete 58 | - step: *DB 59 | 60 | - step: *check 61 | - steop: *delete 62 | - step: *DB 63 | 64 | - step: *check 65 | - steop: *delete 66 | - step: *DB 67 | 68 | 69 | -------------------------------------------------------------------------------- /src/main/kotlin/de/rtrx/a/jrawExtension/RotatingSearchList.kt: -------------------------------------------------------------------------------- 1 | package de.rtrx.a.jrawExtension 2 | 3 | //This class is stolen from jraw itself, so that i can use it in the suspendable stream. 4 | internal class RotatingSearchList(val capacity: Int) { 5 | // All are internal for testing purposes only 6 | internal val backingArray: Array = arrayOfNulls(capacity) 7 | internal var currentIndex = 0 8 | private var _size = 0 9 | 10 | /** The amount of elements currently being stored */ 11 | val size: Int 12 | get() = _size 13 | 14 | /** 15 | * Adds some data. Returns whatever data was overwritten by this call. 16 | */ 17 | fun add(data: T): T? { 18 | @Suppress("UNCHECKED_CAST") 19 | val overwrittenData: T? = backingArray[currentIndex] as T? 20 | 21 | backingArray[currentIndex] = data 22 | 23 | if (++currentIndex >= backingArray.size) 24 | currentIndex = 0 25 | 26 | // If we haven't overwritten anything, then we've added new data 27 | if (overwrittenData == null) 28 | _size++ 29 | 30 | return overwrittenData 31 | } 32 | 33 | /** 34 | * checks if some data is currently being stored. This function assumes the data is likely to have been inserted 35 | * recently 36 | */ 37 | fun contains(data: T): Boolean { 38 | // Start at currentIndex because in our case it's more likely that the data we're looking for (if it's in here) 39 | // is going to be added more recently 40 | for (i in 0 until size) { 41 | // We have to add backingArray.size here because Java does not do the mod operation properly (at least 42 | // according to the mathematical definition of mod). In Python: -1 % 5 --> 4. In Java: -1 % 5 --> -1. We 43 | // want the Python result, and to ensure that, we have to add backingArray.size (5 in the previous example). 44 | // (-1 + 5) % 5 --> 4 in both languages. 45 | val index = (currentIndex - 1 - i + backingArray.size) % backingArray.size 46 | if (backingArray[index] == data) { 47 | return true 48 | } 49 | } 50 | 51 | return false 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/main/kotlin/de/rtrx/a/jrawExtension/SuspendableStream.kt: -------------------------------------------------------------------------------- 1 | package de.rtrx.a.jrawExtension 2 | 3 | import de.rtrx.a.RedditSpec 4 | import kotlinx.coroutines.* 5 | import kotlinx.coroutines.channels.Channel 6 | import kotlinx.coroutines.channels.ReceiveChannel 7 | import mu.KotlinLogging 8 | import net.dean.jraw.models.Created 9 | import net.dean.jraw.models.UniquelyIdentifiable 10 | import net.dean.jraw.pagination.BackoffStrategy 11 | import net.dean.jraw.pagination.ConstantBackoffStrategy 12 | import net.dean.jraw.pagination.Paginator 13 | import net.dean.jraw.pagination.RedditIterable 14 | import java.net.SocketTimeoutException 15 | import java.time.Instant 16 | 17 | class SuspendableStream @JvmOverloads constructor( 18 | private val dataSource: RedditIterable, 19 | private val backoff: BackoffStrategy = ConstantBackoffStrategy(), 20 | historySize: Int = 200, 21 | val ageLimit: Long 22 | ) : Iterator where T: UniquelyIdentifiable, T: Created { 23 | 24 | private val logger = KotlinLogging.logger { } 25 | /** Keeps track of the uniqueIds we've seen recently */ 26 | private val history = RotatingSearchList(historySize) 27 | private var currentIterator: Iterator? = null 28 | private var resumeTimeMillis = -1L 29 | 30 | override fun hasNext(): Boolean = currentIterator != null && currentIterator?.hasNext() ?: false 31 | 32 | override fun next(): T? { 33 | val it = currentIterator 34 | return if (it != null && it.hasNext()) { 35 | it.next() 36 | } else null 37 | } 38 | 39 | suspend fun waitForNext(): T { 40 | val cnt = next() 41 | if(cnt != null) return cnt 42 | val new = requestNew() 43 | currentIterator = new 44 | return new.next() 45 | } 46 | 47 | private suspend fun requestNew(): Iterator { 48 | var newDataIterator: Iterator? = null 49 | 50 | while (newDataIterator == null) { 51 | // Make sure to honor the backoff strategy 52 | if (resumeTimeMillis > System.currentTimeMillis()) { 53 | delay(resumeTimeMillis - System.currentTimeMillis()) 54 | } 55 | 56 | dataSource.restart() 57 | 58 | val old = mutableListOf() 59 | val new = mutableListOf() 60 | do { 61 | lateinit var oldCnt: List 62 | try { 63 | oldCnt = dataSource.next().partition { history.contains(it.uniqueId) || Instant.now().toEpochMilli() - ageLimit > it.created.time }.second 64 | oldCnt.forEach { history.add(it.uniqueId) } 65 | old.addAll(oldCnt) 66 | } catch (ex: SocketTimeoutException) { 67 | logger.error { ex.message } 68 | break; 69 | } 70 | } while (oldCnt.isNotEmpty()) 71 | 72 | // Calculate at which time to poll for new data 73 | val backoffMillis = backoff.delayRequest(old.size, new.size + old.size) 74 | require(backoffMillis >= 0) { "delayRequest must return a non-negative integer, was $backoffMillis" } 75 | resumeTimeMillis = System.currentTimeMillis() + backoff.delayRequest(old.size, new.size + old.size) 76 | 77 | // Yield in reverse order so that if more than one unseen item is present the older items are yielded first 78 | if (old.isNotEmpty()) 79 | newDataIterator = old.asReversed().iterator() 80 | } 81 | 82 | return newDataIterator 83 | } 84 | } 85 | 86 | fun Paginator.subscribe( 87 | waitIntervall: Long, 88 | coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Default), 89 | ageLimit: Long = 1000 * 60 * 60 * 4, 90 | channelStart: CoroutineStart = CoroutineStart.DEFAULT 91 | ): Pair> where T: UniquelyIdentifiable, T: Created { 92 | val logger = KotlinLogging.logger { } 93 | val channel = Channel(capacity = Channel.UNLIMITED) 94 | val job = coroutineScope.launch(start = channelStart){ 95 | while(isActive) { 96 | try { 97 | val stream = SuspendableStream(this@subscribe, ConstantBackoffStrategy(waitIntervall), ageLimit = ageLimit) 98 | while (isActive) { 99 | channel.send(stream.waitForNext()) 100 | } 101 | } catch (e: Throwable){ 102 | logger.error { "An exception was raised while fetching items from reddit:\n" + 103 | e.message.toString() 104 | } 105 | } 106 | } 107 | } 108 | 109 | return job to channel 110 | } 111 | 112 | -------------------------------------------------------------------------------- /src/main/kotlin/de/rtrx/a/jrawExtension/UpdatedCommentNode.kt: -------------------------------------------------------------------------------- 1 | package de.rtrx.a.jrawExtension 2 | 3 | import net.dean.jraw.RedditClient 4 | import net.dean.jraw.models.Comment 5 | import net.dean.jraw.models.MoreChildren 6 | import net.dean.jraw.models.PublicContribution 7 | import net.dean.jraw.models.Submission 8 | import net.dean.jraw.references.PublicContributionReference 9 | import net.dean.jraw.tree.* 10 | import java.util.ArrayDeque 11 | 12 | 13 | class UpdatedCommentNode > constructor(private val delegated: AbstractCommentNode): AbstractCommentNode(delegated.depth, delegated.moreChildren, delegated.subject, delegated.settings) { 14 | override fun walkTree(order: TreeTraversalOrder): Sequence>> { 15 | return UpdatedTreeTraverser.traverse(this, order) 16 | } 17 | 18 | override val parent: CommentNode<*> 19 | get() = delegated.parent 20 | override val depth: Int 21 | get() = delegated.depth 22 | override var moreChildren: MoreChildren? 23 | get() = delegated.moreChildren 24 | set(value) {delegated.moreChildren = value} 25 | override val replies: MutableList> 26 | get() = delegated.replies 27 | override val settings: CommentTreeSettings 28 | get() = delegated.settings 29 | override val subject: T 30 | get() = delegated.subject 31 | 32 | override fun hasMoreChildren(): Boolean { 33 | return delegated.hasMoreChildren() 34 | } 35 | 36 | override fun loadMore(reddit: RedditClient): FakeRootCommentNode { 37 | return delegated.loadMore(reddit) 38 | } 39 | 40 | override fun replaceMore(reddit: RedditClient): List { 41 | return delegated.replaceMore(reddit) 42 | } 43 | 44 | override fun toString(): String { 45 | return delegated.toString() 46 | } 47 | 48 | override fun totalSize(): Int { 49 | return walkTree().count() - 1 50 | } 51 | 52 | override fun loadFully(reddit: RedditClient, depthLimit: Int, requestLimit: Int) { 53 | var requests = 0 54 | if (depthLimit < CommentNode.NO_LIMIT || requestLimit < CommentNode.NO_LIMIT) 55 | throw IllegalArgumentException("Expecting a number greater than or equal to -1, got " + if (requestLimit < CommentNode.NO_LIMIT) requestLimit else depthLimit) 56 | // Load this node's comments first 57 | while (hasMoreChildren()) { 58 | replaceMore(reddit) 59 | if (++requests > requestLimit && depthLimit != CommentNode.NO_LIMIT) 60 | return 61 | } 62 | 63 | // Load the children's comments next 64 | for (node in walkTree(TreeTraversalOrder.BREADTH_FIRST)) { 65 | // Travel breadth first so we can accurately compare depths 66 | if (depthLimit != CommentNode.NO_LIMIT && node.depth > depthLimit) 67 | return 68 | while (node.hasMoreChildren()) { 69 | node.replaceMore(reddit) 70 | if (++requests > requestLimit && depthLimit != CommentNode.NO_LIMIT) 71 | return 72 | } 73 | } 74 | } 75 | } 76 | 77 | object UpdatedTreeTraverser { 78 | fun traverse(root: CommentNode<*>, order: TreeTraversalOrder): Sequence> { 79 | return when (order) { 80 | TreeTraversalOrder.PRE_ORDER -> preOrder(root) 81 | TreeTraversalOrder.POST_ORDER -> postOrder(root) 82 | TreeTraversalOrder.BREADTH_FIRST -> breadthFirst(root) 83 | } 84 | } 85 | 86 | private fun preOrder(base: CommentNode<*>): Sequence> = sequence { 87 | val stack = ArrayDeque>() 88 | stack.add(base) 89 | 90 | var root: CommentNode<*> 91 | while (!stack.isEmpty()) { 92 | root = stack.pop() 93 | yield(root) 94 | 95 | if (root.replies.isNotEmpty()) { 96 | for (i in root.replies.size - 1 downTo 0) { 97 | stack.push(root.replies[i]) 98 | } 99 | } 100 | } 101 | } 102 | 103 | private fun postOrder(base: CommentNode<*>): Sequence> = sequence { 104 | // Post-order traversal isn't going to be as fast as the other methods, this traversal method discovers elements 105 | // in reverse order and sorts them using a stack. Instead of finding the next node and yielding it, we find the 106 | // entire sequence and yield all elements right then and there 107 | val unvisited = ArrayDeque>() 108 | val visited = ArrayDeque>() 109 | unvisited.add(base) 110 | var root: CommentNode<*> 111 | 112 | while (unvisited.isNotEmpty()) { 113 | root = unvisited.pop() 114 | visited.push(root) 115 | 116 | if (root.replies.isNotEmpty()) { 117 | for (reply in root.replies) { 118 | unvisited.push(reply) 119 | } 120 | } 121 | } 122 | 123 | yieldAll(visited) 124 | } 125 | 126 | private fun breadthFirst(base: CommentNode<*>): Sequence> = sequence { 127 | val queue = ArrayDeque>() 128 | var node: CommentNode<*> 129 | 130 | queue.add(base) 131 | 132 | while (queue.isNotEmpty()) { 133 | node = queue.remove() 134 | yield(node) 135 | 136 | queue += node.replies 137 | } 138 | } 139 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/rtrx/a/monitor/Check.kt: -------------------------------------------------------------------------------- 1 | package de.rtrx.a.monitor 2 | 3 | import net.dean.jraw.models.Comment 4 | import net.dean.jraw.references.CommentReference 5 | import net.dean.jraw.references.SubmissionReference 6 | import javax.inject.Provider 7 | 8 | interface Monitor{ 9 | suspend fun start() 10 | } 11 | 12 | interface MonitorBuilder { 13 | fun build(submission: SubmissionReference): M 14 | fun setBotComment(comment: Comment?): MonitorBuilder 15 | } 16 | 17 | abstract class Check(val submission: SubmissionReference, val botComment: Comment?) : Monitor{ 18 | private var _checksPerformed = 0; 19 | val checksPerformed: Int get() = _checksPerformed 20 | protected fun increaseCheckCounter() = _checksPerformed++ 21 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/rtrx/a/monitor/DBCheck.kt: -------------------------------------------------------------------------------- 1 | package de.rtrx.a.monitor 2 | 3 | import com.uchuhimo.konf.Config 4 | import de.rtrx.a.RedditSpec 5 | import de.rtrx.a.database.Linkage 6 | import de.rtrx.a.database.ObservationLinkage 7 | import de.rtrx.a.flow.events.comments.FullComments 8 | import de.rtrx.a.flow.events.comments.ManuallyFetchedEvent 9 | import kotlinx.coroutines.delay 10 | import mu.KotlinLogging 11 | import net.dean.jraw.RedditClient 12 | import net.dean.jraw.models.Comment 13 | import net.dean.jraw.references.SubmissionReference 14 | import javax.inject.Inject 15 | import javax.inject.Provider 16 | 17 | private val logger = KotlinLogging.logger { } 18 | 19 | interface IDBCheck: Monitor{ 20 | suspend fun saveToDB(fullComments: FullComments) 21 | } 22 | 23 | class DBCheck( 24 | submission: SubmissionReference, 25 | botComment: Comment?, 26 | private val config: Config, 27 | private val linkage: ObservationLinkage, 28 | private val redditClient: RedditClient, 29 | private val commentEvent: ManuallyFetchedEvent 30 | ) : Check(submission, botComment), IDBCheck { 31 | 32 | override suspend fun start() { 33 | for (i in 1..config[RedditSpec.checks.DB.forTimes]) { 34 | try { 35 | delay(config[RedditSpec.checks.DB.every]) 36 | commentEvent.fetchComments() 37 | increaseCheckCounter() 38 | } catch (e: Throwable) { 39 | logger.error { 40 | "An Exception was raised while monitoring a submission with id ${submission.id}:\n" + 41 | e.message.toString() 42 | } 43 | } 44 | } 45 | } 46 | 47 | override suspend fun saveToDB(fullComments: FullComments){ 48 | try { 49 | val (comments, hierarchy, sticky) = fullComments 50 | 51 | //Create the check on the DB 52 | logger.trace { "Creating ${checksPerformed + 1} Check for Submission with id ${submission.id}" } 53 | val (_, createdComments) = linkage.createCheck(submission.fullName, botComment, sticky, comments.toTypedArray()) 54 | 55 | //Add the parents to the db for comments that were newly created 56 | logger.trace { "Added the following comments to the db:\n" + createdComments.map { it.fullName }.joinToString(", ") } 57 | createdComments.forEach { 58 | val parent = hierarchy[it.id] 59 | if(parent != null) linkage.add_parent(it.id, parent) 60 | } 61 | } catch (e: Throwable){ 62 | logger.error { 63 | "An Exception was raised while saving comments from submission with id ${submission.id}:\n" + 64 | e.message.toString() 65 | } 66 | } 67 | } 68 | } 69 | 70 | interface IDBCheckBuilder: MonitorBuilder { 71 | fun setCommentEvent(manuallyFetchedEvent: ManuallyFetchedEvent): IDBCheckBuilder 72 | } 73 | class DBCheckBuilder @Inject constructor( 74 | private val config: Config, 75 | private val linkage: ObservationLinkage, 76 | private val redditClient: RedditClient 77 | ): IDBCheckBuilder { 78 | private var botComment: Comment? = null 79 | lateinit var commentEvent: ManuallyFetchedEvent 80 | 81 | override fun setCommentEvent(manuallyFetchedEvent: ManuallyFetchedEvent): IDBCheckBuilder { 82 | this.commentEvent = manuallyFetchedEvent 83 | return this 84 | } 85 | 86 | override fun build(submission: SubmissionReference) = DBCheck(submission, botComment, config, linkage, redditClient, commentEvent) 87 | 88 | override fun setBotComment(botComment: Comment?): MonitorBuilder { 89 | this.botComment = botComment 90 | return this 91 | } 92 | 93 | 94 | } 95 | class DBCheckFactory @Inject constructor( 96 | private val config: Config, 97 | private val linkage: ObservationLinkage, 98 | private val redditClient: RedditClient 99 | ): Provider { 100 | override fun get(): IDBCheckBuilder { 101 | return DBCheckBuilder(config, linkage, redditClient) 102 | } 103 | 104 | } 105 | 106 | -------------------------------------------------------------------------------- /src/main/kotlin/de/rtrx/a/unex/UnexFlow.kt: -------------------------------------------------------------------------------- 1 | package de.rtrx.a.unex 2 | 3 | import com.uchuhimo.konf.Config 4 | import de.rtrx.a.RedditSpec 5 | import de.rtrx.a.database.ConversationLinkage 6 | import de.rtrx.a.database.Linkage 7 | import de.rtrx.a.database.ObservationLinkage 8 | import de.rtrx.a.flow.* 9 | import de.rtrx.a.flow.events.* 10 | import de.rtrx.a.flow.events.comments.FullComments 11 | import de.rtrx.a.flow.events.comments.ManuallyFetchedEvent 12 | import de.rtrx.a.getCompletedOrNull 13 | import de.rtrx.a.jrawExtension.UpdatedCommentNode 14 | import de.rtrx.a.monitor.* 15 | import kotlinx.coroutines.* 16 | import kotlinx.coroutines.channels.ReceiveChannel 17 | import mu.KotlinLogging 18 | import net.dean.jraw.RedditClient 19 | import net.dean.jraw.models.Comment 20 | import net.dean.jraw.models.DistinguishedStatus 21 | import net.dean.jraw.models.Message 22 | import net.dean.jraw.references.CommentReference 23 | import net.dean.jraw.references.SubmissionReference 24 | import java.util.* 25 | import javax.inject.Inject 26 | import javax.inject.Named 27 | import javax.inject.Provider 28 | private val logger = KotlinLogging.logger { } 29 | /** 30 | * @param composingFn Function that sends the message to the user. First Argument is the recipient, second one the url to the post 31 | */ 32 | class UnexFlow( 33 | private val flowStub: IFlowStub, 34 | private val callback: Callback, Unit>, 35 | private val composingFn: MessageComposer, 36 | private val replyFn: Replyer, 37 | private val sentMessages: SentMessageEvent, 38 | private val incomingMessages: IncomingMessagesEvent, 39 | private val commentsFetchedEvent: ManuallyFetchedEvent, 40 | private val conversationLinkage: ConversationLinkage, 41 | private val observationLinkage: ObservationLinkage, 42 | private val monitorBuilder: IDBCheckBuilder, 43 | private val conversation: JumpstartConversation, 44 | private val delayedDeleteFactory: DelayedDeleteFactory 45 | ) : IFlowStub by flowStub, 46 | RelaunchableFlow{ 47 | 48 | private val started: CompletableDeferred = CompletableDeferred() 49 | private var ownMessageID: String? = null 50 | private val defferedComment: CompletableDeferred = CompletableDeferred() 51 | private val defferedCommentRef: CompletableDeferred = CompletableDeferred() 52 | private var foundComment: Comment? = null 53 | val incompletableDefferedComment: Deferred get() = defferedComment 54 | val comment: Comment? 55 | get() { 56 | if (foundComment != null) return foundComment 57 | else return defferedComment.getCompletedOrNull() 58 | } 59 | 60 | lateinit var monitor: IDBCheck 61 | 62 | /** 63 | * Recreate Flow which was cancelled when already at monitoring stage 64 | */ 65 | constructor( 66 | flowStub: IFlowStub, 67 | callback: Callback, Unit>, 68 | composingFn: MessageComposer, 69 | replyFn: Replyer, 70 | sentMessages: SentMessageEvent, 71 | incomingMessages: IncomingMessagesEvent, 72 | commentsFetchedEvent: ManuallyFetchedEvent, 73 | conversationLinkage: ConversationLinkage, 74 | observationLinkage: ObservationLinkage, 75 | monitorBuilder: IDBCheckBuilder, 76 | conversation: JumpstartConversation, 77 | delayedDeleteFactory: DelayedDeleteFactory, 78 | commentRef: CommentReference, 79 | comment: Comment? 80 | ): this( 81 | flowStub, 82 | callback, 83 | composingFn, 84 | replyFn, 85 | sentMessages, 86 | incomingMessages, 87 | commentsFetchedEvent, 88 | conversationLinkage, 89 | observationLinkage, 90 | monitorBuilder, 91 | conversation, 92 | delayedDeleteFactory, 93 | ){ 94 | defferedCommentRef.complete(commentRef) 95 | this.foundComment = comment 96 | } 97 | 98 | /** 99 | * 100 | */ 101 | constructor( 102 | flowStub: IFlowStub, 103 | callback: Callback, Unit>, 104 | composingFn: MessageComposer, 105 | replyFn: Replyer, 106 | sentMessages: SentMessageEvent, 107 | incomingMessages: IncomingMessagesEvent, 108 | commentsFetchedEvent: ManuallyFetchedEvent, 109 | conversationLinkage: ConversationLinkage, 110 | observationLinkage: ObservationLinkage, 111 | monitorBuilder: IDBCheckBuilder, 112 | conversation: JumpstartConversation, 113 | delayedDeleteFactory: DelayedDeleteFactory, 114 | ownMessageID: String 115 | ): this( 116 | flowStub, 117 | callback, 118 | composingFn, 119 | replyFn, 120 | sentMessages, 121 | incomingMessages, 122 | commentsFetchedEvent, 123 | conversationLinkage, 124 | observationLinkage, 125 | monitorBuilder, 126 | conversation, 127 | delayedDeleteFactory, 128 | ){ 129 | this.ownMessageID = ownMessageID 130 | } 131 | 132 | suspend fun checkSubmission(): Boolean { 133 | logger.trace("Starting flow for ${initValue.fullName}") 134 | if (observationLinkage.insertSubmission(initValue.inspect()) == 0) { 135 | logger.trace("Cancelling flow for ${initValue.fullName} because the submission is already present") 136 | callback(SubmissionAlreadyPresent(this)) 137 | return false 138 | } else return true 139 | } 140 | 141 | suspend fun startConversation(): Deferred { 142 | val awaitedReply = async { conversation.run { waitForCompletion(produceCheckMessage(initValue.id)) } } 143 | 144 | return awaitedReply 145 | } 146 | 147 | suspend fun waitForAnswer(awaitedReply: Deferred, skip: Long, ownMessageID: String? = null): Pair { 148 | val subscriptions = if (ownMessageID == null) listOf( Subscription.create(conversation::start, sentMessages), Subscription.create(conversation::reply, incomingMessages) ) 149 | else listOf(Subscription.create(conversation::reply, incomingMessages)) 150 | 151 | var result: Pair? = null 152 | withSubscriptions(subscriptions) { 153 | if (ownMessageID == null) composingFn(initValue.inspect().author, initValue.inspect().permalink) 154 | else conversation.jumpstart(ownMessageID) 155 | 156 | val deletion = delayedDeleteFactory.create(initValue, this, skip) 157 | deletion.start() 158 | val answered = deletion.safeSelectTo(awaitedReply.onAwait) 159 | 160 | if (!answered.bool) { 161 | callback(NoAnswerReceived(this@UnexFlow)) 162 | logger.info { "No answer received for flow for ${initValue.fullName}" } 163 | result = null to answered 164 | } 165 | result = awaitedReply.getCompletedOrNull() to answered 166 | } 167 | return result!! 168 | 169 | } 170 | 171 | suspend fun replyComment(reply: Message){ 172 | val (comment, ref) = replyFn(initValue.inspect(), reply.body) 173 | defferedComment.complete(comment) 174 | defferedCommentRef.complete(ref) 175 | ref.distinguish(DistinguishedStatus.MODERATOR, true) 176 | conversationLinkage.saveCommentMessage(initValue.id, reply, comment) 177 | } 178 | 179 | suspend fun monitor(){ 180 | monitor = monitorBuilder.setCommentEvent(commentsFetchedEvent).setBotComment(comment).build(initValue) 181 | logger.trace("Starting Monitor for ${initValue.fullName}") 182 | withSubscription(Subscription.create(monitor::saveToDB, commentsFetchedEvent)) { 183 | monitor.start() 184 | } 185 | } 186 | 187 | override suspend fun relaunch() { 188 | val skip = System.currentTimeMillis() - initValue.inspect().created.time 189 | try { 190 | if (!started.complete(Unit) || checkSubmission()) { 191 | callback(FlowResult.FailedEnd.LogicFailed(this)) 192 | return 193 | } 194 | if (!defferedCommentRef.isCompleted){ 195 | val awaitedReply = startConversation() 196 | val awaited = waitForAnswer(awaitedReply, skip, ownMessageID) 197 | if(awaited.first != null) replyComment(awaited.first!!) 198 | else if (!awaited.second.bool) return 199 | } 200 | monitor() 201 | callback(FlowResult.NotFailedEnd.RegularEnd(this@UnexFlow)) 202 | } catch (c: CancellationException){ 203 | callback(FlowResult.FailedEnd.Cancelled(this@UnexFlow)) 204 | logger.warn("Flow for submission ${initValue.fullName} was cancelled") 205 | } 206 | } 207 | 208 | override suspend fun start() { 209 | try { 210 | if (!started.complete(Unit)) { 211 | callback(FlowResult.FailedEnd.LogicFailed(this)) 212 | return 213 | } 214 | if(!checkSubmission()) return 215 | val awaitedReply = startConversation() 216 | val awaited = waitForAnswer(awaitedReply, 0) 217 | if(awaited.first != null) replyComment(awaited.first!!) 218 | else if (!awaited.second.bool) return 219 | monitor() 220 | callback(FlowResult.NotFailedEnd.RegularEnd(this@UnexFlow)) 221 | } catch (c: CancellationException){ 222 | callback(FlowResult.FailedEnd.Cancelled(this@UnexFlow)) 223 | logger.warn("Flow for submission ${initValue.fullName} was cancelled") 224 | } 225 | } 226 | 227 | fun addCallback(action: (FlowResult) -> Unit){ 228 | callback.addAction { action(it as FlowResult) } 229 | } 230 | 231 | companion object{ 232 | val logger = KotlinLogging.logger { } 233 | } 234 | } 235 | 236 | 237 | interface UnexFlowFactory : RelaunchableFlowFactory>{ 238 | fun setSentMessages(sentMessages: SentMessageEvent) 239 | fun setIncomingMessages(incomingMessages: IncomingMessagesEvent) 240 | } 241 | 242 | class RedditUnexFlowFactory @Inject constructor( 243 | @param:Named("delayToDeleteMillis") private val delayToDeleteMillis: Long, 244 | @param:Named("delayToFinishMillis") private val delayToFinishMillis: Long, 245 | private val composingFn: MessageComposer, 246 | private val replyFn: Replyer, 247 | private val monitorFactory: Provider, 248 | private val conversationLinkage: ConversationLinkage, 249 | private val observationLinkage: ObservationLinkage, 250 | private val linkage: Linkage, 251 | private val conversationFactory: Provider>, 252 | private val delayedDeleteFactory: DelayedDeleteFactory, 253 | private val multiplexerProvider: Provider>>, 254 | private val config: Config, 255 | private val redditClient: RedditClient, 256 | ) : UnexFlowFactory { 257 | private lateinit var _sentMessages: SentMessageEvent 258 | private lateinit var _incomingMessages: IncomingMessagesEvent 259 | private val unregisterScope = CoroutineScope(Dispatchers.Default) 260 | 261 | private suspend fun provideStub( 262 | dispatcher: FlowDispatcherInterface, 263 | initValue: SubmissionReference, 264 | callback: Callback, Unit> 265 | ): FlowStub { 266 | val stub = FlowStub( 267 | initValue, 268 | { unexFlow: UnexFlow, fn: suspend (Any) -> Unit, type: EventType -> 269 | dispatcher.subscribe(unexFlow, fn, type) 270 | }, 271 | dispatcher::unsubscribe, 272 | CoroutineScope(Dispatchers.Default) 273 | ) 274 | callback.addAction { unregisterScope.launch { dispatcher.unregisterEvent(ManuallyFetchedEvent::class, initValue) }} 275 | return stub 276 | } 277 | override suspend fun create( 278 | dispatcher: FlowDispatcherInterface, 279 | initValue: SubmissionReference, 280 | callback: Callback, Unit> 281 | ): UnexFlow { 282 | val stub = provideStub(dispatcher, initValue, callback) 283 | val flow = UnexFlow( 284 | stub, 285 | callback, 286 | composingFn, 287 | replyFn, 288 | _sentMessages, 289 | _incomingMessages, 290 | dispatcher.createNewEvent(ManuallyFetchedEvent::class, initValue ,multiplexerProvider.get()) , 291 | conversationLinkage, 292 | observationLinkage, 293 | monitorFactory.get(), 294 | conversationFactory.get(), 295 | delayedDeleteFactory 296 | ) 297 | stub.setOuter(flow) 298 | return flow 299 | } 300 | 301 | override fun setSentMessages(sentMessages: SentMessageEvent) { 302 | if (!this::_sentMessages.isInitialized) this._sentMessages = sentMessages 303 | } 304 | 305 | override fun setIncomingMessages(incomingMessages: IncomingMessagesEvent) { 306 | if (!this::_incomingMessages.isInitialized) this._incomingMessages = incomingMessages 307 | } 308 | 309 | override suspend fun recreateFlows(dispatcher: FlowDispatcherInterface, callbackProvider: Provider, Unit>>, additionalData: Collection): Collection { 310 | val flows = mutableListOf() 311 | 312 | val unansweredPst = linkage.connection.prepareStatement( 313 | """SELECT submissions.id, title, submissions.author_id, created FROM submissions LEFT JOIN relevant_messages rm on submissions.id = rm.submission_id 314 | WHERE rm.id IS NULL AND submissions.created >= now() - ? * INTERVAL '1 MILLISECONDS'""" 315 | ) 316 | 317 | unansweredPst.setLong(1, delayToDeleteMillis) 318 | unansweredPst.execute() 319 | val unansweredResultSet = unansweredPst.resultSet 320 | while(unansweredResultSet.next()){ 321 | val id = unansweredResultSet.getString(1) 322 | val title = unansweredResultSet.getString(2) 323 | val author = unansweredResultSet.getString(3) 324 | val created = unansweredResultSet.getTimestamp(4) 325 | 326 | val callback = callbackProvider.get() 327 | val initValue = redditClient.submission(id) 328 | val stub = provideStub(dispatcher, initValue , callback) 329 | 330 | val ownMessage = additionalData.find { produceCheckMessage(id)(it) } 331 | 332 | 333 | val flow = if (ownMessage == null) { 334 | UnexFlow( 335 | stub, 336 | callback, 337 | composingFn, 338 | replyFn, 339 | _sentMessages, 340 | _incomingMessages, 341 | dispatcher.createNewEvent(ManuallyFetchedEvent::class, initValue ,multiplexerProvider.get()) , 342 | conversationLinkage, 343 | observationLinkage, 344 | monitorFactory.get(), 345 | conversationFactory.get(), 346 | delayedDeleteFactory 347 | ) 348 | } else { 349 | UnexFlow( 350 | stub, 351 | callback, 352 | composingFn, 353 | replyFn, 354 | _sentMessages, 355 | _incomingMessages, 356 | dispatcher.createNewEvent(ManuallyFetchedEvent::class, initValue, multiplexerProvider.get()), 357 | conversationLinkage, 358 | observationLinkage, 359 | monitorFactory.get(), 360 | conversationFactory.get(), 361 | delayedDeleteFactory, 362 | ownMessage.fullName) 363 | } 364 | stub.setOuter(flow) 365 | flows.add(flow) 366 | 367 | } 368 | val pst = linkage.connection.prepareStatement( 369 | """SELECT submissions.id, title, submissions.author_id, created, cc.comment_id 370 | FROM submissions JOIN relevant_messages rm on submissions.id = rm.submission_id JOIN comments_caused cc on rm.id = cc.message_id 371 | WHERE submissions.created >= now() - ? * INTERVAL '1 MILLISECONDS' 372 | ORDER BY submissions.created""") 373 | pst.setLong(1, config[RedditSpec.checks.DB.forTimes] * config[RedditSpec.checks.DB.every]) 374 | pst.execute() 375 | val resultSet = pst.resultSet 376 | 377 | while(resultSet.next()){ 378 | val id = resultSet.getString(1) 379 | val title = resultSet.getString(2) 380 | val author = resultSet.getString(3) 381 | val created = resultSet.getTimestamp(4) 382 | val commentID = resultSet.getString(5) 383 | 384 | val callback = callbackProvider.get() 385 | val initValue = redditClient.submission(id) 386 | val stub = provideStub(dispatcher, initValue , callback) 387 | 388 | //retrieve the comment 389 | val commentRef = redditClient.comment(commentID) 390 | val comments = UpdatedCommentNode(initValue.comments()).walkTree().toCollection(LinkedList()) 391 | val comment = comments.find { if(it.subject is Comment) it.subject.isStickied else false }?.subject as Comment? 392 | 393 | val flow = UnexFlow( 394 | stub, 395 | callback, 396 | composingFn, 397 | replyFn, 398 | _sentMessages, 399 | _incomingMessages, 400 | dispatcher.createNewEvent(ManuallyFetchedEvent::class, initValue, multiplexerProvider.get()), 401 | conversationLinkage, 402 | observationLinkage, 403 | monitorFactory.get(), 404 | conversationFactory.get(), 405 | delayedDeleteFactory, 406 | commentRef, 407 | comment 408 | ) 409 | stub.setOuter(flow) 410 | flows.add(flow) 411 | } 412 | 413 | return flows 414 | } 415 | 416 | } 417 | -------------------------------------------------------------------------------- /src/main/kotlin/de/rtrx/a/unex/UnexFlowDispatcher.kt: -------------------------------------------------------------------------------- 1 | package de.rtrx.a.unex 2 | 3 | import de.rtrx.a.database.Booleable 4 | import de.rtrx.a.flow.ArchivingFlow 5 | import de.rtrx.a.flow.IFlowDispatcherStub 6 | import de.rtrx.a.flow.IsolationStrategy 7 | import de.rtrx.a.flow.MarkAsReadFlow 8 | import de.rtrx.a.flow.events.* 9 | import kotlinx.coroutines.channels.ReceiveChannel 10 | import kotlinx.coroutines.joinAll 11 | import kotlinx.coroutines.launch 12 | import kotlinx.coroutines.runBlocking 13 | import mu.KotlinLogging 14 | import net.dean.jraw.models.Message 15 | import javax.inject.Inject 16 | import javax.inject.Named 17 | 18 | 19 | class UnexFlowDispatcher @Inject constructor( 20 | private val stub: IFlowDispatcherStub, 21 | incomingMessageMultiplexerBuilder: @JvmSuppressWildcards EventMultiplexerBuilder, @JvmSuppressWildcards ReceiveChannel>, 22 | sentMessageMultiplexerBuilder: @JvmSuppressWildcards EventMultiplexerBuilder, @JvmSuppressWildcards ReceiveChannel>, 23 | incomingMessageFactory: IncomingMessageFactory, 24 | sentMessageFactory: SentMessageFactory, 25 | isolationStrategy: IsolationStrategy, 26 | markAsReadFlow: MarkAsReadFlow, 27 | archivingFlow: ArchivingFlow, 28 | @Named("restart") restart: Boolean, 29 | ) : IFlowDispatcherStub by stub{ 30 | 31 | private val incomingMessageMultiplexer: EventMultiplexer 32 | private val sentMessageMultiplexer: EventMultiplexer 33 | 34 | private val incomingMessagesEvent: IncomingMessagesEvent 35 | private val sentMessageEvent: SentMessageEvent 36 | 37 | 38 | init { 39 | val (incomingEvent, incomingChannel) = incomingMessageFactory.create("") 40 | val (sentEvent, sentChannel) = sentMessageFactory.create("") 41 | this.incomingMessagesEvent = incomingEvent 42 | this.sentMessageEvent = sentEvent 43 | 44 | stub.flowFactory.setIncomingMessages(incomingMessagesEvent) 45 | stub.flowFactory.setSentMessages(sentMessageEvent) 46 | 47 | incomingMessageMultiplexer = incomingMessageMultiplexerBuilder 48 | .setOrigin(incomingChannel) 49 | .setIsolationStrategy(isolationStrategy) 50 | .build() 51 | sentMessageMultiplexer = sentMessageMultiplexerBuilder 52 | .setOrigin(sentChannel) 53 | .setIsolationStrategy(isolationStrategy) 54 | .build() 55 | 56 | runBlocking { 57 | stub.registerMultiplexer(incomingMessagesEvent, incomingMessageMultiplexer) 58 | stub.registerMultiplexer(sentMessageEvent, sentMessageMultiplexer) 59 | } 60 | incomingMessageMultiplexer.addListener(markAsReadFlow, markAsReadFlow::markAsRead) 61 | launcherScope.launch { archivingFlow.start() } 62 | sentMessageMultiplexer.addListener(archivingFlow, archivingFlow::saveMessage) 63 | 64 | if (restart) { 65 | launcherScope.launch { 66 | archivingFlow.finished.await() 67 | sentMessageMultiplexer.removeListeners(archivingFlow) 68 | stub.setupAndStartFlows(UnexFlow::relaunch) { provider -> stub.flowFactory.recreateFlows(stub, provider, archivingFlow.messages) }.joinAll() 69 | } 70 | } 71 | stub.start() 72 | 73 | this.incomingMessagesEvent.start() 74 | this.sentMessageEvent.start() 75 | 76 | KotlinLogging.logger { }.info("Started UnexFlow Dispatcher") 77 | } 78 | 79 | } 80 | 81 | -------------------------------------------------------------------------------- /src/main/kotlin/de/rtrx/a/unex/UnexFlowModule.kt: -------------------------------------------------------------------------------- 1 | package de.rtrx.a.unex 2 | 3 | import com.google.inject.Provides 4 | import com.google.inject.Scopes 5 | import com.google.inject.assistedinject.FactoryModuleBuilder 6 | import com.google.inject.name.Names 7 | import com.uchuhimo.konf.Config 8 | import de.rtrx.a.database.* 9 | import de.rtrx.a.flow.* 10 | import de.rtrx.a.flow.events.comments.CommentsFetcherFactory 11 | import de.rtrx.a.flow.events.comments.ManuallyFetchedEvent 12 | import dev.misfitlabs.kotlinguice4.KotlinModule 13 | import kotlinx.coroutines.CoroutineScope 14 | import kotlinx.coroutines.Dispatchers 15 | import kotlinx.coroutines.channels.ReceiveChannel 16 | import net.dean.jraw.references.SubmissionReference 17 | import javax.inject.Named 18 | 19 | class UnexFlowModule(private val restart: Boolean): KotlinModule() { 20 | 21 | @Provides 22 | fun provideDispatcherStub( 23 | newPosts: ReceiveChannel, 24 | flowFactory: UnexFlowFactory, 25 | @Named("launcherScope") launcherScope: CoroutineScope, 26 | manuallyFetchedFactory: CommentsFetcherFactory 27 | ) : IFlowDispatcherStub = FlowDispatcherStub(newPosts, flowFactory, launcherScope, 28 | mapOf( ManuallyFetchedEvent::class to (manuallyFetchedFactory to SubmissionReference::class) ) as EventFactories ) 29 | 30 | @Provides 31 | @com.google.inject.name.Named ("functions") 32 | fun provideDDLFunctions(config: Config) = with(DDL.Companion.Functions){listOf( 33 | addParentIfNotExists, 34 | commentIfNotExists, 35 | commentWithMessage, 36 | createCheck, 37 | redditUsername 38 | ).map { it(config) }} 39 | 40 | @Provides 41 | @com.google.inject.name.Named("tables") 42 | fun provideDDLTable(config: Config) = with(DDL.Companion.Tables) { listOf( 43 | submissions, 44 | check, 45 | comments, 46 | comments_caused, 47 | commentsHierarchy, 48 | unexScore, 49 | top_posts, 50 | relevantMessages 51 | ).map { it(config) }} 52 | 53 | @Provides 54 | fun provideApprovedCheck(linkage: ObservationLinkage): DeletePrevention = DelayedDelete.approvedCheck(linkage) 55 | 56 | override fun configure() { 57 | install(FactoryModuleBuilder() 58 | .implement(DelayedDelete::class.java, RedditDelayedDelete::class.java) 59 | .build(DelayedDeleteFactory::class.java)) 60 | 61 | bind(Linkage::class.java).to(PostgresSQLinkage::class.java).`in`(Scopes.SINGLETON) 62 | bind(ObservationLinkage::class.java).to(PostgresSQLinkage::class.java).`in`(Scopes.SINGLETON) 63 | bind(ConversationLinkage::class.java).to(PostgresSQLinkage::class.java).`in`(Scopes.SINGLETON) 64 | 65 | bind(Boolean::class.java).annotatedWith(Names.named("restart")).toInstance(restart) 66 | bind(UnexFlowFactory::class.java).to(RedditUnexFlowFactory::class.java) 67 | bind(CoroutineScope::class.java).annotatedWith(Names.named("launcherScope")) 68 | .toInstance(CoroutineScope(Dispatchers.Default)) 69 | bind(UnexFlowDispatcher::class.java) 70 | } 71 | 72 | 73 | } -------------------------------------------------------------------------------- /src/main/resources/DDL.sql: -------------------------------------------------------------------------------- 1 | create table if not exists comments 2 | ( 3 | id text not null 4 | constraint comments_pkey 5 | primary key, 6 | body text not null, 7 | created timestamp with time zone not null, 8 | author_id text not null 9 | ) 10 | ; 11 | 12 | create unique index comments_id_uindex 13 | on comments (id) 14 | ; 15 | 16 | create table if not exists submissions 17 | ( 18 | id text not null 19 | constraint submission_id 20 | primary key, 21 | title text not null, 22 | url text not null, 23 | author_id text not null, 24 | created timestamp with time zone not null 25 | ) 26 | ; 27 | 28 | 29 | create table if not exists "check" 30 | ( 31 | submission_id text not null 32 | constraint submission_constraint 33 | references submissions, 34 | timestamp timestamp with time zone not null, 35 | user_reports json, 36 | dismissed_user_reports json, 37 | is_deleted boolean not null, 38 | submission_score integer, 39 | removed_by text, 40 | flair text, 41 | current_sticky text 42 | constraint comment_stickied 43 | references comments, 44 | constraint depend_on_submission 45 | primary key (submission_id, timestamp) 46 | ) 47 | ; 48 | 49 | 50 | create unique index check_timestamp_uindex 51 | on "check" (timestamp) 52 | ; 53 | 54 | create table if not exists relevant_messages 55 | ( 56 | id text not null 57 | constraint message_pkey 58 | primary key, 59 | submission_id text not null 60 | constraint submission_explained 61 | references submissions, 62 | body text not null, 63 | author_id text not null, 64 | timestamp timestamp with time zone 65 | ) 66 | ; 67 | 68 | 69 | create table if not exists comments_caused 70 | ( 71 | message_id text not null 72 | constraint message_ref 73 | references relevant_messages, 74 | comment_id text not null 75 | constraint comment_ref 76 | references comments, 77 | constraint comment_caused_pk 78 | primary key (comment_id, message_id) 79 | ) 80 | ; 81 | 82 | 83 | create unique index comment_caused_comment_id_uindex 84 | on comments_caused (comment_id) 85 | ; 86 | 87 | create unique index comment_caused_message_id_uindex 88 | on comments_caused (message_id) 89 | ; 90 | 91 | create unique index message_id_uindex 92 | on relevant_messages (id) 93 | ; 94 | 95 | create table if not exists top_posts 96 | ( 97 | submission_id text not null, 98 | timestamp timestamp with time zone not null, 99 | comment_id text not null 100 | constraint comment_referenced 101 | references comments, 102 | score integer not null, 103 | constraint top_posts_pk 104 | primary key (submission_id, timestamp, comment_id), 105 | constraint during_check 106 | foreign key (submission_id, timestamp) references "check" 107 | ) 108 | ; 109 | 110 | create table if not exists unex_score 111 | ( 112 | submission_id text not null, 113 | timestamp timestamp with time zone not null, 114 | score integer, 115 | constraint check_identifier 116 | primary key (submission_id, timestamp), 117 | constraint check_performed 118 | foreign key (submission_id, timestamp) references "check" 119 | ) 120 | ; 121 | 122 | create table comments_hierarchy 123 | ( 124 | child_id text not null 125 | constraint comments_hierarchy_pk 126 | primary key 127 | constraint comment_child 128 | references comments, 129 | parent_id text not null 130 | constraint parent_comment 131 | references comments 132 | ); 133 | 134 | -------------------------------------------------------------------------------- /src/main/resources/config.yml: -------------------------------------------------------------------------------- 1 | reddit: 2 | subreddit: "YourSubreddit" 3 | 4 | credentials: 5 | username: unexBot 6 | clientID: YOURID 7 | clientSecret: YOURSECRED 8 | password: "YOURPASSWORD" 9 | operatorUsername: Artraxaron 10 | appID: "de.rtrx.a.unexbot" 11 | 12 | submissions: 13 | maxTimeDistance: 3600000 14 | limit: 30 15 | waitIntervall: 5000 16 | 17 | messages: 18 | sent: 19 | maxTimeDistance: 7200000 20 | maxWaitForCompletion: 20000 21 | limit: 25 22 | waitIntervall: 5000 23 | subject: Please explain what is unexpected in your submission 24 | body: | 25 | Hi, I've noticed you submitted [this](%{Submission}) Submission to r/%{subreddit}. 26 | 27 | Please reply to this message with a short explanation of what is unexpected in your submission. 28 | Your reply will be posted by me in the comments section of your post. 29 | If you do not reply to this within %{MinutesUntilRemoval} minutes, your post will be deleted. 30 | You have a total of %{HoursUntilDrop} hours time to reply and get your post approved, 31 | but please note that your post won't be visible in the meantime if you didn't reply within the %{MinutesUntilRemoval} minutes. 32 | 33 | ***** 34 | [*Look at my source code on GitHub*](https://github.com/Artraxon/unexBot) 35 | unread: 36 | waitIntervall: 5000 37 | limit: 25 38 | answerMaxCharacters: 500 39 | 40 | scoring: 41 | timeUntilRemoval: 600000 42 | commentBody: | 43 | **OP sent the following text as an explanation on why this is unexpected:** 44 | 45 | >!%{Reason}!< 46 | 47 | ***** 48 | **Is it a good post?** 49 | **Then upvote this comment, otherwise downvote it.** 50 | 51 | ***** 52 | [*Look at my source code on Github*](https://github.com/Artraxon/unexBot) 53 | 54 | 55 | checks: 56 | db: 57 | every: 600000 58 | forTimes: 8 59 | comments_amount: 20 60 | depth: 10 61 | commentWaitIntervall: 10000 62 | 63 | DB: 64 | password: "" 65 | username: unexbot 66 | address: localhost 67 | db: postgres 68 | -------------------------------------------------------------------------------- /src/main/resources/logging.properties: -------------------------------------------------------------------------------- 1 | handlers= java.util.logging.ConsoleHandler 2 | .level = INFO 3 | java.util.logging.ConsoleHandler.level = INFO 4 | java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter 5 | 6 | de.rtrx.a.level = INFO 7 | -------------------------------------------------------------------------------- /src/test/kotlin/de/rtrx/a/flow/FlowTest.kt: -------------------------------------------------------------------------------- 1 | package de.rtrx.a.flow 2 | 3 | import de.rtrx.a.flow.events.* 4 | import kotlinx.coroutines.* 5 | import kotlinx.coroutines.channels.Channel 6 | import kotlinx.coroutines.channels.ReceiveChannel 7 | import kotlinx.coroutines.channels.produce 8 | import org.junit.jupiter.api.Test 9 | import org.junit.jupiter.params.ParameterizedTest 10 | import org.junit.jupiter.params.provider.Arguments 11 | import org.junit.jupiter.params.provider.ArgumentsSource 12 | import kotlin.random.Random 13 | 14 | 15 | class FlowTest { 16 | 17 | @ParameterizedTest 18 | @ArgumentsSource(StringConcatFlowProvider::class) 19 | fun testStringConcatFlow(seed: Int, tries: Int, failAt: Int, fails: Boolean){ 20 | println("seed: $seed, tries: $tries, failAt: $failAt, fails: $fails") 21 | val rnd = Random(seed) 22 | val source = TestableStringEvent(seed, 1) { _ -> Unit} 23 | var result: String? = null 24 | var flResult: FlowResult? = null 25 | val flow = with(StringFlowBuilder(source)) { 26 | setSubscribeAccess { stringFlow, function: suspend (String) -> Unit, _ -> 27 | runBlocking { 28 | for (i in 0..tries) { 29 | function(rnd.nextBits(8).toChar().toString()) 30 | } } } 31 | setSuccessAt(tries) 32 | setFailAt(failAt) 33 | setFinishCallback(Callback {flRes -> result = flRes.finishedFlow.string; flResult = flRes }) 34 | }.build() 35 | 36 | runBlocking { 37 | flow.start() 38 | } 39 | 40 | if(fails){ 41 | assert(flResult is FlowResult.FailedEnd) 42 | } else { 43 | assert(flResult is FlowResult.NotFailedEnd) 44 | } 45 | 46 | assert(result?.equals(concatStringFromRandom(seed, tries.coerceAtMost(failAt) - 1)) ?: false) 47 | } 48 | 49 | class StringConcatFlowProvider : InlineArgumentProvider( { 50 | listOf(0, 20, 1000).map { seed -> 51 | listOf( 52 | Arguments.of(20, 10, 15, false), 53 | Arguments.of(20, 15, 10, true), 54 | Arguments.of(20, 10, 10, false)) 55 | }.flatten().stream() 56 | }) 57 | 58 | 59 | @ParameterizedTest 60 | @ArgumentsSource(StringEventProvider::class) 61 | fun `test Event Output`(seed: Int, tries: Int, failAt: Int, fails: Boolean, delay: Long){ 62 | val (source, out) = TestableStringEvent.create(seed to delay) 63 | 64 | val multiplexer = SimpleMultiplexer.SimpleMultiplexerBuilder().setOrigin(out!!).build() 65 | 66 | val result: CompletableDeferred> = CompletableDeferred() 67 | val flow = with(StringFlowBuilder(source)){ 68 | setSuccessAt(tries) 69 | setFailAt(failAt) 70 | setSubscribeAccess {stringFlow, function: suspend (String) -> Unit, eventType -> 71 | if(eventType === source){ 72 | multiplexer.addListener(stringFlow, function) 73 | source.start() 74 | } else throw Throwable("Wrong event type") 75 | } 76 | setFinishCallback(Callback {pair -> result.complete(pair); out?.cancel()}) 77 | }.build() 78 | 79 | runBlocking { 80 | flow.start() 81 | result.await() 82 | } 83 | 84 | if(fails){ 85 | assert(result.getCompleted() is FlowResult.FailedEnd) 86 | } else { 87 | assert(result.getCompleted() is FlowResult.NotFailedEnd) 88 | } 89 | 90 | val correctString = concatStringFromRandom(seed, tries.coerceAtMost(failAt) - 1).countChars() 91 | assert(result.getCompleted().finishedFlow.string.countChars() == correctString) 92 | { "expected $correctString but got ${result.getCompleted().finishedFlow.string.countChars()}" } 93 | } 94 | 95 | class StringEventProvider : InlineArgumentProvider( { 96 | listOf(0, 20, 1000).map { seed -> 97 | listOf(0, 1, 10).map { delay -> 98 | listOf( 99 | Arguments.of(seed, 5, 15, false, delay), 100 | Arguments.of(seed, 15, 10, true, delay), 101 | Arguments.of(seed, 10, 10, false, delay)) 102 | }.flatten() 103 | }.flatten().stream() 104 | }) 105 | } 106 | 107 | 108 | 109 | class StringFlowBuilder(val eventSource: TestableStringEvent) : FlowBuilderDSL(){ 110 | private var failAt= 15 111 | private var successAt = 10 112 | 113 | init { 114 | _initValue = "" 115 | } 116 | 117 | fun setFailAt(failAt: Int): StringFlowBuilder { 118 | this.failAt = failAt 119 | return this 120 | } 121 | 122 | fun setSuccessAt(successAt: Int): StringFlowBuilder { 123 | this.successAt = successAt 124 | return this 125 | } 126 | override fun build(): StringFlow { 127 | val stub = FlowStub("", _subscribeAccess, _unsubscribeAccess, CoroutineScope(Dispatchers.Default)) 128 | return StringFlow(_callback, eventSource, failAt, successAt, stub) 129 | } 130 | } 131 | 132 | class StringFlow( 133 | val callback: Callback, Unit>, 134 | val eventSource: TestableStringEvent, 135 | val failAt: Int, 136 | val successAt: Int, 137 | private val stub: FlowStub 138 | ) : IFlowStub by stub, Flow { 139 | var string = initValue 140 | override suspend fun start() { 141 | stub.setOuter(this) 142 | subscribe(this::concat, eventSource) 143 | } 144 | 145 | 146 | suspend fun concat(str: String){ 147 | if(string.length < failAt && string.length < successAt) string += str; 148 | else if (string.length == successAt) { callback(FlowResult.NotFailedEnd.RegularEnd(this) ); return } 149 | else if (string.length == failAt) callback(FlowResult.FailedEnd.LogicFailed(this)) 150 | } 151 | } 152 | 153 | class TestableStringEvent(seed: Int, delay: Long = 0, outReceiver: (() -> ReceiveChannel) -> Unit): EventStream(outReceiver){ 154 | 155 | private val start: CompletableDeferred = CompletableDeferred() 156 | fun start() = start.complete(Unit) 157 | 158 | override val out: ReceiveChannel = CoroutineScope(Dispatchers.Default).produce(capacity = Channel.RENDEZVOUS) { 159 | val rnd = Random(seed) 160 | start.await() 161 | while(isActive){ 162 | val nextChar = rnd.nextBits(8).toChar().toString() 163 | send(nextChar) 164 | delay(delay) 165 | } 166 | } 167 | 168 | companion object : EventTypeFactory> by UniversalEventTypeFactory( {(seed, delay) -> 169 | var out: () -> ReceiveChannel = {throw UninitializedPropertyAccessException()} 170 | val built = TestableStringEvent(seed, delay) { channel -> out = channel} 171 | return@UniversalEventTypeFactory built to out() 172 | } ) 173 | 174 | } -------------------------------------------------------------------------------- /src/test/kotlin/de/rtrx/a/flow/TestUtil.kt: -------------------------------------------------------------------------------- 1 | package de.rtrx.a.flow 2 | 3 | import de.rtrx.a.monitor.Check 4 | import de.rtrx.a.monitor.Monitor 5 | import de.rtrx.a.monitor.MonitorBuilder 6 | import net.dean.jraw.models.Comment 7 | import net.dean.jraw.references.SubmissionReference 8 | import org.junit.jupiter.api.extension.ExtensionContext 9 | import org.junit.jupiter.params.provider.Arguments 10 | import org.junit.jupiter.params.provider.ArgumentsProvider 11 | import java.util.stream.Stream 12 | import javax.inject.Provider 13 | import kotlin.random.Random 14 | import kotlin.reflect.full.declaredMembers 15 | import kotlin.reflect.jvm.isAccessible 16 | 17 | 18 | fun concatStringFromRandom(seed: Int, count: Int): String{ 19 | val rnd = Random(seed) 20 | 21 | return (0..count).fold("", {acc, _ -> acc+rnd.nextBits(8).toChar().toString() }) 22 | } 23 | 24 | 25 | open class InlineArgumentProvider(val provider: (ExtensionContext?) -> Stream): ArgumentsProvider { 26 | 27 | override fun provideArguments(p0: ExtensionContext?) = provider(p0) 28 | 29 | } 30 | fun String.countChars() = this.groupBy { it }.map { it.key.toInt() to it.value.size }.sortedBy { it.first } 31 | 32 | class EmptyCheck : Monitor { 33 | override suspend fun start() { } 34 | } 35 | 36 | class EmptyCheckBuilder: MonitorBuilder { 37 | override fun build(submission: SubmissionReference) = EmptyCheck() 38 | 39 | override fun setBotComment(comment: Comment?): MonitorBuilder { return this } 40 | } 41 | 42 | class EmptyCheckFactory : Provider { 43 | override fun get() = EmptyCheckBuilder() 44 | } 45 | 46 | fun Collection.eachAndAll(vararg predicates: (R) -> Boolean): Boolean{ 47 | return this.all { element -> predicates.any { it(element) } } && predicates.all { this.any(it) } 48 | } 49 | inline fun accessPrivateProperty(instance: R, methodName: String): S { 50 | return R::class.members.find { it.name == methodName }!!.apply { isAccessible = true }.call(instance) as S 51 | } -------------------------------------------------------------------------------- /src/test/kotlin/de/rtrx/a/flow/UnexFlowDispatcherTest.kt: -------------------------------------------------------------------------------- 1 | package de.rtrx.a.flow 2 | 3 | import com.nhaarman.mockitokotlin2.* 4 | import de.rtrx.a.RedditSpec 5 | import de.rtrx.a.database.DummyLinkage 6 | import de.rtrx.a.database.Linkage 7 | import de.rtrx.a.flow.events.* 8 | import de.rtrx.a.flow.events.comments.CommentsFetcherFactory 9 | import de.rtrx.a.flow.events.comments.FullComments 10 | import de.rtrx.a.flow.events.comments.ManuallyFetchedEvent 11 | import de.rtrx.a.unex.RedditUnexFlowFactory 12 | import de.rtrx.a.unex.UnexFlow 13 | import de.rtrx.a.unex.UnexFlowDispatcher 14 | import de.rtrx.a.unex.UnexFlowFactory 15 | import kotlinx.coroutines.* 16 | import kotlinx.coroutines.channels.Channel 17 | import kotlinx.coroutines.channels.ReceiveChannel 18 | import kotlinx.coroutines.selects.select 19 | import net.dean.jraw.RedditClient 20 | import net.dean.jraw.models.Comment 21 | import net.dean.jraw.models.Message 22 | import net.dean.jraw.models.Submission 23 | import net.dean.jraw.references.CommentReference 24 | import net.dean.jraw.references.SubmissionReference 25 | import org.junit.jupiter.api.BeforeEach 26 | import org.junit.jupiter.api.Test 27 | import org.junit.jupiter.api.fail 28 | import java.util.concurrent.ConcurrentHashMap 29 | import java.util.concurrent.ConcurrentLinkedQueue 30 | import kotlin.reflect.KClass 31 | import kotlin.reflect.jvm.isAccessible 32 | 33 | class UnexFlowDispatcherTest { 34 | 35 | val timeout = 10000L 36 | lateinit var unexFlowTest: UnexFlowTest 37 | lateinit var dispatcherStub: IFlowDispatcherStub 38 | lateinit var unexFlowDispatcher: UnexFlowDispatcher 39 | 40 | lateinit var starterEventChannel: Channel 41 | lateinit var flowFactory: UnexFlowFactory 42 | lateinit var producedFlow: CompletableDeferred 43 | lateinit var launcherScope: CoroutineScope 44 | lateinit var eventFactories: EventFactories 45 | lateinit var commentsFetchedWrap: EventWrap 46 | lateinit var startAction: suspend UnexFlow.() -> Unit 47 | 48 | lateinit var linkage: Linkage 49 | lateinit var fullCommentsMultiplexerWrap: MultiplexerWrap 50 | lateinit var redditClient: RedditClient 51 | 52 | lateinit var incomingMessageMultiplexerWrap: MultiplexerWrap 53 | lateinit var sentMessageMultiplexerWrap: MultiplexerWrap 54 | 55 | lateinit var incomingMessageWrap: EventWrap 56 | lateinit var sentMessageWrap: EventWrap 57 | lateinit var isolationStrategy: IsolationStrategy 58 | lateinit var markAsReadFlow: MarkAsReadFlow 59 | val runningEvents get() = accessPrivateProperty< 60 | FlowDispatcherStub, 61 | ConcurrentHashMap, ConcurrentHashMap, ReceiveChannel<*>>>> 62 | >( 63 | dispatcherStub as FlowDispatcherStub, 64 | "runningEvents" 65 | ) 66 | val multiplexerList get() = accessPrivateProperty< 67 | FlowDispatcherStub, 68 | MutableMap, EventMultiplexer<*>> 69 | >( 70 | dispatcherStub as FlowDispatcherStub, 71 | "multiplexers" 72 | ) 73 | 74 | 75 | @BeforeEach 76 | fun setup() { 77 | unexFlowTest = UnexFlowTest() 78 | doReturn(3*1000L).whenever(unexFlowTest.config)[RedditSpec.scoring.timeUntilRemoval] 79 | 80 | unexFlowTest.setup() 81 | 82 | 83 | linkage = spy(DummyLinkage()) 84 | 85 | commentsFetchedWrap = EventWrap.wrap { } 86 | eventFactories = mapOf(ManuallyFetchedEvent::class to (commentsFetchedWrap.eventTypeFactory to SubmissionReference::class)) as EventFactories 87 | 88 | incomingMessageWrap = EventWrap.wrapMessage() 89 | sentMessageWrap = EventWrap.wrapMessage() 90 | 91 | fullCommentsMultiplexerWrap = MultiplexerWrap(commentsFetchedWrap.channel) 92 | incomingMessageMultiplexerWrap = MultiplexerWrap(incomingMessageWrap.channel) 93 | sentMessageMultiplexerWrap = MultiplexerWrap(sentMessageWrap.channel) 94 | 95 | redditClient = mock { 96 | on { submission(any()) }.doReturn(unexFlowTest.submissionRef) 97 | } 98 | 99 | producedFlow = CompletableDeferred() 100 | 101 | starterEventChannel = Channel() 102 | flowFactory = RedditUnexFlowFactory( 103 | unexFlowTest.config[RedditSpec.scoring.timeUntilRemoval], 104 | unexFlowTest.config[RedditSpec.messages.sent.maxTimeDistance] - unexFlowTest.config[RedditSpec.scoring.timeUntilRemoval], 105 | object : MessageComposer { 106 | override fun invoke(recipient: String, submissionURL: String) { 107 | unexFlowTest.foundAuthor = recipient 108 | unexFlowTest.foundSubmissionURL = submissionURL 109 | } 110 | }, 111 | object : Replyer { 112 | override fun invoke(submission: Submission, s: String): Pair { 113 | unexFlowTest.commentText = s 114 | return unexFlowTest.comment to unexFlowTest.commentReference 115 | } 116 | }, 117 | { unexFlowTest.monitorBuilder }, 118 | unexFlowTest.conversationLinkage, 119 | unexFlowTest.observationLinkage, 120 | linkage, 121 | { unexFlowTest.conversation }, 122 | unexFlowTest.delayedDeleteFactory, 123 | { fullCommentsMultiplexerWrap.multiplexerBuilder }, 124 | unexFlowTest.config, 125 | redditClient, 126 | ) 127 | 128 | launcherScope = CoroutineScope(Dispatchers.Default) 129 | startAction = { start() } 130 | isolationStrategy = SingleFlowIsolation() 131 | markAsReadFlow = mock { } 132 | 133 | dispatcherStub = FlowDispatcherStub(starterEventChannel, flowFactory, launcherScope, eventFactories, startAction) 134 | 135 | 136 | unexFlowDispatcher = UnexFlowDispatcher( 137 | dispatcherStub, 138 | incomingMessageMultiplexerWrap.multiplexerBuilder, 139 | sentMessageMultiplexerWrap.multiplexerBuilder, 140 | incomingMessageWrap.eventTypeFactory, 141 | sentMessageWrap.eventTypeFactory, 142 | isolationStrategy, 143 | markAsReadFlow, 144 | mock { }, 145 | false 146 | ) 147 | } 148 | 149 | @Test 150 | fun `test References Removal`() { 151 | unexFlowDispatcher.start() 152 | val createdFlows = unexFlowDispatcher.getCreatedFlows() 153 | runBlocking { 154 | val fullCommentsCalled = CompletableDeferred() 155 | doAnswer { fullCommentsCalled.complete(Unit); Unit } 156 | .whenever(unexFlowTest.monitor).saveToDB(any()) 157 | 158 | starterEventChannel.send(unexFlowTest.submissionRef) 159 | val flow: UnexFlow = select { 160 | createdFlows.onReceive { it } 161 | onTimeout(timeout) { null } 162 | } ?: fail("No flow created within timeout") 163 | 164 | flow.addCallback { unexFlowTest.defferedResult.complete(it) } 165 | 166 | withTimeout(timeout*10, { unexFlowTest.deleteStarted.await() }) 167 | 168 | verify(incomingMessageMultiplexerWrap.multiplexerBuilder).build() 169 | verify(sentMessageMultiplexerWrap.multiplexerBuilder).build() 170 | verify(incomingMessageMultiplexerWrap.multiplexerBuilder).setOrigin(eq(incomingMessageWrap.channel)) 171 | verify(sentMessageMultiplexerWrap.multiplexerBuilder).setOrigin(eq(sentMessageWrap.channel)) 172 | 173 | //verify(dispatcherStub).registerMultiplexer(eq(incomingMessageWrap.event), eq(incomingMessageMultiplexerWrap.multiplexer)) 174 | //verify(dispatcherStub).registerMultiplexer(eq(sentMessageWrap.event), eq(sentMessageMultiplexerWrap.multiplexer)) 175 | 176 | sentMessageWrap.channel.send(unexFlowTest.ownMessage) 177 | incomingMessageWrap.channel.send(unexFlowTest.reply) 178 | 179 | expectResult(unexFlowTest.defferedResult) 180 | assert(runningEvents[ManuallyFetchedEvent::class]!!.size == 0) 181 | assert(multiplexerList.size == 2) 182 | assert(multiplexerList[incomingMessageWrap.event]!! === incomingMessageMultiplexerWrap.multiplexer) 183 | assert(multiplexerList[sentMessageWrap.event]!! == sentMessageMultiplexerWrap.multiplexer) 184 | assert(multiplexerList[commentsFetchedWrap.event] == null) 185 | assert(incomingMessageMultiplexerWrap.accessMultiplexerPrivateProperty Unit>>>("listeners").size == 1) 186 | assert(sentMessageMultiplexerWrap.accessMultiplexerPrivateProperty Unit>>>("listeners").size == 1) 187 | 188 | 189 | } 190 | } 191 | 192 | class MultiplexerWrap(channel: ReceiveChannel) { 193 | val multiplexerBuilder: EventMultiplexerBuilder> 194 | lateinit var multiplexer: EventMultiplexer 195 | 196 | init { 197 | val realBuilder = SimpleMultiplexer.SimpleMultiplexerBuilder() 198 | realBuilder.setOrigin(channel) 199 | multiplexerBuilder = mock { 200 | on { setOrigin(any()) }.doReturn(mock) 201 | on { setIsolationStrategy(any()) }.doReturn(mock) 202 | //Partial Mocking won't work since it creates copies and messes up the interaction with the channel from the outside 203 | on { build() }.then { 204 | if(!this@MultiplexerWrap::multiplexer.isInitialized) { 205 | multiplexer = realBuilder.build() 206 | } 207 | return@then multiplexer 208 | } 209 | 210 | } 211 | } 212 | inline fun accessMultiplexerPrivateProperty(name: String): S{ 213 | return SimpleMultiplexer::class.members.find { it.name == "listeners" }!!.apply { isAccessible = true }.call(multiplexer) as S 214 | } 215 | } 216 | 217 | 218 | interface EventWrap, I : Any, F : EventTypeFactory> { 219 | var eventTypeFactory: F 220 | var channel: Channel 221 | var event: E 222 | 223 | 224 | companion object { 225 | inline fun < 226 | reified R : Any, 227 | reified E : EventType, 228 | reified I : Any, 229 | reified F : EventTypeFactory 230 | > wrap( 231 | crossinline stubbing: KStubbing.() -> Unit 232 | ): EventWrap { 233 | return object : EventWrap { 234 | override var eventTypeFactory: F 235 | override var channel: Channel 236 | override var event: E 237 | 238 | init { 239 | channel = spy(Channel(Channel.RENDEZVOUS)) 240 | event = mock { 241 | stubbing() 242 | } 243 | eventTypeFactory = mock { 244 | on { create(any()) }.doReturn(event to channel) 245 | } 246 | } 247 | 248 | } 249 | } 250 | 251 | inline fun > wrapMessage(): EventWrap { 252 | return EventWrap.wrap { 253 | var started: Boolean = false 254 | on { start() }.then { 255 | return@then if (!started) { 256 | started = true; true 257 | } else false 258 | } 259 | 260 | } 261 | } 262 | } 263 | } 264 | } 265 | 266 | -------------------------------------------------------------------------------- /src/test/kotlin/de/rtrx/a/flow/UnexFlowTest.kt: -------------------------------------------------------------------------------- 1 | package de.rtrx.a.flow 2 | 3 | import com.google.gson.JsonObject 4 | import com.nhaarman.mockitokotlin2.* 5 | import com.uchuhimo.konf.Config 6 | import com.uchuhimo.konf.source.Source 7 | import de.rtrx.a.RedditSpec 8 | import de.rtrx.a.database.* 9 | import de.rtrx.a.flow.events.EventType 10 | import de.rtrx.a.flow.events.IncomingMessagesEvent 11 | import de.rtrx.a.flow.events.MessageEvent 12 | import de.rtrx.a.flow.events.SentMessageEvent 13 | import de.rtrx.a.flow.events.comments.FullComments 14 | import de.rtrx.a.flow.events.comments.ManuallyFetchedEvent 15 | import de.rtrx.a.monitor.IDBCheck 16 | import de.rtrx.a.monitor.IDBCheckBuilder 17 | import de.rtrx.a.monitor.Monitor 18 | import de.rtrx.a.monitor.MonitorBuilder 19 | import de.rtrx.a.unex.* 20 | import kotlinx.coroutines.* 21 | import kotlinx.coroutines.selects.select 22 | import net.dean.jraw.models.Comment 23 | import net.dean.jraw.models.DistinguishedStatus 24 | import net.dean.jraw.models.Message 25 | import net.dean.jraw.models.Submission 26 | import net.dean.jraw.references.CommentReference 27 | import net.dean.jraw.references.PublicContributionReference 28 | import net.dean.jraw.references.SubmissionReference 29 | import org.junit.jupiter.api.* 30 | import org.junit.jupiter.params.ParameterizedTest 31 | import org.junit.jupiter.params.provider.Arguments 32 | import org.junit.jupiter.params.provider.ArgumentsSource 33 | import org.mockito.exceptions.verification.NeverWantedButInvoked 34 | import org.mockito.exceptions.verification.WantedButNotInvoked 35 | import java.time.LocalDateTime 36 | import java.util.* 37 | import kotlin.reflect.jvm.internal.impl.util.CheckResult 38 | 39 | val timeout = 10 * 1000L 40 | class UnexFlowTest { 41 | 42 | lateinit var flow: UnexFlow 43 | lateinit var submission: Submission 44 | lateinit var submissionRef: SubmissionReference 45 | lateinit var ownMessage: Message 46 | lateinit var reply: Message 47 | lateinit var comment: Comment 48 | lateinit var commentReference: CommentReference 49 | lateinit var conversationLinkage: ConversationLinkage 50 | lateinit var observationLinkage: ObservationLinkage 51 | lateinit var stub: IFlowStub 52 | lateinit var calledSubscriptions: MutableSet> 53 | lateinit var monitor: IDBCheck 54 | lateinit var monitorBuilder: IDBCheckBuilder 55 | lateinit var conversation: DefferedConversation 56 | lateinit var delayedDeleteFactory: DelayedDeleteFactory 57 | lateinit var manuallyFetchedEvent: ManuallyFetchedEvent 58 | 59 | private val sentMessageEvent = object : SentMessageEvent { 60 | override fun start(): Boolean { 61 | TODO("Not yet implemented") 62 | } 63 | } 64 | private val incomingMessagesEvent = object : IncomingMessagesEvent { 65 | override fun start(): Boolean { 66 | TODO("Not yet implemented") 67 | } 68 | } 69 | 70 | 71 | 72 | val configValues = Source.from.map.flat(mapOf( 73 | "reddit.messages.sent.timeSaved" to "10000", 74 | "reddit.messages.unread.maxAge" to "10000", 75 | "reddit.scoring.timeUntilRemoval" to "1000", 76 | "reddit.messages.sent.maxTimeDistance" to "300000", 77 | "reddit.messages.sent.maxWaitForCompletion" to "10000" 78 | )) 79 | val config = spy(Config { addSpec(RedditSpec) }.withSource(configValues)) 80 | val ownMessageID = "TestMessageID" 81 | val author = "Testauthor" 82 | val submissionID = "xxxxx" 83 | val submissionURL = "http://notreddit.com/r/unex/comments/$submissionID" 84 | val botMessage = "Please argue with me because of [Your Submission]($submissionURL)" 85 | val reason = "SomethingSomething Unexpected" 86 | val defferedResult: CompletableDeferred> = CompletableDeferred() 87 | val result: FlowResult? 88 | get() = defferedResult.takeIf { defferedResult.isCompleted }?.getCompleted() 89 | lateinit var waitForRemoval: CompletableDeferred 90 | val subscribeCalls = mutableListOf Unit, EventType<*>>>() 91 | val unsubscribeCalls = mutableListOf>>() 92 | 93 | val deleteStarted = CompletableDeferred() 94 | 95 | var foundAuthor: String? = null 96 | var foundSubmissionURL: String? = null 97 | 98 | var commentText: String? = null 99 | 100 | 101 | 102 | @BeforeEach 103 | fun setup(){ 104 | monitor = spy(object : IDBCheck{ 105 | override suspend fun saveToDB(fullComments: FullComments) { } 106 | override suspend fun start() { } }) 107 | 108 | monitorBuilder = mock { 109 | on {build(any())}.doReturn(monitor) 110 | on {setBotComment(anyOrNull())}.doReturn(this.mock) 111 | on { setCommentEvent(any())}.doReturn(this.mock) 112 | } 113 | conversationLinkage = spy(DummyLinkage()) 114 | observationLinkage = conversationLinkage as DummyLinkage 115 | var submissionRemoved = false 116 | waitForRemoval = CompletableDeferred() 117 | submission = mock { 118 | on { author }.doReturn(this@UnexFlowTest.author) 119 | on { permalink }.doReturn(this@UnexFlowTest.submissionURL) 120 | on { id }.doReturn(this@UnexFlowTest.submissionID) 121 | } 122 | doAnswer { submissionRemoved }.whenever(submission).isRemoved 123 | 124 | submissionRef = mock { 125 | on { inspect() }.doReturn(this@UnexFlowTest.submission) 126 | on { id }.doReturn(this@UnexFlowTest.submissionID) 127 | on { fullName }.doReturn("t3_" + this@UnexFlowTest.submissionID) 128 | on { remove() }.then { submissionRemoved = true; waitForRemoval.complete(Unit); Unit } 129 | on { approve() }.then { submissionRemoved = false; Unit } 130 | } 131 | ownMessage = mock { 132 | on { body }.doReturn(this@UnexFlowTest.botMessage) 133 | on { fullName }.doReturn(this@UnexFlowTest.ownMessageID) 134 | } 135 | reply = mock { 136 | on { body }.doReturn(this@UnexFlowTest.reason) 137 | on { firstMessage }.doReturn(this@UnexFlowTest.ownMessageID) 138 | } 139 | conversation = spy(DefferedConversation(config)) 140 | comment = mock() 141 | commentReference = mock() 142 | delayedDeleteFactory = spy(object: DelayedDeleteFactory { 143 | override fun create( 144 | publicContribution: PublicContributionReference, 145 | scope: CoroutineScope, 146 | skip: Long 147 | ) = createDeletion(config[RedditSpec.scoring.timeUntilRemoval], config[RedditSpec.messages.sent.maxTimeDistance] - config[RedditSpec.scoring.timeUntilRemoval], skip) 148 | 149 | }) 150 | 151 | manuallyFetchedEvent = mock() 152 | 153 | calledSubscriptions = mutableSetOf() 154 | stub = spy(object :IFlowStub by FlowStub( 155 | submissionRef, 156 | {flow: UnexFlow, fn, type -> subscribeCalls.add(Triple(flow, fn, type))}, 157 | {flow, type -> unsubscribeCalls.add(flow to type)}, 158 | CoroutineScope(Dispatchers.Default)){ 159 | override suspend fun withSubscription(subscription: Subscription, block: suspend CoroutineScope.() -> T): T { 160 | calledSubscriptions.add(subscription) 161 | return CoroutineScope(Dispatchers.Default).block() 162 | } 163 | 164 | override suspend fun withSubscriptions(subscriptions: Collection>, block: suspend CoroutineScope.() -> T): T { 165 | calledSubscriptions.addAll(subscriptions) 166 | return CoroutineScope(Dispatchers.Default).block() 167 | } 168 | }) 169 | flow = UnexFlow( 170 | stub, 171 | Callback { defferedResult.complete(it) }, 172 | object : MessageComposer { 173 | override fun invoke( recipient: String, submissionURL: String) { 174 | foundAuthor = recipient 175 | foundSubmissionURL = submissionURL 176 | } 177 | }, 178 | object : Replyer { 179 | override fun invoke(submission: Submission, s: String): Pair { 180 | commentText = s 181 | return comment to commentReference 182 | } 183 | }, 184 | sentMessageEvent, 185 | incomingMessagesEvent, 186 | manuallyFetchedEvent, 187 | conversationLinkage, 188 | observationLinkage, 189 | monitorBuilder, 190 | conversation, 191 | delayedDeleteFactory) 192 | 193 | stub.setOuter(flow) 194 | } 195 | 196 | private fun testFlowOutput() = assertAll( 197 | { assert(this.ownMessage.fullName == conversation.ownMessage) }, 198 | { assert(this.reply == conversation.reply) }, 199 | { assert(this.comment == flow.comment) }, 200 | { assert(this.commentText == reply.body)}, 201 | { assert(this.submission.isRemoved.not())}, 202 | { verify(commentReference).distinguish(DistinguishedStatus.MODERATOR, true) }, 203 | { assert(calledSubscriptions.any { it.type is IncomingMessagesEvent })}, 204 | { verify(conversationLinkage, times(1)).saveCommentMessage(submissionID, reply, comment)}, 205 | { runBlocking { verify(monitor).start()} }) 206 | 207 | private fun testFlowStart() = assertAll( 208 | { assert(this.author == foundAuthor) { "Recipient $foundAuthor does not match author $author" } }, 209 | { assert(this.submissionURL == foundSubmissionURL) { "SubmissionURL $foundSubmissionURL does not match ${this.submissionURL}" } }, 210 | { verify(observationLinkage, times(1)).insertSubmission(submission)} 211 | ) 212 | @Test 213 | fun `flow gets messages in correct order`(){ 214 | runBlocking { 215 | flow.startInScope(flow::start) 216 | conversation.start(ownMessage) 217 | conversation.reply(reply) 218 | assert(select { 219 | flow.incompletableDefferedComment.onAwait { true } 220 | onTimeout(timeout) {false} 221 | }) 222 | expectResult(defferedResult) 223 | } 224 | verify(submissionRef, never()).approve() 225 | testFlowOutput() 226 | testFlowStart() 227 | assert(calledSubscriptions.any { it.type is SentMessageEvent }) 228 | } 229 | 230 | 231 | @Test 232 | fun `flow gets Messages in wrong order`(){ 233 | runBlocking { 234 | flow.startInScope(flow::start) 235 | conversation.reply(reply) 236 | delay(10L) 237 | conversation.start(ownMessage) 238 | assert(select { 239 | flow.incompletableDefferedComment.onAwait { true } 240 | onTimeout(timeout) {false} 241 | }) 242 | expectResult(defferedResult) 243 | } 244 | verify(submissionRef, never()).approve() 245 | testFlowOutput() 246 | testFlowStart() 247 | assert(calledSubscriptions.any { it.type is SentMessageEvent }) 248 | 249 | } 250 | 251 | @Test 252 | fun `no Answer`() { 253 | doReturn(createDeletion(1, 10)).whenever(delayedDeleteFactory).create(any(), any(), any()) 254 | doReturn(CheckSelectResult(DelayedDelete.DeleteResult.WasDeleted(), true.toBooleable(), null)) 255 | .whenever(observationLinkage).createCheckSelectValues( 256 | any(), 257 | anyOrNull(), 258 | anyOrNull(), 259 | any(), 260 | any<(JsonObject) -> Booleable>() 261 | ) 262 | 263 | 264 | runBlocking { 265 | flow.startInScope(flow::start) 266 | conversation.start(ownMessage) 267 | expectResult(defferedResult) 268 | } 269 | assert(result is NoAnswerReceived) 270 | verify(submissionRef).remove() 271 | } 272 | 273 | @Test 274 | fun `late Answer`() { 275 | doReturn(createDeletion(1, 10000)).whenever(delayedDeleteFactory).create(any(), any(), any()) 276 | doReturn(CheckSelectResult(DelayedDelete.DeleteResult.WasDeleted(), true.toBooleable(), null)) 277 | .whenever(observationLinkage).createCheckSelectValues( 278 | any(), 279 | anyOrNull(), 280 | anyOrNull(), 281 | any(), 282 | any<(JsonObject) -> Booleable>() 283 | ) 284 | 285 | runBlocking { 286 | flow.startInScope(flow::start) 287 | conversation.start(ownMessage) 288 | delay(50L) 289 | conversation.reply(reply) 290 | expectResult(defferedResult) 291 | } 292 | 293 | verify(submissionRef).remove() 294 | testFlowOutput() 295 | testFlowStart() 296 | assert(calledSubscriptions.any { it.type is SentMessageEvent }) 297 | verify(submissionRef).approve() 298 | } 299 | 300 | 301 | @Test 302 | fun `link Check`(){ 303 | assert(produceCheckString(submissionID)(botMessage)) 304 | assert(!produceCheckString(submissionID)(botMessage.replace("t", "s"))) 305 | assert(!produceCheckString(submissionID)(botMessage.replace("x", "m"))) 306 | } 307 | 308 | @Test 309 | fun `submission already present`(){ 310 | doReturn(0).whenever(observationLinkage).insertSubmission(any()) 311 | val result = runBlocking { runForResult() } 312 | 313 | assertAll( 314 | { assert(result is SubmissionAlreadyPresent) }, 315 | { runBlocking { verify(stub, never()).subscribe(any(), argThat { this is IncomingMessagesEvent }) }}, 316 | { runBlocking { verify(stub, never()).subscribe(any(), argThat { this is SentMessageEvent }) } }) 317 | 318 | } 319 | 320 | @Test 321 | fun `No Removal upon approval`(){ 322 | doReturn(createDeletion(1, 10)).whenever(delayedDeleteFactory).create(any(), any(), any()) 323 | doReturn(CheckSelectResult(DelayedDelete.DeleteResult.NotDeleted(), true.toBooleable(), null)) 324 | .whenever(observationLinkage).createCheckSelectValues( 325 | any(), 326 | anyOrNull(), 327 | anyOrNull(), 328 | any(), 329 | any<(JsonObject) -> Booleable>() 330 | ) 331 | 332 | val result = runBlocking { 333 | runForResult { 334 | conversation.start(ownMessage) 335 | } 336 | } 337 | 338 | assert(result is FlowResult.NotFailedEnd) 339 | verify(submissionRef, never()).remove() 340 | 341 | } 342 | 343 | @Test 344 | fun `Relaunch with answer after no skip`(){ 345 | doReturn(Date()).whenever(submission).created 346 | val messageComposer = spy( 347 | object : MessageComposer { 348 | override fun invoke( recipient: String, submissionURL: String) { 349 | foundAuthor = recipient 350 | foundSubmissionURL = submissionURL 351 | } 352 | }) 353 | flow = UnexFlow( 354 | stub, 355 | Callback { defferedResult.complete(it) }, 356 | messageComposer, 357 | object : Replyer { 358 | override fun invoke(submission: Submission, s: String): Pair { 359 | commentText = s 360 | return comment to commentReference 361 | } 362 | }, 363 | sentMessageEvent, 364 | incomingMessagesEvent, 365 | manuallyFetchedEvent, 366 | conversationLinkage, 367 | observationLinkage, 368 | monitorBuilder, 369 | conversation, 370 | delayedDeleteFactory, 371 | ownMessageID 372 | ) 373 | 374 | runBlocking { 375 | flow.startInScope(flow::relaunch) 376 | conversation.reply(reply) 377 | assert(select { 378 | flow.incompletableDefferedComment.onAwait { true } 379 | onTimeout(timeout) {false} 380 | }) 381 | expectResult(defferedResult) 382 | } 383 | verify(submissionRef, never()).approve() 384 | verify(messageComposer, never()).invoke(any(), any()) 385 | verify(submissionRef, never()).remove() 386 | observationLinkage.insertSubmission(submission) 387 | testFlowOutput() 388 | } 389 | 390 | @Nested 391 | @DisplayName("test relaunch with answer skip to deletion") 392 | inner class RepeatedTestSkipToDeletion{ 393 | @ParameterizedTest 394 | @ArgumentsSource(RepeatedTest::class) 395 | fun `test relaunch with answer skip to deletion`(id: Int){ 396 | doReturn(Date(System.currentTimeMillis() - (config[RedditSpec.scoring.timeUntilRemoval] * 2))).whenever(submission).created 397 | val messageComposer = spy( 398 | object : MessageComposer { 399 | override fun invoke( recipient: String, submissionURL: String) { 400 | foundAuthor = recipient 401 | foundSubmissionURL = submissionURL 402 | } 403 | }) 404 | flow = UnexFlow( 405 | stub, 406 | Callback { defferedResult.complete(it) }, 407 | messageComposer, 408 | object : Replyer { 409 | override fun invoke(submission: Submission, s: String): Pair { 410 | commentText = s 411 | return comment to commentReference 412 | } 413 | }, 414 | sentMessageEvent, 415 | incomingMessagesEvent, 416 | manuallyFetchedEvent, 417 | conversationLinkage, 418 | observationLinkage, 419 | monitorBuilder, 420 | conversation, 421 | delayedDeleteFactory, 422 | ownMessageID 423 | ) 424 | 425 | 426 | runBlocking { 427 | flow.startInScope(flow::relaunch) 428 | assert(select { 429 | waitForRemoval.onAwait { true } 430 | onTimeout(timeout) { false } 431 | }) 432 | conversation.reply(reply) 433 | assert(select { 434 | flow.incompletableDefferedComment.onAwait { true } 435 | onTimeout(timeout) {false} 436 | }) 437 | expectResult(defferedResult) 438 | } 439 | 440 | try { 441 | verify(submissionRef, never()).remove() 442 | verify(submissionRef, never()).approve() 443 | } catch (e: NeverWantedButInvoked){ 444 | try { 445 | verify(submissionRef).remove() 446 | verify(submissionRef).approve() 447 | } catch (c: WantedButNotInvoked){ 448 | fail { "Submission was removed without reapproval" } 449 | } 450 | } 451 | verify(messageComposer, never()).invoke(any(), any()) 452 | observationLinkage.insertSubmission(submission) 453 | testFlowOutput() 454 | } 455 | } 456 | 457 | @Test 458 | fun `test relaunch with late answer skip to deletion`(){ 459 | doReturn(100000).`when`(config)[RedditSpec.scoring.timeUntilRemoval] 460 | doReturn(Date(System.currentTimeMillis() - 999999)).whenever(submission).created 461 | val messageComposer = spy( 462 | object : MessageComposer { 463 | override fun invoke( recipient: String, submissionURL: String) { 464 | foundAuthor = recipient 465 | foundSubmissionURL = submissionURL 466 | } 467 | }) 468 | flow = UnexFlow( 469 | stub, 470 | Callback { defferedResult.complete(it) }, 471 | messageComposer, 472 | object : Replyer { 473 | override fun invoke(submission: Submission, s: String): Pair { 474 | commentText = s 475 | return comment to commentReference 476 | } 477 | }, 478 | sentMessageEvent, 479 | incomingMessagesEvent, 480 | manuallyFetchedEvent, 481 | conversationLinkage, 482 | observationLinkage, 483 | monitorBuilder, 484 | conversation, 485 | delayedDeleteFactory, 486 | ownMessageID 487 | ) 488 | 489 | 490 | runBlocking { 491 | flow.startInScope(flow::relaunch) 492 | delay(500L) 493 | conversation.reply(reply) 494 | assert(select { 495 | flow.incompletableDefferedComment.onAwait { true } 496 | onTimeout(timeout) {false} 497 | }) 498 | expectResult(defferedResult) 499 | } 500 | verify(submissionRef).approve() 501 | verify(submissionRef).remove() 502 | verify(messageComposer, never()).invoke(any(), any()) 503 | observationLinkage.insertSubmission(submission) 504 | testFlowOutput() 505 | } 506 | 507 | 508 | 509 | suspend fun runForResult(fn: suspend () -> Unit = {}): FlowResult{ 510 | flow.startInScope(flow::start) 511 | fn() 512 | expectResult(defferedResult) 513 | return result!! 514 | } 515 | 516 | fun createDeletion(toDeletion: Long, saved: Long, skip: Long = 0): DelayedDelete{ 517 | val original = RedditDelayedDelete( 518 | toDeletion, 519 | saved - toDeletion, 520 | object : Unignorer { override fun invoke(p1: PublicContributionReference) { } }, 521 | DelayedDelete.approvedCheck(observationLinkage), 522 | submissionRef, 523 | CoroutineScope(Dispatchers.Default), 524 | skip 525 | ) 526 | return spy(object : DelayedDelete by original{ 527 | override fun start() { 528 | deleteStarted.complete(Unit) 529 | original.start() 530 | } 531 | }) 532 | } 533 | } 534 | 535 | 536 | class RepeatedTest : InlineArgumentProvider( { 537 | List(50) { Arguments.of(it) }.stream() 538 | }) 539 | 540 | suspend fun expectResult(def: Deferred<*>) = assert(select { 541 | def.onAwait { true } 542 | //onTimeout(timeout) { false } 543 | }) 544 | 545 | --------------------------------------------------------------------------------