diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c9c096 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +#Binaries +admin-bot +admin +main +adminbot-deploy-db +adminbot-add-admin +database/cmd/adminbot-add-admin/adminbot-add-admin +database/cmd/adminbot-deploy-db/adminbot-deploy-db +*.exe + +#Editors folders +.idea/ +.vscode/ + +#Config files +*.toml + +#Gometalinter results +gometalinter-results.xml + +.tdlib/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..684c6a7 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,118 @@ +image: registry.gitlab.com/shitposting/golang + +variables: + REPO_NAME: "gitlab.com/shitposting/admin-bot" + +before_script: + - mkdir -p $GOPATH/src/$(dirname $REPO_NAME) + - ln -svf $CI_PROJECT_DIR $GOPATH/src/$REPO_NAME + - cd $GOPATH/src/$REPO_NAME + - eval $(ssh-agent -s) + - mkdir -p ~/.ssh + - echo "$SSH_PRIVATE_KEY" >> ~/.ssh/id_rsa + - printf "$SSH_PUBLIC_KEY" >> ~/.ssh/id_rsa.pub + - chmod 700 ~/.ssh + - chmod 600 ~/.ssh/id_rsa + - chmod 644 ~/.ssh/id_rsa.pub + - git config --global url.git@gitlab.com:.insteadOf https://gitlab.com/ + - ssh-add ~/.ssh/id_rsa + - ssh-add -l + - ssh-keyscan -t rsa gitlab.com >> ~/.ssh/known_hosts + + +stages: + - format + - test + - build + - staging + - production + +.exceptions: &exclude # use <<: *exclude to add this rule to a job + except: + changes: + - README.md + - FEATURE.md + - .gitignore + - config_example.toml + - run_gometalint.sh + +go-fmt: + stage: format + script: + - go fmt $(go list ./... | grep -v /vendor/) + <<: *exclude + + +lint_code: + stage: format + script: + - mkdir -p $GOPATH/src/$(dirname $REPO_NAME) + - ln -svf $CI_PROJECT_DIR $GOPATH/src/$REPO_NAME + - cd $GOPATH/src/$REPO_NAME + - go get -u golang.org/x/lint/golint + - golint -set_exit_status $(go list ./... | grep -v /vendor/) + allow_failure: true + <<: *exclude + +.race_detector: + stage: test + script: + - go test -race -short $(go list ./... | grep -v "documentstore") + <<: *exclude + +test: + stage: test + script: +# - go test ./... + - go test -race -short $(go list ./... | grep -v "documentstore") + <<: *exclude + +compile: + stage: build + script: + - make build + <<: *exclude + artifacts: + paths: + - admin-bot + +.test-deploy: + stage: staging + script: + - sshpass -V + - export SSHPASS=$USER_PASS + - sshpass -e ssh -p $PORT -o stricthostkeychecking=no $USER_ID@$HOSTNAME systemctl --user stop admin-bot + - sshpass -e ssh -p $PORT -o stricthostkeychecking=no $USER_ID@$HOSTNAME mv /home/$USER_ID/go/bin/admin-bot /home/$USER_ID/go/bin/admin-bot_bak + - sshpass -e scp -P $PORT -o stricthostkeychecking=no -r admin-bot $USER_ID@$HOSTNAME:/home/$USER_ID/go/bin/admin-bot + - sshpass -e ssh -p $PORT -o stricthostkeychecking=no $USER_ID@$HOSTNAME systemctl --user start admin-bot + <<: *exclude + +.stop-unit: + stage: staging + script: + - sshpass -V + - export SSHPASS=$USER_PASS + - sshpass -e ssh -p $PORT -o stricthostkeychecking=no $USER_ID@$HOSTNAME systemctl --user stop admin-bot + when: manual + <<: *exclude + +.start-unit: + stage: staging + script: + - sshpass -V + - export SSHPASS=$USER_PASS + - sshpass -e ssh -p $PORT -o stricthostkeychecking=no $USER_ID@$HOSTNAME systemctl --user start admin-bot + when: manual + <<: *exclude + +prod-deploy: + stage: production + script: + - sshpass -V + - export SSHPASS=$PROD_USER_PASS + - sshpass -e ssh -p $PROD_PORT -o stricthostkeychecking=no $PROD_USER_ID@$PROD_HOSTNAME systemctl --user stop admin-bot + - sshpass -e ssh -p $PROD_PORT -o stricthostkeychecking=no $PROD_USER_ID@$PROD_HOSTNAME mv /home/$PROD_USER_ID/go/bin/admin-bot /home/$PROD_USER_ID/go/bin/admin-bot_bak + - sshpass -e scp -P $PROD_PORT -o stricthostkeychecking=no -r admin-bot $PROD_USER_ID@$PROD_HOSTNAME:/home/$PROD_USER_ID/go/bin/admin-bot + - sshpass -e ssh -p $PROD_PORT -o stricthostkeychecking=no $PROD_USER_ID@$PROD_HOSTNAME systemctl --user start admin-bot + when: manual + <<: *exclude diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6d14d02 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +# 🇧🇷 powered + +# Pull from our golang image with tdlib installed +FROM registry.gitlab.com/shitposting/golang:latest as builder + +# Create the user and group files that will be used in the running +# container to run the process as an unprivileged user. +RUN mkdir /user && \ + echo 'adminbot:x:65534:65534:adminbot:/:' > /user/passwd && \ + echo 'adminbot:x:65534:' > /user/group + +# Set the Current Working Directory inside the container +WORKDIR $GOPATH/src/gitlab.com/shitposting/admin-bot + +# Copy everything from the current directory to the PWD(Present Working Directory) inside the container +COPY . . + +# Compile adminbot +RUN make install + +# Execution stage +FROM registry.gitlab.com/shitposting/tdlib:latest + +# Dependencies +RUN apt update && apt install -y -qq \ + gperf + +# Import the user and group files from the first stage. +COPY --from=builder /user/group /user/passwd /etc/ + +# Set the workdir +WORKDIR /home/adminbot + +# Copy the built file +COPY --from=builder /go/bin/admin-bot . + +# Run the executable +CMD ["./admin-bot", "-config", "configs/admin-bot.toml"] diff --git a/FEATURES.md b/FEATURES.md new file mode 100644 index 0000000..ab1bc4e --- /dev/null +++ b/FEATURES.md @@ -0,0 +1,97 @@ + +# Shitposting.io `admin-bot` + +## Available features + +- Ability to blacklist all kinds of media, stickers and sticker packs +- AI powered recognition of NSFW/suggestive content with automated removal +- Anti spam (an user can send a maximum of 11 text/media messages, 6 other messages or a total of 18 messages in a 10 second span) +- Anti userbot (analyzes every user that joins to check for similarities between recent joins) +- Anti flood (reduces API calls when under attack) +- User verification (requires user to press a button to verify they're human) +- Emergency mode (automatically restrict users that join, requiring approval from moderators) +- Automated deletion of non-whitelisted group/channel handles and links +- Automated deletion of messages forwarded from non-whitelisted channels +- Automated deletion of long messages (over 800 characters or with over 15 newlines) +- Automated deletion of commands to prevent spamming +- Automated reports and backups for various actions, including `@admin` mentions +- Logging of ban motivations and automated actions with the possibility to quickly undo them or to confirm them + +## Available commands in groups + +- `/ban` bans a user. +- `/banh` bans a user given their `@username`. +- `/idban` bans a user given their user id (subject to Telegram's limitations on visibility). +- `/mute` restricts a user from sending messages. +- `/nomedia` restricts a user from sending media, stickers and gifs. +- `/noother` restricts a user from sending stickers and gifs. +- `/blm` adds a media to the blacklist. +- `/bls` adds a sticker to the blacklist. +- `/blsp` adds a sticker pack to the blacklist. +- `/blh` adds one or more handles to the blacklist. **[ONLY FOR DB ADMINS]** + +### Commands usage + +All commands need to be sent as a reply to the message you want to act upon, otherwise they will be automatically deleted. + +#### `/ban` + +Bans a user. The syntax to use is `/ban motivation`. The command **will not** work if no motivation is provided. The motivation, along with additional data, will be stored in the database for future use. + +#### `/banh` + +Bans a user. The syntax to use is `/banh @username motivation`. The command **will not** work if no motivation is provided. The motivation, along with additional data, will be stored in the database for future use. + +#### `/idban` + +Bans a user. The syntax to use is `/idban userid motivation`. The command **will not** work if no motivation is provided. The motivation, along with additional data, will be stored in the database for future use. + +#### `/mute` + +Restricts a user to read only for a period of time. The syntax to use is `/mute [duration(e|w|d|h|m)]`. + +The duration parameter is optional and, if omitted or the specified duration cannot be parsed, the bot will default the duration to 12 hours. **Restricting an user for under a minute will often lead to the restriction being permanent**. + +#### `/nomedia` + +Restricts a user from sending media, stickers and gifs for a period of time. The syntax to use is `/(nomedia|nopic) [duration(e|w|d|h|m)]`. + +The duration parameter is optional and, if omitted or the specified duration cannot be parsed, the bot will default the duration to 12 hours. **Restricting an user for under a minute will often lead to the restriction being permanent**. + +#### `/nosticker` + +Restricts a user from sending stickers and gifs for a period of time. The syntax to use is `/nosticker [duration(e|w|d|h|m)]`. + +The duration parameter is optional and, if omitted or the specified duration cannot be parsed, the bot will default the duration to 12 hours. **Restricting an user for under a minute will often lead to the restriction being permanent**. + +### `/blm` + +Blacklists a sticker/photo/video/audio/voice message/video message/animation. + +### `/bls` + +Blacklists a sticker. + +### `/blsp` + +Blacklists a sticker pack. In case the sticker does not have one, it'll blacklist the single sticker. + +### `/blh` + +Blacklists handles. **ONLY DATABASE ADMINS CAN USE THIS COMMAND**. + +The bot will look for all handles present in a message: @mentions and t.me / telegram.me links. In case no handles are found, the bot will, in case the message has been forwarded, blacklist the handle of the original poster. + +### Available feaures in a private conversation **[DB ADMINS ONLY]** + +In a private conversation with the bot, an admin can perform all blacklist actions with a few additions. + +- Blacklist multiple things at once by activating blacklist all mode with the command `/blacklistall` +- Blacklist handles by sending the handle as a text message to the bot +- Pardon blacklisted content +- Whitelist photos, videos and animations recognised as unsafe by the AI +- Whitelist a channel with the command `/whitelistchannel`. +- Remove a channel from the whitelist with `/removechannel`. +- Get user informations (ex. ban status, restriction status) by forwarding text messages of an user. In case the user is banned or restricted, a button for a quick unban/unrestriction will be provided as well. +- Mod an user in the chat by using `/mod userid`. Additional information on the user will be provided and a button will need to be clicked in order to complete the action. +- Activate an emergency mode with `/emergencymode [duration]`. For the specified amount of time, users without a profile picture or an username will automatically be limited and an alert will be sent on the report channel. Emergency mode can be toggled off at any time by sending `/emergencymode` again. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8e7273b --- /dev/null +++ b/Makefile @@ -0,0 +1,47 @@ +# per gentile concessione: https://gist.github.com/subfuzion/0bd969d08fe0d8b5cc4b23c795854a13 + +SHELL := /bin/bash + +TARGET := $(shell echo $${PWD\#\#*/}) +.DEFAULT_GOAL: $(TARGET) + +VERSION := $(shell git describe --tags --abbrev=0) +BUILD := $(shell git rev-parse HEAD) +LDFLAGS=-ldflags "-X=main.Version=$(VERSION) -X=main.Build=$(BUILD)" + +SRC = $(shell find . -type f -name '*.go' -not -path "./vendor/*") + +.PHONY: all build clean install uninstall fmt simplify check run + +all: check build + +$(TARGET): $(SRC) + $(info Building $(TARGET) ${VERSION}. Build ${BUILD}) + @env CGO_CFLAGS_ALLOW="-L(.*)|-I(.*)" go build $(LDFLAGS) -o $(TARGET) + +build: $(TARGET) + @true + +clean: + @rm -f $(TARGET) + +install: + $(info Building $(TARGET) ${VERSION}. Build ${BUILD}) + @env CGO_CFLAGS_ALLOW="-L(.*)|-I(.*)" go install $(LDFLAGS) + +uninstall: clean + @rm -f $$(which ${TARGET}) + +fmt: + @gofmt -l -w $(SRC) + +simplify: + @gofmt -s -l -w $(SRC) + +check: + @test -z $(shell gofmt -l main.go | tee /dev/stderr) || echo "[WARN] Fix formatting issues with 'make fmt'" + @for d in $$(go list ./... | grep -v /vendor/); do golint $${d}; done + @go tool vet ${SRC} + +run: install + @$(TARGET)GOBUILD=go diff --git a/README.md b/README.md new file mode 100644 index 0000000..5da3ef8 --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# Shitposting.io `admin-bot` + +In order to simplify possible database upgrades, it is recommended to run the database in a Docker container. + +## Docker + +### Install Docker + +```bash +sudo apt install docker.io +``` + +By default, Docker will require superuser permissions to run. To modify this behavior, we need to create the `docker` group if it doesn't already exist, add the connected `$USER` to the `docker` group and relog to apply changes: + +```bash +sudo groupadd docker +sudo usermod -aG docker $USER +exit +``` + +### Docker configuration + +Pull the latest PostgreSQL container from the official repository: + +```bash +docker pull postgres:latest +``` + +Run the container and publish the database port to localhost and add an optional name: + +```bash +docker run -p 127.0.0.1:5432:5432 --name=automod postgres +``` + +## PostgreSQL + +In case you aren't using the PostgreSQL docker container you can install the service by using the following command: + +```bash +sudo apt install postgresql postgresql-contrib +``` + +Log into Postgres: + +```bash +psql -h localhost -U postgres +``` + +Create the database, the user and grant the user permissions on the table: + +```sql +CREATE DATABASE automod; +CREATE USER automod WITH PASSWORD 'automod'; +GRANT ALL PRIVILEGES ON DATABASE "automod" TO automod; +``` + +## Configuration + +It is now necessary to fill in the required data in the configuration file. To do so it's possible to rename `config_example.toml` to `config.toml` and set the required values. + +## Table creation + +Go to the directory `database/cmd/adminbot-deploy-db`, compile the go file and run it specifying the path to the `config.toml` file: + +```bash +cd database/cmd/adminbot-deploy-db +go build +./adminbot-deploy-database -config path/to/config.toml +``` + +## Admin creation + +Since this bot will only reply to users whose Telegram ID is in the database, it is necessary to add them to the admin table (to get the Telegram ID of an user you can send a message to [@rawdatabot](https://t.me/rawdatabot)). + +Go to the directory `database/cmd/adminbot-add-user`, compile the go file and run it run it specifying the path to the `config.toml` file and the `userid` to add: + +```bash +cd database/cmd/adminbot-add-user +go build +./adminbot-add-user -userid id_to_add -config path/to/config.toml -role mod/admin +``` diff --git a/adminbot/ban.go b/adminbot/ban.go new file mode 100644 index 0000000..c16eabc --- /dev/null +++ b/adminbot/ban.go @@ -0,0 +1,101 @@ +package adminbot + +import ( + "github.com/pkg/errors" + "github.com/shitpostingio/admin-bot/reports" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/shitpostingio/admin-bot/api" + "github.com/shitpostingio/admin-bot/api/tdlib" + "github.com/shitpostingio/admin-bot/callback/buttons" + "github.com/shitpostingio/admin-bot/database/database" + "github.com/shitpostingio/admin-bot/telegram" + utility "github.com/shitpostingio/admin-bot/utility/cache" + log "github.com/sirupsen/logrus" +) + +// BanUser bans a user given their tgbotapi.User. +// It will also add the ban data to the database and report it to the report channel. +func BanUser(bannedUser, moderator *tgbotapi.User, reason string, chatID int64) error { + + err := api.BanUser(bannedUser.ID, moderator.ID, chatID) + if err != nil { + return errors.Errorf("BanUser: unable to ban user with id %d: %s", bannedUser.ID, err) + } + + updateBanData(bannedUser, moderator, reason) + reportBan(bannedUser, moderator, reason) + return nil + +} + +// BanUserByID bans a user given their userID. +// It will also add the ban data to the database and report it to the report channel. +func BanUserByID(bannedUserID int64, moderator *tgbotapi.User, reason string, chatID int64) (*tgbotapi.User, error) { + + tdlibUser, err := api.BanUserByID(bannedUserID, moderator.ID, chatID) + if err != nil { + return nil, errors.Errorf("BanUserByID: unable to ban user with id %d: %s", bannedUserID, err) + } + + user := tdlib.GetTgbotapiUserFromTdlibUser(tdlibUser) + updateBanData(user, moderator, reason) + reportBan(user, moderator, reason) + return user, nil + +} + +// BanUserByUsername bans a user their its username. +// It will also add the ban data to the database and report it to the report channel. +func BanUserByUsername(username string, moderator *tgbotapi.User, reason string, chatID int64) (*tgbotapi.User, error) { + + tdlibUser, err := api.BanUserByUsername(username, moderator.ID, chatID) + if err != nil { + return nil, errors.Errorf("BanUserByUsername: unable to ban user with username %s: %s", username, err) + } + + user := tdlib.GetTgbotapiUserFromTdlibUser(tdlibUser) + updateBanData(user, moderator, reason) + reportBan(user, moderator, reason) + return user, nil + +} + +/* ------------------------------------------------------------------------------ */ + +// updateBanData removes the user from the mods if they were one +// and adds the ban data to the database. +func updateBanData(bannedUser, moderator *tgbotapi.User, reason string) { + + wasMod := utility.RemoveFromMods(bannedUser.ID) + if wasMod { + + demotionMessage := reports.ModeratorDemoted(moderator.ID, telegram.GetName(moderator), bannedUser.ID, telegram.GetName(bannedUser)) + _ = reports.Report(demotionMessage, reports.URGENT) + log.Warn(demotionMessage) + + err := database.RemoveModerator(bannedUser.ID) + if err != nil { + _ = reports.Report(reports.ModeratorCannotBeRemovedFromTable(bannedUser.ID, telegram.GetName(bannedUser)), reports.NON_URGENT) + } + } + + _, err := database.AddBan(bannedUser.ID, moderator.ID, reason) + if err != nil { + log.Error("updateBanData:", err) + } + +} + +// reportBan reports the ban to the report channel. +func reportBan(bannedUser, moderator *tgbotapi.User, reason string) { + + reportText := reports.UserBanned(moderator.ID, telegram.GetName(moderator), bannedUser.ID, telegram.GetName(bannedUser), reason) + log.Info(reportText) + + reportMarkup := buttons.CreateKeyboardWithOneRow(buttons.CreateUnbanButton(bannedUser.ID)) + err := reports.ReportWithMarkup(reportText, reportMarkup, reports.URGENT) + if err != nil { + log.Error("reportBan: unable to send ban report:", err) + } +} diff --git a/adminbot/callback.go b/adminbot/callback.go new file mode 100644 index 0000000..02ff2c0 --- /dev/null +++ b/adminbot/callback.go @@ -0,0 +1,10 @@ +package adminbot + +import ( + "github.com/shitpostingio/admin-bot/api" +) + +// SendCallbackWithAlert answer a callback query with an alert popup +func SendCallbackWithAlert(id, text string) error { + return api.SendCallbackWithAlert(id, text) +} diff --git a/adminbot/chat.go b/adminbot/chat.go new file mode 100644 index 0000000..b098a76 --- /dev/null +++ b/adminbot/chat.go @@ -0,0 +1,91 @@ +package adminbot + +import ( + "fmt" + "github.com/shitpostingio/admin-bot/reports" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + log "github.com/sirupsen/logrus" + + "github.com/shitpostingio/admin-bot/api" + "github.com/shitpostingio/admin-bot/repository" + "github.com/shitpostingio/admin-bot/telegram" +) + +const ( + groupNotAllowed = "I'm sorry but @%s is not allowed to be in this group.\nTo have me here contact us: @shitpost" + channelNotAllowed = "I'm sorry but @%s is not allowed to be in this channel.\nTo have me here contact us: @shitpost" +) + +// LeaveUnauthorizedGroup makes the bot leave a group it wasn't authorized to be in. +// Before doing so, the bot will send a message saying it's not authorized +// and link to the channel, so that the users can contact us. +// It will also log where it has been added, so we can check. +func LeaveUnauthorizedGroup(msg *tgbotapi.Message) { + + groupNotAllowedText := fmt.Sprintf(groupNotAllowed, repository.Bot.Self.UserName) + _ = SendPlainTextMessage(msg.Chat.ID, groupNotAllowedText, false) + + response, err := api.LeaveChat(msg.Chat.ID) + if err != nil { + + report := fmt.Sprintf("Unable to leave unauthorized group with ID %d", msg.Chat.ID) + + if response != nil { + log.Error(report, "(", response.ErrorCode, ":", response.Description, ")") + } else { + log.Error(report) + } + + return + + } + + var reportText string + if msg.Chat.UserName != "" { + reportText = reports.UnauthorizedPublicGroup(msg.Chat.Title, msg.Chat.UserName, msg.Chat.ID, + telegram.GetName(msg.From), msg.From.ID) + } else { + reportText = reports.UnauthorizedPrivateGroup(msg.Chat.Title, msg.Chat.ID, + telegram.GetName(msg.From), msg.From.ID) + } + + _ = reports.Report(reportText, reports.NON_URGENT) + log.Warn(reportText) +} + +// LeaveUnauthorizedChannel makes the bot leave a channel it wasn't authorized to be in. +// Before doing so, the bot will send a message saying it's not authorized +// and link to the channel, so that the users can contact us. +// It will also log where it has been added, so we can check. +func LeaveUnauthorizedChannel(post *tgbotapi.Message) { + + channelNotAllowedText := fmt.Sprintf(channelNotAllowed, repository.Bot.Self.UserName) + _ = SendPlainTextMessage(post.Chat.ID, channelNotAllowedText, false) + + response, err := api.LeaveChat(post.Chat.ID) + if err != nil { + + report := fmt.Sprintf("Unable to leave unauthorized channel with ID %d", post.Chat.ID) + + if response != nil { + log.Error(report, "(", response.ErrorCode, ":", response.Description, ")") + } else { + log.Error(report) + } + + return + + } + + var reportText string + if post.Chat.UserName != "" { + reportText = reports.UnauthorizedPublicChannel(post.Chat.Title, post.Chat.UserName, post.Chat.ID) + } else { + reportText = reports.UnauthorizedPrivateChannel(post.Chat.Title, post.Chat.ID) + } + + _ = reports.Report(reportText, reports.NON_URGENT) + log.Warn(reportText) + +} diff --git a/adminbot/delete.go b/adminbot/delete.go new file mode 100644 index 0000000..3466f8a --- /dev/null +++ b/adminbot/delete.go @@ -0,0 +1,41 @@ +package adminbot + +import ( + "github.com/shitpostingio/admin-bot/reports" + log "github.com/sirupsen/logrus" + + "github.com/shitpostingio/admin-bot/api" +) + +// DeleteMessage deletes a `tgbotapi.Message`, performing the request in +// a non-blocking, rate-limited way. +func DeleteMessage(chatID int64, messageID int) { + go func() { + err := api.DeleteMessage(chatID, messageID) + if err != nil { + log.Error("DeleteMessage", err) + } + }() +} + +func DeleteMultipleMessages(chatID int64, messageIDs ...int) { + go func() { + err := api.DeleteMultipleMessages(chatID, messageIDs) + if err != nil { + log.Error("DeleteMultipleMessages", err) + } + }() +} + +// DeleteMessageAndLog deletes a message using `DeleteMessage` and logs the `logText` +func DeleteMessageAndLog(logText string, chatID int64, messageID int) { + DeleteMessage(chatID, messageID) + log.Info(logText) +} + +// DeleteMessageAndReport deletes a message and logs the result using `DeleteMessageAndLog`. +// the `reportText` is then sent on the report channel. +func DeleteMessageAndReport(reportText string, chatID int64, messageID int) { + DeleteMessageAndLog(reportText, chatID, messageID) + _ = reports.Report(reportText, reports.NON_URGENT) +} diff --git a/adminbot/edit.go b/adminbot/edit.go new file mode 100644 index 0000000..438afd8 --- /dev/null +++ b/adminbot/edit.go @@ -0,0 +1,18 @@ +package adminbot + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "github.com/shitpostingio/admin-bot/api" +) + +// EditMessageText edits a text message in a rate limited fashion. +func EditMessageText(messageID int, chatID int64, text, parseMode string, replyMarkup *tgbotapi.InlineKeyboardMarkup) error { + _, err := api.EditMessageText(messageID, chatID, text, parseMode, replyMarkup) + return err +} + +// EditMessageReplyMarkup edits the reply markup in a message in a rate limited fashion. +func EditMessageReplyMarkup(messageID int, chatID int64, replyMarkup *tgbotapi.InlineKeyboardMarkup) (*tgbotapi.Message, error) { + return api.EditMessageReplyMarkup(messageID, chatID, replyMarkup) +} diff --git a/adminbot/file.go b/adminbot/file.go new file mode 100644 index 0000000..9eff098 --- /dev/null +++ b/adminbot/file.go @@ -0,0 +1,12 @@ +package adminbot + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "github.com/shitpostingio/admin-bot/api" +) + +// GetTelegramFile gets a telegram file given its file ID in a rate limited fashion. +func GetTelegramFile(uniqueFileID, fileID string) (*tgbotapi.File, error) { + return api.GetTelegramFile(uniqueFileID, fileID) +} diff --git a/adminbot/forward.go b/adminbot/forward.go new file mode 100644 index 0000000..e4ee48c --- /dev/null +++ b/adminbot/forward.go @@ -0,0 +1,15 @@ +package adminbot + +import ( + "github.com/shitpostingio/admin-bot/api" +) + +// ForwardMessage forwards a message in a rate limited fashion. +func ForwardMessage(toChatID, fromChatID int64, fromMessageID int) (int, error) { + msg, err := api.ForwardMessage(toChatID, fromChatID, fromMessageID) + if err != nil { + return 0, err + } + + return msg.MessageID, err +} diff --git a/adminbot/kick.go b/adminbot/kick.go new file mode 100644 index 0000000..cabd3f7 --- /dev/null +++ b/adminbot/kick.go @@ -0,0 +1,25 @@ +package adminbot + +import ( + "fmt" + + log "github.com/sirupsen/logrus" + + "github.com/shitpostingio/admin-bot/api" +) + +// KickUser kicks a user from a group. +func KickUser(kickedUserID, moderatorUserID int64, chatID int64) { + response, err := api.KickUser(kickedUserID, moderatorUserID, chatID) + if err != nil { + + kickMsg := fmt.Sprintf("Unable to kick user with id %d: %s", kickedUserID, err) + + if response != nil { + log.Error(kickMsg, "(", response.ErrorCode, ":", response.Description) + } else { + log.Error(kickMsg) + } + + } +} diff --git a/adminbot/promotion.go b/adminbot/promotion.go new file mode 100644 index 0000000..eefe4ab --- /dev/null +++ b/adminbot/promotion.go @@ -0,0 +1,31 @@ +package adminbot + +import ( + log "github.com/sirupsen/logrus" + + "github.com/shitpostingio/admin-bot/api" + "github.com/shitpostingio/admin-bot/database/database" +) + +// PromoteToMod promotes a user to a moderator. +func PromoteToMod(userID int64, chatID int64) error { + _, err := api.PromoteUser(userID, chatID, false, true, false, false, false, false) + return err +} + +//PromoteToAdmin promotes a database admin to admin. +func PromoteToAdmin(adminUserID int64, chatID int64) { + + a, err := database.GetModeratorByTelegramID(adminUserID) + if err != nil { + log.Error("PromoteToAdmin:", err) + return + } + + _, err = api.PromoteUser(adminUserID, chatID, + a.CanChangeInfo, a.CanDeleteMessages, a.CanInviteUsers, a.CanRestrictMembers, a.CanPinMessages, a.CanPromoteMembers) + if err != nil { + log.Error("Unable to promote admin with telegramID ", a.TelegramID, err) + } + +} diff --git a/adminbot/restriction.go b/adminbot/restriction.go new file mode 100644 index 0000000..bdc1fdd --- /dev/null +++ b/adminbot/restriction.go @@ -0,0 +1,94 @@ +package adminbot + +import ( + "github.com/shitpostingio/admin-bot/reports" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + log "github.com/sirupsen/logrus" + + "github.com/shitpostingio/admin-bot/api" + "github.com/shitpostingio/admin-bot/database/database" + "github.com/shitpostingio/admin-bot/telegram" + "github.com/shitpostingio/admin-bot/utility" + "github.com/shitpostingio/admin-bot/utility/cache" +) + +// RestrictUser requests the restriction of a user in a chat. +// If the user was a mod, they'll be also removed from the mods map. +func RestrictUser(user *tgbotapi.User, chatID int64, untilDate int64, + canSendMessages, + canSendMediaMessages, + canSendOtherMessages, + canAddWebPagePreviews bool, + admin *tgbotapi.User) error { + + _, err := api.RestrictUser(user.ID, chatID, untilDate, + canSendMessages, + canSendMediaMessages, + canSendOtherMessages, + canAddWebPagePreviews) + if err != nil { + log.Error("Unable to restrict", telegram.GetNameOrUsername(user), ":", err) + return err + } + + log.Info(telegram.GetNameOrUsername(admin), "restricted", telegram.GetNameOrUsername(user), "until", utility.FormatUnixDate(untilDate)) + wasMod := cache.RemoveFromMods(user.ID) + if wasMod { + + demotionMessage := reports.ModeratorDemoted(admin.ID, telegram.GetName(admin), user.ID, telegram.GetName(user)) + _ = reports.Report(demotionMessage, reports.URGENT) + log.Warn(demotionMessage) + + err := database.RemoveModerator(user.ID) + if err != nil { + _ = reports.Report(reports.ModeratorCannotBeRemovedFromTable(user.ID, telegram.GetName(user)), reports.NON_URGENT) + } + } + + return nil + +} + +// UnrestrictUser requests the unrestriction of a user in a chat. +func UnrestrictUser(user *tgbotapi.User, chatID int64, admin *tgbotapi.User) error { + + _, err := api.UnrestrictUser(user.ID, chatID) + if err != nil { + log.Error("Unable to unrestrict", telegram.GetNameOrUsername(user), ":", err) + return err + } + + log.Info(telegram.GetNameOrUsername(admin), "unrestricted", telegram.GetNameOrUsername(user)) + return nil +} + +//RestrictMessages restricts an user from sending messages +func RestrictMessages(user *tgbotapi.User, chatID int64, restrictionEndTime int64, admin *tgbotapi.User) error { + return RestrictUser(user, chatID, restrictionEndTime, + false, + false, + false, + false, + admin) +} + +//RestrictMedia restricts an user from sending media +func RestrictMedia(user *tgbotapi.User, chatID int64, restrictionEndTime int64, admin *tgbotapi.User) error { + return RestrictUser(user, chatID, restrictionEndTime, + true, + false, + false, + false, + admin) +} + +//RestrictOther restricts an user from sending stickers and gifs +func RestrictOther(user *tgbotapi.User, chatID int64, restrictionEndTime int64, admin *tgbotapi.User) error { + return RestrictUser(user, chatID, restrictionEndTime, + true, + true, + false, + false, + admin) +} diff --git a/adminbot/send.go b/adminbot/send.go new file mode 100644 index 0000000..8195f55 --- /dev/null +++ b/adminbot/send.go @@ -0,0 +1,83 @@ +package adminbot + +import ( + "github.com/shitpostingio/admin-bot/api" +) + +/* ------------------------------ NORMAL MESSAGES ------------------------------ */ + +// SendPlainTextMessage sends a plain text message. +func SendPlainTextMessage(chatID int64, text string, urgent bool) error { + _, err := api.SendPlainTextMessage(chatID, text, urgent) + return err +} + +// SendPlainTextMessageWithMarkup sends a text message with an inline keyboard. +func SendPlainTextMessageWithMarkup(chatID int64, text string, replyMarkup interface{}, urgent bool) error { + _, err := api.SendPlainTextMessageWithMarkup(chatID, text, replyMarkup, urgent) + return err +} + +// SendTextMessage sends a text message with HTML parse mode. +func SendTextMessage(chatID int64, text string, urgent bool) error { + _, err := api.SendTextMessage(chatID, text, urgent) + return err +} + +// SendTextMessageWithMarkup sends a text message with HTML parse mode and an inline keyboard. +func SendTextMessageWithMarkup(chatID int64, text string, replyMarkup interface{}, urgent bool) error { + _, err := api.SendTextMessageWithMarkup(chatID, text, replyMarkup, urgent) + return err +} + +/* ------------------------------ REPLY MESSAGES ------------------------------ */ + +// SendReplyPlainTextMessage sends a plain text reply message. +func SendReplyPlainTextMessage(replyToMessageID int, chatID int64, text string, urgent bool) error { + _, err := api.SendReplyPlainTextMessage(replyToMessageID, chatID, text, urgent) + return err +} + +// SendReplyPlainTextMessageWithMarkup sends a text message with markup and an inline keyboard. +func SendReplyPlainTextMessageWithMarkup(replyToMessageID int, chatID int64, text string, replyMarkup interface{}, urgent bool) error { + _, err := api.SendReplyPlainTextMessageWithMarkup(replyToMessageID, chatID, text, replyMarkup, urgent) + return err +} + +// SendReplyTextMessage sends a reply text message with HTML parse mode. +func SendReplyTextMessage(replyToMessageID int, chatID int64, text string, urgent bool) error { + _, err := api.SendReplyTextMessage(replyToMessageID, chatID, text, urgent) + return err +} + +// SendReplyTextMessageWithMarkup sends a reply text message with HTML parse mode and an inline keyboard. +func SendReplyTextMessageWithMarkup(replyToMessageID int, chatID int64, text string, replyMarkup interface{}, urgent bool) error { + _, err := api.SendReplyTextMessageWithMarkup(replyToMessageID, chatID, text, replyMarkup, urgent) + return err +} + +///* ------------------------------ SILENT MESSAGES ------------------------------ */ + +// SendSilentPlainTextMessage sends a plain text message with notifications disabled. +func SendSilentPlainTextMessage(chatID int64, text string, urgent bool) error { + _, err := api.SendSilentPlainTextMessage(chatID, text, urgent) + return err +} + +// SendSilentPlainTextMessageWithMarkup sends a text message with an inline keyboard and notifications disabled. +func SendSilentPlainTextMessageWithMarkup(chatID int64, text string, replyMarkup interface{}, urgent bool) error { + _, err := api.SendSilentPlainTextMessageWithMarkup(chatID, text, replyMarkup, urgent) + return err +} + +// SendSilentTextMessage sends a text message with HTML parse mode and notifications disabled. +func SendSilentTextMessage(chatID int64, text string, urgent bool) error { + _, err := api.SendSilentTextMessage(chatID, text, urgent) + return err +} + +// SendSilentTextMessageWithMarkup sends a text message with HTML parse mode and an inline keyboard and notifications disabled. +func SendSilentTextMessageWithMarkup(chatID int64, text string, replyMarkup interface{}, urgent bool) error { + _, err := api.SendSilentTextMessageWithMarkup(chatID, text, replyMarkup, urgent) + return err +} diff --git a/adminbot/unban.go b/adminbot/unban.go new file mode 100644 index 0000000..af29ea2 --- /dev/null +++ b/adminbot/unban.go @@ -0,0 +1,36 @@ +package adminbot + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/pkg/errors" + "github.com/shitpostingio/admin-bot/api" + "github.com/shitpostingio/admin-bot/api/cache" + "github.com/shitpostingio/admin-bot/database/database" + "github.com/shitpostingio/admin-bot/telegram" + log "github.com/sirupsen/logrus" +) + +// UnbanUser unbans a user in a chat and marks them as unbanned in the database. +func UnbanUser(userID int64, chatID int64, moderator *tgbotapi.User) (err error) { + + _, err = api.UnbanUser(userID, chatID) + if err != nil { + return errors.Errorf("UnbanUser: could not unban user with ID %d: %s", userID, err) + } + + updateUnbanData(userID, moderator) + return nil +} + +// updateUnbanData marks the user as unbanned in the database +// and removes them from the ban cache. +func updateUnbanData(userID int64, moderator *tgbotapi.User) { + + // Remove the ban from the cache so + // the user can be banned again on need + cache.RemoveBanFromCache(userID) + + // + _ = database.MarkUserAsUnbanned(userID) + log.Info(telegram.GetNameOrUsername(moderator), "unbanned user with TelegramID", userID) +} diff --git a/adminbot/user.go b/adminbot/user.go new file mode 100644 index 0000000..d765904 --- /dev/null +++ b/adminbot/user.go @@ -0,0 +1,17 @@ +package adminbot + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "github.com/shitpostingio/admin-bot/api" +) + +// GetChatMember gets a chat member using the bot API in a rate limited fashion. +func GetChatMember(userID int64, groupID int64) (tgbotapi.ChatMember, error) { + return api.GetChatMember(userID, groupID) +} + +// GetUserProfilePhotos returns the user's profile photos in a rate limited fashion. +func GetUserProfilePhotos(userID int64, maxPhotos int) (tgbotapi.UserProfilePhotos, error) { + return api.GetUserProfilePhotos(userID, maxPhotos) +} diff --git a/analysisadapter/analysisAdapter.go b/analysisadapter/analysisAdapter.go new file mode 100644 index 0000000..7f62f0c --- /dev/null +++ b/analysisadapter/analysisAdapter.go @@ -0,0 +1,69 @@ +package analysisadapter + +import ( + "encoding/json" + "fmt" + "github.com/shitpostingio/admin-bot/api" + "github.com/shitpostingio/analysis-commons/structs" + "io/ioutil" + "net/http" + "time" +) + +func GetAnalysis(uniqueFileID, fileID string) (analysis structs.Analysis, err error) { + + file, err := api.GetTelegramFile(uniqueFileID, fileID) + if err != nil { + err = fmt.Errorf("GetAnalysis: unable to retrieve telegram file path: %s", err) + return analysis, err + } + + if file.FileSize > cfg.FileSizeThreshold { + err = fmt.Errorf("GetAnalysis: file size too big: %d", file.FileSize) + return analysis, err + } + + endpoint := getAnalysisEndpoint(file.FileID, file.FileUniqueID) + client := &http.Client{Timeout: time.Second * 30} + request, err := http.NewRequest("GET", endpoint, nil) + if err != nil { + err = fmt.Errorf("GetAnalysis: can't set up request for media with fileID %s: %s", fileID, err) + return analysis, err + } + + request.Header.Add(cfg.AuthorizationHeaderName, cfg.AuthorizationHeaderValue) + request.Header.Add(cfg.CallerAPIKeyHeaderName, botToken) + request.Header.Add(cfg.FilePathHeaderName, file.FilePath) + webResponse, err := client.Do(request) + if err != nil { + err = fmt.Errorf("GetAnalysis: unable to perform request: %s", err) + return analysis, err + } + defer closeSafely(webResponse.Body) + + bodyResult, err := ioutil.ReadAll(webResponse.Body) + if err != nil { + err = fmt.Errorf("GetAnalysis: error while reading response: %s", err) + return analysis, err + } + + fmt.Println(string(bodyResult)) + + err = json.Unmarshal(bodyResult, &analysis) + if err != nil { + err = fmt.Errorf("GetAnalysis: error while unmarshaling response: %s", err) + return analysis, err + } + + if analysis.FingerprintErrorString != "" { + err = fmt.Errorf("GetAnalysis: %w: %s", FingerprintError, analysis.FingerprintErrorString) + return analysis, err + } + + if analysis.NSFWErrorString != "" { + err = fmt.Errorf("GetAnalysis: %w: %s", NSFWError, analysis.NSFWErrorString) + } + + return analysis, err + +} diff --git a/analysisadapter/endpoint.go b/analysisadapter/endpoint.go new file mode 100644 index 0000000..e244b5c --- /dev/null +++ b/analysisadapter/endpoint.go @@ -0,0 +1,75 @@ +package analysisadapter + +import "fmt" + +//nolint +const ( + PHOTO = "AgA" + VIDEO = "BAA" + ANIMATION = "CgA" + STICKER = "CAA" + VOICE = "AwA" + DOCUMENT = "BQA" + AUDIO = "CQA" + VIDEONOTE = "DQA" +) + +func getAnalysisEndpoint(fileID, fileUniqueID string) string { + + // Telegram prefixes are 3 characters long + fileIDPrefix := fileID[:3] + + switch fileIDPrefix { + case PHOTO: + return fmt.Sprintf("%s/%s/%s", cfg.Address, cfg.AnalysisImageEndpoint, fileUniqueID) + case VIDEO: + return fmt.Sprintf("%s/%s/%s", cfg.Address, cfg.AnalysisVideoEndpoint, fileUniqueID) + case ANIMATION: + return fmt.Sprintf("%s/%s/%s", cfg.Address, cfg.AnalysisVideoEndpoint, fileUniqueID) + case STICKER: + return fmt.Sprintf("%s/%s/%s", cfg.Address, cfg.AnalysisImageEndpoint, fileUniqueID) + case VOICE: + fallthrough + case DOCUMENT: + fallthrough + case AUDIO: + fallthrough + case VIDEONOTE: + fallthrough + default: + return "" + } + +} + +func getFingerprintEndpoint(fileID, fileUniqueID string) string { + + // Telegram prefixes are 3 characters long + fileIDPrefix := fileID[:3] + + switch fileIDPrefix { + case PHOTO: + return fmt.Sprintf("%s/%s/%s", cfg.Address, cfg.FingerprintImageEndpoint, fileUniqueID) + case VIDEO: + return fmt.Sprintf("%s/%s/%s", cfg.Address, cfg.FingerprintVideoEndpoint, fileUniqueID) + case ANIMATION: + return fmt.Sprintf("%s/%s/%s", cfg.Address, cfg.FingerprintVideoEndpoint, fileUniqueID) + case STICKER: + return fmt.Sprintf("%s/%s/%s", cfg.Address, cfg.FingerprintImageEndpoint, fileUniqueID) + case VOICE: + fallthrough + case DOCUMENT: + fallthrough + case AUDIO: + fallthrough + case VIDEONOTE: + fallthrough + default: + return "" + } + +} + +func getGibberishEndpoint() string { + return fmt.Sprintf("%s/%s", cfg.Address, cfg.GibberishEndpoint) +} diff --git a/analysisadapter/errors.go b/analysisadapter/errors.go new file mode 100644 index 0000000..3b2316e --- /dev/null +++ b/analysisadapter/errors.go @@ -0,0 +1,15 @@ +package analysisadapter + +import "errors" + +var ( + + // FingerprintError is returned when a fingerprint wasn't successful. + FingerprintError = errors.New("analysis: unable to perform fingerprint") + + // NSFWError is returned when a NSFW analysis wasn't successful. + NSFWError = errors.New("analysis: unable to perform NSFW analysis") + + // GibberishError is returned when a gibberish analysis wasn't successful. + GibberishError = errors.New("analysis: unable to perform gibberish analysis") +) diff --git a/analysisadapter/fingerprintingAdapter.go b/analysisadapter/fingerprintingAdapter.go new file mode 100644 index 0000000..2ea2044 --- /dev/null +++ b/analysisadapter/fingerprintingAdapter.go @@ -0,0 +1,60 @@ +package analysisadapter + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "time" + + "github.com/shitpostingio/admin-bot/api" + analysis "github.com/shitpostingio/analysis-commons/structs" +) + +// GetFingerprint gets the fingerprint values of a media given its file id +func GetFingerprint(uniqueFileID, fileID string) (fingerprint analysis.FingerprintResponse, err error) { + + file, err := api.GetTelegramFile(uniqueFileID, fileID) + if err != nil { + err = fmt.Errorf("GetFingerprint: unable to retrieve telegram file path: %s", err) + return fingerprint, err + } + + if file.FileSize > cfg.FileSizeThreshold { + err = fmt.Errorf("GetFingerprint: file size too big: %d", file.FileSize) + return fingerprint, err + } + + endpoint := getFingerprintEndpoint(file.FileID, file.FileUniqueID) + client := &http.Client{Timeout: time.Second * 30} + request, err := http.NewRequest("GET", endpoint, nil) + if err != nil { + err = fmt.Errorf("GetFingerprint: can't set up request for media with fileID %s: %s", fileID, err) + return fingerprint, err + } + + request.Header.Add(cfg.AuthorizationHeaderName, cfg.AuthorizationHeaderValue) + request.Header.Add(cfg.CallerAPIKeyHeaderName, botToken) + request.Header.Add(cfg.FilePathHeaderName, file.FilePath) + webResponse, err := client.Do(request) + if err != nil { + err = fmt.Errorf("GetFingerprint: unable to perform request: %s", err) + return fingerprint, err + } + defer closeSafely(webResponse.Body) + + bodyResult, err := ioutil.ReadAll(webResponse.Body) + if err != nil { + err = fmt.Errorf("GetFingerprint: error while reading response: %s", err) + return fingerprint, err + } + + err = json.Unmarshal(bodyResult, &fingerprint) + if err != nil { + err = fmt.Errorf("GetFingerprint: error while unmarshaling response: %s", err) + return fingerprint, err + } + + return fingerprint, err + +} diff --git a/analysisadapter/gibberishAdapter.go b/analysisadapter/gibberishAdapter.go new file mode 100644 index 0000000..0f3ba82 --- /dev/null +++ b/analysisadapter/gibberishAdapter.go @@ -0,0 +1,45 @@ +package analysisadapter + +import ( + "encoding/json" + "fmt" + analysis "github.com/shitpostingio/analysis-commons/structs" + "io/ioutil" + "net/http" + "time" +) + +// GetGibberishValues checks if the input string is gibberish +func GetGibberishValues(toCheck string) (gibberish analysis.GibberishResponse, err error) { + + client := &http.Client{Timeout: time.Second * 30} + endpoint := getGibberishEndpoint() + request, err := http.NewRequest("GET", endpoint, nil) + if err != nil { + err = fmt.Errorf("GetGibberishValues: can't set up request for string %s: %s", toCheck, err) + return gibberish, err + } + + request.Header.Add(cfg.AuthorizationHeaderName, cfg.AuthorizationHeaderValue) + request.Header.Add(cfg.GibberishInputHeaderName, toCheck) + webResponse, err := client.Do(request) + if err != nil { + err = fmt.Errorf("GetGibberishValues: unable to perform request: %s", err) + return gibberish, err + } + defer closeSafely(webResponse.Body) + + bodyResult, err := ioutil.ReadAll(webResponse.Body) + if err != nil { + err = fmt.Errorf("GetGibberishValues: error while reading response: %s", err.Error()) + return gibberish, err + } + + err = json.Unmarshal(bodyResult, &gibberish) + if err != nil { + err = fmt.Errorf("GetGibberishValues: error while unmarshaling response: %s", err) + } + + return gibberish, err + +} diff --git a/analysisadapter/starter.go b/analysisadapter/starter.go new file mode 100644 index 0000000..ce33a08 --- /dev/null +++ b/analysisadapter/starter.go @@ -0,0 +1,16 @@ +package analysisadapter + +import ( + "github.com/shitpostingio/admin-bot/config/structs" +) + +var ( + botToken string + cfg *structs.FPServerConfiguration +) + +// Start starts the fpserver adapter +func Start(telegramBotToken string, fpServerConfig *structs.FPServerConfiguration) { + botToken = telegramBotToken + cfg = fpServerConfig +} diff --git a/analysisadapter/utility.go b/analysisadapter/utility.go new file mode 100644 index 0000000..7b21031 --- /dev/null +++ b/analysisadapter/utility.go @@ -0,0 +1,10 @@ +package analysisadapter + +import ( + "io" +) + +//CloseSafely closes an entity and logs in case of errors +func closeSafely(toClose io.Closer) { + _ = toClose.Close() +} diff --git a/api/ban.go b/api/ban.go new file mode 100644 index 0000000..640fcb7 --- /dev/null +++ b/api/ban.go @@ -0,0 +1,140 @@ +package api + +import ( + "github.com/pkg/errors" + "github.com/shitpostingio/admin-bot/api/botapi" + "github.com/shitpostingio/admin-bot/api/cache" + "github.com/shitpostingio/admin-bot/api/tdlib" + limiter "github.com/shitpostingio/admin-bot/ratelimiter" + "github.com/shitpostingio/admin-bot/repository" + "github.com/shitpostingio/go-tdlib/client" +) + +// BanUser bans a user using the bot API. +// It will make sure no ban for the same person has already been requested. +// It will also use a rate limiter not to get restricted by Telegram. +func BanUser(bannedUserID, moderatorID int64, chatID int64) error { + + ban, err := getBanMutex(bannedUserID, moderatorID) + if err != nil { + return err + } + + // + defer ban.Mutex.Unlock() + limiter.AuthorizeUrgentAction() + + // + err = tdlib.BanUser(bannedUserID, chatID) + if err != nil { + return err + } + + ban.Performed = true + return nil +} + +// BanUserByID bans a user using tdlib. +// It will make sure no ban for the same person has already been requested. +// It will also use a rate limiter not to get restricted by Telegram. +func BanUserByID(bannedUserID, moderatorID int64, chatID int64) (*client.User, error) { + + ban, err := getBanMutex(bannedUserID, moderatorID) + if err != nil { + return nil, err + } + + // + defer ban.Mutex.Unlock() + limiter.AuthorizeUrgentAction() + + // + user, err := tdlib.BanUserByID(bannedUserID, chatID) + if err != nil { + return nil, err + } + + ban.Performed = true + return user, nil +} + +// BanUserByUsername bans a user by their username using tdlib. +// It will make sure no ban for the same person has already been requested. +// It will also use a rate limiter not to get restricted by Telegram. +func BanUserByUsername(username string, moderatorID int64, chatID int64) (*client.User, error) { + + chat, err := tdlib.ResolveUsername(username) + if err != nil { + return nil, errors.Errorf("BanUserByUsername.ResolveUsername: %s", err) + } + + if chat.Type.ChatTypeType() != client.TypeChatTypePrivate { + return nil, errors.Errorf("BanUserByUsername: %s is not a user", username) + } + + bannedUserID := chat.ID + ban, err := getBanMutex(bannedUserID, moderatorID) + if err != nil { + return nil, err + } + + // + defer ban.Mutex.Unlock() + limiter.AuthorizeUrgentAction() + user, err := tdlib.BanUserByID(bannedUserID, chatID) + if err != nil { + return nil, err + } + + ban.Performed = true + return user, nil +} + +func BanChannel(bannedSenderChatID, moderatorID, chatID int64) error { + ban, err := getBanMutex(bannedSenderChatID, moderatorID) + if err != nil { + return err + } + + // + defer ban.Mutex.Unlock() + limiter.AuthorizeUrgentAction() + err = botapi.BanChannel(bannedSenderChatID, chatID) + if err != nil { + return err + } + + ban.Performed = true + return nil +} + +/* ------------------------------------------------------------------------------ */ + +// getBanMutex makes sure only one ban action can be performed per bannedUserID. +func getBanMutex(bannedUserID, moderatorUserID int64) (*cache.Action, error) { + + //Check if the hierarchy allows the ban. + if repository.Admins[bannedUserID] { + return nil, errors.New("getBanMutex: admins can't be banned") + } + + if repository.Mods[bannedUserID] && !repository.Admins[moderatorUserID] { + return nil, errors.New("getBanMutex: mods can't ban other mods") + } + + //Get data from the cache to perform the ban + banAction, err := cache.AddBanToCache(bannedUserID) + if err != nil { + banAction, err = cache.GetBanFromCache(bannedUserID) + if err != nil { + return nil, errors.Errorf("getBanMutex: ban cache error") + } + } + + banAction.Mutex.Lock() + if banAction.Performed { + return nil, errors.Errorf("getBanMutex: cache hit for user with id %d", bannedUserID) + } + + return banAction, nil +} diff --git a/api/botapi/authorization.go b/api/botapi/authorization.go new file mode 100644 index 0000000..05d2b24 --- /dev/null +++ b/api/botapi/authorization.go @@ -0,0 +1,22 @@ +package botapi + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +var ( + bot *tgbotapi.BotAPI +) + +// Authorize logs the bot into the provided account using the bot API. +func Authorize(botToken string, debugFlag bool) (*tgbotapi.BotAPI, error) { + + var err error + bot, err = tgbotapi.NewBotAPI(botToken) + if err != nil { + return nil, err + } + + bot.Debug = debugFlag + return bot, nil +} diff --git a/api/botapi/ban.go b/api/botapi/ban.go new file mode 100644 index 0000000..39d4b7d --- /dev/null +++ b/api/botapi/ban.go @@ -0,0 +1,27 @@ +package botapi + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +// BanUser bans a user in a chat using the bot API. +func BanUser(bannedUserID int64, chatID int64) error { + + // + userToBan := tgbotapi.ChatMemberConfig{UserID: bannedUserID, ChatID: chatID} + banConfig := tgbotapi.KickChatMemberConfig{ChatMemberConfig: userToBan} + + // + _, err := bot.Request(banConfig) + return err +} + +func BanChannel(bannedSenderChatID, chatID int64) error { + + // + chatToBan := tgbotapi.BanChatSenderChatConfig{SenderChatID: bannedSenderChatID, ChatID: chatID} + + // + _, err := bot.Request(chatToBan) + return err +} diff --git a/api/botapi/callback.go b/api/botapi/callback.go new file mode 100644 index 0000000..80b2ecd --- /dev/null +++ b/api/botapi/callback.go @@ -0,0 +1,11 @@ +package botapi + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +// SendCallbackWithAlert sends a callback response with an alert using the bot API. +func SendCallbackWithAlert(id, text string) error { + _, err := bot.Request(tgbotapi.NewCallbackWithAlert(id, text)) + return err +} diff --git a/api/botapi/chat.go b/api/botapi/chat.go new file mode 100644 index 0000000..002638d --- /dev/null +++ b/api/botapi/chat.go @@ -0,0 +1,11 @@ +package botapi + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +// LeaveChat leaves a chat using the bot API. +func LeaveChat(chatID int64) (*tgbotapi.APIResponse, error) { + response, err := bot.Request(tgbotapi.LeaveChatConfig{ChatID: chatID}) + return response, err +} diff --git a/api/botapi/delete.go b/api/botapi/delete.go new file mode 100644 index 0000000..5fae29f --- /dev/null +++ b/api/botapi/delete.go @@ -0,0 +1,11 @@ +package botapi + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +// DeleteMessage deletes a message using the bot API. +func DeleteMessage(chatID int64, messageID int) error { + _, err := bot.Request(tgbotapi.NewDeleteMessage(chatID, messageID)) + return err +} diff --git a/api/botapi/edit.go b/api/botapi/edit.go new file mode 100644 index 0000000..ac5668a --- /dev/null +++ b/api/botapi/edit.go @@ -0,0 +1,37 @@ +package botapi + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +// EditMessageText edits a text message using the bot API. +func EditMessageText(messageID int, chatID int64, text, parseMode string, replyMarkup *tgbotapi.InlineKeyboardMarkup) (*tgbotapi.Message, error) { + + edit := tgbotapi.EditMessageTextConfig{ + BaseEdit: tgbotapi.BaseEdit{ + ChatID: chatID, + MessageID: messageID, + ReplyMarkup: replyMarkup, + }, + Text: text, + ParseMode: parseMode, + } + + msg, err := bot.Send(edit) + return &msg, err +} + +// EditMessageReplyMarkup edits the reply markup in a message using the bot API. +func EditMessageReplyMarkup(messageID int, chatID int64, replyMarkup *tgbotapi.InlineKeyboardMarkup) (*tgbotapi.Message, error) { + + edit := tgbotapi.EditMessageReplyMarkupConfig{ + BaseEdit: tgbotapi.BaseEdit{ + ChatID: chatID, + MessageID: messageID, + ReplyMarkup: replyMarkup, + }, + } + + msg, err := bot.Send(edit) + return &msg, err +} diff --git a/api/botapi/file.go b/api/botapi/file.go new file mode 100644 index 0000000..8a18944 --- /dev/null +++ b/api/botapi/file.go @@ -0,0 +1,11 @@ +package botapi + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +// GetTelegramFile gets a telegram File using the bot API. +func GetTelegramFile(fileID string) (*tgbotapi.File, error) { + file, err := bot.GetFile(tgbotapi.FileConfig{FileID: fileID}) + return &file, err +} diff --git a/api/botapi/forward.go b/api/botapi/forward.go new file mode 100644 index 0000000..a93becc --- /dev/null +++ b/api/botapi/forward.go @@ -0,0 +1,11 @@ +package botapi + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +// ForwardMessage forwards a message using the bot API. +func ForwardMessage(toChatID, fromChatID int64, fromMessageID int) (*tgbotapi.Message, error) { + forward, err := bot.Send(tgbotapi.NewForward(toChatID, fromChatID, fromMessageID)) + return &forward, err +} diff --git a/api/botapi/kick.go b/api/botapi/kick.go new file mode 100644 index 0000000..2a00d1f --- /dev/null +++ b/api/botapi/kick.go @@ -0,0 +1,17 @@ +package botapi + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +// KickUser kicks a user using the bot API. +func KickUser(kickedUserID int64, chatID int64) (*tgbotapi.APIResponse, error) { + + response, err := bot.Request(tgbotapi.UnbanChatMemberConfig{ + ChatMemberConfig: tgbotapi.ChatMemberConfig{ + UserID: kickedUserID, + ChatID: chatID, + }}) + + return response, err +} diff --git a/api/botapi/promotion.go b/api/botapi/promotion.go new file mode 100644 index 0000000..fd92dee --- /dev/null +++ b/api/botapi/promotion.go @@ -0,0 +1,31 @@ +package botapi + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +//PromoteUser promotes user using the bot API. +func PromoteUser(userID int64, chatID int64, + canChangeInfo, + canDeleteMessages, + canInviteUsers, + canRestrictMembers, + canPinMessages, + canPromoteMembers bool) (*tgbotapi.APIResponse, error) { + + promotionConfig := tgbotapi.PromoteChatMemberConfig{ + ChatMemberConfig: tgbotapi.ChatMemberConfig{ + ChatID: chatID, + UserID: userID, + }, + CanChangeInfo: canChangeInfo, + CanDeleteMessages: canDeleteMessages, + CanInviteUsers: canInviteUsers, + CanRestrictMembers: canRestrictMembers, + CanPinMessages: canPinMessages, + CanPromoteMembers: canPromoteMembers, + } + + response, err := bot.Request(promotionConfig) + return response, err +} diff --git a/api/botapi/restrict.go b/api/botapi/restrict.go new file mode 100644 index 0000000..5f8a1ac --- /dev/null +++ b/api/botapi/restrict.go @@ -0,0 +1,49 @@ +package botapi + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +// RestrictUser restricts a user using the bot API. +func RestrictUser(userID int64, chatID int64, untilDate int64, + canSendMessages, + canSendMediaMessages, + canSendOtherMessages, + canAddWebPagePreviews bool) (*tgbotapi.APIResponse, error) { + + restrictionConfig := tgbotapi.RestrictChatMemberConfig{ + ChatMemberConfig: tgbotapi.ChatMemberConfig{UserID: userID, ChatID: chatID}, + UntilDate: untilDate, + Permissions: &tgbotapi.ChatPermissions{ + CanSendMessages: canSendMessages, + CanSendMediaMessages: canSendMediaMessages, + CanSendOtherMessages: canSendOtherMessages, + CanAddWebPagePreviews: canAddWebPagePreviews, + }, + } + + response, err := bot.Request(restrictionConfig) + return response, err +} + +// UnrestrictUser unrestricts a user using the bot API. +func UnrestrictUser(userID int64, chatID int64) (*tgbotapi.APIResponse, error) { + + unrestrictionConfig := tgbotapi.RestrictChatMemberConfig{ + ChatMemberConfig: tgbotapi.ChatMemberConfig{UserID: userID, ChatID: chatID}, + UntilDate: 0, + Permissions: &tgbotapi.ChatPermissions{ + CanSendMessages: true, + CanSendMediaMessages: true, + CanSendPolls: true, + CanSendOtherMessages: true, + CanAddWebPagePreviews: true, + CanChangeInfo: true, + CanInviteUsers: true, + CanPinMessages: true, + }, + } + + response, err := bot.Request(unrestrictionConfig) + return response, err +} diff --git a/api/botapi/send.go b/api/botapi/send.go new file mode 100644 index 0000000..1ae238f --- /dev/null +++ b/api/botapi/send.go @@ -0,0 +1,201 @@ +package botapi + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "github.com/shitpostingio/admin-bot/consts" +) + +/* ------------------------------------------------------------------------------------------------------------------ */ + +// SendPlainTextMessage sends a plaintext message using the bot API. +func SendPlainTextMessage(chatID int64, text string) (*tgbotapi.Message, error) { + + message := tgbotapi.MessageConfig{ + BaseChat: tgbotapi.BaseChat{ + ChatID: chatID, + }, + Text: text, + } + + msg, err := bot.Send(message) + return &msg, err +} + +// SendPlainTextMessageWithMarkup sends a plaintext message with markup using the bot API. +func SendPlainTextMessageWithMarkup(chatID int64, text string, replyMarkup interface{}) (*tgbotapi.Message, error) { + + message := tgbotapi.MessageConfig{ + BaseChat: tgbotapi.BaseChat{ + ChatID: chatID, + ReplyMarkup: replyMarkup, + }, + Text: text, + } + + msg, err := bot.Send(message) + return &msg, err +} + +// SendTextMessage sends a text message with ParseMode enabled using the bot API. +func SendTextMessage(chatID int64, text string) (*tgbotapi.Message, error) { + + message := tgbotapi.MessageConfig{ + BaseChat: tgbotapi.BaseChat{ + ChatID: chatID, + }, + Text: text, + ParseMode: consts.ReportParseMode, + } + + msg, err := bot.Send(message) + return &msg, err +} + +// SendTextMessageWithMarkup sends a text message with markup and ParseMode enabled using the bot API. +func SendTextMessageWithMarkup(chatID int64, text string, replyMarkup interface{}) (*tgbotapi.Message, error) { + + message := tgbotapi.MessageConfig{ + BaseChat: tgbotapi.BaseChat{ + ChatID: chatID, + ReplyMarkup: replyMarkup, + }, + Text: text, + ParseMode: consts.ReportParseMode, + } + + msg, err := bot.Send(message) + return &msg, err +} + +/* ------------------------------------------------------------------------------------------------------------------ */ + +// SendReplyPlainTextMessage sends a plaintext reply message using the bot API. +func SendReplyPlainTextMessage(replyToMessageID int, chatID int64, text string) (*tgbotapi.Message, error) { + + message := tgbotapi.MessageConfig{ + BaseChat: tgbotapi.BaseChat{ + ChatID: chatID, + ReplyToMessageID: replyToMessageID, + }, + Text: text, + } + + msg, err := bot.Send(message) + return &msg, err +} + +// SendReplyPlainTextMessageWithMarkup sends a plaintext reply message with markup using the bot API. +func SendReplyPlainTextMessageWithMarkup(replyToMessageID int, chatID int64, text string, replyMarkup interface{}) (*tgbotapi.Message, error) { + + message := tgbotapi.MessageConfig{ + BaseChat: tgbotapi.BaseChat{ + ChatID: chatID, + ReplyToMessageID: replyToMessageID, + ReplyMarkup: replyMarkup, + }, + Text: text, + } + + msg, err := bot.Send(message) + return &msg, err +} + +// SendReplyTextMessage sends a reply text message with ParseMode enabled using the bot API. +func SendReplyTextMessage(replyToMessageID int, chatID int64, text string) (*tgbotapi.Message, error) { + + message := tgbotapi.MessageConfig{ + BaseChat: tgbotapi.BaseChat{ + ChatID: chatID, + ReplyToMessageID: replyToMessageID, + }, + Text: text, + ParseMode: consts.ReportParseMode, + } + + msg, err := bot.Send(message) + return &msg, err +} + +// SendReplyTextMessageWithMarkup sends a reply text message with markup and ParseMode enabled using the bot API. +func SendReplyTextMessageWithMarkup(replyToMessageID int, chatID int64, text string, replyMarkup interface{}) (*tgbotapi.Message, error) { + + message := tgbotapi.MessageConfig{ + BaseChat: tgbotapi.BaseChat{ + ChatID: chatID, + ReplyToMessageID: replyToMessageID, + ReplyMarkup: replyMarkup, + }, + Text: text, + ParseMode: consts.ReportParseMode, + } + + msg, err := bot.Send(message) + return &msg, err +} + +/* ------------------------------------------------------------------------------------------------------------------ */ + +// SendSilentPlainTextMessage sends a plaintext message with notifications disabled using the bot API. +func SendSilentPlainTextMessage(chatID int64, text string) (*tgbotapi.Message, error) { + + message := tgbotapi.MessageConfig{ + BaseChat: tgbotapi.BaseChat{ + ChatID: chatID, + DisableNotification: true, + }, + Text: text, + } + + msg, err := bot.Send(message) + return &msg, err +} + +// SendSilentPlainTextMessageWithMarkup sends a plaintext message with markup and notifications disabled using the bot API. +func SendSilentPlainTextMessageWithMarkup(chatID int64, text string, replyMarkup interface{}) (*tgbotapi.Message, error) { + + message := tgbotapi.MessageConfig{ + BaseChat: tgbotapi.BaseChat{ + ChatID: chatID, + DisableNotification: true, + ReplyMarkup: replyMarkup, + }, + Text: text, + } + + msg, err := bot.Send(message) + return &msg, err +} + +// SendSilentTextMessage sends a text message with ParseMode enabled and notifications disabled using the bot API. +func SendSilentTextMessage(chatID int64, text string) (*tgbotapi.Message, error) { + + message := tgbotapi.MessageConfig{ + BaseChat: tgbotapi.BaseChat{ + ChatID: chatID, + DisableNotification: true, + }, + Text: text, + ParseMode: consts.ReportParseMode, + } + + msg, err := bot.Send(message) + return &msg, err +} + +// SendSilentTextMessageWithMarkup sends a text message with markup, ParseMode enabled and notifications disabled using the bot API. +func SendSilentTextMessageWithMarkup(chatID int64, text string, replyMarkup interface{}) (*tgbotapi.Message, error) { + + message := tgbotapi.MessageConfig{ + BaseChat: tgbotapi.BaseChat{ + ChatID: chatID, + DisableNotification: true, + ReplyMarkup: replyMarkup, + }, + Text: text, + ParseMode: consts.ReportParseMode, + } + + msg, err := bot.Send(message) + return &msg, err +} diff --git a/api/botapi/unban.go b/api/botapi/unban.go new file mode 100644 index 0000000..08ee83c --- /dev/null +++ b/api/botapi/unban.go @@ -0,0 +1,17 @@ +package botapi + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +// UnbanUser unbans a user in a chat using the bot API. +func UnbanUser(userID int64, chatID int64) (*tgbotapi.APIResponse, error) { + + response, err := bot.Request(tgbotapi.UnbanChatMemberConfig{ + ChatMemberConfig: tgbotapi.ChatMemberConfig{ + UserID: userID, + ChatID: chatID, + }}) + + return response, err +} diff --git a/api/botapi/user.go b/api/botapi/user.go new file mode 100644 index 0000000..ad5022c --- /dev/null +++ b/api/botapi/user.go @@ -0,0 +1,31 @@ +package botapi + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "github.com/shitpostingio/admin-bot/repository" +) + +// GetChatMember gets a chat member using the bot API. +func GetChatMember(userID int64, groupID int64) (tgbotapi.ChatMember, error) { + + chatMemberConfig := tgbotapi.GetChatMemberConfig{ + ChatConfigWithUser: tgbotapi.ChatConfigWithUser{ + ChatID: groupID, + UserID: userID, + }, + } + + return bot.GetChatMember(chatMemberConfig) +} + +// GetUserProfilePhotos returns the user's profile photos using the bot API. +func GetUserProfilePhotos(userID int64, maxPhotos int) (tgbotapi.UserProfilePhotos, error) { + + userPhotoConfig := tgbotapi.UserProfilePhotosConfig{ + UserID: userID, + Limit: maxPhotos, + } + + return repository.Bot.GetUserProfilePhotos(userPhotoConfig) +} diff --git a/api/cache/cache.go b/api/cache/cache.go new file mode 100644 index 0000000..bd61fd4 --- /dev/null +++ b/api/cache/cache.go @@ -0,0 +1,122 @@ +package cache + +import ( + "fmt" + "strconv" + "sync" + "time" + + "github.com/patrickmn/go-cache" +) + +/* + *********************************************************************************************************************** + * * + * STRUCTS * + * * + *********************************************************************************************************************** + */ + +// Action represents an action that must be performed just once. +type Action struct { + Performed bool + Mutex sync.Mutex +} + +/* + *********************************************************************************************************************** + * * + * CONSTS AND VARS * + * * + *********************************************************************************************************************** + */ + +const ( + //deletionCacheExpiration = 5 * time.Minute + //deletionCacheCleanup = 10 * time.Minute + banCacheExpiration = 12 * time.Hour + banCacheCleanup = 24 * time.Hour +) + +var ( + //deletionCache *cache.Cache + banCache *cache.Cache +) + +/* + *********************************************************************************************************************** + * * + * START * + * * + *********************************************************************************************************************** + */ + +//CreateActionsCache creates the caches for deletions and bans. +func CreateActionsCache() { + //go DeletionManager() + //deletionCache = cache.New(deletionCacheExpiration, deletionCacheCleanup) + banCache = cache.New(banCacheExpiration, banCacheCleanup) +} + +/* + *********************************************************************************************************************** + * * + * ADDITIONS * + * * + *********************************************************************************************************************** + */ + +//// AddDeletionToCache adds a deletion to its cache. +//func AddDeletionToCache(messageID int) (*Action, error) { +// var outputAction Action +// err := deletionCache.Add(strconv.Itoa(messageID), &outputAction, cache.DefaultExpiration) +// return &outputAction, err +//} + +// AddBanToCache adds a ban to its cache. +func AddBanToCache(userID int64) (*Action, error) { + var outputAction Action + err := banCache.Add(strconv.FormatInt(userID, 10), &outputAction, cache.DefaultExpiration) + return &outputAction, err +} + +/* + *********************************************************************************************************************** + * * + * REMOVALS * + * * + *********************************************************************************************************************** + */ + +// RemoveBanFromCache removes a ban from its cache. +func RemoveBanFromCache(userID int64) { + banCache.Delete(strconv.FormatInt(userID, 10)) +} + +/* + *********************************************************************************************************************** + * * + * GETTERS * + * * + *********************************************************************************************************************** + */ + +//// GetDeletionFromCache gets a deletion from its cache. +//func GetDeletionFromCache(messageID int) (*Action, error) { +// value, found := deletionCache.Get(strconv.Itoa(messageID)) +// if !found { +// return nil, fmt.Errorf("deletion request not found") +// } +// +// return value.(*Action), nil +//} + +// GetBanFromCache gets a ban from its cache. +func GetBanFromCache(userID int64) (*Action, error) { + value, found := banCache.Get(strconv.FormatInt(userID, 10)) + if !found { + return nil, fmt.Errorf("ban request not found") + } + + return value.(*Action), nil +} diff --git a/api/cache/deletions.go b/api/cache/deletions.go new file mode 100644 index 0000000..0c23ece --- /dev/null +++ b/api/cache/deletions.go @@ -0,0 +1,44 @@ +package cache + +import ( + "strconv" + "sync" + "time" + + "github.com/patrickmn/go-cache" +) + +const ( + deletionCacheExpiration = 5 * time.Minute + deletionCacheCleanup = 10 * time.Minute +) + +var ( + deletionCache = cache.New(deletionCacheExpiration, deletionCacheCleanup) + deletionMutex sync.RWMutex +) + +func CheckDeletionCache(messageIDs []int) bool { + + deletionMutex.RLock() + defer deletionMutex.RUnlock() + + for _, messageID := range messageIDs { + _, found := deletionCache.Get(strconv.Itoa(messageID)) + if !found { + return true + } + } + + return false + +} + +func AddToDeletionCache(messageIDs []int) { + deletionMutex.Lock() + for _, messageID := range messageIDs { + _ = deletionCache.Add(strconv.Itoa(messageID), true, cache.DefaultExpiration) + } + + deletionMutex.Unlock() +} diff --git a/api/cache/file_paths.go b/api/cache/file_paths.go new file mode 100644 index 0000000..75cc487 --- /dev/null +++ b/api/cache/file_paths.go @@ -0,0 +1,42 @@ +package cache + +import ( + "sync" + "time" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/patrickmn/go-cache" + log "github.com/sirupsen/logrus" +) + +const ( + filePathCacheExpiration = 1 * time.Hour + filePathCacheCleanup = 2 * time.Hour +) + +var ( + fpCache = cache.New(filePathCacheExpiration, filePathCacheCleanup) +) + +type FilePathCacheElement struct { + File *tgbotapi.File + Mutex sync.RWMutex + Performed bool +} + +func CheckFilePathCache(fileUniqueID string) (*FilePathCacheElement, bool) { + + value, found := fpCache.Get(fileUniqueID) + if found { + return value.(*FilePathCacheElement), true + } + + var fp FilePathCacheElement + err := fpCache.Add(fileUniqueID, &fp, cache.DefaultExpiration) + if err != nil { + log.Error("FPCache: error", err, "for fileUniqueID", fileUniqueID) + } + + return &fp, found + +} diff --git a/api/callback.go b/api/callback.go new file mode 100644 index 0000000..da9858a --- /dev/null +++ b/api/callback.go @@ -0,0 +1,20 @@ +package api + +import ( + "github.com/shitpostingio/admin-bot/api/tdlib" + limiter "github.com/shitpostingio/admin-bot/ratelimiter" +) + +// SendCallbackWithAlert sends a callback response that shows an alert. +// It will also use a rate limiter not to get restricted by Telegram. +func SendCallbackWithAlert(id, text string) error { + limiter.AuthorizeAction() + return tdlib.SendCallback(id, text, true) +} + +// SendCallback sends a callback response. +// It will also use a rate limiter not to get restricted by Telegram. +func SendCallback(id, text string) error { + limiter.AuthorizeAction() + return tdlib.SendCallback(id, text, false) +} diff --git a/api/chat.go b/api/chat.go new file mode 100644 index 0000000..a47b4cf --- /dev/null +++ b/api/chat.go @@ -0,0 +1,15 @@ +package api + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "github.com/shitpostingio/admin-bot/api/botapi" + limiter "github.com/shitpostingio/admin-bot/ratelimiter" +) + +// LeaveChat leaves a chat given its chatID. +// It will also use a rate limiter not to get restricted by Telegram. +func LeaveChat(chatID int64) (*tgbotapi.APIResponse, error) { + limiter.AuthorizeAction() + return botapi.LeaveChat(chatID) +} diff --git a/api/delete.go b/api/delete.go new file mode 100644 index 0000000..d986f3a --- /dev/null +++ b/api/delete.go @@ -0,0 +1,43 @@ +package api + +import ( + "github.com/pkg/errors" + "github.com/shitpostingio/admin-bot/api/botapi" + "github.com/shitpostingio/admin-bot/api/cache" + "github.com/shitpostingio/admin-bot/api/tdlib" + limiter "github.com/shitpostingio/admin-bot/ratelimiter" +) + +// DeleteMessage deletes a message in a chat. +// It will also use a rate limiter not to get restricted by Telegram. +func DeleteMessage(chatID int64, messageID int) error { + + messageIDSlice := []int{messageID} + + if !cache.CheckDeletionCache(messageIDSlice) { + return errors.Errorf("DeleteMessage: cache hit for messageID %d", messageID) + } + + limiter.AuthorizeAction() + err := botapi.DeleteMessage(chatID, messageID) + if err == nil { + cache.AddToDeletionCache(messageIDSlice) + } + + return err +} + +func DeleteMultipleMessages(chatID int64, messageIDs []int) error { + + if !cache.CheckDeletionCache(messageIDs) { + return errors.Errorf("DeleteMultipleMessages: cache hit for messageIDs %v", messageIDs) + } + + limiter.AuthorizeAction() + err := tdlib.DeleteMultipleMessages(chatID, messageIDs) + if err == nil { + cache.AddToDeletionCache(messageIDs) + } + + return err +} diff --git a/api/edit.go b/api/edit.go new file mode 100644 index 0000000..77d7e3f --- /dev/null +++ b/api/edit.go @@ -0,0 +1,22 @@ +package api + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "github.com/shitpostingio/admin-bot/api/botapi" + limiter "github.com/shitpostingio/admin-bot/ratelimiter" +) + +// EditMessageText edits a text message. +// It will also use a rate limiter not to get restricted by Telegram. +func EditMessageText(messageID int, chatID int64, text, parseMode string, replyMarkup *tgbotapi.InlineKeyboardMarkup) (*tgbotapi.Message, error) { + limiter.AuthorizeAction() + return botapi.EditMessageText(messageID, chatID, text, parseMode, replyMarkup) +} + +// EditMessageReplyMarkup edits the reply markup in a message. +// It will also use a rate limiter not to get restricted by Telegram. +func EditMessageReplyMarkup(messageID int, chatID int64, replyMarkup *tgbotapi.InlineKeyboardMarkup) (*tgbotapi.Message, error) { + limiter.AuthorizeAction() + return botapi.EditMessageReplyMarkup(messageID, chatID, replyMarkup) +} diff --git a/api/file.go b/api/file.go new file mode 100644 index 0000000..844d0e2 --- /dev/null +++ b/api/file.go @@ -0,0 +1,37 @@ +package api + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + log "github.com/sirupsen/logrus" + + "github.com/shitpostingio/admin-bot/api/botapi" + "github.com/shitpostingio/admin-bot/api/cache" + limiter "github.com/shitpostingio/admin-bot/ratelimiter" +) + +// GetTelegramFile gets a Telegram file given its fileID. +// It will also use a rate limiter not to get restricted by Telegram. +func GetTelegramFile(uniqueFileID, fileID string) (*tgbotapi.File, error) { + + element, found := cache.CheckFilePathCache(uniqueFileID) + if found { + element.Mutex.RLock() + defer element.Mutex.RUnlock() + if element.Performed { + return element.File, nil + } + } + + element.Mutex.Lock() + limiter.AuthorizeAction() + result, err := botapi.GetTelegramFile(fileID) + if err == nil { + element.Performed = true + element.File = result + } else { + log.Error("GetTelegramFile: error while performing request", err) + } + + element.Mutex.Unlock() + return result, err +} diff --git a/api/forward.go b/api/forward.go new file mode 100644 index 0000000..5bedf2e --- /dev/null +++ b/api/forward.go @@ -0,0 +1,15 @@ +package api + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "github.com/shitpostingio/admin-bot/api/botapi" + limiter "github.com/shitpostingio/admin-bot/ratelimiter" +) + +// ForwardMessage forwards a message. +// It will also use a rate limiter not to get restricted by Telegram. +func ForwardMessage(toChatID, fromChatID int64, fromMessageID int) (*tgbotapi.Message, error) { + limiter.AuthorizeAction() + return botapi.ForwardMessage(toChatID, fromChatID, fromMessageID) +} diff --git a/api/kick.go b/api/kick.go new file mode 100644 index 0000000..0a0ce4f --- /dev/null +++ b/api/kick.go @@ -0,0 +1,40 @@ +package api + +import ( + "errors" + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/shitpostingio/admin-bot/api/botapi" + limiter "github.com/shitpostingio/admin-bot/ratelimiter" + "github.com/shitpostingio/admin-bot/repository" +) + +// KickUser kicks a user in a chat. +// It will make sure the hierarchy allows the kick to be performed. +// It will also use a rate limiter not to get restricted by Telegram. +func KickUser(kickedUserID, moderatorUserID int64, chatID int64) (*tgbotapi.APIResponse, error) { + + err := authorizeKick(kickedUserID, moderatorUserID) + if err != nil { + return nil, err + } + + limiter.AuthorizeAction() + return botapi.KickUser(kickedUserID, chatID) +} + +/* ------------------------------------------------------------------------------ */ + +// authorizeKick checks if the hierarchy allows the kick to be performed. +func authorizeKick(kickedUserID, moderatorUserID int64) error { + + //Check if the hierarchy allows the kick. + if repository.Admins[kickedUserID] { + return errors.New("KickUser: admins can't be kicked") + } + + if repository.Mods[kickedUserID] && !repository.Admins[moderatorUserID] { + return errors.New("KickUser: mods can't kick admins") + } + + return nil +} diff --git a/api/promotion.go b/api/promotion.go new file mode 100644 index 0000000..6a15836 --- /dev/null +++ b/api/promotion.go @@ -0,0 +1,28 @@ +package api + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "github.com/shitpostingio/admin-bot/api/botapi" + limiter "github.com/shitpostingio/admin-bot/ratelimiter" +) + +//PromoteUser promotes a user to admin. +// It will also use a rate limiter not to get restricted by Telegram. +func PromoteUser(userID int64, chatID int64, + canChangeInfo, + canDeleteMessages, + canInviteUsers, + canRestrictMembers, + canPinMessages, + canPromoteMembers bool) (*tgbotapi.APIResponse, error) { + + limiter.AuthorizeUrgentAction() + return botapi.PromoteUser(userID, chatID, + canChangeInfo, + canDeleteMessages, + canInviteUsers, + canRestrictMembers, + canPinMessages, + canPromoteMembers) +} diff --git a/api/restrict.go b/api/restrict.go new file mode 100644 index 0000000..9176e8a --- /dev/null +++ b/api/restrict.go @@ -0,0 +1,59 @@ +package api + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "github.com/shitpostingio/admin-bot/api/botapi" + limiter "github.com/shitpostingio/admin-bot/ratelimiter" +) + +// RestrictUser requests the restriction of a user in a chat. +// If the user was a mod, they'll be also removed from the mods map. +// It will also use a rate limiter not to get restricted by Telegram. +func RestrictUser(userID int64, chatID int64, untilDate int64, + canSendMessages, + canSendMediaMessages, + canSendOtherMessages, + canAddWebPagePreviews bool) (*tgbotapi.APIResponse, error) { + + limiter.AuthorizeUrgentAction() + return botapi.RestrictUser(userID, chatID, untilDate, + canSendMessages, + canSendMediaMessages, + canSendOtherMessages, + canAddWebPagePreviews) +} + +// UnrestrictUser requests the unrestriction of a user in a chat. +// It will also use a rate limiter not to get restricted by Telegram. +func UnrestrictUser(userID int64, chatID int64) (*tgbotapi.APIResponse, error) { + limiter.AuthorizeAction() + return botapi.UnrestrictUser(userID, chatID) +} + +//RestrictMessages restricts an user from sending messages. +func RestrictMessages(userID int64, chatID int64, restrictionEndTime int64) (*tgbotapi.APIResponse, error) { + return RestrictUser(userID, chatID, restrictionEndTime, + false, + false, + false, + false) +} + +//RestrictMedia restricts an user from sending media. +func RestrictMedia(userID int64, chatID int64, restrictionEndTime int64) (*tgbotapi.APIResponse, error) { + return RestrictUser(userID, chatID, restrictionEndTime, + true, + false, + false, + false) +} + +//RestrictOther restricts an user from sending stickers and gifs. +func RestrictOther(userID int64, chatID int64, restrictionEndTime int64) (*tgbotapi.APIResponse, error) { + return RestrictUser(userID, chatID, restrictionEndTime, + true, + true, + false, + false) +} diff --git a/api/send.go b/api/send.go new file mode 100644 index 0000000..4d954d9 --- /dev/null +++ b/api/send.go @@ -0,0 +1,110 @@ +package api + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "github.com/shitpostingio/admin-bot/api/botapi" + limiter "github.com/shitpostingio/admin-bot/ratelimiter" +) + +/* ------------------------------------------------------------------------------------------------------------------ */ + +// SendPlainTextMessage sends a plaintext message. +// It will also use a rate limiter not to get restricted by Telegram. +func SendPlainTextMessage(chatID int64, text string, urgent bool) (*tgbotapi.Message, error) { + authorizeWithAppropriatePriority(urgent) + return botapi.SendPlainTextMessage(chatID, text) +} + +// SendPlainTextMessageWithMarkup sends a plaintext message with markup. +// It will also use a rate limiter not to get restricted by Telegram. +func SendPlainTextMessageWithMarkup(chatID int64, text string, replyMarkup interface{}, urgent bool) (*tgbotapi.Message, error) { + authorizeWithAppropriatePriority(urgent) + return botapi.SendPlainTextMessageWithMarkup(chatID, text, replyMarkup) +} + +// SendTextMessage sends a text message with ParseMode enabled. +// It will also use a rate limiter not to get restricted by Telegram. +func SendTextMessage(chatID int64, text string, urgent bool) (*tgbotapi.Message, error) { + authorizeWithAppropriatePriority(urgent) + return botapi.SendTextMessage(chatID, text) +} + +// SendTextMessageWithMarkup sends a text message with markup and ParseMode enabled. +// It will also use a rate limiter not to get restricted by Telegram. +func SendTextMessageWithMarkup(chatID int64, text string, replyMarkup interface{}, urgent bool) (*tgbotapi.Message, error) { + authorizeWithAppropriatePriority(urgent) + return botapi.SendTextMessageWithMarkup(chatID, text, replyMarkup) +} + +/* ------------------------------------------------------------------------------------------------------------------ */ + +// SendReplyPlainTextMessage sends a plaintext reply message. +// It will also use a rate limiter not to get restricted by Telegram. +func SendReplyPlainTextMessage(replyToMessageID int, chatID int64, text string, urgent bool) (*tgbotapi.Message, error) { + authorizeWithAppropriatePriority(urgent) + return botapi.SendReplyPlainTextMessage(replyToMessageID, chatID, text) +} + +// SendReplyPlainTextMessageWithMarkup sends a plaintext reply message with markup. +// It will also use a rate limiter not to get restricted by Telegram. +func SendReplyPlainTextMessageWithMarkup(replyToMessageID int, chatID int64, text string, replyMarkup interface{}, urgent bool) (*tgbotapi.Message, error) { + authorizeWithAppropriatePriority(urgent) + return botapi.SendReplyPlainTextMessageWithMarkup(replyToMessageID, chatID, text, replyMarkup) +} + +// SendReplyTextMessage sends a reply text message with ParseMode enabled. +// It will also use a rate limiter not to get restricted by Telegram. +func SendReplyTextMessage(replyToMessageID int, chatID int64, text string, urgent bool) (*tgbotapi.Message, error) { + authorizeWithAppropriatePriority(urgent) + return botapi.SendReplyTextMessage(replyToMessageID, chatID, text) +} + +// SendReplyTextMessageWithMarkup sends a reply text message with markup and ParseMode enabled. +// It will also use a rate limiter not to get restricted by Telegram. +func SendReplyTextMessageWithMarkup(replyToMessageID int, chatID int64, text string, replyMarkup interface{}, urgent bool) (*tgbotapi.Message, error) { + authorizeWithAppropriatePriority(urgent) + return botapi.SendReplyTextMessageWithMarkup(replyToMessageID, chatID, text, replyMarkup) +} + +/* ------------------------------------------------------------------------------------------------------------------ */ + +// SendSilentPlainTextMessage sends a plaintext message with notifications disabled. +// It will also use a rate limiter not to get restricted by Telegram. +func SendSilentPlainTextMessage(chatID int64, text string, urgent bool) (*tgbotapi.Message, error) { + authorizeWithAppropriatePriority(urgent) + return botapi.SendSilentPlainTextMessage(chatID, text) +} + +// SendSilentPlainTextMessageWithMarkup sends a plaintext message with markup and notifications disabled. +// It will also use a rate limiter not to get restricted by Telegram. +func SendSilentPlainTextMessageWithMarkup(chatID int64, text string, replyMarkup interface{}, urgent bool) (*tgbotapi.Message, error) { + authorizeWithAppropriatePriority(urgent) + return botapi.SendSilentPlainTextMessageWithMarkup(chatID, text, replyMarkup) +} + +// SendSilentTextMessage sends a text message with ParseMode enabled and notifications disabled. +// It will also use a rate limiter not to get restricted by Telegram. +func SendSilentTextMessage(chatID int64, text string, urgent bool) (*tgbotapi.Message, error) { + authorizeWithAppropriatePriority(urgent) + return botapi.SendSilentTextMessage(chatID, text) +} + +// SendSilentTextMessageWithMarkup sends a text message with markup, ParseMode enabled and notifications disabled. +// It will also use a rate limiter not to get restricted by Telegram. +func SendSilentTextMessageWithMarkup(chatID int64, text string, replyMarkup interface{}, urgent bool) (*tgbotapi.Message, error) { + authorizeWithAppropriatePriority(urgent) + return botapi.SendSilentTextMessageWithMarkup(chatID, text, replyMarkup) +} + +/* ------------------------------------------------------------------------------------------------------------------ */ + +// authorizeWithAppropriatePriority uses the urgent flag to +// authorize the action with the appropriate priority. +func authorizeWithAppropriatePriority(urgent bool) { + if urgent { + limiter.AuthorizeUrgentAction() + } else { + limiter.AuthorizeAction() + } +} diff --git a/api/tdlib/authorization.go b/api/tdlib/authorization.go new file mode 100644 index 0000000..8a933f3 --- /dev/null +++ b/api/tdlib/authorization.go @@ -0,0 +1,46 @@ +package tdlib + +import ( + "github.com/shitpostingio/go-tdlib/client" + + "github.com/shitpostingio/admin-bot/config/structs" +) + +const ( + tdlibMessageConst = 1048576 +) + +var ( + tdlibClient *client.Client +) + +// Authorize logs the bot into the provided account using tdlib. +func Authorize(botToken string, cfg *structs.TdlibConfiguration) (tClient *client.Client, err error) { + + authorizer := client.BotAuthorizer(botToken) + + authorizer.TdlibParameters <- &client.TdlibParameters{ + UseTestDc: cfg.UseTestDc, + DatabaseDirectory: cfg.DatabaseDirectory, + FilesDirectory: cfg.FilesDirectory, + UseFileDatabase: true, + UseChatInfoDatabase: true, + UseMessageDatabase: true, + UseSecretChats: false, + ApiID: cfg.APIID, + ApiHash: cfg.APIHash, + SystemLanguageCode: "en", + DeviceModel: "Other", + SystemVersion: "1.0.0", + ApplicationVersion: "1.0.0", + EnableStorageOptimizer: true, + IgnoreFileNames: false, + } + + logVerbosity := client.WithLogVerbosity(&client.SetLogVerbosityLevelRequest{ + NewVerbosityLevel: cfg.LogVerbosityLevel, + }) + + tdlibClient, err = client.NewClient(authorizer, logVerbosity) + return tdlibClient, err +} diff --git a/api/tdlib/ban.go b/api/tdlib/ban.go new file mode 100644 index 0000000..d2ec6ce --- /dev/null +++ b/api/tdlib/ban.go @@ -0,0 +1,35 @@ +package tdlib + +import ( + "github.com/pkg/errors" + "github.com/shitpostingio/go-tdlib/client" +) + +func BanUser(bannedUserID int64, chatID int64) error { + + _, err := tdlibClient.SetChatMemberStatus(&client.SetChatMemberStatusRequest{ + ChatID: chatID, + MemberID: &client.MessageSenderUser{UserID: bannedUserID}, + Status: &client.ChatMemberStatusBanned{BannedUntilDate: 0}, + }) + + return err +} + +// BanUserByID bans a user given their userID. +func BanUserByID(bannedUserID int64, chatID int64) (*client.User, error) { + + _, err := tdlibClient.GetChat(&client.GetChatRequest{ChatID: chatID}) + if err != nil { + return nil, errors.Errorf("BanUserByID.GetChat: %s", err) + } + + user, err := GetUserByID(bannedUserID) + if err != nil { + return nil, errors.Errorf("BanUserByID.GetUser: %s", err) + } + + err = BanUser(bannedUserID, chatID) + + return user, err +} diff --git a/api/tdlib/callback.go b/api/tdlib/callback.go new file mode 100644 index 0000000..d80dd04 --- /dev/null +++ b/api/tdlib/callback.go @@ -0,0 +1,25 @@ +package tdlib + +import ( + "github.com/pkg/errors" + "strconv" + + "github.com/shitpostingio/go-tdlib/client" +) + +// SendCallback sends a callback response using the Tdlib. +func SendCallback(id, text string, showAlert bool) error { + + queryID, err := strconv.Atoi(id) + if err != nil { + return errors.Errorf("SendCallbackWithAlert: unable to parse callback query ID: %s", err) + } + + _, err = tdlibClient.AnswerCallbackQuery(&client.AnswerCallbackQueryRequest{ + CallbackQueryID: client.JsonInt64(queryID), + Text: text, + ShowAlert: showAlert, + }) + + return err +} diff --git a/api/tdlib/chat.go b/api/tdlib/chat.go new file mode 100644 index 0000000..2693874 --- /dev/null +++ b/api/tdlib/chat.go @@ -0,0 +1,16 @@ +package tdlib + +import ( + "github.com/shitpostingio/go-tdlib/client" +) + +// GetChatMember gets the chat member info via tdlib +func GetChatMember(chatID int64, userID int) (*client.ChatMember, error) { + + chatMember, err := tdlibClient.GetChatMember(&client.GetChatMemberRequest{ + ChatID: chatID, + UserID: int64(userID), + }) + + return chatMember, err +} diff --git a/api/tdlib/delete.go b/api/tdlib/delete.go new file mode 100644 index 0000000..b473beb --- /dev/null +++ b/api/tdlib/delete.go @@ -0,0 +1,36 @@ +package tdlib + +import ( + "github.com/shitpostingio/go-tdlib/client" +) + +// DeleteMessage deletes a message using the bot API. +func DeleteMessage(chatID int64, messageID int) error { + + tdlibMessageID := getTdlibMessageID(messageID) + + _, err := tdlibClient.DeleteMessages(&client.DeleteMessagesRequest{ + ChatID: chatID, + MessageIDs: []int64{tdlibMessageID}, + Revoke: true, + }) + + return err +} + +func DeleteMultipleMessages(chatID int64, messageIDs []int) error { + + messages := make([]int64, len(messageIDs)) + + for i, messageID := range messageIDs { + messages[i] = getTdlibMessageID(messageID) + } + + _, err := tdlibClient.DeleteMessages(&client.DeleteMessagesRequest{ + ChatID: chatID, + MessageIDs: messages, + Revoke: true, + }) + + return err +} diff --git a/api/tdlib/user.go b/api/tdlib/user.go new file mode 100644 index 0000000..c6f0747 --- /dev/null +++ b/api/tdlib/user.go @@ -0,0 +1,34 @@ +package tdlib + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/pkg/errors" + "github.com/shitpostingio/go-tdlib/client" +) + +// GetTgbotapiUserFromTdlibUser returns the equivalent tgbotapi.User structure +// of the input tdlib.client.User structure. +func GetTgbotapiUserFromTdlibUser(tlu *client.User) *tgbotapi.User { + + if tlu == nil { + return nil + } + + return &tgbotapi.User{ + ID: int64(tlu.ID), + FirstName: tlu.FirstName, + LastName: tlu.LastName, + UserName: tlu.Username, + } +} + +// GetUserByID returns a tdlib.client.User given a userID. +func GetUserByID(userID int64) (*client.User, error) { + + user, err := tdlibClient.GetUser(&client.GetUserRequest{UserID: userID}) + if err != nil { + return nil, errors.Errorf("BanUserByID.GetUser: %s", err) + } + + return user, nil +} diff --git a/api/tdlib/usernames.go b/api/tdlib/usernames.go new file mode 100644 index 0000000..abb9da6 --- /dev/null +++ b/api/tdlib/usernames.go @@ -0,0 +1,23 @@ +package tdlib + +import ( + "github.com/shitpostingio/go-tdlib/client" +) + +// ResolveUsername searches public chats to find the corresponding Chat for the input username. +func ResolveUsername(username string) (chat *client.Chat, err error) { + chat, err = tdlibClient.SearchPublicChat(&client.SearchPublicChatRequest{Username: username}) + return chat, err +} + +// IsGroupOrChannelUsername returns true if the input username belongs +// does not belong to a private chat. +func IsGroupOrChannelUsername(username string) bool { + + chat, err := ResolveUsername(username) + if err != nil { + return false + } + + return chat.Type.ChatTypeType() != client.TypeChatTypePrivate +} diff --git a/api/tdlib/utilities.go b/api/tdlib/utilities.go new file mode 100644 index 0000000..16f05e0 --- /dev/null +++ b/api/tdlib/utilities.go @@ -0,0 +1,5 @@ +package tdlib + +func getTdlibMessageID(botApiMessageID int) int64 { + return int64(botApiMessageID * tdlibMessageConst) +} diff --git a/api/unban.go b/api/unban.go new file mode 100644 index 0000000..8023bf1 --- /dev/null +++ b/api/unban.go @@ -0,0 +1,15 @@ +package api + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "github.com/shitpostingio/admin-bot/api/botapi" + limiter "github.com/shitpostingio/admin-bot/ratelimiter" +) + +//UnbanUser unbans a user in a chat and marks them as unbanned in the database. +// It will also use a rate limiter not to get restricted by Telegram. +func UnbanUser(userID int64, chatID int64) (*tgbotapi.APIResponse, error) { + limiter.AuthorizeAction() + return botapi.UnbanUser(userID, chatID) +} diff --git a/api/user.go b/api/user.go new file mode 100644 index 0000000..c0f3857 --- /dev/null +++ b/api/user.go @@ -0,0 +1,22 @@ +package api + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "github.com/shitpostingio/admin-bot/api/botapi" + limiter "github.com/shitpostingio/admin-bot/ratelimiter" +) + +// GetChatMember gets a chat member using the bot API. +// It will also use a rate limiter not to get restricted by Telegram. +func GetChatMember(userID int64, groupID int64) (tgbotapi.ChatMember, error) { + limiter.AuthorizeAction() + return botapi.GetChatMember(userID, groupID) +} + +// GetUserProfilePhotos returns the user's profile photos. +// It will also use a rate limiter not to get restricted by Telegram. +func GetUserProfilePhotos(userID int64, maxPhotos int) (tgbotapi.UserProfilePhotos, error) { + limiter.AuthorizeAction() + return botapi.GetUserProfilePhotos(userID, maxPhotos) +} diff --git a/automod/automod.go b/automod/automod.go new file mode 100644 index 0000000..9e3d7c9 --- /dev/null +++ b/automod/automod.go @@ -0,0 +1,272 @@ +package automod + +import ( + "fmt" + "github.com/shitpostingio/admin-bot/reports" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/shitpostingio/admin-bot/adminbot" + "github.com/shitpostingio/admin-bot/api" + "github.com/shitpostingio/admin-bot/callback/buttons" + "github.com/shitpostingio/admin-bot/commands" + "github.com/shitpostingio/admin-bot/database/database" + "github.com/shitpostingio/admin-bot/defense/antiflood" + "github.com/shitpostingio/admin-bot/defense/antispam" + "github.com/shitpostingio/admin-bot/defense/antiuserbot" + "github.com/shitpostingio/admin-bot/defense/emergencymode" + "github.com/shitpostingio/admin-bot/repository" + "github.com/shitpostingio/admin-bot/telegram" + "github.com/shitpostingio/admin-bot/utility" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +//StartDefensiveRoutines starts defensive mechanisms. +func StartDefensiveRoutines() { + antiflood.Start() + antispam.Start() + antiuserbot.Start() +} + +// HandleText executes commands and performs checks +// on the text of the message. +func HandleText(msg *tgbotapi.Message) { + + if msg.SenderChat != nil && msg.SenderChat.IsChannel() { + + if !database.SourceIsWhitelisted(msg.From.ID, msg.SenderChat.UserName) { + err := api.BanChannel(msg.SenderChat.ID, repository.Bot.Self.ID, msg.Chat.ID) + if err != nil { + log.Debugf("Unable to ban channel %s: %s", msg.SenderChat.UserName, err) + } + adminbot.DeleteMessageAndLog(fmt.Sprintf("Banned channel %s", msg.SenderChat.UserName), msg.Chat.ID, msg.MessageID) + } + + return + } + + if msg.IsCommand() { + HandleCommand(msg) + } + + performChecksOnMessageText(msg) +} + +// HandleMedia handles media messages and checks their content accordingly. +func HandleMedia(msg *tgbotapi.Message, checkNSFW bool) { + + if msg.SenderChat != nil && msg.SenderChat.IsChannel() { + + if !database.SourceIsWhitelisted(msg.From.ID, msg.SenderChat.UserName) { + err := api.BanChannel(msg.SenderChat.ID, repository.Bot.Self.ID, msg.Chat.ID) + if err != nil { + log.Debugf("Unable to ban channel %s: %s", msg.SenderChat.UserName, err) + } + adminbot.DeleteMessageAndLog(fmt.Sprintf("Banned channel %s", msg.SenderChat.UserName), msg.Chat.ID, msg.MessageID) + } + + return + } + + if performChecksOnMessageText(msg) { + return + } + + uniqueID, fileID := telegram.GetFileIDFromMessage(msg) + performAnalysis(uniqueID, fileID, msg) +} + +// HandleSticker checks if a sticker has been forwarded from an unwanted +// source or if it's blacklisted or part of a blacklisted pack. +func HandleSticker(msg *tgbotapi.Message) { + + if msg.SenderChat != nil && msg.SenderChat.IsChannel() { + + if !database.SourceIsWhitelisted(msg.From.ID, msg.SenderChat.UserName) { + err := api.BanChannel(msg.SenderChat.ID, repository.Bot.Self.ID, msg.Chat.ID) + if err != nil { + log.Debugf("Unable to ban channel %s: %s", msg.SenderChat.UserName, err) + } + adminbot.DeleteMessageAndLog(fmt.Sprintf("Banned channel %s", msg.SenderChat.UserName), msg.Chat.ID, msg.MessageID) + } + + return + } + + if checkMessageOrigin(msg) { + return + } + + stickerPackIsBlacklisted := database.StickerPackIsBlacklisted(msg.Sticker.SetName) + stickerIsBlacklisted := database.MediaIsBlacklisted(msg.Sticker.FileUniqueID, msg.Sticker.FileID) + if stickerPackIsBlacklisted || stickerIsBlacklisted { + logText := fmt.Sprintf("Removed a blacklisted sticker posted by %s", telegram.GetNameOrUsername(msg.From)) + adminbot.DeleteMessageAndLog(logText, msg.Chat.ID, msg.MessageID) + } +} + +// HandleGame removes games +func HandleGame(msg *tgbotapi.Message) { + + if msg.SenderChat != nil && msg.SenderChat.IsChannel() { + + if !database.SourceIsWhitelisted(msg.From.ID, msg.SenderChat.UserName) { + err := api.BanChannel(msg.SenderChat.ID, repository.Bot.Self.ID, msg.Chat.ID) + if err != nil { + log.Debugf("Unable to ban channel %s: %s", msg.SenderChat.UserName, err) + } + adminbot.DeleteMessageAndLog(fmt.Sprintf("Banned channel %s", msg.SenderChat.UserName), msg.Chat.ID, msg.MessageID) + } + + return + } + + logText := fmt.Sprintf("Removed a game posted by %s", telegram.GetNameOrUsername(msg.From)) + adminbot.DeleteMessageAndLog(logText, msg.Chat.ID, msg.MessageID) +} + +// HandleCommand removes commands that aren't replies and passes the other +// ones to the command execution. +func HandleCommand(msg *tgbotapi.Message) { + + if !utility.IsChatAdminByMessage(msg) { + + if msg.ReplyToMessage == nil { + logText := fmt.Sprintf("Removed a command by %s", telegram.GetNameOrUsername(msg.From)) + adminbot.DeleteMessageAndLog(logText, msg.Chat.ID, msg.MessageID) + } + + return + } + + commands.ExecuteCommand(msg) + +} + +// HandleNewChatMember promotes database admins automatically and forwards +// every join to the antiuserbot module. +// In case the EmergencyMode is toggled, it'll also mute every new member +// without any picture or handle. +func HandleNewChatMember(msg *tgbotapi.Message) { + + user := msg.NewChatMembers[len(msg.NewChatMembers)-1] + if repository.Admins[user.ID] { + adminbot.PromoteToAdmin(user.ID, msg.Chat.ID) + _ = database.UpdateModeratorDetailsByTelegramUser(&user, msg.Chat.ID, repository.Bot) + return + } + + // Don't send admin joins to the antiuserbot module. + antiuserbot.HandleUser(&user) + + // Bots definitely can't press buttons + if user.IsBot { + return + } + + // During emergency mode moderators need to approve the user + if emergencymode.IsEmergency() { + emergencymode.RestrictUserForEmergency(&user, msg) + return + } + + // Sleep a bit to make sure eventual consistency has kicked in + time.Sleep(200 * time.Millisecond) + + // Check if the user was already restricted + chatMember, err := adminbot.GetChatMember(user.ID, msg.Chat.ID) + //chatMember, err := tdlib.GetChatMember(msg.Chat.ID, user.ID) + + // If the user had "serious" restrictions, don't give them a chance to unrestrict themselves + if err != nil { + log.Error("Unable to get chat member stats for user with ID ", user.ID, ":", err) + } else if chatMember.Status == "restricted" { + log.Info("User with ID", user.ID, " was marked as restricted!") + return + } + + //if err == nil && chatMember.Status.ChatMemberStatusType() == "chatMemberStatusRestricted" { + // log.Info("User with ID", user.ID, " was marked as restricted!") + // return + //} + + /* RESTRICT THE USER AND SHOW THE RULES */ + _ = adminbot.RestrictMessages(&user, msg.Chat.ID, 0, &repository.Bot.Self) + verificationMessageRes, err := api.SendReplyPlainTextMessage(msg.MessageID, msg.Chat.ID, repository.Configuration.AdminBot.WelcomeText, false) + if err != nil { + return + } + + // Give the user time to read the rules + time.Sleep(10 * time.Second) + + if antiuserbot.IsAttack() { + return + } + + replyMarkup := buttons.CreateKeyboardWithOneRow(buttons.CreateHumanVerificationButton(msg.From.ID + repository.Bot.Self.ID)) + _, err = adminbot.EditMessageReplyMarkup(verificationMessageRes.MessageID, verificationMessageRes.Chat.ID, &replyMarkup) + + go func() { + + if repository.GetTestingStatus() { + time.Sleep(15 * time.Second) + } else { + time.Sleep(5 * time.Minute) + } + + deleteErr := api.DeleteMessage(verificationMessageRes.Chat.ID, verificationMessageRes.MessageID) + if deleteErr == nil { + adminbot.KickUser(user.ID, repository.Bot.Self.ID, msg.Chat.ID) + } + + }() + +} + +//HandleAtAdmin handles @admin mentions +func HandleAtAdmin(msg *tgbotapi.Message) { + + antiflood.IncreaseFloodCounter(1) + if chatIsUnderAttack() { + return + } + + var err error + var reportedMessageID, backupMessageID int + var reportedUserID int64 + var reportedUserName string + reportMessageID := msg.MessageID + + // We will first try to backup the reported message. + if msg.ReplyToMessage != nil { + + reportedMessageID = msg.ReplyToMessage.MessageID + reportedUserID = msg.ReplyToMessage.From.ID + reportedUserName = telegram.GetName(msg.ReplyToMessage.From) + backupMessageID, err = adminbot.ForwardMessage(repository.GetTelegramConfiguration().BackupChannelID, msg.Chat.ID, reportedMessageID) + if err != nil { + log.Error(fmt.Sprintf("Can't forward reported message: %s", err)) + } + + } + + // If we didn't manage to backup the reported message + // we will at least try to back up the report. + if backupMessageID == 0 { + backupMessageID, err = adminbot.ForwardMessage(repository.GetTelegramConfiguration().BackupChannelID, msg.Chat.ID, reportMessageID) + } + + var reportText string + if reportedMessageID != 0 { + reportText = reports.ChatMessageReported(msg.From.ID, telegram.GetName(msg.From), reportedUserID, reportedUserName) + } else { + reportText = reports.ChatMessageReport(msg.From.ID, telegram.GetName(msg.From)) + } + + markup := buttons.CreateAtAdminReportMarkup(reportedMessageID, reportMessageID, backupMessageID, msg.Chat.Type) + _ = reports.ReportWithMarkup(reportText, markup, reports.URGENT) + log.Info(reportText) +} diff --git a/automod/fingerprint_checks.go b/automod/fingerprint_checks.go new file mode 100644 index 0000000..4ed814b --- /dev/null +++ b/automod/fingerprint_checks.go @@ -0,0 +1,27 @@ +package automod + +import ( + "fmt" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + log "github.com/sirupsen/logrus" + + "github.com/shitpostingio/admin-bot/adminbot" + "github.com/shitpostingio/admin-bot/database/database" + "github.com/shitpostingio/admin-bot/telegram" +) + +func performFingerprintChecks(uniqueFileID, fileID string, msg *tgbotapi.Message) bool { + + media, err := database.FindMediaByFileID(uniqueFileID, fileID) + if err != nil { + return false + } + + if !media.IsWhitelisted { + adminbot.DeleteMessage(msg.Chat.ID, msg.MessageID) + log.Info(fmt.Sprintf("Removed a blacklisted media posted by %s", telegram.GetNameOrUsername(msg.From))) + } + + return true +} diff --git a/automod/media_checks.go b/automod/media_checks.go new file mode 100644 index 0000000..0e15d94 --- /dev/null +++ b/automod/media_checks.go @@ -0,0 +1,63 @@ +package automod + +import ( + "errors" + "fmt" + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/shitpostingio/admin-bot/adminbot" + "github.com/shitpostingio/admin-bot/analysisadapter" + "github.com/shitpostingio/admin-bot/database/database" + "github.com/shitpostingio/admin-bot/entities" + "github.com/shitpostingio/admin-bot/reports" + "github.com/shitpostingio/admin-bot/repository" + "github.com/shitpostingio/admin-bot/telegram" + log "github.com/sirupsen/logrus" +) + +func performAnalysis(fileUniqueID, fileID string, msg *tgbotapi.Message) { + + // Every media can be looked up via FileUniqueID + media, err := database.FindMediaByFileUniqueID(fileUniqueID) + if err == nil { + handleKnownMedia(media, msg) + return + } + + // We can perform feature-based analysis for only + // certain file types. + if !telegram.MediaCanBeAnalyzed(fileID) { + return + } + + // Feature based search + analysis, analysisErr := analysisadapter.GetAnalysis(fileUniqueID, fileID) + if analysisErr == nil || !errors.Is(analysisErr, analysisadapter.FingerprintError) { + media, err = database.FindMediaByFeatures(analysis.Fingerprint.Histogram, analysis.Fingerprint.PHash, 0.08) + if err == nil { + handleKnownMedia(media, msg) + return + } + } + + // NSFW + if analysisErr == nil || !errors.Is(analysisErr, analysisadapter.NSFWError) { + if analysis.NSFW.IsNSFW { + + nsfwTableID, _ := database.BlacklistNSFWMedia(fileUniqueID, fileID, analysis.NSFW.Label, analysis.NSFW.Confidence, repository.Bot.Self.ID) + reportText := reports.RemovedNSFWMedia(msg.From.ID, telegram.GetName(msg.From), analysis.NSFW.Label, analysis.NSFW.Confidence) + reportNSFWMessage(msg.Chat.ID, msg.MessageID, reportText, nsfwTableID, msg.Chat.Type) + adminbot.DeleteMessageAndLog(reportText, msg.Chat.ID, msg.MessageID) + + } + } +} + +func handleKnownMedia(media entities.Media, msg *tgbotapi.Message) { + + if media.IsWhitelisted { + return + } + + adminbot.DeleteMessage(msg.Chat.ID, msg.MessageID) + log.Info(fmt.Sprintf("Removed a blacklisted media posted by %s", telegram.GetNameOrUsername(msg.From))) +} diff --git a/automod/message_checks.go b/automod/message_checks.go new file mode 100644 index 0000000..17fe52f --- /dev/null +++ b/automod/message_checks.go @@ -0,0 +1,304 @@ +package automod + +import ( + "fmt" + "github.com/shitpostingio/admin-bot/analysisadapter" + "github.com/shitpostingio/admin-bot/reports" + "io/ioutil" + "net/http" + "net/url" + "strings" + "time" + "unicode/utf16" + + log "github.com/sirupsen/logrus" + + "github.com/shitpostingio/admin-bot/adminbot" + "github.com/shitpostingio/admin-bot/api/tdlib" + "github.com/shitpostingio/admin-bot/callback/buttons" + "github.com/shitpostingio/admin-bot/database/database" + "github.com/shitpostingio/admin-bot/database/documentstore" + "github.com/shitpostingio/admin-bot/entities" + "github.com/shitpostingio/admin-bot/localization" + "github.com/shitpostingio/admin-bot/repository" + "github.com/shitpostingio/admin-bot/telegram" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "github.com/shitpostingio/admin-bot/utility" +) + +// performChecksOnMessageText performs various messge checks +// and returns true if the message has been deleted. +func performChecksOnMessageText(msg *tgbotapi.Message) bool { + + if checkMessageOrigin(msg) { + return true + } + + messageText := telegram.GetMessageText(msg) + messageTextLength := len(messageText) + + // We can skip additional checks on empty messages. + if messageTextLength == 0 { + return false + } + + if checkMessageLength(messageText, messageTextLength, msg) { + return true + } + + // Additional checks need to be performed on the + // UTF-16 representation of the text. + tUTF16 := utf16.Encode([]rune(messageText)) + if checkMessageText(tUTF16, msg) { + return true + } + + if checkUnwantedHostnames(tUTF16, msg) { + return true + } + + return false +} + +// checkMessageOrigin checks if a message has been forwarded. If it has, it then +// checks if it has been forwarded from a non-whitelisted channel or from a +// blacklisted handle. If this is the case, the message is deleted and a report +// is sent to the report channel. +func checkMessageOrigin(msg *tgbotapi.Message) bool { + + var reportText string + + // Forwards from channels must be explicitly allowed. + // In case they aren't, we will try to blacklist the source. + if msg.ForwardFromChat != nil && msg.ForwardFromChat.IsChannel() { + + if database.SourceIsWhitelisted(msg.ForwardFromChat.ID, msg.ForwardFromChat.UserName) { + return false + } + + reportText = reports.ForwardFromChannel(msg.From.ID, telegram.GetName(msg.From)) + _, _ = database.BlacklistSource(msg.ForwardFromChat.ID, msg.ForwardFromChat.UserName, msg.From.ID) + handleMessageDeletionAndReport(reportText, msg) + return true + } + + // Remove forwards from blacklisted handles. + if msg.ForwardFrom != nil { + + //handles for users and bots must be explicitly blacklisted to be removed + if !database.SourceIsBlacklisted(msg.ForwardFrom.ID, msg.ForwardFrom.UserName) { + return false + } + + reportText = reports.ForwardFromBlacklistedHandle(msg.From.ID, telegram.GetName(msg.From)) + handleMessageDeletionAndReport(reportText, msg) + _ = database.UpdateSource(msg.ForwardFrom.ID, msg.ForwardFrom.UserName) + return true + } + + // Remove messages sent via blacklisted inline bots + if msg.ViaBot != nil { + + if !database.SourceIsBlacklisted(msg.ViaBot.ID, msg.ViaBot.UserName) { + return false + } + + reportText = reports.MessageSentViaBlacklistedInlineBot(msg.From.ID, telegram.GetName(msg.From)) + handleMessageDeletionAndReport(reportText, msg) + _ = database.UpdateSource(msg.ViaBot.ID, msg.ViaBot.UserName) + return true + } + + return false + +} + +func checkMessageLength(text string, textLength int, msg *tgbotapi.Message) bool { + + // Admins are not subject to these limitations + if repository.Admins[msg.From.ID] { + return false + } + + if textLength > 800 || strings.Count(text, "\n") > 15 { + logText := fmt.Sprintf("Removed long message posted by %s", telegram.GetNameOrUsername(msg.From)) + adminbot.DeleteMessageAndLog(logText, msg.Chat.ID, msg.MessageID) + return true + } + + return false + +} + +// checkMessageText deletes messages over 800 bytes sent by people that are not db admins +// and unwanted handles or links. +func checkMessageText(tUTF16 []uint16, msg *tgbotapi.Message) bool { + + /* UNWANTED HANDLES OR LINKS */ + if messageHasUnwantedHandles(tUTF16, msg) { + reportText := reports.RemovedUnwantedHandle(msg.From.ID, telegram.GetName(msg.From)) + handleMessageDeletionAndReport(reportText, msg) + return true + } + + return false +} + +// messageHasUnwantedHandles returns true if the text has unwanted handles. +func messageHasUnwantedHandles(tUTF16 []uint16, msg *tgbotapi.Message) bool { + + handles := telegram.GetAllMentionsUTF16(tUTF16, telegram.GetMessageEntities(msg), msg.ReplyMarkup) + for _, handle := range handles { + + if handle == "joinchat" { + return true + } + + if strings.HasPrefix(handle, "admin") { + HandleAtAdmin(msg) + continue + } + + source, err := database.GetSource(0, handle) + if err == nil { + if source.IsWhitelisted { + continue + } else { + return true + } + } + + if tdlib.IsGroupOrChannelUsername(handle) { + _, _ = database.BlacklistSource(0, handle, msg.From.ID) + return true + } + + if strings.HasSuffix(handle, "bot") { + + g, err := analysisadapter.GetGibberishValues(handle) + if err != nil || !g.IsGibberish { + continue + } + + bot := repository.Bot + adminbot.DeleteMessage(msg.Chat.ID, msg.MessageID) + _ = adminbot.RestrictMessages(msg.From, msg.Chat.ID, 0, &bot.Self) + markup := buttons.CreateKeyboardWithOneRow(buttons.CreateUnrestrictButton(msg.From.ID), buttons.CreateBanForGibberishHandleButton(msg.From.ID, handle)) + text := reports.UserMutedForUnwantedLink(bot.Self.ID, telegram.GetName(&bot.Self), msg.From.ID, telegram.GetName(msg.From), "posting a gibberish bot handle") + _ = reports.ReportWithMarkup(text, markup, reports.NON_URGENT) + //_ = adminbot.BanUser(msg.From, &repository.Bot.Self, "posting a gibberish bot handle", repository.GetTelegramConfiguration().GroupID) + return true + } + } + + return false +} + +// checkUnwantedHostnames checks if the message contains links from unwanted hostnames. +// Chat moderators and db admins are immune to these checks. +func checkUnwantedHostnames(tUTF16 []uint16, msg *tgbotapi.Message) bool { + + if !repository.GetTestingStatus() && utility.IsChatAdminByMessage(msg) { + return false + } + + urls := telegram.GetURLs(tUTF16, telegram.GetMessageEntities(msg)) + for _, textURL := range urls { + + if !strings.HasPrefix(textURL, "http") { + textURL = fmt.Sprintf("http://%s", textURL) + } + + parsedURL, err := url.Parse(textURL) + if err == nil { + + if strings.Contains(parsedURL.Host, "bitly") || + strings.Contains(parsedURL.Host, "bit.ly") || + strings.Contains(parsedURL.Host, "tinyurl") { + + data, err := http.Get(textURL) // nolint: gosec + if err != nil { + continue + } + + res, err := ioutil.ReadAll(data.Body) + if err != nil { + utility.CloseSafely(data.Body) + continue + } + + if strings.Contains(string(res), `toNumbers("f655ba9d09a112d4968c63579db590b4")`) { + _, _, _ = database.BlacklistHostName(textURL, true, false, repository.Bot.Self.ID) + } + + utility.CloseSafely(data.Body) + + } + + } + + dbHostname, err := database.GetHostName(textURL) + + // Telegram links have already been checked + // in previous functions, we can skip them. + if err != nil || dbHostname.IsTelegram { + continue + } + + adminbot.DeleteMessage(msg.Chat.ID, msg.MessageID) + punishUserForUnwantedHostname(&dbHostname, msg) + return true + + } + + return false +} + +func punishUserForUnwantedHostname(dbHostname *entities.HostName, msg *tgbotapi.Message) { + + if !dbHostname.IsBanworthy { + _ = reports.Report(reports.RemovedUnwantedLink(dbHostname.Host, msg.From.ID, telegram.GetName(msg.From)), reports.NON_URGENT) + return + } + + motivation := fmt.Sprintf(localization.GetString("automod_unwanted_link_ban_reason"), dbHostname.Host) + bot := repository.Bot + + if time.Since(documentstore.GetUserJoinDate(msg.From)) <= time.Hour { + banUserForUnwantedHostname(motivation, dbHostname, msg, bot) + } else { + muteUserForUnwantedHostname(motivation, dbHostname, msg, bot) + } + +} + +func banUserForUnwantedHostname(motivation string, dbHostname *entities.HostName, msg *tgbotapi.Message, bot *tgbotapi.BotAPI) { + + err := adminbot.BanUser(msg.From, &bot.Self, motivation, msg.Chat.ID) + if err != nil { + + logText := fmt.Sprintf(localization.GetString("automod_unwanted_link_unable_to_ban"), msg.From.ID, telegram.GetName(msg.From), msg.From.ID, dbHostname.Host, err) + log.Error(logText) + + } + +} + +func muteUserForUnwantedHostname(motivation string, dbHostname *entities.HostName, msg *tgbotapi.Message, bot *tgbotapi.BotAPI) { + + err := adminbot.RestrictMessages(msg.From, msg.Chat.ID, 0, &bot.Self) + if err != nil { + + logText := fmt.Sprintf(localization.GetString("automod_unwanted_link_unable_to_mute"), msg.From.ID, telegram.GetName(msg.From), msg.From.ID, dbHostname.Host, err) + log.Error(logText) + return + + } + + reportText := reports.UserMutedForUnwantedLink(bot.Self.ID, telegram.GetName(&bot.Self), msg.From.ID, telegram.GetName(msg.From), motivation) + markup := buttons.CreateKeyboardWithOneRow(buttons.CreateUnrestrictButton(msg.From.ID), buttons.CreateHandleButton()) + _ = reports.ReportWithMarkup(reportText, markup, reports.NON_URGENT) + +} diff --git a/automod/nsfw_checks.go b/automod/nsfw_checks.go new file mode 100644 index 0000000..263b1f7 --- /dev/null +++ b/automod/nsfw_checks.go @@ -0,0 +1,39 @@ +package automod + +import ( + "github.com/shitpostingio/admin-bot/reports" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "github.com/shitpostingio/admin-bot/adminbot" + "github.com/shitpostingio/admin-bot/telegram" +) + +func performNSFWChecks(uniqueFileID, fileID string) (isNSFW bool, nsfwTableID string, description string, score float64) { + + //isNSFW, score, description = analysisadapter.GetNSFWScores(uniqueFileID, fileID) + //if !isNSFW { + // return + //} + // + //nsfwTableID, _ = database.BlacklistNSFWMedia(uniqueFileID, fileID, description, score, repository.Bot.Self.ID) + return + +} + +// performNSFWChecksOnPhoto gets the NSFW scores for the photo from FPServer. +// If the photo is NSFW, it'll be blacklisted and added to the nsfw table. +func performNSFWChecksOnMedia(uniqueFileID, fileID string, msg *tgbotapi.Message) (isNSFW bool) { + + isNSFW, nsfwTableID, description, score := performNSFWChecks(uniqueFileID, fileID) + if nsfwTableID != "" { + + reportText := reports.RemovedNSFWMedia(msg.From.ID, telegram.GetName(msg.From), description, score) + reportNSFWMessage(msg.Chat.ID, msg.MessageID, reportText, nsfwTableID, msg.Chat.Type) + adminbot.DeleteMessageAndLog(reportText, msg.Chat.ID, msg.MessageID) + + } + + return isNSFW + +} diff --git a/automod/utility.go b/automod/utility.go new file mode 100644 index 0000000..217f0d1 --- /dev/null +++ b/automod/utility.go @@ -0,0 +1,49 @@ +package automod + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "github.com/shitpostingio/admin-bot/adminbot" + "github.com/shitpostingio/admin-bot/callback/buttons" + "github.com/shitpostingio/admin-bot/defense/antiflood" + "github.com/shitpostingio/admin-bot/defense/antiuserbot" + "github.com/shitpostingio/admin-bot/repository" + "github.com/shitpostingio/admin-bot/utility" +) + +//reportNSFWMessage backs up the NSFW message and reports it. +func reportNSFWMessage(chatID int64, messageID int, reportMessage string, nsfwTableID string, chatType string) { + + fwMsgID, err := adminbot.ForwardMessage(repository.GetTelegramConfiguration().BackupChannelID, chatID, messageID) + + antiflood.IncreaseFloodCounter(1) + if !antiflood.IsFlood() { + + if err == nil { + backupRow := tgbotapi.NewInlineKeyboardRow(buttons.CreateBackupMessageButton(fwMsgID, chatType)) + actionRow := tgbotapi.NewInlineKeyboardRow(buttons.CreateWhitelistMediaButton(nsfwTableID), buttons.CreateHandleButton()) + markup := tgbotapi.NewInlineKeyboardMarkup(backupRow, actionRow) + _ = adminbot.SendTextMessageWithMarkup(repository.GetTelegramConfiguration().ReportChannelID, reportMessage, markup, false) + } else { + _ = adminbot.SendTextMessage(repository.GetTelegramConfiguration().ReportChannelID, reportMessage, false) + } + } +} + +//handleMessageDeletionAndReport deletes a message and reports it if possible +func handleMessageDeletionAndReport(reportText string, msg *tgbotapi.Message) { + + antiflood.IncreaseFloodCounter(1) + if !antiflood.IsFlood() { + adminbot.DeleteMessageAndReport(reportText, msg.Chat.ID, msg.MessageID) + return + } + + adminbot.DeleteMessageAndLog(reportText, msg.Chat.ID, msg.MessageID) + _ = adminbot.RestrictMessages(msg.From, msg.Chat.ID, utility.GetAppropriateRestrictionEnd(), &repository.Bot.Self) +} + +//chatIsUnderAttack returns true if the chat is being flooded or has suspicious joins +func chatIsUnderAttack() bool { + return antiflood.IsFlood() || antiuserbot.IsAttack() +} diff --git a/buildzip.sh b/buildzip.sh new file mode 100644 index 0000000..66aa854 --- /dev/null +++ b/buildzip.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +ROOTDIR=$(pwd) +VERSION=$(git describe --tags --abbrev=0) +DEST=admin-bot-v$VERSION +mkdir $DEST + +echo "[!!] version $VERSION" +echo "[+] building admin-bot..." +make build + +mv admin-bot $DEST +cd database/cmd + +for i in adminbot-*; do + cd $i + echo "[+] building $i..." + go build + mv $i ../../../$DEST + cd ../ +done + + +cd $ROOTDIR +echo "[+] building tar.xz..." +tar -cf - $DEST | xz -9 -c - > $DEST.tar.xz +echo "[+] cleaning..." +rm -r $DEST +echo "[+] done!" \ No newline at end of file diff --git a/callback/buttons/buttons.go b/callback/buttons/buttons.go new file mode 100644 index 0000000..2cf7c68 --- /dev/null +++ b/callback/buttons/buttons.go @@ -0,0 +1,211 @@ +package buttons + +import ( + "fmt" + "strconv" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "github.com/shitpostingio/admin-bot/localization" + "github.com/shitpostingio/admin-bot/repository" +) + +/* + *********************************************************************************************************************** + * * + * CALLBACK BUTTONS * + * * + *********************************************************************************************************************** + */ + +// CreateHandleButton creates a button to mark an action as handled +func CreateHandleButton() tgbotapi.InlineKeyboardButton { + markAsHandledString := "2s ok" + return tgbotapi.NewInlineKeyboardButtonData(localization.GetString("buttons_mark_as_handled"), markAsHandledString) +} + +// CreateWhitelistMediaButton creates a button to whitelist in 2 steps a media +func CreateWhitelistMediaButton(nsfwTableID string) tgbotapi.InlineKeyboardButton { + actionToPerform := fmt.Sprintf("2s whitelist %s", nsfwTableID) + return tgbotapi.NewInlineKeyboardButtonData(localization.GetString("buttons_whitelist_media"), actionToPerform) +} + +// CreateTgUnbanButton creates a button to unban in two steps a user +// whose ban was not in the database +func CreateTgUnbanButton(userID int64) tgbotapi.InlineKeyboardButton { + actionToPerform := fmt.Sprintf("2sa tgunban %d", userID) + return tgbotapi.NewInlineKeyboardButtonData(localization.GetString("buttons_unban_user"), actionToPerform) +} + +//CreateUnbanButton creates a button to unban in two steps a user +func CreateUnbanButton(userID int64) tgbotapi.InlineKeyboardButton { + actionToPerform := fmt.Sprintf("2sa unban %d", userID) + return tgbotapi.NewInlineKeyboardButtonData(localization.GetString("buttons_unban_user"), actionToPerform) +} + +// CreateUnrestrictButton creates a button to unrestrict in two steps a user +func CreateUnrestrictButton(userID int64) tgbotapi.InlineKeyboardButton { + actionToPerform := fmt.Sprintf("2sa unrestrict %d", userID) + return tgbotapi.NewInlineKeyboardButtonData(localization.GetString("buttons_unrestrict_user"), actionToPerform) +} + +// CreateModUserButton creates a button to mod a user +func CreateModUserButton(userID int64) tgbotapi.InlineKeyboardButton { + actionToPerform := fmt.Sprintf("2sa mod %d", userID) + return tgbotapi.NewInlineKeyboardButtonData(localization.GetString("buttons_mod_user"), actionToPerform) +} + +// CreateBlacklistBanworthyHostnameButton creates a button to add a banworthy hostname to the blacklist +func CreateBlacklistBanworthyHostnameButton(hostname string) tgbotapi.InlineKeyboardButton { + actionToPerform := fmt.Sprintf("2sa bbh %s", hostname) + return tgbotapi.NewInlineKeyboardButtonData(localization.GetString("buttons_banworthy"), actionToPerform) +} + +// CreateBlacklistTelegramHostnameButton creates a button to add a telegram hostname to the blacklist +func CreateBlacklistTelegramHostnameButton(hostname string) tgbotapi.InlineKeyboardButton { + actionToPerform := fmt.Sprintf("2sa bth %s", hostname) + return tgbotapi.NewInlineKeyboardButtonData(localization.GetString("buttons_telegram"), actionToPerform) +} + +// CreateBlacklistHostnameButton creates a button to add a hostname to the blacklist +func CreateBlacklistHostnameButton(hostname string) tgbotapi.InlineKeyboardButton { + actionToPerform := fmt.Sprintf("2sa bh %s", hostname) + return tgbotapi.NewInlineKeyboardButtonData(localization.GetString("buttons_remove_only"), actionToPerform) +} + +// CreateBanForGibberishHandleButton creates a button to ban a user for sending a gibberish bot handle +func CreateBanForGibberishHandleButton(userID int64, handle string) tgbotapi.InlineKeyboardButton { + actionToPerform := fmt.Sprintf("2s bg %d %s", userID, handle) + return tgbotapi.NewInlineKeyboardButtonData(localization.GetString("buttons_ban_user"), actionToPerform) +} + +// CreateHumanVerificationButton creates a button for users to verify that they've read the rules +func CreateHumanVerificationButton(userID int64) tgbotapi.InlineKeyboardButton { + actionToPerform := fmt.Sprintf("verify %d", userID) + return tgbotapi.NewInlineKeyboardButtonData(localization.GetString("buttons_rules_read"), actionToPerform) +} + +// CreateApproveUserButton creates a button to approve a user in an emergency +func CreateApproveUserButton(userID int64) tgbotapi.InlineKeyboardButton { + actionToPerform := fmt.Sprintf("2s unrestrict %d", userID) + return tgbotapi.NewInlineKeyboardButtonData(localization.GetString("emergencymode_approve_user"), actionToPerform) +} + +func CreatePrivateBlacklistMediaButton() tgbotapi.InlineKeyboardButton { + actionToPerform := "2s blm" + return tgbotapi.NewInlineKeyboardButtonData(localization.GetString("private_button_blacklist"), actionToPerform) +} + +func CreatePrivateWhitelistMediaButton() tgbotapi.InlineKeyboardButton { + actionToPerform := "2s wlm" + return tgbotapi.NewInlineKeyboardButtonData(localization.GetString("private_button_whitelist"), actionToPerform) +} + +func CreatePrivatePardonMediaButton() tgbotapi.InlineKeyboardButton { + actionToPerform := "2s parm" + return tgbotapi.NewInlineKeyboardButtonData(localization.GetString("private_button_remove"), actionToPerform) +} + +func CreatePrivateBlacklistStickerPackButton() tgbotapi.InlineKeyboardButton { + actionToPerform := "2s blsp" + return tgbotapi.NewInlineKeyboardButtonData(localization.GetString("private_button_blacklist_pack"), actionToPerform) +} + +func CreatePrivatePardonStickerPackButton() tgbotapi.InlineKeyboardButton { + actionToPerform := "2s parsp" + return tgbotapi.NewInlineKeyboardButtonData(localization.GetString("private_button_remove_pack"), actionToPerform) +} + +func CreatePrivateBlacklistStickerButton() tgbotapi.InlineKeyboardButton { + actionToPerform := "2s blms" + return tgbotapi.NewInlineKeyboardButtonData(localization.GetString("private_button_blacklist"), actionToPerform) +} + +func CreatePrivateWhitelistStickerButton() tgbotapi.InlineKeyboardButton { + actionToPerform := "2s wlms" + return tgbotapi.NewInlineKeyboardButtonData(localization.GetString("private_button_whitelist"), actionToPerform) +} + +func CreatePrivatePardonStickerButton() tgbotapi.InlineKeyboardButton { + actionToPerform := "2s parms" + return tgbotapi.NewInlineKeyboardButtonData(localization.GetString("private_button_remove"), actionToPerform) +} + +func CreatePrivateBlacklistSourceButton(source string) tgbotapi.InlineKeyboardButton { + actionToPerform := fmt.Sprintf("2s bs %s", source) + return tgbotapi.NewInlineKeyboardButtonData(localization.GetString("private_button_blacklist"), actionToPerform) +} + +func CreatePrivatePardonSourceButton(source string) tgbotapi.InlineKeyboardButton { + actionToPerform := fmt.Sprintf("2s ps %s", source) + return tgbotapi.NewInlineKeyboardButtonData(localization.GetString("private_button_remove"), actionToPerform) +} + +func CreatePrivateWhitelistSourceButton(source string) tgbotapi.InlineKeyboardButton { + actionToPerform := fmt.Sprintf("2s ws %s", source) + return tgbotapi.NewInlineKeyboardButtonData(localization.GetString("private_button_whitelist"), actionToPerform) +} + +/* + *********************************************************************************************************************** + * * + * URL BUTTONS * + * * + *********************************************************************************************************************** + */ + +// CreateReportedMessageButton creates a button with an URL for a reported message +func CreateReportedMessageButton(messageID int, chatType string) tgbotapi.InlineKeyboardButton { + + var url string + if repository.GetTelegramConfiguration().GroupLink != "" { + url = fmt.Sprintf("%s/%d", repository.GetTelegramConfiguration().GroupLink, messageID) + } else { + url = fmt.Sprintf("https://t.me/c/%s/%d", getPrivateChatIDString(repository.GetTelegramConfiguration().GroupID, chatType), messageID) + } + + return tgbotapi.NewInlineKeyboardButtonURL(localization.GetString("buttons_reported_message"), url) +} + +// CreateReportMessageButton creates a button with an URL for a report message +func CreateReportMessageButton(messageID int, chatType string) tgbotapi.InlineKeyboardButton { + + var url string + if repository.GetTelegramConfiguration().GroupLink != "" { + url = fmt.Sprintf("%s/%d", repository.GetTelegramConfiguration().GroupLink, messageID) + } else { + url = fmt.Sprintf("https://t.me/c/%s/%d", getPrivateChatIDString(repository.GetTelegramConfiguration().GroupID, chatType), messageID) + } + + return tgbotapi.NewInlineKeyboardButtonURL(localization.GetString("buttons_report"), url) +} + +// CreateBackupMessageButton creates a button with an URL for a backup message +func CreateBackupMessageButton(messageID int, chatType string) tgbotapi.InlineKeyboardButton { + + var url string + if repository.GetTelegramConfiguration().BackupChannelLink != "" { + url = fmt.Sprintf("%s/%d", repository.GetTelegramConfiguration().BackupChannelLink, messageID) + } else { + url = fmt.Sprintf("https://t.me/c/%s/%d", getPrivateChatIDString(repository.GetTelegramConfiguration().BackupChannelID, chatType), messageID) + } + + return tgbotapi.NewInlineKeyboardButtonURL(localization.GetString("buttons_backup"), url) +} + +// getPrivateChatIDString returns the chatID converted for private chat links. +// As per Anime Sex Storm: +// This value is modified from a normal integer into this value based on the chat type. +// A "private" chat will always be a normal int, +// A "group" chat will be an int in the negatives, +// A "supergroup" or "channel" chats will be negative and prepended with 100. +func getPrivateChatIDString(originalChatID int64, chatType string) string { + + chatIDStr := strconv.FormatInt(originalChatID, 10) + + if chatType == "group" { + return chatIDStr[1:] + } + + return chatIDStr[4:] +} diff --git a/callback/buttons/keyboard.go b/callback/buttons/keyboard.go new file mode 100644 index 0000000..9739439 --- /dev/null +++ b/callback/buttons/keyboard.go @@ -0,0 +1,47 @@ +package buttons + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +//CreateAtAdminReportMarkup creates the InlineMarkup for a @admin mention +func CreateAtAdminReportMarkup(reportedMessageID, reportMessageID, backupMessageID int, chatType string) (keyboard tgbotapi.InlineKeyboardMarkup) { + keyboard = CreateReportAndBackupMarkup(reportedMessageID, reportMessageID, backupMessageID, chatType) + keyboard.InlineKeyboard = append(keyboard.InlineKeyboard, tgbotapi.NewInlineKeyboardRow(CreateHandleButton())) + return +} + +//CreateReportAndBackupMarkup creates the InlineMarkup containing links to the reported message, report and backup +func CreateReportAndBackupMarkup(reportedMessageID, reportMessageID, backupMessageID int, chatType string) (keyboard tgbotapi.InlineKeyboardMarkup) { + + var reportAndBackupRow []tgbotapi.InlineKeyboardButton + + /* REPORTED MESSAGE ROW (OPTIONAL) */ + if reportedMessageID != 0 { + reportedMessageRow := tgbotapi.NewInlineKeyboardRow(CreateReportedMessageButton(reportedMessageID, chatType)) + keyboard.InlineKeyboard = append(keyboard.InlineKeyboard, reportedMessageRow) + } + + /* REPORT AND BACKUP ROW */ + if reportMessageID != 0 { + reportMessageButton := CreateReportMessageButton(reportMessageID, chatType) + reportAndBackupRow = append(reportAndBackupRow, reportMessageButton) + } + + if backupMessageID != 0 { + backupMessageButton := CreateBackupMessageButton(backupMessageID, chatType) + reportAndBackupRow = append(reportAndBackupRow, backupMessageButton) + } + + /* IF WE ADD AN EMPTY ROW TO THE KEYBOARD IT MIGHT CAUSE ISSUES */ + if len(reportAndBackupRow) > 0 { + keyboard.InlineKeyboard = append(keyboard.InlineKeyboard, reportAndBackupRow) + } + + return keyboard +} + +// CreateKeyboardWithOneRow creates an inline markup keyboard with one row +func CreateKeyboardWithOneRow(buttons ...tgbotapi.InlineKeyboardButton) tgbotapi.InlineKeyboardMarkup { + return tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(buttons...)) +} diff --git a/callback/callback.go b/callback/callback.go new file mode 100644 index 0000000..49185da --- /dev/null +++ b/callback/callback.go @@ -0,0 +1,202 @@ +package callback + +import ( + "strconv" + "strings" + "sync" + "time" + + "github.com/patrickmn/go-cache" + log "github.com/sirupsen/logrus" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "github.com/shitpostingio/admin-bot/adminbot" + "github.com/shitpostingio/admin-bot/localization" + "github.com/shitpostingio/admin-bot/repository" +) + +var ( + authorizationCache *cache.Cache +) + +func init() { + authorizationCache = cache.New(5*time.Second, 10*time.Minute) +} + +type authorizationRequest struct { + action string + mutex sync.Mutex + counter uint8 + handled bool +} + +//HandleCallback handles all callback queries +func HandleCallback(callbackQuery *tgbotapi.CallbackQuery) { + + callbackFields := strings.Fields(callbackQuery.Data) + + var response string + if callbackFields[0] == "verify" { + handleHumanVerification(callbackFields, callbackQuery) + } else if callbackFields[0] == "2sa" { + response = handleAdminOnlyRequests(callbackFields, callbackQuery) + } else { + response = handleRequest(callbackFields, callbackQuery) + } + + if response == "" { + return + } + + err := adminbot.SendCallbackWithAlert(callbackQuery.ID, response) + if err != nil { + log.Error("Unable to send callback response:", err) + } + +} + +func handleHumanVerification(callbackFields []string, callbackQuery *tgbotapi.CallbackQuery) string { + + userID, _ := strconv.ParseInt(callbackFields[1], 10, 64) + userID = userID - repository.Bot.Self.ID + if callbackQuery.From.ID != userID { + return "" + } + + chatMember, err := adminbot.GetChatMember(userID, repository.GetTelegramConfiguration().GroupID) + if err != nil { + log.Error("Unable to find user with ID", userID, "in the group for the human verification:", err) + return localization.GetString("callback_verification_error_occurred") + } + + err = adminbot.UnrestrictUser(chatMember.User, repository.GetTelegramConfiguration().GroupID, callbackQuery.From) + if err != nil { + + log.Error("Unable to unrestrict user with ID", userID, "after successfully passing the human verification:", err) + return localization.GetString("callback_verification_error_occurred") + + } + + adminbot.DeleteMessage(callbackQuery.Message.Chat.ID, callbackQuery.Message.MessageID) + return "" + +} + +func handleAdminOnlyRequests(callbackFields []string, callbackQuery *tgbotapi.CallbackQuery) string { + + if !repository.Admins[callbackQuery.From.ID] { + return localization.GetString("user_unauthorized") + } + + return handleRequest(callbackFields, callbackQuery) + +} + +func handleRequest(callbackFields []string, callbackQuery *tgbotapi.CallbackQuery) string { + + messageIDStr := strconv.Itoa(callbackQuery.Message.MessageID) + item, found := authorizationCache.Get(messageIDStr) + if !found { + return authorizeFirstStep(messageIDStr, callbackFields) + } + + var reply string + request := item.(*authorizationRequest) + request.mutex.Lock() + defer request.mutex.Unlock() + + if request.handled { + return localization.GetString("callback_request_action_already_authorized") + } + + if request.action == callbackFields[1] { + + request.counter++ + if request.counter >= 2 { + go handleSecondStep(callbackFields, callbackQuery) + request.handled = true + } + + reply = localization.GetString("callback_request_performed_shortly") + + } else { + + if request.counter < 2 { + reply = localization.GetString("callback_twostep_different_action_alredy_requested") + } else { + reply = localization.GetString("callback_twostep_different_action_already_approved") + } + + } + + return reply + +} + +//authorizeFirstStep authorizes the first step of a 2fa action +func authorizeFirstStep(messageIDStr string, callbackFields []string) string { + + request := authorizationRequest{ + action: callbackFields[1], + counter: 1, + mutex: sync.Mutex{}, + handled: false, + } + + err := authorizationCache.Add(messageIDStr, &request, cache.DefaultExpiration) + if err != nil { + return localization.GetString("callback_first_step_error") + } + + return localization.GetString("callback_first_step_perform_second") + +} + +func handleSecondStep(callbackFields []string, callbackQuery *tgbotapi.CallbackQuery) { + + /* GET 2ND STEP CALLBACK FIELDS */ + callbackFields = callbackFields[1:] + + switch callbackFields[0] { + case "ok": + markReportAsHandled(callbackQuery) + case "whitelist": + whitelistMedia(callbackFields, callbackQuery) + case "tgunban": + tgunbanUser(callbackFields, callbackQuery) + case "unban": + unbanUser(callbackFields, callbackQuery) + case "unrestrict": + unrestrictUser(callbackFields, callbackQuery) + case "mod": + modUser(callbackFields, callbackQuery) + case "bbh", "bth", "bh": + handleBlacklistHostname(callbackFields, callbackQuery) + case "bg": + banUserForGibberish(callbackFields, callbackQuery) + case "blm": + blacklistPrivateMedia(callbackQuery) + case "wlm": + whitelistPrivateMedia(callbackQuery) + case "parm": + pardonPrivateMedia(callbackQuery) + case "blsp": + blacklistPrivateStickerPack(callbackQuery) + case "parsp": + pardonPrivateStickerPack(callbackQuery) + case "blms": + blacklistPrivateSticker(callbackQuery) + case "wlms": + whitelistPrivateSticker(callbackQuery) + case "parms": + pardonPrivateSticker(callbackQuery) + case "bs": + blacklistSource(callbackFields, callbackQuery) + case "ps": + pardonSource(callbackFields, callbackQuery) + case "ws": + whitelistSource(callbackFields, callbackQuery) + } + +} diff --git a/callback/functions.go b/callback/functions.go new file mode 100644 index 0000000..5ad46d9 --- /dev/null +++ b/callback/functions.go @@ -0,0 +1,247 @@ +package callback + +import ( + "fmt" + "strconv" + "strings" + "unicode/utf16" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + log "github.com/sirupsen/logrus" + "go.mongodb.org/mongo-driver/bson/primitive" + + "github.com/shitpostingio/admin-bot/adminbot" + "github.com/shitpostingio/admin-bot/defense/antispam" + "github.com/shitpostingio/admin-bot/localization" + "github.com/shitpostingio/admin-bot/repository" + "github.com/shitpostingio/admin-bot/telegram" + + "github.com/shitpostingio/admin-bot/consts" + "github.com/shitpostingio/admin-bot/database/database" + "github.com/shitpostingio/admin-bot/utility" +) + +// markReportAsHandled marks a report as handled and saves +// who handled it. +func markReportAsHandled(callbackQuery *tgbotapi.CallbackQuery) { + + handledBy := telegram.GetName(callbackQuery.From) + originalText := getOriginalMessageText(callbackQuery.Message) + updatedText := fmt.Sprintf(localization.GetString("callback_report_handled"), callbackQuery.From.ID, handledBy, originalText) + + updatedReplyMarkup := removeActionButtons(callbackQuery.Message.ReplyMarkup) + err := adminbot.EditMessageText(callbackQuery.Message.MessageID, callbackQuery.Message.Chat.ID, updatedText, consts.ReportParseMode, &updatedReplyMarkup) + if err != nil { + log.Error("Unable to update message after marking report as handled:", err) + } + +} + +//whitelistMedia whitelists media via callback +func whitelistMedia(callbackFields []string, callbackQuery *tgbotapi.CallbackQuery) { + + nsfwTableID, _ := primitive.ObjectIDFromHex(callbackFields[1]) + nsfwEntity, err := database.FindMediaByID(&nsfwTableID) + if err != nil { + log.Error("whitelistMedia:", err) + return + } + + _, _ = database.WhitelistMedia(nsfwEntity.FileID, nsfwEntity.FileID, callbackQuery.From.ID) + + updatedText := fmt.Sprintf(localization.GetString("callback_media_whitelisted"), callbackQuery.From.ID, telegram.GetName(callbackQuery.From), getOriginalMessageText(callbackQuery.Message)) + updatedReplyMarkup := removeActionButtons(callbackQuery.Message.ReplyMarkup) + err = adminbot.EditMessageText(callbackQuery.Message.MessageID, callbackQuery.Message.Chat.ID, updatedText, consts.ReportParseMode, &updatedReplyMarkup) + if err != nil { + log.Error("Unable to update message after whitelisting media:", err) + } + +} + +//tgunbanUser unbans a user that wasn't in the database after adding them +func tgunbanUser(callbackFields []string, callbackQuery *tgbotapi.CallbackQuery) { + userID, _ := strconv.ParseInt(callbackFields[1], 10, 64) + _, _ = database.AddBan(userID, repository.Bot.Self.ID, "Manual ban") + unbanUser(callbackFields, callbackQuery) +} + +//unbanUser unbans a user +func unbanUser(callbackFields []string, callbackQuery *tgbotapi.CallbackQuery) { + + userID, _ := strconv.ParseInt(callbackFields[1], 10, 64) + err := adminbot.UnbanUser(userID, repository.GetTelegramConfiguration().GroupID, callbackQuery.From) + if err != nil { + log.Error("Unable to unban user with ID", userID, "(requested by", telegram.GetNameOrUsername(callbackQuery.From), "):", err) + return + } + + log.Info("User with user ID", callbackFields[1], "has been unbanned by", telegram.GetNameOrUsername(callbackQuery.From)) + + updatedText := fmt.Sprintf(localization.GetString("callback_user_unbanned"), callbackQuery.From.ID, telegram.GetName(callbackQuery.From), getOriginalMessageText(callbackQuery.Message)) + err = adminbot.EditMessageText(callbackQuery.Message.MessageID, callbackQuery.Message.Chat.ID, updatedText, consts.ReportParseMode, nil) + if err != nil { + log.Error("Unable to update message after unbanning user:", err) + } + +} + +func banUserForGibberish(callbackFields []string, callbackQuery *tgbotapi.CallbackQuery) { + + userID, _ := strconv.ParseInt(callbackFields[1], 10, 64) + handle := callbackFields[2] + + _, err := adminbot.BanUserByID(userID, callbackQuery.From, "sending a gibberish handle", repository.GetTelegramConfiguration().GroupID) + if err != nil { + log.Error("Unable to ban user with ID", userID, "for sending a gibberish handle") + return + } + + adminbot.DeleteMessage(callbackQuery.Message.Chat.ID, callbackQuery.Message.MessageID) + _, _ = database.BlacklistSource(0, handle, callbackQuery.From.ID) + +} + +//unrestrictUser unrestricts a user and terminates the antispam routine, if active +func unrestrictUser(callbackFields []string, callbackQuery *tgbotapi.CallbackQuery) { + + userID, _ := strconv.ParseInt(callbackFields[1], 10, 64) + + chatMember, err := adminbot.GetChatMember(userID, repository.GetTelegramConfiguration().GroupID) + if err != nil { + log.Error("Unable to find user with ID", userID, "in the group for the unrestriction requested by", + telegram.GetNameOrUsername(callbackQuery.From), ": ", err) + return + + } + + err = adminbot.UnrestrictUser(chatMember.User, repository.GetTelegramConfiguration().GroupID, callbackQuery.From) + if err != nil { + + log.Error("Unable to unrestrict user with ID", userID, "(requested by", telegram.GetNameOrUsername(callbackQuery.From), "):", err) + return + + } + + antispam.EndAntiSpamRoutineForUser(userID) + + updatedText := fmt.Sprintf(localization.GetString("callback_user_unrestricted"), callbackQuery.From.ID, telegram.GetName(callbackQuery.From), getOriginalMessageText(callbackQuery.Message)) + err = adminbot.EditMessageText(callbackQuery.Message.MessageID, callbackQuery.Message.Chat.ID, updatedText, consts.ReportParseMode, nil) + if err != nil { + log.Error("Unable to update message after unrestricting user:", err) + } + +} + +//modUser mods a user (only CanDeleteMessages) +func modUser(callbackFields []string, callbackQuery *tgbotapi.CallbackQuery) { + + userID, _ := strconv.ParseInt(callbackFields[1], 10, 64) + err := adminbot.PromoteToMod(userID, repository.GetTelegramConfiguration().GroupID) + if err != nil { + log.Error(fmt.Sprintf(localization.GetString("callback_mods_unable_to_mod"), userID, telegram.GetNameOrUsername(callbackQuery.From), err.Error())) + return + } + + // We must also add the user to the mod map. + repository.Mods[userID] = true + log.Info("User with user ID", callbackFields[1], "has been promoted by", telegram.GetNameOrUsername(callbackQuery.From)) + + updatedText := fmt.Sprintf("🛃 User promoted 🛃\n\n%s", callbackQuery.Message.Text) + err = adminbot.EditMessageText(callbackQuery.Message.MessageID, callbackQuery.Message.Chat.ID, updatedText, consts.ReportParseMode, nil) + if err != nil { + log.Error("Unable to update message after promoting user to mod:", err) + } + + chatMember, err := adminbot.GetChatMember(userID, repository.GetTelegramConfiguration().GroupID) + if err != nil { + // Try to add it in another way + _ = database.UpdateModeratorsDetails(repository.GetTelegramConfiguration().GroupID, repository.Bot) + } else { + _, _ = database.AddModerator(&chatMember, callbackQuery.From.ID) + } + +} + +//handleBlacklistHostname displays buttons to handle certain hostnames +func handleBlacklistHostname(callbackFields []string, callbackQuery *tgbotapi.CallbackQuery) { + + //TODO: MIGLIORARE MOLTO + + var err error + + switch callbackFields[0] { + case "bbh": + _, _, err = database.BlacklistHostName(callbackFields[1], true, false, callbackQuery.From.ID) + case "bth": + _, _, err = database.BlacklistHostName(callbackFields[1], false, true, callbackQuery.From.ID) + case "bh": + _, _, err = database.BlacklistHostName(callbackFields[1], false, false, callbackQuery.From.ID) + default: + return + } + + dbEntity, _ := database.GetHostName(callbackFields[1]) + text := fmt.Sprintf(localization.GetString("callback_hosts_result"), err != nil, dbEntity.Host, utility.EmojifyBool(dbEntity.IsBanworthy), utility.EmojifyBool(dbEntity.IsTelegram)) + err = adminbot.EditMessageText(callbackQuery.Message.MessageID, callbackQuery.Message.Chat.ID, text, consts.ReportParseMode, nil) + if err != nil { + log.Error(fmt.Sprintf("Unable to send message to blacklist hostname: %s", err.Error())) + } +} + +func getOriginalMessageText(msg *tgbotapi.Message) string { + + if msg.Entities == nil { + return msg.Text + } + + builder := strings.Builder{} + mRunesUTF16 := utf16.Encode([]rune(msg.Text)) + previousIndex := 0 + normalizedIndex := 0 + + for _, entity := range msg.Entities { + + normalizedIndex = entity.Offset + + switch entity.Type { + case "text_mention": + + builder.WriteString(fmt.Sprintf("%s%s", + string(utf16.Decode(mRunesUTF16[previousIndex:normalizedIndex])), + entity.User.ID, telegram.GetName(entity.User))) + + previousIndex = normalizedIndex + entity.Length + + case "code": + + builder.WriteString(fmt.Sprintf("%s%s", + string(utf16.Decode(mRunesUTF16[previousIndex:normalizedIndex])), + string(utf16.Decode(mRunesUTF16[normalizedIndex:normalizedIndex+entity.Length])))) + + previousIndex = normalizedIndex + entity.Length + } + } + + builder.WriteString(string(utf16.Decode(mRunesUTF16[previousIndex:]))) + return builder.String() +} + +func removeActionButtons(replyMarkup *tgbotapi.InlineKeyboardMarkup) tgbotapi.InlineKeyboardMarkup { + + if replyMarkup == nil { + return tgbotapi.InlineKeyboardMarkup{} + } + + // The `url` buttons are separated from the others + // we can truncate the keyboard when we first see + // a button with some CallbackData in it. + targetRow := 0 + for rowID, row := range replyMarkup.InlineKeyboard { + if row[0].CallbackData != nil { + targetRow = rowID + break + } + } + + return tgbotapi.InlineKeyboardMarkup{InlineKeyboard: replyMarkup.InlineKeyboard[:targetRow]} +} diff --git a/callback/functions_test.go b/callback/functions_test.go new file mode 100644 index 0000000..38e2378 --- /dev/null +++ b/callback/functions_test.go @@ -0,0 +1,117 @@ +package callback + +import ( + "encoding/json" + "testing" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +/* + *********************************************************************************************************************** + * * + * TESTS * + * * + *********************************************************************************************************************** + */ + +func Test_removeActionButtons(t *testing.T) { + + var msg1, msg2 tgbotapi.Message + _ = json.Unmarshal([]byte(`{"reply_markup":{"inline_keyboard":[[{"text":"Reported message","url":"https://t.me/shitpost"}],[{"text":"Report","url":"https://t.me/shitpost"},{"text":"Backup","url":"https://t.me/shitpost"}],[{"text":"Mark as handled","callback_data":"2s ok"}]]}}`), &msg1) + _ = json.Unmarshal([]byte(`{"reply_markup":{"inline_keyboard":[[{"text":"Backup","url":"https://t.me/shitpost"}],[{"text":"Whitelist","callback_data":"2s whitelist 4748 v"},{"text":"Mark as handled","callback_data":"2s ok"}]]}}`), &msg2) + url := "https://t.me/shitpost" + + type args struct { + replyMarkup *tgbotapi.InlineKeyboardMarkup + } + tests := []struct { + name string + args args + want tgbotapi.InlineKeyboardMarkup + }{ + { + name: "@admin report 3 url buttons", + args: args{replyMarkup: msg1.ReplyMarkup}, + want: tgbotapi.InlineKeyboardMarkup{ + InlineKeyboard: [][]tgbotapi.InlineKeyboardButton{ + { + tgbotapi.InlineKeyboardButton{ + Text: "Reported message", + URL: &url, + }, + }, + { + tgbotapi.InlineKeyboardButton{ + Text: "Report", + URL: &url, + }, + tgbotapi.InlineKeyboardButton{ + Text: "Backup", + URL: &url, + }, + }, + }, + }, + }, + { + name: "NSFW deletion 1 url button", + args: args{replyMarkup: msg2.ReplyMarkup}, + want: tgbotapi.InlineKeyboardMarkup{ + InlineKeyboard: [][]tgbotapi.InlineKeyboardButton{ + { + tgbotapi.InlineKeyboardButton{ + Text: "Backup", + URL: &url, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + got := removeActionButtons(tt.args.replyMarkup) + if len(got.InlineKeyboard) != len(tt.want.InlineKeyboard) { + t.Errorf("Different numbers of rows: want %d, got %d", + len(tt.want.InlineKeyboard), len(got.InlineKeyboard)) + return + } + + for rowID, row := range tt.want.InlineKeyboard { + for columnID := range row { + + if got.InlineKeyboard[rowID][columnID].Text != tt.want.InlineKeyboard[rowID][columnID].Text || + *got.InlineKeyboard[rowID][columnID].URL != *tt.want.InlineKeyboard[rowID][columnID].URL { + + t.Errorf("Different items: want: {text: %s, url: %s}, got {text: %s, url: %s}", + tt.want.InlineKeyboard[rowID][columnID].Text, *tt.want.InlineKeyboard[rowID][columnID].URL, + got.InlineKeyboard[rowID][columnID].Text, *got.InlineKeyboard[rowID][columnID].URL) + } + } + } + }) + } +} + +/* + *********************************************************************************************************************** + * * + * BENCHMARKS * + * * + *********************************************************************************************************************** + */ + +func Benchmark_getOriginalMessageText(b *testing.B) { + + var msg tgbotapi.Message + err := json.Unmarshal([]byte(`{"text":"� Alessandro Pomponio banned Telegram Bot Raw.\nFor: test","entities":[{"type":"text_mention","offset":3,"length":19,"url":"","user":{"id":56800135,"first_name":"Alessandro","last_name":"Pomponio","username":"AlessandroPomponio","language_code":"it","is_bot":false}},{"type":"text_mention","offset":30,"length":16,"url":"","user":{"id":211246197,"first_name":"Telegram Bot Raw","last_name":"","username":"RawDataBot","language_code":"","is_bot":true}}]}`), &msg) + if err != nil { + b.Error("unable to parse message", err) + } + + for n := 0; n < b.N; n++ { + getOriginalMessageText(&msg) + } +} diff --git a/callback/media.go b/callback/media.go new file mode 100644 index 0000000..04533c8 --- /dev/null +++ b/callback/media.go @@ -0,0 +1,152 @@ +package callback + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "github.com/shitpostingio/admin-bot/adminbot" + "github.com/shitpostingio/admin-bot/callback/buttons" + "github.com/shitpostingio/admin-bot/database/database" + "github.com/shitpostingio/admin-bot/telegram" +) + +func blacklistPrivateMedia(callbackquery *tgbotapi.CallbackQuery) { + + uniqueID, fileID := telegram.GetFileIDFromMessage(callbackquery.Message.ReplyToMessage) + _, err := database.BlacklistMedia(uniqueID, fileID, callbackquery.From.ID) + if err != nil { + _ = adminbot.SendCallbackWithAlert(callbackquery.ID, "The operation wasn't successful, please retry") + return + } + + newMarkup := buttons.CreateKeyboardWithOneRow(buttons.CreatePrivatePardonMediaButton(), buttons.CreatePrivateWhitelistMediaButton()) + _, _ = adminbot.EditMessageReplyMarkup(callbackquery.Message.MessageID, callbackquery.Message.Chat.ID, &newMarkup) + +} + +func whitelistPrivateMedia(callbackquery *tgbotapi.CallbackQuery) { + + uniqueID, fileID := telegram.GetFileIDFromMessage(callbackquery.Message.ReplyToMessage) + _, err := database.WhitelistMedia(uniqueID, fileID, callbackquery.From.ID) + if err != nil { + _ = adminbot.SendCallbackWithAlert(callbackquery.ID, "The operation wasn't successful, please retry") + return + } + + newMarkup := buttons.CreateKeyboardWithOneRow(buttons.CreatePrivateBlacklistMediaButton()) + _, _ = adminbot.EditMessageReplyMarkup(callbackquery.Message.MessageID, callbackquery.Message.Chat.ID, &newMarkup) + +} + +func pardonPrivateMedia(callbackquery *tgbotapi.CallbackQuery) { + + uniqueID, fileID := telegram.GetFileIDFromMessage(callbackquery.Message.ReplyToMessage) + err := database.RemoveMedia(uniqueID, fileID) + if err != nil { + _ = adminbot.SendCallbackWithAlert(callbackquery.ID, "The operation wasn't successful, please retry") + return + } + + newMarkup := buttons.CreateKeyboardWithOneRow(buttons.CreatePrivateBlacklistMediaButton()) + _, _ = adminbot.EditMessageReplyMarkup(callbackquery.Message.MessageID, callbackquery.Message.Chat.ID, &newMarkup) + +} + +func blacklistPrivateStickerPack(callbackquery *tgbotapi.CallbackQuery) { + + _, err := database.BlacklistStickerPack(callbackquery.Message.ReplyToMessage.Sticker.SetName, callbackquery.From.ID) + if err != nil { + _ = adminbot.SendCallbackWithAlert(callbackquery.ID, "The operation wasn't successful, please retry") + return + } + + var newMarkup tgbotapi.InlineKeyboardMarkup + uniqueFileID, fileID := telegram.GetFileIDFromMessage(callbackquery.Message.ReplyToMessage) + if database.MediaIsBlacklisted(uniqueFileID, fileID) { + newMarkup = buttons.CreateKeyboardWithOneRow(buttons.CreatePrivatePardonStickerButton(), buttons.CreatePrivateWhitelistStickerButton(), buttons.CreatePrivatePardonStickerPackButton()) + } else { + newMarkup = buttons.CreateKeyboardWithOneRow(buttons.CreatePrivateBlacklistStickerButton(), buttons.CreatePrivatePardonStickerPackButton()) + } + + _, _ = adminbot.EditMessageReplyMarkup(callbackquery.Message.MessageID, callbackquery.Message.Chat.ID, &newMarkup) + +} + +func pardonPrivateStickerPack(callbackquery *tgbotapi.CallbackQuery) { + + err := database.PardonStickerPack(callbackquery.Message.ReplyToMessage.Sticker.SetName) + if err != nil { + _ = adminbot.SendCallbackWithAlert(callbackquery.ID, "The operation wasn't successful, please retry") + return + } + + var newMarkup tgbotapi.InlineKeyboardMarkup + uniqueFileID, fileID := telegram.GetFileIDFromMessage(callbackquery.Message.ReplyToMessage) + if database.MediaIsBlacklisted(uniqueFileID, fileID) { + newMarkup = buttons.CreateKeyboardWithOneRow(buttons.CreatePrivatePardonStickerButton(), buttons.CreatePrivateWhitelistStickerButton(), buttons.CreatePrivateBlacklistStickerPackButton()) + } else { + newMarkup = buttons.CreateKeyboardWithOneRow(buttons.CreatePrivateBlacklistStickerButton(), buttons.CreatePrivateBlacklistStickerPackButton()) + } + + _, _ = adminbot.EditMessageReplyMarkup(callbackquery.Message.MessageID, callbackquery.Message.Chat.ID, &newMarkup) + +} + +func blacklistPrivateSticker(callbackquery *tgbotapi.CallbackQuery) { + + uniqueID, fileID := telegram.GetFileIDFromMessage(callbackquery.Message.ReplyToMessage) + _, err := database.BlacklistMedia(uniqueID, fileID, callbackquery.From.ID) + if err != nil { + _ = adminbot.SendCallbackWithAlert(callbackquery.ID, "The operation wasn't successful, please retry") + return + } + + var newMarkup tgbotapi.InlineKeyboardMarkup + if database.StickerPackIsBlacklisted(callbackquery.Message.ReplyToMessage.Sticker.SetName) { + newMarkup = buttons.CreateKeyboardWithOneRow(buttons.CreatePrivatePardonStickerButton(), buttons.CreatePrivateWhitelistStickerButton(), buttons.CreatePrivatePardonStickerPackButton()) + } else { + newMarkup = buttons.CreateKeyboardWithOneRow(buttons.CreatePrivatePardonStickerButton(), buttons.CreatePrivateWhitelistStickerButton(), buttons.CreatePrivateBlacklistStickerPackButton()) + } + + _, _ = adminbot.EditMessageReplyMarkup(callbackquery.Message.MessageID, callbackquery.Message.Chat.ID, &newMarkup) + +} + +func whitelistPrivateSticker(callbackquery *tgbotapi.CallbackQuery) { + + uniqueID, fileID := telegram.GetFileIDFromMessage(callbackquery.Message.ReplyToMessage) + _, err := database.WhitelistMedia(uniqueID, fileID, callbackquery.From.ID) + if err != nil { + _ = adminbot.SendCallbackWithAlert(callbackquery.ID, "The operation wasn't successful, please retry") + return + } + + var newMarkup tgbotapi.InlineKeyboardMarkup + if database.StickerPackIsBlacklisted(callbackquery.Message.ReplyToMessage.Sticker.SetName) { + newMarkup = buttons.CreateKeyboardWithOneRow(buttons.CreatePrivateBlacklistStickerButton(), buttons.CreatePrivatePardonStickerPackButton()) + } else { + newMarkup = buttons.CreateKeyboardWithOneRow(buttons.CreatePrivateBlacklistStickerButton(), buttons.CreatePrivateBlacklistStickerPackButton()) + } + + _, _ = adminbot.EditMessageReplyMarkup(callbackquery.Message.MessageID, callbackquery.Message.Chat.ID, &newMarkup) + +} + +func pardonPrivateSticker(callbackquery *tgbotapi.CallbackQuery) { + + uniqueID, fileID := telegram.GetFileIDFromMessage(callbackquery.Message.ReplyToMessage) + err := database.RemoveMedia(uniqueID, fileID) + if err != nil { + _ = adminbot.SendCallbackWithAlert(callbackquery.ID, "The operation wasn't successful, please retry") + return + } + + var newMarkup tgbotapi.InlineKeyboardMarkup + if database.StickerPackIsBlacklisted(callbackquery.Message.ReplyToMessage.Sticker.SetName) { + newMarkup = buttons.CreateKeyboardWithOneRow(buttons.CreatePrivateBlacklistStickerButton(), buttons.CreatePrivatePardonStickerPackButton()) + } else { + newMarkup = buttons.CreateKeyboardWithOneRow(buttons.CreatePrivateBlacklistStickerButton(), buttons.CreatePrivateBlacklistStickerPackButton()) + } + + _, _ = adminbot.EditMessageReplyMarkup(callbackquery.Message.MessageID, callbackquery.Message.Chat.ID, &newMarkup) + +} diff --git a/callback/sources.go b/callback/sources.go new file mode 100644 index 0000000..ae64146 --- /dev/null +++ b/callback/sources.go @@ -0,0 +1,46 @@ +package callback + +import ( + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/shitpostingio/admin-bot/adminbot" + "github.com/shitpostingio/admin-bot/callback/buttons" + "github.com/shitpostingio/admin-bot/database/database" +) + +func blacklistSource(callbackFields []string, callbackQuery *tgbotapi.CallbackQuery) { + + _, err := database.BlacklistSource(0, callbackFields[1], callbackQuery.From.ID) + if err != nil { + _ = adminbot.SendCallbackWithAlert(callbackQuery.ID, "The operation wasn't successful, please retry") + return + } + + markup := buttons.CreateKeyboardWithOneRow(buttons.CreatePrivatePardonSourceButton(callbackFields[1]), buttons.CreatePrivateWhitelistSourceButton(callbackFields[1])) + _, _ = adminbot.EditMessageReplyMarkup(callbackQuery.Message.MessageID, callbackQuery.Message.Chat.ID, &markup) + +} + +func pardonSource(callbackFields []string, callbackQuery *tgbotapi.CallbackQuery) { + + err := database.RemoveSource(0, callbackFields[1]) + if err != nil { + _ = adminbot.SendCallbackWithAlert(callbackQuery.ID, "The operation wasn't successful, please retry") + return + } + + markup := buttons.CreateKeyboardWithOneRow(buttons.CreatePrivateBlacklistSourceButton(callbackFields[1]), buttons.CreatePrivateWhitelistSourceButton(callbackFields[1])) + _, _ = adminbot.EditMessageReplyMarkup(callbackQuery.Message.MessageID, callbackQuery.Message.Chat.ID, &markup) + +} + +func whitelistSource(callbackFields []string, callbackQuery *tgbotapi.CallbackQuery) { + + _, err := database.WhitelistSource(0, callbackFields[1], callbackQuery.From.ID) + if err != nil { + _ = adminbot.SendCallbackWithAlert(callbackQuery.ID, "The operation wasn't successful, please retry") + return + } + + markup := buttons.CreateKeyboardWithOneRow(buttons.CreatePrivateBlacklistSourceButton(callbackFields[1])) + _, _ = adminbot.EditMessageReplyMarkup(callbackQuery.Message.MessageID, callbackQuery.Message.Chat.ID, &markup) +} diff --git a/commands/ban_commands.go b/commands/ban_commands.go new file mode 100644 index 0000000..9f667df --- /dev/null +++ b/commands/ban_commands.go @@ -0,0 +1,78 @@ +package commands + +import ( + "fmt" + "strconv" + "strings" + "unicode/utf16" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + log "github.com/sirupsen/logrus" + + "github.com/shitpostingio/admin-bot/adminbot" + "github.com/shitpostingio/admin-bot/callback/buttons" + "github.com/shitpostingio/admin-bot/localization" + "github.com/shitpostingio/admin-bot/repository" + "github.com/shitpostingio/admin-bot/telegram" +) + +// banUser bans a user in a supergroup. Telegram command is /ban +func banUser(msg *tgbotapi.Message) { + + adminbot.DeleteMessage(msg.Chat.ID, msg.MessageID) + err := adminbot.BanUser(msg.ReplyToMessage.From, msg.From, msg.CommandArguments(), msg.Chat.ID) + if err != nil { + log.Error("banUser: ", err) + } + +} + +func banByUsername(msg *tgbotapi.Message) { + + adminbot.DeleteMessage(msg.Chat.ID, msg.MessageID) + + mentions := telegram.GetMentions(utf16.Encode([]rune(msg.Text)), telegram.GetMessageEntities(msg)) + if len(mentions) == 0 { + log.Error("banByUsername: attempt to ban by handle with no handle") + return + } + + username := mentions[0] + reason := strings.ReplaceAll(strings.ToLower(msg.CommandArguments()), "@"+username, "") + _, err := adminbot.BanUserByUsername(username, msg.From, reason, msg.Chat.ID) + if err != nil { + log.Error("banByUsername: ", err) + } + +} + +func banByID(msg *tgbotapi.Message) { + + adminbot.DeleteMessage(msg.Chat.ID, msg.MessageID) + + if msg.CommandArguments() == "" { + log.Error("banByID: no id or reason") + return + } + + words := strings.Fields(msg.CommandArguments()) + bannedUserID, err := strconv.ParseInt(words[0], 10, 64) + if err != nil { + log.Error("banByID: couldn't parse user ID", words[0]) + return + } + + reason := strings.ReplaceAll(msg.CommandArguments(), words[0], "") + _, err = adminbot.BanUserByID(bannedUserID, msg.From, reason, msg.Chat.ID) + if err != nil { + log.Error("banByID: ", err) + } + +} + +func reportBan(bannedUser, moderator *tgbotapi.User, reason string, chatID int64) { + reportText := fmt.Sprintf(localization.GetString("user_banned"), moderator.ID, telegram.GetName(moderator), bannedUser.ID, telegram.GetName(bannedUser), reason) + markup := buttons.CreateKeyboardWithOneRow(buttons.CreateUnbanButton(bannedUser.ID)) + _ = adminbot.SendTextMessageWithMarkup(repository.GetTelegramConfiguration().ReportChannelID, reportText, markup, true) +} diff --git a/commands/blacklist_commands.go b/commands/blacklist_commands.go new file mode 100644 index 0000000..480813c --- /dev/null +++ b/commands/blacklist_commands.go @@ -0,0 +1,102 @@ +package commands + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "github.com/shitpostingio/admin-bot/adminbot" + "github.com/shitpostingio/admin-bot/database/database" + "github.com/shitpostingio/admin-bot/repository" + "github.com/shitpostingio/admin-bot/telegram" +) + +// blacklistMedia removes a media and adds it to the blacklist, if not already present +func blacklistMedia(msg *tgbotapi.Message) { + + adminbot.DeleteMultipleMessages(msg.Chat.ID, msg.MessageID, msg.ReplyToMessage.MessageID) + + // TODO: Temporary fix against crashing with animated stickers + if msg.ReplyToMessage.Sticker != nil && msg.ReplyToMessage.Sticker.IsAnimated { + blacklistStickerPack(msg) + return + } + + uniqueID, fileID := telegram.GetFileIDFromMessage(msg.ReplyToMessage) + if fileID != "" { + _, _ = database.BlacklistMedia(uniqueID, fileID, msg.From.ID) + } + +} + +// blacklistStickerPack removes a sticker and adds its sticker pack to the blacklist, if not already present +func blacklistStickerPack(msg *tgbotapi.Message) { + + adminbot.DeleteMultipleMessages(msg.Chat.ID, msg.MessageID, msg.ReplyToMessage.MessageID) + + // Blacklist the the media as well, just to be safe + // TODO: Temporary fix against crashing with animated stickers + //blacklistMedia(msg) + + // A moderator may have used the blacklist sticker + // command on something that is not a sticker. + // We have already blacklisted the media, we can return. + if msg.ReplyToMessage.Sticker == nil { + return + } + + // Some stickers may not have a sticker pack + if msg.ReplyToMessage.Sticker.SetName == "" { + return + } + + if !database.StickerPackIsBlacklisted(msg.ReplyToMessage.Sticker.SetName) { + _, _ = database.BlacklistStickerPack(msg.ReplyToMessage.Sticker.SetName, msg.From.ID) + } + +} + +//blacklistHandle blacklists all handles in a message. +func blacklistHandle(msg *tgbotapi.Message) { + + adminbot.DeleteMessage(msg.Chat.ID, msg.MessageID) + + // The command is restricted to admins only + if !repository.Admins[msg.From.ID] { + return + } + + adminbot.DeleteMessage(msg.ReplyToMessage.Chat.ID, msg.ReplyToMessage.MessageID) + + messageEntities := telegram.GetMessageEntities(msg.ReplyToMessage) + text := telegram.GetMessageText(msg.ReplyToMessage) + handles := telegram.GetAllMentions(text, messageEntities, msg.ReplyMarkup) + + //Try to blacklist handles in the message first. + //If there are none, fallback to blacklisting the + //user from whom the message was forwarded. + if len(handles) != 0 { + + for _, handle := range handles { + + // Don't blacklist handles belonging to moderators + // or those that have been whitelisted. + if !database.IsModeratorUsername(handle) && !database.SourceIsWhitelisted(0, handle) { + _, _ = database.BlacklistSource(0, handle, msg.From.ID) + } + + } + + return + } + + // Check if there's a forward handle to blacklist + if msg.ReplyToMessage.ForwardFrom != nil && msg.ReplyToMessage.ForwardFrom.UserName != "" { + + // Don't blacklist handles belonging to moderators + // or those that have been whitelisted. + forwardHandle := msg.ReplyToMessage.ForwardFrom.UserName + if !database.IsModeratorUsername(forwardHandle) && !database.SourceIsWhitelisted(0, forwardHandle) { + _, _ = database.BlacklistSource(0, forwardHandle, msg.From.ID) + } + + } +} diff --git a/commands/command_handlers.go b/commands/command_handlers.go new file mode 100644 index 0000000..1646e43 --- /dev/null +++ b/commands/command_handlers.go @@ -0,0 +1,22 @@ +package commands + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "github.com/shitpostingio/admin-bot/adminbot" +) + +// kickUser kicks a user in a supergroup. +func kickUser(msg *tgbotapi.Message) { + + /* WE WANT TO BE STEALTHY */ + adminbot.DeleteMessage(msg.Chat.ID, msg.MessageID) + + //We want to re apply the restrictions to the users + // chatMemberRestrictions, userIsRestricted, _ := utility.GetChatMemberRestrictions(msg.ReplyToMessage.From.ID, msg.Chat.ID, admin) + adminbot.KickUser(msg.ReplyToMessage.From.ID, msg.From.ID, msg.Chat.ID) + // if userIsRestricted { + // utility.restrictUser(msg.ReplyToMessage.From, chatMemberRestrictions, &admin.Bot.Self, admin) + // } + +} diff --git a/commands/commands.go b/commands/commands.go new file mode 100644 index 0000000..de38b28 --- /dev/null +++ b/commands/commands.go @@ -0,0 +1,69 @@ +package commands + +import ( + "strings" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "github.com/shitpostingio/admin-bot/adminbot" + "github.com/shitpostingio/admin-bot/utility" +) + +// ExecuteCommand executes commands, if the user is allowed +func ExecuteCommand(msg *tgbotapi.Message) { + + if !utility.IsChatAdminByMessage(msg) { + adminbot.DeleteMessage(msg.Chat.ID, msg.MessageID) + return + } + + command := strings.ToLower(msg.Command()) + if msg.ReplyToMessage == nil { + adminbot.DeleteMessage(msg.Chat.ID, msg.MessageID) + handleCommandsWithoutReply(command, msg) + return + } + + if msg.CommandArguments() == "" { + handleCommandsWithoutArguments(command, msg) + return + } + + handleCommandsWithArguments(command, msg) + +} + +func handleCommandsWithoutReply(command string, msg *tgbotapi.Message) { + + switch { + case command == "banh": + banByUsername(msg) + case command == "idban": + banByID(msg) + } + +} + +func handleCommandsWithArguments(command string, msg *tgbotapi.Message) { + switch { + case command == "ban": + banUser(msg) + case command == "mute", strings.HasPrefix(command, "no"): + restrictUser(msg) + } +} + +func handleCommandsWithoutArguments(command string, msg *tgbotapi.Message) { + switch { + case command == "blsp" || command == "bls": + blacklistStickerPack(msg) + case command == "blm": + blacklistMedia(msg) + case command == "blh": + blacklistHandle(msg) + case command == "kick": + kickUser(msg) + case command == "mute", strings.HasPrefix(command, "no"): + restrictUser(msg) + } +} diff --git a/commands/restriction_commands.go b/commands/restriction_commands.go new file mode 100644 index 0000000..80ed7dc --- /dev/null +++ b/commands/restriction_commands.go @@ -0,0 +1,69 @@ +package commands + +import ( + "strings" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "github.com/shitpostingio/admin-bot/adminbot" + "github.com/shitpostingio/admin-bot/repository" + "github.com/shitpostingio/admin-bot/utility" +) + +//restrictUser restricts a user +func restrictUser(msg *tgbotapi.Message) { + + adminbot.DeleteMessage(msg.Chat.ID, msg.MessageID) + + // Database admins cannot be restricted. + // Moderators can be restricted only by admins. + if repository.Admins[msg.ReplyToMessage.From.ID] { + return + } + + if repository.Mods[msg.ReplyToMessage.From.ID] && !repository.Admins[msg.From.ID] { + return + } + + command := strings.ToLower(msg.Command()) + restrictionEndTime := utility.UnixTimeIn(msg.CommandArguments()) + switch { + case isMute(command): + _ = adminbot.RestrictMessages(msg.ReplyToMessage.From, msg.Chat.ID, restrictionEndTime, msg.From) + case isNoMedia(command): + _ = adminbot.RestrictMedia(msg.ReplyToMessage.From, msg.Chat.ID, restrictionEndTime, msg.From) + case isNoOther(command): + _ = adminbot.RestrictOther(msg.ReplyToMessage.From, msg.Chat.ID, restrictionEndTime, msg.From) + } +} + +func isMute(command string) bool { + + if command == "mute" || + strings.HasPrefix(command, "nomessage") { + return true + } + + return false +} + +func isNoMedia(command string) bool { + + if strings.HasPrefix(command, "nomedia") || + strings.HasPrefix(command, "nopic") { + return true + } + + return false +} + +func isNoOther(command string) bool { + + if strings.HasPrefix(command, "noother") || + strings.HasPrefix(command, "nosticker") || + strings.HasPrefix(command, "nogif") { + return true + } + + return false +} diff --git a/compose_stuff/mongo_init/init.js b/compose_stuff/mongo_init/init.js new file mode 100644 index 0000000..5c9105d --- /dev/null +++ b/compose_stuff/mongo_init/init.js @@ -0,0 +1,13 @@ +db.createUser( + { + user: "automod", + pwd: "automod", + roles: [ + { + role: "readWrite", + db: "automod" + } + ] + + } +) \ No newline at end of file diff --git a/config/checker.go b/config/checker.go new file mode 100644 index 0000000..db2f647 --- /dev/null +++ b/config/checker.go @@ -0,0 +1,151 @@ +package config + +import ( + "fmt" + "log" + "reflect" + "strings" + + "github.com/shitpostingio/admin-bot/config/structs" +) + +// CheckMandatoryFields uses reflection to see if there are +// mandatory fields with zero value +func CheckMandatoryFields(isReload bool, config structs.Config) error { + return checkStruct(isReload, reflect.TypeOf(config), reflect.ValueOf(config)) +} + +// checkWebhookConfig perform checks and sets default values for +// webhook configuration details +func checkWebhookConfig(config *structs.WebhookConfiguration) { + + // To use a reverse proxy, we must bind to localhost. + if config.ReverseProxy { + + config.IP = "127.0.0.1" + if !structs.IsStandardPort(config.ReverseProxyPort) { + log.Fatal("checkWebhookConfig: cannot use non-standard reverse proxy port") + } + + } else { + + if !structs.IsStandardPort(config.Port) { + log.Fatal("checkWebhookConfig: cannot use non-standard port when ReverseProxy is disabled") + } + + } + + if config.Domain == "" { // Domain not set + log.Fatal("Domain not set") + } else if strings.HasPrefix(config.Domain, "http://") || strings.HasPrefix(config.Domain, "https://") { + log.Fatal("Domain must not contain http:// or https://") + } + + if config.TLS { + if config.TLSCertPath == "" { + log.Fatal("missing TLS certificate path") + } else if config.TLSKeyPath == "" { + log.Fatal("missing TLS key path") + } + } +} + +// checkStruct explores structures recursively and checks if +// struct fields have a zero value +func checkStruct(isReload bool, typeToCheck reflect.Type, valueToCheck reflect.Value) (err error) { + + for i := 0; i < typeToCheck.NumField(); i++ { + + currentField := typeToCheck.Field(i) + currentValue := valueToCheck.Field(i) + + if currentField.Type.Kind() == reflect.Struct { + err = checkStruct(isReload, currentField.Type, currentValue) + } else if currentField.Type.Kind() == reflect.Slice { //TODO: capire + err = checkSlice(isReload, currentField, currentValue) + } else { + err = checkField(isReload, currentField, currentValue) + } + + if err != nil { + return + } + } + + return nil +} + +func checkSlice(isReload bool, typeToCheck reflect.StructField, sliceToCheck reflect.Value) error { + + //only check reloadable fields if isReload is true + if isReload { + + reloadableTagValue := typeToCheck.Tag.Get("reloadable") + if reloadableTagValue != "true" { + return nil + } + + } + + typeTagValue := typeToCheck.Tag.Get("type") + if typeTagValue == "optional" { + return nil + } + + if sliceToCheck.Len() == 0 { + return fmt.Errorf("non optional slice field %s had zero length", typeToCheck.Name) + } + + var err error + for i := 0; i < sliceToCheck.Len(); i++ { + + item := sliceToCheck.Index(i) + if item.Kind() == reflect.Struct { + err = checkStruct(isReload, reflect.TypeOf(item), reflect.ValueOf(item)) + } else { + + zeroValue := reflect.Zero(item.Type()) + if item.Interface() == zeroValue.Interface() { + return fmt.Errorf("non optional field %s had zero value at index %d", typeToCheck.Name, i) + } + + } + + if err != nil { + return err + } + + } + + return nil + +} + +// checkField checks if a field is optional or a webhook field +// if it isn't, it checks if the field has a zero value +func checkField(isReload bool, typeToCheck reflect.StructField, valueToCheck reflect.Value) error { + + //only check reloadable fields if isReload is true + if isReload { + + reloadableTagValue := typeToCheck.Tag.Get("reloadable") + if reloadableTagValue != "true" { + return nil + } + + } + + typeTagValue := typeToCheck.Tag.Get("type") + + if typeTagValue == "optional" || typeTagValue == "webhook" { + return nil + } + + zeroValue := reflect.Zero(typeToCheck.Type) + + if valueToCheck.Interface() == zeroValue.Interface() { + return fmt.Errorf("non optional field %s had zero value", typeToCheck.Name) + } + + return nil +} diff --git a/config/loader.go b/config/loader.go new file mode 100644 index 0000000..e5ff369 --- /dev/null +++ b/config/loader.go @@ -0,0 +1,54 @@ +package config + +import ( + "log" + + "github.com/spf13/viper" + + "github.com/shitpostingio/admin-bot/config/structs" +) + +const ( + defaultFileSizeThreshold = 20971520 //20MB + defaultDatabaseAddress = "localhost" + defaultDatabasePort = 3306 + defaultDocumentStoreHosts = "localhost:27017" + defaultSocketPath = "/tmp/log.socket" +) + +// Load reads a configuration file and returns its config instance +func Load(path string, useWebhook bool) (cfg structs.Config, err error) { + + setDefaultValuesForOptionalFields() + + viper.SetConfigFile(path) + err = viper.ReadInConfig() + if err != nil { + return cfg, err + } + + err = viper.Unmarshal(&cfg) + if err != nil { + return cfg, err + } + + err = CheckMandatoryFields(false, cfg) + if err != nil { + log.Fatalf(err.Error()) + } + + if useWebhook { + checkWebhookConfig(&cfg.Webhook) + } + + err = viper.WriteConfig() + return +} + +func setDefaultValuesForOptionalFields() { + viper.SetDefault("fpserver.filesizethreshold", defaultFileSizeThreshold) + viper.SetDefault("log.socketpath", defaultSocketPath) + viper.SetDefault("database.address", defaultDatabaseAddress) + viper.SetDefault("database.port", defaultDatabasePort) + viper.SetDefault("documentstore.hosts", []string{defaultDocumentStoreHosts}) +} diff --git a/config/structs/adminbot.go b/config/structs/adminbot.go new file mode 100644 index 0000000..cc49cc6 --- /dev/null +++ b/config/structs/adminbot.go @@ -0,0 +1,9 @@ +package structs + +// AdminBotConfiguration represents the admin-bot configuration. +type AdminBotConfiguration struct { + Language string + LocalizationPath string + WelcomeText string `reloadable:"true"` + EmergencyText string `reloadable:"true"` +} diff --git a/config/structs/antiflood.go b/config/structs/antiflood.go new file mode 100644 index 0000000..009ab44 --- /dev/null +++ b/config/structs/antiflood.go @@ -0,0 +1,7 @@ +package structs + +// AntiFloodConfiguration represents the antiflood configuration +type AntiFloodConfiguration struct { + Threshold int `reloadable:"true"` + RoutineLifeSpan int `reloadable:"true"` +} diff --git a/config/structs/antinsfw.go b/config/structs/antinsfw.go new file mode 100644 index 0000000..0bde53f --- /dev/null +++ b/config/structs/antinsfw.go @@ -0,0 +1,9 @@ +package structs + +// AntiNSFWConfiguration represents the antinsfw configuration +type AntiNSFWConfiguration struct { + APIKey string + APIEndpoint string + ExplicitThreshold int `reloadable:"true"` + RacyThreshold int `reloadable:"true"` +} diff --git a/config/structs/antispam.go b/config/structs/antispam.go new file mode 100644 index 0000000..0d7a447 --- /dev/null +++ b/config/structs/antispam.go @@ -0,0 +1,9 @@ +package structs + +// AntiSpamConfiguration represents the antispam configuration +type AntiSpamConfiguration struct { + RoutineLifeSpan int `reloadable:"true"` + TextThreshold int `reloadable:"true"` + MediaThreshold int `reloadable:"true"` + OtherThreshold int `reloadable:"true"` +} diff --git a/config/structs/antiuserbot.go b/config/structs/antiuserbot.go new file mode 100644 index 0000000..5b341a1 --- /dev/null +++ b/config/structs/antiuserbot.go @@ -0,0 +1,7 @@ +package structs + +// AntiUserbotConfiguration represents the antiuserbot configuration +type AntiUserbotConfiguration struct { + JoinThreshold int `reloadable:"true"` + RoutineLifespan int `reloadable:"true"` +} diff --git a/config/structs/config.go b/config/structs/config.go new file mode 100644 index 0000000..4826232 --- /dev/null +++ b/config/structs/config.go @@ -0,0 +1,19 @@ +package structs + +// Config represents the bot configuration +type Config struct { + //nolint: maligned + Telegram TelegramConfiguration + Database DatabaseConfiguration + DocumentStore DocumentStoreConfiguration + Loglog LoglogConfiguration + Webhook WebhookConfiguration + AntiNSFW AntiNSFWConfiguration + AntiSpam AntiSpamConfiguration + AntiFlood AntiFloodConfiguration + AntiUserbot AntiUserbotConfiguration + RateLimiter RateLimiterConfiguration + FPServer FPServerConfiguration + Tdlib TdlibConfiguration + AdminBot AdminBotConfiguration +} diff --git a/config/structs/database.go b/config/structs/database.go new file mode 100644 index 0000000..d0f0abd --- /dev/null +++ b/config/structs/database.go @@ -0,0 +1,20 @@ +package structs + +import ( + "fmt" +) + +// DatabaseConfiguration represents a database configuration +type DatabaseConfiguration struct { + DatabaseName string + Username string + Password string + Address string `type:"optional"` + Port int `type:"optional"` +} + +// MariaDBConnectionString returns a connection string for MariaDB +func (c *DatabaseConfiguration) MariaDBConnectionString() (string, string) { + return "mysql", fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4,utf8&parseTime=True", + c.Username, c.Password, c.Address, c.Port, c.DatabaseName) +} diff --git a/config/structs/documentstore.go b/config/structs/documentstore.go new file mode 100644 index 0000000..f6fd642 --- /dev/null +++ b/config/structs/documentstore.go @@ -0,0 +1,46 @@ +package structs + +import ( + "go.mongodb.org/mongo-driver/mongo/options" +) + +// DocumentStoreConfiguration represents MongoDB configuration values. +type DocumentStoreConfiguration struct { + UseAuthentication bool `type:"optional"` + UseReplicaSet bool `type:"optional"` + DatabaseName string + AuthMechanism string `type:"optional"` + Username string `type:"optional"` + Password string `type:"optional"` + AuthSource string `type:"optional"` + ReplicaSetName string `type:"optional"` + Hosts []string `type:"optional"` +} + +// MongoDBConnectionOptions gets the connection options from the DocumentStoreConfiguration +func (c *DocumentStoreConfiguration) MongoDBConnectionOptions() *options.ClientOptions { + + //TODO: CHECK + // + clientOptions := options.Client() + clientOptions.SetHosts(c.Hosts) + + // + if c.UseAuthentication { + clientOptions.SetAuth(options.Credential{ + AuthMechanism: c.AuthMechanism, + AuthSource: c.AuthSource, + Username: c.Username, + Password: c.Password, + PasswordSet: true, + }) + } + + // + if c.UseReplicaSet { + clientOptions.SetReplicaSet(c.ReplicaSetName) + } + + return clientOptions + +} diff --git a/config/structs/fpserver.go b/config/structs/fpserver.go new file mode 100644 index 0000000..791ef76 --- /dev/null +++ b/config/structs/fpserver.go @@ -0,0 +1,17 @@ +package structs + +// FPServerConfiguration represents the fpserver configuration +type FPServerConfiguration struct { + Address string `reloadable:"true"` + AnalysisImageEndpoint string `reloadable:"true"` + AnalysisVideoEndpoint string `reloadable:"true"` + FingerprintImageEndpoint string `reloadable:"true"` + FingerprintVideoEndpoint string `reloadable:"true"` + GibberishEndpoint string `reloadable:"true"` + AuthorizationHeaderName string `reloadable:"true"` + AuthorizationHeaderValue string `reloadable:"true"` + CallerAPIKeyHeaderName string `reloadable:"true"` + GibberishInputHeaderName string `reloadable:"true"` + FilePathHeaderName string `reloadable:"true"` + FileSizeThreshold int `type:"optional" reloadable:"true"` +} diff --git a/config/structs/loglog.go b/config/structs/loglog.go new file mode 100644 index 0000000..14a8a97 --- /dev/null +++ b/config/structs/loglog.go @@ -0,0 +1,7 @@ +package structs + +// LoglogConfiguration represents the loglog configuration +type LoglogConfiguration struct { + SocketPath string `type:"optional"` + ApplicationName string +} diff --git a/config/structs/ratelimiter.go b/config/structs/ratelimiter.go new file mode 100644 index 0000000..d7508df --- /dev/null +++ b/config/structs/ratelimiter.go @@ -0,0 +1,6 @@ +package structs + +// RateLimiterConfiguration represents the rate limiter configuration +type RateLimiterConfiguration struct { + MaxActionsPerSecond int `reloadable:"true"` +} diff --git a/config/structs/tdlib.go b/config/structs/tdlib.go new file mode 100644 index 0000000..4e3cf73 --- /dev/null +++ b/config/structs/tdlib.go @@ -0,0 +1,11 @@ +package structs + +// TdlibConfiguration represents the tdlib configuration +type TdlibConfiguration struct { + LogVerbosityLevel int32 `type:"optional"` + UseTestDc bool `type:"optional"` + DatabaseDirectory string + FilesDirectory string + APIID int64 + APIHash string +} diff --git a/config/structs/telegram.go b/config/structs/telegram.go new file mode 100644 index 0000000..09d99cf --- /dev/null +++ b/config/structs/telegram.go @@ -0,0 +1,16 @@ +package structs + +// TelegramConfiguration represents the Telegram configuration +type TelegramConfiguration struct { + BotToken string + GroupID int64 `reloadable:"true"` + ReportChannelID int64 `reloadable:"true"` + BackupChannelID int64 `reloadable:"true"` + GroupLink string `type:"optional" reloadable:"true"` + BackupChannelLink string `type:"optional" reloadable:"true"` +} + +// WebHookPath returns only the relative path where Telegram will send updates +func (c TelegramConfiguration) WebHookPath() string { + return "/" + c.BotToken + "/updates" +} diff --git a/config/structs/webhook.go b/config/structs/webhook.go new file mode 100644 index 0000000..a1c97bb --- /dev/null +++ b/config/structs/webhook.go @@ -0,0 +1,46 @@ +package structs + +import ( + "fmt" + "strconv" +) + +// WebhookConfiguration represents the webhook configuration +type WebhookConfiguration struct { + Domain string `type:"webhook"` + IP string `type:"webhook"` + Port int `type:"webhook"` + ReverseProxy bool `type:"webhook"` + ReverseProxyPort int `type:"webhook"` + TLS bool `type:"webhook"` + TLSCertPath string `type:"webhook"` + TLSKeyPath string `type:"webhook"` + MaxConnections int `type:"webhook"` +} + +// BindString returns IP+Port, in a suitable syntax for http.ListenAndServe +func (c *WebhookConfiguration) BindString() string { + return c.IP + ":" + strconv.Itoa(c.Port) +} + +// WebHookURL returns the URL to listen on for WebHooks +func (c *WebhookConfiguration) WebHookURL(botToken string) string { + + port := c.Port + if c.ReverseProxy { + port = c.ReverseProxyPort + } + + return fmt.Sprintf("https://%s:%d/%s/updates", c.Domain, port, botToken) +} + +// IsStandardPort returns if the specified port is suitable +// for a webhook connection without reverse proxy +func IsStandardPort(port int) bool { + switch port { + case 443, 80, 88, 8443: + return true + default: + return false + } +} diff --git a/config/watcher.go b/config/watcher.go new file mode 100644 index 0000000..85c0f94 --- /dev/null +++ b/config/watcher.go @@ -0,0 +1,42 @@ +package config + +import ( + "github.com/fsnotify/fsnotify" + log "github.com/sirupsen/logrus" + "github.com/spf13/viper" + + "github.com/shitpostingio/admin-bot/config/structs" +) + +// WatchConfig monitors the configuration for changes. +func WatchConfig(cfg *structs.Config) { + viper.WatchConfig() + viper.OnConfigChange(func(e fsnotify.Event) { + + // we need to make sure the config is valid + // use a temporary one to check it + var tempCfg structs.Config + err := viper.Unmarshal(&tempCfg) + if err != nil { + log.Error("The configuration file was changed but it couldn't be unmarshaled") + return + } + + //Oddly, for each config change, two events seem to be triggered. + //The first one will have an empty configuration, causing an error here. + err = CheckMandatoryFields(true, tempCfg) + if err != nil { + log.Error("The configuration file was changed but there were issues:", err) + return + } + + //The config is correct + err = viper.Unmarshal(&cfg) + if err != nil { + panic("The new configuration couldn't be set") + } + + log.Info("The configuration was updated correctly") + + }) +} diff --git a/consts/consts.go b/consts/consts.go new file mode 100644 index 0000000..57d21d4 --- /dev/null +++ b/consts/consts.go @@ -0,0 +1,6 @@ +package consts + +//nolint +const ( + ReportParseMode = "HTML" +) diff --git a/database/database/bans.go b/database/database/bans.go new file mode 100644 index 0000000..113ca71 --- /dev/null +++ b/database/database/bans.go @@ -0,0 +1,21 @@ +package database + +import ( + "github.com/shitpostingio/admin-bot/database/documentstore" + "github.com/shitpostingio/admin-bot/entities" +) + +// GetBansForTelegramID gets bans from the document store given a user's telegram id +func GetBansForTelegramID(telegramID int64) (bans []entities.Ban, err error) { + return documentstore.GetBansForTelegramID(telegramID, documentstore.BansCollection) +} + +// AddBan adds a ban to the document store +func AddBan(bannedUserID, moderatorUserID int64, reason string) (generatedID string, err error) { + return documentstore.AddBan(bannedUserID, moderatorUserID, reason, documentstore.BansCollection) +} + +// MarkUserAsUnbanned marks a user as unbanned in the document store +func MarkUserAsUnbanned(telegramID int64) error { + return documentstore.MarkUserAsUnbanned(telegramID, documentstore.BansCollection) +} diff --git a/database/database/hostnames.go b/database/database/hostnames.go new file mode 100644 index 0000000..28ad4fd --- /dev/null +++ b/database/database/hostnames.go @@ -0,0 +1,26 @@ +package database + +import ( + "github.com/shitpostingio/admin-bot/database/documentstore" + "github.com/shitpostingio/admin-bot/entities" +) + +// GetHostName gets a hostname from the document store +func GetHostName(url string) (host entities.HostName, err error) { + return documentstore.GetHostName(url, documentstore.HostNameCollection) +} + +// BlacklistHostName blacklists a host name +func BlacklistHostName(url string, isBanworthy, isTelegram bool, adderTelegramID int64) (generatedID, hostname string, err error) { + return documentstore.BlacklistHostName(url, isBanworthy, isTelegram, adderTelegramID, documentstore.HostNameCollection) +} + +// UpdateHostName updates a hostname in the document store +func UpdateHostName(url string, isBanworthy, isTelegram bool, updaterTelegramID int64) error { + return documentstore.UpdateHostName(url, isBanworthy, isTelegram, updaterTelegramID, documentstore.HostNameCollection) +} + +// PardonHostName removes a hostname from the document store +func PardonHostName(url string) error { + return documentstore.PardonHostName(url, documentstore.HostNameCollection) +} diff --git a/database/database/media.go b/database/database/media.go new file mode 100644 index 0000000..f9f8d55 --- /dev/null +++ b/database/database/media.go @@ -0,0 +1,56 @@ +package database + +import ( + "github.com/shitpostingio/admin-bot/database/documentstore" + "github.com/shitpostingio/admin-bot/entities" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// FindMediaByFileID finds a media in the document store given its fileid +func FindMediaByFileID(uniqueFileID, fileID string) (media entities.Media, err error) { + return documentstore.FindMediaByFileID(uniqueFileID, fileID, documentstore.MediaCollection) +} + +func FindMediaByFileUniqueID(fileUniqueID string) (media entities.Media, err error) { + return documentstore.FindMediaByFileUniqueID(fileUniqueID, documentstore.MediaCollection) +} + +// FindMediaByFeatures finds a media in the document store given its features +func FindMediaByFeatures(histogram []float64, pHash string, approximation float64) (media entities.Media, err error) { + return documentstore.FindMediaByFeatures(histogram, pHash, approximation, documentstore.MediaCollection) +} + +// FindMediaByID finds a media in the document store given an ObjectID +func FindMediaByID(ID *primitive.ObjectID) (media entities.Media, err error) { + return documentstore.FindMediaByID(ID, documentstore.MediaCollection) +} + +// BlacklistMedia blacklists a media +func BlacklistMedia(uniqueFileID, fileID string, userID int64) (generatedID string, err error) { + return documentstore.BlacklistMedia(uniqueFileID, fileID, userID, documentstore.MediaCollection) +} + +// WhitelistMedia whitelists a media +func WhitelistMedia(uniqueFileID, fileID string, userID int64) (generatedID string, err error) { + return documentstore.WhitelistMedia(uniqueFileID, fileID, userID, documentstore.MediaCollection) +} + +// RemoveMedia removes a media from the document store +func RemoveMedia(uniqueFileID, fileID string) error { + return documentstore.RemoveMedia(uniqueFileID, fileID, documentstore.MediaCollection) +} + +// BlacklistNSFWMedia blacklists a media for being nsfw +func BlacklistNSFWMedia(uniqueFileID, fileID string, description string, score float64, userID int64) (generatedID string, err error) { + return documentstore.BlacklistNSFWMedia(uniqueFileID, fileID, description, score, userID, documentstore.MediaCollection) +} + +// MediaIsBlacklisted returns true if a media is blacklisted +func MediaIsBlacklisted(uniqueFileID, fileID string) bool { + return documentstore.MediaIsBlacklisted(uniqueFileID, fileID, documentstore.MediaCollection) +} + +// MediaIsWhitelisted returns true if a media is whitelisted +func MediaIsWhitelisted(uniqueFileID, fileID string) bool { + return documentstore.MediaIsWhitelisted(uniqueFileID, fileID, documentstore.MediaCollection) +} diff --git a/database/database/moderators.go b/database/database/moderators.go new file mode 100644 index 0000000..cd6e6ed --- /dev/null +++ b/database/database/moderators.go @@ -0,0 +1,57 @@ +package database + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "github.com/shitpostingio/admin-bot/database/documentstore" + "github.com/shitpostingio/admin-bot/entities" +) + +// GetAllModerators retrieves all the moderators from the database. +func GetAllModerators() (moderators []entities.Moderator, err error) { + return documentstore.GetAllModerators(documentstore.ModeratorsCollection) +} + +// GetModeratorByTelegramID retrieves the moderator with the given telegram id. +func GetModeratorByTelegramID(telegramID int64) (moderator entities.Moderator, err error) { + return documentstore.GetModeratorByTelegramID(telegramID, documentstore.ModeratorsCollection) +} + +// GetModeratorByUsername retrieves the moderator with the given username. +func GetModeratorByUsername(username string) (moderator entities.Moderator, err error) { + return documentstore.GetModeratorByUsername(username, documentstore.ModeratorsCollection) +} + +// AddModerator adds the given user to the moderators. +func AddModerator(user *tgbotapi.ChatMember, moddedByTelegramID int64) (generatedID string, err error) { + return documentstore.AddModerator(user, moddedByTelegramID, documentstore.ModeratorsCollection) +} + +func RemoveModerator(unmoddedUserID int64) (err error) { + return documentstore.RemoveModerator(unmoddedUserID, documentstore.ModeratorsCollection) +} + +// UpdateModeratorsDetails updates the details of the moderators in the database. +func UpdateModeratorsDetails(chatID int64, bot *tgbotapi.BotAPI) error { + return documentstore.UpdateModeratorsDetails(chatID, bot, documentstore.ModeratorsCollection) +} + +// UpdateModeratorDetailsByTelegramUser updates the details of a user in the database. +func UpdateModeratorDetailsByTelegramUser(user *tgbotapi.User, chatID int64, bot *tgbotapi.BotAPI) error { + return documentstore.UpdateModeratorDetailsByTelegramUser(user, chatID, bot, documentstore.ModeratorsCollection) +} + +// UpdateModeratorDetails updates the details of a moderator in the database. +func UpdateModeratorDetails(chatMember *tgbotapi.ChatMember) error { + return documentstore.UpdateModeratorDetails(chatMember, documentstore.ModeratorsCollection) +} + +// IsModeratorUsername returns true if the given username belongs to a moderator. +func IsModeratorUsername(username string) bool { + return documentstore.IsModeratorUsername(username, documentstore.ModeratorsCollection) +} + +// IsModerator returns true if the given telegram id belongs to a moderator. +func IsModerator(telegramID int64) bool { + return documentstore.IsModerator(telegramID, documentstore.ModeratorsCollection) +} diff --git a/database/database/sources.go b/database/database/sources.go new file mode 100644 index 0000000..c90f3db --- /dev/null +++ b/database/database/sources.go @@ -0,0 +1,51 @@ +package database + +import ( + "github.com/shitpostingio/admin-bot/database/documentstore" + "github.com/shitpostingio/admin-bot/entities" +) + +// GetSourceByUsername gets a source from the document store by its username +func GetSourceByUsername(username string) (source entities.Source, err error) { + return documentstore.GetSourceByUsername(username, documentstore.SourcesCollection) +} + +// GetSourceByTelegramID gets a source from the document store by its telegram id +func GetSourceByTelegramID(telegramID int64) (source entities.Source, err error) { + return documentstore.GetSourceByTelegramID(telegramID, documentstore.SourcesCollection) +} + +// GetSource gets a source from the document store +func GetSource(telegramID int64, username string) (source entities.Source, err error) { + return documentstore.GetSource(telegramID, username, documentstore.SourcesCollection) +} + +// BlacklistSource blacklists a source in the document store +func BlacklistSource(sourceTelegramID int64, sourceUsername string, adderTelegramID int64) (generatedID string, err error) { + return documentstore.BlacklistSource(sourceTelegramID, sourceUsername, adderTelegramID, documentstore.SourcesCollection) +} + +// WhitelistSource whitelists a source in the document store +func WhitelistSource(sourceTelegramID int64, sourceUsername string, adderTelegramID int64) (generatedID string, err error) { + return documentstore.WhitelistSource(sourceTelegramID, sourceUsername, adderTelegramID, documentstore.SourcesCollection) +} + +// RemoveSource removes a source from the document store +func RemoveSource(telegramID int64, username string) error { + return documentstore.RemoveSource(telegramID, username, documentstore.SourcesCollection) +} + +// UpdateSource updates a source in the document store +func UpdateSource(telegramID int64, username string) error { + return documentstore.UpdateSource(telegramID, username, documentstore.SourcesCollection) +} + +// SourceIsWhitelisted returns true if the source is whitelisted +func SourceIsWhitelisted(telegramID int64, username string) bool { + return documentstore.SourceIsWhitelisted(telegramID, username, documentstore.SourcesCollection) +} + +// SourceIsBlacklisted returns true if the source is blacklisted +func SourceIsBlacklisted(telegramID int64, username string) bool { + return documentstore.SourceIsBlacklisted(telegramID, username, documentstore.SourcesCollection) +} diff --git a/database/database/stickerpacks.go b/database/database/stickerpacks.go new file mode 100644 index 0000000..8ef353a --- /dev/null +++ b/database/database/stickerpacks.go @@ -0,0 +1,20 @@ +package database + +import ( + "github.com/shitpostingio/admin-bot/database/documentstore" +) + +//BlacklistStickerPack blacklists a sticker pack by its set_name. Also logs the user who added it to the blacklist +func BlacklistStickerPack(setName string, blacklisterTelegramID int64) (generatedID string, err error) { + return documentstore.BlacklistStickerPack(setName, blacklisterTelegramID, documentstore.StickerPackCollection) +} + +//PardonStickerPack removes a sticker pack from the blacklist. Also logs the user who did it +func PardonStickerPack(setName string) error { + return documentstore.PardonStickerPack(setName, documentstore.StickerPackCollection) +} + +// StickerPackIsBlacklisted returns true if a sticker pack is not blacklisted +func StickerPackIsBlacklisted(setName string) bool { + return documentstore.StickerPackIsBlacklisted(setName, documentstore.StickerPackCollection) +} diff --git a/database/documentstore/bans.go b/database/documentstore/bans.go new file mode 100644 index 0000000..d3d1dc6 --- /dev/null +++ b/database/documentstore/bans.go @@ -0,0 +1,138 @@ +package documentstore + +import ( + "context" + "time" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + "golang.org/x/xerrors" + + "github.com/shitpostingio/admin-bot/entities" +) + +/* + *********************************************************************************************************************** + * * + * FIND * + * * + *********************************************************************************************************************** + */ + +// GetBansForTelegramID gets bans for a telegramId from the document store +//TODO: Considerare la possibilità di aggiungere un limit +func GetBansForTelegramID(telegramID int64, collection *mongo.Collection) (bans []entities.Ban, err error) { + + // + if telegramID == 0 { + return bans, xerrors.New("GetBansForTelegramID: telegramID was 0") + } + + // + filter := bson.M{"user": telegramID} + findOptions := options.Find().SetSort(bson.D{{"bandate", -1}}) + findCtx, cancelFindCtx := context.WithTimeout(dsCtx, opDeadline) + defer cancelFindCtx() + + cursor, err := collection.Find(findCtx, filter, findOptions) + if err != nil { + err = xerrors.Errorf("GetBansForTelegramID: unable to find bans for telegramID %d: %s", telegramID, err) + return + } + + decodeCtx, cancelDecodeCtx := context.WithTimeout(dsCtx, opDeadline) + defer cancelDecodeCtx() + err = cursor.All(decodeCtx, &bans) + if err != nil { + err = xerrors.Errorf("GetBansForTelegramID: unable decode bans for telegramID %d: %s", telegramID, err) + } + + return + +} + +/* + *********************************************************************************************************************** + * * + * INSERT * + * * + *********************************************************************************************************************** + */ + +// AddBan adds a ban to the document store +func AddBan(bannedUserID, moderatorUserID int64, reason string, collection *mongo.Collection) (generatedID string, err error) { + + // + if bannedUserID == 0 { + err = xerrors.New("AddBan: bannedUserID was 0") + return + } + + if moderatorUserID == 0 { + err = xerrors.New("AddBan: moderatorUserID was 0") + return + } + + // + ban := entities.Ban{ + User: bannedUserID, + BannedBy: moderatorUserID, + Reason: reason, + BanDate: time.Now(), + } + + // + // TODO: Controllare se context.TODO è appropriato o meno + ctx, cancelCtx := context.WithTimeout(dsCtx, opDeadline) + defer cancelCtx() + + result, err := collection.InsertOne(ctx, ban) + if err != nil { + err = xerrors.Errorf("AddBan: unable to add ban into the document store: %s", err) + return + } + + if objectID, ok := result.InsertedID.(primitive.ObjectID); ok { + generatedID = objectID.Hex() + } + + return generatedID, err + +} + +/* + *********************************************************************************************************************** + * * + * UPDATE * + * * + *********************************************************************************************************************** + */ + +// MarkUserAsUnbanned marks a user as unbanned +func MarkUserAsUnbanned(telegramID int64, collection *mongo.Collection) error { + + // + if telegramID == 0 { + return xerrors.New("MarkUserAsUnbanned: telegramID was 0") + } + + // + filter := bson.M{"user": telegramID, "unbandate": nil} + update := bson.D{{"$set", bson.M{"unbandate": time.Now()}}} + updCtx, cancelUpdCtx := context.WithTimeout(dsCtx, opDeadline) + defer cancelUpdCtx() + + result, err := collection.UpdateMany(updCtx, filter, update) + if err != nil { + return xerrors.Errorf("MarkUserAsUnbanned: unable to perform updates for telegramID %d: %s", telegramID, err) + } + + if result.MatchedCount == 0 { + return xerrors.Errorf("MarkUserAsUnbanned: 0 matches for telegramID %d", telegramID) + } + + return nil + +} diff --git a/database/documentstore/bans_test.go b/database/documentstore/bans_test.go new file mode 100644 index 0000000..e87e69e --- /dev/null +++ b/database/documentstore/bans_test.go @@ -0,0 +1,167 @@ +package documentstore + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/shitpostingio/admin-bot/entities" +) + +func TestAddBan(t *testing.T) { + + type args struct { + bannedUserID int + moderatorUserID int + reason string + } + + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "AddBanWithReason - No Error", + args: args{ + bannedUserID: 1, + moderatorUserID: 2, + reason: "this is a test", + }, + }, + { + name: "AddBanWithoutReason - No Error", + args: args{ + bannedUserID: 1, + moderatorUserID: 2, + reason: "", + }, + }, + { + name: "AddBanBannedUserID 0 - Error", + args: args{ + bannedUserID: 0, + moderatorUserID: 2, + reason: "this is a test", + }, + wantErr: true, + }, + { + name: "AddBanModeratorID 0 - Error", + args: args{ + bannedUserID: 1, + moderatorUserID: 0, + reason: "this is a test", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + _, err := AddBan(tt.args.bannedUserID, tt.args.moderatorUserID, tt.args.reason, testCollection) + if (err != nil) != tt.wantErr { + t.Errorf("AddBan() error = %v, wantErr %v", err, tt.wantErr) + return + } + + }) + } +} + +func TestGetBansForTelegramID(t *testing.T) { + + tests := []struct { + name string + telegramID int + wantBans []entities.Ban + wantErr bool + }{ + { + name: "UserID 1 - Two bans", + telegramID: 1, + wantBans: []entities.Ban{ + { + User: 1, + BannedBy: 2, + Reason: "", + }, + { + User: 1, + BannedBy: 2, + Reason: "this is a test", + }, + }, + }, + { + name: "UserID 2 - No bans", + telegramID: 2, + wantBans: []entities.Ban{}, + }, + { + name: "UserID 0 - Error", + telegramID: 0, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + gotBans, err := GetBansForTelegramID(tt.telegramID, testCollection) + + // error + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + // slice size + assert.Len(t, gotBans, len(tt.wantBans)) + + // ban content + for i, ban := range gotBans { + assert.Equal(t, ban.User, tt.wantBans[i].User) + assert.Equal(t, ban.BannedBy, tt.wantBans[i].BannedBy) + assert.Equal(t, ban.Reason, tt.wantBans[i].Reason) + assert.Equal(t, ban.UnbanDate, tt.wantBans[i].UnbanDate) + } + + }) + } +} + +func TestMarkUserAsUnbanned(t *testing.T) { + + tests := []struct { + name string + telegramID int + wantErr bool + }{ + { + name: "UserID 1 - No error", + telegramID: 1, + }, + { + name: "UserID 2 - No matches", + telegramID: 2, + wantErr: true, + }, + { + name: "UserID 3 - Not found", + telegramID: 3, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := MarkUserAsUnbanned(tt.telegramID, testCollection); (err != nil) != tt.wantErr { + t.Errorf("MarkUserAsUnbanned() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/database/documentstore/documentstore.go b/database/documentstore/documentstore.go new file mode 100644 index 0000000..d7ace03 --- /dev/null +++ b/database/documentstore/documentstore.go @@ -0,0 +1,85 @@ +package documentstore + +import ( + "context" + "time" + + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/readpref" + + "github.com/shitpostingio/admin-bot/config/structs" + "github.com/shitpostingio/admin-bot/utility" +) + +const ( + + /* TIMEOUT */ + opDeadline = 10 * time.Second + + /* COLLECTION NAMES */ + bansCollectionName = "bans" + hostNamesCollectionName = "hostnames" + mediaCollectionName = "media" + moderatorsCollectionName = "moderators" + sourcesCollectionName = "sources" + messagesCollectionName = "messages" + stickerPackCollectionName = "stickerpacks" +) + +var ( + + // + dsCtx context.Context + database *mongo.Database + + // BansCollection represents the bans collection in the document store + BansCollection *mongo.Collection + + // HostNameCollection represents the hostname collection in the document store + HostNameCollection *mongo.Collection + + // MediaCollection represents the media collection in the document store + MediaCollection *mongo.Collection + + // ModeratorsCollection represents the moderators collection in the document store + ModeratorsCollection *mongo.Collection + + // SourcesCollection represents the sources collection in the document store + SourcesCollection *mongo.Collection + + // MessagesCollection represents the messages collection in the document store + MessagesCollection *mongo.Collection + + // StickerPackCollection represents the stickerpack collection in the document store + StickerPackCollection *mongo.Collection +) + +// Connect connects to the document store +func Connect(cfg *structs.DocumentStoreConfiguration) { + + client, err := mongo.Connect(context.Background(), cfg.MongoDBConnectionOptions()) + if err != nil { + utility.LogFatal("Unable to connect to document store:", err) + } + + pingCtx, cancelPingCtx := context.WithTimeout(context.Background(), 1*time.Second) + defer cancelPingCtx() + err = client.Ping(pingCtx, readpref.Primary()) + if err != nil { + utility.LogFatal("Unable to ping document store:", err) + } + + // + dsCtx = context.TODO() + + /* SAVE COLLECTIONS */ + database = client.Database(cfg.DatabaseName) + BansCollection = database.Collection(bansCollectionName) + HostNameCollection = database.Collection(hostNamesCollectionName) + MediaCollection = database.Collection(mediaCollectionName) + ModeratorsCollection = database.Collection(moderatorsCollectionName) + SourcesCollection = database.Collection(sourcesCollectionName) + MessagesCollection = database.Collection(messagesCollectionName) + StickerPackCollection = database.Collection(stickerPackCollectionName) + +} diff --git a/database/documentstore/documentstore_test.go b/database/documentstore/documentstore_test.go new file mode 100644 index 0000000..b17ac2d --- /dev/null +++ b/database/documentstore/documentstore_test.go @@ -0,0 +1,62 @@ +package documentstore + +import ( + "context" + "log" + "os" + "testing" + + log "github.com/sirupsen/logrus" + "go.mongodb.org/mongo-driver/mongo" + + "github.com/shitpostingio/admin-bot/analysisadapter" + "github.com/shitpostingio/admin-bot/api/botapi" + "github.com/shitpostingio/admin-bot/api/cache" + "github.com/shitpostingio/admin-bot/config" + limiter "github.com/shitpostingio/admin-bot/ratelimiter" + "github.com/shitpostingio/admin-bot/repository" +) + +var ( + testCollection *mongo.Collection +) + +func TestMain(m *testing.M) { + + cfg, err := config.Load("../../config.toml", false) + if err != nil { + log.Fatal("Unable to load configuration:", err) + } + + err = loglog.Setup(cfg.Loglog.ApplicationName) + if err != nil { + log.Fatal("Unable to set up loglog:", err) + } + + bot, err := botapi.Authorize(cfg.Telegram.BotToken, false) + if err != nil { + log.Fatal("Unable to connect to the bot apis", err) + } + + //_, err = tdlib.Authorize(cfg.Telegram.BotToken, &cfg.Tdlib) + //if err != nil { + // utility.LogFatal("Unable to log into the bot via Tdlib:", err) + //} + + Connect(&cfg.DocumentStore) + testCollection = database.Collection("a_test_collection") + err = testCollection.Drop(context.Background()) + if err != nil { + log.Fatal("Unable to drop test collection") + } + + repository.SetBot(bot) + repository.SetConfig(&cfg) + cache.CreateActionsCache() + analysisadapter.Start(cfg.Telegram.BotToken, &cfg.FPServer) + limiter.StartRateLimiter(cfg.RateLimiter.MaxActionsPerSecond) + + //TODO: droppare contenuti test collection a fine test + os.Exit(m.Run()) + +} diff --git a/database/documentstore/hostnames.go b/database/documentstore/hostnames.go new file mode 100644 index 0000000..ce7cc5a --- /dev/null +++ b/database/documentstore/hostnames.go @@ -0,0 +1,161 @@ +package documentstore + +import ( + "context" + "time" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + "golang.org/x/xerrors" + + "github.com/shitpostingio/admin-bot/entities" + "github.com/shitpostingio/admin-bot/utility" +) + +// GetHostName gets a hostname from the document store given a url +func GetHostName(url string, collection *mongo.Collection) (host entities.HostName, err error) { + + // + if url == "" { + err = xerrors.New("GetHostName: tried to find empty host") + return + } + + // + hostname, err := utility.GetHostNameFromURL(url) + if err != nil || hostname == "" { + err = xerrors.Errorf("GetHostName: unable to find host for input url %s (error: %s)", url, err) + return + } + + // + ctx, cancelCtx := context.WithTimeout(context.Background(), opDeadline) + defer cancelCtx() + + // + filter := bson.M{"host": hostname} + err = collection.FindOne(ctx, filter, options.FindOne()).Decode(&host) + return + +} + +// BlacklistHostName blacklists a hostname +func BlacklistHostName(url string, isBanworthy, isTelegram bool, adderTelegramID int64, collection *mongo.Collection) (generatedID, hostname string, err error) { + + // + if url == "" { + err = xerrors.New("BlacklistHostName: tried to add empty host") + return + } + + // + hostname, err = utility.GetHostNameFromURL(url) + if err != nil || hostname == "" { + err = xerrors.Errorf("BlacklistHostName: unable to find host for input url %s (error: %s)", url, err) + return + } + + // + host := entities.HostName{ + Host: hostname, + IsBanworthy: isBanworthy, + IsTelegram: isTelegram, + LastEditedBy: adderTelegramID, + LastModified: time.Now(), + } + + // + // TODO: Controllare se context.TODO è appropriato o meno + ctx, cancelCtx := context.WithTimeout(dsCtx, opDeadline) + defer cancelCtx() + + result, err := collection.InsertOne(ctx, host) + if err != nil { + err = xerrors.Errorf("BlacklistHostName: unable to add host into the document store: %s", err) + return + } + + if objectID, ok := result.InsertedID.(primitive.ObjectID); ok { + generatedID = objectID.Hex() + } + + return + +} + +// UpdateHostName updates a hostname +func UpdateHostName(url string, isBanworthy, isTelegram bool, updaterTelegramID int64, collection *mongo.Collection) error { + + // + if url == "" { + return xerrors.New("UpdateHostName: tried to update empty host") + } + + // + hostname, err := utility.GetHostNameFromURL(url) + if err != nil || hostname == "" { + return xerrors.Errorf("UpdateHostName: unable to find host for input url %s (error: %s)", url, err) + } + + // + filter := bson.M{"host": hostname} + update := bson.D{ + { + "$set", bson.M{ + "isbanworthy": isBanworthy, + "istelegram": isTelegram, + "lasteditedby": updaterTelegramID, + "lastmodified": time.Now(), + }, + }, + } + updCtx, cancelUpdCtx := context.WithTimeout(dsCtx, opDeadline) + defer cancelUpdCtx() + + result, err := collection.UpdateOne(updCtx, filter, update) + if err != nil { + return xerrors.Errorf("UpdateHostName: unable to update host %s: %s", hostname, err) + } + + if result.MatchedCount == 0 { + return xerrors.Errorf("UpdateHostName: no match for host %s", hostname) + } + + return nil + +} + +// PardonHostName removes a hostname from the document store +func PardonHostName(url string, collection *mongo.Collection) error { + + // + if url == "" { + return xerrors.New("PardonHostName: tried to remove empty host") + } + + // + hostname, err := utility.GetHostNameFromURL(url) + if err != nil || hostname == "" { + return xerrors.Errorf("PardonHostName: unable to find host for input url %s (error: %s)", url, err) + } + + // + ctx, cancelCtx := context.WithTimeout(context.Background(), opDeadline) + defer cancelCtx() + + // + filter := bson.M{"host": hostname} + result, err := collection.DeleteOne(ctx, filter, options.Delete()) + if err != nil { + return xerrors.Errorf("PardonHostName: error while deleting host %s: %s", hostname, err) + } + + if result.DeletedCount == 0 { + return xerrors.Errorf("PardonHostName: no matches found for host %s", hostname) + } + + return nil + +} diff --git a/database/documentstore/hostnames_test.go b/database/documentstore/hostnames_test.go new file mode 100644 index 0000000..7476c35 --- /dev/null +++ b/database/documentstore/hostnames_test.go @@ -0,0 +1,270 @@ +package documentstore + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/shitpostingio/admin-bot/entities" +) + +func TestAddHostNameToBlacklist(t *testing.T) { + + type args struct { + url string + isBanworthy bool + isTelegram bool + adderTelegramID int + } + + tests := []struct { + name string + args args + wantHost string + wantErr bool + }{ + { + name: "URL https, no banworthy, no telegram, no telegram id", + args: args{ + url: "https://docs.mongodb.com/manual/core/index-case-insensitive/", + isBanworthy: false, + isTelegram: false, + adderTelegramID: 0, + }, + wantHost: "docs.mongodb.com", + }, + { + name: "URL https, yes banworthy, no telegram, yes telegram id", + args: args{ + url: "https://github.com/mongodb/mongo-go-driver/blob/master/examples/documentation_examples/examples.go", + isBanworthy: true, + isTelegram: false, + adderTelegramID: 10, + }, + wantHost: "github.com", + }, + { + name: "URL http, no banworthy, yes telegram, yes telegram id", + args: args{ + url: "http://t.me/shitpost", + isBanworthy: false, + isTelegram: true, + adderTelegramID: 10, + }, + wantHost: "t.me", + }, + { + name: "URL no protocol, no banworthy, yes telegram, yes telegram id", + args: args{ + url: "telegram.dog/shitpost", + isBanworthy: false, + isTelegram: true, + adderTelegramID: 10, + }, + wantHost: "telegram.dog", + }, + { + name: "Empty url", + args: args{ + url: "", + isBanworthy: false, + isTelegram: false, + adderTelegramID: 10, + }, + wantErr: true, + }, + { + name: "Not a url", + args: args{ + url: "ciao", + isBanworthy: false, + isTelegram: false, + adderTelegramID: 10, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + _, gotHost, err := BlacklistHostName(tt.args.url, tt.args.isBanworthy, tt.args.isTelegram, tt.args.adderTelegramID, testCollection) + + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + assert.Equal(t, gotHost, tt.wantHost) + + }) + } +} + +func TestUpdateHostName(t *testing.T) { + + type args struct { + url string + isBanworthy bool + isTelegram bool + updaterTelegramID int + } + + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "docs.mongodb.com - all true no error", + args: args{ + url: "https://docs.mongodb.com/manual/core/index-case-insensitive/", + isBanworthy: true, + isTelegram: true, + updaterTelegramID: 13, + }, + }, + { + name: "github.com - all false no error", + args: args{ + url: "https://github.com/mongodb/mongo-go-driver/blob/master/examples/documentation_examples/examples.go", + isBanworthy: false, + isTelegram: false, + updaterTelegramID: 10, + }, + }, + { + name: "telegram.me - url not found", + args: args{ + url: "http://telegram.me/shitpost", + isBanworthy: false, + isTelegram: true, + updaterTelegramID: 10, + }, + wantErr: true, + }, + { + name: "Empty url", + args: args{ + url: "", + isBanworthy: false, + isTelegram: false, + updaterTelegramID: 10, + }, + wantErr: true, + }, + { + name: "Not a url", + args: args{ + url: "ciao", + isBanworthy: false, + isTelegram: false, + updaterTelegramID: 10, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + err := UpdateHostName(tt.args.url, tt.args.isBanworthy, tt.args.isTelegram, tt.args.updaterTelegramID, testCollection) + + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + }) + } +} + +func TestGetHostName(t *testing.T) { + + tests := []struct { + name string + url string + wantHost entities.HostName + wantErr bool + }{ + { + name: "docs.mongodb.com - no error", + url: "docs.mongodb.org", + wantHost: entities.HostName{ + Host: "docs.mongodb.com", + IsBanworthy: true, + IsTelegram: true, + LastEditedBy: 13, + }, + }, + { + name: "docs.mongodb.com - no match", + url: "mongodb.org", + wantErr: true, + }, + { + name: "empty host", + url: "", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + gotHost, err := GetHostName(tt.url, testCollection) + + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + assert.Equal(t, tt.wantHost.Host, gotHost.Host) + assert.Equal(t, tt.wantHost.IsBanworthy, gotHost.IsBanworthy) + assert.Equal(t, tt.wantHost.IsTelegram, gotHost.IsTelegram) + assert.Equal(t, tt.wantHost.LastEditedBy, gotHost.LastEditedBy) + + }) + } +} + +func TestRemoveHostNameFromBlacklist(t *testing.T) { + + tests := []struct { + name string + url string + wantErr bool + }{ + { + name: "docs.mongodb.com - no error", + url: "docs.mongodb.org", + }, + { + name: "google.com - not found", + url: "google.com", + wantErr: true, + }, + { + name: "empty url", + url: "", + wantErr: true, + }, + } + + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + + err := PardonHostName(tt.url, testCollection) + + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + }) + } +} diff --git a/database/documentstore/media.go b/database/documentstore/media.go new file mode 100644 index 0000000..9da85c2 --- /dev/null +++ b/database/documentstore/media.go @@ -0,0 +1,564 @@ +package documentstore + +import ( + "context" + "fmt" + "math" + "time" + + fpcompare "github.com/shitpostingio/image-fingerprinting/comparer" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + "golang.org/x/xerrors" + + "github.com/shitpostingio/admin-bot/analysisadapter" + "github.com/shitpostingio/admin-bot/entities" +) + +const ( + mediaApproximation = 0.08 //TODO: rendere variabile +) + +// FindMediaByFileID finds a media in the database via its file id +func FindMediaByFileID(uniqueFileID, fileID string, collection *mongo.Collection) (media entities.Media, err error) { + + media, err = FindMediaByFileUniqueID(uniqueFileID, collection) + if err == nil { + return + } + + if fileID == "" { + err = xerrors.New("FindMediaByFileID: empty fileID") + return + } + + // + ctx, cancelCtx := context.WithTimeout(context.Background(), opDeadline) + defer cancelCtx() + + // + filter := bson.M{"fileid": fileID} + err = collection.FindOne(ctx, filter, options.FindOne()).Decode(&media) + if err == nil { //match via fileID + return + } + + // + if !entities.MediaCanBeFingerprinted(fileID) { + err = xerrors.Errorf("FindMediaByFileID no match for fileID %s", fileID) + return + } + + fingerprint, err := analysisadapter.GetFingerprint(uniqueFileID, fileID) + fmt.Println("Fingerprint", fingerprint) + if err != nil { + err = xerrors.Errorf("FindMediaByFileID could not get fingerprint values for fileID %s", fileID) + return + } + + media, err = FindMediaByFeatures(fingerprint.Histogram, fingerprint.PHash, mediaApproximation, collection) + return + +} + +func FindMediaByFileUniqueID(fileUniqueID string, collection *mongo.Collection) (media entities.Media, err error) { + + if fileUniqueID == "" { + err = xerrors.New("FindMediaByFileUniqueID: empty fileID") + return + } + + // + ctx, cancelCtx := context.WithTimeout(context.Background(), opDeadline) + defer cancelCtx() + + // + filter := bson.M{"fileuniqueid": fileUniqueID} + err = collection.FindOne(ctx, filter, options.FindOne()).Decode(&media) + return media, err + +} + +// FindMediaByFeatures finds a media by its features +func FindMediaByFeatures(histogram []float64, pHash string, approximation float64, collection *mongo.Collection) (media entities.Media, err error) { + + // + if histogram == nil { + err = xerrors.New("FindMediaByFeatures: histogram was nil") + return + } + + if pHash == "" { + err = xerrors.New("FindMediaByFeatures: pHash was empty") + return + } + + // + average, sum := entities.GetHistogramAverageAndSum(histogram) + minAvg := math.Trunc(average - 1) + maxAvg := math.Ceil(average + 1) + minSum := math.Trunc(sum - (sum * approximation)) + maxSum := math.Ceil(sum + (sum * approximation)) + + // + filter := bson.D{ + { + Key: "histogramaverage", + Value: bson.D{ + {"$gte", minAvg}, + {"$lte", maxAvg}, + }, + }, + {Key: "histogramsum", + Value: bson.D{ + {"$gte", minSum}, + {"$lte", maxSum}, + }, + }, + } + + // + ctx, cancelCtx := context.WithTimeout(context.Background(), opDeadline) + defer cancelCtx() + + //TODO: ordinare secondo qualcosa i dati + + // + cursor, err := collection.Find(ctx, filter) + if err != nil { + err = xerrors.Errorf("FindMediaByFeatures: unable to retrieve media: %s", err) + return + } + + media, err = findBestMatch(pHash, cursor) + if err != nil { + err = xerrors.Errorf("FindMediaByFeatures: %s", err) + return + } + + return + +} + +// FindMediaByID finds a media given its ObjectID +func FindMediaByID(ID *primitive.ObjectID, collection *mongo.Collection) (media entities.Media, err error) { + + // + if ID == nil { + err = xerrors.New("FindMediaByID: ID was nil") + return + } + + // + ctx, cancelCtx := context.WithTimeout(context.Background(), opDeadline) + defer cancelCtx() + + // + filter := bson.M{"_id": ID} + err = collection.FindOne(ctx, filter).Decode(&media) + return + +} + +/* + *********************************************************************************************************************** + * * + * INSERT * + * * + *********************************************************************************************************************** + */ + +// BlacklistMedia blacklists a media +func BlacklistMedia(uniqueFileID, fileID string, userID int64, collection *mongo.Collection) (generatedID string, err error) { + + // + if fileID == "" && uniqueFileID == "" { + err = xerrors.New("BlacklistMedia: empty fileID and unique id") + return + } + + // + foundMedia, err := FindMediaByFileID(uniqueFileID, fileID, collection) + if err == nil { + + err = markMediaAsBlacklisted(&foundMedia.ID, collection) + if err != nil { + + err = xerrors.Errorf("BlacklistMedia: %s", err) + return + + } + + generatedID = foundMedia.ID.Hex() + return + + } + + // + media := entities.Media{ + FileUniqueID: uniqueFileID, + FileID: fileID, + LastEditedBy: userID, + LastModified: time.Now(), + } + + // + if entities.MediaCanBeFingerprinted(fileID) { + + fingerprint, err := analysisadapter.GetFingerprint(uniqueFileID, fileID) + if err == nil { + + average, sum := entities.GetHistogramAverageAndSum(fingerprint.Histogram) + media.Histogram = fingerprint.Histogram + media.HistogramAverage = average + media.HistogramSum = sum + media.PHash = fingerprint.PHash + + } + + } + + // + ctx, cancelCtx := context.WithTimeout(context.Background(), opDeadline) + defer cancelCtx() + + result, err := collection.InsertOne(ctx, media) + if err != nil { + err = xerrors.Errorf("BlacklistMedia: unable to add media with fileID %s into the document store: %s", fileID, err) + return + } + + if objectID, ok := result.InsertedID.(primitive.ObjectID); ok { + generatedID = objectID.Hex() + } + + return + +} + +// WhitelistMedia whitelists a media +func WhitelistMedia(uniqueFileID, fileID string, userID int64, collection *mongo.Collection) (generatedID string, err error) { + + if fileID == "" { + err = xerrors.New("WhitelistMedia: empty fileID") + return + } + + foundMedia, err := FindMediaByFileID(uniqueFileID, fileID, collection) + if err == nil { + + err = markMediaAsWhitelisted(&foundMedia.ID, collection) + if err != nil { + + err = xerrors.Errorf("WhitelistMedia: %s", err) + return + + } + + generatedID = foundMedia.ID.Hex() + return + + } + + // + media := entities.Media{ + FileUniqueID: uniqueFileID, + FileID: fileID, + IsWhitelisted: true, + LastEditedBy: userID, + LastModified: time.Now(), + } + + // + if entities.MediaCanBeFingerprinted(fileID) { + + fingerprint, err := analysisadapter.GetFingerprint(uniqueFileID, fileID) + if err == nil { + + average, sum := entities.GetHistogramAverageAndSum(fingerprint.Histogram) + media.Histogram = fingerprint.Histogram + media.HistogramAverage = average + media.HistogramSum = sum + media.PHash = fingerprint.PHash + + } + + } + + // + ctx, cancelCtx := context.WithTimeout(context.Background(), opDeadline) + defer cancelCtx() + + result, err := collection.InsertOne(ctx, media) + if err != nil { + err = xerrors.Errorf("WhitelistMedia: unable to add media with fileID %s into the document store: %s", fileID, err) + return + } + + if objectID, ok := result.InsertedID.(primitive.ObjectID); ok { + generatedID = objectID.Hex() + } + + return + +} + +// RemoveMedia removes a media from the document store +func RemoveMedia(uniqueFileID, fileID string, collection *mongo.Collection) error { + + // + if fileID == "" { + return xerrors.New("RemoveMedia: empty file ID") + } + + // Find matching media + foundMedia, err := FindMediaByFileID(uniqueFileID, fileID, collection) + if err != nil { + return xerrors.Errorf("RemoveMedia: no matching media found for fileID %s: %s", fileID, err) + } + + // + ctx, cancelCtx := context.WithTimeout(context.Background(), opDeadline) + defer cancelCtx() + + // + filter := bson.M{"_id": foundMedia.ID} + result, err := collection.DeleteOne(ctx, filter, options.Delete()) + if err != nil { + return xerrors.Errorf("RemoveMedia: error while removing media %s: %s", fileID, err) + } + + if result.DeletedCount == 0 { + return xerrors.Errorf("RemoveMedia: no matches found for media %s", fileID) + } + + return nil +} + +// BlacklistNSFWMedia blacklists a media for being NSFW +func BlacklistNSFWMedia(uniqueFileID, fileID string, description string, score float64, userID int64, collection *mongo.Collection) (generatedID string, err error) { + + // + if fileID == "" { + err = xerrors.New("BlacklistNSFWMedia: empty fileID") + return + } + + // + foundMedia, err := FindMediaByFileID(uniqueFileID, fileID, collection) + if err == nil { + + err = markMediaAsNSFW(&foundMedia.ID, description, score, collection) + if err != nil { + + err = xerrors.Errorf("BlacklistNSFWMedia: %s", err) + return + + } + + generatedID = foundMedia.ID.Hex() + return + + } + + // + media := entities.Media{ + FileUniqueID: uniqueFileID, + FileID: fileID, + NSFWDescription: description, + NSFWScore: score, + LastEditedBy: userID, + LastModified: time.Now(), + } + + // + if entities.MediaCanBeFingerprinted(fileID) { + + fingerprint, err := analysisadapter.GetFingerprint(uniqueFileID, fileID) + if err == nil { + + average, sum := entities.GetHistogramAverageAndSum(fingerprint.Histogram) + media.Histogram = fingerprint.Histogram + media.HistogramAverage = average + media.HistogramSum = sum + media.PHash = fingerprint.PHash + + } + + } + + // + ctx, cancelCtx := context.WithTimeout(context.Background(), opDeadline) + defer cancelCtx() + + result, err := collection.InsertOne(ctx, media) + if err != nil { + err = xerrors.Errorf("BlacklistNSFWMedia: unable to add media with fileID %s into the document store: %s", fileID, err) + return + } + + if objectID, ok := result.InsertedID.(primitive.ObjectID); ok { + generatedID = objectID.Hex() + } + + return + +} + +/* +*********************************************************************************************************************** +* * +* UPDATE * +* * +*********************************************************************************************************************** + */ + +func markMediaAsBlacklisted(ID *primitive.ObjectID, collection *mongo.Collection) error { + + // + filter := bson.M{"_id": ID} + update := bson.D{ + { + "$set", bson.M{ + "iswhitelisted": false, + }, + }, + } + + updCtx, cancelUpdCtx := context.WithTimeout(dsCtx, opDeadline) + defer cancelUpdCtx() + + result, err := collection.UpdateOne(updCtx, filter, update) + if err != nil { + return xerrors.Errorf("markMediaAsBlacklisted: unable to mark media with _id %s as blacklisted: %s", ID.Hex(), err) + } + + if result.MatchedCount == 0 { + return xerrors.Errorf("markMediaAsBlacklisted: no match for media with _id %s", ID.Hex()) + } + + return nil + +} + +func markMediaAsWhitelisted(ID *primitive.ObjectID, collection *mongo.Collection) error { + + // + filter := bson.M{"_id": ID} + update := bson.D{ + { + "$set", bson.M{ + "iswhitelisted": true, + }, + }, + } + + updCtx, cancelUpdCtx := context.WithTimeout(dsCtx, opDeadline) + defer cancelUpdCtx() + + result, err := collection.UpdateOne(updCtx, filter, update) + if err != nil { + return xerrors.Errorf("markMediaAsWhitelisted: unable to mark media with _id %s as whitelisted: %s", ID.Hex(), err) + } + + if result.MatchedCount == 0 { + return xerrors.Errorf("markMediaAsWhitelisted: no match for media with _id %s", ID.Hex()) + } + + return nil + +} + +func markMediaAsNSFW(ID *primitive.ObjectID, description string, score float64, collection *mongo.Collection) error { + + // + filter := bson.M{"_id": ID} + update := bson.D{ + { + "$set", bson.M{ + "iswhitelisted": false, + "nsfwdescription": description, + "nsfwscore": score, + }, + }, + } + + updCtx, cancelUpdCtx := context.WithTimeout(dsCtx, opDeadline) + defer cancelUpdCtx() + + result, err := collection.UpdateOne(updCtx, filter, update) + if err != nil { + return xerrors.Errorf("markMediaAsNSFW: unable to add nsfw data to media with _id %s: %s", ID.Hex(), err) + } + + if result.MatchedCount == 0 { + return xerrors.Errorf("markMediaAsNSFW: no match for media with _id %s", ID.Hex()) + } + + return nil + +} + +/* +*********************************************************************************************************************** +* * +* CHECK * +* * +*********************************************************************************************************************** + */ + +// MediaIsBlacklisted returns true if the media is blacklisted +func MediaIsBlacklisted(uniqueFileID, fileID string, collection *mongo.Collection) bool { + + if fileID == "" { + return false + } + + media, err := FindMediaByFileID(uniqueFileID, fileID, collection) + return err == nil && !media.IsWhitelisted + +} + +// MediaIsWhitelisted returns true if the media is whitelisted +func MediaIsWhitelisted(uniqueFileID, fileID string, collection *mongo.Collection) bool { + + if fileID == "" { + return false + } + + media, err := FindMediaByFileID(uniqueFileID, fileID, collection) + return err == nil && media.IsWhitelisted + +} + +func findBestMatch(referencePHash string, cursor *mongo.Cursor) (media entities.Media, err error) { + + defer func() { + _ = cursor.Close(dsCtx) + }() + + i := 0 + for cursor.Next(context.TODO()) { + + i++ + // Support variable. If we deserialize directly in media, + // since IsWhitelisted is an omitempty field, it won't be + // deserialized in case of it being missing. This way, if + // a document with it set to true has already been retrieved, + // it will always keep being true. + var res entities.Media + err = cursor.Decode(&res) + if err == nil && fpcompare.PhotosAreSimilarEnough(referencePHash, res.PHash) { + media = res + fmt.Println("match in ", i, "iterations. FileID", media.FileID, "whitelist", media.IsWhitelisted, "_id", media.ID) + return + } + + } + + err = xerrors.New("no match found") + return + +} diff --git a/database/documentstore/media_test.go b/database/documentstore/media_test.go new file mode 100644 index 0000000..708f09f --- /dev/null +++ b/database/documentstore/media_test.go @@ -0,0 +1,237 @@ +package documentstore + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/bson/primitive" + + "github.com/shitpostingio/admin-bot/entities" +) + +func TestBlacklistMedia(t *testing.T) { + + type args struct { + fileID string + userID int + } + + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "GIF - No error", + args: args{ + fileID: "CgADBAAD6gEAAop-_VJFQHZLB74cdBYE", + userID: 11, + }, + }, + { + name: "photo - no error", + args: args{ + fileID: "AgADBAAD7rYxG3vG4VMRKAh8HpBPUh0mthsABAEAAwIAA20AA6KhAAIWBA", + userID: 11, + }, + }, + { + name: "video - no error", + args: args{ + fileID: "BAADAQADjQADBSjQR5EcvphDJJnwFgQ", + userID: 11, + }, + }, + { + name: "document - no error", + args: args{ + fileID: "BQADBAADtQYAAnvG4VNbb4oMumoR4xYE", + userID: 11, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := BlacklistMedia(tt.args.fileID, tt.args.userID, testCollection) + if (err != nil) != tt.wantErr { + t.Errorf("BlacklistMedia() error = %v, wantErr %v", err, tt.wantErr) + return + } + }) + } +} + +func TestFindMediaByFileID(t *testing.T) { + + tests := []struct { + name string + fileID string + wantMedia entities.Media + wantErr bool + }{ + { + name: "GIF - No error", + fileID: "CgADBAAD6gEAAop-_VJFQHZLB74cdBYE", + wantMedia: entities.Media{ + FileID: "CgADBAAD6gEAAop-_VJFQHZLB74cdBYE", + Histogram: []float64{1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 95, 1, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0}, + HistogramAverage: 37.25, + HistogramSum: 1192, + PHash: "p:c3320afcd36f3c81", + }, + }, + { + name: "File not found - Error, no fileID match", + fileID: "aCgADBAAD6gEAAop", + wantErr: true, + }, + { + name: "File not found - Error, unable to get fingerprint", + fileID: "CgADBAAD6gEAAop", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotMedia, err := FindMediaByFileID(tt.fileID, testCollection) + + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + assert.Equal(t, tt.wantMedia.FileID, gotMedia.FileID) + assert.Equal(t, tt.wantMedia.Histogram, gotMedia.Histogram) + assert.Equal(t, tt.wantMedia.HistogramAverage, gotMedia.HistogramAverage) + assert.Equal(t, tt.wantMedia.HistogramSum, gotMedia.HistogramSum) + assert.Equal(t, tt.wantMedia.PHash, gotMedia.PHash) + + }) + } +} + +func TestFindMediaByFeatures(t *testing.T) { + + type args struct { + histogram []float64 + pHash string + approximation float64 + } + + tests := []struct { + name string + args args + wantMedia entities.Media + wantErr bool + }{ + { + name: "GIF - No error", + args: args{ + histogram: []float64{1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 95, 1, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0}, + pHash: "p:c3320afcd36f3c81", + approximation: 0.04, + }, + wantMedia: entities.Media{ + FileID: "CgADBAAD6gEAAop-_VJFQHZLB74cdBYE", + Histogram: []float64{1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 95, 1, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0}, + HistogramAverage: 37.25, + HistogramSum: 1192, + PHash: "p:c3320afcd36f3c81", + }, + }, + { + name: "No match - histogram nil", + args: args{ + pHash: "p:c3320afcd36f3c81", + approximation: 0.04, + }, + wantErr: true, + }, + { + name: "No match - no phash", + args: args{ + histogram: []float64{1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 95, 1, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0}, + approximation: 0.04, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotMedia, err := FindMediaByFeatures(tt.args.histogram, tt.args.pHash, tt.args.approximation, testCollection) + + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + assert.Equal(t, tt.wantMedia.FileID, gotMedia.FileID) + assert.Equal(t, tt.wantMedia.Histogram, gotMedia.Histogram) + assert.Equal(t, tt.wantMedia.HistogramAverage, gotMedia.HistogramAverage) + assert.Equal(t, tt.wantMedia.HistogramSum, gotMedia.HistogramSum) + assert.Equal(t, tt.wantMedia.PHash, gotMedia.PHash) + + }) + } +} + +func TestFindMediaByID(t *testing.T) { + + tests := []struct { + name string + fileID string + wantMedia entities.Media + wantErr bool + }{ + { + name: "GIF - No error", + fileID: "CgADBAAD6gEAAop-_VJFQHZLB74cdBYE", + wantMedia: entities.Media{ + FileID: "CgADBAAD6gEAAop-_VJFQHZLB74cdBYE", + Histogram: []float64{1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 95, 1, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0}, + HistogramAverage: 37.25, + HistogramSum: 1192, + PHash: "p:c3320afcd36f3c81", + }, + }, + { + name: "No fileID - Error", + fileID: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + ID := &primitive.ObjectID{} + ID = nil + + if tt.fileID != "" { + wantMedia, err := FindMediaByFileID(tt.fileID, testCollection) + require.NoError(t, err) + ID = &wantMedia.ID + } + + gotMedia, err := FindMediaByID(ID, testCollection) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + assert.Equal(t, tt.wantMedia.FileID, gotMedia.FileID) + assert.Equal(t, tt.wantMedia.Histogram, gotMedia.Histogram) + assert.Equal(t, tt.wantMedia.HistogramAverage, gotMedia.HistogramAverage) + assert.Equal(t, tt.wantMedia.HistogramSum, gotMedia.HistogramSum) + assert.Equal(t, tt.wantMedia.PHash, gotMedia.PHash) + + }) + } +} diff --git a/database/documentstore/messages.go b/database/documentstore/messages.go new file mode 100644 index 0000000..ccc4587 --- /dev/null +++ b/database/documentstore/messages.go @@ -0,0 +1,43 @@ +package documentstore + +import ( + "context" + "fmt" + "time" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + log "github.com/sirupsen/logrus" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +//StoreMessage serializes and saves a message in the database +func StoreMessage(message *tgbotapi.Message) { + + ctx, cancelCtx := context.WithTimeout(context.Background(), opDeadline) + defer cancelCtx() + + _, err := MessagesCollection.InsertOne(ctx, message) + if err != nil { + log.Error(fmt.Sprintf("Error while inserting the update into the document store: %s", err)) + return + } + +} + +//GetUserJoinDate returns the join timestamp of a given user, returns time.Time{} if the join isn't in the database +func GetUserJoinDate(user *tgbotapi.User) time.Time { + + filter := bson.M{"newchatmembers.id": user.ID} + findOneOptions := options.FindOne().SetSort(bson.D{{"_id", -1}}) + ctx, cancelCtx := context.WithTimeout(context.Background(), opDeadline) + defer cancelCtx() + + var msg tgbotapi.Message + err := MessagesCollection.FindOne(ctx, filter, findOneOptions).Decode(&msg) + if err != nil { + return time.Time{} + } + + return time.Unix(int64(msg.Date), 0) +} diff --git a/database/documentstore/moderators.go b/database/documentstore/moderators.go new file mode 100644 index 0000000..8eaa438 --- /dev/null +++ b/database/documentstore/moderators.go @@ -0,0 +1,289 @@ +package documentstore + +import ( + "context" + "time" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/zelenin/go-tdlib/client" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + "golang.org/x/xerrors" + + "github.com/shitpostingio/admin-bot/api/tdlib" + "github.com/shitpostingio/admin-bot/entities" +) + +/* + *********************************************************************************************************************** + * * + * SELECT * + * * + *********************************************************************************************************************** + */ + +// GetAllModerators retrieves all the moderators from the database. +func GetAllModerators(collection *mongo.Collection) (moderators []entities.Moderator, err error) { + + // + findCtx, cancelFindCtx := context.WithTimeout(dsCtx, opDeadline) + defer cancelFindCtx() + + cursor, err := collection.Find(findCtx, bson.D{}) + if err != nil { + err = xerrors.Errorf("GetAllModerators: unable to find moderators: %s", err) + return + } + + decodeCtx, cancelDecodeCtx := context.WithTimeout(dsCtx, opDeadline) + defer cancelDecodeCtx() + err = cursor.All(decodeCtx, &moderators) + if err != nil { + err = xerrors.Errorf("GetAllModerators: unable decode moderators: %s", err) + } + + return + +} + +// GetModeratorByTelegramID retrieves the moderator with the given telegram id. +func GetModeratorByTelegramID(telegramID int64, collection *mongo.Collection) (moderator entities.Moderator, err error) { + + if telegramID == 0 { + err = xerrors.New("GetModeratorByTelegramID: telegramID 0") + return + } + + // + ctx, cancelCtx := context.WithTimeout(context.Background(), opDeadline) + defer cancelCtx() + + // + filter := bson.M{"telegramid": telegramID} + err = collection.FindOne(ctx, filter, options.FindOne()).Decode(&moderator) + return + +} + +// GetModeratorByUsername retrieves the moderator with the given username. +func GetModeratorByUsername(username string, collection *mongo.Collection) (moderator entities.Moderator, err error) { + + if username == "" { + err = xerrors.New("GetModeratorByUsername: empty username") + return + } + + // + //TODO: aggiungere cache + chat, err := tdlib.ResolveUsername(username) + if err != nil { + return + } + + if chat.Type.ChatTypeType() != client.TypeChatTypePrivate { + err = xerrors.Errorf("GetModeratorByUsername: the username %s does not belong to a person", username) + return + } + + return GetModeratorByTelegramID(chat.ID, collection) + +} + +/* + *********************************************************************************************************************** + * * + * INSERT * + * * + *********************************************************************************************************************** + */ + +// AddModerator adds the given user to the moderators. +func AddModerator(user *tgbotapi.ChatMember, moddedByTelegramID int64, collection *mongo.Collection) (generatedID string, err error) { + + if user == nil { + err = xerrors.New("AddModerator: user nil") + return + } + + moderator := entities.Moderator{ + TelegramID: user.User.ID, + CanChangeInfo: user.CanChangeInfo, + CanDeleteMessages: user.CanDeleteMessages, + CanInviteUsers: user.CanInviteUsers, + CanRestrictMembers: user.CanRestrictMembers, + CanPinMessages: user.CanPinMessages, + CanPromoteMembers: user.CanPromoteMembers, + PromotedBy: moddedByTelegramID, + PromotionDate: time.Now(), + } + + // Telegram will return all false values for the creator + if user.IsCreator() { + moderator.CanChangeInfo = true + moderator.CanDeleteMessages = true + moderator.CanInviteUsers = true + moderator.CanRestrictMembers = true + moderator.CanPinMessages = true + moderator.CanPromoteMembers = true + moderator.IsAdmin = true + } + + // + ctx, cancelCtx := context.WithTimeout(context.Background(), opDeadline) + defer cancelCtx() + + result, err := collection.InsertOne(ctx, moderator) + if err != nil { + err = xerrors.Errorf("AddModerator: unable to add moderator into the document store: %s", err) + return + } + + if objectID, ok := result.InsertedID.(primitive.ObjectID); ok { + generatedID = objectID.Hex() + } + + return generatedID, err + +} + +func RemoveModerator(unmoddedUserID int64, collection *mongo.Collection) (err error) { + + if unmoddedUserID == 0 { + err = xerrors.New("RemoveModerator: unmoddedUserID 0") + return + } + + // + ctx, cancelCtx := context.WithTimeout(context.Background(), opDeadline) + defer cancelCtx() + + filter := bson.M{"telegramid": unmoddedUserID} + _, err = collection.DeleteOne(ctx, filter) + if err != nil { + err = xerrors.Errorf("AddModerator: unable to add moderator into the document store: %s", err) + } + + return + +} + +/* + *********************************************************************************************************************** + * * + * UPDATE * + * * + *********************************************************************************************************************** + */ + +// UpdateModeratorsDetails updates the details of the moderators in the database. +func UpdateModeratorsDetails(chatID int64, bot *tgbotapi.BotAPI, collection *mongo.Collection) error { + + chatAdministratorsConfig := tgbotapi.ChatAdministratorsConfig{ + ChatConfig: tgbotapi.ChatConfig{ChatID: chatID}, + } + + chatAdministrators, err := bot.GetChatAdministrators(chatAdministratorsConfig) + if err != nil { + return xerrors.Errorf("UpdateModeratorsDetails: GetChatAdministrators: %s", err) + } + + for _, chatAdministrator := range chatAdministrators { + + if IsModerator(chatAdministrator.User.ID, collection) { + + err = UpdateModeratorDetails(&chatAdministrator, collection) + if err != nil { + return xerrors.Errorf("UpdateModeratorsDetails: %s", err) + } + + continue + + } + + _, err = AddModerator(&chatAdministrator, 0, collection) + if err != nil { + return xerrors.Errorf("UpdateModeratorsDetails: %s", err) + } + } + + return nil + +} + +// UpdateModeratorDetailsByTelegramUser updates the details of a user in the database. +func UpdateModeratorDetailsByTelegramUser(user *tgbotapi.User, chatID int64, bot *tgbotapi.BotAPI, collection *mongo.Collection) error { + + chatMember, err := bot.GetChatMember(tgbotapi.GetChatMemberConfig{ + ChatConfigWithUser: tgbotapi.ChatConfigWithUser{ + ChatID: chatID, + UserID: user.ID, + }, + }) + + if err != nil { + return xerrors.Errorf("UpdateModeratorDetailsByTelegramUser: %s", err) + } + + return UpdateModeratorDetails(&chatMember, collection) + +} + +// UpdateModeratorDetails updates the details of a moderator in the database. +func UpdateModeratorDetails(chatMember *tgbotapi.ChatMember, collection *mongo.Collection) error { + + if chatMember.IsCreator() { + return nil + } + + // + filter := bson.M{"telegramid": chatMember.User.ID} + update := bson.D{ + { + "$set", bson.M{ + "canchangeinfo": chatMember.CanChangeInfo, + "candeletemessages": chatMember.CanDeleteMessages, + "caninviteusers": chatMember.CanInviteUsers, + "canrestrictmembers": chatMember.CanRestrictMembers, + "canpinmessages": chatMember.CanPinMessages, + "canpromotemembers": chatMember.CanPromoteMembers, + }, + }, + } + updCtx, cancelUpdCtx := context.WithTimeout(dsCtx, opDeadline) + defer cancelUpdCtx() + + result, err := collection.UpdateOne(updCtx, filter, update) + if err != nil { + return xerrors.Errorf("UpdateModeratorDetails: unable to update moderator with id %d: %s", chatMember.User.ID, err) + } + + if result.MatchedCount == 0 { + return xerrors.Errorf("UpdateModeratorDetails: no match for moderator with id %d", chatMember.User.ID) + } + + return nil + +} + +/* + *********************************************************************************************************************** + * * + * CHECKS * + * * + *********************************************************************************************************************** + */ + +// IsModeratorUsername returns true if the given username +// belongs to a moderator. +func IsModeratorUsername(username string, collection *mongo.Collection) bool { + _, err := GetModeratorByUsername(username, collection) + return err == nil +} + +// IsModerator returns true if the given telegram id belongs to a moderator. +func IsModerator(telegramID int64, collection *mongo.Collection) bool { + _, err := GetModeratorByTelegramID(telegramID, collection) + return err == nil +} diff --git a/database/documentstore/sources.go b/database/documentstore/sources.go new file mode 100644 index 0000000..32dabb1 --- /dev/null +++ b/database/documentstore/sources.go @@ -0,0 +1,365 @@ +package documentstore + +import ( + "context" + "fmt" + "strings" + "time" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + "golang.org/x/xerrors" + + "github.com/shitpostingio/admin-bot/entities" +) + +/* + *********************************************************************************************************************** + * * + * SELECT * + * * + *********************************************************************************************************************** + */ + +// GetSourceByUsername gets a source from its username +func GetSourceByUsername(username string, collection *mongo.Collection) (source entities.Source, err error) { + + if username == "" { + err = xerrors.New("GetSourceByUsername: empty username") + return + } + + // + ctx, cancelCtx := context.WithTimeout(context.Background(), opDeadline) + defer cancelCtx() + + // + filter := bson.M{"username": username} + err = collection.FindOne(ctx, filter, options.FindOne()).Decode(&source) + return + +} + +// GetSourceByTelegramID gets a source from its telegram id +func GetSourceByTelegramID(telegramID int64, collection *mongo.Collection) (source entities.Source, err error) { + + if telegramID == 0 { + err = xerrors.New("GetSourceByTelegramID: telegramID 0") + return + } + + // + ctx, cancelCtx := context.WithTimeout(context.Background(), opDeadline) + defer cancelCtx() + + // + filter := bson.M{"telegramid": telegramID} + err = collection.FindOne(ctx, filter, options.FindOne()).Decode(&source) + return + +} + +// GetSource gets a source from the document store +func GetSource(telegramID int64, username string, collection *mongo.Collection) (source entities.Source, err error) { + + if telegramID != 0 { + source, err = GetSourceByTelegramID(telegramID, collection) + if err == nil { + return + } + } + + if username != "" { + source, err = GetSourceByUsername(username, collection) + if err == nil { + return + } + } + + return source, fmt.Errorf("GetSource: no source found for telegram ID %d or username %s", telegramID, username) +} + +/* + *********************************************************************************************************************** + * * + * INSERT * + * * + *********************************************************************************************************************** + */ + +// BlacklistSource blacklists a source +func BlacklistSource(sourceTelegramID int64, sourceUsername string, adderTelegramID int64, collection *mongo.Collection) (generatedID string, err error) { + + if sourceTelegramID == 0 && sourceUsername == "" { + err = xerrors.New("BlacklistSource: telegram id 0 and empty sourceUsername") + return + } + + source, err := GetSource(sourceTelegramID, sourceUsername, collection) + if err == nil { + + generatedID = source.ID.Hex() + + if !source.IsWhitelisted { + err = updateSourceByID(&source.ID, sourceTelegramID, sourceUsername, collection) + return + } + + err = markSourceAsBlacklisted(&source.ID, collection) + if err != nil { + err = xerrors.Errorf("BlacklistSource: %s", err) + } + + return + + } + + source = entities.Source{ + AddedBy: adderTelegramID, + LastModified: time.Now(), + } + + if sourceTelegramID != 0 { + source.TelegramID = sourceTelegramID + } + + if sourceUsername != "" { + source.Username = strings.ToLower(sourceUsername) + } + + // + ctx, cancelCtx := context.WithTimeout(context.Background(), opDeadline) + defer cancelCtx() + + result, err := collection.InsertOne(ctx, source) + if err != nil { + err = xerrors.Errorf("BlacklistSource: unable to add source into the document store: %s", err) + return + } + + if objectID, ok := result.InsertedID.(primitive.ObjectID); ok { + generatedID = objectID.Hex() + } + + return generatedID, err + +} + +// WhitelistSource whitelist a source +func WhitelistSource(sourceTelegramID int64, sourceUsername string, adderTelegramID int64, collection *mongo.Collection) (generatedID string, err error) { + + if sourceTelegramID == 0 && sourceUsername == "" { + err = xerrors.New("BlacklistSource: telegram id 0 and empty sourceUsername") + return + } + + source, err := GetSource(sourceTelegramID, sourceUsername, collection) + if err == nil { + + generatedID = source.ID.Hex() + + if source.IsWhitelisted { + err = updateSourceByID(&source.ID, sourceTelegramID, sourceUsername, collection) + return + } + + err = markSourceAsWhitelisted(&source.ID, collection) + if err != nil { + err = xerrors.Errorf("WhitelistSource: %s", err) + } + + return + + } + + source = entities.Source{ + AddedBy: adderTelegramID, + LastModified: time.Now(), + IsWhitelisted: true, + } + + if sourceTelegramID != 0 { + source.TelegramID = sourceTelegramID + } + + if sourceUsername != "" { + source.Username = strings.ToLower(sourceUsername) + } + + // + ctx, cancelCtx := context.WithTimeout(context.Background(), opDeadline) + defer cancelCtx() + + result, err := collection.InsertOne(ctx, source) + if err != nil { + err = xerrors.Errorf("BlacklistSource: unable to add source into the document store: %s", err) + return + } + + if objectID, ok := result.InsertedID.(primitive.ObjectID); ok { + generatedID = objectID.Hex() + } + + return generatedID, err + +} + +// RemoveSource removes a source from the document store +func RemoveSource(telegramID int64, username string, collection *mongo.Collection) error { + + if telegramID == 0 && username == "" { + return xerrors.New("RemoveSource: telegram id 0 and empty username") + } + + source, err := GetSource(telegramID, username, collection) + if err != nil { + return xerrors.Errorf("RemoveSource: source (%d, %s) is not in the database", telegramID, username) + } + + // + ctx, cancelCtx := context.WithTimeout(context.Background(), opDeadline) + defer cancelCtx() + + // + filter := bson.M{"_id": source.ID} + _, err = collection.DeleteOne(ctx, filter, options.Delete()) + if err != nil { + return xerrors.Errorf("RemoveSource: error while deleting source with id %s: %s", source.ID.Hex(), err) + } + + return nil + +} + +/* + *********************************************************************************************************************** + * * + * UPDATES * + * * + *********************************************************************************************************************** + */ + +// UpdateSource updates the source +func UpdateSource(telegramID int64, username string, collection *mongo.Collection) error { + + source, err := GetSource(telegramID, username, collection) + if err != nil { + return xerrors.Errorf("UpdateSource: %s", err) + } + + return updateSourceByID(&source.ID, telegramID, username, collection) + +} + +func updateSourceByID(ID *primitive.ObjectID, telegramID int64, username string, collection *mongo.Collection) error { + + if ID == nil { + return xerrors.New("updateSourceByID: ID nil") + } + + // + filter := bson.M{"_id": ID} + updateMap := bson.M{"lastModified": time.Now()} + + if telegramID != 0 { + updateMap["telegramid"] = telegramID + } + + if username != "" { + updateMap["username"] = strings.ToLower(username) + } + + // + update := bson.D{ + { + "$set", updateMap, + }, + } + + updCtx, cancelUpdCtx := context.WithTimeout(dsCtx, opDeadline) + defer cancelUpdCtx() + + _, err := collection.UpdateOne(updCtx, filter, update) + if err != nil { + return xerrors.Errorf("updateSourceByID: unable to update source with id %s: %s", ID, err) + } + + return nil + +} + +func markSourceAsBlacklisted(ID *primitive.ObjectID, collection *mongo.Collection) error { + + if ID == nil { + return xerrors.New("markSourceAsBlacklisted: ID nil") + } + + // + filter := bson.M{"_id": ID} + update := bson.D{ + { + "$set", bson.M{ + "iswhitelisted": false, + }, + }, + } + updCtx, cancelUpdCtx := context.WithTimeout(dsCtx, opDeadline) + defer cancelUpdCtx() + + _, err := collection.UpdateOne(updCtx, filter, update) + if err != nil { + return xerrors.Errorf("markSourceAsBlacklisted: unable to mark source with id %s as blacklisted: %s", ID, err) + } + + return nil + +} + +func markSourceAsWhitelisted(ID *primitive.ObjectID, collection *mongo.Collection) error { + + if ID == nil { + return xerrors.New("markSourceAsWhitelisted: ID nil") + } + + // + filter := bson.M{"_id": ID} + update := bson.D{ + { + "$set", bson.M{ + "iswhitelisted": true, + }, + }, + } + updCtx, cancelUpdCtx := context.WithTimeout(dsCtx, opDeadline) + defer cancelUpdCtx() + + _, err := collection.UpdateOne(updCtx, filter, update) + if err != nil { + return xerrors.Errorf("markSourceAsWhitelisted: unable to mark source with id %s as whitelisted: %s", ID, err) + } + + return nil + +} + +/* + *********************************************************************************************************************** + * * + * CHECKS * + * * + *********************************************************************************************************************** + */ + +// SourceIsWhitelisted returns true if the source is whitelisted +func SourceIsWhitelisted(telegramID int64, username string, collection *mongo.Collection) bool { + source, err := GetSource(telegramID, username, collection) + return err == nil && source.IsWhitelisted +} + +// SourceIsBlacklisted returns true if the source is blacklisted +func SourceIsBlacklisted(telegramID int64, username string, collection *mongo.Collection) bool { + source, err := GetSource(telegramID, username, collection) + return err == nil && !source.IsWhitelisted +} diff --git a/database/documentstore/stickerpacks.go b/database/documentstore/stickerpacks.go new file mode 100644 index 0000000..2e41900 --- /dev/null +++ b/database/documentstore/stickerpacks.go @@ -0,0 +1,87 @@ +package documentstore + +import ( + "context" + "time" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + "golang.org/x/xerrors" + + "github.com/shitpostingio/admin-bot/entities" +) + +//BlacklistStickerPack blacklists a sticker pack by its set_name. Also logs the user who added it to the blacklist +func BlacklistStickerPack(setName string, blacklisterTelegramID int64, collection *mongo.Collection) (generatedID string, err error) { + + if setName == "" { + err = xerrors.New("BlacklistStickerPack: attempt to blacklist sticker pack with an empty setname") + return + } + + // + stickerPack := entities.StickerPack{ + SetName: setName, + AddedBy: blacklisterTelegramID, + AddedAt: time.Now(), + } + + // + // TODO: Controllare se context.TODO è appropriato o meno + ctx, cancelCtx := context.WithTimeout(dsCtx, opDeadline) + defer cancelCtx() + + result, err := collection.InsertOne(ctx, stickerPack) + if err != nil { + err = xerrors.Errorf("BlacklistStickerPack: unable to add sticker pack %s into the document store: %s", setName, err) + return + } + + if objectID, ok := result.InsertedID.(primitive.ObjectID); ok { + generatedID = objectID.Hex() + } + + return generatedID, err +} + +//PardonStickerPack removes a sticker pack from the blacklist. Also logs the user who did it +func PardonStickerPack(setName string, collection *mongo.Collection) error { + + if setName == "" { + return xerrors.New("PardonStickerPack: attempt to blacklist sticker pack with an empty setname") + } + + // + ctx, cancelCtx := context.WithTimeout(context.Background(), opDeadline) + defer cancelCtx() + + // + filter := bson.M{"setname": setName} + _, err := collection.DeleteOne(ctx, filter, options.Delete()) + if err != nil { + return xerrors.Errorf("PardonStickerPack: error while removing sticker pack %s: %s", setName, err) + } + + return nil + +} + +// StickerPackIsBlacklisted returns true if a sticker pack is not blacklisted +func StickerPackIsBlacklisted(setName string, collection *mongo.Collection) bool { + + if setName == "" { + return false + } + + // + ctx, cancelCtx := context.WithTimeout(context.Background(), opDeadline) + defer cancelCtx() + + // + filter := bson.M{"setname": setName} + result := collection.FindOne(ctx, filter, options.FindOne()) + return result.Err() == nil + +} diff --git a/defense/antiflood/antiflood.go b/defense/antiflood/antiflood.go new file mode 100644 index 0000000..a016e4e --- /dev/null +++ b/defense/antiflood/antiflood.go @@ -0,0 +1,137 @@ +package antiflood + +import ( + "sync" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/shitpostingio/admin-bot/adminbot" + "github.com/shitpostingio/admin-bot/config/structs" + "github.com/shitpostingio/admin-bot/repository" +) + +/* + *********************************************************************************************************************** + * * + * STRUCTS * + * * + *********************************************************************************************************************** + */ + +//FloodState represent infos about reports generated in a group +type FloodState struct { + handled bool + mutex sync.Mutex + counter int +} + +/* + *********************************************************************************************************************** + * * + * CONSTS AND VARS * + * * + *********************************************************************************************************************** + */ + +const ( + floodKey = "flood" +) + +var ( + cfg *structs.AntiFloodConfiguration + state FloodState +) + +/* + *********************************************************************************************************************** + * * + * START * + * * + *********************************************************************************************************************** + */ + +//Start starts flood monitoring +func Start() { + cfg = repository.GetAntiFloodConfiguration() +} + +/* + *********************************************************************************************************************** + * * + * FLOOD CONTROL * + * * + *********************************************************************************************************************** + */ + +//IncreaseFloodCounter increments by `amount` the flood counter +func IncreaseFloodCounter(amount int) { + + state.mutex.Lock() + state.counter += amount + + if state.counter > cfg.Threshold { + if !state.handled { + state.handled = true + reportFlood() + } + } + + state.mutex.Unlock() + go decreaseFloodCounterAfterTime(amount) + +} + +//decreaseFloodCounterAfterTime decreases by `amount` the flood counter after `floodRoutineLifespan` +func decreaseFloodCounterAfterTime(amount int) { + + time.Sleep(time.Duration(cfg.RoutineLifeSpan) * time.Second) + + if IsFlood() { + if repository.GetTestingStatus() { + time.Sleep(15 * time.Second) + } else { + time.Sleep(5 * time.Minute) + } + } + + state.mutex.Lock() + state.counter -= amount + + if state.counter < cfg.Threshold { + state.handled = false + } + + state.mutex.Unlock() + +} + +/* + *********************************************************************************************************************** + * * + * REPORTING * + * * + *********************************************************************************************************************** + */ + +//reportFlood reports a flood to the report channel +func reportFlood() { + report := "⚠️⚠️⚠️WE ARE BEING FLOODED⚠️⚠️⚠️" + log.Warn(report) + _ = adminbot.SendPlainTextMessage(repository.GetTelegramConfiguration().ReportChannelID, report, true) +} + +/* + *********************************************************************************************************************** + * * + * ACCESSORS * + * * + *********************************************************************************************************************** + */ + +//IsFlood returns true if the chat is being flooded +func IsFlood() bool { + state.mutex.Lock() + defer state.mutex.Unlock() + return state.counter > cfg.Threshold +} diff --git a/defense/antispam/antispam.go b/defense/antispam/antispam.go new file mode 100644 index 0000000..772464a --- /dev/null +++ b/defense/antispam/antispam.go @@ -0,0 +1,279 @@ +package antispam + +import ( + "fmt" + "time" + + "github.com/shitpostingio/admin-bot/adminbot" + "github.com/shitpostingio/admin-bot/callback/buttons" + "github.com/shitpostingio/admin-bot/config/structs" + "github.com/shitpostingio/admin-bot/defense/antiflood" + "github.com/shitpostingio/admin-bot/repository" + "github.com/shitpostingio/admin-bot/telegram" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + log "github.com/sirupsen/logrus" + + "github.com/shitpostingio/admin-bot/utility" +) + +/* + *********************************************************************************************************************** + * * + * STRUCTS * + * * + *********************************************************************************************************************** + */ + +type userActions struct { + texts int + media int + other int +} + +/* + *********************************************************************************************************************** + * * + * CONSTS AND VARS * + * * + *********************************************************************************************************************** + */ + +const ( + /* SPAM TYPES */ + textspam = iota + mediaspam + otherspam + mixspam + + /* SPAM REPORT MESSAGES */ + textSpamReport = `🚨The user %s has been temporarily limited for spamming text messages🚨` + mediaSpamReport = `🚨The user %s has been temporarily limited for spamming media🚨` + otherSpamReport = `🚨The user %s has been temporarily limited for spamming other types of messages🚨` + mixSpamReport = `🚨The user %s has been temporarily limited for spamming messages🚨` + + /* CHAN SIZES */ + inputChannelSize = 30 +) + +var ( + /* CHANNELS */ + inputChannel chan *tgbotapi.Message + endAntiSpamCycleChannel chan int64 + userChannels map[int64]chan *tgbotapi.Message + + /* CONFIGURATION */ + cfg *structs.AntiSpamConfiguration +) + +/* + *********************************************************************************************************************** + * * + * START * + * * + *********************************************************************************************************************** + */ + +//Start starts anti spam checks +func Start() { + + cfg = repository.GetAntiSpamConfiguration() + inputChannel = make(chan *tgbotapi.Message, inputChannelSize) + endAntiSpamCycleChannel = make(chan int64) + userChannels = make(map[int64]chan *tgbotapi.Message) + + go detectSpam() +} + +/* + *********************************************************************************************************************** + * * + * USER ROUTINES * + * * + *********************************************************************************************************************** + */ + +//detectSpam receives all incoming messages and sends them to the appropriate routine +func detectSpam() { + for { + select { + + case newMessage := <-inputChannel: + + targetChannel, userIsHandled := userChannels[newMessage.From.ID] + if userIsHandled { + targetChannel <- newMessage + continue + } + + userChannel := make(chan *tgbotapi.Message, inputChannelSize) + go handleUserActions(userChannel, newMessage.From.ID) + userChannels[newMessage.From.ID] = userChannel + userChannel <- newMessage + + case userToRemove := <-endAntiSpamCycleChannel: + if _, found := userChannels[userToRemove]; found { + delete(userChannels, userToRemove) + } + } + } +} + +//handleUserActions handles the actions of a single user +func handleUserActions(inputChannel <-chan *tgbotapi.Message, userID int64) { + + var actions userActions + var hasSpammed bool + var spamType int + + timer := time.After(time.Duration(cfg.RoutineLifeSpan) * time.Second) + messageIDs := make([]int, 0, 3*cfg.OtherThreshold) + + for { + select { + + case <-timer: + + endAntiSpamCycleChannel <- userID + return + + case msg := <-inputChannel: + + if hasSpammed { + adminbot.DeleteMessage(msg.Chat.ID, msg.MessageID) + continue + } + + messageIDs = append(messageIDs, msg.MessageID) + + switch { + case textMessage(msg): + actions.texts++ + case mediaMessage(msg): + actions.media++ + default: + actions.other++ + } + + hasSpammed, spamType = actions.userIsSpamming() + if hasSpammed { + + // if the user has spammed, we add 5 minutes to the timer + timer = time.After(5 * time.Minute) + antiflood.IncreaseFloodCounter(1) + _ = adminbot.RestrictMessages(msg.From, msg.Chat.ID, utility.GetAppropriateRestrictionEnd(), &repository.Bot.Self) + reportSpam(msg.From, spamType) + adminbot.DeleteMultipleMessages(msg.Chat.ID, messageIDs...) + + } + } + } +} + +/* + *********************************************************************************************************************** + * * + * CHANNEL WRAPPERS * + * * + *********************************************************************************************************************** + */ + +// HandleMessage sends a message to the AntiSpam handler. +func HandleMessage(msg *tgbotapi.Message) { + inputChannel <- msg +} + +// EndAntiSpamRoutineForUser ends the antispam routine +// for the input `userID`. +func EndAntiSpamRoutineForUser(userID int64) { + endAntiSpamCycleChannel <- userID +} + +/* + *********************************************************************************************************************** + * * + * SPAM CHECKS * + * * + *********************************************************************************************************************** + */ + +//userIsSpamming returns true if the user is spamming and the type of spam +func (actions userActions) userIsSpamming() (isSpamming bool, spamType int) { + + /* TEXT SPAM */ + if actions.texts > cfg.TextThreshold { + return true, textspam + } + + /* MEDIA SPAM */ + if actions.media > cfg.MediaThreshold { + return true, mediaspam + } + + /* OTHER SPAM */ + if actions.other > cfg.OtherThreshold { + return true, otherspam + } + + /* MIXED SPAM */ + totalActions := actions.texts + actions.media + actions.other + if totalActions > 3*cfg.OtherThreshold { + return true, mixspam + } + + return false, 0 + +} + +/* + *********************************************************************************************************************** + * * + * REPORTING * + * * + *********************************************************************************************************************** + */ + +//reportSpam logs and reports the spam to the report channel +func reportSpam(spammer *tgbotapi.User, spamType int) { + + var report string + + switch spamType { + case textspam: + report = fmt.Sprintf(textSpamReport, spammer.ID, telegram.GetName(spammer)) + case mediaspam: + report = fmt.Sprintf(mediaSpamReport, spammer.ID, telegram.GetName(spammer)) + case otherspam: + report = fmt.Sprintf(otherSpamReport, spammer.ID, telegram.GetName(spammer)) + default: + report = fmt.Sprintf(mixSpamReport, spammer.ID, telegram.GetName(spammer)) + } + + markup := buttons.CreateKeyboardWithOneRow(buttons.CreateUnrestrictButton(spammer.ID), buttons.CreateHandleButton()) + _ = adminbot.SendTextMessageWithMarkup(repository.GetTelegramConfiguration().ReportChannelID, report, markup, true) + log.Warn(report) + +} + +/* + *********************************************************************************************************************** + * * + * UTILITIES * + * * + *********************************************************************************************************************** + */ + +//textMessage returns true if the message is textual +func textMessage(message *tgbotapi.Message) bool { + return message.Text != "" +} + +//mediaMessage returns true if the message contains media +func mediaMessage(message *tgbotapi.Message) bool { + return message.Photo != nil || + message.Video != nil || + message.Voice != nil || + message.VideoNote != nil || + message.Audio != nil +} diff --git a/defense/antiuserbot/antiuserbot.go b/defense/antiuserbot/antiuserbot.go new file mode 100644 index 0000000..d7569dc --- /dev/null +++ b/defense/antiuserbot/antiuserbot.go @@ -0,0 +1,407 @@ +package antiuserbot + +import ( + "fmt" + "github.com/shitpostingio/admin-bot/reports" + "os" + "strconv" + "sync" + "time" + + "github.com/agnivade/levenshtein" + + "github.com/shitpostingio/image-fingerprinting/comparer" + + "github.com/shitpostingio/admin-bot/adminbot" + "github.com/shitpostingio/admin-bot/api" + "github.com/shitpostingio/admin-bot/config/structs" + "github.com/shitpostingio/admin-bot/repository" + "github.com/shitpostingio/admin-bot/telegram" + + "github.com/shitpostingio/admin-bot/analysisadapter" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/patrickmn/go-cache" + + log "github.com/sirupsen/logrus" +) + +/* + *********************************************************************************************************************** + * * + * STRUCTS * + * * + *********************************************************************************************************************** + */ + +// AttackState represents infos about the handling of userbot attacks +type AttackState struct { + handled bool + mutex sync.Mutex +} + +// ChatMemberInfos contains the info needed by Anti Userbot +type ChatMemberInfos struct { + ID int64 + Lock sync.Mutex + Restricted bool + Name string + Handle string + HasProfilePicture bool + ProfilePicturePHash string +} + +/* + *********************************************************************************************************************** + * * + * CONSTS AND VARS * + * * + *********************************************************************************************************************** + */ + +const ( + /* CACHE */ + suspicionKey = "suspicion" + userExpiration = 1 * time.Minute + userCleanup = 5 * time.Minute + + /* PRINTS */ + unableToAddToCache = "Unable to add user %s (id %d) to the join cache: %s" + chatMemberNotRestricted = "Unable to restrict user %s (@%s, id %d) for being a possible userbot" + telegramUserNotRestricted = "Unable to restrict user %s (id %d) for being a possible userbot" +) + +var ( + cfg *structs.AntiUserbotConfiguration + joinChannel chan *tgbotapi.User + joinCache *cache.Cache + suspicionCache *cache.Cache + state AttackState +) + +/* + *********************************************************************************************************************** + * * + * START * + * * + *********************************************************************************************************************** + */ + +// Start starts join monitoring +func Start() { + + cfg = repository.GetAntiUserbotConfiguration() + + joinChannel = make(chan *tgbotapi.User) + joinCache = cache.New(userExpiration, userCleanup) + suspicionCache = cache.New(cache.NoExpiration, cache.NoExpiration) + + err := suspicionCache.Add(suspicionKey, 0, cache.NoExpiration) + if err != nil { + log.Error(fmt.Sprintf("Unable to add counter to antiuserbot suspicion cache: %s", err.Error())) + os.Exit(-10) + } + + go handleNewUsers() +} + +/* + *********************************************************************************************************************** + * * + * JOIN ROUTINES * + * * + *********************************************************************************************************************** + */ + +// handleNewUsers restricts new users if we're being attacked by userbots or starts a routine for each user +func handleNewUsers() { + for { + + newUser := <-joinChannel + + if IsAttack() { + go muteTelegramUser(newUser) + continue + } + + go handleSingleUser(newUser) + + } +} + +// handleSingleUser performs check to determine the similarity of this user compared to +// the ones that joined recently, in order to prevent userbots +func handleSingleUser(user *tgbotapi.User) { + + chatMember := ChatMemberInfos{ + ID: user.ID, + Name: user.FirstName + user.LastName, + Handle: user.UserName, + } + + err := joinCache.Add(strconv.FormatInt(user.ID, 10), &chatMember, userExpiration) + if err != nil { + log.Warn(fmt.Sprintf(unableToAddToCache, telegram.GetNameOrUsername(user), user.ID, err.Error())) + } + + // Increment suspicion after adding the user to the cache, + // so we can restrict them in case of the suspicion going + // over the threshold. + suspicionCount := 1 + increaseSuspicion(1) + + suspicionCount += checkTextSimilarities(&chatMember) + if !chatMember.Restricted && IsAttack() { + muteChatMember(&chatMember) + } + + fingerprintUserProfilePicture(&chatMember) + suspicionCount += checkPictureSimilarities(&chatMember) + if !chatMember.Restricted && IsAttack() { + muteChatMember(&chatMember) + } + + // Wait `joinRoutineLifespan` before decreasing the suspicion. + time.Sleep(time.Duration(cfg.RoutineLifespan)) + decreaseSuspicion(suspicionCount) +} + +/* + *********************************************************************************************************************** + * * + * SIMILARITIES * + * * + *********************************************************************************************************************** + */ + +// checkTextSimilarities checks for similarities in the names and in the handles of the recent joins +func checkTextSimilarities(chatMember *ChatMemberInfos) (similarities int) { + + // 1 suspicion point for no handle. + if chatMember.Handle == "" { + similarities += increaseSuspicion(1) + } + + for _, item := range joinCache.Items() { + + currentUser := item.Object.(*ChatMemberInfos) + if currentUser.ID == chatMember.ID { + continue + } + + similarities += increaseSuspicion(checkNameSimilarities(chatMember, currentUser)) + similarities += increaseSuspicion(checkHandleSimilarities(chatMember, currentUser)) + } + + return similarities +} + +// checkNameSimilarities computes the Levenshtein distance between the names of two users +func checkNameSimilarities(chatMember *ChatMemberInfos, toCompare *ChatMemberInfos) (similarities int) { + + nameDistance := levenshtein.ComputeDistance(chatMember.Name, toCompare.Name) + + if nameDistance < len(chatMember.Name)/2 { + similarities += increaseSuspicion(1) + } + + return +} + +// checkNameSimilarities computes the Levenshtein distance between the handles of two users +func checkHandleSimilarities(chatMember *ChatMemberInfos, toCompare *ChatMemberInfos) (similarities int) { + + if chatMember.Handle != "" { + + handleDistance := levenshtein.ComputeDistance(chatMember.Handle, toCompare.Handle) + + if handleDistance < len(chatMember.Handle)/2 { + similarities += increaseSuspicion(1) + } + } + + return +} + +// fingerprintUserProfilePicture gets the fingerprint of the user's current profile picture +func fingerprintUserProfilePicture(chatMember *ChatMemberInfos) { + + userPhotos, err := adminbot.GetUserProfilePhotos(chatMember.ID, 1) + if err == nil && userPhotos.TotalCount != 0 { + + profilePicture := userPhotos.Photos[0][len(userPhotos.Photos[0])-1] + fingerprint, err := analysisadapter.GetFingerprint(profilePicture.FileUniqueID, profilePicture.FileID) + + if err == nil { + chatMember.HasProfilePicture = true + chatMember.ProfilePicturePHash = fingerprint.PHash + } + } +} + +// checkNameSimilarities checks the similarity between +// the user's current profile picture and the ones of the recent joins +func checkPictureSimilarities(chatMember *ChatMemberInfos) (similarities int) { + + if !chatMember.HasProfilePicture { + similarities += increaseSuspicion(1) + return + } + + for _, item := range joinCache.Items() { + + currentUser := item.Object.(*ChatMemberInfos) + if currentUser.ID == chatMember.ID { + continue + } + + if fpcompare.PhotosAreSimilarEnough(chatMember.ProfilePicturePHash, currentUser.ProfilePicturePHash) { + + // 2 suspicion points if the photos are similar. + similarities += increaseSuspicion(2) + + } + } + + return +} + +/* + *********************************************************************************************************************** + * * + * RESTRICTIONS * + * * + *********************************************************************************************************************** + */ + +func muteAllNewMembers() { + for _, item := range joinCache.Items() { + go muteChatMember(item.Object.(*ChatMemberInfos)) + } +} + +func muteChatMember(user *ChatMemberInfos) { + + user.Lock.Lock() + defer user.Lock.Unlock() + if user.Restricted { + return + } + + _, err := api.RestrictMessages(user.ID, repository.GetTelegramConfiguration().GroupID, 0) + if err == nil { + + user.Restricted = true + restrictionReport := reports.PossibleUserbotRestriction(user.ID, user.Name) + _ = reports.Report(restrictionReport, reports.NON_URGENT) + log.Warn(restrictionReport) + + } else { + + log.Error(fmt.Sprintf(chatMemberNotRestricted, user.Name, user.Handle, user.ID)) + } +} + +func muteTelegramUser(user *tgbotapi.User) { + + err := adminbot.RestrictMessages(user, repository.GetTelegramConfiguration().GroupID, 0, &repository.Bot.Self) + + if err == nil { + + restrictionReport := reports.PossibleUserbotRestriction(user.ID, telegram.GetName(user)) + _ = reports.Report(restrictionReport, reports.NON_URGENT) + log.Warn(restrictionReport) + + } else { + + log.Error(fmt.Sprintf(telegramUserNotRestricted, telegram.GetNameOrUsername(user), user.ID)) + } +} + +/* + *********************************************************************************************************************** + * * + * SUSPICION * + * * + *********************************************************************************************************************** + */ + +func increaseSuspicion(amount int) (amountIncreased int) { + + currentSuspicion, err := suspicionCache.IncrementInt(suspicionKey, amount) + if err != nil { + return + } + + amountIncreased = amount + if currentSuspicion > cfg.JoinThreshold { + + state.mutex.Lock() + if !state.handled { + state.handled = true + go reportPossibleUserbotAttack() + muteAllNewMembers() + } + state.mutex.Unlock() + } + + return +} + +func decreaseSuspicion(amount int) { + + // In case of userbot attack we should wait + // before lowering our defenses. + if IsAttack() { + if repository.GetTestingStatus() { + time.Sleep(15 * time.Second) + } else { + time.Sleep(5 * time.Minute) + } + } + + currentSuspicion, err := suspicionCache.DecrementInt(suspicionKey, amount) + if err != nil { + log.Warn("Unable to decrease suspicion") + } + + if currentSuspicion < cfg.JoinThreshold { + state.handled = false + } +} + +func getUserbotAttackSuspicion() int { + valueItf, _ := suspicionCache.Get(suspicionKey) + return valueItf.(int) +} + +/* + *********************************************************************************************************************** + * * + * REPORTING * + * * + *********************************************************************************************************************** + */ + +func reportPossibleUserbotAttack() { + _ = reports.ReportInPlaintext(reports.PossibleUserbotAttack(), reports.URGENT) + log.Warn(reports.PossibleUserbotAttack()) +} + +/* + *********************************************************************************************************************** + * * + * ACCESSORS * + * * + *********************************************************************************************************************** + */ + +// IsAttack returns true if the chat is under a possible userbot attack +func IsAttack() bool { + return getUserbotAttackSuspicion() > cfg.JoinThreshold +} + +// HandleUser sends an user to the handling routine. +func HandleUser(user *tgbotapi.User) { + joinChannel <- user +} diff --git a/defense/emergencymode/emergencymode.go b/defense/emergencymode/emergencymode.go new file mode 100644 index 0000000..2815a1d --- /dev/null +++ b/defense/emergencymode/emergencymode.go @@ -0,0 +1,96 @@ +package emergencymode + +import ( + "fmt" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + log "github.com/sirupsen/logrus" + + "github.com/shitpostingio/admin-bot/adminbot" + "github.com/shitpostingio/admin-bot/callback/buttons" + "github.com/shitpostingio/admin-bot/defense/antiflood" + "github.com/shitpostingio/admin-bot/localization" + "github.com/shitpostingio/admin-bot/repository" + "github.com/shitpostingio/admin-bot/telegram" +) + +var ( + emergency bool + emergencyStateUpdateChannel chan bool +) + +// Start starts an emergency mode +func Start() chan bool { + emergency = true + emergencyStateUpdateChannel = make(chan bool) + return emergencyStateUpdateChannel +} + +// End ends an emergency mode +func End() { + emergency = false + emergencyStateUpdateChannel <- true +} + +// RestrictUserForEmergency restricts user for an emergency +func RestrictUserForEmergency(user *tgbotapi.User, msg *tgbotapi.Message) { + + _ = adminbot.RestrictMessages(user, msg.Chat.ID, 0, &repository.Bot.Self) + + // Send a message in the report chat + userRestrictionReportText := fmt.Sprintf(localization.GetString("emergencymode_user_restricted"), user.ID, telegram.GetName(user)) + markup := buttons.CreateKeyboardWithOneRow(buttons.CreateApproveUserButton(user.ID), buttons.CreateHandleButton()) + _ = adminbot.SendTextMessageWithMarkup(repository.GetTelegramConfiguration().ReportChannelID, userRestrictionReportText, markup, false) + + // Increase flood counter since we're in an emergency + antiflood.IncreaseFloodCounter(1) + + // Tell the user they'll have to wait to be unrestricted + _ = adminbot.SendReplyPlainTextMessage(msg.MessageID, msg.Chat.ID, repository.Configuration.AdminBot.EmergencyText, false) + +} + +// PerformEmergencyModeChecks performs checks on a user and restricts them if they have +// no handle or no profile pictures during an emergency. +func PerformEmergencyModeChecks(user *tgbotapi.User, msg *tgbotapi.Message) { + + if user.UserName == "" { + restrictUserForEmergency(user, msg) + return + } + + userPhotos, err := adminbot.GetUserProfilePhotos(user.ID, 1) + if err != nil { + + unableToGetPicturesReportText := fmt.Sprintf(localization.GetString("emegencymode_unable_to_get_pictures"), user.ID, telegram.GetName(user)) + _ = adminbot.SendTextMessage(repository.GetTelegramConfiguration().ReportChannelID, unableToGetPicturesReportText, false) + log.Warn(unableToGetPicturesReportText) + return + } + + if userPhotos.TotalCount == 0 { + restrictUserForEmergency(user, msg) + } +} + +// restrictUserForEmergency mutes users for not having the requisites during an emergency. +func restrictUserForEmergency(user *tgbotapi.User, msg *tgbotapi.Message) { + + _ = adminbot.RestrictMessages(user, msg.Chat.ID, 0, &repository.Bot.Self) + userRestrictionReportText := fmt.Sprintf(localization.GetString("emergencymode_user_restricted"), user.ID, telegram.GetName(user)) + + if !antiflood.IsFlood() { + + antiflood.IncreaseFloodCounter(1) + _ = adminbot.SendTextMessage(repository.GetTelegramConfiguration().ReportChannelID, userRestrictionReportText, false) + + } + + log.Warn(userRestrictionReportText) +} + +// IsEmergency returns true if emergency mode is active. +func IsEmergency() bool { + return emergency +} diff --git a/docker-compose-production.yml b/docker-compose-production.yml new file mode 100644 index 0000000..24ba4c7 --- /dev/null +++ b/docker-compose-production.yml @@ -0,0 +1,9 @@ +version: '3' +services: + bot: + build: . + volumes: + - /home/bots/configs:/home/adminbot/configs + network_mode: "host" + ports: + - 21743:21743 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..46c184a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +version: '3' +services: + bot: + build: . + mariadb: + image: mariadb:latest + volumes: + - ./compose_stuff/mysql_init:/docker-entrypoint-initdb.d + ports: + - 3306 + mongolo: + image: mongo:latest + environment: + - MONGO_INITDB_DATABASE=automod + volumes: + - ./compose_stuff/mongo_init:/docker-entrypoint-initdb.d \ No newline at end of file diff --git a/entities/ban.go b/entities/ban.go new file mode 100644 index 0000000..a288e97 --- /dev/null +++ b/entities/ban.go @@ -0,0 +1,17 @@ +package entities + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// Ban represents a ban in the document store +type Ban struct { + ID primitive.ObjectID `bson:"_id,omitempty"` + User int64 + BannedBy int64 + Reason string `bson:",omitempty"` + BanDate time.Time + UnbanDate time.Time `bson:",omitempty"` +} diff --git a/entities/hostname.go b/entities/hostname.go new file mode 100644 index 0000000..a21e6cd --- /dev/null +++ b/entities/hostname.go @@ -0,0 +1,17 @@ +package entities + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// HostName represents a hostname in the document store +type HostName struct { + ID primitive.ObjectID `bson:"_id,omitempty"` + Host string + IsBanworthy bool `bson:",omitempty"` + IsTelegram bool `bson:",omitempty"` + LastEditedBy int64 `bson:",omitempty"` + LastModified time.Time +} diff --git a/entities/media.go b/entities/media.go new file mode 100644 index 0000000..7df2e18 --- /dev/null +++ b/entities/media.go @@ -0,0 +1,71 @@ +package entities + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +//nolint +const ( + PHOTO = "AgA" + VIDEO = "BAA" + ANIMATION = "CgA" + STICKER = "CAA" + VOICE = "AwA" + DOCUMENT = "BQA" + AUDIO = "CQA" + VIDEONOTE = "DQA" +) + +// Media represents a media in the document store +type Media struct { + ID primitive.ObjectID `bson:"_id,omitempty"` + FileUniqueID string + FileID string + IsWhitelisted bool `bson:",omitempty"` + Histogram []float64 `bson:",omitempty"` + HistogramAverage float64 `bson:",omitempty"` + HistogramSum float64 `bson:",omitempty"` + PHash string `bson:",omitempty"` + NSFWScore float64 `bson:",omitempty"` + NSFWDescription string `bson:",omitempty"` + LastEditedBy int64 `bson:",omitempty"` + LastModified time.Time +} + +// MediaCanBeFingerprinted returns true if a media can be fingerprinted +func MediaCanBeFingerprinted(fileID string) bool { + + // Telegram prefixes are 3 characters long + fileIDPrefix := fileID[:3] + + switch fileIDPrefix { + case STICKER: + return true + case PHOTO: + return true + case VIDEO: + return true + case ANIMATION: + return true + } + + return false + +} + +// GetHistogramAverageAndSum gets the average and the sum of the input histogram values +func GetHistogramAverageAndSum(histogram []float64) (average, sum float64) { + + coefficient := 1.0 + for i := 0; i < 16; i++ { + sum += histogram[i] * coefficient + sum += histogram[31-i] * coefficient + coefficient++ + } + + average = sum / float64(len(histogram)) + return + +} diff --git a/entities/moderator.go b/entities/moderator.go new file mode 100644 index 0000000..0b64f27 --- /dev/null +++ b/entities/moderator.go @@ -0,0 +1,22 @@ +package entities + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// Moderator represents a moderator in the document store +type Moderator struct { + ID primitive.ObjectID `bson:"_id,omitempty"` + TelegramID int64 + IsAdmin bool + CanChangeInfo bool + CanDeleteMessages bool + CanInviteUsers bool + CanRestrictMembers bool + CanPinMessages bool + CanPromoteMembers bool + PromotedBy int64 `bson:",omitempty"` + PromotionDate time.Time +} diff --git a/entities/source.go b/entities/source.go new file mode 100644 index 0000000..da4e770 --- /dev/null +++ b/entities/source.go @@ -0,0 +1,17 @@ +package entities + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// Source represents a source in the document store +type Source struct { + ID primitive.ObjectID `bson:"_id,omitempty"` + TelegramID int64 `bson:",omitempty"` + Username string `bson:",omitempty"` + IsWhitelisted bool `bson:",omitempty"` + AddedBy int64 `bson:",omitempty"` + LastModified time.Time +} diff --git a/entities/stickerpack.go b/entities/stickerpack.go new file mode 100644 index 0000000..59f1ec9 --- /dev/null +++ b/entities/stickerpack.go @@ -0,0 +1,15 @@ +package entities + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// StickerPack represents a sticker pack in the document store. +type StickerPack struct { + ID primitive.ObjectID `bson:"_id,omitempty"` + SetName string + AddedBy int64 `bson:",omitempty"` + AddedAt time.Time +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..77c503b --- /dev/null +++ b/go.mod @@ -0,0 +1,29 @@ +module github.com/shitpostingio/admin-bot + +go 1.13 + +require ( + github.com/agnivade/levenshtein v1.1.1 + github.com/aws/aws-sdk-go v1.38.71 // indirect + github.com/bykovme/gotrans v1.1.0 + github.com/corona10/goimagehash v1.0.3 // indirect + github.com/fsnotify/fsnotify v1.4.9 + github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 + github.com/golang/snappy v0.0.4 // indirect + github.com/klauspost/compress v1.13.1 // indirect + github.com/patrickmn/go-cache v2.1.0+incompatible + github.com/pkg/errors v0.9.1 + github.com/shitpostingio/analysis-commons v0.0.0-20210103110506-3853e65ffbe1 + github.com/shitpostingio/go-tdlib v0.4.4-0.20211228122221-91bc2619755c + github.com/shitpostingio/image-fingerprinting v0.0.0-20201010152210-bf01bf1648ef + github.com/sirupsen/logrus v1.8.1 + github.com/spf13/viper v1.8.1 + github.com/stretchr/testify v1.7.0 + github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a // indirect + github.com/zelenin/go-tdlib v0.4.0 + go.mongodb.org/mongo-driver v1.5.3 + golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e // indirect + golang.org/x/net v0.0.0-20210614182718-04defd469f4e + golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect + golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c165f84 --- /dev/null +++ b/go.sum @@ -0,0 +1,718 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= +cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= +cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= +cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= +cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= +cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= +cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= +cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= +cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= +cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= +cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= +cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= +cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/AlessandroPomponio/hsv v0.0.0-20190812070135-855ac4adcd7f/go.mod h1:/QmcYzxY0EIqnyaf45UX96L7RUxhXhmoEZ8c97snyxI= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= +github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= +github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/aws/aws-sdk-go v1.34.28/go.mod h1:H7NKnBqNVzoTJpGfLrQkkD+ytBA93eiDYi/+8rV9s48= +github.com/aws/aws-sdk-go v1.38.71 h1:aWhtgoOiDhBCfaAj9XbxzcyvjEAKovbtv7d5mCVBZXw= +github.com/aws/aws-sdk-go v1.38.71/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= +github.com/bykovme/gotrans v1.1.0 h1:o1KpXxHZqACY9ziCo/T9ZW8RC8nXVLbD5/zzvODOuv8= +github.com/bykovme/gotrans v1.1.0/go.mod h1:EkJY1BJR44ppUqIep0qKiBkQZptuVuaZRb+jfDfHToA= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/corona10/goimagehash v0.3.0/go.mod h1:BQtzmQ2tNr04YeCPYBOiIWe/69WJF5IRGPLmzxvcAKg= +github.com/corona10/goimagehash v1.0.3 h1:NZM518aKLmoNluluhfHGxT3LGOnrojrxhGn63DR/CZA= +github.com/corona10/goimagehash v1.0.3/go.mod h1:VkvE0mLn84L4aF8vCb6mafVajEb6QYMHl2ZJLn0mOGI= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g= +github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= +github.com/gobuffalo/attrs v0.0.0-20190224210810-a9411de4debd/go.mod h1:4duuawTqi2wkkpB4ePgWMaai6/Kc6WEz83bhFwpHzj0= +github.com/gobuffalo/depgen v0.0.0-20190329151759-d478694a28d3/go.mod h1:3STtPUQYuzV0gBVOY3vy6CfMm/ljR4pABfrTeHNLHUY= +github.com/gobuffalo/depgen v0.1.0/go.mod h1:+ifsuy7fhi15RWncXQQKjWS9JPkdah5sZvtHc2RXGlg= +github.com/gobuffalo/envy v1.6.15/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI= +github.com/gobuffalo/envy v1.7.0/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI= +github.com/gobuffalo/flect v0.1.0/go.mod h1:d2ehjJqGOH/Kjqcoz+F7jHTBbmDb38yXA598Hb50EGs= +github.com/gobuffalo/flect v0.1.1/go.mod h1:8JCgGVbRjJhVgD6399mQr4fx5rRfGKVzFjbj6RE/9UI= +github.com/gobuffalo/flect v0.1.3/go.mod h1:8JCgGVbRjJhVgD6399mQr4fx5rRfGKVzFjbj6RE/9UI= +github.com/gobuffalo/genny v0.0.0-20190329151137-27723ad26ef9/go.mod h1:rWs4Z12d1Zbf19rlsn0nurr75KqhYp52EAGGxTbBhNk= +github.com/gobuffalo/genny v0.0.0-20190403191548-3ca520ef0d9e/go.mod h1:80lIj3kVJWwOrXWWMRzzdhW3DsrdjILVil/SFKBzF28= +github.com/gobuffalo/genny v0.1.0/go.mod h1:XidbUqzak3lHdS//TPu2OgiFB+51Ur5f7CSnXZ/JDvo= +github.com/gobuffalo/genny v0.1.1/go.mod h1:5TExbEyY48pfunL4QSXxlDOmdsD44RRq4mVZ0Ex28Xk= +github.com/gobuffalo/gitgen v0.0.0-20190315122116-cc086187d211/go.mod h1:vEHJk/E9DmhejeLeNt7UVvlSGv3ziL+djtTr3yyzcOw= +github.com/gobuffalo/gogen v0.0.0-20190315121717-8f38393713f5/go.mod h1:V9QVDIxsgKNZs6L2IYiGR8datgMhB577vzTDqypH360= +github.com/gobuffalo/gogen v0.1.0/go.mod h1:8NTelM5qd8RZ15VjQTFkAW6qOMx5wBbW4dSCS3BY8gg= +github.com/gobuffalo/gogen v0.1.1/go.mod h1:y8iBtmHmGc4qa3urIyo1shvOD8JftTtfcKi+71xfDNE= +github.com/gobuffalo/logger v0.0.0-20190315122211-86e12af44bc2/go.mod h1:QdxcLw541hSGtBnhUc4gaNIXRjiDppFGaDqzbrBd3v8= +github.com/gobuffalo/mapi v1.0.1/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc= +github.com/gobuffalo/mapi v1.0.2/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc= +github.com/gobuffalo/packd v0.0.0-20190315124812-a385830c7fc0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWeG2RIxq4= +github.com/gobuffalo/packd v0.1.0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWeG2RIxq4= +github.com/gobuffalo/packr/v2 v2.0.9/go.mod h1:emmyGweYTm6Kdper+iywB6YK5YzuKchGtJQZ0Odn4pQ= +github.com/gobuffalo/packr/v2 v2.2.0/go.mod h1:CaAwI0GPIAv+5wKLtv8Afwl+Cm78K/I/VCm/3ptBN+0= +github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= +github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= +github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= +github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= +github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/karrick/godirwalk v1.8.0/go.mod h1:H5KPZjojv4lE+QYImBI8xVtrBRgYrIVsaRPx4tDPEn4= +github.com/karrick/godirwalk v1.10.3/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.9.5/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/compress v1.13.1 h1:wXr2uRxZTJXHLly6qhJabee5JqIhTRoLBhDOA74hDEQ= +github.com/klauspost/compress v1.13.1/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/magiconair/properties v1.8.5 h1:b6kJs+EmPFMYGkow9GiUyCyOvIwYetYJ3fSaWak/Gls= +github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= +github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE= +github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= +github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= +github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/opennota/screengen v0.0.0-20180911053657-079163d5f999/go.mod h1:LC6ik90Gt8mSZWzcYChA5QWdgKEISVlD3Fb8wOrQ7Mk= +github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE= +github.com/pelletier/go-toml v1.9.3 h1:zeC5b1GviRUyKYd6OJPvBU/mcVDVoL1OhT17FCt5dSQ= +github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= +github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/shitpostingio/analysis-commons v0.0.0-20210103110506-3853e65ffbe1 h1:KiFiljwm0KEL3Cl/4IzYjUbxujVDtbo3K59lgyAak+o= +github.com/shitpostingio/analysis-commons v0.0.0-20210103110506-3853e65ffbe1/go.mod h1:MvV7fFPc0DadWeOmeJh311GYlCbLj6d98UW8gLtTeJ8= +github.com/shitpostingio/go-tdlib v0.4.4-0.20211228122221-91bc2619755c h1:rJoFqRkRyybBuO3EjTlODXDg3ybn2bgZMT6uSfyscuw= +github.com/shitpostingio/go-tdlib v0.4.4-0.20211228122221-91bc2619755c/go.mod h1:taRYMZ1Ee5FjbhHO5c5p1w741KZX1vY1ZBQP4F2iI5Q= +github.com/shitpostingio/image-fingerprinting v0.0.0-20201010152210-bf01bf1648ef h1:/m0SNfoi3Srg62lIJ7+AsG7Oi5pH/PEnIt016krekJw= +github.com/shitpostingio/image-fingerprinting v0.0.0-20201010152210-bf01bf1648ef/go.mod h1:7pDZF7VW4T8KwyPhWaSlX5R4gDMb313uVOyT92AMX4M= +github.com/sirupsen/logrus v1.4.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= +github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/spf13/afero v1.6.0 h1:xoax2sJ2DT8S8xA2paPFjDCScCNeWsg75VG0DLRreiY= +github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= +github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng= +github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= +github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.8.1 h1:Kq1fyeebqsBfbjZj4EL7gj2IO0mMaiyjYUWcUsl2O44= +github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= +github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4= +github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.0.2 h1:akYIkZ28e6A96dkWNJQu3nmCzH3YfwMPQExUYDaRv7w= +github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs= +github.com/xdg-go/stringprep v1.0.2 h1:6iq84/ryjjeRmMJwxutI51F2GIPlP5BfTvXHeYjyhBc= +github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= +github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a h1:fZHgsYlfvtyqToslyjUt3VOPF4J7aK/3MPcK7xp3PDk= +github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a/go.mod h1:ul22v+Nro/R083muKhosV54bj5niojjWZvU8xrevuH4= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/zelenin/go-tdlib v0.4.0 h1:pCC930uZrVdfJm0X096E3PZH/LNxxB+GN9MmZEu4y0I= +github.com/zelenin/go-tdlib v0.4.0/go.mod h1:Xs8fXbk5n7VaPyrSs9DP7QYoBScWYsjX+lUcWmx1DIU= +gitlab.com/opennota/screengen v0.0.0-20190303011023-4c1f27a3452a/go.mod h1:aMJ5Zrz1DJL5RysiKMd20d/ndkMQU9Zeaei3z9KAmBQ= +go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= +go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= +go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ= +go.mongodb.org/mongo-driver v1.5.3 h1:wWbFB6zaGHpzguF3f7tW94sVE8sFl3lHx8OZx/4OuFI= +go.mongodb.org/mongo-driver v1.5.3/go.mod h1:gRXCHX4Jo7J0IJ1oDQyUxF7jfy19UfxniMS4xxMmUqw= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= +go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190422162423-af44ce270edf/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e h1:gsTQYXdTw2Gq7RBsWvlQ91b+aEQ6bXFUngBGuR8sPpI= +golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20191214001246-9130b4cfad52/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20201208152932-35266b937fa6/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210614182718-04defd469f4e h1:XpT3nA5TvE525Ne3hInMh6+GETgn27Zfm9dxsThnX2Q= +golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190412183630-56d357773e84/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190419153524-e8e3143a4f4a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190531175056-4c3a928424d2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190329151228-23e29df326fe/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190416151739-9c9e1878f421/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190420181800-aa740d480789/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190531172133-b3315ee88b7d/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= +golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= +google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= +google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= +google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= +google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= +google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= +google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= +google.golang.org/api v0.44.0/go.mod h1:EBOGZqzyhtvMDoxwS97ctnh0zUmYY6CxqXsc1AvkYD8= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= +google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= +google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU= +gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/localization/en.json b/localization/en.json new file mode 100644 index 0000000..2678b63 --- /dev/null +++ b/localization/en.json @@ -0,0 +1,102 @@ +{ + "antiuserbot_possible_attack": "🤖🤖🤖 POSSIBLE USERBOT ATTACK 🤖🤖🤖", + "antiuserbot_possible_userbot_restriction": "User %s has been restricted for being a possible userbot", + "automod_new_report_by": "⚠️ New report by %s (ID: %d)", + "automod_new_report_by_with_reply": "⚠️ %s (ID: %d) reported a message from %s (ID: %d)", + "automod_removed_message_forwarded_channel": "🚮️ Removed a message forwarded from a channel by %s (ID: %d)", + "automod_removed_message_forwared_blacklisted_handle": "🚮️ Removed a message forwarded from a blacklisted handle by %s (ID: %d)", + "automod_removed_message_via_blacklsited_inline_bot": "️🚮 Removed a message sent by %s (ID: %d) via a blacklisted inline bot", + "automod_removed_nsfw_animation": "Removed a NSFW animation posted by %s (ID: %d).\nIt contained: %s.\nConfidence: %f%%", + "automod_removed_nsfw_photo": "Removed a NSFW photo posted by %s (ID: %d).\nIt contained: %s.\nConfidence: %f%%", + "automod_removed_nsfw_video": "Removed a NSFW video posted by %s (ID: %d).\nIt contained: %s.\nConfidence: %f%%", + "automod_removed_nsfw_media": "Removed a NSFW media posted by %s (ID: %d).\nIt contained: %s.\nConfidence: %f%%", + "automod_removed_unwanted_handle": "🚮️ Removed an unwanted handle posted by %s (ID: %d)", + "automod_removed_unwanted_link": "🚮️ Removed unwanted %s link posted by %s (ID: %d)", + "automod_unwanted_link_ban_reason": "posting a link from an unwanted hostname: %s", + "automod_unwanted_link_unable_to_ban": "Unable to ban user %s (ID: %d) for sending %s links: %s", + "automod_unwanted_link_unable_to_mute": "Unable to mute user %s (ID: %d) for sending %s links: %s", + "automod_unwanted_link_user_muted": "🚔 %s muted %s (ID: %d).\nFor: %s", + "buttons_backup": "Backup", + "buttons_ban_user": "Ban user", + "buttons_banworthy": "Banworthy", + "buttons_mark_as_handled": "Mark as handled", + "buttons_mod_user": "Mod user", + "buttons_remove_only": "Remove only", + "buttons_report": "Report", + "buttons_reported_message": "Reported message", + "buttons_rules_read": "I've read the rules", + "buttons_telegram": "Telegram", + "buttons_unban_user": "Unban", + "buttons_unrestrict_user": "Unrestrict", + "buttons_whitelist_media": "Whitelist", + "callback_first_step_error": "Error. Please retry.", + "callback_first_step_perform_second": "Press the button again to authorize the action", + "callback_hosts_result": "The requested operation returned %v\n\nHostname:\t%s\nIsBanworthy:\t%s\nIsTelegram:\t%s", + "callback_media_whitelisted": "🛃 Whitelisted by %s 🛃\n\n%s", + "callback_mods_unable_to_mod": "Unable to promote to mod user with ID %d (requested by %s): %s", + "callback_report_handled": "✅ Handled by %s ✅\n\n%s", + "callback_request_action_already_authorized": "The action has already been authorized", + "callback_request_performed_shortly": "The request will be performed shortly", + "callback_twostep_different_action_already_approved": "Another action has already been approved for this message and it's in the process of execution.", + "callback_twostep_different_action_alredy_requested": "Someone already requested a different action for this message.\nTry again in a few seconds.", + "callback_user_unbanned": "🛃 User unbanned by %s 🛃\n\n%s", + "callback_user_unrestricted": "🛃 User unrestricted by %s 🛃\n\n%s", + "callback_verification_error_occurred": "There was an error, contact an administrator in private", + "emegencymode_unable_to_get_pictures": "Unable to get profile pictures during an emergency for the user %s", + "emergencymode_active": "🚨🚨🚨 EMERGENCY MODE IS ON 🚨🚨🚨\nAll new users will have to be approved for the next %s\nSend me /emergencymode in private to end it sooner", + "emergencymode_approve_user": "Approve user", + "emergencymode_cancelled": "👮 Emergency mode has been cancelled manually 👮", + "emergencymode_expired": "👮 Emergency mode has expired 👮", + "emergencymode_user_restricted": "👮 %s (ID: %d) has been restricted for joining during an emergency 👮\nSend me /emergencyMode in private to stop the emergency mode", + "feature_unimplemented": "Unimplemented", + "moderator_demoted": "🤺 %s (ID: %d) has demoted %s (ID: %d) 🤺", + "moderator_cannot_be_removed": "🛂 Unable to remove %s (ID: %d) from the moderators table 🛂", + "private_blacklistall_active": "Keep in mind that BlacklistAll mode is now ON!\nUse /blacklistAll to turn it off", + "private_blacklistall_handles_added_correctly": "Handles added to the blacklist!\n%s", + "private_blacklistall_media_added_correctly": "Media added to the blacklist!\n%s", + "private_blacklistall_media_already_blacklisted": "Media already blacklisted!\n%s", + "private_blacklistall_media_unable_to_add": "Unable to add the media to the blacklist!\n%s", + "private_blacklistall_stickerpack_added_correctly": "Sticker pack added to the blacklist!\n%s", + "private_blacklistall_stickerpack_already_blacklisted": "Sticker pack already blacklisted!\n%s", + "private_blacklistall_stickerpack_unable_to_add": "Unable to add the sticker pack to the blacklist!\n%s", + "private_checks_ban_entry": "By %d on %s\nFor: %s\n\n", + "private_checks_ban_preamble": "The user %s has been banned %d times\n\n", + "private_checks_could_not_understand": "Couldn't understand what to check 😭", + "private_checks_no_db_info": "The user %s is currently 🚫 banned but there is no ban data in the database", + "private_checks_not_banned_or_restricted": "%s is currently ❌ not banned or restricted 😇", + "private_checks_profile_hidden_from_forwards": "📡 The user has opted to hide their profile from forwarded messages. 📡\nTry using the /check command specifying either the username or the user id.", + "private_checks_restriction_report": "The user %s is restricted until %s\nRestrictions:\n%s", + "private_checks_unable_to_get_user_info": "Unable to get user information: %s", + "private_checks_unable_to_resolve_username": "Failed to resolve username: %s", + "private_checks_username_is_group_or_channel": "The handle @%s belongs to a group or a channel", + "private_command_added_to_blacklist": "Added to the blacklist!", + "private_command_added_to_whitelist": "Successfully whitelisted!", + "private_command_blacklistall_status": "Blacklist all mode is now %v", + "private_command_operation_cancelled": "Operation cancelled", + "private_command_removed_from_blacklist": "Successfully removed from the blacklist!", + "private_command_removed_successfully": "Successfully removed!", + "private_command_unable_to_add_to_blacklist": "Unable to add to the blacklist 😞", + "private_command_unable_to_remove": "Unable to remove 😞", + "private_command_unable_to_remove_from_blacklist": "Unable to remove from the blacklist 😞", + "private_command_unable_to_whitelist": "Unable to whitelist 😞", + "private_emergencymode_cancelled": "Emergency mode has been cancelled", + "private_handle_what_to_do": "What should we do with this handle?", + "private_hosts_what_to_do": "Choose how to add this host to the blacklist", + "private_media_what_to_do": "What should we do with this media?", + "private_mods_are_you_sure": "Are you sure you want to add %s as a moderator?", + "private_mods_unable_to_find_user": "Unable to find the user in the chat 😨", + "private_mods_unable_to_parse_userid": "Unable to parse the userID", + "private_sticker_what_to_do": "What should we do with this sticker?", + "shitposting_bot_active": "Shitposting admin-bot version %s, build %s\nAuthorized on account %s", + "unauthorized_channel_report": "I was added to a channel called \"%s\" with %sID %d", + "unauthorized_group_report": "I was added to a group called \"%s\" with %sID %d by %s (ID: %d)", + "unauthorized_report_handle_part": "handle @%s and ", + "user_banned": "🚔 %s banned %s (ID: %d).\nFor: %s", + "user_banned_report": "🚔 %s banned %s (ID: %d).\nFor: %s", + "user_unauthorized": "Unauthorized", + "private_button_blacklist": "🚫 Blacklist", + "private_button_blacklist_pack": "🚫 Blacklist pack", + "private_button_whitelist": "✅ Whitelist", + "private_button_remove": "🙏 Pardon", + "private_button_remove_pack": "🙏 Pardon pack" +} diff --git a/localization/it.json b/localization/it.json new file mode 100644 index 0000000..9b5eaca --- /dev/null +++ b/localization/it.json @@ -0,0 +1,97 @@ +{ + "antiuserbot_possible_attack": "🤖🤖🤖 POSSIBILE ATTACCO USERBOT 🤖🤖🤖", + "antiuserbot_possible_userbot_restriction": "L'utente %s è stato limitato perché è un sospetto userbot.", + "automod_new_report_by": "⚠️ Nuova segnalazione da %s (ID: %d)", + "automod_new_report_by_with_reply": "⚠️ %s (ID: %d) ha segnalato un messaggio di %s (ID: %d)", + "automod_removed_message_forwarded_channel": "🚮️ Rimosso un messaggio inoltrato da un canale da %s (ID: %d)", + "automod_removed_message_forwared_blacklisted_handle": "🚮️ Rimosso un messaggio inoltrato da un username blacklistato da %s (ID: %d)", + "automod_removed_message_via_blacklsited_inline_bot": "️🚮 Rimosso un messaggio inviato da %s (ID: %d) tramite un bot inline blacklistato", + "automod_removed_nsfw_animation": "Rimossa un'animazione NSFW postata da %s (ID: %d).\nConteneva: %s.\nConfidenza: %f%%", + "automod_removed_nsfw_photo": "Rimossa una immagine NSFW postata da %s (ID: %d).\nConteneva: %s.\nConfidenza: %f%%", + "automod_removed_nsfw_video": "Rimosso un video NSFW postato da %s (ID: %d).\nConteneva: %s.\nConfidenza: %f%%", + "automod_removed_nsfw_media": "Rimosso un media NSFW postato da %s (ID: %d).\nConteneva: %s.\nConfidenza: %f%%", + "automod_removed_unwanted_handle": "🚮️ Rimosso un username blacklistato postato da %s (ID: %d)", + "automod_removed_unwanted_link": "🚮️ Rimosso un link %s postato da %s (ID: %d)", + "automod_unwanted_link_ban_reason": "aver postato un link da un indirizzo blacklistato: %s", + "automod_unwanted_link_unable_to_ban": "Impossibile bannare %s (ID: %d) per aver mandato %s link: %s", + "automod_unwanted_link_unable_to_mute": "Impossibile mutare %s (ID: %d) per aver mandato %s link: %s", + "automod_unwanted_link_user_muted": "🚔 %s ha mutato %s (ID: %d).\nPer: %s", + "buttons_backup": "Backup", + "buttons_ban_user": "Banna", + "buttons_banworthy": "Banna", + "buttons_mark_as_handled": "Segna come gestito", + "buttons_mod_user": "Rendi l'utente moderatore", + "buttons_remove_only": "Rimuovi e basta", + "buttons_report": "Segnalazione", + "buttons_reported_message": "Messaggio segnalato", + "buttons_rules_read": "Ho letto le regole", + "buttons_telegram": "Telegram", + "buttons_unban_user": "Sbanna", + "buttons_unrestrict_user": "Leva le restrizioni", + "buttons_whitelist_media": "Whitelista", + "callback_first_step_error": "Errore. Prova di nuovo.", + "callback_first_step_perform_second": "Premi nuovamente il bottone per autorizzare l'azione.", + "callback_hosts_result": "L'operazione richiesta è risultata %v\n\nHostname:\t%s\nBanna:\t%s\nUrl Telegram:\t%s", + "callback_media_whitelisted": "🛃 Whitelistato da %s 🛃\n\n%s", + "callback_mods_unable_to_mod": "Impossibile rendere l'utente con ID %d un moderatore (richiesto da %s): %s", + "callback_report_handled": "✅ Gestito da %s ✅\n\n%s", + "callback_request_action_already_authorized": "L'azione è già stata autorizzata", + "callback_request_performed_shortly": "L'azione verrà svolta tra poco", + "callback_twostep_different_action_already_approved": "Un'altra azione è già stata approvata ed è in esecuzione.", + "callback_twostep_different_action_alredy_requested": "Qualcuno ha già chiesto un'azione differente per questo messaggio.\nProva di nuovo fra qualche secondo.", + "callback_user_unbanned": "🛃 Utente sbannato da %s 🛃\n\n%s", + "callback_user_unrestricted": "🛃 Restrizioni tolte da %s 🛃\n\n%s", + "callback_verification_error_occurred": "C'è stato un errore. Contatta un amministratore in privato", + "emegencymode_unable_to_get_pictures": "Impossibile ottenere le immagini del profilo durante un'emergenza per l'utente %s", + "emergencymode_active": "🚨🚨🚨 LA MODALITÀ EMERGENZA È ATTIVA 🚨🚨🚨\nTutti gli utenti dovranno essere approvati per le prossime %s\nInviami /emergencymode in privato per terminare prima l'emergenza.", + "emergencymode_approve_user": "Approva utente", + "emergencymode_cancelled": "👮 La modalità emergenza è stata cancellata manualmente 👮", + "emergencymode_expired": "👮 La modalità emergenza è finita 👮", + "emergencymode_user_restricted": "👮 %s è stato limitato per essersi unito durante un'emergenza 👮\nnInviami /emergencyMode in privato per terminare prima l'emergenza.", + "feature_unimplemented": "Non implementato", + "moderator_demoted": "🤺 %s (ID: %d) ha tolto i poteri di moderazione a %s (ID: %d) 🤺", + "moderator_cannot_be_removed": "🛂 Impossibile rimuovere %s (ID: %d) dalla tabella dei moderatori 🛂", + "private_blacklistall_active": "Ricordati che è attiva la modalità BlacklistAll!\nInviami /blacklistAll per disattivarla", + "private_blacklistall_handles_added_correctly": "Username aggiunti alla blacklist!\n%s", + "private_blacklistall_media_added_correctly": "Media aggiunti alla blacklist!\n%s", + "private_blacklistall_media_already_blacklisted": "Media già presenti nella blacklist!\n%s", + "private_blacklistall_media_unable_to_add": "Impossibile aggiungere il media alla blacklist!\n%s", + "private_blacklistall_stickerpack_added_correctly": "Sticker pack aggiunto alla blacklist!\n%s", + "private_blacklistall_stickerpack_already_blacklisted": "Sticker pack già in blacklist!\n%s", + "private_blacklistall_stickerpack_unable_to_add": "Impossibile aggiungere lo sticker pack alla blacklist!\n%s", + "private_checks_ban_entry": "Da %d il %s\nPer: %s\n\n", + "private_checks_ban_preamble": "L'utente %s è stato bannato %d volte\n\n", + "private_checks_could_not_understand": "Non capisco cosa devo controllare 😭", + "private_checks_no_db_info": "L'utente %s è 🚫 bannato ma non ci sono dati nel database", + "private_checks_not_banned_or_restricted": "%s ❌ non è al momento bannato o limitato 😇", + "private_checks_profile_hidden_from_forwards": "📡 L'utente ha deciso di nascondere il suo profilo dai messaggi inoltrati. 📡\nProva ad usare il comando /check specificando l'username o l'user id.", + "private_checks_restriction_report": "L'utente %s è limitato fino al %s\nLimitazioni:\n%s", + "private_checks_unable_to_get_user_info": "Impossibile ricevere informazioni per l'utente: %s", + "private_checks_unable_to_resolve_username": "Impossibile risolvere l'username: %s", + "private_checks_username_is_group_or_channel": "L'username @%s è di un gruppo o di un canale", + "private_command_added_to_blacklist": "Aggiunto alla blacklist!", + "private_command_added_to_whitelist": "Whitelistato con successo!", + "private_command_blacklistall_status": "La modalità blacklist all è ora %v", + "private_command_operation_cancelled": "Operazione cancellata", + "private_command_removed_from_blacklist": "Rimosso con successo dalla blacklist!", + "private_command_removed_successfully": "Rimosso con successo!", + "private_command_unable_to_add_to_blacklist": "Impossibile aggiungere alla blacklist 😞", + "private_command_unable_to_remove": "Impossibile rimuovere 😞", + "private_command_unable_to_remove_from_blacklist": "Impossibile rimuovere dalla blacklist 😞", + "private_command_unable_to_whitelist": "Impossibile whitelistare 😞", + "private_emergencymode_cancelled": "La modalità emergenza è stata cancellata", + "private_handle_what_to_do": "Che devo fare con questo username?", + "private_hosts_what_to_do": "Scegli come aggiungere questo host", + "private_media_what_to_do": "Che dobbiamo fare con questo media?", + "private_mods_are_you_sure": "Sei sicuro di voler rendere %s un moderatore?", + "private_mods_unable_to_find_user": "Impossibile trovare l'utente nella chat 😨", + "private_mods_unable_to_parse_userid": "Impossibile fare parsing dell'userID", + "private_sticker_what_to_do": "Che dobbiamo fare con questo sticker?", + "shitposting_bot_active": "Shitposting admin-bot versione %s, build %s\nAutorizzato sull'account %s", + "unauthorized_channel_report": "Sono stato aggiunto ad un canale chiamato \"%s\" con %sID %d", + "unauthorized_group_report": "Sono stato aggiunto ad un gruppo \"%s\" con %sID %d da %s (ID: %d)", + "unauthorized_report_handle_part": "username @%s e ", + "user_banned": "🚔 %s ha bannato %s (ID: %d).\nPer: %s", + "user_banned_report": "🚔 %s ha bannato %s (ID: %d).\nPer: %s", + "user_unauthorized": "Non autorizzato" +} diff --git a/localization/localization.go b/localization/localization.go new file mode 100644 index 0000000..ed8e989 --- /dev/null +++ b/localization/localization.go @@ -0,0 +1,19 @@ +package localization + +import ( + "github.com/bykovme/gotrans" +) + +var ( + language string +) + +// SetLanguage sets the language for the bot +func SetLanguage(toSet string) { + language = toSet +} + +// GetString gets the translation given a key +func GetString(key string) string { + return gotrans.Tr(language, key) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..8132aa6 --- /dev/null +++ b/main.go @@ -0,0 +1,225 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + log "github.com/sirupsen/logrus" + + "github.com/shitpostingio/admin-bot/adminbot" + "github.com/shitpostingio/admin-bot/analysisadapter" + "github.com/shitpostingio/admin-bot/api/botapi" + "github.com/shitpostingio/admin-bot/api/cache" + "github.com/shitpostingio/admin-bot/api/tdlib" + "github.com/shitpostingio/admin-bot/config/structs" + "github.com/shitpostingio/admin-bot/database/database" + "github.com/shitpostingio/admin-bot/database/documentstore" + "github.com/shitpostingio/admin-bot/defense/antispam" + "github.com/shitpostingio/admin-bot/localization" + limiter "github.com/shitpostingio/admin-bot/ratelimiter" + "github.com/shitpostingio/admin-bot/repository" + "github.com/shitpostingio/admin-bot/updates" + "github.com/shitpostingio/admin-bot/utility" + modCache "github.com/shitpostingio/admin-bot/utility/cache" + + "github.com/bykovme/gotrans" + + "github.com/shitpostingio/admin-bot/automod" + "github.com/shitpostingio/admin-bot/callback" + configuration "github.com/shitpostingio/admin-bot/config" + "github.com/shitpostingio/admin-bot/private" +) + +var ( + //configFilePath is the path to the config file + configFilePath string + + //Version represents the current admin-bot version, a compile-time value + Version string + + //Build is the git tag for the current version + Build string + + //debug tells whether the bot is in debug mode or not + debug bool + + //polling tells whether to retrieve updates via polling or webhook + polling bool + + //testing tells whether we're in testing mode or not + testing bool +) + +func main() { + + setCLIParams() + + /************************************************* + * CONNECT TO SERVICES * + *************************************************/ + cfg, err := configuration.Load(configFilePath, testing) + if err != nil { + log.Fatal("Unable to load configuration:", err) + } + + err = os.RemoveAll(cfg.Tdlib.FilesDirectory) + if err != nil { + log.Error("Unable to delete tdlib files directory", err) + } + + err = os.RemoveAll(cfg.Tdlib.DatabaseDirectory) + if err != nil { + log.Error("Unable to delete tdlib database directory", err) + } + + bot, err := botapi.Authorize(cfg.Telegram.BotToken, debug) + if err != nil { + log.Fatal("Unable to connect to the bot apis", err) + } + + _, err = tdlib.Authorize(cfg.Telegram.BotToken, &cfg.Tdlib) + if err != nil { + utility.LogFatal("Unable to log into the bot via Tdlib:", err) + } + + documentstore.Connect(&cfg.DocumentStore) + + /************************************************* + * LOAD LOCALIZATION FILES * + *************************************************/ + err = gotrans.InitLocales(cfg.AdminBot.LocalizationPath) + if err != nil { + utility.LogFatal("Unable to load language files:", err) + } + + localization.SetLanguage(cfg.AdminBot.Language) + + /************************************************* + * CACHE MODS DATA * + *************************************************/ + _ = database.UpdateModeratorsDetails(cfg.Telegram.GroupID, bot) + adminMap, err := modCache.CreateAdminsCache(documentstore.ModeratorsCollection) + if err != nil { + utility.LogFatal("Unable to cache admins:", err) + } + + modMap, err := modCache.CreateModsCache(adminMap, bot, &cfg.Telegram) + if err != nil { + utility.LogFatal("Unable to cache mods:", err) + } + + /************************************************* + * POPULATE REPOSITORY * + *************************************************/ + repository.SetBot(bot) + repository.SetConfig(&cfg) + repository.SetAdmins(adminMap) + repository.SetMods(modMap) + repository.SetTestingStatus(testing) + repository.SetDebugStatus(debug) + + /************************************************* + * START MONITORING * + *************************************************/ + cache.CreateActionsCache() + analysisadapter.Start(cfg.Telegram.BotToken, &cfg.FPServer) + limiter.StartRateLimiter(cfg.RateLimiter.MaxActionsPerSecond) + automod.StartDefensiveRoutines() + configuration.WatchConfig(&cfg) + + /************************************************* + * START HANDLING UPDATES * + *************************************************/ + updatesChannel := updates.GetUpdatesChannel(polling, bot, &cfg) + if updatesChannel == nil { + utility.LogFatal("Update channel nil") + } + + authorizationText := fmt.Sprintf(localization.GetString("shitposting_bot_active"), Version, Build, bot.Self.ID, bot.Self.UserName) + _ = adminbot.SendSilentTextMessage(cfg.Telegram.ReportChannelID, authorizationText, false) + log.Info(authorizationText) + handleUpdates(updatesChannel, &cfg) + +} + +//setCLIParams parses the command line parameters and sets defaults in case they're missing +func setCLIParams() { + flag.BoolVar(&polling, "polling", false, "use polling instead of webhooks") + flag.BoolVar(&testing, "testing", false, "use testing mode") + flag.BoolVar(&debug, "debug", false, "activate all the debug features") + flag.StringVar(&configFilePath, "config", "./config.toml", "configuration file path") + flag.Parse() +} + +//handleUpdates iterates on the updates and passes them onto the handlers +func handleUpdates(updates tgbotapi.UpdatesChannel, cfg *structs.Config) { + for update := range updates { + switch { + case update.CallbackQuery != nil: + go callback.HandleCallback(update.CallbackQuery) + case update.EditedMessage != nil: + go handleMessage(update.EditedMessage, cfg) + case update.Message != nil: + go handleMessage(update.Message, cfg) + case update.ChannelPost != nil: + go handleChannelPost(update.ChannelPost, cfg) + } + } +} + +//handleMessage handles `Message`s and EditedMessages +func handleMessage(msg *tgbotapi.Message, cfg *structs.Config) { + + /* PRIVATE MESSAGES ARE NOT SPAM */ + if msg.Chat.IsPrivate() { + private.HandlePrivateChat(msg) + return + } + + /* SAVE ALL PUBLIC MESSAGES */ + go documentstore.StoreMessage(msg) + + /* LEAVE THE GROUP IF THE BOT SHOULDN'T BE IN IT */ + if msg.Chat.ID != cfg.Telegram.GroupID && msg.LeftChatMember == nil { + adminbot.LeaveUnauthorizedGroup(msg) + return + } + + /* SEND EVERY MESSAGE TO THE ANTI SPAM */ + antispam.HandleMessage(msg) + + /* SEND THE MESSAGE TO THE APPROPRIATE HANDLER */ + switch { + case msg.Text != "": + automod.HandleText(msg) + case msg.Photo != nil: + automod.HandleMedia(msg, true) + case msg.Video != nil: + automod.HandleMedia(msg, true) + case msg.Animation != nil: + automod.HandleMedia(msg, true) + case msg.Sticker != nil: + automod.HandleSticker(msg) + case msg.Document != nil: + automod.HandleMedia(msg, false) + case msg.NewChatMembers != nil: + automod.HandleNewChatMember(msg) + case msg.Voice != nil: + automod.HandleMedia(msg, false) + case msg.Audio != nil: + automod.HandleMedia(msg, false) + case msg.VideoNote != nil: + automod.HandleMedia(msg, false) + case msg.Game != nil: + automod.HandleGame(msg) + } +} + +//handleChannelPost handles `ChannelPost`s +func handleChannelPost(post *tgbotapi.Message, cfg *structs.Config) { + if post.Chat.ID != cfg.Telegram.ReportChannelID && post.Chat.ID != cfg.Telegram.BackupChannelID { + adminbot.LeaveUnauthorizedChannel(post) + } +} diff --git a/mongo_admin_indexes.js b/mongo_admin_indexes.js new file mode 100644 index 0000000..21c631a --- /dev/null +++ b/mongo_admin_indexes.js @@ -0,0 +1,35 @@ +// +use automod + +// bans +// Index on TelegramID, descending +db.bans.createIndex({telegramid: -1}) + +// hostname +// Index on Host +db.hostnames.createIndex( { host: 1 }, { unique: true } ) + +// media +// Index on fileid +db.media.createIndex( { fileid: 1 }, { unique: true } ) + +// Index on fileuniqueid +db.media.createIndex( { fileuniqueid: 1 }, { sparse: true, unique: true } ) + +// Multikey index on histogramaverage and histogramsum +db.media.createIndex( { histogramaverage: 1, histogramsum: 1 } ) + +// moderators +// Index on telegramid +db.moderators.createIndex({telegramid: 1}, { unique: true }) + +// sources +// Use sparse index to allow for null values while keeping the unique-ness +// Index on telegramid +db.sources.createIndex({telegramid: 1}, { sparse: true, unique: true }) + +// Index on username +db.sources.createIndex( { username: 1 }, { sparse: true, unique: true }) + +// stickerpacks +db.stickerpacks.createIndex( { setname: 1 }, { unique: true } ) diff --git a/private/commands.go b/private/commands.go new file mode 100644 index 0000000..6a0654a --- /dev/null +++ b/private/commands.go @@ -0,0 +1,223 @@ +package private + +import ( + "fmt" + "strings" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/shitpostingio/admin-bot/adminbot" + "github.com/shitpostingio/admin-bot/defense/emergencymode" + "github.com/shitpostingio/admin-bot/localization" + "github.com/shitpostingio/admin-bot/repository" + "github.com/shitpostingio/admin-bot/telegram" + + "github.com/shitpostingio/admin-bot/database/database" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +//HandlePrivateCommand allows admins to give commands to the bot via private messages +func HandlePrivateCommand(msg *tgbotapi.Message) { + + command := strings.ToLower(msg.Command()) + + if command == "cancel" { + text := localization.GetString("private_command_operation_cancelled") + markup := tgbotapi.NewRemoveKeyboard(true) + _ = adminbot.SendPlainTextMessageWithMarkup(msg.Chat.ID, text, markup, false) + return + } + + if command == "blacklistall" { + isBlacklistAllMode = !isBlacklistAllMode + text := fmt.Sprintf(localization.GetString("private_command_blacklistall_status"), isBlacklistAllMode) + _ = adminbot.SendPlainTextMessage(msg.Chat.ID, text, false) + return + } + + if command == "emergencymode" { + toggleEmergencyMode(msg) + return + } + + /* HANDLE COMMANDS */ + var text string + + if msg.CommandArguments() != "" { + switch { + case strings.HasPrefix(command, "ban"): + text = handleBanCommands(command, msg) + case strings.HasPrefix(command, "pardon"): + text = handlePardonCommands(command, msg) + case strings.HasPrefix(command, "whitelist"): + text = handleWhitelistCommands(command, msg) + case strings.HasPrefix(command, "remove"): + text = handleRemoveCommands(command, msg) + default: + handleOtherCommands(command, msg) + } + + if text != "" { + _ = adminbot.SendPlainTextMessageWithMarkup(msg.Chat.ID, text, tgbotapi.NewRemoveKeyboard(true), false) + } + } +} + +func handleBanCommands(command string, msg *tgbotapi.Message) (reply string) { + + var err error + + switch command { + case "banpack": + _, err = database.BlacklistStickerPack(msg.CommandArguments(), msg.From.ID) + case "banmedia": + _, err = database.BlacklistMedia(msg.CommandArguments(), msg.CommandArguments(), msg.From.ID) + case "banhandle": + _, err = database.BlacklistSource(0, msg.CommandArguments(), msg.From.ID) + case "banhost": + setupHostnameBlacklist(msg) + return + default: + return localization.GetString("feature_unimplemented") + } + + if err != nil { + reply = localization.GetString("private_command_unable_to_add_to_blacklist") + } else { + reply = localization.GetString("private_command_added_to_blacklist") + } + + return reply +} + +func handlePardonCommands(command string, msg *tgbotapi.Message) (reply string) { + var err error + + switch command { + case "pardonpack": + err = database.PardonStickerPack(msg.CommandArguments()) + case "pardonmedia": + err = database.RemoveMedia(msg.CommandArguments(), msg.CommandArguments()) + case "pardonhandle": + err = database.RemoveSource(0, msg.CommandArguments()) + case "pardonhost": + err = database.PardonHostName(msg.CommandArguments()) + default: + return localization.GetString("feature_unimplemented") + } + + if err != nil { + reply = localization.GetString("private_command_unable_to_remove_from_blacklist") + } else { + reply = localization.GetString("private_command_removed_from_blacklist") + } + + return reply +} + +func handleWhitelistCommands(command string, msg *tgbotapi.Message) (reply string) { + + var err error + + switch command { + case "whitelistmedia": + _, err = database.WhitelistMedia(msg.CommandArguments(), msg.CommandArguments(), msg.From.ID) + case "whitelistchannel": + _, err = database.WhitelistSource(0, msg.CommandArguments(), msg.From.ID) + default: + return localization.GetString("feature_unimplemented") + } + + if err != nil { + reply = localization.GetString("private_command_unable_to_whitelist") + } else { + reply = localization.GetString("private_command_added_to_whitelist") + } + + return reply +} + +func handleRemoveCommands(command string, msg *tgbotapi.Message) (reply string) { + + var err error + + switch command { + case "removechannel": + err = database.RemoveSource(0, msg.CommandArguments()) + default: + return localization.GetString("feature_unimplemented") + } + + if err != nil { + reply = localization.GetString("private_command_unable_to_remove") + } else { + reply = localization.GetString("private_command_removed_successfully") + } + + return reply +} + +func handleOtherCommands(command string, inputMessage *tgbotapi.Message) { + + switch command { + case "mod": + setupModAddition(inputMessage) + case "check": + attemptCheckByText(inputMessage) + default: + _ = adminbot.SendPlainTextMessage(inputMessage.Chat.ID, localization.GetString("feature_unimplemented"), false) + } +} + +func toggleEmergencyMode(msg *tgbotapi.Message) { + + if emergencymode.IsEmergency() { + emergencymode.End() + _ = adminbot.SendPlainTextMessage(repository.GetTelegramConfiguration().ReportChannelID, localization.GetString("private_emergencymode_cancelled"), false) + return + } + + var emergencyModeDuration time.Duration + var err error + + /* THE USER SPECIFIED THE DURATION */ + if msg.CommandArguments() != "" { + emergencyModeDuration, err = time.ParseDuration(msg.CommandArguments()) + } + + /* USE DEFAULT IF NO DURATION SPECIFIED OR ERROR WHEN PARSING IT */ + if msg.CommandArguments() == "" || err != nil { + emergencyModeDuration = getAppropriateEmergencyDuration(repository.GetTestingStatus()) + } + + emergencyModeText := fmt.Sprintf(localization.GetString("emergencymode_active"), emergencyModeDuration) + emergencyUpdateChannel := emergencymode.Start() + + err = adminbot.SendPlainTextMessage(repository.GetTelegramConfiguration().ReportChannelID, emergencyModeText, false) + if err != nil { + log.Error(fmt.Sprintf("Unable to send the emergency mode message on the report channel: %s", err.Error())) + } + + _ = adminbot.SendPlainTextMessage(msg.Chat.ID, emergencyModeText, false) + log.Warn(fmt.Sprintf("%s TRIGGERED EMERGENCY MODE", telegram.GetNameOrUsername(msg.From))) + + go func() { + emergencyExpirationTimer := time.NewTimer(emergencyModeDuration) + + var emergencyEndedText string + select { + case <-emergencyExpirationTimer.C: + emergencymode.End() + emergencyEndedText = localization.GetString("emergencymode_expired") + case <-emergencyUpdateChannel: + emergencyEndedText = localization.GetString("emergencymode_cancelled") + emergencyExpirationTimer.Stop() + } + + log.Info(emergencyEndedText) + _ = adminbot.SendPlainTextMessage(repository.GetTelegramConfiguration().ReportChannelID, emergencyEndedText, false) + }() + +} diff --git a/private/handlers.go b/private/handlers.go new file mode 100644 index 0000000..62493b7 --- /dev/null +++ b/private/handlers.go @@ -0,0 +1,171 @@ +package private + +import ( + "fmt" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/shitpostingio/admin-bot/adminbot" + "github.com/shitpostingio/admin-bot/callback/buttons" + "github.com/shitpostingio/admin-bot/database/database" + "github.com/shitpostingio/admin-bot/localization" + "github.com/shitpostingio/admin-bot/repository" + "github.com/shitpostingio/admin-bot/telegram" +) + +var isBlacklistAllMode bool + +func init() { + isBlacklistAllMode = false +} + +// HandlePrivateChat handles private messages sent to the bot +func HandlePrivateChat(msg *tgbotapi.Message) { //nolint:gocyclo + + if !repository.Admins[msg.From.ID] { + return + } + + switch { + case msg.Photo != nil: + HandlePrivateMedia(msg) + case msg.Video != nil: + HandlePrivateMedia(msg) + case msg.Animation != nil: + HandlePrivateMedia(msg) + case msg.Document != nil: + HandlePrivateMedia(msg) + case msg.Voice != nil: + HandlePrivateMedia(msg) + case msg.Audio != nil: + HandlePrivateMedia(msg) + case msg.VideoNote != nil: + HandlePrivateMedia(msg) + case msg.Sticker != nil: + HandlePrivateSticker(msg) + case msg.IsCommand(): + HandlePrivateCommand(msg) + case msg.Text != "": + HandlePrivateText(msg) + } +} + +//HandlePrivateText allows admins to blacklist or pardon handles via private messages +func HandlePrivateText(msg *tgbotapi.Message) { + + if msg.ForwardSenderName != "" { + _ = adminbot.SendReplyPlainTextMessage(msg.MessageID, msg.Chat.ID, localization.GetString("private_checks_profile_hidden_from_forwards"), false) + return + } + + switch { + case msg.ForwardFrom != nil: + checkUserStatus(msg, msg.ForwardFrom) + default: + checkForHandles(msg) + } + +} + +// HandlePrivateMedia handles media sent to the bot in private +func HandlePrivateMedia(msg *tgbotapi.Message) { + + var text string + var markup tgbotapi.InlineKeyboardMarkup + + uniqueID, fileID := telegram.GetFileIDFromMessage(msg) + media, err := database.FindMediaByFileID(uniqueID, fileID) + + if isBlacklistAllMode { + + // blacklist it in case of error, better safe than sorry! + if err != nil || media.IsWhitelisted { + + _, err := database.BlacklistMedia(uniqueID, fileID, msg.From.ID) + if err != nil { + text = fmt.Sprintf(localization.GetString("private_blacklistall_media_unable_to_add"), localization.GetString("blacklistall_active")) + } else { + text = fmt.Sprintf(localization.GetString("private_blacklistall_media_added_correctly"), localization.GetString("blacklistall_active")) + } + + } else { + + text = fmt.Sprintf(localization.GetString("private_blacklistall_media_already_blacklisted"), localization.GetString("blacklistall_active")) + + } + + _ = adminbot.SendReplyPlainTextMessage(msg.MessageID, msg.Chat.ID, text, false) + + } else { + + text = localization.GetString("private_media_what_to_do") + if err == nil { + fileID = media.FileID + } + + if err != nil || media.IsWhitelisted { + markup = buttons.CreateKeyboardWithOneRow(buttons.CreatePrivateBlacklistMediaButton()) + } else { + markup = buttons.CreateKeyboardWithOneRow(buttons.CreatePrivatePardonMediaButton(), buttons.CreatePrivateWhitelistMediaButton()) + } + + _ = adminbot.SendReplyTextMessageWithMarkup(msg.MessageID, msg.Chat.ID, text, markup, false) + + } + +} + +//HandlePrivateSticker allows admins to blacklist or pardon stickers/sticker packs via private messages +func HandlePrivateSticker(msg *tgbotapi.Message) { + + var text string + + // In BlacklistAllMode we will blacklist the sticker pack + if isBlacklistAllMode { + + if !database.StickerPackIsBlacklisted(msg.Sticker.SetName) { + + _, err := database.BlacklistStickerPack(msg.Sticker.SetName, msg.From.ID) + if err != nil { + text = fmt.Sprintf(localization.GetString("private_blacklistall_stickerpack_unable_to_add"), localization.GetString("blacklistall_active")) + } else { + text = fmt.Sprintf(localization.GetString("private_blacklistall_stickerpack_added_correctly"), localization.GetString("blacklistall_active")) + } + + } else { + + text = fmt.Sprintf(localization.GetString("private_blacklistall_stickerpack_already_blacklisted"), localization.GetString("blacklistall_active")) + + } + + _ = adminbot.SendReplyPlainTextMessage(msg.MessageID, msg.Chat.ID, text, false) + + } else { + + text = localization.GetString("private_sticker_what_to_do") + + var markup tgbotapi.InlineKeyboardMarkup + var stickerPackAction tgbotapi.InlineKeyboardButton + + // If the sticker belongs to no sticker pack we won't show the button + if msg.Sticker.SetName != "" { + + if !database.StickerPackIsBlacklisted(msg.Sticker.SetName) { + stickerPackAction = buttons.CreatePrivateBlacklistStickerPackButton() + } else { + stickerPackAction = buttons.CreatePrivatePardonStickerPackButton() + } + + } + + media, err := database.FindMediaByFileID(msg.Sticker.FileUniqueID, msg.Sticker.FileID) + if err != nil || media.IsWhitelisted { + markup = buttons.CreateKeyboardWithOneRow(buttons.CreatePrivateBlacklistStickerButton(), stickerPackAction) + } else { + markup = buttons.CreateKeyboardWithOneRow(buttons.CreatePrivatePardonStickerButton(), buttons.CreatePrivateWhitelistStickerButton(), stickerPackAction) + } + + _ = adminbot.SendReplyPlainTextMessageWithMarkup(msg.MessageID, msg.Chat.ID, text, markup, false) + + } + +} diff --git a/private/user_checks.go b/private/user_checks.go new file mode 100644 index 0000000..612496d --- /dev/null +++ b/private/user_checks.go @@ -0,0 +1,195 @@ +package private + +import ( + "fmt" + "strconv" + "strings" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/shitpostingio/admin-bot/adminbot" + "github.com/shitpostingio/admin-bot/api/tdlib" + "github.com/shitpostingio/admin-bot/callback/buttons" + "github.com/shitpostingio/admin-bot/database/database" + "github.com/shitpostingio/admin-bot/entities" + "github.com/shitpostingio/admin-bot/localization" + "github.com/shitpostingio/admin-bot/repository" + "github.com/shitpostingio/admin-bot/telegram" + "github.com/shitpostingio/admin-bot/utility" +) + +func attemptCheckByText(msg *tgbotapi.Message) { + + // Try checking usernames first + mentions := telegram.GetAllMentions(msg.Text, telegram.GetMessageEntities(msg), msg.ReplyMarkup) + if len(mentions) != 0 { + checkUserByUsername(msg, mentions[0]) + return + } + + //check by id + userID, err := strconv.ParseInt(msg.CommandArguments(), 10, 64) + if err == nil { + checkUserByID(msg, userID) + return + } + + _ = adminbot.SendReplyPlainTextMessage(msg.MessageID, msg.Chat.ID, localization.GetString("private_checks_could_not_understand"), false) + +} + +func checkUserByUsername(msg *tgbotapi.Message, username string) { + + if tdlib.IsGroupOrChannelUsername(username) { + result := fmt.Sprintf(localization.GetString("private_checks_username_is_group_or_channel"), username) + _ = adminbot.SendReplyPlainTextMessage(msg.MessageID, msg.Chat.ID, result, false) + return + } + + chat, err := tdlib.ResolveUsername(username) + if err != nil { + result := fmt.Sprintf(localization.GetString("private_checks_unable_to_resolve_username"), err) + _ = adminbot.SendReplyPlainTextMessage(msg.MessageID, msg.Chat.ID, result, false) + return + } + + tdlUser, err := tdlib.GetUserByID(chat.ID) + if err != nil { + result := fmt.Sprintf(localization.GetString("private_checks_unable_to_get_user_info"), err) + _ = adminbot.SendReplyPlainTextMessage(msg.MessageID, msg.Chat.ID, result, false) + return + } + + tgUser := tdlib.GetTgbotapiUserFromTdlibUser(tdlUser) + checkUserStatus(msg, tgUser) + +} + +func checkUserByID(msg *tgbotapi.Message, userID int64) { + + tdlibUser, err := tdlib.GetUserByID(userID) + if err != nil { + result := fmt.Sprintf(localization.GetString("private_checks_unable_to_get_user_info"), err) + _ = adminbot.SendPlainTextMessage(msg.Chat.ID, result, false) + return + } + + tgUser := tdlib.GetTgbotapiUserFromTdlibUser(tdlibUser) + checkUserStatus(msg, tgUser) + +} + +func checkUserStatus(msg *tgbotapi.Message, user *tgbotapi.User) { + + // Retrieve user status from Telegram and check the data in our database. + chatMember, err := adminbot.GetChatMember(user.ID, repository.GetTelegramConfiguration().GroupID) + if err != nil { + result := fmt.Sprintf(localization.GetString("private_checks_unable_to_get_user_info"), err) + _ = adminbot.SendReplyTextMessage(msg.MessageID, msg.Chat.ID, result, false) + } + + bans, _ := database.GetBansForTelegramID(user.ID) + + if telegram.ChatMemberIsBanned(&chatMember) { + banStatus(msg, user, bans) + return + } + + // If we have bans related to the user in our database + // but the user has been unbanned, soft-delete them. + if len(bans) != 0 { + _ = database.MarkUserAsUnbanned(user.ID) + } + + if telegram.ChatMemberIsRestricted(&chatMember) { + restrictionStatus(msg, &chatMember) + return + } + + result := fmt.Sprintf(localization.GetString("private_checks_not_banned_or_restricted"), user.ID, telegram.GetName(user)) + _ = adminbot.SendReplyTextMessage(msg.MessageID, msg.Chat.ID, result, false) + +} + +func banStatus(msg *tgbotapi.Message, user *tgbotapi.User, bans []entities.Ban) { + + timesBanned := len(bans) + if timesBanned == 0 { + + result := fmt.Sprintf(localization.GetString("private_checks_no_db_info"), user.ID, telegram.GetName(user)) + markup := buttons.CreateKeyboardWithOneRow(buttons.CreateTgUnbanButton(user.ID)) + _ = adminbot.SendReplyTextMessageWithMarkup(msg.MessageID, msg.Chat.ID, result, markup, false) + return + + } + + builder := strings.Builder{} + builder.WriteString(fmt.Sprintf(localization.GetString("private_checks_ban_preamble"), user.ID, telegram.GetName(user), timesBanned)) + + for _, ban := range bans { + + builder.WriteString(fmt.Sprintf(localization.GetString("private_checks_ban_entry"), ban.BannedBy, ban.BannedBy, + utility.FormatDate(ban.BanDate), ban.Reason)) + + } + + result := builder.String() + markup := buttons.CreateKeyboardWithOneRow(buttons.CreateUnbanButton(user.ID)) + _ = adminbot.SendReplyTextMessageWithMarkup(msg.MessageID, msg.Chat.ID, result, markup, false) + +} + +func restrictionStatus(msg *tgbotapi.Message, chatMember *tgbotapi.ChatMember) { + + restrictions := telegram.GetPermissionsFromChatMember(chatMember) + restrictedUser := telegram.GetName(chatMember.User) + restrictionEnd := utility.FormatUnixDate(chatMember.UntilDate) + + result := fmt.Sprintf(localization.GetString("private_checks_restriction_report"), chatMember.User.ID, restrictedUser, restrictionEnd, emojifyRestrictions(restrictions)) + markup := buttons.CreateKeyboardWithOneRow(buttons.CreateUnrestrictButton(chatMember.User.ID)) + _ = adminbot.SendReplyTextMessageWithMarkup(msg.MessageID, msg.Chat.ID, result, markup, false) + +} + +func emojifyRestrictions(r *tgbotapi.ChatPermissions) string { + + if r == nil { + return "" + } + + sb := strings.Builder{} + + // Send Messages + sb.WriteString(utility.EmojifyBool(r.CanSendMessages)) + sb.WriteString("\tCan send messages\n") + + // Send Media Messages + sb.WriteString(utility.EmojifyBool(r.CanSendMediaMessages)) + sb.WriteString("\tCan send media\n") + + // Send Polls + sb.WriteString(utility.EmojifyBool(r.CanSendPolls)) + sb.WriteString("\tCan send polls\n") + + // Send Other Messages + sb.WriteString(utility.EmojifyBool(r.CanSendOtherMessages)) + sb.WriteString("\tCan send stickers and gifs\n") + + // Add Web Page Previews + sb.WriteString(utility.EmojifyBool(r.CanAddWebPagePreviews)) + sb.WriteString("\tCan add web page previews\n") + + // Change info + sb.WriteString(utility.EmojifyBool(r.CanChangeInfo)) + sb.WriteString("\tCan change group info\n") + + // Invite users + sb.WriteString(utility.EmojifyBool(r.CanInviteUsers)) + sb.WriteString("\tCan invite users\n") + + // Pin messages + sb.WriteString(utility.EmojifyBool(r.CanPinMessages)) + sb.WriteString("\tCan pin messages\n") + + return sb.String() + +} diff --git a/private/utilities.go b/private/utilities.go new file mode 100644 index 0000000..e884935 --- /dev/null +++ b/private/utilities.go @@ -0,0 +1,125 @@ +package private + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/shitpostingio/admin-bot/adminbot" + "github.com/shitpostingio/admin-bot/localization" + "github.com/shitpostingio/admin-bot/repository" + "github.com/shitpostingio/admin-bot/telegram" + + "github.com/shitpostingio/admin-bot/callback/buttons" + "github.com/shitpostingio/admin-bot/database/database" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +func checkForHandles(msg *tgbotapi.Message) { + + /* FIND ALL HANDLES */ + handles := telegram.GetAllMentions(msg.Text, telegram.GetMessageEntities(msg), msg.ReplyMarkup) + + /* WE ARE JUST CHECKING FOR HANDLES */ + if len(handles) > 0 { + + /* BLACKLIST EVERYTHING THAT IS FOUND WHEN IN BLACKLISTALL MODE */ + if isBlacklistAllMode { + + for _, handle := range handles { + + if !database.SourceIsBlacklisted(0, handle) { + _, _ = database.BlacklistSource(0, handle, msg.From.ID) + } + } + + text := fmt.Sprintf(localization.GetString("private_blacklistall_handles_added_correctly"), localization.GetString("private_blacklistall_active")) + _ = adminbot.SendPlainTextMessage(msg.Chat.ID, text, false) + return + } + + var markup tgbotapi.InlineKeyboardMarkup + if database.SourceIsBlacklisted(0, handles[0]) { + markup = buttons.CreateKeyboardWithOneRow(buttons.CreatePrivatePardonSourceButton(handles[0]), + buttons.CreatePrivateWhitelistSourceButton(handles[0])) + } else { + markup = buttons.CreateKeyboardWithOneRow(buttons.CreatePrivateBlacklistSourceButton(handles[0]), + buttons.CreatePrivateWhitelistSourceButton(handles[0])) + } + + text := fmt.Sprintf("What should we do with the source %s?", handles[0]) + _ = adminbot.SendReplyTextMessageWithMarkup(msg.MessageID, msg.Chat.ID, text, markup, false) + + } +} + +func setupModAddition(inputMessage *tgbotapi.Message) { + + userID, err := strconv.ParseInt(inputMessage.CommandArguments(), 10, 64) + if err != nil { + _ = adminbot.SendPlainTextMessage(inputMessage.Chat.ID, localization.GetString("private_mods_unable_to_parse_userid"), false) + return + } + + chatMember, err := adminbot.GetChatMember(userID, repository.GetTelegramConfiguration().GroupID) + if err != nil { + _ = adminbot.SendPlainTextMessage(inputMessage.Chat.ID, localization.GetString("private_mods_unable_to_find_user"), false) + return + } + + text := fmt.Sprintf(localization.GetString("private_mods_are_you_sure"), chatMember.User.ID, telegram.GetName(chatMember.User)) + markup := buttons.CreateKeyboardWithOneRow(buttons.CreateModUserButton(userID)) + _ = adminbot.SendTextMessageWithMarkup(inputMessage.Chat.ID, text, markup, false) + +} + +func setupHostnameBlacklist(inputMessage *tgbotapi.Message) { + + text := localization.GetString("private_hosts_what_to_do") + + /* CREATE INLINE KEYBOARD */ + upperRow := tgbotapi.NewInlineKeyboardRow(buttons.CreateBlacklistBanworthyHostnameButton(inputMessage.CommandArguments()), + buttons.CreateBlacklistTelegramHostnameButton(inputMessage.CommandArguments())) + lowerRow := tgbotapi.NewInlineKeyboardRow(buttons.CreateBlacklistHostnameButton(inputMessage.CommandArguments())) + replyMarkup := tgbotapi.NewInlineKeyboardMarkup(upperRow, lowerRow) + + _ = adminbot.SendReplyPlainTextMessageWithMarkup(inputMessage.MessageID, inputMessage.Chat.ID, text, replyMarkup, false) + +} + +func getAppropriateEmergencyDuration(testing bool) time.Duration { + + if testing { + return time.Minute * 1 + } + + return time.Hour * 24 +} + +func createOneTimeReplyKeyboardWithCancelOption(buttonArgument, buttonContent string, createBanButton, createPardonButton, createWhitelistButton bool) tgbotapi.ReplyKeyboardMarkup { + + cancelRow := tgbotapi.NewKeyboardButtonRow(tgbotapi.NewKeyboardButton("/cancel")) + + if createBanButton { + banButtonText := fmt.Sprintf("/ban%s %s", strings.Title(buttonArgument), buttonContent) + banButtonRow := tgbotapi.NewKeyboardButtonRow(tgbotapi.NewKeyboardButton(banButtonText)) + return tgbotapi.NewReplyKeyboard(banButtonRow, cancelRow) + } + + var actionRow []tgbotapi.KeyboardButton + if createPardonButton { + pardonButtonText := fmt.Sprintf("/pardon%s %s", strings.Title(buttonArgument), buttonContent) + actionRow = append(actionRow, tgbotapi.NewKeyboardButton(pardonButtonText)) + } + + if createWhitelistButton { + whitelistButtonText := fmt.Sprintf("/whitelist%s %s", strings.Title(buttonArgument), buttonContent) + actionRow = append(actionRow, tgbotapi.NewKeyboardButton(whitelistButtonText)) + } + + keyboard := tgbotapi.NewReplyKeyboard(actionRow, cancelRow) + keyboard.OneTimeKeyboard = true + return keyboard +} diff --git a/ratelimiter/accessors.go b/ratelimiter/accessors.go new file mode 100644 index 0000000..8490866 --- /dev/null +++ b/ratelimiter/accessors.go @@ -0,0 +1,11 @@ +package limiter + +// AuthorizeUrgentAction authorizes an urgent action +func AuthorizeUrgentAction() { + urgent <- true +} + +// AuthorizeAction authorizes a normal action +func AuthorizeAction() { + normal <- true +} diff --git a/ratelimiter/ratelimiter.go b/ratelimiter/ratelimiter.go new file mode 100644 index 0000000..9565ae1 --- /dev/null +++ b/ratelimiter/ratelimiter.go @@ -0,0 +1,83 @@ +package limiter + +import ( + "sync" + "time" +) + +var ( + actions int + actionsThreshold int + mutex sync.Mutex + urgent chan bool + normal chan bool + reset chan bool +) + +//StartRateLimiter starts the rate limiter +func StartRateLimiter(maxActions int) { + + actionsThreshold = maxActions + reset = make(chan bool) + urgent = make(chan bool) + normal = make(chan bool) + + go limitRates() + go handleRequests() + +} + +func limitRates() { + + timeToWait := 1 * time.Second + for { + time.Sleep(timeToWait) + mutex.Lock() + + if actions == actionsThreshold { + reset <- true + } + + actions = 0 + mutex.Unlock() + } +} + +func increaseActions() { + mutex.Lock() + actions++ + mutex.Unlock() +} + +func actionsThresholdReached() bool { + mutex.Lock() + defer mutex.Unlock() + return actions == actionsThreshold +} + +func handleRequests() { + for { + + //If we have already reached the maximum + //amount of actions allowed in our time slice, + //we need to wait for a signal. + if actionsThresholdReached() { + <-reset + } + + increaseActions() + + //Prioritize urgent actions. + select { + case <-urgent: + continue + default: + select { + case <-urgent: + continue + case <-normal: + continue + } + } + } +} diff --git a/reports/adminbot.go b/reports/adminbot.go new file mode 100644 index 0000000..405e52c --- /dev/null +++ b/reports/adminbot.go @@ -0,0 +1,48 @@ +package reports + +import ( + "fmt" + "github.com/shitpostingio/admin-bot/localization" +) + +func ModeratorDemoted(adminID int64, adminName string, moderatorID int64, moderatorName string) string { + return fmt.Sprintf(localization.GetString("moderator_demoted"), + adminID, adminName, adminID, + moderatorID, moderatorName, moderatorID) +} + +func ModeratorCannotBeRemovedFromTable(moderatorID int64, moderatorName string) string { + return fmt.Sprintf(localization.GetString("moderator_cannot_be_removed"), + moderatorID, moderatorName, moderatorID) +} + +func UserBanned(moderatorID int64, moderatorName string, userID int64, userName string, reason string) string { + return fmt.Sprintf(localization.GetString("user_banned"), + moderatorID, moderatorName, + userID, userName, userID, + reason) +} + +func UnauthorizedPrivateGroup(groupName string, groupID int64, adderName string, adderID int64) string { + return fmt.Sprintf(localization.GetString("unauthorized_group_report"), + groupName, "", groupID, + adderName, adderID, adderID) +} + +func UnauthorizedPublicGroup(groupName string, groupHandle string, groupID int64, adderName string, adderID int64) string { + handlePart := fmt.Sprintf(localization.GetString("unauthorized_report_handle_part"), groupHandle) + return fmt.Sprintf(localization.GetString("unauthorized_group_report"), + groupName, handlePart, groupID, + adderName, adderID, adderID) +} + +func UnauthorizedPrivateChannel(channelName string, channelID int64) string { + return fmt.Sprintf(localization.GetString("unauthorized_channel_report"), + channelName, "", channelID) +} + +func UnauthorizedPublicChannel(channelName string, channelHandle string, channelID int64) string { + handlePart := fmt.Sprintf(localization.GetString("unauthorized_report_handle_part"), channelHandle) + return fmt.Sprintf(localization.GetString("unauthorized_channel_report"), + channelName, handlePart, channelID) +} diff --git a/reports/antiuserbot.go b/reports/antiuserbot.go new file mode 100644 index 0000000..9f0b2ca --- /dev/null +++ b/reports/antiuserbot.go @@ -0,0 +1,16 @@ +package reports + +import ( + "fmt" + "github.com/shitpostingio/admin-bot/localization" +) + +func PossibleUserbotAttack() string { + return localization.GetString("antiuserbot_possible_attack") +} + +func PossibleUserbotRestriction(userID int64, userName string) string { + return fmt.Sprintf(localization.GetString("antiuserbot_possible_userbot_restriction"), + userID, + userName) +} diff --git a/reports/automod.go b/reports/automod.go new file mode 100644 index 0000000..333ad30 --- /dev/null +++ b/reports/automod.go @@ -0,0 +1,58 @@ +package reports + +import ( + "fmt" + "github.com/shitpostingio/admin-bot/localization" +) + +func ChatMessageReported(reporteeUserID int64, reporteeUserName string, reportedUserID int64, reportedUserName string) string { + return fmt.Sprintf(localization.GetString("automod_new_report_by_with_reply"), + reporteeUserID, reporteeUserName, reporteeUserID, + reportedUserID, reportedUserName, reportedUserID) +} + +func ChatMessageReport(reporteeUserID int64, reporteeUserName string) string { + return fmt.Sprintf(localization.GetString("automod_new_report_by"), + reporteeUserID, reporteeUserName, reporteeUserID) +} + +func ForwardFromChannel(userID int64, userName string) string { + return fmt.Sprintf(localization.GetString("automod_removed_message_forwarded_channel"), + userID, userName, userID) +} + +func ForwardFromBlacklistedHandle(userID int64, userName string) string { + return fmt.Sprintf(localization.GetString("automod_removed_message_forwared_blacklisted_handle"), + userID, userName, userID) +} + +func MessageSentViaBlacklistedInlineBot(userID int64, userName string) string { + return fmt.Sprintf(localization.GetString("automod_removed_message_via_blacklsited_inline_bot"), + userID, userName, userID) +} + +func RemovedNSFWMedia(userID int64, userName string, label string, confidence float64) string { + return fmt.Sprintf(localization.GetString("automod_removed_nsfw_media"), + userID, userName, userID, + label, confidence) +} + +func RemovedUnwantedHandle(userID int64, userName string) string { + return fmt.Sprintf(localization.GetString("automod_removed_unwanted_handle"), + userID, userName, userID) +} + +func RemovedUnwantedLink(host string, userID int64, userName string) string { + return fmt.Sprintf(localization.GetString("automod_removed_unwanted_link"), + host, + userID, userName, userID) +} + +func UserMutedForUnwantedLink(botID int64, botName string, + userID int64, userName string, + reason string) string { + return fmt.Sprintf(localization.GetString("automod_unwanted_link_user_muted"), + botID, botName, + userID, userName, userID, + reason) +} diff --git a/reports/send.go b/reports/send.go new file mode 100644 index 0000000..badcf5b --- /dev/null +++ b/reports/send.go @@ -0,0 +1,34 @@ +package reports + +import ( + "github.com/shitpostingio/admin-bot/api" + "github.com/shitpostingio/admin-bot/repository" +) + +//nolint +const ( + URGENT = true + NON_URGENT = false +) + +func ReportWithMarkup(message string, markup interface{}, isUrgent bool) error { + _, err := api.SendTextMessageWithMarkup(repository.GetTelegramConfiguration().ReportChannelID, + message, + markup, + isUrgent) + return err +} + +func ReportInPlaintext(message string, isUrgent bool) error { + _, err := api.SendPlainTextMessage(repository.GetTelegramConfiguration().ReportChannelID, + message, + isUrgent) + return err +} + +func Report(message string, isUrgent bool) error { + _, err := api.SendTextMessage(repository.GetTelegramConfiguration().ReportChannelID, + message, + isUrgent) + return err +} diff --git a/repository/getters.go b/repository/getters.go new file mode 100644 index 0000000..02d896e --- /dev/null +++ b/repository/getters.go @@ -0,0 +1,35 @@ +package repository + +import ( + "github.com/shitpostingio/admin-bot/config/structs" +) + +//GetTestingStatus gets the testing status from the repository. +func GetTestingStatus() bool { + return Testing +} + +//GetDebugStatus gets the debug status from the repository. +func GetDebugStatus() bool { + return Debug +} + +// GetTelegramConfiguration gets the telegram configuration +func GetTelegramConfiguration() *structs.TelegramConfiguration { + return &Configuration.Telegram +} + +// GetAntiSpamConfiguration gets the antispam configuration +func GetAntiSpamConfiguration() *structs.AntiSpamConfiguration { + return &Configuration.AntiSpam +} + +// GetAntiFloodConfiguration gets the antiflood configuration +func GetAntiFloodConfiguration() *structs.AntiFloodConfiguration { + return &Configuration.AntiFlood +} + +// GetAntiUserbotConfiguration gets the antiuserbot configuration +func GetAntiUserbotConfiguration() *structs.AntiUserbotConfiguration { + return &Configuration.AntiUserbot +} diff --git a/repository/repository.go b/repository/repository.go new file mode 100644 index 0000000..6e82574 --- /dev/null +++ b/repository/repository.go @@ -0,0 +1,27 @@ +package repository + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/shitpostingio/admin-bot/config/structs" +) + +var ( + + // Bot represents the bot in the Telegram Bot API. + Bot *tgbotapi.BotAPI + + // Configuration represents the configuration + Configuration *structs.Config + + // Mods holds the user IDs of mods + Mods map[int64]bool + + // Admins holds the user ids of admins + Admins map[int64]bool + + // Testing represents the testing status of the bot + Testing bool + + // Debug represents the debug status of the bot + Debug bool +) diff --git a/repository/setters.go b/repository/setters.go new file mode 100644 index 0000000..7b6dbbb --- /dev/null +++ b/repository/setters.go @@ -0,0 +1,37 @@ +package repository + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "github.com/shitpostingio/admin-bot/config/structs" +) + +//SetBot sets the bot in the repository. +func SetBot(inputBot *tgbotapi.BotAPI) { + Bot = inputBot +} + +//SetConfig sets the configuration in the repository. +func SetConfig(inputCfg *structs.Config) { + Configuration = inputCfg +} + +//SetMods sets the mod map in the repository. +func SetMods(inputMap map[int64]bool) { + Mods = inputMap +} + +//SetAdmins sets the admin map in the repository. +func SetAdmins(inputMap map[int64]bool) { + Admins = inputMap +} + +//SetTestingStatus sets the testing status in the repository. +func SetTestingStatus(testingStatus bool) { + Testing = testingStatus +} + +//SetDebugStatus sets the debug status in the repository. +func SetDebugStatus(debugStatus bool) { + Debug = debugStatus +} diff --git a/structs/structs.go b/structs/structs.go new file mode 100644 index 0000000..c876caf --- /dev/null +++ b/structs/structs.go @@ -0,0 +1,35 @@ +package structs + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +//GroupUserRestrictions represents the restrictions that a group member can have +type GroupUserRestrictions struct { + UntilDate int64 + CanSendMessages bool + CanSendMediaMessages bool + CanSendOtherMessages bool + CanAddWebPagePreviews bool +} + +//AntiSpam maps userIDs to goroutine channels +type AntiSpam struct { + InputChannel chan *tgbotapi.Message + EndCycleChannel chan int + UserChannels map[int]chan *tgbotapi.Message +} + +//CloudmersiveNSFWResult represents the data returned by Cloudmersive +type CloudmersiveNSFWResult struct { + Successful bool `json:"Successful"` + Score float64 `json:"Score"` + ClassificationOutcome string `json:"ClassificationOutcome"` +} + +//AWSNSFWResult represents the data returned by Amazon Rekognition +type AWSNSFWResult struct { + ExplicitNudityConfidence float64 + SuggestiveConfidence float64 + BestMatch string +} diff --git a/telegram/files.go b/telegram/files.go new file mode 100644 index 0000000..f8fef7a --- /dev/null +++ b/telegram/files.go @@ -0,0 +1,34 @@ +package telegram + +//nolint +const ( + PHOTO = "AgA" + VIDEO = "BAA" + ANIMATION = "CgA" + STICKER = "CAA" + VOICE = "AwA" + DOCUMENT = "BQA" + AUDIO = "CQA" + VIDEONOTE = "DQA" +) + +func GetFileType(fileID string) string { + return fileID[:3] +} + +func MediaCanBeAnalyzed(fileID string) bool { + + switch GetFileType(fileID) { + case STICKER: + return true + case PHOTO: + return true + case VIDEO: + return true + case ANIMATION: + return true + } + + return false + +} diff --git a/telegram/handles.go b/telegram/handles.go new file mode 100644 index 0000000..5a405b2 --- /dev/null +++ b/telegram/handles.go @@ -0,0 +1,132 @@ +package telegram + +import ( + "net/url" + "strings" + "unicode/utf16" + + "github.com/shitpostingio/admin-bot/database/database" + "github.com/shitpostingio/admin-bot/utility" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +// GetAllMentions returns all handles in a message. +// It'll return explicit handles in the form of @mentions, +// but also handles extracted from telegram links in the form +// of /. +func GetAllMentions(text string, entities []tgbotapi.MessageEntity, markup *tgbotapi.InlineKeyboardMarkup) (handles []string) { + + if len(entities) == 0 && markup == nil { + return handles + } + + tUTF16 := utf16.Encode([]rune(text)) + return GetAllMentionsUTF16(tUTF16, entities, markup) +} + +// GetAllMentionsUTF16 returns all handles in a message. +// It'll return explicit handles in the form of @mentions, +// but also handles extracted from telegram links in the form +// of /. +func GetAllMentionsUTF16(tUTF16 []uint16, entities []tgbotapi.MessageEntity, markup *tgbotapi.InlineKeyboardMarkup) (handles []string) { + + if len(entities) == 0 && markup == nil { + return handles + } + + urls := GetURLs(tUTF16, entities) + for _, textURL := range urls { + + fullTextURL, err := utility.UnshortenURL(textURL) + if err != nil { + continue + } + + fullTextURLLowercase := strings.ToLower(fullTextURL) + parsedURL, err := url.Parse(fullTextURLLowercase) + if err != nil { + continue + } + + // Extract handles from known telegram URLs. + dbHostname, err := database.GetHostName(fullTextURLLowercase) + if err == nil && dbHostname.IsTelegram { + //parsedURL.Path WILL RETURN EVERYTHING AFTER THE HOST + //THAT'S NOT PART OF THE QUERY + parts := strings.SplitN(parsedURL.Path, "/", 3) + if len(parts) >= 2 { + handles = append(handles, parts[1]) + } + } + } + + messageHandles := GetMentions(tUTF16, entities) + handles = append(handles, messageHandles...) + markupHandles := GetInlineKeyboardMentions(markup) + handles = append(handles, markupHandles...) + + return handles +} + +// GetMentions returns the mentions in a message. +func GetMentions(tUTF16 []uint16, entities []tgbotapi.MessageEntity) (handles []string) { + + if len(entities) == 0 { + return handles + } + + for _, entity := range entities { + if entity.Type == "mention" { + handle := strings.ToLower(string(utf16.Decode(tUTF16[entity.Offset+1 : entity.Offset+entity.Length]))) + handles = append(handles, handle) + } + } + + return handles +} + +// GetInlineKeyboardMentions returns the mentions contained in an inline keyboard +func GetInlineKeyboardMentions(markup *tgbotapi.InlineKeyboardMarkup) (handles []string) { + + if markup == nil { + return handles + } + + for _, rows := range markup.InlineKeyboard { + + for _, column := range rows { + + if column.URL != nil { + + fullTextURL, err := utility.UnshortenURL(*column.URL) + if err != nil { + continue + } + + fullTextURLLowercase := strings.ToLower(fullTextURL) + parsedURL, err := url.Parse(fullTextURLLowercase) + if err != nil { + continue + } + + // Extract handles from known telegram URLs. + dbHostname, err := database.GetHostName(fullTextURLLowercase) + if err == nil && dbHostname.IsTelegram { + //parsedURL.Path WILL RETURN EVERYTHING AFTER THE HOST + //THAT'S NOT PART OF THE QUERY + parts := strings.SplitN(parsedURL.Path, "/", 3) + if len(parts) >= 2 { + handles = append(handles, parts[1]) + } + } + + } + + } + + } + + return handles + +} diff --git a/telegram/handles_test.go b/telegram/handles_test.go new file mode 100644 index 0000000..d2f9d2a --- /dev/null +++ b/telegram/handles_test.go @@ -0,0 +1,202 @@ +package telegram + +import ( + "reflect" + "testing" + "unicode/utf16" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +/* + *********************************************************************************************************************** + * * + * SET UP * + * * + *********************************************************************************************************************** + */ + +/* + *********************************************************************************************************************** + * * + * TESTS * + * * + *********************************************************************************************************************** + */ +// +//func TestGetAllMentions(t *testing.T) { +// +// db := SetupTests(false) +// text := `Hey shitposters, you should really join the channels in the shitposting.io network, like @Sushiporn, they're really nice and most definitely the best ones on @teleGram!!!` +// entities := []tgbotapi.MessageEntity{ +// { +// Offset: 4, +// Length: 11, +// Type: "text_link", +// URL: "http://t.me/shitpost", +// }, +// { +// Offset: 60, +// Length: 14, +// Type: "url", +// }, +// { +// Offset: 89, +// Length: 10, +// Type: "mention", +// }, +// { +// Offset: 158, +// Length: 9, +// Type: "mention", +// }, +// } +// +// type args struct { +// entities []tgbotapi.MessageEntity +// text string +// } +// tests := []struct { +// name string +// args args +// wantHandles []string +// }{ +// { +// name: "TestGetAllHandles", +// args: args{ +// entities: entities, +// text: text, +// db: db, +// }, +// wantHandles: []string{"shitpost", "sushiporn", "telegram"}, +// }, +// } +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// if gotHandles := GetAllMentions(tt.args.text, tt.args.entities, nil, tt.args.db); !reflect.DeepEqual(gotHandles, tt.wantHandles) { +// t.Errorf("GetAllMentions() = %v, want %v", gotHandles, tt.wantHandles) +// } +// }) +// } +//} + +func TestGetMentions(t *testing.T) { + + text := "You guys should really join @shitpOst and @sushiporn, the best channels on @teLegram" + entities := []tgbotapi.MessageEntity{ + { + Offset: 28, + Length: 9, + Type: "mention", + }, + { + Offset: 42, + Length: 10, + Type: "mention", + }, + { + Offset: 75, + Length: 9, + Type: "mention", + }, + } + tUTF16 := utf16.Encode([]rune(text)) + + type args struct { + tUTF16 []uint16 + entities []tgbotapi.MessageEntity + } + + tests := []struct { + name string + args args + wantHandles []string + }{ + { + name: "TestGetHandles", + args: args{ + tUTF16: tUTF16, + entities: entities, + }, + wantHandles: []string{"shitpost", "sushiporn", "telegram"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if gotHandles := GetMentions(tt.args.tUTF16, tt.args.entities); !reflect.DeepEqual(gotHandles, tt.wantHandles) { + t.Errorf("GetMentions() = %v, want %v", gotHandles, tt.wantHandles) + } + }) + } +} + +/* + *********************************************************************************************************************** + * * + * BENCHMARKS * + * * + *********************************************************************************************************************** + */ + +//func BenchmarkGetAllMentions(b *testing.B) { +// +// db := SetupTests(false) +// dbReply := []map[string]interface{}{{"id": 1, "hostname": "t.me", "is_banworthy": false, "is_telegram": true}} +// mocket.Catcher.NewMock().WithQuery(`SELECT * FROM "hostnames" WHERE (hostname = t.me)`).WithReply(dbReply) +// +// text := `Hey shitposters, you should really join the channels in the shitposting.io network, like @sushiporn, they're really nice and most definitely the best ones on @telegram!!!` +// entities := []tgbotapi.MessageEntity{ +// { +// Offset: 4, +// Length: 11, +// Type: "text_link", +// URL: "http://t.me/shitpost", +// }, +// { +// Offset: 60, +// Length: 14, +// Type: "url", +// }, +// { +// Offset: 89, +// Length: 10, +// Type: "mention", +// }, +// { +// Offset: 158, +// Length: 9, +// Type: "mention", +// }, +// } +// +// for i := 0; i < b.N; i++ { +// GetAllMentions(text, entities, nil, db) +// } +//} + +func BenchmarkGetMentions(b *testing.B) { + text := "You guys should really join @shitpost and @sushiporn, the best channels on @telegram" + entities := []tgbotapi.MessageEntity{ + { + Offset: 28, + Length: 9, + Type: "mention", + }, + { + Offset: 42, + Length: 10, + Type: "mention", + }, + { + Offset: 75, + Length: 9, + Type: "mention", + }, + } + tUTF16 := utf16.Encode([]rune(text)) + + for i := 0; i < b.N; i++ { + GetMentions(tUTF16, entities) + } +} diff --git a/telegram/members.go b/telegram/members.go new file mode 100644 index 0000000..b4b7855 --- /dev/null +++ b/telegram/members.go @@ -0,0 +1,46 @@ +package telegram + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +// GetPermissionsFromChatMember returns the user permissions. +func GetPermissionsFromChatMember(chatMember *tgbotapi.ChatMember) *tgbotapi.ChatPermissions { + + if chatMember == nil { + return nil + } + + return &tgbotapi.ChatPermissions{ + CanSendMessages: chatMember.CanSendMessages, + CanSendMediaMessages: chatMember.CanSendMediaMessages, + CanSendPolls: chatMember.CanSendPolls, + CanSendOtherMessages: chatMember.CanSendOtherMessages, + CanAddWebPagePreviews: chatMember.CanAddWebPagePreviews, + CanChangeInfo: chatMember.CanChangeInfo, + CanInviteUsers: chatMember.CanInviteUsers, + CanPinMessages: chatMember.CanPinMessages, + } +} + +// ChatMemberIsRestricted returns true if the user is restricted +func ChatMemberIsRestricted(chatMember *tgbotapi.ChatMember) bool { + + if chatMember == nil { + return false + } + + return chatMember.Status == "restricted" + +} + +// ChatMemberIsBanned returns true if the user was kicked +func ChatMemberIsBanned(chatMember *tgbotapi.ChatMember) bool { + + if chatMember == nil { + return false + } + + return chatMember.Status == "kicked" + +} diff --git a/telegram/messages.go b/telegram/messages.go new file mode 100644 index 0000000..a437c43 --- /dev/null +++ b/telegram/messages.go @@ -0,0 +1,79 @@ +package telegram + +import ( + "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +//GetMessageEntities returns an array of entities. It can be message.Entities, +//message.CaptionEntities or an empty array. +func GetMessageEntities(msg *tgbotapi.Message) (entities []tgbotapi.MessageEntity) { + + if msg.Entities != nil { + entities = msg.Entities + } else if msg.CaptionEntities != nil { + entities = msg.CaptionEntities + } + + return entities +} + +//GetMessageText returns msg.Text or msg.Caption +func GetMessageText(msg *tgbotapi.Message) (text string) { + + if msg.Text != "" { + text = msg.Text + } else { + text = msg.Caption + } + + return text +} + +// GetFileIDFromMessage returns the file id given a message +func GetFileIDFromMessage(msg *tgbotapi.Message) (fileUniqueID, fileID string) { + + switch { + case msg.Photo != nil: + + fileUniqueID = msg.Photo[len(msg.Photo)-1].FileUniqueID + fileID = msg.Photo[len(msg.Photo)-1].FileID + + case msg.Video != nil: + + fileUniqueID = msg.Video.FileUniqueID + fileID = msg.Video.FileID + + case msg.Sticker != nil: + + fileUniqueID = msg.Sticker.FileUniqueID + fileID = msg.Sticker.FileID + + case msg.Animation != nil: + + fileUniqueID = msg.Animation.FileUniqueID // TODO: RIMUOVERE QUANDO SYFARO AGGIUNGERA' IL CAMPO + fileID = msg.Animation.FileID + + case msg.Document != nil: + + fileUniqueID = msg.Document.FileUniqueID + fileID = msg.Document.FileID + + case msg.Voice != nil: + + fileUniqueID = msg.Voice.FileUniqueID + fileID = msg.Voice.FileID + + case msg.Audio != nil: + + fileUniqueID = msg.Audio.FileUniqueID + fileID = msg.Audio.FileID + + case msg.VideoNote != nil: + + fileUniqueID = msg.VideoNote.FileUniqueID + fileID = msg.VideoNote.FileID + + } + + return fileUniqueID, fileID +} diff --git a/telegram/names.go b/telegram/names.go new file mode 100644 index 0000000..850607d --- /dev/null +++ b/telegram/names.go @@ -0,0 +1,35 @@ +package telegram + +import ( + "html" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +// GetNameOrUsername returns the user handle or their first and last name +func GetNameOrUsername(user *tgbotapi.User) string { + + if user.UserName != "" { + return "@" + user.UserName + } + + if user.LastName == "" { + return user.FirstName + } + + return user.FirstName + " " + user.LastName +} + +// GetName returns the user's first and last name +func GetName(user *tgbotapi.User) string { + + var output string + + if user.LastName == "" { + output = user.FirstName + } else { + output = user.FirstName + " " + user.LastName + } + + return html.EscapeString(output) +} diff --git a/telegram/names_test.go b/telegram/names_test.go new file mode 100644 index 0000000..3144292 --- /dev/null +++ b/telegram/names_test.go @@ -0,0 +1,57 @@ +package telegram + +import ( + "testing" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +/* + *********************************************************************************************************************** + * * + * TESTS * + * * + *********************************************************************************************************************** + */ + +func TestGetNameOrUsername(t *testing.T) { + + type args struct { + user *tgbotapi.User + } + + tests := []struct { + name string + args args + want string + }{ + { + name: "Handle and name", + args: args{&tgbotapi.User{FirstName: "Admin", LastName: "Bot", UserName: "AdminBot"}}, + want: "@AdminBot", + }, + { + name: "Handle no name", + args: args{&tgbotapi.User{UserName: "AdminBot"}}, + want: "@AdminBot", + }, + { + name: "Name no handle", + args: args{&tgbotapi.User{FirstName: "Admin", LastName: "Bot"}}, + want: "Admin Bot", + }, + { + name: "Nothing", + args: args{&tgbotapi.User{}}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := GetNameOrUsername(tt.args.user); got != tt.want { + t.Errorf("GetHandleOrUserName() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/telegram/urls.go b/telegram/urls.go new file mode 100644 index 0000000..0bdfa61 --- /dev/null +++ b/telegram/urls.go @@ -0,0 +1,26 @@ +package telegram + +import ( + "unicode/utf16" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +// GetURLs returns the urls and the markdown links in a message. +func GetURLs(tUTF16 []uint16, entities []tgbotapi.MessageEntity) (urls []string) { + + if len(entities) == 0 { + return urls + } + + for _, entity := range entities { + switch entity.Type { + case "url": + urls = append(urls, string(utf16.Decode(tUTF16[entity.Offset:entity.Offset+entity.Length]))) + case "text_link": + urls = append(urls, entity.URL) + } + } + + return urls +} diff --git a/telegram/urls_test.go b/telegram/urls_test.go new file mode 100644 index 0000000..1195972 --- /dev/null +++ b/telegram/urls_test.go @@ -0,0 +1,107 @@ +package telegram + +import ( + "reflect" + "testing" + "unicode/utf16" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +/* + *********************************************************************************************************************** + * * + * TESTS * + * * + *********************************************************************************************************************** + */ + +func TestGetURLs(t *testing.T) { + type args struct { + entities []tgbotapi.MessageEntity + text string + } + tests := []struct { + name string + args args + wantURLs []string + }{ + { + name: "Url no http and markdown URL", + args: args{ + text: "Hi! Check out t.me/shitpost, it's a great channel! You can check out sushiporn too, if you'd like", + entities: []tgbotapi.MessageEntity{ + { + Offset: 14, + Length: 13, + Type: "url", + }, + { + Offset: 69, + Length: 9, + Type: "text_link", + URL: "http://t.me/sushiporn", + }, + }, + }, + wantURLs: []string{"t.me/shitpost", "http://t.me/sushiporn"}, + }, + { + name: "Shortened url and google no http", + args: args{ + text: "check out the shitposting website and follow us on google.com", + entities: []tgbotapi.MessageEntity{ + { + Offset: 14, + Length: 11, + Type: "text_link", + URL: "https://bit.ly/2WA4TBH", + }, + { + Offset: 51, + Length: 10, + Type: "url", + }, + }, + }, + wantURLs: []string{"https://bit.ly/2WA4TBH", "google.com"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if gotHandles := GetURLs(utf16.Encode([]rune(tt.args.text)), tt.args.entities); !reflect.DeepEqual(gotHandles, tt.wantURLs) { + t.Errorf("NewFindURLs() = %v, want %v", gotHandles, tt.wantURLs) + } + }) + } +} + +/* + *********************************************************************************************************************** + * * + * BENCHMARKS * + * * + *********************************************************************************************************************** + */ + +func BenchmarkNewFindURLs(b *testing.B) { + text := "Hi! Check out t.me/shitpost, it's a great channel! You can check out sushiporn too, if you'd like" + entities := []tgbotapi.MessageEntity{ + { + Offset: 14, + Length: 13, + Type: "url", + }, + { + Offset: 69, + Length: 9, + Type: "text_link", + URL: "http://t.me/sushiporn", + }, + } + tUTF16 := utf16.Encode([]rune(text)) + + for i := 0; i < b.N; i++ { + GetURLs(tUTF16, entities) + } +} diff --git a/updates/updates.go b/updates/updates.go new file mode 100644 index 0000000..ca05010 --- /dev/null +++ b/updates/updates.go @@ -0,0 +1,82 @@ +package updates + +import ( + "net/http" + "strings" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + log "github.com/sirupsen/logrus" + + "github.com/shitpostingio/admin-bot/config/structs" +) + +// GetUpdatesChannel contacts Telegram's servers in order to get updates. +// Updates can be received either via polling or via webhooks. +func GetUpdatesChannel(connectViaPolling bool, bot *tgbotapi.BotAPI, cfg *structs.Config) tgbotapi.UpdatesChannel { + + if !connectViaPolling { + return useWebhook(bot, cfg) + } + + _, err := bot.Request(tgbotapi.DeleteWebhookConfig{}) + if err != nil { + log.Error("GetUpdatesChannel: unable to remove webhook:", err) + return nil + } + + return usePolling(bot) + +} + +// usePolling gets an `UpdatesChannel` using polling +func usePolling(bot *tgbotapi.BotAPI) tgbotapi.UpdatesChannel { + return bot.GetUpdatesChan(tgbotapi.UpdateConfig{Timeout: 60}) +} + +//useWebhook ets an `UpdatesChannel` using webhooks +func useWebhook(bot *tgbotapi.BotAPI, cfg *structs.Config) tgbotapi.UpdatesChannel { + + go startServer(&cfg.Webhook) + + webhook, err := bot.GetWebhookInfo() + if err == nil && webhook.IsSet() { + + // A webhook has already been set: we need to make sure + // it points to the correct domain. + domainNameStart := strings.Index(webhook.URL, "/") + 2 + + // The webhook points to the correct domain + if strings.HasPrefix(webhook.URL[domainNameStart:], cfg.Webhook.Domain) { + return bot.ListenForWebhook(cfg.Telegram.WebHookPath()) + } + + } + + // Set up new webhooks + webhookConfiguration, err := tgbotapi.NewWebhook(cfg.Webhook.WebHookURL(cfg.Telegram.BotToken)) + if err != nil { + log.Error("useWebhook: unable to create webhook configuration:", err) + return nil + } + + webhookConfiguration.MaxConnections = cfg.Webhook.MaxConnections + _, err = bot.Request(webhookConfiguration) + if err != nil { + log.Error("useWebhook: unable to request webhook creation:", err) + return nil + } + + return bot.ListenForWebhook(cfg.Telegram.WebHookPath()) + +} + +//startServer starts serving HTTP requests with or without TLS +func startServer(cfg *structs.WebhookConfiguration) { + + if cfg.TLS { + log.Error(http.ListenAndServeTLS(cfg.BindString(), cfg.TLSCertPath, cfg.TLSKeyPath, nil)) + } else { + log.Error(http.ListenAndServe(cfg.BindString(), nil)) + } + +} diff --git a/utility/cache/caches.go b/utility/cache/caches.go new file mode 100644 index 0000000..2a15225 --- /dev/null +++ b/utility/cache/caches.go @@ -0,0 +1,68 @@ +package cache + +import ( + "fmt" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "go.mongodb.org/mongo-driver/mongo" + + "github.com/shitpostingio/admin-bot/config/structs" + "github.com/shitpostingio/admin-bot/database/database" + "github.com/shitpostingio/admin-bot/repository" +) + +// CreateAdminsCache caches the database administrators in a map. +func CreateAdminsCache(collection *mongo.Collection) (map[int64]bool, error) { + + dbModerators, err := database.GetAllModerators() + if err != nil { + return nil, fmt.Errorf("CreateAdminsCache: no moderators in the database: %s", err) + } + + isAdmin := make(map[int64]bool) + for _, moderator := range dbModerators { + + if moderator.IsAdmin { + isAdmin[moderator.TelegramID] = true + } + + } + + return isAdmin, nil + +} + +// CreateModsCache caches the mods in a map. +func CreateModsCache(isAdmin map[int64]bool, bot *tgbotapi.BotAPI, cfg *structs.TelegramConfiguration) (map[int64]bool, error) { + + chatAdministratorsConfig := tgbotapi.ChatAdministratorsConfig{ChatConfig: tgbotapi.ChatConfig{ChatID: cfg.GroupID}} + chatAdministrators, err := bot.GetChatAdministrators(chatAdministratorsConfig) + if err != nil { + return nil, fmt.Errorf("CreateModsCache: unable to retrieve chat administrators: %s", err) + } + + // We don't want database admins in the mod map. + isMod := make(map[int64]bool) + for _, moderator := range chatAdministrators { + if !isAdmin[moderator.User.ID] && !moderator.User.IsBot { + isMod[moderator.User.ID] = true + } + } + + return isMod, nil + +} + +//RemoveFromMods removes the `userID` from the mod map, if present. +func RemoveFromMods(userID int64) bool { + + chatMods := repository.Mods + + _, wasMod := chatMods[userID] + if wasMod { + delete(chatMods, userID) + } + + return wasMod + +} diff --git a/utility/host.go b/utility/host.go new file mode 100644 index 0000000..a1a0097 --- /dev/null +++ b/utility/host.go @@ -0,0 +1,27 @@ +package utility + +import ( + "fmt" + "net/url" + "strings" +) + +// GetHostNameFromURL returns the host name given a url +func GetHostNameFromURL(inputHostname string) (string, error) { + + if !strings.HasPrefix(inputHostname, "http") { + inputHostname = fmt.Sprintf("http://%s", inputHostname) + } + + fullTextURL, err := UnshortenURL(inputHostname) + if err != nil { + return "", err + } + + parsedURL, err := url.Parse(fullTextURL) + if err != nil { + return "", err + } + + return strings.ToLower(parsedURL.Host), nil +} diff --git a/utility/restrictions.go b/utility/restrictions.go new file mode 100644 index 0000000..df5ec7b --- /dev/null +++ b/utility/restrictions.go @@ -0,0 +1,18 @@ +package utility + +import ( + "time" + + "github.com/shitpostingio/admin-bot/repository" +) + +//GetAppropriateRestrictionEnd returns 1 minute if +//the bot is in testing mode, 30 minutes otherwise +func GetAppropriateRestrictionEnd() int64 { + + if repository.GetTestingStatus() { + return time.Now().Add(1 * time.Minute).Unix() + } + + return time.Now().Add(30 * time.Minute).Unix() +} diff --git a/utility/utility.go b/utility/utility.go new file mode 100644 index 0000000..698d295 --- /dev/null +++ b/utility/utility.go @@ -0,0 +1,100 @@ +package utility + +import ( + "io" + "os" + "strconv" + "time" + "unicode" + + log "github.com/sirupsen/logrus" + + "github.com/shitpostingio/admin-bot/repository" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +//UnixTimeIn parses a string and returns Time.Now() + the parsed duration in UNIX time +func UnixTimeIn(durationString string) int64 { + + //"e" MEANS FOREVER + if durationString == "e" { + return 0 + } + + /* DEFAULT DURATION */ + restrictionDuration := 43200 //12hrs + + //SUPPORTED DURATIONS: w (weeks), d (days), h (hours), m (minutes) + for index, durationRune := range durationString { + + //WE ONLY SUPPORT SINGLE-RUNE DURATIONS + if !unicode.IsNumber(durationRune) { + number, err := strconv.Atoi(durationString[0:index]) + if err != nil { + break + } + + switch durationRune { + case 'w': + restrictionDuration = number * 604800 //weeks + case 'd': + restrictionDuration = number * 86400 //days + case 'h': + restrictionDuration = number * 3600 //hours + case 'm': + restrictionDuration = number * 60 //minutes + } + } + } + + return time.Now().Unix() + int64(restrictionDuration) +} + +//IsChatAdminByMessage returns true if the user is an admin or the creator +func IsChatAdminByMessage(msg *tgbotapi.Message) bool { + return repository.Admins[msg.From.ID] || repository.Mods[msg.From.ID] +} + +// IsChatAdmin returns true if the telegram id belongs to an admin or a mod +func IsChatAdmin(telegramID int64) bool { + return repository.Admins[telegramID] || repository.Mods[telegramID] +} + +//FormatDate formats a date +func FormatDate(date time.Time) string { + return date.Format("Mon _2 Jan 2006 15:04:05") +} + +//FormatUnixDate formats a date in unix format +func FormatUnixDate(unixDate int64) string { + + if unixDate == 0 { + return "indefinitely" + } + + return time.Unix(unixDate, 0).Format("Mon _2 Jan 2006 15:04:05") +} + +//EmojifyBool returns ✅ for true and ❌ for false +func EmojifyBool(value bool) string { + if value { + return "✅" + } + + return "❌" +} + +//CloseSafely closes an entity and logs in case of errors +func CloseSafely(toClose io.Closer) { + err := toClose.Close() + if err != nil { + log.Println(err) + } +} + +// LogFatal logs an error and exits +func LogFatal(format ...interface{}) { + log.Error(format...) + os.Exit(1) +} diff --git a/utility/utility_test.go b/utility/utility_test.go new file mode 100644 index 0000000..40b0f2a --- /dev/null +++ b/utility/utility_test.go @@ -0,0 +1,125 @@ +package utility + +import ( + "testing" + "time" + + "github.com/shitpostingio/admin-bot/repository" + + "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +func TestUnixTimeIn(t *testing.T) { + + type args struct { + durationString string + } + + tests := []struct { + name string + args args + want int64 + }{ + { + name: "5 minutes", + args: args{"5m"}, + want: time.Now().Add(5 * time.Minute).Unix(), + }, + { + name: "3 hours", + args: args{"3h"}, + want: time.Now().Add(3 * time.Hour).Unix(), + }, + { + name: "1 day", + args: args{"1d"}, + want: time.Now().Add(24 * time.Hour).Unix(), + }, + { + name: "1 week", + args: args{"1w"}, + want: time.Now().Add(7 * 24 * time.Hour).Unix(), + }, + { + name: "Eternal", + args: args{"e"}, + want: 0, + }, + { + name: "Default 12 hours for duration under 2 characters", + args: args{"2"}, + want: time.Now().Add(12 * time.Hour).Unix(), + }, + { + name: "Default 12 hours for no numbers in the duration", + args: args{"d"}, + want: time.Now().Add(12 * time.Hour).Unix(), + }, + { + name: "Default 12 hours for wrong duration", + args: args{"3r"}, + want: time.Now().Add(12 * time.Hour).Unix(), + }, + { + name: "Default 12 hours for missing duration", + args: args{""}, + want: time.Now().Add(12 * time.Hour).Unix(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := UnixTimeIn(tt.args.durationString); got-tt.want >= 100 { + t.Errorf("UnixTimeIn() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIsChatAdmin(t *testing.T) { + + type args struct { + msg *tgbotapi.Message + } + + adminMap := map[int]bool{1: true} + modMap := map[int]bool{2: true} + repository.SetAdmins(adminMap) + repository.SetMods(modMap) + + tests := []struct { + name string + args args + want bool + }{ + { + name: "Admin", + args: args{ + msg: &tgbotapi.Message{From: &tgbotapi.User{ID: 1}}, + }, + want: true, + }, + { + name: "Mod", + args: args{ + msg: &tgbotapi.Message{From: &tgbotapi.User{ID: 1}}, + }, + want: true, + }, + { + name: "Nothing", + args: args{ + msg: &tgbotapi.Message{From: &tgbotapi.User{ID: 3}}, + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsChatAdminByMessage(tt.args.msg); got != tt.want { + t.Errorf("IsChatAdminByMessage() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/utility/web.go b/utility/web.go new file mode 100644 index 0000000..b5b04f3 --- /dev/null +++ b/utility/web.go @@ -0,0 +1,95 @@ +package utility + +import ( + "fmt" + "net/http" + "strings" + + "golang.org/x/net/html" +) + +//UnshortenURL unshortens an URL performing a web request +func UnshortenURL(shortURL string) (unshortenedURL string, err error) { + + if !strings.HasPrefix(shortURL, "http") { + shortURL = fmt.Sprintf("http://%s", shortURL) + } + + data, err := http.Head(shortURL) // nolint: gosec + if err != nil { + return + } + defer CloseSafely(data.Body) + + unshortenedURL = data.Request.URL.String() + return unshortenedURL, err +} + +//IsGroupOrChannelHandle performs a web request to see if an handle belongs to a channel or a group +func IsGroupOrChannelHandle(handle string) bool { + + //Target URL creation + url := fmt.Sprintf("https://t.me/%s", handle) + + //Web request + data, err := http.Get(url) // nolint: gosec + if err != nil { + return false + } + defer CloseSafely(data.Body) + + //HTML parsing + bodyResp, err := html.Parse(data.Body) + if err != nil { + return false + } + + htmlNode := getElementByClass(bodyResp, "tgme_action_button_new") + if htmlNode == nil { + return false + } + + return htmlNode.FirstChild.Data == "View in Telegram" +} + +/* + * FUNCTIONS FOR WEB REQUESTS + * used in IsUnwantedHandle + */ +func getAttribute(n *html.Node, key string) (string, bool) { + for _, attr := range n.Attr { + if attr.Key == key { + return attr.Val, true + } + } + return "", false +} + +func checkClass(n *html.Node, class string) bool { + if n.Type == html.ElementNode { + s, ok := getAttribute(n, "class") + if ok && s == class { + return true + } + } + return false +} + +func traverse(n *html.Node, class string) *html.Node { + if checkClass(n, class) { + return n + } + + for c := n.FirstChild; c != nil; c = c.NextSibling { + result := traverse(c, class) + if result != nil { + return result + } + } + + return nil +} + +func getElementByClass(n *html.Node, class string) *html.Node { + return traverse(n, class) +} diff --git a/utility/web_test.go b/utility/web_test.go new file mode 100644 index 0000000..56a8a4d --- /dev/null +++ b/utility/web_test.go @@ -0,0 +1,90 @@ +package utility + +import ( + "testing" +) + +func TestUnshortenURL(t *testing.T) { + tests := []struct { + name string + URL string + wantUnshortenedURL string + wantErr bool + }{ + { + name: "Shortened URL", + URL: "https://bit.ly/2WA4TBH", + wantUnshortenedURL: "https://shitposting.io/", + wantErr: false, + }, + { + name: "Unshortened URL", + URL: "https://shitposting.io/", + wantUnshortenedURL: "https://shitposting.io/", + wantErr: false, + }, + { + name: "Telegram url no http", + URL: "t.me/shitpost", + wantUnshortenedURL: "https://t.me/shitpost", + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotUnshortenedURL, err := UnshortenURL(tt.URL) + if (err != nil) != tt.wantErr { + t.Errorf("UnshortenURL() error = %v, wantErr %v", err, tt.wantErr) + return + } + if gotUnshortenedURL != tt.wantUnshortenedURL { + t.Errorf("UnshortenURL() = %v, want %v", gotUnshortenedURL, tt.wantUnshortenedURL) + } + }) + } +} + +func TestIsGroupOrChannelHandle(t *testing.T) { + tests := []struct { + name string + handle string + want bool + }{ + { + name: "Channel", + handle: "shitpost", + want: true, + }, + + { + name: "Group", + handle: "thememaly", + want: true, + }, + + { + name: "User", + handle: "emaele_", + want: false, + }, + + { + name: "Bot", + handle: "levelinebot", + want: false, + }, + + { + name: "Invalid Handle", + handle: "rijfioerjgiojerigjoiejgoijeoigrj", + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsGroupOrChannelHandle(tt.handle); got != tt.want { + t.Errorf("IsGroupOrChannelHandle() = %v, want %v", got, tt.want) + } + }) + } +}