├── .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 |
--------------------------------------------------------------------------------