Compare commits

32 Commits

Author SHA1 Message Date
9883c47e1b add commands for cas and fixes in its work 2022-09-26 03:26:01 +06:00
a6d347934b add CAS Support 2022-09-26 03:16:26 +06:00
de5391b121 add setup of sent message in expression worker 2022-09-21 18:15:19 +06:00
823c895e42 fixes 2022-09-21 18:11:23 +06:00
de6a8241c3 fixes 2022-09-21 18:04:14 +06:00
7dead36cd9 fixes in captcha related to expression 2022-09-21 15:14:20 +06:00
c84cff9ddc fixes in captcha chats settings repo 2022-09-21 14:51:24 +06:00
418d6b7f45 updates 2022-09-21 14:26:53 +06:00
2165f665ec update dependencies 2022-09-19 02:02:05 +06:00
27dffce0d2 update dependencies and fix admins cache api getting 2022-09-19 01:40:59 +06:00
772f05729e Update gradle.properties 2022-09-10 21:52:15 +06:00
dae42f95b7 Update gradle.properties 2022-08-31 11:15:14 +06:00
70097c731f update dependencies 2022-08-18 17:30:35 +06:00
5bced22b47 Update gradle.properties 2022-08-05 22:22:15 +06:00
16831b520e Update gradle.properties 2022-08-03 09:27:36 +06:00
d8dbb2512f fixes 2022-07-31 00:35:05 +06:00
f1093d6944 add support of disable/enable kick on unsuccess 2022-07-31 00:17:46 +06:00
58c6d12bdd Update gradle.properties 2022-07-30 23:56:28 +06:00
b678b34cc8 include commands 2022-07-30 22:57:59 +06:00
94cbeacda6 Update gradle.properties 2022-07-30 20:43:25 +06:00
3430595e8a Update gradle.properties 2022-07-30 20:40:32 +06:00
250877459f update publish workfow 2022-07-29 23:47:41 +06:00
8fff301ead update dependencies 2022-07-29 23:44:25 +06:00
7fb5e853b7 Update gradle.properties 2022-07-29 23:39:14 +06:00
cef6ce391b update publish scripts 2022-05-23 22:23:20 +06:00
959b497280 upgrades 2022-05-22 12:37:13 +06:00
fb9933a1de fixes 2022-05-22 12:05:24 +06:00
012922cd0e update dependencies 2022-05-22 11:57:10 +06:00
39c7b42778 temporal progress 2022-05-21 17:31:52 +06:00
e375170567 removing deprecations 2022-05-18 14:27:29 +06:00
8017678da8 fixes after update 2022-05-18 14:22:03 +06:00
317db251b9 Update gradle.properties 2022-05-18 00:54:23 -04:00
13 changed files with 708 additions and 373 deletions

View File

@@ -19,7 +19,7 @@ jobs:
GITHUB_USER: ${{ github.actor }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Publish package
run: ./gradlew publishAllPublicationsToGithubPackagesRepository --no-parallel -x signMavenPublication
run: ./gradlew publishAllPublicationsToGithubPackagesRepository --no-parallel
env:
GITHUBPACKAGES_USER: ${{ github.actor }}
GITHUBPACKAGES_PASSWORD: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -51,4 +51,5 @@ dependencies {
api "dev.inmo:plagubot.plugin:$plagubot_version"
api "dev.inmo:micro_utils.repos.exposed:$micro_utils_version"
api "dev.inmo:tgbotapi.libraries.cache.admins.plagubot:$tgbotapi_libraries_version"
api "dev.inmo:plagubot.plugins.commands:$commands_version"
}

View File

@@ -4,13 +4,14 @@ org.gradle.parallel=true
kotlin.js.generate.externals=true
kotlin.incremental=true
kotlin_version=1.6.21
kotlin_coroutines_version=1.6.1
kotlin_serialisation_runtime_version=1.3.3
plagubot_version=1.0.0
kotlin_version=1.7.10
kotlin_coroutines_version=1.6.4
kotlin_serialisation_runtime_version=1.4.0
plagubot_version=2.3.3
micro_utils_version=0.10.4
tgbotapi_libraries_version=0.0.18
micro_utils_version=0.12.13
tgbotapi_libraries_version=0.5.4
commands_version=0.3.4
project_group=dev.inmo
project_version=0.1.6

View File

@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@@ -1,6 +1,4 @@
apply plugin: 'maven-publish'
apply plugin: 'signing'
task javadocJar(type: Jar) {
from javadoc
@@ -76,7 +74,18 @@ publishing {
}
}
signing {
useGpgCmd()
sign publishing.publications
if (project.hasProperty("signing.gnupg.keyName")) {
apply plugin: 'signing'
signing {
useGpgCmd()
sign publishing.publications
}
task signAll {
tasks.withType(Sign).forEach {
dependsOn(it)
}
}
}

View File

@@ -1 +1 @@
{"licenses":[{"id":"Apache-2.0","title":"Apache Software License 2.0","url":"https://github.com/InsanusMokrassar/CaptchaPlaguBotPlugin/blob/master/LICENSE"}],"mavenConfig":{"name":"${project.name}","description":"${project.name}","url":"https://github.com/InsanusMokrassar/CaptchaPlaguBotPlugin","vcsUrl":"https://github.com/InsanusMokrassar/CaptchaPlaguBotPlugin.git","includeGpgSigning":true,"developers":[{"id":"InsanusMokrassar","name":"Ovsiannikov Aleksei","eMail":"ovsyannikov.alexey95@gmail.com"}],"repositories":[{"name":"GithubPackages","url":"https://maven.pkg.github.com/InsanusMokrassar/CaptchaPlaguBotPlugin"},{"name":"sonatype","url":"https://oss.sonatype.org/service/local/staging/deploy/maven2/"}]},"type":"JVM"}
{"licenses":[{"id":"Apache-2.0","title":"Apache Software License 2.0","url":"https://github.com/InsanusMokrassar/CaptchaPlaguBotPlugin/blob/master/LICENSE"}],"mavenConfig":{"name":"${project.name}","description":"${project.name}","url":"https://github.com/InsanusMokrassar/CaptchaPlaguBotPlugin","vcsUrl":"https://github.com/InsanusMokrassar/CaptchaPlaguBotPlugin.git","developers":[{"id":"InsanusMokrassar","name":"Ovsiannikov Aleksei","eMail":"ovsyannikov.alexey95@gmail.com"}],"repositories":[{"name":"GithubPackages","url":"https://maven.pkg.github.com/InsanusMokrassar/CaptchaPlaguBotPlugin"},{"name":"sonatype","url":"https://oss.sonatype.org/service/local/staging/deploy/maven2/"}],"gpgSigning":{"type":"dev.inmo.kmppscriptbuilder.core.models.GpgSigning.Optional"}},"type":"JVM"}

View File

@@ -1,13 +1,18 @@
package dev.inmo.plagubot.plugins.captcha
import com.benasher44.uuid.uuid4
import dev.inmo.micro_utils.coroutines.*
import dev.inmo.micro_utils.repos.create
import dev.inmo.plagubot.Plugin
import dev.inmo.plagubot.plugins.captcha.cas.CASChecker
import dev.inmo.plagubot.plugins.captcha.cas.KtorCASChecker
import dev.inmo.plagubot.plugins.captcha.db.CaptchaChatsSettingsRepo
import dev.inmo.plagubot.plugins.captcha.provider.*
import dev.inmo.plagubot.plugins.captcha.settings.ChatSettings
import dev.inmo.tgbotapi.extensions.api.chat.get.getChat
import dev.inmo.plagubot.plugins.commands.BotCommandFullInfo
import dev.inmo.plagubot.plugins.commands.CommandsKeeperKey
import dev.inmo.tgbotapi.extensions.api.chat.members.*
import dev.inmo.tgbotapi.extensions.api.delete
import dev.inmo.tgbotapi.extensions.api.deleteMessage
import dev.inmo.tgbotapi.extensions.api.send.*
import dev.inmo.tgbotapi.extensions.behaviour_builder.*
@@ -15,15 +20,21 @@ import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onComman
import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onNewChatMembers
import dev.inmo.tgbotapi.extensions.utils.*
import dev.inmo.tgbotapi.extensions.utils.extensions.parseCommandsWithParams
import dev.inmo.tgbotapi.extensions.utils.extensions.sourceChat
import dev.inmo.tgbotapi.libraries.cache.admins.*
import dev.inmo.tgbotapi.types.BotCommand
import dev.inmo.tgbotapi.types.chat.RestrictionsChatPermissions
import dev.inmo.tgbotapi.types.chat.abstracts.Chat
import dev.inmo.tgbotapi.types.chat.abstracts.extended.ExtendedGroupChat
import dev.inmo.tgbotapi.types.chat.*
import dev.inmo.tgbotapi.types.commands.BotCommandScope
import dev.inmo.tgbotapi.utils.link
import dev.inmo.tgbotapi.utils.mention
import io.ktor.client.HttpClient
import kotlinx.coroutines.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
import org.jetbrains.exposed.sql.Database
import org.koin.core.Koin
import org.koin.core.module.Module
import org.koin.core.qualifier.named
import org.koin.dsl.binds
private const val enableAutoDeleteCommands = "captcha_auto_delete_commands_on"
private const val disableAutoDeleteCommands = "captcha_auto_delete_commands_off"
@@ -39,6 +50,8 @@ private const val enableCaptcha = "enable_captcha"
private val enableDisableKickOnUnsuccess = Regex("captcha_(enable|disable)_kick")
private const val enableKickOnUnsuccess = "captcha_enable_kick"
private const val disableKickOnUnsuccess = "captcha_disable_kick"
private const val enableCAS = "captcha_enable_cas"
private const val disableCAS = "captcha_disable_cas"
private val changeCaptchaMethodCommandRegex = Regex(
"captcha_use_((slot_machine)|(simple)|(expression))"
@@ -46,92 +59,151 @@ private val changeCaptchaMethodCommandRegex = Regex(
@Serializable
class CaptchaBotPlugin : Plugin {
override suspend fun getCommands(): List<BotCommand> = listOf(
BotCommand(
enableAutoDeleteCommands,
"Enable auto removing of commands addressed to captcha plugin"
),
BotCommand(
disableAutoDeleteCommands,
"Disable auto removing of commands addressed to captcha plugin"
),
BotCommand(
enableAutoDeleteServiceMessages,
"Enable auto removing of users joined messages"
),
BotCommand(
disableAutoDeleteServiceMessages,
"Disable auto removing of users joined messages"
),
BotCommand(
enableSlotMachineCaptcha,
"Change captcha method to slot machine"
),
BotCommand(
enableSimpleCaptcha,
"Change captcha method to simple button"
),
BotCommand(
disableCaptcha,
"Disable captcha for chat"
),
BotCommand(
enableCaptcha,
"Enable captcha for chat"
),
BotCommand(
enableExpressionCaptcha,
"Change captcha method to expressions"
),
BotCommand(
enableKickOnUnsuccess,
"Not solved captcha users will be kicked from the chat"
),
BotCommand(
disableKickOnUnsuccess,
"Not solved captcha users will NOT be kicked from the chat"
)
)
override suspend fun BehaviourContext.invoke(
database: Database,
params: Map<String, Any>
) {
val repo = CaptchaChatsSettingsRepo(database)
val adminsAPI = params.adminsPlugin ?.adminsAPI(database)
override fun Module.setupDI(database: Database, params: JsonObject) {
single { CaptchaChatsSettingsRepo(database) }
single(named(uuid4().toString())) {
BotCommandFullInfo(
CommandsKeeperKey(BotCommandScope.AllChatAdministrators),
BotCommand(enableAutoDeleteCommands, "Enable auto removing of commands addressed to captcha plugin")
)
}
single(named(uuid4().toString())) {
BotCommandFullInfo(
CommandsKeeperKey(BotCommandScope.AllChatAdministrators),
BotCommand(disableAutoDeleteCommands, "Disable auto removing of commands addressed to captcha plugin")
)
}
single(named(uuid4().toString())) {
BotCommandFullInfo(
CommandsKeeperKey(BotCommandScope.AllChatAdministrators),
BotCommand(enableAutoDeleteServiceMessages, "Enable auto removing of users joined messages")
)
}
single(named(uuid4().toString())) {
BotCommandFullInfo(
CommandsKeeperKey(BotCommandScope.AllChatAdministrators),
BotCommand(disableAutoDeleteServiceMessages, "Disable auto removing of users joined messages")
)
}
single(named(uuid4().toString())) {
BotCommandFullInfo(
CommandsKeeperKey(BotCommandScope.AllChatAdministrators),
BotCommand(enableSlotMachineCaptcha, "Change captcha method to slot machine")
)
}
single(named(uuid4().toString())) {
BotCommandFullInfo(
CommandsKeeperKey(BotCommandScope.AllChatAdministrators),
BotCommand(enableSimpleCaptcha, "Change captcha method to simple button")
)
}
single(named(uuid4().toString())) {
BotCommandFullInfo(
CommandsKeeperKey(BotCommandScope.AllChatAdministrators),
BotCommand(disableCaptcha, "Disable captcha for chat")
)
}
single(named(uuid4().toString())) {
BotCommandFullInfo(
CommandsKeeperKey(BotCommandScope.AllChatAdministrators),
BotCommand(enableCaptcha, "Enable captcha for chat")
)
}
single(named(uuid4().toString())) {
BotCommandFullInfo(
CommandsKeeperKey(BotCommandScope.AllChatAdministrators),
BotCommand(enableExpressionCaptcha, "Change captcha method to expressions")
)
}
single(named(uuid4().toString())) {
BotCommandFullInfo(
CommandsKeeperKey(BotCommandScope.AllChatAdministrators),
BotCommand(enableKickOnUnsuccess, "Not solved captcha users will be kicked from the chat")
)
}
single(named(uuid4().toString())) {
BotCommandFullInfo(
CommandsKeeperKey(BotCommandScope.AllChatAdministrators),
BotCommand(disableKickOnUnsuccess, "Not solved captcha users will NOT be kicked from the chat")
)
}
single(named(uuid4().toString())) {
BotCommandFullInfo(
CommandsKeeperKey(BotCommandScope.AllChatAdministrators),
BotCommand(enableCAS, "Users banned in CAS will fail captcha automatically")
)
}
single(named(uuid4().toString())) {
BotCommandFullInfo(
CommandsKeeperKey(BotCommandScope.AllChatAdministrators),
BotCommand(disableCAS, "Users banned in CAS will NOT fail captcha automatically")
)
}
single {
KtorCASChecker(
HttpClient(),
get()
)
} binds arrayOf(
CASChecker::class
)
}
override suspend fun BehaviourContext.setupBotPlugin(koin: Koin) {
val repo: CaptchaChatsSettingsRepo by koin.inject()
val adminsAPI = koin.getOrNull<AdminsCacheAPI>()
val casChecker = koin.get<CASChecker>()
suspend fun Chat.settings() = repo.getById(id) ?: repo.create(ChatSettings(id)).first()
onNewChatMembers(
initialFilter = {
it.chat.asPublicChat() != null
},
subcontextUpdatesFilter = { m, u -> u.sourceChat() == m.chat },
) {
launchSafelyWithoutExceptions {
val settings = it.chat.settings()
if (!settings.enabled) return@launchSafelyWithoutExceptions
it.chat is GroupChat
}
) { msg ->
val settings = msg.chat.settings()
if (!settings.enabled) return@onNewChatMembers
safelyWithoutExceptions {
if (settings.autoRemoveEvents) {
deleteMessage(it)
safelyWithoutExceptions {
if (settings.autoRemoveEvents) {
deleteMessage(msg)
}
}
val chat = msg.chat.groupChatOrThrow()
var newUsers = msg.chatEvent.members
newUsers.forEach { user ->
restrictChatMember(
chat,
user,
permissions = RestrictionsChatPermissions
)
}
newUsers = if (settings.casEnabled) {
newUsers.filterNot { user ->
casChecker.isBanned(user.id).also { isBanned ->
runCatchingSafely {
if (isBanned) {
reply(
msg
) {
+"User " + mention(user) + " is banned in " + link("CAS System", "https://cas.chat/query?u=${user.id.chatId}")
}
if (settings.kickOnUnsuccess) {
banChatMember(msg.chat.id, user)
}
}
}
}
}
val chat = it.chat.requireGroupChat()
val newUsers = it.chatEvent.members
newUsers.forEach { user ->
restrictChatMember(
chat,
user,
permissions = RestrictionsChatPermissions
)
}
val defaultChatPermissions = (getChat(it.chat) as ExtendedGroupChat).permissions
} else {
newUsers
}
val defaultChatPermissions = LeftRestrictionsChatPermissions
doInSubContext(stopOnCompletion = false) {
launch {
settings.captchaProvider.apply { doAction(it.date, chat, newUsers, defaultChatPermissions) }
}
}
with (settings.captchaProvider) {
doAction(msg.date, chat, newUsers, defaultChatPermissions, adminsAPI, settings.kickOnUnsuccess)
}
}
@@ -266,6 +338,94 @@ class CaptchaBotPlugin : Plugin {
}
}
}
onCommand(enableKickOnUnsuccess) { message ->
message.doAfterVerification(adminsAPI) {
val settings = message.chat.settings()
repo.update(
message.chat.id,
settings.copy(kickOnUnsuccess = true)
)
reply(message, "Ok, new users didn't passed captcha will be kicked").apply {
launchSafelyWithoutExceptions {
delay(5000L)
delete(this@apply)
}
}
if (settings.autoRemoveCommands) {
deleteMessage(message)
}
}
}
onCommand(disableKickOnUnsuccess) { message ->
message.doAfterVerification(adminsAPI) {
val settings = message.chat.settings()
repo.update(
message.chat.id,
settings.copy(kickOnUnsuccess = false)
)
reply(message, "Ok, new users didn't passed captcha will NOT be kicked").apply {
launchSafelyWithoutExceptions {
delay(5000L)
delete(this@apply)
}
}
if (settings.autoRemoveCommands) {
deleteMessage(message)
}
}
}
onCommand(enableCAS) { message ->
message.doAfterVerification(adminsAPI) {
val settings = message.chat.settings()
repo.update(
message.chat.id,
settings.copy(casEnabled = true)
)
reply(message, "Ok, CAS banned user will automatically fail captcha").apply {
launchSafelyWithoutExceptions {
delay(5000L)
delete(this@apply)
}
}
if (settings.autoRemoveCommands) {
deleteMessage(message)
}
}
}
onCommand(disableCAS) { message ->
message.doAfterVerification(adminsAPI) {
val settings = message.chat.settings()
repo.update(
message.chat.id,
settings.copy(casEnabled = false)
)
reply(message, "Ok, CAS banned user will NOT automatically fail captcha").apply {
launchSafelyWithoutExceptions {
delay(5000L)
delete(this@apply)
}
}
if (settings.autoRemoveCommands) {
deleteMessage(message)
}
}
}
}
}
}

View File

@@ -1,29 +1,30 @@
package dev.inmo.plagubot.plugins.captcha
import dev.inmo.plagubot.plugins.captcha.provider.cancelData
import dev.inmo.tgbotapi.extensions.utils.SlotMachineReelImage
import dev.inmo.tgbotapi.extensions.utils.types.buttons.*
import dev.inmo.tgbotapi.libraries.cache.admins.AdminsCacheAPI
import dev.inmo.tgbotapi.types.buttons.InlineKeyboardButtons.CallbackDataInlineKeyboardButton
import dev.inmo.tgbotapi.types.buttons.InlineKeyboardButtons.InlineKeyboardButton
import dev.inmo.tgbotapi.types.buttons.InlineKeyboardMarkup
infix fun String.startingOf(target: String) = target.startsWith(this)
fun slotMachineReplyMarkup(
first: String? = null,
second: String? = null,
third: String? = null,
): InlineKeyboardMarkup {
val texts = when {
first == null -> SlotMachineReelImage.values().map {
CallbackDataInlineKeyboardButton("${it.text}**", it.text)
}
second == null -> SlotMachineReelImage.values().map {
CallbackDataInlineKeyboardButton("$first${it.text}*", it.text)
}
third == null -> SlotMachineReelImage.values().map {
CallbackDataInlineKeyboardButton("$first$second${it.text}", it.text)
}
else -> listOf(CallbackDataInlineKeyboardButton("$first$second$third", "$first$second$third"))
private val buttonsPreset: List<List<InlineKeyboardButton>> = SlotMachineReelImage.values().toList().chunked(2).map {
it.map {
CallbackDataInlineKeyboardButton(it.text, it.text)
}
}
fun slotMachineReplyMarkup(
adminCancelButton: Boolean = false
): InlineKeyboardMarkup {
return inlineKeyboard {
buttonsPreset.forEach(::add)
if (adminCancelButton) {
row {
dataButton("Cancel (Admins only)", cancelData)
}
}
}
return InlineKeyboardMarkup(
texts.chunked(2)
)
}

View File

@@ -0,0 +1,7 @@
package dev.inmo.plagubot.plugins.captcha.cas
import dev.inmo.tgbotapi.types.UserId
interface CASChecker {
suspend fun isBanned(userId: UserId): Boolean
}

View File

@@ -0,0 +1,24 @@
package dev.inmo.plagubot.plugins.captcha.cas
import dev.inmo.tgbotapi.types.UserId
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
class KtorCASChecker(
private val httpClient: HttpClient,
private val json: Json
) : CASChecker {
@Serializable
private data class CheckResponse(
val ok: Boolean
)
override suspend fun isBanned(userId: UserId): Boolean = httpClient.get(
"https://api.cas.chat/check?user_id=${userId.chatId}"
).body<String>().let {
json.decodeFromString(CheckResponse.serializer(), it)
}.ok
}

View File

@@ -33,6 +33,7 @@ class CaptchaChatsSettingsRepo(
private val autoRemoveCommandsColumn = bool("autoRemoveCommands")
private val autoRemoveEventsColumn = bool("autoRemoveEvents").apply { default(true) }
private val enabledColumn = bool("enabled").default(true)
private val kickOnUnsuccessColumn = bool("kick").default(true)
override val primaryKey = PrimaryKey(chatIdColumn)
@@ -46,6 +47,7 @@ class CaptchaChatsSettingsRepo(
it[autoRemoveCommandsColumn] = value.autoRemoveCommands
it[autoRemoveEventsColumn] = value.autoRemoveEvents
it[enabledColumn] = value.enabled
it[kickOnUnsuccessColumn] = value.kickOnUnsuccess
}
override fun update(id: ChatId, value: ChatSettings, it: UpdateStatement) {
@@ -54,6 +56,7 @@ class CaptchaChatsSettingsRepo(
it[autoRemoveCommandsColumn] = value.autoRemoveCommands
it[autoRemoveEventsColumn] = value.autoRemoveEvents
it[enabledColumn] = value.enabled
it[kickOnUnsuccessColumn] = value.kickOnUnsuccess
}
}
@@ -62,7 +65,8 @@ class CaptchaChatsSettingsRepo(
captchaProvider = captchaProviderSerialFormat.decodeFromString(CaptchaProvider.serializer(), get(captchaProviderColumn)),
autoRemoveCommands = get(autoRemoveCommandsColumn),
autoRemoveEvents = get(autoRemoveEventsColumn),
enabled = get(enabledColumn)
enabled = get(enabledColumn),
kickOnUnsuccess = get(kickOnUnsuccessColumn)
)
override val selectById: SqlExpressionBuilder.(ChatId) -> Op<Boolean> = { chatIdColumn.eq(it.chatId) }
@@ -72,7 +76,8 @@ class CaptchaChatsSettingsRepo(
captchaProvider = captchaProviderSerialFormat.decodeFromString(CaptchaProvider.serializer(), get(captchaProviderColumn)),
autoRemoveCommands = get(autoRemoveCommandsColumn),
autoRemoveEvents = get(autoRemoveEventsColumn),
enabled = get(enabledColumn)
enabled = get(enabledColumn),
kickOnUnsuccess = get(kickOnUnsuccessColumn)
)
init {

View File

@@ -1,36 +1,44 @@
package dev.inmo.plagubot.plugins.captcha.provider
import com.benasher44.uuid.uuid4
import com.soywiz.klock.DateTime
import com.soywiz.klock.seconds
import dev.inmo.micro_utils.coroutines.safelyWithResult
import dev.inmo.micro_utils.coroutines.safelyWithoutExceptions
import com.soywiz.klock.*
import dev.inmo.kslog.common.e
import dev.inmo.kslog.common.logger
import dev.inmo.micro_utils.coroutines.*
import dev.inmo.plagubot.plugins.captcha.slotMachineReplyMarkup
import dev.inmo.tgbotapi.extensions.api.answers.answer
import dev.inmo.tgbotapi.extensions.api.answers.answerCallbackQuery
import dev.inmo.tgbotapi.extensions.api.chat.members.*
import dev.inmo.tgbotapi.extensions.api.delete
import dev.inmo.tgbotapi.extensions.api.deleteMessage
import dev.inmo.tgbotapi.extensions.api.edit.ReplyMarkup.editMessageReplyMarkup
import dev.inmo.tgbotapi.extensions.api.send.sendDice
import dev.inmo.tgbotapi.extensions.api.send.sendTextMessage
import dev.inmo.tgbotapi.extensions.api.edit.edit
import dev.inmo.tgbotapi.extensions.api.edit.reply_markup.editMessageReplyMarkup
import dev.inmo.tgbotapi.extensions.api.send.*
import dev.inmo.tgbotapi.extensions.behaviour_builder.*
import dev.inmo.tgbotapi.extensions.behaviour_builder.expectations.waitDataCallbackQuery
import dev.inmo.tgbotapi.extensions.behaviour_builder.expectations.waitMessageDataCallbackQuery
import dev.inmo.tgbotapi.extensions.utils.asSlotMachineReelImage
import dev.inmo.tgbotapi.extensions.utils.calculateSlotMachineResult
import dev.inmo.tgbotapi.extensions.utils.formatting.*
import dev.inmo.tgbotapi.extensions.utils.extensions.sameMessage
import dev.inmo.tgbotapi.extensions.utils.shortcuts.executeUnsafe
import dev.inmo.tgbotapi.extensions.utils.types.buttons.InlineKeyboardMarkup
import dev.inmo.tgbotapi.extensions.utils.shortcuts.sentMessages
import dev.inmo.tgbotapi.extensions.utils.types.buttons.*
import dev.inmo.tgbotapi.libraries.cache.admins.AdminsCacheAPI
import dev.inmo.tgbotapi.requests.DeleteMessage
import dev.inmo.tgbotapi.types.*
import dev.inmo.tgbotapi.types.MessageEntity.textsources.mention
import dev.inmo.tgbotapi.types.buttons.InlineKeyboardButtons.CallbackDataInlineKeyboardButton
import dev.inmo.tgbotapi.types.chat.ChatPermissions
import dev.inmo.tgbotapi.types.chat.LeftRestrictionsChatPermissions
import dev.inmo.tgbotapi.types.chat.abstracts.*
import dev.inmo.tgbotapi.types.chat.*
import dev.inmo.tgbotapi.types.chat.User
import dev.inmo.tgbotapi.types.dice.SlotMachineDiceAnimationType
import dev.inmo.tgbotapi.types.message.abstracts.Message
import dev.inmo.tgbotapi.utils.*
import dev.inmo.tgbotapi.utils.EntitiesBuilder
import dev.inmo.tgbotapi.utils.bold
import dev.inmo.tgbotapi.utils.regular
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.toList
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable
@@ -39,33 +47,140 @@ import kotlin.random.Random
@Serializable
sealed class CaptchaProvider {
abstract suspend fun BehaviourContext.doAction(
abstract val checkTimeSpan: TimeSpan
interface CaptchaProviderWorker {
suspend fun BehaviourContext.doCaptcha(): Boolean
suspend fun BehaviourContext.onCloseCaptcha(passed: Boolean)
}
protected abstract suspend fun allocateWorker(
eventDateTime: DateTime,
chat: GroupChat,
user: User,
leftRestrictionsPermissions: ChatPermissions,
adminsApi: AdminsCacheAPI?,
kickOnUnsuccess: Boolean
): CaptchaProviderWorker
suspend fun BehaviourContext.doAction(
eventDateTime: DateTime,
chat: GroupChat,
newUsers: List<User>,
leftRestrictionsPermissions: ChatPermissions
leftRestrictionsPermissions: ChatPermissions,
adminsApi: AdminsCacheAPI?,
kickOnUnsuccess: Boolean
) {
val userBanDateTime = eventDateTime + checkTimeSpan
newUsers.map { user ->
launch {
createSubContextAndDoWithUpdatesFilter {
val worker = allocateWorker(
eventDateTime,
chat,
user,
leftRestrictionsPermissions,
adminsApi,
kickOnUnsuccess
)
val deferred = async {
runCatchingSafely {
with(worker) {
doCaptcha()
}
}.onFailure {
this@CaptchaProvider.logger.e("Unable to do captcha", it)
}.getOrElse { false }
}
val subscope = LinkedSupervisorScope()
subscope.launch {
delay((userBanDateTime - eventDateTime).millisecondsLong)
subscope.cancel()
}
subscope.launch {
deferred.await()
subscope.cancel()
}
subscope.coroutineContext.job.join()
val passed = runCatching {
deferred.getCompleted()
}.onFailure {
deferred.cancel()
}.getOrElse { false }
when {
passed -> {
safelyWithoutExceptions {
restrictChatMember(
chat,
user,
permissions = leftRestrictionsPermissions
)
}
}
else -> {
send(chat, " ") {
+"User" + mention(user) + underline("didn't passed") + "captcha"
}
if (kickOnUnsuccess) {
banUser(chat, user, leftRestrictionsPermissions)
}
}
}
with(worker) {
onCloseCaptcha(passed)
}
}
}
}.joinAll()
}
}
internal const val cancelData = "cancel"
private fun EntitiesBuilder.mention(user: User, defaultName: String = "User"): EntitiesBuilder {
return mention(
listOfNotNull(
user.lastName.takeIf { it.isNotBlank() }, user.firstName.takeIf { it.isNotBlank() }
).takeIf {
it.isNotEmpty()
}?.joinToString(" ") ?: defaultName,
user
)
}
private suspend fun BehaviourContext.sendAdminCanceledMessage(
chat: Chat,
captchaSolver: User,
admin: User
) {
safelyWithoutExceptions {
send(
chat
) {
mention(admin, "Admin")
regular(" cancelled captcha for ")
mention(captchaSolver)
}
}
}
private suspend fun BehaviourContext.banUser(
chat: PublicChat,
user: User,
leftRestrictionsPermissions: ChatPermissions,
onFailure: suspend BehaviourContext.(Throwable) -> Unit = {
safelyWithResult {
sendTextMessage(
chat,
buildEntities(" ") {
user.mention(
listOfNotNull(
user.lastName.takeIf { it.isNotBlank() }, user.firstName.takeIf { it.isNotBlank() }
).takeIf {
it.isNotEmpty()
} ?.joinToString(" ") ?: "User"
)
+"failed captcha"
}
)
send(
chat
) {
mention(user)
+"failed captcha"
}
}
}
): Result<Boolean> = safelyWithResult {
@@ -77,158 +192,176 @@ private suspend fun BehaviourContext.banUser(
@Serializable
data class SlotMachineCaptchaProvider(
val checkTimeSeconds: Seconds = 300,
val captchaText: String = "solve this captcha: ",
val kick: Boolean = true
val checkTimeSeconds: Seconds = 60,
val captchaText: String = "Solve this captcha: "
) : CaptchaProvider() {
@Transient
private val checkTimeSpan = checkTimeSeconds.seconds
override val checkTimeSpan = checkTimeSeconds.seconds
override suspend fun BehaviourContext.doAction(
private inner class Worker(
private val chat: GroupChat,
private val user: User,
private val adminsApi: AdminsCacheAPI?
) : CaptchaProviderWorker {
private val messagesToDelete = mutableListOf<Message>()
override suspend fun BehaviourContext.doCaptcha(): Boolean {
val baseBuilder: EntitiesBuilderBody = {
mention(user)
regular(", $captchaText")
}
val sentMessage = send(
chat
) {
baseBuilder()
+": ✖✖✖"
}.also { messagesToDelete.add(it) }
val sentDice = sendDice(
sentMessage.chat,
SlotMachineDiceAnimationType,
replyToMessageId = sentMessage.messageId,
replyMarkup = slotMachineReplyMarkup(adminsApi != null)
).also { messagesToDelete.add(it) }
val reels = sentDice.content.dice.calculateSlotMachineResult()!!
val leftToClick = mutableListOf(
reels.left.asSlotMachineReelImage.text,
reels.center.asSlotMachineReelImage.text,
reels.right.asSlotMachineReelImage.text
)
val clicked = mutableListOf<String>()
fun buildTemplate() = "${clicked.joinToString("")}${leftToClick.joinToString("") { "✖" }}"
waitMessageDataCallbackQuery().filter {
when {
!it.message.sameMessage(sentDice) -> false
it.data == cancelData && adminsApi ?.isAdmin(chat.id, it.user.id) == true -> return@filter true
it.data == cancelData && adminsApi ?.isAdmin(chat.id, it.user.id) != true -> {
answer(
it,
"This button is only for admins"
)
false
}
it.user.id != user.id -> {
answer(it, "This button is not for you")
false
}
it.data != leftToClick.first() -> {
answer(it, "Nope")
false
}
else -> {
clicked.add(leftToClick.removeFirst())
answer(it, "Ok, next one")
edit(sentMessage) {
baseBuilder()
+": ${buildTemplate()}"
}
leftToClick.isEmpty()
}
}
}.first()
return true
}
override suspend fun BehaviourContext.onCloseCaptcha(passed: Boolean) {
while (messagesToDelete.isNotEmpty()) {
runCatchingSafely { delete(messagesToDelete.removeFirst()) }
}
}
}
override suspend fun allocateWorker(
eventDateTime: DateTime,
chat: GroupChat,
newUsers: List<User>,
leftRestrictionsPermissions: ChatPermissions
) {
val userBanDateTime = eventDateTime + checkTimeSpan
val authorized = Channel<User>(newUsers.size)
val messagesToDelete = Channel<Message>(Channel.UNLIMITED)
val subContexts = newUsers.map {
doInSubContext(stopOnCompletion = false) {
val sentMessage = sendTextMessage(
chat,
buildEntities {
+it.mention(it.firstName)
regular(", ${captchaText}")
}
).also { messagesToDelete.send(it) }
val sentDice = sendDice(
sentMessage.chat,
SlotMachineDiceAnimationType,
replyToMessageId = sentMessage.messageId,
replyMarkup = slotMachineReplyMarkup()
).also { messagesToDelete.send(it) }
val reels = sentDice.content.dice.calculateSlotMachineResult()!!
val leftToClick = mutableListOf(
reels.left.asSlotMachineReelImage.text,
reels.center.asSlotMachineReelImage.text,
reels.right.asSlotMachineReelImage.text
)
launch {
val clicked = arrayOf<String?>(null, null, null)
while (leftToClick.isNotEmpty()) {
val userClicked = waitDataCallbackQuery { if (user.id == it.id) this else null }.first()
if (userClicked.data == leftToClick.first()) {
clicked[3 - leftToClick.size] = leftToClick.removeAt(0)
if (clicked.contains(null)) {
safelyWithoutExceptions { answerCallbackQuery(userClicked, "Ok, next one") }
editMessageReplyMarkup(sentDice, slotMachineReplyMarkup(clicked[0], clicked[1], clicked[2]))
} else {
safelyWithoutExceptions { answerCallbackQuery(userClicked, "Thank you and welcome", showAlert = true) }
safelyWithoutExceptions { deleteMessage(sentMessage) }
safelyWithoutExceptions { deleteMessage(sentDice) }
}
} else {
safelyWithoutExceptions { answerCallbackQuery(userClicked, "Nope") }
}
}
authorized.send(it)
safelyWithoutExceptions { restrictChatMember(chat, it, permissions = leftRestrictionsPermissions) }
stop()
}
this to it
}
}
delay((userBanDateTime - eventDateTime).millisecondsLong)
authorized.close()
val authorizedUsers = authorized.toList()
subContexts.forEach { (context, user) ->
if (user !in authorizedUsers) {
context.stop()
if (kick) {
banUser(chat, user, leftRestrictionsPermissions)
}
}
}
messagesToDelete.close()
for (message in messagesToDelete) {
executeUnsafe(DeleteMessage(message.chat.id, message.messageId), retries = 0)
}
}
user: User,
leftRestrictionsPermissions: ChatPermissions,
adminsApi: AdminsCacheAPI?,
kickOnUnsuccess: Boolean
): CaptchaProviderWorker = Worker(chat, user, adminsApi)
}
@Serializable
data class SimpleCaptchaProvider(
val checkTimeSeconds: Seconds = 60,
val captchaText: String = "press this button to pass captcha:",
val buttonText: String = "Press me\uD83D\uDE0A",
val kick: Boolean = true
val buttonText: String = "Press me\uD83D\uDE0A"
) : CaptchaProvider() {
@Transient
private val checkTimeSpan = checkTimeSeconds.seconds
override val checkTimeSpan = checkTimeSeconds.seconds
override suspend fun BehaviourContext.doAction(
eventDateTime: DateTime,
chat: GroupChat,
newUsers: List<User>,
leftRestrictionsPermissions: ChatPermissions
) {
val userBanDateTime = eventDateTime + checkTimeSpan
newUsers.mapNotNull {
safelyWithoutExceptions {
launch {
doInSubContext(stopOnCompletion = false) {
val callbackData = uuid4().toString()
val sentMessage = sendTextMessage(
chat,
buildEntities {
+it.mention(it.firstName)
regular(", $captchaText")
},
replyMarkup = InlineKeyboardMarkup(
CallbackDataInlineKeyboardButton(buttonText, callbackData)
)
)
suspend fun removeRedundantMessages() {
safelyWithoutExceptions {
deleteMessage(sentMessage)
}
private inner class Worker(
private val chat: GroupChat,
private val user: User,
private val adminsApi: AdminsCacheAPI?
) : CaptchaProviderWorker {
private var sentMessage: Message? = null
override suspend fun BehaviourContext.doCaptcha(): Boolean {
val callbackData = uuid4().toString()
val sentMessage = send(
chat,
replyMarkup = inlineKeyboard {
row {
dataButton(buttonText, callbackData)
}
if (adminsApi != null) {
row {
dataButton("Cancel (Admins only)", cancelData)
}
val job = parallel {
waitDataCallbackQuery {
if (it.id == user.id && this.data == callbackData) {
this
} else {
null
}
}.first()
removeRedundantMessages()
safelyWithoutExceptions { restrictChatMember(chat, it, permissions = leftRestrictionsPermissions) }
stop()
}
delay((userBanDateTime - eventDateTime).millisecondsLong)
if (job.isActive) {
job.cancel()
if (kick) {
banUser(chat, it, leftRestrictionsPermissions)
}
}
stop()
}
}
) {
mention(user)
regular(", $captchaText")
}
}.joinAll()
this@Worker.sentMessage = sentMessage
val pushed = waitMessageDataCallbackQuery().filter {
when {
!it.message.sameMessage(sentMessage) -> false
it.data == callbackData && it.user.id == user.id -> true
it.data == cancelData && (adminsApi ?.isAdmin(chat.id, it.user.id) == true) -> true
it.data == callbackData -> {
answer(it, "This button is not for you")
false
}
it.data == cancelData -> {
answer(it, "This button is for admins only")
false
}
else -> false
}
}.first()
answer(
pushed,
when (pushed.data) {
cancelData -> "You have cancelled captcha"
else -> "Ok, thanks"
}
)
return true
}
override suspend fun BehaviourContext.onCloseCaptcha(passed: Boolean) {
sentMessage ?.let {
delete(it)
}
}
}
override suspend fun allocateWorker(
eventDateTime: DateTime,
chat: GroupChat,
user: User,
leftRestrictionsPermissions: ChatPermissions,
adminsApi: AdminsCacheAPI?,
kickOnUnsuccess: Boolean
): CaptchaProviderWorker = Worker(chat, user, adminsApi)
}
private object ExpressionBuilder {
@@ -238,15 +371,19 @@ private object ExpressionBuilder {
override fun Int.perform(other: Int): Int = plus(other)
}
object MinusExpressionOperation : ExpressionOperation() {
override fun asString(): String = "-"
override fun Int.perform(other: Int): Int = minus(other)
}
abstract fun asString(): String
abstract fun Int.perform(other: Int): Int
}
private val experssions = listOf(ExpressionOperation.PlusExpressionOperation, ExpressionOperation.MinusExpressionOperation)
private val experssions =
listOf(ExpressionOperation.PlusExpressionOperation, ExpressionOperation.MinusExpressionOperation)
private fun createNumber(max: Int) = Random.nextInt(max + 1)
fun generateResult(max: Int, operationsNumber: Int = 1): Int {
@@ -258,6 +395,7 @@ private object ExpressionBuilder {
}
return current
}
fun createExpression(max: Int, operationsNumber: Int = 1): Pair<Int, String> {
val operations = (0 until operationsNumber).map { experssions.random() }
var current = createNumber(max)
@@ -279,103 +417,90 @@ data class ExpressionCaptchaProvider(
val maxPerNumber: Int = 10,
val operations: Int = 2,
val answers: Int = 6,
val attempts: Int = 3,
val kick: Boolean = true
val attempts: Int = 3
) : CaptchaProvider() {
@Transient
private val checkTimeSpan = checkTimeSeconds.seconds
override val checkTimeSpan = checkTimeSeconds.seconds
override suspend fun BehaviourContext.doAction(
private inner class Worker(
private val chat: GroupChat,
private val user: User,
private val adminsApi: AdminsCacheAPI?
) : CaptchaProviderWorker {
private var sentMessage: Message? = null
override suspend fun BehaviourContext.doCaptcha(): Boolean {
val callbackData = ExpressionBuilder.createExpression(
maxPerNumber,
operations
)
val correctAnswer = callbackData.first.toString()
val answers = (0 until answers - 1).map {
ExpressionBuilder.generateResult(maxPerNumber, operations)
}.toMutableList().also { orderedAnswers ->
val correctAnswerPosition = Random.nextInt(orderedAnswers.size)
orderedAnswers.add(correctAnswerPosition, callbackData.first)
}.toList()
val sentMessage = send(
chat,
replyMarkup = inlineKeyboard {
answers.map {
CallbackDataInlineKeyboardButton(it.toString(), it.toString())
}.chunked(3).forEach(::add)
if (adminsApi != null) {
row {
dataButton("Cancel (Admins only)", cancelData)
}
}
}
) {
mention(user)
regular(", $captchaText ")
bold(callbackData.second)
}.also {
sentMessage = it
}
var leftAttempts = attempts
return waitMessageDataCallbackQuery().takeWhile { leftAttempts > 0 }.map { query ->
val baseCheck = query.message.messageId == sentMessage.messageId
val dataCorrect = (query.user.id == user.id && query.data == correctAnswer)
val adminCanceled = (query.data == cancelData && (adminsApi?.isAdmin(
sentMessage.chat.id,
query.user.id
)) == true)
baseCheck && if (dataCorrect || adminCanceled) {
if (adminCanceled) {
sendAdminCanceledMessage(
sentMessage.chat,
user,
query.user
)
}
true
} else {
leftAttempts--
if (leftAttempts > 0) {
answerCallbackQuery(query, leftRetriesText + leftAttempts)
}
false
}
}.firstOrNull() ?: false
}
override suspend fun BehaviourContext.onCloseCaptcha(passed: Boolean) {
sentMessage ?.let {
delete(it)
}
}
}
override suspend fun allocateWorker(
eventDateTime: DateTime,
chat: GroupChat,
newUsers: List<User>,
leftRestrictionsPermissions: ChatPermissions
) {
val userBanDateTime = eventDateTime + checkTimeSpan
newUsers.map { user ->
launch {
doInSubContext {
val callbackData = ExpressionBuilder.createExpression(
maxPerNumber,
operations
)
val correctAnswer = callbackData.first.toString()
val answers = (0 until answers - 1).map {
ExpressionBuilder.generateResult(maxPerNumber, operations)
}.toMutableList().also { orderedAnswers ->
val correctAnswerPosition = Random.nextInt(orderedAnswers.size)
orderedAnswers.add(correctAnswerPosition, callbackData.first)
}.toList()
val sentMessage = sendTextMessage(
chat,
buildEntities {
+user.mention(user.firstName)
regular(", $captchaText ")
bold(callbackData.second)
},
replyMarkup = dev.inmo.tgbotapi.types.buttons.InlineKeyboardMarkup(
answers.map {
CallbackDataInlineKeyboardButton(it.toString(), it.toString())
}.chunked(3)
)
)
suspend fun removeRedundantMessages() {
safelyWithoutExceptions {
deleteMessage(sentMessage)
}
}
var passed: Boolean? = null
val passedMutex = Mutex()
val callback: suspend (Boolean) -> Unit = {
passedMutex.withLock {
if (passed == null) {
removeRedundantMessages()
passed = it
if (it) {
safelyWithoutExceptions { restrictChatMember(chat, user, permissions = leftRestrictionsPermissions) }
} else {
if (kick) {
banUser(chat, user, leftRestrictionsPermissions)
}
}
}
}
}
val banJob = launch {
delay((userBanDateTime - eventDateTime).millisecondsLong)
if (passed == null) {
callback(false)
stop()
}
}
var leftAttempts = attempts
waitDataCallbackQuery {
when {
this.user.id != user.id -> null
this.data != correctAnswer -> {
leftAttempts--
if (leftAttempts < 1) {
this
} else {
answerCallbackQuery(this@waitDataCallbackQuery, leftRetriesText + leftAttempts)
null
}
}
else -> this
}
}.first()
banJob.cancel()
callback(leftAttempts > 0)
}
}
}.joinAll()
}
user: User,
leftRestrictionsPermissions: ChatPermissions,
adminsApi: AdminsCacheAPI?,
kickOnUnsuccess: Boolean
): CaptchaProviderWorker = Worker(chat, user, adminsApi)
}

View File

@@ -11,5 +11,7 @@ data class ChatSettings(
val captchaProvider: CaptchaProvider = SimpleCaptchaProvider(),
val autoRemoveCommands: Boolean = false,
val autoRemoveEvents: Boolean = true,
val enabled: Boolean = true
val kickOnUnsuccess: Boolean = true,
val enabled: Boolean = true,
val casEnabled: Boolean = false
)