├── .do ├── db.example.png └── deploy.template.yaml ├── .editorconfig ├── .github └── workflows │ ├── detekt-analysis.yml │ ├── docker-publish.yml │ └── gradle.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── build.gradle.kts ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── img ├── ban.jpg ├── logo.jpg ├── ransom.jpg ├── status.jpg └── transfer.jpg └── src ├── main ├── kotlin │ └── ml │ │ └── demidko │ │ └── timecobot │ │ ├── App.kt │ │ ├── Bank.kt │ │ ├── Command.kt │ │ ├── Gun.kt │ │ ├── Promoter.kt │ │ └── Types.kt └── resources │ └── logback.xml └── test └── kotlin └── ml └── demidko └── timecobot └── CommandTest.kt /.do/db.example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/demidko/timecobot/c0a19471a7f7ed86fd9c04d9c4472e9b1a39ecb3/.do/db.example.png -------------------------------------------------------------------------------- /.do/deploy.template.yaml: -------------------------------------------------------------------------------- 1 | spec: 2 | name: timecobot 3 | workers: 4 | - name: backend 5 | dockerfile_path: Dockerfile 6 | github: 7 | repo: demidko/timecobot 8 | branch: main 9 | deploy_on_push: true 10 | envs: 11 | - key: TOKEN 12 | type: SECRET 13 | scope: RUN_TIME 14 | - key: REDIS 15 | type: SECRET 16 | scope: RUN_TIME -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{kt, kts, java, xml, html, js}] 2 | max_line_length = 100 3 | indent_size = 2 4 | continuation_indent_size = 2 -------------------------------------------------------------------------------- /.github/workflows/detekt-analysis.yml: -------------------------------------------------------------------------------- 1 | # This workflow performs a static analysis of your Kotlin source code using 2 | # Detekt. 3 | # 4 | # Scans are triggered: 5 | # 1. On every push to default and protected branches 6 | # 2. On every Pull Request targeting the default branch 7 | # 3. On a weekly schedule 8 | # 4. Manually, on demand, via the "workflow_dispatch" event 9 | # 10 | # The workflow should work with no modifications, but you might like to use a 11 | # later version of the Detekt CLI by modifing the $DETEKT_RELEASE_TAG 12 | # environment variable. 13 | name: Scan with Detekt 14 | 15 | on: 16 | # Triggers the workflow on push or pull request events but only for default and protected branches 17 | push: 18 | branches: [ main ] 19 | pull_request: 20 | branches: [ main ] 21 | schedule: 22 | - cron: '44 23 * * 1' 23 | 24 | # Allows you to run this workflow manually from the Actions tab 25 | workflow_dispatch: 26 | 27 | env: 28 | # Release tag associated with version of Detekt to be installed 29 | # SARIF support (required for this workflow) was introduced in Detekt v1.15.0 30 | DETEKT_RELEASE_TAG: v1.15.0 31 | 32 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 33 | jobs: 34 | # This workflow contains a single job called "scan" 35 | scan: 36 | name: Scan 37 | # The type of runner that the job will run on 38 | runs-on: ubuntu-latest 39 | 40 | # Steps represent a sequence of tasks that will be executed as part of the job 41 | steps: 42 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 43 | - uses: actions/checkout@v2 44 | 45 | # Gets the download URL associated with the $DETEKT_RELEASE_TAG 46 | - name: Get Detekt download URL 47 | id: detekt_info 48 | env: 49 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | run: | 51 | DETEKT_DOWNLOAD_URL=$( gh api graphql --field tagName=$DETEKT_RELEASE_TAG --raw-field query=' 52 | query getReleaseAssetDownloadUrl($tagName: String!) { 53 | repository(name: "detekt", owner: "detekt") { 54 | release(tagName: $tagName) { 55 | releaseAssets(name: "detekt", first: 1) { 56 | nodes { 57 | downloadUrl 58 | } 59 | } 60 | } 61 | } 62 | } 63 | ' | \ 64 | jq --raw-output '.data.repository.release.releaseAssets.nodes[0].downloadUrl' ) 65 | echo "::set-output name=download_url::$DETEKT_DOWNLOAD_URL" 66 | 67 | # Sets up the detekt cli 68 | - name: Setup Detekt 69 | run: | 70 | dest=$( mktemp -d ) 71 | curl --request GET \ 72 | --url ${{ steps.detekt_info.outputs.download_url }} \ 73 | --silent \ 74 | --location \ 75 | --output $dest/detekt 76 | chmod a+x $dest/detekt 77 | echo $dest >> $GITHUB_PATH 78 | 79 | # Performs static analysis using Detekt 80 | - name: Run Detekt 81 | continue-on-error: true 82 | run: | 83 | detekt --input ${{ github.workspace }} --report sarif:${{ github.workspace }}/detekt.sarif.json 84 | 85 | # Modifies the SARIF output produced by Detekt so that absolute URIs are relative 86 | # This is so we can easily map results onto their source files 87 | # This can be removed once relative URI support lands in Detekt: https://git.io/JLBbA 88 | - name: Make artifact location URIs relative 89 | continue-on-error: true 90 | run: | 91 | echo "$( 92 | jq \ 93 | --arg github_workspace ${{ github.workspace }} \ 94 | '. | ( .runs[].results[].locations[].physicalLocation.artifactLocation.uri |= if test($github_workspace) then .[($github_workspace | length | . + 1):] else . end )' \ 95 | ${{ github.workspace }}/detekt.sarif.json 96 | )" > ${{ github.workspace }}/detekt.sarif.json 97 | 98 | # Uploads results to GitHub repository using the upload-sarif action 99 | - uses: github/codeql-action/upload-sarif@v1 100 | with: 101 | # Path to SARIF file relative to the root of the repository 102 | sarif_file: ${{ github.workspace }}/detekt.sarif.json 103 | checkout_path: ${{ github.workspace }} 104 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | # This workflow uses actions that are not certified by GitHub. 4 | # They are provided by a third-party and are governed by 5 | # separate terms of service, privacy policy, and support 6 | # documentation. 7 | 8 | on: 9 | schedule: 10 | - cron: '29 15 * * *' 11 | push: 12 | branches: [ main ] 13 | # Publish semver tags as releases. 14 | tags: [ 'v*.*.*' ] 15 | pull_request: 16 | branches: [ main ] 17 | 18 | env: 19 | # Use docker.io for Docker Hub if empty 20 | REGISTRY: ghcr.io 21 | # github.repository as / 22 | IMAGE_NAME: ${{ github.repository }} 23 | 24 | 25 | jobs: 26 | build: 27 | 28 | runs-on: ubuntu-latest 29 | permissions: 30 | contents: read 31 | packages: write 32 | 33 | steps: 34 | - name: Checkout repository 35 | uses: actions/checkout@v2 36 | 37 | # Login against a Docker registry except on PR 38 | # https://github.com/docker/login-action 39 | - name: Log into registry ${{ env.REGISTRY }} 40 | if: github.event_name != 'pull_request' 41 | uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c 42 | with: 43 | registry: ${{ env.REGISTRY }} 44 | username: ${{ github.actor }} 45 | password: ${{ secrets.GITHUB_TOKEN }} 46 | 47 | # Extract metadata (tags, labels) for Docker 48 | # https://github.com/docker/metadata-action 49 | - name: Extract Docker metadata 50 | id: meta 51 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 52 | with: 53 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 54 | 55 | # Build and push Docker image with Buildx (don't push on PR) 56 | # https://github.com/docker/build-push-action 57 | - name: Build and push Docker image 58 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 59 | with: 60 | context: . 61 | push: ${{ github.event_name != 'pull_request' }} 62 | tags: ${{ steps.meta.outputs.tags }} 63 | labels: ${{ steps.meta.outputs.labels }} 64 | -------------------------------------------------------------------------------- /.github/workflows/gradle.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-gradle 3 | 4 | name: Java CI with Gradle 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Set up JDK 16 20 | uses: actions/setup-java@v2 21 | with: 22 | java-version: '16' 23 | distribution: 'adopt' 24 | cache: gradle 25 | - name: Grant execute permission for gradlew 26 | run: chmod +x gradlew 27 | - name: Build with Gradle 28 | run: ./gradlew build 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled class file 2 | *.class 3 | 4 | # Log file 5 | *.log 6 | 7 | # BlueJ files 8 | *.ctxt 9 | 10 | # Mobile Tools for Java (J2ME) 11 | .mtj.tmp/ 12 | 13 | # Package Files # 14 | *.dmg 15 | 16 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 17 | hs_err_pid* 18 | 19 | # Build artifacts 20 | .gradle 21 | .idea 22 | build 23 | .DS_Store 24 | 25 | /src/test/kotlin/LoginIT.kt 26 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gradle:jdk16 as builder 2 | WORKDIR /project 3 | COPY src ./src 4 | COPY build.gradle.kts ./build.gradle.kts 5 | RUN gradle clean build 6 | 7 | FROM openjdk:18 as backend 8 | WORKDIR /root 9 | COPY --from=builder /project/build/libs/*-all.jar ./app 10 | ENTRYPOINT ["java", "-jar", "/root/app"] 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Daniel Demidko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Timecobot 2 | 3 | Telegram bot provides time-based currency. The accumulated time can be used to block another user. 4 | There are no discriminatory rules, administrators and moderators. Bot also can be used to pin 5 | messages. 6 | 7 | 1. Each user accumulates time. Time (currency) is calculated automatically using a secret formula. 8 | 1. Time can be passed on to other people. 9 | 1. The accumulated time can be used to block another user. 10 | User will remain in the chat, but user will not be able to write anything. 11 | 1. Time can be used to ransom the user from the ban. 12 | 1. Time can be used to pin messages. 13 | 14 | ## 2022.07.14 update 15 | 16 | The project is on hold until there is a real need for practical application. Most of the time, people don't care who has the banhammer, they just want to chat, not get into gunfights. This game proved to be too difficult for most chats. 17 | 18 | ## Usage 19 | 20 | To start using the system, just add [`@timecobot`](https://t.me/timecobot) to the group with admin 21 | rights. Bot understands spoken language (english and russian). Experiment! 22 | 23 | ## Examples 24 | 25 | ![](img/status.jpg "My status") 26 | ![](img/transfer.jpg "Transfer time to user") 27 | User will remain in the chat, but he will not be able to write anything: 28 | ![](img/ban.jpg "Block user") 29 | ![](img/ransom.jpg "Unblock user") 30 | 31 | ## Build 32 | 33 | ```sh 34 | ./gradlew clean build 35 | ``` 36 | 37 | Self-executable jar will be located in `build/libs`. To start long polling execute command 38 | 39 | ```sh 40 | REDIS=rediss://example.com:37081 TOKEN=4760:zGTAaKGo java -jar build/libs/*-all.jar 41 | ``` 42 | 43 | ## Deploy 44 | 45 | [![Deploy to DigitalOcean](https://www.deploytodo.com/do-btn-blue-ghost.svg)](https://cloud.digitalocean.com/apps/new?repo=https://github.com/demidko/timecobot/tree/main) 46 | 47 | You need use a Redis cluster to store the time: specify the connection string via the `DATABASE_URL` 48 | environment variable. 49 | 50 | ## TODO 51 | 52 | 1. 'Ban feature' to restrict group's admins 53 | 2. New command (timecobot2) 54 | 55 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | repositories { 2 | mavenCentral() 3 | maven("https://jitpack.io") 4 | } 5 | plugins { 6 | kotlin("jvm") version "1.5.30" 7 | id("com.github.johnrengelman.shadow") version "7.0.0" 8 | } 9 | dependencies { 10 | implementation("org.redisson:redisson:3.16.2") 11 | implementation("io.github.kotlin-telegram-bot.kotlin-telegram-bot:telegram:6.0.5") 12 | implementation("ch.qos.logback:logback-classic:1.2.5") 13 | implementation("com.github.demidko:print-utils:2021.09.03") 14 | testImplementation("org.junit.jupiter:junit-jupiter:5.8.0-RC1") 15 | testImplementation("com.willowtreeapps.assertk:assertk-jvm:0.24") 16 | testImplementation("io.mockk:mockk:1.12.0") 17 | } 18 | tasks.compileKotlin { 19 | kotlinOptions.jvmTarget = "16" 20 | kotlinOptions.freeCompilerArgs += "-Xopt-in=kotlin.time.ExperimentalTime" 21 | } 22 | tasks.compileTestKotlin { 23 | kotlinOptions.jvmTarget = "16" 24 | kotlinOptions.freeCompilerArgs += "-Xopt-in=kotlin.time.ExperimentalTime" 25 | } 26 | tasks.test { 27 | useJUnitPlatform() 28 | } 29 | tasks.jar { 30 | isZip64 = true 31 | manifest.attributes("Main-Class" to "ml.demidko.timecobot.AppKt") 32 | } 33 | tasks.build { 34 | dependsOn(tasks.shadowJar) 35 | } -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/demidko/timecobot/c0a19471a7f7ed86fd9c04d9c4472e9b1a39ecb3/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or 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 UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /img/ban.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/demidko/timecobot/c0a19471a7f7ed86fd9c04d9c4472e9b1a39ecb3/img/ban.jpg -------------------------------------------------------------------------------- /img/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/demidko/timecobot/c0a19471a7f7ed86fd9c04d9c4472e9b1a39ecb3/img/logo.jpg -------------------------------------------------------------------------------- /img/ransom.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/demidko/timecobot/c0a19471a7f7ed86fd9c04d9c4472e9b1a39ecb3/img/ransom.jpg -------------------------------------------------------------------------------- /img/status.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/demidko/timecobot/c0a19471a7f7ed86fd9c04d9c4472e9b1a39ecb3/img/status.jpg -------------------------------------------------------------------------------- /img/transfer.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/demidko/timecobot/c0a19471a7f7ed86fd9c04d9c4472e9b1a39ecb3/img/transfer.jpg -------------------------------------------------------------------------------- /src/main/kotlin/ml/demidko/timecobot/App.kt: -------------------------------------------------------------------------------- 1 | package ml.demidko.timecobot 2 | 3 | import Gun 4 | import Promoter 5 | import com.github.kotlintelegrambot.bot 6 | import com.github.kotlintelegrambot.dispatch 7 | import com.github.kotlintelegrambot.dispatcher.command 8 | import com.github.kotlintelegrambot.dispatcher.message 9 | import com.github.kotlintelegrambot.logging.LogLevel.Error 10 | import org.slf4j.LoggerFactory.getLogger 11 | import java.lang.System.getenv 12 | import java.util.concurrent.ExecutionException 13 | 14 | fun main() { 15 | val log = getLogger("Bot") 16 | val owner = getenv("OWNER")?.toLong()?.telegramId() 17 | lateinit var bank: Bank 18 | lateinit var promoter: Promoter 19 | lateinit var gun: Gun 20 | val bot = bot { 21 | token = getenv("TOKEN") 22 | logLevel = Error 23 | dispatch { 24 | command("balance") { 25 | if (!gun.muted(message)) { 26 | bank.inform(message) 27 | } 28 | } 29 | command("help") { 30 | if (!gun.muted(message)) { 31 | bot.faq(message) 32 | } 33 | } 34 | message { 35 | if (!gun.muted(message)) { 36 | val command = Command(message.text ?: return@message) 37 | try { 38 | when { 39 | command.isBan -> gun.mute(message, command.seconds) 40 | command.isUnban -> gun.unmute(message) 41 | command.isPin -> promoter.pin(message, command.seconds) 42 | command.isUnpin -> promoter.unpin(message) 43 | command.isTransfer -> bank.transfer(command.seconds, message) 44 | command.isFaq -> bot.faq(message) 45 | command.isBalance -> bank.inform(message) 46 | } 47 | } catch (e: RuntimeException) { 48 | owner?.let { bot.sendMessage(it, e.stackTraceToString()) } 49 | log.error(e.message, e) 50 | } catch (e: ExecutionException) { 51 | owner?.let { bot.sendMessage(it, e.stackTraceToString()) } 52 | log.error(e.cause?.message, e.cause) 53 | } 54 | } 55 | } 56 | } 57 | } 58 | val redis = redisOf(getenv("REDIS")) 59 | bank = Bank(bot, redis.getMap("bank")) 60 | promoter = Promoter(bot, bank, redis.getMap("promoter")) 61 | gun = Gun(bot, bank, redis.getMap("gun")) 62 | bot.startPolling() 63 | owner?.let { bot.sendMessage(it, "Successful deployment") } 64 | } -------------------------------------------------------------------------------- /src/main/kotlin/ml/demidko/timecobot/Bank.kt: -------------------------------------------------------------------------------- 1 | package ml.demidko.timecobot 2 | 3 | import com.github.demidko.print.utils.printSeconds 4 | import com.github.kotlintelegrambot.Bot 5 | import com.github.kotlintelegrambot.entities.Message 6 | import java.io.Closeable 7 | import java.util.concurrent.Executors.newSingleThreadExecutor 8 | import kotlin.concurrent.timer 9 | import kotlin.time.Duration.Companion.minutes 10 | 11 | /** 12 | * Класс отвечает за накопление, получение и перевод средств. 13 | * Поддерживается многопоточность. 14 | */ 15 | class Bank( 16 | private val api: Bot, 17 | private val storedSeconds: MutableMap 18 | ) : Closeable { 19 | private val executor = newSingleThreadExecutor() 20 | 21 | /** 22 | * Перевести деньги (!) другому пользователю. 23 | * Получатель должен находиться во вложенном сообщении 24 | * @param of отправитель перевода 25 | */ 26 | fun transfer(money: Seconds, of: Message) { 27 | val to = of.replyToMessage ?: return 28 | if (to.from?.id == of.from?.id) { 29 | return 30 | } 31 | if (spend(money, of)) { 32 | add(money, to) 33 | api.sendTempMessage(to.chatId(), "+${money.printSeconds()}", replyToMessageId = to.messageId) 34 | } 35 | } 36 | 37 | private fun add(money: Seconds, to: Message) { 38 | val id = to.from?.id ?: return 39 | executor.submit { 40 | storedSeconds[id] = storedSeconds[id]?.plus(money) ?: money 41 | } 42 | } 43 | 44 | /** 45 | * Потратить деньги пользователя. 46 | * @return true если платеж удался 47 | */ 48 | fun spend(money: Seconds, of: Message): Boolean { 49 | val user = of.from?.id ?: return false 50 | val isSuccessfully = executor.submit { 51 | val availableSeconds = storedSeconds[user] 52 | if (availableSeconds == null || money > availableSeconds) { 53 | false 54 | } else { 55 | storedSeconds[user] = availableSeconds - money 56 | true 57 | } 58 | } 59 | if (isSuccessfully.get()) { 60 | return true 61 | } 62 | api.sendTempMessage(of.chatId(), "Not enough money", replyToMessageId = of.messageId) 63 | return false 64 | } 65 | 66 | fun inform(m: Message) { 67 | val id = m.from?.id ?: return 68 | val report = storedSeconds.getOrDefault(id, 0).printSeconds() 69 | if (m.from?.id == m.chat.id) { 70 | api.sendMessage(m.chatId(), report, replyToMessageId = m.messageId) 71 | } else { 72 | api.sendTempMessage(m.chatId(), report, replyToMessageId = m.messageId) 73 | } 74 | } 75 | 76 | override fun close() = executor.shutdown() 77 | 78 | init { 79 | timer(period = minutes(1).inWholeMilliseconds) { 80 | executor.submit { 81 | for (p in storedSeconds) { 82 | p.setValue(p.value + 60) 83 | } 84 | } 85 | } 86 | } 87 | } -------------------------------------------------------------------------------- /src/main/kotlin/ml/demidko/timecobot/Command.kt: -------------------------------------------------------------------------------- 1 | package ml.demidko.timecobot 2 | 3 | class Command(text: String) { 4 | 5 | private companion object { 6 | const val MINUTE_SECONDS = 60L 7 | const val HOUR_SECONDS = MINUTE_SECONDS * 60 8 | const val DAY_SECONDS = HOUR_SECONDS * 24 9 | const val WEEK_SECONDS = DAY_SECONDS * 7 10 | const val MONTH_SECONDS = DAY_SECONDS * 30 11 | const val YEAR_SECONDS = DAY_SECONDS * 365 12 | const val MIN_SECONDS = 30L 13 | const val MAX_SECONDS = DAY_SECONDS * 366 14 | } 15 | 16 | private val text = text.trim().lowercase() 17 | 18 | val isBan 19 | get() = startStems( 20 | "ban", 21 | "block", 22 | "freez", 23 | "mute", 24 | "бан", 25 | "блок", 26 | "забан", 27 | "заглох", 28 | "завали", 29 | "умри", 30 | "умер", 31 | "мьют", 32 | "замьют", 33 | "💥" 34 | ) 35 | 36 | val isUnban 37 | get() = startStems( 38 | "liberat", 39 | "heal", 40 | "ransom", 41 | "atonement", 42 | "expiation", 43 | "redemption", 44 | "rescue", 45 | "salvation", 46 | "redeem", 47 | "unblock", 48 | "unban", 49 | "unmute", 50 | "разбан", 51 | "разблок", 52 | "ожив", 53 | "выкуп", 54 | "исцел", 55 | "искуп", 56 | "спаст", 57 | "освобод" 58 | ) 59 | 60 | val isTransfer 61 | get() = 62 | startWords("дать") || 63 | startStems( 64 | "transfer", 65 | "give", 66 | "take", 67 | "get", 68 | "keep", 69 | "держи", 70 | "бери", 71 | "возьми", 72 | "трансфер", 73 | "перевод", 74 | "дар", 75 | "подар", 76 | "взял", 77 | "забер", 78 | "забир", 79 | "перевед", 80 | "перевест", 81 | "отправ", 82 | "send" 83 | ) 84 | 85 | val isFaq 86 | get() = startStems( 87 | "помощ", 88 | "справк", 89 | "правил", 90 | "help", 91 | "rule", 92 | "faq", 93 | "onUpdateAction", 94 | "старт" 95 | ) 96 | 97 | val isBalance 98 | get() = startStems( 99 | "time", 100 | "врем", 101 | "balance", 102 | "status", 103 | "score", 104 | "coins", 105 | "баланс", 106 | "статус", 107 | "счет", 108 | "счёт", 109 | "узна", 110 | "timecoin", 111 | "check" 112 | ) 113 | 114 | val isPin 115 | get() = startWords("пин") || startStems("закреп", "pin", "запин") 116 | 117 | val isUnpin 118 | get() = startWords("пин") || startStems("откреп", "unpin", "отпин") 119 | 120 | val seconds: Long 121 | get() { 122 | val s = timeUnit * number 123 | return when { 124 | s < MIN_SECONDS -> MIN_SECONDS 125 | s > MAX_SECONDS -> MAX_SECONDS 126 | else -> s 127 | } 128 | } 129 | 130 | private val number: Long 131 | get() { 132 | val first = text.indexOfFirst(Char::isDigit) 133 | if (first == -1) { 134 | return 1 135 | } 136 | for (last in (first + 1) until text.length) { 137 | if (!text[last].isDigit()) { 138 | return text.substring(first until last).toLong() 139 | } 140 | } 141 | return text.substring(first).toLong() 142 | } 143 | 144 | /** 145 | * Единица времени в секундах 146 | */ 147 | private val timeUnit: Long 148 | get() { 149 | return when { 150 | stems("sec", "сек") || words("s", "с") -> 1 151 | stems("час", "hour") || words("h", "ч") -> HOUR_SECONDS 152 | stems("day", "суток", "сутк", "дня", "ден", "дне") 153 | || words("d", "д") -> DAY_SECONDS 154 | stems("week", "недел") -> WEEK_SECONDS 155 | stems("mon", "mo", "мес") -> MONTH_SECONDS 156 | stems("year", "год", "лет") || words("y", "yr", "г", "л") -> YEAR_SECONDS 157 | else -> MINUTE_SECONDS 158 | } 159 | } 160 | 161 | /** 162 | * @param stem all stems must be in lower case. 163 | */ 164 | private fun startStems(vararg stem: String) = stem.any(text::startsWith) 165 | 166 | /** 167 | * @param word all words must be in lower case. 168 | */ 169 | private fun startWords(vararg word: String) = 170 | word.any { text == it || (text.startsWith(it) && !text[it.length].isLetter()) } 171 | 172 | /** 173 | * @param stem all words must be in lower case. 174 | */ 175 | private fun stems(vararg stem: String) = stem.any(text::contains) 176 | 177 | /** 178 | * @param word all words must be in lower case. 179 | */ 180 | private fun words(vararg word: String) = word.any { 181 | val first = text.indexOf(it) 182 | if (first == -1 || (first > 0 && text[first].isLetter())) { 183 | return@any false 184 | } 185 | val last = first + it.length 186 | if (last < text.length && text[last].isLetter()) { 187 | return@any false 188 | } 189 | return@any true 190 | } 191 | } -------------------------------------------------------------------------------- /src/main/kotlin/ml/demidko/timecobot/Gun.kt: -------------------------------------------------------------------------------- 1 | import com.github.demidko.print.utils.printSeconds 2 | import com.github.kotlintelegrambot.Bot 3 | import com.github.kotlintelegrambot.entities.ChatPermissions 4 | import com.github.kotlintelegrambot.entities.Message 5 | import com.github.kotlintelegrambot.entities.ParseMode.MARKDOWN_V2 6 | import ml.demidko.timecobot.* 7 | import java.io.Closeable 8 | import java.io.Serializable 9 | import java.lang.Thread.currentThread 10 | import java.time.Instant.now 11 | import java.util.concurrent.Executors.newSingleThreadExecutor 12 | import kotlin.concurrent.thread 13 | import kotlin.time.Duration.Companion.days 14 | 15 | /** 16 | * Класс отвечает за блокировки и разблокировки пользователей. 17 | * Поддерживается многопоточность. 18 | */ 19 | class Gun( 20 | private val api: Bot, 21 | private val bank: Bank, 22 | private val mutedUsers: MutableMap 23 | ) : Closeable { 24 | 25 | data class UserAddress(val chatId: ChatId, val userId: UserId) : Serializable 26 | 27 | private companion object { 28 | 29 | private const val MIN_BAN = 30L 30 | 31 | private val MAX_BAN = days(365).inWholeSeconds 32 | 33 | private val NO_PERMISSIONS = ChatPermissions( 34 | canSendMessages = false, 35 | canSendMediaMessages = false, 36 | canSendPolls = false, 37 | canSendOtherMessages = false, 38 | canAddWebPagePreviews = false, 39 | canChangeInfo = false, 40 | canPinMessages = false, 41 | canInviteUsers = false, 42 | ) 43 | 44 | private val ALL_PERMISSIONS = ChatPermissions( 45 | canSendMessages = true, 46 | canSendMediaMessages = true, 47 | canSendPolls = true, 48 | canSendOtherMessages = true, 49 | canAddWebPagePreviews = true, 50 | canChangeInfo = true, 51 | canPinMessages = true, 52 | canInviteUsers = true, 53 | ) 54 | } 55 | 56 | private val executor = newSingleThreadExecutor() 57 | 58 | /** 59 | * Запрос на блокировку (!) другого пользователя. 60 | * Блокируемый пользователь должен находиться во вложенном сообщении. 61 | * @param request запрос от стрелка 62 | */ 63 | fun mute(request: Message, to: Seconds) { 64 | val reply = request.replyToMessage ?: return 65 | val victimId = reply.from?.id ?: return 66 | @Suppress("NAME_SHADOWING") val to = when { 67 | to < MIN_BAN -> MIN_BAN 68 | to > MAX_BAN -> MAX_BAN 69 | else -> to 70 | } 71 | if (bank.spend(to, request)) { 72 | val address = UserAddress(reply.chat.id, victimId) 73 | val chatId = reply.chatId() 74 | executor.submit { 75 | val mutedBefore = mutedUsers[address]?.plus(to) ?: now().epochSecond.plus(to) 76 | mutedUsers[address] = mutedBefore 77 | api.restrictChatMember(chatId, victimId, NO_PERMISSIONS, mutedBefore) 78 | } 79 | api.sendTempMessage(chatId, "💥", replyToMessageId = reply.messageId) 80 | api.sendMessage( 81 | chatId, 82 | "[${request.from?.fullName}](tg://user?id=${request.from!!.id}) muted your for ${to.printSeconds()}", 83 | parseMode = MARKDOWN_V2, 84 | replyToMessageId = reply.messageId 85 | ) 86 | } 87 | } 88 | 89 | /** 90 | * Проверяет не заблокирован ли отправитель сообщения. Если да, то удаляет его и возвращает true 91 | */ 92 | fun muted(m: Message): Boolean { 93 | val address = UserAddress(m.chat.id, m.from?.id ?: return false) 94 | val mutedBefore = mutedUsers[address] ?: return false 95 | val duration = mutedBefore - now().epochSecond 96 | if (duration > 0) { 97 | api.deleteMessage(m.chatId(), m.messageId) 98 | val savedMessage = m.text ?: m.toString() 99 | api.sendMessage( 100 | address.userId.telegramId(), 101 | "`$savedMessage`\nhas been deleted because " + 102 | "you were muted for ${duration.printSeconds()} in chat \n${m.chat}", 103 | parseMode = MARKDOWN_V2 104 | ) 105 | return true 106 | } 107 | return false 108 | } 109 | 110 | /** 111 | * Запрос от пользователя на разблокировку (!) другого пользователя. 112 | * Пользователь которого нужно разблокировать, должен находиться во вложенном сообщении. 113 | * @param request запрос от доброго самаритянина 114 | */ 115 | fun unmute(request: Message) { 116 | val reply = request.replyToMessage ?: return 117 | val address = UserAddress(reply.chat.id, reply.from?.id ?: return) 118 | val chatId = reply.chatId() 119 | executor.submit { 120 | val mutedBefore = mutedUsers[address] ?: return@submit 121 | val duration = mutedBefore - now().epochSecond 122 | if (bank.spend(duration, request)) { 123 | api.restrictChatMember(chatId, address.userId, ALL_PERMISSIONS) 124 | api.sendTempMessage(chatId, "You are free now!", replyToMessageId = reply.messageId) 125 | mutedUsers.remove(address) 126 | } 127 | } 128 | } 129 | 130 | override fun close() = executor.shutdown() 131 | 132 | init { 133 | thread { 134 | while (!currentThread().isInterrupted) { 135 | // Собираем ключи устаревших сообщений без очереди ожидания, 136 | // чтобы не блокировать работу других потоков 137 | val expired = mutedUsers.filterValues { it <= now().epochSecond }.keys 138 | if (expired.isEmpty()) { 139 | continue 140 | } 141 | // Пока мы собирали ключи устаревших записей, они могли обновиться, 142 | // поэтому перед удалением подозреваемых ставим повторную проверку в очередь, 143 | // последующие задачи остальных потоков в это время будут ждать в SingleThreadExecutor, 144 | // потому то финальная проверка и будет атомарной и покажет нам точный результат. 145 | // При этом, так как мы произвели предварительную небезопасную выборку и теперь 146 | // пройдемся только по ней, то остальные потоки будут ждать нас меньше. 147 | executor.submit { 148 | for (address in expired) { 149 | val epochSecond = mutedUsers[address] 150 | if (epochSecond != null && epochSecond <= now().epochSecond) { 151 | mutedUsers.remove(address) 152 | } 153 | } 154 | } 155 | } 156 | } 157 | } 158 | } -------------------------------------------------------------------------------- /src/main/kotlin/ml/demidko/timecobot/Promoter.kt: -------------------------------------------------------------------------------- 1 | import com.github.kotlintelegrambot.Bot 2 | import com.github.kotlintelegrambot.entities.Message 3 | import ml.demidko.timecobot.* 4 | import org.slf4j.LoggerFactory.getLogger 5 | import java.io.Closeable 6 | import java.io.Serializable 7 | import java.lang.Thread.currentThread 8 | import java.time.Instant 9 | import java.time.Instant.now 10 | import java.util.concurrent.Executors.newSingleThreadExecutor 11 | import kotlin.concurrent.thread 12 | 13 | /** 14 | * Класс отвечает за закрепление, продление, и своевременное автоматическое открепление сообщений. 15 | * Поддерживается многопоточность. 16 | */ 17 | class Promoter( 18 | private val api: Bot, 19 | private val bank: Bank, 20 | private val pinnedMessages: MutableMap 21 | ) : Closeable { 22 | 23 | data class MessageAddress(val chatId: ChatId, val messageId: MessageId) : Serializable 24 | 25 | private val log = getLogger("Promoter") 26 | 27 | private val executor = newSingleThreadExecutor() 28 | 29 | /** 30 | * Запрос на закрепление (!) другого сообщения. 31 | * Закрепляемое сообщение должно находиться во вложении. 32 | */ 33 | fun pin(request: Message, to: Seconds) { 34 | val message = request.replyToMessage ?: return 35 | if (bank.spend(to, request)) { 36 | val address = MessageAddress(message.chat.id, message.messageId) 37 | executor.submit { 38 | api.pinChatMessage(message.chatId(), message.messageId) 39 | pinnedMessages[address] = pinnedMessages[address]?.plus(to) ?: now().epochSecond.plus(to) 40 | log.info( 41 | "Pin - {} {} before {} (now {})", 42 | message.text, 43 | address, 44 | pinnedMessages[address], 45 | Instant.now().epochSecond 46 | ) 47 | } 48 | } 49 | } 50 | 51 | /** 52 | * Запрос на открепление (!) другого сообщения. 53 | * Открепляемое сообщение должно находиться во вложении. 54 | */ 55 | fun unpin(request: Message) { 56 | val message = request.replyToMessage ?: return 57 | val address = MessageAddress(message.chat.id, message.messageId) 58 | executor.submit { 59 | val sec = (pinnedMessages[address] ?: return@submit) - now().epochSecond 60 | if (sec > 0 && bank.spend(sec, request)) { 61 | api.unpinChatMessage(message.chatId(), message.messageId) 62 | pinnedMessages.remove(address) 63 | } 64 | } 65 | } 66 | 67 | override fun close() = executor.shutdown() 68 | 69 | init { 70 | thread { 71 | while (!currentThread().isInterrupted) { 72 | 73 | // Собираем ключи устаревших сообщений без очереди ожидания, 74 | // чтобы не блокировать работу других потоков 75 | val expired = pinnedMessages.filterValues { it <= now().epochSecond }.keys 76 | if (expired.isEmpty()) { 77 | continue 78 | } 79 | 80 | // Пока мы собирали ключи устаревших сообщений, они могли обновиться, 81 | // поэтому перед удалением подозреваемых ставим повторную проверку в очередь, 82 | // последующие задачи остальных потоков в это время будут ждать в SingleThreadExecutor, 83 | // потому то финальная проверка и будет атомарной и покажет нам точный результат. 84 | // При этом, так как мы произвели предварительную небезопасную выборку и теперь 85 | // пройдемся только по ней, то остальные потоки будут ждать нас меньше. 86 | executor.submit { 87 | for (address in expired) { 88 | val epochSecond = pinnedMessages[address] 89 | if (epochSecond != null && epochSecond <= now().epochSecond) { 90 | api.unpinChatMessage(address.chatId.telegramId(), address.messageId) 91 | pinnedMessages.remove(address) 92 | log.info("Cleanup - {}", address) 93 | } 94 | } 95 | } 96 | } 97 | } 98 | } 99 | } -------------------------------------------------------------------------------- /src/main/kotlin/ml/demidko/timecobot/Types.kt: -------------------------------------------------------------------------------- 1 | /** 2 | * Тут мы просто определяем читаемые псевдонимы и расширяем библиотечные API. 3 | */ 4 | package ml.demidko.timecobot 5 | 6 | import com.github.kotlintelegrambot.Bot 7 | import com.github.kotlintelegrambot.entities.* 8 | import com.github.kotlintelegrambot.entities.ChatId 9 | import com.github.kotlintelegrambot.entities.ChatId.Companion.fromId 10 | import org.redisson.Redisson.create 11 | import org.redisson.config.Config 12 | import java.util.* 13 | import kotlin.concurrent.schedule 14 | 15 | private val TMP_MESSAGES_TIMER = Timer() 16 | 17 | fun Long.telegramId() = fromId(this) 18 | 19 | fun Message.chatId() = fromId(chat.id) 20 | 21 | typealias ChatId = Long 22 | 23 | typealias MessageId = Long 24 | 25 | typealias EpochSecond = Long 26 | 27 | typealias UserId = Long 28 | 29 | typealias Seconds = Long 30 | 31 | /** 32 | * Use this method to send text messages 33 | * @param chatId Unique identifier for the target chat or username of the target channel (in the format @channelusername) 34 | * @param text text of the message to be sent, 1-4096 characters after entities parsing 35 | * @param parseMode mode for parsing entities in the message text 36 | * @param disableWebPagePreview disables link previews for links in this message 37 | * @param disableNotification sends the message silently - users will receive a notification with no sound 38 | * @param replyToMessageId if the message is a reply, ID of the original message 39 | * @param replyMarkup additional options - inline keyboard, custom reply keyboard, instructions to remove reply 40 | * keyboard or to force a reply from the user 41 | * @return the sent [Message] on success 42 | */ 43 | fun Bot.sendTempMessage( 44 | chatId: ChatId, 45 | text: String, 46 | parseMode: ParseMode? = null, 47 | disableWebPagePreview: Boolean? = null, 48 | disableNotification: Boolean? = null, 49 | replyToMessageId: Long? = null, 50 | allowSendingWithoutReply: Boolean? = null, 51 | replyMarkup: ReplyMarkup? = null 52 | ) { 53 | val messageId = sendMessage( 54 | chatId, 55 | text, 56 | parseMode, 57 | disableWebPagePreview, 58 | disableNotification, 59 | replyToMessageId, 60 | allowSendingWithoutReply, 61 | replyMarkup 62 | ).first 63 | ?.body() 64 | ?.result 65 | ?.messageId 66 | ?: error("Failed to send message") 67 | delayDeleteMessage(chatId, messageId) 68 | } 69 | 70 | /** 71 | * Use this method to delete a message, including service messages, with the following limitations: 72 | * - A message can only be deleted if it was sent less than 48 hours ago. 73 | * - A dice message in a private chat can only be deleted if it was sent more than 24 hours ago. 74 | * - Bots can delete outgoing messages in private chats, groups, and supergroups. 75 | * - Bots can delete incoming messages in private chats. 76 | * - Bots granted `can_post_messages` permissions can delete outgoing messages in channels. 77 | * - If the bot is an administrator of a group, it can delete any message there. 78 | * - If the bot has `can_delete_messages` permission in a supergroup or a channel, it can delete any message there. 79 | * 80 | * @param chatId Unique identifier for the target chat or username of the target channel (in 81 | * the format @channelusername) 82 | * @param messageId Identifier of the message to delete. 83 | * 84 | * @return True on success. 85 | */ 86 | fun Bot.delayDeleteMessage( 87 | chatId: ChatId, 88 | messageId: Long, 89 | delay: Seconds = 15 90 | ) = TMP_MESSAGES_TIMER.schedule((delay * 1000).toLong()) { 91 | deleteMessage(chatId, messageId) 92 | } 93 | 94 | fun redisOf(connection: String) = create(Config().apply { 95 | useSingleServer().apply { 96 | val authorizationIdx = 9 97 | username = 98 | connection 99 | .substring(authorizationIdx) 100 | .substringBefore(':') 101 | password = 102 | connection 103 | .substring(authorizationIdx + username.length + 1) 104 | .substringBefore('@') 105 | address = connection 106 | } 107 | }) 108 | 109 | private fun User.relatedFaq() = when { 110 | isRussian() -> faqRu 111 | else -> faqEn 112 | } 113 | 114 | fun Bot.faq(m: Message) { 115 | val faq = m.from?.relatedFaq() ?: return 116 | sendTempMessage(m.chatId(), faq, replyToMessageId = m.messageId) 117 | } 118 | 119 | fun User.isRussian() = firstName.isRussian() || lastName.isRussian() 120 | 121 | fun String?.isRussian() = this?.lowercase()?.any { it in 'а'..'я' } ?: false 122 | 123 | private val faqRu = 124 | """ 125 | Чтобы начать использовать бота, добавьте его в группу с правами администратора. 126 | 1. Время (валюта) начисляется автоматически по секретной формуле. 127 | Чтобы узнать свой баланс, напишите в чат запрос, например слово ”баланс”, ”статус” или ”время” 128 | (В ответе используются сокращения d - дни, h - часы, m - минуты, s - секунды) 129 | 2. Чтобы заблокировать пользователя используйте приказы вида ”бан 5 минут” или ”блок на 2 часа” в ответном сообщении пользователю. 130 | Бот заблокирует его на указанное время: пользователь останется в чате, но не сможет ничего писать. 131 | 3 Чтобы выкупить человека из бана просто напишите ”разбань его” или ”выкупить” в ответному сообщении ему. 132 | 4. Чтобы перевести время другому человеку напишите, например ”переводи Васе 5 моих минут” в ответном сообщении этому человеку. 133 | 5. Чтобы закрепить сообщения за ваше время, напишите что-то вроде ”закрепить” в ответе к сообщению. 134 | Эти приказы можно перeформулировать по разному, эксперементируйте! 135 | Остальные вопросы задать можно тут @timecochat 136 | """.trimIndent() 137 | 138 | private val faqEn = 139 | """ 140 | To start using the bot, just add it to the group with admin rights. 141 | 1. Time (currency) is calculated automatically using a secret formula. 142 | To check your balance, send a request to the chat, for example word ”balance”, ”status” or ”time” symbol. 143 | Abbreviations: d - days, h - hours, m - minutes, s - seconds 144 | 2. To block a person, use requests like ”ban 5 minutes” or ”block for 2 hours” in the reply message to that person. 145 | Bot will block user specified time: user will remain in the chat, but he will not be able to write anything. 146 | 3. To unblock a user, simply write to him in the reply message ”unblock”, ”unban”, ”ransom” or ”redeem”. 147 | 4. To pass the time to another person, write, for example, ”give my 5 minutes to John” in the reply message to this person. 148 | 5. To pin message, write, for example, ”pin” in the reply message to this message. 149 | These orders can be formulated in different ways, experiment! 150 | Still have questions? You can ask them here @timecochat 151 | """.trimIndent() 152 | 153 | val User.fullName: String 154 | get() { 155 | return when (lastName) { 156 | null -> firstName 157 | else -> "$firstName $lastName" 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | %d{HH:mm:ss, Asia/Vladivostok} [%thread] %.-1level %logger{36} - %msg%n%xEx{30} 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/test/kotlin/ml/demidko/timecobot/CommandTest.kt: -------------------------------------------------------------------------------- 1 | package ml.demidko.timecobot 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEqualTo 5 | import assertk.assertions.isTrue 6 | import org.junit.jupiter.api.Test 7 | 8 | class CommandTest { 9 | 10 | @Test 11 | fun isBan() { 12 | Command("Забанить вакцинаторов на 3 недели").run { 13 | assertThat(isBan).isTrue() 14 | assertThat(seconds).isEqualTo(60L * 60 * 24 * 7 * 3) 15 | } 16 | Command("бан5м").run { 17 | assertThat(isBan).isTrue() 18 | assertThat(seconds).isEqualTo(60L * 5) 19 | } 20 | } 21 | 22 | @Test 23 | fun isPin() { 24 | Command("закрепить на 30 сек").run { 25 | assertThat(isPin).isTrue() 26 | assertThat(seconds).isEqualTo(30L) 27 | } 28 | } 29 | } 30 | --------------------------------------------------------------------------------