├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── src └── main │ ├── resources │ └── Roboto-Regular.ttf │ ├── java │ └── cc │ │ └── ioctl │ │ └── neoauth3bot │ │ ├── util │ │ ├── IndexFrom.java │ │ ├── SdfUtils.java │ │ └── BinaryUtils.java │ │ └── chiral │ │ ├── ChiralCarbonHelper.java │ │ ├── MdlMolParser.java │ │ ├── Molecule.java │ │ └── MoleculeRender.java │ └── kotlin │ └── cc │ └── ioctl │ ├── neoauth3bot │ ├── Utils.kt │ ├── svc │ │ ├── SysVmService.kt │ │ ├── FilterService.kt │ │ └── LogDatabaseService.kt │ ├── HypervisorCommandHandler.kt │ ├── dat │ │ ├── AnointedManager.kt │ │ ├── ChemTableIndex.kt │ │ └── ChemDatabase.kt │ ├── res │ │ ├── Resources.kt │ │ └── ResImpl.kt │ ├── LocaleHelper.kt │ ├── cli │ │ ├── TestRender.kt │ │ └── MoleculeFilter.kt │ ├── SessionManager.kt │ ├── EventLogs.kt │ ├── AdminConfigInterface.kt │ ├── AuthUserInterface.kt │ └── NeoAuth3Bot.kt │ └── misc │ └── InlineBotMsgCleaner.kt ├── gradle.properties ├── .gitignore ├── settings.gradle.kts ├── gradlew.bat └── gradlew /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cinit/NeoAuthBotPlugin/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /src/main/resources/Roboto-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cinit/NeoAuthBotPlugin/HEAD/src/main/resources/Roboto-Regular.ttf -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Specifies the JVM arguments used for the daemon process. 2 | # The setting is particularly useful for tweaking memory settings. 3 | org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 -XX:+UseParallelGC 4 | kotlin.code.style=official 5 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists -------------------------------------------------------------------------------- /src/main/java/cc/ioctl/neoauth3bot/util/IndexFrom.java: -------------------------------------------------------------------------------- 1 | package cc.ioctl.neoauth3bot.util; 2 | 3 | import java.lang.annotation.Retention; 4 | import java.lang.annotation.RetentionPolicy; 5 | import java.lang.annotation.Target; 6 | 7 | import static java.lang.annotation.ElementType.*; 8 | 9 | @Target({TYPE, METHOD, CONSTRUCTOR, FIELD, PARAMETER, LOCAL_VARIABLE}) 10 | @Retention(RetentionPolicy.CLASS) 11 | public @interface IndexFrom { 12 | int value(); 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | build/ 3 | !gradle/wrapper/gradle-wrapper.jar 4 | !**/src/main/**/build/ 5 | !**/src/test/**/build/ 6 | 7 | # Local configuration file (sdk path, etc) 8 | local.properties 9 | 10 | ### IntelliJ IDEA ### 11 | .idea 12 | .idea/modules.xml 13 | .idea/jarRepositories.xml 14 | .idea/compiler.xml 15 | .idea/libraries/ 16 | *.iws 17 | *.iml 18 | *.ipr 19 | out/ 20 | !**/src/main/**/out/ 21 | !**/src/test/**/out/ 22 | 23 | ### Eclipse ### 24 | .apt_generated 25 | .classpath 26 | .factorypath 27 | .project 28 | .settings 29 | .springBeans 30 | .sts4-cache 31 | bin/ 32 | !**/src/main/**/bin/ 33 | !**/src/test/**/bin/ 34 | 35 | ### NetBeans ### 36 | /nbproject/private/ 37 | /nbbuild/ 38 | /dist/ 39 | /nbdist/ 40 | /.nb-gradle/ 41 | 42 | ### VS Code ### 43 | .vscode/ 44 | 45 | ### Mac OS ### 46 | .DS_Store 47 | 48 | cmake-build-* 49 | -------------------------------------------------------------------------------- /src/main/java/cc/ioctl/neoauth3bot/util/SdfUtils.java: -------------------------------------------------------------------------------- 1 | package cc.ioctl.neoauth3bot.util; 2 | 3 | import cc.ioctl.telebot.util.IoUtils; 4 | import org.jetbrains.annotations.NotNull; 5 | 6 | import java.io.*; 7 | 8 | public class SdfUtils { 9 | 10 | private SdfUtils() { 11 | throw new IllegalStateException("no instance"); 12 | } 13 | 14 | /** 15 | * Check whether the given file is a Blocked GNU Zip Format file. 16 | * 17 | * @param file the file to check 18 | * @return true if the file is a Blocked GNU Zip Format file 19 | * @throws IOException if the file could not be read, e.g. because it does not exist 20 | */ 21 | public static boolean isBgzFile(@NotNull File file) throws IOException { 22 | try (InputStream is = new FileInputStream(file)) { 23 | byte[] buf = new byte[4]; 24 | IoUtils.readExact(is, buf, 0, 4); 25 | return buf[0] == (byte) 0x1F && buf[1] == (byte) 0x8B && buf[2] == (byte) 0x8 && buf[3] == (byte) 0x4; 26 | } 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("UnstableApiUsage") 2 | 3 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") 4 | 5 | rootProject.name = "NeoAuth3BotPlugin" 6 | 7 | fun getLocalProperty(baseDir: File, propertyName: String): String? { 8 | val localProp = File(baseDir, "local.properties") 9 | if (!localProp.exists()) { 10 | return null 11 | } 12 | val localProperties = java.util.Properties() 13 | localProp.inputStream().use { 14 | localProperties.load(it) 15 | } 16 | return localProperties.getProperty(propertyName, null) 17 | } 18 | 19 | val gTelebotDirPath = getLocalProperty(rootProject.projectDir, "telebotconsole.project.dir") 20 | if (gTelebotDirPath.isNullOrEmpty()) { 21 | throw IllegalArgumentException("telebotconsole.project.dir is not set, please set it in local.properties") 22 | } 23 | val gTelebotDir = File(gTelebotDirPath!!) 24 | if (!gTelebotDir.isDirectory) { 25 | throw IllegalArgumentException("telebotconsole.project.dir: $gTelebotDirPath is not a directory") 26 | } 27 | 28 | val core = "cc.ioctl.telebot:core:1.0" 29 | 30 | dependencyResolutionManagement { 31 | versionCatalogs { 32 | create("external") { 33 | library("core", core) 34 | } 35 | } 36 | } 37 | 38 | includeBuild(gTelebotDir) { 39 | dependencySubstitution { 40 | substitute(module(core)).using(project(":core")) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/kotlin/cc/ioctl/neoauth3bot/Utils.kt: -------------------------------------------------------------------------------- 1 | package cc.ioctl.neoauth3bot 2 | 3 | import cc.ioctl.telebot.EventHandler 4 | import cc.ioctl.telebot.tdlib.obj.Bot 5 | import cc.ioctl.telebot.tdlib.obj.SessionInfo 6 | import cc.ioctl.telebot.tdlib.tlrpc.api.msg.Message 7 | import com.google.gson.JsonObject 8 | 9 | 10 | fun Bot.doOnNextMessage(si0: SessionInfo, receiver: (senderId: Long, message: Message) -> Boolean) { 11 | check(this.isAuthenticated && this.userId > 0) { "bot is not authenticated" } 12 | val listener = object : EventHandler.MessageListenerV1 { 13 | override fun onReceiveMessage(bot: Bot, si1: SessionInfo, senderId: Long, message: Message): Boolean { 14 | return if (si0 == si1) { 15 | bot.unregisterOnReceiveMessageListener(this) 16 | receiver(senderId, message) 17 | } else { 18 | false 19 | } 20 | } 21 | 22 | override fun onDeleteMessages(bot: Bot, si: SessionInfo, msgIds: List): Boolean { 23 | return false 24 | } 25 | 26 | override fun onUpdateMessageContent(bot: Bot, si: SessionInfo, msgId: Long, content: JsonObject): Boolean { 27 | return false 28 | } 29 | 30 | override fun onMessageEdited(bot: Bot, si: SessionInfo, msgId: Long, editDate: Int): Boolean { 31 | return false 32 | } 33 | } 34 | this.registerOnReceiveMessageListener(listener) 35 | } 36 | -------------------------------------------------------------------------------- /src/main/kotlin/cc/ioctl/neoauth3bot/svc/SysVmService.kt: -------------------------------------------------------------------------------- 1 | package cc.ioctl.neoauth3bot.svc 2 | 3 | import cc.ioctl.neoauth3bot.HypervisorCommandHandler 4 | import cc.ioctl.neoauth3bot.NeoAuth3Bot 5 | import cc.ioctl.telebot.tdlib.obj.Bot 6 | import cc.ioctl.telebot.tdlib.obj.SessionInfo 7 | 8 | object SysVmService : HypervisorCommandHandler.HvCmdCallback { 9 | 10 | override suspend fun onSupervisorCommand( 11 | bot: Bot, 12 | si: SessionInfo, 13 | senderId: Long, 14 | serviceCmd: String, 15 | args: Array 16 | ): String { 17 | when (serviceCmd) { 18 | "exit" -> { 19 | System.exit(0) 20 | error("System.exit() returned") 21 | } 22 | else -> { 23 | return "ENOSYS" 24 | } 25 | } 26 | } 27 | 28 | fun getUptimeString(): String { 29 | val start = NeoAuth3Bot.BOT_START_TIME 30 | val now = System.currentTimeMillis() 31 | val uptime = (now - start) / 1000 32 | val d = uptime / (60 * 60 * 24) 33 | val h = (uptime / (60 * 60)) % 24 34 | val m = (uptime / 60) % 60 35 | val s = uptime % 60 36 | val sb = StringBuilder("Uptime: ") 37 | if (d > 0) { 38 | sb.append(d).append("d ") 39 | } 40 | if (h > 0) { 41 | sb.append(h).append("h ") 42 | } 43 | if (m > 0) { 44 | sb.append(m).append("m ") 45 | } 46 | sb.append(s).append("s") 47 | return sb.toString() 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /src/main/kotlin/cc/ioctl/neoauth3bot/HypervisorCommandHandler.kt: -------------------------------------------------------------------------------- 1 | package cc.ioctl.neoauth3bot 2 | 3 | import cc.ioctl.misc.InlineBotMsgCleaner 4 | import cc.ioctl.neoauth3bot.svc.FilterService 5 | import cc.ioctl.neoauth3bot.svc.LogDatabaseService 6 | import cc.ioctl.neoauth3bot.svc.SysVmService 7 | import cc.ioctl.telebot.tdlib.obj.Bot 8 | import cc.ioctl.telebot.tdlib.obj.SessionInfo 9 | 10 | object HypervisorCommandHandler { 11 | 12 | private const val TAG = "HypervisorCommandHandler" 13 | 14 | interface HvCmdCallback { 15 | suspend fun onSupervisorCommand( 16 | bot: Bot, si: SessionInfo, senderId: Long, serviceCmd: String, args: Array 17 | ): String? 18 | } 19 | 20 | suspend fun onSupervisorCommand( 21 | bot: Bot, 22 | si: SessionInfo, 23 | senderId: Long, 24 | serviceName: String, 25 | serviceCmd: String, 26 | args: Array, 27 | origMsgId: Long 28 | ) { 29 | val service: HvCmdCallback? = when (serviceName) { 30 | "pf" -> FilterService 31 | "sys" -> SysVmService 32 | "db" -> LogDatabaseService 33 | "ic" -> InlineBotMsgCleaner 34 | else -> null 35 | } 36 | if (service != null) { 37 | val ret = service.onSupervisorCommand(bot, si, senderId, serviceCmd, args) 38 | if (!ret.isNullOrEmpty()) { 39 | bot.sendMessageForText(si, ret, replyMsgId = origMsgId) 40 | } 41 | } else { 42 | bot.sendMessageForText(si, "Unknown service: '$serviceName'", replyMsgId = origMsgId) 43 | } 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /src/main/java/cc/ioctl/neoauth3bot/util/BinaryUtils.java: -------------------------------------------------------------------------------- 1 | package cc.ioctl.neoauth3bot.util; 2 | 3 | import org.jetbrains.annotations.NotNull; 4 | 5 | import java.io.IOException; 6 | import java.io.InputStream; 7 | 8 | public class BinaryUtils { 9 | 10 | private BinaryUtils() { 11 | throw new IllegalStateException("no instance"); 12 | } 13 | 14 | public static int readLe32(@NotNull byte[] buf, int index) { 15 | return buf[index] & 0xFF | (buf[index + 1] << 8) & 0xff00 16 | | (buf[index + 2] << 16) & 0xff0000 | (buf[index + 3] << 24) & 0xff000000; 17 | } 18 | 19 | public static int readLe16(@NotNull byte[] buf, int off) { 20 | return (buf[off] & 0xFF) | ((buf[off + 1] << 8) & 0xff00); 21 | } 22 | 23 | public static long readLe64(@NotNull byte[] buf, int off) { 24 | return ((long) readLe32(buf, off) & 0xFFFFFFFFL) | (((long) readLe32(buf, off + 4) & 0xFFFFFFFFL) << 32); 25 | } 26 | 27 | public static void writeLe16(@NotNull byte[] buf, int off, int value) { 28 | buf[off] = (byte) (value & 0xFF); 29 | buf[off + 1] = (byte) ((value >> 8) & 0xFF); 30 | } 31 | 32 | public static void writeLe32(@NotNull byte[] buf, int index, int value) { 33 | buf[index] = (byte) value; 34 | buf[index + 1] = (byte) (value >>> 8); 35 | buf[index + 2] = (byte) (value >>> 16); 36 | buf[index + 3] = (byte) (value >>> 24); 37 | } 38 | 39 | public static void writeLe64(@NotNull byte[] buf, int index, long value) { 40 | writeLe32(buf, index, (int) value); 41 | writeLe32(buf, index + 4, (int) (value >>> 32)); 42 | } 43 | 44 | public static int readLe32(@NotNull InputStream is) throws IOException { 45 | byte b1 = (byte) is.read(); 46 | byte b2 = (byte) is.read(); 47 | byte b3 = (byte) is.read(); 48 | byte b4 = (byte) is.read(); 49 | return (b1 & 0xFF) | ((b2 << 8) & 0xff00) | ((b3 << 16) & 0xff0000) | ((b4 << 24) & 0xff000000); 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /src/main/kotlin/cc/ioctl/neoauth3bot/dat/AnointedManager.kt: -------------------------------------------------------------------------------- 1 | package cc.ioctl.neoauth3bot.dat 2 | 3 | import cc.ioctl.telebot.tdlib.RobotServer 4 | import java.io.File 5 | import java.io.RandomAccessFile 6 | 7 | object AnointedManager { 8 | 9 | private val mLock = Any() 10 | 11 | @JvmStatic 12 | private fun swap64(v: Long): Long { 13 | val b0 = (v and 0xff).toInt() 14 | val b1 = ((v shr 8) and 0xff).toInt() 15 | val b2 = ((v shr 16) and 0xff).toInt() 16 | val b3 = ((v shr 24) and 0xff).toInt() 17 | val b4 = ((v shr 32) and 0xff).toInt() 18 | val b5 = ((v shr 40) and 0xff).toInt() 19 | val b6 = ((v shr 48) and 0xff).toInt() 20 | val b7 = ((v shr 56) and 0xff).toInt() 21 | return (b0.toLong() shl 56) or (b1.toLong() shl 48) or (b2.toLong() shl 40) or (b3.toLong() shl 32) or (b4.toLong() shl 24) or (b5.toLong() shl 16) or (b6.toLong() shl 8) or b7.toLong() 22 | } 23 | 24 | fun getAnointedStatus(gid: Long, uid: Long): Int { 25 | check(gid > 0) { "invalid gid: $gid" } 26 | check(uid > 0) { "invalid uid: $uid" } 27 | val groupBaseDir = File(RobotServer.instance.pluginsDir, "groups" + File.separator + "g_$gid") 28 | val anointedFile = File(groupBaseDir, "anointed.bin") 29 | if (!anointedFile.exists()) { 30 | return 0 31 | } 32 | synchronized(mLock) { 33 | val size = anointedFile.length().toInt() 34 | val count = size / 8 35 | if (size % 8 != 0) { 36 | error("invalid anointed file size: $size") 37 | } 38 | // binary search, little endian int64 39 | RandomAccessFile(anointedFile, "r").use { raf -> 40 | var l = 0 41 | var r = count - 1 42 | while (l <= r) { 43 | val mid = (l + r) / 2 44 | raf.seek(mid * 8L) 45 | val v = swap64(raf.readLong()) 46 | if (v == uid) { 47 | return 1 48 | } else if (v < uid) { 49 | l = mid + 1 50 | } else { 51 | r = mid - 1 52 | } 53 | } 54 | return 0 55 | } 56 | } 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /src/main/kotlin/cc/ioctl/neoauth3bot/res/Resources.kt: -------------------------------------------------------------------------------- 1 | package cc.ioctl.neoauth3bot.res 2 | 3 | import java.util.* 4 | 5 | interface Resources { 6 | 7 | val btn_text_change_quiz: String 8 | val btn_text_reset: String 9 | val btn_text_submit: String 10 | val btn_text_cancel: String 11 | 12 | val auth_instruction_part1: String 13 | val auth_instruction_part2: String 14 | val auth_instruction_part3: String 15 | val auth_ins_fail_on_timeout: String 16 | val auth_change_quiz_chances_left_part1: String 17 | val auth_change_quiz_chances_left_part2: String 18 | val auth_hint_all_region_count_part1: String 19 | val auth_hint_all_region_count_part2: String 20 | val auth_current_selected_regions: String 21 | val auth_none_selected: String 22 | 23 | val msg_text_auth_pass_va1: String 24 | val msg_text_approve_success: String 25 | val msg_text_error_denied_by_other_admin: String 26 | val msg_text_join_auth_required_notice_va2: String 27 | val msg_text_too_many_requests: String 28 | val msg_text_loading: String 29 | val msg_text_command_use_in_group_only: String 30 | val msg_text_command_use_in_private_chat_only: String 31 | val msg_text_no_auth_required: String 32 | val msg_text_approved_manually_by_admin_va1: String 33 | val msg_text_dismissed_manually_by_admin_va1: String 34 | val msg_text_banned_manually_by_admin_va1: String 35 | 36 | val cb_query_auth_session_not_found: String 37 | val cb_query_auth_fail_retry: String 38 | val cb_query_auth_pass: String 39 | val cb_query_selected_va1: String 40 | val cb_query_unselected_va1: String 41 | val cb_query_reset_region: String 42 | val cb_query_change_quiz_wip: String 43 | 44 | val help_info_category_auth: String 45 | val help_info_category_auth_description: String 46 | val help_info_category_test: String 47 | val help_info_category_test_description: String 48 | val help_info_category_other: String 49 | val help_info_category_other_description: String 50 | val help_info_require_at_in_group_va1: String 51 | 52 | val help_about_desc1_part1: String 53 | val help_about_desc1_part2: String 54 | val help_about_desc1_part3: String 55 | val help_about_desc2_part1: String 56 | val help_about_desc2_part2: String 57 | val help_about_desc3: String 58 | val help_about_discussion_group_link_va1: String 59 | 60 | val btn_text_verify_anony_identity: String 61 | val msg_text_anonymous_admin_identity_verification_required: String 62 | val cb_query_admin_permission_required: String 63 | val cb_query_nothing_to_do_with_you: String 64 | 65 | fun format(fm: String, vararg args: Any): String { 66 | return String.format(Locale.ROOT, fm, *args) 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /src/main/kotlin/cc/ioctl/neoauth3bot/dat/ChemTableIndex.kt: -------------------------------------------------------------------------------- 1 | package cc.ioctl.neoauth3bot.dat 2 | 3 | import cc.ioctl.neoauth3bot.util.BinaryUtils 4 | 5 | class ChemTableIndex(val ptrBytes: ByteArray, ptrStartOffset: Int = 0) { 6 | 7 | //struct ChemTableIndex { 8 | // uint32_t cid; +0 9 | // uint32_t _totalCount; +4 10 | // uint64_t oechem; +8 11 | // uint64_t offset; +16 12 | // uint32_t size; +24 13 | // uint32_t unused4_1; +28 14 | // uint16_t atomCount; +32 15 | // uint16_t bondCount; +34 16 | // uint16_t cactvsComplexity; +36 17 | // uint8_t isChiral; +38 18 | // uint8_t _unused0_1; +39 19 | //}; 20 | 21 | var ptrStartOffset: Int = ptrStartOffset 22 | set(value) { 23 | checkBounds(start = value) 24 | field = value 25 | } 26 | 27 | init { 28 | checkBounds() 29 | } 30 | 31 | private fun checkBounds(bytes: ByteArray = this.ptrBytes, start: Int = this.ptrStartOffset) { 32 | if (start < 0) { 33 | throw IllegalArgumentException("start < 0") 34 | } 35 | if (start + OBJECT_SIZE > bytes.size) { 36 | throw IllegalArgumentException("start + size > bytes.size") 37 | } 38 | } 39 | 40 | var cid: Int 41 | get() = BinaryUtils.readLe32(ptrBytes, ptrStartOffset + 0) 42 | set(value) = BinaryUtils.writeLe32(ptrBytes, ptrStartOffset + 0, value) 43 | 44 | var _totalCount: Int 45 | get() = BinaryUtils.readLe32(ptrBytes, ptrStartOffset + 4) 46 | set(value) = BinaryUtils.writeLe32(ptrBytes, ptrStartOffset + 4, value) 47 | 48 | var oechem: Long 49 | get() = BinaryUtils.readLe64(ptrBytes, ptrStartOffset + 8) 50 | set(value) = BinaryUtils.writeLe64(ptrBytes, ptrStartOffset + 8, value) 51 | 52 | var offset: Long 53 | get() = BinaryUtils.readLe64(ptrBytes, ptrStartOffset + 16) 54 | set(value) = BinaryUtils.writeLe64(ptrBytes, ptrStartOffset + 16, value) 55 | 56 | var size: Int 57 | get() = BinaryUtils.readLe32(ptrBytes, ptrStartOffset + 24) 58 | set(value) = BinaryUtils.writeLe32(ptrBytes, ptrStartOffset + 24, value) 59 | 60 | var atomCount: Int 61 | get() = BinaryUtils.readLe16(ptrBytes, ptrStartOffset + 32) 62 | set(value) = BinaryUtils.writeLe16(ptrBytes, ptrStartOffset + 32, value) 63 | 64 | var bondCount: Int 65 | get() = BinaryUtils.readLe16(ptrBytes, ptrStartOffset + 34) 66 | set(value) = BinaryUtils.writeLe16(ptrBytes, ptrStartOffset + 34, value) 67 | 68 | var cactvsComplexity: Int 69 | get() = BinaryUtils.readLe16(ptrBytes, ptrStartOffset + 36) 70 | set(value) = BinaryUtils.writeLe16(ptrBytes, ptrStartOffset + 36, value) 71 | 72 | var isChiral: Boolean 73 | get() = ptrBytes[ptrStartOffset + 38] != 0.toByte() 74 | set(value) { 75 | ptrBytes[ptrStartOffset + 38] = (if (value) 1.toByte() else 0.toByte()) 76 | } 77 | 78 | override fun toString(): String { 79 | return "ChemTableIndex(cid=$cid, _totalCount=$_totalCount, oechem=$oechem, offset=$offset, size=$size, atomCount=$atomCount, bondCount=$bondCount, cactvsComplexity=$cactvsComplexity, isChiral=$isChiral)" 80 | } 81 | 82 | companion object { 83 | const val OBJECT_SIZE = 40 84 | } 85 | 86 | } 87 | -------------------------------------------------------------------------------- /src/main/kotlin/cc/ioctl/neoauth3bot/LocaleHelper.kt: -------------------------------------------------------------------------------- 1 | package cc.ioctl.neoauth3bot 2 | 3 | import cc.ioctl.neoauth3bot.res.ResImpl 4 | import cc.ioctl.telebot.tdlib.obj.Bot 5 | import cc.ioctl.telebot.tdlib.obj.User 6 | import cc.ioctl.telebot.tdlib.tlrpc.api.msg.FormattedText 7 | import cc.ioctl.telebot.tdlib.tlrpc.api.msg.FormattedTextBuilder 8 | 9 | @Suppress("unused") 10 | object LocaleHelper { 11 | 12 | internal var discussionGroupLink: String? = null 13 | 14 | fun createFormattedMsgText( 15 | info: SessionManager.UserAuthSession, 16 | user: User, 17 | maxDuration: Int 18 | ): FormattedText { 19 | val r = ResImpl.getResourceForUser(user) 20 | val selectedNames = ArrayList(1) 21 | for (i in info.selectedRegion) { 22 | val x = i shr 4 23 | val y = i and 0x0f 24 | selectedNames.add(StringBuilder().apply { 25 | appendCodePoint((('A' + x).code)) 26 | appendCodePoint((('1' + y).code)) 27 | }.toString()) 28 | } 29 | val msg = FormattedTextBuilder().apply { 30 | this + r.auth_instruction_part1 + user.name + r.auth_instruction_part2 + 31 | Underline(maxDuration.toString()) + r.auth_instruction_part3 + "\n" + 32 | Bold(r.auth_ins_fail_on_timeout) + "\n" + 33 | r.auth_change_quiz_chances_left_part1 + 34 | Underline(info.changesAllowed.toString()) + 35 | r.auth_change_quiz_chances_left_part2 + "\n" + 36 | r.auth_hint_all_region_count_part1 + 37 | Underline(info.actualChiralRegion.size.toString()) + 38 | r.auth_hint_all_region_count_part2 + "\n" + 39 | r.auth_current_selected_regions + 40 | Underline( 41 | if (selectedNames.isEmpty()) r.auth_none_selected 42 | else selectedNames.joinToString(", ") 43 | ) 44 | }.build() 45 | return msg 46 | } 47 | 48 | fun getBotHelpInfoFormattedText(bot: Bot, user: User): FormattedText { 49 | val r = ResImpl.getResourceForUser(user) 50 | return FormattedTextBuilder().apply { 51 | this + Bold(r.help_info_category_auth) + "\n" + 52 | r.help_info_category_auth_description + "\n\n" + 53 | Bold(r.help_info_category_test) + "\n" + 54 | r.help_info_category_test_description + "\n\n" + 55 | Bold(r.help_info_category_other) + "\n" + 56 | r.help_info_category_other_description + "\n\n" + 57 | r.format(r.help_info_require_at_in_group_va1, "/config@${bot.username}") 58 | }.build() 59 | } 60 | 61 | fun getBotAboutInfoFormattedText(user: User): FormattedText { 62 | val r = ResImpl.getResourceForUser(user) 63 | return FormattedTextBuilder().apply { 64 | this + Bold("NeoAuth3Bot") + "\n" + 65 | r.help_about_desc1_part1 + 66 | TextUrl("TeleBotConsole", "https://github.com/cinit/TeleBotConsole") + 67 | r.help_about_desc1_part2 + 68 | TextUrl("PubChem", "https://pubchem.ncbi.nlm.nih.gov/") + "\n" + 69 | r.help_about_desc2_part1 + 70 | "https://github.com/cinit/NeoAuthBotPlugin" + 71 | r.help_about_desc2_part2 + "\n" + 72 | r.help_about_desc3 + 73 | if (!discussionGroupLink.isNullOrEmpty()) { 74 | "\n" + r.format(r.help_about_discussion_group_link_va1, discussionGroupLink!!) 75 | } else "" 76 | }.build() 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/main/kotlin/cc/ioctl/neoauth3bot/dat/ChemDatabase.kt: -------------------------------------------------------------------------------- 1 | package cc.ioctl.neoauth3bot.dat 2 | 3 | import cc.ioctl.neoauth3bot.util.BinaryUtils 4 | import cc.ioctl.neoauth3bot.util.SdfUtils 5 | import cc.ioctl.telebot.util.IoUtils 6 | import cc.ioctl.telebot.util.Log 7 | import com.vivimice.bgzfrandreader.RandomAccessBgzFile 8 | import java.io.File 9 | import java.io.FileInputStream 10 | import java.io.IOException 11 | import java.io.RandomAccessFile 12 | import kotlin.random.Random 13 | 14 | object ChemDatabase { 15 | 16 | private const val TAG = "ChemDatabase" 17 | 18 | private lateinit var mCandidateList: IntArray 19 | private lateinit var mIndexByteBuffer: RandomAccessFile 20 | private lateinit var mDatabaseFile: File 21 | private var mDatabaseIsBgzf: Boolean = false 22 | private var mInitialized = false 23 | private val mLock = Any() 24 | 25 | @Throws(IOException::class) 26 | fun initialize(candidateFile: File, indexFile: File, sdfFile: File) { 27 | if (!candidateFile.exists()) { 28 | throw IOException("Candidate file does not exist: ${candidateFile.absolutePath}") 29 | } 30 | if (!indexFile.exists()) { 31 | throw IOException("Index file does not exist: ${indexFile.absolutePath}") 32 | } 33 | if (!sdfFile.exists()) { 34 | throw IOException("Database file does not exist: ${sdfFile.absolutePath}") 35 | } 36 | synchronized(mLock) { 37 | FileInputStream(candidateFile).use { 38 | val arrlen = BinaryUtils.readLe32(it) 39 | val array = IntArray(arrlen) 40 | for (i in 0 until arrlen) { 41 | array[i] = BinaryUtils.readLe32(it) 42 | } 43 | mCandidateList = array 44 | } 45 | mIndexByteBuffer = RandomAccessFile(indexFile, "r") 46 | mDatabaseIsBgzf = SdfUtils.isBgzFile(sdfFile) 47 | mDatabaseFile = sdfFile 48 | mInitialized = true 49 | } 50 | } 51 | 52 | fun nextRandomCid(): Int { 53 | ensureInitialized() 54 | val r = Random.nextInt(0, mCandidateList.size) 55 | return mCandidateList[r] 56 | } 57 | 58 | @Throws(IOException::class) 59 | fun loadChemTableString(cid: Int): String? { 60 | ensureInitialized() 61 | val indexItem = ChemTableIndex(ByteArray(ChemTableIndex.OBJECT_SIZE)).also { idx -> 62 | synchronized(mLock) { 63 | mIndexByteBuffer.seek((cid * ChemTableIndex.OBJECT_SIZE).toLong()) 64 | IoUtils.readExact(mIndexByteBuffer, idx.ptrBytes, 0, ChemTableIndex.OBJECT_SIZE) 65 | } 66 | } 67 | if (indexItem.size == 0 || indexItem.cid == 0) { 68 | Log.w(TAG, "error: Compound ID $cid not found in index file") 69 | return null 70 | } 71 | val sdfString: String 72 | if (mDatabaseIsBgzf) { 73 | sdfString = RandomAccessBgzFile(mDatabaseFile).use { 74 | it.seek(indexItem.offset) 75 | val buf = ByteArray(indexItem.size) 76 | var remaining = indexItem.size 77 | var read = 0 78 | while (remaining > 0) { 79 | read = it.read(buf, read, remaining) 80 | remaining -= read 81 | } 82 | String(buf) 83 | } 84 | } else { 85 | sdfString = FileInputStream(mDatabaseFile).use { 86 | IoUtils.skipExact(it, indexItem.offset) 87 | val buf = ByteArray(indexItem.size) 88 | IoUtils.readExact(it, buf, 0, indexItem.size) 89 | String(buf) 90 | } 91 | } 92 | return sdfString 93 | } 94 | 95 | private fun ensureInitialized() { 96 | if (!mInitialized) { 97 | throw IllegalStateException("Not initialized") 98 | } 99 | } 100 | 101 | } 102 | -------------------------------------------------------------------------------- /src/main/kotlin/cc/ioctl/neoauth3bot/svc/FilterService.kt: -------------------------------------------------------------------------------- 1 | package cc.ioctl.neoauth3bot.svc 2 | 3 | import cc.ioctl.neoauth3bot.HypervisorCommandHandler 4 | import cc.ioctl.telebot.tdlib.obj.Bot 5 | import cc.ioctl.telebot.tdlib.obj.SessionInfo 6 | import cc.ioctl.telebot.util.Log 7 | import com.tencent.mmkv.MMKV 8 | 9 | object FilterService : HypervisorCommandHandler.HvCmdCallback { 10 | 11 | private const val TAG = "FilterService" 12 | private val mPersist: MMKV by lazy { MMKV.mmkvWithID("NeoAuth3_FilterService") } 13 | 14 | private val mLock = Any() 15 | 16 | private var mList: ArrayList? = null 17 | 18 | private const val KEY_BLOCK_UID_LIST = "block_uid_list" 19 | 20 | fun isBlocked(uid: Long): Boolean { 21 | synchronized(mLock) { 22 | if (mList == null) { 23 | mList = loadListLocked() 24 | } 25 | return mList!!.contains(uid) 26 | } 27 | } 28 | 29 | fun addBlockUid(uid: Long) { 30 | Log.i(TAG, "addBlockUid: $uid") 31 | synchronized(mLock) { 32 | if (mList == null) { 33 | mList = loadListLocked() 34 | } 35 | mList!!.add(uid) 36 | saveListLocked(mList!!) 37 | } 38 | } 39 | 40 | fun removeBlockUid(uid: Long) { 41 | Log.i(TAG, "removeBlockUid: $uid") 42 | synchronized(mLock) { 43 | if (mList == null) { 44 | mList = loadListLocked() 45 | } 46 | if (mList!!.remove(uid)) { 47 | saveListLocked(mList!!) 48 | } 49 | } 50 | } 51 | 52 | private fun loadListLocked(): ArrayList { 53 | mPersist.getString(KEY_BLOCK_UID_LIST, null)?.let { str -> 54 | return ArrayList(str.split(",").filter { it.isNotEmpty() }.map { it.toLong() }) 55 | } 56 | return ArrayList() 57 | } 58 | 59 | private fun saveListLocked(list: ArrayList) { 60 | mPersist.putString(KEY_BLOCK_UID_LIST, list.joinToString(",")) 61 | } 62 | 63 | override suspend fun onSupervisorCommand( 64 | bot: Bot, 65 | si: SessionInfo, 66 | senderId: Long, 67 | serviceCmd: String, 68 | args: Array 69 | ): String { 70 | when (serviceCmd) { 71 | "b", "block" -> { 72 | if (args.size != 1) { 73 | return "Invalid arguments" 74 | } 75 | val uid = try { 76 | args[0].toLong() 77 | } catch (e: NumberFormatException) { 78 | return "Invalid arguments" 79 | } 80 | if (uid == 0L) { 81 | return "Invalid arguments" 82 | } 83 | if (isBlocked(uid)) { 84 | return "EAGAIN" 85 | } else { 86 | addBlockUid(uid) 87 | return "Success" 88 | } 89 | } 90 | "u", "ub", "unblock" -> { 91 | if (args.size != 1) { 92 | return "Invalid arguments" 93 | } 94 | val uid = try { 95 | args[0].toLong() 96 | } catch (e: NumberFormatException) { 97 | return "Invalid arguments" 98 | } 99 | if (uid == 0L) { 100 | return "Invalid arguments" 101 | } 102 | if (isBlocked(uid)) { 103 | removeBlockUid(uid) 104 | return "Success" 105 | } else { 106 | return "ENOENT" 107 | } 108 | } 109 | "s", "st", "stat" -> { 110 | if (args.size != 1) { 111 | return "Invalid arguments" 112 | } 113 | val uid = try { 114 | args[0].toLong() 115 | } catch (e: NumberFormatException) { 116 | return "Invalid arguments" 117 | } 118 | if (uid == 0L) { 119 | return "Invalid arguments" 120 | } 121 | return if (isBlocked(uid)) { 122 | "1" 123 | } else { 124 | "0" 125 | } 126 | } 127 | else -> { 128 | return "ENOSYS" 129 | } 130 | } 131 | } 132 | 133 | } 134 | -------------------------------------------------------------------------------- /src/main/kotlin/cc/ioctl/neoauth3bot/cli/TestRender.kt: -------------------------------------------------------------------------------- 1 | package cc.ioctl.neoauth3bot.cli 2 | 3 | import cc.ioctl.neoauth3bot.chiral.MdlMolParser 4 | import cc.ioctl.neoauth3bot.chiral.MoleculeRender 5 | import cc.ioctl.neoauth3bot.dat.ChemTableIndex 6 | import cc.ioctl.neoauth3bot.util.SdfUtils 7 | import cc.ioctl.telebot.util.IoUtils 8 | import com.vivimice.bgzfrandreader.RandomAccessBgzFile 9 | import java.io.File 10 | import java.io.FileInputStream 11 | import java.io.RandomAccessFile 12 | import kotlin.system.exitProcess 13 | 14 | fun main(args: Array) { 15 | if (args.size < 6 || args.contains("-h") || args.contains("--help")) { 16 | println("Usage: java [OPTIONS...] -c CID -s -i [-f] [-o ]") 17 | println("Options:") 18 | println(" -c INT : Compound ID, required") 19 | println(" -s FILE : input SDF file, may be uncompressed or BGZF compressed, required") 20 | println(" -i FILE : input index file, required") 21 | println(" -o FILE : output image file, optional") 22 | println(" -f : force overwrite, optional") 23 | println(" -h : help, print this message") 24 | exitProcess(1) 25 | } 26 | val sdfFile = if (args.contains("-s")) File(args[args.indexOf("-s") + 1]) else { 27 | println("error: SDF file not specified, use -s ") 28 | exitProcess(1) 29 | } 30 | if (!sdfFile.exists()) { 31 | println("error: SDF file not found: ${sdfFile.absolutePath}") 32 | exitProcess(1) 33 | } 34 | val indexFile = if (args.contains("-i")) File(args[args.indexOf("-i") + 1]) else { 35 | println("error: index file not specified, use -i ") 36 | exitProcess(1) 37 | } 38 | if (!indexFile.exists()) { 39 | println("error: index file not found: ${indexFile.absolutePath}") 40 | exitProcess(1) 41 | } 42 | val outputFile = if (args.contains("-o")) File(args[args.indexOf("-o") + 1]) else null 43 | if (outputFile != null && outputFile.exists() && !args.contains("-f")) { 44 | println("error: output file already exists: ${outputFile.absolutePath}") 45 | exitProcess(1) 46 | } 47 | if (outputFile == null && args.contains("-f")) { 48 | println("error: -f option requires -o ") 49 | exitProcess(1) 50 | } 51 | val cid = if (args.contains("-c")) args[args.indexOf("-c") + 1].toInt() else { 52 | println("error: compound ID not specified, use -c ") 53 | exitProcess(1) 54 | } 55 | // for (cid in 1..1000) { 56 | // check if SDF file is compressed 57 | val isCompressed = SdfUtils.isBgzFile(sdfFile) 58 | val sdfString: String? 59 | val indexItem = ChemTableIndex(ByteArray(ChemTableIndex.OBJECT_SIZE)).also { idx -> 60 | RandomAccessFile(indexFile, "r").use { file -> 61 | file.seek((cid * ChemTableIndex.OBJECT_SIZE).toLong()) 62 | IoUtils.readExact(file, idx.ptrBytes, 0, ChemTableIndex.OBJECT_SIZE) 63 | } 64 | } 65 | 66 | if (indexItem.size == 0 || indexItem.cid == 0) { 67 | println("error: Compound ID $cid not found in index file") 68 | exitProcess(1) 69 | // continue 70 | } 71 | if (isCompressed) { 72 | sdfString = RandomAccessBgzFile(sdfFile).use { 73 | it.seek(indexItem.offset) 74 | val buf = ByteArray(indexItem.size) 75 | var remaining = indexItem.size 76 | var read = 0 77 | while (remaining > 0) { 78 | read = it.read(buf, read, remaining) 79 | remaining -= read 80 | } 81 | String(buf) 82 | } 83 | } else { 84 | sdfString = FileInputStream(sdfFile).use { 85 | IoUtils.skipExact(it, indexItem.offset) 86 | val buf = ByteArray(indexItem.size) 87 | IoUtils.readExact(it, buf, 0, indexItem.size) 88 | String(buf) 89 | } 90 | } 91 | println("info: SDF string size: ${sdfString.length}") 92 | val molecule = MdlMolParser.parseString(sdfString) 93 | println("info: atom count: ${molecule.atomCount()}, bond count: ${molecule.bondCount()}") 94 | val cfg = MoleculeRender.calculateRenderRect(molecule, 720) 95 | println("info: render rect: ${cfg.width}x${cfg.height}, scale: ${cfg.scaleFactor}, fontSize: ${cfg.fontSize}") 96 | val image = MoleculeRender.renderMoleculeAsImage(molecule, cfg) 97 | val imgData = image.encodeToData() ?: throw Exception("error: image encoding failed") 98 | image.close() 99 | println("info: image data size: ${imgData.size}") 100 | if (outputFile != null) { 101 | // IoUtils.writeFile(outputFile, imgData.bytes) 102 | IoUtils.writeFile(File("/tmp/419/cid_${cid}.png"), imgData.bytes) 103 | } else { 104 | println("info: no output file specified, not writing image") 105 | } 106 | imgData.close() 107 | // } 108 | } 109 | -------------------------------------------------------------------------------- /src/main/java/cc/ioctl/neoauth3bot/chiral/ChiralCarbonHelper.java: -------------------------------------------------------------------------------- 1 | package cc.ioctl.neoauth3bot.chiral; 2 | 3 | import cc.ioctl.neoauth3bot.util.IndexFrom; 4 | 5 | import java.util.ArrayList; 6 | import java.util.HashSet; 7 | 8 | /** 9 | * Utility class for working with chiral molecules. 10 | */ 11 | public class ChiralCarbonHelper { 12 | 13 | private ChiralCarbonHelper() { 14 | throw new AssertionError("no instance"); 15 | } 16 | 17 | public static HashSet getMoleculeChiralCarbons(Molecule mol) { 18 | HashSet ret = new HashSet<>(); 19 | for (int i = 1; i <= mol.atomCount(); i++) { 20 | if (isChiralCarbon(mol, i)) { 21 | ret.add(i); 22 | } 23 | } 24 | return ret; 25 | } 26 | 27 | public static boolean isChiralCarbon(Molecule mol, @IndexFrom(1) int index) { 28 | Molecule.Atom atom = mol.getAtom(index); 29 | if (!"C".equals(atom.element)) { 30 | return false; 31 | } 32 | Molecule.Bond[] bonds = mol.getAtomDeclaredBonds(index); 33 | for (Molecule.Bond b : bonds) { 34 | if (b.type > 1) { 35 | return false; 36 | } 37 | } 38 | int hcnt = atom.hydrogenCount; 39 | ArrayList bondnh = new ArrayList<>(4); 40 | for (int i = 0; i < bonds.length; i++) { 41 | Molecule.Bond b = bonds[i]; 42 | int another = (b.from == index) ? b.to : b.from; 43 | if ("H".equals(mol.getAtom(another).element) && mol.getAtomDeclaredBonds(another).length == 1) { 44 | hcnt++; 45 | bonds[i] = null; 46 | } else { 47 | bondnh.add(b); 48 | } 49 | } 50 | if (bondnh.size() == 4 && hcnt == 0) { 51 | int b1 = mol.getBondId(bondnh.get(0)); 52 | int b2 = mol.getBondId(bondnh.get(1)); 53 | int b3 = mol.getBondId(bondnh.get(2)); 54 | int b4 = mol.getBondId(bondnh.get(3)); 55 | return !(compareChain(mol, index, b1, b2) || compareChain(mol, index, b1, b3) || compareChain(mol, index, b1, b4) 56 | || compareChain(mol, index, b2, b3) || compareChain(mol, index, b2, b4) || compareChain(mol, index, b3, b4)); 57 | } else if (bondnh.size() == 3 && hcnt == 1) { 58 | int b1 = mol.getBondId(bondnh.get(0)); 59 | int b2 = mol.getBondId(bondnh.get(1)); 60 | int b3 = mol.getBondId(bondnh.get(2)); 61 | return !(compareChain(mol, index, b1, b2) || compareChain(mol, index, b1, b3) || compareChain(mol, index, b3, b2)); 62 | } else { 63 | return false; 64 | } 65 | } 66 | 67 | @IndexFrom(1) 68 | public static boolean compareChain(Molecule mol, int center, int chain1, int chain2) { 69 | return compareChain(mol, center, center, chain1, chain2, (int) (3 + Math.sqrt(mol.atomCount()))); 70 | } 71 | 72 | @IndexFrom(1) 73 | public static boolean compareChain(Molecule mol, int atom1, int atom2, int chain1, int chain2, int ttl) { 74 | Molecule.Bond b1 = mol.getBond(chain1); 75 | Molecule.Bond b2 = mol.getBond(chain2); 76 | if (b1.type != b2.type) { 77 | return false; 78 | } 79 | int another1 = (b1.from == atom1) ? b1.to : b1.from; 80 | int another2 = (b2.from == atom2) ? b2.to : b2.from; 81 | Molecule.Atom a1 = mol.getAtom(another1); 82 | Molecule.Atom a2 = mol.getAtom(another2); 83 | if (!a1.element.equals(a2.element)) { 84 | return false; 85 | } 86 | int hcnt1 = a1.hydrogenCount; 87 | int hcnt2 = a2.hydrogenCount; 88 | Molecule.Bond[] bonds1 = mol.getAtomDeclaredBonds(another1); 89 | Molecule.Bond[] bonds2 = mol.getAtomDeclaredBonds(another2); 90 | ArrayList bondnh1 = new ArrayList<>(4); 91 | ArrayList bondnh2 = new ArrayList<>(4); 92 | for (Molecule.Bond b : bonds1) { 93 | int another = (b.from == another1) ? b.to : b.from; 94 | if (another == atom1) { 95 | continue; 96 | } 97 | if ("H".equals(mol.getAtom(another).element) && mol.getAtomDeclaredBonds(another).length == 1) { 98 | hcnt1++; 99 | } else { 100 | bondnh1.add(b); 101 | } 102 | } 103 | for (Molecule.Bond b : bonds2) { 104 | int another = (b.from == another2) ? b.to : b.from; 105 | if (another == atom2) { 106 | continue; 107 | } 108 | if ("H".equals(mol.getAtom(another).element) && mol.getAtomDeclaredBonds(another).length == 1) { 109 | hcnt2++; 110 | } else { 111 | bondnh2.add(b); 112 | } 113 | } 114 | if (hcnt1 != hcnt2 || bondnh1.size() != bondnh2.size()) { 115 | return false; 116 | } 117 | if (ttl < 0) { 118 | return true; 119 | } 120 | --ttl; 121 | for (Molecule.Bond dchain1 : bondnh1) { 122 | boolean success = false; 123 | for (Molecule.Bond bond : bondnh2) { 124 | if (compareChain(mol, another1, another2, mol.getBondId(dchain1), mol.getBondId(bond), ttl)) { 125 | success = true; 126 | break; 127 | } 128 | } 129 | if (!success) { 130 | return false; 131 | } 132 | } 133 | return true; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/main/kotlin/cc/ioctl/neoauth3bot/cli/MoleculeFilter.kt: -------------------------------------------------------------------------------- 1 | package cc.ioctl.neoauth3bot.cli 2 | 3 | import cc.ioctl.neoauth3bot.chiral.ChiralCarbonHelper 4 | import cc.ioctl.neoauth3bot.chiral.MdlMolParser 5 | import cc.ioctl.neoauth3bot.dat.ChemTableIndex 6 | import cc.ioctl.neoauth3bot.util.BinaryUtils 7 | import cc.ioctl.telebot.util.IoUtils 8 | import java.io.File 9 | import java.io.FileOutputStream 10 | import java.io.RandomAccessFile 11 | import kotlin.system.exitProcess 12 | 13 | fun main(args: Array) { 14 | if (args.size < 6 || args.contains("-h") || args.contains("--help")) { 15 | println("Usage: java [OPTIONS...] [-b BEGIN] -e END -s -i [-f] -o ") 16 | println("Options:") 17 | println(" -s FILE : input SDF file(uncompressed), required") 18 | println(" -i FILE : input index file, required") 19 | println(" -o FILE : output file, required") 20 | println(" -f : force overwrite, optional") 21 | println(" -b INT : begin index, inclusive, optional, default 0") 22 | println(" -e INT : end index, inclusive, required") 23 | println(" -h : help, print this message") 24 | exitProcess(1) 25 | } 26 | val sdfFilePath = if (args.contains("-s")) args[args.indexOf("-s") + 1] else { 27 | println("error: -s is required") 28 | exitProcess(1) 29 | } 30 | val indexFilePath = if (args.contains("-i")) args[args.indexOf("-i") + 1] else { 31 | println("error: -i is required") 32 | exitProcess(1) 33 | } 34 | val outputFilePath = if (args.contains("-o")) args[args.indexOf("-o") + 1] else { 35 | println("error: -o is required") 36 | exitProcess(1) 37 | } 38 | val forceOverwrite = args.contains("-f") 39 | val sdfFile = File(sdfFilePath) 40 | val indexFile = File(indexFilePath) 41 | val outputFile = File(outputFilePath) 42 | if (!sdfFile.exists()) { 43 | println("error: SDF file not found: $sdfFilePath") 44 | exitProcess(1) 45 | } 46 | if (!indexFile.exists()) { 47 | println("error: index file not found: $indexFilePath") 48 | exitProcess(1) 49 | } 50 | if (outputFile.exists() && !forceOverwrite) { 51 | println("error: output file already exists: $outputFilePath") 52 | exitProcess(1) 53 | } 54 | val startIndex = if (args.contains("-b")) args[args.indexOf("-b") + 1].toInt() else 0 55 | val endIndex = if (args.contains("-e")) args[args.indexOf("-e") + 1].toInt() else { 56 | println("error: -e is required") 57 | exitProcess(1) 58 | } 59 | val indexByteBuffer = RandomAccessFile(indexFile, "r") 60 | val sdfByteBuffer = RandomAccessFile(sdfFile, "r") 61 | outputFile.createNewFile() 62 | val resultIndexArray: ArrayList = ArrayList(10000) 63 | var validCounter = 0 64 | val chemIndex = ChemTableIndex(ByteArray(40)) 65 | var buf: ByteArray? = null 66 | println("info: start index: $startIndex, end index: $endIndex") 67 | val startTime = System.currentTimeMillis() 68 | for (i in startIndex until endIndex + 1) { 69 | val objOffset = ChemTableIndex.OBJECT_SIZE * i 70 | // set the index to the object offset 71 | indexByteBuffer.seek(objOffset.toLong()) 72 | IoUtils.readExact(indexByteBuffer, chemIndex.ptrBytes, 0, ChemTableIndex.OBJECT_SIZE) 73 | if (chemIndex.cid != 0) { 74 | 75 | if (chemIndex.isChiral) { 76 | val strlen = chemIndex.size 77 | val strOffset = chemIndex.offset 78 | 79 | if (buf == null || buf.size < strlen) { 80 | buf = ByteArray(strlen) 81 | } 82 | sdfByteBuffer.seek(strOffset) 83 | IoUtils.readExact(sdfByteBuffer, buf, 0, strlen) 84 | val sdf = String(buf, 0, strlen) 85 | 86 | 87 | try { 88 | val mol = MdlMolParser.parseString(sdf) 89 | 90 | val cc = ChiralCarbonHelper.getMoleculeChiralCarbons(mol) 91 | 92 | val isCandidate = cc.size > 3 || cc.size * mol.atomCount() > 200 93 | 94 | if (isCandidate) { 95 | resultIndexArray.add(i) 96 | } 97 | } catch (e: Exception) { 98 | println("cid: ${chemIndex.cid}, error: ${e.message}") 99 | e.printStackTrace() 100 | exitProcess(1) 101 | } 102 | } 103 | validCounter++ 104 | } 105 | 106 | if (i % 10000 == 0) { 107 | println("$validCounter/${endIndex - startIndex + 1}, valid: ${validCounter}, candidate: ${resultIndexArray.size}") 108 | } 109 | } 110 | 111 | val endTime = System.currentTimeMillis() 112 | 113 | // write the result to the output file 114 | val resultData = ByteArray(4 * (resultIndexArray.size + 1)) 115 | BinaryUtils.writeLe32(resultData, 0, resultIndexArray.size) 116 | for (i in 0 until resultIndexArray.size) { 117 | BinaryUtils.writeLe32(resultData, 4 * (i + 1), resultIndexArray[i]) 118 | } 119 | val outputFileOutputStream = FileOutputStream(outputFile) 120 | outputFileOutputStream.write(resultData) 121 | outputFileOutputStream.close() 122 | val ns = endIndex - startIndex + 1 123 | val valid = validCounter 124 | val candidate = resultIndexArray.size 125 | println("info: candidate: $candidate(${candidate * 100.0 / valid}%), valid: $valid(${valid * 100.0 / ns}%), ns: $ns") 126 | println("info: time: ${endTime - startTime}ms, ${valid * 1000.0 / (endTime - startTime)}/s") 127 | } 128 | -------------------------------------------------------------------------------- /src/main/java/cc/ioctl/neoauth3bot/chiral/MdlMolParser.java: -------------------------------------------------------------------------------- 1 | package cc.ioctl.neoauth3bot.chiral; 2 | 3 | /** 4 | * Utility class for parsing MDL MOL files. 5 | *

6 | * Adapted from WebMolKit 7 | */ 8 | public class MdlMolParser { 9 | 10 | private MdlMolParser() { 11 | throw new UnsupportedOperationException("This class is not meant to be instantiated"); 12 | } 13 | 14 | public static class BadMolFormatException extends Exception { 15 | public BadMolFormatException(String msg) { 16 | super(msg); 17 | } 18 | } 19 | 20 | /** 21 | * Parse am MDL MOL file and return a Molecule object. 22 | * 23 | * @param str The MDL MOL file as a string. 24 | * @return A Molecule object. 25 | * @throws BadMolFormatException If the MDL MOL file is not well-formed. 26 | */ 27 | public static Molecule parseString(String str) throws BadMolFormatException { 28 | int start = -1; 29 | Molecule.Atom[] atoms; 30 | Molecule.Bond[] bonds; 31 | String[] lines = str.replace("\r\n", "\n").replace('\r', '\n').split("\n"); 32 | for (int i = 0, linesLength = lines.length; i < linesLength; i++) { 33 | String line = lines[i]; 34 | if ((line.length() >= 39 && line.startsWith("V2000", 34))) { 35 | start = i; 36 | break; 37 | } 38 | } 39 | if (start == -1) { 40 | throw new BadMolFormatException("V2000 tag not found at any_line.substring(34, 39)"); 41 | } 42 | int numAtoms = Integer.parseInt(lines[start].substring(0, 3).trim()); 43 | int numBonds = Integer.parseInt(lines[start].substring(3, 6).trim()); 44 | atoms = new Molecule.Atom[numAtoms]; 45 | bonds = new Molecule.Bond[numBonds]; 46 | for (int i = 0; i < numAtoms; i++) { 47 | Molecule.Atom atom = new Molecule.Atom(); 48 | atoms[i] = atom; 49 | String line = lines[start + 1 + i]; 50 | if (line.length() < 39) { 51 | throw new BadMolFormatException("Invalid MDL MOL: atom line" + (start + 2 + i)); 52 | } 53 | atom.x = Float.parseFloat(line.substring(0, 10).trim()); 54 | atom.y = Float.parseFloat(line.substring(10, 20).trim()); 55 | atom.z = Float.parseFloat(line.substring(20, 30).trim()); 56 | atom.element = line.substring(31, 34).trim(); 57 | int chg, rad = 0; 58 | int chg2 = Integer.parseInt(line.substring(36, 39).trim()); 59 | int mapnum = line.length() < 63 ? 0 : Integer.parseInt(line.substring(60, 63).trim()); 60 | if (chg2 >= 1 && chg2 <= 3) { 61 | chg = 4 - chg2; 62 | } else if (chg2 == 4) { 63 | chg = 0; 64 | rad = 2; 65 | } else if (chg2 < 5 || chg2 > 7) { 66 | chg = 0; 67 | } else { 68 | chg = 4 - chg2; 69 | } 70 | atom.charge = chg; 71 | atom.unpaired = rad; 72 | atom.mapnum = mapnum; 73 | } 74 | for (int i = 0; i < numBonds; i++) { 75 | Molecule.Bond bond = new Molecule.Bond(); 76 | bonds[i] = bond; 77 | String line = lines[start + numAtoms + 1 + i]; 78 | if (line.length() < 12) { 79 | throw new BadMolFormatException("Invalid MDL MOL: bond line" + (start + numAtoms + 2 + i)); 80 | } 81 | int from = Integer.parseInt(line.substring(0, 3).trim()); 82 | int to = Integer.parseInt(line.substring(3, 6).trim()); 83 | int type = Integer.parseInt(line.substring(6, 9).trim()); 84 | int stereo = Integer.parseInt(line.substring(9, 12).trim()); 85 | if (from == to || from < 1 || from > numAtoms || to < 1 || to > numAtoms) { 86 | throw new BadMolFormatException("Invalid MDL MOL: bond line" + (start + numAtoms + 2 + i)); 87 | } 88 | int order = (type < 1 || type > 3) ? 1 : type; 89 | int style = 0; 90 | if (stereo == 1) { 91 | style = 1; 92 | } else if (stereo == 6) { 93 | style = 2; 94 | } 95 | bond.from = from; 96 | bond.to = to; 97 | bond.type = order; 98 | bond.stereoDirection = style; 99 | } 100 | Molecule molecule = new Molecule(atoms, bonds, str); 101 | for (int i = start + numAtoms + numBonds + 1; i < lines.length; i++) { 102 | String line = lines[i]; 103 | if (line.startsWith("M END")) { 104 | break; 105 | } 106 | int type2 = 0; 107 | if (line.startsWith("M CHG")) { 108 | type2 = 1; 109 | } else if (line.startsWith("M RAD")) { 110 | type2 = 2; 111 | } else if (line.startsWith("M ISO")) { 112 | type2 = 3; 113 | } else if (line.startsWith("M RGP")) { 114 | type2 = 4; 115 | } else if (line.startsWith("M HYD")) { 116 | type2 = 5; 117 | } else if (line.startsWith("M ZCH")) { 118 | type2 = 6; 119 | } else if (!line.startsWith("M ZBO")) { 120 | int anum = 0; 121 | try { 122 | anum = Integer.parseInt(line.substring(3, 6).trim()); 123 | } catch (NumberFormatException ignored) { 124 | } 125 | if (line.startsWith("A ") && line.length() >= 6 && anum >= 1 && anum <= numAtoms) { 126 | String line5 = lines[++i]; 127 | if (line5 == null) { 128 | break; 129 | } 130 | molecule.getAtom(anum).element = line5; 131 | } 132 | } else { 133 | type2 = 7; 134 | } 135 | if (type2 > 0) { 136 | try { 137 | int len = Integer.parseInt(line.substring(6, 9).trim()); 138 | for (int n3 = 0; n3 < len; n3++) { 139 | int pos = Integer.parseInt(line.substring((n3 * 8) + 9, (n3 * 8) + 13).trim()); 140 | int val = Integer.parseInt(line.substring((n3 * 8) + 13, (n3 * 8) + 17).trim()); 141 | if (pos < 1) { 142 | throw new BadMolFormatException("Invalid MDL MOL: M-block"); 143 | } 144 | if (type2 == 1) { 145 | molecule.getAtom(pos).charge = val; 146 | } else if (type2 == 2) { 147 | molecule.getAtom(pos).unpaired = val; 148 | } else if (type2 == 3) { 149 | molecule.getAtom(pos).isotope = val; 150 | } else if (type2 == 4) { 151 | molecule.getAtom(pos).element = "R" + val; 152 | } else if (type2 == 5) { 153 | molecule.getAtom(pos).showFlag = Molecule.SHOW_FLAG_EXPLICIT; 154 | } else if (type2 == 6) { 155 | molecule.getAtom(pos).charge = val; 156 | } else if (type2 == 7) { 157 | molecule.getBond(pos).stereoDirection = val; 158 | } 159 | } 160 | continue; 161 | } catch (IndexOutOfBoundsException e) { 162 | throw new BadMolFormatException("Invalid MDL MOL: M-block"); 163 | } 164 | } 165 | } 166 | molecule.initOnce(); 167 | return molecule; 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/main/kotlin/cc/ioctl/neoauth3bot/svc/LogDatabaseService.kt: -------------------------------------------------------------------------------- 1 | package cc.ioctl.neoauth3bot.svc 2 | 3 | import cc.ioctl.neoauth3bot.HypervisorCommandHandler 4 | import cc.ioctl.telebot.tdlib.RobotServer 5 | import cc.ioctl.telebot.tdlib.obj.Bot 6 | import cc.ioctl.telebot.tdlib.obj.SessionInfo 7 | import cc.ioctl.telebot.util.Log 8 | import java.io.File 9 | import java.sql.Connection 10 | import java.sql.DriverManager 11 | 12 | object LogDatabaseService : HypervisorCommandHandler.HvCmdCallback { 13 | 14 | const val TAG = "LogDatabaseService" 15 | 16 | override suspend fun onSupervisorCommand( 17 | bot: Bot, 18 | si: SessionInfo, 19 | senderId: Long, 20 | serviceCmd: String, 21 | args: Array 22 | ): String { 23 | return when (serviceCmd) { 24 | "sql" -> { 25 | val sql = args.joinToString(" ") 26 | execNoCheck(bot, sql) 27 | } 28 | 29 | else -> { 30 | "unimplemented subcmd: $serviceCmd" 31 | } 32 | } 33 | } 34 | 35 | data class LogEntry( 36 | val event: String, 37 | val group: Long, 38 | val actor: Long, 39 | val subject: Long, 40 | val time: Long, 41 | val extra: String? 42 | ) 43 | 44 | private lateinit var mBaseDir: File 45 | private val mDatabases: MutableMap = mutableMapOf() 46 | private val mLock = Any() 47 | 48 | private fun init() { 49 | if (!::mBaseDir.isInitialized) { 50 | Class.forName("org.sqlite.JDBC") 51 | mBaseDir = File(RobotServer.instance.pluginsDir, "users") 52 | mBaseDir.mkdirs() 53 | } 54 | } 55 | 56 | private fun initForUser(uid: Long): Connection { 57 | require(uid > 0) 58 | synchronized(mLock) { 59 | init() 60 | if (!mDatabases.containsKey(uid)) { 61 | val dbFile = File(mBaseDir, "u_$uid.db") 62 | val conn = DriverManager.getConnection("jdbc:sqlite:${dbFile.absolutePath}") 63 | conn.createStatement().use { stmt -> 64 | stmt.executeUpdate("CREATE TABLE IF NOT EXISTS channel_logs (evid INTEGER PRIMARY KEY AUTOINCREMENT, event TEXT NOT NULL, gid INTEGER, actor INTEGER, subject INTEGER, time INTEGER NOT NULL, extra TEXT)") 65 | } 66 | mDatabases[uid] = conn 67 | return conn 68 | } else { 69 | return mDatabases[uid]!! 70 | } 71 | } 72 | } 73 | 74 | fun getDatabase(bot: Bot): Connection { 75 | val uid = bot.userId 76 | require(uid > 0) 77 | return if (!mDatabases.containsKey(uid)) { 78 | initForUser(uid) 79 | } else { 80 | mDatabases[uid]!! 81 | } 82 | } 83 | 84 | fun addLog(bot: Bot, entry: LogEntry) { 85 | try { 86 | val conn = getDatabase(bot) 87 | conn.prepareStatement("INSERT INTO channel_logs (event, gid, actor, subject, time, extra) VALUES (?, ?, ?, ?, ?, ?)") 88 | .use { stmt -> 89 | stmt.setString(1, entry.event) 90 | stmt.setLong(2, entry.group) 91 | stmt.setLong(3, entry.actor) 92 | stmt.setLong(4, entry.subject) 93 | stmt.setLong(5, entry.time) 94 | stmt.setString(6, entry.extra) 95 | stmt.executeUpdate() 96 | } 97 | } catch (e: Exception) { 98 | Log.e(TAG, "fail to execute sql", e) 99 | } 100 | } 101 | 102 | fun execNoCheck(bot: Bot, sql: String): String { 103 | val conn = getDatabase(bot) 104 | val sb = StringBuilder() 105 | val maxShowLines = 10 106 | val maxAllowedLines = 10000 107 | runCatching { 108 | checkSingleSqlStatement(sql) 109 | val TYPE_QUERY = 1 110 | val TYPE_UPDATE = 2 111 | val type: Int 112 | sql.lowercase().trimStart().let { 113 | type = when { 114 | it.startsWith("insert ") || it.startsWith("update ") || it.startsWith("replace ") 115 | || it.startsWith("delete ") -> { 116 | TYPE_UPDATE 117 | } 118 | it.matches(Regex("[( ]*select .*")) || it.startsWith("show ") -> { 119 | TYPE_QUERY 120 | } 121 | else -> { 122 | throw IllegalArgumentException("unknown sql type") 123 | } 124 | } 125 | } 126 | if (type == TYPE_QUERY) { 127 | synchronized(conn) { 128 | val startTime = System.nanoTime() 129 | var i = 0 130 | conn.createStatement().use { stmt -> 131 | stmt.executeQuery(sql).use { rs -> 132 | val md = rs.metaData 133 | val colCount = md.columnCount 134 | while (rs.next() && i < maxAllowedLines) { 135 | if (i < maxShowLines) { 136 | for (j in 1..colCount) { 137 | sb.append(rs.getString(j)) 138 | sb.append(",") 139 | } 140 | sb.append('\n') 141 | } 142 | i++ 143 | } 144 | } 145 | } 146 | val execTime = System.nanoTime() - startTime 147 | sb.append('\n') 148 | if (i >= maxAllowedLines) { 149 | sb.append("... more") 150 | } else if (i > maxShowLines) { 151 | sb.append("... ").append(i).append(" rows in all.") 152 | } else { 153 | sb.append(i).append(" row(s).") 154 | } 155 | sb.append('\n') 156 | sb.append("Exec time: %.3f ms".format(execTime / 1000000.0)) 157 | } 158 | } else { 159 | synchronized(conn) { 160 | val startTime = System.nanoTime() 161 | val affectedRows: Int 162 | conn.createStatement().use { stmt -> 163 | affectedRows = stmt.executeUpdate(sql) 164 | } 165 | val execTime = System.nanoTime() - startTime 166 | sb.append('\n') 167 | sb.append("%d rows (%.3f ms) affected.".format(affectedRows, execTime / 1e6f)) 168 | } 169 | } 170 | }.onFailure { 171 | sb.append(it) 172 | } 173 | return sb.toString() 174 | } 175 | 176 | private fun getAffectedRowsLocked(conn: Connection): Int { 177 | conn.createStatement().use { stmt -> 178 | val rs = stmt.executeQuery("SELECT changes()") 179 | rs.first() 180 | return rs.getInt(1) 181 | } 182 | } 183 | 184 | @Throws(IllegalArgumentException::class) 185 | private fun checkSingleSqlStatement(statement: String) { 186 | val sql = statement.trim().trimEnd(';').lowercase() 187 | if (sql.contains(";")) { 188 | throw IllegalArgumentException("only single sql statement is allowed") 189 | } 190 | if (sql.startsWith("drop ") || sql.startsWith("create ") 191 | || sql.startsWith("truncate ") || sql.startsWith("alter ") 192 | ) { 193 | throw IllegalArgumentException("unsafe sql statement") 194 | } 195 | if ((sql.startsWith("delete ") || sql.startsWith("update ")) && !sql.contains(" where ")) { 196 | throw IllegalArgumentException("attempt to execute update without where") 197 | } 198 | } 199 | 200 | } 201 | -------------------------------------------------------------------------------- /src/main/kotlin/cc/ioctl/misc/InlineBotMsgCleaner.kt: -------------------------------------------------------------------------------- 1 | package cc.ioctl.misc 2 | 3 | import cc.ioctl.neoauth3bot.HypervisorCommandHandler 4 | import cc.ioctl.telebot.tdlib.RobotServer 5 | import cc.ioctl.telebot.tdlib.obj.Bot 6 | import cc.ioctl.telebot.tdlib.obj.SessionInfo 7 | import cc.ioctl.telebot.tdlib.tlrpc.api.msg.Message 8 | import com.google.gson.JsonArray 9 | import com.google.gson.JsonObject 10 | import com.google.gson.JsonParseException 11 | import kotlinx.coroutines.Dispatchers 12 | import java.io.File 13 | import java.util.* 14 | import java.util.concurrent.ConcurrentHashMap 15 | 16 | object InlineBotMsgCleaner : HypervisorCommandHandler.HvCmdCallback { 17 | 18 | private const val TAG = "NeoAuth3Bot.InlineBotMsgCleaner" 19 | 20 | override suspend fun onSupervisorCommand( 21 | bot: Bot, si: SessionInfo, senderId: Long, serviceCmd: String, args: Array 22 | ): String { 23 | var groupId: Long = if (si.isGroupOrChannel) { 24 | si.id 25 | } else { 26 | -1L 27 | } 28 | val restArgs: Array 29 | if (args.contains("-g")) { 30 | // find '-g' 31 | val index = args.indexOf("-g") 32 | if (index + 1 < args.size) { 33 | // find group id 34 | groupId = args[index + 1].toLong() 35 | } 36 | restArgs = args.filterIndexed { i, _ -> i != index && i != index + 1 }.toTypedArray() 37 | } else { 38 | restArgs = args 39 | } 40 | if (groupId == -1L) { 41 | return "-g is required" 42 | } 43 | when (serviceCmd) { 44 | "reload" -> { 45 | val p = reloadPoliciesForGroup(bot, groupId) 46 | return "Reloaded ${p?.policies?.size} policies" 47 | } 48 | "p", "print", "get" -> { 49 | val p = getPoliciesForGroup(bot, groupId) 50 | return if (p == null || p.policies.isEmpty()) { 51 | "No policy found" 52 | } else { 53 | p.toJsonArray().toString() 54 | } 55 | } 56 | "update" -> { 57 | val jsonText = restArgs.joinToString(" ") 58 | return try { 59 | val policies = parseJsonPolicies(jsonText) 60 | savePoliciesForGroup(bot, groupId, policies) 61 | "Updated ${policies.policies.size} policies" 62 | } catch (e: JsonParseException) { 63 | "Error parsing json: $e" 64 | } catch (e: java.lang.IllegalArgumentException) { 65 | "Invalid policy: $e" 66 | } catch (e: Exception) { 67 | "Error: $e" 68 | } 69 | } 70 | else -> { 71 | return "ENOSYS. Available commands: reload, print, update." 72 | } 73 | } 74 | } 75 | 76 | data class RetentionPolicies( 77 | val policies: HashMap 78 | ) { 79 | 80 | data class ActionRule( 81 | val inlineBotId: Long, 82 | val retentionSeconds: Int, 83 | ) { 84 | init { 85 | check(inlineBotId > 0) { "inlineBotId must be positive, got $inlineBotId" } 86 | check(retentionSeconds >= -1) { "retentionSeconds must be >= -1, got $retentionSeconds" } 87 | } 88 | } 89 | 90 | /** 91 | * -1 for not deleting 92 | */ 93 | fun getRetentionTime(botId: Long): Int { 94 | if (botId <= 0) { 95 | return -1 96 | } 97 | val policy = policies[botId] 98 | return policy?.retentionSeconds ?: -1 99 | } 100 | 101 | companion object { 102 | @JvmStatic 103 | fun fromJsonArray(json: JsonArray): RetentionPolicies { 104 | val policies = HashMap() 105 | for (i in 0 until json.size()) { 106 | val rule = json[i].asJsonObject 107 | val botId = rule["inlineBotId"].asLong 108 | val retentionSeconds = rule["retentionSeconds"].asInt 109 | policies[botId] = ActionRule(botId, retentionSeconds) 110 | } 111 | return RetentionPolicies(policies) 112 | } 113 | } 114 | 115 | fun toJsonArray(): JsonArray { 116 | val json = JsonArray() 117 | for (rule in policies.values) { 118 | val ruleJson = JsonObject() 119 | ruleJson.addProperty("inlineBotId", rule.inlineBotId) 120 | ruleJson.addProperty("retentionSeconds", rule.retentionSeconds) 121 | json.add(ruleJson) 122 | } 123 | return json 124 | } 125 | 126 | } 127 | 128 | private val mCachedConfig: ConcurrentHashMap> = ConcurrentHashMap(64) 129 | 130 | @JvmStatic 131 | fun onReceiveMessage(bot: Bot, si: SessionInfo, senderId: Long, message: Message): Boolean { 132 | if (!si.isGroupOrChannel) { 133 | return false 134 | } 135 | val gid = si.id 136 | val inlineBotId = message.viaBotUserId 137 | if (inlineBotId != 0L) { 138 | val retentionSeconds = getPoliciesForGroup(bot, gid)?.getRetentionTime(inlineBotId) ?: -1 139 | if (retentionSeconds >= 0) { 140 | scheduleDeleteMsgAfter(bot, si, message.id, retentionSeconds) 141 | return false 142 | } 143 | } 144 | return false 145 | } 146 | 147 | fun getPoliciesForGroup(bot: Bot, uid: Long): RetentionPolicies? { 148 | val v = mCachedConfig[uid] 149 | return if (v != null) { 150 | v.orElse(null) 151 | } else { 152 | val p = loadPoliciesForGroupInternal(bot, uid) 153 | mCachedConfig[uid] = Optional.ofNullable(p) 154 | p 155 | } 156 | } 157 | 158 | private fun loadPoliciesForGroupInternal(bot: Bot, uid: Long): RetentionPolicies? { 159 | check(uid > 0) { "invalid uid: $uid" } 160 | val groupBaseDir = File(RobotServer.instance.pluginsDir, "groups" + File.separator + "g_$uid") 161 | val anointedFile = File(groupBaseDir, "InlineBotCleaner.json") 162 | if (!anointedFile.exists()) { 163 | return null 164 | } 165 | val json = anointedFile.readText() 166 | if (json.isEmpty()) { 167 | return null 168 | } 169 | return RetentionPolicies.fromJsonArray(com.google.gson.JsonParser.parseString(json).asJsonArray) 170 | } 171 | 172 | fun reloadPoliciesForGroup(bot: Bot, uid: Long): RetentionPolicies? { 173 | check(uid > 0) { "invalid uid: $uid" } 174 | val p = loadPoliciesForGroupInternal(bot, uid) 175 | mCachedConfig[uid] = Optional.ofNullable(p) 176 | return p 177 | } 178 | 179 | fun savePoliciesForGroup(bot: Bot, uid: Long, policies: RetentionPolicies) { 180 | check(uid > 0) { "invalid uid: $uid" } 181 | val groupBaseDir = File(RobotServer.instance.pluginsDir, "groups" + File.separator + "g_$uid") 182 | val anointedFile = File(groupBaseDir, "InlineBotCleaner.json") 183 | anointedFile.writeText(policies.toJsonArray().toString()) 184 | mCachedConfig[uid] = Optional.of(policies) 185 | } 186 | 187 | @JvmStatic 188 | @Throws(JsonParseException::class, IllegalArgumentException::class) 189 | fun parseJsonPolicies(json: String): RetentionPolicies { 190 | return RetentionPolicies.fromJsonArray(com.google.gson.JsonParser.parseString(json).asJsonArray) 191 | } 192 | 193 | private fun scheduleDeleteMsgAfter(bot: Bot, si: SessionInfo, msgId: Long, delaySeconds: Int) { 194 | if (delaySeconds <= 0) { 195 | return 196 | } 197 | bot.scheduleTaskDelayedWithContext(Dispatchers.IO, delaySeconds * 1000L) { 198 | bot.deleteMessage(si, msgId) 199 | } 200 | } 201 | 202 | } 203 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 84 | 85 | APP_NAME="Gradle" 86 | APP_BASE_NAME=${0##*/} 87 | 88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | 142 | # Increase the maximum file descriptors if we can. 143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 144 | case $MAX_FD in #( 145 | max*) 146 | MAX_FD=$( ulimit -H -n ) || 147 | warn "Could not query maximum file descriptor limit" 148 | esac 149 | case $MAX_FD in #( 150 | '' | soft) :;; #( 151 | *) 152 | ulimit -n "$MAX_FD" || 153 | warn "Could not set maximum file descriptor limit to $MAX_FD" 154 | esac 155 | fi 156 | 157 | # Collect all arguments for the java command, stacking in reverse order: 158 | # * args from the command line 159 | # * the main class name 160 | # * -classpath 161 | # * -D...appname settings 162 | # * --module-path (only if needed) 163 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 164 | 165 | # For Cygwin or MSYS, switch paths to Windows format before running java 166 | if "$cygwin" || "$msys" ; then 167 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 168 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 169 | 170 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 171 | 172 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 173 | for arg do 174 | if 175 | case $arg in #( 176 | -*) false ;; # don't mess with options #( 177 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 178 | [ -e "$t" ] ;; #( 179 | *) false ;; 180 | esac 181 | then 182 | arg=$( cygpath --path --ignore --mixed "$arg" ) 183 | fi 184 | # Roll the args list around exactly as many times as the number of 185 | # args, so each arg winds up back in the position where it started, but 186 | # possibly modified. 187 | # 188 | # NB: a `for` loop captures its iteration list before it begins, so 189 | # changing the positional parameters here affects neither the number of 190 | # iterations, nor the values presented in `arg`. 191 | shift # remove old arg 192 | set -- "$@" "$arg" # push replacement arg 193 | done 194 | fi 195 | 196 | # Collect all arguments for the java command; 197 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 198 | # shell script including quotes and variable substitutions, so put them in 199 | # double quotes to make sure that they get re-expanded; and 200 | # * put everything else in single quotes, so that it's not re-expanded. 201 | 202 | set -- \ 203 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 204 | -classpath "$CLASSPATH" \ 205 | org.gradle.wrapper.GradleWrapperMain \ 206 | "$@" 207 | 208 | # Use "xargs" to parse quoted args. 209 | # 210 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 211 | # 212 | # In Bash we could simply go: 213 | # 214 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 215 | # set -- "${ARGS[@]}" "$@" 216 | # 217 | # but POSIX shell has neither arrays nor command substitution, so instead we 218 | # post-process each arg (as a line of input to sed) to backslash-escape any 219 | # character that might be a shell metacharacter, then use eval to reverse 220 | # that process (while maintaining the separation between arguments), and wrap 221 | # the whole thing up as a single "set" statement. 222 | # 223 | # This will of course break if any of these variables contains a newline or 224 | # an unmatched quote. 225 | # 226 | 227 | eval "set -- $( 228 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 229 | xargs -n1 | 230 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 231 | tr '\n' ' ' 232 | )" '"$@"' 233 | 234 | exec "$JAVACMD" "$@" 235 | -------------------------------------------------------------------------------- /src/main/kotlin/cc/ioctl/neoauth3bot/SessionManager.kt: -------------------------------------------------------------------------------- 1 | package cc.ioctl.neoauth3bot 2 | 3 | import cc.ioctl.telebot.tdlib.obj.Bot 4 | import cc.ioctl.telebot.tdlib.obj.Group 5 | import cc.ioctl.telebot.tdlib.obj.User 6 | import com.tencent.mmkv.MMKV 7 | import kotlinx.serialization.Serializable 8 | import kotlinx.serialization.json.Json 9 | import java.util.concurrent.ConcurrentHashMap 10 | 11 | object SessionManager { 12 | 13 | private const val TAG = "NeoAuth3Bot.SessionManager" 14 | private const val KEY_GROUP_CONFIG_FOR_GID = "config_for_group_" 15 | private const val KEY_AUTH_INFO_FOR_UID = "auth_info_for_user_" 16 | private const val KEY_NEXT_AUTH_SEQUENCE = "next_auth_sequence" 17 | 18 | private val mNextAuthSeqLock = Any() 19 | 20 | 21 | private val mPersists = ConcurrentHashMap() 22 | private val mGlobalConfig by lazy { 23 | MMKV.mmkvWithID("NeoAuth3Bot_global") 24 | } 25 | 26 | fun nextAuthSequence(): Int { 27 | synchronized(mNextAuthSeqLock) { 28 | val seq = mGlobalConfig.getInt(KEY_NEXT_AUTH_SEQUENCE, 10000) 29 | mGlobalConfig.putInt(KEY_NEXT_AUTH_SEQUENCE, seq + 1) 30 | return seq 31 | } 32 | } 33 | 34 | private fun getConfigForBot(bot: Bot, type: String): MMKV { 35 | val uid = bot.userId 36 | if (bot.userId < 0) { 37 | throw IllegalArgumentException("bot.userId must be positive") 38 | } 39 | if (type.isEmpty()) { 40 | throw IllegalArgumentException("type must not be empty") 41 | } 42 | val name = "NeoAuth3Bot_${type}_${uid}" 43 | return mPersists.computeIfAbsent(name) { MMKV.mmkvWithID(name) } 44 | } 45 | 46 | fun getGroupConfig(bot: Bot, gid: Long): GroupAuthConfig? { 47 | val config = getConfigForBot(bot, "GroupConfig") 48 | val str = config.getString(KEY_GROUP_CONFIG_FOR_GID + gid, null) ?: return null 49 | return Json.decodeFromString(GroupAuthConfig.serializer(), str) 50 | } 51 | 52 | fun getOrCreateGroupConfig(bot: Bot, group: Group): GroupAuthConfig { 53 | return getGroupConfig(bot, group.groupId) ?: GroupAuthConfig(group.groupId, group.name) 54 | } 55 | 56 | fun saveGroupConfig(bot: Bot, gid: Long, config: GroupAuthConfig) { 57 | if (gid <= 0) { 58 | throw IllegalArgumentException("gid must be positive") 59 | } 60 | val configForBot = getConfigForBot(bot, "GroupConfig") 61 | if (config.groupId != gid) { 62 | throw IllegalArgumentException("config.groupId mismatch, config.groupId: ${config.groupId}, gid: $gid") 63 | } 64 | configForBot.putString( 65 | KEY_GROUP_CONFIG_FOR_GID + gid, 66 | Json.encodeToString(GroupAuthConfig.serializer(), config) 67 | ) 68 | } 69 | 70 | fun getAuthSession(bot: Bot, uid: Long): UserAuthSession? { 71 | val config = getConfigForBot(bot, "UserAuthSession") 72 | val str = config.getString(KEY_AUTH_INFO_FOR_UID + uid, null) ?: return null 73 | return Json.decodeFromString(UserAuthSession.serializer(), str) 74 | } 75 | 76 | fun saveAuthSession(bot: Bot, uid: Long, session: UserAuthSession) { 77 | val configForBot = getConfigForBot(bot, "UserAuthSession") 78 | if (session.userId != uid) { 79 | throw IllegalArgumentException("session.userId mismatch, session.userId: ${session.userId}, uid: $uid") 80 | } 81 | configForBot.putString( 82 | KEY_AUTH_INFO_FOR_UID + uid, 83 | Json.encodeToString(UserAuthSession.serializer(), session) 84 | ) 85 | } 86 | 87 | fun dropAuthSession(bot: Bot, uid: Long) { 88 | val configForBot = getConfigForBot(bot, "UserAuthSession") 89 | configForBot.removeValueForKey(KEY_AUTH_INFO_FOR_UID + uid) 90 | } 91 | 92 | object AuthStatus { 93 | const val RESET = 0 94 | const val REQUESTED = 1 95 | const val AUTHENTICATING = 2 96 | const val FAILED = 3 97 | const val SUCCESS = 4 98 | } 99 | 100 | @Serializable 101 | data class UserAuthSession( 102 | val userId: Long, 103 | var userNick: String, 104 | var targetGroupId: Long, 105 | var targetGroupName: String, 106 | var currentAuthId: Int, 107 | var currentCid: Int, 108 | var authStatus: Int, 109 | var requestTime: Long, 110 | var authStartTime: Long, 111 | var changesAllowed: Int, 112 | var originalMessageId: Long, 113 | var numCountX: Int, 114 | var numCountY: Int, 115 | var chiralList: ArrayList, 116 | var actualChiralRegion: ArrayList, 117 | var selectedRegion: ArrayList, 118 | ) { 119 | init { 120 | assert(userId > 0) 121 | assert(targetGroupId >= 0) 122 | assert(currentAuthId >= 0) 123 | } 124 | 125 | fun updateAuthInfo( 126 | authId: Int, 127 | cid: Int, 128 | changesAllowed: Int, 129 | originalMessageId: Long, 130 | numCountX: Int, 131 | numCountY: Int, 132 | chiralList: ArrayList, 133 | actualChiralRegion: ArrayList, 134 | selectedRegion: ArrayList, 135 | ) { 136 | this.currentAuthId = authId 137 | this.currentCid = cid 138 | this.authStartTime = System.currentTimeMillis() 139 | this.changesAllowed = changesAllowed 140 | this.originalMessageId = originalMessageId 141 | this.numCountX = numCountX 142 | this.numCountY = numCountY 143 | this.chiralList = chiralList 144 | this.actualChiralRegion = actualChiralRegion 145 | this.selectedRegion = selectedRegion 146 | } 147 | } 148 | 149 | object EnforceMode { 150 | const val WITH_HINT = 0 151 | const val NO_HINT = 1 152 | } 153 | 154 | @Serializable 155 | data class GroupAuthConfig( 156 | val groupId: Long, 157 | var groutName: String, 158 | var isEnabled: Boolean = true, 159 | var enforceMode: Int = EnforceMode.WITH_HINT, 160 | var startAuthTimeoutSeconds: Int = 600, 161 | var authProcedureTimeoutSeconds: Int = 600, 162 | ) { 163 | init { 164 | assert(groupId > 0) 165 | } 166 | } 167 | 168 | fun handleUserJoinRequest(bot: Bot, user: User, group: Group): Boolean { 169 | val groupConfig = getOrCreateGroupConfig(bot, group) 170 | if (!groupConfig.isEnabled) { 171 | return false 172 | } 173 | if (group.isKnown && groupConfig.groutName != group.name) { 174 | // update group name 175 | groupConfig.groutName = group.name 176 | saveGroupConfig(bot, group.groupId, groupConfig) 177 | } 178 | val session = getAuthSession(bot, user.userId) ?: UserAuthSession( 179 | userId = user.userId, 180 | userNick = user.name, 181 | targetGroupId = group.groupId, 182 | targetGroupName = group.name, 183 | currentAuthId = 0, 184 | currentCid = 0, 185 | authStatus = AuthStatus.REQUESTED, 186 | requestTime = System.currentTimeMillis(), 187 | authStartTime = 0, 188 | changesAllowed = 3, 189 | originalMessageId = 0, 190 | numCountX = 0, 191 | numCountY = 0, 192 | chiralList = ArrayList(), 193 | actualChiralRegion = ArrayList(), 194 | selectedRegion = ArrayList(), 195 | ) 196 | // update session 197 | session.authStatus = AuthStatus.REQUESTED 198 | session.requestTime = System.currentTimeMillis() 199 | session.userNick = user.name 200 | session.targetGroupId = group.groupId 201 | session.targetGroupName = group.name 202 | saveAuthSession(bot, user.userId, session) 203 | return true 204 | } 205 | 206 | fun createAuthSessionForTest(bot: Bot, user: User): UserAuthSession { 207 | var session = getAuthSession(bot, user.userId) 208 | if (session != null) { 209 | // just update the session 210 | session.userNick = user.name 211 | session.authStatus = AuthStatus.AUTHENTICATING 212 | session.authStartTime = System.currentTimeMillis() 213 | session.currentAuthId = nextAuthSequence() 214 | } else { 215 | // create a new session 216 | session = UserAuthSession( 217 | userId = user.userId, 218 | userNick = user.name, 219 | targetGroupId = 0, 220 | targetGroupName = "", 221 | currentAuthId = nextAuthSequence(), 222 | currentCid = 0, 223 | authStatus = AuthStatus.AUTHENTICATING, 224 | requestTime = System.currentTimeMillis(), 225 | authStartTime = System.currentTimeMillis(), 226 | changesAllowed = 3, 227 | originalMessageId = 0, 228 | numCountX = 0, 229 | numCountY = 0, 230 | chiralList = ArrayList(), 231 | actualChiralRegion = ArrayList(), 232 | selectedRegion = ArrayList(), 233 | ) 234 | } 235 | saveAuthSession(bot, user.userId, session) 236 | return session 237 | } 238 | 239 | } 240 | -------------------------------------------------------------------------------- /src/main/kotlin/cc/ioctl/neoauth3bot/res/ResImpl.kt: -------------------------------------------------------------------------------- 1 | package cc.ioctl.neoauth3bot.res 2 | 3 | import cc.ioctl.telebot.tdlib.obj.User 4 | 5 | object ResImpl { 6 | 7 | val zhs = object : Resources { 8 | 9 | override val btn_text_change_quiz = "换一题" 10 | override val btn_text_reset = "重置" 11 | override val btn_text_submit = "提交" 12 | override val btn_text_cancel = "取消" 13 | 14 | override val auth_instruction_part1 = "欢迎 " 15 | override val auth_instruction_part2 = " 申请加群,本群已开启验证,请在 " 16 | override val auth_instruction_part3 = " 秒内选择图中所有包含手性碳的区域,并点击提交按钮。" 17 | override val auth_ins_fail_on_timeout = "超时或验证失败将会自动拒绝申请。" 18 | override val auth_change_quiz_chances_left_part1 = "您还可以更换 " 19 | override val auth_change_quiz_chances_left_part2 = " 次题目。" 20 | override val auth_hint_all_region_count_part1 = "提示:共有 " 21 | override val auth_hint_all_region_count_part2 = " 个区域。" 22 | override val auth_current_selected_regions = "当前已选择:" 23 | override val auth_none_selected = "无" 24 | 25 | override val msg_text_auth_pass_va1 = "验证通过,用时 %d 秒。" 26 | override val msg_text_approve_success = "您应该已经是群成员了。" 27 | override val msg_text_error_denied_by_other_admin = "您的申请已被其他管理员拒绝。" 28 | override val msg_text_join_auth_required_notice_va2 = "欢迎 %1\$s 申请加入群组 %2\$s ," + 29 | "本群已开启验证,请发送 /ccg 开始验证,完成验证后即可加入。" 30 | override val msg_text_too_many_requests = "您的操作过于频繁,请稍后再试。" 31 | override val msg_text_loading = "正在加载..." 32 | override val msg_text_no_auth_required = "您没有申请加入任何群,不需要验证。" 33 | override val msg_text_command_use_in_group_only = "请在群组中使用本命令。" 34 | override val msg_text_command_use_in_private_chat_only = "该命令只能在私聊中使用。" 35 | override val msg_text_approved_manually_by_admin_va1 = "您的加群申请已被 %1\$s 的管理员人工通过。" 36 | override val msg_text_dismissed_manually_by_admin_va1 = "抱歉,您的加群申请已被 %1\$s 的管理员人工拒绝。" 37 | override val msg_text_banned_manually_by_admin_va1 = "抱歉,您已被 %1\$s 的管理员人工封禁。" 38 | 39 | override val cb_query_auth_session_not_found = "太久远了,找不到了。" 40 | override val cb_query_auth_fail_retry = "验证失败,请重试。" 41 | override val cb_query_auth_pass = "验证通过" 42 | override val cb_query_selected_va1 = "选择了 %s" 43 | override val cb_query_unselected_va1 = "取消了 %s" 44 | override val cb_query_reset_region = "已重置" 45 | override val cb_query_change_quiz_wip = "暂不开放(请手动发送 /ccg 命令)" 46 | 47 | override val help_info_category_auth = "验证相关" 48 | override val help_info_category_auth_description = "/ccg 开始入群验证以及更换题目\n" + 49 | "/config 配置入群验证" 50 | override val help_info_category_test = "测试相关" 51 | override val help_info_category_test_description = "/cc1 仅用于测试" 52 | override val help_info_category_other = "其他" 53 | override val help_info_category_other_description = "/help 显示本信息\n" + 54 | "/uptime 测试机器人存活\n" + 55 | "/about 关于本机器人" 56 | override val help_info_require_at_in_group_va1 = "在群里使用命令时请在命令后加上 @机器人用户名, 如 %s" 57 | 58 | override val help_about_desc1_part1 = "使用 " 59 | override val help_about_desc1_part2 = " 框架开发, \n分子数据库来自 " 60 | override val help_about_desc1_part3 = " ." 61 | override val help_about_desc2_part1 = "你可以在 " 62 | override val help_about_desc2_part2 = " 获取本机器人的源代码。" 63 | override val help_about_desc3 = 64 | "如需在自己群使用,需要将群启用 成员需要批准才能加入(如为公开群,请先打开 只有群成员才能发消息)," + 65 | "或者创建一个需要批准才能加入的邀请链接,然后将机器人拉到群里设置为管理员," + 66 | "并给机器人 can_invite_users (必须, 添加成员/生成邀请链接) " + 67 | "和 can_delete_messages (删除消息, 推荐, 但不是必须) 管理员权限(其他权限不需要)。\n" + 68 | "目前仍在开发阶段, 十分不稳定, 提供 0% 的 SLA." 69 | override val help_about_discussion_group_link_va1 = "反馈交流群: %s" 70 | 71 | override val btn_text_verify_anony_identity = "点此验证" 72 | override val msg_text_anonymous_admin_identity_verification_required = 73 | "您现在是以匿名管理员,请点击下方按钮验证身份。" 74 | override val cb_query_admin_permission_required = "需要管理员权限" 75 | override val cb_query_nothing_to_do_with_you = "与汝无瓜" 76 | } 77 | 78 | val eng = object : Resources { 79 | 80 | override val btn_text_change_quiz = "Change Quiz" 81 | override val btn_text_reset = "Reset" 82 | override val btn_text_submit = "Submit" 83 | override val btn_text_cancel = "Cancel" 84 | 85 | override val auth_instruction_part1 = "Welcome " 86 | override val auth_instruction_part2 = ". This group has anti-spam CAPTCHA enabled.\n" + 87 | "Please select all regions containing chiral carbon atoms (aka asymmetric carbon atoms) in " 88 | override val auth_instruction_part3 = " seconds to complete the CAPTCHA." 89 | override val auth_ins_fail_on_timeout = "Join request will be automatically approved after " + 90 | "the CAPTCHA is completed in time, vice versa." 91 | override val auth_change_quiz_chances_left_part1 = "You have " 92 | override val auth_change_quiz_chances_left_part2 = " chance(s) to change for a new CAPTCHA." 93 | override val auth_hint_all_region_count_part1 = "Hint: There should be " 94 | override val auth_hint_all_region_count_part2 = " region(s) in the answer." 95 | override val auth_current_selected_regions = "Selected: " 96 | override val auth_none_selected = "none" 97 | 98 | override val msg_text_auth_pass_va1 = "Authentication passed within %d seconds." 99 | override val msg_text_approve_success = "You should have been approved into the group." 100 | override val msg_text_error_denied_by_other_admin = "Your request was denied by an admin." 101 | override val msg_text_join_auth_required_notice_va2 = "Thank you for your group join request.\n" + 102 | "Group %2\$s has anti-spam CAPTCHA enabled. Please send /ccg to complete the CAPTCHA." 103 | override val msg_text_too_many_requests = "Too many requests. Please try again later." 104 | override val msg_text_loading = "Loading..." 105 | override val msg_text_no_auth_required = 106 | "No authentication required because you did not request to join a group." 107 | override val msg_text_command_use_in_group_only = "Please use this command in the group." 108 | override val msg_text_command_use_in_private_chat_only = "This command can only be used in private chat." 109 | override val msg_text_approved_manually_by_admin_va1 = 110 | "You have been approved by an admin into the group %1\$s." 111 | override val msg_text_dismissed_manually_by_admin_va1 = 112 | "Sorry, your request has been denied by an admin of %1\$s." 113 | override val msg_text_banned_manually_by_admin_va1 = 114 | "Sorry, you have been banned by an admin of %1\$s." 115 | 116 | override val cb_query_auth_session_not_found = "Session not found or lost. (Try to restart authentication.)" 117 | override val cb_query_auth_fail_retry = "Wrong answer. Please check your answer and try again." 118 | override val cb_query_auth_pass = "Authentication succeeded." 119 | override val cb_query_selected_va1 = "Select %s" 120 | override val cb_query_unselected_va1 = "Unselect %s" 121 | override val cb_query_reset_region = "Reset" 122 | override val cb_query_change_quiz_wip = "Not implemented. Send /ccg manually." 123 | 124 | override val help_info_category_auth = "Authentication" 125 | override val help_info_category_auth_description = "/ccg Start authentication or change CAPTCHA\n" + 126 | "/config Edit configuration" 127 | override val help_info_category_test = "Test" 128 | override val help_info_category_test_description = "/cc1 For test use only." 129 | override val help_info_category_other = "Other" 130 | override val help_info_category_other_description = "/help Show this message\n" + 131 | "/uptime Test whether this bot is alive\n" + 132 | "/about About this bot" 133 | override val help_info_require_at_in_group_va1 = 134 | "A @-mention for this bot is required to use commands in group. e.g. %s" 135 | 136 | override val help_about_desc1_part1 = "This bot is developed with " 137 | override val help_about_desc1_part2 = ". \nThe molecular database is from " 138 | override val help_about_desc1_part3 = "." 139 | override val help_about_desc2_part1 = "You may obtain the source code of this bot at " 140 | override val help_about_desc2_part2 = "" 141 | override val help_about_desc3 = "If you want to use this bot in your group, you need to enable " + 142 | "\"approve new member\" for your group (which requires owner permission) or create a " + 143 | "invite link with \"approve new member\", and then set the bot as an administrator " + 144 | "with can_invite_users (required) and can_delete_messages (recommended, but not necessary) " + 145 | "permissions (other permissions are not required).\n" + 146 | "Currently, the bot is still under development, providing 0% SLA." 147 | override val help_about_discussion_group_link_va1 = "Feedback group: %s" 148 | 149 | override val btn_text_verify_anony_identity = "Click To Verify" 150 | override val msg_text_anonymous_admin_identity_verification_required = "You are now as an anonymous admin.\n" + 151 | "Please click the button below to verify your identity." 152 | override val cb_query_admin_permission_required = "Admin permission required." 153 | override val cb_query_nothing_to_do_with_you = "It's not related to you." 154 | } 155 | 156 | fun getResourceForLanguage(lang: String): Resources { 157 | return when { 158 | lang.startsWith("zh") -> zhs 159 | else -> eng 160 | } 161 | } 162 | 163 | fun getResourceForUser(user: User): Resources { 164 | return getResourceForLanguage(user.languageCode ?: "") 165 | } 166 | 167 | } 168 | -------------------------------------------------------------------------------- /src/main/java/cc/ioctl/neoauth3bot/chiral/Molecule.java: -------------------------------------------------------------------------------- 1 | package cc.ioctl.neoauth3bot.chiral; 2 | 3 | import cc.ioctl.neoauth3bot.util.IndexFrom; 4 | 5 | import java.util.HashSet; 6 | 7 | /** 8 | * A molecule object. 9 | */ 10 | public class Molecule { 11 | 12 | private final Atom[] atoms; 13 | private final Bond[] bonds; 14 | private float maxX = 0.0f; 15 | private float maxY = 0.0f; 16 | private float minX = 0.0f; 17 | private float minY = 0.0f; 18 | private boolean invalMinMax = true; 19 | private float avgBondLength; 20 | private final String mdlMolStr; 21 | 22 | public Molecule(Atom[] a, Bond[] b, String mdlMol) { 23 | atoms = a; 24 | bonds = b; 25 | mdlMolStr = mdlMol; 26 | } 27 | 28 | private void determineMinMax() { 29 | this.invalMinMax = false; 30 | if (atoms.length == 0) { 31 | this.maxY = 0.0f; 32 | this.minY = 0.0f; 33 | this.maxX = 0.0f; 34 | this.minX = 0.0f; 35 | return; 36 | } 37 | float atomX = atomX(1); 38 | this.maxX = atomX; 39 | this.minX = atomX; 40 | float atomY = atomY(1); 41 | this.maxY = atomY; 42 | this.minY = atomY; 43 | for (int n = 2; n <= atoms.length; n++) { 44 | float x = atomX(n); 45 | float y = atomY(n); 46 | this.minX = Math.min(this.minX, x); 47 | this.maxX = Math.max(this.maxX, x); 48 | this.minY = Math.min(this.minY, y); 49 | this.maxY = Math.max(this.maxY, y); 50 | } 51 | } 52 | 53 | public Atom getAtom(@IndexFrom(1) int N) { 54 | if (N >= 1 && N <= this.atoms.length) { 55 | return this.atoms[N - 1]; 56 | } 57 | throw new IndexOutOfBoundsException("Atoms: get " + N + ", numAtoms=" + this.atoms.length); 58 | } 59 | 60 | public Bond getBond(@IndexFrom(1) int N) { 61 | if (N >= 1 && N <= this.bonds.length) { 62 | return this.bonds[N - 1]; 63 | } 64 | throw new IndexOutOfBoundsException("Bonds: get " + N + ", numBonds=" + this.bonds.length); 65 | } 66 | 67 | @IndexFrom(1) 68 | public int getAtomIndexNear(float x, float y, float tolerance) { 69 | if (atoms.length == 0) { 70 | return -1; 71 | } 72 | int N = 1; 73 | float t1, t2, t3; 74 | t1 = atoms[0].x - x; 75 | t2 = atoms[0].y - y; 76 | float curr = t1 * t1 + t2 * t2; 77 | for (int i = 1; i < atoms.length; i++) { 78 | t1 = atoms[i].x - x; 79 | t2 = atoms[i].y - y; 80 | t3 = t1 * t1 + t2 * t2; 81 | if (t3 < curr) { 82 | N = i + 1; 83 | curr = t3; 84 | } 85 | } 86 | if (curr < tolerance * tolerance) { 87 | return N; 88 | } else { 89 | return -1; 90 | } 91 | } 92 | 93 | public int atomCount() { 94 | return atoms.length; 95 | } 96 | 97 | public int bondCount() { 98 | return bonds.length; 99 | } 100 | 101 | public float atomX(@IndexFrom(1) int N) { 102 | return getAtom(N).x; 103 | } 104 | 105 | public float atomY(@IndexFrom(1) int N) { 106 | return getAtom(N).y; 107 | } 108 | 109 | public float atomZ(@IndexFrom(1) int N) { 110 | return getAtom(N).z; 111 | } 112 | 113 | public float rangeX() { 114 | return maxX() - minX(); 115 | } 116 | 117 | public float rangeY() { 118 | return maxY() - minY(); 119 | } 120 | 121 | public float minX() { 122 | if (this.invalMinMax) { 123 | determineMinMax(); 124 | } 125 | return this.minX; 126 | } 127 | 128 | public float maxX() { 129 | if (this.invalMinMax) { 130 | determineMinMax(); 131 | } 132 | return this.maxX; 133 | } 134 | 135 | public float minY() { 136 | if (this.invalMinMax) { 137 | determineMinMax(); 138 | } 139 | return this.minY; 140 | } 141 | 142 | public float maxY() { 143 | if (this.invalMinMax) { 144 | determineMinMax(); 145 | } 146 | return this.maxY; 147 | } 148 | 149 | public Bond[] getAtomDeclaredBonds(@IndexFrom(1) int N) { 150 | if (N >= 1 && N <= this.atoms.length) { 151 | HashSet ret = new HashSet<>(); 152 | for (Bond b : bonds) { 153 | if (b.from == N || b.to == N) { 154 | ret.add(b); 155 | } 156 | } 157 | return ret.toArray(new Bond[0]); 158 | } 159 | throw new IndexOutOfBoundsException("getAtomBonds: get " + N + ", bondCount=" + this.bonds.length); 160 | } 161 | 162 | @IndexFrom(1) 163 | public int getAtomId(Atom atom) { 164 | for (int i = 0; i < atoms.length; i++) { 165 | if (atom == atoms[i]) { 166 | return i + 1; 167 | } 168 | } 169 | return -1; 170 | } 171 | 172 | @IndexFrom(1) 173 | public int getBondId(Bond atom) { 174 | for (int i = 0; i < bonds.length; i++) { 175 | if (atom == bonds[i]) { 176 | return i + 1; 177 | } 178 | } 179 | return -1; 180 | } 181 | 182 | public float getAverageBondLength() { 183 | return avgBondLength; 184 | } 185 | 186 | public void initOnce() { 187 | for (int i = 0, atomsLength = atoms.length; i < atomsLength; i++) { 188 | Atom atom = atoms[i]; 189 | Bond[] bs = getAtomDeclaredBonds(i + 1); 190 | int ii = 0; 191 | for (Bond b : bs) { 192 | ii += b.type; 193 | } 194 | if (atom.hydrogenCount == 0) { 195 | switch (atom.element) { 196 | case "C": 197 | atom.hydrogenCount = Math.max(0, 4 - atom.unpaired - Math.abs(atom.charge) - ii); 198 | break; 199 | case "O": 200 | case "S": 201 | atom.hydrogenCount = Math.max(0, 2 - atom.unpaired + atom.charge - ii); 202 | break; 203 | case "N": 204 | case "P": 205 | atom.hydrogenCount = Math.max(0, 3 - atom.unpaired + atom.charge - ii); 206 | break; 207 | case "F": 208 | case "Cl": 209 | case "Br": 210 | case "I": 211 | atom.hydrogenCount = Math.max(0, 1 - atom.unpaired - Math.abs(atom.charge) - ii); 212 | break; 213 | } 214 | } 215 | if (atom.element.equals("C")) { 216 | if (bs.length == 2) { 217 | float t1 = (float) Math.atan2(atomY(bs[0].from) - atomY(bs[0].to), atomX(bs[0].from) - atomX(bs[0].to)); 218 | float t2 = (float) Math.atan2(atomY(bs[1].from) - atomY(bs[1].to), atomX(bs[1].from) - atomX(bs[1].to)); 219 | if (t1 < 0) { 220 | t1 += Math.PI; 221 | } 222 | if (t2 < 0) { 223 | t2 += Math.PI; 224 | } 225 | if (Math.abs(t1 - t2) < 10f / 360f * Math.PI * 2f) { 226 | atom.showFlag |= SHOW_FLAG_EXPLICIT; 227 | } 228 | } 229 | } 230 | float top, bottom, left, right; 231 | top = bottom = left = right = 6.28f; 232 | for (Bond b : bs) { 233 | float x1 = atomX(i + 1); 234 | float y1 = atomY(i + 1); 235 | float x2, y2; 236 | if (b.from == i + 1) { 237 | x2 = atomX(b.to); 238 | y2 = atomY(b.to); 239 | } else { 240 | x2 = atomX(b.from); 241 | y2 = atomY(b.from); 242 | } 243 | float dt = (float) Math.atan2(y2 - y1, x2 - x1); 244 | float tmp; 245 | tmp = Math.abs(dt - 0); 246 | if (tmp > Math.PI * 2) { 247 | tmp -= Math.PI * 2; 248 | } 249 | right = Math.min(right, tmp); 250 | tmp = (float) Math.min(Math.abs(dt - Math.PI), Math.abs(dt + Math.PI)); 251 | if (tmp > Math.PI * 2) { 252 | tmp -= Math.PI * 2; 253 | } 254 | left = Math.min(left, tmp); 255 | tmp = (float) Math.abs(dt - Math.PI / 2f); 256 | if (tmp > Math.PI * 2) { 257 | tmp -= Math.PI * 2; 258 | } 259 | top = Math.min(top, tmp); 260 | tmp = (float) Math.abs(dt + Math.PI / 2); 261 | if (tmp > Math.PI * 2) { 262 | tmp -= Math.PI * 2; 263 | } 264 | bottom = Math.min(bottom, tmp); 265 | } 266 | if (right > 1.0f) { 267 | atom.spareSpace = DIRECTION_RIGHT; 268 | } else if (left > 1.4f) { 269 | atom.spareSpace = DIRECTION_LEFT; 270 | } else { 271 | float max = Math.max(Math.max(top, bottom), Math.max(left, right)); 272 | if (max == right) { 273 | atom.spareSpace = DIRECTION_RIGHT; 274 | } else if (max == left) { 275 | atom.spareSpace = DIRECTION_LEFT; 276 | } else if (max == bottom) { 277 | atom.spareSpace = DIRECTION_BOTTOM; 278 | } else if (max == top) { 279 | atom.spareSpace = DIRECTION_TOP; 280 | } 281 | } 282 | } 283 | float sum = 0; 284 | Atom a1, a2; 285 | for (Bond b : bonds) { 286 | a1 = atoms[b.from - 1]; 287 | a2 = atoms[b.to - 1]; 288 | sum += Math.hypot(a1.x - a2.x, a1.y - a2.y); 289 | } 290 | avgBondLength = sum / bonds.length; 291 | } 292 | 293 | public String toMdlMolString() { 294 | return mdlMolStr; 295 | } 296 | 297 | public static final int SHOW_FLAG_DEFAULT = 0; 298 | public static final int SHOW_FLAG_EXPLICIT = 1; 299 | public static final int SHOW_FLAG_IMPLICIT = 2; 300 | 301 | public static final int DIRECTION_UNSPECIFIED = 0; 302 | public static final int DIRECTION_TOP = 1; 303 | public static final int DIRECTION_BOTTOM = 2; 304 | public static final int DIRECTION_LEFT = 4; 305 | public static final int DIRECTION_RIGHT = 8; 306 | 307 | public static final class Atom { 308 | public int charge; 309 | public String element; 310 | public int showFlag; 311 | public int hydrogenCount; 312 | public int spareSpace; 313 | public int isotope; 314 | public int mapnum; 315 | public int unpaired; 316 | public float x; 317 | public float y; 318 | public float z; 319 | public String[] extra; 320 | } 321 | 322 | public static final class Bond { 323 | public int from; 324 | public int to; 325 | public int type; 326 | public int stereoDirection; 327 | public String[] extra; 328 | } 329 | } 330 | -------------------------------------------------------------------------------- /src/main/kotlin/cc/ioctl/neoauth3bot/EventLogs.kt: -------------------------------------------------------------------------------- 1 | package cc.ioctl.neoauth3bot 2 | 3 | import cc.ioctl.neoauth3bot.svc.LogDatabaseService 4 | import cc.ioctl.telebot.tdlib.obj.Bot 5 | import cc.ioctl.telebot.tdlib.obj.Channel 6 | import cc.ioctl.telebot.tdlib.obj.Group 7 | import cc.ioctl.telebot.tdlib.tlrpc.RemoteApiException 8 | import cc.ioctl.telebot.tdlib.tlrpc.api.msg.FormattedTextBuilder 9 | import cc.ioctl.telebot.util.Log 10 | import com.google.gson.JsonObject 11 | import kotlinx.coroutines.CancellationException 12 | import kotlinx.coroutines.Job 13 | import kotlinx.coroutines.coroutineScope 14 | import kotlinx.coroutines.launch 15 | import kotlinx.coroutines.runBlocking 16 | import kotlinx.coroutines.yield 17 | import java.text.SimpleDateFormat 18 | import java.util.* 19 | 20 | object EventLogs { 21 | 22 | private const val TAG = "EventLogs" 23 | 24 | private var mErrorLogChannel: Channel? = null 25 | private var mDefaultLogChannelId: Long = 0 26 | 27 | suspend fun getLogChannelForGroup(bot: Bot, group: Group): Channel? { 28 | // TODO: 2022-08-23 add support for per-group log channels 29 | val cid = mDefaultLogChannelId 30 | if (cid == 0L) { 31 | return null 32 | } 33 | return try { 34 | val ch = bot.getChannel(cid) 35 | check(ch.canPostMessages(bot)) { "bot $bot can't post messages to channel $ch" } 36 | ch 37 | } catch (e: Exception) { 38 | Log.e(TAG, "getLogChannelForGroup", e) 39 | null 40 | } 41 | } 42 | 43 | suspend fun getErrorLogChannel(bot: Bot): Channel? { 44 | return mErrorLogChannel ?: try { 45 | val ch = bot.getChannel(mDefaultLogChannelId) 46 | check(ch.canPostMessages(bot)) { "bot $bot can't post messages to channel $ch" } 47 | mErrorLogChannel = ch 48 | ch 49 | } catch (e: Exception) { 50 | Log.e(TAG, "getErrorLogChannel", e) 51 | null 52 | } 53 | } 54 | 55 | suspend fun setDefaultLogChannelId(bot: Bot, cid: Long) { 56 | mDefaultLogChannelId = if (cid == 0L) { 57 | 0L 58 | } else { 59 | val channel = bot.getChannel(cid) 60 | check(channel.canPostMessages(bot)) { "bot $bot can't post messages to channel $channel" } 61 | cid 62 | } 63 | } 64 | 65 | suspend fun setErrorLogChannel(bot: Bot, channel: Channel) { 66 | check(channel.canPostMessages(bot)) { "bot $bot can't post messages to channel $channel" } 67 | mErrorLogChannel = channel 68 | } 69 | 70 | suspend fun doPostLogToChannel( 71 | bot: Bot, 72 | channel: Channel, 73 | category: String, 74 | group: Group?, 75 | adminId: Long = 0, 76 | userId: Long = 0, 77 | args: Map = emptyMap() 78 | ) { 79 | val userName = if (userId > 0) try { 80 | bot.getUser(userId).name 81 | } catch (e: RemoteApiException) { 82 | Log.e(TAG, "failed to resolve user $userId: $e") 83 | null 84 | } else null 85 | val adminName = if (adminId > 0) try { 86 | bot.getUser(adminId).name 87 | } catch (e: RemoteApiException) { 88 | Log.e(TAG, "failed to resolve user $adminId: $e") 89 | null 90 | } else null 91 | val msgBody = FormattedTextBuilder().apply { 92 | this + "#" + category + "\n" 93 | if (group != null) { 94 | this + Bold("Group") + ": " + group.name + " [" + Code("${group.groupId}") + "]\n" 95 | } 96 | if (adminId != 0L) { 97 | this + Bold("Admin") + ": " 98 | if (adminName != null) { 99 | this + MentionName(adminName, adminId) 100 | } 101 | this + " [" + Code("$adminId") + "]\n" 102 | } 103 | if (userId != 0L) { 104 | this + Bold("User") + ": " 105 | if (userName != null) { 106 | this + MentionName(userName, userId) 107 | } 108 | this + " [" + Code("$userId") + "]\n" 109 | } 110 | for ((key, value) in args) { 111 | this + Bold(key) + ": " + value + "\n" 112 | } 113 | }.build() 114 | bot.sendMessageForText(channel.sessionInfo, msgBody, options = JsonObject().apply { 115 | addProperty("@type", "messageSendOptions") 116 | addProperty("disable_notification", true) 117 | }) 118 | } 119 | 120 | fun postLogToChannelAsync( 121 | bot: Bot, 122 | channel: Channel, 123 | category: String, 124 | group: Group?, 125 | adminId: Long = 0, 126 | userId: Long = 0, 127 | args: Map = emptyMap() 128 | ) { 129 | runBlocking { 130 | coroutineScope { 131 | launch { 132 | yield() 133 | doPostLogToChannel(bot, channel, category, group, adminId, userId, args) 134 | }.logErrorIfFail() 135 | } 136 | } 137 | } 138 | 139 | suspend fun onJoinRequest(bot: Bot, group: Group, userId: Long) { 140 | LogDatabaseService.addLog( 141 | bot, LogDatabaseService.LogEntry( 142 | event = "JOIN_REQUEST", 143 | group = group.groupId, 144 | subject = userId, 145 | actor = userId, 146 | time = System.currentTimeMillis() / 1000L, 147 | extra = null 148 | ) 149 | ) 150 | getLogChannelForGroup(bot, group)?.let { 151 | postLogToChannelAsync( 152 | bot, it, "JOIN_REQUEST", group, 0, userId 153 | ) 154 | } 155 | } 156 | 157 | suspend fun onStartAuthTimeout(bot: Bot, group: Group, userId: Long) { 158 | LogDatabaseService.addLog( 159 | bot, LogDatabaseService.LogEntry( 160 | event = "AUTH_TIMEOUT_START", 161 | group = group.groupId, 162 | subject = userId, 163 | actor = userId, 164 | time = System.currentTimeMillis() / 1000L, 165 | extra = null 166 | ) 167 | ) 168 | getLogChannelForGroup(bot, group)?.let { 169 | postLogToChannelAsync( 170 | bot, it, "AUTH_TIMEOUT_START", group, 0, userId 171 | ) 172 | } 173 | } 174 | 175 | suspend fun onHideRequesterMissing(bot: Bot, group: Group, userId: Long) { 176 | LogDatabaseService.addLog( 177 | bot, LogDatabaseService.LogEntry( 178 | event = "HIDE_REQUESTER_MISSING", 179 | group = group.groupId, 180 | subject = userId, 181 | actor = userId, 182 | time = System.currentTimeMillis() / 1000L, 183 | extra = null 184 | ) 185 | ) 186 | getLogChannelForGroup(bot, group)?.let { 187 | postLogToChannelAsync( 188 | bot, it, "HIDE_REQUESTER_MISSING", group, 0, userId 189 | ) 190 | } 191 | } 192 | 193 | suspend fun onAuthPassed(bot: Bot, group: Group, userId: Long) { 194 | LogDatabaseService.addLog( 195 | bot, LogDatabaseService.LogEntry( 196 | event = "AUTH_PASSED", 197 | group = group.groupId, 198 | subject = userId, 199 | actor = userId, 200 | time = System.currentTimeMillis() / 1000L, 201 | extra = null 202 | ) 203 | ) 204 | getLogChannelForGroup(bot, group)?.let { 205 | postLogToChannelAsync( 206 | bot, it, "AUTH_PASSED", group, 0, userId 207 | ) 208 | } 209 | } 210 | 211 | suspend fun onAutomaticApprove(bot: Bot, group: Group, userId: Long) { 212 | LogDatabaseService.addLog( 213 | bot, LogDatabaseService.LogEntry( 214 | event = "AUTOMATIC_APPROVE", 215 | group = group.groupId, 216 | subject = userId, 217 | actor = bot.userId, 218 | time = System.currentTimeMillis() / 1000L, 219 | extra = null 220 | ) 221 | ) 222 | getLogChannelForGroup(bot, group)?.let { 223 | postLogToChannelAsync( 224 | bot, it, "AUTOMATIC_APPROVE", group, 0, userId 225 | ) 226 | } 227 | } 228 | 229 | suspend fun onManualApproveJoinRequest(bot: Bot, group: Group, userId: Long, adminId: Long) { 230 | LogDatabaseService.addLog( 231 | bot, LogDatabaseService.LogEntry( 232 | event = "APPROVE", 233 | group = group.groupId, 234 | subject = userId, 235 | actor = adminId, 236 | time = System.currentTimeMillis() / 1000L, 237 | extra = null 238 | ) 239 | ) 240 | getLogChannelForGroup(bot, group)?.let { 241 | postLogToChannelAsync( 242 | bot, it, "APPROVE", group, adminId, userId 243 | ) 244 | } 245 | } 246 | 247 | suspend fun onManualDenyJoinRequest(bot: Bot, group: Group, userId: Long, adminId: Long) { 248 | LogDatabaseService.addLog( 249 | bot, LogDatabaseService.LogEntry( 250 | event = "DISMISS", 251 | group = group.groupId, 252 | subject = userId, 253 | actor = adminId, 254 | time = System.currentTimeMillis() / 1000L, 255 | extra = null 256 | ) 257 | ) 258 | getLogChannelForGroup(bot, group)?.let { 259 | postLogToChannelAsync( 260 | bot, it, "DISMISS", group, adminId, userId 261 | ) 262 | } 263 | } 264 | 265 | private fun Job.logErrorIfFail() { 266 | invokeOnCompletion { 267 | if (it != null && it !is CancellationException) { 268 | Log.e(TAG, "job error: ${it.message}", it) 269 | } 270 | } 271 | } 272 | 273 | fun onError(tag: String, bot: Bot, err: Throwable) { 274 | onError(tag, bot, err.toString()) 275 | } 276 | 277 | private val mTimeFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ROOT) 278 | 279 | @JvmStatic 280 | fun onError(tag: String, bot: Bot, errMsg: String) { 281 | val time = System.currentTimeMillis() 282 | val timeString = mTimeFormat.format(time) 283 | LogDatabaseService.addLog( 284 | bot, LogDatabaseService.LogEntry( 285 | event = "ERROR", 286 | group = 0, 287 | subject = bot.userId, 288 | actor = bot.userId, 289 | time = time / 1000L, 290 | extra = "$tag $errMsg" 291 | ) 292 | ) 293 | bot.server.executor.execute { 294 | runBlocking { 295 | try { 296 | getErrorLogChannel(bot)?.let { 297 | val msg = "#ERROR\n$tag: $errMsg\n\n$timeString" 298 | bot.sendMessageForText(it.sessionInfo, msg) 299 | } 300 | } catch (e: Exception) { 301 | // sigh 302 | Log.e(TAG, "ChannelLog.logError: $errMsg, but $e", e) 303 | } 304 | } 305 | } 306 | } 307 | } 308 | -------------------------------------------------------------------------------- /src/main/kotlin/cc/ioctl/neoauth3bot/AdminConfigInterface.kt: -------------------------------------------------------------------------------- 1 | package cc.ioctl.neoauth3bot 2 | 3 | import cc.ioctl.telebot.tdlib.obj.Bot 4 | import cc.ioctl.telebot.tdlib.obj.Group 5 | import cc.ioctl.telebot.tdlib.obj.SessionInfo 6 | import cc.ioctl.telebot.tdlib.obj.User 7 | import cc.ioctl.telebot.tdlib.tlrpc.api.msg.FormattedTextBuilder 8 | import cc.ioctl.telebot.tdlib.tlrpc.api.msg.Message 9 | import cc.ioctl.telebot.tdlib.tlrpc.api.msg.ReplyMarkup 10 | import cc.ioctl.telebot.util.Log 11 | import kotlinx.coroutines.Dispatchers 12 | import kotlinx.coroutines.delay 13 | import kotlinx.coroutines.runBlocking 14 | import kotlinx.coroutines.withContext 15 | import java.util.* 16 | import kotlin.reflect.KMutableProperty1 17 | 18 | 19 | object AdminConfigInterface { 20 | 21 | private const val TAG = "AdminConfigInterface" 22 | 23 | object PropertyType { 24 | const val STRING = 1 25 | const val INT32 = 2 26 | const val INT64 = 3 27 | const val BOOL = 4 28 | const val FLOAT = 5 29 | const val DOUBLE = 6 30 | } 31 | 32 | data class AdminSession( 33 | val groupId: Long, 34 | val adminId: Long, 35 | var theMessageId: Long = 0L, 36 | 37 | ) 38 | 39 | // key is "gid_uid" 40 | private val mAdminSessionMap = mutableMapOf() 41 | 42 | data class ConfigPropertyMetadata( 43 | val name: String, 44 | val type: Int, 45 | val description: String, 46 | val accessor: KMutableProperty1, 47 | val validator: (T) -> Boolean 48 | ) 49 | 50 | val configProperties: Array> = arrayOf( 51 | ConfigPropertyMetadata( 52 | "总开关", 53 | PropertyType.BOOL, 54 | "是否启用本机器人; false 时则不会自动处理入群申请", 55 | SessionManager.GroupAuthConfig::isEnabled 56 | ) { true }, 57 | ConfigPropertyMetadata( 58 | "难度", 59 | PropertyType.INT32, 60 | "只能为 0 或 1; 0: 在题目中直接将所有手性碳直接标出以便降低验证难度; 1: 不标出手性碳", 61 | SessionManager.GroupAuthConfig::enforceMode 62 | ) { it in 0..1 }, 63 | ConfigPropertyMetadata( 64 | "等待开始验证时间", 65 | PropertyType.INT32, 66 | "单位: 秒; 如果用户在发送加群审核后指定的时间内未发送 /ccg 开始验证, 则自动拒绝; " + 67 | "设置为 0 则不会自动拒绝; 取值范围 {0}U[60, 86400]", 68 | SessionManager.GroupAuthConfig::startAuthTimeoutSeconds 69 | ) { it == 0 || it in 60..86400 }, 70 | ConfigPropertyMetadata( 71 | "验证最长允许时间", 72 | PropertyType.INT32, 73 | "单位: 秒; 用户在一次验证过程中最长允许的时间; 取值范围 [60, 10800], 默认 600 秒 (目前还没有实现)", 74 | SessionManager.GroupAuthConfig::authProcedureTimeoutSeconds 75 | ) { it in 60..10800 }, 76 | ) 77 | 78 | suspend fun onStartConfigCommand(bot: Bot, user: User, group: Group, origMsgId: Long) { 79 | val si = SessionInfo.forGroup(group.groupId) 80 | val botUserName = bot.username 81 | check(botUserName?.isNotEmpty() == true) { "bot username is empty" } 82 | // check whether the user is admin 83 | val isAdmin = group.isMemberAdministrative(bot, user.userId) 84 | if (!isAdmin) { 85 | bot.sendMessageForText(si, "你不是群管理员, 不能使用本命令", replyMsgId = origMsgId) 86 | return 87 | } else { 88 | val key = UUID.randomUUID().toString() 89 | mAdminSessionMap[key] = AdminSession(group.groupId, user.userId) 90 | val msgText = "请点击下方按钮打开机器人私聊配置界面,本消息将在 30 秒后自动删除。" 91 | val startLink = "https://t.me/${botUserName}?start=admincfg_$key" 92 | val replyMarkup = ReplyMarkup.InlineKeyboard( 93 | arrayOf( 94 | arrayOf( 95 | ReplyMarkup.InlineKeyboard.Button( 96 | "打开配置界面", 97 | ReplyMarkup.InlineKeyboard.Button.Type.Url(startLink) 98 | ) 99 | ) 100 | ) 101 | ) 102 | val tmpMsgId = bot.sendMessageForText(si, msgText, replyMarkup = replyMarkup, replyMsgId = origMsgId) 103 | // schedule delete the message after 30 seconds 104 | bot.server.executor.execute { 105 | try { 106 | runBlocking { 107 | withContext(Dispatchers.IO) { 108 | delay(30_000) 109 | bot.deleteMessage(si, tmpMsgId.id) 110 | } 111 | } 112 | } catch (e: Exception) { 113 | Log.e(TAG, "failed to delete msg: $e") 114 | } 115 | } 116 | } 117 | } 118 | 119 | suspend fun onStartConfigInterface(bot: Bot, si: SessionInfo, user: User, message: Message, sid: String) { 120 | val botUserName = bot.username 121 | if (botUserName.isNullOrEmpty()) { 122 | error("bot user name is null") 123 | } 124 | val startUrlPrefix = "https://t.me/${botUserName}?start=" 125 | val adminSession = mAdminSessionMap[sid] 126 | if (adminSession == null) { 127 | bot.sendMessageForText(si, "链接已过期。", replyMsgId = message.id) 128 | return 129 | } 130 | if (user.userId != adminSession.adminId) { 131 | bot.sendMessageForText(si, "你不是生成链接的管理员。", replyMsgId = message.id) 132 | return 133 | } 134 | if (!si.isTrivialPrivateChat) { 135 | bot.sendMessageForText(si, "请使用私聊会话进行配置。", replyMsgId = message.id) 136 | return 137 | } 138 | val groupId = adminSession.groupId 139 | val group = bot.getGroup(adminSession.groupId) 140 | val groupConfig = SessionManager.getOrCreateGroupConfig(bot, group) 141 | val msgBody: FormattedTextBuilder = FormattedTextBuilder().apply { 142 | this + "配置 " + group.name + " (" + Code(groupId.toString()) + ")\n\n" 143 | } 144 | for (idx in configProperties.indices) { 145 | val prop = configProperties[idx] 146 | val valueStr = getConfigValue(groupConfig, prop).toString() 147 | msgBody.apply { 148 | this + prop.name + " [" + TextUrl("修改", startUrlPrefix + "admincfgedit_${sid}_${idx}") + "]\n" 149 | this + "当前值: " + Code(valueStr) + "\n" 150 | this + prop.description + "\n\n" 151 | } 152 | } 153 | msgBody + "[" + msgBody.TextUrl("刷新", startUrlPrefix + "admincfg_${sid}") + "] " 154 | msgBody + "[" + msgBody.TextUrl("结束当前配置", startUrlPrefix + "admincfgend_${sid}") + "]" 155 | bot.sendMessageForText(si, msgBody.build(), replyMsgId = message.id) 156 | } 157 | 158 | private fun getConfigValue(cfg: SessionManager.GroupAuthConfig, property: ConfigPropertyMetadata): T { 159 | val accessor = property.accessor 160 | return accessor.getValue(cfg, accessor) 161 | } 162 | 163 | private fun parseStringInput(input: String, property: ConfigPropertyMetadata): T? { 164 | if (input.isEmpty()) { 165 | return null 166 | } 167 | when (property.type) { 168 | PropertyType.INT32 -> { 169 | return input.toIntOrNull() as T? 170 | } 171 | PropertyType.INT64 -> { 172 | return input.toLongOrNull() as T? 173 | } 174 | PropertyType.FLOAT -> { 175 | return input.toFloatOrNull() as T? 176 | } 177 | PropertyType.DOUBLE -> { 178 | return input.toDoubleOrNull() as T? 179 | } 180 | PropertyType.STRING -> { 181 | return input as T 182 | } 183 | PropertyType.BOOL -> { 184 | return when (input[0].lowercaseChar()) { 185 | 'y', '1', 't', '是', '开' -> { 186 | true as T 187 | } 188 | 'n', '0', 'f', '否', '关' -> { 189 | false as T 190 | } 191 | else -> { 192 | null 193 | } 194 | } 195 | } 196 | else -> { 197 | return null 198 | } 199 | } 200 | } 201 | 202 | private fun checkUpdateConfigValue( 203 | cfg: SessionManager.GroupAuthConfig, 204 | property: ConfigPropertyMetadata, 205 | value: T 206 | ): Boolean { 207 | val validator = property.validator 208 | if (validator(value)) { 209 | val accessor = property.accessor 210 | accessor.setValue(cfg, accessor, value) 211 | return true 212 | } 213 | return false 214 | } 215 | 216 | suspend fun onStartEditValueInterface( 217 | bot: Bot, 218 | si: SessionInfo, 219 | user: User, 220 | message: Message, 221 | sid: String, 222 | idx: Int 223 | ) { 224 | val botUserName = bot.username 225 | check(!botUserName.isNullOrEmpty()) { "bot user name is null" } 226 | val startUrlPrefix = "https://t.me/${botUserName}?start=" 227 | val adminSession = mAdminSessionMap[sid] 228 | if (adminSession == null) { 229 | bot.sendMessageForText(si, "链接已过期。", replyMsgId = message.id) 230 | return 231 | } 232 | if (user.userId != adminSession.adminId) { 233 | bot.sendMessageForText(si, "你不是生成链接的管理员。", replyMsgId = message.id) 234 | return 235 | } 236 | if (!si.isTrivialPrivateChat) { 237 | bot.sendMessageForText(si, "请使用私聊会话进行配置。", replyMsgId = message.id) 238 | return 239 | } 240 | val groupId = adminSession.groupId 241 | val group = bot.getGroup(adminSession.groupId) 242 | val groupConfig = SessionManager.getOrCreateGroupConfig(bot, group) 243 | val prop = configProperties[idx] 244 | val valueStr = getConfigValue(groupConfig, prop).toString() 245 | val msgBody: FormattedTextBuilder = FormattedTextBuilder().apply { 246 | this + prop.name + "当前值: " + Code(valueStr) + "\n" 247 | this + prop.description + "\n\n" 248 | this + "请输入(发送)新的值 [" + TextUrl("取消", startUrlPrefix + "admincfgcancel_${sid}") + "]" 249 | } 250 | bot.doOnNextMessage(si) { senderId, msg -> 251 | check(senderId == user.userId) { "senderId != user.userId" } 252 | return@doOnNextMessage runBlocking { 253 | val msgText = msg.content.get("text")?.asJsonObject?.get("text")?.asString 254 | if (msgText == null) { 255 | bot.sendMessageForText(si, "无效输入,取消修改。", replyMsgId = msg.id) 256 | return@runBlocking true 257 | } 258 | if (msgText.startsWith("/")) { 259 | // silently ignore command 260 | return@runBlocking true 261 | } 262 | val value: Any? = parseStringInput(msgText, prop) 263 | if (value == null) { 264 | bot.sendMessageForText(si, "数据格式不正确,取消修改。", replyMsgId = msg.id) 265 | return@runBlocking true 266 | } 267 | if (checkUpdateConfigValue(groupConfig, prop as ConfigPropertyMetadata, value)) { 268 | SessionManager.saveGroupConfig(bot, groupId, groupConfig) 269 | bot.sendMessageForText( 270 | si, 271 | "已将 " + prop.name + " 设置为 " + value.toString(), 272 | replyMsgId = msg.id 273 | ) 274 | } else { 275 | bot.sendMessageForText(si, "值不在范围内,取消修改。", replyMsgId = msg.id) 276 | } 277 | return@runBlocking true 278 | } 279 | } 280 | bot.sendMessageForText(si, msgBody.build(), replyMsgId = message.id) 281 | } 282 | } 283 | -------------------------------------------------------------------------------- /src/main/kotlin/cc/ioctl/neoauth3bot/AuthUserInterface.kt: -------------------------------------------------------------------------------- 1 | package cc.ioctl.neoauth3bot 2 | 3 | import cc.ioctl.neoauth3bot.chiral.ChiralCarbonHelper 4 | import cc.ioctl.neoauth3bot.chiral.MdlMolParser 5 | import cc.ioctl.neoauth3bot.chiral.Molecule 6 | import cc.ioctl.neoauth3bot.chiral.MoleculeRender 7 | import cc.ioctl.neoauth3bot.dat.ChemDatabase 8 | import cc.ioctl.neoauth3bot.res.ResImpl 9 | import cc.ioctl.neoauth3bot.util.BinaryUtils 10 | import cc.ioctl.telebot.tdlib.obj.Bot 11 | import cc.ioctl.telebot.tdlib.obj.SessionInfo 12 | import cc.ioctl.telebot.tdlib.obj.User 13 | import cc.ioctl.telebot.tdlib.tlrpc.RemoteApiException 14 | import cc.ioctl.telebot.tdlib.tlrpc.api.msg.Message 15 | import cc.ioctl.telebot.tdlib.tlrpc.api.msg.ReplyMarkup 16 | import cc.ioctl.telebot.util.Base64 17 | import cc.ioctl.telebot.util.Log 18 | import cc.ioctl.telebot.util.TokenBucket 19 | import java.io.File 20 | import java.util.* 21 | 22 | object AuthUserInterface { 23 | 24 | private const val TAG = "NeoAuth3Bot.CUI" 25 | private const val BTN_TYPE_REGION = 0 26 | private const val BTN_TYPE_CHANGE = 1 27 | private const val BTN_TYPE_CLEAR = 2 28 | private const val BTN_TYPE_SUBMIT = 3 29 | 30 | private val mNewAuthBpf = TokenBucket(2, 5000) 31 | 32 | fun checkAuthRate(userId: Long): Boolean { 33 | if (userId == 0L) { 34 | throw IllegalArgumentException("userId must not be 0") 35 | } 36 | return mNewAuthBpf.consume(userId) >= 0 37 | } 38 | 39 | fun buildMatrixButtonMarkup(user: User, uniqueId: Int, info: SessionManager.UserAuthSession): ReplyMarkup { 40 | val r = ResImpl.getResourceForUser(user) 41 | val numX = info.numCountX 42 | val numY = info.numCountY 43 | // { i32 id, u8 unused, u8 flags, u8 type, u8 pos } 44 | // 8 bytes per button 45 | val bytes8 = ByteArray(8) 46 | val rows = ArrayList>() 47 | for (y in 0 until numY) { 48 | val cols = ArrayList() 49 | for (x in 0 until numX) { 50 | val btnId = ((x shl 4) or y) 51 | val isSelected = info.selectedRegion.contains(btnId) 52 | val sb = StringBuilder().apply { 53 | if (isSelected) appendCodePoint('['.code) 54 | appendCodePoint((('A' + x).code)) 55 | appendCodePoint((('1' + y).code)) 56 | if (isSelected) appendCodePoint(']'.code) 57 | } 58 | BinaryUtils.writeLe32(bytes8, 0, uniqueId) 59 | bytes8[4] = 0x00.toByte() 60 | bytes8[5] = if (isSelected) 0x01.toByte() else 0x00.toByte() 61 | bytes8[6] = 0x00.toByte() 62 | bytes8[7] = btnId.toByte() 63 | val button = ReplyMarkup.InlineKeyboard.Button( 64 | sb.toString(), ReplyMarkup.InlineKeyboard.Button.Type.Callback( 65 | Base64.encodeToString(bytes8, Base64.NO_WRAP) 66 | ) 67 | ) 68 | cols.add(button) 69 | } 70 | rows.add(cols.toTypedArray()) 71 | } 72 | val texts = arrayOf( 73 | r.btn_text_change_quiz, r.btn_text_reset, r.btn_text_submit 74 | ) 75 | ArrayList().apply { 76 | for (i in texts.indices) { 77 | bytes8[6] = (i + 1).toByte() 78 | add( 79 | ReplyMarkup.InlineKeyboard.Button( 80 | texts[i], ReplyMarkup.InlineKeyboard.Button.Type.Callback( 81 | Base64.encodeToString(bytes8, Base64.NO_WRAP) 82 | ) 83 | ) 84 | ) 85 | } 86 | }.let { 87 | rows.add(it.toTypedArray()) 88 | } 89 | return ReplyMarkup.InlineKeyboard(rows.toTypedArray()) 90 | } 91 | 92 | private suspend fun errorAlert(bot: Bot, queryId: Long, msg: String) { 93 | bot.answerCallbackQuery(queryId, msg, true) 94 | } 95 | 96 | suspend fun onBtnClick( 97 | bot: Bot, 98 | user: User, 99 | si: SessionInfo, 100 | auth3Info: SessionManager.UserAuthSession, 101 | bytes8: ByteArray, 102 | queryId: Long 103 | ) { 104 | var isInvalidateRequired: Boolean = false 105 | val r = ResImpl.getResourceForUser(user) 106 | val msg: String 107 | val flags = bytes8[5].toInt() 108 | val type = bytes8[6].toInt() 109 | val btnArgs = bytes8[7].toInt() 110 | when (type) { 111 | BTN_TYPE_REGION -> { 112 | val x = (btnArgs shr 4) and 0x0f 113 | val y = btnArgs and 0x0f 114 | val regionName = StringBuilder().apply { 115 | appendCodePoint((('A' + x).code)) 116 | appendCodePoint((('1' + y).code)) 117 | }.toString() 118 | if (x >= auth3Info.numCountX || y >= auth3Info.numCountY) { 119 | val msg = "Invalid button, x: $x, y: $y, w: ${auth3Info.numCountX}, x: ${auth3Info.numCountY}" 120 | errorAlert(bot, queryId, msg) 121 | return 122 | } 123 | val originalMessageId = auth3Info.originalMessageId 124 | if (originalMessageId == 0L) { 125 | errorAlert(bot, queryId, "Invalid argument, originalMessageId is 0") 126 | return 127 | } 128 | if (flags and 1 == 0) { 129 | // unchecked -> checked 130 | auth3Info.selectedRegion.add(x shl 4 or y) 131 | msg = r.format(r.cb_query_selected_va1, regionName) 132 | } else { 133 | // checked -> unchecked 134 | auth3Info.selectedRegion.remove(x shl 4 or y) 135 | msg = r.format(r.cb_query_unselected_va1, regionName) 136 | } 137 | isInvalidateRequired = true 138 | } 139 | BTN_TYPE_CHANGE -> { 140 | // change request 141 | errorAlert(bot, queryId, r.cb_query_change_quiz_wip) 142 | return 143 | } 144 | BTN_TYPE_CLEAR -> { 145 | // clear 146 | if (auth3Info.selectedRegion.isNotEmpty()) { 147 | auth3Info.selectedRegion.clear() 148 | isInvalidateRequired = true 149 | } 150 | msg = r.cb_query_reset_region 151 | } 152 | BTN_TYPE_SUBMIT -> { 153 | // submit 154 | val got = ArrayList(auth3Info.selectedRegion).also { 155 | it.sort() 156 | } 157 | val expected = auth3Info.actualChiralRegion 158 | var isCorrect = false 159 | if (got.size == expected.size) { 160 | isCorrect = true 161 | for (i in got) { 162 | if (!expected.contains(i)) { 163 | isCorrect = false 164 | break 165 | } 166 | } 167 | } 168 | if (isCorrect) { 169 | Log.d(TAG, "user $user got the correct answer") 170 | try { 171 | onAuthenticationSuccess(bot, user, si, auth3Info) 172 | bot.answerCallbackQuery(queryId, r.cb_query_auth_pass, false) 173 | } catch (e: Exception) { 174 | Log.e(TAG, "onAuthenticationSuccess: $e", e) 175 | EventLogs.onError(TAG, bot, e) 176 | bot.answerCallbackQuery(queryId, e.toString(), true) 177 | } 178 | return 179 | } else { 180 | bot.answerCallbackQuery(queryId, r.cb_query_auth_fail_retry, true) 181 | } 182 | return 183 | } 184 | else -> { 185 | // unknown 186 | errorAlert(bot, queryId, "Invalid button type: $type") 187 | return 188 | } 189 | } 190 | try { 191 | SessionManager.saveAuthSession(bot, user.userId, auth3Info) 192 | if (isInvalidateRequired) { 193 | // invalidate 194 | val targetGid = auth3Info.targetGroupId 195 | val groupConfig = SessionManager.getGroupConfig(bot, targetGid) 196 | val maxDuration = groupConfig?.authProcedureTimeoutSeconds ?: 600 197 | bot.getMessage(si, auth3Info.originalMessageId, false) 198 | bot.editMessageCaption( 199 | si, 200 | auth3Info.originalMessageId, 201 | LocaleHelper.createFormattedMsgText(auth3Info, user, maxDuration), 202 | buildMatrixButtonMarkup(user, auth3Info.currentAuthId, auth3Info) 203 | ) 204 | } 205 | bot.answerCallbackQuery(queryId, msg, false) 206 | } catch (e: Exception) { 207 | if (e.message.toString().contains("MESSAGE_NOT_MODIFIED")) { 208 | // ignore 209 | return 210 | } 211 | Log.e(TAG, "onBtnClick: editMessageCaption error: " + e.message, e) 212 | errorAlert(bot, queryId, e.message ?: e.toString()) 213 | } 214 | } 215 | 216 | suspend fun onStartAuthCommand(bot: Bot, si: SessionInfo, user: User, msg: Message, isForTest: Boolean) { 217 | val senderId = user.userId 218 | val r = ResImpl.getResourceForUser(user) 219 | if (!checkAuthRate(senderId)) { 220 | bot.sendMessageForText(si, r.msg_text_too_many_requests) 221 | return 222 | } 223 | var auth3Info = SessionManager.getAuthSession(bot, senderId) 224 | val targetGid = auth3Info?.targetGroupId ?: 0L 225 | if (!isForTest && targetGid == 0L) { 226 | bot.sendMessageForText(si, r.msg_text_no_auth_required) 227 | return 228 | } 229 | if (auth3Info == null) { 230 | auth3Info = SessionManager.createAuthSessionForTest(bot, user) 231 | } 232 | startNewAuth(bot, si, user, auth3Info, requestMsgId = msg.id, previousMsgId = 0) 233 | } 234 | 235 | suspend fun startNewAuth( 236 | bot: Bot, 237 | si: SessionInfo, 238 | user: User, 239 | auth3Info: SessionManager.UserAuthSession, 240 | requestMsgId: Long, 241 | previousMsgId: Long 242 | ) { 243 | val r = ResImpl.getResourceForUser(user) 244 | val tmpMsgId = bot.sendMessageForText(si, r.msg_text_loading, replyMsgId = requestMsgId).id 245 | try { 246 | val authId = SessionManager.nextAuthSequence() 247 | val targetGid = auth3Info.targetGroupId 248 | auth3Info.authStatus = SessionManager.AuthStatus.AUTHENTICATING 249 | val groupConfig = if (targetGid > 0) { 250 | val group = bot.getGroup(targetGid) 251 | SessionManager.getOrCreateGroupConfig(bot, group) 252 | } else null 253 | Log.d(TAG, "startNewAuth: authId: $authId, user: ${user.userId}, gid: ${targetGid}") 254 | val t0 = System.currentTimeMillis() 255 | val cid = ChemDatabase.nextRandomCid() 256 | val molecule = MdlMolParser.parseString( 257 | ChemDatabase.loadChemTableString(cid)!! 258 | ) 259 | val t1 = System.currentTimeMillis() 260 | val cfg = initMoleculeConfig(molecule) 261 | val chirals = ChiralCarbonHelper.getMoleculeChiralCarbons(molecule) 262 | val actualChiralRegions = HashSet(5).also { regions -> 263 | chirals.forEach { cidx -> 264 | val gridWidth = cfg.width / cfg.gridCountX 265 | val gridHeight = cfg.height / cfg.gridCountY 266 | val x = cfg.transformX(molecule, molecule.atomX(cidx)) 267 | val y = cfg.transformY(molecule, molecule.atomY(cidx)) 268 | val xn = (x / gridWidth).toInt() 269 | val yn = (y / gridHeight).toInt() 270 | val gridId = (xn shl 4) or yn 271 | regions.add(gridId) 272 | } 273 | }.let { 274 | ArrayList(it).also { 275 | it.sort() 276 | } 277 | } 278 | if (groupConfig?.enforceMode == SessionManager.EnforceMode.WITH_HINT) { 279 | cfg.shownChiralCarbons = ArrayList(chirals) 280 | } else { 281 | cfg.shownChiralCarbons = ArrayList() 282 | } 283 | val maxDuration = groupConfig?.authProcedureTimeoutSeconds ?: 600 284 | val t2 = System.currentTimeMillis() 285 | val tmpFile: File = MoleculeRender.renderMoleculeAsImage(molecule, cfg).use { img -> 286 | img.encodeToData()?.use { 287 | val f = File.createTempFile(System.currentTimeMillis().toString(), ".png") 288 | f.writeBytes(it.bytes) 289 | f 290 | } ?: throw RuntimeException("failed to encode image") 291 | } 292 | val t3 = System.currentTimeMillis() 293 | // update auth info 294 | auth3Info.updateAuthInfo( 295 | authId = authId, 296 | cid = cid, 297 | originalMessageId = 0L, 298 | changesAllowed = 2, 299 | numCountX = cfg.gridCountX, 300 | numCountY = cfg.gridCountY, 301 | chiralList = ArrayList(chirals), 302 | actualChiralRegion = actualChiralRegions, 303 | selectedRegion = ArrayList() 304 | ) 305 | val markup = buildMatrixButtonMarkup(user, auth3Info.currentAuthId, auth3Info) 306 | val ret = bot.sendMessageForPhoto( 307 | si, 308 | tmpFile, 309 | LocaleHelper.createFormattedMsgText(auth3Info, user, maxDuration), 310 | markup, 311 | replyMsgId = requestMsgId 312 | ) 313 | val t4 = System.currentTimeMillis() 314 | auth3Info.originalMessageId = ret.id 315 | SessionManager.saveAuthSession(bot, user.userId, auth3Info) 316 | println("msg id = " + ret.id + ", serverMsgId = " + ret.serverMsgId) 317 | Log.d(TAG, "load cost: ${t1 - t0}, chiral cost: ${t2 - t1}, render cost: ${t3 - t2}, send cost: ${t4 - t3}") 318 | // delete temporary file 319 | tmpFile.delete() 320 | } catch (e: Exception) { 321 | val msg = "create auth3 error: " + (e.message ?: e.toString()) 322 | Log.e(TAG, msg, e) 323 | bot.sendMessageForText(si, msg) 324 | } 325 | bot.deleteMessage(si, tmpMsgId) 326 | } 327 | 328 | private fun initMoleculeConfig(molecule: Molecule): MoleculeRender.MoleculeRenderConfig { 329 | val cfg = MoleculeRender.calculateRenderRect(molecule, 720) 330 | cfg.gridCountX = 5 331 | cfg.gridCountY = 3 332 | cfg.drawGrid = true 333 | return cfg 334 | } 335 | 336 | private suspend fun onAuthenticationSuccess( 337 | bot: Bot, user: User, si: SessionInfo, auth3Info: SessionManager.UserAuthSession 338 | ) { 339 | val r = ResImpl.getResourceForUser(user) 340 | val targetGroupId = auth3Info.targetGroupId 341 | val now = System.currentTimeMillis() 342 | val cost = ((now - auth3Info.authStartTime) / 1000).toInt() 343 | bot.sendMessageForText(si, String.format(Locale.ROOT, r.msg_text_auth_pass_va1, cost)) 344 | bot.deleteMessage(si, auth3Info.originalMessageId) 345 | var isApprovalFailure = false 346 | if (targetGroupId != 0L) { 347 | // approve user to join group 348 | // resolve chat and user to make TDLib happy 349 | bot.getGroup(targetGroupId) 350 | bot.getUser(auth3Info.userId) 351 | try { 352 | bot.processChatJoinRequest(targetGroupId, auth3Info.userId, true) 353 | Log.d(TAG, "approved user ${auth3Info.userId} into group $targetGroupId") 354 | } catch (e: RemoteApiException) { 355 | if (e.message?.contains("USER_ALREADY_PARTICIPANT") == true) { 356 | // ignore 357 | } else if (e.message?.contains("HIDE_REQUESTER_MISSING") == true) { 358 | isApprovalFailure = true 359 | EventLogs.onHideRequesterMissing(bot, bot.getGroup(targetGroupId), user.userId) 360 | } else { 361 | // rethrow 362 | throw e 363 | } 364 | } 365 | EventLogs.onAuthPassed(bot, bot.getGroup(targetGroupId), user.userId) 366 | } 367 | if (!isApprovalFailure) { 368 | SessionManager.dropAuthSession(bot, auth3Info.userId) 369 | auth3Info.originalMessageId = 0L 370 | if (targetGroupId != 0L) { 371 | bot.sendMessageForText(si, r.msg_text_approve_success) 372 | } 373 | } else { 374 | bot.sendMessageForText(si, r.msg_text_error_denied_by_other_admin) 375 | } 376 | } 377 | 378 | } 379 | -------------------------------------------------------------------------------- /src/main/java/cc/ioctl/neoauth3bot/chiral/MoleculeRender.java: -------------------------------------------------------------------------------- 1 | package cc.ioctl.neoauth3bot.chiral; 2 | 3 | import cc.ioctl.neoauth3bot.util.IndexFrom; 4 | import cc.ioctl.telebot.util.IoUtils; 5 | import org.jetbrains.annotations.NotNull; 6 | import org.jetbrains.annotations.Nullable; 7 | import org.jetbrains.skija.*; 8 | 9 | import java.io.InputStream; 10 | import java.util.List; 11 | import java.util.Objects; 12 | 13 | /** 14 | * Render a molecule object. 15 | *

16 | * SKIA is used as the backend to render the molecule. 17 | */ 18 | public class MoleculeRender { 19 | 20 | private MoleculeRender() { 21 | throw new AssertionError("No instances"); 22 | } 23 | 24 | private static Typeface sRobotoNormal = null; 25 | 26 | public static class MoleculeRenderConfig { 27 | public int width; 28 | public int height; 29 | public float fontSize; 30 | public float scaleFactor; 31 | 32 | public int gridCountX = 5; 33 | public int gridCountY = 5; 34 | public boolean drawGrid = true; 35 | public float[] labelTop; 36 | 37 | @Nullable 38 | public List shownChiralCarbons; 39 | 40 | public float[] labelLeft; 41 | public float[] labelRight; 42 | public float[] labelBottom; 43 | 44 | public float transformX(@NotNull Molecule molecule, float x) { 45 | float dx = 0 + fontSize; 46 | float mx = molecule.minX(); 47 | return dx + scaleFactor * (x - mx); 48 | } 49 | 50 | public float transformY(@NotNull Molecule molecule, float y) { 51 | float dy = height - fontSize; 52 | float my = molecule.minY(); 53 | return dy - scaleFactor * (y - my); 54 | } 55 | } 56 | 57 | public static MoleculeRenderConfig calculateRenderRect(@NotNull Molecule molecule, int maxSize) { 58 | float rx = molecule.rangeX(); 59 | float ry = molecule.rangeY(); 60 | if (rx == 0) { 61 | throw new IllegalArgumentException("Molecule has no X range"); 62 | } 63 | if (ry == 0) { 64 | throw new IllegalArgumentException("Molecule has no Y range"); 65 | } 66 | 67 | float scaleFactor = Math.min(maxSize / rx, maxSize / ry); 68 | 69 | float fontSize = molecule.getAverageBondLength() / 1.8f * scaleFactor; 70 | 71 | fontSize = Math.min(fontSize, maxSize / 16.0f); 72 | 73 | int width = (int) (rx * scaleFactor); 74 | int height = (int) (ry * scaleFactor); 75 | 76 | MoleculeRenderConfig config = new MoleculeRenderConfig(); 77 | config.width = width + 2 * (int) fontSize; 78 | config.height = height + 2 * (int) fontSize; 79 | config.fontSize = fontSize; 80 | config.scaleFactor = scaleFactor; 81 | return config; 82 | } 83 | 84 | public static Image renderMoleculeAsImage(Molecule molecule, MoleculeRenderConfig cfg) { 85 | Objects.requireNonNull(cfg); 86 | Objects.requireNonNull(molecule); 87 | int weight = cfg.width; 88 | int height = cfg.height; 89 | if (weight * height == 0) { 90 | throw new IllegalArgumentException("width = " + weight + ", height = " + height); 91 | } 92 | if (cfg.fontSize <= 0 || cfg.scaleFactor <= 0) { 93 | throw new IllegalArgumentException("fontSize = " + cfg.fontSize + ", scaleFactor = " + cfg.scaleFactor); 94 | } 95 | FontMgr fontMgr = FontMgr.getDefault(); 96 | if (sRobotoNormal == null) { 97 | InputStream is = MoleculeRender.class.getClassLoader().getResourceAsStream("Roboto-Regular.ttf"); 98 | if (is == null) { 99 | throw new RuntimeException("assets Roboto-Regular.ttf not found"); 100 | } 101 | try { 102 | Data data = Data.makeFromBytes(IoUtils.readFully(is)); 103 | Typeface typeface = fontMgr.makeFromData(data); 104 | if (typeface == null) { 105 | throw new RuntimeException("Failed to create typeface"); 106 | } 107 | sRobotoNormal = typeface; 108 | } catch (Exception e) { 109 | throw new RuntimeException("Failed to read assets Roboto-Regular.ttf", e); 110 | } 111 | } 112 | Typeface typeface = Objects.requireNonNull(sRobotoNormal, "sRobotoNormal is null"); 113 | Font font = new Font(typeface, cfg.fontSize); 114 | Surface surface = Surface.makeRasterN32Premul(weight, height); 115 | Canvas canvas = surface.getCanvas(); 116 | canvas.clear(0xffffffff); 117 | Paint paint = new Paint(); 118 | if (cfg.drawGrid && cfg.gridCountX > 0 && cfg.gridCountY > 0) { 119 | doDrawGridTagForBackground(canvas, font, paint, cfg); 120 | } 121 | doDrawMolecule(canvas, paint, molecule, font, cfg); 122 | Image image = surface.makeImageSnapshot(); 123 | surface.close(); 124 | paint.close(); 125 | return image; 126 | } 127 | 128 | public static void doDrawGridTagForBackground(Canvas canvas, Font font, Paint paint, MoleculeRenderConfig cfg) { 129 | float unitSizeX = (float) cfg.width / cfg.gridCountX; 130 | float unitSizeY = (float) cfg.height / cfg.gridCountY; 131 | float fontSize = Math.min(Math.min(unitSizeX, unitSizeY) / 2.0f, cfg.fontSize); 132 | final int gridColor1 = 0xFFFFFFFF; 133 | final int gridColor2 = 0xFFE0E0E0; 134 | final int gridTagTextColor1 = 0xFFA0A0A0; 135 | final int gridTagTextColor2 = 0xFFFFFFFF; 136 | // draw block background 137 | for (int i = 0; i < cfg.gridCountX; i++) { 138 | for (int j = 0; j < cfg.gridCountY; j++) { 139 | float x = i * unitSizeX; 140 | float y = j * unitSizeY; 141 | paint.setColor((i + j) % 2 == 0 ? gridColor1 : gridColor2); 142 | canvas.drawRect(Rect.makeLTRB(x, y, x + unitSizeX, y + unitSizeY), paint); 143 | } 144 | } 145 | // draw tag text 146 | FontMetrics metrics = font.getMetrics(); 147 | font.setSize(fontSize); 148 | for (int i = 0; i < cfg.gridCountX; i++) { 149 | for (int j = 0; j < cfg.gridCountY; j++) { 150 | float x = i * unitSizeX; 151 | float y = j * unitSizeY; 152 | paint.setColor((i + j) % 2 == 0 ? gridTagTextColor1 : gridTagTextColor2); 153 | String sb = new StringBuilder().appendCodePoint('A' + i).appendCodePoint('1' + j).toString(); 154 | canvas.drawString(sb, x + fontSize * 0.25f, y - metrics.getTop(), font, paint); 155 | } 156 | } 157 | } 158 | 159 | public static void calcLinePointConfined(float x, float y, float x2, float y2, float left, 160 | float right, float top, float bottom, float[] out) { 161 | float w = x2 > x ? right : left; 162 | float h = y2 < y ? top : bottom; 163 | float k = (float) Math.atan2(h, w); 164 | float sigx = Math.signum(x2 - x); 165 | float sigy = Math.signum(y2 - y); 166 | float absRad = (float) Math.atan2(Math.abs(y2 - y), Math.abs(x2 - x)); 167 | if (absRad > k) { 168 | out[0] = (float) (x + sigx * h / Math.tan(absRad)); 169 | out[1] = y + sigy * h; 170 | } else { 171 | out[0] = x + sigx * w; 172 | out[1] = (float) (y + sigy * w * Math.tan(absRad)); 173 | } 174 | } 175 | 176 | public static void doDrawMolecule(Canvas canvas, Paint paint, Molecule molecule, Font font, MoleculeRenderConfig cfg) { 177 | paint.setAntiAlias(true); 178 | 179 | final int textColor = 0xFF000000; 180 | 181 | final float scaleFactor = cfg.scaleFactor; 182 | 183 | long beginTimestamp = System.currentTimeMillis(); 184 | 185 | List selectedChiral = cfg.shownChiralCarbons; 186 | 187 | float dx = 0 + cfg.fontSize; 188 | float dy = cfg.height - cfg.fontSize; 189 | float mx = molecule.minX(); 190 | float my = molecule.minY(); 191 | FontMetrics fontMetrics = font.getMetrics(); 192 | 193 | float distance = (fontMetrics.getBottom() - fontMetrics.getTop()) / 2 - fontMetrics.getBottom(); 194 | if (cfg.labelTop == null || cfg.labelTop.length < molecule.atomCount()) { 195 | cfg.labelTop = new float[molecule.atomCount()]; 196 | cfg.labelBottom = new float[molecule.atomCount()]; 197 | cfg.labelLeft = new float[molecule.atomCount()]; 198 | cfg.labelRight = new float[molecule.atomCount()]; 199 | } 200 | paint.setColor(textColor); 201 | paint.setStrokeWidth(cfg.fontSize / 12); 202 | Molecule.Atom atom, p1, p2; 203 | Molecule.Bond bond; 204 | for (int i = 0; i < molecule.atomCount(); i++) { 205 | atom = molecule.getAtom(i + 1); 206 | font.setSize(cfg.fontSize); 207 | if (atom.element.equals("C") && atom.charge == 0 && atom.unpaired == 0 && 208 | (atom.showFlag & Molecule.SHOW_FLAG_EXPLICIT) == 0) { 209 | cfg.labelLeft[i] = cfg.labelRight[i] = 0; 210 | cfg.labelTop[i] = cfg.labelBottom[i] = 0; 211 | if (selectedChiral != null && selectedChiral.contains(i + 1)) { 212 | float textWidth = font.measureTextWidth("*"); 213 | float r = textWidth / 4 + font.getSize() / 4; 214 | float cx, cy; 215 | if (atom.spareSpace == Molecule.DIRECTION_BOTTOM) { 216 | cx = dx + scaleFactor * (atom.x - mx); 217 | cy = dy - scaleFactor * (atom.y - my) + 2 * r; 218 | } else if (atom.spareSpace == Molecule.DIRECTION_LEFT) { 219 | cx = dx + scaleFactor * (atom.x - mx) - 2 * r; 220 | cy = dy - scaleFactor * (atom.y - my); 221 | } else if (atom.spareSpace == Molecule.DIRECTION_TOP) { 222 | cx = dx + scaleFactor * (atom.x - mx); 223 | cy = dy - scaleFactor * (atom.y - my) - 2 * r; 224 | } else {//DIRECTION_RIGHT 225 | cx = dx + scaleFactor * (atom.x - mx) + 2 * r; 226 | cy = dy - scaleFactor * (atom.y - my); 227 | } 228 | canvas.drawString("*", cx - textWidth / 2, cy + distance, font, paint); 229 | } 230 | } else { 231 | cfg.labelLeft[i] = cfg.labelRight[i] = font.measureTextWidth(atom.element) / 2; 232 | cfg.labelTop[i] = (-fontMetrics.getAscent()) / 2; 233 | cfg.labelBottom[i] = (fontMetrics.getDescent() / 2 - fontMetrics.getAscent()) / 2; 234 | drawStringCenterHorizontal(canvas, atom.element, dx + scaleFactor * (atom.x - mx), 235 | dy - scaleFactor * (atom.y - my) + distance, font, paint); 236 | if (selectedChiral != null && selectedChiral.contains(i + 1)) { 237 | float sWidth = font.measureTextWidth("*"); 238 | canvas.drawString("*", 239 | dx + scaleFactor * (atom.x - mx) - cfg.labelLeft[i] - sWidth / 2f, 240 | dy - scaleFactor * (atom.y - my) + distance, font, paint); 241 | cfg.labelLeft[i] += sWidth; 242 | } 243 | if (atom.charge != 0) { 244 | int c = atom.charge; 245 | String text; 246 | if (c > 0) { 247 | if (c == 1) { 248 | text = "+"; 249 | } else { 250 | text = c + "+"; 251 | } 252 | } else { 253 | if (c == -1) { 254 | text = "-"; 255 | } else { 256 | text = -c + "-"; 257 | } 258 | } 259 | font.setSize(cfg.fontSize / 1.5f); 260 | float chgwidth = font.measureTextWidth(text); 261 | FontMetrics chgFontMetrics = font.getMetrics(); 262 | float chgdis = (chgFontMetrics.getBottom() - chgFontMetrics.getTop()) / 2 263 | - chgFontMetrics.getBottom(); 264 | drawStringCenterHorizontal(canvas, text, 265 | dx + scaleFactor * (atom.x - mx) + cfg.labelRight[i] + chgwidth / 2, 266 | dy - scaleFactor * (atom.y - my) + fontMetrics.getTop() / 3 + chgdis, 267 | font, paint); 268 | } 269 | if (atom.hydrogenCount > 0) { 270 | int hCount = atom.hydrogenCount; 271 | float hNumWidth = 0; 272 | if (hCount > 1) { 273 | font.setSize(cfg.fontSize / 2); 274 | hNumWidth = font.measureTextWidth("" + hCount); 275 | } 276 | font.setSize(cfg.fontSize); 277 | float hWidth = font.measureTextWidth("H"); 278 | float hcx, hcy; 279 | if (atom.spareSpace == Molecule.DIRECTION_BOTTOM) { 280 | hcx = dx + scaleFactor * (atom.x - mx); 281 | hcy = dy - scaleFactor * (atom.y - my) - fontMetrics.getAscent(); 282 | cfg.labelBottom[i] += -fontMetrics.getAscent(); 283 | } else if (atom.spareSpace == Molecule.DIRECTION_LEFT) { 284 | hcx = dx + scaleFactor * (atom.x - mx) - cfg.labelLeft[i] - hWidth / 2 285 | - hNumWidth; 286 | cfg.labelLeft[i] += hWidth + hNumWidth / 2 * 2; 287 | hcy = dy - scaleFactor * (atom.y - my); 288 | } else if (atom.spareSpace == Molecule.DIRECTION_TOP) { 289 | hcx = dx + scaleFactor * (atom.x - mx); 290 | hcy = dy - scaleFactor * (atom.y - my) + fontMetrics.getAscent(); 291 | cfg.labelTop[i] += -fontMetrics.getAscent(); 292 | } else {//DIRECTION_RIGHT 293 | hcx = dx + scaleFactor * (atom.x - mx) + cfg.labelRight[i] + hWidth / 2; 294 | cfg.labelRight[i] += hWidth + hNumWidth / 2 * 2; 295 | hcy = dy - scaleFactor * (atom.y - my); 296 | } 297 | drawStringCenterHorizontal(canvas, "H", hcx, hcy + distance, font, paint); 298 | if (hCount > 1) { 299 | font.setSize(cfg.fontSize / 2); 300 | drawStringCenterHorizontal(canvas, "" + hCount, hcx + hWidth / 2 + hNumWidth / 2, 301 | hcy - fontMetrics.getTop() / 2, font, paint); 302 | } 303 | } 304 | } 305 | } 306 | for (int i = 0; i < molecule.bondCount(); i++) { 307 | bond = molecule.getBond(i + 1); 308 | p1 = molecule.getAtom(bond.from); 309 | p2 = molecule.getAtom(bond.to); 310 | drawBond(canvas, paint, cfg, 311 | dx + scaleFactor * (p1.x - mx), dy - scaleFactor * (p1.y - my), 312 | dx + scaleFactor * (p2.x - mx), dy - scaleFactor * (p2.y - my), 313 | bond.type, bond.from - 1, bond.to - 1); 314 | } 315 | } 316 | 317 | private static void drawBond(Canvas canvas, Paint paint, MoleculeRenderConfig cfg, float x1, float y1, float x2, float y2, int type, 318 | @IndexFrom(0) int idx1, @IndexFrom(0) int idx2) { 319 | float[] ret = new float[2]; 320 | float rad = (float) Math.atan2(y2 - y1, x2 - x1); 321 | calcLinePointConfined(x1, y1, x2, y2, cfg.labelLeft[idx1], cfg.labelRight[idx1], cfg.labelTop[idx1], 322 | cfg.labelBottom[idx1], ret); 323 | float basex1 = ret[0]; 324 | float basey1 = ret[1]; 325 | calcLinePointConfined(x2, y2, x1, y1, cfg.labelLeft[idx2], cfg.labelRight[idx2], cfg.labelTop[idx2], 326 | cfg.labelBottom[idx2], ret); 327 | float delta = cfg.fontSize / 6; 328 | float basex2 = ret[0]; 329 | float basey2 = ret[1]; 330 | float dx = (float) (Math.sin(rad) * delta); 331 | float dy = (float) (Math.cos(rad) * delta); 332 | switch (type) { 333 | case 1: 334 | canvas.drawLine(basex1, basey1, basex2, basey2, paint); 335 | break; 336 | case 2: 337 | canvas.drawLine(basex1 + dx / 2, basey1 - dy / 2, basex2 + dx / 2, basey2 - dy / 2, paint); 338 | canvas.drawLine(basex1 - dx / 2, basey1 + dy / 2, basex2 - dx / 2, basey2 + dy / 2, paint); 339 | break; 340 | case 3: 341 | canvas.drawLine(basex1, basey1, basex2, basey2, paint); 342 | canvas.drawLine(basex1 + dx, basey1 - dy, basex2 + dx, basey2 - dy, paint); 343 | canvas.drawLine(basex1 - dx, basey1 + dy, basex2 - dx, basey2 + dy, paint); 344 | break; 345 | default: 346 | throw new IllegalArgumentException("Unknown bond type: " + type); 347 | } 348 | } 349 | 350 | private static void drawStringCenterHorizontal(@NotNull Canvas canvas, @NotNull String s, float x, float y, Font font, @NotNull Paint paint) { 351 | float width = font.measureTextWidth(s); 352 | canvas.drawString(s, x - width / 2, y, font, paint); 353 | } 354 | 355 | } 356 | -------------------------------------------------------------------------------- /src/main/kotlin/cc/ioctl/neoauth3bot/NeoAuth3Bot.kt: -------------------------------------------------------------------------------- 1 | package cc.ioctl.neoauth3bot 2 | 3 | import cc.ioctl.misc.InlineBotMsgCleaner 4 | import cc.ioctl.neoauth3bot.dat.AnointedManager 5 | import cc.ioctl.neoauth3bot.dat.ChemDatabase 6 | import cc.ioctl.neoauth3bot.res.ResImpl 7 | import cc.ioctl.neoauth3bot.svc.FilterService 8 | import cc.ioctl.neoauth3bot.svc.SysVmService 9 | import cc.ioctl.neoauth3bot.util.BinaryUtils 10 | import cc.ioctl.telebot.EventHandler 11 | import cc.ioctl.telebot.plugin.PluginBase 12 | import cc.ioctl.telebot.startup.BotStartupMain 13 | import cc.ioctl.telebot.tdlib.RobotServer 14 | import cc.ioctl.telebot.tdlib.obj.Bot 15 | import cc.ioctl.telebot.tdlib.obj.SessionInfo 16 | import cc.ioctl.telebot.tdlib.obj.User 17 | import cc.ioctl.telebot.tdlib.tlrpc.RemoteApiException 18 | import cc.ioctl.telebot.tdlib.tlrpc.api.channel.ChannelMemberStatusEvent 19 | import cc.ioctl.telebot.tdlib.tlrpc.api.channel.ChatJoinRequest 20 | import cc.ioctl.telebot.tdlib.tlrpc.api.channel.ChatPermissions 21 | import cc.ioctl.telebot.tdlib.tlrpc.api.channel.MemberStatus 22 | import cc.ioctl.telebot.tdlib.tlrpc.api.msg.Message 23 | import cc.ioctl.telebot.tdlib.tlrpc.api.msg.ReplyMarkup 24 | import cc.ioctl.telebot.tdlib.tlrpc.api.msg.inlineKeyboardCallbackButton 25 | import cc.ioctl.telebot.tdlib.tlrpc.api.query.CallbackQuery 26 | import cc.ioctl.telebot.util.Log 27 | import cc.ioctl.telebot.util.TokenBucket 28 | import cc.ioctl.telebot.util.postDelayed 29 | import com.google.gson.JsonObject 30 | import com.moandjiezana.toml.Toml 31 | import kotlinx.coroutines.CancellationException 32 | import kotlinx.coroutines.Job 33 | import kotlinx.coroutines.runBlocking 34 | import java.io.File 35 | import java.util.concurrent.atomic.AtomicInteger 36 | 37 | class NeoAuth3Bot : PluginBase(), EventHandler.MessageListenerV1, EventHandler.CallbackQueryListenerV2, 38 | EventHandler.GroupMemberJoinRequestListenerV2, EventHandler.GroupPermissionListenerV2 { 39 | 40 | private lateinit var mBot: Bot 41 | private var mBotUid: Long = 0 42 | private var mBotUsername: String? = null 43 | private val mPrivateMsgBpf = TokenBucket(4, 500) 44 | private val mCallbackQueryBpf = TokenBucket(3, 500) 45 | private val mAntiShockBpf = TokenBucket(3, 100) 46 | private val mHypervisorIds = ArrayList() 47 | private val mNextAnonymousAdminVerificationId = AtomicInteger(1) 48 | private val mAnonymousAdminVerifications = HashMap(1) 49 | private val mCascadeDeleteMsgLock = Any() 50 | private var mDefaultLogChannelId: Long = 0L 51 | private val mCascadeDeleteMsg = object : LinkedHashMap() { 52 | // 1000 elements max 53 | override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean { 54 | return size > 1000 55 | } 56 | } 57 | 58 | data class AnonymousAdminVerification( 59 | val id: Int, val si: SessionInfo, val origMsgId: Long, var tmpMsgId: Long = 0 60 | ) { 61 | fun getMagicBytes(cmd: Int): ByteArray { 62 | val type = 3 63 | val subType = 0 64 | val len = 4 65 | val ret = ByteArray(8) 66 | ret[0] = type.toByte() 67 | ret[1] = subType.toByte() 68 | ret[2] = len.toByte() 69 | ret[3] = cmd.toByte() 70 | BinaryUtils.writeLe32(ret, 4, id) 71 | return ret 72 | } 73 | 74 | companion object { 75 | @JvmStatic 76 | fun getIdFromMagicBytes(bytes: ByteArray): Int { 77 | if (bytes.size != 8) { 78 | return 0 79 | } 80 | if (bytes[0] != 3.toByte()) { 81 | return 0 82 | } 83 | if (bytes[1] != 0.toByte()) { 84 | return 0 85 | } 86 | if (bytes[2] != 4.toByte()) { 87 | return 0 88 | } 89 | return BinaryUtils.readLe32(bytes, 4) 90 | } 91 | 92 | @JvmStatic 93 | fun getCmdFromMagicBytes(bytes: ByteArray): Int { 94 | if (bytes.size != 8) { 95 | return 0 96 | } 97 | if (bytes[0] != 3.toByte()) { 98 | return 0 99 | } 100 | if (bytes[1] != 0.toByte()) { 101 | return 0 102 | } 103 | if (bytes[2] != 4.toByte()) { 104 | return 0 105 | } 106 | return bytes[3].toInt() 107 | } 108 | } 109 | } 110 | 111 | companion object { 112 | // allow up to 5min 113 | internal val BOT_START_TIME = System.currentTimeMillis() 114 | private val SYNC_START_TIME = BOT_START_TIME - 5 * 60 * 1000 115 | private const val TAG = "NeoAuth3Bot" 116 | 117 | @JvmStatic 118 | fun main(args: Array) { 119 | BotStartupMain.main(args) 120 | } 121 | } 122 | 123 | override fun onLoad() { 124 | Log.d(TAG, "onLoad") 125 | } 126 | 127 | override fun onEnable() { 128 | Log.d(TAG, "onEnable") 129 | } 130 | 131 | override fun onServerStart() { 132 | Log.d(TAG, "onServerStart") 133 | val cfgPath = File(server.pluginsDir, "NeoAuth3Bot.toml") 134 | if (!cfgPath.exists()) { 135 | askUserToUpdateConfigFile() 136 | return 137 | } 138 | val cfg = Toml().read(cfgPath) 139 | val sdfPath: String? = cfg.getString("sdf_path") 140 | val indexPath: String? = cfg.getString("index_path") 141 | val candidatePath: String? = cfg.getString("candidate_path") 142 | val botUid = cfg.getLong("bot_uid", 0) 143 | mDefaultLogChannelId = cfg.getLong("default_log_channel_id", 0) 144 | LocaleHelper.discussionGroupLink = cfg.getString("discussion_group_link", null) 145 | mHypervisorIds.clear() 146 | cfg.getList("hypervisor_ids").forEach { id -> 147 | if (id < 0) { 148 | Log.w(TAG, "Using anonymous hypervisor id($id) is discouraged") 149 | } 150 | mHypervisorIds.add(id) 151 | } 152 | if (sdfPath == null || indexPath == null || candidatePath == null || botUid == 0L) { 153 | askUserToUpdateConfigFile() 154 | return 155 | } 156 | mBotUid = botUid 157 | ChemDatabase.initialize(File(candidatePath), File(indexPath), File(sdfPath)) 158 | Log.d(TAG, "onServerStart: ChemDatabase initialized") 159 | } 160 | 161 | override fun onLoginFinish(bots: Map) { 162 | val bot = bots[mBotUid] ?: error("bot not found with uid $mBotUid") 163 | mBot = bot 164 | Log.i(TAG, "bot: $bot") 165 | mBotUsername = bot.username 166 | check(mBotUsername?.isNotEmpty() == true) { "bot $bot has no username" } 167 | runBlocking { EventLogs.setDefaultLogChannelId(bot, mDefaultLogChannelId) } 168 | bot.registerOnReceiveMessageListener(this) 169 | bot.registerCallbackQueryListener(this) 170 | bot.registerGroupMemberJoinRequestListenerV1(this) 171 | bot.registerGroupEventListener(this) 172 | // server.executeRequestBlocking(JsonObject().apply { 173 | // addProperty("@type","searchPublicChat") 174 | // addProperty("username","Telegrzm") 175 | // }.toString(), bot, server.defaultTimeout).let { 176 | // Log.d(TAG, "ret: $it") 177 | // } 178 | server.exceptionHandler = mExceptionHandler 179 | } 180 | 181 | private fun askUserToUpdateConfigFile() { 182 | Log.e(TAG, "Config file not loaded correctly.") 183 | Log.e(TAG, "Please create a config file with the following content:") 184 | Log.e(TAG, "bot_uid = your bot's user id") 185 | Log.e(TAG, "sdf_path = \"/path/to/sdf\"") 186 | Log.e(TAG, "index_path = \"/path/to/index\"") 187 | Log.e(TAG, "candidate_path = \"/path/to/candidate\"") 188 | Log.e(TAG, "hypervisor_ids = [ids of hypervisors]") 189 | Log.e(TAG, "default_log_channel_id = channel_id (which is greater than 0)") 190 | Log.e(TAG, "discussion_group_link = \"Your discussion group link, optional\"") 191 | Log.e(TAG, "NeoAuth3Bot not loaded correctly.") 192 | Log.e(TAG, "Aborting...") 193 | } 194 | 195 | override fun onReceiveMessage(bot: Bot, si: SessionInfo, senderId: Long, message: Message): Boolean { 196 | if (bot != mBot) { 197 | return false 198 | } 199 | val msgId = message.id 200 | if (message.date < SYNC_START_TIME / 1000L) { 201 | Log.d(TAG, "message $si senderId $senderId msgId $msgId is too old, ignore") 202 | return true 203 | } 204 | if (InlineBotMsgCleaner.onReceiveMessage(bot, si, senderId, message)) { 205 | return true 206 | } 207 | if ((si.isTrivialPrivateChat && senderId > 0) || message.content.toString().contains("@" + mBotUsername!!)) { 208 | if (mAntiShockBpf.consume(0) < 0) { 209 | Log.w(TAG, "onReceiveMessage: anti-shock filter failed: $si, senderId=$senderId") 210 | return true 211 | } 212 | return runBlocking { 213 | if (si.isTrivialPrivateChat && FilterService.isBlocked(senderId)) { 214 | // ignore messages from blocked users 215 | return@runBlocking true 216 | } 217 | val msgText = message.content.get("text")?.asJsonObject?.get("text")?.asString 218 | if (msgText == null) { 219 | val d = "onReceiveMessage start, $si, senderId: $senderId, msgId: $msgId, msgType: ${ 220 | message.content.get("@type").asString 221 | }" 222 | Log.d(TAG, d) 223 | return@runBlocking true 224 | } else { 225 | // truncate message text to avoid flooding logs 226 | val dumpShowMsg = msgText.replace("\r", "\\r").replace("\n", "\\n").replace("\t", "\\t").let { 227 | it.substring(0, it.length.coerceAtMost(150)) 228 | } 229 | val d = "onReceiveMessage start, $si, senderId: $senderId, msgId: $msgId, msgText: $dumpShowMsg" 230 | Log.d(TAG, d) 231 | } 232 | if (mHypervisorIds.contains(senderId)) { 233 | if (msgText.startsWith("/") || msgText.startsWith("!")) { 234 | val body = msgText.substring(1) 235 | if (body.startsWith("hvcmd")) { 236 | val parts = body.split(" ") 237 | if (parts.size < 3) { 238 | bot.sendMessageForText( 239 | si, "Invalid argument. Usage: /hvcmd SERVICE CMD [ARGS...]", replyMsgId = msgId 240 | ).scheduledCascadeDelete(msgId) 241 | } else { 242 | val svc = parts[1] 243 | val cmd = parts[2] 244 | val args = parts.drop(3) 245 | try { 246 | Log.d(TAG, "hvcmd $senderId: $svc $cmd $args") 247 | HypervisorCommandHandler.onSupervisorCommand( 248 | bot, si, senderId, svc, cmd, args.toTypedArray(), msgId 249 | ) 250 | } catch (e: Exception) { 251 | Log.e(TAG, "exec hv cmd '$msgText': $e", e) 252 | bot.sendMessageForText( 253 | si, e.message ?: e.toString(), replyMsgId = msgId 254 | ).scheduledCascadeDelete(msgId) 255 | } 256 | } 257 | return@runBlocking true 258 | } 259 | } 260 | } 261 | if (senderId < 0) { 262 | if (si.isGroupOrChannel && senderId == -si.id) { 263 | if (msgText.startsWith("/config")) { 264 | val aaaId = mNextAnonymousAdminVerificationId.getAndIncrement() 265 | val session = AnonymousAdminVerification(aaaId, si, msgId) 266 | val r = ResImpl.eng 267 | val tmpMsgId = bot.sendMessageForText( 268 | si, 269 | r.msg_text_anonymous_admin_identity_verification_required, 270 | replyMsgId = msgId, 271 | replyMarkup = ReplyMarkup.InlineKeyboard( 272 | arrayOf( 273 | arrayOf( 274 | inlineKeyboardCallbackButton( 275 | r.btn_text_verify_anony_identity, session.getMagicBytes(1) 276 | ), inlineKeyboardCallbackButton( 277 | r.btn_text_cancel, session.getMagicBytes(2) 278 | ) 279 | ) 280 | ) 281 | ) 282 | ) 283 | session.tmpMsgId = tmpMsgId.id 284 | mAnonymousAdminVerifications[aaaId] = session 285 | // schedule deletion of the message after 30 seconds 286 | postDelayed(30_000) { 287 | mAnonymousAdminVerifications.remove(aaaId) 288 | try { 289 | bot.deleteMessage(si, tmpMsgId.id) 290 | } catch (e: RemoteApiException) { 291 | Log.i(TAG, "deleteMessage failed: $e") 292 | } 293 | }.logErrorIfFail() 294 | } 295 | } 296 | return@runBlocking true 297 | } 298 | if (!msgText.startsWith("/")) { 299 | return@runBlocking true 300 | } 301 | val user = bot.getUser(senderId) 302 | val r = ResImpl.getResourceForUser(user) 303 | mPrivateMsgBpf.consume(senderId).also { 304 | if (it < 0) { 305 | return@runBlocking true 306 | } else if (it == 0) { 307 | bot.sendMessageForText(si, r.msg_text_too_many_requests).scheduledCascadeDelete(msgId) 308 | return@runBlocking true 309 | } 310 | } 311 | if (message.content.toString().contains("\"/help")) { 312 | bot.sendMessageForText( 313 | si, LocaleHelper.getBotHelpInfoFormattedText(bot, user), replyMsgId = msgId 314 | ).scheduledCascadeDelete(msgId) 315 | } else if (msgText.startsWith("/uptime")) { 316 | bot.sendMessageForText(si, SysVmService.getUptimeString(), replyMsgId = msgId) 317 | .scheduledCascadeDelete(msgId) 318 | } else if (message.content.toString().contains("\"/about")) { 319 | bot.sendMessageForText(si, LocaleHelper.getBotAboutInfoFormattedText(user), replyMsgId = msgId) 320 | .scheduledCascadeDelete(msgId) 321 | } else if (message.content.toString().contains("\"/group_id")) { 322 | try { 323 | val group = bot.getGroup(si.id) 324 | bot.sendMessageForText( 325 | si, "Group ID: ${group.groupId}\nGroup Name: ${group.name}", replyMsgId = msgId 326 | ).scheduledCascadeDelete(msgId) 327 | } catch (e: Exception) { 328 | bot.sendMessageForText(si, e.message ?: e.toString(), replyMsgId = msgId) 329 | .scheduledCascadeDelete(msgId) 330 | } 331 | } else if (message.content.toString().contains("\"/via_id")) { 332 | try { 333 | val originMsgId = message.replyToMessageId 334 | if (originMsgId == 0L) { 335 | bot.sendMessageForText(si, "Please reply to a message.", replyMsgId = msgId) 336 | .scheduledCascadeDelete(msgId) 337 | } else { 338 | val origMsg = bot.getMessage(si, originMsgId, true) 339 | bot.sendMessageForText( 340 | si, origMsg.viaBotUserId.toString(), replyMsgId = msgId 341 | ).scheduledCascadeDelete(msgId) 342 | } 343 | } catch (e: Exception) { 344 | bot.sendMessageForText(si, e.message ?: e.toString(), replyMsgId = msgId) 345 | .scheduledCascadeDelete(msgId) 346 | } 347 | } else if (message.content.toString() 348 | .contains("\"/cc1") || (si.isTrivialPrivateChat && message.content.toString() 349 | .contains("\"/ccg")) 350 | ) { 351 | if (!si.isTrivialPrivateChat) { 352 | // in group chat 353 | bot.sendMessageForText( 354 | si, r.msg_text_command_use_in_private_chat_only, replyMsgId = msgId 355 | ).scheduledCascadeDelete(msgId) 356 | return@runBlocking true 357 | } else { 358 | val isForTest = !message.content.toString().contains("\"/ccg") 359 | AuthUserInterface.onStartAuthCommand(bot, si, user, message, isForTest) 360 | } 361 | } else if (msgText.startsWith("/start")) { 362 | if (msgText.length > "/start".length) { 363 | val arg = msgText.substring("/start".length).trimStart() 364 | when (arg.split("_")[0]) { 365 | "admincfg" -> { 366 | val sid = arg.split("_")[1] 367 | AdminConfigInterface.onStartConfigInterface(bot, si, user, message, sid) 368 | return@runBlocking true 369 | } 370 | "admincfgedit" -> { 371 | val sid = arg.split("_")[1] 372 | val idx = arg.split("_")[2].toInt() 373 | AdminConfigInterface.onStartEditValueInterface(bot, si, user, message, sid, idx) 374 | return@runBlocking true 375 | } 376 | else -> { 377 | bot.sendMessageForText(si, "Unknown command", replyMsgId = msgId) 378 | .scheduledCascadeDelete(msgId) 379 | return@runBlocking true 380 | } 381 | } 382 | } 383 | } else if (message.content.toString().contains("\"/config")) { 384 | if (!si.isGroupOrChannel) { 385 | bot.sendMessageForText( 386 | si, r.msg_text_command_use_in_group_only, replyMsgId = msgId 387 | ).scheduledCascadeDelete(msgId) 388 | return@runBlocking true 389 | } else { 390 | val gid = si.id 391 | val group = bot.getGroup(gid) 392 | AdminConfigInterface.onStartConfigCommand(bot, user, group, message.id) 393 | return@runBlocking true 394 | } 395 | } else { 396 | bot.sendMessageForText(si, "Unknown command.", replyMsgId = msgId).scheduledCascadeDelete(msgId) 397 | } 398 | return@runBlocking true 399 | } 400 | } 401 | return false 402 | } 403 | 404 | private suspend fun errorAlert(bot: Bot, queryId: Long, msg: String) { 405 | bot.answerCallbackQuery(queryId, msg, true) 406 | } 407 | 408 | override fun onCallbackQuery( 409 | bot: Bot, query: CallbackQuery 410 | ): Boolean { 411 | val queryId = query.queryId 412 | if (bot.userId == mBotUid) { 413 | if (mAntiShockBpf.consume(0) < 0) { 414 | Log.e( 415 | TAG, 416 | "onCallbackQuery: anti-shock filter failed: ${query.sessionInfo}, senderId=${query.senderUserId}" 417 | ) 418 | return true 419 | } 420 | runBlocking { 421 | val senderId = query.senderUserId 422 | val user = bot.getUser(senderId) 423 | val r = ResImpl.getResourceForUser(user) 424 | mCallbackQueryBpf.consume(senderId).also { 425 | if (it < 0) { 426 | return@runBlocking 427 | } else if (it == 0) { 428 | errorAlert(bot, query.queryId, r.msg_text_too_many_requests) 429 | return@runBlocking 430 | } 431 | } 432 | try { 433 | val bytes = query.payloadData 434 | if (query.sessionInfo.isTrivialPrivateChat) { 435 | // PM 436 | if (bytes.size != 8) { 437 | errorAlert(bot, queryId, "unexpected payload data, expected 8 bytes") 438 | return@runBlocking 439 | } 440 | val authId = BinaryUtils.readLe32(bytes, 0) 441 | val authInfo = SessionManager.getAuthSession(bot, user.userId) 442 | if (authInfo == null) { 443 | errorAlert(bot, queryId, r.cb_query_auth_session_not_found) 444 | return@runBlocking 445 | } 446 | AuthUserInterface.onBtnClick(bot, user, query.sessionInfo, authInfo, bytes, queryId) 447 | } else { 448 | // group 449 | when (val type = bytes[0].toInt()) { 450 | 3 -> { 451 | onAnonymousAdminCallback(bot, user, query.sessionInfo, bytes, queryId) 452 | return@runBlocking 453 | } 454 | else -> { 455 | errorAlert(bot, queryId, "unknown payload type: $type") 456 | return@runBlocking 457 | } 458 | } 459 | } 460 | } catch (e: Exception) { 461 | Log.e(TAG, "onCallbackQuery, error: ${e.message}", e) 462 | errorAlert(bot, queryId, e.toString()) 463 | } 464 | } 465 | return true 466 | } 467 | return false 468 | } 469 | 470 | private suspend fun onAnonymousAdminCallback( 471 | bot: Bot, user: User, si: SessionInfo, bytes: ByteArray, queryId: Long 472 | ) { 473 | val aaaId = AnonymousAdminVerification.getIdFromMagicBytes(bytes) 474 | val cmd = AnonymousAdminVerification.getCmdFromMagicBytes(bytes) 475 | if (aaaId == 0 || cmd == 0 || !si.isGroupOrChannel) { 476 | errorAlert(bot, queryId, "query data is invalid") 477 | return 478 | } 479 | val r = ResImpl.getResourceForUser(user) 480 | // check if the user is an admin 481 | val gid = si.id 482 | val group = bot.getGroup(gid) 483 | val isAdmin = group.isMemberAdministrative(bot, user.userId) 484 | if (!isAdmin) { 485 | errorAlert(bot, queryId, r.cb_query_nothing_to_do_with_you) 486 | return 487 | } 488 | val session = mAnonymousAdminVerifications[aaaId] 489 | if (session == null) { 490 | errorAlert(bot, queryId, r.cb_query_auth_session_not_found) 491 | return 492 | } 493 | if (si != session.si) { 494 | errorAlert(bot, queryId, r.cb_query_auth_session_not_found) 495 | return 496 | } 497 | when (cmd) { 498 | 1 -> { 499 | AdminConfigInterface.onStartConfigCommand(bot, user, group, session.origMsgId) 500 | mAnonymousAdminVerifications.remove(aaaId) 501 | try { 502 | bot.deleteMessage(si, session.tmpMsgId) 503 | } catch (ignored: RemoteApiException) { 504 | // ignore 505 | } 506 | } 507 | 2 -> { 508 | // cancel 509 | mAnonymousAdminVerifications.remove(aaaId) 510 | try { 511 | bot.deleteMessage(si, session.tmpMsgId) 512 | } catch (ignored: RemoteApiException) { 513 | // ignore 514 | } 515 | } 516 | else -> { 517 | errorAlert(bot, queryId, "unknown command: $cmd") 518 | return 519 | } 520 | } 521 | } 522 | 523 | override fun onMemberJoinRequest(bot: Bot, groupId: Long, userId: Long, request: ChatJoinRequest): Boolean { 524 | runBlocking { 525 | val group = bot.getGroup(groupId) 526 | val user = bot.getUser(userId) 527 | val r = ResImpl.getResourceForUser(user) 528 | if (SessionManager.handleUserJoinRequest(bot, user, group)) { 529 | val pmsi = SessionInfo.forUser(userId) 530 | EventLogs.onJoinRequest(bot, group, userId) 531 | // make TDLib know the PM chat before send msg 532 | bot.getChat(userId) 533 | if (AnointedManager.getAnointedStatus(groupId, userId) == 1) { 534 | // pre-approved 535 | Log.i(TAG, "onMemberJoinRequest: pre-approved: groupId: $groupId, userId: $userId") 536 | bot.processChatJoinRequest(groupId, userId, true) 537 | SessionManager.dropAuthSession(bot, user.userId) 538 | EventLogs.onAutomaticApprove(bot, group, userId) 539 | // done 540 | return@runBlocking 541 | } else { 542 | Log.d(TAG, "onMemberJoinRequest: groupId: $groupId, userId: $userId, not pre-approved") 543 | } 544 | val originHintMsgId: Message 545 | try { 546 | originHintMsgId = bot.sendMessageForText( 547 | pmsi, r.format(r.msg_text_join_auth_required_notice_va2, user.name, group.name) 548 | ) 549 | } catch (e: RemoteApiException) { 550 | if (e.code == 403) { 551 | // RemoteApiException: 403: Bot can't initiate conversation with a user 552 | // This will happen if a group has more than one bot attempting to send a message to the user 553 | // Since there is other bot sending message to the user, we can just ignore this error 554 | Log.w(TAG, "onMemberJoinRequest: race condition, drop auth, " + 555 | "groupId: $groupId, userId: $userId, msg: ${e.message}") 556 | // Drop the auth session 557 | SessionManager.dropAuthSession(bot, user.userId) 558 | return@runBlocking 559 | } else { 560 | throw e 561 | } 562 | } 563 | Log.i(TAG, "send user join request msg to user: $userId, group: $groupId") 564 | val groupConfig = SessionManager.getOrCreateGroupConfig(bot, group) 565 | val maxWaitTimeSeconds = groupConfig.startAuthTimeoutSeconds 566 | if (maxWaitTimeSeconds > 0) { 567 | // schedule a job to dismiss the request after timeout 568 | postDelayed(maxWaitTimeSeconds * 1000L) { 569 | // check whether the auth session is still valid 570 | val authSession = SessionManager.getAuthSession(bot, userId) 571 | if (authSession != null) { 572 | if (authSession.currentAuthId == 0 && authSession.authStatus == SessionManager.AuthStatus.REQUESTED) { 573 | Log.i(TAG, "dismiss timeout join request: $userId, group: $groupId") 574 | EventLogs.onStartAuthTimeout(bot, group, userId) 575 | // drop the auth session if the user didn't start auth in time 576 | SessionManager.dropAuthSession(bot, userId) 577 | // delete the msg and dismiss the request 578 | try { 579 | bot.deleteMessage(pmsi, originHintMsgId.id) 580 | } catch (e: RemoteApiException) { 581 | Log.w(TAG, "delete request msg: $e") 582 | } 583 | try { 584 | bot.processChatJoinRequest(groupId, userId, false) 585 | } catch (e: RemoteApiException) { 586 | if (e.message == "HIDE_REQUESTER_MISSING") { 587 | // nothing serious, just ignore it 588 | } else { 589 | throw e 590 | } 591 | } 592 | } 593 | } 594 | }.invokeOnCompletion { 595 | if (it != null && it !is CancellationException) { 596 | Log.e(TAG, "onMemberJoinRequest postDelayed, error: ${it.message}", it) 597 | } 598 | } 599 | } 600 | } else { 601 | Log.i(TAG, "ignore user join request: $userId, group: $groupId because disabled") 602 | } 603 | } 604 | return true 605 | } 606 | 607 | override fun onUpdateMessageContent(bot: Bot, si: SessionInfo, msgId: Long, content: JsonObject): Boolean { 608 | // Log.d(TAG, "onUpdateMessageContent, chatId: $chatId, msgId: $msgId") 609 | return false 610 | } 611 | 612 | override fun onMessageEdited(bot: Bot, si: SessionInfo, msgId: Long, editDate: Int): Boolean { 613 | // Log.d(TAG, "onMessageEdited, chatId: $chatId, msgId: $msgId, editDate: $editDate") 614 | return false 615 | } 616 | 617 | override fun onGroupDefaultPermissionsChanged(bot: Bot, groupId: Long, permissions: ChatPermissions): Boolean { 618 | Log.d(TAG, "onGroupDefaultPermissionsChanged, groupId: $groupId") 619 | return false 620 | } 621 | 622 | override fun onMemberStatusChanged( 623 | bot: Bot, 624 | groupId: Long, 625 | userId: Long, 626 | event: ChannelMemberStatusEvent 627 | ): Boolean { 628 | Log.d("onMemberStatusChanged", "groupId: $groupId, userId: $userId, event: $event") 629 | if (bot != mBot) { 630 | return false 631 | } 632 | val session = SessionManager.getAuthSession(bot, userId) ?: return false 633 | if (session.targetGroupId != groupId) { 634 | return false 635 | } 636 | if (session.authStatus in listOf( 637 | SessionManager.AuthStatus.REQUESTED, 638 | SessionManager.AuthStatus.AUTHENTICATING 639 | ) 640 | ) return runBlocking { 641 | val user = bot.getUser(userId) 642 | val group = bot.getGroup(groupId) 643 | bot.getChat(userId) 644 | val operatorId = event.actorUserId 645 | val r = ResImpl.getResourceForUser(user) 646 | val pmsi = SessionInfo.forUser(userId) 647 | // notify the user 648 | when (val newStatus = event.newStatus!!.javaClass) { 649 | MemberStatus.Member::class.java, 650 | MemberStatus.Creator::class.java, 651 | MemberStatus.Restricted::class.java, 652 | MemberStatus.Administrator::class.java -> { 653 | if (operatorId != bot.userId) { 654 | // if user get approved by an admin, notify the user 655 | try { 656 | bot.sendMessageForText( 657 | pmsi, 658 | r.format(r.msg_text_approved_manually_by_admin_va1, group.name) 659 | ) 660 | } catch (e: RemoteApiException) { 661 | if (e.code == 403) { 662 | // ignore: Bot was blocked by the user 663 | } else { 664 | throw e 665 | } 666 | } 667 | } 668 | val oldMsgId = session.originalMessageId 669 | SessionManager.dropAuthSession(bot, userId) 670 | EventLogs.onManualApproveJoinRequest(bot, group, userId, operatorId) 671 | if (oldMsgId != 0L) { 672 | bot.deleteMessage(pmsi, oldMsgId) 673 | } 674 | return@runBlocking true 675 | } 676 | MemberStatus.Banned::class.java -> { 677 | bot.sendMessageForText(pmsi, r.format(r.msg_text_banned_manually_by_admin_va1, group.name)) 678 | val oldMsgId = session.originalMessageId 679 | SessionManager.dropAuthSession(bot, userId) 680 | EventLogs.onManualDenyJoinRequest(bot, group, userId, operatorId) 681 | if (oldMsgId != 0L) { 682 | bot.deleteMessage(pmsi, oldMsgId) 683 | } 684 | return@runBlocking true 685 | } 686 | else -> { 687 | Log.e(TAG, "unexpected status: $newStatus, group: $groupId, user: $userId") 688 | return@runBlocking false 689 | } 690 | } 691 | } else { 692 | return false 693 | } 694 | } 695 | 696 | override fun onDeleteMessages(bot: Bot, si: SessionInfo, msgIds: List): Boolean { 697 | val msgToDelete = HashSet(4) 698 | val keys = msgIds.map { si.toTDLibChatId().toString() + "_" + it.toString() } 699 | synchronized(mCascadeDeleteMsgLock) { 700 | keys.forEach { k -> 701 | mCascadeDeleteMsg.remove(k)?.let { id -> 702 | msgToDelete.add(id) 703 | } 704 | } 705 | } 706 | if (msgToDelete.isNotEmpty()) { 707 | runBlocking { 708 | try { 709 | bot.deleteMessages(si, msgToDelete.toList()) 710 | } catch (e: RemoteApiException) { 711 | // we don't really care about the error 712 | Log.w(TAG, "cascade delete msg, error: ${e.message}") 713 | } 714 | } 715 | } 716 | return true 717 | } 718 | 719 | fun scheduleCascadeDeleteMessage(si: SessionInfo, origMsgId: Long, targetMsgId: Long) { 720 | assert(origMsgId != targetMsgId) 721 | assert(si.id != 0L) 722 | assert(origMsgId > 0L) 723 | assert(targetMsgId > 0L) 724 | val key = si.toTDLibChatId().toString() + "_" + origMsgId 725 | synchronized(mCascadeDeleteMsgLock) { 726 | mCascadeDeleteMsg[key] = targetMsgId 727 | } 728 | } 729 | 730 | internal fun Message.scheduledCascadeDelete(origMsgId: Long) { 731 | if (this.serverMsgId == 0L) { 732 | // we don't need to delete the msg if it's not sent to server yet 733 | return 734 | } 735 | scheduleCascadeDeleteMessage(sessionInfo, origMsgId, this.id) 736 | } 737 | 738 | private val mAutoReportFilter = TokenBucket(3, 300) 739 | 740 | private val mExceptionHandler: RobotServer.ExceptionHandler = object : RobotServer.ExceptionHandler { 741 | override fun onException(e: Throwable, t: Thread) { 742 | Log.e(TAG, "uncaught exception in thread: ${t.name}", e) 743 | val sb = StringBuilder().apply { 744 | append("uncaught exception in thread: ").append(t.name).append("\n") 745 | append(e.stackTraceToString()) 746 | } 747 | val msg = sb.toString() 748 | val adminUid = mHypervisorIds.firstOrNull { it > 0 } 749 | if (adminUid != null) { 750 | if (mAutoReportFilter.tryConsume(1)) { 751 | runBlocking { 752 | try { 753 | // make TDLib know the user 754 | mBot.getUser(adminUid) 755 | mBot.getChat(adminUid) 756 | mBot.sendMessageForText( 757 | SessionInfo.forUser(adminUid), 758 | msg 759 | ) 760 | } catch (e: Exception) { 761 | Log.e(TAG, "failed to send exception report to admin", e) 762 | } 763 | } 764 | } else { 765 | Log.w(TAG, "auto report is throttled") 766 | } 767 | } else { 768 | Log.w(TAG, "no admin user") 769 | } 770 | } 771 | } 772 | 773 | internal fun Job.logErrorIfFail() { 774 | invokeOnCompletion { 775 | if (it != null && it !is CancellationException) { 776 | Log.e(TAG, "job error: ${it.message}", it) 777 | } 778 | } 779 | } 780 | 781 | } 782 | --------------------------------------------------------------------------------