├── .editorconfig ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── development-question.md │ └── feature_request.md ├── .gitignore ├── .luacheckrc ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── conf └── nginx.conf ├── crowdin-sync.sh ├── crowdin.yml ├── docker-compose.yml ├── docker ├── docker-compose.polling.deploy.yml ├── docker-compose.polling.yml ├── docker-compose.webhooks.yml ├── polling.Dockerfile └── webhooks.Dockerfile ├── healthchecker └── healthchecker.go ├── install.sh ├── launch.sh ├── locales ├── af_ZA.po ├── ar_SA.po ├── be_BY.po ├── ca_ES.po ├── cs_CZ.po ├── da_DK.po ├── de_DE.po ├── el_GR.po ├── en_GB.po ├── en_US.po ├── es_ES.po ├── es_MX.po ├── fa_IR.po ├── fi_FI.po ├── fil_PH.po ├── fr_FR.po ├── he_IL.po ├── hi_IN.po ├── hu_HU.po ├── id_ID.po ├── it_IT.po ├── ja_JP.po ├── km_KH.po ├── ko_KR.po ├── ml_IN.po ├── ms_MY.po ├── nl_NL.po ├── no_NO.po ├── pl_PL.po ├── pt_BR.po ├── pt_PT.po ├── ro_RO.po ├── ru_RU.po ├── si_LK.po ├── sr_SP.po ├── sv_SE.po ├── tr_TR.po ├── uk_UA.po ├── ur_IN.po ├── vi_VN.po ├── zh_CN.po └── zh_TW.po ├── lua ├── groupbutler.lua ├── groupbutler │ ├── api_errors.lua │ ├── chat.lua │ ├── chatmember.lua │ ├── config.lua │ ├── controller.lua │ ├── health.lua │ ├── languages.lua │ ├── logging.lua │ ├── main.lua │ ├── message.lua │ ├── null.lua │ ├── plugins.lua │ ├── plugins │ │ ├── admin.lua │ │ ├── antispam.lua │ │ ├── backup.lua │ │ ├── banhammer.lua │ │ ├── configure.lua │ │ ├── dashboard.lua │ │ ├── defaultpermissions.lua │ │ ├── extra.lua │ │ ├── floodmanager.lua │ │ ├── help.lua │ │ ├── links.lua │ │ ├── logchannel.lua │ │ ├── mediasettings.lua │ │ ├── menu.lua │ │ ├── onmessage.lua │ │ ├── pin.lua │ │ ├── private.lua │ │ ├── private_settings.lua │ │ ├── report.lua │ │ ├── rules.lua │ │ ├── service.lua │ │ ├── setlang.lua │ │ ├── users.lua │ │ ├── warn.lua │ │ └── welcome.lua │ ├── storage │ │ ├── memory.lua │ │ ├── mixed.lua │ │ ├── postgres.lua │ │ ├── redis.lua │ │ └── util.lua │ ├── user.lua │ ├── util.lua │ └── utilities.lua ├── init_nginx.lua └── vendor │ ├── .gitkeep │ ├── ltn12.lua │ ├── multipart-post.lua │ ├── resty │ └── redis.lua │ └── telegram-bot-api │ ├── methods.lua │ └── utilities.lua ├── polling.lua ├── schema ├── 1.sql ├── 2.sql └── 3.sql └── spec ├── api_errors_spec.lua ├── groupbutler_spec.lua └── logging_spec.lua /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = tab 6 | indent_size = 3 7 | end_of_line = lf 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.{yml,yaml}] 12 | indent_size = 2 13 | indent_style = space 14 | 15 | [*.sql] 16 | indent_size = 4 17 | indent_style = space 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: "Help us smash that pesky bug \U0001F41B" 4 | 5 | --- 6 | 7 | 16 | 17 | **Describe the bug** 18 | A clear and concise description of what the bug is. 19 | 20 | **Steps to reproduce** 21 | Steps to reproduce the behavior: 22 | 1. Go to ‘...’ 23 | 2. Click on ‘...’ 24 | 3. Scroll down to ‘...’ 25 | 4. See error 26 | 27 | **Expected behavior** 28 | A clear and concise description of what you expected to happen. 29 | 30 | **Screenshots** 31 | If applicable, add screenshots to help explain your problem. 32 | 33 | **Additional context** 34 | Add any other context about the problem here. 35 | 36 | **Environment** 37 | Which bot are you using? E.g. @GroupButler_bot or @GBReborn_bot 38 | 39 | 40 | 41 | Commit id: (you can get by running `git rev-parse HEAD`) 42 | OS: Ubuntu 18.04/macOS 10.13/Arch... 43 | Docker: No/Image built from commit above/Pulled from docker hub `:tag` (no need to fill the commit id in this case) 44 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/development-question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Development question 3 | about: "Good thing lua doesn’t require semicolons\U0001F468‍\U0001F4BB" 4 | 5 | --- 6 | 7 | 16 | 17 | Tell us what afflicts your poor dev soul... 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: "Suggest an idea for this project \U0001F4A1" 4 | 5 | --- 6 | 7 | 16 | 17 | **Is your feature request related to a problem? Please describe.** 18 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 19 | 20 | **Describe the solution you'd like** 21 | A clear and concise description of what you want to happen. 22 | 23 | **Describe alternatives you've considered** 24 | A clear and concise description of any alternative solutions or features you've considered. 25 | 26 | **Additional context** 27 | Add any other context or screenshots about the feature request here. 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | schema.sql 3 | docker-compose.*.yml 4 | *.sh 5 | *.json 6 | *.tar 7 | luacov.* 8 | 9 | !install.sh 10 | !launch.sh 11 | !crowdin-sync.sh 12 | -------------------------------------------------------------------------------- /.luacheckrc: -------------------------------------------------------------------------------- 1 | std = 'ngx_lua+busted' 2 | globals = {'string'} 3 | max_string_line_length = false 4 | max_comment_line_length = false 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false 3 | 4 | env: 5 | # - LUA="lua=5.1" # Recommended version for polling mode 6 | - LUA="luajit=2.1" # Recommended version for webhooks mode 7 | # - LUA="lua=5.2" 8 | # - LUA="lua=5.3" 9 | # - LUA="luajit 2.0" 10 | 11 | before_install: 12 | - pip install hererocks 13 | - hererocks lua_install -r^ --$LUA 14 | - export PATH=$PATH:$PWD/lua_install/bin # Add directory with all installed binaries to PATH 15 | 16 | install: 17 | - luarocks install luacheck 18 | - luarocks install busted 19 | - luarocks install luacov 20 | - luarocks install luacov-coveralls 21 | - luarocks install telegram-bot-api 22 | - luarocks install lua-resty-socket 23 | 24 | services: 25 | - redis-server 26 | 27 | before_script: redis-cli ping 28 | 29 | script: 30 | - luacheck . --exclude-files lua/vendor lua_install 31 | - busted --coverage --lpath "./lua/?.lua;./lua/?/?.lua;./lua/?/init.lua;./lua/vendor/?.lua;./lua/vendor/?/?.lua" 32 | 33 | after_success: 34 | - luacov-coveralls --exclude lua/vendor --exclude lua_install 35 | 36 | # matrix: 37 | # allow_failures: 38 | # - env: LUA="lua=5.2" LUA="lua=5.3" LUA="luajit 2.0" 39 | 40 | notifications: 41 | email: 42 | on_success: change 43 | on_failure: always 44 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to GroupButler 2 | Contributing to GroupButler isn't limited to just filing bugs, users are more than welcomed to make suggestions, report any issue they may find, and make pull requests to help make GroupButler better. 3 | 4 | ## Working on GroupButler 5 | ### Prerequisites 6 | * [Git](https://git-scm.com/) 7 | 8 | ### Getting GroupButler 9 | 1. Fork a copy of our repo 10 | 2. Open up Git in an environment of your choice 11 | 3. Run the following 12 | 13 | ```bash 14 | $ git clone https://github.com/group-butler/GroupButler.git 15 | $ cd GroupButler 16 | ``` 17 | 18 | ### Please pay attention to 19 | 1. Open an issue describing the feature/bug you wish to contribute first to start a discussion, explain why, what and how 20 | 2. Write tests covering 100% of the library code you produce. Don't send PRs which reduce the coverage status 21 | 3. One PR per feature/fix unless you follow [standard-version](https://github.com/conventional-changelog/standard-version) commit guidelines 22 | 4. Naming convention: PascalCase for Classes, camelCase for methods and snake_case for other stuff 23 | 24 | ### Using branches 25 | When working on any issue on Github, it's a good practice to make branches that are specific to the issue you're currently working on. For instance, if you're working on an issue with a name like "NAME OF ISSUE #1234", from the master branch run the following code: `git checkout -b Issue#1234`. In doing so, you'll be making a branch that specifically identifies the issue at hand, and moves you right into it with the `checkout` flag. This keeps your main (master) repository clean and your personal workflow cruft out of sight when making a pull request. 26 | 27 | ### Finding issues to fix 28 | After you've forked and cloned our repo, you can find issues to work on by heading over to our [issues list](https://github.com/group-butler/GroupButler/issues). We advise looking at the issues with the labels [help wanted](https://github.com/group-butler/GroupButler/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) or [good first issue](https://github.com/group-butler/GroupButler/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22), as they will help you get familiar with the GroupButler code. 29 | 30 | ### Rules of the discussions 31 | Remember to be very clear and transparent when discussing any issue in the discussions boards. We ask that you keep the language to English and keep on track with the issue at hand. Lastly, please be respectful of our fellow contributors and keep an exemplary level of professionalism at all times. 32 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: dev_polling logs 2 | 3 | clean: kill down 4 | # docker system prune -fa 5 | 6 | pot: 7 | find . -name "*.lua" | sort | xgettext --from-code=utf-8 \ 8 | --add-comments=TRANSLATORS \ 9 | --force-po \ 10 | --keyword=i18n \ 11 | --files-from=/dev/stdin \ 12 | --output=/dev/stdout | msgmerge --backup=off --update locales/en_GB.po /dev/stdin 13 | 14 | luacheck: 15 | luacheck . --exclude-files lua/vendor src 16 | 17 | logs: 18 | docker-compose logs -f --tail 20 19 | 20 | kill: 21 | docker-compose kill groupbutler 22 | 23 | down: 24 | docker-compose -f docker-compose.yml -f docker/docker-compose.polling.yml down 25 | 26 | pull: 27 | docker-compose pull 28 | 29 | easy_deploy: pull 30 | docker-compose -f docker-compose.yml -f docker/docker-compose.polling.deploy.yml up -d 31 | 32 | build_polling: 33 | docker-compose -f docker-compose.yml -f docker/docker-compose.polling.yml build 34 | 35 | build_webhooks: 36 | docker-compose -f docker-compose.yml -f docker/docker-compose.webhooks.yml build 37 | 38 | dev_polling: kill build_polling 39 | docker-compose -f docker-compose.yml -f docker/docker-compose.polling.yml up 40 | 41 | dev_webhooks: kill build_webhooks 42 | docker-compose -f docker-compose.yml -f docker/docker-compose.webhooks.yml up 43 | 44 | dump_pg: 45 | rm -f schema.sql && docker-compose exec postgres pg_dump -U postgres groupbutler --schema-only > schema.sql 46 | 47 | restore_pg: 48 | docker-compose exec postgres dropdb -U postgres groupbutler 49 | docker-compose exec postgres createdb -U postgres groupbutler 50 | docker-compose exec postgres pg_restore -U postgres -C -d groupbutler < schema.sql 51 | -------------------------------------------------------------------------------- /conf/nginx.conf: -------------------------------------------------------------------------------- 1 | env GB_OLD_UPDATE; 2 | env CHANNEL; 3 | env DEFAULT_LANG; 4 | env HELP_GROUP; 5 | env LOG_CHAT; 6 | env LOG_ADMIN; 7 | env LOG_STATS; 8 | env MULTIPURPOSE_PLUGINS; 9 | env POSTGRES_HOST; 10 | env POSTGRES_PORT; 11 | env POSTGRES_USER; 12 | env POSTGRES_PASSWORD; 13 | env POSTGRES_DB; 14 | env REDIS_HOST; 15 | env REDIS_PORT; 16 | env REDIS_DB; 17 | env SOURCE; 18 | env SUPERADMINS; 19 | env TG_TOKEN; 20 | env TG_UPDATES; 21 | env TG_POLLING_LIMIT; 22 | env TG_POLLING_TIMEOUT; 23 | env TG_WEBHOOK_URL; 24 | env TG_WEBHOOK_DOMAIN; 25 | env TG_WEBHOOK_CERT; 26 | env TG_WEBHOOK_MAX_CON; 27 | 28 | error_log /dev/stderr notice; 29 | 30 | master_process on; 31 | worker_processes auto; 32 | worker_cpu_affinity auto; 33 | 34 | events { 35 | worker_connections 10240; 36 | } 37 | 38 | http { 39 | client_body_buffer_size 1M; 40 | client_max_body_size 1M; 41 | log_format combined_no_query '$remote_addr ' '"$request_method $uri" $status $body_bytes_sent '; 42 | lua_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt; 43 | lua_ssl_verify_depth 2; 44 | lua_package_path "$prefix/lua/?.lua;$prefix/lua/vendor/?.lua;;"; 45 | lua_max_running_timers 10240; 46 | # lua_socket_log_errors off; 47 | # lua_code_cache off; # only during development 48 | resolver 127.0.0.11 ipv6=off; # use docker local resolver and disable IPv6 49 | server { 50 | access_log /dev/stdout combined_no_query; 51 | listen 80; 52 | charset utf-8; 53 | charset_types application/json; 54 | default_type application/json; 55 | access_by_lua_block { 56 | local config = require "groupbutler.config" 57 | if (not config.telegram.webhook.domain) -- Using custom URL. Don't check for the token 58 | or (ngx.var.arg_token and (ngx.var.arg_token == config.telegram.token)) then -- Token check enabled and token is valid 59 | return 60 | end 61 | ngx.exit(ngx.HTTP_FORBIDDEN) 62 | } 63 | location / { 64 | content_by_lua_block { 65 | require "groupbutler".go() 66 | } 67 | } 68 | location /set_webhook { 69 | content_by_lua_block { 70 | require "init_nginx".set_webhook() 71 | } 72 | } 73 | } 74 | server { 75 | listen 8000; 76 | access_log /dev/null; 77 | location /health { 78 | content_by_lua_block { 79 | require "groupbutler".health() 80 | } 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /crowdin-sync.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | source .env && export $(cut -d= -f1 .env) 4 | 5 | crowdin upload sources 6 | crowdin download 7 | -------------------------------------------------------------------------------- /crowdin.yml: -------------------------------------------------------------------------------- 1 | project_identifier: group-butler 2 | api_key_env: CROWDIN_API_KEY 3 | preserve_hierarchy: true 4 | 5 | files: 6 | - source: /locales/en_GB.po 7 | translation: /locales/%locale_with_underscore%.po 8 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.6' 2 | 3 | services: 4 | groupbutler: 5 | environment: 6 | - GB_ADMIN_MODE 7 | - GB_CACHE_ADMIN 8 | - GB_OLD_UPDATE 9 | - GB_CHANNEL 10 | - GB_SOURCE 11 | - DEFAULT_LANG 12 | - HELP_GROUP 13 | - LOG_CHAT 14 | - LOG_ADMIN 15 | - LOG_STATS 16 | - MULTIPURPOSE_PLUGINS 17 | - POSTGRES_HOST 18 | - POSTGRES_PORT 19 | - POSTGRES_USER 20 | - POSTGRES_PASSWORD 21 | - POSTGRES_DB 22 | - REDIS_HOST 23 | - REDIS_PORT 24 | - REDIS_DB 25 | - SUPERADMINS 26 | - TG_TOKEN 27 | - TG_UPDATES 28 | - TG_POLLING_LIMIT 29 | - TG_POLLING_TIMEOUT 30 | - TG_WEBHOOK_URL 31 | - TG_WEBHOOK_DOMAIN 32 | - TG_WEBHOOK_CERT 33 | - TG_WEBHOOK_MAX_CON 34 | image: ${IMAGE:-groupbutler/groupbutler}:${TAG:-latest} 35 | -------------------------------------------------------------------------------- /docker/docker-compose.polling.deploy.yml: -------------------------------------------------------------------------------- 1 | version: '3.6' 2 | 3 | services: 4 | groupbutler: 5 | environment: 6 | REDIS_HOST: redis 7 | restart: always 8 | 9 | redis: 10 | image: redis:alpine 11 | restart: always 12 | # Optional. Map redis port to the host if you want to explore the db 13 | # ports: 14 | # - "127.0.0.1:6379:6379" 15 | volumes: 16 | - redis:/data 17 | 18 | volumes: 19 | redis: 20 | -------------------------------------------------------------------------------- /docker/docker-compose.polling.yml: -------------------------------------------------------------------------------- 1 | version: '3.6' 2 | 3 | services: 4 | groupbutler: 5 | build: 6 | context: . 7 | dockerfile: docker/polling.Dockerfile 8 | args: 9 | # DEPS_NATIVE: $DEPS_NATIVE 10 | # DEPS_ROCKS: $DEPS_ROCKS 11 | GB_COMMIT: $GB_COMMIT 12 | environment: 13 | REDIS_HOST: redis 14 | POSTGRES_HOST: postgres 15 | POSTGRES_USER: groupbutler 16 | POSTGRES_PASSWORD: password 17 | volumes: 18 | - ./conf:/srv/app/conf 19 | - ./locales:/srv/app/locales 20 | - ./lua:/srv/app/lua 21 | - ./polling.lua:/srv/app/polling.lua 22 | 23 | redis: 24 | command: --save "" --appendonly no 25 | image: redis:alpine 26 | ports: 27 | - "6379:6379" 28 | 29 | postgres: 30 | image: postgres:alpine 31 | environment: 32 | POSTGRES_PASSWORD: password 33 | ports: 34 | - "5432:5432" 35 | volumes: 36 | - ./schema:/docker-entrypoint-initdb.d 37 | - postgres_data:/var/lib/postgresql/data 38 | 39 | volumes: 40 | postgres_data: 41 | -------------------------------------------------------------------------------- /docker/docker-compose.webhooks.yml: -------------------------------------------------------------------------------- 1 | version: '3.6' 2 | 3 | services: 4 | groupbutler: 5 | build: 6 | context: . 7 | dockerfile: docker/webhooks.Dockerfile 8 | args: 9 | # DEPS_NATIVE: $DEPS_NATIVE 10 | # DEPS_OPM: $DEPS_OPM 11 | GB_COMMIT: $GB_COMMIT 12 | environment: 13 | REDIS_HOST: redis 14 | POSTGRES_HOST: postgres 15 | POSTGRES_USER: groupbutler 16 | POSTGRES_PASSWORD: password 17 | volumes: 18 | - ./conf:/usr/local/openresty/nginx/conf 19 | - ./locales:/usr/local/openresty/nginx/locales 20 | - ./lua:/usr/local/openresty/nginx/lua 21 | ports: 22 | - "80:80" 23 | 24 | redis: 25 | command: --save "" --appendonly no 26 | image: redis:alpine 27 | ports: 28 | - "6379:6379" 29 | 30 | postgres: 31 | image: postgres:alpine 32 | environment: 33 | POSTGRES_USER: groupbutler 34 | POSTGRES_PASSWORD: password 35 | ports: 36 | - "5432:5432" 37 | volumes: 38 | - ./schema:/docker-entrypoint-initdb.d 39 | - postgres_data:/var/lib/postgresql/data 40 | 41 | volumes: 42 | postgres_data: 43 | -------------------------------------------------------------------------------- /docker/polling.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM yangm97/lua:5.1-alpine 2 | 3 | WORKDIR /srv/app 4 | 5 | CMD ["polling.lua"] 6 | 7 | RUN apk add --no-cache outils-md5 8 | 9 | ARG DEPS_ROCKS="telegram-bot-api pgmoon lua-resty-socket" 10 | 11 | RUN for ROCK in $DEPS_ROCKS; do luarocks install $ROCK; done 12 | 13 | COPY locales locales 14 | COPY lua lua 15 | COPY polling.lua . 16 | 17 | ARG GB_COMMIT 18 | ENV GB_COMMIT=$GB_COMMIT 19 | -------------------------------------------------------------------------------- /docker/webhooks.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine as healthchecker-builder 2 | COPY healthchecker /go/src/healthchecker 3 | RUN cd bin && go build healthchecker 4 | 5 | FROM openresty/openresty:alpine-fat 6 | EXPOSE 80 7 | WORKDIR /usr/local/openresty/nginx 8 | 9 | HEALTHCHECK --interval=3s --timeout=3s CMD ["healthchecker"] || exit 1 10 | 11 | ARG DEPS_OPM="yangm97/lua-telegram-bot-api leafo/pgmoon" 12 | RUN opm install $DEPS_OPM 13 | 14 | COPY --from=healthchecker-builder /go/bin/healthchecker /usr/local/bin 15 | COPY conf conf 16 | COPY locales locales 17 | COPY lua lua 18 | 19 | ARG GB_COMMIT 20 | ENV GB_COMMIT=$GB_COMMIT 21 | -------------------------------------------------------------------------------- /healthchecker/healthchecker.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | ) 7 | 8 | func main() { 9 | resp, err := http.Get("http://localhost:8000/health") 10 | 11 | if err != nil || resp.StatusCode != 200 { 12 | os.Exit(1) 13 | } 14 | os.Exit(0) 15 | } 16 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Lua Version 4 | LUA=5.1 5 | 6 | # Dependencies and libraries 7 | NATIVE="lua$LUA liblua$LUA-dev luarocks make redis-server" 8 | ROCKS="telegram-bot-api lua-resty-socket" 9 | 10 | # Color variables 11 | Red='\033[0;31m' 12 | Green='\033[0;32m' 13 | Orange='\033[0;33m' 14 | Blue='\033[0;34m' 15 | Purple='\033[0;35m' 16 | Cyan='\033[0;36m' 17 | BRed='\033[1;31m' 18 | BGreen='\033[1;32m' 19 | BOrange='\033[1;33m' 20 | BBlue='\033[1;34m' 21 | BPurple='\033[1;35m' 22 | BCyan='\033[1;36m' 23 | Default='\033[0m' 24 | 25 | read -p "Do you want me to install Group Butler Bot? (Y/N): " 26 | 27 | case $REPLY in [yY]) 28 | # Install Dependencies 29 | echo -en "${Blue}The packages will be installed:${Default} ${NATIVE}\n${Cyan}Do you want to install the dependencies (Y/N): ${Default}" 30 | read REPLY 31 | if [[ $REPLY == [yY] ]]; then 32 | sudo apt-get install $NATIVE -y 33 | fi 34 | 35 | # Install Luarocks 36 | echo -en "${Cyan}Do you want to download and install luarocks (Y/N): ${Default}" 37 | read REPLY 38 | if [[ $REPLY == [yY] ]]; then 39 | git clone http://github.com/keplerproject/luarocks 40 | cd luarocks 41 | ./configure --lua-version=$LUA 42 | make build 43 | sudo make install 44 | cd .. 45 | rm -rf luarocks* 46 | fi 47 | 48 | echo -en "${Cyan}Do you want to download the luarocks libraries (Y/N): ${Default}" 49 | read REPLY 50 | if [[ $REPLY == [yY] ]]; then 51 | for ROCK in $ROCKS; do 52 | sudo luarocks install $ROCK 53 | done 54 | fi 55 | 56 | if [ ! -d .git ]; then 57 | echo -en "${Green}Would you like to clone the source of GroupButler? (Y/N): ${Default}" 58 | read REPLY 59 | if [[ $REPLY == [yY] ]]; then 60 | echo -en "${Orange}Fetching latest Group Butler source code\n${Default}" 61 | git clone -b master https://github.com/group-butler/GroupButler.git && cd GroupButler 62 | fi 63 | fi 64 | 65 | if [ -d .git ]; then 66 | echo -en "${Green}Do you want to use the beta branch (If you modified something we will do a stash)? (Y/N): ${Default}" 67 | read REPLY 68 | if [[ $REPLY == [yY] ]]; then 69 | git stash 70 | git checkout beta 71 | fi 72 | fi; 73 | 74 | echo -en "${BGreen}Group Butler successfully installed! Change values in config file and run ${BRed}./launch.sh${BGreen}.${Default}";; 75 | *) echo "Exiting...";; 76 | esac 77 | -------------------------------------------------------------------------------- /launch.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | sed -i -E 's/\r$//' .env # Converting CRLF (\r\n) to LF (\n) 4 | source .env && export $(cut -d= -f1 .env) 5 | while true; do 6 | ./polling.lua 7 | sleep 10 8 | done 9 | -------------------------------------------------------------------------------- /lua/groupbutler.lua: -------------------------------------------------------------------------------- 1 | local controller = require "groupbutler.controller" 2 | local health = require "groupbutler.health" 3 | 4 | local _M = {} 5 | 6 | function _M.go() 7 | controller.run() 8 | end 9 | 10 | function _M.health() 11 | health.run() 12 | end 13 | 14 | function _M.test(update) 15 | controller.mock(update) 16 | end 17 | 18 | return _M 19 | -------------------------------------------------------------------------------- /lua/groupbutler/api_errors.lua: -------------------------------------------------------------------------------- 1 | local Util = require("groupbutler.util") 2 | 3 | local _M = {} 4 | 5 | function _M:new(update_obj) 6 | local obj = { 7 | i18n = update_obj.i18n, 8 | } 9 | setmetatable(obj, {__index = self}) 10 | return obj 11 | end 12 | 13 | local function replies(self) 14 | local i18n = self.i18n 15 | local replies_t = { 16 | unknown_error = i18n("An unknown error has ocurred"), 17 | not_enough_permissions = i18n("I don't have enough permissions to restrict users"), 18 | not_admin = i18n("I'm not an admin, I can't kick people"), 19 | cant_restrict_admins = i18n("I can't do that to admins!"), 20 | cant_unban_on_normal_groups = i18n("There is no need to unban in a normal group"), 21 | user_not_found = i18n("This user is not a chat member"), 22 | empty_message = i18n("Please input a text"), 23 | long_message = i18n("This message is too long. Max lenght allowed by Telegram: 4000 characters"), 24 | bad_inline_button_url = i18n("One of the inline buttons you are trying to set is missing the URL"), 25 | bad_hyperlink = i18n("Inline link formatted incorrectly. Check the text between brackets -> \\[]()\n%s"):format(i18n("More info [here](https://telegram.me/GB_tutorials/12)")), -- luacheck: ignore 631 26 | bad_markdown = i18n([[This text breaks the markdown. 27 | More info about a proper use of markdown [here](https://telegram.me/GB_tutorials/10) and [here](https://telegram.me/GB_tutorials/12).]]), -- luacheck: ignore 631 28 | button_url_invalid = i18n("One of the URLs that should be placed in an inline button seems to be invalid (not an URL). Please check it"), -- luacheck: ignore 631 29 | bad_inline_button_name = i18n("One of the inline buttons you are trying to set doesn't have a name"), 30 | } Util.setDefaultTableValue(replies_t, replies_t.unknown_error) 31 | return replies_t 32 | end 33 | 34 | local function errors(self) 35 | return { 36 | ["not enough rights to kick/unban chat member"] = replies(self).not_admin, -- SUPERGROUP: bot is not admin 37 | ["user_admin_invalid"] = replies(self).cant_restrict_admins, -- SUPERGROUP: trying to kick an admin 38 | ["method is available for supergroup chats only"] = replies(self).cant_unban_on_normal_groups, -- NORMAL: trying to unban 39 | ["only creator of the group can kick administrators from the group"] = replies(self).cant_restrict_admins, -- NORMAL: trying to kick an admin 40 | ["need to be inviter of the user to kick it from the group"] = replies(self).not_admin, -- NORMAL: bot is not an admin or everyone is an admin 41 | ["user_not_participant"] = replies(self).user_not_found, -- NORMAL: trying to kick a user that is not in the group 42 | ["chat_admin_required"] = replies(self).not_admin, -- NORMAL: bot is not an admin or everyone is an admin 43 | -- ["there is no administrators in the private chat"] = replies(self).unknown_error, -- something asked in a private chat with the api methods 2.1 44 | ["wrong url host"] = replies(self).bad_hyperlink, -- hyperlink not valid 45 | -- ["peer_id_invalid"] = replies(self).unknown_error, -- user never started the bot 46 | -- ["message is not modified"] = replies(self).unknown_error, -- the edit message method hasn't modified the message 47 | ["can't parse entities in message text: can't find end of the entity starting at byte offset %d+"] = replies(self).bad_markdown, -- the markdown is wrong and breaks the delivery 48 | ["can't parse entities: can't find end of the entity starting at byte offset %d+"] = replies(self).bad_markdown, -- newer wording for the same error as above 49 | -- ["group chat is migrated to a supergroup chat"] = replies(self).unknown_error, -- group updated to supergroup 50 | -- ["message can't be forwarded"] = replies(self).unknown_error, -- unknown 51 | ["message text is empty"] = replies(self).empty_message, -- empty message 52 | -- ["message not found"] = replies(self).unknown_error, -- message id invalid, I guess 53 | -- ["chat not found"] = replies(self).unknown_error, -- I don't know 54 | ["message is too long"] = replies(self).long_message, -- over 4096 char 55 | ["user not found"] = replies(self).user_not_found, -- unknown user_id 56 | -- ["can't parse reply keyboard markup json object"] = replies(self).unknown_error, -- keyboard table invalid 57 | -- ["field \"inline_keyboard\" of the inlinekeyboardmarkup should be an array of arrays"] = replies(self).unknown_error, -- inline keyboard is not an array of array 58 | -- ["can't parse inline keyboard button: inlinekeyboardbutton should be an object"] = replies(self).unknown_error, 59 | -- ["object expected as reply markup"] = replies(self).unknown_error, -- empty inline keyboard table 60 | -- ["query_id_invalid"] = replies(self).unknown_error, -- callback query id invalid 61 | -- ["channel_private"] = replies(self).unknown_error, -- I don't know 62 | -- ["message_too_long"] = replies(self).unknown_error, -- text of an inline callback answer is too long 63 | -- ["wrong user_id specified"] = replies(self).unknown_error, -- invalid user_id 64 | -- ["too big total timeout [%d%.]+"] = replies(self).unknown_error, --something about spam an inline keyboards 65 | -- ["button_data_invalid"] = replies(self).unknown_error, -- callback_data string invalid 66 | -- ["type of file to send mismatch"] = replies(self).unknown_error, -- trying to send a media with the wrong method 67 | -- ["message_id_invalid"] = replies(self).unknown_error, -- I don't know. Probably passing a string as message id 68 | -- ["can't parse inline keyboard button: can't find field \"text\""] = replies(self).unknown_error, -- the text of a button could be nil 69 | -- ["can't parse inline keyboard button: field \"text\" must be of type String"] = replies(self).unknown_error, 70 | ["user_id_invalid"] = replies(self).user_not_found, 71 | -- ["chat_invalid"] = replies(self).unknown_error, 72 | ["user_deactivated"] = replies(self).user_not_found, -- deleted account, probably 73 | ["can't parse inline keyboard button: text buttons are unallowed in the inline keyboard"] = replies(self).bad_inline_button_url, -- luacheck: ignore 631 74 | -- ["message was not forwarded"] = replies(self).unknown_error, 75 | -- ["can't parse inline keyboard button: field \"text\" must be of type string"] = replies(self).unknown_error, -- "text" field in a button object is not a string 76 | -- ["channel invalid"] = replies(self).unknown_error, -- /shrug 77 | ["wrong message entity: unsupproted url protocol"] = replies(self).bad_hyperlink, -- username in an inline link [word](@username) (only?) 78 | ["wrong message entity: url host is empty"] = replies(self).bad_hyperlink, -- inline link without link [word]() 79 | -- ["there is no photo in the request"] = replies(self).unknown_error, 80 | -- ["can't parse message text: unsupported start tag \"%w+\" at byte offset %d+"] = replies(self).unknown_error, 81 | -- ["can't parse message text: expected end tag at byte offset %d+"] = replies(self).unknown_error, 82 | ["button_url_invalid"] = replies(self).button_url_invalid, -- invalid url (inline buttons) 83 | -- ["message must be non%-empty"] = replies(self).unknown_error, --example: ``` ``` 84 | -- ["can\'t parse message text: unmatched end tag at byte offset"] = replies(self).unknown_error, 85 | ["reply_markup_invalid"] = replies(self).bad_inline_button_name, -- returned while trying to send an url button without text and with an invalid url 86 | -- ["message text must be encoded in utf%-8"] = replies(self).unknown_error, 87 | -- ["url host is empty"] = replies(self).unknown_error, 88 | -- ["requested data is unaccessible"] = replies(self).unknown_error, -- the request involves a private channel and the bot is not admin there 89 | -- ["unsupported url protocol"] = replies(self).unknown_error, 90 | -- ["can't parse message text: unexpected end tag at byte offset %d+"] = replies(self).unknown_error, 91 | -- ["message to edit not found"] = replies(self).unknown_error, 92 | -- ["group chat was migrated to a supergroup chat"] = replies(self).unknown_error, 93 | -- ["message to forward not found"] = replies(self).unknown_error, 94 | ["user is an administrator of the chat"] = replies(self).cant_restrict_admins, 95 | ["not enough rights to restrict/unrestrict chat member"] = replies(self).not_enough_permissions, 96 | -- ["have no rights to send a message"] = replies(self).unknown_error, 97 | -- ["user_is_bot"] = replies(self).unknown_error, 98 | -- ["bot was blocked by the user"] = replies(self).unknown_error, -- user blocked the bot 99 | -- ["too many requests: retry later"] = replies(self).unknown_error, -- the bot is hitting api limits 100 | -- ["too big total timeout"] = replies(self).unknown_error, -- too many callback_data requests 101 | } 102 | end 103 | 104 | function _M:trans(err) -- Translate API errors to text 105 | if not err or not err.description then 106 | return replies(self).unknown_error 107 | end 108 | 109 | err = err.description:lower() 110 | for k,v in pairs(errors(self)) do 111 | if err:match(k) then 112 | return v 113 | end 114 | end 115 | 116 | return replies(self).unknown_error 117 | end 118 | 119 | return _M 120 | -------------------------------------------------------------------------------- /lua/groupbutler/chat.lua: -------------------------------------------------------------------------------- 1 | local log = require("groupbutler.logging") 2 | local StorageUtil = require("groupbutler.storage.util") 3 | 4 | local Chat = {} 5 | 6 | local function p(self) 7 | return getmetatable(self).__private 8 | end 9 | 10 | function Chat:new(obj, private) 11 | assert(obj.id, "Chat: Missing obj.id") 12 | assert(private.api, "Chat: Missing private.api") 13 | assert(private.db, "Chat: Missing private.db") 14 | setmetatable(obj, { 15 | __index = function(s, index) 16 | if self[index] then 17 | return self[index] 18 | end 19 | return s:getProperty(index) 20 | end, 21 | __private = private, 22 | }) 23 | return obj 24 | end 25 | 26 | function Chat:getProperty(index) 27 | if not StorageUtil.isChatProperty[index] then 28 | log.warn("Tried to get invalid chat property {property}", {property=index}) 29 | return nil 30 | end 31 | local property = rawget(self, index) 32 | if property == nil then 33 | property = p(self).db:getChatProperty(self, index) 34 | if property == nil then 35 | local ok = p(self).api:getChat(self.id) 36 | if not ok then 37 | log.warn("Chat: Failed to get {property} for {id}", { 38 | property = index, 39 | id = self.id, 40 | }) 41 | return nil 42 | end 43 | for k,v in pairs(ok) do 44 | self[k] = v 45 | end 46 | self:cache() 47 | property = rawget(self, index) 48 | end 49 | self[index] = property 50 | end 51 | return property 52 | end 53 | 54 | function Chat:cache() 55 | p(self).db:cacheChat(self) 56 | end 57 | 58 | return Chat 59 | -------------------------------------------------------------------------------- /lua/groupbutler/chatmember.lua: -------------------------------------------------------------------------------- 1 | local log = require("groupbutler.logging") 2 | local User = require("groupbutler.user") 3 | local StorageUtil = require("groupbutler.storage.util") 4 | 5 | local ChatMember = {} 6 | 7 | local function p(self) 8 | return getmetatable(self).__private 9 | end 10 | 11 | function ChatMember:new(obj, private) 12 | assert(obj.chat, "ChatMember: Missing obj.chat") 13 | assert(obj.user, "ChatMember: Missing obj.user") 14 | assert(private.db, "ChatMember: Missing private.db") 15 | assert(private.api, "ChatMember: Missing private.api") 16 | setmetatable(obj, { 17 | __index = function(s, index) 18 | if self[index] then 19 | return self[index] 20 | end 21 | return s:getProperty(index) 22 | end, 23 | __private = private, 24 | }) 25 | return obj 26 | end 27 | 28 | function ChatMember:getProperty(index) 29 | if not StorageUtil.isChatMemberProperty[index] then 30 | log.warn("Tried to get invalid chatmember property {property}", {property=index}) 31 | return nil 32 | end 33 | local property = rawget(self, index) 34 | if property == nil then 35 | property = p(self).db:getChatMemberProperty(self, index) 36 | if property == nil then 37 | local ok = p(self).api:getChatMember(self.chat.id, self.user.id) 38 | if not ok then 39 | log.warn("ChatMember: Failed to get {property} for {chat_id}, {user_id}", { 40 | property = index, 41 | chat_id = self.chat.id, 42 | user_id = self.user.id, 43 | }) 44 | return nil 45 | end 46 | for k,v in pairs(ok) do 47 | self[k] = v 48 | if k == "user" then 49 | User:new(self.user, p(self)) 50 | end 51 | end 52 | self:cache() 53 | property = rawget(self, index) 54 | end 55 | self[index] = property 56 | end 57 | return property 58 | end 59 | 60 | function ChatMember:cache() 61 | p(self).db:cacheChatMember(self) 62 | end 63 | 64 | function ChatMember:isAdmin() 65 | if self.chat.type == "private" then -- This should never happen but... 66 | return false 67 | end 68 | return self.status == "creator" or self.status == "administrator" 69 | end 70 | 71 | function ChatMember:can(permission) 72 | if self.chat.type == "private" then -- This should never happen but... 73 | return false 74 | end 75 | if self.status == "creator" 76 | or (self.status == "administrator" and self[permission]) then 77 | return true 78 | end 79 | return false 80 | end 81 | 82 | function ChatMember:ban(until_date) 83 | local ok, err = p(self).api:kickChatMember(self.chat.id, self.user.id, until_date) 84 | if not ok then 85 | return nil, p(self).api_err:trans(err) 86 | end 87 | return ok 88 | end 89 | 90 | function ChatMember:kick() 91 | local ok, err = p(self).api:kickChatMember(self.chat.id, self.user.id) 92 | if not ok then 93 | return nil, p(self).api_err:trans(err) 94 | end 95 | p(self).api:unbanChatMember(self.chat.id, self.user.id) 96 | return ok 97 | end 98 | 99 | function ChatMember:mute(until_date) 100 | local ok, err = p(self).api:restrictChatMember({ 101 | chat_id = self.chat.id, 102 | user_id = self.user.id, 103 | until_date = until_date, 104 | can_send_messages = false, 105 | }) 106 | if not ok then 107 | return nil, p(self).api_err:trans(err) 108 | end 109 | return ok 110 | end 111 | 112 | return ChatMember 113 | -------------------------------------------------------------------------------- /lua/groupbutler/config.lua: -------------------------------------------------------------------------------- 1 | -- Editing this file directly is now highly disencouraged. You should instead use environment variables. This new method is a WIP, so if you need to change something which doesn't have a env var, you are encouraged to open an issue or a PR 2 | local json = require 'cjson' 3 | local open = io.open 4 | 5 | local function read_secret(path) 6 | local file = open('/run/secrets/'..path, "rb") 7 | if not file then return nil end 8 | local content = file:read "*a" 9 | file:close() 10 | return content:gsub("%s+", "") 11 | end 12 | 13 | local _M = 14 | { 15 | -- Getting updates 16 | telegram = 17 | { 18 | token = assert(read_secret('telegram/token') or os.getenv('TG_TOKEN'), 19 | 'You must export $TG_TOKEN with your Telegram Bot API token'):gsub("%s+", ""), 20 | allowed_updates = os.getenv('TG_UPDATES') or {'message', 'edited_message', 'callback_query'}, 21 | polling = 22 | { 23 | limit = os.getenv('TG_POLLING_LIMIT'), -- Not implemented 24 | timeout = os.getenv('TG_POLLING_TIMEOUT') -- Not implemented 25 | }, 26 | webhook = 27 | { 28 | domain = os.getenv('TG_WEBHOOK_DOMAIN'), -- Express setup, checks token to increase security 29 | url = os.getenv('TG_WEBHOOK_URL'), -- Manual setup 30 | certificate = read_secret('telegram/webhook/certificate') or os.getenv('TG_WEBHOOK_CERT'), 31 | max_connections = os.getenv('TG_WEBHOOK_MAX_CON') 32 | } 33 | }, 34 | 35 | -- Data 36 | storage = os.getenv("GB_STORAGE") or "mixed", 37 | postgres = { 38 | host = os.getenv('POSTGRES_HOST') or 'localhost', 39 | port = os.getenv('POSTGRES_PORT') or 5432, 40 | user = os.getenv('POSTGRES_USER') or 'postgres', 41 | password = read_secret('postgres/password') or os.getenv('POSTGRES_PASSWORD') or 'postgres', 42 | database = os.getenv('POSTGRES_DB') or 'groupbutler', 43 | }, 44 | redis = 45 | { 46 | host = os.getenv('REDIS_HOST') or 'localhost', 47 | port = os.getenv('REDIS_PORT') or 6379, 48 | db = os.getenv('REDIS_DB') or 0 49 | }, 50 | 51 | -- Aesthetic 52 | lang = os.getenv('DEFAULT_LANG') or 'en_GB', 53 | commit = os.getenv("GB_COMMIT"), 54 | channel = os.getenv("GB_CHANNEL") or '@GroupButler_ch', 55 | source_code = os.getenv("GB_SOURCE") or 'https://github.com/group-butler/GroupButler', 56 | help_group = os.getenv('HELP_GROUP') or 'telegram.me/GBgroups', 57 | 58 | -- Core 59 | log = 60 | { 61 | stats = os.getenv('LOG_STATS') 62 | }, 63 | superadmins = assert(json.decode(os.getenv('SUPERADMINS')), 64 | 'You must export $SUPERADMINS with a JSON array containing at least your Telegram ID'), 65 | cmd = '^[/!#]', 66 | bot_settings = { 67 | old_update = tonumber(os.getenv("GB_OLD_UPDATE")) or 7, -- Age in seconds for updates to be skipped 68 | cache_time = { 69 | -- Admin cache expiration is temporary disabled 70 | adminlist = tonumber(os.getenv("GB_CACHE_ADMIN")) or 18000, -- 5 hours (18000s) Admin Cache time, in seconds. 71 | alert_help = 72, -- amount of hours for cache help alerts 72 | chat_titles = 18000 73 | }, 74 | report = { 75 | duration = 1200, 76 | times_allowed = 2 77 | }, 78 | notify_bug = false, -- notify if a bug occurs! 79 | log_api_errors = true, -- log errors, which happening whilst interacting with the Bot API. 80 | stream_commands = true, 81 | admin_mode = os.getenv('GB_ADMIN_MODE') == 'true' or false 82 | }, 83 | plugins = { 84 | 'onmessage', --THIS MUST BE THE FIRST: IF a user IS FLOODING/IS BLOCKED, THE BOT WON'T GO THROUGH PLUGINS 85 | 'antispam', --SAME OF onmessage.lua 86 | 'backup', 87 | 'banhammer', 88 | 'configure', 89 | 'defaultpermissions', 90 | 'dashboard', 91 | 'floodmanager', 92 | 'help', 93 | 'links', 94 | 'logchannel', 95 | 'mediasettings', 96 | 'menu', 97 | 'pin', 98 | 'private', 99 | 'private_settings', 100 | 'report', 101 | 'rules', 102 | 'service', 103 | 'setlang', 104 | 'users', 105 | 'warn', 106 | 'welcome', 107 | 'admin', 108 | 'extra', --must be the last plugin in the list. 109 | }, 110 | available_languages = { -- Sorted alphabetically 111 | ['en_GB'] = 'English, United Kingdom 🇬🇧', 112 | ['en_US'] = 'English, United States 🇺🇸', 113 | -- ['af_ZA'] = 'Afrikaans 🇿🇦', 114 | -- ['ar_SA'] = 'Arabic 🇸🇩', 115 | -- ['be_BY'] = 'Belarusian 🇧🇾', 116 | -- ['ca_ES'] = 'Catalan', -- Missing emoji flag as of 16/07/2018 117 | ['zh_CN'] = 'Chinese Simplified 🇨🇳', 118 | ['zh_TW'] = 'Chinese Traditional 🇹🇼', 119 | -- ['cs_CZ'] = 'Czech 🇨🇿', 120 | -- ['da_DK'] = 'Danish 🇩🇰', 121 | -- ['nl_NL'] = 'Dutch 🇱🇺', 122 | -- ['fil_PH'] = 'Filipino 🇵🇭', 123 | -- ['fi_FI'] = 'Finnish 🇫🇮', 124 | -- ['fr_FR'] = 'French 🇫🇷', 125 | ['de_DE'] = 'German 🇩🇪', 126 | -- ['el_GR'] = 'Greek 🇬🇷', 127 | ['he_IL'] = 'Hebrew 🇮🇱', 128 | -- ['hi_IN'] = 'Hindi 🇮🇳', 129 | -- ['hu_HU'] = 'Hungarian 🇭🇺', 130 | ['id_ID'] = 'Indonesian 🇮🇩', 131 | ['it_IT'] = 'Italian 🇮🇹', 132 | -- ['ja_JP'] = 'Japanese 🇯🇵', 133 | -- ['km_KH'] = 'Khmer 🇰🇭', 134 | -- ['ko_KR'] = 'Korean 🇰🇷', 135 | -- ['ms_MY'] = 'Malay 🇲🇾', 136 | -- ['ml_IN'] = 'Malayalam 🇮🇳', 137 | -- ['no_NO'] = 'Norwegian 🇳🇴', 138 | -- ['fa_IR'] = 'Persian 🇮🇷', 139 | -- ['pl_PL'] = 'Polish 🇵🇱', 140 | ['pt_PT'] = 'Portuguese 🇵🇹', 141 | ['pt_BR'] = 'Portuguese, Brazilian 🇧🇷', 142 | ['ro_RO'] = 'Romanian 🇷🇴', 143 | ['ru_RU'] = 'Russian 🇷🇺', 144 | -- ['sr_SP'] = 'Serbian (Cyrillic) 🇷🇸', 145 | -- ['si_LK'] = 'Sinhala 🇱🇰', 146 | ['es_ES'] = 'Spanish 🇪🇸', 147 | ['es_MX'] = 'Spanish, Mexico 🇲🇽', 148 | -- ['sv_SE'] = 'Swedish 🇸🇪', 149 | -- ['tr_TR'] = 'Turkish 🇹🇷', 150 | ['uk_UA'] = 'Ukrainian 🇺🇦', 151 | ['ur_IN'] = 'Urdu (India) 🇮🇳', 152 | -- ['vi_VN'] = 'Vietnamese 🇻🇳', 153 | -- Languages become available once they reach 20%+ APPROVAL on https://crowdin.com/project/group-butler 154 | -- Ask on https://t.me/gbtranslators to become a proofreader and be able to approve strings 155 | }, 156 | allow_fuzzy_translations = false, 157 | chat_settings = { 158 | ['settings'] = { 159 | ['Welcome'] = 'off', 160 | ['Extra'] = 'on', 161 | --['Flood'] = 'off', 162 | ['Silent'] = 'off', 163 | ['Rules'] = 'off', 164 | ['Reports'] = 'off', 165 | ['Welbut'] = 'off', -- "read the rules" button under the welcome message 166 | ['Weldelchain'] = 'off', -- delete the previously sent welcome message when a new welcome message is sent 167 | ['Antibot'] = 'off', 168 | ['Clean_service_msg'] = 'off', 169 | Goodbye = 'off', 170 | }, 171 | ['antispam'] = { 172 | ['links'] = 'alwd', 173 | ['forwards'] = 'alwd', 174 | ['warns'] = 2, 175 | ['action'] = 'mute' 176 | }, 177 | ['flood'] = { 178 | ['MaxFlood'] = 5, 179 | ['ActionFlood'] = 'mute' 180 | }, 181 | ['char'] = { 182 | ['Arab'] = 'allowed', --'kick'/'ban' 183 | ['Rtl'] = 'allowed' 184 | }, 185 | ['floodexceptions'] = { 186 | ['text'] = 'no', 187 | ['photo'] = 'no', -- image 188 | ['forward'] = 'no', 189 | ['video'] = 'no', 190 | ['sticker'] = 'no', 191 | ['gif'] = 'no', 192 | }, 193 | ['warnsettings'] = { 194 | ['type'] = 'mute', 195 | ['mediatype'] = 'mute', 196 | ['max'] = 3, 197 | ['mediamax'] = 2 198 | }, 199 | ['welcome'] = { 200 | ['type'] = 'no', 201 | ['content'] = 'no' 202 | }, 203 | ['goodbye'] = { 204 | ['type'] = 'custom', 205 | }, 206 | ['media'] = { 207 | ['photo'] = 'ok', --'notok' | image 208 | ['audio'] = 'ok', 209 | ['video'] = 'ok', 210 | ['video_note'] = 'ok', 211 | ['sticker'] = 'ok', 212 | ['gif'] = 'ok', 213 | ['voice'] = 'ok', 214 | ['contact'] = 'ok', 215 | ['document'] = 'ok', -- file 216 | ['link'] = 'ok', 217 | ['game'] = 'ok', 218 | ['location'] = 'ok', 219 | venue = "ok", 220 | }, 221 | ['tolog'] = { 222 | ['ban'] = 'no', 223 | ['kick'] = 'no', 224 | ['unban'] = 'no', 225 | ['tempban'] = 'no', 226 | ['report'] = 'no', 227 | ['warn'] = 'no', 228 | ['nowarn'] = 'no', 229 | ['mediawarn'] = 'no', 230 | ['spamwarn'] = 'no', 231 | ['flood'] = 'no', 232 | ['new_chat_member'] = 'no', 233 | ['new_chat_photo'] = 'no', 234 | ['delete_chat_photo'] = 'no', 235 | ['new_chat_title'] = 'no', 236 | ['pinned_message'] = 'no' 237 | }, 238 | ['defpermissions'] = { 239 | ['can_send_messages'] = 'true', 240 | ['can_send_media_messages'] = 'true', 241 | ['can_send_other_messages'] = 'true', 242 | ['can_add_web_page_previews'] = 'true' 243 | }, 244 | ['defpermduration'] = { 245 | ['timeframe'] = 'd', 246 | ['duration'] = 1 247 | }, 248 | }, 249 | private_settings = { 250 | rules_on_join = 'off', 251 | reports = 'off' 252 | }, 253 | chat_hashes = {'extra', 'info', 'links', 'warns', 'mediawarn', 'spamwarns', 'blocked', 'report', 'defpermissions', 254 | 'defpermduration'}, 255 | chat_sets = {'whitelist'}, 256 | bot_keys = { 257 | d3 = {'bot:general', 'bot:usernames', 'bot:chat:latsmsg'}, 258 | d2 = {'bot:groupsid', 'bot:groupsid:removed', 'tempbanned', 'bot:blocked', 'remolden_chats'} --remolden_chats: chat removed with $remold command 259 | } 260 | } 261 | 262 | local multipurpose_plugins = os.getenv('MULTIPURPOSE_PLUGINS') 263 | if multipurpose_plugins then 264 | _M.multipurpose_plugins = assert(json.decode(multipurpose_plugins), 265 | '$MULTIPURPOSE_PLUGINS must be a JSON array or empty') 266 | end 267 | 268 | return _M 269 | -------------------------------------------------------------------------------- /lua/groupbutler/controller.lua: -------------------------------------------------------------------------------- 1 | local _M = {} 2 | 3 | local json = require "cjson" 4 | local main = require "groupbutler.main" 5 | local log = require "groupbutler.logging" 6 | 7 | local function process_update(_, update, update_json) 8 | local update_obj = main:new(update) 9 | local ok, retval = xpcall(function() return update_obj:process() end, debug.traceback) 10 | if not ok or not retval then 11 | retval = retval or "" 12 | log.error("Error processing update: {update}\n{retval}", {update = update_json, retval = retval}) 13 | end 14 | end 15 | 16 | function _M.run() 17 | ngx.req.read_body() 18 | local update_json = ngx.req.get_body_data() 19 | ngx.log(ngx.DEBUG, "Incoming update:", update_json) 20 | 21 | local ok, update = pcall(json.decode, update_json) 22 | if not ok then 23 | ngx.status = ngx.HTTP_BAD_REQUEST 24 | return ngx.exit(ngx.HTTP_BAD_REQUEST) 25 | end 26 | 27 | ngx.timer.at(0, process_update, update, update_json) 28 | 29 | ngx.status = ngx.HTTP_OK 30 | ngx.say("{}") 31 | 32 | return ngx.exit(ngx.HTTP_OK) 33 | end 34 | 35 | function _M.mock(update) 36 | local update_obj = main:new(update) 37 | return update_obj:process() 38 | end 39 | 40 | return _M 41 | -------------------------------------------------------------------------------- /lua/groupbutler/health.lua: -------------------------------------------------------------------------------- 1 | local _M = {} 2 | 3 | local json = require "cjson" 4 | 5 | function _M.run() 6 | local body = { 7 | ok = true 8 | } 9 | 10 | if body.ok then 11 | ngx.status = ngx.HTTP_OK 12 | else 13 | ngx.status = 500 14 | end 15 | ngx.say(json.encode(body)) 16 | end 17 | 18 | return _M 19 | -------------------------------------------------------------------------------- /lua/groupbutler/languages.lua: -------------------------------------------------------------------------------- 1 | local config = require "groupbutler.config" 2 | local strings = {} -- internal array with translated strings 3 | 4 | -- String functions 5 | local function trim(str) -- Trims whitespace from a string 6 | return str:gsub('^%s*(.-)%s*$', '%1') 7 | end 8 | 9 | -- Evaluates the Lua's expression 10 | local function eval(str) 11 | return loadstring('return ' .. str)() 12 | end 13 | 14 | -- Parses the file with translation and returns a table with English strings as 15 | -- keys and translated strings as values. The keys are stored without a leading 16 | -- linebreak for crawling bugs of xgettext. It adds needless linebreak to 17 | -- string literals with long brackets. Fuzzy translations are ignored if flag 18 | -- config.allow_fuzzy_translations isn't set. 19 | local function parse(filename) 20 | local state = 'ign_msgstr' -- states of finite state machine 21 | local msgid, msgstr 22 | local result = {} 23 | 24 | for line in io.lines(filename) do 25 | line = trim(line) 26 | local input, argument = line:match('^(%w*)%s*(".*")$') 27 | if line:match('^#,.*fuzzy') then 28 | input = 'fuzzy' 29 | end 30 | 31 | assert(state == 'msgid' or state == 'msgstr' or state == 'ign_msgid' or state == 'ign_msgstr') 32 | assert(input == nil or input == '' or input == 'msgid' or input == 'msgstr' or input == 'fuzzy') 33 | 34 | if state == 'msgid' and input == '' then 35 | msgid = msgid .. eval(argument) 36 | elseif state == 'msgid' and input == 'msgstr' then 37 | msgstr = eval(argument) 38 | state = 'msgstr' 39 | elseif state == 'msgstr' and input == '' then 40 | msgstr = msgstr .. eval(argument) 41 | elseif state == 'msgstr' and input == 'msgid' then 42 | if msgstr ~= '' then result[msgid:gsub('^\n', '')] = msgstr end 43 | msgid = eval(argument) 44 | state = 'msgid' 45 | elseif state == 'msgstr' and input == 'fuzzy' then 46 | if msgstr ~= '' then result[msgid:gsub('^\n', '')] = msgstr end 47 | if not config.allow_fuzzy_translations then 48 | state = 'ign_msgid' 49 | end 50 | elseif state == 'ign_msgid' and input == 'msgstr' then 51 | state = 'ign_msgstr' 52 | elseif state == 'ign_msgstr' and input == 'msgid' then 53 | msgid = eval(argument) 54 | state = 'msgid' 55 | elseif state == 'ign_msgstr' and input == 'fuzzy' then 56 | state = 'ign_msgid' 57 | end 58 | end 59 | if state == 'msgstr' and msgstr ~= '' then 60 | result[msgid:gsub('^\n', '')] = msgstr 61 | end 62 | 63 | return result 64 | end 65 | 66 | do 67 | local directory = "locales" 68 | for lang_code in pairs(config.available_languages) do 69 | strings[lang_code] = parse(string.format('%s/%s.po', directory, lang_code)) 70 | end 71 | end 72 | 73 | local _M = {} 74 | 75 | function _M:new(obj) 76 | obj = obj or { 77 | language = config.lang 78 | } 79 | setmetatable(obj, { 80 | __call = self.translate, 81 | __index = self, 82 | }) 83 | return obj 84 | end 85 | 86 | function _M:setLanguage(language) 87 | if not config.available_languages[language] then 88 | return false 89 | end 90 | self.language = language 91 | return true 92 | end 93 | 94 | function _M:getLanguage() 95 | return self.language 96 | end 97 | 98 | function _M:translate(msgid) 99 | return strings[self.language][msgid:gsub('^\n', '')] or msgid 100 | end 101 | 102 | return _M 103 | -------------------------------------------------------------------------------- /lua/groupbutler/logging.lua: -------------------------------------------------------------------------------- 1 | local JSON = require 'cjson' 2 | 3 | local logging = {} 4 | 5 | local loglevels = { 6 | [10] = "TRACE", 7 | [20] = "DEBUG", 8 | [30] = "INFO", 9 | [40] = "WARN", 10 | [50] = "ERROR", 11 | [60] = "CRITICAL", 12 | } 13 | 14 | for level, name in pairs(loglevels) do 15 | logging[name] = level 16 | end 17 | 18 | local function interpolate(s, tab) 19 | return ( 20 | s:gsub('(%b{})', function(w) 21 | return assert(tab[w:sub(2, -2)], 22 | "missing formatting value: "..w) 23 | end 24 | ) 25 | ) 26 | end 27 | 28 | function logging.text_log_formatter(level, message, data) 29 | if not data then 30 | data = {} 31 | end 32 | local entry = { 33 | message=interpolate(message, data), 34 | time = os.date("%Y/%m/%d %X"), -- YYYY/MM/DD HH:MM:SS 35 | level=loglevels[level], 36 | } 37 | return interpolate(logging.text_format, entry) 38 | end 39 | 40 | function logging.json_log_formatter(level, message, data) 41 | if not data then 42 | data = {} 43 | end 44 | data.message = message 45 | data.level = level 46 | data.timestamp = os.time(os.date("*t")) 47 | return JSON.encode(data) 48 | end 49 | 50 | function logging.print_log_handler(message) 51 | print(message) 52 | end 53 | 54 | function logging.stderr_log_handler(message) 55 | io.stderr:write(message.."\n") 56 | io.stderr:flush() 57 | end 58 | 59 | function logging.log(level, message, data) 60 | if level < logging.loglevel then 61 | return 62 | end 63 | 64 | logging.handler(logging.formatter(level, message, data)) 65 | end 66 | 67 | for level, name in pairs(loglevels) do 68 | logging[name:lower()] = function(message, data) 69 | logging.log(level, message, data) 70 | end 71 | end 72 | 73 | -- DEFAULTS 74 | 75 | logging.loglevel = logging.DEBUG 76 | logging.formatter = logging.text_log_formatter 77 | logging.handler = logging.stderr_log_handler 78 | logging.text_format = "{time} [{level}]: {message}" 79 | 80 | -- export for tests 81 | 82 | if _G.TEST then 83 | logging._interpolate = interpolate 84 | end 85 | 86 | return logging 87 | -------------------------------------------------------------------------------- /lua/groupbutler/message.lua: -------------------------------------------------------------------------------- 1 | local User = require("groupbutler.user") 2 | local ChatMember = require("groupbutler.chatmember") 3 | 4 | local Message = {} 5 | local message = Message 6 | 7 | local function p(self) 8 | return getmetatable(self).__private 9 | end 10 | 11 | function Message:new(obj, private) 12 | assert(private.api, "Message: Missing private.api") 13 | assert(private.i18n, "Message: Missing private.i18n") 14 | setmetatable(obj, { 15 | __index = self, 16 | __private = private, 17 | }) 18 | return obj 19 | end 20 | 21 | local function msg_type(self) 22 | -- TODO: update database to use "animation" instead of "gif" 23 | if self.animation then 24 | return "gif" 25 | end 26 | local media_types = { 27 | "audio", --[["animation",]] "contact", "document", "game", "location", "photo", "sticker", "venue", "video", 28 | "video_note", "voice", 29 | } 30 | for _, v in pairs(media_types) do 31 | if self[v] then 32 | return v 33 | end 34 | end 35 | if self.entities then 36 | for _, entity in pairs(self.entities) do 37 | if entity.type == "url" or entity.type == "text_link" then 38 | return "link" 39 | end 40 | end 41 | end 42 | return "text" 43 | end 44 | 45 | function message:type() 46 | if self._cached_type == nil then 47 | self._cached_type = msg_type(self) 48 | end 49 | return self._cached_type 50 | end 51 | 52 | local function get_file_id(self) 53 | if self.animation then -- TODO: remove this once db migration for gif messages has been completed 54 | return self.animation.file_id 55 | end 56 | if self.photo then 57 | return self.photo[#self.photo].file_id 58 | end 59 | if self[self:type()] and self[self:type()].file_id then 60 | return self[self:type()].file_id 61 | end 62 | return false -- The message has no media file_id 63 | end 64 | 65 | function message:get_file_id() 66 | if self._cached_get_file_id == nil then 67 | self._cached_get_file_id = get_file_id(self) 68 | end 69 | return self._cached_get_file_id 70 | end 71 | 72 | function message:send_reply(text, parse_mode, disable_web_page_preview, disable_notification, reply_markup) 73 | return p(self).api:sendMessage(self.chat.id, text, parse_mode, disable_web_page_preview, disable_notification, 74 | self.message_id, reply_markup) 75 | end 76 | 77 | function Message:getTargetMember(blocks) -- TODO: extract username/id from self.text or move blocks{} into self 78 | if not self.reply_to_message 79 | and (not blocks or not blocks[2]) then 80 | return false, p(self).i18n("Reply to a user or mention them") 81 | end 82 | 83 | local user_not_found = p(self).i18n([[I've never seen this user before. 84 | This command works by reply, username, user ID or text mention. 85 | If you're using it by username and want to teach me who the user is, forward me one of their messages]]) 86 | 87 | if self.reply_to_message then 88 | if self.reply_to_message.new_chat_member then 89 | return ChatMember:new({ 90 | user = self.reply_to_message.new_chat_member, 91 | chat = self.from.chat, 92 | }, p(self)) 93 | end 94 | return self.reply_to_message.from 95 | end 96 | 97 | if blocks[2]:byte(1) == string.byte("@") then 98 | local user = User:new({username = blocks[2]}, p(self)) 99 | if not user then 100 | return false, user_not_found 101 | end 102 | return ChatMember:new({ 103 | user = user, 104 | chat = self.from.chat, 105 | }, p(self)) 106 | end 107 | 108 | if self.mention_id then 109 | return ChatMember:new({ 110 | user = User:new({id=self.mention_id}, p(self)), 111 | chat = self.from.chat, 112 | }, p(self)) 113 | end 114 | 115 | local id = blocks[2]:match("%d+") 116 | if id then 117 | return ChatMember:new({ 118 | user = User:new({id=id}, p(self)), 119 | chat = self.from.chat, 120 | }, p(self)) 121 | end 122 | 123 | return false, user_not_found 124 | end 125 | 126 | function Message:isForwarded() 127 | if self.forward_from 128 | or self.forward_from_chat then 129 | return true 130 | end 131 | return false 132 | end 133 | 134 | return Message 135 | -------------------------------------------------------------------------------- /lua/groupbutler/null.lua: -------------------------------------------------------------------------------- 1 | local null 2 | 3 | if ngx and ngx.null then 4 | null = ngx.null 5 | else 6 | null = require "cjson".null 7 | end 8 | 9 | return null 10 | -------------------------------------------------------------------------------- /lua/groupbutler/plugins.lua: -------------------------------------------------------------------------------- 1 | local config = require "groupbutler.config" 2 | local log = require "groupbutler.logging" 3 | 4 | local _M = {} 5 | 6 | for _, v in ipairs(config.plugins) do 7 | local p = require("groupbutler.plugins."..v) 8 | package.loaded["groupbutler.plugins."..v] = nil 9 | if p.triggers then 10 | for funct, trgs in pairs(p.triggers) do 11 | for i = 1, #trgs do 12 | -- interpret any whitespace character in commands just as space 13 | trgs[i] = trgs[i]:gsub(' ', '%%s+') 14 | end 15 | if not p[funct] then 16 | p.trgs[funct] = nil 17 | log.warn('triggers ignored in {v}: {funct} function not defined', {v=v, funct=funct}) 18 | end 19 | end 20 | end 21 | table.insert(_M, p) 22 | end 23 | 24 | return _M 25 | -------------------------------------------------------------------------------- /lua/groupbutler/plugins/backup.lua: -------------------------------------------------------------------------------- 1 | local config = require "groupbutler.config" 2 | local json = require "cjson" 3 | local null = require "groupbutler.null" 4 | 5 | local _M = {} 6 | 7 | function _M:new(update_obj) 8 | local plugin_obj = {} 9 | setmetatable(plugin_obj, {__index = self}) 10 | for k, v in pairs(update_obj) do 11 | plugin_obj[k] = v 12 | end 13 | return plugin_obj 14 | end 15 | 16 | local function save_data(filename, data) 17 | local s = json.encode(data) 18 | local f = io.open(filename, 'w') 19 | f:write(s) 20 | f:close() 21 | end 22 | 23 | local function load_data(filename) 24 | local f = io.open(filename) 25 | if not f then return {} end 26 | local s = f:read('*all') 27 | f:close() 28 | return json.decode(s) 29 | end 30 | 31 | local function gen_backup(self, chat_id) 32 | local red = self.red 33 | chat_id = tostring(chat_id) 34 | local file_path = '/tmp/snap'..chat_id..'.gbb' 35 | local t = { 36 | [chat_id] = { 37 | hashes = {}, 38 | sets = {} 39 | } 40 | } 41 | local hash 42 | for i=1, #config.chat_hashes do 43 | hash = ('chat:%s:%s'):format(chat_id, config.chat_hashes[i]) 44 | local content = red:array_to_hash(red:hgetall(hash)) 45 | if next(content) then 46 | t[chat_id].hashes[config.chat_hashes[i]] = {} 47 | for key, val in pairs(content) do 48 | t[chat_id].hashes[config.chat_hashes[i]][key] = val 49 | end 50 | end 51 | end 52 | for i=1, #config.chat_sets do 53 | local set = ('chat:%s:%s'):format(chat_id, config.chat_sets[i]) 54 | local content = red:smembers(set) 55 | if next(content) then 56 | t[chat_id].sets[config.chat_sets[i]] = content 57 | end 58 | end 59 | -- u:dump(t) 60 | save_data(file_path, t) 61 | 62 | return file_path 63 | end 64 | 65 | local function get_time_remaining(seconds) 66 | local final = '' 67 | local hours = math.floor(seconds/3600) 68 | seconds = seconds - (hours*60*60) 69 | local min = math.floor(seconds/60) 70 | seconds = seconds - (min*60) 71 | 72 | if hours and hours > 0 then 73 | final = final..hours..'h ' 74 | end 75 | if min and min > 0 then 76 | final = final..min..'m ' 77 | end 78 | if seconds and seconds > 0 then 79 | final = final..seconds..'s' 80 | end 81 | 82 | return final 83 | end 84 | 85 | function _M:onTextMessage(blocks) 86 | local api = self.api 87 | local msg = self.message 88 | local red = self.red 89 | local i18n = self.i18n 90 | local u = self.u 91 | 92 | if not msg.from:isAdmin() then return end 93 | 94 | if blocks[1] == 'snap' then 95 | local key = 'chat:'..msg.from.chat.id..':lastsnap' 96 | local last_user = red:get(key) 97 | if last_user ~= null then -- A snapshot has been done recently 98 | local ttl = red:ttl(key) 99 | local time_remaining = get_time_remaining(ttl) 100 | local text = i18n([[I'm sorry, this command has been used for the last time less then 3 hours ago by %s (ask them for the file). 101 | Wait [%s] to use it again 102 | ]]):format(last_user, time_remaining) 103 | msg:send_reply(text, 'html') 104 | return 105 | end 106 | 107 | local file_path = gen_backup(self, msg.from.chat.id) 108 | local ok = api:sendDocument(msg.from.user.id, {path = file_path}, ('#snap\n%s'):format(msg.from.chat.title)) 109 | 110 | if not ok then return end 111 | 112 | local name = msg.from.user:getLink() 113 | red:setex(key, 10800, name) --3 hours 114 | msg:send_reply(i18n('*Sent in private*'), "Markdown") 115 | return 116 | end 117 | if blocks[1] == 'import' then 118 | local text 119 | if not msg.reply then 120 | text = i18n('Invalid input. Please reply to the backup file (/snap command to get it)') 121 | api:sendMessage(msg.from.chat.id, text) 122 | return 123 | end 124 | if not msg.reply.document then 125 | text = i18n('Invalid input. Please reply to a document') 126 | api:sendMessage(msg.from.chat.id, text) 127 | return 128 | end 129 | if msg.reply.document.file_name ~= 'snap'..msg.from.chat.id..'.gbb' then 130 | text = i18n('This is not a valid backup file.\nReason: invalid name (%s)') 131 | :format(tostring(msg.reply_to_message.document.file_name)) 132 | api:sendMessage(msg.from.chat.id, text) 133 | return 134 | end 135 | local res = api:getFile(msg.reply.document.file_id) 136 | local download_link = u:telegram_file_link(res) 137 | local file_path, code = u:download_to_file(download_link, '/tmp/'..msg.from.chat.id..'.json') 138 | 139 | if not file_path then 140 | text = i18n('Download of the file failed with code %s'):format(tostring(code)) 141 | api:sendMessage(msg.from.chat.id, text) 142 | return 143 | end 144 | 145 | local data = load_data(file_path) 146 | for chat_id, group_data in pairs(data) do 147 | chat_id = tonumber(chat_id) 148 | if tonumber(chat_id) ~= msg.from.chat.id then 149 | text = i18n('Chat IDs don\'t match (%s and %s)'):format(tostring(chat_id), tostring(msg.from.chat.id)) 150 | api:sendMessage(msg.from.chat.id, text) 151 | return 152 | end 153 | --restoring sets 154 | if group_data.sets and next(group_data.sets) then 155 | for set, content in pairs(group_data.sets) do 156 | red:sadd(('chat:%d:%s'):format(chat_id, set), unpack(content)) 157 | end 158 | end 159 | 160 | --restoring hashes 161 | if group_data.hashes and next(group_data.hashes) then 162 | for hash, content in pairs(group_data.hashes) do 163 | if next(content) then 164 | -- for key, val in pairs(content) do 165 | -- print('\tkey:', key) 166 | -- red:hset(('chat:%d:%s'):format(chat_id, hash), key, val) 167 | -- end 168 | red:hmset(('chat:%d:%s'):format(chat_id, hash), content) 169 | end 170 | end 171 | end 172 | api:sendMessage(msg.from.chat.id, i18n([[Import was successful. 173 | 174 | Important: 175 | - #extra commands which are associated with a media must be set again if the bot you are using now is different from the bot that originated the backup. 176 | ]]), "html") 177 | end 178 | end 179 | end 180 | 181 | _M.triggers = { 182 | onTextMessage = { 183 | config.cmd..'(snap)$', 184 | config.cmd..'(import)$' 185 | } 186 | } 187 | 188 | return _M 189 | -------------------------------------------------------------------------------- /lua/groupbutler/plugins/banhammer.lua: -------------------------------------------------------------------------------- 1 | local config = require "groupbutler.config" 2 | local ChatMember = require("groupbutler.chatmember") 3 | local User = require("groupbutler.user") 4 | 5 | local _M = {} 6 | 7 | function _M:new(update_obj) 8 | local plugin_obj = {} 9 | setmetatable(plugin_obj, {__index = self}) 10 | for k, v in pairs(update_obj) do 11 | plugin_obj[k] = v 12 | end 13 | return plugin_obj 14 | end 15 | 16 | local function markup_tempban(self, chat_id, user_id, time_value) 17 | local red = self.red 18 | local key = ('chat:%s:%s:tbanvalue'):format(chat_id, user_id) 19 | time_value = time_value or tonumber(red:get(key)) or 3 20 | 21 | local markup = {inline_keyboard={ 22 | {--first line 23 | {text = '-', callback_data = ('tempban:val:m:%s:%s'):format(user_id, chat_id)}, 24 | {text = "🕑 "..time_value, callback_data = "tempban:nil"}, 25 | {text = '+', callback_data = ('tempban:val:p:%s:%s'):format(user_id, chat_id)} 26 | }, 27 | {--second line 28 | {text = 'minutes', callback_data = ('tempban:ban:m:%s:%s'):format(user_id, chat_id)}, 29 | {text = 'hours', callback_data = ('tempban:ban:h:%s:%s'):format(user_id, chat_id)}, 30 | {text = 'days', callback_data = ('tempban:ban:d:%s:%s'):format(user_id, chat_id)}, 31 | } 32 | }} 33 | 34 | return markup 35 | end 36 | 37 | local function get_motivation(msg) 38 | if msg.reply then 39 | return msg.text:match(("%sban (.+)"):format(config.cmd)) 40 | or msg.text:match(("%skick (.+)"):format(config.cmd)) 41 | or msg.text:match(("%stempban .+\n(.+)"):format(config.cmd)) 42 | else 43 | if msg.text:find(config.cmd.."ban @%w[%w_]+ ") or msg.text:find(config.cmd.."kick @%w[%w_]+ ") then 44 | return msg.text:match(config.cmd.."ban @%w[%w_]+ (.+)") or msg.text:match(config.cmd.."kick @%w[%w_]+ (.+)") 45 | elseif msg.text:find(config.cmd.."ban %d+ ") or msg.text:find(config.cmd.."kick %d+ ") then 46 | return msg.text:match(config.cmd.."ban %d+ (.+)") or msg.text:match(config.cmd.."kick %d+ (.+)") 47 | elseif msg.entities then 48 | return msg.text:match(config.cmd.."ban .+\n(.+)") or msg.text:match(config.cmd.."kick .+\n(.+)") 49 | end 50 | end 51 | end 52 | 53 | function _M:onTextMessage(blocks) 54 | local api = self.api 55 | local msg = self.message 56 | local bot = self.bot 57 | local red = self.red 58 | local i18n = self.i18n 59 | local api_err = self.api_err 60 | local u = self.u 61 | 62 | if msg.from.chat.type == "private" 63 | or not msg.from:can("can_restrict_members") then 64 | return 65 | end 66 | local admin = msg.from 67 | local target 68 | do 69 | local err 70 | target, err = msg:getTargetMember(blocks) 71 | if not target and blocks[1] ~= "fwdban" then 72 | msg:send_reply(err, "Markdown") 73 | return 74 | end 75 | end 76 | if tonumber(target.user.id) == bot.id then return end 77 | 78 | --print(get_motivation(msg)) 79 | 80 | if blocks[1] == 'tempban' then 81 | local time_value = msg.text:match(("%stempban.*\n"):format(config.cmd).."(%d+)") 82 | if time_value then --save the time value passed by the user 83 | if tonumber(time_value) > 100 then 84 | time_value = 100 85 | end 86 | local key = ('chat:%s:%s:tbanvalue'):format(msg.from.chat.id, target.user.id) 87 | red:setex(key, 3600, time_value) 88 | end 89 | local markup = markup_tempban(self, msg.from.chat.id, target.user.id) 90 | msg:send_reply(i18n("Use -/+ to edit the value, then select a timeframe to temporary ban the user"), 91 | "Markdown", nil, nil, markup) 92 | end 93 | 94 | local text 95 | if blocks[1] == 'kick' then 96 | local ok, err = target:kick() 97 | if not ok then 98 | msg:send_reply(err, "Markdown") 99 | return 100 | end 101 | u:logEvent("kick", msg, { 102 | motivation = get_motivation(msg), 103 | admin = admin.user, 104 | user = target.user, 105 | user_id = target.user.id 106 | }) 107 | text = i18n("%s kicked %s!"):format(admin.user:getLink(), target.user:getLink()) 108 | api:sendMessage(msg.from.chat.id, text, "html", true) 109 | end 110 | 111 | if blocks[1] == 'ban' then 112 | local ok, err = target:ban() 113 | if not ok then 114 | msg:send_reply(err, "Markdown") 115 | end 116 | u:logEvent("ban", msg, { 117 | motivation = get_motivation(msg), 118 | admin = admin.user, 119 | user = target.user, 120 | user_id = target.user.id 121 | }) 122 | text = i18n("%s banned %s!"):format(admin.user:getLink(), target.user:getLink()) 123 | api:sendMessage(msg.from.chat.id, text, "html", true) 124 | end 125 | 126 | if blocks[1] == 'fwdban' then 127 | if not msg.reply or not msg.reply.forward_from then 128 | msg:send_reply(i18n("_Use this command in reply to a forwarded message_"), "Markdown") 129 | return 130 | end 131 | target = msg.reply.forward_from 132 | local ok, err = target:ban() 133 | if not ok then 134 | msg:send_reply(err, "Markdown") 135 | end 136 | u:logEvent("ban", msg, { 137 | motivation = get_motivation(msg), 138 | admin = admin.user, 139 | user = target.user, 140 | user_id = target.user.id 141 | }) 142 | text = i18n("%s banned %s!"):format(admin.user:getLink(), target.user:getLink()) 143 | api:sendMessage(msg.from.chat.id, text, "html", true) 144 | end 145 | 146 | if blocks[1] == 'unban' then 147 | if target:isAdmin() then 148 | msg:send_reply(i18n("_An admin can't be unbanned_"), "Markdown") 149 | return 150 | end 151 | if target.status ~= "kicked" then 152 | msg:send_reply(i18n("This user is not banned!")) 153 | return 154 | end 155 | local ok, err = api:unbanChatMember(target.chat.id, target.user.id) 156 | if not ok then 157 | msg:send_reply(api_err:trans(err), "Markdown") 158 | return 159 | end 160 | u:logEvent("unban", msg, { 161 | motivation = get_motivation(msg), 162 | admin = admin, 163 | user = target.user, 164 | user_id = target.user.id 165 | }) 166 | msg:send_reply(i18n("%s unbanned by %s!"):format(target.user:getLink(), admin.user:getLink()), 'html') 167 | end 168 | end 169 | 170 | function _M:onCallbackQuery(matches) 171 | local api = self.api 172 | local msg = self.message 173 | local red = self.red 174 | local i18n = self.i18n 175 | 176 | if msg.from.chat.type == "private" 177 | or not msg.from:can("can_restrict_members") then 178 | api:answerCallbackQuery(msg.cb_id, i18n("Sorry, you don't have permission to restrict members"), true) 179 | return 180 | end 181 | 182 | if matches[1] == "nil" then 183 | api:answerCallbackQuery(msg.cb_id, 184 | i18n("Tap on the -/+ buttons to change this value. Then select a timeframe to execute the ban"), true) 185 | return 186 | end 187 | 188 | local target = ChatMember:new({ 189 | user = User:new({id=matches[3]}, self), 190 | chat = msg.from.chat, 191 | }, self) 192 | 193 | if matches[1] == "val" then 194 | local key = ("chat:%d:%s:tbanvalue"):format(msg.from.chat.id, target.user.id) 195 | local current_value, new_value 196 | current_value = tonumber(red:get(key)) or 3 197 | if matches[2] == "m" then 198 | new_value = current_value - 1 199 | if new_value < 1 then 200 | api:answerCallbackQuery(msg.cb_id, i18n("You can't set a lower value")) 201 | return --don't proceed 202 | else 203 | red:setex(key, 3600, new_value) 204 | end 205 | elseif matches[2] == "p" then 206 | new_value = current_value + 1 207 | if new_value > 100 then 208 | api:answerCallbackQuery(msg.cb_id, i18n("Stop!!!"), true) 209 | return --don't proceed 210 | else 211 | red:setex(key, 3600, new_value) 212 | end 213 | end 214 | local markup = markup_tempban(self, msg.from.chat.id, target.user.id, new_value) 215 | api:editMessageReplyMarkup(msg.from.chat.id, msg.message_id, nil, markup) 216 | return 217 | end 218 | 219 | if matches[1] == 'ban' then 220 | local key = ('chat:%d:%s:tbanvalue'):format(msg.from.chat.id, target.user.id) 221 | local time_value = tonumber(red:get(key)) or 3 222 | local timeframe_string, until_date 223 | if matches[2] == 'h' then 224 | time_value = time_value <= 24 and time_value or 24 225 | timeframe_string = i18n('hours') 226 | until_date = msg.date + (time_value * 3600) 227 | elseif matches[2] == 'd' then 228 | time_value = time_value <= 30 and time_value or 30 229 | timeframe_string = i18n('days') 230 | until_date = msg.date + (time_value * 3600 * 24) 231 | elseif matches[2] == 'm' then 232 | time_value = time_value <= 60 and time_value or 60 233 | timeframe_string = i18n('minutes') 234 | until_date = msg.date + (time_value * 60) 235 | end 236 | local ok, err = target:ban(until_date) 237 | if not ok then 238 | api:editMessageText(msg.from.chat.id, msg.message_id, nil, err) 239 | return 240 | end 241 | local text = i18n("User banned for %d %s"):format(time_value, timeframe_string) 242 | api:editMessageText(msg.from.chat.id, msg.message_id, nil, text) 243 | red:del(key) 244 | end 245 | end 246 | 247 | _M.triggers = { 248 | onTextMessage = { 249 | config.cmd..'(kick) (.+)', 250 | config.cmd..'(kick)$', 251 | config.cmd..'(ban) (.+)', 252 | config.cmd..'(ban)$', 253 | config.cmd..'(fwdban)$', 254 | config.cmd..'(tempban)$', 255 | config.cmd..'(tempban) (.+)', 256 | config.cmd..'(unban) (.+)', 257 | config.cmd..'(unban)$' 258 | }, 259 | onCallbackQuery = { 260 | '^###cb:tempban:(val):(%a):(%d+):(-%d+)', 261 | '^###cb:tempban:(ban):(%a):(%d+):(-%d+)', 262 | '^###cb:tempban:(nil)$' 263 | } 264 | } 265 | 266 | return _M 267 | -------------------------------------------------------------------------------- /lua/groupbutler/plugins/configure.lua: -------------------------------------------------------------------------------- 1 | local config = require "groupbutler.config" 2 | local api_u = require "telegram-bot-api.utilities" 3 | local Chat = require("groupbutler.chat") 4 | local ChatMember = require("groupbutler.chatmember") 5 | 6 | local _M = {} 7 | 8 | function _M:new(update_obj) 9 | local plugin_obj = {} 10 | setmetatable(plugin_obj, {__index = self}) 11 | for k, v in pairs(update_obj) do 12 | plugin_obj[k] = v 13 | end 14 | return plugin_obj 15 | end 16 | 17 | local function doKeyboardConfig(self, member) 18 | local i18n = self.i18n 19 | local reply_markup = api_u.InlineKeyboardMarkup:new() 20 | reply_markup:row({text = i18n("🛠 Menu"), callback_data = "config:menu:"..member.chat.id}) 21 | reply_markup:row({text = i18n("⚡️ Antiflood"), callback_data = "config:antiflood:"..member.chat.id}) 22 | reply_markup:row({text = i18n("🌈 Media"), callback_data = "config:media:"..member.chat.id}) 23 | reply_markup:row({text = i18n("🚫 Antispam"), callback_data = "config:antispam:"..member.chat.id}) 24 | reply_markup:row({text = i18n("📥 Log channel"), callback_data = "config:logchannel:"..member.chat.id}) 25 | if member:can("can_restrict_members") then 26 | reply_markup:row({text = i18n("⛔️ Default permissions"), callback_data = "config:defpermissions:"..member.chat.id}) -- luacheck: ignore 631 27 | end 28 | return reply_markup 29 | end 30 | 31 | local function messageConstructor(self, member) 32 | local i18n = self.i18n 33 | local text = i18n("Change the settings of your group") 34 | if member.chat.title then 35 | text = ("%s\n"):format(member.chat.title:escape_html())..text 36 | end 37 | return { 38 | chat_id = member.user.id, 39 | text = text, 40 | parse_mode = "html", 41 | reply_markup = doKeyboardConfig(self, member) 42 | } 43 | end 44 | 45 | function _M:onTextMessage() 46 | local api = self.api 47 | local msg = self.message 48 | local u = self.u 49 | local i18n = self.i18n 50 | 51 | if msg.from.chat.type ~= "supergroup" 52 | or not msg.from:isAdmin() then 53 | return 54 | end 55 | 56 | local res = api:sendMessage(messageConstructor(self, msg.from)) 57 | 58 | if u:is_silentmode_on(msg.from.chat.id) then -- send the response in the group only if the silent mode is off 59 | return 60 | end 61 | 62 | if not res then 63 | u:sendStartMe(msg) 64 | return 65 | end 66 | api:sendMessage(msg.from.chat.id, i18n("_I've sent you the keyboard via private message_"), "Markdown") 67 | end 68 | 69 | function _M:onCallbackQuery() 70 | local api = self.api 71 | local msg = self.message 72 | 73 | local member = ChatMember:new({ 74 | chat = Chat:new({id=msg.target_id}, self), 75 | user = msg.from.user, 76 | }, self) 77 | 78 | local body = messageConstructor(self, member) 79 | body.message_id = msg.message_id 80 | api:editMessageText(body) 81 | end 82 | 83 | _M.triggers = { 84 | onTextMessage = { 85 | config.cmd..'config$', 86 | config.cmd..'settings$', 87 | }, 88 | onCallbackQuery = { 89 | '^###cb:config:back:' 90 | } 91 | } 92 | 93 | return _M 94 | -------------------------------------------------------------------------------- /lua/groupbutler/plugins/dashboard.lua: -------------------------------------------------------------------------------- 1 | local config = require "groupbutler.config" 2 | local null = require "groupbutler.null" 3 | local Chat = require("groupbutler.chat") 4 | local ChatMember = require("groupbutler.chatmember") 5 | 6 | local _M = {} 7 | 8 | function _M:new(update_obj) 9 | local plugin_obj = {} 10 | setmetatable(plugin_obj, {__index = self}) 11 | for k, v in pairs(update_obj) do 12 | plugin_obj[k] = v 13 | end 14 | return plugin_obj 15 | end 16 | 17 | local function getFloodSettings_text(self, chat_id) 18 | local red = self.red 19 | local i18n = self.i18n 20 | local status = red:hget('chat:'..chat_id..':settings', 'Flood') -- (default: disabled) 21 | if status == 'no' or status == 'on' then 22 | status = i18n("✅ | ON") 23 | else 24 | status = i18n("❌ | OFF") 25 | end 26 | local hash = 'chat:'..chat_id..':flood' 27 | local action = red:hget(hash, 'ActionFlood') 28 | if action == null then action = config.chat_settings['flood']['ActionFlood'] end 29 | 30 | if action == 'kick' then 31 | action = i18n("👞 kick") 32 | elseif action == 'ban' then 33 | action = i18n("🔨 ban") 34 | elseif action == 'mute' then 35 | action = i18n("👁 mute") 36 | end 37 | 38 | local num = tonumber(red:hget(hash, 'MaxFlood')) or config.chat_settings['flood']['MaxFlood'] 39 | local exceptions = { 40 | text = i18n("Texts"), 41 | forward = i18n("Forwards"), 42 | sticker = i18n("Stickers"), 43 | photo = i18n("Images"), 44 | gif = i18n("GIFs"), 45 | video = i18n("Videos"), 46 | } 47 | hash = 'chat:'..chat_id..':floodexceptions' 48 | local list_exc = '' 49 | for media, translation in pairs(exceptions) do 50 | --ignored by the antiflood-> yes, no 51 | local exc_status = red:hget(hash, media) 52 | if exc_status == 'yes' then 53 | exc_status = '✅' 54 | else 55 | exc_status = '❌' 56 | end 57 | list_exc = list_exc..'• `'..translation..'`: '..exc_status..'\n' 58 | end 59 | return i18n("- *Status*: `%s`\n"):format(status) 60 | .. i18n("- *Action* to perform when a user floods: `%s`\n"):format(action) 61 | .. i18n("- Number of messages allowed *every 5 seconds*: `%d`\n"):format(num) 62 | .. i18n("- *Ignored media*:\n%s"):format(list_exc) 63 | end 64 | 65 | local function doKeyboard_dashboard(self, chat_id) 66 | local i18n = self.i18n 67 | local reply_markup = { inline_keyboard = { 68 | { 69 | {text = i18n("Settings"), callback_data = 'dashboard:settings:'..chat_id}, 70 | {text = i18n("Admins"), callback_data = 'dashboard:adminlist:'..chat_id} 71 | }, 72 | { 73 | {text = i18n("Rules"), callback_data = 'dashboard:rules:'..chat_id}, 74 | {text = i18n("Extra commands"), callback_data = 'dashboard:extra:'..chat_id} 75 | }, 76 | { 77 | {text = i18n("Flood settings"), callback_data = 'dashboard:flood:'..chat_id}, 78 | {text = i18n("Media settings"), callback_data = 'dashboard:media:'..chat_id} 79 | }, 80 | }} 81 | 82 | return reply_markup 83 | end 84 | 85 | function _M:onTextMessage() 86 | local api = self.api 87 | local msg = self.message 88 | local u = self.u 89 | local i18n = self.i18n 90 | if msg.from.chat.type ~= 'private' then 91 | local chat_id = msg.from.chat.id 92 | local reply_markup = doKeyboard_dashboard(self, chat_id) 93 | local ok = api:send_message{ 94 | chat_id = msg.from.user.id, 95 | text = i18n("Navigate this message to see *all the info* about this group!"), 96 | parse_mode = "Markdown", 97 | reply_markup = reply_markup 98 | } 99 | if not u:is_silentmode_on(msg.from.chat.id) then --send the responde in the group only if the silent mode is off 100 | if not ok then 101 | u:sendStartMe(msg) 102 | return 103 | end 104 | api:sendMessage(msg.from.chat.id, i18n("_I've sent you the group dashboard via private message_"), "Markdown") 105 | end 106 | end 107 | end 108 | 109 | function _M:onCallbackQuery(blocks) 110 | local api = self.api 111 | local msg = self.message 112 | local u = self.u 113 | local red = self.red 114 | local i18n = self.i18n 115 | 116 | local request = blocks[2] 117 | local text, notification 118 | local parse_mode = "Markdown" 119 | 120 | local chat = Chat:new({id=msg.target_id}, self) 121 | local member = ChatMember:new({ 122 | chat = chat, 123 | user = msg.from.user, 124 | }, self) 125 | 126 | if member.status == 'left' 127 | or member.status == 'kicked' then 128 | api:editMessageText(msg.from.user.id, msg.message_id, nil, i18n("🚷 You are not a member of the chat. " .. 129 | "You can't see the settings of a private group.")) 130 | return 131 | end 132 | local reply_markup = doKeyboard_dashboard(self, chat.id) 133 | if request == 'settings' then 134 | text = u:getSettings(chat.id) 135 | notification = i18n("ℹ️ Group ► Settings") 136 | end 137 | if request == 'rules' then 138 | text = u:getRules(chat.id) 139 | notification = i18n("ℹ️ Group ► Rules") 140 | end 141 | if request == 'adminlist' then 142 | parse_mode = 'html' 143 | local adminlist = u:getAdminlist(chat) 144 | if adminlist then 145 | text = adminlist 146 | else 147 | text = i18n("I got kicked out of this group 😓") 148 | end 149 | notification = i18n("ℹ️ Group ► Admin list") 150 | end 151 | if request == 'extra' then 152 | text = u:getExtraList(chat.id) 153 | notification = i18n("ℹ️ Group ► Extra") 154 | end 155 | if request == 'flood' then 156 | text = getFloodSettings_text(self, chat.id) 157 | notification = i18n("ℹ️ Group ► Flood") 158 | end 159 | if request == 'media' then 160 | local media_texts = { 161 | photo = i18n("Images"), 162 | gif = i18n("GIFs"), 163 | video = i18n("Videos"), 164 | document = i18n("Documents"), 165 | TGlink = i18n("telegram.me links"), 166 | voice = i18n("Voice Messages"), 167 | link = i18n("Links"), 168 | audio = i18n("Music"), 169 | sticker = i18n("Stickers"), 170 | contact = i18n("Contacts"), 171 | game = i18n("Games"), 172 | location = i18n("Locations"), 173 | venue = i18n("Venues"), 174 | } 175 | text = i18n("*Current media settings*:\n\n") 176 | for media, default_status in pairs(config.chat_settings['media']) do 177 | local status = red:hget('chat:'..chat.id..':media', media) 178 | if status == null then status = default_status end 179 | if status == 'ok' then 180 | status = '✅' 181 | else 182 | status = '🚫' 183 | end 184 | local media_cute_name = media_texts[media] or media 185 | text = text..'`'..media_cute_name..'` ≡ '..status..'\n' 186 | end 187 | notification = i18n("ℹ️ Group ► Media") 188 | end 189 | api:edit_message_text(msg.from.user.id, msg.message_id, nil, text, parse_mode, true, reply_markup) 190 | api:answerCallbackQuery(msg.cb_id, notification) 191 | end 192 | 193 | _M.triggers = { 194 | onTextMessage = {config.cmd..'(dashboard)$'}, 195 | onCallbackQuery = {'^###cb:(dashboard):(%a+):(-%d+)'} 196 | } 197 | 198 | return _M 199 | -------------------------------------------------------------------------------- /lua/groupbutler/plugins/defaultpermissions.lua: -------------------------------------------------------------------------------- 1 | local config = require "groupbutler.config" 2 | local null = require "groupbutler.null" 3 | local Chat = require("groupbutler.chat") 4 | local ChatMember = require("groupbutler.chatmember") 5 | local Util = require("groupbutler.util") 6 | 7 | local _M = {} 8 | 9 | function _M:new(update_obj) 10 | local plugin_obj = {} 11 | setmetatable(plugin_obj, {__index = self}) 12 | for k, v in pairs(update_obj) do 13 | plugin_obj[k] = v 14 | end 15 | return plugin_obj 16 | end 17 | 18 | local function toggle_permissions_setting(self, chat_id, key) 19 | local red = self.red 20 | local hash = 'chat:'..chat_id..':defpermissions' 21 | local current = red:hget(hash, key) 22 | if current == null then current = config.chat_settings['defpermissions'][key] end 23 | 24 | local new = "true" 25 | if current == 'true' then 26 | new = 'false' 27 | end 28 | 29 | local new_perm = {[key] = new} 30 | 31 | if new == 'true' then 32 | if key == 'can_send_media_messages' then 33 | new_perm['can_send_messages'] = 'true' 34 | elseif key == 'can_send_other_messages' then 35 | new_perm['can_send_messages'] = 'true' 36 | new_perm['can_send_media_messages'] = 'true' 37 | elseif key == 'can_add_web_page_previews' then 38 | new_perm['can_send_messages'] = 'true' 39 | new_perm['can_send_media_messages'] = 'true' 40 | end 41 | elseif new == 'false' then 42 | if key == 'can_send_messages' then 43 | new_perm['can_send_other_messages'] = 'false' 44 | new_perm['can_send_media_messages'] = 'false' 45 | new_perm['can_add_web_page_previews'] = 'false' 46 | elseif key == 'can_send_media_messages' then 47 | new_perm['can_send_other_messages'] = 'false' 48 | new_perm['can_add_web_page_previews'] = 'false' 49 | end 50 | end 51 | 52 | red:hmset(hash, new_perm) 53 | 54 | return '✅' 55 | end 56 | 57 | local function get_alert_text(self, key) 58 | local i18n = self.i18n 59 | local alert_text = { 60 | can_send_messages = i18n("Permission to send messages. If disabled, the user won't be able to send any kind of message"), -- luacheck: ignore 631 61 | can_send_media_messages = i18n("Permission to send media (audios, documents, photos, videos, video notes and voice notes). Implies the permission to send messages"), -- luacheck: ignore 631 62 | can_send_other_messages = i18n("Permission to send other types of messages (GIFs, games, stickers and use inline bots). Implies the permission to send medias"), -- luacheck: ignore 631 63 | can_add_web_page_previews = i18n("When disabled, user's messages with a link won't show the web page preview"), 64 | } Util.setDefaultTableValue(alert_text, i18n("Description not available")) 65 | 66 | return alert_text[key] 67 | end 68 | 69 | local function humanizations(self) 70 | local i18n = self.i18n 71 | return { 72 | ['can_send_messages'] = i18n('Send messages'), 73 | ['can_send_media_messages'] = i18n('Send media'), 74 | ['can_send_other_messages'] = i18n('Send other types of media'), 75 | ['can_add_web_page_previews'] = i18n('Show web page preview'), 76 | } 77 | end 78 | 79 | local permissions = 80 | {'can_send_messages', 'can_send_media_messages', 'can_send_other_messages', 'can_add_web_page_previews'} 81 | 82 | local function doKeyboard_permissions(self, chat_id) 83 | local red = self.red 84 | local keyboard = {inline_keyboard = {}} 85 | 86 | local line, status, icon, permission 87 | --for field, value in pairs(config.chat_settings['defpermissions']) do 88 | for i=1, #permissions do --pairs() doesn't keep the order of the keys 89 | permission = permissions[i] 90 | icon = '✅' 91 | status = red:hget('chat:'..chat_id..':defpermissions', permission) 92 | if status == null then status = config.chat_settings['defpermissions'][permission] end 93 | 94 | if status == 'false' then icon = '☑️' end 95 | line = { 96 | { 97 | text = humanizations(self)[permission] or permission, 98 | callback_data = 'defpermissions:alert:'..permission 99 | }, 100 | { 101 | text = icon, 102 | callback_data = 'defpermissions:toggle:'..permission..':'..chat_id 103 | } 104 | } 105 | table.insert(keyboard.inline_keyboard, line) 106 | end 107 | 108 | --back button 109 | table.insert(keyboard.inline_keyboard, {{text = '🔙', callback_data = 'config:back:'..chat_id}}) 110 | 111 | return keyboard 112 | end 113 | 114 | function _M:onCallbackQuery(blocks) 115 | local api = self.api 116 | local msg = self.message 117 | local i18n = self.i18n 118 | 119 | if blocks[1] == 'alert' then 120 | local text = get_alert_text(self, blocks[2]) 121 | api:answerCallbackQuery(msg.cb_id, text, true, config.bot_settings.cache_time.alert_help) 122 | return 123 | end 124 | 125 | local member = ChatMember:new({ 126 | chat = Chat:new({id=msg.target_id}, self), 127 | user = msg.from.user, 128 | }, self) 129 | 130 | if not member:can("can_restrict_members") then 131 | api:answerCallbackQuery(msg.cb_id, i18n("Sorry, you don't have permission to restrict members")) 132 | return 133 | end 134 | 135 | local msg_text = i18n([[*Default permissions* 136 | From this menu you can change the default permissions that will be granted when a new member join. 137 | _Only the administrators with the permission to restrict a member can access this menu._ 138 | Tap on the name of a permission for a description of what kind of messages it will influence. 139 | ]]) 140 | 141 | local reply_markup, popup_text, show_alert 142 | 143 | if blocks[1] == 'toggle' then 144 | popup_text = toggle_permissions_setting(self, member.chat.id, blocks[2]) 145 | end 146 | 147 | reply_markup = doKeyboard_permissions(self, member.chat.id) 148 | local ok, err 149 | if blocks[2] then 150 | --if the user tapped on a keybord button, just edit the markup and not the whole message 151 | ok, err = api:editMessageReplyMarkup(msg.from.chat.id, msg.message_id, nil, reply_markup) 152 | else 153 | ok, err = api:editMessageText(msg.from.chat.id, msg.message_id, nil, msg_text, "Markdown", nil, reply_markup) 154 | end 155 | 156 | if not ok and err.retry_after then 157 | popup_text = i18n("Setting saved, but I can't edit the buttons because you are too fast! Wait other %d seconds") 158 | :format(err.retry_after) 159 | show_alert = true 160 | end 161 | if popup_text then 162 | api:answerCallbackQuery(msg.cb_id, popup_text, show_alert) 163 | end 164 | end 165 | 166 | _M.triggers = { 167 | onCallbackQuery = { 168 | '^###cb:config:defpermissions:(-%d+)$', 169 | '^###cb:defpermissions:(toggle):([%w_]+):(-%d+)$', 170 | '^###cb:defpermissions:(alert):([%w_]+):([%w_]+)$', 171 | } 172 | } 173 | 174 | return _M 175 | -------------------------------------------------------------------------------- /lua/groupbutler/plugins/extra.lua: -------------------------------------------------------------------------------- 1 | local config = require "groupbutler.config" 2 | local null = require "groupbutler.null" 3 | local Util = require("groupbutler.util") 4 | 5 | local _M = {} 6 | 7 | function _M:new(update_obj) 8 | local plugin_obj = {} 9 | setmetatable(plugin_obj, {__index = self}) 10 | for k, v in pairs(update_obj) do 11 | plugin_obj[k] = v 12 | end 13 | return plugin_obj 14 | end 15 | 16 | local function is_locked(self, chat_id) 17 | local red = self.red 18 | local hash = 'chat:'..chat_id..':settings' 19 | local current = red:hget(hash, 'Extra') 20 | if current == 'on' then 21 | return true 22 | end 23 | return false 24 | end 25 | 26 | local function sendMedia(self, chat_id, file_id, media, reply_to_message_id, caption) 27 | local api = self.api 28 | if not media then 29 | return false, "Media passed is not voice/video/photo" 30 | end 31 | local body = { 32 | chat_id = chat_id, 33 | [media] = file_id, 34 | caption = caption, 35 | reply_to_message_id = reply_to_message_id 36 | } 37 | local action = { 38 | audio = api.send_audio, 39 | voice = api.send_voice, 40 | video = api.send_video, 41 | photo = api.send_photo 42 | } 43 | Util.setDefaultTableValue(action, function() 44 | return false, "Media passed is not voice/video/photo" 45 | end) 46 | return action[media](api, body) 47 | end 48 | 49 | function _M:onTextMessage(blocks) 50 | local api = self.api 51 | local msg = self.message 52 | local u = self.u 53 | local red = self.red 54 | local i18n = self.i18n 55 | local api_err = self.api_err 56 | if msg.from.chat.type == 'private' and not(blocks[1] == 'start') then return end 57 | 58 | if blocks[1] == 'extra' then 59 | if not blocks[2] then return end 60 | if not blocks[3] and not msg.reply then return end 61 | if not msg.from:isAdmin() then return end 62 | 63 | if msg.reply and not blocks[3] then 64 | local file_id = msg.reply:get_file_id() 65 | if not file_id then 66 | return 67 | end 68 | local to_save = "###file_id###:"..file_id 69 | -- photos, voices, videos need their method to be sent by file_id 70 | local media_with_special_method = {"photo", "video", "voice",} 71 | for _, v in pairs(media_with_special_method) do 72 | if msg.reply:type() == v then 73 | to_save = '###file_id!'..v..'###:'..file_id 74 | end 75 | end 76 | red:hset('chat:'..msg.from.chat.id..':extra', blocks[2], to_save) 77 | msg:send_reply(i18n("This media has been saved as a response to %s"):format(blocks[2])) 78 | else 79 | local hash = 'chat:'..msg.from.chat.id..':extra' 80 | local new_extra = blocks[3] 81 | local reply_markup, test_text = u:reply_markup_from_text(u:replaceholders(new_extra, msg)) 82 | test_text = test_text:gsub('\n', '') 83 | 84 | local ok, err = msg:send_reply(test_text, "Markdown", reply_markup) 85 | if not ok then 86 | api:sendMessage(msg.from.chat.id, api_err:trans(err), "Markdown") 87 | return 88 | end 89 | red:hset(hash, blocks[2]:lower(), new_extra) 90 | local msg_id = ok.message_id 91 | api:editMessageText(msg.from.chat.id, msg_id, nil, i18n("Command '%s' saved!"):format(blocks[2])) 92 | end 93 | elseif blocks[1] == 'extra list' then 94 | local text = u:getExtraList(msg.from.chat.id) 95 | if not is_locked(self, msg.from.chat.id) and not msg.from:isAdmin() then 96 | api:sendMessage(msg.from.user.id, text) 97 | else 98 | msg:send_reply(text) 99 | end 100 | elseif blocks[1] == 'extra del' then 101 | if not msg.from:isAdmin() then return end 102 | local deleted, not_found, found = {}, {} 103 | local hash = 'chat:'..msg.from.chat.id..':extra' 104 | for extra in blocks[2]:gmatch('(#[%w_]+)') do 105 | found = red:hdel(hash, extra) 106 | if found == 1 then 107 | deleted[#deleted + 1] = extra 108 | else 109 | not_found[#not_found + 1] = extra 110 | end 111 | end 112 | if not next(deleted) then deleted[1] = '-' end 113 | local text = i18n("Commands deleted: `%s`"):format(table.concat(deleted, '`, `')) 114 | if next(not_found) then 115 | text = text..i18n('\nCommands not found: `%s`'):format(table.concat(not_found, '`, `')) 116 | end 117 | msg:send_reply(text, "Markdown") 118 | else 119 | local chat_id = blocks[1] == 'start' and tonumber(blocks[2]) or msg.from.chat.id 120 | local extra = blocks[1] == 'start' and '#'..blocks[3] or blocks[1] 121 | --print(chat_id, extra) 122 | local hash = 'chat:'..chat_id..':extra' 123 | local text = red:hget(hash, extra:lower()) 124 | if text == null then text = red:hget(hash, extra) end 125 | 126 | if text == null then return true end -- continue to match plugins 127 | 128 | local file_id = text:match('^###.+###:(.*)') 129 | local special_method = text:match('^###file_id!(.*)###') -- photo, voices, video need their method to be sent by file_id 130 | local link_preview = text:find('telegra%.ph/') == nil 131 | local _, err 132 | 133 | if msg.from.chat.id > 0 134 | or(is_locked(self, msg.from.chat.id) and not msg.from:isAdmin()) then -- send it in private 135 | if not file_id then 136 | local reply_markup, clean_text = u:reply_markup_from_text(u:replaceholders(text, msg.reply or msg)) 137 | _, err = api:sendMessage(msg.from.user.id, clean_text, "Markdown", link_preview, nil, nil, reply_markup) 138 | elseif special_method then 139 | _, err = sendMedia(self, msg.from.user.id, file_id, special_method) -- photo, voices, video need their method to be sent by file_id 140 | else 141 | _, err = api:sendDocument(msg.from.user.id, file_id) 142 | end 143 | else 144 | local msg_to_reply 145 | if msg.reply then 146 | msg_to_reply = msg.reply.message_id 147 | else 148 | msg_to_reply = msg.message_id 149 | end 150 | if file_id then 151 | if special_method then 152 | sendMedia(self, msg.from.chat.id, file_id, special_method, msg_to_reply) -- photo, voices, video need their method to be sent by file_id 153 | else 154 | api:sendDocument(msg.from.chat.id, file_id, nil, nil, msg_to_reply) 155 | end 156 | else 157 | local reply_markup, clean_text = u:reply_markup_from_text(u:replaceholders(text, msg.reply or msg)) 158 | api:sendMessage(msg.from.chat.id, clean_text, "Markdown", link_preview, nil, msg_to_reply, reply_markup) -- if the admin replies to a user, the bot will reply to the user too 159 | end 160 | end 161 | 162 | if err and err.error_code == 403 and msg.from.chat.id < 0 and not u:is_silentmode_on(msg.from.chat.id) then -- if the user haven't started the bot and silent mode is off 163 | msg:send_reply(i18n("_Please_ [start me](%s) _so I can send you the answer_") 164 | :format(u:deeplink_constructor(msg.from.chat.id, extra:sub(2, -1))), "Markdown") 165 | end 166 | end 167 | end 168 | 169 | _M.triggers = { 170 | onTextMessage = { 171 | config.cmd..'(extra)$', 172 | config.cmd..'(extra) (#[%w_]*) (.*)$', 173 | config.cmd..'(extra) (#[%w_]*)', 174 | config.cmd..'(extra del) (.+)$', 175 | config.cmd..'(extra list)$', 176 | '^/(start) (-?%d+)_([%w_]+)$', 177 | '^(#[%w_]+)$' 178 | } 179 | } 180 | 181 | return _M 182 | -------------------------------------------------------------------------------- /lua/groupbutler/plugins/floodmanager.lua: -------------------------------------------------------------------------------- 1 | local config = require "groupbutler.config" 2 | local null = require "groupbutler.null" 3 | local Chat = require("groupbutler.chat") 4 | local ChatMember = require("groupbutler.chatmember") 5 | 6 | local _M = {} 7 | 8 | function _M:new(update_obj) 9 | local plugin_obj = {} 10 | setmetatable(plugin_obj, {__index = self}) 11 | for k, v in pairs(update_obj) do 12 | plugin_obj[k] = v 13 | end 14 | return plugin_obj 15 | end 16 | 17 | local function get_button_description(self, key) 18 | local i18n = self.i18n 19 | if key == 'num' then 20 | return i18n("⚖ Current sensitivity. Tap on the + or the - to change it") 21 | elseif key == 'voice' then 22 | return i18n([[Choose which media must be ignored by the antiflood (the bot won't consider them). 23 | ✅: ignored 24 | ❌: not ignored]]) 25 | else 26 | return i18n("Description not available") 27 | end 28 | end 29 | 30 | local function do_keyboard_flood(self, chat_id) 31 | local red = self.red 32 | local i18n = self.i18n 33 | --no: enabled, yes: disabled 34 | local status = red:hget('chat:'..chat_id..':settings', 'Flood') 35 | if status == null then status = config.chat_settings['settings']['Flood'] end 36 | 37 | if status == 'on' then 38 | status = i18n("✅ | ON") 39 | else 40 | status = i18n("❌ | OFF") 41 | end 42 | 43 | local hash = 'chat:'..chat_id..':flood' 44 | local action = red:hget(hash, 'ActionFlood') 45 | if action == null then action = config.chat_settings['flood']['ActionFlood'] end 46 | if action == 'kick' then 47 | action = i18n("👞️ kick") 48 | elseif action == 'ban' then 49 | action = i18n("🔨 ️ban") 50 | elseif action == 'mute' then 51 | action = i18n("👁 mute") 52 | end 53 | local num = tonumber(red:hget(hash, 'MaxFlood')) or config.chat_settings['flood']['MaxFlood'] 54 | local keyboard = { 55 | inline_keyboard = { 56 | { 57 | {text = status, callback_data = 'flood:status:'..chat_id}, 58 | {text = action, callback_data = 'flood:action:'..chat_id}, 59 | }, 60 | { 61 | {text = '➖', callback_data = 'flood:dim:'..chat_id}, 62 | {text = tostring(num), callback_data = 'flood:alert:num'}, 63 | {text = '➕', callback_data = 'flood:raise:'..chat_id}, 64 | } 65 | } 66 | } 67 | 68 | local exceptions = { 69 | text = i18n("Texts"), 70 | forward = i18n("Forwards"), 71 | sticker = i18n("Stickers"), 72 | photo = i18n("Images"), 73 | gif = i18n("GIFs"), 74 | video = i18n("Videos"), 75 | } 76 | 77 | hash = 'chat:'..chat_id..':floodexceptions' 78 | for media, translation in pairs(exceptions) do 79 | --ignored by the antiflood-> yes, no 80 | local exc_status = red:hget(hash, media) 81 | if exc_status == null then exc_status = config.chat_settings['floodexceptions'][media] end 82 | 83 | if exc_status == 'yes' then 84 | exc_status = '✅' 85 | else 86 | exc_status = '❌' 87 | end 88 | local line = { 89 | {text = translation, callback_data = 'flood:alert:voice'}, 90 | {text = exc_status, callback_data = 'flood:exc:'..media..':'..chat_id}, 91 | } 92 | table.insert(keyboard.inline_keyboard, line) 93 | end 94 | 95 | --back button 96 | table.insert(keyboard.inline_keyboard, {{text = '🔙', callback_data = 'config:back:'..chat_id}}) 97 | 98 | return keyboard 99 | end 100 | 101 | local function changeFloodSettings(self, chat_id, screm) 102 | local red = self.red 103 | local i18n = self.i18n 104 | local hash = 'chat:'..chat_id..':flood' 105 | if type(screm) == 'string' then 106 | if screm == 'mute' then 107 | red:hset(hash, 'ActionFlood', 'ban') 108 | return i18n("Flooders will be banned") 109 | elseif screm == 'ban' then 110 | red:hset(hash, 'ActionFlood', 'kick') 111 | return i18n("Flooders will be kicked") 112 | elseif screm == 'kick' then 113 | red:hset(hash, 'ActionFlood', 'mute') 114 | return i18n("Flooders will be muted") 115 | end 116 | elseif type(screm) == 'number' then 117 | local old = tonumber(red:hget(hash, 'MaxFlood')) or 5 118 | local new 119 | if screm > 0 then 120 | new = red:hincrby(hash, 'MaxFlood', 1) 121 | if new > 25 then 122 | red:hincrby(hash, 'MaxFlood', -1) 123 | return i18n("%d is not a valid value!\n"):format(new) 124 | .. ("The value should be higher than 3 and lower then 26") 125 | end 126 | elseif screm < 0 then 127 | new = red:hincrby(hash, 'MaxFlood', -1) 128 | if new < 3 then 129 | red:hincrby(hash, 'MaxFlood', 1) 130 | return i18n("%d is not a valid value!\n"):format(new) 131 | .. ("The value should be higher than 2 and lower then 26") 132 | end 133 | end 134 | return string.format('%d → %d', old, new) 135 | end 136 | end 137 | 138 | function _M:onCallbackQuery(blocks) 139 | local api = self.api 140 | local msg = self.message 141 | local u = self.u 142 | local red = self.red 143 | local i18n = self.i18n 144 | 145 | if blocks[1] == "alert" then 146 | local text = get_button_description(self, blocks[2]) 147 | api:answerCallbackQuery(msg.cb_id, text, true, config.bot_settings.cache_time.alert_help) 148 | return 149 | end 150 | 151 | local member = ChatMember:new({ 152 | chat = Chat:new({id=msg.target_id}, self), 153 | user = msg.from.user, 154 | }, self) 155 | 156 | if not member:can("can_change_info") then 157 | api:answerCallbackQuery(msg.cb_id, i18n("Sorry, you don't have permission to change settings")) 158 | return 159 | end 160 | 161 | local header = i18n([[You can manage the antiflood settings from here. 162 | 163 | It is also possible to choose which type of messages the antiflood will ignore (✅)]]) 164 | 165 | local text 166 | 167 | if blocks[1] == "config" then 168 | text = i18n("Antiflood settings") 169 | end 170 | 171 | if blocks[1] == "exc" then 172 | local media = blocks[2] 173 | local hash = "chat:"..member.chat.id..":floodexceptions" 174 | local status = red:hget(hash, media) 175 | if status == "no" then 176 | red:hset(hash, media, "yes") 177 | text = i18n("❎ [%s] will be ignored by the anti-flood"):format(media) 178 | else 179 | red:hset(hash, media, "no") 180 | text = i18n("🚫 [%s] won't be ignored by the anti-flood"):format(media) 181 | end 182 | end 183 | 184 | local action 185 | if blocks[1] == "action" or blocks[1] == "dim" or blocks[1] == "raise" then 186 | if blocks[1] == "action" then 187 | action = red:hget("chat:"..member.chat.id..":flood", "ActionFlood") 188 | if action == null then action = config.chat_settings.flood.ActionFlood end 189 | elseif blocks[1] == "dim" then 190 | action = -1 191 | elseif blocks[1] == "raise" then 192 | action = 1 193 | end 194 | text = changeFloodSettings(self, member.chat.id, action) 195 | end 196 | 197 | if blocks[1] == "status" then 198 | text = u:changeSettingStatus(member.chat.id, "Flood") 199 | end 200 | 201 | local keyboard = do_keyboard_flood(self, member.chat.id) 202 | api:editMessageText(msg.from.chat.id, msg.message_id, nil, header, "Markdown", nil, keyboard) 203 | api:answerCallbackQuery(msg.cb_id, text) 204 | end 205 | 206 | _M.triggers = { 207 | onCallbackQuery = { 208 | '^###cb:flood:(alert):([%w_]+):([%w_]+)$', 209 | '^###cb:flood:(status):(-?%d+)$', 210 | '^###cb:flood:(action):(-?%d+)$', 211 | '^###cb:flood:(dim):(-?%d+)$', 212 | '^###cb:flood:(raise):(-?%d+)$', 213 | '^###cb:flood:(exc):(%a+):(-?%d+)$', 214 | 215 | '^###cb:(config):antiflood:' 216 | } 217 | } 218 | 219 | return _M 220 | -------------------------------------------------------------------------------- /lua/groupbutler/plugins/links.lua: -------------------------------------------------------------------------------- 1 | local config = require "groupbutler.config" 2 | local null = require "groupbutler.null" 3 | 4 | local _M = {} 5 | 6 | function _M:new(update_obj) 7 | local plugin_obj = {} 8 | setmetatable(plugin_obj, {__index = self}) 9 | for k, v in pairs(update_obj) do 10 | plugin_obj[k] = v 11 | end 12 | return plugin_obj 13 | end 14 | 15 | function _M:onTextMessage(blocks) 16 | local msg = self.message 17 | local red = self.red 18 | local i18n = self.i18n 19 | 20 | if msg.from.chat.type == "private" 21 | or not msg.from:isAdmin() then 22 | return 23 | end 24 | 25 | local hash = 'chat:'..msg.from.chat.id..':links' 26 | local text 27 | 28 | if blocks[1] == 'link' then 29 | if msg.from.chat.username then 30 | red:sadd('chat:'..msg.from.chat.id..':whitelist', 'telegram.me/'..msg.from.chat.username) 31 | local title = msg.from.chat.title:escape_hard('link') 32 | msg:send_reply(string.format('[%s](telegram.me/%s)', title, msg.from.chat.username), "Markdown") 33 | else 34 | local link = red:hget(hash, 'link') 35 | if link == null then 36 | text = i18n("*No link* for this group. Ask the owner to save it with `/setlink [group link]`") 37 | else 38 | local title = msg.from.chat.title:escape_hard('link') 39 | text = string.format('[%s](%s)', title, link) 40 | end 41 | msg:send_reply(text, "Markdown") 42 | end 43 | end 44 | if blocks[1] == 'setlink' then 45 | if blocks[2] and blocks[2] == '-' then 46 | red:hdel(hash, 'link') 47 | text = i18n("Link *unset*") 48 | else 49 | local link 50 | if msg.from.chat.username then 51 | link = 'https://telegram.me/'..msg.from.chat.username 52 | local substitution = '['..msg.from.chat.title:escape_hard('link')..']('..link..')' 53 | text = i18n("The link has been set.\n*Here's the link*: %s"):format(substitution) 54 | else 55 | if not blocks[2] then 56 | text = i18n("This is not a *public supergroup*, so you need to write the link near `/setlink`") 57 | else 58 | if string.len(blocks[2]) ~= 22 and blocks[2] ~= '-' then 59 | text = i18n("This link is *not valid!*") 60 | else 61 | link = 'https://telegram.me/joinchat/'..blocks[2] 62 | red:sadd('chat:'..msg.from.chat.id..':whitelist', link:gsub('https://', '')) --save the link in the whitelist 63 | 64 | local succ = red:hset(hash, 'link', link) 65 | local title = msg.from.chat.title:escape_hard('link') 66 | local substitution = '['..title..']('..link..')' 67 | if not succ then 68 | text = i18n("The link has been updated.\n*Here's the new link*: %s"):format(substitution) 69 | else 70 | text = i18n("The link has been set.\n*Here's the link*: %s"):format(substitution) 71 | end 72 | end 73 | end 74 | end 75 | end 76 | msg:send_reply(text, "Markdown") 77 | end 78 | end 79 | 80 | _M.triggers = { 81 | onTextMessage = { 82 | config.cmd..'(link)$', 83 | config.cmd..'(setlink)$', 84 | config.cmd..'(setlink) https://telegram%.me/joinchat/(.*)', 85 | config.cmd..'(setlink) https://t%.me/joinchat/(.*)', 86 | config.cmd..'(setlink) (-)' 87 | } 88 | } 89 | 90 | return _M 91 | -------------------------------------------------------------------------------- /lua/groupbutler/plugins/logchannel.lua: -------------------------------------------------------------------------------- 1 | local config = require "groupbutler.config" 2 | local null = require "groupbutler.null" 3 | local Chat = require("groupbutler.chat") 4 | local ChatMember = require("groupbutler.chatmember") 5 | local Util = require("groupbutler.util") 6 | 7 | local _M = {} 8 | 9 | function _M:new(update_obj) 10 | local plugin_obj = {} 11 | setmetatable(plugin_obj, {__index = self}) 12 | for k, v in pairs(update_obj) do 13 | plugin_obj[k] = v 14 | end 15 | return plugin_obj 16 | end 17 | 18 | local function get_alert_text(self, key) 19 | local i18n = self.i18n 20 | local alert_text = { 21 | new_chat_member = i18n("Log every time a user join the group"), 22 | ban = i18n("Bans will be logged. I can't log manual bans"), 23 | kick = i18n("Kicks will be logged. I can't log manual kicks"), 24 | warn = i18n("Manual warns will be logged"), 25 | mediawarn = i18n("Forbidden media will be logged in the channel"), 26 | spamwarn = i18n("Spam links/forwards from channels will be logged in the channel, only if forbidden"), 27 | flood = i18n("Log when a user is flooding (new log message every 5 flood messages)"), 28 | new_chat_photo = i18n("Log when an admin changes the group icon"), 29 | delete_chat_photo = i18n("Log when an admin deletes the group icon"), 30 | new_chat_title = i18n("Log when an admin change the group title"), 31 | pinned_message = i18n("Log pinned messages"), 32 | blockban = i18n("Log when a user who has been blocked is banned from the group on join"), 33 | nowarn = i18n("Log when an admin removes the warning received by a user"), 34 | report = i18n("Log when a user reports a message with the @admin command"), 35 | } Util.setDefaultTableValue(alert_text, i18n("Description not available")) 36 | 37 | return alert_text[key] 38 | end 39 | 40 | local function toggle_event(self, chat_id, event) 41 | local red = self.red 42 | local hash = ('chat:%s:tolog'):format(chat_id) 43 | local current_status = red:hget(hash, event) 44 | if current_status == null then current_status = config.chat_settings['tolog'][event] end 45 | 46 | if current_status == 'yes' then 47 | red:hset(hash, event, 'no') 48 | else 49 | red:hset(hash, event, 'yes') 50 | end 51 | end 52 | 53 | local function doKeyboard_logchannel(self, chat_id) 54 | local red = self.red 55 | local i18n = self.i18n 56 | local event_pretty = { 57 | ['ban'] = i18n('Ban'), 58 | ['kick'] = i18n('Kick'), 59 | ['unban'] = i18n('Unban'), 60 | ['tempban'] = i18n('Tempban'), 61 | ['report'] = i18n('Report'), 62 | ['warn'] = i18n('Warns'), 63 | ['nowarn'] = i18n('Warns resets'), 64 | ['new_chat_member'] = i18n('New members'), 65 | ['mediawarn'] = i18n('Media warns'), 66 | ['spamwarn'] = i18n('Spam warns'), 67 | ['flood'] = i18n('Flood'), 68 | ['new_chat_photo'] = i18n('New group icon'), 69 | ['delete_chat_photo'] = i18n('Group icon removed'), 70 | ['new_chat_title'] = i18n('New group title'), 71 | ['pinned_message'] = i18n('Pinned messages'), 72 | ['blockban'] = i18n("Users blocked and banned"), 73 | ['block'] = i18n("Users blocked"), 74 | ['unblock'] = i18n("Users unblocked") 75 | } 76 | 77 | local keyboard = {inline_keyboard={}} 78 | local icon 79 | 80 | for event, default_status in pairs(config.chat_settings['tolog']) do 81 | local current_status = red:hget('chat:'..chat_id..':tolog', event) 82 | if current_status == null then current_status = default_status end 83 | 84 | icon = '✅' 85 | if current_status == 'no' then icon = '☑️' end 86 | table.insert(keyboard.inline_keyboard, 87 | { 88 | {text = event_pretty[event] or event, callback_data = 'logchannel:alert:'..event}, 89 | {text = icon, callback_data = 'logchannel:toggle:'..event..':'..chat_id} 90 | }) 91 | end 92 | 93 | --back button 94 | table.insert(keyboard.inline_keyboard, {{text = '🔙', callback_data = 'config:back:'..chat_id}}) 95 | 96 | return keyboard 97 | end 98 | 99 | function _M:onCallbackQuery(blocks) 100 | local api = self.api 101 | local msg = self.message 102 | local i18n = self.i18n 103 | 104 | if blocks[1] == 'alert' then 105 | local text = get_alert_text(self, blocks[2]) 106 | api:answerCallbackQuery(msg.cb_id, text, true, config.bot_settings.cache_time.alert_help) 107 | return 108 | end 109 | 110 | local chat = Chat:new({id=msg.target_id}, self) 111 | 112 | if blocks[1] == 'logcb' then 113 | if not msg.from:isAdmin() then 114 | api:answerCallbackQuery(msg.cb_id, i18n("You are not admin of this group"), true) 115 | return 116 | end 117 | if blocks[2] == 'unban' or blocks[2] == 'untempban' then 118 | local user_id = blocks[3] 119 | api:unbanChatMember(chat.id, user_id) 120 | api:answerCallbackQuery(msg.cb_id, i18n("User unbanned!"), true) 121 | end 122 | return 123 | end 124 | 125 | local member = ChatMember:new({ 126 | chat = chat, 127 | user = msg.from.user, 128 | }, self) 129 | 130 | if not member:can("can_change_info") then 131 | api:answerCallbackQuery(msg.cb_id, i18n("Sorry, you don't have permission to change settings")) 132 | return 133 | end 134 | 135 | local text 136 | 137 | if blocks[1] == 'toggle' then 138 | toggle_event(self, chat.id, blocks[2]) 139 | text = '👌🏼' 140 | end 141 | 142 | local reply_markup = doKeyboard_logchannel(self, chat.id) 143 | if blocks[1] == 'config' then 144 | local logchannel_first = i18n([[*Select the events the will be logged in the channel* 145 | ✅ = will be logged 146 | ☑️ = won't be logged 147 | 148 | Tap on an option to get further information]]) 149 | api:editMessageText(msg.from.chat.id, msg.message_id, nil, logchannel_first, "Markdown", true, reply_markup) 150 | else 151 | api:editMessageReplyMarkup(msg.from.chat.id, msg.message_id, nil, reply_markup) 152 | end 153 | 154 | if text then 155 | api:answerCallbackQuery(msg.cb_id, text) 156 | end 157 | end 158 | 159 | function _M:onTextMessage(blocks) 160 | local api = self.api 161 | local msg = self.message 162 | local red = self.red 163 | local i18n = self.i18n 164 | 165 | if msg.from.chat.type ~= 'private' then 166 | if not msg.from:isAdmin() then return end 167 | 168 | if blocks[1] == 'setlog' then 169 | if msg.forward_from_chat then 170 | if msg.forward_from_chat.type == 'channel' then 171 | if not msg.forward_from_chat.username then 172 | local ok, err = api:getChatMember(msg.forward_from_chat.id, msg.from.user.id) 173 | if not ok then 174 | if err.error_code == 429 then 175 | msg:send_reply(i18n('_Too many requests. Retry later_'), "Markdown") 176 | else 177 | msg:send_reply(i18n('_I need to be admin in the channel_'), "Markdown") 178 | end 179 | else 180 | if ok.status == 'creator' then 181 | local text 182 | local old_log = red:hget('bot:chatlogs', msg.from.chat.id) 183 | if old_log == tostring(msg.forward_from_chat.id) then 184 | text = i18n('_Already using this channel_') 185 | else 186 | red:hset('bot:chatlogs', msg.from.chat.id, msg.forward_from_chat.id) 187 | text = i18n('*Log channel added!*') 188 | if old_log then 189 | api:sendMessage(old_log, 190 | i18n("%s changed its log channel"):format(msg.from.chat.title:escape_html()), 'html') 191 | end 192 | api:sendMessage(msg.forward_from_chat.id, 193 | i18n("Logs of %s will be posted here"):format(msg.from.chat.title:escape_html()), 'html') 194 | end 195 | msg:send_reply(text, "Markdown") 196 | else 197 | msg:send_reply(i18n('_Only the channel creator can pair the chat with a channel_'), "Markdown") 198 | end 199 | end 200 | else 201 | msg:send_reply(i18n('_I\'m sorry, only private channels are supported for now_'), "Markdown") 202 | end 203 | end 204 | else 205 | msg:send_reply(i18n("You have to *forward* the message from the channel"), "Markdown") 206 | end 207 | elseif blocks[1] == 'unsetlog' then 208 | local log_channel = red:hget('bot:chatlogs', msg.from.chat.id) 209 | if log_channel == null then 210 | msg:send_reply(i18n("_This groups is not using a log channel_"), "Markdown") 211 | else 212 | red:hdel('bot:chatlogs', msg.from.chat.id) 213 | msg:send_reply(i18n("*Log channel removed*"), "Markdown") 214 | end 215 | elseif blocks[1] == 'logchannel' then 216 | local log_channel = red:hget('bot:chatlogs', msg.from.chat.id) 217 | if log_channel == null then 218 | msg:send_reply(i18n("_This groups is not using a log channel_"), "Markdown") 219 | else 220 | local channel_info, err = api:getChat(log_channel) 221 | if not channel_info and err.error_code == 403 then 222 | msg:send_reply( 223 | i18n("_This group has a log channel saved, but I'm not a member there, so I can't post/retrieve its info_"), 224 | "Markdown") 225 | else 226 | local channel_identifier = log_channel 227 | if channel_info and channel_info then 228 | channel_identifier = channel_info.title 229 | end 230 | msg:send_reply(i18n('This group has a log channel\nChannel: %s') 231 | :format(channel_identifier:escape_html()), 'html') 232 | end 233 | end 234 | end 235 | elseif blocks[1] == 'photo' then 236 | api:sendPhoto(msg.from.chat.id, blocks[2]) 237 | end 238 | end 239 | 240 | _M.triggers = { 241 | onTextMessage = { 242 | config.cmd..'(setlog)$', 243 | config.cmd..'(unsetlog)$', 244 | config.cmd..'(logchannel)$', 245 | 246 | --deeplinking from log buttons 247 | '^/start (photo)_(.*)$' 248 | }, 249 | onCallbackQuery = { 250 | --callbacks from the log channel 251 | '^###cb:(logcb):(%w-):(%d+):(-%d+)$', 252 | 253 | --callbacks from the configuration keyboard 254 | '^###cb:logchannel:(toggle):([%w_]+):(-?%d+)$', 255 | '^###cb:logchannel:(alert):([%w_]+)$', 256 | '^###cb:(config):logchannel:(-?%d+)$' 257 | } 258 | } 259 | 260 | return _M 261 | -------------------------------------------------------------------------------- /lua/groupbutler/plugins/mediasettings.lua: -------------------------------------------------------------------------------- 1 | local config = require "groupbutler.config" 2 | local null = require "groupbutler.null" 3 | local Chat = require("groupbutler.chat") 4 | local ChatMember = require("groupbutler.chatmember") 5 | 6 | local _M = {} 7 | 8 | function _M:new(update_obj) 9 | local plugin_obj = {} 10 | setmetatable(plugin_obj, {__index = self}) 11 | for k, v in pairs(update_obj) do 12 | plugin_obj[k] = v 13 | end 14 | return plugin_obj 15 | end 16 | 17 | local function doKeyboard_media(self, chat_id) 18 | local red = self.red 19 | local i18n = self.i18n 20 | local keyboard = {} 21 | keyboard.inline_keyboard = {} 22 | for media, default_status in pairs(config.chat_settings['media']) do 23 | local status = red:hget('chat:'..chat_id..':media', media) 24 | if status == null then status = default_status end 25 | 26 | if status == 'ok' then 27 | status = '✅' 28 | elseif status == 'notok' then 29 | status = '❌' 30 | elseif status == 'del' then 31 | status = '🗑' 32 | end 33 | 34 | local media_texts = { 35 | photo = i18n("Images"), 36 | gif = i18n("GIFs"), 37 | video = i18n("Videos"), 38 | video_note = i18n("Video messages"), 39 | document = i18n("Documents"), 40 | --TGlink = i18n("telegram.me links"), 41 | voice = i18n("Vocal messages"), 42 | link = i18n("Links"), 43 | audio = i18n("Music"), 44 | sticker = i18n("Stickers"), 45 | contact = i18n("Contacts"), 46 | game = i18n("Games"), 47 | location = i18n("Locations"), 48 | venue = i18n("Venues"), 49 | } 50 | local media_text = media_texts[media] or media 51 | local line = { 52 | {text = media_text, callback_data = 'mediallert'}, 53 | {text = status, callback_data = 'media:'..media..':'..chat_id} 54 | } 55 | table.insert(keyboard.inline_keyboard, line) 56 | end 57 | 58 | --MEDIA WARN 59 | --action line 60 | local max = red:hget('chat:'..chat_id..':warnsettings', 'mediamax') 61 | if max == null then max = config.chat_settings['warnsettings']['mediamax'] end 62 | local action = red:hget('chat:'..chat_id..':warnsettings', 'mediatype') 63 | if action == null then action = config.chat_settings['warnsettings']['mediatype'] end 64 | 65 | local caption 66 | if action == 'kick' then 67 | caption = i18n("Warnings | %d | kick"):format(tonumber(max)) 68 | elseif action == 'mute' then 69 | caption = i18n("Warnings | %d | mute"):format(tonumber(max)) 70 | else 71 | caption = i18n("Warnings | %d | ban"):format(tonumber(max)) 72 | end 73 | table.insert(keyboard.inline_keyboard, {{text = caption, callback_data = 'mediatype:'..chat_id}}) 74 | --buttons line 75 | local warn = { 76 | {text = '➖', callback_data = 'mediawarn:dim:'..chat_id}, 77 | {text = '➕', callback_data = 'mediawarn:raise:'..chat_id}, 78 | } 79 | table.insert(keyboard.inline_keyboard, warn) 80 | 81 | --back button 82 | table.insert(keyboard.inline_keyboard, {{text = '🔙', callback_data = 'config:back:'..chat_id}}) 83 | 84 | return keyboard 85 | end 86 | 87 | local function change_media_status(self, chat_id, media) 88 | local red = self.red 89 | local i18n = self.i18n 90 | local hash = ('chat:%s:media'):format(chat_id) 91 | local status = red:hget(hash, media) 92 | if status == null then status = config.chat_settings.media[media] end 93 | 94 | if status == 'ok' then 95 | red:hset(hash, media, 'notok') 96 | return i18n('❌ warning') 97 | elseif status == 'notok' then 98 | red:hset(hash, media, 'del') 99 | return i18n('🗑 delete') 100 | elseif status == 'del' then 101 | red:hset(hash, media, 'ok') 102 | return '' 103 | else 104 | red:hset(hash, media, 'ok') 105 | return i18n('✅ allowed') 106 | end 107 | end 108 | 109 | function _M:onCallbackQuery(blocks) 110 | local api = self.api 111 | local msg = self.message 112 | local red = self.red 113 | local i18n = self.i18n 114 | 115 | if blocks[1] == "mediallert" then 116 | api:answerCallbackQuery(msg.cb_id, i18n("⚠️ Tap on the right column"), false, 117 | config.bot_settings.cache_time.alert_help) 118 | return 119 | end 120 | 121 | local member = ChatMember:new({ 122 | chat = Chat:new({id=msg.target_id}, self), 123 | user = msg.from.user, 124 | }, self) 125 | 126 | if not member:can("can_change_info") then 127 | api:answerCallbackQuery(msg.cb_id, i18n("Sorry, you don't have permission to change settings")) 128 | return 129 | end 130 | 131 | local media_first = i18n([[Tap on an option on the right to *change the setting* 132 | You can use the last lines to change how many warnings the bot should give before kicking/banning/muting someone. 133 | The number is not related the the normal `/warn` command. 134 | 135 | Possible statuses: ✅ allowed, ❌ warning, 🗑 delete. 136 | When a media is set to delete, the bot will give a warning *only* when this is the users last warning]]) 137 | 138 | if blocks[1] == "config" then 139 | local keyboard = doKeyboard_media(self, member.chat.id) 140 | api:editMessageText(msg.from.chat.id, msg.message_id, nil, media_first, "Markdown", nil, keyboard) 141 | return 142 | end 143 | 144 | local cb_text 145 | if blocks[1] == "mediawarn" then 146 | local current = tonumber(red:hget("chat:"..member.chat.id..":warnsettings", "mediamax")) or 2 147 | if blocks[2] == "dim" then 148 | if current < 2 then 149 | cb_text = i18n("⚙ The new value is too low ( < 1)") 150 | else 151 | local new = red:hincrby("chat:"..member.chat.id..":warnsettings", "mediamax", -1) 152 | cb_text = string.format("⚙ %d → %d", current, new) 153 | end 154 | elseif blocks[2] == "raise" then 155 | if current > 11 then 156 | cb_text = i18n("⚙ The new value is too high ( > 12)") 157 | else 158 | local new = red:hincrby("chat:"..member.chat.id..":warnsettings", "mediamax", 1) 159 | cb_text = string.format("⚙ %d → %d", current, new) 160 | end 161 | end 162 | end 163 | 164 | if blocks[1] == "mediatype" then 165 | local hash = "chat:"..member.chat.id..":warnsettings" 166 | local current = red:hget(hash, "mediatype") 167 | if current == null then current = config.chat_settings["warnsettings"]["mediatype"] end 168 | 169 | if current == "ban" then 170 | red:hset(hash, "mediatype", "kick") 171 | cb_text = i18n("👞 New status is kick") 172 | elseif current == "kick" then 173 | red:hset(hash, "mediatype", "mute") 174 | cb_text = i18n("👁 New status is mute") 175 | elseif current == "mute" then 176 | red:hset(hash, "mediatype", "ban") 177 | cb_text = i18n("🔨 New status is ban") 178 | end 179 | end 180 | 181 | if blocks[1] == "media" then 182 | local media = blocks[2] 183 | cb_text = change_media_status(self, member.chat.id, media) 184 | end 185 | 186 | local keyboard = doKeyboard_media(self, member.chat.id) 187 | api:editMessageText(msg.from.chat.id, msg.message_id, nil, media_first, "Markdown", nil, keyboard) 188 | api:answerCallbackQuery(msg.cb_id, cb_text) 189 | end 190 | 191 | _M.triggers = { 192 | onCallbackQuery = { 193 | '^###cb:(media):([%a_]+):(-?%d+)', 194 | '^###cb:(mediatype):(-?%d+)', 195 | '^###cb:(mediawarn):(%a+):(-?%d+)', 196 | '^###cb:(mediallert)$', 197 | 198 | '^###cb:(config):media:(-?%d+)$' 199 | } 200 | } 201 | 202 | return _M 203 | -------------------------------------------------------------------------------- /lua/groupbutler/plugins/pin.lua: -------------------------------------------------------------------------------- 1 | local config = require "groupbutler.config" 2 | local null = require "groupbutler.null" 3 | 4 | local _M = {} 5 | 6 | function _M:new(update_obj) 7 | local plugin_obj = {} 8 | setmetatable(plugin_obj, {__index = self}) 9 | for k, v in pairs(update_obj) do 10 | plugin_obj[k] = v 11 | end 12 | return plugin_obj 13 | end 14 | 15 | local function get_reply_markup(self, msg, text) 16 | return self.u:reply_markup_from_text(self.u:replaceholders(text, msg, "rules", "title")) 17 | end 18 | 19 | local function pin_message(self, chat_id, message_id) 20 | self.red:set("chat:"..chat_id..":pin", message_id) 21 | return self.api:pin_chat_message(chat_id, message_id, self.u:is_silentmode_on(chat_id)) 22 | end 23 | 24 | local function new_pin(self, msg, pin_text) 25 | local api_err = self.api_err 26 | local reply_markup, text = get_reply_markup(self, msg, pin_text) 27 | local ok, err = self.api:send_message{ 28 | chat_id = msg.from.chat.id, 29 | text = text, 30 | parse_mode = "Markdown", 31 | disable_web_page_preview = true, 32 | reply_markup = reply_markup 33 | } 34 | 35 | if not ok then 36 | msg:send_reply(api_err:trans(err), "Markdown") 37 | return 38 | end 39 | 40 | pin_message(self, msg.from.chat.id, ok.message_id) 41 | return 42 | end 43 | 44 | local function edit_pin(self, msg, pin_text) 45 | local api_err = self.api_err 46 | local pin_id = self.red:get("chat:"..msg.from.chat.id..":pin") 47 | if pin_id == null then 48 | new_pin(self, msg, pin_text) 49 | return 50 | end 51 | local reply_markup, text = get_reply_markup(self, msg, pin_text) 52 | local ok, err = self.api:edit_message_text{ 53 | chat_id = msg.from.chat.id, 54 | message_id = pin_id, 55 | text = text, 56 | parse_mode = "Markdown", 57 | disable_web_page_preview = true, 58 | reply_markup = reply_markup 59 | } 60 | if not ok then 61 | if err.description:lower():match("message to edit not found") then 62 | new_pin(self, msg, pin_text) 63 | return 64 | end 65 | msg:send_reply(api_err:trans(err), "Markdown") 66 | return 67 | end 68 | pin_message(self, msg.from.chat.id, ok.message_id) 69 | return 70 | end 71 | 72 | local function last_pin(self, msg) 73 | local i18n = self.i18n 74 | local pin_id = self.red:get("chat:"..msg.from.chat.id..":pin") 75 | if pin_id == null then 76 | msg:send_reply(i18n("I couldn't find any message generated by /pin."), "html") 77 | return 78 | end 79 | local ok, err = self.api:send_message{ 80 | chat_id = msg.from.chat.id, 81 | text = i18n("Last message generated by /pin ^"), 82 | parse_mode = "html", 83 | reply_to_message_id = pin_id, 84 | } 85 | if not ok and err.description:lower():match("reply message not found") then 86 | msg:send_reply(i18n("The old message generated with /pin does not exist anymore."), "html") 87 | self.red:del("chat:"..msg.from.chat.id..":pin") 88 | return 89 | end 90 | end 91 | 92 | function _M:onTextMessage(blocks) 93 | local msg = self.message 94 | 95 | if msg.from.chat.type == "private" 96 | or not msg.from:isAdmin() then 97 | return 98 | end 99 | 100 | local pin_text = blocks[2] 101 | if msg.reply_to_message and msg.reply_to_message.text then 102 | pin_text = msg.reply_to_message.text 103 | end 104 | 105 | if not pin_text then 106 | last_pin(self, msg) 107 | return 108 | end 109 | 110 | if blocks[1] == "newpin" then 111 | new_pin(self, msg, pin_text) 112 | return 113 | end 114 | 115 | edit_pin(self, msg, pin_text) 116 | return 117 | end 118 | 119 | _M.triggers = { 120 | onTextMessage = { 121 | config.cmd..'(pin)$', 122 | config.cmd..'(pin) (.*)$', 123 | config.cmd..'(newpin) (.*)$' 124 | } 125 | } 126 | 127 | return _M 128 | -------------------------------------------------------------------------------- /lua/groupbutler/plugins/private.lua: -------------------------------------------------------------------------------- 1 | local config = require "groupbutler.config" 2 | 3 | local _M = {} 4 | 5 | function _M:new(update_obj) 6 | local plugin_obj = {} 7 | setmetatable(plugin_obj, {__index = self}) 8 | for k, v in pairs(update_obj) do 9 | plugin_obj[k] = v 10 | end 11 | return plugin_obj 12 | end 13 | 14 | local function bot_version(self) 15 | local i18n = self.i18n 16 | if not config.commit or (config.commit):len() ~= 40 then 17 | return i18n("unknown") 18 | end 19 | return ("[%s](%s/commit/%s)"):format(string.sub(config.commit, 1, 7), config.source_code, config.commit) 20 | end 21 | 22 | local function strings(self) 23 | local i18n = self.i18n 24 | return { 25 | about = i18n([[This bot is based on [otouto](https://github.com/topkecleon/otouto) (AKA @mokubot, channel: @otouto), a multipurpose Lua bot. 26 | Group Butler wouldn't exist without it. 27 | 28 | You can contact the owners of this bot using the /groups command. 29 | 30 | Bot version: %s 31 | *Some useful links:*]]):format(bot_version(self)) 32 | } 33 | end 34 | 35 | local function do_keyboard_credits(self) 36 | local bot = self.bot 37 | local i18n = self.i18n 38 | local keyboard = {} 39 | keyboard.inline_keyboard = { 40 | { 41 | {text = i18n("Channel"), url = 'https://telegram.me/'..config.channel:gsub('@', '')}, 42 | {text = i18n("GitHub"), url = config.source_code}, 43 | {text = i18n("Rate me!"), url = 'https://telegram.me/storebot?start='..bot.username}, 44 | }, 45 | { 46 | {text = i18n("👥 Groups"), callback_data = 'private:groups'} 47 | } 48 | } 49 | return keyboard 50 | end 51 | 52 | function _M:onTextMessage(blocks) 53 | local api = self.api 54 | local msg = self.message 55 | local i18n = self.i18n 56 | local api_err = self.api_err 57 | 58 | if msg.from.chat.type ~= 'private' then return end 59 | 60 | if blocks[1] == 'ping' then 61 | api:sendMessage(msg.from.user.id, i18n("Pong!"), "Markdown") 62 | end 63 | if blocks[1] == 'echo' then 64 | local ok, err = api:sendMessage(msg.from.chat.id, blocks[2], "Markdown") 65 | if not ok then 66 | api:sendMessage(msg.from.chat.id, api_err:trans(err), "Markdown") 67 | end 68 | end 69 | if blocks[1] == 'about' then 70 | local keyboard = do_keyboard_credits(self) 71 | api:sendMessage(msg.from.chat.id, strings(self).about, "Markdown", true, nil, nil, keyboard) 72 | end 73 | if blocks[1] == 'group' then 74 | if config.help_group and config.help_group ~= '' then 75 | api:sendMessage(msg.from.chat.id, 76 | i18n('You can find the list of our support groups in [this channel](%s)'):format(config.help_group), "Markdown") 77 | end 78 | end 79 | end 80 | 81 | function _M:onCallbackQuery(blocks) 82 | local api = self.api 83 | local msg = self.message 84 | local i18n = self.i18n 85 | 86 | if blocks[1] == 'about' then 87 | local keyboard = do_keyboard_credits(self) 88 | api:editMessageText(msg.from.chat.id, msg.message_id, nil, strings(self).about, "Markdown", true, keyboard) 89 | end 90 | if blocks[1] == 'group' then 91 | if config.help_group and config.help_group ~= '' then 92 | local markup = {inline_keyboard={{{text = i18n('🔙 back'), callback_data = 'fromhelp:about'}}}} 93 | api:editMessageText(msg.from.chat.id, msg.message_id, nil, 94 | i18n("You can find the list of our support groups in [this channel](%s)"):format(config.help_group), 95 | "Markdown", nil, markup) 96 | end 97 | end 98 | end 99 | 100 | _M.triggers = { 101 | onTextMessage = { 102 | config.cmd..'(ping)$', 103 | config.cmd..'(echo) (.*)$', 104 | config.cmd..'(about)$', 105 | config.cmd..'(group)s?$', 106 | '^/start (group)s$' 107 | }, 108 | onCallbackQuery = { 109 | '^###cb:fromhelp:(about)$', 110 | '^###cb:private:(group)s$' 111 | } 112 | } 113 | 114 | return _M 115 | -------------------------------------------------------------------------------- /lua/groupbutler/plugins/private_settings.lua: -------------------------------------------------------------------------------- 1 | local config = require "groupbutler.config" 2 | local api_u = require "telegram-bot-api.utilities" 3 | local Util = require("groupbutler.util") 4 | 5 | local _M = {} 6 | 7 | function _M:new(update_obj) 8 | local plugin_obj = {} 9 | setmetatable(plugin_obj, {__index = self}) 10 | for k, v in pairs(update_obj) do 11 | plugin_obj[k] = v 12 | end 13 | return plugin_obj 14 | end 15 | 16 | local function get_button_description(self, key) 17 | local i18n = self.i18n 18 | local button_description = { 19 | rules_on_join = i18n("When you join a group moderated by this bot, you will receive the group rules in private"), 20 | reports = i18n("If enabled, you will receive all the messages reported with the @admin command in the groups you are moderating"), -- luacheck: ignore 631 21 | } Util.setDefaultTableValue(button_description, i18n("Description not available")) 22 | return button_description[key] 23 | end 24 | 25 | local function doKeyboard_privsett(self, user_id) 26 | local i18n = self.i18n 27 | local user_settings = self.db:get_all_user_settings(user_id) 28 | 29 | local keyboard = api_u.InlineKeyboardMarkup:new() 30 | local button_names = { 31 | ['rules_on_join'] = i18n('Rules on join'), 32 | ['reports'] = i18n('Users reports') 33 | } Util.setDefaultTableValue(button_names, i18n("Name not available")) 34 | 35 | for key, status in pairs(user_settings) do 36 | local icon = "☑️" 37 | if status then 38 | icon = "✅" 39 | end 40 | keyboard:row( 41 | {text = button_names[key], callback_data = 'myset:alert:'..key}, 42 | {text = icon, callback_data = 'myset:switch:'..key} 43 | ) 44 | end 45 | 46 | return keyboard 47 | end 48 | 49 | function _M:onTextMessage() 50 | local api = self.api 51 | local msg = self.message 52 | local i18n = self.i18n 53 | if msg.from.chat.type == 'private' then 54 | local reply_markup = doKeyboard_privsett(self, msg.from.user.id) 55 | api:send_message{ 56 | chat_id = msg.from.user.id, 57 | text = i18n("Change your private settings"), 58 | reply_markup = reply_markup 59 | } 60 | end 61 | end 62 | 63 | function _M:onCallbackQuery(blocks) 64 | local api = self.api 65 | local msg = self.message 66 | local i18n = self.i18n 67 | if blocks[1] == 'alert' then 68 | api:answerCallbackQuery(msg.cb_id, get_button_description(self, blocks[2]), true) 69 | return 70 | end 71 | self.db:toggle_user_setting(msg.from.user.id, blocks[2]) 72 | local reply_markup = doKeyboard_privsett(self, msg.from.user.id) 73 | api:edit_message_reply_markup{ 74 | chat_id = msg.from.user.id, 75 | message_id = msg.message_id, 76 | reply_markup = reply_markup 77 | } 78 | api:answer_callback_query(msg.cb_id, i18n('⚙ Setting applied')) 79 | end 80 | 81 | _M.triggers = { 82 | onTextMessage = {config.cmd..'(mysettings)$'}, 83 | onCallbackQuery = { 84 | '^###cb:myset:(alert):(.*)$', 85 | '^###cb:myset:(switch):(.*)$', 86 | } 87 | } 88 | 89 | return _M 90 | -------------------------------------------------------------------------------- /lua/groupbutler/plugins/report.lua: -------------------------------------------------------------------------------- 1 | local config = require "groupbutler.config" 2 | local null = require "groupbutler.null" 3 | local api_u = require("telegram-bot-api.utilities") 4 | 5 | local _M = {} 6 | 7 | function _M:new(update_obj) 8 | local plugin_obj = {} 9 | setmetatable(plugin_obj, {__index = self}) 10 | for k, v in pairs(update_obj) do 11 | plugin_obj[k] = v 12 | end 13 | return plugin_obj 14 | end 15 | 16 | local function seconds2minutes(seconds) 17 | seconds = tonumber(seconds) 18 | local minutes = math.floor(seconds/60) 19 | seconds = seconds % 60 20 | return minutes, seconds 21 | end 22 | 23 | local function report(self, msg, description) 24 | local api = self.api 25 | local red = self.red 26 | local db = self.db 27 | local i18n = self.i18n 28 | 29 | local text = i18n( 30 | '• Message reported by: %s (%d)'):format(msg.from.user:getLink(), msg.from.user.id) 31 | local chat_link = red:hget('chat:'..msg.from.chat.id..':links', 'link') 32 | if msg.reply.forward_from or msg.reply.forward_from_chat or msg.reply.sticker then 33 | text = text..i18n( 34 | '\n• Reported message sent by: %s (%d)' 35 | ):format(msg.reply.from.user:getLink(), msg.reply.from.user.id) 36 | end 37 | if chat_link == null then 38 | text = text..i18n('\n• Group: %s'):format(msg.from.chat.title:escape_html()) 39 | else 40 | text = text..i18n('\n• Group: %s'):format(chat_link, msg.from.chat.title:escape_html()) 41 | end 42 | if msg.from.chat.username then 43 | text = text..i18n( 44 | '\n• Go to the message' 45 | ):format('telegram.me/'..msg.from.chat.username..'/'..msg.message_id) 46 | end 47 | if description then 48 | text = text..i18n('\n• Description: %s'):format(description:escape_html()) 49 | end 50 | 51 | local n = 0 52 | 53 | local admins_list = db:getChatAdministratorsList(msg.from.chat) 54 | if not admins_list then return false end 55 | 56 | local callback_data = ("report:%d:"):format(msg.from.chat.id) 57 | local hash = 'chat:'..msg.from.chat.id..':report:'..msg.message_id --stores the user_id and the msg_id of the report messages sent to the admins 58 | for i=1, #admins_list do 59 | if db:get_user_setting(admins_list[i], "reports") then 60 | local res_fwd = api:forwardMessage(admins_list[i], msg.from.chat.id, msg.reply.message_id) 61 | if res_fwd then 62 | local reply_markup = api_u.InlineKeyboardMarkup:new() 63 | :row({text = i18n("Address this report"), callback_data = callback_data..msg.message_id}) 64 | local desc_msg = api:sendMessage(admins_list[i], text, 'html', true, nil, res_fwd.message_id, reply_markup) 65 | if desc_msg then 66 | red:hset(hash, admins_list[i], desc_msg.message_id) --save the msg_id of the msg sent to the admin 67 | n = n + 1 68 | end 69 | end 70 | end 71 | end 72 | 73 | red:expire(hash, 3600 * 48) 74 | 75 | return n 76 | end 77 | 78 | local function user_is_abusing(self, chat_id, user_id) 79 | local red = self.red 80 | 81 | local hash = 'chat:'..chat_id..':report' 82 | local user_key = hash..':'..user_id 83 | local times = tonumber(red:get(user_key)) or 1 84 | local times_allowed = tonumber(red:hget(hash, 'times_allowed')) or config.bot_settings.report.times_allowed 85 | local duration = tonumber(red:hget(hash, 'duration')) or config.bot_settings.report.duration 86 | if times <= times_allowed then 87 | red:setex(user_key, duration, times + 1) 88 | return false 89 | else 90 | local ttl = red:ttl(user_key) 91 | red:setex(user_key, tonumber(ttl), times) 92 | return true 93 | end 94 | end 95 | 96 | function _M:onTextMessage(blocks) 97 | local msg = self.message 98 | local red = self.red 99 | local i18n = self.i18n 100 | local u = self.u 101 | 102 | if msg.from.chat.id < 0 then 103 | if #blocks > 1 104 | and msg.from:isAdmin() then 105 | local times_allowed, duration = tonumber(blocks[2]), tonumber(blocks[3]) 106 | local text 107 | if times_allowed < 1 or times_allowed > 1000 then 108 | text = i18n("_Invalid value:_ number of times allowed (`input: %d`)"):format(times_allowed) 109 | elseif duration < 1 or duration > 10080 then 110 | text = i18n("_Invalid value:_ time (`input: %d`)"):format(duration) 111 | else 112 | local hash = 'chat:'..msg.from.chat.id..':report' 113 | red:hset(hash, 'times_allowed', times_allowed) 114 | red:hset(hash, 'duration', (duration * 60)) 115 | text = i18n( 116 | '*New parameters saved*.\nUsers will be able to use @admin %d times/%d minutes' 117 | ):format(times_allowed, duration) 118 | end 119 | msg:send_reply(text, "Markdown") 120 | else 121 | if not msg.reply or msg.from:isAdmin() then 122 | return 123 | end 124 | 125 | local status = red:hget('chat:'..msg.from.chat.id..':settings', 'Reports') 126 | if status == null then status = config.chat_settings['settings']['Reports'] end 127 | 128 | if status == 'off' then return end 129 | 130 | local text 131 | if user_is_abusing(self, msg.from.chat.id, msg.from.user.id) then 132 | local hash = 'chat:'..msg.from.chat.id..':report' 133 | local duration = tonumber(red:hget(hash, 'duration')) or config.bot_settings.report.duration 134 | local times_allowed = tonumber(red:hget(hash, 'times_allowed')) or config.bot_settings.report.times_allowed 135 | local ttl = red:ttl(hash..':'..msg.from.user.id) 136 | local minutes, seconds = seconds2minutes(ttl) 137 | text = i18n([[_Please, do not abuse this command. It can be used %d times every %d minutes_. 138 | Wait other %d minutes, %d seconds.]]):format(times_allowed, (duration / 60), minutes, seconds) 139 | msg:send_reply(text, "Markdown") 140 | else 141 | local description 142 | if blocks[1] and blocks[1] ~= '@admin' and blocks[1] ~= config.cmd..'report' then 143 | description = blocks[1] 144 | end 145 | 146 | local n_sent = report(self, msg, description) or 0 147 | 148 | text = i18n('_Reported to %d admin(s)_'):format(n_sent) 149 | 150 | u:logEvent('report', msg, {n_admins = n_sent}) 151 | msg:send_reply(text, "Markdown") 152 | end 153 | end 154 | end 155 | end 156 | 157 | function _M:onCallbackQuery(blocks) 158 | local api = self.api 159 | local msg = self.message 160 | local red = self.red 161 | local i18n = self.i18n 162 | 163 | if not blocks[2] then --###cb:issueclosed 164 | api:answerCallbackQuery(msg.cb_id, 165 | i18n('You closed this issue and deleted all the other reports sent to the admins'), true, 48 * 3600) 166 | return 167 | end 168 | 169 | local chat_id, msg_id = blocks[2], blocks[3] 170 | local hash = 'chat:'..chat_id..':report:'..msg_id 171 | if red:exists(hash) == 0 then 172 | --if the hash doesn't exist, the message is too old 173 | api:answerCallbackQuery(msg.cb_id, i18n("This message is too old (>2 days)"), true) 174 | else 175 | if blocks[1] == "report" then 176 | local addressed_by = red:get(hash..':addressed') 177 | if addressed_by == null then 178 | --no one addressed the issue yet 179 | 180 | local name = msg.from.user.first_name:sub(1, 120) 181 | local chats_reached = red:hgetall(hash) 182 | if next(chats_reached) then 183 | local markup = {inline_keyboard={ 184 | {{text = i18n("❕ Already (being) adressed"), callback_data = ("report:%s:%s"):format(chat_id, msg_id)}} 185 | }} 186 | local close_issue_line = {{text = i18n("Close issue"), callback_data = ("close:%s:%s"):format(chat_id, msg_id)}} 187 | for user_id, message_id in pairs(chats_reached) do 188 | api:editMessageReplyMarkup(user_id, message_id, markup) 189 | end 190 | table.insert(markup.inline_keyboard, close_issue_line) 191 | api:editMessageReplyMarkup(msg.from.user.id, msg.message_id, markup) 192 | end 193 | red:setex(hash..':addressed', 3600*24*2, name) 194 | api:answerCallbackQuery(msg.cb_id, "✅") 195 | else 196 | api:answerCallbackQuery(msg.cb_id, i18n("%s has/will address this report"):format(addressed_by), true, 48 * 3600) 197 | end 198 | elseif blocks[1] == 'close' then 199 | local key = hash .. (':close:%d'):format(msg.from.user.id) 200 | local second_tap = red:get(key) 201 | if second_tap == null then 202 | red:setex(key, 3600*24, 'x') 203 | api:answerCallbackQuery(msg.cb_id, i18n( 204 | 'This button will delete all the reports sent to the other admins. Tap it again to confirm'), true) 205 | else 206 | local chats_reached = red:hgetall(hash) 207 | for user_id, message_id in pairs(chats_reached) do 208 | if tonumber(user_id) ~= msg.from.user.id then 209 | api:deleteMessages(user_id, { [1] = message_id, [2] = (tonumber(message_id) - 1) }) 210 | end 211 | end 212 | local markup = {inline_keyboard={{{text = i18n("(issue closed by you)"), callback_data = "issueclosed"}}}} 213 | api:editMessageReplyMarkup(msg.from.user.id, msg.message_id, nil, markup) 214 | end 215 | end 216 | end 217 | end 218 | 219 | _M.triggers = { 220 | onTextMessage = { 221 | '^@admin$', 222 | '^@admin (.+)', 223 | config.cmd..'report$', 224 | config.cmd..'report (.+)', 225 | config.cmd..'(reportflood) (%d+)[%s/:](%d+)' 226 | }, 227 | onCallbackQuery = { 228 | "^###cb:(report):(-%d+):(%d+)$", 229 | "^###cb:(close):(-%d+):(%d+)$", 230 | "^###cb:issueclosed$" 231 | } 232 | } 233 | 234 | return _M 235 | -------------------------------------------------------------------------------- /lua/groupbutler/plugins/rules.lua: -------------------------------------------------------------------------------- 1 | local config = require "groupbutler.config" 2 | 3 | local _M = {} 4 | 5 | function _M:new(update_obj) 6 | local plugin_obj = {} 7 | setmetatable(plugin_obj, {__index = self}) 8 | for k, v in pairs(update_obj) do 9 | plugin_obj[k] = v 10 | end 11 | return plugin_obj 12 | end 13 | 14 | function _M:onTextMessage(blocks) 15 | local api = self.api 16 | local msg = self.message 17 | local red = self.red 18 | local db = self.db 19 | local i18n = self.i18n 20 | local api_err = self.api_err 21 | local u = self.u 22 | 23 | if msg.from.chat.type == 'private' then 24 | if blocks[1] == 'start' then 25 | msg.from.chat.id = tonumber(blocks[2]) 26 | 27 | local res = api:getChat(msg.from.chat.id) 28 | if not res then 29 | api:sendMessage(msg.from.user.id, i18n("🚫 Unknown or non-existent group")) 30 | return 31 | end 32 | -- Private chats have no username 33 | local private = not res.username 34 | 35 | res = api:getChatMember(msg.from.chat.id, msg.from.user.id) 36 | if not res or (res.status == 'left' or res.status == 'kicked') and private then 37 | api:sendMessage(msg.from.user.id, i18n("🚷 You are not a member of this chat. " .. 38 | "You can't read the rules of a private group.")) 39 | return 40 | end 41 | else 42 | return 43 | end 44 | end 45 | 46 | local hash = 'chat:'..msg.from.chat.id..':info' 47 | if blocks[1] == 'rules' or blocks[1] == 'start' then 48 | local rules = u:getRules(msg.from.chat.id) 49 | local reply_markup 50 | 51 | reply_markup, rules = u:reply_markup_from_text(rules) 52 | 53 | local link_preview = rules:find('telegra%.ph/') == nil 54 | if msg.from.chat.type == 'private' 55 | or (not db:get_chat_setting(msg.from.chat.id, "Rules") and not msg.from:isAdmin()) then 56 | api:sendMessage(msg.from.user.id, rules, "Markdown", link_preview, nil, nil, reply_markup) 57 | else 58 | msg:send_reply(rules, "Markdown", link_preview, nil, nil, reply_markup) 59 | end 60 | end 61 | 62 | if not msg.from:isAdmin() then return end 63 | 64 | if blocks[1] == 'setrules' then 65 | local rules = blocks[2] 66 | --ignore if not input text 67 | if not rules then 68 | msg:send_reply(i18n("Please write something next `/setrules`"), "Markdown") 69 | return 70 | end 71 | --check if an admin want to clean the rules 72 | if rules == '-' then 73 | red:hdel(hash, 'rules') 74 | msg:send_reply(i18n("Rules has been deleted.")) 75 | return 76 | end 77 | 78 | local reply_markup, test_text = u:reply_markup_from_text(rules) 79 | 80 | --set the new rules 81 | local ok, err = msg:send_reply(test_text, "Markdown", nil, nil, reply_markup) 82 | if not ok then 83 | api:sendMessage(msg.from.chat.id, api_err:trans(err), "Markdown") 84 | else 85 | red:hset(hash, 'rules', rules) 86 | local id = ok.message_id 87 | api:editMessageText(msg.from.chat.id, id, nil, i18n("New rules *saved successfully*!"), "Markdown") 88 | end 89 | end 90 | end 91 | 92 | _M.triggers = { 93 | onTextMessage = { 94 | config.cmd..'(setrules)$', 95 | config.cmd..'(setrules) (.*)', 96 | config.cmd..'(rules)$', 97 | '^/(start) (-?%d+)_rules$' 98 | } 99 | } 100 | 101 | return _M 102 | -------------------------------------------------------------------------------- /lua/groupbutler/plugins/service.lua: -------------------------------------------------------------------------------- 1 | local config = require "groupbutler.config" 2 | local null = require "groupbutler.null" 3 | 4 | local _M = {} 5 | 6 | function _M:new(update_obj) 7 | local plugin_obj = {} 8 | setmetatable(plugin_obj, {__index = self}) 9 | for k, v in pairs(update_obj) do 10 | plugin_obj[k] = v 11 | end 12 | return plugin_obj 13 | end 14 | 15 | function _M:onTextMessage(blocks) 16 | local api = self.api 17 | local msg = self.message 18 | local bot = self.bot 19 | local u = self.u 20 | local red = self.red 21 | local i18n = self.i18n 22 | 23 | if not msg.service then return end 24 | 25 | -- if blocks[1] == "new_chat_member" then 26 | -- red:sadd(string.format("chat:%d:members", msg.from.chat.id), msg.new_chat_member.id) 27 | -- end 28 | if blocks[1] == "left_chat_member" then 29 | red:srem(string.format("chat:%d:members", msg.from.chat.id), msg.left_chat_member.id) 30 | end 31 | if blocks[1] == 'new_chat_member' 32 | or blocks[1] == 'left_chat_member' then 33 | local status = red:hget(('chat:%d:settings'):format(msg.from.chat.id), 'Clean_service_msg') 34 | if status == null then status = config.chat_settings.settings.Clean_service_msg end 35 | 36 | if status == 'on' then 37 | api:deleteMessage(msg.from.chat.id, msg.message_id) 38 | end 39 | return true 40 | end 41 | 42 | if blocks[1] == "new_chat_member:bot" then 43 | if u:is_blocked_global(msg.from.user.id) then 44 | api:sendMessage(msg.from.chat.id, i18n("_You (user ID: %d) are in the blocked list_"):format(msg.from.user.id), 45 | "Markdown") 46 | api:leaveChat(msg.from.chat.id) 47 | return 48 | end 49 | if config.bot_settings.admin_mode and not u:is_superadmin(msg.from.user.id) then 50 | api:sendMessage(msg.from.chat.id, i18n("_Admin mode is on: only the bot admin can add me to a new group_"), 51 | "Markdown") 52 | api:leaveChat(msg.from.chat.id) 53 | return 54 | end 55 | u:initGroup(msg.from.chat) 56 | -- send manuals 57 | local text = i18n("Hello everyone!\n" 58 | .. "My name is %s, and I'm a bot made to help administrators in their hard work.\n") 59 | :format(bot.first_name:escape()) 60 | api:sendMessage(msg.from.chat.id, text, "Markdown") 61 | elseif blocks[1] == 'left_chat_member:bot' then 62 | u:remGroup(msg.from.chat.id) 63 | end 64 | 65 | u:logEvent(blocks[1], msg) 66 | end 67 | 68 | _M.triggers = { 69 | onTextMessage = { 70 | '^###(new_chat_member)$', 71 | '^###(left_chat_member)$', 72 | '^###(new_chat_member:bot)', 73 | '^###(left_chat_member:bot)', 74 | '^###(pinned_message)$', 75 | '^###(new_chat_title)$', 76 | '^###(new_chat_photo)$', 77 | '^###(delete_chat_photo)$' 78 | } 79 | } 80 | 81 | return _M 82 | -------------------------------------------------------------------------------- /lua/groupbutler/plugins/setlang.lua: -------------------------------------------------------------------------------- 1 | local config = require "groupbutler.config" 2 | 3 | local _M = {} 4 | 5 | function _M:new(update_obj) 6 | local plugin_obj = {} 7 | setmetatable(plugin_obj, {__index = self}) 8 | for k, v in pairs(update_obj) do 9 | plugin_obj[k] = v 10 | end 11 | return plugin_obj 12 | end 13 | 14 | local function doKeyboard_lang() 15 | local keyboard = { 16 | inline_keyboard = {} 17 | } 18 | for lang, flag in pairs(config.available_languages) do 19 | local line = {{text = flag, callback_data = 'langselected:'..lang}} 20 | table.insert(keyboard.inline_keyboard, line) 21 | end 22 | return keyboard 23 | end 24 | 25 | function _M:onTextMessage() 26 | local api = self.api 27 | local msg = self.message 28 | local i18n = self.i18n 29 | 30 | if msg.from.chat.type == "private" 31 | or msg.from:can("can_change_info") then 32 | local keyboard = doKeyboard_lang() 33 | api:sendMessage(msg.from.chat.id, i18n("*List of available languages*:"), "Markdown", nil, nil, nil, keyboard) 34 | end 35 | end 36 | 37 | function _M:onCallbackQuery(blocks) 38 | local api = self.api 39 | local msg = self.message 40 | local red = self.red 41 | local i18n = self.i18n 42 | 43 | if msg.from.chat.type ~= "private" 44 | and not msg.from:isAdmin() then 45 | api:answerCallbackQuery(msg.cb_id, i18n("Sorry, you don't have permission to change settings")) 46 | return 47 | end 48 | 49 | if blocks[1] == "selectlang" then 50 | local keyboard = doKeyboard_lang() 51 | api:editMessageText(msg.from.chat.id, msg.message_id, nil, i18n("*List of available languages*:"), "Markdown", nil, 52 | keyboard) 53 | return 54 | end 55 | 56 | i18n:setLanguage(blocks[1]) 57 | red:set("lang:"..msg.from.chat.id, i18n:getLanguage()) 58 | if msg.from.chat.type ~= "private" 59 | and (blocks[1] == "ar_SA" or blocks[1] == "fa_IR") then 60 | red:hset("chat:"..msg.from.chat.id..":char", "Arab", "allowed") 61 | red:hset("chat:"..msg.from.chat.id..":char", "Rtl", "allowed") 62 | end 63 | -- TRANSLATORS: replace 'English' with the name of your language 64 | api:editMessageText(msg.from.chat.id, msg.message_id, nil, i18n("English language is *set*")..i18n([[. 65 | Please note that translators are volunteers, and this localization _may be incomplete_. You can help improve translations on our [Crowdin Project](https://crowdin.com/project/group-butler). 66 | ]]), "Markdown") 67 | end 68 | 69 | _M.triggers = { 70 | onTextMessage = {config.cmd..'(lang)$'}, 71 | onCallbackQuery = { 72 | '^###cb:(selectlang)', 73 | '^###cb:langselected:(%l%l)$', 74 | '^###cb:langselected:(%l%l_%u%u)$' 75 | } 76 | } 77 | 78 | return _M 79 | -------------------------------------------------------------------------------- /lua/groupbutler/plugins/users.lua: -------------------------------------------------------------------------------- 1 | local config = require "groupbutler.config" 2 | local User = require("groupbutler.user") 3 | local Chat = require("groupbutler.chat") 4 | local Util = require("groupbutler.util") 5 | 6 | local _M = {} 7 | 8 | function _M:new(update_obj) 9 | local plugin_obj = {} 10 | setmetatable(plugin_obj, {__index = self}) 11 | for k, v in pairs(update_obj) do 12 | plugin_obj[k] = v 13 | end 14 | return plugin_obj 15 | end 16 | 17 | local function permissions(self) 18 | local i18n = self.i18n 19 | return { 20 | can_change_info = i18n("can't change the chat title/description/icon"), 21 | can_send_messages = i18n("can't send messages"), 22 | can_delete_messages = i18n("can't delete messages"), 23 | can_invite_users = i18n("can't invite users/generate a link"), 24 | can_restrict_members = i18n("can't restrict members"), 25 | can_pin_messages = i18n("can't pin messages"), 26 | can_promote_members = i18n("can't promote new admins"), 27 | can_send_media_messages = i18n("can't send photos/videos/documents/audios/voice messages/video messages"), 28 | can_send_other_messages = i18n("can't send stickers/GIFs/games/use inline bots"), 29 | can_add_web_page_previews = i18n("can't show link previews") 30 | } 31 | end 32 | 33 | local function do_keyboard_cache(self, chat_id) 34 | local i18n = self.i18n 35 | local keyboard = {inline_keyboard = {{{text = i18n("🔄️ Refresh cache"), callback_data = 'recache:'..chat_id}}}} 36 | return keyboard 37 | end 38 | 39 | local function do_keyboard_userinfo(self, user_id) 40 | local i18n = self.i18n 41 | local keyboard = { 42 | inline_keyboard = { 43 | {{text = i18n("Remove warnings"), callback_data = 'userbutton:remwarns:'..user_id}} 44 | } 45 | } 46 | return keyboard 47 | end 48 | 49 | local function get_userinfo(self, user_id, chat_id) 50 | local red = self.red 51 | local i18n = self.i18n 52 | 53 | local text = i18n([[*User ID*: `%d` 54 | `Warnings`: *%d* 55 | `Media warnings`: *%d* 56 | `Spam warnings`: *%d* 57 | ]]) 58 | local warns = tonumber(red:hget('chat:'..chat_id..':warns', user_id)) or 0 59 | local media_warns = tonumber(red:hget('chat:'..chat_id..':mediawarn', user_id)) or 0 60 | local spam_warns = tonumber(red:hget('chat:'..chat_id..':spamwarns', user_id)) or 0 61 | return text:format(tonumber(user_id), warns, media_warns, spam_warns) 62 | end 63 | 64 | function _M:onTextMessage(blocks) 65 | local api = self.api 66 | local msg = self.message 67 | local db = self.db 68 | local i18n = self.i18n 69 | local u = self.u 70 | 71 | if blocks[1] == 'id' then --in private: send user id 72 | if msg.from.chat.id > 0 and msg.from.chat.type == 'private' then 73 | api:sendMessage(msg.from.chat.id, string.format(i18n('Your ID is `%d`'), msg.from.user.id), "Markdown") 74 | end 75 | end 76 | 77 | if msg.from.chat.type == 'private' then return end 78 | 79 | if blocks[1] == 'id' then --in groups: send chat ID 80 | if msg.from.chat.id < 0 and msg.from:isAdmin() then 81 | api:sendMessage(msg.from.chat.id, string.format('`%d`', msg.from.chat.id), "Markdown") 82 | end 83 | end 84 | 85 | if blocks[1] == 'adminlist' then 86 | local adminlist = u:getAdminlist(msg.from.chat) 87 | if not msg.from:isAdmin() then 88 | api:sendMessage(msg.from.user.id, adminlist, 'html', true) 89 | else 90 | msg:send_reply(adminlist, 'html', true) 91 | end 92 | end 93 | 94 | if blocks[1] == 'status' then 95 | if (not blocks[2] and not msg.reply) or not msg.from:isAdmin() then 96 | return 97 | end 98 | 99 | local member, err = msg:getTargetMember(blocks) 100 | if not member then 101 | msg:send_reply(err, "Markdown") 102 | return 103 | end 104 | 105 | local statuses = { 106 | kicked = i18n("%s is banned from this group"), 107 | left = i18n("%s left the group or has been kicked and unbanned"), 108 | administrator = i18n("%s is an admin"), 109 | creator = i18n("%s is the group creator"), 110 | unknown = i18n("%s has nothing to do with this chat"), 111 | member = i18n("%s is a chat member"), 112 | restricted = i18n("%s is a restricted") 113 | } Util.setDefaultTableValue(statuses, statuses.unknown) 114 | 115 | local denied_permissions = {} 116 | for permission, str in pairs(permissions(self)) do 117 | if member[permission] ~= nil and member[permission] == false then 118 | table.insert(denied_permissions, str) 119 | end 120 | end 121 | 122 | local text = statuses[member.status]:format(member.user:getLink()) 123 | if next(denied_permissions) then 124 | text = text..i18n('\nRestrictions: %s'):format(table.concat(denied_permissions, ', ')) 125 | end 126 | 127 | msg:send_reply(text, 'html') 128 | end 129 | 130 | if blocks[1] == 'user' then 131 | if not msg.from:isAdmin() then return end 132 | if not msg.reply 133 | and (not blocks[2] or (not blocks[2]:match('@[%w_]+$') and not blocks[2]:match('%d+$') 134 | and not msg.mention_id)) then 135 | msg:send_reply(i18n("Reply to a user or mention them by username or numerical ID")) 136 | return 137 | end 138 | 139 | local member, err = msg:getTargetMember(blocks) 140 | if not member then 141 | msg:send_reply(err, "Markdown") 142 | return 143 | end 144 | 145 | local keyboard = do_keyboard_userinfo(self, member.user.id) 146 | 147 | local text = get_userinfo(self, member.user.id, msg.from.chat.id) 148 | api:sendMessage(msg.from.chat.id, text, "Markdown", nil, nil, nil, keyboard) 149 | end 150 | 151 | if blocks[1] == 'cache' then 152 | if not msg.from:isAdmin() then return end 153 | local text = i18n("👥 Admins cached: %d"):format(db:getChatAdministratorsCount(msg.from.chat)) 154 | local keyboard = do_keyboard_cache(self, msg.from.chat.id) 155 | api:sendMessage(msg.from.chat.id, text, "html", nil, nil, nil, keyboard) 156 | end 157 | 158 | if blocks[1] == 'msglink' then 159 | if not msg.reply or not msg.from.chat.username then return end 160 | local text = string.format('[%s](https://telegram.me/%s/%d)', 161 | i18n("Message N° %d"):format(msg.reply.message_id), msg.from.chat.username, msg.reply.message_id) 162 | if not u:is_silentmode_on(msg.from.chat.id) or msg.from:isAdmin() then 163 | msg.reply:send_reply(text, "Markdown") 164 | else 165 | api:sendMessage(msg.from.user.id, text, "Markdown") 166 | end 167 | end 168 | 169 | if blocks[1] == 'leave' and msg.from:isAdmin() then 170 | -- u:remGroup(msg.from.chat.id) 171 | api:leaveChat(msg.from.chat.id) 172 | end 173 | end 174 | 175 | function _M:onCallbackQuery(blocks) 176 | local api = self.api 177 | local msg = self.message 178 | local db = self.db 179 | local i18n = self.i18n 180 | local u = self.u 181 | 182 | if not msg.from:isAdmin() then 183 | api:answerCallbackQuery(msg.cb_id, i18n("You are not allowed to use this button")) 184 | return 185 | end 186 | 187 | if blocks[1] == 'remwarns' then 188 | db:forgetUserWarns(msg.from.chat.id, blocks[2]) 189 | local admin = msg.from.user 190 | local target = User:new({id = blocks[2]}, self) 191 | local text = i18n("The number of warnings received by this user has been reset, by %s"):format(admin:getLink()) 192 | api:editMessageText(msg.from.chat.id, msg.message_id, nil, text, "html") 193 | u:logEvent("nowarn", msg, { 194 | admin = admin, 195 | user = target, 196 | user_id = blocks[2] 197 | }) 198 | end 199 | 200 | if blocks[1] == 'recache' and msg.from:isAdmin() then 201 | u:cache_adminlist(Chat:new({id=msg.target_id}, self)) 202 | api:answerCallbackQuery(msg.cb_id, i18n("✅ The admin list will be updated soon")) 203 | end 204 | end 205 | 206 | _M.triggers = { 207 | onTextMessage = { 208 | config.cmd..'(id)$', 209 | config.cmd..'(adminlist)$', 210 | config.cmd..'(status) (.+)$', 211 | config.cmd..'(status)$', 212 | config.cmd..'(cache)$', 213 | config.cmd..'(msglink)$', 214 | config.cmd..'(user)$', 215 | config.cmd..'(user) (.*)', 216 | config.cmd..'(leave)$' 217 | }, 218 | onCallbackQuery = { 219 | '^###cb:userbutton:(remwarns):(%d+)$', 220 | '^###cb:(recache):' 221 | } 222 | } 223 | 224 | return _M 225 | -------------------------------------------------------------------------------- /lua/groupbutler/plugins/warn.lua: -------------------------------------------------------------------------------- 1 | local config = require "groupbutler.config" 2 | local null = require "groupbutler.null" 3 | 4 | local _M = {} 5 | 6 | function _M:new(update_obj) 7 | local plugin_obj = {} 8 | setmetatable(plugin_obj, {__index = self}) 9 | for k, v in pairs(update_obj) do 10 | plugin_obj[k] = v 11 | end 12 | return plugin_obj 13 | end 14 | 15 | local function doKeyboard_warn(self, user_id) 16 | local i18n = self.i18n 17 | local keyboard = {} 18 | keyboard.inline_keyboard = {{{text = i18n("Remove warn"), callback_data = 'removewarn:'..user_id}}} 19 | 20 | return keyboard 21 | end 22 | 23 | function _M:onTextMessage(blocks) 24 | local api = self.api 25 | local msg = self.message 26 | local bot = self.bot 27 | local red = self.red 28 | local db = self.db 29 | local i18n = self.i18n 30 | local u = self.u 31 | 32 | if msg.from.chat.type == "private" 33 | or not msg.from:isAdmin() then 34 | return 35 | end 36 | 37 | if blocks[1] == 'warnmax' then 38 | local new, default, text, key 39 | local hash = 'chat:'..msg.from.chat.id..':warnsettings' 40 | if blocks[2] == 'media' then 41 | new = blocks[3] 42 | default = 2 43 | key = 'mediamax' 44 | text = i18n("Max number of warnings changed (media).\n") 45 | else 46 | key = 'max' 47 | new = blocks[2] 48 | default = 3 49 | text = i18n("Max number of warnings changed.\n") 50 | end 51 | local old = red:hget(hash, key) 52 | if old == null then old = default end 53 | 54 | red:hset(hash, key, new) 55 | text = text .. i18n("*Old* value was %d\n*New* max is %d"):format(tonumber(old), tonumber(new)) 56 | msg:send_reply(text, "Markdown") 57 | return 58 | end 59 | 60 | if blocks[1] == 'cleanwarn' then 61 | local reply_markup = 62 | { 63 | inline_keyboard = 64 | {{{text = i18n('Yes'), callback_data = 'cleanwarns:yes'}, {text = i18n('No'), callback_data = 'cleanwarns:no'}}} 65 | } 66 | 67 | api:sendMessage(msg.from.chat.id, 68 | i18n('Do you want to continue and reset *all* the warnings received by *all* the users of the group?'), 69 | "Markdown", nil, nil, nil, reply_markup) 70 | 71 | return 72 | end 73 | 74 | --do not reply when... 75 | local admin = msg.from 76 | local target 77 | do 78 | local err 79 | target, err = msg:getTargetMember(blocks) 80 | if not target then 81 | msg:send_reply(err, "Markdown") 82 | return 83 | end 84 | end 85 | if tonumber(target.user.id) == bot.id then return end 86 | 87 | if blocks[1] == 'nowarn' then 88 | db:forgetUserWarns(msg.from.chat.id, msg.reply.from.user.id) 89 | local text = i18n("Done! %s has been forgiven."):format(target.user:getLink()) 90 | msg:send_reply(text, 'html') 91 | u:logEvent('nowarn', msg, { 92 | admin = admin.user, 93 | user = target.user, 94 | user_id = target.user.id 95 | }) 96 | return 97 | end 98 | 99 | if target:isAdmin() then return end 100 | 101 | if blocks[1] == 'warn' or blocks[1] == 'sw' then 102 | local hash = 'chat:'..msg.from.chat.id..':warns' 103 | local num = tonumber(red:hincrby(hash, target.user.id, 1)) --add one warn 104 | local nmax = tonumber(red:hget('chat:'..msg.from.chat.id..':warnsettings', 'max')) or 3 --get the max num of warnings 105 | local text, res, err, hammer_log 106 | 107 | if num >= nmax then 108 | local type = red:hget('chat:'..msg.from.chat.id..':warnsettings', 'type') 109 | if type == null then type = 'kick' end 110 | 111 | --try to kick/ban 112 | text = i18n("%s %s: reached the max number of warnings (%d/%d)") 113 | if type == 'ban' then 114 | hammer_log = i18n('banned') 115 | text = text:format(target.user:getLink(), hammer_log, num, nmax) 116 | res, err = target:ban() 117 | elseif type == 'kick' then --kick 118 | hammer_log = i18n('kicked') 119 | text = text:format(target.user:getLink(), hammer_log, num, nmax) 120 | res, err = target:kick() 121 | elseif type == 'mute' then --kick 122 | hammer_log = i18n('muted') 123 | text = text:format(target.user:getLink(), hammer_log, num, nmax) 124 | res, err = target:mute() 125 | end 126 | --if kick/ban fails, send the motivation 127 | if not res then 128 | if num > nmax then red:hset(hash, target.user.id, nmax) end --avoid to have a number of warnings bigger than the max 129 | text = err 130 | else 131 | db:forgetUserWarns(msg.from.chat.id, target.user.id) 132 | end 133 | --if the user reached the max num of warns, kick and send message 134 | msg:send_reply(text, 'html') 135 | u:logEvent('warn', msg, { 136 | motivation = blocks[2], 137 | admin = admin.user, 138 | user = target.user, 139 | user_id = target.user.id, 140 | hammered = hammer_log, 141 | warns = num, 142 | warnmax = nmax 143 | }) 144 | else 145 | if blocks[1] ~= 'sw' then 146 | text = i18n("%s has been warned (%d/%d)"):format(target.user:getLink(), num, nmax) 147 | local keyboard = doKeyboard_warn(self, target.user.id) 148 | api:sendMessage(msg.from.chat.id, text, 'html', true, nil, nil, keyboard) 149 | end 150 | u:logEvent('warn', msg, { 151 | motivation = blocks[2], 152 | warns = num, 153 | warnmax = nmax, 154 | admin = admin.user, 155 | user = target.user, 156 | user_id = target.user.id 157 | }) 158 | end 159 | end 160 | end 161 | 162 | function _M:onCallbackQuery(blocks) 163 | local api = self.api 164 | local msg = self.message 165 | local red = self.red 166 | local i18n = self.i18n 167 | 168 | if msg.from.chat.type == "private" 169 | or not msg.from:isAdmin() then 170 | api:answerCallbackQuery(msg.cb_id, i18n("You are not allowed to use this button")) 171 | return 172 | end 173 | 174 | local admin = msg.from.user 175 | 176 | if blocks[1] == 'removewarn' then 177 | local user_id = blocks[2] 178 | local num = tonumber(red:hincrby('chat:'..msg.from.chat.id..':warns', user_id, -1)) --add one warn 179 | local text, nmax 180 | if num < 0 then 181 | text = i18n("The number of warnings received by this user is already zero") 182 | red:hincrby('chat:'..msg.from.chat.id..':warns', user_id, 1) --restore the previouvs number 183 | else 184 | nmax = tonumber(red:hget('chat:'..msg.from.chat.id..':warnsettings', 'max')) or 3 --get the max num of warnings 185 | text = i18n("Warn removed! (%d/%d)"):format(num, nmax) 186 | end 187 | 188 | text = text .. i18n("\n(Admin: %s)"):format(admin:getLink()) 189 | api:editMessageText(msg.from.chat.id, msg.message_id, nil, text, 'html') 190 | end 191 | 192 | if blocks[1] == 'cleanwarns' then 193 | if blocks[2] == 'yes' then 194 | red:del('chat:'..msg.from.chat.id..':warns') 195 | red:del('chat:'..msg.from.chat.id..':mediawarn') 196 | red:del('chat:'..msg.from.chat.id..':spamwarns') 197 | api:editMessageText(msg.from.chat.id, msg.message_id, nil, 198 | i18n('Done. All the warnings of this group have been erased by %s'):format(admin:getLink()), 'html') 199 | else 200 | api:editMessageText(msg.from.chat.id, msg.message_id, nil, i18n('_Action aborted_'), "Markdown") 201 | end 202 | end 203 | end 204 | 205 | _M.triggers = { 206 | onTextMessage = { 207 | config.cmd..'(warnmax) (%d%d?)$', 208 | config.cmd..'(warnmax) (media) (%d%d?)$', 209 | config.cmd..'(warn)$', 210 | config.cmd..'(nowarn)s?$', 211 | config.cmd..'(warn) (.*)$', 212 | config.cmd..'(cleanwarn)s?$', 213 | '[/!#](sw)%s', 214 | '[/!#](sw)$' 215 | }, 216 | onCallbackQuery = { 217 | '^###cb:(resetwarns):(%d+)$', 218 | '^###cb:(removewarn):(%d+)$', 219 | '^###cb:(cleanwarns):(%a%a%a?)$' 220 | } 221 | } 222 | 223 | return _M 224 | -------------------------------------------------------------------------------- /lua/groupbutler/plugins/welcome.lua: -------------------------------------------------------------------------------- 1 | local config = require "groupbutler.config" 2 | local null = require "groupbutler.null" 3 | local api_u = require "telegram-bot-api.utilities" 4 | 5 | local _M = {} 6 | 7 | function _M:new(update_obj) 8 | local plugin_obj = {} 9 | setmetatable(plugin_obj, {__index = self}) 10 | for k, v in pairs(update_obj) do 11 | plugin_obj[k] = v 12 | end 13 | return plugin_obj 14 | end 15 | 16 | local function ban_bots(self, msg) 17 | local db = self.db 18 | -- ignore if added by an admin or new member joined by link or the setting is disabled 19 | if msg.from.user.id == msg.new_chat_member.id 20 | or msg.from:isAdmin() 21 | or not db:get_chat_setting(msg.from.chat.id, 'Antibot') then 22 | return 23 | end 24 | local members = msg.new_chat_members 25 | local n = 0 --bots banned 26 | for i = 1, #members do 27 | if members[i].user.is_bot then 28 | members[i]:ban() 29 | n = n + 1 30 | end 31 | end 32 | if n == #members then 33 | --if all the new members added are bots then don't send a welcome message 34 | return true 35 | end 36 | end 37 | 38 | local function apply_default_permissions(self, msg) 39 | local api = self.api 40 | local red = self.red 41 | local members = msg.new_chat_members 42 | 43 | local hash = ("chat:%d:defpermissions"):format(msg.from.chat.id) 44 | local def_permissions = red:array_to_hash(red:hgetall(hash)) 45 | 46 | if next(def_permissions) then 47 | for i=1, #members do 48 | if members[i].status == "member" then 49 | def_permissions.chat_id = msg.from.chat.id 50 | def_permissions.user_id = members[i].user.id 51 | api:restrictChatMember(def_permissions) 52 | end 53 | end 54 | end 55 | end 56 | 57 | local function get_reply_markup(self, msg, text) 58 | local u = self.u 59 | local i18n = self.i18n 60 | local db = self.db 61 | 62 | local new_text, reply_markup 63 | if text then 64 | reply_markup, new_text = u:reply_markup_from_text(u:replaceholders(text, msg)) 65 | end 66 | 67 | if db:get_chat_setting(msg.from.chat.id, "Welbut") then 68 | if not reply_markup then 69 | reply_markup = api_u.InlineKeyboardMarkup:new() 70 | end 71 | reply_markup:row({text = i18n("Read the rules"), url = u:deeplink_constructor(msg.from.chat.id, "rules")}) 72 | end 73 | 74 | return reply_markup, new_text 75 | end 76 | 77 | local function send_welcome(self, msg) 78 | local api = self.api 79 | local red = self.red 80 | local i18n = self.i18n 81 | local u = self.u 82 | local db = self.db 83 | 84 | if not db:get_chat_setting(msg.from.chat.id, 'Welcome') then 85 | return 86 | end 87 | 88 | local hash = 'chat:'..msg.from.chat.id..':welcome' 89 | local welcome_type = red:hget(hash, "type") 90 | local content = red:hget(hash, 'content') 91 | if welcome_type == "no" or content == "no" -- TODO: database migration no -> null 92 | or welcome_type == null or content == null then 93 | welcome_type = "text" 94 | content = i18n("Hi $name!") 95 | end 96 | 97 | local ok, err 98 | if welcome_type == "custom" -- TODO: database migration custom -> text 99 | or welcome_type == "text" then 100 | local reply_markup, text = get_reply_markup(self, msg, content) 101 | local link_preview = text:find('telegra%.ph/') == nil 102 | ok, err = api:sendMessage(msg.from.chat.id, text, "Markdown", link_preview, nil, nil, reply_markup) 103 | end 104 | if welcome_type == "media" then 105 | local caption = red:hget(hash, "caption") 106 | if caption == null then 107 | caption = nil 108 | end 109 | local reply_markup, text = get_reply_markup(self, msg, caption) 110 | ok, err = api:sendDocument(msg.from.chat.id, content, text, nil, nil, reply_markup) 111 | end 112 | 113 | if not ok and err.description:match("have no rights to send a message") then 114 | u:remGroup(msg.from.chat.id) 115 | api:leaveChat(msg.from.chat.id) 116 | return 117 | end 118 | 119 | if ok and db:get_chat_setting(msg.from.chat.id, "Weldelchain") then 120 | local key = ('chat:%d:lastwelcome'):format(msg.from.chat.id) -- get the id of the last sent welcome message 121 | local message_id = red:get(key) 122 | if message_id ~= null then 123 | api:deleteMessage(msg.from.chat.id, message_id) 124 | end 125 | red:setex(key, 259200, ok.message_id) --set the new message id to delete 126 | end 127 | end 128 | 129 | function _M:onTextMessage(blocks) 130 | local api = self.api 131 | local msg = self.message 132 | local red = self.red 133 | local db = self.db 134 | local i18n = self.i18n 135 | local api_err = self.api_err 136 | local u = self.u 137 | 138 | if blocks[1] == 'welcome' then 139 | if msg.from.chat.type == "private" 140 | or not msg.from:can("can_change_info") then 141 | return 142 | end 143 | 144 | local input = blocks[2] 145 | if not input and not msg.reply then 146 | msg:send_reply(i18n("Welcome and...?")) 147 | return 148 | end 149 | 150 | local hash = 'chat:'..msg.from.chat.id..':welcome' 151 | 152 | if not input and msg.reply then 153 | local replied_to = msg.reply:type() 154 | if replied_to == 'sticker' or replied_to == 'gif' then 155 | local file_id 156 | if replied_to == 'sticker' then 157 | file_id = msg.reply.sticker.file_id 158 | else 159 | file_id = msg.reply.document.file_id 160 | end 161 | red:hset(hash, 'type', 'media') 162 | red:hset(hash, 'content', file_id) 163 | if msg.reply.caption then 164 | red:hset(hash, 'caption', msg.reply.caption) 165 | else 166 | red:hdel(hash, 'caption') --remove the caption key if the new media doesn't have a caption 167 | end 168 | -- turn on the welcome message in the group settings 169 | db:set_chat_setting(msg.from.chat.id, "Welcome", true) 170 | msg:send_reply(i18n("A form of media has been set as the welcome message: `%s`"):format(replied_to), "Markdown") 171 | else 172 | msg:send_reply(i18n("Reply to a `sticker` or a `gif` to set them as the *welcome message*"), "Markdown") 173 | end 174 | else 175 | local reply_markup, new_text = u:reply_markup_from_text(input) 176 | 177 | new_text = new_text:gsub('$rules', u:deeplink_constructor(msg.from.chat.id, 'rules')) 178 | 179 | red:hset(hash, 'type', 'custom') 180 | red:hset(hash, 'content', input) 181 | 182 | local ok, err = msg:send_reply(new_text, "Markdown", reply_markup) 183 | if not ok then 184 | red:hset(hash, 'type', 'no') --if wrong markdown, remove 'custom' again 185 | red:hset(hash, 'content', 'no') 186 | api:sendMessage(msg.from.chat.id, api_err:trans(err), "Markdown") 187 | else 188 | -- turn on the welcome message in the group settings 189 | db:set_chat_setting(msg.from.chat.id, "Welcome", true) 190 | local id = ok.message_id 191 | api:editMessageText(msg.from.chat.id, id, nil, i18n("*Custom welcome message saved!*"), "Markdown") 192 | end 193 | end 194 | end 195 | 196 | if blocks[1] == 'new_chat_member' then 197 | if not msg.service then return end 198 | 199 | local extra 200 | if msg.from.user.id ~= msg.new_chat_member.id then extra = msg.from.user end 201 | u:logEvent(blocks[1], msg, extra) 202 | 203 | local stop = ban_bots(self, msg) 204 | if stop then return end 205 | 206 | apply_default_permissions(self, msg) 207 | 208 | send_welcome(self, msg) 209 | 210 | if db:get_user_setting(msg.new_chat_member.id, 'rules_on_join') then 211 | local rules = red:hget('chat:'..msg.from.chat.id..':info', 'rules') 212 | if rules ~= null then 213 | api:sendMessage(msg.new_chat_member.id, rules, "Markdown") 214 | end 215 | end 216 | end 217 | end 218 | 219 | _M.triggers = { 220 | onTextMessage = { 221 | config.cmd..'(welcome) (.*)$', 222 | config.cmd..'set(welcome) (.*)$', 223 | config.cmd..'(welcome)$', 224 | config.cmd..'set(welcome)$', 225 | config.cmd..'(goodbye) (.*)$', 226 | config.cmd..'set(goodbye) (.*)$', 227 | 228 | '^###(new_chat_member)$' 229 | } 230 | } 231 | 232 | return _M 233 | -------------------------------------------------------------------------------- /lua/groupbutler/storage/memory.lua: -------------------------------------------------------------------------------- 1 | local MemoryStorage = {} 2 | local storage = {} 3 | 4 | local function set(entity, id, property, value) 5 | if type(storage[entity]) ~= "table" then 6 | storage[entity] = {} 7 | end 8 | if type(storage[entity][id]) ~= "table" then 9 | storage[entity][id] = {} 10 | end 11 | storage[entity][id][property] = value 12 | end 13 | 14 | local function get(entity, id, property) 15 | if type(storage[entity]) == "table" 16 | and type(storage[entity][id]) == "table" 17 | and storage[entity][id][property] then 18 | return storage[entity][id][property] 19 | end 20 | end 21 | 22 | local function set_chat_user(entity, chat_id, user_id, property, value) 23 | if type(storage[entity]) ~= "table" then 24 | storage[entity] = {} 25 | end 26 | if type(storage[entity][chat_id]) ~= "table" then 27 | storage[entity][chat_id] = {} 28 | end 29 | if type(storage[entity][chat_id][user_id]) ~= "table" then 30 | storage[entity][chat_id][user_id] = {} 31 | end 32 | storage[entity][chat_id][user_id][property] = value 33 | end 34 | 35 | local function get_chat_user(entity, chat_id, user_id, property) 36 | if type(storage[entity]) == "table" 37 | and type(storage[entity][chat_id]) == "table" 38 | and type(storage[entity][user_id]) == "table" 39 | and storage[entity][chat_id][user_id][property] then 40 | return storage[entity][chat_id][user_id][property] 41 | end 42 | end 43 | 44 | local function delete(entity, id) 45 | if storage[entity] 46 | and storage[entity][id] then 47 | storage[entity][id] = nil 48 | end 49 | end 50 | 51 | function MemoryStorage:new() 52 | return setmetatable({}, {__index = self}) 53 | end 54 | 55 | function MemoryStorage:cacheUser(user) -- luacheck: ignore 212 56 | for k,v in user do 57 | if k ~= "id" then 58 | set("user", user.id, k, v) 59 | end 60 | end 61 | end 62 | 63 | function MemoryStorage:cacheChat(chat) -- luacheck: ignore 212 64 | for k,v in chat do 65 | if k ~= "id" then 66 | set("chat", chat.id, k, v) 67 | end 68 | end 69 | end 70 | 71 | function MemoryStorage:cacheChatMember(member) 72 | for k,v in member do 73 | if k == "user" then 74 | self:cacheUser(member.user) 75 | elseif k == "chat" then 76 | self:cacheChat(member.chat) 77 | else 78 | set_chat_user("member", member.chat.id, member.user.id, k, v) 79 | end 80 | end 81 | end 82 | 83 | function MemoryStorage:cacheAdmins(chat, list) -- luacheck: ignore 212 84 | set("chat", chat.id, "adminlist", list) 85 | end 86 | 87 | function MemoryStorage:getUserProperty(user, property) -- luacheck: ignore 212 88 | return get("user", user.id, property) 89 | end 90 | 91 | function MemoryStorage:getChatProperty(chat, property) -- luacheck: ignore 212 92 | return get("chat", chat.id, property) 93 | end 94 | 95 | function MemoryStorage:getChatMemberProperty(member, property) -- luacheck: ignore 212 96 | return get_chat_user("member", member.chat.id, member.user.id, property) 97 | end 98 | 99 | function MemoryStorage:getUserId(username) -- luacheck: ignore 212 100 | for k,v in storage.user do 101 | if v and v.username == username then 102 | return k 103 | end 104 | end 105 | end 106 | 107 | function MemoryStorage:getChatAdministratorsCount(chat) -- luacheck: ignore 212 108 | return #get("chat", chat.id, "adminlist") 109 | end 110 | 111 | function MemoryStorage:getChatAdministratorsList(chat) -- luacheck: ignore 212 112 | local list = get("chat", chat.id, "adminlist") 113 | if type(list) ~= "table" then 114 | return 115 | end 116 | local retval = {} 117 | for i=1,#list do 118 | retval[i] = list[i].id 119 | end 120 | return retval 121 | end 122 | 123 | function MemoryStorage:deleteChat(chat) -- luacheck: ignore 212 124 | delete("chat", chat.id) 125 | end 126 | 127 | function MemoryStorage:set_keepalive() -- luacheck: ignore 212 128 | end 129 | 130 | function MemoryStorage:get_reused_times() -- luacheck: ignore 212 131 | return "Unknown" 132 | end 133 | 134 | return MemoryStorage 135 | -------------------------------------------------------------------------------- /lua/groupbutler/storage/mixed.lua: -------------------------------------------------------------------------------- 1 | local RedisStorage = require("groupbutler.storage.redis") 2 | local _, PostgresStorage = pcall(function() return require("groupbutler.storage.postgres") end) 3 | 4 | local MixedStorage = {} 5 | 6 | function MixedStorage:new(redis_db) 7 | setmetatable(self, {__index = RedisStorage}) -- Any unimplemented method falls back to redis 8 | local obj = setmetatable({}, {__index = self}) 9 | obj.redis = redis_db 10 | obj.redis_storage = RedisStorage:new(redis_db) 11 | _, obj.postgres_storage = pcall(function() return PostgresStorage:new() end) 12 | return obj 13 | end 14 | 15 | function MixedStorage:cacheUser(user) 16 | local res, ok = pcall(function() return self.postgres_storage:cacheUser(user) end) 17 | if not res or not ok then 18 | self.redis_storage:cacheUser(user) 19 | end 20 | end 21 | 22 | function MixedStorage:getUserId(username) 23 | local ok, id = pcall(function() return self.postgres_storage:getUserId(username) end) 24 | if not ok or not id then 25 | return self.redis_storage:getUserId(username) 26 | end 27 | return id 28 | end 29 | 30 | function MixedStorage:getUserProperty(user, property) 31 | local ok, retval = pcall(function() return self.postgres_storage:getUserProperty(user, property) end) 32 | if not ok or not retval then 33 | return self.redis_storage:getUserProperty(user, property) 34 | end 35 | return retval 36 | end 37 | 38 | function MixedStorage:getChatProperty(chat, property) 39 | local ok, retval = pcall(function() return self.postgres_storage:getChatProperty(chat, property) end) 40 | if not ok or not retval then 41 | return self.redis_storage:getChatProperty(chat, property) 42 | end 43 | return retval 44 | end 45 | 46 | function MixedStorage:getChatMemberProperty(member, property) 47 | local ok, retval = pcall(function() return self.postgres_storage:getChatMemberProperty(member, property) end) 48 | if not ok or not retval then 49 | return self.redis_storage:getChatMemberProperty(member, property) 50 | end 51 | return retval 52 | end 53 | 54 | function MixedStorage:cacheChat(chat) 55 | local res, ok = pcall(function() return self.postgres_storage:cacheChat(chat) end) 56 | if not res or not ok then 57 | self.redis_storage:cacheChat(chat) 58 | end 59 | end 60 | 61 | function MixedStorage:getChatAdministratorsCount(chat) 62 | local ok, title = pcall(function() return self.postgres_storage:getChatAdministratorsCount(chat) end) 63 | if not ok or not title then 64 | return self.redis_storage:getChatAdministratorsCount(chat) 65 | end 66 | return title 67 | end 68 | 69 | function MixedStorage:getChatAdministratorsList(chat) 70 | local ok, title = pcall(function() return self.postgres_storage:getChatAdministratorsList(chat) end) 71 | if not ok or not title then 72 | return self.redis_storage:getChatAdministratorsList(chat) 73 | end 74 | return title 75 | end 76 | 77 | function MixedStorage:deleteChat(chat) 78 | pcall(function() return self.postgres_storage:deleteChat(chat) end) 79 | self.redis_storage:deleteChat(chat) 80 | end 81 | 82 | function MixedStorage:cacheChatMember(member) 83 | pcall(function() return self.postgres_storage:cacheChatMember(member) end) 84 | end 85 | 86 | function MixedStorage:cacheAdmins(chat, list) 87 | local res, ok = pcall(function() return self.postgres_storage:cacheAdmins(chat, list) end) 88 | if not res or not ok then 89 | self.redis_storage:cacheAdmins(chat, list) 90 | end 91 | end 92 | 93 | function MixedStorage:set_keepalive() 94 | pcall(function() return self.postgres_storage:set_keepalive() end) 95 | self.redis_storage:set_keepalive() 96 | end 97 | 98 | function MixedStorage:get_reused_times() 99 | local redis = self.redis_storage:get_reused_times() 100 | local ok, postgres = pcall(function() return self.postgres_storage:get_reused_times() end) 101 | local str = "Redis: "..redis 102 | -- pgmoon does not currently implement this so it will always return "Unknown" 103 | if ok and postgres then 104 | str = str.."\nPostgres: "..postgres 105 | end 106 | return str 107 | end 108 | 109 | return MixedStorage 110 | -------------------------------------------------------------------------------- /lua/groupbutler/storage/postgres.lua: -------------------------------------------------------------------------------- 1 | local config = require("groupbutler.config") 2 | local pgmoon = require("pgmoon") 3 | local null = require("groupbutler.null") 4 | local log = require("groupbutler.logging") 5 | local Util = require("groupbutler.util") 6 | local StorageUtil = require("groupbutler.storage.util") 7 | 8 | local PostgresStorage = {} 9 | 10 | local chat_type = Util.enum({ 11 | -- private = 0, -- Not supported 12 | -- group = 1, -- Not supported 13 | supergroup = 2, 14 | channel = 3, 15 | }) 16 | 17 | local chat_member_status = Util.enum({ 18 | creator = 0, 19 | administrator = 1, 20 | member = 2, 21 | restricted = 3, 22 | left = 4, 23 | kicked = 5, 24 | }) 25 | 26 | local function interpolate(s, tab) 27 | return s:gsub('(%b{})', function(w) 28 | local v = tab[w:sub(2, -2)] 29 | if v == false then 30 | return "false" 31 | end 32 | if v == true then 33 | return "true" 34 | end 35 | if v == nil or v == null then 36 | return "NULL" 37 | end 38 | return v 39 | end) 40 | end 41 | 42 | function PostgresStorage:new() 43 | local obj = setmetatable({}, {__index = self}) 44 | obj.pg = pgmoon.new(config.postgres) 45 | assert(obj.pg:connect()) 46 | return obj 47 | end 48 | 49 | function PostgresStorage:cacheUser(user) 50 | local row = { 51 | id = user.id, 52 | first_name = self.pg:escape_literal(user.first_name) 53 | } 54 | for k, _ in pairs(user) do 55 | if StorageUtil.isUserPropertyOptional[k] then 56 | row[k] = self.pg:escape_literal(user[k]) 57 | end 58 | end 59 | local username = "" 60 | if rawget(user, "username") then 61 | username = 'UPDATE "user" SET username = NULL WHERE lower(username) = lower({username});\n' 62 | end 63 | local insert = 'INSERT INTO "user" (id, first_name' 64 | local values = ") VALUES ({id}, {first_name}" 65 | local on_conflict = " ON CONFLICT (id) DO UPDATE SET first_name = {first_name}" 66 | for k, _ in pairs(row) do 67 | if StorageUtil.isUserPropertyOptional[k] then 68 | insert = insert..", "..k 69 | values = values..", {"..k.."}" 70 | on_conflict = on_conflict..", "..k.." = {"..k.."}" 71 | end 72 | end 73 | values = values..")" 74 | local query = interpolate(username..insert..values..on_conflict, row) 75 | local ok, err = self.pg:query(query) 76 | if not ok then 77 | log.err("Query {query} failed: {err}", {query=query, err=err}) 78 | end 79 | return true 80 | end 81 | 82 | function PostgresStorage:getUserId(username) 83 | if username:byte(1) == string.byte("@") then 84 | username = username:sub(2) 85 | end 86 | local query = interpolate('SELECT id FROM "user" WHERE lower(username) = lower({username})', 87 | {username = self.pg:escape_literal(username)}) 88 | local ok = self.pg:query(query) 89 | if not ok or not ok[1] or not ok[1].id then 90 | return false 91 | end 92 | return ok[1].id 93 | end 94 | 95 | function PostgresStorage:getUserProperty(user, property) 96 | local query = interpolate('SELECT {property} FROM "user" WHERE id = {id}', { 97 | id = user.id, 98 | property = property, 99 | }) 100 | local ok = self.pg:query(query) 101 | if not ok or not ok[1] or not ok[1][property] then 102 | return nil 103 | end 104 | return ok[1][property] 105 | end 106 | 107 | function PostgresStorage:getChatProperty(chat, property) 108 | local query = interpolate('SELECT {property} FROM "chat" WHERE id = {id}', { 109 | id = chat.id, 110 | property = property, 111 | }) 112 | local ok = self.pg:query(query) 113 | if not ok or not ok[1] or not ok[1][property] then 114 | return nil 115 | end 116 | if property == "type" then 117 | return chat_type[ok[1][property]] 118 | end 119 | return ok[1][property] 120 | end 121 | 122 | function PostgresStorage:getChatMemberProperty(member, property) 123 | local query = { 124 | chat_id = member.chat.id, 125 | user_id = member.user.id, 126 | property = property, 127 | } 128 | if property == "until_date" then 129 | query.property = "date_part('epoch',until_date)::int" 130 | end 131 | local ok = self.pg:query(interpolate( 132 | 'SELECT {property} FROM "chat_user" WHERE chat_id = {chat_id} AND user_id = {user_id}', query)) 133 | if not ok or not ok[1] then 134 | return nil 135 | end 136 | if property == "until_date" 137 | and ok[1].date_part then 138 | return ok[1].date_part 139 | end 140 | if not ok[1][property] then 141 | return nil 142 | end 143 | if property == "status" then 144 | return chat_member_status[ok[1][property]] 145 | end 146 | return ok[1][property] 147 | end 148 | 149 | function PostgresStorage:cacheChat(chat) 150 | if chat.type ~= "supergroup" then -- don't cache private chats, channels, etc. 151 | return 152 | end 153 | local row = { 154 | id = chat.id, 155 | type = chat_type[chat.type], 156 | title = self.pg:escape_literal(chat.title) 157 | } 158 | for k, _ in pairs(chat) do 159 | if StorageUtil.isChatPropertyOptional[k] then 160 | row[k] = self.pg:escape_literal(chat[k]) 161 | end 162 | end 163 | local insert = 'INSERT INTO "chat" (id, type, title' 164 | local values = ") VALUES ({id}, '{type}', {title}" 165 | local on_conflict = ") ON CONFLICT (id) DO UPDATE SET title = {title}" 166 | for k, _ in pairs(row) do 167 | if StorageUtil.isChatPropertyOptional[k] then 168 | insert = insert..", "..k 169 | values = values..", {"..k.."}" 170 | on_conflict = on_conflict..", "..k.." = {"..k.."}" 171 | end 172 | end 173 | local query = interpolate(insert..values..on_conflict, row) 174 | local ok, err = self.pg:query(query) 175 | if not ok then 176 | log.err("Query {query} failed: {err}", {query=query, err=err}) 177 | end 178 | return true 179 | end 180 | 181 | function PostgresStorage:getChatAdministratorsCount(chat) 182 | local row = { 183 | chat_id = chat.id, 184 | administrator = chat_member_status["administrator"], 185 | } 186 | local query = interpolate( 187 | 'SELECT count(*) FROM "chat_user" WHERE chat_id = {chat_id} AND status = {administrator}', row) 188 | local ok = self.pg:query(query) 189 | if not ok or not ok[1] or not ok[1].count then 190 | return 0 191 | end 192 | return ok[1].count 193 | end 194 | 195 | function PostgresStorage:getChatAdministratorsList(chat) 196 | local row = { 197 | chat_id = chat.id, 198 | creator = chat_member_status["creator"], 199 | administrator = chat_member_status["administrator"], 200 | } 201 | local query = interpolate('SELECT user_id FROM "chat_user" WHERE chat_id = {chat_id}'.. 202 | 'AND (status = {creator} OR status = {administrator})', row) 203 | local ok = self.pg:query(query) 204 | if not ok or not ok[1] or not ok[1].user_id then 205 | return nil 206 | end 207 | local retval = {} 208 | for _,v in pairs(ok) do 209 | table.insert(retval, v.user_id) 210 | end 211 | return retval 212 | end 213 | 214 | function PostgresStorage:deleteChat(chat) 215 | local query = interpolate('DELETE FROM "chat" WHERE id = {id}', chat) 216 | self.pg:query(query) 217 | return true 218 | end 219 | 220 | function PostgresStorage:cacheChatMember(member) 221 | if member.chat.type ~= "supergroup" then -- don't cache private chats, channels, etc. 222 | return 223 | end 224 | do 225 | local ok = self:cacheChat(member.chat) 226 | if not ok then 227 | return false 228 | end 229 | end 230 | do 231 | local ok = self:cacheUser(member.user) 232 | if not ok then 233 | return false 234 | end 235 | end 236 | if not rawget(member, "status") then 237 | log.warn("Tried to cache member without status {chat_id}, {user_id}", { 238 | chat_id = member.chat.id, 239 | user_id = member.user.id, 240 | }) 241 | return false 242 | end 243 | local row = { 244 | chat_id = member.chat.id, 245 | user_id = member.user.id, 246 | status = chat_member_status[member.status], 247 | } 248 | local insert = 'INSERT INTO "chat_user" (chat_id, user_id, status' 249 | local values = ") VALUES ({chat_id}, {user_id}, {status}" 250 | local on_conflict = ") ON CONFLICT (chat_id, user_id) DO UPDATE SET status = {status}" 251 | for k, v in pairs(member) do 252 | if (member.status == "administrator" and StorageUtil.isAdminPermission[k]) 253 | or (member.status == "restricted" and StorageUtil.isRestrictedMemberProperty[k]) then 254 | row[k] = v 255 | insert = insert..", "..k 256 | values = values..", {"..k.."}" 257 | on_conflict = on_conflict..", "..k.." = {"..k.."}" 258 | end 259 | end 260 | if rawget(member, "until_date") then 261 | row.until_date = "to_timestamp("..member.until_date..")" 262 | end 263 | local query = interpolate(insert..values..on_conflict, row) 264 | local ok, err = self.pg:query(query) 265 | if not ok then 266 | log.err("Query {query} failed: {err}", {query=query, err=err}) 267 | end 268 | return true 269 | end 270 | 271 | function PostgresStorage:wipeAdmins(chat) 272 | local row = { 273 | chat_id = chat.id, 274 | member = chat_member_status["member"], 275 | administrator = chat_member_status["administrator"], 276 | } 277 | local set = 'UPDATE "chat_user" SET status = {member}' 278 | local where = ' WHERE chat_id = {chat_id} AND status = {administrator}' 279 | for k, _ in pairs(StorageUtil.isAdminPermission) do 280 | row[k] = false 281 | set = set..", "..k.." = {"..k.."}" 282 | end 283 | local query = interpolate(set..where, row) 284 | local ok, err = self.pg:query(query) 285 | if not ok then 286 | log.err("Query {query} failed: {err}", {query=query, err=err}) 287 | return false 288 | end 289 | return true 290 | end 291 | 292 | function PostgresStorage:cacheAdmins(chat, list) 293 | do 294 | local ok = self:wipeAdmins(chat) 295 | if not ok then 296 | return false 297 | end 298 | end 299 | for _, admin in pairs(list) do 300 | admin.chat = chat 301 | do 302 | local ok = self:cacheChatMember(admin) 303 | if not ok then 304 | return false 305 | end 306 | end 307 | end 308 | return true 309 | end 310 | 311 | function PostgresStorage:set_keepalive() 312 | return self.pg:keepalive() 313 | end 314 | 315 | function PostgresStorage:get_reused_times() -- luacheck: ignore 212 316 | return "Unknown" 317 | end 318 | 319 | return PostgresStorage 320 | -------------------------------------------------------------------------------- /lua/groupbutler/storage/redis.lua: -------------------------------------------------------------------------------- 1 | local redis = require("resty.redis") 2 | local config = require("groupbutler.config") 3 | local null = require("groupbutler.null") 4 | local log = require("groupbutler.logging") 5 | local StorageUtil = require("groupbutler.storage.util") 6 | 7 | local RedisStorage = {} 8 | 9 | local function string_toboolean(v) 10 | if v == false 11 | or v == "false" 12 | or v == "off" 13 | or v == "notok" 14 | or v == "no" then 15 | return false 16 | end 17 | if v == true 18 | or v == "true" 19 | or v == "on" 20 | or v == "ok" 21 | or v == "yes" then 22 | return true 23 | end 24 | log.warn("Tried toboolean on non truthy value") 25 | return v 26 | end 27 | 28 | local function boolean_tostring(v) 29 | if v == false then 30 | return "off" 31 | end 32 | if v == true then 33 | return "on" 34 | end 35 | log.warn("Tried tostring on non boolean value") 36 | return v 37 | end 38 | 39 | function RedisStorage:new(redis_db) 40 | local obj = setmetatable({}, {__index = self}) 41 | obj.redis = redis_db 42 | if not redis_db then 43 | obj.red = redis:new() 44 | local ok, err = obj.red:connect(config.redis.host, config.redis.port) 45 | if not ok then 46 | log.error("Redis connection failed: {err}", {err=err}) 47 | return nil, err 48 | end 49 | obj.red:select(config.redis.db) 50 | end 51 | return obj 52 | end 53 | 54 | function RedisStorage:_hget_default(hash, key, default) 55 | local val = self.redis:hget(hash, key) 56 | if val == null then 57 | return default 58 | end 59 | return val 60 | end 61 | 62 | function RedisStorage:forgetUserWarns(chat_id, user_id) 63 | self.redis:hdel("chat:"..chat_id..":warns", user_id) 64 | self.redis:hdel("chat:"..chat_id..":mediawarn", user_id) 65 | self.redis:hdel("chat:"..chat_id..":spamwarns", user_id) 66 | end 67 | 68 | function RedisStorage:get_chat_setting(chat_id, setting) 69 | if setting == "Arab" 70 | or setting == "Rtl" then 71 | local default = config.chat_settings.char[setting] 72 | return self:_hget_default("chat:"..chat_id..":char", setting, default) 73 | end 74 | local default = config.chat_settings.settings[setting] 75 | local val = self:_hget_default("chat:"..chat_id..":settings", setting, default) 76 | return string_toboolean(val) 77 | end 78 | 79 | function RedisStorage:set_chat_setting(chat_id, setting, value) 80 | if setting == "Arab" 81 | or setting == "Rtl" then 82 | self.redis:hset("chat:"..chat_id..":char", setting, boolean_tostring(value)) 83 | end 84 | self.redis:hset("chat:"..chat_id..":settings", setting, boolean_tostring(value)) 85 | end 86 | 87 | function RedisStorage:get_user_setting(user_id, setting) 88 | local default = config.private_settings[setting] 89 | local val = self:_hget_default("user:"..user_id..":settings", setting, default) 90 | return string_toboolean(val) 91 | end 92 | 93 | function RedisStorage:get_all_user_settings(user_id) 94 | local settings = self.redis:array_to_hash(self.redis:hgetall("user:"..user_id..":settings")) 95 | for setting, default in pairs(config.private_settings) do 96 | if not settings[setting] then 97 | settings[setting] = default 98 | end 99 | settings[setting] = string_toboolean(settings[setting]) 100 | end 101 | return settings 102 | end 103 | 104 | function RedisStorage:set_user_setting(user_id, setting, value) 105 | self.redis:hset("user:"..user_id..":settings", setting, boolean_tostring(value)) 106 | end 107 | 108 | function RedisStorage:toggle_user_setting(user_id, setting) 109 | self:set_user_setting(user_id, setting, not self:get_user_setting(user_id, setting)) 110 | end 111 | 112 | function RedisStorage:cacheUser(user) 113 | if rawget(user, "username") then 114 | self.redis:hset("bot:usernames", "@"..rawget(user, "username"):lower(), user.id) 115 | end 116 | end 117 | 118 | function RedisStorage:getUserId(username) 119 | if username:byte(1) ~= string.byte("@") then 120 | username = "@"..username 121 | end 122 | return tonumber(self.redis:hget("bot:usernames", username:lower())) 123 | end 124 | 125 | function RedisStorage:getUserProperty(user, property) -- luacheck: ignore 126 | end 127 | 128 | function RedisStorage:getChatProperty(chat, property) 129 | if property == "title" then 130 | local title = self.redis:get("chat:"..chat.id..":title") 131 | if title == null then 132 | return 133 | end 134 | return title 135 | end 136 | end 137 | 138 | function RedisStorage:getChatAdministratorsCount(chat) 139 | return self.redis:scard("cache:chat:"..chat.id..":admins") 140 | end 141 | 142 | function RedisStorage:getChatAdministratorsList(chat) 143 | local admins = self.redis:smembers("cache:chat:"..chat.id..":admins") or {} 144 | local owner = self.redis:get("cache:chat:"..chat.id..":owner") 145 | if owner then 146 | table.insert(admins, owner) 147 | end 148 | return admins 149 | end 150 | 151 | 152 | function RedisStorage:getChatMemberProperty(member, property) 153 | if StorageUtil.isAdminPermission[property] then 154 | local set = ("cache:chat:%s:%s:permissions"):format(member.chat.id, member.user.id) 155 | return self.redis:sismember(set, property) == 1 156 | end 157 | if property == "status" then 158 | if tonumber(self.redis:get("cache:chat:"..member.chat.id..":owner")) == member.user.id then 159 | return "creator" 160 | end 161 | if self.redis:sismember("cache:chat:"..member.chat.id..":admins", member.user.id) == 1 then 162 | return "administrator" 163 | end 164 | return nil 165 | end 166 | end 167 | 168 | function RedisStorage:cacheChat(chat) 169 | if chat.type ~= "supergroup" then -- don"t cache private chats, channels, etc. 170 | return 171 | end 172 | local keys = { 173 | ["chat:"..chat.id..":title"] = chat.title, 174 | } 175 | for k,v in pairs(keys) do 176 | self.redis:set(k, v) 177 | end 178 | end 179 | 180 | 181 | function RedisStorage:cacheAdmins(chat, list) 182 | local set = "cache:chat:"..chat.id..":admins" 183 | local cache_time = config.bot_settings.cache_time.adminlist 184 | self.redis:del(set) 185 | for _, admin in pairs(list) do 186 | if admin.status == "creator" then 187 | self.redis:set("cache:chat:"..chat.id..":owner", admin.user.id) 188 | else 189 | local set_permissions = "cache:chat:"..chat.id..":"..admin.user.id..":permissions" 190 | self.redis:del(set_permissions) 191 | for k, v in pairs(admin) do 192 | if v and StorageUtil.isAdminPermission[k] then 193 | self.redis:sadd(set_permissions, k) 194 | end 195 | end 196 | self.redis:expire(set_permissions, cache_time) 197 | end 198 | self.redis:sadd(set, admin.user.id) 199 | end 200 | self.redis:expire(set, cache_time) 201 | end 202 | 203 | function RedisStorage:deleteChat(chat) 204 | self.redis:srem("bot:groupsid", chat.id) 205 | self.redis:sadd("bot:groupsid:removed", chat.id) -- add to the list of removed groups 206 | 207 | for i=1, #config.chat_hashes do 208 | self.redis:del("chat:"..chat.id..":"..config.chat_hashes[i]) 209 | end 210 | 211 | for i=1, #config.chat_sets do 212 | self.redis:del("chat:"..chat.id..":"..config.chat_sets[i]) 213 | end 214 | 215 | for set, _ in pairs(config.chat_settings) do 216 | self.redis:del("chat:"..chat.id..":"..set) 217 | end 218 | 219 | local keys = { 220 | "cache:chat:"..chat.id..":admins", 221 | "cache:chat:"..chat.id..":owner", 222 | "chat:"..chat.id..":title", 223 | "chat:"..chat.id..":userlast", 224 | "chat:"..chat.id..":members", 225 | "chat:"..chat.id..":pin", 226 | "lang:"..chat.id, 227 | } 228 | local owner_id = self.redis:get("cache:chat:"..chat.id..":owner") 229 | if owner_id 230 | and owner_id ~= null then 231 | table.insert(keys, "cache:chat:"..chat.id..":"..owner_id..":permissions") 232 | end 233 | for _,k in pairs(keys) do 234 | self.redis:del(k) 235 | end 236 | 237 | local fields = { 238 | "bot:logchats", 239 | "bot:chats:latsmsg", 240 | "bot:chatlogs", 241 | } 242 | for _,k in pairs(fields) do 243 | self.redis:hdel(k, chat.id) 244 | end 245 | end 246 | 247 | function RedisStorage:cacheChatMember(member) -- luacheck: ignore 212 248 | end 249 | 250 | function RedisStorage:set_keepalive() 251 | self.redis:set_keepalive() 252 | end 253 | 254 | function RedisStorage:get_reused_times() 255 | return self.redis:get_reused_times() 256 | end 257 | 258 | return RedisStorage 259 | -------------------------------------------------------------------------------- /lua/groupbutler/storage/util.lua: -------------------------------------------------------------------------------- 1 | local Util = require("groupbutler.util") 2 | 3 | local StorageUtil = {} 4 | 5 | do 6 | StorageUtil.isUserPropertyRequired = { 7 | id = true, 8 | first_name = true, 9 | } Util.setDefaultTableValue(StorageUtil.isUserPropertyRequired, false) 10 | 11 | StorageUtil.isUserPropertyOptional = { 12 | last_name = true, 13 | is_bot = true, 14 | username = true, 15 | language_code = true, 16 | } Util.setDefaultTableValue(StorageUtil.isUserPropertyOptional, false) 17 | 18 | StorageUtil.isUserProperty = Util.mergeTables( 19 | StorageUtil.isUserPropertyRequired, 20 | StorageUtil.isUserPropertyOptional 21 | ) Util.setDefaultTableValue(StorageUtil.isUserProperty, false) 22 | end 23 | 24 | do 25 | StorageUtil.isChatPropertyRequired = { 26 | id = true, 27 | type = true, 28 | title = true, 29 | } Util.setDefaultTableValue(StorageUtil.isChatPropertyRequired, false) 30 | 31 | StorageUtil.isChatPropertyOptional = { 32 | username = true, 33 | invite_link = true, 34 | } Util.setDefaultTableValue(StorageUtil.isChatPropertyOptional, false) 35 | 36 | StorageUtil.isChatProperty = Util.mergeTables( 37 | StorageUtil.isChatPropertyRequired, 38 | StorageUtil.isChatPropertyOptional 39 | ) Util.setDefaultTableValue(StorageUtil.isChatProperty, false) 40 | end 41 | 42 | do 43 | StorageUtil.isChatMemberPropertyRequired = { 44 | status = true, 45 | } Util.setDefaultTableValue(StorageUtil.isChatMemberPropertyRequired, false) 46 | 47 | StorageUtil.isAdminPermission = { 48 | can_be_edited = true, 49 | can_change_info = true, 50 | can_delete_messages = true, 51 | can_invite_users = true, 52 | can_restrict_members = true, 53 | can_promote_members = true, 54 | can_pin_messages = true, 55 | } Util.setDefaultTableValue(StorageUtil.isAdminPermission, false) 56 | 57 | StorageUtil.isRestrictedMemberProperty = { 58 | until_date = true, 59 | can_send_messages = true, 60 | can_send_media_messages = true, 61 | can_send_other_messages = true, 62 | can_add_web_page_previews = true, 63 | } Util.setDefaultTableValue(StorageUtil.isRestrictedMemberProperty, false) 64 | 65 | StorageUtil.isChatMemberPropertyOptional = Util.mergeTables( 66 | StorageUtil.isAdminPermission, 67 | StorageUtil.isRestrictedMemberProperty 68 | ) Util.setDefaultTableValue(StorageUtil.isChatMemberPropertyOptional, false) 69 | 70 | StorageUtil.isChatMemberProperty = Util.mergeTables( 71 | StorageUtil.isChatMemberPropertyRequired, 72 | StorageUtil.isChatMemberPropertyOptional 73 | ) Util.setDefaultTableValue(StorageUtil.isChatMemberProperty, false) 74 | end 75 | 76 | return StorageUtil 77 | -------------------------------------------------------------------------------- /lua/groupbutler/user.lua: -------------------------------------------------------------------------------- 1 | local log = require("groupbutler.logging") 2 | local StorageUtil = require("groupbutler.storage.util") 3 | 4 | local User = {} 5 | 6 | local function p(self) 7 | return getmetatable(self).__private 8 | end 9 | 10 | function User:new(obj, private) 11 | assert(obj.id or obj.username, "User: Missing obj.id or obj.username") 12 | assert(private.api, "User: Missing private.api") 13 | assert(private.db, "User: Missing private.db") 14 | setmetatable(obj, { 15 | __index = function(s, index) 16 | if self[index] then 17 | return self[index] 18 | end 19 | return s:getProperty(index) 20 | end, 21 | __private = private, 22 | __tostring = self.__tostring, 23 | }) 24 | if not obj:checkId() then 25 | return nil, "Username not found" 26 | end 27 | return obj 28 | end 29 | 30 | function User:checkId() 31 | local username = rawget(self, "username") 32 | if username 33 | and username:byte(1) == string.byte("@") then 34 | self.username = username:sub(2) 35 | end 36 | local id = rawget(self, "id") 37 | if not id then 38 | id = p(self).db:getUserId(self.username) 39 | self.id = id 40 | if not id then 41 | return false -- No cached id for this username 42 | end 43 | local user = p(self).api:getChat(id) 44 | if not user -- Api call failed 45 | or not user.username then -- User removed their username 46 | return true -- Assuming it's the same user 47 | end 48 | if self.username ~= user.username then -- Got a different user than expected 49 | User:new(user, p(self)):cache() -- Update cache with the different user so this doesn't happen again 50 | return false 51 | end 52 | end 53 | return true 54 | end 55 | 56 | function User:getProperty(index) 57 | if not StorageUtil.isUserProperty[index] then 58 | log.warn("Tried to get invalid user property {property}", {property=index}) 59 | return nil 60 | end 61 | local property = rawget(self, index) 62 | if property == nil then 63 | property = p(self).db:getUserProperty(self, index) 64 | if property == nil then 65 | local ok = p(self).api:getChat(self.id) 66 | if not ok then 67 | log.warn("User: Failed to get {property} for {id}", { 68 | property = index, 69 | id = self.id, 70 | }) 71 | return nil 72 | end 73 | for k,v in pairs(ok) do 74 | self[k] = v 75 | end 76 | self:cache() 77 | property = rawget(self, index) 78 | end 79 | self[index] = property 80 | end 81 | return property 82 | end 83 | 84 | function User:__tostring() 85 | if self.first_name then 86 | if self.last_name then 87 | return self.first_name.." "..self.last_name 88 | end 89 | return self.first_name 90 | end 91 | if self.username then 92 | return self.username 93 | end 94 | return self.id 95 | end 96 | 97 | function User:cache() 98 | p(self).db:cacheUser(self) 99 | end 100 | 101 | function User:getLink() 102 | return ('%s'):format(self.id, tostring(self):escape_html()) 103 | end 104 | 105 | return User 106 | -------------------------------------------------------------------------------- /lua/groupbutler/util.lua: -------------------------------------------------------------------------------- 1 | local json = require("cjson") 2 | 3 | -- Stateless utility functions 4 | local Util = {} 5 | 6 | function Util.Enum(t) 7 | local new_t = {} 8 | for k,v in pairs(t) do 9 | new_t[k] = v 10 | new_t[v] = k 11 | end 12 | return new_t 13 | end 14 | 15 | function Util.setDefaultTableValue(t, d) 16 | return setmetatable(t, { 17 | __index = function() 18 | return d 19 | end 20 | }) 21 | end 22 | 23 | function Util.mergeTables(t1, t2) 24 | local t3 = json.decode(json.encode(t1)) -- TODO: Copy t1 properly. This is ugly 25 | for k, v in pairs(t2) do 26 | if (type(v) == "table") and (type(t1[k] or false) == "table") then 27 | t3[k] = Util.mergeTables(t1[k], t2[k]) 28 | else 29 | t3[k] = v 30 | end 31 | end 32 | return t3 33 | end 34 | 35 | return Util 36 | -------------------------------------------------------------------------------- /lua/init_nginx.lua: -------------------------------------------------------------------------------- 1 | local config = require "groupbutler.config" 2 | local methods = require "telegram-bot-api.methods" 3 | 4 | local _M = {} 5 | 6 | function _M.set_webhook() 7 | local api = methods:new(config.telegram.token) 8 | if not config.telegram.webhook.url and not config.telegram.webhook.domain then 9 | ngx.log(ngx.WARN, "No webhook config detected. Check your config or set the webhook by yourself.") 10 | return 11 | end 12 | 13 | local body = { 14 | certificate = config.telegram.webhook.certificate, 15 | max_connections = config.telegram.webhook.max_connections, 16 | allowed_updates = config.telegram.allowed_updates, 17 | } 18 | 19 | if config.telegram.webhook.url then 20 | ngx.log(ngx.NOTICE, "Using manual webhook setup. Token check will be disabled.") 21 | body.url = config.telegram.webhook.url 22 | api:set_webhook(body) 23 | return 24 | end 25 | 26 | ngx.log(ngx.NOTICE, "Using express webhook setup.") 27 | body.url = "https://"..config.telegram.webhook.domain.."/?token="..config.telegram.token 28 | api:set_webhook(body) 29 | return 30 | end 31 | 32 | return _M 33 | -------------------------------------------------------------------------------- /lua/vendor/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/group-butler/GroupButler/63605252e0c677ff8dbbbe44f8369276c0b29023/lua/vendor/.gitkeep -------------------------------------------------------------------------------- /lua/vendor/multipart-post.lua: -------------------------------------------------------------------------------- 1 | local ltn12 = require "ltn12" 2 | 3 | local fmt = function(p, ...) 4 | if select('#', ...) == 0 then 5 | return p 6 | else return string.format(p, ...) end 7 | end 8 | 9 | local tprintf = function(t, p, ...) 10 | t[#t+1] = fmt(p, ...) 11 | end 12 | 13 | local append_data = function(r, k, data, extra) 14 | tprintf(r, "content-disposition: form-data; name=\"%s\"", k) 15 | if extra.filename then 16 | tprintf(r, "; filename=\"%s\"", extra.filename) 17 | end 18 | if extra.content_type then 19 | tprintf(r, "\r\ncontent-type: %s", extra.content_type) 20 | end 21 | if extra.content_transfer_encoding then 22 | tprintf( 23 | r, "\r\ncontent-transfer-encoding: %s", 24 | extra.content_transfer_encoding 25 | ) 26 | end 27 | tprintf(r, "\r\n\r\n") 28 | tprintf(r, data) 29 | tprintf(r, "\r\n") 30 | end 31 | 32 | local gen_boundary = function() 33 | local t = {"BOUNDARY-"} 34 | for i=2,17 do t[i] = string.char(math.random(65, 90)) end 35 | t[18] = "-BOUNDARY" 36 | return table.concat(t) 37 | end 38 | 39 | local encode = function(t, boundary) 40 | boundary = boundary or gen_boundary() 41 | local r = {} 42 | local _t 43 | for k,v in pairs(t) do 44 | tprintf(r, "--%s\r\n", boundary) 45 | _t = type(v) 46 | if _t == "string" then 47 | append_data(r, k, v, {}) 48 | elseif _t == "table" then 49 | assert(v.data, "invalid input") 50 | local extra = { 51 | filename = v.filename or v.name, 52 | content_type = v.content_type or v.mimetype 53 | or "application/octet-stream", 54 | content_transfer_encoding = v.content_transfer_encoding or "binary", 55 | } 56 | append_data(r, k, v.data, extra) 57 | else error(string.format("unexpected type %s", _t)) end 58 | end 59 | tprintf(r, "--%s--\r\n", boundary) 60 | return table.concat(r), boundary 61 | end 62 | 63 | local gen_request = function(t) 64 | local boundary = gen_boundary() 65 | local s = encode(t, boundary) 66 | return { 67 | method = "POST", 68 | source = ltn12.source.string(s), 69 | headers = { 70 | ["content-length"] = #s, 71 | ["content-type"] = fmt("multipart/form-data; boundary=%s", boundary), 72 | }, 73 | } 74 | end 75 | 76 | return { 77 | encode = encode, 78 | gen_request = gen_request, 79 | } 80 | -------------------------------------------------------------------------------- /lua/vendor/telegram-bot-api/utilities.lua: -------------------------------------------------------------------------------- 1 | local _M = {} 2 | 3 | _M.InlineKeyboardMarkup = {} 4 | -- _M.ReplyKeyboardMarkup = {} 5 | -- _M.ReplyKeyboardRemove = {} 6 | -- _M.ForceReply = {} 7 | 8 | function _M.InlineKeyboardMarkup:new(obj) 9 | obj = obj or {inline_keyboard = {}} 10 | setmetatable(obj, {__index = self}) 11 | return obj 12 | end 13 | 14 | function _M.InlineKeyboardMarkup:row(...) 15 | table.insert(self.inline_keyboard, {...}) 16 | return self 17 | end 18 | 19 | return _M 20 | -------------------------------------------------------------------------------- /polling.lua: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env lua 2 | 3 | package.path="./lua/?.lua;./lua/vendor/?.lua;"..package.path 4 | io.stdout:setvbuf "no" -- switch off buffering for stdout 5 | 6 | local plugins = require "groupbutler.plugins" 7 | local main = require "groupbutler.main" 8 | local config = require "groupbutler.config" 9 | local log = require "groupbutler.logging" 10 | local methods = require "telegram-bot-api.methods" 11 | local api = methods:new(config.telegram.token) 12 | 13 | local bot = api:getMe() 14 | local last_update, last_cron, current 15 | 16 | function bot.init(on_reload) -- The function run when the bot is started or reloaded 17 | if on_reload then 18 | package.loaded.config = nil 19 | package.loaded.languages = nil 20 | package.loaded.utilities = nil 21 | end 22 | 23 | log.info('BOT RUNNING: [@{username}] [{first_name}] [{bot_id}]', 24 | { 25 | username = bot.username, 26 | first_name = bot.first_name, 27 | bot_id = ("%d"):format(bot.id), 28 | }) 29 | 30 | last_update = last_update or -2 -- skip pending updates 31 | last_cron = last_cron or os.time() -- the time of the last cron job 32 | 33 | if on_reload then 34 | return #plugins 35 | else 36 | bot.start_timestamp = os.time() 37 | current = {h = 0} 38 | bot.last = {h = 0} 39 | end 40 | end 41 | 42 | bot.init() 43 | 44 | api:getUpdates(nil, 1, 3600, config.telegram.allowed_updates) -- First update 45 | 46 | local function process_update() 47 | local ok, err = api:getUpdates(last_update+1) -- Get the latest updates 48 | if not ok then 49 | log.error("Connection error: {description}", err) 50 | return 51 | end 52 | -- clocktime_last_update = os.clock() 53 | for i=1, #ok do -- Go through every new message. 54 | last_update = ok[i].update_id 55 | --print(last_update) 56 | current.h = current.h + 1 57 | local update_obj = main:new(ok[i]) 58 | if not update_obj then 59 | log.error("update parser init failed") 60 | end 61 | update_obj:process() 62 | end 63 | end 64 | 65 | local function do_cron() 66 | if last_cron ~= os.date('%H') then -- Run cron jobs every hour. 67 | last_cron = os.date('%H') 68 | bot.last.h = current.h 69 | current.h = 0 70 | log.info("Cron...") 71 | for i=1, #plugins do 72 | if plugins[i].cron then -- Call each plugin's cron function, if it has one. 73 | plugins[i].cron() 74 | end 75 | end 76 | end 77 | end 78 | 79 | while true do -- Start a loop while the bot should be running. 80 | do 81 | local ok, err = pcall(process_update) 82 | if not ok then 83 | log.error("An #error occurred (process_update).\n{err}", {err = err}) 84 | end 85 | end 86 | do 87 | local ok, err = pcall(do_cron) 88 | if not ok then 89 | log.error("An #error occurred (cron).\n{err}", {err = err}) 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /schema/1.sql: -------------------------------------------------------------------------------- 1 | -- 2 | -- Tables 3 | -- 4 | 5 | CREATE TABLE "user" ( 6 | id integer NOT NULL, 7 | is_bot boolean NOT NULL, 8 | first_name text NOT NULL, 9 | 10 | last_name text, 11 | username text, 12 | language_code varchar(35), -- BCP47/RFC5646 section 4.4.1 recommended maximum IETF tag length 13 | -- photo jsonb, 14 | 15 | created_at timestamptz DEFAULT now() NOT NULL, 16 | updated_at timestamptz DEFAULT now() NOT NULL, 17 | 18 | CONSTRAINT user_pkey PRIMARY KEY (id) 19 | ); 20 | 21 | -- 22 | -- Triggers 23 | -- 24 | 25 | CREATE OR REPLACE FUNCTION trigger_set_updated_at() 26 | RETURNS TRIGGER AS $$ 27 | BEGIN 28 | NEW.updated_at = now(); 29 | RETURN NEW; 30 | END; 31 | $$ LANGUAGE plpgsql; 32 | 33 | CREATE TRIGGER set_updated_at 34 | BEFORE UPDATE ON "user" 35 | FOR EACH ROW 36 | EXECUTE PROCEDURE trigger_set_updated_at(); 37 | 38 | -- 39 | -- Indexes 40 | -- 41 | 42 | CREATE UNIQUE INDEX user_username_lower_idx ON "user" (lower(username)); 43 | -------------------------------------------------------------------------------- /schema/2.sql: -------------------------------------------------------------------------------- 1 | -- 2 | -- Tables 3 | -- 4 | 5 | CREATE TABLE "chat" ( 6 | id bigint NOT NULL, 7 | type smallint NOT NULL, 8 | title text NOT NULL, -- Only private chats don't have titles, and we already store private info under "user" 9 | 10 | username text, 11 | -- photo jsonb, 12 | -- description text, 13 | invite_link text, 14 | -- pinned_message jsonb, 15 | -- sticker_set_name text, -- Supergroups only 16 | -- can_set_sticker_set bool, -- Supergroups only 17 | 18 | created_at timestamptz DEFAULT now() NOT NULL, 19 | updated_at timestamptz DEFAULT now() NOT NULL, 20 | 21 | CONSTRAINT chat_pkey PRIMARY KEY (id) 22 | ); 23 | 24 | -- 25 | -- Triggers 26 | -- 27 | 28 | CREATE TRIGGER set_updated_at 29 | BEFORE UPDATE ON "chat" 30 | FOR EACH ROW 31 | EXECUTE PROCEDURE trigger_set_updated_at(); 32 | -------------------------------------------------------------------------------- /schema/3.sql: -------------------------------------------------------------------------------- 1 | -- 2 | -- Tables 3 | -- 4 | 5 | CREATE TABLE "chat_user" ( 6 | chat_id bigint NOT NULL REFERENCES "chat"(id) ON DELETE CASCADE, 7 | user_id integer NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, 8 | status smallint NOT NULL, 9 | 10 | -- status == administrator only 11 | can_be_edited boolean DEFAULT false NOT NULL, 12 | can_change_info boolean DEFAULT false NOT NULL, 13 | can_delete_messages boolean DEFAULT false NOT NULL, 14 | can_invite_users boolean DEFAULT false NOT NULL, 15 | can_restrict_members boolean DEFAULT false NOT NULL, 16 | can_pin_messages boolean DEFAULT false NOT NULL, 17 | can_promote_members boolean DEFAULT false NOT NULL, 18 | -- can_post_messages boolean DEFAULT false NOT NULL, -- Channels only 19 | -- can_edit_messages boolean DEFAULT false NOT NULL, -- Channels only 20 | 21 | -- status == restricted only 22 | until_date timestamptz, 23 | can_send_messages boolean DEFAULT true NOT NULL, 24 | can_send_media_messages boolean DEFAULT true NOT NULL, -- implies can_send_messages 25 | can_send_other_messages boolean DEFAULT true NOT NULL, -- implies can_send_media_messages 26 | can_add_web_page_previews boolean DEFAULT true NOT NULL, -- implies can_send_media_messages 27 | 28 | created_at timestamptz DEFAULT now() NOT NULL, 29 | updated_at timestamptz DEFAULT now() NOT NULL, 30 | 31 | CONSTRAINT chat_user_pkey PRIMARY KEY (chat_id, user_id) 32 | ); 33 | 34 | -- 35 | -- Triggers 36 | -- 37 | 38 | CREATE TRIGGER set_updated_at 39 | BEFORE UPDATE ON "chat_user" 40 | FOR EACH ROW 41 | EXECUTE PROCEDURE trigger_set_updated_at(); 42 | 43 | -- 44 | -- Updates 45 | -- 46 | 47 | ALTER TABLE "user" ALTER COLUMN is_bot DROP NOT NULL; 48 | -------------------------------------------------------------------------------- /spec/api_errors_spec.lua: -------------------------------------------------------------------------------- 1 | _G.TEST = true 2 | local ApiErrors = require "groupbutler.api_errors" 3 | 4 | local api_err = ApiErrors:new({ 5 | i18n = function(s) 6 | return s 7 | end 8 | }) 9 | 10 | describe("error translation", function() 11 | it("should return a generic message on unknown errors api errors", function() 12 | local expected = "An unknown error has ocurred" 13 | local retval = api_err:trans({ 14 | description = "Gibberish" 15 | }) 16 | assert.same(expected, retval) 17 | end) 18 | it("should return an expected message for markdown errors", function() 19 | local expected = [[This text breaks the markdown. 20 | More info about a proper use of markdown [here](https://telegram.me/GB_tutorials/10) and [here](https://telegram.me/GB_tutorials/12).]] -- luacheck: ignore 631 21 | local retval = api_err:trans({ 22 | description = "Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 53" -- luacheck: ignore 631 23 | }) 24 | assert.same(expected, retval) 25 | end) 26 | end) 27 | -------------------------------------------------------------------------------- /spec/groupbutler_spec.lua: -------------------------------------------------------------------------------- 1 | _G.TEST = true 2 | local groupbutler = require("groupbutler") 3 | local json = require("cjson") 4 | 5 | describe("mock", function() 6 | it("should deal with mock updates", function() 7 | local update = json.decode([[ 8 | { 9 | "update_id": 100, 10 | "message": { 11 | "message_id": 200, 12 | "from": { 13 | "id": 300, 14 | "is_bot": false, 15 | "first_name": "Name" 16 | }, 17 | "chat": { 18 | "id": 300, 19 | "first_name": "Name", 20 | "type": "private" 21 | }, 22 | "date": 0, 23 | "text": "Test" 24 | } 25 | } 26 | ]]) 27 | local retval = groupbutler.test(update) 28 | assert.same(nil, retval) 29 | end) 30 | end) 31 | -------------------------------------------------------------------------------- /spec/logging_spec.lua: -------------------------------------------------------------------------------- 1 | _G.TEST = true 2 | local logging = require "groupbutler.logging" 3 | 4 | -- logging.info("starting up") 5 | -- logging.error("startup failed: {name} timed out ({timeout})", {name="telegram", timeout=5}) 6 | -- logging.debug("message from {user}", {user="abc"}) 7 | 8 | -- logging.formatter = logging.json_log_formatter 9 | 10 | -- logging.info("starting up") 11 | -- logging.error("startup failed: {name} timed out ({timeout})", {name="telegram", timeout=5}) 12 | -- logging.debug("message from {user}", {user="abc"}) 13 | 14 | describe("interpolation", function() 15 | it("should interpolate stuff", function() 16 | local str = "Hello, {name}!" 17 | local tbl = {name = "Busted"} 18 | local expected = "Hello, Busted!" 19 | local retval = logging._interpolate(str, tbl) 20 | assert.same(expected, retval) 21 | end) 22 | end) 23 | --------------------------------------------------------------------------------