├── .gitignore ├── .gitlab-ci.yml ├── Dockerfile ├── LICENSE ├── README.md ├── bot ├── __init__.py ├── __main__.py ├── blocklists.py ├── commandsworker.py ├── config_reader.py ├── filters │ ├── __init__.py │ └── supported_media.py ├── handlers │ ├── __init__.py │ ├── admin_no_reply.py │ ├── adminmode.py │ ├── bans.py │ ├── message_edit.py │ ├── unsupported_reply.py │ └── usermode.py ├── locales │ └── ru │ │ ├── errors.ftl │ │ └── strings.ftl └── middlewares │ ├── __init__.py │ └── l10n.py ├── docker-compose.example.yml ├── env_example ├── feedback-bot.example.service ├── requirements.txt └── screenshots ├── what_admin_sees.png └── what_user_sees.png /.gitignore: -------------------------------------------------------------------------------- 1 | /venv/ 2 | .idea/ 3 | __pycache__/ 4 | .env 5 | feedback-bot.service 6 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: jdrouet/docker-with-buildx:stable 2 | 3 | variables: 4 | DOCKER_HOST: tcp://docker:2375 5 | DOCKER_TLS_CERTDIR: "" 6 | 7 | services: 8 | - name: docker:19.03.12-dind 9 | entrypoint: ["env", "-u", "DOCKER_HOST"] 10 | command: ["dockerd-entrypoint.sh"] 11 | 12 | before_script: 13 | - docker login -u $REGISTRY_USERNAME -p $USER_TOKEN $CI_REGISTRY 14 | 15 | stages: 16 | - build 17 | 18 | 19 | bot:latest: 20 | stage: build 21 | rules: 22 | - if: '$CI_COMMIT_BRANCH == "master"' 23 | when: on_success 24 | script: 25 | - | 26 | docker buildx create --use 27 | docker buildx build --platform "linux/amd64" --push -t "$CI_REGISTRY_IMAGE/amd64:latest" . 28 | docker buildx build --platform "linux/arm64" --push -t "$CI_REGISTRY_IMAGE/arm64:latest" . 29 | 30 | 31 | bot:tagged: 32 | stage: build 33 | rules: 34 | - if: '$CI_COMMIT_TAG =~ /^v([0-9.]+)$/' 35 | when: on_success 36 | script: 37 | - | 38 | docker buildx create --use 39 | docker buildx build --platform "linux/amd64" --push -t "$CI_REGISTRY_IMAGE/amd64:$CI_COMMIT_TAG" . 40 | docker buildx build --platform "linux/arm64" --push -t "$CI_REGISTRY_IMAGE/arm64:$CI_COMMIT_TAG" . 41 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Отдельный сборочный образ, чтобы уменьшить финальный размер образа 2 | FROM python:3.9-slim-bullseye as compile-image 3 | RUN python -m venv /opt/venv 4 | ENV PATH="/opt/venv/bin:$PATH" 5 | COPY requirements.txt . 6 | RUN pip install --no-cache-dir --upgrade pip \ 7 | && pip install --no-cache-dir -r requirements.txt 8 | 9 | # Окончательный образ 10 | FROM python:3.9-slim-bullseye 11 | COPY --from=compile-image /opt/venv /opt/venv 12 | ENV PATH="/opt/venv/bin:$PATH" 13 | WORKDIR /app 14 | COPY bot /app/bot 15 | CMD ["python", "-m", "bot"] 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-now Groosha 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Feedback Bot 2 | 3 | 4 | 5 | 6 | ## Предыстория 7 | 8 | Когда-то давно в Telegram все пересланные сообщения содержали информацию об авторе, в частности, ID. Благодаря этому 9 | можно было легко делать ботов для обратной связи, когда юзер пишет боту, а автор бота через него отвечает. К сожалению, 10 | в марте 2019 года всё [изменилось](https://telegram.org/blog/unsend-privacy-emoji#anonymous-forwarding) и пересланные сообщения 11 | от некоторых людей потеряли информацию об отправителях. 12 | 13 | Для решения этой проблемы разработчики ботов стали сохранять ID авторов сообщений на стороне бота, а затем провязывать 14 | эти айдишники, но я лично считаю такой подход избыточным, т.к. такие данные, по сути, должны храниться вечно (мало ли, 15 | на какое сообщение вы решите ответить). В результате появился этот бот. Из плюсов: элементарно контейнеризируется, поскольку 16 | хранит всё в оперативной памяти (например, списки блокировок). Из минусов: поддерживает только те сообщения 17 | от пользователей, где можно добавлять подпись или редактировать текст, не поддерживает возможность сделать "ответ" (reply) 18 | на сообщение (в теории, решаемо) и не позволяет корректно реагировать на редактирование сообщений. Лично меня устраивает 19 | такой расклад. 20 | 21 | ## Принцип работы 22 | 23 | Сообщения от пользователей копируются методом [copyMessage](https://core.telegram.org/bots/api#copymessage) 24 | в чат к админу (или админам) с добавлением ID пользователя в виде хэштега, например, #id1234567, к тексту или подписи 25 | к медиафайлу. Когда администратор отвечает на сообщение, этот хэштег извлекается, парсится и используется в качестве 26 | получателя. 27 | 28 | Как переписку видит пользователь: 29 | 30 | ![Как переписку видит пользователь](screenshots/what_user_sees.png "Совершенно не постановочный диалог :)") 31 | 32 | В свою очередь, администратор видит так (и может пользоваться расширенным набором команд): 33 | 34 | ![Как переписку видит администратор](screenshots/what_admin_sees.png "Всё под рукой") 35 | 36 | ## Установка 37 | 38 | ### Системные требования: 39 | 1. Python 3.9 и выше (не нужно при запуске с Docker); 40 | 2. Linux (должно работать на Windows, но могут быть сложности с установкой); 41 | 3. Systemd (для запуска через systemd); 42 | 4. Docker (для запуска с Docker). Старые версии Docker требуют отдельно docker-compose. 43 | 44 | ### Просто потестировать (не рекомендуется) 45 | 1. Клонируйте репозиторий; 46 | 2. Перейдите (`cd`) в склонированный каталог и создайте виртуальное окружение Python (Virtual environment, venv); 47 | 3. Активируйте venv и установите все зависимости из `requirements.txt`; 48 | 4. Скопируйте `env_example` под именем `.env` (с точкой в начале), откройте его и заполните переменные; 49 | 5. Внутри активированного venv: `python -m bot`. 50 | 51 | ### Systemd 52 | 1. Выполните шаги 1-4 из раздела "просто потестировать" выше; 53 | 2. Скопируйте `feedback-bot.example.service` в `feedback-bot.service`, откройте и отредактируйте переменные `WorkingDirectory` 54 | и `ExecStart`; 55 | 3. Скопируйте (или создайте симлинк) файла службы в каталог `/etc/systemd/system/`; 56 | 4. Активируйте сервис и запустите его: `sudo systemctl enable feedback-bot --now`; 57 | 5. Проверьте, что сервис запустился: `systemctcl status feedback-bot` (можно без root-прав). 58 | 59 | ### Docker + Docker Compose 60 | 1. Возьмите файл `docker-compose.example.yml` из репозитория и переименуйте как `docker-compose.yml`; 61 | 2. Возьмите файл `env_example` там же, переименуйте как `.env` (с точкой в начале), откройте и заполните переменные; 62 | 3. Запустите бота: `docker compose up -d` (или `docker-compose up -d` на старых версиях Docker); 63 | 4. Проверьте, что контейнер поднялся: `docker compose ps` 64 | 65 | ## Локализация 66 | 67 | Если вы хотите изменить тексты в боте, ознакомьтесь с информацией в 68 | [Wiki](https://github.com/MasterGroosha/telegram-feedback-bot/wiki). В настоящий момент поддерживается только 69 | изменение текстов сообщений, но не описаний в меню команд 70 | 71 | Папку `bot/locales` в случае с развертыванием бота в Docker можно переопределить, подсунув её снаружи как volume. -------------------------------------------------------------------------------- /bot/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MasterGroosha/telegram-feedback-bot/5a0b4dfd50f782a4a06f8e9b7492594d142c4b37/bot/__init__.py -------------------------------------------------------------------------------- /bot/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from aiohttp import web 5 | from aiogram import Bot, Dispatcher 6 | from aiogram.client.telegram import TelegramAPIServer 7 | from aiogram.webhook.aiohttp_server import SimpleRequestHandler 8 | from bot.handlers import setup_routers 9 | from fluent.runtime import FluentLocalization, FluentResourceLoader 10 | from bot.commandsworker import set_bot_commands 11 | from bot.middlewares import L10nMiddleware 12 | from pathlib import Path 13 | 14 | from bot.config_reader import config 15 | 16 | 17 | async def main(): 18 | # Настройка логирования в stdout 19 | logging.basicConfig( 20 | level=logging.INFO, 21 | format="%(asctime)s - %(levelname)s - %(name)s - %(message)s", 22 | ) 23 | 24 | # Получение пути до каталога locales относительно текущего файла 25 | locales_dir = Path(__file__).parent.joinpath("locales") 26 | # Создание объектов Fluent 27 | # FluentResourceLoader использует фигурные скобки, поэтому f-strings здесь нельзя 28 | l10n_loader = FluentResourceLoader(str(locales_dir) + "/{locale}") 29 | l10n = FluentLocalization(["ru"], ["strings.ftl", "errors.ftl"], l10n_loader) 30 | 31 | bot = Bot(token=config.bot_token.get_secret_value()) 32 | dp = Dispatcher() 33 | router = setup_routers() 34 | dp.include_router(router) 35 | 36 | if config.custom_bot_api: 37 | bot.session.api = TelegramAPIServer.from_base(config.custom_bot_api, is_local=True) 38 | 39 | # Регистрация мидлварей 40 | dp.update.middleware(L10nMiddleware(l10n)) 41 | 42 | # Регистрация /-команд в интерфейсе 43 | await set_bot_commands(bot) 44 | 45 | try: 46 | if not config.webhook_domain: 47 | await bot.delete_webhook() 48 | await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types()) 49 | else: 50 | # Выключаем логи от aiohttp 51 | aiohttp_logger = logging.getLogger("aiohttp.access") 52 | aiohttp_logger.setLevel(logging.CRITICAL) 53 | 54 | # Установка вебхука 55 | await bot.set_webhook( 56 | url=config.webhook_domain + config.webhook_path, 57 | drop_pending_updates=True, 58 | allowed_updates=dp.resolve_used_update_types() 59 | ) 60 | 61 | # Создание запуска aiohttp 62 | app = web.Application() 63 | SimpleRequestHandler(dispatcher=dp, bot=bot).register(app, path=config.webhook_path) 64 | runner = web.AppRunner(app) 65 | await runner.setup() 66 | site = web.TCPSite(runner, host=config.app_host, port=config.app_port) 67 | await site.start() 68 | 69 | # Бесконечный цикл 70 | await asyncio.Event().wait() 71 | finally: 72 | await bot.session.close() 73 | 74 | 75 | asyncio.run(main()) 76 | -------------------------------------------------------------------------------- /bot/blocklists.py: -------------------------------------------------------------------------------- 1 | # Самая простая и "тупая" реализация in-memory списков. 2 | # При перезапуске бота всё сбрасывается. 3 | 4 | banned = set() 5 | shadowbanned = set() 6 | -------------------------------------------------------------------------------- /bot/commandsworker.py: -------------------------------------------------------------------------------- 1 | from aiogram import Bot 2 | from aiogram.types import BotCommand, BotCommandScopeDefault, BotCommandScopeChat 3 | 4 | from bot.config_reader import config 5 | 6 | 7 | async def set_bot_commands(bot: Bot): 8 | usercommands = [ 9 | BotCommand(command="help", description="Справка по использованию бота"), 10 | ] 11 | await bot.set_my_commands(usercommands, scope=BotCommandScopeDefault()) 12 | 13 | admin_commands = [ 14 | BotCommand(command="who", description="Получение информации о пользователе"), 15 | BotCommand(command="ban", description="Заблокировать пользователя"), 16 | BotCommand(command="shadowban", description="Скрытно заблокировать пользователя"), 17 | BotCommand(command="unban", description="Разблокировать пользователя"), 18 | BotCommand(command="list_banned", description="Список заблокированных"), 19 | ] 20 | await bot.set_my_commands( 21 | admin_commands, 22 | scope=BotCommandScopeChat(chat_id=config.admin_chat_id) 23 | ) 24 | -------------------------------------------------------------------------------- /bot/config_reader.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseSettings, SecretStr 4 | 5 | 6 | class Settings(BaseSettings): 7 | bot_token: SecretStr 8 | admin_chat_id: int 9 | remove_sent_confirmation: bool 10 | webhook_domain: Optional[str] 11 | webhook_path: Optional[str] 12 | app_host: Optional[str] = "0.0.0.0" 13 | app_port: Optional[int] = 9000 14 | custom_bot_api: Optional[str] 15 | 16 | class Config: 17 | env_file = '.env' 18 | env_file_encoding = 'utf-8' 19 | env_nested_delimiter = '__' 20 | 21 | 22 | config = Settings() 23 | -------------------------------------------------------------------------------- /bot/filters/__init__.py: -------------------------------------------------------------------------------- 1 | from .supported_media import SupportedMediaFilter 2 | 3 | __all__ = [ 4 | "SupportedMediaFilter" 5 | ] 6 | -------------------------------------------------------------------------------- /bot/filters/supported_media.py: -------------------------------------------------------------------------------- 1 | from aiogram.filters import BaseFilter 2 | from aiogram.types import Message, ContentType 3 | 4 | 5 | class SupportedMediaFilter(BaseFilter): 6 | async def __call__(self, message: Message) -> bool: 7 | return message.content_type in ( 8 | ContentType.ANIMATION, ContentType.AUDIO, ContentType.DOCUMENT, 9 | ContentType.PHOTO, ContentType.VIDEO, ContentType.VOICE 10 | ) 11 | -------------------------------------------------------------------------------- /bot/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | 3 | 4 | def setup_routers() -> Router: 5 | from . import unsupported_reply, admin_no_reply, bans, adminmode, message_edit, usermode 6 | 7 | router = Router() 8 | router.include_router(unsupported_reply.router) 9 | router.include_router(bans.router) 10 | router.include_router(admin_no_reply.router) 11 | router.include_router(adminmode.router) 12 | router.include_router(message_edit.router) 13 | router.include_router(usermode.router) 14 | 15 | return router 16 | -------------------------------------------------------------------------------- /bot/handlers/admin_no_reply.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router, F 2 | from aiogram.types import ContentType, Message 3 | from fluent.runtime import FluentLocalization 4 | 5 | from bot.config_reader import config 6 | 7 | router = Router() 8 | router.message.filter(F.chat.id == config.admin_chat_id) 9 | 10 | 11 | @router.message(~F.reply_to_message) 12 | async def has_no_reply(message: Message, l10n: FluentLocalization): 13 | """ 14 | Хэндлер на сообщение от админа, не содержащее ответ (reply). 15 | В этом случае надо кинуть ошибку. 16 | 17 | :param message: сообщение от админа, не являющееся ответом на другое сообщение 18 | :param l10n: объект локализации 19 | """ 20 | if message.content_type not in (ContentType.NEW_CHAT_MEMBERS, ContentType.LEFT_CHAT_MEMBER): 21 | await message.reply(l10n.format_value("no-reply-error")) 22 | -------------------------------------------------------------------------------- /bot/handlers/adminmode.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router, F, Bot 2 | from aiogram.exceptions import TelegramAPIError 3 | from aiogram.filters import Command 4 | from aiogram.types import Message, Chat 5 | from fluent.runtime import FluentLocalization 6 | 7 | from bot.config_reader import config 8 | 9 | router = Router() 10 | router.message.filter(F.chat.id == config.admin_chat_id) 11 | 12 | 13 | def extract_id(message: Message) -> int: 14 | """ 15 | Извлекает ID юзера из хэштега в сообщении 16 | 17 | :param message: сообщение, из хэштега в котором нужно достать айди пользователя 18 | :return: ID пользователя, извлечённый из хэштега в сообщении 19 | """ 20 | # Получение списка сущностей (entities) из текста или подписи к медиафайлу в отвечаемом сообщении 21 | entities = message.entities or message.caption_entities 22 | # Если всё сделано верно, то последняя (или единственная) сущность должна быть хэштегом... 23 | if not entities or entities[-1].type != "hashtag": 24 | raise ValueError("Не удалось извлечь ID для ответа!") 25 | 26 | # ... более того, хэштег должен иметь вид #id123456, где 123456 — ID получателя 27 | hashtag = entities[-1].extract_from(message.text or message.caption) 28 | if len(hashtag) < 4 or not hashtag[3:].isdigit(): # либо просто #id, либо #idНЕЦИФРЫ 29 | raise ValueError("Некорректный ID для ответа!") 30 | 31 | return int(hashtag[3:]) 32 | 33 | 34 | @router.message(Command(commands=["get", "who"]), F.reply_to_message) 35 | async def get_user_info(message: Message, bot: Bot, l10n: FluentLocalization): 36 | """ 37 | Обработчик команд /get и /who. Получает информацию о пользователе. 38 | 39 | :param message: объект сообщения, на которое админ ответил одной из команд выше 40 | :param bot: объект бота, который обрабатывает текущий апдейт 41 | :param l10n: объект локализации 42 | """ 43 | def get_full_name(chat: Chat): 44 | if not chat.first_name: 45 | return "" 46 | if not chat.last_name: 47 | return chat.first_name 48 | return f"{chat.first_name} {chat.last_name}" 49 | 50 | try: 51 | user_id = extract_id(message.reply_to_message) 52 | except ValueError as ex: 53 | return await message.reply(str(ex)) 54 | 55 | try: 56 | user = await bot.get_chat(user_id) 57 | except TelegramAPIError as ex: 58 | await message.reply( 59 | l10n.format_value( 60 | msg_id="cannot-get-user-info-error", 61 | args={"error": ex.message}) 62 | ) 63 | return 64 | 65 | u = f"@{user.username}" if user.username else l10n.format_value("no") 66 | await message.reply( 67 | l10n.format_value( 68 | msg_id="user-info", 69 | args={ 70 | "name": get_full_name(user), 71 | "id": user.id, 72 | "username": u 73 | } 74 | ) 75 | ) 76 | 77 | 78 | @router.message(F.reply_to_message) 79 | async def reply_to_user(message: Message, l10n: FluentLocalization): 80 | """ 81 | Ответ администратора на сообщение юзера (отправленное ботом). 82 | Используется метод copy_message, поэтому ответить можно чем угодно, хоть опросом. 83 | 84 | :param message: сообщение от админа, являющееся ответом на другое сообщение 85 | :param l10n: объект локализации 86 | """ 87 | 88 | # Вырезаем ID 89 | try: 90 | user_id = extract_id(message.reply_to_message) 91 | except ValueError as ex: 92 | return await message.reply(str(ex)) 93 | 94 | # Пробуем отправить копию сообщения. 95 | # В теории, это можно оформить через errors_handler, но мне так нагляднее 96 | try: 97 | await message.copy_to(user_id) 98 | except TelegramAPIError as ex: 99 | await message.reply( 100 | l10n.format_value( 101 | msg_id="cannot-answer-to-user-error", 102 | args={"error": ex.message}) 103 | ) 104 | -------------------------------------------------------------------------------- /bot/handlers/bans.py: -------------------------------------------------------------------------------- 1 | from contextlib import suppress 2 | 3 | from aiogram import Router, F 4 | from aiogram.filters import Command 5 | from aiogram.types import Message 6 | from fluent.runtime import FluentLocalization 7 | 8 | from bot.blocklists import banned, shadowbanned 9 | from bot.config_reader import config 10 | from bot.handlers.adminmode import extract_id 11 | 12 | router = Router() 13 | router.message.filter(F.chat.id == config.admin_chat_id) 14 | 15 | 16 | @router.message(Command(commands=["ban"]), F.reply_to_message) 17 | async def cmd_ban(message: Message, l10n: FluentLocalization): 18 | try: 19 | user_id = extract_id(message.reply_to_message) 20 | except ValueError as ex: 21 | return await message.reply(str(ex)) 22 | banned.add(int(user_id)) 23 | await message.reply( 24 | l10n.format_value( 25 | msg_id="user-banned", 26 | args={"id": user_id} 27 | ) 28 | ) 29 | 30 | 31 | @router.message(Command(commands=["shadowban"]), F.reply_to_message) 32 | async def cmd_shadowban(message: Message, l10n: FluentLocalization): 33 | try: 34 | user_id = extract_id(message.reply_to_message) 35 | except ValueError as ex: 36 | return await message.reply(str(ex)) 37 | shadowbanned.add(int(user_id)) 38 | await message.reply( 39 | l10n.format_value( 40 | msg_id="user-shadowbanned", 41 | args={"id": user_id} 42 | ) 43 | ) 44 | 45 | 46 | @router.message(Command(commands=["unban"]), F.reply_to_message) 47 | async def cmd_unban(message: Message, l10n: FluentLocalization): 48 | try: 49 | user_id = extract_id(message.reply_to_message) 50 | except ValueError as ex: 51 | return await message.reply(str(ex)) 52 | user_id = int(user_id) 53 | with suppress(KeyError): 54 | banned.remove(user_id) 55 | with suppress(KeyError): 56 | shadowbanned.remove(user_id) 57 | await message.reply( 58 | l10n.format_value( 59 | msg_id="user-unbanned", 60 | args={"id": user_id} 61 | ) 62 | ) 63 | 64 | 65 | @router.message(Command(commands=["list_banned"])) 66 | async def cmd_list_banned(message: Message, l10n: FluentLocalization): 67 | has_bans = len(banned) > 0 or len(shadowbanned) > 0 68 | if not has_bans: 69 | await message.answer(l10n.format_value("no-banned")) 70 | return 71 | result = [] 72 | if len(banned) > 0: 73 | result.append(l10n.format_value("list-banned-title")) 74 | for item in banned: 75 | result.append(f"• #id{item}") 76 | if len(shadowbanned) > 0: 77 | result.append('\n{}'.format(l10n.format_value("list-shadowbanned-title"))) 78 | for item in shadowbanned: 79 | result.append(f"• #id{item}") 80 | 81 | await message.answer("\n".join(result)) 82 | -------------------------------------------------------------------------------- /bot/handlers/message_edit.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | from aiogram.types import Message 3 | from fluent.runtime import FluentLocalization 4 | 5 | 6 | router = Router() 7 | 8 | 9 | @router.edited_message() 10 | async def edited_message_warning(message: Message, l10n: FluentLocalization): 11 | """ 12 | Хэндлер на редактирование сообщений. 13 | В настоящий момент реакция на редактирование с любой стороны одна: уведомлять о невозможности 14 | изменить нужное сообщение на стороне получателя. 15 | 16 | :param message: отредактированное пользователем или админом сообщение 17 | :param l10n: объект локализации 18 | """ 19 | await message.reply(l10n.format_value("cannot-update-edited-error")) 20 | -------------------------------------------------------------------------------- /bot/handlers/unsupported_reply.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router, F 2 | from aiogram.types import Message 3 | from fluent.runtime import FluentLocalization 4 | 5 | from bot.config_reader import config 6 | 7 | router = Router() 8 | 9 | 10 | @router.message(F.reply_to_message, F.chat.id == config.admin_chat_id, F.poll) 11 | async def unsupported_admin_reply_types(message: Message, l10n: FluentLocalization): 12 | """ 13 | Хэндлер на неподдерживаемые типы сообщений, т.е. те, которые не имеют смысла 14 | для копирования. Например, опросы (админ не увидит результат) 15 | 16 | :param message: сообщение от администратора 17 | :param l10n: объект локализации 18 | """ 19 | await message.reply(l10n.format_value("cannot-reply-with-this-type-error")) 20 | -------------------------------------------------------------------------------- /bot/handlers/usermode.py: -------------------------------------------------------------------------------- 1 | from asyncio import create_task, sleep 2 | 3 | from aiogram import Router, F, Bot 4 | from aiogram.filters import Command 5 | from aiogram.types import ContentType 6 | from aiogram.types import Message 7 | from fluent.runtime import FluentLocalization 8 | 9 | from bot.blocklists import banned, shadowbanned 10 | from bot.config_reader import config 11 | from bot.filters import SupportedMediaFilter 12 | 13 | router = Router() 14 | 15 | 16 | async def _send_expiring_notification(message: Message, l10n: FluentLocalization): 17 | """ 18 | Отправляет "самоуничтожающееся" через 5 секунд сообщение 19 | 20 | :param message: сообщение, на которое бот отвечает подтверждением отправки 21 | :param l10n: объект локализации 22 | """ 23 | msg = await message.reply(l10n.format_value("sent-confirmation")) 24 | if config.remove_sent_confirmation: 25 | await sleep(5.0) 26 | await msg.delete() 27 | 28 | 29 | @router.message(Command(commands=["start"])) 30 | async def cmd_start(message: Message, l10n: FluentLocalization): 31 | """ 32 | Приветственное сообщение от бота пользователю 33 | 34 | :param message: сообщение от пользователя с командой /start 35 | :param l10n: объект локализации 36 | """ 37 | await message.answer(l10n.format_value("intro")) 38 | 39 | 40 | @router.message(Command(commands=["help"])) 41 | async def cmd_help(message: Message, l10n: FluentLocalization): 42 | """ 43 | Справка для пользователя 44 | 45 | :param message: сообщение от пользователя с командой /help 46 | :param l10n: объект локализации 47 | """ 48 | await message.answer(l10n.format_value("help")) 49 | 50 | 51 | @router.message(F.text) 52 | async def text_message(message: Message, bot: Bot, l10n: FluentLocalization): 53 | """ 54 | Хэндлер на текстовые сообщения от пользователя 55 | 56 | :param message: сообщение от пользователя для админа(-ов) 57 | :param l10n: объект локализации 58 | """ 59 | if len(message.text) > 4000: 60 | return await message.reply(l10n.format_value("too-long-text-error")) 61 | 62 | if message.from_user.id in banned: 63 | await message.answer(l10n.format_value("you-were-banned-error")) 64 | elif message.from_user.id in shadowbanned: 65 | return 66 | else: 67 | await bot.send_message( 68 | config.admin_chat_id, 69 | message.html_text + f"\n\n#id{message.from_user.id}", parse_mode="HTML" 70 | ) 71 | create_task(_send_expiring_notification(message, l10n)) 72 | 73 | 74 | @router.message(SupportedMediaFilter()) 75 | async def supported_media(message: Message, l10n: FluentLocalization): 76 | """ 77 | Хэндлер на медиафайлы от пользователя. 78 | Поддерживаются только типы, к которым можно добавить подпись (полный список см. в регистраторе внизу) 79 | 80 | :param message: медиафайл от пользователя 81 | :param l10n: объект локализации 82 | """ 83 | if message.caption and len(message.caption) > 1000: 84 | return await message.reply(l10n.format_value("too-long-caption-error")) 85 | if message.from_user.id in banned: 86 | await message.answer(l10n.format_value("you-were-banned-error")) 87 | elif message.from_user.id in shadowbanned: 88 | return 89 | else: 90 | await message.copy_to( 91 | config.admin_chat_id, 92 | caption=((message.caption or "") + f"\n\n#id{message.from_user.id}"), 93 | parse_mode="HTML" 94 | ) 95 | create_task(_send_expiring_notification(message, l10n)) 96 | 97 | 98 | @router.message() 99 | async def unsupported_types(message: Message, l10n: FluentLocalization): 100 | """ 101 | Хэндлер на неподдерживаемые типы сообщений, т.е. те, к которым нельзя добавить подпись 102 | 103 | :param message: сообщение от пользователя 104 | :param l10n: объект локализации 105 | """ 106 | # Игнорируем служебные сообщения 107 | if message.content_type not in ( 108 | ContentType.NEW_CHAT_MEMBERS, ContentType.LEFT_CHAT_MEMBER, ContentType.VIDEO_CHAT_STARTED, 109 | ContentType.VIDEO_CHAT_ENDED, ContentType.VIDEO_CHAT_PARTICIPANTS_INVITED, 110 | ContentType.MESSAGE_AUTO_DELETE_TIMER_CHANGED, ContentType.NEW_CHAT_PHOTO, ContentType.DELETE_CHAT_PHOTO, 111 | ContentType.SUCCESSFUL_PAYMENT, "proximity_alert_triggered", # в 3.0.0b3 нет поддержка этого контент-тайпа 112 | ContentType.NEW_CHAT_TITLE, ContentType.PINNED_MESSAGE): 113 | await message.reply(l10n.format_value("unsupported-message-type-error")) 114 | -------------------------------------------------------------------------------- /bot/locales/ru/errors.ftl: -------------------------------------------------------------------------------- 1 | no-reply-error = Это сообщение не является ответом на какое-либо другое! 2 | 3 | cannot-answer-to-user-error = 4 | Не удалось отправить сообщение адресату! 5 | Ответ от Telegram: { $error } 6 | 7 | cannot-get-user-info-error = Не удалось получить информацию о пользователе! Ошибка: { $error } 8 | 9 | cannot-update-edited-error = К сожалению, редактирование сообщения не будет видно принимающей стороне. Рекомендую просто отправить новое сообщение. 10 | 11 | cannot-reply-with-this-type-error = К сожалению, этот тип сообщения не поддерживается для ответа пользователю. 12 | 13 | too-long-text-error = К сожалению, длина этого сообщения превышает допустимый размер. Пожалуйста, сократи свою мысль и попробуй ещё раз. 14 | 15 | too-long-caption-error = К сожалению, длина подписи медиафайла превышает допустимый размер. Пожалуйста, сократи свою мысль и попробуй ещё раз. 16 | 17 | you-were-banned-error = К сожалению, автор бота решил тебя заблокировать, сообщения не будут доставлены. 18 | 19 | unsupported-message-type-error = К сожалению, этот тип сообщения не поддерживается. Отправь что-нибудь другое. 20 | -------------------------------------------------------------------------------- /bot/locales/ru/strings.ftl: -------------------------------------------------------------------------------- 1 | no = нет 2 | user-info = 3 | Имя: { $name } 4 | ID: { NUMBER($id, useGrouping: 0) } 5 | Username: { $username } 6 | user-banned = ID { NUMBER($id, useGrouping: 0) } добавлен в список заблокированных. При попытке отправить сообщение пользователь получит уведомление о том, что заблокирован. 7 | user-shadowbanned = ID { NUMBER($id, useGrouping: 0) } добавлен в список скрытно заблокированных. При попытке отправить сообщение пользователь не узнает, что заблокирован. 8 | user-unbanned = ID { NUMBER($id, useGrouping: 0) } разблокирован. 9 | 10 | no-banned = Нет заблокированных пользователей. 11 | list-banned-title = Список заблокированных: 12 | list-shadowbanned-title = Список скрытно заблокированных: 13 | 14 | sent-confirmation = Сообщение отправлено! 15 | 16 | intro = 17 | Привет ✌️ 18 | C моей помощью ты можешь связаться с моим хозяином и получить от него ответ. Просто напиши что-нибудь в этот диалог. 19 | 20 | help = 21 | С моей помощью ты можешь связаться с владельцем этого бота и получить от него ответ. 22 | Просто продолжай писать в этот диалог, но учти, что поддерживаются не все типы сообщений, а только текст, фото, видео, аудио, файлы и голосовые сообщения (последние лучше не использовать без крайней необходимости). -------------------------------------------------------------------------------- /bot/middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | from .l10n import L10nMiddleware 2 | 3 | __all__ = [ 4 | "L10nMiddleware" 5 | ] 6 | -------------------------------------------------------------------------------- /bot/middlewares/l10n.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Dict, Any, Awaitable 2 | 3 | from aiogram import BaseMiddleware 4 | from aiogram.types import Message 5 | from fluent.runtime import FluentLocalization 6 | 7 | 8 | class L10nMiddleware(BaseMiddleware): 9 | def __init__(self, l10n_object: FluentLocalization): 10 | self.l10n_object = l10n_object 11 | 12 | async def __call__( 13 | self, 14 | handler: Callable[[Message, Dict[str, Any]], Awaitable[Any]], 15 | event: Message, 16 | data: Dict[str, Any] 17 | ) -> Any: 18 | data["l10n"] = self.l10n_object 19 | await handler(event, data) 20 | -------------------------------------------------------------------------------- /docker-compose.example.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | bot: 4 | image: groosha/telegram-feedback-bot:latest 5 | stop_signal: SIGINT 6 | restart: unless-stopped 7 | volumes: 8 | - ".env:/app/.env" 9 | # Если хотите переопределить локализацию, подложите нужный каталог 10 | # - "/your/path/to/locales:/app/bot/locales" 11 | # Или даже так: 12 | # - "/your/custom/language:/app/bot/locales/ru" 13 | -------------------------------------------------------------------------------- /env_example: -------------------------------------------------------------------------------- 1 | # Токен бота, можно получить у https://t.me/botfather 2 | BOT_TOKEN=1234567890:AaBbCcDdEeFGgHhIiJjKkLlMmNnOoPpQq 3 | 4 | # ID чата, куда будут приходить сообщения. Можно даже группа или канал, 5 | # в этом случае все участники группы/канала смогут отвечать на сообщения 6 | # Чтобы узнать ID группы, добавьте туда бота @my_id_bot, при добавлении он сам напишет айди. 7 | # Чтобы узнать ID канала, перешлите оттуда любое сообщение боту @my_id_bot. 8 | # Учтите, что айди групп и каналов отрицательный (со знаком минус в начале), в этом случае указывать надо тоже 9 | # со знаком минус! Например, -987654321 10 | ADMIN_CHAT_ID=987654321 11 | 12 | # Удалять или нет подтверждения об отправке сообщений от юзера админам. 13 | # Чтобы удалялось, укажите "yes", "1" или "true" (без кавычек) 14 | REMOVE_SENT_CONFIRMATION=yes 15 | 16 | # Основной домен для установки вебхука 17 | # Если значение непустое, считаем, что бот работает по вебхукам 18 | # WEBHOOK_DOMAIN=feedbackbot.example.com 19 | 20 | # Путь к URL вебхука на домене 21 | # WEBHOOK_PATH=/extra/path/from/root 22 | 23 | # IP-адрес интерфейса, на котором запускать бота. 0.0.0.0 == на всех интерфейсах 24 | # APP_HOST=0.0.0.0 25 | 26 | # Порт интерфейса, на котором запускать бота 27 | # APP_PORT=9000 28 | 29 | # Адрес собственного сервера Bot API (необязательно) 30 | # Непустое значение является признаком включения локального режима 31 | # CUSTOM_BOT_API = http://127.0.0.1:8081 -------------------------------------------------------------------------------- /feedback-bot.example.service: -------------------------------------------------------------------------------- 1 | # Rename to feedback-bot.service 2 | [Unit] 3 | Description=Telegram Feedback Bot 4 | After=network.target 5 | 6 | [Service] 7 | Type=simple 8 | WorkingDirectory=/home/user/telegram-feedback-bot 9 | ExecStart=/home/user/telegram-feedback-bot/venv/bin/python -m bot 10 | KillMode=process 11 | Restart=always 12 | RestartSec=10 13 | 14 | [Install] 15 | WantedBy=multi-user.target 16 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiogram==3.0.0b7 2 | python-dotenv==1.0.0 3 | pydantic==1.10.5 4 | fluent.runtime==0.3.1 -------------------------------------------------------------------------------- /screenshots/what_admin_sees.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MasterGroosha/telegram-feedback-bot/5a0b4dfd50f782a4a06f8e9b7492594d142c4b37/screenshots/what_admin_sees.png -------------------------------------------------------------------------------- /screenshots/what_user_sees.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MasterGroosha/telegram-feedback-bot/5a0b4dfd50f782a4a06f8e9b7492594d142c4b37/screenshots/what_user_sees.png --------------------------------------------------------------------------------