├── .dockerignore ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── lint.yaml │ └── release.yml ├── .gitignore ├── .goreleaser.yml ├── Dockerfile ├── INSTALL-1.md ├── INSTALL-2.md ├── INSTALL-3.md ├── LICENSE ├── Makefile ├── README.md ├── config.toml ├── docker-compose.yml ├── go.mod ├── go.sum ├── main.go └── main_test.go /.dockerignore: -------------------------------------------------------------------------------- 1 | bot 2 | dist 3 | .git 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: https://www.buymeacoffee.com/ydfPU75 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: '/' 5 | schedule: 6 | interval: "weekly" 7 | open-pull-requests-limit: 10 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | open-pull-requests-limit: 10 13 | - package-ecosystem: "docker" 14 | directory: "/" 15 | schedule: 16 | interval: "weekly" 17 | open-pull-requests-limit: 10 18 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | golangci: 9 | name: lint 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/setup-go@v5 13 | - uses: actions/checkout@v4 14 | - name: golangci-lint 15 | uses: golangci/golangci-lint-action@v7 16 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: "Release" 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | docker-release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Docker login 14 | run: docker login --username ${{ secrets.DOCKER_LOGIN }} --password ${{ secrets.DOCKER_PASSWORD }} 15 | - name: Docker release 16 | run: make docker-release 17 | 18 | github-release: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Set up Go 23 | uses: actions/setup-go@v5 24 | with: 25 | go-version: '1.22' 26 | - name: Run GoReleaser 27 | uses: goreleaser/goreleaser-action@v6 28 | with: 29 | args: release --clean 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | dist/ 3 | bot 4 | 5 | dist/ 6 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | builds: 2 | - env: 3 | - CGO_ENABLED=0 4 | goos: 5 | - linux 6 | goarch: 7 | - amd64 8 | checksum: 9 | name_template: 'checksums.txt' 10 | snapshot: 11 | name_template: "{{ .Tag }}" 12 | changelog: 13 | sort: asc 14 | filters: 15 | exclude: 16 | - '^docs:' 17 | - '^test:' 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24.2-alpine3.20 as builder 2 | 3 | WORKDIR /go/src/github.com/mxssl/tg-captcha-bot 4 | COPY . . 5 | 6 | # Install external dependcies 7 | RUN apk add --no-cache \ 8 | ca-certificates \ 9 | curl \ 10 | git 11 | 12 | # Compile binary 13 | RUN CGO_ENABLED=0 \ 14 | go build -v -o bot 15 | 16 | # Copy compiled binary to clear Alpine Linux image 17 | FROM alpine:3.21.3 18 | WORKDIR / 19 | RUN apk add --no-cache ca-certificates 20 | COPY --from=builder /go/src/github.com/mxssl/tg-captcha-bot . 21 | RUN chmod +x bot 22 | CMD ["./bot"] 23 | -------------------------------------------------------------------------------- /INSTALL-1.md: -------------------------------------------------------------------------------- 1 | # docker-compose: use already built docker container image 2 | 3 | ## Prerequisites 4 | 5 | - Obtain bot token from [@BotFather](https://t.me/BotFather) 6 | - Install [Docker](https://docs.docker.com/install) 7 | 8 | ## Instructions 9 | 10 | 1. Clone the repo 11 | 12 | ```bash 13 | git clone https://github.com/mxssl/tg-captcha-bot.git 14 | cd tg-captcha-bot 15 | ``` 16 | 17 | 2. Add a token from BotFather to env variable in docker-compose.yml 18 | 19 | ```yaml 20 | version: '3' 21 | 22 | services: 23 | tg-captcha-bot: 24 | image: mxssl/tg-captcha-bot:v1.1.10 25 | volumes: 26 | - ./config.toml:/config.toml 27 | restart: unless-stopped 28 | environment: 29 | TGTOKEN: 30 | ``` 31 | 32 | 3. Pull the container 33 | 34 | ```bash 35 | docker compose pull 36 | ``` 37 | 38 | 4. Run the container 39 | 40 | ```bash 41 | docker compose up -d 42 | ``` 43 | 44 | 5. Check that the bot started correctly 45 | 46 | ```bash 47 | docker compose ps 48 | docker compose logs 49 | ``` 50 | 51 | 6. Add the bot to your supergroup and give it administrator privileges 52 | -------------------------------------------------------------------------------- /INSTALL-2.md: -------------------------------------------------------------------------------- 1 | # docker-compose: build your own docker container 2 | 3 | ## Prerequisites 4 | 5 | - Obtain bot token from [@BotFather](https://t.me/BotFather) 6 | - Install [Docker](https://docs.docker.com/install) 7 | 8 | ## Instructions 9 | 10 | 1. Clone the repo 11 | 12 | ```bash 13 | git clone https://github.com/mxssl/tg-captcha-bot.git 14 | cd tg-captcha-bot 15 | ``` 16 | 17 | 2. Add a token from BotFather to env variable in docker-compose.yml 18 | 19 | ```yaml 20 | version: '3' 21 | 22 | services: 23 | tg-captcha-bot: 24 | build: 25 | context: . 26 | dockerfile: Dockerfile 27 | image: tg-captcha-bot:latest 28 | volumes: 29 | - ./config.toml:/config.toml 30 | restart: unless-stopped 31 | environment: 32 | TGTOKEN: 33 | ``` 34 | 35 | 3. Build a Docker container 36 | 37 | ```bash 38 | docker compose build 39 | ``` 40 | 41 | 4. Run the container 42 | 43 | ```bash 44 | docker compose up -d 45 | ``` 46 | 47 | 5. Check that the bot started correctly 48 | 49 | ```bash 50 | docker compose ps 51 | docker compose logs 52 | ``` 53 | 54 | 6. Add the bot to your supergroup and give it administrator privileges 55 | -------------------------------------------------------------------------------- /INSTALL-3.md: -------------------------------------------------------------------------------- 1 | # systemd 2 | 3 | ## Prerequisites 4 | 5 | Obtain bot token from [@BotFather](https://t.me/BotFather) 6 | 7 | ## Instructions 8 | 9 | 1. Clone the repo 10 | 11 | ```bash 12 | git clone https://github.com/mxssl/tg-captcha-bot.git 13 | cd tg-captcha-bot 14 | ``` 15 | 16 | 2. Download bot binary and move it to needed directory 17 | 18 | ```bash 19 | wget https://github.com/mxssl/tg-captcha-bot/releases/download/v1.1.10/tg-captcha-bot_1.1.10_linux_amd64.tar.gz 20 | 21 | tar xvzf tg-captcha-bot_1.1.10_linux_amd64.tar.gz 22 | 23 | mv tg-captcha-bot /usr/local/bin/tg-captcha-bot 24 | 25 | chmod +x /usr/local/bin/tg-captcha-bot 26 | ``` 27 | 28 | 3. Move bot's config to needed path 29 | 30 | ```bash 31 | mkdir -p /etc/tg-captcha-bot 32 | cp config.toml /etc/tg-captcha-bot/config.toml 33 | ``` 34 | 35 | 4. Create systemd unit file `/etc/systemd/system/tg-captcha-bot.service` 36 | 37 | ```bash 38 | [Unit] 39 | Description=tg-captcha-bot 40 | Wants=network-online.target 41 | After=network-online.target 42 | 43 | [Service] 44 | Environment="TGTOKEN=your_token" 45 | Environment="CONFIG_PATH=/etc/tg-captcha-bot" 46 | Type=simple 47 | ExecStart=/usr/local/bin/tg-captcha-bot 48 | 49 | Restart=always 50 | RestartSec=3s 51 | 52 | [Install] 53 | WantedBy=multi-user.target 54 | ``` 55 | 56 | 5. Reload configuration and restart service 57 | 58 | ```bash 59 | systemctl daemon-reload 60 | systemctl restart tg-captcha-bot.service 61 | ``` 62 | 63 | 6. Check service status 64 | 65 | ```bash 66 | systemctl status tg-captcha-bot.service 67 | ``` 68 | 69 | 7. Check logs 70 | 71 | ```bash 72 | journalctl -u tg-captcha-bot.service 73 | ``` 74 | 75 | 8. Add the bot to your supergroup and give it administrator privileges 76 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Maksim 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. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BINARY_NAME=bot 2 | CURRENT_DIR=$(shell pwd) 3 | TAG=$(shell git name-rev --tags --name-only $(shell git rev-parse HEAD)) 4 | DOCKER_REGISTRY=mxssl 5 | export GO111MODULE=on 6 | 7 | .PHONY: all build clean lint critic test 8 | 9 | all: build 10 | 11 | build: 12 | go build -v -o ${BINARY_NAME} 13 | 14 | clean: 15 | rm -f ${BINARY_NAME} 16 | 17 | lint: 18 | golangci-lint run -v 19 | 20 | test: 21 | go test -v ./... 22 | 23 | init: 24 | go mod init 25 | 26 | tidy: 27 | go mod tidy 28 | 29 | github-release-dry: 30 | @echo "TAG: ${TAG}" 31 | goreleaser release --rm-dist --snapshot --skip-publish 32 | 33 | github-release: 34 | @echo "TAG: ${TAG}" 35 | goreleaser release --rm-dist 36 | 37 | docker-release: 38 | @echo "Registry: ${DOCKER_REGISTRY}" 39 | @echo "TAG: ${TAG}" 40 | docker build --tag ${DOCKER_REGISTRY}/tg-captcha-bot:${TAG} . 41 | docker push ${DOCKER_REGISTRY}/tg-captcha-bot:${TAG} 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Go Report Card 2 | Version 3 | GitHub tag (latest by date) 4 | GitHub closed issues 5 | GitHub contributors 6 | GitHub All Releases 7 | Docker Pulls 8 | 9 | # Telegram Captcha Bot 10 | 11 | Telegram bot that validates new users that enter supergroup. Validation works like a simple captcha. Bot written in Go (Golang). 12 | 13 | This bot has been tested on several supergroups (2000+ people) for a long time and has shown its effectiveness against spammers. 14 | 15 | ## Cloud hosted instance of the bot 16 | 17 | [@cloud_tg_captcha_bot](https://t.me/cloud_tg_captcha_bot) 18 | 19 | ## How it works 20 | 21 | 1. Add the bot to your supergroup 22 | 2. Promote the bot for administrator privileges 23 | 3. A new user enters your supergroup 24 | 4. Bot restricts the user's ability to send messages 25 | 5. Bot shows a welcome message and a captcha button to the user 26 | 6. If the user doesn't press the button within 30 seconds then the user is banned by the bot 27 | 28 | ## If you want to run your own instance of the bot 29 | 30 | - [Option 1 (the easiest one)](./INSTALL-1.md): docker-compose + already built docker container 31 | - [Option 2](./INSTALL-2.md): docker-compose + build your own docker container 32 | - [Option 3](./INSTALL-3.md): systemd 33 | 34 | ## Commands 35 | 36 | `/healthz` - check that the bot is working correctly 37 | 38 | ## Сustomization 39 | 40 | You can change several bot's settings (welcome message, ban duration, socks5 proxy server) through the configuration file `config.toml` 41 | 42 | ## Alternatives / Forks 43 | 44 | - [momai/tg-captcha-bot](https://github.com/momai/tg-captcha-bot) - fork of `tg-captcha-bot` with interesting additional features. 45 | -------------------------------------------------------------------------------- /config.toml: -------------------------------------------------------------------------------- 1 | # Config file for Telegram Captcha Bot 2 | 3 | # Button text 4 | button_text = "I'm not a robot!" 5 | 6 | # Bot sends this message to new users 7 | welcome_message = "Hello! This is the spam protection system. Please press the button within 30 seconds or you will be banned!" 8 | 9 | # Edit welcome message with this text if user passed validation 10 | after_success_message = "User passed the validation." 11 | 12 | # Edit welcome message with this text if user failed validation 13 | after_fail_message = "User didn't pass the validation and was banned." 14 | 15 | # Show success and failure messages or delete them. Pick "show" or "del" 16 | print_success_and_fail_messages_strategy = "show" 17 | 18 | # During this time in seconds, the new user have to press the captha button 19 | welcome_timeout = "30" 20 | 21 | # If the new user does not press the button, the bot will ban the user for this duration of time. Can be "forever" or number of minutes ("10") 22 | ban_duration = "forever" 23 | 24 | # Do you want to use a socks5 proxy server? Can be "yes" or "no" 25 | use_socks5_proxy = "no" 26 | 27 | # Socks5 proxy server IP address 28 | socks5_address = "1.1.1.1" 29 | 30 | # Socks5 proxy server TCP port 31 | socks5_port = "1080" 32 | 33 | # Socks5 proxy server username 34 | socks5_login = "login" 35 | 36 | # Socks5 proxy server password 37 | socks5_password = "password" 38 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | tg-captcha-bot: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | image: tg-captcha-bot:latest 9 | volumes: 10 | - ./config.toml:/config.toml 11 | restart: unless-stopped 12 | environment: 13 | TGTOKEN: 14 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mxssl/tg-captcha-bot 2 | 3 | go 1.20 4 | toolchain go1.24.1 5 | 6 | require ( 7 | github.com/pkg/errors v0.9.1 8 | github.com/spf13/viper v1.20.0 9 | golang.org/x/net v0.37.0 10 | gopkg.in/tucnak/telebot.v2 v2.5.0 11 | ) 12 | 13 | require ( 14 | github.com/fsnotify/fsnotify v1.8.0 // indirect 15 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 16 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 17 | github.com/sagikazarmark/locafero v0.7.0 // indirect 18 | github.com/sourcegraph/conc v0.3.0 // indirect 19 | github.com/spf13/afero v1.12.0 // indirect 20 | github.com/spf13/cast v1.7.1 // indirect 21 | github.com/spf13/pflag v1.0.6 // indirect 22 | github.com/subosito/gotenv v1.6.0 // indirect 23 | go.uber.org/atomic v1.9.0 // indirect 24 | go.uber.org/multierr v1.9.0 // indirect 25 | golang.org/x/sys v0.31.0 // indirect 26 | golang.org/x/text v0.23.0 // indirect 27 | gopkg.in/yaml.v3 v3.0.1 // indirect 28 | ) 29 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 5 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 6 | github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= 7 | github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 8 | github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= 9 | github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 10 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 11 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 12 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 13 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 14 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 15 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 16 | github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= 17 | github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 18 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 19 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 20 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 21 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 22 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 23 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 24 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 25 | github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= 26 | github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= 27 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 28 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 29 | github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= 30 | github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= 31 | github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= 32 | github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 33 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 34 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 35 | github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY= 36 | github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= 37 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 38 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 39 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 40 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 41 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 42 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 43 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 44 | go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= 45 | go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 46 | go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= 47 | go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= 48 | golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= 49 | golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 50 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 51 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 52 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 53 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 54 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 55 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 56 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 57 | gopkg.in/tucnak/telebot.v2 v2.5.0 h1:i+NynLo443Vp+Zn3Gv9JBjh3Z/PaiKAQwcnhNI7y6Po= 58 | gopkg.in/tucnak/telebot.v2 v2.5.0/go.mod h1:BgaIIx50PSRS9pG59JH+geT82cfvoJU/IaI5TJdN3v8= 59 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 60 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 61 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 62 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "net" 8 | "net/http" 9 | "os" 10 | "os/signal" 11 | "regexp" 12 | "strconv" 13 | "sync" 14 | "syscall" 15 | "time" 16 | 17 | "github.com/pkg/errors" 18 | "github.com/spf13/viper" 19 | "golang.org/x/net/proxy" 20 | tb "gopkg.in/tucnak/telebot.v2" 21 | ) 22 | 23 | // Config struct for toml config file 24 | type Config struct { 25 | ButtonText string `mapstructure:"button_text"` 26 | WelcomeMessage string `mapstructure:"welcome_message"` 27 | AfterSuccessMessage string `mapstructure:"after_success_message"` 28 | AfterFailMessage string `mapstructure:"after_fail_message"` 29 | PrintSuccessAndFail string `mapstructure:"print_success_and_fail_messages_strategy"` 30 | WelcomeTimeout string `mapstructure:"welcome_timeout"` 31 | BanDurations string `mapstructure:"ban_duration"` 32 | UseSocks5Proxy string `mapstructure:"use_socks5_proxy"` 33 | Socks5Address string `mapstructure:"socks5_address"` 34 | Socks5Port string `mapstructure:"socks5_port"` 35 | Socks5Login string `mapstructure:"socks5_login"` 36 | Socks5Password string `mapstructure:"socks5_password"` 37 | } 38 | 39 | var config Config 40 | var passedUsers = sync.Map{} 41 | var bot *tb.Bot 42 | var tgtoken = "TGTOKEN" 43 | var configPath = "CONFIG_PATH" 44 | 45 | func init() { 46 | err := readConfig() 47 | if err != nil { 48 | log.Fatalf("Cannot read config file. Error: %v", err) 49 | } 50 | } 51 | 52 | func main() { 53 | token, err := getToken(tgtoken) 54 | if err != nil { 55 | log.Fatalln(err) 56 | } 57 | log.Printf("Telegram Bot Token [%v] successfully obtained from env variable $TGTOKEN\n", token) 58 | 59 | var httpClient *http.Client 60 | if config.UseSocks5Proxy == "yes" { 61 | var err error 62 | httpClient, err = initSocks5Client() 63 | if err != nil { 64 | log.Fatalln(err) 65 | } 66 | } 67 | 68 | bot, err = tb.NewBot(tb.Settings{ 69 | Token: token, 70 | Poller: &tb.LongPoller{Timeout: 10 * time.Second}, 71 | Client: httpClient, 72 | }) 73 | if err != nil { 74 | log.Fatalf("Cannot start bot. Error: %v\n", err) 75 | } 76 | 77 | bot.Handle(tb.OnUserJoined, challengeUser) 78 | bot.Handle(tb.OnCallback, passChallenge) 79 | 80 | bot.Handle("/healthz", func(m *tb.Message) { 81 | msg := "I'm OK" 82 | if _, err := bot.Send(m.Chat, msg); err != nil { 83 | log.Println(err) 84 | } 85 | log.Printf("Healthz request from user: %v\n in chat: %v", m.Sender, m.Chat) 86 | }) 87 | 88 | log.Println("Bot started!") 89 | go func() { 90 | bot.Start() 91 | }() 92 | 93 | signalChan := make(chan os.Signal, 1) 94 | signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM) 95 | <-signalChan 96 | log.Println("Shutdown signal received, exiting...") 97 | } 98 | 99 | func challengeUser(m *tb.Message) { 100 | if m.UserJoined.ID != m.Sender.ID { 101 | return 102 | } 103 | log.Printf("User: %v joined the chat: %v", m.UserJoined, m.Chat) 104 | 105 | if member, err := bot.ChatMemberOf(m.Chat, m.UserJoined); err == nil { 106 | if member.Role == tb.Restricted { 107 | log.Printf("User: %v already restricted in chat: %v", m.UserJoined, m.Chat) 108 | return 109 | } 110 | } 111 | 112 | newChatMember := tb.ChatMember{User: m.UserJoined, RestrictedUntil: tb.Forever(), Rights: tb.Rights{CanSendMessages: false}} 113 | err := bot.Restrict(m.Chat, &newChatMember) 114 | if err != nil { 115 | log.Println(err) 116 | } 117 | 118 | inlineKeys := [][]tb.InlineButton{{tb.InlineButton{ 119 | Unique: "challenge_btn", 120 | Text: config.ButtonText, 121 | }}} 122 | 123 | challengeMsg, err := bot.Reply(m, config.WelcomeMessage, &tb.ReplyMarkup{InlineKeyboard: inlineKeys}) 124 | if err != nil { 125 | log.Printf("Can't send challenge msg: %v", err) 126 | return 127 | } 128 | 129 | n, err := strconv.ParseInt(config.WelcomeTimeout, 10, 64) 130 | if err != nil { 131 | log.Println(err) 132 | } 133 | time.AfterFunc(time.Duration(n)*time.Second, func() { 134 | _, passed := passedUsers.Load(m.UserJoined.ID) 135 | if !passed { 136 | banDuration, e := getBanDuration() 137 | if e != nil { 138 | log.Println(e) 139 | } 140 | chatMember := tb.ChatMember{User: m.UserJoined, RestrictedUntil: banDuration} 141 | err := bot.Ban(m.Chat, &chatMember) 142 | if err != nil { 143 | log.Println(err) 144 | } 145 | 146 | if config.PrintSuccessAndFail == "show" { 147 | _, err := bot.Edit(challengeMsg, config.AfterFailMessage) 148 | if err != nil { 149 | log.Println(err) 150 | } 151 | } else if config.PrintSuccessAndFail == "del" { 152 | err := bot.Delete(m) 153 | if err != nil { 154 | log.Println(err) 155 | } 156 | err = bot.Delete(challengeMsg) 157 | if err != nil { 158 | log.Println(err) 159 | } 160 | } 161 | 162 | log.Printf("User: %v was banned in chat: %v for: %v minutes", m.UserJoined, m.Chat, config.BanDurations) 163 | } 164 | passedUsers.Delete(m.UserJoined.ID) 165 | }) 166 | } 167 | 168 | // passChallenge is used when user passed the validation 169 | func passChallenge(c *tb.Callback) { 170 | if c.Message.ReplyTo.Sender.ID != c.Sender.ID { 171 | err := bot.Respond(c, &tb.CallbackResponse{Text: "This button isn't for you"}) 172 | if err != nil { 173 | log.Println(err) 174 | } 175 | return 176 | } 177 | passedUsers.Store(c.Sender.ID, struct{}{}) 178 | 179 | if config.PrintSuccessAndFail == "show" { 180 | _, err := bot.Edit(c.Message, config.AfterSuccessMessage) 181 | if err != nil { 182 | log.Println(err) 183 | } 184 | } else if config.PrintSuccessAndFail == "del" { 185 | err := bot.Delete(c.Message) 186 | if err != nil { 187 | log.Println(err) 188 | } 189 | } 190 | 191 | log.Printf("User: %v passed the challenge in chat: %v", c.Sender, c.Message.Chat) 192 | newChatMember := tb.ChatMember{User: c.Sender, RestrictedUntil: tb.Forever(), Rights: tb.Rights{CanSendMessages: true}} 193 | err := bot.Promote(c.Message.Chat, &newChatMember) 194 | if err != nil { 195 | log.Println(err) 196 | } 197 | err = bot.Respond(c, &tb.CallbackResponse{Text: "Validation passed!"}) 198 | if err != nil { 199 | log.Println(err) 200 | } 201 | } 202 | 203 | func readConfig() (err error) { 204 | v := viper.New() 205 | path, ok := os.LookupEnv(configPath) 206 | if ok { 207 | v.SetConfigName("config") 208 | v.AddConfigPath(path) 209 | } 210 | v.SetConfigName("config") 211 | v.AddConfigPath(".") 212 | 213 | if err = v.ReadInConfig(); err != nil { 214 | return err 215 | } 216 | if err = v.Unmarshal(&config); err != nil { 217 | return err 218 | } 219 | return 220 | } 221 | 222 | func getToken(key string) (string, error) { 223 | token, ok := os.LookupEnv(key) 224 | if !ok { 225 | err := errors.Errorf("Env variable %v isn't set!", key) 226 | return "", err 227 | } 228 | match, err := regexp.MatchString(`^[0-9]+:.*$`, token) 229 | if err != nil { 230 | return "", err 231 | } 232 | if !match { 233 | err := errors.Errorf("Telegram Bot Token [%v] is incorrect. Token doesn't comply with regexp: `^[0-9]+:.*$`. Please, provide a correct Telegram Bot Token through env variable TGTOKEN", token) 234 | return "", err 235 | } 236 | return token, nil 237 | } 238 | 239 | func getBanDuration() (int64, error) { 240 | if config.BanDurations == "forever" { 241 | return tb.Forever(), nil 242 | } 243 | 244 | n, err := strconv.ParseInt(config.BanDurations, 10, 64) 245 | if err != nil { 246 | return 0, err 247 | } 248 | 249 | return time.Now().Add(time.Duration(n) * time.Minute).Unix(), nil 250 | } 251 | 252 | func initSocks5Client() (*http.Client, error) { 253 | addr := fmt.Sprintf("%s:%s", config.Socks5Address, config.Socks5Port) 254 | dialer, err := proxy.SOCKS5("tcp", addr, &proxy.Auth{User: config.Socks5Login, Password: config.Socks5Password}, proxy.Direct) 255 | if err != nil { 256 | return nil, fmt.Errorf("cannot init socks5 proxy client dialer: %w", err) 257 | } 258 | 259 | httpTransport := &http.Transport{} 260 | httpClient := &http.Client{Transport: httpTransport} 261 | dialContext := func(ctx context.Context, network, address string) (net.Conn, error) { 262 | return dialer.Dial(network, address) 263 | } 264 | 265 | httpTransport.DialContext = dialContext 266 | 267 | return httpClient, nil 268 | } 269 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestReadConfig(t *testing.T) { 9 | err := readConfig() 10 | if err != nil { 11 | t.Errorf("Cannot read config file. Error: %v", err) 12 | } 13 | } 14 | 15 | func TestCorrectToken(t *testing.T) { 16 | token := "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" 17 | os.Setenv("TEST_TOKEN", token) 18 | 19 | v, err := getToken("TEST_TOKEN") 20 | if err != nil { 21 | t.Errorf("Incorrect token. Error: %v", err) 22 | } 23 | 24 | if v != token { 25 | t.Errorf("Incorrect token. Expected: %v, Have: %v", token, v) 26 | } 27 | } 28 | 29 | func TestIncorrectToken(t *testing.T) { 30 | token := "a123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" 31 | os.Setenv("TEST_TOKEN", token) 32 | 33 | v, _ := getToken("TEST_TOKEN") 34 | 35 | if v != "" { 36 | t.Errorf(`Case failed. Expected "", Have: %v`, v) 37 | } 38 | } 39 | --------------------------------------------------------------------------------