Skip to content

Commit

Permalink
2024 Edition (#9)
Browse files Browse the repository at this point in the history
  • Loading branch information
MasterGroosha authored Nov 26, 2024
1 parent 37417b7 commit 5fb1b5d
Show file tree
Hide file tree
Showing 35 changed files with 968 additions and 591 deletions.
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**/__pycache__/
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/venv/
.idea/
/.env
/docker-compose.yml
/settings.toml
10 changes: 4 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
# Separate build image
FROM python:3.9-slim-bullseye as compile-image
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
FROM python:3.12-slim AS builder
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt

# Final image
FROM python:3.9-slim-bullseye
COPY --from=compile-image /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
FROM python:3.12-slim
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3/site-packages
ENV PYTHONPATH=/usr/local/lib/python3/site-packages
WORKDIR /app
COPY bot /app/bot
CMD ["python", "-m", "bot"]
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2019-2022 Groosha
Copyright (c) 2019-2024 Aleksandr K (also known as Master_Groosha on GitHub)

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
28 changes: 15 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,29 @@ This repository contains source code of a small yet rather powerful bot for Tele
Uses [aiogram](https://github.com/aiogram/aiogram) framework.
The main goal is to build a bot with no external database needed. Thus, it may lack some features, but hey, it's open source!

⚠️ Warning: this bot can be used **ONLY** to notify people (most often, group admins), that someone might
violate group's own rules. If you want to report some content to Telegram official support team, please use "Report"
button in your app.

#### Screenshot

![Left - main group. Right - group for admins only. If you don't see this image, please check GitHub repo](screenshots/cover.png)

#### Features
* `/report` command to gather reports from users;
* Reports can be sent to a dedicated chat or to dialogues with admins;
* `/ro` command to set user "read-only" and `/nomedia` to allow text messages only;
* Reports will be sent to a separate group chat;
* `/ro` command to set user "read-only";
* [optional] Automatically remove "user joined" service messages;
* [optional] Automatically ban channels (since
[December 2021](https://telegram.org/blog/protected-content-delete-by-date-and-more#anonymous-posting-in-public-groups)
users can write on behalf of their channels);
* If text message starts with `@admin`, admins are notified;
* If message starts with `@admin` or `@admins`, admins are notified;
* A simple interface for admins to choose one of actions on reported message;
* English and Russian languages are built-in.
* Use any locale you want, examples for English and Russian languages are included.

#### Requirements
* Python 3.9 and above;
* Tested on Linux, should work on Windows, no platform-specific code is used;
* Systemd (you can use it to enable autostart and autorestart) or Docker.
* Python 3.11+ (developed under 3.12);
* Systemd (you can use it to enable autostart and autorestart), Docker or anything else you prefer.

#### Installation
1. Go to [@BotFather](https://t.me/telegram), create a new bot, write down its token, add it to your existing group
Expand All @@ -34,13 +37,12 @@ and **make bot an admin**. You also need to give it "Delete messages" permission
**Remember**: anyone who is in that group may perform actions like "Delete", "Ban" and so on, so be careful.
3. Use some bot like [@my_id_bot](https://t.me/my_id_bot) to get IDs of these two groups;
4. Clone this repo and `cd` into it;
5. Copy `env_dist` to `.env` (with dot). **Warning**: files starting with dot may be hidden in Linux,
so don't worry if you stop seeing this file, it's still here!
5. Copy `settings.example.toml` to `settings.toml`.
6. Replace default values with your own;
7. Now choose installation method: **systemd** or **Docker**

##### systemd
1. Create a venv (virtual environment): `python3.9 -m venv venv` (or any other Python 3.7+ version);
1. Create a venv (virtual environment): `python3.12 -m venv venv` (or any other Python 3.11+ version);
2. `source venv/bin/activate && pip install -r requirements.txt`;
3. Rename `reportbot.service.example` to `reportbot.service` and move it to `/etc/systemd/system`;
4. Open that file and change values for `WorkingDirectory`, `ExecStart` and `EnvironmentFile` providing the correct
Expand All @@ -49,6 +51,6 @@ path values;
6.Check your bot's status and logs: `systemctl status reportbot.service`.

##### Docker
1. Build and run your container: `docker-compose up -d`.

Alternatively, check [docker-compose.yml](docker-compose.yml) file from this repo.
1. Copy `docker-compose.example.yml` as `docker-compose.yml`.
2. Open it and edit values
3. Build and run your container: `docker-compose up -d`.
100 changes: 43 additions & 57 deletions bot/__main__.py
Original file line number Diff line number Diff line change
@@ -1,78 +1,64 @@
import asyncio
import logging

import structlog
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.exceptions import TelegramAPIError
from aiogram.types import BotCommand, BotCommandScopeChat
from structlog.typing import FilteringBoundLogger

from bot.before_start import fetch_admins, check_rights_and_permissions
from bot.config_reader import config
from bot.handlers import setup_routers
from bot.localization import Lang


async def set_bot_commands(bot: Bot, main_group_id: int):
commands = [
BotCommand(command="report", description="Report message to group admins"),
]
await bot.set_my_commands(commands, scope=BotCommandScopeChat(chat_id=main_group_id))
from bot.before_start import check_bot_rights_main_group, check_bot_rights_reports_group, fetch_admins, set_bot_commands
from bot.config_reader import get_config, LogConfig, BotConfig
from bot.fluent_loader import get_fluent_localization
from bot.handlers import get_routers
from bot.logs import get_structlog_config


async def main():
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
)
log_config: LogConfig = get_config(model=LogConfig, root_key="logs")
structlog.configure(**get_structlog_config(log_config))

# Define bot, dispatcher and include routers to dispatcher
bot = Bot(token=config.bot_token.get_secret_value(), parse_mode="HTML")
dp = Dispatcher()
bot_config: BotConfig = get_config(model=BotConfig, root_key="bot")
bot = Bot(
token=bot_config.token.get_secret_value(),
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)

# Check that bot is admin in "main" group and has necessary permissions
try:
await check_rights_and_permissions(bot, config.group_main)
except (TelegramAPIError, PermissionError) as error:
error_msg = f"Error with main group: {error}"
try:
await bot.send_message(config.group_reports, error_msg)
finally:
print(error_msg)
return
logger: FilteringBoundLogger = structlog.get_logger()

# Collect admins so that we don't have to fetch them every time
# Check that bot can run properly
try:
result = await fetch_admins(bot)
except TelegramAPIError as error:
error_msg = f"Error fetching main group admins: {error}"
try:
await bot.send_message(config.group_reports, error_msg)
finally:
print(error_msg)
return
config.admins = result
await check_bot_rights_main_group(bot, bot_config.main_group_id)
except (TelegramAPIError, PermissionError) as ex:
await logger.aerror(f"Cannot use bot in main group, because {ex.__class__.__name__}: {str(ex)}")
await bot.session.close()
return

try:
lang = Lang(config.lang)
except ValueError:
print(f"Error no localization found for language code: {config.lang}")
await check_bot_rights_reports_group(bot, bot_config.reports_group_id)
except (TelegramAPIError, PermissionError) as ex:
await logger.aerror(f"Cannot use bot in reports group, because {ex.__class__.__name__}: {str(ex)}")
await bot.session.close()
return

# Register handlers
router = setup_routers()
dp.include_router(router)
await set_bot_commands(bot, bot_config.main_group_id)

main_group_admins: dict = await fetch_admins(bot, bot_config.main_group_id)

# Register /-commands in UI
await set_bot_commands(bot, config.group_main)
l10n = get_fluent_localization()

logging.info("Starting bot")
dp = Dispatcher(
admins=main_group_admins,
bot_config=bot_config,
l10n=l10n,
)
dp.include_routers(*get_routers(
main_group_id=bot_config.main_group_id,
reports_group_id=bot_config.reports_group_id,
))

# Start polling
# await bot.get_updates(offset=-1) # skip pending updates (optional)
await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types(), lang=lang)

await logger.ainfo("Program started...")
await dp.start_polling(bot)

if __name__ == '__main__':
try:
asyncio.run(main())
except (KeyboardInterrupt, SystemExit):
logging.error("Bot stopped!")
asyncio.run(main())
54 changes: 40 additions & 14 deletions bot/before_start.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,53 @@
from typing import Dict, List, Union

from aiogram import Bot
from aiogram.types import ChatMemberAdministrator, ChatMemberOwner
from aiogram.types import (
ChatMemberAdministrator, ChatMemberOwner, ChatMemberBanned, ChatMemberLeft, ChatMemberRestricted,
BotCommand, BotCommandScopeChat
)


async def check_bot_rights_main_group(
bot: Bot,
main_group_id: int,
):
chat_member_info = await bot.get_chat_member(
chat_id=main_group_id, user_id=bot.id
)
if not isinstance(chat_member_info, ChatMemberAdministrator):
raise PermissionError("bot must be an administrator to work properly")
if not chat_member_info.can_restrict_members or not chat_member_info.can_delete_messages:
raise PermissionError("bot needs 'ban users' and 'delete messages' permissions to work properly")

from bot.config_reader import config
async def check_bot_rights_reports_group(
bot: Bot,
reports_group_id: int,
):
chat_member_info = await bot.get_chat_member(
chat_id=reports_group_id, user_id=bot.id
)
if isinstance(chat_member_info, (ChatMemberLeft, ChatMemberBanned)):
raise PermissionError("bot is banned from the group")
if isinstance(chat_member_info, ChatMemberRestricted) and not chat_member_info.can_send_messages:
raise PermissionError("bot is not allowed to send messages")


async def fetch_admins(bot: Bot) -> Dict:
async def fetch_admins(
bot: Bot,
main_group_id: int,
) -> dict:
result = {}
admins: List[Union[ChatMemberOwner, ChatMemberAdministrator]]
admins = await bot.get_chat_administrators(config.group_main)
admins: list[ChatMemberOwner | ChatMemberAdministrator] = await bot.get_chat_administrators(main_group_id)
for admin in admins:
if admin.user.is_bot:
continue
if isinstance(admin, ChatMemberOwner):
result[admin.user.id] = {"can_restrict_members": True}
else:
result[admin.user.id] = {"can_restrict_members": admin.can_restrict_members}
return result


async def check_rights_and_permissions(bot: Bot, chat_id: int):
chat_member_info = await bot.get_chat_member(chat_id=chat_id, user_id=bot.id)
if not isinstance(chat_member_info, ChatMemberAdministrator):
raise PermissionError("Bot is not an administrator")
if not chat_member_info.can_restrict_members or not chat_member_info.can_delete_messages:
raise PermissionError("Bot needs 'restrict participants' and 'delete messages' permissions to work properly")

async def set_bot_commands(bot: Bot, main_group_id: int):
commands = [
BotCommand(command="report", description="Report message to group admins"),
]
await bot.set_my_commands(commands, scope=BotCommandScopeChat(chat_id=main_group_id))
19 changes: 11 additions & 8 deletions bot/callback_factories.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from aiogram.dispatcher.filters.callback_data import CallbackData
from enum import Enum

from aiogram.filters.callback_data import CallbackData

class DeleteMsgCallback(CallbackData, prefix="delmsg"):
# action to perform: "del" to simply delete a message or "ban" to ban chat/user as well
action: str
# the ID of chat or user to perform action on
entity_id: int
# string-formatted list of message IDs to delete
message_ids: str # Lists are not supported =(

class AdminAction(str, Enum):
DELETE = "del"
BAN = "ban"

class AdminActionCallbackV1(CallbackData, prefix="v1"):
action: AdminAction
user_or_chat_id: int
reported_message_id: int
Loading

0 comments on commit 5fb1b5d

Please sign in to comment.