added ratings

This commit is contained in:
InsanusMokrassar 2022-09-04 13:27:35 +06:00
parent 18b2e7b3c4
commit 1e393103c8
21 changed files with 437 additions and 32 deletions

View File

@ -0,0 +1,14 @@
package dev.inmo.plaguposter.common
import dev.inmo.tgbotapi.types.ChatId
import dev.inmo.tgbotapi.types.MessageIdentifier
import dev.inmo.tgbotapi.types.message.abstracts.Message
import kotlinx.serialization.Serializable
@Serializable
data class ShortMessageInfo(
val chatId: ChatId,
val messageId: MessageIdentifier
)
fun Message.short() = ShortMessageInfo(chat.id, messageId)

View File

@ -0,0 +1,4 @@
package dev.inmo.plaguposter.common
const val SuccessfulSymbol = ""
const val UnsuccessfulSymbol = ""

View File

@ -8,6 +8,8 @@ tgbotapi = "3.2.0"
microutils = "0.12.6" microutils = "0.12.6"
kslog = "0.5.1" kslog = "0.5.1"
psql = "42.3.6"
dexcount = "3.1.0" dexcount = "3.1.0"
junit_version = "4.12" junit_version = "4.12"
test_ext_junit_version = "1.1.3" test_ext_junit_version = "1.1.3"
@ -37,6 +39,8 @@ microutils-repos-exposed = { module = "dev.inmo:micro_utils.repos.exposed", vers
microutils-repos-cache = { module = "dev.inmo:micro_utils.repos.cache", version.ref = "microutils" } microutils-repos-cache = { module = "dev.inmo:micro_utils.repos.cache", version.ref = "microutils" }
kslog = { module = "dev.inmo:kslog", version.ref = "kslog" } kslog = { module = "dev.inmo:kslog", version.ref = "kslog" }
psql = { module = "org.postgresql:postgresql", version.ref = "psql" }
# buildscript classpaths # buildscript classpaths
android-tools-build = { module = "com.android.tools.build:gradle", version.ref = "android-gradle-plugin" } android-tools-build = { module = "com.android.tools.build:gradle", version.ref = "android-gradle-plugin" }

View File

@ -1,11 +1,15 @@
package dev.inmo.plaguposter.posts.models package dev.inmo.plaguposter.posts.models
import dev.inmo.tgbotapi.types.ChatId import dev.inmo.tgbotapi.types.ChatId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class ChatConfig( data class ChatConfig(
@SerialName("targetChat")
val targetChatId: ChatId, val targetChatId: ChatId,
@SerialName("sourceChat")
val sourceChatId: ChatId, val sourceChatId: ChatId,
@SerialName("cacheChat")
val cacheChatId: ChatId val cacheChatId: ChatId
) )

View File

@ -5,7 +5,7 @@ import dev.inmo.kslog.common.w
import dev.inmo.plagubot.Plugin import dev.inmo.plagubot.Plugin
import dev.inmo.plaguposter.posts.exposed.ExposedPostsRepo import dev.inmo.plaguposter.posts.exposed.ExposedPostsRepo
import dev.inmo.plaguposter.posts.models.ChatConfig import dev.inmo.plaguposter.posts.models.ChatConfig
import dev.inmo.plaguposter.posts.repo.PostsRepo import dev.inmo.plaguposter.posts.repo.*
import dev.inmo.plaguposter.posts.sending.PostPublisher import dev.inmo.plaguposter.posts.sending.PostPublisher
import dev.inmo.tgbotapi.types.ChatId import dev.inmo.tgbotapi.types.ChatId
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
@ -13,6 +13,7 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.json.* import kotlinx.serialization.json.*
import org.jetbrains.exposed.sql.Database import org.jetbrains.exposed.sql.Database
import org.koin.core.module.Module import org.koin.core.module.Module
import org.koin.dsl.binds
object Plugin : Plugin { object Plugin : Plugin {
override fun Module.setupDI(database: Database, params: JsonObject) { override fun Module.setupDI(database: Database, params: JsonObject) {
@ -23,7 +24,11 @@ object Plugin : Plugin {
return return
} }
single { get<Json>().decodeFromJsonElement(ChatConfig.serializer(), configJson) } single { get<Json>().decodeFromJsonElement(ChatConfig.serializer(), configJson) }
single<PostsRepo> { ExposedPostsRepo(database) } single { ExposedPostsRepo(database) } binds arrayOf(
PostsRepo::class,
ReadPostsRepo::class,
WritePostsRepo::class,
)
single { single {
val config = get<ChatConfig>() val config = get<ChatConfig>()
PostPublisher(get(), get(), config.cacheChatId, config.targetChatId) PostPublisher(get(), get(), config.cacheChatId, config.targetChatId)

View File

@ -9,8 +9,7 @@ import dev.inmo.micro_utils.fsm.common.State
import dev.inmo.micro_utils.repos.create import dev.inmo.micro_utils.repos.create
import dev.inmo.plagubot.Plugin import dev.inmo.plagubot.Plugin
import dev.inmo.plaguposter.common.FirstSourceIsCommandsFilter import dev.inmo.plaguposter.common.FirstSourceIsCommandsFilter
import dev.inmo.plaguposter.posts.models.NewPost import dev.inmo.plaguposter.posts.models.*
import dev.inmo.plaguposter.posts.models.PostContentInfo
import dev.inmo.plaguposter.posts.registrar.state.RegistrationState import dev.inmo.plaguposter.posts.registrar.state.RegistrationState
import dev.inmo.plaguposter.posts.repo.PostsRepo import dev.inmo.plaguposter.posts.repo.PostsRepo
import dev.inmo.tgbotapi.extensions.api.delete import dev.inmo.tgbotapi.extensions.api.delete
@ -41,24 +40,8 @@ import org.koin.core.module.Module
@Serializable @Serializable
object Plugin : Plugin { object Plugin : Plugin {
@Serializable
private data class Config(
@SerialName("sourceChat")
val sourceChatId: ChatId
)
override fun Module.setupDI(database: Database, params: JsonObject) {
val configJson = params["registrar"] ?: this@Plugin.let {
it.logger.w {
"Unable to load posts plugin due to absence of `registrar` key in config"
}
return
}
single { get<Json>().decodeFromJsonElement(Config.serializer(), configJson) }
}
override suspend fun BehaviourContextWithFSM<State>.setupBotPlugin(koin: Koin) { override suspend fun BehaviourContextWithFSM<State>.setupBotPlugin(koin: Koin) {
val config = koin.get<Config>() val config = koin.get<ChatConfig>()
val postsRepo = koin.get<PostsRepo>() val postsRepo = koin.get<PostsRepo>()
strictlyOn {state: RegistrationState.InProcess -> strictlyOn {state: RegistrationState.InProcess ->

View File

@ -0,0 +1,22 @@
plugins {
id "org.jetbrains.kotlin.multiplatform"
id "org.jetbrains.kotlin.plugin.serialization"
id "com.android.library"
}
apply from: "$mppProjectWithSerializationPresetPath"
kotlin {
sourceSets {
commonMain {
dependencies {
api project(":plaguposter.common")
api project(":plaguposter.ratings")
}
}
jvmMain {
dependencies {
}
}
}
}

View File

@ -0,0 +1 @@
package dev.inmo.plaguposter.ratings.source

View File

@ -0,0 +1,35 @@
package dev.inmo.plaguposter.ratings.source.models
import dev.inmo.plaguposter.ratings.models.Rating
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.*
typealias RatingsVariants = Map<String, Rating>
object RatingsVariantsSerializer : KSerializer<RatingsVariants> {
val surrogate = JsonObject.serializer()
override val descriptor: SerialDescriptor = surrogate.descriptor
override fun deserialize(decoder: Decoder): RatingsVariants {
val o = surrogate.deserialize(decoder)
return o.entries.mapNotNull { (key, value) ->
val doubleValue = (value as? JsonPrimitive) ?.doubleOrNull ?: return@mapNotNull null
key to Rating(doubleValue)
}.toMap()
}
override fun serialize(encoder: Encoder, value: RatingsVariants) {
surrogate.serialize(
encoder,
buildJsonObject {
value.forEach { (text, rating) ->
put(text, rating.double)
}
}
)
}
}

View File

@ -0,0 +1,7 @@
package dev.inmo.plaguposter.ratings.source.models
import dev.inmo.plaguposter.ratings.models.Rating
fun interface VariantTransformer {
operator fun invoke(from: String): Rating?
}

View File

@ -0,0 +1,8 @@
package dev.inmo.plaguposter.ratings.source.repos
import dev.inmo.micro_utils.repos.KeyValueRepo
import dev.inmo.plaguposter.common.ShortMessageInfo
import dev.inmo.plaguposter.posts.models.PostId
import dev.inmo.tgbotapi.types.PollIdentifier
interface PollsToMessagesInfoRepo : KeyValueRepo<PollIdentifier, ShortMessageInfo>

View File

@ -0,0 +1,7 @@
package dev.inmo.plaguposter.ratings.source.repos
import dev.inmo.micro_utils.repos.KeyValueRepo
import dev.inmo.plaguposter.posts.models.PostId
import dev.inmo.tgbotapi.types.PollIdentifier
interface PollsToPostsIdsRepo : KeyValueRepo<PollIdentifier, PostId>

View File

@ -0,0 +1,197 @@
package dev.inmo.plaguposter.ratings.source
import dev.inmo.kslog.common.e
import dev.inmo.kslog.common.logger
import dev.inmo.micro_utils.coroutines.runCatchingSafely
import dev.inmo.micro_utils.coroutines.subscribeSafelyWithoutExceptions
import dev.inmo.micro_utils.pagination.firstPageWithOneElementPagination
import dev.inmo.micro_utils.repos.id
import dev.inmo.micro_utils.repos.pagination.getAll
import dev.inmo.micro_utils.repos.set
import dev.inmo.plagubot.Plugin
import dev.inmo.plaguposter.common.*
import dev.inmo.plaguposter.posts.models.ChatConfig
import dev.inmo.plaguposter.posts.models.PostId
import dev.inmo.plaguposter.posts.repo.PostsRepo
import dev.inmo.plaguposter.ratings.models.Rating
import dev.inmo.plaguposter.ratings.repo.RatingsRepo
import dev.inmo.plaguposter.ratings.source.models.*
import dev.inmo.plaguposter.ratings.source.repos.*
import dev.inmo.tgbotapi.extensions.api.delete
import dev.inmo.tgbotapi.extensions.api.edit.edit
import dev.inmo.tgbotapi.extensions.api.send.polls.sendRegularPoll
import dev.inmo.tgbotapi.extensions.api.send.reply
import dev.inmo.tgbotapi.extensions.api.send.send
import dev.inmo.tgbotapi.extensions.behaviour_builder.BehaviourContext
import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.*
import dev.inmo.tgbotapi.extensions.utils.extensions.raw.poll
import dev.inmo.tgbotapi.extensions.utils.formatting.buildEntities
import dev.inmo.tgbotapi.types.message.textsources.regular
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.*
import org.jetbrains.exposed.sql.Database
import org.koin.core.Koin
import org.koin.core.module.Module
import org.koin.core.qualifier.named
object Plugin : Plugin {
private val ratingVariantsQualifier = named("ratingsVariants")
@Serializable
internal data class Config(
@Serializable(RatingsVariantsSerializer::class)
val variants: RatingsVariants,
val autoAttach: Boolean,
val ratingOfferText: String
)
override fun Module.setupDI(database: Database, params: JsonObject) {
single {
get<Json>().decodeFromJsonElement(Config.serializer(), params["ratingsPolls"] ?: error("Unable to load config for rating polls in $params"))
}
single<RatingsVariants>(ratingVariantsQualifier) { get<Config>().variants }
single<PollsToPostsIdsRepo> { ExposedPollsToPostsIdsRepo(database) }
single<PollsToMessagesInfoRepo> { ExposedPollsToMessagesInfoRepo(database) }
single<VariantTransformer> {
val ratingsSettings = get<RatingsVariants>(ratingVariantsQualifier)
VariantTransformer {
ratingsSettings[it]
}
}
}
override suspend fun BehaviourContext.setupBotPlugin(koin: Koin) {
val pollsToPostsIdsRepo = koin.get<PollsToPostsIdsRepo>()
val pollsToMessageInfoRepo = koin.get<PollsToMessagesInfoRepo>()
val variantsTransformer = koin.get<VariantTransformer>()
val ratingsRepo = koin.get<RatingsRepo>()
val postsRepo = koin.get<PostsRepo>()
val config = koin.get<Config>()
onPollUpdates (markerFactory = { it.id }) { poll ->
val postId = pollsToPostsIdsRepo.get(poll.id) ?: return@onPollUpdates
val newRating = poll.options.sumOf {
(variantsTransformer(it.text) ?.double ?.times(it.votes)) ?: 0.0
}
ratingsRepo.set(postId, Rating(newRating))
}
suspend fun attachPoll(postId: PostId): Boolean {
if (pollsToPostsIdsRepo.keys(postId, firstPageWithOneElementPagination).results.isNotEmpty()) {
return false
}
val post = postsRepo.getById(postId) ?: return false
for (content in post.content) {
runCatchingSafely {
val sent = send(
content.chatId,
config.ratingOfferText,
config.variants.keys.toList(),
replyToMessageId = content.messageId
)
pollsToPostsIdsRepo.set(sent.content.poll.id, postId)
pollsToMessageInfoRepo.set(sent.content.poll.id, sent.short())
}.getOrNull() ?: continue
return true
}
return false
}
suspend fun detachPoll(postId: PostId): Boolean {
val postIds = pollsToPostsIdsRepo.getAll { keys(postId, it) }.takeIf { it.isNotEmpty() } ?: return false
return postIds.map { (pollId) ->
val messageInfo = pollsToMessageInfoRepo.get(pollId) ?: return@map false
runCatchingSafely {
delete(messageInfo.chatId, messageInfo.messageId)
}.onFailure {
this@Plugin.logger.e(it) { "Something went wrong when trying to remove ratings message ($messageInfo) for post $postId" }
}.isSuccess
}.any().also {
if (it) {
pollsToPostsIdsRepo.unset(postIds.map { it.id })
pollsToMessageInfoRepo.unset(postIds.map { it.id })
}
}
}
postsRepo.deletedObjectsIdsFlow.subscribeSafelyWithoutExceptions(this) { postId ->
detachPoll(postId)
}
if (config.autoAttach) {
postsRepo.newObjectsFlow.subscribeSafelyWithoutExceptions(this) {
attachPoll(it.id)
}
}
onCommand("attach_ratings", requireOnlyCommandInMessage = true) {
val replyTo = it.replyTo ?: run {
reply(
it,
"You should reply to post message to attach ratings"
)
return@onCommand
}
val postId = postsRepo.getIdByChatAndMessage(replyTo.chat.id, replyTo.messageId) ?: run {
reply(
it,
"Unable to find post where the message in reply is presented"
)
return@onCommand
}
if (attachPoll(postId)) {
runCatchingSafely {
edit(
it,
it.content.textSources + regular(" $SuccessfulSymbol")
)
}
} else {
runCatchingSafely {
edit(
it,
it.content.textSources + regular(" $UnsuccessfulSymbol")
)
}
}
}
onCommand("detach_ratings", requireOnlyCommandInMessage = true) {
val replyTo = it.replyTo ?: run {
reply(
it,
"You should reply to post message to detach ratings"
)
return@onCommand
}
val postId = postsRepo.getIdByChatAndMessage(replyTo.chat.id, replyTo.messageId) ?: run {
reply(
it,
"Unable to find post where the message in reply is presented"
)
return@onCommand
}
if (detachPoll(postId)) {
runCatchingSafely {
edit(
it,
it.content.textSources + regular(" $SuccessfulSymbol")
)
}
} else {
runCatchingSafely {
edit(
it,
it.content.textSources + regular(" $UnsuccessfulSymbol")
)
}
}
}
}
}

View File

@ -0,0 +1,49 @@
package dev.inmo.plaguposter.ratings.source.repos
import dev.inmo.micro_utils.repos.exposed.initTable
import dev.inmo.micro_utils.repos.exposed.keyvalue.AbstractExposedKeyValueRepo
import dev.inmo.plaguposter.common.ShortMessageInfo
import dev.inmo.tgbotapi.types.ChatId
import dev.inmo.tgbotapi.types.PollIdentifier
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.statements.InsertStatement
import org.jetbrains.exposed.sql.statements.UpdateStatement
class ExposedPollsToMessagesInfoRepo(
database: Database
) : PollsToMessagesInfoRepo, AbstractExposedKeyValueRepo<PollIdentifier, ShortMessageInfo>(
database,
"polls_to_their_messages_info"
) {
override val keyColumn = text("poll_id")
private val chatIdColumn = long("chat_id")
private val messageIdColumn = long("message_id")
override val selectById: SqlExpressionBuilder.(PollIdentifier) -> Op<Boolean> = { keyColumn.eq(it) }
override val selectByValue: SqlExpressionBuilder.(ShortMessageInfo) -> Op<Boolean> = {
chatIdColumn.eq(it.chatId.chatId).and(
messageIdColumn.eq(it.messageId)
)
}
override val ResultRow.asKey: PollIdentifier
get() = get(keyColumn)
override val ResultRow.asObject: ShortMessageInfo
get() = ShortMessageInfo(
get(chatIdColumn).let(::ChatId),
get(messageIdColumn)
)
init {
initTable()
}
override fun update(k: PollIdentifier, v: ShortMessageInfo, it: UpdateStatement) {
it[chatIdColumn] = v.chatId.chatId
it[messageIdColumn] = v.messageId
}
override fun insert(k: PollIdentifier, v: ShortMessageInfo, it: InsertStatement<Number>) {
it[keyColumn] = k
it[chatIdColumn] = v.chatId.chatId
it[messageIdColumn] = v.messageId
}
}

View File

@ -0,0 +1,35 @@
package dev.inmo.plaguposter.ratings.source.repos
import dev.inmo.micro_utils.repos.exposed.initTable
import dev.inmo.micro_utils.repos.exposed.keyvalue.AbstractExposedKeyValueRepo
import dev.inmo.plaguposter.posts.models.PostId
import dev.inmo.tgbotapi.types.PollIdentifier
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.statements.InsertStatement
import org.jetbrains.exposed.sql.statements.UpdateStatement
class ExposedPollsToPostsIdsRepo(
database: Database
) : PollsToPostsIdsRepo, AbstractExposedKeyValueRepo<PollIdentifier, PostId>(database, "polls_to_posts") {
override val keyColumn = text("poll_id")
val postIdColumn = text("postId")
override val selectById: SqlExpressionBuilder.(PollIdentifier) -> Op<Boolean> = { keyColumn.eq(it) }
override val selectByValue: SqlExpressionBuilder.(PostId) -> Op<Boolean> = { postIdColumn.eq(it.string) }
override val ResultRow.asKey: PollIdentifier
get() = get(keyColumn)
override val ResultRow.asObject: PostId
get() = get(postIdColumn).let(::PostId)
init {
initTable()
}
override fun update(k: PollIdentifier, v: PostId, it: UpdateStatement) {
it[postIdColumn] = v.string
}
override fun insert(k: PollIdentifier, v: PostId, it: InsertStatement<Number>) {
it[keyColumn] = k
it[postIdColumn] = v.string
}
}

View File

@ -0,0 +1 @@
<manifest package="dev.inmo.plaguposter.ratings.source"/>

View File

@ -3,12 +3,18 @@ package dev.inmo.plaguposter.ratings
import dev.inmo.plagubot.Plugin import dev.inmo.plagubot.Plugin
import dev.inmo.plaguposter.posts.exposed.ExposedPostsRepo import dev.inmo.plaguposter.posts.exposed.ExposedPostsRepo
import dev.inmo.plaguposter.ratings.exposed.ExposedRatingsRepo import dev.inmo.plaguposter.ratings.exposed.ExposedRatingsRepo
import dev.inmo.plaguposter.ratings.repo.*
import kotlinx.serialization.json.* import kotlinx.serialization.json.*
import org.jetbrains.exposed.sql.Database import org.jetbrains.exposed.sql.Database
import org.koin.core.module.Module import org.koin.core.module.Module
import org.koin.dsl.binds
object Plugin : Plugin { object Plugin : Plugin {
override fun Module.setupDI(database: Database, params: JsonObject) { override fun Module.setupDI(database: Database, params: JsonObject) {
single { ExposedRatingsRepo(database, get<ExposedPostsRepo>().idColumn) } single { ExposedRatingsRepo(database, get<ExposedPostsRepo>().idColumn) } binds arrayOf(
RatingsRepo::class,
ReadRatingsRepo::class,
WriteRatingsRepo::class,
)
} }
} }

View File

@ -12,6 +12,9 @@ dependencies {
api project(":plaguposter.posts_registrar") api project(":plaguposter.posts_registrar")
api project(":plaguposter.triggers.command") api project(":plaguposter.triggers.command")
api project(":plaguposter.ratings") api project(":plaguposter.ratings")
api project(":plaguposter.ratings.source")
api libs.psql
} }
application { application {

View File

@ -1,21 +1,28 @@
{ {
"database": { "database": {
"url": "jdbc:sqlite:file:test?mode=memory&cache=shared IT IS JUST EXAMPLE", "url": "jdbc:postgresql://127.0.0.1:8091/test",
"driver": "org.sqlite.JDBC", "username": "test",
"username": "OPTIONAL username", "password": "test",
"password": "OPTIONAL password", "driver": "org.postgresql.Driver"
"initAutomatically": false
}, },
"botToken": "1234567890:ABCDEFGHIJKLMNOP_qrstuvwxyz12345678", "botToken": "1234567890:ABCDEFGHIJKLMNOP_qrstuvwxyz12345678",
"plugins": [ "plugins": [
"dev.inmo.plaguposter.posts.Plugin", "dev.inmo.plaguposter.posts.Plugin",
"dev.inmo.plaguposter.posts.registrar.Plugin" "dev.inmo.plaguposter.posts.registrar.Plugin",
"dev.inmo.plaguposter.ratings.Plugin",
"dev.inmo.plaguposter.ratings.source.Plugin"
], ],
"posts": { "posts": {
"targetChat": 12345678, "targetChat": 12345678,
"cacheChat": 12345678 "cacheChat": 12345678,
},
"registrar": {
"sourceChat": 12345678 "sourceChat": 12345678
},
"ratingsPolls": {
"variants": {
"Ok": 1,
"Not ok": -1
},
"autoAttach": true,
"ratingOfferText": "What do you think about it?"
} }
} }

12
runner/docker-compose.yml Normal file
View File

@ -0,0 +1,12 @@
version: "3.4"
services:
plaguposter_postgres:
image: postgres
container_name: "plaguposter_postgres"
environment:
POSTGRES_USER: "test"
POSTGRES_PASSWORD: "test"
POSTGRES_DB: "test"
ports:
- "8091:5432"

View File

@ -5,8 +5,9 @@ String[] includes = [
":posts", ":posts",
":posts_registrar", ":posts_registrar",
":ratings", ":ratings",
":ratings:source",
":triggers:command", ":triggers:command",
":settings", // ":settings",
":runner" ":runner"
] ]